From 54b6940ae134f01322352dbf0dd81896dade1946 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sat, 25 Jul 2026 02:32:09 -0400 Subject: [PATCH 01/82] Fork: Make replay activation state safe Serialize supported tagged catch state per activation and reject non-reconstructible reference and table state. Require the ABI 43 safety capability across build and host launch boundaries. --- Cargo.lock | 5 +- abi/snapshot.json | 1439 +++- .../test/fork-continuation.spec.ts | 54 + .../test/sjlj-noexcept-boundary.spec.ts | 8 +- crates/fork-instrument/Cargo.toml | 6 +- crates/fork-instrument/fuzz/Cargo.lock | 6 + .../fuzz/fuzz_targets/generator.rs | 58 +- crates/fork-instrument/src/call_graph.rs | 898 ++- .../fork-instrument/src/contract_inventory.rs | 291 + crates/fork-instrument/src/instrument.rs | 6019 ++++++++++------- crates/fork-instrument/src/legacy_dlopen.rs | 385 ++ crates/fork-instrument/src/legacy_eh.rs | 880 +++ crates/fork-instrument/src/lib.rs | 478 +- crates/fork-instrument/src/linked_frames.rs | 2 +- crates/fork-instrument/src/main.rs | 80 +- .../src/module_exception_codec.rs | 1594 +++++ crates/fork-instrument/src/module_gc_codec.rs | 3836 +++++++++++ crates/fork-instrument/src/module_state.rs | 3395 ++++++++++ .../fork-instrument/src/reference_analysis.rs | 1624 +++++ crates/fork-instrument/src/runtime.rs | 400 +- .../src/static_reference_catalog.rs | 284 + .../tests/abort_restart_node.rs | 175 + crates/fork-instrument/tests/call_graph.rs | 459 +- .../tests/catch_selector_lifetime_node.rs | 709 ++ .../tests/contract_inventory.rs | 227 + crates/fork-instrument/tests/coverage_wat.rs | 194 +- crates/fork-instrument/tests/determinism.rs | 5 +- crates/fork-instrument/tests/dispatch_tree.rs | 37 +- .../fixtures/trampoline/legacy_catch_fork.wat | 19 + crates/fork-instrument/tests/instrument.rs | 2298 +++++-- .../fork-instrument/tests/large_dispatcher.rs | 57 +- crates/fork-instrument/tests/legacy_dlopen.rs | 251 + .../tests/module_exception_codec.rs | 104 + .../tests/module_exception_codec_node.rs | 333 + .../fork-instrument/tests/module_gc_codec.rs | 482 ++ .../tests/module_gc_codec_node.rs | 741 ++ crates/fork-instrument/tests/module_state.rs | 1795 +++++ .../tests/reference_analysis.rs | 5 + crates/fork-instrument/tests/roundtrip.rs | 5 +- crates/fork-instrument/tests/runtime.rs | 292 +- .../tests/static_reference_catalog.rs | 223 + .../fork-instrument/tests/switch_dispatch.rs | 762 ++- crates/fork-instrument/tests/trampoline.rs | 262 +- crates/shared/src/host_abi.rs | 11 +- crates/shared/src/lib.rs | 1641 ++++- docs/abi-versioning.md | 71 +- docs/architecture.md | 38 +- docs/fork-instrumentation.md | 518 +- docs/posix-status.md | 4 +- host/src/browser-kernel-worker-entry.ts | 12 +- host/src/constants.ts | 90 +- host/src/dylink.ts | 51 +- host/src/generated/abi.ts | 315 +- host/src/node-kernel-worker-entry.ts | 13 +- host/src/worker-main.ts | 42 +- host/test/catch-ref-fresh-worker.test.ts | 58 + host/test/dylink.test.ts | 65 +- host/test/fixtures/catch-ref-fresh-worker.wat | 172 + host/test/fork-from-thread.test.ts | 5 + host/test/fork-instrument-coverage.test.ts | 76 +- host/test/generated-abi.test.ts | 20 + .../test/plain-catch-payload-lifetime.test.ts | 107 +- host/test/sjlj-noexcept-boundary.test.ts | 20 +- host/test/wasm-binary-parse.test.ts | 138 +- libc/glue/abi_constants.h | 2 +- programs/f_03_wasm_gc_anyref.c | 17 +- scripts/build-programs.sh | 108 +- scripts/test-wasm-artifact-guards.sh | 75 +- scripts/wasm-artifact-guards.sh | 107 +- tools/xtask/src/build_deps.rs | 183 +- tools/xtask/src/dump_abi.rs | 89 +- 71 files changed, 31154 insertions(+), 4071 deletions(-) create mode 100644 crates/fork-instrument/src/contract_inventory.rs create mode 100644 crates/fork-instrument/src/legacy_dlopen.rs create mode 100644 crates/fork-instrument/src/legacy_eh.rs create mode 100644 crates/fork-instrument/src/module_exception_codec.rs create mode 100644 crates/fork-instrument/src/module_gc_codec.rs create mode 100644 crates/fork-instrument/src/module_state.rs create mode 100644 crates/fork-instrument/src/reference_analysis.rs create mode 100644 crates/fork-instrument/src/static_reference_catalog.rs create mode 100644 crates/fork-instrument/tests/abort_restart_node.rs create mode 100644 crates/fork-instrument/tests/catch_selector_lifetime_node.rs create mode 100644 crates/fork-instrument/tests/contract_inventory.rs create mode 100644 crates/fork-instrument/tests/fixtures/trampoline/legacy_catch_fork.wat create mode 100644 crates/fork-instrument/tests/legacy_dlopen.rs create mode 100644 crates/fork-instrument/tests/module_exception_codec.rs create mode 100644 crates/fork-instrument/tests/module_exception_codec_node.rs create mode 100644 crates/fork-instrument/tests/module_gc_codec.rs create mode 100644 crates/fork-instrument/tests/module_gc_codec_node.rs create mode 100644 crates/fork-instrument/tests/module_state.rs create mode 100644 crates/fork-instrument/tests/reference_analysis.rs create mode 100644 crates/fork-instrument/tests/static_reference_catalog.rs create mode 100644 host/test/catch-ref-fresh-worker.test.ts create mode 100644 host/test/fixtures/catch-ref-fresh-worker.wat diff --git a/Cargo.lock b/Cargo.lock index dece0e0c20..d603c8835f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -404,6 +404,7 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "sha2", "walrus", "wasm-posix-shared", "wasmparser 0.247.0", @@ -1523,9 +1524,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "walrus" -version = "0.26.1" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e151599d689dac80e85c66a7cfa6ffd1b2ab79220517f9161040a87a5041aee3" +checksum = "3bfa49767bb3a9e1afb02aa95bbcbde8d82f2db4ca377afae94d688f14f62378" dependencies = [ "anyhow", "gimli", diff --git a/abi/snapshot.json b/abi/snapshot.json index 3f84edf3e8..1f14930e44 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -1,5 +1,5 @@ { - "abi_version": 42, + "abi_version": 43, "channel_buffers": { "data_offset": 72, "data_size": 65536, @@ -36,10 +36,22 @@ "offset": 64, "size": 4, "type": "i32" + }, + { + "name": "request_flags", + "offset": 68, + "size": 4, + "type": "u32" } ], "size": 72 }, + "channel_request_flags": [ + { + "bit": 1, + "name": "defer_signal_delivery" + } + ], "channel_signal_area": { "base": 65560, "slots": [ @@ -88,7 +100,15 @@ } ], "custom_sections": [ + "kandelo.wpk_fork.capabilities", + "kandelo.wpk_fork.exception_codec", + "kandelo.wpk_fork.gc_codec", + "kandelo.wpk_fork.imported_globals", + "kandelo.wpk_fork.imported_tables", "kandelo.wpk_fork.linked_frames", + "kandelo.wpk_fork.module_state", + "kandelo.wpk_fork.static_root_catalog", + "kandelo.wpk_fork.unwind_transport", "wasm-posix-abi" ], "export_deny": { @@ -114,7 +134,7 @@ }, "host_adapter": { "manifest": { - "abi_version": 42, + "abi_version": 43, "channel_data_offset": 72, "channel_data_size": 65536, "channel_header_size": 72, @@ -3217,6 +3237,270 @@ }, "program_artifact": { "fork_instrumentation": { + "capabilities": { + "flags": [ + { + "bit": 1, + "name": "side_entry" + }, + { + "bit": 2, + "name": "dylink_main" + }, + { + "bit": 4, + "name": "activation_state_safe" + } + ], + "known_mask": 7, + "required_flags": 4, + "section": "kandelo.wpk_fork.capabilities", + "version": 1 + }, + "exception_codec": { + "activation_import": { + "module": "env", + "mutable": false, + "name": "__wpk_fork_module_activation", + "type": "i32" + }, + "header_size": 8, + "section": "kandelo.wpk_fork.exception_codec", + "tag_record_size": 16, + "version": 1 + }, + "gc_codec": { + "field_record": { + "fields": [ + { + "name": "storage", + "offset": 0, + "size": 1 + }, + { + "name": "flags", + "offset": 1, + "size": 1 + }, + { + "name": "reserved", + "offset": 2, + "size": 2 + }, + { + "name": "scalar_offset_or_none", + "offset": 4, + "size": 4 + }, + { + "name": "reference_ordinal_or_none", + "offset": 8, + "size": 4 + } + ], + "size": 12 + }, + "header_size": 16, + "layout_record": { + "fields": [ + { + "name": "layout_id", + "offset": 0, + "size": 4 + }, + { + "name": "type_ordinal", + "offset": 4, + "size": 4 + }, + { + "name": "kind", + "offset": 8, + "size": 1 + }, + { + "name": "constructor", + "offset": 9, + "size": 1 + }, + { + "name": "flags", + "offset": 10, + "size": 2 + }, + { + "name": "snapshot_scalar_len_or_stride", + "offset": 12, + "size": 4 + }, + { + "name": "field_start", + "offset": 16, + "size": 4 + }, + { + "name": "field_count", + "offset": 20, + "size": 4 + }, + { + "name": "super_type_ordinal_or_none", + "offset": 24, + "size": 4 + }, + { + "name": "base_layout_id", + "offset": 28, + "size": 4 + }, + { + "name": "auxiliary", + "offset": 32, + "size": 4 + }, + { + "name": "provenance_scalar_len", + "offset": 36, + "size": 4 + }, + { + "name": "provenance_ref_count", + "offset": 40, + "size": 4 + } + ], + "size": 44 + }, + "magic_bytes": [ + 75, + 70, + 71, + 67 + ], + "section": "kandelo.wpk_fork.gc_codec", + "transit_table": { + "element": "anyref", + "maximum": null, + "minimum": 1, + "module": "env", + "name": "__wpk_fork_ref_gc_transit", + "table64": false + }, + "version": 1 + }, + "imported_globals": { + "header_size": 16, + "known_flags": 3, + "magic_bytes": [ + 75, + 70, + 73, + 71 + ], + "mutable_flag": 1, + "record_fields": [ + { + "name": "record_size", + "offset": 0, + "size": 4 + }, + { + "name": "owner", + "offset": 4, + "size": 4 + }, + { + "name": "value_type", + "offset": 8, + "size": 1 + }, + { + "name": "flags", + "offset": 9, + "size": 1 + }, + { + "name": "reserved", + "offset": 10, + "size": 2 + }, + { + "name": "module_name_length", + "offset": 12, + "size": 4 + }, + { + "name": "field_name_length", + "offset": 16, + "size": 4 + }, + { + "name": "import_ordinal", + "offset": 20, + "size": 4 + } + ], + "record_header_size": 24, + "section": "kandelo.wpk_fork.imported_globals", + "shared_flag": 2, + "version": 1 + }, + "imported_tables": { + "header_size": 16, + "known_flags": 1, + "magic_bytes": [ + 75, + 70, + 73, + 84 + ], + "record_fields": [ + { + "name": "record_size", + "offset": 0, + "size": 4 + }, + { + "name": "owner", + "offset": 4, + "size": 4 + }, + { + "name": "element_type", + "offset": 8, + "size": 1 + }, + { + "name": "flags", + "offset": 9, + "size": 1 + }, + { + "name": "reserved", + "offset": 10, + "size": 2 + }, + { + "name": "module_name_length", + "offset": 12, + "size": 4 + }, + { + "name": "field_name_length", + "offset": 16, + "size": 4 + }, + { + "name": "import_ordinal", + "offset": 20, + "size": 4 + } + ], + "record_header_size": 24, + "section": "kandelo.wpk_fork.imported_tables", + "table64_flag": 1, + "version": 1 + }, "linked_frame_descriptor": { "alignment": 8, "descriptor_size": 24, @@ -3252,7 +3536,574 @@ "section": "kandelo.wpk_fork.linked_frames", "version": 1 }, + "module_state": { + "arena": { + "chunk_flags": [ + { + "bit": 1, + "name": "root" + }, + { + "bit": 2, + "name": "sealed" + } + ], + "chunk_magic_bytes": [ + 75, + 70, + 77, + 67 + ], + "known_chunk_flags": 3, + "pointer_widths": [ + { + "bytes": 4, + "chunk_header_size": 40 + }, + { + "bytes": 8, + "chunk_header_size": 56 + } + ], + "record": { + "alignment": 8, + "header_size": 24, + "kinds": [ + { + "name": "module", + "number": 1 + }, + { + "name": "reference_recipe", + "number": 2 + }, + { + "name": "mutable_global", + "number": 3 + }, + { + "name": "table", + "number": 4 + }, + { + "name": "table_page", + "number": 5 + }, + { + "name": "element_segments", + "number": 6 + }, + { + "name": "data_segments", + "number": 7 + }, + { + "name": "replay_events", + "number": 8 + }, + { + "name": "imported_global_bindings", + "number": 9 + }, + { + "name": "activation_continuations", + "number": 10 + }, + { + "name": "imported_table_bindings", + "number": 11 + }, + { + "name": "reference_recipe_segment", + "number": 12 + }, + { + "name": "replay_event_segment", + "number": 13 + } + ], + "magic_bytes": [ + 75, + 70, + 77, + 82 + ], + "version": 1 + }, + "version": 1 + }, + "descriptor": { + "alignment": 8, + "descriptor_size": 24, + "flags": [ + { + "bit": 1, + "name": "root_prefix_pointer" + }, + { + "bit": 2, + "name": "explicit_owners" + }, + { + "bit": 4, + "name": "sparse_tables" + } + ], + "known_flags": 7, + "magic_bytes": [ + 75, + 70, + 77, + 68 + ], + "required_flags": 7, + "root_pointer_word_offset": 1, + "section": "kandelo.wpk_fork.module_state", + "version": 1 + }, + "record_payloads": { + "activation_continuations": { + "entry_fields": [ + { + "name": "activation_id", + "offset": 0, + "size": 4 + }, + { + "name": "flags", + "offset": 4, + "size": 4 + }, + { + "name": "root", + "offset": 8, + "size": 8 + } + ], + "entry_known_flags": 0, + "entry_size": 16, + "header_size": 24, + "known_flags": 0, + "magic_bytes": [ + 75, + 70, + 65, + 67 + ], + "owner": 3, + "version": 1 + }, + "data_segments": { + "header_size": 8 + }, + "element_segments": { + "header_size": 8 + }, + "imported_global_bindings": { + "binding_kinds": [ + { + "name": "raw_number", + "number": 1 + }, + { + "name": "raw_bigint", + "number": 2 + }, + { + "name": "raw_reference", + "number": 3 + }, + { + "name": "activation_global", + "number": 4 + }, + { + "name": "base_import", + "number": 5 + } + ], + "entry_fields": [ + { + "name": "consumer_activation", + "offset": 0, + "size": 4 + }, + { + "name": "consumer_owner", + "offset": 4, + "size": 4 + }, + { + "name": "source_activation", + "offset": 8, + "size": 4 + }, + { + "name": "source_owner", + "offset": 12, + "size": 4 + }, + { + "name": "reserved", + "offset": 16, + "size": 4 + }, + { + "name": "recipe_id", + "offset": 20, + "size": 4 + }, + { + "name": "raw_bits", + "offset": 24, + "size": 8 + }, + { + "name": "binding_kind", + "offset": 32, + "size": 1 + }, + { + "name": "import_flags", + "offset": 33, + "size": 1 + }, + { + "name": "value_type", + "offset": 34, + "size": 1 + }, + { + "name": "reserved", + "offset": 35, + "size": 5 + } + ], + "entry_size": 40, + "header_size": 24, + "known_flags": 0, + "magic_bytes": [ + 75, + 70, + 66, + 71 + ], + "owner": 2, + "version": 1 + }, + "imported_table_bindings": { + "binding_kinds": [ + { + "name": "activation_table", + "number": 1 + }, + { + "name": "base_import", + "number": 2 + } + ], + "entry_fields": [ + { + "name": "consumer_activation", + "offset": 0, + "size": 4 + }, + { + "name": "consumer_owner", + "offset": 4, + "size": 4 + }, + { + "name": "source_activation", + "offset": 8, + "size": 4 + }, + { + "name": "source_owner", + "offset": 12, + "size": 4 + }, + { + "name": "reserved", + "offset": 16, + "size": 4 + }, + { + "name": "binding_kind", + "offset": 20, + "size": 1 + }, + { + "name": "reserved", + "offset": 21, + "size": 3 + } + ], + "entry_size": 24, + "header_size": 24, + "known_flags": 0, + "magic_bytes": [ + 75, + 70, + 66, + 84 + ], + "owner": 4, + "version": 1 + }, + "module": { + "known_flags": 0, + "payload_size": 40, + "template_id_size": 32 + }, + "mutable_global": { + "header_size": 8, + "value_types": [ + { + "bytes": 4, + "name": "i32", + "number": 1 + }, + { + "bytes": 8, + "name": "i64", + "number": 2 + }, + { + "bytes": 4, + "name": "f32", + "number": 3 + }, + { + "bytes": 8, + "name": "f64", + "number": 4 + }, + { + "bytes": 16, + "name": "v128", + "number": 5 + }, + { + "bytes": 4, + "name": "funcref_recipe", + "number": 6 + }, + { + "bytes": 4, + "name": "externref_recipe", + "number": 7 + }, + { + "bytes": 4, + "name": "exnref_recipe", + "number": 8 + }, + { + "bytes": 4, + "name": "anyref_recipe", + "number": 9 + } + ] + }, + "reference_transaction": { + "known_flags": 1, + "magic": [ + 75, + 70, + 82, + 86 + ], + "manifest_size": 96, + "node_record_size": 48, + "owner": 1, + "sealed_flag": 1, + "sections": [ + { + "name": "nodes", + "number": 1 + }, + { + "name": "edges", + "number": 2 + }, + { + "name": "scalars", + "number": 3 + }, + { + "name": "vector_index", + "number": 4 + }, + { + "name": "vector_entries", + "number": 5 + } + ], + "segment_header_size": 40, + "segment_known_flags": 0, + "segment_magic": [ + 75, + 70, + 82, + 83 + ], + "vector_index_size": 16, + "version": 2 + }, + "replay_events": { + "entry_size": 8, + "header_size": 40, + "known_flags": 0, + "magic": [ + 75, + 70, + 82, + 69 + ], + "owner": 1, + "segment_capacity": 4080, + "segment_header_size": 24, + "segment_known_flags": 0, + "segment_version": 1, + "version": 2 + }, + "table": { + "baseline_fingerprint_size": 32, + "descriptor_payload_size": 56, + "flags": [ + { + "bit": 1, + "name": "sparse_overrides" + } + ], + "known_flags": 1, + "max_page_shift": 20, + "min_page_shift": 4, + "page_header_size": 16, + "page_shift": 10, + "run_header_size": 8 + } + } + }, "required_exports": [ + { + "kind": "func", + "name": "__wpk_fork_exception_materialize", + "params": [ + "i32" + ], + "results": [] + }, + { + "kind": "func", + "name": "__wpk_fork_ref_decode_exnref", + "params": [ + "i32" + ], + "results": [ + "exnref" + ] + }, + { + "kind": "func", + "name": "__wpk_fork_ref_encode_exnref", + "params": [ + "exnref" + ], + "results": [ + "i32" + ] + }, + { + "kind": "func", + "name": "__wpk_fork_ref_exn_abort", + "params": [], + "results": [] + }, + { + "kind": "func", + "name": "__wpk_fork_ref_exn_clear", + "params": [], + "results": [] + }, + { + "kind": "func", + "name": "__wpk_fork_ref_exn_encode_ingress", + "params": [ + "i32" + ], + "results": [ + "i32" + ] + }, + { + "kind": "func", + "name": "__wpk_fork_ref_exn_throw_recipe", + "params": [ + "i32" + ], + "results": [] + }, + { + "kind": "func", + "name": "__wpk_fork_ref_exn_throw_slot", + "params": [ + "i32" + ], + "results": [] + }, + { + "kind": "func", + "name": "__wpk_fork_ref_gc_allocate", + "params": [ + "i32" + ], + "results": [] + }, + { + "kind": "func", + "name": "__wpk_fork_ref_gc_encode_slot", + "params": [ + "i32" + ], + "results": [ + "i32" + ] + }, + { + "kind": "func", + "name": "__wpk_fork_ref_gc_fill", + "params": [ + "i32" + ], + "results": [] + }, + { + "kind": "func", + "name": "__wpk_fork_ref_gc_probe", + "params": [ + "i32" + ], + "results": [ + "i64" + ] + }, + { + "kind": "func", + "name": "__wpk_fork_ref_gc_publish_externref", + "params": [ + "i32", + "externref" + ], + "results": [] + }, + { + "kind": "func", + "name": "__wpk_fork_static_root_harvest", + "params": [], + "results": [] + }, { "kind": "func", "name": "wpk_fork_abort_begin", @@ -3267,6 +4118,58 @@ "params": [], "results": [] }, + { + "kind": "func", + "name": "wpk_fork_module_bootstrap", + "params": [], + "results": [] + }, + { + "kind": "func", + "name": "wpk_fork_module_state_finish_restore", + "params": [ + "i32" + ], + "results": [] + }, + { + "kind": "func", + "name": "wpk_fork_module_state_restore", + "params": [ + "i32" + ], + "results": [] + }, + { + "kind": "func", + "name": "wpk_fork_module_state_save", + "params": [ + "i32" + ], + "results": [] + }, + { + "kind": "func", + "name": "wpk_fork_module_table_state_restore", + "params": [ + "i32" + ], + "results": [] + }, + { + "kind": "func", + "name": "wpk_fork_module_table_state_save", + "params": [ + "i32" + ], + "results": [] + }, + { + "kind": "func", + "name": "wpk_fork_module_thread_bootstrap", + "params": [], + "results": [] + }, { "kind": "func", "name": "wpk_fork_rewind_begin", @@ -3325,6 +4228,17 @@ "ptr" ] }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_frame_peek", + "params": [ + "ptr" + ], + "results": [ + "ptr" + ] + }, { "kind": "func", "module": "env", @@ -3335,8 +4249,525 @@ "results": [ "ptr" ] - } - ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_module_state_record_commit", + "params": [ + "ptr" + ], + "results": [] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_module_state_record_find", + "params": [ + "i32", + "i32", + "i32", + "i32" + ], + "results": [ + "ptr" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_module_state_record_reserve", + "params": [ + "i32", + "i32", + "i32", + "ptr" + ], + "results": [ + "ptr" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_module_state_table_dirty_count", + "params": [ + "i32" + ], + "results": [ + "i32" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_module_state_table_dirty_mark", + "params": [ + "i32", + "i64", + "i64" + ], + "results": [] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_module_state_table_dirty_page", + "params": [ + "i32", + "i32" + ], + "results": [ + "i64" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_module_state_table_mutation_abort", + "params": [], + "results": [] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_module_state_table_mutation_begin", + "params": [], + "results": [ + "i64" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_module_state_table_mutation_commit", + "params": [ + "i32", + "i64", + "i64" + ], + "results": [] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_module_state_table_reconcile", + "params": [], + "results": [ + "i64" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_module_state_table_state_owned", + "params": [ + "i32" + ], + "results": [ + "i32" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_decode_funcref", + "params": [ + "i32" + ], + "results": [ + "funcref" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_encode_funcref", + "params": [ + "funcref" + ], + "results": [ + "i32" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_exn_broker_encode", + "params": [ + "i32" + ], + "results": [ + "i32" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_exn_broker_throw_recipe", + "params": [ + "i32" + ], + "results": [] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_exn_cache_index", + "params": [ + "i32" + ], + "results": [ + "i32" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_exn_claim", + "params": [ + "i32" + ], + "results": [ + "i32" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_exn_define", + "params": [ + "i32", + "i32", + "i32", + "i32", + "ptr", + "i32", + "ptr", + "i32" + ], + "results": [] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_exn_ingress_throw", + "params": [ + "i32" + ], + "results": [] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_exn_load", + "params": [ + "i32", + "i32", + "i32", + "i32", + "ptr", + "i32", + "ptr", + "i32" + ], + "results": [ + "i32" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_exn_lookup", + "params": [ + "i32" + ], + "results": [ + "i32" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_exn_route", + "params": [ + "i32", + "i32" + ], + "results": [ + "i32" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_gc_broker_encode", + "params": [ + "i32" + ], + "results": [ + "i32" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_gc_capture_layout", + "params": [ + "i32", + "i32", + "i32" + ], + "results": [ + "i32" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_gc_claim", + "params": [ + "i32" + ], + "results": [ + "i32" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_gc_define", + "params": [ + "i32", + "i32", + "i32", + "i32", + "i32", + "ptr", + "i32", + "i32" + ], + "results": [] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_gc_i31", + "params": [ + "i32" + ], + "results": [ + "i32" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_gc_load", + "params": [ + "i32", + "i32", + "i32", + "i32", + "i32", + "ptr", + "i32" + ], + "results": [ + "i32" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_gc_lookup", + "params": [ + "i32" + ], + "results": [ + "i32" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_gc_payload_len", + "params": [ + "i32", + "i32", + "i32" + ], + "results": [ + "i32" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_gc_provenance_begin", + "params": [ + "i32", + "i32", + "i32", + "i32", + "i64", + "i64", + "i32" + ], + "results": [ + "i32" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_gc_provenance_end", + "params": [ + "i32" + ], + "results": [] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_gc_provenance_ref", + "params": [ + "i32", + "i32", + "i32" + ], + "results": [] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_gc_route", + "params": [ + "i32", + "i32" + ], + "results": [ + "i32" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_scratch_release", + "params": [ + "ptr", + "ptr" + ], + "results": [] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_scratch_reserve", + "params": [ + "ptr" + ], + "results": [ + "ptr" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_vector_append", + "params": [ + "i32", + "i32" + ], + "results": [] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_vector_begin", + "params": [ + "i32" + ], + "results": [ + "i32" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_vector_finish", + "params": [ + "i32" + ], + "results": [ + "i32" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_ref_vector_get", + "params": [ + "i32", + "i32" + ], + "results": [ + "i32" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_resume_peek", + "params": [ + "i32" + ], + "results": [ + "i32" + ] + }, + { + "element": "anyref", + "kind": "table", + "maximum": null, + "minimum": 1, + "module": "env", + "name": "__wpk_fork_ref_gc_transit", + "table64": false + }, + { + "element": "funcref", + "kind": "table", + "maximum": null, + "minimum": 1, + "module": "env", + "name": "__wpk_fork_resume_table", + "table64": false + } + ], + "static_root_catalog": { + "export": "__wpk_fork_static_root_catalog", + "harvest_export": "__wpk_fork_static_root_harvest", + "header_size": 12, + "magic_bytes": [ + 75, + 70, + 83, + 82 + ], + "section": "kandelo.wpk_fork.static_root_catalog", + "version": 1 + }, + "unwind_transport": { + "import": { + "kind": "tag", + "module": "env", + "name": "__wpk_fork_unwind" + }, + "payload_arity": 0, + "section": "kandelo.wpk_fork.unwind_transport", + "version": 1 + } } }, "syscall_arg_descriptors": { diff --git a/apps/browser-demos/test/fork-continuation.spec.ts b/apps/browser-demos/test/fork-continuation.spec.ts index 480dc0246d..1c4f4facff 100644 --- a/apps/browser-demos/test/fork-continuation.spec.ts +++ b/apps/browser-demos/test/fork-continuation.spec.ts @@ -1,4 +1,6 @@ import { expect, test, type Page } from "@playwright/test"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { resolveBinary } from "../../../host/src/binary-resolver"; @@ -12,6 +14,14 @@ const memoryFsModulePath = resolve( __dirname, "../../../host/src/vfs/memory-fs.ts", ); +const catchRefFixtureSource = resolve( + __dirname, + "../../../host/test/fixtures/catch-ref-fresh-worker.wat", +); +const forkInstrumenterPath = resolve( + __dirname, + "../../../tools/bin/wasm-fork-instrument", +); interface BrowserFixtureResult { exitCode: number; @@ -163,3 +173,47 @@ test("Chromium preserves the parent across root and later continuation ENOMEM", expect(result.stderr).toBe(""); expect(result.diagnostics).toEqual([]); }); + +test("Chromium reconstructs CatchRef state in a fresh child worker", async ({ + page, + baseURL, + browserName, +}) => { + test.skip(browserName !== "chromium", "the aggregate browser gate uses Chromium"); + test.setTimeout(180_000); + expect(baseURL).toBeTruthy(); + + const workDir = mkdtempSync( + // Vite deliberately refuses to serve arbitrary host temporary paths. + // Keep this generated fixture under the checked-out test tree so the + // browser receives bytes from this exact worktree's allow-listed root. + resolve(__dirname, ".catch-ref-fresh-worker-"), + ); + try { + const rawPath = resolve(workDir, "catch-ref-fresh-worker.raw.wasm"); + const programPath = resolve(workDir, "catch-ref-fresh-worker.wasm"); + execFileSync("wat2wasm", [ + "--enable-exceptions", + "--enable-threads", + catchRefFixtureSource, + "-o", + rawPath, + ]); + execFileSync(forkInstrumenterPath, [rawPath, "-o", programPath]); + + // The parent waits for the child, whose exit 91 means CatchRef payload + // reconstruction failed after the browser worker instantiated a fresh + // module. The parent reports that wait failure as exit 92. + const result = await runBrowserFixture( + page, + baseURL!, + programPath, + "catch-ref-fresh-worker", + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.diagnostics).toEqual([]); + } finally { + rmSync(workDir, { recursive: true, force: true }); + } +}); diff --git a/apps/browser-demos/test/sjlj-noexcept-boundary.spec.ts b/apps/browser-demos/test/sjlj-noexcept-boundary.spec.ts index 580f572f52..72b04589ec 100644 --- a/apps/browser-demos/test/sjlj-noexcept-boundary.spec.ts +++ b/apps/browser-demos/test/sjlj-noexcept-boundary.spec.ts @@ -19,7 +19,6 @@ const fixturePaths = { repoRoot, "local-binaries/test-fixtures/wasm64/sjlj_noexcept_boundary.raw.wasm", ), - instrumented: resolveBinary("programs/sjlj_noexcept_boundary.wasm"), sigchld: resolveBinary("programs/sigchld_sjlj.wasm"), }; @@ -76,11 +75,7 @@ test("Chromium preserves the SjLj controls and positive SIGCHLD path", async ({ "sjlj_noexcept_boundary", "--noexcept", ]), - instrumented: await run(fixtureUrls.instrumented, [ - "sjlj_noexcept_boundary", - "--noexcept", - ]), - permissive: await run(fixtureUrls.instrumented, [ + permissive: await run(fixtureUrls.rawWasm32, [ "sjlj_noexcept_boundary", "--permissive", ]), @@ -99,7 +94,6 @@ test("Chromium preserves the SjLj controls and positive SIGCHLD path", async ({ for (const control of [ results.rawWasm32, - results.instrumented, results.rawWasm64, ]) { expect(control.exitCode).toBe(128 + 6); diff --git a/crates/fork-instrument/Cargo.toml b/crates/fork-instrument/Cargo.toml index 499050811b..0d122a7f46 100644 --- a/crates/fork-instrument/Cargo.toml +++ b/crates/fork-instrument/Cargo.toml @@ -19,10 +19,14 @@ wasm-posix-shared = { path = "../shared" } # walrus — typed wasm IR with validator. The primary workhorse for # parsing, manipulating, and emitting instrumented modules. -walrus = "0.26" +# 0.26.4 pre-registers implicit multi-value legacy-catch block types. Earlier +# 0.26 releases panic while parsing valid `[tag params] -> [try results]` +# handlers, before the legacy-to-modern normalization pass can run. +walrus = "0.26.4" # anyhow — ergonomic error chaining for a CLI / build tool. anyhow = "1" +sha2 = "0.10" # clap — argument parsing. `derive` feature for #[derive(Parser)]. clap = { version = "4", features = ["derive"] } diff --git a/crates/fork-instrument/fuzz/Cargo.lock b/crates/fork-instrument/fuzz/Cargo.lock index d876c1d7cc..4de46e7419 100644 --- a/crates/fork-instrument/fuzz/Cargo.lock +++ b/crates/fork-instrument/fuzz/Cargo.lock @@ -185,6 +185,8 @@ dependencies = [ "anyhow", "clap", "walrus", + "wasm-posix-shared", + "wasmparser 0.247.0", ] [[package]] @@ -493,6 +495,10 @@ dependencies = [ "wasmparser 0.247.0", ] +[[package]] +name = "wasm-posix-shared" +version = "0.1.0" + [[package]] name = "wasmparser" version = "0.245.1" diff --git a/crates/fork-instrument/fuzz/fuzz_targets/generator.rs b/crates/fork-instrument/fuzz/fuzz_targets/generator.rs index a054b633dd..e9cc80adce 100644 --- a/crates/fork-instrument/fuzz/fuzz_targets/generator.rs +++ b/crates/fork-instrument/fuzz/fuzz_targets/generator.rs @@ -4,27 +4,23 @@ //! Every generator output is a syntactically well-formed module that //! imports `kernel.kernel_fork` so the instrumenter has work to do. //! -//! Covers: single or nested try_tables, all four catch-clause shapes, +//! Covers: single or nested try_tables, tagged Catch/CatchRef clauses, //! and 0..=4 scalar locals of varying numeric type. Nested shape wraps //! an inner try_table in an outer try_table of the *same* clause //! variant to keep block result types trivially lined up. use arbitrary::{Arbitrary, Unstructured}; -/// Which catch-clause shape the generated try_table uses. Covers all -/// four `try_table` catch variants so the instrumenter's rewrite of -/// `call $fork` inside an exception-handled region is exercised across -/// ref-returning and non-ref clauses. +/// Which supported tagged catch shape the generated try_table uses. +/// +/// CatchAll and CatchAllRef have no deterministic tag reconstruction recipe +/// and have precise rejection tests outside this successful-output fuzzer. #[derive(Debug, Clone, Copy, arbitrary::Arbitrary)] enum ClauseVariant { /// (catch_ref $exn $handler) — handler receives exnref; try_table result is exnref. CatchRef, - /// (catch_all_ref $handler) — handler receives exnref; try_table result is exnref. - CatchAllRef, /// (catch $exn $handler) — handler receives nothing (tag has no params); try_table empty result. Catch, - /// (catch_all $handler) — handler receives nothing; try_table empty result. - CatchAll, } impl ClauseVariant { @@ -41,14 +37,7 @@ impl ClauseVariant { "ref.null exn", "drop", ), - ClauseVariant::CatchAllRef => ( - format!("(catch_all_ref {label})"), - "(result (ref null exn))", - "ref.null exn", - "drop", - ), ClauseVariant::Catch => (format!("(catch {tag} {label})"), "", "", ""), - ClauseVariant::CatchAll => (format!("(catch_all {label})"), "", "", ""), } } } @@ -73,26 +62,6 @@ impl ScalarLocalTy { } } -/// Reference type used for a generated local declaration. Exercises -/// Phase 4f aux-table spill handling (for funcref/externref) and Phase -/// 6's `captured_exnref_K` non-spill invariant (for exnref). -#[derive(Debug, Clone, Copy, arbitrary::Arbitrary)] -enum RefLocalTy { - FuncRef, - ExternRef, - ExnRef, -} - -impl RefLocalTy { - fn as_wat(&self) -> &'static str { - match self { - RefLocalTy::FuncRef => "(ref null func)", - RefLocalTy::ExternRef => "(ref null extern)", - RefLocalTy::ExnRef => "(ref null exn)", - } - } -} - /// One generated program. Keep fields private so future generator /// extensions don't require downstream changes. #[derive(Debug)] @@ -112,9 +81,6 @@ pub struct WatProgram { /// variant ensures the block result types line up trivially — /// mixed families are not generated here. wrap_in_outer: bool, - /// 0..=2 ref-typed locals. Exercises Phase 4f aux-table spill and - /// Phase 6's captured_exnref_K non-spill invariant. - ref_locals: Vec, /// When true, adds a second function `$inner_fork` called via /// call_indirect through a funcref table, instead of calling /// `$fork` directly. Exercises indirect-call closure (Phase 3a/3b). @@ -128,17 +94,11 @@ impl<'a> Arbitrary<'a> for WatProgram { for _ in 0..count { scalar_locals.push(ScalarLocalTy::arbitrary(u)?); } - let ref_count = (u8::arbitrary(u)? & 0b11).min(2); // 0..=2 - let mut ref_locals = Vec::with_capacity(ref_count as usize); - for _ in 0..ref_count { - ref_locals.push(RefLocalTy::arbitrary(u)?); - } Ok(Self { scalar_locals, has_memory_grow: bool::arbitrary(u)?, clause_variant: ClauseVariant::arbitrary(u)?, wrap_in_outer: bool::arbitrary(u)?, - ref_locals, has_indirect_call: bool::arbitrary(u)?, }) } @@ -155,12 +115,6 @@ impl WatProgram { .map(|ty| format!("(local {}) ", ty.as_wat())) .collect(); - let ref_locals_wat: String = self - .ref_locals - .iter() - .map(|ty| format!("(local {}) ", ty.as_wat())) - .collect(); - let mem_grow = if self.has_memory_grow { "i32.const 0 memory.grow drop" } else { @@ -212,7 +166,7 @@ impl WatProgram { (tag $exn) {extra_decls} (func $caller (export "caller") (result i32) - {locals_wat}{ref_locals_wat} + {locals_wat} {body} i32.const 0) (memory 1)) diff --git a/crates/fork-instrument/src/call_graph.rs b/crates/fork-instrument/src/call_graph.rs index cc58e4971e..53e3b7ff8f 100644 --- a/crates/fork-instrument/src/call_graph.rs +++ b/crates/fork-instrument/src/call_graph.rs @@ -4,15 +4,17 @@ //! `kernel.kernel_fork`), computes the set of functions in the module //! that can transitively reach the seed via calls. //! -//! Discovery follows direct calls and table-aware indirect calls. An -//! indirect call can only reach functions that may inhabit the same -//! table as that `call_indirect` instruction, with the same signature. +//! Discovery follows direct calls, table-aware indirect calls, and typed +//! function-reference calls to a fixed point. Tail calls are transparent +//! edges: execution can reach the seed through them, but their eliminated +//! caller activation is not reported as live at the suspension point. -use std::collections::{HashMap, HashSet, VecDeque}; +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; +use anyhow::{Result, bail}; use walrus::ir::{ - self, dfs_in_order, BinaryOp, Call, Instr, InstrLocId, InstrSeqId, ReturnCall, TableCopy, - TableFill, TableGrow, TableInit, TableSet, Visitor, + self, BinaryOp, Call, Instr, InstrLocId, InstrSeqId, TableCopy, TableFill, TableGrow, + TableInit, TableSet, Visitor, dfs_in_order, }; use walrus::{ ConstExpr, ElementId, ElementItems, ElementKind, FunctionId, ImportKind, LocalFunction, Module, @@ -23,19 +25,139 @@ use walrus::{ /// `"kernel.kernel_fork"`). Returns `None` if the module has no such /// import or if the import exists but isn't a function. pub fn find_import_func(module: &Module, qualified_name: &str) -> Option { - let (mod_name, field) = qualified_name.split_once('.')?; + find_import_funcs(module, qualified_name).into_iter().next() +} + +/// Look up every function import with the qualified name. +/// +/// WebAssembly permits more than one import declaration to use the same +/// module/name pair (including declarations with distinct function types). +/// Fork reachability must seed all of them: selecting only the first could +/// leave a live caller activation outside the continuation. +pub fn find_import_funcs(module: &Module, qualified_name: &str) -> Vec { + let Some((mod_name, field)) = qualified_name.split_once('.') else { + return Vec::new(); + }; + let mut functions = Vec::new(); for import in module.imports.iter() { if import.module == mod_name && import.name == field { if let ImportKind::Function(id) = import.kind { - return Some(id); + functions.push(id); } } } - None + functions +} + +/// Every function import in deterministic module order. +/// +/// A dynamically linked side module can call back into the main image or a +/// different side module through any unresolved function import. The callee +/// may eventually fork even when this module does not itself import +/// `env.fork`, so side-boundary analysis uses all of these functions as roots. +pub fn imported_functions(module: &Module) -> Vec { + module + .imports + .iter() + .filter_map(|import| match import.kind { + ImportKind::Function(id) => Some(id), + _ => None, + }) + .collect() +} + +fn is_dynamic_linker_function_import(module: &str, name: &str) -> bool { + module == "env" + && matches!( + name, + "__wasm_dlopen" + | "__wasm_dlopen_main" + | "__wasm_dlopen_prepare" + | "__wasm_dlopen_next" + | "__wasm_dlopen_commit" + | "__wasm_dlsym" + | "__wasm_dlclose" + | "__wasm_dlerror" + ) +} + +fn is_reentrant_dynamic_linker_function_import(module: &str, name: &str) -> bool { + module == "env" && name == "__wasm_dlopen" +} + +/// Legacy host dynamic-linker calls that can synchronously enter guest +/// side-module initialization code. +/// +/// ABI 43's prepare/next/commit imports never enter Wasm. Its libc-owned +/// `call_indirect` is analyzed as the real cross-module suspension boundary. +pub fn dynamic_linker_imported_functions(module: &Module) -> Vec { + module + .imports + .iter() + .filter_map(|import| { + if !is_reentrant_dynamic_linker_function_import( + &import.module, + &import.name, + ) { + return None; + } + match import.kind { + ImportKind::Function(id) => Some(id), + _ => None, + } + }) + .collect() +} + +/// The tail-call instruction that needs an ordinary resumable landing before +/// the fork transform can preserve that control-flow edge. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum TailCallKind { + Direct, + Indirect, + Ref, +} + +/// A suspension-capable tail-call site. +/// +/// Tail calls do not contribute an activation to [`ReachingAnalysis::activations`]. +/// They are reported separately for diagnostics and coverage. Replay preserves +/// them as tail calls and routes directly to the next committed activation, +/// rather than materializing a caller frame that did not exist at capture time. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct TailCallSite { + pub caller: FunctionId, + pub sequence: InstrSeqId, + pub instruction_index: usize, + pub kind: TailCallKind, } -/// Walks a single local function, collecting every `Call` target -/// and every indirect-call site. +/// Semantic fork reachability plus tail sites that require transform work. +#[derive(Debug)] +pub struct ReachingAnalysis { + /// Functions whose activations can still be live when `seed` executes. + /// The seed itself is retained for the existing reporting contract. + pub activations: HashSet, + /// Every function through which control can reach `seed`, including + /// transparent tail callers whose activations do not survive. + /// + /// Instrumentation uses this set to recognize ordinary call sites that can + /// suspend even when the lexical callee first traverses a tail-call chain. + pub control_reachable: HashSet, + /// Tail edges on a path to the seed. Their caller activation has already + /// been eliminated, so these sites are not implicitly activations. + pub tail_call_landings: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct ProgramPoint { + sequence: InstrSeqId, + instruction_index: usize, +} + +/// Walks a single local function, collecting ordinary direct calls and table +/// operations. Dispatch and tail sites need lexical provenance, so they are +/// collected by [`collect_dispatch_calls`] below. #[derive(Default)] struct CollectCalls { direct: HashSet, @@ -49,10 +171,6 @@ impl<'a> Visitor<'a> for CollectCalls { self.direct.insert(instr.func); } - fn visit_return_call(&mut self, instr: &ReturnCall) { - self.direct.insert(instr.func); - } - fn visit_table_init(&mut self, instr: &TableInit) { self.table_inits.push((instr.elem, instr.table)); } @@ -74,11 +192,15 @@ impl<'a> Visitor<'a> for CollectCalls { } } -/// Per-function analysis: what it directly calls and what -/// indirect calls/table operations it uses. +/// Per-function analysis: activation-preserving calls, transparent tail calls, +/// and table operations. struct FuncProfile { direct: HashSet, indirect: HashSet, + refs: HashSet, + tail_direct: Vec<(FunctionId, ProgramPoint)>, + tail_indirect: Vec<(IndirectCall, ProgramPoint)>, + tail_refs: Vec<(RefCall, ProgramPoint)>, table_inits: Vec<(ElementId, TableId)>, table_copies: Vec<(TableId, TableId)>, dynamic_table_writes: HashSet, @@ -89,12 +211,16 @@ fn profile_functions(module: &Module) -> HashMap { for (id, func) in module.funcs.iter_local() { let mut collector = CollectCalls::default(); dfs_in_order(&mut collector, func, func.entry_block()); - let indirect = collect_indirect_calls(func); + let dispatch = collect_dispatch_calls(func); profiles.insert( id, FuncProfile { direct: collector.direct, - indirect, + indirect: dispatch.indirect, + refs: dispatch.refs, + tail_direct: dispatch.tail_direct, + tail_indirect: dispatch.tail_indirect, + tail_refs: dispatch.tail_refs, table_inits: collector.table_inits, table_copies: collector.table_copies, dynamic_table_writes: collector.dynamic_table_writes, @@ -104,9 +230,11 @@ fn profile_functions(module: &Module) -> HashMap { profiles } -/// Build the reverse call graph: a map from callee to set of direct -/// callers. Only includes edges originating from local (non-imported) -/// functions, since imported functions have no body to scan. +/// Build the reverse activation graph for ordinary direct calls. +/// +/// Tail calls are intentionally absent: their caller frame no longer exists +/// while the callee executes. Use [`analyze_reaching_closure`] when transparent +/// tail traversal is also required. pub fn build_reverse_call_graph(module: &Module) -> HashMap> { let mut reverse: HashMap> = HashMap::new(); for (caller_id, profile) in profile_functions(module) { @@ -117,24 +245,47 @@ pub fn build_reverse_call_graph(module: &Module) -> HashMap HashSet { - let reverse = build_reverse_call_graph(module); - let mut result = HashSet::new(); + let profiles = profile_functions(module); + let mut reverse: HashMap> = HashMap::new(); + let mut reverse_tail: HashMap> = HashMap::new(); + for (caller, profile) in &profiles { + for &callee in &profile.direct { + reverse.entry(callee).or_default().insert(*caller); + } + for &(callee, _) in &profile.tail_direct { + reverse_tail.entry(callee).or_default().insert(*caller); + } + } + + let mut activations = HashSet::new(); + let mut reachable = HashSet::new(); let mut queue = VecDeque::new(); - result.insert(seed); + activations.insert(seed); + reachable.insert(seed); queue.push_back(seed); while let Some(f) = queue.pop_front() { if let Some(callers) = reverse.get(&f) { for &caller in callers { - if result.insert(caller) { + activations.insert(caller); + if reachable.insert(caller) { + queue.push_back(caller); + } + } + } + if let Some(callers) = reverse_tail.get(&f) { + for &caller in callers { + if reachable.insert(caller) { queue.push_back(caller); } } } } - result + activations } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -150,54 +301,97 @@ enum IndexProof { Unknown, } -fn collect_indirect_calls(func: &LocalFunction) -> HashSet { - let mut calls = HashSet::new(); - collect_indirect_calls_seq(func, func.entry_block(), &mut calls); +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct RefCall { + ty: TypeId, + target: RefTargetProof, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum RefTargetProof { + Func(FunctionId), + Null, + Unknown, +} + +#[derive(Default)] +struct DispatchCalls { + indirect: HashSet, + refs: HashSet, + tail_direct: Vec<(FunctionId, ProgramPoint)>, + tail_indirect: Vec<(IndirectCall, ProgramPoint)>, + tail_refs: Vec<(RefCall, ProgramPoint)>, +} + +fn collect_dispatch_calls(func: &LocalFunction) -> DispatchCalls { + let mut calls = DispatchCalls::default(); + collect_dispatch_calls_seq(func, func.entry_block(), &mut calls); calls } -fn collect_indirect_calls_seq( - func: &LocalFunction, - seq_id: InstrSeqId, - calls: &mut HashSet, -) { +fn collect_dispatch_calls_seq(func: &LocalFunction, seq_id: InstrSeqId, calls: &mut DispatchCalls) { let instrs = &func.block(seq_id).instrs; for (idx, (instr, _)) in instrs.iter().enumerate() { + let point = ProgramPoint { + sequence: seq_id, + instruction_index: idx, + }; match instr { Instr::CallIndirect(call) => { - calls.insert(IndirectCall { + calls.indirect.insert(IndirectCall { table: call.table, ty: call.ty, index: infer_call_indirect_index(&instrs[..idx]), }); } Instr::ReturnCallIndirect(call) => { - calls.insert(IndirectCall { - table: call.table, + calls.tail_indirect.push(( + IndirectCall { + table: call.table, + ty: call.ty, + index: infer_call_indirect_index(&instrs[..idx]), + }, + point, + )); + } + Instr::CallRef(call) => { + calls.refs.insert(RefCall { ty: call.ty, - index: infer_call_indirect_index(&instrs[..idx]), + target: infer_call_ref_target(&instrs[..idx]), }); } + Instr::ReturnCallRef(call) => { + calls.tail_refs.push(( + RefCall { + ty: call.ty, + target: infer_call_ref_target(&instrs[..idx]), + }, + point, + )); + } + Instr::ReturnCall(call) => { + calls.tail_direct.push((call.func, point)); + } Instr::Block(ir::Block { seq }) | Instr::Loop(ir::Loop { seq }) => { - collect_indirect_calls_seq(func, *seq, calls); + collect_dispatch_calls_seq(func, *seq, calls); } Instr::IfElse(ir::IfElse { consequent, alternative, }) => { - collect_indirect_calls_seq(func, *consequent, calls); - collect_indirect_calls_seq(func, *alternative, calls); + collect_dispatch_calls_seq(func, *consequent, calls); + collect_dispatch_calls_seq(func, *alternative, calls); } Instr::TryTable(ir::TryTable { seq, .. }) => { - collect_indirect_calls_seq(func, *seq, calls); + collect_dispatch_calls_seq(func, *seq, calls); } Instr::Try(ir::Try { seq, catches }) => { - collect_indirect_calls_seq(func, *seq, calls); + collect_dispatch_calls_seq(func, *seq, calls); for catch in catches { match catch { ir::LegacyCatch::Catch { handler, .. } | ir::LegacyCatch::CatchAll { handler } => { - collect_indirect_calls_seq(func, *handler, calls); + collect_dispatch_calls_seq(func, *handler, calls); } ir::LegacyCatch::Delegate { .. } => {} } @@ -208,6 +402,30 @@ fn collect_indirect_calls_seq( } } +fn infer_call_ref_target(prefix: &[(Instr, InstrLocId)]) -> RefTargetProof { + infer_ref_expr(prefix, prefix.len()) + .map(|(proof, _)| proof) + .unwrap_or(RefTargetProof::Unknown) +} + +fn infer_ref_expr(instrs: &[(Instr, InstrLocId)], end: usize) -> Option<(RefTargetProof, usize)> { + if end == 0 { + return None; + } + + let idx = end - 1; + match &instrs[idx].0 { + Instr::RefFunc(reference) => Some((RefTargetProof::Func(reference.func), idx)), + Instr::RefNull(_) => Some((RefTargetProof::Null, idx)), + // These instructions preserve the identity of the single reference + // operand. Recovering through them avoids whole-signature fallback for + // the common typed-ref lowering without pretending local/global values + // have lexical provenance. + Instr::RefAsNonNull(_) | Instr::RefCast(_) => infer_ref_expr(instrs, idx), + _ => Some((RefTargetProof::Unknown, idx)), + } +} + fn infer_call_indirect_index(prefix: &[(Instr, InstrLocId)]) -> IndexProof { infer_i32_expr(prefix, prefix.len()) .map(|(proof, _)| proof) @@ -553,11 +771,10 @@ fn function_type_id(module: &Module, id: FunctionId) -> TypeId { module.funcs.get(id).ty() } -/// Check whether two type ids refer to structurally identical -/// function types (same params, same results). For modern wasm with -/// type indices the ids usually match exactly when two functions -/// share a signature, but we compare structurally to be robust to -/// modules where the same signature has multiple type-section entries. +/// Check whether two type ids refer to structurally identical function types. +/// +/// Type ids usually match when two functions share a signature, but separate +/// type-section entries can encode the same non-recursive signature. fn types_match(module: &Module, a: TypeId, b: TypeId) -> bool { if a == b { return true; @@ -567,7 +784,66 @@ fn types_match(module: &Module, a: TypeId, b: TypeId) -> bool { ta.params() == tb.params() && ta.results() == tb.results() } -const MAX_INDIRECT_DEPTH: u8 = 2; +/// Whether `candidate` can satisfy a dispatch instruction expecting +/// `expected`. +/// +/// Typed function references allow a function type to be a declared subtype +/// of the call's expected type. Structural equality remains accepted for +/// duplicate non-recursive type-section entries, matching the historical +/// call-indirect behavior. +fn function_type_is_subtype(module: &Module, candidate: TypeId, expected: TypeId) -> bool { + let mut current = Some(candidate); + let mut seen = HashSet::new(); + while let Some(ty) = current { + if !seen.insert(ty) { + // Valid Wasm cannot contain a supertype cycle. Keep malformed + // internal modules finite rather than turning graph discovery into + // an unbounded walk. + return false; + } + if types_match(module, ty, expected) { + return true; + } + current = module.types.get(ty).supertype; + } + false +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct FunctionSignature { + params: Vec, + results: Vec, +} + +fn function_signature(module: &Module, ty: TypeId) -> FunctionSignature { + let ty = module.types.get(ty); + FunctionSignature { + params: ty.params().to_vec(), + results: ty.results().to_vec(), + } +} + +fn compatible_dispatch_types( + module: &Module, + candidate: TypeId, + dispatch_types_by_signature: &HashMap>, +) -> Vec { + let mut compatible = HashSet::new(); + let mut current = Some(candidate); + let mut seen = HashSet::new(); + while let Some(ty) = current { + if !seen.insert(ty) { + break; + } + if let Some(expected_types) = + dispatch_types_by_signature.get(&function_signature(module, ty)) + { + compatible.extend(expected_types.iter().copied()); + } + current = module.types.get(ty).supertype; + } + compatible.into_iter().collect() +} /// Whether this module can resolve and invoke functions installed by Kandelo's /// dynamic linker after static call-graph analysis has completed. @@ -577,146 +853,353 @@ const MAX_INDIRECT_DEPTH: u8 = 2; /// closure below and the artifact claim consumed by the host runtime. pub fn has_dynamic_linker_imports(module: &Module) -> bool { module.imports.iter().any(|import| { - import.module == "env" - && matches!(import.kind, ImportKind::Function(_)) - && matches!( - import.name.as_str(), - "__wasm_dlopen" | "__wasm_dlsym" | "__wasm_dlclose" | "__wasm_dlerror" - ) + matches!(import.kind, ImportKind::Function(_)) + && is_dynamic_linker_function_import(&import.module, &import.name) }) } -/// Compute the transitive closure of functions that reach `seed` via -/// direct calls, plus a bounded number of table/function-pointer dispatches. +/// Compute semantic fork reachability to a fixed point. /// -/// A function `F` reaches `seed` if any of these hold: +/// A function activation `F` can be live at `seed` if any of these hold: /// (1) `F == seed` -/// (2) `F` directly calls some function `G` that reaches `seed` -/// (3) `F` executes `call_indirect` of type `T`, and some -/// function `G` of type `T` reaches `seed` and may inhabit the -/// same table that `F` indexes +/// (2) `F` ordinarily calls a function whose execution reaches `seed` +/// (3) `F` executes `call_indirect` that can dispatch to such a function +/// (4) `F` executes `call_ref` whose proven or type-compatible target can +/// reach `seed` /// -/// Rule 3 is intentionally bounded. Functions discovered through indirect -/// edges still pull in their direct callers, but after `MAX_INDIRECT_DEPTH` -/// indirect hops they do not become new indirect roots. Depth 2 covers the -/// common C/POSIX callback cases plus QuickJS's C-function trampoline -/// (`JS_CallInternal -> js_call_c_function -> js_os_exec`) while avoiding -/// whole-runtime closure in dynamic interpreters where a generic dispatcher -/// can theoretically call thousands of same-table, same-signature callbacks. -pub fn reaching_closure(module: &Module, seed: FunctionId) -> HashSet { +/// Direct, indirect, and reference edges participate in one worklist until it +/// reaches a fixed point. `return_call*` edges make their callers +/// control-reachable but not activation-live; traversal continues through +/// those transparent nodes so an older ordinary caller is still discovered. +pub fn analyze_reaching_closure(module: &Module, seed: FunctionId) -> ReachingAnalysis { + analyze_reaching_closure_from_seeds(module, [seed], has_dynamic_linker_imports(module)) +} + +/// Compute semantic fork reachability from every supplied suspension boundary. +/// +/// `external_dynamic_dispatch` means an unresolved `call_indirect` or +/// `call_ref` may enter another module that can fork. This is true for +/// dlopen-capable main modules and for every dynamically linked side module. +/// It broadens instrumentation only; ordinary valid Wasm is never rejected. +pub fn analyze_reaching_closure_from_seeds( + module: &Module, + seeds: impl IntoIterator, + external_dynamic_dispatch: bool, +) -> ReachingAnalysis { let profiles = profile_functions(module); let table_targets = table_targets(module, &profiles); - // A dlsym result can be installed into the main module's table only after - // static analysis. Every call_indirect in a dlopen-capable main module is - // therefore a possible boundary above a fork-capable side-module frame. - // Keep this opt-in to the dynamic-linker imports so ordinary programs - // retain the precise table-target closure below. - let has_dynamic_linker_imports = has_dynamic_linker_imports(module); - - // Reverse direct-call graph: `callee -> set of callers`. + + // Reverse ordinary and transparent direct-call graphs. let mut reverse_direct: HashMap> = HashMap::new(); + let mut reverse_tail_direct: HashMap> = + HashMap::new(); for (caller, profile) in &profiles { for callee in &profile.direct { reverse_direct.entry(*callee).or_default().insert(*caller); } + for &(callee, point) in &profile.tail_direct { + reverse_tail_direct + .entry(callee) + .or_default() + .push((*caller, point)); + } } - // Reverse indirect-call graph: `(table, call_indirect type T) -> - // callers that index that table with type T`. We compare types - // structurally (§types_match); TypeId is still stored and compared - // at lookup time rather than forcing exact type-index equality. - let indirect_callers: Vec<(IndirectCall, FunctionId)> = profiles - .iter() - .flat_map(|(caller, profile)| { - profile - .indirect - .iter() - .map(move |indirect| (*indirect, *caller)) - }) - .collect(); + // Index dynamic dispatch sites by their expected type. Compatibility is + // computed once per reached candidate type rather than rescanning every + // site for every function. + let mut indirect_callers: HashMap> = HashMap::new(); + let mut tail_indirect_callers: HashMap> = + HashMap::new(); + let mut unknown_ref_callers: HashMap> = HashMap::new(); + let mut tail_unknown_ref_callers: HashMap> = + HashMap::new(); + let mut precise_ref_callers: HashMap> = HashMap::new(); + let mut tail_precise_ref_callers: HashMap> = + HashMap::new(); + let mut dispatch_types = HashSet::new(); - // First compute the direct-only closure. Every function in this set - // reaches the seed without crossing a function-pointer dispatch, so it - // is safe to use as an indirect root below. - let mut result = HashSet::new(); - let mut direct_queue = VecDeque::new(); - result.insert(seed); - direct_queue.push_back(seed); - while let Some(g) = direct_queue.pop_front() { - if let Some(callers) = reverse_direct.get(&g) { - for &caller in callers { - if result.insert(caller) { - direct_queue.push_back(caller); + for (&caller, profile) in &profiles { + for &indirect in &profile.indirect { + dispatch_types.insert(indirect.ty); + indirect_callers + .entry(indirect.ty) + .or_default() + .push((indirect, caller)); + } + for &(indirect, point) in &profile.tail_indirect { + dispatch_types.insert(indirect.ty); + tail_indirect_callers + .entry(indirect.ty) + .or_default() + .push((indirect, caller, point)); + } + for &reference in &profile.refs { + dispatch_types.insert(reference.ty); + match reference.target { + RefTargetProof::Func(target) => precise_ref_callers + .entry(target) + .or_default() + .push((reference.ty, caller)), + RefTargetProof::Null => {} + RefTargetProof::Unknown => { + unknown_ref_callers + .entry(reference.ty) + .or_default() + .insert(caller); } } } + for &(reference, point) in &profile.tail_refs { + dispatch_types.insert(reference.ty); + match reference.target { + RefTargetProof::Func(target) => tail_precise_ref_callers + .entry(target) + .or_default() + .push((reference.ty, caller, point)), + RefTargetProof::Null => {} + RefTargetProof::Unknown => tail_unknown_ref_callers + .entry(reference.ty) + .or_default() + .push((caller, point)), + } + } + } + let mut dispatch_types_by_signature: HashMap> = HashMap::new(); + for &ty in &dispatch_types { + dispatch_types_by_signature + .entry(function_signature(module, ty)) + .or_default() + .push(ty); } - let direct_roots = result.clone(); - let mut best_indirect_depth: HashMap = - direct_roots.iter().map(|&id| (id, 0)).collect(); - let mut worklist: VecDeque<(FunctionId, u8)> = direct_roots.iter().map(|&id| (id, 0)).collect(); + let mut activations = HashSet::new(); + let mut control_reachable = HashSet::new(); + let mut tail_call_landings = HashSet::new(); + let mut worklist = VecDeque::new(); + for seed in seeds { + activations.insert(seed); + if control_reachable.insert(seed) { + worklist.push_back(seed); + } + } - fn enqueue( - func: FunctionId, - indirect_depth: u8, - best_indirect_depth: &mut HashMap, - result: &mut HashSet, - worklist: &mut VecDeque<(FunctionId, u8)>, + fn discover( + caller: FunctionId, + activation_survives: bool, + activations: &mut HashSet, + control_reachable: &mut HashSet, + worklist: &mut VecDeque, ) { - let should_enqueue = match best_indirect_depth.get(&func) { - Some(&old_depth) => indirect_depth < old_depth, - None => true, - }; - if should_enqueue { - best_indirect_depth.insert(func, indirect_depth); - result.insert(func); - worklist.push_back((func, indirect_depth)); + if activation_survives { + activations.insert(caller); + } + if control_reachable.insert(caller) { + worklist.push_back(caller); } } - if has_dynamic_linker_imports { + if external_dynamic_dispatch { for (&caller, profile) in &profiles { if !profile.indirect.is_empty() { - enqueue( + discover( + caller, + true, + &mut activations, + &mut control_reachable, + &mut worklist, + ); + } + if profile + .refs + .iter() + .any(|reference| reference.target == RefTargetProof::Unknown) + { + // WHY: a funcref received from another module has no local + // FunctionId. Treat the call site itself as the boundary so + // the live caller is activation-owned before control crosses + // the instance boundary. + discover( caller, - 1, - &mut best_indirect_depth, - &mut result, + true, + &mut activations, + &mut control_reachable, + &mut worklist, + ); + } + for &(_, point) in &profile.tail_indirect { + // A side-module function installed after instrumentation is + // not present in any static target set. A tail dispatch can + // still reach its fork path, but the caller frame is gone. + tail_call_landings.insert(TailCallSite { + caller, + sequence: point.sequence, + instruction_index: point.instruction_index, + kind: TailCallKind::Indirect, + }); + discover( + caller, + false, + &mut activations, + &mut control_reachable, + &mut worklist, + ); + } + for &(reference, point) in &profile.tail_refs { + if reference.target != RefTargetProof::Unknown { + continue; + } + tail_call_landings.insert(TailCallSite { + caller, + sequence: point.sequence, + instruction_index: point.instruction_index, + kind: TailCallKind::Ref, + }); + discover( + caller, + false, + &mut activations, + &mut control_reachable, &mut worklist, ); } } } - while let Some((g, indirect_depth)) = worklist.pop_front() { - // (2) Direct-reverse: who calls g directly? + let mut compatible_type_cache: HashMap> = HashMap::new(); + while let Some(g) = worklist.pop_front() { + // Ordinary direct callers retain an activation. if let Some(callers) = reverse_direct.get(&g) { for &caller in callers { - enqueue( + discover( + caller, + true, + &mut activations, + &mut control_reachable, + &mut worklist, + ); + } + } + + // A true tail caller is a transparent control-flow node. Record the + // exact site for selective lowering, and continue walking through it + // without claiming that its eliminated frame survives. + if let Some(callers) = reverse_tail_direct.get(&g) { + for &(caller, point) in callers { + tail_call_landings.insert(TailCallSite { + caller, + sequence: point.sequence, + instruction_index: point.instruction_index, + kind: TailCallKind::Direct, + }); + discover( caller, - indirect_depth, - &mut best_indirect_depth, - &mut result, + false, + &mut activations, + &mut control_reachable, &mut worklist, ); } } - // (3) Indirect-reverse: every function that does - // `call_indirect` with g's signature against a table that can - // contain g might be reaching g. Add those callers. - if indirect_depth < MAX_INDIRECT_DEPTH { - let g_ty = function_type_id(module, g); - for &(indirect, caller) in &indirect_callers { - if table_targets.table_can_dispatch(indirect, g) - && types_match(module, indirect.ty, g_ty) - { - enqueue( + let g_ty = function_type_id(module, g); + + // A statically proven ref.func target does not need the all-compatible + // fallback. The type check remains explicit so hand-built walrus + // modules cannot manufacture an impossible edge. + if let Some(callers) = precise_ref_callers.get(&g) { + for &(expected, caller) in callers { + if function_type_is_subtype(module, g_ty, expected) { + discover( caller, - indirect_depth + 1, - &mut best_indirect_depth, - &mut result, + true, + &mut activations, + &mut control_reachable, + &mut worklist, + ); + } + } + } + if let Some(callers) = tail_precise_ref_callers.get(&g) { + for &(expected, caller, point) in callers { + if function_type_is_subtype(module, g_ty, expected) { + tail_call_landings.insert(TailCallSite { + caller, + sequence: point.sequence, + instruction_index: point.instruction_index, + kind: TailCallKind::Ref, + }); + discover( + caller, + false, + &mut activations, + &mut control_reachable, + &mut worklist, + ); + } + } + } + + let compatible_types = compatible_type_cache.entry(g_ty).or_insert_with(|| { + compatible_dispatch_types(module, g_ty, &dispatch_types_by_signature) + }); + for expected in compatible_types.iter().copied() { + if let Some(callers) = indirect_callers.get(&expected) { + for &(indirect, caller) in callers { + if table_targets.table_can_dispatch(indirect, g) { + discover( + caller, + true, + &mut activations, + &mut control_reachable, + &mut worklist, + ); + } + } + } + if let Some(callers) = tail_indirect_callers.get(&expected) { + for &(indirect, caller, point) in callers { + if table_targets.table_can_dispatch(indirect, g) { + tail_call_landings.insert(TailCallSite { + caller, + sequence: point.sequence, + instruction_index: point.instruction_index, + kind: TailCallKind::Indirect, + }); + discover( + caller, + false, + &mut activations, + &mut control_reachable, + &mut worklist, + ); + } + } + } + if let Some(callers) = unknown_ref_callers.get(&expected) { + for &caller in callers { + discover( + caller, + true, + &mut activations, + &mut control_reachable, + &mut worklist, + ); + } + } + if let Some(callers) = tail_unknown_ref_callers.get(&expected) { + for &(caller, point) in callers { + tail_call_landings.insert(TailCallSite { + caller, + sequence: point.sequence, + instruction_index: point.instruction_index, + kind: TailCallKind::Ref, + }); + discover( + caller, + false, + &mut activations, + &mut control_reachable, &mut worklist, ); } @@ -724,7 +1207,122 @@ pub fn reaching_closure(module: &Module, seed: FunctionId) -> HashSet = tail_call_landings.into_iter().collect(); + tail_call_landings.sort_by_key(|site| { + ( + site.caller.index(), + site.sequence.index(), + site.instruction_index, + site.kind, + ) + }); + ReachingAnalysis { + activations, + control_reachable, + tail_call_landings, + } +} + +/// Lower only fork-reaching tail calls to ordinary calls followed by `return`. +/// +/// This is the bridge between semantic analysis and frame instrumentation: +/// analysis first reports the caller as eliminated, this pass creates a real +/// resumable landing at the suspension-capable edge, and a second analysis +/// then includes that newly materialized activation. Tail calls outside +/// `sites` retain their original stack and performance semantics. +/// +/// `sites` must come from [`analyze_reaching_closure`] for this module before +/// any other mutation. Stale or mismatched instruction kinds fail explicitly. +pub fn lower_tail_call_landings(module: &mut Module, sites: &[TailCallSite]) -> Result<()> { + let mut grouped: BTreeMap>> = + BTreeMap::new(); + let mut unique = HashSet::new(); + for &site in sites { + if unique.insert(site) { + grouped + .entry(site.caller) + .or_default() + .entry(site.sequence) + .or_default() + .push(site); + } + } + + for (caller, sequences) in grouped { + let caller_name = func_display_name(module, caller); + let function = module.funcs.get_mut(caller); + let walrus::FunctionKind::Local(local) = &mut function.kind else { + bail!( + "tail-call landing for `{caller_name}` names an imported \ + function; reachability metadata is stale" + ); + }; + + for (sequence, mut sequence_sites) in sequences { + // Inserting after a site shifts later indexes only. Descending + // mutation preserves every original program point in this seq. + sequence_sites.sort_by_key(|site| std::cmp::Reverse(site.instruction_index)); + let instrs = &mut local.block_mut(sequence).instrs; + for site in sequence_sites { + let Some((instruction, location)) = instrs.get(site.instruction_index).cloned() + else { + bail!( + "tail-call landing for `{caller_name}` points past the \ + end of sequence {:?}; reachability metadata is stale", + sequence + ); + }; + + let (replacement, actual_kind) = match instruction { + Instr::ReturnCall(call) => ( + Instr::Call(ir::Call { func: call.func }), + TailCallKind::Direct, + ), + Instr::ReturnCallIndirect(call) => ( + Instr::CallIndirect(ir::CallIndirect { + ty: call.ty, + table: call.table, + }), + TailCallKind::Indirect, + ), + Instr::ReturnCallRef(call) => ( + Instr::CallRef(ir::CallRef { ty: call.ty }), + TailCallKind::Ref, + ), + other => { + bail!( + "tail-call landing for `{caller_name}` points at \ + non-tail instruction {other:?}; reachability \ + metadata is stale" + ); + } + }; + if actual_kind != site.kind { + bail!( + "tail-call landing for `{caller_name}` expected {:?} \ + but found {actual_kind:?}; reachability metadata is stale", + site.kind + ); + } + + instrs.splice( + site.instruction_index..=site.instruction_index, + [ + (replacement, location), + (Instr::Return(ir::Return {}), location), + ], + ); + } + } + } + Ok(()) +} + +/// Compute the set of activation-live functions that need fork frame +/// instrumentation. Use [`analyze_reaching_closure`] when the transform also +/// needs the exact suspension-capable tail sites. +pub fn reaching_closure(module: &Module, seed: FunctionId) -> HashSet { + analyze_reaching_closure(module, seed).activations } /// Human-readable name for a function, for logging and JSON output. diff --git a/crates/fork-instrument/src/contract_inventory.rs b/crates/fork-instrument/src/contract_inventory.rs new file mode 100644 index 0000000000..519a679cf7 --- /dev/null +++ b/crates/fork-instrument/src/contract_inventory.rs @@ -0,0 +1,291 @@ +//! Structural inventory for the fork-artifact publication guards. +//! +//! This deliberately inspects only the sections that define the artifact +//! contract. In particular, code bodies are not decoded: large package +//! executables should not need a text disassembly just to verify their imports, +//! exports, memories, and metadata. + +use anyhow::{Context, Result, bail}; +use std::fmt::{self, Write}; +use wasm_posix_shared::abi::{ + WPK_FORK_CAPABILITIES_SECTION, WPK_FORK_EXPORT_ABORT_BEGIN, WPK_FORK_EXPORT_ABORT_END, + WPK_FORK_EXPORT_REWIND_BEGIN, WPK_FORK_EXPORT_REWIND_END, WPK_FORK_EXPORT_STATE, + WPK_FORK_EXPORT_UNWIND_BEGIN, WPK_FORK_EXPORT_UNWIND_END, WPK_FORK_FRAME_IMPORT_COMMIT, + WPK_FORK_FRAME_IMPORT_MODULE, WPK_FORK_FRAME_IMPORT_NEXT, WPK_FORK_FRAME_IMPORT_RESERVE, + WPK_FORK_LINKED_FRAME_FORMAT_SECTION, +}; +use wasmparser::{ + CompositeInnerType, Encoding, ExternalKind, FuncType, Parser, Payload, TypeRef, ValType, +}; + +/// The exact tab-separated inventory consumed by `wasm-artifact-guards.sh`. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct ForkContractInventory { + pub relocatable: usize, + pub imports_kernel_fork: usize, + pub frame_reserve: usize, + pub frame_commit: usize, + pub frame_next: usize, + pub linked_descriptor: usize, + pub fork_capability: usize, + pub abort_begin: usize, + pub abort_end: usize, + pub rewind_begin: usize, + pub rewind_end: usize, + pub state: usize, + pub unwind_begin: usize, + pub unwind_end: usize, + pub memory_count: usize, + pub memory64_count: usize, + pub signature_mismatch: usize, + pub legacy_dlopen: usize, + pub native_start: usize, +} + +impl fmt::Display for ForkContractInventory { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", + self.relocatable, + self.imports_kernel_fork, + self.frame_reserve, + self.frame_commit, + self.frame_next, + self.linked_descriptor, + self.fork_capability, + self.abort_begin, + self.abort_end, + self.rewind_begin, + self.rewind_end, + self.state, + self.unwind_begin, + self.unwind_end, + self.memory_count, + self.memory64_count, + self.signature_mismatch, + self.legacy_dlopen, + self.native_start, + ) + } +} + +#[derive(Debug, Clone, Copy)] +enum ExpectedSignature { + PointerToPointer, + PointerToNil, + NilToNil, + NilToI32, +} + +/// Inspect the binary structure used to decide whether a module carries one +/// complete fork-instrumentation contract. +pub fn fork_contract_inventory(bytes: &[u8]) -> Result { + let mut inventory = ForkContractInventory::default(); + let mut types: Vec> = Vec::new(); + let mut function_type_indices: Vec = Vec::new(); + let mut checked_functions: Vec<(u32, ExpectedSignature)> = Vec::new(); + + for payload in Parser::new(0).parse_all(bytes) { + match payload.context("parsing wasm structure for fork-contract inventory")? { + Payload::Version { encoding, .. } => { + if encoding != Encoding::Module { + bail!("fork-contract inventory requires a core wasm module"); + } + } + Payload::TypeSection(groups) => { + for group in groups { + let group = group.context("parsing wasm type section")?; + types.extend(group.into_types().map( + |subtype| match subtype.composite_type.inner { + CompositeInnerType::Func(function) => Some(function), + CompositeInnerType::Array(_) + | CompositeInnerType::Struct(_) + | CompositeInnerType::Cont(_) => None, + }, + )); + } + } + Payload::ImportSection(imports) => { + for import in imports.into_imports() { + let import = import.context("parsing wasm import section")?; + match import.ty { + TypeRef::Func(type_index) | TypeRef::FuncExact(type_index) => { + let function_index = function_type_indices.len() as u32; + function_type_indices.push(type_index); + if import.module == "kernel" && import.name == "kernel_fork" { + inventory.imports_kernel_fork = 1; + } + if import.module == "env" && import.name == "__wasm_dlopen" { + inventory.legacy_dlopen += 1; + } + if import.module == WPK_FORK_FRAME_IMPORT_MODULE { + let expected = match import.name { + WPK_FORK_FRAME_IMPORT_RESERVE => { + inventory.frame_reserve += 1; + Some(ExpectedSignature::PointerToPointer) + } + WPK_FORK_FRAME_IMPORT_COMMIT => { + inventory.frame_commit += 1; + Some(ExpectedSignature::PointerToNil) + } + WPK_FORK_FRAME_IMPORT_NEXT => { + inventory.frame_next += 1; + Some(ExpectedSignature::PointerToPointer) + } + _ => None, + }; + if let Some(expected) = expected { + checked_functions.push((function_index, expected)); + } + } + } + TypeRef::Memory(memory) => { + inventory.memory_count += 1; + inventory.memory64_count += usize::from(memory.memory64); + } + TypeRef::Table(_) | TypeRef::Global(_) | TypeRef::Tag(_) => {} + } + } + } + Payload::FunctionSection(functions) => { + for type_index in functions { + function_type_indices + .push(type_index.context("parsing wasm function section")?); + } + } + Payload::MemorySection(memories) => { + for memory in memories { + let memory = memory.context("parsing wasm memory section")?; + inventory.memory_count += 1; + inventory.memory64_count += usize::from(memory.memory64); + } + } + Payload::StartSection { .. } => { + inventory.native_start += 1; + } + Payload::ExportSection(exports) => { + for export in exports { + let export = export.context("parsing wasm export section")?; + if export.kind != ExternalKind::Func { + continue; + } + let expected = match export.name { + WPK_FORK_EXPORT_ABORT_BEGIN => { + inventory.abort_begin += 1; + Some(ExpectedSignature::PointerToNil) + } + WPK_FORK_EXPORT_ABORT_END => { + inventory.abort_end += 1; + Some(ExpectedSignature::NilToNil) + } + WPK_FORK_EXPORT_REWIND_BEGIN => { + inventory.rewind_begin += 1; + Some(ExpectedSignature::PointerToNil) + } + WPK_FORK_EXPORT_REWIND_END => { + inventory.rewind_end += 1; + Some(ExpectedSignature::NilToNil) + } + WPK_FORK_EXPORT_STATE => { + inventory.state += 1; + Some(ExpectedSignature::NilToI32) + } + WPK_FORK_EXPORT_UNWIND_BEGIN => { + inventory.unwind_begin += 1; + Some(ExpectedSignature::PointerToNil) + } + WPK_FORK_EXPORT_UNWIND_END => { + inventory.unwind_end += 1; + Some(ExpectedSignature::NilToNil) + } + _ => None, + }; + if let Some(expected) = expected { + checked_functions.push((export.index, expected)); + } + } + } + Payload::CustomSection(section) => match section.name() { + "linking" => inventory.relocatable = 1, + name if name.starts_with("reloc.") => inventory.relocatable = 1, + WPK_FORK_LINKED_FRAME_FORMAT_SECTION => inventory.linked_descriptor += 1, + WPK_FORK_CAPABILITIES_SECTION => inventory.fork_capability += 1, + _ => {} + }, + _ => {} + } + } + + let pointer = if inventory.memory_count == 1 && inventory.memory64_count == 1 { + ValType::I64 + } else { + ValType::I32 + }; + for (function_index, expected) in checked_functions { + let signature = function_type_indices + .get(function_index as usize) + .and_then(|type_index| types.get(*type_index as usize)) + .and_then(Option::as_ref); + if !signature.is_some_and(|signature| signature_matches(signature, expected, pointer)) { + inventory.signature_mismatch += 1; + } + } + + Ok(inventory) +} + +/// Return the raw custom-section payload used by `wasm-objdump -s -j`, +/// including the encoded section name before its data. +pub fn fork_capability_section_hex(bytes: &[u8]) -> Result { + unique_custom_section_hex(bytes, WPK_FORK_CAPABILITIES_SECTION) +} + +/// Return the raw custom-section payload used by `wasm-objdump -s -j`, +/// including the encoded section name before its data. +pub fn linked_frame_descriptor_section_hex(bytes: &[u8]) -> Result { + unique_custom_section_hex(bytes, WPK_FORK_LINKED_FRAME_FORMAT_SECTION) +} + +fn unique_custom_section_hex(bytes: &[u8], expected_name: &str) -> Result { + let mut found = None; + for payload in Parser::new(0).parse_all(bytes) { + match payload.context("parsing wasm structure for custom-section inventory")? { + Payload::Version { encoding, .. } => { + if encoding != Encoding::Module { + bail!("custom-section inventory requires a core wasm module"); + } + } + Payload::CustomSection(section) if section.name() == expected_name => { + if found.is_some() { + bail!("found duplicate `{expected_name}` custom sections"); + } + found = Some(section.range()); + } + _ => {} + } + } + + let range = found.with_context(|| format!("missing `{expected_name}` custom section"))?; + let section = bytes + .get(range) + .context("custom-section range falls outside the wasm binary")?; + let mut hex = String::with_capacity(section.len() * 2); + for byte in section { + write!(&mut hex, "{byte:02x}").expect("writing to a String cannot fail"); + } + Ok(hex) +} + +fn signature_matches(signature: &FuncType, expected: ExpectedSignature, pointer: ValType) -> bool { + let (params, results): (&[ValType], &[ValType]) = match expected { + ExpectedSignature::PointerToPointer => ( + std::slice::from_ref(&pointer), + std::slice::from_ref(&pointer), + ), + ExpectedSignature::PointerToNil => (std::slice::from_ref(&pointer), &[]), + ExpectedSignature::NilToNil => (&[], &[]), + ExpectedSignature::NilToI32 => (&[], std::slice::from_ref(&ValType::I32)), + }; + signature.params() == params && signature.results() == results +} diff --git a/crates/fork-instrument/src/instrument.rs b/crates/fork-instrument/src/instrument.rs index b78a7ca863..d294c9501d 100644 --- a/crates/fork-instrument/src/instrument.rs +++ b/crates/fork-instrument/src/instrument.rs @@ -22,8 +22,8 @@ //! ;; --- PREAMBLE (runs only when state == REWINDING) --- //! (if (i32.eq (global.get $_wpk_fork_state) (i32.const 2)) //! (then -//! ;; pop frame from save buffer, then restore catch_region_id, -//! ;; exnref_slot, scalar locals, and arg-spill locals +//! ;; pop frame from save buffer, then restore catch_selector, +//! ;; reserved catch metadata, scalar locals, and arg-spill locals //! )) //! //! ;; --- DISPATCH + WRAPPER + NESTED POST LABELS --- @@ -43,7 +43,7 @@ //! ) ;; end $POST_0 — also the br_table landing for call_idx==0 //! //! (call $callee_0) ;; or call_indirect -//! +//! ;; catch capture already selected the exact dynamic arm //! (global.get $_wpk_fork_state) (i32.const 1) (i32.eq) //! (if (then //! ;; frame.call_index = 0 @@ -55,7 +55,7 @@ //! ) ;; end $POST_{N-1} //! //! (call $callee_{N-1}) -//! +//! ;; catch capture already selected the exact dynamic arm //! (if state == UNWINDING: //! frame.call_index = N-1 //! br $unwind_save) @@ -65,28 +65,31 @@ //! //! ;; --- POSTAMBLE (runs only when branched-to via br $unwind_save) --- //! ;; push frame header fields except call_index, save scalar user locals, -//! ;; save arg-spill locals, spill ref-typed user locals to aux tables, -//! ;; advance current_pos, push defaults for the function's result types +//! ;; save arg-spill locals, advance current_pos, push defaults for the +//! ;; function's result types //! ) //! ``` //! -//! ## MVP scope +//! ## Supported replay surface //! -//! - **Top-level fork-path calls only.** A fork-path call nested -//! inside a `block`/`loop`/`if`/`try_table` causes `br_table` to be -//! unable to land at its site (wasm semantics forbid branching into -//! a block from outside). The tool panics with a diagnostic in -//! that case; the function must be restructured or the tool -//! extended. -//! - **Fork from modern `try_table` catches is supported.** Plain-catch -//! arm identity and scalar payloads are frame-backed per activation; -//! catch_ref values use the exnref auxiliary table. Legacy `try` -//! catch handlers remain unsupported. -//! - **Scalar args only for fork-path calls.** If a fork-path call -//! has a ref-typed argument, we'd need to spill it through an aux -//! table (not currently wired up). Panic in that case. +//! - **Top-level and nested fork-path calls.** Top-level calls use the +//! function switch-dispatch. Calls nested in structured control flow use a +//! per-block switch-dispatch so rewind never branches into a block from +//! outside it. +//! - **Fork from statically tagged modern `try_table` catches is +//! supported.** Catch and CatchRef arm identity and scalar tag +//! payloads are frame-backed per activation. Rewind throws the tag +//! again so the fresh module instance creates a fresh exnref. +//! Fork-reachable legacy `try` handlers are normalized to this same modern +//! activation-owned representation before instrumentation. +//! - **Abstract function and external references use activation-owned +//! recipes.** Live locals, parameters, call operands, and operand-stack +//! carryovers are encoded to deterministic recipe IDs in a call-specific +//! process vector and decoded against the fresh child instance. +//! Definitely-null references need no recipe. Statically tagged CatchRef +//! state is reconstructed by rethrowing its saved payload inside Wasm. //! -//! ## Frame layout (unchanged from the previous transform) +//! ## Frame layout //! //! All offsets are relative to the frame's base address. //! @@ -94,37 +97,45 @@ //! |---------------|------|-------------------| //! | 0 | 4 | `func_index` | //! | 4 | 4 | `call_index` | -//! | 8 | 4 | `catch_region_id` | -//! | 12 | 4 | `exnref_slot` | -//! | 16.. | var | scalar locals (user, arg spills, plain-catch state) | +//! | 8 | 4 | `catch_selector` | +//! | 12 | 4 | process reference-vector ordinal | +//! | 16.. | var | scalar locals (user, arg spills, tagged-catch state) | //! -//! Ref-typed user locals are routed through module-level auxiliary -//! tables; their storage is outside the frame. +//! There is deliberately no module-instance auxiliary reference storage: +//! workers reconstruct a child from linear memory in a fresh Wasm instance. //! //! ## What's preserved verbatim //! //! - `crates/fork-instrument/src/call_graph.rs` — fork-path closure //! discovery (direct + indirect). -//! - `crates/fork-instrument/src/runtime.rs` — state machine, five +//! - `crates/fork-instrument/src/runtime.rs` — state machine, seven //! exported control functions, save-buffer layout, saved-globals //! handling. -//! - Phase 4f aux-table injection for ref-typed user locals. -//! - Phase 6a–6d plumbing for `try_table` / catch-handler resume. +//! - Phase 6a–6d plumbing for `try_table` / tagged-catch resume. -use std::collections::{HashMap, HashSet}; +use anyhow::Result; +use std::collections::{BTreeMap, HashMap, HashSet}; use walrus::{ - AbstractHeapType, ExportItem, FunctionId, FunctionKind, HeapType, LocalFunction, LocalId, - MemoryId, Module, RefType, TableId, TagId, TypeId, ValType, + AbstractHeapType, ElementItems, ElementKind, ExportItem, FunctionBuilder, FunctionId, + FunctionKind, HeapType, LocalFunction, LocalId, MemoryId, Module, RawCustomSection, RefType, + TableId, TagId, TypeId, ValType, ir::{ AtomicWidth, BinaryOp, Binop, Block, Br, BrTable, Call, CallIndirect, Const, GlobalGet, IfElse, Instr, InstrLocId, InstrSeqId, InstrSeqType, LegacyCatch, LoadKind, LocalGet, - LocalSet, LocalTee, Loop, MemArg, RefAsNonNull, RefNull, Return, StoreKind, TableGet, - TableSet, Throw, ThrowRef, TryTable, TryTableCatch, UnaryOp, Value, + LocalSet, LocalTee, Loop, MemArg, RefAsNonNull, RefNull, Return, StoreKind, Throw, + TryTable, TryTableCatch, UnaryOp, Unreachable, Value, }, }; -use crate::runtime::{self, Runtime}; +use crate::{ + call_graph::{TailCallKind, TailCallSite}, + reference_analysis::{ + FunctionReferenceAnalysis, OriginalCallKind, ReferenceNullability, + analyze_function_references, + }, + runtime::{self, ReferenceCodecClass as RefClass, Runtime}, +}; const HOST_PARSED_MARKER_EXPORTS: &[&str] = &[ "__abi_version", @@ -132,6 +143,14 @@ const HOST_PARSED_MARKER_EXPORTS: &[&str] = &[ "__get_channel_base_addr", ]; +pub const RESUME_CATALOG_EXPORT: &str = "__wpk_fork_resume_catalog"; +pub const RESUME_CATALOG_SECTION: &str = "kandelo.wpk_fork.resume_catalog"; +pub const RESUME_START_EXPORT: &str = "wpk_fork_resume_start"; +pub const RESUME_THREAD_EXPORT: &str = "wpk_fork_resume_thread"; +const RESUME_CATALOG_MAGIC: [u8; 4] = *b"KFRC"; +const RESUME_CATALOG_VERSION: u16 = 1; +const RESUME_CATALOG_HEADER_SIZE: u16 = 12; + fn is_host_parsed_marker_function(module: &Module, id: FunctionId) -> bool { module.exports.iter().any(|export| { HOST_PARSED_MARKER_EXPORTS.contains(&export.name.as_str()) @@ -139,6 +158,119 @@ fn is_host_parsed_marker_function(module: &Module, id: FunctionId) -> bool { }) } +/// Verify that every fork-reachable reference shape has a typed owner. +/// +/// WHY this runs before any rewriting: the host creates fork children by +/// copying linear memory into a newly instantiated module. The complete Wasm +/// reference hierarchy is routed to a generated codec class here; mutable +/// globals, tables, and segment lifetime are owned by the KFMS guest helpers, +/// while activation references are owned by frame recipe IDs. Errors from +/// this pass indicate malformed/stale transformation metadata, not a policy +/// that excludes otherwise-valid reference-bearing programs. +pub fn validate_activation_state(module: &Module, fork_path: &HashSet) -> Result<()> { + validate_activation_state_with_targets(module, fork_path, fork_path) +} + +/// Validate surviving activations while selecting suspension-capable call +/// sites from the larger semantic control-reachability closure. +/// +/// A function traversed only by `return_call*` is intentionally absent from +/// `activations`: its frame no longer exists at the fork point. It remains in +/// `fork_path_targets` so an older live caller recognizes that an ordinary +/// call into the transparent tail chain is a replay landing. +pub fn validate_activation_state_with_targets( + module: &Module, + activations: &HashSet, + fork_path_targets: &HashSet, +) -> Result<()> { + if activations.is_empty() { + return Ok(()); + } + + let mut targets: Vec = activations.iter().copied().collect(); + targets.sort(); + for func_id in targets { + let function = module.funcs.get(func_id); + let FunctionKind::Local(_) = &function.kind else { + continue; + }; + if is_host_parsed_marker_function(module, func_id) { + continue; + } + let name = function.name.as_deref().unwrap_or(""); + + for (local_id, ty) in collect_user_locals(module, func_id) { + let ValType::Ref(reference) = ty else { + continue; + }; + validate_reference_shape( + module, + reference, + &format!("fork-reachable function `{name}` local/parameter {local_id:?}"), + )?; + } + + let signature = module.types.get(function.ty()); + for reference in signature + .params() + .iter() + .chain(signature.results()) + .filter_map(|ty| match ty { + ValType::Ref(reference) => Some(*reference), + _ => None, + }) + { + validate_reference_shape( + module, + reference, + &format!("fork-reachable function `{name}` signature"), + )?; + } + + let reference_analysis = analyze_function_references(module, func_id, fork_path_targets)?; + validate_reference_call_state(module, name, &reference_analysis)?; + } + + Ok(()) +} + +fn validate_reference_shape(module: &Module, reference: RefType, _owner: &str) -> Result<()> { + // Every WebAssembly reference hierarchy has a typed recipe provider. + // Concrete function/GC types are upcast for encoding and cast back after + // decoding in the fresh instance. + let _ = RefClass::of(module, reference); + Ok(()) +} + +fn validate_reference_call_state( + module: &Module, + function_name: &str, + analysis: &FunctionReferenceAnalysis, +) -> Result<()> { + for site in &analysis.call_sites { + for operand in site + .reference_arguments + .iter() + .chain(site.reference_carryovers.iter()) + { + validate_reference_shape( + module, + operand.ty, + &format!( + "fork-reachable function `{function_name}` call {:?} operand {}", + site.id, operand.index + ), + )?; + } + + if site.has_reference_callee { + // call_ref's concrete callee type is statically recovered with a + // Wasm ref.cast after decoding the abstract funcref recipe. + } + } + Ok(()) +} + /// Instrument every function in `fork_path` that we can instrument. /// /// Returns the set of function IDs that were actually rewritten. @@ -147,6 +279,53 @@ pub fn instrument_functions( runtime: &Runtime, fork_path: &HashSet, plain_catch_plan: &PlainCatchPlan, +) -> HashSet { + instrument_functions_with_targets_and_tail_sites( + module, + runtime, + fork_path, + fork_path, + &[], + plain_catch_plan, + ) +} + +/// Instrument only activation-live functions, selecting their replay +/// landings from the full semantic fork-reachability closure. +pub fn instrument_functions_with_targets( + module: &mut Module, + runtime: &Runtime, + activations: &HashSet, + fork_path_targets: &HashSet, + plain_catch_plan: &PlainCatchPlan, +) -> HashSet { + instrument_functions_with_targets_and_tail_sites( + module, + runtime, + activations, + fork_path_targets, + &[], + plain_catch_plan, + ) +} + +/// Instrument activation-live functions after making every fork boundary use +/// the private exception transport. +/// +/// Ordinary direct calls to rewritten local functions need no shim: their +/// generated postamble throws `__wpk_fork_unwind`. Imported fork entries and +/// dynamic dispatch can instead return normally after setting +/// `STATE_UNWINDING`, so those operations are moved into short generated +/// helpers which check the state before exposing any result to the source +/// activation. Fork-reaching tail sites tail-call the same helpers, retaining +/// bounded-stack semantics for transparent tail chains. +pub fn instrument_functions_with_targets_and_tail_sites( + module: &mut Module, + runtime: &Runtime, + activations: &HashSet, + fork_path_targets: &HashSet, + tail_call_sites: &[TailCallSite], + plain_catch_plan: &PlainCatchPlan, ) -> HashSet { let runtime_funcs: HashSet = [ runtime.unwind_begin, @@ -158,7 +337,7 @@ pub fn instrument_functions( .into_iter() .collect(); - let mut targets: Vec = fork_path + let mut targets: Vec = activations .iter() .copied() .filter(|id| !runtime_funcs.contains(id)) @@ -166,304 +345,816 @@ pub fn instrument_functions( .filter(|id| matches!(module.funcs.get(*id).kind, FunctionKind::Local(_))) .collect(); targets.sort(); + let materialized_activations: HashSet = + targets.iter().copied().collect(); - let (aux_tables, ref_plan, catch_plans) = plan_and_inject_aux_tables(module, &targets); + let catch_plans = plan_catch_regions(module, &targets); + let transport_helpers = inject_unwind_transport_helpers( + module, + runtime, + &targets, + fork_path_targets, + tail_call_sites, + ); + rewrite_activation_unwind_boundaries(module, &targets, tail_call_sites, &transport_helpers); + let unwind_frame_select = emit_unwind_frame_select_helper(module, runtime); + let mut transformed_call_targets = fork_path_targets.clone(); + transformed_call_targets.extend(transport_helpers.values().copied()); + + // Analyze every target before rewriting the first body. Stable original + // program points are the ownership boundary: synthetic dispatch locals + // must never make an otherwise-dead guest reference look live. + let reference_analyses: HashMap = targets + .iter() + .copied() + .map(|id| { + let analysis = analyze_function_references(module, id, &transformed_call_targets) + .unwrap_or_else(|error| panic!("fork reference analysis failed: {error:#}")); + (id, analysis) + }) + .collect(); let empty_plain_catches: Vec<(InstrSeqId, Vec)> = Vec::new(); let mut instrumented = HashSet::new(); + let mut resume_thunks = Vec::with_capacity(targets.len()); for (ordinal, id) in targets.iter().enumerate() { - let empty_plan: Vec = Vec::new(); - let this_plan = ref_plan.get(id).unwrap_or(&empty_plan); let empty_catch_plan: Vec = Vec::new(); let this_catch_plan = catch_plans.get(id).unwrap_or(&empty_catch_plan); let this_plain_catches = plain_catch_plan .per_function .get(id) .unwrap_or(&empty_plain_catches); - instrument_one_function( + let thunk = instrument_one_function( module, *id, runtime, - fork_path, + &materialized_activations, + &transformed_call_targets, ordinal as u32, - &aux_tables, - this_plan, this_catch_plan, this_plain_catches, + &reference_analyses[id], + unwind_frame_select, ); + resume_thunks.push(thunk); instrumented.insert(*id); } + emit_resume_catalog(module, &resume_thunks); + emit_fixed_resume_boundaries(module, runtime); instrumented } -// ---------------------------------------------------------------------- -// Frame layout constants -// ---------------------------------------------------------------------- - -const HEADER_SIZE: u32 = 16; -const FUNC_INDEX_OFFSET: u64 = 0; -const CALL_INDEX_OFFSET: u64 = 4; -const CATCH_REGION_OFFSET: u64 = 8; -const EXNREF_SLOT_OFFSET: u64 = 12; -const LOCALS_START_OFFSET: u32 = HEADER_SIZE; - -// ---------------------------------------------------------------------- -// Per-function pipeline -// ---------------------------------------------------------------------- - -/// Classification of a top-level fork-path call site. -#[derive(Debug, Clone, Copy)] -enum CallTarget { +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum UnwindTransportKey { Direct(FunctionId), - Indirect { table: TableId }, -} - -/// A top-level call site awaiting dispatch-structure emission. -struct CallSiteInfo { - target: CallTarget, - sig_ty: TypeId, - loc: InstrLocId, + Indirect { table: TableId, ty: TypeId }, + Ref { ty: TypeId }, } -#[derive(Debug, Clone, Copy)] -struct CatchStateLocals { - catch_region_id: LocalId, - exnref_slot: LocalId, -} - -#[derive(Debug, Clone, Copy)] -struct AbortDispatch { - live_frame: LocalId, - restart_loop: InstrSeqId, +impl UnwindTransportKey { + fn stable_sort_key(self) -> (u8, usize, usize) { + match self { + Self::Direct(function) => (0, function.index(), 0), + Self::Indirect { table, ty } => (1, table.index(), ty.index()), + Self::Ref { ty } => (2, ty.index(), 0), + } + } } -#[allow(clippy::too_many_arguments)] -fn instrument_one_function( - module: &mut Module, - func_id: FunctionId, - runtime: &Runtime, - fork_path: &HashSet, - func_ordinal: u32, - aux_tables: &AuxTables, - ref_plan: &[RefLocalSlot], - catch_plan: &[CatchRegionPlan], - plain_catches: &[(InstrSeqId, Vec)], -) { - // Choose scheme based on call-site topology. Post-commit-4 - // (2026-05-14) there are TWO live schemes (guard-dispatch was - // deleted; legacy catch-handler forks still panic defensively): - // - // instrument_one_function_switch — top-level fork-path calls - // only. Body is restructured so a top-level `br_table` jumps - // directly to the resumed call site, skipping all code in - // between. Per-call operand-stack carryovers (LLVM `*(sp+K) = - // call(...)` shapes) are absorbed via per-call spill locals - // (sub-commit 2.4c) — formerly forced guard-dispatch. - // - // instrument_one_function_nested_switch — fork-path calls - // nested inside Block/IfElse/Loop/TryTable bodies. Cascading - // POST_K blocks plus per-region br_tables route REWIND through - // each enclosing instruction's own dispatch. Sub-commits 2.5/2.6 - // added carryover spilling at nested direct-call landings, - // nested-Loop-with-carryover (side benefit), and multi-value- - // params SubRegion body-input-param prespill. - // - // Catch-handler bodies live inside a nested try_table; nested - // switch-dispatch handles them via the rewind-throw stub + - // capture block mechanism (see Phase 6 + B1 stages 1+2 docs). - // - // Both schemes: - // - share the same fork-resume contract (state machine, frame - // layout, aux-table ref-typed spills, throw_ref catch resume). - // - skip body chunks before the chosen POST_K on REWIND, so - // non-fork-path calls and side-effect ops in those chunks run - // exactly once on NORMAL — no per-op gating needed (the - // pre-2.5/2.6 Phase 4g machinery was deleted with guard- - // dispatch in commit 4). - if has_nested_fork_calls(module, func_id, fork_path) { - // Nested per-block switch-dispatch: if classify_nested_pattern - // accepts the function's nesting shape, use the cascading - // POST_K + per-region br_table transform. Sub-commits 2.5/2.6 - // expanded "supported" to cover Loops/TryTables/legacy Try - // bodies/multi-value-params/carryovers; only fork-from-legacy- - // catch remains a panic-defensive fallback. - let nested_status = classify_nested_pattern(module, func_id, fork_path); - if nested_status.is_supported() { - instrument_one_function_nested_switch( - module, - func_id, - runtime, - fork_path, - func_ordinal, - aux_tables, - ref_plan, - catch_plan, - plain_catches, - ); - return; - } - // Commit 3 (2026-05-14): the only remaining - // `NestedSupportStatus` rejection is fork-from-legacy-catch. - // Sub-commits 2.5c/2.6c closed `UnsupportedCarryover` and - // `UnsupportedMultiValueParams` respectively; legacy Try bodies - // now use the same nested-switch route as TryTable bodies. If - // we reach this branch on a shipping binary, the fork-path call - // is in a legacy catch handler, which still needs exception - // state reconstruction. - let func = func_name(module, func_id); - if has_fork_call_in_catch_handler(module, func_id, fork_path) { - panic!( - "fork-instrument: function `{func}` has a fork-path call inside a \ - try_table catch-handler body. This pattern is currently \ - unsupported end-to-end (B1 stages 1+2 shipped machinery but the \ - C1 fixture still hangs). See \ - memory/fork-instrument-b1-followup.md and the C1 fixture in \ - programs/cpp_eh_fork_from_catch_test.cpp." - ); - } - match nested_status { - NestedSupportStatus::UnsupportedLegacyTry => panic!( - "fork-instrument: function `{func}` triggered `UnsupportedLegacyTry` \ - — a fork-path call inside a legacy `catch` handler. Legacy `try` \ - bodies are supported by nested switch-dispatch, but legacy catch \ - handlers still need exception-state reconstruction before REWIND \ - can re-enter the handler path." - ), - NestedSupportStatus::UnsupportedCarryover => panic!( - "fork-instrument: function `{func}` has a nested fork-path call with \ - an operand-stack carryover shape the nested-switch analyser cannot \ - type. Extend `compute_nested_carryover_types` / \ - `analyze_subregion_spill_types` for the specific producer." - ), - NestedSupportStatus::UnsupportedMultiValueParams => panic!( - "fork-instrument: function `{func}` has unsupported multi-value \ - params in nested fork-path control flow." - ), - NestedSupportStatus::Supported => unreachable!(), +fn collect_unwind_transport_keys( + module: &Module, + targets: &[FunctionId], + fork_path_targets: &HashSet, + tail_call_sites: &[TailCallSite], +) -> Vec { + fn visit( + module: &Module, + local: &LocalFunction, + seq: InstrSeqId, + fork_path_targets: &HashSet, + keys: &mut HashSet, + ) { + for (instruction, _) in &local.block(seq).instrs { + match instruction { + Instr::Call(call) + if fork_path_targets.contains(&call.func) + && matches!(module.funcs.get(call.func).kind, FunctionKind::Import(_)) => + { + keys.insert(UnwindTransportKey::Direct(call.func)); + } + Instr::CallIndirect(call) => { + keys.insert(UnwindTransportKey::Indirect { + table: call.table, + ty: call.ty, + }); + } + Instr::CallRef(call) => { + keys.insert(UnwindTransportKey::Ref { ty: call.ty }); + } + _ => {} + } + for child in nested_seqs(instruction) { + visit(module, local, child, fork_path_targets, keys); + } } } - if has_top_level_stack_carryovers(module, func_id, fork_path) { - // Sub-commit 2.4c (2026-05-14): switch-dispatch absorbs - // top-level carryovers via in-place spill/reload at the call - // site. The compute_carryover_types Option refactor - // (sub-commit 9-followup) made the analyser succeed for any - // shape whose carryover values are statically typed — and - // unknown-type values consumed before any fork-path call are - // also tolerated. If the analyser still returns None here, a - // shipping binary has an unknown-type value AS a carryover at - // a fork-path call (genuinely rare LLVM output). Panic loudly - // for the same reason as the LegacyTry case above. - if compute_carryover_types(module, func_id, fork_path).is_some() { - instrument_one_function_switch( - module, - func_id, - runtime, - fork_path, - func_ordinal, - aux_tables, - ref_plan, - catch_plan, - plain_catches, - ); - return; - } - let func = func_name(module, func_id); - panic!( - "fork-instrument: function `{func}` has a top-level fork-path call \ - whose operand-stack carryover contains a value of a type the \ - analyser can't statically determine or cannot scalar-spill \ - (ref-typed producer, non-fork-path CallIndirect or CallRef, \ - or ref-typed structured-control result). The 2.6c push-before \ - emission can spill this carryover only if its type is known. \ - Extend `compute_carryover_types` to handle the specific producer, \ - or change the source to avoid the pattern." + let mut keys = HashSet::new(); + for &target in targets { + let FunctionKind::Local(local) = &module.funcs.get(target).kind else { + continue; + }; + visit( + module, + local, + local.entry_block(), + fork_path_targets, + &mut keys, ); } - instrument_one_function_switch( - module, - func_id, - runtime, - fork_path, - func_ordinal, - aux_tables, - ref_plan, - catch_plan, - plain_catches, - ); + for &site in tail_call_sites { + let FunctionKind::Local(local) = &module.funcs.get(site.caller).kind else { + panic!("fork-reaching tail site belongs to a non-local function"); + }; + let Some((instruction, _)) = local + .block(site.sequence) + .instrs + .get(site.instruction_index) + else { + panic!("fork-reaching tail site points past its instruction sequence"); + }; + let key = match (site.kind, instruction) { + (TailCallKind::Direct, Instr::ReturnCall(call)) + if matches!(module.funcs.get(call.func).kind, FunctionKind::Import(_)) => + { + Some(UnwindTransportKey::Direct(call.func)) + } + (TailCallKind::Direct, Instr::ReturnCall(_)) => None, + (TailCallKind::Indirect, Instr::ReturnCallIndirect(call)) => { + Some(UnwindTransportKey::Indirect { + table: call.table, + ty: call.ty, + }) + } + (TailCallKind::Ref, Instr::ReturnCallRef(call)) => { + Some(UnwindTransportKey::Ref { ty: call.ty }) + } + _ => panic!("fork-reaching tail-site metadata disagrees with the original instruction"), + }; + if let Some(key) = key { + keys.insert(key); + } + } + + let mut keys: Vec<_> = keys.into_iter().collect(); + keys.sort_by_key(|key| key.stable_sort_key()); + keys } -/// Switch-dispatch transform: fork-path calls are hoisted out of the -/// function body and reached during REWIND via a top-level `br_table` -/// that lands directly at the post-active-call-site label. Chunks -/// between calls run only on the NORMAL fall-through path. -#[allow(clippy::too_many_arguments)] -fn instrument_one_function_switch( +fn emit_unwind_transport_helper( module: &mut Module, - func_id: FunctionId, runtime: &Runtime, - fork_path: &HashSet, - func_ordinal: u32, - aux_tables: &AuxTables, - ref_plan: &[RefLocalSlot], - catch_plan: &[CatchRegionPlan], - plain_catches: &[(InstrSeqId, Vec)], -) { - // Pre-existing user locals (args + referenced in body). Scalars - // live in the frame; ref-typed locals go through aux tables. - let all_user_locals = collect_user_locals(module, func_id); - let user_scalar_locals: Vec<(LocalId, ValType)> = all_user_locals + key: UnwindTransportKey, +) -> FunctionId { + let (mut params, results, name) = match key { + UnwindTransportKey::Direct(function) => { + let signature = module.types.get(module.funcs.get(function).ty()); + ( + signature.params().to_vec(), + signature.results().to_vec(), + format!("__wpk_fork_unwind_transport_direct_{}", function.index()), + ) + } + UnwindTransportKey::Indirect { table, ty } => { + let signature = module.types.get(ty); + let mut params = signature.params().to_vec(); + params.push(if module.tables.get(table).table64 { + ValType::I64 + } else { + ValType::I32 + }); + ( + params, + signature.results().to_vec(), + format!( + "__wpk_fork_unwind_transport_indirect_{}_{}", + table.index(), + ty.index() + ), + ) + } + UnwindTransportKey::Ref { ty } => { + let signature = module.types.get(ty); + let mut params = signature.params().to_vec(); + params.push(ValType::Ref(RefType::FUNCREF)); + ( + params, + signature.results().to_vec(), + format!("__wpk_fork_unwind_transport_ref_{}", ty.index()), + ) + } + }; + let arguments: Vec<_> = params.drain(..).map(|ty| module.locals.add(ty)).collect(); + let helper_params: Vec<_> = arguments .iter() - .copied() - .filter(|(_, ty)| is_scalar(*ty)) + .map(|argument| module.locals.get(*argument).ty()) .collect(); + let mut builder = FunctionBuilder::new(&mut module.types, &helper_params, &results); + builder.name(name); + let helper = builder.finish(arguments.clone(), &mut module.funcs); - // Sub-commit 2.4c: compute carryover types BEFORE taking the - // original body, since `compute_carryover_types` reads the body - // through `module.funcs.get(func_id)`. Computing it after `take` - // would see an empty body and report no carryovers. - let carryover_types_pre_take = compute_carryover_types(module, func_id, fork_path); - - // Take the original entry body; we rebuild it wholesale. - let entry_id = local_mut(module, func_id).entry_block(); - let original_body: Vec<(Instr, InstrLocId)> = - std::mem::take(&mut local_mut(module, func_id).block_mut(entry_id).instrs); - - // Partition the body at top-level fork-path call sites. - let (mut chunks, call_sites) = partition_body(&original_body, fork_path, module); - let n_calls = call_sites.len(); - - // Allocate per-function synthetic locals. + let local = local_mut(module, helper); + let throws_unwind = local + .builder_mut() + .dangling_instr_seq(InstrSeqType::Simple(None)) + .id(); + let normal_return = local + .builder_mut() + .dangling_instr_seq(InstrSeqType::Simple(None)) + .id(); + { + let out = &mut local.block_mut(throws_unwind).instrs; + push_instr( + out, + Instr::Throw(Throw { + tag: runtime + .unwind_tag + .expect("unwind transport helper requires private tag"), + }), + ); + } + { + let out = &mut local.block_mut(local.entry_block()).instrs; + for &argument in &arguments { + push_instr(out, Instr::LocalGet(LocalGet { local: argument })); + } + match key { + UnwindTransportKey::Direct(function) => { + push_instr(out, Instr::Call(Call { func: function })); + } + UnwindTransportKey::Indirect { table, ty } => { + push_instr(out, Instr::CallIndirect(CallIndirect { ty, table })); + } + UnwindTransportKey::Ref { ty } => { + push_instr( + out, + Instr::RefCast(walrus::ir::RefCast { + nullable: false, + heap_type: HeapType::Concrete(ty), + }), + ); + push_instr(out, Instr::CallRef(walrus::ir::CallRef { ty })); + } + } + // WHY: results deliberately remain below this zero-result test only + // inside the short helper. The source activation receives them only + // after UNWINDING has been converted to the private tag, so engines + // never need result-spill scratch in every recursive source frame. + push_instr( + out, + Instr::GlobalGet(GlobalGet { + global: runtime.state_global, + }), + ); + push_instr( + out, + Instr::Const(Const { + value: Value::I32(runtime::STATE_UNWINDING), + }), + ); + push_instr( + out, + Instr::Binop(Binop { + op: BinaryOp::I32Eq, + }), + ); + push_instr( + out, + Instr::IfElse(IfElse { + consequent: throws_unwind, + alternative: normal_return, + }), + ); + } + helper +} + +fn inject_unwind_transport_helpers( + module: &mut Module, + runtime: &Runtime, + targets: &[FunctionId], + fork_path_targets: &HashSet, + tail_call_sites: &[TailCallSite], +) -> HashMap { + collect_unwind_transport_keys(module, targets, fork_path_targets, tail_call_sites) + .into_iter() + .map(|key| (key, emit_unwind_transport_helper(module, runtime, key))) + .collect() +} + +/// Emit the cold unwind-only frame-selection path once per module. +/// +/// Source activations pass only their constant frame size and static call +/// index. Keeping reserve, null-result handling, abort-scratch selection, and +/// the header write here avoids multiplying that sequence by every lexical +/// call site without adding a local to ordinary recursive activations. +fn emit_unwind_frame_select_helper(module: &mut Module, runtime: &Runtime) -> FunctionId { + let memory = first_memory(module); + let ptr_ty = runtime.buf_type; + let frame_size = module.locals.add(ptr_ty); + let call_index = module.locals.add(ValType::I32); + let mut builder = + FunctionBuilder::new(&mut module.types, &[ptr_ty, ValType::I32], &[ValType::I32]); + builder.name("__wpk_fork_select_unwind_frame".into()); + let helper = builder.finish(vec![frame_size, call_index], &mut module.funcs); + + let local = local_mut(module, helper); + let reserve_succeeded = local + .builder_mut() + .dangling_instr_seq(InstrSeqType::Simple(Some(ValType::I32))) + .id(); + let reserve_failed = local + .builder_mut() + .dangling_instr_seq(InstrSeqType::Simple(Some(ValType::I32))) + .id(); + + { + let out = &mut local.block_mut(reserve_failed).instrs; + // WHY: `frame_reserve == 0` synchronously moves the host runtime to + // abort replay. The descriptor's fixed prefix is therefore the only + // module-owned frame scratch that remains valid for selecting the + // failing live activation. + push_instr( + out, + Instr::GlobalGet(GlobalGet { + global: runtime.buf_global, + }), + ); + push_instr( + out, + Instr::GlobalGet(GlobalGet { + global: runtime.buf_global, + }), + ); + push_instr(out, ptr_const(ptr_ty, runtime.frames_start_offset as i64)); + push_instr( + out, + Instr::Binop(Binop { + op: ptr_add(ptr_ty), + }), + ); + push_instr(out, store_ptr(memory, ptr_ty, 0)); + push_current_frame_ptr(out, runtime, memory, ptr_ty); + push_instr(out, Instr::LocalGet(LocalGet { local: call_index })); + push_instr(out, store_i32(memory, CALL_INDEX_OFFSET)); + push_instr( + out, + Instr::Const(Const { + value: Value::I32(0), + }), + ); + } + { + let out = &mut local.block_mut(reserve_succeeded).instrs; + push_current_frame_ptr(out, runtime, memory, ptr_ty); + push_instr(out, Instr::LocalGet(LocalGet { local: call_index })); + push_instr(out, store_i32(memory, CALL_INDEX_OFFSET)); + push_instr( + out, + Instr::Const(Const { + value: Value::I32(1), + }), + ); + } + { + let out = &mut local.block_mut(local.entry_block()).instrs; + if let Some(frame_reserve) = runtime.frame_reserve { + push_instr( + out, + Instr::GlobalGet(GlobalGet { + global: runtime.buf_global, + }), + ); + push_instr(out, Instr::LocalGet(LocalGet { local: frame_size })); + push_instr( + out, + Instr::Call(Call { + func: frame_reserve, + }), + ); + push_instr(out, store_ptr(memory, ptr_ty, 0)); + + push_instr( + out, + Instr::GlobalGet(GlobalGet { + global: runtime.buf_global, + }), + ); + push_instr(out, load_ptr(memory, ptr_ty, 0)); + push_instr( + out, + Instr::Unop(walrus::ir::Unop { + op: match ptr_ty { + ValType::I32 => UnaryOp::I32Eqz, + ValType::I64 => UnaryOp::I64Eqz, + other => unreachable!("unsupported pointer type {other:?}"), + }, + }), + ); + push_instr( + out, + Instr::IfElse(IfElse { + consequent: reserve_failed, + alternative: reserve_succeeded, + }), + ); + } else { + // The legacy contiguous runtime already points at its active + // frame, so only the static call-index write is required. + push_instr( + out, + Instr::Block(Block { + seq: reserve_succeeded, + }), + ); + } + } + helper +} + +fn rewrite_activation_unwind_boundaries( + module: &mut Module, + targets: &[FunctionId], + tail_call_sites: &[TailCallSite], + helpers: &HashMap, +) { + fn rewrite_seq( + local: &mut LocalFunction, + seq: InstrSeqId, + helpers: &HashMap, + ) { + let original = std::mem::take(&mut local.block_mut(seq).instrs); + let mut rewritten = Vec::with_capacity(original.len()); + for (instruction, location) in original { + for child in nested_seqs(&instruction) { + rewrite_seq(local, child, helpers); + } + let instruction = match instruction { + Instr::Call(call) => helpers + .get(&UnwindTransportKey::Direct(call.func)) + .map_or(Instr::Call(call), |&helper| { + Instr::Call(Call { func: helper }) + }), + Instr::CallIndirect(call) => { + let helper = helpers[&UnwindTransportKey::Indirect { + table: call.table, + ty: call.ty, + }]; + Instr::Call(Call { func: helper }) + } + Instr::CallRef(call) => { + let helper = helpers[&UnwindTransportKey::Ref { ty: call.ty }]; + Instr::Call(Call { func: helper }) + } + other => other, + }; + rewritten.push((instruction, location)); + } + local.block_mut(seq).instrs = rewritten; + } + + for &target in targets { + let FunctionKind::Local(local) = &mut module.funcs.get_mut(target).kind else { + continue; + }; + rewrite_seq(local, local.entry_block(), helpers); + } + + for &site in tail_call_sites { + let FunctionKind::Local(local) = &mut module.funcs.get_mut(site.caller).kind else { + panic!("fork-reaching tail site belongs to a non-local function"); + }; + let Some((instruction, _)) = local + .block_mut(site.sequence) + .instrs + .get_mut(site.instruction_index) + else { + panic!("fork-reaching tail site points past its instruction sequence"); + }; + let helper = match (site.kind, &*instruction) { + (TailCallKind::Direct, Instr::ReturnCall(call)) => { + helpers.get(&UnwindTransportKey::Direct(call.func)).copied() + } + (TailCallKind::Indirect, Instr::ReturnCallIndirect(call)) => Some( + helpers[&UnwindTransportKey::Indirect { + table: call.table, + ty: call.ty, + }], + ), + (TailCallKind::Ref, Instr::ReturnCallRef(call)) => { + Some(helpers[&UnwindTransportKey::Ref { ty: call.ty }]) + } + _ => { + panic!("fork-reaching tail-site metadata disagrees with the rewritten instruction") + } + }; + if let Some(helper) = helper { + *instruction = Instr::ReturnCall(walrus::ir::ReturnCall { func: helper }); + } + } +} + +// ---------------------------------------------------------------------- +// Frame layout constants +// ---------------------------------------------------------------------- + +const HEADER_SIZE: u32 = 16; +const FUNC_INDEX_OFFSET: u64 = 0; +const CALL_INDEX_OFFSET: u64 = 4; +const CATCH_SELECTOR_OFFSET: u64 = 8; +const REFERENCE_VECTOR_OFFSET: u64 = 12; +const LOCALS_START_OFFSET: u32 = HEADER_SIZE; + +/// One reference value addressable from a call-specific recipe vector. +#[derive(Debug, Clone, Copy)] +struct ReferenceFrameSlot { + local: LocalId, + ty: RefType, + class: RefClass, + /// Stable vector position for values present at every landing. Resume + /// thunks need this for function parameters before the original preamble + /// has consumed the frame. + universal_position: Option, +} + +/// Per-call reference state derived from the original IR before rewriting. +/// +/// A slot is emitted only when at least one call landing needs it. Each call +/// names the exact slots to encode/decode; definitely-null live locals instead +/// receive a direct `ref.null` restore and consume no frame bytes or host +/// recipe entry. +#[derive(Debug, Clone)] +struct ReferenceFramePlan { + slots: Vec, + slots_by_call: Vec>, + null_locals_by_call: Vec>, +} + +#[derive(Debug, Clone, Copy)] +struct ResumeThunk { + func_ordinal: u32, + function: FunctionId, +} + +impl ReferenceFramePlan { + fn frame_end(&self, start: u32) -> u32 { + // Reference recipes live in the process transaction's compact vector + // log. The frame owns only its vector ordinal in reserved header word + // 12, independent of function-wide reference liveness. + start + } +} + +type TypedSpillLocal = (LocalId, ValType); + +// ---------------------------------------------------------------------- +// Per-function pipeline +// ---------------------------------------------------------------------- + +/// Classification of a top-level fork-path call site. +#[derive(Debug, Clone, Copy)] +enum CallTarget { + Direct(FunctionId), + Indirect { table: TableId }, + Ref, +} + +/// A top-level call site awaiting dispatch-structure emission. +struct CallSiteInfo { + target: CallTarget, + /// A direct lexical callee whose activation owns the next replay frame. + /// + /// Such calls can enter the original function without an intervening + /// resume thunk. The callee's frame-next import still validates the exact + /// process event before consuming it. + direct_activation: bool, + sig_ty: TypeId, + resume_ty: Option, + loc: InstrLocId, +} + +#[derive(Debug, Clone, Copy)] +struct CatchStateLocals { + /// Zero before any caught edge, otherwise the function-local ordinal of + /// the exact `(try_table region, catch arm)` pair most recently selected + /// by this activation's dynamic execution. + catch_selector: LocalId, +} + +#[derive(Debug, Clone, Copy)] +struct AbortDispatch { + /// Partial allocation failure branches back to the dispatch loop after + /// writing its static call index into the module-owned abort scratch. + /// + /// The replay preamble intentionally lives outside this loop: fresh + /// parent/child replay consumes a committed frame once, while the still- + /// live failing activation restarts directly at its selected call without + /// a per-activation selector/flag local. + restart_loop: InstrSeqId, + /// Cold module helper which reserves/selects the frame and writes the + /// statically supplied call index, returning one on reservation success. + frame_select: FunctionId, +} + +#[allow(clippy::too_many_arguments)] +fn instrument_one_function( + module: &mut Module, + func_id: FunctionId, + runtime: &Runtime, + activations: &HashSet, + fork_path: &HashSet, + func_ordinal: u32, + catch_plan: &[CatchRegionPlan], + plain_catches: &[(InstrSeqId, Vec)], + reference_analysis: &FunctionReferenceAnalysis, + unwind_frame_select: FunctionId, +) -> ResumeThunk { + // Choose scheme based on call-site topology. Post-commit-4 + // (2026-05-14) there are TWO live schemes (guard-dispatch was deleted): + // + // instrument_one_function_switch — top-level fork-path calls + // only. Body is restructured so a top-level `br_table` jumps + // directly to the resumed call site, skipping all code in + // between. Per-call operand-stack carryovers (LLVM `*(sp+K) = + // call(...)` shapes) are absorbed via per-call spill locals + // (sub-commit 2.4c) — formerly forced guard-dispatch. + // + // instrument_one_function_nested_switch — fork-path calls + // nested inside Block/IfElse/Loop/TryTable bodies. Cascading + // POST_K blocks plus per-region br_tables route REWIND through + // each enclosing instruction's own dispatch. Sub-commits 2.5/2.6 + // added carryover spilling at nested direct-call landings, + // nested-Loop-with-carryover (side benefit), and multi-value- + // params SubRegion body-input-param prespill. + // + // Catch-handler bodies live inside a nested try_table; nested + // switch-dispatch handles them via the rewind-throw stub + + // capture block mechanism (see Phase 6 + B1 stages 1+2 docs). + // + // Both schemes: + // - share the same fork-resume contract (state machine, linked + // activation frames, and deterministic tagged-catch rethrow). + // - skip body chunks before the chosen POST_K on REWIND, so + // non-fork-path calls and side-effect ops in those chunks run + // exactly once on NORMAL — no per-op gating needed (the + // pre-2.5/2.6 Phase 4g machinery was deleted with guard- + // dispatch in commit 4). + if has_nested_fork_calls(module, func_id, fork_path) { + // Nested per-block switch-dispatch uses the cascading POST_K + + // per-region br_table transform for every validated Wasm shape. + // Classification below is only an internal typed-stack consistency + // check; it is not an artifact support policy. + let nested_status = classify_nested_pattern(module, func_id, fork_path); + if nested_status.is_supported() { + return instrument_one_function_nested_switch( + module, + func_id, + runtime, + activations, + fork_path, + func_ordinal, + catch_plan, + plain_catches, + reference_analysis, + unwind_frame_select, + ); + } + // Every Walrus producer is typed by `typed_instruction_pushes`. + // Reaching this branch means those exhaustive stack effects disagree + // with validated IR, which is an instrumenter bug rather than a + // reference/control shape the artifact is forbidden to contain. + let func = func_name(module, func_id); + match nested_status { + NestedSupportStatus::AnalysisInvariantFailed => panic!( + "fork-instrument internal error: typed nested-stack analysis \ + disagrees with validated function `{func}`; every valid Wasm \ + reference, GC, EH, and multi-value producer must have an \ + activation-owned carryover type" + ), + NestedSupportStatus::Supported => unreachable!(), + } + } + + if has_top_level_stack_carryovers(module, func_id, fork_path) { + // Switch-dispatch absorbs every typed top-level carryover through + // in-place spill/reload. `None` can now mean only that the exhaustive + // Walrus stack model disagreed with validated IR. + if compute_carryover_types(module, func_id, fork_path).is_some() { + return instrument_one_function_switch( + module, + func_id, + runtime, + activations, + fork_path, + func_ordinal, + catch_plan, + plain_catches, + reference_analysis, + unwind_frame_select, + ); + } + let func = func_name(module, func_id); + panic!( + "fork-instrument internal error: typed top-level stack analysis \ + disagrees with validated function `{func}`; every valid Wasm \ + reference, GC, EH, and multi-value producer must have an \ + activation-owned carryover type" + ); + } + + instrument_one_function_switch( + module, + func_id, + runtime, + activations, + fork_path, + func_ordinal, + catch_plan, + plain_catches, + reference_analysis, + unwind_frame_select, + ) +} + +/// Switch-dispatch transform: fork-path calls are hoisted out of the +/// function body and reached during REWIND via a top-level `br_table` +/// that lands directly at the post-active-call-site label. Chunks +/// between calls run only on the NORMAL fall-through path. +#[allow(clippy::too_many_arguments)] +fn instrument_one_function_switch( + module: &mut Module, + func_id: FunctionId, + runtime: &Runtime, + activations: &HashSet, + fork_path: &HashSet, + func_ordinal: u32, + catch_plan: &[CatchRegionPlan], + plain_catches: &[(InstrSeqId, Vec)], + reference_analysis: &FunctionReferenceAnalysis, + unwind_frame_select: FunctionId, +) -> ResumeThunk { + // Pre-existing user locals (args + referenced in body). Validation + // guarantees that every one is scalar and therefore frame-owned. + let all_user_locals = collect_user_locals(module, func_id); + let user_scalar_locals: Vec<(LocalId, ValType)> = all_user_locals + .iter() + .copied() + .filter(|(_, ty)| is_scalar(*ty)) + .collect(); + + // Sub-commit 2.4c: compute carryover types BEFORE taking the + // original body, since `compute_carryover_types` reads the body + // through `module.funcs.get(func_id)`. Computing it after `take` + // would see an empty body and report no carryovers. + let carryover_types_pre_take = compute_carryover_types(module, func_id, fork_path); + + // Take the original entry body; we rebuild it wholesale. + let entry_id = local_mut(module, func_id).entry_block(); + let original_body: Vec<(Instr, InstrLocId)> = + std::mem::take(&mut local_mut(module, func_id).block_mut(entry_id).instrs); + + // Partition the body at top-level fork-path call sites. + let (mut chunks, mut call_sites) = partition_body(&original_body, fork_path, module); + for site in &mut call_sites { + site.direct_activation = matches!( + site.target, + CallTarget::Direct(target) if activations.contains(&target) + ); + let results = module.types.get(site.sig_ty).results().to_vec(); + site.resume_ty = Some(module.types.add(&[], &results)); + } + let n_calls = call_sites.len(); + assert_reference_call_alignment(reference_analysis, &call_sites); + + // Allocate per-function synthetic locals. let catch_state_locals = if catch_plan.is_empty() && plain_catches.is_empty() { None } else { Some(CatchStateLocals { - catch_region_id: module.locals.add(ValType::I32), - exnref_slot: module.locals.add(ValType::I32), + catch_selector: module.locals.add(ValType::I32), }) }; - let abort_live_frame = module.locals.add(ValType::I32); - // Per-call argument materialization. The default is the existing - // spill-local path; a conservative pure scalar suffix can instead - // be replayed after POST_K and needs no frame-backed arg locals. + // spill-local path; a conservative side-effect-free suffix can instead + // be replayed after POST_K and needs no frame-backed arg locals. Reference + // local.get operands are saved directly in the call's recipe vector. let pending_arg_materializations: Vec = call_sites .iter() .enumerate() .map(|(site_idx, cs)| { let arg_types = call_arg_types(module, cs); - for ty in &arg_types { - if !is_scalar(*ty) { - let name = func_name(module, func_id); - panic!( - "fork-instrument: function `{name}` has a fork-path call with a ref-typed \ - argument ({ty:?}). Ref-typed call arguments need aux-table spilling, \ - which the MVP switch-dispatch transform does not yet support.", - ); - } - } plan_call_arg_materialization(module, &chunks[site_idx], arg_types) }) .collect(); @@ -486,11 +1177,11 @@ fn instrument_one_function_switch( Some(v) if v.len() == n_calls => v, _ => vec![Vec::new(); n_calls], }; - let mut carryover_spills: Vec> = Vec::with_capacity(n_calls); + let mut carryover_spills: Vec> = Vec::with_capacity(n_calls); for site_carryovers in &carryover_types { - let spills: Vec = site_carryovers + let spills: Vec = site_carryovers .iter() - .map(|&ty| module.locals.add(ty)) + .map(|&ty| (module.locals.add(spill_storage_type(ty)), ty)) .collect(); carryover_spills.push(spills); } @@ -508,18 +1199,36 @@ fn instrument_one_function_switch( .iter() .zip(arg_types.iter()) { - frame_scalars.push((lid, ty)); + if is_scalar(ty) { + frame_scalars.push((lid, ty)); + } } } - for (site_idx, cr_types) in carryover_types.iter().enumerate() { - for (&lid, &ty) in carryover_spills[site_idx].iter().zip(cr_types.iter()) { - frame_scalars.push((lid, ty)); + for spills in &carryover_spills { + for &(lid, ty) in spills { + if is_scalar(ty) { + frame_scalars.push((lid, ty)); + } } } - append_plain_catch_frame_scalars(&mut frame_scalars, &plain_catch_state); - let locals_with_offsets = assign_local_offsets(&frame_scalars, LOCALS_START_OFFSET); - let frame_size = HEADER_SIZE + user_locals_size(&frame_scalars); + let ordinary_scalar_end = HEADER_SIZE + user_locals_size(&frame_scalars); + let catch_scalar_frame = plan_plain_catch_scalar_frame(&plain_catch_state, ordinary_scalar_end); + let scalar_end = catch_scalar_frame.frame_end(ordinary_scalar_end); + let mut per_call_references = vec![Vec::new(); n_calls]; + for call_idx in 0..call_sites.len() { + arg_materializations[call_idx] + .append_reference_inputs(module, &mut per_call_references[call_idx]); + for &(local, ty) in &carryover_spills[call_idx] { + if let Some(reference) = supported_reference(ty) { + per_call_references[call_idx].push((local, reference)); + } + } + } + append_resume_parameter_references(module, func_id, &mut per_call_references); + append_plain_catch_frame_references(&mut per_call_references, &plain_catch_state); + let reference_frame = plan_reference_frame(module, reference_analysis, per_call_references); + let frame_size = reference_frame.frame_end(scalar_end); let result_types: Vec = { let ty_id = module.funcs.get(func_id).ty(); @@ -527,29 +1236,22 @@ fn instrument_one_function_switch( }; let restart_loop_ty = InstrSeqType::new(&mut module.types, &[], &result_types); - // Plan catch-handler entry-capture (Phase 6d). We allocate in_catch - // and captured_exnref locals now; the IR rewrite is applied later, - // after the body has been rebuilt. - let catch_handlers = - plan_catch_ref_handlers(module, func_id, catch_plan, aux_tables, &plain_catch_state); + let catch_handlers = plan_catch_handlers(catch_plan, &plain_catch_state); // Build the new body: preamble-if + Block($unwind_save) + postamble. let memory = first_memory(module); let ptr_ty = runtime.buf_type; - // Phase 6c rewind-throw stubs: prepended to each fork-path - // try_table body. Phase 6 covers catch_ref / catch_all_ref. - // B1 Stage 2 (Task 2.3) extends the same stub with a plain-catch - // dispatch when `plain_catches` lists arms for the region. - if !catch_plan.is_empty() && aux_tables.exnref.is_some() { + // Rewind rethrows the frame-restored tag and scalar payload. CatchRef + // clauses then manufacture a fresh instance-local exnref. + if !plain_catch_state.is_empty() { let catch_state = - catch_state_locals.expect("exnref catch plan requires catch-state locals"); + catch_state_locals.expect("tagged catch plan requires catch-state locals"); inject_rewind_throw_stubs( module, func_id, runtime, - catch_state.catch_region_id, - aux_tables, + catch_state.catch_selector, catch_plan, &plain_catch_state, ); @@ -583,9 +1285,20 @@ fn instrument_one_function_switch( .id(); let restart_loop = local.builder_mut().dangling_instr_seq(restart_loop_ty).id(); let abort = AbortDispatch { - live_frame: abort_live_frame, restart_loop, + frame_select: unwind_frame_select, }; + let catch_scalar_restore_dispatch = catch_state_locals.and_then(|catch_state| { + build_plain_catch_scalar_dispatch( + local, + runtime, + memory, + ptr_ty, + catch_state.catch_selector, + &catch_scalar_frame, + PlainCatchScalarIo::Restore, + ) + }); let post_seqs: Vec = (0..n_calls) .map(|_| { local @@ -604,8 +1317,8 @@ fn instrument_one_function_switch( ptr_ty, catch_state_locals, &locals_with_offsets, - ref_plan, - aux_tables, + catch_scalar_restore_dispatch, + &reference_frame, frame_size, ); @@ -627,9 +1340,23 @@ fn instrument_one_function_switch( ); // Postamble lives outside $unwind_save, in the entry block, right - // after the Block($unwind_save) instruction. Built as a flat list - // of instructions. + // after the Block($unwind_save) instruction. It commits this + // activation and throws the private unwind tag; no function result + // is fabricated merely to walk the caller stack. let mut postamble: Vec<(Instr, InstrLocId)> = Vec::new(); + let catch_scalar_save_dispatch = catch_state_locals.and_then(|catch_state| { + build_plain_catch_scalar_dispatch( + local, + runtime, + memory, + ptr_ty, + catch_state.catch_selector, + &catch_scalar_frame, + PlainCatchScalarIo::Save, + ) + }); + let reference_save_dispatch = + build_reference_save_dispatch(local, runtime, memory, ptr_ty, &reference_frame); populate_postamble( &mut postamble, runtime, @@ -637,17 +1364,17 @@ fn instrument_one_function_switch( ptr_ty, catch_state_locals, &locals_with_offsets, - ref_plan, - aux_tables, + catch_scalar_save_dispatch, + reference_save_dispatch, frame_size, func_ordinal, - &result_types, ); - // Rebuild the function body around a result-typed restart loop. A partial - // allocation failure branches here from the still-live activation; fresh - // inner activations keep abort_live_frame=0 and restore committed nodes. - let entry_seq = &mut local.block_mut(restart_loop).instrs; + // The preamble is outside the result-typed live-restart loop. Fresh + // parent/child replay consumes its committed frame once; a synchronous + // reservation failure branches straight back to the selected call inside + // the loop without restoring over the still-live activation. + let entry_seq = &mut local.block_mut(entry_id).instrs; push_instr( entry_seq, Instr::GlobalGet(GlobalGet { @@ -666,24 +1393,6 @@ fn instrument_one_function_switch( op: BinaryOp::I32GeU, }), ); - push_instr( - entry_seq, - Instr::LocalGet(LocalGet { - local: abort_live_frame, - }), - ); - push_instr( - entry_seq, - Instr::Unop(walrus::ir::Unop { - op: UnaryOp::I32Eqz, - }), - ); - push_instr( - entry_seq, - Instr::Binop(Binop { - op: BinaryOp::I32And, - }), - ); push_instr( entry_seq, Instr::IfElse(IfElse { @@ -691,33 +1400,41 @@ fn instrument_one_function_switch( alternative: preamble_else, }), ); - push_instr(entry_seq, Instr::Block(Block { seq: unwind_save })); - entry_seq.extend(postamble); - let entry_seq = &mut local.block_mut(entry_id).instrs; - entry_seq.clear(); push_instr(entry_seq, Instr::Loop(Loop { seq: restart_loop })); + let restart_seq = &mut local.block_mut(restart_loop).instrs; + push_instr(restart_seq, Instr::Block(Block { seq: unwind_save })); + restart_seq.extend(postamble); - // Phase 6d application: replaces each fork-path try_table with - // an $outer/$capture wrap so caught exnrefs are stashed and the - // original handler is re-entered via `br`. Runs after body rebuild - // so it finds the try_tables at their new locations inside chunks. - apply_catch_ref_handlers(module, func_id, &catch_handlers, aux_tables); - - // Stage 2 (B1) plain-catch capture-block emission: per-arm - // captures intercept plain catch dispatch so the operand tuple - // can be saved at unwind time. Runs AFTER Phase 6 so it finds - // try_tables at their post-Phase-6 locations. + // Per-arm captures intercept both Catch and CatchRef dispatch after the + // body rebuild, save only transferable state, and forward the original + // handler operands. if let Some(catch_state) = catch_state_locals { + shield_private_unwind_from_user_catches(module, func_id, runtime); apply_plain_catch_handlers( module, func_id, - catch_state.catch_region_id, + catch_state.catch_selector, &plain_catch_state, - catch_plan, &catch_handlers, ); } else { debug_assert!(plain_catches.is_empty()); + shield_private_unwind_from_user_catches(module, func_id, runtime); + } + + ResumeThunk { + func_ordinal, + function: emit_resume_thunk( + module, + func_id, + runtime, + memory, + ptr_ty, + frame_size, + &locals_with_offsets, + &reference_frame, + func_ordinal, + ), } } @@ -758,7 +1475,7 @@ fn has_nested_fork_calls( return; } } - Instr::CallIndirect(_) => { + Instr::CallIndirect(_) | Instr::CallRef(_) => { if depth > 0 { *found = true; return; @@ -797,15 +1514,11 @@ fn has_nested_fork_calls( /// saving the per-instruction typed-stack walk on functions that /// don't need it). /// -/// The walk is conservative: if we encounter an instruction whose -/// stack effect we can't statically determine (wasm-GC ops, legacy -/// exception `try`, …), we report `true` so the caller invokes -/// `compute_carryover_types`. That analyser may itself return `None` -/// (forcing the post-commit-3 panic) if an unknown-type slot reaches -/// a carryover; otherwise switch-dispatch handles it. -/// Likewise for stack underflows — which shouldn't happen in valid -/// wasm, but we defensively route to the post-commit-3 panic path if -/// the input is malformed in a way we can't analyze. +/// `top_level_stack_effect` is exhaustive over Walrus instructions, +/// including Wasm GC and legacy/modern EH. A depth underflow therefore +/// indicates malformed IR or an instrumenter bug; the exact typed walk will +/// diagnose that invariant rather than treating a valid source shape as +/// unsupported. fn has_top_level_stack_carryovers( module: &Module, func_id: FunctionId, @@ -834,6 +1547,7 @@ fn has_top_level_stack_carryovers( // +1 for the table index on top of the signature's params. Some(module.types.get(ci.ty).params().len() + 1) } + Instr::CallRef(call) => Some(module.types.get(call.ty).params().len() + 1), _ => None, }; if let Some(expected) = expected_args { @@ -860,10 +1574,6 @@ fn has_top_level_stack_carryovers( // any fork-path call there is dead code. return false; } - StackEffect::Unknown => { - // Can't analyze — play safe. - return true; - } } } @@ -873,14 +1583,13 @@ fn has_top_level_stack_carryovers( enum StackEffect { Delta { pops: usize, pushes: usize }, Terminator, - Unknown, } /// Compute the stack effect of a single instruction assuming it is /// reachable (i.e., not sitting in a polymorphic post-terminator /// region). Only used by `has_top_level_stack_carryovers`. fn top_level_stack_effect(module: &Module, local: &LocalFunction, instr: &Instr) -> StackEffect { - use StackEffect::{Delta, Terminator, Unknown}; + use StackEffect::{Delta, Terminator}; let block_params_results = |seq_id: InstrSeqId| -> (usize, usize) { let seq = local.block(seq_id); @@ -966,12 +1675,14 @@ fn top_level_stack_effect(module: &Module, local: &LocalFunction, instr: &Instr) // br_if pops its condition; the target's expected args remain // on the stack on fall-through, so static delta is just pop 1. Instr::BrIf(_) => Delta { pops: 1, pushes: 0 }, - // br_on_null / br_on_non_null / br_on_cast / br_on_cast_fail: - // all pop 1 ref and push back on the non-branching path. - Instr::BrOnNull(_) - | Instr::BrOnNonNull(_) - | Instr::BrOnCast(_) - | Instr::BrOnCastFail(_) => Delta { pops: 1, pushes: 1 }, + // br_on_null refines and preserves the non-null fallthrough value. + // br_on_cast* likewise preserves either the source or target value on + // fallthrough. br_on_non_null consumes the known-null fallthrough + // value; its non-null value is carried only on the branch edge. + Instr::BrOnNull(_) | Instr::BrOnCast(_) | Instr::BrOnCastFail(_) => { + Delta { pops: 1, pushes: 1 } + } + Instr::BrOnNonNull(_) => Delta { pops: 1, pushes: 0 }, // --- Nested blocks --- Instr::Block(b) => { @@ -1035,70 +1746,43 @@ fn top_level_stack_effect(module: &Module, local: &LocalFunction, instr: &Instr) | Instr::ThrowRef(_) | Instr::Rethrow(_) => Terminator, - // --- Wasm-GC: not produced by our LLVM toolchain today. Report - // Unknown so we conservatively force the post-commit-3 panic - // path if any ever appears. --- - Instr::StructNew(_) - | Instr::StructNewDefault(_) - | Instr::StructGet(_) - | Instr::StructGetS(_) - | Instr::StructGetU(_) - | Instr::StructSet(_) - | Instr::ArrayNew(_) - | Instr::ArrayNewDefault(_) - | Instr::ArrayNewFixed(_) - | Instr::ArrayNewData(_) - | Instr::ArrayNewElem(_) - | Instr::ArrayGet(_) - | Instr::ArrayGetS(_) - | Instr::ArrayGetU(_) - | Instr::ArraySet(_) - | Instr::ArrayLen(_) - | Instr::ArrayFill(_) - | Instr::ArrayCopy(_) - | Instr::ArrayInitData(_) - | Instr::ArrayInitElem(_) => Unknown, - } -} - -fn seq_scalar_result_types( - module: &Module, - local: &LocalFunction, - seq_id: InstrSeqId, -) -> Option> { - match local.block(seq_id).ty { - InstrSeqType::Simple(None) => Some(Vec::new()), - InstrSeqType::Simple(Some(ty)) if is_scalar(ty) => Some(vec![ty]), - InstrSeqType::Simple(Some(_)) => None, - InstrSeqType::MultiValue(ty_id) => { - let results = module.types.get(ty_id).results(); - if results.iter().all(|&ty| is_scalar(ty)) { - Some(results.to_vec()) - } else { - None - } + // --- Wasm-GC --- + Instr::StructNew(new) => Delta { + pops: module.types.get(new.ty).kind().unwrap_struct().fields.len(), + pushes: 1, + }, + Instr::StructNewDefault(_) => Delta { pops: 0, pushes: 1 }, + Instr::StructGet(_) | Instr::StructGetS(_) | Instr::StructGetU(_) => { + Delta { pops: 1, pushes: 1 } + } + Instr::StructSet(_) => Delta { pops: 2, pushes: 0 }, + Instr::ArrayNew(_) => Delta { pops: 2, pushes: 1 }, + Instr::ArrayNewDefault(_) => Delta { pops: 1, pushes: 1 }, + Instr::ArrayNewFixed(new) => Delta { + pops: new.len as usize, + pushes: 1, + }, + Instr::ArrayNewData(_) | Instr::ArrayNewElem(_) => Delta { pops: 2, pushes: 1 }, + Instr::ArrayGet(_) | Instr::ArrayGetS(_) | Instr::ArrayGetU(_) => { + Delta { pops: 2, pushes: 1 } } + Instr::ArraySet(_) => Delta { pops: 3, pushes: 0 }, + Instr::ArrayLen(_) => Delta { pops: 1, pushes: 1 }, + Instr::ArrayFill(_) => Delta { pops: 4, pushes: 0 }, + Instr::ArrayCopy(_) => Delta { pops: 5, pushes: 0 }, + Instr::ArrayInitData(_) | Instr::ArrayInitElem(_) => Delta { pops: 4, pushes: 0 }, } } -fn push_structured_results( - stack: &mut Vec>, +fn seq_result_types( module: &Module, local: &LocalFunction, seq_id: InstrSeqId, - fallback_pushes: usize, -) { - match seq_scalar_result_types(module, local, seq_id) { - Some(types) => { - for ty in types { - stack.push(Some(ty)); - } - } - None => { - for _ in 0..fallback_pushes { - stack.push(None); - } - } +) -> Option> { + match local.block(seq_id).ty { + InstrSeqType::Simple(None) => Some(Vec::new()), + InstrSeqType::Simple(Some(ty)) => Some(vec![ty]), + InstrSeqType::MultiValue(ty_id) => Some(module.types.get(ty_id).results().to_vec()), } } @@ -1245,7 +1929,7 @@ fn atomic_width_pushes(width: AtomicWidth) -> ValType { fn select_pushes(explicit: Option, pre_stack: &[Option]) -> Option { if let Some(ty) = explicit { - return is_scalar(ty).then_some(ty); + return Some(ty); } if pre_stack.len() < 3 { return None; @@ -1253,13 +1937,20 @@ fn select_pushes(explicit: Option, pre_stack: &[Option]) -> Op let lhs = pre_stack[pre_stack.len() - 3]; let rhs = pre_stack[pre_stack.len() - 2]; match (lhs, rhs) { - (Some(a), Some(b)) if a == b && is_scalar(a) => Some(a), - (Some(a), None) if is_scalar(a) => Some(a), - (None, Some(b)) if is_scalar(b) => Some(b), + (Some(a), Some(b)) if a == b => Some(a), + (Some(a), None) => Some(a), + (None, Some(b)) => Some(b), _ => None, } } +fn concrete_non_null_ref(ty: TypeId) -> ValType { + ValType::Ref(RefType { + nullable: false, + heap_type: HeapType::Concrete(ty), + }) +} + fn typed_single_push( module: &Module, instr: &Instr, @@ -1277,6 +1968,70 @@ fn typed_single_push( Some(module.locals.get(*l).ty()) } Instr::GlobalGet(GlobalGet { global: g }) => Some(module.globals.get(*g).ty), + Instr::TableGet(table_get) => { + Some(ValType::Ref(module.tables.get(table_get.table).element_ty)) + } + Instr::RefNull(reference) => Some(ValType::Ref(reference.ty)), + Instr::RefFunc(reference) => Some(ValType::Ref(RefType { + nullable: false, + heap_type: HeapType::Concrete(module.funcs.get(reference.func).ty()), + })), + Instr::BrOnNull(_) => pre_stack.last().copied().flatten().and_then(|ty| { + let ValType::Ref(mut reference) = ty else { + return None; + }; + reference.nullable = false; + Some(ValType::Ref(reference)) + }), + Instr::BrOnCast(cast) => Some(ValType::Ref(RefType { + nullable: cast.from_nullable, + heap_type: cast.from_heap_type, + })), + Instr::BrOnCastFail(cast) => Some(ValType::Ref(RefType { + nullable: cast.to_nullable, + heap_type: cast.to_heap_type, + })), + Instr::RefAsNonNull(_) => pre_stack.last().copied().flatten().map(|ty| match ty { + ValType::Ref(mut reference) => { + reference.nullable = false; + ValType::Ref(reference) + } + other => other, + }), + Instr::RefI31(_) => Some(ValType::Ref(RefType { + nullable: false, + heap_type: HeapType::Abstract(AbstractHeapType::I31), + })), + Instr::RefCast(cast) => Some(ValType::Ref(RefType { + nullable: cast.nullable, + heap_type: cast.heap_type, + })), + Instr::AnyConvertExtern(_) => Some(ValType::Ref(RefType::ANYREF)), + Instr::ExternConvertAny(_) => Some(ValType::Ref(RefType::EXTERNREF)), + Instr::StructNew(new) => Some(concrete_non_null_ref(new.ty)), + Instr::StructNewDefault(new) => Some(concrete_non_null_ref(new.ty)), + Instr::StructGet(get) => Some( + module.types.get(get.ty).kind().unwrap_struct().fields[get.field as usize] + .element_type + .unpack(), + ), + Instr::StructGetS(_) | Instr::StructGetU(_) => Some(ValType::I32), + Instr::ArrayNew(new) => Some(concrete_non_null_ref(new.ty)), + Instr::ArrayNewDefault(new) => Some(concrete_non_null_ref(new.ty)), + Instr::ArrayNewFixed(new) => Some(concrete_non_null_ref(new.ty)), + Instr::ArrayNewData(new) => Some(concrete_non_null_ref(new.ty)), + Instr::ArrayNewElem(new) => Some(concrete_non_null_ref(new.ty)), + Instr::ArrayGet(get) => Some( + module + .types + .get(get.ty) + .kind() + .unwrap_array() + .field + .element_type + .unpack(), + ), + Instr::ArrayGetS(_) | Instr::ArrayGetU(_) | Instr::ArrayLen(_) => Some(ValType::I32), Instr::Load(load) => Some(load_pushes(&load.kind)), Instr::LoadSimd(_) => Some(ValType::V128), Instr::Binop(b) => Some(binop_pushes(&b.op)), @@ -1291,6 +2046,7 @@ fn typed_single_push( | Instr::TableSize(_) | Instr::TableGrow(_) | Instr::RefIsNull(_) + | Instr::RefTest(_) | Instr::RefEq(_) | Instr::I31GetS(_) | Instr::I31GetU(_) => Some(ValType::I32), @@ -1299,6 +2055,34 @@ fn typed_single_push( } } +fn typed_instruction_pushes( + module: &Module, + local: &LocalFunction, + instr: &Instr, + pre_stack: &[Option], +) -> Option> { + let types = match instr { + Instr::Call(call) => module + .types + .get(module.funcs.get(call.func).ty()) + .results() + .to_vec(), + Instr::CallIndirect(call) => module.types.get(call.ty).results().to_vec(), + Instr::CallRef(call) => module.types.get(call.ty).results().to_vec(), + Instr::Block(block) => seq_result_types(module, local, block.seq)?, + Instr::Loop(loop_) => seq_result_types(module, local, loop_.seq)?, + Instr::IfElse(if_else) => seq_result_types(module, local, if_else.consequent)?, + Instr::TryTable(try_table) => seq_result_types(module, local, try_table.seq)?, + Instr::Try(try_) => seq_result_types(module, local, try_.seq)?, + Instr::I64Add128 { .. } + | Instr::I64Sub128 { .. } + | Instr::I64MulWideS { .. } + | Instr::I64MulWideU { .. } => vec![ValType::I64, ValType::I64], + _ => vec![typed_single_push(module, instr, pre_stack)?], + }; + Some(types) +} + /// Compute the operand-stack carryover types for each top-level /// fork-path call site in the function. /// @@ -1312,21 +2096,13 @@ fn typed_single_push( /// - `Some(per_call_carryovers)` where `per_call_carryovers[K]` is the /// list of carryover ValTypes (deepest stack slot first) at call K. /// Empty vec if call K has no carryover. -/// - `None` if any producer instruction pushes a value of a type we -/// can't statically determine AND that value ends up in a carryover. -/// Post-commit-3, caller panics in this case — sub-commit 9-followup's -/// `Vec>` refinement made the analyser succeed for -/// any shape whose unknown slots are consumed before a fork-path -/// call's carryover, so `None` should be vanishingly rare in -/// shipping wasm. If it does fire, the panic message names the -/// function so the specific producer can be added to the typed- -/// producer list. +/// - `None` only when the exhaustive typed stack model disagrees with +/// already-validated Wasm IR (underflow, count mismatch, or impossible +/// producer typing). Callers treat that as an instrumenter bug. /// -/// Statically-typed producers handled here: -/// `Const`, `LocalGet`, `LocalTee`, `GlobalGet`, `Load` (all kinds), -/// `Binop` (encoded by op-name prefix), direct `Call` (signature -/// results), `MemorySize`/`TableSize` (i32). Anything else triggers -/// `None`. +/// `typed_instruction_pushes` covers every value-producing Walrus +/// instruction, including references, GC, EH control, indirect/ref calls, +/// structured multi-value results, and SIMD. /// /// Used by `instrument_one_function`'s dispatch decision: if this /// returns Some, switch-dispatch can absorb the carryover by spilling @@ -1343,18 +2119,9 @@ fn compute_carryover_types( }; let entry = local.entry_block(); - // Typed operand stack — bottom-to-top. Sub-commit 9-followup - // (2026-05-14): tracked as `Vec>` so unknown - // producers (ref-typed producers, non-fork-path CallIndirect/ - // CallRef, ref-typed structured-control results) push `None` - // without aborting. Failure is only triggered when a `None` slot - // ends up in a fork-path call's carryover. This mirrors - // `walk_seq_for_carryovers`'s 2.5c policy and closes the - // second `instrument_one_function_guard_dispatch` caller in - // `instrument_one_function` (since deleted by commit 4) for the - // case where a top-level fork-path call HAS no carryover but - // the function body still contains unknown-type producers - // consumed before the call. + // Typed operand stack, bottom-to-top. `Option` remains as a defensive + // assertion channel for analyzer bugs; every valid producer has an exact + // `ValType`, including reference and GC producers. let mut stack: Vec> = Vec::new(); let mut carryovers: Vec> = Vec::new(); @@ -1379,6 +2146,20 @@ fn compute_carryover_types( } continue; } + Instr::CallRef(call) => { + let sig = module.types.get(call.ty); + let n_args = sig.params().len() + 1; + if stack.len() < n_args { + return None; + } + let n_cr = stack.len() - n_args; + carryovers.push(snapshot(&stack[..n_cr])?); + stack.truncate(n_cr); + for &ty in sig.results() { + stack.push(Some(ty)); + } + continue; + } Instr::CallIndirect(ci) => { let sig = module.types.get(ci.ty); let n_args = sig.params().len() + 1; // +1 for table index @@ -1407,60 +2188,11 @@ fn compute_carryover_types( if pushes == 0 { continue; } - // Determine pushed type(s). Multi-push instructions - // are only Call / CallIndirect / CallRef; non-fork- - // path indirect calls go through here as `None` slots - // (the conservative `return None` from the legacy - // code only fired AFTER pops had already happened, so - // emitting None preserves stack-depth accounting). - match instr { - Instr::Call(c) => { - let sig = module.types.get(module.funcs.get(c.func).ty()); - for &ty in sig.results() { - stack.push(Some(ty)); - } - continue; - } - Instr::CallIndirect(ci) => { - let sig = module.types.get(ci.ty); - for _ in sig.results() { - stack.push(None); - } - continue; - } - Instr::CallRef(cr) => { - let sig = module.types.get(cr.ty); - for _ in sig.results() { - stack.push(None); - } - continue; - } - Instr::Block(b) => { - push_structured_results(&mut stack, module, local, b.seq, pushes); - continue; - } - Instr::Loop(l) => { - push_structured_results(&mut stack, module, local, l.seq, pushes); - continue; - } - Instr::IfElse(ie) => { - push_structured_results(&mut stack, module, local, ie.consequent, pushes); - continue; - } - Instr::TryTable(t) => { - push_structured_results(&mut stack, module, local, t.seq, pushes); - continue; - } - Instr::Try(t) => { - push_structured_results(&mut stack, module, local, t.seq, pushes); - continue; - } - _ => {} + let produced = typed_instruction_pushes(module, local, instr, &pre_stack)?; + if produced.len() != pushes { + return None; } - // Single-push, non-call, non-structured-control - // producers. - debug_assert_eq!(pushes, 1, "multi-push non-Call should not appear"); - stack.push(typed_single_push(module, instr, &pre_stack)); + stack.extend(produced.into_iter().map(Some)); } StackEffect::Terminator => { // Post-terminator code in the same seq is unreachable @@ -1480,7 +2212,7 @@ fn compute_carryover_types( Instr::Call(c) if fork_path.contains(&c.func) => { carryovers.push(Vec::new()); } - Instr::CallIndirect(_) => { + Instr::CallIndirect(_) | Instr::CallRef(_) => { carryovers.push(Vec::new()); } _ => {} @@ -1488,7 +2220,6 @@ fn compute_carryover_types( } break; } - StackEffect::Unknown => return None, } } @@ -1512,12 +2243,9 @@ fn compute_carryover_types( /// of carryover ValTypes (deepest stack slot first) for that call /// site; an empty vec means the call has no carryover. /// -/// Returns `None` if any producer instruction in any walked seq -/// pushes a value whose type can't be determined statically (e.g. a -/// non-fork-path `CallIndirect` / `CallRef`, a wasm-GC ref, a -/// multi-value or ref-typed `Block`/`Loop`/`IfElse`/`TryTable` -/// result). The caller (sub-commit 2.5c) keeps the existing rejection -/// in `seq_has_unsupported_carryover` for the `None` case. +/// Returns `None` only if the exhaustive stack model disagrees with +/// validated IR; reference, GC, EH, indirect/ref-call, and multi-value +/// producers all have exact types. fn compute_nested_carryover_types( module: &Module, func_id: FunctionId, @@ -1549,9 +2277,9 @@ fn compute_nested_carryover_types( for (&seq_id, direct_idxs) in &direct_idxs_per_seq { let per_seq = walk_seq_for_carryovers(module, local, seq_id, fork_path)?; if per_seq.len() != direct_idxs.len() { - // Mismatch implies the walk terminated early (e.g., hit a - // terminator before the last fork-path call). Conservative - // fallback: report unanalyzable. + // Mismatch means discovery and reachable typed walking disagree. + // Dead suffixes are excluded before this activation reaches the + // transform; a mismatch here is an internal invariant failure. return None; } for (cr, &idx) in per_seq.into_iter().zip(direct_idxs.iter()) { @@ -1568,16 +2296,10 @@ fn compute_nested_carryover_types( /// instructions are treated as opaque — see /// `compute_nested_carryover_types`. /// -/// Stack values are tracked as `Option`: producers we can -/// type statically push `Some(ty)`; producers we can't scalar-spill -/// (e.g. ref-typed producers or non-fork-path CallRef results) push -/// `None`. -/// `None` slots are tolerated as long as they're consumed before -/// the next fork-path call; only `None` slots that end up IN A -/// carryover force `walk_seq_for_carryovers` to fail conservatively -/// (returning `None`). This makes the analyser succeed for any -/// fork-bearing seq with no carryover at all, regardless of the -/// producer instructions it contains. +/// Stack values retain `Option` as an internal consistency channel. +/// Every valid producer, including reference/GC values and CallRef results, +/// pushes `Some(ty)`; `None` can therefore reach a snapshot only through an +/// analyzer bug. fn walk_seq_for_carryovers( module: &Module, f: &LocalFunction, @@ -1600,8 +2322,8 @@ fn walk_seq_for_carryovers( }; let mut carryovers: Vec> = Vec::new(); - // Helper: materialise the typed-carryover slice. Returns None if - // any `None` slot would be captured. + // Materialize the exact typed carryover. A `None` slot means the + // exhaustive producer model failed its internal invariant. fn snapshot_carryover(slots: &[Option]) -> Option> { slots.iter().copied().collect::>>() } @@ -1637,6 +2359,20 @@ fn walk_seq_for_carryovers( } continue; } + Instr::CallRef(call) => { + let sig = module.types.get(call.ty); + let n_args = sig.params().len() + 1; + if stack.len() < n_args { + return None; + } + let n_cr = stack.len() - n_args; + carryovers.push(snapshot_carryover(&stack[..n_cr])?); + stack.truncate(n_cr); + for &ty in sig.results() { + stack.push(Some(ty)); + } + continue; + } _ => {} } @@ -1650,67 +2386,11 @@ fn walk_seq_for_carryovers( if pushes == 0 { continue; } - // Determine pushed type(s). Multi-push instructions are - // only Call / CallIndirect / CallRef and structured - // control flow with a multi-value result. We type the - // single-result cases precisely; everything else - // contributes `None` slots so the seq can still proceed - // as long as the unknown slot is consumed before any - // carryover snapshot. - match instr { - Instr::Call(c) => { - let sig = module.types.get(module.funcs.get(c.func).ty()); - for &ty in sig.results() { - stack.push(Some(ty)); - } - continue; - } - Instr::CallIndirect(ci) => { - // Non-fork-path CallIndirect (fork-path is - // handled above). Unknown ref-typed result? - // Push None slots — caller may or may not - // observe them as a carryover. - let sig = module.types.get(ci.ty); - for _ in sig.results() { - stack.push(None); - } - continue; - } - Instr::CallRef(cr) => { - let sig = module.types.get(cr.ty); - for _ in sig.results() { - stack.push(None); - } - continue; - } - Instr::Block(b) => { - push_structured_results(&mut stack, module, f, b.seq, pushes); - continue; - } - Instr::Loop(l) => { - push_structured_results(&mut stack, module, f, l.seq, pushes); - continue; - } - Instr::IfElse(ie) => { - push_structured_results(&mut stack, module, f, ie.consequent, pushes); - continue; - } - Instr::TryTable(t) => { - push_structured_results(&mut stack, module, f, t.seq, pushes); - continue; - } - Instr::Try(t) => { - push_structured_results(&mut stack, module, f, t.seq, pushes); - continue; - } - _ => {} + let produced = typed_instruction_pushes(module, f, instr, &pre_stack)?; + if produced.len() != pushes { + return None; } - // Single-push, non-call, non-block-typed producers. - debug_assert_eq!( - pushes, 1, - "multi-push non-call/non-structured-control should not reach here" - ); - stack.push(typed_single_push(module, instr, &pre_stack)); + stack.extend(produced.into_iter().map(Some)); } StackEffect::Terminator => { // Post-terminator code in this seq is unreachable. @@ -1721,7 +2401,6 @@ fn walk_seq_for_carryovers( // `carryovers`.) return Some(carryovers); } - StackEffect::Unknown => return None, } } @@ -1753,7 +2432,9 @@ fn partition_body( let sig_ty = module.funcs.get(c.func).ty(); calls.push(CallSiteInfo { target: CallTarget::Direct(c.func), + direct_activation: false, sig_ty, + resume_ty: None, loc: *loc, }); chunks.push(Vec::new()); @@ -1761,7 +2442,19 @@ fn partition_body( Instr::CallIndirect(ci) => { calls.push(CallSiteInfo { target: CallTarget::Indirect { table: ci.table }, + direct_activation: false, sig_ty: ci.ty, + resume_ty: None, + loc: *loc, + }); + chunks.push(Vec::new()); + } + Instr::CallRef(call) => { + calls.push(CallSiteInfo { + target: CallTarget::Ref, + direct_activation: false, + sig_ty: call.ty, + resume_ty: None, loc: *loc, }); chunks.push(Vec::new()); @@ -1777,11 +2470,42 @@ fn partition_body( (chunks, calls) } +fn assert_reference_call_alignment(analysis: &FunctionReferenceAnalysis, calls: &[CallSiteInfo]) { + assert_eq!( + analysis.call_sites.len(), + calls.len(), + "original reference analysis and top-level transform discovered different call counts" + ); + for (reference, call) in analysis.call_sites.iter().zip(calls) { + let aligned = match (reference.kind, call.target) { + (OriginalCallKind::Direct(expected), CallTarget::Direct(actual)) => expected == actual, + ( + OriginalCallKind::Indirect { + table: expected_table, + ty: expected_ty, + }, + CallTarget::Indirect { + table: actual_table, + }, + ) => expected_table == actual_table && expected_ty == call.sig_ty, + (OriginalCallKind::Ref { ty }, CallTarget::Ref) => ty == call.sig_ty, + _ => false, + }; + assert!( + aligned, + "reference analysis call {:?} does not align with transformed call target {:?}", + reference.kind, call.target + ); + } +} + fn call_arg_types(module: &Module, cs: &CallSiteInfo) -> Vec { let params = module.types.get(cs.sig_ty).params().to_vec(); let mut arg_types = params; - if matches!(cs.target, CallTarget::Indirect { .. }) { - arg_types.push(ValType::I32); + match cs.target { + CallTarget::Indirect { .. } => arg_types.push(ValType::I32), + CallTarget::Ref => arg_types.push(ValType::Ref(RefType::FUNCREF)), + CallTarget::Direct(_) => {} } arg_types } @@ -1801,6 +2525,7 @@ enum PendingCallArgMaterialization { enum CallArgMaterialization { Spill { locals: Vec, + types: Vec, }, PureTail { tail: Vec<(Instr, InstrLocId)>, @@ -1811,7 +2536,7 @@ enum CallArgMaterialization { impl CallArgMaterialization { fn spill_locals(&self) -> &[LocalId] { match self { - Self::Spill { locals } => locals, + Self::Spill { locals, .. } => locals, Self::PureTail { .. } => &[], } } @@ -1822,6 +2547,34 @@ impl CallArgMaterialization { Self::PureTail { tail_len, .. } => *tail_len, } } + + fn append_reference_inputs(&self, module: &Module, references: &mut Vec<(LocalId, RefType)>) { + match self { + Self::Spill { locals, types } => { + for (&local, &ty) in locals.iter().zip(types) { + if let Some(reference) = supported_reference(ty) { + references.push((local, reference)); + } + } + } + Self::PureTail { tail, .. } => { + // WHY: replaying a reference local.get is side-effect-free, + // but only if that exact local is itself activation-owned. + // Recording it here avoids a per-call reference spill local + // while ensuring the preamble restores it before reissuing + // the pure argument suffix. + for (instr, _) in tail { + let Instr::LocalGet(LocalGet { local }) = instr else { + continue; + }; + let ValType::Ref(reference) = module.locals.get(*local).ty() else { + continue; + }; + references.push((*local, reference)); + } + } + } + } } fn plan_call_arg_materialization( @@ -1829,7 +2582,7 @@ fn plan_call_arg_materialization( chunk: &[(Instr, InstrLocId)], arg_types: Vec, ) -> PendingCallArgMaterialization { - if let Some((tail_len, tail)) = split_pure_scalar_tail(module, chunk, &arg_types) { + if let Some((tail_len, tail)) = split_pure_replay_tail(module, chunk, &arg_types) { PendingCallArgMaterialization::PureTail { tail, tail_len } } else { PendingCallArgMaterialization::Spill { arg_types } @@ -1842,8 +2595,14 @@ fn allocate_call_arg_materialization( ) -> CallArgMaterialization { match pending { PendingCallArgMaterialization::Spill { arg_types } => { - let locals = arg_types.iter().map(|&ty| module.locals.add(ty)).collect(); - CallArgMaterialization::Spill { locals } + let locals = arg_types + .iter() + .map(|&ty| module.locals.add(spill_storage_type(ty))) + .collect(); + CallArgMaterialization::Spill { + locals, + types: arg_types, + } } PendingCallArgMaterialization::PureTail { tail, tail_len } => { CallArgMaterialization::PureTail { tail, tail_len } @@ -1859,7 +2618,7 @@ fn truncate_materialized_tail(chunk: &mut Vec<(Instr, InstrLocId)>, tail_len: us chunk.truncate(chunk.len() - tail_len); } -fn split_pure_scalar_tail( +fn split_pure_replay_tail( module: &Module, chunk: &[(Instr, InstrLocId)], expected_outputs: &[ValType], @@ -1870,7 +2629,7 @@ fn split_pure_scalar_tail( for start in 0..chunk.len() { let tail = &chunk[start..]; - if let Some(outputs) = pure_scalar_tail_outputs(module, tail) { + if let Some(outputs) = pure_replay_tail_outputs(module, tail) { if outputs == expected_outputs { return Some((tail.len(), tail.to_vec())); } @@ -1880,16 +2639,13 @@ fn split_pure_scalar_tail( None } -fn pure_scalar_tail_outputs(module: &Module, tail: &[(Instr, InstrLocId)]) -> Option> { +fn pure_replay_tail_outputs(module: &Module, tail: &[(Instr, InstrLocId)]) -> Option> { let mut stack: Vec = Vec::new(); for (instr, _) in tail { match instr { Instr::Const(c) => stack.push(pure_const_type(c)?), Instr::LocalGet(LocalGet { local }) => { let ty = module.locals.get(*local).ty(); - if !is_scalar(ty) { - return None; - } stack.push(ty); } Instr::Unop(u) => { @@ -2341,7 +3097,7 @@ fn populate_dispatch_structure( chunks: &[Vec<(Instr, InstrLocId)>], call_sites: &[CallSiteInfo], arg_materializations: &[CallArgMaterialization], - carryover_spills: &[Vec], + carryover_spills: &[Vec], catch_handlers: &[CatchHandlerInfo], runtime: &Runtime, memory: MemoryId, @@ -2401,7 +3157,7 @@ fn emit_dispatch_node( chunks: &[Vec<(Instr, InstrLocId)>], call_sites: &[CallSiteInfo], arg_materializations: &[CallArgMaterialization], - carryover_spills: &[Vec], + carryover_spills: &[Vec], catch_handlers: &[CatchHandlerInfo], runtime: &Runtime, memory: MemoryId, @@ -2487,7 +3243,7 @@ fn emit_internal_dispatch( chunks: &[Vec<(Instr, InstrLocId)>], call_sites: &[CallSiteInfo], arg_materializations: &[CallArgMaterialization], - carryover_spills: &[Vec], + carryover_spills: &[Vec], catch_handlers: &[CatchHandlerInfo], runtime: &Runtime, memory: MemoryId, @@ -2610,7 +3366,7 @@ fn emit_leaf_dispatch( chunks: &[Vec<(Instr, InstrLocId)>], call_sites: &[CallSiteInfo], arg_materializations: &[CallArgMaterialization], - carryover_spills: &[Vec], + carryover_spills: &[Vec], catch_handlers: &[CatchHandlerInfo], runtime: &Runtime, memory: MemoryId, @@ -2733,171 +3489,82 @@ fn emit_leaf_dispatch( for (instr, loc) in &chunks[n_calls_total] { s.push((instr.clone(), *loc)); } - push_instr(s, Instr::Return(Return {})); - } else { - let s = &mut local.block_mut(exit_seq).instrs; - for (instr, loc) in &chunks[leaf_end] { - s.push((instr.clone(), *loc)); - } - emit_spill_call_tail( - s, - &arg_materializations[leaf_end], - &carryover_spills[leaf_end], - ); - } -} - -/// Spill the arg values off the operand stack into the per-call -/// spill locals. Args are spilled in reverse (top-of-stack first), -/// so the deepest arg ends up in `spills[0]`. -/// -/// When `carryovers` is non-empty (sub-commit 2.4c), the operand -/// stack at the call site is `[..., carryover_0, ..., carryover_{n-1}, -/// arg_0, ..., arg_{m-1}]` (bottom-to-top). After popping all args, -/// we keep popping into `carryovers` (also reverse-order), so -/// `carryovers[0]` ends up holding the deepest carryover slot. -fn emit_spill_args(out: &mut Vec<(Instr, InstrLocId)>, spills: &[LocalId], carryovers: &[LocalId]) { - for &local in spills.iter().rev() { - push_instr(out, Instr::LocalSet(LocalSet { local })); - } - for &local in carryovers.iter().rev() { - push_instr(out, Instr::LocalSet(LocalSet { local })); - } -} - -fn emit_spill_call_tail( - out: &mut Vec<(Instr, InstrLocId)>, - arg_materialization: &CallArgMaterialization, - carryovers: &[LocalId], -) { - emit_spill_args(out, arg_materialization.spill_locals(), carryovers); -} - -fn emit_materialized_call_args( - out: &mut Vec<(Instr, InstrLocId)>, - arg_materialization: &CallArgMaterialization, -) { - match arg_materialization { - CallArgMaterialization::Spill { locals } => { - for &l in locals.iter() { - push_instr(out, Instr::LocalGet(LocalGet { local: l })); - } - } - CallArgMaterialization::PureTail { tail, .. } => { - out.extend(tail.iter().cloned()); - } - } -} - -/// Emit Phase 6e writes inline. Must be called with mutable access to -/// the function (so dangling seqs can be allocated for each handler's -/// if-branch). -fn emit_phase_6e_writes( - local: &mut LocalFunction, - seq_id: InstrSeqId, - catch_handlers: &[CatchHandlerInfo], - catch_state_locals: Option, -) { - if catch_handlers.is_empty() { - return; - } - let catch_state = catch_state_locals.expect("catch handlers require catch-state locals"); - { - let s = &mut local.block_mut(seq_id).instrs; - push_instr( - s, - Instr::Const(Const { - value: Value::I32(0), - }), - ); - push_instr( - s, - Instr::LocalSet(LocalSet { - local: catch_state.catch_region_id, - }), - ); - push_instr( - s, - Instr::Const(Const { - value: Value::I32(0), - }), - ); - push_instr( - s, - Instr::LocalSet(LocalSet { - local: catch_state.exnref_slot, - }), - ); - } - for info in catch_handlers { - let if_ty = InstrSeqType::Simple(None); - let ih_then = local.builder_mut().dangling_instr_seq(if_ty).id(); - let ih_else = local.builder_mut().dangling_instr_seq(if_ty).id(); - { - let s = &mut local.block_mut(ih_then).instrs; - push_instr( - s, - Instr::Const(Const { - value: Value::I32(info.catch_region_id as i32), - }), - ); - push_instr( - s, - Instr::LocalSet(LocalSet { - local: catch_state.catch_region_id, - }), - ); - push_instr( - s, - Instr::Const(Const { - value: Value::I32(info.exnref_slot as i32), - }), - ); - push_instr( - s, - Instr::LocalSet(LocalSet { - local: catch_state.exnref_slot, - }), - ); - } - let s = &mut local.block_mut(seq_id).instrs; - push_instr( - s, - Instr::LocalGet(LocalGet { - local: info.in_catch_local, - }), - ); - push_instr( + push_instr(s, Instr::Return(Return {})); + } else { + let s = &mut local.block_mut(exit_seq).instrs; + for (instr, loc) in &chunks[leaf_end] { + s.push((instr.clone(), *loc)); + } + emit_spill_call_tail( s, - Instr::IfElse(IfElse { - consequent: ih_then, - alternative: ih_else, - }), + &arg_materializations[leaf_end], + &carryover_spills[leaf_end], ); } } -fn emit_call_index_store_and_unwind_branch( +/// Spill the arg values off the operand stack into the per-call +/// spill locals. Args are spilled in reverse (top-of-stack first), +/// so the deepest arg ends up in `spills[0]`. +/// +/// When `carryovers` is non-empty (sub-commit 2.4c), the operand +/// stack at the call site is `[..., carryover_0, ..., carryover_{n-1}, +/// arg_0, ..., arg_{m-1}]` (bottom-to-top). After popping all args, +/// we keep popping into `carryovers` (also reverse-order), so +/// `carryovers[0]` ends up holding the deepest carryover slot. +fn emit_spill_args( + out: &mut Vec<(Instr, InstrLocId)>, + spills: &[LocalId], + carryovers: &[TypedSpillLocal], +) { + for &local in spills.iter().rev() { + push_instr(out, Instr::LocalSet(LocalSet { local })); + } + for &(local, _ty) in carryovers.iter().rev() { + push_instr(out, Instr::LocalSet(LocalSet { local })); + } +} + +fn emit_spill_call_tail( + out: &mut Vec<(Instr, InstrLocId)>, + arg_materialization: &CallArgMaterialization, + carryovers: &[TypedSpillLocal], +) { + emit_spill_args(out, arg_materialization.spill_locals(), carryovers); +} + +fn emit_materialized_call_args( + out: &mut Vec<(Instr, InstrLocId)>, + arg_materialization: &CallArgMaterialization, +) { + match arg_materialization { + CallArgMaterialization::Spill { locals, types } => { + for (&local, &ty) in locals.iter().zip(types) { + push_typed_local_get(out, local, ty); + } + } + CallArgMaterialization::PureTail { tail, .. } => { + out.extend(tail.iter().cloned()); + } + } +} + +/// Handle the private unwind tag at one statically known call site. +/// +/// Successful reservation records the static call index and branches to the +/// common frame postamble. A synchronous allocation failure instead selects +/// the header-sized abort scratch, records the same index, and restarts the +/// live activation at the dispatch loop. Since the replay preamble is outside +/// that loop, no activation-local selector/flag is required. +fn emit_static_call_unwind_handler( local: &mut LocalFunction, seq_id: InstrSeqId, - runtime: &Runtime, - memory: MemoryId, ptr_ty: ValType, frame_size: u32, call_idx: u32, unwind_save: InstrSeqId, - catch_handlers: &[CatchHandlerInfo], - catch_state_locals: Option, abort: AbortDispatch, ) { - let unwind_then = local - .builder_mut() - .dangling_instr_seq(InstrSeqType::Simple(None)) - .id(); - let normal_else = local - .builder_mut() - .dangling_instr_seq(InstrSeqType::Simple(None)) - .id(); let reserve_succeeded = local .builder_mut() .dangling_instr_seq(InstrSeqType::Simple(None)) @@ -2908,106 +3575,31 @@ fn emit_call_index_store_and_unwind_branch( .id(); { - let s = &mut local.block_mut(unwind_then).instrs; - if let Some(frame_reserve) = runtime.frame_reserve { - // This is the first frame write on the unwind path. Reserve the - // complete node before publishing call_index or any postamble - // scalar/reference state. - push_instr( - s, - Instr::GlobalGet(GlobalGet { - global: runtime.buf_global, - }), - ); - push_instr(s, ptr_const(ptr_ty, frame_size as i64)); - push_instr( - s, - Instr::Call(Call { - func: frame_reserve, - }), - ); - push_instr(s, store_ptr(memory, ptr_ty, 0)); - - push_instr( - s, - Instr::GlobalGet(GlobalGet { - global: runtime.buf_global, - }), - ); - push_instr(s, load_ptr(memory, ptr_ty, 0)); - push_instr( - s, - Instr::Unop(walrus::ir::Unop { - op: match ptr_ty { - ValType::I32 => UnaryOp::I32Eqz, - ValType::I64 => UnaryOp::I64Eqz, - other => unreachable!("unsupported pointer type {other:?}"), - }, - }), - ); - push_instr( - s, - Instr::IfElse(IfElse { - consequent: reserve_failed, - alternative: reserve_succeeded, - }), - ); - } else { - push_instr( - s, - Instr::Block(Block { - seq: reserve_succeeded, - }), - ); - } - } - - { - let s = &mut local.block_mut(reserve_failed).instrs; + let s = &mut local.block_mut(seq_id).instrs; + push_instr(s, ptr_const(ptr_ty, frame_size as i64)); push_instr( s, Instr::Const(Const { - value: Value::I32(1), - }), - ); - push_instr( - s, - Instr::LocalSet(LocalSet { - local: abort.live_frame, - }), - ); - // Select the module-owned abort scratch frame. Linked chunks begin - // after the descriptor's larger fixed prefix, so this header-sized - // area can carry the live activation's call index without touching a - // committed node. - push_instr( - s, - Instr::GlobalGet(GlobalGet { - global: runtime.buf_global, - }), - ); - push_instr( - s, - Instr::GlobalGet(GlobalGet { - global: runtime.buf_global, + value: Value::I32(call_idx as i32), }), ); - push_instr(s, ptr_const(ptr_ty, runtime.frames_start_offset as i64)); push_instr( s, - Instr::Binop(Binop { - op: ptr_add(ptr_ty), + Instr::Call(Call { + func: abort.frame_select, }), ); - push_instr(s, store_ptr(memory, ptr_ty, 0)); - push_current_frame_ptr(s, runtime, memory, ptr_ty); push_instr( s, - Instr::Const(Const { - value: Value::I32(call_idx as i32), + Instr::IfElse(IfElse { + consequent: reserve_succeeded, + alternative: reserve_failed, }), ); - push_instr(s, store_i32(memory, CALL_INDEX_OFFSET)); + } + + { + let s = &mut local.block_mut(reserve_failed).instrs; push_instr( s, Instr::Br(Br { @@ -3016,54 +3608,251 @@ fn emit_call_index_store_and_unwind_branch( ); } - emit_phase_6e_writes(local, reserve_succeeded, catch_handlers, catch_state_locals); { let s = &mut local.block_mut(reserve_succeeded).instrs; - push_current_frame_ptr(s, runtime, memory, ptr_ty); - push_instr( - s, - Instr::Const(Const { - value: Value::I32(call_idx as i32), - }), - ); - push_instr(s, store_i32(memory, CALL_INDEX_OFFSET)); push_instr(s, Instr::Br(Br { block: unwind_save })); } +} + +// ---------------------------------------------------------------------- +// Preamble / postamble +// ---------------------------------------------------------------------- - emit_phase_6e_writes(local, normal_else, catch_handlers, catch_state_locals); +fn reference_plan_runs( + plan: &ReferenceFramePlan, +) -> Vec<(usize, usize, Vec, Vec<(LocalId, RefType)>)> { + let mut runs = Vec::new(); + let mut start = 0usize; + while start < plan.slots_by_call.len() { + let slots = &plan.slots_by_call[start]; + let nulls = &plan.null_locals_by_call[start]; + let mut end = start; + while end + 1 < plan.slots_by_call.len() + && plan.slots_by_call[end + 1] == *slots + && plan.null_locals_by_call[end + 1] == *nulls + { + end += 1; + } + if !slots.is_empty() || !nulls.is_empty() { + runs.push((start, end, slots.clone(), nulls.clone())); + } + start = end + 1; + } + runs +} - let s = &mut local.block_mut(seq_id).instrs; +fn push_call_index_in_range( + out: &mut Vec<(Instr, InstrLocId)>, + runtime: &Runtime, + memory: MemoryId, + ptr_ty: ValType, + first: usize, + last: usize, +) { + push_current_call_index(out, runtime, memory, ptr_ty); push_instr( - s, - Instr::GlobalGet(GlobalGet { - global: runtime.state_global, + out, + Instr::Const(Const { + value: Value::I32(first as i32), }), ); + if first == last { + push_instr( + out, + Instr::Binop(Binop { + op: BinaryOp::I32Eq, + }), + ); + return; + } push_instr( - s, + out, + Instr::Binop(Binop { + op: BinaryOp::I32GeU, + }), + ); + push_current_call_index(out, runtime, memory, ptr_ty); + push_instr( + out, Instr::Const(Const { - value: Value::I32(runtime::STATE_UNWINDING), + value: Value::I32(last as i32), }), ); push_instr( - s, + out, Instr::Binop(Binop { - op: BinaryOp::I32Eq, + op: BinaryOp::I32LeU, }), ); push_instr( - s, - Instr::IfElse(IfElse { - consequent: unwind_then, - alternative: normal_else, + out, + Instr::Binop(Binop { + op: BinaryOp::I32And, }), ); } -// ---------------------------------------------------------------------- -// Preamble / postamble -// ---------------------------------------------------------------------- - +#[allow(clippy::too_many_arguments)] +fn emit_reference_restore_dispatch( + local: &mut LocalFunction, + seq: InstrSeqId, + runtime: &Runtime, + memory: MemoryId, + ptr_ty: ValType, + plan: &ReferenceFramePlan, +) { + let codecs = runtime + .reference_codecs + .expect("linked fork reference plan requires typed host codecs"); + let vector_get = runtime + .reference_vector_get + .expect("linked fork reference plan requires recipe-vector lookup"); + for (first, last, slots, nulls) in reference_plan_runs(plan) { + let then_seq = local + .builder_mut() + .dangling_instr_seq(InstrSeqType::Simple(None)) + .id(); + let else_seq = local + .builder_mut() + .dangling_instr_seq(InstrSeqType::Simple(None)) + .id(); + { + let out = &mut local.block_mut(then_seq).instrs; + for (position, slot_idx) in slots.into_iter().enumerate() { + let slot = plan.slots[slot_idx]; + let class = slot.class; + push_current_frame_ptr(out, runtime, memory, ptr_ty); + push_instr(out, load_i32(memory, REFERENCE_VECTOR_OFFSET)); + push_instr( + out, + Instr::Const(Const { + value: Value::I32(position as i32), + }), + ); + push_instr(out, Instr::Call(Call { func: vector_get })); + push_instr( + out, + Instr::Call(Call { + func: class.decoder(codecs), + }), + ); + push_decoded_reference_narrowing(out, class, slot.ty); + push_instr(out, Instr::LocalSet(LocalSet { local: slot.local })); + } + for (local, ty) in nulls { + push_instr(out, Instr::RefNull(RefNull { ty })); + push_instr(out, Instr::LocalSet(LocalSet { local })); + } + } + let out = &mut local.block_mut(seq).instrs; + push_call_index_in_range(out, runtime, memory, ptr_ty, first, last); + push_instr( + out, + Instr::IfElse(IfElse { + consequent: then_seq, + alternative: else_seq, + }), + ); + } +} + +#[allow(clippy::too_many_arguments)] +fn build_reference_save_dispatch( + local: &mut LocalFunction, + runtime: &Runtime, + memory: MemoryId, + ptr_ty: ValType, + plan: &ReferenceFramePlan, +) -> Option { + if plan.slots_by_call.iter().all(Vec::is_empty) { + return None; + } + let codecs = runtime + .reference_codecs + .expect("linked fork reference plan requires typed host codecs"); + let vector_begin = runtime + .reference_vector_begin + .expect("linked fork reference plan requires recipe-vector allocation"); + let vector_append = runtime + .reference_vector_append + .expect("linked fork reference plan requires recipe-vector append"); + let vector_finish = runtime + .reference_vector_finish + .expect("linked fork reference plan requires recipe-vector finish"); + let root = local + .builder_mut() + .dangling_instr_seq(InstrSeqType::Simple(None)) + .id(); + for (first, last, slots, _nulls) in reference_plan_runs(plan) { + if slots.is_empty() { + continue; + } + let then_seq = local + .builder_mut() + .dangling_instr_seq(InstrSeqType::Simple(None)) + .id(); + let else_seq = local + .builder_mut() + .dangling_instr_seq(InstrSeqType::Simple(None)) + .id(); + { + let out = &mut local.block_mut(then_seq).instrs; + push_current_frame_ptr(out, runtime, memory, ptr_ty); + push_instr( + out, + Instr::Const(Const { + value: Value::I32(slots.len() as i32), + }), + ); + push_instr(out, Instr::Call(Call { func: vector_begin })); + push_instr(out, store_i32(memory, REFERENCE_VECTOR_OFFSET)); + for slot_idx in slots { + let slot = plan.slots[slot_idx]; + let class = slot.class; + push_current_frame_ptr(out, runtime, memory, ptr_ty); + push_instr(out, load_i32(memory, REFERENCE_VECTOR_OFFSET)); + push_typed_local_get(out, slot.local, ValType::Ref(slot.ty)); + push_instr( + out, + Instr::Call(Call { + func: class.encoder(codecs), + }), + ); + push_instr( + out, + Instr::Call(Call { + func: vector_append, + }), + ); + } + // WHY: the frame must hold a durable canonical ordinal, never the + // transaction-local builder handle returned by vector_begin. This + // also interns identical vectors across recursive activations + // without adding a source-function local or frame byte. + push_current_frame_ptr(out, runtime, memory, ptr_ty); + push_current_frame_ptr(out, runtime, memory, ptr_ty); + push_instr(out, load_i32(memory, REFERENCE_VECTOR_OFFSET)); + push_instr( + out, + Instr::Call(Call { + func: vector_finish, + }), + ); + push_instr(out, store_i32(memory, REFERENCE_VECTOR_OFFSET)); + } + let out = &mut local.block_mut(root).instrs; + push_call_index_in_range(out, runtime, memory, ptr_ty, first, last); + push_instr( + out, + Instr::IfElse(IfElse { + consequent: then_seq, + alternative: else_seq, + }), + ); + } + Some(root) +} + #[allow(clippy::too_many_arguments)] fn populate_preamble_then( local: &mut LocalFunction, @@ -3073,15 +3862,14 @@ fn populate_preamble_then( ptr_ty: ValType, catch_state_locals: Option, locals_with_offsets: &[(LocalId, ValType, u32)], - ref_plan: &[RefLocalSlot], - aux_tables: &AuxTables, + catch_scalar_restore_dispatch: Option, + reference_plan: &ReferenceFramePlan, frame_size: u32, ) { - let s = &mut local.block_mut(preamble_then).instrs; - // Store the frame selected for replay in *(buf + 0). The linked format // asks the host-managed chain for the next committed frame; the legacy // format walks its contiguous buffer backward. + let s = &mut local.block_mut(preamble_then).instrs; push_instr( s, Instr::GlobalGet(GlobalGet { @@ -3110,22 +3898,14 @@ fn populate_preamble_then( push_instr(s, store_ptr(memory, ptr_ty, 0)); if let Some(catch_state) = catch_state_locals { - // catch_region_id_local / exnref_slot_local - push_current_frame_ptr(s, runtime, memory, ptr_ty); - push_instr(s, load_i32(memory, CATCH_REGION_OFFSET)); - push_instr( - s, - Instr::LocalSet(LocalSet { - local: catch_state.catch_region_id, - }), - ); - + // Frame word +8 owns the exact `(region, arm)` selector. Scalar arm + // payloads are restored separately from their overlaid union. push_current_frame_ptr(s, runtime, memory, ptr_ty); - push_instr(s, load_i32(memory, EXNREF_SLOT_OFFSET)); + push_instr(s, load_i32(memory, CATCH_SELECTOR_OFFSET)); push_instr( s, Instr::LocalSet(LocalSet { - local: catch_state.exnref_slot, + local: catch_state.catch_selector, }), ); } @@ -3136,21 +3916,17 @@ fn populate_preamble_then( push_instr(s, load_scalar(memory, ty, off as u64)); push_instr(s, Instr::LocalSet(LocalSet { local: lid })); } - - // Restore ref-typed user locals from aux tables. - for slot in ref_plan { - let table = aux_tables - .table_for(slot.class) - .expect("aux table for this ref class must be injected"); - push_instr( - s, - Instr::Const(Const { - value: Value::I32(slot.slot as i32), - }), - ); - push_instr(s, Instr::TableGet(TableGet { table })); - push_instr(s, Instr::LocalSet(LocalSet { local: slot.local })); + if let Some(dispatch) = catch_scalar_restore_dispatch { + push_instr(s, Instr::Block(Block { seq: dispatch })); } + emit_reference_restore_dispatch( + local, + preamble_then, + runtime, + memory, + ptr_ty, + reference_plan, + ); } #[allow(clippy::too_many_arguments)] @@ -3161,11 +3937,10 @@ fn populate_postamble( ptr_ty: ValType, catch_state_locals: Option, locals_with_offsets: &[(LocalId, ValType, u32)], - ref_plan: &[RefLocalSlot], - aux_tables: &AuxTables, + catch_scalar_save_dispatch: Option, + reference_save_dispatch: Option, frame_size: u32, func_ordinal: u32, - result_types: &[ValType], ) { // frame[0] = func_ordinal push_current_frame_ptr(out, runtime, memory, ptr_ty); @@ -3178,36 +3953,37 @@ fn populate_postamble( push_instr(out, store_i32(memory, FUNC_INDEX_OFFSET)); if let Some(catch_state) = catch_state_locals { - // frame[8] = dynamic catch_region_id for catch-capable functions. - push_current_frame_ptr(out, runtime, memory, ptr_ty); - push_instr( - out, - Instr::LocalGet(LocalGet { - local: catch_state.catch_region_id, - }), - ); - push_instr(out, store_i32(memory, CATCH_REGION_OFFSET)); - - // frame[12] = dynamic exnref_slot for catch-capable functions. + // frame[8] = exact non-zero `(region, arm)` selector in a catch. push_current_frame_ptr(out, runtime, memory, ptr_ty); push_instr( out, Instr::LocalGet(LocalGet { - local: catch_state.exnref_slot, + local: catch_state.catch_selector, }), ); - push_instr(out, store_i32(memory, EXNREF_SLOT_OFFSET)); + push_instr(out, store_i32(memory, CATCH_SELECTOR_OFFSET)); } else { - // frame[8..16] = zero catch_region_id + exnref_slot. + // frame[8] = no active catch region. push_current_frame_ptr(out, runtime, memory, ptr_ty); push_instr( out, Instr::Const(Const { - value: Value::I64(0), + value: Value::I32(0), }), ); - push_instr(out, store_scalar(memory, ValType::I64, CATCH_REGION_OFFSET)); + push_instr(out, store_i32(memory, CATCH_SELECTOR_OFFSET)); } + // frame[12] starts as the canonical empty reference-vector ordinal. The + // call-specific save dispatch replaces it only when this landing owns + // non-null recipe values. + push_current_frame_ptr(out, runtime, memory, ptr_ty); + push_instr( + out, + Instr::Const(Const { + value: Value::I32(0), + }), + ); + push_instr(out, store_i32(memory, REFERENCE_VECTOR_OFFSET)); // Save scalar user + arg-spill locals for &(lid, ty, off) in locals_with_offsets { @@ -3215,24 +3991,18 @@ fn populate_postamble( push_instr(out, Instr::LocalGet(LocalGet { local: lid })); push_instr(out, store_scalar(memory, ty, off as u64)); } + if let Some(dispatch) = catch_scalar_save_dispatch { + push_instr(out, Instr::Block(Block { seq: dispatch })); + } - // Spill ref-typed user locals to aux tables. - for slot in ref_plan { - let table = aux_tables - .table_for(slot.class) - .expect("aux table for this ref class must be injected"); - push_instr( - out, - Instr::Const(Const { - value: Value::I32(slot.slot as i32), - }), - ); - push_instr(out, Instr::LocalGet(LocalGet { local: slot.local })); - push_instr(out, Instr::TableSet(TableSet { table })); + if let Some(dispatch) = reference_save_dispatch { + // The call selector was written before entering this common postamble. + // Each case encodes only values live for that original call landing. + push_instr(out, Instr::Block(Block { seq: dispatch })); } if let Some(frame_commit) = runtime.frame_commit { - // Publish only after the complete payload and reference stashes exist. + // Publish only after the complete activation-owned payload exists. push_current_frame_ptr(out, runtime, memory, ptr_ty); push_instr(out, Instr::Call(Call { func: frame_commit })); } else { @@ -3254,33 +4024,289 @@ fn populate_postamble( push_instr(out, store_ptr(memory, ptr_ty, 0)); } - // Push defaults for the function's result types, or `unreachable` - // if any result is a non-nullable ref. - let mut fallback_unreachable = false; - for &ty in result_types { - match default_for_type(ty) { - Some(instr) => push_instr(out, instr), - None => { - fallback_unreachable = true; - break; - } - } + // WHY: a synthesized default is not a value owned by this activation, + // and non-nullable reference results do not have a valid default at all. + // The process-owned tag is independent of the function's result type and + // therefore transports unwind through every Wasm signature truthfully. + let unwind_tag = runtime + .unwind_tag + .expect("fork-path instrumentation requires the linked unwind tag"); + push_instr(out, Instr::Throw(Throw { tag: unwind_tag })); +} + +/// Post-call sequence for call site K, appended to sequence `seq_id`. +/// +/// The one-based call selector is installed before entering the callee. Every +/// fork boundary either is an instrumented local function, whose postamble +/// throws the private unwind tag, or a generated transport helper which +/// converts a normal `STATE_UNWINDING` return to that tag before exposing its +/// results. The function-level catch therefore owns all frame reservation and +/// no source result remains on the operand stack across a state probe here. +fn populate_lexical_call( + local: &mut LocalFunction, + sequence: InstrSeqId, + target: CallTarget, + sig_ty: TypeId, + location: InstrLocId, + arguments: &CallArgMaterialization, +) { + let out = &mut local.block_mut(sequence).instrs; + emit_materialized_call_args(out, arguments); + if matches!(target, CallTarget::Ref) { + push_instr( + out, + Instr::RefCast(walrus::ir::RefCast { + nullable: false, + heap_type: HeapType::Concrete(sig_ty), + }), + ); + } + let instruction = match target { + CallTarget::Direct(func) => Instr::Call(Call { func }), + CallTarget::Indirect { table } => Instr::CallIndirect(CallIndirect { ty: sig_ty, table }), + CallTarget::Ref => Instr::CallRef(walrus::ir::CallRef { ty: sig_ty }), + }; + out.push((instruction, location)); +} + +#[allow(clippy::too_many_arguments)] +fn emit_resume_selected_call( + local: &mut LocalFunction, + sequence: InstrSeqId, + target: CallTarget, + sig_ty: TypeId, + resume_ty: TypeId, + location: InstrLocId, + arguments: &CallArgMaterialization, + runtime: &Runtime, + diagnostic_type: i32, +) { + let resume_peek = runtime + .resume_peek + .expect("replay-routed call requires process resume peek"); + let resume_table = runtime + .resume_table + .expect("replay-routed call requires process resume table"); + let branch_ty = InstrSeqType::MultiValue(resume_ty); + let lexical_sentinel = local.builder_mut().dangling_instr_seq(branch_ty).id(); + let dispatch = local.builder_mut().dangling_instr_seq(branch_ty).id(); + + populate_lexical_call(local, lexical_sentinel, target, sig_ty, location, arguments); + { + let out = &mut local.block_mut(dispatch).instrs; + // `resume_peek` is non-consuming and the journal pins its selection + // until frame_next. Calling it again on this replay-only branch avoids + // adding one live i32 local to every ordinary function activation. + push_instr( + out, + Instr::Const(Const { + value: Value::I32(diagnostic_type), + }), + ); + push_instr(out, Instr::Call(Call { func: resume_peek })); + push_instr( + out, + Instr::CallIndirect(CallIndirect { + ty: resume_ty, + table: resume_table, + }), + ); + } + { + let out = &mut local.block_mut(sequence).instrs; + // The ordinal is diagnostic only. Exact template/event identity picks + // the target; Wasm call_indirect is the authoritative recursive-type + // compatibility check and leaves the event unconsumed on mismatch. + push_instr( + out, + Instr::Const(Const { + value: Value::I32(diagnostic_type), + }), + ); + push_instr(out, Instr::Call(Call { func: resume_peek })); + push_instr( + out, + Instr::Unop(walrus::ir::Unop { + op: UnaryOp::I32Eqz, + }), + ); + push_instr( + out, + Instr::IfElse(IfElse { + consequent: lexical_sentinel, + alternative: dispatch, + }), + ); } - if fallback_unreachable { - push_instr(out, Instr::Unreachable(walrus::ir::Unreachable {})); +} + +#[allow(clippy::too_many_arguments)] +fn emit_replay_routed_call( + local: &mut LocalFunction, + sequence: InstrSeqId, + target: CallTarget, + direct_activation: bool, + sig_ty: TypeId, + resume_ty: TypeId, + location: InstrLocId, + arguments: &CallArgMaterialization, + runtime: &Runtime, +) { + let branch_ty = InstrSeqType::MultiValue(resume_ty); + let normal = local.builder_mut().dangling_instr_seq(branch_ty).id(); + let replay = local.builder_mut().dangling_instr_seq(branch_ty).id(); + populate_lexical_call(local, normal, target, sig_ty, location, arguments); + if direct_activation { + // WHY: adding a no-argument resume thunk in front of every ordinary + // recursive activation doubles native rewind depth. A materialized + // direct callee already owns the selected event; its preamble + // validates activation/function identity through frame_next before + // consuming it. Tail-transparent, indirect, and reference calls still + // require the process router because their lexical target need not be + // the next materialized activation. + debug_assert!(matches!(target, CallTarget::Direct(_))); + populate_lexical_call(local, replay, target, sig_ty, location, arguments); + } else { + emit_resume_selected_call( + local, + replay, + target, + sig_ty, + resume_ty, + location, + arguments, + runtime, + sig_ty.index() as i32, + ); } + let out = &mut local.block_mut(sequence).instrs; + push_instr( + out, + Instr::GlobalGet(GlobalGet { + global: runtime.state_global, + }), + ); + push_instr( + out, + Instr::Const(Const { + value: Value::I32(runtime::STATE_REWINDING), + }), + ); + push_instr( + out, + Instr::Binop(Binop { + op: BinaryOp::I32GeU, + }), + ); + push_instr( + out, + Instr::IfElse(IfElse { + consequent: replay, + alternative: normal, + }), + ); } -/// Post-call sequence for call site K, appended to sequence `seq_id`: -/// - reload spilled args -/// - emit the call instruction -/// - Phase 6e writes (compute catch_region_id / exnref_slot from active -/// in_catch flags) -/// - if state == UNWINDING, write K to frame.call_index and branch to -/// `$unwind_save` +/// Emit one result-typed private-tag boundary around a fork-reaching call. /// -/// Takes `&mut LocalFunction` so Phase 6e can allocate dangling -/// IfElse branches for each handler check. +/// Values carried below the call remain below `result_boundary`; a normal +/// call branches out with only its declared results. A private unwind lands +/// after `catch_boundary`, where the statically known call index selects the +/// frame or live-abort restart without any source-function selector local. +#[allow(clippy::too_many_arguments)] +fn emit_replay_routed_call_with_unwind_boundary( + local: &mut LocalFunction, + sequence: InstrSeqId, + target: CallTarget, + direct_activation: bool, + sig_ty: TypeId, + resume_ty: TypeId, + location: InstrLocId, + arguments: &CallArgMaterialization, + call_idx: u32, + runtime: &Runtime, + _memory: MemoryId, + ptr_ty: ValType, + frame_size: u32, + unwind_save: InstrSeqId, + abort: AbortDispatch, +) { + let result_ty = InstrSeqType::MultiValue(resume_ty); + let result_boundary = local.builder_mut().dangling_instr_seq(result_ty).id(); + let catch_boundary = local + .builder_mut() + .dangling_instr_seq(InstrSeqType::Simple(None)) + .id(); + let call_body = local.builder_mut().dangling_instr_seq(result_ty).id(); + + emit_replay_routed_call( + local, + call_body, + target, + direct_activation, + sig_ty, + resume_ty, + location, + arguments, + runtime, + ); + { + let out = &mut local.block_mut(catch_boundary).instrs; + push_instr( + out, + Instr::TryTable(TryTable { + seq: call_body, + catches: vec![TryTableCatch::Catch { + tag: runtime + .unwind_tag + .expect("fork call boundary requires private unwind tag"), + label: catch_boundary, + }], + }), + ); + // On the normal edge the call's results satisfy the result boundary. + // The catch edge branches to the end of this simple block and enters + // the static unwind handler below with no fabricated result values. + push_instr( + out, + Instr::Br(Br { + block: result_boundary, + }), + ); + } + { + let out = &mut local.block_mut(result_boundary).instrs; + push_instr( + out, + Instr::Block(Block { + seq: catch_boundary, + }), + ); + } + emit_static_call_unwind_handler( + local, + result_boundary, + ptr_ty, + frame_size, + call_idx, + unwind_save, + abort, + ); + // Both handler arms branch away, but make that fact explicit to the + // validator: the result-typed boundary has no fallthrough value on the + // caught edge. + push_instr( + &mut local.block_mut(result_boundary).instrs, + Instr::Unreachable(Unreachable {}), + ); + push_instr( + &mut local.block_mut(sequence).instrs, + Instr::Block(Block { + seq: result_boundary, + }), + ); +} + #[allow(clippy::too_many_arguments)] fn emit_post_call_via_local( local: &mut LocalFunction, @@ -3288,13 +4314,13 @@ fn emit_post_call_via_local( call: &CallSiteInfo, call_idx: usize, arg_materialization: &CallArgMaterialization, - carryovers: &[LocalId], - catch_handlers: &[CatchHandlerInfo], + carryovers: &[TypedSpillLocal], + _catch_handlers: &[CatchHandlerInfo], runtime: &Runtime, memory: MemoryId, ptr_ty: ValType, frame_size: u32, - catch_state_locals: Option, + _catch_state_locals: Option, unwind_save: InstrSeqId, abort: AbortDispatch, ) { @@ -3303,33 +4329,254 @@ fn emit_post_call_via_local( // the stack — matching the original code's expected shape. { let s = &mut local.block_mut(seq_id).instrs; - for &l in carryovers.iter() { - push_instr(s, Instr::LocalGet(LocalGet { local: l })); - } - emit_materialized_call_args(s, arg_materialization); - let call_instr = match call.target { - CallTarget::Direct(func) => Instr::Call(Call { func }), - CallTarget::Indirect { table } => Instr::CallIndirect(CallIndirect { - ty: call.sig_ty, - table, - }), + for &(local, ty) in carryovers { + push_typed_local_get(s, local, ty); + } + } + emit_replay_routed_call_with_unwind_boundary( + local, + seq_id, + call.target, + call.direct_activation, + call.sig_ty, + call.resume_ty + .expect("call site resume type was not assigned"), + call.loc, + arg_materialization, + call_idx as u32, + runtime, + memory, + ptr_ty, + frame_size, + unwind_save, + abort, + ); +} + +#[allow(clippy::too_many_arguments)] +fn emit_resume_thunk( + module: &mut Module, + resumed_function: FunctionId, + runtime: &Runtime, + memory: MemoryId, + ptr_ty: ValType, + frame_size: u32, + scalar_offsets: &[(LocalId, ValType, u32)], + references: &ReferenceFramePlan, + func_ordinal: u32, +) -> FunctionId { + let frame_peek = runtime + .frame_peek + .expect("activation resume thunk requires linked frame peek"); + let codecs = runtime + .reference_codecs + .expect("activation resume thunk requires reference codecs"); + let vector_get = runtime + .reference_vector_get + .expect("activation resume thunk requires recipe-vector lookup"); + let (arguments, results) = { + let function = module.funcs.get(resumed_function); + let FunctionKind::Local(local) = &function.kind else { + unreachable!("resume thunk target must be a local function"); }; - s.push((call_instr, call.loc)); + ( + local.args.clone(), + module.types.get(function.ty()).results().to_vec(), + ) + }; + let scalar_offsets: HashMap = scalar_offsets + .iter() + .map(|&(local, ty, offset)| (local, (ty, offset))) + .collect(); + let reference_slots: HashMap = references + .slots + .iter() + .copied() + .map(|slot| (slot.local, slot)) + .collect(); + let frame = module.locals.add(ptr_ty); + let mut builder = FunctionBuilder::new(&mut module.types, &[], &results); + builder.name(format!("__wpk_fork_resume_{func_ordinal}")); + { + let mut body = builder.func_body(); + let out = body.instrs_mut(); + push_instr(out, ptr_const(ptr_ty, frame_size as i64)); + push_instr(out, Instr::Call(Call { func: frame_peek })); + push_instr(out, Instr::LocalSet(LocalSet { local: frame })); + + for argument in arguments { + let ty = module.locals.get(argument).ty(); + match ty { + ValType::Ref(reference) => { + let slot = reference_slots.get(&argument).unwrap_or_else(|| { + panic!("resume thunk parameter {argument:?} has no activation-owned recipe") + }); + let position = slot.universal_position.unwrap_or_else(|| { + panic!( + "resume thunk parameter {argument:?} does not have a stable recipe-vector position" + ) + }); + push_instr(out, Instr::LocalGet(LocalGet { local: frame })); + push_instr(out, load_i32(memory, REFERENCE_VECTOR_OFFSET)); + push_instr( + out, + Instr::Const(Const { + value: Value::I32(position as i32), + }), + ); + push_instr(out, Instr::Call(Call { func: vector_get })); + push_instr( + out, + Instr::Call(Call { + func: slot.class.decoder(codecs), + }), + ); + push_decoded_reference_narrowing(out, slot.class, reference); + } + scalar => { + let &(saved_ty, offset) = scalar_offsets.get(&argument).unwrap_or_else(|| { + panic!("resume thunk scalar parameter {argument:?} has no frame offset") + }); + debug_assert_eq!(scalar, saved_ty); + push_instr(out, Instr::LocalGet(LocalGet { local: frame })); + push_instr(out, load_scalar(memory, scalar, offset as u64)); + } + } + } + push_instr( + out, + Instr::Call(Call { + func: resumed_function, + }), + ); + } + builder.finish(Vec::new(), &mut module.funcs) +} + +fn emit_resume_catalog(module: &mut Module, thunks: &[ResumeThunk]) { + let size = thunks.len() as u64; + let table = module + .tables + .add_local(false, size, Some(size), RefType::FUNCREF); + module.tables.get_mut(table).name = Some(RESUME_CATALOG_EXPORT.into()); + if !thunks.is_empty() { + module.elements.add( + ElementKind::Active { + table, + offset: walrus::ConstExpr::Value(Value::I32(0)), + }, + ElementItems::Functions(thunks.iter().map(|thunk| thunk.function).collect()), + ); + } + module.exports.add(RESUME_CATALOG_EXPORT, table); + + // The host already validates the exact module template and event target. + // Function type equivalence remains the engine's job at the generated + // call_indirect site, avoiding a second recursive-type implementation. + let mut data = Vec::with_capacity(usize::from(RESUME_CATALOG_HEADER_SIZE) + thunks.len() * 8); + data.extend_from_slice(&RESUME_CATALOG_MAGIC); + data.extend_from_slice(&RESUME_CATALOG_VERSION.to_le_bytes()); + data.extend_from_slice(&RESUME_CATALOG_HEADER_SIZE.to_le_bytes()); + data.extend_from_slice(&(thunks.len() as u32).to_le_bytes()); + for (slot, thunk) in thunks.iter().enumerate() { + debug_assert_eq!(thunk.func_ordinal, slot as u32); + data.extend_from_slice(&thunk.func_ordinal.to_le_bytes()); + data.extend_from_slice(&(slot as u32).to_le_bytes()); + } + module.customs.add(RawCustomSection { + name: RESUME_CATALOG_SECTION.into(), + data, + }); +} + +fn exported_function(module: &Module, name: &str) -> Option { + module.exports.iter().find_map(|export| { + if export.name != name { + return None; + } + match export.item { + ExportItem::Function(function) => Some(function), + _ => None, + } + }) +} + +fn exported_table(module: &Module, name: &str) -> Option { + module.exports.iter().find_map(|export| { + if export.name != name { + return None; + } + match export.item { + ExportItem::Table(table) => Some(table), + _ => None, + } + }) +} + +fn emit_fixed_resume_boundaries(module: &mut Module, runtime: &Runtime) { + if runtime.resume_peek.is_none() || runtime.resume_table.is_none() { + return; } - emit_call_index_store_and_unwind_branch( - local, - seq_id, - runtime, - memory, - ptr_ty, - frame_size, - call_idx as u32, - unwind_save, - catch_handlers, - catch_state_locals, - abort, - ); + if let Some(start) = exported_function(module, "_start") { + let start_ty = module.funcs.get(start).ty(); + let signature = module.types.get(start_ty); + if signature.params().is_empty() && signature.results().is_empty() { + let resume_ty = module.types.add(&[], &[]); + let mut builder = FunctionBuilder::new(&mut module.types, &[], &[]); + builder.name(RESUME_START_EXPORT.into()); + let wrapper = builder.finish(Vec::new(), &mut module.funcs); + let entry = local_mut(module, wrapper).entry_block(); + emit_resume_selected_call( + local_mut(module, wrapper), + entry, + CallTarget::Direct(start), + start_ty, + resume_ty, + InstrLocId::default(), + &CallArgMaterialization::Spill { + locals: Vec::new(), + types: Vec::new(), + }, + runtime, + 0, + ); + module.exports.add(RESUME_START_EXPORT, wrapper); + } + } + + if let Some(function_table) = exported_table(module, "__indirect_function_table") { + let ptr_ty = runtime.buf_type; + let thread_ty = module.types.add(&[ptr_ty], &[ptr_ty]); + let resume_ty = module.types.add(&[], &[ptr_ty]); + let table_index = module.locals.add(ValType::I32); + let argument = module.locals.add(ptr_ty); + let mut builder = + FunctionBuilder::new(&mut module.types, &[ValType::I32, ptr_ty], &[ptr_ty]); + builder.name(RESUME_THREAD_EXPORT.into()); + let wrapper = builder.finish(vec![table_index, argument], &mut module.funcs); + let entry = local_mut(module, wrapper).entry_block(); + emit_resume_selected_call( + local_mut(module, wrapper), + entry, + CallTarget::Indirect { + table: function_table, + }, + thread_ty, + resume_ty, + InstrLocId::default(), + &CallArgMaterialization::Spill { + // call_indirect consumes function parameters first and its + // table index last; the public wrapper keeps the ergonomic + // host ABI `(table_index, arg)`. + locals: vec![argument, table_index], + types: vec![ptr_ty, ValType::I32], + }, + runtime, + 0, + ); + module.exports.add(RESUME_THREAD_EXPORT, wrapper); + } } // ---------------------------------------------------------------------- @@ -3405,6 +4652,30 @@ fn collect_user_locals(module: &Module, func_id: FunctionId) -> Vec<(LocalId, Va .collect() } +fn append_resume_parameter_references( + module: &Module, + func_id: FunctionId, + per_call_references: &mut [Vec<(LocalId, RefType)>], +) { + let FunctionKind::Local(local) = &module.funcs.get(func_id).kind else { + return; + }; + let params = module.types.get(module.funcs.get(func_id).ty()).params(); + debug_assert_eq!(local.args.len(), params.len()); + for (&argument, &ty) in local.args.iter().zip(params) { + let Some(reference) = supported_reference(ty) else { + continue; + }; + // WHY: a resume thunk has no parameters so callers with a different + // lexical signature can bypass eliminated tail frames. Even a dead + // non-nullable parameter needs a valid value to enter the original + // function, whose preamble then consumes and restores this frame. + for references in per_call_references.iter_mut() { + references.push((argument, reference)); + } + } +} + // ---------------------------------------------------------------------- // Nested-seq traversal // ---------------------------------------------------------------------- @@ -3442,6 +4713,152 @@ fn is_scalar(ty: ValType) -> bool { !matches!(ty, ValType::Ref(_)) } +fn supported_reference(ty: ValType) -> Option { + match ty { + ValType::Ref(reference) => Some(reference), + _ => None, + } +} + +fn spill_storage_type(ty: ValType) -> ValType { + match supported_reference(ty) { + Some(reference) if !reference.nullable => { + let mut storage = reference; + storage.nullable = true; + ValType::Ref(storage) + } + _ => ty, + } +} + +fn push_typed_local_get(out: &mut Vec<(Instr, InstrLocId)>, local: LocalId, expected: ValType) { + push_instr(out, Instr::LocalGet(LocalGet { local })); + if matches!(expected, ValType::Ref(reference) if !reference.nullable) { + push_instr(out, Instr::RefAsNonNull(RefAsNonNull {})); + } +} + +fn push_decoded_reference_narrowing( + out: &mut Vec<(Instr, InstrLocId)>, + class: RefClass, + expected: RefType, +) { + let broad = class.nullable_type(); + if expected.heap_type != broad.heap_type { + push_instr( + out, + Instr::RefCast(walrus::ir::RefCast { + nullable: expected.nullable, + heap_type: expected.heap_type, + }), + ); + } else if !expected.nullable { + push_instr(out, Instr::RefAsNonNull(RefAsNonNull {})); + } +} + +fn plan_reference_frame( + module: &Module, + analysis: &FunctionReferenceAnalysis, + mut per_call_synthetic: Vec>, +) -> ReferenceFramePlan { + debug_assert_eq!(analysis.call_sites.len(), per_call_synthetic.len()); + let call_count = analysis.call_sites.len(); + let mut per_call_refs: Vec> = vec![BTreeMap::new(); call_count]; + let mut null_locals_by_call = vec![Vec::new(); call_count]; + + for (call_idx, site) in analysis.call_sites.iter().enumerate() { + if site.reachable { + // The replayed callee can still throw after the child-side fork + // return. Preserve references used by either normal continuation + // or an exceptional successor; definitely-null cleanup locals + // remain recipe-free below. + for &local in &site.live_ref_locals_on_any_successor { + let ty = analysis.reference_locals[&local]; + match site + .local_nullability_before_call + .get(&local) + .copied() + .unwrap_or(ReferenceNullability::MaybeNonNull) + { + ReferenceNullability::DefinitelyNull if ty.nullable => { + null_locals_by_call[call_idx].push((local, ty)); + } + ReferenceNullability::DefinitelyNull | ReferenceNullability::MaybeNonNull => { + per_call_refs[call_idx].insert(local, ty); + } + } + } + } + for (local, ty) in per_call_synthetic[call_idx].drain(..) { + per_call_refs[call_idx] + .entry(local) + .and_modify(|existing| { + debug_assert_eq!(RefClass::of(module, *existing), RefClass::of(module, ty)); + existing.nullable &= ty.nullable; + }) + .or_insert(ty); + } + } + + let mut union = BTreeMap::::new(); + let mut occurrence_count = BTreeMap::::new(); + for refs in &per_call_refs { + for (&local, &ty) in refs { + union + .entry(local) + .and_modify(|existing| existing.nullable &= ty.nullable) + .or_insert(ty); + *occurrence_count.entry(local).or_default() += 1; + } + } + + let mut slots = Vec::with_capacity(union.len()); + let mut slot_by_local = BTreeMap::::new(); + // Values present at every landing form a stable vector prefix. Function + // reference parameters are deliberately added to every landing, allowing + // a no-parameter resume thunk to decode them without a synthetic local or + // a call-index dispatch. + let mut ordered: Vec<_> = union.into_iter().collect(); + ordered.sort_by_key(|(local, _)| { + ( + occurrence_count.get(local).copied().unwrap_or(0) != call_count, + *local, + ) + }); + let universal_count = ordered + .iter() + .take_while(|(local, _)| occurrence_count.get(local).copied().unwrap_or(0) == call_count) + .count(); + for (position, (local, ty)) in ordered.into_iter().enumerate() { + let index = slots.len(); + slots.push(ReferenceFrameSlot { + local, + ty, + class: RefClass::of(module, ty), + universal_position: (position < universal_count).then_some(position as u32), + }); + slot_by_local.insert(local, index); + } + let slots_by_call = per_call_refs + .into_iter() + .map(|refs| { + let mut slots: Vec<_> = refs + .into_keys() + .map(|local| slot_by_local[&local]) + .collect(); + slots.sort_unstable(); + slots + }) + .collect(); + + ReferenceFramePlan { + slots, + slots_by_call, + null_locals_by_call, + } +} + fn scalar_size(ty: ValType) -> u32 { match ty { ValType::I32 | ValType::F32 => 4, @@ -3455,28 +4872,6 @@ fn natural_align(ty: ValType) -> u32 { scalar_size(ty) } -fn default_for_type(ty: ValType) -> Option { - Some(match ty { - ValType::I32 => Instr::Const(Const { - value: Value::I32(0), - }), - ValType::I64 => Instr::Const(Const { - value: Value::I64(0), - }), - ValType::F32 => Instr::Const(Const { - value: Value::F32(0.0), - }), - ValType::F64 => Instr::Const(Const { - value: Value::F64(0.0), - }), - ValType::V128 => Instr::Const(Const { - value: Value::V128(0), - }), - ValType::Ref(rt) if rt.nullable => Instr::RefNull(RefNull { ty: rt }), - ValType::Ref(_) => return None, - }) -} - fn load_i32(memory: MemoryId, offset: u64) -> Instr { Instr::Load(walrus::ir::Load { memory, @@ -3631,183 +5026,34 @@ fn push_current_call_index( } // ---------------------------------------------------------------------- -// Phase 4f — ref-typed local spilling via aux tables +// Tagged-catch region planning // ---------------------------------------------------------------------- -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum RefClass { - Funcref, - Externref, - Exnref, -} - -#[derive(Debug, Clone, Copy)] -pub struct RefLocalSlot { - pub local: LocalId, - pub class: RefClass, - pub slot: u32, -} - -#[derive(Debug, Clone, Copy, Default)] -pub struct AuxTables { - pub funcref: Option, - pub externref: Option, - pub exnref: Option, -} - -impl AuxTables { - pub fn table_for(&self, class: RefClass) -> Option { - match class { - RefClass::Funcref => self.funcref, - RefClass::Externref => self.externref, - RefClass::Exnref => self.exnref, - } - } -} - -fn classify_ref(rt: RefType) -> Option { - if !rt.nullable { - return None; - } - match rt.heap_type { - HeapType::Abstract(AbstractHeapType::Func) => Some(RefClass::Funcref), - HeapType::Abstract(AbstractHeapType::NoFunc) => Some(RefClass::Funcref), - HeapType::Abstract(AbstractHeapType::Extern) => Some(RefClass::Externref), - HeapType::Abstract(AbstractHeapType::NoExtern) => Some(RefClass::Externref), - HeapType::Abstract(AbstractHeapType::Exn) => Some(RefClass::Exnref), - HeapType::Abstract(AbstractHeapType::NoExn) => Some(RefClass::Exnref), - _ => None, - } -} - -fn plan_and_inject_aux_tables( - module: &mut Module, +fn plan_catch_regions( + module: &Module, targets: &[FunctionId], -) -> ( - AuxTables, - HashMap>, - HashMap>, -) { - let mut funcref_cursor: u32 = 0; - let mut externref_cursor: u32 = 0; - let mut exnref_cursor: u32 = 0; - - let mut plan: HashMap> = HashMap::new(); - - for &id in targets { - let mut per_func: Vec = Vec::new(); - for (local, ty) in collect_user_locals(module, id) { - let rt = match ty { - ValType::Ref(rt) => rt, - _ => continue, - }; - let class = classify_ref(rt).unwrap_or_else(|| { - let name = module.funcs.get(id).name.as_deref().unwrap_or(""); - panic!( - "fork-instrument 4f: function `{name}` has a ref-typed local of \ - type {rt:?} which is not yet supported (non-nullable or non-abstract \ - ref).", - ) - }); - let slot = match class { - RefClass::Funcref => { - let s = funcref_cursor; - funcref_cursor += 1; - s - } - RefClass::Externref => { - let s = externref_cursor; - externref_cursor += 1; - s - } - RefClass::Exnref => { - let s = exnref_cursor; - exnref_cursor += 1; - s - } - }; - per_func.push(RefLocalSlot { local, class, slot }); - } - if !per_func.is_empty() { - plan.insert(id, per_func); - } - } - +) -> HashMap> { let mut catch_plans: HashMap> = HashMap::new(); for &id in targets { let bodies = discover_try_table_bodies(module, id); let mut per_func: Vec = Vec::with_capacity(bodies.len()); for (lex_idx, body_seq) in bodies.into_iter().enumerate() { - let slot = exnref_cursor; - exnref_cursor += 1; per_func.push(CatchRegionPlan { body_seq, catch_region_id: (lex_idx as u32) + 1, - exnref_slot: slot, }); } if !per_func.is_empty() { catch_plans.insert(id, per_func); } } - - let funcref = if funcref_cursor > 0 { - let id = module.tables.add_local( - false, - funcref_cursor as u64, - Some(funcref_cursor as u64), - RefType::FUNCREF, - ); - module.tables.get_mut(id).name = Some("_wpk_fork_funcref_stash".into()); - Some(id) - } else { - None - }; - let externref = if externref_cursor > 0 { - let id = module.tables.add_local( - false, - externref_cursor as u64, - Some(externref_cursor as u64), - RefType::EXTERNREF, - ); - module.tables.get_mut(id).name = Some("_wpk_fork_externref_stash".into()); - Some(id) - } else { - None - }; - let exnref = if exnref_cursor > 0 { - let exn_rt = RefType { - nullable: true, - heap_type: HeapType::Abstract(AbstractHeapType::Exn), - }; - let id = module.tables.add_local( - false, - exnref_cursor as u64, - Some(exnref_cursor as u64), - exn_rt, - ); - module.tables.get_mut(id).name = Some("_wpk_fork_exnref_stash".into()); - Some(id) - } else { - None - }; - - ( - AuxTables { - funcref, - externref, - exnref, - }, - plan, - catch_plans, - ) + catch_plans } #[derive(Debug, Clone, Copy)] pub struct CatchRegionPlan { pub body_seq: InstrSeqId, pub catch_region_id: u32, - pub exnref_slot: u32, } fn discover_try_table_bodies(module: &Module, func_id: FunctionId) -> Vec { @@ -3831,33 +5077,58 @@ fn visit_try_tables(f: &LocalFunction, seq: InstrSeqId, out: &mut Vec bool { + matches!(self, Self::Plain | Self::AllPlain) + } + + fn is_ref(self) -> bool { + matches!(self, Self::Ref | Self::AllRef) + } +} + +/// Describes one catch clause in a fork-path try_table. #[derive(Debug, Clone)] pub struct PlainCatchArm { - /// Index of this arm within its try_table's `catches` list. Stage 2 - /// writes this value to the region's frame-backed `active_arm` - /// local; the rewind path reads it to select which - /// `throw $tag (operands)` to emit. Combined with the function's - /// `catch_region_id` (tracked by `CatchRegionPlan`), the pair is - /// unique within the function — no module-wide arm_id is needed. + /// Index of this arm within its try_table's `catches` list. The emitted + /// state assigns the `(catch_region_id, arm_idx)` pair one non-zero + /// function-local selector stored directly in frame word +8. pub arm_idx: u32, - /// Tag this arm catches. - pub tag: TagId, + /// Whether normal handler entry receives only the tag payload or the tag + /// payload followed by an instance-local exnref. + pub kind: TaggedCatchKind, + /// Tag this arm catches, or `None` for CatchAll/CatchAllRef. + pub tag: Option, /// Label the arm branches to on catch (target block id). pub label: InstrSeqId, /// Tag's operand types (matches the params of the type that /// `module.tags.get(tag).ty()` references). Cached at discovery /// time so we don't re-look-up on emission. pub operand_tys: Vec, + /// JavaScript cannot inspect `v128`, `exnref`, or GC/reference payloads. + /// Capture the entire exception as one exnref recipe and replay it with + /// `throw_ref` instead of serializing those operands independently. + pub uses_exception_recipe: bool, } /// Stage 1 (B1) — for each try_table in `func_id`, returns -/// `(body_seq, plain_catch_arms)` where `plain_catch_arms` lists -/// every plain `Catch { tag, label }` clause. Following Phase 6's -/// pattern: catch_ref / catch_all_ref clauses are skipped (Phase 6 -/// territory); plain catch is enumerated unfiltered. +/// `(body_seq, catch_arms)` where every tagged and catch-all arm is represented. +/// CatchAll is retargeted through CatchAllRef so an arbitrary Wasm/JSTag/raw +/// exception has the same positive broker/recipe path as an explicit +/// CatchAllRef. Tagged payloads that JavaScript cannot inspect are likewise +/// captured through CatchRef and owned by one exnref recipe. /// /// Function-level filtering happens at the call site (caller passes /// only fork-path `FunctionId`s, mirroring `discover_try_table_bodies`). @@ -3887,22 +5158,36 @@ fn visit_for_plain_catch( if let Instr::TryTable(tt) = instr { let mut arms: Vec = Vec::new(); for (i, c) in tt.catches.iter().enumerate() { - let (tag, label) = match c { - TryTableCatch::Catch { tag, label } => (*tag, *label), - _ => continue, // CatchRef / CatchAllRef: handled by Phase 6. - // CatchAll: unsupported today; not in B1 scope - // (no tag → no operand_tys to save). + let (kind, tag, label) = match c { + TryTableCatch::Catch { tag, label } => { + (TaggedCatchKind::Plain, Some(*tag), *label) + } + TryTableCatch::CatchRef { tag, label } => { + (TaggedCatchKind::Ref, Some(*tag), *label) + } + TryTableCatch::CatchAll { label } => (TaggedCatchKind::AllPlain, None, *label), + TryTableCatch::CatchAllRef { label } => (TaggedCatchKind::AllRef, None, *label), }; - let operand_tys: Vec = module - .types - .get(module.tags.get(tag).ty()) - .params() - .to_vec(); + let operand_tys: Vec = tag + .map(|tag| { + module + .types + .get(module.tags.get(tag).ty()) + .params() + .to_vec() + }) + .unwrap_or_default(); + let uses_exception_recipe = tag.is_none() + || operand_tys + .iter() + .any(|ty| matches!(ty, ValType::Ref(_) | ValType::V128)); arms.push(PlainCatchArm { arm_idx: i as u32, + kind, tag, label, operand_tys, + uses_exception_recipe, }); } if !arms.is_empty() { @@ -3915,45 +5200,20 @@ fn visit_for_plain_catch( } } -/// Module-wide static plain-catch plan. -/// -/// Stage 2 (Task 2.1) adds `b2_carveout`: functions whose plain-catch -/// arms include unsupported operand types (e.g., ref-typed) land here -/// instead of `per_function`. Stage 2 emission tasks check this set -/// and skip plain-catch instrumentation for carved-out functions — -/// falling back to today's behavior (Phase 6 catch_ref still works, -/// plain-catch fork remains unsupported for those specific shapes). +/// Module-wide static tagged-catch plan. #[derive(Debug, Clone, Default)] pub struct PlainCatchPlan { /// Per-function per-region arm metadata. Outer Vec /// parallels `discover_plain_catch_arms`'s return shape (one /// entry per try_table that has at least one plain-catch arm). pub per_function: std::collections::HashMap)>>, - /// Stage 2 (B1): functions whose plain-catch arms include - /// unsupported operand types (e.g., ref-typed). For these - /// functions, B1 emission tasks fall back to today's behavior - /// (Phase 6 doesn't intercept plain-catch arms — the function - /// works for catch_ref but is unsupported for plain-catch fork). - pub b2_carveout: std::collections::HashSet, } -/// Discover supported plain-catch arms across all fork-path functions. -/// -/// Operand types are restricted to scalars (i32/i64/f32/f64/v128). -/// Ref-typed operands (externref/funcref/exnref/GC refs) require -/// auxiliary-table spilling and remain a conservative carve-out. -/// -/// Stage 2 (Task 2.1) detects ref-typed payloads here and routes the -/// affected function to `PlainCatchPlan.b2_carveout` instead of -/// `per_function`. Stage 2 emission tasks check the carve-out set -/// and skip plain-catch instrumentation for those functions. -/// -/// The carve-out is whole-function: if any arm in any region of a -/// function has a ref-typed operand, the entire function's -/// plain-catch instrumentation is skipped. We don't selectively drop -/// arms because Task 2.3's rewind dispatcher needs the whole -/// region's arm set or none. +/// Discover every tagged and catch-all clause across fork-path functions. /// +/// Every arm receives either exact scalar payload ownership or one complete +/// exception recipe. Silently omitting an arm would be an instrumenter bug +/// that surfaced only in a fresh child. pub fn plan_plain_catches(module: &Module, targets: &[FunctionId]) -> PlainCatchPlan { let mut plan = PlainCatchPlan::default(); for &fid in targets { @@ -3961,34 +5221,6 @@ pub fn plan_plain_catches(module: &Module, targets: &[FunctionId]) -> PlainCatch if arms_per_region.is_empty() { continue; } - // Stage 2 (B1): detect unsupported operand types and carve out - // the entire function. We can't selectively drop just the bad - // arms because replay needs a complete region-wide arm set. - let has_unsupported = arms_per_region.iter().any(|(_, arms)| { - arms.iter() - .any(|arm| arm.operand_tys.iter().any(|t| matches!(t, ValType::Ref(_)))) - }); - // Stage 2 (B1) Task 2.4: multi-target plain-catch guard. - // A try_table whose plain-catch arms branch to *different* - // labels has not been verified end-to-end. Per-arm capture - // blocks each branch to their own original target label, and - // the rewind dispatcher's re-throw routes through the - // try_table's catch clauses to reach those captures, so in - // principle multi-target should work — but until a real port - // exercises it, conservatively treat such functions as - // b2_carveout. Single-target multi-arm (multiple catches all - // pointing at the same label) remains supported. - let has_multi_target = arms_per_region.iter().any(|(_, arms)| { - if arms.len() <= 1 { - return false; - } - let first = arms[0].label; - arms.iter().any(|arm| arm.label != first) - }); - if has_unsupported || has_multi_target { - plan.b2_carveout.insert(fid); - continue; - } plan.per_function.insert(fid, arms_per_region); } plan @@ -3998,7 +5230,21 @@ pub fn plan_plain_catches(module: &Module, targets: &[FunctionId]) -> PlainCatch #[derive(Debug, Clone)] struct PlainCatchArmState { arm: PlainCatchArm, + /// Non-zero function-local identity for this exact `(region, arm)` pair. + /// + /// WHY: frame word +8 is copied to a fresh child. Keeping the selector in + /// that existing header word avoids both a frame-backed `active_arm` local + /// and any module-instance auxiliary state. + selector: u32, + /// Typed per-region union used to forward the original tag payload and, + /// for scalar arms, back the selector-overlaid frame bytes. Recipe-backed + /// arms do not serialize these values independently because the exception + /// codec owns their payload atomically. operand_locals: Vec, + /// Function-shared CatchRef forwarding scratch for scalar arms, or + /// region-shared activation state for recipe-backed exceptions. Only the + /// latter is added to the linked frame's reference plan. + captured_exnref: Option, } /// Activation-owned state for one try_table with plain catches. @@ -4008,52 +5254,351 @@ struct PlainCatchArmState { #[derive(Debug, Clone)] struct PlainCatchRegionState { body_seq: InstrSeqId, - active_arm: LocalId, + /// Function-wide retained exception selected by frame word +8. + /// + /// WHY: only one catch selector is activation-live at a time. A retained + /// complete-exception recipe that cannot be named by that selector is not + /// replay state; user-visible exceptions that remain live have separate + /// typed local/operand ownership. Sharing this slot across regions keeps + /// static catch-region count out of the native activation footprint and + /// out of the process reference vector. + retained_recipe_exnref: Option, + /// Arms in a region receive one contiguous selector interval, allowing + /// the rewind guard to recognize the region with two unsigned compares. + first_selector: u32, + last_selector: u32, arms: Vec, } -fn allocate_plain_catch_state( - module: &mut Module, - plain_catches: &[(InstrSeqId, Vec)], -) -> Vec { - plain_catches - .iter() - .map(|(body_seq, arms)| PlainCatchRegionState { - body_seq: *body_seq, - active_arm: module.locals.add(ValType::I32), - arms: arms - .iter() - .cloned() - .map(|arm| { - let operand_locals = arm - .operand_tys - .iter() - .map(|&ty| module.locals.add(ty)) - .collect(); - PlainCatchArmState { - arm, - operand_locals, +fn allocate_plain_catch_state( + module: &mut Module, + plain_catches: &[(InstrSeqId, Vec)], +) -> Vec { + let forwarding_exnref_scratch = plain_catches + .iter() + .flat_map(|(_, arms)| arms) + .any(|arm| arm.kind.is_ref() && !arm.uses_exception_recipe) + .then(|| { + module.locals.add(ValType::Ref(RefType { + nullable: true, + heap_type: HeapType::Abstract(AbstractHeapType::Exn), + })) + }); + let retained_recipe_exnref = plain_catches + .iter() + .flat_map(|(_, arms)| arms) + .any(|arm| arm.uses_exception_recipe) + .then(|| { + module.locals.add(ValType::Ref(RefType { + nullable: true, + heap_type: HeapType::Abstract(AbstractHeapType::Exn), + })) + }); + let mut next_selector = 1u32; + let mut regions = Vec::with_capacity(plain_catches.len()); + let mut operand_pools: Vec<(ValType, Vec)> = Vec::new(); + for (body_seq, arms) in plain_catches { + debug_assert!(!arms.is_empty()); + let first_selector = next_selector; + let mut arm_states = Vec::with_capacity(arms.len()); + for arm in arms.iter().cloned() { + let selector = next_selector; + next_selector = next_selector + .checked_add(1) + .expect("a Wasm function cannot contain 2^32 catch arms"); + let mut uses_by_type: Vec<(ValType, usize)> = Vec::new(); + let mut operand_locals = Vec::with_capacity(arm.operand_tys.len()); + for &ty in &arm.operand_tys { + let storage_ty = spill_storage_type(ty); + let ordinal = match uses_by_type + .iter_mut() + .find(|(candidate, _)| *candidate == storage_ty) + { + Some((_, next)) => { + let ordinal = *next; + *next += 1; + ordinal + } + None => { + uses_by_type.push((storage_ty, 1)); + 0 + } + }; + let pool_index = operand_pools + .iter() + .position(|(candidate, _)| *candidate == storage_ty) + .unwrap_or_else(|| { + operand_pools.push((storage_ty, Vec::new())); + operand_pools.len() - 1 + }); + let pool = &mut operand_pools[pool_index].1; + if pool.len() == ordinal { + pool.push(module.locals.add(storage_ty)); + } + // WHY: catch capture publishes one dynamically latest selector + // per activation, and the capture tail contains no call or + // throw between overwriting this typed scratch and publishing + // that selector. A function-wide typed union therefore cannot + // be observed half-updated by fork. Guest-visible values from + // earlier handlers have ordinary local/operand liveness + // ownership; this scratch exists only to rethrow the selected + // catch. Recursive activations still receive distinct native + // local tuples. + operand_locals.push(pool[ordinal]); + } + let captured_exnref = if arm.uses_exception_recipe { + // The single latest-catch selector owns this value. An older + // synthetic recipe cannot be replayed after another catch + // supersedes its selector; any guest-visible exception that + // remains live is captured independently by typed liveness. + retained_recipe_exnref + } else if arm.kind.is_ref() { + // WHY: a scalar CatchRef needs this local only to move the + // non-null exception past its scalar payload. The generated + // capture tail contains no call or throw and clears the local + // before entering user code, so mutually exclusive arms can + // share one function-local scratch without retaining a GC + // root or increasing every activation by one exnref per arm. + forwarding_exnref_scratch + } else { + None + }; + arm_states.push(PlainCatchArmState { + arm, + selector, + operand_locals, + captured_exnref, + }); + } + regions.push(PlainCatchRegionState { + body_seq: *body_seq, + retained_recipe_exnref, + first_selector, + last_selector: next_selector - 1, + arms: arm_states, + }); + } + regions +} + +#[derive(Debug, Clone)] +struct PlainCatchScalarArmFrame { + selector: u32, + fields: Vec<(LocalId, ValType, u32)>, +} + +/// One overlaid scalar payload range shared by every catch arm in a function. +/// +/// Only one `(region, arm)` selector can own a continuation landing. Giving +/// each arm offsets relative to the same `start` therefore preserves the +/// selected payload in `max(arm_size)` bytes instead of summing all static +/// arms. Save/restore dispatch below makes the aliasing explicit and prevents +/// inactive locals from overwriting the active arm. +#[derive(Debug, Clone, Default)] +struct PlainCatchScalarFrame { + arms: Vec, + byte_len: u32, +} + +impl PlainCatchScalarFrame { + fn frame_end(&self, start: u32) -> u32 { + start + .checked_add(self.byte_len) + .expect("catch payload frame exceeds the 32-bit continuation format") + } +} + +fn plan_plain_catch_scalar_frame( + regions: &[PlainCatchRegionState], + start: u32, +) -> PlainCatchScalarFrame { + let mut plan = PlainCatchScalarFrame::default(); + for region in regions { + for arm in ®ion.arms { + let mut relative = 0u32; + let mut fields = Vec::new(); + if !arm.arm.uses_exception_recipe { + for (&local, &ty) in arm.operand_locals.iter().zip(&arm.arm.operand_tys) { + fields.push(( + local, + ty, + start + .checked_add(relative) + .expect("catch payload offset exceeds the frame format"), + )); + relative = relative + .checked_add(scalar_size(ty)) + .expect("catch payload exceeds the frame format"); + } + } + plan.byte_len = plan.byte_len.max(relative); + plan.arms.push(PlainCatchScalarArmFrame { + selector: arm.selector, + fields, + }); + } + } + plan +} + +#[derive(Debug, Clone, Copy)] +enum PlainCatchScalarIo { + Save, + Restore, +} + +/// Build a selector-guarded frame I/O tree for the overlaid catch payload. +/// +/// Inactive arms intentionally perform no memory access. A non-zero selector +/// not present in the static function plan is corrupt continuation state and +/// traps before replay can branch into user code. +fn build_plain_catch_scalar_dispatch( + local: &mut LocalFunction, + runtime: &Runtime, + memory: MemoryId, + ptr_ty: ValType, + catch_selector: LocalId, + plan: &PlainCatchScalarFrame, + io: PlainCatchScalarIo, +) -> Option { + if plan.arms.is_empty() { + return None; + } + + let empty = local + .builder_mut() + .dangling_instr_seq(InstrSeqType::Simple(None)) + .id(); + let invalid = local + .builder_mut() + .dangling_instr_seq(InstrSeqType::Simple(None)) + .id(); + push_instr( + &mut local.block_mut(invalid).instrs, + Instr::Unreachable(Unreachable {}), + ); + + // Selector zero is the common path outside a catch. Every other value + // must match an exact static arm below. + let mut chain = local + .builder_mut() + .dangling_instr_seq(InstrSeqType::Simple(None)) + .id(); + { + let out = &mut local.block_mut(chain).instrs; + push_instr( + out, + Instr::LocalGet(LocalGet { + local: catch_selector, + }), + ); + push_instr( + out, + Instr::Unop(walrus::ir::Unop { + op: UnaryOp::I32Eqz, + }), + ); + push_instr( + out, + Instr::IfElse(IfElse { + consequent: empty, + alternative: invalid, + }), + ); + } + + for arm in plan.arms.iter().rev() { + let action = local + .builder_mut() + .dangling_instr_seq(InstrSeqType::Simple(None)) + .id(); + { + let out = &mut local.block_mut(action).instrs; + for &(field, ty, offset) in &arm.fields { + match io { + PlainCatchScalarIo::Save => { + push_current_frame_ptr(out, runtime, memory, ptr_ty); + push_instr(out, Instr::LocalGet(LocalGet { local: field })); + push_instr(out, store_scalar(memory, ty, offset as u64)); } - }) - .collect(), - }) - .collect() + PlainCatchScalarIo::Restore => { + push_current_frame_ptr(out, runtime, memory, ptr_ty); + push_instr(out, load_scalar(memory, ty, offset as u64)); + push_instr(out, Instr::LocalSet(LocalSet { local: field })); + } + } + } + } + + let select = local + .builder_mut() + .dangling_instr_seq(InstrSeqType::Simple(None)) + .id(); + { + let out = &mut local.block_mut(select).instrs; + push_instr( + out, + Instr::LocalGet(LocalGet { + local: catch_selector, + }), + ); + push_instr( + out, + Instr::Const(Const { + value: Value::I32(arm.selector as i32), + }), + ); + push_instr( + out, + Instr::Binop(Binop { + op: BinaryOp::I32Eq, + }), + ); + push_instr( + out, + Instr::IfElse(IfElse { + consequent: action, + alternative: chain, + }), + ); + } + chain = select; + } + Some(chain) } -fn append_plain_catch_frame_scalars( - frame_scalars: &mut Vec<(LocalId, ValType)>, +fn append_plain_catch_frame_references( + per_call_references: &mut [Vec<(LocalId, RefType)>], regions: &[PlainCatchRegionState], ) { - for region in regions { - frame_scalars.push((region.active_arm, ValType::I32)); - for arm in ®ion.arms { - frame_scalars.extend( - arm.operand_locals - .iter() - .copied() - .zip(arm.arm.operand_tys.iter().copied()), - ); - } + let exnref = RefType { + nullable: true, + heap_type: HeapType::Abstract(AbstractHeapType::Exn), + }; + let Some(exception) = regions + .iter() + .find_map(|region| region.retained_recipe_exnref) + else { + return; + }; + debug_assert!( + regions + .iter() + .all(|region| region.retained_recipe_exnref == Some(exception)) + ); + debug_assert!( + regions + .iter() + .flat_map(|region| ®ion.arms) + .filter(|arm| arm.arm.uses_exception_recipe) + .all(|arm| arm.captured_exnref == Some(exception)) + ); + // Any call can be reached while a handler activation is live. Encoding + // null when no recipe catch is selected is the deterministic zero-recipe + // fast path; the selected arm's non-null exception is activation state and + // appears exactly once in each call-specific reference vector. + for references in per_call_references.iter_mut() { + references.push((exception, exnref)); } } @@ -4065,32 +5610,21 @@ fn append_plain_catch_frame_scalars( /// Phase 6c (extended by B1 Stage 2 Task 2.3) — prepend a rewind-throw /// stub at the top of each fork-path try_table body. /// -/// On REWIND, when `catch_region_id_local == K`, the stub re-enters the -/// try_table's catch dispatch so the original handler observes the same -/// exception that was caught pre-fork. The shape depends on what kind -/// of catch was originally taken: +/// On REWIND, when `catch_selector_local` falls in this region's selector +/// interval, the stub re-enters the try_table's catch dispatch so the original +/// handler observes the same exception that was caught pre-fork. /// -/// Plain catches restore a frame-backed active-arm local and operand locals, -/// then throw the matching tag. A catch_ref capture writes active-arm `-1`, -/// so a mixed region falls through to the exnref `throw_ref` path without -/// treating stale auxiliary-table contents as mode state. +/// Both Catch and CatchRef restore the exact selector from frame word +8 and +/// scalar tag operands from the overlaid payload range, then throw the matching +/// tag. The original CatchRef clause creates a fresh exnref in the child. fn inject_rewind_throw_stubs( module: &mut Module, func_id: FunctionId, runtime: &Runtime, - catch_region_id_local: LocalId, - aux_tables: &AuxTables, + catch_selector_local: LocalId, catch_plan: &[CatchRegionPlan], plain_catches: &[PlainCatchRegionState], ) { - let exnref_table = match aux_tables.exnref { - Some(t) => t, - None => { - debug_assert!(catch_plan.is_empty()); - return; - } - }; - let plain_lookup: HashMap = plain_catches .iter() .map(|region| (region.body_seq, region)) @@ -4098,40 +5632,26 @@ fn inject_rewind_throw_stubs( for plan in catch_plan { let body_seq_id = plan.body_seq; - let region_id = plan.catch_region_id; - let slot = plan.exnref_slot; - let plain_region = plain_lookup.get(&body_seq_id).copied(); - - // Build the inner "catch_ref path" sequence (Phase 6's existing - // logic). Always emitted — used either as the only path or as - // the fallback when no exact plain arm is active. - let throw_ref_seq_id = { + let Some(region) = plain_lookup.get(&body_seq_id).copied() else { + continue; + }; + + // An unknown arm means the continuation is corrupt or from an + // incompatible artifact. There is no module-instance reference + // fallback in ABI 43. + let invalid_arm = { let local = local_mut(module, func_id); let s = local .builder_mut() .dangling_instr_seq(InstrSeqType::Simple(None)) .id(); let block = &mut local.block_mut(s).instrs; - push_instr( - block, - Instr::Const(Const { - value: Value::I32(slot as i32), - }), - ); - push_instr( - block, - Instr::TableGet(TableGet { - table: exnref_table, - }), - ); - push_instr(block, Instr::RefAsNonNull(RefAsNonNull {})); - push_instr(block, Instr::ThrowRef(ThrowRef {})); + push_instr(block, Instr::Unreachable(Unreachable {})); s }; - let dispatch_seq_id = plain_region.map_or(throw_ref_seq_id, |region| { - build_plain_catch_dispatch(module, func_id, region, throw_ref_seq_id) - }); + let dispatch_seq_id = + build_plain_catch_dispatch(module, func_id, region, catch_selector_local, invalid_arm); // Build the empty else for the outer REWIND-match guard. let else_id = { @@ -4142,8 +5662,9 @@ fn inject_rewind_throw_stubs( .id() }; - // Prepend the outer guard `if state>=REWINDING && cri == K` - // to the try_table body. + // Prepend the outer guard. Selectors are allocated contiguously per + // region, so two unsigned comparisons recognize this exact lexical + // try_table without another activation-local word. let local = local_mut(module, func_id); let original: Vec<(Instr, InstrLocId)> = std::mem::take(&mut local.block_mut(body_seq_id).instrs); @@ -4170,19 +5691,43 @@ fn inject_rewind_throw_stubs( push_instr( body, Instr::LocalGet(LocalGet { - local: catch_region_id_local, + local: catch_selector_local, }), ); push_instr( body, Instr::Const(Const { - value: Value::I32(region_id as i32), + value: Value::I32(region.first_selector as i32), }), ); push_instr( body, Instr::Binop(Binop { - op: BinaryOp::I32Eq, + op: BinaryOp::I32GeU, + }), + ); + push_instr( + body, + Instr::Binop(Binop { + op: BinaryOp::I32And, + }), + ); + push_instr( + body, + Instr::LocalGet(LocalGet { + local: catch_selector_local, + }), + ); + push_instr( + body, + Instr::Const(Const { + value: Value::I32(region.last_selector as i32), + }), + ); + push_instr( + body, + Instr::Binop(Binop { + op: BinaryOp::I32LeU, }), ); push_instr( @@ -4203,20 +5748,17 @@ fn inject_rewind_throw_stubs( } } -/// Build a dangling sequence that rethrows one frame-restored plain catch. -/// -/// Exact nonnegative arm IDs select plain catches. `-1` deliberately falls -/// through to `throw_ref_fallback`, which is what a catch_ref capture records -/// for a mixed region. +/// Build a dangling sequence that rethrows one frame-restored tagged catch. fn build_plain_catch_dispatch( module: &mut Module, func_id: FunctionId, region: &PlainCatchRegionState, - throw_ref_fallback: InstrSeqId, + catch_selector_local: LocalId, + invalid_arm: InstrSeqId, ) -> InstrSeqId { debug_assert!(!region.arms.is_empty()); - let mut chain = throw_ref_fallback; + let mut chain = invalid_arm; for arm in region.arms.iter().rev() { let throw_id = { let local = local_mut(module, func_id); @@ -4227,333 +5769,356 @@ fn build_plain_catch_dispatch( }; let local = local_mut(module, func_id); let s = &mut local.block_mut(throw_id).instrs; - for &operand in &arm.operand_locals { - push_instr(s, Instr::LocalGet(LocalGet { local: operand })); - } - push_instr(s, Instr::Throw(Throw { tag: arm.arm.tag })); - - let outer_id = { - let local = local_mut(module, func_id); - local - .builder_mut() - .dangling_instr_seq(InstrSeqType::Simple(None)) - .id() - }; - { - let local = local_mut(module, func_id); - let s = &mut local.block_mut(outer_id).instrs; - push_instr( - s, - Instr::LocalGet(LocalGet { - local: region.active_arm, - }), - ); - push_instr( - s, - Instr::Const(Const { - value: Value::I32(arm.arm.arm_idx as i32), - }), - ); - push_instr( - s, - Instr::Binop(Binop { - op: BinaryOp::I32Eq, - }), - ); - push_instr( - s, - Instr::IfElse(IfElse { - consequent: throw_id, - alternative: chain, - }), - ); - } - chain = outer_id; - } - - chain -} - -// ---------------------------------------------------------------------- -// Phase 6d — catch-handler entry capture -// ---------------------------------------------------------------------- - -#[derive(Debug, Clone, Copy)] -struct CatchHandlerInfo { - catch_region_id: u32, - exnref_slot: u32, - body_seq: InstrSeqId, - target_label: InstrSeqId, - in_catch_local: LocalId, - captured_exnref_local: LocalId, - plain_active_arm: Option, -} - -fn plan_catch_ref_handlers( - module: &mut Module, - func_id: FunctionId, - catch_plan: &[CatchRegionPlan], - aux_tables: &AuxTables, - plain_catches: &[PlainCatchRegionState], -) -> Vec { - let mut infos = Vec::new(); - if aux_tables.exnref.is_none() { - return infos; - } - let exnref_ty = RefType { - nullable: true, - heap_type: HeapType::Abstract(AbstractHeapType::Exn), - }; - - for plan in catch_plan { - let target_label_opt = { - let local = match &module.funcs.get(func_id).kind { - FunctionKind::Local(l) => l, - _ => continue, - }; - let (_, tt) = match find_try_table_parent_seq(local, local.entry_block(), plan.body_seq) - { - Some(v) => v, - None => continue, - }; - - let mut ref_targets: HashSet = HashSet::new(); - for c in &tt.catches { - match c { - TryTableCatch::CatchRef { label, .. } - | TryTableCatch::CatchAllRef { label } => { - ref_targets.insert(*label); - } - _ => {} - } - } - if ref_targets.len() != 1 { - None - } else { - Some(*ref_targets.iter().next().unwrap()) + if arm.arm.uses_exception_recipe { + let exception = arm + .captured_exnref + .expect("recipe-backed catch must own an exnref local"); + push_instr(s, Instr::LocalGet(LocalGet { local: exception })); + push_instr(s, Instr::RefAsNonNull(RefAsNonNull {})); + push_instr(s, Instr::ThrowRef(walrus::ir::ThrowRef {})); + } else { + for (&operand, &ty) in arm.operand_locals.iter().zip(&arm.arm.operand_tys) { + push_typed_local_get(s, operand, ty); } - }; - let target_label = match target_label_opt { - Some(t) => t, - None => continue, - }; - - let in_catch_local = module.locals.add(ValType::I32); - let captured_exnref_local = module.locals.add(ValType::Ref(exnref_ty)); - - infos.push(CatchHandlerInfo { - catch_region_id: plan.catch_region_id, - exnref_slot: plan.exnref_slot, - body_seq: plan.body_seq, - target_label, - in_catch_local, - captured_exnref_local, - plain_active_arm: plain_catches - .iter() - .find(|region| region.body_seq == plan.body_seq) - .map(|region| region.active_arm), - }); - } - - infos -} - -fn apply_catch_ref_handlers( - module: &mut Module, - func_id: FunctionId, - handlers: &[CatchHandlerInfo], - aux_tables: &AuxTables, -) { - let exnref_table = match aux_tables.exnref { - Some(t) => t, - None => return, - }; - - for info in handlers { - let (parent_seq, original_catches, try_table_type, catch_sig_type) = { - let local = match &module.funcs.get(func_id).kind { - FunctionKind::Local(l) => l, - _ => continue, - }; - let (parent, tt) = - match find_try_table_parent_seq(local, local.entry_block(), info.body_seq) { - Some(v) => v, - None => continue, - }; - let catches = tt.catches.clone(); - let try_sig = local.block(info.body_seq).ty; - let catch_sig = local.block(info.target_label).ty; - (parent, catches, try_sig, catch_sig) - }; - - let (outer_seq_id, capture_seq_id) = { - let local = local_mut(module, func_id); - let cap = local.builder_mut().dangling_instr_seq(catch_sig_type).id(); - let out = local.builder_mut().dangling_instr_seq(try_table_type).id(); - (out, cap) - }; - - let new_catches: Vec = original_catches - .iter() - .map(|c| match c { - TryTableCatch::CatchRef { tag, .. } => TryTableCatch::CatchRef { - tag: *tag, - label: capture_seq_id, - }, - TryTableCatch::CatchAllRef { .. } => TryTableCatch::CatchAllRef { - label: capture_seq_id, - }, - TryTableCatch::Catch { tag, label } => TryTableCatch::Catch { - tag: *tag, - label: *label, - }, - TryTableCatch::CatchAll { label } => TryTableCatch::CatchAll { label: *label }, - }) - .collect(); - - { - let local = local_mut(module, func_id); - let s = &mut local.block_mut(capture_seq_id).instrs; - push_instr( - s, - Instr::TryTable(TryTable { - seq: info.body_seq, - catches: new_catches, - }), - ); push_instr( s, - Instr::Br(Br { - block: outer_seq_id, + Instr::Throw(Throw { + tag: arm + .arm + .tag + .expect("scalar tagged catch dispatch must have a tag"), }), ); } - { + let outer_id = { let local = local_mut(module, func_id); - let s = &mut local.block_mut(outer_seq_id).instrs; - push_instr( - s, - Instr::Block(Block { - seq: capture_seq_id, - }), - ); - push_instr( - s, - Instr::LocalTee(LocalTee { - local: info.captured_exnref_local, - }), - ); - if let Some(active_arm) = info.plain_active_arm { - // WHY: mixed catch regions must restore their capture kind - // from frame-owned state. A negative arm cannot match any - // plain catch, so replay falls through to throw_ref without - // consulting possibly stale exnref-table nullness. - push_instr( - s, - Instr::Const(Const { - value: Value::I32(-1), - }), - ); - push_instr(s, Instr::LocalSet(LocalSet { local: active_arm })); - } - push_instr( - s, - Instr::Const(Const { - value: Value::I32(1), - }), - ); + local + .builder_mut() + .dangling_instr_seq(InstrSeqType::Simple(None)) + .id() + }; + { + let local = local_mut(module, func_id); + let s = &mut local.block_mut(outer_id).instrs; push_instr( s, - Instr::LocalSet(LocalSet { - local: info.in_catch_local, + Instr::LocalGet(LocalGet { + local: catch_selector_local, }), ); push_instr( s, Instr::Const(Const { - value: Value::I32(info.exnref_slot as i32), + value: Value::I32(arm.selector as i32), }), ); push_instr( s, - Instr::LocalGet(LocalGet { - local: info.captured_exnref_local, - }), - ); - push_instr( - s, - Instr::TableSet(TableSet { - table: exnref_table, + Instr::Binop(Binop { + op: BinaryOp::I32Eq, }), ); push_instr( s, - Instr::Br(Br { - block: info.target_label, + Instr::IfElse(IfElse { + consequent: throw_id, + alternative: chain, }), ); } + chain = outer_id; + } - { - let local = local_mut(module, func_id); - let parent_instrs = &mut local.block_mut(parent_seq).instrs; - let tt_idx = parent_instrs + chain +} + +// ---------------------------------------------------------------------- +// Phase 6d — catch-handler entry capture +// ---------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy)] +struct CatchHandlerInfo { + body_seq: InstrSeqId, +} + +fn plan_catch_handlers( + catch_plan: &[CatchRegionPlan], + plain_catches: &[PlainCatchRegionState], +) -> Vec { + plain_catches + .iter() + .filter_map(|region| { + catch_plan .iter() - .position(|(i, _)| matches!(i, Instr::TryTable(tt) if tt.seq == info.body_seq)) - .expect("try_table not found in its parent"); - parent_instrs[tt_idx].0 = Instr::Block(Block { seq: outer_seq_id }); - } - } + .find(|plan| plan.body_seq == region.body_seq) + .map(|plan| CatchHandlerInfo { + body_seq: plan.body_seq, + }) + }) + .collect() } // ---------------------------------------------------------------------- -// Stage 2 (B1) — per-arm capture-block emission for plain catch +// Per-arm capture-block emission for tagged catches // ---------------------------------------------------------------------- -/// Stage 2 (B1) — emit per-arm capture blocks that intercept plain -/// catch dispatch. +/// Ensure a user `catch_all`/`catch_all_ref` can never consume the +/// process-owned unwind transport. /// -/// For each fork-path try_table that has at least one plain-catch arm -/// (and whose function is NOT in `b2_carveout`), this rewrites: +/// A modern `try_table` catch transfers directly to an enclosing label, so a +/// zero-result shield block is inserted around the original instruction: /// -/// ```wat -/// (try_table (catch $tag $h) ... body ...) ;; original -/// ``` -/// into: -/// ```wat -/// (block $b1_outer -/// (block $cap_arm_0 -/// ... -/// (block $cap_arm_N-1 -/// (try_table (catch $tag0 $cap_arm_0) ... (catch $tagN-1 $cap_arm_N-1) body) -/// br $b1_outer) -/// ;; cap_arm_N-1 body: tagN-1.params on stack — save, set flags, br $hN-1 -/// ... -/// ;; cap_arm_0 body: tag0.params on stack — save, set flags, br $h0 +/// ```text +/// block $outer (param P) (result R) +/// block $private_shield (param P) +/// try_table (param P) (result R) +/// (catch $__wpk_fork_unwind $private_shield) +/// ...original catches... +/// br $outer +/// end +/// throw $__wpk_fork_unwind +/// end /// ``` /// -/// Inside each cap_arm_J body the operands are: -/// 1. spilled to activation-local, frame-backed locals. -/// 2. used to set the frame-backed active arm plus -/// `in_catch_local = 1` and `catch_region_id_local = -/// region_id`. -/// 3. re-pushed (in declaration order) and `br $hJ` executes. -/// -/// CatchAll/CatchRef/CatchAllRef clauses are preserved verbatim. If -/// Phase 6 already retargeted CatchRef/CatchAllRef clauses, those -/// retargets are passed through unchanged. -/// -/// `catch_handlers` is Phase 6's per-region info; the `in_catch_local` -/// is reused for any region that overlaps with B1's emission. For -/// plain-catch-only regions, a fresh `in_catch_local` is allocated. +/// Typed block parameters preserve the original operand stack without +/// allocating reference temporaries (which would themselves become stale GC +/// roots). Legacy EH has explicit handler sequences, so it only needs a typed +/// private handler inserted immediately before its catch-all. +fn shield_private_unwind_from_user_catches( + module: &mut Module, + func_id: FunctionId, + runtime: &Runtime, +) { + #[derive(Clone, Copy)] + enum Site { + TryTable { body: InstrSeqId, depth: u32 }, + LegacyTry { body: InstrSeqId, depth: u32 }, + } + + fn collect( + local: &LocalFunction, + seq: InstrSeqId, + depth: u32, + seen: &mut HashSet, + out: &mut Vec, + ) { + if !seen.insert(seq) { + return; + } + for (instr, _) in &local.block(seq).instrs { + match instr { + Instr::TryTable(tt) + if tt.catches.iter().any(|catch| { + matches!( + catch, + TryTableCatch::CatchAll { .. } | TryTableCatch::CatchAllRef { .. } + ) + }) => + { + out.push(Site::TryTable { + body: tt.seq, + depth, + }); + } + Instr::Try(legacy) + if legacy + .catches + .iter() + .any(|catch| matches!(catch, LegacyCatch::CatchAll { .. })) => + { + out.push(Site::LegacyTry { + body: legacy.seq, + depth, + }); + } + _ => {} + } + for child in nested_seqs(instr) { + collect(local, child, depth + 1, seen, out); + } + } + } + + let unwind_tag = runtime + .unwind_tag + .expect("fork-path instrumentation requires the linked unwind tag"); + let mut sites = Vec::new(); + { + let local = match &module.funcs.get(func_id).kind { + FunctionKind::Local(local) => local, + _ => return, + }; + collect( + local, + local.entry_block(), + 0, + &mut HashSet::new(), + &mut sites, + ); + } + sites.sort_by_key(|site| match site { + Site::TryTable { depth, .. } | Site::LegacyTry { depth, .. } => std::cmp::Reverse(*depth), + }); + + for site in sites { + match site { + Site::TryTable { body, .. } => { + let Some((parent, index, loc, mut table, body_ty)) = ({ + let local = match &module.funcs.get(func_id).kind { + FunctionKind::Local(local) => local, + _ => return, + }; + find_try_table_instr_site(local, local.entry_block(), body) + }) else { + continue; + }; + + let params = match body_ty { + InstrSeqType::Simple(_) => Vec::new(), + InstrSeqType::MultiValue(ty) => module.types.get(ty).params().to_vec(), + }; + let shield_ty = InstrSeqType::new(&mut module.types, ¶ms, &[]); + let (outer, shield) = { + let local = local_mut(module, func_id); + let outer = local.builder_mut().dangling_instr_seq(body_ty).id(); + let shield = local.builder_mut().dangling_instr_seq(shield_ty).id(); + (outer, shield) + }; + + let catch_all_index = table + .catches + .iter() + .position(|catch| { + matches!( + catch, + TryTableCatch::CatchAll { .. } | TryTableCatch::CatchAllRef { .. } + ) + }) + .expect("collected try_table still has a catch-all"); + table.catches.insert( + catch_all_index, + TryTableCatch::Catch { + tag: unwind_tag, + label: shield, + }, + ); + + { + let local = local_mut(module, func_id); + let s = &mut local.block_mut(shield).instrs; + push_instr(s, Instr::TryTable(table)); + push_instr(s, Instr::Br(Br { block: outer })); + } + { + let local = local_mut(module, func_id); + let s = &mut local.block_mut(outer).instrs; + push_instr(s, Instr::Block(Block { seq: shield })); + push_instr(s, Instr::Throw(Throw { tag: unwind_tag })); + } + local_mut(module, func_id).block_mut(parent).instrs[index] = + (Instr::Block(Block { seq: outer }), loc); + } + Site::LegacyTry { body, .. } => { + let Some((parent, index, body_ty)) = ({ + let local = match &module.funcs.get(func_id).kind { + FunctionKind::Local(local) => local, + _ => return, + }; + find_legacy_try_instr_site(local, local.entry_block(), body) + }) else { + continue; + }; + let results = match body_ty { + InstrSeqType::Simple(None) => Vec::new(), + InstrSeqType::Simple(Some(result)) => vec![result], + InstrSeqType::MultiValue(ty) => module.types.get(ty).results().to_vec(), + }; + let handler_ty = InstrSeqType::new(&mut module.types, &[], &results); + let handler = { + let local = local_mut(module, func_id); + local.builder_mut().dangling_instr_seq(handler_ty).id() + }; + { + let local = local_mut(module, func_id); + push_instr( + &mut local.block_mut(handler).instrs, + Instr::Throw(Throw { tag: unwind_tag }), + ); + let Instr::Try(legacy) = &mut local.block_mut(parent).instrs[index].0 else { + unreachable!("legacy try site changed during shielding"); + }; + let catch_all_index = legacy + .catches + .iter() + .position(|catch| matches!(catch, LegacyCatch::CatchAll { .. })) + .expect("collected legacy try still has a catch-all"); + legacy.catches.insert( + catch_all_index, + LegacyCatch::Catch { + tag: unwind_tag, + handler, + }, + ); + } + } + } + } +} + +fn find_try_table_instr_site( + local: &LocalFunction, + seq: InstrSeqId, + body: InstrSeqId, +) -> Option<(InstrSeqId, usize, InstrLocId, TryTable, InstrSeqType)> { + for (index, (instr, loc)) in local.block(seq).instrs.iter().enumerate() { + if let Instr::TryTable(table) = instr { + if table.seq == body { + return Some((seq, index, *loc, table.clone(), local.block(body).ty)); + } + } + for child in nested_seqs(instr) { + if let Some(site) = find_try_table_instr_site(local, child, body) { + return Some(site); + } + } + } + None +} + +fn find_legacy_try_instr_site( + local: &LocalFunction, + seq: InstrSeqId, + body: InstrSeqId, +) -> Option<(InstrSeqId, usize, InstrSeqType)> { + for (index, (instr, _)) in local.block(seq).instrs.iter().enumerate() { + if let Instr::Try(legacy) = instr { + if legacy.seq == body { + return Some((seq, index, local.block(body).ty)); + } + } + for child in nested_seqs(instr) { + if let Some(site) = find_legacy_try_instr_site(local, child, body) { + return Some(site); + } + } + } + None +} + +/// Emit per-arm capture blocks that intercept tagged Catch and CatchRef +/// dispatch. /// +/// Each capture spills its scalar operands to activation-owned frame locals, +/// records the active region/arm, re-pushes the original operands, and branches +/// to the user's handler. CatchRef captures only the scalar tag payload; its +/// instance-local exnref is forwarded and then cleared from the synthetic +/// local so it does not survive as a stale GC root. fn apply_plain_catch_handlers( module: &mut Module, func_id: FunctionId, - catch_region_id_local: LocalId, + catch_selector_local: LocalId, plain_catches: &[PlainCatchRegionState], - catch_plan: &[CatchRegionPlan], catch_handlers: &[CatchHandlerInfo], ) { if plain_catches.is_empty() { @@ -4567,10 +6132,6 @@ fn apply_plain_catch_handlers( continue; } - // Phase 6's `apply_catch_ref_handlers` may have moved this - // try_table inside its own capture block. `find_try_table_parent_seq` - // walks recursively from the entry block, so the new parent - // is discovered automatically. let (parent_seq, original_catches, try_table_type) = { let local = match &module.funcs.get(func_id).kind { FunctionKind::Local(l) => l, @@ -4584,23 +6145,7 @@ fn apply_plain_catch_handlers( (parent, tt.catches.clone(), local.block(body_seq).ty) }; - // Look up region_id from catch_plan (every fork-path try_table - // gets a catch_region_id assigned in plan_and_inject_aux_tables). - let catch_region_id = catch_plan - .iter() - .find(|p| p.body_seq == body_seq) - .map(|p| p.catch_region_id) - .unwrap_or(0); - - // Reuse Phase 6's in_catch_local if the region overlaps; else - // allocate a fresh one. (Mixed catch_ref+plain regions share - // the same flag so post-call dispatch sees a single signal per - // region.) - let in_catch_local = catch_handlers - .iter() - .find(|h| h.body_seq == body_seq) - .map(|h| h.in_catch_local) - .unwrap_or_else(|| module.locals.add(ValType::I32)); + debug_assert!(catch_handlers.iter().any(|h| h.body_seq == body_seq)); // ---------------------------------------------------------- // Build dangling sequences: outer + N caps. @@ -4614,40 +6159,100 @@ fn apply_plain_catch_handlers( local.builder_mut().dangling_instr_seq(try_table_type).id() }; - // Build per-arm InstrSeqType up-front (mutates module.types) - // before any &mut LocalFunction borrow is needed. - let cap_types: Vec = arm_states - .iter() - .map(|state| InstrSeqType::new(&mut module.types, &[], &state.arm.operand_tys)) - .collect(); - + // The original target label already carries the exact catch branch + // type. For CatchRef that is tag.params followed by a non-null exnref. + // Reusing it avoids weakening concrete EH reference types. let mut cap_seq_ids: Vec = Vec::with_capacity(arm_states.len()); - for cap_ty in &cap_types { + for state in arm_states { + let original_ty = { + let local = match &module.funcs.get(func_id).kind { + FunctionKind::Local(local) => local, + _ => continue, + }; + local.block(state.arm.label).ty + }; + let cap_ty = if state.arm.kind.is_plain() && state.arm.uses_exception_recipe { + let mut results = match original_ty { + InstrSeqType::Simple(None) => Vec::new(), + InstrSeqType::Simple(Some(result)) => vec![result], + InstrSeqType::MultiValue(ty) => module.types.get(ty).results().to_vec(), + }; + // Retarget a plain catch to CatchRef so the capture owns + // the complete exception. The extra non-null exnref is + // consumed by the synthetic tail and never reaches the + // original plain handler. + results.push(ValType::Ref(RefType { + nullable: false, + heap_type: HeapType::Abstract(AbstractHeapType::Exn), + })); + InstrSeqType::new(&mut module.types, &[], &results) + } else { + original_ty + }; let local = local_mut(module, func_id); - cap_seq_ids.push(local.builder_mut().dangling_instr_seq(*cap_ty).id()); + cap_seq_ids.push(local.builder_mut().dangling_instr_seq(cap_ty).id()); } // ---------------------------------------------------------- - // Rewrite the inner try_table's catches: each plain Catch - // arm now points at its capture block; everything else (incl. - // catch_ref/catch_all_ref already retargeted by Phase 6) is - // preserved verbatim. + // Rewrite every planned tagged arm to its activation capture. // // We map by arm position within `arm_states` -- each entry's // `arm.arm_idx` is the arm's index in the original try_table's - // catches list. We walk `original_catches` and substitute each - // matching plain Catch with its capture target. + // catches list before private-unwind shielding. Locate the live clause + // by its original label/tag instead of indexing directly: shielding + // inserts a private catch immediately before a user catch-all. // ---------------------------------------------------------- let mut new_catches: Vec = original_catches.clone(); for (j, state) in arm_states.iter().enumerate() { - let arm_idx = state.arm.arm_idx as usize; + let arm_idx = new_catches + .iter() + .position(|catch| match (state.arm.kind, catch) { + (TaggedCatchKind::Plain, TryTableCatch::Catch { tag, label }) + | (TaggedCatchKind::Ref, TryTableCatch::CatchRef { tag, label }) => { + Some(*tag) == state.arm.tag && *label == state.arm.label + } + (TaggedCatchKind::AllPlain, TryTableCatch::CatchAll { label }) + | (TaggedCatchKind::AllRef, TryTableCatch::CatchAllRef { label }) => { + *label == state.arm.label + } + _ => false, + }) + .expect("planned user catch no longer exists after private shielding"); if let Some(c) = new_catches.get_mut(arm_idx) { - if let TryTableCatch::Catch { tag, .. } = c { - *c = TryTableCatch::Catch { - tag: *tag, - label: cap_seq_ids[j], - }; - } + let replacement = match (state.arm.kind, &*c) { + (TaggedCatchKind::Plain, TryTableCatch::Catch { tag, .. }) + if state.arm.uses_exception_recipe => + { + TryTableCatch::CatchRef { + tag: *tag, + label: cap_seq_ids[j], + } + } + (TaggedCatchKind::Plain, TryTableCatch::Catch { tag, .. }) => { + TryTableCatch::Catch { + tag: *tag, + label: cap_seq_ids[j], + } + } + (TaggedCatchKind::Ref, TryTableCatch::CatchRef { tag, .. }) => { + TryTableCatch::CatchRef { + tag: *tag, + label: cap_seq_ids[j], + } + } + (TaggedCatchKind::AllPlain, TryTableCatch::CatchAll { .. }) => { + TryTableCatch::CatchAllRef { + label: cap_seq_ids[j], + } + } + (TaggedCatchKind::AllRef, TryTableCatch::CatchAllRef { .. }) => { + TryTableCatch::CatchAllRef { + label: cap_seq_ids[j], + } + } + _ => unreachable!("validated catch plan no longer matches try_table"), + }; + *c = replacement; } } @@ -4724,11 +6329,9 @@ fn apply_plain_catch_handlers( module, func_id, cap_seq_ids[j], - region.active_arm, &arm_states[j + 1], - in_catch_local, - catch_region_id_local, - catch_region_id, + catch_selector_local, + region.retained_recipe_exnref, ); } @@ -4763,11 +6366,9 @@ fn apply_plain_catch_handlers( module, func_id, outer_seq_id, - region.active_arm, &arm_states[0], - in_catch_local, - catch_region_id_local, - catch_region_id, + catch_selector_local, + region.retained_recipe_exnref, ); // ---------------------------------------------------------- @@ -4787,28 +6388,38 @@ fn apply_plain_catch_handlers( } } -/// Emit the capture-block "tail" for a single plain-catch arm: at the -/// point where this is invoked, `tag.params()` are on the operand -/// stack. The emitted sequence: +/// Emit the capture-block tail for one tagged catch. The operand stack holds +/// `tag.params()` and, for CatchRef, a final exnref. /// -/// 1. Spill operands to per-arm frame locals (top-of-stack first). -/// 2. Set the region's frame-backed active arm. -/// 3. Set `in_catch_local = 1`, `catch_region_id_local = region_id`. -/// 4. Re-push operands (declaration order). +/// 1. Temporarily pop CatchRef's exnref, then spill scalar operands. +/// 2. Replace this activation's latest-catch selector with this exact arm. +/// 4. Re-push operands and, for the user's CatchRef, the exnref. +/// Clear capture-only reference locals; retain a recipe-owned exception +/// until this activation no longer needs catch replay. /// 5. `br arm.label` (original handler). fn emit_capture_save_and_branch( module: &mut Module, func_id: FunctionId, cap_seq_id: InstrSeqId, - active_arm: LocalId, arm: &PlainCatchArmState, - in_catch_local: LocalId, - catch_region_id_local: LocalId, - catch_region_id: u32, + catch_selector_local: LocalId, + retained_recipe_exnref: Option, ) { let local = local_mut(module, func_id); let s = &mut local.block_mut(cap_seq_id).instrs; + // CatchRef appends exnref after the tag payload, so it is first off the + // stack. A converted plain Catch also arrives here through CatchRef when + // the typed Wasm codec must own a reference/v128-bearing payload. + if let Some(captured_exnref) = arm.captured_exnref { + push_instr( + s, + Instr::LocalSet(LocalSet { + local: captured_exnref, + }), + ); + } + // 1. Spill operands. Operands were declared L-to-R but appear on // the stack with the LAST one on top — so we spill in reverse // declaration order: spills[M-1] first, then [M-2], ..., [0]. @@ -4821,44 +6432,99 @@ fn emit_capture_save_and_branch( ); } - // 2. Record which arm owns the operand locals for this activation. - push_instr( - s, - Instr::Const(Const { - value: Value::I32(arm.arm.arm_idx as i32), - }), - ); - push_instr(s, Instr::LocalSet(LocalSet { local: active_arm })); - - // 3. Set flags. - push_instr( - s, - Instr::Const(Const { - value: Value::I32(1), - }), - ); - push_instr( - s, - Instr::LocalSet(LocalSet { - local: in_catch_local, - }), - ); + // 2. Record the dynamically latest catch for this activation. + // + // WHY: replay needs the most recently taken exception edge, not the + // lexically last try_table that ever caught. A loop can execute region B + // and later region A, and nested handlers can likewise supersede one + // another. One activation-local selector naturally follows that dynamic + // order and avoids both stale per-region markers and one native i32 local + // per static try_table. push_instr( s, Instr::Const(Const { - value: Value::I32(catch_region_id as i32), + value: Value::I32(arm.selector as i32), }), ); push_instr( s, Instr::LocalSet(LocalSet { - local: catch_region_id_local, + local: catch_selector_local, }), ); + if !arm.arm.uses_exception_recipe { + if let Some(retained_recipe_exnref) = retained_recipe_exnref { + // WHY: a scalar catch has just superseded the only selector that + // could name the previous complete-exception recipe. Clear the + // function-wide slot before entering user code so the obsolete + // exception is neither serialized nor retained as a hidden GC + // root. Guest-visible references have independent typed owners. + push_instr( + s, + Instr::RefNull(RefNull { + ty: RefType { + nullable: true, + heap_type: HeapType::Abstract(AbstractHeapType::Exn), + }, + }), + ); + push_instr( + s, + Instr::LocalSet(LocalSet { + local: retained_recipe_exnref, + }), + ); + } + } // 4. Re-push operands in declaration order. - for &operand in &arm.operand_locals { - push_instr(s, Instr::LocalGet(LocalGet { local: operand })); + for (&operand, &ty) in arm.operand_locals.iter().zip(&arm.arm.operand_tys) { + push_typed_local_get(s, operand, ty); + } + // Capture-only payload locals must not retain reference values as hidden + // GC roots after the branch. The already-pushed handler operands remain on + // the operand stack while these nullable storage locals are cleared. + for (&operand, &ty) in arm.operand_locals.iter().zip(&arm.arm.operand_tys) { + let ValType::Ref(mut reference) = ty else { + continue; + }; + reference.nullable = true; + push_instr(s, Instr::RefNull(RefNull { ty: reference })); + push_instr(s, Instr::LocalSet(LocalSet { local: operand })); + } + if arm.arm.kind.is_ref() { + let captured_exnref = arm + .captured_exnref + .expect("CatchRef capture must own an exnref local"); + push_instr( + s, + Instr::LocalGet(LocalGet { + local: captured_exnref, + }), + ); + push_instr(s, Instr::RefAsNonNull(RefAsNonNull {})); + } + if let Some(captured_exnref) = arm + .captured_exnref + .filter(|_| !arm.arm.uses_exception_recipe) + { + // Scalar CatchRef replay reconstructs from the tag/payload, so its + // forwarding local is scratch and must not retain a stale exception. + push_instr( + s, + Instr::RefNull(RefNull { + ty: RefType { + nullable: true, + heap_type: HeapType::Abstract(AbstractHeapType::Exn), + }, + }), + ); + push_instr( + s, + Instr::LocalSet(LocalSet { + local: captured_exnref, + }), + ); } // 5. Branch to original handler. @@ -4895,26 +6561,18 @@ fn find_try_table_parent_seq<'a>( // (docs/plans/2026-05-13-fork-instrument-megaPR-eliminate-guard-dispatch-and-modern-EH-plan.md) // ===================================================================== // -// Note: guard-dispatch was deleted in commit 4 (2026-05-14) after the -// 2.5/2.6 sub-commits absorbed UnsupportedCarryover/MultiValueParams -// into nested switch-dispatch, commit 9's modern-EH SDK flip -// eliminated UnsupportedLegacyTry from shipping wasm, and commit 3 -// replaced the two `instrument_one_function_guard_dispatch` callers -// with panics. The trampoline scaffolding below remains UNWIRED in -// shipping fork-instrument runs; it's preserved for future use if a -// new "genuinely impossible for switch-dispatch" case ever emerges. +// Guard-dispatch was deleted after nested switch-dispatch absorbed structured +// carryovers and multi-value parameters. Fork-reachable legacy handlers are +// now normalized to activation-owned modern EH before this file runs. The +// trampoline scaffolding below remains unwired historical implementation. // -// Replaces guard-dispatch as the fallback for the three classes -// switch-dispatch can't handle today: +// It was originally intended for three historical classes: // (a) Nested fork-path call inside a Loop/IfElse/TryTable body that -// `classify_nested_pattern` rejects (UnsupportedLegacyTry, -// UnsupportedMultiValueParams, UnsupportedCarryover). +// `classify_nested_pattern` could not type. // (b) Top-level fork-path call with operand-stack carryover. // (c) Nested call_indirect to a fork-path callee, in combination -// with another unsupported pattern. (Simple nested call_indirect -// in a loop is empirically already handled by nested switch- -// dispatch — see crates/fork-instrument/tests/trampoline.rs's -// `today_nested_call_indirect_uses_nested_switch_dispatch`.) +// with a carryover shape absent from the old typed model. +// Nested switch-dispatch now owns all three. // // Per-function dispatch table (open Q #3, resolved 2026-05-13): // each instrumented fork-path function emits its own @@ -4924,10 +6582,9 @@ fn find_try_table_parent_seq<'a>( // // State after sub-commit 2.2 (this commit): the function below is // defined but UNREACHABLE — no caller exists. The body emission -// lands in 2.3; sub-commits 2.4 (carryover), 2.5 (call_indirect + -// pattern), 2.6 (nested unsupported) wire callers one class at a -// time. Once 2.6 ships, guard-dispatch is unreachable; commits 3-4 -// verify and delete it. +// landed in 2.3; later sub-commits wired carryovers, call_indirect, and +// nested typed state one class at a time. Guard-dispatch has since been +// deleted. /// Emit a per-function funcref dispatch table populated with the /// extracted post-call functions for one fork-path function. @@ -5100,8 +6757,6 @@ fn instrument_one_function_trampoline_dispatch( _runtime: &Runtime, _fork_path: &HashSet, _func_ordinal: u32, - _aux_tables: &AuxTables, - _ref_plan: &[RefLocalSlot], _catch_plan: &[CatchRegionPlan], _plain_catches: &[(InstrSeqId, Vec)], ) { @@ -5142,21 +6797,13 @@ fn instrument_one_function_trampoline_dispatch( // back via `local.get` in the post-call sequence. // // MVP supported nesting: `Block` (any result type), `IfElse`, -// `Loop`, `TryTable` body. Unsupported (routes to guard-dispatch): -// legacy `Try`, multi-value-params blocks, sub-region landings whose -// preceding chunk has a stack carryover. +// `Loop`, `TryTable`, or normalized legacy-handler body. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum NestedSupportStatus { Supported, - UnsupportedLegacyTry, - /// Sub-commit 2.6c: no longer produced — multi-value-params - /// SubRegions now route to nested switch-dispatch via the body- - /// param prespill + reload mechanism in `transform_region_seq`. - /// Kept as a documented enum variant for future defensive use - /// (e.g., if a shape regression appears). - #[allow(dead_code)] - UnsupportedMultiValueParams, - UnsupportedCarryover, + /// Exhaustive stack effects disagreed with already-validated Wasm IR. + /// This is an instrumenter invariant failure, never a source-shape policy. + AnalysisInvariantFailed, } impl NestedSupportStatus { @@ -5165,18 +6812,9 @@ impl NestedSupportStatus { } } -/// Classify a function's nesting pattern. MVP scope: returns -/// `Supported` iff every fork-path call in the function lives inside a -/// chain of `Block` bodies only (any depth), no enclosing `IfElse` / -/// `Loop` / `TryTable` / legacy `Try`, no multi-value-params blocks, -/// no nested-seq stack carryovers. -/// -/// This narrow scope handles the popen-class regression — popen's -/// `__fork` and `posix_spawn` reach `kernel_fork` through `block` -/// nesting (no IfElse around the fork-path call). Functions with -/// IfElse-around-fork-call still fall back to guard-dispatch (today's -/// behavior); that's a known divergence-bug exposure but is not the -/// pattern that hangs popen on this branch. +/// Verify that nested switch-dispatch can statically type every activation +/// carryover. Structured control, multi-value parameters, modern EH, and +/// normalized legacy handlers all use this path. fn classify_nested_pattern( module: &Module, func_id: FunctionId, @@ -5187,256 +6825,70 @@ fn classify_nested_pattern( _ => return NestedSupportStatus::Supported, }; let status = classify_seq(module, local, local.entry_block(), fork_path); - if !status.is_supported() { - return status; - } - - // Sub-commit 2.5c (2026-05-14): direct fork-path call landings - // with operand-stack carryovers are absorbed by nested switch- - // dispatch via the per-call carryover-spilling extension wired in - // 2.5b. The carryover types must be statically determinable — - // fall back to guard-dispatch when the analyser can't type them, - // mirroring the policy used by top-level switch-dispatch's 2.4c - // gate in `instrument_one_function`. The seq-level check in - // `seq_has_unsupported_carryover` no longer rejects these; - // function-level here is the appropriate granularity (the - // analyser needs the whole function's call_idx assignment to - // produce its result). - if compute_nested_carryover_types(module, func_id, fork_path).is_none() { - return NestedSupportStatus::UnsupportedCarryover; - } - - NestedSupportStatus::Supported -} - -fn classify_seq( - module: &Module, - f: &LocalFunction, - seq: InstrSeqId, - fork_path: &HashSet, -) -> NestedSupportStatus { - let carryover = seq_has_unsupported_carryover(module, f, seq, fork_path); - if carryover { - return NestedSupportStatus::UnsupportedCarryover; - } - - for (instr, _) in &f.block(seq).instrs { - match instr { - Instr::Loop(_) | Instr::Block(_) | Instr::IfElse(_) | Instr::TryTable(_) => { - // Allowed. Loops, blocks, ifs, and try_tables are - // handled by per-block dispatch inside their body. - // For IfElse, the cond rewrite via `select` selects - // between original cond (NORMAL) and force-flag - // (REWIND). Try_table catches branch to outer - // labels — fork-path calls reachable only via a - // catch (fork-from-catch) are still unsupported, but - // are detected separately as "carryover" / "unknown - // stack-effect" patterns and routed to guard-dispatch. - } - Instr::Try(t) => { - // Legacy try bodies follow the same nested-switch route - // as block/loop/try_table bodies. Legacy catch handlers - // remain unsupported because REWIND cannot re-enter a - // handler without reconstructing the exception path. - for c in &t.catches { - let handler = match c { - LegacyCatch::Catch { handler, .. } => Some(*handler), - LegacyCatch::CatchAll { handler } => Some(*handler), - LegacyCatch::Delegate { .. } => None, - }; - if let Some(h) = handler { - if subtree_contains_fork_call(f, h, fork_path) { - return NestedSupportStatus::UnsupportedLegacyTry; - } - } - } - } - _ => {} - } - for child in nested_seqs(instr) { - // Sub-commit 2.6c (2026-05-14): multi-value-params - // SubRegions are now absorbed by nested switch-dispatch - // via the typed `CarryoverPlan::spill_locals` machinery - // (2.6a/2.6b). The Block's declared type-params are - // spilled at the chunk tail (like any other carryover) - // and pushed back BEFORE the SubRegion runs at - // emit_post_landing. The function-level - // `UnsupportedMultiValueParams` rejection here is gone. - let status = classify_seq(module, f, child, fork_path); - if !status.is_supported() { - return status; - } - } + if !status.is_supported() { + return status; } - NestedSupportStatus::Supported -} -fn subtree_contains_fork_call( - f: &LocalFunction, - seq: InstrSeqId, - fork_path: &HashSet, -) -> bool { - for (instr, _) in &f.block(seq).instrs { - match instr { - Instr::Call(c) if fork_path.contains(&c.func) => return true, - Instr::CallIndirect(_) => return true, - _ => {} - } - for child in nested_seqs(instr) { - if subtree_contains_fork_call(f, child, fork_path) { - return true; - } - } + // Direct fork-path call landings with operand-stack carryovers are + // absorbed by per-call typed spill locals. Failure here means the + // exhaustive stack model disagreed with validated IR. + if compute_nested_carryover_types(module, func_id, fork_path).is_none() { + return NestedSupportStatus::AnalysisInvariantFailed; } - false + + NestedSupportStatus::Supported } -/// Returns true iff `seq` contains a fork-path landing (direct call -/// or sub-region whose nested seq is fork-bearing) whose pre-landing -/// stack has a carryover — extra values left on the stack from before -/// the chunk's start that aren't part of the landing's required -/// inputs. Per-block dispatch's `POST_K` blocks are typed `Simple(None)` -/// (0 → 0), so carryovers can't be expressed. -/// -/// "Required inputs" per landing: -/// - Direct fork-path Call: the call's params count. -/// - CallIndirect: params + 1 (the table index). -/// - Block / Loop / TryTable: 0 (we already reject multi-value -/// params blocks elsewhere in classify_nested_pattern). -/// - IfElse: 1 (the cond). -/// -/// ## Known unfixed case: `tests/sortix/os-test/basic/spawn/posix_spawnattr_setpgroup` -O2 -/// -/// LLVM-O2 inlines `posix_spawn` into `main` and emits a sub-region -/// carryover at the `kernel_fork`-bearing block: -/// -/// ```text -/// local.get 0 ;; push __errno_location() — carryover -/// block (result i32) ;; the block contains kernel_fork -/// ... kernel_fork wrap ... -/// end -/// local.tee 1 ;; save posix_spawn return value -/// i32.store ;; *errno_location = posix_spawn_rc — consumes both -/// ``` -/// -/// We currently route this to **guard-dispatch** (because -/// switch-dispatch's `POST_K` blocks are 0 → 0 and can't express the -/// carryover). On `-O0`/`-O1` the function passes; on `-O2` it fails -/// with `waitpid: ECHILD` because the parent's local `pid` ends up at -/// 0 instead of the child's pid. The `pid` write happens through the -/// `&pid` pointer inside the inlined `posix_spawn` body, after the -/// kernel_fork rewind handshake — but some divergence specific to the -/// `-O2` shape causes that write not to take effect on the parent's -/// REWIND path. -/// -/// We **expected guard-dispatch + the c01554940 non-fork-path-call -/// gate + the LocalTee identity-passthrough fix to handle this case**, -/// but multiple sessions of debugging haven't pinned down the exact -/// divergence; the bug is highly LLVM-codegen-sensitive (adding a -/// single `fprintf`/`fflush` at the right spot makes it pass). The -/// current best understanding is that some pre-call op leaves the -/// stack or shadow-stack in a state that REWIND replay diverges from -/// NORMAL, despite all the targeted fixes. -/// -/// **Next step:** the proper fix is to extend per-block switch-dispatch -/// to handle carryovers at sub-region landings via local-spilling -/// (allocate spill locals, push values from carryover into them -/// before the enclosing instruction, reload after) — that takes the -/// function off the guard-dispatch path entirely and avoids the -/// divergence. Implementation begins immediately below; see -/// `partition_region_instrs` and `emit_chunk_tail_for_landing`. -/// -/// **It remains worth revisiting whether guard-dispatch could be -/// fixed for this case in the future** — a successful guard-dispatch -/// solution would cover any other LLVM-codegen-sensitive carryover -/// shape we discover later, not just the ones that fit the -/// switch-dispatch carryover-spilling extension. -#[allow(dead_code)] -fn seq_has_direct_fork_carryover( +fn classify_seq( module: &Module, f: &LocalFunction, seq: InstrSeqId, fork_path: &HashSet, -) -> bool { - let mut depth: usize = 0; +) -> NestedSupportStatus { + if seq_stack_analysis_invariant_failed(module, f, seq) { + return NestedSupportStatus::AnalysisInvariantFailed; + } + for (instr, _) in &f.block(seq).instrs { - // Check direct fork-path call landings. - let direct_expected: Option = match instr { - Instr::Call(c) if fork_path.contains(&c.func) => Some( - module - .types - .get(module.funcs.get(c.func).ty()) - .params() - .len(), - ), - Instr::CallIndirect(ci) => Some(module.types.get(ci.ty).params().len() + 1), - _ => None, - }; - if let Some(expected) = direct_expected { - if depth > expected { - return true; + match instr { + Instr::Loop(_) | Instr::Block(_) | Instr::IfElse(_) | Instr::TryTable(_) => { + // Loops, blocks, ifs, and try_tables are handled by + // per-block dispatch. For IfElse, the condition rewrite + // selects between the original condition (NORMAL) and the + // replay force flag. Catch handlers use activation-owned + // selector/payload or complete-exception recipes. } - } - - // Check sub-region landings: any enclosing instruction whose - // nested seq's subtree contains a fork-path call (so the - // partition would emit a SubRegion landing for it). - let is_subregion_landing = match instr { - Instr::Block(_) - | Instr::Loop(_) - | Instr::TryTable(_) - | Instr::Try(_) - | Instr::IfElse(_) => nested_seqs(instr) - .iter() - .any(|s| subtree_contains_fork_call(f, *s, fork_path)), - _ => false, - }; - if is_subregion_landing { - let subregion_expected = match instr { - Instr::IfElse(_) => 1, - _ => 0, - }; - if depth > subregion_expected { - return true; + Instr::Try(_) => { + // Fork-reachable legacy handlers were converted to modern + // try_table/catch_ref before this classifier runs. A surviving + // legacy try can therefore only be a handler-free delegate, + // whose body follows the ordinary nested-switch route. } + _ => {} } - - match top_level_stack_effect(module, f, instr) { - StackEffect::Delta { pops, pushes } => { - if depth < pops { - return true; - } - depth = depth - pops + pushes; + for child in nested_seqs(instr) { + // Multi-value SubRegion parameters use the same typed + // `CarryoverPlan::spill_locals` machinery: spill at the chunk + // tail and restore before entering the SubRegion. + let status = classify_seq(module, f, child, fork_path); + if !status.is_supported() { + return status; } - StackEffect::Terminator => return false, - StackEffect::Unknown => return true, } } - false + NestedSupportStatus::Supported } -/// Like `seq_has_direct_fork_carryover` but only flags carryovers -/// that the per-block switch-dispatch transform can NOT handle via -/// local-spilling. Currently: every carryover except a 1-i32 stack -/// item at a SubRegion (non-IfElse) landing whose enclosing -/// instruction produces 0 or 1 i32 result. +/// Check that the depth-only structured walk agrees with validated Wasm IR. /// -/// MVP rationale: in C-emitted wasm at -O2, the most common carryover -/// pattern is `local.get $ptr; block (result i32) { ... fork ... }; -/// local.tee; i32.store` — a single i32 (typically a pointer) pushed -/// before a fork-bearing block and consumed after. We spill the i32 -/// via `local.set $carryover_local` at the chunk tail, then reload it -/// after the block runs (juggling with a `tmp_result_local` if the -/// block produces an i32 result). -/// -/// Wider carryover patterns (multi-value, non-i32 types, carryovers at -/// DirectCall landings) still reject; extending support is -/// straightforward but not needed for the cases we've seen so far. -fn seq_has_unsupported_carryover( +/// All carryover types—including references, GC values, EH references, and +/// multi-value parameters/results—are handled by the typed spill planners. +/// A `true` result here therefore signals an analyzer invariant failure, not a +/// source shape the instrumenter intentionally excludes. +fn seq_stack_analysis_invariant_failed( module: &Module, f: &LocalFunction, seq: InstrSeqId, - fork_path: &HashSet, ) -> bool { // Sub-commit 2.6c: a Block/Loop/TryTable body with declared // type-params enters with those values already on the seq's @@ -5448,46 +6900,6 @@ fn seq_has_unsupported_carryover( _ => 0, }; for (instr, _) in &f.block(seq).instrs { - // Sub-commit 2.5c (2026-05-14): direct fork-path call landings - // with operand-stack carryovers are now absorbed by nested - // switch-dispatch via the carryover-spilling extension (see - // `compute_nested_carryover_types` + the per-call - // `carryover_spills` wiring in `instrument_one_function_nested_switch`). - // The function-level fallback for shapes the analyser can't - // statically type lives at `classify_nested_pattern`. No - // per-seq direct-call rejection here. - - // Sub-region landings. - let is_subregion = match instr { - Instr::Block(_) - | Instr::Loop(_) - | Instr::TryTable(_) - | Instr::Try(_) - | Instr::IfElse(_) => nested_seqs(instr) - .iter() - .any(|s| subtree_contains_fork_call(f, *s, fork_path)), - _ => false, - }; - if is_subregion { - let is_ifelse = matches!(instr, Instr::IfElse(_)); - // Sub-commit 2.6b: a SubRegion's `expected_input` includes - // both the cond (for IfElse) AND any declared type-params - // (for multi-value-params Block/Loop/TryTable). Values - // beyond that are real "extra carryover" above the params. - // The 2.6a analyser spills both bands uniformly into - // `CarryoverPlan::spill_locals`, so multi-value-params - // SubRegions are no longer rejected — their params are - // just one source of spill values. - let subregion_params = subregion_input_param_count(module, f, instr); - let expected_input: usize = if is_ifelse { 1 } else { subregion_params }; - let carryover_depth = depth.saturating_sub(expected_input); - if carryover_depth > 0 { - // Otherwise: this is a supported extra-carryover - // (possibly combined with multi-value params, both - // spilled together by 2.6a's analyser). - } - } - match top_level_stack_effect(module, f, instr) { StackEffect::Delta { pops, pushes } => { if depth < pops { @@ -5496,32 +6908,11 @@ fn seq_has_unsupported_carryover( depth = depth - pops + pushes; } StackEffect::Terminator => return false, - StackEffect::Unknown => return true, } } false } -/// Sub-commit 2.6b: count the declared type-params of a SubRegion -/// (Block/Loop/TryTable). Returns 0 for simple (non-multi-value) -/// signatures, and 0 for non-SubRegion instructions. -fn subregion_input_param_count(module: &Module, f: &LocalFunction, instr: &Instr) -> usize { - let body_seq = match instr { - Instr::Block(b) => Some(b.seq), - Instr::Loop(l) => Some(l.seq), - Instr::TryTable(t) => Some(t.seq), - Instr::Try(t) => Some(t.seq), - _ => None, - }; - let Some(seq) = body_seq else { - return 0; - }; - match f.block(seq).ty { - InstrSeqType::MultiValue(ty_id) => module.types.get(ty_id).params().len(), - _ => 0, - } -} - /// For each non-IfElse SubRegion landing in a fork-bearing seq, /// returns the full Vec of values to spill at that landing — /// covering BOTH the SubRegion's type-params (consumed on entry) AND @@ -5547,10 +6938,9 @@ fn subregion_input_param_count(module: &Module, f: &LocalFunction, instr: &Instr /// value is the original condition and preceding values are restored /// as carryovers below the condition. /// -/// Returns `None` if any producer in this seq pushes a value whose -/// type can't be statically determined AND that value ends up in a -/// SubRegion's spill list. Producers that push unknown-type values -/// consumed before any SubRegion landing are harmless. +/// Returns `None` only if the exhaustive producer model disagrees with +/// validated IR. Reference, GC, EH, and multi-value spill types are all +/// preserved exactly. fn analyze_subregion_spill_types( module: &Module, f: &LocalFunction, @@ -5586,6 +6976,7 @@ fn analyze_subregion_spill_types( let is_fork_landing = match instr { Instr::Call(c) => fork_path.contains(&c.func), Instr::CallIndirect(_) => true, + Instr::CallRef(_) => true, _ => false, }; if is_fork_landing && direct_cursor < direct_idxs_at_this_seq.len() { @@ -5625,10 +7016,8 @@ fn analyze_subregion_spill_types( } } - // Advance the typed stack. Same logic as - // `walk_seq_for_carryovers` — known producers push `Some(ty)`, - // unknown producers push `None`. Fork-path Call/CallIndirect - // pops args and pushes typed results. + // Advance the exact typed stack. Fork-path Call/CallIndirect/CallRef + // pops args and pushes its declared result types. match instr { Instr::Call(c) if fork_path.contains(&c.func) => { let sig = module.types.get(module.funcs.get(c.func).ty()); @@ -5654,6 +7043,18 @@ fn analyze_subregion_spill_types( } continue; } + Instr::CallRef(call) => { + let sig = module.types.get(call.ty); + let n_args = sig.params().len() + 1; + if stack.len() < n_args { + return None; + } + stack.truncate(stack.len() - n_args); + for &ty in sig.results() { + stack.push(Some(ty)); + } + continue; + } _ => {} } @@ -5667,105 +7068,26 @@ fn analyze_subregion_spill_types( if pushes == 0 { continue; } - match instr { - Instr::Call(c) => { - let sig = module.types.get(module.funcs.get(c.func).ty()); - for &ty in sig.results() { - stack.push(Some(ty)); - } - continue; - } - Instr::CallRef(cr) => { - let sig = module.types.get(cr.ty); - for _ in sig.results() { - stack.push(None); - } - continue; - } - Instr::Block(b) => { - push_structured_results(&mut stack, module, f, b.seq, pushes); - continue; - } - Instr::Loop(l) => { - push_structured_results(&mut stack, module, f, l.seq, pushes); - continue; - } - Instr::IfElse(ie) => { - push_structured_results(&mut stack, module, f, ie.consequent, pushes); - continue; - } - Instr::TryTable(t) => { - push_structured_results(&mut stack, module, f, t.seq, pushes); - continue; - } - Instr::Try(t) => { - push_structured_results(&mut stack, module, f, t.seq, pushes); - continue; - } - _ => {} + let produced = typed_instruction_pushes(module, f, instr, &pre_stack)?; + if produced.len() != pushes { + return None; } - debug_assert_eq!(pushes, 1); - stack.push(typed_single_push(module, instr, &pre_stack)); + stack.extend(produced.into_iter().map(Some)); } StackEffect::Terminator => return Some(out), - StackEffect::Unknown => return None, } } Some(out) } -fn has_fork_call_in_catch_handler( - module: &Module, - func_id: FunctionId, - fork_path: &HashSet, -) -> bool { - let local = match &module.funcs.get(func_id).kind { - FunctionKind::Local(l) => l, - _ => return false, - }; - fn walk(f: &LocalFunction, seq: InstrSeqId, fork_path: &HashSet) -> bool { - for (instr, _) in &f.block(seq).instrs { - if let Instr::TryTable(tt) = instr { - for c in &tt.catches { - let handler = match c { - TryTableCatch::Catch { label, .. } - | TryTableCatch::CatchAll { label } - | TryTableCatch::CatchRef { label, .. } - | TryTableCatch::CatchAllRef { label } => *label, - }; - // try_table catch labels target an enclosing block, - // so a fork call in the body of THAT block is a - // fork-from-catch candidate. We approximate: if the - // handler label is reachable from a fork-path call - // in the function. Since walrus IR doesn't easily - // give us "code AT label X", we rely on the simpler - // existing detection in classify_nested_pattern - // which flags TryTable bodies; if you got here via - // the fall-through guard-dispatch path, B1 status - // is already known. Here we conservatively return - // false — let guard-dispatch handle (today's - // behavior). - let _ = handler; - } - } - for child in nested_seqs(instr) { - if walk(f, child, fork_path) { - return true; - } - } - } - false - } - walk(local, local.entry_block(), fork_path) -} - // --- Discovery: walk the function in DFS order, assigning call_idx -- #[derive(Debug, Clone, Copy)] enum NestedTarget { Direct(FunctionId), Indirect { table: TableId }, + Ref, } #[derive(Debug, Clone)] @@ -5773,7 +7095,9 @@ struct NestedCallSite { call_idx: u32, seq_id: InstrSeqId, target: NestedTarget, + direct_activation: bool, sig_ty: TypeId, + resume_ty: Option, loc: InstrLocId, } @@ -5846,7 +7170,9 @@ fn walk_discover( call_idx: idx, seq_id: seq, target: NestedTarget::Direct(c.func), + direct_activation: false, sig_ty: module.funcs.get(c.func).ty(), + resume_ty: None, loc: *loc, }); my_idxs.push(idx); @@ -5858,7 +7184,23 @@ fn walk_discover( call_idx: idx, seq_id: seq, target: NestedTarget::Indirect { table: ci.table }, + direct_activation: false, sig_ty: ci.ty, + resume_ty: None, + loc: *loc, + }); + my_idxs.push(idx); + } + Instr::CallRef(call) => { + let idx = *next_idx; + *next_idx += 1; + sites.push(NestedCallSite { + call_idx: idx, + seq_id: seq, + target: NestedTarget::Ref, + direct_activation: false, + sig_ty: call.ty, + resume_ty: None, loc: *loc, }); my_idxs.push(idx); @@ -5891,6 +7233,40 @@ fn walk_discover( } } +fn assert_nested_reference_call_alignment( + analysis: &FunctionReferenceAnalysis, + sites: &[NestedCallSite], +) { + assert_eq!( + analysis.call_sites.len(), + sites.len(), + "original reference analysis and nested transform discovered different call counts" + ); + for (reference, site) in analysis.call_sites.iter().zip(sites) { + let aligned = match (reference.kind, site.target) { + (OriginalCallKind::Direct(expected), NestedTarget::Direct(actual)) => { + expected == actual + } + ( + OriginalCallKind::Indirect { + table: expected_table, + ty: expected_ty, + }, + NestedTarget::Indirect { + table: actual_table, + }, + ) => expected_table == actual_table && expected_ty == site.sig_ty, + (OriginalCallKind::Ref { ty }, NestedTarget::Ref) => ty == site.sig_ty, + _ => false, + }; + assert!( + aligned, + "reference analysis call {:?} does not align with nested target {:?}", + reference.kind, site.target + ); + } +} + // --- The main transform ---------------------------------------------- #[allow(clippy::too_many_arguments)] @@ -5898,13 +7274,14 @@ fn instrument_one_function_nested_switch( module: &mut Module, func_id: FunctionId, runtime: &Runtime, + activations: &HashSet, fork_path: &HashSet, func_ordinal: u32, - aux_tables: &AuxTables, - ref_plan: &[RefLocalSlot], catch_plan: &[CatchRegionPlan], plain_catches: &[(InstrSeqId, Vec)], -) { + reference_analysis: &FunctionReferenceAnalysis, + unwind_frame_select: FunctionId, +) -> ResumeThunk { // Pre-existing user locals. let all_user_locals = collect_user_locals(module, func_id); let user_scalar_locals: Vec<(LocalId, ValType)> = all_user_locals @@ -5915,24 +7292,33 @@ fn instrument_one_function_nested_switch( // Discover all fork-path call sites (with assigned call_idxs in // DFS order) and the per-seq region info. - let (sites, regions) = discover_calls_and_regions(module, func_id, fork_path); + let (mut sites, regions) = discover_calls_and_regions(module, func_id, fork_path); + for site in &mut sites { + site.direct_activation = matches!( + site.target, + NestedTarget::Direct(target) if activations.contains(&target) + ); + let results = module.types.get(site.sig_ty).results().to_vec(); + site.resume_ty = Some(module.types.add(&[], &results)); + } + assert_nested_reference_call_alignment(reference_analysis, &sites); let n_calls = sites.len(); if n_calls == 0 { // Defensive: function should have at least one fork-path call // by virtue of being in fork_path. Bail out to existing // top-level switch-dispatch (which handles n_calls==0 cleanly). - instrument_one_function_switch( + return instrument_one_function_switch( module, func_id, runtime, + activations, fork_path, func_ordinal, - aux_tables, - ref_plan, catch_plan, plain_catches, + reference_analysis, + unwind_frame_select, ); - return; } // `HashMap` deliberately randomizes its iteration order. Keep one stable @@ -5955,7 +7341,7 @@ fn instrument_one_function_nested_switch( }; // Plan per-call argument materialization before allocating the - // frame. Pure scalar argument tails are replayed after POST_K; + // frame. Side-effect-free argument tails are replayed after POST_K; // all other shapes keep the existing frame-backed spill locals. let mut pending_arg_materializations: HashMap = HashMap::new(); @@ -5980,16 +7366,6 @@ fn instrument_one_function_nested_switch( .find(|site| site.call_idx == call_idx) .expect("call_idx must have a discovered site"); let arg_types = nested_call_arg_types(module, site); - for &ty in &arg_types { - if !is_scalar(ty) { - let name = func_name(module, func_id); - panic!( - "fork-instrument: function `{name}` has a nested fork-path call \ - with a ref-typed argument ({ty:?}). Aux-table arg spilling \ - is not yet supported in the nested per-block transform." - ); - } - } pending_arg_materializations.insert( call_idx, plan_call_arg_materialization(module, &chunks[landing_idx], arg_types), @@ -6018,21 +7394,23 @@ fn instrument_one_function_nested_switch( // round-trip through the fork frame so REWIND can reload them // beneath the call's result. // - // `compute_nested_carryover_types` may return `None` for shapes - // it can't statically type. Until sub-commit 2.5c flips the - // rejection in `seq_has_unsupported_carryover`, the only seqs - // that actually reach this point have already passed that check, - // so `None` here is unexpected — but we treat it identically to - // "no carryovers at any call" for safety (matches 2.4c behavior). + // Classification already proved the exact typed stack model. Do not turn + // a later analyzer disagreement into empty carryovers: that would silently + // lose activation state. let nested_carryover_types: HashMap> = - compute_nested_carryover_types(module, func_id, fork_path).unwrap_or_default(); - let mut carryover_spills: HashMap> = HashMap::new(); + compute_nested_carryover_types(module, func_id, fork_path).unwrap_or_else(|| { + panic!("typed nested carryover analysis changed after classification") + }); + let mut carryover_spills: HashMap> = HashMap::new(); for site in &sites { let cr_types: &[ValType] = nested_carryover_types .get(&site.call_idx) .map(Vec::as_slice) - .unwrap_or(&[]); - let spills: Vec = cr_types.iter().map(|&ty| module.locals.add(ty)).collect(); + .unwrap_or_else(|| panic!("typed carryover plan omitted call {}", site.call_idx)); + let spills: Vec = cr_types + .iter() + .map(|&ty| (module.locals.add(spill_storage_type(ty)), ty)) + .collect(); carryover_spills.insert(site.call_idx, spills); } @@ -6050,35 +7428,30 @@ fn instrument_one_function_nested_switch( .iter() .zip(arg_types.iter()) { - frame_scalars.push((lid, ty)); + if is_scalar(ty) { + frame_scalars.push((lid, ty)); + } } } for site in &sites { - let cr_types: &[ValType] = nested_carryover_types - .get(&site.call_idx) - .map(Vec::as_slice) - .unwrap_or(&[]); - for (&lid, &ty) in carryover_spills[&site.call_idx].iter().zip(cr_types.iter()) { - frame_scalars.push((lid, ty)); + for &(lid, ty) in &carryover_spills[&site.call_idx] { + if is_scalar(ty) { + frame_scalars.push((lid, ty)); + } } } - append_plain_catch_frame_scalars(&mut frame_scalars, &plain_catch_state); - // Synthetic locals. let catch_state_locals = if catch_plan.is_empty() && plain_catches.is_empty() { None } else { Some(CatchStateLocals { - catch_region_id: module.locals.add(ValType::I32), - exnref_slot: module.locals.add(ValType::I32), + catch_selector: module.locals.add(ValType::I32), }) }; // Tmp i32 used by the IfElse cond rewrite to swap stack order // (preserve original cond while computing force_flag and // is_rewind without touching the operand stack). let cond_swap_local = module.locals.add(ValType::I32); - let abort_live_frame = module.locals.add(ValType::I32); - // Pre-pass: walk each fork-bearing seq, identify its // SubRegion-with-1-i32-carryover landings, and pre-allocate spill // locals (+ tmp_result_local for blocks producing 1 i32). The @@ -6102,24 +7475,25 @@ fn instrument_one_function_nested_switch( let mut pending_plans: Vec<(InstrSeqId, usize, PendingCarryoverPlan)> = Vec::new(); for &seq_id in ®ion_ids { let direct = direct_idxs_per_seq.get(&seq_id).unwrap_or(&empty_idxs); - // Sub-commit 2.6a: typed analyser captures per-landing - // spill ValTypes (covering both SubRegion type-params and - // any extra carryover above them). None on unanalyzable - // shapes — `classify_nested_pattern` already gated on a - // best-effort version of this, so None here is the same - // conservative fallback (function routes to guard-dispatch - // unless 2.5c/2.6a's combined gates accepted it). + // The typed analyser captures both SubRegion parameters and extra + // carryovers. An analysis failure here is an internal invariant; + // defaulting to no spills would silently lose activation state. let spill_types = analyze_subregion_spill_types( module, local_ro, seq_id, fork_path, direct, ®ions, ) - .unwrap_or_default(); + .unwrap_or_else(|| { + panic!("typed SubRegion carryover analysis failed after classification") + }); let original = &local_ro.block(seq_id).instrs; let (chunks, landings) = partition_region_instrs(local_ro, original, direct, ®ions, fork_path); + assert_eq!( + spill_types.len(), + landings.len(), + "typed SubRegion carryover analysis and landing partition disagree" + ); for (landing_idx, landing) in landings.iter().enumerate() { - let Some(types) = spill_types.get(landing_idx) else { - continue; - }; + let types = &spill_types[landing_idx]; if types.is_empty() { continue; } @@ -6130,7 +7504,7 @@ fn instrument_one_function_nested_switch( }; if pure_allowed { if let Some((tail_len, tail)) = - split_pure_scalar_tail(module, &chunks[landing_idx], types) + split_pure_replay_tail(module, &chunks[landing_idx], types) { pending_plans.push(( seq_id, @@ -6164,8 +7538,10 @@ fn instrument_one_function_nested_switch( PendingCarryoverPlan::Spill { types } => { let mut spill_locals: Vec<(LocalId, ValType)> = Vec::with_capacity(types.len()); for &ty in &types { - let lid = module.locals.add(ty); - frame_scalars.push((lid, ty)); + let lid = module.locals.add(spill_storage_type(ty)); + if is_scalar(ty) { + frame_scalars.push((lid, ty)); + } spill_locals.push((lid, ty)); } CarryoverPlan::Spill { spill_locals } @@ -6218,7 +7594,7 @@ fn instrument_one_function_nested_switch( for (seq_id, types) in to_allocate { let mut locals: Vec<(LocalId, ValType)> = Vec::with_capacity(types.len()); for &ty in &types { - let lid = module.locals.add(ty); + let lid = module.locals.add(spill_storage_type(ty)); locals.push((lid, ty)); } body_param_locals.insert(seq_id, locals); @@ -6226,7 +7602,59 @@ fn instrument_one_function_nested_switch( } let locals_with_offsets = assign_local_offsets(&frame_scalars, LOCALS_START_OFFSET); - let frame_size = HEADER_SIZE + user_locals_size(&frame_scalars); + let ordinary_scalar_end = HEADER_SIZE + user_locals_size(&frame_scalars); + let catch_scalar_frame = plan_plain_catch_scalar_frame(&plain_catch_state, ordinary_scalar_end); + let scalar_end = catch_scalar_frame.frame_end(ordinary_scalar_end); + let mut per_call_references = vec![Vec::new(); n_calls]; + for site in &sites { + let call_idx = site.call_idx as usize; + arg_materializations[&site.call_idx] + .append_reference_inputs(module, &mut per_call_references[call_idx]); + for &(local, ty) in &carryover_spills[&site.call_idx] { + if let Some(reference) = supported_reference(ty) { + per_call_references[call_idx].push((local, reference)); + } + } + } + { + let local_ro = match &module.funcs.get(func_id).kind { + FunctionKind::Local(local) => local, + _ => unreachable!(), + }; + let empty_idxs = Vec::new(); + for &seq_id in ®ion_ids { + let direct = direct_idxs_per_seq.get(&seq_id).unwrap_or(&empty_idxs); + let original = &local_ro.block(seq_id).instrs; + let (_, landings) = + partition_region_instrs(local_ro, original, direct, ®ions, fork_path); + for (landing_idx, landing) in landings.iter().enumerate() { + let Some(CarryoverPlan::Spill { spill_locals }) = + carryover_plans.get(&(seq_id, landing_idx)) + else { + continue; + }; + let (first, last) = match landing.kind { + LandingKind::SubRegion { range_lo, range_hi } + | LandingKind::SubRegionIfElse { + range_lo, range_hi, .. + } => (range_lo, range_hi), + LandingKind::DirectCall { .. } => continue, + }; + for &(local, ty) in spill_locals { + let Some(reference) = supported_reference(ty) else { + continue; + }; + for call_idx in first..=last { + per_call_references[call_idx as usize].push((local, reference)); + } + } + } + } + } + append_resume_parameter_references(module, func_id, &mut per_call_references); + append_plain_catch_frame_references(&mut per_call_references, &plain_catch_state); + let reference_frame = plan_reference_frame(module, reference_analysis, per_call_references); + let frame_size = reference_frame.frame_end(scalar_end); let result_types: Vec = { let ty_id = module.funcs.get(func_id).ty(); @@ -6234,28 +7662,19 @@ fn instrument_one_function_nested_switch( }; let restart_loop_ty = InstrSeqType::new(&mut module.types, &[], &result_types); - // Plan catch handlers (Phase 6d). These remain dead code for the - // nested transform's MVP (no fork-from-catch), but the plumbing is - // preserved for ref-typed exnref locals that still round-trip. - let catch_handlers = - plan_catch_ref_handlers(module, func_id, catch_plan, aux_tables, &plain_catch_state); + let catch_handlers = plan_catch_handlers(catch_plan, &plain_catch_state); let memory = first_memory(module); let ptr_ty = runtime.buf_type; - // Phase 6c rewind-throw stubs (still emitted for try_table bodies - // without fork-path calls — preserves the exnref serialization - // path). Extended by B1 Stage 2 Task 2.3 with plain-catch arm - // dispatch when `plain_catches` lists arms for the region. - if !catch_plan.is_empty() && aux_tables.exnref.is_some() { + if !plain_catch_state.is_empty() { let catch_state = - catch_state_locals.expect("exnref catch plan requires catch-state locals"); + catch_state_locals.expect("tagged catch plan requires catch-state locals"); inject_rewind_throw_stubs( module, func_id, runtime, - catch_state.catch_region_id, - aux_tables, + catch_state.catch_selector, catch_plan, &plain_catch_state, ); @@ -6277,9 +7696,20 @@ fn instrument_one_function_nested_switch( .id(); let restart_loop = local.builder_mut().dangling_instr_seq(restart_loop_ty).id(); let abort = AbortDispatch { - live_frame: abort_live_frame, restart_loop, + frame_select: unwind_frame_select, }; + let catch_scalar_restore_dispatch = catch_state_locals.and_then(|catch_state| { + build_plain_catch_scalar_dispatch( + local, + runtime, + memory, + ptr_ty, + catch_state.catch_selector, + &catch_scalar_frame, + PlainCatchScalarIo::Restore, + ) + }); populate_preamble_then( local, @@ -6289,8 +7719,8 @@ fn instrument_one_function_nested_switch( ptr_ty, catch_state_locals, &locals_with_offsets, - ref_plan, - aux_tables, + catch_scalar_restore_dispatch, + &reference_frame, frame_size, ); @@ -6390,6 +7820,19 @@ fn instrument_one_function_nested_switch( // Build postamble — same as switch-dispatch. let mut postamble: Vec<(Instr, InstrLocId)> = Vec::new(); + let catch_scalar_save_dispatch = catch_state_locals.and_then(|catch_state| { + build_plain_catch_scalar_dispatch( + local, + runtime, + memory, + ptr_ty, + catch_state.catch_selector, + &catch_scalar_frame, + PlainCatchScalarIo::Save, + ) + }); + let reference_save_dispatch = + build_reference_save_dispatch(local, runtime, memory, ptr_ty, &reference_frame); populate_postamble( &mut postamble, runtime, @@ -6397,14 +7840,13 @@ fn instrument_one_function_nested_switch( ptr_ty, catch_state_locals, &locals_with_offsets, - ref_plan, - aux_tables, + catch_scalar_save_dispatch, + reference_save_dispatch, frame_size, func_ordinal, - &result_types, ); - // Wrap entry block with [preamble-if-else, Block(unwind_save), postamble]. + // Wrap entry block with [preamble-if-else, live-restart loop]. // The entry block's instrs (set by transform_entry_region) become // the body of `unwind_save`. We pull them out and place them inside // unwind_save here, then install the wrapper structure in entry. @@ -6414,8 +7856,7 @@ fn instrument_one_function_nested_switch( let s = &mut local.block_mut(unwind_save).instrs; s.extend(entry_body); } - - let entry_seq = &mut local.block_mut(restart_loop).instrs; + let entry_seq = &mut local.block_mut(entry_id).instrs; push_instr( entry_seq, Instr::GlobalGet(GlobalGet { @@ -6434,24 +7875,6 @@ fn instrument_one_function_nested_switch( op: BinaryOp::I32GeU, }), ); - push_instr( - entry_seq, - Instr::LocalGet(LocalGet { - local: abort_live_frame, - }), - ); - push_instr( - entry_seq, - Instr::Unop(walrus::ir::Unop { - op: UnaryOp::I32Eqz, - }), - ); - push_instr( - entry_seq, - Instr::Binop(Binop { - op: BinaryOp::I32And, - }), - ); push_instr( entry_seq, Instr::IfElse(IfElse { @@ -6459,27 +7882,39 @@ fn instrument_one_function_nested_switch( alternative: preamble_else, }), ); - push_instr(entry_seq, Instr::Block(Block { seq: unwind_save })); - entry_seq.extend(postamble); - let entry_seq = &mut local.block_mut(entry_id).instrs; - entry_seq.clear(); push_instr(entry_seq, Instr::Loop(Loop { seq: restart_loop })); + let restart_seq = &mut local.block_mut(restart_loop).instrs; + push_instr(restart_seq, Instr::Block(Block { seq: unwind_save })); + restart_seq.extend(postamble); - apply_catch_ref_handlers(module, func_id, &catch_handlers, aux_tables); - - // Stage 2 (B1) plain-catch capture-block emission. Runs AFTER - // Phase 6 so it sees post-Phase-6 try_table locations. + // Tagged-catch capture emission runs after the nested body rebuild. if let Some(catch_state) = catch_state_locals { + shield_private_unwind_from_user_catches(module, func_id, runtime); apply_plain_catch_handlers( module, func_id, - catch_state.catch_region_id, + catch_state.catch_selector, &plain_catch_state, - catch_plan, &catch_handlers, ); } else { debug_assert!(plain_catches.is_empty()); + shield_private_unwind_from_user_catches(module, func_id, runtime); + } + + ResumeThunk { + func_ordinal, + function: emit_resume_thunk( + module, + func_id, + runtime, + memory, + ptr_ty, + frame_size, + &locals_with_offsets, + &reference_frame, + func_ordinal, + ), } } @@ -6498,7 +7933,7 @@ fn emit_chunk_tail_for_landing( out: &mut Vec<(Instr, InstrLocId)>, landing: &LandingInfo, arg_materializations: &HashMap, - carryover_spills: &HashMap>, + carryover_spills: &HashMap>, cond_swap_local: LocalId, ) { match &landing.kind { @@ -6510,7 +7945,7 @@ fn emit_chunk_tail_for_landing( // matching top-level switch-dispatch's 2.4c behavior). // `carryover_spills` is keyed by call_idx; an absent entry // is treated as no-carryover. - let empty: Vec = Vec::new(); + let empty: Vec = Vec::new(); let cr = carryover_spills.get(call_idx).unwrap_or(&empty); emit_spill_call_tail(out, &arg_materializations[call_idx], cr); } @@ -6551,8 +7986,10 @@ fn emit_chunk_tail_for_landing( fn nested_call_arg_types(module: &Module, site: &NestedCallSite) -> Vec { let mut arg_types: Vec = module.types.get(site.sig_ty).params().to_vec(); - if matches!(site.target, NestedTarget::Indirect { .. }) { - arg_types.push(ValType::I32); + match site.target { + NestedTarget::Indirect { .. } => arg_types.push(ValType::I32), + NestedTarget::Ref => arg_types.push(ValType::Ref(RefType::FUNCREF)), + NestedTarget::Direct(_) => {} } arg_types } @@ -6585,7 +8022,7 @@ fn transform_region_seq( sites: &[NestedCallSite], fork_path: &HashSet, arg_materializations: &HashMap, - carryover_spills: &HashMap>, + carryover_spills: &HashMap>, carryover_plans: &HashMap<(InstrSeqId, usize), CarryoverPlan>, catch_handlers: &[CatchHandlerInfo], runtime: &Runtime, @@ -6619,11 +8056,8 @@ fn transform_region_seq( // Ordered deepest-first to match the original parent-stack layout. if !body_param_locals.is_empty() && !chunks.is_empty() { let mut prefix: Vec<(Instr, InstrLocId)> = Vec::with_capacity(body_param_locals.len()); - for (lid, _ty) in body_param_locals.iter() { - prefix.push(( - Instr::LocalGet(LocalGet { local: *lid }), - InstrLocId::default(), - )); + for &(local, ty) in body_param_locals { + push_typed_local_get(&mut prefix, local, ty); } prefix.extend(std::mem::take(&mut chunks[0])); chunks[0] = prefix; @@ -6724,7 +8158,7 @@ fn transform_entry_region( sites: &[NestedCallSite], fork_path: &HashSet, arg_materializations: &HashMap, - carryover_spills: &HashMap>, + carryover_spills: &HashMap>, carryover_plans: &HashMap<(InstrSeqId, usize), CarryoverPlan>, catch_handlers: &[CatchHandlerInfo], runtime: &Runtime, @@ -6956,6 +8390,7 @@ fn partition_region_instrs( let is_fork_landing = match instr { Instr::Call(c) => fork_path.contains(&c.func), Instr::CallIndirect(_) => true, + Instr::CallRef(_) => true, _ => false, }; if is_fork_landing && direct_cursor < direct_idxs_at_this_seq.len() { @@ -7160,7 +8595,7 @@ fn populate_region_dispatch_structure( landings: &[LandingInfo], sites: &[NestedCallSite], arg_materializations: &HashMap, - carryover_spills: &HashMap>, + carryover_spills: &HashMap>, catch_handlers: &[CatchHandlerInfo], runtime: &Runtime, memory: MemoryId, @@ -7295,14 +8730,14 @@ fn emit_post_landing( landing: &LandingInfo, sites: &[NestedCallSite], arg_materializations: &HashMap, - carryover_spills: &HashMap>, - catch_handlers: &[CatchHandlerInfo], + carryover_spills: &HashMap>, + _catch_handlers: &[CatchHandlerInfo], runtime: &Runtime, memory: MemoryId, ptr_ty: ValType, frame_size: u32, cond_swap_local: LocalId, - catch_state_locals: Option, + _catch_state_locals: Option, unwind_save: InstrSeqId, abort: AbortDispatch, ) { @@ -7317,39 +8752,39 @@ fn emit_post_landing( // the carryovers + result on the stack — matching the // original code's expected shape, same as top-level // switch-dispatch's `emit_post_call_via_local`. - let empty: Vec = Vec::new(); + let empty: Vec = Vec::new(); let carryovers = carryover_spills.get(call_idx).unwrap_or(&empty); { let s = &mut local.block_mut(seq_id).instrs; - for &l in carryovers.iter() { - push_instr(s, Instr::LocalGet(LocalGet { local: l })); + for &(local, ty) in carryovers { + push_typed_local_get(s, local, ty); } - emit_materialized_call_args(s, &arg_materializations[call_idx]); - let call_instr = match site.target { - NestedTarget::Direct(func) => Instr::Call(Call { func }), - NestedTarget::Indirect { table } => Instr::CallIndirect(CallIndirect { - ty: site.sig_ty, - table, - }), - }; - s.push((call_instr, site.loc)); } - // Phase 6e + call_idx frame write + UNWIND branch. Phase 6e is - // delayed until after frame reservation succeeds so an abort can - // replay the still-live activation without publishing state. - emit_call_index_store_and_unwind_branch( + let target = match site.target { + NestedTarget::Direct(func) => CallTarget::Direct(func), + NestedTarget::Indirect { table } => CallTarget::Indirect { table }, + NestedTarget::Ref => CallTarget::Ref, + }; + emit_replay_routed_call_with_unwind_boundary( local, seq_id, + target, + site.direct_activation, + site.sig_ty, + site.resume_ty + .expect("nested call site resume type was not assigned"), + site.loc, + &arg_materializations[call_idx], + *call_idx, runtime, memory, ptr_ty, frame_size, - *call_idx, unwind_save, - catch_handlers, - catch_state_locals, abort, ); + // The statically scoped private-tag boundary records this exact + // call before any result becomes visible to the continuation. } LandingKind::SubRegion { .. } => { // Block/Loop/TryTable: preserve the enclosing instr @@ -7382,8 +8817,8 @@ fn emit_post_landing( if let Some(plan) = &landing.carryover { match plan { CarryoverPlan::Spill { spill_locals } => { - for (l, _ty) in spill_locals.iter() { - push_instr(s, Instr::LocalGet(LocalGet { local: *l })); + for &(local, ty) in spill_locals { + push_typed_local_get(s, local, ty); } } CarryoverPlan::PureTail { tail, .. } => { @@ -7425,8 +8860,8 @@ fn emit_post_landing( .last() .copied() .expect("IfElse spill plan must include the condition"); - for (l, _ty) in spill_locals.iter().take(spill_locals.len() - 1) { - push_instr(s, Instr::LocalGet(LocalGet { local: *l })); + for &(local, ty) in spill_locals.iter().take(spill_locals.len() - 1) { + push_typed_local_get(s, local, ty); } IfElseCondSource::Local(cond_local) } @@ -7965,10 +9400,10 @@ mod trampoline_tests { } #[test] - fn compute_carryover_types_unknown_producer_in_carryover_returns_none() { - // Contrast case: a ref-typed producer's value IS the carryover - // at a fork-path call. The switch-dispatch spill path only - // supports scalar ValTypes, so the analyser correctly fails. + fn compute_carryover_types_preserves_reference_for_validation() { + // A reference producer's value is the carryover at a fork-path call. + // Preserve the exact type so the activation recipe planner can assign + // its codec class and call-specific vector ownership. let wat = r#" (module (import "kernel" "kernel_fork" (func $fork (result i32))) @@ -7987,9 +9422,7 @@ mod trampoline_tests { let main = find_func_id(&module, "main"); let fork_path = build_fork_path(&module, &["fork", "main"]); let result = compute_carryover_types(&module, main, &fork_path); - // Carryover would be [None] (the ref-typed result). Analyser - // refuses -> None. - assert_eq!(result, None); + assert_eq!(result, Some(vec![vec![ValType::Ref(RefType::EXTERNREF)]])); } // Sub-commit 2.5a: nested-aware carryover analyser. The analyser diff --git a/crates/fork-instrument/src/legacy_dlopen.rs b/crates/fork-instrument/src/legacy_dlopen.rs new file mode 100644 index 0000000000..08ea0aee0b --- /dev/null +++ b/crates/fork-instrument/src/legacy_dlopen.rs @@ -0,0 +1,385 @@ +//! Lower the historical monolithic dynamic-loader import to ABI 43's staged +//! non-reentrant protocol. +//! +//! `env.__wasm_dlopen` used to compile, instantiate, and synchronously call +//! side-module initialization code before its host import returned. A fork +//! below that callback leaves a JavaScript activation in the middle of the +//! Wasm stack, and that activation has no deterministic fresh-child recipe. +//! The staged imports return one initializer table entry at a time so the +//! initializer instead runs as an ordinary Wasm-to-Wasm call. + +use anyhow::{Result, ensure}; +use walrus::ir::{ + BinaryOp, Binop, Br, Call, CallIndirect, Const, LocalGet, LocalSet, Return, ReturnCall, + UnaryOp, Unop, Unreachable, Value, +}; +use walrus::{ + ExportItem, FunctionBuilder, FunctionId, FunctionKind, ImportKind, LocalId, Module, RefType, + TableId, TypeId, ValType, +}; + +const IMPORT_MODULE: &str = "env"; +const LEGACY_IMPORT: &str = "__wasm_dlopen"; +const MAIN_IMPORT: &str = "__wasm_dlopen_main"; +const PREPARE_IMPORT: &str = "__wasm_dlopen_prepare"; +const NEXT_IMPORT: &str = "__wasm_dlopen_next"; +const COMMIT_IMPORT: &str = "__wasm_dlopen_commit"; +const SIGNAL_CHECKPOINT_EXPORT: &str = "__wasm_posix_signal_checkpoint"; +const DEFAULT_RTLD_GLOBAL: i32 = 0x100; + +#[derive(Clone)] +struct LegacyImport { + function: FunctionId, + import: walrus::ImportId, + ty: TypeId, + params: Vec, +} + +/// Replace every canonical legacy loader import with a local staged adapter. +/// +/// The original `FunctionId` is retained. That is important beyond direct +/// calls: exports, active/passive element segments, constant expressions, and +/// `ref.func` instructions all continue to name the now-local adapter without +/// an incomplete graph-wide reference rewrite. +pub fn lower(module: &mut Module) -> Result { + let legacy = collect_legacy_imports(module); + if legacy.is_empty() { + return Ok(0); + } + + for import in &legacy { + validate_signature(module, import)?; + } + + let table = process_function_table(module); + let checkpoint = exported_signal_checkpoint(module); + let main = import_function(module, MAIN_IMPORT, &[], &[ValType::I32]); + let next = import_function( + module, + NEXT_IMPORT, + &[ValType::I32], + &[ValType::I32], + ); + let commit = import_function( + module, + COMMIT_IMPORT, + &[ValType::I32], + &[ValType::I32], + ); + let driver = add_staged_driver(module, table, next, commit, checkpoint); + + for import in &legacy { + let prepare_params = match import.params.as_slice() { + [pointer, length] => vec![*pointer, *length, *pointer, *length, ValType::I32], + [_, _, _, _] => { + let mut params = import.params.clone(); + params.push(ValType::I32); + params + } + _ => import.params.clone(), + }; + let prepare = import_function( + module, + PREPARE_IMPORT, + &prepare_params, + &[ValType::I32], + ); + replace_import_with_adapter(module, import, main, prepare, driver)?; + } + + Ok(legacy.len()) +} + +fn collect_legacy_imports(module: &Module) -> Vec { + module + .imports + .iter() + .filter_map(|import| { + if import.module != IMPORT_MODULE || import.name != LEGACY_IMPORT { + return None; + } + let ImportKind::Function(function) = import.kind else { + return None; + }; + let ty = module.funcs.get(function).ty(); + Some(LegacyImport { + function, + import: import.id(), + ty, + params: module.types.get(ty).params().to_vec(), + }) + }) + .collect() +} + +fn validate_signature(module: &Module, import: &LegacyImport) -> Result<()> { + let signature = module.types.get(import.ty); + ensure!( + matches!(import.params.len(), 2 | 4 | 5) + && signature.results() == [ValType::I32] + && matches!(import.params[0], ValType::I32 | ValType::I64) + && matches!(import.params[1], ValType::I32 | ValType::I64) + && (import.params.len() == 2 || import.params[2] == import.params[0]) + && (import.params.len() == 2 + || matches!(import.params[3], ValType::I32 | ValType::I64)) + && (import.params.len() != 5 || import.params[4] == ValType::I32), + "fork-instrument: reserved env.__wasm_dlopen import has signature \ + {:?} -> {:?}; expected (pointer, integer[, pointer, integer[, i32]]) -> i32", + signature.params(), + signature.results(), + ); + Ok(()) +} + +fn process_function_table(module: &mut Module) -> TableId { + let exported = module.exports.iter().find_map(|export| { + if export.name != "__indirect_function_table" { + return None; + } + match export.item { + ExportItem::Table(table) if module.tables.get(table).element_ty == RefType::FUNCREF => { + Some(table) + } + _ => None, + } + }); + exported.unwrap_or_else(|| { + // A successful Kandelo side-module load already requires the canonical + // exported table and stack pointer. Keep malformed/dead legacy imports + // valid after lowering without inventing a second observable process + // function-pointer table; `prepare` will report the missing host + // linker contract before this private fallback can be reached. + module.tables.add_local(false, 1, None, RefType::FUNCREF) + }) +} + +fn exported_signal_checkpoint(module: &Module) -> Option { + module.exports.iter().find_map(|export| { + if export.name != SIGNAL_CHECKPOINT_EXPORT { + return None; + } + let ExportItem::Function(function) = export.item else { + return None; + }; + let signature = module.types.get(module.funcs.get(function).ty()); + (signature.params().is_empty() && signature.results().is_empty()).then_some(function) + }) +} + +fn import_function( + module: &mut Module, + name: &str, + params: &[ValType], + results: &[ValType], +) -> FunctionId { + if let Some(function) = module.imports.iter().find_map(|import| { + if import.module != IMPORT_MODULE || import.name != name { + return None; + } + let ImportKind::Function(function) = import.kind else { + return None; + }; + let signature = module.types.get(module.funcs.get(function).ty()); + (signature.params() == params && signature.results() == results).then_some(function) + }) { + return function; + } + let ty = module.types.add(params, results); + module.add_import_func(IMPORT_MODULE, name, ty).0 +} + +fn add_staged_driver( + module: &mut Module, + table: TableId, + next: FunctionId, + commit: FunctionId, + checkpoint: Option, +) -> FunctionId { + let token = module.locals.add(ValType::I32); + let entry = module.locals.add(ValType::I32); + let call_ty = module.types.add(&[], &[]); + let mut builder = + FunctionBuilder::new(&mut module.types, &[ValType::I32], &[ValType::I32]); + builder.name("__wpk_fork_legacy_dlopen_driver".into()); + + let mut loop_body = builder.dangling_instr_seq(None); + let loop_id = loop_body.id(); + + // A prepare call can issue loader-owned channel requests. Checkpoint only + // after the adapter's tail call has removed its dead pointer parameters, + // keeping those values out of a continuation captured by a signal handler. + call_optional(&mut loop_body, checkpoint); + + local_get(&mut loop_body, token); + call(&mut loop_body, next); + local_set(&mut loop_body, entry); + call_optional(&mut loop_body, checkpoint); + + local_get(&mut loop_body, entry); + i32_const(&mut loop_body, 0); + binop(&mut loop_body, BinaryOp::I32LtS); + loop_body.if_else( + None, + |failed| { + i32_const(failed, 0); + ret(failed); + }, + |_| {}, + ); + + local_get(&mut loop_body, entry); + unop(&mut loop_body, UnaryOp::I32Eqz); + loop_body.if_else( + None, + |finished| { + local_get(finished, token); + call(finished, commit); + local_set(finished, entry); + call_optional(finished, checkpoint); + local_get(finished, entry); + ret(finished); + }, + |_| {}, + ); + + local_get(&mut loop_body, entry); + if module.tables.get(table).table64 { + unop(&mut loop_body, UnaryOp::I64ExtendUI32); + } + loop_body.instr(CallIndirect { ty: call_ty, table }); + loop_body.instr(Br { block: loop_id }); + drop(loop_body); + + let mut body = builder.func_body(); + body.instr(walrus::ir::Loop { seq: loop_id }); + body.instr(Unreachable {}); + builder.finish(vec![token], &mut module.funcs) +} + +fn replace_import_with_adapter( + module: &mut Module, + import: &LegacyImport, + main: FunctionId, + prepare: FunctionId, + driver: FunctionId, +) -> Result<()> { + let args: Vec = import + .params + .iter() + .copied() + .map(|ty| module.locals.add(ty)) + .collect(); + let mut builder = + FunctionBuilder::new(&mut module.types, &import.params, &[ValType::I32]); + builder.name("__wpk_fork_legacy_dlopen_adapter".into()); + + let body = &mut builder.func_body(); + if args.len() >= 4 { + local_get(body, args[1]); + integer_eqz(body, import.params[1]); + local_get(body, args[3]); + integer_eqz(body, import.params[3]); + binop(body, BinaryOp::I32And); + body.if_else( + None, + |main_program| { + call(main_program, main); + ret(main_program); + }, + |_| {}, + ); + } + + local_get(body, args[0]); + local_get(body, args[1]); + if args.len() == 2 { + // The original Kandelo loader ABI supplied no pathname. Preserve its + // deterministic `dlopen::` naming rule by passing an + // empty name range; the process Worker derives the historical name + // after validating both ranges. + integer_const_zero(body, import.params[0]); + integer_const_zero(body, import.params[1]); + } else { + local_get(body, args[2]); + local_get(body, args[3]); + } + if args.len() == 5 { + local_get(body, args[4]); + } else { + i32_const(body, DEFAULT_RTLD_GLOBAL); + } + call(body, prepare); + // WHY: no guest code ran during prepare, so this true tail call removes + // the adapter's byte/name pointer parameters before a constructor or + // signal handler can fork. Only the driver's two i32 values can then add + // to the continuation payload, regardless of pointer width. + body.instr(ReturnCall { func: driver }); + + let local = builder.local_func(args); + ensure!( + local.ty() == import.ty, + "fork-instrument: legacy dlopen adapter did not retain its canonical function type", + ); + let function = module.funcs.get_mut(import.function); + function.kind = FunctionKind::Local(local); + function.name = Some("__wpk_fork_legacy_dlopen_adapter".into()); + module.imports.delete(import.import); + Ok(()) +} + +fn integer_eqz(body: &mut walrus::InstrSeqBuilder<'_>, ty: ValType) { + unop( + body, + match ty { + ValType::I32 => UnaryOp::I32Eqz, + ValType::I64 => UnaryOp::I64Eqz, + _ => unreachable!("validated legacy dlopen integer"), + }, + ); +} + +fn integer_const_zero(body: &mut walrus::InstrSeqBuilder<'_>, ty: ValType) { + body.instr(Const { + value: match ty { + ValType::I32 => Value::I32(0), + ValType::I64 => Value::I64(0), + _ => unreachable!("validated legacy dlopen integer"), + }, + }); +} + +fn local_get(body: &mut walrus::InstrSeqBuilder<'_>, local: LocalId) { + body.instr(LocalGet { local }); +} + +fn local_set(body: &mut walrus::InstrSeqBuilder<'_>, local: LocalId) { + body.instr(LocalSet { local }); +} + +fn call(body: &mut walrus::InstrSeqBuilder<'_>, function: FunctionId) { + body.instr(Call { func: function }); +} + +fn call_optional(body: &mut walrus::InstrSeqBuilder<'_>, function: Option) { + if let Some(function) = function { + call(body, function); + } +} + +fn i32_const(body: &mut walrus::InstrSeqBuilder<'_>, value: i32) { + body.instr(Const { + value: Value::I32(value), + }); +} + +fn binop(body: &mut walrus::InstrSeqBuilder<'_>, op: BinaryOp) { + body.instr(Binop { op }); +} + +fn unop(body: &mut walrus::InstrSeqBuilder<'_>, op: UnaryOp) { + body.instr(Unop { op }); +} + +fn ret(body: &mut walrus::InstrSeqBuilder<'_>) { + body.instr(Return {}); +} diff --git a/crates/fork-instrument/src/legacy_eh.rs b/crates/fork-instrument/src/legacy_eh.rs new file mode 100644 index 0000000000..5bdc61092c --- /dev/null +++ b/crates/fork-instrument/src/legacy_eh.rs @@ -0,0 +1,880 @@ +//! Legacy exception-handler normalization for fork-reachable functions. +//! +//! A legacy `catch` enters an implicit engine-owned exception context. That +//! context is exactly the state a continuation cannot recover in a fresh Wasm +//! instance. This pass converts legacy handlers to modern `try_table` +//! `catch_ref`/`catch_all_ref` clauses before continuation planning. The caught +//! exception is held in an ordinary activation local, so the reference recipe +//! analysis gives it the same linked-frame ownership as every other live +//! reference. +//! +//! Legacy `delegate` has no handler activation and therefore needs no +//! conversion. Keeping it native also preserves its relative-depth semantics +//! without inventing an exception round trip. + +use anyhow::{Result, bail, ensure}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use walrus::{ + AbstractHeapType, FunctionId, FunctionKind, HeapType, LocalFunction, LocalId, Module, RefType, + ValType, + ir::{ + Block, Br, Instr, InstrLocId, InstrSeqId, InstrSeqType, LegacyCatch, LocalGet, LocalSet, + RefAsNonNull, RefNull, ThrowRef, Try, TryTable, TryTableCatch, + }, +}; + +#[derive(Clone)] +struct HandlerMeta { + root: InstrSeqId, + exception: LocalId, + /// Outer-to-inner legacy handlers active while this handler executes. + ancestors: Vec, +} + +#[derive(Clone)] +struct ExitShim { + seq: InstrSeqId, + target: InstrSeqId, + clear: Vec, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum LabelKind { + Loop, + Other, +} + +#[derive(Clone, Copy)] +struct TrySite { + body: InstrSeqId, + depth: u32, +} + +#[derive(Clone)] +struct RethrowRewrite { + seq: InstrSeqId, + index: usize, + loc: InstrLocId, + exception: LocalId, + clear: Vec, +} + +const NULLABLE_EXNREF: RefType = RefType { + nullable: true, + heap_type: HeapType::Abstract(AbstractHeapType::Exn), +}; + +const NON_NULL_EXNREF: RefType = RefType { + nullable: false, + heap_type: HeapType::Abstract(AbstractHeapType::Exn), +}; + +/// Normalize legacy handlers only in functions that can own a fork +/// continuation. References and legacy EH outside the fork closure remain +/// byte-for-byte under Walrus's normal re-emission. +pub fn normalize_fork_path(module: &mut Module, fork_path: &HashSet) -> Result<()> { + let mut functions: Vec<_> = fork_path.iter().copied().collect(); + functions.sort(); + for function in functions { + if !matches!(module.funcs.get(function).kind, FunctionKind::Local(_)) { + continue; + } + normalize_function(module, function)?; + } + Ok(()) +} + +fn normalize_function(module: &mut Module, function: FunctionId) -> Result<()> { + let (entry, handler_layout, label_kinds, try_sites) = { + let local = local(module, function); + let entry = local.entry_block(); + let mut handlers = Vec::new(); + let mut labels = HashMap::from([(entry, LabelKind::Other)]); + let mut tries = Vec::new(); + collect_structure(local, entry, 0, &[], &mut handlers, &mut labels, &mut tries); + (entry, handlers, labels, tries) + }; + + if handler_layout.is_empty() { + return Ok(()); + } + + let mut handlers = HashMap::new(); + for (root, ancestors) in handler_layout { + let exception = module.locals.add(ValType::Ref(NULLABLE_EXNREF)); + handlers.insert( + root, + HandlerMeta { + root, + exception, + ancestors, + }, + ); + } + + rewrite_rethrows(module, function, entry, &handlers)?; + + let full_subtrees: HashMap> = handlers + .keys() + .copied() + .map(|root| { + let mut subtree = HashSet::new(); + collect_subtree(local(module, function), root, &mut subtree); + (root, subtree) + }) + .collect(); + + // Allocate every branch-cleanup label before lowering changes ancestry. + // All branch opcodes, including br_table and br_on_*, can then be retargeted + // without lowering them into slower instruction sequences. + let mut handler_shims: HashMap> = HashMap::new(); + let handler_roots: HashSet<_> = handlers.keys().copied().collect(); + let mut ordered_handlers: Vec<_> = handlers.values().cloned().collect(); + ordered_handlers.sort_by_key(|handler| handler.root); + for handler in ordered_handlers { + let direct_subtree = + collect_direct_handler_subtree(local(module, function), handler.root, &handler_roots); + let mut targets = BTreeMap::>::new(); + collect_exit_targets( + local(module, function), + handler.root, + &direct_subtree, + &handler, + &handlers, + &full_subtrees, + &mut targets, + ); + + let mut shims = Vec::new(); + let (handler_params, _) = + params_results(module, local(module, function).block(handler.root).ty); + for (target, clear) in targets { + let branch_values = branch_value_types(module, function, target, &label_kinds)?; + // The normal catch-entry path carries the legacy tag payload + // through every enclosing cleanup shim. A branch to a shim label + // supplies only its result tuple, so these params do not alter the + // retargeted branch signature. + let ty = InstrSeqType::new(&mut module.types, &handler_params, &branch_values); + let seq = local_mut(module, function) + .builder_mut() + .dangling_instr_seq(ty) + .id(); + shims.push(ExitShim { seq, target, clear }); + } + let replacements: HashMap<_, _> = + shims.iter().map(|shim| (shim.target, shim.seq)).collect(); + retarget_handler_exits( + local_mut(module, function), + handler.root, + &direct_subtree, + &replacements, + ); + handler_shims.insert(handler.root, shims); + } + + let mut sites = try_sites; + sites.sort_by_key(|site| std::cmp::Reverse(site.depth)); + for site in sites { + lower_try( + module, + function, + entry, + site.body, + &handlers, + &handler_shims, + )?; + } + + Ok(()) +} + +fn collect_structure( + local: &LocalFunction, + seq: InstrSeqId, + depth: u32, + active_handlers: &[InstrSeqId], + handlers: &mut Vec<(InstrSeqId, Vec)>, + labels: &mut HashMap, + tries: &mut Vec, +) { + for (instr, _) in &local.block(seq).instrs { + match instr { + Instr::Block(block) => { + labels.insert(block.seq, LabelKind::Other); + collect_structure( + local, + block.seq, + depth + 1, + active_handlers, + handlers, + labels, + tries, + ); + } + Instr::Loop(loop_) => { + labels.insert(loop_.seq, LabelKind::Loop); + collect_structure( + local, + loop_.seq, + depth + 1, + active_handlers, + handlers, + labels, + tries, + ); + } + Instr::IfElse(if_) => { + for child in [if_.consequent, if_.alternative] { + labels.insert(child, LabelKind::Other); + collect_structure( + local, + child, + depth + 1, + active_handlers, + handlers, + labels, + tries, + ); + } + } + Instr::TryTable(table) => { + labels.insert(table.seq, LabelKind::Other); + collect_structure( + local, + table.seq, + depth + 1, + active_handlers, + handlers, + labels, + tries, + ); + } + Instr::Try(try_) => { + labels.insert(try_.seq, LabelKind::Other); + collect_structure( + local, + try_.seq, + depth + 1, + active_handlers, + handlers, + labels, + tries, + ); + if try_ + .catches + .iter() + .any(|catch| !matches!(catch, LegacyCatch::Delegate { .. })) + { + tries.push(TrySite { + body: try_.seq, + depth, + }); + } + for catch in &try_.catches { + let handler = match catch { + LegacyCatch::Catch { handler, .. } | LegacyCatch::CatchAll { handler } => { + *handler + } + LegacyCatch::Delegate { .. } => continue, + }; + labels.insert(handler, LabelKind::Other); + handlers.push((handler, active_handlers.to_vec())); + let mut nested = active_handlers.to_vec(); + nested.push(handler); + collect_structure(local, handler, depth + 1, &nested, handlers, labels, tries); + } + } + _ => {} + } + } +} + +fn rewrite_rethrows( + module: &mut Module, + function: FunctionId, + entry: InstrSeqId, + handlers: &HashMap, +) -> Result<()> { + let rewrites = { + let mut rewrites = Vec::new(); + collect_rethrows( + local(module, function), + entry, + &mut vec![(entry, None)], + handlers, + &mut rewrites, + )?; + rewrites + }; + + let mut by_seq = BTreeMap::>::new(); + for rewrite in rewrites { + by_seq.entry(rewrite.seq).or_default().push(rewrite); + } + for (seq, mut rewrites) in by_seq { + rewrites.sort_by_key(|rewrite| std::cmp::Reverse(rewrite.index)); + let instrs = &mut local_mut(module, function).block_mut(seq).instrs; + for rewrite in rewrites { + let mut replacement = Vec::new(); + replacement.push(( + Instr::LocalGet(LocalGet { + local: rewrite.exception, + }), + rewrite.loc, + )); + replacement.push((Instr::RefAsNonNull(RefAsNonNull {}), rewrite.loc)); + for local in rewrite.clear { + replacement.push(( + Instr::RefNull(RefNull { + ty: NULLABLE_EXNREF, + }), + rewrite.loc, + )); + replacement.push((Instr::LocalSet(LocalSet { local }), rewrite.loc)); + } + replacement.push((Instr::ThrowRef(ThrowRef {}), rewrite.loc)); + instrs.splice(rewrite.index..=rewrite.index, replacement); + } + } + Ok(()) +} + +fn collect_rethrows( + local: &LocalFunction, + seq: InstrSeqId, + stack: &mut Vec<(InstrSeqId, Option)>, + handlers: &HashMap, + out: &mut Vec, +) -> Result<()> { + for (index, (instr, loc)) in local.block(seq).instrs.iter().enumerate() { + if let Instr::Rethrow(rethrow) = instr { + let depth = rethrow.relative_depth as usize; + ensure!( + depth < stack.len(), + "fork-instrument: legacy rethrow depth {} exceeds control depth {}", + depth, + stack.len(), + ); + let target_index = stack.len() - 1 - depth; + let Some(exception) = stack[target_index].1 else { + bail!( + "fork-instrument: legacy rethrow depth {} does not target a catch handler", + depth, + ); + }; + let mut clear = Vec::new(); + for (_, local) in stack[target_index..].iter().rev() { + if let Some(local) = local + && !clear.contains(local) + { + clear.push(*local); + } + } + out.push(RethrowRewrite { + seq, + index, + loc: *loc, + exception, + clear, + }); + } + + match instr { + Instr::Block(block) => { + collect_rethrows_child(local, block.seq, None, stack, handlers, out)? + } + Instr::Loop(loop_) => { + collect_rethrows_child(local, loop_.seq, None, stack, handlers, out)? + } + Instr::IfElse(if_) => { + collect_rethrows_child(local, if_.consequent, None, stack, handlers, out)?; + collect_rethrows_child(local, if_.alternative, None, stack, handlers, out)?; + } + Instr::TryTable(table) => { + collect_rethrows_child(local, table.seq, None, stack, handlers, out)? + } + Instr::Try(try_) => { + collect_rethrows_child(local, try_.seq, None, stack, handlers, out)?; + for catch in &try_.catches { + let handler = match catch { + LegacyCatch::Catch { handler, .. } | LegacyCatch::CatchAll { handler } => { + *handler + } + LegacyCatch::Delegate { .. } => continue, + }; + collect_rethrows_child( + local, + handler, + Some(handlers[&handler].exception), + stack, + handlers, + out, + )?; + } + } + _ => {} + } + } + Ok(()) +} + +fn collect_rethrows_child( + local: &LocalFunction, + child: InstrSeqId, + handler: Option, + stack: &mut Vec<(InstrSeqId, Option)>, + handlers: &HashMap, + out: &mut Vec, +) -> Result<()> { + stack.push((child, handler)); + collect_rethrows(local, child, stack, handlers, out)?; + stack.pop(); + Ok(()) +} + +fn collect_subtree(local: &LocalFunction, seq: InstrSeqId, out: &mut HashSet) { + if !out.insert(seq) { + return; + } + for (instr, _) in &local.block(seq).instrs { + for child in children(instr) { + collect_subtree(local, child, out); + } + } +} + +fn collect_direct_handler_subtree( + local: &LocalFunction, + root: InstrSeqId, + handler_roots: &HashSet, +) -> HashSet { + fn visit( + local: &LocalFunction, + root: InstrSeqId, + seq: InstrSeqId, + handler_roots: &HashSet, + out: &mut HashSet, + ) { + if seq != root && handler_roots.contains(&seq) { + return; + } + if !out.insert(seq) { + return; + } + for (instr, _) in &local.block(seq).instrs { + for child in children(instr) { + visit(local, root, child, handler_roots, out); + } + } + } + + let mut out = HashSet::new(); + visit(local, root, root, handler_roots, &mut out); + out +} + +fn collect_exit_targets( + local: &LocalFunction, + seq: InstrSeqId, + direct_subtree: &HashSet, + current: &HandlerMeta, + handlers: &HashMap, + full_subtrees: &HashMap>, + out: &mut BTreeMap>, +) { + for (instr, _) in &local.block(seq).instrs { + for target in branch_targets(instr) { + if target != current.root && full_subtrees[¤t.root].contains(&target) { + continue; + } + let mut clear = Vec::new(); + for root in current + .ancestors + .iter() + .copied() + .chain(std::iter::once(current.root)) + .rev() + { + if target == root || !full_subtrees[&root].contains(&target) { + clear.push(handlers[&root].exception); + } + } + out.entry(target).or_insert(clear); + } + for child in children(instr) { + if direct_subtree.contains(&child) { + collect_exit_targets( + local, + child, + direct_subtree, + current, + handlers, + full_subtrees, + out, + ); + } + } + } +} + +fn retarget_handler_exits( + local: &mut LocalFunction, + seq: InstrSeqId, + direct_subtree: &HashSet, + replacements: &HashMap, +) { + let children_to_visit: Vec<_> = local + .block(seq) + .instrs + .iter() + .flat_map(|(instr, _)| children(instr)) + .filter(|child| direct_subtree.contains(child)) + .collect(); + for (instr, _) in &mut local.block_mut(seq).instrs { + replace_branch_targets(instr, replacements); + } + for child in children_to_visit { + retarget_handler_exits(local, child, direct_subtree, replacements); + } +} + +fn lower_try( + module: &mut Module, + function: FunctionId, + entry: InstrSeqId, + body: InstrSeqId, + handlers: &HashMap, + handler_shims: &HashMap>, +) -> Result<()> { + let Some((parent, index, loc, try_)) = find_try(local(module, function), entry, body) else { + return Ok(()); + }; + if try_ + .catches + .iter() + .all(|catch| matches!(catch, LegacyCatch::Delegate { .. })) + { + return Ok(()); + } + ensure!( + try_.catches + .iter() + .all(|catch| !matches!(catch, LegacyCatch::Delegate { .. })), + "fork-instrument: malformed legacy try mixes delegate with catch handlers", + ); + + let body_ty = local(module, function).block(body).ty; + let (try_params, _) = params_results(module, body_ty); + let outer = local_mut(module, function) + .builder_mut() + .dangling_instr_seq(body_ty) + .id(); + + let mut caps = Vec::new(); + let mut modern_catches = Vec::new(); + let mut catch_handlers = Vec::new(); + for catch in &try_.catches { + let (handler, tag) = match catch { + LegacyCatch::Catch { tag, handler } => (*handler, Some(*tag)), + LegacyCatch::CatchAll { handler } => (*handler, None), + LegacyCatch::Delegate { .. } => unreachable!(), + }; + let (handler_params, _) = params_results(module, local(module, function).block(handler).ty); + let mut catch_values = handler_params; + catch_values.push(ValType::Ref(NON_NULL_EXNREF)); + let cap_ty = InstrSeqType::new(&mut module.types, &try_params, &catch_values); + let cap = local_mut(module, function) + .builder_mut() + .dangling_instr_seq(cap_ty) + .id(); + let modern = match tag { + Some(tag) => TryTableCatch::CatchRef { tag, label: cap }, + None => TryTableCatch::CatchAllRef { label: cap }, + }; + caps.push(cap); + modern_catches.push(modern); + catch_handlers.push(handler); + } + + let innermost = *caps.last().expect("legacy try has at least one handler"); + { + let instrs = &mut local_mut(module, function).block_mut(innermost).instrs; + push( + instrs, + Instr::TryTable(TryTable { + seq: body, + catches: modern_catches, + }), + ); + push(instrs, Instr::Br(Br { block: outer })); + } + + for index in (0..caps.len() - 1).rev() { + let child = caps[index + 1]; + push( + &mut local_mut(module, function).block_mut(caps[index]).instrs, + Instr::Block(Block { seq: child }), + ); + emit_handler_adapter( + module, + function, + caps[index], + catch_handlers[index + 1], + outer, + &handlers[&catch_handlers[index + 1]], + &handler_shims[&catch_handlers[index + 1]], + ); + } + + push( + &mut local_mut(module, function).block_mut(outer).instrs, + Instr::Block(Block { seq: caps[0] }), + ); + emit_handler_adapter( + module, + function, + outer, + catch_handlers[0], + outer, + &handlers[&catch_handlers[0]], + &handler_shims[&catch_handlers[0]], + ); + + local_mut(module, function).block_mut(parent).instrs[index] = + (Instr::Block(Block { seq: outer }), loc); + Ok(()) +} + +fn emit_handler_adapter( + module: &mut Module, + function: FunctionId, + container: InstrSeqId, + handler: InstrSeqId, + normal_target: InstrSeqId, + meta: &HandlerMeta, + shims: &[ExitShim], +) { + push( + &mut local_mut(module, function).block_mut(container).instrs, + Instr::LocalSet(LocalSet { + local: meta.exception, + }), + ); + + let execution = shims.last().map(|shim| shim.seq).unwrap_or(container); + emit_handler_execution( + module, + function, + execution, + handler, + meta.exception, + normal_target, + ); + + if !shims.is_empty() { + for index in (0..shims.len() - 1).rev() { + let child = shims[index + 1].seq; + let parent = shims[index].seq; + let instrs = &mut local_mut(module, function).block_mut(parent).instrs; + instrs.insert( + 0, + (Instr::Block(Block { seq: child }), InstrLocId::default()), + ); + emit_clear_and_branch(instrs, &shims[index + 1]); + } + let instrs = &mut local_mut(module, function).block_mut(container).instrs; + push(instrs, Instr::Block(Block { seq: shims[0].seq })); + emit_clear_and_branch(instrs, &shims[0]); + } +} + +fn emit_handler_execution( + module: &mut Module, + function: FunctionId, + container: InstrSeqId, + handler: InstrSeqId, + exception: LocalId, + normal_target: InstrSeqId, +) { + let handler_ty = local(module, function).block(handler).ty; + let (handler_params, _) = params_results(module, handler_ty); + let cleanup_cap_ty = InstrSeqType::new( + &mut module.types, + &handler_params, + &[ValType::Ref(NON_NULL_EXNREF)], + ); + let cleanup_cap = local_mut(module, function) + .builder_mut() + .dangling_instr_seq(cleanup_cap_ty) + .id(); + { + let instrs = &mut local_mut(module, function).block_mut(cleanup_cap).instrs; + push( + instrs, + Instr::TryTable(TryTable { + seq: handler, + catches: vec![TryTableCatch::CatchAllRef { label: cleanup_cap }], + }), + ); + emit_clear(instrs, exception); + push( + instrs, + Instr::Br(Br { + block: normal_target, + }), + ); + } + + let instrs = &mut local_mut(module, function).block_mut(container).instrs; + push(instrs, Instr::Block(Block { seq: cleanup_cap })); + emit_clear(instrs, exception); + push(instrs, Instr::ThrowRef(ThrowRef {})); +} + +fn emit_clear_and_branch(instrs: &mut Vec<(Instr, InstrLocId)>, shim: &ExitShim) { + for local in &shim.clear { + emit_clear(instrs, *local); + } + push(instrs, Instr::Br(Br { block: shim.target })); +} + +fn emit_clear(instrs: &mut Vec<(Instr, InstrLocId)>, local: LocalId) { + push( + instrs, + Instr::RefNull(RefNull { + ty: NULLABLE_EXNREF, + }), + ); + push(instrs, Instr::LocalSet(LocalSet { local })); +} + +fn branch_value_types( + module: &Module, + function: FunctionId, + target: InstrSeqId, + label_kinds: &HashMap, +) -> Result> { + let ty = local(module, function).block(target).ty; + let (params, results) = params_results(module, ty); + let kind = label_kinds + .get(&target) + .copied() + .ok_or_else(|| anyhow::anyhow!("fork-instrument: branch target has no label kind"))?; + Ok(if kind == LabelKind::Loop { + params + } else { + results + }) +} + +fn params_results(module: &Module, ty: InstrSeqType) -> (Vec, Vec) { + match ty { + InstrSeqType::Simple(None) => (Vec::new(), Vec::new()), + InstrSeqType::Simple(Some(result)) => (Vec::new(), vec![result]), + InstrSeqType::MultiValue(ty) => ( + module.types.get(ty).params().to_vec(), + module.types.get(ty).results().to_vec(), + ), + } +} + +fn find_try( + local: &LocalFunction, + seq: InstrSeqId, + body: InstrSeqId, +) -> Option<(InstrSeqId, usize, InstrLocId, Try)> { + for (index, (instr, loc)) in local.block(seq).instrs.iter().enumerate() { + if let Instr::Try(try_) = instr + && try_.seq == body + { + return Some((seq, index, *loc, try_.clone())); + } + for child in children(instr) { + if let Some(site) = find_try(local, child, body) { + return Some(site); + } + } + } + None +} + +fn children(instr: &Instr) -> Vec { + match instr { + Instr::Block(block) => vec![block.seq], + Instr::Loop(loop_) => vec![loop_.seq], + Instr::IfElse(if_) => vec![if_.consequent, if_.alternative], + Instr::TryTable(table) => vec![table.seq], + Instr::Try(try_) => { + let mut children = vec![try_.seq]; + for catch in &try_.catches { + match catch { + LegacyCatch::Catch { handler, .. } | LegacyCatch::CatchAll { handler } => { + children.push(*handler) + } + LegacyCatch::Delegate { .. } => {} + } + } + children + } + _ => Vec::new(), + } +} + +fn branch_targets(instr: &Instr) -> Vec { + match instr { + Instr::Br(branch) => vec![branch.block], + Instr::BrIf(branch) => vec![branch.block], + Instr::BrTable(table) => table + .blocks + .iter() + .copied() + .chain(std::iter::once(table.default)) + .collect(), + Instr::BrOnNull(branch) => vec![branch.block], + Instr::BrOnNonNull(branch) => vec![branch.block], + Instr::BrOnCast(branch) => vec![branch.block], + Instr::BrOnCastFail(branch) => vec![branch.block], + _ => Vec::new(), + } +} + +fn replace_branch_targets(instr: &mut Instr, replacements: &HashMap) { + let replace = |target: &mut InstrSeqId| { + if let Some(replacement) = replacements.get(target) { + *target = *replacement; + } + }; + match instr { + Instr::Br(branch) => replace(&mut branch.block), + Instr::BrIf(branch) => replace(&mut branch.block), + Instr::BrTable(table) => { + for target in &mut table.blocks { + replace(target); + } + replace(&mut table.default); + } + Instr::BrOnNull(branch) => replace(&mut branch.block), + Instr::BrOnNonNull(branch) => replace(&mut branch.block), + Instr::BrOnCast(branch) => replace(&mut branch.block), + Instr::BrOnCastFail(branch) => replace(&mut branch.block), + _ => {} + } +} + +fn push(instrs: &mut Vec<(Instr, InstrLocId)>, instr: Instr) { + instrs.push((instr, InstrLocId::default())); +} + +fn local(module: &Module, function: FunctionId) -> &LocalFunction { + match &module.funcs.get(function).kind { + FunctionKind::Local(local) => local, + _ => unreachable!("fork-path legacy EH normalization requires a local function"), + } +} + +fn local_mut(module: &mut Module, function: FunctionId) -> &mut LocalFunction { + match &mut module.funcs.get_mut(function).kind { + FunctionKind::Local(local) => local, + _ => unreachable!("fork-path legacy EH normalization requires a local function"), + } +} diff --git a/crates/fork-instrument/src/lib.rs b/crates/fork-instrument/src/lib.rs index a8014f80f9..7c7aa3f840 100644 --- a/crates/fork-instrument/src/lib.rs +++ b/crates/fork-instrument/src/lib.rs @@ -4,31 +4,138 @@ //! See `docs/plans/2026-04-20-fork-instrumentation-design.md` for the //! full design. //! -//! Phase 1 (current): skeleton only. Parses a wasm binary, validates -//! it, and emits it unchanged. Subsequent phases add: -//! -//! - Phase 2: direct-call graph discovery -//! - Phase 3: indirect-call graph discovery -//! - Phase 4: core instrumentation (state machine, frame save/restore) -//! - Phase 5: reference-typed local spilling -//! - Phase 6: catch-handler region support -//! - Phase 7: production rollout +//! The ABI 43 transform discovers the direct/indirect fork closure, assigns +//! every replay value to activation-owned bytes or a versioned reconstruction +//! recipe, and emits the linked-frame state machine. Scalars are serialized in +//! the activation frame; references, complete exceptions, mutable reference +//! globals, and table entries are reconstructed from a process-owned typed +//! recipe graph in each fresh module instance. use anyhow::{Context, Result, bail, ensure}; -use walrus::RawCustomSection; +use walrus::{ + ElementItems, ElementKind, FunctionId, RawCustomSection, RefType, TableId, ir::Value, +}; +use wasm_posix_shared::abi::{ + WPK_FORK_CAP_ACTIVATION_STATE_SAFE, WPK_FORK_CAP_DYLINK_MAIN, WPK_FORK_CAP_SIDE_ENTRY, + WPK_FORK_CAPABILITIES_SECTION, WPK_FORK_CAPABILITIES_VERSION, + WPK_FORK_GLOBAL_CATALOG_EXPORT_PREFIX, WPK_FORK_IMPORTED_TABLES_SECTION, + WPK_FORK_MODULE_STATE_ARENA_VERSION, WPK_FORK_MODULE_STATE_DESCRIPTOR_SIZE, + WPK_FORK_MODULE_STATE_FORMAT_MAGIC, WPK_FORK_MODULE_STATE_FORMAT_SECTION, + WPK_FORK_MODULE_STATE_FORMAT_VERSION, WPK_FORK_MODULE_STATE_RECORD_ALIGNMENT, + WPK_FORK_MODULE_STATE_RECORD_VERSION, WPK_FORK_MODULE_STATE_REQUIRED_FLAGS, + WPK_FORK_MODULE_STATE_ROOT_POINTER_WORD_OFFSET, WPK_FORK_REQUIRED_EXPORTS, + WPK_FORK_REQUIRED_IMPORTS, WPK_FORK_TABLE_CATALOG_EXPORT_PREFIX, + WPK_FORK_UNWIND_TRANSPORT_PAYLOAD_ARITY, WPK_FORK_UNWIND_TRANSPORT_SECTION, + WPK_FORK_UNWIND_TRANSPORT_VERSION, +}; use wasmparser::{Parser, Payload}; pub mod call_graph; +pub mod contract_inventory; pub mod instrument; +pub mod legacy_eh; +pub mod legacy_dlopen; pub mod linked_frames; +pub mod module_exception_codec; +pub mod module_gc_codec; +pub mod module_state; +pub mod reference_analysis; pub mod runtime; +pub mod static_reference_catalog; + +/// Fresh instances rebuild this fixed catalog from the module's static element +/// segment, so a funcref recipe needs only a module activation and ordinal. +pub const FUNCTION_CATALOG_EXPORT: &str = "__wpk_fork_function_catalog"; + +/// Declares that unwind completion is transported by the private +/// `env.__wpk_fork_unwind` zero-payload tag rather than synthesized function +/// results. +pub const UNWIND_TRANSPORT_SECTION: &str = WPK_FORK_UNWIND_TRANSPORT_SECTION; +pub const UNWIND_TRANSPORT_VERSION: u8 = WPK_FORK_UNWIND_TRANSPORT_VERSION; -/// Versioned artifact claim emitted by `wasm-fork-instrument` and consumed by -/// the host before it enables cross-module fork coordination. -pub const FORK_CAPABILITIES_SECTION: &str = "kandelo.wpk_fork.capabilities"; -pub const FORK_CAPABILITIES_VERSION: u8 = 1; -pub const FORK_CAP_SIDE_ENTRY: u8 = 1 << 0; -pub const FORK_CAP_DYLINK_MAIN: u8 = 1 << 1; +fn reject_preinstrumented_artifact(module: &walrus::Module) -> Result<()> { + let has_control_export = module.exports.iter().any(|export| { + WPK_FORK_REQUIRED_EXPORTS + .iter() + .any(|requirement| requirement.name == export.name) + }); + let has_frame_import = module.imports.iter().any(|import| { + WPK_FORK_REQUIRED_IMPORTS.iter().any(|requirement| { + requirement.module == import.module && requirement.name == import.name + }) + }); + let has_fork_metadata = module.customs.iter().any(|(_, section)| { + matches!( + section.name(), + WPK_FORK_CAPABILITIES_SECTION + | linked_frames::LINKED_FRAME_FORMAT_SECTION + | module_exception_codec::FORMAT_SECTION + | WPK_FORK_MODULE_STATE_FORMAT_SECTION + | WPK_FORK_IMPORTED_TABLES_SECTION + | instrument::RESUME_CATALOG_SECTION + | static_reference_catalog::FORMAT_SECTION + | UNWIND_TRANSPORT_SECTION + ) + }) || module.exports.iter().any(|export| { + matches!( + export.name.as_str(), + FUNCTION_CATALOG_EXPORT + | instrument::RESUME_CATALOG_EXPORT + | instrument::RESUME_START_EXPORT + | instrument::RESUME_THREAD_EXPORT + | static_reference_catalog::EXPORT + | static_reference_catalog::HARVEST_EXPORT + ) + }) || module.exports.iter().any(|export| { + export + .name + .starts_with(WPK_FORK_GLOBAL_CATALOG_EXPORT_PREFIX) + || export + .name + .starts_with(WPK_FORK_TABLE_CATALOG_EXPORT_PREFIX) + }); + if has_control_export || has_frame_import || has_fork_metadata { + // WHY: restamping an ABI 42 transform would certify code whose frames + // may still name parent-instance reference-table slots. Always rebuild + // from the raw linker output so the ABI 43 validator sees the original + // activation and table state. + bail!( + "fork-instrument: input already contains wasm-fork-instrument \ + imports, exports, or metadata; rebuild and instrument the raw \ + linker output instead of restamping an older artifact" + ); + } + Ok(()) +} + +fn reject_reserved_unwind_import(module: &walrus::Module) -> Result<()> { + let collides = module.imports.iter().any(|import| { + if import.module != runtime::names::IMPORT_UNWIND_TAG_MODULE { + return false; + } + module_exception_codec::is_reserved_host_import(&import.name) + || matches!( + import.name.as_str(), + runtime::names::IMPORT_UNWIND_TAG + | runtime::names::IMPORT_REF_ENCODE_FUNCREF + | runtime::names::IMPORT_REF_DECODE_FUNCREF + | runtime::names::IMPORT_REF_ENCODE_EXTERNREF + | runtime::names::IMPORT_REF_DECODE_EXTERNREF + | runtime::names::IMPORT_REF_ENCODE_EXNREF + | runtime::names::IMPORT_REF_DECODE_EXNREF + | runtime::names::IMPORT_REF_ENCODE_ANYREF + | runtime::names::IMPORT_REF_DECODE_ANYREF + ) + }); + ensure!( + !collides, + "fork-instrument: input already imports a reserved private fork runtime \ + hook from `{}`; the instrumenter must own unwind transport and reference \ + reconstruction imports", + runtime::names::IMPORT_UNWIND_TAG_MODULE, + ); + Ok(()) +} /// Options controlling instrumentation. Fields will grow as phases /// land; a `Default` implementation keeps call sites stable. @@ -36,8 +143,10 @@ pub const FORK_CAP_DYLINK_MAIN: u8 = 1 << 1; pub struct Options { /// The fully-qualified name of the import whose callers should be /// instrumented. Format: `module.field` (e.g. - /// `kernel.kernel_fork`). Future phases read this to seed the - /// call-graph discovery; Phase 1 ignores it. + /// `kernel.kernel_fork`). This import seeds call-graph discovery in a + /// main module. `env.fork` selects complete side-module boundary coverage: + /// every function import and unresolved reference dispatch becomes a + /// possible cross-instance fork boundary. pub entry_import: String, } @@ -63,48 +172,133 @@ pub struct Analysis { /// Phase 2 scope: direct-call closure only. Phase 3 extends to /// indirect calls. pub fn analyze(input: &[u8], opts: &Options) -> Result { - let module = walrus::Module::from_buffer(input).context("failed to parse input wasm module")?; + let mut module = + walrus::Module::from_buffer(input).context("failed to parse input wasm module")?; + legacy_dlopen::lower(&mut module)?; + let side_boundaries = uses_side_module_boundaries(&module, opts); + let entry_imports = call_graph::find_import_funcs(&module, &opts.entry_import); - let Some(entry) = call_graph::find_import_func(&module, &opts.entry_import) else { + if entry_imports.is_empty() + && !side_boundaries + && !call_graph::has_dynamic_linker_imports(&module) + { bail!( "entry import `{}` not found (or not a function) in the module. \ If this module does not use fork, there is nothing to instrument.", opts.entry_import ); - }; + } - let reaching = call_graph::reaching_closure(&module, entry); - let fork_path = call_graph::summarize(&module, &reaching); + let seeds = fork_boundary_seeds(&module, &entry_imports, side_boundaries); + let reaching = prepare_fork_path( + &module, + &seeds, + side_boundaries || call_graph::has_dynamic_linker_imports(&module), + ); + let fork_path = call_graph::summarize(&module, &reaching.activations); Ok(Analysis { fork_path }) } +fn uses_side_module_boundaries(module: &walrus::Module, opts: &Options) -> bool { + // `--entry env.fork` is the historical side-module invocation. ABI 43 + // broadens that role from one named import to every cross-module call + // boundary. Auto-detecting dylink.0 also protects side modules that do not + // import fork themselves but can remain live above a downstream fork. + opts.entry_import == "env.fork" + || module + .customs + .iter() + .any(|(_, section)| section.name() == "dylink.0") +} + +fn fork_boundary_seeds( + module: &walrus::Module, + entry_imports: &[walrus::FunctionId], + side_boundaries: bool, +) -> Vec { + if side_boundaries { + call_graph::imported_functions(module) + } else { + let mut seeds = entry_imports.to_vec(); + // A raw legacy import is a direct boundary when this helper is used + // before lowering. In the normal ABI 43 pipeline, lowering replaces it + // with an ordinary driver call_indirect; external dynamic-dispatch + // discovery then owns that boundary and every surviving caller. + seeds.extend(call_graph::dynamic_linker_imported_functions(module)); + seeds.sort(); + seeds.dedup(); + seeds + } +} + +/// Compute both the surviving activation set and the full semantic control +/// closure. Tail callers remain transparent and retain their bounded-stack +/// `return_call*` semantics; replay bypasses them when the continuation owns a +/// deeper activation frame. +fn prepare_fork_path( + module: &walrus::Module, + seeds: &[walrus::FunctionId], + external_dynamic_dispatch: bool, +) -> call_graph::ReachingAnalysis { + call_graph::analyze_reaching_closure_from_seeds( + module, + seeds.iter().copied(), + external_dynamic_dispatch, + ) +} + /// Instruments `input` (a complete wasm binary) according to `opts` /// and returns the transformed binary. /// -/// Current scope: Phase 4a (runtime scaffolding) + Phase 4b -/// (per-function structural wrap). Future phases 4c–6 extend the -/// per-function transform with call-site state-machine wrapping, -/// frame save/restore, mutable-global save/restore, ref-typed local -/// spilling, and catch-handler resume. +/// The complete transform includes runtime scaffolding, per-function +/// switch-dispatch, linked-frame save/restore, mutable scalar-global +/// save/restore, and activation-owned tagged-catch replay. /// -/// Modules that do not import the configured entry (default -/// `kernel.kernel_fork`) are returned unchanged — there is nothing -/// to instrument. We do **not** treat this as an error because the -/// tool is invoked by build scripts across programs that may or may -/// not use `fork()`. +/// Executable modules that have no configured fork entry, dynamic-loader +/// boundary, or side-module role are returned byte-for-byte unchanged. Such a +/// module cannot participate in a fork transaction, and injecting reference +/// codecs would needlessly require those Wasm features on every host. +/// +/// Dynamic-loader-capable mains and side modules still receive the uniform +/// activation-state helpers even when they do not import fork directly. They +/// can remain live beside a fork-capable activation, so their mutable globals, +/// tables, and segment lifetimes remain part of the child process image. pub fn instrument(input: &[u8], opts: &Options) -> Result> { let mut module = walrus::Module::from_buffer(input).context("failed to parse input wasm module")?; + reject_preinstrumented_artifact(&module)?; + legacy_dlopen::lower(&mut module)?; // Discover the fork-path closure *before* we mutate the module so // the runtime's own injected functions are not mistaken for // fork-path callers. (They can't reach the seed anyway, but the // earlier-is-simpler ordering keeps the invariant trivially.) - let entry = call_graph::find_import_func(&module, &opts.entry_import); - let fork_path = match entry { - Some(seed) => call_graph::reaching_closure(&module, seed), - None => Default::default(), - }; + let side_boundaries = uses_side_module_boundaries(&module, opts); + let entry_imports = call_graph::find_import_funcs(&module, &opts.entry_import); + let seeds = fork_boundary_seeds(&module, &entry_imports, side_boundaries); + let has_dynamic_linker_imports = call_graph::has_dynamic_linker_imports(&module); + let external_dynamic_dispatch = side_boundaries || has_dynamic_linker_imports; + reject_reserved_unwind_import(&module)?; + if entry_imports.is_empty() && !external_dynamic_dispatch { + // WHY: this is a standalone executable with no route into fork or a + // process-wide dynamic activation. Keeping the exact linker bytes + // avoids imposing ABI 43's GC/exnref replay types on non-forking + // software and preserves the advertised no-op transform boundary. + return Ok(input.to_vec()); + } + let initial_fork_path = + prepare_fork_path(&module, &seeds, external_dynamic_dispatch).activations; + legacy_eh::normalize_fork_path(&mut module, &initial_fork_path)?; + // Legacy EH normalization can replace instruction sequences. Recompute + // the semantic closure afterwards so the exact fork-reaching tail-site + // coordinates used by private-tag transport name the normalized IR. + let reaching = prepare_fork_path(&module, &seeds, external_dynamic_dispatch); + let (fork_path, fork_path_targets, tail_call_sites) = ( + reaching.activations, + reaching.control_reachable, + reaching.tail_call_landings, + ); + instrument::validate_activation_state_with_targets(&module, &fork_path, &fork_path_targets)?; // The five wpk_fork_* exports prove only that some instrumentation runtime // was injected. They do not prove which import seeded the transformed call @@ -112,58 +306,110 @@ pub fn instrument(input: &[u8], opts: &Options) -> Result> { // call_indirect boundary. Emit a separate, versioned claim for exactly the // transformations performed in this invocation so the host can reject // stale or generically instrumented artifacts instead of mis-resuming. - let mut fork_capabilities = 0; - if entry.is_some() && opts.entry_import == "env.fork" { - fork_capabilities |= FORK_CAP_SIDE_ENTRY; + let mut fork_capabilities = WPK_FORK_CAP_ACTIVATION_STATE_SAFE; + if side_boundaries { + // ABI 43 interprets SIDE_ENTRY as complete side-boundary coverage, not + // merely proof that one env.fork import was discovered. + fork_capabilities |= WPK_FORK_CAP_SIDE_ENTRY; } - if entry.is_some() + if !side_boundaries + && !entry_imports.is_empty() && opts.entry_import == "kernel.kernel_fork" && call_graph::has_dynamic_linker_imports(&module) { - fork_capabilities |= FORK_CAP_DYLINK_MAIN; + fork_capabilities |= WPK_FORK_CAP_DYLINK_MAIN; } - // Phase 4a: runtime scaffolding. Always injected so the module's - // exported ABI is stable regardless of whether any caller was - // actually rewritten. + // Phase 4a: runtime scaffolding. Every artifact that can participate in a + // process-wide fork receives the same state contract, regardless of + // whether a local caller was actually rewritten. // - // Discover supported plain-catch regions before injecting the runtime. + // Discover supported tagged-catch regions before injecting the runtime. // The plan contains only static tag/label/type metadata; activation state // is allocated later as ordinary frame-backed function locals. Sort the // targets to keep local allocation and emitted bytes deterministic. - let mut fork_path_targets: Vec = fork_path + let mut activation_targets: Vec = fork_path .iter() .copied() .filter(|id| matches!(module.funcs.get(*id).kind, walrus::FunctionKind::Local(_))) .collect(); - fork_path_targets.sort(); - let plain_catch_plan = instrument::plan_plain_catches(&module, &fork_path_targets); - // Only modules with the configured fork seed need linked-frame imports. - // Runtime exports and metadata remain stable for no-seed modules, but - // adding unused host imports would make an otherwise inert side module - // impossible to instantiate through the dynamic linker. - let runtime = if entry.is_some() { - runtime::inject_linked_runtime(&mut module) - } else { - runtime::inject_runtime(&mut module) - }; + activation_targets.sort(); + let plain_catch_plan = instrument::plan_plain_catches(&module, &activation_targets); + let static_reference_plan = static_reference_catalog::plan(&mut module); + let module_state_plan = module_state::plan(&mut module); + + // Capture only original module functions. Runtime and transform helpers + // injected below are implementation details and cannot have appeared in a + // source-level ref.func. A fixed-size table plus an active element segment + // is deterministic across fresh instantiation and does not depend on + // mutable guest table state. + let function_catalog = inject_function_catalog(&mut module); + static_reference_catalog::inject(&mut module, static_reference_plan); + // Every dynamic activation is part of the process image even when none of + // its functions can be on the active fork stack. Give no-memory modules + // the shared process-memory staging contract before deriving the pointer + // ABI, and give each participating artifact the same linked imports and + // state helpers. + // + // WHY: an inactive side module can still own mutated globals, tables, or + // dropped segments referenced by the main process. Limiting these helpers + // to the module that imports fork would silently reset that state in a + // fresh child. + let staging_memory = module_state::ensure_staging_memory(&mut module); + let gc_codec = module_gc_codec::declare(&mut module, staging_memory)?; + let exception_codec = module_exception_codec::inject_with_reference_overrides( + &mut module, + staging_memory, + Some((gc_codec.encode_externref, gc_codec.decode_externref)), + Some((gc_codec.encode_anyref, gc_codec.decode_anyref)), + )?; + let runtime = runtime::inject_linked_runtime_with_reference_overrides( + &mut module, + runtime::ReferenceCodecOverrides { + funcref: Some(( + exception_codec.references.encode_funcref, + exception_codec.references.decode_funcref, + )), + externref: Some(( + exception_codec.references.encode_externref, + exception_codec.references.decode_externref, + )), + exnref: Some((exception_codec.encode, exception_codec.decode)), + anyref: Some((gc_codec.encode_anyref, gc_codec.decode_anyref)), + cleanup: Some(exception_codec.clear), + }, + ); + let _gc_codec = + module_gc_codec::finish_declaration(&mut module, gc_codec, exception_codec, &runtime)?; // Phase 4b: structural wrap of each fork-path function's body. // No-op when `fork_path` is empty (module doesn't use fork). - instrument::instrument_functions(&mut module, &runtime, &fork_path, &plain_catch_plan); + instrument::instrument_functions_with_targets_and_tail_sites( + &mut module, + &runtime, + &fork_path, + &fork_path_targets, + &tail_call_sites, + &plain_catch_plan, + ); + // Dirty-page instrumentation uses short-lived scalar/reference + // temporaries. Add them after continuation frame planning so they neither + // enlarge saved frames nor survive as stale activation roots. + let module_bootstrap = module_state::inject(&mut module, &runtime, module_state_plan)?; + append_function_catalog_entry(&mut module, function_catalog, module_bootstrap); loop { let existing = module .customs .iter() - .find(|(_, section)| section.name() == FORK_CAPABILITIES_SECTION) + .find(|(_, section)| section.name() == WPK_FORK_CAPABILITIES_SECTION) .map(|(id, _)| id); let Some(existing) = existing else { break }; module.customs.delete(existing); } module.customs.add(RawCustomSection { - name: FORK_CAPABILITIES_SECTION.into(), - data: vec![FORK_CAPABILITIES_VERSION, fork_capabilities], + name: WPK_FORK_CAPABILITIES_SECTION.into(), + data: vec![WPK_FORK_CAPABILITIES_VERSION, fork_capabilities], }); loop { @@ -190,6 +436,26 @@ pub fn instrument(input: &[u8], opts: &Options) -> Result> { .to_vec(), }); + replace_custom_section( + &mut module, + WPK_FORK_MODULE_STATE_FORMAT_SECTION, + module_state_descriptor(pointer_width), + ); + + // Every ABI 43 activation imports the private tag as part of its uniform + // state helpers, including a side module with no local fork entry. Keep + // the versioned descriptor equally uniform so a state-only activation can + // be admitted without weakening the host's exact-tag validation. + module.customs.add(RawCustomSection { + name: UNWIND_TRANSPORT_SECTION.into(), + // Byte 1 is the tag payload arity. Version 1 deliberately fixes it + // at zero so host validation can reject a lookalike tag import. + data: vec![ + UNWIND_TRANSPORT_VERSION, + WPK_FORK_UNWIND_TRANSPORT_PAYLOAD_ARITY, + ], + }); + // Historical phase list (Phase 4b/4c/4d/4e/4f/5/6) was an artefact // of guard-dispatch's body-rewriting approach. Post-commit-4 those // phases are folded into `instrument::instrument_functions` itself; @@ -200,6 +466,92 @@ pub fn instrument(input: &[u8], opts: &Options) -> Result> { restore_leading_dylink_section(input, output) } +fn inject_function_catalog(module: &mut walrus::Module) -> TableId { + let mut functions: Vec = + module.funcs.iter().map(|func| func.id()).collect(); + functions.sort(); + let size = functions.len() as u64; + let table = module + .tables + .add_local(false, size, Some(size), RefType::FUNCREF); + module.tables.get_mut(table).name = Some(FUNCTION_CATALOG_EXPORT.into()); + if !functions.is_empty() { + module.elements.add( + ElementKind::Active { + table, + offset: walrus::ConstExpr::Value(Value::I32(0)), + }, + ElementItems::Functions(functions), + ); + } + // WHY: fixed min/max prevents guest growth. The host treats this export as + // immutable catalog input and never uses it as mutable replay storage. + module.exports.add(FUNCTION_CATALOG_EXPORT, table); + table +} + +fn append_function_catalog_entry( + module: &mut walrus::Module, + table: TableId, + function: FunctionId, +) { + let ordinal = module.tables.get(table).initial; + let size = ordinal + .checked_add(1) + .expect("fork function catalog length fits u64"); + let catalog = module.tables.get_mut(table); + catalog.initial = size; + catalog.maximum = Some(size); + module.elements.add( + ElementKind::Active { + table, + offset: walrus::ConstExpr::Value(Value::I32( + i32::try_from(ordinal).expect("fork function catalog ordinal fits i32"), + )), + }, + ElementItems::Functions(vec![function]), + ); +} + +fn module_state_descriptor(pointer_width: linked_frames::PointerWidth) -> Vec { + let pointer_width = match pointer_width { + linked_frames::PointerWidth::Wasm32 => u8::try_from(u32::BITS / u8::BITS).unwrap(), + linked_frames::PointerWidth::Wasm64 => u8::try_from(u64::BITS / u8::BITS).unwrap(), + }; + let mut data = Vec::with_capacity(usize::from(WPK_FORK_MODULE_STATE_DESCRIPTOR_SIZE)); + data.extend_from_slice(&WPK_FORK_MODULE_STATE_FORMAT_MAGIC); + data.extend_from_slice(&WPK_FORK_MODULE_STATE_FORMAT_VERSION.to_le_bytes()); + data.extend_from_slice(&WPK_FORK_MODULE_STATE_DESCRIPTOR_SIZE.to_le_bytes()); + data.push(pointer_width); + data.push(WPK_FORK_MODULE_STATE_RECORD_ALIGNMENT); + data.extend_from_slice(&WPK_FORK_MODULE_STATE_REQUIRED_FLAGS.to_le_bytes()); + data.extend_from_slice(&WPK_FORK_MODULE_STATE_ARENA_VERSION.to_le_bytes()); + data.extend_from_slice(&WPK_FORK_MODULE_STATE_RECORD_VERSION.to_le_bytes()); + data.extend_from_slice(&WPK_FORK_MODULE_STATE_ROOT_POINTER_WORD_OFFSET.to_le_bytes()); + data.extend_from_slice(&u32::default().to_le_bytes()); + debug_assert_eq!( + data.len(), + usize::from(WPK_FORK_MODULE_STATE_DESCRIPTOR_SIZE) + ); + data +} + +fn replace_custom_section(module: &mut walrus::Module, name: &str, data: Vec) { + loop { + let existing = module + .customs + .iter() + .find(|(_, section)| section.name() == name) + .map(|(id, _)| id); + let Some(existing) = existing else { break }; + module.customs.delete(existing); + } + module.customs.add(RawCustomSection { + name: name.into(), + data, + }); +} + /// Walrus emits raw custom sections after the standard sections. That is /// normally valid, but the WebAssembly dynamic-linking convention requires a /// shared module's `dylink.0` custom section to be first. Preserve that input diff --git a/crates/fork-instrument/src/linked_frames.rs b/crates/fork-instrument/src/linked_frames.rs index 534de03d6d..d63c419005 100644 --- a/crates/fork-instrument/src/linked_frames.rs +++ b/crates/fork-instrument/src/linked_frames.rs @@ -14,7 +14,7 @@ pub const WASM_PAGE_SIZE: u64 = 64 * 1024; /// Alignment used for chunk and node records. pub const RECORD_ALIGNMENT: u64 = abi::WPK_FORK_LINKED_FRAME_RECORD_ALIGNMENT as u64; -/// Linked-frame artifact metadata version used by ABI 42. +/// Linked-frame artifact metadata version used by ABI 42 and later. pub const LINKED_FRAME_FORMAT_VERSION: u16 = abi::WPK_FORK_LINKED_FRAME_FORMAT_VERSION; pub const LINKED_FRAME_FORMAT_SECTION: &str = abi::WPK_FORK_LINKED_FRAME_FORMAT_SECTION; diff --git a/crates/fork-instrument/src/main.rs b/crates/fork-instrument/src/main.rs index 8f63f54983..a8a21bb15a 100644 --- a/crates/fork-instrument/src/main.rs +++ b/crates/fork-instrument/src/main.rs @@ -17,7 +17,13 @@ use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; -use fork_instrument::{Options, analyze, instrument}; +use fork_instrument::{ + Options, analyze, + contract_inventory::{ + fork_capability_section_hex, fork_contract_inventory, linked_frame_descriptor_section_hex, + }, + instrument, +}; #[derive(Debug, Parser)] #[command( @@ -29,13 +35,15 @@ struct Cli { /// Input wasm file to instrument. input: PathBuf, - /// Output path for the instrumented wasm file. Required unless - /// `--discover-only` is set (analysis-only mode). + /// Output path for the instrumented wasm file. Required unless an + /// analysis or contract-inspection mode is set. #[arg(short, long)] output: Option, /// The fully-qualified name of the import that triggers unwind. /// Format: `module.field`. Defaults to `kernel.kernel_fork`. + /// `env.fork` selects complete dynamically linked side-module boundary + /// coverage, including downstream fork in another side module. #[arg(long, default_value = "kernel.kernel_fork")] entry: String, @@ -45,27 +53,77 @@ struct Cli { /// hand-maintained onlylists. #[arg(long)] discover_only: bool, + + /// Print the fork-artifact structural inventory as one TSV row. + /// This mode performs no instrumentation and emits no output file. + #[arg(long, conflicts_with_all = ["discover_only", "output"])] + contract_inventory: bool, + + /// Print the unique fork-capability custom section as lowercase hex. + #[arg( + long, + conflicts_with_all = [ + "discover_only", + "contract_inventory", + "linked_frame_descriptor_hex", + "output" + ] + )] + fork_capability_hex: bool, + + /// Print the unique linked-frame descriptor custom section as lowercase hex. + #[arg( + long, + conflicts_with_all = [ + "discover_only", + "contract_inventory", + "fork_capability_hex", + "output" + ] + )] + linked_frame_descriptor_hex: bool, } fn main() -> Result<()> { let cli = Cli::parse(); - let input = fs::read(&cli.input) - .with_context(|| format!("reading input: {}", cli.input.display()))?; + let input = + fs::read(&cli.input).with_context(|| format!("reading input: {}", cli.input.display()))?; + + if cli.contract_inventory { + let inventory = fork_contract_inventory(&input) + .with_context(|| format!("inventorying {}", cli.input.display()))?; + println!("{inventory}"); + return Ok(()); + } + if cli.fork_capability_hex { + let hex = fork_capability_section_hex(&input) + .with_context(|| format!("reading fork capability: {}", cli.input.display()))?; + println!("{hex}"); + return Ok(()); + } + if cli.linked_frame_descriptor_hex { + let hex = linked_frame_descriptor_section_hex(&input) + .with_context(|| format!("reading linked-frame descriptor: {}", cli.input.display()))?; + println!("{hex}"); + return Ok(()); + } let opts = Options { entry_import: cli.entry, }; if cli.discover_only { - let analysis = analyze(&input, &opts) - .with_context(|| format!("analyzing {}", cli.input.display()))?; + let analysis = + analyze(&input, &opts).with_context(|| format!("analyzing {}", cli.input.display()))?; print_analysis_json(&analysis); return Ok(()); } let output_path = cli.output.as_ref().ok_or_else(|| { - anyhow::anyhow!("--output is required unless --discover-only is set") + anyhow::anyhow!( + "--output is required unless an analysis or contract-inspection mode is set" + ) })?; // Capture this before writing: `--output` is allowed to name the input // file, and output creation/truncation must not become the source of truth @@ -115,7 +173,11 @@ fn print_analysis_json(analysis: &fork_instrument::Analysis) { println!("{{"); println!(" \"fork_path\": ["); for (i, entry) in analysis.fork_path.iter().enumerate() { - let comma = if i + 1 == analysis.fork_path.len() { "" } else { "," }; + let comma = if i + 1 == analysis.fork_path.len() { + "" + } else { + "," + }; println!( " {{ \"name\": {}, \"is_import\": {} }}{}", json_string(&entry.name), diff --git a/crates/fork-instrument/src/module_exception_codec.rs b/crates/fork-instrument/src/module_exception_codec.rs new file mode 100644 index 0000000000..b14fcefe64 --- /dev/null +++ b/crates/fork-instrument/src/module_exception_codec.rs @@ -0,0 +1,1594 @@ +//! Exact-tag exception codecs injected into the owning Wasm module. +//! +//! A separate provider module cannot import a module's local tags until that +//! module exists, while the module cannot import an `exnref` codec from that +//! provider before it is instantiated. Injecting the codec here removes that +//! bootstrap cycle and preserves canonical concrete tag and payload types. +//! +//! Cross-activation routing still has scalar-only JavaScript signatures: +//! +//! * encode delegates an unknown exception by asking the host broker to catch +//! `throw_slot(slot)` and call the selected owner's `encode_ingress(token)`; +//! * decode asks the broker to call the selected owner's `throw_recipe(id)`, +//! then catches that thrown value with `CatchAllRef` in the requesting +//! module. +//! +//! Recipe identity is claimed before recursive payload encoding. Decode caches +//! every materialized exception by shared recipe ID, preserving aliases. + +use anyhow::{Result, ensure}; +use walrus::{ + AbstractHeapType, FunctionBuilder, FunctionId, FunctionKind, GlobalId, HeapType, ImportKind, + LocalFunction, LocalId, MemoryId, Module, RawCustomSection, RefType, TableId, TagId, ValType, + ir::{ + BinaryOp, Binop, Block, Call, Const, Drop, GlobalGet, IfElse, Instr, InstrLocId, + InstrSeqId, InstrSeqType, Load, LoadKind, LocalGet, LocalSet, LocalTee, MemArg, + RefAsNonNull, RefCast, RefIsNull, RefNull, Return, Store, StoreKind, TableFill, TableGet, + TableGrow, TableSet, TableSize, Throw, ThrowRef, TryTable, TryTableCatch, UnaryOp, Unop, + Unreachable, Value, + }, +}; +use wasm_posix_shared::abi::{ + WPK_FORK_EXCEPTION_CODEC_HEADER_SIZE, WPK_FORK_EXCEPTION_CODEC_IMPORT_MODULE, + WPK_FORK_EXCEPTION_CODEC_SECTION, WPK_FORK_EXCEPTION_CODEC_TAG_RECORD_SIZE, + WPK_FORK_EXCEPTION_CODEC_VERSION, WPK_FORK_EXCEPTION_EXPORT_ABORT, + WPK_FORK_EXCEPTION_EXPORT_CLEAR, WPK_FORK_EXCEPTION_EXPORT_DECODE, + WPK_FORK_EXCEPTION_EXPORT_ENCODE, WPK_FORK_EXCEPTION_EXPORT_ENCODE_INGRESS, + WPK_FORK_EXCEPTION_EXPORT_MATERIALIZE, WPK_FORK_EXCEPTION_EXPORT_THROW_RECIPE, + WPK_FORK_EXCEPTION_EXPORT_THROW_SLOT, WPK_FORK_EXCEPTION_IMPORT_ACTIVATION, + WPK_FORK_EXCEPTION_IMPORT_BROKER_ENCODE, WPK_FORK_EXCEPTION_IMPORT_BROKER_THROW_RECIPE, + WPK_FORK_EXCEPTION_IMPORT_CACHE_INDEX, WPK_FORK_EXCEPTION_IMPORT_CLAIM, + WPK_FORK_EXCEPTION_IMPORT_DEFINE, WPK_FORK_EXCEPTION_IMPORT_INGRESS_THROW, + WPK_FORK_EXCEPTION_IMPORT_LOAD, WPK_FORK_EXCEPTION_IMPORT_LOOKUP, + WPK_FORK_EXCEPTION_IMPORT_ROUTE, WPK_FORK_REFERENCE_IMPORT_SCRATCH_RELEASE, + WPK_FORK_REFERENCE_IMPORT_SCRATCH_RESERVE, +}; + +use crate::runtime::{ReferenceCodecClass, names as runtime_names}; + +pub const FORMAT_SECTION: &str = WPK_FORK_EXCEPTION_CODEC_SECTION; +pub const FORMAT_VERSION: u8 = WPK_FORK_EXCEPTION_CODEC_VERSION; +pub const FORMAT_HEADER_SIZE: usize = WPK_FORK_EXCEPTION_CODEC_HEADER_SIZE as usize; +pub const FORMAT_TAG_RECORD_SIZE: usize = WPK_FORK_EXCEPTION_CODEC_TAG_RECORD_SIZE as usize; + +pub const HOST_IMPORT_MODULE: &str = WPK_FORK_EXCEPTION_CODEC_IMPORT_MODULE; +pub const IMPORT_ACTIVATION: &str = WPK_FORK_EXCEPTION_IMPORT_ACTIVATION; +pub const IMPORT_LOOKUP: &str = WPK_FORK_EXCEPTION_IMPORT_LOOKUP; +pub const IMPORT_CLAIM: &str = WPK_FORK_EXCEPTION_IMPORT_CLAIM; +pub const IMPORT_DEFINE: &str = WPK_FORK_EXCEPTION_IMPORT_DEFINE; +pub const IMPORT_LOAD: &str = WPK_FORK_EXCEPTION_IMPORT_LOAD; +pub const IMPORT_ROUTE: &str = WPK_FORK_EXCEPTION_IMPORT_ROUTE; +pub const IMPORT_CACHE_INDEX: &str = WPK_FORK_EXCEPTION_IMPORT_CACHE_INDEX; +pub const IMPORT_BROKER_ENCODE: &str = WPK_FORK_EXCEPTION_IMPORT_BROKER_ENCODE; +pub const IMPORT_BROKER_THROW_RECIPE: &str = WPK_FORK_EXCEPTION_IMPORT_BROKER_THROW_RECIPE; +pub const IMPORT_INGRESS_THROW: &str = WPK_FORK_EXCEPTION_IMPORT_INGRESS_THROW; +pub const IMPORT_SCRATCH_RESERVE: &str = WPK_FORK_REFERENCE_IMPORT_SCRATCH_RESERVE; +pub const IMPORT_SCRATCH_RELEASE: &str = WPK_FORK_REFERENCE_IMPORT_SCRATCH_RELEASE; + +pub const EXPORT_ENCODE: &str = WPK_FORK_EXCEPTION_EXPORT_ENCODE; +pub const EXPORT_DECODE: &str = WPK_FORK_EXCEPTION_EXPORT_DECODE; +pub const EXPORT_THROW_SLOT: &str = WPK_FORK_EXCEPTION_EXPORT_THROW_SLOT; +pub const EXPORT_THROW_RECIPE: &str = WPK_FORK_EXCEPTION_EXPORT_THROW_RECIPE; +pub const EXPORT_ENCODE_INGRESS: &str = WPK_FORK_EXCEPTION_EXPORT_ENCODE_INGRESS; +pub const EXPORT_MATERIALIZE: &str = WPK_FORK_EXCEPTION_EXPORT_MATERIALIZE; +pub const EXPORT_CLEAR: &str = WPK_FORK_EXCEPTION_EXPORT_CLEAR; +pub const EXPORT_ABORT: &str = WPK_FORK_EXCEPTION_EXPORT_ABORT; + +const MAX_RECIPE_ID: i32 = 0x7fff_fffe; + +pub fn is_reserved_host_import(name: &str) -> bool { + matches!( + name, + IMPORT_ACTIVATION + | IMPORT_LOOKUP + | IMPORT_CLAIM + | IMPORT_DEFINE + | IMPORT_LOAD + | IMPORT_ROUTE + | IMPORT_CACHE_INDEX + | IMPORT_BROKER_ENCODE + | IMPORT_BROKER_THROW_RECIPE + | IMPORT_INGRESS_THROW + | IMPORT_SCRATCH_RESERVE + | IMPORT_SCRATCH_RELEASE + ) +} + +#[derive(Debug, Clone, Copy)] +pub struct ReferenceDependencies { + pub encode_funcref: FunctionId, + pub decode_funcref: FunctionId, + pub encode_externref: FunctionId, + pub decode_externref: FunctionId, + pub encode_anyref: FunctionId, + pub decode_anyref: FunctionId, +} + +#[derive(Debug, Clone, Copy)] +pub struct InjectedExceptionCodec { + pub encode: FunctionId, + pub decode: FunctionId, + pub throw_slot: FunctionId, + pub throw_recipe: FunctionId, + pub encode_ingress: FunctionId, + pub materialize: FunctionId, + pub clear: FunctionId, + pub abort: FunctionId, + pub memory: MemoryId, + pub references: ReferenceDependencies, +} + +#[derive(Debug, Clone, Copy)] +struct HostImports { + activation: GlobalId, + lookup: FunctionId, + claim: FunctionId, + define: FunctionId, + load: FunctionId, + route: FunctionId, + cache_index: FunctionId, + broker_encode: FunctionId, + broker_throw_recipe: FunctionId, + ingress_throw: FunctionId, + scratch_reserve: FunctionId, + scratch_release: FunctionId, +} + +#[derive(Debug, Clone)] +struct PayloadLayout { + ty: ValType, + scalar_offset: Option, + reference_offset: Option, +} + +#[derive(Debug, Clone)] +struct TagLayout { + tag: TagId, + ordinal: u32, + layout_id: u32, + scalar_len: u32, + references_ptr: u32, + reference_count: u32, + payloads: Vec, +} + +impl TagLayout { + fn staging_len(&self) -> u32 { + self.references_ptr + .checked_add(self.reference_count.saturating_mul(4)) + .expect("validated exception staging layout") + .max(1) + } +} + +#[derive(Debug, Clone, Copy)] +struct PayloadLocals { + value: LocalId, + recipe: Option, +} + +#[derive(Debug, Clone)] +struct HandlerLocals { + payloads: Vec, +} + +const NULLABLE_EXNREF: RefType = RefType { + nullable: true, + heap_type: HeapType::Abstract(AbstractHeapType::Exn), +}; +const NON_NULL_EXNREF: RefType = RefType { + nullable: false, + heap_type: HeapType::Abstract(AbstractHeapType::Exn), +}; + +/// Inject the exact-tag codec before the generic continuation runtime. +/// +/// The module-state plan and function catalog must already be frozen: codec +/// tables are temporary reconstruction caches, not guest mutable table state, +/// and codec helper functions are not source-level `ref.func` targets. +pub fn inject(module: &mut Module, memory: MemoryId) -> Result { + inject_with_reference_overrides(module, memory, None, None) +} + +pub fn inject_with_anyref( + module: &mut Module, + memory: MemoryId, + anyref: Option<(FunctionId, FunctionId)>, +) -> Result { + inject_with_reference_overrides(module, memory, None, anyref) +} + +pub fn inject_with_reference_overrides( + module: &mut Module, + memory: MemoryId, + externref: Option<(FunctionId, FunctionId)>, + anyref: Option<(FunctionId, FunctionId)>, +) -> Result { + let layouts = plan_tags(module)?; + let ptr_ty = if module.memories.get(memory).memory64 { + ValType::I64 + } else { + ValType::I32 + }; + let scratch = module.tables.add_local(false, 1, Some(1), NULLABLE_EXNREF); + module.tables.get_mut(scratch).name = Some("__wpk_fork_ref_exn_scratch".into()); + let replay = module.tables.add_local(false, 1, None, NULLABLE_EXNREF); + module.tables.get_mut(replay).name = Some("__wpk_fork_ref_exn_replay".into()); + + let imports = inject_host_imports(module, ptr_ty); + let references = inject_reference_dependencies(module, externref, anyref); + + let (encode, encode_args) = add_stub( + module, + &[ValType::Ref(NULLABLE_EXNREF)], + &[ValType::I32], + EXPORT_ENCODE, + ); + let (decode, decode_args) = add_stub( + module, + &[ValType::I32], + &[ValType::Ref(NULLABLE_EXNREF)], + EXPORT_DECODE, + ); + let (throw_slot, throw_slot_args) = add_stub(module, &[ValType::I32], &[], EXPORT_THROW_SLOT); + let (throw_recipe, throw_recipe_args) = + add_stub(module, &[ValType::I32], &[], EXPORT_THROW_RECIPE); + let (encode_ingress, encode_ingress_args) = add_stub( + module, + &[ValType::I32], + &[ValType::I32], + EXPORT_ENCODE_INGRESS, + ); + let (materialize, materialize_args) = + add_stub(module, &[ValType::I32], &[], EXPORT_MATERIALIZE); + let (clear, _) = add_stub(module, &[], &[], EXPORT_CLEAR); + let (abort, _) = add_stub(module, &[], &[], EXPORT_ABORT); + + emit_encode( + module, + encode, + encode_args[0], + scratch, + memory, + ptr_ty, + imports, + references, + &layouts, + ); + emit_decode( + module, + decode, + decode_args[0], + replay, + memory, + ptr_ty, + imports, + references, + &layouts, + ); + emit_throw_slot(module, throw_slot, throw_slot_args[0], scratch); + emit_throw_recipe(module, throw_recipe, throw_recipe_args[0], decode); + emit_encode_ingress( + module, + encode_ingress, + encode_ingress_args[0], + imports.ingress_throw, + encode, + ); + emit_materialize(module, materialize, materialize_args[0], decode); + emit_clear(module, clear, scratch, replay); + emit_clear(module, abort, scratch, replay); + + for (name, function) in [ + (EXPORT_ENCODE, encode), + (EXPORT_DECODE, decode), + (EXPORT_THROW_SLOT, throw_slot), + (EXPORT_THROW_RECIPE, throw_recipe), + (EXPORT_ENCODE_INGRESS, encode_ingress), + (EXPORT_MATERIALIZE, materialize), + (EXPORT_CLEAR, clear), + (EXPORT_ABORT, abort), + ] { + module.exports.add(name, function); + } + replace_descriptor(module, &layouts); + + Ok(InjectedExceptionCodec { + encode, + decode, + throw_slot, + throw_recipe, + encode_ingress, + materialize, + clear, + abort, + memory, + references, + }) +} + +fn plan_tags(module: &Module) -> Result> { + let mut layouts = Vec::new(); + for (ordinal, tag) in module.tags.iter().enumerate() { + let ty = module.types.get(tag.ty()); + ensure!( + ty.results().is_empty(), + "fork-instrument: exception tag {ordinal} unexpectedly has results" + ); + let mut scalar_len = 0u32; + let mut reference_count = 0u32; + let mut payloads = Vec::new(); + for payload in ty.params().iter().copied() { + match payload { + ValType::I32 | ValType::F32 => { + let offset = scalar_len; + scalar_len = scalar_len + .checked_add(4) + .ok_or_else(|| anyhow::anyhow!("exception scalar layout overflow"))?; + payloads.push(PayloadLayout { + ty: payload, + scalar_offset: Some(offset), + reference_offset: None, + }); + } + ValType::I64 | ValType::F64 => { + let offset = scalar_len; + scalar_len = scalar_len + .checked_add(8) + .ok_or_else(|| anyhow::anyhow!("exception scalar layout overflow"))?; + payloads.push(PayloadLayout { + ty: payload, + scalar_offset: Some(offset), + reference_offset: None, + }); + } + ValType::V128 => { + let offset = scalar_len; + scalar_len = scalar_len + .checked_add(16) + .ok_or_else(|| anyhow::anyhow!("exception scalar layout overflow"))?; + payloads.push(PayloadLayout { + ty: payload, + scalar_offset: Some(offset), + reference_offset: None, + }); + } + ValType::Ref(_) => { + payloads.push(PayloadLayout { + ty: payload, + scalar_offset: None, + reference_offset: Some(reference_count), + }); + reference_count = reference_count + .checked_add(1) + .ok_or_else(|| anyhow::anyhow!("exception reference layout overflow"))?; + } + } + } + let references_ptr = align_up(scalar_len, 4)?; + layouts.push(TagLayout { + tag: tag.id(), + ordinal: ordinal as u32, + layout_id: ordinal as u32, + scalar_len, + references_ptr, + reference_count, + payloads, + }); + } + Ok(layouts) +} + +fn align_up(value: u32, alignment: u32) -> Result { + let mask = alignment - 1; + value + .checked_add(mask) + .map(|value| value & !mask) + .ok_or_else(|| anyhow::anyhow!("exception staging layout overflow")) +} + +fn inject_host_imports(module: &mut Module, ptr_ty: ValType) -> HostImports { + let existing_activation = module.imports.iter().find_map(|import| { + (import.module == HOST_IMPORT_MODULE && import.name == IMPORT_ACTIVATION) + .then_some(&import.kind) + .and_then(|kind| match kind { + ImportKind::Global(global) => Some(*global), + _ => None, + }) + }); + let activation = match existing_activation { + Some(activation) => activation, + None => { + module + .add_import_global( + HOST_IMPORT_MODULE, + IMPORT_ACTIVATION, + ValType::I32, + false, + false, + ) + .0 + } + }; + let lookup = import_function(module, IMPORT_LOOKUP, &[ValType::I32], &[ValType::I32]); + let claim = import_function(module, IMPORT_CLAIM, &[ValType::I32], &[ValType::I32]); + let define = import_function( + module, + IMPORT_DEFINE, + &[ + ValType::I32, + ValType::I32, + ValType::I32, + ValType::I32, + ptr_ty, + ValType::I32, + ptr_ty, + ValType::I32, + ], + &[], + ); + let load = import_function( + module, + IMPORT_LOAD, + &[ + ValType::I32, + ValType::I32, + ValType::I32, + ValType::I32, + ptr_ty, + ValType::I32, + ptr_ty, + ValType::I32, + ], + &[ValType::I32], + ); + let route = import_function( + module, + IMPORT_ROUTE, + &[ValType::I32, ValType::I32], + &[ValType::I32], + ); + let cache_index = import_function(module, IMPORT_CACHE_INDEX, &[ValType::I32], &[ValType::I32]); + let broker_encode = import_function( + module, + IMPORT_BROKER_ENCODE, + &[ValType::I32], + &[ValType::I32], + ); + let broker_throw_recipe = + import_function(module, IMPORT_BROKER_THROW_RECIPE, &[ValType::I32], &[]); + let ingress_throw = import_function(module, IMPORT_INGRESS_THROW, &[ValType::I32], &[]); + let scratch_reserve = import_function(module, IMPORT_SCRATCH_RESERVE, &[ptr_ty], &[ptr_ty]); + let scratch_release = import_function(module, IMPORT_SCRATCH_RELEASE, &[ptr_ty, ptr_ty], &[]); + HostImports { + activation, + lookup, + claim, + define, + load, + route, + cache_index, + broker_encode, + broker_throw_recipe, + ingress_throw, + scratch_reserve, + scratch_release, + } +} + +fn inject_reference_dependencies( + module: &mut Module, + externref: Option<(FunctionId, FunctionId)>, + anyref: Option<(FunctionId, FunctionId)>, +) -> ReferenceDependencies { + fn pair( + module: &mut Module, + reference: RefType, + encode_name: &str, + decode_name: &str, + ) -> (FunctionId, FunctionId) { + let value = ValType::Ref(reference); + ( + import_function(module, encode_name, &[value], &[ValType::I32]), + import_function(module, decode_name, &[ValType::I32], &[value]), + ) + } + let (encode_funcref, decode_funcref) = pair( + module, + RefType::FUNCREF, + runtime_names::IMPORT_REF_ENCODE_FUNCREF, + runtime_names::IMPORT_REF_DECODE_FUNCREF, + ); + let (encode_externref, decode_externref) = externref.unwrap_or_else(|| { + pair( + module, + RefType::EXTERNREF, + runtime_names::IMPORT_REF_ENCODE_EXTERNREF, + runtime_names::IMPORT_REF_DECODE_EXTERNREF, + ) + }); + let (encode_anyref, decode_anyref) = anyref.unwrap_or_else(|| { + pair( + module, + RefType::ANYREF, + runtime_names::IMPORT_REF_ENCODE_ANYREF, + runtime_names::IMPORT_REF_DECODE_ANYREF, + ) + }); + ReferenceDependencies { + encode_funcref, + decode_funcref, + encode_externref, + decode_externref, + encode_anyref, + decode_anyref, + } +} + +fn import_function( + module: &mut Module, + name: &str, + params: &[ValType], + results: &[ValType], +) -> FunctionId { + let ty = module.types.add(params, results); + module.add_import_func(HOST_IMPORT_MODULE, name, ty).0 +} + +fn add_stub( + module: &mut Module, + params: &[ValType], + results: &[ValType], + name: &str, +) -> (FunctionId, Vec) { + let args: Vec<_> = params + .iter() + .copied() + .map(|ty| module.locals.add(ty)) + .collect(); + let mut builder = FunctionBuilder::new(&mut module.types, params, results); + builder.name(name.into()); + let function = builder.finish(args.clone(), &mut module.funcs); + (function, args) +} + +#[allow(clippy::too_many_arguments)] +fn emit_encode( + module: &mut Module, + function: FunctionId, + exception: LocalId, + scratch: TableId, + memory: MemoryId, + ptr_ty: ValType, + imports: HostImports, + references: ReferenceDependencies, + layouts: &[TagLayout], +) { + let recipe = module.locals.add(ValType::I32); + let staging = module.locals.add(ptr_ty); + let handler_locals: Vec<_> = layouts + .iter() + .map(|layout| HandlerLocals { + payloads: layout + .payloads + .iter() + .map(|payload| PayloadLocals { + value: module.locals.add(storage_type(payload.ty)), + recipe: matches!(payload.ty, ValType::Ref(_)) + .then(|| module.locals.add(ValType::I32)), + }) + .collect(), + }) + .collect(); + + let null_then = dangling(module, function, InstrSeqType::Simple(None)); + { + let instrs = instrs_mut(module, function, null_then); + constant_i32(instrs, 0); + push(instrs, Instr::Return(Return {})); + } + let empty_else = dangling(module, function, InstrSeqType::Simple(None)); + + let existing_then = dangling(module, function, InstrSeqType::Simple(None)); + { + let instrs = instrs_mut(module, function, existing_then); + emit_clear_scratch(instrs, scratch); + local_get(instrs, recipe); + push(instrs, Instr::Return(Return {})); + } + let existing_else = dangling(module, function, InstrSeqType::Simple(None)); + + let outer = dangling(module, function, InstrSeqType::Simple(Some(ValType::I32))); + let mut caps = Vec::new(); + for layout in layouts { + let mut results: Vec<_> = layout.payloads.iter().map(|payload| payload.ty).collect(); + results.push(ValType::Ref(NON_NULL_EXNREF)); + let ty = InstrSeqType::new(&mut module.types, &[], &results); + caps.push(dangling(module, function, ty)); + } + let fallback_ty = InstrSeqType::new(&mut module.types, &[], &[ValType::Ref(NON_NULL_EXNREF)]); + let fallback = dangling(module, function, fallback_ty); + caps.push(fallback); + + let throw_body = dangling(module, function, InstrSeqType::Simple(None)); + { + let instrs = instrs_mut(module, function, throw_body); + local_get(instrs, exception); + push(instrs, Instr::RefAsNonNull(RefAsNonNull {})); + push(instrs, Instr::ThrowRef(ThrowRef {})); + } + let catches: Vec<_> = layouts + .iter() + .zip(caps.iter()) + .map(|(layout, cap)| TryTableCatch::CatchRef { + tag: layout.tag, + label: *cap, + }) + .chain(std::iter::once(TryTableCatch::CatchAllRef { + label: fallback, + })) + .collect(); + { + let innermost = *caps.last().expect("fallback cap always exists"); + let instrs = instrs_mut(module, function, innermost); + push( + instrs, + Instr::TryTable(TryTable { + seq: throw_body, + catches, + }), + ); + push(instrs, Instr::Unreachable(Unreachable {})); + } + + for index in (0..caps.len() - 1).rev() { + let child = caps[index + 1]; + push( + instrs_mut(module, function, caps[index]), + Instr::Block(Block { seq: child }), + ); + if index + 1 == layouts.len() { + emit_unknown_encode_handler( + module, + function, + caps[index], + scratch, + imports.broker_encode, + recipe, + ); + } else { + emit_known_encode_handler( + module, + function, + caps[index], + &layouts[index + 1], + &handler_locals[index + 1], + scratch, + memory, + ptr_ty, + staging, + imports, + references, + function, + recipe, + ); + } + } + push( + instrs_mut(module, function, outer), + Instr::Block(Block { seq: caps[0] }), + ); + if layouts.is_empty() { + emit_unknown_encode_handler( + module, + function, + outer, + scratch, + imports.broker_encode, + recipe, + ); + } else { + emit_known_encode_handler( + module, + function, + outer, + &layouts[0], + &handler_locals[0], + scratch, + memory, + ptr_ty, + staging, + imports, + references, + function, + recipe, + ); + } + + let entry = entry(function, module); + let instrs = instrs_mut(module, function, entry); + local_get(instrs, exception); + push(instrs, Instr::RefIsNull(RefIsNull {})); + push( + instrs, + Instr::IfElse(IfElse { + consequent: null_then, + alternative: empty_else, + }), + ); + constant_i32(instrs, 0); + local_get(instrs, exception); + push(instrs, Instr::TableSet(TableSet { table: scratch })); + constant_i32(instrs, 0); + call(instrs, imports.lookup); + push(instrs, Instr::LocalTee(LocalTee { local: recipe })); + push( + instrs, + Instr::IfElse(IfElse { + consequent: existing_then, + alternative: existing_else, + }), + ); + push(instrs, Instr::Block(Block { seq: outer })); +} + +#[allow(clippy::too_many_arguments)] +fn emit_known_encode_handler( + module: &mut Module, + function: FunctionId, + seq: InstrSeqId, + layout: &TagLayout, + locals: &HandlerLocals, + scratch: TableId, + memory: MemoryId, + ptr_ty: ValType, + staging: LocalId, + imports: HostImports, + references: ReferenceDependencies, + encode_exnref: FunctionId, + recipe: LocalId, +) { + let encoders: Vec<_> = layout + .payloads + .iter() + .map(|payload| match payload.ty { + ValType::Ref(reference) => Some(reference_encoder( + module, + references, + encode_exnref, + reference, + )), + _ => None, + }) + .collect(); + let instrs = instrs_mut(module, function, seq); + push(instrs, Instr::Drop(Drop {})); + for payload in locals.payloads.iter().rev() { + local_set(instrs, payload.value); + } + constant_i32(instrs, 0); + call(instrs, imports.claim); + local_set(instrs, recipe); + emit_clear_scratch(instrs, scratch); + + // WHY: transaction scratch is disjoint for recursive payload codecs and + // lives in the one process memory copied into the child. It is transient + // exchange storage, never continuation evidence. + constant_ptr(instrs, ptr_ty, u64::from(layout.staging_len())); + call(instrs, imports.scratch_reserve); + local_set(instrs, staging); + + for ((payload, local), encoder) in layout.payloads.iter().zip(&locals.payloads).zip(encoders) { + let ValType::Ref(reference) = payload.ty else { + continue; + }; + local_get(instrs, local.value); + let _ = reference; + call(instrs, encoder.expect("reference payload encoder")); + local_set( + instrs, + local.recipe.expect("reference payload has recipe local"), + ); + } + for (payload, local) in layout.payloads.iter().zip(&locals.payloads) { + if let Some(offset) = payload.scalar_offset { + emit_staging_address(instrs, staging, ptr_ty, 0); + local_get(instrs, local.value); + push( + instrs, + Instr::Store(Store { + memory, + kind: scalar_store(payload.ty), + arg: MemArg { + align: 1, + offset: u64::from(offset), + }, + }), + ); + } else { + let index = payload.reference_offset.expect("reference payload index"); + emit_staging_address(instrs, staging, ptr_ty, 0); + local_get( + instrs, + local.recipe.expect("reference payload recipe local"), + ); + push( + instrs, + Instr::Store(Store { + memory, + kind: StoreKind::I32 { atomic: false }, + arg: MemArg { + align: 4, + offset: u64::from(layout.references_ptr + index * 4), + }, + }), + ); + } + } + local_get(instrs, recipe); + push( + instrs, + Instr::GlobalGet(GlobalGet { + global: imports.activation, + }), + ); + constant_i32(instrs, layout.ordinal as i32); + constant_i32(instrs, layout.layout_id as i32); + emit_staging_address(instrs, staging, ptr_ty, 0); + constant_i32(instrs, layout.scalar_len as i32); + emit_staging_address(instrs, staging, ptr_ty, layout.references_ptr); + constant_i32(instrs, layout.reference_count as i32); + call(instrs, imports.define); + local_get(instrs, staging); + constant_ptr(instrs, ptr_ty, u64::from(layout.staging_len())); + call(instrs, imports.scratch_release); + local_get(instrs, recipe); + push(instrs, Instr::Return(Return {})); +} + +fn emit_unknown_encode_handler( + module: &mut Module, + function: FunctionId, + seq: InstrSeqId, + scratch: TableId, + broker_encode: FunctionId, + recipe: LocalId, +) { + let instrs = instrs_mut(module, function, seq); + push(instrs, Instr::Drop(Drop {})); + constant_i32(instrs, 0); + call(instrs, broker_encode); + local_set(instrs, recipe); + emit_clear_scratch(instrs, scratch); + local_get(instrs, recipe); + push(instrs, Instr::Return(Return {})); +} + +#[allow(clippy::too_many_arguments)] +fn emit_decode( + module: &mut Module, + function: FunctionId, + recipe: LocalId, + replay: TableId, + memory: MemoryId, + ptr_ty: ValType, + imports: HostImports, + references: ReferenceDependencies, + layouts: &[TagLayout], +) { + let route = module.locals.add(ValType::I32); + let cache_index = module.locals.add(ValType::I32); + let cached = module.locals.add(ValType::Ref(NULLABLE_EXNREF)); + let staging = module.locals.add(ptr_ty); + let null_then = dangling(module, function, InstrSeqType::Simple(None)); + { + let instrs = instrs_mut(module, function, null_then); + push( + instrs, + Instr::RefNull(RefNull { + ty: NULLABLE_EXNREF, + }), + ); + push(instrs, Instr::Return(Return {})); + } + let empty_else = dangling(module, function, InstrSeqType::Simple(None)); + let invalid_then = dangling(module, function, InstrSeqType::Simple(None)); + push( + instrs_mut(module, function, invalid_then), + Instr::Unreachable(Unreachable {}), + ); + let invalid_else = dangling(module, function, InstrSeqType::Simple(None)); + let grow_then = dangling(module, function, InstrSeqType::Simple(None)); + { + let failed_then = dangling(module, function, InstrSeqType::Simple(None)); + push( + instrs_mut(module, function, failed_then), + Instr::Unreachable(Unreachable {}), + ); + let failed_else = dangling(module, function, InstrSeqType::Simple(None)); + let instrs = instrs_mut(module, function, grow_then); + push( + instrs, + Instr::RefNull(RefNull { + ty: NULLABLE_EXNREF, + }), + ); + local_get(instrs, cache_index); + constant_i32(instrs, 1); + binop(instrs, BinaryOp::I32Add); + push(instrs, Instr::TableSize(TableSize { table: replay })); + binop(instrs, BinaryOp::I32Sub); + push(instrs, Instr::TableGrow(TableGrow { table: replay })); + constant_i32(instrs, -1); + binop(instrs, BinaryOp::I32Eq); + push( + instrs, + Instr::IfElse(IfElse { + consequent: failed_then, + alternative: failed_else, + }), + ); + } + let grow_else = dangling(module, function, InstrSeqType::Simple(None)); + let cached_then = dangling(module, function, InstrSeqType::Simple(None)); + { + let instrs = instrs_mut(module, function, cached_then); + local_get(instrs, cached); + push(instrs, Instr::Return(Return {})); + } + let cached_else = dangling(module, function, InstrSeqType::Simple(None)); + + let broker_then = dangling(module, function, InstrSeqType::Simple(None)); + emit_broker_decode( + module, + function, + broker_then, + recipe, + cache_index, + cached, + replay, + imports.broker_throw_recipe, + ); + let broker_else = dangling(module, function, InstrSeqType::Simple(None)); + + let known_locals: Vec<_> = layouts + .iter() + .map(|layout| HandlerLocals { + payloads: layout + .payloads + .iter() + .map(|payload| PayloadLocals { + value: module.locals.add(storage_type(payload.ty)), + recipe: matches!(payload.ty, ValType::Ref(_)) + .then(|| module.locals.add(ValType::I32)), + }) + .collect(), + }) + .collect(); + let known_blocks: Vec<_> = layouts + .iter() + .zip(&known_locals) + .map(|(layout, locals)| { + let seq = dangling(module, function, InstrSeqType::Simple(None)); + emit_known_decode( + module, + function, + seq, + recipe, + cache_index, + cached, + replay, + memory, + ptr_ty, + staging, + imports, + references, + function, + layout, + locals, + ); + seq + }) + .collect(); + + let entry = entry(function, module); + let instrs = instrs_mut(module, function, entry); + local_get(instrs, recipe); + unop(instrs, UnaryOp::I32Eqz); + push( + instrs, + Instr::IfElse(IfElse { + consequent: null_then, + alternative: empty_else, + }), + ); + local_get(instrs, recipe); + constant_i32(instrs, 0); + binop(instrs, BinaryOp::I32LtS); + local_get(instrs, recipe); + constant_i32(instrs, MAX_RECIPE_ID); + binop(instrs, BinaryOp::I32GtU); + binop(instrs, BinaryOp::I32Or); + push( + instrs, + Instr::IfElse(IfElse { + consequent: invalid_then, + alternative: invalid_else, + }), + ); + local_get(instrs, recipe); + call(instrs, imports.cache_index); + push(instrs, Instr::LocalTee(LocalTee { local: cache_index })); + unop(instrs, UnaryOp::I32Eqz); + push( + instrs, + Instr::IfElse(IfElse { + consequent: invalid_then, + alternative: invalid_else, + }), + ); + local_get(instrs, cache_index); + push(instrs, Instr::TableSize(TableSize { table: replay })); + binop(instrs, BinaryOp::I32GeU); + push( + instrs, + Instr::IfElse(IfElse { + consequent: grow_then, + alternative: grow_else, + }), + ); + local_get(instrs, cache_index); + push(instrs, Instr::TableGet(TableGet { table: replay })); + push(instrs, Instr::LocalTee(LocalTee { local: cached })); + push(instrs, Instr::RefIsNull(RefIsNull {})); + unop(instrs, UnaryOp::I32Eqz); + push( + instrs, + Instr::IfElse(IfElse { + consequent: cached_then, + alternative: cached_else, + }), + ); + local_get(instrs, recipe); + push( + instrs, + Instr::GlobalGet(GlobalGet { + global: imports.activation, + }), + ); + call(instrs, imports.route); + push(instrs, Instr::LocalTee(LocalTee { local: route })); + constant_i32(instrs, -1); + binop(instrs, BinaryOp::I32Eq); + push( + instrs, + Instr::IfElse(IfElse { + consequent: broker_then, + alternative: broker_else, + }), + ); + for (layout, block) in layouts.iter().zip(known_blocks) { + let next = dangling(module, function, InstrSeqType::Simple(None)); + local_get(instrs_mut(module, function, entry), route); + constant_i32(instrs_mut(module, function, entry), layout.layout_id as i32); + binop(instrs_mut(module, function, entry), BinaryOp::I32Eq); + push( + instrs_mut(module, function, entry), + Instr::IfElse(IfElse { + consequent: block, + alternative: next, + }), + ); + } + push( + instrs_mut(module, function, entry), + Instr::Unreachable(Unreachable {}), + ); +} + +fn emit_broker_decode( + module: &mut Module, + function: FunctionId, + seq: InstrSeqId, + recipe: LocalId, + cache_index: LocalId, + cached: LocalId, + replay: TableId, + broker_throw: FunctionId, +) { + let cap_ty = InstrSeqType::new(&mut module.types, &[], &[ValType::Ref(NON_NULL_EXNREF)]); + let cap = dangling(module, function, cap_ty); + let body = dangling(module, function, InstrSeqType::Simple(None)); + { + let instrs = instrs_mut(module, function, body); + local_get(instrs, recipe); + call(instrs, broker_throw); + push(instrs, Instr::Unreachable(Unreachable {})); + } + { + let instrs = instrs_mut(module, function, cap); + push( + instrs, + Instr::TryTable(TryTable { + seq: body, + catches: vec![TryTableCatch::CatchAllRef { label: cap }], + }), + ); + push(instrs, Instr::Unreachable(Unreachable {})); + } + let instrs = instrs_mut(module, function, seq); + push(instrs, Instr::Block(Block { seq: cap })); + local_set(instrs, cached); + emit_cache_and_return(instrs, cache_index, cached, replay); +} + +#[allow(clippy::too_many_arguments)] +fn emit_known_decode( + module: &mut Module, + function: FunctionId, + seq: InstrSeqId, + recipe: LocalId, + cache_index: LocalId, + cached: LocalId, + replay: TableId, + memory: MemoryId, + ptr_ty: ValType, + staging: LocalId, + imports: HostImports, + references: ReferenceDependencies, + decode_exnref: FunctionId, + layout: &TagLayout, + locals: &HandlerLocals, +) { + let invalid = dangling(module, function, InstrSeqType::Simple(None)); + push( + instrs_mut(module, function, invalid), + Instr::Unreachable(Unreachable {}), + ); + let valid = dangling(module, function, InstrSeqType::Simple(None)); + let classes: Vec<_> = layout + .payloads + .iter() + .map(|payload| match payload.ty { + ValType::Ref(reference) => Some(ReferenceCodecClass::of(module, reference)), + _ => None, + }) + .collect(); + { + let instrs = instrs_mut(module, function, seq); + constant_ptr(instrs, ptr_ty, u64::from(layout.staging_len())); + call(instrs, imports.scratch_reserve); + local_set(instrs, staging); + local_get(instrs, recipe); + push( + instrs, + Instr::GlobalGet(GlobalGet { + global: imports.activation, + }), + ); + constant_i32(instrs, layout.ordinal as i32); + constant_i32(instrs, layout.layout_id as i32); + emit_staging_address(instrs, staging, ptr_ty, 0); + constant_i32(instrs, layout.scalar_len as i32); + emit_staging_address(instrs, staging, ptr_ty, layout.references_ptr); + constant_i32(instrs, layout.reference_count as i32); + call(instrs, imports.load); + unop(instrs, UnaryOp::I32Eqz); + push( + instrs, + Instr::IfElse(IfElse { + consequent: invalid, + alternative: valid, + }), + ); + + for (payload, local) in layout.payloads.iter().zip(&locals.payloads) { + emit_staging_address(instrs, staging, ptr_ty, 0); + if let Some(offset) = payload.scalar_offset { + push( + instrs, + Instr::Load(Load { + memory, + kind: scalar_load(payload.ty), + arg: MemArg { + align: 1, + offset: u64::from(offset), + }, + }), + ); + local_set(instrs, local.value); + } else { + let index = payload.reference_offset.expect("reference payload index"); + push( + instrs, + Instr::Load(Load { + memory, + kind: LoadKind::I32 { atomic: false }, + arg: MemArg { + align: 4, + offset: u64::from(layout.references_ptr + index * 4), + }, + }), + ); + local_set( + instrs, + local.recipe.expect("reference payload recipe local"), + ); + } + } + // Scalar bits and child recipe IDs are now in typed locals. Releasing + // here lets recursive decoders reserve disjoint ranges and guarantees + // the transaction zeroes exchange bytes before reuse. + local_get(instrs, staging); + constant_ptr(instrs, ptr_ty, u64::from(layout.staging_len())); + call(instrs, imports.scratch_release); + for ((payload, local), class) in layout.payloads.iter().zip(&locals.payloads).zip(&classes) + { + let ValType::Ref(reference) = payload.ty else { + continue; + }; + local_get( + instrs, + local.recipe.expect("reference payload recipe local"), + ); + let class = class.expect("reference payload class"); + call(instrs, reference_decoder(references, decode_exnref, class)); + emit_narrow(instrs, class, reference); + local_set(instrs, local.value); + } + } + + let cap_ty = InstrSeqType::new(&mut module.types, &[], &[ValType::Ref(NON_NULL_EXNREF)]); + let cap = dangling(module, function, cap_ty); + let throw_body = dangling(module, function, InstrSeqType::Simple(None)); + { + let throw = instrs_mut(module, function, throw_body); + for (payload, local) in layout.payloads.iter().zip(&locals.payloads) { + local_get(throw, local.value); + if let ValType::Ref(reference) = payload.ty + && !reference.nullable + { + push(throw, Instr::RefAsNonNull(RefAsNonNull {})); + } + } + push(throw, Instr::Throw(Throw { tag: layout.tag })); + } + { + let capture = instrs_mut(module, function, cap); + push( + capture, + Instr::TryTable(TryTable { + seq: throw_body, + catches: vec![TryTableCatch::CatchAllRef { label: cap }], + }), + ); + push(capture, Instr::Unreachable(Unreachable {})); + } + push( + instrs_mut(module, function, seq), + Instr::Block(Block { seq: cap }), + ); + local_set(instrs_mut(module, function, seq), cached); + emit_cache_and_return( + instrs_mut(module, function, seq), + cache_index, + cached, + replay, + ); +} + +fn emit_cache_and_return( + instrs: &mut Vec<(Instr, InstrLocId)>, + cache_index: LocalId, + exception: LocalId, + replay: TableId, +) { + local_get(instrs, cache_index); + local_get(instrs, exception); + push(instrs, Instr::TableSet(TableSet { table: replay })); + local_get(instrs, exception); + push(instrs, Instr::Return(Return {})); +} + +fn emit_throw_slot(module: &mut Module, function: FunctionId, slot: LocalId, scratch: TableId) { + let entry = entry(function, module); + let instrs = instrs_mut(module, function, entry); + local_get(instrs, slot); + push(instrs, Instr::TableGet(TableGet { table: scratch })); + push(instrs, Instr::RefAsNonNull(RefAsNonNull {})); + push(instrs, Instr::ThrowRef(ThrowRef {})); +} + +fn emit_throw_recipe( + module: &mut Module, + function: FunctionId, + recipe: LocalId, + decode: FunctionId, +) { + let entry = entry(function, module); + let instrs = instrs_mut(module, function, entry); + local_get(instrs, recipe); + call(instrs, decode); + push(instrs, Instr::RefAsNonNull(RefAsNonNull {})); + push(instrs, Instr::ThrowRef(ThrowRef {})); +} + +fn emit_materialize( + module: &mut Module, + function: FunctionId, + recipe: LocalId, + decode: FunctionId, +) { + let entry = entry(function, module); + let instrs = instrs_mut(module, function, entry); + local_get(instrs, recipe); + call(instrs, decode); + // WHY: the JavaScript embedding rejects calls whose result contains + // `exnref`. Decode and cache entirely inside the owning instance, then + // cross the host boundary with a void result. + push(instrs, Instr::Drop(Drop {})); +} + +fn emit_encode_ingress( + module: &mut Module, + function: FunctionId, + token: LocalId, + ingress_throw: FunctionId, + encode: FunctionId, +) { + let cap_ty = InstrSeqType::new(&mut module.types, &[], &[ValType::Ref(NON_NULL_EXNREF)]); + let cap = dangling(module, function, cap_ty); + let body = dangling(module, function, InstrSeqType::Simple(None)); + { + let instrs = instrs_mut(module, function, body); + local_get(instrs, token); + call(instrs, ingress_throw); + push(instrs, Instr::Unreachable(Unreachable {})); + } + { + let instrs = instrs_mut(module, function, cap); + push( + instrs, + Instr::TryTable(TryTable { + seq: body, + catches: vec![TryTableCatch::CatchAllRef { label: cap }], + }), + ); + push(instrs, Instr::Unreachable(Unreachable {})); + } + let entry = entry(function, module); + let instrs = instrs_mut(module, function, entry); + push(instrs, Instr::Block(Block { seq: cap })); + call(instrs, encode); +} + +fn emit_clear(module: &mut Module, function: FunctionId, scratch: TableId, replay: TableId) { + let entry = entry(function, module); + let instrs = instrs_mut(module, function, entry); + emit_clear_scratch(instrs, scratch); + constant_i32(instrs, 0); + push( + instrs, + Instr::RefNull(RefNull { + ty: NULLABLE_EXNREF, + }), + ); + push(instrs, Instr::TableSize(TableSize { table: replay })); + push(instrs, Instr::TableFill(TableFill { table: replay })); +} + +fn emit_clear_scratch(instrs: &mut Vec<(Instr, InstrLocId)>, scratch: TableId) { + constant_i32(instrs, 0); + push( + instrs, + Instr::RefNull(RefNull { + ty: NULLABLE_EXNREF, + }), + ); + push(instrs, Instr::TableSet(TableSet { table: scratch })); +} + +fn reference_encoder( + module: &Module, + references: ReferenceDependencies, + encode_exnref: FunctionId, + reference: RefType, +) -> FunctionId { + match ReferenceCodecClass::of(module, reference) { + ReferenceCodecClass::Func => references.encode_funcref, + ReferenceCodecClass::Extern => references.encode_externref, + ReferenceCodecClass::Exn => encode_exnref, + ReferenceCodecClass::Any => references.encode_anyref, + } +} + +fn reference_decoder( + references: ReferenceDependencies, + decode_exnref: FunctionId, + class: ReferenceCodecClass, +) -> FunctionId { + match class { + ReferenceCodecClass::Func => references.decode_funcref, + ReferenceCodecClass::Extern => references.decode_externref, + ReferenceCodecClass::Exn => decode_exnref, + ReferenceCodecClass::Any => references.decode_anyref, + } +} + +fn emit_narrow( + instrs: &mut Vec<(Instr, InstrLocId)>, + class: ReferenceCodecClass, + expected: RefType, +) { + let broad = class.nullable_type(); + if expected.heap_type != broad.heap_type { + push( + instrs, + Instr::RefCast(RefCast { + nullable: expected.nullable, + heap_type: expected.heap_type, + }), + ); + } else if !expected.nullable { + push(instrs, Instr::RefAsNonNull(RefAsNonNull {})); + } +} + +fn storage_type(ty: ValType) -> ValType { + match ty { + ValType::Ref(mut reference) => { + reference.nullable = true; + ValType::Ref(reference) + } + scalar => scalar, + } +} + +fn scalar_store(ty: ValType) -> StoreKind { + match ty { + ValType::I32 => StoreKind::I32 { atomic: false }, + ValType::I64 => StoreKind::I64 { atomic: false }, + ValType::F32 => StoreKind::F32, + ValType::F64 => StoreKind::F64, + ValType::V128 => StoreKind::V128, + ValType::Ref(_) => unreachable!("reference payload uses a recipe ID"), + } +} + +fn scalar_load(ty: ValType) -> LoadKind { + match ty { + ValType::I32 => LoadKind::I32 { atomic: false }, + ValType::I64 => LoadKind::I64 { atomic: false }, + ValType::F32 => LoadKind::F32, + ValType::F64 => LoadKind::F64, + ValType::V128 => LoadKind::V128, + ValType::Ref(_) => unreachable!("reference payload uses a recipe ID"), + } +} + +fn replace_descriptor(module: &mut Module, layouts: &[TagLayout]) { + loop { + let existing = module + .customs + .iter() + .find(|(_, section)| section.name() == FORMAT_SECTION) + .map(|(id, _)| id); + let Some(existing) = existing else { break }; + module.customs.delete(existing); + } + let mut data = Vec::with_capacity(FORMAT_HEADER_SIZE + layouts.len() * FORMAT_TAG_RECORD_SIZE); + data.push(FORMAT_VERSION); + data.push(0); + data.extend_from_slice(&0u16.to_le_bytes()); + data.extend_from_slice(&(layouts.len() as u32).to_le_bytes()); + for layout in layouts { + data.extend_from_slice(&layout.ordinal.to_le_bytes()); + data.extend_from_slice(&layout.layout_id.to_le_bytes()); + data.extend_from_slice(&layout.scalar_len.to_le_bytes()); + data.extend_from_slice(&layout.reference_count.to_le_bytes()); + } + module.customs.add(RawCustomSection { + name: FORMAT_SECTION.into(), + data, + }); +} + +fn dangling(module: &mut Module, function: FunctionId, ty: InstrSeqType) -> InstrSeqId { + local_mut(module, function) + .builder_mut() + .dangling_instr_seq(ty) + .id() +} + +fn entry(function: FunctionId, module: &Module) -> InstrSeqId { + local(module, function).entry_block() +} + +fn instrs_mut( + module: &mut Module, + function: FunctionId, + seq: InstrSeqId, +) -> &mut Vec<(Instr, InstrLocId)> { + &mut local_mut(module, function).block_mut(seq).instrs +} + +fn local(module: &Module, function: FunctionId) -> &LocalFunction { + match &module.funcs.get(function).kind { + FunctionKind::Local(local) => local, + _ => unreachable!("injected exception codec function is local"), + } +} + +fn local_mut(module: &mut Module, function: FunctionId) -> &mut LocalFunction { + match &mut module.funcs.get_mut(function).kind { + FunctionKind::Local(local) => local, + _ => unreachable!("injected exception codec function is local"), + } +} + +fn push(instrs: &mut Vec<(Instr, InstrLocId)>, instr: Instr) { + instrs.push((instr, InstrLocId::default())); +} + +fn constant_i32(instrs: &mut Vec<(Instr, InstrLocId)>, value: i32) { + push( + instrs, + Instr::Const(Const { + value: Value::I32(value), + }), + ); +} + +fn constant_ptr(instrs: &mut Vec<(Instr, InstrLocId)>, ptr_ty: ValType, value: u64) { + match ptr_ty { + ValType::I32 => constant_i32(instrs, value as u32 as i32), + ValType::I64 => push( + instrs, + Instr::Const(Const { + value: Value::I64(value as i64), + }), + ), + other => unreachable!("unsupported exception staging pointer type {other:?}"), + } +} + +fn emit_staging_address( + instrs: &mut Vec<(Instr, InstrLocId)>, + staging: LocalId, + ptr_ty: ValType, + offset: u32, +) { + local_get(instrs, staging); + if offset == 0 { + return; + } + constant_ptr(instrs, ptr_ty, u64::from(offset)); + binop( + instrs, + match ptr_ty { + ValType::I32 => BinaryOp::I32Add, + ValType::I64 => BinaryOp::I64Add, + other => unreachable!("unsupported exception staging pointer type {other:?}"), + }, + ); +} + +fn local_get(instrs: &mut Vec<(Instr, InstrLocId)>, local: LocalId) { + push(instrs, Instr::LocalGet(LocalGet { local })); +} + +fn local_set(instrs: &mut Vec<(Instr, InstrLocId)>, local: LocalId) { + push(instrs, Instr::LocalSet(LocalSet { local })); +} + +fn call(instrs: &mut Vec<(Instr, InstrLocId)>, function: FunctionId) { + push(instrs, Instr::Call(Call { func: function })); +} + +fn binop(instrs: &mut Vec<(Instr, InstrLocId)>, op: BinaryOp) { + push(instrs, Instr::Binop(Binop { op })); +} + +fn unop(instrs: &mut Vec<(Instr, InstrLocId)>, op: UnaryOp) { + push(instrs, Instr::Unop(Unop { op })); +} diff --git a/crates/fork-instrument/src/module_gc_codec.rs b/crates/fork-instrument/src/module_gc_codec.rs new file mode 100644 index 0000000000..39e3040f16 --- /dev/null +++ b/crates/fork-instrument/src/module_gc_codec.rs @@ -0,0 +1,3836 @@ +//! Activation-owned codecs for WebAssembly GC references. +//! +//! The durable representation is a scalar recipe graph in copied linear +//! memory. A process-owned `anyref` table is only a transaction-local routing +//! bus: slot zero carries one synchronous probe value and slot `recipe + 1` +//! carries the parent or freshly reconstructed child identity. The host clears +//! every slot on successful replay, abort, and exec. +//! +//! Immutable arrays need constructor provenance. Unlike structs, an arbitrary +//! immutable array cannot be populated after allocation, and +//! `array.new_fixed` has a statically encoded arity. Planning therefore gives +//! every non-generic constructor site a deterministic layout id so replay can +//! execute the same typed constructor in the fresh instance. + +use std::collections::{HashMap, HashSet}; + +use anyhow::{Result, ensure}; +use walrus::{ + AbstractHeapType, CompositeType, DataId, ElementId, FieldType, FunctionBuilder, FunctionId, + FunctionKind, GlobalId, HeapType, ImportKind, LocalFunction, LocalId, MemoryId, Module, + RawCustomSection, RefType, StorageType, TableId, TypeId, ValType, + ir::{ + AnyConvertExtern, ArrayGet, ArrayLen, ArrayNew, ArrayNewData, ArrayNewDefault, + ArrayNewElem, ArrayNewFixed, ArraySet, BinaryOp, Binop, Br, BrIf, Call, Const, + ExternConvertAny, GlobalGet, IfElse, Instr, InstrLocId, Load, LoadKind, LocalGet, LocalSet, + LocalTee, Loop, MemArg, RefAsNonNull, RefCast, RefFunc, RefI31, RefIsNull, RefNull, + RefTest, Return, Store, StoreKind, StructGet, StructGetU, StructNew, StructNewDefault, + StructSet, TableGet, TableSet, Throw, TryTable, TryTableCatch, UnaryOp, Unop, Unreachable, + Value, Visitor, VisitorMut, dfs_in_order, dfs_pre_order_mut, + }, +}; + +use crate::{module_exception_codec, runtime}; +use wasm_posix_shared::abi::{ + WPK_FORK_EXCEPTION_IMPORT_ACTIVATION, WPK_FORK_GC_CODEC_FIELD_RECORD_SIZE, + WPK_FORK_GC_CODEC_HEADER_SIZE, WPK_FORK_GC_CODEC_LAYOUT_RECORD_SIZE, WPK_FORK_GC_CODEC_MAGIC, + WPK_FORK_GC_CODEC_SECTION, WPK_FORK_GC_CODEC_VERSION, WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE, + WPK_FORK_REFERENCE_EXPORT_GC_ALLOCATE, WPK_FORK_REFERENCE_EXPORT_GC_ENCODE_SLOT, + WPK_FORK_REFERENCE_EXPORT_GC_FILL, WPK_FORK_REFERENCE_EXPORT_GC_PROBE, + WPK_FORK_REFERENCE_EXPORT_GC_PUBLISH_EXTERNREF, WPK_FORK_REFERENCE_IMPORT_GC_BROKER_ENCODE, + WPK_FORK_REFERENCE_IMPORT_GC_CAPTURE_LAYOUT, WPK_FORK_REFERENCE_IMPORT_GC_CLAIM, + WPK_FORK_REFERENCE_IMPORT_GC_DEFINE, WPK_FORK_REFERENCE_IMPORT_GC_I31, + WPK_FORK_REFERENCE_IMPORT_GC_LOAD, WPK_FORK_REFERENCE_IMPORT_GC_LOOKUP, + WPK_FORK_REFERENCE_IMPORT_GC_PAYLOAD_LEN, WPK_FORK_REFERENCE_IMPORT_GC_PROVENANCE_BEGIN, + WPK_FORK_REFERENCE_IMPORT_GC_PROVENANCE_END, WPK_FORK_REFERENCE_IMPORT_GC_PROVENANCE_REF, + WPK_FORK_REFERENCE_IMPORT_GC_ROUTE, WPK_FORK_REFERENCE_IMPORT_GC_TRANSIT, +}; + +pub const FORMAT_SECTION: &str = WPK_FORK_GC_CODEC_SECTION; +pub const FORMAT_MAGIC: [u8; 4] = WPK_FORK_GC_CODEC_MAGIC; +pub const FORMAT_VERSION: u16 = WPK_FORK_GC_CODEC_VERSION; +pub const FORMAT_HEADER_SIZE: u16 = WPK_FORK_GC_CODEC_HEADER_SIZE; +pub const FORMAT_LAYOUT_RECORD_SIZE: u16 = WPK_FORK_GC_CODEC_LAYOUT_RECORD_SIZE; +pub const FORMAT_FIELD_RECORD_SIZE: u16 = WPK_FORK_GC_CODEC_FIELD_RECORD_SIZE; + +pub const KIND_STRUCT: u8 = 1; +pub const KIND_ARRAY: u8 = 2; + +pub const CONSTRUCTOR_STRUCT: u8 = 0; +pub const CONSTRUCTOR_ARRAY_GENERIC: u8 = 1; +pub const CONSTRUCTOR_ARRAY_NEW: u8 = 2; +pub const CONSTRUCTOR_ARRAY_DEFAULT: u8 = 3; +pub const CONSTRUCTOR_ARRAY_FIXED: u8 = 4; +pub const CONSTRUCTOR_ARRAY_DATA: u8 = 5; +pub const CONSTRUCTOR_ARRAY_ELEMENT: u8 = 6; + +pub const LAYOUT_FLAG_REQUIRES_PROVENANCE: u16 = 1 << 0; +pub const LAYOUT_FLAG_DEFAULTABLE_SHELL: u16 = 1 << 1; +pub const LAYOUT_KNOWN_FLAGS: u16 = LAYOUT_FLAG_REQUIRES_PROVENANCE | LAYOUT_FLAG_DEFAULTABLE_SHELL; + +pub const FIELD_FLAG_MUTABLE: u8 = 1 << 0; +pub const FIELD_FLAG_NULLABLE: u8 = 1 << 1; +pub const FIELD_FLAG_REFERENCE: u8 = 1 << 2; +pub const FIELD_FLAG_ALLOCATION_DEPENDENCY: u8 = 1 << 3; +pub const FIELD_KNOWN_FLAGS: u8 = FIELD_FLAG_MUTABLE + | FIELD_FLAG_NULLABLE + | FIELD_FLAG_REFERENCE + | FIELD_FLAG_ALLOCATION_DEPENDENCY; + +pub const STORAGE_I8: u8 = 1; +pub const STORAGE_I16: u8 = 2; +pub const STORAGE_I32: u8 = 3; +pub const STORAGE_I64: u8 = 4; +pub const STORAGE_F32: u8 = 5; +pub const STORAGE_F64: u8 = 6; +pub const STORAGE_V128: u8 = 7; +pub const STORAGE_REFERENCE: u8 = 8; + +const NO_ORDINAL: u32 = u32::MAX; + +pub const HOST_IMPORT_MODULE: &str = WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE; +pub const IMPORT_TRANSIT_TABLE: &str = WPK_FORK_REFERENCE_IMPORT_GC_TRANSIT; +pub const IMPORT_LOOKUP: &str = WPK_FORK_REFERENCE_IMPORT_GC_LOOKUP; +pub const IMPORT_CLAIM: &str = WPK_FORK_REFERENCE_IMPORT_GC_CLAIM; +pub const IMPORT_I31: &str = WPK_FORK_REFERENCE_IMPORT_GC_I31; +pub const IMPORT_DEFINE: &str = WPK_FORK_REFERENCE_IMPORT_GC_DEFINE; +pub const IMPORT_ROUTE: &str = WPK_FORK_REFERENCE_IMPORT_GC_ROUTE; +pub const IMPORT_PAYLOAD_LEN: &str = WPK_FORK_REFERENCE_IMPORT_GC_PAYLOAD_LEN; +pub const IMPORT_LOAD: &str = WPK_FORK_REFERENCE_IMPORT_GC_LOAD; +pub const IMPORT_BROKER_ENCODE: &str = WPK_FORK_REFERENCE_IMPORT_GC_BROKER_ENCODE; +pub const IMPORT_CAPTURE_LAYOUT: &str = WPK_FORK_REFERENCE_IMPORT_GC_CAPTURE_LAYOUT; +pub const IMPORT_PROVENANCE_BEGIN: &str = WPK_FORK_REFERENCE_IMPORT_GC_PROVENANCE_BEGIN; +pub const IMPORT_PROVENANCE_REF: &str = WPK_FORK_REFERENCE_IMPORT_GC_PROVENANCE_REF; +pub const IMPORT_PROVENANCE_END: &str = WPK_FORK_REFERENCE_IMPORT_GC_PROVENANCE_END; + +pub const EXPORT_PROBE: &str = WPK_FORK_REFERENCE_EXPORT_GC_PROBE; +pub const EXPORT_ENCODE_SLOT: &str = WPK_FORK_REFERENCE_EXPORT_GC_ENCODE_SLOT; +pub const EXPORT_ALLOCATE: &str = WPK_FORK_REFERENCE_EXPORT_GC_ALLOCATE; +pub const EXPORT_FILL: &str = WPK_FORK_REFERENCE_EXPORT_GC_FILL; +pub const EXPORT_PUBLISH_EXTERNREF: &str = WPK_FORK_REFERENCE_EXPORT_GC_PUBLISH_EXTERNREF; +pub const LOCAL_ENCODE_ANYREF: &str = "__wpk_fork_ref_encode_anyref"; +pub const LOCAL_DECODE_ANYREF: &str = "__wpk_fork_ref_decode_anyref"; +pub const LOCAL_ENCODE_EXTERNREF: &str = "__wpk_fork_ref_encode_externref"; +pub const LOCAL_DECODE_EXTERNREF: &str = "__wpk_fork_ref_decode_externref"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GcLayoutKind { + Struct, + Array, +} + +impl GcLayoutKind { + fn wire(self) -> u8 { + match self { + Self::Struct => KIND_STRUCT, + Self::Array => KIND_ARRAY, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GcConstructorKind { + Struct, + ArrayGeneric, + ArrayNew, + ArrayDefault, + ArrayFixed { len: u32 }, + ArrayData { segment_ordinal: u32 }, + ArrayElement { segment_ordinal: u32 }, +} + +impl GcConstructorKind { + fn wire(self) -> u8 { + match self { + Self::Struct => CONSTRUCTOR_STRUCT, + Self::ArrayGeneric => CONSTRUCTOR_ARRAY_GENERIC, + Self::ArrayNew => CONSTRUCTOR_ARRAY_NEW, + Self::ArrayDefault => CONSTRUCTOR_ARRAY_DEFAULT, + Self::ArrayFixed { .. } => CONSTRUCTOR_ARRAY_FIXED, + Self::ArrayData { .. } => CONSTRUCTOR_ARRAY_DATA, + Self::ArrayElement { .. } => CONSTRUCTOR_ARRAY_ELEMENT, + } + } + + fn auxiliary(self) -> u32 { + match self { + Self::Struct | Self::ArrayGeneric | Self::ArrayNew | Self::ArrayDefault => 0, + Self::ArrayFixed { len } => len, + Self::ArrayData { segment_ordinal } | Self::ArrayElement { segment_ordinal } => { + segment_ordinal + } + } + } + + fn requires_provenance(self) -> bool { + !matches!(self, Self::Struct | Self::ArrayGeneric) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct GcFieldLayout { + pub field: FieldType, + pub scalar_offset: Option, + pub reference_ordinal: Option, + pub allocation_dependency: bool, +} + +impl GcFieldLayout { + pub fn is_allocation_dependency(self) -> bool { + self.allocation_dependency + } +} + +#[derive(Debug, Clone)] +pub struct GcLayout { + pub id: u32, + pub type_id: TypeId, + pub type_ordinal: u32, + /// Base type layout accepted by `capture_layout` for this constructor. + pub base_layout_id: u32, + pub kind: GcLayoutKind, + pub constructor: GcConstructorKind, + pub scalar_len_or_stride: u32, + pub fields: Vec, + pub super_type_ordinal: Option, + pub subtype_depth: u32, + pub defaultable_shell: bool, + /// The current fields are not sufficient to allocate a safe shell. + /// + /// For immutable fields the final snapshot values are constructor inputs. + /// Mutable non-null fields can have diverged from their constructor inputs, + /// so their original seed references are retained by a weak-keyed + /// provenance record and serialized only if the aggregate reaches fork. + pub requires_provenance: bool, + /// Constructor-only scalar bytes prepended to the snapshot scalar payload. + pub provenance_scalar_len: u32, + /// Constructor-only recipe ids prepended to the snapshot edge vector. + pub provenance_reference_count: u32, +} + +#[derive(Debug, Clone)] +pub struct GcCodecPlan { + layouts: Vec, + dispatch_layouts: Vec, +} + +impl GcCodecPlan { + pub fn layouts(&self) -> &[GcLayout] { + &self.layouts + } + + /// Base layouts ordered most-specific-first for exact dynamic dispatch. + pub fn dispatch_layouts(&self) -> &[u32] { + &self.dispatch_layouts + } + + pub fn descriptor(&self) -> Vec { + encode_descriptor(self) + } +} + +#[derive(Debug, Clone, Copy)] +struct HostImports { + activation: GlobalId, + lookup: FunctionId, + claim: FunctionId, + i31: FunctionId, + define: FunctionId, + route: FunctionId, + payload_len: FunctionId, + load: FunctionId, + broker_encode: FunctionId, + capture_layout: FunctionId, + provenance_begin: FunctionId, + provenance_ref: FunctionId, + provenance_end: FunctionId, +} + +/// Stubs are declared before the exception codec so an exception payload that +/// contains an internal GC reference calls back into this module-local codec, +/// never through a typed JavaScript `anyref` import. +#[derive(Debug)] +pub struct DeclaredGcCodec { + pub encode_anyref: FunctionId, + pub decode_anyref: FunctionId, + pub encode_externref: FunctionId, + pub decode_externref: FunctionId, + pub probe: FunctionId, + pub encode_slot: FunctionId, + pub allocate: FunctionId, + pub fill: FunctionId, + pub publish_externref: FunctionId, + pub transit: TableId, + pub memory: MemoryId, + pub ptr_ty: ValType, + plan: GcCodecPlan, + imports: HostImports, + probe_args: Vec, + encode_anyref_args: Vec, + decode_anyref_args: Vec, + encode_externref_args: Vec, + decode_externref_args: Vec, + encode_slot_args: Vec, + allocate_args: Vec, + fill_args: Vec, + publish_externref_args: Vec, +} + +#[derive(Debug, Clone, Copy)] +pub struct InjectedGcCodec { + pub encode_anyref: FunctionId, + pub decode_anyref: FunctionId, + pub encode_externref: FunctionId, + pub decode_externref: FunctionId, + pub probe: FunctionId, + pub encode_slot: FunctionId, + pub allocate: FunctionId, + pub fill: FunctionId, + pub publish_externref: FunctionId, + pub transit: TableId, +} + +/// Freeze source GC types and install the versioned host surface. +/// +/// Emission is split from declaration because exception payloads and GC +/// fields can recursively refer to each other. The two codecs first exchange +/// typed local function ids and only then emit their bodies. +pub fn declare(module: &mut Module, memory: MemoryId) -> Result { + let plan = plan(module)?; + let mut source_functions: Vec<_> = module + .funcs + .iter() + .filter_map(|function| { + matches!(function.kind, FunctionKind::Local(_)).then_some(function.id()) + }) + .collect(); + source_functions.sort(); + let ptr_ty = if module.memories.get(memory).memory64 { + ValType::I64 + } else { + ValType::I32 + }; + let (transit, _) = module.add_import_table( + HOST_IMPORT_MODULE, + IMPORT_TRANSIT_TABLE, + false, + 1, + None, + RefType::ANYREF, + ); + let imports = inject_host_imports(module, ptr_ty); + inject_provenance_wrappers(module, &plan, transit, imports, &source_functions)?; + let (encode_anyref, encode_anyref_args) = add_stub( + module, + &[ValType::Ref(RefType::ANYREF)], + &[ValType::I32], + LOCAL_ENCODE_ANYREF, + ); + let (decode_anyref, decode_anyref_args) = add_stub( + module, + &[ValType::I32], + &[ValType::Ref(RefType::ANYREF)], + LOCAL_DECODE_ANYREF, + ); + let (encode_externref, encode_externref_args) = add_stub( + module, + &[ValType::Ref(RefType::EXTERNREF)], + &[ValType::I32], + LOCAL_ENCODE_EXTERNREF, + ); + let (decode_externref, decode_externref_args) = add_stub( + module, + &[ValType::I32], + &[ValType::Ref(RefType::EXTERNREF)], + LOCAL_DECODE_EXTERNREF, + ); + let (probe, probe_args) = add_stub(module, &[ValType::I32], &[ValType::I64], EXPORT_PROBE); + let (encode_slot, encode_slot_args) = + add_stub(module, &[ValType::I32], &[ValType::I32], EXPORT_ENCODE_SLOT); + let (allocate, allocate_args) = add_stub(module, &[ValType::I32], &[], EXPORT_ALLOCATE); + let (fill, fill_args) = add_stub(module, &[ValType::I32], &[], EXPORT_FILL); + let (publish_externref, publish_externref_args) = add_stub( + module, + &[ValType::I32, ValType::Ref(RefType::EXTERNREF)], + &[], + EXPORT_PUBLISH_EXTERNREF, + ); + + for (name, function) in [ + (EXPORT_PROBE, probe), + (EXPORT_ENCODE_SLOT, encode_slot), + (EXPORT_ALLOCATE, allocate), + (EXPORT_FILL, fill), + (EXPORT_PUBLISH_EXTERNREF, publish_externref), + ] { + module.exports.add(name, function); + } + replace_descriptor(module, &plan); + + Ok(DeclaredGcCodec { + encode_anyref, + decode_anyref, + encode_externref, + decode_externref, + probe, + encode_slot, + allocate, + fill, + publish_externref, + transit, + memory, + ptr_ty, + plan, + imports, + probe_args, + encode_anyref_args, + decode_anyref_args, + encode_externref_args, + decode_externref_args, + encode_slot_args, + allocate_args, + fill_args, + publish_externref_args, + }) +} + +fn inject_provenance_wrappers( + module: &mut Module, + plan: &GcCodecPlan, + transit: TableId, + imports: HostImports, + source_functions: &[FunctionId], +) -> Result<()> { + let mut struct_wrappers = HashMap::new(); + let mut array_wrappers = HashMap::new(); + for layout in plan.layouts() { + let needs_wrapper = match layout.constructor { + GcConstructorKind::Struct => layout.provenance_reference_count != 0, + GcConstructorKind::ArrayGeneric => false, + _ => true, + }; + if !needs_wrapper { + continue; + } + let wrapper = add_provenance_wrapper(module, layout, transit, imports)?; + match layout.constructor { + GcConstructorKind::Struct => { + struct_wrappers.insert(layout.type_id, wrapper); + } + constructor => { + array_wrappers.insert( + (layout.type_id, constructor.wire(), constructor.auxiliary()), + wrapper, + ); + } + } + } + + let data_ordinals: HashMap = module + .data + .iter() + .enumerate() + .map(|(ordinal, data)| (data.id(), ordinal as u32)) + .collect(); + let element_ordinals: HashMap = module + .elements + .iter() + .enumerate() + .map(|(ordinal, element)| (element.id(), ordinal as u32)) + .collect(); + struct Rewrite { + structs: HashMap, + arrays: HashMap<(TypeId, u8, u32), FunctionId>, + data_ordinals: HashMap, + element_ordinals: HashMap, + } + impl VisitorMut for Rewrite { + fn visit_instr_mut(&mut self, instr: &mut Instr, _loc: &mut InstrLocId) { + let wrapper = match instr { + Instr::StructNew(StructNew { ty }) => self.structs.get(ty).copied(), + Instr::ArrayNew(ArrayNew { ty }) => { + self.arrays.get(&(*ty, CONSTRUCTOR_ARRAY_NEW, 0)).copied() + } + Instr::ArrayNewDefault(ArrayNewDefault { ty }) => self + .arrays + .get(&(*ty, CONSTRUCTOR_ARRAY_DEFAULT, 0)) + .copied(), + Instr::ArrayNewFixed(ArrayNewFixed { ty, len }) => self + .arrays + .get(&(*ty, CONSTRUCTOR_ARRAY_FIXED, *len)) + .copied(), + Instr::ArrayNewData(ArrayNewData { ty, data }) => self + .data_ordinals + .get(data) + .and_then(|ordinal| self.arrays.get(&(*ty, CONSTRUCTOR_ARRAY_DATA, *ordinal))) + .copied(), + Instr::ArrayNewElem(ArrayNewElem { ty, elem }) => self + .element_ordinals + .get(elem) + .and_then(|ordinal| { + self.arrays.get(&(*ty, CONSTRUCTOR_ARRAY_ELEMENT, *ordinal)) + }) + .copied(), + _ => None, + }; + if let Some(wrapper) = wrapper { + *instr = Instr::Call(Call { func: wrapper }); + } + } + } + let mut rewrite = Rewrite { + structs: struct_wrappers, + arrays: array_wrappers, + data_ordinals, + element_ordinals, + }; + for &function in source_functions { + let local = local_mut(module, function); + let entry = local.entry_block(); + dfs_pre_order_mut(&mut rewrite, local, entry); + } + Ok(()) +} + +fn add_provenance_wrapper( + module: &mut Module, + layout: &GcLayout, + transit: TableId, + imports: HostImports, +) -> Result { + let params: Vec = match layout.constructor { + GcConstructorKind::Struct => layout + .fields + .iter() + .map(|field| field.field.element_type.unpack()) + .collect(), + GcConstructorKind::ArrayNew => { + vec![layout.fields[0].field.element_type.unpack(), ValType::I32] + } + GcConstructorKind::ArrayDefault + | GcConstructorKind::ArrayData { .. } + | GcConstructorKind::ArrayElement { .. } => vec![ValType::I32, ValType::I32], + GcConstructorKind::ArrayFixed { len } => { + vec![layout.fields[0].field.element_type.unpack(); len as usize] + } + GcConstructorKind::ArrayGeneric => unreachable!( + "generic GC array layouts never receive provenance wrappers" + ), + }; + // `array.new_default` has only its dynamic length operand. + let params = if matches!(layout.constructor, GcConstructorKind::ArrayDefault) { + vec![ValType::I32] + } else { + params + }; + let result_ty = ValType::Ref(RefType { + nullable: false, + heap_type: HeapType::Concrete(layout.type_id), + }); + let name = format!("__wpk_fork_ref_gc_construct_{}", layout.id); + let (wrapper, args) = add_stub(module, ¶ms, &[result_ty], &name); + let result = module.locals.add(ValType::Ref(RefType { + nullable: true, + heap_type: HeapType::Concrete(layout.type_id), + })); + let token = module.locals.add(ValType::I32); + let constructor = match layout.constructor { + GcConstructorKind::Struct => Instr::StructNew(StructNew { ty: layout.type_id }), + GcConstructorKind::ArrayNew => Instr::ArrayNew(ArrayNew { ty: layout.type_id }), + GcConstructorKind::ArrayDefault => { + Instr::ArrayNewDefault(ArrayNewDefault { ty: layout.type_id }) + } + GcConstructorKind::ArrayFixed { len } => Instr::ArrayNewFixed(ArrayNewFixed { + ty: layout.type_id, + len, + }), + GcConstructorKind::ArrayData { segment_ordinal } => { + let data = module + .data + .iter() + .nth(segment_ordinal as usize) + .ok_or_else(|| anyhow::anyhow!("GC provenance data segment disappeared"))? + .id(); + Instr::ArrayNewData(ArrayNewData { + ty: layout.type_id, + data, + }) + } + GcConstructorKind::ArrayElement { segment_ordinal } => { + let elem = module + .elements + .iter() + .nth(segment_ordinal as usize) + .ok_or_else(|| anyhow::anyhow!("GC provenance element segment disappeared"))? + .id(); + Instr::ArrayNewElem(ArrayNewElem { + ty: layout.type_id, + elem, + }) + } + GcConstructorKind::ArrayGeneric => unreachable!(), + }; + let entry = entry(wrapper, module); + { + let instrs = instrs_mut(module, wrapper, entry); + for &arg in &args { + local_get(instrs, arg); + } + push(instrs, constructor); + local_set(instrs, result); + + constant_i32(instrs, 0); + local_get(instrs, result); + push(instrs, Instr::TableSet(TableSet { table: transit })); + constant_i32(instrs, 0); + push( + instrs, + Instr::GlobalGet(GlobalGet { + global: imports.activation, + }), + ); + constant_i32(instrs, layout.base_layout_id as i32); + constant_i32(instrs, layout.id as i32); + emit_provenance_scalars(instrs, layout, &args); + constant_i32(instrs, layout.provenance_reference_count as i32); + call(instrs, imports.provenance_begin); + local_set(instrs, token); + clear_transit_slot(instrs, transit, 0); + } + + let reference_args = provenance_reference_args(module, layout); + for (ordinal, arg) in reference_args.into_iter().enumerate() { + let instrs = instrs_mut(module, wrapper, entry); + constant_i32(instrs, 0); + local_get(instrs, args[arg]); + push(instrs, Instr::TableSet(TableSet { table: transit })); + local_get(instrs, token); + constant_i32(instrs, ordinal as i32); + constant_i32(instrs, 0); + call(instrs, imports.provenance_ref); + clear_transit_slot(instrs, transit, 0); + } + let instrs = instrs_mut(module, wrapper, entry); + local_get(instrs, token); + call(instrs, imports.provenance_end); + local_get(instrs, result); + push(instrs, Instr::RefAsNonNull(RefAsNonNull {})); + Ok(wrapper) +} + +fn provenance_reference_args(module: &Module, layout: &GcLayout) -> Vec { + match layout.constructor { + GcConstructorKind::Struct => layout + .fields + .iter() + .enumerate() + .filter_map(|(index, field)| match field.field.element_type { + StorageType::Val(ValType::Ref(reference)) + if field.field.mutable + && !reference.nullable + && is_internal_gc_reference(module, reference) => + { + Some(index) + } + _ => None, + }) + .collect(), + GcConstructorKind::ArrayNew => match layout.fields[0].field.element_type { + StorageType::Val(ValType::Ref(reference)) + if is_internal_gc_reference(module, reference) => + { + vec![0] + } + _ => Vec::new(), + }, + GcConstructorKind::ArrayFixed { len } => match layout.fields[0].field.element_type { + StorageType::Val(ValType::Ref(reference)) + if layout.fields[0].field.mutable + && !reference.nullable + && is_internal_gc_reference(module, reference) => + { + (0..len as usize).collect() + } + _ => Vec::new(), + }, + _ => Vec::new(), + } +} + +fn emit_provenance_scalars( + instrs: &mut Vec<(Instr, InstrLocId)>, + layout: &GcLayout, + args: &[LocalId], +) { + match layout.constructor { + GcConstructorKind::ArrayNew => match layout.fields[0].field.element_type { + StorageType::Val(ValType::Ref(_)) => { + constant_i64(instrs, 0); + constant_i64(instrs, 0); + } + storage => { + emit_scalar_as_i64_pair(instrs, storage, args[0]); + } + }, + GcConstructorKind::ArrayData { .. } | GcConstructorKind::ArrayElement { .. } => { + // WHY: the wire record owns exactly eight provenance bytes for the + // two i32 constructor operands. Pack both into scalarLo; the host + // intentionally truncates scalarLo/scalarHi to that declared + // length and would otherwise discard the second operand. + local_get(instrs, args[0]); + push( + instrs, + Instr::Unop(Unop { + op: UnaryOp::I64ExtendUI32, + }), + ); + local_get(instrs, args[1]); + push( + instrs, + Instr::Unop(Unop { + op: UnaryOp::I64ExtendUI32, + }), + ); + constant_i64(instrs, 32); + binop(instrs, BinaryOp::I64Shl); + binop(instrs, BinaryOp::I64Or); + constant_i64(instrs, 0); + } + _ => { + constant_i64(instrs, 0); + constant_i64(instrs, 0); + } + } +} + +fn emit_scalar_as_i64_pair( + instrs: &mut Vec<(Instr, InstrLocId)>, + storage: StorageType, + value: LocalId, +) { + match storage { + StorageType::I8 | StorageType::I16 | StorageType::Val(ValType::I32) => { + local_get(instrs, value); + push( + instrs, + Instr::Unop(Unop { + op: UnaryOp::I64ExtendUI32, + }), + ); + constant_i64(instrs, 0); + } + StorageType::Val(ValType::I64) => { + local_get(instrs, value); + constant_i64(instrs, 0); + } + StorageType::Val(ValType::F32) => { + local_get(instrs, value); + push( + instrs, + Instr::Unop(Unop { + op: UnaryOp::I32ReinterpretF32, + }), + ); + push( + instrs, + Instr::Unop(Unop { + op: UnaryOp::I64ExtendUI32, + }), + ); + constant_i64(instrs, 0); + } + StorageType::Val(ValType::F64) => { + local_get(instrs, value); + push( + instrs, + Instr::Unop(Unop { + op: UnaryOp::I64ReinterpretF64, + }), + ); + constant_i64(instrs, 0); + } + StorageType::Val(ValType::V128) => { + local_get(instrs, value); + push( + instrs, + Instr::Unop(Unop { + op: UnaryOp::I64x2ExtractLane { idx: 0 }, + }), + ); + local_get(instrs, value); + push( + instrs, + Instr::Unop(Unop { + op: UnaryOp::I64x2ExtractLane { idx: 1 }, + }), + ); + } + StorageType::Val(ValType::Ref(_)) => unreachable!(), + } +} + +/// Emit the type-test probe. The remaining functions are emitted once their +/// recursive reference codec dependencies have been declared. +pub fn emit_probe(module: &mut Module, codec: &DeclaredGcCodec) { + let value = module.locals.add(ValType::Ref(RefType::ANYREF)); + let entry = entry(codec.probe, module); + { + let instrs = instrs_mut(module, codec.probe, entry); + local_get(instrs, codec.probe_args[0]); + push( + instrs, + Instr::TableGet(TableGet { + table: codec.transit, + }), + ); + local_set(instrs, value); + } + + for &layout_id in codec.plan.dispatch_layouts() { + let layout = &codec.plan.layouts()[(layout_id - 1) as usize]; + let yes = dangling(module, codec.probe, walrus::ir::InstrSeqType::Simple(None)); + { + let instrs = instrs_mut(module, codec.probe, yes); + let packed = (u64::from(layout.type_ordinal) << 32) | u64::from(layout.id); + push( + instrs, + Instr::Const(Const { + value: Value::I64(packed as i64), + }), + ); + push(instrs, Instr::Return(Return {})); + } + let no = dangling(module, codec.probe, walrus::ir::InstrSeqType::Simple(None)); + let instrs = instrs_mut(module, codec.probe, entry); + local_get(instrs, value); + push( + instrs, + Instr::RefTest(RefTest { + nullable: false, + heap_type: HeapType::Concrete(layout.type_id), + }), + ); + push( + instrs, + Instr::IfElse(IfElse { + consequent: yes, + alternative: no, + }), + ); + } + let instrs = instrs_mut(module, codec.probe, entry); + push( + instrs, + Instr::Const(Const { + value: Value::I64(0), + }), + ); +} + +pub fn finish_declaration( + module: &mut Module, + codec: DeclaredGcCodec, + exception: module_exception_codec::InjectedExceptionCodec, + runtime: &runtime::Runtime, +) -> Result { + let deps = emit_dependencies(module, exception, runtime)?; + let seeds = ReferenceSeeds::inject(module, &codec.plan); + emit_probe(module, &codec); + emit_encode_anyref(module, &codec, deps); + emit_decode_anyref(module, &codec); + emit_externref_bridge(module, &codec); + emit_encode_slot(module, &codec); + emit_publish_externref(module, &codec); + // Allocation/fill are deliberately separate. Mutable/defaultable shells + // are allocated for the entire graph before any edge is filled; immutable + // and non-defaultable layouts are constructed in dependency order. + emit_allocate(module, &codec, deps, &seeds)?; + emit_fill(module, &codec, deps)?; + Ok(InjectedGcCodec { + encode_anyref: codec.encode_anyref, + decode_anyref: codec.decode_anyref, + encode_externref: codec.encode_externref, + decode_externref: codec.decode_externref, + probe: codec.probe, + encode_slot: codec.encode_slot, + allocate: codec.allocate, + fill: codec.fill, + publish_externref: codec.publish_externref, + transit: codec.transit, + }) +} + +#[derive(Debug, Clone, Copy)] +struct EmitDependencies { + activation: GlobalId, + codecs: runtime::ReferenceCodecs, + vector_begin: FunctionId, + vector_append: FunctionId, + vector_finish: FunctionId, + vector_get: FunctionId, + scratch_reserve: FunctionId, + scratch_release: FunctionId, +} + +#[derive(Debug)] +struct ReferenceSeeds { + abstract_func: FunctionId, + concrete_funcs: HashMap, + exn: FunctionId, +} + +impl ReferenceSeeds { + fn inject(module: &mut Module, plan: &GcCodecPlan) -> Self { + let abstract_func = add_trapping_function(module, &[], &[], "__wpk_fork_ref_seed_func"); + let mut concrete_funcs = HashMap::new(); + for layout in plan.layouts() { + for field in &layout.fields { + let StorageType::Val(ValType::Ref(reference)) = field.field.element_type else { + continue; + }; + let ty = match reference.heap_type { + HeapType::Concrete(ty) | HeapType::Exact(ty) + if module.types.get(ty).is_function() => + { + ty + } + _ => continue, + }; + concrete_funcs.entry(ty).or_insert_with(|| { + let signature = module.types.get(ty); + let params = signature.params().to_vec(); + let results = signature.results().to_vec(); + add_trapping_function( + module, + ¶ms, + &results, + &format!("__wpk_fork_ref_seed_func_{}", ty.index()), + ) + }); + } + } + let exn = add_seed_exception_function(module); + Self { + abstract_func, + concrete_funcs, + exn, + } + } +} + +fn add_trapping_function( + module: &mut Module, + params: &[ValType], + results: &[ValType], + name: &str, +) -> FunctionId { + let (function, _) = add_stub(module, params, results, name); + push( + instrs_mut(module, function, entry(function, module)), + Instr::Unreachable(Unreachable {}), + ); + function +} + +fn add_seed_exception_function(module: &mut Module) -> FunctionId { + let tag_ty = module.types.add(&[], &[]); + let tag = module.tags.add(tag_ty); + module.tags.get_mut(tag).name = Some("__wpk_fork_ref_seed_tag".into()); + let result_ty = ValType::Ref(RefType { + nullable: false, + heap_type: HeapType::Abstract(AbstractHeapType::Exn), + }); + let (function, _) = add_stub(module, &[], &[result_ty], "__wpk_fork_ref_seed_exn"); + let capture = dangling( + module, + function, + walrus::ir::InstrSeqType::Simple(Some(result_ty)), + ); + let body = dangling(module, function, walrus::ir::InstrSeqType::Simple(None)); + push( + instrs_mut(module, function, body), + Instr::Throw(Throw { tag }), + ); + { + let instrs = instrs_mut(module, function, capture); + push( + instrs, + Instr::TryTable(TryTable { + seq: body, + catches: vec![TryTableCatch::CatchAllRef { label: capture }], + }), + ); + push(instrs, Instr::Unreachable(Unreachable {})); + } + push( + instrs_mut(module, function, entry(function, module)), + Instr::Block(walrus::ir::Block { seq: capture }), + ); + function +} + +fn emit_dependencies( + module: &Module, + exception: module_exception_codec::InjectedExceptionCodec, + runtime: &runtime::Runtime, +) -> Result { + let activation = find_import_global( + module, + module_exception_codec::HOST_IMPORT_MODULE, + module_exception_codec::IMPORT_ACTIVATION, + )?; + let mut codecs = runtime + .reference_codecs + .ok_or_else(|| anyhow::anyhow!("GC codec requires linked reference codecs"))?; + // The exception codec is the exact local owner even if a caller assembled + // runtime overrides differently. + codecs.encode_exnref = exception.encode; + codecs.decode_exnref = exception.decode; + let vector_begin = runtime + .reference_vector_begin + .ok_or_else(|| anyhow::anyhow!("GC codec requires reference-vector begin"))?; + let vector_append = runtime + .reference_vector_append + .ok_or_else(|| anyhow::anyhow!("GC codec requires reference-vector append"))?; + let vector_finish = runtime + .reference_vector_finish + .ok_or_else(|| anyhow::anyhow!("GC codec requires reference-vector finish"))?; + let vector_get = runtime + .reference_vector_get + .ok_or_else(|| anyhow::anyhow!("GC codec requires reference-vector get"))?; + Ok(EmitDependencies { + activation, + codecs, + vector_begin, + vector_append, + vector_finish, + vector_get, + scratch_reserve: find_import_function( + module, + module_exception_codec::HOST_IMPORT_MODULE, + module_exception_codec::IMPORT_SCRATCH_RESERVE, + )?, + scratch_release: find_import_function( + module, + module_exception_codec::HOST_IMPORT_MODULE, + module_exception_codec::IMPORT_SCRATCH_RELEASE, + )?, + }) +} + +fn find_import_global(module: &Module, import_module: &str, name: &str) -> Result { + module + .imports + .iter() + .find_map(|import| { + (import.module == import_module && import.name == name) + .then_some(&import.kind) + .and_then(|kind| match kind { + ImportKind::Global(global) => Some(*global), + _ => None, + }) + }) + .ok_or_else(|| anyhow::anyhow!("missing generated import `{import_module}.{name}`")) +} + +fn find_import_function(module: &Module, import_module: &str, name: &str) -> Result { + module + .imports + .iter() + .find_map(|import| { + (import.module == import_module && import.name == name) + .then_some(&import.kind) + .and_then(|kind| match kind { + ImportKind::Function(function) => Some(*function), + _ => None, + }) + }) + .ok_or_else(|| anyhow::anyhow!("missing generated import `{import_module}.{name}`")) +} + +fn emit_decode_anyref(module: &mut Module, codec: &DeclaredGcCodec) { + let recipe = codec.decode_anyref_args[0]; + let null = dangling( + module, + codec.decode_anyref, + walrus::ir::InstrSeqType::Simple(None), + ); + { + let instrs = instrs_mut(module, codec.decode_anyref, null); + push( + instrs, + Instr::RefNull(RefNull { + ty: RefType::ANYREF, + }), + ); + push(instrs, Instr::Return(Return {})); + } + let nonnull = dangling( + module, + codec.decode_anyref, + walrus::ir::InstrSeqType::Simple(None), + ); + let entry = entry(codec.decode_anyref, module); + let instrs = instrs_mut(module, codec.decode_anyref, entry); + local_get(instrs, recipe); + push( + instrs, + Instr::Unop(Unop { + op: UnaryOp::I32Eqz, + }), + ); + push( + instrs, + Instr::IfElse(IfElse { + consequent: null, + alternative: nonnull, + }), + ); + local_get(instrs, recipe); + constant_i32(instrs, 1); + binop(instrs, BinaryOp::I32Add); + push( + instrs, + Instr::TableGet(TableGet { + table: codec.transit, + }), + ); +} + +fn emit_externref_bridge(module: &mut Module, codec: &DeclaredGcCodec) { + { + let entry = entry(codec.encode_externref, module); + let instrs = instrs_mut(module, codec.encode_externref, entry); + local_get(instrs, codec.encode_externref_args[0]); + // WHY: extern.convert_any may have exposed a module-local GC identity + // as externref. Convert it back inside Wasm before classification so + // the ordinary typed graph owns it instead of an opaque host handle. + push(instrs, Instr::AnyConvertExtern(AnyConvertExtern {})); + call(instrs, codec.encode_anyref); + } + { + let entry = entry(codec.decode_externref, module); + let instrs = instrs_mut(module, codec.decode_externref, entry); + local_get(instrs, codec.decode_externref_args[0]); + call(instrs, codec.decode_anyref); + push(instrs, Instr::ExternConvertAny(ExternConvertAny {})); + } +} + +fn emit_encode_slot(module: &mut Module, codec: &DeclaredGcCodec) { + let entry = entry(codec.encode_slot, module); + let instrs = instrs_mut(module, codec.encode_slot, entry); + local_get(instrs, codec.encode_slot_args[0]); + push( + instrs, + Instr::TableGet(TableGet { + table: codec.transit, + }), + ); + call(instrs, codec.encode_anyref); +} + +fn emit_publish_externref(module: &mut Module, codec: &DeclaredGcCodec) { + let entry = entry(codec.publish_externref, module); + let instrs = instrs_mut(module, codec.publish_externref, entry); + local_get(instrs, codec.publish_externref_args[0]); + constant_i32(instrs, 1); + binop(instrs, BinaryOp::I32Add); + local_get(instrs, codec.publish_externref_args[1]); + // WHY: JavaScript cannot directly manufacture an `anyref` transit value. + // The process owner supplies only its canonical externref token; this + // module-local conversion creates the child-side host reference consumed + // by the same anyref decoder used for GC graph edges. + push(instrs, Instr::AnyConvertExtern(AnyConvertExtern {})); + push( + instrs, + Instr::TableSet(TableSet { + table: codec.transit, + }), + ); +} + +fn emit_encode_anyref(module: &mut Module, codec: &DeclaredGcCodec, deps: EmitDependencies) { + let value = codec.encode_anyref_args[0]; + let recipe = module.locals.add(ValType::I32); + let selected_layout = module.locals.add(ValType::I32); + + let null = dangling( + module, + codec.encode_anyref, + walrus::ir::InstrSeqType::Simple(None), + ); + { + let instrs = instrs_mut(module, codec.encode_anyref, null); + constant_i32(instrs, 0); + push(instrs, Instr::Return(Return {})); + } + let nonnull = dangling( + module, + codec.encode_anyref, + walrus::ir::InstrSeqType::Simple(None), + ); + + let i31 = dangling( + module, + codec.encode_anyref, + walrus::ir::InstrSeqType::Simple(None), + ); + { + let instrs = instrs_mut(module, codec.encode_anyref, i31); + local_get(instrs, value); + push( + instrs, + Instr::RefCast(RefCast { + nullable: false, + heap_type: HeapType::Abstract(AbstractHeapType::I31), + }), + ); + push(instrs, Instr::I31GetS(walrus::ir::I31GetS {})); + call(instrs, codec.imports.i31); + local_set(instrs, recipe); + // Parent replay reads the same process-owned transit table as child + // replay. Publish i31 identity here because JavaScript receives only + // its scalar payload and cannot manufacture an `i31ref`. + local_get(instrs, recipe); + constant_i32(instrs, 1); + binop(instrs, BinaryOp::I32Add); + local_get(instrs, value); + push( + instrs, + Instr::TableSet(TableSet { + table: codec.transit, + }), + ); + local_get(instrs, recipe); + push(instrs, Instr::Return(Return {})); + } + let not_i31 = dangling( + module, + codec.encode_anyref, + walrus::ir::InstrSeqType::Simple(None), + ); + + let existing = dangling( + module, + codec.encode_anyref, + walrus::ir::InstrSeqType::Simple(None), + ); + { + let instrs = instrs_mut(module, codec.encode_anyref, existing); + clear_transit_slot(instrs, codec.transit, 0); + local_get(instrs, recipe); + push(instrs, Instr::Return(Return {})); + } + let fresh = dangling( + module, + codec.encode_anyref, + walrus::ir::InstrSeqType::Simple(None), + ); + + let entry = entry(codec.encode_anyref, module); + { + let instrs = instrs_mut(module, codec.encode_anyref, entry); + local_get(instrs, value); + push(instrs, Instr::RefIsNull(RefIsNull {})); + push( + instrs, + Instr::IfElse(IfElse { + consequent: null, + alternative: nonnull, + }), + ); + local_get(instrs, value); + push( + instrs, + Instr::RefTest(RefTest { + nullable: false, + heap_type: HeapType::Abstract(AbstractHeapType::I31), + }), + ); + push( + instrs, + Instr::IfElse(IfElse { + consequent: i31, + alternative: not_i31, + }), + ); + constant_i32(instrs, 0); + local_get(instrs, value); + push( + instrs, + Instr::TableSet(TableSet { + table: codec.transit, + }), + ); + constant_i32(instrs, 0); + call(instrs, codec.imports.lookup); + push(instrs, Instr::LocalTee(LocalTee { local: recipe })); + push( + instrs, + Instr::IfElse(IfElse { + consequent: existing, + alternative: fresh, + }), + ); + } + + for &layout_id in codec.plan.dispatch_layouts() { + let layout = codec.plan.layouts()[(layout_id - 1) as usize].clone(); + let yes = dangling( + module, + codec.encode_anyref, + walrus::ir::InstrSeqType::Simple(None), + ); + emit_encode_layout( + module, + codec, + deps, + yes, + &layout, + value, + recipe, + selected_layout, + ); + let no = dangling( + module, + codec.encode_anyref, + walrus::ir::InstrSeqType::Simple(None), + ); + let instrs = instrs_mut(module, codec.encode_anyref, entry); + local_get(instrs, value); + push( + instrs, + Instr::RefTest(RefTest { + nullable: false, + heap_type: HeapType::Concrete(layout.type_id), + }), + ); + push( + instrs, + Instr::IfElse(IfElse { + consequent: yes, + alternative: no, + }), + ); + } + + let instrs = instrs_mut(module, codec.encode_anyref, entry); + // A structurally canonical GC value may have entered through another + // module activation. The broker probes registered module-local codecs and + // routes the shared transit slot without exposing `anyref` to JavaScript. + constant_i32(instrs, 0); + call(instrs, codec.imports.broker_encode); + local_set(instrs, recipe); + // `broker_encode` grows the shared table through recipe+1. Publish the + // original internal identity before clearing slot zero so parent replay + // and a later anyref/externref alias both use the canonical recipe. + local_get(instrs, recipe); + constant_i32(instrs, 1); + binop(instrs, BinaryOp::I32Add); + local_get(instrs, value); + push( + instrs, + Instr::TableSet(TableSet { + table: codec.transit, + }), + ); + clear_transit_slot(instrs, codec.transit, 0); + local_get(instrs, recipe); +} + +#[allow(clippy::too_many_arguments)] +fn emit_encode_layout( + module: &mut Module, + codec: &DeclaredGcCodec, + deps: EmitDependencies, + seq: walrus::ir::InstrSeqId, + layout: &GcLayout, + value: LocalId, + recipe: LocalId, + selected_layout: LocalId, +) { + let concrete = RefType { + nullable: true, + heap_type: HeapType::Concrete(layout.type_id), + }; + let typed = module.locals.add(ValType::Ref(concrete)); + let staging = module.locals.add(codec.ptr_ty); + let scalar_len = module.locals.add(ValType::I32); + let vector = module.locals.add(ValType::I32); + let array_len = (layout.kind == GcLayoutKind::Array).then(|| module.locals.add(ValType::I32)); + + { + let instrs = instrs_mut(module, codec.encode_anyref, seq); + constant_i32(instrs, 0); + call(instrs, codec.imports.claim); + local_set(instrs, recipe); + + // Claim grows the process-owned transit table through recipe+1 before + // returning. Publishing the source identity before recursive fields + // is what makes aliases and cycles terminate deterministically. + local_get(instrs, recipe); + constant_i32(instrs, 1); + binop(instrs, BinaryOp::I32Add); + local_get(instrs, value); + push( + instrs, + Instr::TableSet(TableSet { + table: codec.transit, + }), + ); + + constant_i32(instrs, 0); + push( + instrs, + Instr::GlobalGet(GlobalGet { + global: deps.activation, + }), + ); + constant_i32(instrs, layout.id as i32); + call(instrs, codec.imports.capture_layout); + local_set(instrs, selected_layout); + + local_get(instrs, value); + push( + instrs, + Instr::RefCast(RefCast { + nullable: true, + heap_type: HeapType::Concrete(layout.type_id), + }), + ); + local_set(instrs, typed); + clear_transit_slot(instrs, codec.transit, 0); + } + + match layout.kind { + GcLayoutKind::Struct => emit_encode_struct_payload( + module, + codec, + deps, + seq, + layout, + typed, + recipe, + selected_layout, + staging, + scalar_len, + vector, + ), + GcLayoutKind::Array => emit_encode_array_payload( + module, + codec, + deps, + seq, + layout, + typed, + recipe, + selected_layout, + staging, + scalar_len, + vector, + array_len.expect("array length local"), + ), + } + let instrs = instrs_mut(module, codec.encode_anyref, seq); + local_get(instrs, recipe); + push(instrs, Instr::Return(Return {})); +} + +#[allow(clippy::too_many_arguments)] +fn emit_encode_struct_payload( + module: &mut Module, + codec: &DeclaredGcCodec, + deps: EmitDependencies, + seq: walrus::ir::InstrSeqId, + layout: &GcLayout, + typed: LocalId, + recipe: LocalId, + selected_layout: LocalId, + staging: LocalId, + scalar_len: LocalId, + vector: LocalId, +) { + let reference_count = layout + .fields + .iter() + .filter(|field| field.reference_ordinal.is_some()) + .count() as u32; + let reservation_len = layout.scalar_len_or_stride.max(1); + let encoders: Vec<_> = layout + .fields + .iter() + .map(|field| match field.field.element_type { + StorageType::Val(ValType::Ref(reference)) => { + Some(reference_encoder(module, deps.codecs, reference)) + } + _ => None, + }) + .collect(); + + let instrs = instrs_mut(module, codec.encode_anyref, seq); + constant_i32(instrs, layout.scalar_len_or_stride as i32); + local_set(instrs, scalar_len); + constant_ptr(instrs, codec.ptr_ty, u64::from(reservation_len)); + call(instrs, deps.scratch_reserve); + local_set(instrs, staging); + if reference_count == 0 { + constant_i32(instrs, 0); + } else { + constant_i32(instrs, reference_count as i32); + call(instrs, deps.vector_begin); + } + local_set(instrs, vector); + + for (index, (field, encoder)) in layout.fields.iter().zip(encoders).enumerate() { + if let Some(offset) = field.scalar_offset { + local_get(instrs, staging); + local_get(instrs, typed); + emit_struct_get( + instrs, + layout.type_id, + index as u32, + field.field.element_type, + ); + push( + instrs, + Instr::Store(Store { + memory: codec.memory, + kind: scalar_store(field.field.element_type), + arg: MemArg { + align: 1, + offset: u64::from(offset), + }, + }), + ); + } else { + local_get(instrs, vector); + local_get(instrs, typed); + emit_struct_get( + instrs, + layout.type_id, + index as u32, + field.field.element_type, + ); + call(instrs, encoder.expect("reference struct field encoder")); + call(instrs, deps.vector_append); + } + } + if reference_count != 0 { + // WHY: vector_begin returns a transaction-local builder handle. Only + // finish publishes a canonical wire ordinal suitable for durable GC + // recipes and deduplicates identical recursive activation vectors. + local_get(instrs, vector); + call(instrs, deps.vector_finish); + local_set(instrs, vector); + } + emit_define( + instrs, + codec, + deps, + recipe, + selected_layout, + layout, + KIND_STRUCT, + staging, + scalar_len, + vector, + ); + local_get(instrs, staging); + constant_ptr(instrs, codec.ptr_ty, u64::from(reservation_len)); + call(instrs, deps.scratch_release); +} + +#[allow(clippy::too_many_arguments)] +fn emit_encode_array_payload( + module: &mut Module, + codec: &DeclaredGcCodec, + deps: EmitDependencies, + seq: walrus::ir::InstrSeqId, + layout: &GcLayout, + typed: LocalId, + recipe: LocalId, + selected_layout: LocalId, + staging: LocalId, + scalar_len: LocalId, + vector: LocalId, + array_len: LocalId, +) { + let index = module.locals.add(ValType::I32); + let field = layout.fields[0]; + let reference = match field.field.element_type { + StorageType::Val(ValType::Ref(reference)) => Some(reference), + _ => None, + }; + + { + let instrs = instrs_mut(module, codec.encode_anyref, seq); + local_get(instrs, typed); + push(instrs, Instr::ArrayLen(ArrayLen {})); + local_set(instrs, array_len); + emit_array_scalar_len( + module, + codec, + seq, + field.field.element_type, + layout.scalar_len_or_stride, + array_len, + scalar_len, + ); + let instrs = instrs_mut(module, codec.encode_anyref, seq); + local_get(instrs, scalar_len); + emit_i32_to_ptr(instrs, codec.ptr_ty); + call(instrs, deps.scratch_reserve); + local_set(instrs, staging); + local_get(instrs, staging); + local_get(instrs, array_len); + push( + instrs, + Instr::Store(Store { + memory: codec.memory, + kind: StoreKind::I32 { atomic: false }, + arg: MemArg { + align: 1, + offset: 0, + }, + }), + ); + } + + if reference.is_some() { + let yes = dangling( + module, + codec.encode_anyref, + walrus::ir::InstrSeqType::Simple(Some(ValType::I32)), + ); + { + let instrs = instrs_mut(module, codec.encode_anyref, yes); + local_get(instrs, array_len); + call(instrs, deps.vector_begin); + } + let no = dangling( + module, + codec.encode_anyref, + walrus::ir::InstrSeqType::Simple(Some(ValType::I32)), + ); + constant_i32(instrs_mut(module, codec.encode_anyref, no), 0); + let instrs = instrs_mut(module, codec.encode_anyref, seq); + local_get(instrs, array_len); + push( + instrs, + Instr::IfElse(IfElse { + consequent: yes, + alternative: no, + }), + ); + } else { + constant_i32(instrs_mut(module, codec.encode_anyref, seq), 0); + } + local_set(instrs_mut(module, codec.encode_anyref, seq), vector); + constant_i32(instrs_mut(module, codec.encode_anyref, seq), 0); + local_set(instrs_mut(module, codec.encode_anyref, seq), index); + + let outer = dangling( + module, + codec.encode_anyref, + walrus::ir::InstrSeqType::Simple(None), + ); + let body = dangling( + module, + codec.encode_anyref, + walrus::ir::InstrSeqType::Simple(None), + ); + let reference_encoder = + reference.map(|reference| reference_encoder(module, deps.codecs, reference)); + { + let instrs = instrs_mut(module, codec.encode_anyref, body); + local_get(instrs, index); + local_get(instrs, array_len); + binop(instrs, BinaryOp::I32GeU); + push(instrs, Instr::BrIf(BrIf { block: outer })); + if reference.is_some() { + local_get(instrs, vector); + local_get(instrs, typed); + local_get(instrs, index); + push(instrs, Instr::ArrayGet(ArrayGet { ty: layout.type_id })); + call( + instrs, + reference_encoder.expect("reference array element encoder"), + ); + call(instrs, deps.vector_append); + } else { + emit_array_scalar_address( + instrs, + staging, + codec.ptr_ty, + index, + layout.scalar_len_or_stride, + ); + local_get(instrs, typed); + local_get(instrs, index); + emit_array_get(instrs, layout.type_id, field.field.element_type); + push( + instrs, + Instr::Store(Store { + memory: codec.memory, + kind: scalar_store(field.field.element_type), + arg: MemArg { + align: 1, + offset: 0, + }, + }), + ); + } + local_get(instrs, index); + constant_i32(instrs, 1); + binop(instrs, BinaryOp::I32Add); + local_set(instrs, index); + push(instrs, Instr::Br(Br { block: body })); + } + push( + instrs_mut(module, codec.encode_anyref, outer), + Instr::Loop(Loop { seq: body }), + ); + push( + instrs_mut(module, codec.encode_anyref, seq), + Instr::Block(walrus::ir::Block { seq: outer }), + ); + + if reference.is_some() { + let finish = dangling( + module, + codec.encode_anyref, + walrus::ir::InstrSeqType::Simple(None), + ); + { + let instrs = instrs_mut(module, codec.encode_anyref, finish); + local_get(instrs, vector); + call(instrs, deps.vector_finish); + local_set(instrs, vector); + } + let empty = dangling( + module, + codec.encode_anyref, + walrus::ir::InstrSeqType::Simple(None), + ); + let instrs = instrs_mut(module, codec.encode_anyref, seq); + local_get(instrs, array_len); + push( + instrs, + Instr::IfElse(IfElse { + consequent: finish, + alternative: empty, + }), + ); + } + + let instrs = instrs_mut(module, codec.encode_anyref, seq); + emit_define( + instrs, + codec, + deps, + recipe, + selected_layout, + layout, + KIND_ARRAY, + staging, + scalar_len, + vector, + ); + local_get(instrs, staging); + local_get(instrs, scalar_len); + emit_i32_to_ptr(instrs, codec.ptr_ty); + call(instrs, deps.scratch_release); +} + +#[allow(clippy::too_many_arguments)] +fn emit_define( + instrs: &mut Vec<(Instr, InstrLocId)>, + codec: &DeclaredGcCodec, + deps: EmitDependencies, + recipe: LocalId, + selected_layout: LocalId, + layout: &GcLayout, + kind: u8, + staging: LocalId, + scalar_len: LocalId, + vector: LocalId, +) { + local_get(instrs, recipe); + push( + instrs, + Instr::GlobalGet(GlobalGet { + global: deps.activation, + }), + ); + constant_i32(instrs, layout.type_ordinal as i32); + local_get(instrs, selected_layout); + constant_i32(instrs, i32::from(kind)); + local_get(instrs, staging); + local_get(instrs, scalar_len); + local_get(instrs, vector); + call(instrs, codec.imports.define); +} + +fn reference_encoder( + module: &Module, + codecs: runtime::ReferenceCodecs, + reference: RefType, +) -> FunctionId { + runtime::ReferenceCodecClass::of(module, reference).encoder(codecs) +} + +fn emit_struct_get( + instrs: &mut Vec<(Instr, InstrLocId)>, + ty: TypeId, + field: u32, + storage: StorageType, +) { + match storage { + StorageType::I8 | StorageType::I16 => { + push(instrs, Instr::StructGetU(StructGetU { ty, field })) + } + StorageType::Val(_) => push(instrs, Instr::StructGet(StructGet { ty, field })), + } +} + +fn emit_array_get(instrs: &mut Vec<(Instr, InstrLocId)>, ty: TypeId, storage: StorageType) { + match storage { + StorageType::I8 | StorageType::I16 => { + push(instrs, Instr::ArrayGetU(walrus::ir::ArrayGetU { ty })) + } + StorageType::Val(_) => push(instrs, Instr::ArrayGet(ArrayGet { ty })), + } +} + +fn scalar_store(storage: StorageType) -> StoreKind { + match storage { + StorageType::I8 => StoreKind::I32_8 { atomic: false }, + StorageType::I16 => StoreKind::I32_16 { atomic: false }, + StorageType::Val(ValType::I32) => StoreKind::I32 { atomic: false }, + StorageType::Val(ValType::I64) => StoreKind::I64 { atomic: false }, + StorageType::Val(ValType::F32) => StoreKind::F32, + StorageType::Val(ValType::F64) => StoreKind::F64, + StorageType::Val(ValType::V128) => StoreKind::V128, + StorageType::Val(ValType::Ref(_)) => unreachable!("reference field uses recipe vector"), + } +} + +fn emit_array_scalar_len( + module: &mut Module, + codec: &DeclaredGcCodec, + seq: walrus::ir::InstrSeqId, + storage: StorageType, + stride: u32, + length: LocalId, + destination: LocalId, +) { + if matches!(storage, StorageType::Val(ValType::Ref(_))) { + let instrs = instrs_mut(module, codec.encode_anyref, seq); + constant_i32(instrs, 4); + local_set(instrs, destination); + return; + } + let maximum_length = (u32::MAX - 4) / stride; + let too_large = dangling( + module, + codec.encode_anyref, + walrus::ir::InstrSeqType::Simple(None), + ); + push( + instrs_mut(module, codec.encode_anyref, too_large), + Instr::Unreachable(Unreachable {}), + ); + let okay = dangling( + module, + codec.encode_anyref, + walrus::ir::InstrSeqType::Simple(None), + ); + let instrs = instrs_mut(module, codec.encode_anyref, seq); + local_get(instrs, length); + constant_i32(instrs, maximum_length as i32); + binop(instrs, BinaryOp::I32GtU); + push( + instrs, + Instr::IfElse(IfElse { + consequent: too_large, + alternative: okay, + }), + ); + local_get(instrs, length); + constant_i32(instrs, stride as i32); + binop(instrs, BinaryOp::I32Mul); + constant_i32(instrs, 4); + binop(instrs, BinaryOp::I32Add); + local_set(instrs, destination); +} + +fn emit_array_scalar_address( + instrs: &mut Vec<(Instr, InstrLocId)>, + staging: LocalId, + ptr_ty: ValType, + index: LocalId, + stride: u32, +) { + local_get(instrs, staging); + constant_ptr(instrs, ptr_ty, 4); + binop(instrs, pointer_add(ptr_ty)); + local_get(instrs, index); + constant_i32(instrs, stride as i32); + binop(instrs, BinaryOp::I32Mul); + emit_i32_to_ptr(instrs, ptr_ty); + binop(instrs, pointer_add(ptr_ty)); +} + +#[derive(Debug, Clone, Copy)] +struct BaseType { + type_id: TypeId, + type_ordinal: u32, + layout_id: u32, + needs_constructor_provenance: bool, +} + +/// Freeze original GC types and constructor sites before module-state/runtime +/// helpers add synthetic functions and types. +pub fn plan(module: &Module) -> Result { + let mut type_ordinals = HashMap::new(); + for ty in module.types.iter() { + if matches!( + ty.kind(), + CompositeType::Struct(_) | CompositeType::Array(_) + ) { + let ordinal = u32::try_from(type_ordinals.len()) + .map_err(|_| anyhow::anyhow!("GC type catalog exceeds u32"))?; + type_ordinals.insert(ty.id(), ordinal); + } + } + + let mut layouts = Vec::with_capacity(type_ordinals.len()); + let mut base_types = HashMap::new(); + for ty in module.types.iter() { + let Some(&type_ordinal) = type_ordinals.get(&ty.id()) else { + continue; + }; + let id = u32::try_from(layouts.len() + 1) + .map_err(|_| anyhow::anyhow!("GC layout catalog exceeds u31"))?; + ensure!(id <= 0x7fff_ffff, "GC layout catalog exceeds u31"); + let super_type_ordinal = ty + .supertype + .and_then(|supertype| type_ordinals.get(&supertype).copied()); + let subtype_depth = subtype_depth(module, ty.id())?; + match ty.kind() { + CompositeType::Struct(structure) => { + let (fields, scalar_len) = layout_fields(module, &structure.fields)?; + let defaultable_shell = structure + .fields + .iter() + .all(|field| field.mutable && defaultable_field(field)); + let requires_provenance = structure.fields.iter().any(|field| { + field.mutable + && matches!( + field.element_type, + StorageType::Val(ValType::Ref(reference)) + if !reference.nullable + && is_internal_gc_reference(module, reference) + ) + }); + let provenance_reference_count = structure + .fields + .iter() + .filter(|field| { + field.mutable + && matches!( + field.element_type, + StorageType::Val(ValType::Ref(reference)) + if !reference.nullable + && is_internal_gc_reference(module, reference) + ) + }) + .count() as u32; + layouts.push(GcLayout { + id, + type_id: ty.id(), + type_ordinal, + base_layout_id: id, + kind: GcLayoutKind::Struct, + constructor: GcConstructorKind::Struct, + scalar_len_or_stride: scalar_len, + fields, + super_type_ordinal, + subtype_depth, + defaultable_shell, + requires_provenance, + provenance_scalar_len: 0, + provenance_reference_count, + }); + base_types.insert( + ty.id(), + BaseType { + type_id: ty.id(), + type_ordinal, + layout_id: id, + needs_constructor_provenance: requires_provenance, + }, + ); + } + CompositeType::Array(array) => { + let (fields, stride) = layout_fields(module, std::slice::from_ref(&array.field))?; + let defaultable_shell = defaultable_field(&array.field) && array.field.mutable; + let needs_constructor_provenance = + !array.field.mutable || !defaultable_field(&array.field); + layouts.push(GcLayout { + id, + type_id: ty.id(), + type_ordinal, + base_layout_id: id, + kind: GcLayoutKind::Array, + constructor: GcConstructorKind::ArrayGeneric, + scalar_len_or_stride: stride, + fields, + super_type_ordinal, + subtype_depth, + defaultable_shell, + requires_provenance: needs_constructor_provenance, + provenance_scalar_len: 0, + provenance_reference_count: 0, + }); + base_types.insert( + ty.id(), + BaseType { + type_id: ty.id(), + type_ordinal, + layout_id: id, + needs_constructor_provenance, + }, + ); + } + CompositeType::Function(_) => unreachable!(), + } + } + + let data_ordinals: HashMap<_, _> = module + .data + .iter() + .enumerate() + .map(|(ordinal, data)| (data.id(), ordinal as u32)) + .collect(); + let element_ordinals: HashMap<_, _> = module + .elements + .iter() + .enumerate() + .map(|(ordinal, element)| (element.id(), ordinal as u32)) + .collect(); + + #[derive(Default)] + struct Constructors { + sites: Vec<(TypeId, GcConstructorKind)>, + } + impl<'instr> Visitor<'instr> for Constructors { + fn visit_instr(&mut self, instr: &'instr Instr, _loc: &'instr InstrLocId) { + match instr { + Instr::ArrayNew(ArrayNew { ty }) => { + self.sites.push((*ty, GcConstructorKind::ArrayNew)) + } + Instr::ArrayNewDefault(ArrayNewDefault { ty }) => { + self.sites.push((*ty, GcConstructorKind::ArrayDefault)) + } + Instr::ArrayNewFixed(ArrayNewFixed { ty, len }) => self + .sites + .push((*ty, GcConstructorKind::ArrayFixed { len: *len })), + Instr::ArrayNewData(ArrayNewData { ty, data }) => self.sites.push(( + *ty, + GcConstructorKind::ArrayData { + segment_ordinal: data.index() as u32, + }, + )), + Instr::ArrayNewElem(ArrayNewElem { ty, elem }) => self.sites.push(( + *ty, + GcConstructorKind::ArrayElement { + segment_ordinal: elem.index() as u32, + }, + )), + _ => {} + } + } + } + + let mut constructors = Constructors::default(); + let mut functions: Vec<_> = module + .funcs + .iter() + .filter_map(|function| match &function.kind { + FunctionKind::Local(local) => Some((function.id(), local)), + FunctionKind::Import(_) | FunctionKind::Uninitialized(_) => None, + }) + .collect(); + functions.sort_by_key(|(id, _)| *id); + for (_, function) in functions { + dfs_in_order(&mut constructors, function, function.entry_block()); + } + + let mut emitted = HashSet::new(); + for (type_id, mut constructor) in constructors.sites { + let Some(base) = base_types.get(&type_id).copied() else { + continue; + }; + if !base.needs_constructor_provenance { + continue; + } + constructor = match constructor { + GcConstructorKind::ArrayData { segment_ordinal } => { + let data = module + .data + .iter() + .find(|data| data.id().index() as u32 == segment_ordinal) + .and_then(|data| data_ordinals.get(&data.id()).copied()) + .ok_or_else(|| anyhow::anyhow!("GC array data segment is not catalogued"))?; + GcConstructorKind::ArrayData { + segment_ordinal: data, + } + } + GcConstructorKind::ArrayElement { segment_ordinal } => { + let element = module + .elements + .iter() + .find(|element| element.id().index() as u32 == segment_ordinal) + .and_then(|element| element_ordinals.get(&element.id()).copied()) + .ok_or_else(|| anyhow::anyhow!("GC array element segment is not catalogued"))?; + GcConstructorKind::ArrayElement { + segment_ordinal: element, + } + } + other => other, + }; + let key = (type_id, constructor.wire(), constructor.auxiliary()); + if !emitted.insert(key) { + continue; + } + let id = u32::try_from(layouts.len() + 1) + .map_err(|_| anyhow::anyhow!("GC constructor catalog exceeds u31"))?; + ensure!(id <= 0x7fff_ffff, "GC constructor catalog exceeds u31"); + let base_layout = &layouts[(base.layout_id - 1) as usize]; + let (provenance_scalar_len, provenance_reference_count) = + constructor_provenance(module, constructor, base_layout.fields[0].field); + layouts.push(GcLayout { + id, + type_id: base.type_id, + type_ordinal: base.type_ordinal, + base_layout_id: base.layout_id, + kind: GcLayoutKind::Array, + constructor, + scalar_len_or_stride: base_layout.scalar_len_or_stride, + fields: base_layout.fields.clone(), + super_type_ordinal: base_layout.super_type_ordinal, + subtype_depth: base_layout.subtype_depth, + defaultable_shell: base_layout.defaultable_shell, + requires_provenance: true, + provenance_scalar_len, + provenance_reference_count, + }); + } + + let mut dispatch: Vec<_> = base_types.values().copied().collect(); + dispatch.sort_by(|left, right| { + let left_layout = &layouts[(left.layout_id - 1) as usize]; + let right_layout = &layouts[(right.layout_id - 1) as usize]; + right_layout + .subtype_depth + .cmp(&left_layout.subtype_depth) + .then_with(|| left.type_ordinal.cmp(&right.type_ordinal)) + }); + Ok(GcCodecPlan { + layouts, + dispatch_layouts: dispatch.into_iter().map(|entry| entry.layout_id).collect(), + }) +} + +fn subtype_depth(module: &Module, mut ty: TypeId) -> Result { + let mut depth = 0u32; + let limit = module.types.iter().count(); + for _ in 0..=limit { + let Some(supertype) = module.types.get(ty).supertype else { + return Ok(depth); + }; + depth = depth + .checked_add(1) + .ok_or_else(|| anyhow::anyhow!("GC subtype depth overflow"))?; + ty = supertype; + } + anyhow::bail!("fork-instrument: cyclic GC supertype chain") +} + +fn defaultable_field(field: &FieldType) -> bool { + match field.element_type { + StorageType::I8 | StorageType::I16 => true, + StorageType::Val(ValType::Ref(reference)) => reference.nullable, + StorageType::Val(_) => true, + } +} + +fn constructor_provenance( + module: &Module, + constructor: GcConstructorKind, + field: FieldType, +) -> (u32, u32) { + match constructor { + GcConstructorKind::ArrayNew => match field.element_type { + StorageType::Val(ValType::Ref(reference)) + if is_internal_gc_reference(module, reference) => + { + (0, 1) + } + StorageType::Val(ValType::Ref(_)) => (0, 0), + scalar => (storage_size(scalar), 0), + }, + GcConstructorKind::ArrayFixed { len } + if field.mutable + && matches!( + field.element_type, + StorageType::Val(ValType::Ref(reference)) + if !reference.nullable + && is_internal_gc_reference(module, reference) + ) => + { + (0, len) + } + GcConstructorKind::ArrayData { .. } | GcConstructorKind::ArrayElement { .. } => (8, 0), + GcConstructorKind::Struct + | GcConstructorKind::ArrayGeneric + | GcConstructorKind::ArrayDefault + | GcConstructorKind::ArrayFixed { .. } => (0, 0), + } +} + +fn layout_fields(_module: &Module, fields: &[FieldType]) -> Result<(Vec, u32)> { + let mut scalar_offset = 0u32; + let mut reference_ordinal = 0u32; + let mut layouts = Vec::with_capacity(fields.len()); + for field in fields { + match field.element_type { + StorageType::Val(ValType::Ref(reference)) => { + layouts.push(GcFieldLayout { + field: *field, + scalar_offset: None, + reference_ordinal: Some(reference_ordinal), + // Immutable edges are constructor values. Mutable + // internal non-null edges use the separately recorded + // constructor seed; other hierarchies have generated + // temporary seeds and are filled in phase two. + allocation_dependency: !field.mutable + && !matches!( + reference.heap_type, + HeapType::Abstract(AbstractHeapType::None) + ), + }); + reference_ordinal = reference_ordinal + .checked_add(1) + .ok_or_else(|| anyhow::anyhow!("GC reference layout overflow"))?; + } + storage => { + let size = storage_size(storage); + let aligned = align_up(scalar_offset, size.min(16))?; + layouts.push(GcFieldLayout { + field: *field, + scalar_offset: Some(aligned), + reference_ordinal: None, + allocation_dependency: false, + }); + scalar_offset = aligned + .checked_add(size) + .ok_or_else(|| anyhow::anyhow!("GC scalar layout overflow"))?; + } + } + } + Ok((layouts, scalar_offset)) +} + +fn is_internal_gc_reference(module: &Module, reference: RefType) -> bool { + runtime::ReferenceCodecClass::of(module, reference) == runtime::ReferenceCodecClass::Any +} + +fn storage_size(storage: StorageType) -> u32 { + match storage { + StorageType::I8 => 1, + StorageType::I16 => 2, + StorageType::Val(ValType::I32 | ValType::F32) => 4, + StorageType::Val(ValType::I64 | ValType::F64) => 8, + StorageType::Val(ValType::V128) => 16, + StorageType::Val(ValType::Ref(_)) => 4, + } +} + +fn align_up(value: u32, alignment: u32) -> Result { + let mask = alignment - 1; + value + .checked_add(mask) + .map(|value| value & !mask) + .ok_or_else(|| anyhow::anyhow!("GC scalar layout overflow")) +} + +fn storage_code(storage: StorageType) -> u8 { + match storage { + StorageType::I8 => STORAGE_I8, + StorageType::I16 => STORAGE_I16, + StorageType::Val(ValType::I32) => STORAGE_I32, + StorageType::Val(ValType::I64) => STORAGE_I64, + StorageType::Val(ValType::F32) => STORAGE_F32, + StorageType::Val(ValType::F64) => STORAGE_F64, + StorageType::Val(ValType::V128) => STORAGE_V128, + StorageType::Val(ValType::Ref(_)) => STORAGE_REFERENCE, + } +} + +pub fn encode_descriptor(plan: &GcCodecPlan) -> Vec { + let field_count: usize = plan.layouts.iter().map(|layout| layout.fields.len()).sum(); + let mut data = Vec::with_capacity( + usize::from(FORMAT_HEADER_SIZE) + + plan.layouts.len() * usize::from(FORMAT_LAYOUT_RECORD_SIZE) + + field_count * usize::from(FORMAT_FIELD_RECORD_SIZE), + ); + data.extend_from_slice(&FORMAT_MAGIC); + data.extend_from_slice(&FORMAT_VERSION.to_le_bytes()); + data.extend_from_slice(&FORMAT_HEADER_SIZE.to_le_bytes()); + data.extend_from_slice(&(plan.layouts.len() as u32).to_le_bytes()); + data.extend_from_slice(&(field_count as u32).to_le_bytes()); + + let mut field_start = 0u32; + for layout in &plan.layouts { + let flags = (if layout.requires_provenance || layout.constructor.requires_provenance() { + LAYOUT_FLAG_REQUIRES_PROVENANCE + } else { + 0 + }) | (if layout.defaultable_shell { + LAYOUT_FLAG_DEFAULTABLE_SHELL + } else { + 0 + }); + data.extend_from_slice(&layout.id.to_le_bytes()); + data.extend_from_slice(&layout.type_ordinal.to_le_bytes()); + data.push(layout.kind.wire()); + data.push(layout.constructor.wire()); + data.extend_from_slice(&flags.to_le_bytes()); + data.extend_from_slice(&layout.scalar_len_or_stride.to_le_bytes()); + data.extend_from_slice(&field_start.to_le_bytes()); + data.extend_from_slice(&(layout.fields.len() as u32).to_le_bytes()); + data.extend_from_slice( + &layout + .super_type_ordinal + .unwrap_or(NO_ORDINAL) + .to_le_bytes(), + ); + data.extend_from_slice(&layout.base_layout_id.to_le_bytes()); + data.extend_from_slice(&layout.constructor.auxiliary().to_le_bytes()); + data.extend_from_slice(&layout.provenance_scalar_len.to_le_bytes()); + data.extend_from_slice(&layout.provenance_reference_count.to_le_bytes()); + field_start += layout.fields.len() as u32; + } + for layout in &plan.layouts { + for field in &layout.fields { + let reference = matches!(field.field.element_type, StorageType::Val(ValType::Ref(_))); + let nullable = matches!( + field.field.element_type, + StorageType::Val(ValType::Ref(reference)) if reference.nullable + ); + let flags = (if field.field.mutable { + FIELD_FLAG_MUTABLE + } else { + 0 + }) | (if nullable { FIELD_FLAG_NULLABLE } else { 0 }) + | (if reference { FIELD_FLAG_REFERENCE } else { 0 }) + | (if field.is_allocation_dependency() { + FIELD_FLAG_ALLOCATION_DEPENDENCY + } else { + 0 + }); + data.push(storage_code(field.field.element_type)); + data.push(flags); + data.extend_from_slice(&0u16.to_le_bytes()); + data.extend_from_slice(&field.scalar_offset.unwrap_or(NO_ORDINAL).to_le_bytes()); + data.extend_from_slice(&field.reference_ordinal.unwrap_or(NO_ORDINAL).to_le_bytes()); + } + } + data +} + +fn inject_host_imports(module: &mut Module, ptr_ty: ValType) -> HostImports { + HostImports { + activation: module + .add_import_global( + HOST_IMPORT_MODULE, + WPK_FORK_EXCEPTION_IMPORT_ACTIVATION, + ValType::I32, + false, + false, + ) + .0, + lookup: import_function(module, IMPORT_LOOKUP, &[ValType::I32], &[ValType::I32]), + claim: import_function(module, IMPORT_CLAIM, &[ValType::I32], &[ValType::I32]), + i31: import_function(module, IMPORT_I31, &[ValType::I32], &[ValType::I32]), + define: import_function( + module, + IMPORT_DEFINE, + &[ + ValType::I32, + ValType::I32, + ValType::I32, + ValType::I32, + ValType::I32, + ptr_ty, + ValType::I32, + ValType::I32, + ], + &[], + ), + route: import_function( + module, + IMPORT_ROUTE, + &[ValType::I32, ValType::I32], + &[ValType::I32], + ), + payload_len: import_function( + module, + IMPORT_PAYLOAD_LEN, + &[ValType::I32, ValType::I32, ValType::I32], + &[ValType::I32], + ), + load: import_function( + module, + IMPORT_LOAD, + &[ + ValType::I32, + ValType::I32, + ValType::I32, + ValType::I32, + ValType::I32, + ptr_ty, + ValType::I32, + ], + &[ValType::I32], + ), + broker_encode: import_function( + module, + IMPORT_BROKER_ENCODE, + &[ValType::I32], + &[ValType::I32], + ), + capture_layout: import_function( + module, + IMPORT_CAPTURE_LAYOUT, + &[ValType::I32, ValType::I32, ValType::I32], + &[ValType::I32], + ), + provenance_begin: import_function( + module, + IMPORT_PROVENANCE_BEGIN, + &[ + ValType::I32, + ValType::I32, + ValType::I32, + ValType::I32, + ValType::I64, + ValType::I64, + ValType::I32, + ], + &[ValType::I32], + ), + provenance_ref: import_function( + module, + IMPORT_PROVENANCE_REF, + &[ValType::I32, ValType::I32, ValType::I32], + &[], + ), + provenance_end: import_function(module, IMPORT_PROVENANCE_END, &[ValType::I32], &[]), + } +} + +fn import_function( + module: &mut Module, + name: &str, + params: &[ValType], + results: &[ValType], +) -> FunctionId { + let ty = module.types.add(params, results); + module.add_import_func(HOST_IMPORT_MODULE, name, ty).0 +} + +fn add_stub( + module: &mut Module, + params: &[ValType], + results: &[ValType], + name: &str, +) -> (FunctionId, Vec) { + let args: Vec<_> = params + .iter() + .copied() + .map(|ty| module.locals.add(ty)) + .collect(); + let mut builder = FunctionBuilder::new(&mut module.types, params, results); + builder.name(name.into()); + let function = builder.finish(args.clone(), &mut module.funcs); + (function, args) +} + +fn replace_descriptor(module: &mut Module, plan: &GcCodecPlan) { + loop { + let existing = module + .customs + .iter() + .find(|(_, section)| section.name() == FORMAT_SECTION) + .map(|(id, _)| id); + let Some(existing) = existing else { break }; + module.customs.delete(existing); + } + module.customs.add(RawCustomSection { + name: FORMAT_SECTION.into(), + data: encode_descriptor(plan), + }); +} + +fn dangling( + module: &mut Module, + function: FunctionId, + ty: walrus::ir::InstrSeqType, +) -> walrus::ir::InstrSeqId { + local_mut(module, function) + .builder_mut() + .dangling_instr_seq(ty) + .id() +} + +fn entry(function: FunctionId, module: &Module) -> walrus::ir::InstrSeqId { + local(module, function).entry_block() +} + +fn instrs_mut( + module: &mut Module, + function: FunctionId, + seq: walrus::ir::InstrSeqId, +) -> &mut Vec<(Instr, InstrLocId)> { + &mut local_mut(module, function).block_mut(seq).instrs +} + +fn local(module: &Module, function: FunctionId) -> &LocalFunction { + match &module.funcs.get(function).kind { + FunctionKind::Local(local) => local, + _ => unreachable!("injected GC codec function is local"), + } +} + +fn local_mut(module: &mut Module, function: FunctionId) -> &mut LocalFunction { + match &mut module.funcs.get_mut(function).kind { + FunctionKind::Local(local) => local, + _ => unreachable!("injected GC codec function is local"), + } +} + +fn push(instrs: &mut Vec<(Instr, InstrLocId)>, instr: Instr) { + instrs.push((instr, InstrLocId::default())); +} + +fn local_get(instrs: &mut Vec<(Instr, InstrLocId)>, local: LocalId) { + push(instrs, Instr::LocalGet(LocalGet { local })); +} + +fn local_set(instrs: &mut Vec<(Instr, InstrLocId)>, local: LocalId) { + push(instrs, Instr::LocalSet(LocalSet { local })); +} + +fn constant_i32(instrs: &mut Vec<(Instr, InstrLocId)>, value: i32) { + push( + instrs, + Instr::Const(Const { + value: Value::I32(value), + }), + ); +} + +fn constant_i64(instrs: &mut Vec<(Instr, InstrLocId)>, value: i64) { + push( + instrs, + Instr::Const(Const { + value: Value::I64(value), + }), + ); +} + +fn constant_ptr(instrs: &mut Vec<(Instr, InstrLocId)>, ptr_ty: ValType, value: u64) { + match ptr_ty { + ValType::I32 => constant_i32(instrs, value as u32 as i32), + ValType::I64 => push( + instrs, + Instr::Const(Const { + value: Value::I64(value as i64), + }), + ), + other => unreachable!("unsupported GC staging pointer type {other:?}"), + } +} + +fn call(instrs: &mut Vec<(Instr, InstrLocId)>, function: FunctionId) { + push(instrs, Instr::Call(Call { func: function })); +} + +fn binop(instrs: &mut Vec<(Instr, InstrLocId)>, op: BinaryOp) { + push(instrs, Instr::Binop(Binop { op })); +} + +fn pointer_add(ptr_ty: ValType) -> BinaryOp { + match ptr_ty { + ValType::I32 => BinaryOp::I32Add, + ValType::I64 => BinaryOp::I64Add, + other => unreachable!("unsupported GC staging pointer type {other:?}"), + } +} + +fn emit_i32_to_ptr(instrs: &mut Vec<(Instr, InstrLocId)>, ptr_ty: ValType) { + match ptr_ty { + ValType::I32 => {} + ValType::I64 => push( + instrs, + Instr::Unop(Unop { + op: UnaryOp::I64ExtendUI32, + }), + ), + other => unreachable!("unsupported GC staging pointer type {other:?}"), + } +} + +fn clear_transit_slot(instrs: &mut Vec<(Instr, InstrLocId)>, transit: TableId, slot: i32) { + constant_i32(instrs, slot); + push( + instrs, + Instr::RefNull(RefNull { + ty: RefType::ANYREF, + }), + ); + push(instrs, Instr::TableSet(TableSet { table: transit })); +} + +fn emit_allocate( + module: &mut Module, + codec: &DeclaredGcCodec, + deps: EmitDependencies, + seeds: &ReferenceSeeds, +) -> Result<()> { + let recipe = codec.allocate_args[0]; + let routed_layout = module.locals.add(ValType::I32); + let scalar_len = module.locals.add(ValType::I32); + let reservation_len = module.locals.add(ValType::I32); + let staging = module.locals.add(codec.ptr_ty); + let vector = module.locals.add(ValType::I32); + // Replay-created aggregates must become valid parents of a later fork. + // This local exists only in the generated replay helper, never in a saved + // user activation frame. + let provenance_token = module.locals.add(ValType::I32); + let entry = entry(codec.allocate, module); + { + let instrs = instrs_mut(module, codec.allocate, entry); + local_get(instrs, recipe); + push( + instrs, + Instr::GlobalGet(GlobalGet { + global: deps.activation, + }), + ); + call(instrs, codec.imports.route); + local_set(instrs, routed_layout); + } + + let i31 = dangling( + module, + codec.allocate, + walrus::ir::InstrSeqType::Simple(None), + ); + emit_allocate_i31( + module, + codec, + deps, + i31, + recipe, + scalar_len, + reservation_len, + staging, + vector, + ); + let not_i31 = dangling( + module, + codec.allocate, + walrus::ir::InstrSeqType::Simple(None), + ); + { + let instrs = instrs_mut(module, codec.allocate, entry); + local_get(instrs, routed_layout); + push( + instrs, + Instr::Unop(Unop { + op: UnaryOp::I32Eqz, + }), + ); + push( + instrs, + Instr::IfElse(IfElse { + consequent: i31, + alternative: not_i31, + }), + ); + } + + for layout in codec.plan.layouts().iter().cloned() { + let yes = dangling( + module, + codec.allocate, + walrus::ir::InstrSeqType::Simple(None), + ); + emit_allocate_layout( + module, + codec, + deps, + yes, + &layout, + recipe, + scalar_len, + reservation_len, + staging, + vector, + provenance_token, + seeds, + )?; + let no = dangling( + module, + codec.allocate, + walrus::ir::InstrSeqType::Simple(None), + ); + let instrs = instrs_mut(module, codec.allocate, entry); + local_get(instrs, routed_layout); + constant_i32(instrs, layout.id as i32); + binop(instrs, BinaryOp::I32Eq); + push( + instrs, + Instr::IfElse(IfElse { + consequent: yes, + alternative: no, + }), + ); + } + push( + instrs_mut(module, codec.allocate, entry), + Instr::Unreachable(Unreachable {}), + ); + Ok(()) +} + +fn emit_fill(module: &mut Module, codec: &DeclaredGcCodec, deps: EmitDependencies) -> Result<()> { + let recipe = codec.fill_args[0]; + let routed_layout = module.locals.add(ValType::I32); + let scalar_len = module.locals.add(ValType::I32); + let reservation_len = module.locals.add(ValType::I32); + let staging = module.locals.add(codec.ptr_ty); + let vector = module.locals.add(ValType::I32); + let entry = entry(codec.fill, module); + { + let instrs = instrs_mut(module, codec.fill, entry); + local_get(instrs, recipe); + push( + instrs, + Instr::GlobalGet(GlobalGet { + global: deps.activation, + }), + ); + call(instrs, codec.imports.route); + local_set(instrs, routed_layout); + } + for layout in codec.plan.layouts().iter().cloned() { + let yes = dangling(module, codec.fill, walrus::ir::InstrSeqType::Simple(None)); + emit_fill_layout( + module, + codec, + deps, + yes, + &layout, + recipe, + scalar_len, + reservation_len, + staging, + vector, + ); + let no = dangling(module, codec.fill, walrus::ir::InstrSeqType::Simple(None)); + let instrs = instrs_mut(module, codec.fill, entry); + local_get(instrs, routed_layout); + constant_i32(instrs, layout.id as i32); + binop(instrs, BinaryOp::I32Eq); + push( + instrs, + Instr::IfElse(IfElse { + consequent: yes, + alternative: no, + }), + ); + } + push( + instrs_mut(module, codec.fill, entry), + Instr::Unreachable(Unreachable {}), + ); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn emit_load_payload( + module: &mut Module, + function: FunctionId, + seq: walrus::ir::InstrSeqId, + codec: &DeclaredGcCodec, + deps: EmitDependencies, + recipe: LocalId, + layout_id: u32, + type_ordinal: u32, + kind: u8, + scalar_len: LocalId, + reservation_len: LocalId, + staging: LocalId, + vector: LocalId, +) { + { + let instrs = instrs_mut(module, function, seq); + local_get(instrs, recipe); + push( + instrs, + Instr::GlobalGet(GlobalGet { + global: deps.activation, + }), + ); + constant_i32(instrs, layout_id as i32); + call(instrs, codec.imports.payload_len); + local_set(instrs, scalar_len); + } + let nonzero = dangling( + module, + function, + walrus::ir::InstrSeqType::Simple(Some(ValType::I32)), + ); + local_get(instrs_mut(module, function, nonzero), scalar_len); + let zero = dangling( + module, + function, + walrus::ir::InstrSeqType::Simple(Some(ValType::I32)), + ); + constant_i32(instrs_mut(module, function, zero), 1); + { + let instrs = instrs_mut(module, function, seq); + local_get(instrs, scalar_len); + push( + instrs, + Instr::IfElse(IfElse { + consequent: nonzero, + alternative: zero, + }), + ); + local_set(instrs, reservation_len); + local_get(instrs, reservation_len); + emit_i32_to_ptr(instrs, codec.ptr_ty); + call(instrs, deps.scratch_reserve); + local_set(instrs, staging); + + local_get(instrs, recipe); + push( + instrs, + Instr::GlobalGet(GlobalGet { + global: deps.activation, + }), + ); + constant_i32(instrs, type_ordinal as i32); + constant_i32(instrs, layout_id as i32); + constant_i32(instrs, i32::from(kind)); + local_get(instrs, staging); + local_get(instrs, scalar_len); + call(instrs, codec.imports.load); + local_set(instrs, vector); + } +} + +fn emit_release_payload( + instrs: &mut Vec<(Instr, InstrLocId)>, + codec: &DeclaredGcCodec, + deps: EmitDependencies, + staging: LocalId, + reservation_len: LocalId, +) { + local_get(instrs, staging); + local_get(instrs, reservation_len); + emit_i32_to_ptr(instrs, codec.ptr_ty); + call(instrs, deps.scratch_release); +} + +#[allow(clippy::too_many_arguments)] +fn emit_allocate_i31( + module: &mut Module, + codec: &DeclaredGcCodec, + deps: EmitDependencies, + seq: walrus::ir::InstrSeqId, + recipe: LocalId, + scalar_len: LocalId, + reservation_len: LocalId, + staging: LocalId, + vector: LocalId, +) { + emit_load_payload( + module, + codec.allocate, + seq, + codec, + deps, + recipe, + 0, + NO_ORDINAL, + 0, + scalar_len, + reservation_len, + staging, + vector, + ); + let instrs = instrs_mut(module, codec.allocate, seq); + local_get(instrs, recipe); + constant_i32(instrs, 1); + binop(instrs, BinaryOp::I32Add); + local_get(instrs, staging); + push( + instrs, + Instr::Load(Load { + memory: codec.memory, + kind: LoadKind::I32 { atomic: false }, + arg: MemArg { + align: 1, + offset: 0, + }, + }), + ); + push(instrs, Instr::RefI31(RefI31 {})); + push( + instrs, + Instr::TableSet(TableSet { + table: codec.transit, + }), + ); + emit_release_payload(instrs, codec, deps, staging, reservation_len); + push(instrs, Instr::Return(Return {})); +} + +#[allow(clippy::too_many_arguments)] +fn emit_allocate_layout( + module: &mut Module, + codec: &DeclaredGcCodec, + deps: EmitDependencies, + seq: walrus::ir::InstrSeqId, + layout: &GcLayout, + recipe: LocalId, + scalar_len: LocalId, + reservation_len: LocalId, + staging: LocalId, + vector: LocalId, + provenance_token: LocalId, + seeds: &ReferenceSeeds, +) -> Result<()> { + emit_load_payload( + module, + codec.allocate, + seq, + codec, + deps, + recipe, + layout.id, + layout.type_ordinal, + layout.kind.wire(), + scalar_len, + reservation_len, + staging, + vector, + ); + { + let instrs = instrs_mut(module, codec.allocate, seq); + local_get(instrs, recipe); + constant_i32(instrs, 1); + binop(instrs, BinaryOp::I32Add); + } + match layout.kind { + GcLayoutKind::Struct => { + emit_allocate_struct(module, codec, deps, seq, layout, staging, vector, seeds) + } + GcLayoutKind::Array => { + emit_allocate_array(module, codec, deps, seq, layout, staging, vector, seeds)? + } + } + { + let instrs = instrs_mut(module, codec.allocate, seq); + push( + instrs, + Instr::TableSet(TableSet { + table: codec.transit, + }), + ); + } + if layout.requires_provenance { + emit_replay_provenance_registration( + module, + codec, + deps, + seq, + layout, + recipe, + staging, + vector, + provenance_token, + ); + } + let instrs = instrs_mut(module, codec.allocate, seq); + emit_release_payload(instrs, codec, deps, staging, reservation_len); + push(instrs, Instr::Return(Return {})); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn emit_replay_provenance_registration( + module: &mut Module, + codec: &DeclaredGcCodec, + deps: EmitDependencies, + seq: walrus::ir::InstrSeqId, + layout: &GcLayout, + recipe: LocalId, + staging: LocalId, + vector: LocalId, + token: LocalId, +) { + { + let instrs = instrs_mut(module, codec.allocate, seq); + local_get(instrs, recipe); + constant_i32(instrs, 1); + binop(instrs, BinaryOp::I32Add); + push( + instrs, + Instr::GlobalGet(GlobalGet { + global: deps.activation, + }), + ); + constant_i32(instrs, layout.base_layout_id as i32); + constant_i32(instrs, layout.id as i32); + emit_replayed_provenance_scalars(instrs, codec, layout, staging); + constant_i32(instrs, layout.provenance_reference_count as i32); + call(instrs, codec.imports.provenance_begin); + local_set(instrs, token); + } + for index in 0..layout.provenance_reference_count { + let instrs = instrs_mut(module, codec.allocate, seq); + local_get(instrs, token); + constant_i32(instrs, index as i32); + local_get(instrs, vector); + constant_i32(instrs, index as i32); + call(instrs, deps.vector_get); + constant_i32(instrs, 1); + binop(instrs, BinaryOp::I32Add); + call(instrs, codec.imports.provenance_ref); + } + let instrs = instrs_mut(module, codec.allocate, seq); + local_get(instrs, token); + call(instrs, codec.imports.provenance_end); +} + +fn emit_replayed_provenance_scalars( + instrs: &mut Vec<(Instr, InstrLocId)>, + codec: &DeclaredGcCodec, + layout: &GcLayout, + staging: LocalId, +) { + let load = |instrs: &mut Vec<(Instr, InstrLocId)>, + kind: LoadKind, + offset: u64| { + local_get(instrs, staging); + push( + instrs, + Instr::Load(Load { + memory: codec.memory, + kind, + arg: MemArg { + align: 1, + offset, + }, + }), + ); + }; + match layout.provenance_scalar_len { + 0 => { + constant_i64(instrs, 0); + constant_i64(instrs, 0); + } + 1 => { + load( + instrs, + LoadKind::I32_8 { + kind: walrus::ir::ExtendedLoad::ZeroExtend, + }, + 0, + ); + push( + instrs, + Instr::Unop(Unop { + op: UnaryOp::I64ExtendUI32, + }), + ); + constant_i64(instrs, 0); + } + 2 => { + load( + instrs, + LoadKind::I32_16 { + kind: walrus::ir::ExtendedLoad::ZeroExtend, + }, + 0, + ); + push( + instrs, + Instr::Unop(Unop { + op: UnaryOp::I64ExtendUI32, + }), + ); + constant_i64(instrs, 0); + } + 4 => { + load(instrs, LoadKind::I32 { atomic: false }, 0); + push( + instrs, + Instr::Unop(Unop { + op: UnaryOp::I64ExtendUI32, + }), + ); + constant_i64(instrs, 0); + } + 8 => { + load(instrs, LoadKind::I64 { atomic: false }, 0); + constant_i64(instrs, 0); + } + 16 => { + load(instrs, LoadKind::I64 { atomic: false }, 0); + load(instrs, LoadKind::I64 { atomic: false }, 8); + } + length => unreachable!("invalid GC provenance scalar length {length}"), + } +} + +fn emit_allocate_struct( + module: &mut Module, + codec: &DeclaredGcCodec, + deps: EmitDependencies, + seq: walrus::ir::InstrSeqId, + layout: &GcLayout, + staging: LocalId, + vector: LocalId, + seeds: &ReferenceSeeds, +) { + if layout.defaultable_shell { + push( + instrs_mut(module, codec.allocate, seq), + Instr::StructNewDefault(StructNewDefault { ty: layout.type_id }), + ); + return; + } + + let reference_decoders: Vec<_> = layout + .fields + .iter() + .map(|field| match field.field.element_type { + StorageType::Val(ValType::Ref(reference)) => { + let class = runtime::ReferenceCodecClass::of(module, reference); + Some(( + class, + class.decoder(deps.codecs), + class.nullable_type(), + reference_seed(module, seeds, reference), + )) + } + _ => None, + }) + .collect(); + let mut provenance_reference = 0u32; + for (index, field) in layout.fields.iter().enumerate() { + let decoder = reference_decoders[index]; + let instrs = instrs_mut(module, codec.allocate, seq); + match field.field.element_type { + StorageType::Val(ValType::Ref(reference)) if !field.field.mutable => { + local_get(instrs, vector); + constant_i32( + instrs, + (layout.provenance_reference_count + + field.reference_ordinal.expect("reference ordinal")) + as i32, + ); + call(instrs, deps.vector_get); + let (_, decoder, broad, _) = decoder.expect("reference decoder"); + call(instrs, decoder); + emit_narrow(instrs, broad, reference); + } + StorageType::Val(ValType::Ref(reference)) + if field.field.mutable && !reference.nullable => + { + let (class, decoder, broad, seed) = decoder.expect("reference decoder"); + if class == runtime::ReferenceCodecClass::Any { + local_get(instrs, vector); + constant_i32(instrs, provenance_reference as i32); + call(instrs, deps.vector_get); + provenance_reference += 1; + call(instrs, decoder); + emit_narrow(instrs, broad, reference); + } else { + emit_seed_reference(instrs, seed); + } + } + StorageType::Val(ValType::Ref(reference)) => { + push(instrs, Instr::RefNull(RefNull { ty: reference })); + } + storage if !field.field.mutable => { + local_get(instrs, staging); + push( + instrs, + Instr::Load(Load { + memory: codec.memory, + kind: scalar_load(storage), + arg: MemArg { + align: 1, + offset: u64::from( + layout.provenance_scalar_len + + field.scalar_offset.expect("scalar offset"), + ), + }, + }), + ); + } + storage => emit_default_scalar(instrs, storage), + } + } + push( + instrs_mut(module, codec.allocate, seq), + Instr::StructNew(StructNew { ty: layout.type_id }), + ); +} + +#[allow(clippy::too_many_arguments)] +fn emit_array_new_reference_seed( + module: &mut Module, + codec: &DeclaredGcCodec, + deps: EmitDependencies, + seq: walrus::ir::InstrSeqId, + layout: &GcLayout, + staging: LocalId, + vector: LocalId, + reference: RefType, + class: runtime::ReferenceCodecClass, + decoder: FunctionId, + broad: RefType, + seed: ReferenceSeed, +) { + if class == runtime::ReferenceCodecClass::Any { + let instrs = instrs_mut(module, codec.allocate, seq); + local_get(instrs, vector); + constant_i32(instrs, 0); + call(instrs, deps.vector_get); + call(instrs, decoder); + emit_narrow(instrs, broad, reference); + return; + } + if layout.fields[0].field.mutable { + emit_seed_reference(instrs_mut(module, codec.allocate, seq), seed); + return; + } + + // Immutable array.new values equal the constructor seed whenever length + // is nonzero. A zero-length array has no observable element, but the Wasm + // instruction still requires a typed seed; synthesize one locally. + let result_ty = ValType::Ref(reference); + let nonempty = dangling( + module, + codec.allocate, + walrus::ir::InstrSeqType::Simple(Some(result_ty)), + ); + { + let instrs = instrs_mut(module, codec.allocate, nonempty); + local_get(instrs, vector); + constant_i32(instrs, layout.provenance_reference_count as i32); + call(instrs, deps.vector_get); + call(instrs, decoder); + emit_narrow(instrs, broad, reference); + } + let empty = dangling( + module, + codec.allocate, + walrus::ir::InstrSeqType::Simple(Some(result_ty)), + ); + if reference.nullable { + push( + instrs_mut(module, codec.allocate, empty), + Instr::RefNull(RefNull { ty: reference }), + ); + } else { + emit_seed_reference(instrs_mut(module, codec.allocate, empty), seed); + } + let instrs = instrs_mut(module, codec.allocate, seq); + emit_load_i32(instrs, codec.memory, staging, layout.provenance_scalar_len); + push( + instrs, + Instr::IfElse(IfElse { + consequent: nonempty, + alternative: empty, + }), + ); +} + +fn emit_allocate_array( + module: &mut Module, + codec: &DeclaredGcCodec, + deps: EmitDependencies, + seq: walrus::ir::InstrSeqId, + layout: &GcLayout, + staging: LocalId, + vector: LocalId, + seeds: &ReferenceSeeds, +) -> Result<()> { + let field = layout.fields[0].field; + match layout.constructor { + GcConstructorKind::ArrayGeneric if layout.defaultable_shell => { + emit_load_i32( + instrs_mut(module, codec.allocate, seq), + codec.memory, + staging, + layout.provenance_scalar_len, + ); + push( + instrs_mut(module, codec.allocate, seq), + Instr::ArrayNewDefault(ArrayNewDefault { ty: layout.type_id }), + ); + } + GcConstructorKind::ArrayNew => { + let reference_decoder = match field.element_type { + StorageType::Val(ValType::Ref(reference)) => { + let class = runtime::ReferenceCodecClass::of(module, reference); + Some(( + reference, + class, + class.decoder(deps.codecs), + class.nullable_type(), + reference_seed(module, seeds, reference), + )) + } + _ => None, + }; + if let Some((reference, class, decoder, broad, seed)) = reference_decoder { + emit_array_new_reference_seed( + module, codec, deps, seq, layout, staging, vector, reference, class, decoder, + broad, seed, + ); + } + { + let instrs = instrs_mut(module, codec.allocate, seq); + match field.element_type { + StorageType::Val(ValType::Ref(_)) => {} + storage => { + local_get(instrs, staging); + push( + instrs, + Instr::Load(Load { + memory: codec.memory, + kind: scalar_load(storage), + arg: MemArg { + align: 1, + offset: 0, + }, + }), + ); + } + } + emit_load_i32(instrs, codec.memory, staging, layout.provenance_scalar_len); + push(instrs, Instr::ArrayNew(ArrayNew { ty: layout.type_id })); + } + } + GcConstructorKind::ArrayDefault => { + emit_load_i32( + instrs_mut(module, codec.allocate, seq), + codec.memory, + staging, + layout.provenance_scalar_len, + ); + push( + instrs_mut(module, codec.allocate, seq), + Instr::ArrayNewDefault(ArrayNewDefault { ty: layout.type_id }), + ); + } + GcConstructorKind::ArrayFixed { len } => { + for index in 0..len { + emit_load_array_snapshot_element( + module, codec, deps, seq, layout, staging, vector, index, seeds, + ); + } + push( + instrs_mut(module, codec.allocate, seq), + Instr::ArrayNewFixed(ArrayNewFixed { + ty: layout.type_id, + len, + }), + ); + } + GcConstructorKind::ArrayData { segment_ordinal } => { + let data = module + .data + .iter() + .nth(segment_ordinal as usize) + .ok_or_else(|| anyhow::anyhow!("GC data constructor segment disappeared"))? + .id(); + let instrs = instrs_mut(module, codec.allocate, seq); + emit_load_i32(instrs, codec.memory, staging, 0); + emit_load_i32(instrs, codec.memory, staging, 4); + push( + instrs, + Instr::ArrayNewData(ArrayNewData { + ty: layout.type_id, + data, + }), + ); + } + GcConstructorKind::ArrayElement { segment_ordinal } => { + let elem = module + .elements + .iter() + .nth(segment_ordinal as usize) + .ok_or_else(|| anyhow::anyhow!("GC element constructor segment disappeared"))? + .id(); + let instrs = instrs_mut(module, codec.allocate, seq); + emit_load_i32(instrs, codec.memory, staging, 0); + emit_load_i32(instrs, codec.memory, staging, 4); + push( + instrs, + Instr::ArrayNewElem(ArrayNewElem { + ty: layout.type_id, + elem, + }), + ); + } + GcConstructorKind::Struct | GcConstructorKind::ArrayGeneric => { + push( + instrs_mut(module, codec.allocate, seq), + Instr::Unreachable(Unreachable {}), + ); + } + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn emit_fill_layout( + module: &mut Module, + codec: &DeclaredGcCodec, + deps: EmitDependencies, + seq: walrus::ir::InstrSeqId, + layout: &GcLayout, + recipe: LocalId, + scalar_len: LocalId, + reservation_len: LocalId, + staging: LocalId, + vector: LocalId, +) { + let has_mutable = layout.fields.iter().any(|field| field.field.mutable); + if !has_mutable { + push( + instrs_mut(module, codec.fill, seq), + Instr::Return(Return {}), + ); + return; + } + emit_load_payload( + module, + codec.fill, + seq, + codec, + deps, + recipe, + layout.id, + layout.type_ordinal, + layout.kind.wire(), + scalar_len, + reservation_len, + staging, + vector, + ); + match layout.kind { + GcLayoutKind::Struct => { + emit_fill_struct(module, codec, deps, seq, layout, recipe, staging, vector) + } + GcLayoutKind::Array => { + emit_fill_array(module, codec, deps, seq, layout, recipe, staging, vector) + } + } + let instrs = instrs_mut(module, codec.fill, seq); + emit_release_payload(instrs, codec, deps, staging, reservation_len); + push(instrs, Instr::Return(Return {})); +} + +#[allow(clippy::too_many_arguments)] +fn emit_fill_struct( + module: &mut Module, + codec: &DeclaredGcCodec, + deps: EmitDependencies, + seq: walrus::ir::InstrSeqId, + layout: &GcLayout, + recipe: LocalId, + staging: LocalId, + vector: LocalId, +) { + let concrete = RefType { + nullable: true, + heap_type: HeapType::Concrete(layout.type_id), + }; + let object = module.locals.add(ValType::Ref(concrete)); + { + let instrs = instrs_mut(module, codec.fill, seq); + local_get(instrs, recipe); + constant_i32(instrs, 1); + binop(instrs, BinaryOp::I32Add); + push( + instrs, + Instr::TableGet(TableGet { + table: codec.transit, + }), + ); + push( + instrs, + Instr::RefCast(RefCast { + nullable: true, + heap_type: HeapType::Concrete(layout.type_id), + }), + ); + local_set(instrs, object); + } + let reference_decoders: Vec<_> = layout + .fields + .iter() + .map(|field| match field.field.element_type { + StorageType::Val(ValType::Ref(reference)) => { + let class = runtime::ReferenceCodecClass::of(module, reference); + Some((class.decoder(deps.codecs), class.nullable_type())) + } + _ => None, + }) + .collect(); + for (index, field) in layout.fields.iter().enumerate() { + if !field.field.mutable { + continue; + } + let instrs = instrs_mut(module, codec.fill, seq); + local_get(instrs, object); + match field.field.element_type { + StorageType::Val(ValType::Ref(reference)) => { + local_get(instrs, vector); + constant_i32( + instrs, + (layout.provenance_reference_count + + field.reference_ordinal.expect("reference ordinal")) + as i32, + ); + call(instrs, deps.vector_get); + let (decoder, broad) = reference_decoders[index].expect("reference decoder"); + call(instrs, decoder); + emit_narrow(instrs, broad, reference); + } + storage => { + local_get(instrs, staging); + push( + instrs, + Instr::Load(Load { + memory: codec.memory, + kind: scalar_load(storage), + arg: MemArg { + align: 1, + offset: u64::from( + layout.provenance_scalar_len + + field.scalar_offset.expect("scalar offset"), + ), + }, + }), + ); + } + } + push( + instrs, + Instr::StructSet(StructSet { + ty: layout.type_id, + field: index as u32, + }), + ); + } +} + +#[allow(clippy::too_many_arguments)] +fn emit_fill_array( + module: &mut Module, + codec: &DeclaredGcCodec, + deps: EmitDependencies, + seq: walrus::ir::InstrSeqId, + layout: &GcLayout, + recipe: LocalId, + staging: LocalId, + vector: LocalId, +) { + let concrete = RefType { + nullable: true, + heap_type: HeapType::Concrete(layout.type_id), + }; + let object = module.locals.add(ValType::Ref(concrete)); + let length = module.locals.add(ValType::I32); + let index = module.locals.add(ValType::I32); + { + let instrs = instrs_mut(module, codec.fill, seq); + local_get(instrs, recipe); + constant_i32(instrs, 1); + binop(instrs, BinaryOp::I32Add); + push( + instrs, + Instr::TableGet(TableGet { + table: codec.transit, + }), + ); + push( + instrs, + Instr::RefCast(RefCast { + nullable: true, + heap_type: HeapType::Concrete(layout.type_id), + }), + ); + local_set(instrs, object); + emit_load_i32(instrs, codec.memory, staging, layout.provenance_scalar_len); + local_set(instrs, length); + constant_i32(instrs, 0); + local_set(instrs, index); + } + let outer = dangling(module, codec.fill, walrus::ir::InstrSeqType::Simple(None)); + let body = dangling(module, codec.fill, walrus::ir::InstrSeqType::Simple(None)); + let reference_decoder = match layout.fields[0].field.element_type { + StorageType::Val(ValType::Ref(reference)) => { + let class = runtime::ReferenceCodecClass::of(module, reference); + Some((reference, class.decoder(deps.codecs), class.nullable_type())) + } + _ => None, + }; + { + let instrs = instrs_mut(module, codec.fill, body); + local_get(instrs, index); + local_get(instrs, length); + binop(instrs, BinaryOp::I32GeU); + push(instrs, Instr::BrIf(BrIf { block: outer })); + local_get(instrs, object); + local_get(instrs, index); + if let Some((reference, decoder, broad)) = reference_decoder { + local_get(instrs, vector); + local_get(instrs, index); + constant_i32(instrs, layout.provenance_reference_count as i32); + binop(instrs, BinaryOp::I32Add); + call(instrs, deps.vector_get); + call(instrs, decoder); + emit_narrow(instrs, broad, reference); + } else { + emit_array_scalar_address( + instrs, + staging, + codec.ptr_ty, + index, + layout.scalar_len_or_stride, + ); + push( + instrs, + Instr::Load(Load { + memory: codec.memory, + kind: scalar_load(layout.fields[0].field.element_type), + arg: MemArg { + align: 1, + offset: u64::from(layout.provenance_scalar_len), + }, + }), + ); + } + push(instrs, Instr::ArraySet(ArraySet { ty: layout.type_id })); + local_get(instrs, index); + constant_i32(instrs, 1); + binop(instrs, BinaryOp::I32Add); + local_set(instrs, index); + push(instrs, Instr::Br(Br { block: body })); + } + push( + instrs_mut(module, codec.fill, outer), + Instr::Loop(Loop { seq: body }), + ); + push( + instrs_mut(module, codec.fill, seq), + Instr::Block(walrus::ir::Block { seq: outer }), + ); +} + +fn emit_load_array_snapshot_element( + module: &mut Module, + codec: &DeclaredGcCodec, + deps: EmitDependencies, + seq: walrus::ir::InstrSeqId, + layout: &GcLayout, + staging: LocalId, + vector: LocalId, + index: u32, + seeds: &ReferenceSeeds, +) { + let reference_decoder = match layout.fields[0].field.element_type { + StorageType::Val(ValType::Ref(reference)) => { + let class = runtime::ReferenceCodecClass::of(module, reference); + Some(( + reference, + class, + class.decoder(deps.codecs), + class.nullable_type(), + reference_seed(module, seeds, reference), + )) + } + _ => None, + }; + let instrs = instrs_mut(module, codec.allocate, seq); + match layout.fields[0].field.element_type { + StorageType::Val(ValType::Ref(reference)) => { + let (_, class, decoder, broad, seed) = reference_decoder.expect("reference decoder"); + if layout.fields[0].field.mutable { + if class == runtime::ReferenceCodecClass::Any { + local_get(instrs, vector); + constant_i32(instrs, index as i32); + call(instrs, deps.vector_get); + call(instrs, decoder); + emit_narrow(instrs, broad, reference); + } else { + emit_seed_reference(instrs, seed); + } + } else { + local_get(instrs, vector); + constant_i32(instrs, (layout.provenance_reference_count + index) as i32); + call(instrs, deps.vector_get); + call(instrs, decoder); + emit_narrow(instrs, broad, reference); + } + } + storage => { + local_get(instrs, staging); + push( + instrs, + Instr::Load(Load { + memory: codec.memory, + kind: scalar_load(storage), + arg: MemArg { + align: 1, + offset: u64::from( + layout.provenance_scalar_len + 4 + index * layout.scalar_len_or_stride, + ), + }, + }), + ); + } + } +} + +fn emit_load_i32( + instrs: &mut Vec<(Instr, InstrLocId)>, + memory: MemoryId, + staging: LocalId, + offset: u32, +) { + local_get(instrs, staging); + push( + instrs, + Instr::Load(Load { + memory, + kind: LoadKind::I32 { atomic: false }, + arg: MemArg { + align: 1, + offset: u64::from(offset), + }, + }), + ); +} + +fn scalar_load(storage: StorageType) -> LoadKind { + match storage { + StorageType::I8 => LoadKind::I32_8 { + kind: walrus::ir::ExtendedLoad::ZeroExtend, + }, + StorageType::I16 => LoadKind::I32_16 { + kind: walrus::ir::ExtendedLoad::ZeroExtend, + }, + StorageType::Val(ValType::I32) => LoadKind::I32 { atomic: false }, + StorageType::Val(ValType::I64) => LoadKind::I64 { atomic: false }, + StorageType::Val(ValType::F32) => LoadKind::F32, + StorageType::Val(ValType::F64) => LoadKind::F64, + StorageType::Val(ValType::V128) => LoadKind::V128, + StorageType::Val(ValType::Ref(_)) => unreachable!("reference field uses recipe vector"), + } +} + +fn emit_default_scalar(instrs: &mut Vec<(Instr, InstrLocId)>, storage: StorageType) { + let value = match storage { + StorageType::I8 | StorageType::I16 | StorageType::Val(ValType::I32) => Value::I32(0), + StorageType::Val(ValType::I64) => Value::I64(0), + StorageType::Val(ValType::F32) => Value::F32(0.0), + StorageType::Val(ValType::F64) => Value::F64(0.0), + StorageType::Val(ValType::V128) => Value::V128(0), + StorageType::Val(ValType::Ref(_)) => unreachable!(), + }; + push(instrs, Instr::Const(Const { value })); +} + +#[derive(Debug, Clone, Copy)] +enum ReferenceSeed { + Func(FunctionId), + Extern, + Exn(FunctionId), + Uninhabited, +} + +fn reference_seed(module: &Module, seeds: &ReferenceSeeds, reference: RefType) -> ReferenceSeed { + match reference.heap_type { + HeapType::Abstract(AbstractHeapType::Func) => ReferenceSeed::Func(seeds.abstract_func), + HeapType::Concrete(ty) | HeapType::Exact(ty) if module.types.get(ty).is_function() => { + let func = seeds + .concrete_funcs + .get(&ty) + .copied() + .expect("planned concrete function seed"); + ReferenceSeed::Func(func) + } + HeapType::Abstract(AbstractHeapType::Extern) => ReferenceSeed::Extern, + HeapType::Abstract(AbstractHeapType::Exn) => ReferenceSeed::Exn(seeds.exn), + _ => ReferenceSeed::Uninhabited, + } +} + +fn emit_seed_reference(instrs: &mut Vec<(Instr, InstrLocId)>, seed: ReferenceSeed) { + match seed { + ReferenceSeed::Func(func) => push(instrs, Instr::RefFunc(RefFunc { func })), + ReferenceSeed::Extern => { + constant_i32(instrs, 0); + push(instrs, Instr::RefI31(RefI31 {})); + push(instrs, Instr::ExternConvertAny(ExternConvertAny {})); + } + ReferenceSeed::Exn(function) => call(instrs, function), + // Bottom reference types have no runtime inhabitant. If an aggregate + // with such a field is somehow routed here, trap before installing a + // partially reconstructed identity. + ReferenceSeed::Uninhabited => push(instrs, Instr::Unreachable(Unreachable {})), + } +} + +fn emit_narrow(instrs: &mut Vec<(Instr, InstrLocId)>, broad: RefType, expected: RefType) { + if expected.heap_type != broad.heap_type { + push( + instrs, + Instr::RefCast(RefCast { + nullable: expected.nullable, + heap_type: expected.heap_type, + }), + ); + } else if !expected.nullable { + push(instrs, Instr::RefAsNonNull(RefAsNonNull {})); + } +} diff --git a/crates/fork-instrument/src/module_state.rs b/crates/fork-instrument/src/module_state.rs new file mode 100644 index 0000000000..3a9ccc573f --- /dev/null +++ b/crates/fork-instrument/src/module_state.rs @@ -0,0 +1,3395 @@ +//! Guest-owned snapshot and reconstruction of module-instance state. +//! +//! A fork child is a fresh WebAssembly instance. Mutable globals, tables, and +//! passive-segment lifetime therefore do not survive merely because linear +//! memory was copied. This module emits typed guest helpers: +//! +//! * `wpk_fork_module_state_save(activation_id)` encodes every reference root +//! and table entry into the process-wide recipe transaction and writes only +//! recipe IDs/metadata into the KFMS arena. +//! * `wpk_fork_module_state_restore(activation_id)` decodes those IDs inside +//! the fresh instance, restores globals, and replays static table baselines +//! while passive segments are still available to later activations. +//! * `wpk_fork_module_state_finish_restore(activation_id)` restores the one +//! canonical sparse overlay for each physical table, then reapplies the +//! parent instance's `elem.drop`/`data.drop` state after the complete +//! activation graph has consumed every segment-backed initializer. +//! +//! The helpers never expose references through the JavaScript Table/Global +//! APIs. `exnref` and GC values cross only typed Wasm-to-Wasm codec imports. + +use std::collections::{HashMap, HashSet}; + +use anyhow::Result; +use sha2::{Digest, Sha256}; +use walrus::{ + ConstExpr, DataId, DataKind, ElementId, ElementItems, ElementKind, ExportItem, FunctionBuilder, + FunctionId, FunctionKind, GlobalId, InstrSeqBuilder, LocalFunction, LocalId, MemoryId, Module, + RawCustomSection, RefType, TableId, TypeId, ValType, + ir::{ + BinaryOp, Block, Br, BrIf, Call, CallIndirect, DataDrop, ElemDrop, ExtendedLoad, IfElse, + Instr, InstrLocId, InstrSeqId, LegacyCatch, LoadKind, Loop, MemArg, MemoryInit, + RefAsNonNull, RefCast, ReturnCall, ReturnCallIndirect, StoreKind, TableCopy, TableFill, + TableGet, TableGrow, TableInit, TableSet, TableSize, TryTable, UnaryOp, Unreachable, Value, + }, +}; +use wasm_posix_shared::abi::{ + WPK_FORK_EXPORT_MODULE_BOOTSTRAP, WPK_FORK_EXPORT_MODULE_STATE_FINISH_RESTORE, + WPK_FORK_EXPORT_MODULE_STATE_RESTORE, WPK_FORK_EXPORT_MODULE_STATE_SAVE, + WPK_FORK_EXPORT_MODULE_TABLE_STATE_RESTORE, WPK_FORK_EXPORT_MODULE_TABLE_STATE_SAVE, + WPK_FORK_EXPORT_MODULE_THREAD_BOOTSTRAP, WPK_FORK_GLOBAL_CATALOG_EXPORT_PREFIX, + WPK_FORK_IMPORTED_GLOBAL_FLAG_MUTABLE, WPK_FORK_IMPORTED_GLOBAL_FLAG_SHARED, + WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE, WPK_FORK_IMPORTED_GLOBALS_MAGIC, + WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE, WPK_FORK_IMPORTED_GLOBALS_SECTION, + WPK_FORK_IMPORTED_GLOBALS_VERSION, WPK_FORK_IMPORTED_TABLE_FLAG_TABLE64, + WPK_FORK_IMPORTED_TABLES_HEADER_SIZE, WPK_FORK_IMPORTED_TABLES_MAGIC, + WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE, WPK_FORK_IMPORTED_TABLES_SECTION, + WPK_FORK_IMPORTED_TABLES_VERSION, WPK_FORK_MODULE_STATE_DATA_SEGMENT_HEADER_SIZE, + WPK_FORK_MODULE_STATE_ELEMENT_SEGMENT_HEADER_SIZE, WPK_FORK_MODULE_STATE_GLOBAL_HEADER_SIZE, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF, WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, WPK_FORK_MODULE_STATE_GLOBAL_TYPE_F32, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_F64, WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I32, WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I64, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_V128, WPK_FORK_MODULE_STATE_IMPORT_MODULE, + WPK_FORK_MODULE_STATE_IMPORT_RECORD_COMMIT, WPK_FORK_MODULE_STATE_IMPORT_RECORD_FIND, + WPK_FORK_MODULE_STATE_IMPORT_RECORD_RESERVE, WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_COUNT, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_MARK, WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_PAGE, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_GENERATION_ADDR, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_MUTATION_ABORT, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_MUTATION_BEGIN, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_MUTATION_COMMIT, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_RECONCILE, WPK_FORK_MODULE_STATE_IMPORT_TABLE_STATE_OWNED, + WPK_FORK_MODULE_STATE_RECORD_KIND_DATA_SEGMENTS, + WPK_FORK_MODULE_STATE_RECORD_KIND_ELEMENT_SEGMENTS, + WPK_FORK_MODULE_STATE_RECORD_KIND_MUTABLE_GLOBAL, WPK_FORK_MODULE_STATE_RECORD_KIND_TABLE, + WPK_FORK_MODULE_STATE_RECORD_KIND_TABLE_PAGE, + WPK_FORK_MODULE_STATE_TABLE_DESCRIPTOR_PAYLOAD_SIZE, + WPK_FORK_MODULE_STATE_TABLE_FLAG_SPARSE_OVERRIDES, + WPK_FORK_MODULE_STATE_TABLE_PAGE_HEADER_SIZE, WPK_FORK_MODULE_STATE_TABLE_PAGE_SHIFT, + WPK_FORK_MODULE_STATE_TABLE_RUN_HEADER_SIZE, WPK_FORK_RESUME_IMPORT_TABLE, + WPK_FORK_TABLE_CATALOG_EXPORT_PREFIX, +}; + +use crate::runtime::{ReferenceCodecClass, ReferenceCodecs, Runtime}; + +const TABLE_PAGE_SHIFT: u32 = WPK_FORK_MODULE_STATE_TABLE_PAGE_SHIFT as u32; +const TABLE_PAGE_SIZE: u64 = 1 << TABLE_PAGE_SHIFT; +const GLOBAL_RECIPE_PAYLOAD_SIZE: u32 = 4; +const WASM32_MAX_PAGES: u64 = 1 << 16; + +#[derive(Debug, Clone, Copy)] +struct GlobalState { + id: GlobalId, + owner: u32, + ty: ValType, + restore: bool, +} + +#[derive(Debug, Clone)] +struct ImportedGlobalState { + module: String, + name: String, + import_ordinal: u32, + owner: u32, + ty: ValType, + mutable: bool, + shared: bool, +} + +#[derive(Debug, Clone)] +struct ImportedTableState { + module: String, + name: String, + import_ordinal: u32, + owner: u32, + table64: bool, + ty: RefType, +} + +#[derive(Debug, Clone, Copy)] +struct TableState { + id: TableId, + owner: u32, + table64: bool, + ty: RefType, + baseline_len: u64, + baseline_fingerprint: [u8; 32], + synchronized: bool, +} + +#[derive(Debug, Clone)] +struct ActiveElement { + id: ElementId, + table: TableId, + offset: ConstExpr, + offset_global: GlobalId, + len: u64, +} + +#[derive(Debug, Clone)] +struct ActiveData { + id: DataId, + memory: MemoryId, + offset_global: GlobalId, + len: u64, +} + +/// Original module-instance state captured before the instrumenter adds its +/// private function catalog, imports, globals, and helper functions. +#[derive(Debug, Default)] +pub struct ModuleStatePlan { + /// Every source global, including immutable locals and child-only host + /// bindings, exported under a deterministic private name. + /// + /// WHY: a WebAssembly.Global is the only JavaScript API value that can + /// carry an exnref binding into a fresh instance. The loader records which + /// consumer import aliases which provider cell, then resolves the same + /// cell from this catalog before instantiating the consumer. + global_catalog: Vec<(GlobalId, u32)>, + table_catalog: Vec<(TableId, u32)>, + globals: Vec, + imported_globals: Vec, + imported_tables: Vec, + tables: Vec, + elements: Vec<(ElementId, bool)>, + data: Vec<(DataId, bool)>, + active_elements: Vec, + active_data: Vec, + original_start: Option, + original_functions: Vec, +} + +/// Imported record operations shared by every generated helper. +#[derive(Debug, Clone, Copy)] +pub struct ModuleStateImports { + pub reserve: FunctionId, + pub commit: FunctionId, + pub find: FunctionId, + pub table_dirty_mark: FunctionId, + pub table_dirty_count: FunctionId, + pub table_dirty_page: FunctionId, + pub table_state_owned: FunctionId, + pub table_mutation_begin: FunctionId, + pub table_mutation_commit: FunctionId, + pub table_mutation_abort: FunctionId, + pub table_reconcile: FunctionId, + pub table_generation_addr: GlobalId, +} + +/// Plan all state whose owner is a WebAssembly module activation. +pub fn plan(module: &mut Module) -> ModuleStatePlan { + let import_ordinals: HashMap<_, _> = module + .imports + .iter() + .enumerate() + .map(|(ordinal, import)| { + ( + import.id(), + u32::try_from(ordinal).expect("import ordinal fits u32"), + ) + }) + .collect(); + let mut original_functions: Vec<_> = module + .funcs + .iter() + .filter_map(|func| matches!(func.kind, FunctionKind::Local(_)).then_some(func.id())) + .collect(); + original_functions.sort(); + + let mut globals: Vec<_> = module.globals.iter().map(|global| global.id()).collect(); + globals.sort(); + let global_catalog = globals + .iter() + .copied() + .enumerate() + .map(|(ordinal, id)| { + ( + id, + u32::try_from(ordinal + 1).expect("global owner ordinal fits u32"), + ) + }) + .collect(); + let mut state_globals = Vec::new(); + let mut imported_globals = Vec::new(); + for (ordinal, id) in globals.into_iter().enumerate() { + let global = module.globals.get(id); + let imported = match global.kind { + walrus::GlobalKind::Import(import_id) => Some(module.imports.get(import_id)), + walrus::GlobalKind::Local(_) => None, + }; + if imported_global_is_child_binding(module, global) + || (!global.mutable && imported.is_none()) + { + continue; + } + let owner = u32::try_from(ordinal + 1).expect("global owner ordinal fits u32"); + state_globals.push(GlobalState { + id, + owner, + ty: global.ty, + restore: global.mutable, + }); + if let Some(import) = imported { + // Preserve every declaration, including repeated JS property + // identities. Immutable raw imports are coerced independently by + // their declared Wasm types, so collapsing aliases here can lose + // information even though one import-object property supplies all + // declarations. + imported_globals.push(ImportedGlobalState { + module: import.module.to_owned(), + name: import.name.to_owned(), + import_ordinal: import_ordinals[&import.id()], + owner, + ty: global.ty, + mutable: global.mutable, + shared: global.shared, + }); + } + } + + let mut element_ids: Vec<_> = module.elements.iter().map(|elem| elem.id()).collect(); + element_ids.sort(); + let mut elements = Vec::with_capacity(element_ids.len()); + let mut active_elements = Vec::new(); + for id in element_ids { + let initially_dropped = !matches!(module.elements.get(id).kind, ElementKind::Passive); + elements.push((id, initially_dropped)); + let active = match &module.elements.get(id).kind { + ElementKind::Active { table, offset } => Some(( + *table, + offset.clone(), + match &module.elements.get(id).items { + ElementItems::Functions(items) => items.len() as u64, + ElementItems::Expressions(_, items) => items.len() as u64, + }, + )), + _ => None, + }; + if let Some((table, offset, len)) = active { + let offset_global = module.globals.add_local( + if module.tables.get(table).table64 { + ValType::I64 + } else { + ValType::I32 + }, + false, + false, + offset.clone(), + ); + module.elements.get_mut(id).kind = ElementKind::Passive; + active_elements.push(ActiveElement { + id, + table, + offset, + offset_global, + len, + }); + } + } + + let mut data_ids: Vec<_> = module.data.iter().map(|data| data.id()).collect(); + data_ids.sort(); + let mut data = Vec::with_capacity(data_ids.len()); + let mut active_data = Vec::new(); + for id in data_ids { + let initially_dropped = !matches!(module.data.get(id).kind, DataKind::Passive); + data.push((id, initially_dropped)); + let active = match &module.data.get(id).kind { + DataKind::Active { memory, offset } => Some(( + *memory, + offset.clone(), + module.data.get(id).value.len() as u64, + )), + DataKind::Passive => None, + }; + if let Some((memory, offset, len)) = active { + let offset_global = module.globals.add_local( + if module.memories.get(memory).memory64 { + ValType::I64 + } else { + ValType::I32 + }, + false, + false, + offset.clone(), + ); + module.data.get_mut(id).kind = DataKind::Passive; + active_data.push(ActiveData { + id, + memory, + offset_global, + len, + }); + } + } + + let mut table_ids: Vec<_> = module.tables.iter().map(|table| table.id()).collect(); + table_ids.sort(); + let runtime_mutated_tables = collect_source_mutated_tables(module, &original_functions); + let process_indirect_tables: HashSet<_> = + if crate::call_graph::has_dynamic_linker_imports(module) { + module + .exports + .iter() + .filter_map(|export| { + (export.name == "__indirect_function_table") + .then_some(export.item) + .and_then(|item| match item { + ExportItem::Table(table) => Some(table), + _ => None, + }) + }) + .collect() + } else { + HashSet::new() + }; + let table_catalog = table_ids + .iter() + .copied() + .enumerate() + .map(|(ordinal, id)| { + ( + id, + u32::try_from(ordinal + 1).expect("table owner ordinal fits u32"), + ) + }) + .collect(); + let mut imported_tables = Vec::new(); + let tables = table_ids + .into_iter() + .enumerate() + .filter_map(|(ordinal, id)| { + let table = module.tables.get(id); + if imported_table_is_resume_binding(module, table) { + return None; + } + let owner = u32::try_from(ordinal + 1).expect("table owner ordinal fits u32"); + if let Some(import_id) = table.import { + let import = module.imports.get(import_id); + imported_tables.push(ImportedTableState { + module: import.module.to_owned(), + name: import.name.to_owned(), + import_ordinal: import_ordinals[&import.id()], + owner, + table64: table.table64, + ty: table.element_ty, + }); + } + Some(TableState { + id, + owner, + table64: table.table64, + ty: table.element_ty, + baseline_len: table.initial, + baseline_fingerprint: table_baseline_fingerprint( + module, + id, + owner, + &active_elements, + ), + // WHY: a local table with no runtime writer and no host-owned + // process-table role is fully reconstructed by its declared + // minimum plus static element initializers. Synchronizing it + // would add a generation fence to ordinary table reads while + // carrying no state that can differ between Workers. + // + // Imported tables stay synchronized because another + // activation can mutate the aliased physical table. Kandelo's + // dynamic linker mutates only the exact process indirect + // table, and only an artifact importing its dlopen surface can + // activate that writer. wasm-ld exports this table broadly, + // so its name alone is not evidence of mutable state. + synchronized: table.import.is_some() + || process_indirect_tables.contains(&id) + || runtime_mutated_tables.contains(&id), + }) + }) + .collect(); + + ModuleStatePlan { + global_catalog, + table_catalog, + globals: state_globals, + imported_globals, + imported_tables, + tables, + elements, + data, + active_elements, + active_data, + original_start: module.start.take(), + original_functions, + } +} + +fn table_baseline_fingerprint( + module: &Module, + table_id: TableId, + owner: u32, + active_elements: &[ActiveElement], +) -> [u8; 32] { + let table = module.tables.get(table_id); + let mut hasher = Sha256::new(); + hasher.update(b"kandelo-kfms-table-baseline-v1\0"); + hasher.update(owner.to_le_bytes()); + hasher.update([u8::from(table.table64)]); + hasher.update(table.initial.to_le_bytes()); + hasher.update(table.maximum.unwrap_or(u64::MAX).to_le_bytes()); + hasher.update(format!("{:?}", table.element_ty).as_bytes()); + hasher.update(format!("{:?}", table.init).as_bytes()); + for active in active_elements + .iter() + .filter(|active| active.table == table_id) + { + hasher.update(format!("{:?}", active.offset).as_bytes()); + hasher.update(active.len.to_le_bytes()); + hasher.update(format!("{:?}", module.elements.get(active.id).items).as_bytes()); + } + hasher.finalize().into() +} + +/// Return the process-memory view used to exchange KFMS record payloads with +/// the host, adding Kandelo's standard side-module memory import when the +/// original module has no memory. +/// +/// WHY: a module without guest loads/stores can still own mutable globals and +/// tables. The record callbacks return addresses in the process memory copied +/// to a fork child, so a private local memory would write the same numeric +/// address in the wrong allocation. `env.memory` gives every module activation +/// the same staging address space without imposing an artifact-shape failure. +pub fn ensure_staging_memory(module: &mut Module) -> MemoryId { + if let Some(memory) = module.memories.iter().next() { + return memory.id(); + } + let (memory, _) = module.add_import_memory( + "env", + "memory", + true, + false, + 0, + Some(WASM32_MAX_PAGES), + None, + ); + memory +} + +fn imported_global_is_child_binding(module: &Module, global: &walrus::Global) -> bool { + let walrus::GlobalKind::Import(import_id) = global.kind else { + return false; + }; + let import = module.imports.get(import_id); + import.module == "env" && import.name == "__channel_base" +} + +fn imported_table_is_resume_binding(module: &Module, table: &walrus::Table) -> bool { + let Some(import_id) = table.import else { + return false; + }; + let import = module.imports.get(import_id); + import.module == WPK_FORK_MODULE_STATE_IMPORT_MODULE + && import.name == WPK_FORK_RESUME_IMPORT_TABLE +} + +fn scalar_size(ty: ValType) -> u32 { + match ty { + ValType::I32 | ValType::F32 => 4, + ValType::I64 | ValType::F64 => 8, + ValType::V128 => 16, + ValType::Ref(_) => unreachable!("reference globals store a recipe id"), + } +} + +fn scalar_align(ty: ValType) -> u32 { + scalar_size(ty) +} + +fn scalar_store_kind(ty: ValType) -> StoreKind { + match ty { + ValType::I32 => StoreKind::I32 { atomic: false }, + ValType::I64 => StoreKind::I64 { atomic: false }, + ValType::F32 => StoreKind::F32, + ValType::F64 => StoreKind::F64, + ValType::V128 => StoreKind::V128, + ValType::Ref(_) => unreachable!("reference globals store a recipe id"), + } +} + +fn scalar_load_kind(ty: ValType) -> LoadKind { + match ty { + ValType::I32 => LoadKind::I32 { atomic: false }, + ValType::I64 => LoadKind::I64 { atomic: false }, + ValType::F32 => LoadKind::F32, + ValType::F64 => LoadKind::F64, + ValType::V128 => LoadKind::V128, + ValType::Ref(_) => unreachable!("reference globals load a recipe id"), + } +} + +fn global_type_code(ty: ValType, class: Option) -> u8 { + match (ty, class) { + (ValType::I32, None) => WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I32, + (ValType::I64, None) => WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I64, + (ValType::F32, None) => WPK_FORK_MODULE_STATE_GLOBAL_TYPE_F32, + (ValType::F64, None) => WPK_FORK_MODULE_STATE_GLOBAL_TYPE_F64, + (ValType::V128, None) => WPK_FORK_MODULE_STATE_GLOBAL_TYPE_V128, + (ValType::Ref(_), Some(ReferenceCodecClass::Func)) => { + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF + } + (ValType::Ref(_), Some(ReferenceCodecClass::Extern)) => { + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF + } + (ValType::Ref(_), Some(ReferenceCodecClass::Exn)) => { + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF + } + (ValType::Ref(_), Some(ReferenceCodecClass::Any)) => { + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF + } + _ => unreachable!("global type and codec class disagree"), + } +} + +#[derive(Debug)] +struct SegmentTracker { + segments: Vec, + globals: Vec, +} + +#[derive(Debug)] +struct Trackers { + elements: SegmentTracker, + data: SegmentTracker, +} + +/// Inject the KFMS record imports and the two typed guest helpers. +pub fn inject( + module: &mut Module, + runtime: &Runtime, + plan: ModuleStatePlan, +) -> Result { + let memory = module + .memories + .iter() + .next() + .expect("module-state staging memory is injected before the runtime") + .id(); + let codecs = runtime + .reference_codecs + .expect("linked module-state helpers require reference codecs"); + let imports = inject_record_imports(module, runtime.buf_type); + let table_markers = inject_table_dirty_markers(module, imports, &plan); + let (table_reconcile_guard, table_mutation_begin) = + inject_table_reconcile_guard(module, memory, runtime.buf_type, imports); + rewrite_table_mutations( + module, + &plan, + &table_markers, + table_reconcile_guard, + table_mutation_begin, + runtime.resume_table, + ); + let trackers = inject_segment_trackers(module, &plan); + rewrite_segment_drops(module, &trackers); + let bootstrap_done = + module + .globals + .add_local(ValType::I32, true, false, ConstExpr::Value(Value::I32(0))); + let save = emit_save_helper( + module, + memory, + runtime.buf_type, + codecs, + imports, + &plan, + &trackers, + ); + let restore = emit_restore_helper( + module, + memory, + runtime.buf_type, + codecs, + imports, + &plan, + bootstrap_done, + )?; + let finish_restore = emit_finish_restore_helper( + module, + memory, + runtime.buf_type, + codecs, + imports, + &plan, + &trackers, + bootstrap_done, + ); + let table_save = + emit_table_save_helper(module, memory, runtime.buf_type, codecs, imports, &plan); + let table_restore = + emit_table_restore_helper(module, memory, runtime.buf_type, codecs, imports, &plan); + let bootstrap = emit_bootstrap_helper(module, &plan, bootstrap_done)?; + let thread_bootstrap = emit_thread_bootstrap_helper(module, &plan, bootstrap_done)?; + export_helpers( + module, + bootstrap, + thread_bootstrap, + save, + restore, + finish_restore, + table_save, + table_restore, + ); + export_global_catalog(module, &plan.global_catalog); + export_table_catalog(module, &plan.table_catalog); + replace_imported_globals_section(module, &plan.imported_globals); + replace_imported_tables_section(module, &plan.imported_tables); + Ok(bootstrap) +} + +fn export_global_catalog(module: &mut Module, globals: &[(GlobalId, u32)]) { + for (global, owner) in globals { + let name = format!("{WPK_FORK_GLOBAL_CATALOG_EXPORT_PREFIX}{owner}"); + module.exports.add(&name, *global); + } +} + +fn export_table_catalog(module: &mut Module, tables: &[(TableId, u32)]) { + for (table, owner) in tables { + let name = format!("{WPK_FORK_TABLE_CATALOG_EXPORT_PREFIX}{owner}"); + module.exports.add(&name, *table); + } +} + +fn replace_imported_globals_section(module: &mut Module, globals: &[ImportedGlobalState]) { + loop { + let existing = module + .customs + .iter() + .find(|(_, section)| section.name() == WPK_FORK_IMPORTED_GLOBALS_SECTION) + .map(|(id, _)| id); + let Some(existing) = existing else { break }; + module.customs.delete(existing); + } + + let records: Vec<_> = globals + .iter() + .map(|global| { + let class = match global.ty { + ValType::Ref(ty) => Some(ReferenceCodecClass::of(module, ty)), + _ => None, + }; + let module_len = + u32::try_from(global.module.len()).expect("Wasm import module name fits u32"); + let name_len = u32::try_from(global.name.len()).expect("Wasm import name fits u32"); + let record_size = + u32::from(WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE) + module_len + name_len; + ( + global, + global_type_code(global.ty, class), + module_len, + name_len, + record_size, + ) + }) + .collect(); + let capacity = usize::from(WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE) + + records + .iter() + .map(|record| usize::try_from(record.4).unwrap()) + .sum::(); + let mut data = Vec::with_capacity(capacity); + data.extend_from_slice(&WPK_FORK_IMPORTED_GLOBALS_MAGIC); + data.extend_from_slice(&WPK_FORK_IMPORTED_GLOBALS_VERSION.to_le_bytes()); + data.extend_from_slice(&WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE.to_le_bytes()); + data.extend_from_slice( + &u32::try_from(records.len()) + .expect("imported global count fits u32") + .to_le_bytes(), + ); + data.extend_from_slice(&0u32.to_le_bytes()); + for (global, type_code, module_len, name_len, record_size) in records { + data.extend_from_slice(&record_size.to_le_bytes()); + data.extend_from_slice(&global.owner.to_le_bytes()); + data.push(type_code); + data.push( + (if global.mutable { + WPK_FORK_IMPORTED_GLOBAL_FLAG_MUTABLE + } else { + 0 + }) | (if global.shared { + WPK_FORK_IMPORTED_GLOBAL_FLAG_SHARED + } else { + 0 + }), + ); + data.extend_from_slice(&0u16.to_le_bytes()); + data.extend_from_slice(&module_len.to_le_bytes()); + data.extend_from_slice(&name_len.to_le_bytes()); + data.extend_from_slice(&global.import_ordinal.to_le_bytes()); + data.extend_from_slice(global.module.as_bytes()); + data.extend_from_slice(global.name.as_bytes()); + } + debug_assert_eq!(data.len(), capacity); + module.customs.add(RawCustomSection { + name: WPK_FORK_IMPORTED_GLOBALS_SECTION.into(), + data, + }); +} + +fn replace_imported_tables_section(module: &mut Module, tables: &[ImportedTableState]) { + loop { + let existing = module + .customs + .iter() + .find(|(_, section)| section.name() == WPK_FORK_IMPORTED_TABLES_SECTION) + .map(|(id, _)| id); + let Some(existing) = existing else { break }; + module.customs.delete(existing); + } + + let records: Vec<_> = tables + .iter() + .map(|table| { + let type_code = global_type_code( + ValType::Ref(table.ty), + Some(ReferenceCodecClass::of(module, table.ty)), + ); + let module_len = + u32::try_from(table.module.len()).expect("Wasm import module name fits u32"); + let name_len = u32::try_from(table.name.len()).expect("Wasm import name fits u32"); + let record_size = + u32::from(WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE) + module_len + name_len; + (table, type_code, module_len, name_len, record_size) + }) + .collect(); + let capacity = usize::from(WPK_FORK_IMPORTED_TABLES_HEADER_SIZE) + + records + .iter() + .map(|record| usize::try_from(record.4).unwrap()) + .sum::(); + let mut data = Vec::with_capacity(capacity); + data.extend_from_slice(&WPK_FORK_IMPORTED_TABLES_MAGIC); + data.extend_from_slice(&WPK_FORK_IMPORTED_TABLES_VERSION.to_le_bytes()); + data.extend_from_slice(&WPK_FORK_IMPORTED_TABLES_HEADER_SIZE.to_le_bytes()); + data.extend_from_slice( + &u32::try_from(records.len()) + .expect("imported table count fits u32") + .to_le_bytes(), + ); + data.extend_from_slice(&0u32.to_le_bytes()); + for (table, type_code, module_len, name_len, record_size) in records { + data.extend_from_slice(&record_size.to_le_bytes()); + data.extend_from_slice(&table.owner.to_le_bytes()); + data.push(type_code); + data.push(if table.table64 { + WPK_FORK_IMPORTED_TABLE_FLAG_TABLE64 + } else { + 0 + }); + data.extend_from_slice(&0u16.to_le_bytes()); + data.extend_from_slice(&module_len.to_le_bytes()); + data.extend_from_slice(&name_len.to_le_bytes()); + data.extend_from_slice(&table.import_ordinal.to_le_bytes()); + data.extend_from_slice(table.module.as_bytes()); + data.extend_from_slice(table.name.as_bytes()); + } + debug_assert_eq!(data.len(), capacity); + module.customs.add(RawCustomSection { + name: WPK_FORK_IMPORTED_TABLES_SECTION.into(), + data, + }); +} + +fn export_helpers( + module: &mut Module, + bootstrap: FunctionId, + thread_bootstrap: FunctionId, + save: FunctionId, + restore: FunctionId, + finish_restore: FunctionId, + table_save: FunctionId, + table_restore: FunctionId, +) { + module + .exports + .add(WPK_FORK_EXPORT_MODULE_BOOTSTRAP, bootstrap); + module + .exports + .add(WPK_FORK_EXPORT_MODULE_THREAD_BOOTSTRAP, thread_bootstrap); + module.exports.add(WPK_FORK_EXPORT_MODULE_STATE_SAVE, save); + module + .exports + .add(WPK_FORK_EXPORT_MODULE_STATE_RESTORE, restore); + module + .exports + .add(WPK_FORK_EXPORT_MODULE_STATE_FINISH_RESTORE, finish_restore); + module + .exports + .add(WPK_FORK_EXPORT_MODULE_TABLE_STATE_SAVE, table_save); + module + .exports + .add(WPK_FORK_EXPORT_MODULE_TABLE_STATE_RESTORE, table_restore); + module.funcs.get_mut(bootstrap).name = Some(WPK_FORK_EXPORT_MODULE_BOOTSTRAP.into()); + module.funcs.get_mut(thread_bootstrap).name = + Some(WPK_FORK_EXPORT_MODULE_THREAD_BOOTSTRAP.into()); + module.funcs.get_mut(save).name = Some(WPK_FORK_EXPORT_MODULE_STATE_SAVE.into()); + module.funcs.get_mut(restore).name = Some(WPK_FORK_EXPORT_MODULE_STATE_RESTORE.into()); + module.funcs.get_mut(finish_restore).name = + Some(WPK_FORK_EXPORT_MODULE_STATE_FINISH_RESTORE.into()); + module.funcs.get_mut(table_save).name = Some(WPK_FORK_EXPORT_MODULE_TABLE_STATE_SAVE.into()); + module.funcs.get_mut(table_restore).name = + Some(WPK_FORK_EXPORT_MODULE_TABLE_STATE_RESTORE.into()); +} + +fn inject_record_imports(module: &mut Module, ptr_ty: ValType) -> ModuleStateImports { + let reserve_ty = module.types.add( + &[ValType::I32, ValType::I32, ValType::I32, ptr_ty], + &[ptr_ty], + ); + let commit_ty = module.types.add(&[ptr_ty], &[]); + let find_ty = module.types.add( + &[ValType::I32, ValType::I32, ValType::I32, ValType::I32], + &[ptr_ty], + ); + let table_dirty_mark_ty = module + .types + .add(&[ValType::I32, ValType::I64, ValType::I64], &[]); + let table_dirty_count_ty = module.types.add(&[ValType::I32], &[ValType::I32]); + let table_dirty_page_ty = module + .types + .add(&[ValType::I32, ValType::I32], &[ValType::I64]); + let table_state_owned_ty = module.types.add(&[ValType::I32], &[ValType::I32]); + let table_mutation_begin_ty = module.types.add(&[], &[ValType::I64]); + let table_mutation_commit_ty = module + .types + .add(&[ValType::I32, ValType::I64, ValType::I64], &[]); + let table_mutation_abort_ty = module.types.add(&[], &[]); + let table_reconcile_ty = module.types.add(&[], &[ValType::I64]); + let (reserve, _) = module.add_import_func( + WPK_FORK_MODULE_STATE_IMPORT_MODULE, + WPK_FORK_MODULE_STATE_IMPORT_RECORD_RESERVE, + reserve_ty, + ); + let (commit, _) = module.add_import_func( + WPK_FORK_MODULE_STATE_IMPORT_MODULE, + WPK_FORK_MODULE_STATE_IMPORT_RECORD_COMMIT, + commit_ty, + ); + let (find, _) = module.add_import_func( + WPK_FORK_MODULE_STATE_IMPORT_MODULE, + WPK_FORK_MODULE_STATE_IMPORT_RECORD_FIND, + find_ty, + ); + let (table_dirty_count, _) = module.add_import_func( + WPK_FORK_MODULE_STATE_IMPORT_MODULE, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_COUNT, + table_dirty_count_ty, + ); + let (table_dirty_mark, _) = module.add_import_func( + WPK_FORK_MODULE_STATE_IMPORT_MODULE, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_MARK, + table_dirty_mark_ty, + ); + let (table_dirty_page, _) = module.add_import_func( + WPK_FORK_MODULE_STATE_IMPORT_MODULE, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_PAGE, + table_dirty_page_ty, + ); + let (table_state_owned, _) = module.add_import_func( + WPK_FORK_MODULE_STATE_IMPORT_MODULE, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_STATE_OWNED, + table_state_owned_ty, + ); + let (table_mutation_begin, _) = module.add_import_func( + WPK_FORK_MODULE_STATE_IMPORT_MODULE, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_MUTATION_BEGIN, + table_mutation_begin_ty, + ); + let (table_mutation_commit, _) = module.add_import_func( + WPK_FORK_MODULE_STATE_IMPORT_MODULE, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_MUTATION_COMMIT, + table_mutation_commit_ty, + ); + let (table_mutation_abort, _) = module.add_import_func( + WPK_FORK_MODULE_STATE_IMPORT_MODULE, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_MUTATION_ABORT, + table_mutation_abort_ty, + ); + let (table_reconcile, _) = module.add_import_func( + WPK_FORK_MODULE_STATE_IMPORT_MODULE, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_RECONCILE, + table_reconcile_ty, + ); + let (table_generation_addr, _) = module.add_import_global( + WPK_FORK_MODULE_STATE_IMPORT_MODULE, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_GENERATION_ADDR, + ValType::I64, + false, + false, + ); + ModuleStateImports { + reserve, + commit, + find, + table_dirty_mark, + table_dirty_count, + table_dirty_page, + table_state_owned, + table_mutation_begin, + table_mutation_commit, + table_mutation_abort, + table_reconcile, + table_generation_addr, + } +} + +#[derive(Debug, Clone, Copy)] +struct TableDirtyMarker { + mark: FunctionId, + grow: FunctionId, +} + +fn inject_table_dirty_markers( + module: &mut Module, + imports: ModuleStateImports, + plan: &ModuleStatePlan, +) -> HashMap { + let mut markers = HashMap::new(); + for table in plan.tables.iter().filter(|table| table.synchronized) { + let last_page = + module + .globals + .add_local(ValType::I64, true, false, ConstExpr::Value(Value::I64(-1))); + let start = module.locals.add(ValType::I64); + let count = module.locals.add(ValType::I64); + let first_page = module.locals.add(ValType::I64); + let last_page_local = module.locals.add(ValType::I64); + let mut mark_builder = + FunctionBuilder::new(&mut module.types, &[ValType::I64, ValType::I64], &[]); + { + let mut body = mark_builder.func_body(); + body.local_get(count) + .i64_const(0) + .binop(BinaryOp::I64Ne) + .if_else( + None, + |nonempty| { + nonempty + .local_get(start) + .i64_const(i64::from(TABLE_PAGE_SHIFT)) + .binop(BinaryOp::I64ShrU) + .local_set(first_page) + .local_get(start) + .local_get(count) + .binop(BinaryOp::I64Add) + .i64_const(1) + .binop(BinaryOp::I64Sub) + .i64_const(i64::from(TABLE_PAGE_SHIFT)) + .binop(BinaryOp::I64ShrU) + .local_set(last_page_local) + .local_get(first_page) + .local_get(last_page_local) + .binop(BinaryOp::I64Eq) + .global_get(last_page) + .local_get(last_page_local) + .binop(BinaryOp::I64Eq) + .binop(BinaryOp::I32And) + .unop(UnaryOp::I32Eqz) + .if_else( + None, + |uncached| { + uncached + .i32_const(table.owner as i32) + .local_get(first_page) + .local_get(last_page_local) + .local_get(first_page) + .binop(BinaryOp::I64Sub) + .i64_const(1) + .binop(BinaryOp::I64Add) + .call(imports.table_dirty_mark) + .local_get(last_page_local) + .global_set(last_page); + }, + |_| {}, + ) + // WHY: dirty-page caching is sufficient for one + // later fork capture, but another pthread owns a + // different Table object and may consume this + // mutation immediately. Commit while the process + // writer lock is still held. + .i32_const(table.owner as i32) + .local_get(start) + .local_get(count) + .call(imports.table_mutation_commit); + }, + |empty| { + // A zero-length fill/copy/init/grow has no state to + // publish, but its pre-op reconciliation still owns + // one writer-lock depth that must be balanced. + empty.call(imports.table_mutation_abort); + }, + ); + } + let mark = mark_builder.finish(vec![start, count], &mut module.funcs); + + let old = module.locals.add(ValType::I64); + let delta = module.locals.add(ValType::I64); + let failed = if table.table64 { + u64::MAX + } else { + u64::from(u32::MAX) + }; + let mut grow_builder = + FunctionBuilder::new(&mut module.types, &[ValType::I64, ValType::I64], &[]); + { + let mut body = grow_builder.func_body(); + body.local_get(old) + .i64_const(failed as i64) + .binop(BinaryOp::I64Ne) + .if_else( + None, + |succeeded| { + succeeded.local_get(old).local_get(delta).call(mark); + }, + |failed| { + // table.grow reports failure instead of trapping. End + // the mutation transaction without publishing state. + failed.call(imports.table_mutation_abort); + }, + ); + } + let grow = grow_builder.finish(vec![old, delta], &mut module.funcs); + markers.insert(table.id, TableDirtyMarker { mark, grow }); + } + markers +} + +fn inject_table_reconcile_guard( + module: &mut Module, + memory: MemoryId, + ptr_ty: ValType, + imports: ModuleStateImports, +) -> (FunctionId, FunctionId) { + let last_generation = + module + .globals + .add_local(ValType::I64, true, false, ConstExpr::Value(Value::I64(0))); + let memory_is_shared = module.memories.get(memory).shared; + let mut builder = FunctionBuilder::new(&mut module.types, &[], &[]); + { + let mut body = builder.func_body(); + body.global_get(imports.table_generation_addr); + if ptr_ty == ValType::I32 { + body.unop(UnaryOp::I32WrapI64); + } + body.load( + memory, + LoadKind::I64 { + // Atomic loads are required for cross-Worker publication. + // Keep standalone/unshared test modules valid: there is no + // peer Agent in that shape, so an ordinary aligned load is + // already race-free. + atomic: memory_is_shared, + }, + MemArg { + align: 8, + offset: 0, + }, + ) + .global_get(last_generation) + .binop(BinaryOp::I64Ne) + .if_else( + None, + |changed| { + // The host returns the exact generation it applied. Do + // not reread the shared fence here: a writer may publish a + // newer generation after reconcile returns, and caching + // that unapplied value would skip the next guard. + changed + .call(imports.table_reconcile) + .global_set(last_generation); + }, + |_| {}, + ); + } + let guard = builder.finish(Vec::new(), &mut module.funcs); + module.funcs.get_mut(guard).name = Some("__wpk_fork_table_generation_guard".into()); + let mut mutation_builder = FunctionBuilder::new(&mut module.types, &[], &[]); + mutation_builder + .func_body() + .call(imports.table_mutation_begin) + .global_set(last_generation); + let mutation_begin = mutation_builder.finish(Vec::new(), &mut module.funcs); + module.funcs.get_mut(mutation_begin).name = Some("__wpk_fork_table_mutation_begin".into()); + (guard, mutation_begin) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum TableOperation { + Set(TableId), + Fill(TableId), + Copy { src: TableId, dst: TableId }, + Init { table: TableId, elem: ElementId }, + Grow(TableId), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum TableConsumer { + Get(TableId), + Size(TableId), + CallIndirect { table: TableId, ty: TypeId }, + ReturnCallIndirect { table: TableId, ty: TypeId }, +} + +fn rewrite_table_mutations( + module: &mut Module, + plan: &ModuleStatePlan, + markers: &HashMap, + reconcile_guard: FunctionId, + mutation_begin: FunctionId, + resume_table: Option, +) { + let table_states: HashMap<_, _> = plan.tables.iter().map(|table| (table.id, *table)).collect(); + let synchronized_tables: HashSet<_> = plan + .tables + .iter() + .filter(|table| table.synchronized) + .map(|table| table.id) + .collect(); + let mut operations = HashSet::new(); + let mut consumers = HashSet::new(); + for function in &plan.original_functions { + let FunctionKind::Local(local) = &module.funcs.get(*function).kind else { + continue; + }; + collect_table_operations( + local, + local.entry_block(), + resume_table, + &synchronized_tables, + &mut operations, + &mut consumers, + ); + } + let helpers: HashMap<_, _> = operations + .into_iter() + .map(|operation| { + let helper = emit_table_operation_helper( + module, + &table_states, + markers, + mutation_begin, + operation, + ); + (operation, helper) + }) + .collect(); + let consumer_helpers: HashMap<_, _> = consumers + .into_iter() + .map(|consumer| { + let helper = + emit_table_consumer_helper(module, &table_states, reconcile_guard, consumer); + (consumer, helper) + }) + .collect(); + for function in &plan.original_functions { + let FunctionKind::Local(local) = &mut module.funcs.get_mut(*function).kind else { + continue; + }; + rewrite_table_mutation_seq( + local, + local.entry_block(), + &helpers, + &consumer_helpers, + resume_table, + false, + ); + } + + // Fork call-transport helpers are emitted after module_state::plan, so + // they are not in original_functions. Guard them at entry, before their + // parameters are reloaded for the one indirect call, and leave that call + // in place to avoid reintroducing an operand-stack scratch local. + let transport_helpers: Vec<_> = module + .funcs + .iter() + .filter(|function| { + function + .name + .as_deref() + .is_some_and(|name| name.starts_with("__wpk_fork_unwind_transport_indirect_")) + && matches!(function.kind, FunctionKind::Local(_)) + }) + .map(|function| function.id()) + .collect(); + for function in transport_helpers { + let FunctionKind::Local(local) = &module.funcs.get(function).kind else { + unreachable!() + }; + let mut transport_operations = HashSet::new(); + let mut transport_consumers = HashSet::new(); + collect_table_operations( + local, + local.entry_block(), + resume_table, + &synchronized_tables, + &mut transport_operations, + &mut transport_consumers, + ); + let guard_at_entry = !transport_consumers.is_empty(); + let FunctionKind::Local(local) = &mut module.funcs.get_mut(function).kind else { + unreachable!() + }; + let entry = local.entry_block(); + if guard_at_entry { + let loc = local + .block(entry) + .instrs + .first() + .map(|(_, loc)| *loc) + .unwrap_or_default(); + local.block_mut(entry).instrs.insert( + 0, + ( + Call { + func: reconcile_guard, + } + .into(), + loc, + ), + ); + } + rewrite_table_mutation_seq( + local, + entry, + &helpers, + &consumer_helpers, + resume_table, + guard_at_entry, + ); + } +} + +fn collect_table_operations( + local: &LocalFunction, + seq: InstrSeqId, + resume_table: Option, + synchronized_tables: &HashSet, + operations: &mut HashSet, + consumers: &mut HashSet, +) { + for (instr, _) in &local.block(seq).instrs { + for child in nested_seqs(instr) { + collect_table_operations( + local, + child, + resume_table, + synchronized_tables, + operations, + consumers, + ); + } + let operation = match instr { + Instr::TableSet(set) => Some(TableOperation::Set(set.table)), + Instr::TableFill(fill) => Some(TableOperation::Fill(fill.table)), + Instr::TableCopy(copy) => Some(TableOperation::Copy { + src: copy.src, + dst: copy.dst, + }), + Instr::TableInit(init) => Some(TableOperation::Init { + table: init.table, + elem: init.elem, + }), + Instr::TableGrow(grow) => Some(TableOperation::Grow(grow.table)), + _ => None, + }; + if let Some(operation) = operation { + operations.insert(operation); + } + let consumer = match instr { + Instr::TableGet(get) + if Some(get.table) != resume_table && synchronized_tables.contains(&get.table) => + { + Some(TableConsumer::Get(get.table)) + } + Instr::TableSize(size) + if Some(size.table) != resume_table + && synchronized_tables.contains(&size.table) => + { + Some(TableConsumer::Size(size.table)) + } + Instr::CallIndirect(call) + if Some(call.table) != resume_table + && synchronized_tables.contains(&call.table) => + { + Some(TableConsumer::CallIndirect { + table: call.table, + ty: call.ty, + }) + } + Instr::ReturnCallIndirect(call) + if Some(call.table) != resume_table + && synchronized_tables.contains(&call.table) => + { + Some(TableConsumer::ReturnCallIndirect { + table: call.table, + ty: call.ty, + }) + } + _ => None, + }; + if let Some(consumer) = consumer { + consumers.insert(consumer); + } + } +} + +fn collect_source_mutated_tables(module: &Module, functions: &[FunctionId]) -> HashSet { + fn visit(local: &LocalFunction, seq: InstrSeqId, mutated: &mut HashSet) { + for (instr, _) in &local.block(seq).instrs { + for child in nested_seqs(instr) { + visit(local, child, mutated); + } + match instr { + Instr::TableSet(set) => { + mutated.insert(set.table); + } + Instr::TableFill(fill) => { + mutated.insert(fill.table); + } + Instr::TableCopy(copy) => { + mutated.insert(copy.dst); + } + Instr::TableInit(init) => { + mutated.insert(init.table); + } + Instr::TableGrow(grow) => { + mutated.insert(grow.table); + } + _ => {} + } + } + } + + let mut mutated = HashSet::new(); + for function in functions { + let FunctionKind::Local(local) = &module.funcs.get(*function).kind else { + continue; + }; + visit(local, local.entry_block(), &mut mutated); + } + mutated +} + +fn emit_table_operation_helper( + module: &mut Module, + tables: &HashMap, + markers: &HashMap, + mutation_begin: FunctionId, + operation: TableOperation, +) -> FunctionId { + let (function, name) = match operation { + TableOperation::Set(table_id) => { + let table = tables[&table_id]; + let marker = markers[&table_id]; + let index_ty = table_index_type(table); + let index = module.locals.add(index_ty); + let reference = module.locals.add(ValType::Ref(table.ty)); + let mut builder = + FunctionBuilder::new(&mut module.types, &[index_ty, ValType::Ref(table.ty)], &[]); + { + let mut body = builder.func_body(); + body.call(mutation_begin) + .local_get(index) + .local_get(reference) + .instr(TableSet { table: table_id }); + builder_index_as_i64(&mut body, index_ty, index); + body.i64_const(1).call(marker.mark); + } + ( + builder.finish(vec![index, reference], &mut module.funcs), + format!("__wpk_fork_table_set_{}", table.owner), + ) + } + TableOperation::Fill(table_id) => { + let table = tables[&table_id]; + let marker = markers[&table_id]; + let index_ty = table_index_type(table); + let dst = module.locals.add(index_ty); + let reference = module.locals.add(ValType::Ref(table.ty)); + let count = module.locals.add(index_ty); + let mut builder = FunctionBuilder::new( + &mut module.types, + &[index_ty, ValType::Ref(table.ty), index_ty], + &[], + ); + { + let mut body = builder.func_body(); + body.call(mutation_begin) + .local_get(dst) + .local_get(reference) + .local_get(count) + .instr(TableFill { table: table_id }); + builder_index_as_i64(&mut body, index_ty, dst); + builder_index_as_i64(&mut body, index_ty, count); + body.call(marker.mark); + } + ( + builder.finish(vec![dst, reference, count], &mut module.funcs), + format!("__wpk_fork_table_fill_{}", table.owner), + ) + } + TableOperation::Copy { src, dst } => { + let src_table = tables[&src]; + let dst_table = tables[&dst]; + let marker = markers[&dst]; + let src_ty = table_index_type(src_table); + let dst_ty = table_index_type(dst_table); + let count_ty = if src_ty == ValType::I32 || dst_ty == ValType::I32 { + ValType::I32 + } else { + ValType::I64 + }; + let dst_index = module.locals.add(dst_ty); + let src_index = module.locals.add(src_ty); + let count = module.locals.add(count_ty); + let mut builder = + FunctionBuilder::new(&mut module.types, &[dst_ty, src_ty, count_ty], &[]); + { + let mut body = builder.func_body(); + body.call(mutation_begin) + .local_get(dst_index) + .local_get(src_index) + .local_get(count) + .instr(TableCopy { src, dst }); + builder_index_as_i64(&mut body, dst_ty, dst_index); + builder_index_as_i64(&mut body, count_ty, count); + body.call(marker.mark); + } + ( + builder.finish(vec![dst_index, src_index, count], &mut module.funcs), + format!( + "__wpk_fork_table_copy_{}_from_{}", + dst_table.owner, src_table.owner + ), + ) + } + TableOperation::Init { + table: table_id, + elem, + } => { + let table = tables[&table_id]; + let marker = markers[&table_id]; + let index_ty = table_index_type(table); + let dst = module.locals.add(index_ty); + let src = module.locals.add(ValType::I32); + let count = module.locals.add(ValType::I32); + let mut builder = FunctionBuilder::new( + &mut module.types, + &[index_ty, ValType::I32, ValType::I32], + &[], + ); + { + let mut body = builder.func_body(); + body.call(mutation_begin) + .local_get(dst) + .local_get(src) + .local_get(count) + .instr(TableInit { + table: table_id, + elem, + }); + builder_index_as_i64(&mut body, index_ty, dst); + builder_index_as_i64(&mut body, ValType::I32, count); + body.call(marker.mark); + } + ( + builder.finish(vec![dst, src, count], &mut module.funcs), + format!("__wpk_fork_table_init_{}_{}", table.owner, elem.index()), + ) + } + TableOperation::Grow(table_id) => { + let table = tables[&table_id]; + let marker = markers[&table_id]; + let index_ty = table_index_type(table); + let reference = module.locals.add(ValType::Ref(table.ty)); + let delta = module.locals.add(index_ty); + let result = module.locals.add(index_ty); + let mut builder = FunctionBuilder::new( + &mut module.types, + &[ValType::Ref(table.ty), index_ty], + &[index_ty], + ); + { + let mut body = builder.func_body(); + body.call(mutation_begin) + .local_get(reference) + .local_get(delta) + .instr(TableGrow { table: table_id }) + .local_set(result); + builder_index_as_i64(&mut body, index_ty, result); + builder_index_as_i64(&mut body, index_ty, delta); + body.call(marker.grow).local_get(result); + } + ( + builder.finish(vec![reference, delta], &mut module.funcs), + format!("__wpk_fork_table_grow_{}", table.owner), + ) + } + }; + module.funcs.get_mut(function).name = Some(name); + function +} + +fn emit_table_consumer_helper( + module: &mut Module, + tables: &HashMap, + reconcile_guard: FunctionId, + consumer: TableConsumer, +) -> FunctionId { + let (function, name) = match consumer { + TableConsumer::Get(table_id) => { + let table = tables[&table_id]; + let index_ty = table_index_type(table); + let index = module.locals.add(index_ty); + let mut builder = + FunctionBuilder::new(&mut module.types, &[index_ty], &[ValType::Ref(table.ty)]); + builder + .func_body() + .call(reconcile_guard) + .local_get(index) + .instr(TableGet { table: table_id }); + ( + builder.finish(vec![index], &mut module.funcs), + format!("__wpk_fork_table_get_{}", table.owner), + ) + } + TableConsumer::Size(table_id) => { + let table = tables[&table_id]; + let index_ty = table_index_type(table); + let mut builder = FunctionBuilder::new(&mut module.types, &[], &[index_ty]); + builder + .func_body() + .call(reconcile_guard) + .instr(TableSize { table: table_id }); + ( + builder.finish(Vec::new(), &mut module.funcs), + format!("__wpk_fork_table_size_{}", table.owner), + ) + } + TableConsumer::CallIndirect { table, ty } => { + let table_state = tables[&table]; + let signature = module.types.get(ty); + let params = signature.params().to_vec(); + let results = signature.results().to_vec(); + let mut args: Vec<_> = params + .iter() + .map(|param| module.locals.add(*param)) + .collect(); + let index_ty = table_index_type(table_state); + let index = module.locals.add(index_ty); + let mut helper_params = params; + helper_params.push(index_ty); + let mut builder = FunctionBuilder::new(&mut module.types, &helper_params, &results); + { + let mut body = builder.func_body(); + body.call(reconcile_guard); + for arg in &args { + body.local_get(*arg); + } + body.local_get(index).instr(CallIndirect { table, ty }); + } + args.push(index); + ( + builder.finish(args, &mut module.funcs), + format!( + "__wpk_fork_table_call_indirect_{}_{}", + table_state.owner, + ty.index() + ), + ) + } + TableConsumer::ReturnCallIndirect { table, ty } => { + let table_state = tables[&table]; + let signature = module.types.get(ty); + let params = signature.params().to_vec(); + let results = signature.results().to_vec(); + let mut args: Vec<_> = params + .iter() + .map(|param| module.locals.add(*param)) + .collect(); + let index_ty = table_index_type(table_state); + let index = module.locals.add(index_ty); + let mut helper_params = params; + helper_params.push(index_ty); + let mut builder = FunctionBuilder::new(&mut module.types, &helper_params, &results); + { + let mut body = builder.func_body(); + body.call(reconcile_guard); + for arg in &args { + body.local_get(*arg); + } + body.local_get(index) + .instr(ReturnCallIndirect { table, ty }); + } + args.push(index); + ( + builder.finish(args, &mut module.funcs), + format!( + "__wpk_fork_table_return_call_indirect_{}_{}", + table_state.owner, + ty.index() + ), + ) + } + }; + module.funcs.get_mut(function).name = Some(name); + function +} + +fn builder_index_as_i64(body: &mut InstrSeqBuilder, ty: ValType, local: LocalId) { + body.local_get(local); + if ty == ValType::I32 { + body.unop(UnaryOp::I64ExtendUI32); + } +} + +fn rewrite_table_mutation_seq( + local: &mut LocalFunction, + seq: InstrSeqId, + helpers: &HashMap, + consumer_helpers: &HashMap, + resume_table: Option, + consumers_guarded_at_entry: bool, +) { + let old = std::mem::take(&mut local.block_mut(seq).instrs); + let mut rewritten = Vec::with_capacity(old.len()); + for (instr, loc) in old { + for child in nested_seqs(&instr) { + rewrite_table_mutation_seq( + local, + child, + helpers, + consumer_helpers, + resume_table, + consumers_guarded_at_entry, + ); + } + match instr { + Instr::TableSet(set) => { + push( + &mut rewritten, + Call { + func: helpers[&TableOperation::Set(set.table)], + }, + loc, + ); + } + Instr::TableFill(fill) => { + push( + &mut rewritten, + Call { + func: helpers[&TableOperation::Fill(fill.table)], + }, + loc, + ); + } + Instr::TableCopy(copy) => { + push( + &mut rewritten, + Call { + func: helpers[&TableOperation::Copy { + src: copy.src, + dst: copy.dst, + }], + }, + loc, + ); + } + Instr::TableInit(init) => { + push( + &mut rewritten, + Call { + func: helpers[&TableOperation::Init { + table: init.table, + elem: init.elem, + }], + }, + loc, + ); + } + Instr::TableGrow(grow) => { + push( + &mut rewritten, + Call { + func: helpers[&TableOperation::Grow(grow.table)], + }, + loc, + ); + } + other @ Instr::CallIndirect(CallIndirect { table, .. }) + if Some(table) == resume_table || consumers_guarded_at_entry => + { + // WHY: this private dispatch table is host-built, immutable, + // and already excluded from module-state ownership. Keeping + // the guard out of resume_peek -> call_indirect also preserves + // the operand-stack shape that Binaryen can lower without a + // per-call-site scratch local. + rewritten.push((other, loc)); + } + other @ Instr::ReturnCallIndirect(ReturnCallIndirect { table, .. }) + if Some(table) == resume_table || consumers_guarded_at_entry => + { + rewritten.push((other, loc)); + } + other @ Instr::TableGet(TableGet { table }) + if Some(table) == resume_table || consumers_guarded_at_entry => + { + rewritten.push((other, loc)); + } + other @ Instr::TableSize(TableSize { table }) + if Some(table) == resume_table || consumers_guarded_at_entry => + { + rewritten.push((other, loc)); + } + Instr::CallIndirect(call) => { + let consumer = TableConsumer::CallIndirect { + table: call.table, + ty: call.ty, + }; + if let Some(&func) = consumer_helpers.get(&consumer) { + push(&mut rewritten, Call { func }, loc); + } else { + rewritten.push((Instr::CallIndirect(call), loc)); + } + } + Instr::ReturnCallIndirect(call) => { + let consumer = TableConsumer::ReturnCallIndirect { + table: call.table, + ty: call.ty, + }; + if let Some(&func) = consumer_helpers.get(&consumer) { + push(&mut rewritten, ReturnCall { func }, loc); + } else { + rewritten.push((Instr::ReturnCallIndirect(call), loc)); + } + } + Instr::TableGet(get) => { + if let Some(&func) = consumer_helpers.get(&TableConsumer::Get(get.table)) { + push(&mut rewritten, Call { func }, loc); + } else { + rewritten.push((Instr::TableGet(get), loc)); + } + } + Instr::TableSize(size) => { + if let Some(&func) = consumer_helpers.get(&TableConsumer::Size(size.table)) { + push(&mut rewritten, Call { func }, loc); + } else { + rewritten.push((Instr::TableSize(size), loc)); + } + } + other => rewritten.push((other, loc)), + } + } + local.block_mut(seq).instrs = rewritten; +} + +fn push>(out: &mut Vec<(Instr, InstrLocId)>, instr: T, loc: InstrLocId) { + out.push((instr.into(), loc)); +} + +fn inject_segment_trackers(module: &mut Module, plan: &ModuleStatePlan) -> Trackers { + fn tracker(module: &mut Module, segments: &[(T, bool)]) -> SegmentTracker { + let mut globals = Vec::with_capacity(segments.len().div_ceil(32)); + for chunk in segments.chunks(32) { + let mut initial = 0u32; + for (bit, (_, dropped)) in chunk.iter().enumerate() { + if *dropped { + initial |= 1 << bit; + } + } + globals.push(module.globals.add_local( + ValType::I32, + true, + false, + ConstExpr::Value(Value::I32(initial as i32)), + )); + } + SegmentTracker { + segments: segments.iter().map(|(id, _)| *id).collect(), + globals, + } + } + + Trackers { + elements: tracker(module, &plan.elements), + data: tracker(module, &plan.data), + } +} + +fn rewrite_segment_drops(module: &mut Module, trackers: &Trackers) { + let element_bits: HashMap<_, _> = trackers + .elements + .segments + .iter() + .enumerate() + .map(|(index, id)| (*id, index)) + .collect(); + let data_bits: HashMap<_, _> = trackers + .data + .segments + .iter() + .enumerate() + .map(|(index, id)| (*id, index)) + .collect(); + let funcs: Vec<_> = module + .funcs + .iter() + .filter_map(|func| matches!(func.kind, FunctionKind::Local(_)).then_some(func.id())) + .collect(); + for func in funcs { + let FunctionKind::Local(local) = &mut module.funcs.get_mut(func).kind else { + unreachable!() + }; + rewrite_drop_seq( + local, + local.entry_block(), + &element_bits, + &trackers.elements.globals, + &data_bits, + &trackers.data.globals, + ); + } +} + +fn rewrite_drop_seq( + local: &mut LocalFunction, + seq: InstrSeqId, + element_bits: &HashMap, + element_globals: &[GlobalId], + data_bits: &HashMap, + data_globals: &[GlobalId], +) { + let old = std::mem::take(&mut local.block_mut(seq).instrs); + let mut rewritten = Vec::with_capacity(old.len()); + for (instr, loc) in old { + for child in nested_seqs(&instr) { + rewrite_drop_seq( + local, + child, + element_bits, + element_globals, + data_bits, + data_globals, + ); + } + let tracked = match &instr { + Instr::ElemDrop(ElemDrop { elem }) => element_bits + .get(elem) + .map(|index| (*index, element_globals)), + Instr::DataDrop(DataDrop { data }) => { + data_bits.get(data).map(|index| (*index, data_globals)) + } + _ => None, + }; + rewritten.push((instr, loc)); + if let Some((index, globals)) = tracked { + let word = globals[index / 32]; + rewritten.push(( + Instr::GlobalGet(walrus::ir::GlobalGet { global: word }), + loc, + )); + rewritten.push(( + Instr::Const(walrus::ir::Const { + value: Value::I32((1u32 << (index % 32)) as i32), + }), + loc, + )); + rewritten.push(( + Instr::Binop(walrus::ir::Binop { + op: BinaryOp::I32Or, + }), + loc, + )); + rewritten.push(( + Instr::GlobalSet(walrus::ir::GlobalSet { global: word }), + loc, + )); + } + } + local.block_mut(seq).instrs = rewritten; +} + +fn nested_seqs(instr: &Instr) -> Vec { + match instr { + Instr::Block(Block { seq }) => vec![*seq], + Instr::Loop(Loop { seq }) => vec![*seq], + Instr::IfElse(IfElse { + consequent, + alternative, + }) => vec![*consequent, *alternative], + Instr::TryTable(TryTable { seq, .. }) => vec![*seq], + Instr::Try(try_) => { + let mut ids = vec![try_.seq]; + for catch in &try_.catches { + match catch { + LegacyCatch::Catch { handler, .. } | LegacyCatch::CatchAll { handler } => { + ids.push(*handler) + } + LegacyCatch::Delegate { .. } => {} + } + } + ids + } + _ => Vec::new(), + } +} + +#[derive(Debug, Clone, Copy)] +struct TableLocals { + payload: LocalId, + len: LocalId, + current_len: LocalId, + page_count: LocalId, + record_page_count: LocalId, + page_ordinal: LocalId, + page_index: LocalId, + page_start_wide: LocalId, + page_start: LocalId, + count: LocalId, + index: LocalId, +} + +fn allocate_table_locals(module: &mut Module, ptr_ty: ValType, table64: bool) -> TableLocals { + let index_ty = if table64 { ValType::I64 } else { ValType::I32 }; + TableLocals { + payload: module.locals.add(ptr_ty), + len: module.locals.add(index_ty), + current_len: module.locals.add(index_ty), + page_count: module.locals.add(ValType::I32), + record_page_count: module.locals.add(ValType::I32), + page_ordinal: module.locals.add(ValType::I32), + page_index: module.locals.add(ValType::I64), + page_start_wide: module.locals.add(ValType::I64), + page_start: module.locals.add(index_ty), + count: module.locals.add(index_ty), + index: module.locals.add(index_ty), + } +} + +fn emit_save_helper( + module: &mut Module, + memory: MemoryId, + ptr_ty: ValType, + codecs: ReferenceCodecs, + imports: ModuleStateImports, + plan: &ModuleStatePlan, + trackers: &Trackers, +) -> FunctionId { + let global_classes: Vec<_> = plan + .globals + .iter() + .map(|global| match global.ty { + ValType::Ref(ty) => Some(ReferenceCodecClass::of(module, ty)), + _ => None, + }) + .collect(); + let synchronized_tables: Vec<_> = plan + .tables + .iter() + .filter(|table| table.synchronized) + .copied() + .collect(); + let table_classes: Vec<_> = synchronized_tables + .iter() + .map(|table| ReferenceCodecClass::of(module, table.ty)) + .collect(); + let table_locals: Vec<_> = synchronized_tables + .iter() + .map(|table| allocate_table_locals(module, ptr_ty, table.table64)) + .collect(); + let payload = module.locals.add(ptr_ty); + let activation = module.locals.add(ValType::I32); + let mut builder = FunctionBuilder::new(&mut module.types, &[ValType::I32], &[]); + { + let mut body = builder.func_body(); + for (global, class) in plan.globals.iter().zip(global_classes) { + let value_size = class + .map(|_| GLOBAL_RECIPE_PAYLOAD_SIZE) + .unwrap_or_else(|| scalar_size(global.ty)); + let payload_size = u32::from(WPK_FORK_MODULE_STATE_GLOBAL_HEADER_SIZE) + value_size; + reserve_static_record( + &mut body, + imports, + ptr_ty, + activation, + WPK_FORK_MODULE_STATE_RECORD_KIND_MUTABLE_GLOBAL, + global.owner, + payload_size, + payload, + ); + body.local_get(payload) + .i32_const(i32::from(global_type_code(global.ty, class))) + .store( + memory, + StoreKind::I32_8 { atomic: false }, + MemArg { + align: 1, + offset: 0, + }, + ) + .local_get(payload) + .i32_const(value_size as i32) + .store( + memory, + StoreKind::I32_8 { atomic: false }, + MemArg { + align: 1, + offset: 1, + }, + ) + .local_get(payload) + .i32_const(0) + .store( + memory, + StoreKind::I32_16 { atomic: false }, + MemArg { + align: 2, + offset: 2, + }, + ) + .local_get(payload) + .i32_const(0) + .store( + memory, + StoreKind::I32 { atomic: false }, + MemArg { + align: 4, + offset: 4, + }, + ); + body.local_get(payload).global_get(global.id); + match class { + Some(class) => { + body.call(class.encoder(codecs)).store( + memory, + StoreKind::I32 { atomic: false }, + MemArg { + align: 4, + offset: u64::from(WPK_FORK_MODULE_STATE_GLOBAL_HEADER_SIZE), + }, + ); + } + None => { + body.store( + memory, + scalar_store_kind(global.ty), + MemArg { + align: scalar_align(global.ty), + offset: u64::from(WPK_FORK_MODULE_STATE_GLOBAL_HEADER_SIZE), + }, + ); + } + } + body.local_get(payload).call(imports.commit); + } + + emit_save_segments( + &mut body, + memory, + ptr_ty, + imports, + activation, + payload, + WPK_FORK_MODULE_STATE_RECORD_KIND_ELEMENT_SEGMENTS, + u32::from(WPK_FORK_MODULE_STATE_ELEMENT_SEGMENT_HEADER_SIZE), + &trackers.elements, + ); + emit_save_segments( + &mut body, + memory, + ptr_ty, + imports, + activation, + payload, + WPK_FORK_MODULE_STATE_RECORD_KIND_DATA_SEGMENTS, + u32::from(WPK_FORK_MODULE_STATE_DATA_SEGMENT_HEADER_SIZE), + &trackers.data, + ); + + for ((table, class), locals) in synchronized_tables + .iter() + .zip(table_classes) + .zip(table_locals) + { + body.i32_const(table.owner as i32) + .call(imports.table_state_owned) + .if_else( + None, + |owned| { + // WHY: imported aliases name one physical Table. Only + // its canonical activation writes sparse state, while + // every alias still contributes mutation marks to the + // shared journal. + emit_save_table( + owned, memory, ptr_ty, codecs, imports, activation, *table, class, + locals, + ); + }, + |_| {}, + ); + } + } + builder.finish(vec![activation], &mut module.funcs) +} + +fn emit_restore_helper( + module: &mut Module, + memory: MemoryId, + ptr_ty: ValType, + codecs: ReferenceCodecs, + imports: ModuleStateImports, + plan: &ModuleStatePlan, + bootstrap_done: GlobalId, +) -> Result { + let global_classes: Vec<_> = plan + .globals + .iter() + .map(|global| match global.ty { + ValType::Ref(ty) => Some(ReferenceCodecClass::of(module, ty)), + _ => None, + }) + .collect(); + let payload = module.locals.add(ptr_ty); + let activation = module.locals.add(ValType::I32); + let mut builder = FunctionBuilder::new(&mut module.types, &[ValType::I32], &[]); + { + let mut body = builder.func_body(); + for (global, class) in plan.globals.iter().zip(global_classes) { + if !global.restore { + continue; + } + find_record( + &mut body, + imports, + activation, + WPK_FORK_MODULE_STATE_RECORD_KIND_MUTABLE_GLOBAL, + global.owner, + 0, + payload, + ); + body.local_get(payload); + match class { + Some(class) => { + body.load( + memory, + LoadKind::I32 { atomic: false }, + MemArg { + align: 4, + offset: u64::from(WPK_FORK_MODULE_STATE_GLOBAL_HEADER_SIZE), + }, + ) + .call(class.decoder(codecs)); + let ValType::Ref(reference) = global.ty else { + unreachable!("reference codec assigned to scalar global") + }; + emit_narrow_reference(&mut body, class, reference); + } + None => { + body.load( + memory, + scalar_load_kind(global.ty), + MemArg { + align: scalar_align(global.ty), + offset: u64::from(WPK_FORK_MODULE_STATE_GLOBAL_HEADER_SIZE), + }, + ); + } + } + body.global_set(global.id); + } + + // WHY: active element offsets can depend on globals. Restore globals + // first and recreate only this activation's deterministic table + // baseline. Sparse overlays are intentionally deferred until every + // activation has replayed its baseline, matching parent + // instantiation order without serializing shared tables per alias. + // Data initialization and the original start are deliberately absent + // because child linear memory was copied. + body.global_get(bootstrap_done) + .unop(UnaryOp::I32Eqz) + .if_else( + None, + |baseline| { + emit_active_element_initializers(baseline, &plan.active_elements, false) + .expect("active element segment length fits u32"); + }, + |_| {}, + ); + + // WHY: reference recipes can contain array.new_data/array.new_elem + // nodes owned by another activation. Keep every passive segment + // physically live until all activations have restored values/tables; + // the finish helper reapplies the parent-visible drop state globally. + body.i32_const(1).global_set(bootstrap_done); + } + Ok(builder.finish(vec![activation], &mut module.funcs)) +} + +fn emit_finish_restore_helper( + module: &mut Module, + memory: MemoryId, + ptr_ty: ValType, + codecs: ReferenceCodecs, + imports: ModuleStateImports, + plan: &ModuleStatePlan, + trackers: &Trackers, + bootstrap_done: GlobalId, +) -> FunctionId { + let synchronized_tables: Vec<_> = plan + .tables + .iter() + .filter(|table| table.synchronized) + .copied() + .collect(); + let table_classes: Vec<_> = synchronized_tables + .iter() + .map(|table| ReferenceCodecClass::of(module, table.ty)) + .collect(); + let table_locals: Vec<_> = synchronized_tables + .iter() + .map(|table| allocate_table_locals(module, ptr_ty, table.table64)) + .collect(); + let payload = module.locals.add(ptr_ty); + let activation = module.locals.add(ValType::I32); + let mut builder = FunctionBuilder::new(&mut module.types, &[ValType::I32], &[]); + { + let mut body = builder.func_body(); + body.global_get(bootstrap_done) + .unop(UnaryOp::I32Eqz) + .if_else( + None, + |invalid| { + // A pre-restore finish would destroy constructor inputs + // before globals/tables had a chance to decode them. + invalid.unreachable(); + }, + |_| {}, + ); + for ((table, class), locals) in synchronized_tables + .iter() + .zip(table_classes) + .zip(table_locals) + { + body.i32_const(table.owner as i32) + .call(imports.table_state_owned) + .if_else( + None, + |owned| { + // WHY: all activation baselines now exist. Reapply the + // final sparse overlay exactly once for the physical + // Table, so later aliases cannot overwrite it. + emit_restore_table( + owned, memory, ptr_ty, codecs, imports, activation, *table, class, + locals, + ); + }, + |_| {}, + ); + } + emit_restore_segments( + &mut body, + memory, + imports, + activation, + payload, + WPK_FORK_MODULE_STATE_RECORD_KIND_ELEMENT_SEGMENTS, + &trackers.elements, + |body, elem| { + body.instr(ElemDrop { elem }); + }, + ); + emit_restore_segments( + &mut body, + memory, + imports, + activation, + payload, + WPK_FORK_MODULE_STATE_RECORD_KIND_DATA_SEGMENTS, + &trackers.data, + |body, data| { + body.instr(DataDrop { data }); + }, + ); + } + builder.finish(vec![activation], &mut module.funcs) +} + +fn emit_table_save_helper( + module: &mut Module, + memory: MemoryId, + ptr_ty: ValType, + codecs: ReferenceCodecs, + imports: ModuleStateImports, + plan: &ModuleStatePlan, +) -> FunctionId { + let synchronized_tables: Vec<_> = plan + .tables + .iter() + .filter(|table| table.synchronized) + .copied() + .collect(); + let table_classes: Vec<_> = synchronized_tables + .iter() + .map(|table| ReferenceCodecClass::of(module, table.ty)) + .collect(); + let table_locals: Vec<_> = synchronized_tables + .iter() + .map(|table| allocate_table_locals(module, ptr_ty, table.table64)) + .collect(); + let activation = module.locals.add(ValType::I32); + let mut builder = FunctionBuilder::new(&mut module.types, &[ValType::I32], &[]); + { + let mut body = builder.func_body(); + for ((table, class), locals) in synchronized_tables + .iter() + .zip(table_classes) + .zip(table_locals) + { + body.i32_const(table.owner as i32) + .call(imports.table_state_owned) + .if_else( + None, + |owned| { + emit_save_table( + owned, memory, ptr_ty, codecs, imports, activation, *table, class, + locals, + ); + }, + |_| {}, + ); + } + } + builder.finish(vec![activation], &mut module.funcs) +} + +fn emit_table_restore_helper( + module: &mut Module, + memory: MemoryId, + ptr_ty: ValType, + codecs: ReferenceCodecs, + imports: ModuleStateImports, + plan: &ModuleStatePlan, +) -> FunctionId { + let synchronized_tables: Vec<_> = plan + .tables + .iter() + .filter(|table| table.synchronized) + .copied() + .collect(); + let table_classes: Vec<_> = synchronized_tables + .iter() + .map(|table| ReferenceCodecClass::of(module, table.ty)) + .collect(); + let table_locals: Vec<_> = synchronized_tables + .iter() + .map(|table| allocate_table_locals(module, ptr_ty, table.table64)) + .collect(); + let activation = module.locals.add(ValType::I32); + let mut builder = FunctionBuilder::new(&mut module.types, &[ValType::I32], &[]); + { + let mut body = builder.func_body(); + for ((table, class), locals) in synchronized_tables + .iter() + .zip(table_classes) + .zip(table_locals) + { + body.i32_const(table.owner as i32) + .call(imports.table_state_owned) + .if_else( + None, + |owned| { + emit_restore_table( + owned, memory, ptr_ty, codecs, imports, activation, *table, class, + locals, + ); + }, + |_| {}, + ); + } + } + builder.finish(vec![activation], &mut module.funcs) +} + +fn emit_bootstrap_helper( + module: &mut Module, + plan: &ModuleStatePlan, + bootstrap_done: GlobalId, +) -> Result { + let mut builder = FunctionBuilder::new(&mut module.types, &[], &[]); + { + let mut body = builder.func_body(); + body.global_get(bootstrap_done) + .unop(UnaryOp::I32Eqz) + .if_else( + None, + |initialize| { + // Native instantiation applies element segments, then data + // segments, then invokes the start function. Keep that + // ordering observable while allowing pthread instances + // and fork children to skip the parent-only phase. + emit_active_element_initializers(initialize, &plan.active_elements, true) + .expect("active element segment length fits u32"); + emit_active_data_initializers(initialize, &plan.active_data) + .expect("active data segment length fits u32"); + initialize.i32_const(1).global_set(bootstrap_done); + if let Some(start) = plan.original_start { + initialize.call(start); + } + }, + |_| {}, + ); + } + Ok(builder.finish(Vec::new(), &mut module.funcs)) +} + +fn emit_thread_bootstrap_helper( + module: &mut Module, + plan: &ModuleStatePlan, + bootstrap_done: GlobalId, +) -> Result { + let mut builder = FunctionBuilder::new(&mut module.types, &[], &[]); + { + let mut body = builder.func_body(); + body.global_get(bootstrap_done) + .unop(UnaryOp::I32Eqz) + .if_else( + None, + |initialize| { + // WHY: pthread instances have instance-local tables but + // share the parent's already-initialized linear memory. + // Recreate and consume only the element baseline; consume + // converted active data without copying or rerunning start. + emit_active_element_initializers(initialize, &plan.active_elements, true) + .expect("active element segment length fits u32"); + for active in &plan.active_data { + initialize.instr(DataDrop { data: active.id }); + } + initialize.i32_const(1).global_set(bootstrap_done); + }, + |_| {}, + ); + } + Ok(builder.finish(Vec::new(), &mut module.funcs)) +} + +fn emit_active_element_initializers( + body: &mut InstrSeqBuilder<'_>, + active_elements: &[ActiveElement], + drop_after: bool, +) -> Result<()> { + for active in active_elements { + // WHY: retaining the original const expression in an immutable + // global makes Walrus/WebAssembly own extended-const semantics. The + // runtime helper reads the already-evaluated index and therefore does + // not impose an instruction whitelist or evaluate the expression more + // than once when active segments are converted to passive segments. + body.global_get(active.offset_global); + body.i32_const(0) + .i32_const( + u32::try_from(active.len) + .map_err(|_| anyhow::anyhow!("element segment length exceeds u32"))? + as i32, + ) + .instr(TableInit { + table: active.table, + elem: active.id, + }); + if drop_after { + body.instr(ElemDrop { elem: active.id }); + } + } + Ok(()) +} + +fn emit_active_data_initializers( + body: &mut InstrSeqBuilder<'_>, + active_data: &[ActiveData], +) -> Result<()> { + for active in active_data { + body.global_get(active.offset_global); + body.i32_const(0) + .i32_const( + u32::try_from(active.len) + .map_err(|_| anyhow::anyhow!("data segment length exceeds u32"))? + as i32, + ) + .instr(MemoryInit { + memory: active.memory, + data: active.id, + }) + .instr(DataDrop { data: active.id }); + } + Ok(()) +} + +fn reserve_static_record( + body: &mut InstrSeqBuilder<'_>, + imports: ModuleStateImports, + ptr_ty: ValType, + activation: LocalId, + kind: u16, + owner: u32, + size: u32, + payload: LocalId, +) { + body.i32_const(i32::from(kind)) + .local_get(activation) + .i32_const(owner as i32); + emit_ptr_const(body, ptr_ty, u64::from(size)); + body.call(imports.reserve).local_set(payload); +} + +fn find_record( + body: &mut InstrSeqBuilder<'_>, + imports: ModuleStateImports, + activation: LocalId, + kind: u16, + owner: u32, + ordinal: u32, + payload: LocalId, +) { + body.i32_const(i32::from(kind)) + .local_get(activation) + .i32_const(owner as i32) + .i32_const(ordinal as i32) + .call(imports.find) + .local_set(payload); +} + +fn emit_save_segments( + body: &mut InstrSeqBuilder<'_>, + memory: MemoryId, + ptr_ty: ValType, + imports: ModuleStateImports, + activation: LocalId, + payload: LocalId, + kind: u16, + header_size: u32, + tracker: &SegmentTracker, +) { + if tracker.segments.is_empty() { + return; + } + let bitmap_bytes = tracker.segments.len().div_ceil(8); + reserve_static_record( + body, + imports, + ptr_ty, + activation, + kind, + 1, + header_size + bitmap_bytes as u32, + payload, + ); + body.local_get(payload) + .i32_const(tracker.segments.len() as i32) + .store( + memory, + StoreKind::I32 { atomic: false }, + MemArg { + align: 4, + offset: 0, + }, + ) + .local_get(payload) + .i32_const(bitmap_bytes as i32) + .store( + memory, + StoreKind::I32 { atomic: false }, + MemArg { + align: 4, + offset: 4, + }, + ); + for byte in 0..bitmap_bytes { + let word = tracker.globals[byte / 4]; + body.local_get(payload).global_get(word); + if byte % 4 != 0 { + body.i32_const((byte % 4 * 8) as i32) + .binop(BinaryOp::I32ShrU); + } + body.store( + memory, + StoreKind::I32_8 { atomic: false }, + MemArg { + align: 1, + offset: u64::from(header_size) + byte as u64, + }, + ); + } + body.local_get(payload).call(imports.commit); +} + +fn emit_restore_segments( + body: &mut InstrSeqBuilder<'_>, + memory: MemoryId, + imports: ModuleStateImports, + activation: LocalId, + payload: LocalId, + kind: u16, + tracker: &SegmentTracker, + mut emit_drop: impl FnMut(&mut InstrSeqBuilder<'_>, T), +) { + if tracker.segments.is_empty() { + return; + } + find_record(body, imports, activation, kind, 1, 0, payload); + let bitmap_bytes = tracker.segments.len().div_ceil(8); + for (word_index, global) in tracker.globals.iter().copied().enumerate() { + // Assemble the bitmap word from exact byte loads. The KFMS validator + // guarantees the declared payload length, but a guest helper must not + // make correctness depend on allocator padding beyond that payload. + body.i32_const(0); + let first_byte = word_index * 4; + let word_bytes = bitmap_bytes.saturating_sub(first_byte).min(4); + for byte in 0..word_bytes { + body.local_get(payload).load( + memory, + LoadKind::I32_8 { + kind: ExtendedLoad::ZeroExtend, + }, + MemArg { + align: 1, + offset: 8 + (first_byte + byte) as u64, + }, + ); + if byte != 0 { + body.i32_const((byte * 8) as i32).binop(BinaryOp::I32Shl); + } + body.binop(BinaryOp::I32Or); + } + let remaining = tracker.segments.len().saturating_sub(word_index * 32); + if remaining < 32 { + let mask = if remaining == 0 { + 0 + } else { + (1u32 << remaining) - 1 + }; + body.i32_const(mask as i32).binop(BinaryOp::I32And); + } + body.global_set(global); + } + for (index, segment) in tracker.segments.iter().copied().enumerate() { + body.global_get(tracker.globals[index / 32]) + .i32_const((1u32 << (index % 32)) as i32) + .binop(BinaryOp::I32And) + .if_else(None, |then| emit_drop(then, segment), |_| {}); + } +} + +fn emit_save_table( + body: &mut InstrSeqBuilder<'_>, + memory: MemoryId, + ptr_ty: ValType, + codecs: ReferenceCodecs, + imports: ModuleStateImports, + activation: LocalId, + table: TableState, + class: ReferenceCodecClass, + locals: TableLocals, +) { + body.instr(TableSize { table: table.id }) + .local_set(locals.len) + .i32_const(table.owner as i32) + .call(imports.table_dirty_count) + .local_set(locals.page_count); + + reserve_static_record( + body, + imports, + ptr_ty, + activation, + WPK_FORK_MODULE_STATE_RECORD_KIND_TABLE, + table.owner, + u32::from(WPK_FORK_MODULE_STATE_TABLE_DESCRIPTOR_PAYLOAD_SIZE), + locals.payload, + ); + body.local_get(locals.payload) + .i32_const(if table.table64 { 8 } else { 4 }) + .store( + memory, + StoreKind::I32_8 { atomic: false }, + MemArg { + align: 1, + offset: 0, + }, + ) + .local_get(locals.payload) + .i32_const(TABLE_PAGE_SHIFT as i32) + .store( + memory, + StoreKind::I32_8 { atomic: false }, + MemArg { + align: 1, + offset: 1, + }, + ) + .local_get(locals.payload) + .i32_const(WPK_FORK_MODULE_STATE_TABLE_FLAG_SPARSE_OVERRIDES as i32) + .store( + memory, + StoreKind::I32_16 { atomic: false }, + MemArg { + align: 2, + offset: 2, + }, + ) + .local_get(locals.payload) + .local_get(locals.page_count) + .store( + memory, + StoreKind::I32 { atomic: false }, + MemArg { + align: 4, + offset: 4, + }, + ) + .local_get(locals.payload) + .local_get(locals.len); + emit_index_to_i64(body, table); + body.store( + memory, + StoreKind::I64 { atomic: false }, + MemArg { + align: 8, + offset: 8, + }, + ) + .local_get(locals.payload) + .i64_const(table.baseline_len as i64) + .store( + memory, + StoreKind::I64 { atomic: false }, + MemArg { + align: 8, + offset: 16, + }, + ); + for (chunk, bytes) in table.baseline_fingerprint.chunks_exact(8).enumerate() { + body.local_get(locals.payload) + .i64_const(i64::from_le_bytes(bytes.try_into().unwrap())) + .store( + memory, + StoreKind::I64 { atomic: false }, + MemArg { + align: 8, + offset: 24 + (chunk as u64 * 8), + }, + ); + } + body.local_get(locals.payload) + .call(imports.commit) + .i32_const(0) + .local_set(locals.page_ordinal); + + body.block(None, |done| { + let done_id = done.id(); + done.loop_(None, |page_loop| { + let loop_id = page_loop.id(); + page_loop + .local_get(locals.page_ordinal) + .local_get(locals.page_count) + .binop(BinaryOp::I32GeU) + .instr(BrIf { block: done_id }); + + page_loop + .i32_const(table.owner as i32) + .local_get(locals.page_ordinal) + .call(imports.table_dirty_page) + .local_set(locals.page_index) + .local_get(locals.page_index) + .i64_const(i64::from(TABLE_PAGE_SHIFT)) + .binop(BinaryOp::I64Shl); + if !table.table64 { + page_loop + .local_set(locals.page_start_wide) + .local_get(locals.page_start_wide) + .i64_const(i64::from(u32::MAX)) + .binop(BinaryOp::I64GtU); + emit_trap_if(page_loop); + page_loop + .local_get(locals.page_start_wide) + .unop(UnaryOp::I32WrapI64); + } + page_loop.local_set(locals.page_start); + page_loop.local_get(locals.page_start).local_get(locals.len); + emit_index_binop(page_loop, table, BinaryOp::I32GeU, BinaryOp::I64GeU); + emit_trap_if(page_loop); + emit_page_entry_count(page_loop, table, locals); + page_loop + .i32_const(i32::from(WPK_FORK_MODULE_STATE_RECORD_KIND_TABLE_PAGE)) + .local_get(activation) + .i32_const(table.owner as i32) + .local_get(locals.count); + emit_index_to_ptr(page_loop, table, ptr_ty); + emit_ptr_const(page_loop, ptr_ty, 4); + emit_ptr_binop(page_loop, ptr_ty, BinaryOp::I32Mul, BinaryOp::I64Mul); + emit_ptr_const( + page_loop, + ptr_ty, + u64::from( + WPK_FORK_MODULE_STATE_TABLE_PAGE_HEADER_SIZE + + WPK_FORK_MODULE_STATE_TABLE_RUN_HEADER_SIZE, + ), + ); + emit_ptr_binop(page_loop, ptr_ty, BinaryOp::I32Add, BinaryOp::I64Add); + page_loop.call(imports.reserve).local_set(locals.payload); + + page_loop + .local_get(locals.payload) + .local_get(locals.page_index) + .store( + memory, + StoreKind::I64 { atomic: false }, + MemArg { + align: 8, + offset: 0, + }, + ) + .local_get(locals.payload) + .i32_const(1) + .store( + memory, + StoreKind::I32 { atomic: false }, + MemArg { + align: 4, + offset: 8, + }, + ) + .local_get(locals.payload) + .local_get(locals.count); + emit_index_to_i32(page_loop, table); + page_loop + .store( + memory, + StoreKind::I32 { atomic: false }, + MemArg { + align: 4, + offset: 12, + }, + ) + .local_get(locals.payload) + .i32_const(0) + .store( + memory, + StoreKind::I32 { atomic: false }, + MemArg { + align: 4, + offset: 16, + }, + ) + .local_get(locals.payload) + .local_get(locals.count); + emit_index_to_i32(page_loop, table); + page_loop.store( + memory, + StoreKind::I32 { atomic: false }, + MemArg { + align: 4, + offset: 20, + }, + ); + + emit_index_const(page_loop, table, 0); + page_loop.local_set(locals.index); + page_loop.block(None, |entries_done| { + let entries_done_id = entries_done.id(); + entries_done.loop_(None, |entry_loop| { + let entry_loop_id = entry_loop.id(); + entry_loop.local_get(locals.index).local_get(locals.count); + emit_index_binop(entry_loop, table, BinaryOp::I32GeU, BinaryOp::I64GeU); + entry_loop.instr(BrIf { + block: entries_done_id, + }); + emit_table_recipe_addr(entry_loop, ptr_ty, table, locals.payload, locals.index); + entry_loop + .local_get(locals.page_start) + .local_get(locals.index); + emit_index_binop(entry_loop, table, BinaryOp::I32Add, BinaryOp::I64Add); + entry_loop + .instr(TableGet { table: table.id }) + .call(class.encoder(codecs)) + .store( + memory, + StoreKind::I32 { atomic: false }, + MemArg { + align: 4, + offset: 0, + }, + ) + .local_get(locals.index); + emit_index_const(entry_loop, table, 1); + emit_index_binop(entry_loop, table, BinaryOp::I32Add, BinaryOp::I64Add); + entry_loop.local_set(locals.index).instr(Br { + block: entry_loop_id, + }); + }); + }); + page_loop + .local_get(locals.payload) + .call(imports.commit) + .local_get(locals.page_ordinal) + .i32_const(1) + .binop(BinaryOp::I32Add) + .local_set(locals.page_ordinal) + .instr(Br { block: loop_id }); + }); + }); +} + +fn emit_restore_table( + body: &mut InstrSeqBuilder<'_>, + memory: MemoryId, + ptr_ty: ValType, + codecs: ReferenceCodecs, + imports: ModuleStateImports, + activation: LocalId, + table: TableState, + class: ReferenceCodecClass, + locals: TableLocals, +) { + find_record( + body, + imports, + activation, + WPK_FORK_MODULE_STATE_RECORD_KIND_TABLE, + table.owner, + 0, + locals.payload, + ); + body.local_get(locals.payload).load( + memory, + LoadKind::I64 { atomic: false }, + MemArg { + align: 8, + offset: 8, + }, + ); + emit_i64_to_index(body, table); + body.local_set(locals.len) + .local_get(locals.payload) + .load( + memory, + LoadKind::I32 { atomic: false }, + MemArg { + align: 4, + offset: 4, + }, + ) + .local_set(locals.record_page_count); + body.local_get(locals.payload) + .load( + memory, + LoadKind::I64 { atomic: false }, + MemArg { + align: 8, + offset: 16, + }, + ) + .i64_const(table.baseline_len as i64) + .binop(BinaryOp::I64Ne); + emit_trap_if(body); + for (chunk, bytes) in table.baseline_fingerprint.chunks_exact(8).enumerate() { + body.local_get(locals.payload) + .load( + memory, + LoadKind::I64 { atomic: false }, + MemArg { + align: 8, + offset: 24 + (chunk as u64 * 8), + }, + ) + .i64_const(i64::from_le_bytes(bytes.try_into().unwrap())) + .binop(BinaryOp::I64Ne); + emit_trap_if(body); + } + body.instr(TableSize { table: table.id }) + .local_set(locals.current_len) + .local_get(locals.current_len) + .local_get(locals.len); + emit_index_binop(body, table, BinaryOp::I32GtU, BinaryOp::I64GtU); + body.if_else( + None, + |then| { + then.instr(Unreachable {}); + }, + |_| {}, + ); + + body.i32_const(0).local_set(locals.page_ordinal); + body.block(None, |done| { + let done_id = done.id(); + done.loop_(None, |page_loop| { + let loop_id = page_loop.id(); + page_loop + .local_get(locals.page_ordinal) + .local_get(locals.record_page_count) + .binop(BinaryOp::I32GeU) + .instr(BrIf { block: done_id }); + page_loop + .i32_const(i32::from(WPK_FORK_MODULE_STATE_RECORD_KIND_TABLE_PAGE)) + .local_get(activation) + .i32_const(table.owner as i32) + .local_get(locals.page_ordinal) + .call(imports.find) + .local_set(locals.payload); + page_loop + .local_get(locals.payload) + .load( + memory, + LoadKind::I64 { atomic: false }, + MemArg { + align: 8, + offset: 0, + }, + ) + .local_set(locals.page_index) + .local_get(locals.page_index) + .i64_const(i64::from(TABLE_PAGE_SHIFT)) + .binop(BinaryOp::I64Shl); + if table.table64 { + page_loop.local_set(locals.page_start); + } else { + page_loop + .local_set(locals.page_start_wide) + .local_get(locals.page_start_wide) + .i64_const(i64::from(u32::MAX)) + .binop(BinaryOp::I64GtU); + emit_trap_if(page_loop); + page_loop + .local_get(locals.page_start_wide) + .unop(UnaryOp::I32WrapI64) + .local_set(locals.page_start); + } + page_loop.local_get(locals.page_start).local_get(locals.len); + emit_index_binop(page_loop, table, BinaryOp::I32GeU, BinaryOp::I64GeU); + emit_trap_if(page_loop); + emit_page_entry_count(page_loop, table, locals); + emit_validate_sparse_page(page_loop, memory, table, locals); + + // A grown table needs one valid reference before its entries can + // be overlaid. Every successful grow dirties the page containing + // the old end, so the first page spanning current_len owns that + // initializer without a full-table scan. + page_loop + .local_get(locals.current_len) + .local_get(locals.len); + emit_index_binop(page_loop, table, BinaryOp::I32LtU, BinaryOp::I64LtU); + page_loop.if_else( + None, + |needs_growth| { + needs_growth + .local_get(locals.current_len) + .local_get(locals.page_start); + emit_index_binop(needs_growth, table, BinaryOp::I32GeU, BinaryOp::I64GeU); + needs_growth + .local_get(locals.current_len) + .local_get(locals.page_start) + .local_get(locals.count); + emit_index_binop(needs_growth, table, BinaryOp::I32Add, BinaryOp::I64Add); + emit_index_binop(needs_growth, table, BinaryOp::I32LtU, BinaryOp::I64LtU); + needs_growth.binop(BinaryOp::I32And).if_else( + None, + |spans_old_end| { + spans_old_end + .local_get(locals.current_len) + .local_get(locals.page_start); + emit_index_binop( + spans_old_end, + table, + BinaryOp::I32Sub, + BinaryOp::I64Sub, + ); + spans_old_end.local_set(locals.index); + emit_table_recipe_addr( + spans_old_end, + ptr_ty, + table, + locals.payload, + locals.index, + ); + spans_old_end + .load( + memory, + LoadKind::I32 { atomic: false }, + MemArg { + align: 4, + offset: 0, + }, + ) + .call(class.decoder(codecs)); + emit_narrow_reference(spans_old_end, class, table.ty); + spans_old_end + .local_get(locals.len) + .local_get(locals.current_len); + emit_index_binop( + spans_old_end, + table, + BinaryOp::I32Sub, + BinaryOp::I64Sub, + ); + spans_old_end.instr(TableGrow { table: table.id }); + emit_index_const(spans_old_end, table, u64::MAX); + emit_index_binop( + spans_old_end, + table, + BinaryOp::I32Eq, + BinaryOp::I64Eq, + ); + emit_trap_if(spans_old_end); + spans_old_end + .local_get(locals.len) + .local_set(locals.current_len); + }, + |_| {}, + ); + }, + |_| {}, + ); + + emit_index_const(page_loop, table, 0); + page_loop.local_set(locals.index); + page_loop.block(None, |entries_done| { + let entries_done_id = entries_done.id(); + entries_done.loop_(None, |entry_loop| { + let entry_loop_id = entry_loop.id(); + entry_loop.local_get(locals.index).local_get(locals.count); + emit_index_binop(entry_loop, table, BinaryOp::I32GeU, BinaryOp::I64GeU); + entry_loop.instr(BrIf { + block: entries_done_id, + }); + entry_loop + .local_get(locals.page_start) + .local_get(locals.index); + emit_index_binop(entry_loop, table, BinaryOp::I32Add, BinaryOp::I64Add); + emit_table_recipe_addr(entry_loop, ptr_ty, table, locals.payload, locals.index); + entry_loop + .load( + memory, + LoadKind::I32 { atomic: false }, + MemArg { + align: 4, + offset: 0, + }, + ) + .call(class.decoder(codecs)); + emit_narrow_reference(entry_loop, class, table.ty); + entry_loop + .instr(TableSet { table: table.id }) + .local_get(locals.index); + emit_index_const(entry_loop, table, 1); + emit_index_binop(entry_loop, table, BinaryOp::I32Add, BinaryOp::I64Add); + entry_loop.local_set(locals.index).instr(Br { + block: entry_loop_id, + }); + }); + }); + page_loop + .i32_const(table.owner as i32) + .local_get(locals.page_index) + .i64_const(1) + .call(imports.table_dirty_mark) + .local_get(locals.page_ordinal) + .i32_const(1) + .binop(BinaryOp::I32Add) + .local_set(locals.page_ordinal) + .instr(Br { block: loop_id }); + }); + }); + body.local_get(locals.current_len).local_get(locals.len); + emit_index_binop(body, table, BinaryOp::I32Ne, BinaryOp::I64Ne); + emit_trap_if(body); +} + +fn emit_page_entry_count(body: &mut InstrSeqBuilder<'_>, table: TableState, locals: TableLocals) { + // select(page_size, len-page_start, remaining > page_size) + emit_index_const(body, table, TABLE_PAGE_SIZE); + body.local_get(locals.len).local_get(locals.page_start); + emit_index_binop(body, table, BinaryOp::I32Sub, BinaryOp::I64Sub); + body.local_get(locals.len).local_get(locals.page_start); + emit_index_binop(body, table, BinaryOp::I32Sub, BinaryOp::I64Sub); + emit_index_const(body, table, TABLE_PAGE_SIZE); + emit_index_binop(body, table, BinaryOp::I32GtU, BinaryOp::I64GtU); + body.instr(walrus::ir::Select { + ty: Some(table_index_type(table)), + }) + .local_set(locals.count); +} + +fn emit_validate_sparse_page( + body: &mut InstrSeqBuilder<'_>, + memory: MemoryId, + table: TableState, + locals: TableLocals, +) { + body.local_get(locals.payload) + .load( + memory, + LoadKind::I64 { atomic: false }, + MemArg { + align: 8, + offset: 0, + }, + ) + .local_get(locals.page_index) + .binop(BinaryOp::I64Ne); + emit_trap_if(body); + for (offset, expected) in [(8, 1), (16, 0)] { + body.local_get(locals.payload) + .load( + memory, + LoadKind::I32 { atomic: false }, + MemArg { align: 4, offset }, + ) + .i32_const(expected) + .binop(BinaryOp::I32Ne); + emit_trap_if(body); + } + for offset in [12, 20] { + body.local_get(locals.payload).load( + memory, + LoadKind::I32 { atomic: false }, + MemArg { align: 4, offset }, + ); + body.local_get(locals.count); + emit_index_to_i32(body, table); + body.binop(BinaryOp::I32Ne); + emit_trap_if(body); + } +} + +fn emit_trap_if(body: &mut InstrSeqBuilder<'_>) { + body.if_else( + None, + |invalid| { + invalid.instr(Unreachable {}); + }, + |_| {}, + ); +} + +fn emit_table_recipe_addr( + body: &mut InstrSeqBuilder<'_>, + ptr_ty: ValType, + table: TableState, + payload: LocalId, + index: LocalId, +) { + body.local_get(payload).local_get(index); + emit_index_to_ptr(body, table, ptr_ty); + emit_ptr_const(body, ptr_ty, 4); + emit_ptr_binop(body, ptr_ty, BinaryOp::I32Mul, BinaryOp::I64Mul); + emit_ptr_const( + body, + ptr_ty, + u64::from( + WPK_FORK_MODULE_STATE_TABLE_PAGE_HEADER_SIZE + + WPK_FORK_MODULE_STATE_TABLE_RUN_HEADER_SIZE, + ), + ); + emit_ptr_binop(body, ptr_ty, BinaryOp::I32Add, BinaryOp::I64Add); + emit_ptr_binop(body, ptr_ty, BinaryOp::I32Add, BinaryOp::I64Add); +} + +fn emit_narrow_reference( + body: &mut InstrSeqBuilder<'_>, + class: ReferenceCodecClass, + expected: RefType, +) { + let broad = class.nullable_type(); + if expected.heap_type != broad.heap_type { + body.instr(RefCast { + nullable: expected.nullable, + heap_type: expected.heap_type, + }); + } else if !expected.nullable { + body.instr(RefAsNonNull {}); + } +} + +fn table_index_type(table: TableState) -> ValType { + if table.table64 { + ValType::I64 + } else { + ValType::I32 + } +} + +fn emit_ptr_const(body: &mut InstrSeqBuilder<'_>, ptr_ty: ValType, value: u64) { + match ptr_ty { + ValType::I32 => { + body.i32_const(value as u32 as i32); + } + ValType::I64 => { + body.i64_const(value as i64); + } + other => unreachable!("unsupported KFMS pointer type {other:?}"), + } +} + +fn emit_index_const(body: &mut InstrSeqBuilder<'_>, table: TableState, value: u64) { + if table.table64 { + body.i64_const(value as i64); + } else { + body.i32_const(value as u32 as i32); + } +} + +fn emit_ptr_binop(body: &mut InstrSeqBuilder<'_>, ptr_ty: ValType, op32: BinaryOp, op64: BinaryOp) { + body.binop(match ptr_ty { + ValType::I32 => op32, + ValType::I64 => op64, + other => unreachable!("unsupported KFMS pointer type {other:?}"), + }); +} + +fn emit_index_binop( + body: &mut InstrSeqBuilder<'_>, + table: TableState, + op32: BinaryOp, + op64: BinaryOp, +) { + body.binop(if table.table64 { op64 } else { op32 }); +} + +fn emit_index_to_i32(body: &mut InstrSeqBuilder<'_>, table: TableState) { + if table.table64 { + body.unop(UnaryOp::I32WrapI64); + } +} + +fn emit_index_to_i64(body: &mut InstrSeqBuilder<'_>, table: TableState) { + if !table.table64 { + body.unop(UnaryOp::I64ExtendUI32); + } +} + +fn emit_i64_to_index(body: &mut InstrSeqBuilder<'_>, table: TableState) { + if !table.table64 { + // KFMS validation rejects table32 lengths above u32::MAX before this + // helper is called, so the narrowing conversion is exact. + body.unop(UnaryOp::I32WrapI64); + } +} + +fn emit_index_to_ptr(body: &mut InstrSeqBuilder<'_>, table: TableState, ptr_ty: ValType) { + match (table.table64, ptr_ty) { + (false, ValType::I32) | (true, ValType::I64) => {} + (false, ValType::I64) => { + body.unop(UnaryOp::I64ExtendUI32); + } + (true, ValType::I32) => { + body.unop(UnaryOp::I32WrapI64); + } + (_, other) => unreachable!("unsupported KFMS pointer type {other:?}"), + } +} diff --git a/crates/fork-instrument/src/reference_analysis.rs b/crates/fork-instrument/src/reference_analysis.rs new file mode 100644 index 0000000000..9d65f4d143 --- /dev/null +++ b/crates/fork-instrument/src/reference_analysis.rs @@ -0,0 +1,1624 @@ +//! Reference-state analysis for fork continuation planning. +//! +//! This module deliberately analyzes the original Walrus IR. The emission +//! transform splits and nests instruction sequences, so running liveness after +//! rewriting would answer questions about synthetic locals rather than the +//! guest values that are live at a fork landing. +//! +//! The analysis is independent from `instrument.rs` for now. It provides: +//! +//! * stable, depth-first call-site identities; +//! * a structured control-flow graph, including exception edges; +//! * backward reference-local liveness; and +//! * a conservative forward definitely-null analysis. +//! +//! `MaybeNonNull` intentionally includes both non-null values and values whose +//! nullness is unknown. Only `DefinitelyNull` is strong enough to omit a +//! reconstruction recipe. + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; + +use anyhow::{Result, bail}; +use walrus::{ + AbstractHeapType, FunctionId, FunctionKind, HeapType, LocalFunction, LocalId, Module, RefType, + TableId, TagId, TypeId, ValType, + ir::{ + AtomicWidth, Instr, InstrSeqId, InstrSeqType, LegacyCatch, LoadKind, TryTableCatch, + UnaryOp, Value, + }, +}; + +/// Stable identity assigned before any instruction rewriting. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct OriginalCallSiteId(pub u32); + +/// One instruction in the original Walrus IR. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct OriginalProgramPoint { + pub sequence: InstrSeqId, + pub instruction_index: usize, +} + +/// The invocation form at a fork-relevant call landing. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OriginalCallKind { + Direct(FunctionId), + Indirect { table: TableId, ty: TypeId }, + Ref { ty: TypeId }, +} + +impl OriginalCallKind { + fn signature(self, module: &Module) -> TypeId { + match self { + Self::Direct(function) => module.funcs.get(function).ty(), + Self::Indirect { ty, .. } | Self::Ref { ty } => ty, + } + } + + fn extra_stack_operands(self) -> usize { + match self { + Self::Indirect { .. } | Self::Ref { .. } => 1, + Self::Direct(_) => 0, + } + } + + fn has_reference_callee(self) -> bool { + matches!(self, Self::Ref { .. }) + } +} + +/// One statically typed reference operand. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ReferenceOperand { + /// Parameter index for arguments, or stack index for carryovers. + pub index: usize, + pub ty: RefType, +} + +/// Precision of the operand-stack carryover scan. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CarryoverPrecision { + Exact, + /// At least one carryover slot had a producer this bounded scanner could + /// not type. Consumers must not interpret an empty reference list as a + /// proof that no reference is carried. + ContainsUnknownSlots, + Unavailable, +} + +/// Conservative null provenance for a reference local. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReferenceNullability { + DefinitelyNull, + MaybeNonNull, +} + +impl ReferenceNullability { + fn join(self, other: Self) -> Self { + if self == Self::DefinitelyNull && other == Self::DefinitelyNull { + Self::DefinitelyNull + } else { + Self::MaybeNonNull + } + } +} + +/// Reference facts at one fork-relevant original call. +#[derive(Clone, Debug)] +pub struct ReferenceCallSite { + pub id: OriginalCallSiteId, + pub point: OriginalProgramPoint, + pub kind: OriginalCallKind, + pub reference_arguments: Vec, + pub reference_results: Vec, + /// `call_ref` and `return_call_ref` consume a function reference in + /// addition to the declared function parameters. + pub has_reference_callee: bool, + pub reference_carryovers: Vec, + pub carryover_precision: CarryoverPrecision, + /// References needed after the call returns normally. This is the set a + /// fork continuation needs after reissuing its active call. + pub live_ref_locals_on_normal_return: BTreeSet, + /// References needed on any normal or exceptional successor. Keeping this + /// separate makes exceptional CFG coverage testable without making a + /// throwing-only cleanup value look live on deterministic fork replay. + pub live_ref_locals_on_any_successor: BTreeSet, + pub local_nullability_before_call: BTreeMap, + pub reachable: bool, +} + +/// Standalone output for one local function. +#[derive(Clone, Debug)] +pub struct FunctionReferenceAnalysis { + pub function: FunctionId, + pub reference_locals: BTreeMap, + pub call_sites: Vec, +} + +/// Analyze reference state for one function. +/// +/// Direct calls are selected only when their target is in +/// `fork_path_targets`. Indirect and reference calls are selected +/// conservatively because their runtime target is not encoded in the +/// instruction. This mirrors the transform's original-IR landing discovery. +pub fn analyze_function_references( + module: &Module, + function: FunctionId, + fork_path_targets: &HashSet, +) -> Result { + let FunctionKind::Local(local) = &module.funcs.get(function).kind else { + bail!("reference analysis requires a local function"); + }; + + let reference_locals = collect_reference_locals(module, local); + let mut cfg = StructuredCfg::build(module, local, fork_path_targets)?; + let (live_in, live_out) = compute_reference_liveness(&cfg, &reference_locals); + let nullability = compute_nullability(module, local, &cfg, &reference_locals); + annotate_stack_carryovers(module, local, &mut cfg.calls); + + let mut call_sites = Vec::with_capacity(cfg.calls.len()); + for call in cfg.calls { + let signature = module.types.get(call.kind.signature(module)); + let reference_arguments = signature + .params() + .iter() + .enumerate() + .filter_map(|(index, ty)| match ty { + ValType::Ref(ty) => Some(ReferenceOperand { index, ty: *ty }), + _ => None, + }) + .collect(); + let reference_results = signature + .results() + .iter() + .enumerate() + .filter_map(|(index, ty)| match ty { + ValType::Ref(ty) => Some(ReferenceOperand { index, ty: *ty }), + _ => None, + }) + .collect(); + + let normal_live = call + .normal_successor + .map(|node| live_in[node].clone()) + .unwrap_or_default(); + let state = nullability[call.node].clone(); + call_sites.push(ReferenceCallSite { + id: call.id, + point: call.point, + kind: call.kind, + reference_arguments, + reference_results, + has_reference_callee: call.kind.has_reference_callee(), + reference_carryovers: call.reference_carryovers, + carryover_precision: call.carryover_precision, + live_ref_locals_on_normal_return: normal_live, + live_ref_locals_on_any_successor: live_out[call.node].clone(), + local_nullability_before_call: state.clone().unwrap_or_default(), + reachable: state.is_some(), + }); + } + + Ok(FunctionReferenceAnalysis { + function, + reference_locals, + call_sites, + }) +} + +type NodeId = usize; + +#[derive(Clone)] +enum ExceptionRegion { + TryTable(Vec), + Legacy(Vec), +} + +#[derive(Clone, Copy)] +enum SequenceOwner { + Function { exit: NodeId }, + Linear { continuation: NodeId }, + Loop { continuation: NodeId }, +} + +struct CfgNode { + point: Option, + successors: Vec, + predecessors: Vec, + active_exceptions: Vec, +} + +struct PendingCall { + id: OriginalCallSiteId, + point: OriginalProgramPoint, + node: NodeId, + kind: OriginalCallKind, + normal_successor: Option, + reference_carryovers: Vec, + carryover_precision: CarryoverPrecision, +} + +struct StructuredCfg<'a> { + local: &'a LocalFunction, + nodes: Vec, + point_nodes: BTreeMap, + sequence_ends: HashMap, + owners: HashMap, + sequence_order: Vec, + function_exit: NodeId, + calls: Vec, +} + +impl<'a> StructuredCfg<'a> { + fn build( + module: &Module, + local: &'a LocalFunction, + fork_path_targets: &HashSet, + ) -> Result { + let mut cfg = Self { + local, + nodes: Vec::new(), + point_nodes: BTreeMap::new(), + sequence_ends: HashMap::new(), + owners: HashMap::new(), + sequence_order: Vec::new(), + function_exit: 0, + calls: Vec::new(), + }; + cfg.function_exit = cfg.add_node(None, Vec::new()); + let entry = local.entry_block(); + cfg.owners.insert( + entry, + SequenceOwner::Function { + exit: cfg.function_exit, + }, + ); + cfg.enumerate_sequence(module, entry, Vec::new(), fork_path_targets, &mut 0)?; + cfg.add_control_flow_edges()?; + cfg.populate_predecessors(); + Ok(cfg) + } + + fn add_node( + &mut self, + point: Option, + active_exceptions: Vec, + ) -> NodeId { + let id = self.nodes.len(); + self.nodes.push(CfgNode { + point, + successors: Vec::new(), + predecessors: Vec::new(), + active_exceptions, + }); + id + } + + fn enumerate_sequence( + &mut self, + module: &Module, + sequence: InstrSeqId, + active_exceptions: Vec, + fork_path_targets: &HashSet, + next_call_id: &mut u32, + ) -> Result<()> { + if self.sequence_ends.contains_key(&sequence) { + return Ok(()); + } + self.sequence_order.push(sequence); + let block = self.local.block(sequence); + for instruction_index in 0..block.instrs.len() { + let point = OriginalProgramPoint { + sequence, + instruction_index, + }; + let node = self.add_node(Some(point), active_exceptions.clone()); + self.point_nodes.insert(point, node); + } + let end = self.add_node(None, active_exceptions.clone()); + self.sequence_ends.insert(sequence, end); + + // Assign calls in the same parent-before-child DFS order used by the + // switch transform's original call discovery. + for (instruction_index, (instruction, _)) in block.instrs.iter().enumerate() { + let point = OriginalProgramPoint { + sequence, + instruction_index, + }; + let node = self.point_nodes[&point]; + if let Some(kind) = selected_call(instruction, fork_path_targets) { + self.calls.push(PendingCall { + id: OriginalCallSiteId(*next_call_id), + point, + node, + kind, + normal_successor: None, + reference_carryovers: Vec::new(), + carryover_precision: CarryoverPrecision::Unavailable, + }); + *next_call_id += 1; + } + + let continuation = self.next_node(sequence, instruction_index); + match instruction { + Instr::Block(block) => { + self.owners + .insert(block.seq, SequenceOwner::Linear { continuation }); + self.enumerate_sequence( + module, + block.seq, + active_exceptions.clone(), + fork_path_targets, + next_call_id, + )?; + } + Instr::Loop(loop_) => { + self.owners + .insert(loop_.seq, SequenceOwner::Loop { continuation }); + self.enumerate_sequence( + module, + loop_.seq, + active_exceptions.clone(), + fork_path_targets, + next_call_id, + )?; + } + Instr::IfElse(if_else) => { + for child in [if_else.consequent, if_else.alternative] { + self.owners + .insert(child, SequenceOwner::Linear { continuation }); + self.enumerate_sequence( + module, + child, + active_exceptions.clone(), + fork_path_targets, + next_call_id, + )?; + } + } + Instr::TryTable(try_table) => { + self.owners + .insert(try_table.seq, SequenceOwner::Linear { continuation }); + let mut nested = active_exceptions.clone(); + nested.push(ExceptionRegion::TryTable(try_table.catches.clone())); + self.enumerate_sequence( + module, + try_table.seq, + nested, + fork_path_targets, + next_call_id, + )?; + } + Instr::Try(try_) => { + self.owners + .insert(try_.seq, SequenceOwner::Linear { continuation }); + let mut nested = active_exceptions.clone(); + nested.push(ExceptionRegion::Legacy(try_.catches.clone())); + self.enumerate_sequence( + module, + try_.seq, + nested, + fork_path_targets, + next_call_id, + )?; + for catch in &try_.catches { + let handler = match catch { + LegacyCatch::Catch { handler, .. } + | LegacyCatch::CatchAll { handler } => *handler, + LegacyCatch::Delegate { .. } => continue, + }; + self.owners + .insert(handler, SequenceOwner::Linear { continuation }); + // Exceptions in a handler propagate to the enclosing + // region, not to a later clause of the same legacy try. + self.enumerate_sequence( + module, + handler, + active_exceptions.clone(), + fork_path_targets, + next_call_id, + )?; + } + } + _ => {} + } + } + Ok(()) + } + + fn next_node(&self, sequence: InstrSeqId, instruction_index: usize) -> NodeId { + let point = OriginalProgramPoint { + sequence, + instruction_index: instruction_index + 1, + }; + self.point_nodes + .get(&point) + .copied() + .unwrap_or(self.sequence_ends[&sequence]) + } + + fn sequence_entry(&self, sequence: InstrSeqId) -> NodeId { + self.point_nodes + .get(&OriginalProgramPoint { + sequence, + instruction_index: 0, + }) + .copied() + .unwrap_or(self.sequence_ends[&sequence]) + } + + fn label_target(&self, sequence: InstrSeqId) -> Result { + let Some(owner) = self.owners.get(&sequence).copied() else { + bail!("branch references an unowned instruction sequence"); + }; + Ok(match owner { + SequenceOwner::Function { exit } => exit, + SequenceOwner::Linear { continuation } => continuation, + SequenceOwner::Loop { .. } => self.sequence_entry(sequence), + }) + } + + fn normal_completion(&self, sequence: InstrSeqId) -> Result { + let Some(owner) = self.owners.get(&sequence).copied() else { + bail!("instruction sequence has no structural owner"); + }; + Ok(match owner { + SequenceOwner::Function { exit } => exit, + SequenceOwner::Linear { continuation } | SequenceOwner::Loop { continuation } => { + continuation + } + }) + } + + fn add_edge(&mut self, from: NodeId, to: NodeId) { + if !self.nodes[from].successors.contains(&to) { + self.nodes[from].successors.push(to); + } + } + + fn add_control_flow_edges(&mut self) -> Result<()> { + let sequences = self.sequence_order.clone(); + for sequence in sequences { + let end = self.sequence_ends[&sequence]; + let completion = self.normal_completion(sequence)?; + self.add_edge(end, completion); + + let instruction_count = self.local.block(sequence).instrs.len(); + for instruction_index in 0..instruction_count { + let point = OriginalProgramPoint { + sequence, + instruction_index, + }; + let node = self.point_nodes[&point]; + let next = self.next_node(sequence, instruction_index); + let instruction = &self.local.block(sequence).instrs[instruction_index].0; + + match instruction { + Instr::Block(block) => self.add_edge(node, self.sequence_entry(block.seq)), + Instr::Loop(loop_) => self.add_edge(node, self.sequence_entry(loop_.seq)), + Instr::IfElse(if_else) => { + self.add_edge(node, self.sequence_entry(if_else.consequent)); + self.add_edge(node, self.sequence_entry(if_else.alternative)); + } + Instr::TryTable(try_table) => { + self.add_edge(node, self.sequence_entry(try_table.seq)); + } + Instr::Try(try_) => self.add_edge(node, self.sequence_entry(try_.seq)), + Instr::Br(branch) => self.add_edge(node, self.label_target(branch.block)?), + Instr::BrIf(branch) => { + self.add_edge(node, self.label_target(branch.block)?); + self.add_edge(node, next); + } + Instr::BrTable(table) => { + for &target in table.blocks.iter() { + self.add_edge(node, self.label_target(target)?); + } + self.add_edge(node, self.label_target(table.default)?); + } + Instr::BrOnNull(branch) => { + self.add_edge(node, self.label_target(branch.block)?); + self.add_edge(node, next); + } + Instr::BrOnNonNull(branch) => { + self.add_edge(node, self.label_target(branch.block)?); + self.add_edge(node, next); + } + Instr::BrOnCast(branch) => { + self.add_edge(node, self.label_target(branch.block)?); + self.add_edge(node, next); + } + Instr::BrOnCastFail(branch) => { + self.add_edge(node, self.label_target(branch.block)?); + self.add_edge(node, next); + } + Instr::Call(_) | Instr::CallIndirect(_) | Instr::CallRef(_) => { + self.add_edge(node, next); + for target in self.exception_successors(node, None)? { + self.add_edge(node, target); + } + } + Instr::ReturnCall(_) + | Instr::ReturnCallIndirect(_) + | Instr::ReturnCallRef(_) + | Instr::Return(_) => self.add_edge(node, self.function_exit), + Instr::Throw(throw_) => { + for target in self.exception_successors(node, Some(throw_.tag))? { + self.add_edge(node, target); + } + } + Instr::ThrowRef(_) | Instr::Rethrow(_) => { + for target in self.exception_successors(node, None)? { + self.add_edge(node, target); + } + } + Instr::Unreachable(_) => {} + _ => self.add_edge(node, next), + } + } + } + + let normal_successors: HashMap = self + .calls + .iter() + .filter_map(|call| { + Some(( + call.node, + self.next_node(call.point.sequence, call.point.instruction_index), + )) + }) + .collect(); + for call in &mut self.calls { + call.normal_successor = normal_successors.get(&call.node).copied(); + } + Ok(()) + } + + fn exception_successors(&self, node: NodeId, tag: Option) -> Result> { + let mut targets = BTreeSet::new(); + for region in self.nodes[node].active_exceptions.iter().rev() { + match region { + ExceptionRegion::TryTable(catches) => { + let mut catches_all = false; + for catch in catches { + let (matches_tag, is_all, label) = match catch { + TryTableCatch::Catch { tag: caught, label } + | TryTableCatch::CatchRef { tag: caught, label } => { + (tag.is_none_or(|tag| tag == *caught), false, *label) + } + TryTableCatch::CatchAll { label } + | TryTableCatch::CatchAllRef { label } => (true, true, *label), + }; + if matches_tag { + targets.insert(self.label_target(label)?); + if tag.is_some() || is_all { + catches_all = true; + break; + } + } + } + if catches_all { + return Ok(targets.into_iter().collect()); + } + } + ExceptionRegion::Legacy(catches) => { + let mut catches_all = false; + for catch in catches { + match catch { + LegacyCatch::Catch { + tag: caught, + handler, + } if tag.is_none_or(|tag| tag == *caught) => { + targets.insert(self.sequence_entry(*handler)); + if tag.is_some() { + catches_all = true; + break; + } + } + LegacyCatch::CatchAll { handler } => { + targets.insert(self.sequence_entry(*handler)); + catches_all = true; + break; + } + LegacyCatch::Catch { .. } | LegacyCatch::Delegate { .. } => {} + } + } + if catches_all { + return Ok(targets.into_iter().collect()); + } + } + } + } + Ok(targets.into_iter().collect()) + } + + fn populate_predecessors(&mut self) { + for node in 0..self.nodes.len() { + let successors = self.nodes[node].successors.clone(); + for successor in successors { + self.nodes[successor].predecessors.push(node); + } + } + } +} + +fn selected_call( + instruction: &Instr, + fork_path_targets: &HashSet, +) -> Option { + match instruction { + Instr::Call(call) if fork_path_targets.contains(&call.func) => { + Some(OriginalCallKind::Direct(call.func)) + } + Instr::CallIndirect(call) => Some(OriginalCallKind::Indirect { + table: call.table, + ty: call.ty, + }), + Instr::CallRef(call) => Some(OriginalCallKind::Ref { ty: call.ty }), + _ => None, + } +} + +fn collect_reference_locals(module: &Module, local: &LocalFunction) -> BTreeMap { + struct Collector { + locals: BTreeSet, + } + + impl<'a> walrus::ir::Visitor<'a> for Collector { + fn visit_local_id(&mut self, local: &LocalId) { + self.locals.insert(*local); + } + } + + let mut collector = Collector { + locals: local.args.iter().copied().collect(), + }; + walrus::ir::dfs_in_order(&mut collector, local, local.entry_block()); + collector + .locals + .into_iter() + .filter_map(|local| match module.locals.get(local).ty() { + ValType::Ref(ty) => Some((local, ty)), + _ => None, + }) + .collect() +} + +fn compute_reference_liveness( + cfg: &StructuredCfg<'_>, + reference_locals: &BTreeMap, +) -> (Vec>, Vec>) { + let mut live_in = vec![BTreeSet::new(); cfg.nodes.len()]; + let mut live_out = vec![BTreeSet::new(); cfg.nodes.len()]; + + loop { + let mut changed = false; + for node in (0..cfg.nodes.len()).rev() { + let mut next_out = BTreeSet::new(); + for &successor in &cfg.nodes[node].successors { + next_out.extend(live_in[successor].iter().copied()); + } + let (used, defined) = cfg.nodes[node] + .point + .map(|point| local_uses_and_defs(cfg.local, point, reference_locals)) + .unwrap_or_default(); + let mut next_in = next_out.clone(); + if let Some(defined) = defined { + next_in.remove(&defined); + } + next_in.extend(used); + if next_in != live_in[node] || next_out != live_out[node] { + live_in[node] = next_in; + live_out[node] = next_out; + changed = true; + } + } + if !changed { + break; + } + } + (live_in, live_out) +} + +fn local_uses_and_defs( + local: &LocalFunction, + point: OriginalProgramPoint, + reference_locals: &BTreeMap, +) -> (BTreeSet, Option) { + let instruction = &local.block(point.sequence).instrs[point.instruction_index].0; + match instruction { + Instr::LocalGet(get) if reference_locals.contains_key(&get.local) => { + (BTreeSet::from([get.local]), None) + } + Instr::LocalSet(set) if reference_locals.contains_key(&set.local) => { + (BTreeSet::new(), Some(set.local)) + } + Instr::LocalTee(tee) if reference_locals.contains_key(&tee.local) => { + (BTreeSet::new(), Some(tee.local)) + } + _ => (BTreeSet::new(), None), + } +} + +type NullState = BTreeMap; + +fn compute_nullability( + module: &Module, + local: &LocalFunction, + cfg: &StructuredCfg<'_>, + reference_locals: &BTreeMap, +) -> Vec> { + let args: BTreeSet = local.args.iter().copied().collect(); + let initial: NullState = reference_locals + .iter() + .map(|(&local, ty)| { + let state = if !args.contains(&local) && ty.nullable { + ReferenceNullability::DefinitelyNull + } else { + ReferenceNullability::MaybeNonNull + }; + (local, state) + }) + .collect(); + let entry = cfg.sequence_entry(local.entry_block()); + let mut states = vec![None; cfg.nodes.len()]; + states[entry] = Some(initial); + let mut queue = VecDeque::from([entry]); + + while let Some(node) = queue.pop_front() { + let Some(input) = states[node].clone() else { + continue; + }; + let output = transfer_nullability(module, local, cfg, node, input, reference_locals); + for &successor in &cfg.nodes[node].successors { + let candidate = refine_nullability_edge(local, cfg, node, successor, output.clone()); + let changed = match &mut states[successor] { + Some(existing) => join_null_states(existing, &candidate), + slot @ None => { + *slot = Some(candidate); + true + } + }; + if changed { + queue.push_back(successor); + } + } + } + states +} + +fn transfer_nullability( + module: &Module, + local: &LocalFunction, + cfg: &StructuredCfg<'_>, + node: NodeId, + mut state: NullState, + reference_locals: &BTreeMap, +) -> NullState { + let Some(point) = cfg.nodes[node].point else { + return state; + }; + let instruction = &local.block(point.sequence).instrs[point.instruction_index].0; + let target = match instruction { + Instr::LocalSet(set) if reference_locals.contains_key(&set.local) => Some(set.local), + Instr::LocalTee(tee) if reference_locals.contains_key(&tee.local) => Some(tee.local), + _ => None, + }; + if let Some(target) = target { + let value = classify_reference_assignment(module, local, cfg, node, &state); + state.insert(target, value); + } + state +} + +fn classify_reference_assignment( + module: &Module, + local: &LocalFunction, + cfg: &StructuredCfg<'_>, + node: NodeId, + state: &NullState, +) -> ReferenceNullability { + let point = cfg.nodes[node].point.expect("instruction node"); + if point.instruction_index == 0 { + return ReferenceNullability::MaybeNonNull; + } + let producer_point = OriginalProgramPoint { + sequence: point.sequence, + instruction_index: point.instruction_index - 1, + }; + let producer_node = cfg.point_nodes[&producer_point]; + // A catch or branch can land on the assignment with values that did not + // come from the lexically previous instruction. In that case syntax is + // not provenance; retain the conservative state. + if cfg.nodes[node].predecessors.as_slice() != [producer_node] { + return ReferenceNullability::MaybeNonNull; + } + match &local.block(point.sequence).instrs[point.instruction_index - 1].0 { + Instr::RefNull(_) => ReferenceNullability::DefinitelyNull, + Instr::LocalGet(get) => state + .get(&get.local) + .copied() + .unwrap_or(ReferenceNullability::MaybeNonNull), + Instr::RefFunc(_) + | Instr::RefI31(_) + | Instr::StructNew(_) + | Instr::StructNewDefault(_) + | Instr::ArrayNew(_) + | Instr::ArrayNewDefault(_) + | Instr::ArrayNewFixed(_) + | Instr::ArrayNewData(_) + | Instr::ArrayNewElem(_) => ReferenceNullability::MaybeNonNull, + Instr::GlobalGet(get) if matches!(module.globals.get(get.global).ty, ValType::Ref(_)) => { + ReferenceNullability::MaybeNonNull + } + _ => ReferenceNullability::MaybeNonNull, + } +} + +fn refine_nullability_edge( + local: &LocalFunction, + cfg: &StructuredCfg<'_>, + node: NodeId, + successor: NodeId, + mut state: NullState, +) -> NullState { + let Some(point) = cfg.nodes[node].point else { + return state; + }; + let instruction = &local.block(point.sequence).instrs[point.instruction_index].0; + let preceding_local = || { + point.instruction_index.checked_sub(1).and_then(|index| { + match &local.block(point.sequence).instrs[index].0 { + Instr::LocalGet(get) => Some(get.local), + _ => None, + } + }) + }; + match instruction { + Instr::BrOnNull(branch) if successor == cfg.label_target(branch.block).ok().unwrap() => { + if let Some(local) = preceding_local() { + state.insert(local, ReferenceNullability::DefinitelyNull); + } + } + Instr::BrOnNonNull(_) + if successor == cfg.next_node(point.sequence, point.instruction_index) => + { + if let Some(local) = preceding_local() { + state.insert(local, ReferenceNullability::DefinitelyNull); + } + } + Instr::BrIf(branch) => { + if point.instruction_index >= 2 + && matches!( + local.block(point.sequence).instrs[point.instruction_index - 1].0, + Instr::RefIsNull(_) + ) + && successor == cfg.label_target(branch.block).ok().unwrap() + { + if let Instr::LocalGet(get) = + &local.block(point.sequence).instrs[point.instruction_index - 2].0 + { + state.insert(get.local, ReferenceNullability::DefinitelyNull); + } + } + } + Instr::IfElse(if_else) => { + if point.instruction_index >= 2 + && matches!( + local.block(point.sequence).instrs[point.instruction_index - 1].0, + Instr::RefIsNull(_) + ) + && successor == cfg.sequence_entry(if_else.consequent) + { + if let Instr::LocalGet(get) = + &local.block(point.sequence).instrs[point.instruction_index - 2].0 + { + state.insert(get.local, ReferenceNullability::DefinitelyNull); + } + } + } + _ => {} + } + state +} + +fn join_null_states(existing: &mut NullState, incoming: &NullState) -> bool { + let mut changed = false; + for (&local, &incoming) in incoming { + let current = existing + .get(&local) + .copied() + .unwrap_or(ReferenceNullability::MaybeNonNull); + let joined = current.join(incoming); + if joined != current { + existing.insert(local, joined); + changed = true; + } + } + changed +} + +fn annotate_stack_carryovers(module: &Module, local: &LocalFunction, calls: &mut [PendingCall]) { + let mut by_point: BTreeMap = calls + .iter() + .enumerate() + .map(|(index, call)| (call.point, index)) + .collect(); + let mut seen = HashSet::new(); + scan_sequence_stack( + module, + local, + local.entry_block(), + calls, + &mut by_point, + &mut seen, + ); +} + +fn scan_sequence_stack( + module: &Module, + local: &LocalFunction, + sequence: InstrSeqId, + calls: &mut [PendingCall], + by_point: &mut BTreeMap, + seen: &mut HashSet, +) { + if !seen.insert(sequence) { + return; + } + let mut stack = Some(sequence_params(module, local, sequence)); + for (instruction_index, (instruction, _)) in local.block(sequence).instrs.iter().enumerate() { + let point = OriginalProgramPoint { + sequence, + instruction_index, + }; + if let Some(&call_index) = by_point.get(&point) { + let call = &mut calls[call_index]; + let signature = module.types.get(call.kind.signature(module)); + let consumed = signature.params().len() + call.kind.extra_stack_operands(); + match &stack { + Some(stack) if stack.len() >= consumed => { + let carryovers = &stack[..stack.len() - consumed]; + call.reference_carryovers = carryovers + .iter() + .enumerate() + .filter_map(|(index, ty)| match ty { + Some(ValType::Ref(ty)) => Some(ReferenceOperand { index, ty: *ty }), + _ => None, + }) + .collect(); + call.carryover_precision = if carryovers.iter().any(Option::is_none) { + CarryoverPrecision::ContainsUnknownSlots + } else { + CarryoverPrecision::Exact + }; + } + _ => call.carryover_precision = CarryoverPrecision::Unavailable, + } + } + + stack = apply_stack_effect(module, local, instruction, stack); + for child in nested_sequences(instruction) { + scan_sequence_stack(module, local, child, calls, by_point, seen); + } + } +} + +fn sequence_params( + module: &Module, + local: &LocalFunction, + sequence: InstrSeqId, +) -> Vec> { + match local.block(sequence).ty { + InstrSeqType::MultiValue(ty) => module + .types + .get(ty) + .params() + .iter() + .copied() + .map(Some) + .collect(), + InstrSeqType::Simple(_) => Vec::new(), + } +} + +enum BoundedStackEffect { + Delta { + pops: usize, + pushes: Vec>, + }, + Terminator, + Unknown, +} + +fn apply_stack_effect( + module: &Module, + local: &LocalFunction, + instruction: &Instr, + stack: Option>>, +) -> Option>> { + let mut stack = stack?; + match bounded_stack_effect(module, local, instruction, &stack) { + BoundedStackEffect::Delta { pops, pushes } if stack.len() >= pops => { + stack.truncate(stack.len() - pops); + stack.extend(pushes); + Some(stack) + } + BoundedStackEffect::Delta { .. } + | BoundedStackEffect::Terminator + | BoundedStackEffect::Unknown => None, + } +} + +fn bounded_stack_effect( + module: &Module, + local: &LocalFunction, + instruction: &Instr, + pre_stack: &[Option], +) -> BoundedStackEffect { + use BoundedStackEffect::{Delta, Terminator, Unknown}; + let unknown = |pops, pushes| Delta { + pops, + pushes: vec![None; pushes], + }; + let exact = |pops, pushes: Vec| Delta { + pops, + pushes: pushes.into_iter().map(Some).collect(), + }; + match instruction { + Instr::Const(constant) => exact( + 0, + vec![match constant.value { + Value::I32(_) => ValType::I32, + Value::I64(_) => ValType::I64, + Value::F32(_) => ValType::F32, + Value::F64(_) => ValType::F64, + Value::V128(_) => ValType::V128, + }], + ), + Instr::LocalGet(get) => exact(0, vec![module.locals.get(get.local).ty()]), + Instr::LocalSet(_) | Instr::GlobalSet(_) | Instr::Drop(_) => exact(1, vec![]), + Instr::LocalTee(tee) => exact(1, vec![module.locals.get(tee.local).ty()]), + Instr::GlobalGet(get) => exact(0, vec![module.globals.get(get.global).ty]), + Instr::RefNull(null) => exact(0, vec![ValType::Ref(null.ty)]), + Instr::RefFunc(reference) => exact( + 0, + vec![ValType::Ref(RefType { + nullable: false, + heap_type: HeapType::Concrete(module.funcs.get(reference.func).ty()), + })], + ), + Instr::RefI31(_) => exact( + 1, + vec![ValType::Ref(RefType { + nullable: false, + heap_type: HeapType::Abstract(AbstractHeapType::I31), + })], + ), + Instr::RefAsNonNull(_) => { + let ty = pre_stack.last().copied().flatten().map(|ty| match ty { + ValType::Ref(mut reference) => { + reference.nullable = false; + ValType::Ref(reference) + } + scalar => scalar, + }); + Delta { + pops: 1, + pushes: vec![ty], + } + } + Instr::RefCast(cast) => exact( + 1, + vec![ValType::Ref(RefType { + nullable: cast.nullable, + heap_type: cast.heap_type, + })], + ), + Instr::AnyConvertExtern(_) => exact(1, vec![ValType::Ref(RefType::ANYREF)]), + Instr::ExternConvertAny(_) => exact(1, vec![ValType::Ref(RefType::EXTERNREF)]), + Instr::RefIsNull(_) | Instr::RefTest(_) => exact(1, vec![ValType::I32]), + Instr::RefEq(_) => exact(2, vec![ValType::I32]), + Instr::I31GetS(_) | Instr::I31GetU(_) => exact(1, vec![ValType::I32]), + Instr::StructNew(new) => exact( + module.types.get(new.ty).kind().unwrap_struct().fields.len(), + vec![concrete_non_null_ref(new.ty)], + ), + Instr::StructNewDefault(new) => exact(0, vec![concrete_non_null_ref(new.ty)]), + Instr::StructGet(get) => exact( + 1, + vec![ + module.types.get(get.ty).kind().unwrap_struct().fields[get.field as usize] + .element_type + .unpack(), + ], + ), + Instr::StructGetS(_) | Instr::StructGetU(_) => exact(1, vec![ValType::I32]), + Instr::StructSet(_) => exact(2, vec![]), + Instr::ArrayNew(new) => exact(2, vec![concrete_non_null_ref(new.ty)]), + Instr::ArrayNewDefault(new) => exact(1, vec![concrete_non_null_ref(new.ty)]), + Instr::ArrayNewFixed(new) => exact(new.len as usize, vec![concrete_non_null_ref(new.ty)]), + Instr::ArrayNewData(new) => exact(2, vec![concrete_non_null_ref(new.ty)]), + Instr::ArrayNewElem(new) => exact(2, vec![concrete_non_null_ref(new.ty)]), + Instr::ArrayGet(get) => exact( + 2, + vec![ + module + .types + .get(get.ty) + .kind() + .unwrap_array() + .field + .element_type + .unpack(), + ], + ), + Instr::ArrayGetS(_) | Instr::ArrayGetU(_) => exact(2, vec![ValType::I32]), + Instr::ArraySet(_) => exact(3, vec![]), + Instr::ArrayLen(_) => exact(1, vec![ValType::I32]), + Instr::ArrayFill(_) => exact(4, vec![]), + Instr::ArrayCopy(_) => exact(5, vec![]), + Instr::ArrayInitData(_) | Instr::ArrayInitElem(_) => exact(4, vec![]), + Instr::TableGet(get) => exact( + 1, + vec![ValType::Ref(module.tables.get(get.table).element_ty)], + ), + Instr::Call(call) => { + let signature = module.types.get(module.funcs.get(call.func).ty()); + exact(signature.params().len(), signature.results().to_vec()) + } + Instr::CallIndirect(call) => { + let signature = module.types.get(call.ty); + exact(signature.params().len() + 1, signature.results().to_vec()) + } + Instr::CallRef(call) => { + let signature = module.types.get(call.ty); + exact(signature.params().len() + 1, signature.results().to_vec()) + } + Instr::Block(block) => structured_effect(module, local, block.seq, 0), + Instr::Loop(loop_) => structured_effect(module, local, loop_.seq, 0), + Instr::IfElse(if_else) => structured_effect(module, local, if_else.consequent, 1), + Instr::TryTable(try_table) => structured_effect(module, local, try_table.seq, 0), + Instr::Try(try_) => structured_effect(module, local, try_.seq, 0), + Instr::BrIf(_) => exact(1, vec![]), + Instr::BrOnNull(_) => { + let ty = pre_stack.last().copied().flatten().map(|ty| match ty { + ValType::Ref(mut reference) => { + reference.nullable = false; + ValType::Ref(reference) + } + scalar => scalar, + }); + Delta { + pops: 1, + pushes: vec![ty], + } + } + Instr::BrOnNonNull(_) => exact(1, vec![]), + Instr::BrOnCast(cast) => exact( + 1, + vec![ValType::Ref(RefType { + nullable: cast.from_nullable, + heap_type: cast.from_heap_type, + })], + ), + Instr::BrOnCastFail(cast) => exact( + 1, + vec![ValType::Ref(RefType { + nullable: cast.to_nullable, + heap_type: cast.to_heap_type, + })], + ), + Instr::Load(load) => exact(1, vec![load_type(load.kind)]), + Instr::LoadSimd(_) => exact(1, vec![ValType::V128]), + Instr::Store(_) | Instr::TableSet(_) => exact(2, vec![]), + Instr::MemorySize(_) | Instr::TableSize(_) => exact(0, vec![ValType::I32]), + Instr::MemoryGrow(_) => exact(1, vec![ValType::I32]), + Instr::TableGrow(_) => exact(2, vec![ValType::I32]), + Instr::Binop(_) => unknown(2, 1), + Instr::Unop(unary) => exact(1, vec![unary_result_type(&unary.op)]), + Instr::Select(select) => { + let ty = select.ty.or_else(|| { + pre_stack + .get(pre_stack.len().saturating_sub(3)) + .copied() + .flatten() + }); + Delta { + pops: 3, + pushes: vec![ty], + } + } + Instr::TernOp(_) | Instr::V128Bitselect { .. } => exact(3, vec![ValType::V128]), + Instr::AtomicRmw(rmw) => exact(2, vec![atomic_type(rmw.width)]), + Instr::Cmpxchg(cmp) => exact(3, vec![atomic_type(cmp.width)]), + Instr::AtomicNotify(_) => exact(2, vec![ValType::I32]), + Instr::AtomicWait(_) => unknown(3, 1), + Instr::MemoryFill(_) + | Instr::MemoryCopy(_) + | Instr::MemoryInit(_) + | Instr::TableFill(_) + | Instr::TableInit(_) + | Instr::TableCopy(_) => exact(3, vec![]), + Instr::DataDrop(_) | Instr::ElemDrop(_) | Instr::AtomicFence(_) => exact(0, vec![]), + Instr::Return(_) + | Instr::Unreachable(_) + | Instr::Br(_) + | Instr::BrTable(_) + | Instr::ReturnCall(_) + | Instr::ReturnCallIndirect(_) + | Instr::ReturnCallRef(_) + | Instr::Throw(_) + | Instr::ThrowRef(_) + | Instr::Rethrow(_) => Terminator, + _ => Unknown, + } +} + +fn concrete_non_null_ref(ty: TypeId) -> ValType { + ValType::Ref(RefType { + nullable: false, + heap_type: HeapType::Concrete(ty), + }) +} + +fn structured_effect( + module: &Module, + local: &LocalFunction, + sequence: InstrSeqId, + extra_pops: usize, +) -> BoundedStackEffect { + let (params, results) = sequence_params_results(module, local, sequence); + BoundedStackEffect::Delta { + pops: params.len() + extra_pops, + pushes: results.into_iter().map(Some).collect(), + } +} + +fn sequence_params_results( + module: &Module, + local: &LocalFunction, + sequence: InstrSeqId, +) -> (Vec, Vec) { + match local.block(sequence).ty { + InstrSeqType::Simple(None) => (Vec::new(), Vec::new()), + InstrSeqType::Simple(Some(result)) => (Vec::new(), vec![result]), + InstrSeqType::MultiValue(ty) => { + let ty = module.types.get(ty); + (ty.params().to_vec(), ty.results().to_vec()) + } + } +} + +fn load_type(kind: LoadKind) -> ValType { + match kind { + LoadKind::I32 { .. } | LoadKind::I32_8 { .. } | LoadKind::I32_16 { .. } => ValType::I32, + LoadKind::I64 { .. } + | LoadKind::I64_8 { .. } + | LoadKind::I64_16 { .. } + | LoadKind::I64_32 { .. } => ValType::I64, + LoadKind::F32 => ValType::F32, + LoadKind::F64 => ValType::F64, + LoadKind::V128 => ValType::V128, + } +} + +fn unary_result_type(op: &UnaryOp) -> ValType { + let name = format!("{op:?}"); + if name.starts_with("I32") || name == "I64Eqz" { + ValType::I32 + } else if name.starts_with("I64") { + ValType::I64 + } else if name.starts_with("F32") { + ValType::F32 + } else if name.starts_with("F64") { + ValType::F64 + } else { + ValType::V128 + } +} + +fn atomic_type(width: AtomicWidth) -> ValType { + match width { + AtomicWidth::I64 | AtomicWidth::I64_8 | AtomicWidth::I64_16 | AtomicWidth::I64_32 => { + ValType::I64 + } + AtomicWidth::I32 | AtomicWidth::I32_8 | AtomicWidth::I32_16 => ValType::I32, + } +} + +fn nested_sequences(instruction: &Instr) -> Vec { + match instruction { + Instr::Block(block) => vec![block.seq], + Instr::Loop(loop_) => vec![loop_.seq], + Instr::IfElse(if_else) => vec![if_else.consequent, if_else.alternative], + Instr::TryTable(try_table) => vec![try_table.seq], + Instr::Try(try_) => { + let mut sequences = vec![try_.seq]; + for catch in &try_.catches { + match catch { + LegacyCatch::Catch { handler, .. } | LegacyCatch::CatchAll { handler } => { + sequences.push(*handler) + } + LegacyCatch::Delegate { .. } => {} + } + } + sequences + } + _ => Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn module_and_functions( + wat: &str, + caller: &str, + targets: &[&str], + ) -> (Module, FunctionId, HashSet) { + let bytes = wat::parse_str(wat).expect("wat parses"); + let module = Module::from_buffer(&bytes).expect("walrus parses"); + let find = |name: &str| { + module + .funcs + .iter() + .find(|function| function.name.as_deref() == Some(name)) + .unwrap_or_else(|| panic!("function `{name}` exists")) + .id() + }; + let caller = find(caller); + let targets = targets.iter().map(|name| find(name)).collect(); + (module, caller, targets) + } + + fn only_call(analysis: &FunctionReferenceAnalysis) -> &ReferenceCallSite { + assert_eq!(analysis.call_sites.len(), 1); + &analysis.call_sites[0] + } + + #[test] + fn dash_style_cleanup_exnref_is_dead_and_null_at_fork() { + let (module, caller, targets) = module_and_functions( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (tag $cleanup) + (func $caller + (local $scratch (ref null exn)) + (block $caught (result exnref) + (try_table (result exnref) (catch_all_ref $caught) + call $fork + drop + ref.null exn)) + local.set $scratch + local.get $scratch + throw_ref)) + "#, + "caller", + &["fork"], + ); + let analysis = analyze_function_references(&module, caller, &targets).unwrap(); + let call = only_call(&analysis); + let (&scratch, _) = analysis.reference_locals.iter().next().unwrap(); + assert!(!call.live_ref_locals_on_normal_return.contains(&scratch)); + assert!(!call.live_ref_locals_on_any_successor.contains(&scratch)); + assert_eq!( + call.local_nullability_before_call[&scratch], + ReferenceNullability::DefinitelyNull + ); + } + + #[test] + fn reference_used_after_fork_is_live() { + let (module, caller, targets) = module_and_functions( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (func $caller (param $value externref) + call $fork + drop + local.get $value + drop)) + "#, + "caller", + &["fork"], + ); + let analysis = analyze_function_references(&module, caller, &targets).unwrap(); + let call = only_call(&analysis); + let (&value, _) = analysis.reference_locals.iter().next().unwrap(); + assert!(call.live_ref_locals_on_normal_return.contains(&value)); + assert_eq!( + call.local_nullability_before_call[&value], + ReferenceNullability::MaybeNonNull + ); + } + + #[test] + fn reference_arguments_and_carryovers_are_marked_separately() { + let (module, caller, targets) = module_and_functions( + r#" + (module + (func $takes_ref (param externref) (result i32) + i32.const 0) + (func $fork (result i32) + i32.const 0) + (func $caller + ref.null extern + call $takes_ref + drop + ref.null extern + call $fork + drop + drop)) + "#, + "caller", + &["takes_ref", "fork"], + ); + let analysis = analyze_function_references(&module, caller, &targets).unwrap(); + assert_eq!(analysis.call_sites.len(), 2); + assert_eq!(analysis.call_sites[0].id, OriginalCallSiteId(0)); + assert_eq!(analysis.call_sites[0].reference_arguments.len(), 1); + assert!(analysis.call_sites[0].reference_carryovers.is_empty()); + assert!(analysis.call_sites[1].reference_arguments.is_empty()); + assert_eq!(analysis.call_sites[1].reference_carryovers.len(), 1); + assert_eq!( + analysis.call_sites[1].carryover_precision, + CarryoverPrecision::Exact + ); + } + + #[test] + fn inline_struct_new_carryover_has_exact_concrete_type() { + let (module, caller, targets) = module_and_functions( + r#" + (module + (type $pair (struct (field i32) (field i32))) + (func $fork (result i32) + i32.const 0) + (func $caller + i32.const 23 + i32.const 42 + struct.new $pair + call $fork + drop + drop)) + "#, + "caller", + &["fork"], + ); + let analysis = analyze_function_references(&module, caller, &targets).unwrap(); + let call = only_call(&analysis); + assert_eq!(call.carryover_precision, CarryoverPrecision::Exact); + assert_eq!(call.reference_carryovers.len(), 1); + let HeapType::Concrete(pair) = call.reference_carryovers[0].ty.heap_type else { + panic!("struct.new must produce a concrete reference"); + }; + assert!(module.types.get(pair).kind().is_struct()); + assert!(!call.reference_carryovers[0].ty.nullable); + } + + #[test] + fn gc_reference_field_read_remains_an_exact_carryover() { + let (module, caller, targets) = module_and_functions( + r#" + (module + (type $pair (struct (field i32))) + (type $holder (struct (field (ref null $pair)))) + (func $fork (result i32) + i32.const 0) + (func $caller (param $holder (ref $holder)) + local.get $holder + struct.get $holder 0 + call $fork + drop + drop)) + "#, + "caller", + &["fork"], + ); + let analysis = analyze_function_references(&module, caller, &targets).unwrap(); + let call = only_call(&analysis); + assert_eq!(call.carryover_precision, CarryoverPrecision::Exact); + assert_eq!(call.reference_carryovers.len(), 1); + let reference = call.reference_carryovers[0].ty; + assert!(reference.nullable); + let HeapType::Concrete(pair) = reference.heap_type else { + panic!("struct.get must preserve the field's concrete reference type"); + }; + assert!(module.types.get(pair).kind().is_struct()); + } + + #[test] + fn branch_merge_and_null_test_refine_forward_state() { + let (module, caller, targets) = module_and_functions( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (func $caller (param $value externref) (param $condition i32) + (local $merged externref) + local.get $condition + if + local.get $value + local.set $merged + end + local.get $merged + ref.is_null + if + call $fork + drop + end)) + "#, + "caller", + &["fork"], + ); + let analysis = analyze_function_references(&module, caller, &targets).unwrap(); + let call = only_call(&analysis); + let merged = analysis + .reference_locals + .keys() + .copied() + .find(|local| { + !module + .funcs + .get(caller) + .kind + .unwrap_local() + .args + .contains(local) + }) + .unwrap(); + assert_eq!( + call.local_nullability_before_call[&merged], + ReferenceNullability::DefinitelyNull + ); + } + + #[test] + fn loop_backedge_participates_in_liveness() { + let (module, caller, targets) = module_and_functions( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (func $caller (param $value externref) + (loop $again + call $fork + drop + local.get $value + ref.is_null + br_if $again))) + "#, + "caller", + &["fork"], + ); + let analysis = analyze_function_references(&module, caller, &targets).unwrap(); + let call = only_call(&analysis); + let (&value, _) = analysis.reference_locals.iter().next().unwrap(); + assert!(call.live_ref_locals_on_normal_return.contains(&value)); + } + + #[test] + fn try_table_exception_edge_is_distinct_from_normal_return() { + let (module, caller, targets) = module_and_functions( + r#" + (module + (func $candidate) + (func $caller (param $value externref) + (block $done + (block $handler + (try_table (catch_all $handler) + call $candidate + br $done)) + local.get $value + drop))) + "#, + "caller", + &["candidate"], + ); + let analysis = analyze_function_references(&module, caller, &targets).unwrap(); + let call = only_call(&analysis); + let (&value, _) = analysis.reference_locals.iter().next().unwrap(); + assert!(!call.live_ref_locals_on_normal_return.contains(&value)); + assert!(call.live_ref_locals_on_any_successor.contains(&value)); + } +} diff --git a/crates/fork-instrument/src/runtime.rs b/crates/fork-instrument/src/runtime.rs index a9de7bd304..c646369f27 100644 --- a/crates/fork-instrument/src/runtime.rs +++ b/crates/fork-instrument/src/runtime.rs @@ -9,7 +9,7 @@ //! `wpk_fork_unwind_end`, `wpk_fork_rewind_begin`, //! `wpk_fork_rewind_end`, `wpk_fork_abort_begin`, //! `wpk_fork_abort_end`, and `wpk_fork_state`. -//! - In the ABI 42 linked format, three host imports that reserve, commit, and +//! - In the ABI 42+ linked format, three host imports that reserve, commit, and //! replay variable-sized frame nodes. //! //! ## Phase 4e additions: saved-globals area @@ -22,8 +22,10 @@ //! excluded: they are set explicitly by each begin function to the //! known transition values. //! -//! Ref-typed mutable globals (funcref/externref/exnref) require -//! auxiliary tables (Phase 4f); this phase skips them. +//! Mutable reference globals are deliberately absent from this scalar prefix. +//! ABI 43 emits activation-local module-state helpers that encode them into +//! the process reference graph during capture and reconstruct them in each +//! fresh child instance before continuation replay. //! //! Module-prefix layout (all offsets byte-exact; `P` is pointer width — //! 4 bytes on wasm32 and 8 on wasm64): @@ -41,7 +43,8 @@ //! chunks rather than directly after this prefix. use walrus::{ - ConstExpr, FunctionBuilder, FunctionId, GlobalId, InstrSeqBuilder, MemoryId, Module, ValType, + AbstractHeapType, ConstExpr, FunctionBuilder, FunctionId, GlobalId, HeapType, InstrSeqBuilder, + MemoryId, Module, RefType, TableId, TagId, ValType, ir::{BinaryOp, LoadKind, MemArg, StoreKind, Value}, }; @@ -70,6 +73,146 @@ pub mod names { pub const IMPORT_FRAME_RESERVE: &str = wasm_posix_shared::abi::WPK_FORK_FRAME_IMPORT_RESERVE; pub const IMPORT_FRAME_COMMIT: &str = wasm_posix_shared::abi::WPK_FORK_FRAME_IMPORT_COMMIT; pub const IMPORT_FRAME_NEXT: &str = wasm_posix_shared::abi::WPK_FORK_FRAME_IMPORT_NEXT; + pub const IMPORT_FRAME_PEEK: &str = wasm_posix_shared::abi::WPK_FORK_FRAME_IMPORT_PEEK; + pub const IMPORT_RESUME_PEEK: &str = wasm_posix_shared::abi::WPK_FORK_RESUME_IMPORT_PEEK; + pub const IMPORT_RESUME_TABLE: &str = wasm_posix_shared::abi::WPK_FORK_RESUME_IMPORT_TABLE; + pub const IMPORT_REFERENCE_VECTOR_BEGIN: &str = + wasm_posix_shared::abi::WPK_FORK_REFERENCE_IMPORT_VECTOR_BEGIN; + pub const IMPORT_REFERENCE_VECTOR_APPEND: &str = + wasm_posix_shared::abi::WPK_FORK_REFERENCE_IMPORT_VECTOR_APPEND; + pub const IMPORT_REFERENCE_VECTOR_FINISH: &str = + wasm_posix_shared::abi::WPK_FORK_REFERENCE_IMPORT_VECTOR_FINISH; + pub const IMPORT_REFERENCE_VECTOR_GET: &str = + wasm_posix_shared::abi::WPK_FORK_REFERENCE_IMPORT_VECTOR_GET; + + /// Process-owned zero-payload tag used only to transport the internal + /// unwind across arbitrary Wasm result types. + pub const IMPORT_UNWIND_TAG_MODULE: &str = "env"; + pub const IMPORT_UNWIND_TAG: &str = "__wpk_fork_unwind"; + + /// Activation-owned reference recipe codecs. Encoders return a numeric + /// recipe ID that can be stored in linear memory; decoders resolve that ID + /// against the fresh child's reconstruction arena. + pub const IMPORT_REFERENCE_CODEC_MODULE: &str = + wasm_posix_shared::abi::WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE; + pub const IMPORT_REF_ENCODE_FUNCREF: &str = + wasm_posix_shared::abi::WPK_FORK_REFERENCE_IMPORT_ENCODE_FUNCREF; + pub const IMPORT_REF_DECODE_FUNCREF: &str = + wasm_posix_shared::abi::WPK_FORK_REFERENCE_IMPORT_DECODE_FUNCREF; + pub const IMPORT_REF_ENCODE_EXTERNREF: &str = + wasm_posix_shared::abi::WPK_FORK_REFERENCE_IMPORT_ENCODE_EXTERNREF; + pub const IMPORT_REF_DECODE_EXTERNREF: &str = + wasm_posix_shared::abi::WPK_FORK_REFERENCE_IMPORT_DECODE_EXTERNREF; + pub const IMPORT_REF_ENCODE_EXNREF: &str = + wasm_posix_shared::abi::WPK_FORK_REFERENCE_IMPORT_ENCODE_EXNREF; + pub const IMPORT_REF_DECODE_EXNREF: &str = + wasm_posix_shared::abi::WPK_FORK_REFERENCE_IMPORT_DECODE_EXNREF; + pub const IMPORT_REF_ENCODE_ANYREF: &str = + wasm_posix_shared::abi::WPK_FORK_REFERENCE_IMPORT_ENCODE_ANYREF; + pub const IMPORT_REF_DECODE_ANYREF: &str = + wasm_posix_shared::abi::WPK_FORK_REFERENCE_IMPORT_DECODE_ANYREF; +} + +/// The four disjoint WebAssembly reference hierarchies used by the recipe +/// provider. Concrete function references travel through `funcref`; concrete +/// struct/array references travel through `anyref` and are cast back to their +/// exact guest type after decoding. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReferenceCodecClass { + Func, + Extern, + Exn, + Any, +} + +impl ReferenceCodecClass { + pub fn of(module: &Module, reference: RefType) -> Self { + match reference.heap_type { + HeapType::Abstract(AbstractHeapType::Func | AbstractHeapType::NoFunc) => Self::Func, + HeapType::Abstract(AbstractHeapType::Extern | AbstractHeapType::NoExtern) => { + Self::Extern + } + HeapType::Abstract(AbstractHeapType::Exn | AbstractHeapType::NoExn) => Self::Exn, + HeapType::Abstract( + AbstractHeapType::Any + | AbstractHeapType::None + | AbstractHeapType::Eq + | AbstractHeapType::Struct + | AbstractHeapType::Array + | AbstractHeapType::I31, + ) => Self::Any, + HeapType::Concrete(ty) | HeapType::Exact(ty) => { + if module.types.get(ty).is_function() { + Self::Func + } else { + Self::Any + } + } + // Walrus marks heap types non-exhaustive. New internal reference + // hierarchies must travel through the Wasm sidecar until they gain + // a more specific codec class. + _ => Self::Any, + } + } + + pub fn nullable_type(self) -> RefType { + match self { + Self::Func => RefType::FUNCREF, + Self::Extern => RefType::EXTERNREF, + Self::Exn => RefType::EXNREF, + Self::Any => RefType::ANYREF, + } + } + + pub fn encoder(self, codecs: ReferenceCodecs) -> FunctionId { + match self { + Self::Func => codecs.encode_funcref, + Self::Extern => codecs.encode_externref, + Self::Exn => codecs.encode_exnref, + Self::Any => codecs.encode_anyref, + } + } + + pub fn decoder(self, codecs: ReferenceCodecs) -> FunctionId { + match self { + Self::Func => codecs.decode_funcref, + Self::Extern => codecs.decode_externref, + Self::Exn => codecs.decode_exnref, + Self::Any => codecs.decode_anyref, + } + } +} + +/// Typed imported hooks used to turn instance-local references into +/// deterministic reconstruction recipe IDs and back again. A provider may be +/// a JavaScript host function for JS-compatible reference types or a Wasm +/// sidecar when the JS API cannot express the signature. +#[derive(Debug, Clone, Copy)] +pub struct ReferenceCodecs { + pub encode_funcref: FunctionId, + pub decode_funcref: FunctionId, + pub encode_externref: FunctionId, + pub decode_externref: FunctionId, + pub encode_exnref: FunctionId, + pub decode_exnref: FunctionId, + pub encode_anyref: FunctionId, + pub decode_anyref: FunctionId, +} + +/// Optional module-local providers for reference classes whose signatures +/// cannot cross the JavaScript API boundary. +/// +/// The default runtime imports every pair. Exact-tag exception and concrete +/// GC codecs override only the classes they own, while keeping the same +/// `ReferenceCodecs` call sites throughout frame and module-state emission. +#[derive(Debug, Clone, Copy, Default)] +pub struct ReferenceCodecOverrides { + pub funcref: Option<(FunctionId, FunctionId)>, + pub externref: Option<(FunctionId, FunctionId)>, + pub exnref: Option<(FunctionId, FunctionId)>, + pub anyref: Option<(FunctionId, FunctionId)>, + /// Clears provider-only alias/scratch roots at every completed transition. + pub cleanup: Option, } /// Metadata about a saved mutable global. @@ -103,6 +246,29 @@ pub struct Runtime { pub frame_reserve: Option, pub frame_commit: Option, pub frame_next: Option, + /// Non-consuming view of the next payload in this module activation's + /// continuation. Resume thunks use it to materialize their call operands; + /// the original function preamble consumes the same frame with + /// `frame_next`. + pub frame_peek: Option, + /// Process-wide replay router. Slot zero means the lexical callee must run; + /// nonzero slots select an activation resume thunk in `resume_table`. + pub resume_peek: Option, + pub resume_table: Option, + pub reference_vector_begin: Option, + pub reference_vector_append: Option, + pub reference_vector_finish: Option, + pub reference_vector_get: Option, + + /// Private process-owned unwind transport. Linked fork runtimes import + /// this tag so every activation can propagate unwind without fabricating + /// a value of its declared result type. + pub unwind_tag: Option, + + /// Present only in a linked fork runtime. Inert, no-seed modules must not + /// acquire host imports merely because they were passed through the + /// instrumenter. + pub reference_codecs: Option, /// Mutable scalar globals that `wpk_fork_unwind_begin` snapshots /// and `wpk_fork_rewind_begin` restores. Declaration order. @@ -162,14 +328,25 @@ fn zero_const(ptr_ty: ValType) -> ConstExpr { /// machinery in `wpk_fork_unwind_begin` / `wpk_fork_rewind_begin`. /// pub fn inject_runtime(module: &mut Module) -> Runtime { - inject_runtime_with_frame_storage(module, false) + inject_runtime_with_frame_storage(module, false, ReferenceCodecOverrides::default()) } pub fn inject_linked_runtime(module: &mut Module) -> Runtime { - inject_runtime_with_frame_storage(module, true) + inject_runtime_with_frame_storage(module, true, ReferenceCodecOverrides::default()) } -fn inject_runtime_with_frame_storage(module: &mut Module, linked_frames: bool) -> Runtime { +pub fn inject_linked_runtime_with_reference_overrides( + module: &mut Module, + overrides: ReferenceCodecOverrides, +) -> Runtime { + inject_runtime_with_frame_storage(module, true, overrides) +} + +fn inject_runtime_with_frame_storage( + module: &mut Module, + linked_frames: bool, + codec_overrides: ReferenceCodecOverrides, +) -> Runtime { let ptr_ty = ptr_type(module); let memory = module.memories.iter().next().map(|m| m.id()); @@ -190,16 +367,14 @@ fn inject_runtime_with_frame_storage(module: &mut Module, linked_frames: bool) - continue; } if matches!(g.ty, ValType::Ref(_)) { - // Ref-typed globals need auxiliary tables (Phase 4f). + // Reference globals are owned by the typed module-state helper, + // which is injected after this scalar prefix has been laid out. continue; } - if matches!(g.kind, walrus::GlobalKind::Import(_)) { - // Imported globals are host-managed and per-instance. The host - // creates a fresh `WebAssembly.Global` for each process (e.g. - // `env.__channel_base` gets the child's channel offset, not the - // parent's). Overwriting them from the parent's fork buffer - // would corrupt cross-process isolation — the child would end - // up making syscalls against the parent's channel region. + if imported_global_is_child_binding(module, g) { + // `env.__channel_base` is intentionally rebound to the child's + // syscall channel. Every other mutable scalar import has guest + // snapshot ownership and is restored below just like a local. continue; } saved_globals.push(SavedGlobal { @@ -233,19 +408,96 @@ fn inject_runtime_with_frame_storage(module: &mut Module, linked_frames: bool) - zero_const(ptr_ty), ); - let (frame_reserve, frame_commit, frame_next) = if linked_frames { + let ( + frame_reserve, + frame_commit, + frame_next, + frame_peek, + resume_peek, + resume_table, + reference_vector_begin, + reference_vector_append, + reference_vector_finish, + reference_vector_get, + unwind_tag, + reference_codecs, + ) = if linked_frames { let reserve_ty = module.types.add(&[ptr_ty], &[ptr_ty]); let commit_ty = module.types.add(&[ptr_ty], &[]); let next_ty = module.types.add(&[ptr_ty], &[ptr_ty]); + let resume_peek_ty = module.types.add(&[ValType::I32], &[ValType::I32]); + let reference_vector_begin_ty = module.types.add(&[ValType::I32], &[ValType::I32]); + let reference_vector_append_ty = module.types.add(&[ValType::I32, ValType::I32], &[]); + let reference_vector_finish_ty = module.types.add(&[ValType::I32], &[ValType::I32]); + let reference_vector_get_ty = module + .types + .add(&[ValType::I32, ValType::I32], &[ValType::I32]); + let unwind_ty = module.types.add(&[], &[]); let import_module = wasm_posix_shared::abi::WPK_FORK_FRAME_IMPORT_MODULE; let (reserve, _) = module.add_import_func(import_module, names::IMPORT_FRAME_RESERVE, reserve_ty); let (commit, _) = module.add_import_func(import_module, names::IMPORT_FRAME_COMMIT, commit_ty); let (next, _) = module.add_import_func(import_module, names::IMPORT_FRAME_NEXT, next_ty); - (Some(reserve), Some(commit), Some(next)) + let (peek, _) = module.add_import_func(import_module, names::IMPORT_FRAME_PEEK, next_ty); + let (resume_peek, _) = + module.add_import_func(import_module, names::IMPORT_RESUME_PEEK, resume_peek_ty); + let (reference_vector_begin, _) = module.add_import_func( + import_module, + names::IMPORT_REFERENCE_VECTOR_BEGIN, + reference_vector_begin_ty, + ); + let (reference_vector_append, _) = module.add_import_func( + import_module, + names::IMPORT_REFERENCE_VECTOR_APPEND, + reference_vector_append_ty, + ); + let (reference_vector_finish, _) = module.add_import_func( + import_module, + names::IMPORT_REFERENCE_VECTOR_FINISH, + reference_vector_finish_ty, + ); + let (reference_vector_get, _) = module.add_import_func( + import_module, + names::IMPORT_REFERENCE_VECTOR_GET, + reference_vector_get_ty, + ); + // WHY: this table is process-owned rather than module-owned. A replay + // edge may skip eliminated tail callers or cross a main/side-module + // boundary, so no individual module instance can be its registry. + // Slot zero remains null and is the explicit lexical-call sentinel. + let (resume_table, _) = module.add_import_table( + import_module, + names::IMPORT_RESUME_TABLE, + false, + 1, + None, + RefType::FUNCREF, + ); + let (unwind, _) = module.add_import_tag( + names::IMPORT_UNWIND_TAG_MODULE, + names::IMPORT_UNWIND_TAG, + unwind_ty, + ); + let reference_codecs = inject_reference_codecs(module, codec_overrides); + ( + Some(reserve), + Some(commit), + Some(next), + Some(peek), + Some(resume_peek), + Some(resume_table), + Some(reference_vector_begin), + Some(reference_vector_append), + Some(reference_vector_finish), + Some(reference_vector_get), + Some(unwind), + Some(reference_codecs), + ) } else { - (None, None, None) + ( + None, None, None, None, None, None, None, None, None, None, None, None, + ) }; // --- Control functions --- @@ -258,7 +510,13 @@ fn inject_runtime_with_frame_storage(module: &mut Module, linked_frames: bool) - &saved_globals, frames_start_offset, ); - let unwind_end = emit_end_fn(module, state_global, buf_global, ptr_ty); + let unwind_end = emit_end_fn( + module, + state_global, + buf_global, + ptr_ty, + codec_overrides.cleanup, + ); let rewind_begin = emit_rewind_begin( module, ptr_ty, @@ -268,7 +526,13 @@ fn inject_runtime_with_frame_storage(module: &mut Module, linked_frames: bool) - &saved_globals, STATE_REWINDING, ); - let rewind_end = emit_end_fn(module, state_global, buf_global, ptr_ty); + let rewind_end = emit_end_fn( + module, + state_global, + buf_global, + ptr_ty, + codec_overrides.cleanup, + ); let abort_begin = emit_rewind_begin( module, ptr_ty, @@ -278,7 +542,13 @@ fn inject_runtime_with_frame_storage(module: &mut Module, linked_frames: bool) - &saved_globals, STATE_ABORT_UNWINDING, ); - let abort_end = emit_end_fn(module, state_global, buf_global, ptr_ty); + let abort_end = emit_end_fn( + module, + state_global, + buf_global, + ptr_ty, + codec_overrides.cleanup, + ); let state = emit_state_fn(module, state_global); // --- Exports --- @@ -314,12 +584,93 @@ fn inject_runtime_with_frame_storage(module: &mut Module, linked_frames: bool) - frame_reserve, frame_commit, frame_next, + frame_peek, + resume_peek, + resume_table, + reference_vector_begin, + reference_vector_append, + reference_vector_finish, + reference_vector_get, + unwind_tag, + reference_codecs, saved_globals, frames_start_offset, fixed_prefix_size, } } +fn inject_reference_codecs( + module: &mut Module, + overrides: ReferenceCodecOverrides, +) -> ReferenceCodecs { + fn add_pair( + module: &mut Module, + reference: walrus::RefType, + encode_name: &str, + decode_name: &str, + ) -> (FunctionId, FunctionId) { + let reference = ValType::Ref(reference); + let encode_ty = module.types.add(&[reference], &[ValType::I32]); + let decode_ty = module.types.add(&[ValType::I32], &[reference]); + let (encode, _) = + module.add_import_func(names::IMPORT_REFERENCE_CODEC_MODULE, encode_name, encode_ty); + let (decode, _) = + module.add_import_func(names::IMPORT_REFERENCE_CODEC_MODULE, decode_name, decode_ty); + (encode, decode) + } + + let (encode_funcref, decode_funcref) = overrides.funcref.unwrap_or_else(|| { + add_pair( + module, + walrus::RefType::FUNCREF, + names::IMPORT_REF_ENCODE_FUNCREF, + names::IMPORT_REF_DECODE_FUNCREF, + ) + }); + let (encode_externref, decode_externref) = overrides.externref.unwrap_or_else(|| { + add_pair( + module, + walrus::RefType::EXTERNREF, + names::IMPORT_REF_ENCODE_EXTERNREF, + names::IMPORT_REF_DECODE_EXTERNREF, + ) + }); + let (encode_exnref, decode_exnref) = overrides.exnref.unwrap_or_else(|| { + add_pair( + module, + walrus::RefType::EXNREF, + names::IMPORT_REF_ENCODE_EXNREF, + names::IMPORT_REF_DECODE_EXNREF, + ) + }); + let (encode_anyref, decode_anyref) = overrides.anyref.unwrap_or_else(|| { + add_pair( + module, + walrus::RefType::ANYREF, + names::IMPORT_REF_ENCODE_ANYREF, + names::IMPORT_REF_DECODE_ANYREF, + ) + }); + ReferenceCodecs { + encode_funcref, + decode_funcref, + encode_externref, + decode_externref, + encode_exnref, + decode_exnref, + encode_anyref, + decode_anyref, + } +} + +fn imported_global_is_child_binding(module: &Module, global: &walrus::Global) -> bool { + let walrus::GlobalKind::Import(import_id) = global.kind else { + return false; + }; + let import = module.imports.get(import_id); + import.module == "env" && import.name == "__channel_base" +} + /// Emit `wpk_fork_unwind_begin(buf: ptr) -> ()`: /// 1. `_wpk_fork_state := UNWINDING` /// 2. `_wpk_fork_buf := buf` @@ -499,9 +850,16 @@ fn emit_end_fn( state_global: GlobalId, buf_global: GlobalId, ptr_ty: ValType, + codec_cleanup: Option, ) -> FunctionId { let mut builder = FunctionBuilder::new(&mut module.types, &[], &[]); let mut body = builder.func_body(); + if let Some(cleanup) = codec_cleanup { + // WHY: replay caches preserve alias identity only while a transition + // is active. Guest locals/globals now own every surviving reference, + // so retaining provider copies would create unbounded hidden GC roots. + body.call(cleanup); + } // WHY: a continuation mapping stops belonging to this module at every end // transition. Clear the alias to released/reusable storage before // publishing NORMAL; correctness still comes from having no NORMAL-state diff --git a/crates/fork-instrument/src/static_reference_catalog.rs b/crates/fork-instrument/src/static_reference_catalog.rs new file mode 100644 index 0000000000..b7b4221e78 --- /dev/null +++ b/crates/fork-instrument/src/static_reference_catalog.rs @@ -0,0 +1,284 @@ +//! Fresh-instance identities for statically initialized GC references. +//! +//! A continuation recipe must not structurally clone a reference that is also +//! recreated by module instantiation. Doing so would produce two objects in +//! the child and make `ref.eq` observe a fork-only identity split. +//! +//! The exported catalog is deliberately a *harvest buffer*, not permanent +//! module-instance storage. Immediately after instantiation the host calls the +//! generated harvest function, records weak object-to-ordinal mappings, and +//! clears every table entry. Immutable globals are read directly. Allocating +//! element expressions are copied one at a time from their still-live segment, +//! and a table initializer is read from its first initialized slot. Therefore +//! the pass neither evaluates an allocating expression twice nor hoists it into +//! a new immutable global that would retain a stale GC root forever. + +use std::collections::HashMap; + +use walrus::{ + AbstractHeapType, ConstExpr, ElementId, ElementItems, FunctionBuilder, GlobalId, GlobalKind, + HeapType, Module, RawCustomSection, RefType, TableId, ValType, + ir::{RefNull, TableFill, TableGet, TableInit, TableSet}, +}; + +pub const EXPORT: &str = "__wpk_fork_static_root_catalog"; +pub const HARVEST_EXPORT: &str = "__wpk_fork_static_root_harvest"; +pub const FORMAT_SECTION: &str = "kandelo.wpk_fork.static_root_catalog"; +pub const FORMAT_MAGIC: [u8; 4] = *b"KFSR"; +pub const FORMAT_VERSION: u16 = 1; +pub const FORMAT_HEADER_SIZE: u16 = 12; + +#[derive(Debug, Clone, Copy)] +enum RootSource { + Global(GlobalId), + TableFirst { table: TableId, table64: bool }, + ElementItem { element: ElementId, index: u32 }, +} + +#[derive(Debug, Default)] +pub struct StaticReferenceCatalogPlan { + roots: Vec, +} + +impl StaticReferenceCatalogPlan { + pub fn root_count(&self) -> usize { + self.roots.len() + } +} + +#[derive(Default)] +struct RootOrdinals { + roots: Vec, + by_global: HashMap, +} + +impl RootOrdinals { + fn intern_source(&mut self, source: RootSource) -> u32 { + let ordinal = u32::try_from(self.roots.len()) + .expect("static reference catalog exceeds the Wasm u32 index space"); + self.roots.push(source); + ordinal + } + + fn intern_global(&mut self, global: GlobalId) -> u32 { + if let Some(ordinal) = self.by_global.get(&global) { + return *ordinal; + } + let ordinal = self.intern_source(RootSource::Global(global)); + self.by_global.insert(global, ordinal); + ordinal + } + + fn alias(&mut self, alias: GlobalId, target: GlobalId) { + let ordinal = self.intern_global(target); + self.by_global.insert(alias, ordinal); + } +} + +/// Identify the template roots present in the source artifact. +/// +/// This must run before module-state planning. That pass converts active +/// element segments to passive segments, but preserves their IDs and +/// expressions; the harvest helper injected afterward can therefore copy the +/// exact already-instantiated object from each segment before bootstrap drops +/// it. +pub fn plan(module: &mut Module) -> StaticReferenceCatalogPlan { + let mut ordinals = RootOrdinals::default(); + + // Include immutable imports as well as locals. A local global.get alias + // folds onto its source coordinate, including a root supplied by another + // activation. + let globals: Vec<_> = module + .globals + .iter() + .filter_map(|global| { + let ValType::Ref(reference) = global.ty else { + return None; + }; + if global.mutable || !can_participate_in_ref_eq(module, reference) { + return None; + } + let source = match &global.kind { + GlobalKind::Local(ConstExpr::Global(target)) => Some(*target), + GlobalKind::Local(ConstExpr::RefNull(_) | ConstExpr::RefFunc(_)) => return None, + GlobalKind::Local(_) | GlobalKind::Import(_) => None, + }; + Some((global.id(), source)) + }) + .collect(); + for (global, source) in globals { + if let Some(target) = source { + ordinals.alias(global, target); + } else { + ordinals.intern_global(global); + } + } + + // A table declaration evaluates its initializer once and fills every + // initial slot with that one value. Reading slot zero after instantiation + // obtains the exact root without reevaluating the expression. A zero-sized + // table exposes no root and needs no identity coordinate. + let tables: Vec<_> = module + .tables + .iter() + .filter_map(|table| { + if table.import.is_some() + || table.initial == 0 + || !can_participate_in_ref_eq(module, table.element_ty) + { + return None; + } + table + .init + .as_ref() + .cloned() + .map(|initializer| (table.id(), table.table64, initializer)) + }) + .collect(); + for (table, table64, initializer) in tables { + match initializer { + ConstExpr::RefNull(_) | ConstExpr::RefFunc(_) => {} + ConstExpr::Global(global) => { + ordinals.intern_global(global); + } + _ => { + ordinals.intern_source(RootSource::TableFirst { table, table64 }); + } + } + } + + // Element expressions are instantiated once into their segment. Copy only + // allocating entries into the harvest table; global.get aliases reuse the + // global coordinate and null/function entries have other owners. + let elements: Vec<_> = module + .elements + .iter() + .filter_map(|element| { + let ElementItems::Expressions(reference, expressions) = &element.items else { + return None; + }; + if !can_participate_in_ref_eq(module, *reference) { + return None; + } + Some((element.id(), expressions.clone())) + }) + .collect(); + for (element, expressions) in elements { + for (index, initializer) in expressions.into_iter().enumerate() { + match initializer { + ConstExpr::RefNull(_) | ConstExpr::RefFunc(_) => {} + ConstExpr::Global(global) => { + ordinals.intern_global(global); + } + _ => { + ordinals.intern_source(RootSource::ElementItem { + element, + index: u32::try_from(index) + .expect("element segment exceeds the Wasm u32 index space"), + }); + } + } + } + } + + StaticReferenceCatalogPlan { + roots: ordinals.roots, + } +} + +/// Inject an initially-null fixed harvest table and its one-shot population +/// helper after guest module-state planning has completed. +pub fn inject(module: &mut Module, plan: StaticReferenceCatalogPlan) { + let count = u64::try_from(plan.roots.len()) + .expect("static reference catalog length exceeds the Wasm table index space"); + let table = module + .tables + .add_local(false, count, Some(count), RefType::ANYREF); + module.tables.get_mut(table).name = Some(EXPORT.into()); + module.exports.add(EXPORT, table); + + let mut builder = FunctionBuilder::new(&mut module.types, &[], &[]); + builder.name(HARVEST_EXPORT.into()); + { + let mut body = builder.func_body(); + if count != 0 { + // Make repeat invocation deterministic if registration failed + // after a partial host read. Successful registration clears the + // same table immediately and never calls harvest again. + body.i32_const(0) + .instr(RefNull { + ty: RefType::ANYREF, + }) + .i32_const(count as u32 as i32) + .instr(TableFill { table }); + } + for (ordinal, source) in plan.roots.into_iter().enumerate() { + let ordinal = u32::try_from(ordinal) + .expect("static reference catalog exceeds the Wasm u32 index space"); + match source { + RootSource::Global(global) => { + body.i32_const(ordinal as i32) + .global_get(global) + .instr(TableSet { table }); + } + RootSource::TableFirst { + table: source, + table64, + } => { + body.i32_const(ordinal as i32); + if table64 { + body.i64_const(0); + } else { + body.i32_const(0); + } + body.instr(TableGet { table: source }) + .instr(TableSet { table }); + } + RootSource::ElementItem { element, index } => { + body.i32_const(ordinal as i32) + .i32_const(index as i32) + .i32_const(1) + .instr(TableInit { + table, + elem: element, + }); + } + } + } + } + let harvest = builder.finish(Vec::new(), &mut module.funcs); + module.exports.add(HARVEST_EXPORT, harvest); + + let mut descriptor = Vec::with_capacity(usize::from(FORMAT_HEADER_SIZE)); + descriptor.extend_from_slice(&FORMAT_MAGIC); + descriptor.extend_from_slice(&FORMAT_VERSION.to_le_bytes()); + descriptor.extend_from_slice(&FORMAT_HEADER_SIZE.to_le_bytes()); + descriptor.extend_from_slice( + &u32::try_from(count) + .expect("static reference catalog length exceeds u32") + .to_le_bytes(), + ); + module.customs.add(RawCustomSection { + name: FORMAT_SECTION.into(), + data: descriptor, + }); +} + +fn can_participate_in_ref_eq(module: &Module, reference: RefType) -> bool { + match reference.heap_type { + HeapType::Abstract(kind) => matches!( + kind, + AbstractHeapType::Any + | AbstractHeapType::None + | AbstractHeapType::Eq + | AbstractHeapType::Struct + | AbstractHeapType::Array + | AbstractHeapType::I31 + ), + HeapType::Concrete(ty) | HeapType::Exact(ty) => { + let kind = module.types.get(ty).kind(); + kind.is_struct() || kind.is_array() + } + _ => false, + } +} diff --git a/crates/fork-instrument/tests/abort_restart_node.rs b/crates/fork-instrument/tests/abort_restart_node.rs new file mode 100644 index 0000000000..1c33ec5f7a --- /dev/null +++ b/crates/fork-instrument/tests/abort_restart_node.rs @@ -0,0 +1,175 @@ +use std::{ + fs, + process::Command, + time::{SystemTime, UNIX_EPOCH}, +}; + +use fork_instrument::{Options, instrument}; + +const MULTI_RESULT_REFERENCE_ABORT: &str = r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (memory (export "memory") 4) + + ;; The scalar below the fork result is a real operand-stack carryover. + ;; Returning externref as a second result also exercises a result-typed + ;; private-tag boundary without requiring a synthetic result local. + (func $callee (result i32 externref) + i32.const 100 + call $fork + i32.add + ref.null extern) + + (func (export "run") (result i32 externref) + call $callee)) +"#; + +#[test] +fn synchronous_frame_reserve_failure_restarts_live_activation_without_selector_local() { + let input = wat::parse_str(MULTI_RESULT_REFERENCE_ABORT).expect("parse abort fixture"); + let output = instrument(&input, &Options::default()).expect("instrument abort fixture"); + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "kandelo-fork-abort-restart-{}-{nonce}", + std::process::id(), + )); + fs::create_dir(&directory).expect("create abort fixture directory"); + fs::write(directory.join("fixture.wasm"), output).expect("write abort fixture"); + fs::write( + directory.join("test.mjs"), + r#" +import { readFileSync } from "node:fs"; + +const module = new WebAssembly.Module( + readFileSync(new URL("./fixture.wasm", import.meta.url)), +); +const imports = {}; +let instance; +let reserveCalls = 0; +let frameNextCalls = 0; +let frameCommitCalls = 0; +let forkCalls = 0; +const root = 0x10000; + +function hostCall(descriptor, args) { + if (descriptor.name === "__wpk_fork_frame_reserve") { + reserveCalls += 1; + if (instance.exports.wpk_fork_state() !== 1) { + throw new Error("frame reserve did not run during unwind"); + } + // This models the host's synchronous allocation-failure contract: replay + // cursors and ABORT_UNWINDING state are ready before zero is returned. + instance.exports.wpk_fork_abort_begin(root); + if (instance.exports.wpk_fork_state() !== 3) { + throw new Error("abort replay was not established synchronously"); + } + return 0; + } + if (descriptor.name === "__wpk_fork_frame_next") { + frameNextCalls += 1; + throw new Error("live abort restart must not consume a replay frame"); + } + if (descriptor.name === "__wpk_fork_frame_commit") { + frameCommitCalls += 1; + throw new Error("failed reservation must not commit a continuation frame"); + } + if (descriptor.name === "__wpk_fork_resume_peek") { + return 0; + } + if ( + descriptor.module === "kernel" + && descriptor.name === "kernel_fork" + ) { + forkCalls += 1; + const state = instance.exports.wpk_fork_state(); + if (state === 0) { + instance.exports.wpk_fork_unwind_begin(root); + return 0; + } + if (state === 3) { + instance.exports.wpk_fork_abort_end(); + return 7; + } + throw new Error(`kernel_fork observed unexpected state ${state}`); + } + // No reference/module-state codec import is live in this fixture. + return 0; +} + +for (const descriptor of WebAssembly.Module.imports(module)) { + const namespace = imports[descriptor.module] ??= {}; + switch (descriptor.kind) { + case "function": + namespace[descriptor.name] = (...args) => hostCall(descriptor, args); + break; + case "table": + namespace[descriptor.name] = new WebAssembly.Table({ + element: descriptor.name === "__wpk_fork_ref_gc_transit" + ? "anyref" + : "anyfunc", + initial: 64, + }); + break; + case "global": + namespace[descriptor.name] = + descriptor.name === "__wpk_fork_module_state_table_generation_addr" + ? new WebAssembly.Global({ value: "i64", mutable: false }, 0n) + : new WebAssembly.Global({ value: "i32", mutable: false }, 0); + break; + case "tag": + namespace[descriptor.name] = new WebAssembly.Tag({ parameters: [] }); + break; + default: + throw new Error( + `unexpected import ${descriptor.module}.${descriptor.name} (${descriptor.kind})`, + ); + } +} + +instance = new WebAssembly.Instance(module, imports); +const result = instance.exports.run(); +if (!Array.isArray(result) || result[0] !== 107 || result[1] !== null) { + throw new Error(`abort restart returned ${JSON.stringify(result)}`); +} +if (instance.exports.wpk_fork_state() !== 0) { + throw new Error("abort restart did not return the module to NORMAL"); +} +if (reserveCalls !== 1 || forkCalls !== 2) { + throw new Error( + `expected one failed reserve and two fork entries; got ${reserveCalls}/${forkCalls}`, + ); +} +if (frameNextCalls !== 0 || frameCommitCalls !== 0) { + throw new Error( + `failed reserve touched committed frames: next=${frameNextCalls}, commit=${frameCommitCalls}`, + ); +} + +// The helper selected the descriptor's header-sized abort scratch and wrote +// callee call-index zero there before restarting the live activation. +const callIndex = new DataView(instance.exports.memory.buffer) + .getUint32(root + 8 + 4, true); +if (callIndex !== 0) { + throw new Error(`abort scratch contains call index ${callIndex}, expected 0`); +} +"#, + ) + .expect("write Node abort test"); + + let result = Command::new("node") + .arg("--experimental-wasm-exnref") + .arg(directory.join("test.mjs")) + .output() + .expect("run Node abort test"); + let _ = fs::remove_dir_all(&directory); + assert!( + result.status.success(), + "Node synchronous-abort restart failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr), + ); +} diff --git a/crates/fork-instrument/tests/call_graph.rs b/crates/fork-instrument/tests/call_graph.rs index fefcb897b1..aa4595662c 100644 --- a/crates/fork-instrument/tests/call_graph.rs +++ b/crates/fork-instrument/tests/call_graph.rs @@ -1,10 +1,13 @@ -//! Tests for Phase 2: direct-call graph discovery. +//! Tests for semantic fork call-graph discovery. //! //! Each fixture is a small WAT module whose structure lets us assert -//! exactly which functions should be reported as reaching the -//! `kernel.kernel_fork` import through direct calls. +//! exactly which activations can survive while execution reaches the +//! `kernel.kernel_fork` import through direct, table, typed-ref, or tail edges. -use fork_instrument::{Options, analyze}; +use fork_instrument::{ + Options, analyze, + call_graph::{self, TailCallKind}, +}; use std::collections::HashSet; fn discover(wat_src: &str) -> HashSet { @@ -13,6 +16,35 @@ fn discover(wat_src: &str) -> HashSet { analysis.fork_path.iter().map(|e| e.name.clone()).collect() } +fn discover_semantic_activations(wat_src: &str) -> HashSet { + let bytes = wat::parse_str(wat_src).expect("wat parse"); + let module = walrus::Module::from_buffer(&bytes).expect("walrus parse"); + let seed = + call_graph::find_import_func(&module, "kernel.kernel_fork").expect("fork seed import"); + call_graph::analyze_reaching_closure(&module, seed) + .activations + .into_iter() + .map(|id| call_graph::func_display_name(&module, id)) + .collect() +} + +fn discover_tail_landings(wat_src: &str) -> HashSet<(String, TailCallKind)> { + let bytes = wat::parse_str(wat_src).expect("wat parse"); + let module = walrus::Module::from_buffer(&bytes).expect("walrus parse"); + let seed = + call_graph::find_import_func(&module, "kernel.kernel_fork").expect("fork seed import"); + call_graph::analyze_reaching_closure(&module, seed) + .tail_call_landings + .into_iter() + .map(|site| { + ( + call_graph::func_display_name(&module, site.caller), + site.kind, + ) + }) + .collect() +} + #[test] fn seed_alone_when_nothing_calls_fork() { // No function in the module calls $fork. The result should just be @@ -126,6 +158,30 @@ fn missing_entry_import_is_an_error() { ); } +#[test] +fn lowered_legacy_loader_is_a_boundary_without_a_direct_fork_import() { + let wat = r#" + (module + (import "env" "__wasm_dlopen" + (func $legacy (param i32 i32) (result i32))) + (memory 1) + (table (export "__indirect_function_table") 1 funcref) + (func $open_side (export "open_side") (result i32) + i32.const 100 + i32.const 20 + call $legacy)) + "#; + let found = discover(wat); + assert!( + found.contains("open_side"), + "the caller remains live while the generated driver invokes a side initializer: {found:?}", + ); + assert!( + found.contains("__wpk_fork_legacy_dlopen_driver"), + "the staged driver's external call_indirect is the suspension boundary: {found:?}", + ); +} + #[test] fn custom_entry_import_name() { // The entry import is configurable; verify. @@ -144,6 +200,97 @@ fn custom_entry_import_name() { assert!(analysis.fork_path.iter().any(|e| e.name == "a")); } +#[test] +fn duplicate_entry_import_declarations_are_all_roots() { + // A module/name pair is not a unique function identity in Wasm. Both + // declarations can be called by different live activations and therefore + // must seed the configured main-module closure. + let wat = r#" + (module + (import "kernel" "kernel_fork" (func $fork0 (result i32))) + (import "kernel" "kernel_fork" (func $fork1 (param i32) (result i32))) + (func $calls_first (export "calls_first") (result i32) + call $fork0) + (func $calls_second (export "calls_second") (result i32) + i32.const 7 + call $fork1)) + "#; + let found = discover(wat); + assert!( + found.contains("calls_first"), + "first declaration caller: {found:?}" + ); + assert!( + found.contains("calls_second"), + "second declaration caller: {found:?}" + ); + assert_eq!( + found.len(), + 4, + "both imports and both callers must be present: {found:?}" + ); +} + +#[test] +fn dylink_side_module_covers_every_cross_module_boundary() { + // Side A can remain live while an imported function in side B forks even + // though A has no env.fork import. An unresolved table/reference dispatch + // can cross the same boundary. Tail callers remain transparent, but their + // older ordinary callers still own resumable activation frames. + let wat = r#" + (module + (@custom "dylink.0" (before first) "side") + (type $ft (func (result i32))) + (import "env" "side_b" (func $side_b (type $ft))) + (table $dispatch 1 funcref) + (func $direct (export "direct") (result i32) + call $side_b) + (func $direct_parent (export "direct_parent") (result i32) + call $direct) + (func $indirect (export "indirect") (result i32) + i32.const 0 + call_indirect $dispatch (type $ft)) + (func $reference (export "reference") + (param $callee (ref null $ft)) (result i32) + local.get $callee + call_ref $ft) + (func $tail_import (export "tail_import") (result i32) + return_call $side_b) + (func $tail_parent (export "tail_parent") (result i32) + call $tail_import) + (func $unrelated (export "unrelated") (result i32) + i32.const 42)) + "#; + let bytes = wat::parse_str(wat).expect("wat parse"); + let analysis = analyze(&bytes, &Options::default()).expect("side-boundary analysis"); + let found: HashSet<_> = analysis + .fork_path + .iter() + .map(|entry| entry.name.as_str()) + .collect(); + + for expected in [ + "direct", + "direct_parent", + "indirect", + "reference", + "tail_parent", + ] { + assert!( + found.contains(expected), + "{expected} must be activation-owned above a cross-module boundary: {found:?}" + ); + } + assert!( + !found.contains("tail_import"), + "a true tail caller has no surviving activation: {found:?}" + ); + assert!( + !found.contains("unrelated"), + "a static local leaf must retain zero instrumentation overhead: {found:?}" + ); +} + #[test] fn cycle_terminates() { // $a calls $b, $b calls $a, $b calls $fork. Cycle must not loop. @@ -417,6 +564,42 @@ fn dynamic_linker_indirect_call_is_conservative_fork_boundary() { ); } +#[test] +fn dynamic_linker_host_calls_are_direct_fork_boundaries() { + // dlopen can synchronously enter a side module's deferred start and + // constructors. The main activation at the host import therefore survives + // a downstream side-module fork even when it performs no table dispatch. + let wat = r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (import "env" "__wasm_dlopen" + (func $dlopen (param i32 i32 i32 i32) (result i32))) + (func $open_side (export "open_side") (result i32) + i32.const 100 + i32.const 20 + i32.const 200 + i32.const 10 + call $dlopen) + (func $open_parent (export "open_parent") (result i32) + call $open_side) + (func $ordinary (export "ordinary") (result i32) + i32.const 7)) + "#; + let found = discover(wat); + assert!( + found.contains("open_side"), + "dlopen caller must survive: {found:?}" + ); + assert!( + found.contains("open_parent"), + "ordinary callers above dlopen must survive: {found:?}" + ); + assert!( + !found.contains("ordinary"), + "unrelated local work must stay outside the closure: {found:?}" + ); +} + #[test] fn constant_slot_pointing_to_safe_target_excludes_indirect_caller() { // Both functions have the same signature and inhabit the same table. @@ -557,8 +740,123 @@ fn dynamic_table_write_preserves_conservative_indirect_inclusion() { } #[test] -fn return_call_and_return_call_indirect_follow_reachability_rules() { - // Tail-call variants must be graph-equivalent to ordinary calls. +fn call_ref_uses_precise_ref_func_provenance_when_available() { + // Both possible targets have the same type. Immediate ref.func provenance + // proves that one call is safe and the other reaches fork, avoiding the + // all-compatible fallback for these statically named callees. + let wat = r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (type $ft (func (result i32))) + (elem declare func $safe_target $fork_target) + (func $safe_target (result i32) + i32.const 7) + (func $fork_target (result i32) + call $fork) + (func $calls_safe_ref (export "calls_safe_ref") (result i32) + ref.func $safe_target + call_ref $ft) + (func $calls_fork_ref (export "calls_fork_ref") (result i32) + ref.func $fork_target + call_ref $ft)) + "#; + let found = discover(wat); + assert!(found.contains("calls_fork_ref"), "{found:?}"); + assert!( + !found.contains("calls_safe_ref"), + "precise ref.func must not become an all-signature edge: {found:?}" + ); +} + +#[test] +fn call_ref_with_unknown_provenance_includes_all_type_compatible_targets() { + // A reference parameter can name any compatible function. Once a + // compatible target reaches fork, the caller must be in the closure. + let wat = r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (type $ft (func (result i32))) + (func $fork_target (type $ft) (result i32) + call $fork) + (func $calls_unknown_ref + (export "calls_unknown_ref") + (param (ref null $ft)) + (result i32) + local.get 0 + call_ref $ft)) + "#; + let found = discover(wat); + assert!( + found.contains("calls_unknown_ref"), + "unknown call_ref provenance must cover compatible fork targets: {found:?}" + ); +} + +#[test] +fn call_ref_unknown_provenance_honors_declared_function_subtyping() { + // call_ref $base accepts a ref to a declared subtype. Exact TypeId or + // structural-equality-only matching would miss this valid dispatch edge. + let wat = r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (type $base (sub (func (result i32)))) + (type $derived (sub $base (func (result i32)))) + (func $derived_target (type $derived) (result i32) + call $fork) + (func $calls_base_ref + (export "calls_base_ref") + (param (ref null $base)) + (result i32) + local.get 0 + call_ref $base)) + "#; + let found = discover(wat); + assert!( + found.contains("calls_base_ref"), + "declared function subtype must satisfy the call_ref edge: {found:?}" + ); +} + +#[test] +fn direct_indirect_and_call_ref_edges_share_one_fixed_point() { + // target -> call_ref caller -> call_indirect caller -> direct caller. + // Computing each edge class only once, in phases, would miss the outer + // activations after a later edge class discovers a new inner function. + let wat = r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (type $target_ty (func (result i32))) + (type $ref_caller_ty (func (result i64))) + (table 1 funcref) + (elem (i32.const 0) $ref_caller) + (elem declare func $target) + (func $target (type $target_ty) (result i32) + call $fork) + (func $ref_caller (type $ref_caller_ty) (result i64) + ref.func $target + call_ref $target_ty + drop + i64.const 1) + (func $indirect_caller (export "indirect_caller") (result i64) + i32.const 0 + call_indirect (type $ref_caller_ty)) + (func $outer (export "outer") (result i64) + call $indirect_caller)) + "#; + let found = discover(wat); + for name in ["target", "ref_caller", "indirect_caller", "outer"] { + assert!( + found.contains(name), + "mixed-edge fixed point missed {name}: {found:?}" + ); + } +} + +#[test] +fn tail_calls_are_transparent_to_the_activation_closure() { + // A true tail call replaces its caller activation. The tail caller must + // still be traversed so an older ordinary caller is found, but the + // eliminated frame itself must not be serialized. let wat = r#" (module (import "kernel" "kernel_fork" (func $fork (result i32))) @@ -567,30 +865,151 @@ fn return_call_and_return_call_indirect_follow_reachability_rules() { (elem (i32.const 0) $tail_indirect_target) (func $tail_direct (export "tail_direct") (result i32) return_call $fork) + (func $above_tail_direct (export "above_tail_direct") (result i32) + call $tail_direct) (func $tail_indirect_target (export "tail_indirect_target") (result i32) call $fork) (func $calls_tail_indirect (export "calls_tail_indirect") (result i32) i32.const 0 - return_call_indirect (type $ft))) + return_call_indirect (type $ft)) + (func $above_tail_indirect (export "above_tail_indirect") (result i32) + call $calls_tail_indirect)) "#; - let found = discover(wat); - for name in ["tail_direct", "tail_indirect_target", "calls_tail_indirect"] { + let found = discover_semantic_activations(wat); + for name in [ + "above_tail_direct", + "tail_indirect_target", + "above_tail_indirect", + ] { assert!(found.iter().any(|n| n == name), "missing {name}: {found:?}"); } + for name in ["tail_direct", "calls_tail_indirect"] { + assert!( + !found.iter().any(|n| n == name), + "tail-eliminated activation {name} must not be serialized: {found:?}" + ); + } +} + +#[test] +fn suspension_capable_tail_sites_are_reported_without_becoming_activations() { + // The activation graph stays semantically truthful. Exact tail sites + // remain useful diagnostics, but replay no longer lowers them into frames. + let wat = r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (type $ft (func (result i32))) + (table 1 funcref) + (elem (i32.const 0) $fork_target) + (elem declare func $fork_target) + (func $fork_target (result i32) + call $fork) + (func $tail_direct (export "tail_direct") (result i32) + return_call $fork) + (func $tail_indirect (export "tail_indirect") (result i32) + i32.const 0 + return_call_indirect (type $ft)) + (func $tail_ref (export "tail_ref") (result i32) + ref.func $fork_target + return_call_ref $ft)) + "#; + let found = discover_tail_landings(wat); + for expected in [ + ("tail_direct".to_string(), TailCallKind::Direct), + ("tail_indirect".to_string(), TailCallKind::Indirect), + ("tail_ref".to_string(), TailCallKind::Ref), + ] { + assert!( + found.contains(&expected), + "missing tail landing {expected:?}: {found:?}" + ); + } +} + +#[test] +fn public_analysis_does_not_report_eliminated_tail_callers() { + let wat = r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (func $tail_direct (export "tail_direct") (result i32) + return_call $fork)) + "#; + let found = discover(wat); + assert!( + !found.contains("tail_direct"), + "the public transform analysis must not invent an activation for a \ + suspension-capable tail edge: {found:?}" + ); } #[test] -fn indirect_closure_allows_two_hops_but_does_not_cascade_forever() { - // Models trampoline-shaped runtimes without allowing unbounded - // same-table callback closure: +fn semantic_control_closure_retains_tail_nodes_without_materializing_frames() { + let wat = r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (type $ft (func (result i32))) + (table 1 funcref) + (elem (i32.const 0) $fork_target) + (elem declare func $fork_target) + (func $fork_target (result i32) + call $fork) + (func $ordinary_target (result i32) + i32.const 9) + (func $ordinary_tail (export "ordinary_tail") (result i32) + return_call $ordinary_target) + (func $tail_direct (export "tail_direct") (result i32) + return_call $fork) + (func $tail_indirect (export "tail_indirect") (result i32) + i32.const 0 + return_call_indirect (type $ft)) + (func $tail_ref (export "tail_ref") (result i32) + ref.func $fork_target + return_call_ref $ft)) + "#; + let bytes = wat::parse_str(wat).expect("wat parse"); + let module = walrus::Module::from_buffer(&bytes).expect("walrus parse"); + let seed = + call_graph::find_import_func(&module, "kernel.kernel_fork").expect("fork seed import"); + let after = call_graph::analyze_reaching_closure(&module, seed); + assert!( + after.tail_call_landings.len() == 3, + "all fork-reaching tail sites remain semantic tail edges: {:?}", + after.tail_call_landings + ); + let activation_names: HashSet<_> = after + .activations + .iter() + .map(|&id| call_graph::func_display_name(&module, id)) + .collect(); + for name in ["tail_direct", "tail_indirect", "tail_ref"] { + assert!( + !activation_names.contains(name), + "tail traversal must not materialize {name} as a real activation: \ + {activation_names:?}" + ); + let function = module + .funcs + .iter() + .find(|function| function.name.as_deref() == Some(name)) + .expect("named tail function"); + assert!( + after.control_reachable.contains(&function.id()), + "{name} must remain in the semantic call-target closure" + ); + } + assert!(!activation_names.contains("ordinary_tail")); +} + +#[test] +fn indirect_closure_reaches_a_fixed_point_beyond_two_hops() { + // Models a three-dispatch trampoline chain: // // $hop1 call_indirect -> $fork_target (depth 1) // $hop2 call_indirect -> $hop1 (depth 2) - // $false_positive call_indirect -> $hop2 (depth 3; excluded) + // $third_hop call_indirect -> $hop2 (depth 3) // - // The third edge uses a dynamic index and could dispatch to $hop2. - // The unchanged depth bound is the resource-safety guard that stops - // this kind of cascade. + // Every edge is a real possible fork path. A package-specific depth cap + // would miss the third activation and corrupt replay. let wat = r#" (module (import "kernel" "kernel_fork" (func $fork (result i32))) @@ -618,18 +1037,14 @@ fn indirect_closure_allows_two_hops_but_does_not_cascade_forever() { (func $safe_hop2_target (result f32) f32.const 0) - (func $false_positive (export "false_positive") (param i32) (result f32) + (func $third_hop (export "third_hop") (param i32) (result f32) local.get 0 call_indirect (type $hop2_ty))) "#; let found = discover(wat); - for name in ["fork_target", "hop1", "hop2"] { + for name in ["fork_target", "hop1", "hop2", "third_hop"] { assert!(found.iter().any(|n| n == name), "missing {name}: {found:?}"); } - assert!( - !found.iter().any(|n| n == "false_positive"), - "indirect closure should not cascade beyond two dispatch hops; got {found:?}" - ); assert!( !found.iter().any(|n| n == "safe_hop2_target"), "safe table target should not be pulled into the fork path; got {found:?}" diff --git a/crates/fork-instrument/tests/catch_selector_lifetime_node.rs b/crates/fork-instrument/tests/catch_selector_lifetime_node.rs new file mode 100644 index 0000000000..997bd194cd --- /dev/null +++ b/crates/fork-instrument/tests/catch_selector_lifetime_node.rs @@ -0,0 +1,709 @@ +use std::{ + fs, + process::Command, + time::{SystemTime, UNIX_EPOCH}, +}; + +use fork_instrument::{Options, instrument}; + +const SEQUENTIAL_REGIONS: &str = r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (tag $a (param i32)) + (tag $b (param i32)) + (memory (export "memory") 8) + + (func (export "run") + (local $phase i32) + (local $caught i32) + (loop $again + (block $skip_a + (block $caught_a (result i32) + (try_table (catch $a $caught_a) + local.get $phase + if + i32.const 101 + throw $a + else + br $skip_a + end + unreachable) + unreachable) + local.set $caught + i32.const 4096 + call $fork + local.get $caught + i32.add + i32.store + return) + + (block $skip_b + (block $caught_b (result i32) + (try_table (catch $b $caught_b) + local.get $phase + i32.eqz + if + i32.const 202 + throw $b + else + br $skip_b + end + unreachable) + unreachable) + drop) + + i32.const 1 + local.set $phase + br $again))) +"#; + +const NESTED_REGIONS: &str = r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (tag $outer (param i32)) + (tag $inner (param i32)) + (memory (export "memory") 8) + + (func (export "run") + (local $phase i32) + (local $caught i32) + (loop $again + (block $outer_handler + (block $caught_outer (result i32) + (try_table (catch $outer $caught_outer) + i32.const 301 + throw $outer + unreachable) + unreachable) + local.set $caught + + local.get $phase + if + i32.const 4096 + call $fork + local.get $caught + i32.add + i32.store + return + end + + (block $caught_inner (result i32) + (try_table (catch $inner $caught_inner) + i32.const 302 + throw $inner + unreachable) + unreachable) + drop) + + i32.const 1 + local.set $phase + br $again))) +"#; + +const LOOP_REENTERED_ARMS: &str = r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (tag $a (param i32)) + (tag $b (param i32)) + (memory (export "memory") 8) + + (func (export "run") + (local $phase i32) + (local $caught i32) + (loop $again + (block $caught (result i32) + (try_table (catch $a $caught) (catch $b $caught) + local.get $phase + if + i32.const 401 + throw $a + else + i32.const 402 + throw $b + end + unreachable) + unreachable) + local.set $caught + + local.get $phase + if + i32.const 4096 + call $fork + local.get $caught + i32.add + i32.store + return + end + + i32.const 1 + local.set $phase + br $again))) +"#; + +const NESTED_INNER_LATEST: &str = r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (tag $outer (param i32)) + (tag $inner (param i32)) + (memory (export "memory") 8) + + (func (export "run") + (local $outer_value i32) + (local $inner_value i32) + (block $caught_outer (result i32) + (try_table (catch $outer $caught_outer) + i32.const 501 + throw $outer + unreachable) + unreachable) + local.set $outer_value + (block $caught_inner (result i32) + (try_table (catch $inner $caught_inner) + i32.const 502 + throw $inner + unreachable) + unreachable) + local.set $inner_value + i32.const 4096 + call $fork + local.get $outer_value + i32.add + local.get $inner_value + i32.add + i32.store)) +"#; + +const NESTED_RECIPE_REGIONS: &str = r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (tag $outer (param v128)) + (tag $inner (param v128)) + (memory (export "memory") 8) + + (func (export "run") + (local $outer_value i32) + (local $inner_value i32) + (block $outer_handler_scope + (block $caught_outer (result v128 exnref) + (try_table (catch_ref $outer $caught_outer) + v128.const i32x4 701 0 0 0 + throw $outer + unreachable) + unreachable) + drop + i32x4.extract_lane 0 + local.set $outer_value + + ;; This second recipe-backed catch executes in the continuation of + ;; the outer handler. Its selector and exception supersede the outer + ;; synthetic replay state, while the ordinary scalar value remains + ;; independently activation-owned. + (block $caught_inner (result v128 exnref) + (try_table (catch_ref $inner $caught_inner) + v128.const i32x4 702 0 0 0 + throw $inner + unreachable) + unreachable) + drop + i32x4.extract_lane 0 + local.set $inner_value + + i32.const 4096 + call $fork + local.get $outer_value + i32.add + local.get $inner_value + i32.add + i32.store)) + ) +"#; + +const SCALAR_SUPERSEDES_RECIPE: &str = r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (tag $recipe (param v128)) + (tag $scalar (param i32)) + (memory (export "memory") 8) + + (func (export "run") + (local $recipe_value i32) + (local $scalar_value i32) + (block $caught_recipe (result v128 exnref) + (try_table (catch_ref $recipe $caught_recipe) + v128.const i32x4 801 0 0 0 + throw $recipe + unreachable) + unreachable) + drop + i32x4.extract_lane 0 + local.set $recipe_value + + (block $caught_scalar (result i32) + (try_table (catch $scalar $caught_scalar) + i32.const 802 + throw $scalar + unreachable) + unreachable) + local.set $scalar_value + + i32.const 4096 + call $fork + local.get $recipe_value + i32.add + local.get $scalar_value + i32.add + i32.store)) +"#; + +const MERGED_NORMAL_AFTER_CATCH: &str = r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (tag $caught (param i32)) + (memory (export "memory") 8) + + (func (export "run") + (local $caught_value i32) + (block $handler (result i32) + (try_table (catch $caught $handler) + i32.const 601 + throw $caught + unreachable) + unreachable) + local.set $caught_value + + ;; Both the catch and normal predecessor have left their structured + ;; region before this ordinary merged suffix. Replay must dispatch + ;; directly to fork without executing the obsolete throw stub. + (block $merged + nop) + i32.const 4096 + call $fork + local.get $caught_value + i32.add + i32.store)) +"#; + +fn instrument_fixture(source: &str) -> Vec { + let input = wat::parse_str(source).expect("parse catch lifetime fixture"); + instrument(&input, &Options::default()).expect("instrument catch lifetime fixture") +} + +#[test] +fn fresh_replay_uses_dynamic_selector_without_reentering_obsolete_catches() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "kandelo-catch-selector-lifetime-{}-{nonce}", + std::process::id(), + )); + fs::create_dir(&directory).expect("create fixture directory"); + for (name, source, expected_selector, expected_result, expected_recipe) in [ + ("sequential", SEQUENTIAL_REGIONS, 1, 108, -1), + ("nested", NESTED_REGIONS, 1, 308, -1), + ("reentered", LOOP_REENTERED_ARMS, 1, 408, -1), + ("inner-latest", NESTED_INNER_LATEST, 2, 1010, -1), + ("nested-recipe", NESTED_RECIPE_REGIONS, 2, 1410, 1), + ( + "scalar-supersedes-recipe", + SCALAR_SUPERSEDES_RECIPE, + 2, + 1610, + 0, + ), + ("merged-normal", MERGED_NORMAL_AFTER_CATCH, 1, 608, -1), + ] { + fs::write( + directory.join(format!("{name}.wasm")), + instrument_fixture(source), + ) + .unwrap_or_else(|error| panic!("write {name} fixture: {error}")); + fs::write( + directory.join(format!("{name}.expect")), + format!("{expected_selector} {expected_result} {expected_recipe}\n"), + ) + .unwrap_or_else(|error| panic!("write {name} expectation: {error}")); + } + fs::write( + directory.join("test.mjs"), + r#" +import { readFileSync } from "node:fs"; + +function importsFor(module, role) { + const imports = {}; + for (const descriptor of WebAssembly.Module.imports(module)) { + const namespace = imports[descriptor.module] ??= {}; + switch (descriptor.kind) { + case "table": + namespace[descriptor.name] = new WebAssembly.Table({ + element: descriptor.name === "__wpk_fork_ref_gc_transit" + ? "anyref" + : "anyfunc", + initial: 1024, + }); + break; + case "global": + if (descriptor.name === "__wpk_fork_module_state_table_generation_addr") { + namespace[descriptor.name] = new WebAssembly.Global( + { value: "i64", mutable: false }, + 0n, + ); + break; + } + namespace[descriptor.name] = new WebAssembly.Global( + { value: "i32", mutable: false }, + 0, + ); + break; + case "tag": + namespace[descriptor.name] = new WebAssembly.Tag({ parameters: [] }); + break; + case "memory": + throw new Error(`unexpected memory import ${descriptor.module}.${descriptor.name}`); + case "function": + namespace[descriptor.name] = (...args) => role.call(descriptor, args); + break; + default: + throw new Error(`unexpected import kind ${descriptor.kind}`); + } + } + return imports; +} + +function captureAndReplay( + path, + expectedSelector, + expectedResult, + expectedRecipe, +) { + const module = new WebAssembly.Module(readFileSync(path)); + const frames = []; + const exceptionRecipes = new Map(); + const referenceVectors = new Map(); + const unhandled = Symbol("unhandled reference import"); + const root = 0x10000; + let nextPayload = 0x30000; + let nextExceptionRecipe = 1; + let nextReferenceVector = 1; + + function referenceCalls(getInstance) { + const exceptionIds = new WeakMap(); + const scratch = []; + let scratchTop = 0x20000; + const memory = () => getInstance().exports.memory; + const thrownFromSlot = (slot) => { + try { + getInstance().exports.__wpk_fork_ref_exn_throw_slot(slot); + } catch (value) { + if (!(value instanceof WebAssembly.Exception)) throw value; + return value; + } + throw new Error("exception scratch slot returned"); + }; + return (descriptor, args) => { + switch (descriptor.name) { + case "__wpk_fork_ref_vector_begin": { + const id = nextReferenceVector++; + referenceVectors.set(id, { + expected: Number(args[0]), + recipes: [], + }); + return id; + } + case "__wpk_fork_ref_vector_append": { + const vector = referenceVectors.get(Number(args[0])); + if (!vector || vector.recipes.length >= vector.expected) { + throw new Error("invalid reference-vector append"); + } + vector.recipes.push(Number(args[1])); + return 0; + } + case "__wpk_fork_ref_vector_finish": { + const id = Number(args[0]); + const vector = referenceVectors.get(id); + if (!vector || vector.recipes.length !== vector.expected) { + throw new Error("incomplete reference vector"); + } + return id; + } + case "__wpk_fork_ref_vector_get": { + const vector = referenceVectors.get(Number(args[0])); + const index = Number(args[1]); + if (!vector || vector.recipes.length !== vector.expected) { + throw new Error("incomplete reference vector"); + } + if (index < 0 || index >= vector.recipes.length) { + throw new Error("reference-vector index out of range"); + } + return vector.recipes[index]; + } + case "__wpk_fork_ref_exn_lookup": + return exceptionIds.get(thrownFromSlot(Number(args[0]))) ?? 0; + case "__wpk_fork_ref_exn_claim": { + const exception = thrownFromSlot(Number(args[0])); + let id = exceptionIds.get(exception); + if (id === undefined) { + id = nextExceptionRecipe++; + exceptionIds.set(exception, id); + } + return id; + } + case "__wpk_fork_ref_exn_define": { + const [ + id, activation, tag, layout, + scalarPointer, scalarLength, refsPointer, refCount, + ] = args.map(Number); + exceptionRecipes.set(id, { + activation, + tag, + layout, + scalars: new Uint8Array( + memory().buffer, + scalarPointer, + scalarLength, + ).slice(), + refs: new Uint32Array( + memory().buffer, + refsPointer, + refCount, + ).slice(), + }); + return 0; + } + case "__wpk_fork_ref_exn_load": { + const [ + id, activation, tag, layout, + scalarPointer, scalarLength, refsPointer, refCount, + ] = args.map(Number); + const recipe = exceptionRecipes.get(id); + if ( + !recipe + || recipe.activation !== activation + || recipe.tag !== tag + || recipe.layout !== layout + || recipe.scalars.length !== scalarLength + || recipe.refs.length !== refCount + ) return 0; + new Uint8Array(memory().buffer, scalarPointer, scalarLength) + .set(recipe.scalars); + new Uint32Array(memory().buffer, refsPointer, refCount) + .set(recipe.refs); + return 1; + } + case "__wpk_fork_ref_exn_route": { + const recipe = exceptionRecipes.get(Number(args[0])); + return recipe?.activation === Number(args[1]) + ? recipe.layout + : -1; + } + case "__wpk_fork_ref_exn_cache_index": + return Number(args[0]); + case "__wpk_fork_ref_exn_broker_encode": + case "__wpk_fork_ref_exn_broker_throw_recipe": + case "__wpk_fork_ref_exn_ingress_throw": + throw new Error("known local exception unexpectedly used broker routing"); + case "__wpk_fork_ref_scratch_reserve": { + const size = Number(args[0]); + const aligned = (size + 15) & ~15; + const address = scratchTop; + scratchTop += aligned; + scratch.push({ address, size, aligned }); + new Uint8Array(memory().buffer, address, aligned).fill(0); + return address; + } + case "__wpk_fork_ref_scratch_release": { + const address = Number(args[0]); + const size = Number(args[1]); + const reservation = scratch.pop(); + if ( + !reservation + || reservation.address !== address + || reservation.size !== size + ) throw new Error("non-LIFO exception scratch release"); + new Uint8Array(memory().buffer, address, reservation.aligned).fill(0); + scratchTop = address; + return 0; + } + default: + return unhandled; + } + }; + } + + let parent; + const parentReferenceCall = referenceCalls(() => parent); + const parentRole = { + call(descriptor, args) { + if (descriptor.name === "__wpk_fork_frame_reserve") { + const payload = nextPayload; + nextPayload += (Number(args[0]) + 15) & ~15; + frames.push({ payload, size: Number(args[0]) }); + return payload; + } + if (descriptor.name === "__wpk_fork_frame_commit") return; + if (descriptor.name === "__wpk_fork_frame_next") { + throw new Error("parent capture must not enter replay"); + } + if ( + descriptor.module === "kernel" + && descriptor.name === "kernel_fork" + ) { + parent.exports.wpk_fork_unwind_begin(root); + return 0; + } + const referenceResult = parentReferenceCall(descriptor, args); + if (referenceResult !== unhandled) return referenceResult; + return 0; + } + }; + parent = new WebAssembly.Instance(module, importsFor(module, parentRole)); + try { + parent.exports.run(); + } catch (error) { + if (!(error instanceof WebAssembly.Exception)) throw error; + } + if (parent.exports.wpk_fork_state() !== 1) { + throw new Error("fixture did not unwind from fork"); + } + if (frames.length !== 1) { + throw new Error(`expected one activation frame, got ${frames.length}`); + } + const parentMemory = parent.exports.memory; + if (!(parentMemory instanceof WebAssembly.Memory)) { + throw new Error("instrumented fixture did not export its staging memory"); + } + const selector = new DataView(parentMemory.buffer) + .getUint32(frames[0].payload + 8, true); + if (selector !== expectedSelector) { + throw new Error( + `expected dynamically latest selector ${expectedSelector}, got ${selector}`, + ); + } + const vectorId = new DataView(parentMemory.buffer) + .getUint32(frames[0].payload + 12, true); + if (expectedRecipe < 0) { + if (vectorId !== 0) { + throw new Error(`unexpected reference vector ${vectorId}`); + } + } else { + const vector = referenceVectors.get(vectorId); + if ( + !vector + || vector.expected !== 1 + || vector.recipes.length !== 1 + ) { + throw new Error( + `expected one pooled exception recipe, got ${ + JSON.stringify(vector ?? null) + }`, + ); + } + const recipe = vector.recipes[0]; + if (expectedRecipe === 0 && recipe !== 0) { + throw new Error( + `scalar catch retained superseded exception recipe ${recipe}`, + ); + } + if (expectedRecipe > 0 && recipe === 0) { + throw new Error("selected recipe catch encoded a null exception"); + } + } + parent.exports.wpk_fork_unwind_end(); + + let child; + const childReferenceCall = referenceCalls(() => child); + let nextFrame = 0; + const childRole = { + call(descriptor, args) { + if (descriptor.name === "__wpk_fork_frame_next") { + const frame = frames[nextFrame++]; + if (!frame) throw new Error("child requested an unexpected frame"); + return frame.payload; + } + if (descriptor.name === "__wpk_fork_frame_reserve") { + throw new Error("child replay must not reserve a continuation frame"); + } + if (descriptor.name === "__wpk_fork_frame_commit") { + throw new Error("child replay must not commit a continuation frame"); + } + if ( + descriptor.module === "kernel" + && descriptor.name === "kernel_fork" + ) { + if (child.exports.wpk_fork_state() !== 2) { + throw new Error("child did not reach fork while replaying"); + } + child.exports.wpk_fork_rewind_end(); + return 7; + } + const referenceResult = childReferenceCall(descriptor, args); + if (referenceResult !== unhandled) return referenceResult; + // Unused codecs and module-state helpers have no state in these + // focused fixtures. + return 0; + } + }; + child = new WebAssembly.Instance(module, importsFor(module, childRole)); + const childMemory = child.exports.memory; + if (!(childMemory instanceof WebAssembly.Memory)) { + throw new Error("fresh child did not export memory"); + } + new Uint8Array(childMemory.buffer).set(new Uint8Array(parentMemory.buffer)); + child.exports.wpk_fork_rewind_begin(root); + child.exports.run(); + if (nextFrame !== frames.length) { + throw new Error(`child consumed ${nextFrame}/${frames.length} frames`); + } + const replayResult = new DataView(childMemory.buffer).getInt32(4096, true); + if (replayResult !== expectedResult) { + throw new Error( + `fresh child replay produced ${replayResult}, expected ${expectedResult}`, + ); + } +} + +for (const name of [ + "sequential", + "nested", + "reentered", + // The selector deliberately remains nonzero in these two cases. Their fork + // call is after the selected try_table has completed, so the structured + // switch dispatcher must skip that obsolete throw stub in the fresh child. + "inner-latest", + "nested-recipe", + "scalar-supersedes-recipe", + "merged-normal", +]) { + const [selector, result, recipe] = readFileSync( + new URL(`./${name}.expect`, import.meta.url), + "utf8", + ).trim().split(/\s+/).map(Number); + try { + captureAndReplay( + new URL(`./${name}.wasm`, import.meta.url), + selector, + result, + recipe, + ); + } catch (error) { + error.message = `${name}: ${error.message}`; + throw error; + } +} +"#, + ) + .expect("write Node test"); + + let output = Command::new("node") + .arg(directory.join("test.mjs")) + .output() + .expect("run Node"); + let _ = fs::remove_dir_all(&directory); + assert!( + output.status.success(), + "Node catch-selector lifetime test failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} diff --git a/crates/fork-instrument/tests/contract_inventory.rs b/crates/fork-instrument/tests/contract_inventory.rs new file mode 100644 index 0000000000..946fcfdb0c --- /dev/null +++ b/crates/fork-instrument/tests/contract_inventory.rs @@ -0,0 +1,227 @@ +use fork_instrument::contract_inventory::{ + ForkContractInventory, fork_capability_section_hex, fork_contract_inventory, + linked_frame_descriptor_section_hex, +}; +use std::fs; +use std::path::PathBuf; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; + +static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); + +fn contract_wat(pointer: &str, memory: &str) -> String { + format!( + r#" + (module + (@custom "kandelo.wpk_fork.linked_frames" "descriptor") + (@custom "kandelo.wpk_fork.capabilities" "\01\04") + ;; Keep non-function GC and exception types ahead of ABI function + ;; types. Type indices must be resolved structurally, not by assuming + ;; every type-section entry is a function. + (type $cell (struct (field (mut i32)))) + (type $exception (func (param i32))) + (tag $exception_tag (type $exception)) + (import "kernel" "kernel_fork" (func $kernel_fork)) + (import "env" "__wpk_fork_frame_reserve" + (func $frame_reserve (param {pointer}) (result {pointer}))) + (import "env" "__wpk_fork_frame_commit" + (func $frame_commit (param {pointer}))) + (import "env" "__wpk_fork_frame_next" + (func $frame_next (param {pointer}) (result {pointer}))) + {memory} + (func (export "wpk_fork_abort_begin") (param {pointer})) + (func (export "wpk_fork_abort_end")) + (func (export "wpk_fork_rewind_begin") (param {pointer})) + (func (export "wpk_fork_rewind_end")) + (func (export "wpk_fork_state") (result i32) + i32.const 0) + (func (export "wpk_fork_unwind_begin") (param {pointer})) + (func (export "wpk_fork_unwind_end"))) + "# + ) +} + +fn parse_contract(pointer: &str, memory: &str) -> ForkContractInventory { + let bytes = wat::parse_str(contract_wat(pointer, memory)).expect("compile contract WAT"); + fork_contract_inventory(&bytes).expect("inventory contract") +} + +#[test] +fn inventories_gc_and_exception_modules_without_decoding_code_bodies() { + let inventory = parse_contract("i32", "(memory 1)"); + assert_eq!( + inventory.to_string(), + "0\t1\t1\t1\t1\t1\t1\t1\t1\t1\t1\t1\t1\t1\t1\t0\t0\t0\t0" + ); +} + +#[test] +fn imported_memory64_selects_i64_pointer_signatures() { + let inventory = parse_contract("i64", r#"(import "env" "memory" (memory i64 1))"#); + assert_eq!(inventory.memory_count, 1); + assert_eq!(inventory.memory64_count, 1); + assert_eq!(inventory.signature_mismatch, 0); +} + +#[test] +fn reports_each_signature_that_disagrees_with_memory_width() { + let bytes = wat::parse_str( + contract_wat("i64", "(memory 1)") + .replace( + r#"(func (export "wpk_fork_state") (result i32)"#, + r#"(func (export "wpk_fork_state") (result i64)"#, + ) + .replace("i32.const 0", "i64.const 0"), + ) + .expect("compile mismatched contract WAT"); + let inventory = fork_contract_inventory(&bytes).expect("inventory contract"); + + // Six pointer-bearing imports/exports use i64 in a memory32 module, and + // wpk_fork_state independently has the wrong result type. + assert_eq!(inventory.signature_mismatch, 7); +} + +#[test] +fn counts_duplicate_contract_sections_and_function_exports() { + let bytes = wat::parse_str( + contract_wat("i32", "(memory 1)") + .replace( + r#"(@custom "kandelo.wpk_fork.capabilities" "\01\04")"#, + r#"(@custom "kandelo.wpk_fork.capabilities" "\01\04") + (@custom "kandelo.wpk_fork.capabilities" "\01\04")"#, + ) + .replace( + r#"(func (export "wpk_fork_abort_end"))"#, + r#"(func (export "wpk_fork_abort_end") + (export "wpk_fork_abort_end"))"#, + ), + ) + .expect("compile duplicate contract WAT"); + let inventory = fork_contract_inventory(&bytes).expect("inventory contract"); + + assert_eq!(inventory.fork_capability, 2); + assert_eq!(inventory.abort_end, 2); + assert_eq!(inventory.signature_mismatch, 0); +} + +#[test] +fn cli_contract_inventory_emits_only_the_stable_tsv_row() { + let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed); + let path: PathBuf = std::env::temp_dir().join(format!( + "kandelo-contract-inventory-{}-{id}.wasm", + std::process::id() + )); + fs::write( + &path, + wat::parse_str(contract_wat("i32", "(memory 1)")).expect("compile contract WAT"), + ) + .expect("write contract module"); + + let output = Command::new(env!("CARGO_BIN_EXE_wasm-fork-instrument")) + .arg("--contract-inventory") + .arg(&path) + .output() + .expect("run contract inventory CLI"); + + assert!( + output.status.success(), + "inventory CLI failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + String::from_utf8(output.stdout).expect("UTF-8 inventory"), + "0\t1\t1\t1\t1\t1\t1\t1\t1\t1\t1\t1\t1\t1\t1\t0\t0\t0\t0\n" + ); + assert!(output.stderr.is_empty()); + + let capability = Command::new(env!("CARGO_BIN_EXE_wasm-fork-instrument")) + .arg("--fork-capability-hex") + .arg(&path) + .output() + .expect("run capability inventory CLI"); + assert!( + capability.status.success(), + "capability CLI failed: {}", + String::from_utf8_lossy(&capability.stderr) + ); + assert_eq!( + String::from_utf8(capability.stdout).expect("UTF-8 capability"), + "1d6b616e64656c6f2e77706b5f666f726b2e6361706162696c69746965730104\n" + ); + + let descriptor = Command::new(env!("CARGO_BIN_EXE_wasm-fork-instrument")) + .arg("--linked-frame-descriptor-hex") + .arg(&path) + .output() + .expect("run descriptor inventory CLI"); + let _ = fs::remove_file(path); + assert!( + descriptor.status.success(), + "descriptor CLI failed: {}", + String::from_utf8_lossy(&descriptor.stderr) + ); + assert_eq!( + String::from_utf8(descriptor.stdout).expect("UTF-8 descriptor"), + concat!( + "1e6b616e64656c6f2e77706b5f666f726b2e6c696e6b65645f6672616d6573", + "64657363726970746f72\n", + ) + ); +} + +#[test] +fn inventories_the_reentrant_legacy_loader_import() { + let bytes = wat::parse_str( + contract_wat("i32", "(memory 1)").replace( + r#"(import "kernel" "kernel_fork" (func $kernel_fork))"#, + r#"(import "kernel" "kernel_fork" (func $kernel_fork)) + (import "env" "__wasm_dlopen" + (func (param i32 i32 i32 i32 i32) (result i32)))"#, + ), + ) + .expect("compile legacy loader inventory WAT"); + let inventory = fork_contract_inventory(&bytes).expect("inventory legacy loader"); + assert_eq!(inventory.legacy_dlopen, 1); +} + +#[test] +fn inventories_a_native_start_section() { + let bytes = wat::parse_str( + contract_wat("i32", "(memory 1)").replace( + r#"(func (export "wpk_fork_abort_end"))"#, + r#"(func $native_start (export "wpk_fork_abort_end")) + (start $native_start)"#, + ), + ) + .expect("compile native-start inventory WAT"); + let inventory = fork_contract_inventory(&bytes).expect("inventory native start"); + assert_eq!(inventory.native_start, 1); +} + +#[test] +fn custom_section_modes_require_one_exact_section() { + let complete = wat::parse_str(contract_wat("i32", "(memory 1)")).expect("compile contract WAT"); + assert_eq!( + fork_capability_section_hex(&complete).expect("capability hex"), + "1d6b616e64656c6f2e77706b5f666f726b2e6361706162696c69746965730104" + ); + assert_eq!( + linked_frame_descriptor_section_hex(&complete).expect("descriptor hex"), + concat!( + "1e6b616e64656c6f2e77706b5f666f726b2e6c696e6b65645f6672616d6573", + "64657363726970746f72", + ) + ); + + let duplicate = wat::parse_str(contract_wat("i32", "(memory 1)").replace( + r#"(@custom "kandelo.wpk_fork.capabilities" "\01\04")"#, + r#"(@custom "kandelo.wpk_fork.capabilities" "\01\04") + (@custom "kandelo.wpk_fork.capabilities" "\01\04")"#, + )) + .expect("compile duplicate capability WAT"); + assert!(fork_capability_section_hex(&duplicate).is_err()); + + let missing = wat::parse_str(r#"(module (memory 1))"#).expect("compile missing sections WAT"); + assert!(fork_capability_section_hex(&missing).is_err()); + assert!(linked_frame_descriptor_section_hex(&missing).is_err()); +} diff --git a/crates/fork-instrument/tests/coverage_wat.rs b/crates/fork-instrument/tests/coverage_wat.rs index 4d4d45d9da..d513033c68 100644 --- a/crates/fork-instrument/tests/coverage_wat.rs +++ b/crates/fork-instrument/tests/coverage_wat.rs @@ -1,16 +1,15 @@ //! WAT-fixture coverage for fork-instrument patterns that don't have a //! direct C/C++ source surface: //! -//! - **S-04..S-07**: side-effect operations (table.fill, table.copy, -//! table.grow, non-nullable funcref Call result) before fork. -//! Switch-dispatch's body-skip-on-REWIND construction means these -//! ops run exactly once on NORMAL; the test verifies fork-instrument -//! produces validating wasm for these shapes. -//! - **F-03/F-04**: wasm-GC accepted limits. fork-instrument must -//! panic with a clear error rather than silently miscompile. -//! - **C-08/C-09**: ref-typed catch operands. Currently A4 -//! territory — fork-instrument either supports via aux-table -//! spilling (future) or panics with a clear error today. +//! - **S-04..S-06**: mutable table operations are captured by the +//! module-state owner and remain valid across fresh-instance replay. +//! - **S-07**: a dead reference-typed call result needs no continuation +//! recipe and remains accepted. +//! - **F-03/F-04**: abstract and concrete wasm-GC references are encoded as +//! activation-owned recipe IDs and reconstructed through the generated +//! anyref codec. +//! - **C-08/C-09**: ref-typed catch operands are captured as complete +//! exception recipes and reconstructed with `throw_ref`. //! //! These complement `host/test/fork-instrument-coverage.test.ts` //! by covering patterns whose validation can be done at the @@ -18,47 +17,23 @@ use fork_instrument::{Options, instrument}; -fn assert_instruments_and_validates(wat: &str, label: &str) { +fn instrument_and_validate(wat: &str, label: &str) -> Vec { let input = wat::parse_str(wat).unwrap_or_else(|e| panic!("{label}: wat parse: {e}")); let output = instrument(&input, &Options::default()) - .unwrap_or_else(|e| panic!("{label}: instrument: {e}")); - let mut validator = - wasmparser::Validator::new_with_features(wasmparser::WasmFeatures::default()); - validator + .unwrap_or_else(|e| panic!("{label}: fork-instrument rejected supported wasm: {e:#}")); + wasmparser::Validator::new_with_features(wasmparser::WasmFeatures::all()) .validate_all(&output) - .unwrap_or_else(|e| panic!("{label}: wasmparser validation: {e}")); -} - -fn assert_instrument_rejects(wat: &str, label: &str, expected: &[&str]) { - let input = wat::parse_str(wat).unwrap_or_else(|e| panic!("{label}: wat parse: {e}")); - let result = std::panic::catch_unwind(|| instrument(&input, &Options::default())); - let msg = match result { - Ok(Ok(_)) => panic!("{label}: fork-instrument unexpectedly accepted accepted-limit wasm"), - Ok(Err(e)) => e.to_string(), - Err(p) => p - .downcast::() - .map(|s| *s) - .or_else(|p| p.downcast::<&'static str>().map(|s| (*s).to_string())) - .unwrap_or_else(|_| "".into()), - }; - for needle in expected { - assert!( - msg.contains(needle), - "{label}: rejection diagnostic did not contain `{needle}`; got: {msg}", - ); - } + .unwrap_or_else(|e| panic!("{label}: instrumented wasm did not validate: {e}")); + output } // --------------------------------------------------------------------- -// S-04..S-07: side effects before fork +// S-04..S-07: state before fork // --------------------------------------------------------------------- // -// Switch-dispatch's body-skip-on-REWIND construction (sub-commits -// 2.4c/2.5c/2.6c) means the body chunks BEFORE the chosen POST_K -// never re-execute on REWIND — non-fork-path calls and side-effect -// ops in those chunks run exactly once on NORMAL. These tests -// verify fork-instrument produces validating wasm for each -// side-effect op pattern. +// The child starts with a freshly instantiated table. The module-state +// transaction must therefore capture each mutation rather than relying on the +// child instance's static element initialization. #[test] fn s_04_table_fill_before_fork() { @@ -77,7 +52,7 @@ fn s_04_table_fill_before_fork() { (drop (call $fork)) (i32.const 0))) "#; - assert_instruments_and_validates(wat, "S-04 table.fill"); + instrument_and_validate(wat, "S-04 table.fill"); } #[test] @@ -97,7 +72,7 @@ fn s_05_table_copy_before_fork() { (drop (call $fork)) (i32.const 0))) "#; - assert_instruments_and_validates(wat, "S-05 table.copy"); + instrument_and_validate(wat, "S-05 table.copy"); } #[test] @@ -117,28 +92,13 @@ fn s_06_table_grow_before_fork() { (drop (call $fork)) (i32.const 0))) "#; - assert_instruments_and_validates(wat, "S-06 table.grow"); + instrument_and_validate(wat, "S-06 table.grow"); } #[test] fn s_07_non_nullable_funcref_call_result_before_fork() { - // S-07 originally targets the case where a direct call returns - // a non-nullable Ref and that result is consumed AFTER fork - // (the result would need to be saved across the fork boundary, - // but ref-typed values can't be stored in scalar frame slots). - // - // For switch-dispatch's body-skip path, the call's result lives - // in the chunk BEFORE the fork; on REWIND that chunk is skipped - // and the result is never produced. As long as no instruction - // between the call and fork consumes the ref, this validates. - // - // Today fork-instrument REJECTS fork-path functions with ref- - // typed argument types via a panic ("ref-typed argument - // ... needs aux-table spilling, which the MVP switch-dispatch - // transform does not yet support"). To exercise this case - // cleanly we use a non-nullable funcref CALLED-RESULT (not - // arg). The non-nullable funcref result of a direct call is - // dropped immediately so no spilling is needed. + // The original-IR liveness pass proves the result is dropped before fork, + // so this shape must not pay for or be rejected by reference replay. let wat = r#" (module (import "kernel" "kernel_fork" (func $fork (result i32))) @@ -157,44 +117,50 @@ fn s_07_non_nullable_funcref_call_result_before_fork() { (drop (call $fork)) (i32.const 0))) "#; - assert_instruments_and_validates(wat, "S-07 non-nullable funcref call result"); + let input = wat::parse_str(wat).unwrap(); + let output = instrument(&input, &Options::default()) + .expect("dead funcref result should remain instrumentable"); + wasmparser::Validator::new_with_features(wasmparser::WasmFeatures::all()) + .validate_all(&output) + .expect("instrumented dead-funcref fixture should validate"); } // --------------------------------------------------------------------- -// F-03 / F-04: wasm-GC accepted limits — must panic loudly +// F-03 / F-04: wasm-GC activation state uses generated codecs // --------------------------------------------------------------------- // -// Per docs/fork-instrumentation.md §Not guaranteed, abstract and -// concrete wasm-GC reference types on the fork path are explicitly -// out of scope. fork-instrument must reject them at the -// `classify_ref` step rather than silently miscompile. +// JavaScript cannot directly implement anyref-typed imports. The artifact +// therefore calls the generated Wasm codec, which converts each live reference +// to an activation-owned scalar recipe ID and narrows the decoded anyref back +// to the statically expected type in the fresh child. #[test] -fn f_03_anyref_on_fork_path_rejects_with_diagnostic() { - // Use anyref as a function-local on a fork-path function. +fn f_03_anyref_on_fork_path_uses_generated_codec() { + // A non-null i31 value widened to anyref is live across fork, so this + // exercises the codec rather than the definitely-null fast path. let wat = r#" (module (import "kernel" "kernel_fork" (func $fork (result i32))) (memory 1) (func $main (export "_start") (result i32) (local $r anyref) - ref.null any + i32.const 17 + ref.i31 local.set $r (drop (call $fork)) local.get $r drop (i32.const 0))) "#; - assert_instrument_rejects(wat, "F-03 anyref", &["fork-instrument 4f", "not yet supported"]); + instrument_and_validate(wat, "F-03 anyref"); } #[test] -fn f_04_struct_ref_on_fork_path_rejects_with_diagnostic() { - // wasm-GC struct.new isn't produced by our LLVM toolchain, - // but concrete GC references on a fork-path must not silently - // miscompile. A local of `(ref null $pair)` is enough to exercise - // the same accepted-limit rejection path that a `struct.new` - // producer would need before its value could survive fork. +fn f_04_struct_ref_on_fork_path_uses_generated_codec() { + // Concrete GC references encode through the broad anyref codec and are + // ref.cast back to `$pair` on replay. Keep an inline allocation live across + // fork so the original-IR stack analysis must preserve the producer's + // precise concrete type rather than relying on a typed helper call. let wat = r#" (module (import "kernel" "kernel_fork" (func $fork (result i32))) @@ -202,83 +168,71 @@ fn f_04_struct_ref_on_fork_path_rejects_with_diagnostic() { (type $pair (struct (field i32) (field i32))) (func $main (export "_start") (result i32) (local $r (ref null $pair)) - ref.null $pair + i32.const 23 + i32.const 42 + struct.new $pair local.set $r (drop (call $fork)) local.get $r drop (i32.const 0))) "#; - assert_instrument_rejects(wat, "F-04 struct ref", &["fork-instrument 4f", "not yet supported"]); + instrument_and_validate(wat, "F-04 struct ref"); } // --------------------------------------------------------------------- -// C-08 / C-09: ref-typed catch operands (A4 territory) +// C-08 / C-09: ref-typed catch operands // --------------------------------------------------------------------- // -// Per the unsupported-cases review doc, a function whose plain-catch -// arms carry ref-typed operands (funcref / externref) is excluded from -// plain-catch replay support via `PlainCatchPlan::b2_carveout`. The -// function can still be instrumented for other fork sites; a fork reached -// from the affected handler remains explicitly unsupported. A future A4 -// implementation would extend per-arm auxiliary storage to support these -// operands. The current coverage proves the tool accepts the Wasm shape -// without trying to serialize references as scalars. +// A reference-bearing tag payload cannot be serialized independently without +// losing exception identity. The transformed catch therefore captures the +// complete exception as an exnref recipe and replay reconstructs that exception +// before `throw_ref` re-enters the original clause. #[test] -fn c_08_funcref_catch_operand_does_not_panic() { - // Try_table with a `catch` clause whose tag has a funcref - // operand. Since the wat crate may not parse arbitrary tag - // signatures with ref types, this test gracefully skips on - // parse failure. +fn c_08_funcref_catch_operand_uses_exception_recipe() { let wat = r#" (module (import "kernel" "kernel_fork" (func $fork (result i32))) (memory 1) (tag $func_tag (param funcref)) + (func $target) + (elem declare func $target) (func $main (export "_start") (result i32) + (local $caught funcref) (block $h (result funcref) (try_table (result funcref) (catch $func_tag $h) - ref.null func)) - drop + ref.func $target + throw $func_tag + unreachable)) + local.set $caught (drop (call $fork)) + local.get $caught + drop (i32.const 0))) "#; - match wat::parse_str(wat) { - Ok(input) => { - // Should NOT panic. The instrumenter excludes this function - // from plain-catch replay without serializing the reference. - let _ = instrument(&input, &Options::default()) - .expect("fork-instrument should not error on funcref catch arm"); - } - Err(e) => { - eprintln!("skip: wat crate did not parse funcref tag: {e}"); - } - } + instrument_and_validate(wat, "C-08 funcref catch payload"); } #[test] -fn c_09_externref_catch_operand_does_not_panic() { +fn c_09_externref_catch_operand_uses_exception_recipe() { let wat = r#" (module (import "kernel" "kernel_fork" (func $fork (result i32))) (memory 1) (tag $ext_tag (param externref)) (func $main (export "_start") (result i32) + (local $caught externref) (block $h (result externref) (try_table (result externref) (catch $ext_tag $h) - ref.null extern)) - drop + ref.null extern + throw $ext_tag + unreachable)) + local.set $caught (drop (call $fork)) + local.get $caught + drop (i32.const 0))) "#; - match wat::parse_str(wat) { - Ok(input) => { - let _ = instrument(&input, &Options::default()) - .expect("fork-instrument should not error on externref catch arm"); - } - Err(e) => { - eprintln!("skip: wat crate did not parse externref tag: {e}"); - } - } + instrument_and_validate(wat, "C-09 externref catch payload"); } diff --git a/crates/fork-instrument/tests/determinism.rs b/crates/fork-instrument/tests/determinism.rs index 3e5f3d8c4a..afc2930062 100644 --- a/crates/fork-instrument/tests/determinism.rs +++ b/crates/fork-instrument/tests/determinism.rs @@ -71,7 +71,10 @@ fn cli_output_is_byte_reproducible_across_processes() { ); } } else { - assert_ne!(bytes, input, "instrumentation unexpectedly changed no bytes"); + assert_ne!( + bytes, input, + "instrumentation unexpectedly changed no bytes" + ); Validator::new() .validate_all(&bytes) .expect("instrumented baseline validates"); diff --git a/crates/fork-instrument/tests/dispatch_tree.rs b/crates/fork-instrument/tests/dispatch_tree.rs index d658b19d22..adeb25bced 100644 --- a/crates/fork-instrument/tests/dispatch_tree.rs +++ b/crates/fork-instrument/tests/dispatch_tree.rs @@ -144,7 +144,13 @@ fn build_dispatch_tree_just_over_two_levels_promotes_to_three() { assert_eq!(root_children.len(), 2); assert_eq!(root_children[0].start(), 0); assert_eq!(root_children[0].end(), 1024); - assert_eq!(root_children[1], DispatchTree::Leaf { start: 1024, end: 1025 }); + assert_eq!( + root_children[1], + DispatchTree::Leaf { + start: 1024, + end: 1025 + } + ); // Left child must itself be the full two-level shape. let DispatchTree::Internal { @@ -172,10 +178,7 @@ fn build_dispatch_tree_partition_covers_every_index_disjointly() { for (start, end) in &leaves { assert_eq!(*start, cursor, "N={n}: leaf gap at {cursor} → {start}"); assert!(start < end, "N={n}: empty leaf [{start}, {end})"); - assert!( - end - start <= 32, - "N={n}: oversize leaf [{start}, {end})", - ); + assert!(end - start <= 32, "N={n}: oversize leaf [{start}, {end})",); cursor = *end; } assert_eq!(cursor, n, "N={n}: leaves cover only {cursor}/{n}"); @@ -228,8 +231,8 @@ fn max_depth_bounded_by_log_of_n_times_bucket_size() { const LEAF_EXTRA: usize = 3; let bucket_size = 32usize; for &n in &[ - 1usize, 8, 32, 33, 64, 100, 1024, 1025, 2_000, 5_000, 32_768, 32_769, - 100_000, 1_000_000, 10_000_000, + 1usize, 8, 32, 33, 64, 100, 1024, 1025, 2_000, 5_000, 32_768, 32_769, 100_000, 1_000_000, + 10_000_000, ] { let tree = build_dispatch_tree(n, bucket_size); let levels = (ceil_log(n, bucket_size) as usize).max(1); @@ -270,7 +273,9 @@ fn simulate_decode(tree: &DispatchTree, call_idx: usize) -> Option<(usize, usize span_per_child, } => { let child_idx = (call_idx - tree.start()) / span_per_child; - children.get(child_idx).and_then(|c| simulate_decode(c, call_idx)) + children + .get(child_idx) + .and_then(|c| simulate_decode(c, call_idx)) } } } @@ -280,8 +285,8 @@ fn decode_every_k_lands_in_correct_leaf() { for &n in &[33usize, 64, 100, 200, 1024, 1025, 2_000, 5_000] { let tree = build_dispatch_tree(n, BUCKET_SIZE); for k in 0..n { - let (start, end) = simulate_decode(&tree, k) - .unwrap_or_else(|| panic!("N={n}: K={k} → None")); + let (start, end) = + simulate_decode(&tree, k).unwrap_or_else(|| panic!("N={n}: K={k} → None")); assert!( start <= k && k < end, "N={n}: K={k} landed in [{start}, {end})", @@ -302,17 +307,13 @@ fn max_depth_property_holds_for_power_of_bucket_size_progression() { let bucket_size = 32usize; let m = bucket_size; let expected: [(usize, usize); 4] = [ - (32, 35), // 1 level (leaf only) - (1024, 67), // 2 levels - (32_768, 99), // 3 levels + (32, 35), // 1 level (leaf only) + (1024, 67), // 2 levels + (32_768, 99), // 3 levels (1_048_576, 131), // 4 levels ]; for (n, want) in expected { let tree = build_dispatch_tree(n, m); - assert_eq!( - tree.max_depth(), - want, - "N={n}: expected depth {want}", - ); + assert_eq!(tree.max_depth(), want, "N={n}: expected depth {want}",); } } diff --git a/crates/fork-instrument/tests/fixtures/trampoline/legacy_catch_fork.wat b/crates/fork-instrument/tests/fixtures/trampoline/legacy_catch_fork.wat new file mode 100644 index 0000000000..715a25ed66 --- /dev/null +++ b/crates/fork-instrument/tests/fixtures/trampoline/legacy_catch_fork.wat @@ -0,0 +1,19 @@ +;; A fork from a legacy catch handler. The implicit legacy exception context +;; must be made activation-owned before nested switch replay can resume here. +(module + (import "kernel" "kernel_fork" (func $kernel_fork (result i32))) + + (tag $number (param i32)) + + (memory (export "memory") 1) + + (func $main (export "_start") (result i32) + (try (result i32) + (do + i32.const 37 + throw $number) + (catch $number + ;; Keep the payload below the fork result to exercise the handler's + ;; typed operand stack as well as its implicit exception context. + call $kernel_fork + drop)))) diff --git a/crates/fork-instrument/tests/instrument.rs b/crates/fork-instrument/tests/instrument.rs index 182153b96a..f3488a667f 100644 --- a/crates/fork-instrument/tests/instrument.rs +++ b/crates/fork-instrument/tests/instrument.rs @@ -10,7 +10,8 @@ //! - **guard-dispatch**: used when any fork-path call is nested inside //! a block/loop/if/try_table. Each call site carries an in-place //! if-else guard that fires on `(NORMAL) || (REWIND && call_idx == -//! N)`; Phase 4g gates state-mutating ops during REWIND replay. +//! N)`; replay restores activation-owned frame state before entering +//! the selected continuation. //! //! Both schemes share the same frame layout and a result-typed restart loop //! containing `[preamble-ifelse, Block($unwind_save), postamble]`. @@ -35,6 +36,32 @@ fn instrument_wat(wat_src: &str) -> Vec { instrument(&bytes, &Options::default()).expect("instrument") } +/// Exercise the rewrite itself without the artifact-level activation-state +/// policy. This keeps transport tests focused on emitted control flow while +/// reference reconstruction support is expanded independently. +fn instrument_wat_unchecked(wat_src: &str) -> Vec { + let bytes = parse_wat(wat_src); + let mut module = Module::from_buffer(&bytes).expect("walrus parse"); + let seed = fork_instrument::call_graph::find_import_func(&module, "kernel.kernel_fork") + .expect("fork import"); + let fork_path = fork_instrument::call_graph::reaching_closure(&module, seed); + let mut targets: Vec<_> = fork_path + .iter() + .copied() + .filter(|id| matches!(module.funcs.get(*id).kind, FunctionKind::Local(_))) + .collect(); + targets.sort(); + let catch_plan = fork_instrument::instrument::plan_plain_catches(&module, &targets); + let runtime = fork_instrument::runtime::inject_linked_runtime(&mut module); + fork_instrument::instrument::instrument_functions( + &mut module, + &runtime, + &fork_path, + &catch_plan, + ); + module.emit_wasm() +} + fn validate(bytes: &[u8]) { let mut validator = wasmparser::Validator::new_with_features(wasmparser::WasmFeatures::default()); @@ -59,11 +86,14 @@ fn local_func(module: &Module, id: FunctionId) -> &LocalFunction { fn logical_entry_seq(f: &LocalFunction) -> InstrSeqId { let entry = f.block(f.entry_block()); - if let [(Instr::Loop(ir::Loop { seq }), _)] = entry.instrs.as_slice() { - *seq - } else { - f.entry_block() - } + entry + .instrs + .last() + .and_then(|(instruction, _)| match instruction { + Instr::Loop(ir::Loop { seq }) => Some(*seq), + _ => None, + }) + .unwrap_or_else(|| f.entry_block()) } fn entry_instr_kinds(module: &Module, id: FunctionId) -> Vec { @@ -84,8 +114,11 @@ fn seq_kinds(module: &Module, func_id: FunctionId, seq_id: InstrSeqId) -> Vec InstrSeqId { +/// Return the dispatch body inside the live-restart loop. +/// +/// Each fork-reaching call now owns its own result-typed private unwind catch, +/// so there is no function-wide catch wrapper or activation-local selector. +fn protected_unwind_body_seq(module: &Module, id: FunctionId) -> InstrSeqId { let f = local_func(module, id); let blocks: Vec = f .block(logical_entry_seq(f)) @@ -121,6 +154,7 @@ enum InstrKind { IfElse, BrIf, BrTable, + Throw, Other, } @@ -142,6 +176,7 @@ impl InstrKind { Instr::IfElse(_) => InstrKind::IfElse, Instr::BrIf(_) => InstrKind::BrIf, Instr::BrTable(_) => InstrKind::BrTable, + Instr::Throw(_) => InstrKind::Throw, _ => InstrKind::Other, } } @@ -170,6 +205,197 @@ fn walk_all(f: &LocalFunction, seq: InstrSeqId, vi } } +fn reference_codec_function(module: &Module, name: &str) -> FunctionId { + if let Some(function) = module.imports.iter().find_map(|import| { + if import.module != runtime_names::IMPORT_REFERENCE_CODEC_MODULE || import.name != name { + return None; + } + match import.kind { + walrus::ImportKind::Function(function) => Some(function), + _ => None, + } + }) { + return function; + } + + module + .exports + .iter() + .find_map(|export| { + (export.name == name) + .then_some(export.item) + .and_then(|item| { + if let ExportItem::Function(function) = item { + Some(function) + } else { + None + } + }) + }) + .or_else(|| { + module + .funcs + .iter() + .find(|function| function.name.as_deref() == Some(name)) + .map(|function| function.id()) + }) + .unwrap_or_else(|| panic!("missing reference-codec function `{name}`")) +} + +fn assert_function_calls_codec_pair( + module: &Module, + function_name: &str, + encode_name: &str, + decode_name: &str, +) { + let encode = reference_codec_function(module, encode_name); + let decode = reference_codec_function(module, decode_name); + let function = local_func(module, func_by_name(module, function_name)); + let mut calls = HashSet::new(); + walk_all(function, function.entry_block(), &mut |_, instruction| { + if let Instr::Call(call) = instruction { + calls.insert(call.func); + } + }); + assert!( + calls.contains(&encode), + "`{function_name}` must encode its live reference through `{encode_name}`" + ); + assert!( + calls.contains(&decode), + "`{function_name}` must decode its live reference through `{decode_name}`" + ); +} + +fn assert_function_uses_exception_recipe(module: &Module, function_name: &str) { + assert_function_calls_codec_pair( + module, + function_name, + runtime_names::IMPORT_REF_ENCODE_EXNREF, + runtime_names::IMPORT_REF_DECODE_EXNREF, + ); + let function = local_func(module, func_by_name(module, function_name)); + let mut has_throw_ref = false; + walk_all(function, function.entry_block(), &mut |_, instruction| { + has_throw_ref |= matches!(instruction, Instr::ThrowRef(_)); + }); + assert!( + has_throw_ref, + "`{function_name}` must replay a codec-owned exception with throw_ref" + ); +} + +fn sequences_with_direct_call( + module: &Module, + owner_name: &str, + target_name: &str, +) -> Vec> { + let owner = local_func(module, func_by_name(module, owner_name)); + let target = func_by_name(module, target_name); + let mut sequences = HashSet::new(); + walk_all(owner, owner.entry_block(), &mut |sequence, instruction| { + if matches!(instruction, Instr::Call(call) if call.func == target) { + sequences.insert(sequence); + } + }); + sequences + .into_iter() + .map(|sequence| { + owner + .block(sequence) + .instrs + .iter() + .map(|(instruction, _)| InstrKind::of(instruction)) + .collect() + }) + .collect() +} + +fn assert_resume_routing(module: &Module, owner_name: &str) { + let resume_peek = module + .imports + .iter() + .find_map(|import| match &import.kind { + walrus::ImportKind::Function(function) if import.name == "__wpk_fork_resume_peek" => { + Some(*function) + } + _ => None, + }) + .expect("resume peek import"); + let resume_table = module + .imports + .iter() + .find_map(|import| match &import.kind { + walrus::ImportKind::Table(table) if import.name == "__wpk_fork_resume_table" => { + Some(*table) + } + _ => None, + }) + .expect("resume table import"); + let owner = local_func(module, func_by_name(module, owner_name)); + let mut peeks = 0; + let mut dispatches = 0; + walk_all( + owner, + owner.entry_block(), + &mut |_, instruction| match instruction { + Instr::Call(call) if call.func == resume_peek => peeks += 1, + Instr::CallIndirect(call) if call.table == resume_table => dispatches += 1, + _ => {} + }, + ); + assert!(peeks > 0, "{owner_name} must peek the next replay event"); + assert!( + dispatches > 0, + "{owner_name} must dispatch a committed activation through the shared table" + ); +} + +fn assert_direct_activation_replay_is_lexical(module: &Module, owner_name: &str) { + let resume_peek = module + .imports + .iter() + .find_map(|import| match &import.kind { + walrus::ImportKind::Function(function) + if import.name == "__wpk_fork_resume_peek" => + { + Some(*function) + } + _ => None, + }) + .expect("resume peek import"); + let resume_table = module + .imports + .iter() + .find_map(|import| match &import.kind { + walrus::ImportKind::Table(table) + if import.name == "__wpk_fork_resume_table" => + { + Some(*table) + } + _ => None, + }) + .expect("resume table import"); + let owner = local_func(module, func_by_name(module, owner_name)); + let mut peeks = 0; + let mut dispatches = 0; + walk_all( + owner, + owner.entry_block(), + &mut |_, instruction| match instruction { + Instr::Call(call) if call.func == resume_peek => peeks += 1, + Instr::CallIndirect(call) if call.table == resume_table => dispatches += 1, + _ => {} + }, + ); + assert_eq!( + (peeks, dispatches), + (0, 0), + "{owner_name} must enter its exact direct activation without adding a \ + resume-thunk frame" + ); +} + fn count_br_tables(f: &LocalFunction) -> usize { let mut n = 0usize; walk_all(f, f.entry_block(), &mut |_, instr| { @@ -185,28 +411,31 @@ fn entry_preamble_and_postamble( func_id: FunctionId, ) -> (InstrSeqId, InstrSeqId, usize) { let f = local_func(module, func_id); - let entry = f.block(logical_entry_seq(f)); + let physical_entry = f.block(f.entry_block()); + let logical_entry = f.block(logical_entry_seq(f)); - let mut preamble_then: Option = None; - let mut wrapper: Option = None; - let mut postamble_start = 0usize; + let preamble_then = + physical_entry + .instrs + .iter() + .find_map(|(instruction, _)| match instruction { + Instr::IfElse(ie) => Some(ie.consequent), + _ => None, + }); - for (idx, (instr, _)) in entry.instrs.iter().enumerate() { - match instr { - Instr::IfElse(ie) if preamble_then.is_none() => { - preamble_then = Some(ie.consequent); - } - Instr::Block(b) if wrapper.is_none() => { - wrapper = Some(b.seq); - postamble_start = idx + 1; - } - _ => {} - } - } + let (wrapper, postamble_start) = logical_entry + .instrs + .iter() + .enumerate() + .find_map(|(index, (instruction, _))| match instruction { + Instr::Block(block) => Some((block.seq, index + 1)), + _ => None, + }) + .expect("unwind-save Block missing from live-restart loop"); ( preamble_then.expect("preamble IfElse missing"), - wrapper.expect("wrapper Block missing"), + wrapper, postamble_start, ) } @@ -394,24 +623,41 @@ fn direct_caller_entry_shape_is_preamble_wrapper_postamble() { let bytes = instrument_wat(FIXTURE_DIRECT_CALLER); let module = Module::from_buffer(&bytes).unwrap(); let caller = func_by_name(&module, "caller"); - let kinds = entry_instr_kinds(&module, caller); + let function = local_func(&module, caller); + let physical_kinds = function + .block(function.entry_block()) + .instrs + .iter() + .map(|(instruction, _)| InstrKind::of(instruction)) + .collect::>(); + let restart_kinds = entry_instr_kinds(&module, caller); - // The restart-loop body opens with the replay-state preamble check. - assert!( - matches!(kinds.first(), Some(InstrKind::GlobalGet)), - "restart loop should start with GlobalGet (state) for replay check: {kinds:?}", + // Replay restoration is deliberately outside the live-restart loop so a + // synchronous reserve failure can restart without an activation-local flag. + assert_eq!( + &physical_kinds[..4], + &[ + InstrKind::GlobalGet, + InstrKind::Const, + InstrKind::Binop, + InstrKind::IfElse, + ], + "physical entry should perform the replay-state preamble before the \ + live-restart loop: {physical_kinds:?}", ); - // Exactly one wrapper Block ($unwind_save) inside the restart loop. + // Exactly one $unwind_save Block is inside the restart loop. assert_eq!( - kinds.iter().filter(|k| **k == InstrKind::Block).count(), + restart_kinds + .iter() + .filter(|kind| **kind == InstrKind::Block) + .count(), 1, - "entry should contain exactly one wrapper Block: {kinds:?}", + "restart loop should contain exactly one unwind-save Block: {restart_kinds:?}", ); - // Must not terminate with Unreachable (postamble pushes real - // default return values). + // The postamble terminates by transporting the private unwind tag. assert!( - !matches!(kinds.last(), Some(InstrKind::Unreachable)), - "entry must not end in an Unreachable placeholder: {kinds:?}", + matches!(restart_kinds.last(), Some(InstrKind::Throw)), + "restart loop should end in the private unwind throw: {restart_kinds:?}", ); } @@ -496,15 +742,13 @@ fn transitive_callers_are_all_wrapped() { } #[test] -fn module_without_fork_import_leaves_user_function_untouched() { - let bytes = instrument_wat(FIXTURE_NO_FORK); +fn module_without_fork_or_dynamic_boundary_is_byte_identical() { + let input = parse_wat(FIXTURE_NO_FORK); + let bytes = instrument(&input, &Options::default()).expect("instrument"); validate(&bytes); - let module = Module::from_buffer(&bytes).unwrap(); - let only = func_by_name(&module, "only"); assert_eq!( - entry_instr_kinds(&module, only), - vec![InstrKind::Const], - "user function in a no-fork module should be untouched", + bytes, input, + "a standalone non-forking executable must not acquire fork-runtime features", ); } @@ -514,14 +758,21 @@ fn multivalue_return_wraps_and_validates() { validate(&bytes); let module = Module::from_buffer(&bytes).unwrap(); let mv = func_by_name(&module, "mv"); + let function = local_func(&module, mv); let kinds = entry_instr_kinds(&module, mv); + let physical_kinds = function + .block(function.entry_block()) + .instrs + .iter() + .map(|(instruction, _)| InstrKind::of(instruction)) + .collect::>(); assert!( kinds.iter().any(|k| *k == InstrKind::Block), - "mv entry missing wrapper Block: {kinds:?}", + "mv restart loop missing unwind-save Block: {kinds:?}", ); assert!( - kinds.iter().any(|k| *k == InstrKind::IfElse), - "mv entry missing preamble IfElse: {kinds:?}", + physical_kinds.iter().any(|kind| *kind == InstrKind::IfElse), + "mv physical entry missing replay preamble IfElse: {physical_kinds:?}", ); } @@ -529,7 +780,7 @@ fn multivalue_return_wraps_and_validates() { fn instrument_functions_returns_rewritten_set() { use fork_instrument::call_graph; use fork_instrument::instrument::{PlainCatchPlan, instrument_functions}; - use fork_instrument::runtime::inject_runtime; + use fork_instrument::runtime::inject_linked_runtime; let bytes = wat::parse_str(FIXTURE_TRANSITIVE).unwrap(); let mut module = Module::from_buffer(&bytes).unwrap(); @@ -537,7 +788,7 @@ fn instrument_functions_returns_rewritten_set() { let seed = call_graph::find_import_func(&module, "kernel.kernel_fork").expect("seed import present"); let fork_path = call_graph::reaching_closure(&module, seed); - let runtime = inject_runtime(&mut module); + let runtime = inject_linked_runtime(&mut module); let b1_plan = PlainCatchPlan::default(); let rewritten = instrument_functions(&mut module, &runtime, &fork_path, &b1_plan); @@ -632,7 +883,7 @@ fn non_fork_call_remains_bare_in_chunk_0() { let module = Module::from_buffer(&bytes).unwrap(); let caller = func_by_name(&module, "caller"); - let unwind_save = entry_wrapper_seq(&module, caller); + let unwind_save = protected_unwind_body_seq(&module, caller); // Walk the whole $unwind_save body and count direct `Call`s to // `$helper`. There should be exactly one (chunk 0's helper call @@ -653,31 +904,87 @@ fn non_fork_call_remains_bare_in_chunk_0() { } #[test] -fn call_site_post_sequence_sets_call_idx_and_checks_unwinding() { - // For each fork-path call site, the post-call sequence is: - // , GlobalGet(state), Const(UNWINDING), Binop(eq), - // IfElse(then: frame.call_index = K; br $unwind_save). +fn source_call_results_do_not_cross_an_unwinding_state_probe() { + // The imported fork result is held across STATE_UNWINDING only inside a + // short generated helper. The source activation calls that helper through + // a statically indexed private-tag boundary, avoiding per-recursion + // result-spill or call-selector scratch. let bytes = instrument_wat(FIXTURE_DIRECT_CALLER); validate(&bytes); let module = Module::from_buffer(&bytes).unwrap(); let caller = func_by_name(&module, "caller"); - let unwind_save = entry_wrapper_seq(&module, caller); + let unwind_save = protected_unwind_body_seq(&module, caller); + let transport_id = module + .funcs + .iter() + .find(|function| { + function + .name + .as_deref() + .is_some_and(|name| name.starts_with("__wpk_fork_unwind_transport_direct_")) + }) + .expect("direct imported-call transport helper") + .id(); - // $unwind_save body (one call case): - // Block($POST_0), Call($fork), GlobalGet, Const, Binop, IfElse, Return + // The lexical call now lives in NORMAL and zero-sentinel branches, while + // REWIND with another committed frame uses the shared resume table. let kinds = seq_kinds(&module, caller, unwind_save); + assert_eq!(kinds.first(), Some(&InstrKind::Block)); assert_eq!( - kinds, - vec![ - InstrKind::Block, // $POST_0 - InstrKind::Call, // the fork call - InstrKind::GlobalGet, // state - InstrKind::Const, // UNWINDING - InstrKind::Binop, // i32.eq - InstrKind::IfElse, // then stores frame.call_index and branches - InstrKind::Return, // normal-path exit - ], + kinds.get(1), + Some(&InstrKind::Block), + "the lexical call should be followed by a per-site result-typed \ + private-tag boundary, not a selector LocalSet: {kinds:?}", + ); + assert!( + !kinds.contains(&InstrKind::LocalSet), + "the source activation must not carry an active-call selector: {kinds:?}", + ); + let caller_local = local_func(&module, caller); + let mut transport_calls = 0usize; + let mut post_result_state_probes = 0usize; + walk_all( + caller_local, + caller_local.entry_block(), + &mut |_, instruction| { + if matches!(instruction, Instr::Call(call) if call.func == transport_id) { + transport_calls += 1; + } + }, + ); + fn count_post_result_probes(function: &LocalFunction, sequence: InstrSeqId) -> usize { + let mut count = function + .block(sequence) + .instrs + .windows(5) + .filter(|window| { + matches!(window[0].0, Instr::IfElse(_)) + && matches!(window[1].0, Instr::GlobalGet(_)) + && matches!( + window[2].0, + Instr::Const(ir::Const { + value: ir::Value::I32(fork_instrument::runtime::STATE_UNWINDING), + }) + ) + && matches!(window[3].0, Instr::Binop(_)) + && matches!(window[4].0, Instr::IfElse(_)) + }) + .count(); + for (instruction, _) in &function.block(sequence).instrs { + for child in nested_of(instruction) { + count += count_post_result_probes(function, child); + } + } + count + } + post_result_state_probes += count_post_result_probes(caller_local, caller_local.entry_block()); + assert_eq!(transport_calls, 2, "NORMAL and zero-sentinel helper calls"); + assert_eq!( + post_result_state_probes, 0, + "a replay-selection IfElse result must not cross a following \ + UNWINDING state probe in the source activation" ); + assert_resume_routing(&module, "caller"); } #[test] @@ -748,7 +1055,7 @@ fn call_with_pure_args_replays_tail_without_spill_locals() { let module = Module::from_buffer(&bytes).unwrap(); let caller = func_by_name(&module, "caller_with_args"); - let unwind_save = entry_wrapper_seq(&module, caller); + let unwind_save = protected_unwind_body_seq(&module, caller); // Structure after rewrite: // $unwind_save: @@ -763,11 +1070,14 @@ fn call_with_pure_args_replays_tail_without_spill_locals() { // NORMAL and REWIND both reach the same post-call sequence, so // replaying the pure tail here preserves the call arguments without // adding frame-backed arg locals. - let unwind_kinds = seq_kinds(&module, caller, unwind_save); - assert_eq!(unwind_kinds[0], InstrKind::Block); - assert_eq!(unwind_kinds[1], InstrKind::Const, "replay arg 0"); - assert_eq!(unwind_kinds[2], InstrKind::Const, "replay arg 1"); - assert_eq!(unwind_kinds[3], InstrKind::Call); + let lexical = sequences_with_direct_call(&module, "caller_with_args", "leaf"); + assert_eq!(lexical.len(), 2, "NORMAL and direct-replay lexical calls"); + assert!( + lexical + .iter() + .all(|kinds| { kinds == &vec![InstrKind::Const, InstrKind::Const, InstrKind::Call] }) + ); + assert_direct_activation_replay_is_lexical(&module, "caller_with_args"); // Find $POST_0 — it's the inner Block of $unwind_save. let f = local_func(&module, caller); @@ -790,15 +1100,15 @@ fn call_with_non_pure_arg_falls_back_to_spill_local() { let module = Module::from_buffer(&bytes).unwrap(); let caller = func_by_name(&module, "caller_with_load_arg"); - let unwind_save = entry_wrapper_seq(&module, caller); - let unwind_kinds = seq_kinds(&module, caller, unwind_save); - assert_eq!(unwind_kinds[0], InstrKind::Block); - assert_eq!( - unwind_kinds[1], - InstrKind::LocalGet, - "load-produced arg must reload from fallback spill local", + let unwind_save = protected_unwind_body_seq(&module, caller); + let lexical = sequences_with_direct_call(&module, "caller_with_load_arg", "leaf"); + assert_eq!(lexical.len(), 2, "NORMAL and direct-replay lexical calls"); + assert!( + lexical + .iter() + .all(|kinds| { kinds == &vec![InstrKind::LocalGet, InstrKind::Call] }) ); - assert_eq!(unwind_kinds[2], InstrKind::Call); + assert_direct_activation_replay_is_lexical(&module, "caller_with_load_arg"); let f = local_func(&module, caller); let post_0 = match f.block(unwind_save).instrs[0].0 { @@ -820,13 +1130,22 @@ fn call_with_i64_shift_arg_replays_shift_tail() { let module = Module::from_buffer(&bytes).unwrap(); let caller = func_by_name(&module, "caller_with_i64_shift_arg"); - let unwind_save = entry_wrapper_seq(&module, caller); - let unwind_kinds = seq_kinds(&module, caller, unwind_save); - assert_eq!(unwind_kinds[0], InstrKind::Block); - assert_eq!(unwind_kinds[1], InstrKind::Const); - assert_eq!(unwind_kinds[2], InstrKind::Const); - assert_eq!(unwind_kinds[3], InstrKind::Binop); - assert_eq!(unwind_kinds[4], InstrKind::Call); + let unwind_save = protected_unwind_body_seq(&module, caller); + let lexical = sequences_with_direct_call(&module, "caller_with_i64_shift_arg", "leaf"); + assert_eq!(lexical.len(), 2, "NORMAL and direct-replay lexical calls"); + assert!(lexical.iter().all(|kinds| { + kinds + == &vec![ + InstrKind::Const, + InstrKind::Const, + InstrKind::Binop, + InstrKind::Call, + ] + })); + assert_direct_activation_replay_is_lexical( + &module, + "caller_with_i64_shift_arg", + ); let f = local_func(&module, caller); let post_0 = match f.block(unwind_save).instrs[0].0 { @@ -846,7 +1165,7 @@ fn two_calls_assign_sequential_call_idx() { validate(&bytes); let module = Module::from_buffer(&bytes).unwrap(); let caller = func_by_name(&module, "caller"); - let _unwind_save = entry_wrapper_seq(&module, caller); + let _unwind_save = protected_unwind_body_seq(&module, caller); let f = local_func(&module, caller); let reserve = module .imports @@ -857,6 +1176,12 @@ fn two_calls_assign_sequential_call_idx() { _ => None, }) .expect("linked frame reserve import"); + let frame_select = module + .funcs + .iter() + .find(|function| function.name.as_deref() == Some("__wpk_fork_select_unwind_frame")) + .expect("generated unwind-frame selector") + .id(); // Count Const values immediately preceding stores to frame.call_index. fn walk_seqs(f: &LocalFunction, seq: InstrSeqId, visit: &mut F) { @@ -869,38 +1194,97 @@ fn two_calls_assign_sequential_call_idx() { } let mut idxs: Vec = Vec::new(); - let mut reserve_calls = 0usize; + let mut frame_sizes = Vec::new(); + let mut frame_select_calls = 0usize; walk_seqs(f, f.entry_block(), &mut |seq| { let instrs = &f.block(seq).instrs; - reserve_calls += instrs - .iter() - .filter( - |(instr, _)| matches!(instr, Instr::Call(ir::Call { func }) if *func == reserve), - ) - .count(); - for i in 1..instrs.len() { - if let Instr::Store(store) = &instrs[i].0 { - if store.arg.offset == 4 { - if let Instr::Const(c) = &instrs[i - 1].0 { - if let ir::Value::I32(v) = c.value { - idxs.push(v); - } - } - } + for index in 2..instrs.len() { + if matches!( + instrs[index].0, + Instr::Call(ir::Call { func }) if func == frame_select + ) { + let ( + Instr::Const(ir::Const { + value: ir::Value::I32(size), + }), + Instr::Const(ir::Const { + value: ir::Value::I32(call_index), + }), + ) = (&instrs[index - 2].0, &instrs[index - 1].0) + else { + panic!( + "unwind-frame selector must receive static size and \ + call-index constants" + ); + }; + frame_select_calls += 1; + frame_sizes.push(*size); + idxs.push(*call_index); } } }); - // The structure yields the sites in reverse-nesting order: the - // outermost $unwind_save body has call 1's post-sequence, the - // inner $POST_1 body has call 0's post-sequence. Sort before - // asserting the set of assigned indices. + let selector_function = local_func(&module, frame_select); + let mut helper_reserve_calls = 0usize; + walk_seqs( + selector_function, + selector_function.entry_block(), + &mut |sequence| { + helper_reserve_calls += selector_function + .block(sequence) + .instrs + .iter() + .filter(|(instruction, _)| { + matches!(instruction, Instr::Call(ir::Call { func }) if *func == reserve) + }) + .count(); + }, + ); + + let mut active_selectors = Vec::new(); + walk_seqs(f, f.entry_block(), &mut |seq| { + let instrs = &f.block(seq).instrs; + for pair in instrs.windows(2) { + if let ( + Instr::Const(ir::Const { + value: ir::Value::I32(value), + }), + Instr::LocalSet(_), + ) = (&pair[0].0, &pair[1].0) + { + if matches!(*value, 1 | 2) { + active_selectors.push(*value); + } + } + } + }); + active_selectors.sort(); idxs.sort(); - assert_eq!(reserve_calls, 2, "each call site should reserve one frame"); + assert_eq!( + frame_select_calls, 2, + "each statically indexed private-tag call boundary should call the \ + shared unwind-frame selector once", + ); + assert_eq!( + helper_reserve_calls, 1, + "the module helper should own exactly one cold frame-reservation \ + sequence regardless of lexical call-site count", + ); + assert_eq!( + frame_sizes, + vec![16, 16], + "both call sites should pass this function's exact static frame size", + ); + assert_eq!( + active_selectors, + Vec::::new(), + "static call boundaries must not install an activation-local selector", + ); assert_eq!( idxs, - vec![0, 0, 1, 1], - "each call_idx should appear in its committed frame and abort scratch selector", + vec![0, 1], + "each call should pass its static zero-based index directly to the \ + shared frame selector", ); } @@ -911,28 +1295,45 @@ fn call_indirect_replays_pure_table_index_arg() { let module = Module::from_buffer(&bytes).unwrap(); let caller = func_by_name(&module, "caller"); - let unwind_save = entry_wrapper_seq(&module, caller); + let unwind_save = protected_unwind_body_seq(&module, caller); let f = local_func(&module, caller); - // $unwind_save: - // Block($POST_0), - // , CallIndirect, - // GlobalGet, Const, Binop, IfElse, - // Return - let kinds = seq_kinds(&module, caller, unwind_save); + let original_table = module + .tables + .iter() + .find(|table| table.import.is_none() && table.initial == 1) + .expect("fixture indirect table") + .id(); + let transport_id = module + .funcs + .iter() + .find(|function| { + function + .name + .as_deref() + .is_some_and(|name| name.starts_with("__wpk_fork_unwind_transport_indirect_")) + }) + .expect("indirect transport helper") + .id(); + let mut lexical_calls = 0; + walk_all(f, f.entry_block(), &mut |_, instruction| { + if matches!(instruction, Instr::Call(call) if call.func == transport_id) { + lexical_calls += 1; + } + }); + assert_eq!(lexical_calls, 2, "NORMAL and zero-sentinel helper calls"); + let helper = local_func(&module, transport_id); + let mut helper_indirect_calls = 0; + walk_all(helper, helper.entry_block(), &mut |_, instruction| { + if matches!(instruction, Instr::CallIndirect(call) if call.table == original_table) { + helper_indirect_calls += 1; + } + }); assert_eq!( - kinds, - vec![ - InstrKind::Block, - InstrKind::Const, // replay i32 table index - InstrKind::CallIndirect, // indirect call - InstrKind::GlobalGet, - InstrKind::Const, - InstrKind::Binop, - InstrKind::IfElse, - InstrKind::Return, - ], + helper_indirect_calls, 1, + "the shared helper must own exactly one guest-table dispatch" ); + assert_resume_routing(&module, "caller"); // The pure table-index tail is removed from $POST_0 rather than // spilled into a frame-backed local. @@ -955,23 +1356,24 @@ fn preamble_starts_with_rewinding_state_check() { let bytes = instrument_wat(FIXTURE_DIRECT_CALLER); let module = Module::from_buffer(&bytes).unwrap(); let caller = func_by_name(&module, "caller"); - let kinds = entry_instr_kinds(&module, caller); + let f = local_func(&module, caller); + let entry = f.block(f.entry_block()); + let kinds = entry + .instrs + .iter() + .map(|(instruction, _)| InstrKind::of(instruction)) + .collect::>(); assert_eq!( - &kinds[..7], + &kinds[..4], &[ InstrKind::GlobalGet, InstrKind::Const, InstrKind::Binop, - InstrKind::LocalGet, - InstrKind::Unop, - InstrKind::Binop, InstrKind::IfElse, ], ); - let f = local_func(&module, caller); - let entry = f.block(logical_entry_seq(f)); let rewinding_const = match &entry.instrs[1].0 { Instr::Const(c) => c.value, other => panic!("expected Const at entry[1], got {other:?}"), @@ -1019,30 +1421,34 @@ fn postamble_writes_and_commits_the_reserved_linked_frame() { InstrKind::GlobalGet, InstrKind::Other, // Load current frame InstrKind::Const, - InstrKind::Other, // Store packed zero catch_region_id + exnref_slot + InstrKind::Other, // Store zero catch_region_id + InstrKind::GlobalGet, + InstrKind::Other, // Load current frame + InstrKind::Const, + InstrKind::Other, // Store reserved zero catch metadata InstrKind::GlobalGet, InstrKind::Other, // Load current frame InstrKind::Call, // __wpk_fork_frame_commit - InstrKind::Const, // default return value + InstrKind::Throw, // process-owned unwind transport ]; assert_eq!(postamble, expected); } #[test] -fn no_catch_postamble_packs_zero_catch_header_fields() { +fn no_catch_postamble_writes_deterministic_zero_catch_header_fields() { let bytes = instrument_wat(FIXTURE_DIRECT_CALLER); validate(&bytes); let printed = wasmprinter::print_bytes(&bytes).expect("wasmprinter"); let caller_section = extract_function_text(&printed, "caller"); assert!( - caller_section.contains("i64.store offset=8"), - "no-catch postamble should pack catch_region_id/exnref_slot zeroes:\n{caller_section}", + caller_section.contains("i32.store offset=8") + && caller_section.contains("i32.store offset=12"), + "no-catch postamble should zero the catch region and reserved field:\n{caller_section}", ); assert!( - !(caller_section.contains("i32.store offset=8") - && caller_section.contains("i32.store offset=12")), - "no-catch postamble should not emit separate zero stores:\n{caller_section}", + !caller_section.contains("i64.store offset=8"), + "ABI 43 uses explicit versioned header fields:\n{caller_section}", ); } @@ -1059,7 +1465,7 @@ fn catch_capable_postamble_keeps_dynamic_catch_header_stores() { ); assert!( caller_section.contains("i32.store offset=12"), - "catch-capable postamble must store dynamic exnref_slot:\n{caller_section}", + "catch-capable postamble must zero the reserved former exnref slot:\n{caller_section}", ); assert!( !caller_section.contains("i64.store offset=8"), @@ -1106,182 +1512,1119 @@ fn postamble_serializes_user_scalar_locals() { let postamble = &kinds[postamble_start..]; // Postamble with one user local: - // 4 current-frame pointer loads/stores plus three payload stores - // (func_index, packed zero catch fields, user_x) = 7 Others. The linked - // commit replaces the legacy current_pos bump. + // 4 current-frame pointer loads/stores plus four payload stores + // (func_index, catch_region_id, reserved zero, user_x) = 8 stores/loads, + // plus the linked-frame reservation result = 9 Others. The catch fields + // remain separate i32 slots so a catch-capable function can store its + // dynamic region identifier without changing the frame shape. The final + // private Throw has its own instruction kind and is not counted here. let other_count = postamble .iter() .filter(|k| matches!(k, InstrKind::Other)) .count(); assert_eq!( - other_count, 7, + other_count, 9, "postamble should load/store the active payload and serialize its fields: {postamble:?}", ); } #[test] -fn postamble_emits_defaults_for_each_result_type() { +fn postamble_throws_without_fabricating_result_values() { let bytes = instrument_wat(FIXTURE_COMPLEX_RETURN); validate(&bytes); let module = Module::from_buffer(&bytes).unwrap(); let caller = func_by_name(&module, "caller"); let kinds = entry_instr_kinds(&module, caller); - let trailing_consts = kinds - .iter() - .rev() - .take_while(|k| **k == InstrKind::Const) - .count(); assert_eq!( - trailing_consts, 2, - "postamble should emit one Const per result type: {kinds:?}", + kinds.last(), + Some(&InstrKind::Throw), + "postamble must transport unwind independently of result types: {kinds:?}", + ); + assert!( + !matches!( + kinds.last(), + Some(InstrKind::Const | InstrKind::Unreachable) + ), + "postamble must not fabricate typed defaults or trap on a result type: {kinds:?}", ); } -// --- Aux-table (Phase 4f) tests -------------------------------------- - #[test] -fn funcref_local_triggers_aux_table_injection() { - let bytes = instrument_wat(FIXTURE_FUNCREF_LOCAL); +fn nonnullable_reference_result_unwinds_via_private_tag() { + let bytes = instrument_wat_unchecked( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (elem declare func $target) + (func $target) + (func $caller (export "caller") (result (ref func)) + call $fork + drop + ref.func $target) + (memory 1)) + "#, + ); validate(&bytes); - let module = Module::from_buffer(&bytes).unwrap(); - - let stash_count = module - .tables - .iter() - .filter(|t| t.name.as_deref() == Some("_wpk_fork_funcref_stash")) - .count(); - assert_eq!(stash_count, 1, "expected exactly one funcref stash table"); - - let stash = module - .tables - .iter() - .find(|t| t.name.as_deref() == Some("_wpk_fork_funcref_stash")) - .unwrap(); - assert_eq!(stash.initial, 1); + let module = Module::from_buffer(&bytes).expect("parse rewritten module"); + let caller = func_by_name(&module, "caller"); + let (_, _, postamble_start) = entry_preamble_and_postamble(&module, caller); + let postamble = &entry_instr_kinds(&module, caller)[postamble_start..]; + assert_eq!( + postamble.last(), + Some(&InstrKind::Throw), + "non-nullable result needs no fake default when unwind is exceptional: {postamble:?}", + ); + assert!( + !postamble.contains(&InstrKind::Unreachable), + "result typing must not turn a valid unwind into a trap: {postamble:?}", + ); } +// --- Fresh-instance reference-state validation ----------------------- + #[test] -fn funcref_local_is_spilled_to_table_and_reloaded() { +fn definitely_null_funcref_local_needs_no_recipe() { let bytes = instrument_wat(FIXTURE_FUNCREF_LOCAL); - let module = Module::from_buffer(&bytes).unwrap(); - let caller = func_by_name(&module, "caller"); - let f = local_func(&module, caller); - - // Count TableSet and TableGet anywhere in the function. - let mut table_sets = 0usize; - let mut table_gets = 0usize; - walk_all(f, f.entry_block(), &mut |_, instr| match instr { - Instr::TableSet(_) => table_sets += 1, - Instr::TableGet(_) => table_gets += 1, - _ => {} - }); + validate(&bytes); +} - assert_eq!(table_sets, 1, "postamble must spill the one funcref local"); - assert_eq!( - table_gets, 1, - "preamble-then must reload the one funcref local", +#[test] +fn dead_reference_parameter_in_fork_closure_remains_legal() { + let bytes = instrument_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (func $caller (param funcref) (result i32) + call $fork) + (memory 1)) + "#, ); + validate(&bytes); } #[test] -fn functions_without_ref_locals_inject_no_aux_tables() { +fn instrumented_modules_never_emit_legacy_reference_tables() { let bytes = instrument_wat(FIXTURE_DIRECT_CALLER); let module = Module::from_buffer(&bytes).unwrap(); - let stash_names = [ + let legacy_reference_table_names = [ "_wpk_fork_funcref_stash", "_wpk_fork_externref_stash", "_wpk_fork_exnref_stash", ]; - for name in stash_names { + for name in legacy_reference_table_names { assert!( !module .tables .iter() .any(|t| t.name.as_deref() == Some(name)), - "module without ref locals should not have `{name}`", + "ABI 43 must not emit retired module-instance table `{name}`", + ); + } +} + +#[test] +fn nested_reference_activations_validate_without_static_slots() { + let bytes = instrument_wat(FIXTURE_TWO_FUNCREF_CALLERS); + validate(&bytes); +} + +#[test] +fn call_specific_reference_vectors_do_not_enlarge_activation_frames() { + let bytes = instrument_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (import "env" "make_first" (func $make_first (result externref))) + (import "env" "make_second" (func $make_second (result externref))) + (func $caller (result i32) + (local $first externref) + (local $second externref) + call $make_first + local.set $first + call $fork + drop + local.get $first + drop + call $make_second + local.set $second + call $fork + drop + local.get $second + drop + i32.const 0) + (memory 1)) + "#, + ); + validate(&bytes); + let module = Module::from_buffer(&bytes).expect("parse reference-vector fixture"); + let caller = local_func(&module, func_by_name(&module, "caller")); + let imported = |name: &str| { + module + .imports + .iter() + .find_map(|import| { + (import.name == name).then(|| match import.kind { + walrus::ImportKind::Function(function) => Some(function), + _ => None, + })? + }) + .unwrap_or_else(|| panic!("missing import {name}")) + }; + let frame_select = module + .funcs + .iter() + .find(|function| function.name.as_deref() == Some("__wpk_fork_select_unwind_frame")) + .expect("generated unwind-frame selector") + .id(); + let vector_begin = imported(runtime_names::IMPORT_REFERENCE_VECTOR_BEGIN); + let vector_append = imported(runtime_names::IMPORT_REFERENCE_VECTOR_APPEND); + let vector_finish = imported(runtime_names::IMPORT_REFERENCE_VECTOR_FINISH); + let vector_get = imported(runtime_names::IMPORT_REFERENCE_VECTOR_GET); + + let mut reserve_sizes = Vec::new(); + let mut vector_calls = [0usize; 4]; + fn visit_sequences( + function: &LocalFunction, + sequence: InstrSeqId, + frame_select: FunctionId, + vector_functions: [FunctionId; 4], + reserve_sizes: &mut Vec, + vector_calls: &mut [usize; 4], + ) { + let instructions = &function.block(sequence).instrs; + for (index, (instruction, _)) in instructions.iter().enumerate() { + if let Instr::Call(call) = instruction { + if call.func == frame_select { + let Some(( + Instr::Const(ir::Const { + value: ir::Value::I32(size), + }), + _, + )) = index.checked_sub(2).and_then(|i| instructions.get(i)) + else { + panic!( + "unwind-frame selector is not preceded by its \ + constant size and call index" + ); + }; + reserve_sizes.push(*size); + } + for (slot, function) in vector_functions.iter().enumerate() { + if call.func == *function { + vector_calls[slot] += 1; + } + } + } + for child in nested_of(instruction) { + visit_sequences( + function, + child, + frame_select, + vector_functions, + reserve_sizes, + vector_calls, + ); + } + } + } + visit_sequences( + caller, + caller.entry_block(), + frame_select, + [vector_begin, vector_append, vector_finish, vector_get], + &mut reserve_sizes, + &mut vector_calls, + ); + + assert!(!reserve_sizes.is_empty()); + assert!( + reserve_sizes.iter().all(|size| *size == 16), + "this fixture has no scalar activation state, so its total frame is \ + the 16-byte header: two references live at disjoint call landings \ + must add zero frame bytes, not the old function-wide 8-byte slot \ + union: {reserve_sizes:?}", + ); + assert!(vector_calls[0] > 0, "save path must allocate a call vector"); + assert!(vector_calls[1] >= 2, "each live recipe must be appended"); + assert!( + vector_calls[2] > 0, + "save path must replace its transient builder handle with a canonical ordinal" + ); + assert!( + vector_calls[3] >= 2, + "rewind must perform indexed vector lookup" + ); +} + +#[test] +fn externref_local_is_activation_owned() { + let wat = r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (func $caller (export "caller") (result i32) + (local $x externref) + ref.null extern + local.set $x + call $fork + local.get $x + drop) + (memory 1)) + "#; + let bytes = instrument_wat(wat); + validate(&bytes); +} + +#[test] +fn live_reference_call_argument_is_activation_owned() { + let wat = r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (func $target (param $value externref) (result i32) + call $fork) + (func $caller (param $value externref) (result i32) + local.get $value + call $target) + (memory 1)) + "#; + let bytes = instrument_wat(wat); + validate(&bytes); + let module = + Module::from_buffer(&bytes).expect("parse instrumented reference-argument fixture"); + assert_function_calls_codec_pair( + &module, + "caller", + runtime_names::IMPORT_REF_ENCODE_EXTERNREF, + runtime_names::IMPORT_REF_DECODE_EXTERNREF, + ); +} + +#[test] +fn call_ref_callee_is_activation_owned_and_narrowed_for_replay() { + let wat = r#" + (module + (type $fork_ty (func (result i32))) + (import "kernel" "kernel_fork" (func $fork (type $fork_ty))) + (elem declare func $fork) + (func $caller (result i32) + ref.func $fork + call_ref $fork_ty) + (memory 1)) + "#; + let bytes = instrument_wat(wat); + validate(&bytes); + let module = Module::from_buffer(&bytes).expect("parse instrumented call_ref fixture"); + assert_function_calls_codec_pair( + &module, + "caller", + runtime_names::IMPORT_REF_ENCODE_FUNCREF, + runtime_names::IMPORT_REF_DECODE_FUNCREF, + ); + let caller = local_func(&module, func_by_name(&module, "caller")); + let transport_id = module + .funcs + .iter() + .find(|function| { + function + .name + .as_deref() + .is_some_and(|name| name.starts_with("__wpk_fork_unwind_transport_ref_")) + }) + .expect("call_ref transport helper") + .id(); + let mut has_callee_cast = false; + let transport = local_func(&module, transport_id); + walk_all(transport, transport.entry_block(), &mut |_, instruction| { + has_callee_cast |= matches!(instruction, Instr::RefCast(_)); + }); + assert!( + has_callee_cast, + "the call_ref helper must restore the declared concrete function type" + ); + let mut source_calls_transport = false; + walk_all(caller, caller.entry_block(), &mut |_, instruction| { + source_calls_transport |= + matches!(instruction, Instr::Call(call) if call.func == transport_id); + }); + assert!( + source_calls_transport, + "source call_ref must use its helper" + ); +} + +#[test] +fn gc_ref_local_uses_anyref_recipe_codec() { + let wat = r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (func $caller (result i32) + (local $r (ref null any)) + i32.const 17 + ref.i31 + local.set $r + call $fork + local.get $r + drop) + (memory 1)) + "#; + let bytes = instrument_wat(wat); + validate(&bytes); + let module = Module::from_buffer(&bytes).expect("parse instrumented anyref fixture"); + assert_function_calls_codec_pair( + &module, + "caller", + runtime_names::IMPORT_REF_ENCODE_ANYREF, + runtime_names::IMPORT_REF_DECODE_ANYREF, + ); +} + +#[test] +fn nullable_reference_operand_stack_carryover_is_activation_owned() { + let bytes = instrument_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (func $caller (result i32) + ref.null extern + call $fork + drop + drop + i32.const 0) + (memory 1)) + "#, + ); + validate(&bytes); +} + +#[test] +fn non_null_reference_operand_stack_carryover_uses_recipe() { + let bytes = instrument_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (func $caller (param $value externref) (result i32) + local.get $value + call $fork + drop + drop + i32.const 0) + (memory 1)) + "#, + ); + validate(&bytes); + let module = Module::from_buffer(&bytes).expect("parse reference-carryover fixture"); + assert_function_calls_codec_pair( + &module, + "caller", + runtime_names::IMPORT_REF_ENCODE_EXTERNREF, + runtime_names::IMPORT_REF_DECODE_EXTERNREF, + ); +} + +#[test] +fn nested_call_reference_carryover_uses_recipe() { + let bytes = instrument_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (func $caller (param $value externref) (result i32) + (block + local.get $value + call $fork + drop + drop) + i32.const 0) + (memory 1)) + "#, + ); + validate(&bytes); + let module = Module::from_buffer(&bytes).expect("parse nested reference-carryover fixture"); + assert_function_calls_codec_pair( + &module, + "caller", + runtime_names::IMPORT_REF_ENCODE_EXTERNREF, + runtime_names::IMPORT_REF_DECODE_EXTERNREF, + ); +} + +#[test] +fn parent_stack_reference_across_fork_bearing_subregion_uses_recipe() { + let bytes = instrument_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (func $caller (param $value externref) (result i32) + local.get $value + (block + call $fork + drop) + drop + i32.const 0) + (memory 1)) + "#, + ); + validate(&bytes); + let module = Module::from_buffer(&bytes).expect("parse subregion reference-carryover fixture"); + assert_function_calls_codec_pair( + &module, + "caller", + runtime_names::IMPORT_REF_ENCODE_EXTERNREF, + runtime_names::IMPORT_REF_DECODE_EXTERNREF, + ); +} + +#[test] +fn dead_polymorphic_reference_subregion_does_not_create_an_analysis_gap() { + // A valid Wasm sequence remains stack-polymorphic after `return`. Static + // call-graph discovery may still conservatively find a fork edge in that + // dead suffix, so the structural rewrite must keep it validator-clean + // rather than treating absent lexical operands as an analysis failure. + // The block parameter is deliberately a reference: replay + // spill storage must use its nullable representation. + let bytes = instrument_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (func $caller + return + (block (param externref) + call $fork + drop + drop)) + (memory 1)) + "#, + ); + validate(&bytes); +} + +#[test] +fn dead_polymorphic_reference_call_does_not_create_an_analysis_gap() { + // Call discovery intentionally remains conservative in dead suffixes. + // The nested carryover walk must therefore account for the call ordinal + // even though `return` made the operand stack polymorphic. + let bytes = instrument_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (func $target (param externref) (result i32) + call $fork) + (func $caller + (block + return + ref.null extern + call $target + drop)) + (memory 1)) + "#, + ); + validate(&bytes); +} + +#[test] +fn escaped_catch_ref_operand_stack_value_uses_exnref_recipe_codec() { + let bytes = instrument_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (tag $exn) + (func $caller (result i32) + (block $handler (result exnref) + (try_table (result exnref) (catch_ref $exn $handler) + throw $exn)) + call $fork + drop + drop + i32.const 0) + (memory 1)) + "#, + ); + validate(&bytes); +} + +#[test] +fn static_table_reference_operand_stack_carryover_uses_recipe() { + let bytes = instrument_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (table 1 funcref) + (func $target) + (elem (i32.const 0) func $target) + (func $caller (result i32) + i32.const 0 + table.get + call $fork + drop + drop + i32.const 0) + (memory 1)) + "#, + ); + validate(&bytes); +} + +#[test] +fn dead_nonnullable_ref_func_instruction_remains_legal() { + let bytes = instrument_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (elem declare func $target) + (func $target) + (func $caller (result i32) + ref.func $target + drop + call $fork) + (memory 1)) + "#, + ); + validate(&bytes); +} + +#[test] +fn concrete_gc_reference_uses_anyref_recipe_codec_and_narrowing() { + let bytes = instrument_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (type $pair (struct (field i32))) + (func $make_pair (result (ref $pair)) + i32.const 7 + struct.new $pair) + (func $caller (result i32) + (local $value (ref null $pair)) + call $make_pair + local.set $value + call $fork + local.get $value + drop) + (memory 1)) + "#, + ); + validate(&bytes); + let module = Module::from_buffer(&bytes).expect("parse instrumented concrete-ref fixture"); + assert_function_calls_codec_pair( + &module, + "caller", + runtime_names::IMPORT_REF_ENCODE_ANYREF, + runtime_names::IMPORT_REF_DECODE_ANYREF, + ); + let caller = local_func(&module, func_by_name(&module, "caller")); + let mut has_narrowing_cast = false; + walk_all(caller, caller.entry_block(), &mut |_, instruction| { + has_narrowing_cast |= matches!(instruction, Instr::RefCast(_)); + }); + assert!( + has_narrowing_cast, + "decoded anyref must be narrowed back to the concrete `$pair` type", + ); +} + +#[test] +fn module_without_try_tables_has_no_legacy_reference_storage() { + let bytes = instrument_wat(FIXTURE_DIRECT_CALLER); + let module = Module::from_buffer(&bytes).unwrap(); + assert!( + !module + .tables + .iter() + .any(|t| t.name.as_deref() == Some("_wpk_fork_exnref_stash")), + "module with no try_tables must not inject retired exnref storage", + ); +} + +#[test] +fn mutable_reference_global_has_module_state_owner() { + let bytes = instrument_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (global $callback (mut funcref) (ref.null func)) + (func $caller (result i32) call $fork) + (memory 1)) + "#, + ); + validate(&bytes); +} + +#[test] +fn immutable_reference_global_read_before_fork_remains_legal() { + let bytes = instrument_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (global $callback funcref (ref.null func)) + (func $caller (result i32) + global.get $callback + drop + call $fork) + (memory 1)) + "#, + ); + validate(&bytes); +} + +#[test] +fn immutable_reference_global_outside_fork_closure_remains_legal() { + let bytes = instrument_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (global $callback funcref (ref.null func)) + (func $unrelated (export "unrelated") + global.get $callback + drop) + (func $caller (result i32) call $fork) + (memory 1)) + "#, + ); + validate(&bytes); +} + +#[test] +fn dead_reference_typed_call_before_fork_remains_legal() { + let bytes = instrument_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (import "env" "consume" (func $consume (param externref))) + (func $caller (result i32) + ref.null extern + call $consume + call $fork) + (memory 1)) + "#, + ); + validate(&bytes); +} + +#[test] +fn non_fork_reaching_return_call_ref_in_fork_activation_remains_tail() { + let bytes = instrument_wat( + r#" + (module + (type $ft (func (result i32))) + (import "kernel" "kernel_fork" (func $fork (type $ft))) + (func $safe (type $ft) + i32.const 7) + (elem declare func $safe) + (func $caller (param $take_tail i32) (result i32) + local.get $take_tail + if (result i32) + ref.func $safe + return_call_ref $ft + else + call $fork + end) + (memory 1)) + "#, + ); + validate(&bytes); + let printed = wasmprinter::print_bytes(&bytes).expect("print instrumented tail-call fixture"); + assert!( + printed.contains("return_call_ref"), + "a tail call that cannot reach fork must retain bounded-stack semantics" + ); +} + +#[test] +fn fork_reaching_tail_calls_remain_bounded_and_route_to_resume_thunks() { + let wat = r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (type $ft (func (result i32))) + (table 1 funcref) + (elem (i32.const 0) $deep) + (elem declare func $deep) + (func $deep (type $ft) (result i32) + call $fork) + (func $tail_direct (type $ft) (result i32) + return_call $deep) + (func $tail_indirect (type $ft) (result i32) + i32.const 0 + return_call_indirect (type $ft)) + (func $tail_ref (type $ft) (result i32) + ref.func $deep + return_call_ref $ft) + (func $root_direct (export "root_direct") (result i32) + call $tail_direct) + (func $root_indirect (export "root_indirect") (result i32) + call $tail_indirect) + (func $root_ref (export "root_ref") (result i32) + call $tail_ref) + (memory 1)) + "#; + let bytes = instrument_wat(wat); + validate(&bytes); + let module = Module::from_buffer(&bytes).expect("instrumented module"); + + for name in ["tail_direct", "tail_indirect", "tail_ref"] { + let function = local_func(&module, func_by_name(&module, name)); + assert!( + function + .block(function.entry_block()) + .instrs + .iter() + .any(|(instruction, _)| matches!(instruction, Instr::ReturnCall(_))), + "{name} must retain a bounded direct tail call, either to the \ + original local target or to a generated transport helper" ); } -} - -#[test] -fn slot_counts_aggregate_across_functions() { - let bytes = instrument_wat(FIXTURE_TWO_FUNCREF_CALLERS); - validate(&bytes); - let module = Module::from_buffer(&bytes).unwrap(); + assert!( + module.funcs.iter().any(|function| function + .name + .as_deref() + .is_some_and(|name| name.starts_with("__wpk_fork_unwind_transport_indirect_"))), + "fork-reaching return_call_indirect must tail-call a transport helper" + ); + assert!( + module.funcs.iter().any(|function| function + .name + .as_deref() + .is_some_and(|name| name.starts_with("__wpk_fork_unwind_transport_ref_"))), + "fork-reaching return_call_ref must tail-call a transport helper" + ); + for root in ["root_direct", "root_indirect", "root_ref"] { + assert_resume_routing(&module, root); + } - let stash = module - .tables + let catalog = module + .exports .iter() - .find(|t| t.name.as_deref() == Some("_wpk_fork_funcref_stash")) - .expect("funcref stash should be injected"); - assert_eq!(stash.initial, 2); + .find(|export| export.name == "__wpk_fork_resume_catalog") + .and_then(|export| match export.item { + ExportItem::Table(table) => Some(module.tables.get(table)), + _ => None, + }) + .expect("resume catalog export"); + assert_eq!( + catalog.initial, 4, + "only the deep and three root activations receive resume thunks" + ); + assert!( + module + .customs + .iter() + .any(|(_, section)| section.name() == "kandelo.wpk_fork.resume_catalog"), + "resume catalog metadata must bind function ordinals to local slots" + ); } #[test] -fn externref_local_routes_through_externref_stash() { +fn fixed_main_and_pthread_resume_boundaries_dispatch_inside_wasm() { let wat = r#" (module (import "kernel" "kernel_fork" (func $fork (result i32))) - (func $caller (export "caller") (result i32) - (local $x externref) - ref.null extern - local.set $x + (type $thread_ty (func (param i32) (result i32))) + (table $functions (export "__indirect_function_table") 1 1 funcref) + (elem (i32.const 0) $thread) + (func $thread (type $thread_ty) (param $arg i32) (result i32) + local.get $arg) + (func $_start (export "_start") call $fork - local.get $x drop) (memory 1)) "#; let bytes = instrument_wat(wat); validate(&bytes); - let module = Module::from_buffer(&bytes).unwrap(); + let module = Module::from_buffer(&bytes).expect("instrumented module"); - assert!( - module - .tables - .iter() - .any(|t| t.name.as_deref() == Some("_wpk_fork_externref_stash")), - "externref local should trigger externref stash injection", + let start_wrapper = func_by_name(&module, "wpk_fork_resume_start"); + let start_ty = module.types.get(module.funcs.get(start_wrapper).ty()); + assert!(start_ty.params().is_empty()); + assert!(start_ty.results().is_empty()); + assert_resume_routing(&module, "wpk_fork_resume_start"); + assert_eq!( + sequences_with_direct_call(&module, "wpk_fork_resume_start", "_start").len(), + 1, + "zero-sentinel start replay must retain the lexical _start path" ); - assert!( - !module - .tables - .iter() - .any(|t| t.name.as_deref() == Some("_wpk_fork_funcref_stash")), - "externref-only module should not inject funcref stash", + + let thread_wrapper = func_by_name(&module, "wpk_fork_resume_thread"); + let thread_ty = module.types.get(module.funcs.get(thread_wrapper).ty()); + assert_eq!(thread_ty.params(), &[ValType::I32, ValType::I32]); + assert_eq!(thread_ty.results(), &[ValType::I32]); + assert_resume_routing(&module, "wpk_fork_resume_thread"); + let original_table = module + .exports + .iter() + .find(|export| export.name == "__indirect_function_table") + .and_then(|export| match export.item { + ExportItem::Table(table) => Some(table), + _ => None, + }) + .expect("original pthread function table"); + let wrapper = local_func(&module, thread_wrapper); + let mut lexical_thread_calls = 0; + walk_all(wrapper, wrapper.entry_block(), &mut |_, instruction| { + if matches!(instruction, Instr::CallIndirect(call) if call.table == original_table) { + lexical_thread_calls += 1; + } + }); + assert_eq!( + lexical_thread_calls, 1, + "zero-sentinel pthread replay must retain the lexical table dispatch" ); } #[test] -#[should_panic(expected = "fork-instrument 4f")] -fn unsupported_ref_type_panics_with_diagnostic() { - let wat = r#" +fn references_outside_the_fork_closure_remain_legal() { + let bytes = instrument_wat( + r#" (module (import "kernel" "kernel_fork" (func $fork (result i32))) - (func $caller (result i32) - (local $r (ref null any)) - ref.null any - local.set $r - call $fork - local.get $r + (elem declare func $target) + (func $target) + (func $unrelated (export "unrelated") + (local $value externref) + ref.null extern + local.set $value + local.get $value + ref.func $target + drop drop) + (func $caller (result i32) call $fork) (memory 1)) - "#; - let _ = instrument_wat(wat); + "#, + ); + validate(&bytes); } #[test] -fn module_without_try_tables_skips_exnref_stash() { - let bytes = instrument_wat(FIXTURE_DIRECT_CALLER); - let module = Module::from_buffer(&bytes).unwrap(); - assert!( - !module - .tables - .iter() - .any(|t| t.name.as_deref() == Some("_wpk_fork_exnref_stash")), - "module with no try_tables should not inject the exnref stash", +fn dead_catch_all_ref_value_before_fork_remains_legal() { + let bytes = instrument_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (tag $exn) + (func $caller (result i32) + (block $handler (result exnref) + (try_table (result exnref) (catch_all_ref $handler) + throw $exn)) + drop + call $fork) + (memory 1)) + "#, + ); + validate(&bytes); +} + +#[test] +fn catch_all_without_live_reference_state_remains_legal() { + let bytes = instrument_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (tag $exn) + (func $caller (result i32) + (block $handler + (try_table (catch_all $handler) + throw $exn)) + call $fork) + (memory 1)) + "#, + ); + validate(&bytes); +} + +#[test] +fn private_unwind_precedes_user_catch_all_and_catch_all_ref() { + let fixtures = [ + ( + "catch_all", + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (func $leaf (result i32) call $fork) + (func $caller (export "caller") (result i32) + (block $handler + (try_table (catch_all $handler) + call $leaf + drop)) + i32.const 0) + (memory 1)) + "#, + ), + ( + "catch_all_ref", + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (func $leaf (result i32) call $fork) + (func $caller (export "caller") (result i32) + (block $handler (result exnref) + (try_table (result exnref) (catch_all_ref $handler) + call $leaf + drop + ref.null exn)) + drop + i32.const 0) + (memory 1)) + "#, + ), + ]; + + for (label, wat) in fixtures { + let bytes = instrument_wat_unchecked(wat); + validate(&bytes); + let module = Module::from_buffer(&bytes).expect("parse rewritten module"); + let unwind_tag = private_unwind_tag(&module); + let caller = func_by_name(&module, "caller"); + let mut shielded = 0usize; + walk_all( + local_func(&module, caller), + local_func(&module, caller).entry_block(), + &mut |_, instr| { + let Instr::TryTable(table) = instr else { + return; + }; + let Some(catch_all_index) = table.catches.iter().position(|catch| { + matches!( + catch, + ir::TryTableCatch::CatchAll { .. } | ir::TryTableCatch::CatchAllRef { .. } + ) + }) else { + return; + }; + assert!(catch_all_index > 0, "{label}: catch-all cannot be first"); + assert!( + matches!( + table.catches[catch_all_index - 1], + ir::TryTableCatch::Catch { tag, .. } if tag == unwind_tag + ), + "{label}: private transport must be intercepted and rethrown before user catch-all: {:?}", + table.catches, + ); + shielded += 1; + }, + ); + assert_eq!(shielded, 1, "{label}: expected one shielded user try_table"); + } +} + +#[test] +fn catch_all_and_catch_all_ref_live_across_fork_use_complete_exception_recipes() { + let fixtures = [ + ( + "catch_all", + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (tag $failure) + (func $caller (export "caller") (result i32) + (block $done (result i32) + (block $handler + (try_table (catch_all $handler) + throw $failure) + unreachable) + call $fork + drop + i32.const 17 + br $done)) + (memory 1)) + "#, + ), + ( + "catch_all_ref", + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (tag $failure) + (func $caller (export "caller") (result i32) + (block $done (result i32) + (block $handler (result exnref) + (try_table (result exnref) (catch_all_ref $handler) + throw $failure)) + drop + call $fork + drop + i32.const 23 + br $done)) + (memory 1)) + "#, + ), + ]; + + for (label, wat) in fixtures { + let bytes = instrument_wat(wat); + validate(&bytes); + let module = Module::from_buffer(&bytes).expect("parse catch-all replay fixture"); + assert_function_calls_codec_pair( + &module, + "caller", + runtime_names::IMPORT_REF_ENCODE_EXNREF, + runtime_names::IMPORT_REF_DECODE_EXNREF, + ); + let printed = wasmprinter::print_bytes(&bytes).expect("print catch-all replay fixture"); + let caller = extract_function_text(&printed, "caller"); + assert!( + caller.contains("catch_all_ref"), + "{label}: the capture path must bind an instance-local exnref:\n{caller}", + ); + assert!( + caller.contains("throw_ref"), + "{label}: rewind must replay the complete exception recipe:\n{caller}", + ); + } +} + +#[test] +fn every_wasm_table_mutation_has_module_state_owner() { + let cases = [ + ("table.set", "i32.const 0 ref.null func table.set", ""), + ( + "table.fill", + "i32.const 0 ref.null func i32.const 1 table.fill", + "", + ), + ( + "table.copy", + "i32.const 0 i32.const 0 i32.const 1 table.copy", + "", + ), + ( + "table.init", + "i32.const 0 i32.const 0 i32.const 1 table.init $elements", + "(elem $elements funcref (ref.null func))", + ), + ( + "table.grow", + "ref.null func i32.const 1 table.grow drop", + "", + ), + ]; + for (_name, operation, element) in cases { + let wat = format!( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (table 1 funcref) + {element} + (func $unrelated {operation}) + (func $caller (result i32) call $fork) + (memory 1)) + "#, + ); + let bytes = instrument_wat(&wat); + validate(&bytes); + } +} + +#[test] +fn static_table_initialization_is_recreated_and_remains_legal() { + let bytes = instrument_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (table 1 funcref) + (func $target) + (elem (i32.const 0) func $target) + (func $caller (result i32) call $fork) + (memory 1)) + "#, ); + validate(&bytes); } // --- Non-fork-path try_tables ---------------------------------------- @@ -1328,7 +2671,7 @@ fn try_table_on_non_fork_path_is_not_instrumented() { .tables .iter() .any(|t| t.name.as_deref() == Some("_wpk_fork_exnref_stash")), - "non-fork-path try_tables should not force exnref stash injection", + "non-fork-path references must not cause legacy reference storage", ); } @@ -1418,16 +2761,12 @@ fn fork_inside_try_body_uses_per_block_switch_dispatch() { (br_table emitted), not guard-dispatch's body-replay", ); - // The exnref stash and Phase 6a/6c/6d plumbing are still injected - // for try_tables — the per-block dispatch overlays on top of the - // existing catch-handler scaffolding (used by fork-from-catch in - // the B1 follow-up). assert!( - module + !module .tables .iter() .any(|t| t.name.as_deref() == Some("_wpk_fork_exnref_stash")), - "Phase 6a must inject exnref stash for a fork-path try_table", + "fork-path try_tables must not inject module-instance exnref storage", ); } @@ -1498,7 +2837,7 @@ fn fork_in_both_top_level_and_nested_uses_per_block_switch_dispatch() { ); } -// --- Phase 6 (guard-dispatch only) tests ------------------------------------- +// --- Tagged-catch reconstruction (guard-dispatch) tests ---------------------- // // These pin down the Phase 6 plumbing that guard-dispatch uses for // `try_table` catch-handler reconstruction. The fixtures all have @@ -1549,24 +2888,15 @@ fn distinct_try_tables_get_sequential_region_ids() { let caller = func_by_name(&module, "caller"); let f = local_func(&module, caller); - let mut bodies = Vec::new(); - collect_try_table_bodies(f, f.entry_block(), &mut bodies); - assert_eq!(bodies.len(), 2, "fixture has two try_tables"); - - // After per-block switch-dispatch lands on a try_table body's - // seq, the body is rebuilt as [Block(POST_{n-1}), post-call, - // chunks[n], ...]. Phase 6c stubs (which run before the rebuild) - // are folded into the cascade — they live somewhere in the - // chunks but are no longer at fixed positions. Just verify the - // exnref stash is injected with one slot per try_table. - let stash = module - .tables - .iter() - .find(|t| t.name.as_deref() == Some("_wpk_fork_exnref_stash")) - .expect("stash must be injected"); - assert_eq!( - stash.initial, 2, - "two try_tables → two exnref stash slots (one region_id each)", + let try_tables = collect_user_try_tables(&module, f); + assert_eq!(try_tables.len(), 2, "fixture has two user try_tables"); + + assert!( + !module + .tables + .iter() + .any(|t| t.name.as_deref() == Some("_wpk_fork_exnref_stash")), + "region identity must live in activation frames, not module tables", ); } @@ -1583,8 +2913,12 @@ fn catch_ref_clause_is_rewritten_with_capture_block() { // $capture block (not the original $handler). let mut try_table: Option = None; walk_all(f, f.entry_block(), &mut |_, instr| { - if try_table.is_none() { - if let Instr::TryTable(tt) = instr { + if let Instr::TryTable(tt) = instr { + if tt + .catches + .iter() + .any(|catch| matches!(catch, ir::TryTableCatch::CatchRef { .. })) + { try_table = Some(tt.clone()); } } @@ -1603,11 +2937,10 @@ fn catch_ref_clause_is_rewritten_with_capture_block() { } #[test] -fn plain_catch_only_try_table_is_not_6d_rewritten() { - // Plain `catch` clauses (no exnref) are not redirected by Phase - // 6d — fork-from-catch-without-exnref is unsupported. The - // try_table still receives a 6c rewind-throw stub at its body, - // but its catch clause remains pointing at the original handler. +fn plain_catch_capture_preserves_plain_clause_kind() { + // Scalar plain catches use the activation-owned selector/payload path. + // Their generated capture still uses a plain Catch (not CatchRef), + // because replay can reconstruct the exact tag and scalar payload. let wat = r#" (module (import "kernel" "kernel_fork" (func $fork (result i32))) @@ -1642,7 +2975,7 @@ fn plain_catch_only_try_table_is_not_6d_rewritten() { .catches .iter() .all(|c| matches!(c, ir::TryTableCatch::Catch { .. })), - "plain-catch-only try_tables should not be retargeted by Phase 6d", + "scalar plain catches should preserve their Catch clause kind", ); } @@ -1720,7 +3053,8 @@ fn discover_plain_catch_arms_returns_one_arm_for_single_catch() { .expect("module declares one tag") .id(); assert_eq!( - arm.tag, module_tag_id, + arm.tag, + Some(module_tag_id), "arm.tag should equal the module's declared tag id", ); @@ -1943,17 +3277,10 @@ fn plain_catch_plan_preserves_f32_f64_operand_types() { assert_eq!(arms[0].operand_tys, vec![ValType::F32, ValType::F64]); } -// --- B1 Stage 2 Task 2.1 — operand-type carve-out tests ---------------- +// --- Exception-recipe tag payloads and multi-target support ------------ #[test] -fn plain_catch_plan_ref_operand_function_is_carved_out() { - // A try_table with a tag whose payload includes externref. - // The function should land in b2_carveout, NOT in per_function. - // - // Catch label semantics mirror the existing scalar tests: the - // block's RESULT type matches the tag's payload arity, and the - // body drops the value before falling through to a synthesized - // ref to keep stack arity consistent. +fn reference_typed_catch_payload_uses_complete_exception_recipe() { let wat = r#" (module (import "kernel" "kernel_fork" (func $fork (result i32))) @@ -1968,26 +3295,14 @@ fn plain_catch_plan_ref_operand_function_is_carved_out() { call $fork) (memory 1)) "#; - let bytes = parse_wat(wat); - let module = walrus::Module::from_buffer(&bytes).unwrap(); - let caller = func_by_name(&module, "caller"); - let plan = fork_instrument::instrument::plan_plain_catches(&module, &[caller]); - assert!( - !plan.per_function.contains_key(&caller), - "carved-out function must not appear in per_function" - ); - assert!( - plan.b2_carveout.contains(&caller), - "carved-out function must be in b2_carveout" - ); + let bytes = instrument_wat(wat); + validate(&bytes); + let module = Module::from_buffer(&bytes).unwrap(); + assert_function_uses_exception_recipe(&module, "caller"); } #[test] -fn plain_catch_plan_mixed_ref_and_scalar_arms_carves_whole_function() { - // A function with two try_tables: one with i32 payload (supported), - // one with externref (unsupported). The whole function gets carved - // out because we don't selectively drop arms — Task 2.3's rewind - // dispatcher needs the whole function's regions or none. +fn mixed_scalar_and_reference_typed_arms_use_their_matching_replay_form() { let wat = r#" (module (import "kernel" "kernel_fork" (func $fork (result i32))) @@ -2009,27 +3324,30 @@ fn plain_catch_plan_mixed_ref_and_scalar_arms_carves_whole_function() { call $fork) (memory 1)) "#; - let bytes = parse_wat(wat); - let module = walrus::Module::from_buffer(&bytes).unwrap(); - let caller = func_by_name(&module, "caller"); - let plan = fork_instrument::instrument::plan_plain_catches(&module, &[caller]); + let source = Module::from_buffer(&parse_wat(wat)).unwrap(); + let caller = func_by_name(&source, "caller"); + let plan = fork_instrument::instrument::plan_plain_catches(&source, &[caller]); + let arms: Vec<_> = plan.per_function[&caller] + .iter() + .flat_map(|(_, arms)| arms) + .collect(); assert!( - plan.b2_carveout.contains(&caller), - "carve-out must include functions with mixed scalar+ref arms" + arms.iter().any(|arm| arm.uses_exception_recipe), + "the reference-bearing arm must own a complete exception recipe" ); assert!( - !plan.per_function.contains_key(&caller), - "carved-out function must not appear in per_function even \ - though one arm is otherwise supported" + arms.iter().any(|arm| !arm.uses_exception_recipe), + "the scalar arm should retain compact tag-and-payload replay" ); + + let bytes = instrument_wat(wat); + validate(&bytes); + let module = Module::from_buffer(&bytes).unwrap(); + assert_function_uses_exception_recipe(&module, "caller"); } #[test] -fn plain_catch_plan_scalar_only_function_is_not_carved_out() { - // Sanity: the existing scalar-only fixture must NOT be carved out. - // Mirrors the scalar tests above to ensure carve-out is gated - // strictly on ref-typed operands and doesn't accidentally trip - // for the supported case. +fn plain_catch_plan_scalar_only_function_is_supported() { let wat = r#" (module (import "kernel" "kernel_fork" (func $fork (result i32))) @@ -2045,10 +3363,6 @@ fn plain_catch_plan_scalar_only_function_is_not_carved_out() { let module = walrus::Module::from_buffer(&bytes).unwrap(); let caller = func_by_name(&module, "caller"); let plan = fork_instrument::instrument::plan_plain_catches(&module, &[caller]); - assert!( - !plan.b2_carveout.contains(&caller), - "scalar-only function must not be in b2_carveout" - ); assert!( plan.per_function.contains_key(&caller), "scalar-only function must have a per_function entry" @@ -2056,10 +3370,7 @@ fn plain_catch_plan_scalar_only_function_is_not_carved_out() { } #[test] -fn plain_catch_plan_multi_target_plain_catch_carved_out() { - // Two arms in one try_table, pointing at different labels. - // Should be carved out (Task 2.4 conservative guard: multi-target - // plain-catch fork has not been verified end-to-end). +fn plain_catch_plan_multi_target_plain_catch_is_supported() { let wat = r#" (module (import "kernel" "kernel_fork" (func $fork (result i32))) @@ -2077,21 +3388,14 @@ fn plain_catch_plan_multi_target_plain_catch_carved_out() { let module = walrus::Module::from_buffer(&bytes).unwrap(); let caller = func_by_name(&module, "caller"); let plan = fork_instrument::instrument::plan_plain_catches(&module, &[caller]); - assert!( - plan.b2_carveout.contains(&caller), - "multi-target try_table should be carved out" - ); - assert!( - !plan.per_function.contains_key(&caller), - "carved-out function should not have a slot plan" - ); + let regions = &plan.per_function[&caller]; + assert_eq!(regions.len(), 1); + assert_eq!(regions[0].1.len(), 2); + assert_ne!(regions[0].1[0].label, regions[0].1[1].label); } #[test] fn plain_catch_plan_single_target_multi_arm_is_supported() { - // Two arms in one try_table, both pointing at the SAME label. - // Should NOT be carved out (this is the supported multi-arm case - // — Task 2.4's guard only triggers when arms diverge). let wat = r#" (module (import "kernel" "kernel_fork" (func $fork (result i32))) @@ -2108,10 +3412,6 @@ fn plain_catch_plan_single_target_multi_arm_is_supported() { let module = walrus::Module::from_buffer(&bytes).unwrap(); let caller = func_by_name(&module, "caller"); let plan = fork_instrument::instrument::plan_plain_catches(&module, &[caller]); - assert!( - !plan.b2_carveout.contains(&caller), - "single-target multi-arm should be supported" - ); assert!(plan.per_function.contains_key(&caller)); let per_func = &plan.per_function[&caller]; assert_eq!(per_func.len(), 1, "one try_table"); @@ -2184,8 +3484,42 @@ fn collect_try_tables(f: &LocalFunction) -> Vec { out } +fn private_unwind_tag(module: &Module) -> walrus::TagId { + module + .imports + .iter() + .find_map(|import| { + if import.module == runtime_names::IMPORT_UNWIND_TAG_MODULE + && import.name == runtime_names::IMPORT_UNWIND_TAG + { + match import.kind { + walrus::ImportKind::Tag(tag) => Some(tag), + _ => None, + } + } else { + None + } + }) + .expect("private unwind tag import") +} + +fn is_function_unwind_boundary(table: &ir::TryTable, unwind_tag: walrus::TagId) -> bool { + matches!( + table.catches.as_slice(), + [ir::TryTableCatch::Catch { tag, .. }] if *tag == unwind_tag + ) +} + +fn collect_user_try_tables(module: &Module, f: &LocalFunction) -> Vec { + let unwind_tag = private_unwind_tag(module); + collect_try_tables(f) + .into_iter() + .filter(|table| !is_function_unwind_boundary(table, unwind_tag)) + .collect() +} + #[test] -fn b1_stage_2_plain_catch_arm_uses_frame_backed_state() { +fn catch_arm_uses_header_selector_without_an_active_arm_frame_local() { // After instrumentation, the original try_table's plain Catch // clause should point at an injected capture block, not at the // original handler label `$h`. The capture block contains the @@ -2217,7 +3551,7 @@ fn b1_stage_2_plain_catch_arm_uses_frame_backed_state() { let module = Module::from_buffer(&bytes).unwrap(); let caller = func_by_name(&module, "caller"); let f = local_func(&module, caller); - let try_tables = collect_try_tables(f); + let try_tables = collect_user_try_tables(&module, f); assert_eq!( try_tables.len(), 1, @@ -2230,9 +3564,8 @@ fn b1_stage_2_plain_catch_arm_uses_frame_backed_state() { "should be a plain Catch clause" ); - // 2. Byte-level (wasmprinter): active-arm is an ordinary scalar - // local and therefore round-trips through the function frame at - // the first offset after its 16-byte header. + // 2. Byte-level (wasmprinter): the exact region/arm selector reuses + // header word +8. An empty-payload arm adds no scalar frame word. let printed = wasmprinter::print_bytes(&bytes).expect("wasmprinter"); let caller_section = extract_function_text(&printed, "caller"); assert!( @@ -2240,13 +3573,136 @@ fn b1_stage_2_plain_catch_arm_uses_frame_backed_state() { "caller must still have a try_table:\n{caller_section}" ); assert!( - caller_section.contains("i32.store offset=16"), - "active-arm local must be serialized after the frame header:\n\ - {caller_section}" + caller_section.contains("i32.store offset=8") + && caller_section.contains("i32.load offset=8"), + "the exact catch selector must round-trip through header word +8:\n\ + {caller_section}", + ); + assert!( + !caller_section.contains("store offset=16") && !caller_section.contains("load offset=16"), + "an empty catch payload must not allocate the former active-arm frame \ + word:\n{caller_section}", + ); + + let mut locals = HashSet::new(); + walk_all( + f, + f.entry_block(), + &mut |_, instruction| match instruction { + Instr::LocalGet(local) => { + locals.insert(local.local); + } + Instr::LocalSet(local) => { + locals.insert(local.local); + } + _ => {} + }, + ); + assert_eq!( + locals.len(), + 1, + "only the activation-local catch selector is needed; static call \ + boundaries add no abort/live-frame selector, and no per-region marker \ + or native active-arm local should exist: {locals:?}", + ); +} + +#[test] +fn catch_payload_frame_overlays_arms_at_the_maximum_arm_size() { + let wat = r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (tag $small (param i32)) + (tag $wide (param i64 i32)) + (tag $medium (param f64)) + (func $caller (export "caller") (result i32) + (block $small_handler (result i32) + (block $wide_handler (result i64 i32) + (block $medium_handler (result f64) + (try_table + (catch $small $small_handler) + (catch $wide $wide_handler) + (catch $medium $medium_handler) + nop) + f64.const 0) + drop + i64.const 0 + i32.const 0) + drop + drop + i32.const 0) + drop + call $fork) + (memory 1)) + "#; + let bytes = instrument_wat(wat); + validate(&bytes); + let module = Module::from_buffer(&bytes).expect("instrumented catch payload module"); + let caller = local_func(&module, func_by_name(&module, "caller")); + let frame_select = module + .funcs + .iter() + .find(|function| function.name.as_deref() == Some("__wpk_fork_select_unwind_frame")) + .expect("generated unwind-frame selector") + .id(); + + fn collect_reserve_sizes( + function: &LocalFunction, + sequence: InstrSeqId, + frame_select: FunctionId, + sizes: &mut Vec, + ) { + let instructions = &function.block(sequence).instrs; + for (index, (instruction, _)) in instructions.iter().enumerate() { + if matches!(instruction, Instr::Call(call) if call.func == frame_select) { + let Some(( + Instr::Const(ir::Const { + value: ir::Value::I32(size), + }), + _, + )) = index + .checked_sub(2) + .and_then(|previous| instructions.get(previous)) + else { + panic!( + "unwind-frame selector must be preceded by its exact \ + static size and call index" + ); + }; + sizes.push(*size); + } + for child in nested_of(instruction) { + collect_reserve_sizes(function, child, frame_select, sizes); + } + } + } + + let mut sizes = Vec::new(); + collect_reserve_sizes(caller, caller.entry_block(), frame_select, &mut sizes); + assert!( + !sizes.is_empty(), + "caller must reserve at least one unwind frame" + ); + assert!( + sizes.iter().all(|size| *size == 28), + "new frame = 16-byte header + max(4, 12, 8) payload = 28 bytes; \ + the former sum layout was 16 + 4-byte active-arm + 4 + 12 + 8 = \ + 44 bytes: {sizes:?}", + ); + + let printed = wasmprinter::print_bytes(&bytes).expect("wasmprinter"); + let caller_section = extract_function_text(&printed, "caller"); + assert!( + caller_section.contains("i64.store offset=16") + && caller_section.contains("i32.store offset=24") + && caller_section.contains("f64.store offset=16"), + "each selected arm must use the shared payload range, with only the \ + widest arm extending to +24:\n{caller_section}", ); assert!( - caller_section.contains("i32.load offset=16"), - "active-arm local must be restored from its frame:\n{caller_section}" + !caller_section.contains("store offset=28"), + "no catch payload may be appended after the 12-byte union:\n\ + {caller_section}", ); } @@ -2276,21 +3732,7 @@ fn extract_function_text<'a>(printed: &'a str, name: &str) -> String { } #[test] -fn b1_stage_2_b2_carveout_function_is_not_transformed() { - // A function whose plain-catch arm has a ref-typed payload is in - // b2_carveout (per Task 2.1). For these functions, B1 emission - // is skipped, so the byte output must NOT contain the B1 capture - // block's save-to-scratch pattern. The Catch clause must still - // be present (Phase 6 doesn't intercept plain catch). - // - // Note: ref-typed catch payloads are not yet supported by the - // existing Phase-6 ref-local pipeline (function would panic with - // a "non-nullable or non-abstract ref" or fail wasm validation in - // some shapes). We use a fork-bearing function that *contains* - // a try_table whose tag has a ref operand, but the catch handler - // itself stays simple. To avoid type-mismatch errors during wat - // parse, we feed the catch via `throw`-then-`drop` inside a block - // typed `(result externref)`. +fn thrown_reference_payload_replays_from_complete_exception_recipe() { let wat = r#" (module (import "kernel" "kernel_fork" (func $fork (result i32))) @@ -2307,49 +3749,15 @@ fn b1_stage_2_b2_carveout_function_is_not_transformed() { let bytes = instrument_wat(wat); validate(&bytes); let module = Module::from_buffer(&bytes).unwrap(); - let caller = func_by_name(&module, "caller"); - let f = local_func(&module, caller); - - // Carve-out functions have NO B1 transform applied. The try_table - // is preserved with its catches as-emitted by Phase 6 (which - // doesn't intercept plain Catch clauses; only catch_ref / catch_all_ref). - let try_tables = collect_try_tables(f); - assert_eq!( - try_tables.len(), - 1, - "carved-out function must still have exactly one try_table" - ); - let tt = &try_tables[0]; - let has_catch = tt - .catches - .iter() - .any(|c| matches!(c, ir::TryTableCatch::Catch { .. })); - assert!( - has_catch, - "carved-out function's plain Catch clause must be preserved \ - (B1 must NOT have transformed it)" - ); - - // The direct planner API must list the function in b2_carveout, - // not per_function. - let plan = fork_instrument::instrument::plan_plain_catches(&module, &[caller]); - assert!( - plan.b2_carveout.contains(&caller), - "carved-out function (ref-typed catch operand) must be in \ - b2_carveout" - ); - assert!( - !plan.per_function.contains_key(&caller), - "carved-out function must NOT have a per_function entry" - ); + assert_function_uses_exception_recipe(&module, "caller"); } #[test] fn b1_stage_2_byte_identity_for_module_without_plain_catch() { // A fork-using module with NO plain-catch should produce stable - // output that's byte-identical across repeated runs (instrument - // is deterministic) and produces ZERO try_tables — Stage 2's - // emission must not fire when there are no plain-catch arms. + // output that's byte-identical across repeated runs. The only + // try_table is the function-level private unwind boundary; Stage + // 2 must not introduce a user-catch capture table. let wat = r#" (module (import "kernel" "kernel_fork" (func $fork (result i32))) @@ -2365,12 +3773,19 @@ fn b1_stage_2_byte_identity_for_module_without_plain_catch() { let module = Module::from_buffer(&bytes_a).unwrap(); let caller = func_by_name(&module, "caller"); let f = local_func(&module, caller); - let try_tables = collect_try_tables(f); + let all_try_tables = collect_try_tables(f); + assert_eq!( + all_try_tables.len(), + 1, + "fork-only function should contain exactly one private transport boundary", + ); assert!( - try_tables.is_empty(), - "no try_tables expected for a fork-only function — Stage 2 \ - must not introduce any: got {} try_tables", - try_tables.len() + is_function_unwind_boundary(&all_try_tables[0], private_unwind_tag(&module)), + "the sole try_table must catch only the process-owned unwind tag", + ); + assert!( + collect_user_try_tables(&module, f).is_empty(), + "Stage 2 must not introduce a user catch table when the input has none", ); } @@ -2457,8 +3872,7 @@ fn fork_instrumentation_keeps_dylink_section_first() { #[test] fn b1_stage_2_rewind_stub_has_plain_catch_dispatch() { // The rewind-throw stub for a region with a plain-catch arm must - // include a `throw $tag` (in addition to Phase 6's existing - // `throw_ref`) so that on REWIND the original handler observes + // include a `throw $tag` so that on REWIND the original handler observes // the same exception class. The exact wat shape varies with // walrus's emitter, so we just check the key semantic markers. let wat = r#" @@ -2477,25 +3891,18 @@ fn b1_stage_2_rewind_stub_has_plain_catch_dispatch() { let printed = wasmprinter::print_bytes(&bytes).expect("wasmprinter"); let caller_section = extract_function_text(&printed, "caller"); - // Both stub paths must be present: - // - throw_ref (Phase 6 catch_ref re-throw) - // - throw $exn (B1 plain-catch arm dispatch) assert!( - caller_section.contains("throw_ref"), - "rewind stub must retain Phase 6's throw_ref path:\n{caller_section}" + !caller_section.contains("throw_ref"), + "ABI 43 replay must not depend on a saved exnref:\n{caller_section}" ); assert!( caller_section.contains("throw $exn") || caller_section.contains("throw 0"), "rewind stub must contain a `throw $exn` for the plain-catch \ arm dispatch:\n{caller_section}" ); - // The mode decision must be frame-owned. Stale contents in the - // static exnref table cannot decide whether this activation took - // a plain or ref catch. assert!( - !caller_section.contains("ref.is_null"), - "rewind stub must dispatch from frame-backed active_arm, not \ - exnref-table nullness:\n{caller_section}" + !caller_section.contains("_wpk_fork_exnref_stash"), + "rewind stub must be activation-owned:\n{caller_section}" ); } @@ -2534,14 +3941,16 @@ fn mixed_plain_and_catch_ref_uses_frame_backed_arm_kind() { let printed = wasmprinter::print_bytes(&bytes).expect("wasmprinter"); let caller_section = extract_function_text(&printed, "caller"); assert!( - caller_section.contains("i32.const -1"), - "catch_ref capture must mark the frame-backed active arm as \ - non-plain:\n{caller_section}", + caller_section.contains("throw $plain") || caller_section.contains("throw 0"), + "plain arm must have a tagged reconstruction path:\n{caller_section}", + ); + assert!( + caller_section.contains("throw $with_ref") || caller_section.contains("throw 1"), + "CatchRef arm must reconstruct by rethrowing its static tag:\n{caller_section}", ); assert!( - !caller_section.contains("ref.is_null"), - "mixed replay must not use static exnref-table contents as \ - capture-kind state:\n{caller_section}", + !caller_section.contains("throw_ref") && !caller_section.contains("_wpk_fork_exnref_stash"), + "mixed replay must not retain or reload an old-instance exnref:\n{caller_section}", ); } @@ -2586,10 +3995,7 @@ fn b1_stage_2_rewind_stub_dispatches_two_arms() { } #[test] -fn b1_stage_2_carved_out_function_no_b1_dispatch_emitted() { - // A function in `b2_carveout` (here: ref-typed catch payload) - // must NOT receive B1's plain-catch dispatch — its rewind stub - // should retain Phase 6's throw_ref-only form. +fn reference_payload_emits_complete_exception_dispatch() { let wat = r#" (module (import "kernel" "kernel_fork" (func $fork (result i32))) @@ -2605,18 +4011,6 @@ fn b1_stage_2_carved_out_function_no_b1_dispatch_emitted() { "#; let bytes = instrument_wat(wat); validate(&bytes); - - let printed = wasmprinter::print_bytes(&bytes).expect("wasmprinter"); - let caller_section = extract_function_text(&printed, "caller"); - // The carved-out function's rewind stub uses ONLY Phase 6's - // throw_ref path and no plain-catch tag throw. - assert!( - caller_section.contains("throw_ref"), - "carved-out function must retain Phase 6's throw_ref:\n{caller_section}" - ); - assert!( - !caller_section.contains("ref.is_null"), - "carved-out function must not consult exnref nullness as mode \ - state:\n{caller_section}" - ); + let module = Module::from_buffer(&bytes).unwrap(); + assert_function_uses_exception_recipe(&module, "caller"); } diff --git a/crates/fork-instrument/tests/large_dispatcher.rs b/crates/fork-instrument/tests/large_dispatcher.rs index c1ebe5a005..82ee5add41 100644 --- a/crates/fork-instrument/tests/large_dispatcher.rs +++ b/crates/fork-instrument/tests/large_dispatcher.rs @@ -182,14 +182,20 @@ fn dispatcher_call_count(bytes: &[u8]) -> usize { count } -/// `instrument_one_function_switch` places the preamble, unwind-save block, -/// and postamble inside one result-typed restart loop. +/// `instrument_one_function_switch` places the replay preamble before one +/// result-typed live-restart loop containing the unwind-save block and +/// postamble. fn dispatcher_unwind_save(local: &LocalFunction) -> InstrSeqId { let entry = local.block(local.entry_block()); - let restart = match entry.instrs.as_slice() { - [(Instr::Loop(ir::Loop { seq }), _)] => *seq, - other => panic!("expected one top-level restart Loop, got {other:?}"), - }; + let restart = entry + .instrs + .iter() + .filter_map(|(instruction, _)| match instruction { + Instr::Loop(ir::Loop { seq }) => Some(*seq), + _ => None, + }) + .next_back() + .unwrap_or_else(|| panic!("expected a top-level live-restart Loop: {:?}", entry.instrs)); let blocks: Vec = local .block(restart) .instrs @@ -382,13 +388,12 @@ fn bucketed_depth_indirect_dispatcher_passes_v8_limit() { } } -/// Every per-call UNWIND branch must target the function-level -/// `$unwind_save`. A regression re-pointing them at a leaf-local -/// `$child_K` / `$dispatch_normal` would still validate as wasm but -/// scramble the fork frame on the next REWIND. The dispatcher -/// fixtures emit only the successful-unwind branch and the allocation-failure -/// branch back to the restart loop, so their exact target counts pin -/// `(global.get state, const UNWINDING, i32.eq, if)` sequence. +/// Every per-call private-tag handler must target `$unwind_save` after a +/// successful reservation and the live-restart loop after synchronous +/// allocation failure. A regression re-pointing a site at a leaf-local +/// `$child_K` / `$dispatch_normal` would still validate as wasm but scramble +/// the fork frame on the next REWIND. Exact target counts pin one statically +/// indexed boundary per lexical call, with no function-wide selector handler. /// /// N=33 straddles `BUCKET_SIZE=32` to force one full leaf + one /// singleton leaf — exercises both first-leaf and last-leaf paths. @@ -411,10 +416,16 @@ fn leaf_unwind_br_targets_function_level_unwind_save() { }; let unwind_save = dispatcher_unwind_save(local); - let restart_loop = match local.block(local.entry_block()).instrs.as_slice() { - [(Instr::Loop(ir::Loop { seq }), _)] => *seq, - other => panic!("expected restart loop, got {other:?}"), - }; + let restart_loop = local + .block(local.entry_block()) + .instrs + .iter() + .filter_map(|(instruction, _)| match instruction { + Instr::Loop(ir::Loop { seq }) => Some(*seq), + _ => None, + }) + .next_back() + .expect("expected live-restart loop"); let targets = collect_br_targets(local); assert_eq!( @@ -423,7 +434,7 @@ fn leaf_unwind_br_targets_function_level_unwind_save() { .filter(|&&target| target == unwind_save) .count(), n, - "{label} N={n}: each call site must branch to unwind-save after commit", + "{label} N={n}: each static call boundary must branch to unwind-save after commit", ); assert_eq!( targets @@ -431,9 +442,15 @@ fn leaf_unwind_br_targets_function_level_unwind_save() { .filter(|&&target| target == restart_loop) .count(), n, - "{label} N={n}: each call site must branch to restart on allocation failure", + "{label} N={n}: each static call boundary must branch to restart on allocation failure", + ); + assert_eq!( + targets.len(), + 3 * n, + "{label} N={n}: expected one normal result-boundary branch, \ + one successful-unwind branch, and one abort-restart branch \ + per static call", ); - assert_eq!(targets.len(), 2 * n, "{label} N={n}: unexpected Br target"); } } } diff --git a/crates/fork-instrument/tests/legacy_dlopen.rs b/crates/fork-instrument/tests/legacy_dlopen.rs new file mode 100644 index 0000000000..e38009f36e --- /dev/null +++ b/crates/fork-instrument/tests/legacy_dlopen.rs @@ -0,0 +1,251 @@ +//! The ABI 43 transform must not leave the historical monolithic loader +//! callback beneath a forkable side-module initializer. + +use fork_instrument::{Options, instrument, legacy_dlopen}; +use walrus::{ + ElementItems, ExportItem, FunctionId, FunctionKind, ImportKind, LocalFunction, Module, + ir::{self, Instr, InstrSeqId}, +}; + +fn parse(wat: &str) -> Module { + let bytes = wat::parse_str(wat).expect("parse legacy dlopen fixture"); + Module::from_buffer(&bytes).expect("parse fixture with walrus") +} + +fn imported(module: &Module, name: &str) -> Vec { + module + .imports + .iter() + .filter_map(|import| { + if import.module != "env" || import.name != name { + return None; + } + match import.kind { + ImportKind::Function(function) => Some(function), + _ => None, + } + }) + .collect() +} + +fn exported_function(module: &Module, name: &str) -> FunctionId { + match module + .exports + .iter() + .find(|export| export.name == name) + .unwrap_or_else(|| panic!("missing export {name}")) + .item + { + ExportItem::Function(function) => function, + other => panic!("{name} is not a function: {other:?}"), + } +} + +fn local(module: &Module, function: FunctionId) -> &LocalFunction { + match &module.funcs.get(function).kind { + FunctionKind::Local(local) => local, + other => panic!("expected local function, got {other:?}"), + } +} + +fn children(instruction: &Instr) -> Vec { + match instruction { + Instr::Block(ir::Block { seq }) | Instr::Loop(ir::Loop { seq }) => vec![*seq], + Instr::IfElse(ir::IfElse { + consequent, + alternative, + }) => vec![*consequent, *alternative], + Instr::TryTable(ir::TryTable { seq, .. }) => vec![*seq], + Instr::Try(try_) => { + let mut result = vec![try_.seq]; + for catch in &try_.catches { + match catch { + ir::LegacyCatch::Catch { handler, .. } + | ir::LegacyCatch::CatchAll { handler } => result.push(*handler), + ir::LegacyCatch::Delegate { .. } => {} + } + } + result + } + _ => Vec::new(), + } +} + +fn walk(local: &LocalFunction, seq: InstrSeqId, visit: &mut impl FnMut(&Instr)) { + for (instruction, _) in &local.block(seq).instrs { + visit(instruction); + for child in children(instruction) { + walk(local, child, visit); + } + } +} + +#[test] +fn legacy_import_identity_becomes_a_staged_local_adapter() { + let mut module = parse( + r#" + (module + (type $legacy (func (param i32 i32 i32 i32) (result i32))) + (import "env" "__wasm_dlopen" (func $legacy (type $legacy))) + (memory 1) + (table (export "__indirect_function_table") 2 funcref) + (elem (i32.const 1) func $legacy) + (func $checkpoint (export "__wasm_posix_signal_checkpoint")) + (export "legacy_alias" (func $legacy))) + "#, + ); + let legacy = exported_function(&module, "legacy_alias"); + + assert_eq!(legacy_dlopen::lower(&mut module).expect("lower"), 1); + assert!(imported(&module, "__wasm_dlopen").is_empty()); + assert_eq!(exported_function(&module, "legacy_alias"), legacy); + assert!(matches!( + module.funcs.get(legacy).kind, + FunctionKind::Local(_) + )); + assert!(imported(&module, "__wasm_dlopen_main").len() == 1); + assert!(imported(&module, "__wasm_dlopen_prepare").len() == 1); + assert!(imported(&module, "__wasm_dlopen_next").len() == 1); + assert!(imported(&module, "__wasm_dlopen_commit").len() == 1); + + let element_kept_identity = module.elements.iter().any(|element| { + matches!( + &element.items, + ElementItems::Functions(functions) if functions.contains(&legacy) + ) + }); + assert!( + element_kept_identity, + "table aliases must name the local adapter without a partial rewrite", + ); + + let adapter = local(&module, legacy); + let mut tail_driver = None; + walk(adapter, adapter.entry_block(), &mut |instruction| { + if let Instr::ReturnCall(call) = instruction { + tail_driver = Some(call.func); + } + }); + let driver = tail_driver.expect("adapter tail-calls staged driver"); + assert_eq!( + local(&module, driver).args.len(), + 1, + "only the transaction token is a driver parameter", + ); + let checkpoint = exported_function(&module, "__wasm_posix_signal_checkpoint"); + let mut checkpoints = 0; + walk(local(&module, driver), local(&module, driver).entry_block(), &mut |instruction| { + if matches!(instruction, Instr::Call(call) if call.func == checkpoint) { + checkpoints += 1; + } + }); + assert_eq!( + checkpoints, 3, + "prepare, next, and commit each hand deferred signals back to libc", + ); + + let bytes = module.emit_wasm(); + wasmparser::Validator::new_with_features(wasmparser::WasmFeatures::all()) + .validate_all(&bytes) + .expect("lowered module validates"); +} + +#[test] +fn original_two_argument_loader_uses_the_staged_protocol() { + let input = wat::parse_str( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (import "env" "__wasm_dlopen" + (func $legacy (param i32 i32) (result i32))) + (memory 1) + (table (export "__indirect_function_table") 2 funcref) + (func $initializer + call $fork + drop) + (elem (i32.const 1) func $initializer) + (func (export "open") (param i32 i32) (result i32) + local.get 0 + local.get 1 + call $legacy)) + "#, + ) + .expect("parse original loader ABI fixture"); + + let output = instrument(&input, &Options::default()).expect("instrument"); + let module = Module::from_buffer(&output).expect("parse instrumented module"); + assert!(imported(&module, "__wasm_dlopen").is_empty()); + let prepare = imported(&module, "__wasm_dlopen_prepare"); + assert_eq!(prepare.len(), 1); + let prepare_ty = module.types.get(module.funcs.get(prepare[0]).ty()); + assert_eq!( + prepare_ty.params(), + [ + walrus::ValType::I32, + walrus::ValType::I32, + walrus::ValType::I32, + walrus::ValType::I32, + walrus::ValType::I32, + ], + "the two-argument form must supply an empty name range and default flags", + ); + wasmparser::Validator::new_with_features(wasmparser::WasmFeatures::all()) + .validate_all(&output) + .expect("instrumented original loader adapter validates"); +} + +#[test] +fn complete_transform_has_no_reentrant_loader_import() { + let input = wat::parse_str( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (import "env" "__wasm_dlopen" + (func $legacy (param i64 i32 i64 i32 i32) (result i32))) + (memory i64 1) + (table (export "__indirect_function_table") 2 funcref) + (func $initializer + call $fork + drop) + (elem (i32.const 1) func $initializer) + (func (export "open") (param i64 i32 i64 i32 i32) (result i32) + local.get 0 + local.get 1 + local.get 2 + local.get 3 + local.get 4 + call $legacy)) + "#, + ) + .expect("parse memory64 fixture"); + + let output = instrument(&input, &Options::default()).expect("instrument"); + let module = Module::from_buffer(&output).expect("parse instrumented module"); + assert!(imported(&module, "__wasm_dlopen").is_empty()); + assert!(!imported(&module, "__wasm_dlopen_prepare").is_empty()); + assert!(!imported(&module, "__wasm_dlopen_next").is_empty()); + assert!(!imported(&module, "__wasm_dlopen_commit").is_empty()); + wasmparser::Validator::new_with_features(wasmparser::WasmFeatures::all()) + .validate_all(&output) + .expect("instrumented staged adapter validates"); +} + +#[test] +fn malformed_reserved_signature_fails_before_runtime() { + let input = wat::parse_str( + r#" + (module + (import "env" "__wasm_dlopen" + (func (param externref) (result externref)))) + "#, + ) + .expect("parse malformed reserved import"); + let error = instrument(&input, &Options::default()) + .expect_err("reserved loader ABI mismatch must not retain reentrant import"); + assert!( + error + .to_string() + .contains("reserved env.__wasm_dlopen import has signature"), + "unexpected diagnostic: {error:#}", + ); +} diff --git a/crates/fork-instrument/tests/module_exception_codec.rs b/crates/fork-instrument/tests/module_exception_codec.rs new file mode 100644 index 0000000000..2e3793dd7d --- /dev/null +++ b/crates/fork-instrument/tests/module_exception_codec.rs @@ -0,0 +1,104 @@ +use fork_instrument::module_exception_codec::{ + FORMAT_HEADER_SIZE, FORMAT_SECTION, FORMAT_TAG_RECORD_SIZE, FORMAT_VERSION, + IMPORT_SCRATCH_RELEASE, IMPORT_SCRATCH_RESERVE, inject, +}; +use walrus::Module; + +const RETIRED_STAGING_MEMORY_EXPORT: &str = "__wpk_fork_ref_exn_staging"; + +fn codec_fixture() -> Vec { + wat::parse_str( + r#" + (module + (import "env" "memory" (memory 1)) + (tag $empty) + (tag $scalars (param i32 i64 f32 f64 v128)) + (tag $references + (param (ref null extern) (ref null func) (ref null exn) (ref null any)))) + "#, + ) + .expect("codec fixture WAT") +} + +#[test] +fn exact_tag_codec_uses_only_the_existing_process_memory() { + let mut module = Module::from_buffer(&codec_fixture()).expect("parse codec fixture"); + let memory = module.memories.iter().next().expect("fixture memory").id(); + let before_memories = module.memories.iter().count(); + let codec = inject(&mut module, memory).expect("inject codec"); + + assert_eq!(codec.memory, memory); + assert_eq!(module.memories.iter().count(), before_memories); + assert!( + module + .exports + .iter() + .all(|export| export.name != RETIRED_STAGING_MEMORY_EXPORT), + "the codec must not export or depend on a private staging memory", + ); + for name in [IMPORT_SCRATCH_RESERVE, IMPORT_SCRATCH_RELEASE] { + assert!( + module + .imports + .iter() + .any(|import| import.module == "env" && import.name == name), + "missing transaction scratch import {name}", + ); + } + + let output = module.emit_wasm(); + wasmparser::Validator::new_with_features(wasmparser::WasmFeatures::all()) + .validate_all(&output) + .expect("generated codec validates"); +} + +#[test] +fn descriptor_records_exact_scalar_and_reference_layouts() { + let mut module = Module::from_buffer(&codec_fixture()).expect("parse codec fixture"); + let memory = module.memories.iter().next().expect("fixture memory").id(); + inject(&mut module, memory).expect("inject codec"); + let output = module.emit_wasm(); + let engine_module = WebAssemblyModule::new(&output); + let section = engine_module + .custom_section(FORMAT_SECTION) + .expect("codec descriptor"); + + assert_eq!(section[0], FORMAT_VERSION); + assert_eq!(u32::from_le_bytes(section[4..8].try_into().unwrap()), 3,); + assert_eq!( + section.len(), + FORMAT_HEADER_SIZE + 3 * FORMAT_TAG_RECORD_SIZE, + ); + let scalar = §ion[FORMAT_HEADER_SIZE + FORMAT_TAG_RECORD_SIZE + ..FORMAT_HEADER_SIZE + 2 * FORMAT_TAG_RECORD_SIZE]; + assert_eq!(u32::from_le_bytes(scalar[8..12].try_into().unwrap()), 40); + assert_eq!(u32::from_le_bytes(scalar[12..16].try_into().unwrap()), 0); + let references = §ion[FORMAT_HEADER_SIZE + 2 * FORMAT_TAG_RECORD_SIZE..]; + assert_eq!(u32::from_le_bytes(references[8..12].try_into().unwrap()), 0,); + assert_eq!( + u32::from_le_bytes(references[12..16].try_into().unwrap()), + 4, + ); +} + +struct WebAssemblyModule<'a> { + bytes: &'a [u8], +} + +impl<'a> WebAssemblyModule<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes } + } + + fn custom_section(&self, name: &str) -> Option> { + for payload in wasmparser::Parser::new(0).parse_all(self.bytes) { + let payload = payload.ok()?; + if let wasmparser::Payload::CustomSection(section) = payload + && section.name() == name + { + return Some(section.data().to_vec()); + } + } + None + } +} diff --git a/crates/fork-instrument/tests/module_exception_codec_node.rs b/crates/fork-instrument/tests/module_exception_codec_node.rs new file mode 100644 index 0000000000..930637afef --- /dev/null +++ b/crates/fork-instrument/tests/module_exception_codec_node.rs @@ -0,0 +1,333 @@ +use std::{ + fs, + process::Command, + time::{SystemTime, UNIX_EPOCH}, +}; + +use fork_instrument::module_exception_codec; +use walrus::Module; + +fn fixture_module() -> Vec { + let input = wat::parse_str( + r#" + (module + (import "env" "memory" (memory 2)) + (tag $test (export "test_tag") (param i32 i64)) + (tag $inner (export "inner_tag") (param i32)) + (tag $outer (export "outer_tag") (param (ref null exn)))) + "#, + ) + .expect("provider fixture WAT"); + let mut module = Module::from_buffer(&input).expect("provider fixture module"); + let memory = module.memories.iter().next().expect("provider memory").id(); + module_exception_codec::inject(&mut module, memory).expect("inject exception codec"); + module.emit_wasm() +} + +fn helper_module() -> Vec { + wat::parse_str( + r#" + (module + (import "provider" "tag" (tag $test (param i32 i64))) + (import "provider" "inner_tag" (tag $inner (param i32))) + (import "provider" "outer_tag" + (tag $outer (param (ref null exn)))) + (import "provider" "encode" + (func $encode (param (ref null exn)) (result i32))) + (import "provider" "decode" + (func $decode (param i32) (result (ref null exn)))) + + (func (export "capture") (param i32 i64) (result i32) + (local $exception (ref null exn)) + (block $caught (result i32 i64 (ref exn)) + (try_table (catch_ref $test $caught) + (local.get 0) + (local.get 1) + (throw $test)) + unreachable) + (local.set $exception) + drop + drop + (local.get $exception) + (call $encode)) + + (func (export "payload_i32") (param i32) (result i32) + (block $caught (result i32 i64) + (try_table (catch $test $caught) + (local.get 0) + (call $decode) + (ref.as_non_null) + (throw_ref)) + unreachable) + drop) + + (func (export "payload_i64") (param i32) (result i64) + (local $value i64) + (block $caught (result i32 i64) + (try_table (catch $test $caught) + (local.get 0) + (call $decode) + (ref.as_non_null) + (throw_ref)) + unreachable) + (local.set $value) + drop + (local.get $value)) + + (func (export "throw_decoded") (param i32) + (local.get 0) + (call $decode) + (ref.as_non_null) + (throw_ref)) + + (func (export "capture_nested") (param i32) (result i32) + (local $inner_exception (ref null exn)) + (local $outer_exception (ref null exn)) + (block $caught_inner (result i32 (ref exn)) + (try_table (catch_ref $inner $caught_inner) + (local.get 0) + (throw $inner)) + unreachable) + (local.set $inner_exception) + drop + (block $caught_outer (result (ref null exn) (ref exn)) + (try_table (catch_ref $outer $caught_outer) + (local.get $inner_exception) + (throw $outer)) + unreachable) + (local.set $outer_exception) + drop + (local.get $outer_exception) + (call $encode)) + + (func (export "nested_payload") (param i32) (result i32) + (block $caught_inner (result i32) + (try_table (catch $inner $caught_inner) + (block $caught_outer (result (ref null exn)) + (try_table (catch $outer $caught_outer) + (local.get 0) + (call $decode) + (ref.as_non_null) + (throw_ref)) + unreachable) + (ref.as_non_null) + (throw_ref)) + unreachable))) + "#, + ) + .expect("consumer helper WAT") +} + +fn anyref_dependencies() -> Vec { + wat::parse_str( + r#" + (module + (func (export "encode") (param (ref null any)) (result i32) + unreachable) + (func (export "decode") (param i32) (result (ref null any)) + unreachable)) + "#, + ) + .expect("anyref dependency WAT") +} + +#[test] +fn fresh_node_instance_reconstructs_exact_tag_and_alias_identity() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "kandelo-module-exception-codec-{}-{nonce}", + std::process::id(), + )); + fs::create_dir(&directory).expect("create fixture directory"); + fs::write(directory.join("provider.wasm"), fixture_module()).expect("write provider"); + fs::write(directory.join("helper.wasm"), helper_module()).expect("write helper"); + fs::write(directory.join("anyref.wasm"), anyref_dependencies()).expect("write anyref"); + fs::write( + directory.join("test.mjs"), + r#" +import { readFileSync } from "node:fs"; + +const providerModule = new WebAssembly.Module(readFileSync( + new URL("./provider.wasm", import.meta.url), +)); +const helperModule = new WebAssembly.Module(readFileSync( + new URL("./helper.wasm", import.meta.url), +)); +const anyrefModule = new WebAssembly.Module(readFileSync( + new URL("./anyref.wasm", import.meta.url), +)); +const anyrefs = new WebAssembly.Instance(anyrefModule).exports; +const recipes = new Map(); +let nextRecipe = 1; + +function instantiate(memory) { + let provider; + const ids = new WeakMap(); + const scratch = []; + let scratchTop = 0x10000; + const thrown = (slot) => { + try { + provider.exports.__wpk_fork_ref_exn_throw_slot(slot); + } catch (value) { + return value; + } + throw new Error("exception scratch slot returned"); + }; + const imports = { + env: { + memory, + __wpk_fork_module_activation: + new WebAssembly.Global({ value: "i32", mutable: false }, 0), + __wpk_fork_ref_exn_lookup(slot) { + return ids.get(thrown(slot)) ?? 0; + }, + __wpk_fork_ref_exn_claim(slot) { + const value = thrown(slot); + let id = ids.get(value); + if (id === undefined) { + id = nextRecipe++; + ids.set(value, id); + } + return id; + }, + __wpk_fork_ref_exn_define( + id, activation, tag, layout, + scalarPointer, scalarLength, refsPointer, refCount, + ) { + recipes.set(id, { + activation, tag, layout, + scalars: new Uint8Array( + memory.buffer, + scalarPointer, + scalarLength, + ).slice(), + refs: new Uint32Array( + memory.buffer, + refsPointer, + refCount, + ).slice(), + }); + }, + __wpk_fork_ref_exn_load( + id, activation, tag, layout, + scalarPointer, scalarLength, refsPointer, refCount, + ) { + const recipe = recipes.get(id); + if ( + !recipe + || recipe.activation !== activation + || recipe.tag !== tag + || recipe.layout !== layout + || recipe.scalars.length !== scalarLength + || recipe.refs.length !== refCount + ) return 0; + new Uint8Array(memory.buffer, scalarPointer, scalarLength) + .set(recipe.scalars); + new Uint32Array(memory.buffer, refsPointer, refCount) + .set(recipe.refs); + return 1; + }, + __wpk_fork_ref_exn_route(id, activation) { + const recipe = recipes.get(id); + return recipe?.activation === activation ? recipe.layout : -1; + }, + __wpk_fork_ref_exn_cache_index(id) { + return id; + }, + __wpk_fork_ref_exn_broker_encode() { + throw new Error("known local tag unexpectedly reached broker encode"); + }, + __wpk_fork_ref_exn_broker_throw_recipe() { + throw new Error("known local tag unexpectedly reached broker decode"); + }, + __wpk_fork_ref_exn_ingress_throw() { + throw new Error("known local tag unexpectedly used ingress"); + }, + __wpk_fork_ref_scratch_reserve(size) { + const aligned = (size + 15) & ~15; + const address = scratchTop; + scratchTop += aligned; + scratch.push({ address, size, aligned }); + new Uint8Array(memory.buffer, address, aligned).fill(0); + return address; + }, + __wpk_fork_ref_scratch_release(address, size) { + const reservation = scratch.pop(); + if ( + !reservation + || reservation.address !== address + || reservation.size !== size + ) throw new Error("non-LIFO scratch release"); + new Uint8Array(memory.buffer, address, reservation.aligned).fill(0); + scratchTop = address; + }, + __wpk_fork_ref_encode_funcref() { throw new Error("unused funcref"); }, + __wpk_fork_ref_decode_funcref() { throw new Error("unused funcref"); }, + __wpk_fork_ref_encode_externref() { throw new Error("unused externref"); }, + __wpk_fork_ref_decode_externref() { throw new Error("unused externref"); }, + __wpk_fork_ref_encode_anyref: anyrefs.encode, + __wpk_fork_ref_decode_anyref: anyrefs.decode, + }, + }; + provider = new WebAssembly.Instance(providerModule, imports); + const helper = new WebAssembly.Instance(helperModule, { + provider: { + tag: provider.exports.test_tag, + inner_tag: provider.exports.inner_tag, + outer_tag: provider.exports.outer_tag, + encode: provider.exports.__wpk_fork_ref_encode_exnref, + decode: provider.exports.__wpk_fork_ref_decode_exnref, + }, + }); + return { provider, helper }; +} + +const parent = instantiate(new WebAssembly.Memory({ initial: 2 })); +const recipe = parent.helper.exports.capture(0x78563412, 0x102030405060708n); +if (recipe !== 1) throw new Error(`unexpected recipe ${recipe}`); +const nestedRecipe = parent.helper.exports.capture_nested(0x1234abcd); + +// This is a genuinely fresh provider instance with a different local Tag. +const child = instantiate(new WebAssembly.Memory({ initial: 2 })); +if (child.provider.exports.test_tag === parent.provider.exports.test_tag) { + throw new Error("fresh module unexpectedly reused local tag identity"); +} +if (child.helper.exports.payload_i32(recipe) !== 0x78563412) { + throw new Error("child lost i32 exception payload bits"); +} +if (child.helper.exports.payload_i64(recipe) !== 0x102030405060708n) { + throw new Error("child lost i64 exception payload bits"); +} +const catchDecoded = () => { + try { + child.helper.exports.throw_decoded(recipe); + } catch (value) { + return value; + } + throw new Error("decoded exception returned without throwing"); +}; +if (catchDecoded() !== catchDecoded()) { + throw new Error("child did not cache reconstructed exnref identity"); +} +if (child.helper.exports.nested_payload(nestedRecipe) !== 0x1234abcd) { + throw new Error("child lost recursively encoded exnref payload"); +} +"#, + ) + .expect("write Node test"); + + let output = Command::new("node") + .arg(directory.join("test.mjs")) + .output() + .expect("run Node"); + let _ = fs::remove_dir_all(&directory); + assert!( + output.status.success(), + "Node fresh-instance codec test failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} diff --git a/crates/fork-instrument/tests/module_gc_codec.rs b/crates/fork-instrument/tests/module_gc_codec.rs new file mode 100644 index 0000000000..9fdb277002 --- /dev/null +++ b/crates/fork-instrument/tests/module_gc_codec.rs @@ -0,0 +1,482 @@ +use fork_instrument::module_gc_codec::{ + CONSTRUCTOR_ARRAY_FIXED, CONSTRUCTOR_ARRAY_GENERIC, CONSTRUCTOR_STRUCT, FIELD_FLAG_MUTABLE, + FIELD_FLAG_NULLABLE, FIELD_FLAG_REFERENCE, FORMAT_FIELD_RECORD_SIZE, FORMAT_HEADER_SIZE, + FORMAT_LAYOUT_RECORD_SIZE, FORMAT_MAGIC, FORMAT_VERSION, GcConstructorKind, GcLayoutKind, + KIND_ARRAY, KIND_STRUCT, LAYOUT_FLAG_REQUIRES_PROVENANCE, plan, +}; +use fork_instrument::{module_exception_codec, module_gc_codec, runtime}; +use walrus::Module; + +fn parse(wat: &str) -> Module { + let bytes = wat::parse_str(wat).expect("valid test WAT"); + Module::from_buffer(&bytes).expect("walrus accepts test module") +} + +fn u16_at(bytes: &[u8], offset: usize) -> u16 { + u16::from_le_bytes(bytes[offset..offset + 2].try_into().unwrap()) +} + +fn u32_at(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()) +} + +fn finish_codec(mut module: Module) -> Vec { + let memory = module.memories.iter().next().expect("test memory").id(); + let declared = module_gc_codec::declare(&mut module, memory).expect("declare GC codec"); + let exception = module_exception_codec::inject_with_reference_overrides( + &mut module, + memory, + Some((declared.encode_externref, declared.decode_externref)), + Some((declared.encode_anyref, declared.decode_anyref)), + ) + .expect("inject exception codec"); + let runtime = runtime::inject_linked_runtime_with_reference_overrides( + &mut module, + runtime::ReferenceCodecOverrides { + funcref: Some(( + exception.references.encode_funcref, + exception.references.decode_funcref, + )), + externref: Some(( + exception.references.encode_externref, + exception.references.decode_externref, + )), + exnref: Some((exception.encode, exception.decode)), + anyref: Some((declared.encode_anyref, declared.decode_anyref)), + cleanup: Some(exception.clear), + }, + ); + module_gc_codec::finish_declaration(&mut module, declared, exception, &runtime) + .expect("emit GC codec"); + module.emit_wasm() +} + +#[test] +fn plans_exact_scalar_and_reference_layouts() { + let module = parse( + r#" + (module + (type $node + (sub (struct + (field (mut i8)) + (field i64) + (field (mut (ref null $node)))))) + (type $child + (sub $node (struct + (field (mut i8)) + (field i64) + (field (mut (ref null $node))) + (field f32)))) + (type $refs (array (mut (ref null $node)))) + (type $immutable (array i16)) + (func (export "make") (result (ref $child)) + i32.const 0 + i64.const 0 + ref.null $node + f32.const 0 + struct.new $child)) + "#, + ); + + let plan = plan(&module).expect("GC layouts plan"); + assert_eq!(plan.layouts().len(), 4); + + let node = &plan.layouts()[0]; + assert_eq!(node.kind, GcLayoutKind::Struct); + assert_eq!(node.constructor, GcConstructorKind::Struct); + assert_eq!(node.scalar_len_or_stride, 16); + assert!(!node.defaultable_shell); + assert_eq!(node.fields[0].scalar_offset, Some(0)); + assert_eq!(node.fields[1].scalar_offset, Some(8)); + assert_eq!(node.fields[2].reference_ordinal, Some(0)); + + let child = &plan.layouts()[1]; + assert_eq!(child.super_type_ordinal, Some(0)); + assert_eq!(child.subtype_depth, 1); + assert_eq!(child.scalar_len_or_stride, 20); + + let refs = &plan.layouts()[2]; + assert_eq!(refs.kind, GcLayoutKind::Array); + assert_eq!(refs.scalar_len_or_stride, 0); + assert!(refs.defaultable_shell); + assert_eq!(refs.fields[0].reference_ordinal, Some(0)); + + let immutable = &plan.layouts()[3]; + assert_eq!(immutable.kind, GcLayoutKind::Array); + assert_eq!(immutable.scalar_len_or_stride, 2); + assert!(!immutable.defaultable_shell); + + // Exact dynamic type dispatch must test the subtype before its parent. + assert_eq!(&plan.dispatch_layouts()[..2], &[child.id, node.id]); +} + +#[test] +fn assigns_constructor_layouts_only_when_shell_replay_is_not_safe() { + let module = parse( + r#" + (module + (type $mutable (array (mut i32))) + (type $immutable (array i32)) + (data $bytes "\01\00\00\00\02\00\00\00") + (func (export "mutable-fixed") (result (ref $mutable)) + i32.const 7 + i32.const 8 + array.new_fixed $mutable 2) + (func (export "immutable-fixed") (result (ref $immutable)) + i32.const 9 + i32.const 10 + array.new_fixed $immutable 2) + (func (export "immutable-data") (result (ref $immutable)) + i32.const 0 + i32.const 2 + array.new_data $immutable $bytes)) + "#, + ); + + let plan = plan(&module).expect("GC layouts plan"); + assert_eq!(plan.layouts().len(), 4); + assert_eq!( + plan.layouts()[0].constructor, + GcConstructorKind::ArrayGeneric + ); + assert_eq!( + plan.layouts()[1].constructor, + GcConstructorKind::ArrayGeneric + ); + assert_eq!( + plan.layouts()[2].constructor, + GcConstructorKind::ArrayFixed { len: 2 } + ); + assert_eq!( + plan.layouts()[3].constructor, + GcConstructorKind::ArrayData { segment_ordinal: 0 } + ); +} + +#[test] +fn descriptor_is_canonical_and_binds_constructor_safety() { + let module = parse( + r#" + (module + (type $pair + (struct + (field i32) + (field (mut (ref null $pair))))) + (type $immutable (array i16)) + (func (export "new-array") (result (ref $immutable)) + i32.const 1 + i32.const 2 + array.new_fixed $immutable 2)) + "#, + ); + let plan = plan(&module).expect("GC layouts plan"); + let bytes = plan.descriptor(); + + assert_eq!(&bytes[0..4], &FORMAT_MAGIC); + assert_eq!(u16_at(&bytes, 4), FORMAT_VERSION); + assert_eq!(u16_at(&bytes, 6), FORMAT_HEADER_SIZE); + assert_eq!(u32_at(&bytes, 8), 3); + assert_eq!(u32_at(&bytes, 12), 4); + assert_eq!( + bytes.len(), + usize::from(FORMAT_HEADER_SIZE) + + 3 * usize::from(FORMAT_LAYOUT_RECORD_SIZE) + + 4 * usize::from(FORMAT_FIELD_RECORD_SIZE) + ); + + let first = usize::from(FORMAT_HEADER_SIZE); + assert_eq!(bytes[first + 8], KIND_STRUCT); + assert_eq!(bytes[first + 9], CONSTRUCTOR_STRUCT); + assert_eq!(u16_at(&bytes, first + 10), 0); + + let second = first + usize::from(FORMAT_LAYOUT_RECORD_SIZE); + assert_eq!(bytes[second + 8], KIND_ARRAY); + assert_eq!(bytes[second + 9], CONSTRUCTOR_ARRAY_GENERIC); + assert_eq!(u16_at(&bytes, second + 10), LAYOUT_FLAG_REQUIRES_PROVENANCE); + + let third = second + usize::from(FORMAT_LAYOUT_RECORD_SIZE); + assert_eq!(bytes[third + 8], KIND_ARRAY); + assert_eq!(bytes[third + 9], CONSTRUCTOR_ARRAY_FIXED); + assert_eq!(u16_at(&bytes, third + 10), LAYOUT_FLAG_REQUIRES_PROVENANCE); + assert_eq!(u32_at(&bytes, third + 28), plan.layouts()[1].id); + assert_eq!(u32_at(&bytes, third + 32), 2); + + let fields = first + 3 * usize::from(FORMAT_LAYOUT_RECORD_SIZE); + assert_eq!(bytes[fields + 1], 0); + assert_eq!( + bytes[fields + usize::from(FORMAT_FIELD_RECORD_SIZE) + 1], + FIELD_FLAG_MUTABLE | FIELD_FLAG_NULLABLE | FIELD_FLAG_REFERENCE + ); +} + +#[test] +fn generated_probe_and_local_anyref_codec_validate_without_typed_anyref_imports() { + let mut module = parse( + r#" + (module + (memory 1) + (type $base (sub (struct (field (mut i32))))) + (type $child + (sub $base (struct (field (mut i32)) (field (mut i64))))) + (func (export "child") (result (ref $child)) + i32.const 1 + i64.const 2 + struct.new $child)) + "#, + ); + let memory = module.memories.iter().next().unwrap().id(); + let declared = module_gc_codec::declare(&mut module, memory).expect("declare GC codec"); + let exception = module_exception_codec::inject_with_reference_overrides( + &mut module, + memory, + Some((declared.encode_externref, declared.decode_externref)), + Some((declared.encode_anyref, declared.decode_anyref)), + ) + .expect("inject exception codec"); + let runtime = runtime::inject_linked_runtime_with_reference_overrides( + &mut module, + runtime::ReferenceCodecOverrides { + funcref: Some(( + exception.references.encode_funcref, + exception.references.decode_funcref, + )), + externref: Some(( + exception.references.encode_externref, + exception.references.decode_externref, + )), + exnref: Some((exception.encode, exception.decode)), + anyref: Some((declared.encode_anyref, declared.decode_anyref)), + cleanup: Some(exception.clear), + }, + ); + module_gc_codec::finish_declaration(&mut module, declared, exception, &runtime) + .expect("emit GC codec"); + let wasm = module.emit_wasm(); + + let mut validator = wasmparser::Validator::new_with_features(wasmparser::WasmFeatures::all()); + validator + .validate_all(&wasm) + .expect("generated module validates"); + let printed = wasmprinter::print_bytes(&wasm).expect("print generated module"); + assert!(printed.contains("(export \"__wpk_fork_ref_gc_probe\"")); + assert!(printed.contains("(export \"__wpk_fork_ref_gc_encode_slot\"")); + assert!(printed.contains("(export \"__wpk_fork_ref_gc_publish_externref\"")); + assert!(printed.contains("(table (;")); + assert!(printed.contains("anyref"), "{printed}"); + assert!(printed.contains("any.convert_extern"), "{printed}"); + assert!(printed.contains("extern.convert_any"), "{printed}"); + assert!(!printed.contains("(import \"env\" \"__wpk_fork_ref_encode_anyref\"",)); + assert!(!printed.contains("(import \"env\" \"__wpk_fork_ref_decode_anyref\"",)); + assert!(!printed.contains("(import \"env\" \"__wpk_fork_ref_encode_externref\"",)); + assert!(!printed.contains("(import \"env\" \"__wpk_fork_ref_decode_externref\"",)); +} + +#[test] +fn generated_allocate_and_fill_cover_non_shell_structs_and_array_constructors() { + let mut module = parse( + r#" + (module + (memory 1) + (type $leaf (struct (field (mut i32)))) + (type $holder + (struct + (field i64) + (field (mut (ref $leaf))))) + (type $bytes (array i8)) + (type $refs (array (ref null $leaf))) + (type $mutable-refs (array (mut (ref null $leaf)))) + (data $data "\01\02\03") + (elem $elements (ref null $leaf) + (item (ref.null $leaf)) + (item (ref.null $leaf))) + + (func (export "holder") (result (ref $holder)) + i64.const 9 + i32.const 1 + struct.new $leaf + struct.new $holder) + (func (export "fixed") (result (ref $bytes)) + i32.const 1 + i32.const 2 + array.new_fixed $bytes 2) + (func (export "data") (result (ref $bytes)) + i32.const 0 + i32.const 3 + array.new_data $bytes $data) + (func (export "elements") (result (ref $refs)) + i32.const 0 + i32.const 2 + array.new_elem $refs $elements) + (func (export "mutable") (result (ref $mutable-refs)) + ref.null $leaf + i32.const 2 + array.new $mutable-refs)) + "#, + ); + let memory = module.memories.iter().next().unwrap().id(); + let declared = module_gc_codec::declare(&mut module, memory).expect("declare GC codec"); + let exception = module_exception_codec::inject_with_reference_overrides( + &mut module, + memory, + Some((declared.encode_externref, declared.decode_externref)), + Some((declared.encode_anyref, declared.decode_anyref)), + ) + .expect("inject exception codec"); + let runtime = runtime::inject_linked_runtime_with_reference_overrides( + &mut module, + runtime::ReferenceCodecOverrides { + funcref: Some(( + exception.references.encode_funcref, + exception.references.decode_funcref, + )), + externref: Some(( + exception.references.encode_externref, + exception.references.decode_externref, + )), + exnref: Some((exception.encode, exception.decode)), + anyref: Some((declared.encode_anyref, declared.decode_anyref)), + cleanup: Some(exception.clear), + }, + ); + module_gc_codec::finish_declaration(&mut module, declared, exception, &runtime) + .expect("emit GC codec"); + let wasm = module.emit_wasm(); + wasmparser::Validator::new_with_features(wasmparser::WasmFeatures::all()) + .validate_all(&wasm) + .expect("all generated concrete helpers validate"); +} + +#[test] +fn generated_seeds_cover_mutable_nonnullable_function_external_and_exception_refs() { + let wasm = finish_codec(parse( + r#" + (module + (memory 1) + (type $holder + (struct + (field (mut (ref func))) + (field (mut (ref extern))) + (field (mut (ref exn))))) + (type $functions (array (mut (ref func)))) + (type $externals (array (mut (ref extern)))) + (type $exceptions (array (mut (ref exn))))) + "#, + )); + + wasmparser::Validator::new_with_features(wasmparser::WasmFeatures::all()) + .validate_all(&wasm) + .expect("generated non-null reference seeds validate"); + let printed = wasmprinter::print_bytes(&wasm).expect("print generated seeds"); + assert!( + printed.contains("__wpk_fork_ref_seed_func"), + "funcref allocation needs a locally typed temporary seed", + ); + assert!( + printed.contains("extern.convert_any"), + "externref allocation needs a non-null locally constructed seed", + ); + assert!( + printed.contains("__wpk_fork_ref_seed_exn"), + "exnref allocation needs a fresh instance-local exception seed", + ); +} + +#[test] +fn mutable_nonnullable_array_fixed_records_every_constructor_reference() { + let module = parse( + r#" + (module + (type $leaf (struct (field (mut i32)))) + (type $refs (array (mut (ref $leaf)))) + (func (export "fixed") (result (ref $refs)) + i32.const 1 + struct.new $leaf + i32.const 2 + struct.new $leaf + array.new_fixed $refs 2)) + "#, + ); + let plan = plan(&module).expect("GC layouts plan"); + let fixed = plan + .layouts() + .iter() + .find(|layout| layout.constructor == (GcConstructorKind::ArrayFixed { len: 2 })) + .expect("specialized array.fixed layout"); + assert!(fixed.requires_provenance); + assert_eq!( + fixed.provenance_reference_count, 2, + "each element is an allocation dependency: using only one static seed \ + would lose distinct constructor identities", + ); +} + +#[test] +fn parent_i31_capture_publishes_the_identity_to_the_transit_table() { + let wasm = finish_codec(parse( + r#" + (module + (memory 1) + (type $box (struct (field (mut i32))))) + "#, + )); + let module = Module::from_buffer(&wasm).expect("parse generated GC codec"); + let transit = module + .imports + .iter() + .find_map(|import| { + (import.name == module_gc_codec::IMPORT_TRANSIT_TABLE).then(|| match import.kind { + walrus::ImportKind::Table(table) => Some(table), + _ => None, + })? + }) + .expect("GC transit table import"); + let encode = module + .funcs + .iter() + .find(|function| function.name.as_deref() == Some(module_gc_codec::LOCAL_ENCODE_ANYREF)) + .expect("local anyref encoder"); + let encode = match &encode.kind { + walrus::FunctionKind::Local(local) => local, + _ => panic!("anyref encoder must be local"), + }; + + fn has_i31_publication( + function: &walrus::LocalFunction, + sequence: walrus::ir::InstrSeqId, + transit: walrus::TableId, + ) -> bool { + let instructions = &function.block(sequence).instrs; + let reads_i31 = instructions + .iter() + .any(|(instruction, _)| matches!(instruction, walrus::ir::Instr::I31GetS(_))); + let publishes = instructions.iter().any( + |(instruction, _)| { + matches!(instruction, walrus::ir::Instr::TableSet(set) if set.table == transit) + }, + ); + if reads_i31 && publishes { + return true; + } + instructions.iter().any(|(instruction, _)| { + let children: &[walrus::ir::InstrSeqId] = match instruction { + walrus::ir::Instr::Block(block) => std::slice::from_ref(&block.seq), + walrus::ir::Instr::Loop(block) => std::slice::from_ref(&block.seq), + walrus::ir::Instr::TryTable(table) => std::slice::from_ref(&table.seq), + walrus::ir::Instr::IfElse(branches) => { + return has_i31_publication(function, branches.consequent, transit) + || has_i31_publication(function, branches.alternative, transit); + } + _ => &[], + }; + children + .iter() + .any(|child| has_i31_publication(function, *child, transit)) + }) + } + + assert!( + has_i31_publication(encode, encode.entry_block(), transit), + "the parent encoder must publish recipe+1 -> i31ref because JavaScript \ + receives only the scalar i31 payload and cannot manufacture the value", + ); +} diff --git a/crates/fork-instrument/tests/module_gc_codec_node.rs b/crates/fork-instrument/tests/module_gc_codec_node.rs new file mode 100644 index 0000000000..97dd145ffc --- /dev/null +++ b/crates/fork-instrument/tests/module_gc_codec_node.rs @@ -0,0 +1,741 @@ +use std::{ + fs, + process::Command, + time::{SystemTime, UNIX_EPOCH}, +}; + +use fork_instrument::{module_exception_codec, module_gc_codec, runtime}; +use walrus::Module; + +fn fixture_module() -> Vec { + let input = wat::parse_str( + r#" + (module + (import "env" "memory" (memory 2)) + (type $node + (struct + (field (mut i32)) + (field (mut (ref null $node))) + (field (mut (ref null any))))) + (type $fixed (array i16)) + (type $data-bytes (array i8)) + (type $nullable-array (array (ref null $node))) + (table $objects (export "objects") 5 (ref null any)) + (data $bytes "\0b\16\21") + + (func (export "create_cycle") + (local $node (ref null $node)) + i32.const 77 + ref.null $node + ref.null any + struct.new $node + local.set $node + local.get $node + local.get $node + struct.set $node 1 + i32.const 0 + local.get $node + table.set $objects) + + (func (export "verify_cycle") (result i32) + (local $node (ref null $node)) + i32.const 0 + table.get $objects + ref.cast (ref $node) + local.set $node + local.get $node + ref.as_non_null + struct.get $node 0 + i32.const 77 + i32.eq + local.get $node + ref.as_non_null + local.get $node + ref.as_non_null + struct.get $node 1 + ref.as_non_null + ref.eq + i32.and) + + (func (export "create_externalized_cycle") + (param $token externref) + (result externref) + (local $node (ref null $node)) + i32.const 88 + ref.null $node + local.get $token + any.convert_extern + struct.new $node + local.set $node + local.get $node + local.get $node + struct.set $node 1 + i32.const 1 + local.get $node + table.set $objects + local.get $node + extern.convert_any) + + (func (export "verify_externalized_cycle") + (param $root externref) + (result i32) + (local $node (ref null $node)) + local.get $root + any.convert_extern + ref.cast (ref $node) + local.set $node + local.get $node + struct.get $node 0 + i32.const 88 + i32.eq + local.get $node + local.get $node + struct.get $node 1 + ref.as_non_null + ref.eq + i32.and) + + (func (export "externalized_cycle_token") + (param $root externref) + (result externref) + local.get $root + any.convert_extern + ref.cast (ref $node) + struct.get $node 2 + extern.convert_any) + + (func (export "create_fixed") + (local $array (ref null $fixed)) + i32.const 11 + i32.const 22 + array.new_fixed $fixed 2 + local.set $array + i32.const 2 + local.get $array + table.set $objects) + + (func (export "verify_fixed") (result i32) + (local $array (ref null $fixed)) + i32.const 2 + table.get $objects + ref.cast (ref $fixed) + local.set $array + local.get $array + i32.const 0 + array.get_u $fixed + i32.const 11 + i32.eq + local.get $array + i32.const 1 + array.get_u $fixed + i32.const 22 + i32.eq + i32.and) + + (func (export "create_data") + (local $array (ref null $data-bytes)) + i32.const 0 + i32.const 3 + array.new_data $data-bytes $bytes + local.set $array + i32.const 3 + local.get $array + table.set $objects) + + (func (export "verify_data") (result i32) + (local $array (ref null $data-bytes)) + i32.const 3 + table.get $objects + ref.cast (ref $data-bytes) + local.set $array + local.get $array + i32.const 0 + array.get_u $data-bytes + i32.const 11 + i32.eq + local.get $array + i32.const 1 + array.get_u $data-bytes + i32.const 22 + i32.eq + i32.and + local.get $array + i32.const 2 + array.get_u $data-bytes + i32.const 33 + i32.eq + i32.and) + + (func (export "create_nullable_empty") + (local $array (ref null $nullable-array)) + ref.null $node + i32.const 0 + array.new $nullable-array + local.set $array + i32.const 4 + local.get $array + table.set $objects) + + (func (export "verify_nullable_empty") (result i32) + i32.const 4 + table.get $objects + ref.cast (ref $nullable-array) + array.len + i32.eqz)) + "#, + ) + .expect("GC provider fixture WAT"); + let mut module = Module::from_buffer(&input).expect("GC provider fixture module"); + let memory = module.memories.iter().next().expect("provider memory").id(); + let declared = module_gc_codec::declare(&mut module, memory).expect("declare GC codec"); + module + .exports + .add("__test_encode_externref", declared.encode_externref); + module + .exports + .add("__test_decode_externref", declared.decode_externref); + let exception = module_exception_codec::inject_with_reference_overrides( + &mut module, + memory, + Some((declared.encode_externref, declared.decode_externref)), + Some((declared.encode_anyref, declared.decode_anyref)), + ) + .expect("inject exception codec"); + let runtime = runtime::inject_linked_runtime_with_reference_overrides( + &mut module, + runtime::ReferenceCodecOverrides { + funcref: Some(( + exception.references.encode_funcref, + exception.references.decode_funcref, + )), + externref: Some(( + exception.references.encode_externref, + exception.references.decode_externref, + )), + exnref: Some((exception.encode, exception.decode)), + anyref: Some((declared.encode_anyref, declared.decode_anyref)), + cleanup: Some(exception.clear), + }, + ); + module_gc_codec::finish_declaration(&mut module, declared, exception, &runtime) + .expect("finish GC codec"); + module.emit_wasm() +} + +fn transit_provider_module() -> Vec { + wat::parse_str( + r#" + (module + (table (export "transit") 64 (ref null any))) + "#, + ) + .expect("transit provider WAT") +} + +#[test] +fn fresh_node_instance_reconstructs_gc_cycle_and_identity() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "kandelo-module-gc-codec-{}-{nonce}", + std::process::id(), + )); + fs::create_dir(&directory).expect("create fixture directory"); + fs::write(directory.join("provider.wasm"), fixture_module()).expect("write provider"); + fs::write(directory.join("transit.wasm"), transit_provider_module()) + .expect("write transit provider"); + fs::write( + directory.join("test.mjs"), + r#" +import { readFileSync } from "node:fs"; + +const providerModule = new WebAssembly.Module(readFileSync( + new URL("./provider.wasm", import.meta.url), +)); +const transitModule = new WebAssembly.Module(readFileSync( + new URL("./transit.wasm", import.meta.url), +)); +const transit = new WebAssembly.Instance(transitModule).exports.transit; +const recipes = new Map(); +const identities = new WeakMap(); +const capturedValues = new Map(); +const provenance = new WeakMap(); +const brokerValues = new Map(); +const vectors = [{ expected: 0, values: [] }]; +let nextRecipe = 1; +let nextProvenance = 1; +let pendingProvenance = null; + +function concatenate(left, right) { + const result = new Uint8Array(left.length + right.length); + result.set(left); + result.set(right, left.length); + return result; +} + +function instantiate() { + const memory = new WebAssembly.Memory({ initial: 2 }); + const scratch = []; + let scratchTop = 0x10000; + let instance; + + const implemented = { + __wpk_fork_ref_gc_lookup(slot) { + const value = transit.get(slot); + return value === null ? 0 : (identities.get(value) ?? 0); + }, + __wpk_fork_ref_gc_claim(slot) { + const value = transit.get(slot); + if (value === null) throw new Error("claim of null GC transit slot"); + let recipe = identities.get(value); + if (recipe === undefined) { + recipe = nextRecipe++; + identities.set(value, recipe); + capturedValues.set(recipe, value); + } + return recipe; + }, + __wpk_fork_ref_gc_broker_encode(slot) { + const value = transit.get(slot); + if (value === null) throw new Error("broker encode received null"); + let recipe = identities.get(value); + if (recipe === undefined) { + recipe = nextRecipe++; + identities.set(value, recipe); + brokerValues.set(recipe, value); + } + while (transit.length <= recipe + 1) transit.grow(1); + return recipe; + }, + __wpk_fork_ref_gc_i31(value) { + const recipe = nextRecipe++; + recipes.set(recipe, { + activation: 7, + type: 0xffffffff, + layout: 0, + kind: 0, + scalars: Uint8Array.of( + value & 0xff, + (value >>> 8) & 0xff, + (value >>> 16) & 0xff, + (value >>> 24) & 0xff, + ), + vector: 0, + }); + return recipe; + }, + __wpk_fork_ref_gc_capture_layout(slot, activation, baseLayout) { + if (activation !== 7) throw new Error("wrong capture activation"); + const record = provenance.get(transit.get(slot)); + if (!record) { + // Layout 1 is the default-constructible mutable struct used by the + // cycle fixture. Its field snapshot is a complete reconstruction + // recipe, so only the immutable-array layouts require constructor + // provenance. + if (baseLayout === 1) return baseLayout; + throw new Error("GC constructor provenance was not registered"); + } + if (record.activation !== activation) { + throw new Error("GC constructor provenance has the wrong activation"); + } + if (record.baseLayout !== baseLayout) { + throw new Error("GC constructor provenance has the wrong base layout"); + } + return record.specializedLayout; + }, + __wpk_fork_ref_gc_provenance_begin( + slot, activation, baseLayout, specializedLayout, + scalarLo, scalarHi, referenceCount, + ) { + if (pendingProvenance !== null) { + throw new Error("nested GC provenance registration"); + } + if (activation !== 7) throw new Error("wrong provenance activation"); + const object = transit.get(slot); + if (object === null) throw new Error("null GC provenance object"); + let scalars; + if (scalarLo === 0n && scalarHi === 0n) { + scalars = new Uint8Array(); + } else { + // array.new_data(0, 3) must occupy one packed eight-byte record. + if (scalarLo !== 0x0000000300000000n || scalarHi !== 0n) { + throw new Error( + `GC data constructor operands were not packed: ${scalarLo}:${scalarHi}`, + ); + } + scalars = new Uint8Array(8); + new DataView(scalars.buffer).setBigUint64(0, scalarLo, true); + } + const token = nextProvenance++; + pendingProvenance = { + token, + object, + activation, + baseLayout, + specializedLayout, + scalars, + referenceCount, + references: [], + }; + return token; + }, + __wpk_fork_ref_gc_provenance_ref(token, index, slot) { + if ( + pendingProvenance === null + || pendingProvenance.token !== token + || index !== pendingProvenance.references.length + || index >= pendingProvenance.referenceCount + ) { + throw new Error("invalid GC provenance reference"); + } + pendingProvenance.references.push(transit.get(slot)); + }, + __wpk_fork_ref_gc_provenance_end(token) { + if ( + pendingProvenance === null + || pendingProvenance.token !== token + || pendingProvenance.references.length + !== pendingProvenance.referenceCount + ) { + throw new Error("incomplete GC provenance registration"); + } + provenance.set(pendingProvenance.object, { + activation: pendingProvenance.activation, + baseLayout: pendingProvenance.baseLayout, + specializedLayout: pendingProvenance.specializedLayout, + scalars: pendingProvenance.scalars, + references: pendingProvenance.references, + }); + pendingProvenance = null; + }, + __wpk_fork_ref_gc_define( + recipe, activation, type, layout, kind, + scalarPointer, scalarLength, vector, + ) { + const refs = vectors[vector]; + if (!refs || refs.values.length !== refs.expected) { + throw new Error("incomplete capture vector"); + } + const source = capturedValues.get(recipe); + const constructor = source === undefined ? undefined : provenance.get(source); + const snapshot = new Uint8Array( + memory.buffer, + Number(scalarPointer), + scalarLength, + ).slice(); + const constructorRecipes = constructor?.references.map((reference) => { + if (reference === null) return 0; + transit.set(0, reference); + return instance.exports.__wpk_fork_ref_gc_encode_slot(0); + }) ?? []; + const combinedVector = constructorRecipes.length === 0 + ? vector + : vectors.push({ + expected: constructorRecipes.length + refs.values.length, + values: [...constructorRecipes, ...refs.values], + }) - 1; + recipes.set(recipe, { + activation, + type, + layout, + kind, + scalars: concatenate( + constructor?.scalars ?? new Uint8Array(), + snapshot, + ), + vector: combinedVector, + }); + }, + __wpk_fork_ref_gc_route(recipe, activation) { + const value = recipes.get(recipe); + return value?.activation === activation ? value.layout : -1; + }, + __wpk_fork_ref_gc_payload_len(recipe, activation, layout) { + const value = recipes.get(recipe); + if (!value || value.activation !== activation || value.layout !== layout) { + throw new Error("GC payload route mismatch"); + } + return value.scalars.length; + }, + __wpk_fork_ref_gc_load( + recipe, activation, type, layout, kind, destination, length, + ) { + const value = recipes.get(recipe); + if ( + !value + || value.activation !== activation + || value.type !== (type >>> 0) + || value.layout !== layout + || value.kind !== kind + || value.scalars.length !== length + ) throw new Error("GC load coordinate mismatch"); + new Uint8Array(memory.buffer, Number(destination), length) + .set(value.scalars); + return value.vector; + }, + __wpk_fork_ref_vector_begin(expected) { + const ordinal = vectors.length; + vectors.push({ expected, values: [] }); + return ordinal; + }, + __wpk_fork_ref_vector_append(ordinal, recipe) { + const vector = vectors[ordinal]; + if (!vector || vector.values.length >= vector.expected) { + throw new Error("invalid vector append"); + } + vector.values.push(recipe); + }, + __wpk_fork_ref_vector_finish(ordinal) { + const vector = vectors[ordinal]; + if (!vector || vector.values.length !== vector.expected) { + throw new Error("incomplete vector finish"); + } + return ordinal; + }, + __wpk_fork_ref_vector_get(ordinal, index) { + const value = vectors[ordinal]?.values[index]; + if (value === undefined) throw new Error("invalid vector lookup"); + return value; + }, + __wpk_fork_ref_scratch_reserve(size) { + const aligned = (Number(size) + 15) & ~15; + const address = scratchTop; + scratchTop += aligned; + scratch.push({ address, size: Number(size), aligned }); + new Uint8Array(memory.buffer, address, aligned).fill(0); + return address; + }, + __wpk_fork_ref_scratch_release(address, size) { + const reservation = scratch.pop(); + if ( + !reservation + || reservation.address !== Number(address) + || reservation.size !== Number(size) + ) throw new Error("non-LIFO scratch release"); + new Uint8Array( + memory.buffer, + reservation.address, + reservation.aligned, + ).fill(0); + scratchTop = reservation.address; + }, + }; + + const imports = {}; + for (const descriptor of WebAssembly.Module.imports(providerModule)) { + const namespace = imports[descriptor.module] ??= {}; + if (descriptor.kind === "memory") { + namespace[descriptor.name] = memory; + } else if (descriptor.kind === "global") { + namespace[descriptor.name] = descriptor.name === "__wpk_fork_module_activation" + ? new WebAssembly.Global({ value: "i32", mutable: false }, 7) + : new WebAssembly.Global({ value: "i32", mutable: true }, 0); + } else if (descriptor.kind === "table") { + namespace[descriptor.name] = + descriptor.name === "__wpk_fork_ref_gc_transit" + ? transit + : new WebAssembly.Table({ + element: "anyfunc", + initial: 1, + }); + } else if (descriptor.kind === "tag") { + namespace[descriptor.name] = new WebAssembly.Tag({ parameters: [] }); + } else if (descriptor.kind === "function") { + namespace[descriptor.name] = implemented[descriptor.name] ?? (() => { + throw new Error(`unexpected import call ${descriptor.name}`); + }); + } + } + instance = new WebAssembly.Instance(providerModule, imports); + return { instance, memory }; +} + +const parent = instantiate(); +parent.instance.exports.create_cycle(); +if (parent.instance.exports.verify_cycle() !== 1) { + throw new Error("parent fixture did not create its self-cycle"); +} +const parentObject = parent.instance.exports.objects.get(0); +transit.set(0, parentObject); +const recipe = parent.instance.exports.__wpk_fork_ref_gc_encode_slot(0); +if (recipe !== 1) throw new Error(`unexpected root recipe ${recipe}`); +if (vectors[recipes.get(recipe).vector].values[0] !== recipe) { + throw new Error("parent recipe did not preserve the self-edge"); +} +parent.instance.exports.create_fixed(); +parent.instance.exports.create_data(); +parent.instance.exports.create_nullable_empty(); +if ( + parent.instance.exports.verify_fixed() !== 1 + || parent.instance.exports.verify_data() !== 1 + || parent.instance.exports.verify_nullable_empty() !== 1 +) { + throw new Error("parent immutable-array fixture is invalid"); +} +transit.set(0, parent.instance.exports.objects.get(2)); +const fixedRecipe = + parent.instance.exports.__wpk_fork_ref_gc_encode_slot(0); +transit.set(0, parent.instance.exports.objects.get(3)); +const dataRecipe = + parent.instance.exports.__wpk_fork_ref_gc_encode_slot(0); +transit.set(0, parent.instance.exports.objects.get(4)); +const nullableEmptyRecipe = + parent.instance.exports.__wpk_fork_ref_gc_encode_slot(0); + +// Remove every parent-owned transit identity before creating the child. Replay +// must allocate a new object in the child's recursive type universe. +for (let index = 0; index < transit.length; index++) transit.set(index, null); +const child = instantiate(); +child.instance.exports.__wpk_fork_ref_gc_allocate(recipe); +child.instance.exports.__wpk_fork_ref_gc_fill(recipe); +const childObject = transit.get(recipe + 1); +if (childObject === null || childObject === parentObject) { + throw new Error("fresh child reused or lost the parent GC identity"); +} +child.instance.exports.objects.set(0, childObject); +if (child.instance.exports.verify_cycle() !== 1) { + throw new Error("fresh child lost scalar data, alias identity, or the cycle"); +} +for (const [arrayRecipe, tableIndex] of [ + [fixedRecipe, 2], + [dataRecipe, 3], + [nullableEmptyRecipe, 4], +]) { + child.instance.exports.__wpk_fork_ref_gc_allocate(arrayRecipe); + child.instance.exports.__wpk_fork_ref_gc_fill(arrayRecipe); + child.instance.exports.objects.set( + tableIndex, + transit.get(arrayRecipe + 1), + ); +} +if ( + child.instance.exports.verify_fixed() !== 1 + || child.instance.exports.verify_data() !== 1 + || child.instance.exports.verify_nullable_empty() !== 1 +) { + throw new Error("fresh child lost immutable-array constructor state"); +} + +// A replayed child is itself a valid future parent. Encoding its reconstructed +// arrays must use constructor provenance registered by the generated allocate +// helper, not a parent-Worker object or a stale transaction slot. +transit.set(0, child.instance.exports.objects.get(2)); +const nestedFixedRecipe = + child.instance.exports.__wpk_fork_ref_gc_encode_slot(0); +transit.set(0, child.instance.exports.objects.get(3)); +const nestedDataRecipe = + child.instance.exports.__wpk_fork_ref_gc_encode_slot(0); +transit.set(0, child.instance.exports.objects.get(4)); +const nestedNullableEmptyRecipe = + child.instance.exports.__wpk_fork_ref_gc_encode_slot(0); +for (let index = 0; index < transit.length; index++) transit.set(index, null); +const grandchild = instantiate(); +for (const [arrayRecipe, tableIndex] of [ + [nestedFixedRecipe, 2], + [nestedDataRecipe, 3], + [nestedNullableEmptyRecipe, 4], +]) { + grandchild.instance.exports.__wpk_fork_ref_gc_allocate(arrayRecipe); + grandchild.instance.exports.__wpk_fork_ref_gc_fill(arrayRecipe); + grandchild.instance.exports.objects.set( + tableIndex, + transit.get(arrayRecipe + 1), + ); +} +if ( + grandchild.instance.exports.verify_fixed() !== 1 + || grandchild.instance.exports.verify_data() !== 1 + || grandchild.instance.exports.verify_nullable_empty() !== 1 +) { + throw new Error("grandchild lost replay-registered GC constructor state"); +} + +// `extern.convert_any` is only a view of the same GC identity. Encoding that +// view must recover the typed object rather than assigning it an opaque host +// handle, while the token stored inside the object must become one broker leaf. +const parentToken = Object.freeze({ owner: "parent-token" }); +const externalizedRoot = + parent.instance.exports.create_externalized_cycle(parentToken); +if ( + parent.instance.exports.verify_externalized_cycle( + externalizedRoot, + ) !== 1 + || parent.instance.exports.externalized_cycle_token(externalizedRoot) + !== parentToken +) { + throw new Error("parent externalized fixture lost its GC/token identity"); +} +const externalizedRecipe = + parent.instance.exports.__test_encode_externref(externalizedRoot); +const externalizedRecord = recipes.get(externalizedRecipe); +if ( + !externalizedRecord + || vectors[externalizedRecord.vector].values[0] !== externalizedRecipe +) { + throw new Error("externalized GC recipe did not preserve its self-cycle"); +} +const tokenRecipe = vectors[externalizedRecord.vector].values[1]; +if (brokerValues.get(tokenRecipe) !== parentToken) { + throw new Error("opaque token was not captured as the graph's broker leaf"); +} +const directTokenRecipe = + parent.instance.exports.__test_encode_externref(parentToken); +if (directTokenRecipe !== tokenRecipe) { + throw new Error("externref/anyref token aliases received different recipes"); +} +transit.set(0, parent.instance.exports.objects.get(1)); +const directAnyRecipe = + parent.instance.exports.__wpk_fork_ref_gc_encode_slot(0); +if (directAnyRecipe !== externalizedRecipe) { + throw new Error("externalized/direct anyref aliases received different recipes"); +} + +for (let index = 0; index < transit.length; index++) transit.set(index, null); +const externalizedChild = instantiate(); +const childToken = Object.freeze({ owner: "child-token" }); +externalizedChild.instance.exports.__wpk_fork_ref_gc_publish_externref( + tokenRecipe, + childToken, +); +externalizedChild.instance.exports.__wpk_fork_ref_gc_allocate( + externalizedRecipe, +); +externalizedChild.instance.exports.__wpk_fork_ref_gc_fill( + externalizedRecipe, +); +const childExternalizedRoot = + externalizedChild.instance.exports.__test_decode_externref( + externalizedRecipe, + ); +if ( + childExternalizedRoot === externalizedRoot + || childToken === parentToken + || externalizedChild.instance.exports.verify_externalized_cycle( + childExternalizedRoot, + ) !== 1 + || externalizedChild.instance.exports.externalized_cycle_token( + childExternalizedRoot, + ) !== childToken +) { + throw new Error( + "fresh child lost externalized GC identity, cycle, or broker token", + ); +} +"#, + ) + .expect("write Node test"); + + let output = Command::new("node") + .arg(directory.join("test.mjs")) + .output() + .expect("run Node"); + let _ = fs::remove_dir_all(&directory); + assert!( + output.status.success(), + "Node fresh-instance GC codec test failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} diff --git a/crates/fork-instrument/tests/module_state.rs b/crates/fork-instrument/tests/module_state.rs new file mode 100644 index 0000000000..6b2f47116a --- /dev/null +++ b/crates/fork-instrument/tests/module_state.rs @@ -0,0 +1,1795 @@ +//! Guest-owned module-state reconstruction for fresh fork children. + +use fork_instrument::runtime::names; +use fork_instrument::{FUNCTION_CATALOG_EXPORT, Options, instrument}; +use std::{ + fs, + process::Command, + time::{SystemTime, UNIX_EPOCH}, +}; +use walrus::{ + ExportItem, FunctionId, FunctionKind, ImportKind, LocalFunction, Module, ValType, + ir::{self, Instr, InstrSeqId}, +}; +use wasm_posix_shared::abi::{ + WPK_FORK_EXPORT_MODULE_BOOTSTRAP, WPK_FORK_EXPORT_MODULE_STATE_FINISH_RESTORE, + WPK_FORK_EXPORT_MODULE_STATE_RESTORE, WPK_FORK_EXPORT_MODULE_STATE_SAVE, + WPK_FORK_EXPORT_MODULE_TABLE_STATE_SAVE, + WPK_FORK_EXPORT_MODULE_THREAD_BOOTSTRAP, WPK_FORK_GLOBAL_CATALOG_EXPORT_PREFIX, + WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE, WPK_FORK_IMPORTED_GLOBALS_MAGIC, + WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE, WPK_FORK_IMPORTED_GLOBALS_SECTION, + WPK_FORK_IMPORTED_TABLES_HEADER_SIZE, WPK_FORK_IMPORTED_TABLES_MAGIC, + WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE, WPK_FORK_IMPORTED_TABLES_SECTION, + WPK_FORK_MODULE_STATE_IMPORT_RECORD_COMMIT, WPK_FORK_MODULE_STATE_IMPORT_RECORD_FIND, + WPK_FORK_MODULE_STATE_IMPORT_RECORD_RESERVE, WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_COUNT, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_MARK, WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_PAGE, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_MUTATION_ABORT, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_MUTATION_BEGIN, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_MUTATION_COMMIT, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_RECONCILE, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_STATE_OWNED, WPK_FORK_TABLE_CATALOG_EXPORT_PREFIX, +}; + +fn instrument_wat(wat: &str) -> Vec { + let input = wat::parse_str(wat).expect("parse WAT fixture"); + instrument(&input, &Options::default()).expect("instrument fixture") +} + +fn validate(bytes: &[u8]) { + wasmparser::Validator::new_with_features(wasmparser::WasmFeatures::default()) + .validate_all(bytes) + .expect("instrumented module validates"); +} + +fn export_function(module: &Module, name: &str) -> FunctionId { + match module + .exports + .iter() + .find(|export| export.name == name) + .unwrap_or_else(|| panic!("missing export {name}")) + .item + { + ExportItem::Function(function) => function, + other => panic!("{name} is not a function: {other:?}"), + } +} + +fn imported_function(module: &Module, name: &str) -> FunctionId { + module + .imports + .iter() + .find_map(|import| { + if import.module != "env" || import.name != name { + return None; + } + match &import.kind { + ImportKind::Function(function) => Some(*function), + _ => None, + } + }) + .unwrap_or_else(|| panic!("missing import env.{name}")) +} + +fn signature(module: &Module, function: FunctionId) -> (Vec, Vec) { + let ty = module.types.get(module.funcs.get(function).ty()); + (ty.params().to_vec(), ty.results().to_vec()) +} + +fn local(module: &Module, function: FunctionId) -> &LocalFunction { + match &module.funcs.get(function).kind { + FunctionKind::Local(local) => local, + other => panic!("expected local function, got {other:?}"), + } +} + +fn children(instr: &Instr) -> Vec { + match instr { + Instr::Block(ir::Block { seq }) | Instr::Loop(ir::Loop { seq }) => vec![*seq], + Instr::IfElse(ir::IfElse { + consequent, + alternative, + }) => vec![*consequent, *alternative], + Instr::TryTable(ir::TryTable { seq, .. }) => vec![*seq], + Instr::Try(try_) => { + let mut result = vec![try_.seq]; + for catch in &try_.catches { + match catch { + ir::LegacyCatch::Catch { handler, .. } + | ir::LegacyCatch::CatchAll { handler } => result.push(*handler), + ir::LegacyCatch::Delegate { .. } => {} + } + } + result + } + _ => Vec::new(), + } +} + +fn walk(local: &LocalFunction, seq: InstrSeqId, visit: &mut impl FnMut(&Instr)) { + for (instr, _) in &local.block(seq).instrs { + visit(instr); + for child in children(instr) { + walk(local, child, visit); + } + } +} + +fn assert_helper_signature(module: &Module, name: &str) { + assert_eq!( + signature(module, export_function(module, name)), + (vec![ValType::I32], vec![]), + "{name} must use the ABI activation-id signature", + ); +} + +fn custom_section<'a>(bytes: &'a [u8], name: &str) -> &'a [u8] { + wasmparser::Parser::new(0) + .parse_all(bytes) + .find_map(|payload| match payload.expect("parse custom section") { + wasmparser::Payload::CustomSection(section) if section.name() == name => { + Some(section.data()) + } + _ => None, + }) + .unwrap_or_else(|| panic!("missing custom section {name}")) +} + +fn emitted_local_count(bytes: &[u8], export_name: &str) -> u32 { + let mut imported_functions = 0u32; + let mut exported_function = None; + let mut defined_function = 0u32; + for payload in wasmparser::Parser::new(0).parse_all(bytes) { + match payload.expect("parse emitted module") { + wasmparser::Payload::ImportSection(imports) => { + for import in imports.into_imports() { + if matches!( + import.expect("parse emitted import").ty, + wasmparser::TypeRef::Func(_) | wasmparser::TypeRef::FuncExact(_) + ) { + imported_functions += 1; + } + } + } + wasmparser::Payload::ExportSection(exports) => { + for export in exports { + let export = export.expect("parse emitted export"); + if export.name == export_name + && matches!( + export.kind, + wasmparser::ExternalKind::Func + | wasmparser::ExternalKind::FuncExact + ) + { + exported_function = Some(export.index); + } + } + } + wasmparser::Payload::CodeSectionEntry(body) => { + let function_index = imported_functions + defined_function; + defined_function += 1; + if Some(function_index) != exported_function { + continue; + } + return body + .get_locals_reader() + .expect("read emitted locals") + .into_iter() + .map(|local| local.expect("parse emitted local").0) + .sum(); + } + _ => {} + } + } + panic!("missing emitted function export {export_name}"); +} + +fn codec_function(module: &Module, name: &str) -> FunctionId { + module + .exports + .iter() + .find_map(|export| { + (export.name == name) + .then_some(export.item) + .and_then(|item| match item { + ExportItem::Function(function) => Some(function), + _ => None, + }) + }) + .or_else(|| { + module.imports.iter().find_map(|import| { + (import.name == name) + .then_some(&import.kind) + .and_then(|kind| match kind { + ImportKind::Function(function) => Some(*function), + _ => None, + }) + }) + }) + .or_else(|| { + module + .funcs + .iter() + .find(|function| function.name.as_deref() == Some(name)) + .map(|function| function.id()) + }) + .unwrap_or_else(|| panic!("missing codec function {name}")) +} + +#[test] +fn table_synchronization_helpers_do_not_add_source_function_locals() { + let bytes = instrument_wat( + r#" + (module + (type $unary (func (param i32) (result i32))) + (memory 1) + (table $callbacks 8 32 funcref) + (func $identity (type $unary) (param i32) (result i32) + local.get 0) + (elem $passive func $identity) + (func (export "table_get") (param i32) (result funcref) + local.get 0 table.get $callbacks) + (func (export "table_set") (param i32 funcref) + local.get 0 local.get 1 table.set $callbacks) + (func (export "table_fill") (param i32 funcref i32) + local.get 0 local.get 1 local.get 2 table.fill $callbacks) + (func (export "table_copy") (param i32 i32 i32) + local.get 0 local.get 1 local.get 2 + table.copy $callbacks $callbacks) + (func (export "table_init") (param i32 i32 i32) + local.get 0 local.get 1 local.get 2 + table.init $callbacks $passive) + (func (export "table_grow") (param funcref i32) (result i32) + local.get 0 local.get 1 table.grow $callbacks) + (func (export "table_size") (result i32) + table.size $callbacks) + (func (export "call_indirect") (param i32 i32) (result i32) + local.get 0 local.get 1 + call_indirect $callbacks (type $unary)) + (func (export "return_call_indirect") (param i32 i32) (result i32) + local.get 0 local.get 1 + return_call_indirect $callbacks (type $unary))) + "#, + ); + validate(&bytes); + for name in [ + "table_get", + "table_set", + "table_fill", + "table_copy", + "table_init", + "table_grow", + "table_size", + "call_indirect", + "return_call_indirect", + ] { + assert_eq!( + emitted_local_count(&bytes, name), + 0, + "{name} gained an emitted source local; synchronization operands \ + must live only in generated non-suspendable helpers", + ); + } +} + +#[test] +fn deterministic_static_tables_do_not_pay_the_process_generation_fence() { + let static_bytes = instrument_wat( + r#" + (module + (@custom "dylink.0" (before first) "state-only") + (memory 1) + (table $callbacks (export "__indirect_function_table") 1 funcref) + (func $callback) + (elem (i32.const 0) $callback) + (func (export "read") (param i32) (result funcref) + local.get 0 + table.get $callbacks)) + "#, + ); + validate(&static_bytes); + let static_module = + Module::from_buffer(&static_bytes).expect("parse static-table module"); + let read = local(&static_module, export_function(&static_module, "read")); + let mut direct_get = false; + let mut calls_generation_helper = false; + walk(read, read.entry_block(), &mut |instr| match instr { + Instr::TableGet(_) => direct_get = true, + Instr::Call(call) => { + calls_generation_helper |= static_module + .funcs + .get(call.func) + .name + .as_deref() + .is_some_and(|name| name.starts_with("__wpk_fork_table_get_")); + } + _ => {} + }); + assert!( + direct_get && !calls_generation_helper, + "a deterministic local table read must remain a direct Wasm operation", + ); + let table_save = local( + &static_module, + export_function( + &static_module, + WPK_FORK_EXPORT_MODULE_TABLE_STATE_SAVE, + ), + ); + let mut snapshots_static_table = false; + walk(table_save, table_save.entry_block(), &mut |instr| { + snapshots_static_table |= matches!(instr, Instr::TableSize(_) | Instr::TableGet(_)); + }); + assert!( + !snapshots_static_table, + "static element initialization is the reconstruction owner; a peer snapshot is redundant", + ); + + let process_bytes = instrument_wat( + r#" + (module + (import "env" "__wasm_dlopen" + (func (param i32 i32 i32 i32) (result i32))) + (memory 1) + (table $callbacks (export "__indirect_function_table") 1 funcref) + (func (export "read") (param i32) (result funcref) + local.get 0 + table.get $callbacks)) + "#, + ); + validate(&process_bytes); + let process_module = + Module::from_buffer(&process_bytes).expect("parse process-table module"); + let read = local(&process_module, export_function(&process_module, "read")); + direct_get = false; + calls_generation_helper = false; + walk(read, read.entry_block(), &mut |instr| match instr { + Instr::TableGet(_) => direct_get = true, + Instr::Call(call) => { + calls_generation_helper |= process_module + .funcs + .get(call.func) + .name + .as_deref() + .is_some_and(|name| name.starts_with("__wpk_fork_table_get_")); + } + _ => {} + }); + assert!( + !direct_get && calls_generation_helper, + "the dynamic linker's process table must reconcile before it is consumed", + ); +} + +#[test] +fn module_state_imports_and_exports_use_exact_wasm32_signatures() { + let bytes = instrument_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (memory 1) + (global $root (mut funcref) (ref.null func)) + (table $callbacks 2 8 funcref) + (func $caller (result i32) call $fork)) + "#, + ); + validate(&bytes); + let module = Module::from_buffer(&bytes).expect("parse instrumented module"); + + assert_helper_signature(&module, WPK_FORK_EXPORT_MODULE_STATE_SAVE); + assert_helper_signature(&module, WPK_FORK_EXPORT_MODULE_STATE_RESTORE); + assert_helper_signature(&module, WPK_FORK_EXPORT_MODULE_STATE_FINISH_RESTORE); + assert_eq!( + signature( + &module, + export_function(&module, WPK_FORK_EXPORT_MODULE_BOOTSTRAP) + ), + (vec![], vec![]), + ); + assert_eq!( + signature( + &module, + export_function(&module, WPK_FORK_EXPORT_MODULE_THREAD_BOOTSTRAP), + ), + (vec![], vec![]), + ); + assert_eq!( + signature( + &module, + imported_function(&module, WPK_FORK_MODULE_STATE_IMPORT_RECORD_RESERVE), + ), + ( + vec![ValType::I32, ValType::I32, ValType::I32, ValType::I32], + vec![ValType::I32], + ), + ); + assert_eq!( + signature( + &module, + imported_function(&module, WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_MARK), + ), + (vec![ValType::I32, ValType::I64, ValType::I64], vec![]), + ); + assert_eq!( + signature( + &module, + imported_function(&module, WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_COUNT), + ), + (vec![ValType::I32], vec![ValType::I32]), + ); + assert_eq!( + signature( + &module, + imported_function(&module, WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_PAGE), + ), + (vec![ValType::I32, ValType::I32], vec![ValType::I64]), + ); + assert_eq!( + signature( + &module, + imported_function(&module, WPK_FORK_MODULE_STATE_IMPORT_RECORD_COMMIT), + ), + (vec![ValType::I32], vec![]), + ); + assert_eq!( + signature( + &module, + imported_function(&module, WPK_FORK_MODULE_STATE_IMPORT_RECORD_FIND), + ), + ( + vec![ValType::I32, ValType::I32, ValType::I32, ValType::I32], + vec![ValType::I32], + ), + ); + assert_eq!( + signature( + &module, + imported_function( + &module, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_MUTATION_BEGIN, + ), + ), + (vec![], vec![ValType::I64]), + ); + assert_eq!( + signature( + &module, + imported_function( + &module, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_MUTATION_COMMIT, + ), + ), + ( + vec![ValType::I32, ValType::I64, ValType::I64], + vec![], + ), + ); + assert_eq!( + signature( + &module, + imported_function( + &module, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_MUTATION_ABORT, + ), + ), + (vec![], vec![]), + ); + assert_eq!( + signature( + &module, + imported_function(&module, WPK_FORK_MODULE_STATE_IMPORT_TABLE_RECONCILE), + ), + (vec![], vec![ValType::I64]), + ); +} + +#[test] +fn module_state_record_pointers_follow_memory64() { + let bytes = instrument_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (memory i64 1) + (global $root (mut externref) (ref.null extern)) + (func $caller (result i32) call $fork)) + "#, + ); + validate(&bytes); + let module = Module::from_buffer(&bytes).expect("parse instrumented memory64 module"); + assert_eq!( + signature( + &module, + imported_function(&module, WPK_FORK_MODULE_STATE_IMPORT_RECORD_RESERVE), + ), + ( + vec![ValType::I32, ValType::I32, ValType::I32, ValType::I64], + vec![ValType::I64], + ), + ); + assert_eq!( + signature( + &module, + imported_function(&module, WPK_FORK_MODULE_STATE_IMPORT_RECORD_COMMIT), + ), + (vec![ValType::I64], vec![]), + ); + assert_eq!( + signature( + &module, + imported_function(&module, WPK_FORK_MODULE_STATE_IMPORT_RECORD_FIND), + ), + ( + vec![ValType::I32, ValType::I32, ValType::I32, ValType::I32], + vec![ValType::I64], + ), + ); +} + +#[test] +fn save_and_restore_own_reference_globals_and_dirty_table_state() { + let bytes = instrument_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (import "env" "shared_root" (global $shared_root (mut externref))) + (memory 1) + (global $callback (mut funcref) (ref.null func)) + (global $exception (mut exnref) (ref.null exn)) + (global $object (mut anyref) (ref.null any)) + (table $callbacks 2 8 funcref) + (table $exceptions 1 8 exnref) + (table $objects 1 8 anyref) + (func $mutate_tables + (param $callback funcref) + (param $exception exnref) + (param $object anyref) + i32.const 0 + local.get $callback + table.set $callbacks + i32.const 0 + local.get $exception + table.set $exceptions + i32.const 0 + local.get $object + table.set $objects) + (func $caller (result i32) call $fork)) + "#, + ); + validate(&bytes); + let module = Module::from_buffer(&bytes).expect("parse instrumented module"); + let save = local( + &module, + export_function(&module, WPK_FORK_EXPORT_MODULE_STATE_SAVE), + ); + let restore = local( + &module, + export_function(&module, WPK_FORK_EXPORT_MODULE_STATE_RESTORE), + ); + let finish_restore = local( + &module, + export_function(&module, WPK_FORK_EXPORT_MODULE_STATE_FINISH_RESTORE), + ); + let encode_funcref = codec_function(&module, names::IMPORT_REF_ENCODE_FUNCREF); + let encode_externref = codec_function(&module, names::IMPORT_REF_ENCODE_EXTERNREF); + let encode_exnref = codec_function(&module, names::IMPORT_REF_ENCODE_EXNREF); + let encode_anyref = codec_function(&module, names::IMPORT_REF_ENCODE_ANYREF); + let decode_funcref = codec_function(&module, names::IMPORT_REF_DECODE_FUNCREF); + let decode_externref = codec_function(&module, names::IMPORT_REF_DECODE_EXTERNREF); + let decode_exnref = codec_function(&module, names::IMPORT_REF_DECODE_EXNREF); + let decode_anyref = codec_function(&module, names::IMPORT_REF_DECODE_ANYREF); + + let mut save_has_table_size = false; + let mut save_has_table_get = false; + let mut save_codecs = Vec::new(); + walk(save, save.entry_block(), &mut |instr| match instr { + Instr::TableSize(_) => save_has_table_size = true, + Instr::TableGet(_) => save_has_table_get = true, + Instr::Call(call) => save_codecs.push(call.func), + _ => {} + }); + assert!(save_has_table_size && save_has_table_get); + assert!(save_codecs.contains(&encode_funcref)); + assert!(save_codecs.contains(&encode_externref)); + assert!(save_codecs.contains(&encode_exnref)); + assert!(save_codecs.contains(&encode_anyref)); + + let mut restore_has_global_set = false; + let mut restore_has_table_grow = false; + let mut restore_has_table_set = false; + let mut restore_codecs = Vec::new(); + walk( + finish_restore, + finish_restore.entry_block(), + &mut |instr| match instr { + Instr::GlobalSet(_) => restore_has_global_set = true, + Instr::TableGrow(_) => restore_has_table_grow = true, + Instr::TableSet(_) => restore_has_table_set = true, + Instr::Call(call) => restore_codecs.push(call.func), + _ => {} + }, + ); + walk(restore, restore.entry_block(), &mut |instr| match instr { + Instr::GlobalSet(_) => restore_has_global_set = true, + Instr::Call(call) => restore_codecs.push(call.func), + _ => {} + }); + assert!(restore_has_global_set); + assert!(restore_has_table_grow && restore_has_table_set); + assert!(restore_codecs.contains(&decode_funcref)); + assert!(restore_codecs.contains(&decode_externref)); + assert!(restore_codecs.contains(&decode_exnref)); + assert!(restore_codecs.contains(&decode_anyref)); +} + +#[test] +fn save_and_restore_own_every_scalar_global_type() { + let bytes = instrument_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (memory 1) + (global $i32 (mut i32) (i32.const 1)) + (global $i64 (mut i64) (i64.const 2)) + (global $f32 (mut f32) (f32.const 3)) + (global $f64 (mut f64) (f64.const 4)) + (global $v128 (mut v128) (v128.const i32x4 5 6 7 8)) + (func $caller (result i32) call $fork)) + "#, + ); + validate(&bytes); + let module = Module::from_buffer(&bytes).expect("parse instrumented module"); + let save = local( + &module, + export_function(&module, WPK_FORK_EXPORT_MODULE_STATE_SAVE), + ); + let restore = local( + &module, + export_function(&module, WPK_FORK_EXPORT_MODULE_STATE_RESTORE), + ); + + let mut stores = [false; 5]; + walk(save, save.entry_block(), &mut |instr| { + let Instr::Store(store) = instr else { return }; + match store.kind { + ir::StoreKind::I32 { .. } => stores[0] = true, + ir::StoreKind::I64 { .. } => stores[1] = true, + ir::StoreKind::F32 => stores[2] = true, + ir::StoreKind::F64 => stores[3] = true, + ir::StoreKind::V128 => stores[4] = true, + _ => {} + } + }); + assert!(stores.into_iter().all(|seen| seen)); + + let mut loads = [false; 5]; + walk(restore, restore.entry_block(), &mut |instr| { + let Instr::Load(load) = instr else { return }; + match load.kind { + ir::LoadKind::I32 { .. } => loads[0] = true, + ir::LoadKind::I64 { .. } => loads[1] = true, + ir::LoadKind::F32 => loads[2] = true, + ir::LoadKind::F64 => loads[3] = true, + ir::LoadKind::V128 => loads[4] = true, + _ => {} + } + }); + assert!(loads.into_iter().all(|seen| seen)); +} + +#[test] +fn immutable_imports_keep_their_original_binding_and_preinstantiation_recipe() { + let bytes = instrument_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (import "env" "immutable_callback" (global $callback funcref)) + (memory 1) + (export "immutable_callback_global" (global $callback)) + (global $callback_alias funcref (global.get $callback)) + (export "immutable_callback_alias" (global $callback_alias)) + (func $read (export "read") (result funcref) + global.get $callback) + (func $caller (result i32) call $fork)) + "#, + ); + validate(&bytes); + let module = Module::from_buffer(&bytes).expect("parse instrumented module"); + let imported = module + .imports + .iter() + .find_map(|import| { + if import.module != "env" || import.name != "immutable_callback" { + return None; + } + match import.kind { + ImportKind::Global(global) => Some(global), + _ => None, + } + }) + .expect("immutable imported global"); + let read = local(&module, export_function(&module, "read")); + let mut observed = Vec::new(); + walk(read, read.entry_block(), &mut |instr| { + if let Instr::GlobalGet(get) = instr { + observed.push(get.global); + } + }); + assert_eq!(observed.len(), 1); + assert_eq!(observed[0], imported); + assert!(matches!( + module + .exports + .iter() + .find(|export| export.name == "immutable_callback_global") + .map(|export| export.item), + Some(ExportItem::Global(global)) if global == imported + )); + let alias = module + .exports + .iter() + .find_map(|export| (export.name == "immutable_callback_alias").then_some(export.item)) + .expect("immutable alias export"); + let ExportItem::Global(alias) = alias else { + panic!("immutable alias export is not a global") + }; + assert!(matches!( + module.globals.get(alias).kind, + walrus::GlobalKind::Local(walrus::ConstExpr::Global(source)) if source == imported + )); + let catalog_globals: Vec<_> = module + .exports + .iter() + .filter_map(|export| { + if !export + .name + .starts_with(WPK_FORK_GLOBAL_CATALOG_EXPORT_PREFIX) + { + return None; + } + match export.item { + ExportItem::Global(global) => Some(global), + _ => panic!("private global catalog entry is not a global"), + } + }) + .collect(); + assert_eq!(catalog_globals.len(), 2); + assert!( + catalog_globals.contains(&imported), + "the imported provider cell needs an exact private Global wrapper" + ); + assert!( + catalog_globals.contains(&alias), + "local provider cells need the same deterministic catalog" + ); + + let save = local( + &module, + export_function(&module, WPK_FORK_EXPORT_MODULE_STATE_SAVE), + ); + let encode_funcref = imported_function(&module, names::IMPORT_REF_ENCODE_FUNCREF); + let mut saves_import = false; + walk(save, save.entry_block(), &mut |instr| { + if matches!(instr, Instr::Call(call) if call.func == encode_funcref) { + saves_import = true; + } + }); + assert!(saves_import); + + let restore = local( + &module, + export_function(&module, WPK_FORK_EXPORT_MODULE_STATE_RESTORE), + ); + let mut assigns_import = false; + walk(restore, restore.entry_block(), &mut |instr| { + if matches!(instr, Instr::GlobalSet(set) if set.global == imported) { + assigns_import = true; + } + }); + assert!(!assigns_import); + + let descriptor = custom_section(&bytes, WPK_FORK_IMPORTED_GLOBALS_SECTION); + assert!(descriptor.len() >= usize::from(WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE)); + assert_eq!(&descriptor[..4], &WPK_FORK_IMPORTED_GLOBALS_MAGIC); + let record = usize::from(WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE); + assert_eq!(u32::from_le_bytes(descriptor[8..12].try_into().unwrap()), 1); + assert_eq!( + u32::from_le_bytes(descriptor[record..record + 4].try_into().unwrap()) as usize, + usize::from(WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE) + + "env".len() + + "immutable_callback".len(), + ); + assert_eq!( + u32::from_le_bytes(descriptor[record + 20..record + 24].try_into().unwrap()), + 1, + "KFIG must name the full import-section ordinal, including the preceding function", + ); +} + +#[test] +fn imported_global_recipe_preserves_full_wasm_name_lengths() { + let field = "x".repeat(70_000); + let bytes = instrument_wat(&format!( + r#" + (module + (@custom "dylink.0" (before first) "state-only") + (import "env" "{field}" (global $value i32)) + (memory 1) + (func (export "read") (result i32) global.get $value)) + "#, + )); + validate(&bytes); + let descriptor = custom_section(&bytes, WPK_FORK_IMPORTED_GLOBALS_SECTION); + let record = usize::from(WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE); + assert_eq!( + u32::from_le_bytes(descriptor[record + 12..record + 16].try_into().unwrap()), + 3, + ); + assert_eq!( + u32::from_le_bytes(descriptor[record + 16..record + 20].try_into().unwrap()), + 70_000, + ); +} + +#[test] +fn imported_table_identity_has_exact_preinstantiation_recipe_and_catalog() { + let bytes = instrument_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (import "env" "callbacks" (table $callbacks 2 8 funcref)) + (memory 1) + (func $caller (result i32) call $fork)) + "#, + ); + validate(&bytes); + let module = Module::from_buffer(&bytes).expect("parse instrumented module"); + let imported = module + .imports + .iter() + .find_map(|import| { + if import.module != "env" || import.name != "callbacks" { + return None; + } + match import.kind { + ImportKind::Table(table) => Some(table), + _ => None, + } + }) + .expect("imported table"); + assert!(module.exports.iter().any(|export| { + export.name == format!("{WPK_FORK_TABLE_CATALOG_EXPORT_PREFIX}1") + && matches!(export.item, ExportItem::Table(table) if table == imported) + })); + + let descriptor = custom_section(&bytes, WPK_FORK_IMPORTED_TABLES_SECTION); + assert_eq!(&descriptor[..4], &WPK_FORK_IMPORTED_TABLES_MAGIC); + assert_eq!(u32::from_le_bytes(descriptor[8..12].try_into().unwrap()), 1); + let record = usize::from(WPK_FORK_IMPORTED_TABLES_HEADER_SIZE); + assert_eq!( + u32::from_le_bytes(descriptor[record..record + 4].try_into().unwrap()) as usize, + usize::from(WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE) + "env".len() + "callbacks".len(), + ); + assert_eq!( + u32::from_le_bytes(descriptor[record + 20..record + 24].try_into().unwrap()), + 1, + "KFIT must name the full import-section ordinal", + ); +} + +#[test] +fn active_segment_offsets_preserve_extended_const_semantics_without_a_shape_gate() { + let bytes = instrument_wat( + r#" + (module + (@custom "dylink.0" (before first) "state-only") + (import "env" "base" (global $base i32)) + (memory 1) + (table 8 funcref) + (func $target) + (elem (i32.add (global.get $base) (i32.const 1)) $target) + (data (i32.mul (global.get $base) (i32.const 2)) "x")) + "#, + ); + validate(&bytes); + let module = Module::from_buffer(&bytes).expect("parse extended-const module"); + let preserved_offsets = module + .globals + .iter() + .filter(|global| { + matches!( + global.kind, + walrus::GlobalKind::Local(walrus::ConstExpr::Extended(_)) + ) + }) + .count(); + assert_eq!( + preserved_offsets, 2, + "each converted active segment must retain its original const expression", + ); + + for helper in [ + WPK_FORK_EXPORT_MODULE_BOOTSTRAP, + WPK_FORK_EXPORT_MODULE_THREAD_BOOTSTRAP, + WPK_FORK_EXPORT_MODULE_STATE_RESTORE, + ] { + let helper = local(&module, export_function(&module, helper)); + let mut reads_preserved_offset = false; + walk(helper, helper.entry_block(), &mut |instr| { + if let Instr::GlobalGet(get) = instr + && matches!( + module.globals.get(get.global).kind, + walrus::GlobalKind::Local(walrus::ConstExpr::Extended(_)) + ) + { + reads_preserved_offset = true; + } + }); + assert!( + reads_preserved_offset, + "segment helper must consume the naturally evaluated offset global", + ); + } +} + +#[test] +fn segment_lifetime_is_activation_owned_and_reapplied() { + let bytes = instrument_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (memory 1) + (table 1 funcref) + (func $target) + (elem $functions funcref (ref.func $target)) + (data $bytes "payload") + (func $caller (result i32) + elem.drop $functions + data.drop $bytes + call $fork)) + "#, + ); + validate(&bytes); + let module = Module::from_buffer(&bytes).expect("parse instrumented module"); + let restore = local( + &module, + export_function(&module, WPK_FORK_EXPORT_MODULE_STATE_RESTORE), + ); + let finish_restore = local( + &module, + export_function(&module, WPK_FORK_EXPORT_MODULE_STATE_FINISH_RESTORE), + ); + let mut elem_drop = false; + let mut data_drop = false; + walk(restore, restore.entry_block(), &mut |instr| match instr { + Instr::ElemDrop(_) => elem_drop = true, + Instr::DataDrop(_) => data_drop = true, + _ => {} + }); + assert!( + !elem_drop && !data_drop, + "value/table restore must leave constructor segments live" + ); + walk( + finish_restore, + finish_restore.entry_block(), + &mut |instr| match instr { + Instr::ElemDrop(_) => elem_drop = true, + Instr::DataDrop(_) => data_drop = true, + _ => {} + }, + ); + assert!( + elem_drop, + "finish restore must reapply element-segment lifetime" + ); + assert!( + data_drop, + "finish restore must reapply data-segment lifetime" + ); + + let caller = module + .funcs + .iter() + .find(|func| func.name.as_deref() == Some("caller")) + .expect("named caller"); + let caller = local(&module, caller.id()); + let mut caller_drops = 0; + let mut tracker_updates = 0; + walk(caller, caller.entry_block(), &mut |instr| match instr { + Instr::ElemDrop(_) | Instr::DataDrop(_) => caller_drops += 1, + Instr::GlobalSet(_) => tracker_updates += 1, + _ => {} + }); + assert_eq!(caller_drops, 2); + assert!( + tracker_updates >= 2, + "each original segment drop must update its activation-owned bitmap", + ); +} + +#[test] +fn modules_outside_the_active_fork_stack_still_expose_reconstructible_state() { + let bytes = instrument_wat( + r#" + (module + (@custom "dylink.0" (before first) "state-only") + (global $counter (mut i64) (i64.const 7)) + (global $root (mut funcref) (ref.null func)) + (table 1 funcref)) + "#, + ); + validate(&bytes); + let module = Module::from_buffer(&bytes).expect("parse no-seed instrumented module"); + assert_helper_signature(&module, WPK_FORK_EXPORT_MODULE_STATE_SAVE); + assert_helper_signature(&module, WPK_FORK_EXPORT_MODULE_STATE_RESTORE); + assert_helper_signature(&module, WPK_FORK_EXPORT_MODULE_STATE_FINISH_RESTORE); + assert!( + module + .exports + .iter() + .any(|export| export.name == FUNCTION_CATALOG_EXPORT), + "every module activation needs a deterministic funcref catalog", + ); + for reserved in [ + WPK_FORK_MODULE_STATE_IMPORT_RECORD_RESERVE, + WPK_FORK_MODULE_STATE_IMPORT_RECORD_COMMIT, + WPK_FORK_MODULE_STATE_IMPORT_RECORD_FIND, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_COUNT, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_MARK, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_PAGE, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_STATE_OWNED, + names::IMPORT_REF_ENCODE_FUNCREF, + names::IMPORT_REF_DECODE_FUNCREF, + ] { + assert!( + module.imports.iter().any(|import| import.name == reserved), + "no-seed state helper is missing {reserved}", + ); + } + assert!( + module.imports.iter().any(|import| { + import.module == "env" + && import.name == "memory" + && matches!(import.kind, ImportKind::Memory(_)) + }), + "a no-memory module must stage KFMS records through env.memory", + ); +} + +#[test] +fn node_fresh_instance_restores_no_seed_module_state_and_segment_lifetime() { + let module = instrument_wat( + r#" + (module + (@custom "dylink.0" (before first) "state-only") + (import "env" "memory" (memory 0 65536 shared)) + (import "env" "shared_counter" (global $shared_counter (mut i64))) + (import "env" "shared_callbacks" (table $shared_callbacks 2 8 funcref)) + (import "env" "imported_callback" (func $imported_callback (result i32))) + (import "env" "immutable_callback" (global $immutable_callback funcref)) + (import "env" "immutable_token" (global $immutable_token externref)) + (export "immutable_callback_global" (global $immutable_callback)) + (export "immutable_token_global" (global $immutable_token)) + (global $immutable_callback_alias funcref (global.get $immutable_callback)) + (global $immutable_token_alias externref (global.get $immutable_token)) + (export "immutable_callback_alias" (global $immutable_callback_alias)) + (export "immutable_token_alias" (global $immutable_token_alias)) + + (global $counter (mut i32) (i32.const 0)) + (global $f32_bits (mut f32) (f32.const 0)) + (global $f64_bits (mut f64) (f64.const 0)) + (global $vector (mut v128) (v128.const i32x4 0 0 0 0)) + (global $callback (mut funcref) (ref.null func)) + (global $token (mut externref) (ref.null extern)) + (global $start_count (mut i32) (i32.const 0)) + + (table $callbacks (export "callbacks") 3 10 funcref) + (table $tokens (export "tokens") 2 10 externref) + (func $a (result i32) i32.const 11) + (func $b (result i32) i32.const 22) + (elem $baseline (table $callbacks) (i32.const 0) func $a $b $a) + (elem $late func $b $a) + (data $active_data (i32.const 16) "\31\32\33") + (data $late_data "xyz") + + (func $module_start + global.get $start_count + i32.const 1 + i32.add + global.set $start_count + i32.const 16 + i32.const 0x44 + i32.store8) + (start $module_start) + + (func (export "mutate") (param $owned externref) + i32.const 0x11223344 + global.set $counter + i64.const 0x1122334455667788 + global.set $shared_counter + i32.const 0x7fc12345 + f32.reinterpret_i32 + global.set $f32_bits + i64.const 0x7ff8123456789abc + f64.reinterpret_i64 + global.set $f64_bits + v128.const i32x4 101 202 303 404 + global.set $vector + ref.func $b + global.set $callback + local.get $owned + global.set $token + i32.const 16 + i32.const 0x7a + i32.store8 + + ref.func $b + i32.const 2 + table.grow $callbacks + drop + i32.const 0 + ref.func $b + table.set $callbacks + i32.const 1 + ref.func $a + i32.const 2 + table.fill $callbacks + i32.const 3 + i32.const 0 + i32.const 2 + table.copy $callbacks $callbacks + i32.const 1 + i32.const 0 + i32.const 2 + table.init $callbacks $late + elem.drop $late + + local.get $owned + i32.const 2 + table.grow $tokens + drop + i32.const 0 + local.get $owned + i32.const 4 + table.fill $tokens + i32.const 1 + i32.const 0 + i32.const 3 + table.copy $tokens $tokens + + ref.func $a + i32.const 1 + table.grow $shared_callbacks + drop + i32.const 0 + ref.func $b + i32.const 3 + table.fill $shared_callbacks + data.drop $late_data) + + (func (export "counter") (result i32) global.get $counter) + (func (export "shared_counter") (result i64) global.get $shared_counter) + (func (export "f32_bits") (result i32) + global.get $f32_bits + i32.reinterpret_f32) + (func (export "f64_bits") (result i64) + global.get $f64_bits + i64.reinterpret_f64) + (func (export "vector_lane_2") (result i32) + global.get $vector + i32x4.extract_lane 2) + (func (export "callback") (result funcref) global.get $callback) + (func (export "token") (result externref) global.get $token) + (func (export "start_count") (result i32) global.get $start_count) + (func (export "active_data_byte") (result i32) + i32.const 16 + i32.load8_u) + (func (export "immutable_callback") (result funcref) + global.get $immutable_callback) + (func (export "immutable_token") (result externref) + global.get $immutable_token) + (func (export "shared_callback") (param $index i32) (result funcref) + local.get $index + table.get $shared_callbacks) + (func (export "try_late_elem") + i32.const 0 + i32.const 0 + i32.const 1 + table.init $callbacks $late) + (func (export "try_late_data") + i32.const 0 + i32.const 0 + i32.const 1 + memory.init $late_data) + (func (export "try_active_elem") + i32.const 0 + i32.const 0 + i32.const 1 + table.init $callbacks $baseline) + (func (export "try_active_data") + i32.const 0 + i32.const 0 + i32.const 1 + memory.init $active_data)) + "#, + ); + let typed_codecs = wat::parse_str( + r#" + (module + (func (export "callback") (result i32) i32.const 31) + (func (export "__wpk_fork_ref_encode_exnref") + (param (ref null exn)) (result i32) i32.const 0) + (func (export "__wpk_fork_ref_decode_exnref") + (param i32) (result (ref null exn)) ref.null exn) + (func (export "__wpk_fork_ref_encode_anyref") + (param (ref null any)) (result i32) i32.const 0) + (func (export "__wpk_fork_ref_decode_anyref") + (param i32) (result (ref null any)) ref.null any)) + "#, + ) + .expect("compile typed reference codec fixture"); + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "kandelo-module-state-{}-{nonce}", + std::process::id(), + )); + fs::create_dir(&directory).expect("create module-state engine-test directory"); + let module_path = directory.join("module.wasm"); + let codecs_path = directory.join("typed-codecs.wasm"); + fs::write(&module_path, module).expect("write instrumented module fixture"); + fs::write(&codecs_path, typed_codecs).expect("write typed codec fixture"); + + let script = r#" + const fs = require("node:fs"); + const [modulePath, codecsPath] = process.argv.slice(1); + const module = new WebAssembly.Module(fs.readFileSync(modulePath)); + const codecModule = new WebAssembly.Module(fs.readFileSync(codecsPath)); + const typed = new WebAssembly.Instance(codecModule).exports; + const nodes = [{ kind: "null" }]; + const objectIds = new WeakMap(); + const records = []; + let parent; + let child; + let thread; + + function intern(value, node) { + if (value === null) return 0; + const known = objectIds.get(value); + if (known !== undefined) return known; + const id = nodes.length; + nodes.push(node()); + objectIds.set(value, id); + return id; + } + function functionOrdinal(instance, value) { + const catalog = instance.exports.__wpk_fork_function_catalog; + for (let ordinal = 0; ordinal < catalog.length; ordinal++) { + if (catalog.get(ordinal) === value) return ordinal; + } + throw new Error("funcref absent from function catalog"); + } + function encodeFuncref(value) { + return intern(value, () => ({ + kind: "funcref", + ordinal: functionOrdinal(parent, value), + })); + } + function encodeExternref(value) { + return intern(value, () => { + if (typeof value !== "object" || value === null || !Number.isInteger(value.handle)) { + throw new Error("externref bypassed the test process owner"); + } + return { kind: "externref", handle: value.handle }; + }); + } + const childExternrefs = new Map(); + function decodeFuncref(id) { + if (id === 0) return null; + const node = nodes[id]; + if (node?.kind !== "funcref") throw new Error(`recipe ${id} is not funcref`); + return child.exports.__wpk_fork_function_catalog.get(node.ordinal); + } + function decodeExternref(id) { + if (id === 0) return null; + const node = nodes[id]; + if (node?.kind !== "externref") throw new Error(`recipe ${id} is not externref`); + let token = childExternrefs.get(node.handle); + if (!token) { + token = Object.freeze({ handle: node.handle, child: true }); + childExternrefs.set(node.handle, token); + } + return token; + } + + function instantiate(mode, memory, sharedCounter, sharedCallbacks, imports) { + let cursor = 65536; + let pending = null; + const dirtyPages = new Map(); + let nextReferenceVector = 1; + const referenceVectors = new Map(); + // The real process owner sizes this typed transit table for the + // reference recipe transaction. Keep the engine fixture large enough + // for every scalar/global/table recipe it intentionally captures. + const gcTransit = new WebAssembly.Table({ + element: "anyref", initial: 1024, + }); + const beginReferenceVector = (capacity) => { + const id = nextReferenceVector++; + referenceVectors.set(id, { capacity, values: [] }); + return id; + }; + const appendReferenceVector = (id, value) => { + const vector = referenceVectors.get(id); + if (!vector || vector.values.length >= vector.capacity) { + throw new Error(`invalid reference vector append ${id}`); + } + vector.values.push(value); + }; + const finishReferenceVector = (id) => { + const vector = referenceVectors.get(id); + if (!vector || vector.values.length !== vector.capacity) { + throw new Error(`invalid reference vector finish ${id}`); + } + return id; + }; + const getReferenceVector = (id, index) => { + const vector = referenceVectors.get(id); + if (!vector || index < 0 || index >= vector.values.length) { + throw new Error(`invalid reference vector lookup ${id}:${index}`); + } + return vector.values[index]; + }; + const dirtyMark = (owner, firstValue, countValue) => { + const first = BigInt.asUintN(64, firstValue); + const count = BigInt.asUintN(64, countValue); + let pages = dirtyPages.get(owner); + if (!pages) { + pages = new Set(); + dirtyPages.set(owner, pages); + } + for (let offset = 0n; offset < count; offset++) { + pages.add(first + offset); + } + }; + const sortedDirtyPages = (owner) => + [...(dirtyPages.get(owner) ?? [])].sort((a, b) => a < b ? -1 : a > b ? 1 : 0); + const allocate = (size) => { + const pointer = (cursor + 7) & ~7; + cursor = pointer + size; + if (cursor > memory.buffer.byteLength) { + memory.grow(Math.ceil((cursor - memory.buffer.byteLength) / 65536)); + } + return pointer; + }; + const reserve = (kind, activation, owner, size) => { + if (pending) throw new Error("nested record reservation"); + const pointer = allocate(size); + pending = { kind, activation, owner, size, pointer }; + return pointer; + }; + const commit = (pointer) => { + if (!pending || pending.pointer !== pointer) throw new Error("bad record commit"); + records.push({ + kind: pending.kind, + activation: pending.activation, + owner: pending.owner, + payload: new Uint8Array(memory.buffer, pointer, pending.size).slice(), + }); + pending = null; + }; + const find = (kind, activation, owner, ordinal) => { + const matches = records.filter((record) => + record.kind === kind + && record.activation === activation + && record.owner === owner + ); + const record = matches[ordinal]; + if (!record) throw new Error(`missing record ${kind}:${activation}:${owner}:${ordinal}`); + const pointer = allocate(record.payload.length); + new Uint8Array(memory.buffer, pointer, record.payload.length).set(record.payload); + return pointer; + }; + const unreachableFrame = () => { + throw new Error("no-seed module unexpectedly used a continuation-frame hook"); + }; + const env = { + memory, + shared_counter: sharedCounter, + shared_callbacks: sharedCallbacks, + imported_callback: imports.importedCallback, + immutable_callback: imports.importedCallback, + immutable_token: imports.immutableToken, + __wpk_fork_module_activation: new WebAssembly.Global( + { value: "i32", mutable: false }, + 0, + ), + __wpk_fork_unwind: new WebAssembly.Tag({ parameters: [] }), + __wpk_fork_frame_reserve: unreachableFrame, + __wpk_fork_frame_commit: unreachableFrame, + __wpk_fork_frame_next: unreachableFrame, + __wpk_fork_frame_peek: unreachableFrame, + __wpk_fork_resume_peek: () => 0, + __wpk_fork_resume_table: new WebAssembly.Table({ + element: "anyfunc", initial: 1, + }), + __wpk_fork_ref_gc_transit: gcTransit, + __wpk_fork_module_state_record_reserve: + mode === "capture" ? reserve : unreachableFrame, + __wpk_fork_module_state_record_commit: + mode === "capture" ? commit : unreachableFrame, + __wpk_fork_module_state_record_find: + mode === "restore" ? find : unreachableFrame, + __wpk_fork_module_state_table_dirty_mark: dirtyMark, + __wpk_fork_module_state_table_dirty_count: + (owner) => sortedDirtyPages(owner).length, + __wpk_fork_module_state_table_dirty_page: + (owner, ordinal) => BigInt.asIntN(64, sortedDirtyPages(owner)[ordinal]), + __wpk_fork_module_state_table_state_owned: () => 1, + __wpk_fork_module_state_table_generation_addr: new WebAssembly.Global( + { value: "i64", mutable: false }, + 0n, + ), + __wpk_fork_module_state_table_reconcile: () => 0n, + __wpk_fork_module_state_table_mutation_begin: () => 0n, + __wpk_fork_module_state_table_mutation_commit: () => {}, + __wpk_fork_module_state_table_mutation_abort: () => {}, + __wpk_fork_ref_encode_funcref: encodeFuncref, + __wpk_fork_ref_decode_funcref: decodeFuncref, + __wpk_fork_ref_encode_externref: encodeExternref, + __wpk_fork_ref_decode_externref: decodeExternref, + __wpk_fork_ref_encode_exnref: typed.__wpk_fork_ref_encode_exnref, + __wpk_fork_ref_decode_exnref: typed.__wpk_fork_ref_decode_exnref, + __wpk_fork_ref_encode_anyref: typed.__wpk_fork_ref_encode_anyref, + __wpk_fork_ref_decode_anyref: typed.__wpk_fork_ref_decode_anyref, + __wpk_fork_ref_vector_begin: beginReferenceVector, + __wpk_fork_ref_vector_append: appendReferenceVector, + __wpk_fork_ref_vector_finish: finishReferenceVector, + __wpk_fork_ref_vector_get: getReferenceVector, + __wpk_fork_ref_exn_lookup: () => 0, + __wpk_fork_ref_exn_claim: () => 0, + __wpk_fork_ref_exn_define: () => {}, + __wpk_fork_ref_exn_load: () => 0, + __wpk_fork_ref_exn_route: () => 0, + __wpk_fork_ref_exn_cache_index: () => 1, + __wpk_fork_ref_exn_broker_encode: () => 0, + __wpk_fork_ref_exn_broker_throw_recipe: () => { + throw new Error("unused exception recipe route"); + }, + __wpk_fork_ref_exn_ingress_throw: () => { + throw new Error("unused exception ingress route"); + }, + __wpk_fork_ref_gc_lookup: () => 0, + __wpk_fork_ref_gc_claim: () => 0, + __wpk_fork_ref_gc_i31: () => 0, + __wpk_fork_ref_gc_define: () => {}, + __wpk_fork_ref_gc_route: () => 0, + __wpk_fork_ref_gc_payload_len: () => 0, + __wpk_fork_ref_gc_load: () => 0, + __wpk_fork_ref_gc_broker_encode: + (slot) => encodeExternref(gcTransit.get(slot)), + __wpk_fork_ref_gc_capture_layout: () => 0, + __wpk_fork_ref_gc_provenance_begin: () => 0, + __wpk_fork_ref_gc_provenance_ref: () => {}, + __wpk_fork_ref_gc_provenance_end: () => {}, + __wpk_fork_ref_scratch_reserve: (size) => allocate(Number(size)), + __wpk_fork_ref_scratch_release: () => {}, + }; + return new WebAssembly.Instance(module, { env }); + } + + const parentMemory = new WebAssembly.Memory({ + initial: 4, maximum: 65536, shared: true, + }); + const parentSharedCounter = new WebAssembly.Global( + { value: "i64", mutable: true }, 0n, + ); + const parentSharedCallbacks = new WebAssembly.Table({ + element: "anyfunc", initial: 2, maximum: 8, + }); + const parentImportedCallback = + new WebAssembly.Instance(codecModule).exports.callback; + const parentImmutableToken = Object.freeze({ handle: 88, parent: true }); + parent = instantiate( + "capture", + parentMemory, + parentSharedCounter, + parentSharedCallbacks, + { + importedCallback: parentImportedCallback, + immutableToken: parentImmutableToken, + }, + ); + if (parent.exports.start_count() !== 0) { + throw new Error("original start ran during raw instantiation"); + } + parent.exports.wpk_fork_module_bootstrap(); + if (parent.exports.start_count() !== 1 || parent.exports.active_data_byte() !== 0x44) { + throw new Error("parent bootstrap did not preserve segment/start ordering"); + } + const owned = Object.freeze({ handle: 77, parent: true }); + parent.exports.mutate(owned); + if (parent.exports.token() !== owned) { + throw new Error("parent mutable externref global was not assigned"); + } + parent.exports.wpk_fork_module_state_save(7); + if (records.length === 0) throw new Error("module state emitted no records"); + + thread = instantiate( + "thread", + parentMemory, + parentSharedCounter, + parentSharedCallbacks, + { + importedCallback: parentImportedCallback, + immutableToken: parentImmutableToken, + }, + ); + thread.exports.wpk_fork_module_thread_bootstrap(); + if (thread.exports.start_count() !== 0) { + throw new Error("pthread bootstrap reran original start"); + } + if (thread.exports.active_data_byte() !== 0x7a) { + throw new Error("pthread bootstrap overwrote shared linear memory"); + } + [11, 22, 11].forEach((value, index) => { + if (thread.exports.callbacks.get(index)() !== value) { + throw new Error(`pthread table baseline entry ${index} is missing`); + } + }); + for (const name of ["try_active_elem", "try_active_data"]) { + let trapped = false; + try { + thread.exports[name](); + } catch (error) { + trapped = error instanceof WebAssembly.RuntimeError; + } + if (!trapped) throw new Error(`${name} stayed live after pthread bootstrap`); + } + + const childMemory = new WebAssembly.Memory({ + initial: parentMemory.buffer.byteLength / 65536, + maximum: 65536, + shared: true, + }); + new Uint8Array(childMemory.buffer).set(new Uint8Array(parentMemory.buffer)); + const childSharedCounter = new WebAssembly.Global( + { value: "i64", mutable: true }, 0n, + ); + const childSharedCallbacks = new WebAssembly.Table({ + element: "anyfunc", initial: 2, maximum: 8, + }); + const childImportedCallback = + new WebAssembly.Instance(codecModule).exports.callback; + const childImmutableToken = Object.freeze({ handle: 88, child: true }); + childExternrefs.set(88, childImmutableToken); + child = instantiate( + "restore", + childMemory, + childSharedCounter, + childSharedCallbacks, + { + importedCallback: childImportedCallback, + immutableToken: childImmutableToken, + }, + ); + nodes.forEach((node, recipeId) => { + if (node.kind === "externref") { + child.exports.__wpk_fork_ref_gc_publish_externref( + recipeId, + decodeExternref(recipeId), + ); + } + }); + child.exports.wpk_fork_module_state_restore(7); + for (const name of [ + "try_late_elem", + "try_late_data", + "try_active_elem", + "try_active_data", + ]) { + try { + child.exports[name](); + } catch (error) { + throw new Error(`${name} was dropped before reference reconstruction`, { + cause: error, + }); + } + } + // Reapply the exact table/memory-owned state after probes, then cross + // the global segment-lifetime boundary. Both phases are idempotent. + child.exports.wpk_fork_module_state_restore(7); + child.exports.wpk_fork_module_state_finish_restore(7); + child.exports.wpk_fork_module_state_finish_restore(7); + + if (child.exports.start_count() !== 1) throw new Error("child reran original start"); + if (child.exports.active_data_byte() !== 0x7a) { + throw new Error("child reran active data initialization over copied memory"); + } + if (child.exports.counter() !== 0x11223344) throw new Error("i32 global reset"); + if (child.exports.shared_counter() !== 0x1122334455667788n) { + throw new Error("imported mutable scalar global reset"); + } + if ((child.exports.f32_bits() >>> 0) !== 0x7fc12345) { + throw new Error("f32 payload bits changed"); + } + if (child.exports.f64_bits() !== 0x7ff8123456789abcn) { + throw new Error("f64 payload bits changed"); + } + if (child.exports.vector_lane_2() !== 303) throw new Error("v128 global reset"); + if (child.exports.callback()() !== 22) throw new Error("funcref global reset"); + if (child.exports.immutable_callback() !== childImportedCallback) { + throw new Error("immutable imported funcref retained parent identity"); + } + if (child.exports.immutable_callback_global.value !== childImportedCallback) { + throw new Error("exported immutable funcref was not materialized before instantiation"); + } + if (child.exports.immutable_callback_alias.value !== childImportedCallback) { + throw new Error("immutable funcref const initializer saw the wrong child binding"); + } + const token = child.exports.token(); + if (token === owned || token.handle !== 77 || !token.child) { + throw new Error("externref global was not reconstructed"); + } + if (child.exports.immutable_token().handle !== 88) { + throw new Error("immutable imported externref recipe changed"); + } + if ( + child.exports.immutable_token_global.value !== childImmutableToken + || child.exports.immutable_token_alias.value !== childImmutableToken + || child.exports.immutable_token() !== childImmutableToken + ) { + throw new Error("immutable externref was not materialized before instantiation"); + } + + const callbackValues = [22, 22, 11, 22, 11]; + if (child.exports.callbacks.length !== callbackValues.length) { + throw new Error("funcref table length reset"); + } + callbackValues.forEach((value, index) => { + if (child.exports.callbacks.get(index)() !== value) { + throw new Error(`funcref table entry ${index} reset`); + } + }); + if (child.exports.tokens.length !== 4) throw new Error("externref table length reset"); + for (let index = 0; index < 4; index++) { + if (child.exports.tokens.get(index) !== token) { + throw new Error(`externref alias lost at table entry ${index}`); + } + } + if (childSharedCallbacks.length !== 3) { + throw new Error("imported table length reset"); + } + for (let index = 0; index < 3; index++) { + if (child.exports.shared_callback(index)() !== 22) { + throw new Error(`imported table entry ${index} reset`); + } + } + for (const name of ["try_late_elem", "try_late_data"]) { + let trapped = false; + try { + child.exports[name](); + } catch (error) { + trapped = error instanceof WebAssembly.RuntimeError; + } + if (!trapped) throw new Error(`${name} observed a live dropped segment`); + } + "#; + let output = Command::new("node") + .arg("-e") + .arg(script) + .arg(&module_path) + .arg(&codecs_path) + .output() + .expect("run Node module-state fresh-instance test"); + let _ = fs::remove_dir_all(&directory); + assert!( + output.status.success(), + "Node module-state engine test failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} + +#[test] +fn node_fresh_instance_reimports_concrete_gc_global_before_const_initializers() { + let provider = wat::parse_str( + r#" + (module + (type $pair (struct (field i32))) + (global $root (ref $pair) + (struct.new $pair (i32.const 91))) + (export "__wpk_fork_global_1" (global $root)) + (export "root" (global $root))) + "#, + ) + .expect("compile concrete-GC provider"); + let consumer = wat::parse_str( + r#" + (module + (type $pair (struct (field i32))) + (import "provider" "root" (global $root (ref $pair))) + (global $alias (ref $pair) (global.get $root)) + (export "__wpk_fork_global_1" (global $root)) + (export "root" (global $root)) + (export "alias" (global $alias)) + (func (export "same") (result i32) + global.get $root + global.get $alias + ref.eq) + (func (export "value") (result i32) + global.get $alias + struct.get $pair 0)) + "#, + ) + .expect("compile concrete-GC consumer"); + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "kandelo-imported-gc-global-{}-{nonce}", + std::process::id(), + )); + fs::create_dir(&directory).expect("create concrete-GC test directory"); + let provider_path = directory.join("provider.wasm"); + let consumer_path = directory.join("consumer.wasm"); + fs::write(&provider_path, provider).expect("write concrete-GC provider"); + fs::write(&consumer_path, consumer).expect("write concrete-GC consumer"); + let script = r#" + const fs = require("node:fs"); + const [providerPath, consumerPath] = process.argv.slice(1); + const providerModule = + new WebAssembly.Module(fs.readFileSync(providerPath)); + const consumerModule = + new WebAssembly.Module(fs.readFileSync(consumerPath)); + + const parentProvider = new WebAssembly.Instance(providerModule); + const parentConsumer = new WebAssembly.Instance(consumerModule, { + provider: { root: parentProvider.exports.__wpk_fork_global_1 }, + }); + const childProvider = new WebAssembly.Instance(providerModule); + const childConsumer = new WebAssembly.Instance(consumerModule, { + provider: { root: childProvider.exports.__wpk_fork_global_1 }, + }); + + if ( + childConsumer.exports.root + !== childProvider.exports.__wpk_fork_global_1 + ) { + throw new Error("consumer did not bind the provider Global object"); + } + if (childConsumer.exports.root === parentConsumer.exports.root) { + throw new Error("fresh child retained the parent provider Global"); + } + if (childConsumer.exports.root.value === parentConsumer.exports.root.value) { + throw new Error("fresh child retained the parent concrete GC object"); + } + if ( + childConsumer.exports.alias.value + !== childConsumer.exports.root.value + ) { + throw new Error("concrete GC const initializer lost provider identity"); + } + if (childConsumer.exports.same() !== 1 || childConsumer.exports.value() !== 91) { + throw new Error("concrete GC provider recipe changed guest semantics"); + } + "#; + let output = Command::new("node") + .arg("-e") + .arg(script) + .arg(&provider_path) + .arg(&consumer_path) + .output() + .expect("run Node concrete-GC import test"); + let _ = fs::remove_dir_all(&directory); + assert!( + output.status.success(), + "Node concrete-GC import test failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} diff --git a/crates/fork-instrument/tests/reference_analysis.rs b/crates/fork-instrument/tests/reference_analysis.rs new file mode 100644 index 0000000000..eedf67feeb --- /dev/null +++ b/crates/fork-instrument/tests/reference_analysis.rs @@ -0,0 +1,5 @@ +// Keep the reference planner independently compilable until its facts are +// wired into the transform. This avoids creating a temporary public API in +// `lib.rs` while still running the module's focused unit tests in CI. +#[path = "../src/reference_analysis.rs"] +mod reference_analysis; diff --git a/crates/fork-instrument/tests/roundtrip.rs b/crates/fork-instrument/tests/roundtrip.rs index c955979cae..eedf438f85 100644 --- a/crates/fork-instrument/tests/roundtrip.rs +++ b/crates/fork-instrument/tests/roundtrip.rs @@ -20,9 +20,8 @@ fn compile(wat_src: &str) -> Vec { fn validate(bytes: &[u8]) -> Result<(), wasmparser::BinaryReaderError> { // Independent validator (not walrus) — confirms the emitted bytes // are well-formed per the core spec. - let mut validator = wasmparser::Validator::new_with_features( - wasmparser::WasmFeatures::default(), - ); + let mut validator = + wasmparser::Validator::new_with_features(wasmparser::WasmFeatures::default()); validator.validate_all(bytes).map(|_| ()) } diff --git a/crates/fork-instrument/tests/runtime.rs b/crates/fork-instrument/tests/runtime.rs index cd5f3a0ce4..2a67854290 100644 --- a/crates/fork-instrument/tests/runtime.rs +++ b/crates/fork-instrument/tests/runtime.rs @@ -15,11 +15,14 @@ use fork_instrument::linked_frames::{ FrameFormatDescriptor, LINKED_FRAME_FORMAT_SECTION, PointerWidth, }; use fork_instrument::runtime::names; -use fork_instrument::{ - FORK_CAP_DYLINK_MAIN, FORK_CAP_SIDE_ENTRY, FORK_CAPABILITIES_SECTION, - FORK_CAPABILITIES_VERSION, Options, instrument, +use fork_instrument::{Options, UNWIND_TRANSPORT_SECTION, UNWIND_TRANSPORT_VERSION, instrument}; +use walrus::{ExportItem, ImportKind, Module, ValType}; +use wasm_posix_shared::abi::{ + WPK_FORK_CAP_ACTIVATION_STATE_SAFE, WPK_FORK_CAP_DYLINK_MAIN, WPK_FORK_CAP_SIDE_ENTRY, + WPK_FORK_CAPABILITIES_SECTION, WPK_FORK_CAPABILITIES_VERSION, + WPK_FORK_IMPORTED_GLOBALS_SECTION, WPK_FORK_MODULE_STATE_FORMAT_SECTION, + WPK_FORK_REQUIRED_EXPORTS, WPK_FORK_REQUIRED_IMPORTS, WPK_FORK_REQUIRED_TABLE_IMPORTS, }; -use walrus::{ExportItem, Module, ValType}; use wasmparser::{Parser, Payload}; fn instrument_wat(wat_src: &str) -> Vec { @@ -37,7 +40,7 @@ fn fork_capabilities(bytes: &[u8]) -> Vec> { Parser::new(0) .parse_all(bytes) .filter_map(|payload| match payload.expect("parse payload") { - Payload::CustomSection(section) if section.name() == FORK_CAPABILITIES_SECTION => { + Payload::CustomSection(section) if section.name() == WPK_FORK_CAPABILITIES_SECTION => { Some(section.data().to_vec()) } _ => None, @@ -75,6 +78,60 @@ fn instrumented_module_validates() { validate(&bytes); } +#[test] +fn preinstrumented_artifact_cannot_be_restamped_as_activation_safe() { + let once = instrument_wat(EMPTY_MODULE_WITH_FORK); + let error = instrument(&once, &Options::default()) + .expect_err("an existing fork transform must not be restamped"); + let message = error.to_string(); + assert!( + message.contains("input already contains wasm-fork-instrument"), + "{message}" + ); + assert!(message.contains("raw linker output"), "{message}"); +} + +#[test] +fn source_module_cannot_spoof_private_global_catalog_exports() { + let bytes = wat::parse_str( + r#" + (module + (global $value (mut i32) (i32.const 0)) + (export "__wpk_fork_global_1" (global $value)) + (memory 1)) + "#, + ) + .expect("wat parse"); + let error = instrument(&bytes, &Options::default()) + .expect_err("a source export must not collide with the private global catalog"); + let message = error.to_string(); + assert!( + message.contains("input already contains wasm-fork-instrument"), + "{message}" + ); + assert!(message.contains("raw linker output"), "{message}"); +} + +#[test] +fn source_module_cannot_spoof_private_table_catalog_exports() { + let bytes = wat::parse_str( + r#" + (module + (table $value 1 funcref) + (export "__wpk_fork_table_1" (table $value)) + (memory 1)) + "#, + ) + .expect("wat parse"); + let error = instrument(&bytes, &Options::default()) + .expect_err("a source export must not collide with the private table catalog"); + assert!( + error + .to_string() + .contains("input already contains wasm-fork-instrument") + ); +} + #[test] fn linked_runtime_imports_transaction_hooks_and_emits_exact_prefix_metadata() { let bytes = instrument_wat(EMPTY_MODULE_WITH_FORK); @@ -108,6 +165,95 @@ fn linked_runtime_imports_transaction_hooks_and_emits_exact_prefix_metadata() { ); } +#[test] +fn linked_runtime_imports_exact_private_unwind_tag_and_metadata() { + let bytes = instrument_wat(EMPTY_MODULE_WITH_FORK); + let module = Module::from_buffer(&bytes).unwrap(); + let imports: Vec<_> = module + .imports + .iter() + .filter(|import| { + import.module == names::IMPORT_UNWIND_TAG_MODULE + && import.name == names::IMPORT_UNWIND_TAG + }) + .collect(); + assert_eq!(imports.len(), 1, "private transport must have one owner"); + let tag = match imports[0].kind { + ImportKind::Tag(tag) => tag, + ref other => panic!("private unwind transport must be a tag, got {other:?}"), + }; + let tag_ty = module.types.get(module.tags.get(tag).ty()); + assert!( + tag_ty.params().is_empty(), + "unwind tag payload must be empty" + ); + assert!(tag_ty.results().is_empty(), "tag type cannot return values"); + + let metadata: Vec<_> = Parser::new(0) + .parse_all(&bytes) + .filter_map(|payload| match payload.expect("parse payload") { + Payload::CustomSection(section) if section.name() == UNWIND_TRANSPORT_SECTION => { + Some(section.data().to_vec()) + } + _ => None, + }) + .collect(); + assert_eq!( + metadata, + vec![vec![UNWIND_TRANSPORT_VERSION, 0]], + "host must be able to reject a lookalike tag with a different contract", + ); +} + +#[test] +fn state_only_side_activation_carries_exact_private_unwind_metadata() { + let bytes = instrument_wat( + r#"(module + (@custom "dylink.0" (before first) "state-only") + (memory 1))"#, + ); + let metadata: Vec<_> = Parser::new(0) + .parse_all(&bytes) + .filter_map(|payload| match payload.expect("parse payload") { + Payload::CustomSection(section) if section.name() == UNWIND_TRANSPORT_SECTION => { + Some(section.data().to_vec()) + } + _ => None, + }) + .collect(); + assert_eq!( + metadata, + vec![vec![UNWIND_TRANSPORT_VERSION, 0]], + "uniform ABI 43 state helpers require the same exact-tag descriptor", + ); +} + +#[test] +fn raw_module_cannot_preclaim_reserved_unwind_transport() { + let bytes = wat::parse_str( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (import "env" "__wpk_fork_unwind" (tag $unwind)) + (memory 1) + (func (export "run") (result i32) + call $fork)) + "#, + ) + .expect("wat parse"); + let message = instrument(&bytes, &Options::default()) + .expect_err("reserved private tag collision must fail before rewrite") + .to_string(); + assert!( + message.contains("reserved private fork runtime hook"), + "{message}" + ); + assert!( + message.contains("instrumenter must own unwind transport"), + "{message}" + ); +} + #[test] fn plain_catches_do_not_expand_fixed_prefix_metadata() { let bytes = instrument_wat( @@ -179,24 +325,90 @@ fn memory64_plain_catches_do_not_expand_fixed_prefix_metadata() { } #[test] -fn module_without_fork_seed_does_not_import_linked_storage_hooks() { - let bytes = instrument_wat("(module (memory 1) (func (export \"run\")))"); +fn dylink_module_without_local_fork_seed_has_the_uniform_replay_contract() { + let bytes = instrument_wat( + r#"(module + (@custom "dylink.0" (before first) "side") + (memory 1) + (func (export "run")))"#, + ); let module = Module::from_buffer(&bytes).unwrap(); - for name in [ - names::IMPORT_FRAME_RESERVE, - names::IMPORT_FRAME_COMMIT, - names::IMPORT_FRAME_NEXT, - ] { + for requirement in WPK_FORK_REQUIRED_IMPORTS { assert!( - !module - .imports + module.imports.iter().any(|import| { + import.module == requirement.module + && import.name == requirement.name + && matches!(import.kind, ImportKind::Function(_)) + }), + "state-only side module is missing linked function import {}.{}", + requirement.module, + requirement.name, + ); + } + for requirement in WPK_FORK_REQUIRED_TABLE_IMPORTS { + assert!( + module.imports.iter().any(|import| { + import.module == requirement.module + && import.name == requirement.name + && matches!(import.kind, ImportKind::Table(_)) + }), + "state-only side module is missing linked table import {}.{}", + requirement.module, + requirement.name, + ); + } + for requirement in WPK_FORK_REQUIRED_EXPORTS { + assert!( + module + .exports .iter() - .any(|import| import.module == "env" && import.name == name), - "inert module unexpectedly imports linked continuation hook {name}", + .any(|export| export.name == requirement.name), + "state-only side module is missing linked export {}", + requirement.name, + ); + } + for section_name in [ + WPK_FORK_CAPABILITIES_SECTION, + LINKED_FRAME_FORMAT_SECTION, + WPK_FORK_MODULE_STATE_FORMAT_SECTION, + WPK_FORK_IMPORTED_GLOBALS_SECTION, + ] { + assert_eq!( + module + .customs + .iter() + .filter(|(_, section)| section.name() == section_name) + .count(), + 1, + "state-only side module must carry exactly one {section_name} descriptor", ); } } +#[test] +fn reference_vector_finish_import_returns_a_canonical_ordinal() { + let bytes = instrument_wat(EMPTY_MODULE_WITH_FORK); + let module = Module::from_buffer(&bytes).unwrap(); + let finish = module + .imports + .iter() + .find_map(|import| { + (import.module == wasm_posix_shared::abi::WPK_FORK_FRAME_IMPORT_MODULE + && import.name == names::IMPORT_REFERENCE_VECTOR_FINISH) + .then(|| match import.kind { + ImportKind::Function(function) => Some(function), + _ => None, + }) + .flatten() + }) + .expect("reference-vector finish import"); + assert_eq!( + func_signature(&module, finish), + (vec![ValType::I32], vec![ValType::I32]), + "finish consumes a transient builder handle and returns its canonical wire ordinal", + ); +} + #[test] fn marks_dlopen_main_indirect_boundary_separately() { let wat = r#" @@ -213,7 +425,10 @@ fn marks_dlopen_main_indirect_boundary_separately() { let output = instrument_wat(wat); assert_eq!( fork_capabilities(&output), - vec![vec![FORK_CAPABILITIES_VERSION, FORK_CAP_DYLINK_MAIN]], + vec![vec![ + WPK_FORK_CAPABILITIES_VERSION, + WPK_FORK_CAP_DYLINK_MAIN | WPK_FORK_CAP_ACTIVATION_STATE_SAFE, + ]], ); } @@ -237,7 +452,35 @@ fn marks_env_fork_side_entry_separately() { .expect("instrument side"); assert_eq!( fork_capabilities(&output), - vec![vec![FORK_CAPABILITIES_VERSION, FORK_CAP_SIDE_ENTRY]], + vec![vec![ + WPK_FORK_CAPABILITIES_VERSION, + WPK_FORK_CAP_SIDE_ENTRY | WPK_FORK_CAP_ACTIVATION_STATE_SAFE, + ]], + ); +} + +#[test] +fn dylink_module_without_env_fork_claims_complete_side_boundaries() { + let input = wat::parse_str( + r#" + (module + (@custom "dylink.0" (before first) "side") + (import "env" "side_b" (func $side_b (result i32))) + (memory 1) + (func (export "side_a") (result i32) call $side_b)) + "#, + ) + .expect("wat parse"); + let output = instrument(&input, &Options::default()).expect("instrument side boundaries"); + validate(&output); + assert_eq!( + fork_capabilities(&output), + vec![vec![ + WPK_FORK_CAPABILITIES_VERSION, + WPK_FORK_CAP_SIDE_ENTRY | WPK_FORK_CAP_ACTIVATION_STATE_SAFE, + ]], + "SIDE_ENTRY means every cross-module activation boundary is covered, \ + even when fork itself is downstream in another module", ); } @@ -246,7 +489,10 @@ fn generic_runtime_exports_do_not_claim_side_or_dylink_coverage() { let output = instrument_wat(EMPTY_MODULE_WITH_FORK); assert_eq!( fork_capabilities(&output), - vec![vec![FORK_CAPABILITIES_VERSION, 0]], + vec![vec![ + WPK_FORK_CAPABILITIES_VERSION, + WPK_FORK_CAP_ACTIVATION_STATE_SAFE, + ]], ); } @@ -496,8 +742,10 @@ fn wasm64_saved_globals_use_16_byte_header() { } #[test] -fn ref_typed_mutable_globals_are_skipped_in_4e() { - // Phase 4e handles scalar globals only; ref-typed ones await 4f. +fn linked_runtime_prefix_defers_reference_globals_to_kfms() { + // The fixed continuation prefix owns scalar control globals. KFMS owns + // reference-global recipes because it can reconstruct them in a fresh + // module instance without putting references in linear memory. let wat = r#" (module (import "kernel" "kernel_fork" (func $fork (result i32))) @@ -509,7 +757,7 @@ fn ref_typed_mutable_globals_are_skipped_in_4e() { let mut module = Module::from_buffer(&bytes).unwrap(); let runtime = inject_runtime(&mut module); - // Only the i32 scalar should have been picked up. + // Only the scalar is part of the fixed runtime prefix. assert_eq!(runtime.saved_globals.len(), 1); assert_eq!(runtime.saved_globals[0].ty, walrus::ValType::I32); } diff --git a/crates/fork-instrument/tests/static_reference_catalog.rs b/crates/fork-instrument/tests/static_reference_catalog.rs new file mode 100644 index 0000000000..9d96c12a52 --- /dev/null +++ b/crates/fork-instrument/tests/static_reference_catalog.rs @@ -0,0 +1,223 @@ +use std::{ + fs, + process::Command, + time::{SystemTime, UNIX_EPOCH}, +}; + +use fork_instrument::static_reference_catalog; +use walrus::Module; + +fn fixture() -> Vec { + wat::parse_str( + r#" + (module + (type $pair (struct (field i32))) + (global $root (ref $pair) + (struct.new $pair (i32.const 41))) + (global $alias (ref $pair) + (global.get $root)) + (table $values (export "values") 3 3 (ref null $pair)) + (elem $roots (ref $pair) + (global.get $root) + (global.get $alias) + (struct.new $pair (i32.const 99))) + + (func (export "initialize_values") + i32.const 0 + i32.const 0 + i32.const 3 + table.init $values $roots) + + (func (export "matches_root") + (param (ref null $pair)) (result i32) + (local.get 0) + (global.get $root) + ref.eq) + + (func (export "matches_table") + (param i32) (param (ref null $pair)) (result i32) + (local.get 0) + (table.get $values) + (local.get 1) + ref.eq)) + "#, + ) + .expect("static-root fixture WAT") +} + +fn catalogued_fixture() -> (Vec, usize) { + let mut module = Module::from_buffer(&fixture()).expect("parse static-root fixture"); + let plan = static_reference_catalog::plan(&mut module); + let root_count = plan.root_count(); + static_reference_catalog::inject(&mut module, plan); + (module.emit_wasm(), root_count) +} + +#[test] +fn aliases_keep_one_stable_ordinal_without_hoisting_allocating_elements() { + let (bytes, root_count) = catalogued_fixture(); + assert_eq!( + root_count, 2, + "the immutable global and its global.get aliases share ordinal zero; \ + the independently allocating element owns ordinal one", + ); + wasmparser::Validator::new_with_features(wasmparser::WasmFeatures::all()) + .validate_all(&bytes) + .expect("static-root catalog output validates"); + + let module = Module::from_buffer(&bytes).expect("reparse catalogued fixture"); + let catalog = module + .exports + .iter() + .find(|export| export.name == static_reference_catalog::EXPORT) + .expect("static-root table export"); + let walrus::ExportItem::Table(table) = catalog.item else { + panic!("static-root catalog export is not a table"); + }; + let table = module.tables.get(table); + assert_eq!(table.initial, 2); + assert_eq!(table.maximum, Some(2)); + assert_eq!(table.element_ty, walrus::RefType::ANYREF); + assert!( + module + .exports + .iter() + .any(|export| export.name == static_reference_catalog::HARVEST_EXPORT), + "static-root harvest helper must be exported", + ); + + let allocating_expression = module + .elements + .iter() + .find_map(|element| match &element.items { + walrus::ElementItems::Expressions(_, expressions) => expressions.get(2), + _ => None, + }) + .expect("allocating element expression"); + assert!( + !matches!(allocating_expression, walrus::ConstExpr::Global(_)), + "allocating element roots must remain segment-owned rather than being \ + hoisted into a permanent immutable global", + ); +} + +#[test] +fn allocating_local_table_initializer_is_harvested_without_hoisting() { + let input = wat::parse_str( + r#" + (module + (type $pair (struct (field i32))) + (table $values 2 2 (ref $pair) + (struct.new $pair (i32.const 73)))) + "#, + ) + .expect("table-initializer fixture WAT"); + let mut module = Module::from_buffer(&input).expect("parse table-initializer fixture"); + let plan = static_reference_catalog::plan(&mut module); + assert_eq!(plan.root_count(), 1); + + let table = module.tables.iter().next().expect("source table"); + assert!( + !matches!(table.init, Some(walrus::ConstExpr::Global(_))), + "allocating table initializer must not be hoisted into a permanent root", + ); +} + +#[test] +fn fresh_instance_catalog_decodes_to_the_identity_observed_by_ref_eq() { + let (bytes, _) = catalogued_fixture(); + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "kandelo-static-reference-catalog-{}-{nonce}", + std::process::id(), + )); + fs::create_dir_all(&dir).expect("create static-root test directory"); + let wasm = dir.join("fixture.wasm"); + let script = dir.join("verify.mjs"); + fs::write(&wasm, bytes).expect("write static-root fixture"); + fs::write( + &script, + r#" +import fs from "node:fs"; + +const module = new WebAssembly.Module(fs.readFileSync(process.argv[2])); +const parent = new WebAssembly.Instance(module); +const child = new WebAssembly.Instance(module); +const parentCatalog = parent.exports.__wpk_fork_static_root_catalog; +const childCatalog = child.exports.__wpk_fork_static_root_catalog; + +if (parentCatalog.length !== 2 || childCatalog.length !== 2) { + throw new Error("unexpected static-root catalog length"); +} +for (let index = 0; index < 2; index++) { + if (parentCatalog.get(index) !== null || childCatalog.get(index) !== null) { + throw new Error("static-root harvest tables did not instantiate empty"); + } +} +parent.exports.__wpk_fork_static_root_harvest(); +child.exports.__wpk_fork_static_root_harvest(); +const parentRoot = parentCatalog.get(0); +const childRoot = childCatalog.get(0); +const childElementRoot = childCatalog.get(1); +if (parentRoot === childRoot) { + throw new Error("fresh instances unexpectedly share a GC object"); +} +if (childCatalog.get(0) !== childRoot) { + throw new Error("repeated anyref table reads did not preserve JS wrapper identity"); +} +const transit = new WebAssembly.Table({ + element: "anyref", + initial: 1, + maximum: 1, +}); +transit.set(0, childRoot); +if (transit.get(0) !== childRoot) { + throw new Error("anyref table transit did not preserve JS wrapper identity"); +} +if (child.exports.matches_root(childRoot) !== 1) { + throw new Error("child catalog root does not ref.eq its immutable global"); +} +if (child.exports.matches_root(transit.get(0)) !== 1) { + throw new Error("anyref table transit did not preserve Wasm ref.eq identity"); +} +parent.exports.initialize_values(); +child.exports.initialize_values(); +if (child.exports.matches_root(parentRoot) !== 0) { + throw new Error("parent GC root incorrectly aliases the child's root"); +} +if (child.exports.matches_table(0, childRoot) !== 1 + || child.exports.matches_table(1, childRoot) !== 1) { + throw new Error("global.get element aliases lost their canonical root"); +} +if (child.exports.matches_table(2, childElementRoot) !== 1) { + throw new Error("harvested allocating element does not ref.eq its segment root"); +} +for (let index = 0; index < 2; index++) { + parentCatalog.set(index, null); + childCatalog.set(index, null); +} +for (let index = 0; index < 2; index++) { + if (parentCatalog.get(index) !== null || childCatalog.get(index) !== null) { + throw new Error("static-root harvest tables retained stale GC roots"); + } +} +"#, + ) + .expect("write static-root verifier"); + + let output = Command::new("node") + .arg(&script) + .arg(&wasm) + .output() + .expect("run Node static-root verifier"); + let _ = fs::remove_dir_all(&dir); + assert!( + output.status.success(), + "Node static-root verifier failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} diff --git a/crates/fork-instrument/tests/switch_dispatch.rs b/crates/fork-instrument/tests/switch_dispatch.rs index 870438911d..3903c10ead 100644 --- a/crates/fork-instrument/tests/switch_dispatch.rs +++ b/crates/fork-instrument/tests/switch_dispatch.rs @@ -287,15 +287,22 @@ fn no_catch_switch_dispatch_omits_frame_header_state_locals() { let caller = extract_function_text(&printed, "caller"); let locals = declared_scalar_local_count(&caller); assert_eq!( - locals, 2, - "no-catch top-level fork path should declare only the original local and \ - abort_live_frame; \ - call_idx and frame_ptr are loaded from the frame header, and \ + locals, 1, + "no-catch top-level fork path should declare only the original local; \ + the static call boundary must not need an abort-frame/selector local, \ + saved call_idx and frame_ptr are loaded from the frame header, and \ unconditional catch metadata locals would raise this count:\n{caller}" ); assert!( - caller.contains("i32.store offset=4"), - "unwind call site must still write frame.call_index before the shared postamble:\n{caller}" + caller.contains("call $__wpk_fork_select_unwind_frame"), + "unwind call site must pass its static call index to the shared \ + frame selector before the postamble:\n{caller}" + ); + let selector = extract_function_text(&printed, "__wpk_fork_select_unwind_frame"); + assert!( + selector.contains("i32.store offset=4"), + "the shared frame selector must publish frame.call_index before \ + returning success or synchronous-abort routing:\n{selector}" ); } @@ -322,9 +329,9 @@ fn top_level_indirect_switch_dispatch_omits_frame_header_state_locals() { let caller = extract_function_text(&printed, "caller"); let locals = declared_scalar_local_count(&caller); assert_eq!( - locals, 1, - "top-level indirect call with a pure table index should need only \ - abort_live_frame, with no arg, frame_ptr, or call_idx locals:\n{caller}" + locals, 0, + "top-level indirect call with a pure table index should need no \ + arg, abort-frame, selector, frame_ptr, or saved-call-index locals:\n{caller}" ); } @@ -339,10 +346,10 @@ fn nested_direct_switch_dispatch_omits_frame_header_state_locals() { let main = extract_function_text(&printed, "main"); let locals = declared_scalar_local_count(&main); assert_eq!( - locals, 3, - "nested block dispatch should retain only the two source locals and \ - abort_live_frame; \ - frame_ptr and call_idx must not be declared locals:\n{main}" + locals, 2, + "nested block dispatch should retain only the two source locals; \ + static call boundaries do not require an activation-local selector, \ + frame_ptr and saved call_idx must not be declared locals:\n{main}" ); } @@ -368,10 +375,10 @@ fn nested_if_else_dispatch_omits_frame_header_state_locals() { let main = extract_function_text(&printed, "main"); let locals = declared_scalar_local_count(&main); assert_eq!( - locals, 1, + locals, 0, "nested if/else dispatch should replay a pure condition without cond_swap; \ - abort_live_frame is the only declared local, params are not declared locals, \ - and frame_ptr/call_idx must be loaded from the frame:\n{main}" + no abort-frame or call-selector local is declared, \ + params are not declared locals, and frame_ptr/saved call_idx come from the frame:\n{main}" ); } @@ -403,22 +410,594 @@ fn pr701_shape_replays_pure_condition_and_recursive_arg() { let walk = extract_function_text(&printed, "walk"); let locals = declared_scalar_local_count(&walk); assert_eq!( - locals, 1, + locals, 0, "PR701-shaped pure condition and recursive arg should not allocate \ - arg-spill or condition/carryover locals beyond abort_live_frame:\n{walk}" + arg-spill, condition/carryover, abort-frame, or active-call selector \ + locals:\n{walk}" ); + let normalized = walk.lines().map(str::trim).collect::>().join("\n"); assert!( - walk.contains("local.get 0\n i32.eqz\n global.get $_wpk_fork_state"), + normalized.contains("local.get 0\ni32.eqz\nglobal.get $_wpk_fork_state"), "rewritten IfElse landing should replay the pure eqz(depth) condition \ before selecting NORMAL vs REWIND:\n{walk}" ); assert!( - walk.contains("local.get 0\n i32.const 1\n i32.sub\n call $walk"), + !normalized.contains("local.set 1"), + "recursive call landing must use its statically known call index rather \ + than adding an activation-local selector:\n{walk}" + ); + assert!( + normalized.contains("local.get 0\ni32.const 1\ni32.sub\ncall $walk"), "recursive call landing should replay pure depth - 1 argument tail \ - before the call:\n{walk}" + on the lexical branch without allocating an argument local:\n{walk}" + ); +} + +#[test] +fn reference_recipe_vector_adds_no_ordinary_activation_local() { + let wat = r#" + (module + (import "kernel" "kernel_fork" (func $kernel_fork (result i32))) + (memory (export "memory") 1) + (func $walk (export "reference_walk") + (param $depth i32) + (param $value externref) + local.get $depth + i32.eqz + if + call $kernel_fork + drop + local.get $value + drop + else + local.get $depth + i32.const 1 + i32.sub + local.get $value + call $walk + end)) + "#; + let input = wat::parse_str(wat).expect("wat parse"); + let output = instrument(&input, &Options::default()).expect("instrument"); + validate(&output); + + let printed = wasmprinter::print_bytes(&output).expect("wasmprinter"); + let walk = extract_function_text(&printed, "walk"); + let locals = declared_scalar_local_count(&walk); + assert_eq!( + locals, 0, + "activation-owned reference recipes must use the reserved frame word \ + and process vector directly; adding a recipe/vector scratch local \ + would repeat the V8 recursion regression fixed by PR #713. Static \ + call boundaries must not add an abort-frame/selector local either:\n{walk}" + ); +} + +#[test] +fn catch_ref_arm_count_does_not_scale_native_local_tuple() { + fn fixture(arm_count: usize) -> String { + assert!(arm_count > 0); + let tags = (0..arm_count) + .map(|index| format!("(tag $tag{index} (param i32 i64))")) + .collect::>() + .join("\n"); + let catches = (0..arm_count) + .map(|index| format!("(catch_ref $tag{index} $handler)")) + .collect::>() + .join("\n"); + format!( + r#" + (module + (import "kernel" "kernel_fork" (func $kernel_fork (result i32))) + {tags} + (memory (export "memory") 1) + (func $caller (export "catch_ref_scaling") + (block $handler (result i32 i64 exnref) + (try_table (result i32 i64 exnref) + {catches} + call $kernel_fork + drop + i32.const 17 + i64.const 23 + throw $tag0)) + drop + drop + drop)) + "#, + ) + } + + fn counts(arm_count: usize) -> (GeneratedLocalCounts, String) { + let input = wat::parse_str(fixture(arm_count)).expect("wat parse"); + let output = instrument(&input, &Options::default()).expect("instrument"); + validate(&output); + let printed = wasmprinter::print_bytes(&output).expect("wasmprinter"); + ( + generated_local_counts(&output, "catch_ref_scaling"), + extract_function_text(&printed, "caller"), + ) + } + + let (one_arm, one_arm_wat) = counts(1); + let (many_arms, many_arms_wat) = counts(32); + assert_eq!( + one_arm, + GeneratedLocalCounts { + i32: 2, + i64: 1, + f32: 0, + f64: 0, + v128: 0, + nullable_exnref: 1, + other_reference: 0, + total: 4, + }, + "one scalar CatchRef arm should need one selector i32, one typed \ + i32/i64 payload union, and one forwarding exnref; the call boundary \ + adds no local:\n{one_arm_wat}", + ); + assert_eq!( + many_arms, one_arm, + "adding scalar CatchRef arms to one mutually-exclusive try_table must \ + not add native activation locals by type:\n{many_arms_wat}", + ); +} + +#[test] +fn catch_region_count_does_not_add_control_locals_or_frame_bytes() { + fn fixture(region_count: usize) -> String { + assert!(region_count > 0); + let regions = (0..region_count) + .map(|index| { + format!( + r#" + (block $handler{index} + (try_table (catch $tag $handler{index}) + nop)) + "#, + ) + }) + .collect::>() + .join("\n"); + format!( + r#" + (module + (import "kernel" "kernel_fork" (func $kernel_fork (result i32))) + (tag $tag) + (memory (export "memory") 1) + (func $caller (export "catch_region_scaling") + {regions} + call $kernel_fork + drop)) + "#, + ) + } + + fn measure(region_count: usize) -> (GeneratedLocalCounts, Vec, String) { + let input = wat::parse_str(fixture(region_count)).expect("wat parse"); + let output = instrument(&input, &Options::default()).expect("instrument"); + validate(&output); + let printed = wasmprinter::print_bytes(&output).expect("wasmprinter"); + ( + generated_local_counts(&output, "catch_region_scaling"), + frame_reserve_sizes(&output, "catch_region_scaling"), + extract_function_text(&printed, "caller"), + ) + } + + let (one_region, one_frame_sizes, one_region_wat) = measure(1); + let (many_regions, many_frame_sizes, many_regions_wat) = measure(32); + assert_eq!( + one_region, + GeneratedLocalCounts { + i32: 1, + i64: 0, + f32: 0, + f64: 0, + v128: 0, + nullable_exnref: 0, + other_reference: 0, + total: 1, + }, + "one empty-payload catch region needs only the activation's exact-arm \ + selector; the call boundary adds no local:\n{one_region_wat}", + ); + assert_eq!( + many_regions, one_region, + "static catch-region count must not recreate the old one-i32-per-region \ + marker cost in every native activation:\n{many_regions_wat}", + ); + assert!( + one_frame_sizes.iter().all(|size| *size == 16) + && many_frame_sizes.iter().all(|size| *size == 16), + "empty-payload catch regions reuse header selector word +8 and must not \ + enlarge a linked activation frame: one={one_frame_sizes:?}, \ + many={many_frame_sizes:?}", + ); +} + +#[test] +fn catch_region_count_uses_one_function_wide_operand_union() { + fn fixture(region_count: usize) -> String { + assert!(region_count > 0); + let regions = (0..region_count) + .map(|index| { + format!( + r#" + (block $handler{index} (result i32 i64 exnref) + (try_table (result i32 i64 exnref) + (catch_ref $tag $handler{index}) + i32.const {index} + i64.const {index} + throw $tag)) + drop + drop + drop + "#, + ) + }) + .collect::>() + .join("\n"); + format!( + r#" + (module + (import "kernel" "kernel_fork" (func $kernel_fork (result i32))) + (tag $tag (param i32 i64)) + (memory (export "memory") 1) + (func $caller (export "catch_region_operand_scaling") + {regions} + call $kernel_fork + drop)) + "#, + ) + } + + fn measure(region_count: usize) -> (GeneratedLocalCounts, Vec, String) { + let input = wat::parse_str(fixture(region_count)).expect("wat parse"); + let output = instrument(&input, &Options::default()).expect("instrument"); + validate(&output); + let printed = wasmprinter::print_bytes(&output).expect("wasmprinter"); + ( + generated_local_counts(&output, "catch_region_operand_scaling"), + frame_reserve_sizes(&output, "catch_region_operand_scaling"), + extract_function_text(&printed, "caller"), + ) + } + + let (one_region, one_frame_sizes, one_region_wat) = measure(1); + let (many_regions, many_frame_sizes, many_regions_wat) = measure(32); + assert_eq!( + one_region, + GeneratedLocalCounts { + i32: 2, + i64: 1, + f32: 0, + f64: 0, + v128: 0, + nullable_exnref: 1, + other_reference: 0, + total: 4, + }, + "one scalar CatchRef region needs one selector i32, one typed i32/i64 \ + operand union, and one forwarding exnref; the call boundary adds no \ + local:\n{one_region_wat}", + ); + assert_eq!( + many_regions, one_region, + "capture scratch belongs to the dynamically selected catch, so static \ + region count must not add native operand tuples:\n{many_regions_wat}", + ); + assert!( + one_frame_sizes.iter().all(|size| *size == 28) + && many_frame_sizes.iter().all(|size| *size == 28), + "all regions overlay the same 12-byte scalar catch payload range: \ + one={one_frame_sizes:?}, many={many_frame_sizes:?}", ); } +#[test] +fn recipe_backed_catch_arm_count_uses_one_region_local_and_header_only_frame() { + fn fixture(arm_count: usize) -> String { + assert!(arm_count > 0); + let tags = (0..arm_count) + .map(|index| format!("(tag $tag{index} (param externref))")) + .collect::>() + .join("\n"); + let catches = (0..arm_count) + .map(|index| format!("(catch_ref $tag{index} $handler)")) + .collect::>() + .join("\n"); + format!( + r#" + (module + (import "kernel" "kernel_fork" (func $kernel_fork (result i32))) + {tags} + (memory (export "memory") 1) + (func $caller (export "catch_ref_recipe_scaling") + (block $handler (result externref exnref) + (try_table (result externref exnref) + {catches} + call $kernel_fork + drop + ref.null extern + throw $tag0)) + drop + drop)) + "#, + ) + } + + fn counts(arm_count: usize) -> (GeneratedLocalCounts, Vec, String) { + let input = wat::parse_str(fixture(arm_count)).expect("wat parse"); + let output = instrument(&input, &Options::default()).expect("instrument"); + validate(&output); + let printed = wasmprinter::print_bytes(&output).expect("wasmprinter"); + ( + generated_local_counts(&output, "catch_ref_recipe_scaling"), + frame_reserve_sizes(&output, "catch_ref_recipe_scaling"), + extract_function_text(&printed, "caller"), + ) + } + + let (one_arm, one_frame_sizes, one_arm_wat) = counts(1); + let (many_arms, many_frame_sizes, many_arms_wat) = counts(32); + assert_eq!( + one_arm, + GeneratedLocalCounts { + i32: 1, + i64: 0, + f32: 0, + f64: 0, + v128: 0, + nullable_exnref: 1, + other_reference: 1, + total: 3, + }, + "one reference-payload CatchRef arm should need one selector i32, \ + one operand-forwarding externref, and one retained region exnref; \ + the call boundary adds no local:\n{one_arm_wat}", + ); + assert_eq!( + many_arms, one_arm, + "mutually exclusive recipe-backed arms in one try_table must share \ + both their typed operand union and retained exception local:\n\ + {many_arms_wat}", + ); + assert!( + one_frame_sizes.iter().all(|size| *size == 16) + && many_frame_sizes.iter().all(|size| *size == 16), + "reference-bearing catch payloads belong to the recipe vector; adding \ + static arms must not grow the 16-byte linked-frame payload header: \ + one={one_frame_sizes:?}, many={many_frame_sizes:?}", + ); +} + +#[test] +fn recipe_backed_catch_region_count_uses_one_function_local_and_header_only_frame() { + fn fixture(region_count: usize) -> String { + assert!(region_count > 0); + let regions = (0..region_count) + .map(|index| { + format!( + r#" + (block $handler{index} (result externref exnref) + (try_table (result externref exnref) + (catch_ref $tag $handler{index}) + ref.null extern + throw $tag)) + drop + drop + "#, + ) + }) + .collect::>() + .join("\n"); + format!( + r#" + (module + (import "kernel" "kernel_fork" (func $kernel_fork (result i32))) + (tag $tag (param externref)) + (memory (export "memory") 1) + (func $caller (export "catch_ref_recipe_region_scaling") + {regions} + call $kernel_fork + drop)) + "#, + ) + } + + fn measure(region_count: usize) -> (GeneratedLocalCounts, Vec, String) { + let input = wat::parse_str(fixture(region_count)).expect("wat parse"); + let output = instrument(&input, &Options::default()).expect("instrument"); + validate(&output); + let printed = wasmprinter::print_bytes(&output).expect("wasmprinter"); + ( + generated_local_counts(&output, "catch_ref_recipe_region_scaling"), + frame_reserve_sizes(&output, "catch_ref_recipe_region_scaling"), + extract_function_text(&printed, "caller"), + ) + } + + let (one_region, one_frame_sizes, one_region_wat) = measure(1); + let (many_regions, many_frame_sizes, many_regions_wat) = measure(32); + assert_eq!( + one_region, + GeneratedLocalCounts { + i32: 1, + i64: 0, + f32: 0, + f64: 0, + v128: 0, + nullable_exnref: 1, + other_reference: 1, + total: 3, + }, + "one recipe-backed region should need one selector i32, one shared \ + operand-forwarding externref, and one retained exception; the call \ + boundary adds no local:\n{one_region_wat}", + ); + assert_eq!( + many_regions, one_region, + "the one live catch selector can name only one complete-exception \ + recipe, so 32 static regions must not add 32 native exnref locals:\n\ + {many_regions_wat}", + ); + assert!( + one_frame_sizes.iter().all(|size| *size == 16) + && many_frame_sizes.iter().all(|size| *size == 16), + "recipe-backed regions must share the process reference vector's one \ + selected exception and must not grow the 16-byte linked frame: \ + one={one_frame_sizes:?}, many={many_frame_sizes:?}", + ); +} + +#[test] +fn v128_catch_arm_count_uses_one_region_local_and_header_only_frame() { + fn fixture(arm_count: usize) -> String { + assert!(arm_count > 0); + let tags = (0..arm_count) + .map(|index| format!("(tag $tag{index} (param v128))")) + .collect::>() + .join("\n"); + let catches = (0..arm_count) + .map(|index| format!("(catch_ref $tag{index} $handler)")) + .collect::>() + .join("\n"); + format!( + r#" + (module + (import "kernel" "kernel_fork" (func $kernel_fork (result i32))) + {tags} + (memory (export "memory") 1) + (func $caller (export "catch_ref_v128_scaling") + (block $handler (result v128 exnref) + (try_table (result v128 exnref) + {catches} + call $kernel_fork + drop + v128.const i32x4 1 2 3 4 + throw $tag0)) + drop + drop)) + "#, + ) + } + + fn counts(arm_count: usize) -> (GeneratedLocalCounts, Vec, String) { + let input = wat::parse_str(fixture(arm_count)).expect("wat parse"); + let output = instrument(&input, &Options::default()).expect("instrument"); + validate(&output); + let printed = wasmprinter::print_bytes(&output).expect("wasmprinter"); + ( + generated_local_counts(&output, "catch_ref_v128_scaling"), + frame_reserve_sizes(&output, "catch_ref_v128_scaling"), + extract_function_text(&printed, "caller"), + ) + } + + let (one_arm, one_frame_sizes, one_arm_wat) = counts(1); + let (many_arms, many_frame_sizes, many_arms_wat) = counts(32); + assert_eq!( + one_arm, + GeneratedLocalCounts { + i32: 1, + i64: 0, + f32: 0, + f64: 0, + v128: 1, + nullable_exnref: 1, + other_reference: 0, + total: 3, + }, + "one v128-payload CatchRef arm should need one selector i32, \ + one operand-forwarding v128, and one retained region exnref; the \ + call boundary adds no local:\n{one_arm_wat}", + ); + assert_eq!( + many_arms, one_arm, + "mutually exclusive v128 recipe-backed arms in one try_table must \ + share both their typed operand union and retained exception local:\n\ + {many_arms_wat}", + ); + assert!( + one_frame_sizes.iter().all(|size| *size == 16) + && many_frame_sizes.iter().all(|size| *size == 16), + "v128 catch payloads belong to the complete-exception recipe; adding \ + static arms must not grow the 16-byte linked-frame payload header: \ + one={one_frame_sizes:?}, many={many_frame_sizes:?}", + ); +} + +#[test] +fn catch_all_forms_use_one_retained_exception_local_and_no_frame_payload() { + let fixtures = [ + ( + "catch_all", + r#" + (module + (import "kernel" "kernel_fork" (func $kernel_fork (result i32))) + (tag $failure) + (memory (export "memory") 1) + (func $caller (export "catch_all_recipe_footprint") + (block $handler + (try_table (catch_all $handler) + call $kernel_fork + drop + throw $failure)))) + "#, + "catch_all_recipe_footprint", + ), + ( + "catch_all_ref", + r#" + (module + (import "kernel" "kernel_fork" (func $kernel_fork (result i32))) + (tag $failure) + (memory (export "memory") 1) + (func $caller (export "catch_all_ref_recipe_footprint") + (block $handler (result exnref) + (try_table (result exnref) (catch_all_ref $handler) + call $kernel_fork + drop + throw $failure)) + drop)) + "#, + "catch_all_ref_recipe_footprint", + ), + ]; + + for (label, wat, export_name) in fixtures { + let input = wat::parse_str(wat).unwrap_or_else(|error| panic!("{label}: {error}")); + let output = instrument(&input, &Options::default()) + .unwrap_or_else(|error| panic!("{label}: {error:#}")); + validate(&output); + let printed = wasmprinter::print_bytes(&output).expect("wasmprinter"); + let caller = extract_function_text(&printed, "caller"); + assert_eq!( + generated_local_counts(&output, export_name), + GeneratedLocalCounts { + i32: 1, + i64: 0, + f32: 0, + f64: 0, + v128: 0, + nullable_exnref: 1, + other_reference: 0, + total: 2, + }, + "{label}: an untagged catch needs one selector i32 and exactly \ + one retained region exception local; the call boundary adds no \ + local:\n{caller}", + ); + let frame_sizes = frame_reserve_sizes(&output, export_name); + assert!( + frame_sizes.iter().all(|size| *size == 16), + "{label}: the complete exception belongs to the recipe vector, \ + not additional linked-frame bytes: {frame_sizes:?}", + ); + } +} + // -- Helper predicates ---------------------------------------------- fn find_func(module: &Module, name: &str) -> FunctionId { @@ -480,6 +1059,147 @@ fn declared_scalar_local_count(func_text: &str) -> usize { .sum() } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct GeneratedLocalCounts { + i32: usize, + i64: usize, + f32: usize, + f64: usize, + v128: usize, + nullable_exnref: usize, + other_reference: usize, + total: usize, +} + +fn generated_local_counts(bytes: &[u8], export_name: &str) -> GeneratedLocalCounts { + let mut imported_functions = 0u32; + let mut exported_function = None; + let mut defined_function = 0u32; + for payload in wasmparser::Parser::new(0).parse_all(bytes) { + match payload.expect("parse generated module") { + wasmparser::Payload::ImportSection(imports) => { + for import in imports.into_imports() { + if matches!( + import.expect("parse generated import").ty, + wasmparser::TypeRef::Func(_) | wasmparser::TypeRef::FuncExact(_) + ) { + imported_functions += 1; + } + } + } + wasmparser::Payload::ExportSection(exports) => { + for export in exports { + let export = export.expect("parse generated export"); + if export.name == export_name && export.kind == wasmparser::ExternalKind::Func { + exported_function = Some(export.index); + } + } + } + wasmparser::Payload::CodeSectionEntry(body) => { + let function_index = imported_functions + defined_function; + defined_function += 1; + if Some(function_index) != exported_function { + continue; + } + + let mut counts = GeneratedLocalCounts { + i32: 0, + i64: 0, + f32: 0, + f64: 0, + v128: 0, + nullable_exnref: 0, + other_reference: 0, + total: 0, + }; + for local in body + .get_locals_reader() + .expect("read generated locals") + .into_iter() + { + let (count, ty) = local.expect("parse generated local"); + let count = count as usize; + counts.total += count; + match ty { + wasmparser::ValType::I32 => counts.i32 += count, + wasmparser::ValType::I64 => counts.i64 += count, + wasmparser::ValType::F32 => counts.f32 += count, + wasmparser::ValType::F64 => counts.f64 += count, + wasmparser::ValType::V128 => counts.v128 += count, + wasmparser::ValType::Ref(wasmparser::RefType::EXNREF) => { + counts.nullable_exnref += count; + } + wasmparser::ValType::Ref(_) => counts.other_reference += count, + } + } + return counts; + } + _ => {} + } + } + panic!("generated function export `{export_name}` has no code body"); +} + +fn frame_reserve_sizes(bytes: &[u8], export_name: &str) -> Vec { + let module = Module::from_buffer(bytes).expect("parse generated module"); + let frame_select = module + .funcs + .iter() + .find(|function| function.name.as_deref() == Some("__wpk_fork_select_unwind_frame")) + .expect("generated unwind-frame selector") + .id(); + let function_id = module + .exports + .iter() + .find_map(|export| { + (export.name == export_name).then(|| match export.item { + walrus::ExportItem::Function(function) => Some(function), + _ => None, + })? + }) + .unwrap_or_else(|| panic!("generated function export `{export_name}` not found")); + let function = local_func(&module, function_id); + let mut sizes = Vec::new(); + + fn collect( + function: &LocalFunction, + sequence: InstrSeqId, + frame_select: FunctionId, + sizes: &mut Vec, + ) { + let instructions = &function.block(sequence).instrs; + for (index, (instruction, _)) in instructions.iter().enumerate() { + if matches!(instruction, Instr::Call(call) if call.func == frame_select) { + let Some(( + Instr::Const(Const { + value: Value::I32(size), + }), + _, + )) = index + .checked_sub(2) + .and_then(|previous| instructions.get(previous)) + else { + panic!( + "unwind-frame selector must be preceded by its exact \ + static size and call index" + ); + }; + sizes.push(*size); + } + for child in nested_of(instruction) { + collect(function, child, frame_select, sizes); + } + } + } + + collect(function, function.entry_block(), frame_select, &mut sizes); + assert!( + !sizes.is_empty(), + "generated function export `{export_name}` has no unwind-frame selection" + ); + sizes +} + fn find_import_func(module: &Module, qualified: &str) -> FunctionId { let (mod_name, field) = qualified.split_once('.').expect("qualified name"); for imp in module.imports.iter() { diff --git a/crates/fork-instrument/tests/trampoline.rs b/crates/fork-instrument/tests/trampoline.rs index 35f2ca02bc..f552564ca8 100644 --- a/crates/fork-instrument/tests/trampoline.rs +++ b/crates/fork-instrument/tests/trampoline.rs @@ -37,8 +37,8 @@ //! `call_graph::reaching_closure` covers it. //! - **Legacy `try` body** — 2026-05-17 CI showed that shipping C //! ports can still contain legacy `try` in fork-path functions even -//! with explicit modern-EH flags. Forks in the try body are absorbed -//! by nested switch-dispatch; legacy catch-handler forks still panic. +//! with explicit modern-EH flags. Fork-reachable handlers are normalized to +//! activation-owned modern EH before nested switch-dispatch runs. //! //! Net result: the trampoline scaffolding is preserved in //! `crates/fork-instrument/src/instrument.rs` but currently has no @@ -56,6 +56,11 @@ //! | `nested_call_indirect.wat` | nested switch (2.1) | already handled empirically | use fork_instrument::{Options, instrument}; +use std::{ + fs, + process::Command, + sync::atomic::{AtomicU64, Ordering}, +}; use walrus::{ LocalFunction, Module, ir::{Block, IfElse, Instr, InstrSeqId, Loop, Try, TryTable}, @@ -76,6 +81,38 @@ fn try_parse(wat_src: &str) -> Option> { wat::parse_str(wat_src).ok() } +fn parse_legacy_wat(wat_src: &str) -> Vec { + if let Some(bytes) = try_parse(wat_src) { + return bytes; + } + + static NEXT_ID: AtomicU64 = AtomicU64::new(0); + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + let base = std::env::temp_dir().join(format!( + "kandelo-fork-instrument-legacy-{}-{id}", + std::process::id(), + )); + let wat_path = base.with_extension("wat"); + let wasm_path = base.with_extension("wasm"); + fs::write(&wat_path, wat_src).expect("write legacy WAT fixture"); + let output = Command::new("wat2wasm") + .args(["--enable-exceptions"]) + .arg(&wat_path) + .arg("-o") + .arg(&wasm_path) + .output() + .expect("run wat2wasm for legacy EH fixture"); + assert!( + output.status.success(), + "wat2wasm failed for legacy EH fixture:\n{}", + String::from_utf8_lossy(&output.stderr), + ); + let bytes = fs::read(&wasm_path).expect("read compiled legacy EH fixture"); + let _ = fs::remove_file(wat_path); + let _ = fs::remove_file(wasm_path); + bytes +} + /// Walk every instruction sequence reachable from `seq` (including /// nested ones), invoking `visit(seq, depth, instr)` for each instr. /// Mirrored from tests/switch_dispatch.rs's `walk_all` so the two @@ -98,7 +135,10 @@ fn nested_of(instr: &Instr) -> Vec { match instr { Instr::Block(Block { seq }) => vec![*seq], Instr::Loop(Loop { seq }) => vec![*seq], - Instr::IfElse(IfElse { consequent, alternative }) => vec![*consequent, *alternative], + Instr::IfElse(IfElse { + consequent, + alternative, + }) => vec![*consequent, *alternative], Instr::TryTable(TryTable { seq, .. }) => vec![*seq], Instr::Try(Try { seq, .. }) => vec![*seq], _ => vec![], @@ -136,10 +176,12 @@ fn has_br_table_in(module: &Module, export_name: &str) -> bool { /// `_post_table` per fork-path function. #[allow(dead_code)] // used by the ignored trampoline_* tests in 2.3 fn has_table_with_prefix(module: &Module, prefix: &str) -> bool { - module - .tables - .iter() - .any(|t| t.name.as_deref().map(|n| n.starts_with(prefix)).unwrap_or(false)) + module.tables.iter().any(|t| { + t.name + .as_deref() + .map(|n| n.starts_with(prefix)) + .unwrap_or(false) + }) } // --------------------------------------------------------------------- @@ -250,8 +292,8 @@ fn nested_multivalue_params_uses_nested_switch_dispatch() { switch-dispatch (br_table emitted), not guard-dispatch" ); // Trampoline post-table is NOT emitted — switch-dispatch absorbs - // this case. The trampoline scaffolding stays reserved for - // unimplemented cases such as fork-from-legacy-catch. + // this case. Fork-reachable legacy catches are normalized to modern + // activation-owned EH before dispatch selection. assert!( !has_table_with_prefix(&module, "_start_post_table"), "post-2.6c: nested switch-dispatch absorbs multi-value-params; \ @@ -265,9 +307,8 @@ fn nested_multivalue_params_uses_nested_switch_dispatch() { // // 2026-05-17 CI disproved the "modern flags remove every legacy Try" // invariant for C ports such as bash, spidermonkey, and vim. A fork in the -// legacy try body can use the same per-region nested-switch route as -// Block/Loop/TryTable bodies. Legacy catch handlers still need their -// exception path reconstructed and remain unsupported. +// legacy try body and handlers use the same per-region nested-switch route as +// Block/Loop/TryTable after normalization to modern activation-owned EH. // // Note: the wat crate may not parse legacy try/catch on the host's // version (it's gated behind the legacy-EH feature). Skip cleanly @@ -276,10 +317,7 @@ fn nested_multivalue_params_uses_nested_switch_dispatch() { #[test] fn legacy_try_body_fork_uses_nested_switch_dispatch() { let wat = include_str!("fixtures/trampoline/legacy_try_fork.wat"); - let Some(input) = try_parse(wat) else { - eprintln!("skip: wat crate did not parse legacy try/catch fixture"); - return; - }; + let input = parse_legacy_wat(wat); let output = instrument(&input, &Options::default()).expect("instrument"); validate(&output); let module = Module::from_buffer(&output).expect("walrus parse"); @@ -290,16 +328,200 @@ fn legacy_try_body_fork_uses_nested_switch_dispatch() { ); } +#[test] +fn legacy_catch_handler_fork_is_lowered_to_activation_owned_modern_catch() { + let wat = include_str!("fixtures/trampoline/legacy_catch_fork.wat"); + let input = parse_legacy_wat(wat); + let output = instrument(&input, &Options::default()).expect("instrument legacy catch handler"); + validate(&output); + let module = Module::from_buffer(&output).expect("walrus parse"); + + assert!( + has_br_table_in(&module, "_start"), + "legacy catch-handler fork must route through nested switch replay", + ); + let start = module + .exports + .iter() + .find_map(|export| match export.item { + walrus::ExportItem::Function(id) if export.name == "_start" => Some(id), + _ => None, + }) + .expect("_start export"); + let start = match &module.funcs.get(start).kind { + walrus::FunctionKind::Local(local) => local, + _ => panic!("_start is not local"), + }; + let mut has_catch_ref = false; + let mut has_throw_ref = false; + let mut legacy_handlers = 0usize; + walk_all( + start, + start.entry_block(), + 0, + &mut |_, _, instr| match instr { + Instr::TryTable(table) => { + has_catch_ref |= table.catches.iter().any(|catch| { + matches!( + catch, + walrus::ir::TryTableCatch::CatchRef { .. } + | walrus::ir::TryTableCatch::CatchAllRef { .. } + ) + }); + } + Instr::ThrowRef(_) => has_throw_ref = true, + Instr::Try(legacy) => { + legacy_handlers += legacy + .catches + .iter() + .filter(|catch| !matches!(catch, walrus::ir::LegacyCatch::Delegate { .. })) + .count(); + } + _ => {} + }, + ); + assert_eq!( + legacy_handlers, 0, + "normalization must eliminate implicit legacy handler contexts before \ + continuation instrumentation", + ); + assert!(has_catch_ref, "legacy catch must lower through catch_ref"); + assert!( + has_throw_ref, + "rewind must reconstruct the caught exception with throw_ref", + ); +} + +#[test] +fn legacy_catch_all_handler_fork_uses_complete_exception_recipe() { + let input = parse_legacy_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (tag $failure) + (memory 1) + (func (export "_start") (result i32) + (try (result i32) + (do + throw $failure) + (catch_all + call $fork)))) + "#, + ); + let output = instrument(&input, &Options::default()).expect("instrument legacy catch_all"); + validate(&output); + let printed = wasmprinter::print_bytes(&output).expect("print instrumented legacy catch_all"); + assert!( + printed.contains("catch_all_ref"), + "legacy catch_all must capture the complete exception, including unknown tags", + ); + assert!( + printed.contains("throw_ref"), + "legacy catch_all rewind must replay the complete exception recipe", + ); +} + +#[test] +fn legacy_rethrow_after_fork_uses_owned_exception_and_clears_legacy_opcode() { + let input = parse_legacy_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (tag $number (param i32)) + (memory 1) + (func (export "_start") (result i32) + (try (result i32) + (do + (try (result i32) + (do + i32.const 73 + throw $number) + (catch $number + call $fork + drop + rethrow 0))) + (catch $number)))) + "#, + ); + let output = instrument(&input, &Options::default()).expect("instrument legacy rethrow"); + validate(&output); + let module = Module::from_buffer(&output).expect("parse normalized rethrow module"); + let start = module + .exports + .iter() + .find_map(|export| match export.item { + walrus::ExportItem::Function(id) if export.name == "_start" => Some(id), + _ => None, + }) + .expect("_start export"); + let start = match &module.funcs.get(start).kind { + walrus::FunctionKind::Local(local) => local, + _ => panic!("_start is not local"), + }; + let mut rethrows = 0usize; + let mut throw_refs = 0usize; + walk_all( + start, + start.entry_block(), + 0, + &mut |_, _, instr| match instr { + Instr::Rethrow(_) => rethrows += 1, + Instr::ThrowRef(_) => throw_refs += 1, + _ => {} + }, + ); + assert_eq!( + rethrows, 0, + "legacy implicit catch contexts must not survive normalization", + ); + assert!( + throw_refs >= 2, + "normalization and rewind must both use owned exnref throws", + ); +} + +#[test] +fn legacy_handler_br_table_exit_clears_owned_exception_through_typed_shim() { + let input = parse_legacy_wat( + r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (tag $number (param i32)) + (memory 1) + (func (export "_start") (result i32) + (block $done (result i32) + (try (result i32) + (do + i32.const 91 + throw $number) + (catch $number + call $fork + drop + i32.const 0 + br_table $done $done))))) + "#, + ); + let output = instrument(&input, &Options::default()).expect("instrument legacy br_table exit"); + validate(&output); + let printed = wasmprinter::print_bytes(&output).expect("print legacy br_table output"); + assert!( + printed.contains("br_table"), + "typed branch cleanup must preserve br_table rather than scalarizing dispatch", + ); + assert!( + printed.contains("ref.null exn"), + "the branch shim must clear its activation-local exception root", + ); +} + // --------------------------------------------------------------------- // (c) Nested call_indirect — empirically NOT a trampoline case // --------------------------------------------------------------------- // // Empirical finding (sub-commit 2.1): the simple nested call_indirect // case is already handled by nested switch-dispatch today. See the -// fixture's header comment for the explanation. The real class (c) -// trampoline gap is `call_indirect + another unsupported pattern` -// (e.g. carryover); a fixture for that lands in 2.5 once we audit -// which LLVM emission shapes actually trigger it. +// fixture's header comment for the explanation. Nested switch-dispatch now +// also owns call_indirect with typed carryovers. // // This test is a regression gate that nested call_indirect stays on // the switch-dispatch path. diff --git a/crates/shared/src/host_abi.rs b/crates/shared/src/host_abi.rs index 9801751bb9..d96092b04a 100644 --- a/crates/shared/src/host_abi.rs +++ b/crates/shared/src/host_abi.rs @@ -9,8 +9,7 @@ use core::mem::size_of; use crate::abi::extended_syscalls as extra_syscalls; use crate::{ - SCHED_AFFINITY_MASK_SIZE, Syscall, WASM_RUSAGE_WIRE_SIZE, WasmStat, WasmStatfs, - WasmTimespec, + SCHED_AFFINITY_MASK_SIZE, Syscall, WASM_RUSAGE_WIRE_SIZE, WasmStat, WasmStatfs, WasmTimespec, }; /// Direction of a marshalled pointer argument. @@ -330,10 +329,7 @@ pub const SYSCALL_ARG_DESCRIPTORS: &[SyscallArgDescriptor] = &[ entry!(Syscall::Sigsuspend as u32, [desc!(0, In, fixed!(8))]), entry!( Syscall::Pathconf as u32, - [ - desc!(0, In, cstring!()), - desc!(2, Out, fixed!(8), required), - ] + [desc!(0, In, cstring!()), desc!(2, Out, fixed!(8), required),] ), entry!( Syscall::Fpathconf as u32, @@ -625,8 +621,7 @@ mod tests { ); assert!(waitid[1].nullable); - let sched_getaffinity = - find(extra_syscalls::SYS_SCHED_GETAFFINITY).args[0]; + let sched_getaffinity = find(extra_syscalls::SYS_SCHED_GETAFFINITY).args[0]; assert_eq!(sched_getaffinity.arg_index, 2); assert_eq!(sched_getaffinity.direction, SyscallArgDirection::Out); assert_eq!( diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 190b63fc46..ddfa3346ee 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -84,7 +84,12 @@ pub mod host_abi; /// and fork exports return kernel-allocated identities; instrumented /// modules declare the continuation format and import reserve, commit, and /// replay hooks. -pub const ABI_VERSION: u32 = 42; +/// 43: fork artifacts prove activation-state safety explicitly. Tagged +/// CatchRef replay reconstructs an instance-local exception from +/// frame-owned tag identity and scalar payloads; non-transferable +/// reference, mutable-global, and mutable-table state is rejected before +/// launch. +pub const ABI_VERSION: u32 = 43; /// Byte width of Kandelo's Linux-compatible kernel CPU-affinity mask. /// @@ -847,7 +852,7 @@ pub mod mode { /// 8 48B arguments (6 × i64) /// 56 8B return value (i64) /// 64 4B errno (i32) -/// 68 4B reserved/pad +/// 68 4B request flags /// 72 64KB data transfer buffer pub mod channel { /// Byte offset of the status field (i32, atomic). @@ -864,6 +869,13 @@ pub mod channel { pub const RETURN_OFFSET: usize = 56; /// Byte offset of the errno field (i32). pub const ERRNO_OFFSET: usize = 64; + /// Byte offset of host/process request flags (u32). + pub const REQUEST_FLAGS_OFFSET: usize = 68; + /// The request completion is consumed by process-worker JavaScript, not + /// the libc channel trampoline. Caught signals must remain kernel-pending + /// until an explicit guest checkpoint can invoke the handler after the + /// owning host transition returns. + pub const REQUEST_FLAG_DEFER_SIGNAL_DELIVERY: u32 = 1 << 0; /// Byte offset of the data buffer region. pub const DATA_OFFSET: usize = 72; /// Size of the data buffer. @@ -1232,8 +1244,7 @@ pub mod process_memory { /// Size of one fork save buffer in bytes. The control prefix and buffer /// together occupy exactly one dedicated 64 KiB scratch page. - pub const FORK_SAVE_BUFFER_SIZE: u32 = - WASM_PAGE_SIZE - FORK_SAVE_CONTROL_PREFIX_SIZE; + pub const FORK_SAVE_BUFFER_SIZE: u32 = WASM_PAGE_SIZE - FORK_SAVE_CONTROL_PREFIX_SIZE; /// Main-thread fork-save/scratch page, relative to `controlBasePage`. pub const MAIN_FORK_SAVE_PAGE: u32 = 0; @@ -1286,6 +1297,11 @@ pub mod abi { pub enum ProgramArtifactValueType { Pointer, I32, + I64, + FuncRef, + ExternRef, + ExnRef, + AnyRef, } /// One required function import in an instrumented program artifact. @@ -1297,6 +1313,17 @@ pub mod abi { pub results: &'static [ProgramArtifactValueType], } + /// One required private table import in an instrumented program artifact. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct ProgramArtifactTableImport { + pub module: &'static str, + pub name: &'static str, + pub table64: bool, + pub element: ProgramArtifactValueType, + pub minimum: u64, + pub maximum: Option, + } + /// One required function export in an instrumented program artifact. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ProgramArtifactExport { @@ -1305,7 +1332,7 @@ pub mod abi { pub results: &'static [ProgramArtifactValueType], } - /// ABI 42 linked-continuation metadata and function surface. + /// ABI 42+ linked-continuation metadata and function surface. /// /// WHY this lives in `shared::abi`: these names and descriptor fields are /// consumed before a program starts, by the instrumenter, host, package @@ -1324,20 +1351,391 @@ pub mod abi { WPK_FORK_LINKED_FRAME_FLAG_TRANSACTIONAL_NODES | WPK_FORK_LINKED_FRAME_FLAG_ABORT_UNWINDING; pub const WPK_FORK_LINKED_FRAME_POINTER_WIDTHS: &[u8] = &[4, 8]; + /// ABI 43+ activation-owned module-state recipe format. + /// + /// WHY this is shared ABI rather than host-private metadata: an + /// instrumented activation writes the arena before the host copies linear + /// memory, and a fresh child instance validates and consumes it. Every + /// literal below therefore crosses the instrumenter/guest/host boundary. + pub const WPK_FORK_MODULE_STATE_FORMAT_SECTION: &str = "kandelo.wpk_fork.module_state"; + pub const WPK_FORK_MODULE_STATE_FORMAT_MAGIC: [u8; 4] = *b"KFMD"; + pub const WPK_FORK_MODULE_STATE_FORMAT_VERSION: u16 = 1; + pub const WPK_FORK_MODULE_STATE_DESCRIPTOR_SIZE: u16 = 24; + pub const WPK_FORK_MODULE_STATE_RECORD_ALIGNMENT: u8 = 8; + pub const WPK_FORK_MODULE_STATE_FLAG_ROOT_PREFIX_POINTER: u16 = 1 << 0; + pub const WPK_FORK_MODULE_STATE_FLAG_EXPLICIT_OWNERS: u16 = 1 << 1; + pub const WPK_FORK_MODULE_STATE_FLAG_SPARSE_TABLES: u16 = 1 << 2; + pub const WPK_FORK_MODULE_STATE_REQUIRED_FLAGS: u16 = + WPK_FORK_MODULE_STATE_FLAG_ROOT_PREFIX_POINTER + | WPK_FORK_MODULE_STATE_FLAG_EXPLICIT_OWNERS + | WPK_FORK_MODULE_STATE_FLAG_SPARSE_TABLES; + pub const WPK_FORK_MODULE_STATE_KNOWN_FLAGS: u16 = WPK_FORK_MODULE_STATE_REQUIRED_FLAGS; + pub const WPK_FORK_MODULE_STATE_ARENA_VERSION: u16 = 1; + pub const WPK_FORK_MODULE_STATE_RECORD_VERSION: u16 = 1; + pub const WPK_FORK_MODULE_STATE_ROOT_POINTER_WORD_OFFSET: u32 = 1; + pub const WPK_FORK_MODULE_STATE_POINTER_WIDTHS: &[u8] = &[4, 8]; + + pub const WPK_FORK_MODULE_STATE_CHUNK_MAGIC: [u8; 4] = *b"KFMC"; + pub const WPK_FORK_MODULE_STATE_CHUNK_FLAG_ROOT: u16 = 1 << 0; + pub const WPK_FORK_MODULE_STATE_CHUNK_FLAG_SEALED: u16 = 1 << 1; + pub const WPK_FORK_MODULE_STATE_CHUNK_KNOWN_FLAGS: u16 = + WPK_FORK_MODULE_STATE_CHUNK_FLAG_ROOT | WPK_FORK_MODULE_STATE_CHUNK_FLAG_SEALED; + + pub const WPK_FORK_MODULE_STATE_RECORD_MAGIC: [u8; 4] = *b"KFMR"; + pub const WPK_FORK_MODULE_STATE_RECORD_HEADER_SIZE: u16 = 24; + pub const WPK_FORK_MODULE_STATE_RECORD_KIND_MODULE: u16 = 1; + pub const WPK_FORK_MODULE_STATE_RECORD_KIND_REFERENCE_RECIPE: u16 = 2; + pub const WPK_FORK_MODULE_STATE_RECORD_KIND_MUTABLE_GLOBAL: u16 = 3; + pub const WPK_FORK_MODULE_STATE_RECORD_KIND_TABLE: u16 = 4; + pub const WPK_FORK_MODULE_STATE_RECORD_KIND_TABLE_PAGE: u16 = 5; + pub const WPK_FORK_MODULE_STATE_RECORD_KIND_ELEMENT_SEGMENTS: u16 = 6; + pub const WPK_FORK_MODULE_STATE_RECORD_KIND_DATA_SEGMENTS: u16 = 7; + pub const WPK_FORK_MODULE_STATE_RECORD_KIND_REPLAY_EVENTS: u16 = 8; + pub const WPK_FORK_MODULE_STATE_RECORD_KIND_IMPORTED_GLOBAL_BINDINGS: u16 = 9; + pub const WPK_FORK_MODULE_STATE_RECORD_KIND_ACTIVATION_CONTINUATIONS: u16 = 10; + pub const WPK_FORK_MODULE_STATE_RECORD_KIND_IMPORTED_TABLE_BINDINGS: u16 = 11; + pub const WPK_FORK_MODULE_STATE_RECORD_KIND_REFERENCE_RECIPE_SEGMENT: u16 = 12; + pub const WPK_FORK_MODULE_STATE_RECORD_KIND_REPLAY_EVENT_SEGMENT: u16 = 13; + + /// One recognized module-state arena record kind. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct ForkModuleStateRecordKind { + pub number: u16, + pub name: &'static str, + } + + pub const WPK_FORK_MODULE_STATE_RECORD_KINDS: &[ForkModuleStateRecordKind] = &[ + ForkModuleStateRecordKind { + number: WPK_FORK_MODULE_STATE_RECORD_KIND_MODULE, + name: "module", + }, + ForkModuleStateRecordKind { + number: WPK_FORK_MODULE_STATE_RECORD_KIND_REFERENCE_RECIPE, + name: "reference_recipe", + }, + ForkModuleStateRecordKind { + number: WPK_FORK_MODULE_STATE_RECORD_KIND_MUTABLE_GLOBAL, + name: "mutable_global", + }, + ForkModuleStateRecordKind { + number: WPK_FORK_MODULE_STATE_RECORD_KIND_TABLE, + name: "table", + }, + ForkModuleStateRecordKind { + number: WPK_FORK_MODULE_STATE_RECORD_KIND_TABLE_PAGE, + name: "table_page", + }, + ForkModuleStateRecordKind { + number: WPK_FORK_MODULE_STATE_RECORD_KIND_ELEMENT_SEGMENTS, + name: "element_segments", + }, + ForkModuleStateRecordKind { + number: WPK_FORK_MODULE_STATE_RECORD_KIND_DATA_SEGMENTS, + name: "data_segments", + }, + ForkModuleStateRecordKind { + number: WPK_FORK_MODULE_STATE_RECORD_KIND_REPLAY_EVENTS, + name: "replay_events", + }, + ForkModuleStateRecordKind { + number: WPK_FORK_MODULE_STATE_RECORD_KIND_IMPORTED_GLOBAL_BINDINGS, + name: "imported_global_bindings", + }, + ForkModuleStateRecordKind { + number: WPK_FORK_MODULE_STATE_RECORD_KIND_ACTIVATION_CONTINUATIONS, + name: "activation_continuations", + }, + ForkModuleStateRecordKind { + number: WPK_FORK_MODULE_STATE_RECORD_KIND_IMPORTED_TABLE_BINDINGS, + name: "imported_table_bindings", + }, + ForkModuleStateRecordKind { + number: WPK_FORK_MODULE_STATE_RECORD_KIND_REFERENCE_RECIPE_SEGMENT, + name: "reference_recipe_segment", + }, + ForkModuleStateRecordKind { + number: WPK_FORK_MODULE_STATE_RECORD_KIND_REPLAY_EVENT_SEGMENT, + name: "replay_event_segment", + }, + ]; + + pub const WPK_FORK_MODULE_STATE_MODULE_TEMPLATE_ID_SIZE: u16 = 32; + pub const WPK_FORK_MODULE_STATE_MODULE_RECORD_PAYLOAD_SIZE: u16 = + WPK_FORK_MODULE_STATE_MODULE_TEMPLATE_ID_SIZE + 8; + pub const WPK_FORK_MODULE_STATE_MODULE_RECORD_KNOWN_FLAGS: u32 = 0; + pub const WPK_FORK_MODULE_STATE_GLOBAL_HEADER_SIZE: u16 = 8; + pub const WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I32: u8 = 1; + pub const WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I64: u8 = 2; + pub const WPK_FORK_MODULE_STATE_GLOBAL_TYPE_F32: u8 = 3; + pub const WPK_FORK_MODULE_STATE_GLOBAL_TYPE_F64: u8 = 4; + pub const WPK_FORK_MODULE_STATE_GLOBAL_TYPE_V128: u8 = 5; + pub const WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF: u8 = 6; + pub const WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF: u8 = 7; + pub const WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF: u8 = 8; + pub const WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF: u8 = 9; + pub const WPK_FORK_MODULE_STATE_TABLE_BASELINE_FINGERPRINT_SIZE: u16 = 32; + pub const WPK_FORK_MODULE_STATE_TABLE_DESCRIPTOR_PAYLOAD_SIZE: u16 = + WPK_FORK_MODULE_STATE_TABLE_BASELINE_FINGERPRINT_SIZE + 24; + pub const WPK_FORK_MODULE_STATE_TABLE_FLAG_SPARSE_OVERRIDES: u32 = 1 << 0; + pub const WPK_FORK_MODULE_STATE_TABLE_KNOWN_FLAGS: u32 = + WPK_FORK_MODULE_STATE_TABLE_FLAG_SPARSE_OVERRIDES; + pub const WPK_FORK_MODULE_STATE_TABLE_PAGE_HEADER_SIZE: u16 = 16; + pub const WPK_FORK_MODULE_STATE_TABLE_RUN_HEADER_SIZE: u16 = 8; + pub const WPK_FORK_MODULE_STATE_ELEMENT_SEGMENT_HEADER_SIZE: u16 = 8; + pub const WPK_FORK_MODULE_STATE_DATA_SEGMENT_HEADER_SIZE: u16 = 8; + pub const WPK_FORK_MODULE_STATE_REPLAY_EVENTS_OWNER: u32 = 1; + pub const WPK_FORK_MODULE_STATE_REPLAY_EVENTS_MAGIC: [u8; 4] = *b"KFRE"; + pub const WPK_FORK_MODULE_STATE_REPLAY_EVENTS_VERSION: u16 = 2; + pub const WPK_FORK_MODULE_STATE_REPLAY_EVENTS_HEADER_SIZE: u16 = 40; + pub const WPK_FORK_MODULE_STATE_REPLAY_EVENT_SIZE: u16 = 8; + pub const WPK_FORK_MODULE_STATE_REPLAY_EVENTS_KNOWN_FLAGS: u16 = 0; + pub const WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_VERSION: u16 = 1; + pub const WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_HEADER_SIZE: u16 = 24; + pub const WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_CAPACITY: u32 = 4080; + pub const WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_KNOWN_FLAGS: u16 = 0; + pub const WPK_FORK_REFERENCE_TRANSACTION_OWNER: u32 = 1; + pub const WPK_FORK_REFERENCE_TRANSACTION_MAGIC: [u8; 4] = *b"KFRV"; + pub const WPK_FORK_REFERENCE_TRANSACTION_VERSION: u16 = 2; + pub const WPK_FORK_REFERENCE_TRANSACTION_MANIFEST_SIZE: u16 = 96; + pub const WPK_FORK_REFERENCE_TRANSACTION_FLAG_SEALED: u32 = 1 << 0; + pub const WPK_FORK_REFERENCE_TRANSACTION_KNOWN_FLAGS: u32 = + WPK_FORK_REFERENCE_TRANSACTION_FLAG_SEALED; + pub const WPK_FORK_REFERENCE_SEGMENT_MAGIC: [u8; 4] = *b"KFRS"; + pub const WPK_FORK_REFERENCE_SEGMENT_HEADER_SIZE: u16 = 40; + pub const WPK_FORK_REFERENCE_SEGMENT_KNOWN_FLAGS: u16 = 0; + pub const WPK_FORK_REFERENCE_NODE_RECORD_SIZE: u16 = 48; + pub const WPK_FORK_REFERENCE_VECTOR_INDEX_SIZE: u16 = 16; + pub const WPK_FORK_REFERENCE_SECTION_NODES: u16 = 1; + pub const WPK_FORK_REFERENCE_SECTION_EDGES: u16 = 2; + pub const WPK_FORK_REFERENCE_SECTION_SCALARS: u16 = 3; + pub const WPK_FORK_REFERENCE_SECTION_VECTOR_INDEX: u16 = 4; + pub const WPK_FORK_REFERENCE_SECTION_VECTOR_ENTRIES: u16 = 5; + pub const WPK_FORK_IMPORTED_GLOBAL_BINDINGS_OWNER: u32 = 2; + pub const WPK_FORK_IMPORTED_GLOBAL_BINDINGS_MAGIC: [u8; 4] = *b"KFBG"; + pub const WPK_FORK_IMPORTED_GLOBAL_BINDINGS_VERSION: u16 = 1; + pub const WPK_FORK_IMPORTED_GLOBAL_BINDINGS_HEADER_SIZE: u16 = 24; + pub const WPK_FORK_IMPORTED_GLOBAL_BINDINGS_ENTRY_SIZE: u16 = 40; + pub const WPK_FORK_IMPORTED_GLOBAL_BINDINGS_KNOWN_FLAGS: u16 = 0; + pub const WPK_FORK_IMPORTED_GLOBAL_BINDING_RAW_NUMBER: u8 = 1; + pub const WPK_FORK_IMPORTED_GLOBAL_BINDING_RAW_BIGINT: u8 = 2; + pub const WPK_FORK_IMPORTED_GLOBAL_BINDING_RAW_REFERENCE: u8 = 3; + pub const WPK_FORK_IMPORTED_GLOBAL_BINDING_ACTIVATION_GLOBAL: u8 = 4; + pub const WPK_FORK_IMPORTED_GLOBAL_BINDING_BASE_IMPORT: u8 = 5; + pub const WPK_FORK_GLOBAL_CATALOG_EXPORT_PREFIX: &str = "__wpk_fork_global_"; + pub const WPK_FORK_ACTIVATION_CONTINUATIONS_OWNER: u32 = 3; + pub const WPK_FORK_ACTIVATION_CONTINUATIONS_MAGIC: [u8; 4] = *b"KFAC"; + pub const WPK_FORK_ACTIVATION_CONTINUATIONS_VERSION: u16 = 1; + pub const WPK_FORK_ACTIVATION_CONTINUATIONS_HEADER_SIZE: u16 = 24; + pub const WPK_FORK_ACTIVATION_CONTINUATION_ENTRY_SIZE: u16 = 16; + pub const WPK_FORK_ACTIVATION_CONTINUATIONS_KNOWN_FLAGS: u16 = 0; + pub const WPK_FORK_ACTIVATION_CONTINUATION_ENTRY_KNOWN_FLAGS: u32 = 0; + pub const WPK_FORK_IMPORTED_TABLE_BINDINGS_OWNER: u32 = 4; + pub const WPK_FORK_IMPORTED_TABLE_BINDINGS_MAGIC: [u8; 4] = *b"KFBT"; + pub const WPK_FORK_IMPORTED_TABLE_BINDINGS_VERSION: u16 = 1; + pub const WPK_FORK_IMPORTED_TABLE_BINDINGS_HEADER_SIZE: u16 = 24; + pub const WPK_FORK_IMPORTED_TABLE_BINDINGS_ENTRY_SIZE: u16 = 24; + pub const WPK_FORK_IMPORTED_TABLE_BINDINGS_KNOWN_FLAGS: u16 = 0; + pub const WPK_FORK_IMPORTED_TABLE_BINDING_ACTIVATION_TABLE: u8 = 1; + pub const WPK_FORK_IMPORTED_TABLE_BINDING_BASE_IMPORT: u8 = 2; + pub const WPK_FORK_TABLE_CATALOG_EXPORT_PREFIX: &str = "__wpk_fork_table_"; + pub const WPK_FORK_MODULE_STATE_MIN_TABLE_PAGE_SHIFT: u8 = 4; + pub const WPK_FORK_MODULE_STATE_MAX_TABLE_PAGE_SHIFT: u8 = 20; + /// Exact sparse-page geometry emitted by the ABI 43 instrumenter. + pub const WPK_FORK_MODULE_STATE_TABLE_PAGE_SHIFT: u8 = 10; + + /// ABI 43 imported-global ownership metadata. A fresh child consumes this + /// before module instantiation, which is earlier than KFMS restore and is + /// therefore the only phase that can preserve immutable exports and + /// constant initializers that observe imported globals. + pub const WPK_FORK_IMPORTED_GLOBALS_SECTION: &str = "kandelo.wpk_fork.imported_globals"; + pub const WPK_FORK_IMPORTED_GLOBALS_MAGIC: [u8; 4] = *b"KFIG"; + pub const WPK_FORK_IMPORTED_GLOBALS_VERSION: u16 = 1; + pub const WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE: u16 = 16; + pub const WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE: u16 = 24; + pub const WPK_FORK_IMPORTED_GLOBAL_FLAG_MUTABLE: u8 = 1 << 0; + pub const WPK_FORK_IMPORTED_GLOBAL_FLAG_SHARED: u8 = 1 << 1; + pub const WPK_FORK_IMPORTED_GLOBAL_KNOWN_FLAGS: u8 = + WPK_FORK_IMPORTED_GLOBAL_FLAG_MUTABLE | WPK_FORK_IMPORTED_GLOBAL_FLAG_SHARED; + pub const WPK_FORK_IMPORTED_TABLES_SECTION: &str = "kandelo.wpk_fork.imported_tables"; + pub const WPK_FORK_IMPORTED_TABLES_MAGIC: [u8; 4] = *b"KFIT"; + pub const WPK_FORK_IMPORTED_TABLES_VERSION: u16 = 1; + pub const WPK_FORK_IMPORTED_TABLES_HEADER_SIZE: u16 = 16; + pub const WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE: u16 = 24; + pub const WPK_FORK_IMPORTED_TABLE_FLAG_TABLE64: u8 = 1 << 0; + pub const WPK_FORK_IMPORTED_TABLE_KNOWN_FLAGS: u8 = WPK_FORK_IMPORTED_TABLE_FLAG_TABLE64; + + /// ABI 43 structural Wasm GC reconstruction catalog. + /// + /// GC object identities cannot cross Store or worker boundaries. The + /// catalog lets the fresh child allocate the same typed object graph and + /// then fill its scalar and reference fields without retaining parent + /// instance references. + pub const WPK_FORK_GC_CODEC_SECTION: &str = "kandelo.wpk_fork.gc_codec"; + pub const WPK_FORK_GC_CODEC_MAGIC: [u8; 4] = *b"KFGC"; + pub const WPK_FORK_GC_CODEC_VERSION: u16 = 1; + pub const WPK_FORK_GC_CODEC_HEADER_SIZE: u16 = 16; + pub const WPK_FORK_GC_CODEC_LAYOUT_RECORD_SIZE: u16 = 44; + pub const WPK_FORK_GC_CODEC_FIELD_RECORD_SIZE: u16 = 12; + + /// ABI 43 exact-tag exception reconstruction catalog. + /// + /// A tag is instance-local, so copied `exnref` values cannot be replayed + /// by importing a JavaScript-side reference. The instrumented activation + /// publishes deterministic tag ordinals and payload layouts instead. The + /// fresh child reconstructs each exception using its own corresponding tag. + pub const WPK_FORK_EXCEPTION_CODEC_SECTION: &str = "kandelo.wpk_fork.exception_codec"; + pub const WPK_FORK_EXCEPTION_CODEC_VERSION: u8 = 1; + pub const WPK_FORK_EXCEPTION_CODEC_HEADER_SIZE: u16 = 8; + pub const WPK_FORK_EXCEPTION_CODEC_TAG_RECORD_SIZE: u16 = 16; + + /// Private zero-payload tag used to unwind instrumented Wasm without + /// manufacturing values for arbitrary function result types. + pub const WPK_FORK_UNWIND_TAG_IMPORT_MODULE: &str = "env"; + pub const WPK_FORK_UNWIND_TAG_IMPORT_NAME: &str = "__wpk_fork_unwind"; + pub const WPK_FORK_UNWIND_TRANSPORT_SECTION: &str = "kandelo.wpk_fork.unwind_transport"; + pub const WPK_FORK_UNWIND_TRANSPORT_VERSION: u8 = 1; + pub const WPK_FORK_UNWIND_TRANSPORT_PAYLOAD_ARITY: u8 = 0; + + /// Fixed instance-local identities recreated by static initializers. + pub const WPK_FORK_STATIC_ROOT_CATALOG_EXPORT: &str = "__wpk_fork_static_root_catalog"; + pub const WPK_FORK_STATIC_ROOT_CATALOG_SECTION: &str = "kandelo.wpk_fork.static_root_catalog"; + pub const WPK_FORK_STATIC_ROOT_CATALOG_MAGIC: [u8; 4] = *b"KFSR"; + pub const WPK_FORK_STATIC_ROOT_CATALOG_VERSION: u16 = 1; + pub const WPK_FORK_STATIC_ROOT_CATALOG_HEADER_SIZE: u16 = 12; + pub const WPK_FORK_STATIC_ROOT_HARVEST_EXPORT: &str = "__wpk_fork_static_root_harvest"; + + /// Versioned instrumentation claims required by ABI 43. + /// + /// Role flags say which call graph was transformed. In ABI 43, + /// `SIDE_ENTRY` means complete side-module boundary coverage: callers of + /// every function import and unresolved function-reference dispatch are + /// resumable even if fork occurs in a different module. The + /// activation-state bit proves all replay state has a fresh-instance + /// reconstruction owner. + pub const WPK_FORK_CAPABILITIES_SECTION: &str = "kandelo.wpk_fork.capabilities"; + pub const WPK_FORK_CAPABILITIES_VERSION: u8 = 1; + pub const WPK_FORK_CAP_SIDE_ENTRY: u8 = 1 << 0; + pub const WPK_FORK_CAP_DYLINK_MAIN: u8 = 1 << 1; + pub const WPK_FORK_CAP_ACTIVATION_STATE_SAFE: u8 = 1 << 2; + pub const WPK_FORK_CAP_KNOWN_MASK: u8 = + WPK_FORK_CAP_SIDE_ENTRY | WPK_FORK_CAP_DYLINK_MAIN | WPK_FORK_CAP_ACTIVATION_STATE_SAFE; + pub const WPK_FORK_CAP_REQUIRED_FLAGS: u8 = WPK_FORK_CAP_ACTIVATION_STATE_SAFE; + pub const WPK_FORK_FRAME_IMPORT_MODULE: &str = "env"; pub const WPK_FORK_FRAME_IMPORT_RESERVE: &str = "__wpk_fork_frame_reserve"; pub const WPK_FORK_FRAME_IMPORT_COMMIT: &str = "__wpk_fork_frame_commit"; pub const WPK_FORK_FRAME_IMPORT_NEXT: &str = "__wpk_fork_frame_next"; + pub const WPK_FORK_FRAME_IMPORT_PEEK: &str = "__wpk_fork_frame_peek"; + pub const WPK_FORK_RESUME_IMPORT_PEEK: &str = "__wpk_fork_resume_peek"; + pub const WPK_FORK_RESUME_IMPORT_TABLE: &str = "__wpk_fork_resume_table"; + + pub const WPK_FORK_MODULE_STATE_IMPORT_MODULE: &str = "env"; + pub const WPK_FORK_MODULE_STATE_IMPORT_RECORD_COMMIT: &str = + "__wpk_fork_module_state_record_commit"; + pub const WPK_FORK_MODULE_STATE_IMPORT_RECORD_FIND: &str = + "__wpk_fork_module_state_record_find"; + pub const WPK_FORK_MODULE_STATE_IMPORT_RECORD_RESERVE: &str = + "__wpk_fork_module_state_record_reserve"; + pub const WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_COUNT: &str = + "__wpk_fork_module_state_table_dirty_count"; + pub const WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_MARK: &str = + "__wpk_fork_module_state_table_dirty_mark"; + pub const WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_PAGE: &str = + "__wpk_fork_module_state_table_dirty_page"; + pub const WPK_FORK_MODULE_STATE_IMPORT_TABLE_STATE_OWNED: &str = + "__wpk_fork_module_state_table_state_owned"; + pub const WPK_FORK_MODULE_STATE_IMPORT_TABLE_MUTATION_BEGIN: &str = + "__wpk_fork_module_state_table_mutation_begin"; + pub const WPK_FORK_MODULE_STATE_IMPORT_TABLE_MUTATION_COMMIT: &str = + "__wpk_fork_module_state_table_mutation_commit"; + pub const WPK_FORK_MODULE_STATE_IMPORT_TABLE_MUTATION_ABORT: &str = + "__wpk_fork_module_state_table_mutation_abort"; + pub const WPK_FORK_MODULE_STATE_IMPORT_TABLE_RECONCILE: &str = + "__wpk_fork_module_state_table_reconcile"; + pub const WPK_FORK_MODULE_STATE_IMPORT_TABLE_GENERATION_ADDR: &str = + "__wpk_fork_module_state_table_generation_addr"; + + pub const WPK_FORK_EXCEPTION_CODEC_IMPORT_MODULE: &str = "env"; + pub const WPK_FORK_EXCEPTION_IMPORT_ACTIVATION: &str = "__wpk_fork_module_activation"; + pub const WPK_FORK_EXCEPTION_IMPORT_BROKER_ENCODE: &str = "__wpk_fork_ref_exn_broker_encode"; + pub const WPK_FORK_EXCEPTION_IMPORT_BROKER_THROW_RECIPE: &str = + "__wpk_fork_ref_exn_broker_throw_recipe"; + pub const WPK_FORK_EXCEPTION_IMPORT_CACHE_INDEX: &str = "__wpk_fork_ref_exn_cache_index"; + pub const WPK_FORK_EXCEPTION_IMPORT_CLAIM: &str = "__wpk_fork_ref_exn_claim"; + pub const WPK_FORK_EXCEPTION_IMPORT_DEFINE: &str = "__wpk_fork_ref_exn_define"; + pub const WPK_FORK_EXCEPTION_IMPORT_INGRESS_THROW: &str = "__wpk_fork_ref_exn_ingress_throw"; + pub const WPK_FORK_EXCEPTION_IMPORT_LOAD: &str = "__wpk_fork_ref_exn_load"; + pub const WPK_FORK_EXCEPTION_IMPORT_LOOKUP: &str = "__wpk_fork_ref_exn_lookup"; + pub const WPK_FORK_EXCEPTION_IMPORT_ROUTE: &str = "__wpk_fork_ref_exn_route"; + + pub const WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE: &str = "env"; + pub const WPK_FORK_REFERENCE_IMPORT_DECODE_ANYREF: &str = "__wpk_fork_ref_decode_anyref"; + pub const WPK_FORK_REFERENCE_IMPORT_DECODE_EXNREF: &str = "__wpk_fork_ref_decode_exnref"; + pub const WPK_FORK_REFERENCE_IMPORT_DECODE_EXTERNREF: &str = "__wpk_fork_ref_decode_externref"; + pub const WPK_FORK_REFERENCE_IMPORT_DECODE_FUNCREF: &str = "__wpk_fork_ref_decode_funcref"; + pub const WPK_FORK_REFERENCE_IMPORT_ENCODE_ANYREF: &str = "__wpk_fork_ref_encode_anyref"; + pub const WPK_FORK_REFERENCE_IMPORT_ENCODE_EXNREF: &str = "__wpk_fork_ref_encode_exnref"; + pub const WPK_FORK_REFERENCE_IMPORT_ENCODE_EXTERNREF: &str = "__wpk_fork_ref_encode_externref"; + pub const WPK_FORK_REFERENCE_IMPORT_ENCODE_FUNCREF: &str = "__wpk_fork_ref_encode_funcref"; + pub const WPK_FORK_REFERENCE_IMPORT_GC_BROKER_ENCODE: &str = "__wpk_fork_ref_gc_broker_encode"; + pub const WPK_FORK_REFERENCE_IMPORT_GC_CAPTURE_LAYOUT: &str = + "__wpk_fork_ref_gc_capture_layout"; + pub const WPK_FORK_REFERENCE_IMPORT_GC_CLAIM: &str = "__wpk_fork_ref_gc_claim"; + pub const WPK_FORK_REFERENCE_IMPORT_GC_DEFINE: &str = "__wpk_fork_ref_gc_define"; + pub const WPK_FORK_REFERENCE_IMPORT_GC_I31: &str = "__wpk_fork_ref_gc_i31"; + pub const WPK_FORK_REFERENCE_IMPORT_GC_LOAD: &str = "__wpk_fork_ref_gc_load"; + pub const WPK_FORK_REFERENCE_IMPORT_GC_LOOKUP: &str = "__wpk_fork_ref_gc_lookup"; + pub const WPK_FORK_REFERENCE_IMPORT_GC_PAYLOAD_LEN: &str = "__wpk_fork_ref_gc_payload_len"; + pub const WPK_FORK_REFERENCE_IMPORT_GC_PROVENANCE_BEGIN: &str = + "__wpk_fork_ref_gc_provenance_begin"; + pub const WPK_FORK_REFERENCE_IMPORT_GC_PROVENANCE_END: &str = + "__wpk_fork_ref_gc_provenance_end"; + pub const WPK_FORK_REFERENCE_IMPORT_GC_PROVENANCE_REF: &str = + "__wpk_fork_ref_gc_provenance_ref"; + pub const WPK_FORK_REFERENCE_IMPORT_GC_ROUTE: &str = "__wpk_fork_ref_gc_route"; + pub const WPK_FORK_REFERENCE_IMPORT_GC_TRANSIT: &str = "__wpk_fork_ref_gc_transit"; + pub const WPK_FORK_REFERENCE_IMPORT_SCRATCH_RELEASE: &str = "__wpk_fork_ref_scratch_release"; + pub const WPK_FORK_REFERENCE_IMPORT_SCRATCH_RESERVE: &str = "__wpk_fork_ref_scratch_reserve"; + pub const WPK_FORK_REFERENCE_IMPORT_VECTOR_APPEND: &str = "__wpk_fork_ref_vector_append"; + pub const WPK_FORK_REFERENCE_IMPORT_VECTOR_BEGIN: &str = "__wpk_fork_ref_vector_begin"; + pub const WPK_FORK_REFERENCE_IMPORT_VECTOR_FINISH: &str = "__wpk_fork_ref_vector_finish"; + pub const WPK_FORK_REFERENCE_IMPORT_VECTOR_GET: &str = "__wpk_fork_ref_vector_get"; + + pub const WPK_FORK_EXCEPTION_EXPORT_DECODE: &str = "__wpk_fork_ref_decode_exnref"; + pub const WPK_FORK_EXCEPTION_EXPORT_ENCODE: &str = "__wpk_fork_ref_encode_exnref"; + pub const WPK_FORK_EXCEPTION_EXPORT_ABORT: &str = "__wpk_fork_ref_exn_abort"; + pub const WPK_FORK_EXCEPTION_EXPORT_CLEAR: &str = "__wpk_fork_ref_exn_clear"; + pub const WPK_FORK_EXCEPTION_EXPORT_ENCODE_INGRESS: &str = "__wpk_fork_ref_exn_encode_ingress"; + pub const WPK_FORK_EXCEPTION_EXPORT_MATERIALIZE: &str = "__wpk_fork_exception_materialize"; + pub const WPK_FORK_EXCEPTION_EXPORT_THROW_RECIPE: &str = "__wpk_fork_ref_exn_throw_recipe"; + pub const WPK_FORK_EXCEPTION_EXPORT_THROW_SLOT: &str = "__wpk_fork_ref_exn_throw_slot"; + pub const WPK_FORK_REFERENCE_EXPORT_GC_ALLOCATE: &str = "__wpk_fork_ref_gc_allocate"; + pub const WPK_FORK_REFERENCE_EXPORT_GC_ENCODE_SLOT: &str = "__wpk_fork_ref_gc_encode_slot"; + pub const WPK_FORK_REFERENCE_EXPORT_GC_FILL: &str = "__wpk_fork_ref_gc_fill"; + pub const WPK_FORK_REFERENCE_EXPORT_GC_PUBLISH_EXTERNREF: &str = + "__wpk_fork_ref_gc_publish_externref"; + pub const WPK_FORK_REFERENCE_EXPORT_GC_PROBE: &str = "__wpk_fork_ref_gc_probe"; pub const WPK_FORK_EXPORT_ABORT_BEGIN: &str = "wpk_fork_abort_begin"; pub const WPK_FORK_EXPORT_ABORT_END: &str = "wpk_fork_abort_end"; + pub const WPK_FORK_EXPORT_MODULE_BOOTSTRAP: &str = "wpk_fork_module_bootstrap"; + pub const WPK_FORK_EXPORT_MODULE_THREAD_BOOTSTRAP: &str = "wpk_fork_module_thread_bootstrap"; + pub const WPK_FORK_EXPORT_MODULE_STATE_FINISH_RESTORE: &str = + "wpk_fork_module_state_finish_restore"; + pub const WPK_FORK_EXPORT_MODULE_STATE_RESTORE: &str = "wpk_fork_module_state_restore"; + pub const WPK_FORK_EXPORT_MODULE_STATE_SAVE: &str = "wpk_fork_module_state_save"; + pub const WPK_FORK_EXPORT_MODULE_TABLE_STATE_SAVE: &str = "wpk_fork_module_table_state_save"; + pub const WPK_FORK_EXPORT_MODULE_TABLE_STATE_RESTORE: &str = + "wpk_fork_module_table_state_restore"; + pub const WPK_FORK_EXPORT_RESUME_START: &str = "wpk_fork_resume_start"; + pub const WPK_FORK_EXPORT_RESUME_THREAD: &str = "wpk_fork_resume_thread"; pub const WPK_FORK_EXPORT_REWIND_BEGIN: &str = "wpk_fork_rewind_begin"; pub const WPK_FORK_EXPORT_REWIND_END: &str = "wpk_fork_rewind_end"; pub const WPK_FORK_EXPORT_STATE: &str = "wpk_fork_state"; pub const WPK_FORK_EXPORT_UNWIND_BEGIN: &str = "wpk_fork_unwind_begin"; pub const WPK_FORK_EXPORT_UNWIND_END: &str = "wpk_fork_unwind_end"; - use ProgramArtifactValueType::{I32, Pointer}; + use ProgramArtifactValueType::{AnyRef, ExnRef, ExternRef, FuncRef, I32, I64, Pointer}; pub const WPK_FORK_REQUIRED_IMPORTS: &[ProgramArtifactImport] = &[ ProgramArtifactImport { @@ -1352,15 +1750,356 @@ pub mod abi { params: &[Pointer], results: &[Pointer], }, + ProgramArtifactImport { + module: WPK_FORK_FRAME_IMPORT_MODULE, + name: WPK_FORK_FRAME_IMPORT_PEEK, + params: &[Pointer], + results: &[Pointer], + }, ProgramArtifactImport { module: WPK_FORK_FRAME_IMPORT_MODULE, name: WPK_FORK_FRAME_IMPORT_RESERVE, params: &[Pointer], results: &[Pointer], }, + ProgramArtifactImport { + module: WPK_FORK_MODULE_STATE_IMPORT_MODULE, + name: WPK_FORK_MODULE_STATE_IMPORT_RECORD_COMMIT, + params: &[Pointer], + results: &[], + }, + ProgramArtifactImport { + module: WPK_FORK_MODULE_STATE_IMPORT_MODULE, + name: WPK_FORK_MODULE_STATE_IMPORT_RECORD_FIND, + params: &[I32, I32, I32, I32], + results: &[Pointer], + }, + ProgramArtifactImport { + module: WPK_FORK_MODULE_STATE_IMPORT_MODULE, + name: WPK_FORK_MODULE_STATE_IMPORT_RECORD_RESERVE, + params: &[I32, I32, I32, Pointer], + results: &[Pointer], + }, + ProgramArtifactImport { + module: WPK_FORK_MODULE_STATE_IMPORT_MODULE, + name: WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_COUNT, + params: &[I32], + results: &[I32], + }, + ProgramArtifactImport { + module: WPK_FORK_MODULE_STATE_IMPORT_MODULE, + name: WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_MARK, + params: &[I32, I64, I64], + results: &[], + }, + ProgramArtifactImport { + module: WPK_FORK_MODULE_STATE_IMPORT_MODULE, + name: WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_PAGE, + params: &[I32, I32], + results: &[I64], + }, + ProgramArtifactImport { + module: WPK_FORK_MODULE_STATE_IMPORT_MODULE, + name: WPK_FORK_MODULE_STATE_IMPORT_TABLE_MUTATION_ABORT, + params: &[], + results: &[], + }, + ProgramArtifactImport { + module: WPK_FORK_MODULE_STATE_IMPORT_MODULE, + name: WPK_FORK_MODULE_STATE_IMPORT_TABLE_MUTATION_BEGIN, + params: &[], + results: &[I64], + }, + ProgramArtifactImport { + module: WPK_FORK_MODULE_STATE_IMPORT_MODULE, + name: WPK_FORK_MODULE_STATE_IMPORT_TABLE_MUTATION_COMMIT, + params: &[I32, I64, I64], + results: &[], + }, + ProgramArtifactImport { + module: WPK_FORK_MODULE_STATE_IMPORT_MODULE, + name: WPK_FORK_MODULE_STATE_IMPORT_TABLE_RECONCILE, + params: &[], + results: &[I64], + }, + ProgramArtifactImport { + module: WPK_FORK_MODULE_STATE_IMPORT_MODULE, + name: WPK_FORK_MODULE_STATE_IMPORT_TABLE_STATE_OWNED, + params: &[I32], + results: &[I32], + }, + ProgramArtifactImport { + module: WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE, + name: WPK_FORK_REFERENCE_IMPORT_DECODE_FUNCREF, + params: &[I32], + results: &[FuncRef], + }, + ProgramArtifactImport { + module: WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE, + name: WPK_FORK_REFERENCE_IMPORT_ENCODE_FUNCREF, + params: &[FuncRef], + results: &[I32], + }, + ProgramArtifactImport { + module: WPK_FORK_EXCEPTION_CODEC_IMPORT_MODULE, + name: WPK_FORK_EXCEPTION_IMPORT_BROKER_ENCODE, + params: &[I32], + results: &[I32], + }, + ProgramArtifactImport { + module: WPK_FORK_EXCEPTION_CODEC_IMPORT_MODULE, + name: WPK_FORK_EXCEPTION_IMPORT_BROKER_THROW_RECIPE, + params: &[I32], + results: &[], + }, + ProgramArtifactImport { + module: WPK_FORK_EXCEPTION_CODEC_IMPORT_MODULE, + name: WPK_FORK_EXCEPTION_IMPORT_CACHE_INDEX, + params: &[I32], + results: &[I32], + }, + ProgramArtifactImport { + module: WPK_FORK_EXCEPTION_CODEC_IMPORT_MODULE, + name: WPK_FORK_EXCEPTION_IMPORT_CLAIM, + params: &[I32], + results: &[I32], + }, + ProgramArtifactImport { + module: WPK_FORK_EXCEPTION_CODEC_IMPORT_MODULE, + name: WPK_FORK_EXCEPTION_IMPORT_DEFINE, + params: &[I32, I32, I32, I32, Pointer, I32, Pointer, I32], + results: &[], + }, + ProgramArtifactImport { + module: WPK_FORK_EXCEPTION_CODEC_IMPORT_MODULE, + name: WPK_FORK_EXCEPTION_IMPORT_INGRESS_THROW, + params: &[I32], + results: &[], + }, + ProgramArtifactImport { + module: WPK_FORK_EXCEPTION_CODEC_IMPORT_MODULE, + name: WPK_FORK_EXCEPTION_IMPORT_LOAD, + params: &[I32, I32, I32, I32, Pointer, I32, Pointer, I32], + results: &[I32], + }, + ProgramArtifactImport { + module: WPK_FORK_EXCEPTION_CODEC_IMPORT_MODULE, + name: WPK_FORK_EXCEPTION_IMPORT_LOOKUP, + params: &[I32], + results: &[I32], + }, + ProgramArtifactImport { + module: WPK_FORK_EXCEPTION_CODEC_IMPORT_MODULE, + name: WPK_FORK_EXCEPTION_IMPORT_ROUTE, + params: &[I32, I32], + results: &[I32], + }, + ProgramArtifactImport { + module: WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE, + name: WPK_FORK_REFERENCE_IMPORT_GC_BROKER_ENCODE, + params: &[I32], + results: &[I32], + }, + ProgramArtifactImport { + module: WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE, + name: WPK_FORK_REFERENCE_IMPORT_GC_CAPTURE_LAYOUT, + params: &[I32, I32, I32], + results: &[I32], + }, + ProgramArtifactImport { + module: WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE, + name: WPK_FORK_REFERENCE_IMPORT_GC_CLAIM, + params: &[I32], + results: &[I32], + }, + ProgramArtifactImport { + module: WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE, + name: WPK_FORK_REFERENCE_IMPORT_GC_DEFINE, + params: &[I32, I32, I32, I32, I32, Pointer, I32, I32], + results: &[], + }, + ProgramArtifactImport { + module: WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE, + name: WPK_FORK_REFERENCE_IMPORT_GC_I31, + params: &[I32], + results: &[I32], + }, + ProgramArtifactImport { + module: WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE, + name: WPK_FORK_REFERENCE_IMPORT_GC_LOAD, + params: &[I32, I32, I32, I32, I32, Pointer, I32], + results: &[I32], + }, + ProgramArtifactImport { + module: WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE, + name: WPK_FORK_REFERENCE_IMPORT_GC_LOOKUP, + params: &[I32], + results: &[I32], + }, + ProgramArtifactImport { + module: WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE, + name: WPK_FORK_REFERENCE_IMPORT_GC_PAYLOAD_LEN, + params: &[I32, I32, I32], + results: &[I32], + }, + ProgramArtifactImport { + module: WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE, + name: WPK_FORK_REFERENCE_IMPORT_GC_PROVENANCE_BEGIN, + params: &[I32, I32, I32, I32, I64, I64, I32], + results: &[I32], + }, + ProgramArtifactImport { + module: WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE, + name: WPK_FORK_REFERENCE_IMPORT_GC_PROVENANCE_END, + params: &[I32], + results: &[], + }, + ProgramArtifactImport { + module: WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE, + name: WPK_FORK_REFERENCE_IMPORT_GC_PROVENANCE_REF, + params: &[I32, I32, I32], + results: &[], + }, + ProgramArtifactImport { + module: WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE, + name: WPK_FORK_REFERENCE_IMPORT_GC_ROUTE, + params: &[I32, I32], + results: &[I32], + }, + ProgramArtifactImport { + module: WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE, + name: WPK_FORK_REFERENCE_IMPORT_SCRATCH_RELEASE, + params: &[Pointer, Pointer], + results: &[], + }, + ProgramArtifactImport { + module: WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE, + name: WPK_FORK_REFERENCE_IMPORT_SCRATCH_RESERVE, + params: &[Pointer], + results: &[Pointer], + }, + ProgramArtifactImport { + module: WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE, + name: WPK_FORK_REFERENCE_IMPORT_VECTOR_APPEND, + params: &[I32, I32], + results: &[], + }, + ProgramArtifactImport { + module: WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE, + name: WPK_FORK_REFERENCE_IMPORT_VECTOR_BEGIN, + params: &[I32], + results: &[I32], + }, + ProgramArtifactImport { + module: WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE, + name: WPK_FORK_REFERENCE_IMPORT_VECTOR_FINISH, + params: &[I32], + results: &[I32], + }, + ProgramArtifactImport { + module: WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE, + name: WPK_FORK_REFERENCE_IMPORT_VECTOR_GET, + params: &[I32, I32], + results: &[I32], + }, + ProgramArtifactImport { + module: WPK_FORK_FRAME_IMPORT_MODULE, + name: WPK_FORK_RESUME_IMPORT_PEEK, + params: &[I32], + results: &[I32], + }, + ]; + + pub const WPK_FORK_REQUIRED_TABLE_IMPORTS: &[ProgramArtifactTableImport] = &[ + ProgramArtifactTableImport { + module: WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE, + name: WPK_FORK_REFERENCE_IMPORT_GC_TRANSIT, + table64: false, + element: AnyRef, + minimum: 1, + maximum: None, + }, + ProgramArtifactTableImport { + module: WPK_FORK_FRAME_IMPORT_MODULE, + name: WPK_FORK_RESUME_IMPORT_TABLE, + table64: false, + element: FuncRef, + minimum: 1, + maximum: None, + }, ]; pub const WPK_FORK_REQUIRED_EXPORTS: &[ProgramArtifactExport] = &[ + ProgramArtifactExport { + name: WPK_FORK_EXCEPTION_EXPORT_MATERIALIZE, + params: &[I32], + results: &[], + }, + ProgramArtifactExport { + name: WPK_FORK_EXCEPTION_EXPORT_DECODE, + params: &[I32], + results: &[ExnRef], + }, + ProgramArtifactExport { + name: WPK_FORK_EXCEPTION_EXPORT_ENCODE, + params: &[ExnRef], + results: &[I32], + }, + ProgramArtifactExport { + name: WPK_FORK_EXCEPTION_EXPORT_ABORT, + params: &[], + results: &[], + }, + ProgramArtifactExport { + name: WPK_FORK_EXCEPTION_EXPORT_CLEAR, + params: &[], + results: &[], + }, + ProgramArtifactExport { + name: WPK_FORK_EXCEPTION_EXPORT_ENCODE_INGRESS, + params: &[I32], + results: &[I32], + }, + ProgramArtifactExport { + name: WPK_FORK_EXCEPTION_EXPORT_THROW_RECIPE, + params: &[I32], + results: &[], + }, + ProgramArtifactExport { + name: WPK_FORK_EXCEPTION_EXPORT_THROW_SLOT, + params: &[I32], + results: &[], + }, + ProgramArtifactExport { + name: WPK_FORK_REFERENCE_EXPORT_GC_ALLOCATE, + params: &[I32], + results: &[], + }, + ProgramArtifactExport { + name: WPK_FORK_REFERENCE_EXPORT_GC_ENCODE_SLOT, + params: &[I32], + results: &[I32], + }, + ProgramArtifactExport { + name: WPK_FORK_REFERENCE_EXPORT_GC_FILL, + params: &[I32], + results: &[], + }, + ProgramArtifactExport { + name: WPK_FORK_REFERENCE_EXPORT_GC_PROBE, + params: &[I32], + results: &[I64], + }, + ProgramArtifactExport { + name: WPK_FORK_REFERENCE_EXPORT_GC_PUBLISH_EXTERNREF, + params: &[I32, ExternRef], + results: &[], + }, + ProgramArtifactExport { + name: WPK_FORK_STATIC_ROOT_HARVEST_EXPORT, + params: &[], + results: &[], + }, ProgramArtifactExport { name: WPK_FORK_EXPORT_ABORT_BEGIN, params: &[Pointer], @@ -1371,6 +2110,41 @@ pub mod abi { params: &[], results: &[], }, + ProgramArtifactExport { + name: WPK_FORK_EXPORT_MODULE_BOOTSTRAP, + params: &[], + results: &[], + }, + ProgramArtifactExport { + name: WPK_FORK_EXPORT_MODULE_STATE_FINISH_RESTORE, + params: &[I32], + results: &[], + }, + ProgramArtifactExport { + name: WPK_FORK_EXPORT_MODULE_STATE_RESTORE, + params: &[I32], + results: &[], + }, + ProgramArtifactExport { + name: WPK_FORK_EXPORT_MODULE_STATE_SAVE, + params: &[I32], + results: &[], + }, + ProgramArtifactExport { + name: WPK_FORK_EXPORT_MODULE_TABLE_STATE_RESTORE, + params: &[I32], + results: &[], + }, + ProgramArtifactExport { + name: WPK_FORK_EXPORT_MODULE_TABLE_STATE_SAVE, + params: &[I32], + results: &[], + }, + ProgramArtifactExport { + name: WPK_FORK_EXPORT_MODULE_THREAD_BOOTSTRAP, + params: &[], + results: &[], + }, ProgramArtifactExport { name: WPK_FORK_EXPORT_REWIND_BEGIN, params: &[Pointer], @@ -1417,6 +2191,16 @@ pub mod abi { } } + /// Return the version-1 module-state chunk header size for one pointer + /// width, including the required eight-byte alignment. + pub const fn wpk_fork_module_state_chunk_header_size(pointer_width: u8) -> Option { + match pointer_width { + 4 => Some(40), + 8 => Some(56), + _ => None, + } + } + /// Patterns (applied as prefix match) for kernel-wasm exports that /// are implementation details of the toolchain, not part of the /// host/kernel ABI. The snapshot excludes any export whose name @@ -2003,10 +2787,49 @@ pub mod abi { HOST_ADAPTER_MANIFEST_VERSION, HOST_ADAPTER_OPTIONAL_KERNEL_EXPORTS, HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS, HOST_ADAPTER_REQUIRED_WORKER_FEATURES, HOST_ADAPTER_VERSION, HOST_ADAPTER_WORKER_FEATURES, + WPK_FORK_ACTIVATION_CONTINUATION_ENTRY_SIZE, + WPK_FORK_ACTIVATION_CONTINUATIONS_HEADER_SIZE, WPK_FORK_ACTIVATION_CONTINUATIONS_MAGIC, + WPK_FORK_ACTIVATION_CONTINUATIONS_OWNER, WPK_FORK_EXCEPTION_CODEC_HEADER_SIZE, + WPK_FORK_EXCEPTION_CODEC_SECTION, WPK_FORK_EXCEPTION_CODEC_TAG_RECORD_SIZE, + WPK_FORK_EXCEPTION_CODEC_VERSION, WPK_FORK_EXCEPTION_IMPORT_ACTIVATION, + WPK_FORK_GC_CODEC_FIELD_RECORD_SIZE, WPK_FORK_GC_CODEC_HEADER_SIZE, + WPK_FORK_GC_CODEC_LAYOUT_RECORD_SIZE, WPK_FORK_GC_CODEC_MAGIC, + WPK_FORK_IMPORTED_GLOBAL_BINDINGS_ENTRY_SIZE, + WPK_FORK_IMPORTED_GLOBAL_BINDINGS_HEADER_SIZE, WPK_FORK_IMPORTED_GLOBAL_BINDINGS_MAGIC, + WPK_FORK_IMPORTED_GLOBAL_BINDINGS_OWNER, WPK_FORK_IMPORTED_GLOBAL_FLAG_MUTABLE, + WPK_FORK_IMPORTED_GLOBAL_FLAG_SHARED, WPK_FORK_IMPORTED_GLOBAL_KNOWN_FLAGS, + WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE, WPK_FORK_IMPORTED_GLOBALS_MAGIC, + WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE, + WPK_FORK_IMPORTED_TABLE_BINDINGS_ENTRY_SIZE, + WPK_FORK_IMPORTED_TABLE_BINDINGS_HEADER_SIZE, WPK_FORK_IMPORTED_TABLE_BINDINGS_MAGIC, + WPK_FORK_IMPORTED_TABLE_BINDINGS_OWNER, WPK_FORK_IMPORTED_TABLES_HEADER_SIZE, + WPK_FORK_IMPORTED_TABLES_MAGIC, WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE, WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE, WPK_FORK_LINKED_FRAME_FORMAT_MAGIC, WPK_FORK_LINKED_FRAME_POINTER_WIDTHS, WPK_FORK_LINKED_FRAME_REQUIRED_FLAGS, - WPK_FORK_REQUIRED_EXPORTS, WPK_FORK_REQUIRED_IMPORTS, extended_syscalls::SYSCALLS, - wpk_fork_linked_chunk_header_size, wpk_fork_linked_node_header_size, + WPK_FORK_MODULE_STATE_ARENA_VERSION, WPK_FORK_MODULE_STATE_CHUNK_KNOWN_FLAGS, + WPK_FORK_MODULE_STATE_CHUNK_MAGIC, WPK_FORK_MODULE_STATE_DATA_SEGMENT_HEADER_SIZE, + WPK_FORK_MODULE_STATE_DESCRIPTOR_SIZE, + WPK_FORK_MODULE_STATE_ELEMENT_SEGMENT_HEADER_SIZE, WPK_FORK_MODULE_STATE_FORMAT_MAGIC, + WPK_FORK_MODULE_STATE_KNOWN_FLAGS, WPK_FORK_MODULE_STATE_MAX_TABLE_PAGE_SHIFT, + WPK_FORK_MODULE_STATE_MIN_TABLE_PAGE_SHIFT, + WPK_FORK_MODULE_STATE_MODULE_RECORD_PAYLOAD_SIZE, + WPK_FORK_MODULE_STATE_MODULE_TEMPLATE_ID_SIZE, WPK_FORK_MODULE_STATE_POINTER_WIDTHS, + WPK_FORK_MODULE_STATE_RECORD_HEADER_SIZE, WPK_FORK_MODULE_STATE_RECORD_KINDS, + WPK_FORK_MODULE_STATE_RECORD_MAGIC, WPK_FORK_MODULE_STATE_RECORD_VERSION, + WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_CAPACITY, + WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_HEADER_SIZE, + WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_VERSION, + WPK_FORK_MODULE_STATE_REPLAY_EVENT_SIZE, + WPK_FORK_MODULE_STATE_REPLAY_EVENTS_HEADER_SIZE, + WPK_FORK_MODULE_STATE_REPLAY_EVENTS_MAGIC, WPK_FORK_MODULE_STATE_REPLAY_EVENTS_OWNER, + WPK_FORK_MODULE_STATE_REPLAY_EVENTS_VERSION, WPK_FORK_MODULE_STATE_REQUIRED_FLAGS, + WPK_FORK_MODULE_STATE_ROOT_POINTER_WORD_OFFSET, + WPK_FORK_MODULE_STATE_TABLE_BASELINE_FINGERPRINT_SIZE, + WPK_FORK_MODULE_STATE_TABLE_DESCRIPTOR_PAYLOAD_SIZE, + WPK_FORK_MODULE_STATE_TABLE_PAGE_SHIFT, WPK_FORK_REQUIRED_EXPORTS, + WPK_FORK_REQUIRED_IMPORTS, WPK_FORK_REQUIRED_TABLE_IMPORTS, + extended_syscalls::SYSCALLS, wpk_fork_linked_chunk_header_size, + wpk_fork_linked_node_header_size, wpk_fork_module_state_chunk_header_size, }; use crate::Syscall; @@ -2109,28 +2932,128 @@ pub mod abi { assert_eq!(wpk_fork_linked_chunk_header_size(16), None); assert_eq!(wpk_fork_linked_node_header_size(16), None); - assert_eq!(WPK_FORK_REQUIRED_IMPORTS.len(), 3); + assert_eq!(WPK_FORK_REQUIRED_IMPORTS.len(), 45); let mut previous_import = ("", ""); for requirement in WPK_FORK_REQUIRED_IMPORTS { let current = (requirement.module, requirement.name); assert!( previous_import < current, - "fork imports must be sorted and unique" + "fork imports must be sorted and unique: \ + previous={previous_import:?}, current={current:?}" ); previous_import = current; } - assert_eq!(WPK_FORK_REQUIRED_EXPORTS.len(), 7); + assert_eq!(WPK_FORK_REQUIRED_TABLE_IMPORTS.len(), 2); + let mut previous_table_import = ("", ""); + for requirement in WPK_FORK_REQUIRED_TABLE_IMPORTS { + let current = (requirement.module, requirement.name); + assert!( + previous_table_import < current, + "fork table imports must be sorted and unique" + ); + previous_table_import = current; + } + assert_eq!(WPK_FORK_REQUIRED_EXPORTS.len(), 28); let mut previous_export = ""; for requirement in WPK_FORK_REQUIRED_EXPORTS { assert!( previous_export < requirement.name, - "fork exports must be sorted and unique" + "fork exports must be sorted and unique: \ + previous={previous_export:?}, current={:?}", + requirement.name, ); previous_export = requirement.name; } } + #[test] + fn module_state_recipe_contract_is_complete_and_sorted() { + assert_eq!(WPK_FORK_MODULE_STATE_FORMAT_MAGIC, *b"KFMD"); + assert_eq!(WPK_FORK_MODULE_STATE_DESCRIPTOR_SIZE, 24); + assert_eq!(WPK_FORK_MODULE_STATE_REQUIRED_FLAGS, 0b111); + assert_eq!( + WPK_FORK_MODULE_STATE_KNOWN_FLAGS, + WPK_FORK_MODULE_STATE_REQUIRED_FLAGS + ); + assert_eq!(WPK_FORK_MODULE_STATE_ARENA_VERSION, 1); + assert_eq!(WPK_FORK_MODULE_STATE_RECORD_VERSION, 1); + assert_eq!(WPK_FORK_MODULE_STATE_ROOT_POINTER_WORD_OFFSET, 1); + assert_eq!(WPK_FORK_MODULE_STATE_CHUNK_MAGIC, *b"KFMC"); + assert_eq!(WPK_FORK_MODULE_STATE_CHUNK_KNOWN_FLAGS, 0b11); + assert_eq!(WPK_FORK_MODULE_STATE_RECORD_MAGIC, *b"KFMR"); + assert_eq!(WPK_FORK_MODULE_STATE_RECORD_HEADER_SIZE, 24); + assert_eq!(WPK_FORK_MODULE_STATE_POINTER_WIDTHS, &[4, 8]); + assert_eq!(wpk_fork_module_state_chunk_header_size(4), Some(40)); + assert_eq!(wpk_fork_module_state_chunk_header_size(8), Some(56)); + assert_eq!(wpk_fork_module_state_chunk_header_size(16), None); + + let mut previous_number = 0; + for kind in WPK_FORK_MODULE_STATE_RECORD_KINDS { + assert!( + previous_number < kind.number, + "module-state record kinds must be sorted and unique" + ); + assert!(!kind.name.is_empty()); + previous_number = kind.number; + } + assert_eq!(WPK_FORK_MODULE_STATE_RECORD_KINDS.len(), 13); + + assert_eq!(WPK_FORK_MODULE_STATE_MODULE_TEMPLATE_ID_SIZE, 32); + assert_eq!(WPK_FORK_MODULE_STATE_MODULE_RECORD_PAYLOAD_SIZE, 40); + assert_eq!(WPK_FORK_MODULE_STATE_TABLE_BASELINE_FINGERPRINT_SIZE, 32); + assert_eq!(WPK_FORK_MODULE_STATE_TABLE_DESCRIPTOR_PAYLOAD_SIZE, 56); + assert_eq!(WPK_FORK_MODULE_STATE_ELEMENT_SEGMENT_HEADER_SIZE, 8); + assert_eq!(WPK_FORK_MODULE_STATE_DATA_SEGMENT_HEADER_SIZE, 8); + assert_eq!(WPK_FORK_MODULE_STATE_REPLAY_EVENTS_OWNER, 1); + assert_eq!(WPK_FORK_MODULE_STATE_REPLAY_EVENTS_MAGIC, *b"KFRE"); + assert_eq!(WPK_FORK_MODULE_STATE_REPLAY_EVENTS_VERSION, 2); + assert_eq!(WPK_FORK_MODULE_STATE_REPLAY_EVENTS_HEADER_SIZE, 40); + assert_eq!(WPK_FORK_MODULE_STATE_REPLAY_EVENT_SIZE, 8); + assert_eq!(WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_VERSION, 1); + assert_eq!(WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_HEADER_SIZE, 24); + assert_eq!(WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_CAPACITY, 4080); + assert_eq!(WPK_FORK_IMPORTED_GLOBAL_BINDINGS_MAGIC, *b"KFBG"); + assert_eq!(WPK_FORK_IMPORTED_GLOBAL_BINDINGS_OWNER, 2); + assert_eq!(WPK_FORK_IMPORTED_GLOBAL_BINDINGS_HEADER_SIZE, 24); + assert_eq!(WPK_FORK_IMPORTED_GLOBAL_BINDINGS_ENTRY_SIZE, 40); + assert_eq!(WPK_FORK_ACTIVATION_CONTINUATIONS_MAGIC, *b"KFAC"); + assert_eq!(WPK_FORK_ACTIVATION_CONTINUATIONS_OWNER, 3); + assert_eq!(WPK_FORK_ACTIVATION_CONTINUATIONS_HEADER_SIZE, 24); + assert_eq!(WPK_FORK_ACTIVATION_CONTINUATION_ENTRY_SIZE, 16); + assert_eq!(WPK_FORK_IMPORTED_TABLE_BINDINGS_MAGIC, *b"KFBT"); + assert_eq!(WPK_FORK_IMPORTED_TABLE_BINDINGS_OWNER, 4); + assert_eq!(WPK_FORK_IMPORTED_TABLE_BINDINGS_HEADER_SIZE, 24); + assert_eq!(WPK_FORK_IMPORTED_TABLE_BINDINGS_ENTRY_SIZE, 24); + assert_eq!(WPK_FORK_IMPORTED_GLOBALS_MAGIC, *b"KFIG"); + assert_eq!(WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE, 16); + assert_eq!(WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE, 24); + assert_eq!(WPK_FORK_IMPORTED_GLOBAL_KNOWN_FLAGS, 0b11); + assert_eq!(WPK_FORK_IMPORTED_GLOBAL_FLAG_MUTABLE, 0b01); + assert_eq!(WPK_FORK_IMPORTED_GLOBAL_FLAG_SHARED, 0b10); + assert_eq!(WPK_FORK_IMPORTED_TABLES_MAGIC, *b"KFIT"); + assert_eq!(WPK_FORK_IMPORTED_TABLES_HEADER_SIZE, 16); + assert_eq!(WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE, 24); + assert_eq!(WPK_FORK_GC_CODEC_MAGIC, *b"KFGC"); + assert_eq!(WPK_FORK_GC_CODEC_HEADER_SIZE, 16); + assert_eq!(WPK_FORK_GC_CODEC_LAYOUT_RECORD_SIZE, 44); + assert_eq!(WPK_FORK_GC_CODEC_FIELD_RECORD_SIZE, 12); + assert_eq!( + WPK_FORK_EXCEPTION_CODEC_SECTION, + "kandelo.wpk_fork.exception_codec" + ); + assert_eq!(WPK_FORK_EXCEPTION_CODEC_VERSION, 1); + assert_eq!(WPK_FORK_EXCEPTION_CODEC_HEADER_SIZE, 8); + assert_eq!(WPK_FORK_EXCEPTION_CODEC_TAG_RECORD_SIZE, 16); + assert_eq!( + WPK_FORK_EXCEPTION_IMPORT_ACTIVATION, + "__wpk_fork_module_activation" + ); + assert_eq!(WPK_FORK_MODULE_STATE_MIN_TABLE_PAGE_SHIFT, 4); + assert_eq!(WPK_FORK_MODULE_STATE_MAX_TABLE_PAGE_SHIFT, 20); + assert_eq!(WPK_FORK_MODULE_STATE_TABLE_PAGE_SHIFT, 10); + } + fn assert_sorted_unique(items: &[&str]) { let mut prev = None; for item in items { @@ -2299,7 +3222,6 @@ pub mod oss { pub const AFMT_S16_LE: u32 = 0x10; } - /// GLES / EGL ABI: ioctl numbers, opcode tables, and marshalled argument /// structs for `/dev/dri/renderD128`. /// @@ -2331,16 +3253,16 @@ pub mod gl { // built against an older op-table can't talk to a newer kernel (and vice // versa) without the divergence being caught at first contact rather than // surfacing later as a silent decode error. See A6's GLIO_INIT handler. - pub const GLIO_INIT: u32 = 0x40; - pub const GLIO_TERMINATE: u32 = 0x41; - pub const GLIO_CREATE_CONTEXT: u32 = 0x42; + pub const GLIO_INIT: u32 = 0x40; + pub const GLIO_TERMINATE: u32 = 0x41; + pub const GLIO_CREATE_CONTEXT: u32 = 0x42; pub const GLIO_DESTROY_CONTEXT: u32 = 0x43; - pub const GLIO_CREATE_SURFACE: u32 = 0x44; + pub const GLIO_CREATE_SURFACE: u32 = 0x44; pub const GLIO_DESTROY_SURFACE: u32 = 0x45; - pub const GLIO_MAKE_CURRENT: u32 = 0x46; - pub const GLIO_SUBMIT: u32 = 0x47; - pub const GLIO_PRESENT: u32 = 0x48; - pub const GLIO_QUERY: u32 = 0x49; + pub const GLIO_MAKE_CURRENT: u32 = 0x46; + pub const GLIO_SUBMIT: u32 = 0x47; + pub const GLIO_PRESENT: u32 = 0x48; + pub const GLIO_QUERY: u32 = 0x49; // --- surface kind tags ------------------------------------------------- @@ -2368,88 +3290,88 @@ pub mod gl { // endian. Payload formats are documented inline next to the libGLESv2 // stub call sites in glue/libglesv2_stub.c (Phase C). - pub const OP_CLEAR: u16 = 0x0001; - pub const OP_CLEAR_COLOR: u16 = 0x0002; - pub const OP_VIEWPORT: u16 = 0x0003; - pub const OP_SCISSOR: u16 = 0x0004; - pub const OP_ENABLE: u16 = 0x0005; - pub const OP_DISABLE: u16 = 0x0006; - pub const OP_BLEND_FUNC: u16 = 0x0007; - pub const OP_DEPTH_FUNC: u16 = 0x0008; - pub const OP_CULL_FACE: u16 = 0x0009; - pub const OP_FRONT_FACE: u16 = 0x000A; - pub const OP_LINE_WIDTH: u16 = 0x000B; - pub const OP_PIXEL_STOREI: u16 = 0x000C; - - pub const OP_GEN_BUFFERS: u16 = 0x0100; - pub const OP_DELETE_BUFFERS: u16 = 0x0101; - pub const OP_BIND_BUFFER: u16 = 0x0102; - pub const OP_BUFFER_DATA: u16 = 0x0103; - pub const OP_BUFFER_SUB_DATA: u16 = 0x0104; - - pub const OP_GEN_TEXTURES: u16 = 0x0200; - pub const OP_DELETE_TEXTURES: u16 = 0x0201; - pub const OP_BIND_TEXTURE: u16 = 0x0202; - pub const OP_TEX_IMAGE_2D: u16 = 0x0203; - pub const OP_TEX_SUB_IMAGE_2D: u16 = 0x0204; - pub const OP_TEX_PARAMETERI: u16 = 0x0205; - pub const OP_ACTIVE_TEXTURE: u16 = 0x0206; - pub const OP_GENERATE_MIPMAP: u16 = 0x0207; - - pub const OP_CREATE_SHADER: u16 = 0x0300; - pub const OP_SHADER_SOURCE: u16 = 0x0301; - pub const OP_COMPILE_SHADER: u16 = 0x0302; - pub const OP_DELETE_SHADER: u16 = 0x0303; - pub const OP_CREATE_PROGRAM: u16 = 0x0304; - pub const OP_ATTACH_SHADER: u16 = 0x0305; - pub const OP_LINK_PROGRAM: u16 = 0x0306; - pub const OP_USE_PROGRAM: u16 = 0x0307; - pub const OP_BIND_ATTRIB_LOCATION: u16 = 0x0308; - pub const OP_DELETE_PROGRAM: u16 = 0x0309; - - pub const OP_UNIFORM1I: u16 = 0x0400; - pub const OP_UNIFORM1F: u16 = 0x0401; - pub const OP_UNIFORM2F: u16 = 0x0402; - pub const OP_UNIFORM3F: u16 = 0x0403; - pub const OP_UNIFORM4F: u16 = 0x0404; - pub const OP_UNIFORM_MATRIX4FV: u16 = 0x0405; + pub const OP_CLEAR: u16 = 0x0001; + pub const OP_CLEAR_COLOR: u16 = 0x0002; + pub const OP_VIEWPORT: u16 = 0x0003; + pub const OP_SCISSOR: u16 = 0x0004; + pub const OP_ENABLE: u16 = 0x0005; + pub const OP_DISABLE: u16 = 0x0006; + pub const OP_BLEND_FUNC: u16 = 0x0007; + pub const OP_DEPTH_FUNC: u16 = 0x0008; + pub const OP_CULL_FACE: u16 = 0x0009; + pub const OP_FRONT_FACE: u16 = 0x000A; + pub const OP_LINE_WIDTH: u16 = 0x000B; + pub const OP_PIXEL_STOREI: u16 = 0x000C; + + pub const OP_GEN_BUFFERS: u16 = 0x0100; + pub const OP_DELETE_BUFFERS: u16 = 0x0101; + pub const OP_BIND_BUFFER: u16 = 0x0102; + pub const OP_BUFFER_DATA: u16 = 0x0103; + pub const OP_BUFFER_SUB_DATA: u16 = 0x0104; + + pub const OP_GEN_TEXTURES: u16 = 0x0200; + pub const OP_DELETE_TEXTURES: u16 = 0x0201; + pub const OP_BIND_TEXTURE: u16 = 0x0202; + pub const OP_TEX_IMAGE_2D: u16 = 0x0203; + pub const OP_TEX_SUB_IMAGE_2D: u16 = 0x0204; + pub const OP_TEX_PARAMETERI: u16 = 0x0205; + pub const OP_ACTIVE_TEXTURE: u16 = 0x0206; + pub const OP_GENERATE_MIPMAP: u16 = 0x0207; + + pub const OP_CREATE_SHADER: u16 = 0x0300; + pub const OP_SHADER_SOURCE: u16 = 0x0301; + pub const OP_COMPILE_SHADER: u16 = 0x0302; + pub const OP_DELETE_SHADER: u16 = 0x0303; + pub const OP_CREATE_PROGRAM: u16 = 0x0304; + pub const OP_ATTACH_SHADER: u16 = 0x0305; + pub const OP_LINK_PROGRAM: u16 = 0x0306; + pub const OP_USE_PROGRAM: u16 = 0x0307; + pub const OP_BIND_ATTRIB_LOCATION: u16 = 0x0308; + pub const OP_DELETE_PROGRAM: u16 = 0x0309; + + pub const OP_UNIFORM1I: u16 = 0x0400; + pub const OP_UNIFORM1F: u16 = 0x0401; + pub const OP_UNIFORM2F: u16 = 0x0402; + pub const OP_UNIFORM3F: u16 = 0x0403; + pub const OP_UNIFORM4F: u16 = 0x0404; + pub const OP_UNIFORM_MATRIX4FV: u16 = 0x0405; /// `glUniform4fv(location, count, value)` — vector form. es2gears uses /// this for the directional light position. `OP_UNIFORM4F` (scalar) is a /// different signature; both are needed. - pub const OP_UNIFORM4FV: u16 = 0x0406; + pub const OP_UNIFORM4FV: u16 = 0x0406; - pub const OP_ENABLE_VERTEX_ATTRIB_ARRAY: u16 = 0x0500; + pub const OP_ENABLE_VERTEX_ATTRIB_ARRAY: u16 = 0x0500; pub const OP_DISABLE_VERTEX_ATTRIB_ARRAY: u16 = 0x0501; - pub const OP_VERTEX_ATTRIB_POINTER: u16 = 0x0502; - pub const OP_DRAW_ARRAYS: u16 = 0x0503; - pub const OP_DRAW_ELEMENTS: u16 = 0x0504; - - pub const OP_GEN_VERTEX_ARRAYS: u16 = 0x0600; - pub const OP_DELETE_VERTEX_ARRAYS: u16 = 0x0601; - pub const OP_BIND_VERTEX_ARRAY: u16 = 0x0602; - - pub const OP_GEN_FRAMEBUFFERS: u16 = 0x0700; - pub const OP_BIND_FRAMEBUFFER: u16 = 0x0701; - pub const OP_FRAMEBUFFER_TEXTURE_2D: u16 = 0x0702; - pub const OP_GEN_RENDERBUFFERS: u16 = 0x0703; - pub const OP_BIND_RENDERBUFFER: u16 = 0x0704; - pub const OP_RENDERBUFFER_STORAGE: u16 = 0x0705; - pub const OP_FRAMEBUFFER_RENDERBUFFER: u16 = 0x0706; + pub const OP_VERTEX_ATTRIB_POINTER: u16 = 0x0502; + pub const OP_DRAW_ARRAYS: u16 = 0x0503; + pub const OP_DRAW_ELEMENTS: u16 = 0x0504; + + pub const OP_GEN_VERTEX_ARRAYS: u16 = 0x0600; + pub const OP_DELETE_VERTEX_ARRAYS: u16 = 0x0601; + pub const OP_BIND_VERTEX_ARRAY: u16 = 0x0602; + + pub const OP_GEN_FRAMEBUFFERS: u16 = 0x0700; + pub const OP_BIND_FRAMEBUFFER: u16 = 0x0701; + pub const OP_FRAMEBUFFER_TEXTURE_2D: u16 = 0x0702; + pub const OP_GEN_RENDERBUFFERS: u16 = 0x0703; + pub const OP_BIND_RENDERBUFFER: u16 = 0x0704; + pub const OP_RENDERBUFFER_STORAGE: u16 = 0x0705; + pub const OP_FRAMEBUFFER_RENDERBUFFER: u16 = 0x0706; // --- sync query op tags (used in GlQueryInfo.op) ----------------------- - pub const QOP_GET_ERROR: u32 = 0x01; - pub const QOP_GET_STRING: u32 = 0x02; - pub const QOP_GET_INTEGERV: u32 = 0x03; - pub const QOP_GET_FLOATV: u32 = 0x04; - pub const QOP_GET_UNIFORM_LOC: u32 = 0x05; - pub const QOP_GET_ATTRIB_LOC: u32 = 0x06; - pub const QOP_GET_SHADERIV: u32 = 0x07; - pub const QOP_GET_SHADER_INFO_LOG: u32 = 0x08; - pub const QOP_GET_PROGRAMIV: u32 = 0x09; - pub const QOP_GET_PROGRAM_INFO_LOG: u32 = 0x0A; - pub const QOP_READ_PIXELS: u32 = 0x0B; - pub const QOP_CHECK_FB_STATUS: u32 = 0x0C; + pub const QOP_GET_ERROR: u32 = 0x01; + pub const QOP_GET_STRING: u32 = 0x02; + pub const QOP_GET_INTEGERV: u32 = 0x03; + pub const QOP_GET_FLOATV: u32 = 0x04; + pub const QOP_GET_UNIFORM_LOC: u32 = 0x05; + pub const QOP_GET_ATTRIB_LOC: u32 = 0x06; + pub const QOP_GET_SHADERIV: u32 = 0x07; + pub const QOP_GET_SHADER_INFO_LOG: u32 = 0x08; + pub const QOP_GET_PROGRAMIV: u32 = 0x09; + pub const QOP_GET_PROGRAM_INFO_LOG: u32 = 0x0A; + pub const QOP_READ_PIXELS: u32 = 0x0B; + pub const QOP_CHECK_FB_STATUS: u32 = 0x0C; // --- marshalled ioctl argument structs --------------------------------- @@ -2583,14 +3505,14 @@ pub mod dri { #[repr(C)] #[derive(Clone, Copy, Default)] pub struct WpkDrmModeCreateDumb { - pub height: u32, // 0 in - pub width: u32, // 4 in - pub bpp: u32, // 8 in bits-per-pixel (32 for ARGB8888) - pub flags: u32, // 12 in must be 0 - pub handle: u32, // 16 out process-local bo handle - pub pitch: u32, // 20 out stride in bytes - pub size: u64, // 24 out total bytes (pitch * height) - // total: 32 + pub height: u32, // 0 in + pub width: u32, // 4 in + pub bpp: u32, // 8 in bits-per-pixel (32 for ARGB8888) + pub flags: u32, // 12 in must be 0 + pub handle: u32, // 16 out process-local bo handle + pub pitch: u32, // 20 out stride in bytes + pub size: u64, // 24 out total bytes (pitch * height) + // total: 32 } /// Linux `struct drm_mode_map_dumb` (16 bytes). @@ -2652,16 +3574,16 @@ pub mod dri { #[repr(C)] #[derive(Clone, Copy, Default)] pub struct WpkDrmVersion { - pub version_major: i32, // 0 - pub version_minor: i32, // 4 - pub version_patchlevel: i32, // 8 - pub name_len: u32, // 12 in/out - pub name_ptr: u32, // 16 wasm32 user pointer - pub date_len: u32, // 20 in/out - pub date_ptr: u32, // 24 wasm32 user pointer - pub desc_len: u32, // 28 in/out - pub desc_ptr: u32, // 32 wasm32 user pointer - // total: 36 + pub version_major: i32, // 0 + pub version_minor: i32, // 4 + pub version_patchlevel: i32, // 8 + pub name_len: u32, // 12 in/out + pub name_ptr: u32, // 16 wasm32 user pointer + pub date_len: u32, // 20 in/out + pub date_ptr: u32, // 24 wasm32 user pointer + pub desc_len: u32, // 28 in/out + pub desc_ptr: u32, // 32 wasm32 user pointer + // total: 36 } // --- WPK extensions ('d' magic, nrs 0xE0+ — unused by Linux 6.x) ---- @@ -2695,11 +3617,11 @@ pub mod dri { #[repr(C)] #[derive(Clone, Copy, Default)] pub struct WpkDrmGpuBoCreate { - pub width: u32, // 0 in - pub height: u32, // 4 in - pub format: u32, // 8 in DRM_FORMAT_* (ARGB8888 etc.) - pub usage: u32, // 12 in GBM_BO_USE_* bitmask - // total: 16 + pub width: u32, // 0 in + pub height: u32, // 4 in + pub format: u32, // 8 in DRM_FORMAT_* (ARGB8888 etc.) + pub usage: u32, // 12 in GBM_BO_USE_* bitmask + // total: 16 } /// `BIND_FOREIGN_TEXTURE` argument. 16 bytes on wasm32 (4 × u32). After @@ -2713,11 +3635,11 @@ pub mod dri { #[repr(C)] #[derive(Clone, Copy, Default)] pub struct WpkDrmBindForeignTexture { - pub bo_handle: u32, // 0 in caller's local GEM handle - pub gl_target: u32, // 4 in GL_TEXTURE_2D etc. - pub ctx_id: u32, // 8 in caller's GL ctx_id + pub bo_handle: u32, // 0 in caller's local GEM handle + pub gl_target: u32, // 4 in GL_TEXTURE_2D etc. + pub ctx_id: u32, // 8 in caller's GL ctx_id pub gl_texture_id: u32, // 12 out the WebGLTexture id assigned - // (also writable as a sampler binding) + // (also writable as a sampler binding) } // --- KMS ioctls ('d' magic, Linux UAPI) ------------------------------- @@ -2771,92 +3693,92 @@ pub mod dri { #[repr(C)] #[derive(Clone, Copy, Default)] pub struct WpkDrmModeCardRes { - pub fb_id_ptr: u64, // 0 in - pub crtc_id_ptr: u64, // 8 in - pub connector_id_ptr: u64, // 16 in - pub encoder_id_ptr: u64, // 24 in - pub count_fbs: u32, // 32 in/out - pub count_crtcs: u32, // 36 in/out - pub count_connectors: u32, // 40 in/out - pub count_encoders: u32, // 44 in/out - pub min_width: u32, // 48 out - pub max_width: u32, // 52 out - pub min_height: u32, // 56 out - pub max_height: u32, // 60 out - // total: 64 + pub fb_id_ptr: u64, // 0 in + pub crtc_id_ptr: u64, // 8 in + pub connector_id_ptr: u64, // 16 in + pub encoder_id_ptr: u64, // 24 in + pub count_fbs: u32, // 32 in/out + pub count_crtcs: u32, // 36 in/out + pub count_connectors: u32, // 40 in/out + pub count_encoders: u32, // 44 in/out + pub min_width: u32, // 48 out + pub max_width: u32, // 52 out + pub min_height: u32, // 56 out + pub max_height: u32, // 60 out + // total: 64 } /// `struct drm_mode_modeinfo`. 68 bytes. #[repr(C)] #[derive(Clone, Copy, Default)] pub struct WpkDrmModeModeinfo { - pub clock: u32, // 0 - pub hdisplay: u16, // 4 - pub hsync_start: u16, // 6 - pub hsync_end: u16, // 8 - pub htotal: u16, // 10 - pub hskew: u16, // 12 - pub vdisplay: u16, // 14 - pub vsync_start: u16, // 16 - pub vsync_end: u16, // 18 - pub vtotal: u16, // 20 - pub vscan: u16, // 22 - pub vrefresh: u32, // 24 - pub flags: u32, // 28 - pub mode_type: u32, // 32 - pub name: [u8; 32], // 36..68 - // total: 68 + pub clock: u32, // 0 + pub hdisplay: u16, // 4 + pub hsync_start: u16, // 6 + pub hsync_end: u16, // 8 + pub htotal: u16, // 10 + pub hskew: u16, // 12 + pub vdisplay: u16, // 14 + pub vsync_start: u16, // 16 + pub vsync_end: u16, // 18 + pub vtotal: u16, // 20 + pub vscan: u16, // 22 + pub vrefresh: u32, // 24 + pub flags: u32, // 28 + pub mode_type: u32, // 32 + pub name: [u8; 32], // 36..68 + // total: 68 } /// `struct drm_mode_crtc`. 104 bytes (embedded modeinfo at offset 36). #[repr(C)] #[derive(Clone, Copy, Default)] pub struct WpkDrmModeGetCrtc { - pub set_connectors_ptr: u64, // 0 in (SETCRTC only) - pub count_connectors: u32, // 8 in (SETCRTC only) - pub crtc_id: u32, // 12 in/out - pub fb_id: u32, // 16 in/out - pub x: u32, // 20 in/out - pub y: u32, // 24 in/out - pub gamma_size: u32, // 28 out - pub mode_valid: u32, // 32 in/out - pub mode: WpkDrmModeModeinfo, // 36..104 - // total: 104 + pub set_connectors_ptr: u64, // 0 in (SETCRTC only) + pub count_connectors: u32, // 8 in (SETCRTC only) + pub crtc_id: u32, // 12 in/out + pub fb_id: u32, // 16 in/out + pub x: u32, // 20 in/out + pub y: u32, // 24 in/out + pub gamma_size: u32, // 28 out + pub mode_valid: u32, // 32 in/out + pub mode: WpkDrmModeModeinfo, // 36..104 + // total: 104 } /// `struct drm_mode_get_connector`. 80 bytes. #[repr(C)] #[derive(Clone, Copy, Default)] pub struct WpkDrmModeGetConnector { - pub encoders_ptr: u64, // 0 in - pub modes_ptr: u64, // 8 in - pub props_ptr: u64, // 16 in - pub prop_values_ptr: u64, // 24 in - pub count_modes: u32, // 32 in/out - pub count_props: u32, // 36 in/out - pub count_encoders: u32, // 40 in/out - pub encoder_id: u32, // 44 out - pub connector_id: u32, // 48 in/out - pub connector_type: u32, // 52 out - pub connector_type_id: u32, // 56 out - pub connection: u32, // 60 out - pub mm_width: u32, // 64 out - pub mm_height: u32, // 68 out - pub subpixel: u32, // 72 out - pub pad: u32, // 76 - // total: 80 + pub encoders_ptr: u64, // 0 in + pub modes_ptr: u64, // 8 in + pub props_ptr: u64, // 16 in + pub prop_values_ptr: u64, // 24 in + pub count_modes: u32, // 32 in/out + pub count_props: u32, // 36 in/out + pub count_encoders: u32, // 40 in/out + pub encoder_id: u32, // 44 out + pub connector_id: u32, // 48 in/out + pub connector_type: u32, // 52 out + pub connector_type_id: u32, // 56 out + pub connection: u32, // 60 out + pub mm_width: u32, // 64 out + pub mm_height: u32, // 68 out + pub subpixel: u32, // 72 out + pub pad: u32, // 76 + // total: 80 } /// `struct drm_mode_get_encoder`. 20 bytes. #[repr(C)] #[derive(Clone, Copy, Default)] pub struct WpkDrmModeGetEncoder { - pub encoder_id: u32, // 0 in/out - pub encoder_type: u32, // 4 out - pub crtc_id: u32, // 8 out - pub possible_crtcs: u32, // 12 out - pub possible_clones: u32, // 16 out - // total: 20 + pub encoder_id: u32, // 0 in/out + pub encoder_type: u32, // 4 out + pub crtc_id: u32, // 8 out + pub possible_crtcs: u32, // 12 out + pub possible_clones: u32, // 16 out + // total: 20 } /// `struct drm_mode_fb_cmd2`. 104 bytes — `[u64; 4] modifier` aligns @@ -2864,28 +3786,28 @@ pub mod dri { #[repr(C)] #[derive(Clone, Copy, Default)] pub struct WpkDrmModeFbCmd2 { - pub fb_id: u32, // 0 out - pub width: u32, // 4 in - pub height: u32, // 8 in - pub pixel_format: u32, // 12 in - pub flags: u32, // 16 in - pub handles: [u32; 4], // 20 in - pub pitches: [u32; 4], // 36 in - pub offsets: [u32; 4], // 52 in - pub modifier: [u64; 4], // 72..104 in - // total: 104 + pub fb_id: u32, // 0 out + pub width: u32, // 4 in + pub height: u32, // 8 in + pub pixel_format: u32, // 12 in + pub flags: u32, // 16 in + pub handles: [u32; 4], // 20 in + pub pitches: [u32; 4], // 36 in + pub offsets: [u32; 4], // 52 in + pub modifier: [u64; 4], // 72..104 in + // total: 104 } /// `struct drm_mode_crtc_page_flip`. 24 bytes. #[repr(C)] #[derive(Clone, Copy, Default)] pub struct WpkDrmModeCrtcPageFlip { - pub crtc_id: u32, // 0 in - pub fb_id: u32, // 4 in - pub flags: u32, // 8 in - pub reserved: u32, // 12 - pub user_data: u64, // 16 in - // total: 24 + pub crtc_id: u32, // 0 in + pub fb_id: u32, // 4 in + pub flags: u32, // 8 in + pub reserved: u32, // 12 + pub user_data: u64, // 16 in + // total: 24 } /// `struct drm_event_vblank`. 32 bytes — `drm_event` header (8) + @@ -2895,35 +3817,35 @@ pub mod dri { #[repr(C)] #[derive(Clone, Copy, Default)] pub struct WpkDrmEventVblank { - pub ev_type: u32, // 0 - pub length: u32, // 4 - pub user_data: u64, // 8 - pub tv_sec: u32, // 16 - pub tv_usec: u32, // 20 - pub sequence: u32, // 24 - pub crtc_id: u32, // 28 - // total: 32 + pub ev_type: u32, // 0 + pub length: u32, // 4 + pub user_data: u64, // 8 + pub tv_sec: u32, // 16 + pub tv_usec: u32, // 20 + pub sequence: u32, // 24 + pub crtc_id: u32, // 28 + // total: 32 } /// `struct drm_wait_vblank_request`. Union member (input). 16 bytes. #[repr(C)] #[derive(Clone, Copy, Default)] pub struct WpkDrmWaitVblankRequest { - pub req_type: u32, // 0 - pub sequence: u32, // 4 - pub signal: u64, // 8 - // total: 16 + pub req_type: u32, // 0 + pub sequence: u32, // 4 + pub signal: u64, // 8 + // total: 16 } /// `struct drm_wait_vblank_reply`. Union member (output). 16 bytes. #[repr(C)] #[derive(Clone, Copy, Default)] pub struct WpkDrmWaitVblankReply { - pub rep_type: u32, // 0 - pub sequence: u32, // 4 - pub tv_sec: u32, // 8 - pub tv_usec: u32, // 12 - // total: 16 + pub rep_type: u32, // 0 + pub sequence: u32, // 4 + pub tv_sec: u32, // 8 + pub tv_usec: u32, // 12 + // total: 16 } } @@ -2954,22 +3876,68 @@ mod dri_tests { #[test] fn ioctl_numbers_match_linux_uapi() { let iowr = IOC_READ | IOC_WRITE; - assert_eq!(DRM_IOCTL_VERSION, - ioc(iowr, 'd' as u32, 0x00, size_of::() as u32)); - assert_eq!(DRM_IOCTL_GET_CAP, - ioc(iowr, 'd' as u32, 0x0c, size_of::() as u32)); - assert_eq!(DRM_IOCTL_GEM_CLOSE, - ioc(IOC_WRITE, 'd' as u32, 0x09, size_of::() as u32)); - assert_eq!(DRM_IOCTL_PRIME_HANDLE_TO_FD, - ioc(iowr, 'd' as u32, 0x2d, size_of::() as u32)); - assert_eq!(DRM_IOCTL_PRIME_FD_TO_HANDLE, - ioc(iowr, 'd' as u32, 0x2e, size_of::() as u32)); - assert_eq!(DRM_IOCTL_MODE_CREATE_DUMB, - ioc(iowr, 'd' as u32, 0xb2, size_of::() as u32)); - assert_eq!(DRM_IOCTL_MODE_MAP_DUMB, - ioc(iowr, 'd' as u32, 0xb3, size_of::() as u32)); - assert_eq!(DRM_IOCTL_MODE_DESTROY_DUMB, - ioc(iowr, 'd' as u32, 0xb4, size_of::() as u32)); + assert_eq!( + DRM_IOCTL_VERSION, + ioc(iowr, 'd' as u32, 0x00, size_of::() as u32) + ); + assert_eq!( + DRM_IOCTL_GET_CAP, + ioc(iowr, 'd' as u32, 0x0c, size_of::() as u32) + ); + assert_eq!( + DRM_IOCTL_GEM_CLOSE, + ioc( + IOC_WRITE, + 'd' as u32, + 0x09, + size_of::() as u32 + ) + ); + assert_eq!( + DRM_IOCTL_PRIME_HANDLE_TO_FD, + ioc( + iowr, + 'd' as u32, + 0x2d, + size_of::() as u32 + ) + ); + assert_eq!( + DRM_IOCTL_PRIME_FD_TO_HANDLE, + ioc( + iowr, + 'd' as u32, + 0x2e, + size_of::() as u32 + ) + ); + assert_eq!( + DRM_IOCTL_MODE_CREATE_DUMB, + ioc( + iowr, + 'd' as u32, + 0xb2, + size_of::() as u32 + ) + ); + assert_eq!( + DRM_IOCTL_MODE_MAP_DUMB, + ioc( + iowr, + 'd' as u32, + 0xb3, + size_of::() as u32 + ) + ); + assert_eq!( + DRM_IOCTL_MODE_DESTROY_DUMB, + ioc( + iowr, + 'd' as u32, + 0xb4, + size_of::() as u32 + ) + ); } #[test] @@ -2981,10 +3949,24 @@ mod dri_tests { #[test] fn wpk_extension_ioctl_numbers() { let iowr = IOC_READ | IOC_WRITE; - assert_eq!(DRM_IOCTL_WPK_CREATE_GPU_BO, - ioc(iowr, 'd' as u32, 0xE0, size_of::() as u32)); - assert_eq!(DRM_IOCTL_WPK_BIND_FOREIGN_TEXTURE, - ioc(iowr, 'd' as u32, 0xE1, size_of::() as u32)); + assert_eq!( + DRM_IOCTL_WPK_CREATE_GPU_BO, + ioc( + iowr, + 'd' as u32, + 0xE0, + size_of::() as u32 + ) + ); + assert_eq!( + DRM_IOCTL_WPK_BIND_FOREIGN_TEXTURE, + ioc( + iowr, + 'd' as u32, + 0xE1, + size_of::() as u32 + ) + ); } #[test] @@ -3014,28 +3996,76 @@ mod dri_tests { #[test] fn kms_ioctl_numbers_match_linux_uapi() { let iowr = IOC_READ | IOC_WRITE; - assert_eq!(DRM_IOCTL_SET_MASTER, - ioc(0, 'd' as u32, 0x1e, 0)); - assert_eq!(DRM_IOCTL_DROP_MASTER, - ioc(0, 'd' as u32, 0x1f, 0)); - assert_eq!(DRM_IOCTL_WAIT_VBLANK, - ioc(iowr, 'd' as u32, 0x3a, size_of::() as u32)); - assert_eq!(DRM_IOCTL_MODE_GETRESOURCES, - ioc(iowr, 'd' as u32, 0xa0, size_of::() as u32)); - assert_eq!(DRM_IOCTL_MODE_GETCRTC, - ioc(iowr, 'd' as u32, 0xa1, size_of::() as u32)); - assert_eq!(DRM_IOCTL_MODE_SETCRTC, - ioc(iowr, 'd' as u32, 0xa2, size_of::() as u32)); - assert_eq!(DRM_IOCTL_MODE_GETENCODER, - ioc(iowr, 'd' as u32, 0xa6, size_of::() as u32)); - assert_eq!(DRM_IOCTL_MODE_GETCONNECTOR, - ioc(iowr, 'd' as u32, 0xa7, size_of::() as u32)); - assert_eq!(DRM_IOCTL_MODE_RMFB, - ioc(iowr, 'd' as u32, 0xaf, 4)); - assert_eq!(DRM_IOCTL_MODE_PAGE_FLIP, - ioc(iowr, 'd' as u32, 0xb0, size_of::() as u32)); - assert_eq!(DRM_IOCTL_MODE_ADDFB2, - ioc(iowr, 'd' as u32, 0xb8, size_of::() as u32)); + assert_eq!(DRM_IOCTL_SET_MASTER, ioc(0, 'd' as u32, 0x1e, 0)); + assert_eq!(DRM_IOCTL_DROP_MASTER, ioc(0, 'd' as u32, 0x1f, 0)); + assert_eq!( + DRM_IOCTL_WAIT_VBLANK, + ioc( + iowr, + 'd' as u32, + 0x3a, + size_of::() as u32 + ) + ); + assert_eq!( + DRM_IOCTL_MODE_GETRESOURCES, + ioc( + iowr, + 'd' as u32, + 0xa0, + size_of::() as u32 + ) + ); + assert_eq!( + DRM_IOCTL_MODE_GETCRTC, + ioc( + iowr, + 'd' as u32, + 0xa1, + size_of::() as u32 + ) + ); + assert_eq!( + DRM_IOCTL_MODE_SETCRTC, + ioc( + iowr, + 'd' as u32, + 0xa2, + size_of::() as u32 + ) + ); + assert_eq!( + DRM_IOCTL_MODE_GETENCODER, + ioc( + iowr, + 'd' as u32, + 0xa6, + size_of::() as u32 + ) + ); + assert_eq!( + DRM_IOCTL_MODE_GETCONNECTOR, + ioc( + iowr, + 'd' as u32, + 0xa7, + size_of::() as u32 + ) + ); + assert_eq!(DRM_IOCTL_MODE_RMFB, ioc(iowr, 'd' as u32, 0xaf, 4)); + assert_eq!( + DRM_IOCTL_MODE_PAGE_FLIP, + ioc( + iowr, + 'd' as u32, + 0xb0, + size_of::() as u32 + ) + ); + assert_eq!( + DRM_IOCTL_MODE_ADDFB2, + ioc(iowr, 'd' as u32, 0xb8, size_of::() as u32) + ); } #[test] @@ -3054,10 +4084,10 @@ mod gl_tests { #[test] fn struct_sizes_match_abi() { - assert_eq!(size_of::(), 8); + assert_eq!(size_of::(), 8); assert_eq!(size_of::(), 16); assert_eq!(size_of::(), 32); - assert_eq!(size_of::(), 24); + assert_eq!(size_of::(), 24); } #[test] @@ -3068,27 +4098,62 @@ mod gl_tests { #[test] fn opcodes_are_unique() { let ops: &[u16] = &[ - OP_CLEAR, OP_CLEAR_COLOR, OP_VIEWPORT, OP_SCISSOR, - OP_ENABLE, OP_DISABLE, OP_BLEND_FUNC, OP_DEPTH_FUNC, - OP_CULL_FACE, OP_FRONT_FACE, OP_LINE_WIDTH, OP_PIXEL_STOREI, - OP_GEN_BUFFERS, OP_DELETE_BUFFERS, OP_BIND_BUFFER, - OP_BUFFER_DATA, OP_BUFFER_SUB_DATA, - OP_GEN_TEXTURES, OP_DELETE_TEXTURES, OP_BIND_TEXTURE, - OP_TEX_IMAGE_2D, OP_TEX_SUB_IMAGE_2D, OP_TEX_PARAMETERI, - OP_ACTIVE_TEXTURE, OP_GENERATE_MIPMAP, - OP_CREATE_SHADER, OP_SHADER_SOURCE, OP_COMPILE_SHADER, - OP_DELETE_SHADER, OP_CREATE_PROGRAM, OP_ATTACH_SHADER, - OP_LINK_PROGRAM, OP_USE_PROGRAM, OP_BIND_ATTRIB_LOCATION, + OP_CLEAR, + OP_CLEAR_COLOR, + OP_VIEWPORT, + OP_SCISSOR, + OP_ENABLE, + OP_DISABLE, + OP_BLEND_FUNC, + OP_DEPTH_FUNC, + OP_CULL_FACE, + OP_FRONT_FACE, + OP_LINE_WIDTH, + OP_PIXEL_STOREI, + OP_GEN_BUFFERS, + OP_DELETE_BUFFERS, + OP_BIND_BUFFER, + OP_BUFFER_DATA, + OP_BUFFER_SUB_DATA, + OP_GEN_TEXTURES, + OP_DELETE_TEXTURES, + OP_BIND_TEXTURE, + OP_TEX_IMAGE_2D, + OP_TEX_SUB_IMAGE_2D, + OP_TEX_PARAMETERI, + OP_ACTIVE_TEXTURE, + OP_GENERATE_MIPMAP, + OP_CREATE_SHADER, + OP_SHADER_SOURCE, + OP_COMPILE_SHADER, + OP_DELETE_SHADER, + OP_CREATE_PROGRAM, + OP_ATTACH_SHADER, + OP_LINK_PROGRAM, + OP_USE_PROGRAM, + OP_BIND_ATTRIB_LOCATION, OP_DELETE_PROGRAM, - OP_UNIFORM1I, OP_UNIFORM1F, OP_UNIFORM2F, OP_UNIFORM3F, - OP_UNIFORM4F, OP_UNIFORM_MATRIX4FV, OP_UNIFORM4FV, - OP_ENABLE_VERTEX_ATTRIB_ARRAY, OP_DISABLE_VERTEX_ATTRIB_ARRAY, - OP_VERTEX_ATTRIB_POINTER, OP_DRAW_ARRAYS, OP_DRAW_ELEMENTS, - OP_GEN_VERTEX_ARRAYS, OP_DELETE_VERTEX_ARRAYS, + OP_UNIFORM1I, + OP_UNIFORM1F, + OP_UNIFORM2F, + OP_UNIFORM3F, + OP_UNIFORM4F, + OP_UNIFORM_MATRIX4FV, + OP_UNIFORM4FV, + OP_ENABLE_VERTEX_ATTRIB_ARRAY, + OP_DISABLE_VERTEX_ATTRIB_ARRAY, + OP_VERTEX_ATTRIB_POINTER, + OP_DRAW_ARRAYS, + OP_DRAW_ELEMENTS, + OP_GEN_VERTEX_ARRAYS, + OP_DELETE_VERTEX_ARRAYS, OP_BIND_VERTEX_ARRAY, - OP_GEN_FRAMEBUFFERS, OP_BIND_FRAMEBUFFER, - OP_FRAMEBUFFER_TEXTURE_2D, OP_GEN_RENDERBUFFERS, - OP_BIND_RENDERBUFFER, OP_RENDERBUFFER_STORAGE, + OP_GEN_FRAMEBUFFERS, + OP_BIND_FRAMEBUFFER, + OP_FRAMEBUFFER_TEXTURE_2D, + OP_GEN_RENDERBUFFERS, + OP_BIND_RENDERBUFFER, + OP_RENDERBUFFER_STORAGE, OP_FRAMEBUFFER_RENDERBUFFER, ]; for (i, &a) in ops.iter().enumerate() { @@ -3101,10 +4166,18 @@ mod gl_tests { #[test] fn query_opcodes_are_unique() { let qops: &[u32] = &[ - QOP_GET_ERROR, QOP_GET_STRING, QOP_GET_INTEGERV, - QOP_GET_FLOATV, QOP_GET_UNIFORM_LOC, QOP_GET_ATTRIB_LOC, - QOP_GET_SHADERIV, QOP_GET_SHADER_INFO_LOG, QOP_GET_PROGRAMIV, - QOP_GET_PROGRAM_INFO_LOG, QOP_READ_PIXELS, QOP_CHECK_FB_STATUS, + QOP_GET_ERROR, + QOP_GET_STRING, + QOP_GET_INTEGERV, + QOP_GET_FLOATV, + QOP_GET_UNIFORM_LOC, + QOP_GET_ATTRIB_LOC, + QOP_GET_SHADERIV, + QOP_GET_SHADER_INFO_LOG, + QOP_GET_PROGRAMIV, + QOP_GET_PROGRAM_INFO_LOG, + QOP_READ_PIXELS, + QOP_CHECK_FB_STATUS, ]; for (i, &a) in qops.iter().enumerate() { for &b in &qops[i + 1..] { diff --git a/docs/abi-versioning.md b/docs/abi-versioning.md index 473caffc80..7c0023d35c 100644 --- a/docs/abi-versioning.md +++ b/docs/abi-versioning.md @@ -90,7 +90,8 @@ kernel. Specifically, any of the following requires an `ABI_VERSION` bump: explicitly and coordinate the host implementation in the same ABI epoch. - Changing the name, version, encoding, or role semantics of the `kandelo.wpk_fork.capabilities` custom section. The host uses these claims to - decide whether a main/side-module pair can safely coordinate fork replay. + decide whether a main/side-module pair can safely coordinate fork replay and, + in ABI 43, whether the artifact satisfies activation-state ownership. - Renaming the ABI custom section or the process-expected globals. - Changing the meaning of a syscall argument, errno, or blocking behavior without changing its signature. **This is not caught @@ -99,7 +100,11 @@ kernel. Specifically, any of the following requires an `ABI_VERSION` bump: The fork-capability section has an explicit ABI transition rule. ABI 16 accepts an absent section through the pre-existing five-export fallback, while treating a present marker as authoritative. ABI 17 was intentionally skipped; ABI 18 -was the first epoch above 16 and made the role marker mandatory. +was the first epoch above 16 and made the role marker mandatory. ABI 43 adds +`FORK_CAP_ACTIVATION_STATE_SAFE` and requires it on every fork-instrumented +main or side module. An ABI 42 artifact does not become ABI 43-compatible by +copying the new capability byte: the embedded ABI version and the capability +contract are validated together. ABI 26 also makes `kernel_get_process_exit_signal` a required host-adapter export. The host uses the query unconditionally to distinguish signal death @@ -304,6 +309,58 @@ later failure enters `ABORT_UNWINDING`, reconstructs the committed inner frames, releases the partial continuation, and returns the errno from the original `fork()` call without terminating the parent. +### ABI 43 activation-owned fork replay + +ABI 43 closes the remaining dependency on mutable state in the parent Wasm +instance. A fork child receives copied linear memory but a newly instantiated +module, globals, tables, exception tags, and host Store. Module-static +reference tables therefore cannot prove that a replay value survived fork. + +Every ABI 43 fork artifact carries the version-1 +`kandelo.wpk_fork.capabilities` section with +`FORK_CAP_ACTIVATION_STATE_SAFE`. Instrumentation, package guards, Node and +browser executable resolution, worker launch, pthread launch, and side-module +loading treat the capability as part of the artifact contract. Missing, +duplicate, malformed, unknown-version, unknown-bit, or safety-bit-free +capabilities fail before execution. + +The instrumenter also rejects any input that already carries fork control +exports, linked-frame imports, or fork metadata. This prevents a transformed +ABI 42 module from being run through the ABI 43 tool merely to acquire the new +safety claim; package builds must instrument raw linker output. + +The frame contract remains version 1 and keeps its existing size and offsets, +but the formerly reference-stash-related word at frame offset `+12` is now +reserved zero. The instrumenter no longer creates +`_wpk_fork_funcref_stash`, `_wpk_fork_externref_stash`, or +`_wpk_fork_exnref_stash`. Supported statically tagged `Catch` and `CatchRef` +arms serialize their exact arm and scalar tag operands in each activation's +linked frame. During rewind the tool executes `throw` with that reconstructed +tag payload; the original `CatchRef` clause creates a fresh child-instance +exnref. + +Until a transferable representation or explicit versioned reconstruction +owner exists, the instrumenter rejects fork-reachable reference locals and +parameters, reference signatures and call carryovers, reference global reads, +reference operand-stack carryovers, reference-typed catch payloads, +`CatchAll`/`CatchAllRef`, and unsupported non-nullable, concrete, and Wasm-GC +references. References outside the conservative fork closure remain legal. +Mutable reference globals and guest table mutation are module-wide rejection +boundaries in a fork-using artifact; static element initialization remains +legal because instantiation recreates it. Dlopen is the explicit table-state +exception: host replay preserves the exact table base and re-instantiates each +side module's static element initialization in the child. + +This is an incompatible artifact epoch even though the linked-frame descriptor +version is unchanged. All fork-instrumented programs, side modules, package +bottles, binary indexes, shell closures, and VFS images must be rebuilt from +source. Existing C++ modern-EH outputs that retain exnref locals or use +`CatchAllRef` are truthful rebuild blockers, not candidates for metadata +relabeling or package-specific bypasses. The current Dash build is likewise +blocked by a fork-reachable exnref local in `expandstr`, so no ABI 43 shell +closure or rootfs/VFS image is presently publishable. Broad bottle, index, +shell, and image publication requires explicit release coordination. + ## The snapshot `abi/snapshot.json` is generated by `cargo xtask dump-abi` from the @@ -340,15 +397,17 @@ captures: contract. - `custom_sections` — names of wasm custom sections that participate in the ABI: `wasm-posix-abi` for the per-binary version and - `kandelo.wpk_fork.linked_frames` for the linked-continuation layout. + `kandelo.wpk_fork.linked_frames` for the linked-continuation layout, and + `kandelo.wpk_fork.capabilities` for fork role and activation-safety claims. - `process_expected_globals` — globals every user process instance is expected to expose for the host to thread through fork/exec. - `program_artifact` — requirements checked on instrumented user programs before they can be published: the linked-frame descriptor schema, its wasm32/wasm64 header sizes, the three transactional frame imports, and - the seven `wpk_fork_*` control exports with pointer-width-aware signatures. - The descriptor width, function signatures, and the module's single memory - address width are validated as one contract. + the seven `wpk_fork_*` control exports with pointer-width-aware signatures, + plus the capability-section version, known bits, and required safety bit. + The descriptor width, function signatures, capability claims, and the + module's single memory address width are validated as one contract. WHY this is snapshot-owned: a program can otherwise pass kernel ABI checks yet fail only when its first `fork()` reaches a newer host. - `kernel_exports` — every non-toolchain export in the built kernel diff --git a/docs/architecture.md b/docs/architecture.md index 832f1558f4..04695c210f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -426,17 +426,30 @@ channel, fork-context, and clear-TID metadata. ### fork() Fork uses the in-tree `wasm-fork-instrument` tool to snapshot the Wasm call stack (details in [fork-instrumentation.md](fork-instrumentation.md)): +Before compilation or worker launch, Node and browser hosts validate the +embedded ABI version, linked-frame contract, control exports, and ABI 43 +`FORK_CAP_ACTIVATION_STATE_SAFE` claim. Pthread and side-module entry points +apply the same policy. 1. User calls `fork()` → musl → `__syscall(SYS_clone, ...)` → glue 2. The host's `kernel_fork` override maps a root continuation chunk and calls `wpk_fork_unwind_begin(root + chunk_header_size)`. The tool-injected export sets state to UNWINDING and snapshots every mutable scalar global (including `__tls_base` and `__stack_pointer`) into the root's fixed prefix. -3. The return-to-caller chain unwinds. After each fork-path call returns in the unwinding state, the caller asks the host to reserve a complete node before its first frame write; its postamble commits the node only after all scalar and reference state has been saved. The host maps additional page-rounded chunks when necessary. +3. The return-to-caller chain unwinds. After each fork-path call returns in the + unwinding state, the caller asks the host to reserve a complete node before + its first frame write; its postamble commits the node only after all + activation-owned scalar state has been saved. The host maps additional + page-rounded chunks when necessary. No accepted frame names a + module-instance reference-table slot. 4. Once `_start` returns (top-of-stack), the host sends SYS_FORK through the channel. 5. Kernel's `kernel_fork_process(parent_pid, caller_tid)` validates the caller, allocates the child PID from the global task-ID sequence, and copies process metadata and the fd/OFD tables. The child receives the calling task's blocked signal mask, while inherited stateful descriptors retain references to their existing kernel-global backings. -6. Host copies the parent's linear memory, including continuation mappings, to a new `WebAssembly.Memory` and spawns a child worker. Kernel mmap metadata is inherited with the process state. +6. Host copies the parent's linear memory, including continuation mappings, to + a new `WebAssembly.Memory` and spawns a child worker. Kernel mmap metadata is + inherited with the process state. The worker creates a fresh Wasm instance: + mutable globals, tables, exception references, and Store-owned references + are not copied and are not evidence that replay state survived. 7. Child worker attaches to the copied root and calls `wpk_fork_rewind_begin(buf)` — the tool's export restores all saved globals. The host then calls `setupChannelBase(...)` (which reads the now-correct `__tls_base`) and invokes `_start`. 8. Each instrumented function's preamble requests and validates the next committed frame, then re-enters the call site where the parent was interrupted. Eventually it reaches the `kernel_fork` call site in the leaf function, which returns 0. Libc then refreshes the copied pthread TID from the kernel through `set_tid_address` before returning to user code. 9. `wpk_fork_rewind_end` resets state; parent and child independently unmap their continuation chunks; fork returns 0 in child and the child PID in the parent. @@ -469,17 +482,32 @@ errno. A negative `SYS_FORK` result after step 4 instead uses the complete parent rewind. These resource failures create no child and leave the parent in `NORMAL`, able to continue or retry `fork()`. -The instrumentation handles LLVM's new-EH `try_table` output correctly, including fork from inside C++ catch handlers. See [fork-instrumentation.md](fork-instrumentation.md) for the current guarantees and documented unanticipated Wasm-level carve-outs. +ABI 43 accepts statically tagged `Catch` and `CatchRef` handlers with scalar tag +payloads. Their exact arm and operands are activation-owned bytes; rewind +rethrows the tag so the original `CatchRef` clause creates a fresh +instance-local exnref. Fork-reachable reference locals, `CatchAllRef`, reference +payloads and carryovers, mutable reference globals, and arbitrary guest table +mutation are rejected during instrumentation. Current LLVM C++ output that +uses cleanup exnref locals or `CatchAllRef` therefore remains an explicit +unsupported artifact boundary rather than appearing to work through +parent-instance scratch. See +[fork-instrumentation.md](fork-instrumentation.md) for the exact accepted and +rejected shapes. A fork reached directly inside an instrumented dlopened side module uses two ordered state machines and two linked continuations: side then main during unwind, main then side during rewind. Versioned fork-instrument capability metadata lets -marker-present artifacts prove their role. ABI 16 defines the historical +marker-present artifacts prove their role, and ABI 43 additionally requires +`FORK_CAP_ACTIVATION_STATE_SAFE` before launch. ABI 16 defines the historical five-export fallback, while ABI 18 and later require role claims and reject stale call-graph artifacts. The ABI 36 epoch combines that contract with side-module replay state and concurrent pthread-fork arbitration. Dlopen replay records both the parent's memory base and exact table base, including null gaps -left by failed loads. TLS-bearing side modules additionally record their live, +left by failed loads, then re-instantiates each side module's static element +initialization at that exact base in the child. This host-owned, versioned replay +path is the explicit reconstruction owner for supported dlopen table state; +guest `table.set`/`fill`/`copy`/`init`/`grow` effects are not accepted. +TLS-bearing side modules additionally record their live, positive `__tls_base`. A child restores the pointer-width-correct mutable global without calling `__wasm_init_tls`, because copied memory already holds the parent's live TLS bytes and reinitialization would reset C++ unwinder state diff --git a/docs/fork-instrumentation.md b/docs/fork-instrumentation.md index 6f4c3a3c58..aefe445beb 100644 --- a/docs/fork-instrumentation.md +++ b/docs/fork-instrumentation.md @@ -20,7 +20,7 @@ For motivation, tradeoffs, and the rollout plan that led here, read for the post-rollout switch-dispatch redesign and non-fork-path-call gating that fix the kernel-side-effect re-fire bug, read [`plans/2026-04-22-fork-instrument-switch-dispatch-redesign.md`](plans/2026-04-22-fork-instrument-switch-dispatch-redesign.md). -ABI version: `42` (see +ABI version: `43` (see [`crates/shared/src/lib.rs`](../crates/shared/src/lib.rs) — see [abi-versioning.md](abi-versioning.md) for the policy). @@ -33,6 +33,13 @@ ABI version: `42` (see - Missing instrumentation is a build/runtime error, not an optional feature loss. A fork-using program without complete `wpk_fork_*` exports cannot resume the child at the fork call site. +- Every value needed after replay must be either activation-owned bytes in the + linked continuation or the output of a versioned deterministic + reconstruction recipe in the fresh child. Module globals and tables are + instance state, not evidence that a value survived `fork()`. +- ABI 43 fork artifacts must carry the activation-state-safe capability. An + unsupported reference or table shape fails during instrumentation or + pre-launch artifact validation; it must not become a child-only trap. - Binaries exporting legacy `asyncify_*` symbols are stale and must be rebuilt. Do not add host support for them. - Do not keep compiler/linker flags solely for the retired legacy path. The @@ -125,17 +132,17 @@ wpk_fork_state() -> i32 Returns current state. Exported for host-side assertions. ``` -ABI 42 modules additionally import three exact `env` functions. A module that +ABI 42 and later modules additionally import three exact `env` functions. A module that imports any one of them must import all three and carry the linked-frame custom section described below. ``` __wpk_fork_frame_reserve(frame_size: ptr) -> ptr Reserves a complete node and returns its payload address before any frame - bytes or reference-table entries are written. + bytes are written. __wpk_fork_frame_commit(payload: ptr) -> () - Publishes the pending node after all payload and reference writes complete. + Publishes the pending node after the activation-owned payload is complete. __wpk_fork_frame_next(expected_frame_size: ptr) -> ptr Returns the next committed payload during rewind and rejects size/order @@ -151,27 +158,46 @@ section `kandelo.wpk_fork.capabilities`. Its two-byte payload is `env.fork`-importing side module has complete side-entry coverage; - bit 1 (`0x02`): a default-entry main module imported Kandelo's dynamic-linker functions and conservatively instrumented every `call_indirect` boundary - plus its direct callers. - -Capability enforcement follows the compiled kernel ABI. ABI 16 predates this -section, so an artifact with no section retains the legacy five-export fallback -and can still coordinate a main/side-module fork. If an ABI-16 artifact does -carry the section, its marker is authoritative and malformed, unknown, or -role-inconsistent claims fail loudly. Starting with ABI 18, the -role-appropriate bit is mandatory: generic five-export artifacts and binaries -produced by the older call-graph pass fail with a rebuild diagnostic. This -threshold ensures that mandatory enforcement and the incompatible artifact -contract activate in the same ABI-bump commit. Changing the meaning or encoding -of these capability claims changes fork replay assumptions and must follow the -ABI-versioning policy. - -ABI 17 was intentionally skipped. ABI 18 activated the mandatory role marker. -ABI 16 remains only a historical compatibility boundary. The reconstructed -line enforces role claims and this ABI 36 epoch adds side-module replay state -and pthread-fork arbitration. ABI-16 artifacts without a marker remain -historical inputs for the parser's explicit compatibility tests; they do not -satisfy an ABI-36 launch. Any future capability-contract change must still -advance `ABI_VERSION` and regenerate `abi/snapshot.json` atomically. + plus its direct callers; +- bit 2 (`0x04`, `WPK_FORK_CAP_ACTIVATION_STATE_SAFE`): the instrumenter + validated the complete fork closure and rejected state that cannot be + reconstructed in a fresh module instance. + +ABI 43 requires exactly one two-byte capability section, version 1, with bit 2 +set. Unknown bits, missing/duplicate/malformed sections, or a missing safety bit +fail artifact validation before execution. Role bits retain their existing +meaning and are still required for side-entry and dynamic-linking replay where +applicable. The safety bit is not inferred from the seven control exports: +ABI 42 emitted those exports while still depending on module-instance +reference tables. A copied safety claim also cannot upgrade a normal ABI 42 +program because the program ABI marker must match 43. + +ABI 16/18 role-marker compatibility remains historical parser-test coverage; +it is not a launch fallback for an ABI 43 kernel. Changing the capability +encoding or meaning requires another ABI bump and regenerated snapshot. + +### ABI 43 deployment and rebuild boundary + +ABI 43 is an artifact epoch, not a host-only update. All fork-instrumented main +programs and side modules must be rebuilt with the ABI 43 instrumenter, then +the affected package archives, bottles, binary indexes, shell closure, and VFS +images must be regenerated against the new cache keys. Kernel, host, SDK/libc, +and generated ABI constants must ship as one coordinated set. + +Do not republish ABI 42 artifacts with edited metadata or a copied capability. +The ABI 43 instrumenter refuses inputs that already contain fork control +exports, linked-frame imports, or fork metadata; builds must start from raw +linker output so the new validation sees the original activation and table +state. +The source package projection may be regenerated while developing this epoch, +but broad bottle/index/VFS publication requires explicit release coordination. +Modern C++ exception artifacts that contain fork-reachable `exnref` locals or +`CatchAllRef` are currently rebuild blockers: the ABI 43 instrumenter rejects +them truthfully until a sound liveness or reconstruction design exists. +The current source build of Dash is blocked for the same reason in +`expandstr`, where LLVM emits a fork-reachable `exnref` local. Consequently, +the ABI 43 shell closure and canonical rootfs/VFS images cannot be rebuilt or +published yet; ABI 42 images must not be relabeled for this epoch. `ptr` is `i32` on wasm32 user programs and `i64` on wasm64 user programs. The tool picks the pointer width from the module's primary memory — a memory64 @@ -328,7 +354,7 @@ The module prefix retains the runtime's active-frame pointer word, a reserved pointer word, saved scalar globals, and a 16-byte abort selector. `frames_start_offset = 2P + N` identifies the selector, while the host-visible fixed-prefix size is `frames_start_offset + 16`. Frame nodes and -plain-catch activation state are not stored in that prefix. +tagged-catch activation state are not stored in that prefix. This does not introduce a new linked-frame encoding. `fixed_prefix_size` has always been a module-specific value in the version-1 descriptor, and each node @@ -345,7 +371,7 @@ not fit the next complete node, the host maps another page-rounded chunk. A single node larger than a WebAssembly page receives a multi-page chunk. Allocation is transactional: a reserved node is not linked from the committed -tail until all scalar and reference writes finish. If a later chunk allocation +tail until all activation-owned bytes are written. If a later chunk allocation fails, the reserve import records the positive errno, enters `ABORT_UNWINDING`, and returns a zero pointer. The still-live activation stores only its call-site selector in the fixed-prefix scratch and restarts; already @@ -365,9 +391,11 @@ Parent and child independently walk and unmap their copies after rewind. The linked format makes chunk boundaries explicit, but version 1 does not rebase internal pointers or relocate the chain in the child. -Ref-typed mutable globals (`funcref` / `externref` / `exnref`) are not stored -in the linear-memory header — they would need aux-table spill slots, which is -a future extension. The tool currently ignores them when snapshotting globals. +Mutable reference globals (`funcref` / `externref` / `exnref`) are not stored +in the linear-memory header. A fork-capable module containing one is rejected +before runtime injection because its current value belongs to the parent module +instance. The inert runtime injected into a module with no fork seed may leave +unrelated reference globals alone because that module never replays. ## Frame format @@ -384,27 +412,26 @@ wasm64 before alignment. | `+0` | 4 | `func_index` | Ordinal assigned at instrument time | | `+4` | 4 | `call_index` | Which call site within the function | | `+8` | 4 | `catch_region_id` | 0 in normal flow; non-zero for catches | -| `+12` | 4 | `exnref_slot` | Aux-table slot for `_ref` catch replay | +| `+12` | 4 | reserved | Deterministic zero in ABI 43 | | `+16` | var | `saved_locals[]` | User and synthetic scalars, aligned | -Ref-typed user locals (funcref, externref, exnref) do **not** appear in this -frame. They are spilled to auxiliary tables — see [Auxiliary -tables](#auxiliary-tables) below. The frame only records the ordinal identity -of the function and its call-site, which together with the ref-table slot -assignment is sufficient to restore the ref-typed locals during rewind. +Every value in a frame is scalar. Fork-reachable reference locals, parameters, +signatures, global reads, call carryovers, and reference-typed catch payloads +are rejected before rewriting; the instrumenter never substitutes a +module-instance table slot for a transferable value. Synthetic frame locals include call-argument and operand-stack carryover -spills. For each supported plain-catch region they also include one +spills. For each supported tagged-catch region they also include one `active_arm` i32 and typed scalar operand locals for every static arm. Capture therefore belongs to one function activation, and recursive activations serialize distinct values in distinct linked frames. `catch_region_id` is zero in the common case (the frame was captured outside any catch handler). When non-zero, it identifies the `try_table` whose catch -handler the frame lives in. For a `_ref` catch, `exnref_slot` identifies the -auxiliary-table entry. For a plain catch, the restored `active_arm` and operand -locals select and reconstruct the exact arm. See [Catch-handler -resume](#catch-handler-resume). +handler the frame lives in. The restored `active_arm` and operand locals select +the exact static `Catch` or `CatchRef` clause. Rewind throws that arm's tag and +scalar payload; for `CatchRef`, normal Wasm exception dispatch creates a fresh +child-instance exnref. See [Catch-handler resume](#catch-handler-resume). ## Dispatch schemes @@ -440,8 +467,8 @@ Both shapes share: - The state machine, exported ABI, and save-buffer header. - The per-function frame layout (header + scalar locals). -- Aux-table spill for ref-typed user locals (Phase 4f). -- Catch-handler resume via `throw_ref` (Phase 6). +- Activation-owned tagged-catch arm and scalar payload state. +- Catch-handler reconstruction by throwing the saved static tag. Switch-dispatch avoids the need for per-call gating: no chunk before the chosen `POST_K` runs on REWIND, so non-fork-path calls and side-effect ops @@ -549,7 +576,7 @@ Numbered callouts: test. Under `REWINDING`, the preamble calls `__wpk_fork_frame_next(frame_size)`, stores the returned payload in `*(buf + 0)`, and deserializes every frame scalar: user locals, - argument/carryover spills, and plain-catch activation state. Dispatch reads + argument/carryover spills, and tagged-catch activation state. Dispatch reads `call_index` directly from that active frame payload. 2. **Body wrapper (Phase 4b/4c).** The original body is wrapped in a `$unwind_save` block. On `REWINDING`, a `br_table` keyed by `frame.call_index` jumps to @@ -563,10 +590,10 @@ Numbered callouts: `frame.call_index` and exits `$unwind_save`. If the callee did not begin unwinding, execution continues normally. 5. **Postamble (Phase 4d).** Emits the remaining frame header fields - (func_index, catch_region_id, exnref_slot), writes every user and synthetic - frame scalar, commits the reserved node, and returns a default value of the - function's result type. Callers see the default on the unwind path but - discard it because their own postamble runs next. + (`func_index`, `catch_region_id`, and a zero reserved word), writes every + user and synthetic frame scalar, commits the reserved node, and returns a + default value of the function's result type. Callers see the default on the + unwind path but discard it because their own postamble runs next. ### (b) Fork from inside a catch handler @@ -575,65 +602,67 @@ Fixture: `FIXTURE_FORK_FROM_CATCH_HANDLER` (see ```wat (func $caller (result i32) - (block $handler (result (ref null exn)) - (try_table (result (ref null exn)) (catch_ref $exn $handler) - ref.null exn)) + (local $caught i32) + (block $handler (result i32 exnref) + (try_table (result i32 exnref) (catch_ref $exn $handler) + i32.const 7 + throw $exn)) drop + local.set $caught call $fork) ``` -After instrumentation the try_table clause gets wrapped in two injected -blocks, `$outer` and `$capture`, and the try_table body gets a rewind-throw -stub prepended: +After instrumentation the `CatchRef` clause targets an injected capture block +and the try_table body gets a rewind-throw stub. The exact emitted nesting is +omitted here; the important dataflow is: ```wat -(block $outer (result (ref null exn)) - (block $capture (result (ref null exn) exnref) - (try_table (result (ref null exn)) (catch_ref $exn $capture) - ;; [6c] Rewind-throw stub: executed lexically first on every entry. - (if (i32.and - (i32.eq (global.get $_wpk_fork_state) (i32.const 2)) - (i32.eq (local.get $catch_region_id_local) (i32.const 1))) - (then - ;; Resume into this try_table's catch handler by re-throwing - ;; the saved exnref. - (throw_ref - (ref.as_non_null - (table.get $_wpk_fork_exnref_stash - (local.get $exnref_slot_local)))))) - - ;; Original try_table body. - (ref.null exn))) - - ;; [6d] On catch_ref dispatch, stack = (ref null exn, exnref). - (local.tee $captured_exnref_1) - (local.set $in_catch_1 (i32.const 1)) - (table.set $_wpk_fork_exnref_stash - (i32.const 0 (; slot ;)) - (local.get $captured_exnref_1)) - (br $outer)) ;; fall through to the original handler continuation +;; Inside the original try_table body: +(if (i32.and + (i32.ge_u (global.get $_wpk_fork_state) (i32.const 2)) + (i32.eq (local.get $catch_region_id) (i32.const 1))) + (then + (if (i32.eq (local.get $active_arm) (i32.const 0)) + (then + ;; The frame restored 7 (or the actual scalar payload). + local.get $saved_payload + throw $exn) + (else unreachable)))) + +;; On CatchRef dispatch, stack = (i32 payload, non-null exnref): +local.set $temporary_exnref +local.set $saved_payload +i32.const 0 +local.set $active_arm +i32.const 1 +local.set $in_catch +i32.const 1 +local.set $catch_region_id + +;; Forward the original handler values, but retain no synthetic GC root. +local.get $saved_payload +local.get $temporary_exnref +ref.as_non_null +ref.null exn +local.set $temporary_exnref +br $handler ``` Numbered callouts: -- **6c — Rewind-throw stub.** Prepended to every fork-path try_table body. - On `REWINDING` with a matching `catch_region_id`, it re-throws the saved - exnref using `throw_ref`. The try_table's own catch clause catches it, - which dispatches into `$capture` exactly as if the original exception had - been thrown by the body. -- **6d — Capture block.** The tool rewrites every `catch_ref` / `catch_all_ref` - clause to target an injected `$capture` block rather than the user's - original handler. `$capture` stashes the exnref into - `_wpk_fork_exnref_stash`, sets the `$in_catch_K` flag, then unconditionally - branches to `$outer`, which is the block the user's original handler falls - through from. The net effect: the user's handler code runs with the exnref - already stashed and `in_catch_K == 1`, ready for a later fork call to - record it. -- **6e — Call-site region writes.** Any call site inside the handler - observes `$in_catch_K == 1` and writes the active region's id and exnref - slot into `$catch_region_id_local` / `$exnref_slot_local` before the - unwind-only call-index store and `$unwind_save` branch, so the frame - carries the handler identity into the save buffer. +- **Rewind-throw stub.** On replay with a matching `catch_region_id`, dispatch + validates the restored arm index, pushes that arm's restored scalar payload, + and executes `throw $tag`. The original `CatchRef` clause catches this new + exception and creates a fresh exnref in the child instance. +- **Capture block.** Every statically tagged `Catch` and `CatchRef` clause is + retargeted through a per-arm capture. It stores only the arm index and scalar + payload in frame-backed locals. A `CatchRef` exnref is temporarily forwarded + to the original handler; the synthetic local is nulled before the branch so + successful replay and abort paths do not retain a stale GC root. +- **Call-site region writes.** A call inside the handler observes the + activation-local `$in_catch_K` flag and records the lexical region in the + frame before unwinding. There is no reference slot or module-global + reference state. ### (c) Indirect fork through `call_indirect` @@ -864,47 +893,50 @@ The same `Option` policy applies to the top-level function still reaches an unsupported carryover shape, the tool rejects that shape loudly; there is no guard-dispatch fallback after the mega-PR cleanup. -## Auxiliary tables - -When the module has at least one fork-path ref-typed user local of a given -class, the tool emits a per-class stash table: - -``` -(table $_wpk_fork_funcref_stash funcref) -(table $_wpk_fork_externref_stash externref) -(table $_wpk_fork_exnref_stash (ref null exn)) -``` - -Modules with no ref-typed fork-path locals of a given class emit no table for -that class. A module with no fork-path try_tables and no fork-path ref-typed -locals emits zero aux tables. - -Slot assignment is per-class and contiguous: - -- The tool walks the fork-path functions in deterministic order. -- For each function, each ref-typed user local gets the next slot in its - class's table. -- For each fork-path `try_table`, the exnref class additionally reserves one - slot to hold the currently-caught exnref while a handler runs. - -Each table's `initial` size is set to exactly the assigned slot count so the -cost is bounded. Slot indices are baked into the postamble (as `table.set`) -and preamble (as `table.get`) of the owning function, and into the `$capture` -blocks emitted for fork-path `catch_ref` / `catch_all_ref` clauses. - -Scalar operand-stack values at call sites are spilled to synthetic scalar -locals, not tables — they are scoped to a single call-site window and do not -cross the unwind/rewind boundary. +## Reference and table-state validation + +ABI 43 retires `_wpk_fork_funcref_stash`, +`_wpk_fork_externref_stash`, and `_wpk_fork_exnref_stash`. The tool never +emits them. Static slots were unsafe twice over: recursive/reentrant +activations could alias, and every fork child starts from a fresh module +instance whose tables are empty. JavaScript cannot generically transfer +`funcref`/`externref` across workers or Stores, and the Table API cannot copy +`exnref`. + +Validation runs after fork-closure discovery and before runtime injection. In a +fork-reachable function it rejects: + +- reference locals, parameters, function signatures, global reads, call + signatures, and operand-stack carryovers; +- `CallRef`/`ReturnCallRef`, unsupported nonnullable/concrete/GC reference + instructions, and reference-typed catch tag payloads; +- `CatchAll` and `CatchAllRef`, because there is no static tag identity to save. + +Reference-bearing functions outside the fork closure remain legal and are not +rewritten. Mutable reference globals are rejected module-wide in a +fork-capable module because code outside the closure may mutate them before a +later call into `fork()`. + +Wasm table mutation is likewise module-instance state. If a module can fork, +the presence of `table.set`, `table.fill`, `table.copy`, `table.init`, or +`table.grow` anywhere in its local functions rejects the artifact. Active +static element initialization remains legal because instantiation recreates +it. Dynamic linking is an explicit host-owned reconstruction boundary rather +than an exception to this rule: the dlopen archive preserves each side +module's exact table base, the child replays libraries in order, and normal +side-module instantiation recreates their static elements. A different table +mutation owner must define and test an equally deterministic recipe before +the instrumenter may accept it. ## Catch-handler resume -Catch-handler resume is the subtlest piece of the tool. The overall idea: -at unwind time, save the caught exnref into the stash table and record the -try_table's `catch_region_id` in the frame. At rewind time, re-throw the -saved exnref *from inside the same try_table body*, so the normal wasm -exception-dispatch rules deliver it back to the original catch clause, which -sends control into the handler — whose own state-machine preamble then -continues to the fork call site. +Catch-handler resume saves a reconstruction recipe, never an exception +reference. Normal handler entry records the lexical region, exact catch-list +arm, and that static tag's scalar payload in activation-owned locals. Those +locals serialize with the function frame. Rewind dispatches inside the same +`try_table` body, restores the selected scalar tuple, and executes the +selected arm's `throw $tag`. Normal Wasm exception dispatch reaches the +original clause; `CatchRef` receives a new exnref owned by the child instance. ``` ┌────────────────────────────────────────────────────────────────────┐ @@ -918,9 +950,8 @@ continues to the fork call site. │ more_handler_code │ └────────────────────────────────────────────────────────────────────┘ │ - │ unwind: save exnref X to stash, - │ frame.catch_region_id = K, - │ frame.exnref_slot = S, + │ unwind: save region K, arm A, + │ and scalar tag payload in this frame, │ drain frames to top. ▼ ┌────────────────────────────────────────────────────────────────────┐ @@ -931,10 +962,9 @@ continues to the fork call site. │ │ │ try_table body rewind-throw stub: │ │ state == REWINDING && catch_region_id == K → │ -│ throw_ref (table.get $_wpk_fork_exnref_stash S) │ -│ ← caught by try_table's own catch clause, dispatches to │ -│ the $capture block; $capture branches to $outer, placing │ -│ control at the top of the user's handler code. │ +│ validate arm A; push saved scalar payload; throw $tag_A │ +│ ← caught by the original Catch/CatchRef clause; CatchRef │ +│ creates a fresh child-instance exnref. │ │ │ │ handler-level preamble (state still REWINDING): │ │ resume at the fork() call site with return value = child pid 0 │ @@ -943,20 +973,13 @@ continues to the fork call site. └────────────────────────────────────────────────────────────────────┘ ``` -`catch_ref` and `catch_all_ref` clauses use the exnref stash + `throw_ref` -flow above. Plain (non-`_ref`) `catch` clauses have no exnref to re-throw. -For those clauses, normal capture stores the exact nonnegative catch-list arm -index and scalar operand tuple in activation-local frame locals. Rewind -compares the restored arm index, pushes that arm's restored operands, and -throws the original tag back through the same `try_table`. - -In a region that mixes `_ref` and plain catches, `_ref` capture stores -`active_arm = -1`; exact nonnegative IDs select plain arms and every other -value falls through to `throw_ref`. The mode is deliberately frame-owned. -Auxiliary-table nullness is neither activation identity nor reliable handler -kind state because a static table entry can retain an older value. See -[Fork-from-plain-catch](#fork-from-plain-catch) under "Maintainer notes" for -the implementation. +Mixed `Catch`/`CatchRef` lists, multiple arms, and distinct target labels use +the same exact catch-list index. An unknown restored index executes +`unreachable`; it cannot fall back to old instance state. `CatchAllRef` is +rejected, as are reference-typed tag payloads and a caught exnref that remains +live in a local or operand-stack carryover at the fork call. See +[Fork from a tagged catch](#fork-from-a-tagged-catch) under "Maintainer notes" +for the implementation. ## Call-graph discovery @@ -1014,9 +1037,11 @@ K-04, and K-07 cover the current behavior. invoked `fork()`. - **Scalar user locals.** All i32, i64, f32, f64, and v128 locals on the fork-path are saved to linear memory at unwind and restored at rewind. -- **Ref-typed user locals.** funcref, externref, and exnref locals are - spilled to aux tables at unwind and restored at rewind. Slot assignments - are deterministic per module. +- **Fresh-instance ownership.** Every accepted replay value is either scalar + activation state in the linked continuation or state rebuilt by an explicit, + versioned reconstruction owner. Instrumented modules carry + `FORK_CAP_ACTIVATION_STATE_SAFE`; ABI 43 hosts and artifact guards reject a + fork-shaped artifact without that capability before execution. - **Byte-reproducible instrumentation.** Given the same input bytes, CLI options, and built tool, separate processes emit byte-identical Wasm. Synthetic locals and nested regions are assigned in canonical sequence-ID @@ -1026,9 +1051,16 @@ K-04, and K-07 cover the current behavior. Includes `__stack_pointer`, `__tls_base`, and any program-declared mutable globals. - **try_table context.** Frames captured inside a supported fork-path catch - handler carry the active `catch_region_id`. `_ref` catches replay from their - exnref stash slot; plain catches replay from activation-local arm and scalar - payload state serialized in the same frame. + handler carry the active `catch_region_id`, exact catch-list arm, and scalar + tag operands. Rewind rethrows the restored tag and operands through the + original `Catch` or `CatchRef` clause. A `CatchRef` clause therefore creates + a fresh exnref in the child instance; no parent-instance reference is + serialized or consulted. +- **No retained replay references.** The instrumenter emits none of the + historical `_wpk_fork_*ref_stash` tables. The temporary exnref used while a + `CatchRef` handler enters is cleared immediately after the original handler + value has been re-pushed, so normal completion, rewind, and abort do not + retain it as an instrumentation-owned GC root. - **Kernel-side-effect calls don't re-fire during REWIND.** Switch-dispatch (the only live scheme post-commit-4) skips the body chunks before the matching `POST_K` entirely on REWIND, so non-fork-path direct calls @@ -1041,37 +1073,37 @@ K-04, and K-07 cover the current behavior. - **`makecontext` / `swapcontext` / `getcontext` / `setcontext`.** Userspace stack-switching primitives are unsupported and not on any roadmap. See [posix-status.md](posix-status.md) for rationale. -- **Functions whose plain-catch arms carry ref-typed operands.** Catch arms - whose operand tuple includes an `(ref ...)` value (typically a function or - GC ref) are excluded from plain-catch replay support at instrument time. - The function may still be instrumented for other fork sites, but a fork - reached from the affected handler remains unsupported. Spilling ref-typed - catch operands would require per-arm activation-aware auxiliary storage. - The current implementation keeps that explicit - `PlainCatchPlan::b2_carveout` boundary and covers it with WAT-level tests. - This is an unanticipated Wasm-level case, - not expected output from ordinary C++ EH lowering: C++ exception payloads - live in linear memory / libc++abi state rather than as `funcref` or - `externref` plain-catch tag operands. If a future language frontend or - hand-written Wasm module needs it, implement per-arm funcref/externref - aux-table stashing and promote C-08/C-09 from carve-out validation to full - replay tests. -- **Recursive/reentrant ref activation state.** Auxiliary-table slots for - ref-typed user locals and caught exnrefs are assigned statically per - function or `try_table`. Recursive or reentrant activations that keep - distinct ref values live across one fork can therefore alias those slots. - Scalar plain-catch arm/payload state does not have this limitation because - it is serialized per activation. Closing the ref case requires - activation-aware auxiliary storage and executable recursive regressions. +- **Reference activation state.** Reference-typed locals or parameters, + reference function signatures and call carryovers, reference global reads, + reference operand-stack carryovers, and reference-typed catch payloads are + rejected when they are in the conservative fork closure. `CatchAll` and + `CatchAllRef` are also rejected there because they provide no statically + tagged scalar reconstruction recipe. References in functions outside the + fork closure remain legal. +- **Module-owned mutable reference state.** A mutable reference global is + rejected whenever a module has a fork closure. The child receives a fresh + module instance, so copying linear memory cannot reproduce that global. +- **Mutable table state.** Guest `table.set`, `table.fill`, `table.copy`, + `table.init`, and `table.grow` are rejected in a fork-using module. Static + element initialization is recreated by instantiation and remains supported. + Host-owned dlopen replay is a separate explicit reconstruction boundary: it + preserves the exact table base and re-instantiates the side module's static + elements in the child. - **IfElse with operand-stack carryover.** A fork-bearing `if/else` enclosing a stack value that survives across the branch is rejected by `seq_has_unsupported_carryover` — the cond rewrite via `select` (see §IfElse cond rewrite) doesn't currently compose with carryover spilling. Rare in LLVM output; not tracked as a current blocker. -- **Wasm-GC refs.** Abstract `any` / `eq` / `struct` / `array` / `i31` refs - and concrete GC refs are rejected at the `classify_ref` step — the tool - panics rather than produce a silently-broken module. Add classes in - `crates/fork-instrument/src/instrument.rs` when a real program needs them. +- **Non-nullable, concrete, and Wasm-GC refs.** Unsupported reference + construction and GC operations in the fork closure are rejected before + rewriting. Support requires an activation-owned byte representation or a + deterministic fresh-instance reconstruction recipe, not a new module-static + table. +- **Current C++ cleanup-EH shapes.** LLVM output that keeps exnref locals live + across fork or lowers cleanup regions to `CatchAllRef` is intentionally + rejected. Those programs must remain unavailable in ABI 43 until the + compiler output or replay design satisfies the ownership invariant; a + capability stamp or package patch must not hide this boundary. #### Closed since the mega-PR's 2.5/2.6 sub-commits @@ -1138,15 +1170,15 @@ and loop structure but does not allocate continuation memory. The module-format fixed cost is three imports, two abort exports, plus the 24-byte `kandelo.wpk_fork.linked_frames` descriptor and normal Wasm section/name encoding. The fixed 60 KiB host-reserved control-region geometry remains in -ABI 42, but it is no longer continuation capacity: only its anchor word is -used to find the dynamically allocated root chunk. +place from ABI 42, but it is no longer continuation capacity: only its anchor +word is used to find the dynamically allocated root chunk. -A function with supported plain catches adds one i32 `active_arm` local per -plain-capable region plus typed scalar operand locals for every supported arm -to each activation's frame payload. This can use more aggregate continuation -bytes than one module-global tuple, but distinct activation storage is required -for recursion and reentrancy correctness and removes normal-execution writes -to continuation-owned memory. +A function with supported tagged catches adds one i32 `active_arm` local per +region plus typed scalar operand locals for every supported `Catch` or +`CatchRef` arm to each activation's frame payload. This can use more aggregate +continuation bytes than one module-global tuple, but distinct activation +storage is required for recursion and reentrancy correctness and avoids any +module-global replay tuple. As a narrow size check, instrumenting the P-10 deep-recursion fixture from the same 27,886-byte raw Wasm produced 50,873 bytes with the ABI 41 instrumenter @@ -1243,22 +1275,24 @@ interceptor recurses into ASAN init which holds a spin mutex). The fuzzer targets validator/semantic divergence rather than memory-safety, so ASAN is not load-bearing. -### Adding a new ref type +### Supporting additional reference state + +Do not add a module-static reference stash. A fresh fork child has a new Wasm +instance, table, Store, and exception-tag identity, so a slot number is not a +transferable value even if it happens to fix same-instance recursion. + +Support for a new reference shape requires one of two complete designs: -Ref types accepted for local / global spilling are gated by `classify_ref` -in `crates/fork-instrument/src/instrument.rs`. To add support for a new -class: +1. Encode every value needed by replay as versioned activation-owned bytes in + the linked continuation, then reconstruct the reference deterministically + in the child. +2. Name an explicit host reconstruction owner, version its recipe, and prove + Node, browser, pthread, and side-module parity. -1. Extend the `RefClass` enum with the new class. -2. Map the corresponding `HeapType` variant in `classify_ref` to the new - class. -3. If the new class cannot share an existing stash table (e.g. it is a - wasm-GC ref that requires `ref.cast` at reload time), add a new table to - `AuxTables`, size it the same way the existing classes do, and extend - the spill / reload emitters to target it. -4. Add a fixture test under `tests/instrument.rs` that exercises the new - type both as a local and as a function parameter, and confirms the - module validates after round-tripping through the tool. +Add rejection tests first, then fresh-instance replay tests that would fail if +the parent module's globals or tables were consulted. Update the capability +contract and bump the ABI if the accepted artifact surface or reconstruction +format changes. ### Extending side-effect coverage @@ -1268,48 +1302,46 @@ the containing switch-dispatch shape skips that opcode on REWIND. Existing examples are the S-01..S-08 host fixtures plus the WAT-level table-operation tests in `crates/fork-instrument/tests/coverage_wat.rs`. -### Fork-from-plain-catch +### Fork from a tagged catch -Plain (non-`_ref`) `catch` arms unwrap the thrown exception's operand tuple -onto the operand stack at handler entry, but unlike `catch_ref` / -`catch_all_ref` they do not push an exnref. The Phase 6 rewind-throw stub -reaches the handler by `throw_ref`-ing a saved exnref into the original -try_table's catch clause; with a plain catch there is no exnref to save, so -some other resume path is needed. +`Catch` arms unwrap the thrown exception's operand tuple at handler entry. +`CatchRef` arms additionally push an instance-local exnref. Neither reference +identity nor module scratch is available in a fresh child, so both forms replay +from the same statically tagged scalar recipe. The implementation adds that path without accessing continuation memory during ordinary catch execution: -1. **Static discovery (`plan_plain_catches`).** Walk each fork-path function - and collect every supported plain-catch arm's tag, target label, catch-list - index, and operand types. This plan contains no runtime addresses or - activation state. -2. **Activation allocation (`allocate_plain_catch_state`).** Allocate one - `PlainCatchRegionState` per static region: an i32 `active_arm` plus typed - scalar operand locals for every supported arm. -3. **Frame ownership (`append_plain_catch_frame_scalars`).** Add those locals - before frame offsets and size are assigned. Each recursive activation then - saves and restores its own catch values in its own linked frame. -4. **Capture and replay.** `apply_plain_catch_handlers` stores the incoming - operand tuple and exact arm index in those locals, then re-pushes the - operands for the user handler. `inject_rewind_throw_stubs` dispatches on - the restored exact arm index and rethrows its tag with the restored tuple. - Mixed `_ref` capture records `-1`, which selects the `throw_ref` fallback - without consulting stale table nullness. -5. **Carve-out (`PlainCatchPlan::b2_carveout`).** A function whose plain-catch - arms carry ref-typed operands or use an unverified multi-target shape is - excluded from supported plain-catch replay. Other fork sites in that - function may still be instrumented. Scalar frame serialization does not - solve reference ownership; that needs activation-aware auxiliary storage. - -The lifetime boundary is load-bearing: a plain catch can run before any fork -or after a prior continuation has been released. Its normal capture path must +1. **Static discovery (`plan_plain_catches`).** Walk each fork-path + function and collect each supported `Catch` or `CatchRef` arm's tag, target + label, exact catch-list index, kind, and scalar operand types. This plan has + no runtime addresses or activation state. +2. **Activation allocation.** Allocate one region-local i32 arm selector plus + typed scalar operand locals for every supported arm. A `CatchRef` arm also + gets one temporary nullable exnref local used only while entering the + capture block. +3. **Frame ownership.** Append the selector and scalar operands before frame + offsets are assigned. Each recursive or reentrant activation therefore + owns a distinct serialized catch recipe. +4. **Capture.** A generated block stores the incoming scalar tuple and exact + arm index, then restores the original handler stack. For `CatchRef`, it + temporarily stores the exnref, pushes it back as non-null for the original + target, and clears the synthetic local immediately so instrumentation does + not retain a stale GC root. +5. **Replay.** `inject_rewind_throw_stubs` dispatches on the restored exact arm + index, pushes its scalar tuple, and executes `throw` with the original tag. + The original clause then reconstructs either the plain payload or a fresh + child-local exnref. An unknown arm traps instead of consulting old instance + state. + +The lifetime boundary is load-bearing: a catch can run before any fork or +after a prior continuation has been released. Its normal capture path must therefore never dereference `_wpk_fork_buf`. C-08/C-09 in `crates/fork-instrument/tests/coverage_wat.rs` verify that funcref and -externref catch operands retain this explicit unsupported boundary rather than -being serialized as scalars. +externref catch operands are rejected precisely rather than serialized as +scalars or placed in a module-static table. ## See also diff --git a/docs/posix-status.md b/docs/posix-status.md index 54bdfff181..255a07b720 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -144,7 +144,7 @@ same final-OFD lifetime rules. | `futex()` | Partial | FUTEX_WAIT, FUTEX_WAKE, FUTEX_REQUEUE, FUTEX_CMP_REQUEUE, and FUTEX_WAKE_OP operate on one process's shared memory. Main-process WAIT uses host `Atomics.waitAsync`; pthread workers use direct `Atomics.wait`. Separate processes have separate `SharedArrayBuffer` objects, so these operations do not wake or synchronize a peer PID even when the futex word lies in a host-coordinated MAP_SHARED mapping. | | `execve()` | Partial | Delegates to the in-place `exec()` path and has the same remaining descriptor/signal/mapping limitations described above. | | `execveat()` | Partial | SYS_EXECVEAT (386). Resolves fd path via `kernel_get_fd_path`, supports AT_EMPTY_PATH for `fexecve()`, and resolves relative paths against process CWD; otherwise has the same remaining `exec()` limitations. | -| `fork()` (syscall) | Partial | Glue traps through channel IPC; the kernel copies process state, the host starts a child Worker, and `wasm-fork-instrument` replays the supported call stack so parent/child receive the POSIX return values. Negative results replay to the caller without terminating the parent, including continuation-allocation failure before or during unwind. The side-module and ordinary-OFD limitations in the main `fork()` row still apply. | +| `fork()` (syscall) | Partial | Glue traps through channel IPC; the kernel copies process state, the host starts a child Worker, and `wasm-fork-instrument` replays the supported call stack so parent/child receive the POSIX return values. ABI 43 requires the activation-state-safe artifact capability before launch and rejects non-reconstructible reference or mutable table state during instrumentation. Negative results replay to the caller without terminating the parent, including continuation-allocation failure before or during unwind. The reference-shape, side-module, and ordinary-OFD limitations in the main `fork()` row still apply. | | `vfork()` | Partial | Alias for `fork()` and therefore has the same continuation/OFD limitations. It neither suspends the calling parent thread nor shares that process memory with the child until `exec()` or `_exit()`, so it cannot avoid Kandelo's eager fork-memory copy. | | `posix_spawn()` | Partial | **Non-forking implementation** (this kernel's invention; no Linux equivalent). Glue issues `SYS_SPAWN` (500) with a marshalled blob (argv + envp + file actions + spawn attrs). The host passes the calling TID to `kernel_spawn_process`; the Rust `ProcessTable` validates that task, allocates the child PID from the global task-ID sequence, and builds the child Process descriptor before `onSpawn` launches a fresh Worker. The child inherits the calling task's signal mask unless `POSIX_SPAWN_SETSIGMASK` replaces it. No fork, no `wpk_fork_*` rewind, no exec replay. Supports POSIX_SPAWN_SETSID / SETPGROUP / SETSIGMASK / SETSIGDEF and FDOP_OPEN / CLOSE / DUP2 / CHDIR / FCHDIR. SIG_IGN dispositions persist across the implicit exec; custom handlers reset to SIG_DFL (POSIX exec semantics). An inherited directory never aliases the parent's live host iterator: spawn preserves its next-record cookie and lazily reopens a child-owned iterator there. Its later cursor movement and ordinary-file OFD metadata are still process-local rather than shared; see the known OFD gap below. Regression-guarded: `kernel_get_fork_count` exposes a per-process counter the test suite asserts is unchanged across SYS_SPAWN. See `docs/plans/2026-05-04-non-forking-posix-spawn-design.md`. | | `posix_spawnp()` | Partial | PATH search lives in libc (`libc/musl-overlay/src/process/wasm32posix/posix_spawnp.c`); resolves the absolute path then delegates to `posix_spawn()`. Empty PATH entries are treated as `.` and EACCES is deferred per `__execvpe` policy. It inherits `posix_spawn()`'s cross-process open-file-description limitation. | @@ -169,7 +169,7 @@ same final-OFD lifetime rules. | `ioperm()` / `iopl()` | Stub | Returns EPERM. No I/O port access. | | `remap_file_pages()` | Stub | Returns ENOSYS. | | `getcontext()` / `setcontext()` / `makecontext()` / `swapcontext()` | Unsupported | Userspace stack-switching primitives, deprecated in POSIX.1-2008, not planned. See the "ucontext API unsupported" row under [Wasm-Inherent gaps](#wasm-inherent--gaps-that-cannot-be-fully-resolved-in-wasm) for rationale. | -| `fork()` called from a C++/Ruby exception catch handler | Partial | Modern wasm-EH `try_table` catch replay is implemented. Plain-catch arm identity and scalar operands are activation-local and serialized in each linked frame, so distinct recursive activations do not share module scratch; multi-arm capture/rethrow restores the exact arm. `_ref` catches use `_wpk_fork_exnref_stash`. Existing C-01 through C-07, C-10, C-11, and S-08 coverage plus `host/test/plain-catch-payload-lifetime.test.ts` exercise the supported surface. Remaining gaps are explicit: ref-typed plain-catch operands are carved out, and static auxiliary-table slots can alias caught exnrefs or ref-typed locals across recursive/reentrant activations. See [docs/fork-instrumentation.md §Not guaranteed](fork-instrumentation.md#not-guaranteed-unsupported-patterns). | +| `fork()` called from an exception catch handler | Partial | ABI 43 supports statically tagged `Catch` and `CatchRef` arms with scalar payloads. Exact arm identity and operands are serialized per activation; rewind rethrows the tag so the original `CatchRef` clause creates a fresh child-instance exnref. Mixed arms, multiple targets, recursion, and real fresh-instance replay are covered without module-static reference stashes. Fork-reachable reference locals or carryovers, reference-typed tag payloads, `CatchAll`/`CatchAllRef`, mutable reference globals, and guest table mutations are rejected before execution. Current LLVM C++ modern-EH cleanup output commonly contains exnref locals or `CatchAllRef` and therefore remains unsupported until it has a deterministic reconstruction design. The current Dash `expandstr` build also contains a fork-reachable exnref local, so the ABI 43 shell/rootfs rebuild is blocked rather than relabeled from ABI 42. References wholly outside the fork closure remain legal. See [docs/fork-instrumentation.md §Not guaranteed](fork-instrumentation.md#not-guaranteed-unsupported-patterns). | ## Signals diff --git a/host/src/browser-kernel-worker-entry.ts b/host/src/browser-kernel-worker-entry.ts index b61c73fbec..ee4535fce3 100644 --- a/host/src/browser-kernel-worker-entry.ts +++ b/host/src/browser-kernel-worker-entry.ts @@ -38,7 +38,13 @@ import { restoreBrowserKernelInitMounts } from "./browser-kernel-vfs-init"; import type { MountConfig } from "./vfs/types"; import { TlsNetworkBackend } from "./networking/tls-network-backend"; import { patchWasmForThread } from "./worker-main"; -import { detectPtrWidth, extractAbiVersion, extractHeapBase, isWasmModuleBytes } from "./constants"; +import { + describeWasmArtifactPolicyFailures, + detectPtrWidth, + extractAbiVersion, + extractHeapBase, + isWasmModuleBytes, +} from "./constants"; import { ThreadExitCoordinator } from "./thread-exit-coordinator"; import { classifiedSignalOrFallback, @@ -214,6 +220,10 @@ async function resolveExecutableForLaunch( const shebang = parseShebang(bytes); if (!shebang) { if (!isWasmModuleBytes(bytes)) return { errno: ENOEXEC }; + const artifactFailures = describeWasmArtifactPolicyFailures(bytes, { + expectedAbi: kernelWorker.getKernelAbiVersion(), + }); + if (artifactFailures.length > 0) return { errno: ENOEXEC }; let programModule: WebAssembly.Module; try { programModule = await WebAssembly.compile(bytes); diff --git a/host/src/constants.ts b/host/src/constants.ts index 6422330b96..77e3e83a5b 100644 --- a/host/src/constants.ts +++ b/host/src/constants.ts @@ -3,6 +3,10 @@ import { PROCESS_MEMORY_PAGES_PER_THREAD_SLOT, PROCESS_MEMORY_THREAD_SLOT_DECL_EXPORT, PROCESS_MEMORY_WASM_PAGE_SIZE, + WPK_FORK_CAPABILITIES_SECTION, + WPK_FORK_CAPABILITIES_VERSION, + WPK_FORK_CAP_KNOWN_MASK, + WPK_FORK_CAP_REQUIRED_FLAGS, WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE, WPK_FORK_LINKED_FRAME_FORMAT_MAGIC, WPK_FORK_LINKED_FRAME_FORMAT_SECTION, @@ -295,6 +299,7 @@ interface WasmForkArtifactFacts { functionImports: Map; functionExports: Map; memoryPointerWidths: number[]; + forkCapabilities: Uint8Array[]; linkedFrameDescriptors: Uint8Array[]; importsKernelFork: boolean; } @@ -328,7 +333,7 @@ function readLimits( } /** - * Parse the portions of a final Wasm module that jointly define the ABI 42 + * Parse the portions of a final Wasm module that jointly define the ABI 43 * fork-artifact contract. * * WHY: names alone can look complete while the host and guest disagree about @@ -346,6 +351,7 @@ function readWasmForkArtifactFacts(programBytes: ArrayBuffer): WasmForkArtifactF functionImports: new Map(), functionExports: new Map(), memoryPointerWidths: [], + forkCapabilities: [], linkedFrameDescriptors: [], importsKernelFork: false, }; @@ -364,6 +370,8 @@ function readWasmForkArtifactFacts(programBytes: ArrayBuffer): WasmForkArtifactF const [name, afterName] = readName(src, pos); if (name === WPK_FORK_LINKED_FRAME_FORMAT_SECTION) { facts.linkedFrameDescriptors.push(src.slice(afterName, sectionEnd)); + } else if (name === WPK_FORK_CAPABILITIES_SECTION) { + facts.forkCapabilities.push(src.slice(afterName, sectionEnd)); } } else if (sectionId === 1) { requireFullyConsumed = true; @@ -514,6 +522,40 @@ function validateLinkedFrameDescriptor(descriptor: Uint8Array): number { return pointerFormat.bytes; } +function validateForkCapabilities(sections: Uint8Array[]): string[] { + if (sections.length === 0) { + return [`missing required ${WPK_FORK_CAPABILITIES_SECTION} capability`]; + } + if (sections.length !== 1) { + return [ + `has ${sections.length} ${WPK_FORK_CAPABILITIES_SECTION} sections, expected exactly one`, + ]; + } + const capability = sections[0]; + if (capability.byteLength !== 2) { + return [ + `${WPK_FORK_CAPABILITIES_SECTION} has ${capability.byteLength} bytes, expected 2`, + ]; + } + if (capability[0] !== WPK_FORK_CAPABILITIES_VERSION) { + return [ + `${WPK_FORK_CAPABILITIES_SECTION} version ${capability[0]} is unsupported`, + ]; + } + const flags = capability[1]!; + if ((flags & ~WPK_FORK_CAP_KNOWN_MASK) !== 0) { + return [ + `${WPK_FORK_CAPABILITIES_SECTION} has unknown flags 0x${flags.toString(16)}`, + ]; + } + if ((flags & WPK_FORK_CAP_REQUIRED_FLAGS) !== WPK_FORK_CAP_REQUIRED_FLAGS) { + return [ + `${WPK_FORK_CAPABILITIES_SECTION} flags 0x${flags.toString(16)} omit required activation-state safety flags 0x${WPK_FORK_CAP_REQUIRED_FLAGS.toString(16)}`, + ]; + } + return []; +} + function expectedWasmValueType( value: "ptr" | "i32", pointerWidth: number, @@ -552,11 +594,12 @@ function describeForkArtifactContractFailures( facts: WasmForkArtifactFacts, ): string[] { const failures: string[] = []; + failures.push(...validateForkCapabilities(facts.forkCapabilities)); for (const requirement of WPK_FORK_REQUIRED_EXPORTS) { const signatures = facts.functionExports.get(requirement.name); if (!signatures) continue; if (signatures.length !== 1) { - failures.push(`duplicate ABI 42 wasm-fork-instrument export ${requirement.name}`); + failures.push(`duplicate ABI 43 wasm-fork-instrument export ${requirement.name}`); } } const missingExports = WPK_FORK_REQUIRED_EXPORTS @@ -593,14 +636,14 @@ function describeForkArtifactContractFailures( .map(({ module, name }) => `${module}.${name}`); if (missingImports.length > 0) { failures.push( - `incomplete ABI 42 linked-frame imports; missing ${missingImports.join(", ")}`, + `incomplete ABI 43 linked-frame imports; missing ${missingImports.join(", ")}`, ); } for (const requirement of WPK_FORK_REQUIRED_IMPORTS) { const identity = `${requirement.module}.${requirement.name}`; const signatures = facts.functionImports.get(identity); if (signatures && signatures.length !== 1) { - failures.push(`duplicate ABI 42 linked-frame import ${identity}`); + failures.push(`duplicate ABI 43 linked-frame import ${identity}`); } } } @@ -608,12 +651,12 @@ function describeForkArtifactContractFailures( if (pointerWidth !== null) { if (facts.memoryPointerWidths.length !== 1) { failures.push( - `ABI 42 fork instrumentation requires exactly one module memory, found ${facts.memoryPointerWidths.length}`, + `ABI 43 fork instrumentation requires exactly one module memory, found ${facts.memoryPointerWidths.length}`, ); } else if (facts.memoryPointerWidths[0] !== pointerWidth) { const article = pointerWidth === 8 ? "an" : "a"; failures.push( - `ABI 42 linked-frame descriptor declares ${article} ${pointerWidth}-byte pointer but the module memory uses ${facts.memoryPointerWidths[0]}-byte addresses`, + `ABI 43 linked-frame descriptor declares ${article} ${pointerWidth}-byte pointer but the module memory uses ${facts.memoryPointerWidths[0]}-byte addresses`, ); } for (const requirement of WPK_FORK_REQUIRED_EXPORTS) { @@ -628,7 +671,7 @@ function describeForkArtifactContractFailures( ) ) { failures.push( - `ABI 42 wasm-fork-instrument export ${requirement.name} has the wrong signature; expected ${ + `ABI 43 wasm-fork-instrument export ${requirement.name} has the wrong signature; expected ${ signatureText(requirement.params, requirement.results, pointerWidth) }`, ); @@ -648,7 +691,7 @@ function describeForkArtifactContractFailures( ) ) { failures.push( - `ABI 42 linked-frame import ${identity} has the wrong signature; expected ${ + `ABI 43 linked-frame import ${identity} has the wrong signature; expected ${ signatureText(requirement.params, requirement.results, pointerWidth) }`, ); @@ -800,14 +843,15 @@ export function describeWasmArtifactPolicyFailures( } = {}, ): string[] { const failures: string[] = []; + let declaredAbi: number | null = null; if (wasmContainsLegacyAsyncify(programBytes)) { failures.push("contains asyncify_"); } if (options.expectedAbi !== undefined && options.expectedAbi !== null) { - const abi = extractAbiVersion(programBytes); - if (abi !== null && abi !== options.expectedAbi) { - failures.push(`ABI ${abi}, expected ${options.expectedAbi}`); + declaredAbi = extractAbiVersion(programBytes); + if (declaredAbi !== null && declaredAbi !== options.expectedAbi) { + failures.push(`ABI ${declaredAbi}, expected ${options.expectedAbi}`); } } @@ -828,10 +872,28 @@ export function describeWasmArtifactPolicyFailures( const descriptorCount = customSections.filter((name) => name === WPK_FORK_LINKED_FRAME_FORMAT_SECTION ).length; + const capabilityCount = customSections.filter((name) => + name === WPK_FORK_CAPABILITIES_SECTION + ).length; const hasForkArtifactSurface = - presentWpkExports.length > 0 || presentWpkImports.length > 0 || descriptorCount > 0; + presentWpkExports.length > 0 || presentWpkImports.length > 0 || + descriptorCount > 0 || capabilityCount > 0; + if ( + options.expectedAbi !== undefined && + options.expectedAbi !== null && + hasForkArtifactSurface && + declaredAbi === null + ) { + // WHY: the safety bit names an ABI-epoch contract. Without the program's + // ABI marker, copied capability metadata could make an ABI 42 transform + // look safe to an ABI 43 host. + failures.push( + `ABI ${options.expectedAbi} fork artifact is missing __abi_version; ` + + "the activation-state capability epoch cannot be verified", + ); + } if (options.forbidForkInstrumentation && hasForkArtifactSurface) { - failures.push("contains ABI 42 wasm-fork-instrument metadata, imports, or exports"); + failures.push("contains ABI 43 wasm-fork-instrument metadata, imports, or exports"); } const requireForkInstrumentation = @@ -846,7 +908,7 @@ export function describeWasmArtifactPolicyFailures( ); } catch (error) { failures.push( - `cannot validate ABI 42 fork-artifact contract: ${ + `cannot validate ABI 43 fork-artifact contract: ${ error instanceof Error ? error.message : String(error) }`, ); diff --git a/host/src/dylink.ts b/host/src/dylink.ts index 2f2d1171bc..b6cd9cfff8 100644 --- a/host/src/dylink.ts +++ b/host/src/dylink.ts @@ -8,9 +8,16 @@ import { ABI_VERSION, + WPK_FORK_CAPABILITIES_SECTION, + WPK_FORK_CAPABILITIES_VERSION, + WPK_FORK_CAP_ACTIVATION_STATE_SAFE, + WPK_FORK_CAP_DYLINK_MAIN, + WPK_FORK_CAP_KNOWN_MASK, + WPK_FORK_CAP_SIDE_ENTRY, WPK_FORK_REQUIRED_EXPORTS, WPK_FORK_REQUIRED_IMPORTS, } from "./generated/abi"; +import { extractAbiVersion } from "./constants"; import { ContinuationAllocationError, invokeForkContinuationBegin, @@ -34,11 +41,12 @@ export const SIDE_MODULE_FORK_EXPORTS = WPK_FORK_REQUIRED_EXPORTS.map( ({ name }) => name, ); -export const FORK_CAPABILITIES_SECTION = "kandelo.wpk_fork.capabilities"; -export const FORK_CAPABILITIES_VERSION = 1; -export const FORK_CAP_SIDE_ENTRY = 1 << 0; -export const FORK_CAP_DYLINK_MAIN = 1 << 1; -const FORK_CAP_KNOWN_MASK = FORK_CAP_SIDE_ENTRY | FORK_CAP_DYLINK_MAIN; +export const FORK_CAPABILITIES_SECTION = WPK_FORK_CAPABILITIES_SECTION; +export const FORK_CAPABILITIES_VERSION = WPK_FORK_CAPABILITIES_VERSION; +export const FORK_CAP_SIDE_ENTRY = WPK_FORK_CAP_SIDE_ENTRY; +export const FORK_CAP_DYLINK_MAIN = WPK_FORK_CAP_DYLINK_MAIN; +export const FORK_CAP_ACTIVATION_STATE_SAFE = WPK_FORK_CAP_ACTIVATION_STATE_SAFE; +const FORK_CAP_KNOWN_MASK = WPK_FORK_CAP_KNOWN_MASK; export const FORK_CAPABILITIES_REQUIRED_ABI = 17; const WPK_FORK_NORMAL = 0; @@ -750,6 +758,37 @@ function instantiateSharedLibrary( `${name}: incomplete wasm-fork-instrument exports; missing ${missing.join(", ")}`, ); } + if ( + hasCompleteForkInstrumentation && + ( + !forkCapabilityClaim.present || + (forkCapabilityClaim.flags & FORK_CAP_ACTIVATION_STATE_SAFE) === 0 + ) + ) { + throw new Error( + `${name}: wasm-fork-instrument artifact lacks the ABI 43 ` + + "activation-state-safe capability; rebuild the side module", + ); + } + if (hasCompleteForkInstrumentation) { + const artifactBytes = wasmBytes.buffer.slice( + wasmBytes.byteOffset, + wasmBytes.byteOffset + wasmBytes.byteLength, + ) as ArrayBuffer; + const declaredAbi = extractAbiVersion(artifactBytes); + if (declaredAbi === null) { + throw new Error( + `${name}: ABI 43 fork-instrumented side module is missing __abi_version; ` + + "the activation-state capability epoch cannot be verified", + ); + } + if (declaredAbi !== ABI_VERSION) { + throw new Error( + `${name}: fork-instrumented side module declares ABI ${declaredAbi}, ` + + `but the host requires ABI ${ABI_VERSION}`, + ); + } + } if (importsFork && !hasCompleteForkInstrumentation) { throw new Error( `${name}: env.fork requires complete side-module instrumentation; ` + @@ -766,7 +805,7 @@ function instantiateSharedLibrary( throw new Error(`${name}: incomplete linked fork instrumentation imports; rebuild the module`); } if (importsFork && linkedFrameImportCount !== linkedFrameImportNames.length) { - throw new Error(`${name}: env.fork requires ABI 42 linked continuation imports`); + throw new Error(`${name}: env.fork requires ABI 43 linked continuation imports`); } if (claimsSideEntry && !importsFork) { throw new Error(`${name}: side-entry capability is present without an env.fork import`); diff --git a/host/src/generated/abi.ts b/host/src/generated/abi.ts index 0c8c2498bd..659383a267 100644 --- a/host/src/generated/abi.ts +++ b/host/src/generated/abi.ts @@ -1,7 +1,7 @@ /* GENERATED by `cargo xtask dump-abi`. Do not edit by hand. */ /* Regenerated by scripts/check-abi-version.sh; drift is a CI failure. */ -export const ABI_VERSION = 42 as const; +export const ABI_VERSION = 43 as const; export const ABI_CUSTOM_SECTION = "wasm-posix-abi" as const; export const ABI_KERNEL_EXPORT = "__abi_version" as const; @@ -15,14 +15,325 @@ export const WPK_FORK_LINKED_FRAME_POINTER_WIDTHS = [ { bytes: 4, chunkHeaderSize: 32, nodeHeaderSize: 24 }, { bytes: 8, chunkHeaderSize: 56, nodeHeaderSize: 32 }, ] as const; +export const WPK_FORK_MODULE_STATE_FORMAT_SECTION = "kandelo.wpk_fork.module_state" as const; +export const WPK_FORK_MODULE_STATE_FORMAT_VERSION = 1 as const; +export const WPK_FORK_MODULE_STATE_FORMAT_MAGIC = [75, 70, 77, 68] as const; +export const WPK_FORK_MODULE_STATE_DESCRIPTOR_SIZE = 24 as const; +export const WPK_FORK_MODULE_STATE_RECORD_ALIGNMENT = 8 as const; +export const WPK_FORK_MODULE_STATE_FLAG_ROOT_PREFIX_POINTER = 1 as const; +export const WPK_FORK_MODULE_STATE_FLAG_EXPLICIT_OWNERS = 2 as const; +export const WPK_FORK_MODULE_STATE_FLAG_SPARSE_TABLES = 4 as const; +export const WPK_FORK_MODULE_STATE_REQUIRED_FLAGS = 7 as const; +export const WPK_FORK_MODULE_STATE_KNOWN_FLAGS = 7 as const; +export const WPK_FORK_MODULE_STATE_ARENA_VERSION = 1 as const; +export const WPK_FORK_MODULE_STATE_RECORD_VERSION = 1 as const; +export const WPK_FORK_MODULE_STATE_ROOT_POINTER_WORD_OFFSET = 1 as const; +export const WPK_FORK_MODULE_STATE_CHUNK_MAGIC = [75, 70, 77, 67] as const; +export const WPK_FORK_MODULE_STATE_CHUNK_FLAG_ROOT = 1 as const; +export const WPK_FORK_MODULE_STATE_CHUNK_FLAG_SEALED = 2 as const; +export const WPK_FORK_MODULE_STATE_CHUNK_KNOWN_FLAGS = 3 as const; +export const WPK_FORK_MODULE_STATE_RECORD_MAGIC = [75, 70, 77, 82] as const; +export const WPK_FORK_MODULE_STATE_RECORD_HEADER_SIZE = 24 as const; +export const WPK_FORK_MODULE_STATE_RECORD_KIND_MODULE = 1 as const; +export const WPK_FORK_MODULE_STATE_RECORD_KIND_REFERENCE_RECIPE = 2 as const; +export const WPK_FORK_MODULE_STATE_RECORD_KIND_MUTABLE_GLOBAL = 3 as const; +export const WPK_FORK_MODULE_STATE_RECORD_KIND_TABLE = 4 as const; +export const WPK_FORK_MODULE_STATE_RECORD_KIND_TABLE_PAGE = 5 as const; +export const WPK_FORK_MODULE_STATE_RECORD_KIND_ELEMENT_SEGMENTS = 6 as const; +export const WPK_FORK_MODULE_STATE_RECORD_KIND_DATA_SEGMENTS = 7 as const; +export const WPK_FORK_MODULE_STATE_RECORD_KIND_REPLAY_EVENTS = 8 as const; +export const WPK_FORK_MODULE_STATE_RECORD_KIND_IMPORTED_GLOBAL_BINDINGS = 9 as const; +export const WPK_FORK_MODULE_STATE_RECORD_KIND_ACTIVATION_CONTINUATIONS = 10 as const; +export const WPK_FORK_MODULE_STATE_RECORD_KIND_IMPORTED_TABLE_BINDINGS = 11 as const; +export const WPK_FORK_MODULE_STATE_RECORD_KIND_REFERENCE_RECIPE_SEGMENT = 12 as const; +export const WPK_FORK_MODULE_STATE_RECORD_KIND_REPLAY_EVENT_SEGMENT = 13 as const; +export const WPK_FORK_MODULE_STATE_RECORD_KINDS = [ + { number: 1, name: "module" }, + { number: 2, name: "reference_recipe" }, + { number: 3, name: "mutable_global" }, + { number: 4, name: "table" }, + { number: 5, name: "table_page" }, + { number: 6, name: "element_segments" }, + { number: 7, name: "data_segments" }, + { number: 8, name: "replay_events" }, + { number: 9, name: "imported_global_bindings" }, + { number: 10, name: "activation_continuations" }, + { number: 11, name: "imported_table_bindings" }, + { number: 12, name: "reference_recipe_segment" }, + { number: 13, name: "replay_event_segment" }, +] as const; +export const WPK_FORK_MODULE_STATE_POINTER_WIDTHS = [ + { bytes: 4, chunkHeaderSize: 40 }, + { bytes: 8, chunkHeaderSize: 56 }, +] as const; +export const WPK_FORK_MODULE_STATE_REPLAY_EVENTS_MAGIC = [75, 70, 82, 69] as const; +export const WPK_FORK_REFERENCE_TRANSACTION_MAGIC = [75, 70, 82, 86] as const; +export const WPK_FORK_REFERENCE_SEGMENT_MAGIC = [75, 70, 82, 83] as const; +export const WPK_FORK_REFERENCE_TRANSACTION_OWNER = 1 as const; +export const WPK_FORK_REFERENCE_TRANSACTION_VERSION = 2 as const; +export const WPK_FORK_REFERENCE_TRANSACTION_MANIFEST_SIZE = 96 as const; +export const WPK_FORK_REFERENCE_TRANSACTION_FLAG_SEALED = 1 as const; +export const WPK_FORK_REFERENCE_TRANSACTION_KNOWN_FLAGS = 1 as const; +export const WPK_FORK_REFERENCE_SEGMENT_HEADER_SIZE = 40 as const; +export const WPK_FORK_REFERENCE_SEGMENT_KNOWN_FLAGS = 0 as const; +export const WPK_FORK_REFERENCE_NODE_RECORD_SIZE = 48 as const; +export const WPK_FORK_REFERENCE_VECTOR_INDEX_SIZE = 16 as const; +export const WPK_FORK_REFERENCE_SECTION_NODES = 1 as const; +export const WPK_FORK_REFERENCE_SECTION_EDGES = 2 as const; +export const WPK_FORK_REFERENCE_SECTION_SCALARS = 3 as const; +export const WPK_FORK_REFERENCE_SECTION_VECTOR_INDEX = 4 as const; +export const WPK_FORK_REFERENCE_SECTION_VECTOR_ENTRIES = 5 as const; +export const WPK_FORK_MODULE_STATE_MODULE_TEMPLATE_ID_SIZE = 32 as const; +export const WPK_FORK_MODULE_STATE_MODULE_RECORD_PAYLOAD_SIZE = 40 as const; +export const WPK_FORK_MODULE_STATE_MODULE_RECORD_KNOWN_FLAGS = 0 as const; +export const WPK_FORK_MODULE_STATE_GLOBAL_HEADER_SIZE = 8 as const; +export const WPK_FORK_MODULE_STATE_TABLE_BASELINE_FINGERPRINT_SIZE = 32 as const; +export const WPK_FORK_MODULE_STATE_TABLE_DESCRIPTOR_PAYLOAD_SIZE = 56 as const; +export const WPK_FORK_MODULE_STATE_TABLE_FLAG_SPARSE_OVERRIDES = 1 as const; +export const WPK_FORK_MODULE_STATE_TABLE_KNOWN_FLAGS = 1 as const; +export const WPK_FORK_MODULE_STATE_TABLE_PAGE_HEADER_SIZE = 16 as const; +export const WPK_FORK_MODULE_STATE_TABLE_RUN_HEADER_SIZE = 8 as const; +export const WPK_FORK_MODULE_STATE_ELEMENT_SEGMENT_HEADER_SIZE = 8 as const; +export const WPK_FORK_MODULE_STATE_DATA_SEGMENT_HEADER_SIZE = 8 as const; +export const WPK_FORK_MODULE_STATE_REPLAY_EVENTS_OWNER = 1 as const; +export const WPK_FORK_MODULE_STATE_REPLAY_EVENTS_VERSION = 2 as const; +export const WPK_FORK_MODULE_STATE_REPLAY_EVENTS_HEADER_SIZE = 40 as const; +export const WPK_FORK_MODULE_STATE_REPLAY_EVENT_SIZE = 8 as const; +export const WPK_FORK_MODULE_STATE_REPLAY_EVENTS_KNOWN_FLAGS = 0 as const; +export const WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_VERSION = 1 as const; +export const WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_HEADER_SIZE = 24 as const; +export const WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_CAPACITY = 4080 as const; +export const WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_KNOWN_FLAGS = 0 as const; +export const WPK_FORK_MODULE_STATE_MIN_TABLE_PAGE_SHIFT = 4 as const; +export const WPK_FORK_MODULE_STATE_MAX_TABLE_PAGE_SHIFT = 20 as const; +export const WPK_FORK_MODULE_STATE_TABLE_PAGE_SHIFT = 10 as const; +export const WPK_FORK_IMPORTED_GLOBAL_BINDINGS_MAGIC = [75, 70, 66, 71] as const; +export const WPK_FORK_IMPORTED_GLOBAL_BINDINGS_OWNER = 2 as const; +export const WPK_FORK_IMPORTED_GLOBAL_BINDINGS_VERSION = 1 as const; +export const WPK_FORK_IMPORTED_GLOBAL_BINDINGS_HEADER_SIZE = 24 as const; +export const WPK_FORK_IMPORTED_GLOBAL_BINDINGS_ENTRY_SIZE = 40 as const; +export const WPK_FORK_IMPORTED_GLOBAL_BINDINGS_KNOWN_FLAGS = 0 as const; +export const WPK_FORK_IMPORTED_GLOBAL_BINDING_RAW_NUMBER = 1 as const; +export const WPK_FORK_IMPORTED_GLOBAL_BINDING_RAW_BIGINT = 2 as const; +export const WPK_FORK_IMPORTED_GLOBAL_BINDING_RAW_REFERENCE = 3 as const; +export const WPK_FORK_IMPORTED_GLOBAL_BINDING_ACTIVATION_GLOBAL = 4 as const; +export const WPK_FORK_IMPORTED_GLOBAL_BINDING_BASE_IMPORT = 5 as const; +export const WPK_FORK_GLOBAL_CATALOG_EXPORT_PREFIX = "__wpk_fork_global_" as const; +export const WPK_FORK_ACTIVATION_CONTINUATIONS_MAGIC = [75, 70, 65, 67] as const; +export const WPK_FORK_ACTIVATION_CONTINUATIONS_OWNER = 3 as const; +export const WPK_FORK_ACTIVATION_CONTINUATIONS_VERSION = 1 as const; +export const WPK_FORK_ACTIVATION_CONTINUATIONS_HEADER_SIZE = 24 as const; +export const WPK_FORK_ACTIVATION_CONTINUATIONS_ENTRY_SIZE = 16 as const; +export const WPK_FORK_ACTIVATION_CONTINUATIONS_KNOWN_FLAGS = 0 as const; +export const WPK_FORK_ACTIVATION_CONTINUATIONS_ENTRY_KNOWN_FLAGS = 0 as const; +export const WPK_FORK_IMPORTED_TABLE_BINDINGS_MAGIC = [75, 70, 66, 84] as const; +export const WPK_FORK_IMPORTED_TABLE_BINDINGS_OWNER = 4 as const; +export const WPK_FORK_IMPORTED_TABLE_BINDINGS_VERSION = 1 as const; +export const WPK_FORK_IMPORTED_TABLE_BINDINGS_HEADER_SIZE = 24 as const; +export const WPK_FORK_IMPORTED_TABLE_BINDINGS_ENTRY_SIZE = 24 as const; +export const WPK_FORK_IMPORTED_TABLE_BINDINGS_KNOWN_FLAGS = 0 as const; +export const WPK_FORK_IMPORTED_TABLE_BINDING_ACTIVATION_TABLE = 1 as const; +export const WPK_FORK_IMPORTED_TABLE_BINDING_BASE_IMPORT = 2 as const; +export const WPK_FORK_TABLE_CATALOG_EXPORT_PREFIX = "__wpk_fork_table_" as const; +export const WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I32 = 1 as const; +export const WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I64 = 2 as const; +export const WPK_FORK_MODULE_STATE_GLOBAL_TYPE_F32 = 3 as const; +export const WPK_FORK_MODULE_STATE_GLOBAL_TYPE_F64 = 4 as const; +export const WPK_FORK_MODULE_STATE_GLOBAL_TYPE_V128 = 5 as const; +export const WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF = 6 as const; +export const WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF = 7 as const; +export const WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF = 8 as const; +export const WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF = 9 as const; +export const WPK_FORK_CAPABILITIES_SECTION = "kandelo.wpk_fork.capabilities" as const; +export const WPK_FORK_CAPABILITIES_VERSION = 1 as const; +export const WPK_FORK_CAP_SIDE_ENTRY = 1 as const; +export const WPK_FORK_CAP_DYLINK_MAIN = 2 as const; +export const WPK_FORK_CAP_ACTIVATION_STATE_SAFE = 4 as const; +export const WPK_FORK_CAP_KNOWN_MASK = 7 as const; +export const WPK_FORK_CAP_REQUIRED_FLAGS = 4 as const; +export const WPK_FORK_EXCEPTION_CODEC_SECTION = "kandelo.wpk_fork.exception_codec" as const; +export const WPK_FORK_EXCEPTION_CODEC_VERSION = 1 as const; +export const WPK_FORK_EXCEPTION_CODEC_HEADER_SIZE = 8 as const; +export const WPK_FORK_EXCEPTION_CODEC_TAG_RECORD_SIZE = 16 as const; +export const WPK_FORK_GC_CODEC_SECTION = "kandelo.wpk_fork.gc_codec" as const; +export const WPK_FORK_GC_CODEC_MAGIC = [75, 70, 71, 67] as const; +export const WPK_FORK_GC_CODEC_VERSION = 1 as const; +export const WPK_FORK_GC_CODEC_HEADER_SIZE = 16 as const; +export const WPK_FORK_GC_CODEC_LAYOUT_RECORD_SIZE = 44 as const; +export const WPK_FORK_GC_CODEC_FIELD_RECORD_SIZE = 12 as const; +export const WPK_FORK_UNWIND_TAG_IMPORT_MODULE = "env" as const; +export const WPK_FORK_UNWIND_TAG_IMPORT_NAME = "__wpk_fork_unwind" as const; +export const WPK_FORK_UNWIND_TRANSPORT_SECTION = "kandelo.wpk_fork.unwind_transport" as const; +export const WPK_FORK_STATIC_ROOT_CATALOG_EXPORT = "__wpk_fork_static_root_catalog" as const; +export const WPK_FORK_STATIC_ROOT_CATALOG_SECTION = "kandelo.wpk_fork.static_root_catalog" as const; +export const WPK_FORK_STATIC_ROOT_HARVEST_EXPORT = "__wpk_fork_static_root_harvest" as const; +export const WPK_FORK_UNWIND_TRANSPORT_VERSION = 1 as const; +export const WPK_FORK_UNWIND_TRANSPORT_PAYLOAD_ARITY = 0 as const; +export const WPK_FORK_STATIC_ROOT_CATALOG_VERSION = 1 as const; +export const WPK_FORK_STATIC_ROOT_CATALOG_HEADER_SIZE = 12 as const; +export const WPK_FORK_STATIC_ROOT_CATALOG_MAGIC = [75, 70, 83, 82] as const; +export const WPK_FORK_IMPORTED_GLOBALS_SECTION = "kandelo.wpk_fork.imported_globals" as const; +export const WPK_FORK_FRAME_IMPORT_COMMIT = "__wpk_fork_frame_commit" as const; +export const WPK_FORK_FRAME_IMPORT_NEXT = "__wpk_fork_frame_next" as const; +export const WPK_FORK_FRAME_IMPORT_PEEK = "__wpk_fork_frame_peek" as const; +export const WPK_FORK_FRAME_IMPORT_RESERVE = "__wpk_fork_frame_reserve" as const; +export const WPK_FORK_RESUME_IMPORT_PEEK = "__wpk_fork_resume_peek" as const; +export const WPK_FORK_RESUME_IMPORT_TABLE = "__wpk_fork_resume_table" as const; +export const WPK_FORK_IMPORTED_GLOBALS_MAGIC = [75, 70, 73, 71] as const; +export const WPK_FORK_IMPORTED_GLOBALS_VERSION = 1 as const; +export const WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE = 16 as const; +export const WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE = 24 as const; +export const WPK_FORK_IMPORTED_GLOBALS_FLAG_MUTABLE = 1 as const; +export const WPK_FORK_IMPORTED_GLOBALS_FLAG_SHARED = 2 as const; +export const WPK_FORK_IMPORTED_GLOBALS_KNOWN_FLAGS = 3 as const; +export const WPK_FORK_IMPORTED_TABLES_SECTION = "kandelo.wpk_fork.imported_tables" as const; +export const WPK_FORK_IMPORTED_TABLES_MAGIC = [75, 70, 73, 84] as const; +export const WPK_FORK_IMPORTED_TABLES_VERSION = 1 as const; +export const WPK_FORK_IMPORTED_TABLES_HEADER_SIZE = 16 as const; +export const WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE = 24 as const; +export const WPK_FORK_IMPORTED_TABLES_FLAG_TABLE64 = 1 as const; +export const WPK_FORK_IMPORTED_TABLES_KNOWN_FLAGS = 1 as const; +export const WPK_FORK_EXCEPTION_CODEC_IMPORT_MODULE = "env" as const; +export const WPK_FORK_EXCEPTION_IMPORT_ACTIVATION = "__wpk_fork_module_activation" as const; +export const WPK_FORK_EXCEPTION_IMPORT_BROKER_ENCODE = "__wpk_fork_ref_exn_broker_encode" as const; +export const WPK_FORK_EXCEPTION_IMPORT_BROKER_THROW_RECIPE = "__wpk_fork_ref_exn_broker_throw_recipe" as const; +export const WPK_FORK_EXCEPTION_IMPORT_CACHE_INDEX = "__wpk_fork_ref_exn_cache_index" as const; +export const WPK_FORK_EXCEPTION_IMPORT_CLAIM = "__wpk_fork_ref_exn_claim" as const; +export const WPK_FORK_EXCEPTION_IMPORT_DEFINE = "__wpk_fork_ref_exn_define" as const; +export const WPK_FORK_EXCEPTION_IMPORT_INGRESS_THROW = "__wpk_fork_ref_exn_ingress_throw" as const; +export const WPK_FORK_EXCEPTION_IMPORT_LOAD = "__wpk_fork_ref_exn_load" as const; +export const WPK_FORK_EXCEPTION_IMPORT_LOOKUP = "__wpk_fork_ref_exn_lookup" as const; +export const WPK_FORK_EXCEPTION_IMPORT_ROUTE = "__wpk_fork_ref_exn_route" as const; +export const WPK_FORK_EXCEPTION_EXPORT_ABORT = "__wpk_fork_ref_exn_abort" as const; +export const WPK_FORK_EXCEPTION_EXPORT_CLEAR = "__wpk_fork_ref_exn_clear" as const; +export const WPK_FORK_EXCEPTION_EXPORT_DECODE = "__wpk_fork_ref_decode_exnref" as const; +export const WPK_FORK_EXCEPTION_EXPORT_ENCODE = "__wpk_fork_ref_encode_exnref" as const; +export const WPK_FORK_EXCEPTION_EXPORT_ENCODE_INGRESS = "__wpk_fork_ref_exn_encode_ingress" as const; +export const WPK_FORK_EXCEPTION_EXPORT_MATERIALIZE = "__wpk_fork_exception_materialize" as const; +export const WPK_FORK_EXCEPTION_EXPORT_THROW_RECIPE = "__wpk_fork_ref_exn_throw_recipe" as const; +export const WPK_FORK_EXCEPTION_EXPORT_THROW_SLOT = "__wpk_fork_ref_exn_throw_slot" as const; +export const WPK_FORK_MODULE_STATE_IMPORT_RECORD_COMMIT = "__wpk_fork_module_state_record_commit" as const; +export const WPK_FORK_MODULE_STATE_IMPORT_RECORD_FIND = "__wpk_fork_module_state_record_find" as const; +export const WPK_FORK_MODULE_STATE_IMPORT_RECORD_RESERVE = "__wpk_fork_module_state_record_reserve" as const; +export const WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_COUNT = "__wpk_fork_module_state_table_dirty_count" as const; +export const WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_MARK = "__wpk_fork_module_state_table_dirty_mark" as const; +export const WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_PAGE = "__wpk_fork_module_state_table_dirty_page" as const; +export const WPK_FORK_MODULE_STATE_IMPORT_TABLE_STATE_OWNED = "__wpk_fork_module_state_table_state_owned" as const; +export const WPK_FORK_EXPORT_MODULE_BOOTSTRAP = "wpk_fork_module_bootstrap" as const; +export const WPK_FORK_EXPORT_MODULE_STATE_FINISH_RESTORE = "wpk_fork_module_state_finish_restore" as const; +export const WPK_FORK_EXPORT_MODULE_STATE_RESTORE = "wpk_fork_module_state_restore" as const; +export const WPK_FORK_EXPORT_MODULE_STATE_SAVE = "wpk_fork_module_state_save" as const; +export const WPK_FORK_EXPORT_MODULE_THREAD_BOOTSTRAP = "wpk_fork_module_thread_bootstrap" as const; +export const WPK_FORK_EXPORT_RESUME_START = "wpk_fork_resume_start" as const; +export const WPK_FORK_EXPORT_RESUME_THREAD = "wpk_fork_resume_thread" as const; +export const WPK_FORK_REFERENCE_IMPORT_DECODE_ANYREF = "__wpk_fork_ref_decode_anyref" as const; +export const WPK_FORK_REFERENCE_IMPORT_DECODE_EXNREF = "__wpk_fork_ref_decode_exnref" as const; +export const WPK_FORK_REFERENCE_IMPORT_DECODE_EXTERNREF = "__wpk_fork_ref_decode_externref" as const; +export const WPK_FORK_REFERENCE_IMPORT_DECODE_FUNCREF = "__wpk_fork_ref_decode_funcref" as const; +export const WPK_FORK_REFERENCE_IMPORT_ENCODE_ANYREF = "__wpk_fork_ref_encode_anyref" as const; +export const WPK_FORK_REFERENCE_IMPORT_ENCODE_EXNREF = "__wpk_fork_ref_encode_exnref" as const; +export const WPK_FORK_REFERENCE_IMPORT_ENCODE_EXTERNREF = "__wpk_fork_ref_encode_externref" as const; +export const WPK_FORK_REFERENCE_IMPORT_ENCODE_FUNCREF = "__wpk_fork_ref_encode_funcref" as const; +export const WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE = "env" as const; +export const WPK_FORK_REFERENCE_IMPORT_GC_BROKER_ENCODE = "__wpk_fork_ref_gc_broker_encode" as const; +export const WPK_FORK_REFERENCE_IMPORT_GC_CAPTURE_LAYOUT = "__wpk_fork_ref_gc_capture_layout" as const; +export const WPK_FORK_REFERENCE_IMPORT_GC_CLAIM = "__wpk_fork_ref_gc_claim" as const; +export const WPK_FORK_REFERENCE_IMPORT_GC_DEFINE = "__wpk_fork_ref_gc_define" as const; +export const WPK_FORK_REFERENCE_IMPORT_GC_I31 = "__wpk_fork_ref_gc_i31" as const; +export const WPK_FORK_REFERENCE_IMPORT_GC_LOAD = "__wpk_fork_ref_gc_load" as const; +export const WPK_FORK_REFERENCE_IMPORT_GC_LOOKUP = "__wpk_fork_ref_gc_lookup" as const; +export const WPK_FORK_REFERENCE_IMPORT_GC_PAYLOAD_LEN = "__wpk_fork_ref_gc_payload_len" as const; +export const WPK_FORK_REFERENCE_IMPORT_GC_PROVENANCE_BEGIN = "__wpk_fork_ref_gc_provenance_begin" as const; +export const WPK_FORK_REFERENCE_IMPORT_GC_PROVENANCE_END = "__wpk_fork_ref_gc_provenance_end" as const; +export const WPK_FORK_REFERENCE_IMPORT_GC_PROVENANCE_REF = "__wpk_fork_ref_gc_provenance_ref" as const; +export const WPK_FORK_REFERENCE_IMPORT_GC_ROUTE = "__wpk_fork_ref_gc_route" as const; +export const WPK_FORK_REFERENCE_IMPORT_GC_TRANSIT = "__wpk_fork_ref_gc_transit" as const; +export const WPK_FORK_REFERENCE_EXPORT_GC_ALLOCATE = "__wpk_fork_ref_gc_allocate" as const; +export const WPK_FORK_REFERENCE_EXPORT_GC_ENCODE_SLOT = "__wpk_fork_ref_gc_encode_slot" as const; +export const WPK_FORK_REFERENCE_EXPORT_GC_FILL = "__wpk_fork_ref_gc_fill" as const; +export const WPK_FORK_REFERENCE_EXPORT_GC_PUBLISH_EXTERNREF = "__wpk_fork_ref_gc_publish_externref" as const; +export const WPK_FORK_REFERENCE_EXPORT_GC_PROBE = "__wpk_fork_ref_gc_probe" as const; +export const WPK_FORK_REFERENCE_IMPORT_SCRATCH_RELEASE = "__wpk_fork_ref_scratch_release" as const; +export const WPK_FORK_REFERENCE_IMPORT_SCRATCH_RESERVE = "__wpk_fork_ref_scratch_reserve" as const; +export const WPK_FORK_REFERENCE_IMPORT_VECTOR_APPEND = "__wpk_fork_ref_vector_append" as const; +export const WPK_FORK_REFERENCE_IMPORT_VECTOR_BEGIN = "__wpk_fork_ref_vector_begin" as const; +export const WPK_FORK_REFERENCE_IMPORT_VECTOR_FINISH = "__wpk_fork_ref_vector_finish" as const; +export const WPK_FORK_REFERENCE_IMPORT_VECTOR_GET = "__wpk_fork_ref_vector_get" as const; export const WPK_FORK_REQUIRED_IMPORTS = [ { module: "env", name: "__wpk_fork_frame_commit", params: ["ptr"], results: [] }, { module: "env", name: "__wpk_fork_frame_next", params: ["ptr"], results: ["ptr"] }, + { module: "env", name: "__wpk_fork_frame_peek", params: ["ptr"], results: ["ptr"] }, { module: "env", name: "__wpk_fork_frame_reserve", params: ["ptr"], results: ["ptr"] }, + { module: "env", name: "__wpk_fork_module_state_record_commit", params: ["ptr"], results: [] }, + { module: "env", name: "__wpk_fork_module_state_record_find", params: ["i32", "i32", "i32", "i32"], results: ["ptr"] }, + { module: "env", name: "__wpk_fork_module_state_record_reserve", params: ["i32", "i32", "i32", "ptr"], results: ["ptr"] }, + { module: "env", name: "__wpk_fork_module_state_table_dirty_count", params: ["i32"], results: ["i32"] }, + { module: "env", name: "__wpk_fork_module_state_table_dirty_mark", params: ["i32", "i64", "i64"], results: [] }, + { module: "env", name: "__wpk_fork_module_state_table_dirty_page", params: ["i32", "i32"], results: ["i64"] }, + { module: "env", name: "__wpk_fork_module_state_table_mutation_abort", params: [], results: [] }, + { module: "env", name: "__wpk_fork_module_state_table_mutation_begin", params: [], results: ["i64"] }, + { module: "env", name: "__wpk_fork_module_state_table_mutation_commit", params: ["i32", "i64", "i64"], results: [] }, + { module: "env", name: "__wpk_fork_module_state_table_reconcile", params: [], results: ["i64"] }, + { module: "env", name: "__wpk_fork_module_state_table_state_owned", params: ["i32"], results: ["i32"] }, + { module: "env", name: "__wpk_fork_ref_decode_funcref", params: ["i32"], results: ["funcref"] }, + { module: "env", name: "__wpk_fork_ref_encode_funcref", params: ["funcref"], results: ["i32"] }, + { module: "env", name: "__wpk_fork_ref_exn_broker_encode", params: ["i32"], results: ["i32"] }, + { module: "env", name: "__wpk_fork_ref_exn_broker_throw_recipe", params: ["i32"], results: [] }, + { module: "env", name: "__wpk_fork_ref_exn_cache_index", params: ["i32"], results: ["i32"] }, + { module: "env", name: "__wpk_fork_ref_exn_claim", params: ["i32"], results: ["i32"] }, + { module: "env", name: "__wpk_fork_ref_exn_define", params: ["i32", "i32", "i32", "i32", "ptr", "i32", "ptr", "i32"], results: [] }, + { module: "env", name: "__wpk_fork_ref_exn_ingress_throw", params: ["i32"], results: [] }, + { module: "env", name: "__wpk_fork_ref_exn_load", params: ["i32", "i32", "i32", "i32", "ptr", "i32", "ptr", "i32"], results: ["i32"] }, + { module: "env", name: "__wpk_fork_ref_exn_lookup", params: ["i32"], results: ["i32"] }, + { module: "env", name: "__wpk_fork_ref_exn_route", params: ["i32", "i32"], results: ["i32"] }, + { module: "env", name: "__wpk_fork_ref_gc_broker_encode", params: ["i32"], results: ["i32"] }, + { module: "env", name: "__wpk_fork_ref_gc_capture_layout", params: ["i32", "i32", "i32"], results: ["i32"] }, + { module: "env", name: "__wpk_fork_ref_gc_claim", params: ["i32"], results: ["i32"] }, + { module: "env", name: "__wpk_fork_ref_gc_define", params: ["i32", "i32", "i32", "i32", "i32", "ptr", "i32", "i32"], results: [] }, + { module: "env", name: "__wpk_fork_ref_gc_i31", params: ["i32"], results: ["i32"] }, + { module: "env", name: "__wpk_fork_ref_gc_load", params: ["i32", "i32", "i32", "i32", "i32", "ptr", "i32"], results: ["i32"] }, + { module: "env", name: "__wpk_fork_ref_gc_lookup", params: ["i32"], results: ["i32"] }, + { module: "env", name: "__wpk_fork_ref_gc_payload_len", params: ["i32", "i32", "i32"], results: ["i32"] }, + { module: "env", name: "__wpk_fork_ref_gc_provenance_begin", params: ["i32", "i32", "i32", "i32", "i64", "i64", "i32"], results: ["i32"] }, + { module: "env", name: "__wpk_fork_ref_gc_provenance_end", params: ["i32"], results: [] }, + { module: "env", name: "__wpk_fork_ref_gc_provenance_ref", params: ["i32", "i32", "i32"], results: [] }, + { module: "env", name: "__wpk_fork_ref_gc_route", params: ["i32", "i32"], results: ["i32"] }, + { module: "env", name: "__wpk_fork_ref_scratch_release", params: ["ptr", "ptr"], results: [] }, + { module: "env", name: "__wpk_fork_ref_scratch_reserve", params: ["ptr"], results: ["ptr"] }, + { module: "env", name: "__wpk_fork_ref_vector_append", params: ["i32", "i32"], results: [] }, + { module: "env", name: "__wpk_fork_ref_vector_begin", params: ["i32"], results: ["i32"] }, + { module: "env", name: "__wpk_fork_ref_vector_finish", params: ["i32"], results: ["i32"] }, + { module: "env", name: "__wpk_fork_ref_vector_get", params: ["i32", "i32"], results: ["i32"] }, + { module: "env", name: "__wpk_fork_resume_peek", params: ["i32"], results: ["i32"] }, +] as const; +export const WPK_FORK_REQUIRED_TABLE_IMPORTS = [ + { module: "env", name: "__wpk_fork_ref_gc_transit", table64: false, element: "anyref", minimum: 1, maximum: null }, + { module: "env", name: "__wpk_fork_resume_table", table64: false, element: "funcref", minimum: 1, maximum: null }, ] as const; export const WPK_FORK_REQUIRED_EXPORTS = [ + { name: "__wpk_fork_exception_materialize", params: ["i32"], results: [] }, + { name: "__wpk_fork_ref_decode_exnref", params: ["i32"], results: ["exnref"] }, + { name: "__wpk_fork_ref_encode_exnref", params: ["exnref"], results: ["i32"] }, + { name: "__wpk_fork_ref_exn_abort", params: [], results: [] }, + { name: "__wpk_fork_ref_exn_clear", params: [], results: [] }, + { name: "__wpk_fork_ref_exn_encode_ingress", params: ["i32"], results: ["i32"] }, + { name: "__wpk_fork_ref_exn_throw_recipe", params: ["i32"], results: [] }, + { name: "__wpk_fork_ref_exn_throw_slot", params: ["i32"], results: [] }, + { name: "__wpk_fork_ref_gc_allocate", params: ["i32"], results: [] }, + { name: "__wpk_fork_ref_gc_encode_slot", params: ["i32"], results: ["i32"] }, + { name: "__wpk_fork_ref_gc_fill", params: ["i32"], results: [] }, + { name: "__wpk_fork_ref_gc_probe", params: ["i32"], results: ["i64"] }, + { name: "__wpk_fork_ref_gc_publish_externref", params: ["i32", "externref"], results: [] }, + { name: "__wpk_fork_static_root_harvest", params: [], results: [] }, { name: "wpk_fork_abort_begin", params: ["ptr"], results: [] }, { name: "wpk_fork_abort_end", params: [], results: [] }, + { name: "wpk_fork_module_bootstrap", params: [], results: [] }, + { name: "wpk_fork_module_state_finish_restore", params: ["i32"], results: [] }, + { name: "wpk_fork_module_state_restore", params: ["i32"], results: [] }, + { name: "wpk_fork_module_state_save", params: ["i32"], results: [] }, + { name: "wpk_fork_module_table_state_restore", params: ["i32"], results: [] }, + { name: "wpk_fork_module_table_state_save", params: ["i32"], results: [] }, + { name: "wpk_fork_module_thread_bootstrap", params: [], results: [] }, { name: "wpk_fork_rewind_begin", params: ["ptr"], results: [] }, { name: "wpk_fork_rewind_end", params: [], results: [] }, { name: "wpk_fork_state", params: [], results: ["i32"] }, @@ -120,6 +431,8 @@ export const CH_ARGS_COUNT = 6 as const; export const CH_ARG_SIZE = 8 as const; export const CH_RETURN = 56 as const; export const CH_ERRNO = 64 as const; +export const CH_REQUEST_FLAGS = 68 as const; +export const CH_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY = 1 as const; export const CH_DATA = 72 as const; export const CH_DATA_SIZE = 65536 as const; export const CH_HEADER_SIZE = 72 as const; diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts index 6225e1aef9..b051c02fd8 100644 --- a/host/src/node-kernel-worker-entry.ts +++ b/host/src/node-kernel-worker-entry.ts @@ -56,7 +56,14 @@ import { DeferredWorkerHandle } from "./deferred-worker-handle"; import { ThreadPageAllocator } from "./thread-allocator"; import { patchWasmForThread } from "./worker-main"; import { ThreadExitCoordinator } from "./thread-exit-coordinator"; -import { detectPtrWidth, extractAbiVersion, extractHeapBase, isWasmModuleBytes } from "./constants"; +import { readForkContinuationAnchor } from "./fork-continuation"; +import { + describeWasmArtifactPolicyFailures, + detectPtrWidth, + extractAbiVersion, + extractHeapBase, + isWasmModuleBytes, +} from "./constants"; import { CH_TOTAL_SIZE, DEFAULT_MAX_PAGES, PAGES_PER_THREAD, WASM_PAGE_SIZE } from "./constants"; import { classifiedSignalOrFallback, @@ -723,6 +730,10 @@ async function resolveExecutableForLaunch( const shebang = parseShebang(bytes); if (!shebang) { if (!isWasmModuleBytes(bytes)) return { errno: ENOEXEC }; + const artifactFailures = describeWasmArtifactPolicyFailures(bytes, { + expectedAbi: kernelWorker.getKernelAbiVersion(), + }); + if (artifactFailures.length > 0) return { errno: ENOEXEC }; let programModule: WebAssembly.Module; try { programModule = await WebAssembly.compile(bytes); diff --git a/host/src/worker-main.ts b/host/src/worker-main.ts index 8320770511..444888449e 100644 --- a/host/src/worker-main.ts +++ b/host/src/worker-main.ts @@ -23,7 +23,11 @@ import { type LoadedSharedLibrary, type SideModuleForkState, } from "./dylink"; -import { extractAbiVersion, WASM_PAGE_SIZE } from "./constants"; +import { + describeWasmArtifactPolicyFailures, + extractAbiVersion, + WASM_PAGE_SIZE, +} from "./constants"; import { ABI_SYSCALLS, CHANNEL_STATUS_IDLE, @@ -41,6 +45,7 @@ import { HOST_INTERCEPTED_SYSCALLS, WPK_FORK_REQUIRED_EXPORTS, WPK_FORK_REQUIRED_IMPORTS, + WPK_FORK_CAP_ACTIVATION_STATE_SAFE, } from "./generated/abi"; import { FORK_SAVE_BUFFER_SIZE, @@ -1375,7 +1380,7 @@ const FORK_BUF_SIZE = FORK_SAVE_BUFFER_SIZE; /** * Detect a legacy contiguous fork-save-buffer overrun after unwind. * - * ABI 42 linked continuations do not use this check. It remains exported for + * Linked continuations do not use this check. It remains exported for * stale-buffer regression coverage. Legacy instrumentation keeps * `current_pos` — the pointer-width integer at the * base of the save buffer (`forkBufAddr + 0`) — seeded to the absolute address @@ -1482,9 +1487,10 @@ const DLOPEN_ENTRY_SIZE_WASM64 = 72; const WPK_FORK_EXPORTS = WPK_FORK_REQUIRED_EXPORTS.map(({ name }) => name); function hasCompleteForkInstrumentation( - moduleExports: WebAssembly.ModuleExportDescriptor[], + module: WebAssembly.Module, pid: number, ): boolean { + const moduleExports = WebAssembly.Module.exports(module); const exportNames = new Set(moduleExports.map((e) => e.name)); const legacyAsyncifyExports = [...exportNames].filter((name) => name.startsWith("asyncify_")); if (legacyAsyncifyExports.length > 0) { @@ -1504,7 +1510,20 @@ function hasCompleteForkInstrumentation( ); } - return presentWpkExports.length === WPK_FORK_EXPORTS.length; + const complete = presentWpkExports.length === WPK_FORK_EXPORTS.length; + if (complete) { + const claim = readForkInstrumentCapabilityClaim(module); + if ( + !claim.present || + (claim.flags & WPK_FORK_CAP_ACTIVATION_STATE_SAFE) === 0 + ) { + throw new Error( + `pid=${pid}: wasm-fork-instrument artifact lacks the required ` + + "activation-state-safe capability; rebuild it for ABI 43.", + ); + } + } + return complete; } /** @@ -1573,6 +1592,15 @@ export async function centralizedWorkerMain( try { const { memory, programBytes, channelOffset, pid } = initData; const ptrWidth = initData.ptrWidth ?? 4; + const artifactFailures = describeWasmArtifactPolicyFailures(programBytes, { + expectedAbi: initData.kernelAbiVersion, + }); + if (artifactFailures.length > 0) { + throw new Error( + `pid=${pid}: refusing unsafe program artifact before execution: ` + + artifactFailures.join("; "), + ); + } // Use pre-compiled module if provided (avoids recompilation in workers) const module = initData.programModule ? initData.programModule @@ -1654,8 +1682,7 @@ export async function centralizedWorkerMain( // Check if the module has complete wpk_fork_* instrumentation exports, // and reject stale legacy fork artifacts before they can run. - const moduleExports = WebAssembly.Module.exports(module); - const hasForkInstrumentation = hasCompleteForkInstrumentation(moduleExports, pid); + const hasForkInstrumentation = hasCompleteForkInstrumentation(module, pid); const forkCapabilityClaim = readForkInstrumentCapabilityClaim(module); const hasDylinkForkRole = forkInstrumentRoleAvailable( forkCapabilityClaim, @@ -2684,8 +2711,7 @@ export async function centralizedThreadWorkerMain( ? initData.programModule : new WebAssembly.Module(programBytes!); - const moduleExports = WebAssembly.Module.exports(module); - const hasForkInstrumentation = hasCompleteForkInstrumentation(moduleExports, pid); + const hasForkInstrumentation = hasCompleteForkInstrumentation(module, pid); let forkBufAddr = 0; const forkAnchorAddr = channelOffset - FORK_BUF_SIZE; const threadForkContinuation = hasForkInstrumentation diff --git a/host/test/catch-ref-fresh-worker.test.ts b/host/test/catch-ref-fresh-worker.test.ts new file mode 100644 index 0000000000..2272c1e8f7 --- /dev/null +++ b/host/test/catch-ref-fresh-worker.test.ts @@ -0,0 +1,58 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const testDir = dirname(fileURLToPath(import.meta.url)); +const fixtureSource = resolve( + testDir, + "fixtures/catch-ref-fresh-worker.wat", +); +const instrumenter = resolve( + testDir, + "../../tools/bin/wasm-fork-instrument", +); + +describe("CatchRef fresh process worker replay", () => { + let workDir = ""; + let programPath = ""; + + beforeAll(() => { + workDir = mkdtempSync(join(tmpdir(), "kandelo-catch-ref-worker-")); + const rawPath = join(workDir, "catch-ref-fresh-worker.raw.wasm"); + programPath = join(workDir, "catch-ref-fresh-worker.wasm"); + execFileSync("wat2wasm", [ + "--enable-exceptions", + "--enable-threads", + fixtureSource, + "-o", + rawPath, + ]); + execFileSync(instrumenter, [rawPath, "-o", programPath]); + }); + + afterAll(() => { + if (workDir) rmSync(workDir, { recursive: true, force: true }); + }); + + it("reconstructs the caught exception in a fresh Node child worker", async () => { + // The fixture's parent waits for the fork child. The child exits 91 if + // CatchRef replay did not restore payload 42; the parent converts any + // failed wait status into exit 92. + const result = await runCentralizedProgram({ + programPath, + argv: ["catch-ref-fresh-worker"], + timeout: 30_000, + useDefaultRootfs: false, + }); + + expect( + result.exitCode, + `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ).toBe(0); + expect(result.stderr).toBe(""); + }); +}); diff --git a/host/test/dylink.test.ts b/host/test/dylink.test.ts index 4de05a8e5a..9ce05ccb13 100644 --- a/host/test/dylink.test.ts +++ b/host/test/dylink.test.ts @@ -10,6 +10,7 @@ import { loadSharedLibrary, loadSharedLibrarySync, DynamicLinker, + FORK_CAP_ACTIVATION_STATE_SAFE, FORK_CAP_DYLINK_MAIN, FORK_CAP_SIDE_ENTRY, FORK_CAPABILITIES_SECTION, @@ -25,6 +26,7 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { LINKED_FRAME_FORMAT_SECTION } from "../src/fork-continuation"; +import { ABI_VERSION } from "../src/generated/abi"; function hasCompiler(compiler = "wasm32posix-cc"): boolean { try { @@ -79,18 +81,26 @@ function buildDylinkWat( memorySize = 0, wat2wasmFlags: string[] = [], tlsExports: string[] = [], + abiVersion: number | null = ABI_VERSION, ): Uint8Array { const dir = join(tmpdir(), "wasm-dylink-wat-test"); mkdirSync(dir, { recursive: true }); const watPath = join(dir, `${name}.wat`); const wasmPath = join(dir, `${name}.wasm`); - const linkedWat = forkCapabilities !== undefined + let linkedWat = forkCapabilities !== undefined && (forkCapabilities & FORK_CAP_SIDE_ENTRY) !== 0 ? wat.replace("(module", `(module (import "env" "__wpk_fork_frame_reserve" (func (param i32) (result i32))) (import "env" "__wpk_fork_frame_commit" (func (param i32))) (import "env" "__wpk_fork_frame_next" (func (param i32) (result i32)))`) : wat; + if (forkCapabilities !== undefined && abiVersion !== null) { + const moduleEnd = linkedWat.lastIndexOf(")"); + if (moduleEnd < 0) throw new Error("test WAT has no module terminator"); + linkedWat = `${linkedWat.slice(0, moduleEnd)} + (func (export "__abi_version") (result i32) i32.const ${abiVersion}) + ${linkedWat.slice(moduleEnd)}`; + } writeFileSync(watPath, linkedWat); execFileSync("wat2wasm", ["--enable-threads", ...wat2wasmFlags, watPath, "-o", wasmPath], { stdio: "pipe", @@ -124,7 +134,10 @@ function buildDylinkWat( let marked = appendCustomSection( out, FORK_CAPABILITIES_SECTION, - new Uint8Array([FORK_CAPABILITIES_VERSION, forkCapabilities]), + new Uint8Array([ + FORK_CAPABILITIES_VERSION, + forkCapabilities | FORK_CAP_ACTIVATION_STATE_SAFE, + ]), ); if ((forkCapabilities & FORK_CAP_SIDE_ENTRY) !== 0) { marked = appendCustomSection( @@ -708,7 +721,7 @@ describe("side-module fork contract", () => { if (legacyAllowed) { expect(load).not.toThrow(); } else { - expect(load).toThrow(/versioned side-entry capability/); + expect(load).toThrow(/activation-state-safe capability/); } }); @@ -757,6 +770,46 @@ describe("side-module fork contract", () => { .toThrow(/versioned side-entry capability/); }); + it("binds a side module's activation-safety claim to ABI 43", () => { + const sideWat = ` + (module + (import "env" "memory" (memory 1 100 shared)) + (func (export "wpk_fork_unwind_begin") (param i32)) + (func (export "wpk_fork_unwind_end")) + (func (export "wpk_fork_rewind_begin") (param i32)) + (func (export "wpk_fork_rewind_end")) + (func (export "wpk_fork_abort_begin") (param i32)) + (func (export "wpk_fork_abort_end")) + (func (export "wpk_fork_state") (result i32) i32.const 0)) + `; + const options = createSideForkLoadOptions(); + const stale = buildDylinkWat( + sideWat, + "side-fork-stale-abi", + 0, + 0, + 0, + [], + [], + ABI_VERSION - 1, + ); + expect(() => loadSharedLibrarySync("libstale.so", stale, options)) + .toThrow(/declares ABI 42, but the host requires ABI 43/); + + const missing = buildDylinkWat( + sideWat, + "side-fork-missing-abi", + 0, + 0, + 0, + [], + [], + null, + ); + expect(() => loadSharedLibrarySync("libmissing.so", missing, options)) + .toThrow(/missing __abi_version/); + }); + it("reads the versioned side-entry capability independently", () => { const wasmBytes = buildDylinkWat(` (module (import "env" "memory" (memory 1 100 shared))) @@ -764,9 +817,11 @@ describe("side-module fork contract", () => { const module = new WebAssembly.Module(wasmBytes as unknown as BufferSource); expect(readForkInstrumentCapabilityClaim(module)).toEqual({ present: true, - flags: FORK_CAP_SIDE_ENTRY, + flags: FORK_CAP_SIDE_ENTRY | FORK_CAP_ACTIVATION_STATE_SAFE, }); - expect(readForkInstrumentCapabilities(module)).toBe(FORK_CAP_SIDE_ENTRY); + expect(readForkInstrumentCapabilities(module)).toBe( + FORK_CAP_SIDE_ENTRY | FORK_CAP_ACTIVATION_STATE_SAFE, + ); }); it("rejects a malformed marker even during the ABI-16 compatibility window", () => { diff --git a/host/test/fixtures/catch-ref-fresh-worker.wat b/host/test/fixtures/catch-ref-fresh-worker.wat new file mode 100644 index 0000000000..bd191cdd27 --- /dev/null +++ b/host/test/fixtures/catch-ref-fresh-worker.wat @@ -0,0 +1,172 @@ +;; ABI 43 integration fixture for real process workers. +;; +;; The parent catches a scalar tag through CatchRef, forks from the handler, +;; and waits for the child. Rewind can succeed only if the copied continuation +;; rethrows the tag in the fresh child instance and the original CatchRef clause +;; creates a new instance-local exnref. The child exits 91 if its scalar payload +;; was not restored; the parent exits 92 if wait4 observes any failure. +(module + (import "env" "memory" (memory 1 16384 shared)) + (import "env" "__channel_base" (global $__channel_base (mut i32))) + (import "kernel" "kernel_exit" (func $kernel_exit (param i32))) + (import "kernel" "kernel_fork" (func $kernel_fork (result i32))) + + (tag $payload (param i32)) + + (global $__stack_pointer (export "__stack_pointer") (mut i32) + (i32.const 65536)) + + (func (export "__abi_version") (result i32) + i32.const 43) + + (func $wait_child (param $pid i32) (result i32) + (local $base i32) + (local $result i32) + + global.get $__channel_base + local.set $base + + ;; SYS_wait4(pid, &status, 0, 0) + local.get $base + i32.const 4 + i32.add + i32.const 139 + i32.store + + local.get $base + i32.const 8 + i32.add + local.get $pid + i64.extend_i32_s + i64.store + + local.get $base + i32.const 16 + i32.add + i64.const 1024 + i64.store + + local.get $base + i32.const 24 + i32.add + i64.const 0 + i64.store + + local.get $base + i32.const 32 + i32.add + i64.const 0 + i64.store + + local.get $base + i32.const 40 + i32.add + i64.const 0 + i64.store + + local.get $base + i32.const 48 + i32.add + i64.const 0 + i64.store + + local.get $base + i32.const 1 + i32.atomic.store + local.get $base + i32.const 1 + memory.atomic.notify + drop + + block $complete + loop $wait + local.get $base + i32.atomic.load + i32.const 1 + i32.ne + br_if $complete + + local.get $base + i32.const 1 + i64.const -1 + memory.atomic.wait32 + drop + br $wait + end + end + + local.get $base + i32.const 64 + i32.add + i32.load + if + i32.const -1 + local.set $result + else + local.get $base + i32.const 56 + i32.add + i64.load + i32.wrap_i64 + local.set $result + end + + local.get $base + i32.const 0 + i32.atomic.store + + local.get $result) + + (func (export "_start") + (local $caught i32) + (local $pid i32) + + (block $handler (result i32 exnref) + (try_table (result i32 exnref) + (catch_ref $payload $handler) + i32.const 42 + throw $payload + unreachable)) + drop + local.set $caught + + call $kernel_fork + local.set $pid + + local.get $pid + i32.eqz + if + local.get $caught + i32.const 42 + i32.ne + if + i32.const 91 + call $kernel_exit + unreachable + end + i32.const 0 + call $kernel_exit + unreachable + end + + local.get $pid + call $wait_child + local.get $pid + i32.ne + if + i32.const 92 + call $kernel_exit + unreachable + end + + i32.const 1024 + i32.load + if + i32.const 92 + call $kernel_exit + unreachable + end + + i32.const 0 + call $kernel_exit + unreachable)) diff --git a/host/test/fork-from-thread.test.ts b/host/test/fork-from-thread.test.ts index 63ee648f48..1a26c7f7f6 100644 --- a/host/test/fork-from-thread.test.ts +++ b/host/test/fork-from-thread.test.ts @@ -33,6 +33,10 @@ describe("fork-from-non-main-thread", () => { programPath: forkFromThreadBinary!, argv: ["fork-from-thread"], timeout: 15_000, + // This fixture exercises worker/continuation ownership only and never + // touches the VFS. Keep the pthread fork proof independent of the + // separately versioned rootfs package rebuild. + useDefaultRootfs: false, }); expect(result.exitCode, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0); @@ -65,6 +69,7 @@ describe("fork-from-non-main-thread", () => { programPath: concurrentForkBinary!, argv: ["fork-from-concurrent-threads"], timeout: 60_000, + useDefaultRootfs: false, }); expect(result.exitCode, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0); diff --git a/host/test/fork-instrument-coverage.test.ts b/host/test/fork-instrument-coverage.test.ts index a1e0a24006..f4c049b9b8 100644 --- a/host/test/fork-instrument-coverage.test.ts +++ b/host/test/fork-instrument-coverage.test.ts @@ -190,79 +190,65 @@ describe("fork_instrument_coverage / D-* dispatch", () => { // --------------------------------------------------------------------------- describe("fork_instrument_coverage / C-* catch-handler resume", () => { - it("C-01 try { fork() } catch (int) — no throw, fork in try body", async () => { + // LLVM 21 currently adds exnref locals and/or untagged cleanup catches to + // these C++ functions. ABI 43 rejects those raw modules during + // instrumentation; the supported tagged Catch/CatchRef surface is exercised + // in catch-ref-fresh-worker.test.ts and plain-catch-payload-lifetime.test.ts. + it.skip("C-01 compiler EH output requires transferable cleanup state", async () => { await runFixture("programs/c_01_fork_in_try_no_throw.wasm", { contains: ["IN_TRY", "PRE_FORK", "CHILD: ok", "PASS: C-01"], }); }); - // C-02: B1 plain catch, single arm — fork inside catch handler. - // The B1-stages-1+2 machinery (Phase 6 rewind-throw stub + capture - // block + exnref stash) handles this correctly under modern wasm-EH - // lowering. Was `it.fails` pre-2026-05-14 because the SDK emitted - // legacy `try`/`catch`; the B1 machinery is structured for modern - // `try_table`/`catch_ref`/`throw_ref` only. Commit 9's SDK flip - // (with the empirical 2026-05-14 follow-up adding - // `-wasm-use-legacy-eh=false` explicitly) made this case actually - // exercise the existing modern-EH path. - it("C-02 fork inside single-arm plain catch (B1)", async () => { + // C-02..C-07 retain their source fixtures as compiler-policy probes. The + // build requires a precise instrumentation rejection and keeps the raw + // modules only under test-fixtures/unsupported-abi43. + it.skip("C-02 compiler EH output requires transferable cleanup state", async () => { await runFixture("programs/c_02_fork_in_catch.wasm", { contains: ["THROWING", "CAUGHT: 7", "PRE_FORK", "CHILD: ok", "PASS: C-02"], }); }); - // C-03: multi-arm plain-catch try_tables. The B1 stage 2 machinery's - // per-arm capture-block emission handles multi-arm under modern EH. - it("C-03 fork in multi-arm plain catch", async () => { + it.skip("C-03 compiler EH output requires transferable cleanup state", async () => { await runFixture("programs/c_03_fork_in_multi_arm_catch.wasm", { contains: ["THROWING", "CAUGHT_STR: x", "PRE_FORK", "CHILD: ok", "PASS: C-03"], }); }); - // C-04: throw originates outside the instrumented region. Switch- - // dispatch's body-skip-on-REWIND construction means the throw - // doesn't re-fire on REWIND — no gating needed. - it("C-04 fork in catch where throw originates outside instrumented region (B2)", async () => { + it.skip("C-04 compiler EH output requires transferable cleanup state", async () => { await runFixture("programs/c_04_fork_in_catch_external_throw.wasm", { contains: ["CALLING_HELPER", "IN_HELPER", "CAUGHT: 99", "PRE_FORK", "CHILD: ok", "PASS: C-04"], }); }); - // C-05..C-07: modern wasm-EH variants. Post-commit-9 + 2026-05-14 - // follow-up, ALL C++ programs lower via modern EH, so these are - // effectively duplicates of C-02 / C-03 / multi-typed-catch under - // the unified lowering — but kept distinct in case future toolchain - // versions reintroduce divergence. - it("C-05 modern EH single-clause typed catch + fork", async () => { + it.skip("C-05 compiler EH output requires transferable cleanup state", async () => { await runFixture("programs/c_05_fork_modern_eh_single.wasm", { contains: ["THROWING", "CAUGHT: 1", "PRE_FORK", "CHILD: ok", "PASS: C-05"], }); }); - it("C-06 modern EH multi-target *_ref try_table + fork", async () => { + it.skip("C-06 compiler EH output requires transferable cleanup state", async () => { await runFixture("programs/c_06_fork_modern_eh_multi_ref.wasm", { contains: ["THROWING", "CAUGHT_DOUBLE: 3.14", "PRE_FORK", "CHILD: ok", "PASS: C-06"], }); }); - it("C-07 modern EH multi-arm plain catches + fork", async () => { + it.skip("C-07 compiler EH output requires transferable cleanup state", async () => { await runFixture("programs/c_07_fork_modern_eh_multi_plain.wasm", { contains: ["THROWING", "CAUGHT_LONG: 1234567", "PRE_FORK", "CHILD: ok", "PASS: C-07"], }); }); - // C-08, C-09 — A4 funcref/externref catch operands. No C-source - // surface; covered by `crates/fork-instrument/tests/coverage_wat.rs` - // which verifies fork-instrument doesn't panic on these patterns. - // Full A4 implementation (per-arm aux-table spilling for ref-typed - // catch operands) is future work — today the affected function is - // carved out of the fork-path set via b2_carveout. + // C-08, C-09 — funcref/externref catch operands. There is no C-source + // surface, so `crates/fork-instrument/tests/coverage_wat.rs` verifies the + // ABI 43 boundary directly: a reference payload in the fork closure is + // rejected during instrumentation instead of being placed in + // module-instance scratch state. it.skip("C-08 plain catch arm with funcref operand [tested via crates/fork-instrument/tests/coverage_wat.rs]", () => {}); it.skip("C-09 plain catch arm with externref operand [tested via crates/fork-instrument/tests/coverage_wat.rs]", () => {}); - // C-10: fork in BOTH try body and catch handler. Combines D-06 with - // C-02. Passes under modern EH. - it("C-10 fork in both try body and catch handler", async () => { + // C-10/C-11 are part of the same compiler-output rejection boundary. + it.skip("C-10 compiler EH output requires transferable cleanup state", async () => { await runFixture("programs/c_10_fork_in_try_and_catch.wasm", { contains: [ "IN_TRY", "PRE_FORK_TRY", "CHILD_TRY: ok", @@ -272,10 +258,7 @@ describe("fork_instrument_coverage / C-* catch-handler resume", () => { }); }); - // C-11: post-catch fork (catch frame fully popped). Repro of the - // SpiderMonkey spike test (b). Closed by commit 9 + follow-up - // alongside C-02 — same root cause (modern-EH-only B1 machinery). - it("C-11 fork after fully-popped catch frame (spike test b)", async () => { + it.skip("C-11 compiler EH output requires transferable cleanup state", async () => { await runFixture("programs/c_11_post_catch_fork.wasm", { contains: ["CAUGHT: 42", "PRE_FORK", "CHILD: ok", "PASS: C-11"], }); @@ -321,10 +304,8 @@ describe("fork_instrument_coverage / S-* side effects during rewind", () => { it.skip("S-06 table.grow before fork [tested via crates/fork-instrument/tests/coverage_wat.rs]", () => {}); it.skip("S-07 non-nullable funcref direct-call result before fork [tested via crates/fork-instrument/tests/coverage_wat.rs]", () => {}); - // S-08: throw from outside instrumented region, caught inside, - // fork in catch. Sibling of C-04. Closed by commit 9 + 2026-05-14 - // follow-up (explicit modern EH). - it("S-08 throw from outside instrumented region, fork in catch (B2)", async () => { + // S-08's current LLVM output retains an exnref local across the fork path. + it.skip("S-08 compiler EH output requires transferable cleanup state", async () => { await runFixture("programs/s_08_external_throw_fork_in_catch.wasm", { contains: ["ENTER_OUTER", "ENTER_INNER", "THROWING", "CAUGHT: 73", "PRE_FORK", "CHILD: ok", "PASS: S-08"], }); @@ -379,10 +360,9 @@ describe("fork_instrument_coverage / K-* callback fork roots", () => { }); }); - // K-06: fork() from a C++ destructor. Unusual but legal RAII - // pattern. The dtor is called as part of stack unwinding when - // the object goes out of scope; fork() inside it must work. - it("K-06 fork from C++ destructor (RAII)", async () => { + // K-06 lowers destructor cleanup to an untagged CatchAll. ABI 43 rejects it + // until that cleanup state has a deterministic child reconstruction recipe. + it.skip("K-06 compiler CatchAll cleanup is not reconstructible", async () => { await runFixture("programs/k_06_fork_from_dtor.wasm", { contains: ["IN_SCOPE", "IN_DTOR", "PRE_FORK", "CHILD: ok", "PARENT: child=", "PASS: K-06"], }); @@ -480,7 +460,7 @@ describe("fork_instrument_coverage / P-* process & threading", () => { // P-10: 4,096 live recursive activations require more frame payload than // ABI 41's retired 60 KiB contiguous reserve. This is the end-to-end guard - // that the ABI 42 host grows a linked continuation and replays it safely. + // that the current host grows a linked continuation and replays it safely. it("P-10 continuation grows beyond the retired fixed reserve", async () => { await runFixture("programs/p_10_deep_linked_continuation.wasm", { contains: ["PRE_DEEP_FORK", "DEEP_CHILD: ok", "DEEP_PARENT: child=", "PASS: P-10"], diff --git a/host/test/generated-abi.test.ts b/host/test/generated-abi.test.ts index 2f0a7e46c0..22a480b015 100644 --- a/host/test/generated-abi.test.ts +++ b/host/test/generated-abi.test.ts @@ -61,6 +61,13 @@ import { STRUCT_SIZE_WASM_STATFS, STRUCT_SIZE_WASM_TIMESPEC, SYSCALL_ARGS, + WPK_FORK_CAPABILITIES_SECTION, + WPK_FORK_CAPABILITIES_VERSION, + WPK_FORK_CAP_ACTIVATION_STATE_SAFE, + WPK_FORK_CAP_DYLINK_MAIN, + WPK_FORK_CAP_KNOWN_MASK, + WPK_FORK_CAP_REQUIRED_FLAGS, + WPK_FORK_CAP_SIDE_ENTRY, WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE, WPK_FORK_LINKED_FRAME_FORMAT_MAGIC, WPK_FORK_LINKED_FRAME_FORMAT_SECTION, @@ -112,7 +119,20 @@ function hostAdapterManifestField(name: string): { offset: number; size: number describe("generated host ABI bindings", () => { it("match the complete fork-artifact contract", () => { const fork = snapshot.program_artifact.fork_instrumentation; + const capabilities = fork.capabilities; const descriptor = fork.linked_frame_descriptor; + expect(WPK_FORK_CAPABILITIES_SECTION).toBe(capabilities.section); + expect(WPK_FORK_CAPABILITIES_VERSION).toBe(capabilities.version); + expect(WPK_FORK_CAP_KNOWN_MASK).toBe(capabilities.known_mask); + expect(WPK_FORK_CAP_REQUIRED_FLAGS).toBe(capabilities.required_flags); + expect([ + { bit: WPK_FORK_CAP_SIDE_ENTRY, name: "side_entry" }, + { bit: WPK_FORK_CAP_DYLINK_MAIN, name: "dylink_main" }, + { + bit: WPK_FORK_CAP_ACTIVATION_STATE_SAFE, + name: "activation_state_safe", + }, + ]).toEqual(capabilities.flags); expect(WPK_FORK_LINKED_FRAME_FORMAT_SECTION).toBe(descriptor.section); expect(WPK_FORK_LINKED_FRAME_FORMAT_VERSION).toBe(descriptor.version); expect(WPK_FORK_LINKED_FRAME_FORMAT_MAGIC).toEqual(descriptor.magic_bytes); diff --git a/host/test/plain-catch-payload-lifetime.test.ts b/host/test/plain-catch-payload-lifetime.test.ts index 666a52b08a..510cc299b1 100644 --- a/host/test/plain-catch-payload-lifetime.test.ts +++ b/host/test/plain-catch-payload-lifetime.test.ts @@ -293,7 +293,7 @@ describe("plain-catch payload lifetime", () => { } }); - it("distinguishes plain and catch_ref state in either reuse order", () => { + it("reconstructs mixed plain and catch_ref state in a fresh child instance", () => { const dir = mkdtempSync(join(tmpdir(), "kandelo-mixed-catch-lifetime-")); try { const watPath = join(dir, "mixed-catch-lifetime.wat"); @@ -303,12 +303,13 @@ describe("plain-catch payload lifetime", () => { (import "kernel" "kernel_fork" (func $fork (result i32))) (import "env" "memory" (memory 4)) (tag $plain (param i32)) - (tag $with_ref) + (tag $with_ref (param i32)) (func (export "run") (param $take_plain i32) (result i32) + (local $caught i32) (block $done (result i32) (block $plain_handler (result i32) - (block $ref_handler (result exnref) - (try_table (result exnref) + (block $ref_handler (result i32 exnref) + (try_table (result i32 exnref) (catch $plain $plain_handler) (catch_ref $with_ref $ref_handler) local.get $take_plain @@ -316,15 +317,19 @@ describe("plain-catch payload lifetime", () => { i32.const 41 throw $plain else + i32.const 42 throw $with_ref end unreachable)) drop + local.set $caught call $fork - i32.const 42 + local.get $caught i32.add br $done) + local.set $caught call $fork + local.get $caught i32.add br $done)))`); execFileSync("wat2wasm", [ @@ -340,59 +345,95 @@ describe("plain-catch payload lifetime", () => { const module = new WebAssembly.Module(readFileSync(instrumentedPath)); const runOrder = (modes: readonly number[]): void => { - const memory = new WebAssembly.Memory({ initial: 4 }); - let instance: WebAssembly.Instance; + const parentMemory = new WebAssembly.Memory({ initial: 4 }); + let parentInstance: WebAssembly.Instance; let moduleBuffer = 0; - const continuation = new LinkedForkContinuation( - memory, + const parentContinuation = new LinkedForkContinuation( + parentMemory, readLinkedFrameFormat(module), () => 65_536, () => {}, - "mixed-catch-lifetime", + "mixed-catch-parent", ); - instance = new WebAssembly.Instance(module, { + parentInstance = new WebAssembly.Instance(module, { env: { - memory, + memory: parentMemory, __wpk_fork_frame_reserve: (size: number) => - continuation.reserveFrame(size), + parentContinuation.reserveFrame(size), __wpk_fork_frame_commit: (payload: number) => - continuation.commitFrame(payload), + parentContinuation.commitFrame(payload), __wpk_fork_frame_next: (size: number) => - continuation.nextFrame(size), + parentContinuation.nextFrame(size), }, kernel: { kernel_fork: () => { - const state = (instance.exports.wpk_fork_state as () => number)(); - if (state === 2) { - (instance.exports.wpk_fork_rewind_end as () => void)(); - continuation.finishReplayAndRelease(); - return 7; - } - moduleBuffer = Number(continuation.beginUnwind()); - (instance.exports.wpk_fork_unwind_begin as (addr: number) => void)( + moduleBuffer = Number(parentContinuation.beginUnwind()); + (parentInstance.exports.wpk_fork_unwind_begin as (addr: number) => void)( moduleBuffer, ); return 0; }, }, }); - const run = instance.exports.run as (takePlain: number) => number; + const parentRun = parentInstance.exports.run as (takePlain: number) => number; for (const mode of modes) { - expect(run(mode)).toBe(0); - (instance.exports.wpk_fork_unwind_end as () => void)(); - continuation.finishUnwind(); - continuation.beginReplay(); - (instance.exports.wpk_fork_rewind_begin as (addr: number) => void)( + expect(parentRun(mode)).toBe(0); + (parentInstance.exports.wpk_fork_unwind_end as () => void)(); + parentContinuation.finishUnwind(); + + // Model the actual worker boundary: the child receives only copied + // linear memory and instantiates an otherwise fresh Wasm module. + const childMemory = new WebAssembly.Memory({ initial: 4 }); + new Uint8Array(childMemory.buffer).set( + new Uint8Array(parentMemory.buffer), + ); + parentContinuation.cancelUnwindAndRelease(); + + const childContinuation = new LinkedForkContinuation( + childMemory, + readLinkedFrameFormat(module), + () => { + throw new Error("fresh child replay must not allocate a continuation"); + }, + () => {}, + "mixed-catch-child", + ); + childContinuation.attachForReplay(moduleBuffer); + let childInstance: WebAssembly.Instance; + childInstance = new WebAssembly.Instance(module, { + env: { + memory: childMemory, + __wpk_fork_frame_reserve: (size: number) => + childContinuation.reserveFrame(size), + __wpk_fork_frame_commit: (payload: number) => + childContinuation.commitFrame(payload), + __wpk_fork_frame_next: (size: number) => + childContinuation.nextFrame(size), + }, + kernel: { + kernel_fork: () => { + expect( + (childInstance.exports.wpk_fork_state as () => number)(), + ).toBe(2); + (childInstance.exports.wpk_fork_rewind_end as () => void)(); + childContinuation.finishReplayAndRelease(); + return 7; + }, + }, + }); + (childInstance.exports.wpk_fork_rewind_begin as (addr: number) => void)( moduleBuffer, ); - expect(run(mode)).toBe((mode ? 41 : 42) + 7); + const childRun = childInstance.exports.run as (takePlain: number) => number; + expect(childRun(mode)).toBe((mode ? 41 : 42) + 7); } }; - // WHY: the exnref table is intentionally reused. A stale non-null - // entry after catch_ref must not make the next plain activation take - // throw_ref, and a preceding plain catch must not suppress catch_ref. + // Both arm orders exercise one long-lived parent instance, while every + // child has empty module globals/tables. CatchRef can pass only if rewind + // restores the scalar tag payload and rethrows the tag to create a new + // instance-local exnref. runOrder([0, 1]); runOrder([1, 0]); } finally { diff --git a/host/test/sjlj-noexcept-boundary.test.ts b/host/test/sjlj-noexcept-boundary.test.ts index 53fe0be083..55a252fd68 100644 --- a/host/test/sjlj-noexcept-boundary.test.ts +++ b/host/test/sjlj-noexcept-boundary.test.ts @@ -13,28 +13,32 @@ const rawWasm64Fixture = join( repoRoot, "local-binaries/test-fixtures/wasm64/sjlj_noexcept_boundary.raw.wasm", ); -const instrumentedFixture = resolveBinary( - "programs/sjlj_noexcept_boundary.wasm", +const unsupportedForkFixture = join( + repoRoot, + "local-binaries/test-fixtures/wasm32/unsupported-abi43/sjlj_noexcept_boundary.raw.wasm", ); +const unsupportedForkDiagnostic = `${unsupportedForkFixture}.instrument-error.txt`; const sigchldFixture = resolveBinary("programs/sigchld_sjlj.wasm"); const TERMINATED_BY_SIGABRT = 128 + 6; describe("LLVM Wasm SjLj across a noexcept boundary", () => { it("keeps the raw wasm32 control independent of fork instrumentation", () => { const rawModule = new WebAssembly.Module(readFileSync(rawWasm32Fixture)); - const instrumentedModule = new WebAssembly.Module( - readFileSync(instrumentedFixture), - ); const exportNames = (module: WebAssembly.Module) => WebAssembly.Module.exports(module).map(({ name }) => name); expect(exportNames(rawModule)).not.toContain("wpk_fork_state"); - expect(exportNames(instrumentedModule)).toContain("wpk_fork_state"); + }); + + it("keeps the fork-bearing compiler output outside the ABI 43 resolver", () => { + expect(readFileSync(unsupportedForkFixture).byteLength).toBeGreaterThan(0); + expect(readFileSync(unsupportedForkDiagnostic, "utf8")).toMatch( + /reference local\/parameter|uses CatchAll|uses CatchAllRef/, + ); }); it.each([ ["raw wasm32", rawWasm32Fixture], - ["fork-instrumented wasm32", instrumentedFixture], ["raw wasm64", rawWasm64Fixture], ])("documents the pinned LLVM failure in the %s control", async (_, path) => { const result = await runCentralizedProgram({ @@ -52,7 +56,7 @@ describe("LLVM Wasm SjLj across a noexcept boundary", () => { it("resumes the same SjLj tag when it does not cross noexcept", async () => { const result = await runCentralizedProgram({ - programPath: instrumentedFixture, + programPath: rawWasm32Fixture, argv: ["sjlj_noexcept_boundary", "--permissive"], timeout: 10_000, useDefaultRootfs: false, diff --git a/host/test/wasm-binary-parse.test.ts b/host/test/wasm-binary-parse.test.ts index d625dcce1d..731c5b39ad 100644 --- a/host/test/wasm-binary-parse.test.ts +++ b/host/test/wasm-binary-parse.test.ts @@ -15,6 +15,10 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { ABI_VERSION, + WPK_FORK_CAPABILITIES_SECTION, + WPK_FORK_CAPABILITIES_VERSION, + WPK_FORK_CAP_ACTIVATION_STATE_SAFE, + WPK_FORK_CAP_KNOWN_MASK, WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE, WPK_FORK_LINKED_FRAME_FORMAT_MAGIC, WPK_FORK_LINKED_FRAME_FORMAT_SECTION, @@ -223,6 +227,10 @@ function completeForkWasm(options: { pointerWidth?: 4 | 8; memoryPointerWidth?: 4 | 8; exportPointerWidth?: 4 | 8; + capabilityFlags?: number | null; + capabilityPayloads?: number[][]; + abiVersion?: number; + includeAbiMarker?: boolean; } = {}): ArrayBuffer { const pointerWidth = options.pointerWidth ?? 4; const pointerType = pointerWidth === 8 ? I64 : I32; @@ -247,11 +255,25 @@ function completeForkWasm(options: { return requirement.params.length === 1 ? 1 : 2; }); const firstDefinedFunction = funcImports.length; + const capabilityFlags = + options.capabilityFlags === undefined + ? WPK_FORK_CAP_ACTIVATION_STATE_SAFE + : options.capabilityFlags; + const capabilityPayloads = options.capabilityPayloads ?? + (capabilityFlags === null + ? [] + : [[WPK_FORK_CAPABILITIES_VERSION, capabilityFlags]]); return buildWasm({ - customSections: [{ - name: WPK_FORK_LINKED_FRAME_FORMAT_SECTION, - data: linkedFrameDescriptor(pointerWidth), - }], + customSections: [ + ...capabilityPayloads.map((data) => ({ + name: WPK_FORK_CAPABILITIES_SECTION, + data, + })), + { + name: WPK_FORK_LINKED_FRAME_FORMAT_SECTION, + data: linkedFrameDescriptor(pointerWidth), + }, + ], types, funcImports, funcTypes: [...forkTypeIndices, 0], @@ -262,10 +284,20 @@ function completeForkWasm(options: { kind: 0 as const, index: firstDefinedFunction + index, })), - { + ...(options.includeAbiMarker === false ? [] : [{ name: "__abi_version", - kind: 0, + kind: 0 as const, index: firstDefinedFunction + forkTypeIndices.length, + }]), + ], + funcBodies: [ + ...WPK_FORK_REQUIRED_EXPORTS.map((requirement) => ({ + locals: [0], + instructions: requirement.results.length === 1 ? [0x41, 0] : [], + })), + { + locals: [0], + instructions: [0x41, ...sleb128_i32(options.abiVersion ?? ABI_VERSION)], }, ], }); @@ -512,23 +544,105 @@ describe("wasm artifact policy helpers", () => { `missing required ${WPK_FORK_LINKED_FRAME_FORMAT_SECTION} descriptor`, ); expect(failures).toContain( - "incomplete ABI 42 linked-frame imports; missing env.__wpk_fork_frame_commit, env.__wpk_fork_frame_next, env.__wpk_fork_frame_reserve", + "incomplete ABI 43 linked-frame imports; missing env.__wpk_fork_frame_commit, env.__wpk_fork_frame_next, env.__wpk_fork_frame_reserve", ); }); - it("accepts the complete ABI 42 contract for wasm32 and wasm64", () => { + it("accepts the complete ABI 43 contract for wasm32 and wasm64", () => { for (const pointerWidth of [4, 8] as const) { const wasm = completeForkWasm({ pointerWidth }); expect(wasmHasCompleteForkInstrumentation(wasm)).toBe(true); - expect(describeWasmArtifactPolicyFailures(wasm, { expectedAbi: 12 })).toEqual([]); + expect(describeWasmArtifactPolicyFailures(wasm, { expectedAbi: ABI_VERSION })).toEqual([]); + } + }); + + it("rejects every malformed or unsafe activation-state capability shape", () => { + const cases: Array<{ + label: string; + options: Parameters[0]; + diagnostic: string; + }> = [ + { + label: "missing", + options: { capabilityFlags: null }, + diagnostic: `missing required ${WPK_FORK_CAPABILITIES_SECTION} capability`, + }, + { + label: "unsafe flags", + options: { capabilityFlags: 0 }, + diagnostic: "omit required activation-state safety flags", + }, + { + label: "short payload", + options: { + capabilityPayloads: [[WPK_FORK_CAPABILITIES_VERSION]], + }, + diagnostic: "has 1 bytes, expected 2", + }, + { + label: "unsupported version", + options: { + capabilityPayloads: [[ + WPK_FORK_CAPABILITIES_VERSION + 1, + WPK_FORK_CAP_ACTIVATION_STATE_SAFE, + ]], + }, + diagnostic: "version 2 is unsupported", + }, + { + label: "unknown flags", + options: { + capabilityPayloads: [[ + WPK_FORK_CAPABILITIES_VERSION, + WPK_FORK_CAP_KNOWN_MASK | 0x80, + ]], + }, + diagnostic: "has unknown flags", + }, + { + label: "duplicate", + options: { + capabilityPayloads: [ + [WPK_FORK_CAPABILITIES_VERSION, WPK_FORK_CAP_ACTIVATION_STATE_SAFE], + [WPK_FORK_CAPABILITIES_VERSION, WPK_FORK_CAP_ACTIVATION_STATE_SAFE], + ], + }, + diagnostic: "sections, expected exactly one", + }, + ]; + + for (const { label, options, diagnostic } of cases) { + const wasm = completeForkWasm(options); + expect(wasmHasCompleteForkInstrumentation(wasm)).toBe(false); + expect( + describeWasmArtifactPolicyFailures(wasm).join("\n"), + label, + ).toContain(diagnostic); } }); + it("does not let an ABI 42 artifact masquerade as ABI 43 with a copied capability", () => { + const wasm = completeForkWasm({ abiVersion: ABI_VERSION - 1 }); + expect(describeWasmArtifactPolicyFailures(wasm, { + expectedAbi: ABI_VERSION, + })).toContain(`ABI ${ABI_VERSION - 1}, expected ${ABI_VERSION}`); + }); + + it("does not accept a fork capability without an ABI epoch marker", () => { + const wasm = completeForkWasm({ includeAbiMarker: false }); + expect(describeWasmArtifactPolicyFailures(wasm, { + expectedAbi: ABI_VERSION, + })).toContain( + `ABI ${ABI_VERSION} fork artifact is missing __abi_version; ` + + "the activation-state capability epoch cannot be verified", + ); + }); + it("rejects descriptor and module-memory pointer-width drift", () => { const wasm = completeForkWasm({ pointerWidth: 8, memoryPointerWidth: 4 }); expect(wasmHasCompleteForkInstrumentation(wasm)).toBe(false); expect(describeWasmArtifactPolicyFailures(wasm)).toContain( - "ABI 42 linked-frame descriptor declares an 8-byte pointer but the module memory uses 4-byte addresses", + "ABI 43 linked-frame descriptor declares an 8-byte pointer but the module memory uses 4-byte addresses", ); }); @@ -536,7 +650,7 @@ describe("wasm artifact policy helpers", () => { const wasm = completeForkWasm({ pointerWidth: 8, exportPointerWidth: 4 }); expect(wasmHasCompleteForkInstrumentation(wasm)).toBe(false); expect(describeWasmArtifactPolicyFailures(wasm)).toContain( - "ABI 42 wasm-fork-instrument export wpk_fork_abort_begin has the wrong signature; expected (i64) -> ()", + "ABI 43 wasm-fork-instrument export wpk_fork_abort_begin has the wrong signature; expected (i64) -> ()", ); }); @@ -627,7 +741,7 @@ describe("wasm artifact policy helpers", () => { expectedAbi: 12, requireForkInstrumentation: false, forbidForkInstrumentation: true, - })).toContain("contains ABI 42 wasm-fork-instrument metadata, imports, or exports"); + })).toContain("contains ABI 43 wasm-fork-instrument metadata, imports, or exports"); }); }); diff --git a/libc/glue/abi_constants.h b/libc/glue/abi_constants.h index 0ff0a85295..8adf6c8cae 100644 --- a/libc/glue/abi_constants.h +++ b/libc/glue/abi_constants.h @@ -4,7 +4,7 @@ #define WASM_POSIX_ABI_CONSTANTS_H /* Mirrors wasm_posix_shared::ABI_VERSION. */ -#define WASM_POSIX_ABI_VERSION 42u +#define WASM_POSIX_ABI_VERSION 43u /* Default process-wasm pthread slot declaration. */ #define WASM_POSIX_THREAD_SLOT_DECL_DEFAULT -1 diff --git a/programs/f_03_wasm_gc_anyref.c b/programs/f_03_wasm_gc_anyref.c index 3937013a85..8c2486a710 100644 --- a/programs/f_03_wasm_gc_anyref.c +++ b/programs/f_03_wasm_gc_anyref.c @@ -2,19 +2,14 @@ // type (anyref / eqref) on the fork path (A5). // // Coverage matrix: docs/plans/2026-05-13-fork-instrument-megaPR-eliminate-guard-dispatch-and-modern-EH-plan.md -// Stub: wasm-GC reference types have no C-source surface. The fixture -// needs a hand-written WAT module containing an `anyref` or `eqref` -// local on the fork path; the test driver invokes `wasm-fork-instrument` -// directly and asserts it exits non-zero with a clear error message -// naming the function and ref type (the existing classify_ref panic -// is the current mechanism). -// -// Replace this stub with the WAT fixture + driver harness when the -// test is wired up in the commit that documents the accepted limit. +// wasm-GC reference types have no C-source surface, so the authoritative +// rejection coverage lives in crates/fork-instrument/tests/coverage_wat.rs. +// That hand-written WAT invokes the instrumenter directly and requires a +// precise, non-panicking unsupported-reference diagnostic. #include int main(void) { - printf("STUB: F-03 anyref accepted limit (WAT + driver pending)\n"); - return 1; // Intentional FAIL — test driver marks this it.fails. + printf("STUB: F-03 anyref accepted limit (covered by WAT)\n"); + return 1; // Intentional FAIL — runtime coverage skips this C-only stub. } diff --git a/scripts/build-programs.sh b/scripts/build-programs.sh index 0f493ef976..bc71100709 100755 --- a/scripts/build-programs.sh +++ b/scripts/build-programs.sh @@ -174,6 +174,8 @@ build_program() { local name arch="" name=$(basename "$src" .c) local wasm="$out_dir/${name}.wasm" + local raw_wasm="$out_dir/${name}.raw.wasm" + local next_wasm="$out_dir/${name}.next.wasm" case "$out_dir" in "$OUT_DIR_32") arch=wasm32 ;; @@ -210,6 +212,9 @@ build_program() { fi echo " Compiling $name..." + # WHY: a failed compile or instrumentation pass must not leave a raw or + # stale-ABI module at the resolver-visible final path. + rm -f "$wasm" "$raw_wasm" "$next_wasm" # Bash 3.2 (macOS system bash) under `set -u` treats expansion of # an empty array as unbound; the `${arr[@]+...}` guard suppresses # that when extra_libs is empty. @@ -217,15 +222,38 @@ build_program() { "${LINK_PRE_LIBS[@]}" \ ${extra_libs[@]+"${extra_libs[@]}"} \ "${LINK_POST_LIBS[@]}" \ - -o "$wasm" - - # Apply fork instrumentation if the program uses fork. The tool is a - # no-op for modules without `kernel.kernel_fork`, so it's safe to run - # unconditionally on every program. Programs without fork stay - # byte-identical except for a small ABI metadata section the tool - # always emits (see runtime::inject_runtime). - "$FORK_INSTRUMENT" "$wasm" -o "$wasm.instr" - mv "$wasm.instr" "$wasm" + -o "$raw_wasm" + + # Apply fork instrumentation if the program can participate in fork. The + # tool returns standalone executables without a fork or dynamic-loader + # boundary byte-for-byte unchanged, so it is safe to run unconditionally. + # Side modules and loader-capable mains still receive process-image state + # helpers even when they have no local fork import. + "$FORK_INSTRUMENT" "$raw_wasm" -o "$next_wasm" + mv "$next_wasm" "$wasm" + rm -f "$raw_wasm" +} + +cpp_requires_activation_state_rejection() { + case "$1" in + c_01_fork_in_try_no_throw|\ + c_02_fork_in_catch|\ + c_03_fork_in_multi_arm_catch|\ + c_04_fork_in_catch_external_throw|\ + c_05_fork_modern_eh_single|\ + c_06_fork_modern_eh_multi_ref|\ + c_07_fork_modern_eh_multi_plain|\ + c_10_fork_in_try_and_catch|\ + c_11_post_catch_fork|\ + k_06_fork_from_dtor|\ + s_08_external_throw_fork_in_catch|\ + sjlj_noexcept_boundary) + return 0 + ;; + *) + return 1 + ;; + esac } # Build a C++ program via the SDK's wasm32posix-c++ wrapper. The SDK @@ -240,8 +268,19 @@ build_cpp_program() { local name name=$(basename "$src" .cpp) local wasm="$out_dir/${name}.wasm" + local raw_wasm="$out_dir/${name}.raw.wasm" + local next_wasm="$out_dir/${name}.next.wasm" + local rejection_expected=false + + if cpp_requires_activation_state_rejection "$name"; then + rejection_expected=true + mkdir -p "$TEST_FIXTURE_DIR/wasm32/unsupported-abi43" + raw_wasm="$TEST_FIXTURE_DIR/wasm32/unsupported-abi43/${name}.raw.wasm" + next_wasm="$TEST_FIXTURE_DIR/wasm32/unsupported-abi43/${name}.unexpected.wasm" + fi echo " Compiling $name (C++)..." + rm -f "$wasm" "$raw_wasm" "$next_wasm" # -fwasm-exceptions is required for clang to lower C++ try/catch # to wasm-EH `try`/`catch` instructions. Without it clang emits # `__cxa_throw; unreachable` and DCEs the catch handlers, so the @@ -252,24 +291,49 @@ build_cpp_program() { -fwasm-exceptions \ "$src" \ -lc++ -lc++abi \ - -o "$wasm" + -o "$raw_wasm" - # Preserve a real pre-instrumentation control for issue #918. The source - # contains an unreachable-at-test-time fork branch solely so the normal - # output is transformed below. A raw module with kernel_fork but without - # wpk_fork_* exports is test evidence, not a distributable program, so it - # lives outside the resolver's programs tree. + # Preserve a launchable no-fork control for issue #918. The fork-bearing + # compiler output is retained under unsupported-abi43 and must fail the + # instrumenter rather than enter the resolver's programs tree. if [ "$name" = "sjlj_noexcept_boundary" ]; then mkdir -p "$TEST_FIXTURE_DIR/wasm32" - cp "$wasm" "$TEST_FIXTURE_DIR/wasm32/${name}.raw.wasm" + # Keep the unrelated SjLj/noexcept control launchable under ABI 43 by + # omitting its dormant fork anchor. The fork-bearing compiler output is + # retained separately above as explicit unsupported-artifact evidence. + wasm32posix-c++ \ + -O2 \ + -fwasm-exceptions \ + -DKANDELO_SJLJ_NO_FORK_ANCHOR \ + "$src" \ + -lc++ -lc++abi \ + -o "$TEST_FIXTURE_DIR/wasm32/${name}.raw.wasm" + fi + + if [ "$rejection_expected" = true ]; then + local diagnostic="$raw_wasm.instrument-error.txt" + rm -f "$diagnostic" + if "$FORK_INSTRUMENT" "$raw_wasm" -o "$next_wasm" 2>"$diagnostic"; then + echo "Error: $name unexpectedly became activation-state safe; update its ABI 43 coverage before publishing it." >&2 + rm -f "$next_wasm" + exit 1 + fi + if ! grep -Eq \ + 'reference local/parameter|uses CatchAll|uses CatchAllRef|reference-typed catch payload' \ + "$diagnostic"; then + echo "Error: $name failed instrumentation for an unexpected reason:" >&2 + cat "$diagnostic" >&2 + exit 1 + fi + echo " Expected ABI 43 rejection: $name (see $diagnostic)" + return 0 fi - # Phase 7: fork support comes from wasm-fork-instrument. The tool is - # a no-op for modules without `kernel.kernel_fork`, so it's safe to - # run unconditionally — programs without fork stay byte-identical - # except for the ABI metadata section. - "$FORK_INSTRUMENT" "$wasm" -o "$wasm.instr" - mv "$wasm.instr" "$wasm" + # Publish the resolver-visible path only after instrumentation and its + # complete ABI 43 artifact contract succeed. + "$FORK_INSTRUMENT" "$raw_wasm" -o "$next_wasm" + mv "$next_wasm" "$wasm" + rm -f "$raw_wasm" } ensure_libcxx_in_sysroot() { diff --git a/scripts/test-wasm-artifact-guards.sh b/scripts/test-wasm-artifact-guards.sh index 41194bc96a..146ff6543a 100755 --- a/scripts/test-wasm-artifact-guards.sh +++ b/scripts/test-wasm-artifact-guards.sh @@ -428,6 +428,7 @@ cat >"$work/complete-fork.wat" <<'WAT' (module (@custom "kandelo.wpk_fork.linked_frames" "KLCF\01\00\18\00\04\08\03\00\20\00\00\00\18\00\00\00\10\00\00\00") + (@custom "kandelo.wpk_fork.capabilities" "\01\04") (import "kernel" "kernel_fork" (func $kernel_fork)) (import "env" "__wpk_fork_frame_reserve" (func $frame_reserve (param i32) (result i32))) @@ -458,10 +459,73 @@ if wasm_has_missing_fork_instrumentation "$work/complete-fork.wasm"; then fi wasm_require_fork_instrumentation_if_needed "$work/complete-fork.wasm" +assert_rejects_fork_capability() { + local wat_path="$1" + local description="$2" + local wasm_path="${wat_path%.wat}.wasm" + local error_path="${wat_path%.wat}.error" + + wat2wasm --enable-annotations "$wat_path" -o "$wasm_path" + if wasm_has_complete_fork_instrumentation "$wasm_path"; then + echo "ERROR: complete-fork predicate accepted $description" >&2 + exit 1 + fi + if ! wasm_has_missing_fork_instrumentation "$wasm_path"; then + echo "ERROR: missing-fork predicate accepted $description" >&2 + exit 1 + fi + if wasm_require_fork_instrumentation_if_needed "$wasm_path" 2>"$error_path"; then + echo "ERROR: fork guard accepted $description" >&2 + exit 1 + fi + grep -F ' capability:' "$error_path" >/dev/null || { + echo "ERROR: fork guard did not identify the capability failure for $description" >&2 + cat "$error_path" >&2 + exit 1 + } +} + +sed '/kandelo\.wpk_fork\.capabilities/d' \ + "$work/complete-fork.wat" >"$work/missing-fork-capability.wat" +assert_rejects_fork_capability \ + "$work/missing-fork-capability.wat" \ + "an ABI 42-style artifact with no activation-state capability" + +sed 's/"\\01\\04"/"\\01\\00"/' \ + "$work/complete-fork.wat" >"$work/unsafe-fork-capability.wat" +assert_rejects_fork_capability \ + "$work/unsafe-fork-capability.wat" \ + "a capability that omits activation-state safety" + +sed 's/"\\01\\04"/"\\02\\04"/' \ + "$work/complete-fork.wat" >"$work/versioned-fork-capability.wat" +assert_rejects_fork_capability \ + "$work/versioned-fork-capability.wat" \ + "an unsupported capability version" + +sed 's/"\\01\\04"/"\\01\\84"/' \ + "$work/complete-fork.wat" >"$work/unknown-fork-capability.wat" +assert_rejects_fork_capability \ + "$work/unknown-fork-capability.wat" \ + "a capability with unknown flags" + +sed 's/"\\01\\04"/"\\01"/' \ + "$work/complete-fork.wat" >"$work/malformed-fork-capability.wat" +assert_rejects_fork_capability \ + "$work/malformed-fork-capability.wat" \ + "a malformed capability payload" + +sed '/kandelo\.wpk_fork\.capabilities/p' \ + "$work/complete-fork.wat" >"$work/duplicate-fork-capability.wat" +assert_rejects_fork_capability \ + "$work/duplicate-fork-capability.wat" \ + "duplicate capability sections" + cat >"$work/complete-fork-wasm64.wat" <<'WAT' (module (@custom "kandelo.wpk_fork.linked_frames" "KLCF\01\00\18\00\08\08\03\00\38\00\00\00\20\00\00\00\10\00\00\00") + (@custom "kandelo.wpk_fork.capabilities" "\01\04") (import "kernel" "kernel_fork" (func $kernel_fork)) (import "env" "__wpk_fork_frame_reserve" (func $frame_reserve (param i64) (result i64))) @@ -493,6 +557,7 @@ cat >"$work/partial-fork.wat" <<'WAT' (module (@custom "kandelo.wpk_fork.linked_frames" "KLCF\01\00\18\00\04\08\03\00\20\00\00\00\18\00\00\00\10\00\00\00") + (@custom "kandelo.wpk_fork.capabilities" "\01\04") (import "kernel" "kernel_fork" (func $kernel_fork)) (import "env" "__wpk_fork_frame_reserve" (func $frame_reserve (param i32) (result i32))) @@ -525,7 +590,8 @@ grep -Fqx ' missing: wpk_fork_state' "$partial_fork_error" || { # A section name is not sufficient evidence. Publication must reject a missing # payload, malformed layout fields, or a partially installed transaction hook. -sed '/(@custom/,+1d' "$work/complete-fork.wat" >"$work/missing-fork-descriptor.wat" +sed '/kandelo\.wpk_fork\.linked_frames/,+1d' \ + "$work/complete-fork.wat" >"$work/missing-fork-descriptor.wat" wat2wasm --enable-annotations "$work/missing-fork-descriptor.wat" \ -o "$work/missing-fork-descriptor.wasm" if wasm_require_fork_instrumentation_if_needed \ @@ -613,6 +679,7 @@ cat >"$work/inert-fork.wat" <<'WAT' (module (@custom "kandelo.wpk_fork.linked_frames" "KLCF\01\00\18\00\04\08\03\00\20\00\00\00\18\00\00\00\10\00\00\00") + (@custom "kandelo.wpk_fork.capabilities" "\01\04") (memory 1) (func (export "wpk_fork_abort_begin") (param i32)) (func (export "wpk_fork_abort_end")) @@ -660,9 +727,9 @@ count_file="$work/wasm-objdump.count" wasm_require_fork_instrumentation_if_needed "$work/complete-fork.wasm" ) [ "$(grep -c '^-x$' "$count_file")" = 1 ] && - [ "$(grep -c '^-s$' "$count_file")" = 1 ] && - [ "$(wc -l <"$count_file" | tr -d ' ')" = 2 ] || { - echo "ERROR: fork validation did not use one structure pass and one descriptor pass" >&2 + [ "$(grep -c '^-s$' "$count_file")" = 2 ] && + [ "$(wc -l <"$count_file" | tr -d ' ')" = 3 ] || { + echo "ERROR: fork validation did not use one structure and two metadata passes" >&2 cat "$count_file" >&2 exit 1 } diff --git a/scripts/wasm-artifact-guards.sh b/scripts/wasm-artifact-guards.sh index d4558d44d7..eeaf3ffa85 100644 --- a/scripts/wasm-artifact-guards.sh +++ b/scripts/wasm-artifact-guards.sh @@ -782,7 +782,8 @@ wasm_validate_side_module_imports() { # Output fields are, in order: # relocatable, imports a main or side-module fork entry, # frame reserve/commit/next imports, -# linked-frame descriptor count, abort begin/end, rewind begin/end, state, +# linked-frame descriptor and capability counts, abort begin/end, +# rewind begin/end, state, # unwind begin/end exports, module-memory count, memory64 count, and # signature mismatches against the module memory's pointer type. _wasm_fork_contract_inventory() { @@ -830,6 +831,7 @@ _wasm_fork_contract_inventory() { frame_next_signatures[frame_next] = function_signatures[function_index($0)] } /^ - name: "kandelo\.wpk_fork\.linked_frames"$/ { linked_descriptor++ } + /^ - name: "kandelo\.wpk_fork\.capabilities"$/ { fork_capability++ } /^ - memory\[[0-9]+\] pages:/ { memory_count++ if ($0 ~ / i64( |$)/) memory64_count++ @@ -888,10 +890,10 @@ _wasm_fork_contract_inventory() { for (i = 1; i <= unwind_end; i++) if (unwind_end_signatures[i] != nil_to_nil) signature_mismatch++ - printf "%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\n", + printf "%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\n", relocatable + 0, imports_fork + 0, frame_reserve + 0, frame_commit + 0, frame_next + 0, - linked_descriptor + 0, + linked_descriptor + 0, fork_capability + 0, abort_begin + 0, abort_end + 0, rewind_begin + 0, rewind_end + 0, state + 0, unwind_begin + 0, unwind_end + 0, @@ -900,6 +902,50 @@ _wasm_fork_contract_inventory() { ' wasm-objdump -x "$path" } +_wasm_fork_capability_hex() { + local path="${1:-}" + wasm_is_binary "$path" || return 2 + command -v wasm-objdump >/dev/null 2>&1 || return 2 + + _wasm_stream_awk ' + /^Contents of section Custom:$/ { + sections++ + next + } + sections > 0 && /^[0-9a-fA-F]+:/ { + line = $0 + sub(/^[^:]*:[[:space:]]*/, "", line) + sub(/[[:space:]][[:space:]].*$/, "", line) + gsub(/[[:space:]]/, "", line) + if (line !~ /^[0-9a-fA-F]+$/) exit 3 + hex = hex tolower(line) + } + END { + if (sections != 1 || hex == "") exit 1 + print hex + } + ' wasm-objdump -s -j kandelo.wpk_fork.capabilities "$path" +} + +wasm_has_activation_state_safe_capability() { + local path="${1:-}" + local section_hex capability_hex flags_hex flags + section_hex="$(_wasm_fork_capability_hex "$path")" || return $? + + # One-byte name length (29), UTF-8 section name, then [version, flags]. + local name_prefix="1d6b616e64656c6f2e77706b5f666f726b2e6361706162696c6974696573" + case "$section_hex" in + "$name_prefix"*) capability_hex="${section_hex#"$name_prefix"}" ;; + *) return 3 ;; + esac + [ "${#capability_hex}" -eq 4 ] || return 3 + [ "${capability_hex:0:2}" = "01" ] || return 3 + flags_hex="${capability_hex:2:2}" + flags=$((16#$flags_hex)) + [ $((flags & ~7)) -eq 0 ] || return 3 + [ $((flags & 4)) -eq 4 ] +} + _wasm_linked_frame_descriptor_hex() { local path="${1:-}" wasm_is_binary "$path" || return 2 @@ -1034,17 +1080,19 @@ wasm_require_exports() { wasm_has_complete_fork_instrumentation() { local path="${1:-}" local inventory inventory_status=0 - local relocatable imports_fork frame_reserve frame_commit frame_next linked_descriptor + local relocatable imports_fork frame_reserve frame_commit frame_next linked_descriptor fork_capability local abort_begin abort_end rewind_begin rewind_end state unwind_begin unwind_end local memory_count memory64_count signature_mismatch extra inventory="$(_wasm_fork_contract_inventory "$path")" || inventory_status=$? [ "$inventory_status" -eq 0 ] || return "$inventory_status" IFS=$'\t' read -r relocatable imports_fork frame_reserve frame_commit frame_next \ - linked_descriptor abort_begin abort_end rewind_begin rewind_end state \ + linked_descriptor fork_capability abort_begin abort_end rewind_begin rewind_end state \ unwind_begin unwind_end memory_count memory64_count signature_mismatch extra <<< "$inventory" [ -z "$extra" ] || return 2 [ "$frame_reserve$frame_commit$frame_next" = 111 ] || return 1 [ "$linked_descriptor" = 1 ] || return 1 + [ "$fork_capability" = 1 ] || return 1 + wasm_has_activation_state_safe_capability "$path" || return $? [ "$abort_begin$abort_end$rewind_begin$rewind_end$state$unwind_begin$unwind_end" = 1111111 ] || return 1 [ "$memory_count" = 1 ] && [ "$signature_mismatch" = 0 ] || return 1 @@ -1089,7 +1137,7 @@ wasm_memory_arch() { wasm_has_any_wpk_fork_export() { local path="${1:-}" local inventory inventory_status=0 - local relocatable imports_fork frame_reserve frame_commit frame_next linked_descriptor + local relocatable imports_fork frame_reserve frame_commit frame_next linked_descriptor fork_capability local abort_begin abort_end rewind_begin rewind_end state unwind_begin unwind_end local memory_count memory64_count signature_mismatch extra inventory="$(_wasm_fork_contract_inventory "$path")" || inventory_status=$? @@ -1099,7 +1147,7 @@ wasm_has_any_wpk_fork_export() { *) return 0 ;; # Decoder failure: classify as unsafe/present. esac IFS=$'\t' read -r relocatable imports_fork frame_reserve frame_commit frame_next \ - linked_descriptor abort_begin abort_end rewind_begin rewind_end state \ + linked_descriptor fork_capability abort_begin abort_end rewind_begin rewind_end state \ unwind_begin unwind_end memory_count memory64_count signature_mismatch extra <<< "$inventory" [ -z "$extra" ] || return 0 [ "$abort_begin$abort_end$rewind_begin$rewind_end$state$unwind_begin$unwind_end" != 0000000 ] @@ -1108,7 +1156,7 @@ wasm_has_any_wpk_fork_export() { wasm_has_any_fork_instrumentation() { local path="${1:-}" local inventory inventory_status=0 - local relocatable imports_fork frame_reserve frame_commit frame_next linked_descriptor + local relocatable imports_fork frame_reserve frame_commit frame_next linked_descriptor fork_capability local abort_begin abort_end rewind_begin rewind_end state unwind_begin unwind_end local memory_count memory64_count signature_mismatch extra inventory="$(_wasm_fork_contract_inventory "$path")" || inventory_status=$? @@ -1118,18 +1166,19 @@ wasm_has_any_fork_instrumentation() { *) return 0 ;; # Decoder failure: classify as unsafe/present. esac IFS=$'\t' read -r relocatable imports_fork frame_reserve frame_commit frame_next \ - linked_descriptor abort_begin abort_end rewind_begin rewind_end state \ + linked_descriptor fork_capability abort_begin abort_end rewind_begin rewind_end state \ unwind_begin unwind_end memory_count memory64_count signature_mismatch extra <<< "$inventory" [ -z "$extra" ] || return 0 [ "$frame_reserve$frame_commit$frame_next" != 000 ] || [ "$linked_descriptor" != 0 ] || + [ "$fork_capability" != 0 ] || [ "$abort_begin$abort_end$rewind_begin$rewind_end$state$unwind_begin$unwind_end" != 0000000 ] } wasm_has_missing_fork_instrumentation() { local path="${1:-}" local inventory inventory_status=0 - local relocatable imports_fork frame_reserve frame_commit frame_next linked_descriptor + local relocatable imports_fork frame_reserve frame_commit frame_next linked_descriptor fork_capability local abort_begin abort_end rewind_begin rewind_end state unwind_begin unwind_end local memory_count memory64_count signature_mismatch extra wasm_is_binary "$path" || return 1 @@ -1144,7 +1193,7 @@ wasm_has_missing_fork_instrumentation() { inventory="$(_wasm_fork_contract_inventory "$path")" || inventory_status=$? [ "$inventory_status" -eq 0 ] || return 0 # Decoder failure: unsafe. IFS=$'\t' read -r relocatable imports_fork frame_reserve frame_commit frame_next \ - linked_descriptor abort_begin abort_end rewind_begin rewind_end state \ + linked_descriptor fork_capability abort_begin abort_end rewind_begin rewind_end state \ unwind_begin unwind_end memory_count memory64_count signature_mismatch extra <<< "$inventory" [ -z "$extra" ] || return 0 [ "$relocatable" = 1 ] && return 1 @@ -1152,9 +1201,12 @@ wasm_has_missing_fork_instrumentation() { local frame_imports="$frame_reserve$frame_commit$frame_next" local exports="$abort_begin$abort_end$rewind_begin$rewind_end$state$unwind_begin$unwind_end" [ "$imports_fork" = 0 ] && [ "$frame_imports" = 000 ] && - [ "$linked_descriptor" = 0 ] && [ "$exports" = 0000000 ] && return 1 + [ "$linked_descriptor" = 0 ] && [ "$fork_capability" = 0 ] && + [ "$exports" = 0000000 ] && return 1 [ "$linked_descriptor" = 1 ] || return 0 + [ "$fork_capability" = 1 ] || return 0 + wasm_has_activation_state_safe_capability "$path" || return 0 local descriptor_pointer_width descriptor_pointer_width="$(wasm_linked_frame_descriptor_pointer_width "$path")" || return 0 [ "$exports" = 1111111 ] || return 0 @@ -1188,7 +1240,7 @@ wasm_require_fork_instrumentation_if_needed() { fi local inventory inventory_status=0 - local relocatable imports_fork frame_reserve frame_commit frame_next linked_descriptor + local relocatable imports_fork frame_reserve frame_commit frame_next linked_descriptor fork_capability local abort_begin abort_end rewind_begin rewind_end state unwind_begin unwind_end local memory_count memory64_count signature_mismatch extra inventory="$(_wasm_fork_contract_inventory "$path")" || inventory_status=$? @@ -1198,7 +1250,7 @@ wasm_require_fork_instrumentation_if_needed() { return 1 fi IFS=$'\t' read -r relocatable imports_fork frame_reserve frame_commit frame_next \ - linked_descriptor abort_begin abort_end rewind_begin rewind_end state \ + linked_descriptor fork_capability abort_begin abort_end rewind_begin rewind_end state \ unwind_begin unwind_end memory_count memory64_count signature_mismatch extra <<< "$inventory" if [ -n "$extra" ]; then echo "ERROR: unable to inspect fork instrumentation: $path" >&2 @@ -1210,7 +1262,8 @@ wasm_require_fork_instrumentation_if_needed() { local frame_imports="$frame_reserve$frame_commit$frame_next" local exports="$abort_begin$abort_end$rewind_begin$rewind_end$state$unwind_begin$unwind_end" [ "$imports_fork" = 0 ] && [ "$frame_imports" = 000 ] && - [ "$linked_descriptor" = 0 ] && [ "$exports" = 0000000 ] && return 0 + [ "$linked_descriptor" = 0 ] && [ "$fork_capability" = 0 ] && + [ "$exports" = 0000000 ] && return 0 local missing=() local duplicates=() @@ -1248,9 +1301,18 @@ wasm_require_fork_instrumentation_if_needed() { descriptor_error="kandelo.wpk_fork.linked_frames descriptor is malformed or unsupported" fi + local capability_error="" + if [ "$fork_capability" = 0 ]; then + capability_error="missing kandelo.wpk_fork.capabilities" + elif [ "$fork_capability" != 1 ]; then + capability_error="found $fork_capability kandelo.wpk_fork.capabilities sections; expected exactly one" + elif ! wasm_has_activation_state_safe_capability "$path"; then + capability_error="capability is malformed or omits activation-state safety" + fi + local memory_error="" if [ "$memory_count" != 1 ]; then - memory_error="ABI 42 fork instrumentation requires exactly one module memory; found $memory_count" + memory_error="ABI 43 fork instrumentation requires exactly one module memory; found $memory_count" elif [ -n "$descriptor_pointer_width" ]; then local memory_width_mismatch=0 if [ "$descriptor_pointer_width" = 8 ] && [ "$memory64_count" != 1 ]; then @@ -1272,18 +1334,20 @@ wasm_require_fork_instrumentation_if_needed() { local signature_error="" [ "$signature_mismatch" = 0 ] || - signature_error="$signature_mismatch ABI 42 fork import/export signatures do not match module memory" + signature_error="$signature_mismatch ABI 43 fork import/export signatures do not match module memory" if [ ${#missing[@]} -eq 0 ] && [ ${#duplicates[@]} -eq 0 ] && - [ -z "$descriptor_error" ] && [ -z "$memory_error" ] && + [ -z "$descriptor_error" ] && [ -z "$capability_error" ] && + [ -z "$memory_error" ] && [ -z "$signature_error" ]; then return 0 fi - echo "ERROR: refusing wasm artifact with incomplete ABI 42 fork instrumentation: $path" >&2 + echo "ERROR: refusing wasm artifact with incomplete ABI 43 fork instrumentation: $path" >&2 [ ${#missing[@]} -eq 0 ] || printf ' missing: %s\n' "${missing[*]}" >&2 [ ${#duplicates[@]} -eq 0 ] || printf ' duplicate: %s\n' "${duplicates[*]}" >&2 [ -z "$descriptor_error" ] || printf ' descriptor: %s\n' "$descriptor_error" >&2 + [ -z "$capability_error" ] || printf ' capability: %s\n' "$capability_error" >&2 [ -z "$memory_error" ] || printf ' memory: %s\n' "$memory_error" >&2 [ -z "$signature_error" ] || printf ' signatures: %s\n' "$signature_error" >&2 echo " Fork-capable binaries must be processed with scripts/run-wasm-fork-instrument.sh from the current ABI." >&2 @@ -1294,7 +1358,7 @@ wasm_require_no_fork_instrumentation() { local path="${1:-}" wasm_is_binary "$path" || return 0 local inventory inventory_status=0 - local relocatable imports_fork frame_reserve frame_commit frame_next linked_descriptor + local relocatable imports_fork frame_reserve frame_commit frame_next linked_descriptor fork_capability local abort_begin abort_end rewind_begin rewind_end state unwind_begin unwind_end local memory_count memory64_count signature_mismatch extra inventory="$(_wasm_fork_contract_inventory "$path")" || inventory_status=$? @@ -1303,7 +1367,7 @@ wasm_require_no_fork_instrumentation() { return 1 fi IFS=$'\t' read -r relocatable imports_fork frame_reserve frame_commit frame_next \ - linked_descriptor abort_begin abort_end rewind_begin rewind_end state \ + linked_descriptor fork_capability abort_begin abort_end rewind_begin rewind_end state \ unwind_begin unwind_end memory_count memory64_count signature_mismatch extra <<< "$inventory" if [ -n "$extra" ]; then echo "ERROR: unable to inspect fork instrumentation policy: $path" >&2 @@ -1311,6 +1375,7 @@ wasm_require_no_fork_instrumentation() { fi if [ "$frame_reserve$frame_commit$frame_next" != 000 ] || [ "$linked_descriptor" != 0 ] || + [ "$fork_capability" != 0 ] || [ "$abort_begin$abort_end$rewind_begin$rewind_end$state$unwind_begin$unwind_end" != 0000000 ]; then echo "ERROR: refusing wasm artifact with disabled fork instrumentation policy: $path" >&2 echo " Rebuild it without scripts/run-wasm-fork-instrument.sh." >&2 diff --git a/tools/xtask/src/build_deps.rs b/tools/xtask/src/build_deps.rs index d6a7c1a40f..5e709a3836 100644 --- a/tools/xtask/src/build_deps.rs +++ b/tools/xtask/src/build_deps.rs @@ -5049,6 +5049,7 @@ struct WasmArtifactFacts { function_imports: BTreeMap<(String, String), Vec>, function_exports: BTreeMap>, memory_pointer_widths: Vec, + fork_capabilities: Vec>, linked_frame_descriptors: Vec>, is_relocatable_object: bool, } @@ -5187,6 +5188,8 @@ fn wasm_artifact_facts(bytes: &[u8]) -> Result { } if name == wasm_posix_shared::abi::WPK_FORK_LINKED_FRAME_FORMAT_SECTION { facts.linked_frame_descriptors.push(c.data().to_vec()); + } else if name == wasm_posix_shared::abi::WPK_FORK_CAPABILITIES_SECTION { + facts.fork_capabilities.push(c.data().to_vec()); } } _ => {} @@ -5293,6 +5296,52 @@ fn validate_linked_frame_descriptor( Ok(LinkedFrameDescriptorFacts { pointer_width }) } +fn validate_fork_capabilities(sections: &[Vec]) -> Result<(), String> { + use wasm_posix_shared::abi; + + let [capability] = sections else { + return Err(match sections.len() { + 0 => format!( + "is missing required {} capability", + abi::WPK_FORK_CAPABILITIES_SECTION + ), + count => format!( + "has {count} {} sections, expected exactly one", + abi::WPK_FORK_CAPABILITIES_SECTION + ), + }); + }; + if capability.len() != 2 { + return Err(format!( + "{} has {} bytes, expected 2", + abi::WPK_FORK_CAPABILITIES_SECTION, + capability.len() + )); + } + if capability[0] != abi::WPK_FORK_CAPABILITIES_VERSION { + return Err(format!( + "{} version {} is unsupported", + abi::WPK_FORK_CAPABILITIES_SECTION, + capability[0] + )); + } + let flags = capability[1]; + if flags & !abi::WPK_FORK_CAP_KNOWN_MASK != 0 { + return Err(format!( + "{} has unknown flags 0x{flags:02x}", + abi::WPK_FORK_CAPABILITIES_SECTION + )); + } + if flags & abi::WPK_FORK_CAP_REQUIRED_FLAGS != abi::WPK_FORK_CAP_REQUIRED_FLAGS { + return Err(format!( + "{} flags 0x{flags:02x} omit required activation-state safety flags 0x{:02x}", + abi::WPK_FORK_CAPABILITIES_SECTION, + abi::WPK_FORK_CAP_REQUIRED_FLAGS + )); + } + Ok(()) +} + fn program_artifact_signature_matches( actual: &wasmparser::FuncType, params: &[wasm_posix_shared::abi::ProgramArtifactValueType], @@ -5406,13 +5455,16 @@ fn wasm_artifact_policy_failures_for( }) .count(); let descriptor_count = facts.linked_frame_descriptors.len(); - let has_fork_artifact_surface = - present_fork_exports > 0 || present_fork_imports > 0 || descriptor_count > 0; + let capability_count = facts.fork_capabilities.len(); + let has_fork_artifact_surface = present_fork_exports > 0 + || present_fork_imports > 0 + || descriptor_count > 0 + || capability_count > 0; if fork_instrumentation == ForkInstrumentationPolicy::Disabled { if has_fork_artifact_surface { failures.push( - "has ABI 42 wasm-fork-instrument metadata, imports, or exports but this output disables fork instrumentation".to_string(), + "has ABI 43 wasm-fork-instrument metadata, imports, or exports but this output disables fork instrumentation".to_string(), ); } return failures; @@ -5430,7 +5482,7 @@ fn wasm_artifact_policy_failures_for( .collect::>(); if !missing_exports.is_empty() { failures.push(format!( - "has incomplete ABI 42 wasm-fork-instrument exports; missing {}", + "has incomplete ABI 43 wasm-fork-instrument exports; missing {}", missing_exports.join(", ") )); } @@ -5441,12 +5493,16 @@ fn wasm_artifact_policy_failures_for( .is_some_and(|signatures| signatures.len() != 1) { failures.push(format!( - "has duplicate ABI 42 wasm-fork-instrument export {}", + "has duplicate ABI 43 wasm-fork-instrument export {}", requirement.name )); } } + if let Err(error) = validate_fork_capabilities(&facts.fork_capabilities) { + failures.push(error); + } + let descriptor = match facts.linked_frame_descriptors.as_slice() { [] => { failures.push(format!( @@ -5491,7 +5547,7 @@ fn wasm_artifact_policy_failures_for( .join(", "); if !missing_imports.is_empty() { failures.push(format!( - "has incomplete ABI 42 linked-frame imports; missing {missing_imports}" + "has incomplete ABI 43 linked-frame imports; missing {missing_imports}" )); } for requirement in fork_imports { @@ -5502,7 +5558,7 @@ fn wasm_artifact_policy_failures_for( .is_some_and(|signatures| signatures.len() != 1) { failures.push(format!( - "has duplicate ABI 42 linked-frame import {}.{}", + "has duplicate ABI 43 linked-frame import {}.{}", requirement.module, requirement.name )); } @@ -5519,12 +5575,12 @@ fn wasm_artifact_policy_failures_for( "a" }; failures.push(format!( - "ABI 42 linked-frame descriptor declares {article} {}-byte pointer but the module memory uses {}-byte addresses", + "ABI 43 linked-frame descriptor declares {article} {}-byte pointer but the module memory uses {}-byte addresses", descriptor.pointer_width, pointer_width )); } pointer_widths => failures.push(format!( - "ABI 42 fork instrumentation requires exactly one module memory, found {}", + "ABI 43 fork instrumentation requires exactly one module memory, found {}", pointer_widths.len() )), } @@ -5544,7 +5600,7 @@ fn wasm_artifact_policy_failures_for( descriptor.pointer_width, ) { failures.push(format!( - "ABI 42 wasm-fork-instrument export {} has the wrong signature; expected {}", + "ABI 43 wasm-fork-instrument export {} has the wrong signature; expected {}", requirement.name, program_artifact_signature_text( requirement.params, @@ -5568,7 +5624,7 @@ fn wasm_artifact_policy_failures_for( descriptor.pointer_width, ) { failures.push(format!( - "ABI 42 linked-frame import {}.{} has the wrong signature; expected {}", + "ABI 43 linked-frame import {}.{} has the wrong signature; expected {}", requirement.module, requirement.name, program_artifact_signature_text( @@ -5584,7 +5640,7 @@ fn wasm_artifact_policy_failures_for( if facts.imports_kernel_fork && failures.len() != contract_failure_start { failures.push( - "imports kernel.kernel_fork without the complete ABI 42 wasm-fork-instrument contract" + "imports kernel.kernel_fork without the complete ABI 43 wasm-fork-instrument contract" .to_string(), ); } @@ -11437,7 +11493,7 @@ wasm = "second.wasm" ty } - fn wasm_fork_artifact( + fn wasm_fork_artifact_with_capabilities( descriptor_pointer_width: u8, signature_pointer_width: u8, memory_pointer_width: u8, @@ -11445,6 +11501,7 @@ wasm = "second.wasm" frame_imports: &[&str], fork_exports: &[&str], descriptors: &[Vec], + capabilities: &[Vec], ) -> Vec { use wasm_posix_shared::abi; @@ -11454,6 +11511,12 @@ wasm = "second.wasm" other => panic!("unsupported fixture pointer width {other}"), }; let mut bytes = b"\0asm\x01\0\0\0".to_vec(); + for capability in capabilities { + bytes.extend(wasm_custom_section( + abi::WPK_FORK_CAPABILITIES_SECTION, + capability, + )); + } for descriptor in descriptors { bytes.extend(wasm_custom_section( abi::WPK_FORK_LINKED_FRAME_FORMAT_SECTION, @@ -11553,6 +11616,32 @@ wasm = "second.wasm" bytes } + fn wasm_fork_artifact( + descriptor_pointer_width: u8, + signature_pointer_width: u8, + memory_pointer_width: u8, + include_kernel_fork: bool, + frame_imports: &[&str], + fork_exports: &[&str], + descriptors: &[Vec], + ) -> Vec { + use wasm_posix_shared::abi; + + wasm_fork_artifact_with_capabilities( + descriptor_pointer_width, + signature_pointer_width, + memory_pointer_width, + include_kernel_fork, + frame_imports, + fork_exports, + descriptors, + &[vec![ + abi::WPK_FORK_CAPABILITIES_VERSION, + abi::WPK_FORK_CAP_ACTIVATION_STATE_SAFE, + ]], + ) + } + fn complete_wasm_fork_artifact(pointer_width: u8) -> Vec { let imports = wasm_posix_shared::abi::WPK_FORK_REQUIRED_IMPORTS .iter() @@ -18561,7 +18650,7 @@ wasm = "bad.wasm" } #[test] - fn program_artifact_policy_accepts_complete_abi42_fork_contracts() { + fn program_artifact_policy_accepts_complete_abi43_fork_contracts() { for pointer_width in [4, 8] { let bytes = complete_wasm_fork_artifact(pointer_width); let failures = wasm_artifact_policy_failures_for( @@ -18594,7 +18683,7 @@ wasm = "bad.wasm" } #[test] - fn program_artifact_policy_rejects_each_missing_abi42_fork_import() { + fn program_artifact_policy_rejects_each_missing_abi43_fork_import() { let all_imports = wasm_posix_shared::abi::WPK_FORK_REQUIRED_IMPORTS .iter() .map(|requirement| requirement.name) @@ -18628,7 +18717,7 @@ wasm = "bad.wasm" } #[test] - fn program_artifact_policy_rejects_each_missing_abi42_fork_export() { + fn program_artifact_policy_rejects_each_missing_abi43_fork_export() { let all_imports = wasm_posix_shared::abi::WPK_FORK_REQUIRED_IMPORTS .iter() .map(|requirement| requirement.name) @@ -18745,6 +18834,68 @@ wasm = "bad.wasm" } } + #[test] + fn program_artifact_policy_rejects_every_unsafe_activation_capability_shape() { + use wasm_posix_shared::abi; + + let imports = abi::WPK_FORK_REQUIRED_IMPORTS + .iter() + .map(|requirement| requirement.name) + .collect::>(); + let exports = abi::WPK_FORK_REQUIRED_EXPORTS + .iter() + .map(|requirement| requirement.name) + .collect::>(); + let safe = vec![ + abi::WPK_FORK_CAPABILITIES_VERSION, + abi::WPK_FORK_CAP_ACTIVATION_STATE_SAFE, + ]; + let cases: Vec<(&str, Vec>)> = vec![ + ("missing required", vec![]), + ("expected exactly one", vec![safe.clone(), safe]), + ( + "bytes, expected 2", + vec![vec![abi::WPK_FORK_CAPABILITIES_VERSION]], + ), + ( + "version", + vec![vec![ + abi::WPK_FORK_CAPABILITIES_VERSION + 1, + abi::WPK_FORK_CAP_ACTIVATION_STATE_SAFE, + ]], + ), + ( + "unknown flags", + vec![vec![ + abi::WPK_FORK_CAPABILITIES_VERSION, + abi::WPK_FORK_CAP_KNOWN_MASK | 0x80, + ]], + ), + ( + "omit required activation-state safety", + vec![vec![abi::WPK_FORK_CAPABILITIES_VERSION, 0]], + ), + ]; + + for (expected, capabilities) in cases { + let bytes = wasm_fork_artifact_with_capabilities( + 4, + 4, + 4, + true, + &imports, + &exports, + &[linked_frame_descriptor(4)], + &capabilities, + ); + let failures = wasm_artifact_policy_failures(&bytes, ForkInstrumentationPolicy::Auto); + assert!( + failures.iter().any(|failure| failure.contains(expected)), + "capability case {expected:?} was not reported: {failures:?}" + ); + } + } + #[test] fn program_artifact_policy_rejects_pointer_width_signature_drift() { let imports = wasm_posix_shared::abi::WPK_FORK_REQUIRED_IMPORTS diff --git a/tools/xtask/src/dump_abi.rs b/tools/xtask/src/dump_abi.rs index 3fcd7681d7..e513e70a9b 100644 --- a/tools/xtask/src/dump_abi.rs +++ b/tools/xtask/src/dump_abi.rs @@ -249,6 +249,34 @@ fn render_ts_module() -> String { )); } out.push_str("] as const;\n"); + out.push_str(&format!( + "export const WPK_FORK_CAPABILITIES_SECTION = {:?} as const;\n", + shared::abi::WPK_FORK_CAPABILITIES_SECTION + )); + out.push_str(&format!( + "export const WPK_FORK_CAPABILITIES_VERSION = {} as const;\n", + shared::abi::WPK_FORK_CAPABILITIES_VERSION + )); + out.push_str(&format!( + "export const WPK_FORK_CAP_SIDE_ENTRY = {} as const;\n", + shared::abi::WPK_FORK_CAP_SIDE_ENTRY + )); + out.push_str(&format!( + "export const WPK_FORK_CAP_DYLINK_MAIN = {} as const;\n", + shared::abi::WPK_FORK_CAP_DYLINK_MAIN + )); + out.push_str(&format!( + "export const WPK_FORK_CAP_ACTIVATION_STATE_SAFE = {} as const;\n", + shared::abi::WPK_FORK_CAP_ACTIVATION_STATE_SAFE + )); + out.push_str(&format!( + "export const WPK_FORK_CAP_KNOWN_MASK = {} as const;\n", + shared::abi::WPK_FORK_CAP_KNOWN_MASK + )); + out.push_str(&format!( + "export const WPK_FORK_CAP_REQUIRED_FLAGS = {} as const;\n", + shared::abi::WPK_FORK_CAP_REQUIRED_FLAGS + )); out.push_str("export const WPK_FORK_REQUIRED_IMPORTS = [\n"); for requirement in shared::abi::WPK_FORK_REQUIRED_IMPORTS { out.push_str(&format!( @@ -1874,6 +1902,7 @@ fn channel_status_codes() -> Value { fn custom_sections() -> Value { let mut sections = vec![ shared::abi::ABI_CUSTOM_SECTION, + shared::abi::WPK_FORK_CAPABILITIES_SECTION, shared::abi::WPK_FORK_LINKED_FRAME_FORMAT_SECTION, ]; sections.sort(); @@ -1888,13 +1917,15 @@ fn process_expected_globals() -> Value { fn program_artifact() -> Value { use shared::abi::{ - ProgramArtifactValueType, WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE, - WPK_FORK_LINKED_FRAME_FLAG_ABORT_UNWINDING, WPK_FORK_LINKED_FRAME_FLAG_TRANSACTIONAL_NODES, - WPK_FORK_LINKED_FRAME_FORMAT_MAGIC, WPK_FORK_LINKED_FRAME_FORMAT_SECTION, - WPK_FORK_LINKED_FRAME_FORMAT_VERSION, WPK_FORK_LINKED_FRAME_POINTER_WIDTHS, - WPK_FORK_LINKED_FRAME_RECORD_ALIGNMENT, WPK_FORK_LINKED_FRAME_REQUIRED_FLAGS, - WPK_FORK_REQUIRED_EXPORTS, WPK_FORK_REQUIRED_IMPORTS, wpk_fork_linked_chunk_header_size, - wpk_fork_linked_node_header_size, + ProgramArtifactValueType, WPK_FORK_CAP_ACTIVATION_STATE_SAFE, WPK_FORK_CAP_DYLINK_MAIN, + WPK_FORK_CAP_KNOWN_MASK, WPK_FORK_CAP_REQUIRED_FLAGS, WPK_FORK_CAP_SIDE_ENTRY, + WPK_FORK_CAPABILITIES_SECTION, WPK_FORK_CAPABILITIES_VERSION, + WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE, WPK_FORK_LINKED_FRAME_FLAG_ABORT_UNWINDING, + WPK_FORK_LINKED_FRAME_FLAG_TRANSACTIONAL_NODES, WPK_FORK_LINKED_FRAME_FORMAT_MAGIC, + WPK_FORK_LINKED_FRAME_FORMAT_SECTION, WPK_FORK_LINKED_FRAME_FORMAT_VERSION, + WPK_FORK_LINKED_FRAME_POINTER_WIDTHS, WPK_FORK_LINKED_FRAME_RECORD_ALIGNMENT, + WPK_FORK_LINKED_FRAME_REQUIRED_FLAGS, WPK_FORK_REQUIRED_EXPORTS, WPK_FORK_REQUIRED_IMPORTS, + wpk_fork_linked_chunk_header_size, wpk_fork_linked_node_header_size, }; let value_types = |values: &[ProgramArtifactValueType]| { @@ -1999,7 +2030,28 @@ fn program_artifact() -> Value { json!(WPK_FORK_LINKED_FRAME_FORMAT_VERSION), ); + let mut capabilities: JsonMap = BTreeMap::new(); + capabilities.insert("section".into(), json!(WPK_FORK_CAPABILITIES_SECTION)); + capabilities.insert("version".into(), json!(WPK_FORK_CAPABILITIES_VERSION)); + capabilities.insert("known_mask".into(), json!(WPK_FORK_CAP_KNOWN_MASK)); + capabilities.insert("required_flags".into(), json!(WPK_FORK_CAP_REQUIRED_FLAGS)); + capabilities.insert( + "flags".into(), + json!([ + {"bit": WPK_FORK_CAP_SIDE_ENTRY, "name": "side_entry"}, + {"bit": WPK_FORK_CAP_DYLINK_MAIN, "name": "dylink_main"}, + { + "bit": WPK_FORK_CAP_ACTIVATION_STATE_SAFE, + "name": "activation_state_safe" + } + ]), + ); + let mut fork: JsonMap = BTreeMap::new(); + fork.insert( + "capabilities".into(), + Value::Object(capabilities.into_iter().collect()), + ); fork.insert( "linked_frame_descriptor".into(), Value::Object(descriptor.into_iter().collect()), @@ -2533,7 +2585,7 @@ mod tests { } #[test] - fn program_artifact_snapshot_captures_complete_abi42_fork_contract() { + fn program_artifact_snapshot_captures_complete_abi43_fork_contract() { let artifact = program_artifact(); let fork = &artifact["fork_instrumentation"]; let descriptor = &fork["linked_frame_descriptor"]; @@ -2545,6 +2597,20 @@ mod tests { assert_eq!(descriptor["version"], json!(1)); assert_eq!(descriptor["descriptor_size"], json!(24)); assert_eq!(descriptor["required_flags"], json!(3)); + assert_eq!( + fork["capabilities"], + json!({ + "section": "kandelo.wpk_fork.capabilities", + "version": 1, + "known_mask": 7, + "required_flags": 4, + "flags": [ + {"bit": 1, "name": "side_entry"}, + {"bit": 2, "name": "dylink_main"}, + {"bit": 4, "name": "activation_state_safe"} + ] + }) + ); assert_eq!( descriptor["pointer_widths"], json!([ @@ -2581,11 +2647,16 @@ mod tests { assert_eq!( custom_sections(), - json!(["kandelo.wpk_fork.linked_frames", "wasm-posix-abi"]) + json!([ + "kandelo.wpk_fork.capabilities", + "kandelo.wpk_fork.linked_frames", + "wasm-posix-abi" + ]) ); let rendered = render_ts_module(); for expected in [ "export const WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE = 24 as const;", + "export const WPK_FORK_CAP_ACTIVATION_STATE_SAFE = 4 as const;", "name: \"__wpk_fork_frame_reserve\", params: [\"ptr\"], results: [\"ptr\"]", "name: \"wpk_fork_abort_end\", params: [], results: []", ] { From ae22e8938b392eb12c163d877f4bf208f92d2497 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sun, 26 Jul 2026 01:17:16 -0400 Subject: [PATCH 02/82] Host: Rebuild replay references in fresh workers Give Node and browser process Workers the same versioned reference graph, external-reference authority, imported state, table replicas, exceptions, and replay-gate owners. Recreate main and side-module Store-local identities before continuation rewind. Release temporary roots on completion, abort, exec, and teardown. Stage dynamic loading as host-only prepare/next operations followed by ordinary Wasm initializers. Serialize pthread loader ownership, replay exact module layouts, and defer signal delivery until libc regains the guest boundary. --- .../test/fixtures/static-root-gc.wat | 17 + .../test/fork-continuation.spec.ts | 97 +- .../test/wasm-gc-reference-transport.spec.ts | 100 + examples/wait_lifecycle_test.c | 93 +- host/src/browser-kernel-worker-entry.ts | 274 +- host/src/constants.ts | 1649 +++++- host/src/dylink-fork-archive.ts | 2152 ++++++++ host/src/dylink.ts | 3279 ++++++++++-- host/src/fork-activation-registry.ts | 1790 +++++++ host/src/fork-anyref-transit.ts | 135 + host/src/fork-continuation.ts | 56 +- host/src/fork-early-reference-provider.ts | 1604 ++++++ host/src/fork-exception-provider.ts | 509 ++ host/src/fork-externref-import-mailbox.ts | 1314 +++++ host/src/fork-externref-process-owner.ts | 234 + host/src/fork-function-catalog.ts | 120 + host/src/fork-gc-codec.ts | 905 ++++ host/src/fork-host-import-runtime.ts | 497 ++ host/src/fork-imported-globals.ts | 1151 +++++ host/src/fork-module-state.ts | 3662 ++++++++++++++ host/src/fork-process-continuation.ts | 873 ++++ host/src/fork-reference-broker.ts | 675 +++ host/src/fork-reference-recipes.ts | 1316 +++++ host/src/fork-reference-segments.ts | 2131 ++++++++ host/src/fork-reference-transaction.ts | 2054 ++++++++ host/src/fork-replay-events.ts | 738 +++ host/src/fork-replay-gate.ts | 222 + host/src/fork-resume-catalog.ts | 134 + host/src/fork-static-root-catalog.ts | 236 + host/src/fork-unwind-transport.ts | 47 + host/src/fork-worker-exception-capability.ts | 134 + host/src/fork-worker-import-exceptions.ts | 855 ++++ host/src/kernel-worker.ts | 17 + host/src/node-kernel-worker-entry.ts | 275 +- host/src/worker-main.ts | 4506 +++++++++++++---- host/src/worker-protocol.ts | 49 +- host/test/abi-version.test.ts | 93 +- host/test/audio-integration.test.ts | 46 +- host/test/catch-ref-fresh-worker.test.ts | 44 + host/test/centralized-test-helper.ts | 238 +- host/test/dlopen-host-imports.test.ts | 131 +- host/test/dri-cube-pyramid.test.ts | 111 +- host/test/dri-smoke.test.ts | 53 +- host/test/dylink-fork-archive.test.ts | 492 ++ host/test/dylink.test.ts | 1865 ++++++- .../fixtures/fork-externref-import-worker.ts | 68 + .../fork-worker-import-exception-worker.ts | 78 + .../gc-reference-state-fresh-worker-bytes.ts | 15 + .../gc-reference-state-fresh-worker.wat | 230 + host/test/fixtures/gc-transit-object.wat | 9 + .../reference-catch-payload-fresh-worker.wat | 223 + host/test/fork-abort-unwind.test.ts | 135 +- host/test/fork-activation-registry.test.ts | 433 ++ host/test/fork-anyref-transit.test.ts | 50 + host/test/fork-artifact-gc-types.test.ts | 88 + host/test/fork-continuation.test.ts | 3 + host/test/fork-dlopen-replay-e2e.test.ts | 212 +- .../fork-early-reference-provider.test.ts | 1011 ++++ host/test/fork-exception-provider.test.ts | 336 ++ host/test/fork-externref-host-parity.test.ts | 90 + .../fork-externref-import-mailbox.test.ts | 754 +++ .../test/fork-externref-process-owner.test.ts | 182 + .../fork-from-dlopen-side-module-e2e.test.ts | 244 +- host/test/fork-function-catalog.test.ts | 149 + host/test/fork-gc-codec.test.ts | 255 + host/test/fork-host-import-runtime.test.ts | 700 +++ host/test/fork-imported-globals.test.ts | 795 +++ host/test/fork-instrument-coverage.test.ts | 97 +- host/test/fork-instrument-runtime-harness.ts | 159 + host/test/fork-module-state.test.ts | 1345 +++++ host/test/fork-process-continuation.test.ts | 592 +++ host/test/fork-reference-broker.test.ts | 333 ++ host/test/fork-reference-recipes.test.ts | 719 +++ host/test/fork-reference-segments.test.ts | 359 ++ host/test/fork-reference-transaction.test.ts | 936 ++++ host/test/fork-replay-events.test.ts | 289 ++ host/test/fork-replay-gate.test.ts | 175 + host/test/fork-replay-host-parity.test.ts | 94 + host/test/fork-resume-catalog.test.ts | 194 + host/test/fork-save-buffer-overrun.test.ts | 61 +- host/test/fork-static-root-catalog.test.ts | 69 + host/test/fork-unwind-transport.test.ts | 62 + .../fork-worker-import-exceptions.test.ts | 454 ++ host/test/framebuffer-integration.test.ts | 63 +- .../gc-reference-state-fresh-worker.test.ts | 58 + host/test/generated-abi.test.ts | 200 +- host/test/mouse-integration.test.ts | 59 +- host/test/patch-wasm-for-thread-gc.test.ts | 104 + .../test/plain-catch-payload-lifetime.test.ts | 210 +- host/test/process-reference-owner-helper.ts | 130 + host/test/process-table-replication.test.ts | 192 + host/test/signal-accept-livelock.test.ts | 35 + host/test/sjlj-noexcept-boundary.test.ts | 36 +- host/test/wasm-binary-parse.test.ts | 1029 +++- libc/glue/channel_syscall.c | 48 +- libc/glue/dlopen.c | 77 +- .../src/thread/wasm32posix/clone.c | 10 +- programs/p_11_fork_continuation_enomem.c | 73 +- 98 files changed, 49026 insertions(+), 2269 deletions(-) create mode 100644 apps/browser-demos/test/fixtures/static-root-gc.wat create mode 100644 apps/browser-demos/test/wasm-gc-reference-transport.spec.ts create mode 100644 host/src/dylink-fork-archive.ts create mode 100644 host/src/fork-activation-registry.ts create mode 100644 host/src/fork-anyref-transit.ts create mode 100644 host/src/fork-early-reference-provider.ts create mode 100644 host/src/fork-exception-provider.ts create mode 100644 host/src/fork-externref-import-mailbox.ts create mode 100644 host/src/fork-externref-process-owner.ts create mode 100644 host/src/fork-function-catalog.ts create mode 100644 host/src/fork-gc-codec.ts create mode 100644 host/src/fork-host-import-runtime.ts create mode 100644 host/src/fork-imported-globals.ts create mode 100644 host/src/fork-module-state.ts create mode 100644 host/src/fork-process-continuation.ts create mode 100644 host/src/fork-reference-broker.ts create mode 100644 host/src/fork-reference-recipes.ts create mode 100644 host/src/fork-reference-segments.ts create mode 100644 host/src/fork-reference-transaction.ts create mode 100644 host/src/fork-replay-events.ts create mode 100644 host/src/fork-replay-gate.ts create mode 100644 host/src/fork-resume-catalog.ts create mode 100644 host/src/fork-static-root-catalog.ts create mode 100644 host/src/fork-unwind-transport.ts create mode 100644 host/src/fork-worker-exception-capability.ts create mode 100644 host/src/fork-worker-import-exceptions.ts create mode 100644 host/test/dylink-fork-archive.test.ts create mode 100644 host/test/fixtures/fork-externref-import-worker.ts create mode 100644 host/test/fixtures/fork-worker-import-exception-worker.ts create mode 100644 host/test/fixtures/gc-reference-state-fresh-worker-bytes.ts create mode 100644 host/test/fixtures/gc-reference-state-fresh-worker.wat create mode 100644 host/test/fixtures/gc-transit-object.wat create mode 100644 host/test/fixtures/reference-catch-payload-fresh-worker.wat create mode 100644 host/test/fork-activation-registry.test.ts create mode 100644 host/test/fork-anyref-transit.test.ts create mode 100644 host/test/fork-artifact-gc-types.test.ts create mode 100644 host/test/fork-early-reference-provider.test.ts create mode 100644 host/test/fork-exception-provider.test.ts create mode 100644 host/test/fork-externref-host-parity.test.ts create mode 100644 host/test/fork-externref-import-mailbox.test.ts create mode 100644 host/test/fork-externref-process-owner.test.ts create mode 100644 host/test/fork-function-catalog.test.ts create mode 100644 host/test/fork-gc-codec.test.ts create mode 100644 host/test/fork-host-import-runtime.test.ts create mode 100644 host/test/fork-imported-globals.test.ts create mode 100644 host/test/fork-instrument-runtime-harness.ts create mode 100644 host/test/fork-module-state.test.ts create mode 100644 host/test/fork-process-continuation.test.ts create mode 100644 host/test/fork-reference-broker.test.ts create mode 100644 host/test/fork-reference-recipes.test.ts create mode 100644 host/test/fork-reference-segments.test.ts create mode 100644 host/test/fork-reference-transaction.test.ts create mode 100644 host/test/fork-replay-events.test.ts create mode 100644 host/test/fork-replay-gate.test.ts create mode 100644 host/test/fork-replay-host-parity.test.ts create mode 100644 host/test/fork-resume-catalog.test.ts create mode 100644 host/test/fork-static-root-catalog.test.ts create mode 100644 host/test/fork-unwind-transport.test.ts create mode 100644 host/test/fork-worker-import-exceptions.test.ts create mode 100644 host/test/gc-reference-state-fresh-worker.test.ts create mode 100644 host/test/patch-wasm-for-thread-gc.test.ts create mode 100644 host/test/process-reference-owner-helper.ts create mode 100644 host/test/process-table-replication.test.ts diff --git a/apps/browser-demos/test/fixtures/static-root-gc.wat b/apps/browser-demos/test/fixtures/static-root-gc.wat new file mode 100644 index 0000000000..b90942a860 --- /dev/null +++ b/apps/browser-demos/test/fixtures/static-root-gc.wat @@ -0,0 +1,17 @@ +(module + (type $pair (struct (field i32))) + (global $root (ref $pair) + (struct.new $pair (i32.const 41))) + (table $catalog (export "catalog") 1 1 (ref null any)) + + (func (export "harvest") + i32.const 0 + global.get $root + table.set $catalog) + + (func (export "matches_root") + (param (ref null $pair)) + (result i32) + local.get 0 + global.get $root + ref.eq)) diff --git a/apps/browser-demos/test/fork-continuation.spec.ts b/apps/browser-demos/test/fork-continuation.spec.ts index 1c4f4facff..c3a323713a 100644 --- a/apps/browser-demos/test/fork-continuation.spec.ts +++ b/apps/browser-demos/test/fork-continuation.spec.ts @@ -1,9 +1,12 @@ import { expect, test, type Page } from "@playwright/test"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { resolveBinary } from "../../../host/src/binary-resolver"; +import { + RAW_GC_REFERENCE_STATE_FRESH_WORKER_HEX, +} from "../../../host/test/fixtures/gc-reference-state-fresh-worker-bytes"; const __dirname = dirname(fileURLToPath(import.meta.url)); const browserKernelModulePath = resolve( @@ -18,6 +21,10 @@ const catchRefFixtureSource = resolve( __dirname, "../../../host/test/fixtures/catch-ref-fresh-worker.wat", ); +const referenceCatchPayloadFixtureSource = resolve( + __dirname, + "../../../host/test/fixtures/reference-catch-payload-fresh-worker.wat", +); const forkInstrumenterPath = resolve( __dirname, "../../../tools/bin/wasm-fork-instrument", @@ -132,7 +139,7 @@ test("Chromium grows and replays a continuation beyond ABI 41's fixed reserve", "p_10_deep_linked_continuation", ); - expect(result.exitCode).toBe(0); + expect(result.exitCode, JSON.stringify(result, null, 2)).toBe(0); expect(result.stdout).toContain("PRE_DEEP_FORK"); expect(result.stdout).toContain("DEEP_CHILD: ok"); expect(result.stdout).toContain("DEEP_PARENT: child="); @@ -217,3 +224,89 @@ test("Chromium reconstructs CatchRef state in a fresh child worker", async ({ rmSync(workDir, { recursive: true, force: true }); } }); + +test("Chromium reconstructs reference-bearing catches in fresh child workers", async ({ + page, + baseURL, + browserName, +}) => { + test.skip(browserName !== "chromium", "the aggregate browser gate uses Chromium"); + test.setTimeout(180_000); + expect(baseURL).toBeTruthy(); + + const workDir = mkdtempSync( + resolve(__dirname, ".reference-catch-payload-fresh-worker-"), + ); + try { + const rawPath = resolve( + workDir, + "reference-catch-payload-fresh-worker.raw.wasm", + ); + const programPath = resolve( + workDir, + "reference-catch-payload-fresh-worker.wasm", + ); + execFileSync("wat2wasm", [ + "--enable-exceptions", + "--enable-threads", + referenceCatchPayloadFixtureSource, + "-o", + rawPath, + ]); + execFileSync(forkInstrumenterPath, [rawPath, "-o", programPath]); + + // One fresh child calls the reconstructed non-null funcref; a second + // verifies the nullable externref path. Either child exits nonzero if its + // caught exception recipe depended on the parent's module instance. + const result = await runBrowserFixture( + page, + baseURL!, + programPath, + "reference-catch-payload-fresh-worker", + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.diagnostics).toEqual([]); + } finally { + rmSync(workDir, { recursive: true, force: true }); + } +}); + +test("Chromium reconstructs aliased Wasm GC state in a fresh child worker", async ({ + page, + baseURL, + browserName, +}) => { + test.skip(browserName !== "chromium", "the aggregate browser gate uses Chromium"); + test.setTimeout(180_000); + expect(baseURL).toBeTruthy(); + + const workDir = mkdtempSync( + resolve(__dirname, ".gc-reference-state-fresh-worker-"), + ); + try { + const rawPath = resolve(workDir, "gc-reference-state.raw.wasm"); + const programPath = resolve(workDir, "gc-reference-state.wasm"); + writeFileSync( + rawPath, + Buffer.from(RAW_GC_REFERENCE_STATE_FRESH_WORKER_HEX, "hex"), + ); + execFileSync(forkInstrumenterPath, [rawPath, "-o", programPath]); + + // The child verifies one cyclic identity through a live parameter, + // operand-stack carryover, mutable reference global, and mutated typed + // table. Any fresh-instance alias break exits 91; its waiting parent then + // exits 92. + const result = await runBrowserFixture( + page, + baseURL!, + programPath, + "gc-reference-state-fresh-worker", + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.diagnostics).toEqual([]); + } finally { + rmSync(workDir, { recursive: true, force: true }); + } +}); diff --git a/apps/browser-demos/test/wasm-gc-reference-transport.spec.ts b/apps/browser-demos/test/wasm-gc-reference-transport.spec.ts new file mode 100644 index 0000000000..2f15f61429 --- /dev/null +++ b/apps/browser-demos/test/wasm-gc-reference-transport.spec.ts @@ -0,0 +1,100 @@ +import { expect, test } from "@playwright/test"; +import { + FORK_ANYREF_TRANSIT_IMPORT, + forkAnyrefTransitProviderBytes, +} from "../../../host/src/fork-anyref-transit"; + +// WHY: the dev shell's WABT release cannot parse typed Wasm GC references. +// This is the Rust `wat` crate's deterministic encoding of the adjacent +// fixtures/static-root-gc.wat source. +const FIXTURE_WASM_HEX = [ + "0061736d01000000010e035f017f0060000060016300017f03030201020405016e", + "010101060a016400004129fb00000b07240307636174616c6f6701000768617276", + "65737400000c6d6174636865735f726f6f7400010a120208004100230026000b07", + "0020002300d30b0023046e616d65040701000470616972050a010007636174616c", + "6f670707010004726f6f74", +].join(""); + +function fixtureBytes(): number[] { + return Array.from(Buffer.from(FIXTURE_WASM_HEX, "hex")); +} + +test("browser preserves GC identity through weak harvest and anyref transit", async ({ + page, + baseURL, +}) => { + const bytes = fixtureBytes(); + const providerBytes = Array.from(forkAnyrefTransitProviderBytes()); + await page.goto(new URL("/trap-signal-test.html", baseURL!).href); + const result = await page.evaluate(async ({ + moduleBytes, + transitProviderBytes, + transitExport, + }) => { + const module = await WebAssembly.compile(new Uint8Array(moduleBytes)); + const transitProviderModule = await WebAssembly.compile( + new Uint8Array(transitProviderBytes), + ); + const parent = await WebAssembly.instantiate(module); + const child = await WebAssembly.instantiate(module); + const transitProvider = await WebAssembly.instantiate(transitProviderModule); + const parentExports = parent.exports as { + catalog: WebAssembly.Table; + harvest: () => void; + matches_root: (value: unknown) => number; + }; + const childExports = child.exports as typeof parentExports; + const transit = transitProvider.exports[transitExport] as WebAssembly.Table; + const clearTransit = transitProvider.exports[ + `${transitExport}_clear` + ] as () => void; + + parentExports.harvest(); + childExports.harvest(); + const parentRoot = parentExports.catalog.get(0); + const childRoot = childExports.catalog.get(0); + const repeatedReadIsIdentical = + childExports.catalog.get(0) === childRoot; + const freshInstancesDiffer = parentRoot !== childRoot; + + // The host creates the ABI transit table from an audited provider module + // for this exact WebKit compatibility boundary. + transit.set(0, childRoot); + transit.grow(2); + transit.set(1, parentRoot); + transit.set(2, childRoot); + const transported = transit.get(0); + const jsTransitIsIdentical = transported === childRoot; + const wasmTransitIsIdentical = + childExports.matches_root(transported) === 1; + + parentExports.catalog.set(0, null); + childExports.catalog.set(0, null); + clearTransit(); + return { + repeatedReadIsIdentical, + freshInstancesDiffer, + jsTransitIsIdentical, + wasmTransitIsIdentical, + harvestCleared: + parentExports.catalog.get(0) === null + && childExports.catalog.get(0) === null + && Array.from( + { length: transit.length }, + (_, index) => transit.get(index), + ).every((value) => value === null), + }; + }, { + moduleBytes: bytes, + transitProviderBytes: providerBytes, + transitExport: FORK_ANYREF_TRANSIT_IMPORT, + }); + + expect(result).toEqual({ + repeatedReadIsIdentical: true, + freshInstancesDiffer: true, + jsTransitIsIdentical: true, + wasmTransitIsIdentical: true, + harvestCleared: true, + }); +}); diff --git a/examples/wait_lifecycle_test.c b/examples/wait_lifecycle_test.c index ba66130047..cf9fbdc204 100644 --- a/examples/wait_lifecycle_test.c +++ b/examples/wait_lifecycle_test.c @@ -493,6 +493,31 @@ static int test_getrusage_pointer_validation(void) return expect_zero_rusage(&usage); } +struct delayed_stop_ctx { + int fd; + atomic_int armed; + int error; +}; + +static void *release_delayed_stop(void *opaque) +{ + struct delayed_stop_ctx *ctx = opaque; + while (!atomic_load_explicit(&ctx->armed, memory_order_acquire)) + usleep(1000); + + /* + * The main thread sets armed immediately before entering waitpid. + * Leave enough time for it to publish the blocking wait to the kernel; + * otherwise a fast fresh child can stop and deliver SIGCHLD before the + * wait begins, in which case POSIX correctly permits that later wait to + * remain blocked. + */ + usleep(50000); + if (write(ctx->fd, "s", 1) != 1) + ctx->error = errno != 0 ? errno : EIO; + return NULL; +} + static int test_nonmatching_sigchld_interrupts_wait(void) { struct sigaction action; @@ -504,16 +529,76 @@ static int test_nonmatching_sigchld_interrupts_wait(void) sigchld_count = 0; int gate[2]; - pid_t pid = spawn_stopping_child(gate, 27); + if (pipe(gate) != 0) + return fail("interrupt test pipe"); + pid_t pid = fork(); if (pid < 0) - return -1; + return fail("interrupt test fork"); + if (pid == 0) { + close(gate[1]); + char byte = 0; + if (read(gate[0], &byte, 1) != 1) + _exit(121); + if (raise(SIGSTOP) != 0) + _exit(120); + if (read(gate[0], &byte, 1) != 1) + _exit(121); + close(gate[0]); + _exit(27); + } + close(gate[0]); + + /* + * SIGCHLD is process-directed. Block it while creating the helper so the + * helper inherits the blocked mask, then restore the main thread's mask. + * The child's stop notification therefore has exactly one eligible + * recipient: the thread blocked in waitpid below. + */ + sigset_t block; + sigset_t previous; + sigemptyset(&block); + sigaddset(&block, SIGCHLD); + int error = pthread_sigmask(SIG_BLOCK, &block, &previous); + if (error != 0) { + errno = error; + return fail("interrupt test block SIGCHLD"); + } + struct delayed_stop_ctx stop = { + .fd = gate[1], + .armed = ATOMIC_VAR_INIT(0), + .error = 0, + }; + pthread_t releaser; + error = pthread_create(&releaser, NULL, release_delayed_stop, &stop); + if (error != 0) { + pthread_sigmask(SIG_SETMASK, &previous, NULL); + errno = error; + return fail("interrupt test pthread_create"); + } + error = pthread_sigmask(SIG_SETMASK, &previous, NULL); + if (error != 0) { + errno = error; + return fail("interrupt test restore SIGCHLD mask"); + } int status = 0; errno = 0; - if (waitpid(pid, &status, 0) != -1 || errno != EINTR || sigchld_count != 1) { + atomic_store_explicit(&stop.armed, 1, memory_order_release); + pid_t got = waitpid(pid, &status, 0); + int wait_errno = errno; + error = pthread_join(releaser, NULL); + if (error != 0) { + errno = error; + return fail("interrupt test pthread_join"); + } + if (stop.error != 0) { + errno = stop.error; + return fail("interrupt test release stop"); + } + if (got != -1 || wait_errno != EINTR || sigchld_count != 1) { fprintf(stderr, "nonmatching stop SIGCHLD did not interrupt wait: errno=%d count=%d\n", - errno, (int)sigchld_count); + wait_errno, (int)sigchld_count); return -1; } diff --git a/host/src/browser-kernel-worker-entry.ts b/host/src/browser-kernel-worker-entry.ts index ee4535fce3..68ef9fca2e 100644 --- a/host/src/browser-kernel-worker-entry.ts +++ b/host/src/browser-kernel-worker-entry.ts @@ -65,7 +65,16 @@ import { } from "./worker-quiescence"; import { RootfsSnapshotGate } from "./rootfs-snapshot-gate"; import { reapHostOwnedExitedProcess } from "./host-owned-process-reap"; -import { uninitializedKernelPipeResult } from "./kernel-pipe-transport"; +import { + ForkReplayGateCoordinator, + observeForkReplayWorker, +} from "./fork-replay-gate"; +import { ForkExternrefProcessOwner } from "./fork-externref-process-owner"; +import type { ForkExternrefGeneration } from "./fork-reference-broker"; +import { + ForkHostImportOwnerRuntime, + type ForkHostImportOwnerWorker, +} from "./fork-host-import-runtime"; import type { CentralizedWorkerInitMessage, CentralizedThreadInitMessage, @@ -155,10 +164,17 @@ interface ProcessInfo extends ProcessGenerationOwnership { ptrWidth: 4 | 8; layout: ProcessMemoryLayout; threadAllocator: ThreadPageAllocator; + /** Exact broker authority for this PID's current Wasm image. */ + externrefGeneration: ForkExternrefGeneration; /** Non-_start continuation root inherited from a pthread fork until exec. */ forkReplayContext?: ForkReplayContext; } const processes = new Map(); +const externrefProcessOwner = new ForkExternrefProcessOwner(); +const forkHostImportOwnerRuntime = + new ForkHostImportOwnerRuntime(externrefProcessOwner); +const forkHostImportsByWorker = + new WeakMap(); const processTeardowns = new Map>(); const vmInterruptTimers = new VmInterruptTimerManager( (pid) => processes.get(pid), @@ -365,6 +381,7 @@ async function terminateTrackedWorker( settleMs = 0, ): Promise { intentionallyTerminated.add(worker as object); + forkHostImportsByWorker.get(worker as object)?.close(); const teardown = (async () => { await worker.terminate().catch(() => {}); if (settleMs > 0) await delay(settleMs); @@ -374,6 +391,29 @@ async function terminateTrackedWorker( await teardown; } +function bindForkHostImports( + worker: ReturnType, + owner: ForkHostImportOwnerWorker, +): void { + forkHostImportsByWorker.set(worker as object, owner); +} + +function dispatchForkHostImport( + worker: ReturnType, + message: Extract, +): void { + const owner = forkHostImportsByWorker.get(worker as object); + if (!owner || !owner.dispatch(message.wake)) { + reportHostDiagnostic({ + pid: message.wake.pid, + source: "fork host-import protocol", + message: + `[kernel-worker] ignored stale or unbound fork host-import wake ` + + `pid=${message.wake.pid} sender=${message.wake.senderId}`, + }, "warn"); + } +} + async function terminateThreadWorkers( pid: number, requireExecRetirement = false, @@ -1146,6 +1186,8 @@ async function handleSpawn(msg: Extract) let workerCreationAttempted = false; let createdWorker: ProcessInfo["worker"] | undefined; let createdGeneration: ProcessInfo | undefined; + let createdExternrefGeneration: ForkExternrefGeneration | undefined; + let createdForkHostImports: ForkHostImportOwnerWorker | undefined; try { releaseMutation = rootfsSnapshotGate.beginMutation("spawn a process"); await waitForProcessTeardowns(); @@ -1224,12 +1266,32 @@ async function handleSpawn(msg: Extract) } } + const externrefGeneration = externrefProcessOwner.startGeneration(pid); + createdExternrefGeneration = externrefGeneration; + let worker: ReturnType; + const forkHostImports = forkHostImportOwnerRuntime.createWorker({ + pid, + generationId: externrefGeneration.id, + authorizeSender: () => { + const current = processes.get(pid); + if ( + !current + || current.worker !== worker + || current.externrefGeneration !== externrefGeneration + ) { + throw new Error(`stale fork host-import sender for pid=${pid}`); + } + }, + }); + createdForkHostImports = forkHostImports; const initData: CentralizedWorkerInitMessage = { type: "centralized_init", pid, programBytes, memory, channelOffset, + externrefGenerationId: externrefGeneration.id, + forkHostImports: forkHostImports.init, env: launchEnv, argv: msg.argv, cwd: msg.cwd, @@ -1238,8 +1300,9 @@ async function handleSpawn(msg: Extract) }; workerCreationAttempted = true; - const worker = workerAdapter.createWorker(initData); + worker = workerAdapter.createWorker(initData); createdWorker = worker; + bindForkHostImports(worker, forkHostImports); createdGeneration = { generation: allocateProcessGeneration(), memory, @@ -1255,15 +1318,22 @@ async function handleSpawn(msg: Extract) ptrWidth, layout, threadAllocator, + externrefGeneration, }; processes.set(pid, createdGeneration); installProcessWorkerListeners(worker, pid); createdMemoryLease = undefined; createdPid = undefined; + createdExternrefGeneration = undefined; + createdForkHostImports = undefined; respond(msg.requestId, pid); } catch (e) { + createdForkHostImports?.close(); + if (createdExternrefGeneration) { + externrefProcessOwner.releaseGeneration(createdExternrefGeneration); + } if (createdPid !== undefined) { if (createdWorker) await terminateTrackedWorker(createdWorker); const lease = createdGeneration?.memoryLease ?? createdMemoryLease; @@ -1435,6 +1505,8 @@ function installProcessWorkerListeners( finalize(m.status ?? 0); } else if (m.type === "vm_interrupt_timer") { handleVmInterruptTimer(m, pid, process); + } else if (m.type === "fork_host_import") { + dispatchForkHostImport(worker, m); } }); } @@ -1470,6 +1542,11 @@ async function handleFork( let workerStartAttempted = false; let lifecycleTeardownStarted = false; let childGeneration: ProcessInfo | undefined; + let childExternrefGeneration: ForkExternrefGeneration | undefined; + let childForkHostImports: ForkHostImportOwnerWorker | undefined; + const forkReplay = new ForkReplayGateCoordinator( + `fork child pid=${childPid}`, + ); try { await waitForProcessTeardowns(); // Preserve fork's exact syscall-time snapshot before any await, then hold @@ -1515,6 +1592,34 @@ async function handleFork( : parentInfo.forkReplayContext ? { ...parentInfo.forkReplayContext, forkBufAddr: activeForkBufAddr } : undefined; + const forkBufAddr = activeForkBufAddr; + const externrefGrant = + externrefProcessOwner.forkGenerationFromContinuation( + parentInfo.externrefGeneration, + childPid, + parentMemory, + ptrWidth, + forkBufAddr, + ); + childExternrefGeneration = externrefGrant.generation; + let launchedWorker: DeferredWorkerHandle; + const forkHostImports = forkHostImportOwnerRuntime.createWorker({ + pid: childPid, + generationId: externrefGrant.generation.id, + authorizeSender: () => { + const current = processes.get(childPid); + if ( + !current + || current.worker !== launchedWorker + || current.externrefGeneration !== externrefGrant.generation + ) { + throw new Error( + `stale fork host-import sender for child pid=${childPid}`, + ); + } + }, + }); + childForkHostImports = forkHostImports; const childInitData: CentralizedWorkerInitMessage = { type: "centralized_init", pid: childPid, @@ -1522,8 +1627,11 @@ async function handleFork( programModule: parentInfo.programModule, memory: childMemory, channelOffset: childChannelOffset, + externrefGenerationId: externrefGrant.generation.id, + forkHostImports: forkHostImports.init, isForkChild: true, - forkBufAddr: activeForkBufAddr, + forkBufAddr, + forkReplayGate: forkReplay.gate, forkChildThreadFnPtr: forkReplayContext?.fnPtr, forkChildThreadArgPtr: forkReplayContext?.argPtr, ptrWidth, @@ -1534,6 +1642,8 @@ async function handleFork( () => workerAdapter.createWorker(childInitData), ); const worker = childWorker; + launchedWorker = worker; + bindForkHostImports(worker, forkHostImports); childGeneration = { generation: allocateProcessGeneration(), memory: childMemory, @@ -1551,9 +1661,16 @@ async function handleFork( layout: childLayout, threadAllocator: threadAllocatorForLayout(childLayout, ptrWidth, childPid), forkReplayContext, + externrefGeneration: externrefGrant.generation, }; processes.set(childPid, childGeneration); + observeForkReplayWorker( + forkReplay, + launchedWorker, + childPid, + () => processes.get(childPid)?.worker === launchedWorker, + ); installProcessWorkerListeners(worker, childPid); const startDisposition = kernelWorker.startProcessWorkerWhenRunnable( childPid, @@ -1562,13 +1679,25 @@ async function handleFork( workerStartAttempted = true; worker.start(); }, - () => { void worker.terminate(); }, + () => { + forkReplay.cancel( + new Error( + `Fork child ${childPid} launch was cancelled before replay readiness`, + ), + ); + forkHostImports.close(); + void launchedWorker.terminate(); + }, ); if (startDisposition === "stale") { throw new Error(`Fork child ${childPid} changed generation before Worker launch`); } if (startDisposition === "dead") { - await worker.terminate(); + forkReplay.cancel( + new Error(`Fork child ${childPid} exited before Worker launch`), + ); + forkHostImports.close(); + await terminateTrackedWorker(worker); processes.get(childPid)?.workerQuiescence.settle(); const signal = kernelWorker.finalizePendingChildTermination(childPid); lifecycleTeardownStarted = true; @@ -1580,9 +1709,27 @@ async function handleFork( ); return []; } + await forkReplay.waitUntilReady(); + if (processes.get(childPid)?.worker !== launchedWorker) { + throw new Error( + `Fork child ${childPid} changed generation before replay commit`, + ); + } + if (!kernelWorker.shouldLaunchPendingChild(childPid)) { + throw new Error(`Fork child ${childPid} exited before replay commit`); + } + // WHY: keep the child blocked inside its inherited fork import until the + // exact fresh Worker generation proves reconstruction completed. onFork + // resolves only after the shared gate is committed. + forkReplay.commit(); } catch (error) { if (lifecycleTeardownStarted) throw error; + forkReplay.cancel(error); + childForkHostImports?.close(); if (childWorker) await terminateTrackedWorker(childWorker); + if (childExternrefGeneration) { + externrefProcessOwner.releaseGeneration(childExternrefGeneration); + } const generation = childGeneration ?? { memory: childMemory, memoryLease: childMemoryLease, @@ -1684,6 +1831,8 @@ async function handleExec( return addressSpaceResult; } let replacementWorker: ReturnType | undefined; + let replacementExternrefGeneration: ForkExternrefGeneration | undefined; + let replacementForkHostImports: ForkHostImportOwnerWorker | undefined; try { const setupResult = kernelWorker.kernelExecSetup(pid, callerTid); if (setupResult < 0) { @@ -1717,6 +1866,9 @@ async function handleExec( if (!kernelWorker.prepareProcessForExec(pid, initiatingInfo.memory)) { throw new Error(`Exec pid ${pid} changed generation during commit`); } + replacementExternrefGeneration = externrefProcessOwner.replaceGeneration( + initiatingInfo.externrefGeneration, + ); const finalizeResult = kernelWorker.finalizeAddressSpaceForExec(pid); if (finalizeResult < 0) { @@ -1734,6 +1886,7 @@ async function handleExec( ]); if (initiatingInfo.worker) { intentionallyTerminated.add(initiatingInfo.worker as object); + forkHostImportsByWorker.get(initiatingInfo.worker as object)?.close(); await initiatingInfo.worker.terminate().catch(() => {}); } if (mainQuiescent) { @@ -1758,6 +1911,10 @@ async function handleExec( if (handoffExitSignal > 0) { prepared.memoryLease.release(); preparedLeaseConsumed = true; + externrefProcessOwner.releaseGeneration( + replacementExternrefGeneration, + ); + replacementExternrefGeneration = undefined; await awaitFinalizedProcessTeardown( pid, signalExitStatus(handoffExitSignal), @@ -1781,6 +1938,21 @@ async function handleExec( threadAllocator: newThreadAllocator, } = prepared; const newChannelOffset = newLayout.channelOffset; + replacementForkHostImports = forkHostImportOwnerRuntime.createWorker({ + pid, + generationId: replacementExternrefGeneration.id, + authorizeSender: () => { + const current = processes.get(pid); + if ( + !replacementWorker + || !current + || current.worker !== replacementWorker + || current.externrefGeneration !== replacementExternrefGeneration + ) { + throw new Error(`stale fork host-import sender for exec pid=${pid}`); + } + }, + }); const execInitData: CentralizedWorkerInitMessage = { type: "centralized_init", @@ -1789,6 +1961,8 @@ async function handleExec( programModule, memory: newMemory, channelOffset: newChannelOffset, + externrefGenerationId: replacementExternrefGeneration.id, + forkHostImports: replacementForkHostImports.init, argv: launchArgv, env: envp, ptrWidth, @@ -1810,6 +1984,7 @@ async function handleExec( env: envp, }); replacementRegistered = true; + bindForkHostImports(replacementWorker, replacementForkHostImports); // Clear cached thread module — the new program binary is different threadModuleCache.delete(pid); @@ -1830,6 +2005,7 @@ async function handleExec( ptrWidth, layout: newLayout, threadAllocator: newThreadAllocator, + externrefGeneration: replacementExternrefGeneration, }); preparedTransferred = true; @@ -1848,12 +2024,16 @@ async function handleExec( replacementStartAttempted = true; (replacementWorker as DeferredWorkerHandle).start(); }, - () => { void replacementWorker?.terminate(); }, + () => { + replacementForkHostImports?.close(); + void replacementWorker?.terminate(); + }, ); if (startDisposition === "stale") { throw new Error(`Exec pid ${pid} changed generation before Worker launch`); } if (startDisposition === "dead") { + replacementForkHostImports.close(); // startProcessWorkerWhenRunnable proved that the replacement Worker was // never started. Publish the equivalent ownership fence so the ordinary // exit teardown can retire listeners and release its lease safely. @@ -1871,6 +2051,11 @@ async function handleExec( kernelWorker.finishProcessExecHandoff(pid); return 0; } catch (err) { + replacementForkHostImports?.close(); + if (replacementExternrefGeneration) { + externrefProcessOwner.releaseGeneration(replacementExternrefGeneration); + replacementExternrefGeneration = undefined; + } if (initiatingInfo.worker) { intentionallyTerminated.add(initiatingInfo.worker as object); } @@ -2031,6 +2216,8 @@ async function handlePosixSpawn( let workerStartAttempted = false; let lifecycleTeardownStarted = false; let childGeneration: ProcessInfo | undefined; + let externrefGeneration: ForkExternrefGeneration | undefined; + let forkHostImports: ForkHostImportOwnerWorker | undefined; try { // Kernel already created the child via kernel_spawn_process. Treat every // subsequent host attachment as one rollback-capable transaction. @@ -2042,6 +2229,26 @@ async function handlePosixSpawn( }); registered = true; + externrefGeneration = externrefProcessOwner.startGeneration(childPid); + const processExternrefGeneration = externrefGeneration; + const processForkHostImports = forkHostImportOwnerRuntime.createWorker({ + pid: childPid, + generationId: processExternrefGeneration.id, + authorizeSender: () => { + const current = processes.get(childPid); + if ( + !newWorker + || !current + || current.worker !== newWorker + || current.externrefGeneration !== processExternrefGeneration + ) { + throw new Error( + `stale fork host-import sender for spawn pid=${childPid}`, + ); + } + }, + }); + forkHostImports = processForkHostImports; const initData: CentralizedWorkerInitMessage = { type: "centralized_init", pid: childPid, @@ -2049,6 +2256,8 @@ async function handlePosixSpawn( programModule, memory: newMemory, channelOffset: newChannelOffset, + externrefGenerationId: processExternrefGeneration.id, + forkHostImports: processForkHostImports.init, argv, env: envp, ptrWidth, @@ -2059,6 +2268,7 @@ async function handlePosixSpawn( () => workerAdapter.createWorker(initData), ); const worker = newWorker; + bindForkHostImports(worker, processForkHostImports); childGeneration = { generation: allocateProcessGeneration(), memory: newMemory, @@ -2075,6 +2285,7 @@ async function handlePosixSpawn( ptrWidth, layout: newLayout, threadAllocator, + externrefGeneration: processExternrefGeneration, }; processes.set(childPid, childGeneration); @@ -2086,13 +2297,17 @@ async function handlePosixSpawn( workerStartAttempted = true; worker.start(); }, - () => { void worker.terminate(); }, + () => { + processForkHostImports.close(); + void worker.terminate(); + }, ); if (startDisposition === "stale") { throw new Error(`Spawn child ${childPid} changed generation before Worker launch`); } if (startDisposition === "dead") { - await worker.terminate(); + processForkHostImports.close(); + await terminateTrackedWorker(worker); processes.get(childPid)?.workerQuiescence.settle(); const signal = kernelWorker.finalizePendingChildTermination(childPid); lifecycleTeardownStarted = true; @@ -2107,6 +2322,10 @@ async function handlePosixSpawn( } catch (error) { if (lifecycleTeardownStarted) throw error; if (newWorker) await terminateTrackedWorker(newWorker); + forkHostImports?.close(); + if (externrefGeneration) { + externrefProcessOwner.releaseGeneration(externrefGeneration); + } const generation = childGeneration ?? { memory: newMemory, memoryLease }; const detachResult = await detachExactProcessGeneration({ pid: childPid, @@ -2196,6 +2415,25 @@ async function handleClone( throw err; } + let threadWorker: DeferredWorkerHandle; + let threadEntry: ThreadWorkerInfo; + const forkHostImports = forkHostImportOwnerRuntime.createWorker({ + pid, + generationId: processInfo.externrefGeneration.id, + authorizeSender: () => { + const entries = threadWorkers.get(pid); + if ( + !belongsToCurrentProcessImage() + || !threadEntry + || threadEntry.worker !== threadWorker + || !entries?.includes(threadEntry) + ) { + throw new Error( + `stale fork host-import sender for pid=${pid} tid=${tid}`, + ); + } + }, + }); const threadInitData: CentralizedThreadInitMessage = { type: "centralized_thread_init", pid, @@ -2205,6 +2443,8 @@ async function handleClone( memory, processChannelOffset: processInfo.channelOffset, channelOffset: alloc.channelOffset, + externrefGenerationId: processInfo.externrefGeneration.id, + forkHostImports: forkHostImports.init, fnPtr, argPtr, stackPtr, @@ -2216,11 +2456,12 @@ async function handleClone( kernelAbiVersion: kernelWorker.getKernelAbiVersion(), }; - const threadWorker = new DeferredWorkerHandle( + threadWorker = new DeferredWorkerHandle( () => workerAdapter.createWorker(threadInitData), ); + bindForkHostImports(threadWorker, forkHostImports); if (!threadWorkers.has(pid)) threadWorkers.set(pid, []); - const threadEntry: ThreadWorkerInfo = { + threadEntry = { worker: threadWorker, channelOffset: alloc.channelOffset, tid, @@ -2322,6 +2563,8 @@ async function handleClone( } else if (m.type === "vm_interrupt_timer") { if (!isCurrentThreadGeneration() || m.pid !== pid) return; handleVmInterruptTimer(m, pid, processInfo); + } else if (m.type === "fork_host_import") { + dispatchForkHostImport(threadWorker, m); } }); threadWorker.on("error", (err: Error) => { @@ -2336,7 +2579,10 @@ async function handleClone( pid, memory, () => { threadWorker.start(); }, - () => { void threadWorker.terminate(); }, + () => { + forkHostImports.close(); + void threadWorker.terminate(); + }, () => { kernelWorker.finalizeThreadExit(pid, tid, alloc.channelOffset); const failedClone = kernelWorker.failDeferredCloneLaunch(pid, tid, 12); @@ -2459,6 +2705,8 @@ async function finishProcessExit( return; } + externrefProcessOwner.releaseGeneration(info.externrefGeneration); + if (!detachResult.mayReapPid) return; try { reapHostOwnedExitedProcess(kernelInstance, pid); @@ -2639,6 +2887,9 @@ async function handleTerminateProcess(msg: Extract= buf.length) throw new Error(`${context} is truncated`); + if ((buf[off++]! & 0x80) === 0) return off; + } + throw new Error(`${context} has an overlong LEB128 encoding`); +} + +/** + * Read one complete value/reference type. + * + * Concrete and exact references are multi-byte (`ref[ null] heaptype`), and + * recursive GC modules can use them in function, table, and global types. + * Treating every value type as one byte desynchronizes the artifact guard and + * can make a valid ABI function appear to have an arbitrary signature. + */ +function readWasmValueType( + buf: Uint8Array, + off: number, + context: string, +): ParsedWasmValueType { + if (off >= buf.length) throw new Error(`${context} is truncated`); + const code = buf[off++]!; + switch (code) { + case 0x7f: // i32 + case 0x7e: // i64 + case 0x7d: // f32 + case 0x7c: // f64 + case 0x7b: // v128 + case 0x75: // nocontref + case 0x74: // noexnref + case 0x73: // nofuncref + case 0x72: // noexternref + case 0x71: // nullref + case 0x70: // funcref + case 0x6f: // externref + case 0x6e: // anyref + case 0x6d: // eqref + case 0x6c: // i31ref + case 0x6b: // structref + case 0x6a: // arrayref + case 0x69: // exnref + case 0x68: // contref + return { code, shared: false, next: off }; + case 0x62: // exact heaptype + case 0x63: // ref null heaptype + case 0x64: { // ref heaptype + // Shared abstract heap types add a prefix before the signed heap type. + const shared = buf[off] === 0x65; + if (shared) off++; + const next = skipSignedLeb128(buf, off, 5, `${context} heap type`); + const [heapType] = readSLEB128_i64(buf, off); + return { + code, + heapType: Number(heapType), + shared, + next, + }; + } + default: + throw new Error( + `${context} has unknown value type 0x${code.toString(16)}`, + ); + } +} + +function readWasmStorageType( + buf: Uint8Array, + off: number, + context: string, +): ParsedWasmValueType { + if (buf[off] === 0x78 || buf[off] === 0x77) { + return { code: buf[off]!, shared: false, next: off + 1 }; + } + return readWasmValueType(buf, off, context); +} + function skipWasmBlockType(buf: Uint8Array, off: number): number { const first = buf[off]; if ( @@ -123,6 +278,163 @@ function skipWasmBlockType(buf: Uint8Array, off: number): number { return off + bytes; } +function readWasmFunctionType( + src: Uint8Array, + pos: number, + context: string, +): { signature: WasmFunctionSignature; next: number } { + const [paramCount, paramCountBytes] = readULEB128(src, pos); + pos += paramCountBytes; + const params: number[] = []; + const paramTypes: WasmValueType[] = []; + for (let index = 0; index < paramCount; index++) { + const value = readWasmValueType( + src, + pos, + `${context} parameter ${index}`, + ); + params.push(value.code); + paramTypes.push({ + code: value.code, + heapType: value.heapType, + shared: value.shared, + }); + pos = value.next; + } + const [resultCount, resultCountBytes] = readULEB128(src, pos); + pos += resultCountBytes; + const results: number[] = []; + const resultTypes: WasmValueType[] = []; + for (let index = 0; index < resultCount; index++) { + const value = readWasmValueType( + src, + pos, + `${context} result ${index}`, + ); + results.push(value.code); + resultTypes.push({ + code: value.code, + heapType: value.heapType, + shared: value.shared, + }); + pos = value.next; + } + return { + signature: { params, results, paramTypes, resultTypes }, + next: pos, + }; +} + +function readWasmFieldType( + src: Uint8Array, + pos: number, + context: string, +): number { + const storage = readWasmStorageType(src, pos, context); + pos = storage.next; + if (pos >= src.length) throw new Error(`${context} mutability is truncated`); + const mutability = src[pos++]!; + if (mutability !== 0 && mutability !== 1) { + throw new Error(`${context} has invalid mutability ${mutability}`); + } + return pos; +} + +function readWasmCompositeType( + src: Uint8Array, + opcode: number, + pos: number, + context: string, +): { signature?: WasmFunctionSignature; next: number } { + if (opcode === 0x65) { + if (pos >= src.length) throw new Error(`${context} shared type is truncated`); + opcode = src[pos++]!; + } + // Descriptor types may prefix the actual composite type. + for (const prefix of [0x4c, 0x4d]) { + if (opcode !== prefix) continue; + const [, indexBytes] = readULEB128(src, pos); + pos += indexBytes; + if (pos >= src.length) throw new Error(`${context} descriptor is truncated`); + opcode = src[pos++]!; + } + + if (opcode === 0x60) { + return readWasmFunctionType(src, pos, context); + } + if (opcode === 0x5f) { + const [fieldCount, fieldCountBytes] = readULEB128(src, pos); + pos += fieldCountBytes; + for (let index = 0; index < fieldCount; index++) { + pos = readWasmFieldType(src, pos, `${context} field ${index}`); + } + return { next: pos }; + } + if (opcode === 0x5e) { + return { + next: readWasmFieldType(src, pos, `${context} array field`), + }; + } + if (opcode === 0x5d) { + return { + next: skipSignedLeb128(src, pos, 5, `${context} continuation type`), + }; + } + throw new Error( + `${context} has unknown composite type 0x${opcode.toString(16)}`, + ); +} + +function readWasmSubtype( + src: Uint8Array, + pos: number, + context: string, +): { signature?: WasmFunctionSignature; next: number } { + if (pos >= src.length) throw new Error(`${context} is truncated`); + let opcode = src[pos++]!; + if (opcode === 0x4f || opcode === 0x50) { + const [supertypeCount, countBytes] = readULEB128(src, pos); + pos += countBytes; + for (let index = 0; index < supertypeCount; index++) { + const [, indexBytes] = readULEB128(src, pos); + pos += indexBytes; + } + if (pos >= src.length) throw new Error(`${context} body is truncated`); + opcode = src[pos++]!; + } + return readWasmCompositeType(src, opcode, pos, context); +} + +function readWasmTypeSection( + src: Uint8Array, + pos: number, +): { types: Array; next: number } { + const [groupCount, groupCountBytes] = readULEB128(src, pos); + pos += groupCountBytes; + const types: Array = []; + for (let groupIndex = 0; groupIndex < groupCount; groupIndex++) { + if (src[pos] === 0x4e) { + pos++; + const [typeCount, typeCountBytes] = readULEB128(src, pos); + pos += typeCountBytes; + for (let typeIndex = 0; typeIndex < typeCount; typeIndex++) { + const parsed = readWasmSubtype( + src, + pos, + `recursive type ${groupIndex}:${typeIndex}`, + ); + types.push(parsed.signature); + pos = parsed.next; + } + } else { + const parsed = readWasmSubtype(src, pos, `type ${groupIndex}`); + types.push(parsed.signature); + pos = parsed.next; + } + } + return { types, next: pos }; +} + function skipVectorMemarg(buf: Uint8Array, off: number): number { const [, alignBytes] = readULEB128(buf, off); off += alignBytes; @@ -237,19 +549,20 @@ function skipImportEntry( const [, n] = readULEB128(src, pos); pos += n; } else if (kind === 1) { // table: reftype + limits - pos++; // reftype - const f = src[pos++]; - const [, n] = readULEB128(src, pos); pos += n; - if (f & 1) { const [, n2] = readULEB128(src, pos); pos += n2; } + pos = readWasmValueType(src, pos, "table import type").next; + pos = readLimits(src, pos).next; } else if (kind === 2) { // memory: limits - const f = src[pos++]; - const [, n] = readULEB128(src, pos); pos += n; - if (f & 1) { const [, n2] = readULEB128(src, pos); pos += n2; } + pos = readLimits(src, pos).next; } else if (kind === 3) { // global: valtype + mutability counts.globalImports++; - pos += 2; + pos = readWasmValueType(src, pos, "global import type").next; + pos++; + } else if (kind === 4) { + // exception tag: attribute byte + function type index + pos++; + const [, n] = readULEB128(src, pos); pos += n; } return pos; } @@ -290,20 +603,137 @@ function containsAscii(src: Uint8Array, needle: string): boolean { */ export const WPK_FORK_EXPORTS = WPK_FORK_REQUIRED_EXPORTS.map(({ name }) => name); -interface WasmFunctionSignature { - params: number[]; - results: number[]; +export interface WasmFunctionSignature { + readonly params: readonly number[]; + readonly results: readonly number[]; + /** Complete binary value types, including concrete heap type and nullability. */ + readonly paramTypes: readonly WasmValueType[]; + /** Complete binary value types, including concrete heap type and nullability. */ + readonly resultTypes: readonly WasmValueType[]; +} + +export interface WasmFunctionImportType { + readonly module: string; + readonly name: string; + /** Ordinal among every import-section entry, regardless of import kind. */ + readonly importOrdinal: number; + /** Function index assigned by the core Wasm index space. */ + readonly functionIndex: number; + readonly signature: WasmFunctionSignature; +} + +interface WasmGlobalImportType { + module: string; + name: string; + importOrdinal: number; + index: number; + valueType: number; + recipeTypeCode: number | null; + mutable: boolean; + shared: boolean; +} + +interface WasmTableType { + elementType: number; + table64: boolean; + minimum: number; + maximum: number | null; +} + +interface WasmTableImportType extends WasmTableType { + module: string; + name: string; + importOrdinal: number; + index: number; + recipeTypeCode: number | null; +} + +interface WasmExportEntry { + kind: number; + index: number; } interface WasmForkArtifactFacts { functionImports: Map; + functionImportEntries: WasmFunctionImportType[]; + globalImports: Map; + tableImports: Map; + tables: WasmTableType[]; + tagImports: Map; functionExports: Map; + globalExports: Map; + tableExports: Map; + exports: Map; memoryPointerWidths: number[]; forkCapabilities: Uint8Array[]; linkedFrameDescriptors: Uint8Array[]; + exceptionCodecDescriptors: Uint8Array[]; + importedGlobalsDescriptors: Uint8Array[]; + importedTablesDescriptors: Uint8Array[]; + moduleStateDescriptors: Uint8Array[]; + staticRootDescriptors: Uint8Array[]; + unwindTransportDescriptors: Uint8Array[]; + nativeStartCount: number; importsKernelFork: boolean; } +function forkGlobalRecipeTypeCode( + valueType: ParsedWasmValueType, + functionTypes: readonly (WasmFunctionSignature | undefined)[], +): number | null { + switch (valueType.code) { + case 0x7f: + return WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I32; + case 0x7e: + return WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I64; + case 0x7d: + return WPK_FORK_MODULE_STATE_GLOBAL_TYPE_F32; + case 0x7c: + return WPK_FORK_MODULE_STATE_GLOBAL_TYPE_F64; + case 0x7b: + return WPK_FORK_MODULE_STATE_GLOBAL_TYPE_V128; + case 0x70: // funcref + case 0x73: // nofuncref + return WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF; + case 0x6f: // externref + case 0x72: // noexternref + return WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF; + case 0x69: // exnref + case 0x74: // noexnref + return WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF; + case 0x68: // contref + case 0x6a: // arrayref + case 0x6b: // structref + case 0x6c: // i31ref + case 0x6d: // eqref + case 0x6e: // anyref + case 0x71: // nullref + case 0x75: // nocontref + return WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF; + case 0x62: // exact heaptype + case 0x63: // ref null heaptype + case 0x64: { // ref heaptype + const heapType = valueType.heapType; + if (heapType === undefined) return null; + if (heapType === -16 || heapType === -13) { + return WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF; + } + if (heapType === -17 || heapType === -14) { + return WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF; + } + if (heapType === -23 || heapType === -12) { + return WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF; + } + if (heapType >= 0 && functionTypes[heapType] !== undefined) { + return WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF; + } + return WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF; + } + default: + return null; + } +} + function appendSignature( signatures: Map, identity: string, @@ -317,19 +747,36 @@ function appendSignature( signatures.set(identity, values); } +function appendImportType( + imports: Map, + identity: string, + value: T, +): void { + const values = imports.get(identity) ?? []; + values.push(value); + imports.set(identity, values); +} + function readLimits( src: Uint8Array, pos: number, -): { flags: number; next: number } { +): { + flags: number; + minimum: number; + maximum: number | null; + next: number; +} { const [flags, flagBytes] = readULEB128(src, pos); pos += flagBytes; - const [, minBytes] = readULEB128(src, pos); + const [minimum, minBytes] = readULEB128(src, pos); pos += minBytes; + let maximum: number | null = null; if ((flags & 1) !== 0) { - const [, maxBytes] = readULEB128(src, pos); + const [value, maxBytes] = readULEB128(src, pos); pos += maxBytes; + maximum = value; } - return { flags, next: pos }; + return { flags, minimum, maximum, next: pos }; } /** @@ -344,18 +791,35 @@ function readWasmForkArtifactFacts(programBytes: ArrayBuffer): WasmForkArtifactF const src = new Uint8Array(programBytes); if (!hasWasmMagic(src)) throw new Error("not a wasm binary"); - const functionTypes: WasmFunctionSignature[] = []; + const functionTypes: Array = []; const functionTypeIndices: number[] = []; const pendingFunctionExports: Array<{ name: string; index: number }> = []; const facts: WasmForkArtifactFacts = { functionImports: new Map(), + functionImportEntries: [], + globalImports: new Map(), + tableImports: new Map(), + tables: [], + tagImports: new Map(), functionExports: new Map(), + globalExports: new Map(), + tableExports: new Map(), + exports: new Map(), memoryPointerWidths: [], forkCapabilities: [], linkedFrameDescriptors: [], + exceptionCodecDescriptors: [], + importedGlobalsDescriptors: [], + importedTablesDescriptors: [], + moduleStateDescriptors: [], + staticRootDescriptors: [], + unwindTransportDescriptors: [], + nativeStartCount: 0, importsKernelFork: false, }; + let globalImportCount = 0; + let tableImportCount = 0; let offset = 8; while (offset < src.length) { const sectionId = src[offset]; @@ -372,25 +836,24 @@ function readWasmForkArtifactFacts(programBytes: ArrayBuffer): WasmForkArtifactF facts.linkedFrameDescriptors.push(src.slice(afterName, sectionEnd)); } else if (name === WPK_FORK_CAPABILITIES_SECTION) { facts.forkCapabilities.push(src.slice(afterName, sectionEnd)); + } else if (name === WPK_FORK_EXCEPTION_CODEC_SECTION) { + facts.exceptionCodecDescriptors.push(src.slice(afterName, sectionEnd)); + } else if (name === WPK_FORK_IMPORTED_GLOBALS_SECTION) { + facts.importedGlobalsDescriptors.push(src.slice(afterName, sectionEnd)); + } else if (name === WPK_FORK_IMPORTED_TABLES_SECTION) { + facts.importedTablesDescriptors.push(src.slice(afterName, sectionEnd)); + } else if (name === WPK_FORK_MODULE_STATE_FORMAT_SECTION) { + facts.moduleStateDescriptors.push(src.slice(afterName, sectionEnd)); + } else if (name === FORK_STATIC_ROOT_CATALOG_SECTION) { + facts.staticRootDescriptors.push(src.slice(afterName, sectionEnd)); + } else if (name === FORK_UNWIND_TRANSPORT_SECTION) { + facts.unwindTransportDescriptors.push(src.slice(afterName, sectionEnd)); } } else if (sectionId === 1) { requireFullyConsumed = true; - const [count, countBytes] = readULEB128(src, pos); - pos += countBytes; - for (let i = 0; i < count; i++) { - if (src[pos++] !== 0x60) { - throw new Error("unsupported non-function type in fork artifact"); - } - const [paramCount, paramCountBytes] = readULEB128(src, pos); - pos += paramCountBytes; - const params = [...src.slice(pos, pos + paramCount)]; - pos += paramCount; - const [resultCount, resultCountBytes] = readULEB128(src, pos); - pos += resultCountBytes; - const results = [...src.slice(pos, pos + resultCount)]; - pos += resultCount; - functionTypes.push({ params, results }); - } + const parsed = readWasmTypeSection(src, pos); + functionTypes.push(...parsed.types); + pos = parsed.next; } else if (sectionId === 2) { requireFullyConsumed = true; const [count, countBytes] = readULEB128(src, pos); @@ -403,23 +866,101 @@ function readWasmForkArtifactFacts(programBytes: ArrayBuffer): WasmForkArtifactF if (kind === 0) { const [typeIndex, typeBytes] = readULEB128(src, pos); pos += typeBytes; + const functionIndex = functionTypeIndices.length; functionTypeIndices.push(typeIndex); const identity = `${moduleName}.${fieldName}`; - appendSignature(facts.functionImports, identity, functionTypes[typeIndex]); + const signature = functionTypes[typeIndex]; + appendSignature(facts.functionImports, identity, signature); + facts.functionImportEntries.push({ + module: moduleName, + name: fieldName, + importOrdinal: i, + functionIndex, + signature: signature!, + }); if (identity === "kernel.kernel_fork") facts.importsKernelFork = true; } else if (kind === 1) { - pos++; // reference type - pos = readLimits(src, pos).next; + const element = readWasmValueType( + src, + pos, + `table import ${moduleName}.${fieldName}`, + ); + pos = element.next; + const limits = readLimits(src, pos); + pos = limits.next; + appendImportType( + facts.tableImports, + `${moduleName}.${fieldName}`, + { + module: moduleName, + name: fieldName, + importOrdinal: i, + index: tableImportCount++, + elementType: element.code, + recipeTypeCode: forkGlobalRecipeTypeCode( + element, + functionTypes, + ), + table64: (limits.flags & 4) !== 0, + minimum: limits.minimum, + maximum: limits.maximum, + }, + ); + facts.tables.push({ + elementType: element.code, + table64: (limits.flags & 4) !== 0, + minimum: limits.minimum, + maximum: limits.maximum, + }); } else if (kind === 2) { const limits = readLimits(src, pos); pos = limits.next; facts.memoryPointerWidths.push((limits.flags & 4) !== 0 ? 8 : 4); } else if (kind === 3) { - pos += 2; // value type + mutability + const valueType = readWasmValueType( + src, + pos, + `global import ${moduleName}.${fieldName}`, + ); + pos = valueType.next; + if (pos >= src.length) { + throw new Error(`global import ${moduleName}.${fieldName} is truncated`); + } + const flags = src[pos++]!; + if ((flags & ~0b11) !== 0) { + throw new Error( + `global import ${moduleName}.${fieldName} has invalid flags ${flags}`, + ); + } + appendImportType( + facts.globalImports, + `${moduleName}.${fieldName}`, + { + module: moduleName, + name: fieldName, + importOrdinal: i, + index: globalImportCount++, + valueType: valueType.code, + recipeTypeCode: forkGlobalRecipeTypeCode( + valueType, + functionTypes, + ), + mutable: (flags & 0b01) !== 0, + shared: (flags & 0b10) !== 0, + }, + ); } else if (kind === 4) { - pos++; // tag attribute - const [, typeBytes] = readULEB128(src, pos); + const attribute = src[pos++]; + if (attribute !== 0) { + throw new Error(`unsupported wasm tag attribute ${attribute}`); + } + const [typeIndex, typeBytes] = readULEB128(src, pos); pos += typeBytes; + appendSignature( + facts.tagImports, + `${moduleName}.${fieldName}`, + functionTypes[typeIndex], + ); } else { throw new Error(`unsupported wasm import kind ${kind}`); } @@ -433,6 +974,26 @@ function readWasmForkArtifactFacts(programBytes: ArrayBuffer): WasmForkArtifactF pos += typeBytes; functionTypeIndices.push(typeIndex); } + } else if (sectionId === 4) { + requireFullyConsumed = true; + const [count, countBytes] = readULEB128(src, pos); + pos += countBytes; + for (let index = 0; index < count; index++) { + const element = readWasmValueType( + src, + pos, + `defined table ${index}`, + ); + pos = element.next; + const limits = readLimits(src, pos); + pos = limits.next; + facts.tables.push({ + elementType: element.code, + table64: (limits.flags & 4) !== 0, + minimum: limits.minimum, + maximum: limits.maximum, + }); + } } else if (sectionId === 5) { requireFullyConsumed = true; const [count, countBytes] = readULEB128(src, pos); @@ -452,8 +1013,20 @@ function readWasmForkArtifactFacts(programBytes: ArrayBuffer): WasmForkArtifactF const kind = src[pos++]; const [index, indexBytes] = readULEB128(src, pos); pos += indexBytes; - if (kind === 0) pendingFunctionExports.push({ name, index }); + appendImportType(facts.exports, name, { kind, index }); + if (kind === 0) { + pendingFunctionExports.push({ name, index }); + } else if (kind === 3) { + appendImportType(facts.globalExports, name, index); + } else if (kind === 1) { + appendImportType(facts.tableExports, name, index); + } } + } else if (sectionId === 8) { + requireFullyConsumed = true; + facts.nativeStartCount++; + const [, functionIndexBytes] = readULEB128(src, pos); + pos += functionIndexBytes; } if (requireFullyConsumed && pos !== sectionEnd) { @@ -556,18 +1129,679 @@ function validateForkCapabilities(sections: Uint8Array[]): string[] { return []; } +function validateForkUnwindTransport(facts: WasmForkArtifactFacts): string[] { + const failures: string[] = []; + const identity = `${FORK_UNWIND_TAG_IMPORT_MODULE}.${FORK_UNWIND_TAG_IMPORT_NAME}`; + const tags = facts.tagImports.get(identity); + if (!tags) { + failures.push(`missing required private fork-unwind tag import ${identity}`); + } else if (tags.length !== 1) { + failures.push(`duplicate private fork-unwind tag import ${identity}`); + } else if (tags[0]!.params.length !== 0 || tags[0]!.results.length !== 0) { + failures.push(`private fork-unwind tag ${identity} must have an empty payload`); + } + + if (facts.unwindTransportDescriptors.length === 0) { + failures.push(`missing required ${FORK_UNWIND_TRANSPORT_SECTION} descriptor`); + } else if (facts.unwindTransportDescriptors.length !== 1) { + failures.push( + `has ${facts.unwindTransportDescriptors.length} ${FORK_UNWIND_TRANSPORT_SECTION} descriptors, expected exactly one`, + ); + } else { + const descriptor = facts.unwindTransportDescriptors[0]!; + if ( + descriptor.length !== 2 + || descriptor[0] !== FORK_UNWIND_TRANSPORT_VERSION + || descriptor[1] !== FORK_UNWIND_TRANSPORT_PAYLOAD_ARITY + ) { + failures.push( + `${FORK_UNWIND_TRANSPORT_SECTION} must be [${ + FORK_UNWIND_TRANSPORT_VERSION + }, ${FORK_UNWIND_TRANSPORT_PAYLOAD_ARITY}]`, + ); + } + } + return failures; +} + +function validateForkModuleStateDescriptor( + descriptors: readonly Uint8Array[], + expectedPointerWidth: number | null, +): string[] { + if (descriptors.length === 0) { + return [`missing required ${WPK_FORK_MODULE_STATE_FORMAT_SECTION} descriptor`]; + } + if (descriptors.length !== 1) { + return [ + `has ${descriptors.length} ${WPK_FORK_MODULE_STATE_FORMAT_SECTION} descriptors, expected exactly one`, + ]; + } + const bytes = descriptors[0]!; + if (bytes.byteLength !== WPK_FORK_MODULE_STATE_DESCRIPTOR_SIZE) { + return [ + `${WPK_FORK_MODULE_STATE_FORMAT_SECTION} has ${bytes.byteLength} bytes, expected ${WPK_FORK_MODULE_STATE_DESCRIPTOR_SIZE}`, + ]; + } + if (!WPK_FORK_MODULE_STATE_FORMAT_MAGIC.every((byte, index) => bytes[index] === byte)) { + return [`${WPK_FORK_MODULE_STATE_FORMAT_SECTION} has invalid magic`]; + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const version = view.getUint16(4, true); + const declaredSize = view.getUint16(6, true); + const pointerWidth = view.getUint8(8); + const pointerFormat = WPK_FORK_MODULE_STATE_POINTER_WIDTHS.find( + ({ bytes }) => bytes === pointerWidth, + ); + const alignment = view.getUint8(9); + const flags = view.getUint16(10, true); + const arenaVersion = view.getUint16(12, true); + const recordVersion = view.getUint16(14, true); + const rootWord = view.getUint32(16, true); + const reserved = view.getUint32(20, true); + const failures: string[] = []; + if (version !== WPK_FORK_MODULE_STATE_FORMAT_VERSION) { + failures.push(`${WPK_FORK_MODULE_STATE_FORMAT_SECTION} version ${version} is unsupported`); + } + if (declaredSize !== WPK_FORK_MODULE_STATE_DESCRIPTOR_SIZE) { + failures.push(`${WPK_FORK_MODULE_STATE_FORMAT_SECTION} declares size ${declaredSize}`); + } + if (!pointerFormat) { + failures.push(`${WPK_FORK_MODULE_STATE_FORMAT_SECTION} pointer width ${pointerWidth} is unsupported`); + } else if (expectedPointerWidth !== null && pointerWidth !== expectedPointerWidth) { + failures.push( + `${WPK_FORK_MODULE_STATE_FORMAT_SECTION} pointer width ${pointerWidth} does not match linked frames ${expectedPointerWidth}`, + ); + } + if (alignment !== WPK_FORK_MODULE_STATE_RECORD_ALIGNMENT) { + failures.push(`${WPK_FORK_MODULE_STATE_FORMAT_SECTION} alignment ${alignment} is unsupported`); + } + if (flags !== WPK_FORK_MODULE_STATE_REQUIRED_FLAGS) { + failures.push( + `${WPK_FORK_MODULE_STATE_FORMAT_SECTION} flags 0x${flags.toString(16)} do not equal required flags 0x${WPK_FORK_MODULE_STATE_REQUIRED_FLAGS.toString(16)}`, + ); + } + if (arenaVersion !== WPK_FORK_MODULE_STATE_ARENA_VERSION) { + failures.push(`${WPK_FORK_MODULE_STATE_FORMAT_SECTION} arena version ${arenaVersion} is unsupported`); + } + if (recordVersion !== WPK_FORK_MODULE_STATE_RECORD_VERSION) { + failures.push(`${WPK_FORK_MODULE_STATE_FORMAT_SECTION} record version ${recordVersion} is unsupported`); + } + if (rootWord !== WPK_FORK_MODULE_STATE_ROOT_POINTER_WORD_OFFSET) { + failures.push(`${WPK_FORK_MODULE_STATE_FORMAT_SECTION} root word ${rootWord} is unsupported`); + } + if (reserved !== 0) { + failures.push(`${WPK_FORK_MODULE_STATE_FORMAT_SECTION} reserved field is nonzero`); + } + return failures; +} + +function validateForkExceptionCodecDescriptor( + descriptors: readonly Uint8Array[], +): string[] { + if (descriptors.length === 0) { + return [`missing required ${WPK_FORK_EXCEPTION_CODEC_SECTION} descriptor`]; + } + if (descriptors.length !== 1) { + return [ + `has ${descriptors.length} ${WPK_FORK_EXCEPTION_CODEC_SECTION} descriptors, expected exactly one`, + ]; + } + const bytes = descriptors[0]!; + if (bytes.byteLength < WPK_FORK_EXCEPTION_CODEC_HEADER_SIZE) { + return [`${WPK_FORK_EXCEPTION_CODEC_SECTION} descriptor is truncated`]; + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const failures: string[] = []; + if (view.getUint8(0) !== WPK_FORK_EXCEPTION_CODEC_VERSION) { + failures.push( + `${WPK_FORK_EXCEPTION_CODEC_SECTION} version ${view.getUint8(0)} is unsupported`, + ); + } + if (view.getUint8(1) !== 0 || view.getUint16(2, true) !== 0) { + failures.push(`${WPK_FORK_EXCEPTION_CODEC_SECTION} reserved fields are nonzero`); + } + const count = view.getUint32(4, true); + const expectedSize = WPK_FORK_EXCEPTION_CODEC_HEADER_SIZE + + count * WPK_FORK_EXCEPTION_CODEC_TAG_RECORD_SIZE; + if (!Number.isSafeInteger(expectedSize) || bytes.byteLength !== expectedSize) { + failures.push( + `${WPK_FORK_EXCEPTION_CODEC_SECTION} has ${bytes.byteLength} bytes, expected ${expectedSize}`, + ); + return failures; + } + + const layouts = new Set(); + for (let index = 0; index < count; index++) { + const offset = WPK_FORK_EXCEPTION_CODEC_HEADER_SIZE + + index * WPK_FORK_EXCEPTION_CODEC_TAG_RECORD_SIZE; + const ordinal = view.getUint32(offset, true); + const layoutId = view.getUint32(offset + 4, true); + if (ordinal !== index) { + failures.push( + `${WPK_FORK_EXCEPTION_CODEC_SECTION} tag ordinal ${ordinal} is noncanonical at ${index}`, + ); + } + if (layoutId > 0x7fff_ffff || layouts.has(layoutId)) { + failures.push( + `${WPK_FORK_EXCEPTION_CODEC_SECTION} layout id ${layoutId} is invalid or duplicated`, + ); + } + layouts.add(layoutId); + // The remaining u32 fields are deliberately shape-neutral byte/reference + // counts. Any tag payload is legal when the instrumenter can emit its + // recursive reference recipe; the guard validates format, not user shape. + } + return failures; +} + +const FORK_IMPORTED_GLOBAL_TYPE_CODES = new Set([ + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I32, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I64, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_F32, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_F64, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_V128, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF, +]); + +interface ForkImportedGlobalRecord { + ownerId: number; + typeCode: number; + flags: number; + importOrdinal: number; + module: string; + name: string; +} + +function importedGlobalNeedsRecipe(global: WasmGlobalImportType): boolean { + return !( + global.module === WPK_FORK_EXCEPTION_CODEC_IMPORT_MODULE + && ( + global.name === WPK_FORK_EXCEPTION_IMPORT_ACTIVATION + || global.name === "__channel_base" + // This immutable control address is reconstructed by each host Worker + // from the ABI-defined process channel layout. It is not guest module + // state and must not be serialized as an imported-global recipe. + || global.name === "__wpk_fork_module_state_table_generation_addr" + ) + ); +} + +function validateForkImportedGlobalsDescriptor( + facts: WasmForkArtifactFacts, +): string[] { + const descriptors = facts.importedGlobalsDescriptors; + if (descriptors.length === 0) { + return [`missing required ${WPK_FORK_IMPORTED_GLOBALS_SECTION} descriptor`]; + } + if (descriptors.length !== 1) { + return [ + `has ${descriptors.length} ${WPK_FORK_IMPORTED_GLOBALS_SECTION} descriptors, expected exactly one`, + ]; + } + const bytes = descriptors[0]!; + if (bytes.byteLength < WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE) { + return [`${WPK_FORK_IMPORTED_GLOBALS_SECTION} descriptor is truncated`]; + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const failures: string[] = []; + if (!WPK_FORK_IMPORTED_GLOBALS_MAGIC.every((byte, index) => bytes[index] === byte)) { + failures.push(`${WPK_FORK_IMPORTED_GLOBALS_SECTION} has invalid magic`); + } + if (view.getUint16(4, true) !== WPK_FORK_IMPORTED_GLOBALS_VERSION) { + failures.push( + `${WPK_FORK_IMPORTED_GLOBALS_SECTION} version ${view.getUint16(4, true)} is unsupported`, + ); + } + if (view.getUint16(6, true) !== WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE) { + failures.push(`${WPK_FORK_IMPORTED_GLOBALS_SECTION} declares an invalid header size`); + } + const count = view.getUint32(8, true); + if (view.getUint32(12, true) !== 0) { + failures.push(`${WPK_FORK_IMPORTED_GLOBALS_SECTION} reserved field is nonzero`); + } + + const owners = new Set(); + const importOrdinals = new Set(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + const records: ForkImportedGlobalRecord[] = []; + let previousImportOrdinal = -1; + let offset = WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE; + for (let index = 0; index < count; index++) { + if (offset + WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE > bytes.byteLength) { + failures.push( + `${WPK_FORK_IMPORTED_GLOBALS_SECTION} record ${index} header is truncated`, + ); + return failures; + } + const recordSize = view.getUint32(offset, true); + const ownerId = view.getUint32(offset + 4, true); + const typeCode = view.getUint8(offset + 8); + const flags = view.getUint8(offset + 9); + const moduleLength = view.getUint32(offset + 12, true); + const nameLength = view.getUint32(offset + 16, true); + const importOrdinal = view.getUint32(offset + 20, true); + const expectedSize = WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE + + moduleLength + + nameLength; + if ( + !Number.isSafeInteger(expectedSize) + || recordSize !== expectedSize + || recordSize < WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE + || offset + recordSize > bytes.byteLength + ) { + failures.push( + `${WPK_FORK_IMPORTED_GLOBALS_SECTION} record ${index} has invalid bounds`, + ); + return failures; + } + if (ownerId === 0 || owners.has(ownerId)) { + failures.push( + `${WPK_FORK_IMPORTED_GLOBALS_SECTION} record ${index} has invalid or duplicated owner ${ownerId}`, + ); + } + owners.add(ownerId); + if (!FORK_IMPORTED_GLOBAL_TYPE_CODES.has(typeCode)) { + failures.push( + `${WPK_FORK_IMPORTED_GLOBALS_SECTION} record ${index} has unknown value type ${typeCode}`, + ); + } + if ((flags & ~WPK_FORK_IMPORTED_GLOBALS_KNOWN_FLAGS) !== 0) { + failures.push( + `${WPK_FORK_IMPORTED_GLOBALS_SECTION} record ${index} has unknown flags 0x${flags.toString(16)}`, + ); + } + if (view.getUint16(offset + 10, true) !== 0) { + failures.push( + `${WPK_FORK_IMPORTED_GLOBALS_SECTION} record ${index} reserved fields are nonzero`, + ); + } + if ( + importOrdinals.has(importOrdinal) + || importOrdinal <= previousImportOrdinal + ) { + failures.push( + `${WPK_FORK_IMPORTED_GLOBALS_SECTION} record ${index} has duplicated or unordered import ordinal`, + ); + } + importOrdinals.add(importOrdinal); + previousImportOrdinal = importOrdinal; + const namesOffset = offset + WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE; + try { + const module = decoder.decode( + bytes.subarray(namesOffset, namesOffset + moduleLength), + ); + const name = decoder.decode( + bytes.subarray( + namesOffset + moduleLength, + namesOffset + moduleLength + nameLength, + ), + ); + records.push({ + ownerId, + typeCode, + flags, + importOrdinal, + module, + name, + }); + } catch { + failures.push( + `${WPK_FORK_IMPORTED_GLOBALS_SECTION} record ${index} contains invalid UTF-8`, + ); + } + offset += recordSize; + } + if (offset !== bytes.byteLength) { + failures.push(`${WPK_FORK_IMPORTED_GLOBALS_SECTION} has trailing bytes`); + } + + const globalImports = [...facts.globalImports.values()].flat(); + const globalImportsByIndex = new Map( + globalImports.map((global) => [global.index, global]), + ); + const matchedImportIndices = new Set(); + for (const record of records) { + const catalogName = + `${WPK_FORK_GLOBAL_CATALOG_EXPORT_PREFIX}${record.ownerId}`; + const catalog = facts.exports.get(catalogName); + if (!catalog || catalog.length !== 1 || catalog[0]!.kind !== 3) { + failures.push( + `${WPK_FORK_IMPORTED_GLOBALS_SECTION} owner ${record.ownerId} lacks exactly one global catalog export ${catalogName}`, + ); + continue; + } + const imported = globalImportsByIndex.get(catalog[0]!.index); + if (!imported || !importedGlobalNeedsRecipe(imported)) { + failures.push( + `${WPK_FORK_IMPORTED_GLOBALS_SECTION} owner ${record.ownerId} does not identify a reconstructible imported global`, + ); + continue; + } + if ( + imported.module !== record.module + || imported.name !== record.name + || imported.importOrdinal !== record.importOrdinal + || imported.recipeTypeCode !== record.typeCode + || imported.mutable !== + ((record.flags & WPK_FORK_IMPORTED_GLOBALS_FLAG_MUTABLE) !== 0) + || imported.shared !== + ((record.flags & WPK_FORK_IMPORTED_GLOBALS_FLAG_SHARED) !== 0) + ) { + failures.push( + `${WPK_FORK_IMPORTED_GLOBALS_SECTION} owner ${record.ownerId} does not match its imported global declaration`, + ); + continue; + } + if (matchedImportIndices.has(imported.index)) { + failures.push( + `${WPK_FORK_IMPORTED_GLOBALS_SECTION} repeats imported global index ${imported.index}`, + ); + continue; + } + matchedImportIndices.add(imported.index); + } + + for (const imported of globalImports) { + if ( + importedGlobalNeedsRecipe(imported) + && !matchedImportIndices.has(imported.index) + ) { + failures.push( + `${WPK_FORK_IMPORTED_GLOBALS_SECTION} omits imported global ` + + `${imported.module}.${imported.name} at index ${imported.index}`, + ); + } + } + + for (const [name, exports] of facts.exports) { + if (!name.startsWith(WPK_FORK_GLOBAL_CATALOG_EXPORT_PREFIX)) continue; + const suffix = name.slice(WPK_FORK_GLOBAL_CATALOG_EXPORT_PREFIX.length); + const owner = Number(suffix); + if ( + !/^[1-9][0-9]*$/.test(suffix) + || !Number.isSafeInteger(owner) + || owner > 0xffff_ffff + || exports.length !== 1 + || exports[0]!.kind !== 3 + ) { + failures.push(`malformed reserved fork global catalog export ${name}`); + } + } + return failures; +} + +const FORK_IMPORTED_TABLE_TYPE_CODES = new Set([ + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF, +]); + +interface ForkImportedTableRecord { + ownerId: number; + typeCode: number; + flags: number; + importOrdinal: number; + module: string; + name: string; +} + +function importedTableNeedsRecipe(table: WasmTableImportType): boolean { + return !WPK_FORK_REQUIRED_TABLE_IMPORTS.some( + ({ module, name }) => table.module === module && table.name === name, + ); +} + +/** + * Validate the pre-instantiation table-identity recipe one declaration at a + * time. + * + * WHY: the same import-object property may feed several Wasm table imports, + * and an imported table may be shared by several module activations. Names + * alone cannot prove which declaration owns which catalog export; the full + * import ordinal and exact table index make that identity deterministic before + * any child continuation executes. + */ +function validateForkImportedTablesDescriptor( + facts: WasmForkArtifactFacts, +): string[] { + const descriptors = facts.importedTablesDescriptors; + if (descriptors.length === 0) { + return [`missing required ${WPK_FORK_IMPORTED_TABLES_SECTION} descriptor`]; + } + if (descriptors.length !== 1) { + return [ + `has ${descriptors.length} ${WPK_FORK_IMPORTED_TABLES_SECTION} descriptors, expected exactly one`, + ]; + } + const bytes = descriptors[0]!; + if (bytes.byteLength < WPK_FORK_IMPORTED_TABLES_HEADER_SIZE) { + return [`${WPK_FORK_IMPORTED_TABLES_SECTION} descriptor is truncated`]; + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const failures: string[] = []; + if (!WPK_FORK_IMPORTED_TABLES_MAGIC.every((byte, index) => bytes[index] === byte)) { + failures.push(`${WPK_FORK_IMPORTED_TABLES_SECTION} has invalid magic`); + } + if (view.getUint16(4, true) !== WPK_FORK_IMPORTED_TABLES_VERSION) { + failures.push( + `${WPK_FORK_IMPORTED_TABLES_SECTION} version ${view.getUint16(4, true)} is unsupported`, + ); + } + if (view.getUint16(6, true) !== WPK_FORK_IMPORTED_TABLES_HEADER_SIZE) { + failures.push(`${WPK_FORK_IMPORTED_TABLES_SECTION} declares an invalid header size`); + } + const count = view.getUint32(8, true); + if (view.getUint32(12, true) !== 0) { + failures.push(`${WPK_FORK_IMPORTED_TABLES_SECTION} reserved field is nonzero`); + } + + const owners = new Set(); + const importOrdinals = new Set(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + const records: ForkImportedTableRecord[] = []; + let previousImportOrdinal = -1; + let offset = WPK_FORK_IMPORTED_TABLES_HEADER_SIZE; + for (let index = 0; index < count; index++) { + if (offset + WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE > bytes.byteLength) { + failures.push( + `${WPK_FORK_IMPORTED_TABLES_SECTION} record ${index} header is truncated`, + ); + return failures; + } + const recordSize = view.getUint32(offset, true); + const ownerId = view.getUint32(offset + 4, true); + const typeCode = view.getUint8(offset + 8); + const flags = view.getUint8(offset + 9); + const moduleLength = view.getUint32(offset + 12, true); + const nameLength = view.getUint32(offset + 16, true); + const importOrdinal = view.getUint32(offset + 20, true); + const expectedSize = WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE + + moduleLength + + nameLength; + if ( + !Number.isSafeInteger(expectedSize) + || recordSize !== expectedSize + || recordSize < WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE + || offset + recordSize > bytes.byteLength + ) { + failures.push( + `${WPK_FORK_IMPORTED_TABLES_SECTION} record ${index} has invalid bounds`, + ); + return failures; + } + if (ownerId === 0 || owners.has(ownerId)) { + failures.push( + `${WPK_FORK_IMPORTED_TABLES_SECTION} record ${index} has invalid or duplicated owner ${ownerId}`, + ); + } + owners.add(ownerId); + if (!FORK_IMPORTED_TABLE_TYPE_CODES.has(typeCode)) { + failures.push( + `${WPK_FORK_IMPORTED_TABLES_SECTION} record ${index} has unknown element type ${typeCode}`, + ); + } + if ((flags & ~WPK_FORK_IMPORTED_TABLES_KNOWN_FLAGS) !== 0) { + failures.push( + `${WPK_FORK_IMPORTED_TABLES_SECTION} record ${index} has unknown flags 0x${flags.toString(16)}`, + ); + } + if (view.getUint16(offset + 10, true) !== 0) { + failures.push( + `${WPK_FORK_IMPORTED_TABLES_SECTION} record ${index} reserved fields are nonzero`, + ); + } + if ( + importOrdinals.has(importOrdinal) + || importOrdinal <= previousImportOrdinal + ) { + failures.push( + `${WPK_FORK_IMPORTED_TABLES_SECTION} record ${index} has duplicated or unordered import ordinal`, + ); + } + importOrdinals.add(importOrdinal); + previousImportOrdinal = importOrdinal; + const namesOffset = offset + WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE; + try { + const module = decoder.decode( + bytes.subarray(namesOffset, namesOffset + moduleLength), + ); + const name = decoder.decode( + bytes.subarray( + namesOffset + moduleLength, + namesOffset + moduleLength + nameLength, + ), + ); + records.push({ + ownerId, + typeCode, + flags, + importOrdinal, + module, + name, + }); + } catch { + failures.push( + `${WPK_FORK_IMPORTED_TABLES_SECTION} record ${index} contains invalid UTF-8`, + ); + } + offset += recordSize; + } + if (offset !== bytes.byteLength) { + failures.push(`${WPK_FORK_IMPORTED_TABLES_SECTION} has trailing bytes`); + } + + const tableImports = [...facts.tableImports.values()].flat(); + const tableImportsByIndex = new Map( + tableImports.map((table) => [table.index, table]), + ); + const matchedImportIndices = new Set(); + for (const record of records) { + const catalogName = + `${WPK_FORK_TABLE_CATALOG_EXPORT_PREFIX}${record.ownerId}`; + const catalog = facts.exports.get(catalogName); + if (!catalog || catalog.length !== 1 || catalog[0]!.kind !== 1) { + failures.push( + `${WPK_FORK_IMPORTED_TABLES_SECTION} owner ${record.ownerId} lacks exactly one table catalog export ${catalogName}`, + ); + continue; + } + const imported = tableImportsByIndex.get(catalog[0]!.index); + if (!imported || !importedTableNeedsRecipe(imported)) { + failures.push( + `${WPK_FORK_IMPORTED_TABLES_SECTION} owner ${record.ownerId} does not identify a reconstructible imported table`, + ); + continue; + } + if ( + imported.module !== record.module + || imported.name !== record.name + || imported.importOrdinal !== record.importOrdinal + || imported.recipeTypeCode !== record.typeCode + || imported.table64 !== + ((record.flags & WPK_FORK_IMPORTED_TABLES_FLAG_TABLE64) !== 0) + ) { + failures.push( + `${WPK_FORK_IMPORTED_TABLES_SECTION} owner ${record.ownerId} does not match its imported table declaration`, + ); + continue; + } + if (matchedImportIndices.has(imported.index)) { + failures.push( + `${WPK_FORK_IMPORTED_TABLES_SECTION} repeats imported table index ${imported.index}`, + ); + continue; + } + matchedImportIndices.add(imported.index); + } + + for (const imported of tableImports) { + if ( + importedTableNeedsRecipe(imported) + && !matchedImportIndices.has(imported.index) + ) { + failures.push( + `${WPK_FORK_IMPORTED_TABLES_SECTION} omits imported table ` + + `${imported.module}.${imported.name} at index ${imported.index}`, + ); + } + } + + for (const [name, exports] of facts.exports) { + if (!name.startsWith(WPK_FORK_TABLE_CATALOG_EXPORT_PREFIX)) continue; + const suffix = name.slice(WPK_FORK_TABLE_CATALOG_EXPORT_PREFIX.length); + const owner = Number(suffix); + if ( + !/^[1-9][0-9]*$/.test(suffix) + || !Number.isSafeInteger(owner) + || owner > 0xffff_ffff + || exports.length !== 1 + || exports[0]!.kind !== 1 + ) { + failures.push(`malformed reserved fork table catalog export ${name}`); + } + } + return failures; +} + +type ForkAbiValueType = + | "ptr" + | "i32" + | "i64" + | "anyref" + | "exnref" + | "externref" + | "funcref"; + function expectedWasmValueType( - value: "ptr" | "i32", + value: ForkAbiValueType, pointerWidth: number, ): number { - if (value === "i32") return 0x7f; - return pointerWidth === 8 ? 0x7e : 0x7f; + switch (value) { + case "ptr": + return pointerWidth === 8 ? 0x7e : 0x7f; + case "i32": + return 0x7f; + case "i64": + return 0x7e; + case "anyref": + return 0x6e; + case "exnref": + return 0x69; + case "externref": + return 0x6f; + case "funcref": + return 0x70; + } } function signatureMatches( actual: WasmFunctionSignature, - params: readonly ("ptr" | "i32")[], - results: readonly ("ptr" | "i32")[], + params: readonly ForkAbiValueType[], + results: readonly ForkAbiValueType[], pointerWidth: number, ): boolean { return actual.params.length === params.length && @@ -581,20 +1815,166 @@ function signatureMatches( } function signatureText( - params: readonly ("ptr" | "i32")[], - results: readonly ("ptr" | "i32")[], + params: readonly ForkAbiValueType[], + results: readonly ForkAbiValueType[], pointerWidth: number, ): string { - const render = (value: "ptr" | "i32") => - value === "ptr" ? (pointerWidth === 8 ? "i64" : "i32") : "i32"; + const render = (value: ForkAbiValueType) => + value === "ptr" ? (pointerWidth === 8 ? "i64" : "i32") : value; return `(${params.map(render).join(", ")}) -> (${results.map(render).join(", ")})`; } +function validateForkActivationImport(facts: WasmForkArtifactFacts): string[] { + const identity = + `${WPK_FORK_EXCEPTION_CODEC_IMPORT_MODULE}.${WPK_FORK_EXCEPTION_IMPORT_ACTIVATION}`; + const imports = facts.globalImports.get(identity); + if (!imports) { + return [`missing required immutable exception-codec activation import ${identity}`]; + } + if (imports.length !== 1) { + return [`duplicate exception-codec activation import ${identity}`]; + } + if (imports[0]!.valueType !== 0x7f || imports[0]!.mutable) { + return [`exception-codec activation import ${identity} must be immutable i32`]; + } + return []; +} + +function validateForkTableImports(facts: WasmForkArtifactFacts): string[] { + const failures: string[] = []; + for (const requirement of WPK_FORK_REQUIRED_TABLE_IMPORTS) { + const identity = `${requirement.module}.${requirement.name}`; + const imports = facts.tableImports.get(identity); + if (!imports) { + failures.push(`missing required ABI 43 fork-runtime table import ${identity}`); + continue; + } + if (imports.length !== 1) { + failures.push(`duplicate ABI 43 fork-runtime table import ${identity}`); + continue; + } + const actual = imports[0]!; + const expectedElement = expectedWasmValueType(requirement.element, 4); + if ( + actual.elementType !== expectedElement + || actual.table64 !== requirement.table64 + || actual.minimum !== requirement.minimum + || actual.maximum !== requirement.maximum + ) { + failures.push( + `ABI 43 fork-runtime table import ${identity} has the wrong type or limits`, + ); + } + } + return failures; +} + +function validateForkStaticRootCatalog( + facts: WasmForkArtifactFacts, +): string[] { + if (facts.staticRootDescriptors.length === 0) { + return [`missing required ${FORK_STATIC_ROOT_CATALOG_SECTION} descriptor`]; + } + if (facts.staticRootDescriptors.length !== 1) { + return [ + `has ${facts.staticRootDescriptors.length} ${FORK_STATIC_ROOT_CATALOG_SECTION} descriptors, expected exactly one`, + ]; + } + const descriptor = facts.staticRootDescriptors[0]!; + if (descriptor.byteLength !== FORK_STATIC_ROOT_CATALOG_HEADER_SIZE) { + return [ + `${FORK_STATIC_ROOT_CATALOG_SECTION} has ${descriptor.byteLength} bytes, expected ${FORK_STATIC_ROOT_CATALOG_HEADER_SIZE}`, + ]; + } + const failures: string[] = []; + if ( + FORK_STATIC_ROOT_CATALOG_MAGIC.some( + (byte, index) => descriptor[index] !== byte, + ) + ) { + failures.push(`${FORK_STATIC_ROOT_CATALOG_SECTION} has invalid magic`); + } + const view = new DataView( + descriptor.buffer, + descriptor.byteOffset, + descriptor.byteLength, + ); + if (view.getUint16(4, true) !== FORK_STATIC_ROOT_CATALOG_VERSION) { + failures.push( + `${FORK_STATIC_ROOT_CATALOG_SECTION} version ${view.getUint16(4, true)} is unsupported`, + ); + } + if (view.getUint16(6, true) !== FORK_STATIC_ROOT_CATALOG_HEADER_SIZE) { + failures.push( + `${FORK_STATIC_ROOT_CATALOG_SECTION} declares an invalid header size`, + ); + } + const count = view.getUint32(8, true); + const exports = facts.tableExports.get(FORK_STATIC_ROOT_CATALOG_EXPORT); + if (!exports || exports.length !== 1) { + failures.push( + `missing exactly one table export ${FORK_STATIC_ROOT_CATALOG_EXPORT}`, + ); + return failures; + } + const importedTableCount = [...facts.tableImports.values()] + .reduce((total, entries) => total + entries.length, 0); + const tableIndex = exports[0]!; + const table = facts.tables[tableIndex]; + if (tableIndex < importedTableCount || !table) { + failures.push( + `${FORK_STATIC_ROOT_CATALOG_EXPORT} must export a module-local table`, + ); + return failures; + } + if ( + table.elementType !== 0x6e + || table.table64 + || table.minimum !== count + || table.maximum !== count + ) { + failures.push( + `${FORK_STATIC_ROOT_CATALOG_EXPORT} must be a fixed table32 anyref catalog of length ${count}`, + ); + } + return failures; +} + function describeForkArtifactContractFailures( facts: WasmForkArtifactFacts, ): string[] { const failures: string[] = []; + if (facts.nativeStartCount !== 0) { + // WHY: staged dlopen may instantiate this module while a loader import is + // active. The transform must defer the source start function to the + // explicit bootstrap so guest Wasm cannot reenter that import. + failures.push( + `ABI 43 fork artifact retains ${facts.nativeStartCount} native Wasm start ` + + `section${facts.nativeStartCount === 1 ? "" : "s"}; rebuild and ` + + "reinstrument it so initialization is owned by " + + "wpk_fork_module_bootstrap", + ); + } + if (facts.functionImports.has("env.__wasm_dlopen")) { + // WHY: this host import can synchronously enter side-module Wasm before + // returning. ABI 43 instrumentation lowers every valid occurrence to the + // staged prepare/next/commit protocol, so retaining it proves that the + // activation-state capability was copied or emitted by an incomplete + // transform. + failures.push( + "ABI 43 fork artifact retains reentrant env.__wasm_dlopen; " + + "rebuild and reinstrument it with the staged loader lowering", + ); + } failures.push(...validateForkCapabilities(facts.forkCapabilities)); + failures.push( + ...validateForkExceptionCodecDescriptor(facts.exceptionCodecDescriptors), + ...validateForkImportedGlobalsDescriptor(facts), + ...validateForkImportedTablesDescriptor(facts), + ...validateForkActivationImport(facts), + ...validateForkTableImports(facts), + ...validateForkStaticRootCatalog(facts), + ); for (const requirement of WPK_FORK_REQUIRED_EXPORTS) { const signatures = facts.functionExports.get(requirement.name); if (!signatures) continue; @@ -625,25 +2005,40 @@ function describeForkArtifactContractFailures( failures.push(error instanceof Error ? error.message : String(error)); } } + failures.push( + ...validateForkModuleStateDescriptor( + facts.moduleStateDescriptors, + pointerWidth, + ), + ); const presentFrameImports = WPK_FORK_REQUIRED_IMPORTS.filter(({ module, name }) => facts.functionImports.has(`${module}.${name}`) ); + const unwindTagIdentity = + `${FORK_UNWIND_TAG_IMPORT_MODULE}.${FORK_UNWIND_TAG_IMPORT_NAME}`; const requiresFrameImports = facts.importsKernelFork || presentFrameImports.length > 0; + const requiresUnwindTransport = + requiresFrameImports + || facts.tagImports.has(unwindTagIdentity) + || facts.unwindTransportDescriptors.length > 0; + if (requiresUnwindTransport) { + failures.push(...validateForkUnwindTransport(facts)); + } if (requiresFrameImports) { const missingImports = WPK_FORK_REQUIRED_IMPORTS .filter(({ module, name }) => !facts.functionImports.has(`${module}.${name}`)) .map(({ module, name }) => `${module}.${name}`); if (missingImports.length > 0) { failures.push( - `incomplete ABI 43 linked-frame imports; missing ${missingImports.join(", ")}`, + `incomplete ABI 43 fork-runtime imports; missing ${missingImports.join(", ")}`, ); } for (const requirement of WPK_FORK_REQUIRED_IMPORTS) { const identity = `${requirement.module}.${requirement.name}`; const signatures = facts.functionImports.get(identity); if (signatures && signatures.length !== 1) { - failures.push(`duplicate ABI 43 linked-frame import ${identity}`); + failures.push(`duplicate ABI 43 fork-runtime import ${identity}`); } } } @@ -691,7 +2086,7 @@ function describeForkArtifactContractFailures( ) ) { failures.push( - `ABI 43 linked-frame import ${identity} has the wrong signature; expected ${ + `ABI 43 fork-runtime import ${identity} has the wrong signature; expected ${ signatureText(requirement.params, requirement.results, pointerWidth) }`, ); @@ -703,6 +2098,61 @@ function describeForkArtifactContractFailures( return failures; } +/** + * Validate the complete ABI-epoch fork contract without compiling or running + * the artifact. This is shared by program admission and the dynamic linker so + * a side module cannot defer a malformed reconstruction recipe until replay. + */ +export function describeWasmForkArtifactContractFailures( + programBytes: ArrayBuffer, +): string[] { + try { + return describeForkArtifactContractFailures( + readWasmForkArtifactFacts(programBytes), + ); + } catch (error) { + return [ + `cannot validate ABI 43 fork-artifact contract: ${ + error instanceof Error ? error.message : String(error) + }`, + ]; + } +} + +/** + * Return exact function-import identities, ordinals, and binary signatures. + * + * WebAssembly.Module.imports() omits function types. Fork-safe host-import + * routing needs the artifact-declared signature so an owner descriptor cannot + * accidentally reinterpret the same scalar words under a different type. + */ +export function readWasmFunctionImports( + programBytes: ArrayBuffer, +): readonly WasmFunctionImportType[] { + return Object.freeze( + readWasmForkArtifactFacts(programBytes).functionImportEntries.map( + (entry) => + Object.freeze({ + ...entry, + signature: Object.freeze({ + params: Object.freeze([...entry.signature.params]), + results: Object.freeze([...entry.signature.results]), + paramTypes: Object.freeze( + entry.signature.paramTypes.map((type) => + Object.freeze({ ...type }) + ), + ), + resultTypes: Object.freeze( + entry.signature.resultTypes.map((type) => + Object.freeze({ ...type }) + ), + ), + }), + }), + ), + ); +} + /** * Return import names in `module.field` form. This is intentionally a small * section parser rather than `new WebAssembly.Module(...)` so release/resolver @@ -734,16 +2184,16 @@ export function readWasmImportNames(programBytes: ArrayBuffer): string[] { if (kind === 0) { const [, n] = readULEB128(src, pos); pos += n; } else if (kind === 1) { - pos++; - const flags = src[pos++]; - const [, minBytes] = readULEB128(src, pos); pos += minBytes; - if (flags & 1) { const [, maxBytes] = readULEB128(src, pos); pos += maxBytes; } + pos = readWasmValueType(src, pos, "table import type").next; + pos = readLimits(src, pos).next; } else if (kind === 2) { - const flags = src[pos++]; - const [, minBytes] = readULEB128(src, pos); pos += minBytes; - if (flags & 1) { const [, maxBytes] = readULEB128(src, pos); pos += maxBytes; } + pos = readLimits(src, pos).next; } else if (kind === 3) { - pos += 2; + pos = readWasmValueType(src, pos, "global import type").next; + pos++; + } else if (kind === 4) { + pos++; // tag attribute + const [, typeBytes] = readULEB128(src, pos); pos += typeBytes; } } break; @@ -820,7 +2270,10 @@ export function wasmHasCompleteForkInstrumentation(programBytes: ArrayBuffer): b const facts = readWasmForkArtifactFacts(programBytes); const hasForkSurface = WPK_FORK_REQUIRED_EXPORTS.some(({ name }) => facts.functionExports.has(name) - ) || facts.linkedFrameDescriptors.length > 0; + ) || facts.linkedFrameDescriptors.length > 0 + || facts.exceptionCodecDescriptors.length > 0 + || facts.importedGlobalsDescriptors.length > 0 + || facts.importedTablesDescriptors.length > 0; return hasForkSurface && describeForkArtifactContractFailures(facts).length === 0; } catch { return false; @@ -875,9 +2328,31 @@ export function describeWasmArtifactPolicyFailures( const capabilityCount = customSections.filter((name) => name === WPK_FORK_CAPABILITIES_SECTION ).length; + const moduleStateDescriptorCount = customSections.filter((name) => + name === WPK_FORK_MODULE_STATE_FORMAT_SECTION + ).length; + const exceptionCodecDescriptorCount = customSections.filter((name) => + name === WPK_FORK_EXCEPTION_CODEC_SECTION + ).length; + const importedGlobalsDescriptorCount = customSections.filter((name) => + name === WPK_FORK_IMPORTED_GLOBALS_SECTION + ).length; + const importedTablesDescriptorCount = customSections.filter((name) => + name === WPK_FORK_IMPORTED_TABLES_SECTION + ).length; + const unwindTransportCount = customSections.filter((name) => + name === FORK_UNWIND_TRANSPORT_SECTION + ).length; + const hasUnwindTagImport = importNames.includes( + `${FORK_UNWIND_TAG_IMPORT_MODULE}.${FORK_UNWIND_TAG_IMPORT_NAME}`, + ); const hasForkArtifactSurface = presentWpkExports.length > 0 || presentWpkImports.length > 0 || - descriptorCount > 0 || capabilityCount > 0; + descriptorCount > 0 || capabilityCount > 0 || + moduleStateDescriptorCount > 0 || exceptionCodecDescriptorCount > 0 || + importedGlobalsDescriptorCount > 0 || importedTablesDescriptorCount > 0 || + unwindTransportCount > 0 || + hasUnwindTagImport; if ( options.expectedAbi !== undefined && options.expectedAbi !== null && @@ -925,7 +2400,7 @@ export function describeWasmArtifactPolicyFailures( */ function readGlobalInitAddr(src: Uint8Array, pos: number): bigint | null { // valtype + mut + init expr (terminated by 0x0B) - pos++; // valtype + pos = readWasmValueType(src, pos, "global type").next; pos++; // mut const opcode = src[pos++]; if (opcode === 0x41) { @@ -947,7 +2422,8 @@ function readGlobalInitAddr(src: Uint8Array, pos: number): bigint | null { * expression ends at the first 0x0B (end) opcode. */ function skipGlobalEntry(src: Uint8Array, pos: number): number { - pos += 2; // valtype + mut + pos = readWasmValueType(src, pos, "global type").next; + pos++; // mutability while (src[pos] !== 0x0B) pos++; return pos + 1; // skip the end opcode } @@ -1119,7 +2595,11 @@ function extractI32ConstFunctionExport( pos += localGroupsBytes; for (let i = 0; i < localGroups; i++) { const [, n] = readULEB128(src, pos); pos += n; // count - pos++; // valtype + try { + pos = readWasmValueType(src, pos, `function local group ${i}`).next; + } catch { + return null; + } if (pos > bodyEnd) return null; } return pos; @@ -1238,48 +2718,41 @@ export function detectPtrWidth(programBytes: ArrayBuffer): 4 | 8 { const src = new Uint8Array(programBytes); if (src.length < 8) return 4; - function readLEB128(buf: Uint8Array, off: number): [number, number] { - let result = 0, shift = 0, pos = off; - for (;;) { - const byte = buf[pos++]; - result |= (byte & 0x7f) << shift; - if ((byte & 0x80) === 0) break; - shift += 7; - } - return [result, pos - off]; - } - // Skip magic + version (8 bytes) let offset = 8; while (offset < src.length) { const sectionId = src[offset]; - const [sectionSize, sizeBytes] = readLEB128(src, offset + 1); + const [sectionSize, sizeBytes] = readULEB128(src, offset + 1); const contentOffset = offset + 1 + sizeBytes; if (sectionId === 2) { // Import section — look for memory imports let pos = contentOffset; - const [importCount, countBytes] = readLEB128(src, pos); + const [importCount, countBytes] = readULEB128(src, pos); pos += countBytes; for (let i = 0; i < importCount; i++) { - const [modLen, modLenBytes] = readLEB128(src, pos); pos += modLenBytes + modLen; - const [fieldLen, fieldLenBytes] = readLEB128(src, pos); pos += fieldLenBytes + fieldLen; + const [modLen, modLenBytes] = readULEB128(src, pos); pos += modLenBytes + modLen; + const [fieldLen, fieldLenBytes] = readULEB128(src, pos); pos += fieldLenBytes + fieldLen; const kind = src[pos++]; if (kind === 2) { - // Memory import: flags byte, then limits - const flags = src[pos]; - if (flags & 0x04) return 8; // memory64 bit set + const limits = readLimits(src, pos); + if ((limits.flags & 0x04) !== 0) return 8; return 4; } // Skip non-memory imports - if (kind === 0) { const [, n] = readLEB128(src, pos); pos += n; } + if (kind === 0) { const [, n] = readULEB128(src, pos); pos += n; } else if (kind === 1) { - pos++; // ref type - const f = src[pos++]; - const [, n] = readLEB128(src, pos); pos += n; - if (f & 1) { const [, n2] = readLEB128(src, pos); pos += n2; } + pos = readWasmValueType(src, pos, "table import type").next; + pos = readLimits(src, pos).next; + } + else if (kind === 3) { + pos = readWasmValueType(src, pos, "global import type").next; + pos++; + } else if (kind === 4) { + pos++; // tag attribute + const [, typeBytes] = readULEB128(src, pos); + pos += typeBytes; } - else if (kind === 3) { pos += 2; } // global: type + mutability } break; } diff --git a/host/src/dylink-fork-archive.ts b/host/src/dylink-fork-archive.ts new file mode 100644 index 0000000000..d708a29d7d --- /dev/null +++ b/host/src/dylink-fork-archive.ts @@ -0,0 +1,2152 @@ +import type { + DylinkForkLibraryState, + DylinkForkState, + DylinkForkTransactionState, + DylinkInitializationStage, +} from "./dylink"; +import { computeForkModuleTemplateIdSync } from "./fork-module-state"; + +const ARCHIVE_MAGIC = 0x414c_464b; // "KFLA" in little-endian memory. +const ARCHIVE_VERSION = 4; +const ARCHIVE_HEADER_SIZE = 104; +const MODULE_MAGIC = 0x4d4c_464b; // "KFLM" in little-endian memory. +const MODULE_VERSION = 5; +const MODULE_HEADER_SIZE = 136; +const MODULE_DIGEST_OFFSET = 72; +const MODULE_DIGEST_SIZE = 32; +const MODULE_ALLOCATION_SIZE = 32; +const MODULE_FLAG_INITIALIZING = 1; +const MODULE_FLAG_GLOBAL = 1 << 1; +const MODULE_FLAG_COMMITTED_GLOBAL_ROOT = 1 << 2; +const MODULE_FLAG_KNOWN_MASK = + MODULE_FLAG_INITIALIZING + | MODULE_FLAG_GLOBAL + | MODULE_FLAG_COMMITTED_GLOBAL_ROOT; +const TRANSACTION_MAGIC = 0x544c_464b; // "KFLT" in little-endian memory. +const TRANSACTION_VERSION = 2; +const TRANSACTION_HEADER_SIZE = 80; +const TRANSACTION_DIGEST_OFFSET = 40; +const TRANSACTION_FLAG_GLOBAL = 1; +const TABLE_PATCH_MAGIC = 0x504a_464b; // "KFJP" in little-endian memory. +const TABLE_PATCH_VERSION = 1; +const TABLE_PATCH_HEADER_SIZE = 64; +const TABLE_PATCH_RUN_SIZE = 24; +const MAX_TABLE_PATCH_RECORDS = 256; +const MAX_TABLE_PATCH_BYTES = 1024 * 1024; +const FIRST_DYLINK_HANDLE = 2; +const EXHAUSTED_DYLINK_HANDLE = 0x1_0000_0000; +const MAX_EXACT_GENERATION = Number.MAX_SAFE_INTEGER; + +export interface DylinkForkArchiveSnapshot extends DylinkForkState { + /** + * Monotonic publication generation. + * + * Zero means that no archive has ever been published. A Worker may compare + * this scalar before parsing module records; a changed value requires a + * complete validated read before it can execute a table function installed + * by dlopen. + */ + readonly generation: number; + /** Sealed table-only KFMS arena, or zero before the first mutation. */ + readonly tableStateRoot: number; + /** Generation represented by `tableStateRoot`, or zero when it is absent. */ + readonly tableCheckpointGeneration: number; + /** Ordered funcref patches published after the current checkpoint. */ + readonly tablePatches: readonly DylinkForkTablePatch[]; +} + +export interface DylinkForkTablePatchRun { + readonly length: number; + /** Null has no function coordinate. */ + readonly function: + | null + | Readonly<{ + activationId: number; + ordinal: number; + }>; +} + +export interface DylinkForkTablePatch { + /** Assigned atomically by the archive at publication. */ + readonly generation?: number; + readonly activationId: number; + readonly ownerId: number; + readonly start: number; + readonly tableLength: number; + readonly runs: readonly DylinkForkTablePatchRun[]; +} + +export interface DylinkForkArchiveAllocation { + readonly address: number; + readonly size: number; +} + +export type DylinkForkArchiveAllocate = ( + size: number, +) => DylinkForkArchiveAllocation; + +export type DylinkForkArchiveDeallocate = ( + allocation: DylinkForkArchiveAllocation, +) => void; + +export interface DylinkForkGenerationFence { + read(): number; + write(generation: number): void; +} + +export interface DylinkForkTablePublication { + readonly snapshot: DylinkForkArchiveSnapshot; + readonly previousTableStateRoot: number; +} + +export interface DylinkForkTablePatchPublication { + readonly snapshot: DylinkForkArchiveSnapshot; +} + +/** + * Per-Worker generation gate for deterministic module/table recipes. + * + * The callback instantiates missing side modules into that Worker's own table + * and activation catalog. No function object crosses the Worker boundary. + */ +export class DylinkForkTableReplica { + private appliedGeneration = 0; + + constructor( + private readonly archive: DylinkForkArchive, + private readonly materialize: ( + snapshot: DylinkForkArchiveSnapshot, + previousGeneration: number, + ) => void, + private readonly label: string, + ) {} + + generation(): number { + return this.appliedGeneration; + } + + /** + * Advance the Worker that encoded the just-published state without + * reconstructing typed references back into their source Table. + */ + adoptPublishedGeneration(generation: number): void { + if ( + !Number.isSafeInteger(generation) + || generation < this.appliedGeneration + ) { + throw new RangeError( + `${this.label}: cannot adopt dylink generation ${String(generation)}`, + ); + } + this.appliedGeneration = generation; + } + + reconcile(): boolean { + const published = this.archive.generation(); + if (published === this.appliedGeneration) return false; + if (published < this.appliedGeneration) { + throw new Error( + `${this.label}: dylink archive generation moved backward from ` + + `${this.appliedGeneration} to ${published}`, + ); + } + const snapshot = this.archive.read(); + if (snapshot.generation !== published) { + throw new Error( + `${this.label}: dylink archive changed while its reader lock was held`, + ); + } + this.materialize(snapshot, this.appliedGeneration); + // Publish locally only after every fresh function object is installed. + this.appliedGeneration = snapshot.generation; + return true; + } +} + +interface IndexedModule { + readonly allocation: DylinkForkArchiveAllocation; + readonly state: DylinkForkLibraryState; +} + +interface IndexedTablePatch { + readonly allocation: DylinkForkArchiveAllocation; + readonly patch: DylinkForkTablePatch & { readonly generation: number }; +} + +interface IndexedTransaction { + readonly allocation: DylinkForkArchiveAllocation; + readonly state: DylinkForkTransactionState; +} + +function align8(value: number): number { + const aligned = Math.ceil(value / 8) * 8; + if (!Number.isSafeInteger(aligned)) { + throw new RangeError("dylink fork archive size exceeds exact host integers"); + } + return aligned; +} + +function canonicalProviderDependencies( + state: DylinkForkLibraryState, +): string[] { + const dependencies = [...(state.providerDependencies ?? [])].sort(); + const seen = new Set(); + for (const dependency of dependencies) { + if ( + typeof dependency !== "string" + || dependency.length === 0 + || dependency === state.name + || seen.has(dependency) + ) { + throw new Error( + `${state.name}: invalid or duplicate runtime provider ${String(dependency)}`, + ); + } + seen.add(dependency); + } + return dependencies; +} + +function canonicalMemoryAllocations( + state: DylinkForkLibraryState, +): NonNullable[number][] { + const allocations = [...(state.allocations ?? [])] + .map((allocation) => ({ ...allocation })) + .sort( + (left, right) => + left.mappingAddress - right.mappingAddress + || left.address - right.address, + ); + let previousMappingEnd = 0; + for (const [index, allocation] of allocations.entries()) { + checkedAddress( + allocation.address, + `${state.name}: allocation ${index} address`, + ); + checkedAddress( + allocation.size, + `${state.name}: allocation ${index} size`, + ); + checkedAddress( + allocation.mappingAddress, + `${state.name}: allocation ${index} mapping address`, + ); + checkedAddress( + allocation.mappingSize, + `${state.name}: allocation ${index} mapping size`, + ); + const logicalEnd = allocation.address + allocation.size; + const mappingEnd = allocation.mappingAddress + allocation.mappingSize; + if ( + !Number.isSafeInteger(logicalEnd) + || !Number.isSafeInteger(mappingEnd) + || allocation.address < allocation.mappingAddress + || logicalEnd > mappingEnd + ) { + throw new RangeError( + `${state.name}: allocation ${index} escapes its process mapping`, + ); + } + if (allocation.mappingAddress < previousMappingEnd) { + throw new Error(`${state.name}: process allocation mappings overlap`); + } + previousMappingEnd = mappingEnd; + } + return allocations; +} + +function encodeProviderDependencies( + state: DylinkForkLibraryState, +): Readonly<{ + bytes: Uint8Array; + count: number; +}> { + const encoded = canonicalProviderDependencies(state).map((dependency) => { + const bytes = new TextEncoder().encode(dependency); + checkedU32(bytes.length, `${state.name}: runtime provider name length`); + return bytes; + }); + const size = encoded.reduce((total, bytes) => { + const next = total + 4 + bytes.length; + if (!Number.isSafeInteger(next) || next > 0xffff_ffff) { + throw new RangeError( + `${state.name}: runtime provider archive is too large`, + ); + } + return next; + }, 0); + const bytes = new Uint8Array(size); + const view = new DataView(bytes.buffer); + let cursor = 0; + for (const name of encoded) { + view.setUint32(cursor, name.length, true); + cursor += 4; + bytes.set(name, cursor); + cursor += name.length; + } + return { bytes, count: encoded.length }; +} + +function decodeProviderDependencies( + bytes: Uint8Array, + count: number, + context: string, +): string[] { + checkedU32(count, `${context} runtime provider count`); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const dependencies: string[] = []; + let cursor = 0; + for (let index = 0; index < count; index++) { + if (cursor > bytes.length - 4) { + throw new Error(`${context}: truncated runtime provider metadata`); + } + const length = view.getUint32(cursor, true); + cursor += 4; + if (length === 0 || cursor > bytes.length - length) { + throw new Error(`${context}: invalid runtime provider name length`); + } + let dependency: string; + try { + dependency = new TextDecoder("utf-8", { fatal: true }).decode( + bytes.subarray(cursor, cursor + length), + ); + } catch { + throw new Error(`${context}: invalid UTF-8 runtime provider name`); + } + if ( + dependencies.length > 0 + && dependencies[dependencies.length - 1]! >= dependency + ) { + throw new Error(`${context}: noncanonical runtime provider ordering`); + } + dependencies.push(dependency); + cursor += length; + } + if (cursor !== bytes.length) { + throw new Error(`${context}: noncanonical runtime provider metadata`); + } + return dependencies; +} + +function equalBytes(left: Readonly, right: Readonly): boolean { + return left.length === right.length + && left.every((byte, index) => byte === right[index]); +} + +function checkedU32(value: number, context: string, allowZero = true): number { + if ( + !Number.isInteger(value) + || value < (allowZero ? 0 : 1) + || value > 0xffff_ffff + ) { + throw new RangeError(`${context} is not ${allowZero ? "a" : "a nonzero"} u32`); + } + return value; +} + +function checkedAddress(value: number, context: string, allowZero = false): number { + if (!Number.isSafeInteger(value) || value < (allowZero ? 0 : 1)) { + throw new RangeError(`${context} is not an exact positive address`); + } + return value; +} + +function checkedExactNonnegative(value: number, context: string): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError(`${context} is not an exact non-negative integer`); + } + return value; +} + +function checkedNextHandle(value: number): number { + if ( + !Number.isSafeInteger(value) + || value < FIRST_DYLINK_HANDLE + || value > EXHAUSTED_DYLINK_HANDLE + ) { + throw new RangeError(`dylink fork archive next handle ${String(value)} is invalid`); + } + return value; +} + +function initializationStageCode(stage: DylinkInitializationStage): number { + switch (stage) { + case "bootstrap": return 1; + case "relocations": return 2; + case "constructors": return 3; + } +} + +function decodeInitializationStage( + code: number, + context: string, +): DylinkInitializationStage { + switch (code) { + case 1: return "bootstrap"; + case 2: return "relocations"; + case 3: return "constructors"; + default: throw new Error(`${context}: invalid initialization stage ${code}`); + } +} + +/** + * Versioned, bounded-by-live-closure dylink state copied through process memory. + * + * The JavaScript object graph is only an index/cache. Durable state is the + * header and module records in linear memory, which a pthread or fresh process + * worker can validate and adopt independently. + */ +export class DylinkForkArchive { + private headerAddress = 0; + private indexed = false; + private modules = new Map(); + private transactions = new Map(); + private tablePatchRecords: IndexedTablePatch[] = []; + private state: DylinkForkArchiveSnapshot = { + generation: 0, + tableStateRoot: 0, + tableCheckpointGeneration: 0, + tablePatches: [], + nextHandle: FIRST_DYLINK_HANDLE, + libraries: [], + transactions: [], + }; + + constructor( + private readonly memory: WebAssembly.Memory, + private readonly ptrWidth: 4 | 8, + private readonly readHead: () => number, + private readonly writeHead: (address: number) => void, + private readonly allocate: DylinkForkArchiveAllocate, + private readonly deallocate: DylinkForkArchiveDeallocate, + private readonly label: string, + private readonly generationFence?: DylinkForkGenerationFence, + ) { + if (ptrWidth !== 4 && ptrWidth !== 8) { + throw new RangeError(`${label}: invalid archive pointer width ${ptrWidth}`); + } + } + + /** + * Return the current publication generation without walking module records. + * + * Callers still hold the process archive reader lock while acting on the + * result. The scalar is a fast-path hint, not permission to consume a + * concurrently changing archive. + */ + generation(): number { + const head = this.readHead(); + const fenced = this.generationFence?.read(); + if (head === 0) { + if (fenced !== undefined && fenced !== 0) { + throw new Error(`${this.label}: published generation has no archive header`); + } + return 0; + } + this.checkedRange(head, ARCHIVE_HEADER_SIZE, "archive header"); + return fenced ?? this.readGeneration(head); + } + + /** Validate and return an owned snapshot of the copied archive. */ + read(): DylinkForkArchiveSnapshot { + this.refreshIndex(); + return this.copyState(this.state); + } + + /** + * Publish the exact compact live linker state. + * + * Callers serialize this operation with process dlopen/fork arbitration. + * New records are fully initialized before the header points at them; stale + * records become unreachable before their mappings are released. + */ + sync(nextState: DylinkForkState): DylinkForkArchiveSnapshot { + const owned = this.validateState(nextState); + this.refreshIndex(); + this.ensureHeader(owned.nextHandle); + const generation = this.nextGeneration(this.state.generation); + + const target: IndexedModule[] = []; + const targetNames = new Set(); + const replaced: IndexedModule[] = []; + for (const library of owned.libraries) { + targetNames.add(library.name); + const current = this.modules.get(library.name); + if (current) { + this.requireImmutableMatch(current.state, library); + const currentProviders = + canonicalProviderDependencies(current.state); + const nextProviders = canonicalProviderDependencies(library); + if ( + currentProviders.length === nextProviders.length + && currentProviders.every( + (dependency, index) => dependency === nextProviders[index], + ) + ) { + this.writeMutableState(current.allocation.address, library); + target.push({ + allocation: current.allocation, + state: library, + }); + } else { + // Constructor dlsym can add a runtime-provider edge between archive + // generations. Publish a complete replacement record; never resize a + // reachable record beneath pthread readers. + target.push(this.allocateModule(library)); + replaced.push(current); + } + } else { + target.push(this.allocateModule(library)); + } + } + + for (let index = 0; index < target.length; index++) { + this.writeU64( + target[index]!.allocation.address + 8, + target[index + 1]?.allocation.address ?? 0, + ); + } + + const transactionTarget: IndexedTransaction[] = []; + const transactionTokens = new Set(); + for (const transaction of owned.transactions ?? []) { + transactionTokens.add(transaction.token); + const current = this.transactions.get(transaction.token); + if (current) { + if ( + current.state.name !== transaction.name + || current.state.globalVisibility !== transaction.globalVisibility + || !equalBytes(current.state.moduleBytes, transaction.moduleBytes) + ) { + throw new Error( + `${this.label}: staged transaction ${transaction.token} changed identity`, + ); + } + transactionTarget.push({ + allocation: current.allocation, + state: transaction, + }); + } else { + transactionTarget.push(this.allocateTransaction(transaction)); + } + } + for (let index = 0; index < transactionTarget.length; index++) { + this.writeU64( + transactionTarget[index]!.allocation.address + 8, + transactionTarget[index + 1]?.allocation.address ?? 0, + ); + } + + const view = new DataView(this.memory.buffer); + this.writeU64(this.headerAddress + 16, owned.nextHandle); + view.setUint32(this.headerAddress + 24, target.length, true); + view.setUint32(this.headerAddress + 28, 0, true); + this.writeU64( + this.headerAddress + 32, + target[0]?.allocation.address ?? 0, + ); + view.setUint32(this.headerAddress + 88, transactionTarget.length, true); + view.setUint32(this.headerAddress + 92, 0, true); + this.writeU64( + this.headerAddress + 96, + transactionTarget[0]?.allocation.address ?? 0, + ); + // WHY: generation is the publication fence consumed by other Workers. + // Write it only after every reachable record and header field is complete; + // otherwise a pthread could observe "new" and instantiate a half-written + // function recipe graph. + this.writeGeneration(this.headerAddress, generation); + this.generationFence?.write(generation); + + const stale = [...this.modules.values()].filter( + ({ state }) => !targetNames.has(state.name), + ); + const staleTransactions = [...this.transactions.values()].filter( + ({ state }) => !transactionTokens.has(state.token), + ); + this.modules = new Map(target.map((entry) => [entry.state.name, entry])); + this.transactions = new Map( + transactionTarget.map((entry) => [entry.state.token, entry]), + ); + this.state = { + generation, + tableStateRoot: this.state.tableStateRoot, + tableCheckpointGeneration: this.state.tableCheckpointGeneration, + tablePatches: this.state.tablePatches, + nextHandle: owned.nextHandle, + libraries: target.map(({ state }) => state), + transactions: transactionTarget.map(({ state }) => state), + }; + for (const entry of stale) this.deallocate(entry.allocation); + for (const entry of replaced) this.deallocate(entry.allocation); + for (const entry of staleTransactions) this.deallocate(entry.allocation); + return this.copyState(this.state); + } + + /** + * Publish a sealed typed table snapshot while the process writer lock is held. + */ + publishTableState(tableStateRoot: number): DylinkForkTablePublication { + checkedAddress(tableStateRoot, `${this.label}: table-state root`); + this.refreshIndex(); + this.ensureHeader(this.state.nextHandle); + const previousTableStateRoot = this.state.tableStateRoot; + const generation = this.nextGeneration(this.state.generation); + const stalePatches = this.tablePatchRecords; + this.writeU64(this.headerAddress + 48, tableStateRoot); + this.writeU64(this.headerAddress + 56, 0); + this.writeU64(this.headerAddress + 64, 0); + const view = new DataView(this.memory.buffer); + view.setUint32(this.headerAddress + 72, 0, true); + view.setUint32(this.headerAddress + 76, 0, true); + this.writeU64(this.headerAddress + 80, generation); + this.writeGeneration(this.headerAddress, generation); + // This fixed shared-memory word is the Wasm fast-path fence. It must be + // last so a changed value always names a complete header and sealed arena. + this.generationFence?.write(generation); + this.state = { + ...this.state, + generation, + tableStateRoot, + tableCheckpointGeneration: generation, + tablePatches: [], + }; + this.tablePatchRecords = []; + for (const record of stalePatches) { + this.deallocate(record.allocation); + } + return { + snapshot: this.copyState(this.state), + previousTableStateRoot, + }; + } + + /** + * Whether one deterministic funcref patch fits before bounded compaction. + */ + canPublishTablePatch(patch: DylinkForkTablePatch): boolean { + this.refreshIndex(); + const owned = this.validateTablePatch(patch); + const size = this.tablePatchSize(owned); + return ( + this.tablePatchRecords.length < MAX_TABLE_PATCH_RECORDS + && this.tablePatchBytes() + size <= MAX_TABLE_PATCH_BYTES + ); + } + + /** + * Append one deterministic funcref patch under the process writer lock. + * + * A caller that receives `false` from `canPublishTablePatch` first publishes + * a full typed checkpoint. This keeps retained journal memory bounded while + * making the common mutation proportional only to its changed range. + */ + publishTablePatch( + patch: DylinkForkTablePatch, + ): DylinkForkTablePatchPublication { + const owned = this.validateTablePatch(patch); + this.refreshIndex(); + this.ensureHeader(this.state.nextHandle); + const size = this.tablePatchSize(owned); + if ( + this.tablePatchRecords.length >= MAX_TABLE_PATCH_RECORDS + || this.tablePatchBytes() + size > MAX_TABLE_PATCH_BYTES + ) { + throw new Error(`${this.label}: table patch journal requires compaction`); + } + const generation = this.nextGeneration(this.state.generation); + const record = this.allocateTablePatch(owned, generation); + const previous = this.tablePatchRecords.at(-1); + if (previous) { + this.writeU64(previous.allocation.address + 8, record.allocation.address); + } else { + this.writeU64(this.headerAddress + 56, record.allocation.address); + } + this.writeU64(this.headerAddress + 64, record.allocation.address); + const view = new DataView(this.memory.buffer); + view.setUint32( + this.headerAddress + 72, + this.tablePatchRecords.length + 1, + true, + ); + view.setUint32( + this.headerAddress + 76, + this.tablePatchBytes() + size, + true, + ); + this.writeGeneration(this.headerAddress, generation); + this.generationFence?.write(generation); + this.tablePatchRecords = [...this.tablePatchRecords, record]; + this.state = { + ...this.state, + generation, + tablePatches: [...this.state.tablePatches, record.patch], + }; + return { snapshot: this.copyState(this.state) }; + } + + private refreshIndex(): void { + const head = this.readHead(); + if (head === 0) { + if (this.headerAddress !== 0 || this.state.generation !== 0) { + this.resetIndex(); + } + this.indexed = true; + return; + } + if ( + this.indexed + && this.headerAddress === head + && this.state.generation === this.generation() + ) { + return; + } + this.resetIndex(); + this.ensureIndexed(); + } + + private resetIndex(): void { + this.headerAddress = 0; + this.indexed = false; + this.modules = new Map(); + this.transactions = new Map(); + this.tablePatchRecords = []; + this.state = { + generation: 0, + tableStateRoot: 0, + tableCheckpointGeneration: 0, + tablePatches: [], + nextHandle: FIRST_DYLINK_HANDLE, + libraries: [], + transactions: [], + }; + } + + private ensureIndexed(): void { + if (this.indexed) return; + const head = this.readHead(); + if (head === 0) { + this.indexed = true; + return; + } + this.headerAddress = this.checkedRange( + head, + ARCHIVE_HEADER_SIZE, + "archive header", + ); + const view = new DataView(this.memory.buffer); + if (view.getUint32(head, true) !== ARCHIVE_MAGIC) { + throw new Error(`${this.label}: invalid dylink fork archive magic`); + } + if (view.getUint16(head + 4, true) !== ARCHIVE_VERSION) { + throw new Error(`${this.label}: unsupported dylink fork archive version`); + } + if (view.getUint16(head + 6, true) !== ARCHIVE_HEADER_SIZE) { + throw new Error(`${this.label}: invalid dylink fork archive header size`); + } + if (view.getUint8(head + 8) !== this.ptrWidth) { + throw new Error(`${this.label}: dylink fork archive pointer-width mismatch`); + } + for (let offset = 9; offset < 16; offset++) { + if (view.getUint8(head + offset) !== 0) { + throw new Error(`${this.label}: nonzero dylink fork archive header reserved byte`); + } + } + const nextHandle = checkedNextHandle(this.readU64(head + 16, "next handle")); + const count = view.getUint32(head + 24, true); + const maximumPhysicalModules = Math.floor( + (this.memory.buffer.byteLength - ARCHIVE_HEADER_SIZE) + / MODULE_HEADER_SIZE, + ); + if (count > maximumPhysicalModules) { + throw new RangeError( + `${this.label}: dylink fork archive module count exceeds its memory geometry`, + ); + } + if (view.getUint32(head + 28, true) !== 0) { + throw new Error(`${this.label}: nonzero dylink fork archive header flags`); + } + let cursor = this.readU64(head + 32, "first module"); + const generation = this.readGeneration(head); + if (generation === 0) { + throw new Error(`${this.label}: unpublished dylink fork archive`); + } + const fenced = this.generationFence?.read(); + if (fenced !== undefined && fenced !== generation) { + throw new Error( + `${this.label}: dylink archive generation does not match its publication fence`, + ); + } + const tableStateRoot = this.readU64(head + 48, "table-state root"); + if (tableStateRoot !== 0) { + this.checkedRange(tableStateRoot, 1, "table-state root"); + } + let tablePatchCursor = this.readU64(head + 56, "first table patch"); + const tablePatchTail = this.readU64(head + 64, "last table patch"); + const tablePatchCount = view.getUint32(head + 72, true); + const declaredTablePatchBytes = view.getUint32(head + 76, true); + const tableCheckpointGeneration = this.readU64( + head + 80, + "table checkpoint generation", + ); + const transactionCount = view.getUint32(head + 88, true); + if (view.getUint32(head + 92, true) !== 0) { + throw new Error(`${this.label}: nonzero dylink transaction flags`); + } + let transactionCursor = this.readU64( + head + 96, + "first staged transaction", + ); + const maximumPhysicalTransactions = Math.floor( + (this.memory.buffer.byteLength - ARCHIVE_HEADER_SIZE) + / TRANSACTION_HEADER_SIZE, + ); + if (transactionCount > maximumPhysicalTransactions) { + throw new RangeError( + `${this.label}: staged transaction count exceeds its memory geometry`, + ); + } + if ((transactionCount === 0) !== (transactionCursor === 0)) { + throw new Error( + `${this.label}: staged transaction count/head mismatch`, + ); + } + if ( + tableCheckpointGeneration > generation + || (tableStateRoot === 0) !== (tableCheckpointGeneration === 0) + ) { + throw new Error(`${this.label}: inconsistent table checkpoint`); + } + if ( + tablePatchCount > MAX_TABLE_PATCH_RECORDS + || declaredTablePatchBytes > MAX_TABLE_PATCH_BYTES + ) { + throw new RangeError(`${this.label}: table patch journal is too large`); + } + if ( + (tablePatchCount === 0) + !== (tablePatchCursor === 0 && tablePatchTail === 0) + ) { + throw new Error(`${this.label}: table patch count/head/tail mismatch`); + } + if ((count === 0) !== (cursor === 0)) { + throw new Error(`${this.label}: dylink fork archive count/head mismatch`); + } + + const intervals: Array<{ start: number; end: number }> = [{ + start: head, + end: head + ARCHIVE_HEADER_SIZE, + }]; + const seenAddresses = new Set(); + const seenNames = new Set(); + const seenActivations = new Set(); + const seenHandles = new Set(); + const libraries: DylinkForkLibraryState[] = []; + const modules = new Map(); + for (let ordinal = 0; ordinal < count; ordinal++) { + if (cursor === 0 || seenAddresses.has(cursor)) { + throw new Error(`${this.label}: cyclic or truncated dylink fork archive`); + } + seenAddresses.add(cursor); + const decoded = this.readModule(cursor, ordinal, intervals); + if (seenNames.has(decoded.state.name)) { + throw new Error(`${this.label}: duplicate archived module ${decoded.state.name}`); + } + seenNames.add(decoded.state.name); + if (decoded.state.activationId !== undefined) { + if (seenActivations.has(decoded.state.activationId)) { + throw new Error( + `${this.label}: duplicate archived activation ${decoded.state.activationId}`, + ); + } + seenActivations.add(decoded.state.activationId); + } + if (decoded.state.handle !== undefined) { + if (seenHandles.has(decoded.state.handle)) { + throw new Error( + `${this.label}: duplicate archived handle ${decoded.state.handle}`, + ); + } + if (decoded.state.handle >= nextHandle) { + throw new Error( + `${this.label}: archived handle ${decoded.state.handle} reaches next handle`, + ); + } + seenHandles.add(decoded.state.handle); + } + libraries.push(decoded.state); + modules.set(decoded.state.name, decoded); + cursor = this.readU64(cursor + 8, `module ${ordinal} next`); + } + if (cursor !== 0) { + throw new Error(`${this.label}: dylink fork archive has more records than declared`); + } + const transactionRecords: IndexedTransaction[] = []; + const seenTransactionTokens = new Set(); + for (let ordinal = 0; ordinal < transactionCount; ordinal++) { + if ( + transactionCursor === 0 + || seenAddresses.has(transactionCursor) + ) { + throw new Error( + `${this.label}: cyclic or truncated staged transaction archive`, + ); + } + seenAddresses.add(transactionCursor); + const decoded = this.readTransaction( + transactionCursor, + ordinal, + intervals, + ); + if (seenTransactionTokens.has(decoded.state.token)) { + throw new Error( + `${this.label}: duplicate staged transaction ${decoded.state.token}`, + ); + } + seenTransactionTokens.add(decoded.state.token); + transactionRecords.push(decoded); + transactionCursor = this.readU64( + transactionCursor + 8, + `staged transaction ${ordinal} next`, + ); + } + if (transactionCursor !== 0) { + throw new Error( + `${this.label}: staged transaction archive has extra records`, + ); + } + const tablePatchRecords: IndexedTablePatch[] = []; + let previousPatchGeneration = tableCheckpointGeneration; + let tablePatchBytes = 0; + for (let ordinal = 0; ordinal < tablePatchCount; ordinal++) { + if ( + tablePatchCursor === 0 + || seenAddresses.has(tablePatchCursor) + ) { + throw new Error(`${this.label}: cyclic or truncated table patch journal`); + } + seenAddresses.add(tablePatchCursor); + const decoded = this.readTablePatch( + tablePatchCursor, + ordinal, + intervals, + ); + const patchGeneration = decoded.patch.generation; + if ( + patchGeneration <= previousPatchGeneration + || patchGeneration > generation + ) { + throw new Error( + `${this.label}: table patch ${ordinal} has non-monotonic generation`, + ); + } + previousPatchGeneration = patchGeneration; + tablePatchBytes += decoded.allocation.size; + if (tablePatchBytes > MAX_TABLE_PATCH_BYTES) { + throw new RangeError(`${this.label}: table patch journal byte count overflow`); + } + tablePatchRecords.push(decoded); + tablePatchCursor = this.readU64( + tablePatchCursor + 8, + `table patch ${ordinal} next`, + ); + } + if (tablePatchCursor !== 0) { + throw new Error(`${this.label}: table patch journal has extra records`); + } + if ( + (tablePatchRecords.at(-1)?.allocation.address ?? 0) !== tablePatchTail + || tablePatchBytes !== declaredTablePatchBytes + ) { + throw new Error(`${this.label}: table patch tail/byte count mismatch`); + } + for (const library of libraries) { + for (const allocation of library.allocations ?? []) { + const start = allocation.mappingAddress; + const end = start + allocation.mappingSize; + if ( + intervals.some( + (interval) => start < interval.end && interval.start < end, + ) + ) { + throw new Error( + `${this.label}: ${library.name} process mapping overlaps archive storage`, + ); + } + } + } + const validatedState = this.validateState({ + nextHandle, + libraries, + transactions: transactionRecords.map(({ state }) => state), + }); + this.modules = modules; + this.transactions = new Map( + transactionRecords.map((entry) => [entry.state.token, entry]), + ); + this.tablePatchRecords = tablePatchRecords; + this.state = { + generation, + tableStateRoot, + tableCheckpointGeneration, + tablePatches: tablePatchRecords.map(({ patch }) => patch), + nextHandle: validatedState.nextHandle, + libraries: validatedState.libraries, + transactions: validatedState.transactions ?? [], + }; + this.indexed = true; + } + + private ensureHeader(nextHandle: number): void { + if (this.headerAddress !== 0) return; + const allocation = this.allocate(ARCHIVE_HEADER_SIZE); + if (allocation.size !== ARCHIVE_HEADER_SIZE) { + throw new Error(`${this.label}: archive allocator changed the header size`); + } + const address = this.checkedRange( + allocation.address, + allocation.size, + "new archive header", + ); + const bytes = new Uint8Array(this.memory.buffer, address, allocation.size); + bytes.fill(0); + const view = new DataView(this.memory.buffer); + view.setUint32(address, ARCHIVE_MAGIC, true); + view.setUint16(address + 4, ARCHIVE_VERSION, true); + view.setUint16(address + 6, ARCHIVE_HEADER_SIZE, true); + view.setUint8(address + 8, this.ptrWidth); + this.writeU64(address + 16, nextHandle); + this.headerAddress = address; + // The zero generation keeps this header explicitly unpublished until + // sync() has linked every module record and performs the final release. + this.writeHead(address); + } + + private validateTablePatch( + patch: DylinkForkTablePatch, + ): DylinkForkTablePatch { + if (patch.generation !== undefined) { + throw new Error(`${this.label}: caller assigned a table patch generation`); + } + const activationId = checkedU32( + patch.activationId, + `${this.label}: table patch activation`, + ); + const ownerId = checkedU32( + patch.ownerId, + `${this.label}: table patch owner`, + false, + ); + const start = checkedExactNonnegative( + patch.start, + `${this.label}: table patch start`, + ); + const tableLength = checkedExactNonnegative( + patch.tableLength, + `${this.label}: table patch length`, + ); + if (!Array.isArray(patch.runs) || patch.runs.length === 0) { + throw new Error(`${this.label}: table patch has no runs`); + } + checkedU32( + patch.runs.length, + `${this.label}: table patch run count`, + false, + ); + let changed = 0; + const runs = patch.runs.map((run, ordinal) => { + const length = checkedExactNonnegative( + run.length, + `${this.label}: table patch run ${ordinal} length`, + ); + if (length === 0) { + throw new RangeError(`${this.label}: table patch run ${ordinal} is empty`); + } + changed += length; + if (!Number.isSafeInteger(changed)) { + throw new RangeError(`${this.label}: table patch range is too large`); + } + if (run.function === null) { + return Object.freeze({ length, function: null }); + } + if ( + typeof run.function !== "object" + || run.function === null + ) { + throw new TypeError( + `${this.label}: table patch run ${ordinal} has no function recipe`, + ); + } + return Object.freeze({ + length, + function: Object.freeze({ + activationId: checkedU32( + run.function.activationId, + `${this.label}: table patch run ${ordinal} activation`, + ), + ordinal: checkedU32( + run.function.ordinal, + `${this.label}: table patch run ${ordinal} function ordinal`, + ), + }), + }); + }); + if (start + changed > tableLength) { + throw new RangeError(`${this.label}: table patch exceeds final table length`); + } + return Object.freeze({ + activationId, + ownerId, + start, + tableLength, + runs: Object.freeze(runs), + }); + } + + private tablePatchSize(patch: DylinkForkTablePatch): number { + return TABLE_PATCH_HEADER_SIZE + patch.runs.length * TABLE_PATCH_RUN_SIZE; + } + + private tablePatchBytes(): number { + return this.tablePatchRecords.reduce( + (total, record) => total + record.allocation.size, + 0, + ); + } + + private allocateTablePatch( + patch: DylinkForkTablePatch, + generation: number, + ): IndexedTablePatch { + const totalSize = this.tablePatchSize(patch); + const allocation = this.allocate(totalSize); + if (allocation.size !== totalSize) { + throw new Error(`${this.label}: archive allocator changed a table patch size`); + } + const address = this.checkedRange( + allocation.address, + allocation.size, + "new table patch", + ); + const bytes = new Uint8Array(this.memory.buffer, address, totalSize); + bytes.fill(0); + const view = new DataView(this.memory.buffer); + view.setUint32(address, TABLE_PATCH_MAGIC, true); + view.setUint16(address + 4, TABLE_PATCH_VERSION, true); + view.setUint16(address + 6, TABLE_PATCH_HEADER_SIZE, true); + this.writeU64(address + 16, totalSize); + this.writeU64(address + 24, generation); + view.setUint32(address + 32, patch.activationId, true); + view.setUint32(address + 36, patch.ownerId, true); + this.writeU64(address + 40, patch.start); + this.writeU64(address + 48, patch.tableLength); + view.setUint32(address + 56, patch.runs.length, true); + for (const [ordinal, run] of patch.runs.entries()) { + const offset = address + TABLE_PATCH_HEADER_SIZE + + ordinal * TABLE_PATCH_RUN_SIZE; + this.writeU64(offset, run.length); + view.setUint32(offset + 8, run.function === null ? 0 : 1, true); + view.setUint32(offset + 12, run.function?.activationId ?? 0, true); + view.setUint32(offset + 16, run.function?.ordinal ?? 0, true); + view.setUint32(offset + 20, 0, true); + } + return { + allocation, + patch: Object.freeze({ + ...this.copyTablePatch(patch), + generation, + }), + }; + } + + private readTablePatch( + address: number, + ordinal: number, + intervals: Array<{ start: number; end: number }>, + ): IndexedTablePatch { + this.checkedRange( + address, + TABLE_PATCH_HEADER_SIZE, + `table patch ${ordinal} header`, + ); + const view = new DataView(this.memory.buffer); + if (view.getUint32(address, true) !== TABLE_PATCH_MAGIC) { + throw new Error(`${this.label}: table patch ${ordinal} has invalid magic`); + } + if (view.getUint16(address + 4, true) !== TABLE_PATCH_VERSION) { + throw new Error(`${this.label}: table patch ${ordinal} has unsupported version`); + } + if (view.getUint16(address + 6, true) !== TABLE_PATCH_HEADER_SIZE) { + throw new Error(`${this.label}: table patch ${ordinal} has invalid header size`); + } + const runCount = view.getUint32(address + 56, true); + const totalSize = this.readU64( + address + 16, + `table patch ${ordinal} allocation size`, + ); + const expectedSize = + TABLE_PATCH_HEADER_SIZE + runCount * TABLE_PATCH_RUN_SIZE; + if (totalSize !== expectedSize) { + throw new Error(`${this.label}: table patch ${ordinal} has invalid size`); + } + this.checkedRange(address, totalSize, `table patch ${ordinal}`); + const end = address + totalSize; + if (intervals.some((interval) => address < interval.end && interval.start < end)) { + throw new Error(`${this.label}: table patch ${ordinal} overlaps an archive record`); + } + intervals.push({ start: address, end }); + const generation = this.readU64( + address + 24, + `table patch ${ordinal} generation`, + ); + if (generation === 0 || view.getUint32(address + 60, true) !== 0) { + throw new Error(`${this.label}: table patch ${ordinal} has invalid metadata`); + } + const runs: DylinkForkTablePatchRun[] = []; + for (let index = 0; index < runCount; index++) { + const offset = address + TABLE_PATCH_HEADER_SIZE + + index * TABLE_PATCH_RUN_SIZE; + const length = this.readU64( + offset, + `table patch ${ordinal} run ${index} length`, + ); + const kind = view.getUint32(offset + 8, true); + const activationId = view.getUint32(offset + 12, true); + const functionOrdinal = view.getUint32(offset + 16, true); + if (view.getUint32(offset + 20, true) !== 0 || (kind !== 0 && kind !== 1)) { + throw new Error(`${this.label}: table patch ${ordinal} run ${index} is invalid`); + } + if (kind === 0 && (activationId !== 0 || functionOrdinal !== 0)) { + throw new Error( + `${this.label}: null table patch run ${index} has a function coordinate`, + ); + } + runs.push({ + length, + function: kind === 0 + ? null + : { activationId, ordinal: functionOrdinal }, + }); + } + const patch = this.validateTablePatch({ + activationId: view.getUint32(address + 32, true), + ownerId: view.getUint32(address + 36, true), + start: this.readU64(address + 40, `table patch ${ordinal} start`), + tableLength: this.readU64( + address + 48, + `table patch ${ordinal} table length`, + ), + runs, + }); + return { + allocation: { address, size: totalSize }, + patch: Object.freeze({ + ...this.copyTablePatch(patch), + generation, + }), + }; + } + + private allocateTransaction( + state: DylinkForkTransactionState, + ): IndexedTransaction { + const name = new TextEncoder().encode(state.name); + const nameAligned = align8(name.length); + const totalSize = + TRANSACTION_HEADER_SIZE + nameAligned + state.moduleBytes.length; + const allocation = this.allocate(totalSize); + if (allocation.size !== totalSize) { + throw new Error( + `${this.label}: archive allocator changed a transaction record size`, + ); + } + const address = this.checkedRange( + allocation.address, + allocation.size, + `new staged transaction ${state.token}`, + ); + const bytes = new Uint8Array(this.memory.buffer, address, totalSize); + bytes.fill(0); + const view = new DataView(this.memory.buffer); + view.setUint32(address, TRANSACTION_MAGIC, true); + view.setUint16(address + 4, TRANSACTION_VERSION, true); + view.setUint16(address + 6, TRANSACTION_HEADER_SIZE, true); + this.writeU64(address + 16, totalSize); + view.setUint32(address + 24, state.token, true); + view.setUint32(address + 28, name.length, true); + view.setUint32(address + 32, state.moduleBytes.length, true); + view.setUint32( + address + 36, + state.globalVisibility ? TRANSACTION_FLAG_GLOBAL : 0, + true, + ); + bytes.set( + computeForkModuleTemplateIdSync(state.moduleBytes), + TRANSACTION_DIGEST_OFFSET, + ); + bytes.set(name, TRANSACTION_HEADER_SIZE); + bytes.set( + state.moduleBytes, + TRANSACTION_HEADER_SIZE + nameAligned, + ); + return { + allocation, + state: this.copyTransaction(state), + }; + } + + private readTransaction( + address: number, + ordinal: number, + intervals: Array<{ start: number; end: number }>, + ): IndexedTransaction { + this.checkedRange( + address, + TRANSACTION_HEADER_SIZE, + `staged transaction ${ordinal} header`, + ); + const view = new DataView(this.memory.buffer); + if (view.getUint32(address, true) !== TRANSACTION_MAGIC) { + throw new Error( + `${this.label}: staged transaction ${ordinal} has invalid magic`, + ); + } + if (view.getUint16(address + 4, true) !== TRANSACTION_VERSION) { + throw new Error( + `${this.label}: staged transaction ${ordinal} has unsupported version`, + ); + } + if (view.getUint16(address + 6, true) !== TRANSACTION_HEADER_SIZE) { + throw new Error( + `${this.label}: staged transaction ${ordinal} has invalid header size`, + ); + } + const allocationSize = this.readU64( + address + 16, + `staged transaction ${ordinal} allocation size`, + ); + const nameLength = view.getUint32(address + 28, true); + const bytesLength = view.getUint32(address + 32, true); + const expectedSize = + TRANSACTION_HEADER_SIZE + align8(nameLength) + bytesLength; + const flags = view.getUint32(address + 36, true); + if ( + allocationSize !== expectedSize + || (flags & ~TRANSACTION_FLAG_GLOBAL) !== 0 + ) { + throw new Error( + `${this.label}: staged transaction ${ordinal} has invalid metadata`, + ); + } + this.checkedRange( + address, + allocationSize, + `staged transaction ${ordinal}`, + ); + const end = address + allocationSize; + if ( + intervals.some((interval) => + address < interval.end && interval.start < end + ) + ) { + throw new Error( + `${this.label}: staged transaction ${ordinal} overlaps an archive record`, + ); + } + intervals.push({ start: address, end }); + const nameBytes = new Uint8Array( + this.memory.buffer, + address + TRANSACTION_HEADER_SIZE, + nameLength, + ); + let name: string; + try { + name = new TextDecoder("utf-8", { fatal: true }).decode( + new Uint8Array(nameBytes), + ); + } catch { + throw new Error( + `${this.label}: staged transaction ${ordinal} has invalid UTF-8 name`, + ); + } + const moduleBytes = new Uint8Array( + new Uint8Array( + this.memory.buffer, + address + TRANSACTION_HEADER_SIZE + align8(nameLength), + bytesLength, + ), + ); + const expectedDigest = new Uint8Array( + this.memory.buffer, + address + TRANSACTION_DIGEST_OFFSET, + MODULE_DIGEST_SIZE, + ); + if ( + !equalBytes( + expectedDigest, + computeForkModuleTemplateIdSync(moduleBytes), + ) + ) { + throw new Error( + `${this.label}: staged transaction ${ordinal} failed SHA-256 validation`, + ); + } + const state = this.validateTransaction({ + token: view.getUint32(address + 24, true), + name, + moduleBytes, + globalVisibility: (flags & TRANSACTION_FLAG_GLOBAL) !== 0, + }); + return { + allocation: { address, size: allocationSize }, + state, + }; + } + + private allocateModule(state: DylinkForkLibraryState): IndexedModule { + const name = new TextEncoder().encode(state.name); + const nameAligned = align8(name.length); + const moduleBytesAligned = align8(state.moduleBytes.length); + const providers = encodeProviderDependencies(state); + const providerBytesAligned = align8(providers.bytes.length); + const allocations = canonicalMemoryAllocations(state); + const allocationBytesLength = + allocations.length * MODULE_ALLOCATION_SIZE; + checkedU32( + allocationBytesLength, + `${state.name}: process allocation archive size`, + ); + const totalSize = + MODULE_HEADER_SIZE + + nameAligned + + moduleBytesAligned + + providerBytesAligned + + allocationBytesLength; + const allocation = this.allocate(totalSize); + if (allocation.size !== totalSize) { + throw new Error(`${this.label}: archive allocator changed a module record size`); + } + const address = this.checkedRange( + allocation.address, + allocation.size, + `new module ${state.name}`, + ); + const bytes = new Uint8Array(this.memory.buffer, address, totalSize); + bytes.fill(0); + const view = new DataView(this.memory.buffer); + view.setUint32(address, MODULE_MAGIC, true); + view.setUint16(address + 4, MODULE_VERSION, true); + view.setUint16(address + 6, MODULE_HEADER_SIZE, true); + this.writeU64(address + 16, totalSize); + this.writeU64(address + 24, state.memoryBase); + this.writeU64(address + 32, state.tableBase); + this.writeU64(address + 40, state.tlsBase ?? 0); + view.setUint32(address + 48, state.activationId ?? 0, true); + view.setUint32(address + 52, state.handle ?? 0, true); + view.setUint32(address + 56, state.refCount ?? 0, true); + view.setUint32(address + 60, name.length, true); + view.setUint32(address + 64, state.moduleBytes.length, true); + view.setUint32( + address + 68, + (state.initialization === undefined ? 0 : MODULE_FLAG_INITIALIZING) + | (state.globalVisibility ? MODULE_FLAG_GLOBAL : 0) + | ( + state.committedGlobalRoot + ? MODULE_FLAG_COMMITTED_GLOBAL_ROOT + : 0 + ), + true, + ); + bytes.set( + computeForkModuleTemplateIdSync(state.moduleBytes), + MODULE_DIGEST_OFFSET, + ); + bytes.set(name, MODULE_HEADER_SIZE); + bytes.set(state.moduleBytes, MODULE_HEADER_SIZE + nameAligned); + bytes.set( + providers.bytes, + MODULE_HEADER_SIZE + nameAligned + moduleBytesAligned, + ); + const allocationOffset = + address + + MODULE_HEADER_SIZE + + nameAligned + + moduleBytesAligned + + providerBytesAligned; + for (const [index, allocation] of allocations.entries()) { + const offset = allocationOffset + index * MODULE_ALLOCATION_SIZE; + this.writeU64(offset, allocation.address); + this.writeU64(offset + 8, allocation.size); + this.writeU64(offset + 16, allocation.mappingAddress); + this.writeU64(offset + 24, allocation.mappingSize); + } + view.setUint32( + address + 104, + state.initialization?.transactionToken ?? 0, + true, + ); + view.setUint32( + address + 108, + state.initialization === undefined + ? 0 + : initializationStageCode(state.initialization.stage), + true, + ); + this.writeU64( + address + 112, + state.initialization?.tableIndex ?? 0, + ); + view.setUint32(address + 120, providers.bytes.length, true); + view.setUint32(address + 124, providers.count, true); + view.setUint32(address + 128, allocationBytesLength, true); + view.setUint32(address + 132, allocations.length, true); + return { + allocation, + state: this.copyLibrary(state), + }; + } + + private readModule( + address: number, + ordinal: number, + intervals: Array<{ start: number; end: number }>, + ): IndexedModule { + this.checkedRange(address, MODULE_HEADER_SIZE, `module ${ordinal} header`); + const view = new DataView(this.memory.buffer); + if (view.getUint32(address, true) !== MODULE_MAGIC) { + throw new Error(`${this.label}: module ${ordinal} has invalid archive magic`); + } + if (view.getUint16(address + 4, true) !== MODULE_VERSION) { + throw new Error(`${this.label}: module ${ordinal} has unsupported archive version`); + } + if (view.getUint16(address + 6, true) !== MODULE_HEADER_SIZE) { + throw new Error(`${this.label}: module ${ordinal} has invalid archive header size`); + } + const allocationSize = this.readU64( + address + 16, + `module ${ordinal} allocation size`, + ); + const nameLength = view.getUint32(address + 60, true); + const bytesLength = view.getUint32(address + 64, true); + const providerBytesLength = view.getUint32(address + 120, true); + const providerCount = view.getUint32(address + 124, true); + const allocationBytesLength = view.getUint32(address + 128, true); + const allocationCount = view.getUint32(address + 132, true); + if ( + allocationBytesLength + !== allocationCount * MODULE_ALLOCATION_SIZE + ) { + throw new Error( + `${this.label}: module ${ordinal} has noncanonical allocation metadata`, + ); + } + const expectedSize = + MODULE_HEADER_SIZE + + align8(nameLength) + + align8(bytesLength) + + align8(providerBytesLength) + + allocationBytesLength; + if (allocationSize !== expectedSize) { + throw new Error(`${this.label}: module ${ordinal} has noncanonical allocation size`); + } + this.checkedRange(address, allocationSize, `module ${ordinal} allocation`); + const end = address + allocationSize; + if (intervals.some((interval) => address < interval.end && interval.start < end)) { + throw new Error(`${this.label}: module ${ordinal} overlaps another archive record`); + } + intervals.push({ start: address, end }); + const flags = view.getUint32(address + 68, true); + if ((flags & ~MODULE_FLAG_KNOWN_MASK) !== 0) { + throw new Error(`${this.label}: module ${ordinal} has unknown archive flags`); + } + const nameBytes = new Uint8Array( + this.memory.buffer, + address + MODULE_HEADER_SIZE, + nameLength, + ); + let name: string; + try { + name = new TextDecoder("utf-8", { fatal: true }).decode( + new Uint8Array(nameBytes), + ); + } catch { + throw new Error(`${this.label}: module ${ordinal} has invalid UTF-8 name`); + } + if (name.length === 0) { + throw new Error(`${this.label}: module ${ordinal} has an empty name`); + } + const moduleBytes = new Uint8Array( + new Uint8Array( + this.memory.buffer, + address + MODULE_HEADER_SIZE + align8(nameLength), + bytesLength, + ), + ); + const providerBytes = new Uint8Array( + new Uint8Array( + this.memory.buffer, + address + + MODULE_HEADER_SIZE + + align8(nameLength) + + align8(bytesLength), + providerBytesLength, + ), + ); + const providerDependencies = decodeProviderDependencies( + providerBytes, + providerCount, + `${this.label}: module ${name}`, + ); + const allocationOffset = + address + + MODULE_HEADER_SIZE + + align8(nameLength) + + align8(bytesLength) + + align8(providerBytesLength); + const allocations = Array.from( + { length: allocationCount }, + (_, index) => { + const offset = allocationOffset + index * MODULE_ALLOCATION_SIZE; + return { + address: this.readU64( + offset, + `${name} allocation ${index} address`, + ), + size: this.readU64( + offset + 8, + `${name} allocation ${index} size`, + ), + mappingAddress: this.readU64( + offset + 16, + `${name} allocation ${index} mapping address`, + ), + mappingSize: this.readU64( + offset + 24, + `${name} allocation ${index} mapping size`, + ), + }; + }, + ); + const expectedDigest = new Uint8Array( + this.memory.buffer, + address + MODULE_DIGEST_OFFSET, + MODULE_DIGEST_SIZE, + ); + if (!equalBytes(expectedDigest, computeForkModuleTemplateIdSync(moduleBytes))) { + throw new Error(`${this.label}: module ${name} failed archive SHA-256 validation`); + } + const activationId = view.getUint32(address + 48, true); + const handle = view.getUint32(address + 52, true); + const refCount = view.getUint32(address + 56, true); + if ((handle === 0) !== (refCount === 0)) { + throw new Error(`${this.label}: module ${name} has inconsistent handle/refcount`); + } + const initializing = (flags & MODULE_FLAG_INITIALIZING) !== 0; + const transactionToken = view.getUint32(address + 104, true); + const stageCode = view.getUint32(address + 108, true); + const initializationTableIndex = this.readU64( + address + 112, + `${name} initialization table index`, + ); + if ( + initializing + !== ( + transactionToken !== 0 + && stageCode !== 0 + && initializationTableIndex !== 0 + ) + ) { + throw new Error( + `${this.label}: module ${name} has inconsistent initialization metadata`, + ); + } + const state: DylinkForkLibraryState = { + name, + moduleBytes, + memoryBase: this.readU64(address + 24, `${name} memory base`), + tableBase: this.readU64(address + 32, `${name} table base`), + activationId: activationId === 0 ? undefined : activationId, + tlsBase: this.optionalPositiveU64(address + 40, `${name} TLS base`), + globalVisibility: (flags & MODULE_FLAG_GLOBAL) !== 0, + committedGlobalRoot: + (flags & MODULE_FLAG_COMMITTED_GLOBAL_ROOT) !== 0 + ? true + : undefined, + ...(providerDependencies.length === 0 + ? {} + : { providerDependencies }), + ...(allocations.length === 0 ? {} : { allocations }), + handle: handle === 0 ? undefined : handle, + refCount: refCount === 0 ? undefined : refCount, + ...(initializing + ? { + initialization: { + transactionToken, + stage: decodeInitializationStage( + stageCode, + `${this.label}: module ${name}`, + ), + tableIndex: initializationTableIndex, + }, + } + : {}), + }; + this.validateLibrary(state, Number.MAX_SAFE_INTEGER); + return { + allocation: { address, size: allocationSize }, + state, + }; + } + + private validateState(state: DylinkForkState): DylinkForkState { + const nextHandle = checkedNextHandle(state.nextHandle); + checkedU32( + state.libraries.length, + `${this.label}: live module count`, + ); + const names = new Set(); + const activations = new Set(); + const handles = new Set(); + const libraries = state.libraries.map((library) => { + this.validateLibrary(library, nextHandle); + if (names.has(library.name)) { + throw new Error(`${this.label}: duplicate live module ${library.name}`); + } + names.add(library.name); + if (library.activationId !== undefined) { + if (activations.has(library.activationId)) { + throw new Error( + `${this.label}: duplicate live activation ${library.activationId}`, + ); + } + activations.add(library.activationId); + } + if (library.handle !== undefined) { + if (handles.has(library.handle)) { + throw new Error(`${this.label}: duplicate live handle ${library.handle}`); + } + handles.add(library.handle); + } + return this.copyLibrary(library); + }); + const ownedMappings = libraries + .flatMap((library) => + (library.allocations ?? []).map((allocation) => ({ + name: library.name, + start: allocation.mappingAddress, + end: allocation.mappingAddress + allocation.mappingSize, + })) + ) + .sort((left, right) => left.start - right.start); + for (let index = 1; index < ownedMappings.length; index++) { + const previous = ownedMappings[index - 1]!; + const current = ownedMappings[index]!; + if (current.start < previous.end) { + throw new Error( + `${this.label}: ${previous.name} and ${current.name} own overlapping mappings`, + ); + } + } + for (const library of libraries) { + for (const dependency of library.providerDependencies ?? []) { + if (!names.has(dependency)) { + throw new Error( + `${this.label}: ${library.name} names absent runtime provider ` + + dependency, + ); + } + } + } + const transactionTokens = new Set(); + const transactions = (state.transactions ?? []).map((transaction) => { + const owned = this.validateTransaction(transaction); + if (transactionTokens.has(owned.token)) { + throw new Error( + `${this.label}: duplicate staged transaction ${owned.token}`, + ); + } + transactionTokens.add(owned.token); + return owned; + }); + const initializationCounts = new Map(); + for (const library of libraries) { + const initialization = library.initialization; + if (!initialization) continue; + if (!transactionTokens.has(initialization.transactionToken)) { + throw new Error( + `${this.label}: ${library.name} names absent staged transaction ` + + `${initialization.transactionToken}`, + ); + } + initializationCounts.set( + initialization.transactionToken, + (initializationCounts.get(initialization.transactionToken) ?? 0) + 1, + ); + } + for (const transaction of transactions) { + if (initializationCounts.get(transaction.token) !== 1) { + throw new Error( + `${this.label}: staged transaction ${transaction.token} must own ` + + "exactly one issued initialization entry", + ); + } + } + return { + nextHandle, + libraries, + ...(transactions.length === 0 ? {} : { transactions }), + }; + } + + private validateTransaction( + state: DylinkForkTransactionState, + ): DylinkForkTransactionState { + const token = checkedU32( + state.token, + `${this.label}: staged transaction token`, + false, + ); + if (typeof state.name !== "string" || state.name.length === 0) { + throw new TypeError( + `${this.label}: staged transaction ${token} has an empty name`, + ); + } + if ( + !(state.moduleBytes instanceof Uint8Array) + || state.moduleBytes.length === 0 + ) { + throw new TypeError( + `${this.label}: staged transaction ${token} has no module bytes`, + ); + } + if (typeof state.globalVisibility !== "boolean") { + throw new TypeError( + `${this.label}: staged transaction ${token} has invalid visibility`, + ); + } + return this.copyTransaction({ + token, + name: state.name, + moduleBytes: state.moduleBytes, + globalVisibility: state.globalVisibility, + }); + } + + private validateLibrary( + state: DylinkForkLibraryState, + nextHandle: number, + ): void { + if (typeof state.name !== "string" || state.name.length === 0) { + throw new TypeError(`${this.label}: live module name is empty`); + } + if (!(state.moduleBytes instanceof Uint8Array) || state.moduleBytes.length === 0) { + throw new TypeError(`${this.label}: ${state.name} has no owned module bytes`); + } + if (typeof state.globalVisibility !== "boolean") { + throw new TypeError(`${this.label}: ${state.name} has invalid visibility`); + } + if (state.committedGlobalRoot && !state.globalVisibility) { + throw new Error( + `${this.label}: ${state.name} is a committed GLOBAL root but is LOCAL`, + ); + } + canonicalProviderDependencies(state); + const allocations = canonicalMemoryAllocations(state); + for (const [index, allocation] of allocations.entries()) { + if ( + allocation.mappingAddress + > this.memory.buffer.byteLength - allocation.mappingSize + ) { + throw new RangeError( + `${this.label}: ${state.name} allocation ${index} escapes linear memory`, + ); + } + } + checkedAddress(state.memoryBase, `${state.name} memory base`, true); + checkedAddress(state.tableBase, `${state.name} table base`, true); + if (state.activationId !== undefined) { + checkedU32(state.activationId, `${state.name} activation id`, false); + } + if (state.tlsBase !== undefined) { + checkedAddress(state.tlsBase, `${state.name} TLS base`); + } + const hasHandle = state.handle !== undefined; + if (hasHandle !== (state.refCount !== undefined)) { + throw new Error(`${this.label}: ${state.name} handle/refcount presence differs`); + } + if (hasHandle) { + const handle = checkedU32(state.handle!, `${state.name} handle`, false); + if (handle < FIRST_DYLINK_HANDLE || handle >= nextHandle) { + throw new RangeError(`${this.label}: ${state.name} handle ${handle} is out of range`); + } + checkedU32(state.refCount!, `${state.name} refcount`, false); + } + if (state.initialization !== undefined) { + checkedU32( + state.initialization.transactionToken, + `${state.name} initialization transaction`, + false, + ); + initializationStageCode(state.initialization.stage); + checkedAddress( + state.initialization.tableIndex, + `${state.name} initialization table index`, + ); + if (hasHandle) { + throw new Error( + `${this.label}: initializing module ${state.name} already has a handle`, + ); + } + } + } + + private requireImmutableMatch( + current: DylinkForkLibraryState, + next: DylinkForkLibraryState, + ): void { + const currentAllocations = canonicalMemoryAllocations(current); + const nextAllocations = canonicalMemoryAllocations(next); + if ( + current.memoryBase !== next.memoryBase + || current.tableBase !== next.tableBase + || current.activationId !== next.activationId + || currentAllocations.length !== nextAllocations.length + || currentAllocations.some((allocation, index) => { + const expected = nextAllocations[index]; + return ( + expected === undefined + || allocation.address !== expected.address + || allocation.size !== expected.size + || allocation.mappingAddress !== expected.mappingAddress + || allocation.mappingSize !== expected.mappingSize + ); + }) + || ( + current.tlsBase !== next.tlsBase + && current.initialization === undefined + ) + || !equalBytes(current.moduleBytes, next.moduleBytes) + ) { + throw new Error( + `${this.label}: live module ${next.name} changed immutable archive identity`, + ); + } + } + + private writeMutableState( + address: number, + state: DylinkForkLibraryState, + ): void { + const view = new DataView(this.memory.buffer); + this.writeU64(address + 40, state.tlsBase ?? 0); + view.setUint32(address + 52, state.handle ?? 0, true); + view.setUint32(address + 56, state.refCount ?? 0, true); + view.setUint32( + address + 68, + (state.initialization === undefined ? 0 : MODULE_FLAG_INITIALIZING) + | (state.globalVisibility ? MODULE_FLAG_GLOBAL : 0) + | ( + state.committedGlobalRoot + ? MODULE_FLAG_COMMITTED_GLOBAL_ROOT + : 0 + ), + true, + ); + view.setUint32( + address + 104, + state.initialization?.transactionToken ?? 0, + true, + ); + view.setUint32( + address + 108, + state.initialization === undefined + ? 0 + : initializationStageCode(state.initialization.stage), + true, + ); + this.writeU64( + address + 112, + state.initialization?.tableIndex ?? 0, + ); + } + + private checkedRange(address: number, size: number, context: string): number { + checkedAddress(address, `${this.label}: ${context}`); + if ( + !Number.isSafeInteger(size) + || size <= 0 + || address > this.memory.buffer.byteLength - size + ) { + throw new RangeError(`${this.label}: ${context} escapes linear memory`); + } + return address; + } + + private readU64(address: number, context: string): number { + const value = new DataView(this.memory.buffer).getBigUint64(address, true); + if (value > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new RangeError(`${this.label}: ${context} exceeds exact host integers`); + } + return Number(value); + } + + private optionalPositiveU64( + address: number, + context: string, + ): number | undefined { + const value = this.readU64(address, context); + return value === 0 ? undefined : checkedAddress(value, context); + } + + private writeU64(address: number, value: number): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError(`${this.label}: cannot archive inexact u64 ${String(value)}`); + } + new DataView(this.memory.buffer).setBigUint64(address, BigInt(value), true); + } + + private nextGeneration(current: number): number { + if ( + !Number.isSafeInteger(current) + || current < 0 + || current >= MAX_EXACT_GENERATION + ) { + throw new RangeError(`${this.label}: dylink archive generation is exhausted`); + } + return current + 1; + } + + private readGeneration(header: number): number { + const address = header + 40; + let value: bigint; + if ( + typeof SharedArrayBuffer !== "undefined" + && this.memory.buffer instanceof SharedArrayBuffer + ) { + value = Atomics.load(new BigUint64Array(this.memory.buffer, address, 1), 0); + } else { + value = new DataView(this.memory.buffer).getBigUint64(address, true); + } + if (value > BigInt(MAX_EXACT_GENERATION)) { + throw new RangeError(`${this.label}: dylink archive generation is inexact`); + } + return Number(value); + } + + private writeGeneration(header: number, generation: number): void { + if ( + !Number.isSafeInteger(generation) + || generation <= 0 + || generation > MAX_EXACT_GENERATION + ) { + throw new RangeError(`${this.label}: invalid dylink archive generation`); + } + const address = header + 40; + if ( + typeof SharedArrayBuffer !== "undefined" + && this.memory.buffer instanceof SharedArrayBuffer + ) { + Atomics.store( + new BigUint64Array(this.memory.buffer, address, 1), + 0, + BigInt(generation), + ); + } else { + new DataView(this.memory.buffer).setBigUint64( + address, + BigInt(generation), + true, + ); + } + } + + private copyLibrary(state: DylinkForkLibraryState): DylinkForkLibraryState { + const providerDependencies = canonicalProviderDependencies(state); + const allocations = canonicalMemoryAllocations(state); + return { + name: state.name, + moduleBytes: new Uint8Array(state.moduleBytes), + memoryBase: state.memoryBase, + tableBase: state.tableBase, + ...(state.activationId === undefined + ? {} + : { activationId: state.activationId }), + ...(state.tlsBase === undefined ? {} : { tlsBase: state.tlsBase }), + globalVisibility: state.globalVisibility, + ...(state.committedGlobalRoot + ? { committedGlobalRoot: true } + : {}), + ...(providerDependencies.length === 0 + ? {} + : { providerDependencies }), + ...(allocations.length === 0 ? {} : { allocations }), + ...(state.handle === undefined ? {} : { handle: state.handle }), + ...(state.refCount === undefined ? {} : { refCount: state.refCount }), + ...(state.initialization === undefined + ? {} + : { + initialization: { + transactionToken: state.initialization.transactionToken, + stage: state.initialization.stage, + tableIndex: state.initialization.tableIndex, + }, + }), + }; + } + + private copyTransaction( + state: DylinkForkTransactionState, + ): DylinkForkTransactionState { + return { + token: state.token, + name: state.name, + moduleBytes: new Uint8Array(state.moduleBytes), + globalVisibility: state.globalVisibility, + }; + } + + private copyTablePatch( + patch: DylinkForkTablePatch, + ): DylinkForkTablePatch { + return { + ...(patch.generation === undefined + ? {} + : { generation: patch.generation }), + activationId: patch.activationId, + ownerId: patch.ownerId, + start: patch.start, + tableLength: patch.tableLength, + runs: patch.runs.map((run) => ({ + length: run.length, + function: run.function === null + ? null + : { + activationId: run.function.activationId, + ordinal: run.function.ordinal, + }, + })), + }; + } + + private copyState( + state: DylinkForkArchiveSnapshot, + ): DylinkForkArchiveSnapshot { + return { + generation: state.generation, + tableStateRoot: state.tableStateRoot, + tableCheckpointGeneration: state.tableCheckpointGeneration, + tablePatches: state.tablePatches.map((patch) => + this.copyTablePatch(patch) + ), + nextHandle: state.nextHandle, + libraries: state.libraries.map((library) => this.copyLibrary(library)), + ...(state.transactions === undefined || state.transactions.length === 0 + ? {} + : { + transactions: state.transactions.map((transaction) => + this.copyTransaction(transaction) + ), + }), + }; + } +} diff --git a/host/src/dylink.ts b/host/src/dylink.ts index b6cd9cfff8..c156a55e1b 100644 --- a/host/src/dylink.ts +++ b/host/src/dylink.ts @@ -17,15 +17,17 @@ import { WPK_FORK_REQUIRED_EXPORTS, WPK_FORK_REQUIRED_IMPORTS, } from "./generated/abi"; -import { extractAbiVersion } from "./constants"; import { - ContinuationAllocationError, - invokeForkContinuationBegin, - LinkedForkContinuation, - readLinkedFrameFormat, - type ContinuationAllocate, - type ContinuationDeallocate, -} from "./fork-continuation"; + describeWasmForkArtifactContractFailures, + extractAbiVersion, + readWasmFunctionImports, + type WasmFunctionImportType, +} from "./constants"; +import { + FORK_UNWIND_TAG_IMPORT_MODULE, + FORK_UNWIND_TAG_IMPORT_NAME, + requireForkUnwindTag, +} from "./fork-unwind-transport"; // dylink.0 sub-section types const WASM_DYLINK_MEM_INFO = 1; @@ -40,6 +42,12 @@ const WASM_DYLINK_FLAG_WEAK = 0x02; export const SIDE_MODULE_FORK_EXPORTS = WPK_FORK_REQUIRED_EXPORTS.map( ({ name }) => name, ); +const SIDE_MODULE_FORK_EXPORT_SET: ReadonlySet = + new Set(SIDE_MODULE_FORK_EXPORTS); + +function isForkRuntimeExport(name: string): boolean { + return SIDE_MODULE_FORK_EXPORT_SET.has(name); +} export const FORK_CAPABILITIES_SECTION = WPK_FORK_CAPABILITIES_SECTION; export const FORK_CAPABILITIES_VERSION = WPK_FORK_CAPABILITIES_VERSION; @@ -49,11 +57,6 @@ export const FORK_CAP_ACTIVATION_STATE_SAFE = WPK_FORK_CAP_ACTIVATION_STATE_SAFE const FORK_CAP_KNOWN_MASK = WPK_FORK_CAP_KNOWN_MASK; export const FORK_CAPABILITIES_REQUIRED_ABI = 17; -const WPK_FORK_NORMAL = 0; -const WPK_FORK_UNWINDING = 1; -const WPK_FORK_REWINDING = 2; -const WPK_FORK_ABORT_UNWINDING = 3; - export interface ForkInstrumentCapabilityClaim { /** False for an ABI-16 artifact built before role markers were introduced. */ present: boolean; @@ -320,6 +323,39 @@ function requireWasmAddress( return value; } +function copyForkMemoryAllocation( + allocation: DylinkForkMemoryAllocation, + context: string, +): DylinkForkMemoryAllocation { + const fields = [ + ["address", allocation.address], + ["size", allocation.size], + ["mapping address", allocation.mappingAddress], + ["mapping size", allocation.mappingSize], + ] as const; + for (const [field, value] of fields) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new RangeError(`${context}: ${field} is not an exact positive integer`); + } + } + const logicalEnd = allocation.address + allocation.size; + const mappingEnd = allocation.mappingAddress + allocation.mappingSize; + if ( + !Number.isSafeInteger(logicalEnd) + || !Number.isSafeInteger(mappingEnd) + || allocation.address < allocation.mappingAddress + || logicalEnd > mappingEnd + ) { + throw new RangeError(`${context}: logical allocation escapes its process mapping`); + } + return Object.freeze({ + address: allocation.address, + size: allocation.size, + mappingAddress: allocation.mappingAddress, + mappingSize: allocation.mappingSize, + }); +} + function tableAddress( table: WebAssembly.Table, value: number, @@ -375,6 +411,17 @@ function growMemory(memory: WebAssembly.Memory, delta: number, ptrWidth: 4 | 8): /** * Shared library instance loaded into a process's address space. */ +export interface DylinkForkMemoryAllocation { + /** Aligned address returned to the side module. */ + readonly address: number; + /** Logical byte count requested by the side module. */ + readonly size: number; + /** Exact mmap base owned by the process allocator. */ + readonly mappingAddress: number; + /** Exact mmap byte count that must be passed to munmap. */ + readonly mappingSize: number; +} + export interface LoadedSharedLibrary { /** Wasm module instance */ instance: WebAssembly.Instance; @@ -388,43 +435,169 @@ export interface LoadedSharedLibrary { metadata: DylinkMetadata; /** Path/name of the library */ name: string; - /** Fork save buffer for an instrumented side module importing env.fork. */ - forkBufAddr?: number; - forkContinuation?: LinkedForkContinuation; + /** Immutable loader-owned snapshot used by dependency-first fork archives. */ + moduleBytes: Uint8Array; + /** Stable process activation coordinate persisted in the dlopen archive. */ + activationId?: number; + /** + * Exact process-table entries whose callable values belong to this module. + * + * Table length cannot shrink, so final unload clears these slots to null. + * Gaps stay addressable and a later archive preserves their positions. + */ + ownedTableEntries: readonly number[]; + /** GOT cells consumed by this module, with their exact symbol kind. */ + gotImports: readonly Readonly<{ + name: string; + kind: "mem" | "func"; + }>[]; + /** Release the registered activation exactly once on final unload. */ + unregisterForkActivation?: () => void; /** Thread-local-storage base captured from the parent instance. */ tlsBase?: number; - /** Whether this module can originate a coordinated env.fork unwind. */ - forkCapable: boolean; - /** Function/GOT.func imports used for conservative cross-side isolation. */ - functionImports: ReadonlySet; - /** Function exports visible to later side modules. */ - functionExports: ReadonlySet; - /** Dynamic lookup from a side module defeats static cross-side isolation. */ - importsDynamicLookup: boolean; + /** Provisional objects are visible to nested loader transactions. */ + loadState?: "initializing" | "loaded"; + /** Whether this object contributes exports to the RTLD_DEFAULT scope. */ + globalVisibility: boolean; + /** + * True when a completed RTLD_GLOBAL dlopen selected this object as its root. + * + * WHY: an outer constructor can promote a pre-existing LOCAL closure and + * then fail after a nested, independently committed GLOBAL open. Rollback + * must undo only the outer promotion and reapply the surviving root. + */ + committedGlobalRoot?: boolean; + /** Present only while libc owns an issued loader entry. */ + initialization?: Readonly<{ + transactionToken: number; + stage: DylinkInitializationStage; + tableIndex: number; + }>; + /** Other side modules whose symbols this instance captured while linking. */ + providerDependencies?: ReadonlySet; + /** Process mappings owned until rollback or final unload. */ + allocations?: readonly DylinkForkMemoryAllocation[]; + /** Standalone-linker heap high-water mark owned by this object. */ + heapReservationEnd?: number; } -export interface SideModuleForkState { - name: string; - instance: WebAssembly.Instance; - forkBufAddr: number; - continuation: LinkedForkContinuation; +export interface DylinkForkActivationRequest { + readonly name: string; + readonly module: WebAssembly.Module; + readonly moduleBytes: Uint8Array; + /** Exact archived coordinate in a fresh fork child; absent in the parent. */ + readonly replayActivationId?: number; } /** - * Process-worker coordination for the one supported side-module fork shape: - * a main-module call_indirect directly invokes one instrumented side module. - * The loader rejects statically visible side-to-side linkage and side-owned - * dlopen/dlsym around a fork-capable module. Opaque callbacks passed through - * main memory or the shared table cannot yet be attributed to a module at - * runtime and remain an explicit unsupported residual. + * One pre-instantiation reservation from the process activation coordinator. + * + * `env` owns every fork/frame/module/reference/exception/GC import, including + * `fork` itself. The loader only binds those values; it does not keep a + * module-local continuation or infer which activation is currently active. */ -export interface SideModuleForkSupport { - setActiveFork: (state: SideModuleForkState) => void; - clearActiveFork: (state: SideModuleForkState) => void; - /** Invoke the immutable main-module fork trampoline and verify its state. */ - invokeMainFork: (expectedStateAfter: 0 | 1 | readonly (0 | 1)[]) => number; - /** Put the already-unwinding main image into allocation-failure replay. */ - beginMainAbort: (errno: number) => void; +export interface PreparedDylinkForkActivation { + readonly activationId: number; + readonly env: Readonly>; + /** + * Wrap the loader's final lazy import object immediately before + * instantiation. Imported-global/table ownership observes the engine's exact + * property reads, including duplicate `(module, name)` declarations. + */ + wrapImports(imports: WebAssembly.Imports): WebAssembly.Imports; + register(instance: WebAssembly.Instance): void; + unregister(): void; +} + +export interface DylinkForkActivationOwner { + prepare(request: DylinkForkActivationRequest): PreparedDylinkForkActivation; +} + +/** Compact live linker state persisted by the process fork archive. */ +export interface DylinkForkLibraryState { + readonly name: string; + /** Loader-owned immutable-by-contract artifact snapshot. */ + readonly moduleBytes: Readonly; + readonly memoryBase: number; + readonly tableBase: number; + readonly activationId?: number; + readonly tlsBase?: number; + readonly globalVisibility: boolean; + readonly committedGlobalRoot?: boolean; + /** + * Runtime symbol providers captured outside immutable DT_NEEDED edges. + * + * Constructor dlsym calls are not re-executed in a fresh child, so their + * lifetime edges must be explicit reconstruction data. + */ + readonly providerDependencies?: readonly string[]; + /** + * Exact allocator ownership copied into a fork child. + * + * The child's linear memory and kernel mmap map are copied, but its Worker + * has fresh JavaScript bookkeeping. These recipes reconnect the two without + * issuing a second mmap or guessing the allocator's alignment padding. + */ + readonly allocations?: readonly DylinkForkMemoryAllocation[]; + /** Absent when the module is live only as a NEEDED dependency. */ + readonly handle?: number; + /** Present exactly when `handle` is present. */ + readonly refCount?: number; + /** Durable continuation point for one libc-driven initialization call. */ + readonly initialization?: Readonly<{ + transactionToken: number; + stage: DylinkInitializationStage; + tableIndex: number; + }>; +} + +export interface DylinkForkTransactionState { + readonly token: number; + readonly name: string; + readonly moduleBytes: Readonly; + readonly globalVisibility: boolean; +} + +export interface DylinkForkState { + readonly nextHandle: number; + /** Dependency-first `loadedLibraries` insertion order. */ + readonly libraries: readonly DylinkForkLibraryState[]; + /** Outer-to-inner loader transactions stopped in ordinary Wasm calls. */ + readonly transactions?: readonly DylinkForkTransactionState[]; +} + +export interface DylinkForkPublishedState extends DylinkForkState { + /** Monotonic archive publication observed under the process reader lock. */ + readonly generation: number; +} + +interface PendingDlopenTransaction { + readonly token: number; + readonly name: string; + readonly moduleBytes: Uint8Array; + readonly globalVisibility: boolean; + readonly steps: Generator< + DylinkInitializationStep, + LoadedSharedLibrary, + DylinkForkLibraryState | undefined + >; + /** + * Mutable view captured by `loadSharedLibrarySyncSteps`. + * + * A replica may first observe a dependency initializer and only learn the + * root module's exact layout in a later archive generation. Updating this + * map lets the suspended generator consume that later reconstruction recipe + * without allocating a Worker-local layout. + */ + readonly replayModules?: Map; + readonly initialLibraries: ReadonlySet; + readonly initialVisibility: ReadonlyMap; + readonly ownedLibraries: Set; + readonly initialHeapPointer?: number; + tableIndex?: number; + awaitingCompletion: boolean; + currentStep?: DylinkInitializationStep; + loaded?: LoadedSharedLibrary; } /** @@ -436,10 +609,9 @@ export interface SideModuleForkSupport { * null gaps up to that base but rejects a child table that already grew * past it (an interleaved dlsym, future GOT preallocation, etc.). * - `options.loadedLibraries` must NOT already contain `name`. Replay - * does not refresh existing entries; a duplicate would be silently - * deduped and return a handle whose memoryBase may not match. - * - The library must have no `dylink.0` NEEDED deps. Dep replay is not - * yet plumbed; `loadSharedLibrarySync` throws if you try. + * rejects duplicate module-load records before mutating linker state. + * - Every `dylink.0` NEEDED dependency must already have been replayed from + * its own earlier archive entry. */ export interface DylinkReplayOptions { /** Memory base returned by the parent's allocator. Data relocations in @@ -448,13 +620,23 @@ export interface DylinkReplayOptions { memoryBase: number; /** Exact table base observed in the parent, including failed-load gaps. */ tableBase: number; - /** Exact side-module save buffer copied from the fork parent. */ - forkBufAddr?: number; + /** Exact stable activation coordinate copied from the fork parent. */ + activationId?: number; /** Exact mutable `__tls_base` value from the fork parent. The child memory * already contains the parent's live TLS bytes, so replay restores only * this instance-local global and deliberately does not call * `__wasm_init_tls`, which would reset those bytes to the initial image. */ tlsBase?: number; + /** Rebuild a generator stopped before this direct libc table call. */ + initializationStage?: DylinkInitializationStage; + /** Exact RTLD visibility of the parent object. */ + globalVisibility?: boolean; + /** Whether this object is the root of a committed RTLD_GLOBAL open. */ + committedGlobalRoot?: boolean; + /** Exact runtime provider edges already established in the parent. */ + providerDependencies?: readonly string[]; + /** Exact live mapping ownership copied from the parent process. */ + allocations?: readonly DylinkForkMemoryAllocation[]; } /** @@ -471,16 +653,31 @@ export interface LoadSharedLibraryOptions { heapPointer?: { value: number }; /** Allocate side-module linear-memory data in the process address space */ allocateMemory?: (size: number, align: number) => number; + /** + * Describe the exact mapping behind an aligned allocateMemory result. + * + * Process Workers use this to persist raw mmap ownership for fresh-Worker + * replay. Embedders whose allocator/deallocator use the logical range may + * omit it. + */ + describeMemoryAllocation?: ( + address: number, + size: number, + ) => Readonly<{ mappingAddress: number; mappingSize: number }>; + /** Adopt copied mapping ownership without allocating new process memory. */ + adoptMemoryAllocation?: (allocation: DylinkForkMemoryAllocation) => void; + /** Drop Worker-local ownership after another pthread published the unload. */ + forgetMemoryAllocation?: (allocation: DylinkForkMemoryAllocation) => void; /** Release a successful allocateMemory result when loading rolls back. */ deallocateMemory?: (addr: number, size: number) => void; - /** Page-granular process mapping used only for linked continuation chunks. */ - allocateContinuation?: ContinuationAllocate; - /** Release one inherited or parent-owned continuation mapping. */ - deallocateContinuation?: ContinuationDeallocate; /** Global symbol table: name → function or WebAssembly.Global */ globalSymbols: Map; + /** Defining side module for each global symbol; absent means the main image. */ + globalSymbolOwners?: Map; /** GOT entries: symbol name → mutable pointer-width WebAssembly.Global */ got: Map; + /** Internal exact type of every live GOT cell. */ + gotKinds?: Map; /** Already-loaded libraries for dedup and dependency resolution */ loadedLibraries: Map; /** @@ -495,18 +692,218 @@ export interface LoadSharedLibraryOptions { * matching payload type, so this must not be allocated per dlopen. */ cppExceptionTag?: WebAssembly.Tag; + /** + * Private unwind transport shared by the main image and every instrumented + * side module in this process Worker. + */ + forkUnwindTag?: WebAssembly.Tag; /** Process pointer width, which also determines the __c_longjmp payload. */ ptrWidth?: 4 | 8; - /** Immutable symbol names exported by the main module. */ - mainModuleSymbols?: ReadonlySet; - /** Present only in a process worker that can drive side-module unwind. */ - sideModuleFork?: SideModuleForkSupport; - /** Precise rebuild/boundary diagnostic when sideModuleFork is unavailable. */ - sideModuleForkUnavailableReason?: string; - /** Callback to locate and read a library file by name (async version) */ - resolveLibrary?: (name: string) => Promise; - /** Callback to locate and read a library file by name (sync version) */ - resolveLibrarySync?: (name: string) => Uint8Array | null; + /** Process owner for every ABI-43 side-module activation. */ + forkActivationOwner?: DylinkForkActivationOwner; + /** Precise rebuild/boundary diagnostic when the owner is unavailable. */ + forkActivationOwnerUnavailableReason?: string; + /** + * Journal host-created function-table entries (currently dlsym of a main + * export) into the same activation-owned sparse table state as Wasm writes. + */ + onTableMutation?: ( + table: WebAssembly.Table, + firstIndex: number, + length: number, + ) => void; + /** + * Route the exact final function import through the process Worker owner. + * This runs at Proxy property resolution so duplicate declarations and + * activation-owned exception identities are not collapsed eagerly. + */ + routeFunctionImport?: ( + imported: WasmFunctionImportType, + localImplementation: CallableFunction, + ) => CallableFunction; + /** Callback to locate a dependency relative to its requesting object. */ + resolveLibrary?: ( + name: string, + requester?: string, + ) => Promise; + /** Synchronous dependency resolver used by guest dlopen(). */ + resolveLibrarySync?: ( + name: string, + requester?: string, + ) => Uint8Array | null; +} + +interface DylinkLoadContext { + readonly ownedLibraries: Set; +} + +function symbolOwners( + options: LoadSharedLibraryOptions, +): Map { + options.globalSymbolOwners ??= new Map( + Array.from(options.globalSymbols.keys(), (name) => [name, undefined]), + ); + return options.globalSymbolOwners; +} + +function functionTableIndex( + options: LoadSharedLibraryOptions, + fn: Function, +): number | undefined { + const length = tableLength(options.table); + for (let index = 0; index < length; index++) { + if (getTableEntry(options.table, index) === fn) return index; + } + return undefined; +} + +function isPublicDylinkExport( + name: string, + value: WebAssembly.ExportValue, +): value is Function | WebAssembly.Global { + return ( + !name.startsWith("__") + && !isForkRuntimeExport(name) + && ( + typeof value === "function" + || value instanceof WebAssembly.Global + ) + ); +} + +function publishGlobalLibrarySymbols( + library: LoadedSharedLibrary, + options: LoadSharedLibraryOptions, +): void { + if (!library.globalVisibility) return; + const owners = symbolOwners(options); + for (const [name, value] of Object.entries(library.exports)) { + if ( + !isPublicDylinkExport(name, value) + || options.globalSymbols.has(name) + ) { + continue; + } + options.globalSymbols.set(name, value); + owners.set(name, library.name); + } +} + +function promoteLibraryGlobal( + library: LoadedSharedLibrary, + options: LoadSharedLibraryOptions, + visited = new Set(), +): void { + if (visited.has(library.name)) return; + visited.add(library.name); + for (const dependencyName of library.metadata.neededDynlibs) { + const dependency = options.loadedLibraries.get(dependencyName); + if (!dependency) { + throw new Error( + `${library.name}: loaded dependency ${dependencyName} is missing during promotion`, + ); + } + promoteLibraryGlobal(dependency, options, visited); + } + library.globalVisibility = true; + publishGlobalLibrarySymbols(library, options); +} + +function appendDependencyScope( + scope: LoadedSharedLibrary[], + roots: readonly LoadedSharedLibrary[], + options: LoadSharedLibraryOptions, +): void { + const seen = new Set(scope.map((library) => library.name)); + const queue = [...roots]; + for (let index = 0; index < queue.length; index++) { + const library = queue[index]!; + if (seen.has(library.name)) continue; + seen.add(library.name); + scope.push(library); + for (const dependencyName of library.metadata.neededDynlibs) { + const dependency = options.loadedLibraries.get(dependencyName); + if (!dependency) { + throw new Error( + `${library.name}: loaded dependency ${dependencyName} is missing`, + ); + } + if (!seen.has(dependency.name)) queue.push(dependency); + } + } +} + +function runtimeDependencyNames( + library: LoadedSharedLibrary, +): ReadonlySet { + const dependencies = new Set([ + ...library.metadata.neededDynlibs, + ...(library.providerDependencies ?? []), + ]); + dependencies.delete(library.name); + return dependencies; +} + +function scopedSymbol( + options: LoadSharedLibraryOptions, + dependencyScope: readonly LoadedSharedLibrary[], + name: string, +): Readonly<{ + value: Function | WebAssembly.Global; + owner?: string; +}> | undefined { + const global = options.globalSymbols.get(name); + if (global !== undefined) { + return { value: global, owner: symbolOwners(options).get(name) }; + } + for (const dependency of dependencyScope) { + const value = dependency.exports[name]; + if ( + typeof value === "function" + || value instanceof WebAssembly.Global + ) { + return { value, owner: dependency.name }; + } + } + return undefined; +} + +function refreshGlobalGotEntries(options: LoadSharedLibraryOptions): void { + const ptrWidth = options.ptrWidth ?? 4; + for (const [name, entry] of options.got) { + const kind = options.gotKinds?.get(name); + if (!kind) continue; + const symbol = options.globalSymbols.get(name); + if (kind === "mem" && symbol instanceof WebAssembly.Global) { + entry.value = requireWasmAddress( + symbol.value as WasmAddress, + ptrWidth, + `GOT.mem.${name}`, + ); + continue; + } + if (kind === "func" && typeof symbol === "function") { + const index = functionTableIndex(options, symbol); + entry.value = wasmAddress( + index ?? 0, + ptrWidth, + `GOT.func.${name}`, + ); + continue; + } + entry.value = wasmAddress(0, ptrWidth, `unresolved GOT.${kind}.${name}`); + } +} + +function requireNonzeroU32(value: number, context: string): number { + if ( + !Number.isInteger(value) + || value <= 0 + || value > 0xffff_ffff + ) { + throw new RangeError(`${context} is not a nonzero u32`); + } + return value; } type TagConstructor = new ( @@ -593,68 +990,24 @@ function resolveCppExceptionTag(options: LoadSharedLibraryOptions): WebAssembly. return options.cppExceptionTag; } -const SIDE_DYNAMIC_LOOKUP_IMPORTS = new Set([ - "__wasm_dlopen", - "__wasm_dlsym", - "dlopen", - "dlsym", -]); - -function intersectSideSymbols( - imports: ReadonlySet, - exports: ReadonlySet, - mainSymbols: ReadonlySet, -): string[] { - return Array.from(imports) - .filter((name) => !mainSymbols.has(name) && exports.has(name)) - .sort(); -} - -/** - * The current two-module unwind protocol supports main -> one side module. - * It cannot serialize an intervening side-module frame. Preserve ordinary - * independent multi-extension loading, but reject statically visible - * side-to-side linkage and side-originated dynamic lookup whenever either - * participant can fork. Function pointers passed opaquely through main memory - * remain a documented residual until the runtime has module activation hooks. - */ -function enforceDirectMainSideForkBoundary( - name: string, - forkCapable: boolean, - functionImports: ReadonlySet, - functionExports: ReadonlySet, - importsDynamicLookup: boolean, - options: LoadSharedLibraryOptions, -): void { - const mainSymbols = options.mainModuleSymbols ?? new Set(); - for (const loaded of options.loadedLibraries.values()) { - if (!forkCapable && !loaded.forkCapable) continue; - - if (importsDynamicLookup || loaded.importsDynamicLookup) { - throw new Error( - `${name}: fork-capable side modules cannot coexist with side-originated ` + - `dlopen/dlsym; only a direct main-module-to-side fork path is supported`, - ); - } +export type DylinkInitializationStage = + | "bootstrap" + | "relocations" + | "constructors"; - const newToLoaded = intersectSideSymbols( - functionImports, - loaded.functionExports, - mainSymbols, - ); - const loadedToNew = intersectSideSymbols( - loaded.functionImports, - functionExports, - mainSymbols, - ); - const crossSymbols = [...newToLoaded, ...loadedToNew]; - if (crossSymbols.length > 0) { - throw new Error( - `${name}: fork-capable side-module nesting through ${loaded.name} is unsupported ` + - `(cross-side symbols: ${Array.from(new Set(crossSymbols)).join(", ")})`, - ); - } - } +export interface DylinkInitializationStep { + readonly libraryName: string; + readonly stage: DylinkInitializationStage; + /** Exact module identity that must be publishable before `invoke` runs. */ + readonly forkState: DylinkForkLibraryState; + /** + * All loader-controlled guest entries have the canonical `() -> ()` shape. + * + * A process loader may install this exact function in its shared table and + * let libc invoke it as an ordinary Wasm call. Standalone embedders drive + * the same state machine synchronously. + */ + readonly invoke: () => void; } /** @@ -662,54 +1015,68 @@ function enforceDirectMainSideForkBoundary( * side module into the process address space. Used by both async and sync * entry points. */ -function instantiateSharedLibrary( +function* instantiateSharedLibrarySteps( name: string, wasmBytes: Uint8Array, metadata: DylinkMetadata, options: LoadSharedLibraryOptions, replay?: DylinkReplayOptions, -): LoadedSharedLibrary { + loadContext?: DylinkLoadContext, + globalVisibility = true, + dependencyScope: readonly LoadedSharedLibrary[] = [], +): Generator< + DylinkInitializationStep, + LoadedSharedLibrary, + DylinkForkLibraryState | undefined +> { validateLongjmpConfiguration(options); const ptrWidth = options.ptrWidth ?? 4; const pointerGlobalType = ptrWidth === 8 ? "i64" : "i32"; const module = new WebAssembly.Module(wasmBytes as unknown as BufferSource); const moduleImports = WebAssembly.Module.imports(module); const moduleExports = WebAssembly.Module.exports(module); + const moduleExportKinds = new Map( + moduleExports.map((moduleExport) => [ + moduleExport.name, + moduleExport.kind, + ]), + ); + const functionImports = readWasmFunctionImports( + wasmBytes.buffer.slice( + wasmBytes.byteOffset, + wasmBytes.byteOffset + wasmBytes.byteLength, + ) as ArrayBuffer, + ); + const functionImportsByName = new Map(); + for (const imported of functionImports) { + const key = `${imported.module.length}:${imported.module}${imported.name}`; + const entries = functionImportsByName.get(key) ?? []; + entries.push(imported); + functionImportsByName.set(key, entries); + } + const functionImportReads = new Map(); const importsFork = moduleImports.some((imp) => imp.module === "env" && imp.name === "fork" && imp.kind === "function" ); - const linkedFrameImportNames = WPK_FORK_REQUIRED_IMPORTS + const requiredForkFunctionImportNames = WPK_FORK_REQUIRED_IMPORTS .filter(({ module }) => module === "env") .map(({ name }) => name); - const linkedFrameImportCount = linkedFrameImportNames.filter((importName) => - moduleImports.some((imp) => - imp.module === "env" && imp.name === importName && imp.kind === "function" - ) - ).length; + const requiredForkFunctionImportCount = + requiredForkFunctionImportNames.filter((importName) => + moduleImports.some((imp) => + imp.module === "env" && imp.name === importName && imp.kind === "function" + ) + ).length; const presentForkExports = SIDE_MODULE_FORK_EXPORTS.filter((exportName) => moduleExports.some((exp) => exp.kind === "function" && exp.name === exportName) ); const hasCompleteForkInstrumentation = presentForkExports.length === SIDE_MODULE_FORK_EXPORTS.length; const forkCapabilityClaim = readForkInstrumentCapabilityClaim(module); - const claimsSideEntry = - forkCapabilityClaim.present - && (forkCapabilityClaim.flags & FORK_CAP_SIDE_ENTRY) !== 0; const sideEntryAvailable = forkInstrumentRoleAvailable( forkCapabilityClaim, FORK_CAP_SIDE_ENTRY, ); - const functionImports = new Set( - moduleImports - .filter((imp) => - (imp.module === "env" && imp.kind === "function") - || imp.module === "GOT.func" - ) - .map((imp) => imp.name), - ); - const functionExports = new Set( - moduleExports.filter((exp) => exp.kind === "function").map((exp) => exp.name), - ); const importedFunctionCount = moduleImports.filter((imp) => imp.kind === "function").length; const definedFunctionExports = readDefinedFunctionExports( wasmBytes, @@ -730,11 +1097,6 @@ function instantiateSharedLibrary( ) .map((imp) => imp.name), ); - const importsDynamicLookup = moduleImports.some((imp) => - imp.module === "env" - && imp.kind === "function" - && SIDE_DYNAMIC_LOOKUP_IMPORTS.has(imp.name) - ); const importsLongjmpTag = moduleImports.some((imp) => imp.module === "env" && imp.name === "__c_longjmp" @@ -749,6 +1111,11 @@ function instantiateSharedLibrary( const cppExceptionTag = importsCppExceptionTag ? resolveCppExceptionTag(options) : undefined; + const importsForkUnwindTag = moduleImports.some((imp) => + imp.module === FORK_UNWIND_TAG_IMPORT_MODULE + && imp.name === FORK_UNWIND_TAG_IMPORT_NAME + && (imp.kind as string) === "tag" + ); if (presentForkExports.length > 0 && !hasCompleteForkInstrumentation) { const missing = SIDE_MODULE_FORK_EXPORTS.filter((exportName) => @@ -758,6 +1125,12 @@ function instantiateSharedLibrary( `${name}: incomplete wasm-fork-instrument exports; missing ${missing.join(", ")}`, ); } + if (options.forkActivationOwner && !hasCompleteForkInstrumentation) { + throw new Error( + `${name}: fork-capable process requires complete ABI 43 side-boundary ` + + "instrumentation; rebuild the side module with wasm-fork-instrument", + ); + } if ( hasCompleteForkInstrumentation && ( @@ -788,6 +1161,14 @@ function instantiateSharedLibrary( `but the host requires ABI ${ABI_VERSION}`, ); } + const contractFailures = + describeWasmForkArtifactContractFailures(artifactBytes); + if (contractFailures.length > 0) { + throw new Error( + `${name}: invalid ABI 43 fork reconstruction contract: ` + + contractFailures.join("; "), + ); + } } if (importsFork && !hasCompleteForkInstrumentation) { throw new Error( @@ -798,54 +1179,143 @@ function instantiateSharedLibrary( if (importsFork && !sideEntryAvailable) { throw new Error( `${name}: env.fork requires the versioned side-entry capability; ` + - "rebuild with the current wasm-fork-instrument --entry env.fork", + "rebuild with the current wasm-fork-instrument --entry env.fork", + ); + } + if ( + options.forkActivationOwner + && hasCompleteForkInstrumentation + && !sideEntryAvailable + ) { + throw new Error( + `${name}: fork-capable process requires the versioned side-entry ` + + "boundary capability; rebuild the side module", ); } - if (linkedFrameImportCount !== 0 && linkedFrameImportCount !== linkedFrameImportNames.length) { + if ( + requiredForkFunctionImportCount !== 0 + && requiredForkFunctionImportCount !== requiredForkFunctionImportNames.length + ) { throw new Error(`${name}: incomplete linked fork instrumentation imports; rebuild the module`); } - if (importsFork && linkedFrameImportCount !== linkedFrameImportNames.length) { + if ( + importsFork + && requiredForkFunctionImportCount !== requiredForkFunctionImportNames.length + ) { throw new Error(`${name}: env.fork requires ABI 43 linked continuation imports`); } - if (claimsSideEntry && !importsFork) { - throw new Error(`${name}: side-entry capability is present without an env.fork import`); + if (hasCompleteForkInstrumentation && !options.forkActivationOwner) { + throw new Error( + `${name}: fork activation cannot be coordinated: ` + + (options.forkActivationOwnerUnavailableReason + ?? "ABI 43 side modules require a process activation owner"), + ); + } + if ( + hasCompleteForkInstrumentation + && replay !== undefined + && replay.activationId === undefined + ) { + throw new Error(`${name}: fork replay is missing its archived activation id`); } - if (importsFork && !options.sideModuleFork) { + const replayActivationId = replay?.activationId === undefined + ? undefined + : requireNonzeroU32( + replay.activationId, + `${name}: archived activation id`, + ); + if (!hasCompleteForkInstrumentation && replay?.activationId !== undefined) { throw new Error( - `${name}: env.fork cannot be coordinated: ` + - (options.sideModuleForkUnavailableReason - ?? "side-module fork requires a process-worker unwind coordinator"), + `${name}: fork replay supplied an activation id for an uninstrumented module`, ); } - enforceDirectMainSideForkBoundary( - name, - importsFork, - functionImports, - functionExports, - importsDynamicLookup, - options, - ); const tableRollbackBase = tableLength(options.table); const heapRollbackValue = options.heapPointer?.value; const symbolRollback = new Map(options.globalSymbols); + const owners = symbolOwners(options); + const ownerRollback = new Map(owners); const gotRollback = new Map( Array.from(options.got, ([symbol, global]) => [ symbol, { global, value: global.value }, ] as const), ); - const allocations: Array<{ addr: number; size: number }> = []; + const gotKinds = options.gotKinds ??= new Map(); + const gotKindsRollback = new Map(gotKinds); + const allocations: DylinkForkMemoryAllocation[] = []; + const ownedTableEntries = new Set(); + const gotImports = new Map(); + const localGot = new Map(); + const providerDependencies = new Set( + replay?.providerDependencies ?? [], + ); + const recordProvider = (owner: string | undefined): void => { + if (owner !== undefined && owner !== name) { + providerDependencies.add(owner); + } + }; + let preparedActivation: PreparedDylinkForkActivation | undefined; + let preparedActivationId: number | undefined; + let provisionalLibrary: LoadedSharedLibrary | undefined; + let forkActivationReleased = false; + const unregisterForkActivation = (): void => { + if (!preparedActivation || forkActivationReleased) return; + // WHY: registration may have partially succeeded before throwing. The + // owner's teardown is the only authority that can release the activation + // ID, resume catalog, typed roots, and continuation binding atomically. + forkActivationReleased = true; + preparedActivation.unregister(); + }; const allocate = (size: number, align: number): number => { if (!options.allocateMemory) { throw new Error(`${name}: no side-module memory allocator configured`); } - const addr = options.allocateMemory(size, align); - allocations.push({ addr, size }); - return addr; + const address = options.allocateMemory(size, align); + const described = options.describeMemoryAllocation?.(address, size); + allocations.push(copyForkMemoryAllocation({ + address, + size, + mappingAddress: described?.mappingAddress ?? address, + mappingSize: described?.mappingSize ?? size, + }, `${name}: allocated side-module memory`)); + return address; }; try { + if (hasCompleteForkInstrumentation) { + preparedActivation = options.forkActivationOwner!.prepare({ + name, + module, + moduleBytes: wasmBytes, + replayActivationId, + }); + if ( + !preparedActivation + || typeof preparedActivation !== "object" + || typeof preparedActivation.wrapImports !== "function" + || typeof preparedActivation.register !== "function" + || typeof preparedActivation.unregister !== "function" + || !preparedActivation.env + || typeof preparedActivation.env !== "object" + ) { + throw new TypeError(`${name}: activation owner returned an invalid preparation`); + } + preparedActivationId = requireNonzeroU32( + preparedActivation.activationId, + `${name}: prepared activation id`, + ); + if ( + replayActivationId !== undefined + && preparedActivationId !== replayActivationId + ) { + throw new Error( + `${name}: activation owner returned ${preparedActivationId}, ` + + `but replay requires ${replayActivationId}`, + ); + } + } + // Allocate memory region const memAlign = 1 << metadata.memoryAlign; let memoryBase = 0; @@ -854,6 +1324,47 @@ function instantiateSharedLibrary( // Reuse parent's memoryBase: data-reloc'd pointers baked into the // memcpy'd data section already encode (parentMemoryBase + offset). memoryBase = replay.memoryBase; + const archivedAllocations = (replay.allocations ?? []).map( + (allocation, index) => copyForkMemoryAllocation( + allocation, + `${name}: archived allocation ${index}`, + ), + ); + if ( + archivedAllocations.length !== 0 + && ( + archivedAllocations.length !== 1 + || archivedAllocations[0]!.address !== memoryBase + || archivedAllocations[0]!.size !== metadata.memorySize + ) + ) { + throw new Error( + `${name}: archived allocation does not match its dylink memory region`, + ); + } + if ( + options.adoptMemoryAllocation + && archivedAllocations.length === 0 + ) { + throw new Error( + `${name}: fork replay is missing process mapping ownership`, + ); + } + for (const allocation of archivedAllocations) { + if ( + allocation.mappingAddress + > options.memory.buffer.byteLength - allocation.mappingSize + ) { + throw new RangeError( + `${name}: archived process mapping escapes copied linear memory`, + ); + } + // WHY: fork copied both kernel mmap state and the bytes, but the new + // Worker has an empty JS allocator index. Adopt that ownership; do + // not allocate or zero a second region. + options.adoptMemoryAllocation?.(allocation); + allocations.push(allocation); + } } else if (options.allocateMemory) { memoryBase = allocate(metadata.memorySize, memAlign); const end = memoryBase + metadata.memorySize; @@ -884,6 +1395,8 @@ function instantiateSharedLibrary( // post-startup data via fork memcpy. new Uint8Array(options.memory.buffer, memoryBase, metadata.memorySize).fill(0); } + } else if ((replay?.allocations?.length ?? 0) !== 0) { + throw new Error(`${name}: zero-memory side module owns archived mappings`); } // Reproduce the parent's exact table base, including null gaps left by a @@ -904,25 +1417,10 @@ function instantiateSharedLibrary( } tableBase = replay.tableBase; } - if (metadata.tableSize > 0) growTable(options.table, metadata.tableSize); - - let sideForkBufAddr = 0; - let sideForkContinuation: LinkedForkContinuation | undefined; - if (importsFork) { - if (!options.allocateContinuation || !options.deallocateContinuation) { - throw new Error( - `${name}: linked continuations require process-mapping allocation and cleanup`, - ); - } - sideForkContinuation = new LinkedForkContinuation( - options.memory, - readLinkedFrameFormat(module), - options.allocateContinuation, - options.deallocateContinuation, - name, - ); - if (replay) { - sideForkBufAddr = replay.forkBufAddr ?? 0; + if (metadata.tableSize > 0) { + growTable(options.table, metadata.tableSize); + for (let index = 0; index < metadata.tableSize; index++) { + ownedTableEntries.add(tableBase + index); } } @@ -957,14 +1455,14 @@ function instantiateSharedLibrary( // function at runtime, the function must live in the shared // indirect_function_table and the GOT entry must hold its index. const tableIndexFor = (fn: Function): number => { + const existing = functionTableIndex(options, fn); + if (existing !== undefined) return existing; const tbl = options.table; const length = tableLength(tbl); - for (let i = 0; i < length; i++) { - if (getTableEntry(tbl, i) === fn) return i; - } const idx = length; growTable(tbl, 1); setTableEntry(tbl, idx, fn); + options.onTableMutation?.(tbl, idx, 1); return idx; }; @@ -972,19 +1470,88 @@ function instantiateSharedLibrary( symName: string, kind: "mem" | "func", ): WebAssembly.Global => { + const resolved = scopedSymbol(options, dependencyScope, symName); + recordProvider(resolved?.owner); + if ( + resolved + && ( + (kind === "mem" && !(resolved.value instanceof WebAssembly.Global)) + || (kind === "func" && typeof resolved.value !== "function") + ) + ) { + throw new Error( + `${name}: GOT.${kind} symbol ${symName} has the wrong kind`, + ); + } + const resolvedFunctionIndex = + kind === "func" && typeof resolved?.value === "function" + ? tableIndexFor(resolved.value) + : undefined; + const globallyResolved = + resolved !== undefined + && options.globalSymbols.get(symName) === resolved.value; + const localKey = `${kind}:${symName}`; + const selfExportKind = moduleExportKinds.get(symName); + const isSelfReference = + resolved === undefined + && ( + (kind === "mem" && selfExportKind === "global") + || (kind === "func" && selfExportKind === "function") + ); + + // A LOCAL dependency and a module's own interposable export must not + // acquire a process-global GOT cell. The importing instance owns this + // cell, and its exact provider is captured in the dependency closure. + if ((resolved && !globallyResolved) || isSelfReference) { + let localEntry = localGot.get(localKey); + if (!localEntry) { + let initial = wasmAddress( + 0, + ptrWidth, + `${name}: local GOT.${kind}.${symName}`, + ); + if (resolved) { + initial = kind === "mem" + ? requireWasmAddress( + (resolved.value as WebAssembly.Global).value as WasmAddress, + ptrWidth, + `${name}: local GOT.mem.${symName}`, + ) + : wasmAddress( + resolvedFunctionIndex!, + ptrWidth, + `${name}: local GOT.func.${symName}`, + ); + } + localEntry = new WebAssembly.Global( + { value: pointerGlobalType, mutable: true }, + initial, + ); + localGot.set(localKey, localEntry); + } + return localEntry; + } + + const knownKind = gotKinds.get(symName); + if (knownKind !== undefined && knownKind !== kind) { + throw new Error( + `${name}: GOT symbol ${symName} is both ${knownKind} and ${kind}`, + ); + } + gotKinds.set(symName, kind); + gotImports.set(symName, kind); let entry = options.got.get(symName); if (!entry) { let initial = wasmAddress(0, ptrWidth, `${name}: GOT.${kind}.${symName}`); - const sym = options.globalSymbols.get(symName); - if (kind === "mem" && sym instanceof WebAssembly.Global) { + if (kind === "mem" && resolved?.value instanceof WebAssembly.Global) { initial = requireWasmAddress( - sym.value as WasmAddress, + resolved.value.value as WasmAddress, ptrWidth, `${name}: GOT.mem.${symName}`, ); - } else if (kind === "func" && typeof sym === "function") { + } else if (resolvedFunctionIndex !== undefined) { initial = wasmAddress( - tableIndexFor(sym), + resolvedFunctionIndex, ptrWidth, `${name}: GOT.func.${symName}`, ); @@ -1000,171 +1567,122 @@ function instantiateSharedLibrary( ptrWidth, `${name}: existing GOT.${kind}.${symName}`, ); + if (kind === "mem" && resolved?.value instanceof WebAssembly.Global) { + entry.value = requireWasmAddress( + resolved.value.value as WasmAddress, + ptrWidth, + `${name}: GOT.mem.${symName}`, + ); + } else if (resolvedFunctionIndex !== undefined) { + entry.value = wasmAddress( + resolvedFunctionIndex, + ptrWidth, + `${name}: GOT.func.${symName}`, + ); + } } return entry; }; let instance: WebAssembly.Instance | null = null; - let sideForkState: SideModuleForkState | null = null; - const forkState = (): number => { - if (!instance) throw new Error(`${name}: side-module fork before instantiation`); - return Number((instance.exports.wpk_fork_state as () => number)()); - }; - - const sideModuleForkImport = (): number => { - if (!instance || !options.sideModuleFork || !sideForkContinuation) { - throw new Error(`${name}: side-module fork coordinator is unavailable`); - } - const state = forkState(); - if (state === WPK_FORK_NORMAL) { - try { - sideForkBufAddr = Number(sideForkContinuation!.beginUnwind()); - } catch (error) { - if (error instanceof ContinuationAllocationError) return -error.errno; - throw error; - } - const loaded = options.loadedLibraries.get(name); - if (loaded) loaded.forkBufAddr = sideForkBufAddr; - invokeForkContinuationBegin( - instance.exports.wpk_fork_unwind_begin, - sideForkBufAddr, - ptrWidth, - `${name}: side-module linked fork unwind`, - ); - if (forkState() !== WPK_FORK_UNWINDING) { - throw new Error(`${name}: side-module fork failed to enter UNWINDING`); - } - const startedState: SideModuleForkState = { - name, - instance, - forkBufAddr: sideForkBufAddr, - continuation: sideForkContinuation!, - }; - sideForkState = startedState; - options.sideModuleFork.setActiveFork(startedState); - const result = options.sideModuleFork.invokeMainFork([ - WPK_FORK_NORMAL, - WPK_FORK_UNWINDING, - ]); - if (result < 0) { - // Main root allocation failed synchronously: no side activation has - // returned yet, so unwind the side control state without replay. - (instance.exports.wpk_fork_unwind_end as () => void)(); - sideForkContinuation!.cancelUnwindAndRelease(); - options.sideModuleFork.clearActiveFork(startedState); - const loaded = options.loadedLibraries.get(name); - if (loaded) loaded.forkBufAddr = undefined; - sideForkState = null; - } - return result; - } - - if (state === WPK_FORK_REWINDING) { - (instance.exports.wpk_fork_rewind_end as () => void)(); - sideForkContinuation!.finishReplayAndRelease(); - if (forkState() !== WPK_FORK_NORMAL) { - throw new Error(`${name}: side-module fork failed to finish REWINDING`); - } - // A fork child re-instantiates this module, so its closure cannot retain - // the parent's SideModuleForkState object. The worker reconstructs the - // active identity from the copied archive/buffer metadata; rebuild the - // same structural identity here before clearing it. - const completedState = sideForkState ?? { - name, - instance, - forkBufAddr: sideForkBufAddr, - continuation: sideForkContinuation!, - }; - const result = options.sideModuleFork.invokeMainFork(WPK_FORK_NORMAL); - options.sideModuleFork.clearActiveFork(completedState); - const loaded = options.loadedLibraries.get(name); - if (loaded) loaded.forkBufAddr = undefined; - sideForkState = null; - return result; - } - - if (state === WPK_FORK_ABORT_UNWINDING) { - const errno = sideForkContinuation!.abortErrno(); - (instance.exports.wpk_fork_abort_end as () => void)(); - sideForkContinuation!.finishAbortReplayAndRelease(); - const completedState = sideForkState; - if (!completedState) { - throw new Error(`${name}: side-module abort lost its active fork identity`); - } - const result = options.sideModuleFork.invokeMainFork(WPK_FORK_NORMAL); - options.sideModuleFork.clearActiveFork(completedState); - const loaded = options.loadedLibraries.get(name); - if (loaded) loaded.forkBufAddr = undefined; - sideForkState = null; - if (result !== -errno) { - throw new Error(`${name}: main/side continuation abort errno mismatch`); - } - return result; + const routeFunctionImport = ( + moduleName: string, + importName: string, + value: WebAssembly.ImportValue | WebAssembly.Tag | undefined, + ): WebAssembly.ImportValue | WebAssembly.Tag | undefined => { + if (typeof value !== "function" || !options.routeFunctionImport) { + return value; } - - throw new Error(`${name}: env.fork reached in unexpected state ${state}`); + const key = `${moduleName.length}:${moduleName}${importName}`; + const entries = functionImportsByName.get(key); + if (!entries || entries.length === 0) return value; + const read = functionImportReads.get(key) ?? 0; + const imported = entries[Math.min(read, entries.length - 1)]!; + functionImportReads.set(key, read + 1); + return options.routeFunctionImport(imported, value); }; // Construct imports const imports: WebAssembly.Imports = { env: new Proxy({} as Record, { get(_target, prop: string) { + let value: WebAssembly.ImportValue | WebAssembly.Tag | undefined; switch (prop) { - case "memory": return options.memory; - case "__indirect_function_table": return options.table; - case "__memory_base": return memoryBaseGlobal; - case "__table_base": return tableBaseGlobal; - case "__stack_pointer": return options.stackPointer; - case "__c_longjmp": return longjmpTag; - case "__cpp_exception": return cppExceptionTag; - case "fork": - if (importsFork) return sideModuleForkImport; + case "memory": value = options.memory; break; + case "__indirect_function_table": value = options.table; break; + case "__memory_base": value = memoryBaseGlobal; break; + case "__table_base": value = tableBaseGlobal; break; + case "__stack_pointer": value = options.stackPointer; break; + case "__c_longjmp": value = longjmpTag; break; + case "__cpp_exception": value = cppExceptionTag; break; + case FORK_UNWIND_TAG_IMPORT_NAME: + if ( + preparedActivation + && Object.hasOwn(preparedActivation.env, prop) + ) { + value = preparedActivation.env[prop]; + } else if (hasCompleteForkInstrumentation) { + // WHY: an ABI 43 linked continuation has one process-level + // activation owner. Letting this tag fall back independently + // could bind unwind exceptions to a different realm than the + // owner's frame and exception reconstruction imports. + value = undefined; + } else { + value = importsForkUnwindTag + ? requireForkUnwindTag(options.forkUnwindTag, name) + : undefined; + } break; - case "__wpk_fork_frame_reserve": - if (importsFork) return (size: number | bigint) => { - const frame = sideForkContinuation!.reserveFrame(size); - if (frame === 0 || frame === 0n) { - const errno = sideForkContinuation!.abortErrno(); - options.sideModuleFork!.beginMainAbort(errno); - invokeForkContinuationBegin( - instance!.exports.wpk_fork_abort_begin, - sideForkBufAddr, - ptrWidth, - `${name}: side-module linked fork abort`, - ); + default: + if ( + preparedActivation + && Object.hasOwn(preparedActivation.env, prop) + ) { + value = preparedActivation.env[prop]; + } else if ( + hasCompleteForkInstrumentation + && (prop === "fork" || prop.startsWith("__wpk_fork_")) + ) { + // WHY: falling through to a process symbol would split + // ownership between the loader and coordinator. Missing + // activation imports fail before the side module executes. + value = undefined; + } else { + const resolved = scopedSymbol( + options, + dependencyScope, + prop, + ); + if (resolved !== undefined) { + recordProvider(resolved.owner); + value = resolved.value; + } else if (selfFunctionImports.has(prop)) { + value = (...args: unknown[]) => { + const fn = instance?.exports[prop]; + if (typeof fn !== "function") { + throw new Error(`${name}: self import env.${prop} is unavailable`); + } + return (fn as Function)(...args); + }; } - return frame; - }; - break; - case "__wpk_fork_frame_commit": - if (importsFork) return (payload: number | bigint) => - sideForkContinuation!.commitFrame(payload); - break; - case "__wpk_fork_frame_next": - if (importsFork) return (size: number | bigint) => - sideForkContinuation!.nextFrame(size); - break; - } - const sym = options.globalSymbols.get(prop); - if (sym !== undefined) return sym; - if (selfFunctionImports.has(prop)) { - return (...args: unknown[]) => { - const fn = instance?.exports[prop]; - if (typeof fn !== "function") { - throw new Error(`${name}: self import env.${prop} is unavailable`); } - return (fn as Function)(...args); - }; } - return undefined; + return routeFunctionImport("env", prop, value); }, has(_target, prop: string) { if (["memory", "__indirect_function_table", "__memory_base", "__table_base", "__stack_pointer", "__c_longjmp", "__cpp_exception"].includes(prop)) return true; - if (prop === "fork" && importsFork) return true; - if (linkedFrameImportNames.some((name) => name === prop) && importsFork) return true; - return options.globalSymbols.has(prop) || selfFunctionImports.has(prop); + if ( + preparedActivation + && Object.hasOwn(preparedActivation.env, prop) + ) return true; + if ( + hasCompleteForkInstrumentation + && (prop === "fork" || prop.startsWith("__wpk_fork_")) + ) return false; + return scopedSymbol(options, dependencyScope, prop) !== undefined + || selfFunctionImports.has(prop); }, }), "GOT.mem": new Proxy({} as Record, { @@ -1179,8 +1697,77 @@ function instantiateSharedLibrary( }), }; + // Imported global/table identity is observable only while WebAssembly + // lazily resolves this exact proxy graph. Give the process owner one + // synchronous wrapper boundary; eager enumeration would collapse duplicate + // `(module, name)` declarations and capture the wrong provider. + const instanceImports = preparedActivation + ? preparedActivation.wrapImports(imports) + : imports; + if (!instanceImports || typeof instanceImports !== "object") { + throw new TypeError(`${name}: activation owner returned invalid wrapped imports`); + } + // Instantiate synchronously after validating the side-module fork contract. - instance = new WebAssembly.Instance(module, imports); + instance = new WebAssembly.Instance(module, instanceImports); + preparedActivation?.register(instance); + const ownedModuleBytes = wasmBytes.slice(); + const initializationForkState = ( + tlsBase?: number, + ): DylinkForkLibraryState => ({ + name, + moduleBytes: ownedModuleBytes, + memoryBase, + tableBase, + activationId: preparedActivationId, + globalVisibility, + ...(allocations.length === 0 + ? {} + : { allocations: allocations.map((allocation) => ({ ...allocation })) }), + ...(tlsBase === undefined ? {} : { tlsBase }), + }); + provisionalLibrary = { + instance, + memoryBase, + tableBase, + exports: {}, + metadata, + name, + moduleBytes: ownedModuleBytes, + activationId: preparedActivationId, + ownedTableEntries: [], + gotImports: [], + unregisterForkActivation: preparedActivation + ? unregisterForkActivation + : undefined, + loadState: "initializing", + globalVisibility, + providerDependencies, + allocations, + heapReservationEnd: options.heapPointer?.value, + }; + options.loadedLibraries.set(name, provisionalLibrary); + loadContext?.ownedLibraries.add(provisionalLibrary); + let stateAfterBootstrap: DylinkForkLibraryState | undefined; + if ( + preparedActivation + && (!replay || replay.initializationStage !== undefined) + ) { + const bootstrap = instance.exports.wpk_fork_module_bootstrap; + if (typeof bootstrap !== "function") { + throw new Error(`${name}: fork activation is missing its module bootstrap`); + } + // WHY: activation registration must not itself enter guest code. Keeping + // bootstrap at the loader boundary lets the process/libc staged loader + // replace this direct call with a normal Wasm table call without + // changing activation ownership or registration ordering. + stateAfterBootstrap = yield { + libraryName: name, + stage: "bootstrap", + forkState: initializationForkState(), + invoke: bootstrap as () => void, + }; + } // A threaded wasm-ld side module initializes its mutable __tls_base from // __memory_base in the start function. Fork-child memory already carries @@ -1235,13 +1822,27 @@ function instantiateSharedLibrary( throw new Error(`${name}: exported __tls_base must be mutable for fork replay`); } if (replay) { - if (!Number.isSafeInteger(replay.tlsBase) || replay.tlsBase! <= 0) { + const replayTlsBase = stateAfterBootstrap?.tlsBase ?? replay.tlsBase; + if (replayTlsBase !== undefined) { + if (!Number.isSafeInteger(replayTlsBase) || replayTlsBase <= 0) { + throw new Error(`${name}: fork replay is missing a valid side-module TLS base`); + } + try { + tlsBaseExport.value = typeof initialRawTlsBase === "bigint" + ? BigInt(replayTlsBase) + : replayTlsBase; + } catch { + throw new Error(`${name}: exported __tls_base must be mutable for fork replay`); + } + } else if (replay.initializationStage !== "bootstrap") { throw new Error(`${name}: fork replay is missing a valid side-module TLS base`); } + // A child stopped inside bootstrap has no archived TLS value yet. + // Once its restored bootstrap call returns, the instance-local global + // is authoritative. A non-calling pthread replica instead receives the + // later archived value through the generator resume above. try { - tlsBaseExport.value = typeof initialRawTlsBase === "bigint" - ? BigInt(replay.tlsBase!) - : replay.tlsBase!; + tlsBaseExport.value = tlsBaseExport.value; } catch { throw new Error(`${name}: exported __tls_base must be mutable for fork replay`); } @@ -1310,79 +1911,144 @@ function instantiateSharedLibrary( relocatedExports[exportName] = exportValue; } } + provisionalLibrary.exports = relocatedExports; + provisionalLibrary.tlsBase = tlsBase; // Update GOT with this library's exports for (const [exportName, exportValue] of Object.entries(relocatedExports)) { - if (exportName.startsWith("__")) continue; + if (exportName.startsWith("__") || isForkRuntimeExport(exportName)) { + // WHY: these are activation-control entry points, not ELF-visible + // application symbols. Publishing them would put post-catalog + // instrumenter helpers into the mutable process table and manufacture + // reference state with no source-function reconstruction recipe. + continue; + } const alreadyDefined = options.globalSymbols.has(exportName); if (typeof exportValue === "function") { const tableIdx = tableLength(options.table); growTable(options.table, 1); setTableEntry(options.table, tableIdx, exportValue as unknown as Function); - - const gotEntry = options.got.get(exportName); - if (gotEntry && !alreadyDefined) { - gotEntry.value = wasmAddress( + ownedTableEntries.add(tableIdx); + // This write is performed by the host loader, outside generated + // table.set instrumentation. Attribute it to the shared table owner so + // fork captures the side function as an activation+ordinal recipe. + options.onTableMutation?.(options.table, tableIdx, 1); + + const localEntry = localGot.get(`func:${exportName}`); + if (localEntry) { + localEntry.value = wasmAddress( tableIdx, ptrWidth, - `${name}: GOT.func.${exportName}`, + `${name}: local GOT.func.${exportName}`, ); } - if (!alreadyDefined) { + const gotEntry = options.got.get(exportName); + if (globalVisibility && gotEntry) { + const gotKind = gotKinds.get(exportName); + if (gotKind !== undefined && gotKind !== "func") { + throw new Error(`${name}: GOT symbol ${exportName} changes kind`); + } + gotKinds.set(exportName, "func"); + if (!alreadyDefined) { + gotEntry.value = wasmAddress( + tableIdx, + ptrWidth, + `${name}: GOT.func.${exportName}`, + ); + } + } + if (globalVisibility && !alreadyDefined) { options.globalSymbols.set(exportName, exportValue as Function); + owners.set(exportName, name); } } else if (exportValue instanceof WebAssembly.Global) { const addr = (exportValue as WebAssembly.Global).value; - const gotEntry = options.got.get(exportName); - if (gotEntry && !alreadyDefined) { - gotEntry.value = requireWasmAddress( + const localEntry = localGot.get(`mem:${exportName}`); + if (localEntry) { + localEntry.value = requireWasmAddress( addr as WasmAddress, ptrWidth, - `${name}: GOT.mem.${exportName}`, + `${name}: local GOT.mem.${exportName}`, ); } - if (!alreadyDefined) { + const gotEntry = options.got.get(exportName); + if (globalVisibility && gotEntry) { + const gotKind = gotKinds.get(exportName); + if (gotKind !== undefined && gotKind !== "mem") { + throw new Error(`${name}: GOT symbol ${exportName} changes kind`); + } + gotKinds.set(exportName, "mem"); + if (!alreadyDefined) { + gotEntry.value = requireWasmAddress( + addr as WasmAddress, + ptrWidth, + `${name}: GOT.mem.${exportName}`, + ); + } + } + if (globalVisibility && !alreadyDefined) { options.globalSymbols.set(exportName, exportValue); + owners.set(exportName, name); } } } // Run data relocations const applyRelocs = instance.exports.__wasm_apply_data_relocs as Function | undefined; - if (applyRelocs) { - applyRelocs(); + if (applyRelocs && (!replay || replay.initializationStage !== undefined)) { + // A complete fork replay receives already-relocated live bytes from the + // parent. Re-running this entry would relocate pointers a second time. + // An in-flight replay still yields the full stage sequence so the + // archived selector can stop at the exact guest call being resumed. + yield { + libraryName: name, + stage: "relocations", + forkState: initializationForkState(tlsBase), + invoke: applyRelocs as () => void, + }; } - if (!replay) { + if (!replay || replay.initializationStage !== undefined) { // Skip ctors in replay: parent already ran them and post-startup state // (e.g. opcache accel_globals, registered INI entries) is in the // memcpy'd data; re-running would clobber it. const ctors = instance.exports.__wasm_call_ctors as Function | undefined; if (ctors) { - ctors(); + yield { + libraryName: name, + stage: "constructors", + forkState: initializationForkState(tlsBase), + invoke: ctors as () => void, + }; } } - const loaded: LoadedSharedLibrary = { - instance, - memoryBase, - tableBase, - exports: relocatedExports, - metadata, - name, - forkBufAddr: sideForkBufAddr || undefined, - forkContinuation: sideForkContinuation, - tlsBase, - forkCapable: importsFork, - functionImports, - functionExports, - importsDynamicLookup, - }; - - options.loadedLibraries.set(name, loaded); - return loaded; + provisionalLibrary.ownedTableEntries = + [...ownedTableEntries].sort((left, right) => left - right); + provisionalLibrary.gotImports = [...gotImports] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([symbol, kind]) => Object.freeze({ name: symbol, kind })); + provisionalLibrary.providerDependencies = new Set(providerDependencies); + provisionalLibrary.allocations = allocations.map((allocation) => + copyForkMemoryAllocation(allocation, `${name}: live allocation`) + ); + provisionalLibrary.heapReservationEnd = options.heapPointer?.value; + provisionalLibrary.loadState = "loaded"; + return provisionalLibrary; } catch (error) { + if ( + provisionalLibrary + && options.loadedLibraries.get(name) === provisionalLibrary + ) { + options.loadedLibraries.delete(name); + } + let activationReleaseError: unknown; + try { + unregisterForkActivation(); + } catch (releaseError) { + activationReleaseError = releaseError; + } // Restore every mutable host-side linker structure we can. Table length and // Wasm memory cannot shrink, so clear newly-addressable table slots and let // the next successful archive entry record the resulting exact table base. @@ -1394,23 +2060,84 @@ function instantiateSharedLibrary( } options.globalSymbols.clear(); for (const [symbol, value] of symbolRollback) options.globalSymbols.set(symbol, value); + owners.clear(); + for (const [symbol, owner] of ownerRollback) owners.set(symbol, owner); options.got.clear(); for (const [symbol, snapshot] of gotRollback) { try { snapshot.global.value = snapshot.value; } catch { /* immutable should not occur */ } options.got.set(symbol, snapshot.global); } + gotKinds.clear(); + for (const [symbol, kind] of gotKindsRollback) { + gotKinds.set(symbol, kind); + } if (options.heapPointer && heapRollbackValue !== undefined) { options.heapPointer.value = heapRollbackValue; } if (options.deallocateMemory) { for (const allocation of allocations.reverse()) { - try { options.deallocateMemory(allocation.addr, allocation.size); } catch { /* preserve cause */ } + try { + options.deallocateMemory(allocation.address, allocation.size); + } catch { /* preserve cause */ } } } + if (activationReleaseError !== undefined) { + throw new AggregateError( + [error, activationReleaseError], + `${name}: side-module load failed and activation rollback was incomplete`, + ); + } throw error; } } +function driveDylinkInitialization( + steps: Generator< + DylinkInitializationStep, + LoadedSharedLibrary, + DylinkForkLibraryState | undefined + >, +): LoadedSharedLibrary { + let cursor = steps.next(); + while (!cursor.done) { + try { + cursor.value.invoke(); + } catch (error) { + // Re-enter the generator at its guarded yield so its ordinary rollback + // path releases allocations, table entries, and activation ownership. + // A process/libc staged driver deliberately does not do this for the + // private fork unwind: the same generator remains live until replay + // returns to the next state transition. + return steps.throw(error).value as never; + } + cursor = steps.next(); + } + return cursor.value; +} + +function instantiateSharedLibrary( + name: string, + wasmBytes: Uint8Array, + metadata: DylinkMetadata, + options: LoadSharedLibraryOptions, + replay?: DylinkReplayOptions, + globalVisibility = true, + dependencyScope: readonly LoadedSharedLibrary[] = [], +): LoadedSharedLibrary { + return driveDylinkInitialization( + instantiateSharedLibrarySteps( + name, + wasmBytes, + metadata, + options, + replay, + undefined, + globalVisibility, + dependencyScope, + ), + ); +} + /** * Load a shared library (.so / side module) into a process's address space. * Async version — uses async WebAssembly compilation for large modules and @@ -1423,10 +2150,17 @@ export async function loadSharedLibrary( name: string, wasmBytes: Uint8Array, options: LoadSharedLibraryOptions, + globalVisibility = true, ): Promise { validateLongjmpConfiguration(options); const existing = options.loadedLibraries.get(name); - if (existing) return existing; + if (existing) { + if (globalVisibility && !existing.globalVisibility) { + promoteLibraryGlobal(existing, options); + refreshGlobalGotEntries(options); + } + return existing; + } const metadata = parseDylinkSection(wasmBytes); if (!metadata) { @@ -1439,61 +2173,189 @@ export async function loadSharedLibrary( if (!options.resolveLibrary) { throw new Error(`${name}: depends on ${dep} but no resolveLibrary callback provided`); } - const depBytes = await options.resolveLibrary(dep); + const depBytes = await options.resolveLibrary(dep, name); if (!depBytes) { throw new Error(`${name}: dependency ${dep} not found`); } - await loadSharedLibrary(dep, depBytes, options); + await loadSharedLibrary(dep, depBytes, options, globalVisibility); } - return instantiateSharedLibrary(name, wasmBytes, metadata, options); + const dependencyScope: LoadedSharedLibrary[] = []; + appendDependencyScope( + dependencyScope, + metadata.neededDynlibs.map((dependencyName) => { + const dependency = options.loadedLibraries.get(dependencyName); + if (!dependency) { + throw new Error(`${name}: loaded dependency ${dependencyName} is missing`); + } + return dependency; + }), + options, + ); + return instantiateSharedLibrary( + name, + wasmBytes, + metadata, + options, + undefined, + globalVisibility, + dependencyScope, + ); } /** * Load a shared library synchronously. Required for dlopen() which must * return synchronously to C code. Uses synchronous WebAssembly compilation. */ -export function loadSharedLibrarySync( +function* loadSharedLibrarySyncSteps( name: string, wasmBytes: Uint8Array, options: LoadSharedLibraryOptions, replay?: DylinkReplayOptions, -): LoadedSharedLibrary { + replayModules?: ReadonlyMap, + loadContext?: DylinkLoadContext, + globalVisibility = true, +): Generator< + DylinkInitializationStep, + LoadedSharedLibrary, + DylinkForkLibraryState | undefined +> { validateLongjmpConfiguration(options); const existing = options.loadedLibraries.get(name); - if (existing) return existing; + if (existing) { + if (replay) { + throw new Error( + `${name}: fork replay cannot reuse an already-loaded library; ` + + "archive entries must be unique", + ); + } + if (globalVisibility && !existing.globalVisibility) { + promoteLibraryGlobal(existing, options); + refreshGlobalGotEntries(options); + } + return existing; + } const metadata = parseDylinkSection(wasmBytes); if (!metadata) { throw new Error(`${name}: not a shared library (no dylink.0 section)`); } - // Replay-with-deps would re-allocate the dep at the child's *current* - // mmap cursor (not the parent's address) and corrupt the replayed - // library's data-relocs, which encode the parent's dep memoryBase. - // Fail loudly instead of silently producing wrong addresses. - if (replay && metadata.neededDynlibs.length > 0) { - throw new Error( - `${name}: replay does not yet support NEEDED deps; ` + - `each dep would need its own DylinkReplayOptions in a future API extension`, - ); - } - - // Load dependencies first (sync). Replay is not forwarded: dep replay is - // out-of-scope (guarded above); the recursive call instantiates deps freshly. + // Parent archive entries are emitted in dependency-first load order. A + // child must replay each dependency with its own exact layout/activation + // record before replaying this consumer; silently allocating a missing dep + // here would choose fresh addresses and corrupt copied relocations. for (const dep of metadata.neededDynlibs) { if (options.loadedLibraries.has(dep)) continue; - if (!options.resolveLibrarySync) { + const archivedDependency = replayModules?.get(dep); + if (replay && !archivedDependency) { + throw new Error( + `${name}: fork replay is missing dependency ${dep}; ` + + "archive entries must be replayed in dependency order", + ); + } + if (!archivedDependency && !options.resolveLibrarySync) { throw new Error(`${name}: depends on ${dep} but no resolveLibrarySync callback provided`); } - const depBytes = options.resolveLibrarySync(dep); + const depBytes = archivedDependency + ? new Uint8Array(archivedDependency.moduleBytes) + : options.resolveLibrarySync!(dep, name); if (!depBytes) { throw new Error(`${name}: dependency ${dep} not found`); } - loadSharedLibrarySync(dep, depBytes, options); + const loadedDependency = yield* loadSharedLibrarySyncSteps( + dep, + depBytes, + options, + archivedDependency + ? { + memoryBase: archivedDependency.memoryBase, + tableBase: archivedDependency.tableBase, + activationId: archivedDependency.activationId, + tlsBase: archivedDependency.tlsBase, + globalVisibility: archivedDependency.globalVisibility, + committedGlobalRoot: archivedDependency.committedGlobalRoot, + providerDependencies: archivedDependency.providerDependencies, + allocations: archivedDependency.allocations, + initializationStage: + archivedDependency.initialization?.stage, + } + : undefined, + replayModules, + loadContext, + archivedDependency?.globalVisibility ?? globalVisibility, + ); + if (archivedDependency) { + loadedDependency.committedGlobalRoot = + archivedDependency.committedGlobalRoot; + loadedDependency.providerDependencies = new Set( + archivedDependency.providerDependencies + ?? loadedDependency.providerDependencies + ?? [], + ); + } } - return instantiateSharedLibrary(name, wasmBytes, metadata, options, replay); + const archivedSelf = replayModules?.get(name); + const effectiveGlobalVisibility = + archivedSelf?.globalVisibility + ?? replay?.globalVisibility + ?? globalVisibility; + const effectiveReplay = replay ?? (archivedSelf + ? { + memoryBase: archivedSelf.memoryBase, + tableBase: archivedSelf.tableBase, + activationId: archivedSelf.activationId, + tlsBase: archivedSelf.tlsBase, + globalVisibility: archivedSelf.globalVisibility, + committedGlobalRoot: archivedSelf.committedGlobalRoot, + providerDependencies: archivedSelf.providerDependencies, + allocations: archivedSelf.allocations, + initializationStage: archivedSelf.initialization?.stage, + } + : undefined); + const dependencyScope: LoadedSharedLibrary[] = []; + appendDependencyScope( + dependencyScope, + metadata.neededDynlibs.map((dependencyName) => { + const dependency = options.loadedLibraries.get(dependencyName); + if (!dependency) { + throw new Error(`${name}: loaded dependency ${dependencyName} is missing`); + } + return dependency; + }), + options, + ); + return yield* instantiateSharedLibrarySteps( + name, + wasmBytes, + metadata, + options, + effectiveReplay, + loadContext, + effectiveGlobalVisibility, + dependencyScope, + ); +} + +export function loadSharedLibrarySync( + name: string, + wasmBytes: Uint8Array, + options: LoadSharedLibraryOptions, + replay?: DylinkReplayOptions, + globalVisibility = true, +): LoadedSharedLibrary { + return driveDylinkInitialization( + loadSharedLibrarySyncSteps( + name, + wasmBytes, + options, + replay, + undefined, + undefined, + globalVisibility, + ), + ); } /** @@ -1505,107 +2367,1570 @@ export class DynamicLinker { private options: LoadSharedLibraryOptions; private handleCounter = DynamicLinker.MAIN_PROGRAM_HANDLE + 1; private handleMap = new Map(); + private libraryHandles = new Map(); + private handleRefCounts = new Map(); + /** One retain per live consumer -> immutable or runtime provider edge. */ + private dependencyRetainCounts = new Map(); + /** Consumers whose immutable NEEDED edges have been accounted exactly once. */ + private dependencyOwners = new Set(); + private pendingTokenCounter = 1; + private pendingDlopens = new Map(); private lastError: string | null = null; + private readonly baseGlobalSymbols: Map< + string, + Function | WebAssembly.Global + >; + private readonly baseGlobalSymbolOwners: Map; + private readonly baseGot: Map; constructor(options: LoadSharedLibraryOptions) { validateLongjmpConfiguration(options); this.options = options; + this.options.gotKinds ??= new Map(); + const owners = symbolOwners(this.options); + this.baseGlobalSymbols = new Map(options.globalSymbols); + this.baseGlobalSymbolOwners = new Map(owners); + this.baseGot = new Map(options.got); } - /** Return the stable opaque handle used by dlopen(NULL, ...). */ - dlopenMain(): number { - this.lastError = null; - return DynamicLinker.MAIN_PROGRAM_HANDLE; + private stateForLibrary(lib: LoadedSharedLibrary): DylinkForkLibraryState { + const providerDependencies = [...(lib.providerDependencies ?? [])] + .filter((dependency) => dependency !== lib.name) + .sort(); + const providerState = providerDependencies.length === 0 + ? {} + : { providerDependencies }; + const allocations = (lib.allocations ?? []).map((allocation, index) => + copyForkMemoryAllocation( + allocation, + `${lib.name}: live allocation ${index}`, + ) + ); + const allocationState = allocations.length === 0 + ? {} + : { allocations }; + const handle = this.libraryHandles.get(lib.name); + if (handle === undefined) { + return { + name: lib.name, + moduleBytes: lib.moduleBytes, + memoryBase: lib.memoryBase, + tableBase: lib.tableBase, + activationId: lib.activationId, + tlsBase: lib.tlsBase, + globalVisibility: lib.globalVisibility, + ...(lib.committedGlobalRoot + ? { committedGlobalRoot: true } + : {}), + ...providerState, + ...allocationState, + initialization: lib.initialization, + }; + } + if (this.handleMap.get(handle) !== lib) { + throw new Error( + `${lib.name}: dynamic-linker fork state points at a different instance`, + ); + } + const refCount = this.handleRefCounts.get(handle); + if (!Number.isInteger(refCount) || refCount! <= 0) { + throw new Error( + `${lib.name}: dynamic-linker fork state has invalid handle refcount`, + ); + } + return { + name: lib.name, + moduleBytes: lib.moduleBytes, + memoryBase: lib.memoryBase, + tableBase: lib.tableBase, + activationId: lib.activationId, + tlsBase: lib.tlsBase, + globalVisibility: lib.globalVisibility, + ...(lib.committedGlobalRoot + ? { committedGlobalRoot: true } + : {}), + ...providerState, + ...allocationState, + handle, + refCount, + initialization: lib.initialization, + }; } - /** Open a shared library. Returns a handle (>0) or 0 on error. - * When `replay` is provided, behaves as fork-replay: uses the parent's - * saved memoryBase and skips __wasm_call_ctors. See `DylinkReplayOptions` - * for preconditions. */ - dlopenSync(name: string, wasmBytes: Uint8Array, replay?: DylinkReplayOptions): number { - try { - const lib = loadSharedLibrarySync(name, wasmBytes, this.options, replay); - // Check if already mapped to a handle - for (const [h, l] of this.handleMap) { - if (l === lib) return h; - } - const handle = this.handleCounter++; - this.handleMap.set(handle, lib); - this.lastError = null; - return handle; - } catch (e) { - this.lastError = e instanceof Error ? e.message : String(e); - return 0; - } + /** O(1) lookup for incrementally updating one live-module archive record. */ + forkLibraryState(name: string): DylinkForkLibraryState | undefined { + const lib = this.options.loadedLibraries.get(name); + return lib ? this.stateForLibrary(lib) : undefined; } - private symbolAddress( - symbolName: string, - exp: Function | WebAssembly.Global | undefined, - ): number | null { - if (typeof exp === "function") { - // Return the table index for this function (C function pointers are table indices) - const table = this.options.table; - const length = tableLength(table); - for (let i = 0; i < length; i++) { - if (getTableEntry(table, i) === exp) { - this.lastError = null; - return i; + /** + * Read the compact live process state; closed modules and historical events + * are deliberately absent so archive size is bounded by the live closure. + */ + forkState(): DylinkForkState { + const transactions = Array.from( + this.pendingDlopens.values(), + (transaction): DylinkForkTransactionState => ({ + token: transaction.token, + name: transaction.name, + moduleBytes: transaction.moduleBytes, + globalVisibility: transaction.globalVisibility, + }), + ); + return { + nextHandle: this.handleCounter, + libraries: Array.from( + this.options.loadedLibraries.values(), + (lib) => this.stateForLibrary(lib), + ), + ...(transactions.length === 0 ? {} : { transactions }), + }; + } + + /** + * Materialize every archived module recipe into this Worker's table graph. + * + * This does not recreate user-visible dlopen handles: pthread replicas need + * callable functions and activation catalogs, while the process-wide handle + * snapshot remains owned by the archive/main API. Existing instances are + * verified rather than re-instantiated, so a generation check makes the + * steady-state path O(1). + */ + reconcileForkModules(state: DylinkForkState): void { + const archivedNames = new Set(); + let visibilityChanged = false; + let dependencyStateChanged = false; + for (const archived of state.libraries) { + if (archivedNames.has(archived.name)) { + throw new Error( + `${archived.name}: duplicate module in dynamic-linker table recipe state`, + ); + } + archivedNames.add(archived.name); + const live = this.options.loadedLibraries.get(archived.name); + if (live) { + // The immutable instance recipe must never drift, but a peer + // publication can legitimately advance TLS discovery and the issued + // initializer while this Worker's generator is suspended. + this.requireForkLibraryIdentity(live, archived, true); + if ( + live.globalVisibility !== archived.globalVisibility + || live.committedGlobalRoot !== archived.committedGlobalRoot + ) { + live.globalVisibility = archived.globalVisibility; + live.committedGlobalRoot = archived.committedGlobalRoot; + visibilityChanged = true; + } + const liveProviders = [...(live.providerDependencies ?? [])].sort(); + const archivedProviders = [...(archived.providerDependencies ?? [])] + .sort(); + if ( + liveProviders.length !== archivedProviders.length + || liveProviders.some( + (dependency, index) => dependency !== archivedProviders[index], + ) + ) { + live.providerDependencies = new Set(archivedProviders); + dependencyStateChanged = true; } } - // Not in table yet — add it - const idx = length; - growTable(table, 1); - setTableEntry(table, idx, exp as unknown as Function); - this.lastError = null; - return idx; } - - if (exp instanceof WebAssembly.Global) { - this.lastError = null; - return Number(exp.value); + if (visibilityChanged) this.rebuildRuntimeIndexes(); + + this.restorePendingDlopenTransactions(state); + + // A pthread Worker can observe dlclose after it materialized an earlier + // generation. Remove consumers before providers, clear their exact table + // slots, and release activation-owned reference catalogs before loading + // anything from the new authoritative closure. + const stale = [...this.options.loadedLibraries.values()] + .filter((lib) => !archivedNames.has(lib.name)) + .reverse(); + for (const lib of stale) { + const handle = this.libraryHandles.get(lib.name); + if (handle !== undefined) { + this.handleMap.delete(handle); + this.handleRefCounts.delete(handle); + this.libraryHandles.delete(lib.name); + } + this.clearLibraryTableEntries(lib); + this.options.loadedLibraries.delete(lib.name); + lib.unregisterForkActivation?.(); + for (const allocation of lib.allocations ?? []) { + // The publishing pthread already performed munmap. This replica only + // drops the copied Worker-local allocator index. + this.options.forgetMemoryAllocation?.(allocation); + } + lib.allocations = []; + } + if (stale.length > 0) { + this.rebuildRuntimeIndexes(); } - this.lastError = `symbol not found: ${symbolName}`; - return null; + for (const archived of state.libraries) { + if (this.options.loadedLibraries.has(archived.name)) continue; + if (archived.initialization !== undefined) continue; + this.loadModuleSync( + archived.name, + new Uint8Array(archived.moduleBytes), + { + memoryBase: archived.memoryBase, + tableBase: archived.tableBase, + activationId: archived.activationId, + tlsBase: archived.tlsBase, + globalVisibility: archived.globalVisibility, + committedGlobalRoot: archived.committedGlobalRoot, + providerDependencies: archived.providerDependencies, + allocations: archived.allocations, + }, + archived.globalVisibility, + false, + ); + } + // Replay can restore a constructor-created provider edge whose provider + // appears later in insertion order. Account lifetimes only after the exact + // module closure has been materialized. + this.rebuildDependencyBookkeeping(); + if (dependencyStateChanged) this.rebuildRuntimeIndexes(); + for (const archived of state.libraries) { + const live = this.options.loadedLibraries.get(archived.name); + if (!live) { + throw new Error( + `${archived.name}: dynamic-linker reconciliation lost its live instance`, + ); + } + this.requireForkLibraryIdentity(live, archived); + } } - /** Look up a symbol by name. Returns its function-table index or data address. */ - dlsym(handle: number, symbolName: string): number | null { - if (handle === DynamicLinker.MAIN_PROGRAM_HANDLE || handle === 0) { - return this.symbolAddress(symbolName, this.options.globalSymbols.get(symbolName)); + private restorePendingDlopenTransactions(state: DylinkForkState): void { + const transactions = state.transactions ?? []; + const archivedModules = new Map( + state.libraries.map((library) => [library.name, library]), + ); + const transactionStates = new Map(); + const activeStates = new Map(); + for (const transaction of transactions) { + if (transactionStates.has(transaction.token)) { + throw new Error( + `duplicate staged dlopen transaction ${transaction.token}`, + ); + } + transactionStates.set(transaction.token, transaction); } - - const lib = this.handleMap.get(handle); - if (!lib) { - this.lastError = "invalid handle"; - return null; + for (const library of state.libraries) { + const initialization = library.initialization; + if (!initialization) continue; + if (!transactionStates.has(initialization.transactionToken)) { + throw new Error( + `${library.name}: issued initializer names missing staged dlopen ` + + `transaction ${initialization.transactionToken}`, + ); + } + if (activeStates.has(initialization.transactionToken)) { + throw new Error( + `staged dlopen transaction ${initialization.transactionToken} has ` + + "multiple issued entries", + ); + } + activeStates.set(initialization.transactionToken, library); + } + for (const transaction of transactions) { + if (!activeStates.has(transaction.token)) { + throw new Error( + `staged dlopen transaction ${transaction.token} has no issued entry`, + ); + } } - const exp = lib.exports[symbolName]; - return this.symbolAddress( - symbolName, - typeof exp === "function" || exp instanceof WebAssembly.Global - ? exp - : this.options.globalSymbols.get(symbolName), - ); - } + const bytesEqual = ( + left: Readonly, + right: Readonly, + ): boolean => + left.length === right.length + && left.every((byte, index) => byte === right[index]); + const clearIssuedMarker = ( + transaction: PendingDlopenTransaction, + ): void => { + const current = transaction.currentStep; + if (!current) return; + const live = this.options.loadedLibraries.get(current.libraryName); + if ( + live?.initialization?.transactionToken === transaction.token + ) { + delete live.initialization; + } + transaction.currentStep = undefined; + transaction.awaitingCompletion = false; + }; + const clearTransactionTableEntry = ( + transaction: PendingDlopenTransaction, + ): void => { + if (transaction.tableIndex === undefined) return; + setTableEntry(this.options.table, transaction.tableIndex, null); + this.options.onTableMutation?.( + this.options.table, + transaction.tableIndex, + 1, + ); + }; + const installIssuedStep = ( + transaction: PendingDlopenTransaction, + archived: DylinkForkLibraryState, + ): void => { + const initialization = archived.initialization; + const current = transaction.currentStep; + if ( + !initialization + || !current + || current.libraryName !== archived.name + || current.stage !== initialization.stage + || transaction.tableIndex !== initialization.tableIndex + ) { + throw new Error( + `staged dlopen transaction ${transaction.token} could not ` + + `reconstruct ${archived.name}:${initialization?.stage ?? "none"}`, + ); + } + const tableLengthBefore = tableLength(this.options.table); + if (tableLengthBefore <= initialization.tableIndex) { + growTable( + this.options.table, + initialization.tableIndex + 1 - tableLengthBefore, + ); + } + setTableEntry( + this.options.table, + initialization.tableIndex, + current.invoke as unknown as Function, + ); + this.options.onTableMutation?.( + this.options.table, + initialization.tableIndex, + 1, + ); + const provisional = this.options.loadedLibraries.get(archived.name); + if (!provisional || provisional.loadState !== "initializing") { + throw new Error( + `${archived.name}: staged dlopen replay lost its provisional module`, + ); + } + provisional.providerDependencies = new Set( + archived.providerDependencies ?? [], + ); + provisional.initialization = Object.freeze({ ...initialization }); + transaction.awaitingCompletion = true; + }; + const refreshReplayModules = ( + transaction: PendingDlopenTransaction, + ): void => { + if (!transaction.replayModules) return; + transaction.replayModules.clear(); + for (const [name, library] of archivedModules) { + transaction.replayModules.set(name, library); + } + }; + const advanceWithoutGuestCalls = ( + transaction: PendingDlopenTransaction, + target?: DylinkForkLibraryState, + ): void => { + refreshReplayModules(transaction); + for (;;) { + if ( + target + && transaction.currentStep?.libraryName === target.name + && transaction.currentStep.stage === target.initialization?.stage + ) { + installIssuedStep(transaction, target); + return; + } - /** Close a library handle. Returns 0 on success. */ - dlclose(handle: number): number { - if (handle === DynamicLinker.MAIN_PROGRAM_HANDLE) { - this.lastError = null; - return 0; + const completedStep = transaction.currentStep; + const resumeState = completedStep + ? archivedModules.get(completedStep.libraryName) + : undefined; + clearIssuedMarker(transaction); + const cursor = transaction.steps.next(resumeState); + if (cursor.done) { + transaction.loaded = cursor.value; + clearTransactionTableEntry(transaction); + if (target) { + throw new Error( + `${target.name}: staged dlopen replay could not reach ` + + `${target.initialization?.stage ?? "an issued step"}`, + ); + } + return; + } + transaction.currentStep = cursor.value; + transaction.awaitingCompletion = true; + } + }; + const discardRolledBackTransaction = ( + transaction: PendingDlopenTransaction, + ): void => { + clearIssuedMarker(transaction); + clearTransactionTableEntry(transaction); + try { + transaction.steps.throw( + new Error( + `${transaction.name}: peer publication rolled back staged dlopen`, + ), + ); + } catch { + // The generator reports the synthetic rollback cause after releasing + // its activation/table/symbol ownership. The authoritative archive + // state, not that local exception, determines reconciliation. + } + }; + + for (const transaction of [...this.pendingDlopens.values()]) { + const archivedTransaction = transactionStates.get(transaction.token); + if (!archivedTransaction) { + const committed = archivedModules.get(transaction.name); + if (committed?.handle !== undefined) { + advanceWithoutGuestCalls(transaction); + } else { + discardRolledBackTransaction(transaction); + } + this.pendingDlopens.delete(transaction.token); + continue; + } + if ( + transaction.name !== archivedTransaction.name + || transaction.globalVisibility !== archivedTransaction.globalVisibility + || !bytesEqual(transaction.moduleBytes, archivedTransaction.moduleBytes) + ) { + throw new Error( + `staged dlopen transaction ${transaction.token} changed identity`, + ); + } + const target = activeStates.get(transaction.token)!; + if ( + transaction.tableIndex !== undefined + && transaction.tableIndex !== target.initialization!.tableIndex + ) { + throw new Error( + `staged dlopen transaction ${transaction.token} changed its ` + + "initialization table slot", + ); + } + transaction.tableIndex ??= target.initialization!.tableIndex; + advanceWithoutGuestCalls(transaction, target); } - if (!this.handleMap.has(handle)) { - this.lastError = "invalid handle"; - return -1; + + for (const transactionState of transactions) { + if (this.pendingDlopens.has(transactionState.token)) continue; + const archived = activeStates.get(transactionState.token)!; + const initialization = archived.initialization!; + for (const prior of state.libraries) { + if (prior.name === archived.name) break; + if ( + this.options.loadedLibraries.has(prior.name) + || prior.initialization !== undefined + ) { + continue; + } + this.loadModuleSync( + prior.name, + new Uint8Array(prior.moduleBytes), + { + memoryBase: prior.memoryBase, + tableBase: prior.tableBase, + activationId: prior.activationId, + tlsBase: prior.tlsBase, + globalVisibility: prior.globalVisibility, + committedGlobalRoot: prior.committedGlobalRoot, + providerDependencies: prior.providerDependencies, + allocations: prior.allocations, + }, + prior.globalVisibility, + false, + ); + } + const replayModules = new Map(archivedModules); + const initialLibraries = new Set(this.options.loadedLibraries.values()); + const ownedLibraries = new Set(); + const steps = loadSharedLibrarySyncSteps( + transactionState.name, + new Uint8Array(transactionState.moduleBytes), + this.options, + undefined, + replayModules, + { ownedLibraries }, + transactionState.globalVisibility, + ); + const cursor = steps.next(); + if (cursor.done) { + throw new Error( + `${archived.name}: staged dlopen replay completed before ` + + `${initialization.stage}`, + ); + } + const tableLengthBefore = tableLength(this.options.table); + if (tableLengthBefore <= initialization.tableIndex) { + growTable( + this.options.table, + initialization.tableIndex + 1 - tableLengthBefore, + ); + } + const pending: PendingDlopenTransaction = { + token: transactionState.token, + name: transactionState.name, + moduleBytes: new Uint8Array(transactionState.moduleBytes), + globalVisibility: transactionState.globalVisibility, + steps, + replayModules, + initialLibraries, + initialVisibility: new Map( + Array.from( + initialLibraries, + (library) => [library, library.globalVisibility], + ), + ), + ownedLibraries, + initialHeapPointer: this.options.heapPointer?.value, + tableIndex: initialization.tableIndex, + awaitingCompletion: true, + currentStep: cursor.value, + }; + advanceWithoutGuestCalls(pending, archived); + this.pendingDlopens.set(transactionState.token, pending); + this.pendingTokenCounter = Math.max( + this.pendingTokenCounter, + transactionState.token + 1, + ); + } + } + + /** + * Restore the compact user-visible handle index after every archived module + * has been instantiated dependency-first. + * + * WHY: module-load order and dlopen-handle order are different domains. + * Dependencies are instantiated before their consumers, while handles are + * allocated only for explicit dlopen calls; final dlclose also leaves + * permanent gaps in the monotonic handle sequence. Replaying synthetic + * open/close events would either invent history or allocate the wrong next + * handle. The copied snapshot is the reconstruction owner instead. + */ + restoreForkHandleState(state: DylinkForkState): void { + this.applyForkHandleState(state, true); + } + + /** + * Replace this Worker's local handle index with the process publication. + * + * Pthread Workers can observe many generations, so unlike one-shot child + * replay this operation deliberately accepts an already populated index. + */ + reconcileForkHandleState(state: DylinkForkState): void { + this.applyForkHandleState(state, false); + } + + private applyForkHandleState( + state: DylinkForkState, + requirePristine: boolean, + ): void { + if ( + requirePristine + && ( + this.handleCounter !== DynamicLinker.MAIN_PROGRAM_HANDLE + 1 + || this.handleMap.size !== 0 + || this.libraryHandles.size !== 0 + || this.handleRefCounts.size !== 0 + ) + ) { + throw new Error( + "dynamic-linker fork handle state requires a pristine child handle index", + ); + } + if ( + !Number.isSafeInteger(state.nextHandle) + || state.nextHandle < DynamicLinker.MAIN_PROGRAM_HANDLE + 1 + || state.nextHandle > 0x1_0000_0000 + ) { + throw new RangeError( + `dynamic-linker fork next handle ${String(state.nextHandle)} is invalid`, + ); + } + + const liveByName = this.options.loadedLibraries; + if (state.libraries.length !== liveByName.size) { + throw new Error( + "dynamic-linker fork state does not describe the exact live module closure", + ); + } + + const restoredHandles = new Map(); + const restoredLibraryHandles = new Map(); + const restoredRefCounts = new Map(); + const seenNames = new Set(); + for (const archived of state.libraries) { + if (seenNames.has(archived.name)) { + throw new Error( + `${archived.name}: duplicate module in dynamic-linker fork state`, + ); + } + seenNames.add(archived.name); + const live = liveByName.get(archived.name); + if (!live) { + throw new Error( + `${archived.name}: dynamic-linker fork state has no live replay instance`, + ); + } + this.requireForkLibraryIdentity(live, archived); + + const hasHandle = archived.handle !== undefined; + if (hasHandle !== (archived.refCount !== undefined)) { + throw new Error( + `${archived.name}: dynamic-linker fork handle/refcount presence is inconsistent`, + ); + } + if (!hasHandle) continue; + const handle = archived.handle!; + const refCount = archived.refCount!; + if ( + !Number.isInteger(handle) + || handle <= DynamicLinker.MAIN_PROGRAM_HANDLE + || handle >= state.nextHandle + || handle > 0xffff_ffff + ) { + throw new RangeError( + `${archived.name}: dynamic-linker fork handle ${String(handle)} is invalid`, + ); + } + if (!Number.isInteger(refCount) || refCount <= 0 || refCount > 0xffff_ffff) { + throw new RangeError( + `${archived.name}: dynamic-linker fork refcount ${String(refCount)} is invalid`, + ); + } + if (restoredHandles.has(handle)) { + throw new Error( + `${archived.name}: duplicate dynamic-linker fork handle ${handle}`, + ); + } + restoredHandles.set(handle, live); + restoredLibraryHandles.set(archived.name, handle); + restoredRefCounts.set(handle, refCount); + } + for (const name of liveByName.keys()) { + if (!seenNames.has(name)) { + throw new Error( + `${name}: live replay module is missing from dynamic-linker fork state`, + ); + } + } + + this.handleMap = restoredHandles; + this.libraryHandles = restoredLibraryHandles; + this.handleRefCounts = restoredRefCounts; + this.handleCounter = state.nextHandle; + this.lastError = null; + } + + private requireForkLibraryIdentity( + live: LoadedSharedLibrary, + archived: DylinkForkLibraryState, + allowInitializationTransition = false, + ): void { + const liveProviders = [...(live.providerDependencies ?? [])].sort(); + const archivedProviders = [...(archived.providerDependencies ?? [])].sort(); + const liveAllocations = [...(live.allocations ?? [])]; + const archivedAllocations = [...(archived.allocations ?? [])]; + if ( + live.memoryBase !== archived.memoryBase + || live.tableBase !== archived.tableBase + || live.activationId !== archived.activationId + || liveAllocations.length !== archivedAllocations.length + || liveAllocations.some((allocation, index) => { + const expected = archivedAllocations[index]; + return ( + expected === undefined + || allocation.address !== expected.address + || allocation.size !== expected.size + || allocation.mappingAddress !== expected.mappingAddress + || allocation.mappingSize !== expected.mappingSize + ); + }) + || ( + !allowInitializationTransition + && ( + live.globalVisibility !== archived.globalVisibility + || live.committedGlobalRoot !== archived.committedGlobalRoot + || liveProviders.length !== archivedProviders.length + || liveProviders.some( + (dependency, index) => dependency !== archivedProviders[index], + ) + || live.tlsBase !== archived.tlsBase + || live.initialization?.transactionToken + !== archived.initialization?.transactionToken + || live.initialization?.stage !== archived.initialization?.stage + || live.initialization?.tableIndex + !== archived.initialization?.tableIndex + || (live.loadState === "initializing") + !== (archived.initialization !== undefined) + ) + ) + || live.moduleBytes.length !== archived.moduleBytes.length + || !live.moduleBytes.every( + (byte, index) => byte === archived.moduleBytes[index], + ) + ) { + throw new Error( + `${archived.name}: dynamic-linker replay instance does not match its fork state`, + ); + } + } + + /** Return the stable opaque handle used by dlopen(NULL, ...). */ + dlopenMain(): number { + this.lastError = null; + return DynamicLinker.MAIN_PROGRAM_HANDLE; + } + + /** + * Begin one process-driven dlopen transaction without entering guest code. + * + * The returned token is private to libc's prepare/next/commit loop and is + * never exposed as a user-visible dlopen handle. + */ + beginDlopenSync( + name: string, + wasmBytes: Uint8Array, + globalVisibility = true, + ): number { + try { + const token = requireNonzeroU32( + this.pendingTokenCounter, + `${name}: next staged dlopen token`, + ); + if (token === 0xffff_ffff) { + throw new RangeError(`${name}: staged dlopen token space is exhausted`); + } + this.pendingTokenCounter = token + 1; + const ownedBytes = wasmBytes.slice(); + const initialLibraries = new Set(this.options.loadedLibraries.values()); + const initialVisibility = new Map( + Array.from( + initialLibraries, + (library) => [library, library.globalVisibility], + ), + ); + const ownedLibraries = new Set(); + this.pendingDlopens.set(token, { + token, + name, + moduleBytes: ownedBytes, + globalVisibility, + steps: loadSharedLibrarySyncSteps( + name, + ownedBytes, + this.options, + undefined, + undefined, + { ownedLibraries }, + globalVisibility, + ), + initialLibraries, + initialVisibility, + ownedLibraries, + initialHeapPointer: this.options.heapPointer?.value, + awaitingCompletion: false, + }); + this.lastError = null; + return token; + } catch (error) { + this.lastError = error instanceof Error ? error.message : String(error); + return 0; + } + } + + /** + * Acknowledge the previously returned `() -> ()` entry and select the next. + * + * Zero means initialization is complete. The selected function remains + * rooted in one transaction-owned table slot until the following call. + */ + nextDlopenInitialization(token: number): number { + const transaction = this.pendingDlopens.get(token); + if (!transaction) { + this.lastError = `invalid staged dlopen token ${String(token)}`; + return -1; + } + try { + if (transaction.currentStep) { + const previous = this.options.loadedLibraries.get( + transaction.currentStep.libraryName, + ); + if ( + previous?.initialization?.transactionToken === transaction.token + ) { + delete previous.initialization; + } + transaction.currentStep = undefined; + } + transaction.awaitingCompletion = false; + const cursor = transaction.steps.next(); + if (cursor.done) { + transaction.loaded = cursor.value; + if (transaction.tableIndex !== undefined) { + setTableEntry(this.options.table, transaction.tableIndex, null); + this.options.onTableMutation?.( + this.options.table, + transaction.tableIndex, + 1, + ); + } + this.lastError = null; + return 0; + } + + let index = transaction.tableIndex; + if (index === undefined) { + index = tableLength(this.options.table); + if (index === 0) { + growTable(this.options.table, 1); + index = 1; + } + growTable(this.options.table, 1); + transaction.tableIndex = index; + } + if (!Number.isSafeInteger(index) || index <= 0 || index > 0x7fff_ffff) { + throw new RangeError( + `${transaction.name}: initialization table index ${String(index)} is invalid`, + ); + } + setTableEntry( + this.options.table, + index, + cursor.value.invoke as unknown as Function, + ); + this.options.onTableMutation?.(this.options.table, index, 1); + transaction.awaitingCompletion = true; + transaction.currentStep = cursor.value; + const provisional = this.options.loadedLibraries.get( + cursor.value.libraryName, + ); + if (!provisional || provisional.loadState !== "initializing") { + throw new Error( + `${cursor.value.libraryName}: initialization step has no provisional module`, + ); + } + provisional.initialization = Object.freeze({ + transactionToken: token, + stage: cursor.value.stage, + tableIndex: index, + }); + this.lastError = null; + return index; + } catch (error) { + this.abortDlopenTransaction(token, error); + return -1; + } + } + + /** + * Advance one staged load and atomically publish its public handle on finish. + * + * The process import uses this combined transition so no guest instruction + * can observe a completed generator whose transaction is still archived as + * an issued initializer. The separate next/commit methods remain useful to + * standalone embedders and focused state-machine tests. + */ + advanceDlopenSync(token: number): Readonly<{ + entry: number; + handle: number; + }> { + const entry = this.nextDlopenInitialization(token); + if (entry !== 0) return { entry, handle: 0 }; + const handle = this.commitDlopenSync(token); + return handle > 0 + ? { entry: 0, handle } + : { entry: -1, handle: 0 }; + } + + hasPendingDlopen(token: number): boolean { + return this.pendingDlopens.has(token); + } + + /** Commit the fully initialized module closure and return its stable handle. */ + commitDlopenSync(token: number): number { + const transaction = this.pendingDlopens.get(token); + if (!transaction) { + this.lastError = `invalid staged dlopen token ${String(token)}`; + return 0; + } + if (transaction.awaitingCompletion || !transaction.loaded) { + this.lastError = + `${transaction.name}: staged dlopen committed before initialization completed`; + return 0; + } + try { + this.registerDependencyEdges(); + if (transaction.globalVisibility) { + promoteLibraryGlobal(transaction.loaded, this.options); + transaction.loaded.committedGlobalRoot = true; + refreshGlobalGotEntries(this.options); + } + const handle = this.openLoadedLibrary(transaction.loaded); + this.pendingDlopens.delete(token); + this.lastError = null; + return handle; + } catch (error) { + this.abortDlopenTransaction(token, error); + return 0; + } + } + + private rollbackDlopenLibraries( + transaction: PendingDlopenTransaction, + ): unknown[] { + const failures: unknown[] = []; + const rolledBack = new Set(transaction.ownedLibraries); + const invalidNames = new Set( + Array.from(rolledBack, (library) => library.name), + ); + const loadedNow = [...this.options.loadedLibraries.values()]; + + // A constructor can complete a nested, independent dlopen before its outer + // initializer fails. Keep that nested transaction unless it captured an + // outer symbol or has a NEEDED edge into the failed closure. + let changed = true; + while (changed) { + changed = false; + for (const library of loadedNow) { + if ( + transaction.initialLibraries.has(library) + || rolledBack.has(library) + ) { + continue; + } + const dependsOnInvalid = [ + ...library.metadata.neededDynlibs, + ...(library.providerDependencies ?? []), + ].some((dependency) => invalidNames.has(dependency)); + if (!dependsOnInvalid) continue; + rolledBack.add(library); + invalidNames.add(library.name); + changed = true; + } + } + + for (const library of loadedNow.reverse()) { + if ( + !rolledBack.has(library) + || this.options.loadedLibraries.get(library.name) !== library + ) { + continue; + } + const handle = this.libraryHandles.get(library.name); + if (handle !== undefined) { + this.handleMap.delete(handle); + this.handleRefCounts.delete(handle); + this.libraryHandles.delete(library.name); + } + try { + this.clearLibraryTableEntries(library); + } catch (error) { + failures.push(error); + } + this.options.loadedLibraries.delete(library.name); + try { + library.unregisterForkActivation?.(); + } catch (error) { + failures.push(error); + } + if (this.options.deallocateMemory) { + for (const allocation of [...(library.allocations ?? [])].reverse()) { + try { + this.options.deallocateMemory( + allocation.address, + allocation.size, + ); + } catch (error) { + failures.push(error); + } + } + } + } + transaction.ownedLibraries.clear(); + + if ( + this.options.heapPointer + && transaction.initialHeapPointer !== undefined + ) { + let retainedEnd = transaction.initialHeapPointer; + for (const library of this.options.loadedLibraries.values()) { + if (transaction.initialLibraries.has(library)) continue; + retainedEnd = Math.max( + retainedEnd, + library.heapReservationEnd ?? retainedEnd, + ); + } + this.options.heapPointer.value = retainedEnd; + } + // Undo visibility changes made by this transaction, then reapply the + // closure of independently committed GLOBAL roots that survived it. + for (const [library, visibility] of transaction.initialVisibility) { + if (this.options.loadedLibraries.get(library.name) === library) { + library.globalVisibility = visibility; + } + } + for (const library of this.options.loadedLibraries.values()) { + if (library.committedGlobalRoot) { + promoteLibraryGlobal(library, this.options); + } + } + try { + this.rebuildDependencyBookkeeping(); + this.rebuildRuntimeIndexes(); + } catch (error) { + failures.push(error); + } + return failures; + } + + abortDlopenTransaction(token: number, cause?: unknown): void { + const transaction = this.pendingDlopens.get(token); + if (!transaction) return; + this.pendingDlopens.delete(token); + if (transaction.currentStep) { + const provisional = this.options.loadedLibraries.get( + transaction.currentStep.libraryName, + ); + if ( + provisional?.initialization?.transactionToken === transaction.token + ) { + delete provisional.initialization; + } + } + let failure = cause; + if (transaction.tableIndex !== undefined) { + try { + setTableEntry(this.options.table, transaction.tableIndex, null); + this.options.onTableMutation?.( + this.options.table, + transaction.tableIndex, + 1, + ); + } catch (error) { + failure ??= error; + } + } + try { + transaction.steps.throw( + cause ?? new Error(`${transaction.name}: staged dlopen aborted`), + ); + } catch (error) { + failure ??= error; + } + const rollbackFailures = this.rollbackDlopenLibraries(transaction); + if (rollbackFailures.length > 0) { + failure = failure === undefined + ? new AggregateError( + rollbackFailures, + `${transaction.name}: staged dlopen rollback was incomplete`, + ) + : new AggregateError( + [failure, ...rollbackFailures], + `${transaction.name}: staged dlopen rollback was incomplete`, + ); + } + this.lastError = failure instanceof Error + ? failure.message + : String(failure ?? "staged dlopen aborted"); + } + + /** + * Instantiate one module without manufacturing a dlopen handle. + * + * Fork replay records module loads separately from user-visible open/close + * events. This lets NEEDED dependencies be restored in exact parent order + * without accidentally incrementing their handle counts. + */ + loadModuleSync( + name: string, + wasmBytes: Uint8Array, + replay?: DylinkReplayOptions, + globalVisibility = replay?.globalVisibility ?? true, + registerDependencies = true, + ): LoadedSharedLibrary { + const loaded = loadSharedLibrarySync( + name, + wasmBytes, + this.options, + replay, + globalVisibility, + ); + if (replay) { + loaded.committedGlobalRoot = replay.committedGlobalRoot; + loaded.providerDependencies = new Set( + replay.providerDependencies ?? loaded.providerDependencies ?? [], + ); + } + if (registerDependencies) this.registerDependencyEdges(); + this.lastError = null; + return loaded; + } + + private registerDependencyEdges(): void { + // loadedLibraries insertion order is dependency-first. Scanning only the + // unaccounted suffix makes recursive dependency loads cheap while keeping + // the relationship derivable from immutable dylink metadata. + for (const lib of this.options.loadedLibraries.values()) { + if (this.dependencyOwners.has(lib.name)) continue; + for (const dependency of runtimeDependencyNames(lib)) { + if (!this.options.loadedLibraries.has(dependency)) { + throw new Error( + `${lib.name}: loaded dependency ${dependency} has no live provider`, + ); + } + const retains = this.dependencyRetainCounts.get(dependency) ?? 0; + if (retains >= 0xffff_ffff) { + throw new RangeError( + `${dependency}: dynamic-linker dependency retain count overflow`, + ); + } + this.dependencyRetainCounts.set(dependency, retains + 1); + } + this.dependencyOwners.add(lib.name); + } + } + + private rebuildDependencyBookkeeping(): void { + this.dependencyRetainCounts.clear(); + this.dependencyOwners.clear(); + this.registerDependencyEdges(); + } + + private clearLibraryTableEntries(lib: LoadedSharedLibrary): void { + const entries = [...new Set(lib.ownedTableEntries)] + .sort((left, right) => left - right); + const length = tableLength(this.options.table); + for (const index of entries) { + if ( + !Number.isSafeInteger(index) + || index < 0 + || index >= length + ) { + throw new Error( + `${lib.name}: owned table entry ${String(index)} is out of bounds`, + ); + } + } + for (const index of entries) { + setTableEntry(this.options.table, index, null); + } + for (let first = 0; first < entries.length;) { + let end = first + 1; + while (end < entries.length && entries[end] === entries[end - 1]! + 1) { + end++; + } + this.options.onTableMutation?.( + this.options.table, + entries[first]!, + entries[end - 1]! - entries[first]! + 1, + ); + first = end; + } + } + + /** + * Rebuild the loader-owned indexes from the exact live module closure. + * + * WHY: retaining an unloaded function in `globalSymbols`, a GOT cell, or a + * process-table slot would keep a stale callable GC root even though the + * archive no longer contains its activation recipe. + */ + private rebuildRuntimeIndexes(): void { + this.options.globalSymbols.clear(); + const owners = symbolOwners(this.options); + owners.clear(); + for (const [name, value] of this.baseGlobalSymbols) { + this.options.globalSymbols.set(name, value); + owners.set(name, this.baseGlobalSymbolOwners.get(name)); + } + for (const lib of this.options.loadedLibraries.values()) { + if (!lib.globalVisibility) continue; + for (const [name, value] of Object.entries(lib.exports)) { + if ( + !isPublicDylinkExport(name, value) + || this.options.globalSymbols.has(name) + ) { + continue; + } + this.options.globalSymbols.set(name, value); + owners.set(name, lib.name); + } + } + + const previousGot = new Map(this.options.got); + const liveGotKinds = new Map(); + for (const lib of this.options.loadedLibraries.values()) { + for (const { name, kind } of lib.gotImports) { + const previous = liveGotKinds.get(name); + if (previous !== undefined && previous !== kind) { + throw new Error( + `live GOT symbol ${name} is both ${previous} and ${kind}`, + ); + } + liveGotKinds.set(name, kind); + } + } + for (const name of this.baseGot.keys()) { + const kind = this.options.gotKinds!.get(name); + if (kind !== undefined) liveGotKinds.set(name, kind); + } + + this.options.got.clear(); + for (const [name, global] of this.baseGot) { + this.options.got.set(name, global); + } + for (const name of liveGotKinds.keys()) { + const global = previousGot.get(name); + if (!global) { + throw new Error(`live GOT symbol ${name} lost its Global cell`); + } + this.options.got.set(name, global); + } + for (const name of [...this.options.gotKinds!.keys()]) { + if (!this.options.got.has(name)) this.options.gotKinds!.delete(name); + } + for (const [name, kind] of liveGotKinds) { + this.options.gotKinds!.set(name, kind); + const global = this.options.got.get(name)!; + const symbol = this.options.globalSymbols.get(name); + if (kind === "mem" && symbol instanceof WebAssembly.Global) { + global.value = symbol.value; + continue; + } + if (kind === "func" && typeof symbol === "function") { + let index = -1; + const length = tableLength(this.options.table); + for (let candidate = 0; candidate < length; candidate++) { + if (getTableEntry(this.options.table, candidate) === symbol) { + index = candidate; + break; + } + } + if (index < 0) { + index = length; + growTable(this.options.table, 1); + setTableEntry(this.options.table, index, symbol); + this.options.onTableMutation?.(this.options.table, index, 1); + } + global.value = wasmAddress( + index, + this.options.ptrWidth ?? 4, + `GOT.func.${name}`, + ); + continue; + } + global.value = wasmAddress( + 0, + this.options.ptrWidth ?? 4, + `unresolved GOT.${kind}.${name}`, + ); + } + } + + private releaseUnretainedLibrary(lib: LoadedSharedLibrary): void { + if (this.libraryHandles.has(lib.name)) return; + if ((this.dependencyRetainCounts.get(lib.name) ?? 0) !== 0) return; + if (this.options.loadedLibraries.get(lib.name) !== lib) return; + + for (const dependency of runtimeDependencyNames(lib)) { + const retains = this.dependencyRetainCounts.get(dependency); + if (!Number.isInteger(retains) || retains! <= 0) { + throw new Error( + `${lib.name}: dependency ${dependency} has no matching retain`, + ); + } + } + // Remove the consumer before releasing its providers so recursive NEEDED + // chains observe the exact remaining live closure. + this.clearLibraryTableEntries(lib); + this.options.loadedLibraries.delete(lib.name); + this.dependencyOwners.delete(lib.name); + lib.unregisterForkActivation?.(); + const releaseFailures: unknown[] = []; + if (this.options.deallocateMemory) { + for (const allocation of [...(lib.allocations ?? [])].reverse()) { + try { + this.options.deallocateMemory(allocation.address, allocation.size); + } catch (error) { + releaseFailures.push(error); + } + } + } + lib.allocations = []; + for (const dependency of runtimeDependencyNames(lib)) { + const retains = this.dependencyRetainCounts.get(dependency)!; + if (retains === 1) this.dependencyRetainCounts.delete(dependency); + else this.dependencyRetainCounts.set(dependency, retains! - 1); + const provider = this.options.loadedLibraries.get(dependency); + if (provider) { + try { + this.releaseUnretainedLibrary(provider); + } catch (error) { + releaseFailures.push(error); + } + } + } + if (releaseFailures.length !== 0) { + throw new AggregateError( + releaseFailures, + `${lib.name}: final unload could not release every process mapping`, + ); + } + } + + private openLoadedLibrary( + lib: LoadedSharedLibrary, + replayHandle?: number, + ): number { + const existingHandle = this.libraryHandles.get(lib.name); + if (existingHandle !== undefined) { + if (this.handleMap.get(existingHandle) !== lib) { + throw new Error( + `${lib.name}: dynamic-linker handle index points at a different instance`, + ); + } + if (replayHandle !== undefined && replayHandle !== existingHandle) { + throw new Error( + `${lib.name}: replay open returned handle ${replayHandle}, ` + + `but the live handle is ${existingHandle}`, + ); + } + const references = this.handleRefCounts.get(existingHandle); + if ( + !Number.isInteger(references) + || references! <= 0 + || references! >= 0xffff_ffff + ) { + throw new Error( + `${lib.name}: dynamic-linker handle ${existingHandle} has invalid refcount`, + ); + } + this.handleRefCounts.set(existingHandle, references! + 1); + this.lastError = null; + return existingHandle; + } + + const handle = requireNonzeroU32( + replayHandle ?? this.handleCounter, + `${lib.name}: ${replayHandle === undefined ? "next" : "replay"} dlopen handle`, + ); + if (handle !== this.handleCounter) { + throw new Error( + `${lib.name}: replay dlopen handle ${handle} does not match ` + + `next handle ${this.handleCounter}`, + ); + } + if (this.handleMap.has(handle)) { + throw new Error(`${lib.name}: replay dlopen handle ${handle} is already in use`); + } + this.handleCounter = handle + 1; + this.handleMap.set(handle, lib); + this.libraryHandles.set(lib.name, handle); + this.handleRefCounts.set(handle, 1); + this.lastError = null; + return handle; + } + + /** Open a shared library. Returns a handle (>0) or 0 on error. + * + * New replay code must use `loadModuleSync` followed by `replayOpen`, because + * copied guest state requires the parent's exact handle rather than a newly + * allocated child handle. The replay option remains here only as a + * layout-preserving convenience for non-archived embedders. + */ + dlopenSync( + name: string, + wasmBytes: Uint8Array, + replay?: DylinkReplayOptions, + globalVisibility = true, + ): number { + try { + const loaded = this.loadModuleSync( + name, + wasmBytes, + replay, + globalVisibility, + ); + if (globalVisibility) { + promoteLibraryGlobal(loaded, this.options); + loaded.committedGlobalRoot = true; + refreshGlobalGotEntries(this.options); + } + return this.openLoadedLibrary(loaded); + } catch (e) { + this.lastError = e instanceof Error ? e.message : String(e); + return 0; + } + } + + /** + * Replay one successful parent dlopen event after its module-load event. + * + * The exact returned handle is part of process state: guest code may retain + * it in copied memory. A mismatch therefore rejects replay instead of + * silently allocating a child-local replacement. + */ + replayOpen(name: string, exactHandle: number): number { + const lib = this.options.loadedLibraries.get(name); + if (!lib) { + throw new Error(`${name}: replay dlopen requires a prior module-load event`); + } + return this.openLoadedLibrary(lib, exactHandle); + } + + private symbolAddress( + symbolName: string, + exp: Function | WebAssembly.Global | undefined, + ): number | null { + if (typeof exp === "function") { + // Return the table index for this function (C function pointers are table indices) + const table = this.options.table; + const length = tableLength(table); + for (let i = 0; i < length; i++) { + if (getTableEntry(table, i) === exp) { + this.lastError = null; + return i; + } + } + // Not in table yet — add it + const idx = length; + growTable(table, 1); + setTableEntry(table, idx, exp as unknown as Function); + this.options.onTableMutation?.(table, idx, 1); + this.lastError = null; + return idx; + } + + if (exp instanceof WebAssembly.Global) { + this.lastError = null; + return Number(exp.value); + } + + this.lastError = `symbol not found: ${symbolName}`; + return null; + } + + private recordConstructorProvider(owner: string | undefined): void { + if (owner === undefined) return; + const active = [...this.pendingDlopens.values()] + .reverse() + .find((transaction) => + transaction.currentStep?.stage === "constructors" + ); + const consumerName = active?.currentStep?.libraryName; + if (!consumerName || consumerName === owner) return; + const consumer = this.options.loadedLibraries.get(consumerName); + if (!consumer) return; + const dependencies = consumer.providerDependencies instanceof Set + ? consumer.providerDependencies + : new Set(consumer.providerDependencies ?? []); + dependencies.add(owner); + consumer.providerDependencies = dependencies; + } + + /** Look up a symbol by name. Returns its function-table index or data address. */ + dlsym(handle: number, symbolName: string): number | null { + if (isForkRuntimeExport(symbolName)) { + this.lastError = `symbol not found: ${symbolName}`; + return null; + } + if (handle === DynamicLinker.MAIN_PROGRAM_HANDLE || handle === 0) { + this.recordConstructorProvider( + symbolOwners(this.options).get(symbolName), + ); + return this.symbolAddress( + symbolName, + this.options.globalSymbols.get(symbolName), + ); + } + + const lib = this.handleMap.get(handle); + if (!lib) { + this.lastError = "invalid handle"; + return null; + } + + const scope: LoadedSharedLibrary[] = []; + const seen = new Set(); + const queue = [lib]; + for (let index = 0; index < queue.length; index++) { + const candidate = queue[index]!; + if (seen.has(candidate.name)) continue; + seen.add(candidate.name); + scope.push(candidate); + for (const dependencyName of candidate.metadata.neededDynlibs) { + const dependency = this.options.loadedLibraries.get(dependencyName); + if (dependency && !seen.has(dependency.name)) queue.push(dependency); + } + } + for (const candidate of scope) { + const exp = candidate.exports[symbolName]; + if (typeof exp !== "function" && !(exp instanceof WebAssembly.Global)) { + continue; + } + this.recordConstructorProvider(candidate.name); + return this.symbolAddress(symbolName, exp); + } + this.lastError = `symbol not found: ${symbolName}`; + return null; + } + + private closeHandle(handle: number): void { + if (handle === DynamicLinker.MAIN_PROGRAM_HANDLE) { + this.lastError = null; + return; + } + const lib = this.handleMap.get(handle); + if (!lib) { + throw new Error(`invalid dlopen handle ${handle}`); + } + if (this.libraryHandles.get(lib.name) !== handle) { + throw new Error( + `${lib.name}: dynamic-linker library index does not match handle ${handle}`, + ); + } + const references = this.handleRefCounts.get(handle); + if (!Number.isInteger(references) || references! <= 0) { + throw new Error(`${lib.name}: handle ${handle} has invalid refcount`); + } + if (references! > 1) { + this.handleRefCounts.set(handle, references! - 1); + this.lastError = null; + return; } this.handleMap.delete(handle); + this.libraryHandles.delete(lib.name); + this.handleRefCounts.delete(handle); + this.releaseUnretainedLibrary(lib); + this.rebuildRuntimeIndexes(); this.lastError = null; + } + + /** Close a library handle. Returns 0 on success. */ + dlclose(handle: number): number { + try { + this.closeHandle(handle); + } catch (error) { + this.lastError = error instanceof Error ? error.message : String(error); + return -1; + } return 0; } + /** Replay one successful parent dlclose event exactly. */ + replayClose(exactHandle: number): void { + requireNonzeroU32(exactHandle, "replay dlclose handle"); + this.closeHandle(exactHandle); + } + /** Get the last error message, or null if no error. */ dlerror(): string | null { const err = this.lastError; diff --git a/host/src/fork-activation-registry.ts b/host/src/fork-activation-registry.ts new file mode 100644 index 0000000000..6707a10de7 --- /dev/null +++ b/host/src/fork-activation-registry.ts @@ -0,0 +1,1790 @@ +import { + WPK_FORK_EXPORT_MODULE_STATE_FINISH_RESTORE, + WPK_FORK_MODULE_STATE_IMPORT_RECORD_COMMIT, + WPK_FORK_MODULE_STATE_IMPORT_RECORD_FIND, + WPK_FORK_MODULE_STATE_IMPORT_RECORD_RESERVE, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_COUNT, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_MARK, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_PAGE, + WPK_FORK_MODULE_STATE_IMPORT_TABLE_STATE_OWNED, + WPK_FORK_MODULE_STATE_TABLE_PAGE_SHIFT, + WPK_FORK_REFERENCE_IMPORT_DECODE_EXTERNREF, + WPK_FORK_REFERENCE_IMPORT_DECODE_FUNCREF, + WPK_FORK_REFERENCE_IMPORT_ENCODE_EXTERNREF, + WPK_FORK_REFERENCE_IMPORT_ENCODE_FUNCREF, + WPK_FORK_REFERENCE_IMPORT_GC_BROKER_ENCODE, + WPK_FORK_REFERENCE_IMPORT_GC_CAPTURE_LAYOUT, + WPK_FORK_REFERENCE_IMPORT_GC_CLAIM, + WPK_FORK_REFERENCE_IMPORT_GC_DEFINE, + WPK_FORK_REFERENCE_IMPORT_GC_I31, + WPK_FORK_REFERENCE_IMPORT_GC_LOAD, + WPK_FORK_REFERENCE_IMPORT_GC_LOOKUP, + WPK_FORK_REFERENCE_IMPORT_GC_PAYLOAD_LEN, + WPK_FORK_REFERENCE_IMPORT_GC_PROVENANCE_BEGIN, + WPK_FORK_REFERENCE_IMPORT_GC_PROVENANCE_END, + WPK_FORK_REFERENCE_IMPORT_GC_PROVENANCE_REF, + WPK_FORK_REFERENCE_IMPORT_GC_ROUTE, + WPK_FORK_REFERENCE_IMPORT_VECTOR_APPEND, + WPK_FORK_REFERENCE_IMPORT_VECTOR_BEGIN, + WPK_FORK_REFERENCE_IMPORT_VECTOR_FINISH, + WPK_FORK_REFERENCE_IMPORT_VECTOR_GET, + WPK_FORK_TABLE_CATALOG_EXPORT_PREFIX, +} from "./generated/abi"; +import { + FORK_ANYREF_TRANSIT_IMPORT, + ForkAnyrefTransitTable, +} from "./fork-anyref-transit"; +import { + ForkModuleStateArena, + ForkModuleStateRecordKind, + ForkTableDirtyTracker, + requireForkModuleTemplate, +} from "./fork-module-state"; +import { + FORK_FUNCTION_CATALOG_EXPORT, + ForkFunctionCatalog, +} from "./fork-function-catalog"; +import type { + DylinkForkTablePatch, + DylinkForkTablePatchRun, +} from "./dylink-fork-archive"; +import { + FORK_GC_LAYOUT_REQUIRES_PROVENANCE, + ForkGcProvenanceRegistry, + forkGcCodecProviderFromInstance, + type ForkGcCodecProvider, +} from "./fork-gc-codec"; +import { + FORK_HOST_EXCEPTION_ACTIVATION_ID, + ForkReferenceTransaction, + type ForkGcDefinitionProvenance, + type ForkExternrefRecipeProvider, + type ForkReferenceScratchAllocate, + type ForkReferenceScratchDeallocate, +} from "./fork-reference-transaction"; +import type { + DecodedSegmentedForkReferenceTransaction, +} from "./fork-reference-segments"; +import { + clearForkStaticRootTable, + FORK_STATIC_ROOT_CATALOG_EXPORT, + FORK_STATIC_ROOT_HARVEST_EXPORT, + ForkStaticRootCatalog, +} from "./fork-static-root-catalog"; + +export const FORK_MODULE_BOOTSTRAP_EXPORT = "wpk_fork_module_bootstrap"; +export const FORK_MODULE_STATE_SAVE_EXPORT = "wpk_fork_module_state_save"; +export const FORK_MODULE_STATE_RESTORE_EXPORT = "wpk_fork_module_state_restore"; +export const FORK_MODULE_STATE_FINISH_RESTORE_EXPORT = + WPK_FORK_EXPORT_MODULE_STATE_FINISH_RESTORE; +export const FORK_MODULE_TABLE_STATE_SAVE_EXPORT = + "wpk_fork_module_table_state_save"; +export const FORK_MODULE_TABLE_STATE_RESTORE_EXPORT = + "wpk_fork_module_table_state_restore"; +export const FORK_MODULE_TABLE_GENERATION_ADDR_IMPORT = + "__wpk_fork_module_state_table_generation_addr"; +export const FORK_MODULE_TABLE_MUTATION_BEGIN_IMPORT = + "__wpk_fork_module_state_table_mutation_begin"; +export const FORK_MODULE_TABLE_MUTATION_COMMIT_IMPORT = + "__wpk_fork_module_state_table_mutation_commit"; +export const FORK_MODULE_TABLE_MUTATION_ABORT_IMPORT = + "__wpk_fork_module_state_table_mutation_abort"; +export const FORK_MODULE_TABLE_RECONCILE_IMPORT = + "__wpk_fork_module_state_table_reconcile"; + +export interface ForkActivationTableReplication { + /** Immutable pointer-width address of the shared generation fence. */ + readonly generationAddress: WebAssembly.Global; + /** + * Acquire the process writer, apply the latest snapshot, and return its + * exact generation. Ownership remains live until commit() or abort(). + */ + beginMutation(): bigint; + /** Apply the latest process snapshot and return its exact generation. */ + reconcile(): bigint; + /** Publish a successful guest mutation and release writer ownership. */ + commit( + activationId: number, + ownerId: number, + firstIndex: number | bigint, + length: number | bigint, + ): void; + /** Release mutation writer ownership after a non-mutating failure/no-op. */ + abort(): void; +} + +export interface ForkActivationExceptionProvider { + /** Throw the exact exception currently rooted in an activation-local slot. */ + throwSlot(slot: number): never; + /** Throw an exception reconstructed from the process recipe graph. */ + throwRecipe(recipeId: number): never; + /** Route a host/JSTag ingress token into the process recipe graph. */ + encodeIngress(token: number): number; + /** Decode/cache a recipe without returning an `exnref` through JavaScript. */ + materialize?(recipeId: number): void; + /** Release transient roots after the outermost replay frame is restored. */ + clear(): void; + /** Release the same roots when capture or replay aborts. */ + abort(): void; +} + +export interface ForkActivationTypedReferenceProvider { + readonly activationId?: number; + readonly descriptor?: ForkGcCodecProvider["descriptor"]; + probe?(slot: number): bigint; + encodeSlot?(slot: number): number; + allocate?(recipeId: number): void; + fill?(recipeId: number): void; + publishExternref?(recipeId: number, value: unknown): void; + /** Release transient GC/reference codec roots after successful replay. */ + clear?(): void; + /** Release the same roots when capture or replay aborts. */ + abort?(): void; +} + +export interface ForkActivationModuleState { + /** Parent-only initialization: active segments followed by the original start. */ + bootstrap(): void; + /** Append this activation's globals, sparse tables, and segment lifetimes. */ + save(activationId: number): void; + /** Restore this activation before any continuation frame executes. */ + restore(activationId: number): void; + /** Drop passive segments after typed constructor replay has completed. */ + finishRestore(activationId: number): void; + /** Append only cumulative sparse table state to a peer-replication arena. */ + saveTables(activationId: number): void; + /** Restore only cumulative sparse table state in another Worker instance. */ + restoreTables(activationId: number): void; +} + +export interface ForkActivationRegistration { + readonly activationId: number; + readonly instance: WebAssembly.Instance; + /** SHA-256 of the exact instrumented module bytes. */ + readonly templateId: Uint8Array; + readonly functionCatalog: WebAssembly.Table; + /** + * Immutable GC/reference roots recreated by this exact instantiation. + * + * Recipes name these by activation and ordinal so `ref.eq` aliases resolve + * to the child's own canonical root instead of a structural clone. + */ + readonly staticRootCatalog: WebAssembly.Table; + /** Populate the one-shot static-root observation table before registration. */ + readonly staticRootHarvest: () => void; + readonly moduleState: ForkActivationModuleState; + readonly exceptionProvider?: ForkActivationExceptionProvider; + readonly typedReferenceProvider?: ForkActivationTypedReferenceProvider; + /** + * One journal per activation because generated owner ordinals are local to + * an artifact. Imported/shared table identity is deduplicated by the loader + * before it binds an activation to a journal. + */ + readonly tableDirty: ForkTableDirtyTracker; +} + +/** + * Replay-only scalar callbacks needed while a fresh child is still + * instantiating its activation graph. + * + * Capture callbacks deliberately remain registry-owned. Before `attachChild` + * there is no capture transaction, so invoking one is a phase error rather + * than an invitation to mutate the copied recipe graph. + */ +export interface ForkActivationReferenceReplayImports { + decodeFuncref(recipeId: number): CallableFunction | null; + decodeExternref(recipeId: number): unknown; + getReferenceVector(ordinal: number, index: number): number; + routeGc(recipeId: number, expectedActivation: number): number; + gcPayloadLength( + recipeId: number, + expectedActivation: number, + expectedLayoutId: number, + ): number; + loadGc( + recipeId: number, + moduleActivation: number, + typeOrdinal: number, + layoutId: number, + kind: number, + scalarDestination: number | bigint, + scalarByteLength: number, + ): number; +} + +type RegistryPhase = + | "idle" + | "capture" + | "table-capture" + | "sealed-parent" + | "parent-replay" + | "child-replay" + | "table-replay"; + +function assertActivationId(value: number): void { + if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff) { + throw new RangeError(`invalid fork module activation id ${value}`); + } +} + +function checkedTableMutationIndex( + value: number | bigint, + context: string, +): bigint { + if ( + typeof value === "number" + && (!Number.isSafeInteger(value) || value < 0) + ) { + throw new RangeError(`${context} must be an exact non-negative integer`); + } + const result = typeof value === "bigint" ? value : BigInt(value); + if (result < 0n || result >= (1n << 64n)) { + throw new RangeError(`${context} exceeds the WebAssembly table64 index space`); + } + return result; +} + +interface ForkRegisteredTableCoordinate { + readonly activationId: number; + readonly ownerId: number; + readonly tracker: ForkTableDirtyTracker; +} + +interface ForkActivationTableCatalogEntry { + readonly ownerId: number; + readonly table: WebAssembly.Table; +} + +function activationTableCatalog( + registration: ForkActivationRegistration, + label: string, +): ForkActivationTableCatalogEntry[] { + const entries: ForkActivationTableCatalogEntry[] = []; + for (const [name, value] of Object.entries(registration.instance.exports)) { + if (!name.startsWith(WPK_FORK_TABLE_CATALOG_EXPORT_PREFIX)) continue; + const suffix = name.slice(WPK_FORK_TABLE_CATALOG_EXPORT_PREFIX.length); + const ownerId = Number(suffix); + if ( + !/^[1-9][0-9]*$/.test(suffix) + || !Number.isSafeInteger(ownerId) + || ownerId > 0xffff_ffff + ) { + throw new Error(`${label}: malformed private table catalog export ${name}`); + } + if (!(value instanceof WebAssembly.Table)) { + throw new Error(`${label}: private table catalog ${name} is not a Table`); + } + entries.push({ ownerId, table: value }); + } + entries.sort((left, right) => left.ownerId - right.ownerId); + return entries; +} + +function copyTemplateId(value: Uint8Array): Uint8Array { + if (!(value instanceof Uint8Array) || value.byteLength !== 32) { + throw new TypeError("fork module template id must contain exactly 32 bytes"); + } + return value.slice(); +} + +function requireExportFunction( + instance: WebAssembly.Instance, + name: string, +): CallableFunction { + const value = instance.exports[name]; + if (typeof value !== "function") { + throw new Error(`fork module activation is missing function export ${name}`); + } + return value as CallableFunction; +} + +function requireExportTable( + instance: WebAssembly.Instance, + name: string, +): WebAssembly.Table { + const value = instance.exports[name]; + if (!(value instanceof WebAssembly.Table)) { + throw new Error(`fork module activation is missing table export ${name}`); + } + return value; +} + +/** + * Resolve the uniform ABI 43 activation exports after instantiation. + * + * Keeping this reflection in one place makes main modules, pthread instances, + * and dlopen activations obey the same state-ownership contract. + */ +export function forkActivationRegistrationFromInstance(options: { + activationId: number; + module?: WebAssembly.Module; + instance: WebAssembly.Instance; + templateId: Uint8Array; + tableDirty?: ForkTableDirtyTracker; + exceptionProvider?: ForkActivationExceptionProvider; + typedReferenceProvider?: ForkActivationTypedReferenceProvider; +}): ForkActivationRegistration { + const { + activationId, + instance, + exceptionProvider, + typedReferenceProvider, + } = options; + assertActivationId(activationId); + const bootstrap = requireExportFunction(instance, FORK_MODULE_BOOTSTRAP_EXPORT); + const save = requireExportFunction(instance, FORK_MODULE_STATE_SAVE_EXPORT); + const restore = requireExportFunction(instance, FORK_MODULE_STATE_RESTORE_EXPORT); + const finishRestore = requireExportFunction( + instance, + FORK_MODULE_STATE_FINISH_RESTORE_EXPORT, + ); + const saveTables = requireExportFunction( + instance, + FORK_MODULE_TABLE_STATE_SAVE_EXPORT, + ); + const restoreTables = requireExportFunction( + instance, + FORK_MODULE_TABLE_STATE_RESTORE_EXPORT, + ); + const harvestStaticRoots = requireExportFunction( + instance, + FORK_STATIC_ROOT_HARVEST_EXPORT, + ); + return { + activationId, + instance, + templateId: copyTemplateId(options.templateId), + functionCatalog: requireExportTable(instance, FORK_FUNCTION_CATALOG_EXPORT), + staticRootCatalog: requireExportTable( + instance, + FORK_STATIC_ROOT_CATALOG_EXPORT, + ), + staticRootHarvest: () => { harvestStaticRoots(); }, + moduleState: { + bootstrap: () => { bootstrap(); }, + save: (id) => { save(id); }, + restore: (id) => { restore(id); }, + finishRestore: (id) => { finishRestore(id); }, + saveTables: (id) => { saveTables(id); }, + restoreTables: (id) => { restoreTables(id); }, + }, + exceptionProvider, + typedReferenceProvider: typedReferenceProvider ?? ( + options.module + ? forkGcCodecProviderFromInstance(activationId, options.module, instance) + : undefined + ), + tableDirty: options.tableDirty ?? new ForkTableDirtyTracker(), + }; +} + +/** + * Scalar/JS-callable imports shared by every activation. + * + * Typed GC and exception codecs are activation-local Wasm functions and are + * bound separately by their providers. Keeping this helper to callbacks that + * JavaScript can represent prevents an accidental exnref/anyref round-trip + * through the embedding API. + */ +export function buildForkActivationStateImports( + activationId: number, + registry: ForkActivationRegistry, + referenceReplay: () => ForkActivationReferenceReplayImports = + () => registry.currentReferences(), + tableReplication?: ForkActivationTableReplication, +): Record { + assertActivationId(activationId); + const arena = () => registry.currentArena(); + const references = () => registry.currentReferences(); + const tableDirty = () => registry.tableDirty(activationId); + return { + [FORK_ANYREF_TRANSIT_IMPORT]: registry.gcTransitTable(), + [WPK_FORK_MODULE_STATE_IMPORT_RECORD_RESERVE]: ( + kind: number, + recordActivationId: number, + ownerId: number, + payloadSize: number | bigint, + ) => { + if (recordActivationId !== activationId) { + throw new Error( + `activation ${activationId} cannot reserve module state for ` + + `activation ${recordActivationId}`, + ); + } + return arena().reserveRecord( + kind, + recordActivationId, + ownerId, + payloadSize, + ); + }, + [WPK_FORK_MODULE_STATE_IMPORT_RECORD_COMMIT]: ( + payload: number | bigint, + ): void => arena().commitRecord(payload), + [WPK_FORK_MODULE_STATE_IMPORT_RECORD_FIND]: ( + kind: number, + recordActivationId: number, + ownerId: number, + ordinal: number, + ) => { + if (recordActivationId !== activationId) { + throw new Error( + `activation ${activationId} cannot restore module state for ` + + `activation ${recordActivationId}`, + ); + } + return arena().findRecord( + kind, + recordActivationId, + ownerId, + ordinal, + ); + }, + [WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_MARK]: ( + ownerId: number, + firstPage: number | bigint, + pageCount: number | bigint, + ): void => tableDirty().markPages(ownerId, firstPage, pageCount), + [WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_COUNT]: ( + ownerId: number, + ): number => tableDirty().pageCount(ownerId), + [WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_PAGE]: ( + ownerId: number, + ordinal: number, + ): bigint => tableDirty().pageAt(ownerId, ordinal), + [WPK_FORK_MODULE_STATE_IMPORT_TABLE_STATE_OWNED]: ( + ownerId: number, + ): number => Number(tableDirty().ownsState(ownerId)), + [FORK_MODULE_TABLE_GENERATION_ADDR_IMPORT]: + tableReplication?.generationAddress + ?? new WebAssembly.Global({ value: "i64", mutable: false }, 0n), + [FORK_MODULE_TABLE_RECONCILE_IMPORT]: (): bigint => + tableReplication?.reconcile() ?? 0n, + [FORK_MODULE_TABLE_MUTATION_BEGIN_IMPORT]: (): bigint => + tableReplication?.beginMutation() ?? 0n, + [FORK_MODULE_TABLE_MUTATION_COMMIT_IMPORT]: ( + ownerId: number, + firstIndex: number | bigint, + length: number | bigint, + ): void => { + tableReplication?.commit( + activationId, + ownerId, + firstIndex, + length, + ); + }, + [FORK_MODULE_TABLE_MUTATION_ABORT_IMPORT]: (): void => { + tableReplication?.abort(); + }, + [WPK_FORK_REFERENCE_IMPORT_ENCODE_FUNCREF]: ( + value: unknown, + ): number => references().encodeFuncref(value), + [WPK_FORK_REFERENCE_IMPORT_DECODE_FUNCREF]: ( + recipeId: number, + ): CallableFunction | null => referenceReplay().decodeFuncref(recipeId >>> 0), + [WPK_FORK_REFERENCE_IMPORT_ENCODE_EXTERNREF]: ( + value: unknown, + ): number => references().encodeExternref(value), + [WPK_FORK_REFERENCE_IMPORT_DECODE_EXTERNREF]: ( + recipeId: number, + ): unknown => referenceReplay().decodeExternref(recipeId >>> 0), + [WPK_FORK_REFERENCE_IMPORT_VECTOR_BEGIN]: ( + expectedLength: number, + ): number => references().beginReferenceVector(expectedLength >>> 0), + [WPK_FORK_REFERENCE_IMPORT_VECTOR_APPEND]: ( + handle: number, + recipeId: number, + ): void => references().appendReferenceVector(handle >>> 0, recipeId >>> 0), + [WPK_FORK_REFERENCE_IMPORT_VECTOR_FINISH]: ( + handle: number, + ): number => references().finishReferenceVector(handle >>> 0), + [WPK_FORK_REFERENCE_IMPORT_VECTOR_GET]: ( + ordinal: number, + index: number, + ): number => referenceReplay().getReferenceVector( + ordinal >>> 0, + index >>> 0, + ), + [WPK_FORK_REFERENCE_IMPORT_GC_LOOKUP]: ( + slot: number, + ): number => registry.lookupGcSlot(activationId, slot), + [WPK_FORK_REFERENCE_IMPORT_GC_CLAIM]: ( + slot: number, + ): number => registry.claimGcSlot(slot), + [WPK_FORK_REFERENCE_IMPORT_GC_I31]: ( + value: number, + ): number => registry.encodeI31(value), + [WPK_FORK_REFERENCE_IMPORT_GC_DEFINE]: ( + recipeId: number, + recordActivationId: number, + typeOrdinal: number, + layoutId: number, + kind: number, + scalarPointer: number | bigint, + scalarByteLength: number, + referenceVectorOrdinal: number, + ): void => registry.defineGc( + activationId, + recipeId >>> 0, + recordActivationId >>> 0, + typeOrdinal, + layoutId, + kind, + scalarPointer, + scalarByteLength, + referenceVectorOrdinal >>> 0, + ), + [WPK_FORK_REFERENCE_IMPORT_GC_ROUTE]: ( + recipeId: number, + expectedActivation: number, + ): number => referenceReplay().routeGc( + recipeId >>> 0, + expectedActivation >>> 0, + ), + [WPK_FORK_REFERENCE_IMPORT_GC_PAYLOAD_LEN]: ( + recipeId: number, + expectedActivation: number, + expectedLayoutId: number, + ): number => referenceReplay().gcPayloadLength( + recipeId >>> 0, + expectedActivation >>> 0, + expectedLayoutId, + ), + [WPK_FORK_REFERENCE_IMPORT_GC_LOAD]: ( + recipeId: number, + moduleActivation: number, + typeOrdinal: number, + layoutId: number, + kind: number, + scalarDestination: number | bigint, + scalarByteLength: number, + ): number => referenceReplay().loadGc( + recipeId >>> 0, + moduleActivation >>> 0, + typeOrdinal, + layoutId, + kind, + scalarDestination, + scalarByteLength, + ), + [WPK_FORK_REFERENCE_IMPORT_GC_BROKER_ENCODE]: ( + slot: number, + ): number => registry.encodeGcFromSlot(activationId, slot), + [WPK_FORK_REFERENCE_IMPORT_GC_CAPTURE_LAYOUT]: ( + slot: number, + recordActivationId: number, + baseLayoutId: number, + ): number => { + if (recordActivationId !== activationId) { + throw new Error( + `activation ${activationId} cannot select GC layout for ` + + `activation ${recordActivationId}`, + ); + } + return registry.captureGcLayout( + activationId, + slot, + baseLayoutId, + ); + }, + [WPK_FORK_REFERENCE_IMPORT_GC_PROVENANCE_BEGIN]: ( + slot: number, + recordActivationId: number, + baseLayoutId: number, + specializedLayoutId: number, + scalarLo: bigint, + scalarHi: bigint, + referenceCount: number, + ): number => registry.beginGcProvenance( + activationId, + slot, + recordActivationId, + baseLayoutId, + specializedLayoutId, + scalarLo, + scalarHi, + referenceCount, + ), + [WPK_FORK_REFERENCE_IMPORT_GC_PROVENANCE_REF]: ( + token: number, + index: number, + slot: number, + ): void => registry.appendGcProvenanceReference(token, index, slot), + [WPK_FORK_REFERENCE_IMPORT_GC_PROVENANCE_END]: ( + token: number, + ): void => registry.endGcProvenance(token), + }; +} + +/** + * Process-worker owner for every Wasm module activation participating in fork. + * + * The registry owns no copied Wasm references. It rebuilds a transient + * function-identity catalog for each fork transaction, while durable state is + * represented only by bytes in the linked continuation/KFMS arena. Main, + * side-module, and pthread paths all register here before state restore. + */ +export class ForkActivationRegistry { + private readonly registrations = new Map(); + private readonly bootstrapped = new Set(); + private tableCoordinates = new WeakMap< + WebAssembly.Table, + ForkRegisteredTableCoordinate[] + >(); + private readonly activationTables = + new Map(); + /** + * Live process catalog used only for short table-delta recipes. + * + * Unlike a fork transaction catalog, this catalog follows dlopen/dlclose so + * a successful guest mutation can be encoded in O(changed range) without + * rebuilding every activation's function index on each table.set/fill. + */ + private readonly tablePatchFunctions = new ForkFunctionCatalog(); + private phase: RegistryPhase = "idle"; + private arena: ForkModuleStateArena | null = null; + private references: ForkReferenceTransaction | null = null; + private functions: ForkFunctionCatalog | null = null; + private readonly staticRoots = new ForkStaticRootCatalog(); + private readonly gcTransit = new ForkAnyrefTransitTable(); + private readonly gcProvenance = new ForkGcProvenanceRegistry(); + + constructor( + private readonly memory: WebAssembly.Memory, + private readonly externrefs: ForkExternrefRecipeProvider, + private readonly label: string, + private readonly allocateScratch?: ForkReferenceScratchAllocate, + private readonly deallocateScratch?: ForkReferenceScratchDeallocate, + ) {} + + registerActivation(registration: ForkActivationRegistration): void { + this.requireIdle("register a module activation"); + assertActivationId(registration.activationId); + if (this.registrations.has(registration.activationId)) { + throw new Error( + `${this.label}: module activation ${registration.activationId} is already registered`, + ); + } + const ownedRegistration = { + ...registration, + templateId: copyTemplateId(registration.templateId), + }; + const tableCatalog = activationTableCatalog(ownedRegistration, this.label); + try { + ownedRegistration.staticRootHarvest(); + this.staticRoots.register( + ownedRegistration.activationId, + ownedRegistration.staticRootCatalog, + ); + } catch (error) { + // A trapping harvest can have populated a strict prefix. Never let a + // failed dlopen/activation registration retain those temporary roots. + clearForkStaticRootTable(ownedRegistration.staticRootCatalog); + throw error; + } + try { + this.tablePatchFunctions.register( + ownedRegistration.activationId, + ownedRegistration.functionCatalog, + ); + } catch (error) { + this.staticRoots.unregister(ownedRegistration.activationId); + throw error; + } + this.registrations.set(registration.activationId, ownedRegistration); + this.activationTables.set(registration.activationId, tableCatalog); + const affectedTables = new Set(); + for (const { ownerId, table } of tableCatalog) { + const coordinates = this.tableCoordinates.get(table) ?? []; + coordinates.push({ + activationId: registration.activationId, + ownerId, + tracker: ownedRegistration.tableDirty, + }); + coordinates.sort( + (left, right) => + left.activationId - right.activationId + || left.ownerId - right.ownerId, + ); + this.tableCoordinates.set(table, coordinates); + affectedTables.add(table); + } + for (const table of affectedTables) { + this.bindTableCoordinates(table); + } + } + + getActivation(activationId: number): ForkActivationRegistration { + assertActivationId(activationId); + const registration = this.registrations.get(activationId); + if (!registration) { + throw new Error(`${this.label}: module activation ${activationId} is not registered`); + } + return registration; + } + + activations(): readonly ForkActivationRegistration[] { + return [...this.registrations.values()].sort( + (left, right) => left.activationId - right.activationId, + ); + } + + unregisterActivation(activationId: number): void { + this.requireIdle("unregister a module activation"); + const registration = this.getActivation(activationId); + // A provider may retain scratch roots even when no fork is active (for + // example, a caught exception awaiting an ingress callback). Abort is the + // stronger teardown operation and is therefore correct for dlclose/exec. + registration.exceptionProvider?.abort(); + registration.typedReferenceProvider?.abort?.(); + const affectedTables = new Set(); + for (const { table } of this.activationTables.get(activationId) ?? []) { + const remaining = (this.tableCoordinates.get(table) ?? []) + .filter((coordinate) => coordinate.activationId !== activationId); + if (remaining.length === 0) this.tableCoordinates.delete(table); + else { + this.tableCoordinates.set(table, remaining); + affectedTables.add(table); + } + } + this.activationTables.delete(activationId); + for (const table of affectedTables) { + this.bindTableCoordinates(table); + } + this.registrations.delete(activationId); + this.tablePatchFunctions.unregister(activationId); + this.bootstrapped.delete(activationId); + this.staticRoots.unregister(activationId); + } + + bootstrapActivation(activationId: number): void { + this.requireIdle("bootstrap a module activation"); + if (this.bootstrapped.has(activationId)) { + throw new Error(`${this.label}: module activation ${activationId} was bootstrapped twice`); + } + const registration = this.getActivation(activationId); + registration.moduleState.bootstrap(); + this.bootstrapped.add(activationId); + } + + tableDirty(activationId: number): ForkTableDirtyTracker { + return this.getActivation(activationId).tableDirty; + } + + /** + * Record a successful host-side mutation of an activation-owned Table. + * + * Dynamic-linker helpers can call `Table.grow`/`Table.set` without executing + * an instrumented Wasm opcode. Resolve the actual Table identity back to all + * live catalog coordinates and mark the same sparse pages the guest hook + * would have marked. Marking aliases is idempotent after journal union and + * also preserves mutations made before a newly loaded alias is bound. + */ + markTableMutation( + table: WebAssembly.Table, + firstIndexValue: number | bigint, + lengthValue: number | bigint, + ): void { + this.requireIdle("record a host table mutation"); + if (!(table instanceof WebAssembly.Table)) { + throw new TypeError(`${this.label}: host table mutation target is not a Table`); + } + const coordinates = this.tableCoordinates.get(table); + if (!coordinates || coordinates.length === 0) { + throw new Error( + `${this.label}: host mutated a Table outside the registered fork catalogs`, + ); + } + const firstIndex = checkedTableMutationIndex( + firstIndexValue, + "fork table mutation first index", + ); + const length = checkedTableMutationIndex( + lengthValue, + "fork table mutation length", + ); + if (length === 0n) return; + const end = firstIndex + length; + if (end > (1n << 64n)) { + throw new RangeError("fork table mutation range exceeds table64"); + } + const shift = BigInt(WPK_FORK_MODULE_STATE_TABLE_PAGE_SHIFT); + const firstPage = firstIndex >> shift; + const finalPage = (end - 1n) >> shift; + const pageCount = finalPage - firstPage + 1n; + const canonical = coordinates[0]!; + canonical.tracker.markPages(canonical.ownerId, firstPage, pageCount); + } + + /** + * Encode one successful null/funcref mutation using stable activation + * coordinates. + * + * `null` means that this exact range needs the full typed KFMS checkpoint: + * externref, exnref, GC values, and engine-hidden table kinds deliberately + * stay on the Wasm-owned codec path instead of crossing JavaScript. + */ + captureFuncrefTablePatch( + activationId: number, + ownerId: number, + firstIndexValue: number | bigint, + lengthValue: number | bigint, + ): DylinkForkTablePatch | null { + this.requireIdle("capture a table mutation patch"); + const table = this.requireActivationTable(activationId, ownerId); + const firstIndex = checkedTableMutationIndex( + firstIndexValue, + "fork table patch first index", + ); + const length = checkedTableMutationIndex( + lengthValue, + "fork table patch length", + ); + if (length === 0n) { + throw new Error(`${this.label}: cannot publish an empty table mutation`); + } + const end = firstIndex + length; + if ( + end > BigInt(table.length) + || firstIndex > BigInt(Number.MAX_SAFE_INTEGER) + || end > BigInt(Number.MAX_SAFE_INTEGER) + ) { + throw new RangeError( + `${this.label}: table patch range does not match its final Table`, + ); + } + + const start = Number(firstIndex); + const count = Number(length); + const runs: DylinkForkTablePatchRun[] = []; + for (let offset = 0; offset < count; offset++) { + let value: unknown; + try { + value = table.get(start + offset); + } catch { + return null; + } + let recipe: DylinkForkTablePatchRun["function"]; + if (value === null) { + recipe = null; + } else if (typeof value === "function") { + try { + const encoded = this.tablePatchFunctions.encode(value); + if (!encoded) return null; + recipe = { + activationId: encoded.moduleActivation, + ordinal: encoded.ordinal, + }; + } catch { + return null; + } + } else { + return null; + } + const previous = runs.at(-1); + if ( + previous + && ( + previous.function === null + ? recipe === null + : recipe !== null + && previous.function.activationId === recipe.activationId + && previous.function.ordinal === recipe.ordinal + ) + ) { + runs[runs.length - 1] = { + length: previous.length + 1, + function: previous.function, + }; + } else { + runs.push({ length: 1, function: recipe }); + } + } + return { + activationId, + ownerId, + start, + tableLength: table.length, + runs, + }; + } + + /** + * Apply one stable null/funcref patch with this Worker's own function + * objects. The process writer lock is held by the caller. + */ + applyFuncrefTablePatch(patch: DylinkForkTablePatch): void { + this.requireIdle("apply a table mutation patch"); + const table = this.requireActivationTable( + patch.activationId, + patch.ownerId, + ); + if ( + patch.generation === undefined + || !Number.isSafeInteger(patch.start) + || patch.start < 0 + || !Number.isSafeInteger(patch.tableLength) + || patch.tableLength < 0 + ) { + throw new Error(`${this.label}: table patch is not a published recipe`); + } + const decodedRuns: Array<{ + readonly length: number; + readonly value: CallableFunction | null; + }> = []; + let changedLength = 0; + for (const run of patch.runs) { + if (!Number.isSafeInteger(run.length) || run.length <= 0) { + throw new Error(`${this.label}: table patch has an invalid run`); + } + const value = run.function === null + ? null + : this.tablePatchFunctions.decode({ + moduleActivation: run.function.activationId, + ordinal: run.function.ordinal, + }); + changedLength += run.length; + if (!Number.isSafeInteger(changedLength)) { + throw new Error(`${this.label}: table patch changed range is inexact`); + } + decodedRuns.push({ length: run.length, value }); + } + if (patch.start + changedLength > patch.tableLength) { + throw new Error(`${this.label}: table patch exceeds its final length`); + } + if (table.length > patch.tableLength) { + throw new Error(`${this.label}: local Table is longer than its patch`); + } + if (table.length < patch.tableLength) { + const growthOffset = table.length - patch.start; + if (growthOffset < 0 || growthOffset >= changedLength) { + throw new Error( + `${this.label}: table patch cannot reconstruct its growth gap`, + ); + } + let remaining = growthOffset; + const initializer = decodedRuns.find((run) => { + if (remaining < run.length) return true; + remaining -= run.length; + return false; + })?.value; + if (initializer === undefined) { + throw new Error(`${this.label}: table patch has no growth initializer`); + } + // WHY: nullable tables accept null, but a non-nullable typed function + // table requires a real instance-local initializer. The patch covers + // every new entry from the old length, so any value at that coordinate + // is a safe temporary initializer before the exact runs are applied. + table.grow( + patch.tableLength - table.length, + initializer, + ); + } + let index = patch.start; + for (const run of decodedRuns) { + for (let offset = 0; offset < run.length; offset++) { + table.set(index++, run.value); + } + } + this.markTableMutation(table, patch.start, changedLength); + } + + currentArena(): ForkModuleStateArena { + if (!this.arena) { + throw new Error(`${this.label}: no fork module-state transaction is active`); + } + return this.arena; + } + + currentReferences(): ForkReferenceTransaction { + if (!this.references) { + throw new Error(`${this.label}: no fork reference transaction is active`); + } + return this.references; + } + + /** Host-owned typed scratch table imported by every activation codec. */ + gcTransitTable(): WebAssembly.Table { + return this.gcTransit.table; + } + + /** + * Reserve/read the same transit slots used by the normal replay owner while + * imported references are reconstructed before every activation exists. + */ + prepareEarlyGcTransit(maxRecipeId: number): void { + if ( + !Number.isInteger(maxRecipeId) + || maxRecipeId < 0 + || maxRecipeId > 0x7fff_fffe + ) { + throw new RangeError(`invalid early GC recipe maximum ${maxRecipeId}`); + } + this.gcTransit.clear(); + if (maxRecipeId > 0) this.gcTransit.ensureRecipeSlot(maxRecipeId); + } + + readEarlyGcTransit(recipeId: number): unknown { + if ( + !Number.isInteger(recipeId) + || recipeId <= 0 + || recipeId > 0x7fff_fffe + ) { + throw new RangeError(`invalid early GC recipe id ${recipeId}`); + } + return this.gcTransit.get(recipeId + 1); + } + + publishEarlyGcTransit(recipeId: number, value: unknown): void { + if ( + !Number.isInteger(recipeId) + || recipeId <= 0 + || recipeId > 0x7fff_fffe + ) { + throw new RangeError(`invalid early GC recipe id ${recipeId}`); + } + this.gcTransit.ensureRecipeSlot(recipeId); + this.gcTransit.set(recipeId + 1, value); + } + + abortEarlyGcTransit(): void { + this.gcTransit.clear(); + } + + decodeStaticRoot(activationId: number, ordinal: number): unknown { + return this.staticRoots.decode({ + moduleActivation: activationId, + ordinal, + }); + } + + lookupGcSlot(requestingActivation: number, slot: number): number { + const provenance = this.gcProvenance.find(this.gcTransit.get(slot)); + if (provenance && provenance.activationId !== requestingActivation) { + // Canonically equivalent recursive types can test true in more than one + // instance. Constructor/segment provenance decides the reconstruction + // owner before the requesting codec claims graph identity. + return this.requireTypedProvider(provenance.activationId).encodeSlot(slot); + } + return this.currentReferences().lookupGcSlot(this.gcTransit.table, slot); + } + + claimGcSlot(slot: number): number { + const recipeId = this.currentReferences().claimGcSlot( + this.gcTransit.table, + slot, + ); + this.gcTransit.ensureRecipeSlot(recipeId); + return recipeId; + } + + encodeI31(value: number): number { + const recipeId = this.currentReferences().encodeI31(value); + this.gcTransit.ensureRecipeSlot(recipeId); + return recipeId; + } + + captureGcLayout( + activationId: number, + slot: number, + baseLayoutId: number, + ): number { + const provider = this.requireTypedProvider(activationId); + const base = provider.descriptor.require(baseLayoutId); + const object = this.gcTransit.get(slot); + const provenance = this.gcProvenance.lookup( + object, + activationId, + provider.descriptor, + baseLayoutId, + ); + if (provenance) return provenance.layoutId; + if ((base.flags & FORK_GC_LAYOUT_REQUIRES_PROVENANCE) !== 0) { + throw new Error( + `${this.label}: GC layout ${activationId}:${baseLayoutId} ` + + "requires constructor provenance", + ); + } + return base.id; + } + + defineGc( + activationId: number, + recipeId: number, + recordActivationId: number, + typeOrdinal: number, + layoutId: number, + kind: number, + scalarPointer: number | bigint, + scalarByteLength: number, + referenceVectorOrdinal: number, + ): void { + if (recordActivationId !== activationId) { + throw new Error( + `activation ${activationId} cannot define GC state for ` + + `activation ${recordActivationId}`, + ); + } + const provider = this.requireTypedProvider(activationId); + const layout = provider.descriptor.require(layoutId); + const source = this.currentReferences().capturedGcValue(recipeId); + const record = this.gcProvenance.lookup( + source, + activationId, + provider.descriptor, + layout.baseLayoutId, + ); + let provenance: ForkGcDefinitionProvenance | null = null; + if (record) { + const recipeIds = record.references.map((reference) => + reference === null ? 0 : this.encodeGcObject(reference) + ); + provenance = { record, recipeIds }; + } + this.currentReferences().defineGc( + recipeId, + recordActivationId, + typeOrdinal, + layoutId, + kind, + scalarPointer, + scalarByteLength, + referenceVectorOrdinal, + provider.descriptor, + provenance, + ); + } + + routeGc(recipeId: number, expectedActivation: number): number { + return this.currentReferences().routeGc(recipeId, expectedActivation); + } + + gcPayloadLength( + recipeId: number, + expectedActivation: number, + expectedLayoutId: number, + ): number { + return this.currentReferences().gcPayloadLength( + recipeId, + expectedActivation, + expectedLayoutId, + ); + } + + loadGc( + recipeId: number, + moduleActivation: number, + typeOrdinal: number, + layoutId: number, + kind: number, + scalarDestination: number | bigint, + scalarByteLength: number, + ): number { + return this.currentReferences().loadGc( + recipeId, + moduleActivation, + typeOrdinal, + layoutId, + kind, + scalarDestination, + scalarByteLength, + ); + } + + encodeGcFromSlot(sourceActivation: number, slot: number): number { + const provenance = this.gcProvenance.find(this.gcTransit.get(slot)); + if ( + provenance + && provenance.activationId !== sourceActivation + ) { + return this.requireTypedProvider(provenance.activationId).encodeSlot(slot); + } + const candidates = this.activations().filter( + ({ activationId, typedReferenceProvider }) => + activationId !== sourceActivation && typedReferenceProvider !== undefined, + ); + for (const activation of candidates) { + const provider = this.requireTypedProvider(activation.activationId); + const packed = provider.probe(slot); + if (packed === 0n) continue; + const baseLayoutId = Number(packed & 0xffff_ffffn); + const typeOrdinal = Number(packed >> 32n); + const base = provider.descriptor.require(baseLayoutId); + if ( + base.baseLayoutId !== base.id + || base.typeOrdinal !== typeOrdinal + ) { + throw new Error( + `${this.label}: activation ${activation.activationId} returned ` + + "an invalid GC probe coordinate", + ); + } + return provider.encodeSlot(slot); + } + // No module codec recognized the internal value, so it is a hostref made + // by `any.convert_extern`. Its worker-local token names a process-owned + // broker handle; retain that handle as an externref leaf in the same graph. + const recipeId = this.currentReferences().encodeExternref( + this.gcTransit.get(slot), + ); + this.gcTransit.ensureRecipeSlot(recipeId); + return recipeId; + } + + beginGcProvenance( + expectedActivationId: number, + slot: number, + activationId: number, + baseLayoutId: number, + specializedLayoutId: number, + scalarLo: bigint, + scalarHi: bigint, + referenceCount: number, + ): number { + return this.gcProvenance.begin( + this.gcTransit.table, + this.requireTypedProvider(expectedActivationId).descriptor, + expectedActivationId, + slot, + activationId, + baseLayoutId, + specializedLayoutId, + scalarLo, + scalarHi, + referenceCount, + ); + } + + appendGcProvenanceReference( + token: number, + index: number, + slot: number, + ): void { + this.gcProvenance.appendReference( + this.gcTransit.table, + token, + index, + slot, + ); + } + + endGcProvenance(token: number): void { + this.gcProvenance.end(token); + } + + /** + * Start capture and snapshot every registered module before stack unwind. + * + * Frame codecs continue appending reference nodes while unwind walks + * outward. `sealCapture` publishes the single process graph only after the + * last committed frame exists. + */ + beginCapture(arena: ForkModuleStateArena): void { + this.requirePhase("idle", "begin fork activation capture"); + if (!arena.hasActiveArena() || arena.isSealed()) { + throw new Error(`${this.label}: capture requires a writable module-state arena`); + } + // A prior trap must never make a stale object appear as a recipe hit. + this.gcProvenance.abortPending(); + this.gcTransit.clear(); + const functions = this.buildFunctionCatalog(); + const references = new ForkReferenceTransaction( + functions, + this.externrefs, + this.memory, + this.allocateScratch, + this.deallocateScratch, + `${this.label}: references`, + this.staticRoots, + this.typedReplayOwner(), + ); + references.beginCapture(); + this.functions = functions; + this.references = references; + this.arena = arena; + this.phase = "capture"; + try { + for (const activation of this.activations()) { + arena.appendModule({ + activationId: activation.activationId, + templateId: activation.templateId, + }); + } + for (const activation of this.activations()) { + activation.moduleState.save(activation.activationId); + } + } catch (error) { + this.abort(); + throw error; + } + } + + /** + * Seal one process-wide, table-only snapshot for peer Workers. + * + * The generated helpers reuse the same typed reference codecs as fork, so + * exnref and Wasm-GC entries never cross the JavaScript Table API. Every + * cumulative dirty page is captured in one reference transaction; aliases + * therefore remain aliases even when they span tables, pages, or module + * activations. + */ + captureTableState(arena: ForkModuleStateArena): number { + this.requirePhase("idle", "capture peer table state"); + if (!arena.hasActiveArena() || arena.isSealed()) { + throw new Error( + `${this.label}: peer table capture requires a writable module-state arena`, + ); + } + this.gcProvenance.abortPending(); + this.gcTransit.clear(); + const functions = this.buildFunctionCatalog(); + const references = new ForkReferenceTransaction( + functions, + this.externrefs, + this.memory, + this.allocateScratch, + this.deallocateScratch, + `${this.label}: peer table references`, + this.staticRoots, + this.typedReplayOwner(), + ); + references.beginCapture(); + this.functions = functions; + this.references = references; + this.arena = arena; + this.phase = "table-capture"; + try { + for (const activation of this.activations()) { + arena.appendModule({ + activationId: activation.activationId, + templateId: activation.templateId, + }); + } + for (const activation of this.activations()) { + activation.moduleState.saveTables(activation.activationId); + } + references.sealInto(arena); + const root = arena.seal(); + // No live activation consumes capture-side recipe objects. Drop every + // transient codec/catalog root after the scalar arena is sealed. + this.abort(); + return root; + } catch (error) { + this.abort(); + throw error; + } + } + + /** + * Apply one validated table-only snapshot to this Worker's instance graph. + */ + restoreTableState(arena: ForkModuleStateArena): void { + this.requirePhase("idle", "restore peer table state"); + if (!arena.hasActiveArena() || !arena.isSealed()) { + throw new Error( + `${this.label}: peer table replay requires a validated sealed arena`, + ); + } + this.gcTransit.clear(); + this.gcProvenance.abortPending(); + const records = arena.recordViews(); + const declared = records + .filter((record) => record.kind === ForkModuleStateRecordKind.Module) + .map((record) => record.activationId) + .sort((left, right) => left - right); + const registered = this.activations().map(({ activationId }) => activationId); + if ( + declared.length !== registered.length + || declared.some((id, index) => id !== registered[index]) + ) { + throw new Error( + `${this.label}: peer table snapshot activations do not match the local registry`, + ); + } + for (const activation of this.activations()) { + requireForkModuleTemplate( + records, + activation.activationId, + activation.templateId, + ); + } + const functions = this.buildFunctionCatalog(); + const references = new ForkReferenceTransaction( + functions, + this.externrefs, + this.memory, + this.allocateScratch, + this.deallocateScratch, + `${this.label}: peer table references`, + this.staticRoots, + this.typedReplayOwner(), + ); + references.attachChild(records); + this.functions = functions; + this.references = references; + this.arena = arena; + this.phase = "table-replay"; + try { + references.materializeAllTyped(); + for (const activation of this.activations()) { + activation.moduleState.restoreTables(activation.activationId); + } + references.finishReplay(); + for (const activation of this.activations()) { + activation.exceptionProvider?.clear(); + activation.typedReferenceProvider?.clear?.(); + } + this.gcTransit.clear(); + this.resetTransaction(); + } catch (error) { + this.abort(); + throw error; + } + } + + sealCapture(): void { + this.requirePhase("capture", "seal fork activation capture"); + const references = this.currentReferences(); + const arena = this.currentArena(); + references.sealInto(arena); + arena.seal(); + this.phase = "sealed-parent"; + } + + beginParentReplay(): void { + this.requirePhase("sealed-parent", "begin parent activation replay"); + this.currentReferences().beginParentReplay(); + this.phase = "parent-replay"; + } + + /** + * Attach copied recipes only after every child activation and codec exists. + */ + attachChild( + arena: ForkModuleStateArena, + decodedReferences?: DecodedSegmentedForkReferenceTransaction, + ): void { + this.requirePhase("idle", "attach child activation state"); + if (!arena.hasActiveArena() || !arena.isSealed()) { + throw new Error(`${this.label}: child replay requires a validated sealed arena`); + } + this.gcTransit.clear(); + this.gcProvenance.abortPending(); + const records = arena.recordViews(); + const declared = records + .filter((record) => record.kind === ForkModuleStateRecordKind.Module) + .map((record) => record.activationId) + .sort((left, right) => left - right); + const registered = this.activations().map(({ activationId }) => activationId); + if ( + declared.length !== registered.length + || declared.some((id, index) => id !== registered[index]) + ) { + throw new Error( + `${this.label}: copied module activations do not match the fresh child registry`, + ); + } + for (const activation of this.activations()) { + requireForkModuleTemplate( + records, + activation.activationId, + activation.templateId, + ); + } + const functions = this.buildFunctionCatalog(); + const references = new ForkReferenceTransaction( + functions, + this.externrefs, + this.memory, + this.allocateScratch, + this.deallocateScratch, + `${this.label}: references`, + this.staticRoots, + this.typedReplayOwner(), + ); + references.attachChild(decodedReferences ?? records); + this.functions = functions; + this.references = references; + this.arena = arena; + this.phase = "child-replay"; + } + + restoreModuleState(): void { + if (this.phase !== "parent-replay" && this.phase !== "child-replay") { + throw new Error( + `${this.label}: cannot restore module state while registry is ${this.phase}`, + ); + } + if (this.phase === "child-replay") { + // WHY: generated global/table restore helpers decode recipe ids through + // the fresh instance's transit table. Publish every reconstructed typed + // identity first, while passive data/element segments are still intact + // for array.new_data/array.new_elem constructors. + this.currentReferences().materializeAllTyped(); + } + for (const activation of this.activations()) { + activation.moduleState.restore(activation.activationId); + } + for (const activation of this.activations()) { + activation.moduleState.finishRestore(activation.activationId); + } + } + + finishReplay(): void { + if (this.phase !== "parent-replay" && this.phase !== "child-replay") { + throw new Error( + `${this.label}: cannot finish activation replay while registry is ${this.phase}`, + ); + } + let failure: unknown; + try { + this.references?.finishReplay(); + } catch (error) { + failure = error; + } + for (const activation of this.activations()) { + for (const provider of [ + activation.exceptionProvider, + activation.typedReferenceProvider, + ]) { + try { + provider?.clear?.(); + } catch (error) { + failure ??= error; + } + } + } + try { + this.gcTransit.clear(); + } catch (error) { + failure ??= error; + } + this.resetTransaction(); + if (failure !== undefined) throw failure; + } + + abort(): void { + let failure: unknown; + try { + this.references?.abort(); + } catch (error) { + failure = error; + } + for (const activation of this.activations()) { + for (const provider of [ + activation.exceptionProvider, + activation.typedReferenceProvider, + ]) { + try { + provider?.abort?.(); + } catch (error) { + failure ??= error; + } + } + } + try { + this.gcTransit.clear(); + } catch (error) { + failure ??= error; + } + this.resetTransaction(); + if (failure !== undefined) throw failure; + } + + clear(): void { + this.abort(); + this.registrations.clear(); + this.bootstrapped.clear(); + this.activationTables.clear(); + this.tablePatchFunctions.clear(); + this.tableCoordinates = new WeakMap(); + this.staticRoots.clear(); + this.gcProvenance.clear(); + } + + phaseName(): RegistryPhase { + return this.phase; + } + + private buildFunctionCatalog(): ForkFunctionCatalog { + const functions = new ForkFunctionCatalog(); + for (const activation of this.activations()) { + functions.register(activation.activationId, activation.functionCatalog); + } + return functions; + } + + private requireActivationTable( + activationId: number, + ownerId: number, + ): WebAssembly.Table { + assertActivationId(activationId); + if ( + !Number.isInteger(ownerId) + || ownerId <= 0 + || ownerId > 0xffff_ffff + ) { + throw new RangeError(`invalid fork table owner id ${ownerId}`); + } + const entry = this.activationTables + .get(activationId) + ?.find((candidate) => candidate.ownerId === ownerId); + if (!entry) { + throw new Error( + `${this.label}: table coordinate ${activationId}:${ownerId} is not registered`, + ); + } + return entry.table; + } + + private bindTableCoordinates(table: WebAssembly.Table): void { + const coordinates = this.tableCoordinates.get(table); + if (!coordinates || coordinates.length === 0) return; + const canonical = coordinates[0]!; + canonical.tracker.setStateOwner(canonical.ownerId, true); + for (const coordinate of coordinates.slice(1)) { + coordinate.tracker.aliasOwner( + coordinate.ownerId, + canonical.tracker, + canonical.ownerId, + ); + coordinate.tracker.setStateOwner(coordinate.ownerId, false); + } + } + + private requireTypedProvider( + activationId: number, + ): ForkGcCodecProvider & ForkActivationTypedReferenceProvider { + const provider = this.getActivation(activationId).typedReferenceProvider; + if ( + !provider + || provider.activationId !== activationId + || !provider.descriptor + || typeof provider.probe !== "function" + || typeof provider.encodeSlot !== "function" + || typeof provider.allocate !== "function" + || typeof provider.fill !== "function" + || typeof provider.publishExternref !== "function" + ) { + throw new Error( + `${this.label}: module activation ${activationId} has no GC codec`, + ); + } + return provider as ForkGcCodecProvider & ForkActivationTypedReferenceProvider; + } + + private encodeGcObject(value: object): number { + this.gcTransit.set(0, value); + try { + return this.encodeGcFromSlot(-1, 0); + } finally { + this.gcTransit.clearSlot(0); + } + } + + private typedReplayOwner() { + return { + prepareTransit: (maxRecipeId: number): void => { + if (maxRecipeId > 0) this.gcTransit.ensureRecipeSlot(maxRecipeId); + }, + publishTransit: (recipeId: number, value: unknown): void => { + this.gcTransit.ensureRecipeSlot(recipeId); + this.gcTransit.set(recipeId + 1, value); + }, + publishExternref: (recipeId: number, value: unknown): void => { + const provider = this.activations() + .map(({ activationId, typedReferenceProvider }) => + typedReferenceProvider ? this.requireTypedProvider(activationId) : null + ) + .find((candidate) => candidate !== null); + if (!provider) { + throw new Error( + `${this.label}: externref replay has no generated GC codec`, + ); + } + this.gcTransit.ensureRecipeSlot(recipeId); + provider.publishExternref(recipeId, value); + if (!Object.is(this.gcTransit.get(recipeId + 1), value)) { + throw new Error( + `${this.label}: externref recipe ${recipeId} lost token identity ` + + "during anyref publication", + ); + } + }, + provider: (activationId: number): ForkGcCodecProvider => + this.requireTypedProvider(activationId), + providers: (): readonly ForkGcCodecProvider[] => + this.activations().flatMap(({ activationId, typedReferenceProvider }) => { + if ( + !typedReferenceProvider?.descriptor + || typeof typedReferenceProvider.probe !== "function" + || typeof typedReferenceProvider.encodeSlot !== "function" + || typeof typedReferenceProvider.allocate !== "function" + || typeof typedReferenceProvider.fill !== "function" + || typeof typedReferenceProvider.publishExternref !== "function" + ) { + return []; + } + return [this.requireTypedProvider(activationId)]; + }), + validateExceptionOwner: (activationId: number): void => { + if (activationId === FORK_HOST_EXCEPTION_ACTIVATION_ID) { + if (!this.activations().some(({ exceptionProvider }) => + exceptionProvider?.materialize + )) { + throw new Error( + `${this.label}: host exception replay has no local codec`, + ); + } + return; + } + const provider = this.getActivation(activationId).exceptionProvider; + if (!provider?.materialize) { + throw new Error( + `${this.label}: activation ${activationId} cannot materialize ` + + "exception recipes", + ); + } + }, + materializeException: ( + recipeId: number, + activationId: number, + ): void => { + const provider = activationId === FORK_HOST_EXCEPTION_ACTIVATION_ID + ? this.activations() + .map(({ exceptionProvider }) => exceptionProvider) + .find((candidate) => candidate?.materialize) + : this.getActivation(activationId).exceptionProvider; + if (!provider?.materialize) { + throw new Error( + `${this.label}: no exception materializer for activation ` + + `${activationId}`, + ); + } + provider.materialize(recipeId); + }, + }; + } + + private resetTransaction(): void { + this.functions?.clear(); + this.functions = null; + this.references = null; + this.arena = null; + this.phase = "idle"; + } + + private requireIdle(operation: string): void { + this.requirePhase("idle", operation); + } + + private requirePhase(expected: RegistryPhase, operation: string): void { + if (this.phase !== expected) { + throw new Error( + `${this.label}: cannot ${operation} while activation registry is ${this.phase}; ` + + `expected ${expected}`, + ); + } + } +} diff --git a/host/src/fork-anyref-transit.ts b/host/src/fork-anyref-transit.ts new file mode 100644 index 0000000000..0c44b0c4cb --- /dev/null +++ b/host/src/fork-anyref-transit.ts @@ -0,0 +1,135 @@ +/** + * The ABI 43 transaction-local Wasm-GC routing table. + * + * WebKit can import and export `(ref null any)` tables, but its JavaScript + * `WebAssembly.Table` constructor does not accept `element: "anyref"`. + * Creating the table in this fixed Wasm provider therefore gives Node and all + * browser engines the same host-owned object without weakening its type. + */ +export const FORK_ANYREF_TRANSIT_IMPORT = "__wpk_fork_ref_gc_transit"; +const FORK_ANYREF_TRANSIT_CLEAR_EXPORT = + "__wpk_fork_ref_gc_transit_clear"; + +/* + * Deterministic encoding of: + * + * (module + * (table (export "__wpk_fork_ref_gc_transit") 1 (ref null any)) + * (func (export "__wpk_fork_ref_gc_transit_clear") + * i32.const 0 + * ref.null any + * table.size 0 + * table.fill 0)) + * + * Keep this provider deliberately closed: no imports, memory, globals, start + * function, or mutable state other than the exported scratch table. + */ +const FORK_ANYREF_TRANSIT_PROVIDER_BYTES = Uint8Array.of( + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x04, 0x01, 0x60, + 0x00, 0x00, 0x03, 0x02, 0x01, 0x00, 0x04, 0x04, 0x01, 0x6e, 0x00, 0x01, + 0x07, 0x3f, 0x02, 0x19, 0x5f, 0x5f, 0x77, 0x70, 0x6b, 0x5f, 0x66, 0x6f, + 0x72, 0x6b, 0x5f, 0x72, 0x65, 0x66, 0x5f, 0x67, 0x63, 0x5f, 0x74, 0x72, + 0x61, 0x6e, 0x73, 0x69, 0x74, 0x01, 0x00, 0x1f, 0x5f, 0x5f, 0x77, 0x70, + 0x6b, 0x5f, 0x66, 0x6f, 0x72, 0x6b, 0x5f, 0x72, 0x65, 0x66, 0x5f, 0x67, + 0x63, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x69, 0x74, 0x5f, 0x63, 0x6c, + 0x65, 0x61, 0x72, 0x00, 0x00, 0x0a, 0x0e, 0x01, 0x0c, 0x00, 0x41, 0x00, + 0xd0, 0x6e, 0xfc, 0x10, 0x00, 0xfc, 0x11, 0x00, 0x0b, +); + +let providerModule: WebAssembly.Module | undefined; + +function compileProviderModule(): WebAssembly.Module { + if (providerModule) return providerModule; + try { + providerModule = new WebAssembly.Module( + FORK_ANYREF_TRANSIT_PROVIDER_BYTES as BufferSource, + ); + } catch (cause) { + throw new Error( + "this host cannot construct the ABI 43 Wasm-GC transit table", + { cause }, + ); + } + return providerModule; +} + +/** Copy the audited provider binary for cross-engine contract tests. */ +export function forkAnyrefTransitProviderBytes(): Uint8Array { + return FORK_ANYREF_TRANSIT_PROVIDER_BYTES.slice(); +} + +/** + * One process-worker owner for the scratch table shared by all activations. + * + * The generated codecs may grow the table, but every entry is null-filled by + * Wasm at transaction boundaries. Using `table.fill` avoids one JS call per + * recipe while guaranteeing that no stale GC object remains a strong root. + */ +export class ForkAnyrefTransitTable { + readonly table: WebAssembly.Table; + private readonly clearTable: () => void; + + constructor() { + const instance = new WebAssembly.Instance(compileProviderModule()); + const table = instance.exports[FORK_ANYREF_TRANSIT_IMPORT]; + const clearTable = instance.exports[FORK_ANYREF_TRANSIT_CLEAR_EXPORT]; + if (!(table instanceof WebAssembly.Table) || typeof clearTable !== "function") { + throw new Error("invalid ABI 43 Wasm-GC transit provider exports"); + } + this.table = table; + this.clearTable = clearTable as () => void; + this.clear(); + } + + clear(): void { + this.clearTable(); + } + + /** + * Reserve the canonical `recipe + 1` slot before generated Wasm publishes + * an identity there. The table has no maximum, but keeping growth here lets + * the host reject integer overflow before it becomes an engine-dependent + * `table.grow` trap. + */ + ensureRecipeSlot(recipeId: number): void { + if ( + !Number.isInteger(recipeId) + || recipeId <= 0 + || recipeId > 0x7fff_fffe + ) { + throw new RangeError(`invalid Wasm-GC recipe id ${recipeId}`); + } + const requiredLength = recipeId + 2; + if (this.table.length >= requiredLength) return; + const delta = requiredLength - this.table.length; + const previous = this.table.grow(delta, null); + if (previous + delta !== requiredLength) { + throw new Error("Wasm-GC transit table grew to an unexpected length"); + } + } + + get(slot: number): unknown { + this.assertSlot(slot); + return this.table.get(slot); + } + + set(slot: number, value: unknown): void { + this.assertSlot(slot); + this.table.set(slot, value); + } + + clearSlot(slot: number): void { + this.assertSlot(slot); + this.table.set(slot, null); + } + + private assertSlot(slot: number): void { + if ( + !Number.isInteger(slot) + || slot < 0 + || slot >= this.table.length + ) { + throw new RangeError(`Wasm-GC transit slot ${slot} is out of bounds`); + } + } +} diff --git a/host/src/fork-continuation.ts b/host/src/fork-continuation.ts index 84d96171a9..4797379555 100644 --- a/host/src/fork-continuation.ts +++ b/host/src/fork-continuation.ts @@ -228,6 +228,16 @@ interface ContinuationChunk { used: number; } +interface ValidatedReplayNode { + node: number; + payload: number; + previous: number; + nextReplay: { + chunkIndex: number; + expectedEnd: number; + }; +} + /** * Host-side owner and validator for one module instance's linked fork frames. * Allocations are ordinary anonymous process mappings, so kernel brk/mmap @@ -241,8 +251,10 @@ export class LinkedForkContinuation { private replayExpectedEnd = 0; private pending: PendingNode | null = null; private chunks: ContinuationChunk[] = []; - private committedFrames = 0; - private committedBytes = 0; + // Diagnostics must not become the first precision ceiling in a wasm64 + // continuation. The linked list is allocator-bounded, not Number-bounded. + private committedFrames = 0n; + private committedBytes = 0n; private abortFailure: AbortFailure | null = null; constructor( @@ -262,8 +274,8 @@ export class LinkedForkContinuation { this.format.alignment, ); const capacity = alignUp(Math.max(initialUsed, WASM_PAGE_SIZE), WASM_PAGE_SIZE); - this.committedFrames = 0; - this.committedBytes = 0; + this.committedFrames = 0n; + this.committedBytes = 0n; this.abortFailure = null; let root: number; try { @@ -438,12 +450,35 @@ export class LinkedForkContinuation { this.writePtr(this.root + 8 + 5 * this.format.ptrWidth, pending.node); const payloadSize = this.readPtr(pending.node + 8 + this.format.ptrWidth); this.committedFrames++; - this.committedBytes += payloadSize; + this.committedBytes += BigInt(payloadSize); this.pending = null; } + /** + * Validate and expose the next frame without advancing the replay cursor. + * + * Tail-call replay selects an activation-specific resume thunk from the + * common frame header before entering the original function. That function's + * ordinary preamble remains the sole consumer through `nextFrame`. + */ + peekFrame(expectedSize: number | bigint): number | bigint { + const expected = this.fromGuestPtr(expectedSize); + const validated = this.validateNextFrame(expected); + return this.asGuestPtr(validated.payload); + } + nextFrame(expectedSize: number | bigint): number | bigint { const expected = this.fromGuestPtr(expectedSize); + const validated = this.validateNextFrame(expected); + const { node, payload, previous, nextReplay } = validated; + this.replayNode = previous; + this.replayChunkIndex = nextReplay.chunkIndex; + this.replayExpectedEnd = nextReplay.expectedEnd; + this.view().setUint16(node + 6, NODE_CONSUMED, true); + return this.asGuestPtr(payload); + } + + private validateNextFrame(expected: number): ValidatedReplayNode { const node = this.replayNode; if (this.root === 0 || node === 0) { throw new Error(`${this.label}: linked continuation replay exhausted early`); @@ -473,11 +508,12 @@ export class LinkedForkContinuation { } const previous = this.readPtr(node + 8); const nextReplay = this.previousReplayPosition(previous, node); - this.replayNode = previous; - this.replayChunkIndex = nextReplay.chunkIndex; - this.replayExpectedEnd = nextReplay.expectedEnd; - view.setUint16(node + 6, NODE_CONSUMED, true); - return this.asGuestPtr(node + this.format.nodeHeaderSize); + return { + node, + payload: node + this.format.nodeHeaderSize, + previous, + nextReplay, + }; } finishUnwind(): void { diff --git a/host/src/fork-early-reference-provider.ts b/host/src/fork-early-reference-provider.ts new file mode 100644 index 0000000000..0fba95700c --- /dev/null +++ b/host/src/fork-early-reference-provider.ts @@ -0,0 +1,1604 @@ +import { + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, +} from "./generated/abi"; +import type { + ForkActivationExceptionProvider, +} from "./fork-activation-registry"; +import type { + ForkImportedReferenceProvider, +} from "./fork-imported-globals"; +import { + FORK_GC_FIELD_ALLOCATION_DEPENDENCY, + FORK_GC_FIELD_REFERENCE, + FORK_GC_LAYOUT_DEFAULTABLE_SHELL, + ForkGcConstructorKind, + type ForkGcCodecDescriptor, + type ForkGcCodecProvider, + type ForkGcLayoutDescriptor, +} from "./fork-gc-codec"; +import type { + ForkExceptionCodecDescriptor, +} from "./fork-exception-provider"; +import { + ForkImportedGlobalBindingKind, + ForkModuleStateRecordKind, + importedGlobalBindingsForChild, + type ForkModuleStateRecordView, +} from "./fork-module-state"; +import { + type ForkReferenceRecipeEntry, + type ForkReferenceRecipeNode, +} from "./fork-reference-recipes"; +import { + FORK_HOST_EXCEPTION_ACTIVATION_ID, + type ForkExternrefRecipeProvider, + type ForkReferenceChildReplayAdoption, + type ForkReferenceScratchAllocate, + type ForkReferenceScratchDeallocate, + ForkReferenceTransaction, +} from "./fork-reference-transaction"; +import { + findForkReferenceVectorOrdinal, + forkReferenceVectorFrom, + ForkReferenceDirectoryOverlay, + indexForkReferenceVector, + PagedForkReferenceDirectory, + type DecodedSegmentedForkReferenceTransaction, + type ForkReferenceDirectory, + type ForkReferenceVector, + type MutableForkReferenceVectorInternIndex, +} from "./fork-reference-segments"; + +const MAX_REFERENCE_VECTOR_ORDINAL = 0xffff_ffff; + +type ReferenceTypeCode = + | typeof WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF + | typeof WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF + | typeof WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF + | typeof WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF; + +type ProviderPhase = "active" | "adopted" | "aborted"; + +export interface ForkEarlyReferenceActivationDeclaration { + readonly activationId: number; + /** + * Descriptor-only type evidence is available from the module before its + * instance exists. It is what makes owner planning and graph validation an + * actual pre-instantiation operation. + */ + readonly gcDescriptor?: ForkGcCodecDescriptor; + readonly exceptionDescriptor?: ForkExceptionCodecDescriptor; +} + +export interface ForkEarlyFunctionProvider { + decode(ordinal: number): CallableFunction; +} + +export interface ForkEarlyStaticRootProvider { + decode(ordinal: number): unknown; +} + +/** + * View of the same anyref transit table imported by generated activation + * codecs. Implementations must map recipe N to the canonical slot N + 1. + */ +export interface ForkEarlyReferenceTransit { + prepare(maxRecipeId: number): void; + /** + * Route an already-instantiated GC root at canonical slot `recipeId + 1`. + * Only instrumenter-proven anyref-compatible static roots use this path. + */ + publish(recipeId: number, value: unknown): void; + read(recipeId: number): unknown; + /** Release every early typed root if launch fails before adoption. */ + abort(): void; +} + +export interface ForkEarlyReferenceActivationProviders { + readonly activationId: number; + readonly functions?: ForkEarlyFunctionProvider; + readonly staticRoots?: ForkEarlyStaticRootProvider; + readonly typed?: ForkGcCodecProvider; + readonly exceptions?: ForkActivationExceptionProvider; + /** + * Optional activation-owned rollback for roots created before the registry + * takes over. It is not called after successful adoption. + */ + readonly abort?: () => void; +} + +export interface ForkEarlyChildReferenceProviderOptions { + readonly records: readonly ForkModuleStateRecordView[]; + /** One decoder result shared verbatim with ordinary child replay adoption. */ + readonly transaction: DecodedSegmentedForkReferenceTransaction; + readonly declarations: readonly ForkEarlyReferenceActivationDeclaration[]; + readonly externrefs: ForkExternrefRecipeProvider; + readonly transit: ForkEarlyReferenceTransit; + readonly memory: WebAssembly.Memory; + readonly allocateScratch: ForkReferenceScratchAllocate; + readonly deallocateScratch: ForkReferenceScratchDeallocate; + readonly label?: string; +} + +interface RegisteredActivation extends ForkEarlyReferenceActivationProviders {} + +interface ScratchChunk { + readonly addr: number; + readonly size: number; + used: number; +} + +interface ScratchReservation { + readonly addr: number; + readonly requestedSize: number; + readonly alignedSize: number; + readonly previousUsed: number; + readonly chunk: ScratchChunk; +} + +function assertU32(value: number, context: string): number { + if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff) { + throw new RangeError(`${context} is not a u32`); + } + return value; +} + +function assertRecipeId(value: number, nodeCount: number): number { + if ( + !Number.isInteger(value) + || value < 0 + || value > 0xffff_ffff + || value >= nodeCount + ) { + throw new RangeError(`invalid fork reference recipe id ${value}`); + } + return value; +} + +function requireReferenceTypeCode(value: number): ReferenceTypeCode { + switch (value) { + case WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF: + case WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF: + case WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF: + case WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF: + return value; + default: + throw new Error(`invalid imported reference ABI type code ${value}`); + } +} + +function nodeEdges(node: ForkReferenceRecipeNode): readonly number[] { + switch (node.kind) { + case "exnref": + return node.payloads; + case "struct": + return node.fields; + case "array": + return node.elements; + case "null": + case "funcref": + case "externref": + case "i31": + case "static-root": + return []; + } +} + +function sameGcDescriptor( + left: ForkGcCodecDescriptor, + right: ForkGcCodecDescriptor, +): boolean { + if (left.layouts.length !== right.layouts.length) return false; + return left.layouts.every((layout, index) => { + const other = right.layouts[index]!; + return ( + layout.id === other.id + && layout.typeOrdinal === other.typeOrdinal + && layout.kind === other.kind + && layout.constructor === other.constructor + && layout.flags === other.flags + && layout.scalarLengthOrStride === other.scalarLengthOrStride + && layout.superTypeOrdinal === other.superTypeOrdinal + && layout.baseLayoutId === other.baseLayoutId + && layout.auxiliary === other.auxiliary + && layout.provenanceScalarLength === other.provenanceScalarLength + && layout.provenanceReferenceCount === other.provenanceReferenceCount + && layout.fields.length === other.fields.length + && layout.fields.every((field, fieldIndex) => { + const otherField = other.fields[fieldIndex]!; + return ( + field.storage === otherField.storage + && field.flags === otherField.flags + && field.scalarOffset === otherField.scalarOffset + && field.referenceOrdinal === otherField.referenceOrdinal + ); + }) + ); + }); +} + +function validateGcSnapshot( + layout: ForkGcLayoutDescriptor, + scalars: Uint8Array, + references: readonly number[], + context: string, +): void { + const referenceFieldCount = layout.fields.filter( + ({ flags }) => (flags & FORK_GC_FIELD_REFERENCE) !== 0, + ).length; + if (layout.kind === 1) { + if ( + scalars.byteLength !== layout.scalarLengthOrStride + || references.length !== referenceFieldCount + ) { + throw new Error(`${context} does not match struct layout ${layout.id}`); + } + return; + } + if (scalars.byteLength < 4) { + throw new Error(`${context} array length is truncated`); + } + const length = new DataView( + scalars.buffer, + scalars.byteOffset, + scalars.byteLength, + ).getUint32(0, true); + const referenceElements = + (layout.fields[0]!.flags & FORK_GC_FIELD_REFERENCE) !== 0; + const expectedScalarLength = referenceElements + ? 4 + : 4 + length * layout.scalarLengthOrStride; + if ( + !Number.isSafeInteger(expectedScalarLength) + || expectedScalarLength > 0xffff_ffff + || scalars.byteLength !== expectedScalarLength + || references.length !== (referenceElements ? length : 0) + || ( + layout.constructor === ForkGcConstructorKind.ArrayFixed + && layout.auxiliary !== length + ) + ) { + throw new Error(`${context} does not match array layout ${layout.id}`); + } +} + +function validateGcRecipe( + entry: ForkReferenceRecipeEntry, + descriptor: ForkGcCodecDescriptor, +): ForkGcLayoutDescriptor { + const node = entry.node; + if (node.kind !== "struct" && node.kind !== "array") { + throw new Error(`fork recipe ${entry.id} is not a GC aggregate`); + } + const layout = descriptor.require(node.layoutId ?? 0); + if ( + layout.typeOrdinal !== node.typeOrdinal + || (node.kind === "struct" ? 1 : 2) !== layout.kind + ) { + throw new Error( + `fork GC recipe ${entry.id} has an invalid type/layout coordinate`, + ); + } + const scalars = node.scalars ?? new Uint8Array(); + const references = node.kind === "struct" ? node.fields : node.elements; + if ( + scalars.byteLength < layout.provenanceScalarLength + || references.length < layout.provenanceReferenceCount + ) { + throw new Error( + `fork GC recipe ${entry.id} has truncated constructor provenance`, + ); + } + validateGcSnapshot( + layout, + scalars.subarray(layout.provenanceScalarLength), + references.slice(layout.provenanceReferenceCount), + `fork GC recipe ${entry.id}`, + ); + return layout; +} + +function gcAllocationDependencies( + node: Extract, + layout: ForkGcLayoutDescriptor, +): readonly number[] { + const edges = node.kind === "struct" ? node.fields : node.elements; + const dependencies = edges.slice(0, layout.provenanceReferenceCount); + const snapshotStart = layout.provenanceReferenceCount; + if (node.kind === "struct") { + for (const field of layout.fields) { + if ( + (field.flags & FORK_GC_FIELD_ALLOCATION_DEPENDENCY) !== 0 + && field.referenceOrdinal !== null + ) { + dependencies.push(edges[snapshotStart + field.referenceOrdinal]!); + } + } + return dependencies; + } + if ((layout.fields[0]!.flags & FORK_GC_FIELD_REFERENCE) === 0) { + return dependencies; + } + const snapshot = edges.slice(snapshotStart); + if (layout.constructor === ForkGcConstructorKind.ArrayFixed) { + if (layout.provenanceReferenceCount === 0) { + dependencies.push(...snapshot); + } + } else if ( + layout.constructor === ForkGcConstructorKind.ArrayNew + && layout.provenanceReferenceCount === 0 + && snapshot.length !== 0 + ) { + dependencies.push(snapshot[0]!); + } + return dependencies; +} + +/** + * Pre-instantiation child owner for raw imported reference globals. + * + * It never treats a parent Worker object as reconstruction evidence. Every + * non-null value comes from a deterministic recipe owner registered from a + * fresh activation, or from the process externref provider. + */ +export class ForkEarlyChildReferenceProvider + implements ForkImportedReferenceProvider +{ + private transaction: DecodedSegmentedForkReferenceTransaction | null; + private nodes: ForkReferenceDirectory; + private readonly referenceVectors = + new ForkReferenceDirectoryOverlay(); + private readonly referenceVectorIntern: + MutableForkReferenceVectorInternIndex = new Map(); + private readonly declarations = + new Map(); + private readonly registrations = new Map(); + private readonly materializedValues = new Map(); + private readonly publishedExternrefRecipes = new Set(); + private readonly allocatedTypedRecipes = new Set(); + private readonly filledTypedRecipes = new Set(); + private readonly materializedExceptionRecipes = new Set(); + private readonly exceptionCacheIndexes = new Map(); + private readonly gcLayouts = new Map(); + private readonly replayGcVectors = new Map(); + private readonly scratchChunks: ScratchChunk[] = []; + private readonly scratchReservations: ScratchReservation[] = []; + private readonly i31Owner: number | null; + private readonly hostExceptionOwner: number | null; + private transitPrepared = false; + private phase: ProviderPhase = "active"; + private readonly label: string; + private readonly externrefs: ForkExternrefRecipeProvider; + private readonly transit: ForkEarlyReferenceTransit; + private readonly memory: WebAssembly.Memory; + private readonly allocateScratch: ForkReferenceScratchAllocate; + private readonly deallocateScratch: ForkReferenceScratchDeallocate; + + constructor(options: ForkEarlyChildReferenceProviderOptions) { + this.label = options.label ?? "early child references"; + this.externrefs = options.externrefs; + this.transit = options.transit; + this.memory = options.memory; + this.allocateScratch = options.allocateScratch; + this.deallocateScratch = options.deallocateScratch; + this.transaction = options.transaction; + this.nodes = options.transaction.graph.nodes; + // WHY: keep the exact decoded KFRV vector directory as the immutable base. + // Early codec vectors append to a small overlay instead of copying every + // transaction vector into a second page tree. + this.referenceVectors.reset(options.transaction.vectors); + + const moduleIds = new Set(); + for (const record of options.records) { + if (record.kind !== ForkModuleStateRecordKind.Module) continue; + assertU32(record.activationId, "fork module activation"); + if (moduleIds.has(record.activationId)) { + throw new Error( + `${this.label}: duplicate module activation ${record.activationId}`, + ); + } + moduleIds.add(record.activationId); + } + if (moduleIds.size === 0) { + throw new Error(`${this.label}: reference graph has no module activations`); + } + + for (const declaration of options.declarations) { + const activationId = assertU32( + declaration.activationId, + "early reference activation", + ); + if (!moduleIds.has(activationId)) { + throw new Error( + `${this.label}: declaration names unknown activation ${activationId}`, + ); + } + if (this.declarations.has(activationId)) { + throw new Error( + `${this.label}: activation ${activationId} was declared twice`, + ); + } + this.declarations.set(activationId, declaration); + } + for (const activationId of moduleIds) { + if (!this.declarations.has(activationId)) { + throw new Error( + `${this.label}: module activation ${activationId} has no declaration`, + ); + } + } + + this.i31Owner = [...this.declarations.values()] + .filter(({ gcDescriptor }) => gcDescriptor !== undefined) + .map(({ activationId }) => activationId) + .sort((left, right) => left - right)[0] ?? null; + this.hostExceptionOwner = [...this.declarations.values()] + .filter(({ exceptionDescriptor }) => exceptionDescriptor !== undefined) + .map(({ activationId }) => activationId) + .sort((left, right) => left - right)[0] ?? null; + + for (const binding of importedGlobalBindingsForChild(options.records)) { + if ( + binding.kind === ForkImportedGlobalBindingKind.RawReference + && binding.typeCode === WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF + && binding.recipeId !== 0 + ) { + // WHY: JavaScript cannot read a non-null exnref out of a Global or + // carry one as a raw import value. Parent capture therefore represents + // every real non-null exnref import as ActivationGlobal/BaseImport; a + // nonzero raw recipe can only be a malformed provenance manifest. + throw new Error( + `${this.label}: imported exnref ${binding.consumerActivation}:` + + `${binding.consumerOwner} has a non-null raw recipe instead of ` + + "an activation-owned Global carrier", + ); + } + } + + for (const entry of this.nodes) { + this.validateRecipeOwnership(entry, moduleIds); + if (entry.node.kind === "exnref") { + this.exceptionCacheIndexes.set( + entry.id, + this.exceptionCacheIndexes.size + 1, + ); + } + } + } + + registerActivation(providers: ForkEarlyReferenceActivationProviders): void { + this.requireActive("register an activation"); + const activationId = assertU32( + providers.activationId, + "early reference activation", + ); + const declaration = this.declarations.get(activationId); + if (!declaration) { + throw new Error( + `${this.label}: activation ${activationId} was not declared`, + ); + } + if (this.registrations.has(activationId)) { + throw new Error( + `${this.label}: activation ${activationId} was registered twice`, + ); + } + + let ownsFuncref = false; + let ownsStaticRoot = false; + let ownsTyped = false; + let ownsException = false; + for (const { node } of this.nodes) { + if (this.directOwner(node) !== activationId) continue; + ownsFuncref ||= node.kind === "funcref"; + ownsStaticRoot ||= node.kind === "static-root"; + ownsTyped ||= ( + node.kind === "struct" || node.kind === "array" || node.kind === "i31" + ); + ownsException ||= node.kind === "exnref"; + } + if (ownsFuncref && !providers.functions) { + throw new Error( + `${this.label}: activation ${activationId} has no function provider`, + ); + } + if ( + ownsStaticRoot && !providers.staticRoots + ) { + throw new Error( + `${this.label}: activation ${activationId} has no static-root provider`, + ); + } + if ( + ( + ownsTyped + || this.i31Owner === activationId + ) + ) { + if ( + !providers.typed + || providers.typed.activationId !== activationId + || !declaration.gcDescriptor + || !sameGcDescriptor( + providers.typed.descriptor, + declaration.gcDescriptor, + ) + ) { + throw new Error( + `${this.label}: activation ${activationId} has no matching GC provider`, + ); + } + } + if ( + ( + ownsException + || this.hostExceptionOwner === activationId + ) + && typeof providers.exceptions?.materialize !== "function" + ) { + throw new Error( + `${this.label}: activation ${activationId} has no exception materializer`, + ); + } + + this.registrations.set(activationId, { ...providers }); + } + + ownerActivation(recipeId: number, typeCode: number): number | null { + this.requireActive("plan a reference owner"); + const entry = this.requireCompatibleRecipe(recipeId, typeCode); + return this.directOwner(entry.node); + } + + /** + * Every activation needed to reconstruct the complete reachable identity. + * + * `ForkImportedReferenceProvider.ownerActivation` predates typed graphs and + * can name only the direct owner. Loaders should add this full set to their + * topological dependency graph before resolving a raw reference import. + */ + activationDependencies(recipeId: number, typeCode: number): number[] { + this.requireActive("plan reference dependencies"); + const entry = this.requireCompatibleRecipe(recipeId, typeCode); + const dependencies = new Set(); + const visited = new Set(); + const visit = (id: number): void => { + if (visited.has(id)) return; + visited.add(id); + const node = this.nodes.get(id)!.node; + const owner = this.directOwner(node); + if (owner !== null) dependencies.add(owner); + nodeEdges(node).forEach(visit); + }; + visit(entry.id); + return [...dependencies].sort((left, right) => left - right); + } + + materialize(recipeId: number, typeCode: number): unknown { + this.requireActive("materialize an imported reference"); + const entry = this.requireCompatibleRecipe(recipeId, typeCode); + if ( + entry.node.kind === "exnref" + && entry.id !== 0 + ) { + throw new Error( + `${this.label}: non-null exnref recipe ${entry.id} cannot cross ` + + "JavaScript; import its activation-owned WebAssembly.Global instead", + ); + } + this.requireRegisteredDependencies(entry.id, typeCode); + try { + const value = this.materializeRecipe(entry.id); + this.validateMaterializedValue(entry.id, typeCode, value); + return value; + } catch (error) { + // A provider that returned a malformed value may already have retained + // it in a catalog or transit slot. Poison the one-shot owner and release + // all early roots instead of permitting a retry over ambiguous state. + this.abortAfterFailure(error); + } + } + + decodeFuncref(recipeId: number): CallableFunction | null { + const value = this.materialize( + recipeId, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + ); + if (value !== null && typeof value !== "function") { + throw new TypeError( + `${this.label}: recipe ${recipeId} did not reconstruct a funcref`, + ); + } + return value as CallableFunction | null; + } + + decodeExternref(recipeId: number): unknown { + return this.materialize( + recipeId, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + ); + } + + getReferenceVector(ordinal: number, index: number): number { + this.requireActive("read a reference vector"); + if ( + !Number.isInteger(ordinal) + || ordinal < 0 + || ordinal > 0xffff_ffff + ) { + throw new RangeError( + `${this.label}: reference vector ordinal is not a u32`, + ); + } + assertU32(index, "reference vector index"); + const vector = this.referenceVectors.get(ordinal); + if (!vector) { + throw new Error( + `${this.label}: reference vector ${ordinal} is not available`, + ); + } + const recipeId = vector.get(index); + if (recipeId === undefined) { + throw new Error( + `${this.label}: reference vector ${ordinal} index ${index} ` + + "is out of bounds", + ); + } + return recipeId; + } + + routeGc(recipeId: number, expectedActivation: number): number { + this.requireActive("route a GC recipe"); + const entry = this.requireRecipe(recipeId); + assertU32(expectedActivation, "GC route activation"); + if (entry.node.kind === "i31") return 0; + if ( + (entry.node.kind !== "struct" && entry.node.kind !== "array") + || entry.node.moduleActivation !== expectedActivation + ) { + return -1; + } + return entry.node.layoutId ?? 0; + } + + gcPayloadLength( + recipeId: number, + expectedActivation: number, + expectedLayoutId: number, + ): number { + this.requireActive("read a GC payload length"); + const entry = this.requireRecipe(recipeId); + assertU32(expectedActivation, "GC payload activation"); + assertU32(expectedLayoutId, "GC payload layout"); + if (entry.node.kind === "i31") { + if (expectedLayoutId !== 0) { + throw new Error( + `${this.label}: i31 recipe ${recipeId} has a nonzero layout`, + ); + } + return 4; + } + if ( + (entry.node.kind !== "struct" && entry.node.kind !== "array") + || entry.node.moduleActivation !== expectedActivation + || (entry.node.layoutId ?? 0) !== expectedLayoutId + ) { + throw new Error( + `${this.label}: GC recipe ${recipeId} does not match payload route ` + + `${expectedActivation}:${expectedLayoutId}`, + ); + } + return (entry.node.scalars ?? new Uint8Array()).byteLength; + } + + loadGc( + recipeId: number, + moduleActivation: number, + typeOrdinal: number, + layoutId: number, + kind: number, + scalarDestination: number | bigint, + scalarByteLength: number, + ): number { + this.requireActive("load a GC recipe"); + const entry = this.requireRecipe(recipeId); + assertU32(moduleActivation, "GC load activation"); + assertU32(typeOrdinal, "GC load type ordinal"); + assertU32(layoutId, "GC load layout"); + assertU32(kind, "GC load kind"); + assertU32(scalarByteLength, "GC scalar byte length"); + if (entry.node.kind === "i31") { + if ( + layoutId !== 0 + || typeOrdinal !== 0xffff_ffff + || kind !== 0 + || scalarByteLength !== 4 + ) { + throw new Error( + `${this.label}: i31 recipe ${recipeId} has an invalid load coordinate`, + ); + } + const bytes = new Uint8Array(4); + new DataView(bytes.buffer).setInt32(0, entry.node.value, true); + this.writeBytes( + scalarDestination, + bytes, + "early GC i31 destination", + ); + return 0; + } + if (entry.node.kind !== "struct" && entry.node.kind !== "array") { + throw new Error( + `${this.label}: recipe ${recipeId} is not a GC aggregate`, + ); + } + const nodeKind = entry.node.kind === "struct" ? 1 : 2; + const scalars = entry.node.scalars ?? new Uint8Array(); + if ( + entry.node.moduleActivation !== moduleActivation + || entry.node.typeOrdinal !== typeOrdinal + || (entry.node.layoutId ?? 0) !== layoutId + || nodeKind !== kind + || scalars.byteLength !== scalarByteLength + ) { + throw new Error( + `${this.label}: GC recipe ${recipeId} payload does not match ` + + "the generated codec", + ); + } + this.writeBytes( + scalarDestination, + scalars, + "early GC scalar destination", + ); + const edges = + entry.node.kind === "struct" ? entry.node.fields : entry.node.elements; + if (edges.length === 0) return 0; + const known = this.replayGcVectors.get(recipeId); + if (known !== undefined) return known; + const existing = findForkReferenceVectorOrdinal( + [ + this.transaction!.vectorIntern, + this.referenceVectorIntern, + ], + this.referenceVectors, + forkReferenceVectorFrom(edges, edges.length), + ); + if (existing !== undefined) { + this.replayGcVectors.set(recipeId, existing); + return existing; + } + const ordinal = this.referenceVectors.length; + if (ordinal > MAX_REFERENCE_VECTOR_ORDINAL) { + throw new RangeError( + `${this.label}: reference vector ordinal space exhausted`, + ); + } + const canonical = forkReferenceVectorFrom(edges, edges.length); + this.referenceVectors.push(canonical); + indexForkReferenceVector(this.referenceVectorIntern, canonical, ordinal); + this.replayGcVectors.set(recipeId, ordinal); + return ordinal; + } + + routeException(recipeId: number, expectedActivation: number): number { + this.requireActive("route an exception recipe"); + const entry = this.requireRecipe(recipeId); + assertU32(expectedActivation, "exception route activation"); + if ( + entry.node.kind !== "exnref" + || entry.node.moduleActivation !== expectedActivation + ) { + return -1; + } + return entry.node.layoutId ?? 0; + } + + exceptionOwner(recipeId: number): number { + this.requireActive("read an exception owner"); + const entry = this.requireRecipe(recipeId); + if (entry.node.kind !== "exnref") { + throw new Error( + `${this.label}: recipe ${recipeId} is not an exception`, + ); + } + // WHY: retain the process-graph owner here instead of the activation used + // to instantiate a host-exception codec. ForkExceptionBroker needs the + // sentinel to distinguish a host/JSTag value from an activation tag. + return entry.node.moduleActivation; + } + + materializeHostException(recipeId: number): unknown { + const owner = this.exceptionOwner(recipeId); + if (owner !== FORK_HOST_EXCEPTION_ACTIVATION_ID) { + throw new Error( + `${this.label}: exception recipe ${recipeId} is not host-owned`, + ); + } + const entry = this.requireRecipe(recipeId); + if ( + entry.node.kind !== "exnref" + || entry.node.payloads.length !== 1 + || this.nodes.get(entry.node.payloads[0]!)?.node.kind !== "externref" + ) { + throw new Error( + `${this.label}: host exception recipe ${recipeId} is malformed`, + ); + } + // The payload is an opaque process-owned handle; decodeExternref provides + // the same canonical child token to every early broker invocation. + return this.decodeExternref(entry.node.payloads[0]!); + } + + exceptionCacheIndex(recipeId: number): number { + this.requireActive("read an exception cache index"); + this.requireRecipe(recipeId); + const index = this.exceptionCacheIndexes.get(recipeId); + if (index === undefined) { + throw new Error( + `${this.label}: recipe ${recipeId} has no exception cache index`, + ); + } + return index; + } + + loadException( + recipeId: number, + moduleActivation: number, + tagOrdinal: number, + layoutId: number, + scalarDestination: number | bigint, + scalarByteLength: number, + referenceIdsDestination: number | bigint, + referenceCount: number, + ): number { + this.requireActive("load an exception recipe"); + const entry = this.requireRecipe(recipeId); + assertU32(moduleActivation, "exception load activation"); + assertU32(tagOrdinal, "exception load tag ordinal"); + assertU32(layoutId, "exception load layout"); + assertU32(scalarByteLength, "exception scalar byte length"); + assertU32(referenceCount, "exception reference count"); + if (entry.node.kind !== "exnref") { + throw new Error( + `${this.label}: recipe ${recipeId} is not an exception`, + ); + } + const scalars = entry.node.scalars ?? new Uint8Array(); + if ( + entry.node.moduleActivation !== moduleActivation + || entry.node.tagOrdinal !== tagOrdinal + || (entry.node.layoutId ?? 0) !== layoutId + || scalars.byteLength !== scalarByteLength + || entry.node.payloads.length !== referenceCount + ) { + throw new Error( + `${this.label}: exception recipe ${recipeId} payload does not match ` + + "the generated codec", + ); + } + this.writeBytes( + scalarDestination, + scalars, + "early exception scalar destination", + ); + this.writeRecipeIds( + referenceIdsDestination, + entry.node.payloads, + "early exception reference destination", + ); + return 1; + } + + reserveScratch(size: number | bigint): number { + this.requireActive("reserve reference scratch"); + const requestedSize = this.checkedScratchSize(size); + const alignedSize = this.alignScratch(requestedSize); + let chunk = this.scratchChunks[this.scratchChunks.length - 1]; + if (!chunk || alignedSize > chunk.size - chunk.used) { + const chunkSize = this.alignScratch(Math.max(65_536, alignedSize), 65_536); + const addr = this.allocateScratch(chunkSize); + if ( + !Number.isSafeInteger(addr) + || addr <= 0 + || addr % 16 !== 0 + || addr > this.memory.buffer.byteLength - chunkSize + ) { + if (Number.isSafeInteger(addr) && addr > 0) { + try { + this.deallocateScratch(addr, chunkSize); + } catch { + // Preserve the allocator contract violation. + } + } + throw new RangeError( + `${this.label}: scratch allocator returned an invalid mapping`, + ); + } + chunk = { addr, size: chunkSize, used: 0 }; + this.scratchChunks.push(chunk); + } + const previousUsed = chunk.used; + const addr = chunk.addr + previousUsed; + chunk.used += alignedSize; + new Uint8Array(this.memory.buffer, addr, alignedSize).fill(0); + this.scratchReservations.push({ + addr, + requestedSize, + alignedSize, + previousUsed, + chunk, + }); + return addr; + } + + releaseScratch(pointer: number | bigint, size: number | bigint): void { + this.requireActive("release reference scratch"); + const addr = this.checkedScratchPointer(pointer); + const requestedSize = this.checkedScratchSize(size); + const reservation = this.scratchReservations.pop(); + if ( + !reservation + || reservation.addr !== addr + || reservation.requestedSize !== requestedSize + ) { + if (reservation) this.scratchReservations.push(reservation); + throw new Error( + `${this.label}: scratch release is not the most recent reservation`, + ); + } + new Uint8Array( + this.memory.buffer, + reservation.addr, + reservation.alignedSize, + ).fill(0); + reservation.chunk.used = reservation.previousUsed; + const tail = this.scratchChunks[this.scratchChunks.length - 1]; + if ( + tail === reservation.chunk + && tail.used === 0 + && this.scratchChunks.length > 1 + ) { + this.scratchChunks.pop(); + this.deallocateScratch(tail.addr, tail.size); + } + } + + /** + * Adapter target for encode/claim/define imports while the child is still + * constructing activations. Those callbacks are capture-only by contract. + */ + captureUnavailable(operation: string): never { + this.requireActive(`run capture callback ${operation}`); + throw new Error( + `${this.label}: capture callback ${operation} is unavailable during ` + + "pre-instantiation child replay", + ); + } + + adoptInto(transaction: ForkReferenceTransaction): void { + this.requireActive("adopt reference replay"); + if (this.scratchReservations.length !== 0) { + throw new Error( + `${this.label}: cannot adopt with ` + + `${this.scratchReservations.length} live scratch reservation(s)`, + ); + } + this.releaseScratchChunks(); + const adoption: ForkReferenceChildReplayAdoption = { + transaction: this.transaction!, + materializedValues: this.materializedValues, + allocatedTypedRecipes: this.allocatedTypedRecipes, + filledTypedRecipes: this.filledTypedRecipes, + materializedExceptionRecipes: this.materializedExceptionRecipes, + }; + transaction.adoptChildReplay(adoption); + // WHY: the transaction copied every sparse value and milestone. Clear only + // this owner's JS collections; the registry now owns codec/transit cleanup. + this.releaseCollections(); + this.phase = "adopted"; + } + + abort(): void { + if (this.phase === "aborted") return; + if (this.phase === "adopted") { + throw new Error(`${this.label}: adopted reference replay cannot be aborted`); + } + const callbacks = [...this.registrations.values()] + .sort((left, right) => right.activationId - left.activationId) + .flatMap(({ abort }) => abort ? [abort] : []); + this.releaseCollections(); + this.phase = "aborted"; + + const failures: unknown[] = []; + try { + if (this.scratchReservations.length !== 0) { + // A trapping codec can bypass its generated release. All reservations + // still belong to this one-shot owner, so zero and release the complete + // retained chunk set during abort. + this.scratchReservations.length = 0; + } + this.releaseScratchChunks(); + } catch (error) { + failures.push(error); + } + for (const callback of callbacks) { + try { + callback(); + } catch (error) { + failures.push(error); + } + } + try { + this.transit.abort(); + } catch (error) { + failures.push(error); + } + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) { + throw new AggregateError( + failures, + `${this.label}: early reference cleanup was incomplete`, + ); + } + } + + private validateRecipeOwnership( + entry: ForkReferenceRecipeEntry, + moduleIds: ReadonlySet, + ): void { + const node = entry.node; + const requireModule = (activationId: number, kind: string): void => { + if (!moduleIds.has(activationId)) { + throw new Error( + `${this.label}: ${kind} recipe ${entry.id} names missing ` + + `activation ${activationId}`, + ); + } + }; + switch (node.kind) { + case "funcref": + requireModule(node.moduleActivation, "funcref"); + break; + case "static-root": + requireModule(node.moduleActivation, "static-root"); + break; + case "struct": + case "array": { + requireModule(node.moduleActivation, node.kind); + const descriptor = + this.declarations.get(node.moduleActivation)?.gcDescriptor; + if (!descriptor) { + throw new Error( + `${this.label}: ${node.kind} recipe ${entry.id} owner ` + + `${node.moduleActivation} has no GC descriptor`, + ); + } + this.gcLayouts.set(entry.id, validateGcRecipe(entry, descriptor)); + break; + } + case "exnref": { + if (node.moduleActivation === FORK_HOST_EXCEPTION_ACTIVATION_ID) { + if ( + this.hostExceptionOwner === null + || node.tagOrdinal !== 0 + || (node.layoutId ?? 0) !== 0 + || (node.scalars?.byteLength ?? 0) !== 0 + || node.payloads.length !== 1 + || this.nodes.get(node.payloads[0]!)?.node.kind !== "externref" + ) { + throw new Error( + `${this.label}: host exception recipe ${entry.id} is malformed ` + + "or has no fresh-child codec", + ); + } + break; + } + requireModule(node.moduleActivation, "exnref"); + const descriptor = + this.declarations.get(node.moduleActivation)?.exceptionDescriptor; + const layout = descriptor?.tags[node.tagOrdinal]; + if ( + !layout + || layout.tagOrdinal !== node.tagOrdinal + || layout.layoutId !== (node.layoutId ?? 0) + || layout.scalarByteLength !== (node.scalars?.byteLength ?? 0) + || layout.referenceCount !== node.payloads.length + ) { + throw new Error( + `${this.label}: exnref recipe ${entry.id} does not match ` + + `activation ${node.moduleActivation}'s exception descriptor`, + ); + } + break; + } + case "i31": + if (this.i31Owner === null) { + throw new Error( + `${this.label}: i31 recipe ${entry.id} has no fresh-child GC codec`, + ); + } + break; + case "null": + case "externref": + break; + } + } + + private directOwner(node: ForkReferenceRecipeNode): number | null { + switch (node.kind) { + case "funcref": + case "struct": + case "array": + case "static-root": + return node.moduleActivation; + case "exnref": + return node.moduleActivation === FORK_HOST_EXCEPTION_ACTIVATION_ID + ? this.hostExceptionOwner + : node.moduleActivation; + case "i31": + return this.i31Owner; + case "null": + case "externref": + return null; + } + } + + private requireCompatibleRecipe( + recipeId: number, + typeCode: number, + ): ForkReferenceRecipeEntry { + const id = assertRecipeId(recipeId, this.nodes.length); + const code = requireReferenceTypeCode(typeCode); + const entry = this.nodes.get(id)!; + const kind = entry.node.kind; + const compatible = kind === "null" || ( + code === WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF + ? kind === "funcref" || kind === "static-root" + : code === WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF + ? ( + kind === "funcref" + || kind === "externref" + || kind === "static-root" + ) + : code === WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF + ? kind === "exnref" + : ( + kind === "i31" + || kind === "struct" + || kind === "array" + || kind === "static-root" + ) + ); + if (!compatible) { + throw new Error( + `${this.label}: ${kind} recipe ${id} cannot initialize ` + + `reference type code ${code}`, + ); + } + return entry; + } + + private requireRecipe(recipeId: number): ForkReferenceRecipeEntry { + return this.nodes.get(assertRecipeId(recipeId, this.nodes.length))!; + } + + private requireRegisteredDependencies( + recipeId: number, + typeCode: number, + ): void { + for (const activationId of this.activationDependencies(recipeId, typeCode)) { + if (!this.registrations.has(activationId)) { + throw new Error( + `${this.label}: recipe ${recipeId} needs unregistered activation ` + + `${activationId}`, + ); + } + } + } + + private materializeRecipe(recipeId: number): unknown { + if (this.materializedValues.has(recipeId)) { + return this.materializedValues.get(recipeId); + } + const entry = this.nodes.get(recipeId)!; + const node = entry.node; + let value: unknown; + switch (node.kind) { + case "null": + value = null; + break; + case "funcref": { + value = this.requireRegistration(node.moduleActivation).functions! + .decode(node.functionOrdinal); + if (typeof value !== "function") { + throw new TypeError( + `${this.label}: funcref recipe ${recipeId} did not produce a function`, + ); + } + break; + } + case "externref": + value = this.externrefs.materialize(node.handle); + break; + case "static-root": + this.prepareTransit(); + value = this.requireRegistration(node.moduleActivation).staticRoots! + .decode(node.staticRootOrdinal); + // Static-root catalogs contain only GC-domain references accepted by + // the instrumenter's `(ref null any)` harvest table. Unlike dynamic + // recipes, no generated allocator exists to publish this identity. + this.transit.publish(recipeId, value); + break; + case "i31": + case "struct": + case "array": + this.materializeTypedGraph(recipeId); + return this.materializedValues.get(recipeId); + case "exnref": + this.materializeException(recipeId, new Set()); + return undefined; + } + this.materializedValues.set(recipeId, value); + return value; + } + + private materializeTypedGraph(rootRecipeId: number): void { + this.prepareTransit(); + const reachable = this.reachableRecipes(rootRecipeId); + for (const entry of this.nodes) { + if (!reachable.has(entry.id) || entry.node.kind !== "externref") continue; + this.publishExternref(entry.id); + } + for (const entry of this.nodes) { + if (!reachable.has(entry.id)) continue; + if (entry.node.kind === "static-root") { + // WHY: immutable constructors can consume static roots while + // allocating, before the later identity walk. Publish every reachable + // instantiation-owned root first so both constructor dependencies and + // mutable field fills observe the activation's canonical identity. + this.materializeRecipe(entry.id); + } + } + for (const entry of this.nodes) { + if (!reachable.has(entry.id)) continue; + const { node } = entry; + if (node.kind !== "struct" && node.kind !== "array") continue; + const layout = this.gcLayouts.get(entry.id)!; + if ( + (layout.flags & FORK_GC_LAYOUT_DEFAULTABLE_SHELL) !== 0 + && !this.allocatedTypedRecipes.has(entry.id) + ) { + this.allocateTyped(entry.id, new Set(), true); + } + } + + const visiting = new Set(); + for (const entry of this.nodes) { + if (reachable.has(entry.id)) this.ensureIdentity(entry.id, visiting); + } + for (const entry of this.nodes) { + if (!reachable.has(entry.id)) continue; + const { node } = entry; + if ( + (node.kind !== "struct" && node.kind !== "array") + || this.filledTypedRecipes.has(entry.id) + ) { + continue; + } + nodeEdges(node).forEach((edge) => this.ensureIdentity(edge, visiting)); + this.requireRegistration(node.moduleActivation).typed!.fill(entry.id); + this.filledTypedRecipes.add(entry.id); + } + } + + private ensureIdentity(recipeId: number, visiting: Set): void { + const node = this.nodes.get(recipeId)!.node; + switch (node.kind) { + case "null": + case "funcref": + case "static-root": + this.materializeRecipe(recipeId); + return; + case "externref": + this.publishExternref(recipeId); + return; + case "exnref": + this.materializeException(recipeId, visiting); + return; + case "i31": + case "struct": + case "array": + this.allocateTyped(recipeId, visiting); + return; + } + } + + private prepareTransit(): void { + if (this.transitPrepared) return; + this.transit.prepare(Math.max(0, this.nodes.length - 1)); + this.transitPrepared = true; + } + + private publishExternref(recipeId: number): void { + if (this.publishedExternrefRecipes.has(recipeId)) return; + const entry = this.nodes.get(recipeId); + if (entry?.node.kind !== "externref") { + throw new Error(`${this.label}: recipe ${recipeId} is not an externref`); + } + const value = this.materializeRecipe(recipeId); + const publisher = [...this.registrations.values()] + .filter((registration) => registration.typed !== undefined) + .sort((left, right) => left.activationId - right.activationId)[0]?.typed; + if (!publisher) { + throw new Error( + `${this.label}: externref recipe ${recipeId} has no generated GC codec`, + ); + } + // WHY: the transit table stores anyref. Only generated Wasm can perform + // the required any.convert_extern for this Worker's canonical token. + publisher.publishExternref(recipeId, value); + if (!Object.is(this.transit.read(recipeId), value)) { + throw new Error( + `${this.label}: externref recipe ${recipeId} lost token identity ` + + "during anyref publication", + ); + } + this.publishedExternrefRecipes.add(recipeId); + } + + private allocateTyped( + recipeId: number, + visiting: Set, + defaultableShell = false, + ): void { + if (this.allocatedTypedRecipes.has(recipeId)) return; + if (visiting.has(recipeId)) { + throw new Error( + `${this.label}: typed replay has an unallocatable constructor cycle ` + + `at recipe ${recipeId}`, + ); + } + const node = this.nodes.get(recipeId)!.node; + if (node.kind !== "i31" && node.kind !== "struct" && node.kind !== "array") { + throw new Error(`${this.label}: recipe ${recipeId} is not a typed reference`); + } + visiting.add(recipeId); + try { + let activationId: number; + if (node.kind === "i31") { + activationId = this.i31Owner!; + } else { + activationId = node.moduleActivation; + if (!defaultableShell) { + const layout = this.gcLayouts.get(recipeId)!; + gcAllocationDependencies(node, layout) + .forEach((dependency) => this.ensureIdentity(dependency, visiting)); + } + } + this.requireRegistration(activationId).typed!.allocate(recipeId); + const value = this.transit.read(recipeId); + if (value === null || value === undefined) { + throw new Error( + `${this.label}: typed provider did not publish recipe ${recipeId}`, + ); + } + this.materializedValues.set(recipeId, value); + this.allocatedTypedRecipes.add(recipeId); + } finally { + visiting.delete(recipeId); + } + } + + private materializeException( + recipeId: number, + visiting: Set, + ): void { + if (this.materializedExceptionRecipes.has(recipeId)) return; + if (visiting.has(recipeId)) { + throw new Error( + `${this.label}: exception replay has an unallocatable cycle at ` + + `recipe ${recipeId}`, + ); + } + const node = this.nodes.get(recipeId)!.node; + if (node.kind !== "exnref") { + throw new Error(`${this.label}: recipe ${recipeId} is not an exception`); + } + visiting.add(recipeId); + try { + node.payloads.forEach((payload) => this.ensureIdentity(payload, visiting)); + const owner = this.directOwner(node)!; + this.requireRegistration(owner).exceptions!.materialize!(recipeId); + this.materializedExceptionRecipes.add(recipeId); + } finally { + visiting.delete(recipeId); + } + } + + private reachableRecipes(rootRecipeId: number): ReadonlySet { + const visited = new Set(); + const visit = (recipeId: number): void => { + if (visited.has(recipeId)) return; + visited.add(recipeId); + nodeEdges(this.nodes.get(recipeId)!.node).forEach(visit); + }; + visit(rootRecipeId); + return visited; + } + + private validateMaterializedValue( + recipeId: number, + typeCode: number, + value: unknown, + ): void { + if ( + typeCode === WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF + && value !== null + && typeof value !== "function" + ) { + throw new TypeError( + `${this.label}: recipe ${recipeId} did not materialize a funcref`, + ); + } + if ( + typeCode === WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF + && value !== null + ) { + throw new TypeError( + `${this.label}: recipe ${recipeId} did not materialize a nullable exnref`, + ); + } + } + + private writeBytes( + pointer: number | bigint, + bytes: Uint8Array, + context: string, + ): void { + const { offset } = this.memoryRange(pointer, bytes.byteLength, context); + new Uint8Array(this.memory.buffer, offset, bytes.byteLength).set(bytes); + } + + private writeRecipeIds( + pointer: number | bigint, + ids: readonly number[], + context: string, + ): void { + const { offset } = this.memoryRange(pointer, ids.length * 4, context); + const view = new DataView(this.memory.buffer); + ids.forEach((id, index) => { + assertRecipeId(id, this.nodes.length); + view.setUint32(offset + index * 4, id, true); + }); + } + + private memoryRange( + pointer: number | bigint, + byteLength: number, + context: string, + ): { readonly offset: number; readonly length: number } { + assertU32(byteLength, `${context} byte length`); + const offset = typeof pointer === "bigint" ? Number(pointer) : pointer; + if ( + !Number.isSafeInteger(offset) + || offset < 0 + || (typeof pointer === "bigint" && BigInt(offset) !== pointer) + ) { + throw new RangeError(`${this.label}: ${context} has an invalid guest pointer`); + } + const memoryLength = this.memory.buffer.byteLength; + if (offset > memoryLength || byteLength > memoryLength - offset) { + throw new RangeError(`${this.label}: ${context} exceeds WebAssembly memory`); + } + return { offset, length: byteLength }; + } + + private checkedScratchPointer(value: number | bigint): number { + const result = typeof value === "bigint" ? Number(value) : value; + if ( + !Number.isSafeInteger(result) + || result <= 0 + || (typeof value === "bigint" && BigInt(result) !== value) + ) { + throw new RangeError(`${this.label}: scratch pointer is invalid`); + } + return result; + } + + private checkedScratchSize(value: number | bigint): number { + const result = typeof value === "bigint" ? Number(value) : value; + if ( + !Number.isSafeInteger(result) + || result <= 0 + || result > 0xffff_ffff + || (typeof value === "bigint" && BigInt(result) !== value) + ) { + throw new RangeError(`${this.label}: scratch size is not a nonzero u32`); + } + return result; + } + + private alignScratch(value: number, alignment = 16): number { + const result = Math.ceil(value / alignment) * alignment; + if (!Number.isSafeInteger(result) || result < value) { + throw new RangeError(`${this.label}: scratch alignment overflow`); + } + return result; + } + + private releaseScratchChunks(): void { + if (this.scratchReservations.length !== 0) { + throw new Error( + `${this.label}: cannot release scratch with live reservations`, + ); + } + const chunks = this.scratchChunks.splice(0).reverse(); + const failures: unknown[] = []; + for (const chunk of chunks) { + try { + new Uint8Array(this.memory.buffer, chunk.addr, chunk.size).fill(0); + this.deallocateScratch(chunk.addr, chunk.size); + } catch (error) { + failures.push(error); + } + } + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) { + throw new AggregateError( + failures, + `${this.label}: scratch cleanup was incomplete`, + ); + } + } + + private requireRegistration(activationId: number): RegisteredActivation { + const registration = this.registrations.get(activationId); + if (!registration) { + throw new Error( + `${this.label}: activation ${activationId} is not registered`, + ); + } + return registration; + } + + private abortAfterFailure(cause: unknown): never { + try { + this.abort(); + } catch (cleanupError) { + throw new AggregateError( + [cause, cleanupError], + `${this.label}: reference materialization and cleanup both failed`, + ); + } + throw cause; + } + + private releaseCollections(): void { + this.materializedValues.clear(); + this.publishedExternrefRecipes.clear(); + this.allocatedTypedRecipes.clear(); + this.filledTypedRecipes.clear(); + this.materializedExceptionRecipes.clear(); + this.exceptionCacheIndexes.clear(); + this.gcLayouts.clear(); + this.replayGcVectors.clear(); + this.referenceVectorIntern.clear(); + this.registrations.clear(); + this.declarations.clear(); + this.nodes = new PagedForkReferenceDirectory(); + this.referenceVectors.clear(); + this.transaction = null; + } + + private requireActive(operation: string): void { + if (this.phase !== "active") { + throw new Error( + `${this.label}: cannot ${operation} after provider was ${this.phase}`, + ); + } + } +} diff --git a/host/src/fork-exception-provider.ts b/host/src/fork-exception-provider.ts new file mode 100644 index 0000000000..981efe8095 --- /dev/null +++ b/host/src/fork-exception-provider.ts @@ -0,0 +1,509 @@ +import { + type ForkActivationExceptionProvider, + ForkActivationRegistry, +} from "./fork-activation-registry"; +import { + FORK_HOST_EXCEPTION_ACTIVATION_ID, + type ForkExceptionSlotProvider, +} from "./fork-reference-transaction"; +import { + WPK_FORK_EXCEPTION_CODEC_HEADER_SIZE, + WPK_FORK_EXCEPTION_CODEC_SECTION, + WPK_FORK_EXCEPTION_CODEC_TAG_RECORD_SIZE, + WPK_FORK_EXCEPTION_CODEC_VERSION, + WPK_FORK_EXCEPTION_EXPORT_ABORT, + WPK_FORK_EXCEPTION_EXPORT_CLEAR, + WPK_FORK_EXCEPTION_EXPORT_DECODE, + WPK_FORK_EXCEPTION_EXPORT_ENCODE, + WPK_FORK_EXCEPTION_EXPORT_ENCODE_INGRESS, + WPK_FORK_EXCEPTION_EXPORT_MATERIALIZE, + WPK_FORK_EXCEPTION_EXPORT_THROW_RECIPE, + WPK_FORK_EXCEPTION_EXPORT_THROW_SLOT, + WPK_FORK_EXCEPTION_IMPORT_ACTIVATION, + WPK_FORK_EXCEPTION_IMPORT_BROKER_ENCODE, + WPK_FORK_EXCEPTION_IMPORT_BROKER_THROW_RECIPE, + WPK_FORK_EXCEPTION_IMPORT_CACHE_INDEX, + WPK_FORK_EXCEPTION_IMPORT_CLAIM, + WPK_FORK_EXCEPTION_IMPORT_DEFINE, + WPK_FORK_EXCEPTION_IMPORT_INGRESS_THROW, + WPK_FORK_EXCEPTION_IMPORT_LOAD, + WPK_FORK_EXCEPTION_IMPORT_LOOKUP, + WPK_FORK_EXCEPTION_IMPORT_ROUTE, + WPK_FORK_REFERENCE_IMPORT_SCRATCH_RELEASE, + WPK_FORK_REFERENCE_IMPORT_SCRATCH_RESERVE, +} from "./generated/abi"; + +export const FORK_EXCEPTION_CODEC_SECTION = + WPK_FORK_EXCEPTION_CODEC_SECTION; +export const FORK_EXCEPTION_CODEC_VERSION = WPK_FORK_EXCEPTION_CODEC_VERSION; +export const FORK_EXCEPTION_CODEC_HEADER_SIZE = + WPK_FORK_EXCEPTION_CODEC_HEADER_SIZE; +export const FORK_EXCEPTION_CODEC_TAG_RECORD_SIZE = + WPK_FORK_EXCEPTION_CODEC_TAG_RECORD_SIZE; + +export const FORK_EXCEPTION_ACTIVATION_IMPORT = + WPK_FORK_EXCEPTION_IMPORT_ACTIVATION; +export const FORK_EXCEPTION_LOOKUP_IMPORT = WPK_FORK_EXCEPTION_IMPORT_LOOKUP; +export const FORK_EXCEPTION_CLAIM_IMPORT = WPK_FORK_EXCEPTION_IMPORT_CLAIM; +export const FORK_EXCEPTION_DEFINE_IMPORT = WPK_FORK_EXCEPTION_IMPORT_DEFINE; +export const FORK_EXCEPTION_LOAD_IMPORT = WPK_FORK_EXCEPTION_IMPORT_LOAD; +export const FORK_EXCEPTION_ROUTE_IMPORT = WPK_FORK_EXCEPTION_IMPORT_ROUTE; +export const FORK_EXCEPTION_CACHE_INDEX_IMPORT = + WPK_FORK_EXCEPTION_IMPORT_CACHE_INDEX; +export const FORK_EXCEPTION_BROKER_ENCODE_IMPORT = + WPK_FORK_EXCEPTION_IMPORT_BROKER_ENCODE; +export const FORK_EXCEPTION_BROKER_THROW_RECIPE_IMPORT = + WPK_FORK_EXCEPTION_IMPORT_BROKER_THROW_RECIPE; +export const FORK_EXCEPTION_INGRESS_THROW_IMPORT = + WPK_FORK_EXCEPTION_IMPORT_INGRESS_THROW; +export const FORK_REFERENCE_SCRATCH_RESERVE_IMPORT = + WPK_FORK_REFERENCE_IMPORT_SCRATCH_RESERVE; +export const FORK_REFERENCE_SCRATCH_RELEASE_IMPORT = + WPK_FORK_REFERENCE_IMPORT_SCRATCH_RELEASE; + +export const FORK_EXCEPTION_ENCODE_EXPORT = WPK_FORK_EXCEPTION_EXPORT_ENCODE; +export const FORK_EXCEPTION_DECODE_EXPORT = WPK_FORK_EXCEPTION_EXPORT_DECODE; +export const FORK_EXCEPTION_THROW_SLOT_EXPORT = + WPK_FORK_EXCEPTION_EXPORT_THROW_SLOT; +export const FORK_EXCEPTION_THROW_RECIPE_EXPORT = + WPK_FORK_EXCEPTION_EXPORT_THROW_RECIPE; +export const FORK_EXCEPTION_ENCODE_INGRESS_EXPORT = + WPK_FORK_EXCEPTION_EXPORT_ENCODE_INGRESS; +export const FORK_EXCEPTION_MATERIALIZE_EXPORT = + WPK_FORK_EXCEPTION_EXPORT_MATERIALIZE; +export const FORK_EXCEPTION_CLEAR_EXPORT = WPK_FORK_EXCEPTION_EXPORT_CLEAR; +export const FORK_EXCEPTION_ABORT_EXPORT = WPK_FORK_EXCEPTION_EXPORT_ABORT; + +const MAX_RECIPE_ID = 0x7fff_fffe; +const MAX_ACTIVATION_ID = 0x7fff_ffff; + +export interface ForkExceptionTagLayout { + readonly tagOrdinal: number; + readonly layoutId: number; + readonly scalarByteLength: number; + readonly referenceCount: number; +} + +export interface ForkExceptionCodecDescriptor { + readonly version: number; + readonly tags: readonly ForkExceptionTagLayout[]; +} + +export interface ForkExceptionProvider + extends ForkActivationExceptionProvider, ForkExceptionSlotProvider +{ + readonly activationId: number; + readonly encode: CallableFunction; + readonly decode: CallableFunction; +} + +function assertI32(value: number, context: string): void { + if (!Number.isInteger(value) || value < -0x8000_0000 || value > 0x7fff_ffff) { + throw new RangeError(`${context} is not an i32`); + } +} + +function assertU31(value: number, context: string, allowZero = true): void { + if ( + !Number.isInteger(value) + || value < (allowZero ? 0 : 1) + || value > 0x7fff_ffff + ) { + throw new RangeError(`${context} is not ${allowZero ? "a" : "a nonzero"} u31`); + } +} + +function assertRecipeId(value: number, allowZero: boolean): void { + assertU31(value, "fork exception recipe id", allowZero); + if (value > MAX_RECIPE_ID) { + throw new RangeError(`fork exception recipe id ${value} is reserved`); + } +} + +function requireFunction( + exports: WebAssembly.Exports, + name: string, +): CallableFunction { + const value = exports[name]; + if (typeof value !== "function") { + throw new Error(`fork exception provider is missing function export ${name}`); + } + return value as CallableFunction; +} + +function checkedPointerResult( + value: number, + ptrWidth: 4 | 8, +): number | bigint { + return ptrWidth === 8 ? BigInt(value) : value; +} + +/** + * Parse and validate the exact-tag catalog emitted by the instrumenter. + * + * The module template hash binds tag identities and concrete payload types; + * this descriptor binds their deterministic codec ordinals and byte layout. + */ +export function readForkExceptionCodecDescriptor( + module: WebAssembly.Module, +): ForkExceptionCodecDescriptor { + const sections = WebAssembly.Module.customSections( + module, + FORK_EXCEPTION_CODEC_SECTION, + ); + if (sections.length !== 1) { + throw new Error( + `expected one ${FORK_EXCEPTION_CODEC_SECTION} section, found ${sections.length}`, + ); + } + const bytes = new Uint8Array(sections[0]!); + if (bytes.byteLength < FORK_EXCEPTION_CODEC_HEADER_SIZE) { + throw new Error("fork exception codec descriptor is truncated"); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const version = view.getUint8(0); + if (version !== FORK_EXCEPTION_CODEC_VERSION) { + throw new Error(`unsupported fork exception codec version ${version}`); + } + if (view.getUint8(1) !== 0 || view.getUint16(2, true) !== 0) { + throw new Error("fork exception codec descriptor reserved fields are nonzero"); + } + const count = view.getUint32(4, true); + const expected = FORK_EXCEPTION_CODEC_HEADER_SIZE + + count * FORK_EXCEPTION_CODEC_TAG_RECORD_SIZE; + if (!Number.isSafeInteger(expected) || bytes.byteLength !== expected) { + throw new Error("fork exception codec descriptor has an invalid size"); + } + const tags: ForkExceptionTagLayout[] = []; + const layouts = new Set(); + for (let index = 0; index < count; index++) { + const offset = FORK_EXCEPTION_CODEC_HEADER_SIZE + + index * FORK_EXCEPTION_CODEC_TAG_RECORD_SIZE; + const tagOrdinal = view.getUint32(offset, true); + const layoutId = view.getUint32(offset + 4, true); + const scalarByteLength = view.getUint32(offset + 8, true); + const referenceCount = view.getUint32(offset + 12, true); + if (tagOrdinal !== index) { + throw new Error( + `fork exception tag ordinal ${tagOrdinal} is noncanonical at ${index}`, + ); + } + if (layoutId > MAX_ACTIVATION_ID || layouts.has(layoutId)) { + throw new Error(`fork exception layout id ${layoutId} is invalid or duplicated`); + } + layouts.add(layoutId); + tags.push({ tagOrdinal, layoutId, scalarByteLength, referenceCount }); + } + return { version, tags }; +} + +/** Resolve one activation's local, exact-tag codec after instantiation. */ +export function forkExceptionProviderFromInstance( + activationId: number, + instance: WebAssembly.Instance, +): ForkExceptionProvider { + assertU31(activationId, "fork exception activation id"); + const throwSlot = requireFunction( + instance.exports, + FORK_EXCEPTION_THROW_SLOT_EXPORT, + ); + const throwRecipe = requireFunction( + instance.exports, + FORK_EXCEPTION_THROW_RECIPE_EXPORT, + ); + const encodeIngress = requireFunction( + instance.exports, + FORK_EXCEPTION_ENCODE_INGRESS_EXPORT, + ); + const materialize = requireFunction( + instance.exports, + FORK_EXCEPTION_MATERIALIZE_EXPORT, + ); + const clear = requireFunction(instance.exports, FORK_EXCEPTION_CLEAR_EXPORT); + const abort = requireFunction(instance.exports, FORK_EXCEPTION_ABORT_EXPORT); + return { + activationId, + encode: requireFunction(instance.exports, FORK_EXCEPTION_ENCODE_EXPORT), + decode: requireFunction(instance.exports, FORK_EXCEPTION_DECODE_EXPORT), + throwSlot(slot): never { + assertI32(slot, "fork exception scratch slot"); + throwSlot(slot); + throw new Error(`activation ${activationId} exception slot returned without throwing`); + }, + throwRecipe(recipeId): never { + assertRecipeId(recipeId, false); + throwRecipe(recipeId); + throw new Error(`activation ${activationId} exception recipe returned without throwing`); + }, + encodeIngress(token): number { + assertU31(token, "fork exception ingress token", false); + const recipeId = Number(encodeIngress(token)); + assertRecipeId(recipeId, true); + return recipeId; + }, + materialize(recipeId): void { + assertRecipeId(recipeId, false); + materialize(recipeId); + }, + clear(): void { + clear(); + }, + clearSlots(): void { + clear(); + }, + abort(): void { + abort(); + }, + }; +} + +function catchProviderThrow( + provider: ForkActivationExceptionProvider, + slot: number, +): unknown { + try { + provider.throwSlot(slot); + } catch (value) { + return value; + } + throw new Error(`fork exception slot ${slot} returned without throwing`); +} + +/** + * Scalar-only bridge for exceptions whose tag is owned by another activation. + * + * Providers are probed in activation order. During a probe, the candidate's + * own unknown-tag callback returns zero for the same thrown identity instead + * of recursively restarting discovery. A raw JavaScript/JSTag exception that + * no Wasm activation owns is represented by a host-owned recipe. + */ +export class ForkExceptionBroker { + private nextIngressToken = 1; + private readonly ingress = new Map(); + private readonly probes: unknown[] = []; + + constructor( + private readonly registry: ForkActivationRegistry, + private readonly label: string, + private readonly replayReferences: () => { + exceptionOwner(recipeId: number): number; + materializeHostException(recipeId: number): unknown; + } = () => registry.currentReferences(), + /** + * Called only after every exact activation codec declines an exception. + * It returns the owner-backed externref payload a fresh child can decode; + * the parent transaction still retains the original exception identity. + */ + private readonly normalizeUnclaimedHostException?: ( + value: unknown, + ) => unknown, + ) {} + + encodeFromSlot(sourceActivation: number, slot: number): number { + const source = this.requireProvider(sourceActivation); + const value = catchProviderThrow(source, slot); + if ( + this.probes.length !== 0 + && Object.is(this.probes[this.probes.length - 1], value) + ) { + return 0; + } + + const token = this.allocateIngress(value); + try { + for (const activation of this.registry.activations()) { + if (activation.activationId === sourceActivation) continue; + const provider = activation.exceptionProvider; + if (!provider) continue; + this.probes.push(value); + let recipeId: number; + try { + recipeId = provider.encodeIngress(token); + } finally { + this.probes.pop(); + } + assertRecipeId(recipeId, true); + if (recipeId !== 0) return recipeId; + } + const childPayload = this.normalizeUnclaimedHostException + ? this.normalizeUnclaimedHostException(value) + : value; + return this.registry.currentReferences().captureHostException( + value, + childPayload, + ); + } finally { + this.ingress.delete(token); + } + } + + throwIngress(token: number): never { + assertU31(token, "fork exception ingress token", false); + if (!this.ingress.has(token)) { + throw new Error(`${this.label}: unknown exception ingress token ${token}`); + } + throw this.ingress.get(token); + } + + throwRecipe(recipeId: number): never { + assertRecipeId(recipeId, false); + const references = this.replayReferences(); + const owner = references.exceptionOwner(recipeId); + if (owner === FORK_HOST_EXCEPTION_ACTIVATION_ID) { + throw references.materializeHostException(recipeId); + } + return this.requireProvider(owner).throwRecipe(recipeId); + } + + clear(): void { + this.ingress.clear(); + this.probes.length = 0; + } + + private requireProvider(activationId: number): ForkActivationExceptionProvider { + const provider = this.registry.getActivation(activationId).exceptionProvider; + if (!provider) { + throw new Error( + `${this.label}: activation ${activationId} has no exception provider`, + ); + } + return provider; + } + + private allocateIngress(value: unknown): number { + if (this.nextIngressToken > MAX_RECIPE_ID) { + this.nextIngressToken = 1; + } + const start = this.nextIngressToken; + do { + const token = this.nextIngressToken++; + if (!this.ingress.has(token)) { + this.ingress.set(token, value); + return token; + } + if (this.nextIngressToken > MAX_RECIPE_ID) this.nextIngressToken = 1; + } while (this.nextIngressToken !== start); + throw new RangeError(`${this.label}: exception ingress token space exhausted`); + } +} + +export interface ForkExceptionImportOptions { + readonly activationId: number; + readonly ptrWidth: 4 | 8; + readonly registry: ForkActivationRegistry; + readonly broker: ForkExceptionBroker; + /** Late-bound because imports must exist before the instance exports do. */ + readonly provider: () => ForkExceptionProvider; + /** + * Replay owner used before the complete fresh-child registry can attach. + * Capture-only callbacks remain bound to the registry transaction. + */ + readonly referenceReplay?: () => ForkExceptionReferenceReplayImports; +} + +export interface ForkExceptionReferenceReplayImports { + loadException( + recipeId: number, + moduleActivation: number, + tagOrdinal: number, + layoutId: number, + scalarPointer: number | bigint, + scalarByteLength: number, + referenceIdsPointer: number | bigint, + referenceCount: number, + ): number; + routeException(recipeId: number, expectedActivation: number): number; + exceptionCacheIndex(recipeId: number): number; + reserveScratch(size: number | bigint): number; + releaseScratch(pointer: number | bigint, size: number | bigint): void; +} + +/** + * Bind one in-module codec to the active process transaction. + * + * Every callback has only scalar parameters. The sole reference transfer is a + * thrown exception caught by JavaScript and immediately re-thrown into another + * provider; no reference enters the continuation or module-state arena. + */ +export function buildForkExceptionImports( + options: ForkExceptionImportOptions, +): Record { + const { activationId, ptrWidth, registry, broker } = options; + assertU31(activationId, "fork exception activation id"); + if (ptrWidth !== 4 && ptrWidth !== 8) { + throw new TypeError(`invalid fork exception pointer width ${ptrWidth}`); + } + const references = () => registry.currentReferences(); + const replayReferences = options.referenceReplay ?? references; + return { + [FORK_EXCEPTION_ACTIVATION_IMPORT]: new WebAssembly.Global( + { value: "i32", mutable: false }, + activationId, + ), + [FORK_EXCEPTION_LOOKUP_IMPORT]: (slot: number): number => + references().lookupExceptionSlot(slot, options.provider()), + [FORK_EXCEPTION_CLAIM_IMPORT]: (slot: number): number => + references().claimExceptionSlot(slot, options.provider()), + [FORK_EXCEPTION_DEFINE_IMPORT]: ( + recipeId: number, + moduleActivation: number, + tagOrdinal: number, + layoutId: number, + scalarPointer: number | bigint, + scalarByteLength: number, + referenceIdsPointer: number | bigint, + referenceCount: number, + ): void => references().defineException( + recipeId, + moduleActivation, + tagOrdinal, + layoutId, + scalarPointer, + scalarByteLength, + referenceIdsPointer, + referenceCount, + ), + [FORK_EXCEPTION_LOAD_IMPORT]: ( + recipeId: number, + moduleActivation: number, + tagOrdinal: number, + layoutId: number, + scalarPointer: number | bigint, + scalarByteLength: number, + referenceIdsPointer: number | bigint, + referenceCount: number, + ): number => replayReferences().loadException( + recipeId, + moduleActivation, + tagOrdinal, + layoutId, + scalarPointer, + scalarByteLength, + referenceIdsPointer, + referenceCount, + ), + [FORK_EXCEPTION_ROUTE_IMPORT]: ( + recipeId: number, + expectedActivation: number, + ): number => replayReferences().routeException( + recipeId, + expectedActivation, + ), + [FORK_EXCEPTION_CACHE_INDEX_IMPORT]: (recipeId: number): number => + replayReferences().exceptionCacheIndex(recipeId), + [FORK_EXCEPTION_BROKER_ENCODE_IMPORT]: (slot: number): number => + broker.encodeFromSlot(activationId, slot), + [FORK_EXCEPTION_BROKER_THROW_RECIPE_IMPORT]: (recipeId: number): never => + broker.throwRecipe(recipeId), + [FORK_EXCEPTION_INGRESS_THROW_IMPORT]: (token: number): never => + broker.throwIngress(token), + [FORK_REFERENCE_SCRATCH_RESERVE_IMPORT]: ( + size: number | bigint, + ): number | bigint => + checkedPointerResult(replayReferences().reserveScratch(size), ptrWidth), + [FORK_REFERENCE_SCRATCH_RELEASE_IMPORT]: ( + pointer: number | bigint, + size: number | bigint, + ): void => replayReferences().releaseScratch(pointer, size), + }; +} diff --git a/host/src/fork-externref-import-mailbox.ts b/host/src/fork-externref-import-mailbox.ts new file mode 100644 index 0000000000..cc86e79bad --- /dev/null +++ b/host/src/fork-externref-import-mailbox.ts @@ -0,0 +1,1314 @@ +import type { + ForkExternrefToken, + ForkExternrefTokenCache, +} from "./fork-reference-broker"; + +/** + * Version 2 is a catalog-sized, one-request-at-a-time mailbox. It is private + * to the host runtime, but versioning it prevents a mixed Worker/owner build + * from interpreting the same scalar words differently. + */ +export const FORK_EXTERNREF_IMPORT_MAILBOX_VERSION = 2; +export const FORK_EXTERNREF_IMPORT_DESCRIPTOR_VERSION = 1; + +const MAILBOX_MAGIC = 0x4b465849; // "KFXI" +const MAX_U32 = 0xffff_ffff; +const MAX_I32 = 0x7fff_ffff; +const MIN_I32 = -0x8000_0000; +const MAX_I64 = (1n << 63n) - 1n; +const MIN_I64 = -(1n << 63n); + +const enum HeaderWord { + Status = 0, + Magic = 1, + MailboxVersion = 2, + Pid = 3, + Generation = 4, + Sender = 5, + SequenceLow = 6, + SequenceHigh = 7, + DescriptorVersion = 8, + Ordinal = 9, + ParamCount = 10, + ResultCount = 11, + ParamCapacity = 12, + ResultCapacity = 13, + FailureCode = 14, + ExceptionHandle = 15, + CloseReason = 16, + Reserved1 = 17, +} + +const HEADER_WORDS = HeaderWord.Reserved1 + 1; +const HEADER_BYTES = HEADER_WORDS * Int32Array.BYTES_PER_ELEMENT; +const SLOT_BYTES = BigInt64Array.BYTES_PER_ELEMENT; +const TYPE_CODES_PER_BYTE = 2; + +export interface ForkExternrefImportMailboxCapacity { + readonly params: number; + readonly results: number; +} + +interface ForkExternrefImportMailboxLayout + extends ForkExternrefImportMailboxCapacity { + readonly paramTypesOffset: number; + readonly resultTypesOffset: number; + readonly paramOffset: number; + readonly resultOffset: number; + readonly byteLength: number; +} + +const enum MailboxStatus { + Idle = 0, + Writing = 1, + RequestReady = 2, + Dispatching = 3, + ResultReady = 4, + ExceptionReady = 5, + Failed = 6, + Closed = 7, +} + +export enum ForkExternrefImportFailureCode { + Protocol = 1, + Unauthorized = 2, + ArgumentAuthorization = 3, + HandlerContract = 4, + OwnerFailure = 5, + NotificationFailure = 6, + Teardown = 7, +} + +export type ForkExternrefImportValueType = + | "i32" + | "i64" + | "f32" + | "f64" + | "externref"; + +export type ForkExternrefImportValue = + | number + | bigint + | null + | ForkExternrefToken; + +export interface ForkExternrefImportDescriptor { + readonly version: typeof FORK_EXTERNREF_IMPORT_DESCRIPTOR_VERSION; + readonly ordinal: number; + readonly params: readonly ForkExternrefImportValueType[]; + readonly results: readonly ForkExternrefImportValueType[]; +} + +export interface ForkExternrefImportBinding { + readonly pid: number; + readonly generationId: number; + /** + * One nonzero u32 assigned to one process or pthread Worker. Side modules + * execute on that same Worker and deliberately reuse the same sender. + */ + readonly senderId: number; +} + +/** + * The only per-call message that needs to cross postMessage. All fields are + * scalar; the mailbox itself is transferred once in the Worker init message. + */ +export interface ForkExternrefImportWake { + readonly mailboxVersion: number; + readonly pid: number; + readonly generationId: number; + readonly senderId: number; + readonly sequenceLow: number; + readonly sequenceHigh: number; +} + +export interface ForkExternrefImportAuthority { + authorizeForWire( + pid: number, + generationId: number, + handle: number, + ): unknown; + registerForWire( + pid: number, + generationId: number, + value: unknown, + ): number; +} + +export interface ForkExternrefImportHandlerContext + extends ForkExternrefImportBinding { + readonly descriptor: ForkExternrefImportDescriptor; +} + +export type ForkExternrefImportHandler = ( + context: ForkExternrefImportHandlerContext, + ...args: unknown[] +) => unknown; + +export interface ForkExternrefImportOwnerEndpointOptions { + /** + * Revalidate the exact live Worker and process image for every request. + * Implementations should compare object identity owned by the entrypoint, + * not trust PID/generation numbers copied from the wake message. + */ + readonly authorizeSender: ( + binding: ForkExternrefImportBinding, + ) => void; + /** Owner-realm diagnostics; no Error or host value crosses the mailbox. */ + readonly onDiagnostic?: ( + error: unknown, + failure: ForkExternrefImportFailureCode, + ) => void; +} + +interface RegisteredHandler { + readonly descriptor: ForkExternrefImportDescriptor; + readonly handler: ForkExternrefImportHandler; +} + +function assertSafeByteCount(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError(`${label} exceeds JavaScript's safe byte range`); + } +} + +function checkedAdd(left: number, right: number, label: string): number { + const result = left + right; + assertSafeByteCount(result, label); + return result; +} + +function checkedMultiply( + left: number, + right: number, + label: string, +): number { + const result = left * right; + assertSafeByteCount(result, label); + return result; +} + +function alignToSlot(value: number): number { + const remainder = value % SLOT_BYTES; + return remainder === 0 + ? value + : checkedAdd(value, SLOT_BYTES - remainder, "mailbox alignment"); +} + +function validateCapacity( + capacity: ForkExternrefImportMailboxCapacity, +): void { + if ( + typeof capacity !== "object" + || capacity === null + ) { + throw new TypeError("fork externref import mailbox capacity is required"); + } + assertU32(capacity.params, "mailbox parameter capacity", true); + assertU32(capacity.results, "mailbox result capacity", true); +} + +function typeSignatureBytes(count: number): number { + return Math.ceil(count / TYPE_CODES_PER_BYTE); +} + +function mailboxLayout( + capacity: ForkExternrefImportMailboxCapacity, +): ForkExternrefImportMailboxLayout { + validateCapacity(capacity); + const paramTypesOffset = HEADER_BYTES; + const resultTypesOffset = checkedAdd( + paramTypesOffset, + typeSignatureBytes(capacity.params), + "mailbox parameter type signature", + ); + const typeEnd = checkedAdd( + resultTypesOffset, + typeSignatureBytes(capacity.results), + "mailbox result type signature", + ); + const paramOffset = alignToSlot(typeEnd); + const resultOffset = checkedAdd( + paramOffset, + checkedMultiply( + capacity.params, + SLOT_BYTES, + "mailbox parameter slots", + ), + "mailbox result offset", + ); + const byteLength = checkedAdd( + resultOffset, + checkedMultiply( + capacity.results, + SLOT_BYTES, + "mailbox result slots", + ), + "mailbox byte length", + ); + return Object.freeze({ + params: capacity.params, + results: capacity.results, + paramTypesOffset, + resultTypesOffset, + paramOffset, + resultOffset, + byteLength, + }); +} + +function assertU32(value: number, label: string, allowZero = false): void { + if ( + !Number.isInteger(value) + || value < (allowZero ? 0 : 1) + || value > MAX_U32 + ) { + throw new RangeError( + `${label} must be ${allowZero ? "an" : "a positive"} unsigned 32-bit integer`, + ); + } +} + +function validateBinding(binding: ForkExternrefImportBinding): void { + assertU32(binding.pid, "fork externref import pid"); + assertU32( + binding.generationId, + "fork externref import generation", + ); + assertU32(binding.senderId, "fork externref import sender"); +} + +function valueTypeCode(type: ForkExternrefImportValueType): number { + switch (type) { + case "i32": + return 1; + case "i64": + return 2; + case "f32": + return 3; + case "f64": + return 4; + case "externref": + return 5; + default: + throw new TypeError( + `unsupported fork externref import value type ${String(type)}`, + ); + } +} + +function validateTypes( + types: readonly ForkExternrefImportValueType[], + label: string, +): void { + if (!Array.isArray(types)) { + throw new TypeError(`${label} types must be an array`); + } + assertU32(types.length, `${label} count`, true); + for (const type of types) valueTypeCode(type); +} + +function writeTypeSequence( + view: DataView, + byteOffset: number, + types: readonly ForkExternrefImportValueType[], +): void { + for ( + let typeIndex = 0; + typeIndex < types.length; + typeIndex += TYPE_CODES_PER_BYTE + ) { + const low = valueTypeCode(types[typeIndex]!); + const high = typeIndex + 1 < types.length + ? valueTypeCode(types[typeIndex + 1]!) + : 0; + view.setUint8(byteOffset + typeIndex / TYPE_CODES_PER_BYTE, low | (high << 4)); + } +} + +function typeSequenceMatches( + view: DataView, + byteOffset: number, + types: readonly ForkExternrefImportValueType[], +): boolean { + for ( + let typeIndex = 0; + typeIndex < types.length; + typeIndex += TYPE_CODES_PER_BYTE + ) { + const low = valueTypeCode(types[typeIndex]!); + const high = typeIndex + 1 < types.length + ? valueTypeCode(types[typeIndex + 1]!) + : 0; + if ( + view.getUint8(byteOffset + typeIndex / TYPE_CODES_PER_BYTE) + !== (low | (high << 4)) + ) { + return false; + } + } + return true; +} + +export function defineForkExternrefImport( + ordinal: number, + params: readonly ForkExternrefImportValueType[], + results: readonly ForkExternrefImportValueType[], +): ForkExternrefImportDescriptor { + assertU32(ordinal, "fork externref import ordinal", true); + validateTypes(params, "parameter"); + validateTypes(results, "result"); + return Object.freeze({ + version: FORK_EXTERNREF_IMPORT_DESCRIPTOR_VERSION, + ordinal, + params: Object.freeze([...params]), + results: Object.freeze([...results]), + }); +} + +export function forkExternrefImportMailboxBytes( + capacity: ForkExternrefImportMailboxCapacity, +): number { + return mailboxLayout(capacity).byteLength; +} + +export function createForkExternrefImportMailbox( + capacity: ForkExternrefImportMailboxCapacity, +): SharedArrayBuffer { + const layout = mailboxLayout(capacity); + const buffer = new SharedArrayBuffer(layout.byteLength); + const words = new Int32Array(buffer); + words[HeaderWord.Magic] = MAILBOX_MAGIC; + words[HeaderWord.MailboxVersion] = + FORK_EXTERNREF_IMPORT_MAILBOX_VERSION; + words[HeaderWord.ParamCapacity] = capacity.params; + words[HeaderWord.ResultCapacity] = capacity.results; + Atomics.store(words, HeaderWord.Status, MailboxStatus.Idle); + return buffer; +} + +function readMailboxLayout( + buffer: SharedArrayBuffer, +): ForkExternrefImportMailboxLayout { + if ( + !(buffer instanceof SharedArrayBuffer) + || buffer.byteLength < HEADER_BYTES + || buffer.byteLength % Int32Array.BYTES_PER_ELEMENT !== 0 + ) { + throw new TypeError( + "fork externref import mailbox is not a complete shared header", + ); + } + const words = new Int32Array(buffer); + if ((words[HeaderWord.Magic]! >>> 0) !== MAILBOX_MAGIC) { + throw new Error("invalid fork externref import mailbox magic"); + } + if ( + (words[HeaderWord.MailboxVersion]! >>> 0) + !== FORK_EXTERNREF_IMPORT_MAILBOX_VERSION + ) { + throw new Error( + `unsupported fork externref import mailbox version ` + + `${words[HeaderWord.MailboxVersion]! >>> 0}`, + ); + } + const layout = mailboxLayout({ + params: words[HeaderWord.ParamCapacity]! >>> 0, + results: words[HeaderWord.ResultCapacity]! >>> 0, + }); + if (buffer.byteLength !== layout.byteLength) { + throw new TypeError( + `fork externref import mailbox has ${buffer.byteLength} bytes; ` + + `declared capacity requires exactly ${layout.byteLength}`, + ); + } + return layout; +} + +function validateMailboxHeader( + words: Int32Array, + layout: ForkExternrefImportMailboxLayout, +): void { + if ((words[HeaderWord.Magic]! >>> 0) !== MAILBOX_MAGIC) { + throw new Error("invalid fork externref import mailbox magic"); + } + if ( + (words[HeaderWord.MailboxVersion]! >>> 0) + !== FORK_EXTERNREF_IMPORT_MAILBOX_VERSION + ) { + throw new Error( + `unsupported fork externref import mailbox version ` + + `${words[HeaderWord.MailboxVersion]! >>> 0}`, + ); + } + if ( + (words[HeaderWord.ParamCapacity]! >>> 0) !== layout.params + || (words[HeaderWord.ResultCapacity]! >>> 0) !== layout.results + ) { + throw new Error("fork externref import mailbox capacity changed"); + } +} + +function writeU32(view: DataView, byteOffset: number, value: number): void { + view.setUint32(byteOffset, value >>> 0, true); +} + +function readU32(view: DataView, byteOffset: number): number { + return view.getUint32(byteOffset, true); +} + +function wordOffset(word: HeaderWord): number { + return word * Int32Array.BYTES_PER_ELEMENT; +} + +function writeHeaderU32( + view: DataView, + word: HeaderWord, + value: number, +): void { + writeU32(view, wordOffset(word), value); +} + +function readHeaderU32(view: DataView, word: HeaderWord): number { + return readU32(view, wordOffset(word)); +} + +function slotOffset(base: number, index: number): number { + return base + index * SLOT_BYTES; +} + +function writeScalarSlot( + view: DataView, + base: number, + index: number, + type: Exclude, + value: unknown, +): void { + const offset = slotOffset(base, index); + view.setBigUint64(offset, 0n, true); + switch (type) { + case "i32": + if ( + typeof value !== "number" + || !Number.isInteger(value) + || value < MIN_I32 + || value > MAX_I32 + ) { + throw new TypeError(`i32 value at slot ${index} is not signed i32`); + } + view.setInt32(offset, value, true); + return; + case "i64": + if ( + typeof value !== "bigint" + || value < MIN_I64 + || value > MAX_I64 + ) { + throw new TypeError(`i64 value at slot ${index} is not signed i64`); + } + view.setBigInt64(offset, value, true); + return; + case "f32": + if (typeof value !== "number") { + throw new TypeError(`f32 value at slot ${index} is not a number`); + } + view.setFloat32(offset, value, true); + return; + case "f64": + if (typeof value !== "number") { + throw new TypeError(`f64 value at slot ${index} is not a number`); + } + view.setFloat64(offset, value, true); + return; + } +} + +function readScalarSlot( + view: DataView, + base: number, + index: number, + type: Exclude, +): number | bigint { + const offset = slotOffset(base, index); + switch (type) { + case "i32": + return view.getInt32(offset, true); + case "i64": + return view.getBigInt64(offset, true); + case "f32": + return view.getFloat32(offset, true); + case "f64": + return view.getFloat64(offset, true); + } +} + +function nextSequence( + low: number, + high: number, +): { low: number; high: number } { + if (low === MAX_U32) { + if (high === MAX_U32) { + throw new RangeError( + "fork externref import mailbox sequence space exhausted", + ); + } + return { low: 0, high: high + 1 }; + } + return { low: low + 1, high }; +} + +function bindingEquals( + first: ForkExternrefImportBinding, + second: ForkExternrefImportBinding, +): boolean { + return first.pid === second.pid + && first.generationId === second.generationId + && first.senderId === second.senderId; +} + +function failureDescription(code: number): string { + const known = ForkExternrefImportFailureCode[ + code as ForkExternrefImportFailureCode + ]; + return known ?? `Unknown(${code})`; +} + +export class ForkExternrefImportRemoteFailure extends Error { + constructor( + readonly failureCode: number, + options?: ErrorOptions, + ) { + super( + `fork externref host import failed: ${failureDescription(failureCode)}`, + options, + ); + this.name = "ForkExternrefImportRemoteFailure"; + } +} + +export class ForkExternrefImportClosedError extends Error { + constructor(readonly reasonCode: number) { + super( + `fork externref host import mailbox is closed: ` + + `${failureDescription(reasonCode)}`, + ); + this.name = "ForkExternrefImportClosedError"; + } +} + +/** + * One synchronous caller per process or pthread Worker. + * + * The caller can bind imports from the main module and any side module to this + * same object. Its single atomic state rejects reentrancy instead of allowing + * two Wasm activations to overwrite one mailbox and deadlock each other. + */ +export class ForkExternrefImportWorkerCaller { + private readonly words: Int32Array; + private readonly view: DataView; + private readonly layout: ForkExternrefImportMailboxLayout; + private sequenceLow = 0; + private sequenceHigh = 0; + + constructor( + readonly mailbox: SharedArrayBuffer, + readonly binding: ForkExternrefImportBinding, + private readonly tokens: ForkExternrefTokenCache, + private readonly notifyOwner: (wake: ForkExternrefImportWake) => void, + ) { + this.layout = readMailboxLayout(mailbox); + validateBinding(binding); + if (tokens.generationId !== binding.generationId) { + throw new Error( + `fork externref token generation ${tokens.generationId} does not ` + + `match mailbox generation ${binding.generationId}`, + ); + } + this.words = new Int32Array(mailbox); + this.view = new DataView(mailbox); + validateMailboxHeader(this.words, this.layout); + } + + bind( + descriptor: ForkExternrefImportDescriptor, + ): (...args: ForkExternrefImportValue[]) => unknown { + this.validateDescriptor(descriptor); + return (...args: ForkExternrefImportValue[]) => + this.call(descriptor, args); + } + + call( + descriptor: ForkExternrefImportDescriptor, + args: readonly ForkExternrefImportValue[], + ): unknown { + this.validateDescriptor(descriptor); + if (args.length !== descriptor.params.length) { + throw new TypeError( + `fork externref import ${descriptor.ordinal} expects ` + + `${descriptor.params.length} arguments, received ${args.length}`, + ); + } + + const prior = Atomics.compareExchange( + this.words, + HeaderWord.Status, + MailboxStatus.Idle, + MailboxStatus.Writing, + ); + if (prior === MailboxStatus.Closed) throw this.closedError(); + if (prior !== MailboxStatus.Idle) { + throw new Error( + `reentrant fork externref host import while mailbox state=${prior}`, + ); + } + + try { + const sequence = nextSequence( + this.sequenceLow, + this.sequenceHigh, + ); + this.sequenceLow = sequence.low; + this.sequenceHigh = sequence.high; + this.writeRequest(descriptor, args, sequence); + + if ( + Atomics.compareExchange( + this.words, + HeaderWord.Status, + MailboxStatus.Writing, + MailboxStatus.RequestReady, + ) !== MailboxStatus.Writing + ) { + throw this.closedError(); + } + + const wake: ForkExternrefImportWake = Object.freeze({ + mailboxVersion: FORK_EXTERNREF_IMPORT_MAILBOX_VERSION, + pid: this.binding.pid, + generationId: this.binding.generationId, + senderId: this.binding.senderId, + sequenceLow: sequence.low, + sequenceHigh: sequence.high, + }); + try { + this.notifyOwner(wake); + } catch (error) { + const reset = Atomics.compareExchange( + this.words, + HeaderWord.Status, + MailboxStatus.RequestReady, + MailboxStatus.Idle, + ); + if (reset === MailboxStatus.RequestReady) { + throw new ForkExternrefImportRemoteFailure( + ForkExternrefImportFailureCode.NotificationFailure, + { cause: error }, + ); + } + // The owner already claimed the request. It owns completion now, so + // waiting is the only state-safe choice even if notification reported + // a local error after publishing the wake. + } + return this.waitForCompletion(descriptor); + } catch (error) { + Atomics.compareExchange( + this.words, + HeaderWord.Status, + MailboxStatus.Writing, + MailboxStatus.Idle, + ); + throw error; + } + } + + private writeRequest( + descriptor: ForkExternrefImportDescriptor, + args: readonly ForkExternrefImportValue[], + sequence: { low: number; high: number }, + ): void { + writeHeaderU32(this.view, HeaderWord.Pid, this.binding.pid); + writeHeaderU32( + this.view, + HeaderWord.Generation, + this.binding.generationId, + ); + writeHeaderU32(this.view, HeaderWord.Sender, this.binding.senderId); + writeHeaderU32(this.view, HeaderWord.SequenceLow, sequence.low); + writeHeaderU32(this.view, HeaderWord.SequenceHigh, sequence.high); + writeHeaderU32( + this.view, + HeaderWord.DescriptorVersion, + descriptor.version, + ); + writeHeaderU32(this.view, HeaderWord.Ordinal, descriptor.ordinal); + writeHeaderU32( + this.view, + HeaderWord.ParamCount, + descriptor.params.length, + ); + writeHeaderU32( + this.view, + HeaderWord.ResultCount, + descriptor.results.length, + ); + writeTypeSequence( + this.view, + this.layout.paramTypesOffset, + descriptor.params, + ); + writeTypeSequence( + this.view, + this.layout.resultTypesOffset, + descriptor.results, + ); + writeHeaderU32(this.view, HeaderWord.FailureCode, 0); + writeHeaderU32(this.view, HeaderWord.ExceptionHandle, 0); + + for (let index = 0; index < descriptor.params.length; index++) { + const type = descriptor.params[index]!; + const value = args[index]; + if (type === "externref") { + const handle = value === null ? 0 : this.tokens.encode(value); + if (handle === null) { + throw new Error( + `externref argument ${index} for import ${descriptor.ordinal} ` + + `did not come from this process-image owner`, + ); + } + this.view.setBigUint64( + slotOffset(this.layout.paramOffset, index), + BigInt(handle), + true, + ); + } else { + writeScalarSlot( + this.view, + this.layout.paramOffset, + index, + type, + value, + ); + } + } + } + + private waitForCompletion( + descriptor: ForkExternrefImportDescriptor, + ): unknown { + for (;;) { + const status = Atomics.load(this.words, HeaderWord.Status); + if ( + status === MailboxStatus.RequestReady + || status === MailboxStatus.Dispatching + ) { + Atomics.wait(this.words, HeaderWord.Status, status); + continue; + } + if (status === MailboxStatus.ResultReady) { + if (!this.takeCompletion(MailboxStatus.ResultReady)) continue; + return this.readResults(descriptor); + } + if (status === MailboxStatus.ExceptionReady) { + if (!this.takeCompletion(MailboxStatus.ExceptionReady)) continue; + const handle = readHeaderU32( + this.view, + HeaderWord.ExceptionHandle, + ); + throw handle === 0 ? null : this.tokens.materialize(handle); + } + if (status === MailboxStatus.Failed) { + const code = readHeaderU32(this.view, HeaderWord.FailureCode); + if (!this.takeCompletion(MailboxStatus.Failed)) continue; + throw new ForkExternrefImportRemoteFailure(code); + } + if (status === MailboxStatus.Closed) throw this.closedError(); + throw new Error( + `invalid fork externref import completion state ${status}`, + ); + } + } + + private takeCompletion(expected: MailboxStatus): boolean { + // Once the Worker returns the status to IDLE, only its own synchronous JS + // stack can start another request. Result slots therefore remain stable + // while this call decodes them, without an extra per-call buffer. + return Atomics.compareExchange( + this.words, + HeaderWord.Status, + expected, + MailboxStatus.Idle, + ) === expected; + } + + private readResults( + descriptor: ForkExternrefImportDescriptor, + ): unknown { + const results = descriptor.results.map((type, index) => { + if (type === "externref") { + const bits = this.view.getBigUint64( + slotOffset(this.layout.resultOffset, index), + true, + ); + if (bits > BigInt(MAX_U32)) { + throw new Error( + `invalid externref result handle ${bits} at slot ${index}`, + ); + } + const handle = Number(bits); + return handle === 0 ? null : this.tokens.materialize(handle); + } + return readScalarSlot( + this.view, + this.layout.resultOffset, + index, + type, + ); + }); + if (results.length === 0) return undefined; + if (results.length === 1) return results[0]; + return results; + } + + private closedError(): ForkExternrefImportClosedError { + return new ForkExternrefImportClosedError( + readHeaderU32(this.view, HeaderWord.CloseReason), + ); + } + + private validateDescriptor( + descriptor: ForkExternrefImportDescriptor, + ): void { + if ( + descriptor.version !== FORK_EXTERNREF_IMPORT_DESCRIPTOR_VERSION + ) { + throw new Error( + `unsupported fork externref import descriptor version ` + + `${descriptor.version}`, + ); + } + assertU32(descriptor.ordinal, "fork externref import ordinal", true); + validateTypes(descriptor.params, "parameter"); + validateTypes(descriptor.results, "result"); + if ( + descriptor.params.length > this.layout.params + || descriptor.results.length > this.layout.results + ) { + throw new RangeError( + `fork externref import ${descriptor.ordinal} signature ` + + `(${descriptor.params.length}, ${descriptor.results.length}) exceeds ` + + `mailbox capacity (${this.layout.params}, ${this.layout.results})`, + ); + } + } +} + +/** + * Immutable owner-realm descriptor catalog. The ordinal alone never selects a + * handler: every request must also match its complete packed type sequence. + */ +export class ForkExternrefImportOwnerCatalog { + private readonly handlers = new Map(); + private maxParams = 0; + private maxResults = 0; + + register( + descriptor: ForkExternrefImportDescriptor, + handler: ForkExternrefImportHandler, + ): void { + if (this.handlers.has(descriptor.ordinal)) { + throw new Error( + `duplicate fork externref import ordinal ${descriptor.ordinal}`, + ); + } + const canonical = defineForkExternrefImport( + descriptor.ordinal, + descriptor.params, + descriptor.results, + ); + if (descriptor.version !== canonical.version) { + throw new Error( + `unsupported fork externref import descriptor version ` + + `${descriptor.version}`, + ); + } + this.handlers.set(descriptor.ordinal, { + descriptor: canonical, + handler, + }); + this.maxParams = Math.max(this.maxParams, canonical.params.length); + this.maxResults = Math.max(this.maxResults, canonical.results.length); + } + + lookup(ordinal: number): RegisteredHandler | undefined { + return this.handlers.get(ordinal); + } + + get mailboxCapacity(): ForkExternrefImportMailboxCapacity { + return Object.freeze({ + params: this.maxParams, + results: this.maxResults, + }); + } +} + +/** + * Owner-side endpoint bound to one exact Worker mailbox. + * + * Entry-point message handlers pass the independently observed sender binding + * into dispatch(). That prevents a numeric sender copied from an untrusted + * wake message from authorizing itself. + */ +export class ForkExternrefImportOwnerEndpoint { + private readonly words: Int32Array; + private readonly view: DataView; + private readonly layout: ForkExternrefImportMailboxLayout; + + constructor( + readonly mailbox: SharedArrayBuffer, + readonly binding: ForkExternrefImportBinding, + private readonly catalog: ForkExternrefImportOwnerCatalog, + private readonly authority: ForkExternrefImportAuthority, + private readonly options: ForkExternrefImportOwnerEndpointOptions, + ) { + this.layout = readMailboxLayout(mailbox); + validateBinding(binding); + const required = catalog.mailboxCapacity; + if ( + required.params > this.layout.params + || required.results > this.layout.results + ) { + throw new RangeError( + `fork externref import catalog requires capacity ` + + `(${required.params}, ${required.results}); mailbox provides ` + + `(${this.layout.params}, ${this.layout.results})`, + ); + } + this.words = new Int32Array(mailbox); + this.view = new DataView(mailbox); + validateMailboxHeader(this.words, this.layout); + } + + /** + * Dispatch one ready request. False means the wake was stale, duplicated, or + * routed from a different Worker; in those cases this endpoint does not + * disturb a possibly newer live request. + */ + dispatch( + wake: ForkExternrefImportWake, + observedSender: ForkExternrefImportBinding, + ): boolean { + validateBinding(observedSender); + if ( + !bindingEquals(observedSender, this.binding) + || wake.mailboxVersion !== FORK_EXTERNREF_IMPORT_MAILBOX_VERSION + || wake.pid !== this.binding.pid + || wake.generationId !== this.binding.generationId + || wake.senderId !== this.binding.senderId + || wake.sequenceLow !== readHeaderU32( + this.view, + HeaderWord.SequenceLow, + ) + || wake.sequenceHigh !== readHeaderU32( + this.view, + HeaderWord.SequenceHigh, + ) + ) { + return false; + } + if ( + Atomics.compareExchange( + this.words, + HeaderWord.Status, + MailboxStatus.RequestReady, + MailboxStatus.Dispatching, + ) !== MailboxStatus.RequestReady + ) { + return false; + } + + let registered: RegisteredHandler; + try { + this.validateClaimedRequest(wake); + registered = this.requireRegisteredHandler(); + } catch (error) { + this.completeFailure( + ForkExternrefImportFailureCode.Protocol, + error, + ); + return true; + } + try { + this.options.authorizeSender(this.binding); + } catch (error) { + this.completeFailure( + ForkExternrefImportFailureCode.Unauthorized, + error, + ); + return true; + } + + let args: unknown[]; + try { + args = this.readArguments(registered.descriptor); + } catch (error) { + this.completeFailure( + ForkExternrefImportFailureCode.ArgumentAuthorization, + error, + ); + return true; + } + + let returned: unknown; + try { + returned = registered.handler( + { + ...this.binding, + descriptor: registered.descriptor, + }, + ...args, + ); + } catch (thrown) { + try { + // WHY: exception completion never uses the externref-null sentinel. + // JavaScript may throw null, undefined, or any other primitive; each + // still needs a nonzero owner handle so CatchAllRef cannot retain a + // raw Worker-local value that the fork recipe provider cannot encode. + const handle = this.authority.registerForWire( + this.binding.pid, + this.binding.generationId, + thrown, + ); + assertU32( + handle, + "fork externref exception handle", + ); + writeHeaderU32( + this.view, + HeaderWord.ExceptionHandle, + handle, + ); + this.complete(MailboxStatus.ExceptionReady); + } catch (error) { + this.completeFailure( + ForkExternrefImportFailureCode.OwnerFailure, + error, + ); + } + return true; + } + + try { + this.writeResults(registered.descriptor, returned); + this.complete(MailboxStatus.ResultReady); + } catch (error) { + this.completeFailure( + ForkExternrefImportFailureCode.HandlerContract, + error, + ); + } + return true; + } + + /** + * Close on exec, exit, Worker crash, or host destruction. Any blocked call is + * woken even if teardown races request publication or owner dispatch. + */ + close( + reason: ForkExternrefImportFailureCode = + ForkExternrefImportFailureCode.Teardown, + ): void { + // WHY: failure completion and teardown can race. Keep the terminal close + // reason in its own word so a losing dispatch cannot rewrite what wakes a + // blocked Worker after the owner has retired this process image. + writeHeaderU32(this.view, HeaderWord.CloseReason, reason); + Atomics.exchange( + this.words, + HeaderWord.Status, + MailboxStatus.Closed, + ); + Atomics.notify(this.words, HeaderWord.Status); + } + + private validateClaimedRequest(wake: ForkExternrefImportWake): void { + validateMailboxHeader(this.words, this.layout); + const requestBinding: ForkExternrefImportBinding = { + pid: readHeaderU32(this.view, HeaderWord.Pid), + generationId: readHeaderU32( + this.view, + HeaderWord.Generation, + ), + senderId: readHeaderU32(this.view, HeaderWord.Sender), + }; + if (!bindingEquals(requestBinding, this.binding)) { + throw new Error("fork externref mailbox request binding mismatch"); + } + if ( + readHeaderU32(this.view, HeaderWord.SequenceLow) + !== wake.sequenceLow + || readHeaderU32(this.view, HeaderWord.SequenceHigh) + !== wake.sequenceHigh + ) { + throw new Error("fork externref mailbox sequence changed after claim"); + } + } + + private requireRegisteredHandler(): RegisteredHandler { + const descriptorVersion = readHeaderU32( + this.view, + HeaderWord.DescriptorVersion, + ); + if ( + descriptorVersion !== FORK_EXTERNREF_IMPORT_DESCRIPTOR_VERSION + ) { + throw new Error( + `unsupported fork externref descriptor version ` + + `${descriptorVersion}`, + ); + } + const ordinal = readHeaderU32(this.view, HeaderWord.Ordinal); + const registered = this.catalog.lookup(ordinal); + if (!registered) { + throw new Error(`unknown fork externref import ordinal ${ordinal}`); + } + const descriptor = registered.descriptor; + const matches = + readHeaderU32(this.view, HeaderWord.ParamCount) + === descriptor.params.length + && readHeaderU32(this.view, HeaderWord.ResultCount) + === descriptor.results.length + && typeSequenceMatches( + this.view, + this.layout.paramTypesOffset, + descriptor.params, + ) + && typeSequenceMatches( + this.view, + this.layout.resultTypesOffset, + descriptor.results, + ); + if (!matches) { + throw new Error( + `fork externref import ${ordinal} signature mismatch`, + ); + } + return registered; + } + + private readArguments( + descriptor: ForkExternrefImportDescriptor, + ): unknown[] { + return descriptor.params.map((type, index) => { + if (type === "externref") { + const bits = this.view.getBigUint64( + slotOffset(this.layout.paramOffset, index), + true, + ); + if (bits > BigInt(MAX_U32)) { + throw new RangeError( + `externref argument handle ${bits} exceeds u32`, + ); + } + const handle = Number(bits); + return handle === 0 + ? null + : this.authority.authorizeForWire( + this.binding.pid, + this.binding.generationId, + handle, + ); + } + return readScalarSlot( + this.view, + this.layout.paramOffset, + index, + type, + ); + }); + } + + private writeResults( + descriptor: ForkExternrefImportDescriptor, + returned: unknown, + ): void { + let values: readonly unknown[]; + if (descriptor.results.length === 0) { + // Match the WebAssembly JS embedding: a return value from a void import + // is ignored. This also lets every host import use the exception- + // normalization path without imposing a new result-value policy. + values = []; + } else if (descriptor.results.length === 1) { + values = [returned]; + } else { + if ( + !Array.isArray(returned) + || returned.length !== descriptor.results.length + ) { + throw new TypeError( + `fork externref import ${descriptor.ordinal} must return ` + + `${descriptor.results.length} values`, + ); + } + values = returned; + } + + for (let index = 0; index < descriptor.results.length; index++) { + const type = descriptor.results[index]!; + const value = values[index]; + if (type === "externref") { + const handle = value === null + ? 0 + : this.authority.registerForWire( + this.binding.pid, + this.binding.generationId, + value, + ); + assertU32( + handle, + "fork externref result handle", + value === null, + ); + this.view.setBigUint64( + slotOffset(this.layout.resultOffset, index), + BigInt(handle), + true, + ); + } else { + writeScalarSlot( + this.view, + this.layout.resultOffset, + index, + type, + value, + ); + } + } + } + + private complete(status: MailboxStatus): void { + const previous = Atomics.compareExchange( + this.words, + HeaderWord.Status, + MailboxStatus.Dispatching, + status, + ); + // Teardown wins a race with dispatch. Never resurrect a closed mailbox or + // publish a result from an image whose generation was already retired. + if (previous === MailboxStatus.Dispatching) { + Atomics.notify(this.words, HeaderWord.Status); + } + } + + private completeFailure( + failure: ForkExternrefImportFailureCode, + error: unknown, + ): void { + try { + this.options.onDiagnostic?.(error, failure); + } catch { + // WHY: diagnostics are observational. A throwing logger must not leave + // the Worker asleep forever with the mailbox stuck in DISPATCHING. + } + writeHeaderU32(this.view, HeaderWord.FailureCode, failure); + this.complete(MailboxStatus.Failed); + } +} diff --git a/host/src/fork-externref-process-owner.ts b/host/src/fork-externref-process-owner.ts new file mode 100644 index 0000000000..5c564d3b0b --- /dev/null +++ b/host/src/fork-externref-process-owner.ts @@ -0,0 +1,234 @@ +import { + ForkExternrefBroker, + type ForkExternrefGeneration, +} from "./fork-reference-broker"; +import { + ForkModuleStateArena, + ForkModuleStateRecordKind, + readForkModuleStateRoot, +} from "./fork-module-state"; +import { + FORK_REFERENCE_TRANSACTION_OWNER_ID, +} from "./fork-reference-transaction"; +import { + scanSegmentedForkReferenceExternrefHandles, +} from "./fork-reference-segments"; +import { + unwrapForkWorkerExceptionCapability, +} from "./fork-worker-exception-capability"; + +export interface ForkExternrefForkGrant { + readonly generation: ForkExternrefGeneration; + readonly handleCount: number; +} + +/** + * Kernel-Worker owner for opaque host references across process lifetimes. + * + * Process and pthread Workers receive only `generation.id` plus Worker-local + * handle tokens. Real JavaScript values stay in this owner and are reached by + * host-import adapters through `registerForWire` / `authorizeForWire`. + * + * This is intentionally independent of activation-frame layout: fork leases + * are acquired from the process-wide reference-recipe record already copied + * through linear memory, so supporting an externref adds no bytes to each + * activation frame. + */ +export class ForkExternrefProcessOwner { + private readonly current = new Map(); + + constructor( + private readonly broker = new ForkExternrefBroker(), + ) {} + + /** Start a PID that does not already have a live Wasm image. */ + startGeneration(pid: number): ForkExternrefGeneration { + if (this.current.has(pid)) { + throw new Error(`externref process pid ${pid} already has a live generation`); + } + const generation = this.broker.createGeneration(pid); + this.current.set(pid, generation); + return generation; + } + + /** + * Replace one exact process image at exec's irreversible commit point. + * + * The broker retires the old token before returning the replacement, so an + * async callback from the discarded Worker cannot authorize a post-exec + * operation merely because the PID stayed the same. + */ + replaceGeneration( + expected: ForkExternrefGeneration, + ): ForkExternrefGeneration { + this.requireCurrent(expected); + const replacement = this.broker.createGeneration(expected.pid); + this.current.set(expected.pid, replacement); + return replacement; + } + + /** + * Grant a fresh fork child the unique externref handles named by the exact + * sealed continuation it will replay. + */ + forkGenerationFromContinuation( + parent: ForkExternrefGeneration, + childPid: number, + memory: WebAssembly.Memory, + ptrWidth: 4 | 8, + moduleBufferAddress: number, + label = `fork child pid=${childPid}: externref owner`, + ): ForkExternrefForkGrant { + this.requireCurrent(parent); + if (this.current.has(childPid)) { + throw new Error( + `externref fork child pid ${childPid} already has a live generation`, + ); + } + + const root = readForkModuleStateRoot( + memory, + moduleBufferAddress, + ptrWidth, + ); + if (root === 0) { + throw new Error(`${label}: copied continuation has no module-state arena`); + } + + // Inspect only the KFRV payload. The scanner validates the sealed chunk + // chain and every record envelope without copying unrelated table pages; + // the fresh child performs the full semantic arena validation before + // execution. Its allocation callbacks remain deliberately impossible: + // the arena belongs to the blocked parent and cannot be mutated here. + const arena = new ForkModuleStateArena( + memory, + ptrWidth, + () => { + throw new Error(`${label}: read-only arena attempted allocation`); + }, + () => { + throw new Error(`${label}: read-only arena attempted release`); + }, + `${label}: copied module state`, + ); + const records = arena.inspectSealedRecordViews( + root, + [ + ForkModuleStateRecordKind.ReferenceRecipeSegment, + ForkModuleStateRecordKind.ReferenceRecipe, + ], + ); + const handles = scanSegmentedForkReferenceExternrefHandles( + records, + FORK_REFERENCE_TRANSACTION_OWNER_ID, + ); + + const child = this.broker.createGeneration(childPid); + try { + const lease = this.broker.acquireFork(parent, child, handles); + this.current.set(childPid, child); + return Object.freeze({ + generation: child, + handleCount: lease.handleCount, + }); + } catch (error) { + // `acquireFork` is transactional, and retiring the provisional + // generation also clears any lease bookkeeping if a future broker + // implementation adds a fallible step after publication. + this.broker.releaseGeneration(child); + throw error; + } + } + + /** + * Release an exact process image after its process and pthread Workers can no + * longer execute. Returns false for already-retired generations. + */ + releaseGeneration(generation: ForkExternrefGeneration): boolean { + const current = this.current.get(generation.pid); + if (current === generation) this.current.delete(generation.pid); + return this.broker.releaseGeneration(generation); + } + + generationId(generation: ForkExternrefGeneration): number { + this.requireCurrent(generation); + return generation.id; + } + + /** + * Owner-side endpoint for an externref-producing host import. + * + * The adapter executes in this Realm, registers the real value here, and + * returns only the u32 handle to the process Worker. + */ + registerForWire( + pid: number, + generationId: number, + value: unknown, + ): number { + return this.broker.register( + this.requireWireGeneration(pid, generationId), + value, + ); + } + + /** Resolve an externref-consuming host import under exact image authority. */ + authorizeForWire( + pid: number, + generationId: number, + handle: number, + ): unknown { + return unwrapForkWorkerExceptionCapability( + this.broker.authorize( + this.requireWireGeneration(pid, generationId), + handle, + ), + ); + } + + /** Permanently close a host resource and invalidate all fork aliases. */ + tombstoneForWire( + pid: number, + generationId: number, + handle: number, + ): void { + this.broker.tombstone( + this.requireWireGeneration(pid, generationId), + handle, + ); + } + + private requireCurrent( + generation: ForkExternrefGeneration, + ): ForkExternrefGeneration { + if (this.current.get(generation.pid) !== generation) { + throw new Error( + `stale externref process generation ${generation.id} ` + + `for pid ${generation.pid}`, + ); + } + return generation; + } + + private requireWireGeneration( + pid: number, + generationId: number, + ): ForkExternrefGeneration { + if ( + !Number.isInteger(generationId) + || generationId <= 0 + || generationId > 0xffff_ffff + ) { + throw new RangeError( + `invalid externref process generation id ${generationId}`, + ); + } + const generation = this.current.get(pid); + if (!generation || generation.id !== generationId) { + throw new Error( + `stale externref process generation ${generationId} for pid ${pid}`, + ); + } + return generation; + } +} diff --git a/host/src/fork-function-catalog.ts b/host/src/fork-function-catalog.ts new file mode 100644 index 0000000000..31efeea36e --- /dev/null +++ b/host/src/fork-function-catalog.ts @@ -0,0 +1,120 @@ +/** + * Deterministic fresh-instance recipes for Wasm function references. + * + * A WebAssembly function object belongs to one JS Agent and cannot be moved to + * a fork child's Worker. Instrumented modules therefore export an immutable + * catalog table containing every function that can become a reference. The + * parent records `(module activation, catalog ordinal)`; after main/side + * modules are instantiated in the child, the same pair resolves to that + * instance's fresh function object. + */ + +export const FORK_FUNCTION_CATALOG_EXPORT = "__wpk_fork_function_catalog"; + +export interface ForkFunctionRecipe { + readonly moduleActivation: number; + readonly ordinal: number; +} + +interface RegisteredCatalog { + table: WebAssembly.Table; + entries: Array; +} + +function assertU32(value: number, label: string): void { + if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff) { + throw new RangeError(`invalid ${label} ${value}`); + } +} + +function recipeKey(moduleActivation: number, ordinal: number): string { + return `${moduleActivation}:${ordinal}`; +} + +export class ForkFunctionCatalog { + private readonly catalogs = new Map(); + private recipesByFunction = + new WeakMap(); + + register(moduleActivation: number, table: WebAssembly.Table): void { + assertU32(moduleActivation, "module activation"); + if (this.catalogs.has(moduleActivation)) { + throw new Error(`function catalog ${moduleActivation} is already registered`); + } + const entries: Array = []; + for (let ordinal = 0; ordinal < table.length; ordinal++) { + const value = table.get(ordinal); + if (typeof value !== "function") { + throw new Error( + `function catalog ${moduleActivation} has non-function entry ${ordinal}`, + ); + } + entries.push(value); + const recipes = this.recipesByFunction.get(value) ?? []; + if (!recipes.some((recipe) => + recipe.moduleActivation === moduleActivation + && recipe.ordinal === ordinal + )) { + recipes.push({ moduleActivation, ordinal }); + recipes.sort( + (left, right) => + left.moduleActivation - right.moduleActivation + || left.ordinal - right.ordinal, + ); + this.recipesByFunction.set(value, recipes); + } + } + this.catalogs.set(moduleActivation, { table, entries }); + } + + unregister(moduleActivation: number): void { + assertU32(moduleActivation, "module activation"); + const catalog = this.catalogs.get(moduleActivation); + if (!catalog) { + throw new Error(`function catalog ${moduleActivation} is not registered`); + } + for (const value of new Set(catalog.entries)) { + const remaining = (this.recipesByFunction.get(value) ?? []) + .filter((recipe) => recipe.moduleActivation !== moduleActivation); + this.recipesByFunction.set(value, remaining); + } + this.catalogs.delete(moduleActivation); + } + + encode(value: unknown): ForkFunctionRecipe | null { + if (value === null) return null; + if (typeof value !== "function") { + throw new TypeError("funcref encoder received a non-function value"); + } + const recipe = this.recipesByFunction.get(value)?.[0]; + if (!recipe) { + throw new Error("funcref is absent from the process module catalogs"); + } + return recipe; + } + + decode(recipe: ForkFunctionRecipe | null): CallableFunction | null { + if (recipe === null) return null; + assertU32(recipe.moduleActivation, "module activation"); + assertU32(recipe.ordinal, "function ordinal"); + const catalog = this.catalogs.get(recipe.moduleActivation); + if (!catalog) { + throw new Error(`function catalog ${recipe.moduleActivation} is not registered`); + } + const value = catalog.entries[recipe.ordinal]; + if (!value) { + throw new Error( + `function recipe ${recipeKey(recipe.moduleActivation, recipe.ordinal)} is out of bounds`, + ); + } + return value; + } + + clear(): void { + // WeakMap entries disappear with their function objects. Catalog entries + // are the only strong roots owned here and must not outlive replay/abort. + this.catalogs.clear(); + this.recipesByFunction = + new WeakMap(); + } +} diff --git a/host/src/fork-gc-codec.ts b/host/src/fork-gc-codec.ts new file mode 100644 index 0000000000..7b6a4c7ae3 --- /dev/null +++ b/host/src/fork-gc-codec.ts @@ -0,0 +1,905 @@ +import { + WPK_FORK_GC_CODEC_FIELD_RECORD_SIZE, + WPK_FORK_GC_CODEC_HEADER_SIZE, + WPK_FORK_GC_CODEC_LAYOUT_RECORD_SIZE, + WPK_FORK_GC_CODEC_MAGIC, + WPK_FORK_GC_CODEC_SECTION, + WPK_FORK_GC_CODEC_VERSION, + WPK_FORK_REFERENCE_EXPORT_GC_ALLOCATE, + WPK_FORK_REFERENCE_EXPORT_GC_ENCODE_SLOT, + WPK_FORK_REFERENCE_EXPORT_GC_FILL, + WPK_FORK_REFERENCE_EXPORT_GC_PUBLISH_EXTERNREF, + WPK_FORK_REFERENCE_EXPORT_GC_PROBE, +} from "./generated/abi"; + +export const enum ForkGcLayoutKind { + Struct = 1, + Array = 2, +} + +export const enum ForkGcConstructorKind { + Struct = 0, + ArrayGeneric = 1, + ArrayNew = 2, + ArrayDefault = 3, + ArrayFixed = 4, + ArrayData = 5, + ArrayElement = 6, +} + +export const FORK_GC_LAYOUT_REQUIRES_PROVENANCE = 1 << 0; +export const FORK_GC_LAYOUT_DEFAULTABLE_SHELL = 1 << 1; +const FORK_GC_LAYOUT_KNOWN_FLAGS = + FORK_GC_LAYOUT_REQUIRES_PROVENANCE + | FORK_GC_LAYOUT_DEFAULTABLE_SHELL; + +export const FORK_GC_FIELD_MUTABLE = 1 << 0; +export const FORK_GC_FIELD_NULLABLE = 1 << 1; +export const FORK_GC_FIELD_REFERENCE = 1 << 2; +export const FORK_GC_FIELD_ALLOCATION_DEPENDENCY = 1 << 3; +const FORK_GC_FIELD_KNOWN_FLAGS = + FORK_GC_FIELD_MUTABLE + | FORK_GC_FIELD_NULLABLE + | FORK_GC_FIELD_REFERENCE + | FORK_GC_FIELD_ALLOCATION_DEPENDENCY; + +const NO_ORDINAL = 0xffff_ffff; +const MAX_RECIPE_ID = 0x7fff_fffe; + +export interface ForkGcFieldDescriptor { + readonly storage: number; + readonly flags: number; + readonly scalarOffset: number | null; + readonly referenceOrdinal: number | null; +} + +export interface ForkGcLayoutDescriptor { + readonly id: number; + readonly typeOrdinal: number; + readonly kind: ForkGcLayoutKind; + readonly constructor: ForkGcConstructorKind; + readonly flags: number; + readonly scalarLengthOrStride: number; + readonly fields: readonly ForkGcFieldDescriptor[]; + readonly superTypeOrdinal: number | null; + readonly baseLayoutId: number; + readonly auxiliary: number; + readonly provenanceScalarLength: number; + readonly provenanceReferenceCount: number; +} + +export interface ForkGcCodecProvider { + readonly activationId: number; + readonly descriptor: ForkGcCodecDescriptor; + /** Probe the object in a shared transit slot without crossing `anyref`. */ + probe(slot: number): bigint; + /** Encode the object in a shared transit slot into the active recipe graph. */ + encodeSlot(slot: number): number; + /** Allocate one routed aggregate/i31 recipe into `recipe + 1`. */ + allocate(recipeId: number): void; + /** Restore mutable aggregate fields after every shell has been allocated. */ + fill(recipeId: number): void; + /** + * Convert one process-owned token inside Wasm and publish it at recipe+1. + * + * JavaScript cannot directly create an anyref value for the shared transit + * table, so this remains an activation-local scalar/externref entry point. + */ + publishExternref(recipeId: number, value: unknown): void; +} + +function assertU31(value: number, context: string): void { + if (!Number.isInteger(value) || value <= 0 || value > 0x7fff_ffff) { + throw new Error(`${context} is not a nonzero u31`); + } +} + +function assertU32(value: number, context: string): void { + if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff) { + throw new Error(`${context} is not a u32`); + } +} + +function checkedProduct(left: number, right: number, context: string): number { + const result = left * right; + if (!Number.isSafeInteger(result) || result > 0xffff_ffff) { + throw new Error(`${context} exceeds the u32 format`); + } + return result; +} + +function storageByteLength(storage: number): number { + switch (storage) { + case 1: + return 1; + case 2: + return 2; + case 3: + case 5: + return 4; + case 4: + case 6: + return 8; + case 7: + return 16; + case 8: + return 4; + default: + throw new Error(`unsupported GC storage code ${storage}`); + } +} + +function requireFunction( + exports: WebAssembly.Exports, + name: string, +): CallableFunction { + const value = exports[name]; + if (typeof value !== "function") { + throw new Error(`fork GC codec is missing function export ${name}`); + } + return value as CallableFunction; +} + +function assertSlot(value: number, context: string): void { + assertU32(value, context); + if (value > 0x7fff_ffff) { + throw new RangeError(`${context} is not a routable table slot`); + } +} + +function assertRecipeId(value: number): void { + assertU31(value, "GC recipe id"); + if (value > MAX_RECIPE_ID) { + throw new RangeError(`GC recipe id ${value} is reserved`); + } +} + +/** + * Validated, activation-local structural type evidence. + * + * A host callback may select only a constructor record whose `baseLayoutId` + * points at the exact base record supplied by generated Wasm. This prevents a + * stale or malicious provenance map from changing the concrete replay helper. + */ +export class ForkGcCodecDescriptor { + private readonly byId = new Map(); + private readonly baseByType = new Map(); + + constructor(readonly layouts: readonly ForkGcLayoutDescriptor[]) { + layouts.forEach((layout, index) => { + if (layout.id !== index + 1 || this.byId.has(layout.id)) { + throw new Error( + `GC codec layout ${layout.id} is not in canonical id order`, + ); + } + this.byId.set(layout.id, layout); + if (layout.baseLayoutId === layout.id) { + if (this.baseByType.has(layout.typeOrdinal)) { + throw new Error( + `GC type ordinal ${layout.typeOrdinal} has multiple base layouts`, + ); + } + this.baseByType.set(layout.typeOrdinal, layout); + } + }); + for (const layout of layouts) { + const base = this.byId.get(layout.baseLayoutId); + if ( + !base + || base.baseLayoutId !== base.id + || base.typeOrdinal !== layout.typeOrdinal + || base.kind !== layout.kind + || ( + layout.id !== base.id + && layout.constructor === ForkGcConstructorKind.ArrayGeneric + ) + ) { + throw new Error( + `GC codec layout ${layout.id} has invalid base layout ` + + `${layout.baseLayoutId}`, + ); + } + if ( + layout.kind === ForkGcLayoutKind.Struct + ? layout.constructor !== ForkGcConstructorKind.Struct + : layout.fields.length !== 1 + || ( + layout.id === base.id + ? layout.constructor !== ForkGcConstructorKind.ArrayGeneric + : layout.constructor === ForkGcConstructorKind.ArrayGeneric + ) + ) { + throw new Error(`GC codec layout ${layout.id} has an invalid constructor`); + } + if ( + layout.id !== base.id + && ( + layout.scalarLengthOrStride !== base.scalarLengthOrStride + || layout.superTypeOrdinal !== base.superTypeOrdinal + || layout.flags + !== ( + base.flags + | FORK_GC_LAYOUT_REQUIRES_PROVENANCE + ) + || !sameFields(layout.fields, base.fields) + ) + ) { + throw new Error( + `GC constructor layout ${layout.id} does not match base ` + + `${base.id}`, + ); + } + if ( + layout.provenanceScalarLength > 16 + || ( + (layout.flags & FORK_GC_LAYOUT_REQUIRES_PROVENANCE) === 0 + && ( + layout.provenanceScalarLength !== 0 + || layout.provenanceReferenceCount !== 0 + ) + ) + ) { + throw new Error(`GC codec layout ${layout.id} has invalid provenance`); + } + validateLayoutPayload(layout); + } + } + + require(layoutId: number): ForkGcLayoutDescriptor { + assertU31(layoutId, "GC layout id"); + const layout = this.byId.get(layoutId); + if (!layout) throw new Error(`unknown GC layout ${layoutId}`); + return layout; + } + + requireCaptureLayout( + baseLayoutId: number, + specializedLayoutId: number, + ): ForkGcLayoutDescriptor { + const base = this.require(baseLayoutId); + const selected = this.require(specializedLayoutId); + if ( + base.baseLayoutId !== base.id + || selected.baseLayoutId !== base.id + || selected.typeOrdinal !== base.typeOrdinal + || selected.kind !== base.kind + || ( + (base.flags & FORK_GC_LAYOUT_REQUIRES_PROVENANCE) !== 0 + && selected.id === base.id + && selected.kind === ForkGcLayoutKind.Array + ) + ) { + throw new Error( + `GC constructor layout ${specializedLayoutId} does not belong to ` + + `base layout ${baseLayoutId}`, + ); + } + return selected; + } +} + +function sameFields( + left: readonly ForkGcFieldDescriptor[], + right: readonly ForkGcFieldDescriptor[], +): boolean { + return left.length === right.length && left.every((field, index) => { + const other = right[index]!; + return field.storage === other.storage + && field.flags === other.flags + && field.scalarOffset === other.scalarOffset + && field.referenceOrdinal === other.referenceOrdinal; + }); +} + +function validateLayoutPayload(layout: ForkGcLayoutDescriptor): void { + let expectedReferenceOrdinal = 0; + let minimumScalarLength = 0; + for (const [index, field] of layout.fields.entries()) { + const isReference = (field.flags & FORK_GC_FIELD_REFERENCE) !== 0; + const isMutable = (field.flags & FORK_GC_FIELD_MUTABLE) !== 0; + const isNullable = (field.flags & FORK_GC_FIELD_NULLABLE) !== 0; + const isDependency = + (field.flags & FORK_GC_FIELD_ALLOCATION_DEPENDENCY) !== 0; + if ( + isReference !== (field.storage === 8) + || (!isReference && (isNullable || isDependency)) + || (isDependency && isMutable) + ) { + throw new Error( + `GC layout ${layout.id} field ${index} has inconsistent flags`, + ); + } + if (isReference) { + if (field.referenceOrdinal !== expectedReferenceOrdinal) { + throw new Error( + `GC layout ${layout.id} field ${index} has noncanonical ` + + `reference ordinal`, + ); + } + expectedReferenceOrdinal++; + continue; + } + const scalarOffset = field.scalarOffset!; + const end = scalarOffset + storageByteLength(field.storage); + if ( + !Number.isSafeInteger(end) + || end > 0xffff_ffff + || scalarOffset < minimumScalarLength + ) { + throw new Error( + `GC layout ${layout.id} field ${index} has an invalid scalar offset`, + ); + } + minimumScalarLength = end; + } + if ( + layout.kind === ForkGcLayoutKind.Struct + && minimumScalarLength > layout.scalarLengthOrStride + ) { + throw new Error(`GC struct layout ${layout.id} scalar fields overflow`); + } + if ( + layout.kind === ForkGcLayoutKind.Array + && layout.fields[0]!.storage !== 8 + && layout.scalarLengthOrStride + !== storageByteLength(layout.fields[0]!.storage) + ) { + throw new Error(`GC array layout ${layout.id} has an invalid stride`); + } + + switch (layout.constructor) { + case ForkGcConstructorKind.Struct: + if (layout.provenanceScalarLength !== 0) { + throw new Error( + `GC struct layout ${layout.id} has unexpected scalar provenance`, + ); + } + break; + case ForkGcConstructorKind.ArrayGeneric: + case ForkGcConstructorKind.ArrayDefault: + if ( + layout.provenanceScalarLength !== 0 + || layout.provenanceReferenceCount !== 0 + ) { + throw new Error( + `GC layout ${layout.id} has unexpected constructor provenance`, + ); + } + break; + case ForkGcConstructorKind.ArrayFixed: + if ( + layout.provenanceScalarLength !== 0 + || ( + layout.provenanceReferenceCount !== 0 + && layout.provenanceReferenceCount !== layout.auxiliary + ) + ) { + throw new Error( + `GC array.new_fixed layout ${layout.id} is malformed`, + ); + } + break; + case ForkGcConstructorKind.ArrayNew: + if ( + layout.provenanceReferenceCount > 1 + || ( + layout.provenanceReferenceCount !== 0 + && layout.provenanceScalarLength !== 0 + ) + ) { + throw new Error(`GC array.new layout ${layout.id} is malformed`); + } + break; + case ForkGcConstructorKind.ArrayData: + if ( + layout.fields[0]!.storage === 8 + || layout.provenanceScalarLength !== 8 + || layout.provenanceReferenceCount !== 0 + ) { + throw new Error(`GC array.new_data layout ${layout.id} is malformed`); + } + break; + case ForkGcConstructorKind.ArrayElement: + if ( + layout.fields[0]!.storage !== 8 + || layout.provenanceScalarLength !== 8 + || layout.provenanceReferenceCount !== 0 + ) { + throw new Error(`GC array.new_elem layout ${layout.id} is malformed`); + } + break; + } +} + +export function decodeForkGcCodecDescriptor( + bytes: Uint8Array, +): ForkGcCodecDescriptor { + if (bytes.byteLength < WPK_FORK_GC_CODEC_HEADER_SIZE) { + throw new Error("GC codec descriptor is truncated"); + } + if ( + WPK_FORK_GC_CODEC_MAGIC.some((byte, index) => bytes[index] !== byte) + ) { + throw new Error("GC codec descriptor has an invalid magic"); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if ( + view.getUint16(4, true) !== WPK_FORK_GC_CODEC_VERSION + || view.getUint16(6, true) !== WPK_FORK_GC_CODEC_HEADER_SIZE + ) { + throw new Error("GC codec descriptor has an unsupported version/header"); + } + const layoutCount = view.getUint32(8, true); + const fieldCount = view.getUint32(12, true); + const layoutsLength = checkedProduct( + layoutCount, + WPK_FORK_GC_CODEC_LAYOUT_RECORD_SIZE, + "GC layout catalog", + ); + const fieldsLength = checkedProduct( + fieldCount, + WPK_FORK_GC_CODEC_FIELD_RECORD_SIZE, + "GC field catalog", + ); + const expectedLength = + WPK_FORK_GC_CODEC_HEADER_SIZE + layoutsLength + fieldsLength; + if (expectedLength !== bytes.byteLength) { + throw new Error( + `GC codec descriptor has ${bytes.byteLength} bytes; ` + + `expected ${expectedLength}`, + ); + } + + const rawLayouts: Array<{ + id: number; + typeOrdinal: number; + kind: ForkGcLayoutKind; + constructor: ForkGcConstructorKind; + flags: number; + scalarLengthOrStride: number; + fieldStart: number; + fieldCount: number; + superTypeOrdinal: number | null; + baseLayoutId: number; + auxiliary: number; + provenanceScalarLength: number; + provenanceReferenceCount: number; + }> = []; + let expectedFieldStart = 0; + for (let index = 0; index < layoutCount; index++) { + const offset = + WPK_FORK_GC_CODEC_HEADER_SIZE + + index * WPK_FORK_GC_CODEC_LAYOUT_RECORD_SIZE; + const id = view.getUint32(offset, true); + assertU31(id, `GC layout ${index} id`); + const kind = view.getUint8(offset + 8); + const constructor = view.getUint8(offset + 9); + const flags = view.getUint16(offset + 10, true); + const fieldStart = view.getUint32(offset + 16, true); + const layoutFieldCount = view.getUint32(offset + 20, true); + if ( + (kind !== ForkGcLayoutKind.Struct && kind !== ForkGcLayoutKind.Array) + || constructor > ForkGcConstructorKind.ArrayElement + || (flags & ~FORK_GC_LAYOUT_KNOWN_FLAGS) !== 0 + || fieldStart !== expectedFieldStart + || layoutFieldCount > fieldCount - Math.min(fieldStart, fieldCount) + ) { + throw new Error(`GC layout ${id} has unsupported kind/flags`); + } + expectedFieldStart += layoutFieldCount; + rawLayouts.push({ + id, + typeOrdinal: view.getUint32(offset + 4, true), + kind, + constructor, + flags, + scalarLengthOrStride: view.getUint32(offset + 12, true), + fieldStart, + fieldCount: layoutFieldCount, + superTypeOrdinal: + view.getUint32(offset + 24, true) === NO_ORDINAL + ? null + : view.getUint32(offset + 24, true), + baseLayoutId: view.getUint32(offset + 28, true), + auxiliary: view.getUint32(offset + 32, true), + provenanceScalarLength: view.getUint32(offset + 36, true), + provenanceReferenceCount: view.getUint32(offset + 40, true), + }); + } + if (expectedFieldStart !== fieldCount) { + throw new Error("GC codec descriptor has unowned field records"); + } + + const fields: ForkGcFieldDescriptor[] = []; + const fieldsOffset = WPK_FORK_GC_CODEC_HEADER_SIZE + layoutsLength; + for (let index = 0; index < fieldCount; index++) { + const offset = fieldsOffset + index * WPK_FORK_GC_CODEC_FIELD_RECORD_SIZE; + const storage = view.getUint8(offset); + const flags = view.getUint8(offset + 1); + const reserved = view.getUint16(offset + 2, true); + const scalarOffset = view.getUint32(offset + 4, true); + const referenceOrdinal = view.getUint32(offset + 8, true); + if ( + storage < 1 + || storage > 8 + || (flags & ~FORK_GC_FIELD_KNOWN_FLAGS) !== 0 + || reserved !== 0 + || ( + (flags & FORK_GC_FIELD_REFERENCE) !== 0 + && (scalarOffset !== NO_ORDINAL || referenceOrdinal === NO_ORDINAL) + ) + || ( + (flags & FORK_GC_FIELD_REFERENCE) === 0 + && (scalarOffset === NO_ORDINAL || referenceOrdinal !== NO_ORDINAL) + ) + ) { + throw new Error(`GC field ${index} is malformed`); + } + fields.push({ + storage, + flags, + scalarOffset: scalarOffset === NO_ORDINAL ? null : scalarOffset, + referenceOrdinal: + referenceOrdinal === NO_ORDINAL ? null : referenceOrdinal, + }); + } + + const layouts = rawLayouts.map((layout) => { + if ( + layout.fieldStart > fieldCount + || layout.fieldCount > fieldCount - layout.fieldStart + ) { + throw new Error(`GC layout ${layout.id} field range is out of bounds`); + } + const selectedFields = fields.slice( + layout.fieldStart, + layout.fieldStart + layout.fieldCount, + ); + if ( + (layout.kind === ForkGcLayoutKind.Array && selectedFields.length !== 1) + || (layout.kind === ForkGcLayoutKind.Struct + && layout.constructor !== ForkGcConstructorKind.Struct) + ) { + throw new Error(`GC layout ${layout.id} has inconsistent shape`); + } + return { + ...layout, + fields: selectedFields, + }; + }); + return new ForkGcCodecDescriptor(layouts); +} + +export function readForkGcCodecDescriptor( + module: WebAssembly.Module, +): ForkGcCodecDescriptor { + const sections = WebAssembly.Module.customSections( + module, + WPK_FORK_GC_CODEC_SECTION, + ); + if (sections.length !== 1) { + throw new Error( + `expected one ${WPK_FORK_GC_CODEC_SECTION} section, ` + + `found ${sections.length}`, + ); + } + return decodeForkGcCodecDescriptor(new Uint8Array(sections[0]!)); +} + +/** + * Bind the four scalar-callable entry points generated for one activation. + * + * No method accepts or returns `anyref`; values move only through the + * process-owned transit table imported by both the parent and fresh child. + */ +export function forkGcCodecProviderFromInstance( + activationId: number, + module: WebAssembly.Module, + instance: WebAssembly.Instance, +): ForkGcCodecProvider { + assertU32(activationId, "GC codec activation"); + const probe = requireFunction( + instance.exports, + WPK_FORK_REFERENCE_EXPORT_GC_PROBE, + ); + const encodeSlot = requireFunction( + instance.exports, + WPK_FORK_REFERENCE_EXPORT_GC_ENCODE_SLOT, + ); + const allocate = requireFunction( + instance.exports, + WPK_FORK_REFERENCE_EXPORT_GC_ALLOCATE, + ); + const fill = requireFunction( + instance.exports, + WPK_FORK_REFERENCE_EXPORT_GC_FILL, + ); + const publishExternref = requireFunction( + instance.exports, + WPK_FORK_REFERENCE_EXPORT_GC_PUBLISH_EXTERNREF, + ); + return { + activationId, + descriptor: readForkGcCodecDescriptor(module), + probe(slot): bigint { + assertSlot(slot, "GC probe slot"); + const packed = probe(slot); + if (typeof packed !== "bigint") { + throw new TypeError("GC probe did not return an i64"); + } + return BigInt.asUintN(64, packed); + }, + encodeSlot(slot): number { + assertSlot(slot, "GC encode slot"); + const recipeId = Number(encodeSlot(slot)); + assertRecipeId(recipeId); + return recipeId; + }, + allocate(recipeId): void { + assertRecipeId(recipeId); + if (recipeId === 0) { + throw new RangeError("the null recipe cannot be allocated"); + } + allocate(recipeId); + }, + fill(recipeId): void { + assertRecipeId(recipeId); + if (recipeId === 0) { + throw new RangeError("the null recipe cannot be filled"); + } + fill(recipeId); + }, + publishExternref(recipeId, value): void { + assertRecipeId(recipeId); + if (recipeId === 0) { + throw new RangeError("the null recipe cannot publish an externref"); + } + publishExternref(recipeId, value); + }, + }; +} + +export interface ForkGcConstructorProvenance { + readonly activationId: number; + readonly baseLayoutId: number; + readonly layoutId: number; + readonly scalars: Uint8Array; + readonly references: readonly (object | null)[]; +} + +interface PendingProvenance { + readonly token: number; + readonly object: object; + readonly activationId: number; + readonly baseLayoutId: number; + readonly layout: ForkGcLayoutDescriptor; + readonly scalars: Uint8Array; + readonly references: (object | null)[]; +} + +/** + * Weak-keyed constructor evidence for non-shell GC objects. + * + * The registry owns a key strongly only between `begin` and `end`; finalized + * records are ephemerons and disappear with their Wasm wrapper. `abortPending` + * is called at every transaction/activation teardown so a trapping wrapper + * cannot leave a hidden strong root. + */ +export class ForkGcProvenanceRegistry { + private finalized = new WeakMap(); + private pending: PendingProvenance | null = null; + private nextToken = 1; + + begin( + table: WebAssembly.Table, + descriptor: ForkGcCodecDescriptor, + expectedActivationId: number, + slot: number, + activationId: number, + baseLayoutId: number, + specializedLayoutId: number, + scalarLo: bigint, + scalarHi: bigint, + referenceCount: number, + ): number { + try { + if (this.pending) { + throw new Error( + `GC provenance registration ${this.pending.token} is still pending`, + ); + } + assertU32(expectedActivationId, "expected GC activation"); + assertU32(activationId, "GC provenance activation"); + assertU32(slot, "GC provenance slot"); + assertU32(referenceCount, "GC provenance reference count"); + if (activationId !== expectedActivationId) { + throw new Error( + `activation ${expectedActivationId} cannot register GC provenance ` + + `for activation ${activationId}`, + ); + } + if (slot >= table.length) { + throw new Error(`GC provenance slot ${slot} is out of bounds`); + } + const object = table.get(slot); + if ( + (typeof object !== "object" || object === null) + && typeof object !== "function" + ) { + throw new Error("GC provenance source is not a non-null Wasm object"); + } + const layout = descriptor.requireCaptureLayout( + baseLayoutId, + specializedLayoutId, + ); + if (layout.provenanceReferenceCount !== referenceCount) { + throw new Error( + `GC layout ${layout.id} expects ` + + `${layout.provenanceReferenceCount} provenance references, ` + + `found ${referenceCount}`, + ); + } + const scalarBytes = new Uint8Array(16); + const scalarView = new DataView(scalarBytes.buffer); + scalarView.setBigUint64(0, BigInt.asUintN(64, scalarLo), true); + scalarView.setBigUint64(8, BigInt.asUintN(64, scalarHi), true); + const token = this.nextToken++; + if (!Number.isSafeInteger(token) || token > 0x7fff_ffff) { + this.nextToken = 1; + throw new Error("GC provenance token space exhausted"); + } + this.pending = { + token, + object: object as object, + activationId, + baseLayoutId, + layout, + scalars: scalarBytes.slice(0, layout.provenanceScalarLength), + references: [], + }; + return token; + } catch (error) { + this.abortPending(); + try { + if (Number.isInteger(slot) && slot >= 0 && slot < table.length) { + table.set(slot, null); + } + } catch { + // Preserve the fail-closed provenance error. + } + throw error; + } + } + + appendReference( + table: WebAssembly.Table, + token: number, + index: number, + slot: number, + ): void { + try { + const pending = this.requirePending(token); + assertU32(index, "GC provenance reference index"); + assertU32(slot, "GC provenance reference slot"); + if ( + index !== pending.references.length + || index >= pending.layout.provenanceReferenceCount + ) { + throw new Error( + `GC provenance reference ${index} is out of canonical order`, + ); + } + if (slot >= table.length) { + throw new Error(`GC provenance reference slot ${slot} is out of bounds`); + } + const value = table.get(slot); + if ( + value !== null + && (typeof value !== "object") + && typeof value !== "function" + ) { + throw new Error( + `GC provenance reference ${index} is neither null nor an object`, + ); + } + // A nullable seed for a zero-length immutable array is unobservable but + // still a typed constructor operand. Preserve it as recipe zero so a + // replayed child can register the same constructor evidence. + pending.references.push(value as object | null); + } catch (error) { + this.abortPending(); + try { + if (Number.isInteger(slot) && slot >= 0 && slot < table.length) { + table.set(slot, null); + } + } catch { + // Preserve the fail-closed provenance error. + } + throw error; + } + } + + end(token: number): void { + try { + const pending = this.requirePending(token); + if ( + pending.references.length + !== pending.layout.provenanceReferenceCount + ) { + throw new Error( + `GC provenance registration ${token} has ` + + `${pending.references.length} references; expected ` + + `${pending.layout.provenanceReferenceCount}`, + ); + } + this.finalized.set(pending.object, { + activationId: pending.activationId, + baseLayoutId: pending.baseLayoutId, + layoutId: pending.layout.id, + scalars: pending.scalars, + references: [...pending.references], + }); + this.pending = null; + } catch (error) { + this.abortPending(); + throw error; + } + } + + lookup( + object: unknown, + expectedActivationId: number, + descriptor: ForkGcCodecDescriptor, + baseLayoutId: number, + ): ForkGcConstructorProvenance | null { + if ( + (typeof object !== "object" || object === null) + && typeof object !== "function" + ) { + return null; + } + const provenance = this.finalized.get(object as object); + if (!provenance) return null; + if (provenance.activationId !== expectedActivationId) { + throw new Error( + `GC provenance belongs to activation ${provenance.activationId}, ` + + `not ${expectedActivationId}`, + ); + } + descriptor.requireCaptureLayout(baseLayoutId, provenance.layoutId); + if (provenance.baseLayoutId !== baseLayoutId) { + throw new Error( + `GC provenance base ${provenance.baseLayoutId} does not match ` + + `${baseLayoutId}`, + ); + } + return provenance; + } + + find(object: unknown): ForkGcConstructorProvenance | null { + if ( + (typeof object !== "object" || object === null) + && typeof object !== "function" + ) { + return null; + } + return this.finalized.get(object as object) ?? null; + } + + abortPending(): void { + this.pending = null; + } + + clear(): void { + this.abortPending(); + this.finalized = new WeakMap(); + } + + private requirePending(token: number): PendingProvenance { + assertU31(token, "GC provenance token"); + if (!this.pending || this.pending.token !== token) { + throw new Error(`GC provenance token ${token} is not active`); + } + return this.pending; + } +} diff --git a/host/src/fork-host-import-runtime.ts b/host/src/fork-host-import-runtime.ts new file mode 100644 index 0000000000..e069013c0f --- /dev/null +++ b/host/src/fork-host-import-runtime.ts @@ -0,0 +1,497 @@ +import { + readWasmFunctionImports, + type WasmFunctionImportType, + type WasmFunctionSignature, + type WasmValueType, +} from "./constants"; +import { + createForkExternrefImportMailbox, + type ForkExternrefImportAuthority, + type ForkExternrefImportBinding, + type ForkExternrefImportDescriptor, + type ForkExternrefImportHandler, + ForkExternrefImportOwnerCatalog, + ForkExternrefImportOwnerEndpoint, + type ForkExternrefImportWake, + ForkExternrefImportWorkerCaller, +} from "./fork-externref-import-mailbox"; +import { + FORK_WORKER_EXCEPTION_RESERVED_ORDINAL_START, + ForkWorkerExceptionCapabilityOwner, + ForkWorkerLocalImportExceptionNormalizer, + type ForkWorkerLocalImportExceptionNormalizerOptions, +} from "./fork-worker-import-exceptions"; +import { + ForkExternrefTokenCache, +} from "./fork-reference-broker"; + +export interface ForkOwnerImportWireRegistration { + readonly module: string; + readonly name: string; + readonly descriptor: ForkExternrefImportDescriptor; +} + +export interface ForkHostImportWorkerInit { + readonly mailbox: SharedArrayBuffer; + readonly senderId: number; + readonly ownerImports: readonly ForkOwnerImportWireRegistration[]; +} + +export interface ForkHostImportOwnerWorkerOptions { + readonly pid: number; + readonly generationId: number; + /** + * Must compare the exact live Worker object/generation held by the host + * entrypoint. Numeric wake fields are never sufficient authorization. + */ + readonly authorizeSender: (binding: ForkExternrefImportBinding) => void; + readonly onDiagnostic?: (error: unknown) => void; +} + +function importKey(module: string, name: string): string { + return `${module.length}:${module}${name}`; +} + +function validateImportName(value: string, label: string): void { + if (typeof value !== "string" || value.length === 0) { + throw new TypeError(`${label} must be a nonempty string`); + } +} + +function freezeDescriptor( + descriptor: ForkExternrefImportDescriptor, +): ForkExternrefImportDescriptor { + return Object.freeze({ + version: descriptor.version, + ordinal: descriptor.ordinal, + params: Object.freeze([...descriptor.params]), + results: Object.freeze([...descriptor.results]), + }); +} + +function cloneWireRegistration( + registration: ForkOwnerImportWireRegistration, +): ForkOwnerImportWireRegistration { + return Object.freeze({ + module: registration.module, + name: registration.name, + descriptor: freezeDescriptor(registration.descriptor), + }); +} + +function wasmTypeForMailbox(type: string): number { + switch (type) { + case "i32": + return 0x7f; + case "i64": + return 0x7e; + case "f32": + return 0x7d; + case "f64": + return 0x7c; + case "externref": + return 0x6f; + default: + throw new Error(`unknown fork host-import mailbox type ${type}`); + } +} + +function isExternReferenceType(type: WasmValueType): boolean { + return ( + type.code === 0x6f + || type.code === 0x72 + || ( + (type.code === 0x62 || type.code === 0x63 || type.code === 0x64) + && (type.heapType === -17 || type.heapType === -14) + ) + ); +} + +/** + * Vectors and exception/continuation references deliberately cannot enter a + * JavaScript host function. They remain valid on a direct Wasm-to-Wasm import + * and are reconstructed by the scalar frame, exception codec, or activation + * codec respectively. + */ +function requiresDirectWasmBoundary(type: WasmValueType): boolean { + if ( + type.code === 0x7b // v128 + || type.code === 0x69 // exnref + || type.code === 0x74 // noexnref + || type.code === 0x68 // contref + || type.code === 0x75 // nocontref + ) { + return true; + } + return ( + (type.code === 0x62 || type.code === 0x63 || type.code === 0x64) + && ( + type.heapType === -23 // exn + || type.heapType === -12 // noexn + || type.heapType === -24 // cont + || type.heapType === -11 // nocont + ) + ); +} + +function signatureRequiresDirectWasmBoundary( + signature: WasmFunctionSignature, +): boolean { + for (const type of signature.paramTypes) { + if (requiresDirectWasmBoundary(type)) return true; + } + for (const type of signature.resultTypes) { + if (requiresDirectWasmBoundary(type)) return true; + } + return false; +} + +function signatureMatchesDescriptor( + signature: WasmFunctionSignature, + descriptor: ForkExternrefImportDescriptor, +): boolean { + return ( + signature.params.length === descriptor.params.length + && signature.results.length === descriptor.results.length + && signature.params.every( + (type, index) => { + const expected = descriptor.params[index]!; + return expected === "externref" + ? isExternReferenceType(signature.paramTypes[index]!) + : type === wasmTypeForMailbox(expected); + }, + ) + && signature.results.every( + (type, index) => { + const expected = descriptor.results[index]!; + return expected === "externref" + ? isExternReferenceType(signature.resultTypes[index]!) + : type === wasmTypeForMailbox(expected); + }, + ) + ); +} + +function signatureText(signature: WasmFunctionSignature): string { + const valueName = (value: WasmValueType): string => { + switch (value.code) { + case 0x7f: + return "i32"; + case 0x7e: + return "i64"; + case 0x7d: + return "f32"; + case 0x7c: + return "f64"; + case 0x6f: + return "externref"; + case 0x70: + return "funcref"; + case 0x6e: + return "anyref"; + case 0x6d: + return "eqref"; + case 0x6c: + return "i31ref"; + case 0x6b: + return "structref"; + case 0x6a: + return "arrayref"; + case 0x69: + return "exnref"; + case 0x68: + return "contref"; + case 0x7b: + return "v128"; + case 0x62: + case 0x63: + case 0x64: + return `${value.code === 0x62 ? "exact" : "ref"}` + + `${value.code === 0x63 ? " null" : ""}` + + `${value.shared ? " shared" : ""} ${String(value.heapType)}`; + default: + return `0x${value.code.toString(16)}`; + } + }; + return `(${signature.paramTypes.map(valueName).join(",")}) -> (` + + `${signature.resultTypes.map(valueName).join(",")})`; +} + +/** + * One owner-realm catalog shared by every process/pthread Worker. + * + * The catalog is sealed when the first Worker is created. This guarantees a + * side module and the process main module see the same immutable routing + * policy, while every Worker still gets a distinct mailbox/sender identity. + */ +export class ForkHostImportOwnerRuntime { + private readonly catalog = new ForkExternrefImportOwnerCatalog(); + private readonly exceptionCapabilities = + new ForkWorkerExceptionCapabilityOwner(); + private readonly registrations = + new Map(); + private nextSenderId = 1; + private sealed = false; + + constructor( + private readonly authority: ForkExternrefImportAuthority, + ) { + this.exceptionCapabilities.install(this.catalog); + } + + register( + module: string, + name: string, + descriptor: ForkExternrefImportDescriptor, + handler: ForkExternrefImportHandler, + ): void { + if (this.sealed) { + throw new Error( + "fork owner host-import catalog is sealed by a live Worker", + ); + } + validateImportName(module, "fork owner import module"); + validateImportName(name, "fork owner import name"); + if (descriptor.ordinal >= FORK_WORKER_EXCEPTION_RESERVED_ORDINAL_START) { + throw new RangeError( + `fork owner import ordinal ${descriptor.ordinal} is reserved`, + ); + } + const key = importKey(module, name); + if (this.registrations.has(key)) { + throw new Error(`duplicate fork owner import ${module}.${name}`); + } + const registration = cloneWireRegistration({ + module, + name, + descriptor, + }); + this.catalog.register(registration.descriptor, handler); + this.registrations.set(key, registration); + } + + createWorker( + options: ForkHostImportOwnerWorkerOptions, + ): ForkHostImportOwnerWorker { + this.sealed = true; + if (this.nextSenderId > 0xffff_ffff) { + throw new RangeError("fork host-import sender id space exhausted"); + } + const binding: ForkExternrefImportBinding = Object.freeze({ + pid: options.pid, + generationId: options.generationId, + senderId: this.nextSenderId++, + }); + // WHY: the sealed catalog is the reconstruction owner for these imports. + // Size one reusable mailbox from its widest exact signature so valid wide + // imports need neither a continuation-frame field nor per-call buffers. + const mailbox = createForkExternrefImportMailbox( + this.catalog.mailboxCapacity, + ); + const endpoint = new ForkExternrefImportOwnerEndpoint( + mailbox, + binding, + this.catalog, + this.authority, + { + authorizeSender: options.authorizeSender, + onDiagnostic: (error) => options.onDiagnostic?.(error), + }, + ); + return new ForkHostImportOwnerWorker( + endpoint, + this.exceptionCapabilities, + Object.freeze( + [...this.registrations.values()].map(cloneWireRegistration), + ), + ); + } +} + +export class ForkHostImportOwnerWorker { + readonly init: ForkHostImportWorkerInit; + private closed = false; + + constructor( + private readonly endpoint: ForkExternrefImportOwnerEndpoint, + private readonly exceptionCapabilities: + ForkWorkerExceptionCapabilityOwner, + ownerImports: readonly ForkOwnerImportWireRegistration[], + ) { + this.init = Object.freeze({ + mailbox: endpoint.mailbox, + senderId: endpoint.binding.senderId, + ownerImports, + }); + } + + get binding(): ForkExternrefImportBinding { + return this.endpoint.binding; + } + + dispatch(wake: ForkExternrefImportWake): boolean { + if (this.closed) return false; + // This method is called only by the listener attached to this exact Worker + // object; pass that independently observed binding into the core endpoint. + return this.endpoint.dispatch(wake, this.endpoint.binding); + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.exceptionCapabilities.clearBinding(this.endpoint.binding); + this.endpoint.close(); + } +} + +/** + * One Worker-side caller shared by its main module and every side module. + */ +export class ForkHostImportWorkerRuntime { + readonly caller: ForkExternrefImportWorkerCaller; + readonly localExceptions: ForkWorkerLocalImportExceptionNormalizer; + private readonly ownerImports = + new Map(); + + constructor( + init: ForkHostImportWorkerInit, + pid: number, + generationId: number, + tokens: ForkExternrefTokenCache, + notifyOwner: (wake: ForkExternrefImportWake) => void, + normalizerOptions: + ForkWorkerLocalImportExceptionNormalizerOptions = {}, + ) { + const binding: ForkExternrefImportBinding = { + pid, + generationId, + senderId: init.senderId, + }; + this.caller = new ForkExternrefImportWorkerCaller( + init.mailbox, + binding, + tokens, + notifyOwner, + ); + this.localExceptions = new ForkWorkerLocalImportExceptionNormalizer( + this.caller, + tokens, + normalizerOptions, + ); + for (const raw of init.ownerImports) { + validateImportName(raw.module, "fork owner import module"); + validateImportName(raw.name, "fork owner import name"); + const registration = cloneWireRegistration(raw); + if ( + registration.descriptor.ordinal + >= FORK_WORKER_EXCEPTION_RESERVED_ORDINAL_START + ) { + throw new Error( + `fork owner import ${raw.module}.${raw.name} uses reserved ordinal ` + + `${registration.descriptor.ordinal}`, + ); + } + const key = importKey(raw.module, raw.name); + if (this.ownerImports.has(key)) { + throw new Error(`duplicate fork owner import ${raw.module}.${raw.name}`); + } + this.ownerImports.set(key, registration); + } + } + + /** + * Select the owner RPC for a registered opaque-value import, otherwise wrap + * the same-Worker intrinsic so nested Wasm traps retain trap semantics. + * Ordinary thrown values remain exact until fork capture. + */ + routeFunction( + imported: WasmFunctionImportType, + localImplementation: CallableFunction, + ): CallableFunction { + const owner = this.ownerImports.get( + importKey(imported.module, imported.name), + ); + if (owner) { + if (!signatureMatchesDescriptor(imported.signature, owner.descriptor)) { + throw new Error( + `owner import ${imported.module}.${imported.name} descriptor does ` + + `not match artifact signature ${signatureText(imported.signature)}`, + ); + } + return this.caller.bind(owner.descriptor); + } + if (signatureRequiresDirectWasmBoundary(imported.signature)) { + // WHY: wrapping creates a JavaScript host function. The JS embedding + // rejects v128/exnref (and has no continuation-reference conversion), + // while a direct imported Wasm function is valid and preserves its + // instance-local typed value. Those values are captured by the typed + // scalar/exception/module codec path, never by the externref mailbox. + return localImplementation; + } + return this.localExceptions.wrap( + imported.importOrdinal, + localImplementation, + ); + } + + /** + * Parse the artifact once and route all function imports in a conventional + * import object. Dynamic-linker Proxies can instead call routeFunction at + * their final property-resolution boundary. + */ + routeImportObject( + programBytes: ArrayBuffer, + imports: WebAssembly.Imports, + ): WebAssembly.Imports { + const grouped = new Map(); + for (const imported of readWasmFunctionImports(programBytes)) { + const key = importKey(imported.module, imported.name); + const entries = grouped.get(key) ?? []; + entries.push(imported); + grouped.set(key, entries); + } + + const routed: WebAssembly.Imports = { ...imports }; + const modules = new Map>(); + for (const entries of grouped.values()) { + const imported = entries[0]!; + const originalModule = imports[imported.module] as + | Record + | undefined; + if (!originalModule) continue; + let routedModule = modules.get(imported.module); + if (!routedModule) { + routedModule = { ...originalModule }; + modules.set(imported.module, routedModule); + routed[imported.module] = routedModule; + } + const implementation = originalModule[imported.name]; + if (typeof implementation !== "function") continue; + + const owner = this.ownerImports.get( + importKey(imported.module, imported.name), + ); + if ( + owner + && entries.some( + (entry) => + !signatureMatchesDescriptor(entry.signature, owner.descriptor), + ) + ) { + throw new Error( + `owner import ${imported.module}.${imported.name} has multiple ` + + "artifact signatures that do not share its exact descriptor", + ); + } + routedModule[imported.name] = this.routeFunction( + imported, + implementation, + ) as WebAssembly.ImportValue; + } + return routed; + } + + clear(): void { + this.localExceptions.clear(); + } +} diff --git a/host/src/fork-imported-globals.ts b/host/src/fork-imported-globals.ts new file mode 100644 index 0000000000..06991b54a5 --- /dev/null +++ b/host/src/fork-imported-globals.ts @@ -0,0 +1,1151 @@ +import { + WPK_FORK_GLOBAL_CATALOG_EXPORT_PREFIX, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + WPK_FORK_TABLE_CATALOG_EXPORT_PREFIX, +} from "./generated/abi"; +import { + findForkGlobalSnapshot, + ForkImportedGlobalBindingKind, + ForkImportedTableBindingKind, + type ForkGlobalSnapshot, + type ForkImportedGlobalBinding, + type ForkImportedGlobalState, + type ForkImportedTableBinding, + type ForkImportedTableState, + type ForkModuleStateArena, + type ForkModuleStateRecord, + type ForkTableDirtyTracker, + importedGlobalBindingsForChild, + importedTableBindingsForChild, + readForkImportedGlobals, + readForkImportedTables, +} from "./fork-module-state"; + +export type ForkWasmImports = Readonly< + Record>> +>; + +interface ParentActivation { + readonly activationId: number; + readonly module: WebAssembly.Module; + readonly globalDescriptors: readonly ForkImportedGlobalState[]; + readonly tableDescriptors: readonly ForkImportedTableState[]; + readonly globalBindings: ReadonlyMap; + readonly tableBindings: ReadonlyMap; + instance?: WebAssembly.Instance; +} + +interface GlobalCoordinate { + readonly activationId: number; + readonly ownerId: number; + readonly imported: boolean; +} + +interface TableCoordinate { + readonly activationId: number; + readonly ownerId: number; + readonly imported: boolean; +} + +/** + * Early child-side view of the process reference transaction. + * + * This interface is intentionally smaller than the replay transaction. The + * loader needs only raw immutable import values and their owning activation; + * ordinary global/table/frame restore still uses the full transaction after + * every activation is registered. + */ +export interface ForkImportedReferenceProvider { + ownerActivation(recipeId: number, typeCode: number): number | null; + /** + * Complete activation closure needed to materialize a typed recipe. + * + * A GC aggregate may be owned by one activation while its constructor or + * fields depend on codecs/catalogs from several earlier activations. The + * direct owner alone is therefore insufficient for child instantiation + * ordering. Scalar/funcref-only providers may omit this and retain the + * direct-owner behavior. + */ + activationDependencies?(recipeId: number, typeCode: number): number[]; + materialize(recipeId: number, typeCode: number): unknown; +} + +export interface PreparedForkParentActivation { + readonly imports: ForkWasmImports; + complete(instance: WebAssembly.Instance): void; + abort(): void; +} + +function assertU32(value: number, context: string, allowZero = true): number { + if ( + !Number.isInteger(value) + || value < (allowZero ? 0 : 1) + || value > 0xffff_ffff + ) { + throw new RangeError(`${context} is not ${allowZero ? "a" : "a nonzero"} u32`); + } + return value; +} + +function bindingKey(activationId: number, ownerId: number): string { + return `${activationId}:${ownerId}`; +} + +function importKey(module: string, name: string): string { + return `${module.length}:${module}${name}`; +} + +function catalogName(ownerId: number): string { + return `${WPK_FORK_GLOBAL_CATALOG_EXPORT_PREFIX}${ownerId}`; +} + +function tableCatalogName(ownerId: number): string { + return `${WPK_FORK_TABLE_CATALOG_EXPORT_PREFIX}${ownerId}`; +} + +function isReferenceType(typeCode: number): boolean { + return typeCode === WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF + || typeCode === WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF + || typeCode === WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF + || typeCode === WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF; +} + +interface TableCatalogActivation { + readonly activationId: number; + readonly instance: WebAssembly.Instance; +} + +function aliasTableDirtyTrackers( + activations: readonly TableCatalogActivation[], + trackers: ReadonlyMap, + label: string, +): void { + const identities = new Map< + WebAssembly.Table, + Array<{ activationId: number; ownerId: number }> + >(); + for (const activation of activations) { + const tracker = trackers.get(activation.activationId); + if (!tracker) { + throw new Error( + `${label}: activation ${activation.activationId} has no table dirty tracker`, + ); + } + for (const [name, value] of Object.entries(activation.instance.exports)) { + if (!name.startsWith(WPK_FORK_TABLE_CATALOG_EXPORT_PREFIX)) continue; + const ownerText = name.slice(WPK_FORK_TABLE_CATALOG_EXPORT_PREFIX.length); + if (!/^[1-9][0-9]*$/.test(ownerText)) { + throw new Error(`${label}: malformed private table catalog export ${name}`); + } + if (!(value instanceof WebAssembly.Table)) { + throw new Error(`${label}: private table catalog ${name} is not a Table`); + } + const ownerId = assertU32( + Number(ownerText), + "fork table catalog owner", + false, + ); + const coordinates = identities.get(value) ?? []; + coordinates.push({ activationId: activation.activationId, ownerId }); + identities.set(value, coordinates); + } + } + for (const coordinates of identities.values()) { + coordinates.sort( + (left, right) => + left.activationId - right.activationId + || left.ownerId - right.ownerId, + ); + const source = coordinates[0]!; + const sourceTracker = trackers.get(source.activationId)!; + sourceTracker.setStateOwner(source.ownerId, true); + for (const coordinate of coordinates.slice(1)) { + const tracker = trackers.get(coordinate.activationId)!; + tracker.aliasOwner( + coordinate.ownerId, + sourceTracker, + source.ownerId, + ); + tracker.setStateOwner(coordinate.ownerId, false); + } + } +} + +function f64Bits(value: number): bigint { + const bytes = new ArrayBuffer(8); + const view = new DataView(bytes); + view.setFloat64(0, value, true); + return view.getBigUint64(0, true); +} + +function numberFromF64Bits(value: bigint): number { + const bytes = new ArrayBuffer(8); + const view = new DataView(bytes); + view.setBigUint64(0, value, true); + return view.getFloat64(0, true); +} + +function requireImportNamespace( + imports: ForkWasmImports, + moduleName: string, +): Readonly> { + const namespace = imports[moduleName]; + if (!namespace || (typeof namespace !== "object" && typeof namespace !== "function")) { + throw new Error(`fork import object is missing namespace ${JSON.stringify(moduleName)}`); + } + return namespace; +} + +function validateImportedDescriptors( + module: WebAssembly.Module, + globalDescriptors: readonly ForkImportedGlobalState[], + tableDescriptors: readonly ForkImportedTableState[], + context: string, +): readonly WebAssembly.ModuleImportDescriptor[] { + const imports = WebAssembly.Module.imports(module); + const ordinals = new Set(); + for (const descriptor of globalDescriptors) { + const declaration = imports[descriptor.importOrdinal]; + if ( + !declaration + || declaration.kind !== "global" + || declaration.module !== descriptor.module + || declaration.name !== descriptor.name + ) { + throw new Error( + `${context}: KFIG owner ${descriptor.ownerId} does not match ` + + `global import ordinal ${descriptor.importOrdinal}`, + ); + } + if (ordinals.has(descriptor.importOrdinal)) { + throw new Error( + `${context}: duplicate ownership for import ordinal ${descriptor.importOrdinal}`, + ); + } + ordinals.add(descriptor.importOrdinal); + } + for (const descriptor of tableDescriptors) { + const declaration = imports[descriptor.importOrdinal]; + if ( + !declaration + || declaration.kind !== "table" + || declaration.module !== descriptor.module + || declaration.name !== descriptor.name + ) { + throw new Error( + `${context}: KFIT owner ${descriptor.ownerId} does not match ` + + `table import ordinal ${descriptor.importOrdinal}`, + ); + } + if (ordinals.has(descriptor.importOrdinal)) { + throw new Error( + `${context}: duplicate ownership for import ordinal ${descriptor.importOrdinal}`, + ); + } + ordinals.add(descriptor.importOrdinal); + } + return imports; +} + +type OwnedImport = + | { readonly kind: "global"; readonly descriptor: ForkImportedGlobalState } + | { readonly kind: "table"; readonly descriptor: ForkImportedTableState }; + +interface ImportAccess { + readonly owned?: OwnedImport; +} + +function importAccesses( + module: WebAssembly.Module, + globalDescriptors: readonly ForkImportedGlobalState[], + tableDescriptors: readonly ForkImportedTableState[], + context: string, +): Map { + const imports = validateImportedDescriptors( + module, + globalDescriptors, + tableDescriptors, + context, + ); + const byOrdinal = new Map(); + for (const descriptor of globalDescriptors) { + byOrdinal.set(descriptor.importOrdinal, { kind: "global", descriptor }); + } + for (const descriptor of tableDescriptors) { + byOrdinal.set(descriptor.importOrdinal, { kind: "table", descriptor }); + } + const interesting = new Set( + [...globalDescriptors, ...tableDescriptors] + .map(({ module, name }) => importKey(module, name)), + ); + const accesses = new Map(); + imports.forEach((declaration, importOrdinal) => { + const key = importKey(declaration.module, declaration.name); + if (!interesting.has(key)) return; + const values = accesses.get(key) ?? []; + values.push({ owned: byOrdinal.get(importOrdinal) }); + accesses.set(key, values); + }); + return accesses; +} + +/** + * Wrap an import object so each global declaration records the exact raw + * JavaScript value observed by WebAssembly instantiation. + * + * Duplicate `(module,name)` declarations are not collapsed. A getter may + * legally return a different value for each declaration, and Wasm performs + * each declaration's own type conversion. The child planner installs the same + * ordered getter sequence. + */ +function recordingImports( + module: WebAssembly.Module, + imports: ForkWasmImports, + globalDescriptors: readonly ForkImportedGlobalState[], + tableDescriptors: readonly ForkImportedTableState[], + capturedGlobals: Map, + capturedTables: Map, +): ForkWasmImports { + const accessPlan = importAccesses( + module, + globalDescriptors, + tableDescriptors, + "fork parent import capture", + ); + const byModule = new Map>(); + for (const descriptor of [...globalDescriptors, ...tableDescriptors]) { + let names = byModule.get(descriptor.module); + if (!names) { + names = new Map(); + byModule.set(descriptor.module, names); + } + names.set( + descriptor.name, + accessPlan.get(importKey(descriptor.module, descriptor.name))!, + ); + } + + const topLevel = new Map(); + for (const [moduleName, names] of byModule) { + const source = requireImportNamespace(imports, moduleName); + const ordinals = new Map(); + topLevel.set(moduleName, new Proxy(source as object, { + get(target, property, receiver) { + if (typeof property !== "string") { + return Reflect.get(target, property, receiver); + } + const declarations = names.get(property); + if (!declarations) return Reflect.get(target, property, receiver); + const ordinal = ordinals.get(property) ?? 0; + const access = declarations[ordinal]; + if (!access) { + throw new Error( + `WebAssembly read imported global ${JSON.stringify(moduleName)}.` + + `${JSON.stringify(property)} more than ${declarations.length} time(s)`, + ); + } + ordinals.set(property, ordinal + 1); + const value = Reflect.get(target, property, receiver); + if (access.owned?.kind === "global") { + capturedGlobals.set(access.owned.descriptor.ownerId, value); + } else if (access.owned?.kind === "table") { + capturedTables.set(access.owned.descriptor.ownerId, value); + } + return value; + }, + })); + } + + return new Proxy(imports as object, { + get(target, property, receiver) { + if (typeof property === "string" && topLevel.has(property)) { + return topLevel.get(property); + } + return Reflect.get(target, property, receiver); + }, + }) as ForkWasmImports; +} + +/** + * Parent-side capture of imported-global identity and raw binding semantics. + * + * This owner is process-lifetime state, not per-fork reference state. It holds + * only already-live activation instances/import values and drops the temporary + * declaration map after each prepared instantiation completes or aborts. + */ +export class ForkImportedGlobalCapture { + private readonly activations = new Map(); + private readonly prepared = new Set(); + + constructor(private readonly label: string) {} + + prepareActivation( + activationId: number, + module: WebAssembly.Module, + imports: ForkWasmImports, + ): PreparedForkParentActivation { + assertU32(activationId, "fork imported-global activation"); + if (this.activations.has(activationId) || this.prepared.has(activationId)) { + throw new Error(`${this.label}: activation ${activationId} is already prepared`); + } + const globalDescriptors = readForkImportedGlobals(module); + const tableDescriptors = readForkImportedTables(module); + const capturedGlobals = new Map(); + const capturedTables = new Map(); + this.prepared.add(activationId); + let finished = false; + const finish = (): void => { + if (finished) { + throw new Error(`${this.label}: activation ${activationId} preparation is finished`); + } + finished = true; + this.prepared.delete(activationId); + }; + return { + imports: recordingImports( + module, + imports, + globalDescriptors, + tableDescriptors, + capturedGlobals, + capturedTables, + ), + complete: (instance) => { + finish(); + for (const descriptor of globalDescriptors) { + if (!capturedGlobals.has(descriptor.ownerId)) { + throw new Error( + `${this.label}: WebAssembly did not resolve imported global ` + + `${activationId}:${descriptor.ownerId}`, + ); + } + } + const tables = new Map(); + for (const descriptor of tableDescriptors) { + const value = capturedTables.get(descriptor.ownerId); + if (!(value instanceof WebAssembly.Table)) { + throw new Error( + `${this.label}: WebAssembly did not resolve imported table ` + + `${activationId}:${descriptor.ownerId}`, + ); + } + tables.set(descriptor.ownerId, value); + } + this.activations.set(activationId, { + activationId, + module, + globalDescriptors, + tableDescriptors, + globalBindings: capturedGlobals, + tableBindings: tables, + instance, + }); + }, + abort: finish, + }; + } + + unregisterActivation(activationId: number): void { + assertU32(activationId, "fork imported-global activation"); + if (!this.activations.delete(activationId)) { + throw new Error(`${this.label}: activation ${activationId} is not registered`); + } + } + + /** + * Join activation-local dirty journals that name the same live Table. + * + * Call this after every activation is registered and before bootstrap/start + * mutations when possible. Joining is still correct after mutations because + * `aliasOwner` merges both existing interval sets before sharing the journal. + */ + bindTableDirtyTrackers( + trackers: ReadonlyMap, + ): void { + if (this.prepared.size !== 0) { + throw new Error( + `${this.label}: cannot bind table journals with ` + + `${this.prepared.size} incomplete activation(s)`, + ); + } + aliasTableDirtyTrackers( + this.orderedActivations().map(({ activationId, instance }) => ({ + activationId, + instance: instance!, + })), + trackers, + this.label, + ); + } + + appendTo(arena: ForkModuleStateArena): readonly ForkImportedGlobalBinding[] { + if (this.prepared.size !== 0) { + throw new Error( + `${this.label}: cannot snapshot with ${this.prepared.size} incomplete activation(s)`, + ); + } + const records = arena.recordsForCapture(); + const globalCoordinates = this.globalCoordinates(); + const tableCoordinates = this.tableCoordinates(); + const bindings: ForkImportedGlobalBinding[] = []; + const tableBindings: ForkImportedTableBinding[] = []; + for (const activation of this.orderedActivations()) { + for (const descriptor of activation.globalDescriptors) { + const snapshot = findForkGlobalSnapshot( + records, + activation.activationId, + descriptor.ownerId, + ); + if (snapshot.typeCode !== descriptor.typeCode) { + throw new Error( + `${this.label}: imported global ${activation.activationId}:` + + `${descriptor.ownerId} snapshot type does not match KFIG`, + ); + } + const value = activation.globalBindings.get(descriptor.ownerId)!; + bindings.push(this.captureBinding( + activation.activationId, + descriptor, + snapshot, + value, + globalCoordinates, + )); + } + for (const descriptor of activation.tableDescriptors) { + tableBindings.push(this.captureTableBinding( + activation.activationId, + descriptor, + activation.tableBindings.get(descriptor.ownerId)!, + tableCoordinates, + )); + } + } + bindings.sort( + (left, right) => + left.consumerActivation - right.consumerActivation + || left.consumerOwner - right.consumerOwner, + ); + tableBindings.sort( + (left, right) => + left.consumerActivation - right.consumerActivation + || left.consumerOwner - right.consumerOwner, + ); + arena.appendImportedGlobalBindings(bindings); + arena.appendImportedTableBindings(tableBindings); + return bindings; + } + + clear(): void { + this.activations.clear(); + this.prepared.clear(); + } + + private orderedActivations(): ParentActivation[] { + return [...this.activations.values()].sort( + (left, right) => left.activationId - right.activationId, + ); + } + + private globalCoordinates(): WeakMap { + const coordinates = new WeakMap(); + for (const activation of this.orderedActivations()) { + const importedOwners = new Set( + activation.globalDescriptors.map(({ ownerId }) => ownerId), + ); + for (const [name, value] of Object.entries(activation.instance!.exports)) { + if (!name.startsWith(WPK_FORK_GLOBAL_CATALOG_EXPORT_PREFIX)) continue; + const ownerText = name.slice(WPK_FORK_GLOBAL_CATALOG_EXPORT_PREFIX.length); + if (!/^[1-9][0-9]*$/.test(ownerText)) { + throw new Error(`${this.label}: malformed private global catalog export ${name}`); + } + const ownerId = Number(ownerText); + assertU32(ownerId, "fork global catalog owner", false); + if (!(value instanceof WebAssembly.Global)) { + throw new Error(`${this.label}: private global catalog ${name} is not a Global`); + } + const entries = coordinates.get(value) ?? []; + entries.push({ + activationId: activation.activationId, + ownerId, + imported: importedOwners.has(ownerId), + }); + coordinates.set(value, entries); + } + } + return coordinates; + } + + private tableCoordinates(): WeakMap { + const coordinates = new WeakMap(); + for (const activation of this.orderedActivations()) { + const importedOwners = new Set( + activation.tableDescriptors.map(({ ownerId }) => ownerId), + ); + for (const [name, value] of Object.entries(activation.instance!.exports)) { + if (!name.startsWith(WPK_FORK_TABLE_CATALOG_EXPORT_PREFIX)) continue; + const ownerText = name.slice(WPK_FORK_TABLE_CATALOG_EXPORT_PREFIX.length); + if (!/^[1-9][0-9]*$/.test(ownerText)) { + throw new Error(`${this.label}: malformed private table catalog export ${name}`); + } + const ownerId = Number(ownerText); + assertU32(ownerId, "fork table catalog owner", false); + if (!(value instanceof WebAssembly.Table)) { + throw new Error(`${this.label}: private table catalog ${name} is not a Table`); + } + const entries = coordinates.get(value) ?? []; + entries.push({ + activationId: activation.activationId, + ownerId, + imported: importedOwners.has(ownerId), + }); + coordinates.set(value, entries); + } + } + return coordinates; + } + + private captureBinding( + activationId: number, + descriptor: ForkImportedGlobalState, + snapshot: ForkGlobalSnapshot, + value: unknown, + coordinates: WeakMap, + ): ForkImportedGlobalBinding { + const base = { + consumerActivation: activationId, + consumerOwner: descriptor.ownerId, + sourceActivation: 0, + sourceOwner: 0, + reserved: 0, + recipeId: 0, + rawBits: 0n, + mutable: descriptor.mutable, + shared: descriptor.shared, + typeCode: descriptor.typeCode, + }; + if (value instanceof WebAssembly.Global) { + const candidates = (coordinates.get(value) ?? []) + .filter((coordinate) => !coordinate.imported) + .sort( + (left, right) => + left.activationId - right.activationId + || left.ownerId - right.ownerId, + ); + const provider = candidates[0]; + if (provider) { + return { + ...base, + kind: ForkImportedGlobalBindingKind.ActivationGlobal, + sourceActivation: provider.activationId, + sourceOwner: provider.ownerId, + }; + } + return { + ...base, + // The final child import builder owns process cells such as GOT, + // stack-pointer, and dylink base globals. Re-resolving the exact + // declaration preserves its identity without copying a JS handle. + kind: ForkImportedGlobalBindingKind.BaseImport, + }; + } + + if (isReferenceType(descriptor.typeCode)) { + if (snapshot.recipeId === undefined) { + throw new Error( + `${this.label}: reference import ${activationId}:${descriptor.ownerId} ` + + "has no recipe id", + ); + } + if ( + descriptor.typeCode === WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF + && snapshot.recipeId !== 0 + ) { + // WHY: JavaScript cannot read or carry a non-null exnref. A legitimate + // non-null import is necessarily a WebAssembly.Global carrier and was + // handled above as ActivationGlobal/BaseImport. Emitting RawReference + // here would manufacture a child transport that the embedding API + // cannot represent. + throw new Error( + `${this.label}: non-null exnref import ${activationId}:` + + `${descriptor.ownerId} has no WebAssembly.Global carrier`, + ); + } + return { + ...base, + kind: ForkImportedGlobalBindingKind.RawReference, + recipeId: snapshot.recipeId, + }; + } + if (typeof value === "number") { + return { + ...base, + kind: ForkImportedGlobalBindingKind.RawNumber, + rawBits: f64Bits(value), + }; + } + if (typeof value === "bigint") { + return { + ...base, + kind: ForkImportedGlobalBindingKind.RawBigInt, + rawBits: BigInt.asUintN(64, value), + }; + } + return { + ...base, + kind: ForkImportedGlobalBindingKind.BaseImport, + }; + } + + private captureTableBinding( + activationId: number, + descriptor: ForkImportedTableState, + value: WebAssembly.Table, + coordinates: WeakMap, + ): ForkImportedTableBinding { + const candidates = (coordinates.get(value) ?? []) + .filter((coordinate) => !coordinate.imported) + .sort( + (left, right) => + left.activationId - right.activationId + || left.ownerId - right.ownerId, + ); + const provider = candidates[0]; + if (provider) { + return { + consumerActivation: activationId, + consumerOwner: descriptor.ownerId, + sourceActivation: provider.activationId, + sourceOwner: provider.ownerId, + reserved: 0, + kind: ForkImportedTableBindingKind.ActivationTable, + }; + } + return { + consumerActivation: activationId, + consumerOwner: descriptor.ownerId, + sourceActivation: 0, + sourceOwner: 0, + reserved: 0, + // Process tables are reconstructed by the same main/dylink import + // builder that created them in the parent. The planner deliberately + // leaves this declaration's lazy getter in control. + kind: ForkImportedTableBindingKind.BaseImport, + }; + } +} + +interface ChildActivation { + readonly activationId: number; + readonly module: WebAssembly.Module; + readonly globalDescriptors: readonly ForkImportedGlobalState[]; + readonly tableDescriptors: readonly ForkImportedTableState[]; + readonly globalBindings: readonly ForkImportedGlobalBinding[]; + readonly tableBindings: readonly ForkImportedTableBinding[]; +} + +/** + * Fresh-child instantiation planner for imported globals. + * + * Provider activations are topologically ordered before consumers. Mutable + * Global cells may still contain their deterministic baseline while consumers + * bind them; KFMS restore updates that one shared cell after all activations + * exist. Immutable cells are already final by definition, so const + * initializers and direct re-exports observe the exact provider identity at + * instantiation time. + */ +export class ForkImportedGlobalPlanner { + private readonly activations = new Map(); + private readonly instances = new Map(); + private readonly globalBindingsByConsumer = + new Map(); + private readonly tableBindingsByConsumer = + new Map(); + + constructor( + records: readonly ForkModuleStateRecord[], + modules: ReadonlyMap, + private readonly references: ForkImportedReferenceProvider, + private readonly label: string, + ) { + const globalBindings = importedGlobalBindingsForChild(records); + const tableBindings = importedTableBindingsForChild(records); + for (const binding of globalBindings) { + this.globalBindingsByConsumer.set( + bindingKey(binding.consumerActivation, binding.consumerOwner), + binding, + ); + } + for (const binding of tableBindings) { + this.tableBindingsByConsumer.set( + bindingKey(binding.consumerActivation, binding.consumerOwner), + binding, + ); + } + for (const [activationId, module] of [...modules].sort( + ([left], [right]) => left - right, + )) { + assertU32(activationId, "fork imported-global activation"); + const globalDescriptors = readForkImportedGlobals(module); + const tableDescriptors = readForkImportedTables(module); + const activationGlobalBindings = globalDescriptors.map((descriptor) => { + const binding = this.globalBindingsByConsumer.get( + bindingKey(activationId, descriptor.ownerId), + ); + if (!binding) { + throw new Error( + `${this.label}: missing imported-global binding ` + + `${activationId}:${descriptor.ownerId}`, + ); + } + if ( + binding.typeCode !== descriptor.typeCode + || binding.mutable !== descriptor.mutable + || binding.shared !== descriptor.shared + ) { + throw new Error( + `${this.label}: imported-global binding ${activationId}:` + + `${descriptor.ownerId} does not match KFIG`, + ); + } + if ( + binding.kind === ForkImportedGlobalBindingKind.RawReference + && binding.typeCode === WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF + && binding.recipeId !== 0 + ) { + throw new Error( + `${this.label}: imported exnref binding ${activationId}:` + + `${descriptor.ownerId} has impossible raw non-null provenance`, + ); + } + return binding; + }); + const activationTableBindings = tableDescriptors.map((descriptor) => { + const binding = this.tableBindingsByConsumer.get( + bindingKey(activationId, descriptor.ownerId), + ); + if (!binding) { + throw new Error( + `${this.label}: missing imported-table binding ` + + `${activationId}:${descriptor.ownerId}`, + ); + } + return binding; + }); + this.activations.set(activationId, { + activationId, + module, + globalDescriptors, + tableDescriptors, + globalBindings: activationGlobalBindings, + tableBindings: activationTableBindings, + }); + } + if ( + globalBindings.length + !== [...this.activations.values()] + .reduce((count, activation) => count + activation.globalBindings.length, 0) + ) { + throw new Error(`${this.label}: imported-global bindings name unknown declarations`); + } + if ( + tableBindings.length + !== [...this.activations.values()] + .reduce((count, activation) => count + activation.tableBindings.length, 0) + ) { + throw new Error(`${this.label}: imported-table bindings name unknown declarations`); + } + } + + instantiationOrder(): number[] { + const ids = [...this.activations.keys()].sort((left, right) => left - right); + const dependencies = new Map>( + ids.map((id) => [id, new Set(this.dependenciesFor(id))]), + ); + const order: number[] = []; + const remaining = new Set(ids); + while (remaining.size !== 0) { + const ready = [...remaining] + .filter((id) => + [...dependencies.get(id)!].every( + (dependency) => !remaining.has(dependency), + )) + .sort((left, right) => left - right); + if (ready.length === 0) { + const cycle = [...remaining].sort((left, right) => left - right); + throw new Error( + `${this.label}: imported-global provider cycle among activations ` + + cycle.join(", "), + ); + } + for (const id of ready) { + remaining.delete(id); + order.push(id); + } + } + return order; + } + + dependenciesFor(activationId: number): number[] { + const activation = this.requireActivation(activationId); + const dependencies = new Set(); + for (const binding of activation.globalBindings) { + let dependency: number | null = null; + switch (binding.kind) { + case ForkImportedGlobalBindingKind.ActivationGlobal: + dependency = binding.sourceActivation; + break; + case ForkImportedGlobalBindingKind.RawReference: { + const closure = this.references.activationDependencies?.( + binding.recipeId, + binding.typeCode, + ); + if (closure) { + for (const activationDependency of closure) { + if (activationDependency === activationId) continue; + if (!this.activations.has(activationDependency)) { + throw new Error( + `${this.label}: activation ${activationId} depends on missing ` + + `provider activation ${activationDependency}`, + ); + } + dependencies.add(activationDependency); + } + break; + } + dependency = this.references.ownerActivation( + binding.recipeId, + binding.typeCode, + ); + break; + } + case ForkImportedGlobalBindingKind.BaseImport: + case ForkImportedGlobalBindingKind.RawNumber: + case ForkImportedGlobalBindingKind.RawBigInt: + break; + } + if (dependency === null || dependency === activationId) continue; + if (!this.activations.has(dependency)) { + throw new Error( + `${this.label}: activation ${activationId} depends on missing ` + + `provider activation ${dependency}`, + ); + } + dependencies.add(dependency); + } + for (const binding of activation.tableBindings) { + let dependency: number | null = null; + switch (binding.kind) { + case ForkImportedTableBindingKind.ActivationTable: + dependency = binding.sourceActivation; + break; + case ForkImportedTableBindingKind.BaseImport: + break; + } + if (dependency === null || dependency === activationId) continue; + if (!this.activations.has(dependency)) { + throw new Error( + `${this.label}: activation ${activationId} depends on missing ` + + `provider activation ${dependency}`, + ); + } + dependencies.add(dependency); + } + return [...dependencies].sort((left, right) => left - right); + } + + importsForActivation( + activationId: number, + baseImports: ForkWasmImports, + ): ForkWasmImports { + const activation = this.requireActivation(activationId); + const resolvedByOrdinal = new Map< + number, + { override: boolean; value?: unknown } + >(); + activation.globalDescriptors.forEach((descriptor, index) => { + const binding = activation.globalBindings[index]!; + resolvedByOrdinal.set( + descriptor.importOrdinal, + binding.kind === ForkImportedGlobalBindingKind.BaseImport + ? { override: false } + : { override: true, value: this.resolveGlobal(binding) }, + ); + }); + activation.tableDescriptors.forEach((descriptor, index) => { + const binding = activation.tableBindings[index]!; + resolvedByOrdinal.set( + descriptor.importOrdinal, + binding.kind === ForkImportedTableBindingKind.BaseImport + ? { override: false } + : { override: true, value: this.resolveTable(binding) }, + ); + }); + const accessPlan = importAccesses( + activation.module, + activation.globalDescriptors, + activation.tableDescriptors, + `${this.label}: activation ${activationId}`, + ); + const byModule = new Map< + string, + Map> + >(); + for ( + const descriptor of [ + ...activation.globalDescriptors, + ...activation.tableDescriptors, + ] + ) { + let names = byModule.get(descriptor.module); + if (!names) { + names = new Map(); + byModule.set(descriptor.module, names); + } + names.set( + descriptor.name, + accessPlan.get(importKey(descriptor.module, descriptor.name))!.map( + (access) => access.owned + ? resolvedByOrdinal.get(access.owned.descriptor.importOrdinal)! + : { override: false }, + ), + ); + } + + const namespaces = new Map(); + for (const [moduleName, names] of byModule) { + const source = baseImports[moduleName] ?? {}; + const ordinals = new Map(); + namespaces.set(moduleName, new Proxy(source as object, { + get(target, property, receiver) { + if (typeof property !== "string") { + return Reflect.get(target, property, receiver); + } + const accesses = names.get(property); + if (!accesses) return Reflect.get(target, property, receiver); + const ordinal = ordinals.get(property) ?? 0; + const access = accesses[ordinal]; + if (!access) { + throw new Error( + `WebAssembly read reconstructed global ${JSON.stringify(moduleName)}.` + + `${JSON.stringify(property)} more than ${accesses.length} time(s)`, + ); + } + ordinals.set(property, ordinal + 1); + return access.override + ? access.value + : Reflect.get(target, property, receiver); + }, + })); + } + return new Proxy(baseImports as object, { + get(target, property, receiver) { + if (typeof property === "string" && namespaces.has(property)) { + return namespaces.get(property); + } + return Reflect.get(target, property, receiver); + }, + }) as ForkWasmImports; + } + + registerInstance(activationId: number, instance: WebAssembly.Instance): void { + this.requireActivation(activationId); + if (this.instances.has(activationId)) { + throw new Error(`${this.label}: activation ${activationId} was instantiated twice`); + } + this.instances.set(activationId, instance); + } + + /** + * Join child journals before KFMS restore marks replayed sparse pages. + * + * All instances are required so aliases are derived from actual provider + * identity instead of guessed from import names. + */ + bindTableDirtyTrackers( + trackers: ReadonlyMap, + ): void { + if (this.instances.size !== this.activations.size) { + throw new Error( + `${this.label}: cannot bind table journals before all ` + + `${this.activations.size} activation(s) are instantiated`, + ); + } + aliasTableDirtyTrackers( + [...this.instances] + .sort(([left], [right]) => left - right) + .map(([activationId, instance]) => ({ activationId, instance })), + trackers, + this.label, + ); + } + + clear(): void { + this.instances.clear(); + } + + private resolveGlobal(binding: ForkImportedGlobalBinding): unknown { + switch (binding.kind) { + case ForkImportedGlobalBindingKind.RawNumber: + return numberFromF64Bits(binding.rawBits); + case ForkImportedGlobalBindingKind.RawBigInt: + return BigInt.asIntN(64, binding.rawBits); + case ForkImportedGlobalBindingKind.RawReference: + return this.references.materialize(binding.recipeId, binding.typeCode); + case ForkImportedGlobalBindingKind.ActivationGlobal: { + const provider = this.instances.get(binding.sourceActivation); + if (!provider) { + throw new Error( + `${this.label}: provider activation ${binding.sourceActivation} ` + + "is not instantiated", + ); + } + const value = provider.exports[catalogName(binding.sourceOwner)]; + if (!(value instanceof WebAssembly.Global)) { + throw new Error( + `${this.label}: provider global ${binding.sourceActivation}:` + + `${binding.sourceOwner} is missing`, + ); + } + return value; + } + case ForkImportedGlobalBindingKind.BaseImport: + throw new Error(`${this.label}: base import cannot be eagerly resolved`); + } + } + + private resolveTable(binding: ForkImportedTableBinding): WebAssembly.Table { + switch (binding.kind) { + case ForkImportedTableBindingKind.ActivationTable: { + const provider = this.instances.get(binding.sourceActivation); + if (!provider) { + throw new Error( + `${this.label}: provider activation ${binding.sourceActivation} ` + + "is not instantiated", + ); + } + const value = provider.exports[tableCatalogName(binding.sourceOwner)]; + if (!(value instanceof WebAssembly.Table)) { + throw new Error( + `${this.label}: provider table ${binding.sourceActivation}:` + + `${binding.sourceOwner} is missing`, + ); + } + return value; + } + case ForkImportedTableBindingKind.BaseImport: + throw new Error(`${this.label}: base table import cannot be eagerly resolved`); + } + } + + private requireActivation(activationId: number): ChildActivation { + assertU32(activationId, "fork imported-global activation"); + const activation = this.activations.get(activationId); + if (!activation) { + throw new Error(`${this.label}: activation ${activationId} is not declared`); + } + return activation; + } +} diff --git a/host/src/fork-module-state.ts b/host/src/fork-module-state.ts new file mode 100644 index 0000000000..029c02ad81 --- /dev/null +++ b/host/src/fork-module-state.ts @@ -0,0 +1,3662 @@ +import { WASM_PAGE_SIZE } from "./constants"; +import { + ContinuationAllocationError, + type ContinuationAllocate, + type ContinuationDeallocate, +} from "./fork-continuation"; +import { + type ForkReplayEventCaptureSource, + type ForkReplayEventWire, + validateForkReplayEventWire, +} from "./fork-replay-events"; +/* + * Keep the allocation error as a runtime import: fork() must return its errno + * after an arena mmap failure, not turn an ordinary resource failure into a + * process trap. + */ +import { + WPK_FORK_ACTIVATION_CONTINUATIONS_ENTRY_KNOWN_FLAGS, + WPK_FORK_ACTIVATION_CONTINUATIONS_ENTRY_SIZE, + WPK_FORK_ACTIVATION_CONTINUATIONS_HEADER_SIZE, + WPK_FORK_ACTIVATION_CONTINUATIONS_KNOWN_FLAGS, + WPK_FORK_ACTIVATION_CONTINUATIONS_MAGIC, + WPK_FORK_ACTIVATION_CONTINUATIONS_OWNER, + WPK_FORK_ACTIVATION_CONTINUATIONS_VERSION, + WPK_FORK_MODULE_STATE_ARENA_VERSION, + WPK_FORK_MODULE_STATE_CHUNK_FLAG_ROOT, + WPK_FORK_MODULE_STATE_CHUNK_FLAG_SEALED, + WPK_FORK_MODULE_STATE_CHUNK_MAGIC, + WPK_FORK_MODULE_STATE_DATA_SEGMENT_HEADER_SIZE, + WPK_FORK_MODULE_STATE_DESCRIPTOR_SIZE, + WPK_FORK_MODULE_STATE_ELEMENT_SEGMENT_HEADER_SIZE, + WPK_FORK_MODULE_STATE_FLAG_EXPLICIT_OWNERS, + WPK_FORK_MODULE_STATE_FLAG_ROOT_PREFIX_POINTER, + WPK_FORK_MODULE_STATE_FLAG_SPARSE_TABLES, + WPK_FORK_MODULE_STATE_FORMAT_MAGIC, + WPK_FORK_MODULE_STATE_FORMAT_SECTION, + WPK_FORK_MODULE_STATE_FORMAT_VERSION, + WPK_FORK_MODULE_STATE_GLOBAL_HEADER_SIZE, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_F32, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_F64, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I32, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I64, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_V128, + WPK_FORK_MODULE_STATE_KNOWN_FLAGS, + WPK_FORK_MODULE_STATE_MAX_TABLE_PAGE_SHIFT, + WPK_FORK_MODULE_STATE_MIN_TABLE_PAGE_SHIFT, + WPK_FORK_MODULE_STATE_MODULE_RECORD_KNOWN_FLAGS, + WPK_FORK_MODULE_STATE_MODULE_RECORD_PAYLOAD_SIZE, + WPK_FORK_MODULE_STATE_MODULE_TEMPLATE_ID_SIZE, + WPK_FORK_MODULE_STATE_POINTER_WIDTHS, + WPK_FORK_MODULE_STATE_RECORD_ALIGNMENT, + WPK_FORK_MODULE_STATE_RECORD_HEADER_SIZE, + WPK_FORK_MODULE_STATE_RECORD_KIND_DATA_SEGMENTS, + WPK_FORK_MODULE_STATE_RECORD_KIND_ACTIVATION_CONTINUATIONS, + WPK_FORK_MODULE_STATE_RECORD_KIND_ELEMENT_SEGMENTS, + WPK_FORK_MODULE_STATE_RECORD_KIND_IMPORTED_GLOBAL_BINDINGS, + WPK_FORK_MODULE_STATE_RECORD_KIND_IMPORTED_TABLE_BINDINGS, + WPK_FORK_MODULE_STATE_RECORD_KIND_MODULE, + WPK_FORK_MODULE_STATE_RECORD_KIND_MUTABLE_GLOBAL, + WPK_FORK_MODULE_STATE_RECORD_KIND_REFERENCE_RECIPE, + WPK_FORK_MODULE_STATE_RECORD_KIND_REFERENCE_RECIPE_SEGMENT, + WPK_FORK_MODULE_STATE_RECORD_KIND_REPLAY_EVENTS, + WPK_FORK_MODULE_STATE_RECORD_KIND_REPLAY_EVENT_SEGMENT, + WPK_FORK_MODULE_STATE_RECORD_KIND_TABLE, + WPK_FORK_MODULE_STATE_RECORD_KIND_TABLE_PAGE, + WPK_FORK_MODULE_STATE_RECORD_MAGIC, + WPK_FORK_MODULE_STATE_RECORD_VERSION, + WPK_FORK_MODULE_STATE_REQUIRED_FLAGS, + WPK_FORK_MODULE_STATE_ROOT_POINTER_WORD_OFFSET, + WPK_FORK_MODULE_STATE_TABLE_BASELINE_FINGERPRINT_SIZE, + WPK_FORK_MODULE_STATE_TABLE_DESCRIPTOR_PAYLOAD_SIZE, + WPK_FORK_MODULE_STATE_TABLE_FLAG_SPARSE_OVERRIDES, + WPK_FORK_MODULE_STATE_TABLE_KNOWN_FLAGS, + WPK_FORK_MODULE_STATE_TABLE_PAGE_HEADER_SIZE, + WPK_FORK_MODULE_STATE_TABLE_RUN_HEADER_SIZE, + WPK_FORK_MODULE_STATE_REPLAY_EVENTS_OWNER, + WPK_FORK_IMPORTED_GLOBALS_FLAG_MUTABLE, + WPK_FORK_IMPORTED_GLOBALS_FLAG_SHARED, + WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE, + WPK_FORK_IMPORTED_GLOBALS_KNOWN_FLAGS, + WPK_FORK_IMPORTED_GLOBALS_MAGIC, + WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE, + WPK_FORK_IMPORTED_GLOBALS_SECTION, + WPK_FORK_IMPORTED_GLOBALS_VERSION, + WPK_FORK_IMPORTED_GLOBAL_BINDINGS_ENTRY_SIZE, + WPK_FORK_IMPORTED_GLOBAL_BINDINGS_HEADER_SIZE, + WPK_FORK_IMPORTED_GLOBAL_BINDINGS_KNOWN_FLAGS, + WPK_FORK_IMPORTED_GLOBAL_BINDINGS_MAGIC, + WPK_FORK_IMPORTED_GLOBAL_BINDINGS_OWNER, + WPK_FORK_IMPORTED_GLOBAL_BINDINGS_VERSION, + WPK_FORK_IMPORTED_GLOBAL_BINDING_ACTIVATION_GLOBAL, + WPK_FORK_IMPORTED_GLOBAL_BINDING_BASE_IMPORT, + WPK_FORK_IMPORTED_GLOBAL_BINDING_RAW_BIGINT, + WPK_FORK_IMPORTED_GLOBAL_BINDING_RAW_NUMBER, + WPK_FORK_IMPORTED_GLOBAL_BINDING_RAW_REFERENCE, + WPK_FORK_IMPORTED_TABLES_FLAG_TABLE64, + WPK_FORK_IMPORTED_TABLES_HEADER_SIZE, + WPK_FORK_IMPORTED_TABLES_KNOWN_FLAGS, + WPK_FORK_IMPORTED_TABLES_MAGIC, + WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE, + WPK_FORK_IMPORTED_TABLES_SECTION, + WPK_FORK_IMPORTED_TABLES_VERSION, + WPK_FORK_IMPORTED_TABLE_BINDINGS_ENTRY_SIZE, + WPK_FORK_IMPORTED_TABLE_BINDINGS_HEADER_SIZE, + WPK_FORK_IMPORTED_TABLE_BINDINGS_KNOWN_FLAGS, + WPK_FORK_IMPORTED_TABLE_BINDINGS_MAGIC, + WPK_FORK_IMPORTED_TABLE_BINDINGS_OWNER, + WPK_FORK_IMPORTED_TABLE_BINDINGS_VERSION, + WPK_FORK_IMPORTED_TABLE_BINDING_ACTIVATION_TABLE, + WPK_FORK_IMPORTED_TABLE_BINDING_BASE_IMPORT, +} from "./generated/abi"; + +/** + * Versioned artifact metadata for activation-owned module-state recipes. + * + * Keep the short aliases as the arena's public API, but source every wire + * literal from the generated shared ABI contract. + */ +export const FORK_MODULE_STATE_SECTION = WPK_FORK_MODULE_STATE_FORMAT_SECTION; +export const FORK_MODULE_STATE_DESCRIPTOR_MAGIC = WPK_FORK_MODULE_STATE_FORMAT_MAGIC; +export const FORK_MODULE_STATE_DESCRIPTOR_VERSION = WPK_FORK_MODULE_STATE_FORMAT_VERSION; +export const FORK_MODULE_STATE_DESCRIPTOR_SIZE = WPK_FORK_MODULE_STATE_DESCRIPTOR_SIZE; +export const FORK_MODULE_STATE_RECORD_ALIGNMENT = WPK_FORK_MODULE_STATE_RECORD_ALIGNMENT; +export const FORK_MODULE_STATE_ARENA_VERSION = WPK_FORK_MODULE_STATE_ARENA_VERSION; +export const FORK_MODULE_STATE_RECORD_VERSION = WPK_FORK_MODULE_STATE_RECORD_VERSION; +export const FORK_MODULE_STATE_ROOT_POINTER_WORD_OFFSET = + WPK_FORK_MODULE_STATE_ROOT_POINTER_WORD_OFFSET; + +export const FORK_MODULE_STATE_FLAG_ROOT_PREFIX_POINTER = + WPK_FORK_MODULE_STATE_FLAG_ROOT_PREFIX_POINTER; +export const FORK_MODULE_STATE_FLAG_EXPLICIT_OWNERS = + WPK_FORK_MODULE_STATE_FLAG_EXPLICIT_OWNERS; +export const FORK_MODULE_STATE_FLAG_SPARSE_TABLES = + WPK_FORK_MODULE_STATE_FLAG_SPARSE_TABLES; +export const FORK_MODULE_STATE_REQUIRED_FLAGS = WPK_FORK_MODULE_STATE_REQUIRED_FLAGS; +export const FORK_MODULE_STATE_KNOWN_FLAGS = WPK_FORK_MODULE_STATE_KNOWN_FLAGS; + +const CHUNK_MAGIC = littleEndianMagic(WPK_FORK_MODULE_STATE_CHUNK_MAGIC); +const RECORD_MAGIC = littleEndianMagic(WPK_FORK_MODULE_STATE_RECORD_MAGIC); +const CHUNK_FLAG_ROOT = WPK_FORK_MODULE_STATE_CHUNK_FLAG_ROOT; +const CHUNK_FLAG_SEALED = WPK_FORK_MODULE_STATE_CHUNK_FLAG_SEALED; +const RECORD_HEADER_SIZE = WPK_FORK_MODULE_STATE_RECORD_HEADER_SIZE; +export const FORK_MODULE_STATE_TEMPLATE_ID_SIZE = + WPK_FORK_MODULE_STATE_MODULE_TEMPLATE_ID_SIZE; +export const FORK_MODULE_STATE_BASELINE_FINGERPRINT_SIZE = + WPK_FORK_MODULE_STATE_TABLE_BASELINE_FINGERPRINT_SIZE; +const MODULE_TEMPLATE_ID_SIZE = FORK_MODULE_STATE_TEMPLATE_ID_SIZE; +const MODULE_RECORD_PAYLOAD_SIZE = WPK_FORK_MODULE_STATE_MODULE_RECORD_PAYLOAD_SIZE; +const MODULE_RECORD_KNOWN_FLAGS = WPK_FORK_MODULE_STATE_MODULE_RECORD_KNOWN_FLAGS; +const TABLE_DESCRIPTOR_PAYLOAD_SIZE = WPK_FORK_MODULE_STATE_TABLE_DESCRIPTOR_PAYLOAD_SIZE; +const TABLE_FLAG_SPARSE_OVERRIDES = WPK_FORK_MODULE_STATE_TABLE_FLAG_SPARSE_OVERRIDES; +const TABLE_KNOWN_FLAGS = WPK_FORK_MODULE_STATE_TABLE_KNOWN_FLAGS; +const TABLE_PAGE_HEADER_SIZE = WPK_FORK_MODULE_STATE_TABLE_PAGE_HEADER_SIZE; +const TABLE_RUN_HEADER_SIZE = WPK_FORK_MODULE_STATE_TABLE_RUN_HEADER_SIZE; +const ELEMENT_SEGMENT_HEADER_SIZE = WPK_FORK_MODULE_STATE_ELEMENT_SEGMENT_HEADER_SIZE; +const DATA_SEGMENT_HEADER_SIZE = WPK_FORK_MODULE_STATE_DATA_SEGMENT_HEADER_SIZE; +const GLOBAL_HEADER_SIZE = WPK_FORK_MODULE_STATE_GLOBAL_HEADER_SIZE; +const MIN_TABLE_PAGE_SHIFT = WPK_FORK_MODULE_STATE_MIN_TABLE_PAGE_SHIFT; +const MAX_TABLE_PAGE_SHIFT = WPK_FORK_MODULE_STATE_MAX_TABLE_PAGE_SHIFT; + +export interface ForkModuleStateDescriptor { + version: number; + ptrWidth: 4 | 8; + alignment: number; + flags: number; + arenaVersion: number; + recordVersion: number; + rootPointerWordOffset: number; +} + +export const ForkModuleStateRecordKind = { + Module: WPK_FORK_MODULE_STATE_RECORD_KIND_MODULE, + ReferenceRecipe: WPK_FORK_MODULE_STATE_RECORD_KIND_REFERENCE_RECIPE, + MutableGlobal: WPK_FORK_MODULE_STATE_RECORD_KIND_MUTABLE_GLOBAL, + Table: WPK_FORK_MODULE_STATE_RECORD_KIND_TABLE, + TablePage: WPK_FORK_MODULE_STATE_RECORD_KIND_TABLE_PAGE, + ElementSegments: WPK_FORK_MODULE_STATE_RECORD_KIND_ELEMENT_SEGMENTS, + DataSegments: WPK_FORK_MODULE_STATE_RECORD_KIND_DATA_SEGMENTS, + ReplayEvents: WPK_FORK_MODULE_STATE_RECORD_KIND_REPLAY_EVENTS, + ImportedGlobalBindings: WPK_FORK_MODULE_STATE_RECORD_KIND_IMPORTED_GLOBAL_BINDINGS, + ActivationContinuations: WPK_FORK_MODULE_STATE_RECORD_KIND_ACTIVATION_CONTINUATIONS, + ImportedTableBindings: WPK_FORK_MODULE_STATE_RECORD_KIND_IMPORTED_TABLE_BINDINGS, + ReferenceRecipeSegment: + WPK_FORK_MODULE_STATE_RECORD_KIND_REFERENCE_RECIPE_SEGMENT, + ReplayEventSegment: WPK_FORK_MODULE_STATE_RECORD_KIND_REPLAY_EVENT_SEGMENT, +} as const; + +export type ForkModuleStateRecordKind = + typeof ForkModuleStateRecordKind[keyof typeof ForkModuleStateRecordKind]; + +const RECORD_KINDS = new Set(Object.values(ForkModuleStateRecordKind)); + +export interface ForkModuleStateRecord { + kind: ForkModuleStateRecordKind; + activationId: number; + ownerId: number; + payload: Uint8Array; +} + +/** + * A validated record envelope whose payload may alias the sealed arena. + * + * The view is valid only while its owning process memory and arena mapping are + * alive. Consumers that need a longer lifetime must copy the specific bytes + * they retain; streaming decoders should keep the view to avoid duplicating a + * whole segmented transaction. + */ +export interface ForkModuleStateRecordView { + readonly kind: ForkModuleStateRecordKind; + readonly activationId: number; + readonly ownerId: number; + readonly payload: Uint8Array; +} + +export interface ForkImportedGlobalState { + module: string; + name: string; + importOrdinal: number; + ownerId: number; + typeCode: number; + mutable: boolean; + shared: boolean; +} + +export interface ForkImportedTableState { + module: string; + name: string; + importOrdinal: number; + ownerId: number; + typeCode: number; + table64: boolean; +} + +export const ForkImportedGlobalBindingKind = { + RawNumber: WPK_FORK_IMPORTED_GLOBAL_BINDING_RAW_NUMBER, + RawBigInt: WPK_FORK_IMPORTED_GLOBAL_BINDING_RAW_BIGINT, + RawReference: WPK_FORK_IMPORTED_GLOBAL_BINDING_RAW_REFERENCE, + ActivationGlobal: WPK_FORK_IMPORTED_GLOBAL_BINDING_ACTIVATION_GLOBAL, + BaseImport: WPK_FORK_IMPORTED_GLOBAL_BINDING_BASE_IMPORT, +} as const; + +export type ForkImportedGlobalBindingKind = + typeof ForkImportedGlobalBindingKind[keyof typeof ForkImportedGlobalBindingKind]; + +export interface ForkImportedGlobalBinding { + consumerActivation: number; + consumerOwner: number; + sourceActivation: number; + sourceOwner: number; + reserved: number; + recipeId: number; + rawBits: bigint; + kind: ForkImportedGlobalBindingKind; + mutable: boolean; + shared: boolean; + typeCode: number; +} + +export const ForkImportedTableBindingKind = { + ActivationTable: WPK_FORK_IMPORTED_TABLE_BINDING_ACTIVATION_TABLE, + BaseImport: WPK_FORK_IMPORTED_TABLE_BINDING_BASE_IMPORT, +} as const; + +export type ForkImportedTableBindingKind = + typeof ForkImportedTableBindingKind[keyof typeof ForkImportedTableBindingKind]; + +export interface ForkImportedTableBinding { + consumerActivation: number; + consumerOwner: number; + sourceActivation: number; + sourceOwner: number; + reserved: number; + kind: ForkImportedTableBindingKind; +} + +export interface ForkGlobalSnapshot { + typeCode: number; + value: Uint8Array; + recipeId?: number; +} + +export interface ForkActivationContinuation { + activationId: number; + root: bigint; +} + +export interface ForkModuleDescriptorRecord { + activationId: number; + templateId: Uint8Array; + flags?: number; +} + +export interface ForkSparseTableRun { + start: number; + recipeIds: readonly number[] | Uint32Array; +} + +export interface ForkSparseTablePage { + pageIndex: number | bigint; + runs: readonly ForkSparseTableRun[]; +} + +export interface ForkSparseTableSnapshot { + activationId: number; + ownerId: number; + indexWidth: 4 | 8; + pageShift: number; + length: number | bigint; + baselineLength: number | bigint; + baselineFingerprint: Uint8Array; + pages: readonly ForkSparseTablePage[]; +} + +export interface DecodedForkSparseTableRun { + start: number; + recipeIds: Uint32Array; +} + +export interface DecodedForkSparseTablePage { + pageIndex: bigint; + runs: DecodedForkSparseTableRun[]; +} + +export interface DecodedForkSparseTableSnapshot { + activationId: number; + ownerId: number; + indexWidth: 4 | 8; + pageShift: number; + length: bigint; + baselineLength: bigint; + baselineFingerprint: Uint8Array; + pages: DecodedForkSparseTablePage[]; +} + +interface DirtyPageJournal { + intervals: Array<{ start: bigint; end: bigint }>; + cumulativeEnds: bigint[]; + count: bigint; +} + +interface DirtyPageJournalNode { + parent: DirtyPageJournalNode; + journal: DirtyPageJournal; + stateOwner: boolean; +} + +/** + * Process-lifetime journal of table pages changed from the deterministic + * instantiation baseline. + * + * Generated Wasm calls `markPages` after successful table mutations. The + * journal stores merged intervals rather than one object per page, while + * `pageAt` exposes a deterministic sorted enumeration to the KFMS save helper. + * It intentionally outlives one fork transaction: a replayed child seeds the + * same journal while applying overlays so a later child does not mistake the + * restored parent state for its static baseline. + */ +export class ForkTableDirtyTracker { + private readonly journals = new Map(); + + /** + * Make two activation-local owner ordinals describe one physical Table. + * + * Imported table aliases are discovered only after instantiation. Unioning + * journals (including journals that already contain start-function writes) + * ensures a mutation through any alias reaches one canonical KFMS sparse + * snapshot. Every activation still replays its static element baseline. + * State ownership is elected separately because the union root may belong + * to a provider activation that was later unloaded while an imported alias + * remains live. + */ + aliasOwner( + ownerId: number, + source: ForkTableDirtyTracker, + sourceOwnerId: number, + ): void { + checkedU32(ownerId, "table dirty owner", false); + checkedU32(sourceOwnerId, "table dirty source owner", false); + const targetNode = this.node(ownerId); + const sourceNode = source.node(sourceOwnerId); + targetNode.stateOwner = false; + sourceNode.stateOwner = true; + const targetRoot = this.root(targetNode); + const sourceRoot = source.root(sourceNode); + if (targetRoot === sourceRoot) return; + mergeDirtyPageJournals(sourceRoot.journal, targetRoot.journal); + targetRoot.parent = sourceRoot; + } + + /** Whether this activation-local coordinate owns the physical table state. */ + ownsState(ownerId: number): boolean { + checkedU32(ownerId, "table state owner", false); + return this.node(ownerId).stateOwner; + } + + /** + * Elect or retire this live activation coordinate as sparse-state owner. + * + * The journal's union topology deliberately remains intact so mutations + * accumulated through a now-unloaded provider are not lost when ownership + * moves to a surviving alias. + */ + setStateOwner(ownerId: number, owned: boolean): void { + checkedU32(ownerId, "table state owner", false); + this.node(ownerId).stateOwner = owned; + } + + markPages( + ownerId: number, + firstPageValue: number | bigint, + pageCountValue: number | bigint, + ): void { + checkedU32(ownerId, "table dirty owner", false); + const firstPage = checkedWasmU64(firstPageValue, "table dirty first page"); + const pageCount = checkedWasmU64(pageCountValue, "table dirty page count"); + if (pageCount === 0n) return; + const end = firstPage + pageCount; + if (end > (1n << 64n)) { + throw new RangeError("table dirty page range exceeds u64"); + } + const journal = this.root(this.node(ownerId)).journal; + let insertion = 0; + while ( + insertion < journal.intervals.length + && journal.intervals[insertion]!.end < firstPage + ) { + insertion++; + } + let mergedStart = firstPage; + let mergedEnd = end; + let removalEnd = insertion; + while ( + removalEnd < journal.intervals.length + && journal.intervals[removalEnd]!.start <= mergedEnd + ) { + const interval = journal.intervals[removalEnd]!; + if (interval.start < mergedStart) mergedStart = interval.start; + if (interval.end > mergedEnd) mergedEnd = interval.end; + removalEnd++; + } + journal.intervals.splice( + insertion, + removalEnd - insertion, + { start: mergedStart, end: mergedEnd }, + ); + rebuildDirtyPageJournal(journal); + } + + pageCount(ownerId: number): number { + checkedU32(ownerId, "table dirty owner", false); + const node = this.journals.get(ownerId); + const count = node ? this.root(node).journal.count : 0n; + if (count > 0xffff_ffffn) { + throw new RangeError("table dirty page count exceeds KFMS u32 record count"); + } + return Number(count); + } + + pageAt(ownerId: number, ordinal: number): bigint { + checkedU32(ownerId, "table dirty owner", false); + checkedU32(ordinal, "table dirty page ordinal"); + const node = this.journals.get(ownerId); + const journal = node ? this.root(node).journal : undefined; + if (!journal || BigInt(ordinal) >= journal.count) { + throw new RangeError( + `table dirty owner ${ownerId} has no page ordinal ${ordinal}`, + ); + } + const target = BigInt(ordinal); + let low = 0; + let high = journal.cumulativeEnds.length; + while (low < high) { + const mid = low + ((high - low) >> 1); + if (target < journal.cumulativeEnds[mid]!) high = mid; + else low = mid + 1; + } + const previous = low === 0 ? 0n : journal.cumulativeEnds[low - 1]!; + const page = journal.intervals[low]!.start + (target - previous); + // WebAssembly i64 crosses JavaScript as signed BigInt. Preserve the exact + // unsigned page bits for the generated helper's i64 shifts/stores. + return BigInt.asIntN(64, page); + } + + private node(ownerId: number): DirtyPageJournalNode { + const existing = this.journals.get(ownerId); + if (existing) return existing; + const journal: DirtyPageJournal = { + intervals: [], + cumulativeEnds: [], + count: 0n, + }; + const node = {} as DirtyPageJournalNode; + node.parent = node; + node.journal = journal; + node.stateOwner = true; + this.journals.set(ownerId, node); + return node; + } + + private root(node: DirtyPageJournalNode): DirtyPageJournalNode { + let root = node; + while (root.parent !== root) root = root.parent; + let cursor = node; + while (cursor.parent !== cursor) { + const parent = cursor.parent; + cursor.parent = root; + cursor = parent; + } + return root; + } +} + +function checkedWasmU64(value: number | bigint, context: string): bigint { + if (typeof value === "number" && (!Number.isSafeInteger(value) || value < 0)) { + throw new RangeError(`${context}: expected an exact non-negative integer`); + } + const signed = typeof value === "bigint" ? value : BigInt(value); + const exact = BigInt.asUintN(64, signed); + if (signed >= 0n && signed !== exact) { + throw new RangeError(`${context}: value exceeds u64`); + } + return exact; +} + +function rebuildDirtyPageJournal(journal: DirtyPageJournal): void { + let count = 0n; + journal.cumulativeEnds = journal.intervals.map((interval) => { + count += interval.end - interval.start; + return count; + }); + journal.count = count; +} + +function mergeDirtyPageJournals( + target: DirtyPageJournal, + source: DirtyPageJournal, +): void { + if (source.intervals.length === 0) return; + const intervals = [...target.intervals, ...source.intervals] + .sort((left, right) => + left.start < right.start ? -1 : left.start > right.start ? 1 : 0 + ); + target.intervals = []; + for (const interval of intervals) { + const previous = target.intervals[target.intervals.length - 1]; + if (!previous || previous.end < interval.start) { + target.intervals.push({ ...interval }); + } else if (interval.end > previous.end) { + previous.end = interval.end; + } + } + rebuildDirtyPageJournal(target); +} + +export interface ForkElementSegmentState { + activationId: number; + ownerId: number; + segmentCount: number; + dropped: Uint8Array; +} + +export interface ForkDataSegmentState { + activationId: number; + ownerId: number; + segmentCount: number; + dropped: Uint8Array; +} + +export async function computeForkModuleTemplateId( + bytes: ArrayBuffer | ArrayBufferView, +): Promise { + if (!globalThis.crypto?.subtle) { + throw new Error("SHA-256 is unavailable for fork module template identity"); + } + const source = bytes instanceof ArrayBuffer + ? new Uint8Array(bytes) + : new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength); + // WebCrypto excludes SharedArrayBuffer-backed views. A module template is + // immutable input, so one exact owned copy also avoids hashing a concurrently + // changing shared view. + const owned = new Uint8Array(source); + return new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", owned)); +} + +const SHA256_INITIAL_STATE = new Uint32Array([ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, +]); + +const SHA256_ROUND_CONSTANTS = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, + 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, + 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, + 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, + 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, + 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +function rotateRight32(value: number, amount: number): number { + return (value >>> amount) | (value << (32 - amount)); +} + +/** + * Synchronous, host-neutral SHA-256 for the synchronous `dlopen` import. + * + * The implementation streams one 64-byte block at a time, so exact module + * identity does not require a second module-sized padding allocation. Keep + * the WebCrypto implementation above for async main-program admission; tests + * require both paths to produce byte-identical digests. + */ +export function computeForkModuleTemplateIdSync( + bytes: ArrayBuffer | ArrayBufferView, +): Uint8Array { + const source = bytes instanceof ArrayBuffer + ? new Uint8Array(bytes) + : new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const totalLength = Math.ceil((source.byteLength + 9) / 64) * 64; + if (!Number.isSafeInteger(totalLength)) { + throw new RangeError("fork module template is too large to hash safely"); + } + + const state = new Uint32Array(SHA256_INITIAL_STATE); + const schedule = new Uint32Array(64); + const block = new Uint8Array(64); + const blockView = new DataView(block.buffer); + const bitLength = BigInt(source.byteLength) * 8n; + + for (let offset = 0; offset < totalLength; offset += 64) { + block.fill(0); + const sourceEnd = Math.min(offset + 64, source.byteLength); + if (offset < sourceEnd) { + block.set(source.subarray(offset, sourceEnd)); + } + if (source.byteLength >= offset && source.byteLength < offset + 64) { + block[source.byteLength - offset] = 0x80; + } + if (offset + 64 === totalLength) { + blockView.setBigUint64(56, bitLength, false); + } + + for (let word = 0; word < 16; word++) { + schedule[word] = blockView.getUint32(word * 4, false); + } + for (let word = 16; word < 64; word++) { + const x = schedule[word - 15]!; + const y = schedule[word - 2]!; + const sigma0 = rotateRight32(x, 7) ^ rotateRight32(x, 18) ^ (x >>> 3); + const sigma1 = rotateRight32(y, 17) ^ rotateRight32(y, 19) ^ (y >>> 10); + schedule[word] = ( + schedule[word - 16]! + + sigma0 + + schedule[word - 7]! + + sigma1 + ) >>> 0; + } + + let a = state[0]!; + let b = state[1]!; + let c = state[2]!; + let d = state[3]!; + let e = state[4]!; + let f = state[5]!; + let g = state[6]!; + let h = state[7]!; + for (let round = 0; round < 64; round++) { + const upper = rotateRight32(e, 6) + ^ rotateRight32(e, 11) + ^ rotateRight32(e, 25); + const choose = (e & f) ^ (~e & g); + const temporary1 = ( + h + + upper + + choose + + SHA256_ROUND_CONSTANTS[round]! + + schedule[round]! + ) >>> 0; + const lower = rotateRight32(a, 2) + ^ rotateRight32(a, 13) + ^ rotateRight32(a, 22); + const majority = (a & b) ^ (a & c) ^ (b & c); + const temporary2 = (lower + majority) >>> 0; + h = g; + g = f; + f = e; + e = (d + temporary1) >>> 0; + d = c; + c = b; + b = a; + a = (temporary1 + temporary2) >>> 0; + } + state[0] = (state[0]! + a) >>> 0; + state[1] = (state[1]! + b) >>> 0; + state[2] = (state[2]! + c) >>> 0; + state[3] = (state[3]! + d) >>> 0; + state[4] = (state[4]! + e) >>> 0; + state[5] = (state[5]! + f) >>> 0; + state[6] = (state[6]! + g) >>> 0; + state[7] = (state[7]! + h) >>> 0; + } + + const digest = new Uint8Array(32); + const digestView = new DataView(digest.buffer); + for (let word = 0; word < state.length; word++) { + digestView.setUint32(word * 4, state[word]!, false); + } + return digest; +} + +export function requireForkModuleTemplate( + records: readonly ForkModuleStateRecord[], + activationId: number, + expectedTemplateId: Uint8Array, +): void { + checkedU32(activationId, "module activation id"); + if (expectedTemplateId.byteLength !== MODULE_TEMPLATE_ID_SIZE) { + throw new RangeError( + `module template id has ${expectedTemplateId.byteLength} bytes, ` + + `expected ${MODULE_TEMPLATE_ID_SIZE}`, + ); + } + const record = records.find( + (candidate) => + candidate.kind === ForkModuleStateRecordKind.Module + && candidate.activationId === activationId, + ); + if (!record) { + throw new Error(`module-state arena is missing activation ${activationId}`); + } + if ( + !bytesEqual( + record.payload.subarray(0, MODULE_TEMPLATE_ID_SIZE), + expectedTemplateId, + ) + ) { + throw new Error(`module-state activation ${activationId} has the wrong template`); + } +} + +interface ArenaChunk { + addr: number; + size: number; + used: number; + recordCount: number; +} + +interface PendingRecord { + chunk: ArenaChunk; + kind: ForkModuleStateRecordKind; + activationId: number; + ownerId: number; + payloadAddr: number; + totalSize: number; + payloadSize: number; +} + +interface DecodedTableDescriptor { + activationId: number; + ownerId: number; + indexWidth: 4 | 8; + pageShift: number; + flags: number; + pageCount: number; + length: bigint; + baselineLength: bigint; + baselineFingerprint: Uint8Array; +} + +interface DecodedTablePage { + activationId: number; + ownerId: number; + pageIndex: bigint; + runs: DecodedForkSparseTableRun[]; + entryCount: number; +} + +interface ValidatedTablePage { + pageIndex: bigint; + runCount: number; + entryCount: number; +} + +interface ValidatedSparseTablePage { + pageIndex: bigint; + entryCount: number; + payloadSize: number; +} + +function littleEndianMagic(bytes: readonly number[]): number { + return ( + bytes[0]! + | (bytes[1]! << 8) + | (bytes[2]! << 16) + | (bytes[3]! << 24) + ) >>> 0; +} + +function alignUp(value: number, alignment: number): number { + const result = Math.ceil(value / alignment) * alignment; + if (!Number.isSafeInteger(result)) { + throw new RangeError(`module-state alignment overflow: ${value}`); + } + return result; +} + +function checkedEnd(addr: number, size: number, context: string): number { + const end = addr + size; + if ( + !Number.isSafeInteger(addr) + || !Number.isSafeInteger(size) + || addr < 0 + || size < 0 + || !Number.isSafeInteger(end) + ) { + throw new RangeError(`${context}: invalid range addr=${addr} size=${size}`); + } + return end; +} + +function checkedMemoryRange( + memory: WebAssembly.Memory, + addr: number, + size: number, + context: string, +): void { + if (checkedEnd(addr, size, context) > memory.buffer.byteLength) { + throw new RangeError(`${context}: range exceeds WebAssembly memory`); + } +} + +function checkedU32(value: number, context: string, allowZero = true): number { + if ( + !Number.isInteger(value) + || value < (allowZero ? 0 : 1) + || value > 0xffff_ffff + ) { + throw new RangeError(`${context}: expected ${allowZero ? "a" : "a nonzero"} u32`); + } + return value; +} + +function checkedU64(value: number | bigint, context: string): bigint { + if ( + typeof value === "number" + && (!Number.isSafeInteger(value) || value < 0) + ) { + throw new RangeError(`${context}: expected a u64`); + } + const result = typeof value === "bigint" ? value : BigInt(value); + if (result < 0n || result > 0xffff_ffff_ffff_ffffn) { + throw new RangeError(`${context}: expected a u64`); + } + return result; +} + +function checkedPointer( + value: number | bigint, + ptrWidth: 4 | 8, + context: string, + allowZero: boolean, +): number { + const result = typeof value === "bigint" ? Number(value) : value; + if ( + (typeof value === "bigint" && BigInt(result) !== value) + || !Number.isSafeInteger(result) + || result < (allowZero ? 0 : 1) + || (ptrWidth === 4 && result > 0xffff_ffff) + ) { + throw new RangeError(`${context}: invalid ${ptrWidth * 8}-bit guest pointer`); + } + return result; +} + +function writePointer( + memory: WebAssembly.Memory, + ptrWidth: 4 | 8, + addr: number, + value: number, +): void { + checkedMemoryRange(memory, addr, ptrWidth, "module-state pointer write"); + const checked = checkedPointer(value, ptrWidth, "module-state pointer write", true); + const view = new DataView(memory.buffer); + if (ptrWidth === 8) view.setBigUint64(addr, BigInt(checked), true); + else view.setUint32(addr, checked, true); +} + +function readPointer( + memory: WebAssembly.Memory, + ptrWidth: 4 | 8, + addr: number, + context: string, +): number { + checkedMemoryRange(memory, addr, ptrWidth, context); + const view = new DataView(memory.buffer); + const raw = ptrWidth === 8 + ? view.getBigUint64(addr, true) + : BigInt(view.getUint32(addr, true)); + return checkedPointer(raw, ptrWidth, context, true); +} + +function bytesEqual(actual: Uint8Array, expected: ArrayLike): boolean { + if (actual.byteLength < expected.length) return false; + for (let index = 0; index < expected.length; index++) { + if (actual[index] !== expected[index]) return false; + } + return true; +} + +function requireZeroBytes(bytes: Uint8Array, context: string): void { + if (bytes.some((value) => value !== 0)) { + throw new Error(`${context}: reserved or padding bytes must be zero`); + } +} + +function chunkHeaderSize(ptrWidth: 4 | 8): number { + const format = WPK_FORK_MODULE_STATE_POINTER_WIDTHS.find( + ({ bytes }) => bytes === ptrWidth, + ); + if (!format) { + throw new Error(`unsupported module-state pointer width ${ptrWidth}`); + } + return format.chunkHeaderSize; +} + +function chunkOffset(ptrWidth: 4 | 8, field: 0 | 1 | 2 | 3 | 4): number { + return 8 + field * ptrWidth; +} + +function chunkRecordCountOffset(ptrWidth: 4 | 8): number { + return 8 + 5 * ptrWidth; +} + +function chunkReservedOffset(ptrWidth: 4 | 8): number { + return 12 + 5 * ptrWidth; +} + +function tableKey(activationId: number, ownerId: number): string { + return `${activationId}:${ownerId}`; +} + +function ownerKey( + kind: ForkModuleStateRecordKind, + activationId: number, + ownerId: number, +): string { + return `${kind}:${activationId}:${ownerId}`; +} + +/** + * Encode the artifact descriptor that binds an instrumented module to this + * exact arena/record/root-prefix contract. + */ +export function encodeForkModuleStateDescriptor( + ptrWidth: 4 | 8, +): Uint8Array { + const bytes = new Uint8Array(FORK_MODULE_STATE_DESCRIPTOR_SIZE); + const view = new DataView(bytes.buffer); + bytes.set(FORK_MODULE_STATE_DESCRIPTOR_MAGIC, 0); + view.setUint16(4, FORK_MODULE_STATE_DESCRIPTOR_VERSION, true); + view.setUint16(6, FORK_MODULE_STATE_DESCRIPTOR_SIZE, true); + view.setUint8(8, ptrWidth); + view.setUint8(9, FORK_MODULE_STATE_RECORD_ALIGNMENT); + view.setUint16(10, FORK_MODULE_STATE_REQUIRED_FLAGS, true); + view.setUint16(12, FORK_MODULE_STATE_ARENA_VERSION, true); + view.setUint16(14, FORK_MODULE_STATE_RECORD_VERSION, true); + view.setUint32(16, FORK_MODULE_STATE_ROOT_POINTER_WORD_OFFSET, true); + view.setUint32(20, 0, true); + return bytes; +} + +export function decodeForkModuleStateDescriptor( + bytes: Uint8Array, +): ForkModuleStateDescriptor { + if (bytes.byteLength !== FORK_MODULE_STATE_DESCRIPTOR_SIZE) { + throw new Error( + `module-state descriptor has ${bytes.byteLength} bytes, ` + + `expected ${FORK_MODULE_STATE_DESCRIPTOR_SIZE}`, + ); + } + if (!bytesEqual(bytes, FORK_MODULE_STATE_DESCRIPTOR_MAGIC)) { + throw new Error("module-state descriptor has invalid magic"); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const version = view.getUint16(4, true); + if (version !== FORK_MODULE_STATE_DESCRIPTOR_VERSION) { + throw new Error(`unsupported module-state descriptor version ${version}`); + } + if (view.getUint16(6, true) !== FORK_MODULE_STATE_DESCRIPTOR_SIZE) { + throw new Error("module-state descriptor declares an invalid size"); + } + const ptrWidth = view.getUint8(8); + if (ptrWidth !== 4 && ptrWidth !== 8) { + throw new Error(`unsupported module-state pointer width ${ptrWidth}`); + } + const alignment = view.getUint8(9); + if (alignment !== FORK_MODULE_STATE_RECORD_ALIGNMENT) { + throw new Error(`unsupported module-state record alignment ${alignment}`); + } + const flags = view.getUint16(10, true); + if ((flags & ~FORK_MODULE_STATE_KNOWN_FLAGS) !== 0) { + throw new Error(`unknown module-state descriptor flags 0x${flags.toString(16)}`); + } + if ((flags & FORK_MODULE_STATE_REQUIRED_FLAGS) !== FORK_MODULE_STATE_REQUIRED_FLAGS) { + throw new Error("module-state descriptor omits required ownership features"); + } + const arenaVersion = view.getUint16(12, true); + if (arenaVersion !== FORK_MODULE_STATE_ARENA_VERSION) { + throw new Error(`unsupported module-state arena version ${arenaVersion}`); + } + const recordVersion = view.getUint16(14, true); + if (recordVersion !== FORK_MODULE_STATE_RECORD_VERSION) { + throw new Error(`unsupported module-state record version ${recordVersion}`); + } + const rootPointerWordOffset = view.getUint32(16, true); + if (rootPointerWordOffset !== FORK_MODULE_STATE_ROOT_POINTER_WORD_OFFSET) { + throw new Error( + `unsupported module-state root-pointer word offset ${rootPointerWordOffset}`, + ); + } + if (view.getUint32(20, true) !== 0) { + throw new Error("module-state descriptor reserved field is nonzero"); + } + return { + version, + ptrWidth, + alignment, + flags, + arenaVersion, + recordVersion, + rootPointerWordOffset, + }; +} + +export function readForkModuleStateDescriptor( + module: WebAssembly.Module, +): ForkModuleStateDescriptor { + const sections = WebAssembly.Module.customSections(module, FORK_MODULE_STATE_SECTION); + if (sections.length !== 1) { + throw new Error( + `expected one ${FORK_MODULE_STATE_SECTION} section, found ${sections.length}`, + ); + } + return decodeForkModuleStateDescriptor(new Uint8Array(sections[0]!)); +} + +const IMPORTED_GLOBAL_TYPE_CODES = new Set([ + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I32, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I64, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_F32, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_F64, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_V128, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF, +]); + +/** + * Read the pre-instantiation ownership recipe for imported globals. + * + * Immutable imports must be supplied with their saved parent value before + * WebAssembly instantiation: exported imported Globals and const initializers + * observe that exact binding and cannot be repaired by a later `global.set`. + */ +export function readForkImportedGlobals( + module: WebAssembly.Module, +): readonly ForkImportedGlobalState[] { + const sections = WebAssembly.Module.customSections( + module, + WPK_FORK_IMPORTED_GLOBALS_SECTION, + ); + if (sections.length !== 1) { + throw new Error( + `expected one ${WPK_FORK_IMPORTED_GLOBALS_SECTION} section, found ${sections.length}`, + ); + } + const bytes = new Uint8Array(sections[0]!); + if (bytes.byteLength < WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE) { + throw new Error("imported-global descriptor is truncated"); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if ( + view.getUint32(0, true) + !== littleEndianMagic(WPK_FORK_IMPORTED_GLOBALS_MAGIC) + ) { + throw new Error("imported-global descriptor has the wrong magic"); + } + if (view.getUint16(4, true) !== WPK_FORK_IMPORTED_GLOBALS_VERSION) { + throw new Error( + `unsupported imported-global descriptor version ${view.getUint16(4, true)}`, + ); + } + if (view.getUint16(6, true) !== WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE) { + throw new Error("imported-global descriptor declares an invalid header size"); + } + const count = view.getUint32(8, true); + if (view.getUint32(12, true) !== 0) { + throw new Error("imported-global descriptor reserved field is nonzero"); + } + + const decoder = new TextDecoder("utf-8", { fatal: true }); + const owners = new Set(); + const importOrdinals = new Set(); + const globals: ForkImportedGlobalState[] = []; + let previousImportOrdinal = -1; + let offset = WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE; + for (let index = 0; index < count; index++) { + if (offset + WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE > bytes.byteLength) { + throw new Error(`imported-global record ${index} header is truncated`); + } + const recordSize = view.getUint32(offset, true); + const ownerId = view.getUint32(offset + 4, true); + const typeCode = view.getUint8(offset + 8); + const flags = view.getUint8(offset + 9); + const moduleLength = view.getUint32(offset + 12, true); + const nameLength = view.getUint32(offset + 16, true); + const importOrdinal = view.getUint32(offset + 20, true); + const expectedSize = WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE + + moduleLength + + nameLength; + if ( + recordSize !== expectedSize + || recordSize < WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE + || offset + recordSize > bytes.byteLength + ) { + throw new Error(`imported-global record ${index} has invalid bounds`); + } + checkedU32(ownerId, `imported-global record ${index} owner`, false); + if (owners.has(ownerId)) { + throw new Error(`imported-global record ${index} duplicates owner ${ownerId}`); + } + owners.add(ownerId); + if (!IMPORTED_GLOBAL_TYPE_CODES.has(typeCode)) { + throw new Error( + `imported-global record ${index} has unknown value type ${typeCode}`, + ); + } + if ((flags & ~WPK_FORK_IMPORTED_GLOBALS_KNOWN_FLAGS) !== 0) { + throw new Error( + `imported-global record ${index} has unknown flags 0x${flags.toString(16)}`, + ); + } + if (view.getUint16(offset + 10, true) !== 0) { + throw new Error(`imported-global record ${index} reserved field is nonzero`); + } + const namesOffset = offset + WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE; + let moduleName: string; + let fieldName: string; + try { + moduleName = decoder.decode( + bytes.subarray(namesOffset, namesOffset + moduleLength), + ); + fieldName = decoder.decode( + bytes.subarray( + namesOffset + moduleLength, + namesOffset + moduleLength + nameLength, + ), + ); + } catch { + throw new Error(`imported-global record ${index} contains invalid UTF-8`); + } + if ( + importOrdinals.has(importOrdinal) + || importOrdinal <= previousImportOrdinal + ) { + throw new Error( + `imported-global record ${index} has duplicated or unordered import ordinal`, + ); + } + importOrdinals.add(importOrdinal); + previousImportOrdinal = importOrdinal; + globals.push({ + module: moduleName, + name: fieldName, + importOrdinal, + ownerId, + typeCode, + mutable: (flags & WPK_FORK_IMPORTED_GLOBALS_FLAG_MUTABLE) !== 0, + shared: (flags & WPK_FORK_IMPORTED_GLOBALS_FLAG_SHARED) !== 0, + }); + offset += recordSize; + } + if (offset !== bytes.byteLength) { + throw new Error("imported-global descriptor has trailing bytes"); + } + return globals; +} + +const IMPORTED_TABLE_ELEMENT_TYPE_CODES = new Set([ + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF, +]); + +/** + * Read exact import-section coordinates for every application-owned table. + * + * A table import is an identity edge in the module graph, not just an initial + * sequence of elements. The fresh child must wire that edge before + * instantiation so aliases, active element initializers, and exported imported + * tables all observe the same reconstructed Table object. + */ +export function readForkImportedTables( + module: WebAssembly.Module, +): readonly ForkImportedTableState[] { + const sections = WebAssembly.Module.customSections( + module, + WPK_FORK_IMPORTED_TABLES_SECTION, + ); + if (sections.length !== 1) { + throw new Error( + `expected one ${WPK_FORK_IMPORTED_TABLES_SECTION} section, found ${sections.length}`, + ); + } + const bytes = new Uint8Array(sections[0]!); + if (bytes.byteLength < WPK_FORK_IMPORTED_TABLES_HEADER_SIZE) { + throw new Error("imported-table descriptor is truncated"); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if ( + view.getUint32(0, true) + !== littleEndianMagic(WPK_FORK_IMPORTED_TABLES_MAGIC) + ) { + throw new Error("imported-table descriptor has the wrong magic"); + } + if (view.getUint16(4, true) !== WPK_FORK_IMPORTED_TABLES_VERSION) { + throw new Error( + `unsupported imported-table descriptor version ${view.getUint16(4, true)}`, + ); + } + if (view.getUint16(6, true) !== WPK_FORK_IMPORTED_TABLES_HEADER_SIZE) { + throw new Error("imported-table descriptor declares an invalid header size"); + } + const count = view.getUint32(8, true); + if (view.getUint32(12, true) !== 0) { + throw new Error("imported-table descriptor reserved field is nonzero"); + } + + const decoder = new TextDecoder("utf-8", { fatal: true }); + const owners = new Set(); + const importOrdinals = new Set(); + const tables: ForkImportedTableState[] = []; + let previousImportOrdinal = -1; + let offset = WPK_FORK_IMPORTED_TABLES_HEADER_SIZE; + for (let index = 0; index < count; index++) { + if (offset + WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE > bytes.byteLength) { + throw new Error(`imported-table record ${index} header is truncated`); + } + const recordSize = view.getUint32(offset, true); + const ownerId = view.getUint32(offset + 4, true); + const typeCode = view.getUint8(offset + 8); + const flags = view.getUint8(offset + 9); + const moduleLength = view.getUint32(offset + 12, true); + const nameLength = view.getUint32(offset + 16, true); + const importOrdinal = view.getUint32(offset + 20, true); + const expectedSize = WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE + + moduleLength + + nameLength; + if ( + recordSize !== expectedSize + || recordSize < WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE + || offset + recordSize > bytes.byteLength + ) { + throw new Error(`imported-table record ${index} has invalid bounds`); + } + checkedU32(ownerId, `imported-table record ${index} owner`, false); + if (owners.has(ownerId)) { + throw new Error(`imported-table record ${index} duplicates owner ${ownerId}`); + } + owners.add(ownerId); + if (!IMPORTED_TABLE_ELEMENT_TYPE_CODES.has(typeCode)) { + throw new Error( + `imported-table record ${index} has unknown element type ${typeCode}`, + ); + } + if ((flags & ~WPK_FORK_IMPORTED_TABLES_KNOWN_FLAGS) !== 0) { + throw new Error( + `imported-table record ${index} has unknown flags 0x${flags.toString(16)}`, + ); + } + if (view.getUint16(offset + 10, true) !== 0) { + throw new Error(`imported-table record ${index} reserved field is nonzero`); + } + const namesOffset = offset + WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE; + let moduleName: string; + let fieldName: string; + try { + moduleName = decoder.decode( + bytes.subarray(namesOffset, namesOffset + moduleLength), + ); + fieldName = decoder.decode( + bytes.subarray( + namesOffset + moduleLength, + namesOffset + moduleLength + nameLength, + ), + ); + } catch { + throw new Error(`imported-table record ${index} contains invalid UTF-8`); + } + if ( + importOrdinals.has(importOrdinal) + || importOrdinal <= previousImportOrdinal + ) { + throw new Error( + `imported-table record ${index} has duplicated or unordered import ordinal`, + ); + } + importOrdinals.add(importOrdinal); + previousImportOrdinal = importOrdinal; + tables.push({ + module: moduleName, + name: fieldName, + importOrdinal, + ownerId, + typeCode, + table64: (flags & WPK_FORK_IMPORTED_TABLES_FLAG_TABLE64) !== 0, + }); + offset += recordSize; + } + if (offset !== bytes.byteLength) { + throw new Error("imported-table descriptor has trailing bytes"); + } + return tables; +} + +/** + * Publish the arena root in the second pointer word of the module prefix. + * + * WHY: the first word is the activation-frame cursor. The linked runtime has + * reserved the `+P` word since ABI 42, so using it gives copied module state an + * activation-owned root without changing activation-frame replay ordering. + */ +export function writeForkModuleStateRoot( + memory: WebAssembly.Memory, + moduleBufferAddr: number, + ptrWidth: 4 | 8, + arenaRoot: number, +): void { + const moduleBuffer = checkedPointer( + moduleBufferAddr, + ptrWidth, + "module-state module buffer", + false, + ); + const root = checkedPointer(arenaRoot, ptrWidth, "module-state arena root", true); + if (root !== 0 && root % WASM_PAGE_SIZE !== 0) { + throw new RangeError("module-state arena root must be page-aligned"); + } + writePointer( + memory, + ptrWidth, + moduleBuffer + FORK_MODULE_STATE_ROOT_POINTER_WORD_OFFSET * ptrWidth, + root, + ); +} + +export function readForkModuleStateRoot( + memory: WebAssembly.Memory, + moduleBufferAddr: number, + ptrWidth: 4 | 8, +): number { + const moduleBuffer = checkedPointer( + moduleBufferAddr, + ptrWidth, + "module-state module buffer", + false, + ); + const root = readPointer( + memory, + ptrWidth, + moduleBuffer + FORK_MODULE_STATE_ROOT_POINTER_WORD_OFFSET * ptrWidth, + "module-state root-prefix pointer", + ); + if (root !== 0 && root % WASM_PAGE_SIZE !== 0) { + throw new Error("module-state root-prefix pointer is not page-aligned"); + } + return root; +} + +function encodeModulePayload(record: ForkModuleDescriptorRecord): Uint8Array { + checkedU32(record.activationId, "module activation id"); + if (record.templateId.byteLength !== MODULE_TEMPLATE_ID_SIZE) { + throw new RangeError( + `module template id has ${record.templateId.byteLength} bytes, ` + + `expected ${MODULE_TEMPLATE_ID_SIZE}`, + ); + } + const flags = checkedU32(record.flags ?? 0, "module flags"); + if ((flags & ~MODULE_RECORD_KNOWN_FLAGS) !== 0) { + throw new RangeError(`unknown module-state module flags 0x${flags.toString(16)}`); + } + const payload = new Uint8Array(MODULE_RECORD_PAYLOAD_SIZE); + payload.set(record.templateId, 0); + const view = new DataView(payload.buffer); + view.setUint32(MODULE_TEMPLATE_ID_SIZE, flags, true); + view.setUint32(MODULE_TEMPLATE_ID_SIZE + 4, 0, true); + return payload; +} + +function decodeModulePayload(payload: Uint8Array, context: string): void { + if (payload.byteLength !== MODULE_RECORD_PAYLOAD_SIZE) { + throw new Error( + `${context}: module payload has ${payload.byteLength} bytes, ` + + `expected ${MODULE_RECORD_PAYLOAD_SIZE}`, + ); + } + const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); + const flags = view.getUint32(MODULE_TEMPLATE_ID_SIZE, true); + if ((flags & ~MODULE_RECORD_KNOWN_FLAGS) !== 0) { + throw new Error(`${context}: unknown module flags 0x${flags.toString(16)}`); + } + if (view.getUint32(MODULE_TEMPLATE_ID_SIZE + 4, true) !== 0) { + throw new Error(`${context}: module payload reserved field is nonzero`); + } +} + +function encodeTableDescriptor(snapshot: ForkSparseTableSnapshot): Uint8Array { + const indexWidth = snapshot.indexWidth; + if (indexWidth !== 4 && indexWidth !== 8) { + throw new RangeError(`table index width ${String(indexWidth)} is unsupported`); + } + if ( + !Number.isInteger(snapshot.pageShift) + || snapshot.pageShift < MIN_TABLE_PAGE_SHIFT + || snapshot.pageShift > MAX_TABLE_PAGE_SHIFT + ) { + throw new RangeError( + `table page shift must be ${MIN_TABLE_PAGE_SHIFT}..${MAX_TABLE_PAGE_SHIFT}`, + ); + } + const length = checkedU64(snapshot.length, "table length"); + const baselineLength = checkedU64(snapshot.baselineLength, "table baseline length"); + if (indexWidth === 4 && length > 0xffff_ffffn) { + throw new RangeError("table32 length exceeds u32"); + } + if (baselineLength > length) { + throw new RangeError("table baseline length exceeds final length"); + } + if ( + snapshot.baselineFingerprint.byteLength + !== FORK_MODULE_STATE_BASELINE_FINGERPRINT_SIZE + ) { + throw new RangeError( + `table baseline fingerprint must be ` + + `${FORK_MODULE_STATE_BASELINE_FINGERPRINT_SIZE} bytes`, + ); + } + checkedU32(snapshot.pages.length, "table page count"); + const payload = new Uint8Array(TABLE_DESCRIPTOR_PAYLOAD_SIZE); + const view = new DataView(payload.buffer); + view.setUint8(0, indexWidth); + view.setUint8(1, snapshot.pageShift); + view.setUint16(2, TABLE_FLAG_SPARSE_OVERRIDES, true); + view.setUint32(4, snapshot.pages.length, true); + view.setBigUint64(8, length, true); + view.setBigUint64(16, baselineLength, true); + payload.set(snapshot.baselineFingerprint, 24); + return payload; +} + +function decodeTableDescriptor( + record: ForkModuleStateRecord, + context: string, +): DecodedTableDescriptor { + if (record.payload.byteLength !== TABLE_DESCRIPTOR_PAYLOAD_SIZE) { + throw new Error( + `${context}: table descriptor has ${record.payload.byteLength} bytes, ` + + `expected ${TABLE_DESCRIPTOR_PAYLOAD_SIZE}`, + ); + } + const view = new DataView( + record.payload.buffer, + record.payload.byteOffset, + record.payload.byteLength, + ); + const indexWidth = view.getUint8(0); + if (indexWidth !== 4 && indexWidth !== 8) { + throw new Error(`${context}: unsupported table index width ${indexWidth}`); + } + const pageShift = view.getUint8(1); + if (pageShift < MIN_TABLE_PAGE_SHIFT || pageShift > MAX_TABLE_PAGE_SHIFT) { + throw new Error(`${context}: unsupported table page shift ${pageShift}`); + } + const flags = view.getUint16(2, true); + if ( + (flags & ~TABLE_KNOWN_FLAGS) !== 0 + || (flags & TABLE_FLAG_SPARSE_OVERRIDES) === 0 + ) { + throw new Error(`${context}: invalid table flags 0x${flags.toString(16)}`); + } + const length = view.getBigUint64(8, true); + const baselineLength = view.getBigUint64(16, true); + if (indexWidth === 4 && length > 0xffff_ffffn) { + throw new Error(`${context}: table32 length exceeds u32`); + } + if (baselineLength > length) { + throw new Error(`${context}: table baseline length exceeds final length`); + } + return { + activationId: record.activationId, + ownerId: record.ownerId, + indexWidth, + pageShift, + flags, + pageCount: view.getUint32(4, true), + length, + baselineLength, + baselineFingerprint: record.payload.slice(24, 56), + }; +} + +function validateSparseTablePage( + descriptor: DecodedTableDescriptor, + page: ForkSparseTablePage, + previousPageIndex: bigint | null, +): ValidatedSparseTablePage { + const pageIndex = checkedU64(page.pageIndex, "table page index"); + if (previousPageIndex !== null && pageIndex <= previousPageIndex) { + throw new RangeError("sparse table pages must be strictly increasing"); + } + checkedU32(page.runs.length, "table page run count"); + const pageSize = 1 << descriptor.pageShift; + let previousEnd = 0; + let entryCount = 0; + let payloadSize = TABLE_PAGE_HEADER_SIZE; + for (const [runIndex, run] of page.runs.entries()) { + if (!Number.isInteger(run.start) || run.start < previousEnd || run.start >= pageSize) { + throw new RangeError(`table page run ${runIndex} is unordered or out of bounds`); + } + if (run.recipeIds.length === 0) { + throw new RangeError(`table page run ${runIndex} is empty`); + } + checkedU32(run.recipeIds.length, `table page run ${runIndex} length`, false); + const end = run.start + run.recipeIds.length; + if (!Number.isSafeInteger(end) || end > pageSize) { + throw new RangeError(`table page run ${runIndex} exceeds its page`); + } + const absoluteEnd = pageIndex * BigInt(pageSize) + BigInt(end); + if (absoluteEnd > descriptor.length) { + throw new RangeError(`table page run ${runIndex} exceeds final table length`); + } + for (let entryIndex = 0; entryIndex < run.recipeIds.length; entryIndex++) { + checkedU32( + run.recipeIds[entryIndex]!, + `table page run ${runIndex} recipe ${entryIndex}`, + ); + } + previousEnd = end; + entryCount += run.recipeIds.length; + payloadSize += TABLE_RUN_HEADER_SIZE + run.recipeIds.length * 4; + } + if (entryCount === 0) { + throw new RangeError("sparse table page must contain at least one override"); + } + checkedU32(entryCount, "table page entry count"); + return { pageIndex, entryCount, payloadSize }; +} + +function encodeTablePage( + descriptor: DecodedTableDescriptor, + page: ForkSparseTablePage, + previousPageIndex: bigint | null, +): Uint8Array { + const validated = validateSparseTablePage( + descriptor, + page, + previousPageIndex, + ); + const payload = new Uint8Array(validated.payloadSize); + const view = new DataView(payload.buffer); + view.setBigUint64(0, validated.pageIndex, true); + view.setUint32(8, page.runs.length, true); + view.setUint32(12, validated.entryCount, true); + let offset = TABLE_PAGE_HEADER_SIZE; + for (const run of page.runs) { + view.setUint32(offset, run.start, true); + view.setUint32(offset + 4, run.recipeIds.length, true); + offset += TABLE_RUN_HEADER_SIZE; + for (const recipeId of run.recipeIds) { + view.setUint32(offset, recipeId, true); + offset += 4; + } + } + return payload; +} + +function validateTablePage( + record: ForkModuleStateRecord, + descriptor: DecodedTableDescriptor, + context: string, +): ValidatedTablePage { + if (record.payload.byteLength < TABLE_PAGE_HEADER_SIZE) { + throw new Error(`${context}: table page payload is truncated`); + } + const view = new DataView( + record.payload.buffer, + record.payload.byteOffset, + record.payload.byteLength, + ); + const pageIndex = view.getBigUint64(0, true); + const runCount = view.getUint32(8, true); + const declaredEntryCount = view.getUint32(12, true); + const pageSize = 1 << descriptor.pageShift; + let previousEnd = 0; + let entryCount = 0; + let offset = TABLE_PAGE_HEADER_SIZE; + for (let runIndex = 0; runIndex < runCount; runIndex++) { + if (offset + TABLE_RUN_HEADER_SIZE > record.payload.byteLength) { + throw new Error(`${context}: table page run ${runIndex} header is truncated`); + } + const start = view.getUint32(offset, true); + const count = view.getUint32(offset + 4, true); + offset += TABLE_RUN_HEADER_SIZE; + if (count === 0 || start < previousEnd || start >= pageSize || start + count > pageSize) { + throw new Error(`${context}: table page run ${runIndex} is unordered or out of bounds`); + } + if (offset + count * 4 > record.payload.byteLength) { + throw new Error(`${context}: table page run ${runIndex} recipes are truncated`); + } + const absoluteEnd = pageIndex * BigInt(pageSize) + BigInt(start + count); + if (absoluteEnd > descriptor.length) { + throw new Error(`${context}: table page run ${runIndex} exceeds final table length`); + } + // WHY: attachment only needs structural validity and page ordering. Read + // each recipe in place so validating an arbitrarily long segmented table + // does not allocate and immediately discard one Uint32Array per run. + for (let index = 0; index < count; index++) { + view.getUint32(offset, true); + offset += 4; + } + previousEnd = start + count; + entryCount += count; + } + if ( + runCount === 0 + || entryCount !== declaredEntryCount + || offset !== record.payload.byteLength + ) { + throw new Error(`${context}: table page counts or payload size are inconsistent`); + } + return { pageIndex, runCount, entryCount }; +} + +function decodeTablePage( + record: ForkModuleStateRecord, + descriptor: DecodedTableDescriptor, + context: string, +): DecodedTablePage { + const validated = validateTablePage(record, descriptor, context); + const view = new DataView( + record.payload.buffer, + record.payload.byteOffset, + record.payload.byteLength, + ); + let offset = TABLE_PAGE_HEADER_SIZE; + const runs: DecodedForkSparseTableRun[] = []; + for (let runIndex = 0; runIndex < validated.runCount; runIndex++) { + const start = view.getUint32(offset, true); + const count = view.getUint32(offset + 4, true); + offset += TABLE_RUN_HEADER_SIZE; + const recipeIds = new Uint32Array(count); + for (let index = 0; index < count; index++) { + recipeIds[index] = view.getUint32(offset, true); + offset += 4; + } + runs.push({ start, recipeIds }); + } + return { + activationId: record.activationId, + ownerId: record.ownerId, + pageIndex: validated.pageIndex, + runs, + entryCount: validated.entryCount, + }; +} + +function encodeSegmentBitmap( + state: ForkElementSegmentState | ForkDataSegmentState, + label: "element" | "data", + headerSize: number, +): Uint8Array { + checkedU32(state.segmentCount, `${label} segment count`); + const expectedBytes = Math.ceil(state.segmentCount / 8); + if (state.dropped.byteLength !== expectedBytes) { + throw new RangeError( + `${label} drop bitmap has ${state.dropped.byteLength} bytes, expected ${expectedBytes}`, + ); + } + if (expectedBytes > 0 && state.segmentCount % 8 !== 0) { + const liveBits = state.segmentCount % 8; + const invalidMask = 0xff << liveBits; + if ((state.dropped[expectedBytes - 1]! & invalidMask) !== 0) { + throw new RangeError(`${label} drop bitmap has nonzero bits beyond segment count`); + } + } + const payload = new Uint8Array(headerSize + expectedBytes); + const view = new DataView(payload.buffer); + view.setUint32(0, state.segmentCount, true); + view.setUint32(4, expectedBytes, true); + payload.set(state.dropped, headerSize); + return payload; +} + +function decodeSegmentBitmap( + payload: Uint8Array, + context: string, + label: "element" | "data", + headerSize: number, +): void { + if (payload.byteLength < headerSize) { + throw new Error(`${context}: ${label}-segment payload is truncated`); + } + const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); + const segmentCount = view.getUint32(0, true); + const bitmapBytes = view.getUint32(4, true); + const expectedBytes = Math.ceil(segmentCount / 8); + if ( + bitmapBytes !== expectedBytes + || payload.byteLength !== headerSize + expectedBytes + ) { + throw new Error(`${context}: ${label}-segment bitmap size is inconsistent`); + } + if (expectedBytes > 0 && segmentCount % 8 !== 0) { + const invalidMask = 0xff << (segmentCount % 8); + if ((payload[payload.byteLength - 1]! & invalidMask) !== 0) { + throw new Error(`${context}: ${label}-segment bitmap has nonzero trailing bits`); + } + } +} + +function encodeElementSegments(state: ForkElementSegmentState): Uint8Array { + return encodeSegmentBitmap(state, "element", ELEMENT_SEGMENT_HEADER_SIZE); +} + +function encodeDataSegments(state: ForkDataSegmentState): Uint8Array { + return encodeSegmentBitmap(state, "data", DATA_SEGMENT_HEADER_SIZE); +} + +function decodeElementSegments(payload: Uint8Array, context: string): void { + decodeSegmentBitmap(payload, context, "element", ELEMENT_SEGMENT_HEADER_SIZE); +} + +function decodeDataSegments(payload: Uint8Array, context: string): void { + decodeSegmentBitmap(payload, context, "data", DATA_SEGMENT_HEADER_SIZE); +} + +function assertActivationContinuationSet( + continuations: readonly ForkActivationContinuation[], + activeActivationIds: ReadonlySet, + context: string, +): void { + const expected = [...activeActivationIds].sort((left, right) => left - right); + const actual = continuations.map(({ activationId }) => activationId); + if ( + actual.length !== expected.length + || actual.some((activationId, index) => activationId !== expected[index]) + ) { + throw new Error( + `${context}: activation set does not exactly match replay events ` + + `(continuations ${actual.join(",")}; replay ${expected.join(",")})`, + ); + } +} + +/** + * Encode the copied continuation root for every activation participating in + * this fork. Roots are fixed-width u64 values so one process record can name + * wasm32 and wasm64 activations without an archive-private side channel. + */ +export function encodeForkActivationContinuations( + continuations: readonly ForkActivationContinuation[], +): Uint8Array { + if (continuations.length === 0) { + throw new Error("activation-continuation manifest must not be empty"); + } + if (continuations.length > 0xffff_ffff) { + throw new RangeError("activation-continuation count exceeds u32"); + } + const payloadSize = WPK_FORK_ACTIVATION_CONTINUATIONS_HEADER_SIZE + + continuations.length * WPK_FORK_ACTIVATION_CONTINUATIONS_ENTRY_SIZE; + if (!Number.isSafeInteger(payloadSize)) { + throw new RangeError( + "activation-continuation payload size exceeds JavaScript safe integer", + ); + } + const payload = new Uint8Array(payloadSize); + const view = new DataView(payload.buffer); + view.setUint32( + 0, + littleEndianMagic(WPK_FORK_ACTIVATION_CONTINUATIONS_MAGIC), + true, + ); + view.setUint16(4, WPK_FORK_ACTIVATION_CONTINUATIONS_VERSION, true); + view.setUint16(6, WPK_FORK_ACTIVATION_CONTINUATIONS_HEADER_SIZE, true); + view.setUint16(8, WPK_FORK_ACTIVATION_CONTINUATIONS_ENTRY_SIZE, true); + view.setUint16(10, WPK_FORK_ACTIVATION_CONTINUATIONS_KNOWN_FLAGS, true); + view.setUint32(12, continuations.length, true); + view.setBigUint64(16, 0n, true); + + let previousActivation = -1; + for (const [index, continuation] of continuations.entries()) { + checkedU32( + continuation.activationId, + `activation continuation ${index} activation`, + ); + if (continuation.activationId <= previousActivation) { + throw new Error( + `activation continuation ${index}: activations must be unique and strictly ordered`, + ); + } + const root = checkedU64( + continuation.root, + `activation continuation ${index} root`, + ); + if (root === 0n) { + throw new RangeError(`activation continuation ${index} root is zero`); + } + const offset = WPK_FORK_ACTIVATION_CONTINUATIONS_HEADER_SIZE + + index * WPK_FORK_ACTIVATION_CONTINUATIONS_ENTRY_SIZE; + view.setUint32(offset, continuation.activationId, true); + view.setUint32( + offset + 4, + WPK_FORK_ACTIVATION_CONTINUATIONS_ENTRY_KNOWN_FLAGS, + true, + ); + view.setBigUint64(offset + 8, root, true); + previousActivation = continuation.activationId; + } + return payload; +} + +export function decodeForkActivationContinuations( + payload: Uint8Array, + context = "module-state activation continuations", +): ForkActivationContinuation[] { + if (payload.byteLength < WPK_FORK_ACTIVATION_CONTINUATIONS_HEADER_SIZE) { + throw new Error(`${context}: payload is truncated`); + } + const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); + if ( + view.getUint32(0, true) + !== littleEndianMagic(WPK_FORK_ACTIVATION_CONTINUATIONS_MAGIC) + ) { + throw new Error(`${context}: wrong magic`); + } + const version = view.getUint16(4, true); + if (version !== WPK_FORK_ACTIVATION_CONTINUATIONS_VERSION) { + throw new Error(`${context}: unsupported version ${version}`); + } + if ( + view.getUint16(6, true) !== WPK_FORK_ACTIVATION_CONTINUATIONS_HEADER_SIZE + || view.getUint16(8, true) !== WPK_FORK_ACTIVATION_CONTINUATIONS_ENTRY_SIZE + ) { + throw new Error(`${context}: header or entry size is inconsistent`); + } + const flags = view.getUint16(10, true); + if ((flags & ~WPK_FORK_ACTIVATION_CONTINUATIONS_KNOWN_FLAGS) !== 0) { + throw new Error(`${context}: unknown flags 0x${flags.toString(16)}`); + } + const count = view.getUint32(12, true); + if (view.getBigUint64(16, true) !== 0n) { + throw new Error(`${context}: reserved header field is nonzero`); + } + const expectedSize = WPK_FORK_ACTIVATION_CONTINUATIONS_HEADER_SIZE + + count * WPK_FORK_ACTIVATION_CONTINUATIONS_ENTRY_SIZE; + if (payload.byteLength !== expectedSize) { + throw new Error(`${context}: entry count is inconsistent with payload size`); + } + if (count === 0) { + throw new Error(`${context}: manifest is empty`); + } + + const continuations: ForkActivationContinuation[] = []; + let previousActivation = -1; + for (let index = 0; index < count; index++) { + const offset = WPK_FORK_ACTIVATION_CONTINUATIONS_HEADER_SIZE + + index * WPK_FORK_ACTIVATION_CONTINUATIONS_ENTRY_SIZE; + const activationId = view.getUint32(offset, true); + const entryFlags = view.getUint32(offset + 4, true); + if ( + (entryFlags & ~WPK_FORK_ACTIVATION_CONTINUATIONS_ENTRY_KNOWN_FLAGS) !== 0 + ) { + throw new Error( + `${context} entry ${index}: unknown flags 0x${entryFlags.toString(16)}`, + ); + } + if (activationId <= previousActivation) { + throw new Error( + `${context} entry ${index}: activations are duplicated or unordered`, + ); + } + const root = view.getBigUint64(offset + 8, true); + if (root === 0n) { + throw new Error(`${context} entry ${index}: continuation root is zero`); + } + continuations.push({ activationId, root }); + previousActivation = activationId; + } + return continuations; +} + +const IMPORTED_GLOBAL_BINDING_KINDS = new Set( + Object.values(ForkImportedGlobalBindingKind), +); + +function importedGlobalBindingFlags(binding: ForkImportedGlobalBinding): number { + return (binding.mutable ? WPK_FORK_IMPORTED_GLOBALS_FLAG_MUTABLE : 0) + | (binding.shared ? WPK_FORK_IMPORTED_GLOBALS_FLAG_SHARED : 0); +} + +function validateImportedGlobalBinding( + binding: ForkImportedGlobalBinding, + context: string, +): void { + checkedU32(binding.consumerActivation, `${context} consumer activation`); + checkedU32(binding.consumerOwner, `${context} consumer owner`, false); + checkedU32(binding.sourceActivation, `${context} source activation`); + checkedU32(binding.sourceOwner, `${context} source owner`); + checkedU32(binding.reserved, `${context} reserved field`); + checkedU32(binding.recipeId, `${context} recipe id`); + checkedU64(binding.rawBits, `${context} raw bits`); + if (!IMPORTED_GLOBAL_BINDING_KINDS.has(binding.kind)) { + throw new Error(`${context}: unknown binding kind ${binding.kind}`); + } + if (!IMPORTED_GLOBAL_TYPE_CODES.has(binding.typeCode)) { + throw new Error(`${context}: unknown value type ${binding.typeCode}`); + } + + const zeroSource = (): boolean => + binding.sourceActivation === 0 && binding.sourceOwner === 0; + const zeroRaw = (): boolean => binding.rawBits === 0n; + switch (binding.kind) { + case ForkImportedGlobalBindingKind.RawNumber: + case ForkImportedGlobalBindingKind.RawBigInt: + if (!zeroSource() || binding.reserved !== 0 || binding.recipeId !== 0) { + throw new Error(`${context}: raw scalar binding has nonzero owner fields`); + } + break; + case ForkImportedGlobalBindingKind.RawReference: + if (!zeroSource() || binding.reserved !== 0 || !zeroRaw()) { + throw new Error(`${context}: raw reference binding has nonzero owner fields`); + } + break; + case ForkImportedGlobalBindingKind.ActivationGlobal: + if ( + binding.sourceOwner === 0 + || binding.reserved !== 0 + || binding.recipeId !== 0 + || !zeroRaw() + ) { + throw new Error(`${context}: activation-global binding fields are inconsistent`); + } + break; + case ForkImportedGlobalBindingKind.BaseImport: + if ( + binding.reserved !== 0 + || !zeroSource() + || binding.recipeId !== 0 + || !zeroRaw() + ) { + throw new Error(`${context}: base-import binding fields are inconsistent`); + } + break; + } +} + +/** + * Encode the process-wide provenance of every imported global declaration. + * + * Entries are declaration-owned, while repeated `(module,name)` properties + * intentionally carry the same raw recipe/provider coordinates. This keeps + * independent Wasm coercions valid without losing the JavaScript binding that + * produced them. + */ +export function encodeForkImportedGlobalBindings( + bindings: readonly ForkImportedGlobalBinding[], +): Uint8Array { + if (bindings.length > 0xffff_ffff) { + throw new RangeError("imported-global binding count exceeds u32"); + } + const payloadSize = WPK_FORK_IMPORTED_GLOBAL_BINDINGS_HEADER_SIZE + + bindings.length * WPK_FORK_IMPORTED_GLOBAL_BINDINGS_ENTRY_SIZE; + if (!Number.isSafeInteger(payloadSize)) { + throw new RangeError( + "imported-global binding payload size exceeds JavaScript safe integer", + ); + } + const payload = new Uint8Array(payloadSize); + const view = new DataView(payload.buffer); + view.setUint32( + 0, + littleEndianMagic(WPK_FORK_IMPORTED_GLOBAL_BINDINGS_MAGIC), + true, + ); + view.setUint16(4, WPK_FORK_IMPORTED_GLOBAL_BINDINGS_VERSION, true); + view.setUint16(6, WPK_FORK_IMPORTED_GLOBAL_BINDINGS_HEADER_SIZE, true); + view.setUint16(8, WPK_FORK_IMPORTED_GLOBAL_BINDINGS_ENTRY_SIZE, true); + view.setUint16(10, WPK_FORK_IMPORTED_GLOBAL_BINDINGS_KNOWN_FLAGS, true); + view.setUint32(12, bindings.length, true); + view.setBigUint64(16, 0n, true); + + let previousKey = ""; + for (const [index, binding] of bindings.entries()) { + const context = `imported-global binding ${index}`; + validateImportedGlobalBinding(binding, context); + const key = `${binding.consumerActivation.toString(16).padStart(8, "0")}:` + + binding.consumerOwner.toString(16).padStart(8, "0"); + if (key <= previousKey) { + throw new Error( + `${context}: consumer declarations must be unique and strictly ordered`, + ); + } + previousKey = key; + const offset = WPK_FORK_IMPORTED_GLOBAL_BINDINGS_HEADER_SIZE + + index * WPK_FORK_IMPORTED_GLOBAL_BINDINGS_ENTRY_SIZE; + view.setUint32(offset, binding.consumerActivation, true); + view.setUint32(offset + 4, binding.consumerOwner, true); + view.setUint32(offset + 8, binding.sourceActivation, true); + view.setUint32(offset + 12, binding.sourceOwner, true); + view.setUint32(offset + 16, binding.reserved, true); + view.setUint32(offset + 20, binding.recipeId, true); + view.setBigUint64(offset + 24, binding.rawBits, true); + view.setUint8(offset + 32, binding.kind); + view.setUint8(offset + 33, importedGlobalBindingFlags(binding)); + view.setUint8(offset + 34, binding.typeCode); + } + return payload; +} + +export function decodeForkImportedGlobalBindings( + payload: Uint8Array, + context = "module-state imported-global bindings", +): ForkImportedGlobalBinding[] { + if (payload.byteLength < WPK_FORK_IMPORTED_GLOBAL_BINDINGS_HEADER_SIZE) { + throw new Error(`${context}: payload is truncated`); + } + const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); + if ( + view.getUint32(0, true) + !== littleEndianMagic(WPK_FORK_IMPORTED_GLOBAL_BINDINGS_MAGIC) + ) { + throw new Error(`${context}: wrong magic`); + } + if (view.getUint16(4, true) !== WPK_FORK_IMPORTED_GLOBAL_BINDINGS_VERSION) { + throw new Error(`${context}: unsupported version ${view.getUint16(4, true)}`); + } + if ( + view.getUint16(6, true) !== WPK_FORK_IMPORTED_GLOBAL_BINDINGS_HEADER_SIZE + || view.getUint16(8, true) !== WPK_FORK_IMPORTED_GLOBAL_BINDINGS_ENTRY_SIZE + ) { + throw new Error(`${context}: header or entry size is inconsistent`); + } + const flags = view.getUint16(10, true); + if ((flags & ~WPK_FORK_IMPORTED_GLOBAL_BINDINGS_KNOWN_FLAGS) !== 0) { + throw new Error(`${context}: unknown flags 0x${flags.toString(16)}`); + } + const count = view.getUint32(12, true); + if (view.getBigUint64(16, true) !== 0n) { + throw new Error(`${context}: reserved header field is nonzero`); + } + const expectedSize = WPK_FORK_IMPORTED_GLOBAL_BINDINGS_HEADER_SIZE + + count * WPK_FORK_IMPORTED_GLOBAL_BINDINGS_ENTRY_SIZE; + if (payload.byteLength !== expectedSize) { + throw new Error(`${context}: entry count is inconsistent with payload size`); + } + + const bindings: ForkImportedGlobalBinding[] = []; + let previousKey = ""; + for (let index = 0; index < count; index++) { + const offset = WPK_FORK_IMPORTED_GLOBAL_BINDINGS_HEADER_SIZE + + index * WPK_FORK_IMPORTED_GLOBAL_BINDINGS_ENTRY_SIZE; + if (view.getUint8(offset + 35) !== 0 || view.getUint32(offset + 36, true) !== 0) { + throw new Error(`${context} entry ${index}: reserved fields are nonzero`); + } + const entryFlags = view.getUint8(offset + 33); + if ((entryFlags & ~WPK_FORK_IMPORTED_GLOBALS_KNOWN_FLAGS) !== 0) { + throw new Error( + `${context} entry ${index}: unknown flags 0x${entryFlags.toString(16)}`, + ); + } + const binding: ForkImportedGlobalBinding = { + consumerActivation: view.getUint32(offset, true), + consumerOwner: view.getUint32(offset + 4, true), + sourceActivation: view.getUint32(offset + 8, true), + sourceOwner: view.getUint32(offset + 12, true), + reserved: view.getUint32(offset + 16, true), + recipeId: view.getUint32(offset + 20, true), + rawBits: view.getBigUint64(offset + 24, true), + kind: view.getUint8(offset + 32) as ForkImportedGlobalBindingKind, + mutable: (entryFlags & WPK_FORK_IMPORTED_GLOBALS_FLAG_MUTABLE) !== 0, + shared: (entryFlags & WPK_FORK_IMPORTED_GLOBALS_FLAG_SHARED) !== 0, + typeCode: view.getUint8(offset + 34), + }; + validateImportedGlobalBinding(binding, `${context} entry ${index}`); + const key = `${binding.consumerActivation.toString(16).padStart(8, "0")}:` + + binding.consumerOwner.toString(16).padStart(8, "0"); + if (key <= previousKey) { + throw new Error( + `${context} entry ${index}: consumer declarations are duplicated or unordered`, + ); + } + previousKey = key; + bindings.push(binding); + } + return bindings; +} + +const IMPORTED_TABLE_BINDING_KINDS = new Set( + Object.values(ForkImportedTableBindingKind), +); + +function validateImportedTableBinding( + binding: ForkImportedTableBinding, + context: string, +): void { + checkedU32(binding.consumerActivation, `${context} consumer activation`); + checkedU32(binding.consumerOwner, `${context} consumer owner`, false); + checkedU32(binding.sourceActivation, `${context} source activation`); + checkedU32(binding.sourceOwner, `${context} source owner`); + checkedU32(binding.reserved, `${context} reserved field`); + if (!IMPORTED_TABLE_BINDING_KINDS.has(binding.kind)) { + throw new Error(`${context}: unknown binding kind ${binding.kind}`); + } + switch (binding.kind) { + case ForkImportedTableBindingKind.ActivationTable: + if (binding.sourceOwner === 0 || binding.reserved !== 0) { + throw new Error(`${context}: activation-table binding fields are inconsistent`); + } + break; + case ForkImportedTableBindingKind.BaseImport: + if ( + binding.sourceActivation !== 0 + || binding.sourceOwner !== 0 + || binding.reserved !== 0 + ) { + throw new Error(`${context}: base-import binding fields are inconsistent`); + } + break; + } +} + +/** + * Encode the process-wide identity graph for imported Table declarations. + * + * KFMS table records own element contents. This manifest separately owns + * pre-instantiation identity: every consumer declaration is wired either to a + * table exported by another activation or re-resolved through the child's + * exact base import builder. The split avoids copying a full table per + * activation while preserving aliases. + */ +export function encodeForkImportedTableBindings( + bindings: readonly ForkImportedTableBinding[], +): Uint8Array { + if (bindings.length > 0xffff_ffff) { + throw new RangeError("imported-table binding count exceeds u32"); + } + const payloadSize = WPK_FORK_IMPORTED_TABLE_BINDINGS_HEADER_SIZE + + bindings.length * WPK_FORK_IMPORTED_TABLE_BINDINGS_ENTRY_SIZE; + if (!Number.isSafeInteger(payloadSize)) { + throw new RangeError( + "imported-table binding payload size exceeds JavaScript safe integer", + ); + } + const payload = new Uint8Array(payloadSize); + const view = new DataView(payload.buffer); + view.setUint32( + 0, + littleEndianMagic(WPK_FORK_IMPORTED_TABLE_BINDINGS_MAGIC), + true, + ); + view.setUint16(4, WPK_FORK_IMPORTED_TABLE_BINDINGS_VERSION, true); + view.setUint16(6, WPK_FORK_IMPORTED_TABLE_BINDINGS_HEADER_SIZE, true); + view.setUint16(8, WPK_FORK_IMPORTED_TABLE_BINDINGS_ENTRY_SIZE, true); + view.setUint16(10, WPK_FORK_IMPORTED_TABLE_BINDINGS_KNOWN_FLAGS, true); + view.setUint32(12, bindings.length, true); + view.setBigUint64(16, 0n, true); + + let previousKey = ""; + for (const [index, binding] of bindings.entries()) { + const context = `imported-table binding ${index}`; + validateImportedTableBinding(binding, context); + const key = `${binding.consumerActivation.toString(16).padStart(8, "0")}:` + + binding.consumerOwner.toString(16).padStart(8, "0"); + if (key <= previousKey) { + throw new Error( + `${context}: consumer declarations must be unique and strictly ordered`, + ); + } + previousKey = key; + const offset = WPK_FORK_IMPORTED_TABLE_BINDINGS_HEADER_SIZE + + index * WPK_FORK_IMPORTED_TABLE_BINDINGS_ENTRY_SIZE; + view.setUint32(offset, binding.consumerActivation, true); + view.setUint32(offset + 4, binding.consumerOwner, true); + view.setUint32(offset + 8, binding.sourceActivation, true); + view.setUint32(offset + 12, binding.sourceOwner, true); + view.setUint32(offset + 16, binding.reserved, true); + view.setUint8(offset + 20, binding.kind); + } + return payload; +} + +export function decodeForkImportedTableBindings( + payload: Uint8Array, + context = "module-state imported-table bindings", +): ForkImportedTableBinding[] { + if (payload.byteLength < WPK_FORK_IMPORTED_TABLE_BINDINGS_HEADER_SIZE) { + throw new Error(`${context}: payload is truncated`); + } + const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); + if ( + view.getUint32(0, true) + !== littleEndianMagic(WPK_FORK_IMPORTED_TABLE_BINDINGS_MAGIC) + ) { + throw new Error(`${context}: wrong magic`); + } + if (view.getUint16(4, true) !== WPK_FORK_IMPORTED_TABLE_BINDINGS_VERSION) { + throw new Error(`${context}: unsupported version ${view.getUint16(4, true)}`); + } + if ( + view.getUint16(6, true) !== WPK_FORK_IMPORTED_TABLE_BINDINGS_HEADER_SIZE + || view.getUint16(8, true) !== WPK_FORK_IMPORTED_TABLE_BINDINGS_ENTRY_SIZE + ) { + throw new Error(`${context}: header or entry size is inconsistent`); + } + const flags = view.getUint16(10, true); + if ((flags & ~WPK_FORK_IMPORTED_TABLE_BINDINGS_KNOWN_FLAGS) !== 0) { + throw new Error(`${context}: unknown flags 0x${flags.toString(16)}`); + } + const count = view.getUint32(12, true); + if (view.getBigUint64(16, true) !== 0n) { + throw new Error(`${context}: reserved header field is nonzero`); + } + const expectedSize = WPK_FORK_IMPORTED_TABLE_BINDINGS_HEADER_SIZE + + count * WPK_FORK_IMPORTED_TABLE_BINDINGS_ENTRY_SIZE; + if (payload.byteLength !== expectedSize) { + throw new Error(`${context}: entry count is inconsistent with payload size`); + } + + const bindings: ForkImportedTableBinding[] = []; + let previousKey = ""; + for (let index = 0; index < count; index++) { + const offset = WPK_FORK_IMPORTED_TABLE_BINDINGS_HEADER_SIZE + + index * WPK_FORK_IMPORTED_TABLE_BINDINGS_ENTRY_SIZE; + if (view.getUint8(offset + 21) !== 0 || view.getUint16(offset + 22, true) !== 0) { + throw new Error(`${context} entry ${index}: reserved fields are nonzero`); + } + const binding: ForkImportedTableBinding = { + consumerActivation: view.getUint32(offset, true), + consumerOwner: view.getUint32(offset + 4, true), + sourceActivation: view.getUint32(offset + 8, true), + sourceOwner: view.getUint32(offset + 12, true), + reserved: view.getUint32(offset + 16, true), + kind: view.getUint8(offset + 20) as ForkImportedTableBindingKind, + }; + validateImportedTableBinding(binding, `${context} entry ${index}`); + const key = `${binding.consumerActivation.toString(16).padStart(8, "0")}:` + + binding.consumerOwner.toString(16).padStart(8, "0"); + if (key <= previousKey) { + throw new Error( + `${context} entry ${index}: consumer declarations are duplicated or unordered`, + ); + } + previousKey = key; + bindings.push(binding); + } + return bindings; +} + +export function importedGlobalBindingsForChild( + records: readonly ForkModuleStateRecord[], +): ForkImportedGlobalBinding[] { + const matches = records.filter( + (record) => record.kind === ForkModuleStateRecordKind.ImportedGlobalBindings, + ); + if (matches.length !== 1) { + throw new Error( + `module-state arena has ${matches.length} imported-global binding records; expected one`, + ); + } + const record = matches[0]!; + if ( + record.activationId !== 0 + || record.ownerId !== WPK_FORK_IMPORTED_GLOBAL_BINDINGS_OWNER + ) { + throw new Error("module-state imported-global bindings have invalid ownership"); + } + return decodeForkImportedGlobalBindings(record.payload); +} + +export function importedTableBindingsForChild( + records: readonly ForkModuleStateRecord[], +): ForkImportedTableBinding[] { + const matches = records.filter( + (record) => record.kind === ForkModuleStateRecordKind.ImportedTableBindings, + ); + if (matches.length !== 1) { + throw new Error( + `module-state arena has ${matches.length} imported-table binding records; expected one`, + ); + } + const record = matches[0]!; + if ( + record.activationId !== 0 + || record.ownerId !== WPK_FORK_IMPORTED_TABLE_BINDINGS_OWNER + ) { + throw new Error("module-state imported-table bindings have invalid ownership"); + } + return decodeForkImportedTableBindings(record.payload); +} + +export function activationContinuationsForChild( + records: readonly ForkModuleStateRecordView[], + ptrWidth: 4 | 8, +): ForkActivationContinuation[] { + const matches = records.filter( + (record) => record.kind === ForkModuleStateRecordKind.ActivationContinuations, + ); + if (matches.length !== 1) { + throw new Error( + `module-state arena has ${matches.length} activation-continuation records; expected one`, + ); + } + const record = matches[0]!; + if ( + record.activationId !== 0 + || record.ownerId !== WPK_FORK_ACTIVATION_CONTINUATIONS_OWNER + ) { + throw new Error("module-state activation continuations have invalid ownership"); + } + const continuations = decodeForkActivationContinuations(record.payload); + const replay = validateForkReplayEventWire(replayEventsForChild(records)); + assertActivationContinuationSet( + continuations, + replay.activationIds, + "module-state activation continuations", + ); + const maxRoot = ptrWidth === 4 ? 0xffff_ffffn : 0xffff_ffff_ffff_ffffn; + for (const continuation of continuations) { + if (continuation.root > maxRoot) { + throw new RangeError( + `module-state activation ${continuation.activationId} continuation root ` + + `does not fit wasm${ptrWidth * 8}`, + ); + } + } + return continuations; +} + +/** Select and validate the ordered process replay-event segment stream. */ +export function replayEventsForChild( + records: readonly ForkModuleStateRecordView[], +): ForkReplayEventWire { + let manifest: Uint8Array | null = null; + for (const record of records) { + if ( + record.kind !== ForkModuleStateRecordKind.ReplayEventSegment + && record.kind !== ForkModuleStateRecordKind.ReplayEvents + ) { + continue; + } + if ( + record.activationId !== 0 + || record.ownerId !== WPK_FORK_MODULE_STATE_REPLAY_EVENTS_OWNER + ) { + throw new Error("module-state replay events have invalid process ownership"); + } + if (record.kind === ForkModuleStateRecordKind.ReplayEventSegment) { + if (manifest) { + throw new Error( + "module-state replay-event segment follows its final manifest", + ); + } + continue; + } + if (manifest) { + throw new Error("module-state arena has duplicate process replay-event records"); + } + manifest = record.payload; + } + if (!manifest) { + throw new Error("module-state arena has no process replay-event manifest"); + } + const segments: Iterable = { + *[Symbol.iterator]() { + for (const record of records) { + if (record.kind === ForkModuleStateRecordKind.ReplayEventSegment) { + yield record.payload; + } + } + }, + }; + const wire = { manifest, segments }; + validateForkReplayEventWire(wire); + return wire; +} + +export function decodeForkGlobalSnapshot( + payload: Uint8Array, + context = "module-state global", +): ForkGlobalSnapshot { + if (payload.byteLength < GLOBAL_HEADER_SIZE) { + throw new Error(`${context}: mutable-global payload is truncated`); + } + const valueSizes = new Map([ + [WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I32, 4], + [WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I64, 8], + [WPK_FORK_MODULE_STATE_GLOBAL_TYPE_F32, 4], + [WPK_FORK_MODULE_STATE_GLOBAL_TYPE_F64, 8], + [WPK_FORK_MODULE_STATE_GLOBAL_TYPE_V128, 16], + [WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, 4], + [WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, 4], + [WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF, 4], + [WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF, 4], + ]); + const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); + const type = view.getUint8(0); + const expectedValueSize = valueSizes.get(type); + if (expectedValueSize === undefined) { + throw new Error(`${context}: unknown mutable-global value type ${type}`); + } + const valueSize = view.getUint8(1); + if ( + valueSize !== expectedValueSize + || payload.byteLength !== GLOBAL_HEADER_SIZE + expectedValueSize + ) { + throw new Error(`${context}: mutable-global value size is inconsistent`); + } + if (view.getUint16(2, true) !== 0 || view.getUint32(4, true) !== 0) { + throw new Error(`${context}: mutable-global reserved fields are nonzero`); + } + const value = payload.slice(GLOBAL_HEADER_SIZE); + const snapshot: ForkGlobalSnapshot = { typeCode: type, value }; + if ( + type === WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF + || type === WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF + || type === WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF + || type === WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF + ) { + snapshot.recipeId = new DataView( + value.buffer, + value.byteOffset, + value.byteLength, + ).getUint32(0, true); + } + return snapshot; +} + +export function findForkGlobalSnapshot( + records: readonly ForkModuleStateRecord[], + activationId: number, + ownerId: number, +): ForkGlobalSnapshot { + checkedU32(activationId, "global snapshot activation"); + checkedU32(ownerId, "global snapshot owner", false); + const matches = records.filter( + (record) => + record.kind === ForkModuleStateRecordKind.MutableGlobal + && record.activationId === activationId + && record.ownerId === ownerId, + ); + if (matches.length !== 1) { + throw new Error( + `module-state has ${matches.length} global snapshots for ` + + `${activationId}:${ownerId}; expected one`, + ); + } + return decodeForkGlobalSnapshot( + matches[0]!.payload, + `module-state global ${activationId}:${ownerId}`, + ); +} + +function validateRecordOwnership( + records: readonly ForkModuleStateRecord[], + ptrWidth?: 4 | 8, +): void { + const modules = new Set(); + const owned = new Set(); + const tables = new Map(); + const tablePageCounts = new Map(); + const lastTablePages = new Map(); + let replayEventsSeen = false; + let replayEventSegments = 0; + let referenceRecipeSeen = false; + let referenceRecipeSegments = 0; + let importedGlobalBindingsSeen = false; + let importedTableBindingsSeen = false; + let replayEventManifest: Uint8Array | null = null; + let activationContinuations: ForkActivationContinuation[] | null = null; + + for (const [recordIndex, record] of records.entries()) { + const context = `module-state record ${recordIndex}`; + checkedU32(record.activationId, `${context} activation id`); + checkedU32(record.ownerId, `${context} owner id`); + if (record.kind === ForkModuleStateRecordKind.ReferenceRecipeSegment) { + if (record.activationId !== 0 || record.ownerId !== 1) { + throw new Error( + `${context}: reference-recipe segments must use process ownership`, + ); + } + if (referenceRecipeSeen) { + throw new Error( + `${context}: reference-recipe segment follows its final manifest`, + ); + } + referenceRecipeSegments++; + continue; + } + if (record.kind === ForkModuleStateRecordKind.ReplayEventSegment) { + if ( + record.activationId !== 0 + || record.ownerId !== WPK_FORK_MODULE_STATE_REPLAY_EVENTS_OWNER + ) { + throw new Error( + `${context}: replay-event segments must use process ownership`, + ); + } + if (replayEventsSeen) { + throw new Error( + `${context}: replay-event segment follows its final manifest`, + ); + } + replayEventSegments++; + continue; + } + if (record.kind === ForkModuleStateRecordKind.ReplayEvents) { + if ( + record.activationId !== 0 + || record.ownerId !== WPK_FORK_MODULE_STATE_REPLAY_EVENTS_OWNER + ) { + throw new Error(`${context}: replay events must use process ownership`); + } + if (replayEventsSeen) { + throw new Error(`${context}: duplicate process replay-event record`); + } + replayEventManifest = record.payload; + replayEventsSeen = true; + continue; + } + if (record.kind === ForkModuleStateRecordKind.ImportedGlobalBindings) { + if ( + record.activationId !== 0 + || record.ownerId !== WPK_FORK_IMPORTED_GLOBAL_BINDINGS_OWNER + ) { + throw new Error( + `${context}: imported-global bindings must use process ownership`, + ); + } + if (importedGlobalBindingsSeen) { + throw new Error(`${context}: duplicate imported-global binding record`); + } + decodeForkImportedGlobalBindings(record.payload, context); + importedGlobalBindingsSeen = true; + continue; + } + if (record.kind === ForkModuleStateRecordKind.ImportedTableBindings) { + if ( + record.activationId !== 0 + || record.ownerId !== WPK_FORK_IMPORTED_TABLE_BINDINGS_OWNER + ) { + throw new Error( + `${context}: imported-table bindings must use process ownership`, + ); + } + if (importedTableBindingsSeen) { + throw new Error(`${context}: duplicate imported-table binding record`); + } + decodeForkImportedTableBindings(record.payload, context); + importedTableBindingsSeen = true; + continue; + } + if (record.kind === ForkModuleStateRecordKind.ActivationContinuations) { + if ( + record.activationId !== 0 + || record.ownerId !== WPK_FORK_ACTIVATION_CONTINUATIONS_OWNER + ) { + throw new Error( + `${context}: activation continuations must use process ownership`, + ); + } + if (activationContinuations) { + throw new Error(`${context}: duplicate activation-continuation record`); + } + activationContinuations = decodeForkActivationContinuations( + record.payload, + context, + ); + if (ptrWidth === 4) { + for (const continuation of activationContinuations) { + if (continuation.root > 0xffff_ffffn) { + throw new RangeError( + `${context}: activation ${continuation.activationId} root does not fit wasm32`, + ); + } + } + } + continue; + } + if (record.kind === ForkModuleStateRecordKind.Module) { + if (record.ownerId !== 0) { + throw new Error(`${context}: module record must use owner id zero`); + } + if (modules.has(record.activationId)) { + throw new Error(`${context}: duplicate module activation ${record.activationId}`); + } + decodeModulePayload(record.payload, context); + modules.add(record.activationId); + continue; + } + + if (!modules.has(record.activationId)) { + throw new Error( + `${context}: state refers to undeclared module activation ${record.activationId}`, + ); + } + if (record.ownerId === 0) { + throw new Error(`${context}: state record has no explicit owner`); + } + + if (record.kind === ForkModuleStateRecordKind.TablePage) { + const key = tableKey(record.activationId, record.ownerId); + const table = tables.get(key); + if (!table) { + throw new Error(`${context}: table page precedes its owning table descriptor`); + } + const page = validateTablePage(record, table, context); + const previous = lastTablePages.get(key); + if (previous !== undefined && page.pageIndex <= previous) { + throw new Error(`${context}: table pages are not strictly increasing`); + } + lastTablePages.set(key, page.pageIndex); + tablePageCounts.set(key, (tablePageCounts.get(key) ?? 0) + 1); + continue; + } + + const key = ownerKey(record.kind, record.activationId, record.ownerId); + if (owned.has(key)) { + throw new Error( + `${context}: duplicate owner ${record.ownerId} for record kind ${record.kind}`, + ); + } + owned.add(key); + + if (record.kind === ForkModuleStateRecordKind.ReferenceRecipe) { + referenceRecipeSeen = true; + } + + if (record.kind === ForkModuleStateRecordKind.Table) { + const table = decodeTableDescriptor(record, context); + const tableIdentity = tableKey(record.activationId, record.ownerId); + if (tables.has(tableIdentity)) { + throw new Error(`${context}: duplicate table owner ${tableIdentity}`); + } + tables.set(tableIdentity, table); + tablePageCounts.set(tableIdentity, 0); + } else if (record.kind === ForkModuleStateRecordKind.MutableGlobal) { + decodeForkGlobalSnapshot(record.payload, context); + } else if (record.kind === ForkModuleStateRecordKind.ElementSegments) { + decodeElementSegments(record.payload, context); + } else if (record.kind === ForkModuleStateRecordKind.DataSegments) { + decodeDataSegments(record.payload, context); + } + } + + if (referenceRecipeSegments !== 0 && !referenceRecipeSeen) { + throw new Error( + `module-state has ${referenceRecipeSegments} reference-recipe segment(s) ` + + "without a final manifest", + ); + } + if (replayEventSegments !== 0 && !replayEventsSeen) { + throw new Error( + `module-state has ${replayEventSegments} replay-event segment(s) ` + + "without a final manifest", + ); + } + const replayActivationIds = replayEventManifest + ? validateForkReplayEventWire({ + manifest: replayEventManifest, + segments: { + *[Symbol.iterator]() { + for (const record of records) { + if (record.kind === ForkModuleStateRecordKind.ReplayEventSegment) { + yield record.payload; + } + } + }, + }, + }).activationIds + : null; + + for (const [key, table] of tables) { + const actual = tablePageCounts.get(key) ?? 0; + if (actual !== table.pageCount) { + throw new Error( + `module-state table ${key} declares ${table.pageCount} sparse pages, found ${actual}`, + ); + } + } + if (modules.size === 0) { + throw new Error("module-state arena has no declared module activation"); + } + if (activationContinuations) { + if (!replayActivationIds) { + throw new Error( + "module-state activation continuations have no replay-event manifest", + ); + } + assertActivationContinuationSet( + activationContinuations, + replayActivationIds, + "module-state activation continuations", + ); + for (const { activationId } of activationContinuations) { + if (!modules.has(activationId)) { + throw new Error( + `module-state activation continuation ${activationId} has no module descriptor`, + ); + } + } + } +} + +function decodeSparseTables( + records: readonly ForkModuleStateRecord[], +): DecodedForkSparseTableSnapshot[] { + validateRecordOwnership(records); + const tables = new Map(); + for (const [recordIndex, record] of records.entries()) { + if (record.kind === ForkModuleStateRecordKind.Table) { + const descriptor = decodeTableDescriptor( + record, + `module-state record ${recordIndex}`, + ); + tables.set(tableKey(record.activationId, record.ownerId), { + activationId: descriptor.activationId, + ownerId: descriptor.ownerId, + indexWidth: descriptor.indexWidth, + pageShift: descriptor.pageShift, + length: descriptor.length, + baselineLength: descriptor.baselineLength, + baselineFingerprint: descriptor.baselineFingerprint, + pages: [], + }); + } else if (record.kind === ForkModuleStateRecordKind.TablePage) { + const table = tables.get(tableKey(record.activationId, record.ownerId)); + if (!table) { + throw new Error(`module-state record ${recordIndex}: missing table descriptor`); + } + const descriptor: DecodedTableDescriptor = { + ...table, + flags: TABLE_FLAG_SPARSE_OVERRIDES, + pageCount: 0, + }; + const page = decodeTablePage( + record, + descriptor, + `module-state record ${recordIndex}`, + ); + table.pages.push({ pageIndex: page.pageIndex, runs: page.runs }); + } + } + return Array.from(tables.values()); +} + +/** + * Owner for one activation's versioned module-state recipe arena. + * + * The arena contains bytes only. Any temporary JS reference-to-recipe maps + * belong to the future coordinator and must be cleared before calling + * `release`; this class never turns such references into hidden GC roots. + */ +export class ForkModuleStateArena { + private root = 0; + private tail = 0; + private chunks: ArenaChunk[] = []; + private sealed = false; + private pending: PendingRecord | null = null; + private readonly payloadIndex = new Map(); + + constructor( + private readonly memory: WebAssembly.Memory, + readonly ptrWidth: 4 | 8, + private readonly allocate: ContinuationAllocate, + private readonly deallocate: ContinuationDeallocate, + private readonly label: string, + ) { + if (ptrWidth !== 4 && ptrWidth !== 8) { + throw new RangeError(`${label}: unsupported module-state pointer width ${ptrWidth}`); + } + } + + begin(): number { + if (this.root !== 0) { + throw new Error(`${this.label}: module-state arena is already active`); + } + const root = this.allocateChunk(WASM_PAGE_SIZE, 0, true); + this.root = root; + this.tail = root; + return root; + } + + attach(root: number | bigint): void { + if (this.root !== 0) { + throw new Error(`${this.label}: module-state arena is already active`); + } + const rootNumber = checkedPointer( + root, + this.ptrWidth, + `${this.label}: module-state root`, + false, + ); + if (rootNumber % WASM_PAGE_SIZE !== 0) { + throw new Error(`${this.label}: module-state root is not page-aligned`); + } + const chunks = this.validateChunks(rootNumber, true); + const records = this.decodeRecords(chunks, false); + validateRecordOwnership(records, this.ptrWidth); + const payloadIndex = this.buildPayloadIndex(chunks); + // Publish ownership only after the complete guest-controlled arena passes + // structural and semantic validation. Failed attachment must not release + // mappings that this host never safely adopted. + this.root = rootNumber; + this.tail = chunks[chunks.length - 1]!.addr; + this.chunks = chunks; + this.payloadIndex.clear(); + for (const [key, addresses] of payloadIndex) { + this.payloadIndex.set(key, addresses); + } + this.sealed = true; + } + + /** + * Copy one exact record from a sealed, guest-owned arena without adopting + * or copying every other payload. + * + * This validates the chunk chain and every record envelope, but deliberately + * leaves record-kind semantics to the selected payload's decoder. It exists + * for pre-launch owners such as the externref broker: a large dirty table + * must not be copied into JavaScript merely to inspect the small KFRV graph. + */ + inspectSealedRecordPayload( + root: number | bigint, + kind: ForkModuleStateRecordKind, + activationId: number, + ownerId: number, + ): Uint8Array { + const matches = this.inspectSealedRecordViews( + root, + [kind], + activationId, + ownerId, + ); + if (matches.length !== 1) { + throw new Error( + `${this.label}: ${matches.length === 0 ? "missing" : "duplicate"} ` + + `module-state record ${kind}:${activationId}:${ownerId}`, + ); + } + return matches[0]!.payload.slice(); + } + + /** + * Validate a sealed arena and expose selected payloads as zero-copy views. + * + * WHY: process owners must sometimes inspect a segmented reference/event + * stream before the child Worker exists. Copying every selected segment (or + * unrelated table page) would recreate a whole-transaction allocation + * boundary. Returned views remain valid only while `memory` and the sealed + * arena mappings remain alive. + */ + inspectSealedRecordViews( + root: number | bigint, + kinds: readonly ForkModuleStateRecordKind[], + activationId?: number, + ownerId?: number, + ): readonly ForkModuleStateRecordView[] { + if (this.root !== 0) { + throw new Error( + `${this.label}: cannot inspect while the arena owns another root`, + ); + } + if (kinds.length === 0) { + throw new RangeError(`${this.label}: no module-state record kinds selected`); + } + const selectedKinds = new Set(); + for (const kind of kinds) { + if (!RECORD_KINDS.has(kind)) { + throw new RangeError(`${this.label}: unknown module-state record kind ${kind}`); + } + selectedKinds.add(kind); + } + if ((activationId === undefined) !== (ownerId === undefined)) { + throw new Error( + `${this.label}: record activation and owner filters must be paired`, + ); + } + if (activationId !== undefined && ownerId !== undefined) { + checkedU32(activationId, `${this.label}: record activation id`); + checkedU32(ownerId, `${this.label}: record owner id`); + } + const rootNumber = checkedPointer( + root, + this.ptrWidth, + `${this.label}: module-state root`, + false, + ); + if (rootNumber % WASM_PAGE_SIZE !== 0) { + throw new Error(`${this.label}: module-state root is not page-aligned`); + } + + const chunks = this.validateChunks(rootNumber, true); + return Object.freeze( + this.decodeRecords(chunks, false).filter((record) => + selectedKinds.has(record.kind) + && ( + activationId === undefined + || ( + record.activationId === activationId + && record.ownerId === ownerId + ) + ) + ), + ); + } + + appendModule(record: ForkModuleDescriptorRecord): void { + this.appendRecord({ + kind: ForkModuleStateRecordKind.Module, + activationId: record.activationId, + ownerId: 0, + payload: encodeModulePayload(record), + }); + } + + appendElementSegmentState(state: ForkElementSegmentState): void { + this.appendRecord({ + kind: ForkModuleStateRecordKind.ElementSegments, + activationId: state.activationId, + ownerId: state.ownerId, + payload: encodeElementSegments(state), + }); + } + + appendDataSegmentState(state: ForkDataSegmentState): void { + this.appendRecord({ + kind: ForkModuleStateRecordKind.DataSegments, + activationId: state.activationId, + ownerId: state.ownerId, + payload: encodeDataSegments(state), + }); + } + + appendImportedGlobalBindings( + bindings: readonly ForkImportedGlobalBinding[], + ): void { + this.appendRecord({ + kind: ForkModuleStateRecordKind.ImportedGlobalBindings, + activationId: 0, + ownerId: WPK_FORK_IMPORTED_GLOBAL_BINDINGS_OWNER, + payload: encodeForkImportedGlobalBindings(bindings), + }); + } + + appendImportedTableBindings( + bindings: readonly ForkImportedTableBinding[], + ): void { + this.appendRecord({ + kind: ForkModuleStateRecordKind.ImportedTableBindings, + activationId: 0, + ownerId: WPK_FORK_IMPORTED_TABLE_BINDINGS_OWNER, + payload: encodeForkImportedTableBindings(bindings), + }); + } + + appendSparseTable(snapshot: ForkSparseTableSnapshot): void { + checkedU32(snapshot.activationId, "table activation id"); + checkedU32(snapshot.ownerId, "table owner id", false); + const descriptorPayload = encodeTableDescriptor(snapshot); + const descriptor = decodeTableDescriptor({ + kind: ForkModuleStateRecordKind.Table, + activationId: snapshot.activationId, + ownerId: snapshot.ownerId, + payload: descriptorPayload, + }, "table snapshot"); + // Validate the complete logical snapshot before publishing its descriptor. + // This pass allocates no page payloads, so table size does not become a + // second whole-transaction memory requirement in the host. + let previousPageIndex: bigint | null = null; + for (const page of snapshot.pages) { + previousPageIndex = validateSparseTablePage( + descriptor, + page, + previousPageIndex, + ).pageIndex; + } + + this.appendRecord({ + kind: ForkModuleStateRecordKind.Table, + activationId: snapshot.activationId, + ownerId: snapshot.ownerId, + payload: descriptorPayload, + }); + previousPageIndex = null; + for (const page of snapshot.pages) { + const payload = encodeTablePage(descriptor, page, previousPageIndex); + this.appendRecord({ + kind: ForkModuleStateRecordKind.TablePage, + activationId: snapshot.activationId, + ownerId: snapshot.ownerId, + payload, + }); + previousPageIndex = checkedU64(page.pageIndex, "table page index"); + } + } + + appendRecord(record: ForkModuleStateRecord): void { + if (!(record.payload instanceof Uint8Array)) { + throw new TypeError(`${this.label}: module-state record payload must be Uint8Array`); + } + const payloadAddr = this.reserveRecord( + record.kind, + record.activationId, + record.ownerId, + record.payload.byteLength, + ); + const payloadNumber = checkedPointer( + payloadAddr, + this.ptrWidth, + `${this.label}: reserved module-state payload`, + false, + ); + new Uint8Array( + this.memory.buffer, + payloadNumber, + record.payload.byteLength, + ).set(record.payload); + this.commitRecord(payloadAddr); + } + + appendReplayEvents(source: ForkReplayEventCaptureSource): void { + // WHY: segments are committed before the small final manifest. The + // manifest is the transaction marker, so a failed page allocation cannot + // make a truncated journal appear complete in a copied child arena. + for (const payload of source.capturedSegmentPayloads()) { + this.appendRecord({ + kind: ForkModuleStateRecordKind.ReplayEventSegment, + activationId: 0, + ownerId: WPK_FORK_MODULE_STATE_REPLAY_EVENTS_OWNER, + payload, + }); + } + this.appendRecord({ + kind: ForkModuleStateRecordKind.ReplayEvents, + activationId: 0, + ownerId: WPK_FORK_MODULE_STATE_REPLAY_EVENTS_OWNER, + payload: source.capturedManifestPayload(), + }); + } + + appendActivationContinuations( + continuations: readonly ForkActivationContinuation[], + ): void { + this.appendRecord({ + kind: ForkModuleStateRecordKind.ActivationContinuations, + activationId: 0, + ownerId: WPK_FORK_ACTIVATION_CONTINUATIONS_OWNER, + payload: encodeForkActivationContinuations(continuations), + }); + } + + /** + * Reserve one record payload for an instrumented Wasm snapshot helper. + * + * The record is not reachable through a chunk's committed `used` boundary + * until `commitRecord` succeeds. A trap or encoding failure can therefore + * release the whole arena without publishing partial recipe bytes. + */ + reserveRecord( + kind: number, + activationId: number, + ownerId: number, + payloadSize: number | bigint, + ): number | bigint { + this.requireWritable(); + if (this.pending) { + throw new Error(`${this.label}: a module-state record reservation is already pending`); + } + if (!RECORD_KINDS.has(kind)) { + throw new RangeError(`${this.label}: unknown module-state record kind ${kind}`); + } + checkedU32(activationId, `${this.label}: record activation id`); + checkedU32(ownerId, `${this.label}: record owner id`); + const payloadBytes = checkedPointer( + payloadSize, + this.ptrWidth, + `${this.label}: module-state payload size`, + true, + ); + const totalSize = alignUp( + RECORD_HEADER_SIZE + payloadBytes, + FORK_MODULE_STATE_RECORD_ALIGNMENT, + ); + if (totalSize > 0xffff_ffff) { + throw new RangeError(`${this.label}: module-state record exceeds u32 size`); + } + let chunk = this.chunks[this.chunks.length - 1]!; + if (totalSize > chunk.size - chunk.used) { + const capacity = alignUp( + Math.max(WASM_PAGE_SIZE, chunkHeaderSize(this.ptrWidth) + totalSize), + WASM_PAGE_SIZE, + ); + const next = this.allocateChunk(capacity, this.tail, false); + writePointer( + this.memory, + this.ptrWidth, + this.tail + chunkOffset(this.ptrWidth, 2), + next, + ); + this.tail = next; + chunk = this.chunks[this.chunks.length - 1]!; + } + const recordAddr = chunk.addr + chunk.used; + checkedMemoryRange( + this.memory, + recordAddr, + totalSize, + `${this.label}: module-state record`, + ); + const view = new DataView(this.memory.buffer); + view.setUint32(recordAddr, RECORD_MAGIC, true); + view.setUint16(recordAddr + 4, FORK_MODULE_STATE_RECORD_VERSION, true); + view.setUint16(recordAddr + 6, kind, true); + view.setUint32(recordAddr + 8, totalSize, true); + view.setUint32(recordAddr + 12, payloadBytes, true); + view.setUint32(recordAddr + 16, activationId, true); + view.setUint32(recordAddr + 20, ownerId, true); + new Uint8Array( + this.memory.buffer, + recordAddr + RECORD_HEADER_SIZE, + totalSize - RECORD_HEADER_SIZE, + ).fill(0); + const payloadAddr = recordAddr + RECORD_HEADER_SIZE; + this.pending = { + chunk, + kind: kind as ForkModuleStateRecordKind, + activationId, + ownerId, + payloadAddr, + totalSize, + payloadSize: payloadBytes, + }; + return this.ptrWidth === 8 ? BigInt(payloadAddr) : payloadAddr; + } + + commitRecord(payloadAddr: number | bigint): void { + this.requireWritable(); + const payloadNumber = checkedPointer( + payloadAddr, + this.ptrWidth, + `${this.label}: module-state payload commit`, + false, + ); + const pending = this.pending; + if (!pending || pending.payloadAddr !== payloadNumber) { + throw new Error(`${this.label}: module-state commit does not match reservation`); + } + const chunk = this.chunks[this.chunks.length - 1]; + if (!chunk || chunk !== pending.chunk) { + throw new Error(`${this.label}: pending module-state record is not in the active chunk`); + } + // Publish used/count only after the complete record and zero padding are + // visible. A sealed root can therefore never expose a partial TLV. + const paddingSize = pending.totalSize - RECORD_HEADER_SIZE - pending.payloadSize; + requireZeroBytes( + new Uint8Array( + this.memory.buffer, + pending.payloadAddr + pending.payloadSize, + paddingSize, + ), + `${this.label}: pending module-state record`, + ); + chunk.used += pending.totalSize; + chunk.recordCount++; + writePointer( + this.memory, + this.ptrWidth, + chunk.addr + chunkOffset(this.ptrWidth, 4), + chunk.used, + ); + new DataView(this.memory.buffer).setUint32( + chunk.addr + chunkRecordCountOffset(this.ptrWidth), + chunk.recordCount, + true, + ); + const key = ownerKey( + pending.kind, + pending.activationId, + pending.ownerId, + ); + const addresses = this.payloadIndex.get(key) ?? []; + addresses.push(pending.payloadAddr); + this.payloadIndex.set(key, addresses); + this.pending = null; + } + + /** + * Resolve one committed record without copying its payload. + * + * The generated restore helper addresses table pages by ordinal, so keeping + * this index linearizes attachment once and makes large-table replay O(n) + * instead of rescanning the arena for every page. + */ + findRecord( + kind: number, + activationId: number, + ownerId: number, + ordinal: number, + ): number | bigint { + if (!this.sealed || this.root === 0) { + throw new Error(`${this.label}: cannot find a record in an unsealed arena`); + } + if (!RECORD_KINDS.has(kind)) { + throw new RangeError(`${this.label}: unknown module-state record kind ${kind}`); + } + checkedU32(activationId, `${this.label}: record activation id`); + checkedU32(ownerId, `${this.label}: record owner id`); + checkedU32(ordinal, `${this.label}: record ordinal`); + const addresses = this.payloadIndex.get(ownerKey( + kind as ForkModuleStateRecordKind, + activationId, + ownerId, + )); + const payload = addresses?.[ordinal]; + if (payload === undefined) { + throw new Error( + `${this.label}: missing module-state record ` + + `${kind}:${activationId}:${ownerId}:${ordinal}`, + ); + } + return this.ptrWidth === 8 ? BigInt(payload) : payload; + } + + seal(): number { + this.requireWritable(); + if (this.pending) { + throw new Error(`${this.label}: cannot seal with a pending module-state record`); + } + const validatedChunks = this.validateChunks(this.root, false); + if ( + validatedChunks.length !== this.chunks.length + || validatedChunks.some((chunk, index) => ( + chunk.addr !== this.chunks[index]!.addr + || chunk.size !== this.chunks[index]!.size + )) + ) { + throw new Error(`${this.label}: module-state chunk ownership changed before seal`); + } + const records = this.decodeRecords(validatedChunks, false); + validateRecordOwnership(records, this.ptrWidth); + for (let index = this.chunks.length - 1; index >= 1; index--) { + this.writeChunkFlags(this.chunks[index]!.addr, CHUNK_FLAG_SEALED); + } + // Root is the commit point. Child attachment refuses an arena unless this + // flag is present, so it cannot race a partially populated tail. + this.writeChunkFlags(this.root, CHUNK_FLAG_ROOT | CHUNK_FLAG_SEALED); + this.sealed = true; + return this.root; + } + + records(): ForkModuleStateRecord[] { + if (!this.sealed || this.root === 0) { + throw new Error(`${this.label}: module-state arena is not sealed`); + } + return this.decodeRecords(this.chunks); + } + + /** + * Zero-copy counterpart to `records()` for bounded streaming decoders. + * + * Callers must finish using these views before `release()` recycles the arena + * mappings. The returned record envelopes and underlying sealed bytes are + * immutable by contract. + */ + recordViews(): readonly ForkModuleStateRecordView[] { + if (!this.sealed || this.root === 0) { + throw new Error(`${this.label}: module-state arena is not sealed`); + } + return Object.freeze(this.decodeRecords(this.chunks, false)); + } + + /** + * Inspect committed records while the parent transaction is still writable. + * + * Process-owned manifests such as imported-global provenance are derived + * from module-owned records and must themselves be appended before `seal`. + * Pending reservations are excluded so callers never derive state from + * bytes that Wasm has not committed. + */ + recordsForCapture(): ForkModuleStateRecord[] { + this.requireWritable(); + if (this.pending) { + throw new Error( + `${this.label}: cannot inspect module state with a pending record`, + ); + } + const chunks = this.validateChunks(this.root, false); + const records = this.decodeRecords(chunks); + validateRecordOwnership(records, this.ptrWidth); + return records; + } + + sparseTables(): DecodedForkSparseTableSnapshot[] { + return decodeSparseTables(this.records()); + } + + rootAddress(): number { + if (this.root === 0) { + throw new Error(`${this.label}: no active module-state arena`); + } + return this.root; + } + + hasActiveArena(): boolean { + return this.root !== 0; + } + + isSealed(): boolean { + return this.sealed; + } + + release(): void { + if (this.root === 0) { + throw new Error(`${this.label}: no active module-state arena to release`); + } + const chunks = this.chunks.splice(0).reverse(); + this.pending = null; + this.payloadIndex.clear(); + this.root = 0; + this.tail = 0; + this.sealed = false; + let firstError: unknown; + for (const chunk of chunks) { + try { + this.deallocate(chunk.addr, chunk.size); + } catch (error) { + firstError ??= error; + } + } + if (firstError !== undefined) throw firstError; + } + + private requireWritable(): void { + if (this.root === 0) { + throw new Error(`${this.label}: module-state arena has not begun`); + } + if (this.sealed) { + throw new Error(`${this.label}: module-state arena is sealed`); + } + } + + private allocateChunk(capacity: number, previous: number, root: boolean): number { + let addr: number; + try { + addr = this.allocate(capacity); + } catch (error) { + if (error instanceof ContinuationAllocationError) throw error; + throw new Error( + `${this.label}: module-state allocation of ${capacity} bytes failed: ` + + `${error instanceof Error ? error.message : String(error)}`, + ); + } + const valid = Number.isSafeInteger(addr) + && addr > 0 + && addr % WASM_PAGE_SIZE === 0 + && capacity >= WASM_PAGE_SIZE + && capacity % WASM_PAGE_SIZE === 0 + && checkedEnd(addr, capacity, `${this.label}: module-state allocation`) + <= this.memory.buffer.byteLength + && (this.ptrWidth === 8 || addr + capacity <= 0x1_0000_0000); + if (!valid) { + if (Number.isSafeInteger(addr) && addr > 0) { + try { + this.deallocate(addr, capacity); + } catch { + // Preserve the allocator contract failure. + } + } + throw new Error(`${this.label}: allocator returned an invalid module-state chunk`); + } + const headerSize = chunkHeaderSize(this.ptrWidth); + const view = new DataView(this.memory.buffer); + view.setUint32(addr, CHUNK_MAGIC, true); + view.setUint16(addr + 4, FORK_MODULE_STATE_ARENA_VERSION, true); + view.setUint16(addr + 6, root ? CHUNK_FLAG_ROOT : 0, true); + writePointer(this.memory, this.ptrWidth, addr + chunkOffset(this.ptrWidth, 0), root ? addr : this.root); + writePointer(this.memory, this.ptrWidth, addr + chunkOffset(this.ptrWidth, 1), previous); + writePointer(this.memory, this.ptrWidth, addr + chunkOffset(this.ptrWidth, 2), 0); + writePointer(this.memory, this.ptrWidth, addr + chunkOffset(this.ptrWidth, 3), capacity); + writePointer(this.memory, this.ptrWidth, addr + chunkOffset(this.ptrWidth, 4), headerSize); + new DataView(this.memory.buffer).setUint32( + addr + chunkRecordCountOffset(this.ptrWidth), + 0, + true, + ); + new DataView(this.memory.buffer).setUint32( + addr + chunkReservedOffset(this.ptrWidth), + 0, + true, + ); + const fieldsEnd = chunkReservedOffset(this.ptrWidth) + 4; + new Uint8Array(this.memory.buffer, fieldsEnd, headerSize - fieldsEnd).fill(0); + this.chunks.push({ + addr, + size: capacity, + used: headerSize, + recordCount: 0, + }); + return addr; + } + + private writeChunkFlags(addr: number, flags: number): void { + new DataView(this.memory.buffer).setUint16(addr + 6, flags, true); + } + + private validateChunks(root: number, requireSealed: boolean): ArenaChunk[] { + const chunks: ArenaChunk[] = []; + const seen = new Set(); + const maxChunks = Math.floor(this.memory.buffer.byteLength / WASM_PAGE_SIZE); + let current = root; + let previous = 0; + for (;;) { + if (seen.has(current)) { + throw new Error(`${this.label}: module-state chunk cycle`); + } + if (seen.size >= maxChunks) { + throw new Error(`${this.label}: module-state chunk chain exceeds memory`); + } + seen.add(current); + const chunk = this.validateChunk( + current, + root, + previous, + chunks.length === 0, + requireSealed, + ); + chunks.push(chunk); + const next = readPointer( + this.memory, + this.ptrWidth, + current + chunkOffset(this.ptrWidth, 2), + `${this.label}: module-state next chunk`, + ); + if (next === 0) break; + previous = current; + current = next; + } + + const sorted = [...chunks].sort((left, right) => left.addr - right.addr); + for (let index = 1; index < sorted.length; index++) { + const prior = sorted[index - 1]!; + const currentChunk = sorted[index]!; + if ( + checkedEnd(prior.addr, prior.size, `${this.label}: module-state chunk`) + > currentChunk.addr + ) { + throw new Error(`${this.label}: module-state chunk ranges overlap`); + } + } + return chunks; + } + + private validateChunk( + addr: number, + root: number, + previous: number, + isRoot: boolean, + requireSealed: boolean, + ): ArenaChunk { + const headerSize = chunkHeaderSize(this.ptrWidth); + if ( + !Number.isSafeInteger(addr) + || addr <= 0 + || addr % WASM_PAGE_SIZE !== 0 + || checkedEnd(addr, headerSize, `${this.label}: module-state chunk header`) + > this.memory.buffer.byteLength + ) { + throw new Error(`${this.label}: invalid module-state chunk address`); + } + const view = new DataView(this.memory.buffer); + const expectedFlags = (requireSealed ? CHUNK_FLAG_SEALED : 0) + | (isRoot ? CHUNK_FLAG_ROOT : 0); + if ( + view.getUint32(addr, true) !== CHUNK_MAGIC + || view.getUint16(addr + 4, true) !== FORK_MODULE_STATE_ARENA_VERSION + || view.getUint16(addr + 6, true) !== expectedFlags + || readPointer( + this.memory, + this.ptrWidth, + addr + chunkOffset(this.ptrWidth, 0), + `${this.label}: module-state chunk root`, + ) !== root + || readPointer( + this.memory, + this.ptrWidth, + addr + chunkOffset(this.ptrWidth, 1), + `${this.label}: module-state previous chunk`, + ) !== previous + ) { + throw new Error(`${this.label}: invalid or unsealed module-state chunk`); + } + const capacity = readPointer( + this.memory, + this.ptrWidth, + addr + chunkOffset(this.ptrWidth, 3), + `${this.label}: module-state chunk capacity`, + ); + const used = readPointer( + this.memory, + this.ptrWidth, + addr + chunkOffset(this.ptrWidth, 4), + `${this.label}: module-state chunk used bytes`, + ); + const recordCount = view.getUint32( + addr + chunkRecordCountOffset(this.ptrWidth), + true, + ); + if ( + capacity < WASM_PAGE_SIZE + || capacity % WASM_PAGE_SIZE !== 0 + || checkedEnd(addr, capacity, `${this.label}: module-state chunk bounds`) + > this.memory.buffer.byteLength + || used < headerSize + || used > capacity + || (!isRoot && (used === headerSize || recordCount === 0)) + || view.getUint32(addr + chunkReservedOffset(this.ptrWidth), true) !== 0 + ) { + throw new Error(`${this.label}: invalid module-state chunk bounds or metadata`); + } + const fieldsEnd = chunkReservedOffset(this.ptrWidth) + 4; + requireZeroBytes( + new Uint8Array(this.memory.buffer, addr + fieldsEnd, headerSize - fieldsEnd), + `${this.label}: module-state chunk header`, + ); + return { addr, size: capacity, used, recordCount }; + } + + private decodeRecords( + chunks: readonly ArenaChunk[], + copyPayload = true, + ): ForkModuleStateRecord[] { + const records: ForkModuleStateRecord[] = []; + for (const [chunkIndex, chunk] of chunks.entries()) { + let offset = chunkHeaderSize(this.ptrWidth); + let recordCount = 0; + while (offset < chunk.used) { + const addr = chunk.addr + offset; + if (offset + RECORD_HEADER_SIZE > chunk.used) { + throw new Error( + `${this.label}: module-state chunk ${chunkIndex} has a truncated record header`, + ); + } + const view = new DataView(this.memory.buffer); + const kind = view.getUint16(addr + 6, true); + if ( + view.getUint32(addr, true) !== RECORD_MAGIC + || view.getUint16(addr + 4, true) !== FORK_MODULE_STATE_RECORD_VERSION + || !RECORD_KINDS.has(kind) + ) { + throw new Error( + `${this.label}: module-state chunk ${chunkIndex} has an invalid record header`, + ); + } + const totalSize = view.getUint32(addr + 8, true); + const payloadSize = view.getUint32(addr + 12, true); + const expectedTotal = alignUp( + RECORD_HEADER_SIZE + payloadSize, + FORK_MODULE_STATE_RECORD_ALIGNMENT, + ); + if ( + totalSize !== expectedTotal + || totalSize < RECORD_HEADER_SIZE + || offset + totalSize > chunk.used + ) { + throw new Error( + `${this.label}: module-state chunk ${chunkIndex} has invalid record bounds`, + ); + } + const paddingSize = totalSize - RECORD_HEADER_SIZE - payloadSize; + requireZeroBytes( + new Uint8Array( + this.memory.buffer, + addr + RECORD_HEADER_SIZE + payloadSize, + paddingSize, + ), + `${this.label}: module-state record ${records.length}`, + ); + records.push({ + kind: kind as ForkModuleStateRecordKind, + activationId: view.getUint32(addr + 16, true), + ownerId: view.getUint32(addr + 20, true), + payload: (() => { + const payload = new Uint8Array( + this.memory.buffer, + addr + RECORD_HEADER_SIZE, + payloadSize, + ); + // `records()` preserves the historical detached-lifetime contract; + // internal validation and streaming consumers opt into sealed + // arena views explicitly. + return copyPayload ? payload.slice() : payload; + })(), + }); + offset += totalSize; + recordCount++; + } + if (offset !== chunk.used || recordCount !== chunk.recordCount) { + throw new Error( + `${this.label}: module-state chunk ${chunkIndex} record count is inconsistent`, + ); + } + } + return records; + } + + private buildPayloadIndex( + chunks: readonly ArenaChunk[], + ): Map { + const index = new Map(); + for (const chunk of chunks) { + let offset = chunkHeaderSize(this.ptrWidth); + while (offset < chunk.used) { + const addr = chunk.addr + offset; + const view = new DataView(this.memory.buffer); + const kind = view.getUint16(addr + 6, true) as ForkModuleStateRecordKind; + const totalSize = view.getUint32(addr + 8, true); + const activationId = view.getUint32(addr + 16, true); + const ownerId = view.getUint32(addr + 20, true); + const key = ownerKey(kind, activationId, ownerId); + const addresses = index.get(key) ?? []; + addresses.push(addr + RECORD_HEADER_SIZE); + index.set(key, addresses); + offset += totalSize; + } + } + return index; + } +} diff --git a/host/src/fork-process-continuation.ts b/host/src/fork-process-continuation.ts new file mode 100644 index 0000000000..45f9a888d4 --- /dev/null +++ b/host/src/fork-process-continuation.ts @@ -0,0 +1,873 @@ +import { + WPK_FORK_FRAME_IMPORT_PEEK, + WPK_FORK_RESUME_IMPORT_PEEK, + WPK_FORK_RESUME_IMPORT_TABLE, +} from "./generated/abi"; +import { + type ForkActivationRegistration, + ForkActivationRegistry, +} from "./fork-activation-registry"; +import { + invokeForkContinuationBegin, + LinkedForkContinuation, +} from "./fork-continuation"; +import { + activationContinuationsForChild, + type ForkModuleStateArena, + replayEventsForChild, + writeForkModuleStateRoot, +} from "./fork-module-state"; +import { + type ForkReplayEvent, + ForkReplayEventJournal, + type ForkResumeTarget, + ForkResumeTable, +} from "./fork-replay-events"; +import { + checkedWasmGuestPointerOffset, + type WasmGuestPointer, +} from "./wasm-guest-pointer"; +import type { + DecodedSegmentedForkReferenceTransaction, +} from "./fork-reference-segments"; + +const WPK_FORK_NORMAL = 0; +const WPK_FORK_UNWINDING = 1; +const WPK_FORK_REWINDING = 2; +const WPK_FORK_ABORT_UNWINDING = 3; +// These names are already part of the Rust ABI table; generated named TS +// exports land with the ABI generator update that also publishes the fixed +// resume-boundary export names. +const WPK_FORK_FRAME_IMPORT_RESERVE = "__wpk_fork_frame_reserve"; +const WPK_FORK_FRAME_IMPORT_COMMIT = "__wpk_fork_frame_commit"; +const WPK_FORK_FRAME_IMPORT_NEXT = "__wpk_fork_frame_next"; + +type ProcessContinuationPhase = + | "idle" + | "capture" + | "sealed-parent" + | "parent-replay" + | "child-replay" + | "abort-replay"; + +export interface ForkProcessActivationBinding { + readonly activationId: number; + readonly continuation: LinkedForkContinuation; + /** + * Publish the process launch root in process-owned copied memory. + * + * Only activation zero owns this process-wide anchor. Its value may name + * any active activation's continuation: a side module can call the fork + * import without placing a main-module Wasm frame on the captured stack. + */ + readonly publishProcessLaunchRoot?: (address: number) => void; + /** Read the copied process launch root after fresh-child instantiation. */ + readonly readProcessLaunchRoot?: () => number; +} + +interface CompleteForkProcessActivation extends ForkProcessActivationBinding { + readonly registration: ForkActivationRegistration; + root: number; +} + +function assertActivationId(value: number): void { + if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff) { + throw new RangeError(`invalid fork process activation id ${value}`); + } +} + +function requireExportFunction( + activation: CompleteForkProcessActivation, + name: string, +): CallableFunction { + const value = activation.registration.instance.exports[name]; + if (typeof value !== "function") { + throw new Error( + `fork activation ${activation.activationId} is missing export ${name}`, + ); + } + return value as CallableFunction; +} + +function activationState(activation: CompleteForkProcessActivation): number { + return Number(requireExportFunction(activation, "wpk_fork_state")()); +} + +/** + * One process-worker transaction for linked frames from every module instance. + * + * Per-activation continuations still own their bytes because each artifact has + * its own fixed runtime prefix. The event journal is process-wide: it records + * the exact order in which frames from main and side modules commit, and + * replay consumes the reverse order. No module-instance table slot is durable + * state. + */ +export class ForkProcessContinuationCoordinator { + readonly resumeTable = new ForkResumeTable(); + + private readonly prepared = new Map(); + private readonly activations = new Map(); + private readonly events = new ForkReplayEventJournal(); + private phase: ProcessContinuationPhase = "idle"; + private arena: ForkModuleStateArena | null = null; + + constructor( + private readonly memory: WebAssembly.Memory, + private readonly registry: ForkActivationRegistry, + private readonly label: string, + ) {} + + /** + * Bind frame imports before the Wasm instance exists. + * + * Instantiation then calls `registerActivation` with the reflected exports + * and resume catalog. Splitting these steps avoids a circular dependency + * between imports and the instance that implements the codecs. + */ + prepareActivation(binding: ForkProcessActivationBinding): void { + this.requireIdle("prepare a module activation"); + assertActivationId(binding.activationId); + if ( + this.prepared.has(binding.activationId) + || this.activations.has(binding.activationId) + ) { + throw new Error( + `${this.label}: fork activation ${binding.activationId} is already prepared`, + ); + } + if ( + binding.activationId !== 0 + && ( + binding.publishProcessLaunchRoot !== undefined + || binding.readProcessLaunchRoot !== undefined + ) + ) { + throw new Error( + `${this.label}: only activation zero may own the process launch anchor`, + ); + } + if ( + (binding.publishProcessLaunchRoot === undefined) + !== (binding.readProcessLaunchRoot === undefined) + ) { + throw new Error( + `${this.label}: process launch anchor must provide both read and publish`, + ); + } + this.prepared.set(binding.activationId, binding); + } + + registerActivation( + registration: ForkActivationRegistration, + resumeTargets: readonly ForkResumeTarget[], + ): void { + this.requireIdle("register a module activation"); + const binding = this.prepared.get(registration.activationId); + if (!binding) { + throw new Error( + `${this.label}: fork activation ${registration.activationId} was not prepared`, + ); + } + this.registry.registerActivation(registration); + try { + this.resumeTable.registerActivation(registration.activationId, resumeTargets); + } catch (error) { + this.registry.unregisterActivation(registration.activationId); + throw error; + } + this.prepared.delete(registration.activationId); + this.activations.set(registration.activationId, { + ...binding, + registration, + root: 0, + }); + } + + unregisterActivation(activationId: number): void { + this.requireIdle("unregister a module activation"); + const activation = this.getActivation(activationId); + this.resumeTable.unregisterActivation(activationId); + this.registry.unregisterActivation(activationId); + this.activations.delete(activationId); + } + + discardPreparedActivation(activationId: number): void { + this.requireIdle("discard a prepared module activation"); + assertActivationId(activationId); + if (!this.prepared.delete(activationId)) { + throw new Error( + `${this.label}: fork activation ${activationId} is not prepared`, + ); + } + } + + /** + * Imports bound to one activation but coordinated by the process journal. + */ + continuationImports( + activationId: number, + onReservationAbort?: (errno: number) => void, + ): Record { + assertActivationId(activationId); + const binding = this.prepared.get(activationId) + ?? this.activations.get(activationId); + if (!binding) { + throw new Error( + `${this.label}: fork activation ${activationId} has no continuation binding`, + ); + } + const continuation = binding.continuation; + return { + [WPK_FORK_FRAME_IMPORT_RESERVE]: (size: number | bigint) => { + const payload = continuation.reserveFrame(size); + if ((payload === 0 || payload === 0n) && onReservationAbort) { + onReservationAbort(continuation.abortErrno()); + } + return payload; + }, + [WPK_FORK_FRAME_IMPORT_COMMIT]: (payload: WasmGuestPointer): void => { + continuation.commitFrame(payload); + const functionOrdinal = this.readFunctionOrdinal( + payload, + continuation, + "committed", + ); + this.events.recordCommit(activationId, functionOrdinal); + }, + [WPK_FORK_FRAME_IMPORT_PEEK]: (size: number | bigint) => { + const event = this.requireSelectedEvent(activationId, "peek"); + const payload = continuation.peekFrame(size); + const functionOrdinal = this.readFunctionOrdinal( + payload, + continuation, + "peeked", + ); + if (functionOrdinal !== event.functionOrdinal) { + throw new Error( + `${this.label}: replay selected ${activationId}:${event.functionOrdinal}, ` + + `but the frame belongs to ${activationId}:${functionOrdinal}`, + ); + } + return payload; + }, + [WPK_FORK_FRAME_IMPORT_NEXT]: (size: number | bigint) => { + const event = this.requireSelectedEvent(activationId, "consume"); + // WHY: validate identity through the non-consuming path first. A bad + // activation/function coordinate must not advance the linked cursor + // and turn a deterministic launch failure into later child corruption. + const peeked = continuation.peekFrame(size); + const functionOrdinal = this.readFunctionOrdinal( + peeked, + continuation, + "consumed", + ); + if (functionOrdinal !== event.functionOrdinal) { + throw new Error( + `${this.label}: replay selected ${activationId}:${event.functionOrdinal}, ` + + `but the frame belongs to ${activationId}:${functionOrdinal}`, + ); + } + const payload = continuation.nextFrame(size); + this.events.consume(activationId, functionOrdinal); + return payload; + }, + [WPK_FORK_RESUME_IMPORT_PEEK]: (_typeDiagnostic: number): number => + this.resumeTable.slotFor(this.events.peek()), + [WPK_FORK_RESUME_IMPORT_TABLE]: + this.resumeTable.table as unknown as WebAssembly.ImportValue, + }; + } + + /** + * Snapshot all activations, allocate their private runtime prefixes, and put + * every instance in UNWINDING before the private transport crosses modules. + */ + beginCapture(arena: ForkModuleStateArena): void { + this.requirePhase("idle", "begin process continuation capture"); + if (this.prepared.size !== 0) { + throw new Error( + `${this.label}: cannot fork with ${this.prepared.size} incomplete activation(s)`, + ); + } + this.events.beginCapture(); + this.arena = arena; + try { + this.publishProcessLaunchRoot(0); + this.registry.beginCapture(arena); + this.phase = "capture"; + for (const activation of this.orderedActivations()) { + const root = Number(activation.continuation.beginUnwind()); + activation.root = root; + // WHY: the main Wasm activation need not be on a side-module fork + // stack. Every activation prefix therefore carries the process arena + // root, allowing the deterministic launch root chosen after unwind + // to come from any active module without an archive-private side slot. + writeForkModuleStateRoot( + this.memory, + root, + activation.continuation.format.ptrWidth, + arena.rootAddress(), + ); + invokeForkContinuationBegin( + requireExportFunction(activation, "wpk_fork_unwind_begin"), + root, + activation.continuation.format.ptrWidth, + `${this.label}: activation ${activation.activationId} unwind`, + ); + this.requireActivationState(activation, WPK_FORK_UNWINDING, "unwind"); + } + } catch (error) { + this.cancelCapture(); + throw error; + } + } + + /** + * Close unwind, discard activations that were not on the captured stack, + * and seal references, module state, and global frame ordering together. + */ + sealCapture(): void { + this.requirePhase("capture", "seal process continuation capture"); + this.events.sealCapture(); + const active = this.events.capturedActivationIds(); + try { + for (const activation of this.orderedActivations()) { + requireExportFunction(activation, "wpk_fork_unwind_end")(); + this.requireActivationState(activation, WPK_FORK_NORMAL, "end unwind"); + if (active.has(activation.activationId)) { + activation.continuation.finishUnwind(); + } else { + activation.continuation.cancelUnwindAndRelease(); + activation.root = 0; + } + } + if (active.size === 0) { + throw new Error(`${this.label}: captured fork stack has no continuation frames`); + } + const continuations = this.orderedActivations() + .filter(({ activationId }) => active.has(activationId)) + .map(({ activationId, root }) => ({ + activationId, + root: BigInt(root), + })); + const arena = this.registry.currentArena(); + arena.appendReplayEvents(this.events); + arena.appendActivationContinuations(continuations); + this.registry.sealCapture(); + this.publishProcessLaunchRoot( + this.selectProcessLaunchRoot(continuations), + ); + this.phase = "sealed-parent"; + } catch (error) { + this.abort(); + throw error; + } + } + + beginParentReplay(): void { + this.requirePhase("sealed-parent", "begin parent process replay"); + this.registry.beginParentReplay(); + this.events.beginParentReplay(); + this.phase = "parent-replay"; + try { + this.registry.restoreModuleState(); + this.beginActivationReplay(WPK_FORK_REWINDING); + } catch (error) { + this.abort(); + throw error; + } + } + + /** + * Attach copied state only after every fresh child activation is registered. + */ + attachChild( + arena: ForkModuleStateArena, + adoptPreinstantiatedReferences?: () => void, + decodedReferences?: DecodedSegmentedForkReferenceTransaction, + ): void { + this.requirePhase("idle", "attach child process replay"); + if (this.prepared.size !== 0) { + throw new Error( + `${this.label}: child has ${this.prepared.size} incomplete activation(s)`, + ); + } + this.arena = arena; + try { + this.registry.attachChild(arena, decodedReferences); + // Imported immutable references may have forced a strict prefix of the + // recipe graph to be materialized while provider activations were still + // being instantiated. Adopt those exact identities after the full + // transaction validates the copied wire, but before module restore can + // request them again. + adoptPreinstantiatedReferences?.(); + const records = arena.recordViews(); + this.events.attachChild(replayEventsForChild(records)); + const continuations = activationContinuationsForChild( + records, + arena.ptrWidth, + ); + const copiedLaunchRoot = this.readProcessLaunchRoot(); + const expectedLaunchRoot = this.selectProcessLaunchRoot(continuations); + if (copiedLaunchRoot !== expectedLaunchRoot) { + throw new Error( + `${this.label}: copied process launch root ${copiedLaunchRoot} ` + + `does not match manifest root ${expectedLaunchRoot}`, + ); + } + this.phase = "child-replay"; + this.registry.restoreModuleState(); + const roots = new Map( + continuations.map(({ activationId, root }) => [ + activationId, + Number(root), + ]), + ); + for (const activation of this.orderedActivations()) { + const root = roots.get(activation.activationId) ?? 0; + if (root === 0) { + activation.root = 0; + continue; + } + if (!Number.isSafeInteger(root) || root <= 0) { + throw new Error( + `${this.label}: active child activation ${activation.activationId} ` + + "has no copied continuation root", + ); + } + activation.root = root; + activation.continuation.attachForReplay( + activation.continuation.format.ptrWidth === 8 ? BigInt(root) : root, + ); + } + this.beginActivationReplay(WPK_FORK_REWINDING, false); + } catch (error) { + this.abort(); + throw error; + } + } + + /** + * Switch a sealed parent transaction to allocation-failure replay. + * + * The caller first seals capture so every already-committed frame and + * reference recipe has one deterministic owner. No child is launched. + */ + beginAbortReplay(errno: number): void { + this.requirePhase("sealed-parent", "begin process abort replay"); + if (!Number.isInteger(errno) || errno <= 0) { + throw new RangeError(`${this.label}: invalid fork abort errno ${errno}`); + } + this.registry.beginParentReplay(); + this.events.beginParentReplay(); + this.phase = "abort-replay"; + try { + this.registry.restoreModuleState(); + for (const activation of this.activeActivations()) { + activation.continuation.beginAbortReplay(errno); + invokeForkContinuationBegin( + requireExportFunction(activation, "wpk_fork_abort_begin"), + activation.root, + activation.continuation.format.ptrWidth, + `${this.label}: activation ${activation.activationId} abort replay`, + ); + this.requireActivationState( + activation, + WPK_FORK_ABORT_UNWINDING, + "begin abort replay", + ); + } + } catch (error) { + this.abort(); + throw error; + } + } + + /** + * Turn a partially committed unwind into deterministic errno replay. + * + * A linked-continuation chunk allocation can fail from inside a generated + * frame postamble. JavaScript cannot return an errno through that postamble, + * so the null reservation asks the same activation stack to replay the + * already committed inner frames and return the allocation error from the + * original fork call. Every activation remains rooted until that replay + * reaches the leaf: an activation with no committed frame can still be an + * outer live caller whose runtime state must return to NORMAL. + */ + beginCaptureAbort(errno: number): void { + this.requirePhase("capture", "begin partial-capture abort replay"); + if (!Number.isInteger(errno) || errno <= 0) { + throw new RangeError(`${this.label}: invalid fork abort errno ${errno}`); + } + try { + this.events.sealCapture(); + this.registry.currentArena().appendReplayEvents(this.events); + this.registry.sealCapture(); + this.registry.beginParentReplay(); + this.events.beginParentReplay(); + this.phase = "abort-replay"; + for (const activation of this.orderedActivations()) { + // reserveFrame has already marked the failing continuation. Repeating + // the same errno is intentionally idempotent; the other activation + // owners need the identical replay cursor and failure result. + activation.continuation.beginAbortReplay(errno); + invokeForkContinuationBegin( + requireExportFunction(activation, "wpk_fork_abort_begin"), + activation.root, + activation.continuation.format.ptrWidth, + `${this.label}: activation ${activation.activationId} partial abort replay`, + ); + this.requireActivationState( + activation, + WPK_FORK_ABORT_UNWINDING, + "begin partial abort replay", + ); + } + } catch (error) { + this.abort(); + throw error; + } + } + + finishReplay(): void { + if (this.phase !== "parent-replay" && this.phase !== "child-replay") { + throw new Error( + `${this.label}: cannot finish process replay while coordinator is ${this.phase}`, + ); + } + this.finishTransaction(false); + } + + finishAbortReplay(): void { + this.requirePhase("abort-replay", "finish process abort replay"); + this.finishTransaction(true); + } + + phaseName(): ProcessContinuationPhase { + return this.phase; + } + + rootFor(activationId: number): number { + return this.getActivation(activationId).root; + } + + abort(): void { + let failure: unknown; + for (const activation of this.orderedActivations()) { + if (!activation.continuation.hasActiveContinuation()) continue; + try { + activation.continuation.cancelUnwindAndRelease(); + } catch (error) { + failure ??= error; + } + activation.root = 0; + } + try { + this.publishProcessLaunchRoot(0, false); + } catch (error) { + failure ??= error; + } + try { + this.events.abort(); + } catch (error) { + failure ??= error; + } + try { + this.registry.abort(); + } catch (error) { + failure ??= error; + } + try { + this.releaseArena(); + } catch (error) { + failure ??= error; + } + this.phase = "idle"; + if (failure !== undefined) throw failure; + } + + clear(): void { + this.abort(); + this.resumeTable.clear(); + this.activations.clear(); + this.prepared.clear(); + this.registry.clear(); + } + + private beginActivationReplay( + expectedState: typeof WPK_FORK_REWINDING, + beginContinuation = true, + ): void { + for (const activation of this.activeActivations()) { + if (beginContinuation) activation.continuation.beginReplay(); + invokeForkContinuationBegin( + requireExportFunction(activation, "wpk_fork_rewind_begin"), + activation.root, + activation.continuation.format.ptrWidth, + `${this.label}: activation ${activation.activationId} replay`, + ); + this.requireActivationState(activation, expectedState, "begin replay"); + } + } + + private finishTransaction(abortReplay: boolean): void { + let failure: unknown; + for (const activation of this.activeActivations()) { + try { + requireExportFunction( + activation, + abortReplay ? "wpk_fork_abort_end" : "wpk_fork_rewind_end", + )(); + this.requireActivationState(activation, WPK_FORK_NORMAL, "finish replay"); + } catch (error) { + failure ??= error; + } + } + try { + this.events.finishReplay(); + } catch (error) { + failure ??= error; + // The child journal may retain zero-copy views into the arena. Drop its + // cursor before the arena mappings are released even when replay ended + // early, or a later diagnostic could read recycled process memory. + this.events.abort(); + } + try { + this.registry.finishReplay(); + } catch (error) { + failure ??= error; + } + for (const activation of this.activeActivations()) { + try { + if (abortReplay) activation.continuation.finishAbortReplayAndRelease(); + else activation.continuation.finishReplayAndRelease(); + } catch (error) { + failure ??= error; + } + activation.root = 0; + } + try { + this.publishProcessLaunchRoot(0); + } catch (error) { + failure ??= error; + } + try { + this.releaseArena(); + } catch (error) { + failure ??= error; + } + this.phase = "idle"; + if (failure !== undefined) throw failure; + } + + private cancelCapture(): void { + let failure: unknown; + for (const activation of this.orderedActivations()) { + if (!activation.continuation.hasActiveContinuation()) continue; + try { + if (activationState(activation) === WPK_FORK_UNWINDING) { + requireExportFunction(activation, "wpk_fork_unwind_end")(); + } + } catch (error) { + failure ??= error; + } + try { + activation.continuation.cancelUnwindAndRelease(); + } catch (error) { + failure ??= error; + } + activation.root = 0; + } + try { + this.publishProcessLaunchRoot(0); + } catch (error) { + failure ??= error; + } + try { + this.events.abort(); + } catch (error) { + failure ??= error; + } + try { + this.registry.abort(); + } catch (error) { + failure ??= error; + } + try { + this.releaseArena(); + } catch (error) { + failure ??= error; + } + this.phase = "idle"; + if (failure !== undefined) throw failure; + } + + private activeActivationIds(): Set { + const ids = new Set(); + const phase = this.events.phaseName(); + if (phase === "capture" || phase === "sealed-parent") { + return this.events.capturedActivationIds(); + } + // During replay, selecting every remaining event would mutate the journal. + // Activation roots came from the exact-set-validated KFMS manifest. + for (const activation of this.activations.values()) { + if (activation.root !== 0) { + ids.add(activation.activationId); + } + } + return ids; + } + + private selectProcessLaunchRoot( + continuations: readonly { + activationId: number; + root: bigint; + }[], + ): number { + if (continuations.length === 0) { + throw new Error(`${this.label}: process continuation manifest is empty`); + } + const selected = continuations.find(({ activationId }) => activationId === 0) + ?? continuations[0]!; + const root = Number(selected.root); + if (!Number.isSafeInteger(root) || root <= 0) { + throw new RangeError( + `${this.label}: activation ${selected.activationId} launch root ` + + `${selected.root} is not a safe guest address`, + ); + } + return root; + } + + private publishProcessLaunchRoot( + root: number, + required = true, + ): void { + const owner = this.activations.get(0); + const publish = owner?.publishProcessLaunchRoot; + const read = owner?.readProcessLaunchRoot; + if (!owner || !publish || !read) { + if (!required) return; + throw new Error( + `${this.label}: activation zero has no process launch anchor`, + ); + } + publish(root); + } + + private readProcessLaunchRoot(): number { + const owner = this.activations.get(0); + const read = owner?.readProcessLaunchRoot; + if (!owner || !owner.publishProcessLaunchRoot || !read) { + throw new Error( + `${this.label}: activation zero has no process launch anchor`, + ); + } + const root = read(); + if (!Number.isSafeInteger(root) || root <= 0) { + throw new Error(`${this.label}: copied process launch root is invalid`); + } + return root; + } + + private releaseArena(): void { + const arena = this.arena; + if (!arena) return; + // Clear ownership first so a failing deallocator cannot make stale KFMS + // bytes look reusable by a later fork transaction. + this.arena = null; + arena.release(); + } + + private activeActivations(): CompleteForkProcessActivation[] { + const active = this.activeActivationIds(); + return this.orderedActivations().filter(({ activationId }) => + active.has(activationId) + ); + } + + private requireSelectedEvent( + activationId: number, + operation: string, + ): ForkReplayEvent { + const event = this.events.peek(); + if (!event) { + throw new Error( + `${this.label}: activation ${activationId} cannot ${operation}; ` + + "the replay event stream is exhausted", + ); + } + if (event.activationId !== activationId) { + throw new Error( + `${this.label}: activation ${activationId} cannot ${operation} frame for ` + + `activation ${event.activationId}`, + ); + } + return event; + } + + private readFunctionOrdinal( + payload: WasmGuestPointer, + continuation: LinkedForkContinuation, + operation: string, + ): number { + const offset = checkedWasmGuestPointerOffset( + payload, + continuation.format.ptrWidth, + `${this.label}: ${operation} frame`, + ); + if (offset > this.memory.buffer.byteLength - 4) { + throw new RangeError( + `${this.label}: ${operation} frame header escapes process memory`, + ); + } + return new DataView(this.memory.buffer).getUint32(offset, true); + } + + private getActivation(activationId: number): CompleteForkProcessActivation { + assertActivationId(activationId); + const activation = this.activations.get(activationId); + if (!activation) { + throw new Error( + `${this.label}: fork activation ${activationId} is not registered`, + ); + } + return activation; + } + + private orderedActivations(): CompleteForkProcessActivation[] { + return [...this.activations.values()].sort( + (left, right) => left.activationId - right.activationId, + ); + } + + private requireActivationState( + activation: CompleteForkProcessActivation, + expected: number, + operation: string, + ): void { + const actual = activationState(activation); + if (actual !== expected) { + throw new Error( + `${this.label}: activation ${activation.activationId} ${operation} ` + + `ended in state ${actual}, expected ${expected}`, + ); + } + } + + private requireIdle(operation: string): void { + this.requirePhase("idle", operation); + } + + private requirePhase( + expected: ProcessContinuationPhase, + operation: string, + ): void { + if (this.phase !== expected) { + throw new Error( + `${this.label}: cannot ${operation} while process continuation is ${this.phase}; ` + + `expected ${expected}`, + ); + } + } +} diff --git a/host/src/fork-reference-broker.ts b/host/src/fork-reference-broker.ts new file mode 100644 index 0000000000..c84f523d1e --- /dev/null +++ b/host/src/fork-reference-broker.ts @@ -0,0 +1,675 @@ +/** + * Process-independent ownership for opaque `externref` values. + * + * WebAssembly treats an externref as an opaque identity. A fork child runs in + * a fresh Worker, so copying a JavaScript object into that Worker is neither + * generally possible nor identity preserving. The host instead keeps the real + * value behind a stable handle and gives every Worker one canonical token for + * that handle. Host-import adapters can route token-bearing calls back to the + * owner without putting JavaScript heap objects in the Wasm continuation. + * + * This file deliberately owns only identity and lifetime. Dispatching a + * particular host import remains the responsibility of that import's adapter; + * the adapter resolves handles through the broker rather than receiving a + * best-effort structured clone. + */ + +const GENERATION_TOKEN = Symbol("kandelo.fork.externref-generation"); +const HANDLE_TOKEN = Symbol("kandelo.fork.externref-handle"); +const WORKER_GENERATION_TOKEN = + Symbol("kandelo.fork.externref-worker-generation"); +const MAX_WIRE_ID = 0xffff_ffff; + +export interface ForkExternrefToken { + readonly [HANDLE_TOKEN]: number; + readonly [WORKER_GENERATION_TOKEN]: number; +} + +/** + * Exact lifetime of one process Wasm image. + * + * A PID survives exec, so it is not sufficient authority for a host-owned + * externref. The broker issues a fresh token for every execution generation + * and rejects a token as soon as that generation is replaced or released. + */ +export interface ForkExternrefGeneration { + readonly id: number; + readonly pid: number; + readonly [GENERATION_TOKEN]: true; +} + +export interface ForkExternrefLease { + readonly generation: ForkExternrefGeneration; + readonly handleCount: number; + release(): void; +} + +export interface ForkExternrefBrokerOptions { + /** Test seam; production handles use the complete nonzero-u32 wire space. */ + readonly maxHandle?: number; + /** Test seam; production generations use the complete nonzero-u32 space. */ + readonly maxGeneration?: number; +} + +interface BrokerEntry { + value: unknown; + holders: Set; +} + +interface BrokerForkLeaseState { + readonly generation: BrokerGenerationState; + readonly handles: Set; + released: boolean; +} + +interface BrokerGenerationState { + readonly token: ForkExternrefGeneration; + readonly directHandles: Set; + readonly forkHandleCounts: Map; + readonly handles: Set; + readonly forkLeases: Set; + status: "active" | "released" | "replaced"; +} + +function assertProcessId(pid: number): void { + if (!Number.isInteger(pid) || pid <= 0 || pid > MAX_WIRE_ID) { + throw new RangeError(`invalid externref holder pid ${pid}`); + } +} + +function assertHandle(handle: number): void { + if (!Number.isInteger(handle) || handle <= 0 || handle > MAX_WIRE_ID) { + throw new RangeError(`invalid externref handle ${handle}`); + } +} + +function assertWireLimit(value: number, name: string): void { + if (!Number.isInteger(value) || value <= 0 || value > MAX_WIRE_ID) { + throw new RangeError(`${name} must be a positive unsigned 32-bit integer`); + } +} + +class BrokerForkExternrefLease implements ForkExternrefLease { + readonly handleCount: number; + + constructor( + readonly generation: ForkExternrefGeneration, + state: BrokerForkLeaseState, + private readonly releaseState: () => void, + ) { + // Do not duplicate the potentially large handle set merely for + // diagnostics; the broker-owned lease state is its sole lifetime owner. + this.handleCount = state.handles.size; + } + + release(): void { + this.releaseState(); + } +} + +/** + * Kernel-side owner for real JavaScript values. + * + * Ownership is deliberately generation-scoped and set-valued. Ten globals or + * graph edges that alias one externref require one strong owner entry, not ten + * reference counts. Separate successful fork transactions retain independent + * leases so rolling one transaction back cannot revoke another transaction or + * a host import's direct registration. + */ +export class ForkExternrefBroker { + private nextHandle = 1; + private nextGeneration = 1; + private readonly entries = new Map(); + private readonly objectHandles = new WeakMap(); + private readonly primitiveHandles = new Map(); + private readonly numberHandles = new Map(); + private readonly generations = + new WeakMap(); + private readonly currentGenerations = new Map(); + private readonly maxHandle: number; + private readonly maxGeneration: number; + + constructor(options: ForkExternrefBrokerOptions = {}) { + this.maxHandle = options.maxHandle ?? MAX_WIRE_ID; + this.maxGeneration = options.maxGeneration ?? MAX_WIRE_ID; + assertWireLimit(this.maxHandle, "externref broker maxHandle"); + assertWireLimit(this.maxGeneration, "externref broker maxGeneration"); + } + + /** + * Begin one exact process-image lifetime. + * + * Creating a replacement for the same PID retires the old generation before + * the new token is returned. WHY: delayed worker teardown must never use an + * old PID-only capability to resolve values for the post-exec image. + */ + createGeneration(pid: number): ForkExternrefGeneration { + assertProcessId(pid); + if (this.nextGeneration > this.maxGeneration) { + throw new RangeError("externref generation space exhausted"); + } + const id = this.nextGeneration++; + const token: ForkExternrefGeneration = Object.freeze({ + id, + pid, + [GENERATION_TOKEN]: true as const, + }); + const state: BrokerGenerationState = { + token, + directHandles: new Set(), + forkHandleCounts: new Map(), + handles: new Set(), + forkLeases: new Set(), + status: "active", + }; + + const previous = this.currentGenerations.get(pid); + if (previous) this.closeGeneration(previous, "replaced"); + this.generations.set(token, state); + this.currentGenerations.set(pid, state); + return token; + } + + register(generation: ForkExternrefGeneration, value: unknown): number { + const state = this.requireActiveGeneration(generation); + const known = this.lookupValueHandle(value); + if (known !== undefined) { + const entry = this.requireEntry(known); + this.acquireDirect(state, known, entry); + return known; + } + + if (this.nextHandle > this.maxHandle) { + throw new RangeError("externref handle space exhausted"); + } + // Reserve monotonically before publishing any map entry. Even a failed + // publication leaves a gap rather than making a stale wire handle alias a + // future value. + const handle = this.nextHandle++; + const entry: BrokerEntry = { + value, + holders: new Set(), + }; + this.entries.set(handle, entry); + try { + this.rememberValueHandle(value, handle); + state.directHandles.add(handle); + state.handles.add(handle); + entry.holders.add(state); + } catch (error) { + state.directHandles.delete(handle); + state.handles.delete(handle); + entry.holders.delete(state); + this.forget(handle, value); + throw error; + } + return handle; + } + + /** + * Grant a directly managed handle to a generation. + * + * Repeated acquisition is idempotent because aliases share the generation's + * one direct lease. + */ + acquire(generation: ForkExternrefGeneration, handle: number): void { + const state = this.requireActiveGeneration(generation); + assertHandle(handle); + this.acquireDirect(state, handle, this.requireEntry(handle)); + } + + /** + * Duplicate a parent's unique handle set for a fork child. + * + * WHY validation and mutation are separate passes: a corrupt recipe must not + * leave the child holding the valid prefix of an otherwise rejected + * snapshot. The mutation pass also has an explicit rollback so any future + * bookkeeping that can fail preserves that all-or-nothing boundary. + */ + acquireFork( + parentGeneration: ForkExternrefGeneration, + childGeneration: ForkExternrefGeneration, + uniqueHandles: Iterable, + ): ForkExternrefLease { + const parent = this.requireActiveGeneration(parentGeneration); + const child = this.requireActiveGeneration(childGeneration); + if (parent === child || parent.token.pid === child.token.pid) { + throw new Error("externref fork requires distinct process generations"); + } + + const handles = new Set(); + for (const handle of uniqueHandles) { + assertHandle(handle); + handles.add(handle); + } + + const validated: Array<[number, BrokerEntry]> = []; + for (const handle of handles) { + const entry = this.requireEntry(handle); + if (!parent.handles.has(handle) || !entry.holders.has(parent)) { + throw new Error( + `externref generation ${parent.token.id} for pid ${parent.token.pid} ` + + `does not own handle ${handle}`, + ); + } + if (child.handles.has(handle) !== entry.holders.has(child)) { + throw new Error( + `externref generation ${child.token.id} has inconsistent ownership ` + + `for handle ${handle}`, + ); + } + const childLeaseCount = child.forkHandleCounts.get(handle) ?? 0; + if (childLeaseCount >= Number.MAX_SAFE_INTEGER) { + throw new RangeError( + `externref fork lease count overflow for handle ${handle}`, + ); + } + validated.push([handle, entry]); + } + + const leaseState: BrokerForkLeaseState = { + generation: child, + handles: new Set(), + released: false, + }; + const applied: Array<[number, BrokerEntry, number, boolean]> = []; + try { + for (const [handle, entry] of validated) { + const previousCount = child.forkHandleCounts.get(handle) ?? 0; + const addedOwnership = !child.handles.has(handle); + // Record the old state before the first mutation so every partial step + // in this iteration is included in rollback. + applied.push([handle, entry, previousCount, addedOwnership]); + child.forkHandleCounts.set(handle, previousCount + 1); + if (addedOwnership) { + child.handles.add(handle); + entry.holders.add(child); + } + leaseState.handles.add(handle); + } + child.forkLeases.add(leaseState); + } catch (error) { + for (let index = applied.length - 1; index >= 0; index--) { + const [handle, entry, previousCount, addedOwnership] = applied[index]!; + if (previousCount === 0) child.forkHandleCounts.delete(handle); + else child.forkHandleCounts.set(handle, previousCount); + if (addedOwnership) { + child.handles.delete(handle); + entry.holders.delete(child); + } + } + throw error; + } + return new BrokerForkExternrefLease( + child.token, + leaseState, + () => this.releaseForkLease(leaseState), + ); + } + + /** Release one generation's direct (non-fork-transaction) lease. */ + release(generation: ForkExternrefGeneration, handle: number): void { + const state = this.requireActiveGeneration(generation); + assertHandle(handle); + const entry = this.requireEntry(handle); + if (!state.directHandles.has(handle)) { + throw new Error( + `externref generation ${state.token.id} for pid ${state.token.pid} ` + + `has no direct lease for handle ${handle}`, + ); + } + state.directHandles.delete(handle); + if ((state.forkHandleCounts.get(handle) ?? 0) === 0) { + this.removeGenerationHandle(state, handle, entry); + } + } + + /** Retire every handle and lease owned by one exact execution generation. */ + releaseGeneration(generation: ForkExternrefGeneration): boolean { + const state = this.generationState(generation); + if (state.status !== "active") return false; + this.closeGeneration(state, "released"); + return true; + } + + /** + * Validate one generation-scoped capability and return its opaque value. + * + * Host adapters must call this at dispatch time; possession of a numeric + * handle or a PID alone is not authority. + */ + authorize(generation: ForkExternrefGeneration, handle: number): unknown { + const state = this.requireActiveGeneration(generation); + assertHandle(handle); + const entry = this.requireEntry(handle); + if (!state.handles.has(handle) || !entry.holders.has(state)) { + throw new Error( + `externref generation ${state.token.id} for pid ${state.token.pid} ` + + `is not authorized for handle ${handle}`, + ); + } + return entry.value; + } + + /** Compatibility name for adapters that previously resolved PID ownership. */ + resolve(generation: ForkExternrefGeneration, handle: number): unknown { + return this.authorize(generation, handle); + } + + /** + * Permanently retire a handle after an explicit host-resource close. + * + * Every generation loses authorization, and registering the same JS value + * later receives a fresh monotonically larger handle. The monotonic allocator + * itself is the tombstone set: any issued-but-absent number is retired. + */ + tombstone(generation: ForkExternrefGeneration, handle: number): void { + const owner = this.requireActiveGeneration(generation); + assertHandle(handle); + const entry = this.requireEntry(handle); + if (!owner.handles.has(handle) || !entry.holders.has(owner)) { + throw new Error( + `externref generation ${owner.token.id} for pid ${owner.token.pid} ` + + `cannot tombstone unowned handle ${handle}`, + ); + } + + for (const holder of [...entry.holders]) { + holder.directHandles.delete(handle); + holder.forkHandleCounts.delete(handle); + holder.handles.delete(handle); + for (const lease of holder.forkLeases) lease.handles.delete(handle); + } + entry.holders.clear(); + this.forget(handle, entry.value); + } + + /** Set ownership is observable as either zero or one, never graph aliases. */ + holderCount( + handle: number, + generation: ForkExternrefGeneration, + ): 0 | 1 { + assertHandle(handle); + const state = this.generationState(generation); + if (state.status !== "active") return 0; + const entry = this.entries.get(handle); + return state.handles.has(handle) && entry?.holders.has(state) ? 1 : 0; + } + + private releaseForkLease(lease: BrokerForkLeaseState): void { + if (lease.released) { + throw new Error("externref fork lease is already released"); + } + const generation = lease.generation; + this.requireActiveGeneration(generation.token); + + // Verify the whole lease before removing anything. Lifecycle corruption + // must not release a valid prefix and retain the rest. + const entries = new Map(); + for (const handle of lease.handles) { + const entry = this.requireEntry(handle); + const count = generation.forkHandleCounts.get(handle) ?? 0; + if ( + count <= 0 + || !generation.handles.has(handle) + || !entry.holders.has(generation) + ) { + throw new Error( + `externref generation ${generation.token.id} no longer owns ` + + `fork lease handle ${handle}`, + ); + } + entries.set(handle, entry); + } + + for (const handle of lease.handles) { + const entry = entries.get(handle)!; + const count = generation.forkHandleCounts.get(handle)!; + if (count === 1) { + generation.forkHandleCounts.delete(handle); + if (!generation.directHandles.has(handle)) { + this.removeGenerationHandle(generation, handle, entry); + } + } else { + generation.forkHandleCounts.set(handle, count - 1); + } + } + lease.handles.clear(); + lease.released = true; + generation.forkLeases.delete(lease); + } + + private acquireDirect( + generation: BrokerGenerationState, + handle: number, + entry: BrokerEntry, + ): void { + if (generation.directHandles.has(handle)) return; + const addedOwnership = !generation.handles.has(handle); + generation.directHandles.add(handle); + try { + if (addedOwnership) { + generation.handles.add(handle); + entry.holders.add(generation); + } + } catch (error) { + generation.directHandles.delete(handle); + if (addedOwnership) { + generation.handles.delete(handle); + entry.holders.delete(generation); + } + throw error; + } + } + + private removeGenerationHandle( + generation: BrokerGenerationState, + handle: number, + entry: BrokerEntry, + ): void { + generation.handles.delete(handle); + entry.holders.delete(generation); + if (entry.holders.size === 0) this.forget(handle, entry.value); + } + + private closeGeneration( + generation: BrokerGenerationState, + status: "released" | "replaced", + ): void { + if (generation.status !== "active") return; + generation.status = status; + if (this.currentGenerations.get(generation.token.pid) === generation) { + this.currentGenerations.delete(generation.token.pid); + } + for (const lease of generation.forkLeases) { + lease.handles.clear(); + lease.released = true; + } + generation.forkLeases.clear(); + generation.directHandles.clear(); + generation.forkHandleCounts.clear(); + for (const handle of generation.handles) { + const entry = this.entries.get(handle); + if (!entry) continue; + entry.holders.delete(generation); + if (entry.holders.size === 0) this.forget(handle, entry.value); + } + generation.handles.clear(); + } + + private generationState( + generation: ForkExternrefGeneration, + ): BrokerGenerationState { + if ( + typeof generation !== "object" + || generation === null + || generation[GENERATION_TOKEN] !== true + ) { + throw new Error("unknown externref generation token"); + } + const state = this.generations.get(generation); + if (!state) throw new Error("externref generation belongs to another broker"); + return state; + } + + private requireActiveGeneration( + generation: ForkExternrefGeneration, + ): BrokerGenerationState { + const state = this.generationState(generation); + if ( + state.status !== "active" + || this.currentGenerations.get(state.token.pid) !== state + ) { + throw new Error( + `stale externref generation ${state.token.id} for pid ${state.token.pid}`, + ); + } + return state; + } + + private requireEntry(handle: number): BrokerEntry { + const entry = this.entries.get(handle); + if (entry) return entry; + if (handle < this.nextHandle) { + throw new Error(`retired externref handle ${handle}`); + } + throw new Error(`unknown externref handle ${handle}`); + } + + private lookupValueHandle(value: unknown): number | undefined { + if ((typeof value === "object" && value !== null) || typeof value === "function") { + return this.objectHandles.get(value as object); + } + if (typeof value === "number") { + return this.numberHandles.get(exactNumberBits(value)); + } + return this.primitiveHandles.get(value); + } + + private rememberValueHandle(value: unknown, handle: number): void { + if ((typeof value === "object" && value !== null) || typeof value === "function") { + this.objectHandles.set(value as object, handle); + } else if (typeof value === "number") { + this.numberHandles.set(exactNumberBits(value), handle); + } else { + this.primitiveHandles.set(value, handle); + } + } + + private forget(handle: number, value: unknown): void { + this.entries.delete(handle); + if ((typeof value === "object" && value !== null) || typeof value === "function") { + // WeakMap has no conditional delete. Deleting is safe because a handle + // is removed only after every process holder released the strong entry. + this.objectHandles.delete(value as object); + } else if (typeof value === "number") { + const bits = exactNumberBits(value); + if (this.numberHandles.get(bits) === handle) { + this.numberHandles.delete(bits); + } + } else if (this.primitiveHandles.get(value) === handle) { + this.primitiveHandles.delete(value); + } + } +} + +function exactNumberBits(value: number): bigint { + const bytes = new ArrayBuffer(Float64Array.BYTES_PER_ELEMENT); + const view = new DataView(bytes); + view.setFloat64(0, value, true); + return view.getBigUint64(0, true); +} + +/** + * Worker-local canonical tokens for broker handles. + * + * A child never receives the parent's token object. It recreates exactly one + * local token per handle, which preserves all identity observations available + * to Wasm while keeping the actual object under broker ownership. + */ +export class ForkExternrefTokenCache { + private readonly tokens = new Map>(); + + constructor(readonly generationId: number) { + assertWireLimit(generationId, "externref worker generation"); + } + + materialize(handle: number): ForkExternrefToken { + assertHandle(handle); + let token = this.tokens.get(handle)?.deref(); + if (!token) { + token = Object.freeze({ + [HANDLE_TOKEN]: handle, + [WORKER_GENERATION_TOKEN]: this.generationId, + }); + this.tokens.set(handle, new WeakRef(token)); + } + return token; + } + + encode(value: unknown): number | null { + if ( + typeof value !== "object" + || value === null + || !(HANDLE_TOKEN in value) + || !(WORKER_GENERATION_TOKEN in value) + ) { + return null; + } + if ( + (value as ForkExternrefToken)[WORKER_GENERATION_TOKEN] + !== this.generationId + ) { + return null; + } + const handle = (value as ForkExternrefToken)[HANDLE_TOKEN]; + assertHandle(handle); + return handle; + } + + clear(): void { + // Weak references do not own the tokens; clearing merely forgets canonical + // lookup entries at exec/process teardown. + this.tokens.clear(); + } +} + +/** + * Worker-facing recipe provider for externrefs already adapted by the process + * owner. + * + * Host imports that create opaque values must register them with the + * process-wide owner and return this Worker's canonical token. Consequently + * the continuation encoder never needs to clone or inspect the real value. + */ +export class ForkExternrefTokenRecipeProvider { + constructor( + private readonly tokens: ForkExternrefTokenCache, + /** + * Late owner adoption for an exact Worker-local value that has never + * needed to cross a process boundary before this fork. + */ + private readonly normalizeUnclaimed?: ( + value: unknown, + ) => ForkExternrefToken, + ) {} + + capture(value: unknown): number { + let handle = this.tokens.encode(value); + if (handle === null && this.normalizeUnclaimed) { + // WHY: the transaction separately retains `value` for parent replay. + // Only the fresh-child recipe uses this canonical owner token. + handle = this.tokens.encode(this.normalizeUnclaimed(value)); + } + if (handle === null) { + throw new Error( + "externref reached fork without passing through the process reference owner", + ); + } + return handle; + } + + materialize(handle: number): ForkExternrefToken { + return this.tokens.materialize(handle); + } +} diff --git a/host/src/fork-reference-recipes.ts b/host/src/fork-reference-recipes.ts new file mode 100644 index 0000000000..ef702c7408 --- /dev/null +++ b/host/src/fork-reference-recipes.ts @@ -0,0 +1,1316 @@ +/** + * Versioned, activation-owned reconstruction recipes for Wasm references. + * + * The wire image contains only integers and graph edges. JavaScript/Wasm + * objects stay under an explicit reconstruction owner and never become + * accidental evidence that a fresh fork Worker inherited module state. + */ + +import { + ForkFunctionCatalog, + type ForkFunctionRecipe, +} from "./fork-function-catalog"; +import { + ForkExternrefBroker, + type ForkExternrefGeneration, + type ForkExternrefLease, +} from "./fork-reference-broker"; +import { + ForkStaticRootCatalog, + type ForkStaticRootRecipe, +} from "./fork-static-root-catalog"; + +export const FORK_REFERENCE_RECIPE_VERSION = 1; + +const WIRE_MAGIC = 0x5252_464b; // "KFRR", little endian. +const HEADER_SIZE = 40; +const NODE_SIZE = 32; +const MAX_I31 = 0x3fff_ffff; +const MIN_I31 = -0x4000_0000; + +// Version 1 is: a 40-byte header, 32-byte node records in ascending recipe-ID +// order, ordered root IDs, one canonical edge vector, then exact scalar +// payload bytes. Fixed records make bounds validation O(1) per node and let +// decoders reject overlapping or reordered edge/blob ranges rather than +// accepting multiple encodings of one graph. + +const enum WireNodeKind { + Null = 0, + Funcref = 1, + Externref = 2, + Exnref = 3, + I31 = 4, + Struct = 5, + Array = 6, + StaticRoot = 7, +} + +export interface ForkReferenceRecipeLimits { + readonly maxWireBytes: number; + readonly maxNodes: number; + readonly maxRoots: number; + readonly maxEdges: number; +} + +const MAX_WIRE_U32 = 0xffff_ffff; + +export const DEFAULT_FORK_REFERENCE_RECIPE_LIMITS: ForkReferenceRecipeLimits = + Object.freeze({ + // These are the version-1 wire fields' representational bounds, not + // smaller policy quotas. The canonical byte-length equation below is the + // tighter combined bound and allocation failure remains the truthful + // resource boundary for a valid process graph. + maxWireBytes: MAX_WIRE_U32, + maxNodes: MAX_WIRE_U32, + maxRoots: MAX_WIRE_U32, + maxEdges: MAX_WIRE_U32, + }); + +export interface ForkNullRecipe { + readonly kind: "null"; +} + +export interface ForkFuncrefRecipe { + readonly kind: "funcref"; + readonly moduleActivation: number; + readonly functionOrdinal: number; +} + +export interface ForkExternrefRecipe { + readonly kind: "externref"; + readonly handle: number; +} + +export interface ForkExnrefRecipe { + readonly kind: "exnref"; + readonly moduleActivation: number; + readonly tagOrdinal: number; + /** Stable artifact-emitted payload layout for this tag. */ + readonly layoutId?: number; + /** Exact scalar payload bits; reference payloads remain graph edges. */ + readonly scalars?: Uint8Array; + readonly payloads: readonly number[]; +} + +export interface ForkI31Recipe { + readonly kind: "i31"; + readonly value: number; +} + +export interface ForkStructRecipe { + readonly kind: "struct"; + readonly moduleActivation: number; + readonly typeOrdinal: number; + readonly layoutId?: number; + /** Exact packed/non-reference field bits in artifact-catalog order. */ + readonly scalars?: Uint8Array; + readonly fields: readonly number[]; +} + +export interface ForkArrayRecipe { + readonly kind: "array"; + readonly moduleActivation: number; + readonly typeOrdinal: number; + readonly layoutId?: number; + /** Exact element bits for scalar arrays; empty for reference arrays. */ + readonly scalars?: Uint8Array; + readonly elements: readonly number[]; +} + +export interface ForkStaticReferenceRootRecipe { + readonly kind: "static-root"; + readonly moduleActivation: number; + readonly staticRootOrdinal: number; +} + +export type ForkReferenceRecipeNode = + | ForkNullRecipe + | ForkFuncrefRecipe + | ForkExternrefRecipe + | ForkExnrefRecipe + | ForkI31Recipe + | ForkStructRecipe + | ForkArrayRecipe + | ForkStaticReferenceRootRecipe; + +export interface ForkReferenceRecipeEntry { + /** Graph-local identity. Aggregate edges and roots refer to this value. */ + readonly id: number; + readonly node: ForkReferenceRecipeNode; +} + +export interface ForkReferenceRecipeGraph { + readonly roots: readonly number[]; + readonly nodes: readonly ForkReferenceRecipeEntry[]; +} + +export interface ForkReferenceModuleTypes { + readonly tags?: readonly { + readonly ordinal: number; + readonly payloadCount: number; + }[]; + readonly structs?: readonly { + readonly ordinal: number; + readonly fieldCount: number; + }[]; + readonly arrays?: readonly { + readonly ordinal: number; + }[]; +} + +interface RegisteredReferenceTypes { + tags: Map; + structs: Map; + arrays: Set; +} + +/** + * Instance-local ownership for exception tags and Wasm GC type identities. + * + * Ordinals are emitted from deterministic artifact catalogs. A child registers + * the same module-activation/type coordinates after instantiation, before a + * recipe is allowed to allocate anything. + */ +export class ForkReferenceTypeCatalog { + private readonly modules = new Map(); + + register(moduleActivation: number, types: ForkReferenceModuleTypes): void { + assertU32(moduleActivation, "module activation"); + if (this.modules.has(moduleActivation)) { + throw new Error( + `reference type catalog ${moduleActivation} is already registered`, + ); + } + + const registered: RegisteredReferenceTypes = { + tags: new Map(), + structs: new Map(), + arrays: new Set(), + }; + for (const tag of types.tags ?? []) { + assertU32(tag.ordinal, "exception tag ordinal"); + assertU32(tag.payloadCount, "exception tag payload count"); + if (registered.tags.has(tag.ordinal)) { + throw new Error(`duplicate exception tag ordinal ${tag.ordinal}`); + } + registered.tags.set(tag.ordinal, tag.payloadCount); + } + for (const struct of types.structs ?? []) { + assertU32(struct.ordinal, "struct type ordinal"); + assertU32(struct.fieldCount, "struct field count"); + if (registered.structs.has(struct.ordinal)) { + throw new Error(`duplicate struct type ordinal ${struct.ordinal}`); + } + registered.structs.set(struct.ordinal, struct.fieldCount); + } + for (const array of types.arrays ?? []) { + assertU32(array.ordinal, "array type ordinal"); + if (registered.arrays.has(array.ordinal)) { + throw new Error(`duplicate array type ordinal ${array.ordinal}`); + } + registered.arrays.add(array.ordinal); + } + this.modules.set(moduleActivation, registered); + } + + validateTag( + moduleActivation: number, + tagOrdinal: number, + payloadCount: number, + ): void { + const types = this.requireModule(moduleActivation); + assertU32(tagOrdinal, "exception tag ordinal"); + const expected = types.tags.get(tagOrdinal); + if (expected === undefined) { + throw new Error( + `exception tag ${moduleActivation}:${tagOrdinal} is not registered`, + ); + } + if (expected !== payloadCount) { + throw new Error( + `exception tag ${moduleActivation}:${tagOrdinal} expects ` + + `${expected} reference payloads, found ${payloadCount}`, + ); + } + } + + validateStruct( + moduleActivation: number, + typeOrdinal: number, + fieldCount: number, + ): void { + const types = this.requireModule(moduleActivation); + assertU32(typeOrdinal, "struct type ordinal"); + const expected = types.structs.get(typeOrdinal); + if (expected === undefined) { + throw new Error( + `struct type ${moduleActivation}:${typeOrdinal} is not registered`, + ); + } + if (expected !== fieldCount) { + throw new Error( + `struct type ${moduleActivation}:${typeOrdinal} expects ` + + `${expected} reference fields, found ${fieldCount}`, + ); + } + } + + validateArray(moduleActivation: number, typeOrdinal: number): void { + const types = this.requireModule(moduleActivation); + assertU32(typeOrdinal, "array type ordinal"); + if (!types.arrays.has(typeOrdinal)) { + throw new Error( + `array type ${moduleActivation}:${typeOrdinal} is not registered`, + ); + } + } + + clear(): void { + this.modules.clear(); + } + + private requireModule(moduleActivation: number): RegisteredReferenceTypes { + assertU32(moduleActivation, "module activation"); + const types = this.modules.get(moduleActivation); + if (!types) { + throw new Error( + `reference type catalog ${moduleActivation} is not registered`, + ); + } + return types; + } +} + +/** + * One fresh-instance staging arena. + * + * Aggregate allocation and edge initialization are separate so the target can + * preserve cycles and shared identity. `commit` must atomically install the + * roots and release its staging roots. `abort` must discard every staged root. + */ +export interface ForkReferenceReplayArena { + materializeExternref(handle: number): unknown; + materializeI31(value: number): unknown; + allocateException( + moduleActivation: number, + tagOrdinal: number, + payloadCount: number, + ): unknown; + allocateStruct( + moduleActivation: number, + typeOrdinal: number, + fieldCount: number, + ): unknown; + allocateArray( + moduleActivation: number, + typeOrdinal: number, + length: number, + ): unknown; + setExceptionPayload(exception: unknown, index: number, value: unknown): void; + setStructField(struct: unknown, index: number, value: unknown): void; + setArrayElement(array: unknown, index: number, value: unknown): void; + commit(roots: readonly unknown[]): void; + abort(): void; +} + +export interface ForkReferenceReplayTarget { + readonly functions: ForkFunctionCatalog; + readonly types: ForkReferenceTypeCatalog; + readonly staticRoots?: ForkStaticRootCatalog; + beginReferenceReplay(nodeCount: number): ForkReferenceReplayArena; +} + +export interface ForkReferenceReplayRequest { + readonly parentGeneration: ForkExternrefGeneration; + readonly childGeneration: ForkExternrefGeneration; + readonly wire: Uint8Array; + readonly target: ForkReferenceReplayTarget; +} + +/** + * Numeric broker ownership transferred to the child by a successful replay. + * + * The coordinator retains no JS/Wasm references after `replay` returns. The + * process/activation owner releases this lease when the reconstructed values + * can no longer reach host externrefs. + */ +export interface ForkReferenceReplayOwnership { + readonly childGeneration: ForkExternrefGeneration; + readonly childPid: number; + release(): void; +} + +class BrokerReferenceReplayOwnership implements ForkReferenceReplayOwnership { + private released = false; + readonly childPid: number; + + constructor( + readonly childGeneration: ForkExternrefGeneration, + private readonly lease: ForkExternrefLease, + ) { + this.childPid = childGeneration.pid; + } + + release(): void { + if (this.released) { + throw new Error("reference replay ownership is already released"); + } + this.lease.release(); + this.released = true; + } +} + +export class ForkReferenceRecipeCoordinator { + constructor( + private readonly sourceFunctions: ForkFunctionCatalog, + private readonly sourceTypes: ForkReferenceTypeCatalog, + private readonly broker: ForkExternrefBroker, + private readonly limits: ForkReferenceRecipeLimits = + DEFAULT_FORK_REFERENCE_RECIPE_LIMITS, + private readonly sourceStaticRoots?: ForkStaticRootCatalog, + ) { + validateLimits(limits); + } + + replay(request: ForkReferenceReplayRequest): ForkReferenceReplayOwnership { + if (request.parentGeneration === request.childGeneration) { + throw new Error( + "fork reference replay requires distinct parent and child generations", + ); + } + + const graph = decodeForkReferenceRecipes(request.wire, this.limits); + validateCatalogOwnership( + graph, + this.sourceFunctions, + this.sourceTypes, + this.sourceStaticRoots, + "source", + ); + validateCatalogOwnership( + graph, + request.target.functions, + request.target.types, + request.target.staticRoots, + "target", + ); + const handles = externrefOwnershipSet(graph); + + let lease: ForkExternrefLease | undefined; + let arena: ForkReferenceReplayArena | undefined; + const values: unknown[] = new Array(graph.nodes.length); + let roots: unknown[] = []; + try { + lease = this.broker.acquireFork( + request.parentGeneration, + request.childGeneration, + handles, + ); + arena = request.target.beginReferenceReplay(graph.nodes.length); + + for (const entry of graph.nodes) { + values[entry.id] = allocateRecipeNode( + entry.node, + request.target.functions, + request.target.staticRoots, + arena, + ); + } + for (const entry of graph.nodes) { + connectRecipeNode(entry, values, arena); + } + roots = graph.roots.map((id) => values[id]); + arena.commit(roots); + + // WHY: successful installation transfers the only strong roots to the + // child activation. The coordinator keeps only numeric broker ownership. + values.fill(undefined); + roots.fill(undefined); + return new BrokerReferenceReplayOwnership( + request.childGeneration, + lease, + ); + } catch (error) { + const rollbackErrors: unknown[] = [error]; + if (arena) { + try { + arena.abort(); + } catch (abortError) { + rollbackErrors.push(abortError); + } + } + values.fill(undefined); + roots.fill(undefined); + if (lease) { + try { + lease.release(); + } catch (releaseError) { + rollbackErrors.push(releaseError); + } + } + if (rollbackErrors.length === 1) throw error; + throw new AggregateError( + rollbackErrors, + "reference replay failed and rollback was incomplete", + ); + } + } +} + +export function encodeForkReferenceRecipes( + graph: ForkReferenceRecipeGraph, + limits: ForkReferenceRecipeLimits = + DEFAULT_FORK_REFERENCE_RECIPE_LIMITS, +): Uint8Array { + validateLimits(limits); + if (graph.nodes.length > limits.maxNodes) { + throw new RangeError( + `reference recipe has ${graph.nodes.length} nodes; limit is ${limits.maxNodes}`, + ); + } + if (graph.roots.length > limits.maxRoots) { + throw new RangeError( + `reference recipe has ${graph.roots.length} roots; limit is ${limits.maxRoots}`, + ); + } + + const ordered = graph.nodes.slice().sort((left, right) => left.id - right.id); + const remap = new Map(); + for (const [wireId, entry] of ordered.entries()) { + assertU32(entry.id, `reference recipe node ${wireId} id`); + if (remap.has(entry.id)) { + throw new Error(`duplicate reference recipe node id ${entry.id}`); + } + remap.set(entry.id, wireId); + validateNodeScalars(entry.node, `reference recipe node ${entry.id}`); + } + + const canonicalNodes: ForkReferenceRecipeEntry[] = ordered.map( + (entry, id) => ({ + id, + node: remapNodeEdges(entry.node, remap, `reference recipe node ${entry.id}`), + }), + ); + const roots = graph.roots.map((id, index) => + remapRequiredId(remap, id, `reference recipe root ${index}`) + ); + const canonical: ForkReferenceRecipeGraph = { roots, nodes: canonicalNodes }; + validateReachability(canonical); + + const edgeCount = canonicalNodes.reduce( + (count, entry) => checkedAdd(count, nodeEdges(entry.node).length, "edge count"), + 0, + ); + if (edgeCount > limits.maxEdges) { + throw new RangeError( + `reference recipe has ${edgeCount} edges; limit is ${limits.maxEdges}`, + ); + } + const blobByteLength = canonicalNodes.reduce( + (count, entry) => + checkedAdd(count, nodeScalarBytes(entry.node).byteLength, "scalar blob byte length"), + 0, + ); + const totalBytes = wireByteLength( + canonicalNodes.length, + roots.length, + edgeCount, + blobByteLength, + ); + if (totalBytes > limits.maxWireBytes) { + throw new RangeError( + `reference recipe needs ${totalBytes} bytes; limit is ${limits.maxWireBytes}`, + ); + } + + const bytes = new Uint8Array(totalBytes); + const view = new DataView(bytes.buffer); + view.setUint32(0, WIRE_MAGIC, true); + view.setUint16(4, FORK_REFERENCE_RECIPE_VERSION, true); + view.setUint16(6, HEADER_SIZE, true); + view.setUint32(8, totalBytes, true); + view.setUint32(12, canonicalNodes.length, true); + view.setUint32(16, roots.length, true); + view.setUint32(20, edgeCount, true); + view.setUint32(24, blobByteLength, true); + view.setUint32(28, NODE_SIZE, true); + view.setUint32(32, 0, true); + view.setUint32(36, 0, true); + + const rootsOffset = HEADER_SIZE + canonicalNodes.length * NODE_SIZE; + const edgesOffset = rootsOffset + roots.length * 4; + const blobsOffset = edgesOffset + edgeCount * 4; + let nextEdge = 0; + let nextBlobByte = 0; + for (const entry of canonicalNodes) { + const offset = HEADER_SIZE + entry.id * NODE_SIZE; + const edges = nodeEdges(entry.node); + const blob = nodeScalarBytes(entry.node); + encodeNodeRecord( + view, + offset, + entry.node, + nextEdge, + edges.length, + nextBlobByte, + blob.byteLength, + ); + for (const edge of edges) { + view.setUint32(edgesOffset + nextEdge * 4, edge, true); + nextEdge++; + } + bytes.set(blob, blobsOffset + nextBlobByte); + nextBlobByte += blob.byteLength; + } + for (const [index, root] of roots.entries()) { + view.setUint32(rootsOffset + index * 4, root, true); + } + return bytes; +} + +export function decodeForkReferenceRecipes( + bytes: Uint8Array, + limits: ForkReferenceRecipeLimits = + DEFAULT_FORK_REFERENCE_RECIPE_LIMITS, +): ForkReferenceRecipeGraph { + validateLimits(limits); + if (bytes.byteLength < HEADER_SIZE) { + throw new Error("reference recipe header is truncated"); + } + if (bytes.byteLength > limits.maxWireBytes) { + throw new RangeError( + `reference recipe has ${bytes.byteLength} bytes; limit is ${limits.maxWireBytes}`, + ); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (view.getUint32(0, true) !== WIRE_MAGIC) { + throw new Error("reference recipe has invalid magic"); + } + const version = view.getUint16(4, true); + if (version !== FORK_REFERENCE_RECIPE_VERSION) { + throw new Error(`unsupported reference recipe version ${version}`); + } + if (view.getUint16(6, true) !== HEADER_SIZE) { + throw new Error("reference recipe declares an invalid header size"); + } + if (view.getUint32(8, true) !== bytes.byteLength) { + throw new Error("reference recipe declared byte length does not match its buffer"); + } + const nodeCount = view.getUint32(12, true); + const rootCount = view.getUint32(16, true); + const edgeCount = view.getUint32(20, true); + const blobByteLength = view.getUint32(24, true); + if (nodeCount > limits.maxNodes) { + throw new RangeError( + `reference recipe has ${nodeCount} nodes; limit is ${limits.maxNodes}`, + ); + } + if (rootCount > limits.maxRoots) { + throw new RangeError( + `reference recipe has ${rootCount} roots; limit is ${limits.maxRoots}`, + ); + } + if (edgeCount > limits.maxEdges) { + throw new RangeError( + `reference recipe has ${edgeCount} edges; limit is ${limits.maxEdges}`, + ); + } + if (view.getUint32(28, true) !== NODE_SIZE) { + throw new Error("reference recipe declares an invalid node record size"); + } + if (view.getUint32(32, true) !== 0 || view.getUint32(36, true) !== 0) { + throw new Error("reference recipe reserved header fields are nonzero"); + } + const expectedBytes = wireByteLength( + nodeCount, + rootCount, + edgeCount, + blobByteLength, + ); + if (expectedBytes !== bytes.byteLength) { + throw new Error( + `reference recipe layout needs ${expectedBytes} bytes, ` + + `found ${bytes.byteLength}`, + ); + } + + const rootsOffset = HEADER_SIZE + nodeCount * NODE_SIZE; + const edgesOffset = rootsOffset + rootCount * 4; + const blobsOffset = edgesOffset + edgeCount * 4; + const edgeIds = new Uint32Array(edgeCount); + for (let index = 0; index < edgeCount; index++) { + const id = view.getUint32(edgesOffset + index * 4, true); + if (id >= nodeCount) { + throw new Error(`reference recipe edge ${index} targets missing node ${id}`); + } + edgeIds[index] = id; + } + + let expectedEdgeStart = 0; + let expectedBlobStart = 0; + const nodes: ForkReferenceRecipeEntry[] = []; + for (let id = 0; id < nodeCount; id++) { + const offset = HEADER_SIZE + id * NODE_SIZE; + const decoded = decodeNodeRecord( + view, + offset, + id, + edgeIds, + expectedEdgeStart, + bytes.subarray(blobsOffset, blobsOffset + blobByteLength), + expectedBlobStart, + ); + expectedEdgeStart += nodeEdges(decoded).length; + expectedBlobStart += nodeScalarBytes(decoded).byteLength; + nodes.push(Object.freeze({ id, node: decoded })); + } + if (expectedEdgeStart !== edgeCount) { + throw new Error( + `reference recipe node records consume ${expectedEdgeStart} edges, ` + + `header declares ${edgeCount}`, + ); + } + if (expectedBlobStart !== blobByteLength) { + throw new Error( + `reference recipe node records consume ${expectedBlobStart} scalar bytes, ` + + `header declares ${blobByteLength}`, + ); + } + + const roots: number[] = []; + for (let index = 0; index < rootCount; index++) { + const id = view.getUint32(rootsOffset + index * 4, true); + if (id >= nodeCount) { + throw new Error(`reference recipe root ${index} targets missing node ${id}`); + } + roots.push(id); + } + const graph: ForkReferenceRecipeGraph = Object.freeze({ + roots: Object.freeze(roots), + nodes: Object.freeze(nodes), + }); + validateReachability(graph); + return graph; +} + +function validateCatalogOwnership( + graph: ForkReferenceRecipeGraph, + functions: ForkFunctionCatalog, + types: ForkReferenceTypeCatalog, + staticRoots: ForkStaticRootCatalog | undefined, + side: "source" | "target", +): void { + for (const entry of graph.nodes) { + const node = entry.node; + try { + switch (node.kind) { + case "funcref": + functions.decode(functionRecipe(node)); + break; + case "exnref": + types.validateTag( + node.moduleActivation, + node.tagOrdinal, + node.payloads.length, + ); + break; + case "struct": + types.validateStruct( + node.moduleActivation, + node.typeOrdinal, + node.fields.length, + ); + break; + case "array": + types.validateArray(node.moduleActivation, node.typeOrdinal); + break; + case "static-root": + if (!staticRoots) { + throw new Error("static-root catalog is not registered"); + } + staticRoots.decode(staticRootRecipe(node)); + break; + case "null": + case "externref": + case "i31": + break; + } + } catch (error) { + throw new Error( + `${side} catalog rejected reference recipe node ${entry.id}: ` + + `${errorMessage(error)}`, + { cause: error }, + ); + } + } +} + +function allocateRecipeNode( + node: ForkReferenceRecipeNode, + functions: ForkFunctionCatalog, + staticRoots: ForkStaticRootCatalog | undefined, + arena: ForkReferenceReplayArena, +): unknown { + switch (node.kind) { + case "null": + return null; + case "funcref": + return functions.decode(functionRecipe(node)); + case "externref": + return arena.materializeExternref(node.handle); + case "exnref": + return arena.allocateException( + node.moduleActivation, + node.tagOrdinal, + node.payloads.length, + ); + case "i31": + return arena.materializeI31(node.value); + case "struct": + return arena.allocateStruct( + node.moduleActivation, + node.typeOrdinal, + node.fields.length, + ); + case "array": + return arena.allocateArray( + node.moduleActivation, + node.typeOrdinal, + node.elements.length, + ); + case "static-root": + if (!staticRoots) { + throw new Error("static-root catalog is not registered"); + } + return staticRoots.decode(staticRootRecipe(node)); + } +} + +function connectRecipeNode( + entry: ForkReferenceRecipeEntry, + values: readonly unknown[], + arena: ForkReferenceReplayArena, +): void { + const value = values[entry.id]; + switch (entry.node.kind) { + case "exnref": + entry.node.payloads.forEach((payload, index) => { + arena.setExceptionPayload(value, index, values[payload]); + }); + break; + case "struct": + entry.node.fields.forEach((field, index) => { + arena.setStructField(value, index, values[field]); + }); + break; + case "array": + entry.node.elements.forEach((element, index) => { + arena.setArrayElement(value, index, values[element]); + }); + break; + case "null": + case "funcref": + case "externref": + case "i31": + case "static-root": + break; + } +} + +function externrefOwnershipSet( + graph: ForkReferenceRecipeGraph, +): Set { + const handles = new Set(); + // WHY: reference-graph multiplicity expresses Wasm aliasing, not independent + // host lifetime. One process execution generation owns one lease per opaque + // identity no matter how many roots or aggregate fields point at it. + const addIfExternref = (id: number): void => { + const node = graph.nodes[id]?.node; + if (node?.kind !== "externref") return; + handles.add(node.handle); + }; + graph.roots.forEach(addIfExternref); + for (const entry of graph.nodes) { + nodeEdges(entry.node).forEach(addIfExternref); + } + return handles; +} + +function functionRecipe(node: ForkFuncrefRecipe): ForkFunctionRecipe { + return { + moduleActivation: node.moduleActivation, + ordinal: node.functionOrdinal, + }; +} + +function staticRootRecipe( + node: ForkStaticReferenceRootRecipe, +): ForkStaticRootRecipe { + return { + moduleActivation: node.moduleActivation, + ordinal: node.staticRootOrdinal, + }; +} + +function encodeNodeRecord( + view: DataView, + offset: number, + node: ForkReferenceRecipeNode, + edgeStart: number, + edgeCount: number, + blobStart: number, + blobByteLength: number, +): void { + let kind: WireNodeKind; + let first = 0; + let second = 0; + let third = 0; + switch (node.kind) { + case "null": + kind = WireNodeKind.Null; + break; + case "funcref": + kind = WireNodeKind.Funcref; + first = node.moduleActivation; + second = node.functionOrdinal; + break; + case "externref": { + kind = WireNodeKind.Externref; + const handle = BigInt(node.handle); + first = Number(handle & 0xffff_ffffn); + second = Number(handle >> 32n); + break; + } + case "exnref": + kind = WireNodeKind.Exnref; + first = node.moduleActivation; + second = node.tagOrdinal; + third = node.layoutId ?? 0; + break; + case "i31": + kind = WireNodeKind.I31; + first = node.value >>> 0; + break; + case "struct": + kind = WireNodeKind.Struct; + first = node.moduleActivation; + second = node.typeOrdinal; + third = node.layoutId ?? 0; + break; + case "array": + kind = WireNodeKind.Array; + first = node.moduleActivation; + second = node.typeOrdinal; + third = node.layoutId ?? 0; + break; + case "static-root": + kind = WireNodeKind.StaticRoot; + first = node.moduleActivation; + second = node.staticRootOrdinal; + break; + } + const aggregate = + node.kind === "exnref" || node.kind === "struct" || node.kind === "array"; + const recordEdgeStart = aggregate ? edgeStart : 0; + const recordBlobStart = aggregate ? blobStart : 0; + view.setUint8(offset, kind); + view.setUint8(offset + 1, 0); + view.setUint16(offset + 2, 0, true); + view.setUint32(offset + 4, first, true); + view.setUint32(offset + 8, second, true); + view.setUint32(offset + 12, third, true); + view.setUint32(offset + 16, recordEdgeStart, true); + view.setUint32(offset + 20, edgeCount, true); + view.setUint32(offset + 24, recordBlobStart, true); + view.setUint32(offset + 28, blobByteLength, true); +} + +function decodeNodeRecord( + view: DataView, + offset: number, + id: number, + edges: Uint32Array, + expectedEdgeStart: number, + blobs: Uint8Array, + expectedBlobStart: number, +): ForkReferenceRecipeNode { + const context = `reference recipe node ${id}`; + const kind = view.getUint8(offset); + if ( + view.getUint8(offset + 1) !== 0 + || view.getUint16(offset + 2, true) !== 0 + ) { + throw new Error(`${context} has nonzero flags or reserved fields`); + } + const first = view.getUint32(offset + 4, true); + const second = view.getUint32(offset + 8, true); + const third = view.getUint32(offset + 12, true); + const edgeStart = view.getUint32(offset + 16, true); + const edgeCount = view.getUint32(offset + 20, true); + const blobStart = view.getUint32(offset + 24, true); + const blobByteLength = view.getUint32(offset + 28, true); + const edgeEnd = checkedAdd(edgeStart, edgeCount, `${context} edge range`); + if (edgeEnd > edges.length) { + throw new Error(`${context} edge range exceeds the shared edge vector`); + } + const blobEnd = checkedAdd( + blobStart, + blobByteLength, + `${context} scalar blob range`, + ); + if (blobEnd > blobs.byteLength) { + throw new Error(`${context} scalar blob range exceeds the shared blob vector`); + } + + const requireNoAggregateData = (): void => { + if ( + edgeStart !== 0 + || edgeCount !== 0 + || blobStart !== 0 + || blobByteLength !== 0 + ) { + throw new Error(`${context} scalar record declares graph edges or payload bytes`); + } + }; + const requireZeroScalars = (): void => { + if (first !== 0 || second !== 0 || third !== 0) { + throw new Error(`${context} has noncanonical scalar fields`); + } + }; + const aggregateEdges = (): readonly number[] => { + if (edgeStart !== expectedEdgeStart) { + throw new Error( + `${context} has noncanonical edge start ${edgeStart}; ` + + `expected ${expectedEdgeStart}`, + ); + } + return Object.freeze(Array.from(edges.subarray(edgeStart, edgeEnd))); + }; + const aggregateBlob = (): Uint8Array => { + if (blobStart !== expectedBlobStart) { + throw new Error( + `${context} has noncanonical scalar blob start ${blobStart}; ` + + `expected ${expectedBlobStart}`, + ); + } + return blobs.slice(blobStart, blobEnd); + }; + + let node: ForkReferenceRecipeNode; + switch (kind) { + case WireNodeKind.Null: + requireNoAggregateData(); + requireZeroScalars(); + node = { kind: "null" }; + break; + case WireNodeKind.Funcref: + requireNoAggregateData(); + if (third !== 0) { + throw new Error(`${context} funcref reserved scalar field is nonzero`); + } + node = { + kind: "funcref", + moduleActivation: first, + functionOrdinal: second, + }; + break; + case WireNodeKind.Externref: { + requireNoAggregateData(); + if (third !== 0) { + throw new Error(`${context} externref reserved scalar field is nonzero`); + } + const handle = Number((BigInt(second) << 32n) | BigInt(first)); + assertHandle(handle, `${context} externref handle`); + node = { kind: "externref", handle }; + break; + } + case WireNodeKind.Exnref: + node = { + kind: "exnref", + moduleActivation: first, + tagOrdinal: second, + layoutId: third, + scalars: aggregateBlob(), + payloads: aggregateEdges(), + }; + break; + case WireNodeKind.I31: + requireNoAggregateData(); + if (second !== 0 || third !== 0) { + throw new Error(`${context} i31 reserved scalar field is nonzero`); + } + node = { kind: "i31", value: first | 0 }; + break; + case WireNodeKind.Struct: + node = { + kind: "struct", + moduleActivation: first, + typeOrdinal: second, + layoutId: third, + scalars: aggregateBlob(), + fields: aggregateEdges(), + }; + break; + case WireNodeKind.Array: + node = { + kind: "array", + moduleActivation: first, + typeOrdinal: second, + layoutId: third, + scalars: aggregateBlob(), + elements: aggregateEdges(), + }; + break; + case WireNodeKind.StaticRoot: + requireNoAggregateData(); + if (third !== 0) { + throw new Error(`${context} static-root reserved scalar field is nonzero`); + } + node = { + kind: "static-root", + moduleActivation: first, + staticRootOrdinal: second, + }; + break; + default: + throw new Error(`${context} has unknown kind ${kind}`); + } + validateNodeScalars(node, context); + return Object.freeze(node); +} + +function remapNodeEdges( + node: ForkReferenceRecipeNode, + remap: ReadonlyMap, + context: string, +): ForkReferenceRecipeNode { + switch (node.kind) { + case "exnref": + return { + ...node, + scalars: nodeScalarBytes(node).slice(), + payloads: node.payloads.map((id, index) => + remapRequiredId(remap, id, `${context} payload ${index}`) + ), + }; + case "struct": + return { + ...node, + scalars: nodeScalarBytes(node).slice(), + fields: node.fields.map((id, index) => + remapRequiredId(remap, id, `${context} field ${index}`) + ), + }; + case "array": + return { + ...node, + scalars: nodeScalarBytes(node).slice(), + elements: node.elements.map((id, index) => + remapRequiredId(remap, id, `${context} element ${index}`) + ), + }; + case "null": + case "funcref": + case "externref": + case "i31": + case "static-root": + return { ...node }; + } +} + +function remapRequiredId( + remap: ReadonlyMap, + id: number, + context: string, +): number { + assertU32(id, context); + const mapped = remap.get(id); + if (mapped === undefined) { + throw new Error(`${context} targets missing node ${id}`); + } + return mapped; +} + +function nodeEdges(node: ForkReferenceRecipeNode): readonly number[] { + switch (node.kind) { + case "exnref": + return node.payloads; + case "struct": + return node.fields; + case "array": + return node.elements; + case "null": + case "funcref": + case "externref": + case "i31": + case "static-root": + return []; + } +} + +function nodeScalarBytes(node: ForkReferenceRecipeNode): Uint8Array { + switch (node.kind) { + case "exnref": + case "struct": + case "array": + return node.scalars ?? new Uint8Array(); + case "null": + case "funcref": + case "externref": + case "i31": + case "static-root": + return new Uint8Array(); + } +} + +function validateNodeScalars( + node: ForkReferenceRecipeNode, + context: string, +): void { + switch (node.kind) { + case "null": + return; + case "funcref": + assertU32(node.moduleActivation, `${context} module activation`); + assertU32(node.functionOrdinal, `${context} function ordinal`); + return; + case "externref": + assertHandle(node.handle, `${context} externref handle`); + return; + case "exnref": + assertU32(node.moduleActivation, `${context} module activation`); + assertU32(node.tagOrdinal, `${context} tag ordinal`); + assertU32(node.layoutId ?? 0, `${context} layout id`); + if (!(nodeScalarBytes(node) instanceof Uint8Array)) { + throw new TypeError(`${context} scalar payload is not a Uint8Array`); + } + assertU32(node.payloads.length, `${context} payload count`); + return; + case "i31": + if ( + !Number.isInteger(node.value) + || node.value < MIN_I31 + || node.value > MAX_I31 + ) { + throw new RangeError(`${context} has invalid i31 value ${node.value}`); + } + return; + case "struct": + assertU32(node.moduleActivation, `${context} module activation`); + assertU32(node.typeOrdinal, `${context} type ordinal`); + assertU32(node.layoutId ?? 0, `${context} layout id`); + if (!(nodeScalarBytes(node) instanceof Uint8Array)) { + throw new TypeError(`${context} scalar payload is not a Uint8Array`); + } + assertU32(node.fields.length, `${context} field count`); + return; + case "array": + assertU32(node.moduleActivation, `${context} module activation`); + assertU32(node.typeOrdinal, `${context} type ordinal`); + assertU32(node.layoutId ?? 0, `${context} layout id`); + if (!(nodeScalarBytes(node) instanceof Uint8Array)) { + throw new TypeError(`${context} scalar payload is not a Uint8Array`); + } + assertU32(node.elements.length, `${context} element count`); + return; + case "static-root": + assertU32(node.moduleActivation, `${context} module activation`); + assertU32(node.staticRootOrdinal, `${context} static-root ordinal`); + return; + } +} + +function validateReachability(graph: ForkReferenceRecipeGraph): void { + const reached = new Uint8Array(graph.nodes.length); + const pending = [...graph.roots]; + while (pending.length > 0) { + const id = pending.pop()!; + if (reached[id] !== 0) continue; + reached[id] = 1; + for (const edge of nodeEdges(graph.nodes[id]!.node)) pending.push(edge); + } + const unreachable = reached.findIndex((value) => value === 0); + if (unreachable !== -1) { + throw new Error( + `reference recipe node ${unreachable} is unreachable from every root`, + ); + } +} + +function wireByteLength( + nodeCount: number, + rootCount: number, + edgeCount: number, + blobByteLength: number, +): number { + const nodesEnd = checkedAdd( + HEADER_SIZE, + checkedMultiply(nodeCount, NODE_SIZE, "node byte length"), + "node section end", + ); + const rootsEnd = checkedAdd( + nodesEnd, + checkedMultiply(rootCount, 4, "root byte length"), + "root section end", + ); + const edgesEnd = checkedAdd( + rootsEnd, + checkedMultiply(edgeCount, 4, "edge byte length"), + "edge section end", + ); + return checkedAdd(edgesEnd, blobByteLength, "wire byte length"); +} + +function validateLimits(limits: ForkReferenceRecipeLimits): void { + for (const [label, value] of [ + ["wire byte", limits.maxWireBytes], + ["node", limits.maxNodes], + ["root", limits.maxRoots], + ["edge", limits.maxEdges], + ] as const) { + if ( + !Number.isSafeInteger(value) + || value < 0 + || value > 0xffff_ffff + ) { + throw new RangeError(`invalid reference recipe ${label} limit ${value}`); + } + } + if (limits.maxWireBytes < HEADER_SIZE) { + throw new RangeError( + `reference recipe wire byte limit must be at least ${HEADER_SIZE}`, + ); + } +} + +function checkedAdd(left: number, right: number, context: string): number { + const value = left + right; + if (!Number.isSafeInteger(value)) { + throw new RangeError(`${context} exceeds the host safe integer range`); + } + return value; +} + +function checkedMultiply(left: number, right: number, context: string): number { + const value = left * right; + if (!Number.isSafeInteger(value)) { + throw new RangeError(`${context} exceeds the host safe integer range`); + } + return value; +} + +function assertU32(value: number, context: string): void { + if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff) { + throw new RangeError(`${context} is not an unsigned 32-bit integer`); + } +} + +function assertHandle(value: number, context: string): void { + if (!Number.isInteger(value) || value <= 0 || value > 0xffff_ffff) { + throw new RangeError(`${context} is not a positive unsigned 32-bit integer`); + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/host/src/fork-reference-segments.ts b/host/src/fork-reference-segments.ts new file mode 100644 index 0000000000..3bf63ac902 --- /dev/null +++ b/host/src/fork-reference-segments.ts @@ -0,0 +1,2131 @@ +import { + ForkModuleStateRecordKind, + type ForkModuleStateArena, + type ForkModuleStateRecord, + type ForkModuleStateRecordView, +} from "./fork-module-state"; +import type { + ForkReferenceRecipeEntry, + ForkReferenceRecipeNode, +} from "./fork-reference-recipes"; +import { + WPK_FORK_REFERENCE_NODE_RECORD_SIZE, + WPK_FORK_REFERENCE_SECTION_EDGES, + WPK_FORK_REFERENCE_SECTION_NODES, + WPK_FORK_REFERENCE_SECTION_SCALARS, + WPK_FORK_REFERENCE_SECTION_VECTOR_ENTRIES, + WPK_FORK_REFERENCE_SECTION_VECTOR_INDEX, + WPK_FORK_REFERENCE_SEGMENT_HEADER_SIZE, + WPK_FORK_REFERENCE_SEGMENT_KNOWN_FLAGS, + WPK_FORK_REFERENCE_SEGMENT_MAGIC, + WPK_FORK_REFERENCE_TRANSACTION_FLAG_SEALED, + WPK_FORK_REFERENCE_TRANSACTION_KNOWN_FLAGS, + WPK_FORK_REFERENCE_TRANSACTION_MAGIC, + WPK_FORK_REFERENCE_TRANSACTION_MANIFEST_SIZE, + WPK_FORK_REFERENCE_TRANSACTION_VERSION, + WPK_FORK_REFERENCE_VECTOR_INDEX_SIZE, +} from "./generated/abi"; + +const KFRV_MAGIC = littleEndianMagic(WPK_FORK_REFERENCE_TRANSACTION_MAGIC); +const KFRS_MAGIC = littleEndianMagic(WPK_FORK_REFERENCE_SEGMENT_MAGIC); +export const FORK_REFERENCE_TRANSACTION_VERSION = + WPK_FORK_REFERENCE_TRANSACTION_VERSION; +export const FORK_REFERENCE_MANIFEST_SIZE = + WPK_FORK_REFERENCE_TRANSACTION_MANIFEST_SIZE; +export const FORK_REFERENCE_SEGMENT_HEADER_SIZE = + WPK_FORK_REFERENCE_SEGMENT_HEADER_SIZE; +export const FORK_REFERENCE_NODE_RECORD_SIZE = + WPK_FORK_REFERENCE_NODE_RECORD_SIZE; +export const FORK_REFERENCE_VECTOR_INDEX_SIZE = + WPK_FORK_REFERENCE_VECTOR_INDEX_SIZE; +export const DEFAULT_FORK_REFERENCE_SEGMENT_DATA_BYTES = 1024 * 1024; +const FORK_REFERENCE_MANIFEST_FLAG_SEALED = + WPK_FORK_REFERENCE_TRANSACTION_FLAG_SEALED; +const FORK_REFERENCE_MANIFEST_KNOWN_FLAGS = + WPK_FORK_REFERENCE_TRANSACTION_KNOWN_FLAGS; +const MAX_U64 = 0xffff_ffff_ffff_ffffn; +const MAX_U32 = 0xffff_ffff; +const MAX_U32_DIRECTORY_LENGTH = 0x1_0000_0000; +const VECTOR_PAGE_ENTRIES = 4096; + +const enum ReferenceSection { + Nodes = WPK_FORK_REFERENCE_SECTION_NODES, + Edges = WPK_FORK_REFERENCE_SECTION_EDGES, + Scalars = WPK_FORK_REFERENCE_SECTION_SCALARS, + VectorIndex = WPK_FORK_REFERENCE_SECTION_VECTOR_INDEX, + VectorEntries = WPK_FORK_REFERENCE_SECTION_VECTOR_ENTRIES, +} + +const SECTION_COUNT = 5; + +const enum WireNodeKind { + Null = 0, + Funcref = 1, + Externref = 2, + Exnref = 3, + I31 = 4, + Struct = 5, + Array = 6, + StaticRoot = 7, +} + +/** + * Read-only paged storage for one activation recipe-ID vector. + * + * A vector may grow to the generated Wasm u32 index boundary without asking + * JavaScript for one equally large dense Array. Pages are private and become + * immutable when the builder finishes. + */ +export interface ForkReferenceVector extends Iterable { + readonly length: number; + get(index: number): number | undefined; + forEach(callback: (value: number, index: number) => void): void; +} + +/** + * Random-access paged directory whose index namespace is the complete u32. + * + * Unlike a JavaScript Array, it can represent 2^32 entries (indices zero + * through 0xffff_ffff) without one engine-level contiguous backing store. + */ +export interface ForkReferenceDirectory extends Iterable { + readonly length: number; + get(index: number): T | undefined; + has(index: number): boolean; + forEach(callback: (value: T, index: number) => void): void; + some(predicate: (value: T, index: number) => boolean): boolean; +} + +type ForkReferenceSequence = + | ForkReferenceDirectory + | readonly T[]; + +export class PagedForkReferenceDirectory + implements ForkReferenceDirectory +{ + private pages = new Map>(); + private count = 0; + + get length(): number { + return this.count; + } + + get(index: number): T | undefined { + if ( + !Number.isInteger(index) + || index < 0 + || index > MAX_U32 + || index >= this.count + ) { + return undefined; + } + return this.pages.get(Math.floor(index / VECTOR_PAGE_ENTRIES))?.[ + index % VECTOR_PAGE_ENTRIES + ]; + } + + has(index: number): boolean { + if ( + !Number.isInteger(index) + || index < 0 + || index > MAX_U32 + || index >= this.count + ) { + return false; + } + const page = this.pages.get(Math.floor(index / VECTOR_PAGE_ENTRIES)); + return !!page && (index % VECTOR_PAGE_ENTRIES) in page; + } + + push(value: T): number { + if (this.count >= MAX_U32_DIRECTORY_LENGTH) { + throw new RangeError("fork reference u32 directory is exhausted"); + } + const index = this.count; + const pageIndex = Math.floor(index / VECTOR_PAGE_ENTRIES); + const page = this.pages.get(pageIndex) ?? []; + page[index % VECTOR_PAGE_ENTRIES] = value; + this.pages.set(pageIndex, page); + this.count++; + return this.count; + } + + set(index: number, value: T): void { + if (!Number.isInteger(index) || index < 0 || index >= this.count) { + throw new RangeError(`fork reference directory index ${index} is out of bounds`); + } + const pageIndex = Math.floor(index / VECTOR_PAGE_ENTRIES); + const page = this.pages.get(pageIndex) ?? []; + page[index % VECTOR_PAGE_ENTRIES] = value; + this.pages.set(pageIndex, page); + } + + clear(): void { + this.pages.clear(); + this.count = 0; + } + + forEach(callback: (value: T, index: number) => void): void { + let index = 0; + const pageCount = Math.ceil(this.count / VECTOR_PAGE_ENTRIES); + for (let pageIndex = 0; pageIndex < pageCount; pageIndex++) { + const page = this.pages.get(pageIndex); + if (!page) { + throw new Error( + `fork reference directory has no page ${pageIndex}`, + ); + } + const count = Math.min(page.length, this.count - index); + for (let local = 0; local < count; local++, index++) { + if (!(local in page)) { + throw new Error(`fork reference directory has a hole at ${index}`); + } + callback(page[local]!, index); + } + } + if (index !== this.count) { + throw new Error(`fork reference directory ends at ${index}; expected ${this.count}`); + } + } + + some(predicate: (value: T, index: number) => boolean): boolean { + for (const [index, value] of this.indexed()) { + if (predicate(value, index)) return true; + } + return false; + } + + *[Symbol.iterator](): Iterator { + for (const [, value] of this.indexed()) yield value; + } + + private *indexed(): IterableIterator { + let index = 0; + const pageCount = Math.ceil(this.count / VECTOR_PAGE_ENTRIES); + for (let pageIndex = 0; pageIndex < pageCount; pageIndex++) { + const page = this.pages.get(pageIndex); + if (!page) { + throw new Error( + `fork reference directory has no page ${pageIndex}`, + ); + } + const count = Math.min(page.length, this.count - index); + for (let local = 0; local < count; local++, index++) { + if (!(local in page)) { + throw new Error(`fork reference directory has a hole at ${index}`); + } + yield [index, page[local]!] as const; + } + } + if (index !== this.count) { + throw new Error(`fork reference directory ends at ${index}; expected ${this.count}`); + } + } +} + +/** + * Mutable tail layered over one immutable decoded directory. + * + * Early replay and ordinary replay both need to intern short-lived codec + * vectors after decoding KFRV. Keeping the decoded directory as the base avoids + * copying every vector reference into a second page tree merely to make the + * tail appendable. + */ +export class ForkReferenceDirectoryOverlay + implements ForkReferenceDirectory +{ + private base: ForkReferenceDirectory = + new PagedForkReferenceDirectory(); + private readonly extension = new PagedForkReferenceDirectory(); + + constructor(base?: ForkReferenceDirectory) { + if (base) this.base = base; + } + + get length(): number { + return this.base.length + this.extension.length; + } + + get(index: number): T | undefined { + if (!Number.isInteger(index) || index < 0 || index >= this.length) { + return undefined; + } + return index < this.base.length + ? this.base.get(index) + : this.extension.get(index - this.base.length); + } + + has(index: number): boolean { + if (!Number.isInteger(index) || index < 0 || index >= this.length) { + return false; + } + return index < this.base.length + ? this.base.has(index) + : this.extension.has(index - this.base.length); + } + + push(value: T): number { + if (this.length >= MAX_U32_DIRECTORY_LENGTH) { + throw new RangeError("fork reference u32 directory is exhausted"); + } + this.extension.push(value); + return this.length; + } + + reset(base?: ForkReferenceDirectory): void { + this.extension.clear(); + this.base = base ?? new PagedForkReferenceDirectory(); + } + + clear(): void { + this.reset(); + } + + forEach(callback: (value: T, index: number) => void): void { + this.base.forEach(callback); + const baseLength = this.base.length; + this.extension.forEach((value, index) => { + callback(value, baseLength + index); + }); + } + + some(predicate: (value: T, index: number) => boolean): boolean { + if (this.base.some(predicate)) return true; + const baseLength = this.base.length; + return this.extension.some((value, index) => + predicate(value, baseLength + index) + ); + } + + *[Symbol.iterator](): Iterator { + yield* this.base; + yield* this.extension; + } +} + +export class PagedForkReferenceVector implements ForkReferenceVector { + static readonly empty = new PagedForkReferenceVector(new Map(), 0); + + constructor( + private readonly pages: ReadonlyMap, + readonly length: number, + ) { + if (!Number.isInteger(length) || length < 0 || length > MAX_U32) { + throw new RangeError(`fork reference vector length ${length} is not a u32`); + } + } + + get(index: number): number | undefined { + if (!Number.isInteger(index) || index < 0 || index >= this.length) { + return undefined; + } + return this.pages.get(Math.floor(index / VECTOR_PAGE_ENTRIES))![ + index % VECTOR_PAGE_ENTRIES + ]; + } + + forEach(callback: (value: number, index: number) => void): void { + let index = 0; + const pageCount = Math.ceil(this.length / VECTOR_PAGE_ENTRIES); + for (let pageIndex = 0; pageIndex < pageCount; pageIndex++) { + const page = this.pages.get(pageIndex)!; + const remaining = this.length - index; + const count = Math.min(page.length, remaining); + for (let local = 0; local < count; local++, index++) { + callback(page[local]!, index); + } + } + } + + *[Symbol.iterator](): Iterator { + let emitted = 0; + const pageCount = Math.ceil(this.length / VECTOR_PAGE_ENTRIES); + for (let pageIndex = 0; pageIndex < pageCount; pageIndex++) { + const page = this.pages.get(pageIndex)!; + const count = Math.min(page.length, this.length - emitted); + for (let index = 0; index < count; index++, emitted++) { + yield page[index]!; + } + } + } +} + +export class ForkReferenceVectorBuilder { + private readonly pages = new Map(); + private count = 0; + + constructor(readonly expectedLength: number) { + if ( + !Number.isInteger(expectedLength) + || expectedLength <= 0 + || expectedLength > MAX_U32 + ) { + throw new RangeError( + `fork reference vector length ${expectedLength} is not a nonzero u32`, + ); + } + } + + get length(): number { + return this.count; + } + + append(recipeId: number): void { + assertRecipeId(recipeId, "fork reference vector recipe"); + if (this.count >= this.expectedLength) { + throw new Error("fork reference vector exceeds its declared length"); + } + const pageIndex = Math.floor(this.count / VECTOR_PAGE_ENTRIES); + let page = this.pages.get(pageIndex); + if (!page) { + page = new Uint32Array( + Math.min(VECTOR_PAGE_ENTRIES, this.expectedLength - this.count), + ); + this.pages.set(pageIndex, page); + } + page[this.count % VECTOR_PAGE_ENTRIES] = recipeId; + this.count++; + } + + finish(): PagedForkReferenceVector { + if (this.count !== this.expectedLength) { + throw new Error( + `fork reference vector has ${this.count} entries; ` + + `expected ${this.expectedLength}`, + ); + } + return new PagedForkReferenceVector(new Map(this.pages), this.count); + } +} + +export function forkReferenceVectorFrom( + values: Iterable, + expectedLength?: number, +): PagedForkReferenceVector { + if (expectedLength === 0) return PagedForkReferenceVector.empty; + if (expectedLength !== undefined) { + const builder = new ForkReferenceVectorBuilder(expectedLength); + for (const value of values) builder.append(value); + return builder.finish(); + } + const pages = new Map(); + let count = 0; + for (const value of values) { + assertRecipeId(value, "fork reference vector recipe"); + if (count === MAX_U32) { + throw new RangeError("fork reference vector length exceeds u32"); + } + const pageIndex = Math.floor(count / VECTOR_PAGE_ENTRIES); + let page = pages.get(pageIndex); + if (!page) { + page = new Uint32Array(VECTOR_PAGE_ENTRIES); + pages.set(pageIndex, page); + } + page[count % VECTOR_PAGE_ENTRIES] = value; + count++; + } + return count === 0 + ? PagedForkReferenceVector.empty + : new PagedForkReferenceVector(pages, count); +} + +export function forkReferenceVectorsEqual( + left: ForkReferenceVector, + right: ForkReferenceVector, +): boolean { + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index++) { + if (left.get(index) !== right.get(index)) return false; + } + return true; +} + +export function forkReferenceVectorInternKey( + values: ForkReferenceVector, +): string { + let first = (0x811c_9dc5 ^ values.length) >>> 0; + let second = (0x9e37_79b9 ^ values.length) >>> 0; + values.forEach((value) => { + first = Math.imul(first ^ value, 0x0100_0193) >>> 0; + const rotated = ((value << 16) | (value >>> 16)) >>> 0; + second = Math.imul(second ^ rotated, 0x85eb_ca6b) >>> 0; + second = (second ^ (first >>> 13)) >>> 0; + }); + return `${values.length}:${first}:${second}`; +} + +export type ForkReferenceVectorInternIndex = + ReadonlyMap>; + +export type MutableForkReferenceVectorInternIndex = + Map>; + +export function indexForkReferenceVector( + index: MutableForkReferenceVectorInternIndex, + values: ForkReferenceVector, + ordinal: number, +): void { + assertU32(ordinal, "fork reference vector ordinal"); + const key = forkReferenceVectorInternKey(values); + let candidates = index.get(key); + if (!candidates) { + candidates = new PagedForkReferenceDirectory(); + index.set(key, candidates); + } + candidates.push(ordinal); +} + +export function findForkReferenceVectorOrdinal( + indexes: Iterable, + directory: ForkReferenceDirectory, + values: ForkReferenceVector, +): number | undefined { + const key = forkReferenceVectorInternKey(values); + for (const index of indexes) { + const candidates = index.get(key); + if (!candidates) continue; + for (const ordinal of candidates) { + const candidate = directory.get(ordinal); + if (candidate && forkReferenceVectorsEqual(candidate, values)) { + return ordinal; + } + } + } + return undefined; +} + +export interface DecodedSegmentedForkReferenceTransaction { + /** + * One object is shared by pre-instantiation and ordinary child replay. + * Object identity is the adoption proof; no complete wire copy is retained. + */ + readonly identity: object; + readonly graph: { + readonly roots: readonly number[]; + readonly nodes: ForkReferenceDirectory; + }; + /** Index zero is the canonical empty-vector sentinel. */ + readonly vectors: ForkReferenceDirectory; + /** Canonical hash candidates shared by early and ordinary replay. */ + readonly vectorIntern: ForkReferenceVectorInternIndex; +} + +export interface ForkReferenceSegmentEncodingOptions { + /** + * Transient writer target, not a total-state limit. Smaller values are useful + * for boundary tests; production uses a near-1-MiB bounded copy window. + */ + readonly segmentDataBytes?: number; +} + +interface SectionTotals { + readonly nodes: bigint; + readonly edges: bigint; + readonly scalars: bigint; + readonly vectorIndex: bigint; + readonly vectorEntries: bigint; +} + +interface SegmentState { + ordinal: bigint; +} + +class SegmentWriter { + private readonly buffer: Uint8Array; + private used = 0; + private logicalOffset = 0n; + + constructor( + private readonly arena: ForkModuleStateArena, + private readonly ownerId: number, + private readonly section: ReferenceSection, + private readonly state: SegmentState, + segmentDataBytes: number, + ) { + this.buffer = new Uint8Array(segmentDataBytes); + } + + write(bytes: Uint8Array): void { + let source = 0; + while (source < bytes.byteLength) { + const count = Math.min( + bytes.byteLength - source, + this.buffer.byteLength - this.used, + ); + this.buffer.set(bytes.subarray(source, source + count), this.used); + this.used += count; + source += count; + if (this.used === this.buffer.byteLength) this.flush(); + } + } + + finish(): void { + this.flush(); + } + + private flush(): void { + if (this.used === 0) return; + const payload = new Uint8Array( + FORK_REFERENCE_SEGMENT_HEADER_SIZE + this.used, + ); + const view = new DataView(payload.buffer); + view.setUint32(0, KFRS_MAGIC, true); + view.setUint16(4, FORK_REFERENCE_TRANSACTION_VERSION, true); + view.setUint16(6, FORK_REFERENCE_SEGMENT_HEADER_SIZE, true); + view.setUint16(8, this.section, true); + view.setUint16(10, WPK_FORK_REFERENCE_SEGMENT_KNOWN_FLAGS, true); + view.setUint32(12, 0, true); + view.setBigUint64(16, this.state.ordinal, true); + view.setBigUint64(24, this.logicalOffset, true); + view.setUint32(32, this.used, true); + view.setUint32(36, 0, true); + payload.set(this.buffer.subarray(0, this.used), FORK_REFERENCE_SEGMENT_HEADER_SIZE); + this.arena.appendRecord({ + kind: ForkModuleStateRecordKind.ReferenceRecipeSegment, + activationId: 0, + ownerId: this.ownerId, + payload, + }); + this.logicalOffset = addU64( + this.logicalOffset, + BigInt(this.used), + "fork reference section offset", + ); + this.state.ordinal = addU64( + this.state.ordinal, + 1n, + "fork reference segment ordinal", + ); + this.used = 0; + } +} + +/** + * Stream canonical production KFRV v2 records directly into the KFMS arena. + * + * The transaction already assigns dense recipe IDs as values are discovered, + * so production must not sort/remap the graph or manufacture an all-node root + * list merely to serialize it. + */ +export function appendSegmentedForkReferenceTransaction( + arena: ForkModuleStateArena, + ownerId: number, + nodes: ForkReferenceSequence, + vectors: ForkReferenceSequence, + options: ForkReferenceSegmentEncodingOptions = {}, +): Uint8Array { + assertOwner(ownerId); + const segmentDataBytes = + options.segmentDataBytes ?? DEFAULT_FORK_REFERENCE_SEGMENT_DATA_BYTES; + if ( + !Number.isInteger(segmentDataBytes) + || segmentDataBytes <= 0 + || segmentDataBytes > MAX_U32 - FORK_REFERENCE_SEGMENT_HEADER_SIZE + ) { + throw new RangeError( + `fork reference segment data size ${segmentDataBytes} is invalid`, + ); + } + validateCanonicalCapture(nodes, vectors); + const totals = computeSectionTotals(nodes, vectors); + const state: SegmentState = { ordinal: 0n }; + + const nodeWriter = new SegmentWriter( + arena, + ownerId, + ReferenceSection.Nodes, + state, + segmentDataBytes, + ); + let edgeStart = 0n; + let scalarStart = 0n; + const nodeRecord = new Uint8Array(FORK_REFERENCE_NODE_RECORD_SIZE); + for (const { node } of nodes) { + nodeRecord.fill(0); + const edges = nodeEdges(node); + const scalars = nodeScalars(node); + encodeNodeRecordV2( + new DataView(nodeRecord.buffer), + node, + edgeStart, + BigInt(edges.length), + scalarStart, + BigInt(scalars.byteLength), + ); + nodeWriter.write(nodeRecord); + edgeStart = addU64(edgeStart, BigInt(edges.length), "fork reference edge count"); + scalarStart = addU64( + scalarStart, + BigInt(scalars.byteLength), + "fork reference scalar byte count", + ); + } + nodeWriter.finish(); + + const u32 = new Uint8Array(4); + const u32View = new DataView(u32.buffer); + const edgeWriter = new SegmentWriter( + arena, + ownerId, + ReferenceSection.Edges, + state, + segmentDataBytes, + ); + for (const { node } of nodes) { + for (const edge of nodeEdges(node)) { + u32View.setUint32(0, edge, true); + edgeWriter.write(u32); + } + } + edgeWriter.finish(); + + const scalarWriter = new SegmentWriter( + arena, + ownerId, + ReferenceSection.Scalars, + state, + segmentDataBytes, + ); + for (const { node } of nodes) scalarWriter.write(nodeScalars(node)); + scalarWriter.finish(); + + const vectorIndexWriter = new SegmentWriter( + arena, + ownerId, + ReferenceSection.VectorIndex, + state, + segmentDataBytes, + ); + const vectorIndex = new Uint8Array(FORK_REFERENCE_VECTOR_INDEX_SIZE); + const vectorIndexView = new DataView(vectorIndex.buffer); + let vectorEntryStart = 0n; + for (let ordinal = 1; ordinal < vectors.length; ordinal++) { + const vector = requiredDirectoryEntry( + vectors, + ordinal, + "fork reference vector", + ); + vectorIndexView.setBigUint64(0, vectorEntryStart, true); + vectorIndexView.setBigUint64(8, BigInt(vector.length), true); + vectorIndexWriter.write(vectorIndex); + vectorEntryStart = addU64( + vectorEntryStart, + BigInt(vector.length), + "fork reference vector entry count", + ); + } + vectorIndexWriter.finish(); + + const vectorEntryWriter = new SegmentWriter( + arena, + ownerId, + ReferenceSection.VectorEntries, + state, + segmentDataBytes, + ); + for (let ordinal = 1; ordinal < vectors.length; ordinal++) { + for (const recipeId of requiredDirectoryEntry( + vectors, + ordinal, + "fork reference vector", + )) { + u32View.setUint32(0, recipeId, true); + vectorEntryWriter.write(u32); + } + } + vectorEntryWriter.finish(); + + const manifest = encodeManifest( + state.ordinal, + BigInt(nodes.length), + BigInt(vectors.length - 1), + totals, + ); + // WHY: the manifest is the transaction commit point. A failed segment + // allocation leaves no authoritative KFRV record, and arena sealing rejects + // the incomplete stream instead of exposing a truncated graph to a child. + arena.appendRecord({ + kind: ForkModuleStateRecordKind.ReferenceRecipe, + activationId: 0, + ownerId, + payload: manifest, + }); + return manifest; +} + +/** + * Test/helper encoder that preserves record segmentation without allocating a + * whole KFRV transaction. Production capture writes to a real KFMS arena. + */ +export function encodeSegmentedForkReferenceRecords( + ownerId: number, + nodes: ForkReferenceSequence, + vectors: ForkReferenceSequence, + options: ForkReferenceSegmentEncodingOptions = {}, +): ForkModuleStateRecord[] { + const records: ForkModuleStateRecord[] = []; + const sink = { + appendRecord(record: ForkModuleStateRecord): void { + records.push({ + ...record, + payload: record.payload.slice(), + }); + }, + } as Pick; + appendSegmentedForkReferenceTransaction( + sink as ForkModuleStateArena, + ownerId, + nodes, + vectors, + options, + ); + return records; +} + +function computeSectionTotals( + nodes: ForkReferenceSequence, + vectors: ForkReferenceSequence, +): SectionTotals { + let edgeCount = 0n; + let scalarBytes = 0n; + for (const { node } of nodes) { + edgeCount = addU64( + edgeCount, + BigInt(nodeEdges(node).length), + "fork reference edge count", + ); + scalarBytes = addU64( + scalarBytes, + BigInt(nodeScalars(node).byteLength), + "fork reference scalar byte count", + ); + } + let vectorEntries = 0n; + for (let ordinal = 1; ordinal < vectors.length; ordinal++) { + vectorEntries = addU64( + vectorEntries, + BigInt(requiredDirectoryEntry( + vectors, + ordinal, + "fork reference vector", + ).length), + "fork reference vector entry count", + ); + } + return { + nodes: multiplyU64( + BigInt(nodes.length), + BigInt(FORK_REFERENCE_NODE_RECORD_SIZE), + "fork reference node bytes", + ), + edges: multiplyU64(edgeCount, 4n, "fork reference edge bytes"), + scalars: scalarBytes, + vectorIndex: multiplyU64( + BigInt(vectors.length - 1), + BigInt(FORK_REFERENCE_VECTOR_INDEX_SIZE), + "fork reference vector-index bytes", + ), + vectorEntries: multiplyU64( + vectorEntries, + 4n, + "fork reference vector-entry bytes", + ), + }; +} + +function encodeManifest( + segmentCount: bigint, + nodeCount: bigint, + vectorCount: bigint, + totals: SectionTotals, +): Uint8Array { + const totalLogical = sectionTotalsArray(totals).reduce( + (sum, value) => addU64(sum, value, "fork reference logical bytes"), + 0n, + ); + const manifest = new Uint8Array(FORK_REFERENCE_MANIFEST_SIZE); + const view = new DataView(manifest.buffer); + view.setUint32(0, KFRV_MAGIC, true); + view.setUint16(4, FORK_REFERENCE_TRANSACTION_VERSION, true); + view.setUint16(6, FORK_REFERENCE_MANIFEST_SIZE, true); + view.setUint32(8, FORK_REFERENCE_MANIFEST_FLAG_SEALED, true); + view.setUint32(12, FORK_REFERENCE_NODE_RECORD_SIZE, true); + view.setUint32(16, FORK_REFERENCE_VECTOR_INDEX_SIZE, true); + view.setUint32(20, 0, true); + view.setBigUint64(24, segmentCount, true); + view.setBigUint64(32, nodeCount, true); + view.setBigUint64(40, vectorCount, true); + view.setBigUint64(48, totals.nodes, true); + view.setBigUint64(56, totals.edges, true); + view.setBigUint64(64, totals.scalars, true); + view.setBigUint64(72, totals.vectorIndex, true); + view.setBigUint64(80, totals.vectorEntries, true); + view.setBigUint64(88, totalLogical, true); + return manifest; +} + +interface ReferenceSegment { + readonly ordinal: bigint; + readonly offset: bigint; + readonly data: Uint8Array; +} + +const SEGMENT_DIRECTORY_PAGE_ENTRIES = 4096n; + +/** + * Segment metadata uses bigint indexes as well as u64 wire ordinals. + * + * A JavaScript Array (including a paged directory addressed by `number`) would + * reintroduce a 2^32-segment ceiling even though KFRV v2 deliberately carries + * u64 segment counts. Pages remain ordinary small arrays; only their sparse + * page keys and the logical length are bigint. + */ +class BigIntPagedDirectory implements Iterable { + private readonly pages = new Map>(); + private count = 0n; + + get length(): bigint { + return this.count; + } + + get(index: bigint): T | undefined { + if (index < 0n || index >= this.count) return undefined; + const page = this.pages.get(index / SEGMENT_DIRECTORY_PAGE_ENTRIES); + return page?.[Number(index % SEGMENT_DIRECTORY_PAGE_ENTRIES)]; + } + + push(value: T): void { + if (this.count === MAX_U64) { + throw new RangeError("fork reference segment directory exceeds u64"); + } + const pageIndex = this.count / SEGMENT_DIRECTORY_PAGE_ENTRIES; + let page = this.pages.get(pageIndex); + if (!page) { + page = []; + this.pages.set(pageIndex, page); + } + page[Number(this.count % SEGMENT_DIRECTORY_PAGE_ENTRIES)] = value; + this.count++; + } + + *[Symbol.iterator](): Iterator { + for (let index = 0n; index < this.count; index++) { + const value = this.get(index); + if (value === undefined) { + throw new Error(`fork reference segment directory has a hole at ${index}`); + } + yield value; + } + } +} + +interface ParsedReferenceManifest { + readonly segmentCount: bigint; + readonly nodeCount: number; + readonly vectorCount: number; + readonly totals: SectionTotals; +} + +interface ParsedSegmentedForkReferenceTransaction { + readonly manifest: ParsedReferenceManifest; + readonly sections: ReadonlyMap; +} + +interface ValidatedVectorRange { + readonly start: bigint; + readonly length: number; +} + +interface ValidatedReferenceSemantics { + readonly vectors: ForkReferenceDirectory; + readonly vectorIntern: ForkReferenceVectorInternIndex; +} + +/** + * One logical section backed by ordered KFMS record payloads. + * + * `totalBytes` and offsets remain bigint all the way through validation. A + * transaction may therefore cross the 4-GiB boundary without requiring a + * JavaScript ArrayBuffer of that size. + */ +class SegmentedSection { + constructor( + readonly segments: BigIntPagedDirectory, + readonly totalBytes: bigint, + ) {} + + reader(): SegmentedSectionReader { + return new SegmentedSectionReader(this); + } + + readU32At(offset: bigint): number { + if (offset < 0n || addU64(offset, 4n, "fork reference u32 end") > this.totalBytes) { + throw new Error(`fork reference section offset ${offset} is out of bounds`); + } + return ( + this.byteAt(offset) + | (this.byteAt(offset + 1n) << 8) + | (this.byteAt(offset + 2n) << 16) + | (this.byteAt(offset + 3n) << 24) + ) >>> 0; + } + + private byteAt(offset: bigint): number { + let low = 0n; + let high = this.segments.length; + while (low < high) { + const middle = low + ((high - low) >> 1n); + const segment = this.segments.get(middle)!; + const end = segment.offset + BigInt(segment.data.byteLength); + if (offset < segment.offset) { + high = middle; + } else if (offset >= end) { + low = middle + 1n; + } else { + return segment.data[Number(offset - segment.offset)]!; + } + } + throw new Error(`fork reference section offset ${offset} has no segment`); + } +} + +class SegmentedSectionReader { + private segmentIndex = 0n; + private localOffset = 0; + private consumed = 0n; + private readonly numberBytes = new Uint8Array(8); + private readonly numberView = new DataView(this.numberBytes.buffer); + + constructor(private readonly section: SegmentedSection) {} + + get position(): bigint { + return this.consumed; + } + + readInto(target: Uint8Array): void { + let targetOffset = 0; + while (targetOffset < target.byteLength) { + const segment = this.section.segments.get(this.segmentIndex); + if (!segment) { + throw new Error( + `fork reference section is truncated at logical byte ${this.consumed}`, + ); + } + const available = segment.data.byteLength - this.localOffset; + const count = Math.min(available, target.byteLength - targetOffset); + target.set( + segment.data.subarray(this.localOffset, this.localOffset + count), + targetOffset, + ); + this.advance(count, segment); + targetOffset += count; + } + } + + readBytes(length: number): Uint8Array { + if (!Number.isInteger(length) || length < 0 || length > MAX_U32) { + throw new RangeError(`fork reference byte count ${length} is not a u32`); + } + const bytes = new Uint8Array(length); + this.readInto(bytes); + return bytes; + } + + readU32(): number { + this.readInto(this.numberBytes.subarray(0, 4)); + return this.numberView.getUint32(0, true); + } + + skip(length: bigint): void { + if (length < 0n) { + throw new RangeError("fork reference skip length is negative"); + } + let remaining = length; + while (remaining !== 0n) { + const segment = this.section.segments.get(this.segmentIndex); + if (!segment) { + throw new Error( + `fork reference section is truncated at logical byte ${this.consumed}`, + ); + } + const available = segment.data.byteLength - this.localOffset; + const count = remaining < BigInt(available) + ? Number(remaining) + : available; + this.advance(count, segment); + remaining -= BigInt(count); + } + } + + requireEnd(context: string): void { + if (this.consumed !== this.section.totalBytes) { + throw new Error( + `${context} consumed ${this.consumed} bytes; ` + + `section contains ${this.section.totalBytes}`, + ); + } + } + + private advance(count: number, segment: ReferenceSegment): void { + this.localOffset += count; + this.consumed += BigInt(count); + if (this.localOffset === segment.data.byteLength) { + this.segmentIndex++; + this.localOffset = 0; + } + } +} + +/** + * Validate and decode the production KFRV v2 stream without concatenating it. + * + * Structural and semantic validation completes before graph nodes or reference + * vectors are materialized. This prevents malformed guest bytes from driving + * partial Wasm reconstruction. + */ +export function decodeSegmentedForkReferenceTransaction( + records: readonly ForkModuleStateRecordView[], + ownerId: number, +): DecodedSegmentedForkReferenceTransaction { + const parsed = parseSegmentedForkReferenceTransaction(records, ownerId); + const semantics = validateReferenceSemantics(parsed); + const graph = materializeReferenceGraph(parsed); + const vectors = materializeReferenceVectors(parsed, semantics); + const identity = Object.freeze({}); + return Object.freeze({ + identity, + graph, + vectors, + vectorIntern: semantics.vectorIntern, + }); +} + +/** + * Scan only opaque externref handles for the pre-launch process owner. + * + * This uses the exact production parser and semantic validator but deliberately + * avoids constructing graph/vector objects or retaining a whole wire image. + */ +export function scanSegmentedForkReferenceExternrefHandles( + records: readonly ForkModuleStateRecordView[], + ownerId: number, +): ReadonlySet { + const parsed = parseSegmentedForkReferenceTransaction(records, ownerId); + validateReferenceSemantics(parsed); + const handles = new Set(); + const reader = requiredSection(parsed, ReferenceSection.Nodes).reader(); + const recordBytes = new Uint8Array(FORK_REFERENCE_NODE_RECORD_SIZE); + for (let id = 0; id < parsed.manifest.nodeCount; id++) { + reader.readInto(recordBytes); + const view = new DataView(recordBytes.buffer); + if (view.getUint8(0) !== WireNodeKind.Externref) continue; + handles.add(decodeHandle(view.getUint32(4, true), view.getUint32(8, true), id)); + } + reader.requireEnd("fork reference node scan"); + return handles; +} + +function parseSegmentedForkReferenceTransaction( + records: readonly ForkModuleStateRecordView[], + ownerId: number, +): ParsedSegmentedForkReferenceTransaction { + assertOwner(ownerId); + const bySection = new Map< + ReferenceSection, + BigIntPagedDirectory + >(); + const observed = new Map(); + let selectedCount = 0n; + let segmentCount = 0n; + let manifestCount = 0; + let manifest: ParsedReferenceManifest | undefined; + let previousSection = 0; + + for (const record of records) { + if ( + record.kind !== ForkModuleStateRecordKind.ReferenceRecipeSegment + && record.kind !== ForkModuleStateRecordKind.ReferenceRecipe + ) { + continue; + } + const selectedIndex = selectedCount; + selectedCount = addU64( + selectedCount, + 1n, + "fork reference record count", + ); + if (record.activationId !== 0 || record.ownerId !== ownerId) { + throw new Error( + `fork reference record ${selectedIndex} has invalid process ownership ` + + `${record.activationId}:${record.ownerId}`, + ); + } + + if (record.kind === ForkModuleStateRecordKind.ReferenceRecipe) { + manifestCount++; + if (manifestCount === 1) manifest = decodeManifest(record.payload); + continue; + } + if (manifestCount !== 0) { + throw new Error("fork reference segment follows its final manifest"); + } + const segment = decodeSegment(record.payload, segmentCount); + if (segment.section < previousSection) { + throw new Error( + `fork reference segment ${segmentCount} reorders section ${segment.section}`, + ); + } + previousSection = segment.section; + const expectedOffset = observed.get(segment.section) ?? 0n; + if (segment.offset !== expectedOffset) { + throw new Error( + `fork reference segment ${segmentCount} starts at ${segment.offset}; ` + + `expected ${expectedOffset} (gap, overlap, or duplicate)`, + ); + } + observed.set( + segment.section, + addU64( + expectedOffset, + BigInt(segment.data.byteLength), + `fork reference section ${segment.section} bytes`, + ), + ); + let sectionSegments = bySection.get(segment.section); + if (!sectionSegments) { + sectionSegments = new BigIntPagedDirectory(); + bySection.set(segment.section, sectionSegments); + } + sectionSegments.push({ + ordinal: segmentCount, + offset: segment.offset, + data: segment.data, + }); + segmentCount = addU64( + segmentCount, + 1n, + "fork reference segment count", + ); + } + + if (selectedCount === 0n) { + throw new Error("fork module state has no process reference transaction"); + } + if (manifestCount !== 1 || manifest === undefined) { + throw new Error( + `fork module state has ${manifestCount} process reference manifests; ` + + "expected one", + ); + } + if (segmentCount !== manifest.segmentCount) { + throw new Error( + `fork reference manifest declares ${manifest.segmentCount} segments; ` + + `found ${segmentCount}`, + ); + } + + const totals = sectionTotalsArray(manifest.totals); + const sections = new Map(); + for (let ordinal = 0; ordinal < SECTION_COUNT; ordinal++) { + const section = (ordinal + 1) as ReferenceSection; + const expected = totals[ordinal]!; + const actual = observed.get(section) ?? 0n; + if (actual !== expected) { + throw new Error( + `fork reference section ${section} contains ${actual} bytes; ` + + `manifest declares ${expected}`, + ); + } + sections.set( + section, + new SegmentedSection( + bySection.get(section) ?? new BigIntPagedDirectory(), + expected, + ), + ); + } + return { manifest, sections }; +} + +function decodeManifest(payload: Uint8Array): ParsedReferenceManifest { + if (payload.byteLength !== FORK_REFERENCE_MANIFEST_SIZE) { + throw new Error( + `fork reference manifest has ${payload.byteLength} bytes; ` + + `expected ${FORK_REFERENCE_MANIFEST_SIZE}`, + ); + } + const view = new DataView( + payload.buffer, + payload.byteOffset, + payload.byteLength, + ); + if (view.getUint32(0, true) !== KFRV_MAGIC) { + throw new Error("fork reference manifest has invalid KFRV magic"); + } + const version = view.getUint16(4, true); + if (version !== FORK_REFERENCE_TRANSACTION_VERSION) { + throw new Error(`unsupported fork reference transaction version ${version}`); + } + if (view.getUint16(6, true) !== FORK_REFERENCE_MANIFEST_SIZE) { + throw new Error("fork reference manifest declares an invalid header size"); + } + const flags = view.getUint32(8, true); + if ( + flags !== FORK_REFERENCE_MANIFEST_FLAG_SEALED + || (flags & ~FORK_REFERENCE_MANIFEST_KNOWN_FLAGS) !== 0 + ) { + throw new Error(`fork reference manifest has invalid flags 0x${flags.toString(16)}`); + } + if ( + view.getUint32(12, true) !== FORK_REFERENCE_NODE_RECORD_SIZE + || view.getUint32(16, true) !== FORK_REFERENCE_VECTOR_INDEX_SIZE + ) { + throw new Error("fork reference manifest declares invalid record sizes"); + } + if (view.getUint32(20, true) !== 0) { + throw new Error("fork reference manifest reserved field is nonzero"); + } + + const nodeCount64 = view.getBigUint64(32, true); + const vectorCount64 = view.getBigUint64(40, true); + if (nodeCount64 === 0n || nodeCount64 > BigInt(MAX_U32_DIRECTORY_LENGTH)) { + throw new RangeError(`fork reference node count ${nodeCount64} is invalid`); + } + if (vectorCount64 > BigInt(MAX_U32)) { + throw new RangeError(`fork reference vector count ${vectorCount64} is invalid`); + } + const totals: SectionTotals = { + nodes: view.getBigUint64(48, true), + edges: view.getBigUint64(56, true), + scalars: view.getBigUint64(64, true), + vectorIndex: view.getBigUint64(72, true), + vectorEntries: view.getBigUint64(80, true), + }; + const expectedNodeBytes = multiplyU64( + nodeCount64, + BigInt(FORK_REFERENCE_NODE_RECORD_SIZE), + "fork reference node bytes", + ); + const expectedVectorIndexBytes = multiplyU64( + vectorCount64, + BigInt(FORK_REFERENCE_VECTOR_INDEX_SIZE), + "fork reference vector-index bytes", + ); + if (totals.nodes !== expectedNodeBytes) { + throw new Error( + `fork reference node section has ${totals.nodes} bytes; ` + + `expected ${expectedNodeBytes}`, + ); + } + if (totals.vectorIndex !== expectedVectorIndexBytes) { + throw new Error( + `fork reference vector-index section has ${totals.vectorIndex} bytes; ` + + `expected ${expectedVectorIndexBytes}`, + ); + } + if (totals.edges % 4n !== 0n || totals.vectorEntries % 4n !== 0n) { + throw new Error("fork reference u32 section byte length is not divisible by four"); + } + const expectedTotal = sectionTotalsArray(totals).reduce( + (sum, value) => addU64(sum, value, "fork reference logical bytes"), + 0n, + ); + const declaredTotal = view.getBigUint64(88, true); + if (declaredTotal !== expectedTotal) { + throw new Error( + `fork reference manifest declares ${declaredTotal} logical bytes; ` + + `sections contain ${expectedTotal}`, + ); + } + return { + segmentCount: view.getBigUint64(24, true), + nodeCount: Number(nodeCount64), + vectorCount: Number(vectorCount64), + totals, + }; +} + +function decodeSegment( + payload: Uint8Array, + expectedOrdinal: bigint, +): { + readonly section: ReferenceSection; + readonly offset: bigint; + readonly data: Uint8Array; +} { + if (payload.byteLength < FORK_REFERENCE_SEGMENT_HEADER_SIZE) { + throw new Error(`fork reference segment ${expectedOrdinal} header is truncated`); + } + const view = new DataView( + payload.buffer, + payload.byteOffset, + payload.byteLength, + ); + if (view.getUint32(0, true) !== KFRS_MAGIC) { + throw new Error(`fork reference segment ${expectedOrdinal} has invalid magic`); + } + if (view.getUint16(4, true) !== FORK_REFERENCE_TRANSACTION_VERSION) { + throw new Error( + `fork reference segment ${expectedOrdinal} has unsupported version`, + ); + } + if (view.getUint16(6, true) !== FORK_REFERENCE_SEGMENT_HEADER_SIZE) { + throw new Error( + `fork reference segment ${expectedOrdinal} declares an invalid header size`, + ); + } + const section = view.getUint16(8, true); + if (section < ReferenceSection.Nodes || section > ReferenceSection.VectorEntries) { + throw new Error( + `fork reference segment ${expectedOrdinal} has unknown section ${section}`, + ); + } + if ( + view.getUint16(10, true) !== WPK_FORK_REFERENCE_SEGMENT_KNOWN_FLAGS + || view.getUint32(12, true) !== 0 + || view.getUint32(36, true) !== 0 + ) { + throw new Error( + `fork reference segment ${expectedOrdinal} flags or reserved fields are nonzero`, + ); + } + const ordinal = view.getBigUint64(16, true); + if (ordinal !== expectedOrdinal) { + throw new Error( + `fork reference segment ordinal ${ordinal} is not expected ${expectedOrdinal}`, + ); + } + const dataLength = view.getUint32(32, true); + if ( + dataLength === 0 + || payload.byteLength !== FORK_REFERENCE_SEGMENT_HEADER_SIZE + dataLength + ) { + throw new Error( + `fork reference segment ${expectedOrdinal} has invalid data length`, + ); + } + return { + section: section as ReferenceSection, + offset: view.getBigUint64(24, true), + data: payload.subarray(FORK_REFERENCE_SEGMENT_HEADER_SIZE), + }; +} + +function validateReferenceSemantics( + parsed: ParsedSegmentedForkReferenceTransaction, +): ValidatedReferenceSemantics { + validateNodeSemantics(parsed); + return validateVectorSemantics(parsed); +} + +function validateNodeSemantics( + parsed: ParsedSegmentedForkReferenceTransaction, +): void { + const nodeReader = requiredSection(parsed, ReferenceSection.Nodes).reader(); + const edgeReader = requiredSection(parsed, ReferenceSection.Edges).reader(); + const scalarReader = requiredSection(parsed, ReferenceSection.Scalars).reader(); + const recordBytes = new Uint8Array(FORK_REFERENCE_NODE_RECORD_SIZE); + let expectedEdgeStart = 0n; + let expectedScalarStart = 0n; + + for (let id = 0; id < parsed.manifest.nodeCount; id++) { + nodeReader.readInto(recordBytes); + const header = decodeNodeHeader(recordBytes, id); + validateNodeHeader( + header, + id, + expectedEdgeStart, + expectedScalarStart, + ); + for (let edgeIndex = 0n; edgeIndex < header.edgeCount; edgeIndex++) { + const recipeId = edgeReader.readU32(); + if (recipeId >= parsed.manifest.nodeCount) { + throw new Error( + `fork reference node ${id} edge ${edgeIndex} names ` + + `missing recipe ${recipeId}`, + ); + } + } + scalarReader.skip(header.scalarLength); + expectedEdgeStart = addU64( + expectedEdgeStart, + header.edgeCount, + "fork reference consumed edge count", + ); + expectedScalarStart = addU64( + expectedScalarStart, + header.scalarLength, + "fork reference consumed scalar bytes", + ); + } + nodeReader.requireEnd("fork reference node records"); + edgeReader.requireEnd("fork reference graph edges"); + scalarReader.requireEnd("fork reference scalar payloads"); +} + +function validateVectorSemantics( + parsed: ParsedSegmentedForkReferenceTransaction, +): ValidatedReferenceSemantics { + const indexReader = requiredSection( + parsed, + ReferenceSection.VectorIndex, + ).reader(); + const entries = requiredSection(parsed, ReferenceSection.VectorEntries); + const entryReader = entries.reader(); + const indexBytes = new Uint8Array(FORK_REFERENCE_VECTOR_INDEX_SIZE); + const vectors = new PagedForkReferenceDirectory(); + const vectorIntern: MutableForkReferenceVectorInternIndex = new Map(); + let expectedStart = 0n; + + for (let ordinal = 1; ordinal <= parsed.manifest.vectorCount; ordinal++) { + indexReader.readInto(indexBytes); + const view = new DataView(indexBytes.buffer); + const start = view.getBigUint64(0, true); + const length64 = view.getBigUint64(8, true); + if (start !== expectedStart) { + throw new Error( + `fork reference vector ${ordinal} starts at ${start}; ` + + `expected ${expectedStart}`, + ); + } + if (length64 === 0n || length64 > BigInt(MAX_U32)) { + throw new RangeError( + `fork reference vector ${ordinal} length ${length64} is invalid`, + ); + } + const length = Number(length64); + let first = (0x811c_9dc5 ^ length) >>> 0; + let second = (0x9e37_79b9 ^ length) >>> 0; + for (let index = 0; index < length; index++) { + const recipeId = entryReader.readU32(); + if (recipeId >= parsed.manifest.nodeCount) { + throw new Error( + `fork reference vector ${ordinal} entry ${index} names ` + + `missing recipe ${recipeId}`, + ); + } + first = Math.imul(first ^ recipeId, 0x0100_0193) >>> 0; + const rotated = ((recipeId << 16) | (recipeId >>> 16)) >>> 0; + second = Math.imul(second ^ rotated, 0x85eb_ca6b) >>> 0; + second = (second ^ (first >>> 13)) >>> 0; + } + const key = `${length}:${first}:${second}`; + for (const previousOrdinal of vectorIntern.get(key) ?? []) { + const previous = requiredDirectoryEntry( + vectors, + previousOrdinal - 1, + "fork reference vector range", + ); + if (vectorRangesEqual(entries, previous, { start, length })) { + throw new Error( + `fork reference vector ${ordinal} duplicates canonical vector ` + + `${previousOrdinal}`, + ); + } + } + let bucket = vectorIntern.get(key); + if (!bucket) { + bucket = new PagedForkReferenceDirectory(); + vectorIntern.set(key, bucket); + } + bucket.push(ordinal); + vectors.push(Object.freeze({ start, length })); + expectedStart = addU64( + expectedStart, + length64, + "fork reference vector entry count", + ); + } + indexReader.requireEnd("fork reference vector indexes"); + entryReader.requireEnd("fork reference vector entries"); + return { vectors, vectorIntern }; +} + +function vectorRangesEqual( + entries: SegmentedSection, + left: ValidatedVectorRange, + right: ValidatedVectorRange, +): boolean { + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index++) { + const local = BigInt(index) * 4n; + if ( + entries.readU32At(left.start * 4n + local) + !== entries.readU32At(right.start * 4n + local) + ) { + return false; + } + } + return true; +} + +function materializeReferenceGraph( + parsed: ParsedSegmentedForkReferenceTransaction, +): DecodedSegmentedForkReferenceTransaction["graph"] { + const nodeReader = requiredSection(parsed, ReferenceSection.Nodes).reader(); + const edgeReader = requiredSection(parsed, ReferenceSection.Edges).reader(); + const scalarReader = requiredSection(parsed, ReferenceSection.Scalars).reader(); + const recordBytes = new Uint8Array(FORK_REFERENCE_NODE_RECORD_SIZE); + const nodes = new PagedForkReferenceDirectory(); + + for (let id = 0; id < parsed.manifest.nodeCount; id++) { + nodeReader.readInto(recordBytes); + const header = decodeNodeHeader(recordBytes, id); + const edges: number[] = []; + for (let edgeIndex = 0; edgeIndex < Number(header.edgeCount); edgeIndex++) { + edges.push(edgeReader.readU32()); + } + const scalars = scalarReader.readBytes(Number(header.scalarLength)); + nodes.push(Object.freeze({ + id, + node: decodeRecipeNode(header, edges, scalars, id), + })); + } + return Object.freeze({ + // Recipe IDs are direct roots from frames/globals/tables. A redundant + // all-node root vector would double graph bookkeeping and force remapping. + roots: Object.freeze([]), + nodes, + }); +} + +function materializeReferenceVectors( + parsed: ParsedSegmentedForkReferenceTransaction, + semantics: ValidatedReferenceSemantics, +): ForkReferenceDirectory { + const reader = requiredSection( + parsed, + ReferenceSection.VectorEntries, + ).reader(); + const vectors = new PagedForkReferenceDirectory(); + vectors.push(PagedForkReferenceVector.empty); + for (const range of semantics.vectors) { + const builder = new ForkReferenceVectorBuilder(range.length); + for (let index = 0; index < range.length; index++) { + builder.append(reader.readU32()); + } + vectors.push(builder.finish()); + } + reader.requireEnd("fork reference vector materialization"); + return vectors; +} + +interface DecodedNodeHeader { + readonly kind: WireNodeKind; + readonly first: number; + readonly second: number; + readonly third: number; + readonly edgeStart: bigint; + readonly edgeCount: bigint; + readonly scalarStart: bigint; + readonly scalarLength: bigint; +} + +function decodeNodeHeader( + bytes: Uint8Array, + id: number, +): DecodedNodeHeader { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (view.getUint8(1) !== 0 || view.getUint16(2, true) !== 0) { + throw new Error(`fork reference node ${id} has nonzero flags or reserved fields`); + } + const kind = view.getUint8(0); + if (kind < WireNodeKind.Null || kind > WireNodeKind.StaticRoot) { + throw new Error(`fork reference node ${id} has unknown kind ${kind}`); + } + return { + kind: kind as WireNodeKind, + first: view.getUint32(4, true), + second: view.getUint32(8, true), + third: view.getUint32(12, true), + edgeStart: view.getBigUint64(16, true), + edgeCount: view.getBigUint64(24, true), + scalarStart: view.getBigUint64(32, true), + scalarLength: view.getBigUint64(40, true), + }; +} + +function validateNodeHeader( + header: DecodedNodeHeader, + id: number, + expectedEdgeStart: bigint, + expectedScalarStart: bigint, +): void { + const context = `fork reference node ${id}`; + const aggregate = + header.kind === WireNodeKind.Exnref + || header.kind === WireNodeKind.Struct + || header.kind === WireNodeKind.Array; + if (aggregate) { + if ( + header.edgeStart !== expectedEdgeStart + || header.scalarStart !== expectedScalarStart + ) { + throw new Error( + `${context} has noncanonical edge or scalar start`, + ); + } + if ( + header.edgeCount > BigInt(MAX_U32) + || header.scalarLength > BigInt(MAX_U32) + ) { + throw new RangeError(`${context} aggregate length exceeds u32`); + } + } else if ( + header.edgeStart !== 0n + || header.edgeCount !== 0n + || header.scalarStart !== 0n + || header.scalarLength !== 0n + ) { + throw new Error(`${context} scalar record declares aggregate data`); + } + + switch (header.kind) { + case WireNodeKind.Null: + if ( + id !== 0 + || header.first !== 0 + || header.second !== 0 + || header.third !== 0 + ) { + throw new Error(`${context} is not the canonical null recipe`); + } + break; + case WireNodeKind.Funcref: + case WireNodeKind.StaticRoot: + if (header.third !== 0) { + throw new Error(`${context} reserved scalar field is nonzero`); + } + break; + case WireNodeKind.Externref: + decodeHandle(header.first, header.second, id); + if (header.third !== 0) { + throw new Error(`${context} externref reserved scalar field is nonzero`); + } + break; + case WireNodeKind.I31: { + if (header.second !== 0 || header.third !== 0) { + throw new Error(`${context} i31 reserved scalar field is nonzero`); + } + const value = header.first | 0; + if (value < -0x4000_0000 || value > 0x3fff_ffff) { + throw new RangeError(`${context} has invalid i31 payload ${value}`); + } + break; + } + case WireNodeKind.Exnref: + case WireNodeKind.Struct: + case WireNodeKind.Array: + break; + } +} + +function decodeRecipeNode( + header: DecodedNodeHeader, + edges: readonly number[], + scalars: Uint8Array, + id: number, +): ForkReferenceRecipeNode { + switch (header.kind) { + case WireNodeKind.Null: + return Object.freeze({ kind: "null" }); + case WireNodeKind.Funcref: + return Object.freeze({ + kind: "funcref", + moduleActivation: header.first, + functionOrdinal: header.second, + }); + case WireNodeKind.Externref: + return Object.freeze({ + kind: "externref", + handle: decodeHandle(header.first, header.second, id), + }); + case WireNodeKind.Exnref: + return Object.freeze({ + kind: "exnref", + moduleActivation: header.first, + tagOrdinal: header.second, + layoutId: header.third, + scalars, + payloads: Object.freeze([...edges]), + }); + case WireNodeKind.I31: + return Object.freeze({ kind: "i31", value: header.first | 0 }); + case WireNodeKind.Struct: + return Object.freeze({ + kind: "struct", + moduleActivation: header.first, + typeOrdinal: header.second, + layoutId: header.third, + scalars, + fields: Object.freeze([...edges]), + }); + case WireNodeKind.Array: + return Object.freeze({ + kind: "array", + moduleActivation: header.first, + typeOrdinal: header.second, + layoutId: header.third, + scalars, + elements: Object.freeze([...edges]), + }); + case WireNodeKind.StaticRoot: + return Object.freeze({ + kind: "static-root", + moduleActivation: header.first, + staticRootOrdinal: header.second, + }); + } +} + +function decodeHandle(first: number, second: number, id: number): number { + const handle = Number((BigInt(second) << 32n) | BigInt(first)); + if ( + !Number.isInteger(handle) + || handle <= 0 + || handle > MAX_U32 + ) { + throw new RangeError( + `fork reference node ${id} externref handle ${handle} is invalid`, + ); + } + return handle; +} + +function requiredSection( + parsed: ParsedSegmentedForkReferenceTransaction, + section: ReferenceSection, +): SegmentedSection { + const value = parsed.sections.get(section); + if (!value) { + throw new Error(`fork reference section ${section} is absent`); + } + return value; +} + +function requiredDirectoryEntry( + directory: ForkReferenceSequence, + index: number, + context: string, +): T { + const present = Array.isArray(directory) + ? index >= 0 && index < directory.length && index in directory + : (directory as ForkReferenceDirectory).has(index); + if (!present) { + throw new Error(`${context} ${index} is absent`); + } + return ( + Array.isArray(directory) + ? directory[index] + : (directory as ForkReferenceDirectory).get(index) + )!; +} + +function validateCanonicalCapture( + nodes: ForkReferenceSequence, + vectors: ForkReferenceSequence, +): void { + if (nodes.length === 0 || nodes.length > MAX_U32_DIRECTORY_LENGTH) { + throw new RangeError(`fork reference node count ${nodes.length} is invalid`); + } + if (vectors.length === 0 || vectors.length - 1 > MAX_U32) { + throw new RangeError(`fork reference vector count ${vectors.length - 1} is invalid`); + } + if (requiredDirectoryEntry(vectors, 0, "fork reference vector").length !== 0) { + throw new Error("fork reference vector zero is not the empty sentinel"); + } + let id = 0; + for (const entry of nodes) { + if (entry.id !== id) { + throw new Error( + `fork reference recipe node ${entry.id} is out of canonical order at ${id}`, + ); + } + if ((id === 0) !== (entry.node.kind === "null")) { + throw new Error( + id === 0 + ? "fork reference recipe node zero is not null" + : `fork reference recipe node ${id} duplicates null`, + ); + } + validateCaptureNode(entry.node, id, nodes.length); + id++; + } + const intern: MutableForkReferenceVectorInternIndex = new Map(); + for (let ordinal = 1; ordinal < vectors.length; ordinal++) { + const vector = requiredDirectoryEntry( + vectors, + ordinal, + "fork reference vector", + ); + if (vector.length === 0) { + throw new Error( + `fork reference vector ${ordinal} duplicates the empty sentinel`, + ); + } + vector.forEach((recipeId, index) => { + assertRecipeId(recipeId, `fork reference vector ${ordinal} entry ${index}`); + if (recipeId >= nodes.length) { + throw new Error( + `fork reference vector ${ordinal} entry ${index} names ` + + `missing recipe ${recipeId}`, + ); + } + }); + const key = forkReferenceVectorInternKey(vector); + for (const previous of intern.get(key) ?? []) { + if (forkReferenceVectorsEqual( + requiredDirectoryEntry(vectors, previous, "fork reference vector"), + vector, + )) { + throw new Error( + `fork reference vector ${ordinal} duplicates canonical vector ${previous}`, + ); + } + } + let bucket = intern.get(key); + if (!bucket) { + bucket = new PagedForkReferenceDirectory(); + intern.set(key, bucket); + } + bucket.push(ordinal); + } +} + +function validateCaptureNode( + node: ForkReferenceRecipeNode, + id: number, + nodeCount: number, +): void { + const context = `fork reference node ${id}`; + switch (node.kind) { + case "null": + return; + case "funcref": + assertU32(node.moduleActivation, `${context} module activation`); + assertU32(node.functionOrdinal, `${context} function ordinal`); + return; + case "externref": + if (!Number.isInteger(node.handle) || node.handle <= 0 || node.handle > MAX_U32) { + throw new RangeError(`${context} externref handle is invalid`); + } + return; + case "i31": + if ( + !Number.isInteger(node.value) + || node.value < -0x4000_0000 + || node.value > 0x3fff_ffff + ) { + throw new RangeError(`${context} has invalid i31 value ${node.value}`); + } + return; + case "exnref": + assertU32(node.moduleActivation, `${context} module activation`); + assertU32(node.tagOrdinal, `${context} tag ordinal`); + assertU32(node.layoutId ?? 0, `${context} layout id`); + validateCaptureAggregate(node.payloads, node.scalars, context, nodeCount); + return; + case "struct": + assertU32(node.moduleActivation, `${context} module activation`); + assertU32(node.typeOrdinal, `${context} type ordinal`); + assertU32(node.layoutId ?? 0, `${context} layout id`); + validateCaptureAggregate(node.fields, node.scalars, context, nodeCount); + return; + case "array": + assertU32(node.moduleActivation, `${context} module activation`); + assertU32(node.typeOrdinal, `${context} type ordinal`); + assertU32(node.layoutId ?? 0, `${context} layout id`); + validateCaptureAggregate(node.elements, node.scalars, context, nodeCount); + return; + case "static-root": + assertU32(node.moduleActivation, `${context} module activation`); + assertU32(node.staticRootOrdinal, `${context} static-root ordinal`); + return; + } +} + +function validateCaptureAggregate( + edges: readonly number[], + scalars: Uint8Array | undefined, + context: string, + nodeCount: number, +): void { + if (edges.length > MAX_U32) { + throw new RangeError(`${context} edge count exceeds u32`); + } + if (scalars !== undefined && !(scalars instanceof Uint8Array)) { + throw new TypeError(`${context} scalar payload is not a Uint8Array`); + } + edges.forEach((recipeId, index) => { + assertRecipeId(recipeId, `${context} edge ${index}`); + if (recipeId >= nodeCount) { + throw new Error(`${context} edge ${index} names missing recipe ${recipeId}`); + } + }); +} + +function encodeNodeRecordV2( + view: DataView, + node: ForkReferenceRecipeNode, + edgeStart: bigint, + edgeCount: bigint, + scalarStart: bigint, + scalarLength: bigint, +): void { + let kind: WireNodeKind; + let first = 0; + let second = 0; + let third = 0; + switch (node.kind) { + case "null": + kind = WireNodeKind.Null; + break; + case "funcref": + kind = WireNodeKind.Funcref; + first = node.moduleActivation; + second = node.functionOrdinal; + break; + case "externref": + kind = WireNodeKind.Externref; + first = node.handle >>> 0; + second = Math.floor(node.handle / 0x1_0000_0000); + break; + case "exnref": + kind = WireNodeKind.Exnref; + first = node.moduleActivation; + second = node.tagOrdinal; + third = node.layoutId ?? 0; + break; + case "i31": + kind = WireNodeKind.I31; + first = node.value >>> 0; + break; + case "struct": + kind = WireNodeKind.Struct; + first = node.moduleActivation; + second = node.typeOrdinal; + third = node.layoutId ?? 0; + break; + case "array": + kind = WireNodeKind.Array; + first = node.moduleActivation; + second = node.typeOrdinal; + third = node.layoutId ?? 0; + break; + case "static-root": + kind = WireNodeKind.StaticRoot; + first = node.moduleActivation; + second = node.staticRootOrdinal; + break; + } + const aggregate = + node.kind === "exnref" || node.kind === "struct" || node.kind === "array"; + view.setUint8(0, kind); + view.setUint8(1, 0); + view.setUint16(2, 0, true); + view.setUint32(4, first, true); + view.setUint32(8, second, true); + view.setUint32(12, third, true); + view.setBigUint64(16, aggregate ? edgeStart : 0n, true); + view.setBigUint64(24, aggregate ? edgeCount : 0n, true); + view.setBigUint64(32, aggregate ? scalarStart : 0n, true); + view.setBigUint64(40, aggregate ? scalarLength : 0n, true); +} + +function nodeEdges(node: ForkReferenceRecipeNode): readonly number[] { + switch (node.kind) { + case "exnref": + return node.payloads; + case "struct": + return node.fields; + case "array": + return node.elements; + case "null": + case "funcref": + case "externref": + case "i31": + case "static-root": + return []; + } +} + +function nodeScalars(node: ForkReferenceRecipeNode): Uint8Array { + switch (node.kind) { + case "exnref": + case "struct": + case "array": + return node.scalars ?? new Uint8Array(); + case "null": + case "funcref": + case "externref": + case "i31": + case "static-root": + return new Uint8Array(); + } +} + +function sectionTotalsArray(totals: SectionTotals): readonly bigint[] { + return [ + totals.nodes, + totals.edges, + totals.scalars, + totals.vectorIndex, + totals.vectorEntries, + ]; +} + +function addU64(left: bigint, right: bigint, context: string): bigint { + if (left < 0n || right < 0n || left > MAX_U64 - right) { + throw new RangeError(`${context} exceeds u64`); + } + return left + right; +} + +function multiplyU64(left: bigint, right: bigint, context: string): bigint { + if (left < 0n || right < 0n || (right !== 0n && left > MAX_U64 / right)) { + throw new RangeError(`${context} exceeds u64`); + } + return left * right; +} + +/** Pure u64 helper used by boundary tests without allocating a 4-GiB buffer. */ +export function advanceForkReferenceLogicalOffset( + offset: bigint, + byteLength: number, +): bigint { + if (!Number.isSafeInteger(byteLength) || byteLength < 0) { + throw new RangeError("fork reference logical byte length is invalid"); + } + return addU64(offset, BigInt(byteLength), "fork reference logical offset"); +} + +function assertRecipeId(value: number, context: string): void { + if (!Number.isInteger(value) || value < 0 || value > MAX_U32) { + throw new RangeError(`${context} ${value} is not a u32 recipe id`); + } +} + +function assertU32(value: number, context: string): void { + if (!Number.isInteger(value) || value < 0 || value > MAX_U32) { + throw new RangeError(`${context} is not an unsigned 32-bit integer`); + } +} + +function assertOwner(ownerId: number): void { + assertU32(ownerId, "fork reference owner id"); + if (ownerId === 0) { + throw new RangeError("fork reference owner id must be nonzero"); + } +} + +function littleEndianMagic(bytes: readonly number[]): number { + if (bytes.length !== 4) { + throw new Error("fork reference ABI magic must contain four bytes"); + } + return ( + bytes[0]! + | (bytes[1]! << 8) + | (bytes[2]! << 16) + | (bytes[3]! << 24) + ) >>> 0; +} diff --git a/host/src/fork-reference-transaction.ts b/host/src/fork-reference-transaction.ts new file mode 100644 index 0000000000..8d8ec0eb33 --- /dev/null +++ b/host/src/fork-reference-transaction.ts @@ -0,0 +1,2054 @@ +import { + type ForkModuleStateArena, +} from "./fork-module-state"; +import { + type ForkReferenceRecipeEntry, + type ForkReferenceRecipeNode, +} from "./fork-reference-recipes"; +import { + appendSegmentedForkReferenceTransaction, + decodeSegmentedForkReferenceTransaction, + findForkReferenceVectorOrdinal, + forkReferenceVectorFrom, + ForkReferenceDirectoryOverlay, + ForkReferenceVectorBuilder, + indexForkReferenceVector, + PagedForkReferenceDirectory, + PagedForkReferenceVector, + type DecodedSegmentedForkReferenceTransaction, + type ForkReferenceDirectory, + type ForkReferenceVector, + type MutableForkReferenceVectorInternIndex, +} from "./fork-reference-segments"; +import { ForkFunctionCatalog } from "./fork-function-catalog"; +import { + FORK_GC_FIELD_ALLOCATION_DEPENDENCY, + FORK_GC_FIELD_MUTABLE, + FORK_GC_FIELD_REFERENCE, + FORK_GC_LAYOUT_DEFAULTABLE_SHELL, + FORK_GC_LAYOUT_REQUIRES_PROVENANCE, + ForkGcConstructorKind, + type ForkGcCodecDescriptor, + type ForkGcCodecProvider, + type ForkGcConstructorProvenance, + type ForkGcLayoutDescriptor, +} from "./fork-gc-codec"; +import { ForkStaticRootCatalog } from "./fork-static-root-catalog"; +import { + WPK_FORK_REFERENCE_TRANSACTION_OWNER, +} from "./generated/abi"; + +/** + * The owner id is local to the ReferenceRecipe record kind. One process fork + * has one identity space shared by main-module frames, side-module frames, + * globals, and tables, so aliases never become module-local by accident. + */ +export const FORK_REFERENCE_TRANSACTION_OWNER_ID = + WPK_FORK_REFERENCE_TRANSACTION_OWNER; +export const FORK_HOST_EXCEPTION_ACTIVATION_ID = 0xffff_ffff; +// Generated Wasm carries recipe IDs and vector ordinals as raw i32 bits. The +// host import boundary normalizes signed JavaScript arguments with `>>> 0`, so +// the complete u32 namespace is available; zero alone is the empty sentinel. +const MAX_REFERENCE_VECTOR_ORDINAL = 0xffff_ffff; + +export interface ForkExternrefRecipeProvider { + /** + * Move an opaque value under process-owned lifetime management and return + * the scalar handle that is safe to copy through the continuation. + */ + capture(value: unknown): number; + + /** + * Produce this Worker's canonical token for a process-owned handle. + * The real host value remains with the provider's owner. + */ + materialize(handle: number): unknown; +} + +export interface ForkExceptionSlotProvider { + /** Throw the exact exnref held in a Wasm-only scratch slot. */ + throwSlot(slot: number): never; + /** Release every Wasm scratch-table root after replay or abort. */ + clearSlots(): void; +} + +export type ForkReferenceScratchAllocate = (size: number) => number; +export type ForkReferenceScratchDeallocate = (addr: number, size: number) => void; + +/** + * Late-bound process owner used only in a fresh child. Keeping this scalar + * interface on the transaction prevents GC/exn references from crossing the + * JavaScript boundary while still allowing graph-wide validation and ordering. + */ +export interface ForkTypedReferenceReplayOwner { + prepareTransit(maxRecipeId: number): void; + /** + * Publish an instantiation-owned GC root at its canonical recipe slot. + * + * Generated codecs publish reconstructed struct/array/i31 identities + * themselves. Static roots already exist in the fresh activation, so the + * host must route that exact identity before a constructor or field fill + * consumes it as a graph edge. + */ + publishTransit(recipeId: number, value: unknown): void; + /** + * Publish an opaque process-owned leaf through a generated + * `any.convert_extern`, preserving the canonical token without asking + * JavaScript to manufacture an anyref. + */ + publishExternref(recipeId: number, value: unknown): void; + provider(activationId: number): ForkGcCodecProvider; + providers(): readonly ForkGcCodecProvider[]; + validateExceptionOwner(activationId: number): void; + materializeException(recipeId: number, activationId: number): void; +} + +/** + * Canonical identities materialized before every fresh activation exists. + * + * Imported immutable reference globals must be supplied while their consumer + * is instantiated. The early child provider therefore reconstructs a strict + * prefix of the process graph before the full replay transaction can attach. + * Adoption transfers those identities and typed-codec milestones so replay + * never allocates a second object for a recipe already visible to Wasm. + */ +export interface ForkReferenceChildReplayAdoption { + /** Exact decoded KFRV transaction object that produced this state. */ + readonly transaction: DecodedSegmentedForkReferenceTransaction; + /** + * Sparse canonical values. A Map is required because `undefined` is a valid + * externref and must remain distinct from an unmaterialized recipe. + */ + readonly materializedValues: ReadonlyMap; + readonly allocatedTypedRecipes: ReadonlySet; + readonly filledTypedRecipes: ReadonlySet; + readonly materializedExceptionRecipes: ReadonlySet; +} + +export interface ForkGcDefinitionProvenance { + readonly record: ForkGcConstructorProvenance; + readonly recipeIds: readonly number[]; +} + +interface ScratchChunk { + readonly addr: number; + readonly size: number; + used: number; +} + +interface ScratchReservation { + readonly addr: number; + readonly requestedSize: number; + readonly alignedSize: number; + readonly previousUsed: number; + readonly chunk: ScratchChunk; +} + +interface CaptureReferenceVector { + readonly expectedLength: number; + readonly builder: ForkReferenceVectorBuilder; +} + +type CanonicalReferenceVector = ForkReferenceVector; + +type TransactionPhase = + | "idle" + | "capture" + | "sealed-parent" + | "parent-replay" + | "child-replay"; + +function assertRecipeId(value: number): void { + if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff) { + throw new RangeError(`invalid fork reference recipe id ${value}`); + } +} + +export type DecodedForkReferenceTransaction = + DecodedSegmentedForkReferenceTransaction; + +/** + * Validate and expose the one process-wide reference graph in a sealed arena. + * + * This is shared by fresh-child reconstruction and the kernel-side externref + * owner. WHY: the owner must acquire the child's numeric handle lease before + * starting its Worker, but it must validate the exact same KFRV bytes that the + * child will later consume rather than maintaining a second permissive parser. + */ +export function decodeForkReferenceTransactionRecord( + records: Parameters[0], +): DecodedForkReferenceTransaction { + return decodeSegmentedForkReferenceTransaction( + records, + FORK_REFERENCE_TRANSACTION_OWNER_ID, + ); +} + +/** + * Per-fork owner for activation reference recipes. + * + * Recipe id zero is node zero and the canonical null value. Every nonnull + * recipe id is its graph node id. Seeding the graph this way keeps reference + * payload vectors lossless: a null field is an ordinary edge to node zero, + * while zero remains cheap in every generated typed codec. + */ +export class ForkReferenceTransaction { + private phase: TransactionPhase = "idle"; + private readonly nodes = + new PagedForkReferenceDirectory(); + private readonly capturedValues = + new PagedForkReferenceDirectory(); + private objectIds = new WeakMap(); + private readonly primitiveIds = new Map(); + private decodedNodes: ForkReferenceDirectory = + new PagedForkReferenceDirectory(); + private readonly materializedValues = new Map(); + private readonly pendingExceptions = new Set(); + private readonly pendingGc = new Set(); + private readonly i31Ids = new Map(); + private readonly exceptionCacheIndexes = new Map(); + private exceptionSlots: ForkExceptionSlotProvider | undefined; + private readonly scratchChunks: ScratchChunk[] = []; + private readonly scratchReservations: ScratchReservation[] = []; + /** Index zero is the canonical empty-vector sentinel. */ + private readonly referenceVectors = + new PagedForkReferenceDirectory(); + private readonly pendingReferenceVectors = new Map< + number, + CaptureReferenceVector + >(); + private readonly freeReferenceVectorHandles: number[] = []; + private nextReferenceVectorHandle = 1; + private readonly referenceVectorIntern: + MutableForkReferenceVectorInternIndex = new Map(); + private readonly decodedReferenceVectors = + new ForkReferenceDirectoryOverlay(); + private readonly decodedReferenceVectorIntern: + MutableForkReferenceVectorInternIndex = new Map(); + private readonly replayGcVectors = new Map(); + private typedMaterialized = false; + private childTransaction: DecodedSegmentedForkReferenceTransaction | null = null; + private childReplayAdopted = false; + private readonly adoptedAllocatedTypedRecipes = new Set(); + private readonly adoptedFilledTypedRecipes = new Set(); + private readonly adoptedMaterializedExceptionRecipes = new Set(); + + constructor( + readonly functions: ForkFunctionCatalog, + private readonly externrefs: ForkExternrefRecipeProvider, + private readonly memory?: WebAssembly.Memory, + private readonly allocateScratch?: ForkReferenceScratchAllocate, + private readonly deallocateScratch?: ForkReferenceScratchDeallocate, + private readonly label = "fork reference transaction", + private readonly staticRoots?: ForkStaticRootCatalog, + private readonly typedReplay?: ForkTypedReferenceReplayOwner, + ) {} + + setExceptionSlotProvider(provider: ForkExceptionSlotProvider): void { + if (this.exceptionSlots && this.exceptionSlots !== provider) { + throw new Error("fork exception slot provider is already registered"); + } + this.exceptionSlots = provider; + } + + beginCapture(): void { + if (this.phase !== "idle") { + throw new Error(`cannot begin reference capture while transaction is ${this.phase}`); + } + this.nodes.push({ id: 0, node: { kind: "null" } }); + this.capturedValues.push(null); + this.referenceVectors.push(PagedForkReferenceVector.empty); + this.phase = "capture"; + } + + encodeFuncref(value: unknown): number { + this.requirePhase("capture", "encode a funcref"); + if (value === null) return 0; + if (typeof value !== "function") { + throw new TypeError("fork funcref encoder received a non-function value"); + } + return this.intern(value, () => { + const recipe = this.functions.encode(value); + if (!recipe) throw new Error("non-null funcref produced a null catalog recipe"); + return { + kind: "funcref", + moduleActivation: recipe.moduleActivation, + functionOrdinal: recipe.ordinal, + }; + }); + } + + encodeExternref(value: unknown): number { + this.requirePhase("capture", "encode an externref"); + if (value === null) return 0; + return this.intern(value, () => { + // A WebAssembly function can cross an externref conversion. Keep one + // node for it so converting back in the child observes the same fresh + // function identity as a funcref slot. + if (typeof value === "function") { + const recipe = this.functions.encode(value); + if (recipe) { + return { + kind: "funcref", + moduleActivation: recipe.moduleActivation, + functionOrdinal: recipe.ordinal, + }; + } + } + return { + kind: "externref", + handle: this.externrefs.capture(value), + }; + }); + } + + lookupGcSlot(table: WebAssembly.Table, slot: number): number { + this.requirePhase("capture", "look up a Wasm-GC identity"); + const value = this.gcSlotValue(table, slot); + const known = this.lookupId(value); + if (known !== undefined) return known; + const staticRoot = this.staticRoots?.encode(value); + if (!staticRoot) return 0; + const id = this.nodes.length; + if (id > 0xffff_ffff) { + throw new RangeError("fork reference recipe id space exhausted"); + } + this.nodes.push({ + id, + node: { + kind: "static-root", + moduleActivation: staticRoot.moduleActivation, + staticRootOrdinal: staticRoot.ordinal, + }, + }); + this.capturedValues.push(value); + this.rememberId(value, id); + return id; + } + + claimGcSlot(table: WebAssembly.Table, slot: number): number { + this.requirePhase("capture", "claim a Wasm-GC identity"); + const value = this.gcSlotValue(table, slot); + const known = this.lookupId(value); + if (known !== undefined) return known; + const id = this.nodes.length; + if (id > 0xffff_ffff) { + throw new RangeError("fork reference recipe id space exhausted"); + } + // WHY: publish graph identity before recursively encoding fields. The + // placeholder is never serializable; `sealInto` rejects it via pendingGc + // unless the generated codec completes `defineGc`. + this.nodes.push({ + id, + node: { + kind: "struct", + moduleActivation: 0, + typeOrdinal: 0, + layoutId: 0, + scalars: new Uint8Array(), + fields: [], + }, + }); + this.capturedValues.push(value); + this.rememberId(value, id); + this.pendingGc.add(id); + return id; + } + + encodeI31(value: number): number { + this.requirePhase("capture", "encode an i31ref"); + if ( + !Number.isInteger(value) + || value < -0x4000_0000 + || value > 0x3fff_ffff + ) { + throw new RangeError(`invalid signed i31 payload ${value}`); + } + const known = this.i31Ids.get(value); + if (known !== undefined) return known; + const id = this.nodes.length; + if (id > 0xffff_ffff) { + throw new RangeError("fork reference recipe id space exhausted"); + } + this.nodes.push({ id, node: { kind: "i31", value } }); + // i31 identity is defined by its 31-bit payload. Keep a scalar here so + // parent replay and graph aliases use the same canonical recipe id. + this.capturedValues.push(value); + this.i31Ids.set(value, id); + return id; + } + + capturedGcValue(recipeId: number): unknown { + this.requirePhase("capture", "read a captured Wasm-GC identity"); + assertRecipeId(recipeId); + if (recipeId === 0 || recipeId >= this.capturedValues.length) { + throw new Error(`fork Wasm-GC recipe ${recipeId} is out of bounds`); + } + return this.capturedValues.get(recipeId); + } + + defineGc( + recipeId: number, + moduleActivation: number, + typeOrdinal: number, + layoutId: number, + kind: number, + scalarPointer: number | bigint, + scalarByteLength: number, + referenceVectorOrdinal: number, + descriptor: ForkGcCodecDescriptor, + provenance: ForkGcDefinitionProvenance | null, + ): void { + this.requirePhase("capture", "define a Wasm-GC recipe"); + assertRecipeId(recipeId); + this.assertU32(moduleActivation, "GC module activation"); + this.assertU32(typeOrdinal, "GC type ordinal"); + this.assertU31(layoutId, "GC layout id", false); + this.assertU32(referenceVectorOrdinal, "GC reference vector ordinal"); + if (!this.pendingGc.has(recipeId)) { + throw new Error(`fork Wasm-GC recipe ${recipeId} is not pending definition`); + } + const layout = descriptor.require(layoutId); + if ( + layout.typeOrdinal !== typeOrdinal + || layout.kind !== kind + ) { + throw new Error( + `fork Wasm-GC recipe ${recipeId} coordinate does not match ` + + `${moduleActivation}:${typeOrdinal}:${layoutId}:${kind}`, + ); + } + const snapshotScalars = this.readBytes( + scalarPointer, + scalarByteLength, + "fork Wasm-GC scalar payload", + ); + const vector = this.referenceVectors.get(referenceVectorOrdinal); + if (!vector) { + throw new Error( + `fork Wasm-GC recipe ${recipeId} names an unavailable reference vector`, + ); + } + this.validateGcSnapshot( + layout, + snapshotScalars, + vector, + `fork Wasm-GC recipe ${recipeId}`, + ); + + const requiresProvenance = + (layout.flags & FORK_GC_LAYOUT_REQUIRES_PROVENANCE) !== 0; + if (requiresProvenance !== (provenance !== null)) { + throw new Error( + `fork Wasm-GC recipe ${recipeId} has ` + + `${provenance ? "unexpected" : "missing"} constructor provenance`, + ); + } + let provenanceScalars: Uint8Array = new Uint8Array(); + let provenanceIds: readonly number[] = []; + if (provenance) { + const selected = descriptor.requireCaptureLayout( + layout.baseLayoutId, + provenance.record.layoutId, + ); + if ( + selected.id !== layout.id + || provenance.record.activationId !== moduleActivation + || provenance.record.baseLayoutId !== layout.baseLayoutId + || provenance.record.scalars.byteLength + !== layout.provenanceScalarLength + || provenance.record.references.length + !== layout.provenanceReferenceCount + || provenance.recipeIds.length + !== layout.provenanceReferenceCount + ) { + throw new Error( + `fork Wasm-GC recipe ${recipeId} provenance does not match ` + + `layout ${layout.id}`, + ); + } + provenance.recipeIds.forEach((id, index) => { + assertRecipeId(id); + if (id >= this.nodes.length) { + throw new Error( + `fork Wasm-GC provenance ${index} names missing recipe ${id}`, + ); + } + }); + provenanceScalars = provenance.record.scalars; + provenanceIds = provenance.recipeIds; + } + + const scalars = new Uint8Array( + provenanceScalars.byteLength + snapshotScalars.byteLength, + ); + scalars.set(provenanceScalars); + scalars.set(snapshotScalars, provenanceScalars.byteLength); + const references = [...provenanceIds, ...vector]; + const node: ForkReferenceRecipeNode = + layout.kind === 1 + ? { + kind: "struct", + moduleActivation, + typeOrdinal, + layoutId, + scalars, + fields: references, + } + : { + kind: "array", + moduleActivation, + typeOrdinal, + layoutId, + scalars, + elements: references, + }; + this.nodes.set(recipeId, { id: recipeId, node }); + this.pendingGc.delete(recipeId); + } + + sealInto(arena: ForkModuleStateArena): Uint8Array { + this.requirePhase("capture", "seal reference capture"); + if (this.scratchReservations.length !== 0) { + throw new Error( + `cannot seal with ${this.scratchReservations.length} live reference scratch reservation(s)`, + ); + } + if (this.pendingExceptions.size !== 0) { + throw new Error( + `cannot seal ${this.pendingExceptions.size} incomplete exception recipe(s)`, + ); + } + if (this.pendingGc.size !== 0) { + throw new Error( + `cannot seal ${this.pendingGc.size} incomplete Wasm-GC recipe(s)`, + ); + } + if (this.pendingReferenceVectors.size !== 0) { + throw new Error( + `cannot seal ${this.pendingReferenceVectors.size} unfinished reference vector(s)`, + ); + } + const manifest = appendSegmentedForkReferenceTransaction( + arena, + FORK_REFERENCE_TRANSACTION_OWNER_ID, + this.nodes, + this.referenceVectors, + ); + this.phase = "sealed-parent"; + return manifest; + } + + beginParentReplay(): void { + this.requirePhase("sealed-parent", "begin parent reference replay"); + this.phase = "parent-replay"; + } + + attachChild( + source: + | Parameters[0] + | DecodedSegmentedForkReferenceTransaction, + ): void { + if (this.phase !== "idle") { + throw new Error(`cannot attach child reference state while transaction is ${this.phase}`); + } + const decoded = "identity" in source + ? source + : decodeForkReferenceTransactionRecord(source); + const { graph } = decoded; + this.decodedNodes = graph.nodes; + // WHY: generated GC codecs need an appendable tail, but copying every + // decoded vector into a mutable directory would retain a redundant + // transaction-wide index. The immutable decoded directory remains the + // shared base used by both early and ordinary replay. + this.decodedReferenceVectors.reset(decoded.vectors); + this.decodedReferenceVectorIntern.clear(); + for (const entry of graph.nodes) { + if (entry.node.kind === "exnref") { + this.exceptionCacheIndexes.set( + entry.id, + this.exceptionCacheIndexes.size + 1, + ); + } + } + this.materializedValues.clear(); + for (const entry of graph.nodes) { + if (entry.node.kind !== "static-root") continue; + if (!this.staticRoots) { + throw new Error( + `fork recipe ${entry.id} requires a static-root catalog`, + ); + } + // WHY: module-state restore can overwrite a template table and the + // second restore phase can drop its element segment before activation + // locals decode. Pin only roots actually named by this continuation, + // then release them with the rest of the transaction after replay. + this.materializedValues.set(entry.id, this.staticRoots.decode({ + moduleActivation: entry.node.moduleActivation, + ordinal: entry.node.staticRootOrdinal, + })); + } + this.childTransaction = decoded; + this.phase = "child-replay"; + } + + /** + * Take ownership of identities reconstructed for imported globals. + * + * This is deliberately a separate one-shot step after `attachChild`: the + * transaction first validates the authoritative wire itself, then accepts + * only state proven to come from those exact bytes. + */ + adoptChildReplay(adoption: ForkReferenceChildReplayAdoption): void { + this.requirePhase("child-replay", "adopt early child reference replay"); + if (this.typedMaterialized) { + throw new Error("cannot adopt child references after typed materialization"); + } + if (this.childReplayAdopted) { + throw new Error("early child reference replay was adopted twice"); + } + if (!this.childTransaction || adoption.transaction !== this.childTransaction) { + throw new Error( + "early child reference replay does not share the attached transaction", + ); + } + + const requireNodeKind = ( + recipeId: number, + kinds: readonly ForkReferenceRecipeNode["kind"][], + context: string, + ): ForkReferenceRecipeNode => { + assertRecipeId(recipeId); + const node = this.decodedNodes.get(recipeId)?.node; + if (!node) { + throw new Error(`${context} names missing recipe ${recipeId}`); + } + if (!kinds.includes(node.kind)) { + throw new Error( + `${context} recipe ${recipeId} is ${node.kind}, expected ` + + kinds.join(" or "), + ); + } + return node; + }; + + const stagedValues = new Map(); + for (const [recipeId, value] of adoption.materializedValues) { + const node = requireNodeKind( + recipeId, + ["null", "funcref", "externref", "i31", "struct", "array", "static-root"], + "early materialized value", + ); + if ( + (node.kind === "null" && value !== null) + || (node.kind === "funcref" && typeof value !== "function") + ) { + throw new TypeError( + `early materialized recipe ${recipeId} has an invalid ${node.kind} value`, + ); + } + if (this.materializedValues.has(recipeId)) { + if (!Object.is(this.materializedValues.get(recipeId), value)) { + throw new Error( + `early materialized recipe ${recipeId} conflicts with its child catalog`, + ); + } + continue; + } + stagedValues.set(recipeId, value); + } + + const stagedAllocated = new Set(); + for (const recipeId of adoption.allocatedTypedRecipes) { + requireNodeKind( + recipeId, + ["i31", "struct", "array"], + "early allocated typed reference", + ); + stagedAllocated.add(recipeId); + } + const stagedFilled = new Set(); + for (const recipeId of adoption.filledTypedRecipes) { + requireNodeKind( + recipeId, + ["struct", "array"], + "early filled typed reference", + ); + if (!stagedAllocated.has(recipeId)) { + throw new Error( + `early filled typed recipe ${recipeId} was not allocated`, + ); + } + stagedFilled.add(recipeId); + } + const stagedExceptions = new Set(); + for (const recipeId of adoption.materializedExceptionRecipes) { + requireNodeKind( + recipeId, + ["exnref"], + "early materialized exception", + ); + stagedExceptions.add(recipeId); + } + for (const recipeId of stagedAllocated) { + if ( + !stagedValues.has(recipeId) + && !this.materializedValues.has(recipeId) + ) { + throw new Error( + `early allocated typed recipe ${recipeId} has no canonical value`, + ); + } + } + for (const [recipeId] of stagedValues) { + const kind = this.decodedNodes.get(recipeId)!.node.kind; + if ( + (kind === "i31" || kind === "struct" || kind === "array") + && !stagedAllocated.has(recipeId) + ) { + throw new Error( + `early materialized typed recipe ${recipeId} was not allocated`, + ); + } + } + + for (const [recipeId, value] of stagedValues) { + // Assign even when value is undefined: the own property is the cache bit. + this.materializedValues.set(recipeId, value); + } + stagedAllocated.forEach((id) => this.adoptedAllocatedTypedRecipes.add(id)); + stagedFilled.forEach((id) => this.adoptedFilledTypedRecipes.add(id)); + stagedExceptions.forEach((id) => + this.adoptedMaterializedExceptionRecipes.add(id) + ); + this.childReplayAdopted = true; + } + + decodeFuncref(recipeId: number): CallableFunction | null { + const value = this.decode(recipeId, "funcref"); + if (value !== null && typeof value !== "function") { + throw new TypeError(`fork recipe ${recipeId} did not reconstruct a funcref`); + } + return value as CallableFunction | null; + } + + decodeExternref(recipeId: number): unknown { + return this.decode(recipeId, "externref"); + } + + beginReferenceVector(expectedLength: number): number { + this.requirePhase("capture", "begin a reference vector"); + this.assertU32(expectedLength, "reference vector length"); + if (expectedLength === 0) { + throw new RangeError("reference vector zero is the canonical empty vector"); + } + let handle = this.freeReferenceVectorHandles.pop(); + if (handle === undefined) { + if (this.nextReferenceVectorHandle > MAX_REFERENCE_VECTOR_ORDINAL) { + throw new RangeError("fork reference vector builder handle space exhausted"); + } + handle = this.nextReferenceVectorHandle++; + } + this.pendingReferenceVectors.set(handle, { + expectedLength, + builder: new ForkReferenceVectorBuilder(expectedLength), + }); + return handle; + } + + appendReferenceVector(handle: number, recipeId: number): void { + this.requirePhase("capture", "append a reference vector"); + this.assertU32(handle, "reference vector builder handle"); + assertRecipeId(recipeId); + const vector = this.pendingReferenceVectors.get(handle); + if (!vector) { + throw new Error(`fork reference vector builder ${handle} is not allocated`); + } + if (recipeId >= this.nodes.length) { + throw new Error( + `fork reference vector builder ${handle} names missing recipe ${recipeId}`, + ); + } + vector.builder.append(recipeId); + } + + /** + * Intern one complete activation vector and return its stable wire ordinal. + * + * WHY: recursive activations commonly carry the same reference-recipe + * sequence. Frames keep only this canonical ordinal in their existing header + * word, so recursion grows the linked continuation without duplicating the + * process-owned vector payload. + */ + finishReferenceVector(handle: number): number { + this.requirePhase("capture", "finish a reference vector"); + this.assertU32(handle, "reference vector builder handle"); + const pending = this.pendingReferenceVectors.get(handle); + if (!pending) { + throw new Error(`fork reference vector builder ${handle} is not allocated`); + } + if (pending.builder.length !== pending.expectedLength) { + throw new Error( + `fork reference vector builder ${handle} has ${pending.builder.length} entries; ` + + `expected ${pending.expectedLength}`, + ); + } + const values = pending.builder.finish(); + + const existing = findForkReferenceVectorOrdinal( + [this.referenceVectorIntern], + this.referenceVectors, + values, + ); + if (existing !== undefined) { + this.releaseReferenceVectorHandle(handle); + return existing; + } + const ordinal = this.referenceVectors.length; + if (ordinal > MAX_REFERENCE_VECTOR_ORDINAL) { + throw new RangeError("fork reference vector ordinal space exhausted"); + } + const canonical = values; + this.referenceVectors.push(canonical); + indexForkReferenceVector(this.referenceVectorIntern, canonical, ordinal); + this.releaseReferenceVectorHandle(handle); + return ordinal; + } + + getReferenceVector(ordinal: number, index: number): number { + this.assertU32(ordinal, "reference vector ordinal"); + this.assertU32(index, "reference vector index"); + let vector: ForkReferenceVector | undefined; + if (this.phase === "parent-replay") { + vector = this.referenceVectors.get(ordinal); + } else { + this.requirePhase("child-replay", "read a reference vector"); + vector = this.decodedReferenceVectors.get(ordinal); + } + if (!vector) { + throw new Error(`fork reference vector ${ordinal} is not available`); + } + const recipeId = vector.get(index); + if (recipeId === undefined) { + throw new Error( + `fork reference vector ${ordinal} index ${index} is out of bounds`, + ); + } + return recipeId; + } + + /** + * Reserve transient bytes in the one process memory copied by fork. + * + * Reservations are stack-disciplined because generated codecs recurse while + * walking reference payloads. A retained page amortizes the common case; + * unusually large or deeply nested payloads allocate extra page-rounded + * chunks and release those chunks as soon as their nested scope returns. + */ + reserveScratch(size: number | bigint): number { + this.requireActivePhase("reserve reference scratch"); + const requestedSize = this.checkedScratchSize(size); + const alignedSize = this.alignScratch(requestedSize); + let chunk = this.scratchChunks[this.scratchChunks.length - 1]; + if (!chunk || alignedSize > chunk.size - chunk.used) { + const allocate = this.allocateScratch; + if (!allocate || !this.deallocateScratch) { + throw new Error(`${this.label} has no scratch mapping owner`); + } + const chunkSize = this.alignScratch(Math.max(65_536, alignedSize), 65_536); + const addr = allocate(chunkSize); + if ( + !Number.isSafeInteger(addr) + || addr <= 0 + || addr % 16 !== 0 + || addr > this.requireMemory().buffer.byteLength - chunkSize + ) { + if (Number.isSafeInteger(addr) && addr > 0) { + try { + this.deallocateScratch(addr, chunkSize); + } catch { + // Preserve the allocator contract violation. + } + } + throw new RangeError( + `${this.label} scratch allocator returned an invalid mapping`, + ); + } + chunk = { addr, size: chunkSize, used: 0 }; + this.scratchChunks.push(chunk); + } + const previousUsed = chunk.used; + const addr = chunk.addr + previousUsed; + chunk.used += alignedSize; + new Uint8Array(this.requireMemory().buffer, addr, alignedSize).fill(0); + this.scratchReservations.push({ + addr, + requestedSize, + alignedSize, + previousUsed, + chunk, + }); + return addr; + } + + releaseScratch(pointer: number | bigint, size: number | bigint): void { + this.requireActivePhase("release reference scratch"); + const addr = this.checkedScratchPointer(pointer); + const requestedSize = this.checkedScratchSize(size); + const reservation = this.scratchReservations.pop(); + if ( + !reservation + || reservation.addr !== addr + || reservation.requestedSize !== requestedSize + ) { + if (reservation) this.scratchReservations.push(reservation); + throw new Error( + `${this.label} scratch release is not the most recent reservation`, + ); + } + new Uint8Array( + this.requireMemory().buffer, + reservation.addr, + reservation.alignedSize, + ).fill(0); + reservation.chunk.used = reservation.previousUsed; + + const tail = this.scratchChunks[this.scratchChunks.length - 1]; + if ( + tail === reservation.chunk + && tail.used === 0 + && this.scratchChunks.length > 1 + ) { + this.scratchChunks.pop(); + this.deallocateScratch!(tail.addr, tail.size); + } + } + + lookupExceptionSlot( + slot: number, + provider: ForkExceptionSlotProvider = this.requireExceptionSlotProvider(), + ): number { + this.requirePhase("capture", "look up an exception identity"); + const value = this.exceptionValue(provider, slot); + const known = this.lookupExceptionId(value); + return known !== undefined && this.nodes.get(known)?.node.kind === "exnref" + ? known + : 0; + } + + claimExceptionSlot( + slot: number, + provider: ForkExceptionSlotProvider = this.requireExceptionSlotProvider(), + ): number { + this.requirePhase("capture", "claim an exception identity"); + const value = this.exceptionValue(provider, slot); + const known = this.lookupExceptionId(value); + if (known !== undefined) { + const existing = this.nodes.get(known)?.node; + if (existing?.kind === "exnref") return known; + // WHY: one value may cross the embedding first as externref/anyref and + // later as exnref. Keep its recipe ID and upgrade the node in place so + // every view retains one graph identity. + this.nodes.set(known, { + id: known, + node: { + kind: "exnref", + moduleActivation: 0, + tagOrdinal: 0, + layoutId: 0, + scalars: new Uint8Array(), + payloads: [], + }, + }); + this.pendingExceptions.add(known); + this.exceptionCacheIndexes.set( + known, + this.exceptionCacheIndexes.size + 1, + ); + return known; + } + const id = this.nodes.length; + if (id > 0xffff_ffff) { + throw new RangeError("fork reference recipe id space exhausted"); + } + this.nodes.push({ + id, + node: { + kind: "exnref", + moduleActivation: 0, + tagOrdinal: 0, + layoutId: 0, + scalars: new Uint8Array(), + payloads: [], + }, + }); + this.capturedValues.push(value); + this.rememberExceptionId(value, id); + this.pendingExceptions.add(id); + this.exceptionCacheIndexes.set(id, this.exceptionCacheIndexes.size + 1); + return id; + } + + /** + * Encode a raw JavaScript exception caught through `WebAssembly.JSTag`. + * + * The exception itself is an exnref recipe; its JS payload is a separate + * externref node so process-wide broker ownership remains visible to the + * wire graph and the child receives its canonical local token. + */ + captureHostException( + value: unknown, + childPayloadValue: unknown = value, + ): number { + this.requirePhase("capture", "capture a host exception"); + const known = this.lookupExceptionId(value); + if (known !== undefined && this.nodes.get(known)?.node.kind === "exnref") { + return known; + } + const recipeId = known ?? this.nodes.length; + if (recipeId >= 0x7fff_fffe) { + throw new RangeError("fork reference recipe id space exhausted"); + } + const payloadId = this.nodes.length + (known === undefined ? 1 : 0); + const existing = known === undefined + ? undefined + : this.nodes.get(known)?.node; + const handle = ( + existing?.kind === "externref" + && Object.is(value, childPayloadValue) + ) + ? existing.handle + : this.externrefs.capture(childPayloadValue); + const exceptionEntry: ForkReferenceRecipeEntry = { + id: recipeId, + node: { + kind: "exnref", + moduleActivation: FORK_HOST_EXCEPTION_ACTIVATION_ID, + tagOrdinal: 0, + layoutId: 0, + scalars: new Uint8Array(), + payloads: [payloadId], + }, + }; + if (known === undefined) { + this.nodes.push(exceptionEntry); + this.capturedValues.push(value); + this.rememberId(value, recipeId); + } else { + this.nodes.set(known, exceptionEntry); + } + this.nodes.push({ + id: payloadId, + node: { + kind: "externref", + handle, + }, + }); + this.capturedValues.push(childPayloadValue); + this.exceptionCacheIndexes.set( + recipeId, + this.exceptionCacheIndexes.size + 1, + ); + return recipeId; + } + + exceptionOwner(recipeId: number): number { + assertRecipeId(recipeId); + const node = this.recipeNode(recipeId); + if (node?.kind !== "exnref") { + throw new Error(`fork recipe ${recipeId} is not an exception`); + } + return node.moduleActivation; + } + + materializeHostException(recipeId: number): unknown { + const owner = this.exceptionOwner(recipeId); + if (owner !== FORK_HOST_EXCEPTION_ACTIVATION_ID) { + throw new Error(`fork exception recipe ${recipeId} is not host-owned`); + } + const node = this.recipeNode(recipeId); + if (node?.kind !== "exnref" || node.payloads.length !== 1) { + throw new Error(`fork host exception recipe ${recipeId} is malformed`); + } + if (this.phase === "parent-replay") { + // WHY: the parent still owns the original JavaScript/Wasm exception and + // must retain its exact tag and identity. Only a fresh child consumes + // the owner-normalized externref payload. + if (recipeId >= this.capturedValues.length) { + throw new Error(`fork host exception recipe ${recipeId} is out of bounds`); + } + return this.capturedValues.get(recipeId); + } + return this.decodeExternref(node.payloads[0]!); + } + + exceptionCacheIndex(recipeId: number): number { + assertRecipeId(recipeId); + const index = this.exceptionCacheIndexes.get(recipeId); + if (index === undefined) { + throw new Error(`fork recipe ${recipeId} has no exception cache index`); + } + return index; + } + + defineException( + recipeId: number, + moduleActivation: number, + tagOrdinal: number, + layoutId: number, + scalarPointer: number | bigint, + scalarByteLength: number, + referenceIdsPointer: number | bigint, + referenceCount: number, + ): void { + this.requirePhase("capture", "define an exception recipe"); + this.assertExceptionCoordinate( + recipeId, + moduleActivation, + tagOrdinal, + layoutId, + ); + if (!this.pendingExceptions.has(recipeId)) { + throw new Error(`fork exception recipe ${recipeId} is not pending definition`); + } + const scalars = this.readBytes( + scalarPointer, + scalarByteLength, + "fork exception scalar payload", + ); + const payloads = this.readRecipeIds( + referenceIdsPointer, + referenceCount, + "fork exception reference payloads", + ); + this.nodes.set(recipeId, { + id: recipeId, + node: { + kind: "exnref", + moduleActivation, + tagOrdinal, + layoutId, + scalars, + payloads, + }, + }); + this.pendingExceptions.delete(recipeId); + } + + routeException(recipeId: number, expectedActivation: number): number { + assertRecipeId(recipeId); + this.assertU32(expectedActivation, "exception route activation"); + const node = this.recipeNode(recipeId); + if (node?.kind !== "exnref" || node.moduleActivation !== expectedActivation) { + return -1; + } + const layoutId = node.layoutId ?? 0; + if (layoutId > 0x7fff_ffff) { + throw new Error(`fork exception recipe ${recipeId} has a non-routable layout id`); + } + return layoutId; + } + + loadException( + recipeId: number, + moduleActivation: number, + tagOrdinal: number, + layoutId: number, + scalarDestination: number | bigint, + scalarByteLength: number, + referenceIdsDestination: number | bigint, + referenceCount: number, + ): number { + this.assertExceptionCoordinate( + recipeId, + moduleActivation, + tagOrdinal, + layoutId, + ); + const node = this.recipeNode(recipeId); + if (node?.kind !== "exnref") { + throw new Error(`fork recipe ${recipeId} is not an exception`); + } + const scalars = node.scalars ?? new Uint8Array(); + if ( + scalars.byteLength !== scalarByteLength + || node.payloads.length !== referenceCount + ) { + throw new Error( + `fork exception recipe ${recipeId} payload layout does not match ` + + `the generated codec`, + ); + } + this.writeBytes( + scalarDestination, + scalars, + "fork exception scalar destination", + ); + this.writeRecipeIds( + referenceIdsDestination, + node.payloads, + "fork exception reference destination", + ); + return 1; + } + + routeGc(recipeId: number, expectedActivation: number): number { + assertRecipeId(recipeId); + this.assertU32(expectedActivation, "GC route activation"); + const node = this.recipeNode(recipeId); + if (node?.kind === "i31") return 0; + if ( + (node?.kind !== "struct" && node?.kind !== "array") + || node.moduleActivation !== expectedActivation + ) { + return -1; + } + const layoutId = node.layoutId ?? 0; + this.assertU31(layoutId, `fork Wasm-GC recipe ${recipeId} layout`, false); + return layoutId; + } + + gcPayloadLength( + recipeId: number, + expectedActivation: number, + expectedLayoutId: number, + ): number { + assertRecipeId(recipeId); + this.assertU32(expectedActivation, "GC payload activation"); + this.assertU31(expectedLayoutId, "GC payload layout"); + const node = this.recipeNode(recipeId); + if (node?.kind === "i31") { + if (expectedLayoutId !== 0) { + throw new Error(`fork i31 recipe ${recipeId} has a nonzero layout`); + } + return 4; + } + if ( + (node?.kind !== "struct" && node?.kind !== "array") + || node.moduleActivation !== expectedActivation + || (node.layoutId ?? 0) !== expectedLayoutId + ) { + throw new Error( + `fork Wasm-GC recipe ${recipeId} does not match payload route ` + + `${expectedActivation}:${expectedLayoutId}`, + ); + } + return (node.scalars ?? new Uint8Array()).byteLength; + } + + loadGc( + recipeId: number, + moduleActivation: number, + typeOrdinal: number, + layoutId: number, + kind: number, + scalarDestination: number | bigint, + scalarByteLength: number, + ): number { + this.requirePhase("child-replay", "load a Wasm-GC recipe"); + assertRecipeId(recipeId); + this.assertU32(moduleActivation, "GC load activation"); + this.assertU32(typeOrdinal, "GC load type ordinal"); + this.assertU31(layoutId, "GC load layout id"); + this.assertU32(kind, "GC load kind"); + const node = this.recipeNode(recipeId); + if (node?.kind === "i31") { + if ( + layoutId !== 0 + || typeOrdinal !== 0xffff_ffff + || kind !== 0 + || scalarByteLength !== 4 + ) { + throw new Error(`fork i31 recipe ${recipeId} has an invalid load coordinate`); + } + const bytes = new Uint8Array(4); + new DataView(bytes.buffer).setInt32(0, node.value, true); + this.writeBytes( + scalarDestination, + bytes, + "fork i31 scalar destination", + ); + return 0; + } + if (node?.kind !== "struct" && node?.kind !== "array") { + throw new Error(`fork recipe ${recipeId} is not a Wasm-GC aggregate`); + } + const nodeKind = node.kind === "struct" ? 1 : 2; + const scalars = node.scalars ?? new Uint8Array(); + if ( + node.moduleActivation !== moduleActivation + || node.typeOrdinal !== typeOrdinal + || (node.layoutId ?? 0) !== layoutId + || nodeKind !== kind + || scalars.byteLength !== scalarByteLength + ) { + throw new Error( + `fork Wasm-GC recipe ${recipeId} payload does not match ` + + `the generated codec`, + ); + } + this.writeBytes( + scalarDestination, + scalars, + "fork Wasm-GC scalar destination", + ); + const edges = node.kind === "struct" ? node.fields : node.elements; + if (edges.length === 0) return 0; + const known = this.replayGcVectors.get(recipeId); + if (known !== undefined) return known; + const existing = findForkReferenceVectorOrdinal( + [ + this.childTransaction!.vectorIntern, + this.decodedReferenceVectorIntern, + ], + this.decodedReferenceVectors, + forkReferenceVectorFrom(edges, edges.length), + ); + if (existing !== undefined) { + this.replayGcVectors.set(recipeId, existing); + return existing; + } + const ordinal = this.decodedReferenceVectors.length; + if (ordinal > MAX_REFERENCE_VECTOR_ORDINAL) { + throw new RangeError("fork reference vector ordinal space exhausted"); + } + const canonical = forkReferenceVectorFrom(edges, edges.length); + this.decodedReferenceVectors.push(canonical); + indexForkReferenceVector( + this.decodedReferenceVectorIntern, + canonical, + ordinal, + ); + this.replayGcVectors.set(recipeId, ordinal); + return ordinal; + } + + /** + * Eager fresh-child barrier for all Wasm-only reference identities. + * + * The graph is validated in full before allocation. Defaultable shells are + * then allocated globally, remaining constructors follow their exact + * dependency edges, exceptions are cached in their owning activation, and + * mutable fields are filled only after every identity exists. + */ + materializeAllTyped(): void { + this.requirePhase("child-replay", "materialize typed references"); + if (this.typedMaterialized) { + throw new Error("typed fork references were materialized twice"); + } + const owner = this.typedReplay; + if (!owner) { + if ( + this.decodedNodes.some(({ node }) => + node.kind === "i31" + || node.kind === "struct" + || node.kind === "array" + || node.kind === "exnref" + ) + ) { + throw new Error(`${this.label} has no typed-reference replay owner`); + } + this.typedMaterialized = true; + return; + } + const providers = owner.providers(); + const layouts = new Map(); + for (const entry of this.decodedNodes) { + switch (entry.node.kind) { + case "struct": + case "array": { + const provider = owner.provider(entry.node.moduleActivation); + layouts.set( + entry.id, + this.validateGcRecipeNode( + entry.node, + provider.descriptor, + `fork Wasm-GC recipe ${entry.id}`, + ), + ); + break; + } + case "exnref": + owner.validateExceptionOwner(entry.node.moduleActivation); + break; + case "i31": + if (providers.length === 0) { + throw new Error("fork i31 replay has no generated GC codec"); + } + break; + case "null": + case "funcref": + case "externref": + case "static-root": + break; + } + } + + owner.prepareTransit(Math.max(0, this.decodedNodes.length - 1)); + for (const entry of this.decodedNodes) { + if (entry.node.kind !== "static-root") continue; + if (!this.materializedValues.has(entry.id)) { + throw new Error( + `fork static-root recipe ${entry.id} was not pinned during child attach`, + ); + } + // WHY: generated GC constructors and field fills decode reference edges + // from recipe+1 in the shared anyref table. Instantiation recreated this + // identity instead of a codec, so publish the pinned child root before + // any dynamic object can consume it. + owner.publishTransit(entry.id, this.materializedValues.get(entry.id)); + } + for (const entry of this.decodedNodes) { + if (entry.node.kind !== "externref") continue; + // Externref leaves must exist before immutable GC constructors or + // exception payload decoders consume their recipe edges. Materializing + // only the JavaScript token is insufficient: the shared transit table + // stores anyref, so a generated Wasm helper performs the conversion. + owner.publishExternref(entry.id, this.decodeExternref(entry.id)); + } + const allocated = new Set(this.adoptedAllocatedTypedRecipes); + const completedExceptions = new Set( + this.adoptedMaterializedExceptionRecipes, + ); + for (const entry of this.decodedNodes) { + if (entry.node.kind === "i31") { + if (allocated.has(entry.id)) continue; + providers[0]!.allocate(entry.id); + allocated.add(entry.id); + continue; + } + if ( + (entry.node.kind === "struct" || entry.node.kind === "array") + && ( + layouts.get(entry.id)!.flags + & FORK_GC_LAYOUT_DEFAULTABLE_SHELL + ) !== 0 + ) { + if (allocated.has(entry.id)) continue; + owner.provider(entry.node.moduleActivation).allocate(entry.id); + allocated.add(entry.id); + } + } + + type PendingTypedRecipe = { + readonly recipeId: number; + readonly node: Extract< + ForkReferenceRecipeNode, + { kind: "exnref" | "struct" | "array" } + >; + readonly dependencies: readonly number[]; + nextDependency: number; + }; + // A sparse set tracks only the active constructor path. A graph-sized + // Uint8Array would reintroduce one contiguous allocation cliff. + const visiting = new Set(); + const beginTypedRecipe = ( + recipeId: number, + ): PendingTypedRecipe | null => { + if (allocated.has(recipeId) || completedExceptions.has(recipeId)) { + return null; + } + const node = this.decodedNodes.get(recipeId)?.node; + if (!node) { + throw new Error(`typed replay dependency ${recipeId} is missing`); + } + if ( + node.kind === "null" + || node.kind === "funcref" + || node.kind === "externref" + || node.kind === "static-root" + || node.kind === "i31" + ) { + return null; + } + if (visiting.has(recipeId)) { + throw new Error( + `typed replay has an unallocatable constructor cycle at recipe ` + + `${recipeId}`, + ); + } + visiting.add(recipeId); + return { + recipeId, + node, + dependencies: node.kind === "exnref" + ? node.payloads + : this.gcAllocationDependencies(node, layouts.get(recipeId)!), + nextDependency: 0, + }; + }; + const materialize = (recipeId: number): void => { + const first = beginTypedRecipe(recipeId); + if (!first) return; + const pending: PendingTypedRecipe[] = [first]; + while (pending.length !== 0) { + const current = pending[pending.length - 1]!; + let descended = false; + while (current.nextDependency < current.dependencies.length) { + const dependency = + current.dependencies[current.nextDependency++]!; + const child = beginTypedRecipe(dependency); + if (!child) continue; + pending.push(child); + descended = true; + break; + } + if (descended) continue; + + if (current.node.kind === "exnref") { + owner.materializeException( + current.recipeId, + current.node.moduleActivation, + ); + completedExceptions.add(current.recipeId); + } else { + owner.provider(current.node.moduleActivation).allocate( + current.recipeId, + ); + allocated.add(current.recipeId); + } + visiting.delete(current.recipeId); + pending.pop(); + } + }; + this.decodedNodes.forEach(({ id }) => materialize(id)); + + for (const entry of this.decodedNodes) { + if (entry.node.kind !== "struct" && entry.node.kind !== "array") { + continue; + } + if (this.adoptedFilledTypedRecipes.has(entry.id)) continue; + owner.provider(entry.node.moduleActivation).fill(entry.id); + } + this.typedMaterialized = true; + } + + /** + * Drop every temporary strong root after the outermost frame has restored. + * Module globals/tables/locals now own live reconstructed values; this + * transaction must not extend their lifetime across a later fork. + */ + finishReplay(): void { + if (this.phase !== "parent-replay" && this.phase !== "child-replay") { + throw new Error(`cannot finish reference replay while transaction is ${this.phase}`); + } + this.clear(); + } + + abort(): void { + if (this.phase === "idle") return; + this.clear(); + } + + private decode(recipeId: number, expected: "funcref" | "externref"): unknown { + assertRecipeId(recipeId); + if (recipeId === 0) return null; + if (this.phase === "parent-replay") { + if (recipeId >= this.capturedValues.length) { + throw new Error(`fork ${expected} recipe ${recipeId} is out of bounds`); + } + return this.capturedValues.get(recipeId); + } + this.requirePhase("child-replay", `decode a ${expected}`); + const index = recipeId; + const node = this.decodedNodes.get(index)?.node; + if (!node) { + throw new Error(`fork ${expected} recipe ${recipeId} is out of bounds`); + } + if (this.materializedValues.has(index)) { + return this.materializedValues.get(index); + } + const value = this.materializeNode(node, recipeId); + this.materializedValues.set(index, value); + return value; + } + + private recipeNode(recipeId: number): ForkReferenceRecipeNode | undefined { + if (this.phase === "capture" || this.phase === "sealed-parent" || this.phase === "parent-replay") { + return this.nodes.get(recipeId)?.node; + } + if (this.phase === "child-replay") { + return this.decodedNodes.get(recipeId)?.node; + } + throw new Error("fork reference transaction has no active recipe graph"); + } + + private requireExceptionSlotProvider(): ForkExceptionSlotProvider { + if (!this.exceptionSlots) { + throw new Error("fork exception scratch provider is not registered"); + } + return this.exceptionSlots; + } + + private exceptionValue(provider: ForkExceptionSlotProvider, slot: number): unknown { + this.assertU32(slot, "exception scratch slot"); + try { + provider.throwSlot(slot); + } catch (value) { + return value; + } + throw new Error(`fork exception scratch slot ${slot} returned without throwing`); + } + + private assertExceptionCoordinate( + recipeId: number, + moduleActivation: number, + tagOrdinal: number, + layoutId: number, + ): void { + assertRecipeId(recipeId); + if (recipeId === 0) { + throw new Error("the null recipe cannot be defined or loaded as an exception"); + } + this.assertU32(moduleActivation, "exception module activation"); + this.assertU32(tagOrdinal, "exception tag ordinal"); + this.assertU32(layoutId, "exception layout id"); + if (layoutId > 0x7fff_ffff) { + throw new RangeError(`exception layout id ${layoutId} is not routable`); + } + const node = this.recipeNode(recipeId); + if (!node) { + throw new Error(`fork exception recipe ${recipeId} is out of bounds`); + } + if ( + !this.pendingExceptions.has(recipeId) + && ( + node.kind !== "exnref" + || node.moduleActivation !== moduleActivation + || node.tagOrdinal !== tagOrdinal + || (node.layoutId ?? 0) !== layoutId + ) + ) { + throw new Error( + `fork exception recipe ${recipeId} coordinate does not match ` + + `${moduleActivation}:${tagOrdinal}:${layoutId}`, + ); + } + } + + private gcSlotValue(table: WebAssembly.Table, slot: number): unknown { + this.assertU32(slot, "Wasm-GC transit slot"); + if (slot >= table.length) { + throw new RangeError(`Wasm-GC transit slot ${slot} is out of bounds`); + } + const value = table.get(slot); + if ( + (typeof value !== "object" || value === null) + && typeof value !== "function" + ) { + throw new TypeError("Wasm-GC transit slot is not a non-null reference"); + } + return value; + } + + private validateGcSnapshot( + layout: ForkGcLayoutDescriptor, + scalars: Uint8Array, + references: readonly number[] | ForkReferenceVector, + context: string, + ): void { + references.forEach((id, index) => { + assertRecipeId(id); + if (id >= this.nodes.length && id >= this.decodedNodes.length) { + throw new Error(`${context} reference ${index} names missing recipe ${id}`); + } + }); + const referenceFieldCount = layout.fields.filter( + ({ flags }) => (flags & FORK_GC_FIELD_REFERENCE) !== 0, + ).length; + if (layout.kind === 1) { + if ( + scalars.byteLength !== layout.scalarLengthOrStride + || references.length !== referenceFieldCount + ) { + throw new Error(`${context} does not match struct layout ${layout.id}`); + } + return; + } + if (scalars.byteLength < 4) { + throw new Error(`${context} array length is truncated`); + } + const length = new DataView( + scalars.buffer, + scalars.byteOffset, + scalars.byteLength, + ).getUint32(0, true); + const referenceElements = + (layout.fields[0]!.flags & FORK_GC_FIELD_REFERENCE) !== 0; + const expectedScalarLength = referenceElements + ? 4 + : 4 + length * layout.scalarLengthOrStride; + if ( + !Number.isSafeInteger(expectedScalarLength) + || expectedScalarLength > 0xffff_ffff + || scalars.byteLength !== expectedScalarLength + || references.length !== (referenceElements ? length : 0) + || ( + layout.constructor === ForkGcConstructorKind.ArrayFixed + && layout.auxiliary !== length + ) + ) { + throw new Error(`${context} does not match array layout ${layout.id}`); + } + } + + private validateGcRecipeNode( + node: Extract, + descriptor: ForkGcCodecDescriptor, + context: string, + ): ForkGcLayoutDescriptor { + const layout = descriptor.require(node.layoutId ?? 0); + if ( + layout.typeOrdinal !== node.typeOrdinal + || (node.kind === "struct" ? 1 : 2) !== layout.kind + ) { + throw new Error(`${context} has an invalid type/layout coordinate`); + } + const scalars = node.scalars ?? new Uint8Array(); + const references = node.kind === "struct" ? node.fields : node.elements; + if ( + scalars.byteLength < layout.provenanceScalarLength + || references.length < layout.provenanceReferenceCount + ) { + throw new Error(`${context} has truncated constructor provenance`); + } + this.validateGcSnapshot( + layout, + scalars.subarray(layout.provenanceScalarLength), + references.slice(layout.provenanceReferenceCount), + context, + ); + return layout; + } + + private gcAllocationDependencies( + node: Extract, + layout: ForkGcLayoutDescriptor, + ): readonly number[] { + const edges = node.kind === "struct" ? node.fields : node.elements; + const dependencies = edges.slice(0, layout.provenanceReferenceCount); + const snapshotStart = layout.provenanceReferenceCount; + if (node.kind === "struct") { + for (const field of layout.fields) { + if ( + (field.flags & FORK_GC_FIELD_ALLOCATION_DEPENDENCY) === 0 + || field.referenceOrdinal === null + ) { + continue; + } + dependencies.push(edges[snapshotStart + field.referenceOrdinal]!); + } + return dependencies; + } + if ((layout.fields[0]!.flags & FORK_GC_FIELD_REFERENCE) === 0) { + return dependencies; + } + const snapshot = edges.slice(snapshotStart); + if (layout.constructor === ForkGcConstructorKind.ArrayFixed) { + // Mutable non-null internal arrays use constructor provenance as their + // seed; immutable arrays use their final (and therefore original) + // elements directly. + if (layout.provenanceReferenceCount === 0) { + dependencies.push(...snapshot); + } + } else if ( + layout.constructor === ForkGcConstructorKind.ArrayNew + && layout.provenanceReferenceCount === 0 + && snapshot.length !== 0 + ) { + dependencies.push(snapshot[0]!); + } + return dependencies; + } + + private readBytes( + pointer: number | bigint, + byteLength: number, + context: string, + ): Uint8Array { + const { offset, length } = this.memoryRange(pointer, byteLength, context); + return new Uint8Array( + new Uint8Array(this.requireMemory().buffer, offset, length), + ); + } + + private writeBytes( + pointer: number | bigint, + bytes: Uint8Array, + context: string, + ): void { + const { offset } = this.memoryRange(pointer, bytes.byteLength, context); + new Uint8Array(this.requireMemory().buffer, offset, bytes.byteLength).set(bytes); + } + + private readRecipeIds( + pointer: number | bigint, + count: number, + context: string, + ): number[] { + this.assertU32(count, `${context} count`); + const bytes = this.readBytes(pointer, count * 4, context); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const ids: number[] = []; + for (let index = 0; index < count; index++) { + const id = view.getUint32(index * 4, true); + assertRecipeId(id); + if (id >= this.nodes.length) { + throw new Error(`${context} entry ${index} names missing recipe ${id}`); + } + ids.push(id); + } + return ids; + } + + private writeRecipeIds( + pointer: number | bigint, + ids: readonly number[], + context: string, + ): void { + const { offset } = this.memoryRange(pointer, ids.length * 4, context); + const view = new DataView(this.requireMemory().buffer); + ids.forEach((id, index) => { + assertRecipeId(id); + view.setUint32(offset + index * 4, id, true); + }); + } + + private memoryRange( + pointer: number | bigint, + byteLength: number, + context: string, + ): { offset: number; length: number } { + this.assertU32(byteLength, `${context} byte length`); + const offset = typeof pointer === "bigint" ? Number(pointer) : pointer; + if ( + !Number.isSafeInteger(offset) + || offset < 0 + || (typeof pointer === "bigint" && BigInt(offset) !== pointer) + ) { + throw new RangeError(`${context} has an invalid guest pointer`); + } + const memoryLength = this.requireMemory().buffer.byteLength; + if (offset > memoryLength || byteLength > memoryLength - offset) { + throw new RangeError(`${context} exceeds WebAssembly memory`); + } + return { offset, length: byteLength }; + } + + private requireMemory(): WebAssembly.Memory { + if (!this.memory) { + throw new Error("fork reference transaction has no staging memory"); + } + return this.memory; + } + + private assertU32(value: number, context: string): void { + if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff) { + throw new RangeError(`${context} is not a u32`); + } + } + + private assertU31( + value: number, + context: string, + allowZero = true, + ): void { + if ( + !Number.isInteger(value) + || value < (allowZero ? 0 : 1) + || value > 0x7fff_ffff + ) { + throw new RangeError(`${context} is not ${allowZero ? "a" : "a nonzero"} u31`); + } + } + + private requireActivePhase(operation: string): void { + if ( + this.phase !== "capture" + && this.phase !== "parent-replay" + && this.phase !== "child-replay" + ) { + throw new Error(`cannot ${operation} while reference transaction is ${this.phase}`); + } + } + + private checkedScratchPointer(value: number | bigint): number { + const result = typeof value === "bigint" ? Number(value) : value; + if ( + !Number.isSafeInteger(result) + || result <= 0 + || (typeof value === "bigint" && BigInt(result) !== value) + ) { + throw new RangeError(`${this.label} scratch pointer is invalid`); + } + return result; + } + + private checkedScratchSize(value: number | bigint): number { + const result = typeof value === "bigint" ? Number(value) : value; + if ( + !Number.isSafeInteger(result) + || result <= 0 + || result > 0xffff_ffff + || (typeof value === "bigint" && BigInt(result) !== value) + ) { + throw new RangeError(`${this.label} scratch size is not a nonzero u32`); + } + return result; + } + + private alignScratch(value: number, alignment = 16): number { + const result = Math.ceil(value / alignment) * alignment; + if (!Number.isSafeInteger(result) || result < value) { + throw new RangeError(`${this.label} scratch alignment overflow`); + } + return result; + } + + private materializeNode( + node: ForkReferenceRecipeNode, + recipeId: number, + ): unknown { + switch (node.kind) { + case "funcref": + return this.functions.decode({ + moduleActivation: node.moduleActivation, + ordinal: node.functionOrdinal, + }); + case "externref": + return this.externrefs.materialize(node.handle); + case "static-root": + if (!this.staticRoots) { + throw new Error( + `fork recipe ${recipeId} requires a static-root catalog`, + ); + } + return this.staticRoots.decode({ + moduleActivation: node.moduleActivation, + ordinal: node.staticRootOrdinal, + }); + case "null": + return null; + case "exnref": + case "i31": + case "struct": + case "array": + // Those nodes are materialized by generated Wasm codecs. Reaching one + // through an abstract JS-compatible import is an ABI/provider mismatch, + // not a value-shape policy decision. + throw new Error( + `fork recipe ${recipeId} requires its generated ${node.kind} codec`, + ); + } + } + + private intern( + value: unknown, + createNode: () => ForkReferenceRecipeNode, + ): number { + const known = this.lookupId(value); + if (known !== undefined) return known; + const id = this.nodes.length; + if (id > 0xffff_ffff) { + throw new RangeError("fork reference recipe id space exhausted"); + } + const recipeId = id; + const staticRoot = this.staticRoots?.encode(value); + const node: ForkReferenceRecipeNode = staticRoot + ? { + kind: "static-root", + moduleActivation: staticRoot.moduleActivation, + staticRootOrdinal: staticRoot.ordinal, + } + : createNode(); + this.nodes.push({ id, node }); + this.capturedValues.push(value); + this.rememberId(value, recipeId); + return recipeId; + } + + private lookupId(value: unknown): number | undefined { + return (typeof value === "object" && value !== null) || typeof value === "function" + ? this.objectIds.get(value as object) + : this.primitiveIds.get(value); + } + + private rememberId(value: unknown, recipeId: number): void { + if ((typeof value === "object" && value !== null) || typeof value === "function") { + this.objectIds.set(value as object, recipeId); + } else { + this.primitiveIds.set(value, recipeId); + } + } + + private lookupExceptionId(value: unknown): number | undefined { + return this.lookupId(value); + } + + private rememberExceptionId(value: unknown, recipeId: number): void { + this.rememberId(value, recipeId); + } + + private releaseReferenceVectorHandle(handle: number): void { + if (!this.pendingReferenceVectors.delete(handle)) { + throw new Error(`fork reference vector builder ${handle} is not allocated`); + } + // Builder handles are transaction-local and never enter sealed bytes. + // Reuse keeps deeply repetitive capture bounded by simultaneous nesting + // instead of total activation count. + this.freeReferenceVectorHandles.push(handle); + } + + private requirePhase(expected: TransactionPhase, operation: string): void { + if (this.phase !== expected) { + throw new Error( + `cannot ${operation} while reference transaction is ${this.phase}; expected ${expected}`, + ); + } + } + + private clear(): void { + this.exceptionSlots?.clearSlots(); + // An exception or host callback may have aborted between reserve/release. + // Zero every transaction-owned byte before returning its mappings. + for (const chunk of this.scratchChunks) { + new Uint8Array(this.requireMemory().buffer, chunk.addr, chunk.size).fill(0); + } + this.scratchReservations.length = 0; + const chunks = this.scratchChunks.splice(0).reverse(); + let firstScratchError: unknown; + for (const chunk of chunks) { + try { + this.deallocateScratch?.(chunk.addr, chunk.size); + } catch (error) { + firstScratchError ??= error; + } + } + this.pendingExceptions.clear(); + this.pendingGc.clear(); + this.i31Ids.clear(); + this.exceptionCacheIndexes.clear(); + this.nodes.clear(); + this.capturedValues.clear(); + this.objectIds = new WeakMap(); + this.primitiveIds.clear(); + this.decodedNodes = new PagedForkReferenceDirectory(); + this.referenceVectors.clear(); + this.pendingReferenceVectors.clear(); + this.freeReferenceVectorHandles.length = 0; + this.nextReferenceVectorHandle = 1; + this.referenceVectorIntern.clear(); + this.decodedReferenceVectors.clear(); + this.decodedReferenceVectorIntern.clear(); + this.replayGcVectors.clear(); + this.typedMaterialized = false; + this.childTransaction = null; + this.childReplayAdopted = false; + this.adoptedAllocatedTypedRecipes.clear(); + this.adoptedFilledTypedRecipes.clear(); + this.adoptedMaterializedExceptionRecipes.clear(); + this.materializedValues.clear(); + this.phase = "idle"; + if (firstScratchError !== undefined) throw firstScratchError; + } +} diff --git a/host/src/fork-replay-events.ts b/host/src/fork-replay-events.ts new file mode 100644 index 0000000000..571f021cca --- /dev/null +++ b/host/src/fork-replay-events.ts @@ -0,0 +1,738 @@ +import { + WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_CAPACITY, + WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_HEADER_SIZE, + WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_KNOWN_FLAGS, + WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_VERSION, + WPK_FORK_MODULE_STATE_REPLAY_EVENTS_HEADER_SIZE, + WPK_FORK_MODULE_STATE_REPLAY_EVENTS_KNOWN_FLAGS, + WPK_FORK_MODULE_STATE_REPLAY_EVENTS_MAGIC, + WPK_FORK_MODULE_STATE_REPLAY_EVENTS_VERSION, + WPK_FORK_MODULE_STATE_REPLAY_EVENT_SIZE, +} from "./generated/abi"; + +function littleEndianMagic(bytes: readonly number[]): number { + return bytes.reduce( + (magic, byte, index) => magic | (byte << (index * 8)), + 0, + ) >>> 0; +} + +const REPLAY_EVENT_MAGIC = littleEndianMagic( + WPK_FORK_MODULE_STATE_REPLAY_EVENTS_MAGIC, +); +export const FORK_REPLAY_EVENT_VERSION = + WPK_FORK_MODULE_STATE_REPLAY_EVENTS_VERSION; +export const FORK_REPLAY_EVENT_HEADER_SIZE = + WPK_FORK_MODULE_STATE_REPLAY_EVENTS_HEADER_SIZE; +export const FORK_REPLAY_EVENT_SEGMENT_VERSION = + WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_VERSION; +export const FORK_REPLAY_EVENT_SEGMENT_HEADER_SIZE = + WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_HEADER_SIZE; +export const FORK_REPLAY_EVENT_ENTRY_SIZE = + WPK_FORK_MODULE_STATE_REPLAY_EVENT_SIZE; +/** + * Allocation geometry, not a continuation-depth limit. + * + * A roughly 32-KiB event page fits twice in the arena's normal 64-KiB chunks, + * including two record envelopes and the larger wasm64 chunk header. + */ +export const FORK_REPLAY_EVENT_SEGMENT_CAPACITY = + WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_CAPACITY; +const REPLAY_EVENT_KNOWN_FLAGS = + WPK_FORK_MODULE_STATE_REPLAY_EVENTS_KNOWN_FLAGS; +const REPLAY_EVENT_SEGMENT_KNOWN_FLAGS = + WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_KNOWN_FLAGS; +const MAX_U32 = 0xffff_ffff; +const MAX_U64 = 0xffff_ffff_ffff_ffffn; + +export interface ForkReplayEvent { + readonly activationId: number; + readonly functionOrdinal: number; +} + +export interface ForkResumeTarget { + readonly functionOrdinal: number; + /** No-parameter Wasm thunk that restores params from the unconsumed frame. */ + readonly thunk: CallableFunction; +} + +type JournalPhase = "idle" | "capture" | "sealed-parent" | "replay"; + +function assertU32(value: number, context: string): void { + if (!Number.isInteger(value) || value < 0 || value > MAX_U32) { + throw new RangeError(`${context} is not a u32: ${value}`); + } +} + +function exactU64(value: bigint | number, context: string): bigint { + if ( + typeof value === "number" + && (!Number.isSafeInteger(value) || value < 0) + ) { + throw new RangeError(`${context} is not an exact nonnegative integer`); + } + const exact = typeof value === "bigint" ? value : BigInt(value); + if (exact < 0n || exact > MAX_U64) { + throw new RangeError(`${context} is not representable as u64`); + } + return exact; +} + +function eventKey(activationId: number, functionOrdinal: number): string { + return `${activationId}:${functionOrdinal}`; +} + +interface CapturedEventPage { + readonly words: Uint32Array; + count: number; + previous: ReplayEventPage | null; + next: ReplayEventPage | null; +} + +interface ChildEventPage { + readonly payload: Uint8Array; + readonly view: DataView; + readonly count: number; + previous: ReplayEventPage | null; + next: ReplayEventPage | null; +} + +type ReplayEventPage = CapturedEventPage | ChildEventPage; + +export interface ForkReplayEventWire { + readonly manifest: Uint8Array; + /** Restartable ordered source; callers may validate before attaching. */ + readonly segments: Iterable; +} + +export interface ForkReplayEventCaptureSource { + capturedSegmentPayloads(): Iterable; + capturedManifestPayload(): Uint8Array; +} + +export interface ForkReplayEventWireSummary { + readonly eventCount: bigint; + readonly segmentCount: bigint; + readonly activationIds: ReadonlySet; +} + +function segmentCountForEvents(eventCount: bigint): bigint { + if (eventCount === 0n) return 0n; + return ( + eventCount + BigInt(FORK_REPLAY_EVENT_SEGMENT_CAPACITY) - 1n + ) / BigInt(FORK_REPLAY_EVENT_SEGMENT_CAPACITY); +} + +export function encodeForkReplayEventManifest( + eventCount: bigint, + segmentCount: bigint | number, +): Uint8Array { + const exactEventCount = exactU64(eventCount, "fork replay event count"); + const exactSegmentCount = exactU64( + segmentCount, + "fork replay event segment count", + ); + if (segmentCountForEvents(exactEventCount) !== exactSegmentCount) { + throw new RangeError( + "fork replay event segment count is inconsistent with event count", + ); + } + const bytes = new Uint8Array(FORK_REPLAY_EVENT_HEADER_SIZE); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + view.setUint32(0, REPLAY_EVENT_MAGIC, true); + view.setUint16(4, FORK_REPLAY_EVENT_VERSION, true); + view.setUint16(6, FORK_REPLAY_EVENT_HEADER_SIZE, true); + view.setUint16(8, FORK_REPLAY_EVENT_ENTRY_SIZE, true); + view.setUint16(10, FORK_REPLAY_EVENT_SEGMENT_HEADER_SIZE, true); + view.setUint32(12, FORK_REPLAY_EVENT_SEGMENT_CAPACITY, true); + view.setUint16(16, REPLAY_EVENT_KNOWN_FLAGS, true); + view.setUint16(18, 0, true); + view.setUint32(20, 0, true); + view.setBigUint64(24, exactSegmentCount, true); + view.setBigUint64(32, exactEventCount, true); + return bytes; +} + +export function encodeForkReplayEventSegment( + words: Uint32Array, + count: number, + sequence: bigint | number, +): Uint8Array { + const exactSequence = exactU64( + sequence, + "fork replay event segment sequence", + ); + if ( + !Number.isInteger(count) + || count <= 0 + || count > FORK_REPLAY_EVENT_SEGMENT_CAPACITY + || words.length < count * 2 + ) { + throw new RangeError(`invalid fork replay event segment count ${count}`); + } + const bytes = new Uint8Array( + FORK_REPLAY_EVENT_SEGMENT_HEADER_SIZE + + count * FORK_REPLAY_EVENT_ENTRY_SIZE, + ); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + view.setUint16(0, FORK_REPLAY_EVENT_SEGMENT_VERSION, true); + view.setUint16(2, FORK_REPLAY_EVENT_SEGMENT_HEADER_SIZE, true); + view.setUint16(4, FORK_REPLAY_EVENT_ENTRY_SIZE, true); + view.setUint16(6, REPLAY_EVENT_SEGMENT_KNOWN_FLAGS, true); + view.setBigUint64(8, exactSequence, true); + view.setUint32(16, count, true); + view.setUint32(20, 0, true); + for (let index = 0; index < count; index++) { + const offset = + FORK_REPLAY_EVENT_SEGMENT_HEADER_SIZE + + index * FORK_REPLAY_EVENT_ENTRY_SIZE; + view.setUint32(offset, words[index * 2]!, true); + view.setUint32(offset + 4, words[index * 2 + 1]!, true); + } + return bytes; +} + +interface DecodedReplayEventManifest { + readonly eventCount: bigint; + readonly segmentCount: bigint; +} + +function decodeForkReplayEventManifest( + bytes: Uint8Array, +): DecodedReplayEventManifest { + if ( + !(bytes instanceof Uint8Array) + || bytes.byteLength < FORK_REPLAY_EVENT_HEADER_SIZE + ) { + throw new Error("fork replay event manifest is truncated"); + } + if (bytes.byteLength !== FORK_REPLAY_EVENT_HEADER_SIZE) { + throw new Error("fork replay event manifest has inconsistent bounds"); + } + const view = new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ); + if (view.getUint32(0, true) !== REPLAY_EVENT_MAGIC) { + throw new Error("fork replay event manifest has invalid magic"); + } + if (view.getUint16(4, true) !== FORK_REPLAY_EVENT_VERSION) { + throw new Error( + `fork replay event manifest has version ${view.getUint16(4, true)}`, + ); + } + if (view.getUint16(6, true) !== FORK_REPLAY_EVENT_HEADER_SIZE) { + throw new Error("fork replay event manifest has an invalid header size"); + } + if ( + view.getUint16(8, true) !== FORK_REPLAY_EVENT_ENTRY_SIZE + || view.getUint16(10, true) !== FORK_REPLAY_EVENT_SEGMENT_HEADER_SIZE + ) { + throw new Error("fork replay event manifest has an invalid entry size"); + } + if (view.getUint32(12, true) !== FORK_REPLAY_EVENT_SEGMENT_CAPACITY) { + throw new Error("fork replay event manifest has an invalid segment capacity"); + } + if ( + (view.getUint16(16, true) & ~REPLAY_EVENT_KNOWN_FLAGS) !== 0 + || view.getUint16(18, true) !== 0 + || view.getUint32(20, true) !== 0 + ) { + throw new Error("fork replay event manifest has nonzero reserved fields"); + } + const segmentCount = view.getBigUint64(24, true); + const eventCount = view.getBigUint64(32, true); + if (segmentCountForEvents(eventCount) !== segmentCount) { + throw new Error( + "fork replay event manifest segment count is inconsistent with event count", + ); + } + return { eventCount, segmentCount }; +} + +function decodeForkReplayEventSegment( + payload: Uint8Array, + sequence: bigint, + expectedCount: number, +): ChildEventPage { + if ( + !(payload instanceof Uint8Array) + || payload.byteLength < FORK_REPLAY_EVENT_SEGMENT_HEADER_SIZE + ) { + throw new Error(`fork replay event segment ${sequence} is truncated`); + } + const view = new DataView( + payload.buffer, + payload.byteOffset, + payload.byteLength, + ); + if ( + view.getUint16(0, true) !== FORK_REPLAY_EVENT_SEGMENT_VERSION + || view.getUint16(2, true) !== FORK_REPLAY_EVENT_SEGMENT_HEADER_SIZE + || view.getUint16(4, true) !== FORK_REPLAY_EVENT_ENTRY_SIZE + ) { + throw new Error( + `fork replay event segment ${sequence} has an invalid version or layout`, + ); + } + if ( + (view.getUint16(6, true) & ~REPLAY_EVENT_SEGMENT_KNOWN_FLAGS) !== 0 + ) { + throw new Error(`fork replay event segment ${sequence} has unknown flags`); + } + if (view.getBigUint64(8, true) !== sequence) { + throw new Error( + `fork replay event segment sequence ${view.getBigUint64(8, true)} ` + + `is out of order; expected ${sequence}`, + ); + } + const count = view.getUint32(16, true); + if (view.getUint32(20, true) !== 0) { + throw new Error(`fork replay event segment ${sequence} has reserved data`); + } + if (count !== expectedCount) { + throw new Error( + `fork replay event segment ${sequence} has ${count} entries; ` + + `expected ${expectedCount}`, + ); + } + const expectedSize = + FORK_REPLAY_EVENT_SEGMENT_HEADER_SIZE + + count * FORK_REPLAY_EVENT_ENTRY_SIZE; + if (payload.byteLength !== expectedSize) { + throw new Error( + `fork replay event segment ${sequence} has inconsistent bounds`, + ); + } + return { + payload, + view, + count, + previous: null, + next: null, + }; +} + +function inspectForkReplayEventWire( + wire: ForkReplayEventWire, + retainPages: boolean, +): { + readonly summary: ForkReplayEventWireSummary; + readonly firstPage: ChildEventPage | null; + readonly lastPage: ChildEventPage | null; +} { + const manifest = decodeForkReplayEventManifest(wire.manifest); + const iterator = wire.segments[Symbol.iterator](); + const activationIds = new Set(); + let firstPage: ChildEventPage | null = null; + let lastPage: ChildEventPage | null = null; + let remaining = manifest.eventCount; + let sequence = 0n; + while (sequence < manifest.segmentCount) { + const item = iterator.next(); + if (item.done) { + throw new Error( + `fork replay event wire ended after ${sequence} segments; ` + + `expected ${manifest.segmentCount}`, + ); + } + const count = Number( + remaining > BigInt(FORK_REPLAY_EVENT_SEGMENT_CAPACITY) + ? BigInt(FORK_REPLAY_EVENT_SEGMENT_CAPACITY) + : remaining, + ); + const page = decodeForkReplayEventSegment( + item.value, + sequence, + count, + ); + for (let index = 0; index < page.count; index++) { + activationIds.add(readPageWord(page, index, 0)); + } + if (retainPages) { + page.previous = lastPage; + if (lastPage) lastPage.next = page; + firstPage ??= page; + lastPage = page; + } + remaining -= BigInt(page.count); + sequence++; + } + if (!iterator.next().done) { + throw new Error( + "fork replay event wire has segments after its declared segment count " + + `${manifest.segmentCount}`, + ); + } + if (remaining !== 0n) { + throw new Error("fork replay event wire ended before its declared event count"); + } + return { + summary: { + eventCount: manifest.eventCount, + segmentCount: manifest.segmentCount, + activationIds, + }, + firstPage, + lastPage, + }; +} + +/** + * Validate segmented replay-event bytes and derive only their active modules. + * + * The caller does not receive per-frame objects, so exact-set validation stays + * proportional to the number of activations rather than continuation depth. + */ +export function validateForkReplayEventWire( + wire: ForkReplayEventWire, +): ForkReplayEventWireSummary { + return inspectForkReplayEventWire(wire, false).summary; +} + +function readPageWord( + page: ReplayEventPage, + eventIndex: number, + wordIndex: 0 | 1, +): number { + if ("words" in page) { + return page.words[eventIndex * 2 + wordIndex]!; + } + return page.view.getUint32( + FORK_REPLAY_EVENT_SEGMENT_HEADER_SIZE + + eventIndex * FORK_REPLAY_EVENT_ENTRY_SIZE + + wordIndex * 4, + true, + ); +} + +/** + * Global ordering for frames committed into per-module linked continuations. + * + * Unwind commits the innermost activation first. Replay therefore consumes + * the exact reverse order. `peek` is non-consuming so a resume thunk can be + * selected before the original function preamble atomically validates and + * consumes the same event alongside `frame_next`. + */ +export class ForkReplayEventJournal { + private phase: JournalPhase = "idle"; + private capturedFirstPage: CapturedEventPage | null = null; + private capturedLastPage: CapturedEventPage | null = null; + private capturedPageCount = 0n; + private capturedCount = 0n; + private replayPage: ReplayEventPage | null = null; + private replayEventIndex = -1; + private replayRemaining = 0n; + private selected: ForkReplayEvent | null = null; + + beginCapture(): void { + this.requirePhase("idle", "begin replay-event capture"); + this.capturedFirstPage = null; + this.capturedLastPage = null; + this.capturedPageCount = 0n; + this.capturedCount = 0n; + this.phase = "capture"; + } + + recordCommit(activationId: number, functionOrdinal: number): void { + this.requirePhase("capture", "record a replay event"); + assertU32(activationId, "fork replay activation id"); + assertU32(functionOrdinal, "fork replay function ordinal"); + let page = this.capturedLastPage; + if (!page || page.count === FORK_REPLAY_EVENT_SEGMENT_CAPACITY) { + page = { + words: new Uint32Array(FORK_REPLAY_EVENT_SEGMENT_CAPACITY * 2), + count: 0, + previous: this.capturedLastPage, + next: null, + }; + if (this.capturedLastPage) this.capturedLastPage.next = page; + this.capturedFirstPage ??= page; + this.capturedLastPage = page; + this.capturedPageCount++; + } + page.words[page.count * 2] = activationId; + page.words[page.count * 2 + 1] = functionOrdinal; + page.count++; + this.capturedCount++; + } + + sealCapture(): void { + this.requirePhase("capture", "seal replay-event capture"); + this.phase = "sealed-parent"; + } + + capturedEventCount(): bigint { + if (this.phase !== "capture" && this.phase !== "sealed-parent") { + throw new Error( + `cannot read captured replay events while replay-event journal is ${this.phase}`, + ); + } + return this.capturedCount; + } + + capturedActivationIds(): Set { + if (this.phase !== "capture" && this.phase !== "sealed-parent") { + throw new Error( + `cannot read captured replay events while replay-event journal is ${this.phase}`, + ); + } + const ids = new Set(); + for ( + let page: ReplayEventPage | null = this.capturedFirstPage; + page; + page = page.next + ) { + if (!("words" in page)) { + throw new Error("captured replay-event chain contains a child page"); + } + for (let index = 0; index < page.count; index++) { + ids.add(page.words[index * 2]!); + } + } + return ids; + } + + *capturedSegmentPayloads(): IterableIterator { + if (this.phase !== "capture" && this.phase !== "sealed-parent") { + throw new Error( + `cannot encode captured replay events while replay-event journal is ${this.phase}`, + ); + } + let sequence = 0n; + for ( + let page: ReplayEventPage | null = this.capturedFirstPage; + page; + page = page.next + ) { + if (!("words" in page)) { + throw new Error("captured replay-event chain contains a child page"); + } + yield encodeForkReplayEventSegment(page.words, page.count, sequence); + sequence++; + } + } + + capturedManifestPayload(): Uint8Array { + if (this.phase !== "capture" && this.phase !== "sealed-parent") { + throw new Error( + `cannot encode captured replay events while replay-event journal is ${this.phase}`, + ); + } + return encodeForkReplayEventManifest( + this.capturedCount, + this.capturedPageCount, + ); + } + + beginParentReplay(): void { + this.requirePhase("sealed-parent", "begin parent replay events"); + this.beginReplayFrom(this.capturedLastPage, this.capturedCount); + } + + attachChild(wire: ForkReplayEventWire): void { + this.requirePhase("idle", "attach child replay events"); + const { summary, lastPage } = inspectForkReplayEventWire(wire, true); + this.beginReplayFrom(lastPage, summary.eventCount); + } + + peek(): ForkReplayEvent | null { + this.requirePhase("replay", "peek a replay event"); + if (this.selected) return this.selected; + const page = this.replayPage; + if (!page) return null; + this.selected = { + activationId: readPageWord(page, this.replayEventIndex, 0), + functionOrdinal: readPageWord(page, this.replayEventIndex, 1), + }; + return this.selected; + } + + consume(activationId: number, functionOrdinal: number): void { + this.requirePhase("replay", "consume a replay event"); + const event = this.selected; + if (!event) { + throw new Error( + "fork replay frame was consumed without selecting its resume target", + ); + } + if ( + event.activationId !== activationId + || event.functionOrdinal !== functionOrdinal + ) { + throw new Error( + `fork replay event expected ${event.activationId}:${event.functionOrdinal}, ` + + `found ${activationId}:${functionOrdinal}`, + ); + } + this.replayRemaining--; + this.replayEventIndex--; + if (this.replayEventIndex < 0) { + this.replayPage = this.replayPage?.previous ?? null; + this.replayEventIndex = this.replayPage + ? this.replayPage.count - 1 + : -1; + } + this.selected = null; + } + + finishReplay(): void { + this.requirePhase("replay", "finish replay events"); + if (this.replayRemaining !== 0n || this.selected !== null) { + throw new Error( + `fork replay event stream has ${this.replayRemaining} unconsumed entries`, + ); + } + this.clear(); + } + + abort(): void { + this.clear(); + } + + phaseName(): JournalPhase { + return this.phase; + } + + private beginReplayFrom( + lastPage: ReplayEventPage | null, + eventCount: bigint, + ): void { + this.replayPage = lastPage; + this.replayEventIndex = lastPage ? lastPage.count - 1 : -1; + this.replayRemaining = eventCount; + this.selected = null; + this.phase = "replay"; + } + + private clear(): void { + this.capturedFirstPage = null; + this.capturedLastPage = null; + this.capturedPageCount = 0n; + this.capturedCount = 0n; + this.replayPage = null; + this.replayEventIndex = -1; + this.replayRemaining = 0n; + this.selected = null; + this.phase = "idle"; + } + + private requirePhase(expected: JournalPhase, operation: string): void { + if (this.phase !== expected) { + throw new Error( + `cannot ${operation} while replay-event journal is ${this.phase}; ` + + `expected ${expected}`, + ); + } + } +} + +interface RegisteredResumeTarget extends ForkResumeTarget { + readonly activationId: number; + readonly slot: number; +} + +/** + * Host reconstruction owner for the private heterogeneous resume table. + * + * The table is not guest state. Every fresh worker populates it from exact + * artifact catalogs after all main/side activations instantiate. Slots may + * differ across workers because continuation bytes name activation/function + * coordinates, never raw table indexes. + */ +export class ForkResumeTable { + readonly table = new WebAssembly.Table({ + element: "anyfunc", + initial: 1, + }); + + private readonly targets = new Map(); + private readonly activationKeys = new Map(); + private freeSlots: number[] = []; + + registerActivation( + activationId: number, + targets: readonly ForkResumeTarget[], + ): void { + assertU32(activationId, "resume-table activation id"); + if (this.activationKeys.has(activationId)) { + throw new Error(`resume-table activation ${activationId} is already registered`); + } + const ordered = [...targets].sort( + (left, right) => left.functionOrdinal - right.functionOrdinal, + ); + const keys: string[] = []; + let previous: number | undefined; + for (const target of ordered) { + assertU32(target.functionOrdinal, "resume function ordinal"); + if (typeof target.thunk !== "function") { + throw new TypeError("resume target thunk is not a Wasm function"); + } + if (previous === target.functionOrdinal) { + throw new Error( + `resume-table activation ${activationId} repeats function ordinal ${previous}`, + ); + } + previous = target.functionOrdinal; + const slot = this.allocateSlot(); + this.table.set(slot, target.thunk); + const key = eventKey(activationId, target.functionOrdinal); + this.targets.set(key, { ...target, activationId, slot }); + keys.push(key); + } + this.activationKeys.set(activationId, keys); + } + + unregisterActivation(activationId: number): void { + assertU32(activationId, "resume-table activation id"); + const keys = this.activationKeys.get(activationId); + if (!keys) { + throw new Error(`resume-table activation ${activationId} is not registered`); + } + for (const key of keys) { + const target = this.targets.get(key)!; + this.table.set(target.slot, null); + this.targets.delete(key); + this.freeSlots.push(target.slot); + } + this.freeSlots.sort((left, right) => left - right); + this.activationKeys.delete(activationId); + } + + slotFor(event: ForkReplayEvent | null): number { + if (!event) return 0; + const target = this.targets.get( + eventKey(event.activationId, event.functionOrdinal), + ); + if (!target) { + throw new Error( + `fork replay target ${event.activationId}:${event.functionOrdinal} ` + + `is not registered`, + ); + } + // WHY: recursive/reference type equality belongs to the Wasm engine. The + // caller invokes this heterogeneous slot through a statically typed + // call_indirect, which validates the exact result type before the thunk + // can consume its frame. Reimplementing canonical recursive types in JS + // would create a second, weaker type system. + return target.slot; + } + + clear(): void { + for (const activationId of [...this.activationKeys.keys()].sort( + (left, right) => right - left, + )) { + this.unregisterActivation(activationId); + } + this.freeSlots = []; + } + + private allocateSlot(): number { + const reused = this.freeSlots.shift(); + if (reused !== undefined) return reused; + const slot = this.table.length; + this.table.grow(1); + return slot; + } +} diff --git a/host/src/fork-replay-gate.ts b/host/src/fork-replay-gate.ts new file mode 100644 index 0000000000..6e563b6a78 --- /dev/null +++ b/host/src/fork-replay-gate.ts @@ -0,0 +1,222 @@ +import type { WorkerHandle } from "./worker-adapter"; +import type { WorkerToHostMessage } from "./worker-protocol"; + +const FORK_REPLAY_PENDING = 0; +const FORK_REPLAY_COMMITTED = 1; +const FORK_REPLAY_CANCELLED = -1; +const FORK_REPLAY_GATE_BYTES = Int32Array.BYTES_PER_ELEMENT; + +function gateView(buffer: SharedArrayBuffer): Int32Array { + if ( + !(buffer instanceof SharedArrayBuffer) + || buffer.byteLength !== FORK_REPLAY_GATE_BYTES + ) { + throw new TypeError("fork replay gate must be one shared i32"); + } + return new Int32Array(buffer); +} + +export function createForkReplayGate(): SharedArrayBuffer { + return new SharedArrayBuffer(FORK_REPLAY_GATE_BYTES); +} + +/** + * Release a child that proved reconstruction reached the inherited fork site. + */ +export function commitForkReplayGate(buffer: SharedArrayBuffer): void { + const gate = gateView(buffer); + if ( + Atomics.compareExchange( + gate, + 0, + FORK_REPLAY_PENDING, + FORK_REPLAY_COMMITTED, + ) !== FORK_REPLAY_PENDING + ) { + throw new Error("fork replay gate is no longer pending"); + } + Atomics.notify(gate, 0); +} + +/** + * Wake a reconstruction Worker that the kernel host is rolling back. + */ +export function cancelForkReplayGate(buffer: SharedArrayBuffer): void { + const gate = gateView(buffer); + if ( + Atomics.compareExchange( + gate, + 0, + FORK_REPLAY_PENDING, + FORK_REPLAY_CANCELLED, + ) === FORK_REPLAY_PENDING + ) { + Atomics.notify(gate, 0); + } +} + +/** + * Stop the child immediately before the inherited fork() returns zero. + * + * WHY this is a blocking shared-memory gate: a JavaScript promise cannot be + * awaited inside a synchronous Wasm import. Process Workers already execute + * off the main thread and use Atomics.wait for syscall channels, so the same + * primitive gives Node and browsers one exact two-phase commit boundary. + */ +export function waitForForkReplayCommit( + buffer: SharedArrayBuffer, + context: string, +): void { + const gate = gateView(buffer); + for (;;) { + const state = Atomics.load(gate, 0); + if (state === FORK_REPLAY_COMMITTED) return; + if (state === FORK_REPLAY_CANCELLED) { + throw new Error(`${context}: fork replay was cancelled before commit`); + } + if (state !== FORK_REPLAY_PENDING) { + throw new Error(`${context}: invalid fork replay gate state ${state}`); + } + Atomics.wait(gate, 0, FORK_REPLAY_PENDING); + } +} + +type ForkReplayCoordinatorPhase = + | "pending" + | "ready" + | "committed" + | "cancelled"; + +function cancellationError(context: string, reason: unknown): Error { + if (reason instanceof Error) return reason; + return new Error( + `${context}: ${reason === undefined ? "fork replay was cancelled" : String(reason)}`, + ); +} + +/** + * Host-side half of the fork replay two-phase commit. + * + * `ready()` records the child Worker proving it reconstructed the inherited + * fork site, but deliberately leaves the shared gate closed. `commit()` is a + * separate operation performed only after the entrypoint has revalidated the + * exact child generation. Any launch, protocol, error, or exit path can call + * `cancel()` idempotently; a child already blocked in its synchronous Wasm + * import is woken with cancellation rather than leaked forever. + */ +export class ForkReplayGateCoordinator { + readonly gate = createForkReplayGate(); + private phase: ForkReplayCoordinatorPhase = "pending"; + private cancelledWith: Error | null = null; + private readonly readyPromise: Promise; + private resolveReady!: () => void; + private rejectReady!: (reason: Error) => void; + + constructor(readonly context: string) { + this.readyPromise = new Promise((resolve, reject) => { + this.resolveReady = resolve; + this.rejectReady = reject; + }); + // A Worker constructor can fail before handleFork reaches its await. Keep + // that synchronous rollback from producing an unhandled rejection while + // preserving the rejected promise for any later waiter. + void this.readyPromise.catch(() => {}); + } + + get currentPhase(): ForkReplayCoordinatorPhase { + return this.phase; + } + + ready(): void { + if (this.phase === "pending") { + this.phase = "ready"; + this.resolveReady(); + } + } + + waitUntilReady(): Promise { + return this.readyPromise; + } + + commit(): void { + if (this.phase === "cancelled") { + throw this.cancelledWith + ?? new Error(`${this.context}: fork replay was cancelled before commit`); + } + if (this.phase !== "ready") { + throw new Error( + `${this.context}: cannot commit fork replay while ${this.phase}`, + ); + } + commitForkReplayGate(this.gate); + this.phase = "committed"; + } + + cancel(reason?: unknown): void { + if (this.phase === "cancelled" || this.phase === "committed") return; + const error = cancellationError(this.context, reason); + this.cancelledWith = error; + this.phase = "cancelled"; + cancelForkReplayGate(this.gate); + this.rejectReady(error); + } +} + +/** + * Bind readiness and every premature Worker terminal path to one coordinator. + * + * Entry points still own normal process teardown. This observer only controls + * the launch transaction and is intentionally host-neutral so Node and browser + * cannot drift in which events release or cancel a fork. + */ +export function observeForkReplayWorker( + coordinator: ForkReplayGateCoordinator, + worker: WorkerHandle, + pid: number, + isCurrentGeneration: () => boolean, +): void { + const protocolFailure = (detail: string): void => { + coordinator.cancel(new Error(`${coordinator.context}: ${detail}`)); + }; + + worker.on("message", (raw: unknown) => { + const message = raw as Partial; + if (message.type === "fork_replay_ready") { + if (message.pid !== pid) { + protocolFailure( + `Worker reported replay readiness for pid=${String(message.pid)}, expected pid=${pid}`, + ); + } else if (!isCurrentGeneration()) { + protocolFailure("stale Worker generation reported replay readiness"); + } else { + coordinator.ready(); + } + return; + } + + if (message.type === "error" || message.type === "exit") { + if (message.pid !== pid) { + protocolFailure( + `Worker reported ${message.type} for pid=${String(message.pid)}, expected pid=${pid}`, + ); + } else if (message.type === "error") { + protocolFailure( + `Worker failed before replay readiness: ${message.message ?? "unknown error"}`, + ); + } else { + protocolFailure( + `Worker exited before replay readiness (status=${String(message.status)})`, + ); + } + } + }); + + worker.on("error", (error: Error) => { + protocolFailure( + `Worker error before replay readiness: ${error.message || String(error)}`, + ); + }); + worker.on("exit", (code: number) => { + protocolFailure(`Worker exited before replay readiness (code=${code})`); + }); +} diff --git a/host/src/fork-resume-catalog.ts b/host/src/fork-resume-catalog.ts new file mode 100644 index 0000000000..591b0bd285 --- /dev/null +++ b/host/src/fork-resume-catalog.ts @@ -0,0 +1,134 @@ +import type { ForkResumeTarget } from "./fork-replay-events"; + +export const FORK_RESUME_CATALOG_SECTION = + "kandelo.wpk_fork.resume_catalog"; +export const FORK_RESUME_CATALOG_EXPORT = "__wpk_fork_resume_catalog"; +export const FORK_RESUME_CATALOG_VERSION = 1; +export const FORK_RESUME_CATALOG_HEADER_SIZE = 12; +export const FORK_RESUME_CATALOG_RECORD_SIZE = 8; + +const FORK_RESUME_CATALOG_MAGIC = 0x4352_464b; // "KFRC", little endian. + +export interface ForkResumeCatalogRecord { + readonly functionOrdinal: number; + readonly localCatalogSlot: number; +} + +export interface ForkResumeCatalogTarget extends ForkResumeTarget { + readonly localCatalogSlot: number; +} + +function requireCatalogTable(instance: WebAssembly.Instance): WebAssembly.Table { + const value = instance.exports[FORK_RESUME_CATALOG_EXPORT]; + if (!(value instanceof WebAssembly.Table)) { + throw new Error( + `fork resume catalog is missing table export ${FORK_RESUME_CATALOG_EXPORT}`, + ); + } + return value; +} + +/** + * Parse the deterministic function-ordinal to local-table-slot metadata. + * + * Result types are deliberately absent: the exact module template chooses the + * target and the generated Wasm `call_indirect` performs the authoritative + * recursive/reference-type compatibility check before consuming a frame. + */ +export function readForkResumeCatalog( + module: WebAssembly.Module, +): readonly ForkResumeCatalogRecord[] { + const sections = WebAssembly.Module.customSections( + module, + FORK_RESUME_CATALOG_SECTION, + ); + if (sections.length !== 1) { + throw new Error( + `expected one ${FORK_RESUME_CATALOG_SECTION} section, found ${sections.length}`, + ); + } + const bytes = new Uint8Array(sections[0]!); + if (bytes.byteLength < FORK_RESUME_CATALOG_HEADER_SIZE) { + throw new Error("fork resume catalog is truncated"); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (view.getUint32(0, true) !== FORK_RESUME_CATALOG_MAGIC) { + throw new Error("fork resume catalog has invalid magic"); + } + const version = view.getUint16(4, true); + if (version !== FORK_RESUME_CATALOG_VERSION) { + throw new Error(`unsupported fork resume catalog version ${version}`); + } + if (view.getUint16(6, true) !== FORK_RESUME_CATALOG_HEADER_SIZE) { + throw new Error("fork resume catalog has an invalid header size"); + } + const count = view.getUint32(8, true); + const expected = + FORK_RESUME_CATALOG_HEADER_SIZE + count * FORK_RESUME_CATALOG_RECORD_SIZE; + if (!Number.isSafeInteger(expected) || bytes.byteLength !== expected) { + throw new Error("fork resume catalog has an invalid size"); + } + + const records: ForkResumeCatalogRecord[] = []; + const slots = new Set(); + let previousOrdinal: number | undefined; + for (let index = 0; index < count; index++) { + const offset = + FORK_RESUME_CATALOG_HEADER_SIZE + index * FORK_RESUME_CATALOG_RECORD_SIZE; + const functionOrdinal = view.getUint32(offset, true); + const localCatalogSlot = view.getUint32(offset + 4, true); + if ( + previousOrdinal !== undefined + && functionOrdinal <= previousOrdinal + ) { + throw new Error( + `fork resume catalog function ordinal ${functionOrdinal} is not strictly ordered`, + ); + } + if (slots.has(localCatalogSlot)) { + throw new Error( + `fork resume catalog repeats local slot ${localCatalogSlot}`, + ); + } + previousOrdinal = functionOrdinal; + slots.add(localCatalogSlot); + records.push({ functionOrdinal, localCatalogSlot }); + } + return records; +} + +/** + * Resolve one fresh module instance's local catalog to process registration + * targets. No function object is serialized; each child performs this pairing + * again after instantiation. + */ +export function forkResumeTargetsFromInstance( + module: WebAssembly.Module, + instance: WebAssembly.Instance, +): readonly ForkResumeCatalogTarget[] { + const records = readForkResumeCatalog(module); + const table = requireCatalogTable(instance); + if (table.length !== records.length) { + throw new Error( + `fork resume catalog table has length ${table.length}, expected ${records.length}`, + ); + } + return records.map(({ functionOrdinal, localCatalogSlot }) => { + if (localCatalogSlot >= table.length) { + throw new Error( + `fork resume catalog slot ${localCatalogSlot} is out of bounds`, + ); + } + const thunk = table.get(localCatalogSlot); + if (typeof thunk !== "function") { + throw new Error( + `fork resume catalog slot ${localCatalogSlot} is not a Wasm function`, + ); + } + return { + functionOrdinal, + localCatalogSlot, + thunk: thunk as CallableFunction, + }; + }); +} diff --git a/host/src/fork-static-root-catalog.ts b/host/src/fork-static-root-catalog.ts new file mode 100644 index 0000000000..e500d32aea --- /dev/null +++ b/host/src/fork-static-root-catalog.ts @@ -0,0 +1,236 @@ +/** + * Deterministic identities for GC references recreated by instantiation. + * + * A structurally cloned child object is not interchangeable with an immutable + * global or static element root: `ref.eq` would see two identities. Each + * instrumented activation therefore exposes an instantiation-time harvest + * table. The host records only weak identities and immediately clears every + * table entry. Recipes name roots by `(activationId, ordinal)` and pin only + * referenced child roots for the duration of replay. + */ + +import { + WPK_FORK_STATIC_ROOT_CATALOG_EXPORT, + WPK_FORK_STATIC_ROOT_CATALOG_HEADER_SIZE, + WPK_FORK_STATIC_ROOT_CATALOG_MAGIC, + WPK_FORK_STATIC_ROOT_CATALOG_SECTION, + WPK_FORK_STATIC_ROOT_CATALOG_VERSION, + WPK_FORK_STATIC_ROOT_HARVEST_EXPORT, +} from "./generated/abi"; + +export const FORK_STATIC_ROOT_CATALOG_EXPORT = + WPK_FORK_STATIC_ROOT_CATALOG_EXPORT; +export const FORK_STATIC_ROOT_HARVEST_EXPORT = + WPK_FORK_STATIC_ROOT_HARVEST_EXPORT; +export const FORK_STATIC_ROOT_CATALOG_SECTION = + WPK_FORK_STATIC_ROOT_CATALOG_SECTION; +export const FORK_STATIC_ROOT_CATALOG_VERSION = + WPK_FORK_STATIC_ROOT_CATALOG_VERSION; +export const FORK_STATIC_ROOT_CATALOG_HEADER_SIZE = + WPK_FORK_STATIC_ROOT_CATALOG_HEADER_SIZE; + +const FORMAT_MAGIC = Uint8Array.from(WPK_FORK_STATIC_ROOT_CATALOG_MAGIC); + +export interface ForkStaticRootRecipe { + readonly moduleActivation: number; + readonly ordinal: number; +} + +interface RegisteredStaticRoots { + readonly entries: readonly StaticRootHandle[]; +} + +type StaticRootHandle = + | { readonly kind: "object"; readonly value: WeakRef } + | { readonly kind: "primitive"; readonly value: unknown }; + +function assertU32(value: number, label: string): void { + if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff) { + throw new RangeError(`invalid ${label} ${value}`); + } +} + +function isObjectIdentity(value: unknown): value is object { + return ( + (typeof value === "object" && value !== null) + || typeof value === "function" + ); +} + +export function readForkStaticRootCatalogCount( + module: WebAssembly.Module, +): number { + const sections = WebAssembly.Module.customSections( + module, + FORK_STATIC_ROOT_CATALOG_SECTION, + ); + if (sections.length !== 1) { + throw new Error( + `expected one ${FORK_STATIC_ROOT_CATALOG_SECTION} section, ` + + `found ${sections.length}`, + ); + } + const bytes = new Uint8Array(sections[0]!); + if (bytes.byteLength !== FORK_STATIC_ROOT_CATALOG_HEADER_SIZE) { + throw new Error("fork static-root catalog descriptor has an invalid size"); + } + if (FORMAT_MAGIC.some((byte, index) => bytes[index] !== byte)) { + throw new Error("fork static-root catalog descriptor has invalid magic"); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const version = view.getUint16(4, true); + if (version !== FORK_STATIC_ROOT_CATALOG_VERSION) { + throw new Error(`unsupported fork static-root catalog version ${version}`); + } + if (view.getUint16(6, true) !== FORK_STATIC_ROOT_CATALOG_HEADER_SIZE) { + throw new Error("fork static-root catalog descriptor has an invalid header size"); + } + return view.getUint32(8, true); +} + +export function forkStaticRootTableFromInstance( + module: WebAssembly.Module, + instance: WebAssembly.Instance, +): WebAssembly.Table { + const count = readForkStaticRootCatalogCount(module); + const table = instance.exports[FORK_STATIC_ROOT_CATALOG_EXPORT]; + if (!(table instanceof WebAssembly.Table)) { + throw new Error( + `fork activation is missing table export ${FORK_STATIC_ROOT_CATALOG_EXPORT}`, + ); + } + if (table.length !== count) { + throw new Error( + `fork static-root catalog has ${table.length} entries; descriptor declares ${count}`, + ); + } + return table; +} + +export function clearForkStaticRootTable(table: WebAssembly.Table): void { + for (let ordinal = 0; ordinal < table.length; ordinal++) { + table.set(ordinal, null); + } +} + +/** + * Process-worker view of every activation's static roots. + * + * Aliases are canonicalized when registrations are read. Keeping the first + * coordinate makes recipe selection deterministic even when an imported + * immutable global exposes the same object through multiple activations. The + * weak reverse handles preserve later-fork identity without extending object + * lifetime after its guest-owned global/table/segment/local releases it. + */ +export class ForkStaticRootCatalog { + private readonly catalogs = new Map(); + private objectRecipes = new WeakMap(); + private readonly primitiveRecipes = new Map(); + + register(moduleActivation: number, table: WebAssembly.Table): void { + assertU32(moduleActivation, "static-root module activation"); + if (this.catalogs.has(moduleActivation)) { + throw new Error( + `static-root catalog ${moduleActivation} is already registered`, + ); + } + const entries: StaticRootHandle[] = []; + try { + for (let ordinal = 0; ordinal < table.length; ordinal++) { + const value = table.get(ordinal); + entries.push( + isObjectIdentity(value) + ? { kind: "object", value: new WeakRef(value) } + : { kind: "primitive", value }, + ); + } + } finally { + // WHY: this table is only an instantiation-time observation window. + // Retaining entries here would recreate the retired module stash after + // table mutation or elem.drop. + clearForkStaticRootTable(table); + } + this.catalogs.set(moduleActivation, { + entries: Object.freeze(entries), + }); + this.rebuildIndexes(); + } + + unregister(moduleActivation: number): void { + assertU32(moduleActivation, "static-root module activation"); + if (!this.catalogs.delete(moduleActivation)) return; + this.rebuildIndexes(); + } + + encode(value: unknown): ForkStaticRootRecipe | null { + if (value === null) return null; + return this.lookup(value) ?? null; + } + + decode(recipe: ForkStaticRootRecipe): unknown { + assertU32(recipe.moduleActivation, "static-root module activation"); + assertU32(recipe.ordinal, "static-root ordinal"); + const catalog = this.catalogs.get(recipe.moduleActivation); + if (!catalog) { + throw new Error( + `static-root catalog ${recipe.moduleActivation} is not registered`, + ); + } + if (recipe.ordinal >= catalog.entries.length) { + throw new Error( + `static-root recipe ${recipe.moduleActivation}:${recipe.ordinal} ` + + "is out of bounds", + ); + } + const handle = catalog.entries[recipe.ordinal]!; + if (handle.kind === "primitive") return handle.value; + const value = handle.value.deref(); + if (value === undefined) { + throw new Error( + `static-root recipe ${recipe.moduleActivation}:${recipe.ordinal} ` + + "was collected before replay pinned it", + ); + } + return value; + } + + clear(): void { + this.catalogs.clear(); + this.objectRecipes = new WeakMap(); + this.primitiveRecipes.clear(); + } + + private lookup(value: unknown): ForkStaticRootRecipe | undefined { + return isObjectIdentity(value) + ? this.objectRecipes.get(value) + : this.primitiveRecipes.get(value); + } + + private rebuildIndexes(): void { + this.objectRecipes = new WeakMap(); + this.primitiveRecipes.clear(); + const catalogs = [...this.catalogs].sort( + ([left], [right]) => left - right, + ); + for (const [moduleActivation, catalog] of catalogs) { + catalog.entries.forEach((handle, ordinal) => { + const value = handle.kind === "object" + ? handle.value.deref() + : handle.value; + if ( + value === undefined + || value === null + || this.lookup(value) !== undefined + ) { + return; + } + const recipe = Object.freeze({ moduleActivation, ordinal }); + if (isObjectIdentity(value)) { + this.objectRecipes.set(value, recipe); + } else { + this.primitiveRecipes.set(value, recipe); + } + }); + } + } +} diff --git a/host/src/fork-unwind-transport.ts b/host/src/fork-unwind-transport.ts new file mode 100644 index 0000000000..eaad6891ed --- /dev/null +++ b/host/src/fork-unwind-transport.ts @@ -0,0 +1,47 @@ +/** + * Private Wasm-EH transport used while serializing a fork continuation. + * + * This tag is process-owned and shared by the main instance and every loaded + * side module in the same Worker. It is not a program exception: instrumented + * catch-all clauses rethrow it, each activation commits its frame while it + * propagates, and only the worker entry boundary consumes it. + */ + +export { + WPK_FORK_UNWIND_TAG_IMPORT_MODULE as FORK_UNWIND_TAG_IMPORT_MODULE, + WPK_FORK_UNWIND_TAG_IMPORT_NAME as FORK_UNWIND_TAG_IMPORT_NAME, + WPK_FORK_UNWIND_TRANSPORT_PAYLOAD_ARITY as FORK_UNWIND_TRANSPORT_PAYLOAD_ARITY, + WPK_FORK_UNWIND_TRANSPORT_SECTION as FORK_UNWIND_TRANSPORT_SECTION, + WPK_FORK_UNWIND_TRANSPORT_VERSION as FORK_UNWIND_TRANSPORT_VERSION, +} from "./generated/abi"; + +export function createForkUnwindTag(): WebAssembly.Tag { + if (typeof WebAssembly.Tag !== "function") { + throw new Error("WebAssembly exception tags are required for fork instrumentation"); + } + return new WebAssembly.Tag({ parameters: [] }); +} + +export function requireForkUnwindTag( + tag: unknown, + context: string, +): WebAssembly.Tag { + if (typeof WebAssembly.Tag !== "function") { + throw new Error(`${context}: WebAssembly exception tags are unavailable`); + } + if (!(tag instanceof WebAssembly.Tag)) { + throw new TypeError(`${context}: missing valid process-owned fork unwind tag`); + } + return tag; +} + +export function isForkUnwindException( + value: unknown, + tag: WebAssembly.Tag, +): value is WebAssembly.Exception { + return ( + typeof WebAssembly.Exception === "function" + && value instanceof WebAssembly.Exception + && value.is(tag) + ); +} diff --git a/host/src/fork-worker-exception-capability.ts b/host/src/fork-worker-exception-capability.ts new file mode 100644 index 0000000000..b37b153d7b --- /dev/null +++ b/host/src/fork-worker-exception-capability.ts @@ -0,0 +1,134 @@ +/** + * Durable owner-realm representation of a JavaScript value thrown by a + * deliberately Worker-local Wasm import. + * + * A raw object or function cannot move from a process Worker into the durable + * process owner, and a fork child cannot inherit the Worker's JavaScript heap. + * Ordinary imports and WebAssembly.JSTag first retain exact local behavior. + * If such a value is still live at fork and no activation-local exception + * codec owns it, capture normalizes the child's payload into this explicit + * capability. Primitive values are retained exactly behind the capability and + * are unwrapped when a later owner-side host import consumes the handle. + */ + +const WORKER_EXCEPTION_CAPABILITY = + Symbol("kandelo.fork.worker-exception-capability"); + +export const FORK_WORKER_EXCEPTION_RECIPE_VERSION = 1; + +export type ForkWorkerExceptionKind = + | "undefined" + | "null" + | "boolean" + | "number" + | "bigint" + | "string" + | "symbol" + | "error" + | "object" + | "function"; + +export interface ForkWorkerExceptionCapability { + readonly recipeVersion: typeof FORK_WORKER_EXCEPTION_RECIPE_VERSION; + readonly sourceImportOrdinal: number; + readonly kind: ForkWorkerExceptionKind; + /** + * Worker-local Error objects have no transferable original. Preserve the + * standard observable fields on their stable owner capability. + */ + readonly name?: string; + readonly message?: string; + readonly [WORKER_EXCEPTION_CAPABILITY]: true; +} + +class WorkerExceptionCapability + implements ForkWorkerExceptionCapability +{ + readonly recipeVersion = FORK_WORKER_EXCEPTION_RECIPE_VERSION; + readonly [WORKER_EXCEPTION_CAPABILITY] = true as const; + + constructor( + readonly sourceImportOrdinal: number, + readonly kind: ForkWorkerExceptionKind, + readonly name?: string, + readonly message?: string, + /** + * Exact owner-side value exposed to a later owner-routed host import. + * Opaque Worker objects/functions and Worker Errors use this capability + * itself because no durable original exists outside the Worker. + */ + private boundaryValue?: unknown, + ) { + if (boundaryValue === undefined && kind === "undefined") { + // Undefined is a real exact boundary value, not an omitted initializer. + this.boundaryValue = undefined; + } + Object.freeze(this); + } + + unwrap(): unknown { + if ( + this.kind === "error" + || this.kind === "object" + || this.kind === "function" + ) { + return this; + } + return this.boundaryValue; + } +} + +export interface CreateForkWorkerExceptionCapabilityOptions { + readonly sourceImportOrdinal: number; + readonly kind: ForkWorkerExceptionKind; + readonly name?: string; + readonly message?: string; + readonly boundaryValue?: unknown; +} + +export function createForkWorkerExceptionCapability( + options: CreateForkWorkerExceptionCapabilityOptions, +): ForkWorkerExceptionCapability { + if ( + !Number.isInteger(options.sourceImportOrdinal) + || options.sourceImportOrdinal < 0 + || options.sourceImportOrdinal > 0x7fff_ffff + ) { + throw new RangeError( + `invalid Worker exception source import ordinal ` + + `${options.sourceImportOrdinal}`, + ); + } + return new WorkerExceptionCapability( + options.sourceImportOrdinal, + options.kind, + options.name, + options.message, + options.boundaryValue, + ); +} + +export function isForkWorkerExceptionCapability( + value: unknown, +): value is ForkWorkerExceptionCapability { + return ( + typeof value === "object" + && value !== null + && (value as Partial)[ + WORKER_EXCEPTION_CAPABILITY + ] === true + ); +} + +/** + * Restore exact primitives at owner-side host boundaries. For values that + * never had a durable owner-realm original, return the stable capability + * itself; callers can inspect Error name/message and otherwise treat it as the + * opaque identity WebAssembly exposes. + */ +export function unwrapForkWorkerExceptionCapability( + value: unknown, +): unknown { + if (!isForkWorkerExceptionCapability(value)) return value; + return (value as WorkerExceptionCapability).unwrap(); +} diff --git a/host/src/fork-worker-import-exceptions.ts b/host/src/fork-worker-import-exceptions.ts new file mode 100644 index 0000000000..e28e1c7477 --- /dev/null +++ b/host/src/fork-worker-import-exceptions.ts @@ -0,0 +1,855 @@ +import { + defineForkExternrefImport, + type ForkExternrefImportBinding, + type ForkExternrefImportDescriptor, + ForkExternrefImportOwnerCatalog, + type ForkExternrefImportValue, + ForkExternrefImportWorkerCaller, +} from "./fork-externref-import-mailbox"; +import { + createForkWorkerExceptionCapability, + FORK_WORKER_EXCEPTION_RECIPE_VERSION, + type ForkWorkerExceptionCapability, + type ForkWorkerExceptionKind, +} from "./fork-worker-exception-capability"; +import { + type ForkExternrefToken, + ForkExternrefTokenCache, +} from "./fork-reference-broker"; + +const NORMALIZE_BEGIN_ORDINAL = 0xffff_fffc; +const NORMALIZE_CHUNK_ORDINAL = 0xffff_fffd; +const NORMALIZE_COMMIT_ORDINAL = 0xffff_fffe; +const NORMALIZE_ABORT_ORDINAL = 0xffff_ffff; +const CHUNK_WORDS = 13; +const CODE_UNITS_PER_WORD = 4; +const CHUNK_CODE_UNITS = CHUNK_WORDS * CODE_UNITS_PER_WORD; +const MAX_SESSION_ID = 0x7fff_fffe; +export const FORK_WORKER_EXCEPTION_FORK_CAPTURE_ORDINAL = 0x7fff_ffff; + +export const FORK_WORKER_EXCEPTION_RESERVED_ORDINAL_START = + NORMALIZE_BEGIN_ORDINAL; + +export const FORK_WORKER_EXCEPTION_BEGIN_DESCRIPTOR = + defineForkExternrefImport( + NORMALIZE_BEGIN_ORDINAL, + ["i32", "i32", "i32", "i32", "i64", "i32", "i32"], + ["i32"], + ); + +export const FORK_WORKER_EXCEPTION_CHUNK_DESCRIPTOR = + defineForkExternrefImport( + NORMALIZE_CHUNK_ORDINAL, + [ + "i32", + "i32", + "i32", + ...Array(CHUNK_WORDS).fill("i64"), + ] as const, + [], + ); + +export const FORK_WORKER_EXCEPTION_COMMIT_DESCRIPTOR = + defineForkExternrefImport( + NORMALIZE_COMMIT_ORDINAL, + ["i32"], + ["externref"], + ); + +export const FORK_WORKER_EXCEPTION_ABORT_DESCRIPTOR = + defineForkExternrefImport( + NORMALIZE_ABORT_ORDINAL, + ["i32"], + [], + ); + +const enum ExceptionKindCode { + Undefined = 1, + Null = 2, + Boolean = 3, + Number = 4, + BigInt = 5, + String = 6, + Symbol = 7, + Error = 8, + Object = 9, + Function = 10, +} + +const FLAG_BOOLEAN_TRUE = 1 << 0; +const FLAG_SYMBOL_GLOBAL = 1 << 0; +const FLAG_SYMBOL_HAS_DESCRIPTION = 1 << 1; + +interface WorkerExceptionRecipe { + readonly kind: ExceptionKindCode; + readonly flags: number; + readonly scalarBits: bigint; + readonly fields: readonly [string, string]; +} + +interface OwnerExceptionSession { + readonly binding: ForkExternrefImportBinding; + readonly sourceImportOrdinal: number; + readonly kind: ExceptionKindCode; + readonly flags: number; + readonly scalarBits: bigint; + readonly expectedLengths: readonly [number, number]; + readonly chunks: [string[], string[]]; + receivedLengths: [number, number]; +} + +function sameBinding( + left: ForkExternrefImportBinding, + right: ForkExternrefImportBinding, +): boolean { + return ( + left.pid === right.pid + && left.generationId === right.generationId + && left.senderId === right.senderId + ); +} + +function assertI32(value: unknown, label: string): number { + if ( + typeof value !== "number" + || !Number.isInteger(value) + || value < -0x8000_0000 + || value > 0x7fff_ffff + ) { + throw new TypeError(`${label} must be an i32`); + } + return value; +} + +function assertNonnegativeI32(value: unknown, label: string): number { + const checked = assertI32(value, label); + if (checked < 0) throw new RangeError(`${label} must be nonnegative`); + return checked; +} + +function assertI64(value: unknown, label: string): bigint { + if (typeof value !== "bigint") { + throw new TypeError(`${label} must be an i64`); + } + return value; +} + +function kindName(code: ExceptionKindCode): ForkWorkerExceptionKind { + switch (code) { + case ExceptionKindCode.Undefined: + return "undefined"; + case ExceptionKindCode.Null: + return "null"; + case ExceptionKindCode.Boolean: + return "boolean"; + case ExceptionKindCode.Number: + return "number"; + case ExceptionKindCode.BigInt: + return "bigint"; + case ExceptionKindCode.String: + return "string"; + case ExceptionKindCode.Symbol: + return "symbol"; + case ExceptionKindCode.Error: + return "error"; + case ExceptionKindCode.Object: + return "object"; + case ExceptionKindCode.Function: + return "function"; + default: + throw new RangeError(`unknown Worker exception kind ${code}`); + } +} + +function exactNumberBits(value: number): bigint { + const bytes = new ArrayBuffer(8); + const view = new DataView(bytes); + view.setFloat64(0, value, true); + return view.getBigInt64(0, true); +} + +function numberFromExactBits(bits: bigint): number { + const bytes = new ArrayBuffer(8); + const view = new DataView(bytes); + view.setBigInt64(0, bits, true); + return view.getFloat64(0, true); +} + +function readStringDataProperty( + value: object, + name: "name" | "message", +): string | undefined { + let current: object | null = value; + // Avoid invoking arbitrary getters while normalizing an already failing + // import. Standard Error name/message properties are data descriptors. + for (let depth = 0; current !== null && depth < 32; depth++) { + try { + const descriptor = Object.getOwnPropertyDescriptor(current, name); + if (descriptor && "value" in descriptor) { + return typeof descriptor.value === "string" + ? descriptor.value + : undefined; + } + current = Object.getPrototypeOf(current) as object | null; + } catch { + return undefined; + } + } + return undefined; +} + +function isErrorObject(value: object): boolean { + try { + return value instanceof Error; + } catch { + return false; + } +} + +function describeThrown(value: unknown): WorkerExceptionRecipe { + switch (typeof value) { + case "undefined": + return { + kind: ExceptionKindCode.Undefined, + flags: 0, + scalarBits: 0n, + fields: ["", ""], + }; + case "boolean": + return { + kind: ExceptionKindCode.Boolean, + flags: value ? FLAG_BOOLEAN_TRUE : 0, + scalarBits: 0n, + fields: ["", ""], + }; + case "number": + return { + kind: ExceptionKindCode.Number, + flags: 0, + scalarBits: exactNumberBits(value), + fields: ["", ""], + }; + case "bigint": + return { + kind: ExceptionKindCode.BigInt, + flags: 0, + scalarBits: 0n, + fields: [value.toString(10), ""], + }; + case "string": + return { + kind: ExceptionKindCode.String, + flags: 0, + scalarBits: 0n, + fields: [value, ""], + }; + case "symbol": { + const globalKey = Symbol.keyFor(value); + const description = value.description; + return { + kind: ExceptionKindCode.Symbol, + flags: + (globalKey === undefined ? 0 : FLAG_SYMBOL_GLOBAL) + | (description === undefined ? 0 : FLAG_SYMBOL_HAS_DESCRIPTION), + scalarBits: 0n, + fields: [globalKey ?? description ?? "", ""], + }; + } + case "function": + return { + kind: ExceptionKindCode.Function, + flags: 0, + scalarBits: 0n, + fields: ["", ""], + }; + case "object": + if (value === null) { + return { + kind: ExceptionKindCode.Null, + flags: 0, + scalarBits: 0n, + fields: ["", ""], + }; + } + if (isErrorObject(value)) { + return { + kind: ExceptionKindCode.Error, + flags: 0, + scalarBits: 0n, + fields: [ + readStringDataProperty(value, "name") ?? "Error", + readStringDataProperty(value, "message") ?? "", + ], + }; + } + return { + kind: ExceptionKindCode.Object, + flags: 0, + scalarBits: 0n, + fields: ["", ""], + }; + } +} + +function packCodeUnits( + value: string, + offset: number, +): bigint[] { + const words: bigint[] = []; + for (let wordIndex = 0; wordIndex < CHUNK_WORDS; wordIndex++) { + let word = 0n; + for ( + let codeUnitIndex = 0; + codeUnitIndex < CODE_UNITS_PER_WORD; + codeUnitIndex++ + ) { + const index = + offset + wordIndex * CODE_UNITS_PER_WORD + codeUnitIndex; + const codeUnit = index < value.length ? value.charCodeAt(index) : 0; + word |= BigInt(codeUnit) << BigInt(codeUnitIndex * 16); + } + words.push(BigInt.asIntN(64, word)); + } + return words; +} + +function unpackCodeUnits( + words: readonly bigint[], + count: number, +): string { + const codeUnits: number[] = []; + for (const signedWord of words) { + const word = BigInt.asUintN(64, signedWord); + for (let index = 0; index < CODE_UNITS_PER_WORD; index++) { + codeUnits.push( + Number((word >> BigInt(index * 16)) & 0xffffn), + ); + } + } + for (let index = count; index < codeUnits.length; index++) { + if (codeUnits[index] !== 0) { + throw new Error("Worker exception chunk has nonzero padding"); + } + } + return String.fromCharCode(...codeUnits.slice(0, count)); +} + +function validateRecipeShape( + kind: ExceptionKindCode, + flags: number, + fieldLengths: readonly [number, number], +): void { + kindName(kind); + if (!Number.isInteger(flags) || flags < 0) { + throw new RangeError("Worker exception recipe flags are invalid"); + } + const [first, second] = fieldLengths; + switch (kind) { + case ExceptionKindCode.Boolean: + if ((flags & ~FLAG_BOOLEAN_TRUE) !== 0 || first !== 0 || second !== 0) { + throw new Error("malformed boolean Worker exception recipe"); + } + return; + case ExceptionKindCode.Symbol: + if ( + (flags & ~(FLAG_SYMBOL_GLOBAL | FLAG_SYMBOL_HAS_DESCRIPTION)) !== 0 + || second !== 0 + || ( + (flags & FLAG_SYMBOL_GLOBAL) !== 0 + && (flags & FLAG_SYMBOL_HAS_DESCRIPTION) === 0 + ) + ) { + throw new Error("malformed symbol Worker exception recipe"); + } + return; + case ExceptionKindCode.BigInt: + case ExceptionKindCode.String: + if (flags !== 0 || second !== 0) { + throw new Error("malformed scalar Worker exception recipe"); + } + return; + case ExceptionKindCode.Error: + if (flags !== 0) { + throw new Error("malformed Error Worker exception recipe"); + } + return; + default: + if (flags !== 0 || first !== 0 || second !== 0) { + throw new Error("malformed opaque Worker exception recipe"); + } + } +} + +/** + * Owner-side state for the exceptional, chunked normalization protocol. + * + * Sessions contain only scalar code units. They own no Worker object and are + * cleared explicitly with the Worker binding on teardown. + */ +export class ForkWorkerExceptionCapabilityOwner { + private readonly sessions = new Map(); + private nextSessionId = 1; + private installed = false; + + install(catalog: ForkExternrefImportOwnerCatalog): void { + if (this.installed) { + throw new Error("Worker exception owner was installed twice"); + } + this.installed = true; + catalog.register( + FORK_WORKER_EXCEPTION_BEGIN_DESCRIPTOR, + (context, ...args) => this.begin(context, args), + ); + catalog.register( + FORK_WORKER_EXCEPTION_CHUNK_DESCRIPTOR, + (context, ...args) => this.append(context, args), + ); + catalog.register( + FORK_WORKER_EXCEPTION_COMMIT_DESCRIPTOR, + (context, sessionId) => this.commit(context, sessionId), + ); + catalog.register( + FORK_WORKER_EXCEPTION_ABORT_DESCRIPTOR, + (context, sessionId) => { + this.abort(context, sessionId); + }, + ); + } + + clearBinding(binding: ForkExternrefImportBinding): void { + for (const [sessionId, session] of this.sessions) { + if (sameBinding(session.binding, binding)) { + this.sessions.delete(sessionId); + } + } + } + + /** Test/diagnostic visibility without exposing mutable session contents. */ + get activeSessionCount(): number { + return this.sessions.size; + } + + private begin( + binding: ForkExternrefImportBinding, + args: readonly unknown[], + ): number { + const version = assertI32(args[0], "Worker exception recipe version"); + if (version !== FORK_WORKER_EXCEPTION_RECIPE_VERSION) { + throw new Error(`unsupported Worker exception recipe version ${version}`); + } + const sourceImportOrdinal = assertNonnegativeI32( + args[1], + "Worker exception source import ordinal", + ); + const kind = assertI32( + args[2], + "Worker exception kind", + ) as ExceptionKindCode; + const flags = assertNonnegativeI32( + args[3], + "Worker exception flags", + ); + const scalarBits = assertI64(args[4], "Worker exception scalar bits"); + const fieldLengths: [number, number] = [ + assertNonnegativeI32(args[5], "Worker exception field 0 length"), + assertNonnegativeI32(args[6], "Worker exception field 1 length"), + ]; + validateRecipeShape(kind, flags, fieldLengths); + + const sessionId = this.allocateSessionId(); + this.sessions.set(sessionId, { + binding: { + pid: binding.pid, + generationId: binding.generationId, + senderId: binding.senderId, + }, + sourceImportOrdinal, + kind, + flags, + scalarBits, + expectedLengths: fieldLengths, + chunks: [[], []], + receivedLengths: [0, 0], + }); + return sessionId; + } + + private append( + binding: ForkExternrefImportBinding, + args: readonly unknown[], + ): undefined { + const sessionId = assertNonnegativeI32( + args[0], + "Worker exception session", + ); + const field = assertNonnegativeI32( + args[1], + "Worker exception field", + ); + const offset = assertNonnegativeI32( + args[2], + "Worker exception field offset", + ); + if (field > 1) throw new RangeError(`invalid Worker exception field ${field}`); + const session = this.requireSession(binding, sessionId); + if (offset !== session.receivedLengths[field]) { + throw new Error( + `Worker exception field ${field} expected offset ` + + `${session.receivedLengths[field]}, received ${offset}`, + ); + } + const remaining = session.expectedLengths[field] - offset; + if (remaining <= 0) { + throw new Error(`Worker exception field ${field} is already complete`); + } + const count = Math.min(remaining, CHUNK_CODE_UNITS); + const words = args.slice(3).map((value, index) => + assertI64(value, `Worker exception chunk word ${index}`) + ); + if (words.length !== CHUNK_WORDS) { + throw new Error("Worker exception chunk has the wrong word count"); + } + session.chunks[field].push(unpackCodeUnits(words, count)); + session.receivedLengths[field] += count; + return undefined; + } + + private commit( + binding: ForkExternrefImportBinding, + rawSessionId: unknown, + ): ForkWorkerExceptionCapability { + const sessionId = assertNonnegativeI32( + rawSessionId, + "Worker exception session", + ); + const session = this.requireSession(binding, sessionId); + if ( + session.receivedLengths[0] !== session.expectedLengths[0] + || session.receivedLengths[1] !== session.expectedLengths[1] + ) { + throw new Error("Worker exception recipe was committed before completion"); + } + this.sessions.delete(sessionId); + const fields: [string, string] = [ + session.chunks[0].join(""), + session.chunks[1].join(""), + ]; + return this.materializeCapability(session, fields); + } + + private abort( + binding: ForkExternrefImportBinding, + rawSessionId: unknown, + ): void { + const sessionId = assertNonnegativeI32( + rawSessionId, + "Worker exception session", + ); + this.requireSession(binding, sessionId); + this.sessions.delete(sessionId); + } + + private materializeCapability( + session: OwnerExceptionSession, + fields: readonly [string, string], + ): ForkWorkerExceptionCapability { + let boundaryValue: unknown; + switch (session.kind) { + case ExceptionKindCode.Undefined: + boundaryValue = undefined; + break; + case ExceptionKindCode.Null: + boundaryValue = null; + break; + case ExceptionKindCode.Boolean: + boundaryValue = (session.flags & FLAG_BOOLEAN_TRUE) !== 0; + break; + case ExceptionKindCode.Number: + boundaryValue = numberFromExactBits(session.scalarBits); + break; + case ExceptionKindCode.BigInt: + boundaryValue = BigInt(fields[0]); + break; + case ExceptionKindCode.String: + boundaryValue = fields[0]; + break; + case ExceptionKindCode.Symbol: + boundaryValue = (session.flags & FLAG_SYMBOL_GLOBAL) !== 0 + ? Symbol.for(fields[0]) + : (session.flags & FLAG_SYMBOL_HAS_DESCRIPTION) !== 0 + ? Symbol(fields[0]) + : Symbol(); + break; + case ExceptionKindCode.Error: + case ExceptionKindCode.Object: + case ExceptionKindCode.Function: + boundaryValue = undefined; + break; + default: + kindName(session.kind); + } + return createForkWorkerExceptionCapability({ + sourceImportOrdinal: session.sourceImportOrdinal, + kind: kindName(session.kind), + name: session.kind === ExceptionKindCode.Error ? fields[0] : undefined, + message: session.kind === ExceptionKindCode.Error + ? fields[1] + : undefined, + boundaryValue, + }); + } + + private requireSession( + binding: ForkExternrefImportBinding, + sessionId: number, + ): OwnerExceptionSession { + const session = this.sessions.get(sessionId); + if (!session || !sameBinding(session.binding, binding)) { + throw new Error( + `unknown Worker exception session ${sessionId} for ` + + `pid=${binding.pid} generation=${binding.generationId} ` + + `sender=${binding.senderId}`, + ); + } + return session; + } + + private allocateSessionId(): number { + const start = this.nextSessionId; + do { + const candidate = this.nextSessionId++; + if (this.nextSessionId > MAX_SESSION_ID) this.nextSessionId = 1; + if (!this.sessions.has(candidate)) return candidate; + } while (this.nextSessionId !== start); + throw new RangeError("Worker exception normalization session space exhausted"); + } +} + +function buildFatalTrap(): () => never { + // (module (func (export "trap") unreachable)) + const bytes = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + 0x01, 0x04, 0x01, 0x60, 0x00, 0x00, + 0x03, 0x02, 0x01, 0x00, + 0x07, 0x08, 0x01, 0x04, 0x74, 0x72, 0x61, 0x70, 0x00, 0x00, + 0x0a, 0x05, 0x01, 0x03, 0x00, 0x00, 0x0b, + ]); + const instance = new WebAssembly.Instance(new WebAssembly.Module(bytes)); + const trap = instance.exports.trap; + if (typeof trap !== "function") { + throw new Error("failed to construct Worker exception fatal trap"); + } + return (): never => { + trap(); + throw new Error("unreachable Worker exception fatal trap returned"); + }; +} + +const fatalTrap = buildFatalTrap(); + +export interface ForkWorkerLocalImportExceptionNormalizerOptions { + readonly onFatal?: ( + error: unknown, + sourceImportOrdinal: number, + ) => void; +} + +/** + * Exception-only adapter for imports that must execute beside their Wasm + * instance (memory, activation, syscall, and dynamic-linker intrinsics). + * + * Normal returns and ordinary throws perform no owner RPC, preserving exact + * JavaScript/Wasm exception behavior. A nested Wasm RuntimeError is re-trapped + * so it cannot become CatchAllRef-visible merely by crossing this JS frame. + * Values that remain live at fork are normalized separately, after exact tag + * codecs have had the opportunity to claim them. + */ +export class ForkWorkerLocalImportExceptionNormalizer { + private readonly objectTokens = + new WeakMap(); + private readonly symbolTokens = + new Map(); + + constructor( + private readonly caller: ForkExternrefImportWorkerCaller, + private readonly tokens: ForkExternrefTokenCache, + private readonly options: + ForkWorkerLocalImportExceptionNormalizerOptions = {}, + ) {} + + wrap( + sourceImportOrdinal: number, + implementation: T, + ): T { + if ( + !Number.isInteger(sourceImportOrdinal) + || sourceImportOrdinal < 0 + || sourceImportOrdinal > 0x7fff_ffff + ) { + throw new RangeError( + `invalid Worker-local import ordinal ${sourceImportOrdinal}`, + ); + } + const normalizer = this; + return function ( + this: unknown, + ...args: unknown[] + ): unknown { + try { + return Reflect.apply(implementation, this, args); + } catch (thrown) { + return normalizer.replaceThrown( + sourceImportOrdinal, + thrown, + ); + } + } as unknown as T; + } + + clear(): void { + this.symbolTokens.clear(); + // WeakMap keys do not keep Worker-local objects alive. The whole + // normalizer becomes unreachable on exec/Worker teardown. + } + + /** + * Normalize a Worker-local value only when fork capture proves a fresh child + * needs it. This is shared by raw externrefs and by exceptions that every + * activation-local exact-tag codec has declined. + * + * Before that point the value remains exact, preserving ordinary host-import + * and exception behavior in the parent. + */ + normalizeUnclaimedForkValue(value: unknown): ForkExternrefToken { + const existingHandle = this.tokens.encode(value); + if (existingHandle !== null) return value as ForkExternrefToken; + const cached = this.cachedToken(value); + if (cached) return cached; + try { + const token = this.normalize( + FORK_WORKER_EXCEPTION_FORK_CAPTURE_ORDINAL, + value, + ); + this.rememberToken(value, token); + return token; + } catch (error) { + try { + this.options.onFatal?.( + error, + FORK_WORKER_EXCEPTION_FORK_CAPTURE_ORDINAL, + ); + } catch { + // Diagnostics cannot replace the capture failure. + } + throw error; + } + } + + normalizeUnclaimedForkException(thrown: unknown): ForkExternrefToken { + return this.normalizeUnclaimedForkValue(thrown); + } + + private replaceThrown( + _sourceImportOrdinal: number, + thrown: unknown, + ): never { + if (thrown instanceof WebAssembly.RuntimeError) { + // A nested Wasm call can surface a trap as a RuntimeError in this JS + // frame. Re-entering Wasm by throwing that JS object would turn it into + // a catchable JSTag exception, so preserve trap semantics explicitly. + return fatalTrap(); + } + // WHY: eager normalization would change ordinary CatchAllRef/rethrow + // behavior even when fork is never called. The broker normalizes only if + // this exact value remains live at fork and no activation codec owns it. + throw thrown; + } + + private normalize( + sourceImportOrdinal: number, + thrown: unknown, + ): ForkExternrefToken { + const recipe = describeThrown(thrown); + let sessionId: number | undefined; + try { + sessionId = this.caller.call( + FORK_WORKER_EXCEPTION_BEGIN_DESCRIPTOR, + [ + FORK_WORKER_EXCEPTION_RECIPE_VERSION, + sourceImportOrdinal, + recipe.kind, + recipe.flags, + recipe.scalarBits, + recipe.fields[0].length, + recipe.fields[1].length, + ], + ) as number; + for (let field = 0; field < recipe.fields.length; field++) { + const value = recipe.fields[field]!; + for (let offset = 0; offset < value.length; offset += CHUNK_CODE_UNITS) { + this.caller.call( + FORK_WORKER_EXCEPTION_CHUNK_DESCRIPTOR, + [ + sessionId, + field, + offset, + ...packCodeUnits(value, offset), + ] as ForkExternrefImportValue[], + ); + } + } + const token = this.caller.call( + FORK_WORKER_EXCEPTION_COMMIT_DESCRIPTOR, + [sessionId], + ); + const handle = this.tokens.encode(token); + if (handle === null) { + throw new Error( + "Worker exception owner returned a noncanonical externref token", + ); + } + sessionId = undefined; + return token as ForkExternrefToken; + } catch (error) { + if (sessionId !== undefined) { + try { + this.caller.call( + FORK_WORKER_EXCEPTION_ABORT_DESCRIPTOR, + [sessionId], + ); + } catch { + // Preserve the original normalization failure. Endpoint teardown + // clears any scalar-only abandoned session. + } + } + throw error; + } + } + + private cachedToken(thrown: unknown): ForkExternrefToken | undefined { + if ( + (typeof thrown === "object" && thrown !== null) + || typeof thrown === "function" + ) { + return this.objectTokens.get(thrown as object); + } + if (typeof thrown === "symbol") { + return this.symbolTokens.get(thrown); + } + return undefined; + } + + private rememberToken( + thrown: unknown, + token: ForkExternrefToken, + ): void { + if ( + (typeof thrown === "object" && thrown !== null) + || typeof thrown === "function" + ) { + this.objectTokens.set(thrown as object, token); + } else if (typeof thrown === "symbol") { + this.symbolTokens.set(thrown, token); + } + } +} diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index 48ead340a5..9b8fffd6be 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -43,6 +43,8 @@ import { CH_DATA, CH_DATA_SIZE, CH_ERRNO, + CH_REQUEST_FLAGS, + CH_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY, CH_RETURN, CH_SIG_BASE, CH_SIG_FLAGS, @@ -5001,6 +5003,21 @@ export class CentralizedKernelWorker { * handler signal number, or zero when no caught handler was dequeued. */ private dequeueSignalForDelivery(channel: ChannelInfo): number { + const requestFlags = new DataView( + channel.memory.buffer, + channel.channelOffset, + ).getUint32(CH_REQUEST_FLAGS, true); + if ( + (requestFlags & CH_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY) !== 0 + ) { + // WHY: process-worker JavaScript consumes this completion outside + // libc's post-syscall signal trampoline. Dequeuing here would consume + // the kernel signal and block it for a handler that this completion can + // never invoke. Leave it pending for the explicit guest checkpoint after + // the owning fork, clone, or staged-loader transition. + return 0; + } + const preparedSignals = this.resumePreparedSignals; if (preparedSignals?.has(channel)) { const existingSignal = new DataView( diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts index b051c02fd8..c102cefd75 100644 --- a/host/src/node-kernel-worker-entry.ts +++ b/host/src/node-kernel-worker-entry.ts @@ -56,7 +56,6 @@ import { DeferredWorkerHandle } from "./deferred-worker-handle"; import { ThreadPageAllocator } from "./thread-allocator"; import { patchWasmForThread } from "./worker-main"; import { ThreadExitCoordinator } from "./thread-exit-coordinator"; -import { readForkContinuationAnchor } from "./fork-continuation"; import { describeWasmArtifactPolicyFailures, detectPtrWidth, @@ -85,6 +84,16 @@ import { import { RootfsSnapshotGate } from "./rootfs-snapshot-gate"; import { reapHostOwnedExitedProcess } from "./host-owned-process-reap"; import { uninitializedKernelPipeResult } from "./kernel-pipe-transport"; +import { + ForkReplayGateCoordinator, + observeForkReplayWorker, +} from "./fork-replay-gate"; +import { ForkExternrefProcessOwner } from "./fork-externref-process-owner"; +import type { ForkExternrefGeneration } from "./fork-reference-broker"; +import { + ForkHostImportOwnerRuntime, + type ForkHostImportOwnerWorker, +} from "./fork-host-import-runtime"; import { acquireForkMemoryClone, computeProcessMemoryLayout, @@ -195,10 +204,17 @@ interface ProcessInfo extends ProcessGenerationOwnership { ptrWidth: 4 | 8; layout: ProcessMemoryLayout; threadAllocator: ThreadPageAllocator; + /** Exact broker authority for this PID's current Wasm image. */ + externrefGeneration: ForkExternrefGeneration; /** Non-_start continuation root inherited from a pthread fork until exec. */ forkReplayContext?: ForkReplayContext; } const processes = new Map(); +const externrefProcessOwner = new ForkExternrefProcessOwner(); +const forkHostImportOwnerRuntime = + new ForkHostImportOwnerRuntime(externrefProcessOwner); +const forkHostImportsByWorker = + new WeakMap(); const processTeardowns = new Map>(); const vmInterruptTimers = new VmInterruptTimerManager( (pid) => processes.get(pid), @@ -286,6 +302,8 @@ function installProcessWorkerListeners( && message.pid === pid ) { handleVmInterruptTimer(message, pid, process); + } else if (message.type === "fork_host_import") { + dispatchForkHostImport(worker, message); } }); installCrashSafetyNet(worker, pid); @@ -311,9 +329,33 @@ async function terminateTrackedWorker( worker: ReturnType, ): Promise { intentionallyTerminated.add(worker as object); + forkHostImportsByWorker.get(worker as object)?.close(); await worker.terminate().catch(() => {}); } +function bindForkHostImports( + worker: ReturnType, + owner: ForkHostImportOwnerWorker, +): void { + forkHostImportsByWorker.set(worker as object, owner); +} + +function dispatchForkHostImport( + worker: ReturnType, + message: Extract, +): void { + const owner = forkHostImportsByWorker.get(worker as object); + if (!owner || !owner.dispatch(message.wake)) { + reportHostDiagnostic({ + pid: message.wake.pid, + source: "fork host-import protocol", + message: + `[kernel-worker] ignored stale or unbound fork host-import wake ` + + `pid=${message.wake.pid} sender=${message.wake.senderId}`, + }, "warn"); + } +} + async function terminateThreadWorkers( pid: number, requireExecRetirement = false, @@ -987,6 +1029,8 @@ async function handleSpawn(msg: SpawnMessage) { let workerCreationAttempted = false; let createdWorker: ProcessInfo["worker"] | undefined; let createdGeneration: ProcessInfo | undefined; + let createdExternrefGeneration: ForkExternrefGeneration | undefined; + let createdForkHostImports: ForkHostImportOwnerWorker | undefined; try { releaseMutation = rootfsSnapshotGate.beginMutation("spawn a process"); const hasProgramBytes = msg.programBytes !== undefined; @@ -1063,6 +1107,24 @@ async function handleSpawn(msg: SpawnMessage) { } } + const externrefGeneration = externrefProcessOwner.startGeneration(pid); + createdExternrefGeneration = externrefGeneration; + let worker: ReturnType; + const forkHostImports = forkHostImportOwnerRuntime.createWorker({ + pid, + generationId: externrefGeneration.id, + authorizeSender: () => { + const current = processes.get(pid); + if ( + !current + || current.worker !== worker + || current.externrefGeneration !== externrefGeneration + ) { + throw new Error(`stale fork host-import sender for pid=${pid}`); + } + }, + }); + createdForkHostImports = forkHostImports; const initData: CentralizedWorkerInitMessage = { type: "centralized_init", pid, @@ -1070,6 +1132,8 @@ async function handleSpawn(msg: SpawnMessage) { programModule, memory, channelOffset, + externrefGenerationId: externrefGeneration.id, + forkHostImports: forkHostImports.init, env: msg.env, argv: msg.argv, ptrWidth, @@ -1079,8 +1143,9 @@ async function handleSpawn(msg: SpawnMessage) { // A constructor may expose Memory to a partially created Worker before it // throws, so any failure from this point uses forced retirement. workerCreationAttempted = true; - const worker = workerAdapter.createWorker(initData); + worker = workerAdapter.createWorker(initData); createdWorker = worker; + bindForkHostImports(worker, forkHostImports); createdGeneration = { memory, memoryLease, @@ -1093,15 +1158,22 @@ async function handleSpawn(msg: SpawnMessage) { ptrWidth, layout, threadAllocator, + externrefGeneration, }; processes.set(pid, createdGeneration); installProcessWorkerListeners(worker, pid); createdMemoryLease = undefined; createdPid = undefined; + createdExternrefGeneration = undefined; + createdForkHostImports = undefined; respond(msg.requestId, pid); } catch (e) { + createdForkHostImports?.close(); + if (createdExternrefGeneration) { + externrefProcessOwner.releaseGeneration(createdExternrefGeneration); + } if (createdPid !== undefined) { if (createdWorker) await terminateTrackedWorker(createdWorker); const lease = createdGeneration?.memoryLease ?? createdMemoryLease; @@ -1177,6 +1249,11 @@ async function handleFork( let workerStartAttempted = false; let lifecycleTeardownStarted = false; let childGeneration: ProcessInfo | undefined; + let childExternrefGeneration: ForkExternrefGeneration | undefined; + let childForkHostImports: ForkHostImportOwnerWorker | undefined; + const forkReplay = new ForkReplayGateCoordinator( + `fork child pid=${childPid}`, + ); try { // The committed child already owns its exact syscall-time snapshot. // Delay only Worker launch while a short retirement burst drains. @@ -1216,7 +1293,33 @@ async function handleFork( ? { ...parentInfo.forkReplayContext, forkBufAddr: activeForkBufAddr } : undefined; const forkBufAddr = activeForkBufAddr; - + const externrefGrant = + externrefProcessOwner.forkGenerationFromContinuation( + parentInfo.externrefGeneration, + childPid, + parentMemory, + ptrWidth, + forkBufAddr, + ); + childExternrefGeneration = externrefGrant.generation; + let launchedWorker: DeferredWorkerHandle; + const forkHostImports = forkHostImportOwnerRuntime.createWorker({ + pid: childPid, + generationId: externrefGrant.generation.id, + authorizeSender: () => { + const current = processes.get(childPid); + if ( + !current + || current.worker !== launchedWorker + || current.externrefGeneration !== externrefGrant.generation + ) { + throw new Error( + `stale fork host-import sender for child pid=${childPid}`, + ); + } + }, + }); + childForkHostImports = forkHostImports; const childInitData: CentralizedWorkerInitMessage = { type: "centralized_init", pid: childPid, @@ -1224,8 +1327,11 @@ async function handleFork( programModule: parentInfo.programModule, memory: childMemory, channelOffset: childChannelOffset, + externrefGenerationId: externrefGrant.generation.id, + forkHostImports: forkHostImports.init, isForkChild: true, forkBufAddr, + forkReplayGate: forkReplay.gate, forkChildThreadFnPtr: forkReplayContext?.fnPtr, forkChildThreadArgPtr: forkReplayContext?.argPtr, ptrWidth, @@ -1236,6 +1342,8 @@ async function handleFork( () => workerAdapter.createWorker(childInitData), ); const worker = childWorker; + launchedWorker = worker; + bindForkHostImports(worker, forkHostImports); childGeneration = { memory: childMemory, memoryLease: childMemoryLease, @@ -1249,9 +1357,16 @@ async function handleFork( layout: childLayout, threadAllocator: threadAllocatorForLayout(childLayout, ptrWidth, childPid), forkReplayContext, + externrefGeneration: externrefGrant.generation, }; processes.set(childPid, childGeneration); + observeForkReplayWorker( + forkReplay, + launchedWorker, + childPid, + () => processes.get(childPid)?.worker === launchedWorker, + ); installProcessWorkerListeners(worker, childPid); const startDisposition = kernelWorker.startProcessWorkerWhenRunnable( childPid, @@ -1260,13 +1375,25 @@ async function handleFork( workerStartAttempted = true; worker.start(); }, - () => { void worker.terminate(); }, + () => { + forkReplay.cancel( + new Error( + `Fork child ${childPid} launch was cancelled before replay readiness`, + ), + ); + forkHostImports.close(); + void launchedWorker.terminate(); + }, ); if (startDisposition === "stale") { throw new Error(`Fork child ${childPid} changed generation before Worker launch`); } if (startDisposition === "dead") { - await worker.terminate(); + forkReplay.cancel( + new Error(`Fork child ${childPid} exited before Worker launch`), + ); + forkHostImports.close(); + await terminateTrackedWorker(worker); processes.get(childPid)?.workerQuiescence.settle(); const signal = kernelWorker.finalizePendingChildTermination(childPid); lifecycleTeardownStarted = true; @@ -1277,9 +1404,27 @@ async function handleFork( ); return []; } + await forkReplay.waitUntilReady(); + if (processes.get(childPid)?.worker !== launchedWorker) { + throw new Error( + `Fork child ${childPid} changed generation before replay commit`, + ); + } + if (!kernelWorker.shouldLaunchPendingChild(childPid)) { + throw new Error(`Fork child ${childPid} exited before replay commit`); + } + // WHY: only this commit wakes the child inside the inherited fork import. + // Resolve onFork afterward so the parent cannot observe a child whose + // continuation has not proved it reached the copied activation. + forkReplay.commit(); } catch (error) { if (lifecycleTeardownStarted) throw error; + forkReplay.cancel(error); + childForkHostImports?.close(); if (childWorker) await terminateTrackedWorker(childWorker); + if (childExternrefGeneration) { + externrefProcessOwner.releaseGeneration(childExternrefGeneration); + } const generation = childGeneration ?? { memory: childMemory, memoryLease: childMemoryLease, @@ -1370,6 +1515,8 @@ async function handleExec( return addressSpaceResult; } let replacementWorker: ReturnType | undefined; + let replacementExternrefGeneration: ForkExternrefGeneration | undefined; + let replacementForkHostImports: ForkHostImportOwnerWorker | undefined; try { const setupResult = kernelWorker.kernelExecSetup(pid, callerTid); if (setupResult < 0) { @@ -1401,6 +1548,9 @@ async function handleExec( if (!kernelWorker.prepareProcessForExec(pid, initiatingInfo.memory)) { throw new Error(`Exec pid ${pid} changed generation during commit`); } + replacementExternrefGeneration = externrefProcessOwner.replaceGeneration( + initiatingInfo.externrefGeneration, + ); const finalizeResult = kernelWorker.finalizeAddressSpaceForExec(pid); if (finalizeResult < 0) { @@ -1436,6 +1586,10 @@ async function handleExec( if (handoffExitSignal > 0) { prepared.memoryLease.release(); preparedLeaseConsumed = true; + externrefProcessOwner.releaseGeneration( + replacementExternrefGeneration, + ); + replacementExternrefGeneration = undefined; await awaitFinalizedProcessTeardown( pid, signalExitStatus(handoffExitSignal), @@ -1451,6 +1605,21 @@ async function handleExec( threadAllocator: newThreadAllocator, } = prepared; const newChannelOffset = newLayout.channelOffset; + replacementForkHostImports = forkHostImportOwnerRuntime.createWorker({ + pid, + generationId: replacementExternrefGeneration.id, + authorizeSender: () => { + const current = processes.get(pid); + if ( + !replacementWorker + || !current + || current.worker !== replacementWorker + || current.externrefGeneration !== replacementExternrefGeneration + ) { + throw new Error(`stale fork host-import sender for exec pid=${pid}`); + } + }, + }); const initData: CentralizedWorkerInitMessage = { type: "centralized_init", @@ -1459,6 +1628,8 @@ async function handleExec( programModule, memory: newMemory, channelOffset: newChannelOffset, + externrefGenerationId: replacementExternrefGeneration.id, + forkHostImports: replacementForkHostImports.init, argv: launchArgv, env: envp, ptrWidth: newPtrWidth, @@ -1481,6 +1652,7 @@ async function handleExec( env: envp, }); replacementRegistered = true; + bindForkHostImports(replacementWorker, replacementForkHostImports); // Clear thread module cache — new program binary is different threadModuleCache.delete(pid); @@ -1497,6 +1669,7 @@ async function handleExec( ptrWidth: newPtrWidth, layout: newLayout, threadAllocator: newThreadAllocator, + externrefGeneration: replacementExternrefGeneration, }); preparedTransferred = true; @@ -1516,13 +1689,17 @@ async function handleExec( pid, newMemory, () => { (replacementWorker as DeferredWorkerHandle).start(); }, - () => { void replacementWorker?.terminate(); }, + () => { + replacementForkHostImports?.close(); + void replacementWorker?.terminate(); + }, ); if (startDisposition === "stale") { throw new Error(`Exec pid ${pid} changed generation before Worker launch`); } if (startDisposition === "dead") { - await replacementWorker.terminate(); + replacementForkHostImports.close(); + await terminateTrackedWorker(replacementWorker); kernelWorker.finishProcessExecHandoff(pid); const signal = kernelWorker.finalizeExecHandoffTermination(pid); await awaitFinalizedProcessTeardown( @@ -1535,6 +1712,11 @@ async function handleExec( kernelWorker.finishProcessExecHandoff(pid); return 0; } catch (err) { + replacementForkHostImports?.close(); + if (replacementExternrefGeneration) { + externrefProcessOwner.releaseGeneration(replacementExternrefGeneration); + replacementExternrefGeneration = undefined; + } // A kernel trap can leave the commit point uncertain. We cannot safely // return to the caller, so invalidate the old generation before yielding // and report a truthful signal death. @@ -1684,6 +1866,8 @@ async function handlePosixSpawn( let workerStartAttempted = false; let lifecycleTeardownStarted = false; let childGeneration: ProcessInfo | undefined; + let externrefGeneration: ForkExternrefGeneration | undefined; + let forkHostImports: ForkHostImportOwnerWorker | undefined; try { // The kernel already created the child Process via kernel_spawn_process. kernelWorker.registerProcess(childPid, memory, [channelOffset], { @@ -1694,6 +1878,26 @@ async function handlePosixSpawn( }); registered = true; + externrefGeneration = externrefProcessOwner.startGeneration(childPid); + const processExternrefGeneration = externrefGeneration; + const processForkHostImports = forkHostImportOwnerRuntime.createWorker({ + pid: childPid, + generationId: processExternrefGeneration.id, + authorizeSender: () => { + const current = processes.get(childPid); + if ( + !newWorker + || !current + || current.worker !== newWorker + || current.externrefGeneration !== processExternrefGeneration + ) { + throw new Error( + `stale fork host-import sender for spawn pid=${childPid}`, + ); + } + }, + }); + forkHostImports = processForkHostImports; const initData: CentralizedWorkerInitMessage = { type: "centralized_init", pid: childPid, @@ -1701,6 +1905,8 @@ async function handlePosixSpawn( programModule, memory, channelOffset, + externrefGenerationId: processExternrefGeneration.id, + forkHostImports: processForkHostImports.init, argv, env: envp, ptrWidth, @@ -1711,6 +1917,7 @@ async function handlePosixSpawn( () => workerAdapter.createWorker(initData), ); const worker = newWorker; + bindForkHostImports(worker, processForkHostImports); childGeneration = { memory, memoryLease, @@ -1723,6 +1930,7 @@ async function handlePosixSpawn( ptrWidth, layout, threadAllocator, + externrefGeneration: processExternrefGeneration, }; processes.set(childPid, childGeneration); @@ -1738,13 +1946,17 @@ async function handlePosixSpawn( workerStartAttempted = true; worker.start(); }, - () => { void worker.terminate(); }, + () => { + processForkHostImports.close(); + void worker.terminate(); + }, ); if (startDisposition === "stale") { throw new Error(`Spawn child ${childPid} changed generation before Worker launch`); } if (startDisposition === "dead") { - await worker.terminate(); + processForkHostImports.close(); + await terminateTrackedWorker(worker); processes.get(childPid)?.workerQuiescence.settle(); const signal = kernelWorker.finalizePendingChildTermination(childPid); lifecycleTeardownStarted = true; @@ -1758,6 +1970,10 @@ async function handlePosixSpawn( } catch (error) { if (lifecycleTeardownStarted) throw error; if (newWorker) await terminateTrackedWorker(newWorker); + forkHostImports?.close(); + if (externrefGeneration) { + externrefProcessOwner.releaseGeneration(externrefGeneration); + } const generation = childGeneration ?? { memory, memoryLease }; const detachResult = await detachExactProcessGeneration({ pid: childPid, @@ -1838,6 +2054,25 @@ async function handleClone( throw err; } + let threadWorker: DeferredWorkerHandle; + let threadEntry: ThreadWorkerInfo; + const forkHostImports = forkHostImportOwnerRuntime.createWorker({ + pid, + generationId: processInfo.externrefGeneration.id, + authorizeSender: () => { + const entries = threadWorkers.get(pid); + if ( + !belongsToCurrentProcessImage() + || !threadEntry + || threadEntry.worker !== threadWorker + || !entries?.includes(threadEntry) + ) { + throw new Error( + `stale fork host-import sender for pid=${pid} tid=${tid}`, + ); + } + }, + }); const threadInitData: CentralizedThreadInitMessage = { type: "centralized_thread_init", pid, @@ -1847,6 +2082,8 @@ async function handleClone( memory, processChannelOffset: processInfo.channelOffset, channelOffset: alloc.channelOffset, + externrefGenerationId: processInfo.externrefGeneration.id, + forkHostImports: forkHostImports.init, fnPtr, argPtr, stackPtr, @@ -1858,11 +2095,12 @@ async function handleClone( kernelAbiVersion: kernelWorker.getKernelAbiVersion(), }; - const threadWorker = new DeferredWorkerHandle( + threadWorker = new DeferredWorkerHandle( () => workerAdapter.createWorker(threadInitData), ); + bindForkHostImports(threadWorker, forkHostImports); if (!threadWorkers.has(pid)) threadWorkers.set(pid, []); - const threadEntry: ThreadWorkerInfo = { + threadEntry = { worker: threadWorker, channelOffset: alloc.channelOffset, tid, @@ -1951,6 +2189,8 @@ async function handleClone( if (isCurrentThreadGeneration() && m.pid === pid) { handleVmInterruptTimer(m, pid, processInfo); } + } else if (m.type === "fork_host_import") { + dispatchForkHostImport(threadWorker, m); } }); threadWorker.on("error", (err: Error) => failThread(`worker error: ${err.message ?? err}`)); @@ -1963,7 +2203,10 @@ async function handleClone( pid, memory, () => { threadWorker.start(); }, - () => { void threadWorker.terminate(); }, + () => { + forkHostImports.close(); + void threadWorker.terminate(); + }, () => { kernelWorker.finalizeThreadExit(pid, tid, alloc.channelOffset); const failedClone = kernelWorker.failDeferredCloneLaunch(pid, tid, 12); @@ -2062,6 +2305,8 @@ async function finishProcessExit( return; } + externrefProcessOwner.releaseGeneration(info.externrefGeneration); + // A superseded old image must not reap the persistent PID that now belongs // to its exec successor. if (!detachResult.mayReapPid) return; @@ -2105,6 +2350,7 @@ async function handleTerminate(msg: TerminateProcessMessage) { if (threads) { for (const t of threads) { intentionallyTerminated.add(t.worker as object); + forkHostImportsByWorker.get(t.worker as object)?.close(); await t.worker.terminate().catch(() => {}); try { kernelWorker.notifyThreadExit(pid, t.tid); @@ -2118,6 +2364,9 @@ async function handleTerminate(msg: TerminateProcessMessage) { if (info?.worker) { await terminateTrackedWorker(info.worker); } + if (info) { + externrefProcessOwner.releaseGeneration(info.externrefGeneration); + } if (info) { const detachResult = await detachExactProcessGeneration({ @@ -2209,6 +2458,7 @@ async function performDestroy() { terminateThreadWorkers(pid), ]); await terminateTrackedWorker(info.worker); + externrefProcessOwner.releaseGeneration(info.externrefGeneration); const detachResult = await detachExactProcessGeneration({ pid, generation: info, @@ -2252,6 +2502,7 @@ async function performDestroy() { for (const threads of threadWorkers.values()) { for (const t of threads) { intentionallyTerminated.add(t.worker as object); + forkHostImportsByWorker.get(t.worker as object)?.close(); t.worker.terminate().catch(() => {}); } } diff --git a/host/src/worker-main.ts b/host/src/worker-main.ts index 444888449e..f34d212ded 100644 --- a/host/src/worker-main.ts +++ b/host/src/worker-main.ts @@ -20,9 +20,15 @@ import { readForkInstrumentCapabilityClaim, requireCppExceptionTag, requireLongjmpTag, + type DylinkForkActivationOwner, + type DylinkForkState, type LoadedSharedLibrary, - type SideModuleForkState, } from "./dylink"; +import { + DylinkForkArchive, + DylinkForkTableReplica, + type DylinkForkArchiveSnapshot, +} from "./dylink-fork-archive"; import { describeWasmArtifactPolicyFailures, extractAbiVersion, @@ -36,6 +42,8 @@ import { CH_ARGS, CH_DATA, CH_ERRNO, + CH_REQUEST_FLAGS, + CH_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY, CH_RETURN, CH_SIG_BASE, CH_SIG_SIGNUM, @@ -43,6 +51,10 @@ import { CH_SYSCALL, CH_TOTAL_SIZE, HOST_INTERCEPTED_SYSCALLS, + WPK_FORK_EXPORT_MODULE_THREAD_BOOTSTRAP, + WPK_FORK_MODULE_STATE_IMPORT_RECORD_COMMIT, + WPK_FORK_MODULE_STATE_IMPORT_RECORD_FIND, + WPK_FORK_MODULE_STATE_IMPORT_RECORD_RESERVE, WPK_FORK_REQUIRED_EXPORTS, WPK_FORK_REQUIRED_IMPORTS, WPK_FORK_CAP_ACTIVATION_STATE_SAFE, @@ -58,6 +70,61 @@ import { readLinkedFrameFormat, writeForkContinuationAnchor, } from "./fork-continuation"; +import { + createForkUnwindTag, + FORK_UNWIND_TAG_IMPORT_MODULE, + FORK_UNWIND_TAG_IMPORT_NAME, + isForkUnwindException, + requireForkUnwindTag, +} from "./fork-unwind-transport"; +import { waitForForkReplayCommit } from "./fork-replay-gate"; +import { + computeForkModuleTemplateId, + computeForkModuleTemplateIdSync, + ForkModuleStateArena, + readForkModuleStateDescriptor, + readForkModuleStateRoot, +} from "./fork-module-state"; +import { + buildForkActivationStateImports, + ForkActivationRegistry, + forkActivationRegistrationFromInstance, + type ForkActivationTableReplication, + type ForkActivationReferenceReplayImports, + type ForkActivationRegistration, +} from "./fork-activation-registry"; +import { + buildForkExceptionImports, + ForkExceptionBroker, + forkExceptionProviderFromInstance, + readForkExceptionCodecDescriptor, + type ForkExceptionReferenceReplayImports, + type ForkExceptionProvider, +} from "./fork-exception-provider"; +import { ForkEarlyChildReferenceProvider } from "./fork-early-reference-provider"; +import { + decodeSegmentedForkReferenceTransaction, + type DecodedSegmentedForkReferenceTransaction, +} from "./fork-reference-segments"; +import { FORK_REFERENCE_TRANSACTION_OWNER_ID } from "./fork-reference-transaction"; +import { + forkGcCodecProviderFromInstance, + readForkGcCodecDescriptor, + type ForkGcCodecProvider, +} from "./fork-gc-codec"; +import { ForkProcessContinuationCoordinator } from "./fork-process-continuation"; +import { forkResumeTargetsFromInstance } from "./fork-resume-catalog"; +import { + ForkExternrefTokenCache, + ForkExternrefTokenRecipeProvider, +} from "./fork-reference-broker"; +import { ForkHostImportWorkerRuntime } from "./fork-host-import-runtime"; +import { + ForkImportedGlobalCapture, + ForkImportedGlobalPlanner, + type ForkWasmImports, + type PreparedForkParentActivation, +} from "./fork-imported-globals"; import { checkedWasmGuestPointerOffset, type WasmGuestPointer, @@ -86,6 +153,24 @@ const CH_SIG_SI_CODE = CH_SIG_BASE + 24; class ExecRetirement extends Error {} +function markDeferredSignalDelivery( + view: DataView, + channelOffset: number, +): void { + view.setUint32( + channelOffset + CH_REQUEST_FLAGS, + CH_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY, + true, + ); +} + +function clearDeferredSignalDelivery( + view: DataView, + channelOffset: number, +): void { + view.setUint32(channelOffset + CH_REQUEST_FLAGS, 0, true); +} + function continuationMmap( memory: WebAssembly.Memory, channelOffset: number, @@ -97,19 +182,33 @@ function continuationMmap( view.setInt32(base + CH_SYSCALL, SYS_MMAP_NR, true); view.setBigInt64(base + CH_ARGS + 0 * CH_ARG_SIZE, 0n, true); view.setBigInt64(base + CH_ARGS + 1 * CH_ARG_SIZE, BigInt(size), true); - view.setBigInt64(base + CH_ARGS + 2 * CH_ARG_SIZE, BigInt(PROT_READ_WRITE), true); - view.setBigInt64(base + CH_ARGS + 3 * CH_ARG_SIZE, BigInt(MAP_PRIVATE_ANONYMOUS), true); + view.setBigInt64( + base + CH_ARGS + 2 * CH_ARG_SIZE, + BigInt(PROT_READ_WRITE), + true, + ); + view.setBigInt64( + base + CH_ARGS + 3 * CH_ARG_SIZE, + BigInt(MAP_PRIVATE_ANONYMOUS), + true, + ); view.setBigInt64(base + CH_ARGS + 4 * CH_ARG_SIZE, -1n, true); view.setBigInt64(base + CH_ARGS + 5 * CH_ARG_SIZE, 0n, true); + markDeferredSignalDelivery(view, base); let i32 = new Int32Array(memory.buffer); Atomics.store(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING); Atomics.notify(i32, (base + CH_STATUS) / 4, 1); - while (Atomics.wait(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING) === "ok") { /* */ } + while ( + Atomics.wait(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING) === "ok" + ) { + /* */ + } view = new DataView(memory.buffer); i32 = new Int32Array(memory.buffer); const result = Number(view.getBigInt64(base + CH_RETURN, true)); const err = view.getUint32(base + CH_ERRNO, true); + clearDeferredSignalDelivery(view, base); Atomics.store(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_IDLE); if (err || result < 0) { const errno = err || -result; @@ -137,17 +236,25 @@ function continuationMunmap( for (let i = 2; i < 6; i++) { view.setBigInt64(base + CH_ARGS + i * CH_ARG_SIZE, 0n, true); } + markDeferredSignalDelivery(view, base); const i32 = new Int32Array(memory.buffer); Atomics.store(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING); Atomics.notify(i32, (base + CH_STATUS) / 4, 1); - while (Atomics.wait(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING) === "ok") { /* */ } + while ( + Atomics.wait(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING) === "ok" + ) { + /* */ + } const resultView = new DataView(memory.buffer); const resultI32 = new Int32Array(memory.buffer); const result = Number(resultView.getBigInt64(base + CH_RETURN, true)); const err = resultView.getUint32(base + CH_ERRNO, true); + clearDeferredSignalDelivery(resultView, base); Atomics.store(resultI32, (base + CH_STATUS) / 4, CHANNEL_STATUS_IDLE); if (err || result < 0) { - throw new Error(`${label}: munmap(0x${addr.toString(16)}, ${size}) failed errno=${err || -result}`); + throw new Error( + `${label}: munmap(0x${addr.toString(16)}, ${size}) failed errno=${err || -result}`, + ); } } @@ -175,35 +282,53 @@ function buildKernelImports( const _envVars = envVars || []; const encoder = new TextEncoder(); /** Convert wasm64 BigInt pointer to number (safe since addresses < 4GB) */ - const n = (v: number | bigint): number => typeof v === "bigint" ? Number(v) : v; + const n = (v: number | bigint): number => + typeof v === "bigint" ? Number(v) : v; return { // CRT argv support kernel_get_argc: (): number => _argv.length, - kernel_argv_read: (index: number, bufPtr: number | bigint, bufMax: number): number => { + kernel_argv_read: ( + index: number, + bufPtr: number | bigint, + bufMax: number, + ): number => { if (index >= _argv.length) return 0; const encoded = encoder.encode(_argv[index]); const len = Math.min(encoded.length, bufMax); - new Uint8Array(memory.buffer, n(bufPtr), len).set(encoded.subarray(0, len)); + new Uint8Array(memory.buffer, n(bufPtr), len).set( + encoded.subarray(0, len), + ); return len; }, // CRT environ support kernel_environ_count: (): number => _envVars.length, - kernel_environ_get: (index: number, bufPtr: number | bigint, bufMax: number): number => { + kernel_environ_get: ( + index: number, + bufPtr: number | bigint, + bufMax: number, + ): number => { if (index >= _envVars.length) return -1; const encoded = encoder.encode(_envVars[index]); const len = Math.min(encoded.length, bufMax); - new Uint8Array(memory.buffer, n(bufPtr), len).set(encoded.subarray(0, len)); + new Uint8Array(memory.buffer, n(bufPtr), len).set( + encoded.subarray(0, len), + ); return len; }, // Fork/exec state — not a fork child. kernel_is_fork_child: (): number => 0, kernel_apply_fork_fd_actions: (): number => 0, - kernel_get_fork_exec_path: (_buf: number | bigint, _max: number): number => 0, + kernel_get_fork_exec_path: (_buf: number | bigint, _max: number): number => + 0, kernel_get_fork_exec_argc: (): number => 0, - kernel_get_fork_exec_argv: (_index: number, _buf: number | bigint, _max: number): number => 0, + kernel_get_fork_exec_argv: ( + _index: number, + _buf: number | bigint, + _max: number, + ): number => 0, kernel_push_argv: (_ptr: number | bigint, _len: number): void => {}, kernel_clear_fork_exec: (): number => 0, @@ -233,7 +358,12 @@ function buildKernelImports( Atomics.notify(i32, (base + CH_STATUS) / 4, 1); // Wait until the reusable kernel transaction returns and the host has // committed the exit before terminating this disposable process Worker. - while (Atomics.wait(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING) === "ok") { /* */ } + while ( + Atomics.wait(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING) === + "ok" + ) { + /* */ + } onKernelExit?.(status); // WHY: this trap belongs at the disposable guest-Worker boundary, not in // the reusable kernel Wasm. It enforces `_Noreturn` even if a caller was @@ -244,14 +374,25 @@ function buildKernelImports( }, // Clone dispatches through channel (SYS_CLONE) - kernel_clone: (fnPtr: number | bigint, stackPtr: number | bigint, flags: number, - arg: number | bigint, ptidPtr: number | bigint, tlsPtr: number | bigint, ctidPtr: number | bigint): number => { + kernel_clone: ( + fnPtr: number | bigint, + stackPtr: number | bigint, + flags: number, + arg: number | bigint, + ptidPtr: number | bigint, + tlsPtr: number | bigint, + ctidPtr: number | bigint, + ): number => { const SYS_CLONE_NR = ABI_SYSCALLS.Clone; const view = new DataView(memory.buffer); const base = channelOffset; view.setInt32(base + CH_SYSCALL, SYS_CLONE_NR, true); view.setBigInt64(base + CH_ARGS + 0 * CH_ARG_SIZE, BigInt(flags), true); - view.setBigInt64(base + CH_ARGS + 1 * CH_ARG_SIZE, BigInt(stackPtr), true); + view.setBigInt64( + base + CH_ARGS + 1 * CH_ARG_SIZE, + BigInt(stackPtr), + true, + ); view.setBigInt64(base + CH_ARGS + 2 * CH_ARG_SIZE, BigInt(ptidPtr), true); view.setBigInt64(base + CH_ARGS + 3 * CH_ARG_SIZE, BigInt(tlsPtr), true); view.setBigInt64(base + CH_ARGS + 4 * CH_ARG_SIZE, BigInt(ctidPtr), true); @@ -260,13 +401,20 @@ function buildKernelImports( view.setUint32(base + CH_DATA, n(fnPtr), true); view.setUint32(base + CH_DATA + 4, n(arg), true); + markDeferredSignalDelivery(view, base); const i32 = new Int32Array(memory.buffer); Atomics.store(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING); Atomics.notify(i32, (base + CH_STATUS) / 4, 1); - while (Atomics.wait(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING) === "ok") { /* */ } + while ( + Atomics.wait(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING) === + "ok" + ) { + /* */ + } const result = Number(view.getBigInt64(base + CH_RETURN, true)); const err = view.getUint32(base + CH_ERRNO, true); + clearDeferredSignalDelivery(view, base); Atomics.store(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_IDLE); if (err) return -err; @@ -277,16 +425,28 @@ function buildKernelImports( kernel_fork: (): number => { const view = new DataView(memory.buffer); const base = channelOffset; - view.setInt32(base + CH_SYSCALL, HOST_INTERCEPTED_SYSCALLS.SYS_FORK, true); - for (let i = 0; i < 6; i++) view.setBigInt64(base + CH_ARGS + i * CH_ARG_SIZE, 0n, true); + view.setInt32( + base + CH_SYSCALL, + HOST_INTERCEPTED_SYSCALLS.SYS_FORK, + true, + ); + for (let i = 0; i < 6; i++) + view.setBigInt64(base + CH_ARGS + i * CH_ARG_SIZE, 0n, true); + markDeferredSignalDelivery(view, base); const i32 = new Int32Array(memory.buffer); Atomics.store(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING); Atomics.notify(i32, (base + CH_STATUS) / 4, 1); - while (Atomics.wait(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING) === "ok") { /* */ } + while ( + Atomics.wait(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING) === + "ok" + ) { + /* */ + } const result = Number(view.getBigInt64(base + CH_RETURN, true)); const err = view.getUint32(base + CH_ERRNO, true); + clearDeferredSignalDelivery(view, base); Atomics.store(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_IDLE); if (err) return -err; @@ -297,32 +457,421 @@ function buildKernelImports( export interface DlopenSupport { imports: Record; - /** Replay the parent's dlopen list (read from the archive in linear - * memory). No-op if the archive head pointer is 0. Call this in the - * fork-child path AFTER setupChannelBase and BEFORE the wpk_fork - * rewind into _start. */ - replayDlopens: () => void; - /** Finish the one active side-module unwind after the main image unwinds. */ - completeSideModuleForkUnwind: () => void; - /** Begin the active side-module rewind after fork-child dlopen replay. */ - beginSideModuleForkRewind: () => void; - /** Replay and discard active side-module frames after main allocation failure. */ - beginSideModuleForkAbort: (errno: number) => void; - /** Reject a leaked active side-module identity on a normal main return. */ - assertNoActiveSideModuleFork: () => void; + /** Validate and return the compact copied live-module closure. */ + readForkState: () => DylinkForkState; + /** Recreate the parent's live module and handle state from linear memory. */ + replayDlopens: (validatedState?: DylinkForkState) => void; /** Clear a fork parent's copied archive lock in the child's private memory. */ resetForkChildLock: () => void; + readonly archive: DylinkForkArchive; + /** Acquire one reentrant process-archive writer depth, blocking if needed. */ + acquireArchiveWriter(): void; + /** Release exactly one writer depth acquired by this Worker. */ + releaseArchiveWriter(): void; + /** Acquire one process-archive reader token, blocking behind a writer. */ + acquireArchiveReader(): void; + /** Release one reader token acquired by this Worker. */ + releaseArchiveReader(): void; + withArchiveWriter(operation: () => T): T; + withArchiveReader(operation: () => T): T; + writerOwned(): boolean; + /** Run after a fresh writer acquisition and before the protected operation. */ + setWriterAcquireObserver(observer: () => void): void; + /** Clean up state owned by a failed linker operation before releasing it. */ + setOperationAbortObserver(observer: () => void): void; + setCommitObserver( + observer: ( + linkerPublication: DylinkForkArchiveSnapshot | undefined, + tableMutationCommitted: boolean, + ) => void, + ): void; +} + +interface ProcessDylinkActivationOwnerOptions { + readonly memory: WebAssembly.Memory; + readonly ptrWidth: 4 | 8; + readonly channelOffset: number; + readonly forkUnwindTag: WebAssembly.Tag | undefined; + readonly coordinator: ForkProcessContinuationCoordinator; + readonly registry: ForkActivationRegistry; + readonly exceptionBroker: ForkExceptionBroker; + readonly importedStateCapture?: ForkImportedGlobalCapture; + readonly tableReplication?: ForkActivationTableReplication; + /** + * The child planner needs the copied dlopen archive, while the archive + * reader needs the activation owner installed first. Resolve it lazily at + * the actual side-module instantiation boundary to break that construction + * cycle without permitting a side activation to instantiate unplanned. + */ + readonly importedStatePlanner?: () => ForkImportedGlobalPlanner | null; + readonly referenceReplay?: () => ProcessReferenceReplayImports; + readonly registerChildReferenceActivation?: ( + activationId: number, + module: WebAssembly.Module, + registration: ForkActivationRegistration, + typedReferenceProvider: ForkGcCodecProvider, + ) => void; + readonly isForkChild: boolean; + /** + * A pthread owns a separate instance graph but adopts the process archive's + * stable activation coordinates. Unlike a fork child it captures live state + * and therefore uses the parent imported-state owner and bootstrap path. + */ + readonly isPthreadReplica?: boolean; + readonly invokeProcessFork: () => number; + readonly label: string; +} + +interface ProcessReferenceReplayImports + extends + ForkActivationReferenceReplayImports, + ForkExceptionReferenceReplayImports {} + +/** + * Bind every instrumented side-module instance to the one process + * continuation transaction. + * + * Activation IDs are monotonic in a parent and copied verbatim through the + * dlopen replay archive. They are coordinates in KFMS recipes and replay + * events, not reusable loader handles. + */ +function createProcessDylinkActivationOwner( + options: ProcessDylinkActivationOwnerOptions, +): DylinkForkActivationOwner { + let nextActivationId = 1; + const claimed = new Set(); + + const claimActivationId = ( + replayActivationId: number | undefined, + ): number => { + const activationId = replayActivationId ?? nextActivationId; + if ( + !Number.isInteger(activationId) || + activationId <= 0 || + activationId > 0xffff_ffff + ) { + throw new RangeError( + `${options.label}: side-module activation id ${String(activationId)} is invalid`, + ); + } + if (claimed.has(activationId)) { + throw new Error( + `${options.label}: side-module activation id ${activationId} was claimed twice`, + ); + } + claimed.add(activationId); + if (activationId >= nextActivationId) { + if (activationId === 0xffff_ffff) { + nextActivationId = 0x1_0000_0000; + } else { + nextActivationId = activationId + 1; + } + } + return activationId; + }; + + return { + prepare(request) { + if (options.isForkChild && request.replayActivationId === undefined) { + throw new Error( + `${request.name}: fresh-child replay is missing its activation id`, + ); + } + if ( + !options.isForkChild && + !options.tableReplication && + request.replayActivationId !== undefined + ) { + throw new Error( + `${request.name}: a parent load supplied a replay activation id`, + ); + } + // WHY: any live process Worker may reconcile an activation published by + // a peer or originate dlopen while holding the process archive writer. + // Its writer-acquire hook first adopts every published activation, so + // the same monotonic allocator safely claims the next process-wide ID. + const activationId = claimActivationId(request.replayActivationId); + let prepared = false; + let registered = false; + let released = false; + let exceptionProvider: ForkExceptionProvider | null = null; + let importedStatePreparation: PreparedForkParentActivation | null = null; + let childImportedStatePlanner: ForkImportedGlobalPlanner | null = null; + let importedStateRegistered = false; + let importsWrapped = false; + const continuation = new LinkedForkContinuation( + options.memory, + readLinkedFrameFormat(request.module), + (size) => + continuationMmap( + options.memory, + options.channelOffset, + size, + `${options.label}: ${request.name} continuation`, + ), + (addr, size) => + continuationMunmap( + options.memory, + options.channelOffset, + addr, + size, + `${options.label}: ${request.name} continuation`, + ), + `${options.label}: ${request.name}`, + ); + if (continuation.format.ptrWidth !== options.ptrWidth) { + throw new Error( + `${request.name}: linked continuation pointer width ` + + `${continuation.format.ptrWidth} does not match the process ` + + `pointer width ${options.ptrWidth}`, + ); + } + const moduleState = readForkModuleStateDescriptor(request.module); + if (moduleState.ptrWidth !== options.ptrWidth) { + throw new Error( + `${request.name}: module-state pointer width ${moduleState.ptrWidth} ` + + `does not match the process pointer width ${options.ptrWidth}`, + ); + } + const templateId = computeForkModuleTemplateIdSync(request.moduleBytes); + + try { + options.coordinator.prepareActivation({ + activationId, + continuation, + }); + prepared = true; + } catch (error) { + claimed.delete(activationId); + throw error; + } + + const env: Record = { + fork: (): number => options.invokeProcessFork(), + [FORK_UNWIND_TAG_IMPORT_NAME]: requireForkUnwindTag( + options.forkUnwindTag, + `${request.name}: fork activation`, + ) as unknown as WebAssembly.ImportValue, + ...options.coordinator.continuationImports(activationId, (errno) => + options.coordinator.beginCaptureAbort(errno), + ), + ...buildForkActivationStateImports( + activationId, + options.registry, + options.referenceReplay, + options.tableReplication, + ), + ...buildForkExceptionImports({ + activationId, + ptrWidth: options.ptrWidth, + registry: options.registry, + broker: options.exceptionBroker, + provider: () => { + if (!exceptionProvider) { + throw new Error( + `${request.name}: exception codec called before activation registration`, + ); + } + return exceptionProvider; + }, + referenceReplay: options.referenceReplay, + }), + }; + + return { + activationId, + env, + wrapImports: (imports) => { + if (importsWrapped) { + throw new Error( + `${request.name}: activation ${activationId} wrapped its imports twice`, + ); + } + importsWrapped = true; + childImportedStatePlanner = options.importedStatePlanner?.() ?? null; + if (options.isForkChild && !childImportedStatePlanner) { + throw new Error( + `${request.name}: child activation ${activationId} has no ` + + "pre-instantiation imported-state plan", + ); + } + let resolvedImports = imports; + if (childImportedStatePlanner) { + resolvedImports = childImportedStatePlanner.importsForActivation( + activationId, + imports as unknown as ForkWasmImports, + ) as unknown as WebAssembly.Imports; + } + if (!options.importedStateCapture) return resolvedImports; + // WHY: a fresh child must become a parent-capable owner after replay. + // Plan the copied identities first, then observe the exact Global and + // Table objects WebAssembly binds so a later fork can publish fresh + // provenance instead of depending on its parent's consumed arena. + importedStatePreparation = + options.importedStateCapture.prepareActivation( + activationId, + request.module, + resolvedImports, + ); + return importedStatePreparation.imports as unknown as WebAssembly.Imports; + }, + register(instance) { + if (released || registered || !prepared) { + throw new Error( + `${request.name}: side-module activation ${activationId} ` + + "cannot be registered in its current state", + ); + } + if (options.importedStateCapture) { + if (!importedStatePreparation) { + throw new Error( + `${request.name}: activation ${activationId} did not wrap its final imports`, + ); + } + importedStatePreparation.complete(instance); + importedStateRegistered = true; + } + if (options.isForkChild && !childImportedStatePlanner) { + throw new Error( + `${request.name}: child activation ${activationId} did not wrap its final imports`, + ); + } + exceptionProvider = forkExceptionProviderFromInstance( + activationId, + instance, + ); + const typedReferenceProvider = forkGcCodecProviderFromInstance( + activationId, + request.module, + instance, + ); + const registration = forkActivationRegistrationFromInstance({ + activationId, + module: request.module, + instance, + templateId, + exceptionProvider, + typedReferenceProvider, + }); + options.coordinator.registerActivation( + registration, + forkResumeTargetsFromInstance(request.module, instance), + ); + registered = true; + prepared = false; + childImportedStatePlanner?.registerInstance(activationId, instance); + options.registerChildReferenceActivation?.( + activationId, + request.module, + registration, + typedReferenceProvider, + ); + options.importedStateCapture?.bindTableDirtyTrackers( + new Map( + options.registry + .activations() + .map((activation) => [ + activation.activationId, + activation.tableDirty, + ]), + ), + ); + if ( + options.isPthreadReplica && + request.replayActivationId !== undefined + ) { + const threadBootstrap = + instance.exports[WPK_FORK_EXPORT_MODULE_THREAD_BOOTSTRAP]; + if (typeof threadBootstrap !== "function") { + throw new Error( + `${request.name}: pthread replica is missing its table bootstrap`, + ); + } + // WHY: this Worker needs fresh instance-local element functions, + // but process linear memory and constructors are already live. + // The thread helper initializes only tables and drops data + // segments; the parent bootstrap would re-run side effects. + threadBootstrap(); + } + }, + unregister() { + if (released) { + throw new Error( + `${request.name}: side-module activation ${activationId} was released twice`, + ); + } + released = true; + let failure: unknown; + try { + if (registered) { + try { + options.coordinator.unregisterActivation(activationId); + } catch (error) { + failure = error; + } + } else if (prepared) { + try { + options.coordinator.discardPreparedActivation(activationId); + exceptionProvider?.abort(); + } catch (error) { + failure = error; + } + } + if (importedStateRegistered) { + try { + options.importedStateCapture!.unregisterActivation( + activationId, + ); + } catch (error) { + failure ??= error; + } + } else if (importedStatePreparation) { + try { + importedStatePreparation.abort(); + } catch (error) { + failure ??= error; + } + } + } finally { + registered = false; + prepared = false; + exceptionProvider = null; + importedStatePreparation = null; + childImportedStatePlanner = null; + importedStateRegistered = false; + importsWrapped = false; + } + if (failure !== undefined) throw failure; + }, + }; + }, + }; } +/** + * Wasm-owned codecs for reference hierarchies that cannot appear in a + * JavaScript function signature. + * + * These are intentionally dependencies, not optional fallbacks. The + * activation provider registry must resolve them before instantiating a module + * that imports the corresponding ABI hook. + */ const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER); -function checkedWasmByteLength(value: number | bigint, context: string): number { +function checkedWasmByteLength( + value: number | bigint, + context: string, +): number { if (typeof value === "number" && !Number.isSafeInteger(value)) { - throw new RangeError(`${context}: length is not an exact non-negative JavaScript integer`); + throw new RangeError( + `${context}: length is not an exact non-negative JavaScript integer`, + ); } const exact = typeof value === "bigint" ? value : BigInt(value); if (exact < 0n || exact > MAX_SAFE_BIGINT) { - throw new RangeError(`${context}: length is not an exact non-negative JavaScript integer`); + throw new RangeError( + `${context}: length is not an exact non-negative JavaScript integer`, + ); } return Number(exact); } @@ -345,38 +894,6 @@ function checkedWasmMemoryRange( return { offset, length }; } -/** - * Thread workers instantiate a separate Wasm module/table/tag graph, so they - * cannot safely load or invoke process side modules. Keep dlopen's ordinary C - * failure contract (NULL plus dlerror text) instead of letting a generic - * unresolved-import stub trap the pthread. - */ -function buildUnsupportedThreadDlopenImports( - memory: WebAssembly.Memory, -): Record { - const message = new TextEncoder().encode( - "dlopen is unsupported from pthread workers; load side modules on the process main worker", - ); - const n = (value: number | bigint): number => - typeof value === "bigint" ? Number(value) : value; - return { - __wasm_dlopen: (): number => 0, - __wasm_dlsym: (): number => 0, - __wasm_dlclose: (): number => -1, - __wasm_dlerror: (bufPtr: number | bigint, bufMax: number | bigint): number => { - const ptr = n(bufPtr); - const max = n(bufMax); - if (!Number.isSafeInteger(ptr) || !Number.isSafeInteger(max) || ptr < 0 || max <= 0) { - return 0; - } - const len = Math.min(message.length, max, memory.buffer.byteLength - ptr); - if (len <= 0) return 0; - new Uint8Array(memory.buffer, ptr, len).set(message.subarray(0, len)); - return len; - }, - }; -} - /** * Build dlopen host imports for a process. These are called directly from * the user program's dlopen/dlsym/dlclose C stubs (libc/glue/dlopen.c). @@ -402,66 +919,185 @@ export function buildDlopenImports( ptrWidth: 4 | 8, longjmpTag: WebAssembly.Tag | undefined, cppExceptionTag: WebAssembly.Tag | undefined, - mainHasDylinkForkRole: boolean, - beginMainForkAbort?: (errno: number) => void, + forkActivationOwner?: DylinkForkActivationOwner, + forkActivationOwnerUnavailableReason?: string, + forkUnwindTag?: WebAssembly.Tag, + onTableMutation?: ( + table: WebAssembly.Table, + firstIndex: number, + length: number, + ) => void, + hostImportRuntime?: ForkHostImportWorkerRuntime, + workerIdentity = 1, ): DlopenSupport { + if ( + !Number.isInteger(workerIdentity) || + workerIdentity <= 0 || + workerIdentity > 0x7fff_ffff + ) { + throw new RangeError( + `invalid dynamic-loader Worker identity ${String(workerIdentity)}`, + ); + } let linker: DynamicLinker | null = null; const loadedLibraries = new Map(); - let activeSideFork: SideModuleForkState | null = null; const decoder = new TextDecoder(); const encoder = new TextEncoder(); - const n = (v: number | bigint): number => typeof v === "bigint" ? Number(v) : v; - - const headOffset = ptrWidth === 8 ? DLOPEN_HEAD_OFFSET_WASM64 : DLOPEN_HEAD_OFFSET_WASM32; - const sideForkOffset = ptrWidth === 8 - ? DLOPEN_ACTIVE_SIDE_FORK_OFFSET_WASM64 - : DLOPEN_ACTIVE_SIDE_FORK_OFFSET_WASM32; - const lockOffset = ptrWidth === 8 - ? DLOPEN_LOCK_OFFSET_WASM64 - : DLOPEN_LOCK_OFFSET_WASM32; + const n = (v: number | bigint): number => + typeof v === "bigint" ? Number(v) : v; + const resolvedLibraryPaths = new Map(); + + const headOffset = + ptrWidth === 8 ? DLOPEN_HEAD_OFFSET_WASM64 : DLOPEN_HEAD_OFFSET_WASM32; + const lockOffset = + ptrWidth === 8 ? DLOPEN_LOCK_OFFSET_WASM64 : DLOPEN_LOCK_OFFSET_WASM32; + const generationOffset = + ptrWidth === 8 + ? DLOPEN_GENERATION_OFFSET_WASM64 + : DLOPEN_GENERATION_OFFSET_WASM32; + const ownerOffset = + ptrWidth === 8 ? DLOPEN_OWNER_OFFSET_WASM64 : DLOPEN_OWNER_OFFSET_WASM32; const headSlot = archiveControlAddr - headOffset; - const activeSideForkSlot = archiveControlAddr - sideForkOffset; - const archiveLock = new Int32Array(memory.buffer, archiveControlAddr - lockOffset, 1); - const entrySize = ptrWidth === 8 ? DLOPEN_ENTRY_SIZE_WASM64 : DLOPEN_ENTRY_SIZE_WASM32; - - const readPtr = (view: DataView, addr: number): number => - ptrWidth === 8 ? Number(view.getBigUint64(addr, true)) : view.getUint32(addr, true); - const writePtr = (view: DataView, addr: number, value: number): void => { - if (ptrWidth === 8) view.setBigUint64(addr, BigInt(value), true); - else view.setUint32(addr, value, true); + const archiveLock = new Int32Array( + memory.buffer, + archiveControlAddr - lockOffset, + 1, + ); + const loaderOwner = new Int32Array( + memory.buffer, + archiveControlAddr - ownerOffset, + 1, + ); + const generationSlot = archiveControlAddr - generationOffset; + const readGenerationFence = (): number => { + const value = + typeof SharedArrayBuffer !== "undefined" && + memory.buffer instanceof SharedArrayBuffer + ? Atomics.load(new BigUint64Array(memory.buffer, generationSlot, 1), 0) + : new DataView(memory.buffer).getBigUint64(generationSlot, true); + if (value > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new RangeError( + "dlopen process generation exceeds exact host integers", + ); + } + return Number(value); + }; + const writeGenerationFence = (generation: number): void => { + if (!Number.isSafeInteger(generation) || generation <= 0) { + throw new RangeError( + `invalid dlopen process generation ${String(generation)}`, + ); + } + if ( + typeof SharedArrayBuffer !== "undefined" && + memory.buffer instanceof SharedArrayBuffer + ) { + Atomics.store( + new BigUint64Array(memory.buffer, generationSlot, 1), + 0, + BigInt(generation), + ); + } else { + new DataView(memory.buffer).setBigUint64( + generationSlot, + BigInt(generation), + true, + ); + } }; - const readArchiveHead = (): number => ptrWidth === 8 - ? Number(Atomics.load(new BigUint64Array(memory.buffer, headSlot, 1), 0)) - : Atomics.load(new Uint32Array(memory.buffer, headSlot, 1), 0); + const readArchiveHead = (): number => + ptrWidth === 8 + ? Number(Atomics.load(new BigUint64Array(memory.buffer, headSlot, 1), 0)) + : Atomics.load(new Uint32Array(memory.buffer, headSlot, 1), 0); const writeArchiveHead = (value: number): void => { if (ptrWidth === 8) { - Atomics.store(new BigUint64Array(memory.buffer, headSlot, 1), 0, BigInt(value)); + Atomics.store( + new BigUint64Array(memory.buffer, headSlot, 1), + 0, + BigInt(value), + ); } else { Atomics.store(new Uint32Array(memory.buffer, headSlot, 1), 0, value); } }; - const linkerAllocations = new Map(); - const archiveEntries = new Map(); + const linkerAllocations = new Map< + number, + { rawAddr: number; length: number } + >(); let hostDlopenError: string | null = null; let mainDlopenDepth = 0; - const acquireMainDlopenLock = (): boolean => { - if (mainDlopenDepth > 0) { - mainDlopenDepth++; - return true; + let mainArchiveReaderDepth = 0; + const ownedDlopenTransactions = new Set(); + let tableMutationPending = false; + let commitObserver: + | (( + linkerPublication: DylinkForkArchiveSnapshot | undefined, + tableMutationCommitted: boolean, + ) => void) + | null = null; + let writerAcquireObserver: (() => void) | null = null; + let operationAbortObserver: (() => void) | null = null; + const finishFreshWriterAcquisition = (): void => { + mainDlopenDepth = 1; + try { + writerAcquireObserver?.(); + } catch (error) { + releaseMainDlopenLock(); + throw error; } + }; + const foreignLoaderOwner = (): number => { + const owner = Atomics.load(loaderOwner, 0); + return owner !== DLOPEN_OWNER_IDLE && owner !== workerIdentity + ? owner + : DLOPEN_OWNER_IDLE; + }; + const releaseRawWriterLock = (): void => { const owner = Atomics.compareExchange( archiveLock, 0, - DLOPEN_LOCK_IDLE, DLOPEN_LOCK_WRITER, + DLOPEN_LOCK_IDLE, ); - if (owner !== 0) { - hostDlopenError = owner > 0 - ? "dlopen is temporarily unavailable while pthreads are forking" - : "dlopen is temporarily unavailable while another dlopen operation owns the process lock"; - return false; + if (owner !== DLOPEN_LOCK_WRITER) { + throw new Error( + `dlopen process lock lost writer ownership (state=${owner})`, + ); } - mainDlopenDepth = 1; + Atomics.notify(archiveLock, 0); + }; + const claimLoaderOwnership = (): void => { + if (mainDlopenDepth <= 0) { + throw new Error("dynamic-loader ownership requires the archive writer"); + } + const owner = Atomics.compareExchange( + loaderOwner, + 0, + DLOPEN_OWNER_IDLE, + workerIdentity, + ); + if (owner !== DLOPEN_OWNER_IDLE && owner !== workerIdentity) { + throw new Error(`dynamic-loader ownership belongs to Worker ${owner}`); + } + }; + const releaseLoaderOwnershipIfIdle = (): void => { + if (ownedDlopenTransactions.size !== 0) return; + const owner = Atomics.compareExchange( + loaderOwner, + 0, + workerIdentity, + DLOPEN_OWNER_IDLE, + ); + if (owner !== workerIdentity && owner !== DLOPEN_OWNER_IDLE) { + throw new Error(`dynamic-loader ownership changed to Worker ${owner}`); + } + Atomics.notify(loaderOwner, 0); + }; + const acquireMainDlopenLock = (): boolean => { + // POSIX loader serialization is blocking. Imports run in process Workers, + // so Atomics.wait can suspend only the contending pthread while the owner + // continues its staged guest initializer in a different Worker. + acquireArchiveWriter(); return true; }; const releaseMainDlopenLock = (): void => { @@ -470,85 +1106,491 @@ export function buildDlopenImports( } mainDlopenDepth--; if (mainDlopenDepth === 0) { + releaseRawWriterLock(); + } + }; + const withArchiveWriter = (operation: () => T): T => { + acquireArchiveWriter(); + try { + return operation(); + } finally { + releaseMainDlopenLock(); + } + }; + const acquireArchiveWriter = (): void => { + if (mainArchiveReaderDepth > 0) { + throw new Error( + "cannot acquire the process archive writer while owning a reader", + ); + } + if (mainDlopenDepth > 0) { + mainDlopenDepth++; + return; + } + for (;;) { + const transactionOwner = foreignLoaderOwner(); + if (transactionOwner !== DLOPEN_OWNER_IDLE) { + Atomics.wait(loaderOwner, 0, transactionOwner); + continue; + } const owner = Atomics.compareExchange( archiveLock, 0, - DLOPEN_LOCK_WRITER, DLOPEN_LOCK_IDLE, + DLOPEN_LOCK_WRITER, + ); + if (owner === DLOPEN_LOCK_IDLE) { + const racedTransactionOwner = foreignLoaderOwner(); + if (racedTransactionOwner === DLOPEN_OWNER_IDLE) break; + releaseRawWriterLock(); + Atomics.wait(loaderOwner, 0, racedTransactionOwner); + continue; + } + Atomics.wait(archiveLock, 0, owner); + } + finishFreshWriterAcquisition(); + }; + const acquireArchiveReader = (): void => { + if (mainDlopenDepth > 0) { + throw new Error( + "cannot acquire a process archive reader while owning its writer", + ); + } + for (;;) { + const transactionOwner = foreignLoaderOwner(); + if (transactionOwner !== DLOPEN_OWNER_IDLE) { + // POSIX fork preserves only its calling thread. Waiting here prevents + // a child from inheriting another thread's half-executed constructor, + // whose Wasm continuation cannot exist in the child. + Atomics.wait(loaderOwner, 0, transactionOwner); + continue; + } + const owner = Atomics.load(archiveLock, 0); + if (owner < 0) { + Atomics.wait(archiveLock, 0, owner); + continue; + } + if (owner >= DLOPEN_LOCK_MAX_READERS) { + throw new RangeError("dlopen process archive reader count exhausted"); + } + if (Atomics.compareExchange(archiveLock, 0, owner, owner + 1) !== owner) { + continue; + } + mainArchiveReaderDepth++; + return; + } + }; + const releaseArchiveReader = (): void => { + if (mainArchiveReaderDepth <= 0) { + throw new Error( + "dlopen process archive reader released without ownership", ); - if (owner !== DLOPEN_LOCK_WRITER) { + } + for (;;) { + const owner = Atomics.load(archiveLock, 0); + if (owner <= DLOPEN_LOCK_IDLE) { throw new Error( - `dlopen process lock lost writer ownership (state=${owner})`, + `dlopen process archive reader lost ownership (state=${owner})`, ); } - Atomics.notify(archiveLock, 0); + if (Atomics.compareExchange(archiveLock, 0, owner, owner - 1) !== owner) { + continue; + } + mainArchiveReaderDepth--; + if (owner === 1) Atomics.notify(archiveLock, 0); + return; + } + }; + const withArchiveReader = (operation: () => T): T => { + acquireArchiveReader(); + try { + return operation(); + } finally { + releaseArchiveReader(); } }; + const notifyCommit = ( + publication: DylinkForkArchiveSnapshot | undefined, + ): void => { + const mutated = tableMutationPending; + tableMutationPending = false; + commitObserver?.(publication, mutated); + }; + const abortLinkerOperation = (): void => { + tableMutationPending = false; + operationAbortObserver?.(); + }; - // The kernel mmap allocator. Shared with the linker, but also used - // directly by persistArchiveEntry to obtain blocks for the archive. - const allocateMemory = (size: number, align: number): number => { - const requested = size + Math.max(align, 1) - 1; + const invokeChannelSyscall = ( + syscall: number, + args: readonly (number | bigint)[], + ): { result: number; errno: number } => { const view = new DataView(memory.buffer); const base = channelOffset; - view.setInt32(base + CH_SYSCALL, SYS_MMAP_NR, true); + view.setInt32(base + CH_SYSCALL, syscall, true); + for (let i = 0; i < 6; i++) { + view.setBigInt64( + base + CH_ARGS + i * CH_ARG_SIZE, + BigInt(args[i] ?? 0), + true, + ); + } + markDeferredSignalDelivery(view, base); + const i32 = new Int32Array(memory.buffer); + Atomics.store(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING); + Atomics.notify(i32, (base + CH_STATUS) / 4, 1); + while ( + Atomics.wait(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING) === "ok" + ) { + /* wait for the kernel Worker */ + } + const result = Number(view.getBigInt64(base + CH_RETURN, true)); + const errno = view.getUint32(base + CH_ERRNO, true); + clearDeferredSignalDelivery(view, base); + Atomics.store(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_IDLE); + return { result, errno }; + }; + + // The kernel mmap allocator. Shared with the linker, but also used + // directly by persistArchiveEntry to obtain blocks for the archive. + const allocateMemory = (size: number, align: number): number => { + const requested = size + Math.max(align, 1) - 1; + const view = new DataView(memory.buffer); + const base = channelOffset; + view.setInt32(base + CH_SYSCALL, SYS_MMAP_NR, true); view.setBigInt64(base + CH_ARGS + 0 * CH_ARG_SIZE, 0n, true); view.setBigInt64(base + CH_ARGS + 1 * CH_ARG_SIZE, BigInt(requested), true); - view.setBigInt64(base + CH_ARGS + 2 * CH_ARG_SIZE, BigInt(PROT_READ_WRITE), true); - view.setBigInt64(base + CH_ARGS + 3 * CH_ARG_SIZE, BigInt(MAP_PRIVATE_ANONYMOUS), true); + view.setBigInt64( + base + CH_ARGS + 2 * CH_ARG_SIZE, + BigInt(PROT_READ_WRITE), + true, + ); + view.setBigInt64( + base + CH_ARGS + 3 * CH_ARG_SIZE, + BigInt(MAP_PRIVATE_ANONYMOUS), + true, + ); view.setBigInt64(base + CH_ARGS + 4 * CH_ARG_SIZE, -1n, true); view.setBigInt64(base + CH_ARGS + 5 * CH_ARG_SIZE, 0n, true); + markDeferredSignalDelivery(view, base); const i32 = new Int32Array(memory.buffer); Atomics.store(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING); Atomics.notify(i32, (base + CH_STATUS) / 4, 1); - while (Atomics.wait(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING) === "ok") { /* wait for mmap */ } + while ( + Atomics.wait(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING) === "ok" + ) { + /* wait for mmap */ + } const result = Number(view.getBigInt64(base + CH_RETURN, true)); const err = view.getUint32(base + CH_ERRNO, true); + clearDeferredSignalDelivery(view, base); Atomics.store(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_IDLE); if (err || result < 0) { - throw new Error(`dlopen: mmap(${requested}) failed errno=${err || -result}`); + throw new Error( + `dlopen: mmap(${requested}) failed errno=${err || -result}`, + ); } const aligned = alignUp(n(result), Math.max(align, 1)); linkerAllocations.set(aligned, { rawAddr: n(result), length: requested }); return aligned; }; - const deallocateMemory = (addr: number, _size: number): void => { + const deallocateMemory = ( + addr: number, + size: number, + allowCopiedArchiveAllocation = false, + ): void => { const allocation = linkerAllocations.get(addr); - if (!allocation) { - throw new Error(`dlopen rollback: unknown allocation 0x${addr.toString(16)}`); + if (!allocation && !allowCopiedArchiveAllocation) { + throw new Error( + `dlopen rollback: unknown allocation 0x${addr.toString(16)}`, + ); + } + const rawAddr = allocation?.rawAddr ?? addr; + const length = allocation?.length ?? size; + if ( + !Number.isSafeInteger(rawAddr) || + rawAddr <= 0 || + !Number.isSafeInteger(length) || + length <= 0 || + rawAddr > memory.buffer.byteLength - length + ) { + throw new Error("dlopen archive release names an invalid copied mapping"); } const view = new DataView(memory.buffer); const base = channelOffset; view.setInt32(base + CH_SYSCALL, ABI_SYSCALLS.Munmap, true); - view.setBigInt64(base + CH_ARGS + 0 * CH_ARG_SIZE, BigInt(allocation.rawAddr), true); - view.setBigInt64(base + CH_ARGS + 1 * CH_ARG_SIZE, BigInt(allocation.length), true); + view.setBigInt64(base + CH_ARGS + 0 * CH_ARG_SIZE, BigInt(rawAddr), true); + view.setBigInt64(base + CH_ARGS + 1 * CH_ARG_SIZE, BigInt(length), true); for (let i = 2; i < 6; i++) { view.setBigInt64(base + CH_ARGS + i * CH_ARG_SIZE, 0n, true); } + markDeferredSignalDelivery(view, base); const i32 = new Int32Array(memory.buffer); Atomics.store(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING); Atomics.notify(i32, (base + CH_STATUS) / 4, 1); - while (Atomics.wait(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING) === "ok") { /* wait */ } + while ( + Atomics.wait(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING) === "ok" + ) { + /* wait */ + } const result = Number(view.getBigInt64(base + CH_RETURN, true)); const err = view.getUint32(base + CH_ERRNO, true); + clearDeferredSignalDelivery(view, base); Atomics.store(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_IDLE); if (err || result < 0) { throw new Error(`dlopen rollback: munmap failed errno=${err || -result}`); } - linkerAllocations.delete(addr); + if (allocation) linkerAllocations.delete(addr); + }; + + const describeMemoryAllocation = ( + address: number, + size: number, + ): Readonly<{ mappingAddress: number; mappingSize: number }> => { + const allocation = linkerAllocations.get(address); + if (!allocation) { + throw new Error( + `dlopen: allocation 0x${address.toString(16)} has no mmap owner`, + ); + } + if ( + !Number.isSafeInteger(size) || + size <= 0 || + address > allocation.rawAddr + allocation.length - size + ) { + throw new RangeError("dlopen: logical allocation escapes its mmap owner"); + } + return { + mappingAddress: allocation.rawAddr, + mappingSize: allocation.length, + }; + }; + + const adoptMemoryAllocation = ( + allocation: Readonly<{ + address: number; + size: number; + mappingAddress: number; + mappingSize: number; + }>, + ): void => { + const existing = linkerAllocations.get(allocation.address); + if (existing) { + if ( + existing.rawAddr === allocation.mappingAddress && + existing.length === allocation.mappingSize + ) + return; + throw new Error( + `dlopen replay: allocation 0x${allocation.address.toString(16)} ` + + "has conflicting mmap ownership", + ); + } + if ( + !Number.isSafeInteger(allocation.mappingAddress) || + allocation.mappingAddress <= 0 || + !Number.isSafeInteger(allocation.mappingSize) || + allocation.mappingSize <= 0 || + allocation.address < allocation.mappingAddress || + allocation.address + allocation.size > + allocation.mappingAddress + allocation.mappingSize || + allocation.mappingAddress > + memory.buffer.byteLength - allocation.mappingSize + ) { + throw new RangeError("dlopen replay: invalid copied mmap ownership"); + } + linkerAllocations.set(allocation.address, { + rawAddr: allocation.mappingAddress, + length: allocation.mappingSize, + }); + }; + + const forgetMemoryAllocation = ( + allocation: Readonly<{ + address: number; + mappingAddress: number; + mappingSize: number; + }>, + ): void => { + const existing = linkerAllocations.get(allocation.address); + if (!existing) return; + if ( + existing.rawAddr !== allocation.mappingAddress || + existing.length !== allocation.mappingSize + ) { + throw new Error( + `dlopen replay: allocation 0x${allocation.address.toString(16)} ` + + "changed before peer unload", + ); + } + linkerAllocations.delete(allocation.address); + }; + + const readDependencyFile = (path: string): Uint8Array | null => { + if (path.includes("\0")) { + throw new Error("dlopen dependency path contains NUL"); + } + const pathBytes = encoder.encode(`${path}\0`); + const pathAddr = allocateMemory(pathBytes.length, 1); + let openResult: { result: number; errno: number } | undefined; + let pathFailure: unknown; + try { + new Uint8Array(memory.buffer, pathAddr, pathBytes.length).set(pathBytes); + openResult = invokeChannelSyscall(ABI_SYSCALLS.Openat, [ + -100, + pathAddr, + 0, + 0, + ]); + } catch (error) { + pathFailure = error; + } finally { + try { + deallocateMemory(pathAddr, pathBytes.length); + } catch (error) { + pathFailure ??= error; + } + } + if (pathFailure !== undefined) { + if (openResult && openResult.errno === 0 && openResult.result >= 0) { + try { + invokeChannelSyscall(ABI_SYSCALLS.Close, [openResult.result]); + } catch { + // Preserve the path-allocation failure. + } + } + throw pathFailure; + } + if (!openResult) { + throw new Error(`dlopen dependency open(${path}) returned no result`); + } + if (openResult.errno === 2 || openResult.errno === 20) return null; + if (openResult.errno || openResult.result < 0) { + throw new Error( + `dlopen dependency open(${path}) failed errno=` + + `${openResult.errno || -openResult.result}`, + ); + } + + const fd = openResult.result; + const chunkSize = 64 * 1024; + const maxBytes = 64 * 1024 * 1024; + let chunkAddr: number | undefined; + const chunks: Uint8Array[] = []; + let total = 0; + let failure: unknown; + try { + chunkAddr = allocateMemory(chunkSize, 16); + for (;;) { + const read = invokeChannelSyscall(ABI_SYSCALLS.Read, [ + fd, + chunkAddr, + chunkSize, + ]); + if (read.errno || read.result < 0) { + throw new Error( + `dlopen dependency read(${path}) failed errno=` + + `${read.errno || -read.result}`, + ); + } + if (read.result === 0) break; + if (read.result > chunkSize) { + throw new Error( + `dlopen dependency read(${path}) returned ${read.result} bytes`, + ); + } + total += read.result; + if (total > maxBytes) { + throw new Error( + `dlopen dependency ${path} exceeds ${maxBytes} bytes`, + ); + } + chunks.push( + new Uint8Array(new Uint8Array(memory.buffer, chunkAddr, read.result)), + ); + } + } catch (error) { + failure = error; + } finally { + try { + if (chunkAddr !== undefined) { + deallocateMemory(chunkAddr, chunkSize); + } + } catch (error) { + failure ??= error; + } + try { + const close = invokeChannelSyscall(ABI_SYSCALLS.Close, [fd]); + if ((close.errno || close.result < 0) && failure === undefined) { + failure = new Error( + `dlopen dependency close(${path}) failed errno=` + + `${close.errno || -close.result}`, + ); + } + } catch (error) { + failure ??= error; + } + } + if (failure !== undefined) throw failure; + + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + return bytes; + }; + + const resolveLibrarySync = ( + dependency: string, + requester?: string, + ): Uint8Array | null => { + const candidates: string[] = []; + const addCandidate = (candidate: string): void => { + if (!candidates.includes(candidate)) candidates.push(candidate); + }; + if (dependency.startsWith("/")) { + addCandidate(dependency); + } else { + const requesterPath = + requester === undefined + ? undefined + : (resolvedLibraryPaths.get(requester) ?? requester); + const slash = requesterPath?.lastIndexOf("/") ?? -1; + if (requesterPath && slash >= 0) { + const directory = slash === 0 ? "/" : requesterPath.slice(0, slash); + addCandidate( + directory === "/" ? `/${dependency}` : `${directory}/${dependency}`, + ); + } + addCandidate(dependency); + addCandidate(`/lib/${dependency}`); + addCandidate(`/usr/lib/${dependency}`); + addCandidate(`/usr/local/lib/${dependency}`); + } + + for (const candidate of candidates) { + const bytes = readDependencyFile(candidate); + if (bytes === null) continue; + resolvedLibraryPaths.set(dependency, candidate); + return bytes; + } + return null; }; const getLinker = (): DynamicLinker => { if (linker) return linker; const table = getTable(); const sp = getStackPointer(); - if (!table || !sp) throw new Error("dlopen: program has no table or stack pointer"); + if (!table || !sp) + throw new Error("dlopen: program has no table or stack pointer"); // Register main program's exported functions and data globals as global // symbols so shared libraries can resolve references to libc, libphp, etc. @@ -557,97 +1599,43 @@ export function buildDlopenImports( // RESERVED names are handled per-module by the dylink env Proxy and must // not be shadowed by main exports. const RESERVED = new Set([ - "memory", "__indirect_function_table", - "__memory_base", "__table_base", "__stack_pointer", "__c_longjmp", + "memory", + "__indirect_function_table", + "__memory_base", + "__table_base", + "__stack_pointer", + "__c_longjmp", "__cpp_exception", + FORK_UNWIND_TAG_IMPORT_NAME, ]); const globalSymbols = new Map(); + const globalSymbolOwners = new Map(); const inst = getInstance(); if (inst) { for (const [name, exp] of Object.entries(inst.exports)) { if (RESERVED.has(name)) continue; if (typeof exp === "function" || exp instanceof WebAssembly.Global) { globalSymbols.set(name, exp); + globalSymbolOwners.set(name, undefined); } } } - const mainModuleSymbols = new Set(globalSymbols.keys()); // A main-defined/exported tag is the process ABI authority. If the main // image instead imports and re-exports the host tag, the identity is the // same; if it has no export, retain the process-owned fallback created // before main instantiation. Every side module must receive this one // canonical identity for cross-module exception propagation. const exportedLongjmpTag = inst?.exports.__c_longjmp; - const canonicalLongjmpTag = exportedLongjmpTag === undefined - ? longjmpTag - : requireLongjmpTag(exportedLongjmpTag, "main module export"); + const canonicalLongjmpTag = + exportedLongjmpTag === undefined + ? longjmpTag + : requireLongjmpTag(exportedLongjmpTag, "main module export"); const exportedCppExceptionTag = inst?.exports.__cpp_exception; - const canonicalCppExceptionTag = exportedCppExceptionTag === undefined - ? cppExceptionTag - : requireCppExceptionTag(exportedCppExceptionTag, "main module export"); - const mainFork = inst?.exports.fork; - const mainForkState = inst?.exports.wpk_fork_state; - const sideModuleFork = mainHasDylinkForkRole - && typeof mainFork === "function" - && typeof mainForkState === "function" - ? { - setActiveFork: (state: SideModuleForkState) => { - const persisted = readPtr(new DataView(memory.buffer), activeSideForkSlot); - if (activeSideFork || persisted !== 0) { - throw new Error( - `${state.name}: nested or concurrent side-module fork is unsupported`, - ); - } - activeSideFork = state; - const loaded = loadedLibraries.get(state.name); - if (!loaded || loaded.forkContinuation !== state.continuation) { - throw new Error(`${state.name}: linked continuation owner mismatch`); - } - loaded.forkBufAddr = state.forkBufAddr; - updateArchiveForkBuffer(state.name, state.forkBufAddr); - writePtr(new DataView(memory.buffer), activeSideForkSlot, state.forkBufAddr); - }, - clearActiveFork: (state: SideModuleForkState) => { - const view = new DataView(memory.buffer); - const persisted = readPtr(view, activeSideForkSlot); - if ( - !activeSideFork - || activeSideFork.name !== state.name - || activeSideFork.instance !== state.instance - || activeSideFork.forkBufAddr !== state.forkBufAddr - || persisted !== state.forkBufAddr - ) { - throw new Error(`${state.name}: stale side-module fork identity during rewind`); - } - activeSideFork = null; - const loaded = loadedLibraries.get(state.name); - if (loaded) loaded.forkBufAddr = undefined; - updateArchiveForkBuffer(state.name, 0); - writePtr(view, activeSideForkSlot, 0); - }, - invokeMainFork: (expectedStateAfter: 0 | 1 | readonly (0 | 1)[]): number => { - const result = Number((mainFork as () => number)()); - const actualState = Number((mainForkState as () => number)()); - const expectedStates = Array.isArray(expectedStateAfter) - ? expectedStateAfter - : [expectedStateAfter]; - if (!expectedStates.includes(actualState as 0 | 1)) { - throw new Error( - `main-module fork transition ended in state ${actualState}; ` + - `expected ${expectedStates.join(" or ")}`, - ); - } - return result; - }, - beginMainAbort: (errno: number): void => { - if (!beginMainForkAbort) { - throw new Error("main-module continuation abort coordinator is unavailable"); - } - beginMainForkAbort(errno); - }, - } - : undefined; + const canonicalCppExceptionTag = + exportedCppExceptionTag === undefined + ? cppExceptionTag + : requireCppExceptionTag(exportedCppExceptionTag, "main module export"); linker = new DynamicLinker({ memory, @@ -655,299 +1643,295 @@ export function buildDlopenImports( stackPointer: sp, allocateMemory, deallocateMemory, - allocateContinuation: (size) => continuationMmap( - memory, - channelOffset, - size, - "side-module continuation", - ), - deallocateContinuation: (addr, size) => continuationMunmap( - memory, - channelOffset, - addr, - size, - "side-module continuation", - ), + describeMemoryAllocation, + adoptMemoryAllocation, + forgetMemoryAllocation, globalSymbols, + globalSymbolOwners, got: new Map(), loadedLibraries, + resolveLibrarySync, longjmpTag: canonicalLongjmpTag, cppExceptionTag: canonicalCppExceptionTag, + forkUnwindTag, ptrWidth, - mainModuleSymbols, - sideModuleFork, - sideModuleForkUnavailableReason: !mainHasDylinkForkRole - ? "main module lacks the versioned dlopen-main fork capability; rebuild it with the current wasm-fork-instrument" - : sideModuleFork - ? undefined - : "main module does not export the fork trampoline and wpk_fork_state required for side-module fork", + forkActivationOwner, + forkActivationOwnerUnavailableReason, + onTableMutation: (table, firstIndex, length) => { + onTableMutation?.(table, firstIndex, length); + tableMutationPending = true; + }, + routeFunctionImport: hostImportRuntime + ? (imported, implementation) => + hostImportRuntime.routeFunction(imported, implementation) + : undefined, }); return linker; }; - // Append an entry to the linked-list archive in linear memory. Each - // entry is one mmap block: struct, then name UTF-8 (padded to 8-byte - // alignment), then the side-module wasm bytes. Pointers are absolute - // — fork's memcpy preserves the parent's address space. - const persistArchiveEntry = ( - name: string, - bytes: Uint8Array, - memoryBase: number, - tableBase: number, - sideForkBufAddr: number, - tlsBase: number, - ): void => { - const nameBytes = encoder.encode(name); - const nameLen = nameBytes.length; - const nameAligned = (nameLen + 7) & ~7; - const totalSize = entrySize + nameAligned + bytes.length; + const forkArchive = new DylinkForkArchive( + memory, + ptrWidth, + readArchiveHead, + writeArchiveHead, + (size) => ({ + address: allocateMemory(size, 1), + size, + }), + ({ address, size }) => { + deallocateMemory(address, size, true); + }, + "process dylink archive", + { + read: readGenerationFence, + write: writeGenerationFence, + }, + ); - const entry = allocateMemory(totalSize, 8); - archiveEntries.set(name, entry); - const namePtr = entry + entrySize; - const bytesPtr = namePtr + nameAligned; + const readForkState = (): DylinkForkState => forkArchive.read(); - const view = new DataView(memory.buffer); - if (ptrWidth === 8) { - view.setBigUint64(entry + 0, 0n, true); - view.setBigUint64(entry + 8, BigInt(namePtr), true); - view.setBigUint64(entry + 16, BigInt(nameLen), true); - view.setBigUint64(entry + 24, BigInt(bytesPtr), true); - view.setBigUint64(entry + 32, BigInt(bytes.length), true); - view.setBigUint64(entry + 40, BigInt(memoryBase), true); - view.setBigUint64(entry + 48, BigInt(tableBase), true); - view.setBigUint64(entry + 56, BigInt(sideForkBufAddr), true); - view.setBigUint64(entry + 64, BigInt(tlsBase), true); - } else { - view.setUint32(entry + 0, 0, true); - view.setUint32(entry + 4, namePtr, true); - view.setUint32(entry + 8, nameLen, true); - view.setUint32(entry + 12, bytesPtr, true); - view.setUint32(entry + 16, bytes.length, true); - view.setUint32(entry + 20, memoryBase, true); - view.setUint32(entry + 24, tableBase, true); - view.setUint32(entry + 28, sideForkBufAddr, true); - view.setUint32(entry + 32, tlsBase, true); - } - - new Uint8Array(memory.buffer, namePtr, nameLen).set(nameBytes); - new Uint8Array(memory.buffer, bytesPtr, bytes.length).set(bytes); - - // Append to tail (preserves insertion order). - const head = readArchiveHead(); - if (head === 0) { - // Publish only after the complete entry and payload are visible. A - // pthread fork acquire-loads this word before deciding whether it can - // safely fork without access to the process side-module graph. - writeArchiveHead(entry); + const replayDlopens = (validatedState?: DylinkForkState): void => { + const state = validatedState ?? readForkState(); + if ( + state.nextHandle === 2 && + state.libraries.length === 0 && + linker === null + ) return; - } - let cursor = head; - for (;;) { - const next = readPtr(view, cursor); - if (next === 0) { - writePtr(view, cursor, entry); - return; - } - cursor = next; - } - }; - - const updateArchiveForkBuffer = (name: string, forkBufAddr: number): void => { - const entry = archiveEntries.get(name); - if (entry === undefined) { - throw new Error(`${name}: missing dlopen archive entry for fork continuation`); - } - const view = new DataView(memory.buffer); - if (ptrWidth === 8) view.setBigUint64(entry + 56, BigInt(forkBufAddr), true); - else view.setUint32(entry + 28, forkBufAddr, true); - }; - - const replayDlopens = (): void => { - const view = new DataView(memory.buffer); - let cursor = readArchiveHead(); - if (cursor === 0) return; - // Force linker creation: it's lazily built on the first C-side - // __wasm_dlopen call, which the fork child hasn't made yet. We need - // it now to drive replay before _start resumes. + // Materialize only missing modules, then replace the Worker-local handle + // view. Pthread Workers can call this for every process generation. const lk = getLinker(); - - while (cursor !== 0) { - let next: number; - let namePtr: number; - let nameLen: number; - let bytesPtr: number; - let bytesLen: number; - let memoryBase: number; - let tableBase: number; - let sideForkBufAddr: number; - let tlsBase: number; - if (ptrWidth === 8) { - next = Number(view.getBigUint64(cursor + 0, true)); - namePtr = Number(view.getBigUint64(cursor + 8, true)); - nameLen = Number(view.getBigUint64(cursor + 16, true)); - bytesPtr = Number(view.getBigUint64(cursor + 24, true)); - bytesLen = Number(view.getBigUint64(cursor + 32, true)); - memoryBase = Number(view.getBigUint64(cursor + 40, true)); - tableBase = Number(view.getBigUint64(cursor + 48, true)); - sideForkBufAddr = Number(view.getBigUint64(cursor + 56, true)); - tlsBase = Number(view.getBigUint64(cursor + 64, true)); - } else { - next = view.getUint32(cursor + 0, true); - namePtr = view.getUint32(cursor + 4, true); - nameLen = view.getUint32(cursor + 8, true); - bytesPtr = view.getUint32(cursor + 12, true); - bytesLen = view.getUint32(cursor + 16, true); - memoryBase = view.getUint32(cursor + 20, true); - tableBase = view.getUint32(cursor + 24, true); - sideForkBufAddr = view.getUint32(cursor + 28, true); - tlsBase = view.getUint32(cursor + 32, true); - } - - // Copy name + bytes out of shared memory before passing to - // WebAssembly / TextDecoder — some engines reject SAB-backed - // views, and we already pay the bytes copy cost on the parent's - // initial dlopen path. - const name = decoder.decode( - new Uint8Array(new Uint8Array(memory.buffer, namePtr, nameLen)), - ); - archiveEntries.set(name, cursor); - const bytesCopy = new Uint8Array(new Uint8Array(memory.buffer, bytesPtr, bytesLen)); - - // DynamicLinker.dlopenSync returns 0 on error, >0 on success. - const handle = lk.dlopenSync(name, bytesCopy, { - memoryBase, - tableBase, - forkBufAddr: sideForkBufAddr || undefined, - tlsBase: tlsBase === 0 ? undefined : tlsBase, - }); - if (handle === 0) { - throw new Error(`dlopen(${name}): ${lk.dlerror() || "unknown"}`); - } - if (sideForkBufAddr !== 0) { - const loaded = loadedLibraries.get(name); - if (!loaded || loaded.forkBufAddr !== sideForkBufAddr) { - throw new Error(`${name}: fork replay restored a mismatched save buffer`); - } - } - if (tlsBase !== 0) { - const loaded = loadedLibraries.get(name); - if (!loaded || loaded.tlsBase !== tlsBase) { - throw new Error(`${name}: fork replay restored a mismatched TLS base`); - } - } - - cursor = next; + try { + lk.reconcileForkModules(state); + lk.reconcileForkHandleState(state); + } catch (error) { + abortLinkerOperation(); + throw error; + } finally { + // Replay materializes a publication already owned by the archive; its + // local table writes are not a new source mutation to republish. + tableMutationPending = false; } }; - const findActiveSideFork = (): SideModuleForkState | null => { - const persisted = readPtr(new DataView(memory.buffer), activeSideForkSlot); - if (persisted === 0) { - if (activeSideFork) { - throw new Error(`${activeSideFork.name}: active side fork lost its persisted identity`); - } - return null; - } - if (activeSideFork) { - if (activeSideFork.forkBufAddr !== persisted) { - throw new Error(`${activeSideFork.name}: active side fork buffer identity changed`); - } - return activeSideFork; - } - - const matches = Array.from(loadedLibraries.values()).filter( - (loaded) => loaded.forkBufAddr === persisted, - ); - if (matches.length !== 1) { - throw new Error( - `fork replay could not resolve active side-module buffer 0x${persisted.toString(16)}`, - ); + const resetForkChildLock = (): void => { + Atomics.store(archiveLock, 0, 0); + Atomics.notify(archiveLock, 0); + const copiedOwner = Atomics.load(loaderOwner, 0); + if (copiedOwner !== DLOPEN_OWNER_IDLE) { + // The loader continuation belongs to the one thread that survived fork. + // Rebind its copied process lease to the child's new Worker coordinate. + Atomics.store(loaderOwner, 0, workerIdentity); } - const loaded = matches[0]!; - activeSideFork = { - name: loaded.name, - instance: loaded.instance, - forkBufAddr: persisted, - continuation: loaded.forkContinuation!, - }; - return activeSideFork; + Atomics.notify(loaderOwner, 0); }; - const sideForkState = (state: SideModuleForkState): number => - Number((state.instance.exports.wpk_fork_state as () => number)()); - - const completeSideModuleForkUnwind = (): void => { - const state = findActiveSideFork(); - if (!state) return; - finalizeSideModuleForkUnwind(memory, state, ptrWidth); - }; - - const beginSideModuleForkRewind = (): void => { - const state = findActiveSideFork(); - if (!state) return; - if (sideForkState(state) !== 0) { - throw new Error(`${state.name}: expected NORMAL before side-module rewind`); - } - if (state.continuation.hasActiveContinuation()) { - state.continuation.beginReplay(); - } else { - // WHY: attachment validates the copied pointer through the same guest - // ABI boundary as frame callbacks, where memory64 i64 values are BigInt. - state.continuation.attachForReplay( - ptrWidth === 8 ? BigInt(state.forkBufAddr) : state.forkBufAddr, - ); - } - invokeForkContinuationBegin( - state.instance.exports.wpk_fork_rewind_begin, - state.forkBufAddr, + const readDlopenRequest = ( + bytesPtr: WasmGuestPointer, + bytesLen: number | bigint, + namePtr: WasmGuestPointer, + nameLen: number | bigint, + ): { readonly name: string; readonly bytes: Uint8Array } => { + const bytesRange = checkedWasmMemoryRange( + memory, + bytesPtr, + bytesLen, ptrWidth, - `${state.name}: side-module linked fork rewind`, + "__wasm_dlopen_prepare bytes", ); - if (sideForkState(state) !== 2) { - throw new Error(`${state.name}: side-module rewind did not enter REWINDING`); - } - }; - - const beginSideModuleForkAbort = (errno: number): void => { - const state = findActiveSideFork(); - if (!state) return; - if (sideForkState(state) !== 1) { - throw new Error(`${state.name}: expected UNWINDING before side-module abort replay`); - } - state.continuation.beginAbortReplay(errno); - invokeForkContinuationBegin( - state.instance.exports.wpk_fork_abort_begin, - state.forkBufAddr, + const nameRange = checkedWasmMemoryRange( + memory, + namePtr, + nameLen, ptrWidth, - `${state.name}: side-module linked fork abort`, + "__wasm_dlopen_prepare name", ); - if (sideForkState(state) !== 3) { - throw new Error(`${state.name}: side-module abort did not enter ABORT_UNWINDING`); - } + const bytes = new Uint8Array( + memory.buffer, + bytesRange.offset, + bytesRange.length, + ); + const nameBytes = new Uint8Array( + memory.buffer, + nameRange.offset, + nameRange.length, + ); + // WHY: compilation may grow/detach memory, and Firefox/Chrome reject + // TextDecoder views backed directly by SharedArrayBuffer. + return { + // The first Kandelo dlopen import carried only (bytes, length) and + // historically keyed the module as `dlopen::`. ABI 43's + // lowering supplies an empty name range for that exact form. + name: nameRange.length === 0 && bytesRange.length !== 0 + ? `dlopen:${bytesRange.offset}:${bytesRange.length}` + : decoder.decode(new Uint8Array(nameBytes)), + bytes: new Uint8Array(bytes), + }; }; - const assertNoActiveSideModuleFork = (): void => { - const persisted = readPtr(new DataView(memory.buffer), activeSideForkSlot); - if (activeSideFork || persisted !== 0) { - throw new Error( - `${activeSideFork?.name ?? "unknown side module"}: main image returned with an active side-module fork`, - ); - } - }; + const imports: Record = { + __wasm_dlopen_main: (): number => { + if (!acquireMainDlopenLock()) return 0; + hostDlopenError = null; + try { + return getLinker().dlopenMain(); + } finally { + releaseMainDlopenLock(); + } + }, - const resetForkChildLock = (): void => { - Atomics.store(archiveLock, 0, 0); - Atomics.notify(archiveLock, 0); - }; + __wasm_dlopen_prepare: ( + bytesPtr: WasmGuestPointer, + bytesLen: number | bigint, + namePtr: WasmGuestPointer, + nameLen: number | bigint, + flags: number, + ): number => { + if (!acquireMainDlopenLock()) return 0; + hostDlopenError = null; + let claimedLoader = false; + try { + if (!Number.isInteger(flags)) { + throw new Error( + "__wasm_dlopen_prepare requires ABI 43 dlopen flags; rebuild the process", + ); + } + if (ownedDlopenTransactions.size === 0) { + claimLoaderOwnership(); + claimedLoader = true; + } + const request = readDlopenRequest(bytesPtr, bytesLen, namePtr, nameLen); + const transaction = getLinker().beginDlopenSync( + request.name, + request.bytes, + (flags & RTLD_GLOBAL) !== 0, + ); + if (transaction > 0) { + ownedDlopenTransactions.add(transaction); + } else if (claimedLoader) { + releaseLoaderOwnershipIfIdle(); + } + return transaction; + } catch (error) { + if (claimedLoader) releaseLoaderOwnershipIfIdle(); + abortLinkerOperation(); + throw error; + } finally { + releaseMainDlopenLock(); + } + }, - const imports: Record = { - __wasm_dlopen: (bytesPtr: WasmGuestPointer, bytesLen: number | bigint, - namePtr: WasmGuestPointer, nameLen: number | bigint): number => { + __wasm_dlopen_next: ( + transaction: number, + handlePtr?: WasmGuestPointer, + ): number => { + if (!acquireMainDlopenLock()) return -1; + hostDlopenError = null; + try { + const linker = getLinker(); + if ( + !ownedDlopenTransactions.has(transaction) && + Atomics.load(loaderOwner, 0) === workerIdentity && + linker.hasPendingDlopen(transaction) + ) { + // A fresh fork child reconstructed this token from the copied + // archive. Its loader lease was rebound before module replay. + ownedDlopenTransactions.add(transaction); + } + if (handlePtr === undefined) { + // Transitional standalone callers still use the explicit commit + // import. ABI-43 libc always supplies the output pointer and takes + // the atomic finish path below. + const entry = linker.nextDlopenInitialization(transaction); + if (entry !== 0) { + notifyCommit(forkArchive.sync(linker.forkState())); + } + if (entry < 0) { + ownedDlopenTransactions.delete(transaction); + releaseLoaderOwnershipIfIdle(); + } + return entry; + } + const handleRange = checkedWasmMemoryRange( + memory, + handlePtr, + 4, + ptrWidth, + "__wasm_dlopen_next handle", + ); + const { entry, handle } = linker.advanceDlopenSync(transaction); + new DataView(memory.buffer).setInt32(handleRange.offset, handle, true); + if (entry > 0) { + // Publish the exact provisional activation/stage before libc can + // enter it. A fork from that table call can therefore reconstruct + // both the fresh side instance and the stopped loader generator. + notifyCommit(forkArchive.sync(linker.forkState())); + } else { + // Completion opens the public handle and removes the private + // transaction in this same host transition. Rollback likewise + // removes the issued entry before control returns to Wasm. + notifyCommit(forkArchive.sync(linker.forkState())); + ownedDlopenTransactions.delete(transaction); + releaseLoaderOwnershipIfIdle(); + } + return entry; + } catch (error) { + getLinker().abortDlopenTransaction(transaction, error); + ownedDlopenTransactions.delete(transaction); + releaseLoaderOwnershipIfIdle(); + abortLinkerOperation(); + throw error; + } finally { + releaseMainDlopenLock(); + } + }, + + __wasm_dlopen_commit: (transaction: number): number => { + if (!acquireMainDlopenLock()) return 0; + hostDlopenError = null; + try { + const linker = getLinker(); + const handle = linker.commitDlopenSync(transaction); + notifyCommit(forkArchive.sync(linker.forkState())); + // WHY: commit deliberately returns zero without destroying a + // transaction whose initializer is still outstanding. Retaining the + // process lease keeps another pthread from interleaving loader state + // if arbitrary Wasm calls this transitional import too early. + if (!linker.hasPendingDlopen(transaction)) { + ownedDlopenTransactions.delete(transaction); + releaseLoaderOwnershipIfIdle(); + } + return handle; + } catch (error) { + getLinker().abortDlopenTransaction(transaction, error); + ownedDlopenTransactions.delete(transaction); + releaseLoaderOwnershipIfIdle(); + abortLinkerOperation(); + throw error; + } finally { + releaseMainDlopenLock(); + } + }, + + __wasm_dlopen: ( + bytesPtr: WasmGuestPointer, + bytesLen: number | bigint, + namePtr: WasmGuestPointer, + nameLen: number | bigint, + flags = RTLD_GLOBAL, + ): number => { if (!acquireMainDlopenLock()) return 0; hostDlopenError = null; + let claimedLoader = false; try { + if (!Number.isInteger(flags)) { + throw new Error("__wasm_dlopen received invalid dlopen flags"); + } + if (ownedDlopenTransactions.size === 0) { + claimLoaderOwnership(); + claimedLoader = true; + } const bytesRange = checkedWasmMemoryRange( memory, bytesPtr, @@ -969,7 +1953,11 @@ export function buildDlopenImports( return getLinker().dlopenMain(); } - const bytes = new Uint8Array(memory.buffer, bytesRange.offset, bytesRange.length); + const bytes = new Uint8Array( + memory.buffer, + bytesRange.offset, + bytesRange.length, + ); // Copy bytes since memory.buffer may detach during Wasm instantiation const bytesCopy = new Uint8Array(bytes); // TextDecoder.decode() rejects views backed by SharedArrayBuffer @@ -983,27 +1971,24 @@ export function buildDlopenImports( ); const nameBytesCopy = new Uint8Array(nameBytesView); const name = decoder.decode(nameBytesCopy); - const handle = getLinker().dlopenSync(name, bytesCopy); + const lk = getLinker(); + const handle = lk.dlopenSync( + name, + bytesCopy, + undefined, + (flags & RTLD_GLOBAL) !== 0, + ); if (handle > 0) { - // The linker just instantiated this — the map MUST contain it. - // A miss means the shared-map ref got rewired and replay would - // silently see an empty archive after fork; fail loudly here - // instead of corrupting the fork child later. - const loaded = loadedLibraries.get(name); - if (!loaded) { - throw new Error(`__wasm_dlopen(${name}): handle=${handle} but loadedLibraries lookup failed`); - } - persistArchiveEntry( - name, - bytesCopy, - loaded.memoryBase, - loaded.tableBase, - loaded.forkBufAddr ?? 0, - loaded.tlsBase ?? 0, - ); + notifyCommit(forkArchive.sync(lk.forkState())); + } else { + abortLinkerOperation(); } return handle; + } catch (error) { + abortLinkerOperation(); + throw error; } finally { + if (claimedLoader) releaseLoaderOwnershipIfIdle(); releaseMainDlopenLock(); } }, @@ -1013,31 +1998,60 @@ export function buildDlopenImports( namePtr: WasmGuestPointer, nameLen: number | bigint, ): number => { - // See __wasm_dlopen above: copy off the shared buffer before - // TextDecoder.decode() touches it. - const nameRange = checkedWasmMemoryRange( - memory, - namePtr, - nameLen, - ptrWidth, - "__wasm_dlsym name", - ); - const nameBytesView = new Uint8Array( - memory.buffer, - nameRange.offset, - nameRange.length, - ); - const nameBytesCopy = new Uint8Array(nameBytesView); - const name = decoder.decode(nameBytesCopy); - const result = getLinker().dlsym(handle, name); - return result === null ? 0 : (result as number); + if (!acquireMainDlopenLock()) return 0; + hostDlopenError = null; + try { + // See __wasm_dlopen above: copy off the shared buffer before + // TextDecoder.decode() touches it. + const nameRange = checkedWasmMemoryRange( + memory, + namePtr, + nameLen, + ptrWidth, + "__wasm_dlsym name", + ); + const nameBytesView = new Uint8Array( + memory.buffer, + nameRange.offset, + nameRange.length, + ); + const nameBytesCopy = new Uint8Array(nameBytesView); + const name = decoder.decode(nameBytesCopy); + const result = getLinker().dlsym(handle, name); + notifyCommit(undefined); + return result === null ? 0 : (result as number); + } catch (error) { + abortLinkerOperation(); + throw error; + } finally { + releaseMainDlopenLock(); + } }, __wasm_dlclose: (handle: number): number => { - return getLinker().dlclose(handle); + if (!acquireMainDlopenLock()) return -1; + hostDlopenError = null; + try { + const lk = getLinker(); + const result = lk.dlclose(handle); + if (result === 0) { + notifyCommit(forkArchive.sync(lk.forkState())); + } else { + abortLinkerOperation(); + } + return result; + } catch (error) { + abortLinkerOperation(); + throw error; + } finally { + releaseMainDlopenLock(); + } }, - __wasm_dlerror: (bufPtr: WasmGuestPointer, bufMax: number | bigint): number => { + __wasm_dlerror: ( + bufPtr: WasmGuestPointer, + bufMax: number | bigint, + ): number => { const err = hostDlopenError ?? getLinker().dlerror(); hostDlopenError = null; if (!err) return 0; @@ -1050,20 +2064,35 @@ export function buildDlopenImports( ptrWidth, "__wasm_dlerror buffer", ); - new Uint8Array(memory.buffer, range.offset, range.length) - .set(encoded.subarray(0, range.length)); + new Uint8Array(memory.buffer, range.offset, range.length).set( + encoded.subarray(0, range.length), + ); return range.length; }, }; return { imports, + readForkState, replayDlopens, - completeSideModuleForkUnwind, - beginSideModuleForkRewind, - beginSideModuleForkAbort, - assertNoActiveSideModuleFork, resetForkChildLock, + archive: forkArchive, + acquireArchiveWriter, + releaseArchiveWriter: releaseMainDlopenLock, + acquireArchiveReader, + releaseArchiveReader, + withArchiveWriter, + withArchiveReader, + writerOwned: () => mainDlopenDepth > 0, + setWriterAcquireObserver: (observer) => { + writerAcquireObserver = observer; + }, + setOperationAbortObserver: (observer) => { + operationAbortObserver = observer; + }, + setCommitObserver: (observer) => { + commitObserver = observer; + }, }; } @@ -1080,74 +2109,142 @@ function buildImportObject( ptrWidth: 4 | 8 = 4, longjmpTag?: WebAssembly.Tag, cppExceptionTag?: WebAssembly.Tag, + forkUnwindTag?: WebAssembly.Tag, postVmInterruptTimer?: ( timedOutPtr: number, vmInterruptPtr: number, seconds: number, ) => void, - forkContinuation?: LinkedForkContinuation, - onContinuationAbort?: () => void, + forkEnvImports?: Record, ): WebAssembly.Imports { const envImports: Record = { memory }; /** Convert wasm64 BigInt pointer to number (safe since addresses < 4GB) */ - const n = (v: number | bigint): number => typeof v === "bigint" ? Number(v) : v; + const n = (v: number | bigint): number => + typeof v === "bigint" ? Number(v) : v; /** Wrap a number as the correct return type for pointer-returning imports */ - const retPtr = (v: number): number | bigint => ptrWidth === 8 ? BigInt(v) : v; + const retPtr = (v: number): number | bigint => + ptrWidth === 8 ? BigInt(v) : v; // Provide __channel_base as a mutable wasm global if the module imports it. // Each instance gets its own global, immune to cross-thread shared memory corruption. // On wasm64, __channel_base is i64 (BigInt); on wasm32 it's i32 (number). const moduleImports = WebAssembly.Module.imports(module); - const importsFunction = (name: string): boolean => moduleImports.some( - (i) => i.module === "env" && i.name === name && i.kind === "function", - ); + const importsFunction = (name: string): boolean => + moduleImports.some( + (i) => i.module === "env" && i.name === name && i.kind === "function", + ); const linkedFrameImports = WPK_FORK_REQUIRED_IMPORTS.filter( ({ module }) => module === "env", ); - const linkedFrameImportCount = linkedFrameImports.filter( - ({ name }) => importsFunction(name), + const linkedFrameImportCount = linkedFrameImports.filter(({ name }) => + importsFunction(name), ).length; - if (linkedFrameImportCount !== 0 && linkedFrameImportCount !== linkedFrameImports.length) { - throw new Error("incomplete linked fork instrumentation imports; rebuild the program"); + if ( + linkedFrameImportCount !== 0 && + linkedFrameImportCount !== linkedFrameImports.length + ) { + throw new Error( + "incomplete linked fork instrumentation imports; rebuild the program", + ); } if (linkedFrameImportCount !== 0) { - if (!forkContinuation) { - throw new Error("linked fork instrumentation requested without continuation storage"); + if (!forkEnvImports) { + throw new Error( + "linked fork instrumentation requested without continuation and activation-state owners", + ); + } + for (const imported of moduleImports) { + if ( + imported.module !== "env" || + !imported.name.startsWith("__wpk_fork_") || + (imported.name === FORK_UNWIND_TAG_IMPORT_NAME && + (imported.kind as string) === "tag") + ) { + continue; + } + const value = forkEnvImports[imported.name]; + if (value === undefined) { + throw new Error( + `linked fork activation owner is missing env.${imported.name}`, + ); + } + if ( + imported.kind !== "function" && + imported.kind !== "global" && + imported.kind !== "table" + ) { + throw new Error( + `linked fork activation import env.${imported.name} has invalid kind ` + + `${imported.kind}`, + ); + } + envImports[imported.name] = value as WebAssembly.ExportValue; } - envImports.__wpk_fork_frame_reserve = (size: number | bigint) => { - const frame = forkContinuation.reserveFrame(size); - if (frame === 0 || frame === 0n) onContinuationAbort?.(); - return frame; - }; - envImports.__wpk_fork_frame_commit = (payload: number | bigint) => - forkContinuation.commitFrame(payload); - envImports.__wpk_fork_frame_next = (size: number | bigint) => - forkContinuation.nextFrame(size); } - if (moduleImports.some(i => i.module === "env" && i.name === "__channel_base" && i.kind === "global")) { + if ( + moduleImports.some( + (i) => + i.module === "env" && + i.name === "__channel_base" && + i.kind === "global", + ) + ) { if (ptrWidth === 8) { - envImports.__channel_base = new WebAssembly.Global({ value: "i64", mutable: true }, BigInt(channelOffset)); + envImports.__channel_base = new WebAssembly.Global( + { value: "i64", mutable: true }, + BigInt(channelOffset), + ); } else { - envImports.__channel_base = new WebAssembly.Global({ value: "i32", mutable: true }, channelOffset); + envImports.__channel_base = new WebAssembly.Global( + { value: "i32", mutable: true }, + channelOffset, + ); } } // LLVM/lld >= 22 import this tag for setjmp users. The process owns its // identity so a longjmp thrown through a side module can be caught by the // main image (and vice versa). - if (moduleImports.some(i => i.module === "env" && i.name === "__c_longjmp" && (i.kind as string) === "tag")) { + if ( + moduleImports.some( + (i) => + i.module === "env" && + i.name === "__c_longjmp" && + (i.kind as string) === "tag", + ) + ) { envImports.__c_longjmp = requireLongjmpTag( longjmpTag, "process module", ) as unknown as WebAssembly.ExportValue; } - if (moduleImports.some(i => i.module === "env" && i.name === "__cpp_exception" && (i.kind as string) === "tag")) { + if ( + moduleImports.some( + (i) => + i.module === "env" && + i.name === "__cpp_exception" && + (i.kind as string) === "tag", + ) + ) { envImports.__cpp_exception = requireCppExceptionTag( cppExceptionTag, "process module", ) as unknown as WebAssembly.ExportValue; } + if ( + moduleImports.some( + (i) => + i.module === FORK_UNWIND_TAG_IMPORT_MODULE && + i.name === FORK_UNWIND_TAG_IMPORT_NAME && + (i.kind as string) === "tag", + ) + ) { + envImports[FORK_UNWIND_TAG_IMPORT_NAME] = requireForkUnwindTag( + forkUnwindTag, + "process module", + ) as unknown as WebAssembly.ExportValue; + } // Add dlopen imports if provided if (dlopenImports) { @@ -1163,7 +2260,9 @@ function buildImportObject( ) ) { if (!postVmInterruptTimer) { - throw new Error("VM interrupt timer import requested without a host timer route"); + throw new Error( + "VM interrupt timer import requested without a host timer route", + ); } envImports.__wasm_posix_vm_interrupt_after = ( timedOutPtr: number | bigint, @@ -1179,21 +2278,23 @@ function buildImportObject( if (getInstance) { const cppMalloc = (size: number | bigint): number | bigint => { const inst = getInstance(); - const malloc = inst?.exports.malloc as ((n: number | bigint) => number | bigint) | undefined; + const malloc = inst?.exports.malloc as + ((n: number | bigint) => number | bigint) | undefined; if (!malloc) return ptrWidth === 8 ? 0n : 0; return malloc(size || (ptrWidth === 8 ? 1n : 1)); }; const cppFree = (ptr: number | bigint): void => { const inst = getInstance(); - const free = inst?.exports.free as ((p: number | bigint) => void) | undefined; + const free = inst?.exports.free as + ((p: number | bigint) => void) | undefined; if (free) free(ptr); }; - envImports._Znwm = cppMalloc; // operator new(size_t) - envImports._Znam = cppMalloc; // operator new[](size_t) - envImports._ZdlPv = cppFree; // operator delete(void*) - envImports._ZdlPvm = cppFree; // operator delete(void*, size_t) - envImports._ZdaPv = cppFree; // operator delete[](void*) - envImports._ZdaPvm = cppFree; // operator delete[](void*, size_t) + envImports._Znwm = cppMalloc; // operator new(size_t) + envImports._Znam = cppMalloc; // operator new[](size_t) + envImports._ZdlPv = cppFree; // operator delete(void*) + envImports._ZdlPvm = cppFree; // operator delete(void*, size_t) + envImports._ZdaPv = cppFree; // operator delete[](void*) + envImports._ZdaPvm = cppFree; // operator delete[](void*, size_t) envImports._ZnwmRKSt9nothrow_t = cppMalloc; // operator new(size_t, nothrow) envImports._ZnamRKSt9nothrow_t = cppMalloc; // operator new[](size_t, nothrow) } @@ -1211,7 +2312,9 @@ function buildImportObject( const view = new Uint8Array(memory.buffer); view[n(guardPtr)] = 1; // mark initialized }; - envImports.__cxa_guard_abort = (_guardPtr: number | bigint): void => { /* no-op */ }; + envImports.__cxa_guard_abort = (_guardPtr: number | bigint): void => { + /* no-op */ + }; envImports.__cxa_pure_virtual = (): void => { throw new Error("pure virtual method called"); }; @@ -1219,13 +2322,20 @@ function buildImportObject( envImports.__cxa_thread_atexit = (): number => 0; // no-op, return success // libc++ verbose abort — called on internal library errors - envImports._ZNSt3__122__libcpp_verbose_abortEPKcz = (_fmt: number | bigint, _args: number | bigint): void => { + envImports._ZNSt3__122__libcpp_verbose_abortEPKcz = ( + _fmt: number | bigint, + _args: number | bigint, + ): void => { throw new Error("libc++ verbose abort"); }; // libc++ sort — MariaDB doesn't actually call this at runtime // (linked from empty stub libc++.a). Signature: sort, ull*>(first, last, comp) - envImports["_ZNSt3__16__sortIRNS_6__lessIyyEEPyEEvT0_S5_T_"] = (_first: number | bigint, _last: number | bigint, _comp: number | bigint): void => { + envImports["_ZNSt3__16__sortIRNS_6__lessIyyEEPyEEvT0_S5_T_"] = ( + _first: number | bigint, + _last: number | bigint, + _comp: number | bigint, + ): void => { throw new Error("libc++ sort called unexpectedly"); }; const dcTiClassCache = new Map(); // typeinfo addr → metaclass (0=leaf, 1=SI, 2=VMI) @@ -1233,7 +2343,12 @@ function buildImportObject( // Reads RTTI from the object's vtable and walks the type hierarchy to // check if dst_type is reachable from the object's runtime type. // Args: (src_ptr, src_typeinfo*, dst_typeinfo*, src2dst_hint) - envImports.__dynamic_cast = (srcPtr_: number | bigint, _srcType: number | bigint, dstType_: number | bigint, _src2dst: number | bigint): number | bigint => { + envImports.__dynamic_cast = ( + srcPtr_: number | bigint, + _srcType: number | bigint, + dstType_: number | bigint, + _src2dst: number | bigint, + ): number | bigint => { const srcPtr = n(srcPtr_); const dstType = n(dstType_); if (srcPtr === 0) return retPtr(0); @@ -1241,9 +2356,13 @@ function buildImportObject( const memSize = memory.buffer.byteLength; const PS = ptrWidth; // pointer size in bytes const readPtr = (addr: number): number => - PS === 8 ? Number(view.getBigUint64(addr, true)) : view.getUint32(addr, true); + PS === 8 + ? Number(view.getBigUint64(addr, true)) + : view.getUint32(addr, true); const readSPtr = (addr: number): number => - PS === 8 ? Number(view.getBigInt64(addr, true)) : view.getInt32(addr, true); + PS === 8 + ? Number(view.getBigInt64(addr, true)) + : view.getInt32(addr, true); // Read vtable pointer from object (Itanium ABI: first word is vtable ptr) const vtablePtr = readPtr(srcPtr); @@ -1277,7 +2396,11 @@ function buildImportObject( const tiClassCache = dcTiClassCache; - const isTypeAncestor = (ti: number, target: number, visited: Set): boolean => { + const isTypeAncestor = ( + ti: number, + target: number, + visited: Set, + ): boolean => { if (ti === target) return true; if (ti === 0 || ti >= memSize || visited.has(ti)) return false; visited.add(ti); @@ -1296,7 +2419,8 @@ function buildImportObject( const baseCount = view.getUint32(ti + TI_FIELD2 + 4, true); for (let i = 0; i < baseCount; i++) { const baseType = readPtr(ti + TI_FIELD2 + 8 + i * BASE_INFO_STRIDE); - if (baseType > 0 && isTypeAncestor(baseType, target, visited)) return true; + if (baseType > 0 && isTypeAncestor(baseType, target, visited)) + return true; } return false; } @@ -1318,11 +2442,16 @@ function buildImportObject( const flags32 = view.getUint32(ti + TI_FIELD2, true); if (flags32 <= 3 && ti + TI_FIELD2 + 8 <= memSize) { const baseCount = view.getUint32(ti + TI_FIELD2 + 4, true); - if (baseCount > 0 && baseCount < 100 && ti + TI_FIELD2 + 8 + baseCount * BASE_INFO_STRIDE <= memSize) { + if ( + baseCount > 0 && + baseCount < 100 && + ti + TI_FIELD2 + 8 + baseCount * BASE_INFO_STRIDE <= memSize + ) { tiClassCache.set(ti, 2); for (let i = 0; i < baseCount; i++) { const baseType = readPtr(ti + TI_FIELD2 + 8 + i * BASE_INFO_STRIDE); - if (baseType > 0 && isTypeAncestor(baseType, target, visited)) return true; + if (baseType > 0 && isTypeAncestor(baseType, target, visited)) + return true; } return false; } @@ -1339,16 +2468,20 @@ function buildImportObject( }; // libc++ sort specialization — sort uint64 array in-place - envImports['_ZNSt3__16__sortIRNS_6__lessIyyEEPyEEvT0_S5_T_'] = ( - begin_: number | bigint, end_: number | bigint, + envImports["_ZNSt3__16__sortIRNS_6__lessIyyEEPyEEvT0_S5_T_"] = ( + begin_: number | bigint, + end_: number | bigint, ): void => { - const begin = n(begin_), end = n(end_); + const begin = n(begin_), + end = n(end_); const view = new DataView(memory.buffer); const count = (end - begin) / 8; const arr: bigint[] = []; - for (let i = 0; i < count; i++) arr.push(view.getBigUint64(begin + i * 8, true)); + for (let i = 0; i < count; i++) + arr.push(view.getBigUint64(begin + i * 8, true)); arr.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); - for (let i = 0; i < count; i++) view.setBigUint64(begin + i * 8, arr[i], true); + for (let i = 0; i < count; i++) + view.setBigUint64(begin + i * 8, arr[i], true); }; // Stub any remaining unresolved function imports @@ -1406,45 +2539,20 @@ export function forkSaveBufferOverrun( forkBufSize: number, ): number { const view = new DataView(memory.buffer); - const currentPos = ptrWidth === 8 - ? Number(view.getBigUint64(forkBufAddr, true)) - : view.getUint32(forkBufAddr, true); + const currentPos = + ptrWidth === 8 + ? Number(view.getBigUint64(forkBufAddr, true)) + : view.getUint32(forkBufAddr, true); const bufferEnd = forkBufAddr + forkBufSize; return currentPos > bufferEnd ? currentPos - bufferEnd : 0; } -/** - * Finish the active side-module unwind and reject an overrun before the main - * worker is allowed to send SYS_FORK. Side modules own a save-buffer - * allocation separate from the main process channel, so checking only the - * main buffer cannot protect this continuation. - */ -export function finalizeSideModuleForkUnwind( - _memory: WebAssembly.Memory, - state: SideModuleForkState, - _ptrWidth: 4 | 8, -): void { - const sideForkState = (): number => - Number((state.instance.exports.wpk_fork_state as () => number)()); - if (sideForkState() !== 1) { - throw new Error(`${state.name}: expected UNWINDING before side-module unwind completion`); - } - (state.instance.exports.wpk_fork_unwind_end as () => void)(); - if (sideForkState() !== 0) { - throw new Error(`${state.name}: side-module unwind did not return to NORMAL`); - } - - state.continuation.finishUnwind(); -} - // Host-private control slots below the process main channel's fork buffer. // Fork's memcpy carries the parent's dlopen archive into the child intact; // the child walks it to replay each module before wpk_fork rewind. These are // intentionally not relative to a pthread's rewind buffer. const DLOPEN_HEAD_OFFSET_WASM32 = 12; const DLOPEN_HEAD_OFFSET_WASM64 = 24; -const DLOPEN_ACTIVE_SIDE_FORK_OFFSET_WASM32 = 16; -const DLOPEN_ACTIVE_SIDE_FORK_OFFSET_WASM64 = 32; // Atomic host-private reader/writer arbitration between process-main dlopen // and pthread fork. A negative value is the exclusive main-worker dlopen // writer; a positive value counts concurrent pthread forks from their @@ -1454,37 +2562,267 @@ const DLOPEN_ACTIVE_SIDE_FORK_OFFSET_WASM64 = 32; // clears its copied value before replay because its memory is independent. const DLOPEN_LOCK_OFFSET_WASM32 = 20; const DLOPEN_LOCK_OFFSET_WASM64 = 40; +// Positive identity of the Worker whose ordinary Wasm stack owns every live +// staged loader transaction. Unlike the short archive writer, this lease spans +// guest bootstrap/relocation/constructor calls. Same-owner fork is legal; +// another pthread must wait because its child cannot inherit the owner's stack. +const DLOPEN_OWNER_OFFSET_WASM32 = 24; +const DLOPEN_OWNER_OFFSET_WASM64 = 36; +// One fixed, naturally aligned u64 fence lets instrumented Wasm detect a +// newer process table snapshot without crossing into JavaScript on the steady +// state path. The archive header remains the authoritative validated value. +const DLOPEN_GENERATION_OFFSET_WASM32 = 32; +const DLOPEN_GENERATION_OFFSET_WASM64 = 48; const DLOPEN_MAX_CONTROL_OFFSET = Math.max( DLOPEN_HEAD_OFFSET_WASM32, DLOPEN_HEAD_OFFSET_WASM64, - DLOPEN_ACTIVE_SIDE_FORK_OFFSET_WASM32, - DLOPEN_ACTIVE_SIDE_FORK_OFFSET_WASM64, DLOPEN_LOCK_OFFSET_WASM32, DLOPEN_LOCK_OFFSET_WASM64, + DLOPEN_OWNER_OFFSET_WASM32, + DLOPEN_OWNER_OFFSET_WASM64, + DLOPEN_GENERATION_OFFSET_WASM32, + DLOPEN_GENERATION_OFFSET_WASM64, ); if ( - FORK_BUF_SIZE % 16 !== 0 - || FORK_SAVE_CONTROL_PREFIX_SIZE + FORK_BUF_SIZE !== WASM_PAGE_SIZE - || DLOPEN_MAX_CONTROL_OFFSET > FORK_SAVE_CONTROL_PREFIX_SIZE + FORK_BUF_SIZE % 16 !== 0 || + FORK_SAVE_CONTROL_PREFIX_SIZE + FORK_BUF_SIZE !== WASM_PAGE_SIZE || + DLOPEN_MAX_CONTROL_OFFSET > FORK_SAVE_CONTROL_PREFIX_SIZE ) { throw new Error("invalid fork-save scratch-page geometry"); } -const DLOPEN_LOCK_IDLE = 0; -const DLOPEN_LOCK_WRITER = -1; -const DLOPEN_LOCK_MAX_READERS = 0x7fff_ffff; -// Each entry also carries the side module's instance-local TLS base. Fork -// copies the TLS bytes in memory, but a new replay instance's mutable global -// must be restored explicitly. Zero is the explicit no-TLS sentinel; TLS -// allocations are required to have a positive base. -// -// This is a host-private, transient replay record: the same host build writes -// and reads it around one fork, and neither guest code nor persisted package -// artifacts interpret the layout. Enlarging it therefore does not alter the -// guest/kernel ABI. The ABI classifier/check still guards the public contract. -const DLOPEN_ENTRY_SIZE_WASM32 = 40; -const DLOPEN_ENTRY_SIZE_WASM64 = 72; +const DLOPEN_LOCK_IDLE = 0; +const DLOPEN_LOCK_WRITER = -1; +const DLOPEN_LOCK_MAX_READERS = 0x7fff_ffff; +const DLOPEN_OWNER_IDLE = 0; +const RTLD_GLOBAL = 0x100; +const WPK_FORK_EXPORTS = WPK_FORK_REQUIRED_EXPORTS.map(({ name }) => name); + +interface ProcessTableReplicationOwner extends ForkActivationTableReplication { + /** Bring this Worker to the latest complete process generation. */ + reconcileNow(): number; + /** Check the archive fence while the caller already excludes writers. */ + isCurrentUnderLock(): boolean; + /** Release any mutation writer depths unwound by a Wasm trap. */ + abortActiveMutations(): void; +} + +function createProcessTableReplicationOwner(options: { + readonly generationAddress: number; + readonly registry: ForkActivationRegistry; + readonly dlopen: DlopenSupport; + readonly newArena: () => ForkModuleStateArena; + readonly materializeModules: (snapshot: DylinkForkArchiveSnapshot) => void; + readonly restoreSnapshots: boolean; + readonly label: string; +}): ProcessTableReplicationOwner { + const generationAddress = new WebAssembly.Global( + { value: "i64", mutable: false }, + BigInt(options.generationAddress), + ); + let deferredPublication = false; + let replicaMaterializing = false; + let suppressInitialSnapshotRestore = !options.restoreSnapshots; + const mutationContexts: Array<{ readonly deferPublication: boolean }> = []; + + const releaseArena = (root: number): void => { + if (root === 0) return; + const arena = options.newArena(); + arena.attach(root); + arena.release(); + }; + const replica = new DylinkForkTableReplica( + options.dlopen.archive, + (snapshot, previousGeneration) => { + options.materializeModules(snapshot); + if (suppressInitialSnapshotRestore) { + // WHY: a fork child restores the exact capture-time table graph from + // its normal KFMS arena after all activations exist. The archive + // generation is still adopted now; only this first redundant restore + // is suppressed. Later pthread/process mutations must be applied. + suppressInitialSnapshotRestore = false; + return; + } + let patchFloor = previousGeneration; + if ( + snapshot.tableStateRoot !== 0 && + snapshot.tableCheckpointGeneration > previousGeneration + ) { + const arena = options.newArena(); + arena.attach(snapshot.tableStateRoot); + options.registry.restoreTableState(arena); + // The archive, not this temporary validated view, owns the mappings. + patchFloor = snapshot.tableCheckpointGeneration; + } + for (const patch of snapshot.tablePatches) { + if (patch.generation! > patchFloor) { + options.registry.applyFuncrefTablePatch(patch); + } + } + }, + `${options.label}: table replica`, + ); + + const reconcileLocked = (): number => { + replicaMaterializing = true; + try { + const suppressingInitialRestore = suppressInitialSnapshotRestore; + const changed = replica.reconcile(); + if (suppressingInitialRestore && !changed) { + // A child whose copied process has never published a dylink/table + // archive still completed its one startup reconciliation. Do not + // suppress the first real peer mutation published later. + suppressInitialSnapshotRestore = false; + } + return replica.generation(); + } finally { + replicaMaterializing = false; + // Module constructors and loader table writes performed while applying + // a validated archive snapshot are effects of that publication, not a + // new mutation authored by this Worker. + deferredPublication = false; + } + }; + + const publishLocked = (): DylinkForkArchiveSnapshot => { + const arena = options.newArena(); + arena.begin(); + let root: number; + try { + root = options.registry.captureTableState(arena); + } catch (error) { + if (arena.hasActiveArena()) arena.release(); + throw error; + } + let publication; + try { + publication = options.dlopen.archive.publishTableState(root); + } catch (error) { + arena.release(); + throw error; + } + replica.adoptPublishedGeneration(publication.snapshot.generation); + if ( + publication.previousTableStateRoot !== 0 && + publication.previousTableStateRoot !== root + ) { + releaseArena(publication.previousTableStateRoot); + } + deferredPublication = false; + return publication.snapshot; + }; + + options.dlopen.setCommitObserver( + (linkerPublication, tableMutationCommitted) => { + if (linkerPublication) { + replica.adoptPublishedGeneration(linkerPublication.generation); + } + const hasPriorGuestOverlay = + linkerPublication?.tableStateRoot !== undefined && + (linkerPublication.tableStateRoot !== 0 || + linkerPublication.tablePatches.length !== 0); + // Loader-owned table entries are already deterministic module recipes in + // the dylink archive. Until a guest/dlsym overlay exists, publishing the + // same entries again as a typed KFMS snapshot would turn ordinary dlopen + // into an O(table closure) operation. Once an overlay exists, a module-set + // change still needs a fresh exact activation manifest. + if ( + deferredPublication || + (!linkerPublication && tableMutationCommitted) || + hasPriorGuestOverlay + ) { + publishLocked(); + } + }, + ); + options.dlopen.setWriterAcquireObserver(() => { + // Called with exclusive process ownership. Reconcile before any linker or + // guest mutation reads this Worker's instance-local table so the protected + // operation cannot overwrite a newer peer publication. + reconcileLocked(); + }); + + const reconcileNow = (): number => + options.dlopen.withArchiveWriter(reconcileLocked); + const abortActiveMutations = (): void => { + while (mutationContexts.length > 0) { + mutationContexts.pop(); + options.dlopen.releaseArchiveWriter(); + } + deferredPublication = false; + }; + options.dlopen.setOperationAbortObserver(abortActiveMutations); + + return { + generationAddress, + beginMutation: (): bigint => { + const deferPublication = options.dlopen.writerOwned(); + options.dlopen.acquireArchiveWriter(); + mutationContexts.push({ deferPublication }); + return BigInt(replica.generation()); + }, + reconcile: (): bigint => BigInt(reconcileNow()), + commit: (activationId, ownerId, firstIndex, length): void => { + const context = mutationContexts.pop(); + if (!context) { + throw new Error( + `${options.label}: table mutation committed without ownership`, + ); + } + try { + // Dlopen/start mutations occur before the module manifest and handle + // graph are publishable. The enclosing linker transaction snapshots + // once at its commit. Replica materialization is already represented + // by the generation being applied and must not echo a publication. + if (context.deferPublication) { + if (!replicaMaterializing) deferredPublication = true; + } else { + const patch = options.registry.captureFuncrefTablePatch( + activationId, + ownerId, + firstIndex, + length, + ); + if ( + patch !== null && + options.dlopen.archive.canPublishTablePatch(patch) + ) { + const publication = options.dlopen.archive.publishTablePatch(patch); + replica.adoptPublishedGeneration(publication.snapshot.generation); + } else { + // Typed/opaque entries stay on the Wasm codec path. The same full + // checkpoint transparently compacts a bounded patch journal; no + // table shape or mutation is rejected at either threshold. + publishLocked(); + } + } + } finally { + options.dlopen.releaseArchiveWriter(); + } + }, + abort: (): void => { + const context = mutationContexts.pop(); + if (!context) { + throw new Error( + `${options.label}: table mutation aborted without ownership`, + ); + } + options.dlopen.releaseArchiveWriter(); + }, + reconcileNow, + isCurrentUnderLock: () => + replica.generation() === options.dlopen.archive.generation(), + abortActiveMutations, + }; +} -const WPK_FORK_EXPORTS = WPK_FORK_REQUIRED_EXPORTS.map(({ name }) => name); +/** @internal Exact publication-lifecycle seam; not re-exported by the host API. */ +export function __testCreateProcessTableReplicationOwner( + options: unknown, +): unknown { + return createProcessTableReplicationOwner( + options as Parameters[0], + ); +} function hasCompleteForkInstrumentation( module: WebAssembly.Module, @@ -1492,7 +2830,9 @@ function hasCompleteForkInstrumentation( ): boolean { const moduleExports = WebAssembly.Module.exports(module); const exportNames = new Set(moduleExports.map((e) => e.name)); - const legacyAsyncifyExports = [...exportNames].filter((name) => name.startsWith("asyncify_")); + const legacyAsyncifyExports = [...exportNames].filter((name) => + name.startsWith("asyncify_"), + ); if (legacyAsyncifyExports.length > 0) { throw new Error( `pid=${pid}: user program exports legacy Asyncify instrumentation ` + @@ -1501,8 +2841,13 @@ function hasCompleteForkInstrumentation( ); } - const presentWpkExports = WPK_FORK_EXPORTS.filter((name) => exportNames.has(name)); - if (presentWpkExports.length > 0 && presentWpkExports.length !== WPK_FORK_EXPORTS.length) { + const presentWpkExports = WPK_FORK_EXPORTS.filter((name) => + exportNames.has(name), + ); + if ( + presentWpkExports.length > 0 && + presentWpkExports.length !== WPK_FORK_EXPORTS.length + ) { const missing = WPK_FORK_EXPORTS.filter((name) => !exportNames.has(name)); throw new Error( `pid=${pid}: incomplete wasm-fork-instrument exports; missing ${missing.join(", ")}. ` + @@ -1589,6 +2934,7 @@ export async function centralizedWorkerMain( port: MessagePort, initData: CentralizedWorkerInitMessage, ): Promise { + let processHostImportRuntime: ForkHostImportWorkerRuntime | null = null; try { const { memory, programBytes, channelOffset, pid } = initData; const ptrWidth = initData.ptrWidth ?? 4; @@ -1610,7 +2956,7 @@ export async function centralizedWorkerMain( if (wasiModuleDefinesMemory(module)) { throw new Error( "WASI module defines its own memory. Only modules that import memory " + - "(compiled with --import-memory) are supported.", + "(compiled with --import-memory) are supported.", ); } @@ -1620,13 +2966,19 @@ export async function centralizedWorkerMain( const { WasiShim, WasiExit } = await import("./wasi-shim"); const wasiShim = new WasiShim( - memory, channelOffset, initData.argv || [], initData.env || [], + memory, + channelOffset, + initData.argv || [], + initData.env || [], ); const wasiImports = wasiShim.getImports(); // Build import object: provide wasi_snapshot_preview1 namespace + env.memory const importObject: WebAssembly.Imports = { - wasi_snapshot_preview1: wasiImports as Record, + wasi_snapshot_preview1: wasiImports as Record< + string, + WebAssembly.ExportValue + >, env: { memory }, }; @@ -1637,7 +2989,11 @@ export async function centralizedWorkerMain( if (!(importObject.env as Record)[imp.name]) { (importObject.env as Record)[imp.name] = imp.kind === "function" - ? (..._args: unknown[]) => { throw new Error(`Unimplemented WASI env import: ${imp.name}`); } + ? (..._args: unknown[]) => { + throw new Error( + `Unimplemented WASI env import: ${imp.name}`, + ); + } : undefined; } } @@ -1664,20 +3020,27 @@ export async function centralizedWorkerMain( } } - port.postMessage({ type: "exit", pid, status: exitCode } satisfies WorkerToHostMessage); + port.postMessage({ + type: "exit", + pid, + status: exitCode, + } satisfies WorkerToHostMessage); return; } // --- SDK module path (existing) --- const processLongjmpTag = createLongjmpTag(ptrWidth); const processCppExceptionTag = createCppExceptionTag(ptrWidth); + const processForkUnwindTag = createForkUnwindTag(); let kernelExitStatus: number | null = null; const kernelImports = buildKernelImports( memory, channelOffset, initData.argv || [], initData.env || [], - (status) => { kernelExitStatus = status; }, + (status) => { + kernelExitStatus = status; + }, ); // Check if the module has complete wpk_fork_* instrumentation exports, @@ -1695,90 +3058,578 @@ export async function centralizedWorkerMain( if (hasForkInstrumentation) { const linkedFrameFormat = readLinkedFrameFormat(module); + const moduleStateFormat = readForkModuleStateDescriptor(module); + if (moduleStateFormat.ptrWidth !== linkedFrameFormat.ptrWidth) { + throw new Error( + `pid=${pid}: module-state pointer width ${moduleStateFormat.ptrWidth} ` + + `does not match linked frames ${linkedFrameFormat.ptrWidth}`, + ); + } + const mainTemplateId = await computeForkModuleTemplateId(programBytes); const forkContinuation = new LinkedForkContinuation( memory, linkedFrameFormat, (size) => continuationMmap(memory, channelOffset, size, `pid=${pid}`), - (addr, size) => continuationMunmap(memory, channelOffset, addr, size, `pid=${pid}`), + (addr, size) => + continuationMunmap(memory, channelOffset, addr, size, `pid=${pid}`), `pid=${pid}`, ); - // Override kernel_fork with fork-instrumentation-aware version. - // Late-bound: processInstance is set after instantiation. let processInstance: WebAssembly.Instance | null = null; + const newModuleStateArena = (): ForkModuleStateArena => + new ForkModuleStateArena( + memory, + ptrWidth, + (size) => + continuationMmap( + memory, + channelOffset, + size, + `pid=${pid}: module state`, + ), + (addr, size) => + continuationMunmap( + memory, + channelOffset, + addr, + size, + `pid=${pid}: module state`, + ), + `pid=${pid}`, + ); + + if ( + initData.forkHostImports === undefined || + initData.externrefGenerationId === undefined + ) { + throw new Error( + `pid=${pid}: ABI 43 fork artifact requires its process owner ` + + "host-import mailbox and externref generation", + ); + } + const externrefTokens = new ForkExternrefTokenCache( + initData.externrefGenerationId, + ); + processHostImportRuntime = new ForkHostImportWorkerRuntime( + initData.forkHostImports, + pid, + initData.externrefGenerationId, + externrefTokens, + (wake) => { + port.postMessage({ + type: "fork_host_import", + wake, + } satisfies WorkerToHostMessage); + }, + ); + const externrefRecipes = new ForkExternrefTokenRecipeProvider( + externrefTokens, + (value) => + processHostImportRuntime!.localExceptions.normalizeUnclaimedForkValue( + value, + ), + ); + const activationRegistry = new ForkActivationRegistry( + memory, + externrefRecipes, + `pid=${pid}: fork activations`, + (size) => + continuationMmap( + memory, + channelOffset, + size, + `pid=${pid}: reference scratch`, + ), + (addr, size) => + continuationMunmap( + memory, + channelOffset, + addr, + size, + `pid=${pid}: reference scratch`, + ), + ); + // Every process instance, including a freshly reconstructed child, owns + // the provenance manifest for any fork it may issue later. + const importedStateCapture = new ForkImportedGlobalCapture( + `pid=${pid}: imported activation state`, + ); + let importedStatePlanner: ForkImportedGlobalPlanner | null = null; + let earlyChildReferences: ForkEarlyChildReferenceProvider | null = null; + let decodedChildReferences: DecodedSegmentedForkReferenceTransaction | null = + null; + let childDylinkState: DylinkForkState | null = null; + const referenceReplay = (): ProcessReferenceReplayImports => + earlyChildReferences ?? activationRegistry.currentReferences(); + const processContinuation = new ForkProcessContinuationCoordinator( + memory, + activationRegistry, + `pid=${pid}: process continuation`, + ); + let processDlopenSupport: DlopenSupport | null = null; + let processForkArchiveReaderHeld = false; + const tableGenerationOffset = + ptrWidth === 8 + ? DLOPEN_GENERATION_OFFSET_WASM64 + : DLOPEN_GENERATION_OFFSET_WASM32; + const tableGenerationAddress = + dlopenArchiveControlAddr - tableGenerationOffset; + let processTableReplication: ProcessTableReplicationOwner | null = null; + const tableReplicationImports: ForkActivationTableReplication = { + generationAddress: new WebAssembly.Global( + { value: "i64", mutable: false }, + BigInt(tableGenerationAddress), + ), + reconcile: (): bigint => processTableReplication?.reconcile() ?? 0n, + beginMutation: (): bigint => + processTableReplication?.beginMutation() ?? 0n, + commit: (activationId, ownerId, firstIndex, length): void => { + processTableReplication?.commit( + activationId, + ownerId, + firstIndex, + length, + ); + }, + abort: (): void => { + processTableReplication?.abort(); + }, + }; + const exceptionBroker = new ForkExceptionBroker( + activationRegistry, + `pid=${pid}: exception broker`, + () => earlyChildReferences ?? activationRegistry.currentReferences(), + (value) => + processHostImportRuntime!.localExceptions.normalizeUnclaimedForkException( + value, + ), + ); + let mainExceptionProvider: ForkExceptionProvider | null = null; + const registerChildReferenceActivation = ( + activationId: number, + activationModule: WebAssembly.Module, + registration: ForkActivationRegistration, + typedReferenceProvider: ForkGcCodecProvider, + ): void => { + const early = earlyChildReferences; + if (!early) { + throw new Error( + `pid=${pid}: child activation ${activationId} registered ` + + "outside early reference reconstruction", + ); + } + if ( + registration.activationId !== activationId || + typedReferenceProvider.activationId !== activationId + ) { + throw new Error( + `pid=${pid}: child activation ${activationId} provider coordinate mismatch`, + ); + } + // Parse against this exact compiled module before publishing any + // provider. ForkEarlyChildReferenceProvider compares the resulting + // descriptor to the pre-instantiation declaration. + readForkGcCodecDescriptor(activationModule); + // WHY: these catalogs contain fresh-instance identities. Register the + // activation only after the full registry has harvested static roots, + // but before a later module's immutable import getter can request one. + early.registerActivation({ + activationId, + functions: { + decode(ordinal) { + if ( + !Number.isInteger(ordinal) || + ordinal < 0 || + ordinal >= registration.functionCatalog.length + ) { + throw new RangeError( + `pid=${pid}: function recipe ${activationId}:` + + `${String(ordinal)} is out of bounds`, + ); + } + const value = registration.functionCatalog.get(ordinal); + if (typeof value !== "function") { + throw new TypeError( + `pid=${pid}: function recipe ${activationId}:${ordinal} ` + + "did not resolve to a function", + ); + } + return value as CallableFunction; + }, + }, + staticRoots: { + decode: (ordinal) => + activationRegistry.decodeStaticRoot(activationId, ordinal), + }, + typed: typedReferenceProvider, + exceptions: registration.exceptionProvider, + }); + }; + + const readProcessLaunchRoot = (): number => { + const view = new DataView(memory.buffer); + return ptrWidth === 8 + ? Number(view.getBigUint64(dlopenArchiveControlAddr, true)) + : view.getUint32(dlopenArchiveControlAddr, true); + }; + let copiedLaunchRoot = 0; + let childArena: ForkModuleStateArena | null = null; + if (initData.isForkChild) { + if ( + initData.forkChildThreadFnPtr !== undefined && + initData.forkBufAddr !== undefined + ) { + // A pthread continuation is rooted in the caller's channel page, + // not the process-main anchor copied into the child. Publish the + // kernel-validated launch root under activation zero before any + // child reconstruction recipe is inspected. + writeForkContinuationAnchor( + memory, + dlopenArchiveControlAddr, + ptrWidth, + initData.forkBufAddr, + ); + } + copiedLaunchRoot = readProcessLaunchRoot(); + if ( + initData.forkBufAddr !== undefined && + copiedLaunchRoot !== initData.forkBufAddr + ) { + throw new Error( + `pid=${pid}: copied process launch root ${copiedLaunchRoot} ` + + `does not match launch root ${initData.forkBufAddr}`, + ); + } + if (!Number.isSafeInteger(copiedLaunchRoot) || copiedLaunchRoot <= 0) { + throw new Error( + `pid=${pid}: fork child has no copied process launch root`, + ); + } + const moduleStateRoot = readForkModuleStateRoot( + memory, + copiedLaunchRoot, + ptrWidth, + ); + childArena = newModuleStateArena(); + childArena.attach( + ptrWidth === 8 ? BigInt(moduleStateRoot) : moduleStateRoot, + ); + } + processContinuation.prepareActivation({ + activationId: 0, + continuation: forkContinuation, + publishProcessLaunchRoot: (address) => { + // WHY: this copied control-page word is the fresh child's route to + // the main activation. No JavaScript closure survives fork. + writeForkContinuationAnchor( + memory, + dlopenArchiveControlAddr, + ptrWidth, + address, + ); + forkBufAddr = address; + }, + readProcessLaunchRoot, + }); + + const releaseProcessForkArchiveReader = (): void => { + if (!processForkArchiveReaderHeld) return; + processForkArchiveReaderHeld = false; + processDlopenSupport?.releaseArchiveReader(); + }; + const acquireCurrentProcessForkArchiveReader = (): void => { + if (!processDlopenSupport || !processTableReplication) { + throw new Error(`pid=${pid}: fork archive owner is not initialized`); + } + for (;;) { + processTableReplication.reconcileNow(); + processDlopenSupport.acquireArchiveReader(); + processForkArchiveReaderHeld = true; + if (processTableReplication.isCurrentUnderLock()) return; + releaseProcessForkArchiveReader(); + } + }; + kernelImports.kernel_fork = (): number => { if (!processInstance) return -38; // ENOSYS - const getState = processInstance.exports.wpk_fork_state as () => number; - const state = getState(); - if (state === 2) { - // Rewinding: end rewind and return the stored fork result - (processInstance.exports.wpk_fork_rewind_end as () => void)(); - forkContinuation.finishReplayAndRelease(); - writeForkContinuationAnchor(memory, dlopenArchiveControlAddr, ptrWidth, 0); - forkBufAddr = 0; + const phase = processContinuation.phaseName(); + if (phase === "parent-replay" || phase === "child-replay") { + try { + processContinuation.finishReplay(); + } finally { + releaseProcessForkArchiveReader(); + } + if (initData.isForkChild) { + const gate = initData.forkReplayGate; + if (!gate) { + throw new Error( + `pid=${pid}: fork child is missing its replay commit gate`, + ); + } + // Every outer activation has already restored its frame before + // descending to this import. Reaching here is therefore the exact + // point at which the host may commit the fresh child. + port.postMessage({ + type: "fork_replay_ready", + pid, + } satisfies WorkerToHostMessage); + waitForForkReplayCommit(gate, `pid=${pid}`); + } return forkResult; } - if (state === 3) { + if (phase === "abort-replay") { const errno = forkContinuation.abortErrno(); - (processInstance.exports.wpk_fork_abort_end as () => void)(); - forkContinuation.finishAbortReplayAndRelease(); - writeForkContinuationAnchor(memory, dlopenArchiveControlAddr, ptrWidth, 0); - forkBufAddr = 0; + try { + processContinuation.finishAbortReplay(); + } finally { + releaseProcessForkArchiveReader(); + } return -errno; } + if (phase !== "idle") { + throw new Error( + `pid=${pid}: fork import reached while process continuation is ${phase}`, + ); + } - // Normal call: start unwind to save the call stack. - // SYS_FORK is sent after _start returns (unwind complete). - // wpk_fork_unwind_begin self-initializes current_pos and snapshots - // saved_globals (including __tls_base and __stack_pointer) into the - // buffer — the host no longer pre-seeds the header. + // The arena and every activation prefix are allocated before any user + // frame commits. If this fails, fork returns errno with no partially + // published activation graph. + acquireCurrentProcessForkArchiveReader(); + const arena = newModuleStateArena(); try { - forkBufAddr = Number(forkContinuation.beginUnwind()); + arena.begin(); + processContinuation.beginCapture(arena); + importedStateCapture?.appendTo(arena); } catch (error) { + if (processContinuation.phaseName() !== "idle") { + try { + processContinuation.abort(); + } catch { + // Preserve the capture failure; abort has already made the + // transaction unreachable before attempting cleanup. + } + } else if (arena.hasActiveArena()) { + arena.release(); + } + releaseProcessForkArchiveReader(); + forkBufAddr = 0; if (error instanceof ContinuationAllocationError) return -error.errno; throw error; } - writeForkContinuationAnchor( - memory, - dlopenArchiveControlAddr, - ptrWidth, - forkBufAddr, - ); - invokeForkContinuationBegin( - processInstance.exports.wpk_fork_unwind_begin, - forkBufAddr, - ptrWidth, - `pid=${pid}: linked fork unwind`, - ); return 0; // ignored during unwind }; + const dylinkForkActivationOwner = hasDylinkForkRole + ? createProcessDylinkActivationOwner({ + memory, + ptrWidth, + channelOffset, + forkUnwindTag: processForkUnwindTag, + coordinator: processContinuation, + registry: activationRegistry, + exceptionBroker, + importedStateCapture, + tableReplication: tableReplicationImports, + importedStatePlanner: initData.isForkChild + ? () => importedStatePlanner + : undefined, + referenceReplay, + registerChildReferenceActivation: initData.isForkChild + ? registerChildReferenceActivation + : undefined, + isForkChild: Boolean(initData.isForkChild), + invokeProcessFork: () => { + const fork = processInstance?.exports.fork; + if (typeof fork !== "function") { + throw new Error( + `pid=${pid}: dylink fork role is missing the main libc fork export`, + ); + } + return Number((fork as () => number)()); + }, + label: `pid=${pid}: dylink activations`, + }) + : undefined; + // Build import object and instantiate const dlopenSupport = buildDlopenImports( memory, channelOffset, dlopenArchiveControlAddr, - () => processInstance?.exports.__indirect_function_table as WebAssembly.Table | undefined, - () => processInstance?.exports.__stack_pointer as WebAssembly.Global | undefined, + () => + processInstance?.exports.__indirect_function_table as + WebAssembly.Table | undefined, + () => + processInstance?.exports.__stack_pointer as + WebAssembly.Global | undefined, () => processInstance ?? undefined, ptrWidth, processLongjmpTag, processCppExceptionTag, - hasDylinkForkRole, - (errno) => { - if (!processInstance) throw new Error(`pid=${pid}: side abort before main instantiation`); - forkContinuation.beginAbortReplay(errno); - invokeForkContinuationBegin( - processInstance.exports.wpk_fork_abort_begin, - forkBufAddr, - ptrWidth, - `pid=${pid}: linked fork abort`, - ); + dylinkForkActivationOwner, + hasDylinkForkRole + ? undefined + : `pid=${pid}: main artifact lacks the dylink fork role capability`, + processForkUnwindTag, + (table, firstIndex, length) => { + activationRegistry.markTableMutation(table, firstIndex, length); }, + processHostImportRuntime, + pid, ); - const importObject = buildImportObject(module, memory, kernelImports, channelOffset, dlopenSupport.imports, - () => processInstance ?? undefined, ptrWidth, processLongjmpTag, processCppExceptionTag, + processDlopenSupport = dlopenSupport; + processTableReplication = createProcessTableReplicationOwner({ + generationAddress: tableGenerationAddress, + registry: activationRegistry, + dlopen: dlopenSupport, + newArena: newModuleStateArena, + materializeModules: (snapshot) => { + dlopenSupport.replayDlopens(snapshot); + }, + // The copied fork arena restores a child process's complete + // global/table/reference graph and preserves aliases with live frames. + // The process table journal is for separately instantiated pthread + // Workers and later generations, not a second initial child restore. + restoreSnapshots: !initData.isForkChild, + label: `pid=${pid}`, + }); + if (initData.isForkChild) { + if (!childArena) { + throw new Error( + `pid=${pid}: fork child lost its validated module-state arena`, + ); + } + // A parent can be copied while the archive mutex word names its + // now-nonexistent Worker. The validated archive bytes are immutable + // for this child launch, so clear that private lock before creating + // any loader state. + dlopenSupport.resetForkChildLock(); + childDylinkState = dlopenSupport.readForkState(); + const records = childArena.recordViews(); + decodedChildReferences = decodeSegmentedForkReferenceTransaction( + records, + FORK_REFERENCE_TRANSACTION_OWNER_ID, + ); + const modules = new Map([[0, module]]); + for (const library of childDylinkState.libraries) { + if (library.activationId === undefined) continue; + if (modules.has(library.activationId)) { + throw new Error( + `pid=${pid}: archived activation ${library.activationId} ` + + "is duplicated or aliases the main activation", + ); + } + modules.set( + library.activationId, + new WebAssembly.Module( + library.moduleBytes as unknown as BufferSource, + ), + ); + } + const declarations = [...modules] + .sort(([left], [right]) => left - right) + .map(([activationId, activationModule]) => ({ + activationId, + gcDescriptor: readForkGcCodecDescriptor(activationModule), + exceptionDescriptor: + readForkExceptionCodecDescriptor(activationModule), + })); + earlyChildReferences = new ForkEarlyChildReferenceProvider({ + records, + transaction: decodedChildReferences, + declarations, + externrefs: externrefRecipes, + transit: { + prepare: (maxRecipeId) => + activationRegistry.prepareEarlyGcTransit(maxRecipeId), + publish: (recipeId, value) => + activationRegistry.publishEarlyGcTransit(recipeId, value), + read: (recipeId) => activationRegistry.readEarlyGcTransit(recipeId), + abort: () => activationRegistry.abortEarlyGcTransit(), + }, + memory, + allocateScratch: (size) => + continuationMmap( + memory, + channelOffset, + size, + `pid=${pid}: early reference scratch`, + ), + deallocateScratch: (addr, size) => + continuationMunmap( + memory, + channelOffset, + addr, + size, + `pid=${pid}: early reference scratch`, + ), + label: `pid=${pid}: early child references`, + }); + importedStatePlanner = new ForkImportedGlobalPlanner( + records, + modules, + earlyChildReferences, + `pid=${pid}: child imported activation state`, + ); + const archivedOrder = [ + 0, + ...childDylinkState.libraries.flatMap(({ activationId }) => + activationId === undefined ? [] : [activationId], + ), + ]; + const plannedOrder = importedStatePlanner.instantiationOrder(); + if ( + archivedOrder.length !== plannedOrder.length || + archivedOrder.some( + (activationId, index) => activationId !== plannedOrder[index], + ) + ) { + throw new Error( + `pid=${pid}: copied activation import dependencies require order ` + + `${plannedOrder.join(",")}, but the replay archive provides ` + + archivedOrder.join(","), + ); + } + } + const forkEnvImports: Record = { + ...processContinuation.continuationImports(0, (errno) => { + processContinuation.beginCaptureAbort(errno); + }), + ...buildForkActivationStateImports( + 0, + activationRegistry, + referenceReplay, + tableReplicationImports, + ), + ...buildForkExceptionImports({ + activationId: 0, + ptrWidth, + registry: activationRegistry, + broker: exceptionBroker, + provider: () => { + if (!mainExceptionProvider) { + throw new Error( + `pid=${pid}: exception codec called before registration`, + ); + } + return mainExceptionProvider; + }, + referenceReplay, + }), + }; + const importObject = buildImportObject( + module, + memory, + kernelImports, + channelOffset, + dlopenSupport.imports, + () => processInstance ?? undefined, + ptrWidth, + processLongjmpTag, + processCppExceptionTag, + processForkUnwindTag, (timedOutPtr, vmInterruptPtr, seconds) => { port.postMessage({ type: "vm_interrupt_timer", @@ -1788,35 +3639,172 @@ export async function centralizedWorkerMain( seconds, } satisfies WorkerToHostMessage); }, - forkContinuation, - () => { - if (!processInstance) throw new Error(`pid=${pid}: continuation abort before instantiation`); - const errno = forkContinuation.abortErrno(); - dlopenSupport.beginSideModuleForkAbort(errno); - invokeForkContinuationBegin( - processInstance.exports.wpk_fork_abort_begin, - forkBufAddr, - ptrWidth, - `pid=${pid}: linked fork abort`, - ); - }); - const instance = await WebAssembly.instantiate(module, importObject); + forkEnvImports, + ); + const routedImportObject = processHostImportRuntime.routeImportObject( + programBytes, + importObject, + ); + const reconstructedMainImports = importedStatePlanner + ? importedStatePlanner.importsForActivation( + 0, + routedImportObject as unknown as ForkWasmImports, + ) + : routedImportObject; + // WHY: reconstruction supplies copied identities; capture wraps that + // resolved view so this child can safely become the parent of another + // fresh instance without retaining the previous fork arena. + const mainImportedStatePreparation = + importedStateCapture.prepareActivation( + 0, + module, + reconstructedMainImports, + ); + const mainInstantiationImports = mainImportedStatePreparation.imports; + let instance: WebAssembly.Instance; + try { + instance = await WebAssembly.instantiate( + module, + mainInstantiationImports as unknown as WebAssembly.Imports, + ); + } catch (error) { + mainImportedStatePreparation?.abort(); + if (earlyChildReferences) { + try { + earlyChildReferences.abort(); + } catch { + // Preserve the instantiation failure. No replay can proceed, and + // the outer process teardown still clears registered providers. + } + earlyChildReferences = null; + } + importedStatePlanner?.clear(); + throw error; + } processInstance = instance; + mainImportedStatePreparation?.complete(instance); + mainExceptionProvider = forkExceptionProviderFromInstance(0, instance); + const mainTypedReferenceProvider = forkGcCodecProviderFromInstance( + 0, + module, + instance, + ); + const mainRegistration = forkActivationRegistrationFromInstance({ + activationId: 0, + module, + instance, + templateId: mainTemplateId, + exceptionProvider: mainExceptionProvider, + typedReferenceProvider: mainTypedReferenceProvider, + }); + processContinuation.registerActivation( + mainRegistration, + forkResumeTargetsFromInstance(module, instance), + ); + importedStatePlanner?.registerInstance(0, instance); if (initData.isForkChild) { - dlopenSupport.resetForkChildLock(); + registerChildReferenceActivation( + 0, + module, + mainRegistration, + mainTypedReferenceProvider, + ); + } + importedStateCapture?.bindTableDirtyTrackers( + new Map( + activationRegistry + .activations() + .map((activation) => [ + activation.activationId, + activation.tableDirty, + ]), + ), + ); + if (!initData.isForkChild) { + try { + // Registration harvests static roots before bootstrap consumes the + // converted active segments, and installs the dirty-table owner + // before the original start can mutate a table. + activationRegistry.bootstrapActivation(0); + } catch (error) { + processTableReplication.abortActiveMutations(); + processContinuation.unregisterActivation(0); + mainExceptionProvider = null; + throw error; + } } verifyProgramAbi(programBytes, initData.kernelAbiVersion, pid); - // For the fork-parent case (initial launch, not a fork child), install - // __channel_base now — the parent's __tls_base is already correctly - // populated by instantiation, so setupChannelBase can read it. - // - // For fork children: defer until AFTER wpk_fork_rewind_begin runs - // (inside the loop below), because rewind_begin is what restores - // the child's __tls_base from the fork save buffer; setupChannelBase - // would otherwise see a zeroed __tls_base. if (!initData.isForkChild) { - setupChannelBase(instance, module, memory, channelOffset, programBytes as ArrayBuffer, ptrWidth); + setupChannelBase( + instance, + module, + memory, + channelOffset, + programBytes as ArrayBuffer, + ptrWidth, + ); + } else { + // Every side activation must exist before the process transaction is + // attached: module/reference recipes name activation coordinates, not + // whichever instance happens to load first in the child. + try { + if (!childDylinkState) { + throw new Error("copied dynamic-linker state was not prepared"); + } + dlopenSupport.replayDlopens(childDylinkState); + processTableReplication.reconcileNow(); + } catch (error) { + throw new Error( + `fork-replay-dlopen failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + if (!childArena) { + throw new Error( + `pid=${pid}: fork child lost its validated module-state arena`, + ); + } + if (!importedStatePlanner || !earlyChildReferences) { + throw new Error( + `pid=${pid}: fork child lost its pre-instantiation reference plan`, + ); + } + importedStatePlanner.bindTableDirtyTrackers( + new Map( + activationRegistry + .activations() + .map((activation) => [ + activation.activationId, + activation.tableDirty, + ]), + ), + ); + const early = earlyChildReferences; + processContinuation.attachChild( + childArena, + () => { + early.adoptInto(activationRegistry.currentReferences()); + earlyChildReferences = null; + }, + decodedChildReferences ?? undefined, + ); + decodedChildReferences = null; + importedStatePlanner.clear(); + importedStatePlanner = null; + forkResult = 0; + + // attachChild restores __tls_base/__stack_pointer for every + // activation before any continuation frame can execute. + setupChannelBase( + instance, + module, + memory, + channelOffset, + programBytes as ArrayBuffer, + ptrWidth, + ); } // Signal ready @@ -1826,121 +3814,99 @@ export async function centralizedWorkerMain( let exitCode = 0; try { const start = instance.exports._start as () => void; - const getState = instance.exports.wpk_fork_state as () => number; - const unwindEnd = instance.exports.wpk_fork_unwind_end as () => void; - - // For fork children: start with rewind to resume from fork point - let needsRewind = !!initData.isForkChild; - if (needsRewind) { - forkResult = 0; // fork() returns 0 in child + const resumeStart = instance.exports.wpk_fork_resume_start as + (() => void) | undefined; + if (typeof resumeStart !== "function") { + throw new Error( + `pid=${pid}: fork-capable program is missing wpk_fork_resume_start`, + ); } - let replayedForkChildDlopens = false; - let attachedForkChildContinuation = false; - // Choose entry: normal _start, or — for a fork-from-non-main-thread // child — call the parent thread's thread function directly. _start // is not in the thread's fork-path call chain, so rewinding through // it would never reach the saved fork() call site. The thread // function's instrumented body sees state==REWINDING on entry and // replays the saved frames back to fork(). - let entry: () => void; + let lexicalEntry: () => void; + let replayEntry: () => void; if (initData.isForkChild && initData.forkChildThreadFnPtr != null) { - const table = instance.exports.__indirect_function_table as WebAssembly.Table | undefined; - if (!table) { - throw new Error("Fork-from-thread child: no __indirect_function_table export"); - } const fnIdx = initData.forkChildThreadFnPtr; - const tableIdx = ptrWidth === 8 ? (BigInt(fnIdx) as unknown as number) : fnIdx; - const threadFn = table.get(tableIdx) as ((arg: number | bigint) => unknown) | null; - if (!threadFn) { - throw new Error(`Fork-from-thread child: thread function at index ${fnIdx} is null`); - } const childArgPtr = initData.forkChildThreadArgPtr ?? 0; const threadArg = ptrWidth === 8 ? BigInt(childArgPtr) : childArgPtr; - entry = () => { threadFn(threadArg); }; - } else { - entry = start; - } - - for (;;) { - if (needsRewind) { - const rewindAddr = initData.isForkChild - && !attachedForkChildContinuation - && initData.forkBufAddr != null - ? initData.forkBufAddr - : forkBufAddr; - if (initData.isForkChild && !attachedForkChildContinuation) { - // A fork child has copied chunks but a fresh JS owner. - // Preserve the guest ABI type when handing that copied pointer - // to the continuation validator: memory64 i64 requires BigInt. - forkContinuation.attachForReplay( - ptrWidth === 8 ? BigInt(rewindAddr) : rewindAddr, - ); - attachedForkChildContinuation = true; - } else { - forkContinuation.beginReplay(); - } - // wpk_fork_rewind_begin restores all saved mutable globals - // (including __tls_base and __stack_pointer) from the fork - // buffer. Must run before setupChannelBase, which reads - // __tls_base to locate the channel-base TLS slot. - invokeForkContinuationBegin( - instance.exports.wpk_fork_rewind_begin, - rewindAddr, - ptrWidth, - `pid=${pid}: linked fork rewind`, + const resumeThread = instance.exports.wpk_fork_resume_thread as + | ((tableIndex: number, arg: number | bigint) => number | bigint) + | undefined; + if (typeof resumeThread !== "function") { + throw new Error( + "Fork-from-thread child: missing wpk_fork_resume_thread", ); - // Now that rewind_begin has restored __tls_base, install - // __channel_base for this (child) instance. - setupChannelBase(instance, module, memory, channelOffset, programBytes as ArrayBuffer, ptrWidth); - if (initData.isForkChild && !replayedForkChildDlopens) { - try { - dlopenSupport.replayDlopens(); - } catch (e) { - throw new Error(`fork-replay-dlopen failed: ${e instanceof Error ? e.message : String(e)}`); - } - replayedForkChildDlopens = true; - } - dlopenSupport.beginSideModuleForkRewind(); - needsRewind = false; } + // A fork child never executes the lexical pthread entry. Keep the + // two closures structurally complete so the loop can select solely + // from coordinator phase below. + lexicalEntry = () => { + throw new Error( + "Fork-from-thread child entered lexical thread path", + ); + }; + replayEntry = () => { + resumeThread(fnIdx, threadArg); + }; + } else { + lexicalEntry = start; + replayEntry = resumeStart; + } + for (;;) { + let transportedForkUnwind = false; try { + const phaseBeforeEntry = processContinuation.phaseName(); + const entry = + phaseBeforeEntry === "idle" ? lexicalEntry : replayEntry; entry(); } catch (e) { - if (e instanceof Error && e.message.includes("unreachable")) { + if (isForkUnwindException(e, processForkUnwindTag)) { + transportedForkUnwind = true; + } else if ( + e instanceof Error && + e.message.includes("unreachable") + ) { if (kernelExitStatus !== null) { exitCode = kernelExitStatus; break; // Normal exit via kernel_exit -> unreachable trap } + throw e; + } else { + throw e; } - throw e; } - const forkState = getState(); - if (forkState === 1) { - // Unwind completed (fork) — finalize and send SYS_FORK. - unwindEnd(); - forkContinuation.finishUnwind(); - - dlopenSupport.completeSideModuleForkUnwind(); - - // Send SYS_FORK through the channel now that memory has the - // fork save buffer populated (saved_globals + frames). + const phase = processContinuation.phaseName(); + if (transportedForkUnwind && phase !== "capture") { + throw new Error( + `pid=${pid}: private fork-unwind exception escaped while ` + + `process continuation is ${phase}`, + ); + } + if (phase === "capture") { + processContinuation.sealCapture(); const childPid = sendForkSyscall(memory, channelOffset); + forkResult = childPid; if (childPid < 0) { - forkResult = childPid; - needsRewind = true; - continue; + processContinuation.beginAbortReplay(-childPid); + } else { + processContinuation.beginParentReplay(); } - forkResult = childPid; - needsRewind = true; continue; } + if (phase !== "idle") { + throw new Error( + `pid=${pid}: process entry returned while continuation is ${phase}`, + ); + } // Normal return — program finished - dlopenSupport.assertNoActiveSideModuleFork(); if (kernelExitStatus === null) { kernelImports.kernel_exit(0); exitCode = kernelExitStatus ?? 0; @@ -1948,14 +3914,37 @@ export async function centralizedWorkerMain( break; } } catch (e) { - if (e instanceof Error && e.message.includes("unreachable") && kernelExitStatus !== null) { + processTableReplication.abortActiveMutations(); + releaseProcessForkArchiveReader(); + if ( + e instanceof Error && + e.message.includes("unreachable") && + kernelExitStatus !== null + ) { exitCode = kernelExitStatus; } else { + if (processContinuation.phaseName() !== "idle") { + try { + processContinuation.abort(); + } catch { + // Preserve the execution failure; abort already made its + // transaction state unreachable before attempting deallocation. + } + } throw e; } } - port.postMessage({ type: "exit", pid, status: exitCode } satisfies WorkerToHostMessage); + processContinuation.clear(); + releaseProcessForkArchiveReader(); + importedStateCapture?.clear(); + externrefTokens.clear(); + processHostImportRuntime.clear(); + port.postMessage({ + type: "exit", + pid, + status: exitCode, + } satisfies WorkerToHostMessage); } else { // No fork instrumentation: fork cannot be represented safely because // the child cannot resume at the fork call site. Fail loudly if the @@ -1972,16 +3961,34 @@ export async function centralizedWorkerMain( memory, channelOffset, dlopenArchiveControlAddr, - () => processInstance?.exports.__indirect_function_table as WebAssembly.Table | undefined, - () => processInstance?.exports.__stack_pointer as WebAssembly.Global | undefined, + () => + processInstance?.exports.__indirect_function_table as + WebAssembly.Table | undefined, + () => + processInstance?.exports.__stack_pointer as + WebAssembly.Global | undefined, () => processInstance ?? undefined, ptrWidth, processLongjmpTag, processCppExceptionTag, - false, + undefined, + `pid=${pid}: main artifact has no fork activation coordinator`, + processForkUnwindTag, + undefined, + undefined, + pid, ); - const importObject = buildImportObject(module, memory, kernelImports, channelOffset, dlopenSupport.imports, - () => processInstance ?? undefined, ptrWidth, processLongjmpTag, processCppExceptionTag, + const importObject = buildImportObject( + module, + memory, + kernelImports, + channelOffset, + dlopenSupport.imports, + () => processInstance ?? undefined, + ptrWidth, + processLongjmpTag, + processCppExceptionTag, + processForkUnwindTag, (timedOutPtr, vmInterruptPtr, seconds) => { port.postMessage({ type: "vm_interrupt_timer", @@ -1990,12 +3997,20 @@ export async function centralizedWorkerMain( vmInterruptPtr, seconds, } satisfies WorkerToHostMessage); - }); + }, + ); const instance = await WebAssembly.instantiate(module, importObject); processInstance = instance; verifyProgramAbi(programBytes, initData.kernelAbiVersion, pid); - setupChannelBase(instance, module, memory, channelOffset, programBytes as ArrayBuffer, ptrWidth); + setupChannelBase( + instance, + module, + memory, + channelOffset, + programBytes as ArrayBuffer, + ptrWidth, + ); port.postMessage({ type: "ready", pid } satisfies WorkerToHostMessage); @@ -2022,9 +4037,14 @@ export async function centralizedWorkerMain( exitCode = kernelExitStatus ?? exitCode; } - port.postMessage({ type: "exit", pid, status: exitCode } satisfies WorkerToHostMessage); + port.postMessage({ + type: "exit", + pid, + status: exitCode, + } satisfies WorkerToHostMessage); } } catch (err) { + processHostImportRuntime?.clear(); if (err instanceof ExecRetirement) { port.postMessage({ type: "exec_retired", @@ -2035,7 +4055,10 @@ export async function centralizedWorkerMain( let errMsg: string; if (err instanceof Error) { errMsg = `${err.message}\n${err.stack}`; - } else if ((WebAssembly as any).Exception && err instanceof (WebAssembly as any).Exception) { + } else if ( + (WebAssembly as any).Exception && + err instanceof (WebAssembly as any).Exception + ) { // WebAssembly.Exception isn't an Error subclass in V8, so String(err) // produces the useless "[object WebAssembly.Exception]". Surface // anything we can read off it for build-time debugging. @@ -2072,7 +4095,9 @@ function detectChannelBaseTlsOffset(programBytes: ArrayBuffer): number { if (src.length < 8) return -1; function readLEB128(buf: Uint8Array, off: number): [number, number] { - let result = 0, shift = 0, pos = off; + let result = 0, + shift = 0, + pos = off; for (;;) { const byte = buf[pos++]; result |= (byte & 0x7f) << shift; @@ -2083,7 +4108,11 @@ function detectChannelBaseTlsOffset(programBytes: ArrayBuffer): number { } // Parse sections to find Export and Code sections - interface Section { id: number; contentOffset: number; contentSize: number; } + interface Section { + id: number; + contentOffset: number; + contentSize: number; + } const sections: Section[] = []; let numFuncImports = 0; let offset = 8; @@ -2091,7 +4120,11 @@ function detectChannelBaseTlsOffset(programBytes: ArrayBuffer): number { while (offset < src.length) { const sectionId = src[offset]; const [sectionSize, sizeBytes] = readLEB128(src, offset + 1); - sections.push({ id: sectionId, contentOffset: offset + 1 + sizeBytes, contentSize: sectionSize }); + sections.push({ + id: sectionId, + contentOffset: offset + 1 + sizeBytes, + contentSize: sectionSize, + }); offset += 1 + sizeBytes + sectionSize; } @@ -2102,13 +4135,35 @@ function detectChannelBaseTlsOffset(programBytes: ArrayBuffer): number { const [importCount, countBytes] = readLEB128(src, pos); pos += countBytes; for (let i = 0; i < importCount; i++) { - const [modLen, modLenBytes] = readLEB128(src, pos); pos += modLenBytes + modLen; - const [fieldLen, fieldLenBytes] = readLEB128(src, pos); pos += fieldLenBytes + fieldLen; + const [modLen, modLenBytes] = readLEB128(src, pos); + pos += modLenBytes + modLen; + const [fieldLen, fieldLenBytes] = readLEB128(src, pos); + pos += fieldLenBytes + fieldLen; const kind = src[pos++]; - if (kind === 0) { numFuncImports++; const [, n] = readLEB128(src, pos); pos += n; } - else if (kind === 1) { pos++; const f = src[pos++]; const [, n] = readLEB128(src, pos); pos += n; if (f & 1) { const [, n2] = readLEB128(src, pos); pos += n2; } } - else if (kind === 2) { const f = src[pos++]; const [, n] = readLEB128(src, pos); pos += n; if (f & 1) { const [, n2] = readLEB128(src, pos); pos += n2; } } - else if (kind === 3) { pos += 2; } + if (kind === 0) { + numFuncImports++; + const [, n] = readLEB128(src, pos); + pos += n; + } else if (kind === 1) { + pos++; + const f = src[pos++]; + const [, n] = readLEB128(src, pos); + pos += n; + if (f & 1) { + const [, n2] = readLEB128(src, pos); + pos += n2; + } + } else if (kind === 2) { + const f = src[pos++]; + const [, n] = readLEB128(src, pos); + pos += n; + if (f & 1) { + const [, n2] = readLEB128(src, pos); + pos += n2; + } + } else if (kind === 3) { + pos += 2; + } } break; } @@ -2119,12 +4174,16 @@ function detectChannelBaseTlsOffset(programBytes: ArrayBuffer): number { for (const sec of sections) { if (sec.id === 7) { let pos = sec.contentOffset; - const [exportCount, countBytes] = readLEB128(src, pos); pos += countBytes; + const [exportCount, countBytes] = readLEB128(src, pos); + pos += countBytes; for (let i = 0; i < exportCount; i++) { - const [nameLen, nameLenBytes] = readLEB128(src, pos); pos += nameLenBytes; - const name = new TextDecoder().decode(src.subarray(pos, pos + nameLen)); pos += nameLen; + const [nameLen, nameLenBytes] = readLEB128(src, pos); + pos += nameLenBytes; + const name = new TextDecoder().decode(src.subarray(pos, pos + nameLen)); + pos += nameLen; const kind = src[pos++]; - const [idx, idxBytes] = readLEB128(src, pos); pos += idxBytes; + const [idx, idxBytes] = readLEB128(src, pos); + pos += idxBytes; if (kind === 0 && name === "__get_channel_base_addr") { channelBaseExportFuncIdx = idx; break; @@ -2145,17 +4204,24 @@ function detectChannelBaseTlsOffset(programBytes: ArrayBuffer): number { for (const sec of sections) { if (sec.id !== 10) continue; let pos = sec.contentOffset; - const [, funcCountBytes] = readLEB128(src, pos); pos += funcCountBytes; + const [, funcCountBytes] = readLEB128(src, pos); + pos += funcCountBytes; // Skip to the exported function's body for (let i = 0; i < exportCodeEntry; i++) { const [bodySize, bodySizeBytes] = readLEB128(src, pos); pos += bodySizeBytes + bodySize; } - const [, bodySizeBytes] = readLEB128(src, pos); pos += bodySizeBytes; + const [, bodySizeBytes] = readLEB128(src, pos); + pos += bodySizeBytes; // Skip locals - const [localCount, lcBytes] = readLEB128(src, pos); pos += lcBytes; - for (let i = 0; i < localCount; i++) { const [, n] = readLEB128(src, pos); pos += n; pos++; } + const [localCount, lcBytes] = readLEB128(src, pos); + pos += lcBytes; + for (let i = 0; i < localCount; i++) { + const [, n] = readLEB128(src, pos); + pos += n; + pos++; + } // i32.const = 0x41, i64.const = 0x42 (wasm64 uses i64 for addresses) const I32_CONST = 0x41; @@ -2171,7 +4237,8 @@ function detectChannelBaseTlsOffset(programBytes: ArrayBuffer): number { // Pattern 3: instrumented/optimized — global.get ; i32/i64.const ; i32/i64.add if (src[pos] === 0x23) { let p3 = pos + 1; - const [, globalIdxBytes] = readLEB128(src, p3); p3 += globalIdxBytes; + const [, globalIdxBytes] = readLEB128(src, p3); + p3 += globalIdxBytes; if (src[p3] === I32_CONST || src[p3] === I64_CONST) { p3++; const [tlsOffset] = readLEB128(src, p3); @@ -2182,7 +4249,8 @@ function detectChannelBaseTlsOffset(programBytes: ArrayBuffer): number { // Pattern 2: wrapper — call ; call ; end if (src[pos] !== 0x10) return -1; pos++; - const [, ctorIdxBytes] = readLEB128(src, pos); pos += ctorIdxBytes; + const [, ctorIdxBytes] = readLEB128(src, pos); + pos += ctorIdxBytes; if (src[pos] !== 0x10) return -1; pos++; const [actualFuncIdx] = readLEB128(src, pos); @@ -2191,14 +4259,21 @@ function detectChannelBaseTlsOffset(programBytes: ArrayBuffer): number { if (actualCodeEntry < 0) return -1; let pos2 = sec.contentOffset; - const [, fcb2] = readLEB128(src, pos2); pos2 += fcb2; + const [, fcb2] = readLEB128(src, pos2); + pos2 += fcb2; for (let i = 0; i < actualCodeEntry; i++) { const [bs, bsb] = readLEB128(src, pos2); pos2 += bsb + bs; } - const [, bsb2] = readLEB128(src, pos2); pos2 += bsb2; - const [lc2, lcb2] = readLEB128(src, pos2); pos2 += lcb2; - for (let i = 0; i < lc2; i++) { const [, n] = readLEB128(src, pos2); pos2 += n; pos2++; } + const [, bsb2] = readLEB128(src, pos2); + pos2 += bsb2; + const [lc2, lcb2] = readLEB128(src, pos2); + pos2 += lcb2; + for (let i = 0; i < lc2; i++) { + const [, n] = readLEB128(src, pos2); + pos2 += n; + pos2++; + } if (src[pos2] !== I32_CONST && src[pos2] !== I64_CONST) return -1; pos2++; @@ -2220,7 +4295,14 @@ function setupChannelBase( // If the module imports env.__channel_base as a global, the channel offset was // already set at instantiation via WebAssembly.Global in buildImportObject. const moduleImports = WebAssembly.Module.imports(module); - if (moduleImports.some(i => i.module === "env" && i.name === "__channel_base" && i.kind === "global")) { + if ( + moduleImports.some( + (i) => + i.module === "env" && + i.name === "__channel_base" && + i.kind === "global", + ) + ) { return; } @@ -2247,20 +4329,37 @@ function setupChannelBase( * Send SYS_FORK through the channel and wait for the result. * Returns child pid on success, or -errno on failure. */ -function sendForkSyscall(memory: WebAssembly.Memory, channelOffset: number): number { +function sendForkSyscall( + memory: WebAssembly.Memory, + channelOffset: number, +): number { const view = new DataView(memory.buffer); - view.setInt32(channelOffset + CH_SYSCALL, HOST_INTERCEPTED_SYSCALLS.SYS_FORK, true); + view.setInt32( + channelOffset + CH_SYSCALL, + HOST_INTERCEPTED_SYSCALLS.SYS_FORK, + true, + ); for (let i = 0; i < 6; i++) { view.setBigInt64(channelOffset + CH_ARGS + i * CH_ARG_SIZE, 0n, true); } + markDeferredSignalDelivery(view, channelOffset); const i32 = new Int32Array(memory.buffer); Atomics.store(i32, (channelOffset + CH_STATUS) / 4, CHANNEL_STATUS_PENDING); Atomics.notify(i32, (channelOffset + CH_STATUS) / 4, 1); - while (Atomics.wait(i32, (channelOffset + CH_STATUS) / 4, CHANNEL_STATUS_PENDING) === "ok") { /* */ } + while ( + Atomics.wait( + i32, + (channelOffset + CH_STATUS) / 4, + CHANNEL_STATUS_PENDING, + ) === "ok" + ) { + /* */ + } const result = Number(view.getBigInt64(channelOffset + CH_RETURN, true)); const err = view.getUint32(channelOffset + CH_ERRNO, true); + clearDeferredSignalDelivery(view, channelOffset); Atomics.store(i32, (channelOffset + CH_STATUS) / 4, CHANNEL_STATUS_IDLE); if (err) return -err; @@ -2313,7 +4412,13 @@ export function patchWasmForThread(bytes: ArrayBuffer): ArrayBuffer { } // Parse all sections - interface Section { id: number; offset: number; totalSize: number; contentOffset: number; contentSize: number; } + interface Section { + id: number; + offset: number; + totalSize: number; + contentOffset: number; + contentSize: number; + } const sections: Section[] = []; let numFuncImports = 0; let hasStartSection = false; @@ -2324,46 +4429,27 @@ export function patchWasmForThread(bytes: ArrayBuffer): ArrayBuffer { const [sectionSize, sizeBytes] = readLEB128(src, offset + 1); const contentOffset = offset + 1 + sizeBytes; const totalSize = 1 + sizeBytes + sectionSize; - sections.push({ id: sectionId, offset, totalSize, contentOffset, contentSize: sectionSize }); + sections.push({ + id: sectionId, + offset, + totalSize, + contentOffset, + contentSize: sectionSize, + }); if (sectionId === 8) hasStartSection = true; offset += totalSize; } if (!hasStartSection) return bytes; - // Count function imports from Import section (id=2) - for (const sec of sections) { - if (sec.id === 2) { - let pos = sec.contentOffset; - const [importCount, countBytes] = readLEB128(src, pos); - pos += countBytes; - for (let i = 0; i < importCount; i++) { - const [modLen, modLenBytes] = readLEB128(src, pos); - pos += modLenBytes + modLen; - const [fieldLen, fieldLenBytes] = readLEB128(src, pos); - pos += fieldLenBytes + fieldLen; - const kind = src[pos++]; - if (kind === 0) { // function import - numFuncImports++; - const [, typeIdxBytes] = readLEB128(src, pos); - pos += typeIdxBytes; - } else if (kind === 1) { // table - pos++; // reftype - const flags = src[pos++]; - const [, minBytes] = readLEB128(src, pos); pos += minBytes; - if (flags & 1) { const [, maxBytes] = readLEB128(src, pos); pos += maxBytes; } - } else if (kind === 2) { // memory - const flags = src[pos++]; - const [, minBytes] = readLEB128(src, pos); pos += minBytes; - if (flags & 1) { const [, maxBytes] = readLEB128(src, pos); pos += maxBytes; } - } else if (kind === 3) { // global - pos++; // valtype - pos++; // mutability - } - } - break; - } - } + // WHY: import descriptors can contain recursive GC types, multi-byte + // concrete references, table64 limits, tags, and future standardized + // imports. The engine has already validated and decoded that grammar; using + // its reflection avoids a second partial parser shifting every function + // index when a non-function import is not one byte wide. + numFuncImports = WebAssembly.Module.imports( + new WebAssembly.Module(bytes), + ).filter((entry) => entry.kind === "function").length; // Find the constructor function by looking at the exported helper wrappers. // Plain lld output puts `call $__wasm_call_ctors` first. After @@ -2388,7 +4474,8 @@ export function patchWasmForThread(bytes: ArrayBuffer): ArrayBuffer { const kind = src[pos++]; const [idx, idxBytes] = readLEB128(src, pos); pos += idxBytes; - if (kind === 0) { // function export + if (kind === 0) { + // function export exportedFuncIndices.push(idx); exportFuncIndicesByName.set(name, idx); } @@ -2407,6 +4494,28 @@ export function patchWasmForThread(bytes: ArrayBuffer): ArrayBuffer { return skipLEB(pos); // offset } + function skipValueType(pos: number): number { + const kind = src[pos++]; + // `(ref null )` and `(ref )` carry a signed + // heap-type/type-index LEB. Abstract shorthand references and all numeric + // value types are single-byte encodings. + return kind === 0x63 || kind === 0x64 ? skipLEB(pos) : pos; + } + + function skipBlockType(pos: number): number { + const kind = src[pos]; + if (kind === 0x40) return pos + 1; // empty + if ( + (kind >= 0x7b && kind <= 0x7f) || + (kind >= 0x65 && kind <= 0x70) || + kind === 0x63 || + kind === 0x64 + ) { + return skipValueType(pos); + } + return skipLEB(pos); // signed type index + } + function getInstructionStartAndEnd( codeSection: Section, funcIndex: number, @@ -2432,7 +4541,7 @@ export function patchWasmForThread(bytes: ArrayBuffer): ArrayBuffer { pos += localCountBytes; for (let i = 0; i < localCount; i++) { pos = skipLEB(pos); // count - pos++; // valtype + pos = skipValueType(pos); } return { start: pos, end: bodyEnd }; @@ -2446,21 +4555,29 @@ export function patchWasmForThread(bytes: ArrayBuffer): ArrayBuffer { let pos = bounds.start; while (pos < bounds.end) { const op = src[pos++]; - if (op === 0x10) { // call + if (op === 0x10) { + // call const [target, n] = readLEB128(src, pos); pos += n; calls.push(target); - } else if (op === 0x11 || op === 0x13) { // call_indirect / return_call_indirect + } else if (op === 0x11 || op === 0x13) { + // call_indirect / return_call_indirect pos = skipLEB(pos); pos = skipLEB(pos); } else if (op === 0x12 || op === 0x14 || op === 0x15) { pos = skipLEB(pos); } else if (op === 0x02 || op === 0x03 || op === 0x04) { - // blocktype: empty marker, valtype, or signed type index. - pos = src[pos] === 0x40 || src[pos] >= 0x70 ? pos + 1 : skipLEB(pos); - } else if (op === 0x0c || op === 0x0d || (op >= 0x20 && op <= 0x26) || op === 0xd0 || op === 0xd2) { + pos = skipBlockType(pos); + } else if ( + op === 0x0c || + op === 0x0d || + (op >= 0x20 && op <= 0x26) || + op === 0xd0 || + op === 0xd2 + ) { pos = skipLEB(pos); - } else if (op === 0x0e) { // br_table + } else if (op === 0x0e) { + // br_table const [count, n] = readLEB128(src, pos); pos += n; for (let i = 0; i <= count; i++) pos = skipLEB(pos); @@ -2511,7 +4628,11 @@ export function patchWasmForThread(bytes: ArrayBuffer): ArrayBuffer { for (const name of helperNames) { const funcIndex = exportFuncIndicesByName.get(name); if (funcIndex === undefined) continue; - const perFunction = new Set(scanCallTargets(sec, funcIndex).filter(target => target >= numFuncImports)); + const perFunction = new Set( + scanCallTargets(sec, funcIndex).filter( + (target) => target >= numFuncImports, + ), + ); for (const target of perFunction) { const entry = counts.get(target); if (entry) { @@ -2522,11 +4643,13 @@ export function patchWasmForThread(bytes: ArrayBuffer): ArrayBuffer { } } - let best: { target: number; count: number; firstOrder: number } | null = null; + let best: { target: number; count: number; firstOrder: number } | null = + null; for (const [target, value] of counts) { if ( value.count >= 2 && - (!best || value.count > best.count || + (!best || + value.count > best.count || (value.count === best.count && value.firstOrder < best.firstOrder)) ) { best = { target, count: value.count, firstOrder: value.firstOrder }; @@ -2552,7 +4675,8 @@ export function patchWasmForThread(bytes: ArrayBuffer): ArrayBuffer { } } - const ctorCodeEntry = ctorFuncIndex >= 0 ? ctorFuncIndex - numFuncImports : -1; + const ctorCodeEntry = + ctorFuncIndex >= 0 ? ctorFuncIndex - numFuncImports : -1; if (ctorFuncIndex < 0) { // No ctor found — still strip start section but can't neuter the ctor body } @@ -2578,7 +4702,10 @@ export function patchWasmForThread(bytes: ArrayBuffer): ArrayBuffer { const [bodySize, bodySizeBytes] = readLEB128(src, targetBodyStart); targetBodyStart += bodySizeBytes + bodySize; } - const [origBodySize, origBodySizeBytes] = readLEB128(src, targetBodyStart); + const [origBodySize, origBodySizeBytes] = readLEB128( + src, + targetBodyStart, + ); const origBodyEnd = targetBodyStart + origBodySizeBytes + origBodySize; // New body: size=2, content = 0x00 (0 locals) + 0x0B (end) @@ -2586,7 +4713,7 @@ export function patchWasmForThread(bytes: ArrayBuffer): ArrayBuffer { // Compute new section content size const beforeTarget = targetBodyStart - sec.contentOffset; - const afterTarget = (sec.contentOffset + sec.contentSize) - origBodyEnd; + const afterTarget = sec.contentOffset + sec.contentSize - origBodyEnd; const newContentSize = beforeTarget + newBody.length + afterTarget; const newSectionSizeBytes = encodeLEB128(newContentSize); @@ -2594,7 +4721,9 @@ export function patchWasmForThread(bytes: ArrayBuffer): ArrayBuffer { chunks.push(new Uint8Array(newSectionSizeBytes)); chunks.push(src.subarray(sec.contentOffset, targetBodyStart)); // func count + bodies before target chunks.push(newBody); // patched function body - chunks.push(src.subarray(origBodyEnd, sec.contentOffset + sec.contentSize)); // bodies after target + chunks.push( + src.subarray(origBodyEnd, sec.contentOffset + sec.contentSize), + ); // bodies after target } else { // Copy section as-is chunks.push(src.subarray(sec.offset, sec.offset + sec.totalSize)); @@ -2651,26 +4780,46 @@ export async function centralizedThreadWorkerMain( synchronizeReceivedSharedWasmMemory(memory, ptrWidth); let threadInstance: WebAssembly.Instance | undefined; + let threadProcessContinuation: ForkProcessContinuationCoordinator | null = + null; + let threadTableReplication: ProcessTableReplicationOwner | null = null; + let threadHostImportRuntime: ForkHostImportWorkerRuntime | null = null; + let threadExternrefTokens: ForkExternrefTokenCache | null = null; let processDlopenLock: Int32Array | undefined; + let processDlopenOwner: Int32Array | undefined; let pthreadForkLockHeld = false; const acquirePthreadForkLock = (): boolean => { - if (!processDlopenLock) { - throw new Error(`pid=${pid} tid=${tid}: missing process dlopen lock`); + if (!processDlopenLock || !processDlopenOwner) { + throw new Error( + `pid=${pid} tid=${tid}: missing process dlopen ownership`, + ); } if (pthreadForkLockHeld) { throw new Error(`pid=${pid} tid=${tid}: pthread fork lock already held`); } for (;;) { + const transactionOwner = Atomics.load(processDlopenOwner, 0); + if (transactionOwner !== DLOPEN_OWNER_IDLE && transactionOwner !== tid) { + Atomics.wait(processDlopenOwner, 0, transactionOwner); + continue; + } const owner = Atomics.load(processDlopenLock, 0); - if (owner < DLOPEN_LOCK_IDLE) return false; + if (owner < DLOPEN_LOCK_IDLE) { + // dlopen is finite and publishes the archive generation before + // releasing this writer token. Waiting preserves ordinary pthread + // fork/dlopen semantics instead of exposing a scheduler race as + // ENOTSUP. + Atomics.wait(processDlopenLock, 0, owner); + continue; + } if (owner >= DLOPEN_LOCK_MAX_READERS) { throw new Error( `pid=${pid} tid=${tid}: process dlopen lock reader overflow`, ); } if ( - Atomics.compareExchange(processDlopenLock, 0, owner, owner + 1) - === owner + Atomics.compareExchange(processDlopenLock, 0, owner, owner + 1) === + owner ) { pthreadForkLockHeld = true; return true; @@ -2689,8 +4838,8 @@ export async function centralizedThreadWorkerMain( ); } if ( - Atomics.compareExchange(processDlopenLock, 0, owner, owner - 1) - === owner + Atomics.compareExchange(processDlopenLock, 0, owner, owner - 1) === + owner ) { pthreadForkLockHeld = false; if (owner === 1) Atomics.notify(processDlopenLock, 0); @@ -2712,53 +4861,222 @@ export async function centralizedThreadWorkerMain( : new WebAssembly.Module(programBytes!); const hasForkInstrumentation = hasCompleteForkInstrumentation(module, pid); + if (hasForkInstrumentation) { + if ( + initData.forkHostImports === undefined || + initData.externrefGenerationId === undefined + ) { + throw new Error( + `pid=${pid} tid=${tid}: ABI 43 fork artifact requires its process ` + + "owner host-import mailbox and externref generation", + ); + } + threadExternrefTokens = new ForkExternrefTokenCache( + initData.externrefGenerationId, + ); + threadHostImportRuntime = new ForkHostImportWorkerRuntime( + initData.forkHostImports, + pid, + initData.externrefGenerationId, + threadExternrefTokens, + (wake) => { + port.postMessage({ + type: "fork_host_import", + wake, + } satisfies WorkerToHostMessage); + }, + ); + } + const threadForkCapabilityClaim = readForkInstrumentCapabilityClaim(module); + const hasDylinkForkRole = forkInstrumentRoleAvailable( + threadForkCapabilityClaim, + FORK_CAP_DYLINK_MAIN, + ); let forkBufAddr = 0; const forkAnchorAddr = channelOffset - FORK_BUF_SIZE; const threadForkContinuation = hasForkInstrumentation ? new LinkedForkContinuation( memory, readLinkedFrameFormat(module), - (size) => continuationMmap(memory, channelOffset, size, `pid=${pid} tid=${tid}`), - (addr, size) => continuationMunmap( + (size) => + continuationMmap( + memory, + channelOffset, + size, + `pid=${pid} tid=${tid}`, + ), + (addr, size) => + continuationMunmap( + memory, + channelOffset, + addr, + size, + `pid=${pid} tid=${tid}`, + ), + `pid=${pid} tid=${tid}`, + ) + : null; + const threadTemplateId = hasForkInstrumentation + ? await computeForkModuleTemplateId(initData.programBytes) + : null; + const newThreadModuleStateArena = (): ForkModuleStateArena => + new ForkModuleStateArena( + memory, + ptrWidth, + (size) => + continuationMmap( + memory, + channelOffset, + size, + `pid=${pid} tid=${tid}: module state`, + ), + (addr, size) => + continuationMunmap( memory, channelOffset, addr, size, - `pid=${pid} tid=${tid}`, + `pid=${pid} tid=${tid}: module state`, ), - `pid=${pid} tid=${tid}`, + `pid=${pid} tid=${tid}`, + ); + const threadActivationRegistry = hasForkInstrumentation + ? new ForkActivationRegistry( + memory, + new ForkExternrefTokenRecipeProvider( + threadExternrefTokens!, + (value) => + threadHostImportRuntime!.localExceptions.normalizeUnclaimedForkValue( + value, + ), + ), + `pid=${pid} tid=${tid}: fork activations`, + (size) => + continuationMmap( + memory, + channelOffset, + size, + `pid=${pid} tid=${tid}: reference scratch`, + ), + (addr, size) => + continuationMunmap( + memory, + channelOffset, + addr, + size, + `pid=${pid} tid=${tid}: reference scratch`, + ), + ) + : null; + const threadImportedStateCapture = threadActivationRegistry + ? new ForkImportedGlobalCapture( + `pid=${pid} tid=${tid}: imported activation state`, + ) + : null; + threadProcessContinuation = threadActivationRegistry + ? new ForkProcessContinuationCoordinator( + memory, + threadActivationRegistry, + `pid=${pid} tid=${tid}: process continuation`, + ) + : null; + const threadExceptionBroker = threadActivationRegistry + ? new ForkExceptionBroker( + threadActivationRegistry, + `pid=${pid} tid=${tid}: exception broker`, + undefined, + (value) => + threadHostImportRuntime!.localExceptions.normalizeUnclaimedForkException( + value, + ), ) : null; - const processArchiveHeadOffset = ptrWidth === 8 - ? DLOPEN_HEAD_OFFSET_WASM64 - : DLOPEN_HEAD_OFFSET_WASM32; - const processArchiveHeadAddr = processChannelOffset - - FORK_BUF_SIZE - - processArchiveHeadOffset; - const processArchiveLockOffset = ptrWidth === 8 - ? DLOPEN_LOCK_OFFSET_WASM64 - : DLOPEN_LOCK_OFFSET_WASM32; - const processArchiveLockAddr = processChannelOffset - - FORK_BUF_SIZE - - processArchiveLockOffset; + let threadExceptionProvider: ForkExceptionProvider | null = null; + if (threadProcessContinuation && threadForkContinuation) { + threadProcessContinuation.prepareActivation({ + activationId: 0, + continuation: threadForkContinuation, + publishProcessLaunchRoot: (address) => { + writeForkContinuationAnchor( + memory, + forkAnchorAddr, + ptrWidth, + address, + ); + forkBufAddr = address; + }, + readProcessLaunchRoot: () => { + const view = new DataView(memory.buffer); + return ptrWidth === 8 + ? Number(view.getBigUint64(forkAnchorAddr, true)) + : view.getUint32(forkAnchorAddr, true); + }, + }); + } + const processArchiveHeadOffset = + ptrWidth === 8 ? DLOPEN_HEAD_OFFSET_WASM64 : DLOPEN_HEAD_OFFSET_WASM32; + const processArchiveHeadAddr = + processChannelOffset - FORK_BUF_SIZE - processArchiveHeadOffset; + const processArchiveLockOffset = + ptrWidth === 8 ? DLOPEN_LOCK_OFFSET_WASM64 : DLOPEN_LOCK_OFFSET_WASM32; + const processArchiveLockAddr = + processChannelOffset - FORK_BUF_SIZE - processArchiveLockOffset; + const processArchiveOwnerOffset = + ptrWidth === 8 ? DLOPEN_OWNER_OFFSET_WASM64 : DLOPEN_OWNER_OFFSET_WASM32; + const processArchiveOwnerAddr = + processChannelOffset - FORK_BUF_SIZE - processArchiveOwnerOffset; if ( - !Number.isSafeInteger(processArchiveHeadAddr) - || processArchiveHeadAddr <= 0 - || processArchiveHeadAddr + ptrWidth > memory.buffer.byteLength - || !Number.isSafeInteger(processArchiveLockAddr) - || processArchiveLockAddr <= 0 - || processArchiveLockAddr + 4 > memory.buffer.byteLength + !Number.isSafeInteger(processArchiveHeadAddr) || + processArchiveHeadAddr <= 0 || + processArchiveHeadAddr + ptrWidth > memory.buffer.byteLength || + !Number.isSafeInteger(processArchiveLockAddr) || + processArchiveLockAddr <= 0 || + processArchiveLockAddr + 4 > memory.buffer.byteLength || + !Number.isSafeInteger(processArchiveOwnerAddr) || + processArchiveOwnerAddr <= 0 || + processArchiveOwnerAddr + 4 > memory.buffer.byteLength ) { throw new Error( `pid=${pid} tid=${tid}: invalid process dlopen archive anchor ` + `${String(processArchiveHeadAddr)}`, ); } - processDlopenLock = new Int32Array(memory.buffer, processArchiveLockAddr, 1); - const processHasDlopenArchive = (): boolean => { - return ptrWidth === 8 - ? Atomics.load(new BigUint64Array(memory.buffer, processArchiveHeadAddr, 1), 0) !== 0n - : Atomics.load(new Uint32Array(memory.buffer, processArchiveHeadAddr, 1), 0) !== 0; + processDlopenLock = new Int32Array( + memory.buffer, + processArchiveLockAddr, + 1, + ); + processDlopenOwner = new Int32Array( + memory.buffer, + processArchiveOwnerAddr, + 1, + ); + const processArchiveControlAddr = processChannelOffset - FORK_BUF_SIZE; + const processGenerationOffset = + ptrWidth === 8 + ? DLOPEN_GENERATION_OFFSET_WASM64 + : DLOPEN_GENERATION_OFFSET_WASM32; + const processGenerationAddress = + processArchiveControlAddr - processGenerationOffset; + const threadTableReplicationImports: ForkActivationTableReplication = { + generationAddress: new WebAssembly.Global( + { value: "i64", mutable: false }, + BigInt(processGenerationAddress), + ), + reconcile: (): bigint => threadTableReplication?.reconcile() ?? 0n, + beginMutation: (): bigint => + threadTableReplication?.beginMutation() ?? 0n, + commit: (activationId, ownerId, firstIndex, length): void => { + threadTableReplication?.commit( + activationId, + ownerId, + firstIndex, + length, + ); + }, + abort: (): void => { + threadTableReplication?.abort(); + }, }; let forkResult = 0; @@ -2774,62 +5092,61 @@ export async function centralizedThreadWorkerMain( ); if (hasForkInstrumentation) { kernelImports.kernel_fork = (): number => { - if (!threadInstance) return -38; // ENOSYS + if (!threadInstance || !threadProcessContinuation) return -38; // ENOSYS - const getState = threadInstance.exports.wpk_fork_state as () => number; - const state = getState(); - if (state === 2) { + const phase = threadProcessContinuation.phaseName(); + if (phase === "parent-replay") { try { - (threadInstance.exports.wpk_fork_rewind_end as () => void)(); - threadForkContinuation!.finishReplayAndRelease(); - writeForkContinuationAnchor(memory, forkAnchorAddr, ptrWidth, 0); - forkBufAddr = 0; + threadProcessContinuation.finishReplay(); } finally { releasePthreadForkLock(); } return forkResult; } - if (state === 3) { + if (phase === "abort-replay") { const errno = threadForkContinuation!.abortErrno(); try { - (threadInstance.exports.wpk_fork_abort_end as () => void)(); - threadForkContinuation!.finishAbortReplayAndRelease(); - writeForkContinuationAnchor(memory, forkAnchorAddr, ptrWidth, 0); - forkBufAddr = 0; + threadProcessContinuation.finishAbortReplay(); } finally { releasePthreadForkLock(); } return -errno; } - - // Side modules live in the process main worker's module/table/tag - // graph. A pthread worker cannot replay that graph into its own - // instance, so fork must fail before unwind once the process has ever - // loaded a side module. The head is read live from shared memory so a - // dlopen after pthread creation is still observed. - if (!acquirePthreadForkLock()) { - return -95; // ENOTSUP: process-main dlopen is active + if (phase !== "idle") { + throw new Error( + `pid=${pid} tid=${tid}: fork import reached while process ` + + `continuation is ${phase}`, + ); } - if (processHasDlopenArchive()) { + + try { + // Reconciliation may instantiate a missing side module and execute + // its start function, so it requires writer ownership. Afterward, + // acquire the long-lived fork reader and verify no publication won + // the handoff race before capturing activation state. + for (;;) { + threadTableReplication?.reconcileNow(); + acquirePthreadForkLock(); + if ( + !threadTableReplication || + threadTableReplication.isCurrentUnderLock() + ) { + break; + } + releasePthreadForkLock(); + } + } catch (error) { releasePthreadForkLock(); - return -95; // ENOTSUP: pthreads cannot replay process side modules + throw error; } + const arena = newThreadModuleStateArena(); try { - forkBufAddr = Number(threadForkContinuation!.beginUnwind()); - writeForkContinuationAnchor( - memory, - forkAnchorAddr, - ptrWidth, - forkBufAddr, - ); - invokeForkContinuationBegin( - threadInstance.exports.wpk_fork_unwind_begin, - forkBufAddr, - ptrWidth, - `pid=${pid} tid=${tid}: linked fork unwind`, - ); + arena.begin(); + threadProcessContinuation.beginCapture(arena); + threadImportedStateCapture?.appendTo(arena); } catch (error) { + if (arena.hasActiveArena()) arena.release(); releasePthreadForkLock(); if (error instanceof ContinuationAllocationError) return -error.errno; throw error; @@ -2838,7 +5155,6 @@ export async function centralizedThreadWorkerMain( }; } else { kernelImports.kernel_fork = (): number => { - if (processHasDlopenArchive()) return -95; // ENOTSUP throw new Error( `pid=${pid} tid=${tid}: kernel_fork reached without complete ` + "wasm-fork-instrument exports. Rebuild the program with " + @@ -2848,9 +5164,115 @@ export async function centralizedThreadWorkerMain( } const threadLongjmpTag = createLongjmpTag(ptrWidth); const threadCppExceptionTag = createCppExceptionTag(ptrWidth); - const threadDlopenImports = buildUnsupportedThreadDlopenImports(memory); - const importObject = buildImportObject(module, memory, kernelImports, channelOffset, threadDlopenImports, - () => threadInstance, ptrWidth, threadLongjmpTag, threadCppExceptionTag, + const threadForkUnwindTag = createForkUnwindTag(); + const replicaActivationOwner = + hasDylinkForkRole && + threadProcessContinuation && + threadActivationRegistry && + threadExceptionBroker + ? createProcessDylinkActivationOwner({ + memory, + ptrWidth, + channelOffset, + forkUnwindTag: threadForkUnwindTag, + coordinator: threadProcessContinuation, + registry: threadActivationRegistry, + exceptionBroker: threadExceptionBroker, + importedStateCapture: threadImportedStateCapture ?? undefined, + tableReplication: threadTableReplicationImports, + isForkChild: false, + isPthreadReplica: true, + invokeProcessFork: () => { + const fork = threadInstance?.exports.fork; + if (typeof fork !== "function") { + throw new Error( + `pid=${pid} tid=${tid}: dylink fork role is missing ` + + "the main libc fork export", + ); + } + return Number((fork as () => number)()); + }, + label: `pid=${pid} tid=${tid}: dylink table activations`, + }) + : undefined; + const threadDlopenSupport = buildDlopenImports( + memory, + channelOffset, + processArchiveControlAddr, + () => + threadInstance?.exports.__indirect_function_table as + WebAssembly.Table | undefined, + () => + threadInstance?.exports.__stack_pointer as + WebAssembly.Global | undefined, + () => threadInstance, + ptrWidth, + threadLongjmpTag, + threadCppExceptionTag, + replicaActivationOwner, + hasDylinkForkRole + ? undefined + : `pid=${pid} tid=${tid}: main artifact lacks the dylink fork role capability`, + threadForkUnwindTag, + (table, firstIndex, length) => { + threadActivationRegistry?.markTableMutation(table, firstIndex, length); + }, + threadHostImportRuntime ?? undefined, + tid, + ); + if (threadActivationRegistry) { + threadTableReplication = createProcessTableReplicationOwner({ + generationAddress: processGenerationAddress, + registry: threadActivationRegistry, + dlopen: threadDlopenSupport, + newArena: newThreadModuleStateArena, + materializeModules: (snapshot) => { + threadDlopenSupport.replayDlopens(snapshot); + }, + restoreSnapshots: true, + label: `pid=${pid} tid=${tid}`, + }); + } + const threadCoordinator = threadProcessContinuation; + const threadForkEnvImports = + threadCoordinator && threadActivationRegistry && threadExceptionBroker + ? { + ...threadCoordinator.continuationImports(0, (errno) => { + threadCoordinator.beginCaptureAbort(errno); + }), + ...buildForkActivationStateImports( + 0, + threadActivationRegistry, + undefined, + threadTableReplicationImports, + ), + ...buildForkExceptionImports({ + activationId: 0, + ptrWidth, + registry: threadActivationRegistry, + broker: threadExceptionBroker, + provider: () => { + if (!threadExceptionProvider) { + throw new Error( + `pid=${pid} tid=${tid}: exception codec called before registration`, + ); + } + return threadExceptionProvider; + }, + }), + } + : undefined; + const importObject = buildImportObject( + module, + memory, + kernelImports, + channelOffset, + threadDlopenSupport.imports, + () => threadInstance, + ptrWidth, + threadLongjmpTag, + threadCppExceptionTag, + threadForkUnwindTag, (timedOutPtr, vmInterruptPtr, seconds) => { port.postMessage({ type: "vm_interrupt_timer", @@ -2860,23 +5282,90 @@ export async function centralizedThreadWorkerMain( seconds, } satisfies WorkerToHostMessage); }, - threadForkContinuation ?? undefined, - () => { - if (!threadInstance) { - throw new Error(`pid=${pid} tid=${tid}: continuation abort before instantiation`); - } - invokeForkContinuationBegin( - threadInstance.exports.wpk_fork_abort_begin, - forkBufAddr, - ptrWidth, - `pid=${pid} tid=${tid}: linked fork abort`, - ); - }); - const instance = new WebAssembly.Instance(module, importObject); + threadForkEnvImports, + ); + const routedThreadImportObject = threadHostImportRuntime + ? threadHostImportRuntime.routeImportObject( + initData.programBytes, + importObject, + ) + : importObject; + const threadMainImportedState = + threadImportedStateCapture?.prepareActivation( + 0, + module, + routedThreadImportObject, + ); + const threadInstanceImports = (threadMainImportedState?.imports ?? + routedThreadImportObject) as WebAssembly.Imports; + const instance = new WebAssembly.Instance(module, threadInstanceImports); threadInstance = instance; + threadMainImportedState?.complete(instance); + if ( + hasForkInstrumentation && + threadProcessContinuation && + threadActivationRegistry && + threadTemplateId + ) { + const threadBootstrap = instance.exports + .wpk_fork_module_thread_bootstrap as (() => void) | undefined; + if (!threadBootstrap) { + throw new Error( + `pid=${pid} tid=${tid}: fork module is missing thread bootstrap`, + ); + } + threadExceptionProvider = forkExceptionProviderFromInstance(0, instance); + threadProcessContinuation.registerActivation( + forkActivationRegistrationFromInstance({ + activationId: 0, + module, + instance, + templateId: threadTemplateId, + exceptionProvider: threadExceptionProvider, + }), + forkResumeTargetsFromInstance(module, instance), + ); + try { + // The pthread bootstrap consumes passive element segments, so static + // root harvesting and table-dirty registration must precede it just as + // they do for the process-main bootstrap. + threadBootstrap(); + threadImportedStateCapture?.bindTableDirtyTrackers( + new Map( + threadActivationRegistry + .activations() + .map((activation) => [ + activation.activationId, + activation.tableDirty, + ]), + ), + ); + } catch (error) { + threadTableReplication?.abortActiveMutations(); + threadProcessContinuation.unregisterActivation(0); + threadExceptionProvider = null; + throw error; + } + } + + const threadTable = instance.exports.__indirect_function_table as + WebAssembly.Table | undefined; + const threadStackPointer = instance.exports.__stack_pointer as + WebAssembly.Global | undefined; + if ( + (!threadTable || !threadStackPointer) && + threadDlopenSupport.archive.generation() !== 0 + ) { + throw new Error( + `pid=${pid} tid=${tid}: process has dlopen table recipes but ` + + "the pthread instance has no shared table/stack binding", + ); + } + threadTableReplication?.reconcileNow(); // Initialize Wasm TLS for this thread in the slot's explicit TLS/control page. - const wasmInitTls = instance.exports.__wasm_init_tls as ((addr: number | bigint) => void) | undefined; + const wasmInitTls = instance.exports.__wasm_init_tls as + ((addr: number | bigint) => void) | undefined; const tlsBlock = tlsOffset; if (wasmInitTls && tlsBlock > 0) { @@ -2884,13 +5373,15 @@ export async function centralizedThreadWorkerMain( } // Set __stack_pointer - const stackPointer = instance.exports.__stack_pointer as WebAssembly.Global | undefined; + const stackPointer = instance.exports.__stack_pointer as + WebAssembly.Global | undefined; if (stackPointer) { stackPointer.value = ptrWidth === 8 ? BigInt(stackPtr) : stackPtr; } // Initialize musl thread pointer if available - const wasmThreadInit = instance.exports.__wasm_thread_init as ((tp: number | bigint) => void) | undefined; + const wasmThreadInit = instance.exports.__wasm_thread_init as + ((tp: number | bigint) => void) | undefined; if (wasmThreadInit && tlsPtr > 0) { wasmThreadInit(ptrWidth === 8 ? BigInt(tlsPtr) : tlsPtr); } @@ -2898,77 +5389,92 @@ export async function centralizedThreadWorkerMain( // Set __channel_base without calling the exported helper. lld can prefix // exported functions with __wasm_call_ctors, and thread workers must not // re-run constructors in shared process memory. - setupChannelBase(instance, module, memory, channelOffset, initData.programBytes, ptrWidth); + setupChannelBase( + instance, + module, + memory, + channelOffset, + initData.programBytes, + ptrWidth, + ); // Call the thread function via indirect function table - const table = instance.exports.__indirect_function_table as WebAssembly.Table | undefined; + const table = threadTable; if (!table) { - throw new Error("No __indirect_function_table export — cannot call thread function"); + throw new Error( + "No __indirect_function_table export — cannot call thread function", + ); } // On wasm64, table indices may require BigInt (table64 extension) const tableIdx = ptrWidth === 8 ? BigInt(fnPtr) : fnPtr; - const threadFn = table.get(tableIdx as number) as ((...args: (number | bigint)[]) => number | bigint) | null; + const threadFn = table.get(tableIdx as number) as + ((...args: (number | bigint)[]) => number | bigint) | null; if (!threadFn) { throw new Error(`Thread function at table index ${fnPtr} is null`); } const threadArg = ptrWidth === 8 ? BigInt(argPtr) : argPtr; + const resumeThread = hasForkInstrumentation + ? (instance.exports.wpk_fork_resume_thread as + | ((tableIndex: number, arg: number | bigint) => number | bigint) + | undefined) + : undefined; + if (hasForkInstrumentation && typeof resumeThread !== "function") { + throw new Error( + `pid=${pid} tid=${tid}: fork-capable program is missing ` + + "wpk_fork_resume_thread", + ); + } let result = 0; - if (hasForkInstrumentation) { - const getState = instance.exports.wpk_fork_state as () => number; - const unwindEnd = instance.exports.wpk_fork_unwind_end as () => void; - let needsRewind = false; - + if (hasForkInstrumentation && threadProcessContinuation) { for (;;) { - if (needsRewind) { - threadForkContinuation!.beginReplay(); - invokeForkContinuationBegin( - instance.exports.wpk_fork_rewind_begin, - forkBufAddr, - ptrWidth, - `pid=${pid} tid=${tid}: linked fork rewind`, - ); - needsRewind = false; - } - + let transportedForkUnwind = false; try { - const raw = threadFn(threadArg); + const raw = + threadProcessContinuation.phaseName() === "idle" + ? threadFn(threadArg) + : resumeThread!(fnPtr, threadArg); result = Number(raw); } catch (e) { - if ( + if (isForkUnwindException(e, threadForkUnwindTag)) { + transportedForkUnwind = true; + } else if ( e instanceof Error && e.message.includes("unreachable") && kernelThreadExitStatus !== null ) { result = kernelThreadExitStatus; break; + } else { + throw e; } - throw e; } - const forkState = getState(); - if (forkState === 1) { - unwindEnd(); - threadForkContinuation!.finishUnwind(); - // Close the race where the process main worker dlopens after this - // pthread began unwinding but before it completed. Rewind locally - // with ENOTSUP and do not create a child. - if (processHasDlopenArchive()) { - forkResult = -95; - needsRewind = true; - continue; - } + const phase = threadProcessContinuation.phaseName(); + if (transportedForkUnwind && phase !== "capture") { + throw new Error( + `pid=${pid} tid=${tid}: private fork-unwind exception escaped ` + + `while process continuation is ${phase}`, + ); + } + if (phase === "capture") { + threadProcessContinuation.sealCapture(); const childPid = sendForkSyscall(memory, channelOffset); + forkResult = childPid; if (childPid < 0) { - forkResult = childPid; - needsRewind = true; - continue; + threadProcessContinuation.beginAbortReplay(-childPid); + } else { + threadProcessContinuation.beginParentReplay(); } - forkResult = childPid; - needsRewind = true; continue; } + if (phase !== "idle") { + throw new Error( + `pid=${pid} tid=${tid}: pthread entry returned while process ` + + `continuation is ${phase}`, + ); + } break; } } else { @@ -2988,10 +5494,13 @@ export async function centralizedThreadWorkerMain( } } - // A well-formed fork releases its reader token from the state=2 import - // above. Keep normal-return cleanup defensive so an unexpected - // instrumenter state cannot strand the process-wide writer lock. + // A well-formed replay releases its reader token from the inherited fork + // import above. Keep normal-return cleanup defensive so an unexpected + // execution exit cannot strand the process-wide writer lock. releasePthreadForkLock(); + threadProcessContinuation?.clear(); + threadExternrefTokens?.clear(); + threadHostImportRuntime?.clear(); // A normal return has not passed through libc's noreturn kernel_exit // import, so publish SYS_EXIT here. When kernel_exit already ran it sent @@ -3009,7 +5518,12 @@ export async function centralizedThreadWorkerMain( Atomics.notify(i32, (base + CH_STATUS) / 4, 1); // Wait for kernel to process the exit. The kernel completes the channel // (CH_STATUS -> COMPLETE), which returns this Atomics.wait. - while (Atomics.wait(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING) === "ok") { /* */ } + while ( + Atomics.wait(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING) === + "ok" + ) { + /* */ + } // Intentionally do NOT reset CH_STATUS back to IDLE here. A normal syscall // resets to IDLE so the next syscall can set PENDING, but an exiting thread // issues no further syscalls — the channel is torn down and the slot is @@ -3024,7 +5538,16 @@ export async function centralizedThreadWorkerMain( tid, } satisfies WorkerToHostMessage); } catch (err) { + threadTableReplication?.abortActiveMutations(); releasePthreadForkLock(); + try { + threadProcessContinuation?.clear(); + } catch { + // Preserve the original worker failure after making transaction roots + // unreachable as far as the coordinator can. + } + threadExternrefTokens?.clear(); + threadHostImportRuntime?.clear(); if (err instanceof ExecRetirement) { port.postMessage({ type: "exec_retired", @@ -3033,9 +5556,8 @@ export async function centralizedThreadWorkerMain( } satisfies WorkerToHostMessage); return; } - const message = err instanceof Error - ? `${err.message}\n${err.stack ?? ""}` - : String(err); + const message = + err instanceof Error ? `${err.message}\n${err.stack ?? ""}` : String(err); port.postMessage({ type: "error", pid, diff --git a/host/src/worker-protocol.ts b/host/src/worker-protocol.ts index d9c331dfd7..65a4cd352d 100644 --- a/host/src/worker-protocol.ts +++ b/host/src/worker-protocol.ts @@ -1,3 +1,10 @@ +import type { + ForkHostImportWorkerInit, +} from "./fork-host-import-runtime"; +import type { + ForkExternrefImportWake, +} from "./fork-externref-import-mailbox"; + // --- Host → Worker messages --- /** @@ -32,6 +39,20 @@ export interface CentralizedWorkerInitMessage { memory: WebAssembly.Memory; /** Channel offset within the shared Memory for this thread's syscall channel */ channelOffset: number; + /** + * Exact process-image generation issued by the kernel-side externref owner. + * Workers use this scalar only when routing token-bearing host imports; the + * broker capability and real JavaScript values never cross the Worker edge. + * Optional only for direct non-fork harnesses; an instrumented artifact must + * reject launch unless this and `forkHostImports` are both present. + */ + externrefGenerationId?: number; + /** + * One fixed owner-import mailbox for this Worker. Side modules reuse it. + * Optional only for direct non-fork test harnesses that do not create a + * durable process owner; production Node/browser launch paths always set it. + */ + forkHostImports?: ForkHostImportWorkerInit; /** Optional env vars to set up in the program */ env?: string[]; /** Optional argv */ @@ -42,6 +63,12 @@ export interface CentralizedWorkerInitMessage { isForkChild?: boolean; /** Address of the fork save-buffer in memory (used for fork child rewind) */ forkBufAddr?: number; + /** + * Two-phase launch gate for a fork child. The child announces that all + * reconstruction and activation frames reached the inherited fork import, + * then waits here until the kernel host commits the launch. + */ + forkReplayGate?: SharedArrayBuffer; /** * Entry-point override for fork children created by a non-main thread. * @@ -77,6 +104,13 @@ export interface CentralizedThreadInitMessage { * archive head relative to this live shared-memory anchor before fork. */ processChannelOffset: number; channelOffset: number; + /** + * Same process-image externref generation as the process's main Worker. + * Optional only for direct non-fork harnesses. + */ + externrefGenerationId?: number; + /** Distinct pthread mailbox; side modules in this pthread reuse it. */ + forkHostImports?: ForkHostImportWorkerInit; fnPtr: number; argPtr: number; stackPtr: number; @@ -100,6 +134,7 @@ export interface WorkerTerminateMessage { export type WorkerToHostMessage = | WorkerReadyMessage + | ForkReplayReadyMessage | WorkerExitMessage | ThreadExitMessage | WorkerMemoryQuiescentMessage @@ -108,13 +143,19 @@ export type WorkerToHostMessage = | ExecRequestMessage | ExecCompleteMessage | AlarmSetMessage - | VmInterruptTimerMessage; + | VmInterruptTimerMessage + | ForkHostImportWakeMessage; export interface WorkerReadyMessage { type: "ready"; pid: number; } +export interface ForkReplayReadyMessage { + type: "fork_replay_ready"; + pid: number; +} + export interface WorkerExitMessage { type: "exit"; pid: number; @@ -175,6 +216,12 @@ export interface VmInterruptTimerMessage { seconds: number; } +export interface ForkHostImportWakeMessage { + type: "fork_host_import"; + /** Contains scalar identity/sequence fields only; the SAB moved at init. */ + wake: ForkExternrefImportWake; +} + export interface ExecReplyMessage { type: "exec_reply"; wasmBytes: ArrayBuffer; diff --git a/host/test/abi-version.test.ts b/host/test/abi-version.test.ts index 3169078314..135d08be81 100644 --- a/host/test/abi-version.test.ts +++ b/host/test/abi-version.test.ts @@ -2,6 +2,19 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import { resolveBinary } from "../src/binary-resolver"; import { detectPtrWidth } from "../src/constants"; +import { + FORK_ANYREF_TRANSIT_IMPORT, + ForkAnyrefTransitTable, +} from "../src/fork-anyref-transit"; +import { FORK_MODULE_TABLE_GENERATION_ADDR_IMPORT } from "../src/fork-activation-registry"; +import { + createForkUnwindTag, + FORK_UNWIND_TAG_IMPORT_NAME, +} from "../src/fork-unwind-transport"; +import { + WPK_FORK_EXCEPTION_IMPORT_ACTIVATION, + WPK_FORK_RESUME_IMPORT_TABLE, +} from "../src/generated/abi"; /** * Coverage for the ABI version surface: @@ -20,7 +33,10 @@ describe("ABI version marker", () => { const kernelWasm = readFileSync(resolveBinary("kernel.wasm")); function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { - return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + return bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ); } async function instantiateKernelOnly( @@ -29,18 +45,19 @@ describe("ABI version marker", () => { const ptrWidth = detectPtrWidth(toArrayBuffer(bytes)); // Match host/src/kernel.ts. Keep headroom above the kernel Wasm's // linker-derived minimum without re-tuning this test per change. - const memory = ptrWidth === 8 - ? new WebAssembly.Memory({ - initial: 24n, - maximum: 16384n, - shared: true, - address: "i64", - } as unknown as WebAssembly.MemoryDescriptor) - : new WebAssembly.Memory({ - initial: 24, - maximum: 16384, - shared: true, - }); + const memory = + ptrWidth === 8 + ? new WebAssembly.Memory({ + initial: 24n, + maximum: 16384n, + shared: true, + address: "i64", + } as unknown as WebAssembly.MemoryDescriptor) + : new WebAssembly.Memory({ + initial: 24, + maximum: 16384, + shared: true, + }); const module = await WebAssembly.compile(bytes as BufferSource); // The kernel imports many host functions. We only need to inspect // the exports, so provide minimal stubs for every import. @@ -60,9 +77,7 @@ describe("ABI version marker", () => { it("kernel exports __abi_version as a function returning u32", async () => { const instance = await instantiateKernelOnly(kernelWasm); - const fn = instance.exports.__abi_version as - | (() => number) - | undefined; + const fn = instance.exports.__abi_version as (() => number) | undefined; expect(typeof fn).toBe("function"); const value = fn!(); expect(typeof value).toBe("number"); @@ -96,18 +111,52 @@ describe("ABI version marker", () => { }); const importObject: WebAssembly.Imports = { env: { memory } }; const envImports = importObject.env as Record; + const gcTransit = new ForkAnyrefTransitTable(); + const resumeTable = new WebAssembly.Table({ + initial: 1, + element: "anyfunc", + }); + const unwindTag = createForkUnwindTag(); for (const imp of WebAssembly.Module.imports(module)) { if (imp.module === "env" && imp.name === "memory") continue; const target = (importObject[imp.module] ??= {}) as Record< string, unknown >; - target[imp.name] ??= - imp.kind === "function" - ? (..._args: unknown[]) => 0 - : imp.kind === "global" - ? new WebAssembly.Global({ value: "i32", mutable: true }, 0) - : undefined; + if (target[imp.name] !== undefined) continue; + if (imp.kind === "function") { + target[imp.name] = (..._args: unknown[]) => 0; + } else if (imp.kind === "global") { + target[imp.name] = + imp.name === FORK_MODULE_TABLE_GENERATION_ADDR_IMPORT + ? new WebAssembly.Global({ value: "i64", mutable: false }, 0n) + : new WebAssembly.Global( + { + value: "i32", + mutable: imp.name !== WPK_FORK_EXCEPTION_IMPORT_ACTIVATION, + }, + 0, + ); + } else if (imp.kind === "table") { + // WHY: ABI 43's GC transit table has `(ref null any)` element type and + // cannot be replaced by the legacy `anyfunc` resume table. + if (imp.name === FORK_ANYREF_TRANSIT_IMPORT) { + target[imp.name] = gcTransit.table; + } else if (imp.name === WPK_FORK_RESUME_IMPORT_TABLE) { + target[imp.name] = resumeTable; + } else { + throw new Error(`unhandled table import ${imp.module}.${imp.name}`); + } + } else if ( + (imp.kind as string) === "tag" && + imp.name === FORK_UNWIND_TAG_IMPORT_NAME + ) { + target[imp.name] = unwindTag; + } else { + throw new Error( + `unhandled ${imp.kind} import ${imp.module}.${imp.name}`, + ); + } void envImports; } const instance = await WebAssembly.instantiate(module, importObject); diff --git a/host/test/audio-integration.test.ts b/host/test/audio-integration.test.ts index b226df5f9f..e91808ea17 100644 --- a/host/test/audio-integration.test.ts +++ b/host/test/audio-integration.test.ts @@ -25,7 +25,11 @@ import { CAPTURED_STDIO, CentralizedKernelWorker } from "../src/kernel-worker"; import { NodePlatformIO } from "../src/platform/node"; import { NodeWorkerAdapter } from "../src/worker-adapter"; import { detectPtrWidth } from "../src/constants"; -import type { CentralizedWorkerInitMessage } from "../src/worker-protocol"; +import type { + CentralizedWorkerInitMessage, + WorkerToHostMessage, +} from "../src/worker-protocol"; +import { TestProcessReferenceOwners } from "./process-reference-owner-helper"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -57,22 +61,34 @@ describe.skipIf(!existsSync(audiotestBinary))("audio integration", () => { const io = new NodePlatformIO(); const workerAdapter = new NodeWorkerAdapter(); - const workers = new Map>(); + const referenceOwners = new TestProcessReferenceOwners(); + const workers = new Map< + number, + ReturnType + >(); let pid = 0; let stdout = ""; let resolveExit: (status: number) => void; - const exitPromise = new Promise((resolve) => { + let rejectExit: (reason: Error) => void; + const exitPromise = new Promise((resolve, reject) => { resolveExit = resolve; + rejectExit = reject; }); const kernel = new CentralizedKernelWorker( - { maxWorkers: 4, dataBufferSize: 65536, useSharedMemory: true, enableSyscallLog: false }, + { + maxWorkers: 4, + dataBufferSize: 65536, + useSharedMemory: true, + enableSyscallLog: false, + }, io, { onExit: (exitPid, exitStatus) => { if (exitPid === pid) { + referenceOwners.release(exitPid); kernel.unregisterProcess(exitPid); const w = workers.get(exitPid); if (w) { @@ -104,6 +120,7 @@ describe.skipIf(!existsSync(audiotestBinary))("audio integration", () => { new Uint8Array(memory.buffer, channelOffset, CH_TOTAL_SIZE).fill(0); kernel.registerProcess(pid, memory, [channelOffset], { ptrWidth }); + const referenceInit = referenceOwners.start(pid); const initData: CentralizedWorkerInitMessage = { type: "centralized_init", @@ -114,16 +131,34 @@ describe.skipIf(!existsSync(audiotestBinary))("audio integration", () => { argv: ["audiotest"], env: [], ptrWidth, + ...referenceInit, }; const mainWorker = workerAdapter.createWorker(initData); + referenceOwners.attach(pid, mainWorker); + mainWorker.on("error", rejectExit); + mainWorker.on("message", (raw: unknown) => { + const message = raw as WorkerToHostMessage; + if (message.type === "error" && message.pid === pid) { + rejectExit(new Error(message.message)); + } + }); workers.set(pid, mainWorker); try { const exitCode = await Promise.race([ exitPromise, new Promise((_, reject) => - setTimeout(() => reject(new Error("audiotest didn't exit in 10s")), 10_000), + setTimeout( + () => + reject( + new Error( + "audiotest didn't exit in 10s" + + (stderr ? `: ${stderr}` : ""), + ), + ), + 10_000, + ), ), ]); expect(exitCode).toBe(0); @@ -169,6 +204,7 @@ describe.skipIf(!existsSync(audiotestBinary))("audio integration", () => { expect(kernel.drainAudio(after)).toBe(0); } finally { for (const [, w] of workers) await w.terminate().catch(() => {}); + referenceOwners.close(); void exitPromise.catch(() => {}); } }, 30_000); diff --git a/host/test/catch-ref-fresh-worker.test.ts b/host/test/catch-ref-fresh-worker.test.ts index 2272c1e8f7..19610d5d72 100644 --- a/host/test/catch-ref-fresh-worker.test.ts +++ b/host/test/catch-ref-fresh-worker.test.ts @@ -11,6 +11,10 @@ const fixtureSource = resolve( testDir, "fixtures/catch-ref-fresh-worker.wat", ); +const referencePayloadFixtureSource = resolve( + testDir, + "fixtures/reference-catch-payload-fresh-worker.wat", +); const instrumenter = resolve( testDir, "../../tools/bin/wasm-fork-instrument", @@ -19,6 +23,7 @@ const instrumenter = resolve( describe("CatchRef fresh process worker replay", () => { let workDir = ""; let programPath = ""; + let referencePayloadProgramPath = ""; beforeAll(() => { workDir = mkdtempSync(join(tmpdir(), "kandelo-catch-ref-worker-")); @@ -32,6 +37,27 @@ describe("CatchRef fresh process worker replay", () => { rawPath, ]); execFileSync(instrumenter, [rawPath, "-o", programPath]); + + const referencePayloadRawPath = join( + workDir, + "reference-catch-payload-fresh-worker.raw.wasm", + ); + referencePayloadProgramPath = join( + workDir, + "reference-catch-payload-fresh-worker.wasm", + ); + execFileSync("wat2wasm", [ + "--enable-exceptions", + "--enable-threads", + referencePayloadFixtureSource, + "-o", + referencePayloadRawPath, + ]); + execFileSync(instrumenter, [ + referencePayloadRawPath, + "-o", + referencePayloadProgramPath, + ]); }); afterAll(() => { @@ -55,4 +81,22 @@ describe("CatchRef fresh process worker replay", () => { ).toBe(0); expect(result.stderr).toBe(""); }); + + it("reconstructs reference-bearing catches in fresh Node child workers", async () => { + // The first child calls a non-null funcref reconstructed from the child's + // static function catalog. The second verifies a nullable externref + // payload; both values originated in a caught exception recipe. + const result = await runCentralizedProgram({ + programPath: referencePayloadProgramPath, + argv: ["reference-catch-payload-fresh-worker"], + timeout: 30_000, + useDefaultRootfs: false, + }); + + expect( + result.exitCode, + `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ).toBe(0); + expect(result.stderr).toBe(""); + }); }); diff --git a/host/test/centralized-test-helper.ts b/host/test/centralized-test-helper.ts index d836a7c724..eaa01e71b4 100644 --- a/host/test/centralized-test-helper.ts +++ b/host/test/centralized-test-helper.ts @@ -20,6 +20,16 @@ import { type ProcessMemoryLayout, } from "../src/process-memory"; import { NodeKernelHost } from "../src/node-kernel-host"; +import { + ForkHostImportOwnerRuntime, + type ForkHostImportOwnerWorker, +} from "../src/fork-host-import-runtime"; +import { ForkExternrefProcessOwner } from "../src/fork-externref-process-owner"; +import { + ForkReplayGateCoordinator, + observeForkReplayWorker, +} from "../src/fork-replay-gate"; +import type { ForkExternrefGeneration } from "../src/fork-reference-broker"; import type { HostDiagnostic } from "../src/host-diagnostic"; import type { CentralizedWorkerInitMessage, CentralizedThreadInitMessage, WorkerToHostMessage } from "../src/worker-protocol"; import type { PlatformIO } from "../src/types"; @@ -359,10 +369,25 @@ async function runOnMainThread(options: RunProgramOptions): Promise(); const processPtrWidths = new Map(); const forkReplayContexts = new Map(); + const externrefProcessOwner = new ForkExternrefProcessOwner(); + const forkHostImportOwnerRuntime = + new ForkHostImportOwnerRuntime(externrefProcessOwner); + const externrefGenerations = new Map(); + const processForkHostImports = new Map(); let mainThreadForkCount: bigint | undefined; let pid = 0; + const releaseProcessReferenceOwner = (releasePid: number): void => { + processForkHostImports.get(releasePid)?.close(); + processForkHostImports.delete(releasePid); + const generation = externrefGenerations.get(releasePid); + if (generation) { + externrefProcessOwner.releaseGeneration(generation); + externrefGenerations.delete(releasePid); + } + }; + const kernelWorker = new CentralizedKernelWorker( { maxWorkers: 4, dataBufferSize: 65536, useSharedMemory: true, enableSyscallLog: !!process.env.KERNEL_SYSCALL_LOG }, io, @@ -408,8 +433,42 @@ async function runOnMainThread(options: RunProgramOptions): Promise; + const childForkHostImports = forkHostImportOwnerRuntime.createWorker({ + pid: childPid, + generationId: childGeneration.id, + authorizeSender: () => { + if ( + workers.get(childPid) !== childWorker + || externrefGenerations.get(childPid) !== childGeneration + ) { + throw new Error( + `stale centralized-test host-import sender for pid=${childPid}`, + ); + } + }, + }); const childInitData: CentralizedWorkerInitMessage = { type: "centralized_init", @@ -419,13 +478,24 @@ async function runOnMainThread(options: RunProgramOptions): Promise {}); }; childWorker.on("error", finalizeChildWorkerError); @@ -462,10 +533,46 @@ async function runOnMainThread(options: RunProgramOptions): Promise workers.get(childPid) === childWorker, + ); - return [childChannelOffset]; + try { + await forkReplay.waitUntilReady(); + if (workers.get(childPid) !== childWorker) { + throw new Error( + `Fork child ${childPid} changed generation before replay commit`, + ); + } + if (!kernelWorker.shouldLaunchPendingChild(childPid)) { + throw new Error(`Fork child ${childPid} exited before replay commit`); + } + // Match the real Node/browser host: the parent cannot observe the + // child until replay has reached the inherited fork import and this + // separate commit wakes that exact Worker generation. + forkReplay.commit(); + return [childChannelOffset]; + } catch (error) { + forkReplay.cancel(error); + if (workers.get(childPid) === childWorker) { + workers.delete(childPid); + processProgramBytes.delete(childPid); + processLayouts.delete(childPid); + threadAllocators.delete(childPid); + processPtrWidths.delete(childPid); + forkReplayContexts.delete(childPid); + releaseProcessReferenceOwner(childPid); + } + childWorker.terminate().catch(() => {}); + throw error; + } }, onExec: async (execPid, path, argv, envp, callerTid) => { const wasmPath = options.execPrograms?.get(path); @@ -498,10 +605,21 @@ async function runOnMainThread(options: RunProgramOptions): Promise | undefined; + let replacementGeneration: ForkExternrefGeneration | undefined; + let replacementForkHostImports: ForkHostImportOwnerWorker | undefined; try { const setupResult = kernelWorker.kernelExecSetup(execPid, callerTid); if (setupResult < 0) return setupResult; kernelWorker.prepareProcessForExec(execPid); + const previousGeneration = externrefGenerations.get(execPid); + if (!previousGeneration) { + throw new Error( + `Unknown externref generation for exec pid ${execPid}`, + ); + } + replacementGeneration = + externrefProcessOwner.replaceGeneration(previousGeneration); + externrefGenerations.set(execPid, replacementGeneration); const finalizeResult = kernelWorker.finalizeAddressSpaceForExec(execPid); if (finalizeResult < 0) { @@ -509,11 +627,18 @@ async function runOnMainThread(options: RunProgramOptions): Promise {}); workers.delete(execPid); } - if (kernelWorker.finalizeExecHandoffTermination(execPid) > 0) return 0; + if (kernelWorker.finalizeExecHandoffTermination(execPid) > 0) { + externrefProcessOwner.releaseGeneration(replacementGeneration); + externrefGenerations.delete(execPid); + replacementGeneration = undefined; + return 0; + } kernelWorker.registerProcess(execPid, newMemory, [newChannelOffset], { preserveProcessState: true, @@ -531,6 +656,23 @@ async function runOnMainThread(options: RunProgramOptions): Promise { + if ( + !replacementWorker + || workers.get(execPid) !== replacementWorker + || externrefGenerations.get(execPid) + !== replacementGeneration + ) { + throw new Error( + `stale centralized-test host-import sender for exec pid=${execPid}`, + ); + } + }, + }); const initData: CentralizedWorkerInitMessage = { type: "centralized_init", pid: execPid, @@ -540,16 +682,26 @@ async function runOnMainThread(options: RunProgramOptions): Promise { console.error(`[exec] worker error for pid ${execPid}:`, err); }); + replacementWorker.on("message", (msg: unknown) => { + const m = msg as WorkerToHostMessage; + if (m.type === "fork_host_import") { + replacementForkHostImports?.dispatch(m.wake); + } + }); kernelWorker.finishProcessExecHandoff(execPid); return 0; } catch (err) { + replacementForkHostImports?.close(); try { kernelWorker.prepareProcessForExec(execPid); } catch { /* best-effort */ } if (replacementWorker && workers.get(execPid) !== replacementWorker) { await replacementWorker.terminate().catch(() => {}); @@ -566,6 +718,7 @@ async function runOnMainThread(options: RunProgramOptions): Promise; + let threadWorkerLive = true; + const threadForkHostImports = forkHostImportOwnerRuntime.createWorker({ + pid: clonePid, + generationId: processGeneration.id, + authorizeSender: () => { + if ( + !threadWorkerLive + || externrefGenerations.get(clonePid) !== processGeneration + ) { + throw new Error( + `stale centralized-test pthread host-import sender ` + + `for pid=${clonePid} tid=${tid}`, + ); + } + }, + }); const threadInitData: CentralizedThreadInitMessage = { type: "centralized_thread_init", @@ -614,17 +791,32 @@ async function runOnMainThread(options: RunProgramOptions): Promise { const m = msg as WorkerToHostMessage; if (m.type === "thread_exit") { + threadWorkerLive = false; + threadForkHostImports.close(); threadAllocator.free(alloc.basePage); threadWorker.terminate().catch(() => {}); + } else if (m.type === "fork_host_import") { + threadForkHostImports.dispatch(m.wake); } }); threadWorker.on("error", () => { + threadWorkerLive = false; + threadForkHostImports.close(); kernelWorker.notifyThreadExit(clonePid, tid); kernelWorker.removeChannel(clonePid, alloc.channelOffset); threadAllocator.free(alloc.basePage); @@ -642,6 +834,7 @@ async function runOnMainThread(options: RunProgramOptions): Promise {}); @@ -655,6 +848,7 @@ async function runOnMainThread(options: RunProgramOptions): Promise {}); @@ -710,6 +904,23 @@ async function runOnMainThread(options: RunProgramOptions): Promise; + const mainForkHostImports = forkHostImportOwnerRuntime.createWorker({ + pid, + generationId: mainGeneration.id, + authorizeSender: () => { + if ( + workers.get(pid) !== mainWorker + || externrefGenerations.get(pid) !== mainGeneration + ) { + throw new Error( + `stale centralized-test host-import sender for pid=${pid}`, + ); + } + }, + }); const initData: CentralizedWorkerInitMessage = { type: "centralized_init", pid, @@ -719,10 +930,20 @@ async function runOnMainThread(options: RunProgramOptions): Promise { for (const [, w] of workers) w.terminate().catch(() => {}); + for (const livePid of [...externrefGenerations.keys()]) { + releaseProcessReferenceOwner(livePid); + } rejectExit(new Error(`Program timed out after ${timeout}ms`)); }, timeout); mainWorker.on("error", (err: Error) => { clearTimeout(timer); + releaseProcessReferenceOwner(pid); rejectExit(err); }); @@ -748,7 +973,10 @@ async function runOnMainThread(options: RunProgramOptions): Promise {}); + releaseProcessReferenceOwner(pid); rejectExit(new Error(m.message)); + } else if (m.type === "fork_host_import") { + mainForkHostImports.dispatch(m.wake); } }); diff --git a/host/test/dlopen-host-imports.test.ts b/host/test/dlopen-host-imports.test.ts index 4d640bd780..0ec62486ec 100644 --- a/host/test/dlopen-host-imports.test.ts +++ b/host/test/dlopen-host-imports.test.ts @@ -1,4 +1,13 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { + ABI_SYSCALLS, + CHANNEL_STATUS_COMPLETE, + CHANNEL_STATUS_PENDING, + CH_ERRNO, + CH_RETURN, + CH_STATUS, + CH_SYSCALL, +} from "../src/generated/abi"; import { buildDlopenImports } from "../src/worker-main"; type WasmPointer = number | bigint; @@ -18,6 +27,29 @@ type DlsymImport = ( type DlerrorImport = (bufPtr: WasmPointer, bufMax: number) => number; +type DlopenPrepareImport = ( + bytesPtr: WasmPointer, + bytesLen: number, + namePtr: WasmPointer, + nameLen: number, + flags: number, +) => number; + +type DlopenNextImport = (transaction: number) => number; +type DlopenCommitImport = (transaction: number) => number; + +const STAGED_CTOR_SIDE_MODULE = Uint8Array.from( + Buffer.from( + "0061736d01000000" + + "000f0864796c696e6b2e30010400000000" + + "010401600000" + + "03020100" + + "071501115f5f7761736d5f63616c6c5f63746f72730000" + + "0a040102000b", + "hex", + ), +); + function createImports(ptrWidth: 4 | 8): { memory: WebAssembly.Memory; pointer: (value: number) => WasmPointer; @@ -125,4 +157,101 @@ describe("dlopen host import pointer widths", () => { .toThrow(/__wasm_dlopen bytes: memory range .* exceeds 65536 bytes/); expect(dlopen(pointer(0), 0, pointer(0), 0)).toBe(1); }); + + it("retains the loader lease after a premature explicit staged commit", () => { + const channelOffset = 4_096; + const archiveControlAddr = 128; + const memory = new WebAssembly.Memory({ + initial: 2, + maximum: 2, + shared: true, + }); + const table = new WebAssembly.Table({ initial: 1, element: "anyfunc" }); + const stackPointer = new WebAssembly.Global( + { value: "i32", mutable: true }, + 32_768, + ); + const support = buildDlopenImports( + memory, + channelOffset, + archiveControlAddr, + () => table, + () => stackPointer, + () => undefined, + 4, + undefined, + undefined, + false, + ); + const prepare = support.imports + .__wasm_dlopen_prepare as DlopenPrepareImport; + const next = support.imports.__wasm_dlopen_next as DlopenNextImport; + const commit = support.imports.__wasm_dlopen_commit as DlopenCommitImport; + const moduleOffset = 8_192; + const nameOffset = 8_512; + const name = new TextEncoder().encode("libstaged-lease.so"); + const bytes = new Uint8Array(memory.buffer); + bytes.set(STAGED_CTOR_SIDE_MODULE, moduleOffset); + bytes.set(name, nameOffset); + + let nextMapping = 16_384; + const wait = vi.spyOn(Atomics, "wait").mockImplementation( + (array, index, expected) => { + if (Atomics.load(array, index) !== expected) return "not-equal"; + if ( + array.buffer !== memory.buffer + || index !== (channelOffset + CH_STATUS) / 4 + || expected !== CHANNEL_STATUS_PENDING + ) { + throw new Error("unexpected Atomics.wait in staged-loader test"); + } + const view = new DataView(memory.buffer); + const syscall = view.getInt32(channelOffset + CH_SYSCALL, true); + const result = syscall === ABI_SYSCALLS.Mmap + ? nextMapping + : syscall === ABI_SYSCALLS.Munmap + ? 0 + : -1; + if (syscall === ABI_SYSCALLS.Mmap) nextMapping += 8_192; + view.setBigInt64(channelOffset + CH_RETURN, BigInt(result), true); + view.setUint32( + channelOffset + CH_ERRNO, + result < 0 ? 38 : 0, + true, + ); + Atomics.store(array, index, CHANNEL_STATUS_COMPLETE); + return "ok"; + }, + ); + + try { + const transaction = prepare( + moduleOffset, + STAGED_CTOR_SIDE_MODULE.length, + nameOffset, + name.length, + 0x100, + ); + expect(transaction).toBeGreaterThan(0); + + const entry = next(transaction); + expect(entry).toBeGreaterThan(0); + // Once `next` has issued an entry, commit must wait for ordinary Wasm + // to execute it. This is a truthful misuse failure, not cancellation. + expect(commit(transaction)).toBe(0); + const loaderOwner = new Int32Array( + memory.buffer, + archiveControlAddr - 24, + 1, + ); + expect(loaderOwner[0]).toBe(1); + + (table.get(entry) as () => void)(); + expect(next(transaction)).toBe(0); + expect(commit(transaction)).toBeGreaterThan(0); + expect(loaderOwner[0]).toBe(0); + } finally { + wait.mockRestore(); + } + }); }); diff --git a/host/test/dri-cube-pyramid.test.ts b/host/test/dri-cube-pyramid.test.ts index cc3acab485..730b8ee4cb 100644 --- a/host/test/dri-cube-pyramid.test.ts +++ b/host/test/dri-cube-pyramid.test.ts @@ -11,12 +11,17 @@ import { NodePlatformIO } from "../src/platform/node"; import { NodeWorkerAdapter } from "../src/worker-adapter"; import { detectPtrWidth, extractHeapBase } from "../src/constants"; import { tryResolveBinary } from "../src/binary-resolver"; +import { + ForkReplayGateCoordinator, + observeForkReplayWorker, +} from "../src/fork-replay-gate"; import { GlMuxer } from "../src/webgl/muxer"; import type { GlBinding } from "../src/webgl/registry"; import type { CentralizedWorkerInitMessage, WorkerToHostMessage, } from "../src/worker-protocol"; +import { TestProcessReferenceOwners } from "./process-reference-owner-helper"; const programBinary = tryResolveBinary("programs/cube_pyramid.wasm") ?? ""; const kernelBinary = tryResolveBinary("kernel.wasm") ?? ""; @@ -39,7 +44,10 @@ function createProcessMemory(pages: number): WebAssembly.Memory { /** Proxy WebGL2 context: every call lands in `log`; `create*` returns * a fresh object so the bridge's per-name maps stay distinct. */ -function makeFakeGl(): { log: Array<[string, unknown[]]>; gl: WebGL2RenderingContext } { +function makeFakeGl(): { + log: Array<[string, unknown[]]>; + gl: WebGL2RenderingContext; +} { const log: Array<[string, unknown[]]> = []; const handler: ProxyHandler = { get(_t, prop) { @@ -52,7 +60,10 @@ function makeFakeGl(): { log: Array<[string, unknown[]]>; gl: WebGL2RenderingCon }; }, }; - return { log, gl: new Proxy({}, handler) as unknown as WebGL2RenderingContext }; + return { + log, + gl: new Proxy({}, handler) as unknown as WebGL2RenderingContext, + }; } function makeFakeCanvas(gl: WebGL2RenderingContext) { @@ -84,7 +95,11 @@ describe.skipIf(!existsSync(programBinary) || !existsSync(kernelBinary))( const io = new NodePlatformIO(); const workerAdapter = new NodeWorkerAdapter(); - const workers = new Map>(); + const referenceOwners = new TestProcessReferenceOwners(); + const workers = new Map< + number, + ReturnType + >(); let parentPid = 0; let stdout = ""; @@ -127,17 +142,38 @@ describe.skipIf(!existsSync(programBinary) || !existsSync(kernelBinary))( new Uint8Array(childMemory.buffer).set(parentBuf); const childChannelOffset = (MAX_PAGES - 2) * 65536; - new Uint8Array(childMemory.buffer, childChannelOffset, CH_TOTAL_SIZE).fill(0); + new Uint8Array( + childMemory.buffer, + childChannelOffset, + CH_TOTAL_SIZE, + ).fill(0); - kernel.registerProcess(childPid, childMemory, [childChannelOffset], { - ptrWidth, - }); + kernel.registerProcess( + childPid, + childMemory, + [childChannelOffset], + { + ptrWidth, + }, + ); kernel.inheritProcessSharedMappings(parentForkPid, childPid); // Same canvas → same WebGL2 context → same muxer instance // (gl_muxers is a WeakMap keyed by context). kernel.gl.attachCanvas(childPid, fakeCanvas); + const forkBufAddr = continuation.forkBufAddr; + const childReferenceInit = referenceOwners.fork( + parentForkPid, + childPid, + parentMemory, + ptrWidth, + forkBufAddr, + ); + const forkReplay = new ForkReplayGateCoordinator( + `DRI cube fork child pid=${childPid}`, + ); + const childInit: CentralizedWorkerInitMessage = { type: "centralized_init", pid: childPid, @@ -145,26 +181,61 @@ describe.skipIf(!existsSync(programBinary) || !existsSync(kernelBinary))( memory: childMemory, channelOffset: childChannelOffset, isForkChild: true, - forkBufAddr: continuation.forkBufAddr, + forkBufAddr, + forkReplayGate: forkReplay.gate, ptrWidth, + ...childReferenceInit, }; const childWorker = workerAdapter.createWorker(childInit); + referenceOwners.attach(childPid, childWorker); workers.set(childPid, childWorker); childWorker.on("error", () => { + referenceOwners.release(childPid); kernel.unregisterProcess(childPid); workers.delete(childPid); }); childWorker.on("message", (m: unknown) => { const msg = m as WorkerToHostMessage; if (msg.type !== "error") return; + referenceOwners.release(childPid); kernel.unregisterProcess(childPid); workers.delete(childPid); }); + observeForkReplayWorker( + forkReplay, + childWorker, + childPid, + () => workers.get(childPid) === childWorker, + ); - return [childChannelOffset]; + try { + await forkReplay.waitUntilReady(); + if (workers.get(childPid) !== childWorker) { + throw new Error( + `fork child ${childPid} changed generation before commit`, + ); + } + if (!kernel.shouldLaunchPendingChild(childPid)) { + throw new Error( + `fork child ${childPid} exited before replay commit`, + ); + } + forkReplay.commit(); + return [childChannelOffset]; + } catch (error) { + forkReplay.cancel(error); + if (workers.get(childPid) === childWorker) { + workers.delete(childPid); + referenceOwners.release(childPid); + kernel.unregisterProcess(childPid); + } + childWorker.terminate().catch(() => {}); + throw error; + } }, onExit: (exitPid, exitStatus) => { + referenceOwners.release(exitPid); const w = workers.get(exitPid); if (w) { w.terminate().catch(() => {}); @@ -200,6 +271,7 @@ describe.skipIf(!existsSync(programBinary) || !existsSync(kernelBinary))( kernel.registerProcess(parentPid, memory, [channelOffset], { ptrWidth }); const heapBase = extractHeapBase(programBytes); if (heapBase !== null) kernel.setBrkBase(parentPid, heapBase); + const parentReferenceInit = referenceOwners.start(parentPid); // Attach before the worker starts so eglInitialize → host_gl_bind // sees the canvas. @@ -214,14 +286,20 @@ describe.skipIf(!existsSync(programBinary) || !existsSync(kernelBinary))( argv: ["cube_pyramid", "200"], env: [], ptrWidth, + ...parentReferenceInit, }; const mainWorker = workerAdapter.createWorker(initData); + referenceOwners.attach(parentPid, mainWorker); workers.set(parentPid, mainWorker); const timer = setTimeout(() => { for (const [, w] of workers) w.terminate().catch(() => {}); - rejectExit(new Error(`cube_pyramid timed out. stdout=${stdout} stderr=${stderr}`)); + rejectExit( + new Error( + `cube_pyramid timed out. stdout=${stdout} stderr=${stderr}`, + ), + ); }, 30_000); mainWorker.on("error", (err: Error) => { clearTimeout(timer); @@ -240,17 +318,26 @@ describe.skipIf(!existsSync(programBinary) || !existsSync(kernelBinary))( exitCode = await exitPromise; } finally { clearTimeout(timer); + for (const [, worker] of workers) { + await worker.terminate().catch(() => {}); + } + referenceOwners.close(); } expect(exitCode, `stdout=${stdout}\nstderr=${stderr}`).toBe(0); - expect(stdout).toMatch(/cube_pyramid: parent pid=\d+ rc=0, child pid=\d+ rc=0/); + expect(stdout).toMatch( + /cube_pyramid: parent pid=\d+ rc=0, child pid=\d+ rc=0/, + ); const switchedKeys = new Set(); for (const call of switchSpy.mock.calls) { const b = call[0] as Pick; switchedKeys.add(`${b.pid}/${b.contextId ?? "-"}`); } - expect(switchedKeys.size, `switched keys = ${[...switchedKeys].join(",")}`).toBeGreaterThanOrEqual(2); + expect( + switchedKeys.size, + `switched keys = ${[...switchedKeys].join(",")}`, + ).toBeGreaterThanOrEqual(2); }, 60_000); }, ); diff --git a/host/test/dri-smoke.test.ts b/host/test/dri-smoke.test.ts index 2dc8eda021..8f502240d8 100644 --- a/host/test/dri-smoke.test.ts +++ b/host/test/dri-smoke.test.ts @@ -27,7 +27,11 @@ import { NodePlatformIO } from "../src/platform/node"; import { NodeWorkerAdapter } from "../src/worker-adapter"; import { detectPtrWidth } from "../src/constants"; import { tryResolveBinary } from "../src/binary-resolver"; -import type { CentralizedWorkerInitMessage } from "../src/worker-protocol"; +import type { + CentralizedWorkerInitMessage, + WorkerToHostMessage, +} from "../src/worker-protocol"; +import { TestProcessReferenceOwners } from "./process-reference-owner-helper"; const driSmokeBinary = tryResolveBinary("programs/dri-smoke.wasm") ?? ""; const kernelBinary = tryResolveBinary("kernel.wasm") ?? ""; @@ -57,7 +61,11 @@ describe.skipIf(!existsSync(driSmokeBinary))("dri-smoke integration", () => { const io = new NodePlatformIO(); const workerAdapter = new NodeWorkerAdapter(); - const workers = new Map>(); + const referenceOwners = new TestProcessReferenceOwners(); + const workers = new Map< + number, + ReturnType + >(); let pid = 0; @@ -65,8 +73,10 @@ describe.skipIf(!existsSync(driSmokeBinary))("dri-smoke integration", () => { let stderr = ""; let stdoutResolved = false; let resolveOk: () => void; - const okPromise = new Promise((resolve) => { + let rejectOk: (reason: Error) => void; + const okPromise = new Promise((resolve, reject) => { resolveOk = resolve; + rejectOk = reject; }); let resolveExit: (status: number) => void; const exitPromise = new Promise((resolve) => { @@ -74,11 +84,17 @@ describe.skipIf(!existsSync(driSmokeBinary))("dri-smoke integration", () => { }); const kernel = new CentralizedKernelWorker( - { maxWorkers: 4, dataBufferSize: 65536, useSharedMemory: true, enableSyscallLog: false }, + { + maxWorkers: 4, + dataBufferSize: 65536, + useSharedMemory: true, + enableSyscallLog: false, + }, io, { onExit: (exitPid, exitStatus) => { if (exitPid === pid) { + referenceOwners.release(exitPid); kernel.unregisterProcess(exitPid); const w = workers.get(exitPid); if (w) { @@ -113,6 +129,7 @@ describe.skipIf(!existsSync(driSmokeBinary))("dri-smoke integration", () => { new Uint8Array(memory.buffer, channelOffset, CH_TOTAL_SIZE).fill(0); kernel.registerProcess(pid, memory, [channelOffset], { ptrWidth }); + const referenceInit = referenceOwners.start(pid); const initData: CentralizedWorkerInitMessage = { type: "centralized_init", @@ -123,16 +140,33 @@ describe.skipIf(!existsSync(driSmokeBinary))("dri-smoke integration", () => { argv: ["dri-smoke"], env: [], ptrWidth, + ...referenceInit, }; const mainWorker = workerAdapter.createWorker(initData); + referenceOwners.attach(pid, mainWorker); + mainWorker.on("error", rejectOk); + mainWorker.on("message", (raw: unknown) => { + const message = raw as WorkerToHostMessage; + if (message.type === "error" && message.pid === pid) { + rejectOk(new Error(message.message)); + } + }); workers.set(pid, mainWorker); try { await Promise.race([ okPromise, new Promise((_, reject) => - setTimeout(() => reject(new Error(`dri-smoke didn't print 'ok' in 10s. stdout=${stdout!} stderr=${stderr!}`)), 10_000), + setTimeout( + () => + reject( + new Error( + `dri-smoke didn't print 'ok' in 10s. stdout=${stdout!} stderr=${stderr!}`, + ), + ), + 10_000, + ), ), ]); @@ -154,11 +188,15 @@ describe.skipIf(!existsSync(driSmokeBinary))("dri-smoke integration", () => { // Read pixel pattern from the bound region of the process Memory SAB. const procMem = kernel.getProcessMemory(pid); expect(procMem).toBeDefined(); - const view = new DataView(procMem!.buffer, bo.binding!.addr, bo.binding!.len); + const view = new DataView( + procMem!.buffer, + bo.binding!.addr, + bo.binding!.len, + ); const sample = (r: number, c: number) => view.getUint32((r * bo.w + c) * 4, /*littleEndian*/ true); const expected = (r: number, c: number) => - ((0xff000000 | (r << 16) | c) >>> 0); + (0xff000000 | (r << 16) | c) >>> 0; expect(sample(0, 0)).toBe(expected(0, 0)); expect(sample(10, 20)).toBe(expected(10, 20)); expect(sample(255, 255)).toBe(expected(255, 255)); @@ -168,6 +206,7 @@ describe.skipIf(!existsSync(driSmokeBinary))("dri-smoke integration", () => { if (mainW) { await mainW.terminate().catch(() => {}); } + referenceOwners.close(); await Promise.race([ exitPromise, new Promise((resolve) => setTimeout(() => resolve(0), 1_000)), diff --git a/host/test/dylink-fork-archive.test.ts b/host/test/dylink-fork-archive.test.ts new file mode 100644 index 0000000000..b824c7aa44 --- /dev/null +++ b/host/test/dylink-fork-archive.test.ts @@ -0,0 +1,492 @@ +import { describe, expect, it } from "vitest"; +import type { DylinkForkState } from "../src/dylink"; +import { + DylinkForkArchive, + DylinkForkTableReplica, +} from "../src/dylink-fork-archive"; + +function fixture(ptrWidth: 4 | 8 = 4) { + const memory = new WebAssembly.Memory({ initial: 4, maximum: 4 }); + let head = 0; + let next = 4096; + const allocations = new Map(); + const deallocated: Array<{ address: number; size: number }> = []; + const archive = () => new DylinkForkArchive( + memory, + ptrWidth, + () => head, + (value) => { head = value; }, + (size) => { + const address = next; + next += Math.ceil(size / 8) * 8; + allocations.set(address, size); + return { address, size }; + }, + ({ address, size }) => { + expect(allocations.get(address)).toBe(size); + allocations.delete(address); + deallocated.push({ address, size }); + }, + "test dylink archive", + ); + return { + memory, + archive, + allocations, + deallocated, + get head() { + return head; + }, + }; +} + +function state(): DylinkForkState { + return { + nextHandle: 4, + libraries: [ + { + name: "libdependency.so", + moduleBytes: new Uint8Array([0, 97, 115, 109, 1]), + memoryBase: 8192, + tableBase: 3, + activationId: 7, + globalVisibility: true, + allocations: [{ + address: 8192, + size: 64, + mappingAddress: 8176, + mappingSize: 95, + }], + }, + { + name: "libconsumer.so", + moduleBytes: new Uint8Array([0, 97, 115, 109, 2]), + memoryBase: 12288, + tableBase: 9, + activationId: 8, + tlsBase: 16384, + globalVisibility: true, + committedGlobalRoot: true, + allocations: [{ + address: 12288, + size: 128, + mappingAddress: 12272, + mappingSize: 159, + }], + handle: 3, + refCount: 2, + }, + ], + }; +} + +describe("compact dylink fork archive", () => { + it("round-trips dependency-first live state and updates records in place", () => { + const f = fixture(); + const parent = f.archive(); + expect(parent.generation()).toBe(0); + const first = parent.sync(state()); + expect(first.generation).toBe(1); + const allocationCount = f.allocations.size; + expect(parent.read()).toEqual({ + generation: 1, + tableStateRoot: 0, + tableCheckpointGeneration: 0, + tablePatches: [], + ...state(), + }); + + const updated = state(); + updated.libraries[1] = { + ...updated.libraries[1]!, + refCount: 3, + }; + expect(parent.sync(updated).generation).toBe(2); + expect(f.allocations.size).toBe(allocationCount); + expect(parent.read()).toEqual({ + generation: 2, + tableStateRoot: 0, + tableCheckpointGeneration: 0, + tablePatches: [], + ...updated, + }); + + // A separately constructed worker owns no JS cache and must validate the + // complete copied archive before returning any module bytes. + const replica = f.archive(); + expect(replica.generation()).toBe(2); + expect(replica.read()).toEqual({ + generation: 2, + tableStateRoot: 0, + tableCheckpointGeneration: 0, + tablePatches: [], + ...updated, + }); + + const closed: DylinkForkState = { + nextHandle: 4, + libraries: [updated.libraries[0]!], + }; + parent.sync(closed); + expect(parent.read()).toEqual({ + generation: 3, + tableStateRoot: 0, + tableCheckpointGeneration: 0, + tablePatches: [], + ...closed, + }); + // The same replica must invalidate its JavaScript index after another + // Worker publishes a generation. + expect(replica.read()).toEqual({ + generation: 3, + tableStateRoot: 0, + tableCheckpointGeneration: 0, + tablePatches: [], + ...closed, + }); + expect(f.deallocated).toHaveLength(1); + expect(f.allocations.size).toBe(2); // persistent header + dependency + }); + + it("round-trips an issued initialization stage and retires its transaction", () => { + const f = fixture(); + const archive = f.archive(); + const moduleBytes = new Uint8Array([0, 97, 115, 109, 43]); + const pending: DylinkForkState = { + nextHandle: 2, + libraries: [{ + name: "libinitializing.so", + moduleBytes, + memoryBase: 8192, + tableBase: 3, + activationId: 7, + globalVisibility: false, + initialization: { + transactionToken: 11, + stage: "bootstrap", + tableIndex: 19, + }, + }], + transactions: [{ + token: 11, + name: "libinitializing.so", + moduleBytes, + globalVisibility: false, + }], + }; + + archive.sync(pending); + expect(f.archive().read()).toMatchObject({ + generation: 1, + ...pending, + }); + const allocationCount = f.allocations.size; + + const relocated: DylinkForkState = { + ...pending, + libraries: [{ + ...pending.libraries[0]!, + tlsBase: 12288, + initialization: { + transactionToken: 11, + stage: "constructors", + tableIndex: 19, + }, + }], + }; + archive.sync(relocated); + expect(f.allocations.size).toBe(allocationCount); + expect(f.archive().read()).toMatchObject({ + generation: 2, + ...relocated, + }); + + archive.sync({ + nextHandle: 3, + libraries: [{ + ...relocated.libraries[0]!, + initialization: undefined, + handle: 2, + refCount: 1, + }], + }); + expect(f.archive().read()).toMatchObject({ + generation: 3, + nextHandle: 3, + libraries: [{ + name: "libinitializing.so", + handle: 2, + refCount: 1, + }], + }); + expect(f.deallocated).toHaveLength(1); + }); + + it("replaces a live record when constructor binding ownership grows", () => { + const f = fixture(); + const archive = f.archive(); + const initial = state(); + archive.sync(initial); + const allocationCount = f.allocations.size; + + const withRuntimeProvider: DylinkForkState = { + ...initial, + libraries: [ + initial.libraries[0]!, + { + ...initial.libraries[1]!, + providerDependencies: ["libdependency.so"], + }, + ], + }; + archive.sync(withRuntimeProvider); + expect(f.allocations.size).toBe(allocationCount); + expect(f.deallocated).toHaveLength(1); + expect(f.archive().read().libraries[1]).toMatchObject({ + providerDependencies: ["libdependency.so"], + }); + }); + + it("retains an empty header so closed handle gaps survive another fork", () => { + const f = fixture(8); + const archive = f.archive(); + archive.sync({ nextHandle: 19, libraries: [] }); + + expect(f.head).toBeGreaterThan(0); + expect(f.archive().read()).toEqual({ + generation: 1, + tableStateRoot: 0, + tableCheckpointGeneration: 0, + tablePatches: [], + nextHandle: 19, + libraries: [], + }); + }); + + it("publishes generation last and advances it without reallocating records", () => { + const f = fixture(); + const archive = f.archive(); + const initial = archive.sync(state()); + const addresses = [...f.allocations.keys()]; + expect(initial.generation).toBe(1); + expect(archive.generation()).toBe(1); + + const next = archive.sync(state()); + expect(next.generation).toBe(2); + expect(archive.generation()).toBe(2); + expect([...f.allocations.keys()]).toEqual(addresses); + + const view = new DataView(f.memory.buffer); + view.setBigUint64(f.head + 40, 0n, true); + expect(() => f.archive().read()).toThrow(/unpublished/); + }); + + it("publishes one sealed table root and preserves it across linker updates", () => { + const f = fixture(); + const writer = f.archive(); + const reader = f.archive(); + writer.sync(state()); + + const first = writer.publishTableState(2048); + expect(first.previousTableStateRoot).toBe(0); + expect(first.snapshot).toMatchObject({ + generation: 2, + tableStateRoot: 2048, + tableCheckpointGeneration: 2, + tablePatches: [], + }); + expect(reader.read()).toMatchObject({ + generation: 2, + tableStateRoot: 2048, + }); + + const updated = state(); + updated.libraries[1] = { + ...updated.libraries[1]!, + refCount: 4, + }; + expect(writer.sync(updated)).toMatchObject({ + generation: 3, + tableStateRoot: 2048, + tableCheckpointGeneration: 2, + }); + const replacement = writer.publishTableState(3072); + expect(replacement.previousTableStateRoot).toBe(2048); + expect(reader.read()).toMatchObject({ + generation: 4, + tableStateRoot: 3072, + tableCheckpointGeneration: 4, + tablePatches: [], + }); + }); + + it("round-trips bounded stable funcref patches after a checkpoint", () => { + const f = fixture(); + const writer = f.archive(); + const reader = f.archive(); + writer.sync(state()); + writer.publishTableState(2048); + + const patch = { + activationId: 7, + ownerId: 3, + start: 5, + tableLength: 12, + runs: [ + { length: 2, function: null }, + { + length: 3, + function: { activationId: 8, ordinal: 4 }, + }, + ], + } as const; + expect(writer.canPublishTablePatch(patch)).toBe(true); + const publication = writer.publishTablePatch(patch); + expect(publication.snapshot).toMatchObject({ + generation: 3, + tableStateRoot: 2048, + tableCheckpointGeneration: 2, + tablePatches: [{ ...patch, generation: 3 }], + }); + expect(reader.read()).toMatchObject(publication.snapshot); + + // A linker-only generation remains ordered after the patch without + // duplicating or discarding its deterministic replay recipe. + const linked = writer.sync(state()); + expect(linked.generation).toBe(4); + expect(linked.tablePatches).toEqual([{ ...patch, generation: 3 }]); + + const allocationsBeforeCheckpoint = f.allocations.size; + const replacement = writer.publishTableState(3072); + expect(replacement.snapshot).toMatchObject({ + generation: 5, + tableStateRoot: 3072, + tableCheckpointGeneration: 5, + tablePatches: [], + }); + expect(f.allocations.size).toBe(allocationsBeforeCheckpoint - 1); + }); + + it("bounds the patch journal and requires checkpoint compaction", () => { + const f = fixture(); + const writer = f.archive(); + writer.sync({ nextHandle: 2, libraries: [] }); + const patch = { + activationId: 0, + ownerId: 1, + start: 0, + tableLength: 1, + runs: [{ length: 1, function: null }], + } as const; + + for (let index = 0; index < 256; index++) { + expect(writer.canPublishTablePatch(patch)).toBe(true); + writer.publishTablePatch(patch); + } + expect(writer.canPublishTablePatch(patch)).toBe(false); + expect(() => writer.publishTablePatch(patch)).toThrow( + /requires compaction/, + ); + }); + + it("keeps the steady-state Worker table path to one generation read", () => { + const f = fixture(); + const writer = f.archive(); + const reader = f.archive(); + const materialized: Array<[number, number]> = []; + const replica = new DylinkForkTableReplica( + reader, + (snapshot, previousGeneration) => { + materialized.push([snapshot.generation, previousGeneration]); + }, + "pthread table replica", + ); + + expect(replica.reconcile()).toBe(false); + writer.sync(state()); + expect(replica.reconcile()).toBe(true); + expect(replica.generation()).toBe(1); + expect(replica.reconcile()).toBe(false); + + const updated = state(); + updated.libraries[1] = { + ...updated.libraries[1]!, + refCount: 4, + }; + writer.sync(updated); + expect(replica.reconcile()).toBe(true); + expect(replica.reconcile()).toBe(false); + expect(materialized).toEqual([[1, 0], [2, 1]]); + }); + + it("rejects hash corruption and record cycles before exposing bytes", () => { + const hashFixture = fixture(); + hashFixture.archive().sync(state()); + const hashView = new DataView(hashFixture.memory.buffer); + const first = Number(hashView.getBigUint64(hashFixture.head + 32, true)); + const nameLength = hashView.getUint32(first + 60, true); + const moduleOffset = first + 136 + Math.ceil(nameLength / 8) * 8; + new Uint8Array(hashFixture.memory.buffer)[moduleOffset] ^= 0xff; + expect(() => hashFixture.archive().read()).toThrow(/SHA-256 validation/); + + const cycleFixture = fixture(); + cycleFixture.archive().sync(state()); + const cycleView = new DataView(cycleFixture.memory.buffer); + const cycleFirst = Number(cycleView.getBigUint64(cycleFixture.head + 32, true)); + cycleView.setBigUint64(cycleFirst + 8, BigInt(cycleFirst), true); + expect(() => cycleFixture.archive().read()).toThrow(/cyclic or truncated/); + + const countFixture = fixture(); + countFixture.archive().sync({ nextHandle: 2, libraries: [] }); + new DataView(countFixture.memory.buffer).setUint32( + countFixture.head + 24, + 0xffff_ffff, + true, + ); + expect(() => countFixture.archive().read()).toThrow(/memory geometry/); + }); + + it("rejects duplicate identities, impossible handles, and immutable drift", () => { + const f = fixture(); + const archive = f.archive(); + const valid = state(); + expect(() => archive.sync({ + ...valid, + libraries: [ + valid.libraries[0]!, + { ...valid.libraries[0]! }, + ], + })).toThrow(/duplicate live module/); + expect(() => archive.sync({ + nextHandle: 3, + libraries: [valid.libraries[1]!], + })).toThrow(/handle 3 is out of range/); + + archive.sync(valid); + expect(() => archive.sync({ + ...valid, + libraries: [ + { + ...valid.libraries[0]!, + memoryBase: valid.libraries[0]!.memoryBase + 1, + }, + valid.libraries[1]!, + ], + })).toThrow(/changed immutable archive identity/); + }); + + it("binds the copied archive to its pointer-width contract", () => { + const f = fixture(4); + f.archive().sync(state()); + const wrongWidth = new DylinkForkArchive( + f.memory, + 8, + () => f.head, + () => { throw new Error("must not publish"); }, + () => { throw new Error("must not allocate"); }, + () => { throw new Error("must not deallocate"); }, + "wrong width", + ); + expect(() => wrongWidth.read()).toThrow(/pointer-width mismatch/); + }); +}); diff --git a/host/test/dylink.test.ts b/host/test/dylink.test.ts index 9ce05ccb13..7e16d497bd 100644 --- a/host/test/dylink.test.ts +++ b/host/test/dylink.test.ts @@ -18,15 +18,26 @@ import { forkInstrumentRoleAvailable, readForkInstrumentCapabilityClaim, readForkInstrumentCapabilities, + SIDE_MODULE_FORK_EXPORTS, + type DylinkForkActivationOwner, + type DylinkForkActivationRequest, type LoadSharedLibraryOptions, - type SideModuleForkState, } from "../src/dylink.ts"; import { execFileSync } from "node:child_process"; import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; import { LINKED_FRAME_FORMAT_SECTION } from "../src/fork-continuation"; import { ABI_VERSION } from "../src/generated/abi"; +import { ForkAnyrefTransitTable } from "../src/fork-anyref-transit"; +import { + createForkUnwindTag, + FORK_UNWIND_TAG_IMPORT_NAME, +} from "../src/fork-unwind-transport"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const forkInstrument = join(repoRoot, "scripts", "run-wasm-fork-instrument.sh"); function hasCompiler(compiler = "wasm32posix-cc"): boolean { try { @@ -82,6 +93,7 @@ function buildDylinkWat( wat2wasmFlags: string[] = [], tlsExports: string[] = [], abiVersion: number | null = ABI_VERSION, + neededDynlibs: string[] = [], ): Uint8Array { const dir = join(tmpdir(), "wasm-dylink-wat-test"); mkdirSync(dir, { recursive: true }); @@ -117,11 +129,25 @@ function buildDylinkWat( const exportInfo = tlsExports.length > 0 ? [3, 1 + exportInfoBody.length, tlsExports.length, ...exportInfoBody] : []; - const payload = new Uint8Array(1 + dylinkName.length + 6 + exportInfo.length); + const neededBody = neededDynlibs.flatMap((libraryName) => { + const bytes = [...new TextEncoder().encode(libraryName)]; + if (bytes.length >= 128) throw new Error("test dependency name is too long"); + return [bytes.length, ...bytes]; + }); + const neededInfo = neededDynlibs.length > 0 + ? [2, 1 + neededBody.length, neededDynlibs.length, ...neededBody] + : []; + const payload = new Uint8Array( + 1 + dylinkName.length + 6 + neededInfo.length + exportInfo.length, + ); + if (payload.length >= 128) { + throw new Error("test dylink section helper only supports one-byte LEB lengths"); + } payload[0] = dylinkName.length; payload.set(dylinkName, 1); payload.set([1, 4, memorySize, 0, tableSize, 0], 1 + dylinkName.length); - payload.set(exportInfo, 1 + dylinkName.length + 6); + payload.set(neededInfo, 1 + dylinkName.length + 6); + payload.set(exportInfo, 1 + dylinkName.length + 6 + neededInfo.length); const section = new Uint8Array(2 + payload.length); section[0] = 0; section[1] = payload.length; @@ -155,6 +181,155 @@ function buildDylinkWat( return marked; } +/** Build a real ABI-43 side artifact instead of hand-maintaining its contract. */ +function buildInstrumentedDylinkWat( + wat: string, + name: string, + neededDynlibs: string[] = [], +): Uint8Array { + const dir = join(tmpdir(), "wasm-dylink-instrumented-test"); + mkdirSync(dir, { recursive: true }); + const inputPath = join(dir, `${name}.input.wasm`); + const outputPath = join(dir, `${name}.instrumented.wasm`); + const moduleEnd = wat.lastIndexOf(")"); + if (moduleEnd < 0) throw new Error("instrumented test WAT has no module terminator"); + const versionedWat = `${wat.slice(0, moduleEnd)} + (func (export "__abi_version") (result i32) i32.const ${ABI_VERSION}) + ${wat.slice(moduleEnd)}`; + writeFileSync( + inputPath, + buildDylinkWat( + versionedWat, + `${name}-raw`, + undefined, + 0, + 0, + [], + [], + null, + neededDynlibs, + ), + ); + execFileSync( + "bash", + [forkInstrument, inputPath, "-o", outputPath, "--entry", "env.fork"], + { cwd: repoRoot, stdio: "pipe" }, + ); + return new Uint8Array(readFileSync(outputPath)); +} + +interface TestForkActivationOwner { + readonly owner: DylinkForkActivationOwner; + readonly prepares: DylinkForkActivationRequest[]; + readonly registered: Array<{ activationId: number; instance: WebAssembly.Instance }>; + readonly unregistered: number[]; + readonly active: ReadonlySet; + readonly forkImport: () => number; + readonly wrappedImports: WebAssembly.Imports[]; +} + +/** + * Minimal process owner for loader contract tests. + * + * The real worker binds state arenas and typed codecs. These fixtures never + * execute a continuation, so inert functions are sufficient; tables and the + * private exception tag still use their exact WebAssembly types. + */ +function createTestForkActivationOwner( + firstActivationId = 1, +): TestForkActivationOwner { + const prepares: DylinkForkActivationRequest[] = []; + const registered: Array<{ + activationId: number; + instance: WebAssembly.Instance; + }> = []; + const unregistered: number[] = []; + const wrappedImports: WebAssembly.Imports[] = []; + const active = new Set(); + const gcTransit = new ForkAnyrefTransitTable(); + const resumeTable = new WebAssembly.Table({ initial: 1, element: "anyfunc" }); + const unwindTag = createForkUnwindTag(); + const forkImport = () => -12; + let nextActivationId = firstActivationId; + + const owner: DylinkForkActivationOwner = { + prepare(request) { + const activationId = request.replayActivationId ?? nextActivationId++; + prepares.push(request); + if (active.has(activationId)) { + throw new Error(`duplicate test activation id ${activationId}`); + } + active.add(activationId); + + const env: Record = {}; + for (const imported of WebAssembly.Module.imports(request.module)) { + if (imported.module !== "env") continue; + if (imported.name === "fork") { + env[imported.name] = forkImport; + } else if (imported.name === "__wpk_fork_ref_gc_transit") { + env[imported.name] = gcTransit.table; + } else if (imported.name === "__wpk_fork_resume_table") { + env[imported.name] = resumeTable; + } else if ( + imported.name === FORK_UNWIND_TAG_IMPORT_NAME + && (imported.kind as string) === "tag" + ) { + env[imported.name] = + unwindTag as unknown as WebAssembly.ImportValue; + } else if ( + imported.name.startsWith("__wpk_fork_") + && imported.kind === "function" + ) { + env[imported.name] = () => 0; + } else if ( + imported.name.startsWith("__wpk_fork_") + && imported.kind === "global" + ) { + env[imported.name] = + imported.name === "__wpk_fork_module_state_table_generation_addr" + ? new WebAssembly.Global( + { value: "i64", mutable: false }, + 0n, + ) + : new WebAssembly.Global( + { value: "i32", mutable: false }, + activationId, + ); + } + } + + let released = false; + return { + activationId, + env, + wrapImports(imports) { + wrappedImports.push(imports); + return imports; + }, + register(instance) { + registered.push({ activationId, instance }); + }, + unregister() { + if (released) throw new Error(`test activation ${activationId} released twice`); + released = true; + active.delete(activationId); + unregistered.push(activationId); + }, + }; + }, + }; + + return { + owner, + prepares, + registered, + unregistered, + active, + forkImport, + wrappedImports, + }; +} + describe.skipIf(typeof WebAssembly.Tag !== "function")("longjmp tag identity", () => { const cases = [ { ptrWidth: 4 as const, wasmType: "i32", value: 37 }, @@ -649,80 +824,567 @@ describe.skipIf(!hasCompiler())("synchronous loading (loadSharedLibrarySync)", ( }); function createSideForkLoadOptions(): LoadSharedLibraryOptions { - const memory = new WebAssembly.Memory({ initial: 1, maximum: 100, shared: true }); - let nextContinuation = 65536; return { - memory, + memory: new WebAssembly.Memory({ initial: 1, maximum: 100, shared: true }), table: new WebAssembly.Table({ initial: 1, element: "anyfunc" }), stackPointer: new WebAssembly.Global({ value: "i32", mutable: true }, 65536), heapPointer: { value: 1024 }, - allocateContinuation: (size) => { - const addr = nextContinuation; - nextContinuation += size; - const requiredPages = Math.ceil(nextContinuation / 65536); - const currentPages = memory.buffer.byteLength / 65536; - if (requiredPages > currentPages) memory.grow(requiredPages - currentPages); - return addr; - }, - deallocateContinuation: () => {}, globalSymbols: new Map(), got: new Map(), loadedLibraries: new Map(), }; } +describe("DynamicLinker deterministic replay events", () => { + it("loads dependencies without handles and replays exact open/close state", () => { + const dependencyBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (func (export "dependency_value") (result i32) i32.const 5)) + `, "replay-event-dependency"); + const consumerBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "dependency_value" (func $dependency_value (result i32))) + (func (export "consumer_value") (result i32) call $dependency_value)) + `, "replay-event-consumer", undefined, 0, 0, [], [], null, [ + "libevent-dependency.so", + ]); + const options = createSideForkLoadOptions(); + options.resolveLibrarySync = (name) => + name === "libevent-dependency.so" ? dependencyBytes : null; + const linker = new DynamicLinker(options); + + const consumer = linker.loadModuleSync( + "libevent-consumer.so", + consumerBytes, + ); + expect(Array.from(options.loadedLibraries.keys())).toEqual([ + "libevent-dependency.so", + "libevent-consumer.so", + ]); + const dependency = options.loadedLibraries.get("libevent-dependency.so")!; + expect(dependency.moduleBytes).toEqual(dependencyBytes); + expect(dependency.moduleBytes).not.toBe(dependencyBytes); + expect(consumer.moduleBytes).toEqual(consumerBytes); + expect(linker.forkState()).toMatchObject({ + nextHandle: 2, + libraries: [ + { name: "libevent-dependency.so" }, + { name: "libevent-consumer.so" }, + ], + }); + expect(linker.forkLibraryState("libevent-dependency.so")).not.toHaveProperty("handle"); + expect(linker.forkLibraryState("libevent-consumer.so")).not.toHaveProperty("handle"); + expect((consumer.exports.consumer_value as () => number)()).toBe(5); + expect(linker.dlsym(2, "consumer_value")).toBeNull(); + expect(linker.dlerror()).toContain("invalid handle"); + + expect(() => linker.replayOpen("libevent-consumer.so", 3)) + .toThrow(/does not match next handle 2/); + expect(linker.replayOpen("libevent-consumer.so", 2)).toBe(2); + expect(linker.replayOpen("libevent-consumer.so", 2)).toBe(2); + expect(linker.forkLibraryState("libevent-consumer.so")).toMatchObject({ + handle: 2, + refCount: 2, + }); + expect(linker.forkState().nextHandle).toBe(3); + expect(() => linker.replayOpen("libevent-dependency.so", 2)) + .toThrow(/does not match next handle 3/); + expect(linker.replayOpen("libevent-dependency.so", 3)).toBe(3); + + linker.replayClose(2); + expect(linker.forkLibraryState("libevent-consumer.so")).toMatchObject({ + handle: 2, + refCount: 1, + }); + expect(options.loadedLibraries.has("libevent-consumer.so")).toBe(true); + linker.replayClose(2); + expect(options.loadedLibraries.has("libevent-consumer.so")).toBe(false); + expect(options.loadedLibraries.has("libevent-dependency.so")).toBe(true); + expect(() => linker.replayClose(2)).toThrow(/invalid dlopen handle 2/); + + expect(linker.forkState()).toMatchObject({ + nextHandle: 4, + libraries: [ + { + name: "libevent-dependency.so", + handle: 3, + refCount: 1, + }, + ], + }); + }); + + it("retains NEEDED providers until both dependency and handle owners release", () => { + const dependencyBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (func (export "dependency_value") (result i32) i32.const 5)) + `, "dependency-retain-provider"); + const consumerBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "dependency_value" (func $dependency_value (result i32))) + (func (export "consumer_value") (result i32) call $dependency_value)) + `, "dependency-retain-consumer", undefined, 0, 0, [], [], null, [ + "libretain-provider.so", + ]); + const options = createSideForkLoadOptions(); + options.resolveLibrarySync = (name) => + name === "libretain-provider.so" ? dependencyBytes : null; + const linker = new DynamicLinker(options); + + const consumerHandle = linker.dlopenSync("libretain-consumer.so", consumerBytes); + const providerHandle = linker.dlopenSync("libretain-provider.so", dependencyBytes); + expect(consumerHandle).toBe(2); + expect(providerHandle).toBe(3); + + expect(linker.dlclose(providerHandle)).toBe(0); + expect(options.loadedLibraries.has("libretain-provider.so")).toBe(true); + expect(linker.forkLibraryState("libretain-provider.so")).not.toHaveProperty("handle"); + expect(linker.dlclose(consumerHandle)).toBe(0); + expect(options.loadedLibraries.size).toBe(0); + expect(linker.forkState()).toMatchObject({ + nextHandle: 4, + libraries: [], + }); + }); + + it("retains a side-module provider captured by a direct relocation", () => { + const providerBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (func (export "runtime_provider") (result i32) i32.const 41)) + `, "runtime-provider-retain-provider"); + const consumerBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "runtime_provider" + (func $runtime_provider (result i32))) + (func (export "runtime_consumer") (result i32) + call $runtime_provider + i32.const 1 + i32.add)) + `, "runtime-provider-retain-consumer"); + const options = createSideForkLoadOptions(); + const linker = new DynamicLinker(options); + const providerHandle = linker.dlopenSync( + "libruntime-provider.so", + providerBytes, + ); + const consumerHandle = linker.dlopenSync( + "libruntime-consumer.so", + consumerBytes, + ); + expect( + linker.forkLibraryState("libruntime-consumer.so") + ?.providerDependencies, + ).toEqual(["libruntime-provider.so"]); + + expect(linker.dlclose(providerHandle)).toBe(0); + expect(options.loadedLibraries.has("libruntime-provider.so")).toBe(true); + const consumer = linker.dlsym(consumerHandle, "runtime_consumer"); + expect((options.table.get(consumer!) as () => number)()).toBe(42); + + expect(linker.dlclose(consumerHandle)).toBe(0); + expect(options.loadedLibraries.size).toBe(0); + }); + + it("keeps RTLD_LOCAL exports private and archives their later promotion", () => { + const wasmBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (func (export "local_then_global") (result i32) i32.const 37)) + `, "staged-local-visibility"); + const options = createSideForkLoadOptions(); + const linker = new DynamicLinker(options); + + const localHandle = linker.dlopenSync( + "liblocal-visibility.so", + wasmBytes, + undefined, + false, + ); + const explicit = linker.dlsym(localHandle, "local_then_global"); + expect(explicit).not.toBeNull(); + expect(linker.dlsym(0, "local_then_global")).toBeNull(); + expect(linker.forkState().libraries[0]).toMatchObject({ + globalVisibility: false, + }); + expect(linker.forkState().libraries[0]).not.toHaveProperty( + "committedGlobalRoot", + ); + + const promotedHandle = linker.dlopenSync( + "liblocal-visibility.so", + wasmBytes, + undefined, + true, + ); + expect(promotedHandle).toBe(localHandle); + expect(linker.dlsym(0, "local_then_global")).toBe(explicit); + expect(linker.forkState().libraries[0]).toMatchObject({ + globalVisibility: true, + committedGlobalRoot: true, + }); + }); + + it("binds an RTLD_LOCAL root through its private NEEDED scope", () => { + const dependency = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (func (export "dependency_value") (result i32) i32.const 29)) + `, "staged-local-needed-dependency"); + const root = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "dependency_value" (func $dependency_value (result i32))) + (func (export "root_value") (result i32) + call $dependency_value + i32.const 8 + i32.add)) + `, "staged-local-needed-root", undefined, 0, 0, [], [], null, [ + "libscope-dep.so", + ]); + const options = createSideForkLoadOptions(); + options.resolveLibrarySync = (name) => + name === "libscope-dep.so" ? dependency : null; + const linker = new DynamicLinker(options); + + const localHandle = linker.dlopenSync( + "libscope-root.so", + root, + undefined, + false, + ); + const rootIndex = linker.dlsym(localHandle, "root_value"); + expect(rootIndex).not.toBeNull(); + expect((options.table.get(rootIndex!) as () => number)()).toBe(37); + expect( + options.loadedLibraries.get("libscope-dep.so")?.globalVisibility, + ).toBe(false); + expect(linker.dlsym(0, "root_value")).toBeNull(); + expect(linker.dlsym(0, "dependency_value")).toBeNull(); + + expect(linker.dlopenSync( + "libscope-root.so", + root, + undefined, + true, + )).toBe(localHandle); + expect( + options.loadedLibraries.get("libscope-dep.so")?.globalVisibility, + ).toBe(true); + expect(linker.dlsym(0, "root_value")).toBe(rootIndex); + expect(linker.dlsym(0, "dependency_value")).not.toBeNull(); + }); + + it("restores compact handle/refcount state with closed-handle gaps", () => { + const firstBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (func (export "first_value") (result i32) i32.const 1)) + `, "fork-handle-first"); + const secondBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (func (export "second_value") (result i32) i32.const 2)) + `, "fork-handle-second"); + const parentOptions = createSideForkLoadOptions(); + const parent = new DynamicLinker(parentOptions); + + expect(parent.dlopenSync("libfirst.so", firstBytes)).toBe(2); + expect(parent.dlopenSync("libsecond.so", secondBytes)).toBe(3); + expect(parent.dlopenSync("libsecond.so", secondBytes)).toBe(3); + expect(parent.dlclose(2)).toBe(0); + const archived = parent.forkState(); + expect(archived).toMatchObject({ + nextHandle: 4, + libraries: [{ + name: "libsecond.so", + handle: 3, + refCount: 2, + }], + }); + + const childOptions = createSideForkLoadOptions(); + const child = new DynamicLinker(childOptions); + for (const library of archived.libraries) { + child.loadModuleSync( + library.name, + new Uint8Array(library.moduleBytes), + { + memoryBase: library.memoryBase, + tableBase: library.tableBase, + activationId: library.activationId, + tlsBase: library.tlsBase, + globalVisibility: library.globalVisibility, + committedGlobalRoot: library.committedGlobalRoot, + }, + ); + } + child.restoreForkHandleState(archived); + expect(child.forkState()).toEqual(archived); + + // The duplicate open keeps the inherited handle and reference count. + expect(child.dlopenSync("libsecond.so", secondBytes)).toBe(3); + expect(child.forkLibraryState("libsecond.so")).toMatchObject({ + handle: 3, + refCount: 3, + }); + // The next new module must not reuse the parent's closed handle 2. + expect(child.dlopenSync("libfirst.so", firstBytes)).toBe(4); + }); + + it("reconciles dlopen function recipes to fresh Worker-local table entries", () => { + const sideBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "__indirect_function_table" (table 1 funcref)) + (import "env" "__table_base" (global $table_base i32)) + (func $side_value (export "side_value") (result i32) i32.const 73) + (elem (global.get $table_base) func $side_value)) + `, "fork-table-replica", undefined, 1); + const parentOptions = createSideForkLoadOptions(); + const parentMutations: Array<[number, number]> = []; + parentOptions.onTableMutation = (_table, firstIndex, length) => { + parentMutations.push([firstIndex, length]); + }; + const parent = new DynamicLinker(parentOptions); + expect(parent.dlopenSync("libtable-replica.so", sideBytes)).toBe(2); + const archived = parent.forkState(); + const parentLibrary = archived.libraries[0]!; + const parentFunction = parentOptions.table.get(parentLibrary.tableBase); + expect(typeof parentFunction).toBe("function"); + expect((parentFunction as () => number)()).toBe(73); + expect(parentMutations).toContainEqual([ + parentOptions.table.length - 1, + 1, + ]); + + const replicaOptions = createSideForkLoadOptions(); + const replicaMutations: Array<[number, number]> = []; + replicaOptions.onTableMutation = (_table, firstIndex, length) => { + replicaMutations.push([firstIndex, length]); + }; + const replica = new DynamicLinker(replicaOptions); + replica.reconcileForkModules(archived); + const freshFunction = replicaOptions.table.get(parentLibrary.tableBase); + expect(typeof freshFunction).toBe("function"); + expect(freshFunction).not.toBe(parentFunction); + expect((freshFunction as () => number)()).toBe(73); + expect(replicaMutations).toContainEqual([ + replicaOptions.table.length - 1, + 1, + ]); + + const length = replicaOptions.table.length; + replica.reconcileForkModules(archived); + expect(replicaOptions.table.length).toBe(length); + expect(replicaOptions.table.get(parentLibrary.tableBase)).toBe(freshFunction); + + const parentOwnedEntries = [ + ...parentOptions.loadedLibraries.get("libtable-replica.so")! + .ownedTableEntries, + ]; + const replicaOwnedEntries = [ + ...replicaOptions.loadedLibraries.get("libtable-replica.so")! + .ownedTableEntries, + ]; + expect(parent.dlclose(2)).toBe(0); + for (const index of parentOwnedEntries) { + expect(parentOptions.table.get(index)).toBeNull(); + } + expect(parentOptions.globalSymbols.has("side_value")).toBe(false); + expect(parent.dlsym(0, "side_value")).toBeNull(); + const closed = parent.forkState(); + expect(closed.libraries).toEqual([]); + + replica.reconcileForkModules(closed); + expect(replicaOptions.loadedLibraries.size).toBe(0); + for (const index of replicaOwnedEntries) { + expect(replicaOptions.table.get(index)).toBeNull(); + } + }); + + it("rejects non-pristine or inconsistent compact handle snapshots", () => { + const wasmBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (func (export "value") (result i32) i32.const 1)) + `, "fork-handle-validation"); + const options = createSideForkLoadOptions(); + const linker = new DynamicLinker(options); + const live = linker.loadModuleSync("libvalidation.so", wasmBytes); + const baseState = { + nextHandle: 4, + libraries: [{ + name: live.name, + moduleBytes: live.moduleBytes, + memoryBase: live.memoryBase, + tableBase: live.tableBase, + globalVisibility: live.globalVisibility, + handle: 3, + refCount: 1, + }], + }; + + expect(() => linker.restoreForkHandleState({ + ...baseState, + libraries: [ + ...baseState.libraries, + { ...baseState.libraries[0]!, handle: 2 }, + ], + })).toThrow(/exact live module closure/); + expect(() => linker.restoreForkHandleState({ + ...baseState, + libraries: [{ ...baseState.libraries[0]!, handle: 4 }], + })).toThrow(/fork handle 4 is invalid/); + linker.restoreForkHandleState(baseState); + expect(() => linker.restoreForkHandleState(baseState)) + .toThrow(/requires a pristine child handle index/); + }); + + it("rejects replay of the same module-load record twice", () => { + const wasmBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (func (export "value") (result i32) i32.const 1)) + `, "duplicate-replay-load"); + const options = createSideForkLoadOptions(); + const linker = new DynamicLinker(options); + const loaded = linker.loadModuleSync("libduplicate-replay.so", wasmBytes, { + memoryBase: 0, + tableBase: 1, + }); + expect(loaded.name).toBe("libduplicate-replay.so"); + + expect(() => linker.loadModuleSync("libduplicate-replay.so", wasmBytes, { + memoryBase: 0, + tableBase: 1, + })).toThrow(/archive entries must be unique/); + }); +}); + describe("side-module fork contract", () => { - it("rejects an uninstrumented side module that imports fork", () => { + it("keeps raw side modules legal when the process has no fork activation owner", () => { const wasmBytes = buildDylinkWat(` (module (import "env" "memory" (memory 1 100 shared)) - (import "env" "fork" (func $fork (result i32))) - (func (export "side_fork") (result i32) call $fork)) - `, "side-fork-uninstrumented"); + (func (export "raw_value") (result i32) i32.const 17)) + `, "raw-side-without-fork-owner"); + const options = createSideForkLoadOptions(); + + const loaded = loadSharedLibrarySync( + "libraw-nonfork.so", + wasmBytes, + options, + ); + + expect((loaded.exports.raw_value as () => number)()).toBe(17); + expect(loaded.activationId).toBeUndefined(); + }); + + it("rejects a raw side module before instantiation in a fork-capable process", () => { + const wasmBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (func (export "raw_value") (result i32) i32.const 17)) + `, "raw-side-with-fork-owner"); + const testOwner = createTestForkActivationOwner(); + const options = createSideForkLoadOptions(); + options.forkActivationOwner = testOwner.owner; + + expect(() => loadSharedLibrarySync( + "libraw-fork-process.so", + wasmBytes, + options, + )).toThrow(/requires complete ABI 43 side-boundary instrumentation/); + expect(testOwner.prepares).toEqual([]); + expect(options.loadedLibraries.size).toBe(0); + }); + + it("accepts a complete side-boundary artifact without an env.fork import", () => { + const wasmBytes = buildInstrumentedDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "host_value" (func $host_value (result i32))) + (func (export "side_value") (result i32) + call $host_value + i32.const 1 + i32.add)) + `, "side-boundary-without-fork-import"); + const module = new WebAssembly.Module( + wasmBytes as unknown as BufferSource, + ); + expect(WebAssembly.Module.imports(module).some( + (entry) => + entry.module === "env" + && entry.name === "fork" + && entry.kind === "function", + )).toBe(false); + expect(readForkInstrumentCapabilities(module) & FORK_CAP_SIDE_ENTRY) + .toBe(FORK_CAP_SIDE_ENTRY); + + const testOwner = createTestForkActivationOwner(21); const options = createSideForkLoadOptions(); - options.sideModuleFork = { - setActiveFork: () => {}, - clearActiveFork: () => {}, - invokeMainFork: () => 0, - beginMainAbort: () => {}, + options.globalSymbols.set("host_value", () => 16); + options.forkActivationOwner = testOwner.owner; + + const loaded = loadSharedLibrarySync( + "libside-boundary.so", + wasmBytes, + options, + ); + + expect(loaded.activationId).toBe(21); + expect((loaded.exports.side_value as () => number)()).toBe(17); + expect(testOwner.registered).toEqual([ + { activationId: 21, instance: loaded.instance }, + ]); + }); + + it("validates ABI 43 reconstruction metadata before side-module instantiation", () => { + // Export every reserved function name so the loader takes the complete + // ABI-43 path. Deliberately give the stubs the wrong signatures and omit + // reconstruction descriptors/imports: none of this module may execute. + const reservedStubs = SIDE_MODULE_FORK_EXPORTS + .map((name) => `(func (export "${name}"))`) + .join("\n"); + const wasmBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + ${reservedStubs}) + `, "side-fork-invalid-reconstruction-contract", 0); + const options = createSideForkLoadOptions(); + let prepareCalls = 0; + options.forkActivationOwner = { + prepare() { + prepareCalls++; + throw new Error("must not prepare an invalid artifact"); + }, }; - expect(() => loadSharedLibrarySync("libbadfork.so", wasmBytes, options)) - .toThrow(/requires complete side-module instrumentation/); + expect(() => loadSharedLibrarySync("libinvalidfork.so", wasmBytes, options)) + .toThrow( + /invalid ABI 43 fork reconstruction contract: .*exception_codec.*imported_globals/, + ); + expect(prepareCalls).toBe(0); + expect(options.loadedLibraries.has("libinvalidfork.so")).toBe(false); }); - it("applies the generated ABI transition to a legacy five-export side artifact", () => { + it("rejects an uninstrumented side module that imports fork", () => { const wasmBytes = buildDylinkWat(` (module (import "env" "memory" (memory 1 100 shared)) (import "env" "fork" (func $fork (result i32))) - (func (export "wpk_fork_unwind_begin") (param i32)) - (func (export "wpk_fork_unwind_end")) - (func (export "wpk_fork_rewind_begin") (param i32)) - (func (export "wpk_fork_rewind_end")) - (func (export "wpk_fork_abort_begin") (param i32)) - (func (export "wpk_fork_abort_end")) - (func (export "wpk_fork_state") (result i32) i32.const 0) (func (export "side_fork") (result i32) call $fork)) - `, "side-fork-generic"); + `, "side-fork-uninstrumented"); const options = createSideForkLoadOptions(); - options.sideModuleFork = { - setActiveFork: () => {}, - clearActiveFork: () => {}, - invokeMainFork: () => 0, - beginMainAbort: () => {}, - }; - const load = () => loadSharedLibrarySync("liblegacyfork.so", wasmBytes, options); - const legacyAllowed = forkInstrumentRoleAvailable( - { present: false, flags: 0 }, - FORK_CAP_SIDE_ENTRY, - ); - if (legacyAllowed) { - expect(load).not.toThrow(); - } else { - expect(load).toThrow(/activation-state-safe capability/); - } + expect(() => loadSharedLibrarySync("libbadfork.so", wasmBytes, options)) + .toThrow(/requires complete side-module instrumentation/); }); it("makes missing side and main role claims mandatory at ABI 17", () => { @@ -744,45 +1406,23 @@ describe("side-module fork contract", () => { )).toBe(false); }); - it("rejects a marker-present artifact that does not claim side-entry coverage", () => { - const wasmBytes = buildDylinkWat(` - (module - (import "env" "memory" (memory 1 100 shared)) - (import "env" "fork" (func $fork (result i32))) - (func (export "wpk_fork_unwind_begin") (param i32)) - (func (export "wpk_fork_unwind_end")) - (func (export "wpk_fork_rewind_begin") (param i32)) - (func (export "wpk_fork_rewind_end")) - (func (export "wpk_fork_abort_begin") (param i32)) - (func (export "wpk_fork_abort_end")) - (func (export "wpk_fork_state") (result i32) i32.const 0) - (func (export "side_fork") (result i32) call $fork)) - `, "side-fork-wrong-marker", 0); - const options = createSideForkLoadOptions(); - options.sideModuleFork = { - setActiveFork: () => {}, - clearActiveFork: () => {}, - invokeMainFork: () => 0, - beginMainAbort: () => {}, - }; - - expect(() => loadSharedLibrarySync("libwrongmarker.so", wasmBytes, options)) - .toThrow(/versioned side-entry capability/); - }); - it("binds a side module's activation-safety claim to ABI 43", () => { + const reservedStubs = SIDE_MODULE_FORK_EXPORTS + .map((name) => `(func (export "${name}"))`) + .join("\n"); const sideWat = ` (module (import "env" "memory" (memory 1 100 shared)) - (func (export "wpk_fork_unwind_begin") (param i32)) - (func (export "wpk_fork_unwind_end")) - (func (export "wpk_fork_rewind_begin") (param i32)) - (func (export "wpk_fork_rewind_end")) - (func (export "wpk_fork_abort_begin") (param i32)) - (func (export "wpk_fork_abort_end")) - (func (export "wpk_fork_state") (result i32) i32.const 0)) + ${reservedStubs}) `; const options = createSideForkLoadOptions(); + let prepareCalls = 0; + options.forkActivationOwner = { + prepare() { + prepareCalls++; + throw new Error("must not prepare an ABI-mismatched artifact"); + }, + }; const stale = buildDylinkWat( sideWat, "side-fork-stale-abi", @@ -808,6 +1448,7 @@ describe("side-module fork contract", () => { ); expect(() => loadSharedLibrarySync("libmissing.so", missing, options)) .toThrow(/missing __abi_version/); + expect(prepareCalls).toBe(0); }); it("reads the versioned side-entry capability independently", () => { @@ -839,183 +1480,292 @@ describe("side-module fork contract", () => { .toThrow(/malformed kandelo\.wpk_fork\.capabilities custom section/); }); - it("reports an explicit stale-main diagnostic for a valid side artifact", () => { - const wasmBytes = buildDylinkWat(` + it("requires a process activation owner for a valid ABI-43 side module", () => { + const wasmBytes = buildInstrumentedDylinkWat(` (module (import "env" "memory" (memory 1 100 shared)) (import "env" "fork" (func $fork (result i32))) - (func (export "wpk_fork_unwind_begin") (param i32)) - (func (export "wpk_fork_unwind_end")) - (func (export "wpk_fork_rewind_begin") (param i32)) - (func (export "wpk_fork_rewind_end")) - (func (export "wpk_fork_abort_begin") (param i32)) - (func (export "wpk_fork_abort_end")) - (func (export "wpk_fork_state") (result i32) i32.const 0) (func (export "side_fork") (result i32) call $fork)) - `, "side-with-stale-main", FORK_CAP_SIDE_ENTRY); + `, "side-fork-owner-required"); const options = createSideForkLoadOptions(); - options.sideModuleForkUnavailableReason = - "main module lacks the versioned dlopen-main fork capability; rebuild it"; + options.forkActivationOwnerUnavailableReason = + "main activation registry is unavailable; rebuild or relaunch the process"; - expect(() => loadSharedLibrarySync("libside.so", wasmBytes, options)) - .toThrow(/main module lacks the versioned dlopen-main fork capability; rebuild it/); + expect(() => loadSharedLibrarySync("libowner-required.so", wasmBytes, options)) + .toThrow(/main activation registry is unavailable/); }); - it("rejects a fork-capable side module without process-mapping storage", () => { - const wasmBytes = buildDylinkWat(` + it("registers multiple linked side activations without module-static fork roots", () => { + const providerBytes = buildInstrumentedDylinkWat(` (module (import "env" "memory" (memory 1 100 shared)) (import "env" "fork" (func $fork (result i32))) - (func (export "wpk_fork_unwind_begin") (param i32)) - (func (export "wpk_fork_unwind_end")) - (func (export "wpk_fork_rewind_begin") (param i32)) - (func (export "wpk_fork_rewind_end")) - (func (export "wpk_fork_abort_begin") (param i32)) - (func (export "wpk_fork_abort_end")) - (func (export "wpk_fork_state") (result i32) i32.const 0) + (export "raw_fork_import" (func $fork)) + (func (export "provider_value") (result i32) i32.const 7)) + `, "side-fork-provider"); + const consumerBytes = buildInstrumentedDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "fork" (func $fork (result i32))) + (import "env" "provider_value" (func $provider_value (result i32))) + (func (export "nested_value") (result i32) + call $provider_value + i32.const 1 + i32.add) + (func (export "consumer_fork") (result i32) call $fork)) + `, "side-fork-consumer"); + const testOwner = createTestForkActivationOwner(); + const options = createSideForkLoadOptions(); + options.forkActivationOwner = testOwner.owner; + + const provider = loadSharedLibrarySync( + "libfork-provider.so", + providerBytes, + options, + ); + const consumer = loadSharedLibrarySync( + "libfork-consumer.so", + consumerBytes, + options, + ); + + expect(provider.activationId).toBe(1); + expect(consumer.activationId).toBe(2); + expect(testOwner.registered).toEqual([ + { activationId: 1, instance: provider.instance }, + { activationId: 2, instance: consumer.instance }, + ]); + expect((consumer.exports.nested_value as () => number)()).toBe(8); + // Engines expose a Wasm wrapper when an imported JS function is + // re-exported, so behavior—not JS object identity—proves the exact owner + // callback reached the module. + expect((provider.instance.exports.raw_fork_import as () => number)()).toBe(-12); + expect("forkBufAddr" in provider).toBe(false); + expect("forkContinuation" in provider).toBe(false); + expect(typeof provider.activationId).toBe("number"); + }); + + it("wraps the final lazy imports before instantiation without collapsing duplicates", () => { + const wasmBytes = buildInstrumentedDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "__indirect_function_table" (table 1 funcref)) + (import "env" "shared_counter" (global $first_counter (mut i32))) + (import "env" "shared_counter" (global $second_counter (mut i32))) + (import "env" "fork" (func $fork (result i32))) + (func (export "counter_sum") (result i32) + global.get $first_counter + global.get $second_counter + i32.add) (func (export "side_fork") (result i32) call $fork)) - `, "side-without-continuation-mapping", FORK_CAP_SIDE_ENTRY); + `, "side-fork-lazy-import-capture"); + const testOwner = createTestForkActivationOwner(31); const options = createSideForkLoadOptions(); - options.allocateContinuation = undefined; - options.deallocateContinuation = undefined; - options.sideModuleFork = { - setActiveFork: () => {}, - clearActiveFork: () => {}, - invokeMainFork: () => 0, - beginMainAbort: () => {}, + const sharedCounter = new WebAssembly.Global( + { value: "i32", mutable: true }, + 6, + ); + options.globalSymbols.set("shared_counter", sharedCounter); + const observedGlobals: unknown[] = []; + const observedTables: unknown[] = []; + options.forkActivationOwner = { + prepare(request) { + const prepared = testOwner.owner.prepare(request); + return { + ...prepared, + wrapImports(imports) { + const baseImports = prepared.wrapImports(imports); + return new Proxy(baseImports as object, { + get(target, moduleName, receiver) { + const namespace = Reflect.get(target, moduleName, receiver); + if (moduleName !== "env" || typeof namespace !== "object") { + return namespace; + } + return new Proxy(namespace as object, { + get(namespaceTarget, importName, namespaceReceiver) { + const value = Reflect.get( + namespaceTarget, + importName, + namespaceReceiver, + ); + if (importName === "shared_counter") { + observedGlobals.push(value); + } else if (importName === "__indirect_function_table") { + observedTables.push(value); + } + return value; + }, + }); + }, + }) as WebAssembly.Imports; + }, + }; + }, }; - expect(() => loadSharedLibrarySync("libunmappedfork.so", wasmBytes, options)) - .toThrow(/require process-mapping allocation and cleanup/); + const loaded = loadSharedLibrarySync( + "libfork-lazy-import-capture.so", + wasmBytes, + options, + ); + + expect(observedGlobals).toEqual([sharedCounter, sharedCounter]); + expect(observedTables).toEqual([options.table]); + expect(testOwner.wrappedImports).toHaveLength(1); + expect(testOwner.registered).toEqual([ + { activationId: 31, instance: loaded.instance }, + ]); + expect((loaded.exports.counter_sum as () => number)()).toBe(12); }); - it("drives repeated instrumented side-module forks through exact states", () => { - const wasmBytes = buildDylinkWat(` + it("replays dependency-first with the parent's exact activation ids", () => { + const dependencyBytes = buildInstrumentedDylinkWat(` (module (import "env" "memory" (memory 1 100 shared)) (import "env" "fork" (func $fork (result i32))) - (global $state (mut i32) (i32.const 0)) - (global $buf (mut i32) (i32.const 0)) - (func (export "wpk_fork_unwind_begin") (param $addr i32) - local.get $addr - global.set $buf - i32.const 1 - global.set $state) - (func (export "wpk_fork_unwind_end") - i32.const 0 - global.set $state) - (func (export "wpk_fork_rewind_begin") (param $addr i32) - local.get $addr - global.set $buf - i32.const 2 - global.set $state) - (func (export "wpk_fork_rewind_end") - i32.const 0 - global.set $state) - (func (export "wpk_fork_abort_begin") (param $addr i32) - local.get $addr - global.set $buf - i32.const 3 - global.set $state) - (func (export "wpk_fork_abort_end") - i32.const 0 - global.set $state) - (func (export "wpk_fork_state") (result i32) - global.get $state) - (func (export "side_fork_with_local") (result i32) - i32.const 41 - call $fork - i32.add)) - `, "side-fork-instrumented", FORK_CAP_SIDE_ENTRY); - const options = createSideForkLoadOptions(); - let forkResult = 0; - let active: SideModuleForkState | null = null; - options.sideModuleFork = { - setActiveFork: (state) => { - expect(active).toBeNull(); - active = state; + (func (export "dependency_value") (result i32) i32.const 19) + (func (export "dependency_fork") (result i32) call $fork)) + `, "side-fork-needed-dependency"); + const consumerBytes = buildInstrumentedDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "fork" (func $fork (result i32))) + (import "env" "dependency_value" (func $dependency_value (result i32))) + (func (export "needed_value") (result i32) call $dependency_value) + (func (export "needed_fork") (result i32) call $fork)) + `, "side-fork-needed-consumer", ["libfork-dependency.so"]); + const parentOwner = createTestForkActivationOwner(41); + const parent = createSideForkLoadOptions(); + parent.forkActivationOwner = parentOwner.owner; + parent.resolveLibrarySync = (name) => + name === "libfork-dependency.so" ? dependencyBytes : null; + + const parentConsumer = loadSharedLibrarySync( + "libfork-consumer.so", + consumerBytes, + parent, + ); + const parentDependency = parent.loadedLibraries.get("libfork-dependency.so")!; + expect(Array.from(parent.loadedLibraries)).toEqual([ + ["libfork-dependency.so", parentDependency], + ["libfork-consumer.so", parentConsumer], + ]); + expect(parentDependency.activationId).toBe(41); + expect(parentConsumer.activationId).toBe(42); + + const childOwner = createTestForkActivationOwner(100); + const child = createSideForkLoadOptions(); + child.forkActivationOwner = childOwner.owner; + expect(() => loadSharedLibrarySync( + "libfork-consumer.so", + consumerBytes, + child, + { + memoryBase: parentConsumer.memoryBase, + tableBase: parentConsumer.tableBase, + activationId: parentConsumer.activationId, }, - clearActiveFork: (state) => { - expect(active).toBe(state); - active = null; + )).toThrow(/archive entries must be replayed in dependency order/); + expect(childOwner.prepares).toHaveLength(0); + + const childDependency = loadSharedLibrarySync( + "libfork-dependency.so", + dependencyBytes, + child, + { + memoryBase: parentDependency.memoryBase, + tableBase: parentDependency.tableBase, + activationId: parentDependency.activationId, }, - invokeMainFork: () => forkResult, - beginMainAbort: () => {}, - }; + ); + const childConsumer = loadSharedLibrarySync( + "libfork-consumer.so", + consumerBytes, + child, + { + memoryBase: parentConsumer.memoryBase, + tableBase: parentConsumer.tableBase, + activationId: parentConsumer.activationId, + }, + ); - const lib = loadSharedLibrarySync("libsidefork.so", wasmBytes, options); - const sideFork = lib.exports.side_fork_with_local as () => number; - const state = lib.instance.exports.wpk_fork_state as () => number; - const unwindEnd = lib.instance.exports.wpk_fork_unwind_end as () => void; - const rewindBegin = lib.instance.exports.wpk_fork_rewind_begin as (addr: number) => void; - - // A main root-allocation failure returns synchronously before either Wasm - // stack has unwound. The side owner must cancel its just-opened root and - // clear the persisted active identity without entering replay. - forkResult = -12; - expect(sideFork()).toBe(29); - expect(state()).toBe(0); - expect(active).toBeNull(); - expect(lib.forkContinuation?.hasActiveContinuation()).toBe(false); - - for (const expectedForkResult of [101, 202]) { - forkResult = 0; - expect(sideFork()).toBe(41); - expect(state()).toBe(1); - expect(active?.forkBufAddr).toBe(lib.forkBufAddr); - expect(active?.continuation).toBe(lib.forkContinuation); - - unwindEnd(); - forkResult = expectedForkResult; - rewindBegin(lib.forkBufAddr!); - expect(sideFork()).toBe(41 + expectedForkResult); - expect(state()).toBe(0); - expect(active).toBeNull(); - } + expect(childDependency.activationId).toBe(41); + expect(childConsumer.activationId).toBe(42); + expect(childOwner.prepares.map((request) => request.replayActivationId)) + .toEqual([41, 42]); + expect((childConsumer.exports.needed_value as () => number)()).toBe(19); }); - it("allows independent extensions but rejects visible side-to-side fork nesting", () => { - const options = createSideForkLoadOptions(); - options.sideModuleFork = { - setActiveFork: () => {}, - clearActiveFork: () => {}, - invokeMainFork: () => 0, - beginMainAbort: () => {}, - }; - const provider = buildDylinkWat(` + it("unregisters exactly once when post-instantiation startup rolls back", () => { + const wasmBytes = buildInstrumentedDylinkWat(` (module (import "env" "memory" (memory 1 100 shared)) - (func (export "provider_value") (result i32) i32.const 7)) - `, "fork-provider"); - loadSharedLibrarySync("libprovider.so", provider, options); + (import "env" "fork" (func $fork (result i32))) + (func (export "__wasm_call_ctors") unreachable) + (func (export "side_fork") (result i32) call $fork)) + `, "side-fork-rollback"); + const testOwner = createTestForkActivationOwner(9); + const options = createSideForkLoadOptions(); + options.forkActivationOwner = testOwner.owner; + + expect(() => loadSharedLibrarySync("libfork-rollback.so", wasmBytes, options)) + .toThrow(); + expect(testOwner.registered).toHaveLength(1); + expect(testOwner.unregistered).toEqual([9]); + expect(testOwner.active.size).toBe(0); + expect(options.loadedLibraries.size).toBe(0); + }); - const independentFork = buildDylinkWat(` + it("does not fall back to process symbols for owner-controlled fork imports", () => { + const wasmBytes = buildInstrumentedDylinkWat(` (module (import "env" "memory" (memory 1 100 shared)) (import "env" "fork" (func $fork (result i32))) - (global $state (mut i32) (i32.const 0)) - (func (export "wpk_fork_unwind_begin") (param i32) - i32.const 1 global.set $state) - (func (export "wpk_fork_unwind_end") i32.const 0 global.set $state) - (func (export "wpk_fork_rewind_begin") (param i32) - i32.const 2 global.set $state) - (func (export "wpk_fork_rewind_end") i32.const 0 global.set $state) - (func (export "wpk_fork_abort_begin") (param i32) - i32.const 3 global.set $state) - (func (export "wpk_fork_abort_end") i32.const 0 global.set $state) - (func (export "wpk_fork_state") (result i32) global.get $state) (func (export "side_fork") (result i32) call $fork)) - `, "independent-fork-side", FORK_CAP_SIDE_ENTRY); - loadSharedLibrarySync("libindependent-fork.so", independentFork, options); + `, "side-fork-owner-import"); + const testOwner = createTestForkActivationOwner(15); + const options = createSideForkLoadOptions(); + options.globalSymbols.set("fork", () => 123); + options.forkActivationOwner = { + prepare(request) { + const prepared = testOwner.owner.prepare(request); + const { fork: _fork, ...envWithoutFork } = prepared.env; + return { ...prepared, env: envWithoutFork }; + }, + }; + + expect(() => loadSharedLibrarySync("libfork-owner-import.so", wasmBytes, options)) + .toThrow(/function import requires a callable/); + expect(testOwner.unregistered).toEqual([15]); + expect(testOwner.active.size).toBe(0); + }); - const visibleConsumer = buildDylinkWat(` + it("keeps a shared activation until the final dlclose reference", () => { + const wasmBytes = buildInstrumentedDylinkWat(` (module (import "env" "memory" (memory 1 100 shared)) - (import "env" "side_fork" (func $side_fork (result i32))) - (func (export "nested") (result i32) call $side_fork)) - `, "visible-side-consumer"); - expect(() => loadSharedLibrarySync("libnested.so", visibleConsumer, options)) - .toThrow(/fork-capable side-module nesting/); + (import "env" "fork" (func $fork (result i32))) + (func (export "side_fork") (result i32) call $fork)) + `, "side-fork-refcount"); + const testOwner = createTestForkActivationOwner(27); + const options = createSideForkLoadOptions(); + options.forkActivationOwner = testOwner.owner; + const linker = new DynamicLinker(options); + + const firstHandle = linker.dlopenSync("libfork-refcount.so", wasmBytes); + const secondHandle = linker.dlopenSync("libfork-refcount.so", wasmBytes); + expect(firstHandle).toBeGreaterThan(0); + expect(secondHandle).toBe(firstHandle); + expect(testOwner.prepares).toHaveLength(1); + + expect(linker.dlclose(firstHandle)).toBe(0); + expect(testOwner.unregistered).toEqual([]); + expect(options.loadedLibraries.has("libfork-refcount.so")).toBe(true); + + expect(linker.dlclose(secondHandle)).toBe(0); + expect(testOwner.unregistered).toEqual([27]); + expect(testOwner.active.size).toBe(0); + expect(options.loadedLibraries.has("libfork-refcount.so")).toBe(false); }); }); @@ -1063,6 +1813,37 @@ describe("dylink symbol interposition", () => { }); describe("dylink replay layout and rollback", () => { + it("does not apply data relocations twice to copied child memory", () => { + const wasmBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (func (export "__wasm_apply_data_relocs") + i32.const 32 + i32.const 32 + i32.load + i32.const 1 + i32.add + i32.store)) + `, "replay-does-not-relocate-twice"); + const parent = createSideForkLoadOptions(); + const loaded = loadSharedLibrarySync( + "librelocate-once.so", + wasmBytes, + parent, + ); + expect(new DataView(parent.memory.buffer).getInt32(32, true)).toBe(1); + + const child = createSideForkLoadOptions(); + new Uint8Array(child.memory.buffer).set( + new Uint8Array(parent.memory.buffer), + ); + loadSharedLibrarySync("librelocate-once.so", wasmBytes, child, { + memoryBase: loaded.memoryBase, + tableBase: loaded.tableBase, + }); + expect(new DataView(child.memory.buffer).getInt32(32, true)).toBe(1); + }); + it("restores copied live TLS without re-running side-module TLS initialization", () => { const tlsSide = buildDylinkWat(` (module @@ -1359,6 +2140,532 @@ describe.skipIf(!hasCompiler())("DynamicLinker", () => { expect(linker.dlclose(handle)).toBe(0); }); + it("lets libc drive initialization without a host-to-Wasm callback", () => { + const memory = new WebAssembly.Memory({ initial: 1 }); + const table = new WebAssembly.Table({ initial: 1, element: "anyfunc" }); + const stackPointer = new WebAssembly.Global( + { value: "i32", mutable: true }, + 65536, + ); + const linker = new DynamicLinker({ + memory, + table, + stackPointer, + heapPointer: { value: 1024 }, + globalSymbols: new Map(), + got: new Map(), + loadedLibraries: new Map(), + }); + const wasmBytes = buildDylinkWat(` + (module + (global $state (mut i32) (i32.const 0)) + (func (export "__wasm_apply_data_relocs") + global.get $state + i32.const 1 + i32.add + global.set $state) + (func (export "__wasm_call_ctors") + global.get $state + i32.const 10 + i32.add + global.set $state) + (func (export "initialization_state") (result i32) + global.get $state)) + `, "process-driven-initialization"); + + const token = linker.beginDlopenSync( + "libprocess-driven-initialization.so", + wasmBytes, + false, + ); + expect(token).toBeGreaterThan(0); + + const relocations = linker.nextDlopenInitialization(token); + expect(relocations).toBeGreaterThan(0); + expect(linker.forkState()).toMatchObject({ + libraries: [{ globalVisibility: false }], + transactions: [{ token, globalVisibility: false }], + }); + const relocationEntry = table.get(relocations); + expect(typeof relocationEntry).toBe("function"); + (relocationEntry as () => void)(); + + const constructors = linker.nextDlopenInitialization(token); + expect(constructors).toBe(relocations); + const constructorEntry = table.get(constructors); + expect(typeof constructorEntry).toBe("function"); + (constructorEntry as () => void)(); + + expect(linker.nextDlopenInitialization(token)).toBe(0); + expect(table.get(relocations)).toBeNull(); + const handle = linker.commitDlopenSync(token); + expect(handle).toBeGreaterThan(0); + const stateIndex = linker.dlsym(handle, "initialization_state"); + expect(stateIndex).not.toBeNull(); + expect((table.get(stateIndex!) as () => number)()).toBe(11); + expect(linker.dlsym(0, "initialization_state")).toBeNull(); + }); + + it("reconstructs an issued initialization entry in a fresh instance", () => { + const wasmBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1)) + (func (export "__wasm_apply_data_relocs") + i32.const 32 + i32.const 32 + i32.load + i32.const 1 + i32.add + i32.store) + (func (export "__wasm_call_ctors") + i32.const 32 + i32.const 32 + i32.load + i32.const 10 + i32.add + i32.store) + (func (export "initialization_state") (result i32) + i32.const 32 + i32.load)) + `, "fresh-process-driven-initialization"); + const parentMemory = new WebAssembly.Memory({ initial: 1 }); + const parentTable = new WebAssembly.Table({ initial: 1, element: "anyfunc" }); + const parent = new DynamicLinker({ + memory: parentMemory, + table: parentTable, + stackPointer: new WebAssembly.Global( + { value: "i32", mutable: true }, + 65536, + ), + heapPointer: { value: 1024 }, + globalSymbols: new Map(), + got: new Map(), + loadedLibraries: new Map(), + }); + const token = parent.beginDlopenSync( + "libfresh-process-driven.so", + wasmBytes, + false, + ); + const relocations = parent.nextDlopenInitialization(token); + (parentTable.get(relocations) as () => void)(); + const constructors = parent.nextDlopenInitialization(token); + expect(constructors).toBe(relocations); + const archived = parent.forkState(); + expect(archived.transactions).toHaveLength(1); + expect(archived.transactions![0]).toMatchObject({ + globalVisibility: false, + }); + expect(archived.libraries[0]!.initialization).toMatchObject({ + transactionToken: token, + stage: "constructors", + tableIndex: constructors, + }); + + const childMemory = new WebAssembly.Memory({ initial: 1 }); + new Uint8Array(childMemory.buffer).set( + new Uint8Array(parentMemory.buffer), + ); + const childTable = new WebAssembly.Table({ initial: 1, element: "anyfunc" }); + const child = new DynamicLinker({ + memory: childMemory, + table: childTable, + stackPointer: new WebAssembly.Global( + { value: "i32", mutable: true }, + 65536, + ), + heapPointer: { value: 1024 }, + globalSymbols: new Map(), + got: new Map(), + loadedLibraries: new Map(), + }); + child.reconcileForkModules(archived); + child.reconcileForkHandleState(archived); + + const childConstructor = childTable.get(constructors); + expect(typeof childConstructor).toBe("function"); + (childConstructor as () => void)(); + expect(child.nextDlopenInitialization(token)).toBe(0); + const handle = child.commitDlopenSync(token); + expect(handle).toBe(2); + const stateIndex = child.dlsym(handle, "initialization_state"); + expect(stateIndex).not.toBeNull(); + expect((childTable.get(stateIndex!) as () => number)()).toBe(11); + expect(child.dlsym(0, "initialization_state")).toBeNull(); + }); + + it("archives constructor dlsym ownership across a fresh staged replay", () => { + const providerBytes = buildDylinkWat(` + (module + (func (export "runtime_provider") (result i32) i32.const 61)) + `, "fresh-constructor-provider"); + const consumerBytes = buildDylinkWat(` + (module + (import "env" "lookup_provider" + (func $lookup_provider (result i32))) + (func (export "__wasm_call_ctors") + call $lookup_provider + drop) + (func (export "consumer_value") (result i32) i32.const 17)) + `, "fresh-constructor-provider-consumer"); + const parentOptions = createSideForkLoadOptions(); + let parent!: DynamicLinker; + let providerHandle = 0; + parentOptions.globalSymbols.set("lookup_provider", () => { + return parent.dlsym(providerHandle, "runtime_provider") ?? 0; + }); + parent = new DynamicLinker(parentOptions); + providerHandle = parent.dlopenSync( + "libconstructor-provider.so", + providerBytes, + ); + const token = parent.beginDlopenSync( + "libconstructor-consumer.so", + consumerBytes, + ); + const constructor = parent.nextDlopenInitialization(token); + (parentOptions.table.get(constructor) as () => void)(); + const archived = parent.forkState(); + expect( + archived.libraries.find( + (library) => library.name === "libconstructor-consumer.so", + )?.providerDependencies, + ).toEqual(["libconstructor-provider.so"]); + + const childOptions = createSideForkLoadOptions(); + childOptions.globalSymbols.set("lookup_provider", () => 0); + const child = new DynamicLinker(childOptions); + child.reconcileForkModules(archived); + child.reconcileForkHandleState(archived); + expect(child.nextDlopenInitialization(token)).toBe(0); + const consumerHandle = child.commitDlopenSync(token); + expect(consumerHandle).toBe(3); + + expect(child.dlclose(providerHandle)).toBe(0); + expect( + childOptions.loadedLibraries.has("libconstructor-provider.so"), + ).toBe(true); + expect(child.dlclose(consumerHandle)).toBe(0); + expect(Array.from(childOptions.loadedLibraries.keys())).toEqual([]); + }); + + it("reconciles staged initializer generations without repeating guest calls", () => { + const wasmBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1)) + (func (export "__wasm_apply_data_relocs") + i32.const 32 + i32.const 32 + i32.load + i32.const 1 + i32.add + i32.store) + (func (export "__wasm_call_ctors") + i32.const 32 + i32.const 32 + i32.load + i32.const 10 + i32.add + i32.store) + (func (export "initialization_state") (result i32) + i32.const 32 + i32.load)) + `, "replicated-process-driven-initialization"); + const memory = new WebAssembly.Memory({ initial: 1 }); + const parentTable = new WebAssembly.Table({ initial: 1, element: "anyfunc" }); + const replicaTable = new WebAssembly.Table({ initial: 1, element: "anyfunc" }); + const makeLinker = (table: WebAssembly.Table): DynamicLinker => + new DynamicLinker({ + memory, + table, + stackPointer: new WebAssembly.Global( + { value: "i32", mutable: true }, + 65536, + ), + heapPointer: { value: 1024 }, + globalSymbols: new Map(), + got: new Map(), + loadedLibraries: new Map(), + }); + const parent = makeLinker(parentTable); + const replica = makeLinker(replicaTable); + + const token = parent.beginDlopenSync("libreplicated-stages.so", wasmBytes); + const relocations = parent.nextDlopenInitialization(token); + const relocationState = parent.forkState(); + replica.reconcileForkModules(relocationState); + replica.reconcileForkHandleState(relocationState); + expect(typeof replicaTable.get(relocations)).toBe("function"); + + (parentTable.get(relocations) as () => void)(); + const constructors = parent.nextDlopenInitialization(token); + expect(constructors).toBe(relocations); + const constructorState = parent.forkState(); + replica.reconcileForkModules(constructorState); + replica.reconcileForkHandleState(constructorState); + expect(typeof replicaTable.get(constructors)).toBe("function"); + // Only the source Worker called the relocation entry. + expect(new DataView(memory.buffer).getInt32(32, true)).toBe(1); + + (parentTable.get(constructors) as () => void)(); + expect(parent.nextDlopenInitialization(token)).toBe(0); + expect(parent.commitDlopenSync(token)).toBe(2); + const committedState = parent.forkState(); + replica.reconcileForkModules(committedState); + replica.reconcileForkHandleState(committedState); + expect(replicaTable.get(constructors)).toBeNull(); + expect(new DataView(memory.buffer).getInt32(32, true)).toBe(11); + const stateIndex = replica.dlsym(2, "initialization_state"); + expect(stateIndex).not.toBeNull(); + expect((replicaTable.get(stateIndex!) as () => number)()).toBe(11); + }); + + it("retires a replicated staged initializer after authoritative rollback", () => { + const wasmBytes = buildDylinkWat(` + (module + (func (export "__wasm_call_ctors")) + ) + `, "replicated-process-driven-rollback"); + const makeLinker = (): { + readonly linker: DynamicLinker; + readonly table: WebAssembly.Table; + } => { + const table = new WebAssembly.Table({ initial: 1, element: "anyfunc" }); + return { + table, + linker: new DynamicLinker({ + memory: new WebAssembly.Memory({ initial: 1 }), + table, + stackPointer: new WebAssembly.Global( + { value: "i32", mutable: true }, + 65536, + ), + heapPointer: { value: 1024 }, + globalSymbols: new Map(), + got: new Map(), + loadedLibraries: new Map(), + }), + }; + }; + const parent = makeLinker(); + const replica = makeLinker(); + const token = parent.linker.beginDlopenSync( + "libreplicated-rollback.so", + wasmBytes, + ); + const constructors = parent.linker.nextDlopenInitialization(token); + const issuedState = parent.linker.forkState(); + replica.linker.reconcileForkModules(issuedState); + replica.linker.reconcileForkHandleState(issuedState); + expect(typeof replica.table.get(constructors)).toBe("function"); + + parent.linker.abortDlopenTransaction(token, new Error("constructor failed")); + const rolledBackState = parent.linker.forkState(); + replica.linker.reconcileForkModules(rolledBackState); + replica.linker.reconcileForkHandleState(rolledBackState); + expect(replica.table.get(constructors)).toBeNull(); + expect(rolledBackState.libraries).toHaveLength(0); + }); + + it("rolls back completed NEEDED objects when the staged root fails", () => { + const dependencyBytes = buildDylinkWat(` + (module + (func (export "__wasm_call_ctors")) + (func (export "dependency_value") (result i32) i32.const 7) + ) + `, "staged-needed-rollback-dependency"); + const rootBytes = buildDylinkWat(` + (module + (import "env" "missing_root_symbol" (func)) + ) + `, "staged-needed-rollback-root", undefined, 0, 0, [], [], null, [ + "libstaged-needed-dependency.so", + ]); + const options = createSideForkLoadOptions(); + options.resolveLibrarySync = (name) => + name === "libstaged-needed-dependency.so" ? dependencyBytes : null; + const linker = new DynamicLinker(options); + const token = linker.beginDlopenSync("libstaged-needed-root.so", rootBytes); + const dependencyConstructor = linker.nextDlopenInitialization(token); + expect(dependencyConstructor).toBeGreaterThan(0); + (options.table.get(dependencyConstructor) as () => void)(); + + expect(linker.nextDlopenInitialization(token)).toBe(-1); + expect(options.loadedLibraries.size).toBe(0); + expect(linker.forkState()).toMatchObject({ + nextHandle: 2, + libraries: [], + }); + expect(options.table.get(dependencyConstructor)).toBeNull(); + }); + + it("preserves an independently committed constructor-nested load", () => { + const nestedBytes = buildDylinkWat(` + (module + (func (export "nested_value") (result i32) i32.const 19) + ) + `, "staged-independent-nested"); + const outerBytes = buildDylinkWat(` + (module + (import "env" "nested_open" (func $nested_open)) + (func (export "__wasm_call_ctors") + call $nested_open + unreachable) + ) + `, "staged-failing-outer-independent"); + const options = createSideForkLoadOptions(); + let linker!: DynamicLinker; + let nestedHandle = 0; + options.globalSymbols.set("nested_open", () => { + const token = linker.beginDlopenSync( + "libindependent-nested.so", + nestedBytes, + ); + const result = linker.advanceDlopenSync(token); + expect(result.entry).toBe(0); + nestedHandle = result.handle; + }); + linker = new DynamicLinker(options); + + const outerToken = linker.beginDlopenSync( + "libfailing-outer-independent.so", + outerBytes, + ); + const constructor = linker.nextDlopenInitialization(outerToken); + let failure: unknown; + try { + (options.table.get(constructor) as () => void)(); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(WebAssembly.RuntimeError); + linker.abortDlopenTransaction(outerToken, failure); + + expect(nestedHandle).toBe(2); + expect(Array.from(options.loadedLibraries.keys())).toEqual([ + "libindependent-nested.so", + ]); + const nestedValue = linker.dlsym(nestedHandle, "nested_value"); + expect(nestedValue).not.toBeNull(); + expect((options.table.get(nestedValue!) as () => number)()).toBe(19); + }); + + it("preserves an independently committed nested GLOBAL promotion", () => { + const localBytes = buildDylinkWat(` + (module + (func (export "promoted_value") (result i32) i32.const 31) + ) + `, "staged-independent-promotion"); + const outerBytes = buildDylinkWat(` + (module + (import "env" "nested_promote" (func $nested_promote)) + (func (export "__wasm_call_ctors") + call $nested_promote + unreachable) + ) + `, "staged-failing-outer-promotion"); + const options = createSideForkLoadOptions(); + let linker!: DynamicLinker; + let promotedHandle = 0; + options.globalSymbols.set("nested_promote", () => { + const token = linker.beginDlopenSync( + "libpromoted-local.so", + localBytes, + true, + ); + const result = linker.advanceDlopenSync(token); + expect(result.entry).toBe(0); + promotedHandle = result.handle; + }); + linker = new DynamicLinker(options); + const localHandle = linker.dlopenSync( + "libpromoted-local.so", + localBytes, + undefined, + false, + ); + expect(localHandle).toBeGreaterThan(0); + expect(linker.dlsym(0, "promoted_value")).toBeNull(); + + const outerToken = linker.beginDlopenSync( + "libfailing-outer-promotion.so", + outerBytes, + ); + const constructor = linker.nextDlopenInitialization(outerToken); + let failure: unknown; + try { + (options.table.get(constructor) as () => void)(); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(WebAssembly.RuntimeError); + linker.abortDlopenTransaction(outerToken, failure); + + expect(promotedHandle).toBe(localHandle); + const promoted = options.loadedLibraries.get("libpromoted-local.so")!; + expect(promoted.globalVisibility).toBe(true); + expect(promoted.committedGlobalRoot).toBe(true); + expect(linker.forkLibraryState("libpromoted-local.so")).toMatchObject({ + globalVisibility: true, + committedGlobalRoot: true, + handle: localHandle, + refCount: 2, + }); + const value = linker.dlsym(0, "promoted_value"); + expect(value).not.toBeNull(); + expect((options.table.get(value!) as () => number)()).toBe(31); + }); + + it("cascades rollback into a constructor-nested load bound to the outer", () => { + const nestedBytes = buildDylinkWat(` + (module + (import "env" "outer_value" (func $outer_value (result i32))) + (func (export "nested_value") (result i32) call $outer_value) + ) + `, "staged-dependent-nested"); + const outerBytes = buildDylinkWat(` + (module + (import "env" "nested_open" (func $nested_open)) + (func (export "outer_value") (result i32) i32.const 23) + (func (export "__wasm_call_ctors") + call $nested_open + unreachable) + ) + `, "staged-failing-outer-dependent"); + const options = createSideForkLoadOptions(); + let linker!: DynamicLinker; + let nestedHandle = 0; + options.globalSymbols.set("nested_open", () => { + const token = linker.beginDlopenSync( + "libdependent-nested.so", + nestedBytes, + ); + const result = linker.advanceDlopenSync(token); + expect(result.entry).toBe(0); + nestedHandle = result.handle; + }); + linker = new DynamicLinker(options); + + const outerToken = linker.beginDlopenSync( + "libfailing-outer-dependent.so", + outerBytes, + ); + const constructor = linker.nextDlopenInitialization(outerToken); + let failure: unknown; + try { + (options.table.get(constructor) as () => void)(); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(WebAssembly.RuntimeError); + linker.abortDlopenTransaction(outerToken, failure); + + expect(nestedHandle).toBe(2); + expect(options.loadedLibraries.size).toBe(0); + expect(linker.dlsym(nestedHandle, "nested_value")).toBeNull(); + expect(linker.dlerror()).toBe("invalid handle"); + }); + it("reserves a stable handle for the main program symbol scope", () => { const memory = new WebAssembly.Memory({ initial: 1, maximum: 100, shared: true }); const table = new WebAssembly.Table({ initial: 1, element: "anyfunc" }); @@ -1422,6 +2729,110 @@ describe.skipIf(!hasCompiler())("DynamicLinker", () => { expect(allocAlign).toBeGreaterThan(0); }); + it("adopts copied mmap ownership and releases it on child dlclose", () => { + const wasmBytes = buildSharedLib( + ` + int value = 7; + int get_value(void) { return value; } + `, + "dl-fork-allocation-owner", + ); + const parentMemory = new WebAssembly.Memory({ + initial: 2, + maximum: 100, + shared: true, + }); + const parentTable = new WebAssembly.Table({ initial: 1, element: "anyfunc" }); + const parentAllocations = new Map< + number, + { mappingAddress: number; mappingSize: number } + >(); + const parent = new DynamicLinker({ + memory: parentMemory, + table: parentTable, + stackPointer: new WebAssembly.Global( + { value: "i32", mutable: true }, + 65536, + ), + allocateMemory: (size) => { + const address = 0x3000; + parentAllocations.set(address, { + mappingAddress: 0x2ff0, + mappingSize: size + 31, + }); + return address; + }, + describeMemoryAllocation: (address) => parentAllocations.get(address)!, + deallocateMemory: () => {}, + globalSymbols: new Map(), + got: new Map(), + loadedLibraries: new Map(), + }); + const handle = parent.dlopenSync("libfork-allocation.so", wasmBytes); + expect(handle).toBeGreaterThan(0); + const forkState = parent.forkState(); + expect(forkState.libraries[0]?.allocations).toEqual([{ + address: 0x3000, + size: expect.any(Number), + mappingAddress: 0x2ff0, + mappingSize: expect.any(Number), + }]); + + const childMemory = new WebAssembly.Memory({ + initial: 2, + maximum: 100, + shared: true, + }); + new Uint8Array(childMemory.buffer).set( + new Uint8Array(parentMemory.buffer), + ); + const adopted = new Map< + number, + { mappingAddress: number; mappingSize: number } + >(); + const released: Array<{ + address: number; + mappingAddress: number; + mappingSize: number; + }> = []; + const child = new DynamicLinker({ + memory: childMemory, + table: new WebAssembly.Table({ initial: 1, element: "anyfunc" }), + stackPointer: new WebAssembly.Global( + { value: "i32", mutable: true }, + 65536, + ), + adoptMemoryAllocation: (allocation) => { + adopted.set(allocation.address, { + mappingAddress: allocation.mappingAddress, + mappingSize: allocation.mappingSize, + }); + }, + deallocateMemory: (address) => { + const mapping = adopted.get(address); + if (!mapping) throw new Error("missing adopted mmap owner"); + released.push({ address, ...mapping }); + adopted.delete(address); + }, + globalSymbols: new Map(), + got: new Map(), + loadedLibraries: new Map(), + }); + child.reconcileForkModules(forkState); + child.reconcileForkHandleState(forkState); + expect(adopted.get(0x3000)).toEqual({ + mappingAddress: 0x2ff0, + mappingSize: forkState.libraries[0]!.allocations![0]!.mappingSize, + }); + expect(child.dlclose(handle)).toBe(0); + expect(released).toEqual([{ + address: 0x3000, + mappingAddress: 0x2ff0, + mappingSize: forkState.libraries[0]!.allocations![0]!.mappingSize, + }]); + expect(adopted.size).toBe(0); + }); + it("dlerror reports failures", () => { const linker = createLinker(); diff --git a/host/test/fixtures/fork-externref-import-worker.ts b/host/test/fixtures/fork-externref-import-worker.ts new file mode 100644 index 0000000000..95275c6c1c --- /dev/null +++ b/host/test/fixtures/fork-externref-import-worker.ts @@ -0,0 +1,68 @@ +import { parentPort, workerData } from "node:worker_threads"; +import { + defineForkExternrefImport, + type ForkExternrefImportBinding, + type ForkExternrefImportWake, + ForkExternrefImportWorkerCaller, +} from "../../src/fork-externref-import-mailbox"; +import { ForkExternrefTokenCache } from "../../src/fork-reference-broker"; + +const { + mailbox, + binding, + inputHandle, +} = workerData as { + mailbox: SharedArrayBuffer; + binding: ForkExternrefImportBinding; + inputHandle: number; +}; + +const aliasDescriptor = defineForkExternrefImport( + 30, + ["externref", "i64"], + ["externref", "i64"], +); +const throwingDescriptor = defineForkExternrefImport( + 31, + ["i32"], + ["i32"], +); +const tokens = new ForkExternrefTokenCache(binding.generationId); +const caller = new ForkExternrefImportWorkerCaller( + mailbox, + binding, + tokens, + (wake: ForkExternrefImportWake) => { + parentPort!.postMessage({ type: "wake", wake }); + }, +); + +try { + const input = tokens.materialize(inputHandle); + const [alias, scalar] = caller.call( + aliasDescriptor, + [input, -9n], + ) as [unknown, bigint]; + + let exception: unknown; + try { + caller.call(throwingDescriptor, [7]); + throw new Error("owner import unexpectedly returned"); + } catch (error) { + exception = error; + } + + parentPort!.postMessage({ + type: "complete", + resultHandle: tokens.encode(alias), + resultScalar: scalar, + exceptionHandle: tokens.encode(exception), + }); +} catch (error) { + parentPort!.postMessage({ + type: "failed", + message: error instanceof Error + ? error.stack ?? error.message + : String(error), + }); +} diff --git a/host/test/fixtures/fork-worker-import-exception-worker.ts b/host/test/fixtures/fork-worker-import-exception-worker.ts new file mode 100644 index 0000000000..24df0908b4 --- /dev/null +++ b/host/test/fixtures/fork-worker-import-exception-worker.ts @@ -0,0 +1,78 @@ +import { parentPort, workerData } from "node:worker_threads"; +import { + defineForkExternrefImport, + type ForkExternrefImportBinding, +} from "../../src/fork-externref-import-mailbox"; +import { + type ForkHostImportWorkerInit, + ForkHostImportWorkerRuntime, +} from "../../src/fork-host-import-runtime"; +import { ForkExternrefTokenCache } from "../../src/fork-reference-broker"; + +if (!parentPort) throw new Error("fork exception fixture requires parentPort"); + +const data = workerData as { + readonly mode: "parent" | "child"; + readonly binding: ForkExternrefImportBinding; + readonly init: ForkHostImportWorkerInit; + readonly inheritedHandle?: number; +}; +const tokens = new ForkExternrefTokenCache(data.binding.generationId); +const runtime = new ForkHostImportWorkerRuntime( + data.init, + data.binding.pid, + data.binding.generationId, + tokens, + (wake) => parentPort.postMessage({ type: "wake", wake }), +); + +const echo = defineForkExternrefImport( + 77, + ["externref"], + ["externref"], +); + +try { + if (data.mode === "parent") { + const localOnly = Object.freeze({ + source: "parent Worker", + callback: () => 42, + }); + const wrapped = runtime.localExceptions.wrap(5, () => { + throw localOnly; + }); + let thrown: unknown; + try { + wrapped(); + throw new Error("expected Worker-local exception"); + } catch (value) { + thrown = value; + } + if (thrown !== localOnly) { + throw new Error("Worker-local import changed exception identity"); + } + const token = + runtime.localExceptions.normalizeUnclaimedForkException(thrown); + const handle = tokens.encode(token); + if (handle === null) { + throw new Error("parent Worker received a noncanonical token"); + } + parentPort.postMessage({ type: "complete", handle }); + } else { + if (data.inheritedHandle === undefined) { + throw new Error("child Worker is missing its inherited handle"); + } + const inherited = tokens.materialize(data.inheritedHandle); + const echoed = runtime.caller.call(echo, [inherited]); + const handle = tokens.encode(echoed); + if (handle === null) { + throw new Error("child Worker received a noncanonical echo"); + } + parentPort.postMessage({ type: "complete", handle }); + } +} catch (error) { + parentPort.postMessage({ + type: "failed", + message: error instanceof Error ? error.stack ?? error.message : String(error), + }); +} diff --git a/host/test/fixtures/gc-reference-state-fresh-worker-bytes.ts b/host/test/fixtures/gc-reference-state-fresh-worker-bytes.ts new file mode 100644 index 0000000000..49cba8ec3d --- /dev/null +++ b/host/test/fixtures/gc-reference-state-fresh-worker-bytes.ts @@ -0,0 +1,15 @@ +// WHY: the dev shell's WABT parser does not accept current typed-reference +// syntax even with --enable-gc. These are the Rust `wat` crate's deterministic +// bytes for the adjacent, reviewed gc-reference-state-fresh-worker.wat source. +// Node and browser integration tests share the exact input artifact. +export const RAW_GC_REFERENCE_STATE_FRESH_WORKER_HEX = [ + "0061736d01000000011e065f027f0163000160017f006000017f60017f017f60016400017f60000002520403656e76066d656d6f727902030180800103656e760e5f5f6368616e6e656c5f62617365037f01066b65726e656c0b6b65726e656c", + "5f657869740001066b65726e656c0b6b65726e656c5f666f726b0002030504020304050406016300010101060e02630001d0000b7f01418080040b072c030f5f5f737461636b5f706f696e74657203020d5f5f6162695f76657273696f6e0002", + "065f737461727400050ad202040400412b0ba60101027f23002101200141046a418b01360200200141086a2000ac370300200141106a428008370300200141186a4200370300200141206a4200370300200141286a4200370300200141306a42", + "0037030020014101fe17020020014101fe0002001a024003402001fe1002004101470d0120014101427ffe0102001a0c000b0b200141c0006a2802000440417f210205200141386a290300a721020b20014100fe17020020020b530201630002", + "7f2000100121022101200245044020012000d323012000d371410025002000d3712000fb02000041cd00467120002000fb020001d4d3712103200345044041db001000000b41001000000b20020b4f02016300017f41cd00d000fb0000210020", + "00d42000fb050001200024014100200026002000d41004210120011003200147044041dc001000000b418008280200044041dc001000000b41001000000b00e401046e616d65014204000b6b65726e656c5f65786974010b6b65726e656c5f66", + "6f726b030a776169745f6368696c640419666f726b5f776974685f7265666572656e63655f7374617465024003030300037069640104626173650206726573756c74040400046e6f64650107636172726965640203706964030576616c696405", + "0200046e6f6465010370696403130103020008636f6d706c65746501047761697404070100046e6f6465050e01000b73617665645f7461626c65072903000e5f5f6368616e6e656c5f6261736501057361766564020f5f5f737461636b5f706f", + "696e746572", +].join(""); diff --git a/host/test/fixtures/gc-reference-state-fresh-worker.wat b/host/test/fixtures/gc-reference-state-fresh-worker.wat new file mode 100644 index 0000000000..1b6028ffaf --- /dev/null +++ b/host/test/fixtures/gc-reference-state-fresh-worker.wat @@ -0,0 +1,230 @@ +;; ABI 43 real-worker integration fixture for activation-owned Wasm GC state. +;; +;; One cyclic object is aliased simultaneously by a reference parameter, an +;; operand-stack carryover across kernel_fork, a mutable reference global, and +;; a mutated reference table. A fresh child must rebuild one canonical local +;; identity for every alias; copying only linear memory cannot make any of +;; these module-instance values survive. +(module + (import "env" "memory" (memory 1 16384 shared)) + (import "env" "__channel_base" (global $__channel_base (mut i32))) + (import "kernel" "kernel_exit" (func $kernel_exit (param i32))) + (import "kernel" "kernel_fork" (func $kernel_fork (result i32))) + + (type $node + (struct + (field (mut i32)) + (field (mut (ref null $node))))) + + (global $saved (mut (ref null $node)) (ref.null $node)) + (table $saved_table 1 1 (ref null $node)) + (global $__stack_pointer (export "__stack_pointer") (mut i32) + (i32.const 65536)) + + (func (export "__abi_version") (result i32) + i32.const 43) + + (func $wait_child (param $pid i32) (result i32) + (local $base i32) + (local $result i32) + + global.get $__channel_base + local.set $base + + ;; SYS_wait4(pid, &status, 0, 0) + local.get $base + i32.const 4 + i32.add + i32.const 139 + i32.store + + local.get $base + i32.const 8 + i32.add + local.get $pid + i64.extend_i32_s + i64.store + + local.get $base + i32.const 16 + i32.add + i64.const 1024 + i64.store + + local.get $base + i32.const 24 + i32.add + i64.const 0 + i64.store + + local.get $base + i32.const 32 + i32.add + i64.const 0 + i64.store + + local.get $base + i32.const 40 + i32.add + i64.const 0 + i64.store + + local.get $base + i32.const 48 + i32.add + i64.const 0 + i64.store + + local.get $base + i32.const 1 + i32.atomic.store + local.get $base + i32.const 1 + memory.atomic.notify + drop + + block $complete + loop $wait + local.get $base + i32.atomic.load + i32.const 1 + i32.ne + br_if $complete + + local.get $base + i32.const 1 + i64.const -1 + memory.atomic.wait32 + drop + br $wait + end + end + + local.get $base + i32.const 64 + i32.add + i32.load + if + i32.const -1 + local.set $result + else + local.get $base + i32.const 56 + i32.add + i64.load + i32.wrap_i64 + local.set $result + end + + local.get $base + i32.const 0 + i32.atomic.store + + local.get $result) + + (func $fork_with_reference_state + (param $node (ref $node)) + (result i32) + (local $carried (ref null $node)) + (local $pid i32) + (local $valid i32) + + ;; Leave the reference below the fork result on the operand stack. This is + ;; a real call carryover, not merely a local that happens to stay live. + local.get $node + call $kernel_fork + local.set $pid + local.set $carried + + local.get $pid + i32.eqz + if + local.get $carried + local.get $node + ref.eq + + global.get $saved + local.get $node + ref.eq + i32.and + + i32.const 0 + table.get $saved_table + local.get $node + ref.eq + i32.and + + local.get $node + struct.get $node 0 + i32.const 77 + i32.eq + i32.and + + local.get $node + local.get $node + struct.get $node 1 + ref.as_non_null + ref.eq + i32.and + local.set $valid + + local.get $valid + i32.eqz + if + i32.const 91 + call $kernel_exit + unreachable + end + i32.const 0 + call $kernel_exit + unreachable + end + + local.get $pid) + + (func (export "_start") + (local $node (ref null $node)) + (local $pid i32) + + i32.const 77 + ref.null $node + struct.new $node + local.set $node + + local.get $node + ref.as_non_null + local.get $node + struct.set $node 1 + + local.get $node + global.set $saved + i32.const 0 + local.get $node + table.set $saved_table + + local.get $node + ref.as_non_null + call $fork_with_reference_state + local.set $pid + + local.get $pid + call $wait_child + local.get $pid + i32.ne + if + i32.const 92 + call $kernel_exit + unreachable + end + + i32.const 1024 + i32.load + if + i32.const 92 + call $kernel_exit + unreachable + end + + i32.const 0 + call $kernel_exit + unreachable)) diff --git a/host/test/fixtures/gc-transit-object.wat b/host/test/fixtures/gc-transit-object.wat new file mode 100644 index 0000000000..08cdd6981e --- /dev/null +++ b/host/test/fixtures/gc-transit-object.wat @@ -0,0 +1,9 @@ +(module + (type $box (struct (field (mut i32)))) + (import "env" "__wpk_fork_ref_gc_transit" + (table $transit 1 (ref null any))) + (func (export "publish") (param $value i32) + i32.const 0 + local.get $value + struct.new $box + table.set $transit)) diff --git a/host/test/fixtures/reference-catch-payload-fresh-worker.wat b/host/test/fixtures/reference-catch-payload-fresh-worker.wat new file mode 100644 index 0000000000..36fca1d75b --- /dev/null +++ b/host/test/fixtures/reference-catch-payload-fresh-worker.wat @@ -0,0 +1,223 @@ +;; ABI 43 integration fixture for reference-bearing exception payloads. +;; +;; Each path catches a reference payload through CatchRef, forks from that +;; handler, and waits for the child. The funcref is non-null; the externref +;; exercises the nullable/null recipe. The child has a fresh Wasm instance, so +;; success requires the complete exception recipe to reconstruct the payload +;; and create a fresh child-local exnref. +(module + (import "env" "memory" (memory 1 16384 shared)) + (import "env" "__channel_base" (global $__channel_base (mut i32))) + (import "kernel" "kernel_exit" (func $kernel_exit (param i32))) + (import "kernel" "kernel_fork" (func $kernel_fork (result i32))) + + (tag $func_payload (param funcref)) + (tag $extern_payload (param externref)) + (type $sentinel_type (func (result i32))) + (table $verify 1 funcref) + + (global $__stack_pointer (export "__stack_pointer") (mut i32) + (i32.const 65536)) + + (func (export "__abi_version") (result i32) + i32.const 43) + + (func $sentinel (type $sentinel_type) (result i32) + i32.const 77) + (elem declare func $sentinel) + + (func $wait_child (param $pid i32) (result i32) + (local $base i32) + (local $result i32) + + global.get $__channel_base + local.set $base + + ;; SYS_wait4(pid, &status, 0, 0) + local.get $base + i32.const 4 + i32.add + i32.const 139 + i32.store + + local.get $base + i32.const 8 + i32.add + local.get $pid + i64.extend_i32_s + i64.store + + local.get $base + i32.const 16 + i32.add + i64.const 1024 + i64.store + + local.get $base + i32.const 24 + i32.add + i64.const 0 + i64.store + + local.get $base + i32.const 32 + i32.add + i64.const 0 + i64.store + + local.get $base + i32.const 40 + i32.add + i64.const 0 + i64.store + + local.get $base + i32.const 48 + i32.add + i64.const 0 + i64.store + + local.get $base + i32.const 1 + i32.atomic.store + local.get $base + i32.const 1 + memory.atomic.notify + drop + + block $complete + loop $wait + local.get $base + i32.atomic.load + i32.const 1 + i32.ne + br_if $complete + + local.get $base + i32.const 1 + i64.const -1 + memory.atomic.wait32 + drop + br $wait + end + end + + local.get $base + i32.const 64 + i32.add + i32.load + if + i32.const -1 + local.set $result + else + local.get $base + i32.const 56 + i32.add + i64.load + i32.wrap_i64 + local.set $result + end + + local.get $base + i32.const 0 + i32.atomic.store + + local.get $result) + + (func $require_child_ok (param $pid i32) + local.get $pid + call $wait_child + local.get $pid + i32.ne + if + i32.const 92 + call $kernel_exit + unreachable + end + + i32.const 1024 + i32.load + if + i32.const 92 + call $kernel_exit + unreachable + end) + + (func $test_funcref_payload + (local $caught funcref) + (local $pid i32) + + (block $handler (result funcref exnref) + (try_table (result funcref exnref) + (catch_ref $func_payload $handler) + ref.func $sentinel + throw $func_payload + unreachable)) + drop + local.set $caught + + call $kernel_fork + local.set $pid + local.get $pid + i32.eqz + if + i32.const 0 + local.get $caught + table.set $verify + i32.const 0 + call_indirect $verify (type $sentinel_type) + i32.const 77 + i32.ne + if + i32.const 91 + call $kernel_exit + unreachable + end + i32.const 0 + call $kernel_exit + unreachable + end + + local.get $pid + call $require_child_ok) + + (func $test_externref_payload + (local $caught externref) + (local $pid i32) + + (block $handler (result externref exnref) + (try_table (result externref exnref) + (catch_ref $extern_payload $handler) + ref.null extern + throw $extern_payload + unreachable)) + drop + local.set $caught + + call $kernel_fork + local.set $pid + local.get $pid + i32.eqz + if + local.get $caught + ref.is_null + i32.eqz + if + i32.const 93 + call $kernel_exit + unreachable + end + i32.const 0 + call $kernel_exit + unreachable + end + + local.get $pid + call $require_child_ok) + + (func (export "_start") + call $test_funcref_payload + call $test_externref_payload + i32.const 0 + call $kernel_exit + unreachable)) diff --git a/host/test/fork-abort-unwind.test.ts b/host/test/fork-abort-unwind.test.ts index a2e12fb36b..fe4a3f3d5b 100644 --- a/host/test/fork-abort-unwind.test.ts +++ b/host/test/fork-abort-unwind.test.ts @@ -9,6 +9,8 @@ import { LinkedForkContinuation, readLinkedFrameFormat, } from "../src/fork-continuation"; +import { ForkModuleStateArena } from "../src/fork-module-state"; +import { SingleActivationForkRuntime } from "./fork-instrument-runtime-harness"; describe("instrumented ABORT_UNWINDING", () => { it("reconstructs committed inner frames and permits a later successful fork", () => { @@ -53,10 +55,10 @@ describe("instrumented ABORT_UNWINDING", () => { const module = new WebAssembly.Module(bytes); const memory = new WebAssembly.Memory({ initial: 8 }); let instance: WebAssembly.Instance; - let moduleBuffer = 0; let forkResult = 0; let failGrowth = true; let nextAddress = 65_536; + let nextArenaAddress = 5 * 65_536; const released: Array<{ addr: number; size: number }> = []; const continuation = new LinkedForkContinuation( memory, @@ -72,41 +74,51 @@ describe("instrumented ABORT_UNWINDING", () => { (addr, size) => released.push({ addr, size }), "abort-e2e", ); + const runtime = new SingleActivationForkRuntime({ + module, + moduleBytes: bytes, + memory, + continuation, + newArena: () => new ForkModuleStateArena( + memory, + 4, + (size) => { + const address = nextArenaAddress; + nextArenaAddress += size; + const missing = nextArenaAddress - memory.buffer.byteLength; + if (missing > 0) memory.grow(Math.ceil(missing / 65_536)); + return address; + }, + () => {}, + "abort-e2e module state", + ), + label: "abort-e2e", + }); const imports = { env: { memory, - __wpk_fork_frame_reserve: (size: number) => { - const frame = continuation.reserveFrame(size); - if (frame === 0) { - (instance.exports.wpk_fork_abort_begin as (addr: number) => void)(moduleBuffer); - } - return frame; - }, - __wpk_fork_frame_commit: (payload: number) => continuation.commitFrame(payload), - __wpk_fork_frame_next: (size: number) => continuation.nextFrame(size), + ...runtime.envImports, }, kernel: { kernel_fork: () => { - const state = (instance.exports.wpk_fork_state as () => number)(); - if (state === 2) { - (instance.exports.wpk_fork_rewind_end as () => void)(); - continuation.finishReplayAndRelease(); + const phase = runtime.coordinator.phaseName(); + if (phase === "parent-replay") { + runtime.coordinator.finishReplay(); return forkResult; } - if (state === 3) { + if (phase === "abort-replay") { const errno = continuation.abortErrno(); - (instance.exports.wpk_fork_abort_end as () => void)(); - continuation.finishAbortReplayAndRelease(); + runtime.coordinator.finishAbortReplay(); return -errno; } - moduleBuffer = Number(continuation.beginUnwind()); - (instance.exports.wpk_fork_unwind_begin as (addr: number) => void)(moduleBuffer); + runtime.beginCapture(); return 0; }, }, }; instance = new WebAssembly.Instance(module, imports); + runtime.register(instance); const run = instance.exports.run as () => number; const state = instance.exports.wpk_fork_state as () => number; @@ -119,25 +131,21 @@ describe("instrumented ABORT_UNWINDING", () => { // SYS_FORK result after a complete unwind must replay to the guest. failGrowth = false; nextAddress = 65_536; - expect(run()).toBe(0); // transformed unwind returns the result-type default + runtime.expectCaptureTransport(run); expect(state()).toBe(1); - (instance.exports.wpk_fork_unwind_end as () => void)(); - continuation.finishUnwind(); + runtime.coordinator.sealCapture(); forkResult = -11; - continuation.beginReplay(); - (instance.exports.wpk_fork_rewind_begin as (addr: number) => void)(moduleBuffer); + runtime.coordinator.beginParentReplay(); expect(run()).toBe(-4); expect(state()).toBe(0); expect(continuation.hasActiveContinuation()).toBe(false); // A later independent fork can still complete successfully. nextAddress = 65_536; - expect(run()).toBe(0); - (instance.exports.wpk_fork_unwind_end as () => void)(); - continuation.finishUnwind(); + runtime.expectCaptureTransport(run); + runtime.coordinator.sealCapture(); forkResult = 123; - continuation.beginReplay(); - (instance.exports.wpk_fork_rewind_begin as (addr: number) => void)(moduleBuffer); + runtime.coordinator.beginParentReplay(); expect(run()).toBe(130); expect(state()).toBe(0); expect(continuation.hasActiveContinuation()).toBe(false); @@ -209,13 +217,15 @@ describe("instrumented ABORT_UNWINDING", () => { instrumentedPath, ]); - const module = new WebAssembly.Module(readFileSync(instrumentedPath)); + const bytes = readFileSync(instrumentedPath); + const module = new WebAssembly.Module(bytes); const memory = new WebAssembly.Memory({ initial: 8 }); const view = new DataView(memory.buffer); let instance: WebAssembly.Instance; let moduleBuffer = 0; let failGrowth = true; let nextAddress = 65_536; + let nextArenaAddress = 5 * 65_536; let abortCommits = 0; let successfulCommits = 0; let lowMemoryUntouched = false; @@ -254,41 +264,52 @@ describe("instrumented ABORT_UNWINDING", () => { (addr, size) => released.push({ addr, size }), "abort-catch-e2e", ); + const runtime = new SingleActivationForkRuntime({ + module, + moduleBytes: bytes, + memory, + continuation, + newArena: () => new ForkModuleStateArena( + memory, + 4, + (size) => { + const address = nextArenaAddress; + nextArenaAddress += size; + const missing = nextArenaAddress - memory.buffer.byteLength; + if (missing > 0) memory.grow(Math.ceil(missing / 65_536)); + return address; + }, + () => {}, + "abort-catch-e2e module state", + ), + label: "abort-catch-e2e", + }); + const coordinatedCommit = runtime.envImports.__wpk_fork_frame_commit as + (payload: number) => void; const imports = { env: { memory, - __wpk_fork_frame_reserve: (size: number) => { - const frame = continuation.reserveFrame(size); - if (frame === 0) { - (instance.exports.wpk_fork_abort_begin as (addr: number) => void)( - moduleBuffer, - ); - } - return frame; - }, + ...runtime.envImports, __wpk_fork_frame_commit: (payload: number) => { - continuation.commitFrame(payload); + coordinatedCommit(payload); if (failGrowth) { abortCommits++; } else { successfulCommits++; } }, - __wpk_fork_frame_next: (size: number) => continuation.nextFrame(size), }, kernel: { kernel_fork: () => { - const state = (instance.exports.wpk_fork_state as () => number)(); - if (state === 2) { - (instance.exports.wpk_fork_rewind_end as () => void)(); - continuation.finishReplayAndRelease(); + const phase = runtime.coordinator.phaseName(); + if (phase === "parent-replay") { + runtime.coordinator.finishReplay(); return 17; } - if (state === 3) { + if (phase === "abort-replay") { const errno = continuation.abortErrno(); - (instance.exports.wpk_fork_abort_end as () => void)(); - continuation.finishAbortReplayAndRelease(); + runtime.coordinator.finishAbortReplay(); return -errno; } @@ -298,15 +319,14 @@ describe("instrumented ABORT_UNWINDING", () => { lowMemoryUntouched = scratchWordsAreUntouched(0); retiredStorageUntouched = scratchWordsAreUntouched(moduleBuffer); } - moduleBuffer = Number(continuation.beginUnwind()); - (instance.exports.wpk_fork_unwind_begin as (addr: number) => void)( - moduleBuffer, - ); + runtime.beginCapture(); + moduleBuffer = runtime.coordinator.rootFor(0); return 0; }, }, }; instance = new WebAssembly.Instance(module, imports); + runtime.register(instance); const run = instance.exports.run as () => number; const state = instance.exports.wpk_fork_state as () => number; @@ -326,15 +346,10 @@ describe("instrumented ABORT_UNWINDING", () => { fillScratchWords(moduleBuffer); failGrowth = false; nextAddress = 65_536; - const unwindResult = run(); - expect(unwindResult).toBe(0); + runtime.expectCaptureTransport(run); expect(state()).toBe(1); - (instance.exports.wpk_fork_unwind_end as () => void)(); - continuation.finishUnwind(); - continuation.beginReplay(); - (instance.exports.wpk_fork_rewind_begin as (addr: number) => void)( - moduleBuffer, - ); + runtime.coordinator.sealCapture(); + runtime.coordinator.beginParentReplay(); const successfulResult = run(); expect({ diff --git a/host/test/fork-activation-registry.test.ts b/host/test/fork-activation-registry.test.ts new file mode 100644 index 0000000000..8d5e35f14d --- /dev/null +++ b/host/test/fork-activation-registry.test.ts @@ -0,0 +1,433 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { + buildForkActivationStateImports, + ForkActivationRegistry, + type ForkActivationRegistration, +} from "../src/fork-activation-registry"; +import { + ForkModuleStateArena, + ForkTableDirtyTracker, +} from "../src/fork-module-state"; +import { + WPK_FORK_REFERENCE_IMPORT_GC_CAPTURE_LAYOUT, +} from "../src/generated/abi"; + +const PAGE_SIZE = 65_536; + +function makeArena(memory: WebAssembly.Memory, label: string): ForkModuleStateArena { + let next = PAGE_SIZE; + return new ForkModuleStateArena( + memory, + 4, + (size) => { + const address = next; + next += size; + if (next > memory.buffer.byteLength) { + memory.grow(Math.ceil((next - memory.buffer.byteLength) / PAGE_SIZE)); + } + return address; + }, + () => {}, + label, + ); +} + +function emptyCatalog(): WebAssembly.Table { + return new WebAssembly.Table({ + element: "anyfunc", + initial: 0, + maximum: 0, + }); +} + +function emptyStaticRootCatalog(): WebAssembly.Table { + return new WebAssembly.Table({ + element: "externref", + initial: 0, + maximum: 0, + }); +} + +function funcrefTableActivation(): { + readonly instance: WebAssembly.Instance; + readonly functionCatalog: WebAssembly.Table; + readonly mutableTable: WebAssembly.Table; +} { + const dir = mkdtempSync(join(tmpdir(), "kandelo-table-patch-")); + const wat = join(dir, "table-patch.wat"); + const wasm = join(dir, "table-patch.wasm"); + writeFileSync(wat, `(module + (table $catalog (export "__wpk_fork_function_catalog") 2 2 funcref) + (table $mutable (export "__wpk_fork_table_3") 2 4 funcref) + (func $first (result i32) i32.const 17) + (func $second (result i32) i32.const 29) + (elem (table $catalog) (i32.const 0) func $first $second) + )`); + execFileSync("wat2wasm", [wat, "-o", wasm]); + const instance = new WebAssembly.Instance( + new WebAssembly.Module(readFileSync(wasm)), + ); + return { + instance, + functionCatalog: + instance.exports.__wpk_fork_function_catalog as WebAssembly.Table, + mutableTable: instance.exports.__wpk_fork_table_3 as WebAssembly.Table, + }; +} + +function registration( + activationId: number, + calls: string[], + options: { + clear?: () => void; + abort?: () => void; + } = {}, +): ForkActivationRegistration { + return { + activationId, + instance: { exports: {} } as unknown as WebAssembly.Instance, + templateId: new Uint8Array(32).fill(activationId + 1), + functionCatalog: emptyCatalog(), + staticRootCatalog: emptyStaticRootCatalog(), + staticRootHarvest: () => {}, + tableDirty: new ForkTableDirtyTracker(), + moduleState: { + bootstrap: () => { calls.push(`bootstrap:${activationId}`); }, + save: (id) => { calls.push(`save:${id}`); }, + restore: (id) => { calls.push(`restore:${id}`); }, + finishRestore: (id) => { calls.push(`finish-restore:${id}`); }, + saveTables: (id) => { calls.push(`save-tables:${id}`); }, + restoreTables: (id) => { calls.push(`restore-tables:${id}`); }, + }, + typedReferenceProvider: { + clear: options.clear ?? (() => { calls.push(`clear:${activationId}`); }), + abort: options.abort ?? (() => { calls.push(`abort:${activationId}`); }), + }, + }; +} + +function registry(memory: WebAssembly.Memory, label: string): ForkActivationRegistry { + return new ForkActivationRegistry( + memory, + { + capture: () => { + throw new Error("fixture has no externrefs"); + }, + materialize: () => { + throw new Error("fixture has no externrefs"); + }, + }, + label, + ); +} + +describe("ForkActivationRegistry", () => { + it("binds GC layout capture in the generated slot/activation/layout order", () => { + const memory = new WebAssembly.Memory({ initial: 2 }); + const owner = registry(memory, "GC capture import"); + const capture = vi.spyOn(owner, "captureGcLayout").mockReturnValue(23); + const imports = buildForkActivationStateImports(7, owner); + const captureLayout = imports[ + WPK_FORK_REFERENCE_IMPORT_GC_CAPTURE_LAYOUT + ] as CallableFunction; + + expect(captureLayout(5, 7, 11)).toBe(23); + expect(capture).toHaveBeenCalledWith(7, 5, 11); + expect(() => captureLayout(7, 5, 11)).toThrow( + "activation 7 cannot select GC layout for activation 5", + ); + }); + + it("captures and restores every activation in deterministic id order", () => { + const memory = new WebAssembly.Memory({ initial: 8 }); + const calls: string[] = []; + const parent = registry(memory, "parent"); + parent.registerActivation(registration(7, calls)); + parent.registerActivation(registration(0, calls)); + parent.bootstrapActivation(0); + parent.bootstrapActivation(7); + + const arena = makeArena(memory, "parent arena"); + arena.begin(); + parent.beginCapture(arena); + expect(calls).toEqual([ + "bootstrap:0", + "bootstrap:7", + "save:0", + "save:7", + ]); + parent.sealCapture(); + parent.beginParentReplay(); + parent.restoreModuleState(); + parent.finishReplay(); + expect(calls.slice(-6)).toEqual([ + "restore:0", + "restore:7", + "finish-restore:0", + "finish-restore:7", + "clear:0", + "clear:7", + ]); + expect(parent.phaseName()).toBe("idle"); + }); + + it("attaches a fresh child only after the complete activation set exists", () => { + const parentMemory = new WebAssembly.Memory({ initial: 8 }); + const parent = registry(parentMemory, "parent"); + parent.registerActivation(registration(0, [])); + parent.registerActivation(registration(2, [])); + const parentArena = makeArena(parentMemory, "parent arena"); + const root = parentArena.begin(); + parent.beginCapture(parentArena); + parent.sealCapture(); + + const childMemory = new WebAssembly.Memory({ + initial: parentMemory.buffer.byteLength / PAGE_SIZE, + }); + new Uint8Array(childMemory.buffer).set(new Uint8Array(parentMemory.buffer)); + const childArena = new ForkModuleStateArena( + childMemory, + 4, + () => { throw new Error("attached child arena must not allocate"); }, + () => {}, + "child arena", + ); + childArena.attach(root); + + const childCalls: string[] = []; + const child = registry(childMemory, "child"); + child.registerActivation(registration(0, childCalls)); + expect(() => child.attachChild(childArena)).toThrow( + "copied module activations do not match", + ); + child.registerActivation(registration(2, childCalls)); + child.attachChild(childArena); + child.currentReferences().materializeAllTyped = () => { + childCalls.push("materialize-typed"); + }; + child.restoreModuleState(); + child.finishReplay(); + expect(childCalls).toEqual([ + "materialize-typed", + "restore:0", + "restore:2", + "finish-restore:0", + "finish-restore:2", + "clear:0", + "clear:2", + ]); + }); + + it("drops every provider root even when one cleanup reports an error", () => { + const memory = new WebAssembly.Memory({ initial: 8 }); + const calls: string[] = []; + const owner = registry(memory, "cleanup"); + owner.registerActivation(registration(0, calls, { + abort: () => { + calls.push("abort:0"); + throw new Error("first cleanup failed"); + }, + })); + owner.registerActivation(registration(1, calls)); + const arena = makeArena(memory, "cleanup arena"); + arena.begin(); + owner.beginCapture(arena); + + expect(() => owner.abort()).toThrow("first cleanup failed"); + expect(calls.slice(-2)).toEqual(["abort:0", "abort:1"]); + expect(owner.phaseName()).toBe("idle"); + }); + + it("keeps weak static identity across later forks and forgets it on unregister", () => { + const memory = new WebAssembly.Memory({ initial: 8 }); + const root = Object.freeze({ segment: "already dropped" }); + const harvest = new WebAssembly.Table({ + element: "externref", + initial: 1, + maximum: 1, + }); + harvest.set(0, root); + const owner = registry(memory, "static roots"); + owner.registerActivation({ + ...registration(0, []), + staticRootCatalog: harvest, + staticRootHarvest: () => {}, + }); + expect(harvest.get(0)).toBeNull(); + + for (const label of ["first fork", "later fork"]) { + const arena = makeArena(memory, label); + arena.begin(); + owner.beginCapture(arena); + expect(owner.currentReferences().encodeExternref(root)).toBe(1); + owner.abort(); + } + + owner.unregisterActivation(0); + const afterUnload = makeArena(memory, "after unload"); + afterUnload.begin(); + owner.beginCapture(afterUnload); + expect(() => owner.currentReferences().encodeExternref(root)).toThrow( + "fixture has no externrefs", + ); + owner.abort(); + }); + + it("keeps table owner ordinals activation-local", () => { + const memory = new WebAssembly.Memory({ initial: 2 }); + const owner = registry(memory, "tables"); + owner.registerActivation(registration(0, [])); + owner.registerActivation(registration(1, [])); + owner.tableDirty(0).markPages(3, 1n, 1n); + owner.tableDirty(1).markPages(3, 9n, 1n); + expect(owner.tableDirty(0).pageAt(3, 0)).toBe(1n); + expect(owner.tableDirty(1).pageAt(3, 0)).toBe(9n); + }); + + it("attributes host table mutations to every catalog alias", () => { + const memory = new WebAssembly.Memory({ initial: 2 }); + const table = new WebAssembly.Table({ + element: "anyfunc", + initial: 2_048, + maximum: 4_096, + }); + const owner = registry(memory, "host table mutation"); + const first = registration(0, []); + const second = registration(1, []); + owner.registerActivation({ + ...first, + instance: { + exports: { __wpk_fork_table_3: table }, + } as unknown as WebAssembly.Instance, + }); + owner.registerActivation({ + ...second, + instance: { + exports: { __wpk_fork_table_9: table }, + } as unknown as WebAssembly.Instance, + }); + expect(owner.tableDirty(0).ownsState(3)).toBe(true); + expect(owner.tableDirty(1).ownsState(9)).toBe(false); + + // The range crosses the ABI-defined 1,024-entry sparse-page boundary. + owner.markTableMutation(table, 1_023, 2); + for (const [activationId, tableOwner] of [[0, 3], [1, 9]] as const) { + expect(owner.tableDirty(activationId).pageCount(tableOwner)).toBe(2); + expect(owner.tableDirty(activationId).pageAt(tableOwner, 0)).toBe(0n); + expect(owner.tableDirty(activationId).pageAt(tableOwner, 1)).toBe(1n); + } + + // Catalog removal must neither retain nor keep writing the unloaded + // activation's coordinate. + owner.unregisterActivation(0); + expect(owner.tableDirty(1).ownsState(9)).toBe(true); + owner.markTableMutation(table, 2_048, 1); + expect(owner.tableDirty(1).pageAt(9, 2)).toBe(2n); + }); + + it("rejects host mutations of tables outside activation catalogs", () => { + const memory = new WebAssembly.Memory({ initial: 2 }); + const owner = registry(memory, "unknown host table"); + const table = new WebAssembly.Table({ + element: "anyfunc", + initial: 1, + maximum: 1, + }); + expect(() => owner.markTableMutation(table, 0, 1)).toThrow( + "outside the registered fork catalogs", + ); + }); + + it("replays funcref/null table patches through fresh activation catalogs", () => { + const parentMemory = new WebAssembly.Memory({ initial: 2 }); + const parent = registry(parentMemory, "parent table patch"); + const parentModule = funcrefTableActivation(); + parent.registerActivation({ + ...registration(0, []), + instance: parentModule.instance, + functionCatalog: parentModule.functionCatalog, + }); + // A second activation aliases the same physical process Table. The patch + // target deliberately uses that non-canonical coordinate. + parent.registerActivation({ + ...registration(1, []), + instance: { + exports: { __wpk_fork_table_9: parentModule.mutableTable }, + } as unknown as WebAssembly.Instance, + }); + + const parentFirst = parentModule.functionCatalog.get(0); + const parentSecond = parentModule.functionCatalog.get(1); + parentModule.mutableTable.set(0, parentFirst); + parentModule.mutableTable.set(1, parentFirst); + const first = parent.captureFuncrefTablePatch(1, 9, 0, 2); + expect(first).toEqual({ + activationId: 1, + ownerId: 9, + start: 0, + tableLength: 2, + runs: [{ + length: 2, + function: { activationId: 0, ordinal: 0 }, + }], + }); + parentModule.mutableTable.grow(2, parentSecond); + const growth = parent.captureFuncrefTablePatch(1, 9, 2, 2); + + const childMemory = new WebAssembly.Memory({ initial: 2 }); + const child = registry(childMemory, "child table patch"); + const childModule = funcrefTableActivation(); + child.registerActivation({ + ...registration(0, []), + instance: childModule.instance, + functionCatalog: childModule.functionCatalog, + }); + child.registerActivation({ + ...registration(1, []), + instance: { + exports: { __wpk_fork_table_9: childModule.mutableTable }, + } as unknown as WebAssembly.Instance, + }); + child.applyFuncrefTablePatch({ ...first!, generation: 1 }); + child.applyFuncrefTablePatch({ ...growth!, generation: 2 }); + + expect(childModule.mutableTable.length).toBe(4); + expect(childModule.mutableTable.get(0)).toBe( + childModule.functionCatalog.get(0), + ); + expect(childModule.mutableTable.get(1)).toBe( + childModule.functionCatalog.get(0), + ); + expect(childModule.mutableTable.get(2)).toBe( + childModule.functionCatalog.get(1), + ); + expect(childModule.mutableTable.get(3)).toBe( + childModule.functionCatalog.get(1), + ); + expect(childModule.mutableTable.get(0)).not.toBe(parentFirst); + expect(child.tableDirty(0).pageCount(3)).toBe(1); + expect(child.tableDirty(1).pageCount(9)).toBe(1); + }); + + it("routes non-funcref table values to the typed checkpoint", () => { + const memory = new WebAssembly.Memory({ initial: 2 }); + const owner = registry(memory, "typed table fallback"); + const table = new WebAssembly.Table({ + element: "externref", + initial: 1, + maximum: 1, + }); + table.set(0, Object.freeze({ processHandle: 47 })); + owner.registerActivation({ + ...registration(0, []), + instance: { + exports: { __wpk_fork_table_4: table }, + } as unknown as WebAssembly.Instance, + }); + expect(owner.captureFuncrefTablePatch(0, 4, 0, 1)).toBeNull(); + }); +}); diff --git a/host/test/fork-anyref-transit.test.ts b/host/test/fork-anyref-transit.test.ts new file mode 100644 index 0000000000..933b9596b4 --- /dev/null +++ b/host/test/fork-anyref-transit.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + FORK_ANYREF_TRANSIT_IMPORT, + ForkAnyrefTransitTable, + forkAnyrefTransitProviderBytes, +} from "../src/fork-anyref-transit"; + +describe("ForkAnyrefTransitTable", () => { + it("uses a closed audited Wasm provider with the exact table type", () => { + const bytes = forkAnyrefTransitProviderBytes(); + const module = new WebAssembly.Module(bytes as BufferSource); + + expect(WebAssembly.Module.imports(module)).toEqual([]); + expect(WebAssembly.Module.exports(module)).toEqual([ + { + name: FORK_ANYREF_TRANSIT_IMPORT, + kind: "table", + }, + { + name: `${FORK_ANYREF_TRANSIT_IMPORT}_clear`, + kind: "function", + }, + ]); + }); + + it("clears every grown slot and isolates workers", () => { + const first = new ForkAnyrefTransitTable(); + const second = new ForkAnyrefTransitTable(); + + expect(first.table).not.toBe(second.table); + expect(first.table.length).toBe(1); + first.table.grow(3); + expect(first.table.length).toBe(4); + + first.clear(); + expect( + Array.from( + { length: first.table.length }, + (_, index) => first.table.get(index), + ), + ).toEqual([null, null, null, null]); + expect(second.table.length).toBe(1); + }); + + it("does not expose mutable provider bytes", () => { + const first = forkAnyrefTransitProviderBytes(); + first[0] = 0xff; + expect(forkAnyrefTransitProviderBytes()[0]).toBe(0x00); + }); +}); diff --git a/host/test/fork-artifact-gc-types.test.ts b/host/test/fork-artifact-gc-types.test.ts new file mode 100644 index 0000000000..0923fed44f --- /dev/null +++ b/host/test/fork-artifact-gc-types.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; + +import { + detectPtrWidth, + describeWasmArtifactPolicyFailures, + readWasmImportNames, + wasmImportsKernelFork, +} from "../src/constants"; + +function uleb(value: number): number[] { + const bytes: number[] = []; + do { + let byte = value & 0x7f; + value >>>= 7; + if (value !== 0) byte |= 0x80; + bytes.push(byte); + } while (value !== 0); + return bytes; +} + +function name(value: string): number[] { + const bytes = new TextEncoder().encode(value); + return [...uleb(bytes.byteLength), ...bytes]; +} + +function section(id: number, payload: number[]): number[] { + return [id, ...uleb(payload.length), ...payload]; +} + +/** + * A structurally parseable module whose function type follows a recursive GC + * group. The fork artifact is intentionally incomplete: this fixture proves + * the policy reader reports the missing ABI contract instead of losing type + * indices or treating the struct/array definitions as malformed functions. + */ +function gcForkImportFixture(): ArrayBuffer { + const typeSection = [ + ...uleb(1), // one explicit recursive group + 0x4e, + ...uleb(3), + // type 0: (struct (field (mut (ref null 0)))) + 0x5f, ...uleb(1), 0x63, 0x00, 0x01, + // type 1: (array (mut i32)) + 0x5e, 0x7f, 0x01, + // type 2: (func (param (ref null 0)) (result i32)) + 0x60, ...uleb(1), 0x63, 0x00, ...uleb(1), 0x7f, + ]; + const imports = [ + ...uleb(4), + ...name("kernel"), ...name("kernel_fork"), 0x00, ...uleb(2), + ...name("env"), ...name("gc_table"), 0x01, + 0x63, 0x00, // concrete nullable table reference + 0x00, ...uleb(1), // limits + ...name("env"), ...name("gc_global"), 0x03, + 0x63, 0x00, // concrete nullable global reference + 0x01, // mutable + ...name("env"), ...name("memory"), 0x02, + 0x04, ...uleb(1), // memory64 limits + ]; + return new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, + 0x01, 0x00, 0x00, 0x00, + ...section(1, typeSection), + ...section(2, imports), + ]).buffer; +} + +describe("fork artifact parsing with recursive GC types", () => { + it("keeps function type indices aligned across struct and array types", () => { + const wasm = gcForkImportFixture(); + + expect(readWasmImportNames(wasm)).toEqual([ + "kernel.kernel_fork", + "env.gc_table", + "env.gc_global", + "env.memory", + ]); + expect(wasmImportsKernelFork(wasm)).toBe(true); + expect(detectPtrWidth(wasm)).toBe(8); + + const failures = describeWasmArtifactPolicyFailures(wasm); + expect(failures.join("\n")).not.toContain("cannot validate"); + expect(failures.join("\n")).not.toContain("non-function type"); + expect(failures).toContain( + "missing required kandelo.wpk_fork.capabilities capability", + ); + }); +}); diff --git a/host/test/fork-continuation.test.ts b/host/test/fork-continuation.test.ts index c0d54765ae..cfee45c449 100644 --- a/host/test/fork-continuation.test.ts +++ b/host/test/fork-continuation.test.ts @@ -210,7 +210,10 @@ describe("LinkedForkContinuation", () => { "child", ); child.attachForReplay(moduleBuffer); + const peekOuter = Number(child.peekFrame(24)); + expect(Number(child.peekFrame(24))).toBe(peekOuter); const replayOuter = Number(child.nextFrame(24)); + expect(replayOuter).toBe(peekOuter); const replayInner = Number(child.nextFrame(16)); expect(new Uint8Array(childMemory.buffer, replayOuter, 24)).toEqual( new Uint8Array(24).fill(0x22), diff --git a/host/test/fork-dlopen-replay-e2e.test.ts b/host/test/fork-dlopen-replay-e2e.test.ts index 7a5b0375d8..ad7960c8da 100644 --- a/host/test/fork-dlopen-replay-e2e.test.ts +++ b/host/test/fork-dlopen-replay-e2e.test.ts @@ -89,16 +89,21 @@ function buildSharedLib(source: string, name: string): string { const objPath = join(BUILD_DIR, `${name}.o`); const soPath = join(BUILD_DIR, `${name}.so`); - writeFileSync(srcPath, source); + writeFileSync(srcPath, `${source} + #include "abi_constants.h" + __attribute__((export_name("__abi_version"))) + unsigned __abi_version(void) { return WASM_POSIX_ABI_VERSION; } + `); execSync( - `${CLANG} --target=wasm32-unknown-unknown -fPIC -O2 -matomics -mbulk-memory -c ${srcPath} -o ${objPath}`, + `${CLANG} --target=wasm32-unknown-unknown -fPIC -O2 -matomics -mbulk-memory -I${GLUE_DIR} -c ${srcPath} -o ${objPath}`, { stdio: "pipe" }, ); execSync( `${WASM_LD} --experimental-pic --shared --shared-memory --export-all --allow-undefined -o ${soPath} ${objPath}`, { stdio: "pipe" }, ); + execSync(`${FORK_INSTRUMENT} ${soPath} -o ${soPath}`, { stdio: "pipe" }); return soPath; } @@ -109,7 +114,11 @@ function buildCppSharedLib(source: string, name: string): string { const srcPath = join(BUILD_DIR, `${name}.cpp`); const objPath = join(BUILD_DIR, `${name}.o`); const soPath = join(BUILD_DIR, `${name}.so`); - writeFileSync(srcPath, source); + writeFileSync(srcPath, `${source} + #include "abi_constants.h" + extern "C" __attribute__((export_name("__abi_version"))) + unsigned __abi_version(void) { return WASM_POSIX_ABI_VERSION; } + `); execFileSync(CLANGXX, [ "--target=wasm32-unknown-unknown", `--sysroot=${SYSROOT}`, @@ -119,6 +128,7 @@ function buildCppSharedLib(source: string, name: string): string { "-fwasm-exceptions", "-matomics", "-mbulk-memory", + `-I${GLUE_DIR}`, `-I${join(libcxxPrefix, "include", "c++", "v1")}`, "-c", srcPath, @@ -138,6 +148,12 @@ function buildCppSharedLib(source: string, name: string): string { join(libcxxPrefix, "lib", "libc++-pic.a"), join(libcxxPrefix, "lib", "libc++abi-pic.a"), ], { stdio: "pipe" }); + execFileSync("bash", [ + FORK_INSTRUMENT, + soPath, + "-o", + soPath, + ], { stdio: "pipe" }); return soPath; } @@ -270,60 +286,80 @@ describe.skipIf(!hasSysroot || !hasKernel)("fork after dlopen end-to-end", () => }); expect(result.stderr).not.toContain("table index is out of bounds"); - expect(result.exitCode).toBe(0); + expect(result.exitCode, JSON.stringify(result)).toBe(0); expect(result.stdout).toContain("ok"); }); - it("fails pthread dlopen and fork after process dlopen without creating a child", { timeout: 30_000 }, async () => { + it("replays pthread-hosted dlopen table state into a fresh fork child", { timeout: 30_000 }, async () => { const soPath = buildSharedLib( - `int pthread_boundary_fixture(void) { return 1; }`, - "libpthreadboundary", + ` + typedef int (*step_fn)(int); + static int increment(int value) { return value + 1; } + static step_fn relocated_step = increment; + int pthread_replay_value(int value) { return relocated_step(value); } + `, + "libpthreadreplay", ); const wasmPath = buildMainProgram(` #include - #include #include #include - #include #include + #include static const char *side_path; static int thread_result; + typedef int (*replay_fn)(int); static void *run_thread(void *unused) { (void)unused; - void *nested = dlopen(side_path, RTLD_NOW); - const char *error = dlerror(); - if (nested != NULL || error == NULL || strstr(error, "pthread workers") == NULL) { + void *side = dlopen(side_path, RTLD_NOW); + if (!side) { + fprintf(stderr, "pthread dlopen: %s\\n", dlerror()); thread_result = 1; return NULL; } - errno = 0; - pid_t child = fork(); - if (child != -1 || errno != ENOTSUP) { + replay_fn replay = (replay_fn)dlsym(side, "pthread_replay_value"); + if (!replay || replay(40) != 41) { thread_result = 2; return NULL; } + + pid_t child = fork(); + if (child == 0) { + _exit(replay(41) == 42 ? 0 : 3); + } + if (child < 0) { + thread_result = 4; + return NULL; + } + int status = 0; + if ( + waitpid(child, &status, 0) != child + || !WIFEXITED(status) + || WEXITSTATUS(status) != 0 + ) { + thread_result = 5; + return NULL; + } thread_result = 0; return NULL; } int main(int argc, char **argv) { side_path = argv[1]; - void *side = dlopen(side_path, RTLD_NOW); - if (!side) { fprintf(stderr, "main dlopen: %s\\n", dlerror()); return 2; } pthread_t thread; if (pthread_create(&thread, NULL, run_thread, NULL) != 0) return 3; if (pthread_join(thread, NULL) != 0) return 4; if (thread_result != 0) return 10 + thread_result; - puts("pthread dylink boundary ok"); + puts("pthread dlopen fork replay ok"); return 0; } - `, "test-pthread-dylink-boundary"); + `, "test-pthread-dlopen-fork-replay"); const result = await runCentralizedProgram({ programPath: wasmPath, - argv: ["pthread-dylink-boundary", soPath], + argv: ["pthread-dlopen-fork-replay", soPath], timeout: 30_000, io: io(), captureForkCount: true, @@ -331,8 +367,140 @@ describe.skipIf(!hasSysroot || !hasKernel)("fork after dlopen end-to-end", () => expect(result.stderr).toBe(""); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("pthread dylink boundary ok"); - expect(result.forkCount).toBe(0n); + expect(result.stdout).toContain("pthread dlopen fork replay ok"); + expect(result.forkCount).toBe(1n); + }); + + it("blocks a foreign pthread until the staged loader owner commits", { timeout: 30_000 }, async () => { + const slowPath = buildSharedLib( + ` + extern void loader_ctor_enter(void); + extern void loader_ctor_wait(void); + __attribute__((constructor)) + static void slow_constructor(void) { + loader_ctor_enter(); + loader_ctor_wait(); + } + int slow_value(void) { return 17; } + `, + "libpthread-loader-owner", + ); + const fastPath = buildSharedLib( + `int fast_value(void) { return 29; }`, + "libpthread-loader-waiter", + ); + const wasmPath = buildMainProgram(` + #include + #include + #include + #include + #include + #include + + static const char *slow_path; + static const char *fast_path; + static _Atomic int owner_ready; + static _Atomic int waiter_ready; + static _Atomic int start_owner; + static _Atomic int constructor_entered; + static _Atomic int release_constructor; + static _Atomic int waiter_entered; + static _Atomic int waiter_done; + static _Atomic int constructor_timeout; + static int owner_result; + static int waiter_result; + + static int wait_for(_Atomic int *value) { + for (int attempt = 0; attempt < 5000; attempt++) { + if (atomic_load_explicit(value, memory_order_acquire)) return 1; + usleep(1000); + } + return atomic_load_explicit(value, memory_order_acquire) != 0; + } + + void loader_ctor_enter(void) { + atomic_store_explicit(&constructor_entered, 1, memory_order_release); + } + + void loader_ctor_wait(void) { + if (!wait_for(&release_constructor)) { + atomic_store_explicit(&constructor_timeout, 1, memory_order_release); + } + } + + static void *run_owner(void *unused) { + (void)unused; + atomic_store_explicit(&owner_ready, 1, memory_order_release); + if (!wait_for(&start_owner)) { + owner_result = 2; + return NULL; + } + void *handle = dlopen(slow_path, RTLD_NOW | RTLD_GLOBAL); + if (!handle) fprintf(stderr, "owner dlopen: %s\\n", dlerror()); + owner_result = handle ? 0 : 1; + return NULL; + } + + static void *run_waiter(void *unused) { + (void)unused; + atomic_store_explicit(&waiter_ready, 1, memory_order_release); + if (!wait_for(&constructor_entered)) { + waiter_result = 2; + return NULL; + } + atomic_store_explicit(&waiter_entered, 1, memory_order_release); + void *handle = dlopen(fast_path, RTLD_NOW | RTLD_LOCAL); + if (!handle) fprintf(stderr, "waiter dlopen: %s\\n", dlerror()); + waiter_result = handle ? 0 : 1; + atomic_store_explicit(&waiter_done, 1, memory_order_release); + return NULL; + } + + int main(int argc, char **argv) { + slow_path = argv[1]; + fast_path = argv[2]; + pthread_t owner; + pthread_t waiter; + if (pthread_create(&owner, NULL, run_owner, NULL) != 0) return 2; + if (pthread_create(&waiter, NULL, run_waiter, NULL) != 0) return 3; + if (!wait_for(&owner_ready)) return 11; + if (!wait_for(&waiter_ready)) return 12; + atomic_store_explicit(&start_owner, 1, memory_order_release); + if (!wait_for(&constructor_entered)) return 8; + if (!wait_for(&waiter_entered)) return 9; + usleep(10000); + int waiter_completed_early = + atomic_load_explicit(&waiter_done, memory_order_acquire); + atomic_store_explicit( + &release_constructor, + 1, + memory_order_release + ); + if (pthread_join(owner, NULL) != 0) return 5; + if (pthread_join(waiter, NULL) != 0) return 6; + if (atomic_load_explicit(&constructor_timeout, memory_order_acquire)) { + return 10; + } + if (waiter_completed_early) return waiter_result == 0 ? 4 : 14; + if (owner_result != 0 || waiter_result != 0) return 7; + puts("pthread loader lease ok"); + return 0; + } + `, "test-pthread-loader-owner", [ + "loader_ctor_enter", + "loader_ctor_wait", + ]); + + const result = await runCentralizedProgram({ + programPath: wasmPath, + argv: ["pthread-loader-owner", slowPath, fastPath], + timeout: 30_000, + io: io(), + }); + + expect(result.stderr).toBe(""); + expect(result.exitCode, JSON.stringify(result)).toBe(0); + expect(result.stdout).toContain("pthread loader lease ok"); }); it.skipIf(!hasCppPrerequisites)( diff --git a/host/test/fork-early-reference-provider.test.ts b/host/test/fork-early-reference-provider.test.ts new file mode 100644 index 0000000000..77b8fbd9c2 --- /dev/null +++ b/host/test/fork-early-reference-provider.test.ts @@ -0,0 +1,1011 @@ +import { describe, expect, it } from "vitest"; +import { + WPK_FORK_IMPORTED_GLOBAL_BINDINGS_OWNER, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, +} from "../src/generated/abi"; +import { + ForkEarlyChildReferenceProvider, + type ForkEarlyChildReferenceProviderOptions, + type ForkEarlyReferenceActivationDeclaration, + type ForkEarlyReferenceTransit, +} from "../src/fork-early-reference-provider"; +import { + FORK_GC_FIELD_ALLOCATION_DEPENDENCY, + FORK_GC_FIELD_MUTABLE, + FORK_GC_FIELD_NULLABLE, + FORK_GC_FIELD_REFERENCE, + FORK_GC_LAYOUT_DEFAULTABLE_SHELL, + ForkGcCodecDescriptor, + ForkGcConstructorKind, + ForkGcLayoutKind, + type ForkGcCodecProvider, +} from "../src/fork-gc-codec"; +import { ForkFunctionCatalog } from "../src/fork-function-catalog"; +import { + encodeForkImportedGlobalBindings, + ForkImportedGlobalBindingKind, + ForkModuleStateRecordKind, + type ForkImportedGlobalBinding, + type ForkModuleStateRecord, +} from "../src/fork-module-state"; +import { + type ForkReferenceRecipeGraph, +} from "../src/fork-reference-recipes"; +import { + decodeSegmentedForkReferenceTransaction, + encodeSegmentedForkReferenceRecords, + forkReferenceVectorFrom, + PagedForkReferenceVector, +} from "../src/fork-reference-segments"; +import { + FORK_HOST_EXCEPTION_ACTIVATION_ID, + FORK_REFERENCE_TRANSACTION_OWNER_ID, + ForkReferenceTransaction, + type ForkExternrefRecipeProvider, + type ForkTypedReferenceReplayOwner, +} from "../src/fork-reference-transaction"; + +function recordsFor( + graph: ForkReferenceRecipeGraph, + activationIds: readonly number[], + bindings: readonly ForkImportedGlobalBinding[] = [], + vectors: readonly (readonly number[])[] = [], +): ForkModuleStateRecord[] { + return [ + ...activationIds.map((activationId) => ({ + kind: ForkModuleStateRecordKind.Module, + activationId, + ownerId: 0, + payload: new Uint8Array(32).fill(activationId), + })), + ...encodeSegmentedForkReferenceRecords( + FORK_REFERENCE_TRANSACTION_OWNER_ID, + graph.nodes, + [ + PagedForkReferenceVector.empty, + ...vectors.map((vector) => + forkReferenceVectorFrom(vector, vector.length) + ), + ], + { segmentDataBytes: 19 }, + ), + { + kind: ForkModuleStateRecordKind.ImportedGlobalBindings, + activationId: 0, + ownerId: WPK_FORK_IMPORTED_GLOBAL_BINDINGS_OWNER, + payload: encodeForkImportedGlobalBindings(bindings), + }, + ]; +} + +class TestTransit implements ForkEarlyReferenceTransit { + readonly values = new Map(); + prepared: number[] = []; + aborts = 0; + + prepare(maxRecipeId: number): void { + this.prepared.push(maxRecipeId); + } + + read(recipeId: number): unknown { + return this.values.get(recipeId); + } + + publish(recipeId: number, value: unknown): void { + this.values.set(recipeId, value); + } + + abort(): void { + this.aborts++; + this.values.clear(); + } +} + +function scratchOwner(memory: WebAssembly.Memory): { + readonly allocate: (size: number) => number; + readonly deallocate: (addr: number, size: number) => void; + readonly deallocated: Array<{ addr: number; size: number }>; +} { + let next = 65_536; + const deallocated: Array<{ addr: number; size: number }> = []; + return { + allocate(size) { + const addr = next; + next += size; + if (next > memory.buffer.byteLength) { + throw new Error("test scratch memory exhausted"); + } + return addr; + }, + deallocate(addr, size) { + deallocated.push({ addr, size }); + }, + deallocated, + }; +} + +function externrefs(values: ReadonlyMap): { + readonly provider: ForkExternrefRecipeProvider; + readonly materializations: number[]; +} { + const materializations: number[] = []; + return { + provider: { + capture(): number { + throw new Error("capture is not available in a fresh child"); + }, + materialize(handle): unknown { + materializations.push(handle); + if (!values.has(handle)) throw new Error(`unknown externref handle ${handle}`); + return values.get(handle); + }, + }, + materializations, + }; +} + +function providerOptions( + records: readonly ForkModuleStateRecord[], + declarations: readonly ForkEarlyReferenceActivationDeclaration[], + provider: ForkExternrefRecipeProvider, + transit: TestTransit, +): ForkEarlyChildReferenceProviderOptions & { + readonly scratch: ReturnType; +} { + const memory = new WebAssembly.Memory({ initial: 8 }); + const scratch = scratchOwner(memory); + return { + records, + transaction: decodeSegmentedForkReferenceTransaction( + records, + FORK_REFERENCE_TRANSACTION_OWNER_ID, + ), + declarations, + externrefs: provider, + transit, + memory, + allocateScratch: scratch.allocate, + deallocateScratch: scratch.deallocate, + label: "test early references", + scratch, + }; +} + +function mutableReferenceStructDescriptor(): ForkGcCodecDescriptor { + return new ForkGcCodecDescriptor([{ + id: 1, + typeOrdinal: 0, + kind: ForkGcLayoutKind.Struct, + constructor: ForkGcConstructorKind.Struct, + flags: FORK_GC_LAYOUT_DEFAULTABLE_SHELL, + scalarLengthOrStride: 0, + fields: [{ + storage: 8, + flags: + FORK_GC_FIELD_REFERENCE + | FORK_GC_FIELD_MUTABLE + | FORK_GC_FIELD_NULLABLE, + scalarOffset: null, + referenceOrdinal: 0, + }], + superTypeOrdinal: null, + baseLayoutId: 1, + auxiliary: 0, + provenanceScalarLength: 0, + provenanceReferenceCount: 0, + }]); +} + +function immutableReferenceStructDescriptor(): ForkGcCodecDescriptor { + return new ForkGcCodecDescriptor([{ + id: 1, + typeOrdinal: 0, + kind: ForkGcLayoutKind.Struct, + constructor: ForkGcConstructorKind.Struct, + flags: 0, + scalarLengthOrStride: 0, + fields: [{ + storage: 8, + flags: + FORK_GC_FIELD_REFERENCE + | FORK_GC_FIELD_ALLOCATION_DEPENDENCY, + scalarOffset: null, + referenceOrdinal: 0, + }], + superTypeOrdinal: null, + baseLayoutId: 1, + auxiliary: 0, + provenanceScalarLength: 0, + provenanceReferenceCount: 0, + }]); +} + +function typedProvider( + activationId: number, + descriptor: ForkGcCodecDescriptor, + transit: TestTransit, + events: string[], + beforeAllocate?: (recipeId: number) => void, +): ForkGcCodecProvider { + return { + activationId, + descriptor, + probe: () => 0n, + encodeSlot: () => 0, + allocate(recipeId) { + beforeAllocate?.(recipeId); + events.push(`allocate:${recipeId}`); + transit.publish(recipeId, Object.freeze({ activationId, recipeId })); + }, + fill(recipeId) { + events.push(`fill:${recipeId}`); + }, + publishExternref(recipeId, value) { + transit.publish(recipeId, value); + }, + }; +} + +function nullGraph(): ForkReferenceRecipeGraph { + return { + roots: [0], + nodes: [{ id: 0, node: { kind: "null" } }], + }; +} + +describe("early child reference provider", () => { + it("materializes funcref, externref, and static-root recipes once", () => { + const callback = (() => 73) as CallableFunction; + const token = Object.freeze({ token: "child" }); + const staticRoot = Object.freeze({ root: "fresh activation" }); + const graph: ForkReferenceRecipeGraph = { + roots: [0, 1, 2, 3], + nodes: [ + { id: 0, node: { kind: "null" } }, + { + id: 1, + node: { + kind: "funcref", + moduleActivation: 1, + functionOrdinal: 4, + }, + }, + { id: 2, node: { kind: "externref", handle: 91 } }, + { + id: 3, + node: { + kind: "static-root", + moduleActivation: 2, + staticRootOrdinal: 7, + }, + }, + ], + }; + const refs = externrefs(new Map([[91, token]])); + const transit = new TestTransit(); + const options = providerOptions( + recordsFor(graph, [1, 2]), + [{ activationId: 1 }, { activationId: 2 }], + refs.provider, + transit, + ); + const provider = new ForkEarlyChildReferenceProvider(options); + const functionReads: number[] = []; + const staticReads: number[] = []; + provider.registerActivation({ + activationId: 1, + functions: { + decode(ordinal) { + functionReads.push(ordinal); + return callback; + }, + }, + }); + provider.registerActivation({ + activationId: 2, + staticRoots: { + decode(ordinal) { + staticReads.push(ordinal); + return staticRoot; + }, + }, + }); + + expect(provider.ownerActivation( + 1, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + )).toBe(1); + expect(provider.activationDependencies( + 2, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + )).toEqual([]); + expect(provider.activationDependencies( + 3, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF, + )).toEqual([2]); + + expect(provider.materialize( + 1, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + )).toBe(callback); + expect(provider.materialize( + 1, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + )).toBe(callback); + expect(provider.materialize( + 2, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + )).toBe(token); + expect(provider.materialize( + 2, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + )).toBe(token); + expect(provider.materialize( + 3, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF, + )).toBe(staticRoot); + expect(transit.read(3)).toBe(staticRoot); + expect(transit.prepared).toEqual([3]); + expect(functionReads).toEqual([4]); + expect(refs.materializations).toEqual([91]); + expect(staticReads).toEqual([7]); + }); + + it("publishes a static root before an immutable GC constructor consumes it", () => { + const descriptor = immutableReferenceStructDescriptor(); + const staticRoot = Object.freeze({ root: "fresh activation" }); + const graph: ForkReferenceRecipeGraph = { + roots: [0, 1, 2], + nodes: [ + { id: 0, node: { kind: "null" } }, + { + id: 1, + node: { + kind: "static-root", + moduleActivation: 1, + staticRootOrdinal: 0, + }, + }, + { + id: 2, + node: { + kind: "struct", + moduleActivation: 2, + typeOrdinal: 0, + layoutId: 1, + scalars: new Uint8Array(), + fields: [1], + }, + }, + ], + }; + const transit = new TestTransit(); + const options = providerOptions( + recordsFor(graph, [1, 2]), + [ + { activationId: 1 }, + { activationId: 2, gcDescriptor: descriptor }, + ], + externrefs(new Map()).provider, + transit, + ); + const events: string[] = []; + const provider = new ForkEarlyChildReferenceProvider(options); + provider.registerActivation({ + activationId: 1, + staticRoots: { + decode: () => staticRoot, + }, + }); + provider.registerActivation({ + activationId: 2, + typed: typedProvider( + 2, + descriptor, + transit, + events, + () => expect(transit.read(1)).toBe(staticRoot), + ), + }); + + expect(provider.activationDependencies( + 2, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF, + )).toEqual([1, 2]); + expect(provider.materialize( + 2, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF, + )).toBe(transit.read(2)); + expect(transit.read(1)).toBe(staticRoot); + expect(events).toEqual(["allocate:2", "fill:2"]); + }); + + it("publishes an owner token before an immutable GC constructor consumes it", () => { + const descriptor = immutableReferenceStructDescriptor(); + const token = Object.freeze({ token: "fresh child owner token" }); + const graph: ForkReferenceRecipeGraph = { + roots: [0, 1, 2], + nodes: [ + { id: 0, node: { kind: "null" } }, + { id: 1, node: { kind: "externref", handle: 71 } }, + { + id: 2, + node: { + kind: "struct", + moduleActivation: 2, + typeOrdinal: 0, + layoutId: 1, + scalars: new Uint8Array(), + fields: [1], + }, + }, + ], + }; + const transit = new TestTransit(); + const refs = externrefs(new Map([[71, token]])); + const options = providerOptions( + recordsFor(graph, [2]), + [{ activationId: 2, gcDescriptor: descriptor }], + refs.provider, + transit, + ); + const events: string[] = []; + const provider = new ForkEarlyChildReferenceProvider(options); + provider.registerActivation({ + activationId: 2, + typed: typedProvider( + 2, + descriptor, + transit, + events, + () => expect(transit.read(1)).toBe(token), + ), + }); + + expect(provider.materialize( + 2, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF, + )).toBe(transit.read(2)); + expect(transit.read(1)).toBe(token); + expect(refs.materializations).toEqual([71]); + expect(events).toEqual(["allocate:2", "fill:2"]); + }); + + it("adopts cached undefined and typed identities without reconstructing them twice", () => { + const descriptor = mutableReferenceStructDescriptor(); + const graph: ForkReferenceRecipeGraph = { + roots: [0, 1, 2], + nodes: [ + { id: 0, node: { kind: "null" } }, + { id: 1, node: { kind: "externref", handle: 44 } }, + { + id: 2, + node: { + kind: "struct", + moduleActivation: 1, + typeOrdinal: 0, + layoutId: 1, + scalars: new Uint8Array(), + fields: [0], + }, + }, + ], + }; + const records = recordsFor(graph, [1]); + const refs = externrefs(new Map([[44, undefined]])); + const transit = new TestTransit(); + const options = providerOptions( + records, + [{ activationId: 1, gcDescriptor: descriptor }], + refs.provider, + transit, + ); + const events: string[] = []; + const typed = typedProvider(1, descriptor, transit, events); + const early = new ForkEarlyChildReferenceProvider(options); + early.registerActivation({ activationId: 1, typed }); + + expect(early.materialize( + 1, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + )).toBeUndefined(); + const typedValue = early.materialize( + 2, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF, + ); + expect(events).toEqual(["allocate:2", "fill:2"]); + + const typedOwner: ForkTypedReferenceReplayOwner = { + prepareTransit: () => {}, + publishTransit: () => {}, + publishExternref: (recipeId, value) => { + transit.publish(recipeId, value); + events.push(`publish-externref:${recipeId}`); + }, + provider: () => typed, + providers: () => [typed], + validateExceptionOwner: () => {}, + materializeException: () => {}, + }; + const transaction = new ForkReferenceTransaction( + new ForkFunctionCatalog(), + refs.provider, + options.memory, + options.allocateScratch, + options.deallocateScratch, + "adopted transaction", + undefined, + typedOwner, + ); + transaction.attachChild(options.transaction); + early.adoptInto(transaction); + + expect(transaction.decodeExternref(1)).toBeUndefined(); + transaction.materializeAllTyped(); + expect(events).toEqual([ + "allocate:2", + "fill:2", + "publish-externref:1", + ]); + expect(transit.read(2)).toBe(typedValue); + expect(refs.materializations).toEqual([44]); + transaction.finishReplay(); + expect(() => early.adoptInto(transaction)).toThrow("was adopted"); + }); + + it("allocates defaultable shells before filling cyclic and aliased GC edges", () => { + const descriptor = mutableReferenceStructDescriptor(); + const graph: ForkReferenceRecipeGraph = { + roots: [0, 1, 2], + nodes: [ + { id: 0, node: { kind: "null" } }, + { + id: 1, + node: { + kind: "struct", + moduleActivation: 1, + typeOrdinal: 0, + layoutId: 1, + scalars: new Uint8Array(), + fields: [2], + }, + }, + { + id: 2, + node: { + kind: "struct", + moduleActivation: 1, + typeOrdinal: 0, + layoutId: 1, + scalars: new Uint8Array(), + fields: [1], + }, + }, + ], + }; + const refs = externrefs(new Map()); + const transit = new TestTransit(); + const options = providerOptions( + recordsFor(graph, [1]), + [{ activationId: 1, gcDescriptor: descriptor }], + refs.provider, + transit, + ); + const events: string[] = []; + const provider = new ForkEarlyChildReferenceProvider(options); + provider.registerActivation({ + activationId: 1, + typed: typedProvider(1, descriptor, transit, events), + }); + + const first = provider.materialize( + 1, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF, + ); + expect(provider.materialize( + 1, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF, + )).toBe(first); + expect(events).toEqual([ + "allocate:1", + "allocate:2", + "fill:1", + "fill:2", + ]); + }); + + it("rejects an immutable constructor cycle and cleans partial replay roots", () => { + const descriptor = immutableReferenceStructDescriptor(); + const graph: ForkReferenceRecipeGraph = { + roots: [0, 1, 2], + nodes: [ + { id: 0, node: { kind: "null" } }, + { + id: 1, + node: { + kind: "struct", + moduleActivation: 1, + typeOrdinal: 0, + layoutId: 1, + scalars: new Uint8Array(), + fields: [2], + }, + }, + { + id: 2, + node: { + kind: "struct", + moduleActivation: 1, + typeOrdinal: 0, + layoutId: 1, + scalars: new Uint8Array(), + fields: [1], + }, + }, + ], + }; + const refs = externrefs(new Map()); + const transit = new TestTransit(); + const options = providerOptions( + recordsFor(graph, [1]), + [{ activationId: 1, gcDescriptor: descriptor }], + refs.provider, + transit, + ); + let activationAborts = 0; + const provider = new ForkEarlyChildReferenceProvider(options); + provider.registerActivation({ + activationId: 1, + typed: typedProvider(1, descriptor, transit, []), + abort: () => { activationAborts++; }, + }); + + expect(() => provider.materialize( + 1, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF, + )).toThrow("unallocatable constructor cycle"); + expect(transit.aborts).toBe(1); + expect(activationAborts).toBe(1); + expect(() => provider.ownerActivation( + 1, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF, + )).toThrow("was aborted"); + }); + + it("orders GC/exnref dependencies and serves generated replay callbacks", () => { + const descriptor = mutableReferenceStructDescriptor(); + const graph: ForkReferenceRecipeGraph = { + roots: [0, 1, 2, 3], + nodes: [ + { id: 0, node: { kind: "null" } }, + { + id: 1, + node: { + kind: "funcref", + moduleActivation: 1, + functionOrdinal: 0, + }, + }, + { + id: 2, + node: { + kind: "exnref", + moduleActivation: 2, + tagOrdinal: 0, + layoutId: 7, + scalars: Uint8Array.of(0xaa, 0xbb), + payloads: [1], + }, + }, + { + id: 3, + node: { + kind: "struct", + moduleActivation: 3, + typeOrdinal: 0, + layoutId: 1, + scalars: new Uint8Array(), + fields: [2], + }, + }, + ], + }; + const refs = externrefs(new Map()); + const transit = new TestTransit(); + const options = providerOptions( + recordsFor(graph, [1, 2, 3], [], [[1]]), + [ + { activationId: 1 }, + { + activationId: 2, + exceptionDescriptor: { + version: 1, + tags: [{ + tagOrdinal: 0, + layoutId: 7, + scalarByteLength: 2, + referenceCount: 1, + }], + }, + }, + { activationId: 3, gcDescriptor: descriptor }, + ], + refs.provider, + transit, + ); + const events: string[] = []; + const provider = new ForkEarlyChildReferenceProvider(options); + const callback = (() => 12) as CallableFunction; + provider.registerActivation({ + activationId: 1, + functions: { + decode(ordinal) { + events.push(`function:${ordinal}`); + return callback; + }, + }, + }); + provider.registerActivation({ + activationId: 2, + exceptions: { + throwSlot(): never { + throw new Error("unused exception slot"); + }, + throwRecipe(): never { + throw new Error("unused exception throw"); + }, + encodeIngress: () => 0, + materialize(recipeId) { + events.push(`exception:${recipeId}`); + expect(provider.routeException(recipeId, 2)).toBe(7); + expect(provider.exceptionCacheIndex(recipeId)).toBe(1); + const scratch = provider.reserveScratch(16); + new Uint8Array(options.memory.buffer, scratch, 16).fill(0xcc); + expect(provider.loadException( + recipeId, + 2, + 0, + 7, + scratch, + 2, + scratch + 4, + 1, + )).toBe(1); + expect([...new Uint8Array(options.memory.buffer, scratch, 2)]) + .toEqual([0xaa, 0xbb]); + expect(new DataView(options.memory.buffer).getUint32(scratch + 4, true)) + .toBe(1); + provider.releaseScratch(scratch, 16); + expect(new Uint8Array(options.memory.buffer, scratch, 16) + .every((byte) => byte === 0)).toBe(true); + }, + clear: () => {}, + abort: () => {}, + }, + }); + provider.registerActivation({ + activationId: 3, + typed: typedProvider( + 3, + descriptor, + transit, + events, + (recipeId) => { + expect(provider.routeGc(recipeId, 3)).toBe(1); + expect(provider.gcPayloadLength(recipeId, 3, 1)).toBe(0); + const scratch = provider.reserveScratch(1); + const vector = provider.loadGc( + recipeId, + 3, + 0, + 1, + 1, + scratch, + 0, + ); + expect(provider.getReferenceVector(vector, 0)).toBe(2); + provider.releaseScratch(scratch, 1); + }, + ), + }); + + expect(provider.ownerActivation( + 2, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF, + )).toBe(2); + expect(provider.activationDependencies( + 3, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF, + )).toEqual([1, 2, 3]); + expect(provider.getReferenceVector(1, 0)).toBe(1); + expect(() => provider.captureUnavailable("encode funcref")) + .toThrow("unavailable during pre-instantiation child replay"); + + provider.materialize( + 3, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF, + ); + expect(events).toEqual([ + "allocate:3", + "function:0", + "exception:2", + "fill:3", + ]); + provider.abort(); + expect(options.scratch.deallocated).toEqual([ + { addr: 65_536, size: 65_536 }, + ]); + }); + + it("routes host-owned exception recipes through the canonical externref token", () => { + const hostException = Object.freeze({ host: "fresh child token" }); + const graph: ForkReferenceRecipeGraph = { + roots: [0, 1, 2], + nodes: [ + { id: 0, node: { kind: "null" } }, + { id: 1, node: { kind: "externref", handle: 73 } }, + { + id: 2, + node: { + kind: "exnref", + moduleActivation: FORK_HOST_EXCEPTION_ACTIVATION_ID, + tagOrdinal: 0, + layoutId: 0, + scalars: new Uint8Array(), + payloads: [1], + }, + }, + ], + }; + const refs = externrefs(new Map([[73, hostException]])); + const transit = new TestTransit(); + const options = providerOptions( + recordsFor(graph, [5]), + [{ + activationId: 5, + exceptionDescriptor: { version: 1, tags: [] }, + }], + refs.provider, + transit, + ); + const provider = new ForkEarlyChildReferenceProvider(options); + provider.registerActivation({ + activationId: 5, + exceptions: { + throwSlot(): never { + throw new Error("unused exception slot"); + }, + throwRecipe(): never { + throw new Error("unused exception throw"); + }, + encodeIngress: () => 0, + materialize: () => {}, + clear: () => {}, + abort: () => {}, + }, + }); + + expect(provider.activationDependencies( + 2, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF, + )).toEqual([5]); + expect(provider.exceptionOwner(2)).toBe( + FORK_HOST_EXCEPTION_ACTIVATION_ID, + ); + expect(provider.materializeHostException(2)).toBe(hostException); + expect(provider.materializeHostException(2)).toBe(hostException); + expect(refs.materializations).toEqual([73]); + expect(() => provider.materializeHostException(1)) + .toThrow("is not an exception"); + }); + + it("rejects malformed recipe ownership and non-null raw exnref provenance", () => { + const missingOwner: ForkReferenceRecipeGraph = { + roots: [0, 1], + nodes: [ + { id: 0, node: { kind: "null" } }, + { + id: 1, + node: { + kind: "funcref", + moduleActivation: 9, + functionOrdinal: 0, + }, + }, + ], + }; + const refs = externrefs(new Map()); + const transit = new TestTransit(); + expect(() => new ForkEarlyChildReferenceProvider(providerOptions( + recordsFor(missingOwner, [1]), + [{ activationId: 1 }], + refs.provider, + transit, + ))).toThrow("names missing activation 9"); + + const exnGraph: ForkReferenceRecipeGraph = { + roots: [0, 1], + nodes: [ + { id: 0, node: { kind: "null" } }, + { + id: 1, + node: { + kind: "exnref", + moduleActivation: 1, + tagOrdinal: 0, + layoutId: 3, + scalars: new Uint8Array(), + payloads: [], + }, + }, + ], + }; + const rawExnBinding: ForkImportedGlobalBinding = { + consumerActivation: 1, + consumerOwner: 1, + sourceActivation: 0, + sourceOwner: 0, + reserved: 0, + recipeId: 1, + rawBits: 0n, + kind: ForkImportedGlobalBindingKind.RawReference, + mutable: false, + shared: false, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF, + }; + expect(() => new ForkEarlyChildReferenceProvider(providerOptions( + recordsFor(exnGraph, [1], [rawExnBinding]), + [{ + activationId: 1, + exceptionDescriptor: { + version: 1, + tags: [{ + tagOrdinal: 0, + layoutId: 3, + scalarByteLength: 0, + referenceCount: 0, + }], + }, + }], + refs.provider, + new TestTransit(), + ))).toThrow("non-null raw recipe"); + }); + + it("zeros live scratch and releases activation roots on abort", () => { + const refs = externrefs(new Map()); + const transit = new TestTransit(); + const options = providerOptions( + recordsFor(nullGraph(), [1]), + [{ activationId: 1 }], + refs.provider, + transit, + ); + let activationAborts = 0; + const provider = new ForkEarlyChildReferenceProvider(options); + provider.registerActivation({ + activationId: 1, + abort: () => { activationAborts++; }, + }); + const scratch = provider.reserveScratch(32); + new Uint8Array(options.memory.buffer, scratch, 32).fill(0x5a); + provider.abort(); + + expect(new Uint8Array(options.memory.buffer, scratch, 32) + .every((byte) => byte === 0)).toBe(true); + expect(options.scratch.deallocated).toEqual([ + { addr: 65_536, size: 65_536 }, + ]); + expect(activationAborts).toBe(1); + expect(transit.aborts).toBe(1); + provider.abort(); + expect(activationAborts).toBe(1); + expect(() => provider.materialize( + 0, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + )).toThrow("was aborted"); + }); +}); diff --git a/host/test/fork-exception-provider.test.ts b/host/test/fork-exception-provider.test.ts new file mode 100644 index 0000000000..a07ea44319 --- /dev/null +++ b/host/test/fork-exception-provider.test.ts @@ -0,0 +1,336 @@ +import { describe, expect, it } from "vitest"; +import { + ForkActivationRegistry, + type ForkActivationExceptionProvider, + type ForkActivationRegistration, +} from "../src/fork-activation-registry"; +import { + ForkExceptionBroker, +} from "../src/fork-exception-provider"; +import { + ForkModuleStateArena, + ForkTableDirtyTracker, + writeForkModuleStateRoot, +} from "../src/fork-module-state"; +import { ForkExternrefProcessOwner } from "../src/fork-externref-process-owner"; +import { + ForkExternrefTokenCache, + ForkExternrefTokenRecipeProvider, +} from "../src/fork-reference-broker"; + +const PAGE_SIZE = 65_536; + +function emptyCatalog(): WebAssembly.Table { + return new WebAssembly.Table({ + element: "anyfunc", + initial: 0, + maximum: 0, + }); +} + +function registration( + activationId: number, + exceptionProvider: ForkActivationExceptionProvider, +): ForkActivationRegistration { + return { + activationId, + instance: { exports: {} } as unknown as WebAssembly.Instance, + templateId: new Uint8Array(32).fill(activationId + 1), + functionCatalog: emptyCatalog(), + staticRootCatalog: new WebAssembly.Table({ + element: "externref", + initial: 0, + maximum: 0, + }), + staticRootHarvest: () => {}, + moduleState: { + bootstrap: () => {}, + save: () => {}, + restore: () => {}, + finishRestore: () => {}, + saveTables: () => {}, + restoreTables: () => {}, + }, + exceptionProvider, + tableDirty: new ForkTableDirtyTracker(), + }; +} + +function activeRegistry( + providers: readonly [number, ForkActivationExceptionProvider][], +): { + registry: ForkActivationRegistry; + arena: ForkModuleStateArena; +} { + const memory = new WebAssembly.Memory({ initial: 16 }); + let next = PAGE_SIZE; + const allocate = (size: number): number => { + const address = next; + next += size; + return address; + }; + const registry = new ForkActivationRegistry( + memory, + { + capture: () => 41, + materialize: () => { + throw new Error("parent materialization should use captured identity"); + }, + }, + "exception broker test", + allocate, + () => {}, + ); + for (const [activationId, provider] of providers) { + registry.registerActivation(registration(activationId, provider)); + } + const arena = new ForkModuleStateArena( + memory, + 4, + allocate, + () => {}, + "exception broker arena", + ); + arena.begin(); + registry.beginCapture(arena); + return { registry, arena }; +} + +function provider(options: { + throwValue: unknown; + encodeIngress?: (token: number) => number; +}): ForkActivationExceptionProvider { + return { + throwSlot(): never { + throw options.throwValue; + }, + throwRecipe(): never { + throw options.throwValue; + }, + encodeIngress: options.encodeIngress ?? (() => 0), + clear: () => {}, + abort: () => {}, + }; +} + +describe("ForkExceptionBroker", () => { + it("probes providers in activation order and terminates nested unknown probes", () => { + const exception = Object.freeze({ exact: "exception" }); + const order: number[] = []; + let broker: ForkExceptionBroker; + const first = provider({ + throwValue: exception, + encodeIngress() { + order.push(1); + // This is the callback made by the candidate codec's CatchAllRef + // fallback. The broker recognizes the active identity and returns the + // explicit not-owned sentinel instead of recursing. + return broker.encodeFromSlot(1, 0); + }, + }); + const owner = provider({ + throwValue: exception, + encodeIngress() { + order.push(2); + return 17; + }, + }); + const source = provider({ throwValue: exception }); + const { registry } = activeRegistry([ + [2, owner], + [0, source], + [1, first], + ]); + broker = new ForkExceptionBroker(registry, "deterministic broker"); + + expect(broker.encodeFromSlot(0, 0)).toBe(17); + expect(order).toEqual([1, 2]); + registry.abort(); + }); + + it("owns raw JSTag-style values as recipes and rethrows parent identity", () => { + const exception = Object.freeze({ host: "error token" }); + const source = provider({ throwValue: exception }); + const { registry } = activeRegistry([[0, source]]); + const broker = new ForkExceptionBroker(registry, "host exception broker"); + const recipeId = broker.encodeFromSlot(0, 0); + expect(recipeId).toBe(1); + + registry.sealCapture(); + registry.beginParentReplay(); + let replayed: unknown; + try { + broker.throwRecipe(recipeId); + } catch (value) { + replayed = value; + } + expect(replayed).toBe(exception); + registry.finishReplay(); + }); + + it("keeps unclaimed object/primitive identity in the parent and tokenizes only the fresh child", () => { + const owner = new ForkExternrefProcessOwner(); + const parentGeneration = owner.startGeneration(101); + const rawException = new WebAssembly.Exception( + new WebAssembly.Tag({ parameters: ["i32"] }), + [73], + ); + const rawObject = Object.freeze({ + callback: () => 73, + }); + const rawValues: readonly unknown[] = [ + rawException, + rawObject, + -0, + ]; + const durableValues: readonly unknown[] = [ + Object.freeze({ opaqueWorkerException: true }), + Object.freeze({ opaqueWorkerObject: true }), + -0, + ]; + const handles = durableValues.map((value) => + owner.registerForWire( + parentGeneration.pid, + parentGeneration.id, + value, + ) + ); + const parentTokens = new ForkExternrefTokenCache(parentGeneration.id); + const parentTokensByValue = handles.map((handle) => + parentTokens.materialize(handle) + ); + + const memory = new WebAssembly.Memory({ initial: 16 }); + let next = PAGE_SIZE; + const allocate = (size: number): number => { + const address = next; + next += size; + return address; + }; + const parentRegistry = new ForkActivationRegistry( + memory, + new ForkExternrefTokenRecipeProvider(parentTokens), + "normalized JSTag parent", + allocate, + () => {}, + ); + const sourceProvider: ForkActivationExceptionProvider = { + throwSlot(slot): never { + if (!Number.isInteger(slot) || slot < 0 || slot >= rawValues.length) { + throw new Error(`invalid raw exception slot ${slot}`); + } + throw rawValues[slot]; + }, + throwRecipe(): never { + throw new Error("parent source provider does not decode host recipes"); + }, + encodeIngress: () => 0, + clear: () => {}, + abort: () => {}, + }; + parentRegistry.registerActivation( + registration(0, sourceProvider), + ); + const parentArena = new ForkModuleStateArena( + memory, + 4, + allocate, + () => {}, + "normalized JSTag parent arena", + ); + const root = parentArena.begin(); + parentRegistry.beginCapture(parentArena); + const parentBroker = new ForkExceptionBroker( + parentRegistry, + "normalized JSTag parent broker", + undefined, + (value) => { + const index = rawValues.findIndex((candidate) => + Object.is(candidate, value) + ); + if (index < 0) throw new Error("unknown raw host exception value"); + return parentTokensByValue[index]!; + }, + ); + const recipeIds = rawValues.map((_value, index) => + parentBroker.encodeFromSlot(0, index) + ); + parentRegistry.sealCapture(); + + const moduleBufferAddress = 0x1000; + writeForkModuleStateRoot(memory, moduleBufferAddress, 4, root); + const childGrant = owner.forkGenerationFromContinuation( + parentGeneration, + 102, + memory, + 4, + moduleBufferAddress, + ); + + const childMemory = new WebAssembly.Memory({ initial: 16 }); + new Uint8Array(childMemory.buffer).set(new Uint8Array(memory.buffer)); + + parentRegistry.beginParentReplay(); + for (let index = 0; index < recipeIds.length; index++) { + let parentReplay: unknown; + try { + parentBroker.throwRecipe(recipeIds[index]!); + } catch (value) { + parentReplay = value; + } + expect(Object.is(parentReplay, rawValues[index])).toBe(true); + } + parentRegistry.finishReplay(); + + const childArena = new ForkModuleStateArena( + childMemory, + 4, + () => { + throw new Error("fresh child arena must not allocate"); + }, + () => {}, + "normalized JSTag child arena", + ); + childArena.attach(root); + const childTokens = new ForkExternrefTokenCache(childGrant.generation.id); + const childRegistry = new ForkActivationRegistry( + childMemory, + new ForkExternrefTokenRecipeProvider(childTokens), + "normalized JSTag child", + ); + childRegistry.registerActivation(registration( + 0, + provider({ throwValue: null }), + )); + childRegistry.attachChild(childArena); + const childBroker = new ForkExceptionBroker( + childRegistry, + "normalized JSTag child broker", + ); + + for (let index = 0; index < recipeIds.length; index++) { + let childToken: unknown; + try { + childBroker.throwRecipe(recipeIds[index]!); + } catch (value) { + childToken = value; + } + expect(childToken).not.toBe(parentTokensByValue[index]); + expect(childTokens.encode(childToken)).toBe(handles[index]); + expect( + Object.is( + owner.authorizeForWire( + childGrant.generation.pid, + childGrant.generation.id, + handles[index]!, + ), + durableValues[index], + ), + ).toBe(true); + } + + childRegistry.finishReplay(); + owner.releaseGeneration(parentGeneration); + owner.releaseGeneration(childGrant.generation); + }); +}); diff --git a/host/test/fork-externref-host-parity.test.ts b/host/test/fork-externref-host-parity.test.ts new file mode 100644 index 0000000000..7a2445b6fd --- /dev/null +++ b/host/test/fork-externref-host-parity.test.ts @@ -0,0 +1,90 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const testDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(testDir, "..", ".."); + +function source(relativePath: string): string { + return readFileSync(join(repoRoot, relativePath), "utf8"); +} + +function functionSource( + text: string, + startName: string, + nextName: string, +): string { + const start = text.indexOf(startName); + const end = text.indexOf(nextName, start + startName.length); + expect(start, `missing ${startName}`).toBeGreaterThanOrEqual(0); + expect(end, `missing ${nextName} after ${startName}`).toBeGreaterThan(start); + return text.slice(start, end); +} + +describe.each([ + ["Node", "host/src/node-kernel-worker-entry.ts"], + ["browser", "host/src/browser-kernel-worker-entry.ts"], +])("%s externref process ownership", (_host, relativePath) => { + const entry = source(relativePath); + + it("replaces PID-stable authority only in the committed exec transition", () => { + const exec = functionSource( + entry, + "async function handleExec(", + "async function handlePosixSpawnResolve(", + ); + const commit = exec.indexOf( + "kernelWorker.prepareProcessForExec(pid, initiatingInfo.memory)", + ); + const replace = exec.indexOf( + "externrefProcessOwner.replaceGeneration(", + commit, + ); + const replacementInit = exec.indexOf( + "externrefGenerationId: replacementExternrefGeneration.id", + replace, + ); + + expect(commit).toBeGreaterThanOrEqual(0); + expect(replace).toBeGreaterThan(commit); + expect(replacementInit).toBeGreaterThan(replace); + }); + + it("gives pthread Workers the main process image generation", () => { + const clone = functionSource( + entry, + "async function handleClone(", + "function handleThreadExit(", + ); + expect(clone).toContain( + "externrefGenerationId: processInfo.externrefGeneration.id", + ); + }); + + it("releases owner generations on exit, explicit terminate, and destroy", () => { + const release = + "externrefProcessOwner.releaseGeneration(info.externrefGeneration)"; + const terminateStart = relativePath.includes("browser") + ? "async function handleTerminateProcess(" + : "async function handleTerminate("; + const exit = functionSource( + entry, + "async function finishProcessExit(", + terminateStart, + ); + const destroyStart = "async function handleDestroy("; + const terminate = functionSource(entry, terminateStart, destroyStart); + const performDestroy = functionSource( + entry, + "async function performDestroy(", + destroyStart, + ); + const destroy = entry.slice(entry.indexOf(destroyStart)); + + expect(exit).toContain(release); + expect(terminate).toContain(release); + expect(performDestroy).toContain(release); + expect(destroy).toContain("performDestroy"); + }); +}); diff --git a/host/test/fork-externref-import-mailbox.test.ts b/host/test/fork-externref-import-mailbox.test.ts new file mode 100644 index 0000000000..dbbe6af703 --- /dev/null +++ b/host/test/fork-externref-import-mailbox.test.ts @@ -0,0 +1,754 @@ +import { Worker } from "node:worker_threads"; +import { describe, expect, it } from "vitest"; +import { + createForkExternrefImportMailbox, + defineForkExternrefImport, + forkExternrefImportMailboxBytes, + ForkExternrefImportClosedError, + ForkExternrefImportFailureCode, + type ForkExternrefImportBinding, + type ForkExternrefImportDescriptor, + type ForkExternrefImportHandler, + type ForkExternrefImportValueType, + ForkExternrefImportOwnerCatalog, + ForkExternrefImportOwnerEndpoint, + ForkExternrefImportRemoteFailure, + type ForkExternrefImportWake, + ForkExternrefImportWorkerCaller, +} from "../src/fork-externref-import-mailbox"; +import { + ForkExternrefBroker, + type ForkExternrefGeneration, + ForkExternrefTokenCache, +} from "../src/fork-reference-broker"; + +class TestAuthority { + constructor( + readonly broker: ForkExternrefBroker, + readonly generation: ForkExternrefGeneration, + ) {} + + authorizeForWire( + pid: number, + generationId: number, + handle: number, + ): unknown { + this.assertBinding(pid, generationId); + return this.broker.authorize(this.generation, handle); + } + + registerForWire( + pid: number, + generationId: number, + value: unknown, + ): number { + this.assertBinding(pid, generationId); + return this.broker.register(this.generation, value); + } + + private assertBinding(pid: number, generationId: number): void { + if ( + pid !== this.generation.pid + || generationId !== this.generation.id + ) { + throw new Error( + `stale test authority pid=${pid} generation=${generationId}`, + ); + } + } +} + +interface Harness { + readonly broker: ForkExternrefBroker; + readonly generation: ForkExternrefGeneration; + readonly tokens: ForkExternrefTokenCache; + readonly binding: ForkExternrefImportBinding; + readonly mailbox: SharedArrayBuffer; + readonly catalog: ForkExternrefImportOwnerCatalog; + readonly endpoint: ForkExternrefImportOwnerEndpoint; + readonly caller: ForkExternrefImportWorkerCaller; + readonly wakes: ForkExternrefImportWake[]; +} + +function harness( + registrations: readonly [ + ForkExternrefImportDescriptor, + ForkExternrefImportHandler, + ][], + options: { + readonly authorizeSender?: ( + binding: ForkExternrefImportBinding, + ) => void; + readonly notify?: ( + wake: ForkExternrefImportWake, + endpoint: ForkExternrefImportOwnerEndpoint, + binding: ForkExternrefImportBinding, + ) => void; + readonly diagnostics?: Array<{ + error: unknown; + failure: ForkExternrefImportFailureCode; + }>; + readonly onDiagnostic?: ( + error: unknown, + failure: ForkExternrefImportFailureCode, + ) => void; + } = {}, +): Harness { + const broker = new ForkExternrefBroker(); + const generation = broker.createGeneration(101); + const tokens = new ForkExternrefTokenCache(generation.id); + const binding: ForkExternrefImportBinding = { + pid: generation.pid, + generationId: generation.id, + senderId: 17, + }; + const catalog = new ForkExternrefImportOwnerCatalog(); + for (const [descriptor, handler] of registrations) { + catalog.register(descriptor, handler); + } + const mailbox = createForkExternrefImportMailbox( + catalog.mailboxCapacity, + ); + const authority = new TestAuthority(broker, generation); + const endpoint = new ForkExternrefImportOwnerEndpoint( + mailbox, + binding, + catalog, + authority, + { + authorizeSender: options.authorizeSender ?? (() => {}), + onDiagnostic: (error, failure) => { + options.diagnostics?.push({ error, failure }); + options.onDiagnostic?.(error, failure); + }, + }, + ); + const wakes: ForkExternrefImportWake[] = []; + const caller = new ForkExternrefImportWorkerCaller( + mailbox, + binding, + tokens, + (wake) => { + wakes.push(wake); + if (options.notify) { + options.notify(wake, endpoint, binding); + } else if (!endpoint.dispatch(wake, binding)) { + throw new Error("test owner did not claim current wake"); + } + }, + ); + return { + broker, + generation, + tokens, + binding, + mailbox, + catalog, + endpoint, + caller, + wakes, + }; +} + +describe("fork externref host-import mailbox", () => { + it("allocates one catalog-sized mailbox per Worker", () => { + const empty = new ForkExternrefImportOwnerCatalog(); + const mailbox = createForkExternrefImportMailbox( + empty.mailboxCapacity, + ); + expect(mailbox).toBeInstanceOf(SharedArrayBuffer); + expect(mailbox.byteLength).toBe( + forkExternrefImportMailboxBytes({ params: 0, results: 0 }), + ); + expect(mailbox.byteLength).toBe(72); + + expect(() => + defineForkExternrefImport( + 1, + Array(257).fill("i32"), + [], + ) + ).not.toThrow(); + expect(() => + defineForkExternrefImport(1, ["v128" as never], []) + ).toThrow(/unsupported fork externref import value type v128/); + }); + + it("rejects forged capacity metadata without allocating from it", () => { + const mailbox = createForkExternrefImportMailbox({ + params: 0, + results: 0, + }); + // Header word 12 is the declared parameter capacity. A Worker receives an + // already allocated SAB; it validates this count against byteLength and + // never allocates storage based on the untrusted word. + new DataView(mailbox).setUint32(12 * 4, 0xffff_ffff, true); + const generation = new ForkExternrefBroker().createGeneration(102); + const tokens = new ForkExternrefTokenCache(generation.id); + + expect(() => + new ForkExternrefImportWorkerCaller( + mailbox, + { + pid: generation.pid, + generationId: generation.id, + senderId: 18, + }, + tokens, + () => {}, + ) + ).toThrow(/declared capacity requires exactly/); + }); + + it("round-trips signatures wider than 16 with exact tail validation", () => { + const params = Array.from( + { length: 40 }, + (_, index): ForkExternrefImportValueType => + index === 31 ? "i64" : "i32", + ); + const results = Array.from( + { length: 24 }, + (_, index): ForkExternrefImportValueType => + index === 22 ? "i64" : "i32", + ); + const descriptor = defineForkExternrefImport(41, params, results); + const resultValues = results.map((type, index) => + type === "i64" ? BigInt(index) : index === 0 ? 0 : -index + ); + const state = harness([[ + descriptor, + (_context, ...args) => { + expect(args).toHaveLength(params.length); + expect(args[31]).toBe(31n); + return resultValues; + }, + ]]); + expect(state.mailbox.byteLength).toBe( + forkExternrefImportMailboxBytes({ params: 40, results: 24 }), + ); + + const args = params.map((type, index) => + type === "i64" ? BigInt(index) : index + ); + expect(state.caller.call(descriptor, args)).toEqual(resultValues); + + const mismatchedParams = [...params]; + mismatchedParams[31] = "f64"; + const mismatched = defineForkExternrefImport( + descriptor.ordinal, + mismatchedParams, + results, + ); + const mismatchedArgs = mismatchedParams.map((type, index) => + type === "i64" ? BigInt(index) : index + ); + try { + state.caller.call(mismatched, mismatchedArgs); + throw new Error("expected wide signature mismatch"); + } catch (error) { + expect(error).toBeInstanceOf(ForkExternrefImportRemoteFailure); + expect((error as ForkExternrefImportRemoteFailure).failureCode).toBe( + ForkExternrefImportFailureCode.Protocol, + ); + } + }); + + it("round-trips scalar bit patterns and owner-authorized externref aliases", () => { + const descriptor = defineForkExternrefImport( + 1, + ["i32", "i64", "f32", "f64", "externref"], + ["i64", "f32", "f64", "externref"], + ); + const realValue = { owner: true }; + const state = harness([ + [ + descriptor, + (_context, i32, i64, f32, f64, externref) => { + expect(i32).toBe(-17); + expect(i64).toBe(-0x7fff_ffff_ffff_ffffn); + expect(f32).toBe(Math.fround(1 / 3)); + expect(Object.is(f64, -0)).toBe(true); + expect(externref).toBe(realValue); + return [i64, f32, f64, externref]; + }, + ], + ]); + const handle = state.broker.register(state.generation, realValue); + const token = state.tokens.materialize(handle); + + const result = state.caller.call( + descriptor, + [-17, -0x7fff_ffff_ffff_ffffn, 1 / 3, -0, token], + ); + expect(result).toEqual([ + -0x7fff_ffff_ffff_ffffn, + Math.fround(1 / 3), + -0, + token, + ]); + expect(Object.is((result as unknown[])[2], -0)).toBe(true); + }); + + it("normalizes a scalar-only host import exception into a forkable token", () => { + const descriptor = defineForkExternrefImport( + 2, + ["i32"], + ["i32"], + ); + const ownerError = new Error("owner-only failure"); + const state = harness([ + [ + descriptor, + () => { + throw ownerError; + }, + ], + ]); + + let parentToken: unknown; + try { + state.caller.call(descriptor, [41]); + throw new Error("expected owner exception"); + } catch (error) { + parentToken = error; + } + const handle = state.tokens.encode(parentToken); + expect(handle).not.toBeNull(); + expect(state.broker.authorize(state.generation, handle!)).toBe(ownerError); + + // A fork child creates a different canonical token for the same leased + // owner handle. CatchAllRef can retain that child-local identity without + // consulting the parent's Worker or copying the Error through postMessage. + const child = state.broker.createGeneration(102); + state.broker.acquireFork(state.generation, child, [handle!]); + const childTokens = new ForkExternrefTokenCache(child.id); + const childToken = childTokens.materialize(handle!); + expect(childToken).not.toBe(parentToken); + expect(childTokens.encode(childToken)).toBe(handle); + expect(state.broker.authorize(child, handle!)).toBe(ownerError); + }); + + it("gives thrown null a nonzero owner handle instead of the null sentinel", () => { + const descriptor = defineForkExternrefImport(15, [], []); + const state = harness([[descriptor, () => { + throw null; + }]]); + + let token: unknown; + try { + state.caller.call(descriptor, []); + throw new Error("expected owner exception"); + } catch (error) { + token = error; + } + expect(token).not.toBeNull(); + const handle = state.tokens.encode(token); + expect(handle).not.toBeNull(); + expect(state.broker.authorize(state.generation, handle!)).toBeNull(); + }); + + it("routes only numeric wake metadata and rejects stale duplicate wakes", () => { + const descriptor = defineForkExternrefImport(3, ["i32"], ["i32"]); + let previous: ForkExternrefImportWake | undefined; + const state = harness( + [[descriptor, (_context, value) => (value as number) + 1]], + { + notify: (wake, endpoint, binding) => { + if (previous) { + expect(endpoint.dispatch(previous, binding)).toBe(false); + } + expect(endpoint.dispatch(wake, binding)).toBe(true); + previous = wake; + }, + }, + ); + expect(state.caller.call(descriptor, [1])).toBe(2); + expect(state.caller.call(descriptor, [2])).toBe(3); + for (const wake of state.wakes) { + expect(Object.values(wake).every((value) => typeof value === "number")) + .toBe(true); + } + expect(state.wakes[1]!.sequenceLow).toBeGreaterThan( + state.wakes[0]!.sequenceLow, + ); + }); + + it("requires independently observed exact sender identity", () => { + const descriptor = defineForkExternrefImport(4, [], ["i32"]); + let calls = 0; + const state = harness( + [[descriptor, () => ++calls]], + { + notify: (wake, endpoint, binding) => { + expect(endpoint.dispatch(wake, { + ...binding, + senderId: binding.senderId + 1, + })).toBe(false); + expect(endpoint.dispatch(wake, binding)).toBe(true); + }, + }, + ); + + expect(state.caller.call(descriptor, [])).toBe(1); + expect(calls).toBe(1); + }); + + it("fails a replaced sender generation before invoking its handler", () => { + const descriptor = defineForkExternrefImport(5, [], ["i32"]); + const diagnostics: Array<{ + error: unknown; + failure: ForkExternrefImportFailureCode; + }> = []; + let current = false; + let invoked = false; + const state = harness( + [[descriptor, () => { + invoked = true; + return 1; + }]], + { + authorizeSender: () => { + if (!current) throw new Error("process image was replaced"); + }, + diagnostics, + }, + ); + + expect(() => state.caller.call(descriptor, [])).toThrow( + ForkExternrefImportRemoteFailure, + ); + try { + state.caller.call(descriptor, []); + } catch (error) { + expect((error as ForkExternrefImportRemoteFailure).failureCode).toBe( + ForkExternrefImportFailureCode.Unauthorized, + ); + } + expect(invoked).toBe(false); + expect(diagnostics.at(-1)?.failure).toBe( + ForkExternrefImportFailureCode.Unauthorized, + ); + + current = true; + expect(state.caller.call(descriptor, [])).toBe(1); + }); + + it("matches an ordinal's complete signature instead of trusting a hash", () => { + const ownerDescriptor = defineForkExternrefImport( + 6, + ["i32", "f64"], + ["i32"], + ); + const mismatchedWorkerDescriptor = defineForkExternrefImport( + 6, + ["f32", "f64"], + ["i32"], + ); + let invoked = false; + const state = harness([ + [ownerDescriptor, () => { + invoked = true; + return 1; + }], + ]); + + try { + state.caller.call(mismatchedWorkerDescriptor, [1, 2]); + throw new Error("expected signature rejection"); + } catch (error) { + expect(error).toBeInstanceOf(ForkExternrefImportRemoteFailure); + expect((error as ForkExternrefImportRemoteFailure).failureCode).toBe( + ForkExternrefImportFailureCode.Protocol, + ); + } + expect(invoked).toBe(false); + }); + + it("rejects wrong-generation raw tokens before notifying the owner", () => { + const descriptor = defineForkExternrefImport( + 7, + ["externref"], + ["externref"], + ); + const state = harness([ + [descriptor, (_context, value) => value], + ]); + const foreignTokens = new ForkExternrefTokenCache( + state.generation.id + 1, + ); + + expect(() => + state.caller.call(descriptor, [foreignTokens.materialize(9)]) + ).toThrow(/did not come from this process-image owner/); + expect(state.wakes).toHaveLength(0); + + const value = { valid: true }; + const handle = state.broker.register(state.generation, value); + const token = state.tokens.materialize(handle); + expect(state.caller.call(descriptor, [token])).toBe(token); + }); + + it("rejects mailbox reentrancy rather than overwriting the live request", () => { + const descriptor = defineForkExternrefImport(8, ["i32"], ["i32"]); + let nestedError: unknown; + let bound: (...args: Parameters< + ForkExternrefImportWorkerCaller["call"] + >) => unknown; + const state = harness( + [[descriptor, (_context, value) => value]], + { + notify: (wake, endpoint, binding) => { + try { + state.caller.call(descriptor, [99]); + } catch (error) { + nestedError = error; + } + expect(endpoint.dispatch(wake, binding)).toBe(true); + }, + }, + ); + bound = state.caller.call.bind(state.caller); + expect(bound(descriptor, [17])).toBe(17); + expect(String(nestedError)).toMatch(/reentrant/); + }); + + it("lets main and side-module wrappers share one Worker mailbox", () => { + const main = defineForkExternrefImport(9, ["i32"], ["i32"]); + const side = defineForkExternrefImport(10, ["i64"], ["i64"]); + const state = harness([ + [main, (_context, value) => (value as number) + 1], + [side, (_context, value) => (value as bigint) + 1n], + ]); + const mainImport = state.caller.bind(main); + const sideImport = state.caller.bind(side); + + expect(mainImport(4)).toBe(5); + expect(sideImport(9n)).toBe(10n); + expect(state.caller.mailbox).toBe(state.mailbox); + expect(state.wakes).toHaveLength(2); + }); + + it("wakes a pending caller when process teardown closes the mailbox", () => { + const descriptor = defineForkExternrefImport(11, [], []); + const state = harness( + [[descriptor, () => undefined]], + { + notify: (_wake, endpoint) => { + endpoint.close(ForkExternrefImportFailureCode.Teardown); + }, + }, + ); + + try { + state.caller.call(descriptor, []); + throw new Error("expected closed mailbox"); + } catch (error) { + expect(error).toBeInstanceOf(ForkExternrefImportClosedError); + expect((error as ForkExternrefImportClosedError).reasonCode).toBe( + ForkExternrefImportFailureCode.Teardown, + ); + } + expect(() => state.caller.call(descriptor, [])).toThrow( + ForkExternrefImportClosedError, + ); + }); + + it("does not resurrect a mailbox closed during owner dispatch", () => { + const descriptor = defineForkExternrefImport(12, [], ["i32"]); + let endpoint: ForkExternrefImportOwnerEndpoint; + const state = harness([ + [descriptor, () => { + endpoint.close(); + return 42; + }], + ]); + endpoint = state.endpoint; + + expect(() => state.caller.call(descriptor, [])).toThrow( + ForkExternrefImportClosedError, + ); + }); + + it("does not let losing dispatch failure overwrite the teardown reason", () => { + const descriptor = defineForkExternrefImport(14, [], ["i64"]); + let endpoint: ForkExternrefImportOwnerEndpoint; + const state = harness([ + [descriptor, () => { + endpoint.close( + ForkExternrefImportFailureCode.NotificationFailure, + ); + // This invalid i64 result makes dispatch publish HandlerContract after + // close. The caller must still observe the independently owned close + // reason, not that losing completion. + return 42; + }], + ]); + endpoint = state.endpoint; + + try { + state.caller.call(descriptor, []); + throw new Error("expected closed mailbox"); + } catch (error) { + expect(error).toBeInstanceOf(ForkExternrefImportClosedError); + expect((error as ForkExternrefImportClosedError).reasonCode).toBe( + ForkExternrefImportFailureCode.NotificationFailure, + ); + } + }); + + it("reports handler result-shape failures without publishing partial data", () => { + const descriptor = defineForkExternrefImport(13, [], ["i64", "i32"]); + const diagnostics: Array<{ + error: unknown; + failure: ForkExternrefImportFailureCode; + }> = []; + const state = harness( + [[descriptor, () => [1n]]], + { diagnostics }, + ); + + try { + state.caller.call(descriptor, []); + throw new Error("expected handler contract failure"); + } catch (error) { + expect((error as ForkExternrefImportRemoteFailure).failureCode).toBe( + ForkExternrefImportFailureCode.HandlerContract, + ); + } + expect(diagnostics.at(-1)?.failure).toBe( + ForkExternrefImportFailureCode.HandlerContract, + ); + }); + + it("completes a failure even when the diagnostic observer throws", () => { + const descriptor = defineForkExternrefImport(16, [], ["i64"]); + const state = harness( + [[descriptor, () => 42]], + { + onDiagnostic: () => { + throw new Error("broken diagnostic sink"); + }, + }, + ); + + try { + state.caller.call(descriptor, []); + throw new Error("expected handler contract failure"); + } catch (error) { + expect(error).toBeInstanceOf(ForkExternrefImportRemoteFailure); + expect((error as ForkExternrefImportRemoteFailure).failureCode).toBe( + ForkExternrefImportFailureCode.HandlerContract, + ); + } + }); + + it("blocks a real Worker while the owner returns only scalar wire data", async () => { + const aliasDescriptor = defineForkExternrefImport( + 30, + ["externref", "i64"], + ["externref", "i64"], + ); + const throwingDescriptor = defineForkExternrefImport( + 31, + ["i32"], + ["i32"], + ); + const broker = new ForkExternrefBroker(); + const generation = broker.createGeneration(201); + const binding: ForkExternrefImportBinding = { + pid: generation.pid, + generationId: generation.id, + senderId: 29, + }; + const realValue = { ownerOnly: true }; + const ownerError = new Error("owner-only exception"); + const inputHandle = broker.register(generation, realValue); + const catalog = new ForkExternrefImportOwnerCatalog(); + catalog.register( + aliasDescriptor, + (_context, value, scalar) => [ + value, + (scalar as bigint) - 1n, + ], + ); + catalog.register(throwingDescriptor, () => { + throw ownerError; + }); + const mailbox = createForkExternrefImportMailbox( + catalog.mailboxCapacity, + ); + const endpoint = new ForkExternrefImportOwnerEndpoint( + mailbox, + binding, + catalog, + new TestAuthority(broker, generation), + { authorizeSender: () => {} }, + ); + const worker = new Worker( + new URL( + "./fixtures/fork-externref-import-worker.ts", + import.meta.url, + ), + { + execArgv: ["--import", "tsx"], + workerData: { mailbox, binding, inputHandle }, + }, + ); + + try { + const complete = new Promise<{ + resultHandle: number; + resultScalar: bigint; + exceptionHandle: number; + }>((resolve, reject) => { + worker.on("message", (message: { + type: string; + wake?: ForkExternrefImportWake; + resultHandle?: number; + resultScalar?: bigint; + exceptionHandle?: number; + message?: string; + }) => { + if (message.type === "wake") { + if (!endpoint.dispatch(message.wake!, binding)) { + reject(new Error("owner rejected current Worker wake")); + } + } else if (message.type === "complete") { + resolve({ + resultHandle: message.resultHandle!, + resultScalar: message.resultScalar!, + exceptionHandle: message.exceptionHandle!, + }); + } else if (message.type === "failed") { + reject(new Error(message.message)); + } + }); + worker.once("error", reject); + worker.once("exit", (code) => { + if (code !== 0) { + reject(new Error(`externref import Worker exited ${code}`)); + } + }); + }); + const timeout = new Promise((_, reject) => { + setTimeout( + () => reject(new Error("externref import Worker watchdog expired")), + 5_000, + ); + }); + const result = await Promise.race([complete, timeout]); + + expect(result.resultHandle).toBe(inputHandle); + expect(result.resultScalar).toBe(-10n); + expect(broker.authorize(generation, result.resultHandle)).toBe( + realValue, + ); + expect(broker.authorize(generation, result.exceptionHandle)).toBe( + ownerError, + ); + } finally { + endpoint.close(); + await worker.terminate(); + } + }, 8_000); +}); diff --git a/host/test/fork-externref-process-owner.test.ts b/host/test/fork-externref-process-owner.test.ts new file mode 100644 index 0000000000..d593aa6b11 --- /dev/null +++ b/host/test/fork-externref-process-owner.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it, vi } from "vitest"; +import { ForkExternrefProcessOwner } from "../src/fork-externref-process-owner"; +import { + ForkModuleStateArena, + ForkModuleStateRecordKind, + writeForkModuleStateRoot, +} from "../src/fork-module-state"; +import { + type ForkReferenceRecipeGraph, +} from "../src/fork-reference-recipes"; +import { FORK_REFERENCE_TRANSACTION_OWNER_ID } from "../src/fork-reference-transaction"; +import { + encodeSegmentedForkReferenceRecords, + PagedForkReferenceVector, +} from "../src/fork-reference-segments"; + +function copiedContinuation( + graph: ForkReferenceRecipeGraph, +): { + memory: WebAssembly.Memory; + moduleBufferAddress: number; +} { + const memory = new WebAssembly.Memory({ initial: 8 }); + let next = 0x2_0000; + const arena = new ForkModuleStateArena( + memory, + 4, + (size) => { + const address = next; + next += Math.ceil(Number(size) / 0x1_0000) * 0x1_0000; + return address; + }, + () => {}, + "externref owner test arena", + ); + const root = arena.begin(); + arena.appendModule({ + activationId: 0, + templateId: new Uint8Array(32).fill(0x71), + }); + for (const record of encodeSegmentedForkReferenceRecords( + FORK_REFERENCE_TRANSACTION_OWNER_ID, + graph.nodes, + [PagedForkReferenceVector.empty], + { segmentDataBytes: 17 }, + )) { + arena.appendRecord(record); + } + arena.seal(); + + const moduleBufferAddress = 0x1_0000; + writeForkModuleStateRoot(memory, moduleBufferAddress, 4, root); + return { memory, moduleBufferAddress }; +} + +function graphForHandles(handles: readonly number[]): ForkReferenceRecipeGraph { + return { + roots: [0, ...handles.map((_, index) => index + 1)], + nodes: [ + { id: 0, node: { kind: "null" } }, + ...handles.map((handle, index) => ({ + id: index + 1, + node: { kind: "externref" as const, handle }, + })), + ], + }; +} + +describe("ForkExternrefProcessOwner", () => { + it("leases each aliased handle once before a fresh child starts", () => { + const owner = new ForkExternrefProcessOwner(); + const parent = owner.startGeneration(41); + const value = { opaque: true }; + const handle = owner.registerForWire( + 41, + owner.generationId(parent), + value, + ); + const copied = copiedContinuation( + graphForHandles([handle, handle, handle]), + ); + + const grant = owner.forkGenerationFromContinuation( + parent, + 42, + copied.memory, + 4, + copied.moduleBufferAddress, + ); + expect(grant.handleCount).toBe(1); + expect( + owner.authorizeForWire(42, grant.generation.id, handle), + ).toBe(value); + + owner.releaseGeneration(parent); + expect( + owner.authorizeForWire(42, grant.generation.id, handle), + ).toBe(value); + owner.releaseGeneration(grant.generation); + expect(() => + owner.authorizeForWire(42, grant.generation.id, handle) + ).toThrow("stale"); + }); + + it("retires PID-stable authority exactly when exec replaces an image", () => { + const owner = new ForkExternrefProcessOwner(); + const beforeExec = owner.startGeneration(51); + const beforeId = owner.generationId(beforeExec); + const handle = owner.registerForWire(51, beforeId, Symbol("old image")); + + const afterExec = owner.replaceGeneration(beforeExec); + expect(afterExec.pid).toBe(51); + expect(afterExec.id).not.toBe(beforeId); + expect(() => owner.authorizeForWire(51, beforeId, handle)).toThrow( + "stale", + ); + expect(() => + owner.authorizeForWire(51, afterExec.id, handle) + ).toThrow("retired"); + }); + + it("rolls back a provisional child generation when its graph is not owned", () => { + const owner = new ForkExternrefProcessOwner(); + const parent = owner.startGeneration(61); + const copied = copiedContinuation(graphForHandles([900])); + + expect(() => + owner.forkGenerationFromContinuation( + parent, + 62, + copied.memory, + 4, + copied.moduleBufferAddress, + ) + ).toThrow("unknown externref handle"); + + // A failed grant leaves no hidden child generation behind. + expect(owner.startGeneration(62).pid).toBe(62); + }); + + it("uses one process generation for main and pthread import adapters", () => { + const owner = new ForkExternrefProcessOwner(); + const generation = owner.startGeneration(71); + const idForMainWorker = owner.generationId(generation); + const idForPthreadWorker = owner.generationId(generation); + const handle = owner.registerForWire(71, idForMainWorker, "shared"); + + expect( + owner.authorizeForWire(71, idForPthreadWorker, handle), + ).toBe("shared"); + }); + + it("does not adopt or copy the complete module-state arena to grant a lease", () => { + const owner = new ForkExternrefProcessOwner(); + const parent = owner.startGeneration(81); + const handle = owner.registerForWire(81, parent.id, { opaque: true }); + const copied = copiedContinuation(graphForHandles([handle])); + const attach = vi.spyOn(ForkModuleStateArena.prototype, "attach") + .mockImplementation(() => { + throw new Error("full arena attachment is not allowed in the grant path"); + }); + const records = vi.spyOn(ForkModuleStateArena.prototype, "records") + .mockImplementation(() => { + throw new Error("full arena copying is not allowed in the grant path"); + }); + try { + const grant = owner.forkGenerationFromContinuation( + parent, + 82, + copied.memory, + 4, + copied.moduleBufferAddress, + ); + expect(grant.handleCount).toBe(1); + owner.releaseGeneration(grant.generation); + } finally { + attach.mockRestore(); + records.mockRestore(); + owner.releaseGeneration(parent); + } + }); +}); diff --git a/host/test/fork-from-dlopen-side-module-e2e.test.ts b/host/test/fork-from-dlopen-side-module-e2e.test.ts index aae67a2daa..e55dcb9cb6 100644 --- a/host/test/fork-from-dlopen-side-module-e2e.test.ts +++ b/host/test/fork-from-dlopen-side-module-e2e.test.ts @@ -14,6 +14,7 @@ import { NodePlatformIO } from "../src/platform/node"; import { FORK_CAP_DYLINK_MAIN, FORK_CAP_SIDE_ENTRY, + parseDylinkSection, readForkInstrumentCapabilities, } from "../src/dylink"; import { runCentralizedProgram } from "./centralized-test-helper"; @@ -57,17 +58,26 @@ function instrumentInPlace(wasmPath: string, entry?: string): void { renameSync(output, wasmPath); } -function buildSharedLibrary(source: string): string { - const sourcePath = join(buildDir, "libforkinside.c"); - const objectPath = join(buildDir, "libforkinside.o"); - const libraryPath = join(buildDir, "libforkinside.so"); - writeFileSync(sourcePath, source); +function buildSharedLibrary( + source: string, + name = "libforkinside", + dependencies: readonly string[] = [], +): string { + const sourcePath = join(buildDir, `${name}.c`); + const objectPath = join(buildDir, `${name}.o`); + const libraryPath = join(buildDir, `${name}.so`); + writeFileSync(sourcePath, `${source} + #include "abi_constants.h" + __attribute__((export_name("__abi_version"))) + unsigned __abi_version(void) { return WASM_POSIX_ABI_VERSION; } + `); execFileSync(llvmTool("clang"), [ "--target=wasm32-unknown-unknown", "-fPIC", "-O2", "-matomics", "-mbulk-memory", + `-I${glueDir}`, "-c", sourcePath, "-o", @@ -82,6 +92,7 @@ function buildSharedLibrary(source: string): string { "-o", libraryPath, objectPath, + ...(dependencies.length === 0 ? [] : ["--Bdynamic", ...dependencies]), ], { stdio: "pipe" }); instrumentInPlace(libraryPath, "env.fork"); return libraryPath; @@ -147,21 +158,31 @@ describe.skipIf(!hasPrerequisites)("fork from a dlopened side module", () => { `); const programPath = buildMainProgram(` #include + #include #include #include + #include typedef int (*side_fork_fn)(void); int main(int argc, char **argv) { void *lib = dlopen(argv[1], RTLD_NOW); - if (!lib) return 2; + if (!lib) { + fprintf(stderr, "dlopen failed: %s\\n", dlerror()); + return 2; + } side_fork_fn side_fork = (side_fork_fn)dlsym(lib, "side_fork"); if (!side_fork) return 3; for (int i = 0; i < 2; i++) { int pid = side_fork(); if (pid < 0) return 4; + if (pid == 0) { + if (dlclose(lib) != 0) exit(7); + exit(0); + } int status = 0; if (waitpid(pid, &status, 0) != pid) return 5; if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) return 6; } + if (dlclose(lib) != 0) return 8; puts("side fork ok"); return 0; } @@ -191,4 +212,215 @@ describe.skipIf(!hasPrerequisites)("fork from a dlopened side module", () => { expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0); expect(result.stdout).toContain("side fork ok"); }, 30_000); + + it("replays a fork issued while dlopen runs a side-module constructor", async () => { + const libraryPath = buildSharedLibrary(` + extern int fork(void); + extern void exit(int); + static int constructor_child = -1; + __attribute__((constructor)) + static void fork_during_constructor(void) { + volatile int preserved = 73; + int pid = fork(); + if (preserved != 73) exit(92); + if (pid == 0) exit(0); + constructor_child = pid; + } + int constructor_child_pid(void) { + return constructor_child; + } + `); + const programPath = buildMainProgram(` + #include + #include + #include + typedef int (*constructor_child_pid_fn)(void); + int main(int argc, char **argv) { + void *lib = dlopen(argv[1], RTLD_NOW); + if (!lib) { + fprintf(stderr, "constructor dlopen failed: %s\\n", dlerror()); + return 2; + } + constructor_child_pid_fn child_pid = + (constructor_child_pid_fn)dlsym(lib, "constructor_child_pid"); + if (!child_pid) return 3; + int pid = child_pid(); + if (pid <= 0) return 4; + int status = 0; + if (waitpid(pid, &status, 0) != pid) return 5; + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) return 6; + puts("constructor fork ok"); + return 0; + } + `); + + const result = await runCentralizedProgram({ + programPath, + argv: ["fork-from-constructor-main", libraryPath], + timeout: 30_000, + io: new NodePlatformIO(), + }); + expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0); + expect(result.stdout).toContain("constructor fork ok"); + }, 30_000); + + it("lowers the original two-argument loader before a constructor can fork", async () => { + const libraryPath = buildSharedLibrary(` + extern int fork(void); + extern void exit(int); + static int constructor_child = -1; + __attribute__((constructor)) + static void fork_during_legacy_load(void) { + volatile int preserved = 89; + int pid = fork(); + if (preserved != 89) exit(93); + if (pid == 0) exit(0); + constructor_child = pid; + } + int legacy_constructor_child_pid(void) { + return constructor_child; + } + `, "liblegacy-constructor-fork"); + const programPath = buildMainProgram(` + #include + #include + #include + #include + #include + #include + #include + #include + + __attribute__((import_module("env"), import_name("__wasm_dlopen"))) + extern int legacy_host_dlopen(const void *, int); + + static int legacy_open(const char *path) { + struct stat st; + if (stat(path, &st) != 0 || st.st_size <= 0) return 0; + int fd = open(path, O_RDONLY); + if (fd < 0) return 0; + void *bytes = malloc((size_t)st.st_size); + if (!bytes) { + close(fd); + return 0; + } + ssize_t total = 0; + while (total < st.st_size) { + ssize_t count = read( + fd, (char *)bytes + total, (size_t)(st.st_size - total)); + if (count <= 0) break; + total += count; + } + close(fd); + int handle = total == st.st_size + ? legacy_host_dlopen(bytes, (int)st.st_size) + : 0; + free(bytes); + return handle; + } + + typedef int (*child_pid_fn)(void); + int main(int argc, char **argv) { + int handle = legacy_open(argv[1]); + if (handle <= 0) { + fprintf(stderr, "legacy loader failed: %s\\n", dlerror()); + return 2; + } + child_pid_fn child_pid = (child_pid_fn)dlsym( + (void *)(long)handle, "legacy_constructor_child_pid"); + if (!child_pid) return 3; + int pid = child_pid(); + if (pid <= 0) return 4; + int status = 0; + if (waitpid(pid, &status, 0) != pid) return 5; + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) return 6; + puts("lowered legacy constructor fork ok"); + return 0; + } + `); + + const programModule = new WebAssembly.Module( + new Uint8Array(readFileSync(programPath)) as unknown as BufferSource, + ); + const imports = WebAssembly.Module.imports(programModule); + expect(imports.some( + (entry) => entry.module === "env" && entry.name === "__wasm_dlopen", + )).toBe(false); + expect(imports.some( + (entry) => entry.module === "env" + && entry.name === "__wasm_dlopen_prepare", + )).toBe(true); + expect(WebAssembly.Module.exports(programModule).map((entry) => entry.name)) + .toContain("__wasm_posix_signal_checkpoint"); + + const result = await runCentralizedProgram({ + programPath, + argv: ["legacy-constructor-main", libraryPath], + timeout: 30_000, + io: new NodePlatformIO(), + }); + expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0); + expect(result.stdout).toContain("lowered legacy constructor fork ok"); + }, 30_000); + + it("resolves and replays a real DT_NEEDED closure from the process VFS", async () => { + const providerPath = buildSharedLibrary(` + int dependency_value(void) { + return 41; + } + `, "libneeded-provider"); + const consumerPath = buildSharedLibrary(` + extern int dependency_value(void); + int needed_value(void) { + return dependency_value() + 1; + } + `, "libneeded-consumer", [providerPath]); + const consumerBytes = new Uint8Array(readFileSync(consumerPath)); + const metadata = parseDylinkSection(consumerBytes); + expect(metadata?.neededDynlibs.some( + (dependency) => dependency.endsWith("libneeded-provider.so"), + )).toBe(true); + + const programPath = buildMainProgram(` + #include + #include + #include + #include + #include + typedef int (*needed_value_fn)(void); + int main(int argc, char **argv) { + void *lib = dlopen(argv[1], RTLD_NOW | RTLD_LOCAL); + if (!lib) { + fprintf(stderr, "needed dlopen failed: %s\\n", dlerror()); + return 2; + } + needed_value_fn needed_value = + (needed_value_fn)dlsym(lib, "needed_value"); + if (!needed_value || needed_value() != 42) return 3; + int pid = fork(); + if (pid < 0) return 4; + if (pid == 0) { + if (needed_value() != 42) exit(5); + if (dlclose(lib) != 0) exit(6); + exit(0); + } + int status = 0; + if (waitpid(pid, &status, 0) != pid) return 7; + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) return 8; + if (needed_value() != 42) return 9; + if (dlclose(lib) != 0) return 10; + puts("needed fork ok"); + return 0; + } + `); + + const result = await runCentralizedProgram({ + programPath, + argv: ["fork-needed-main", consumerPath], + timeout: 30_000, + io: new NodePlatformIO(), + }); + expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0); + expect(result.stdout).toContain("needed fork ok"); + }, 30_000); }); diff --git a/host/test/fork-function-catalog.test.ts b/host/test/fork-function-catalog.test.ts new file mode 100644 index 0000000000..e027f9daf2 --- /dev/null +++ b/host/test/fork-function-catalog.test.ts @@ -0,0 +1,149 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { ForkFunctionCatalog } from "../src/fork-function-catalog"; + +function catalogModule(): WebAssembly.Module { + const dir = mkdtempSync(join(tmpdir(), "kandelo-funcref-catalog-")); + const wat = join(dir, "catalog.wat"); + const wasm = join(dir, "catalog.wasm"); + writeFileSync(wat, `(module + (table $catalog (export "__wpk_fork_function_catalog") 2 2 funcref) + (func $first (result i32) i32.const 17) + (func $second (result i32) i32.const 29) + (elem (table $catalog) (i32.const 0) func $first $second) + )`); + execFileSync("wat2wasm", [wat, "-o", wasm]); + return new WebAssembly.Module(readFileSync(wasm)); +} + +describe("ForkFunctionCatalog", () => { + it("reconstructs the same logical function from a fresh module instance", () => { + const module = catalogModule(); + const parentInstance = new WebAssembly.Instance(module); + const childInstance = new WebAssembly.Instance(module); + const parentTable = parentInstance.exports.__wpk_fork_function_catalog as WebAssembly.Table; + const childTable = childInstance.exports.__wpk_fork_function_catalog as WebAssembly.Table; + + const parent = new ForkFunctionCatalog(); + parent.register(0, parentTable); + const recipe = parent.encode(parentTable.get(1)); + expect(recipe).toEqual({ moduleActivation: 0, ordinal: 1 }); + + const child = new ForkFunctionCatalog(); + child.register(0, childTable); + const reconstructed = child.decode(recipe); + expect(reconstructed).not.toBe(parentTable.get(1)); + expect(reconstructed).toBe(childTable.get(1)); + expect((reconstructed as () => number)()).toBe(29); + }); + + it("keeps side-module activation identities distinct", () => { + const module = catalogModule(); + const mainTable = new WebAssembly.Instance(module).exports + .__wpk_fork_function_catalog as WebAssembly.Table; + const sideTable = new WebAssembly.Instance(module).exports + .__wpk_fork_function_catalog as WebAssembly.Table; + const catalog = new ForkFunctionCatalog(); + catalog.register(0, mainTable); + catalog.register(7, sideTable); + + expect(catalog.encode(mainTable.get(0))).toEqual({ + moduleActivation: 0, + ordinal: 0, + }); + expect(catalog.encode(sideTable.get(0))).toEqual({ + moduleActivation: 7, + ordinal: 0, + }); + }); + + it("reconstructs a side-module funcref written into the shared process table", () => { + const module = catalogModule(); + const parentMain = new WebAssembly.Instance(module); + const parentSide = new WebAssembly.Instance(module); + const parentSideCatalog = parentSide.exports + .__wpk_fork_function_catalog as WebAssembly.Table; + const processTable = new WebAssembly.Table({ + element: "anyfunc", + initial: 1, + maximum: 1, + }); + processTable.set(0, parentSideCatalog.get(1)); + + const parent = new ForkFunctionCatalog(); + parent.register( + 0, + parentMain.exports.__wpk_fork_function_catalog as WebAssembly.Table, + ); + parent.register(9, parentSideCatalog); + const recipe = parent.encode(processTable.get(0)); + expect(recipe).toEqual({ moduleActivation: 9, ordinal: 1 }); + + const childMain = new WebAssembly.Instance(module); + const childSide = new WebAssembly.Instance(module); + const childSideCatalog = childSide.exports + .__wpk_fork_function_catalog as WebAssembly.Table; + const child = new ForkFunctionCatalog(); + child.register( + 0, + childMain.exports.__wpk_fork_function_catalog as WebAssembly.Table, + ); + child.register(9, childSideCatalog); + const reconstructed = child.decode(recipe); + expect(reconstructed).toBe(childSideCatalog.get(1)); + expect(reconstructed).not.toBe(processTable.get(0)); + expect((reconstructed as () => number)()).toBe(29); + }); + + it("rejects values that have no deterministic module recipe", () => { + const module = catalogModule(); + const first = new WebAssembly.Instance(module); + const second = new WebAssembly.Instance(module); + const catalog = new ForkFunctionCatalog(); + catalog.register( + 0, + first.exports.__wpk_fork_function_catalog as WebAssembly.Table, + ); + const foreign = ( + second.exports.__wpk_fork_function_catalog as WebAssembly.Table + ).get(0); + expect(() => catalog.encode(foreign)).toThrow("absent from"); + }); + + it("rebinds shared function aliases when a module is unloaded", () => { + const module = catalogModule(); + const source = new WebAssembly.Instance(module).exports + .__wpk_fork_function_catalog as WebAssembly.Table; + const value = source.get(0); + const first = new WebAssembly.Table({ + element: "anyfunc", + initial: 1, + maximum: 1, + }); + const second = new WebAssembly.Table({ + element: "anyfunc", + initial: 1, + maximum: 1, + }); + first.set(0, value); + second.set(0, value); + + const catalog = new ForkFunctionCatalog(); + catalog.register(7, second); + catalog.register(2, first); + expect(catalog.encode(value)).toEqual({ + moduleActivation: 2, + ordinal: 0, + }); + catalog.unregister(2); + expect(catalog.encode(value)).toEqual({ + moduleActivation: 7, + ordinal: 0, + }); + catalog.unregister(7); + expect(() => catalog.encode(value)).toThrow("absent from"); + }); +}); diff --git a/host/test/fork-gc-codec.test.ts b/host/test/fork-gc-codec.test.ts new file mode 100644 index 0000000000..ec1cd64ea5 --- /dev/null +++ b/host/test/fork-gc-codec.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from "vitest"; + +import { ForkAnyrefTransitTable } from "../src/fork-anyref-transit"; +import { + FORK_GC_FIELD_MUTABLE, + FORK_GC_FIELD_REFERENCE, + FORK_GC_LAYOUT_REQUIRES_PROVENANCE, + ForkGcCodecDescriptor, + ForkGcConstructorKind, + ForkGcLayoutKind, + ForkGcProvenanceRegistry, + decodeForkGcCodecDescriptor, +} from "../src/fork-gc-codec"; +import { + WPK_FORK_GC_CODEC_FIELD_RECORD_SIZE, + WPK_FORK_GC_CODEC_HEADER_SIZE, + WPK_FORK_GC_CODEC_LAYOUT_RECORD_SIZE, + WPK_FORK_GC_CODEC_MAGIC, + WPK_FORK_GC_CODEC_VERSION, +} from "../src/generated/abi"; + +const GC_OBJECT_MODULE = Uint8Array.of( + 0, 97, 115, 109, 1, 0, 0, 0, 1, 9, 2, 95, 1, 127, 1, 96, 1, 127, 0, + 2, 35, 1, 3, 101, 110, 118, 25, 95, 95, 119, 112, 107, 95, 102, 111, + 114, 107, 95, 114, 101, 102, 95, 103, 99, 95, 116, 114, 97, 110, 115, + 105, 116, 1, 110, 0, 1, 3, 2, 1, 1, 7, 11, 1, 7, 112, 117, 98, 108, + 105, 115, 104, 0, 0, 10, 13, 1, 11, 0, 65, 0, 32, 0, 251, 0, 0, 38, + 0, 11, +); + +function descriptorBytes(): Uint8Array { + const bytes = new Uint8Array( + WPK_FORK_GC_CODEC_HEADER_SIZE + + WPK_FORK_GC_CODEC_LAYOUT_RECORD_SIZE + + WPK_FORK_GC_CODEC_FIELD_RECORD_SIZE, + ); + bytes.set(WPK_FORK_GC_CODEC_MAGIC); + const view = new DataView(bytes.buffer); + view.setUint16(4, WPK_FORK_GC_CODEC_VERSION, true); + view.setUint16(6, WPK_FORK_GC_CODEC_HEADER_SIZE, true); + view.setUint32(8, 1, true); + view.setUint32(12, 1, true); + const layout = WPK_FORK_GC_CODEC_HEADER_SIZE; + view.setUint32(layout, 1, true); + view.setUint32(layout + 4, 0, true); + view.setUint8(layout + 8, ForkGcLayoutKind.Struct); + view.setUint8(layout + 9, ForkGcConstructorKind.Struct); + view.setUint16(layout + 10, FORK_GC_LAYOUT_REQUIRES_PROVENANCE, true); + view.setUint32(layout + 12, 0, true); + view.setUint32(layout + 16, 0, true); + view.setUint32(layout + 20, 1, true); + view.setUint32(layout + 24, 0xffff_ffff, true); + view.setUint32(layout + 28, 1, true); + view.setUint32(layout + 32, 0, true); + view.setUint32(layout + 36, 0, true); + view.setUint32(layout + 40, 1, true); + const field = layout + WPK_FORK_GC_CODEC_LAYOUT_RECORD_SIZE; + view.setUint8(field, 8); + view.setUint8( + field + 1, + FORK_GC_FIELD_MUTABLE | FORK_GC_FIELD_REFERENCE, + ); + view.setUint32(field + 4, 0xffff_ffff, true); + view.setUint32(field + 8, 0, true); + return bytes; +} + +function objectFixture(): { + transit: ForkAnyrefTransitTable; + publish(value: number): void; +} { + const transit = new ForkAnyrefTransitTable(); + const instance = new WebAssembly.Instance( + new WebAssembly.Module(GC_OBJECT_MODULE), + { env: { __wpk_fork_ref_gc_transit: transit.table } }, + ); + return { + transit, + publish: instance.exports.publish as (value: number) => void, + }; +} + +describe("fork GC codec metadata", () => { + it("decodes the canonical structural/provenance layout", () => { + const descriptor = decodeForkGcCodecDescriptor(descriptorBytes()); + expect(descriptor.require(1)).toMatchObject({ + id: 1, + kind: ForkGcLayoutKind.Struct, + provenanceReferenceCount: 1, + }); + }); + + it("rejects malformed magic, field order, and base coordinates", () => { + const magic = descriptorBytes(); + magic[0] ^= 1; + expect(() => decodeForkGcCodecDescriptor(magic)).toThrow(/magic/); + + const fields = descriptorBytes(); + new DataView(fields.buffer).setUint32( + WPK_FORK_GC_CODEC_HEADER_SIZE + 16, + 1, + true, + ); + expect(() => decodeForkGcCodecDescriptor(fields)).toThrow(); + + expect(() => new ForkGcCodecDescriptor([{ + ...decodeForkGcCodecDescriptor(descriptorBytes()).require(1), + baseLayoutId: 2, + }])).toThrow(/invalid base/); + }); +}); + +describe("ForkGcProvenanceRegistry", () => { + it("records exact activation/base evidence without retaining a pending root", () => { + const descriptor = decodeForkGcCodecDescriptor(descriptorBytes()); + const provenance = new ForkGcProvenanceRegistry(); + const { transit, publish } = objectFixture(); + publish(11); + const object = transit.get(0); + const token = provenance.begin( + transit.table, + descriptor, + 7, + 0, + 7, + 1, + 1, + 0n, + 0n, + 1, + ); + publish(12); + const seed = transit.get(0); + provenance.appendReference(transit.table, token, 0, 0); + transit.clearSlot(0); + provenance.end(token); + + expect(provenance.lookup(object, 7, descriptor, 1)).toMatchObject({ + activationId: 7, + baseLayoutId: 1, + layoutId: 1, + references: [seed], + }); + }); + + it("retains a nullable zero-length constructor seed as recipe-zero evidence", () => { + const descriptor = decodeForkGcCodecDescriptor(descriptorBytes()); + const provenance = new ForkGcProvenanceRegistry(); + const { transit, publish } = objectFixture(); + publish(13); + const object = transit.get(0); + const token = provenance.begin( + transit.table, + descriptor, + 7, + 0, + 7, + 1, + 1, + 0n, + 0n, + 1, + ); + transit.clearSlot(0); + provenance.appendReference(transit.table, token, 0, 0); + provenance.end(token); + + expect(provenance.lookup(object, 7, descriptor, 1)).toMatchObject({ + references: [null], + }); + }); + + it("fails closed for wrong activation/base and reentrant/interleaved hooks", () => { + const descriptor = decodeForkGcCodecDescriptor(descriptorBytes()); + const provenance = new ForkGcProvenanceRegistry(); + const { transit, publish } = objectFixture(); + publish(1); + expect(() => provenance.begin( + transit.table, + descriptor, + 3, + 0, + 4, + 1, + 1, + 0n, + 0n, + 1, + )).toThrow(/cannot register/); + expect(transit.get(0)).toBeNull(); + + publish(2); + expect(() => provenance.begin( + transit.table, + descriptor, + 3, + 0, + 3, + 1, + 2, + 0n, + 0n, + 1, + )).toThrow(/unknown GC layout/); + expect(transit.get(0)).toBeNull(); + + publish(3); + const token = provenance.begin( + transit.table, + descriptor, + 3, + 0, + 3, + 1, + 1, + 0n, + 0n, + 1, + ); + publish(4); + expect(() => provenance.begin( + transit.table, + descriptor, + 3, + 0, + 3, + 1, + 1, + 0n, + 0n, + 1, + )).toThrow(/still pending/); + expect(() => provenance.end(token)).toThrow(/not active/); + + publish(5); + const next = provenance.begin( + transit.table, + descriptor, + 3, + 0, + 3, + 1, + 1, + 0n, + 0n, + 1, + ); + publish(6); + expect(() => + provenance.appendReference(transit.table, next, 1, 0) + ).toThrow(/canonical order/); + expect(transit.get(0)).toBeNull(); + expect(() => provenance.end(next)).toThrow(/not active/); + }); +}); diff --git a/host/test/fork-host-import-runtime.test.ts b/host/test/fork-host-import-runtime.test.ts new file mode 100644 index 0000000000..c4b9347fa2 --- /dev/null +++ b/host/test/fork-host-import-runtime.test.ts @@ -0,0 +1,700 @@ +import { execFileSync } from "node:child_process"; +import { + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + readWasmFunctionImports, +} from "../src/constants"; +import { + defineForkExternrefImport, + forkExternrefImportMailboxBytes, + type ForkExternrefImportWake, +} from "../src/fork-externref-import-mailbox"; +import { + ForkHostImportOwnerRuntime, + ForkHostImportWorkerRuntime, +} from "../src/fork-host-import-runtime"; +import { ForkExternrefProcessOwner } from "../src/fork-externref-process-owner"; +import { + ForkExternrefTokenCache, +} from "../src/fork-reference-broker"; +import { + isForkWorkerExceptionCapability, +} from "../src/fork-worker-exception-capability"; + +function uleb(value: number): number[] { + const bytes: number[] = []; + do { + let byte = value & 0x7f; + value >>>= 7; + if (value !== 0) byte |= 0x80; + bytes.push(byte); + } while (value !== 0); + return bytes; +} + +function name(value: string): number[] { + const bytes = [...new TextEncoder().encode(value)]; + return [...uleb(bytes.length), ...bytes]; +} + +function section(id: number, payload: number[]): number[] { + return [id, ...uleb(payload.length), ...payload]; +} + +function importedFunctionsModule(): ArrayBuffer { + const typeSection = section(1, [ + 2, + 0x60, 2, 0x7f, 0x63, 0x6f, 1, 0x64, 0x6f, + 0x60, 1, 0x7f, 1, 0x7f, + ]); + const importSection = section(2, [ + 2, + ...name("host"), ...name("opaque"), 0, 0, + ...name("env"), ...name("local"), 0, 1, + ]); + return new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, + 0x01, 0x00, 0x00, 0x00, + ...typeSection, + ...importSection, + ]).buffer; +} + +function taggedImportModule(): ArrayBuffer { + const typeSection = section(1, [ + 1, + 0x60, 0, 0, + ]); + const importSection = section(2, [ + 1, + ...name("env"), ...name("throw_tagged"), 0, 0, + ]); + return new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, + 0x01, 0x00, 0x00, 0x00, + ...typeSection, + ...importSection, + ]).buffer; +} + +function typedBoundaryImportsModule(): ArrayBuffer { + const typeSection = section(1, [ + 1, + 0x60, + 12, + 0x7b, // v128 + 0x70, // funcref + 0x6f, // externref + 0x6e, // anyref + 0x6d, // eqref + 0x6c, // i31ref + 0x6b, // structref + 0x6a, // arrayref + 0x69, // exnref + 0x63, 0x00, // (ref null 0) + 0x64, 0x00, // (ref 0) + 0x63, 0x65, 0x00, // (ref null shared 0) + 2, + 0x68, // contref + 0x74, // noexnref + ]); + const importSection = section(2, [ + 1, + ...name("typed"), ...name("all"), 0, 0, + ]); + return new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, + 0x01, 0x00, 0x00, 0x00, + ...typeSection, + ...importSection, + ]).buffer; +} + +function compileWat( + directory: string, + stem: string, + source: string, + flags: readonly string[] = [], +): ArrayBuffer { + const watPath = join(directory, `${stem}.wat`); + const wasmPath = join(directory, `${stem}.wasm`); + writeFileSync(watPath, source); + execFileSync("wat2wasm", [ + ...flags, + watPath, + "-o", + wasmPath, + ]); + const file = readFileSync(wasmPath); + return file.buffer.slice( + file.byteOffset, + file.byteOffset + file.byteLength, + ) as ArrayBuffer; +} + +function thrownBy(fn: () => unknown): unknown { + let didThrow = false; + let thrown: unknown; + try { + fn(); + } catch (value) { + didThrow = true; + thrown = value; + } + expect(didThrow).toBe(true); + return thrown; +} + +describe("production fork host-import routing", () => { + it("retains complete scalar, vector, abstract, and concrete import types", () => { + const [imported] = readWasmFunctionImports( + typedBoundaryImportsModule(), + ); + expect(imported?.signature.params).toEqual([ + 0x7b, 0x70, 0x6f, 0x6e, 0x6d, 0x6c, 0x6b, 0x6a, 0x69, + 0x63, 0x64, 0x63, + ]); + expect(imported?.signature.paramTypes).toEqual([ + { code: 0x7b, shared: false }, + { code: 0x70, shared: false }, + { code: 0x6f, shared: false }, + { code: 0x6e, shared: false }, + { code: 0x6d, shared: false }, + { code: 0x6c, shared: false }, + { code: 0x6b, shared: false }, + { code: 0x6a, shared: false }, + { code: 0x69, shared: false }, + { code: 0x63, heapType: 0, shared: false }, + { code: 0x64, heapType: 0, shared: false }, + { code: 0x63, heapType: 0, shared: true }, + ]); + expect(imported?.signature.resultTypes).toEqual([ + { code: 0x68, shared: false }, + { code: 0x74, shared: false }, + ]); + }); + + it("keeps v128 and exnref imports on a direct Wasm-to-Wasm boundary", () => { + const directory = mkdtempSync(join(tmpdir(), "kandelo-typed-import-")); + try { + const vectorProviderBytes = compileWat( + directory, + "vector-provider", + `(module + (func (export "id") (param v128) (result v128) + local.get 0))`, + ); + const vectorConsumerBytes = compileWat( + directory, + "vector-consumer", + `(module + (import "m" "id" (func $id (param v128) (result v128))) + (func (export "run") (result i32) + v128.const i32x4 1 2 3 4 + call $id + i32x4.extract_lane 2))`, + ); + const exceptionProviderBytes = compileWat( + directory, + "exception-provider", + `(module + (func (export "id") (param exnref) (result exnref) + local.get 0))`, + ["--enable-exceptions"], + ); + const exceptionConsumerBytes = compileWat( + directory, + "exception-consumer", + `(module + (import "m" "id" (func $id (param exnref) (result exnref))) + (tag $tag (param i32)) + (func (export "run") (result i32) + (block $done (result i32) + (try_table (result i32) (catch $tag $done) + (block $captured (result i32 exnref) + (try_table (result i32 exnref) + (catch_ref $tag $captured) + i32.const 77 + throw $tag)) + call $id + throw_ref))))`, + ["--enable-exceptions"], + ); + + const processOwner = new ForkExternrefProcessOwner(); + const generation = processOwner.startGeneration(408); + const ownerRuntime = new ForkHostImportOwnerRuntime(processOwner); + const ownerWorker = ownerRuntime.createWorker({ + pid: generation.pid, + generationId: generation.id, + authorizeSender: () => {}, + }); + const workerRuntime = new ForkHostImportWorkerRuntime( + ownerWorker.init, + generation.pid, + generation.id, + new ForkExternrefTokenCache(generation.id), + (wake) => expect(ownerWorker.dispatch(wake)).toBe(true), + ); + + const vectorProvider = new WebAssembly.Instance( + new WebAssembly.Module(vectorProviderBytes), + ); + const vectorId = + vectorProvider.exports.id as CallableFunction; + const routedVector = workerRuntime.routeImportObject( + vectorConsumerBytes, + { m: { id: vectorId } }, + ); + expect(routedVector.m!.id).toBe(vectorId); + const vectorConsumer = new WebAssembly.Instance( + new WebAssembly.Module(vectorConsumerBytes), + routedVector, + ); + expect((vectorConsumer.exports.run as CallableFunction)()).toBe(3); + + const exceptionProvider = new WebAssembly.Instance( + new WebAssembly.Module(exceptionProviderBytes), + ); + const exceptionId = + exceptionProvider.exports.id as CallableFunction; + const routedException = workerRuntime.routeImportObject( + exceptionConsumerBytes, + { m: { id: exceptionId } }, + ); + expect(routedException.m!.id).toBe(exceptionId); + const exceptionConsumer = new WebAssembly.Instance( + new WebAssembly.Module(exceptionConsumerBytes), + routedException, + ); + expect((exceptionConsumer.exports.run as CallableFunction)()).toBe(77); + ownerWorker.close(); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + it("parses exact artifact signatures and routes only registered opaque imports", () => { + const bytes = importedFunctionsModule(); + expect(readWasmFunctionImports(bytes)).toEqual([ + { + module: "host", + name: "opaque", + importOrdinal: 0, + functionIndex: 0, + signature: { + params: [0x7f, 0x63], + results: [0x64], + paramTypes: [ + { code: 0x7f, shared: false }, + { code: 0x63, heapType: -17, shared: false }, + ], + resultTypes: [{ code: 0x64, heapType: -17, shared: false }], + }, + }, + { + module: "env", + name: "local", + importOrdinal: 1, + functionIndex: 1, + signature: { + params: [0x7f], + results: [0x7f], + paramTypes: [{ code: 0x7f, shared: false }], + resultTypes: [{ code: 0x7f, shared: false }], + }, + }, + ]); + + const processOwner = new ForkExternrefProcessOwner(); + const generation = processOwner.startGeneration(401); + const ownerRuntime = new ForkHostImportOwnerRuntime(processOwner); + const opaque = defineForkExternrefImport( + 100, + ["i32", "externref"], + ["externref"], + ); + const seen: unknown[] = []; + ownerRuntime.register( + "host", + "opaque", + opaque, + (_context, scalar, value) => { + expect(scalar).toBe(9); + seen.push(value); + return value; + }, + ); + + let current = true; + const ownerWorker = ownerRuntime.createWorker({ + pid: generation.pid, + generationId: generation.id, + authorizeSender: () => { + if (!current) throw new Error("replaced Worker"); + }, + }); + const tokens = new ForkExternrefTokenCache(generation.id); + const wakes: ForkExternrefImportWake[] = []; + const workerRuntime = new ForkHostImportWorkerRuntime( + structuredClone(ownerWorker.init), + generation.pid, + generation.id, + tokens, + (wake) => { + wakes.push(wake); + expect(ownerWorker.dispatch(wake)).toBe(true); + }, + ); + const local = (value: number): number => value + 1; + const routed = workerRuntime.routeImportObject(bytes, { + host: { opaque: () => "unsafe local fallback" }, + env: { local }, + }); + const realValue = { ownerOnly: true }; + const handle = processOwner.registerForWire( + generation.pid, + generation.id, + realValue, + ); + const token = tokens.materialize(handle); + + expect( + (routed.host!.opaque as CallableFunction)(9, token), + ).toBe(token); + expect(seen).toEqual([realValue]); + const wakeCount = wakes.length; + expect((routed.env!.local as CallableFunction)(4)).toBe(5); + // The scalar memory-local fast path does not call the owner. + expect(wakes).toHaveLength(wakeCount); + + current = false; + expect(() => + (routed.host!.opaque as CallableFunction)(9, token) + ).toThrow(/Unauthorized/); + ownerWorker.close(); + }); + + it("preserves a primitive rethrow and unwraps its capture-time child token", () => { + const bytes = importedFunctionsModule(); + const processOwner = new ForkExternrefProcessOwner(); + const generation = processOwner.startGeneration(402); + const ownerRuntime = new ForkHostImportOwnerRuntime(processOwner); + const opaque = defineForkExternrefImport( + 101, + ["i32", "externref"], + ["externref"], + ); + const seen: unknown[] = []; + ownerRuntime.register( + "host", + "opaque", + opaque, + (_context, _scalar, value) => { + seen.push(value); + return value; + }, + ); + const ownerWorker = ownerRuntime.createWorker({ + pid: generation.pid, + generationId: generation.id, + authorizeSender: () => {}, + }); + const tokens = new ForkExternrefTokenCache(generation.id); + const workerRuntime = new ForkHostImportWorkerRuntime( + ownerWorker.init, + generation.pid, + generation.id, + tokens, + (wake) => { + expect(ownerWorker.dispatch(wake)).toBe(true); + }, + ); + const routed = workerRuntime.routeImportObject(bytes, { + host: { opaque: () => undefined }, + env: { + local: () => { + throw null; + }, + }, + }); + const importThrown = thrownBy( + routed.env!.local as CallableFunction, + ); + expect(importThrown).toBeNull(); + const normalizedNull = + workerRuntime.localExceptions.normalizeUnclaimedForkException( + importThrown, + ); + expect(tokens.encode(normalizedNull)).not.toBeNull(); + + expect( + (routed.host!.opaque as CallableFunction)(1, normalizedNull), + ).toBeNull(); + expect(seen).toEqual([null]); + ownerWorker.close(); + }); + + it("preserves an Error rethrow and captures a stable child capability", () => { + const bytes = importedFunctionsModule(); + const processOwner = new ForkExternrefProcessOwner(); + const generation = processOwner.startGeneration(403); + const ownerRuntime = new ForkHostImportOwnerRuntime(processOwner); + const opaque = defineForkExternrefImport( + 102, + ["i32", "externref"], + ["externref"], + ); + let observed: unknown; + ownerRuntime.register( + "host", + "opaque", + opaque, + (_context, _scalar, value) => { + observed = value; + return value; + }, + ); + const ownerWorker = ownerRuntime.createWorker({ + pid: generation.pid, + generationId: generation.id, + authorizeSender: () => {}, + }); + const tokens = new ForkExternrefTokenCache(generation.id); + const workerRuntime = new ForkHostImportWorkerRuntime( + ownerWorker.init, + generation.pid, + generation.id, + tokens, + (wake) => { + expect(ownerWorker.dispatch(wake)).toBe(true); + }, + ); + const error = new RangeError("Worker-local range failure"); + const routed = workerRuntime.routeImportObject(bytes, { + host: { opaque: () => undefined }, + env: { + local: () => { + throw error; + }, + }, + }); + const importThrown = thrownBy(routed.env!.local as CallableFunction); + expect(importThrown).toBe(error); + const normalized = + workerRuntime.localExceptions.normalizeUnclaimedForkException( + importThrown, + ); + const echoed = (routed.host!.opaque as CallableFunction)(1, normalized); + + expect(echoed).toBe(normalized); + expect(isForkWorkerExceptionCapability(observed)).toBe(true); + expect(observed).toMatchObject({ + kind: "error", + name: "RangeError", + message: error.message, + }); + ownerWorker.close(); + }); + + it("preserves exact imported-tag exception semantics before fork capture", () => { + const processOwner = new ForkExternrefProcessOwner(); + const generation = processOwner.startGeneration(405); + const ownerRuntime = new ForkHostImportOwnerRuntime(processOwner); + const ownerWorker = ownerRuntime.createWorker({ + pid: generation.pid, + generationId: generation.id, + authorizeSender: () => {}, + }); + const workerRuntime = new ForkHostImportWorkerRuntime( + ownerWorker.init, + generation.pid, + generation.id, + new ForkExternrefTokenCache(generation.id), + (wake) => { + expect(ownerWorker.dispatch(wake)).toBe(true); + }, + ); + const exception = new WebAssembly.Exception( + new WebAssembly.Tag({ parameters: [] }), + [], + ); + const routed = workerRuntime.routeImportObject( + taggedImportModule(), + { + env: { + throw_tagged: () => { + throw exception; + }, + }, + }, + ); + + expect( + thrownBy(routed.env!.throw_tagged as CallableFunction), + ).toBe(exception); + ownerWorker.close(); + }); + + it("keeps imported-tag Catch and CatchRef matching on the real Wasm boundary", () => { + const directory = mkdtempSync(join(tmpdir(), "kandelo-import-tag-")); + try { + const watPath = join(directory, "import-tag.wat"); + const wasmPath = join(directory, "import-tag.wasm"); + writeFileSync(watPath, `(module + (import "env" "tag" (tag $tag (param i32))) + (import "env" "throw_tagged" (func $throw_tagged)) + (import "env" "throw_any" (func $throw_any)) + (func (export "catch_plain") (result i32) + (block $caught (result i32) + (try_table (result i32) (catch $tag $caught) + call $throw_tagged + i32.const -1))) + (func (export "catch_ref") (result i32) + (block $caught (result i32 exnref) + (try_table (result i32 exnref) (catch_ref $tag $caught) + call $throw_tagged + i32.const -1 + ref.null exn)) + drop) + (func (export "catch_all_rethrow") + (block $caught (result exnref) + (try_table (result exnref) (catch_all_ref $caught) + call $throw_any + unreachable)) + (throw_ref)))`); + execFileSync("wat2wasm", [ + "--enable-exceptions", + watPath, + "-o", + wasmPath, + ]); + const file = readFileSync(wasmPath); + const bytes = file.buffer.slice( + file.byteOffset, + file.byteOffset + file.byteLength, + ) as ArrayBuffer; + const processOwner = new ForkExternrefProcessOwner(); + const generation = processOwner.startGeneration(406); + const ownerRuntime = new ForkHostImportOwnerRuntime(processOwner); + const ownerWorker = ownerRuntime.createWorker({ + pid: generation.pid, + generationId: generation.id, + authorizeSender: () => {}, + }); + const workerRuntime = new ForkHostImportWorkerRuntime( + ownerWorker.init, + generation.pid, + generation.id, + new ForkExternrefTokenCache(generation.id), + (wake) => { + expect(ownerWorker.dispatch(wake)).toBe(true); + }, + ); + const tag = new WebAssembly.Tag({ parameters: ["i32"] }); + let arbitraryThrown: unknown; + const routed = workerRuntime.routeImportObject(bytes, { + env: { + tag, + throw_tagged: () => { + throw new WebAssembly.Exception(tag, [37]); + }, + throw_any: () => { + throw arbitraryThrown; + }, + }, + }); + const instance = new WebAssembly.Instance( + new WebAssembly.Module(bytes), + routed, + ); + + expect((instance.exports.catch_plain as CallableFunction)()).toBe(37); + expect((instance.exports.catch_ref as CallableFunction)()).toBe(37); + const workerObject = Object.freeze({ callback: () => 1 }); + for (const value of [workerObject, -0, "exact primitive"]) { + arbitraryThrown = value; + expect( + Object.is( + thrownBy( + instance.exports.catch_all_rethrow as CallableFunction, + ), + value, + ), + ).toBe(true); + } + ownerWorker.close(); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + it("allocates one distinct catalog-sized mailbox per pthread Worker", () => { + const processOwner = new ForkExternrefProcessOwner(); + const generation = processOwner.startGeneration(404); + const ownerRuntime = new ForkHostImportOwnerRuntime(processOwner); + const wideDescriptor = defineForkExternrefImport( + 91, + Array(33).fill("i32"), + Array(19).fill("i32"), + ); + ownerRuntime.register( + "host", + "wide", + wideDescriptor, + (_context, ...args) => args.slice(0, 19), + ); + const main = ownerRuntime.createWorker({ + pid: generation.pid, + generationId: generation.id, + authorizeSender: () => {}, + }); + const pthread = ownerRuntime.createWorker({ + pid: generation.pid, + generationId: generation.id, + authorizeSender: () => {}, + }); + + expect(main.init.mailbox).not.toBe(pthread.init.mailbox); + const expectedBytes = forkExternrefImportMailboxBytes({ + params: 33, + results: 19, + }); + expect(main.init.mailbox.byteLength).toBe(expectedBytes); + expect(pthread.init.mailbox.byteLength).toBe(expectedBytes); + expect(main.init.senderId).not.toBe(pthread.init.senderId); + const mainRuntime = new ForkHostImportWorkerRuntime( + main.init, + generation.pid, + generation.id, + new ForkExternrefTokenCache(generation.id), + (wake) => expect(main.dispatch(wake)).toBe(true), + ); + const pthreadRuntime = new ForkHostImportWorkerRuntime( + pthread.init, + generation.pid, + generation.id, + new ForkExternrefTokenCache(generation.id), + (wake) => expect(pthread.dispatch(wake)).toBe(true), + ); + const args = Array.from({ length: 33 }, (_, index) => index); + const expectedResults = args.slice(0, 19); + expect(mainRuntime.caller.call(wideDescriptor, args)).toEqual( + expectedResults, + ); + expect(pthreadRuntime.caller.call(wideDescriptor, args)).toEqual( + expectedResults, + ); + mainRuntime.clear(); + pthreadRuntime.clear(); + main.close(); + pthread.close(); + }); +}); diff --git a/host/test/fork-imported-globals.test.ts b/host/test/fork-imported-globals.test.ts new file mode 100644 index 0000000000..616d0a83e8 --- /dev/null +++ b/host/test/fork-imported-globals.test.ts @@ -0,0 +1,795 @@ +import { execFileSync } from "node:child_process"; +import { + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + ForkImportedGlobalCapture, + ForkImportedGlobalPlanner, + type ForkImportedReferenceProvider, + type ForkWasmImports, +} from "../src/fork-imported-globals"; +import { + decodeForkImportedTableBindings, + ForkImportedGlobalBindingKind, + ForkImportedTableBindingKind, + ForkModuleStateArena, + ForkModuleStateRecordKind, + ForkTableDirtyTracker, +} from "../src/fork-module-state"; +import { + WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE, + WPK_FORK_IMPORTED_GLOBALS_MAGIC, + WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE, + WPK_FORK_IMPORTED_GLOBALS_SECTION, + WPK_FORK_IMPORTED_GLOBALS_VERSION, + WPK_FORK_IMPORTED_TABLES_HEADER_SIZE, + WPK_FORK_IMPORTED_TABLES_MAGIC, + WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE, + WPK_FORK_IMPORTED_TABLES_SECTION, + WPK_FORK_IMPORTED_TABLES_VERSION, + WPK_FORK_MODULE_STATE_GLOBAL_HEADER_SIZE, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I32, +} from "../src/generated/abi"; + +const PAGE_SIZE = 65_536; + +function uleb128(value: number): number[] { + const result: number[] = []; + do { + let byte = value & 0x7f; + value >>>= 7; + if (value !== 0) byte |= 0x80; + result.push(byte); + } while (value !== 0); + return result; +} + +function importedGlobalsSection( + records: ReadonlyArray<{ + module: string; + name: string; + importOrdinal?: number; + ownerId: number; + typeCode: number; + }>, +): Uint8Array { + const encoder = new TextEncoder(); + const encoded = records.map((record, importOrdinal) => ({ + ...record, + importOrdinal: record.importOrdinal ?? importOrdinal, + moduleBytes: encoder.encode(record.module), + nameBytes: encoder.encode(record.name), + })); + const size = WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE + + encoded.reduce( + (sum, record) => + sum + WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE + + record.moduleBytes.byteLength + record.nameBytes.byteLength, + 0, + ); + const bytes = new Uint8Array(size); + const view = new DataView(bytes.buffer); + bytes.set(WPK_FORK_IMPORTED_GLOBALS_MAGIC); + view.setUint16(4, WPK_FORK_IMPORTED_GLOBALS_VERSION, true); + view.setUint16(6, WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE, true); + view.setUint32(8, encoded.length, true); + let offset = WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE; + for (const record of encoded) { + const recordSize = WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE + + record.moduleBytes.byteLength + record.nameBytes.byteLength; + view.setUint32(offset, recordSize, true); + view.setUint32(offset + 4, record.ownerId, true); + view.setUint8(offset + 8, record.typeCode); + view.setUint32(offset + 12, record.moduleBytes.byteLength, true); + view.setUint32(offset + 16, record.nameBytes.byteLength, true); + view.setUint32(offset + 20, record.importOrdinal, true); + bytes.set( + record.moduleBytes, + offset + WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE, + ); + bytes.set( + record.nameBytes, + offset + WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE + + record.moduleBytes.byteLength, + ); + offset += recordSize; + } + return bytes; +} + +function importedTablesSection( + records: ReadonlyArray<{ + module: string; + name: string; + importOrdinal?: number; + ownerId: number; + typeCode: number; + table64?: boolean; + }>, +): Uint8Array { + const encoder = new TextEncoder(); + const encoded = records.map((record, importOrdinal) => ({ + ...record, + importOrdinal: record.importOrdinal ?? importOrdinal, + moduleBytes: encoder.encode(record.module), + nameBytes: encoder.encode(record.name), + })); + const size = WPK_FORK_IMPORTED_TABLES_HEADER_SIZE + + encoded.reduce( + (sum, record) => + sum + WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE + + record.moduleBytes.byteLength + record.nameBytes.byteLength, + 0, + ); + const bytes = new Uint8Array(size); + const view = new DataView(bytes.buffer); + bytes.set(WPK_FORK_IMPORTED_TABLES_MAGIC); + view.setUint16(4, WPK_FORK_IMPORTED_TABLES_VERSION, true); + view.setUint16(6, WPK_FORK_IMPORTED_TABLES_HEADER_SIZE, true); + view.setUint32(8, encoded.length, true); + let offset = WPK_FORK_IMPORTED_TABLES_HEADER_SIZE; + for (const record of encoded) { + const recordSize = WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE + + record.moduleBytes.byteLength + record.nameBytes.byteLength; + view.setUint32(offset, recordSize, true); + view.setUint32(offset + 4, record.ownerId, true); + view.setUint8(offset + 8, record.typeCode); + view.setUint8(offset + 9, record.table64 ? 1 : 0); + view.setUint32(offset + 12, record.moduleBytes.byteLength, true); + view.setUint32(offset + 16, record.nameBytes.byteLength, true); + view.setUint32(offset + 20, record.importOrdinal, true); + bytes.set( + record.moduleBytes, + offset + WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE, + ); + bytes.set( + record.nameBytes, + offset + WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE + + record.moduleBytes.byteLength, + ); + offset += recordSize; + } + return bytes; +} + +function appendCustomSection( + wasm: Uint8Array, + name: string, + data: Uint8Array, +): Uint8Array { + const nameBytes = new TextEncoder().encode(name); + const payload = new Uint8Array( + uleb128(nameBytes.byteLength).length + nameBytes.byteLength + data.byteLength, + ); + const nameLength = uleb128(nameBytes.byteLength); + payload.set(nameLength); + payload.set(nameBytes, nameLength.length); + payload.set(data, nameLength.length + nameBytes.byteLength); + const sectionSize = uleb128(payload.byteLength); + const result = new Uint8Array(wasm.byteLength + 1 + sectionSize.length + payload.byteLength); + result.set(wasm); + let offset = wasm.byteLength; + result[offset++] = 0; + result.set(sectionSize, offset); + offset += sectionSize.length; + result.set(payload, offset); + return result; +} + +function compileModule( + wat: string, + descriptor: Uint8Array, + tableDescriptor = importedTablesSection([]), +): WebAssembly.Module { + const directory = mkdtempSync(join(tmpdir(), "kandelo-imported-globals-")); + try { + const watPath = join(directory, "fixture.wat"); + const wasmPath = join(directory, "fixture.wasm"); + writeFileSync(watPath, wat); + execFileSync("wat2wasm", [ + "--enable-exceptions", + watPath, + "-o", + wasmPath, + ]); + const withGlobals = appendCustomSection( + readFileSync(wasmPath), + WPK_FORK_IMPORTED_GLOBALS_SECTION, + descriptor, + ); + const bytes = appendCustomSection( + withGlobals, + WPK_FORK_IMPORTED_TABLES_SECTION, + tableDescriptor, + ); + // Node Buffers are typed as ArrayBufferLike, while the WebAssembly + // constructor correctly requires an owned, non-shared BufferSource. + const owned = new Uint8Array(bytes.byteLength); + owned.set(bytes); + return new WebAssembly.Module(owned); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +function allocator(memory: WebAssembly.Memory) { + let next = PAGE_SIZE; + return { + allocate(size: number): number { + const address = next; + next += Math.ceil(size / 8) * 8; + if (next > memory.buffer.byteLength) { + memory.grow(Math.ceil((next - memory.buffer.byteLength) / PAGE_SIZE)); + } + return address; + }, + deallocate(): void {}, + }; +} + +function referenceGlobal(typeCode: number, recipeId: number): Uint8Array { + const payload = new Uint8Array(WPK_FORK_MODULE_STATE_GLOBAL_HEADER_SIZE + 4); + const view = new DataView(payload.buffer); + view.setUint8(0, typeCode); + view.setUint8(1, 4); + view.setUint32(WPK_FORK_MODULE_STATE_GLOBAL_HEADER_SIZE, recipeId, true); + return payload; +} + +describe("fork imported-global provider planning", () => { + it("rebinds fresh funcref/externref/exnref providers before const initialization", () => { + const providerModule = compileModule( + `(module + (func (export "callback") (result i32) i32.const 73) + (global (export "__wpk_fork_global_1") exnref (ref.null exn)))`, + importedGlobalsSection([]), + ); + const descriptors = [ + { + module: "provider", + name: "callback", + ownerId: 1, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + }, + { + module: "provider", + name: "token", + ownerId: 2, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + }, + { + module: "provider", + name: "exception", + ownerId: 3, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF, + }, + ] as const; + const consumerModule = compileModule( + `(module + (import "provider" "callback" (global $callback funcref)) + (import "provider" "token" (global $token externref)) + (import "provider" "exception" (global $exception exnref)) + (global $callback_alias funcref (global.get $callback)) + (global $token_alias externref (global.get $token)) + (global $exception_alias exnref (global.get $exception)) + (export "__wpk_fork_global_1" (global $callback)) + (export "__wpk_fork_global_2" (global $token)) + (export "__wpk_fork_global_3" (global $exception)) + (export "callback_global" (global $callback)) + (export "callback_alias" (global $callback_alias)) + (export "token_global" (global $token)) + (export "token_alias" (global $token_alias)) + (export "exception_global" (global $exception)) + (export "exception_alias" (global $exception_alias)))`, + importedGlobalsSection(descriptors), + ); + + const parentToken = Object.freeze({ generation: "parent" }); + const capture = new ForkImportedGlobalCapture("parent imported globals"); + const preparedProvider = capture.prepareActivation(1, providerModule, {}); + // Use the wrapped imports for the real instantiation boundary even though + // this provider has no imported globals. + const capturedParentProvider = new WebAssembly.Instance( + providerModule, + preparedProvider.imports as WebAssembly.Imports, + ); + preparedProvider.complete(capturedParentProvider); + const parentProvider = capturedParentProvider; + const preparedConsumer = capture.prepareActivation(2, consumerModule, { + provider: { + callback: parentProvider.exports.callback, + token: parentToken, + exception: parentProvider.exports.__wpk_fork_global_1, + }, + }); + const parentConsumer = new WebAssembly.Instance( + consumerModule, + preparedConsumer.imports as WebAssembly.Imports, + ); + preparedConsumer.complete(parentConsumer); + + const memory = new WebAssembly.Memory({ initial: 4 }); + const allocations = allocator(memory); + const arena = new ForkModuleStateArena( + memory, + 4, + allocations.allocate, + allocations.deallocate, + "imported-global capture", + ); + arena.begin(); + arena.appendModule({ activationId: 1, templateId: new Uint8Array(32).fill(1) }); + arena.appendModule({ activationId: 2, templateId: new Uint8Array(32).fill(2) }); + for (const [ownerId, typeCode, recipeId] of [ + [1, WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, 1], + [2, WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, 2], + [3, WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF, 0], + ] as const) { + arena.appendRecord({ + kind: ForkModuleStateRecordKind.MutableGlobal, + activationId: 2, + ownerId, + payload: referenceGlobal(typeCode, recipeId), + }); + } + const capturedBindings = capture.appendTo(arena); + expect(capturedBindings.map((binding) => binding.kind)).toEqual([ + ForkImportedGlobalBindingKind.RawReference, + ForkImportedGlobalBindingKind.RawReference, + ForkImportedGlobalBindingKind.ActivationGlobal, + ]); + expect(capturedBindings[2]).toMatchObject({ + sourceActivation: 1, + sourceOwner: 1, + }); + arena.seal(); + + const childInstances = new Map(); + const childToken = Object.freeze({ generation: "child" }); + const references: ForkImportedReferenceProvider = { + ownerActivation(recipeId) { + return recipeId === 1 ? 1 : null; + }, + materialize(recipeId) { + if (recipeId === 1) return childInstances.get(1)!.exports.callback; + if (recipeId === 2) return childToken; + throw new Error(`unknown test recipe ${recipeId}`); + }, + }; + const planner = new ForkImportedGlobalPlanner( + arena.records(), + new Map([ + [1, providerModule], + [2, consumerModule], + ]), + references, + "child imported globals", + ); + expect(planner.instantiationOrder()).toEqual([1, 2]); + for (const activationId of planner.instantiationOrder()) { + const module = activationId === 1 ? providerModule : consumerModule; + const imports = planner.importsForActivation(activationId, {}); + const instance = new WebAssembly.Instance( + module, + imports as WebAssembly.Imports, + ); + childInstances.set(activationId, instance); + planner.registerInstance(activationId, instance); + } + + const childProvider = childInstances.get(1)!; + const childConsumer = childInstances.get(2)!; + expect(childProvider.exports.callback).not.toBe(parentProvider.exports.callback); + expect(childConsumer.exports.callback_global).toBeInstanceOf(WebAssembly.Global); + expect((childConsumer.exports.callback_global as WebAssembly.Global).value) + .toBe(childProvider.exports.callback); + expect((childConsumer.exports.callback_alias as WebAssembly.Global).value) + .toBe(childProvider.exports.callback); + expect((childConsumer.exports.token_global as WebAssembly.Global).value) + .toBe(childToken); + expect((childConsumer.exports.token_alias as WebAssembly.Global).value) + .toBe(childToken); + expect(childToken).not.toBe(parentToken); + expect(childConsumer.exports.exception_global) + .toBe(childProvider.exports.__wpk_fork_global_1); + // The alias is a distinct immutable Global cell initialized from the exact + // provider exnref. JavaScript cannot read exnref values, which is precisely + // why the provider Global wrapper is the pre-instantiation transport. + expect(childConsumer.exports.exception_alias) + .toBeInstanceOf(WebAssembly.Global); + expect(() => (childConsumer.exports.exception_alias as WebAssembly.Global).value) + .toThrow(); + }); + + it("rebinds an imported mutable table to its activation owner before restore", () => { + const providerModule = compileModule( + `(module + (type $callback (func (result i32))) + (func $initial (type $callback) (result i32) i32.const 11) + (func $mutated (type $callback) (result i32) i32.const 22) + (table $dispatch 2 funcref) + (elem (i32.const 0) $initial) + (export "__wpk_fork_table_1" (table $dispatch)) + (export "mutated" (func $mutated)))`, + importedGlobalsSection([]), + ); + const consumerModule = compileModule( + `(module + (type $callback (func (result i32))) + (import "provider" "dispatch" (table $dispatch 2 funcref)) + (export "__wpk_fork_table_1" (table $dispatch)) + (func (export "call") (result i32) + i32.const 0 + call_indirect (type $callback)))`, + importedGlobalsSection([]), + importedTablesSection([{ + module: "provider", + name: "dispatch", + importOrdinal: 0, + ownerId: 1, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + }]), + ); + + const capture = new ForkImportedGlobalCapture("parent imported tables"); + const preparedProvider = capture.prepareActivation(1, providerModule, {}); + const parentProvider = new WebAssembly.Instance( + providerModule, + preparedProvider.imports as WebAssembly.Imports, + ); + preparedProvider.complete(parentProvider); + const preparedConsumer = capture.prepareActivation(2, consumerModule, { + provider: { dispatch: parentProvider.exports.__wpk_fork_table_1 }, + }); + const parentConsumer = new WebAssembly.Instance( + consumerModule, + preparedConsumer.imports as WebAssembly.Imports, + ); + preparedConsumer.complete(parentConsumer); + + const memory = new WebAssembly.Memory({ initial: 4 }); + const allocations = allocator(memory); + const arena = new ForkModuleStateArena( + memory, + 4, + allocations.allocate, + allocations.deallocate, + "imported-table capture", + ); + arena.begin(); + arena.appendModule({ activationId: 1, templateId: new Uint8Array(32).fill(1) }); + arena.appendModule({ activationId: 2, templateId: new Uint8Array(32).fill(2) }); + capture.appendTo(arena); + const tableRecord = arena.recordsForCapture().find( + (record) => record.kind === ForkModuleStateRecordKind.ImportedTableBindings, + )!; + expect(decodeForkImportedTableBindings(tableRecord.payload)).toEqual([{ + consumerActivation: 2, + consumerOwner: 1, + sourceActivation: 1, + sourceOwner: 1, + reserved: 0, + kind: ForkImportedTableBindingKind.ActivationTable, + }]); + arena.seal(); + + const planner = new ForkImportedGlobalPlanner( + arena.records(), + new Map([[1, providerModule], [2, consumerModule]]), + { + ownerActivation: () => null, + materialize: () => null, + }, + "child imported tables", + ); + expect(planner.instantiationOrder()).toEqual([1, 2]); + const children = new Map(); + for (const activationId of planner.instantiationOrder()) { + const module = activationId === 1 ? providerModule : consumerModule; + const instance = new WebAssembly.Instance( + module, + planner.importsForActivation(activationId, {}) as WebAssembly.Imports, + ); + children.set(activationId, instance); + planner.registerInstance(activationId, instance); + } + const childProvider = children.get(1)!; + const childConsumer = children.get(2)!; + expect(childConsumer.exports.__wpk_fork_table_1) + .toBe(childProvider.exports.__wpk_fork_table_1); + expect((childConsumer.exports.call as () => number)()).toBe(11); + + // KFMS restores table overrides through the consumer's imported alias. + // Exercising the same identity here proves the mutation is immediately + // visible to continuation code after the restore phase. + (childProvider.exports.__wpk_fork_table_1 as WebAssembly.Table).set( + 0, + childProvider.exports.mutated, + ); + expect((childConsumer.exports.call as () => number)()).toBe(22); + }); + + it("re-resolves one fresh base-import table for every captured alias", () => { + const module = compileModule( + `(module + (import "host" "dispatch" (table $dispatch 1 4 funcref)) + (export "__wpk_fork_table_1" (table $dispatch)))`, + importedGlobalsSection([]), + importedTablesSection([{ + module: "host", + name: "dispatch", + importOrdinal: 0, + ownerId: 1, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + }]), + ); + const parentTable = new WebAssembly.Table({ + element: "anyfunc", + initial: 1, + maximum: 4, + }); + const capture = new ForkImportedGlobalCapture("base table parent"); + for (const activationId of [1, 2]) { + const prepared = capture.prepareActivation(activationId, module, { + host: { dispatch: parentTable }, + }); + const instance = new WebAssembly.Instance( + module, + prepared.imports as WebAssembly.Imports, + ); + prepared.complete(instance); + } + const parentTrackers = new Map([ + [1, new ForkTableDirtyTracker()], + [2, new ForkTableDirtyTracker()], + ]); + parentTrackers.get(1)!.markPages(1, 2n, 1n); + parentTrackers.get(2)!.markPages(1, 7n, 1n); + capture.bindTableDirtyTrackers(parentTrackers); + expect(parentTrackers.get(1)!.ownsState(1)).toBe(true); + expect(parentTrackers.get(2)!.ownsState(1)).toBe(false); + parentTrackers.get(1)!.markPages(1, 11n, 1n); + expect( + [0, 1, 2].map((ordinal) => + parentTrackers.get(2)!.pageAt(1, ordinal) + ), + ).toEqual([2n, 7n, 11n]); + + const memory = new WebAssembly.Memory({ initial: 4 }); + const allocations = allocator(memory); + const arena = new ForkModuleStateArena( + memory, + 4, + allocations.allocate, + allocations.deallocate, + "host table aliases", + ); + arena.begin(); + arena.appendModule({ activationId: 1, templateId: new Uint8Array(32).fill(1) }); + arena.appendModule({ activationId: 2, templateId: new Uint8Array(32).fill(2) }); + capture.appendTo(arena); + const tableBindingRecord = arena.recordsForCapture().find( + (record) => record.kind === ForkModuleStateRecordKind.ImportedTableBindings, + )!; + expect(decodeForkImportedTableBindings(tableBindingRecord.payload)).toEqual([ + { + consumerActivation: 1, + consumerOwner: 1, + sourceActivation: 0, + sourceOwner: 0, + reserved: 0, + kind: ForkImportedTableBindingKind.BaseImport, + }, + { + consumerActivation: 2, + consumerOwner: 1, + sourceActivation: 0, + sourceOwner: 0, + reserved: 0, + kind: ForkImportedTableBindingKind.BaseImport, + }, + ]); + arena.seal(); + + const planner = new ForkImportedGlobalPlanner( + arena.records(), + new Map([[1, module], [2, module]]), + { ownerActivation: () => null, materialize: () => null }, + "base table child", + ); + const childTable = new WebAssembly.Table({ + element: "anyfunc", + initial: 1, + maximum: 4, + }); + const instances = new Map(); + for (const activationId of planner.instantiationOrder()) { + const instance = new WebAssembly.Instance( + module, + planner.importsForActivation(activationId, { + host: { dispatch: childTable }, + }) as WebAssembly.Imports, + ); + instances.set(activationId, instance); + planner.registerInstance(activationId, instance); + } + const childTrackers = new Map([ + [1, new ForkTableDirtyTracker()], + [2, new ForkTableDirtyTracker()], + ]); + childTrackers.get(1)!.markPages(1, 3n, 1n); + childTrackers.get(2)!.markPages(1, 9n, 1n); + planner.bindTableDirtyTrackers(childTrackers); + expect(childTrackers.get(1)!.ownsState(1)).toBe(true); + expect(childTrackers.get(2)!.ownsState(1)).toBe(false); + childTrackers.get(2)!.markPages(1, 12n, 1n); + expect( + [0, 1, 2].map((ordinal) => + childTrackers.get(1)!.pageAt(1, ordinal) + ), + ).toEqual([3n, 9n, 12n]); + expect(instances.get(1)!.exports.__wpk_fork_table_1) + .toBe(instances.get(2)!.exports.__wpk_fork_table_1); + expect(instances.get(1)!.exports.__wpk_fork_table_1).toBe(childTable); + expect(instances.get(1)!.exports.__wpk_fork_table_1).not.toBe(parentTable); + planner.clear(); + }); + + it("rejects a provider cycle deterministically before instantiation", () => { + const cycleModule = compileModule( + `(module + (import "peer" "value" (global $value i32)) + (export "__wpk_fork_global_1" (global $value)))`, + importedGlobalsSection([{ + module: "peer", + name: "value", + ownerId: 1, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I32, + }]), + ); + const memory = new WebAssembly.Memory({ initial: 3 }); + const allocations = allocator(memory); + const arena = new ForkModuleStateArena( + memory, + 4, + allocations.allocate, + allocations.deallocate, + "cycle", + ); + arena.begin(); + arena.appendModule({ activationId: 1, templateId: new Uint8Array(32).fill(1) }); + arena.appendModule({ activationId: 2, templateId: new Uint8Array(32).fill(2) }); + arena.appendImportedGlobalBindings([ + { + consumerActivation: 1, + consumerOwner: 1, + sourceActivation: 2, + sourceOwner: 1, + reserved: 0, + recipeId: 0, + rawBits: 0n, + kind: ForkImportedGlobalBindingKind.ActivationGlobal, + mutable: false, + shared: false, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I32, + }, + { + consumerActivation: 2, + consumerOwner: 1, + sourceActivation: 1, + sourceOwner: 1, + reserved: 0, + recipeId: 0, + rawBits: 0n, + kind: ForkImportedGlobalBindingKind.ActivationGlobal, + mutable: false, + shared: false, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I32, + }, + ]); + arena.appendImportedTableBindings([]); + arena.seal(); + const planner = new ForkImportedGlobalPlanner( + arena.records(), + new Map([[1, cycleModule], [2, cycleModule]]), + { + ownerActivation: () => null, + materialize: () => null, + }, + "cycle", + ); + expect(() => planner.instantiationOrder()) + .toThrow("provider cycle among activations 1, 2"); + }); + + it("orders the complete typed-reference provider closure before its consumer", () => { + const emptyModule = compileModule( + `(module)`, + importedGlobalsSection([]), + ); + const consumerModule = compileModule( + `(module + (import "env" "token" (global $token externref)) + (export "token" (global $token)))`, + importedGlobalsSection([{ + module: "env", + name: "token", + ownerId: 1, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + }]), + ); + const memory = new WebAssembly.Memory({ initial: 3 }); + const allocations = allocator(memory); + const arena = new ForkModuleStateArena( + memory, + 4, + allocations.allocate, + allocations.deallocate, + "typed reference dependency closure", + ); + arena.begin(); + for (const activationId of [1, 2, 3]) { + arena.appendModule({ + activationId, + templateId: new Uint8Array(32).fill(activationId), + }); + } + arena.appendImportedGlobalBindings([{ + consumerActivation: 3, + consumerOwner: 1, + sourceActivation: 0, + sourceOwner: 0, + reserved: 0, + recipeId: 1, + rawBits: 0n, + kind: ForkImportedGlobalBindingKind.RawReference, + mutable: false, + shared: false, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + }]); + arena.appendImportedTableBindings([]); + arena.seal(); + + const registered = new Set(); + const token = Object.freeze({ child: true }); + const planner = new ForkImportedGlobalPlanner( + arena.records(), + new Map([ + [1, emptyModule], + [2, emptyModule], + [3, consumerModule], + ]), + { + ownerActivation: () => 1, + activationDependencies: () => [2, 1], + materialize: () => { + expect([...registered].sort()).toEqual([1, 2]); + return token; + }, + }, + "typed reference dependency closure", + ); + expect(planner.instantiationOrder()).toEqual([1, 2, 3]); + for (const activationId of planner.instantiationOrder()) { + const module = activationId === 3 ? consumerModule : emptyModule; + const instance = new WebAssembly.Instance( + module, + planner.importsForActivation( + activationId, + activationId === 3 ? { env: {} } : {}, + ) as WebAssembly.Imports, + ); + planner.registerInstance(activationId, instance); + registered.add(activationId); + } + }); +}); diff --git a/host/test/fork-instrument-coverage.test.ts b/host/test/fork-instrument-coverage.test.ts index f4c049b9b8..8f4a69a10c 100644 --- a/host/test/fork-instrument-coverage.test.ts +++ b/host/test/fork-instrument-coverage.test.ts @@ -2,35 +2,31 @@ * fork_instrument_coverage — comprehensive regression matrix for * `wasm-fork-instrument`. * - * Source of truth: docs/plans/2026-05-13-fork-instrument-megaPR-eliminate-guard-dispatch-and-modern-EH-plan.md + * The test IDs originated in: + * docs/plans/2026-05-13-fork-instrument-megaPR-eliminate-guard-dispatch-and-modern-EH-plan.md * - * Six categories, 41 test IDs: + * Six categories, 51 test IDs: * D-* (10) dispatch coverage — switch-dispatch and the runtime * trampoline that replaces guard-dispatch. * C-* (11) catch-handler resume — B1/A2/A3/A4 patterns. (C-01..C-10 * from the matrix plus C-11 post-catch fork.) * S-* (8) side-effects-during-rewind — atomic ops, table.*, * non-nullable funcref, throw-from-outside. - * K-* (4) callback-registration fork roots — sigaction, signal, - * pthread_cleanup_push, qsort comparator. + * K-* (7) callback-registration and asynchronous fork roots. * P-* (11) process / threading patterns — main thread, blocked * cond, held mutex, popen, posix_spawn, deep and failed * continuation allocation. - * F-* (4) accepted-limit failure modes — ucontext, wasm-GC refs. + * F-* (4) explicit ucontext boundaries and Wasm-GC ownership. * - * Pre-refactor expected behaviour is encoded with vitest modifiers: - * - it() — should pass today AND after the architectural - * pivot. Regression gate against the refactor - * accidentally breaking working features. - * - it.fails() — expected to fail today; should pass after the - * named commit lands. When CI flags it as - * unexpectedly passing, flip to it(). - * - it.todo() — fixture not yet written (e.g. needs WAT). Marked - * for tracking; no assertion runs. + * Modifiers describe the current ownership of each proof: + * - it() — this file executes the process-runtime gate. + * - it.fails() — an explicit platform boundary is expected to fail + * truthfully; an unexpected pass requires review. + * - it.skip() — another named suite owns the executable proof because + * the shape has no C/C++ source fixture. * - * The whole file must stay green until the architectural pivot ships - * (commits 2-N of the mega-PR). Each pivot commit should flip the - * relevant tests from it.fails() to it(). + * Supported compiler/reference shapes must not be hidden behind a skip whose + * label still claims that ABI 43 rejects them. */ import { describe, it, expect } from "vitest"; import { runCentralizedProgram } from "./centralized-test-helper"; @@ -190,50 +186,46 @@ describe("fork_instrument_coverage / D-* dispatch", () => { // --------------------------------------------------------------------------- describe("fork_instrument_coverage / C-* catch-handler resume", () => { - // LLVM 21 currently adds exnref locals and/or untagged cleanup catches to - // these C++ functions. ABI 43 rejects those raw modules during - // instrumentation; the supported tagged Catch/CatchRef surface is exercised - // in catch-ref-fresh-worker.test.ts and plain-catch-payload-lifetime.test.ts. - it.skip("C-01 compiler EH output requires transferable cleanup state", async () => { + // LLVM 21 emits exnref locals and untagged cleanup catches for these C++ + // functions. ABI 43 gives both forms deterministic exception recipes, so + // keep the compiler output in the real process-runtime gate. + it("C-01 fork in compiler EH try body", async () => { await runFixture("programs/c_01_fork_in_try_no_throw.wasm", { contains: ["IN_TRY", "PRE_FORK", "CHILD: ok", "PASS: C-01"], }); }); - // C-02..C-07 retain their source fixtures as compiler-policy probes. The - // build requires a precise instrumentation rejection and keeps the raw - // modules only under test-fixtures/unsupported-abi43. - it.skip("C-02 compiler EH output requires transferable cleanup state", async () => { + it("C-02 fork in compiler EH catch", async () => { await runFixture("programs/c_02_fork_in_catch.wasm", { contains: ["THROWING", "CAUGHT: 7", "PRE_FORK", "CHILD: ok", "PASS: C-02"], }); }); - it.skip("C-03 compiler EH output requires transferable cleanup state", async () => { + it("C-03 fork in a distinct multi-arm catch target", async () => { await runFixture("programs/c_03_fork_in_multi_arm_catch.wasm", { contains: ["THROWING", "CAUGHT_STR: x", "PRE_FORK", "CHILD: ok", "PASS: C-03"], }); }); - it.skip("C-04 compiler EH output requires transferable cleanup state", async () => { + it("C-04 fork after an external throw reaches a catch", async () => { await runFixture("programs/c_04_fork_in_catch_external_throw.wasm", { contains: ["CALLING_HELPER", "IN_HELPER", "CAUGHT: 99", "PRE_FORK", "CHILD: ok", "PASS: C-04"], }); }); - it.skip("C-05 compiler EH output requires transferable cleanup state", async () => { + it("C-05 fork in a single modern-EH catch", async () => { await runFixture("programs/c_05_fork_modern_eh_single.wasm", { contains: ["THROWING", "CAUGHT: 1", "PRE_FORK", "CHILD: ok", "PASS: C-05"], }); }); - it.skip("C-06 compiler EH output requires transferable cleanup state", async () => { + it("C-06 fork in a reference-form multi-arm catch", async () => { await runFixture("programs/c_06_fork_modern_eh_multi_ref.wasm", { contains: ["THROWING", "CAUGHT_DOUBLE: 3.14", "PRE_FORK", "CHILD: ok", "PASS: C-06"], }); }); - it.skip("C-07 compiler EH output requires transferable cleanup state", async () => { + it("C-07 fork in a plain-form multi-arm catch", async () => { await runFixture("programs/c_07_fork_modern_eh_multi_plain.wasm", { contains: ["THROWING", "CAUGHT_LONG: 1234567", "PRE_FORK", "CHILD: ok", "PASS: C-07"], }); @@ -241,14 +233,12 @@ describe("fork_instrument_coverage / C-* catch-handler resume", () => { // C-08, C-09 — funcref/externref catch operands. There is no C-source // surface, so `crates/fork-instrument/tests/coverage_wat.rs` verifies the - // ABI 43 boundary directly: a reference payload in the fork closure is - // rejected during instrumentation instead of being placed in - // module-instance scratch state. - it.skip("C-08 plain catch arm with funcref operand [tested via crates/fork-instrument/tests/coverage_wat.rs]", () => {}); - it.skip("C-09 plain catch arm with externref operand [tested via crates/fork-instrument/tests/coverage_wat.rs]", () => {}); - - // C-10/C-11 are part of the same compiler-output rejection boundary. - it.skip("C-10 compiler EH output requires transferable cleanup state", async () => { + // ABI 43 boundary directly: reference payloads become complete exception + // recipes and never enter module-instance scratch state. + it.skip("C-08 funcref catch operand [coverage_wat.rs + catch-ref-fresh-worker.test.ts]", () => {}); + it.skip("C-09 externref catch operand [coverage_wat.rs + catch-ref-fresh-worker.test.ts]", () => {}); + + it("C-10 forks in both a try body and its catch", async () => { await runFixture("programs/c_10_fork_in_try_and_catch.wasm", { contains: [ "IN_TRY", "PRE_FORK_TRY", "CHILD_TRY: ok", @@ -258,7 +248,7 @@ describe("fork_instrument_coverage / C-* catch-handler resume", () => { }); }); - it.skip("C-11 compiler EH output requires transferable cleanup state", async () => { + it("C-11 forks after a compiler catch has completed", async () => { await runFixture("programs/c_11_post_catch_fork.wasm", { contains: ["CAUGHT: 42", "PRE_FORK", "CHILD: ok", "PASS: C-11"], }); @@ -304,8 +294,9 @@ describe("fork_instrument_coverage / S-* side effects during rewind", () => { it.skip("S-06 table.grow before fork [tested via crates/fork-instrument/tests/coverage_wat.rs]", () => {}); it.skip("S-07 non-nullable funcref direct-call result before fork [tested via crates/fork-instrument/tests/coverage_wat.rs]", () => {}); - // S-08's current LLVM output retains an exnref local across the fork path. - it.skip("S-08 compiler EH output requires transferable cleanup state", async () => { + // LLVM retains an exnref local across this external-throw path. Its + // activation-owned recipe must survive the child instance boundary. + it("S-08 external throw with live compiler exnref state", async () => { await runFixture("programs/s_08_external_throw_fork_in_catch.wasm", { contains: ["ENTER_OUTER", "ENTER_INNER", "THROWING", "CAUGHT: 73", "PRE_FORK", "CHILD: ok", "PASS: S-08"], }); @@ -360,9 +351,9 @@ describe("fork_instrument_coverage / K-* callback fork roots", () => { }); }); - // K-06 lowers destructor cleanup to an untagged CatchAll. ABI 43 rejects it - // until that cleanup state has a deterministic child reconstruction recipe. - it.skip("K-06 compiler CatchAll cleanup is not reconstructible", async () => { + // K-06 lowers destructor cleanup to an untagged CatchAll. ABI 43 captures + // the complete exception recipe rather than relying on the parent instance. + it("K-06 fork from destructor through compiler CatchAll cleanup", async () => { await runFixture("programs/k_06_fork_from_dtor.wasm", { contains: ["IN_SCOPE", "IN_DTOR", "PRE_FORK", "CHILD: ok", "PARENT: child=", "PASS: K-06"], }); @@ -494,10 +485,10 @@ describe("fork_instrument_coverage / P-* process & threading", () => { }); // --------------------------------------------------------------------------- -// F-* accepted-limit failure modes +// F-* explicit boundaries and Wasm-GC ownership // --------------------------------------------------------------------------- -describe("fork_instrument_coverage / F-* accepted limits", () => { +describe("fork_instrument_coverage / F-* boundaries and Wasm-GC", () => { // F-01: getcontext(). Empirically: musl's wasm sysroot exposes // the symbol via an `env.getcontext` import that the kernel // doesn't implement — the program traps at first call with @@ -520,11 +511,9 @@ describe("fork_instrument_coverage / F-* accepted limits", () => { }); }); - // F-03, F-04 — wasm-GC anyref / struct.new. No C-source surface - // (LLVM-emitted C doesn't produce these); covered by cargo-level - // tests in `crates/fork-instrument/tests/coverage_wat.rs` which - // verify fork-instrument rejects the accepted-limit shapes with a - // clear diagnostic rather than silently accepting them. - it.skip("F-03 wasm-GC anyref accepted limit [tested via crates/fork-instrument/tests/coverage_wat.rs]", () => {}); - it.skip("F-04 wasm-GC struct.new accepted limit [tested via crates/fork-instrument/tests/coverage_wat.rs]", () => {}); + // F-03, F-04 — wasm-GC anyref / struct.new have no C-source surface. + // `coverage_wat.rs` verifies that both are accepted, encoded into + // activation-owned recipes, and emitted as independently valid Wasm. + it.skip("F-03 wasm-GC anyref [coverage_wat.rs + gc-reference-state-fresh-worker.test.ts]", () => {}); + it.skip("F-04 wasm-GC struct.new [coverage_wat.rs + gc-reference-state-fresh-worker.test.ts]", () => {}); }); diff --git a/host/test/fork-instrument-runtime-harness.ts b/host/test/fork-instrument-runtime-harness.ts new file mode 100644 index 0000000000..8e8304fa02 --- /dev/null +++ b/host/test/fork-instrument-runtime-harness.ts @@ -0,0 +1,159 @@ +import { + buildForkActivationStateImports, + ForkActivationRegistry, + forkActivationRegistrationFromInstance, +} from "../src/fork-activation-registry"; +import type { LinkedForkContinuation } from "../src/fork-continuation"; +import { + buildForkExceptionImports, + ForkExceptionBroker, + forkExceptionProviderFromInstance, + type ForkExceptionProvider, +} from "../src/fork-exception-provider"; +import { + computeForkModuleTemplateIdSync, + ForkModuleStateArena, +} from "../src/fork-module-state"; +import { ForkProcessContinuationCoordinator } from "../src/fork-process-continuation"; +import { forkResumeTargetsFromInstance } from "../src/fork-resume-catalog"; +import { + createForkUnwindTag, + FORK_UNWIND_TAG_IMPORT_NAME, + isForkUnwindException, +} from "../src/fork-unwind-transport"; + +export interface SingleActivationForkRuntimeOptions { + readonly module: WebAssembly.Module; + readonly moduleBytes: ArrayBufferView; + readonly memory: WebAssembly.Memory; + readonly continuation: LinkedForkContinuation; + readonly newArena: () => ForkModuleStateArena; + readonly label: string; +} + +/** + * Production-shaped ABI 43 owner for direct instrumenter tests. + * + * WHY: these tests instantiate generated Wasm without a process Worker. They + * still need the real activation registry, resume-event journal, module-state + * arena, and typed codecs; inert zero stubs would let ABI drift pass while + * bypassing the ownership protocol the test is supposed to exercise. + */ +export class SingleActivationForkRuntime { + readonly registry: ForkActivationRegistry; + readonly coordinator: ForkProcessContinuationCoordinator; + readonly envImports: Record; + + private readonly unwindTag = createForkUnwindTag(); + private instance: WebAssembly.Instance | null = null; + private exceptionProvider: ForkExceptionProvider | null = null; + private processLaunchRoot = 0; + + constructor( + private readonly options: SingleActivationForkRuntimeOptions, + ) { + const { memory, continuation, label } = options; + this.registry = new ForkActivationRegistry( + memory, + { + capture: () => { + throw new Error(`${label}: fixture unexpectedly captured externref`); + }, + materialize: () => { + throw new Error(`${label}: fixture unexpectedly replayed externref`); + }, + }, + `${label}: activation registry`, + ); + this.coordinator = new ForkProcessContinuationCoordinator( + memory, + this.registry, + `${label}: process continuation`, + ); + this.coordinator.prepareActivation({ + activationId: 0, + continuation, + publishProcessLaunchRoot: (address) => { + this.processLaunchRoot = address; + }, + readProcessLaunchRoot: () => this.processLaunchRoot, + }); + const exceptionBroker = new ForkExceptionBroker( + this.registry, + `${label}: exception broker`, + ); + this.envImports = { + [FORK_UNWIND_TAG_IMPORT_NAME]: + this.unwindTag as unknown as WebAssembly.ImportValue, + ...this.coordinator.continuationImports(0, (errno) => { + this.coordinator.beginCaptureAbort(errno); + }), + ...buildForkActivationStateImports(0, this.registry), + ...buildForkExceptionImports({ + activationId: 0, + ptrWidth: continuation.format.ptrWidth, + registry: this.registry, + broker: exceptionBroker, + provider: () => { + if (!this.exceptionProvider) { + throw new Error(`${label}: exception provider is not registered`); + } + return this.exceptionProvider; + }, + }), + }; + } + + register( + instance: WebAssembly.Instance, + options: { readonly bootstrap?: boolean } = {}, + ): void { + if (this.instance) { + throw new Error(`${this.options.label}: activation is already registered`); + } + this.instance = instance; + this.exceptionProvider = forkExceptionProviderFromInstance(0, instance); + this.coordinator.registerActivation( + forkActivationRegistrationFromInstance({ + activationId: 0, + module: this.options.module, + instance, + templateId: computeForkModuleTemplateIdSync(this.options.moduleBytes), + exceptionProvider: this.exceptionProvider, + }), + forkResumeTargetsFromInstance(this.options.module, instance), + ); + if (options.bootstrap ?? true) this.registry.bootstrapActivation(0); + } + + beginCapture(): void { + const arena = this.options.newArena(); + arena.begin(); + this.coordinator.beginCapture(arena); + } + + setCopiedProcessLaunchRoot(address: number): void { + if (!Number.isSafeInteger(address) || address <= 0) { + throw new RangeError( + `${this.options.label}: copied process launch root is invalid`, + ); + } + this.processLaunchRoot = address; + } + + isForkUnwind(value: unknown): boolean { + return isForkUnwindException(value, this.unwindTag); + } + + expectCaptureTransport(invoke: () => unknown): void { + try { + invoke(); + } catch (error) { + if (this.isForkUnwind(error)) return; + throw error; + } + throw new Error( + `${this.options.label}: capture returned instead of transporting unwind`, + ); + } +} diff --git a/host/test/fork-module-state.test.ts b/host/test/fork-module-state.test.ts new file mode 100644 index 0000000000..3309f01132 --- /dev/null +++ b/host/test/fork-module-state.test.ts @@ -0,0 +1,1345 @@ +import { describe, expect, it } from "vitest"; +import { + activationContinuationsForChild, + decodeForkActivationContinuations, + decodeForkModuleStateDescriptor, + decodeForkImportedGlobalBindings, + decodeForkImportedTableBindings, + encodeForkActivationContinuations, + encodeForkImportedGlobalBindings, + encodeForkImportedTableBindings, + encodeForkModuleStateDescriptor, + computeForkModuleTemplateId, + computeForkModuleTemplateIdSync, + ForkImportedGlobalBindingKind, + ForkImportedTableBindingKind, + ForkModuleStateArena, + ForkModuleStateRecordKind, + ForkTableDirtyTracker, + FORK_MODULE_STATE_DESCRIPTOR_SIZE, + FORK_MODULE_STATE_DESCRIPTOR_VERSION, + FORK_MODULE_STATE_REQUIRED_FLAGS, + FORK_MODULE_STATE_ROOT_POINTER_WORD_OFFSET, + FORK_MODULE_STATE_SECTION, + readForkModuleStateDescriptor, + readForkImportedGlobals, + readForkImportedTables, + readForkModuleStateRoot, + writeForkModuleStateRoot, + replayEventsForChild, + type ForkSparseTableSnapshot, +} from "../src/fork-module-state"; +import { + type ForkReplayEvent, + ForkReplayEventJournal, +} from "../src/fork-replay-events"; +import { + WPK_FORK_MODULE_STATE_GLOBAL_HEADER_SIZE, + WPK_FORK_ACTIVATION_CONTINUATIONS_HEADER_SIZE, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I32, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_F64, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I64, + WPK_FORK_IMPORTED_GLOBALS_FLAG_MUTABLE, + WPK_FORK_IMPORTED_GLOBALS_FLAG_SHARED, + WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE, + WPK_FORK_IMPORTED_GLOBALS_MAGIC, + WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE, + WPK_FORK_IMPORTED_GLOBALS_SECTION, + WPK_FORK_IMPORTED_GLOBALS_VERSION, + WPK_FORK_IMPORTED_TABLES_HEADER_SIZE, + WPK_FORK_IMPORTED_TABLES_MAGIC, + WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE, + WPK_FORK_IMPORTED_TABLES_SECTION, + WPK_FORK_IMPORTED_TABLES_VERSION, +} from "../src/generated/abi"; + +const PAGE_SIZE = 65_536; + +function hex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function replayEventCapture( + events: readonly ForkReplayEvent[], +): ForkReplayEventJournal { + const journal = new ForkReplayEventJournal(); + journal.beginCapture(); + for (const event of events) { + journal.recordCommit(event.activationId, event.functionOrdinal); + } + journal.sealCapture(); + return journal; +} + +function replayEventRecords(events: readonly ForkReplayEvent[]) { + const journal = replayEventCapture(events); + return [ + ...[...journal.capturedSegmentPayloads()].map((payload) => ({ + kind: ForkModuleStateRecordKind.ReplayEventSegment, + activationId: 0, + ownerId: 1, + payload, + })), + { + kind: ForkModuleStateRecordKind.ReplayEvents, + activationId: 0, + ownerId: 1, + payload: journal.capturedManifestPayload(), + }, + ]; +} + +describe("fork module template identity", () => { + it("matches the published SHA-256 empty and abc vectors synchronously", () => { + expect(hex(computeForkModuleTemplateIdSync(new Uint8Array()))) + .toBe("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + expect(hex(computeForkModuleTemplateIdSync(new TextEncoder().encode("abc")))) + .toBe("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + }); + + it("matches WebCrypto for sliced inputs spanning several blocks", async () => { + const storage = new Uint8Array(271); + for (let index = 0; index < storage.length; index++) { + storage[index] = (index * 73 + 19) & 0xff; + } + const source = storage.subarray(7, 264); + expect(computeForkModuleTemplateIdSync(source)) + .toEqual(await computeForkModuleTemplateId(source)); + }); +}); + +describe("fork table dirty journal", () => { + it("merges mutation ranges and enumerates pages deterministically", () => { + const tracker = new ForkTableDirtyTracker(); + tracker.markPages(7, 20n, 3n); + tracker.markPages(7, 4n, 2n); + tracker.markPages(7, 6n, 4n); + tracker.markPages(7, 9n, 12n); + tracker.markPages(7, 5n, 0n); + + expect(tracker.pageCount(7)).toBe(19); + expect( + Array.from({ length: tracker.pageCount(7) }, (_, ordinal) => + tracker.pageAt(7, ordinal) + ), + ).toEqual(Array.from({ length: 19 }, (_, index) => BigInt(index + 4))); + expect(() => tracker.pageAt(7, 19)).toThrow("has no page ordinal"); + }); + + it("round-trips unsigned i64 page bits through signed Wasm BigInt", () => { + const tracker = new ForkTableDirtyTracker(); + tracker.markPages(1, -1n, 1n); + expect(tracker.pageCount(1)).toBe(1); + expect(tracker.pageAt(1, 0)).toBe(-1n); + }); + + it("unions per-activation journals for one imported Table identity", () => { + const provider = new ForkTableDirtyTracker(); + const consumer = new ForkTableDirtyTracker(); + provider.markPages(4, 1n, 2n); + consumer.markPages(9, 7n, 1n); + consumer.aliasOwner(9, provider, 4); + expect(provider.ownsState(4)).toBe(true); + expect(consumer.ownsState(9)).toBe(false); + provider.markPages(4, 12n, 2n); + consumer.markPages(9, 20n, 1n); + + const expected = [1n, 2n, 7n, 12n, 13n, 20n]; + for (const [tracker, owner] of [[provider, 4], [consumer, 9]] as const) { + expect(tracker.pageCount(owner)).toBe(expected.length); + expect(expected.map((_, index) => tracker.pageAt(owner, index))) + .toEqual(expected); + } + + // A provider can be dlclosed while its physical Table remains reachable + // through a consumer import. Ownership moves without severing the merged + // mutation journal or losing pages written through the old provider. + provider.setStateOwner(4, false); + consumer.setStateOwner(9, true); + expect(provider.ownsState(4)).toBe(false); + expect(consumer.ownsState(9)).toBe(true); + expect(expected.map((_, index) => consumer.pageAt(9, index))) + .toEqual(expected); + }); +}); + +function allocator(memory: WebAssembly.Memory) { + let next = PAGE_SIZE; + const allocations: Array<{ addr: number; size: number }> = []; + const releases: Array<{ addr: number; size: number }> = []; + return { + allocations, + releases, + allocate(size: number): number { + const addr = next; + next += size; + if (next > memory.buffer.byteLength) { + memory.grow(Math.ceil((next - memory.buffer.byteLength) / PAGE_SIZE)); + } + allocations.push({ addr, size }); + return addr; + }, + deallocate(addr: number, size: number): void { + releases.push({ addr, size }); + }, + }; +} + +function cloneMemory(memory: WebAssembly.Memory): WebAssembly.Memory { + const clone = new WebAssembly.Memory({ + initial: memory.buffer.byteLength / PAGE_SIZE, + }); + new Uint8Array(clone.buffer).set(new Uint8Array(memory.buffer)); + return clone; +} + +function uleb128(value: number): number[] { + const bytes: number[] = []; + do { + let byte = value & 0x7f; + value >>>= 7; + if (value !== 0) byte |= 0x80; + bytes.push(byte); + } while (value !== 0); + return bytes; +} + +function moduleWithDescriptors(...descriptors: Uint8Array[]): WebAssembly.Module { + return moduleWithCustomSections(FORK_MODULE_STATE_SECTION, ...descriptors); +} + +function moduleWithCustomSections( + sectionName: string, + ...descriptors: Uint8Array[] +): WebAssembly.Module { + const name = [...new TextEncoder().encode(sectionName)]; + const sections = descriptors.flatMap((descriptor) => { + const payload = [...uleb128(name.length), ...name, ...descriptor]; + return [0, ...uleb128(payload.length), ...payload]; + }); + return new WebAssembly.Module(new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + ...sections, + ])); +} + +function importedGlobalsSection( + records: ReadonlyArray<{ + module: string; + name: string; + ownerId: number; + importOrdinal?: number; + typeCode: number; + mutable?: boolean; + shared?: boolean; + }>, +): Uint8Array { + const encoder = new TextEncoder(); + const encoded = records.map((record, importOrdinal) => ({ + ...record, + importOrdinal: record.importOrdinal ?? importOrdinal, + moduleBytes: encoder.encode(record.module), + nameBytes: encoder.encode(record.name), + })); + const size = WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE + + encoded.reduce( + (sum, record) => + sum + + WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE + + record.moduleBytes.byteLength + + record.nameBytes.byteLength, + 0, + ); + const bytes = new Uint8Array(size); + const view = new DataView(bytes.buffer); + bytes.set(WPK_FORK_IMPORTED_GLOBALS_MAGIC, 0); + view.setUint16(4, WPK_FORK_IMPORTED_GLOBALS_VERSION, true); + view.setUint16(6, WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE, true); + view.setUint32(8, encoded.length, true); + let offset = WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE; + for (const record of encoded) { + const recordSize = WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE + + record.moduleBytes.byteLength + + record.nameBytes.byteLength; + view.setUint32(offset, recordSize, true); + view.setUint32(offset + 4, record.ownerId, true); + view.setUint8(offset + 8, record.typeCode); + view.setUint8( + offset + 9, + (record.mutable ? WPK_FORK_IMPORTED_GLOBALS_FLAG_MUTABLE : 0) + | (record.shared ? WPK_FORK_IMPORTED_GLOBALS_FLAG_SHARED : 0), + ); + view.setUint32(offset + 12, record.moduleBytes.byteLength, true); + view.setUint32(offset + 16, record.nameBytes.byteLength, true); + view.setUint32(offset + 20, record.importOrdinal, true); + bytes.set( + record.moduleBytes, + offset + WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE, + ); + bytes.set( + record.nameBytes, + offset + + WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE + + record.moduleBytes.byteLength, + ); + offset += recordSize; + } + return bytes; +} + +function importedTablesSection( + records: ReadonlyArray<{ + module: string; + name: string; + ownerId: number; + importOrdinal?: number; + typeCode: number; + table64?: boolean; + }>, +): Uint8Array { + const encoder = new TextEncoder(); + const encoded = records.map((record, importOrdinal) => ({ + ...record, + importOrdinal: record.importOrdinal ?? importOrdinal, + moduleBytes: encoder.encode(record.module), + nameBytes: encoder.encode(record.name), + })); + const size = WPK_FORK_IMPORTED_TABLES_HEADER_SIZE + + encoded.reduce( + (sum, record) => + sum + + WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE + + record.moduleBytes.byteLength + + record.nameBytes.byteLength, + 0, + ); + const bytes = new Uint8Array(size); + const view = new DataView(bytes.buffer); + bytes.set(WPK_FORK_IMPORTED_TABLES_MAGIC, 0); + view.setUint16(4, WPK_FORK_IMPORTED_TABLES_VERSION, true); + view.setUint16(6, WPK_FORK_IMPORTED_TABLES_HEADER_SIZE, true); + view.setUint32(8, encoded.length, true); + let offset = WPK_FORK_IMPORTED_TABLES_HEADER_SIZE; + for (const record of encoded) { + const recordSize = WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE + + record.moduleBytes.byteLength + + record.nameBytes.byteLength; + view.setUint32(offset, recordSize, true); + view.setUint32(offset + 4, record.ownerId, true); + view.setUint8(offset + 8, record.typeCode); + view.setUint8(offset + 9, record.table64 ? 1 : 0); + view.setUint32(offset + 12, record.moduleBytes.byteLength, true); + view.setUint32(offset + 16, record.nameBytes.byteLength, true); + view.setUint32(offset + 20, record.importOrdinal, true); + bytes.set( + record.moduleBytes, + offset + WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE, + ); + bytes.set( + record.nameBytes, + offset + + WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE + + record.moduleBytes.byteLength, + ); + offset += recordSize; + } + return bytes; +} + +function templateId(byte: number): Uint8Array { + return new Uint8Array(32).fill(byte); +} + +function mutableI32(value: number): Uint8Array { + const payload = new Uint8Array(WPK_FORK_MODULE_STATE_GLOBAL_HEADER_SIZE + 4); + const view = new DataView(payload.buffer); + view.setUint8(0, WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I32); + view.setUint8(1, 4); + view.setInt32(WPK_FORK_MODULE_STATE_GLOBAL_HEADER_SIZE, value, true); + return payload; +} + +function sparseTable( + overrides: Partial = {}, +): ForkSparseTableSnapshot { + return { + activationId: 0, + ownerId: 4, + indexWidth: 4, + pageShift: 4, + length: 40, + baselineLength: 16, + baselineFingerprint: new Uint8Array(32).fill(0x5a), + pages: [ + { + pageIndex: 0, + runs: [ + { start: 2, recipeIds: [10, 11] }, + { start: 8, recipeIds: new Uint32Array([12, 13, 14]) }, + ], + }, + { + pageIndex: 2, + runs: [{ start: 0, recipeIds: [20, 21, 22, 23] }], + }, + ], + ...overrides, + }; +} + +describe("fork module-state descriptor", () => { + it.each([4, 8] as const)( + "round-trips the exact wasm%s descriptor and custom section", + (ptrWidth) => { + const bytes = encodeForkModuleStateDescriptor(ptrWidth); + expect(bytes).toHaveLength(FORK_MODULE_STATE_DESCRIPTOR_SIZE); + expect(decodeForkModuleStateDescriptor(bytes)).toEqual({ + version: FORK_MODULE_STATE_DESCRIPTOR_VERSION, + ptrWidth, + alignment: 8, + flags: FORK_MODULE_STATE_REQUIRED_FLAGS, + arenaVersion: 1, + recordVersion: 1, + rootPointerWordOffset: FORK_MODULE_STATE_ROOT_POINTER_WORD_OFFSET, + }); + expect(readForkModuleStateDescriptor(moduleWithDescriptors(bytes))).toEqual( + decodeForkModuleStateDescriptor(bytes), + ); + }, + ); + + it("rejects duplicate, unknown, and noncanonical descriptors", () => { + const exact = encodeForkModuleStateDescriptor(4); + expect(() => readForkModuleStateDescriptor( + moduleWithDescriptors(exact, exact), + )).toThrow("expected one kandelo.wpk_fork.module_state section, found 2"); + + const unknownFlags = exact.slice(); + new DataView(unknownFlags.buffer).setUint16( + 10, + FORK_MODULE_STATE_REQUIRED_FLAGS | 0x8000, + true, + ); + expect(() => decodeForkModuleStateDescriptor(unknownFlags)) + .toThrow("unknown module-state descriptor flags"); + + const wrongRootWord = exact.slice(); + new DataView(wrongRootWord.buffer).setUint32(16, 2, true); + expect(() => decodeForkModuleStateDescriptor(wrongRootWord)) + .toThrow("unsupported module-state root-pointer word offset 2"); + + const reserved = exact.slice(); + new DataView(reserved.buffer).setUint32(20, 1, true); + expect(() => decodeForkModuleStateDescriptor(reserved)) + .toThrow("reserved field is nonzero"); + }); +}); + +describe("fork imported-global ownership", () => { + it("parses immutable and mutable pre-instantiation recipes exactly", () => { + const descriptor = importedGlobalsSection([ + { + module: "callbacks", + name: "handler", + importOrdinal: 0, + ownerId: 2, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + }, + { + module: "env", + name: "counter", + importOrdinal: 1, + ownerId: 7, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I64, + mutable: true, + shared: true, + }, + ]); + expect(readForkImportedGlobals( + moduleWithCustomSections(WPK_FORK_IMPORTED_GLOBALS_SECTION, descriptor), + )).toEqual([ + { + module: "callbacks", + name: "handler", + importOrdinal: 0, + ownerId: 2, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + mutable: false, + shared: false, + }, + { + module: "env", + name: "counter", + importOrdinal: 1, + ownerId: 7, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I64, + mutable: true, + shared: true, + }, + ]); + }); + + it("preserves repeated bindings but rejects ambiguous owners and trailing bytes", () => { + const duplicate = importedGlobalsSection([ + { + module: "env", + name: "value", + ownerId: 1, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I32, + }, + { + module: "env", + name: "value", + ownerId: 2, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I32, + }, + ]); + expect(readForkImportedGlobals( + moduleWithCustomSections(WPK_FORK_IMPORTED_GLOBALS_SECTION, duplicate), + )).toHaveLength(2); + + const duplicateOwner = importedGlobalsSection([ + { + module: "env", + name: "first", + ownerId: 1, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I32, + }, + { + module: "env", + name: "second", + ownerId: 1, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I32, + }, + ]); + expect(() => readForkImportedGlobals( + moduleWithCustomSections(WPK_FORK_IMPORTED_GLOBALS_SECTION, duplicateOwner), + )).toThrow("duplicates owner 1"); + + const trailing = new Uint8Array(duplicate.byteLength + 1); + trailing.set(importedGlobalsSection([])); + expect(() => readForkImportedGlobals( + moduleWithCustomSections(WPK_FORK_IMPORTED_GLOBALS_SECTION, trailing), + )).toThrow("trailing bytes"); + }); +}); + +describe("fork imported-table ownership", () => { + it("parses exact import ordinals, reference classes, and table64 flags", () => { + const descriptor = importedTablesSection([ + { + module: "env", + name: "dispatch", + importOrdinal: 2, + ownerId: 3, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + }, + { + module: "shared", + name: "objects", + importOrdinal: 7, + ownerId: 8, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + table64: true, + }, + ]); + expect(readForkImportedTables( + moduleWithCustomSections(WPK_FORK_IMPORTED_TABLES_SECTION, descriptor), + )).toEqual([ + { + module: "env", + name: "dispatch", + importOrdinal: 2, + ownerId: 3, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + table64: false, + }, + { + module: "shared", + name: "objects", + importOrdinal: 7, + ownerId: 8, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + table64: true, + }, + ]); + }); + + it("rejects duplicate owners, unordered ordinals, and non-reference elements", () => { + expect(() => readForkImportedTables(moduleWithCustomSections( + WPK_FORK_IMPORTED_TABLES_SECTION, + importedTablesSection([ + { + module: "env", + name: "a", + importOrdinal: 1, + ownerId: 1, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + }, + { + module: "env", + name: "b", + importOrdinal: 0, + ownerId: 2, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + }, + ]), + ))).toThrow("duplicated or unordered import ordinal"); + expect(() => readForkImportedTables(moduleWithCustomSections( + WPK_FORK_IMPORTED_TABLES_SECTION, + importedTablesSection([ + { + module: "env", + name: "a", + ownerId: 1, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + }, + { + module: "env", + name: "b", + ownerId: 1, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + }, + ]), + ))).toThrow("duplicates owner 1"); + expect(() => readForkImportedTables(moduleWithCustomSections( + WPK_FORK_IMPORTED_TABLES_SECTION, + importedTablesSection([{ + module: "env", + name: "bad", + ownerId: 1, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I32, + }]), + ))).toThrow("unknown element type"); + }); +}); + +describe("fork tail replay manifest", () => { + it("round-trips commit order and exposes reverse replay order", () => { + const events = [ + { activationId: 0, functionOrdinal: 9 }, + { activationId: 4, functionOrdinal: 3 }, + { activationId: 4, functionOrdinal: 3 }, + ]; + const memory = new WebAssembly.Memory({ initial: 3 }); + const arenaAllocator = allocator(memory); + const arena = new ForkModuleStateArena( + memory, + 4, + arenaAllocator.allocate, + arenaAllocator.deallocate, + "tail-events", + ); + arena.begin(); + arena.appendModule({ activationId: 2, templateId: templateId(2) }); + arena.appendModule({ activationId: 4, templateId: templateId(4) }); + arena.appendReplayEvents(replayEventCapture(events)); + arena.seal(); + const replay = new ForkReplayEventJournal(); + replay.attachChild(replayEventsForChild(arena.recordViews())); + for (const event of [...events].reverse()) { + expect(replay.peek()).toEqual(event); + replay.consume(event.activationId, event.functionOrdinal); + } + replay.finishReplay(); + arena.release(); + }); + + it("requires at most one process-owned manifest", () => { + const memory = new WebAssembly.Memory({ initial: 3 }); + const arenaAllocator = allocator(memory); + const arena = new ForkModuleStateArena( + memory, + 4, + arenaAllocator.allocate, + arenaAllocator.deallocate, + "duplicate-tail-events", + ); + arena.begin(); + arena.appendModule({ activationId: 1, templateId: templateId(1) }); + const empty = replayEventCapture([]); + arena.appendReplayEvents(empty); + arena.appendReplayEvents(empty); + expect(() => arena.seal()).toThrow("duplicate process replay-event record"); + arena.release(); + }); + + it("requires every ordered segment to precede the final manifest", () => { + const records = replayEventRecords([ + { activationId: 0, functionOrdinal: 1 }, + ]); + expect(() => replayEventsForChild([...records].reverse())) + .toThrow("segment follows its final manifest"); + expect(() => replayEventsForChild(records.slice(0, -1))) + .toThrow("no process replay-event manifest"); + }); +}); + +describe("fork imported-global binding manifest", () => { + const bindings = [ + { + consumerActivation: 1, + consumerOwner: 1, + sourceActivation: 0, + sourceOwner: 0, + reserved: 0, + recipeId: 0, + rawBits: 0x7ff8_0000_0000_0042n, + kind: ForkImportedGlobalBindingKind.RawNumber, + mutable: false, + shared: false, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_F64, + }, + { + consumerActivation: 1, + consumerOwner: 2, + sourceActivation: 0, + sourceOwner: 0, + reserved: 0, + recipeId: 0, + rawBits: 0xffff_ffff_ffff_fffen, + kind: ForkImportedGlobalBindingKind.RawBigInt, + mutable: false, + shared: false, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I64, + }, + { + consumerActivation: 2, + consumerOwner: 1, + sourceActivation: 0, + sourceOwner: 0, + reserved: 0, + recipeId: 17, + rawBits: 0n, + kind: ForkImportedGlobalBindingKind.RawReference, + mutable: false, + shared: false, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + }, + { + consumerActivation: 2, + consumerOwner: 2, + sourceActivation: 7, + sourceOwner: 4, + reserved: 0, + recipeId: 0, + rawBits: 0n, + kind: ForkImportedGlobalBindingKind.ActivationGlobal, + mutable: true, + shared: false, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF, + }, + { + consumerActivation: 3, + consumerOwner: 1, + sourceActivation: 0, + sourceOwner: 0, + reserved: 0, + recipeId: 0, + rawBits: 0n, + kind: ForkImportedGlobalBindingKind.BaseImport, + mutable: true, + shared: true, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + }, + ] as const; + + it("round-trips every deterministic provider kind with exact scalar bits", () => { + expect(decodeForkImportedGlobalBindings( + encodeForkImportedGlobalBindings(bindings), + )).toEqual(bindings); + }); + + it("rejects duplicate declarations, inconsistent owners, and reserved bytes", () => { + expect(() => encodeForkImportedGlobalBindings([ + bindings[0], + bindings[0], + ])).toThrow("unique and strictly ordered"); + expect(() => encodeForkImportedGlobalBindings([{ + ...bindings[3], + sourceOwner: 0, + }])).toThrow("activation-global binding fields are inconsistent"); + + const wire = encodeForkImportedGlobalBindings(bindings); + wire[24 + 35] = 1; + expect(() => decodeForkImportedGlobalBindings(wire)) + .toThrow("reserved fields are nonzero"); + }); +}); + +describe("fork imported-table binding manifest", () => { + const bindings = [ + { + consumerActivation: 1, + consumerOwner: 2, + sourceActivation: 4, + sourceOwner: 3, + reserved: 0, + kind: ForkImportedTableBindingKind.ActivationTable, + }, + { + consumerActivation: 7, + consumerOwner: 1, + sourceActivation: 0, + sourceOwner: 0, + reserved: 0, + kind: ForkImportedTableBindingKind.BaseImport, + }, + ] as const; + + it("round-trips activation and base-import table identities", () => { + expect(decodeForkImportedTableBindings( + encodeForkImportedTableBindings(bindings), + )).toEqual(bindings); + }); + + it("rejects duplicate consumers, inconsistent owners, and reserved bytes", () => { + expect(() => encodeForkImportedTableBindings([ + bindings[0], + bindings[0], + ])).toThrow("unique and strictly ordered"); + expect(() => encodeForkImportedTableBindings([{ + ...bindings[0], + sourceOwner: 0, + }])).toThrow("activation-table binding fields are inconsistent"); + expect(() => encodeForkImportedTableBindings([{ + ...bindings[1], + sourceActivation: 1, + }])).toThrow("base-import binding fields are inconsistent"); + + const wire = encodeForkImportedTableBindings(bindings); + wire[24 + 21] = 1; + expect(() => decodeForkImportedTableBindings(wire)) + .toThrow("reserved fields are nonzero"); + }); +}); + +describe("fork activation-continuation manifest", () => { + const continuations = [ + { activationId: 0, root: 0x1_0000n }, + { activationId: 7, root: 0x1_0000_0040n }, + ] as const; + const events = [ + { activationId: 0, functionOrdinal: 3 }, + { activationId: 7, functionOrdinal: 11 }, + { activationId: 7, functionOrdinal: 9 }, + ] as const; + + it("round-trips sorted nonzero u64 roots and checks the exact replay set", () => { + const payload = encodeForkActivationContinuations(continuations); + expect(decodeForkActivationContinuations(payload)).toEqual(continuations); + const records = [ + ...replayEventRecords(events), + { + kind: ForkModuleStateRecordKind.ActivationContinuations, + activationId: 0, + ownerId: 3, + payload, + }, + ]; + expect(activationContinuationsForChild(records, 8)).toEqual(continuations); + expect(() => activationContinuationsForChild(records, 4)) + .toThrow("does not fit wasm32"); + }); + + it("accepts side-only stacks and rejects empty, zero, unordered, or drifted manifests", () => { + expect(decodeForkActivationContinuations( + encodeForkActivationContinuations([ + { activationId: 7, root: 1n }, + ]), + )).toEqual([{ activationId: 7, root: 1n }]); + expect(() => encodeForkActivationContinuations([])) + .toThrow("must not be empty"); + expect(() => encodeForkActivationContinuations([ + { activationId: 0, root: 0n }, + ])).toThrow("root is zero"); + expect(() => encodeForkActivationContinuations([ + continuations[1], + continuations[0], + ])).toThrow("strictly ordered"); + + const zeroRoot = encodeForkActivationContinuations(continuations); + zeroRoot.fill( + 0, + WPK_FORK_ACTIVATION_CONTINUATIONS_HEADER_SIZE + 8, + WPK_FORK_ACTIVATION_CONTINUATIONS_HEADER_SIZE + 16, + ); + expect(() => decodeForkActivationContinuations(zeroRoot)) + .toThrow("continuation root is zero"); + + const records = [ + ...replayEventRecords(events.slice(0, 1)), + { + kind: ForkModuleStateRecordKind.ActivationContinuations, + activationId: 0, + ownerId: 3, + payload: encodeForkActivationContinuations(continuations), + }, + ]; + expect(() => activationContinuationsForChild(records, 8)) + .toThrow("does not exactly match replay events"); + }); + + it("seals only manifests whose active roots have module descriptors", () => { + const memory = new WebAssembly.Memory({ initial: 2 }); + const owner = allocator(memory); + const arena = new ForkModuleStateArena( + memory, + 8, + owner.allocate, + owner.deallocate, + "activation-continuations", + ); + arena.begin(); + arena.appendModule({ activationId: 0, templateId: templateId(1) }); + arena.appendModule({ activationId: 7, templateId: templateId(7) }); + arena.appendReplayEvents(replayEventCapture(events)); + arena.appendActivationContinuations(continuations); + expect(() => arena.seal()).not.toThrow(); + expect(activationContinuationsForChild(arena.records(), 8)) + .toEqual(continuations); + arena.release(); + }); +}); + +describe("module-state root-prefix ownership", () => { + it.each([4, 8] as const)( + "stores a wasm%s arena root in the reserved +P word and supports clearing it", + (ptrWidth) => { + const memory = new WebAssembly.Memory({ initial: 2 }); + const moduleBuffer = 256; + writeForkModuleStateRoot(memory, moduleBuffer, ptrWidth, PAGE_SIZE); + expect(readForkModuleStateRoot(memory, moduleBuffer, ptrWidth)).toBe(PAGE_SIZE); + + const view = new DataView(memory.buffer); + const slot = moduleBuffer + ptrWidth; + expect( + ptrWidth === 8 + ? view.getBigUint64(slot, true) + : BigInt(view.getUint32(slot, true)), + ).toBe(BigInt(PAGE_SIZE)); + + writeForkModuleStateRoot(memory, moduleBuffer, ptrWidth, 0); + expect(readForkModuleStateRoot(memory, moduleBuffer, ptrWidth)).toBe(0); + }, + ); + + it("rejects roots that cannot own page-aligned arena mappings", () => { + const memory = new WebAssembly.Memory({ initial: 1 }); + expect(() => writeForkModuleStateRoot(memory, 128, 4, 1234)) + .toThrow("arena root must be page-aligned"); + }); +}); + +describe("ForkModuleStateArena", () => { + it.each([4, 8] as const)( + "streams, validates, clones, and releases typed wasm%s state", + (ptrWidth) => { + const parentMemory = new WebAssembly.Memory({ initial: 4 }); + const parentAllocator = allocator(parentMemory); + const parent = new ForkModuleStateArena( + parentMemory, + ptrWidth, + parentAllocator.allocate, + parentAllocator.deallocate, + `parent-wasm${ptrWidth * 8}`, + ); + const root = parent.begin(); + parent.appendModule({ + activationId: 0, + templateId: templateId(0xa0), + }); + parent.appendRecord({ + kind: ForkModuleStateRecordKind.ReferenceRecipe, + activationId: 0, + ownerId: 1, + payload: new Uint8Array(70_000).fill(0x91), + }); + parent.appendRecord({ + kind: ForkModuleStateRecordKind.MutableGlobal, + activationId: 0, + ownerId: 2, + payload: mutableI32(0x0908_0706), + }); + parent.appendElementSegmentState({ + activationId: 0, + ownerId: 3, + segmentCount: 10, + dropped: new Uint8Array([0b0101_0101, 0b0000_0010]), + }); + parent.appendDataSegmentState({ + activationId: 0, + ownerId: 5, + segmentCount: 3, + dropped: new Uint8Array([0b0000_0101]), + }); + parent.appendSparseTable(sparseTable({ + indexWidth: ptrWidth, + length: ptrWidth === 8 ? 40n : 40, + })); + parent.seal(); + expect(parentAllocator.allocations.length).toBeGreaterThan(1); + + const moduleBuffer = 512; + writeForkModuleStateRoot(parentMemory, moduleBuffer, ptrWidth, root); + const childMemory = cloneMemory(parentMemory); + + const parentRecords = parent.records(); + expect(parentRecords.map(({ kind, ownerId }) => [kind, ownerId])).toEqual([ + [ForkModuleStateRecordKind.Module, 0], + [ForkModuleStateRecordKind.ReferenceRecipe, 1], + [ForkModuleStateRecordKind.MutableGlobal, 2], + [ForkModuleStateRecordKind.ElementSegments, 3], + [ForkModuleStateRecordKind.DataSegments, 5], + [ForkModuleStateRecordKind.Table, 4], + [ForkModuleStateRecordKind.TablePage, 4], + [ForkModuleStateRecordKind.TablePage, 4], + ]); + expect(parentRecords[1]!.payload[0]).toBe(0x91); + expect(parentRecords[1]!.payload.at(-1)).toBe(0x91); + const parentGlobalPayload = parent.findRecord( + ForkModuleStateRecordKind.MutableGlobal, + 0, + 2, + 0, + ); + expect(typeof parentGlobalPayload).toBe(ptrWidth === 8 ? "bigint" : "number"); + expect( + new Uint8Array( + parentMemory.buffer, + Number(parentGlobalPayload), + WPK_FORK_MODULE_STATE_GLOBAL_HEADER_SIZE + 4, + ), + ).toEqual(mutableI32(0x0908_0706)); + + const childReleases: Array<{ addr: number; size: number }> = []; + const child = new ForkModuleStateArena( + childMemory, + ptrWidth, + () => { throw new Error("attached arena must not allocate"); }, + (addr, size) => childReleases.push({ addr, size }), + `child-wasm${ptrWidth * 8}`, + ); + child.attach(readForkModuleStateRoot(childMemory, moduleBuffer, ptrWidth)); + expect(child.records()).toEqual(parentRecords); + const childGlobalPayload = child.findRecord( + ForkModuleStateRecordKind.MutableGlobal, + 0, + 2, + 0, + ); + expect( + new Uint8Array( + childMemory.buffer, + Number(childGlobalPayload), + WPK_FORK_MODULE_STATE_GLOBAL_HEADER_SIZE + 4, + ), + ).toEqual(mutableI32(0x0908_0706)); + expect(() => child.findRecord( + ForkModuleStateRecordKind.MutableGlobal, + 0, + 2, + 1, + )).toThrow("missing module-state record"); + expect(child.sparseTables()).toEqual([ + { + activationId: 0, + ownerId: 4, + indexWidth: ptrWidth, + pageShift: 4, + length: 40n, + baselineLength: 16n, + baselineFingerprint: new Uint8Array(32).fill(0x5a), + pages: [ + { + pageIndex: 0n, + runs: [ + { start: 2, recipeIds: new Uint32Array([10, 11]) }, + { start: 8, recipeIds: new Uint32Array([12, 13, 14]) }, + ], + }, + { + pageIndex: 2n, + runs: [ + { start: 0, recipeIds: new Uint32Array([20, 21, 22, 23]) }, + ], + }, + ], + }, + ]); + + child.release(); + expect(child.hasActiveArena()).toBe(false); + expect(childReleases).toEqual([...parentAllocator.allocations].reverse()); + + parent.release(); + expect(parentAllocator.releases).toEqual( + [...parentAllocator.allocations].reverse(), + ); + }, + ); + + it("rejects ownership ambiguity and undeclared activation state before sealing", () => { + const memory = new WebAssembly.Memory({ initial: 3 }); + const arenaAllocator = allocator(memory); + const arena = new ForkModuleStateArena( + memory, + 4, + arenaAllocator.allocate, + arenaAllocator.deallocate, + "ambiguous", + ); + arena.begin(); + arena.appendModule({ activationId: 0, templateId: templateId(1) }); + arena.appendRecord({ + kind: ForkModuleStateRecordKind.MutableGlobal, + activationId: 0, + ownerId: 8, + payload: mutableI32(1), + }); + arena.appendRecord({ + kind: ForkModuleStateRecordKind.MutableGlobal, + activationId: 0, + ownerId: 8, + payload: mutableI32(2), + }); + expect(() => arena.seal()).toThrow("duplicate owner 8"); + expect(arena.hasActiveArena()).toBe(true); + arena.release(); + + const second = new ForkModuleStateArena( + memory, + 4, + arenaAllocator.allocate, + arenaAllocator.deallocate, + "undeclared", + ); + second.begin(); + second.appendRecord({ + kind: ForkModuleStateRecordKind.ReferenceRecipe, + activationId: 99, + ownerId: 1, + payload: new Uint8Array(), + }); + expect(() => second.seal()).toThrow("undeclared module activation 99"); + second.release(); + }); + + it.each([4, 8] as const)( + "publishes wasm%s guest-written records only after their exact reservation commits", + (ptrWidth) => { + const memory = new WebAssembly.Memory({ initial: 3 }); + const arenaAllocator = allocator(memory); + const arena = new ForkModuleStateArena( + memory, + ptrWidth, + arenaAllocator.allocate, + arenaAllocator.deallocate, + `transactional-wasm${ptrWidth * 8}`, + ); + arena.begin(); + arena.appendModule({ activationId: 0, templateId: templateId(6) }); + const payload = arena.reserveRecord( + ForkModuleStateRecordKind.MutableGlobal, + 0, + 7, + ptrWidth === 8 ? 12n : 12, + ); + expect(typeof payload).toBe(ptrWidth === 8 ? "bigint" : "number"); + new Uint8Array(memory.buffer, Number(payload), 12).set(mutableI32(0x0907_0503)); + expect(() => arena.seal()).toThrow("pending module-state record"); + expect(() => arena.commitRecord(Number(payload) + 8)) + .toThrow("does not match reservation"); + arena.commitRecord(payload); + arena.seal(); + expect(arena.records().at(-1)).toEqual({ + kind: ForkModuleStateRecordKind.MutableGlobal, + activationId: 0, + ownerId: 7, + payload: mutableI32(0x0907_0503), + }); + arena.release(); + }, + ); + + it("rejects sparse pages that are unordered, overlapping, or outside final length", () => { + const memory = new WebAssembly.Memory({ initial: 3 }); + const arenaAllocator = allocator(memory); + const arena = new ForkModuleStateArena( + memory, + 4, + arenaAllocator.allocate, + arenaAllocator.deallocate, + "bad-sparse-table", + ); + arena.begin(); + arena.appendModule({ activationId: 0, templateId: templateId(2) }); + + expect(() => arena.appendSparseTable(sparseTable({ + pages: [{ + pageIndex: 0, + runs: [ + { start: 4, recipeIds: [1, 2] }, + { start: 5, recipeIds: [3] }, + ], + }], + }))).toThrow("unordered or out of bounds"); + arena.release(); + }); + + it("rejects tampered sparse page counts during fresh-instance attachment", () => { + const memory = new WebAssembly.Memory({ initial: 4 }); + const arenaAllocator = allocator(memory); + const arena = new ForkModuleStateArena( + memory, + 4, + arenaAllocator.allocate, + arenaAllocator.deallocate, + "table-count-parent", + ); + const root = arena.begin(); + arena.appendModule({ activationId: 0, templateId: templateId(3) }); + arena.appendSparseTable(sparseTable({ pages: [sparseTable().pages[0]!] })); + arena.seal(); + + // wasm32 chunk header is 40 bytes. The module record occupies 64 bytes; + // the following table record's payload begins after its 24-byte TLV header. + const tablePayload = root + 40 + 64 + 24; + new DataView(memory.buffer).setUint32(tablePayload + 4, 2, true); + const releases: Array<{ addr: number; size: number }> = []; + const child = new ForkModuleStateArena( + cloneMemory(memory), + 4, + () => { throw new Error("attachment must not allocate"); }, + (addr, size) => releases.push({ addr, size }), + "table-count-child", + ); + expect(() => child.attach(root)).toThrow("declares 2 sparse pages, found 1"); + expect(child.hasActiveArena()).toBe(false); + expect(releases).toEqual([]); + arena.release(); + }); + + it("does not adopt or release unsealed or malformed guest arenas", () => { + const memory = new WebAssembly.Memory({ initial: 3 }); + const arenaAllocator = allocator(memory); + const parent = new ForkModuleStateArena( + memory, + 4, + arenaAllocator.allocate, + arenaAllocator.deallocate, + "unsealed-parent", + ); + const root = parent.begin(); + parent.appendModule({ activationId: 0, templateId: templateId(4) }); + const childReleases: Array<{ addr: number; size: number }> = []; + const unsealedChild = new ForkModuleStateArena( + cloneMemory(memory), + 4, + () => { throw new Error("attachment must not allocate"); }, + (addr, size) => childReleases.push({ addr, size }), + "unsealed-child", + ); + expect(() => unsealedChild.attach(root)).toThrow("invalid or unsealed"); + expect(childReleases).toEqual([]); + + parent.seal(); + // Record kind is at root + wasm32 chunk header + 6. + new DataView(memory.buffer).setUint16(root + 40 + 6, 0xffff, true); + const malformedChild = new ForkModuleStateArena( + cloneMemory(memory), + 4, + () => { throw new Error("attachment must not allocate"); }, + (addr, size) => childReleases.push({ addr, size }), + "malformed-child", + ); + expect(() => malformedChild.attach(root)).toThrow("invalid record header"); + expect(malformedChild.hasActiveArena()).toBe(false); + expect(childReleases).toEqual([]); + parent.release(); + }); + + it("rejects a copied multi-chunk cycle without adopting forged ownership", () => { + const memory = new WebAssembly.Memory({ initial: 4 }); + const arenaAllocator = allocator(memory); + const parent = new ForkModuleStateArena( + memory, + 4, + arenaAllocator.allocate, + arenaAllocator.deallocate, + "cycle-parent", + ); + const root = parent.begin(); + parent.appendModule({ activationId: 0, templateId: templateId(7) }); + parent.appendRecord({ + kind: ForkModuleStateRecordKind.ReferenceRecipe, + activationId: 0, + ownerId: 1, + payload: new Uint8Array(70_000), + }); + parent.seal(); + expect(arenaAllocator.allocations).toHaveLength(2); + const tail = arenaAllocator.allocations[1]!.addr; + // wasm32 chunk next pointer is at +8 + 2P. + new DataView(memory.buffer).setUint32(tail + 16, root, true); + + const releases: Array<{ addr: number; size: number }> = []; + const child = new ForkModuleStateArena( + cloneMemory(memory), + 4, + () => { throw new Error("attachment must not allocate"); }, + (addr, size) => releases.push({ addr, size }), + "cycle-child", + ); + expect(() => child.attach(root)).toThrow("module-state chunk cycle"); + expect(child.hasActiveArena()).toBe(false); + expect(releases).toEqual([]); + parent.release(); + }); + + it("drops ownership before reporting cleanup failure", () => { + const memory = new WebAssembly.Memory({ initial: 3 }); + const arenaAllocator = allocator(memory); + const arena = new ForkModuleStateArena( + memory, + 4, + arenaAllocator.allocate, + () => { throw new Error("synthetic munmap failure"); }, + "release-failure", + ); + arena.begin(); + arena.appendModule({ activationId: 0, templateId: templateId(5) }); + arena.seal(); + expect(() => arena.release()).toThrow("synthetic munmap failure"); + expect(arena.hasActiveArena()).toBe(false); + expect(arena.isSealed()).toBe(false); + }); + + it("releases an uncommitted guest reservation during abort cleanup", () => { + const memory = new WebAssembly.Memory({ initial: 3 }); + const arenaAllocator = allocator(memory); + const arena = new ForkModuleStateArena( + memory, + 4, + arenaAllocator.allocate, + arenaAllocator.deallocate, + "pending-abort", + ); + arena.begin(); + arena.appendModule({ activationId: 0, templateId: templateId(8) }); + arena.reserveRecord( + ForkModuleStateRecordKind.ReferenceRecipe, + 0, + 1, + 128, + ); + arena.release(); + expect(arena.hasActiveArena()).toBe(false); + expect(arenaAllocator.releases).toEqual( + [...arenaAllocator.allocations].reverse(), + ); + }); +}); diff --git a/host/test/fork-process-continuation.test.ts b/host/test/fork-process-continuation.test.ts new file mode 100644 index 0000000000..8c29f31fad --- /dev/null +++ b/host/test/fork-process-continuation.test.ts @@ -0,0 +1,592 @@ +import { describe, expect, it } from "vitest"; +import { + ForkActivationRegistry, + type ForkActivationRegistration, +} from "../src/fork-activation-registry"; +import { + type LinkedFrameFormatDescriptor, + LinkedForkContinuation, +} from "../src/fork-continuation"; +import { + ForkModuleStateArena, + ForkTableDirtyTracker, +} from "../src/fork-module-state"; +import { ForkProcessContinuationCoordinator } from "../src/fork-process-continuation"; +import { FORK_REPLAY_EVENT_SEGMENT_CAPACITY } from "../src/fork-replay-events"; + +const PAGE_SIZE = 65_536; + +interface AllocationOwner { + allocate(size: number): number; + deallocate(addr: number, size: number): void; +} + +function allocationOwner(memory: WebAssembly.Memory): AllocationOwner { + let next = PAGE_SIZE; + return { + allocate(size) { + const address = next; + next += size; + if (next > memory.buffer.byteLength) { + memory.grow(Math.ceil((next - memory.buffer.byteLength) / PAGE_SIZE)); + } + return address; + }, + deallocate() {}, + }; +} + +function linkedFormat(): LinkedFrameFormatDescriptor { + return { + version: 1, + ptrWidth: 4, + alignment: 16, + flags: 1, + chunkHeaderSize: 32, + nodeHeaderSize: 32, + fixedPrefixSize: 64, + }; +} + +function externrefs() { + return { + capture(): number { + throw new Error("fixture has no externrefs"); + }, + materialize(): unknown { + throw new Error("fixture has no externrefs"); + }, + }; +} + +function emptyFunctionCatalog(): WebAssembly.Table { + return new WebAssembly.Table({ + element: "anyfunc", + initial: 0, + maximum: 0, + }); +} + +function wasmThunk(): CallableFunction { + const module = new WebAssembly.Module(new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + 0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7f, + 0x03, 0x02, 0x01, 0x00, + 0x07, 0x05, 0x01, 0x01, 0x66, 0x00, 0x00, + 0x0a, 0x06, 0x01, 0x04, 0x00, 0x41, 0x07, 0x0b, + ])); + return new WebAssembly.Instance(module).exports.f as CallableFunction; +} + +function fakeActivation( + activationId: number, + calls: string[], +): { + registration: ForkActivationRegistration; + state: () => number; +} { + let state = 0; + const exports = { + wpk_fork_state: () => state, + wpk_fork_unwind_begin: () => { + expect(state).toBe(0); + state = 1; + calls.push(`unwind-begin:${activationId}`); + }, + wpk_fork_unwind_end: () => { + expect(state).toBe(1); + state = 0; + calls.push(`unwind-end:${activationId}`); + }, + wpk_fork_rewind_begin: () => { + expect(state).toBe(0); + state = 2; + calls.push(`rewind-begin:${activationId}`); + }, + wpk_fork_rewind_end: () => { + expect(state).toBe(2); + state = 0; + calls.push(`rewind-end:${activationId}`); + }, + wpk_fork_abort_begin: () => { + expect([0, 1]).toContain(state); + state = 3; + calls.push(`abort-begin:${activationId}`); + }, + wpk_fork_abort_end: () => { + expect(state).toBe(3); + state = 0; + calls.push(`abort-end:${activationId}`); + }, + }; + return { + registration: { + activationId, + instance: { exports } as unknown as WebAssembly.Instance, + templateId: new Uint8Array(32).fill(activationId + 1), + functionCatalog: emptyFunctionCatalog(), + staticRootCatalog: new WebAssembly.Table({ + element: "externref", + initial: 0, + maximum: 0, + }), + staticRootHarvest: () => {}, + moduleState: { + bootstrap: () => {}, + save: (id) => { calls.push(`save:${id}`); }, + restore: (id) => { calls.push(`restore:${id}`); }, + finishRestore: (id) => { calls.push(`finish-restore:${id}`); }, + saveTables: (id) => { calls.push(`save-tables:${id}`); }, + restoreTables: (id) => { calls.push(`restore-tables:${id}`); }, + }, + tableDirty: new ForkTableDirtyTracker(), + }, + state: () => state, + }; +} + +function makeCoordinator( + memory: WebAssembly.Memory, + owner: AllocationOwner, + calls: string[], + roots: Map, + label: string, +): { + coordinator: ForkProcessContinuationCoordinator; + arena: ForkModuleStateArena; +} { + const registry = new ForkActivationRegistry(memory, externrefs(), `${label}: registry`); + const coordinator = new ForkProcessContinuationCoordinator( + memory, + registry, + label, + ); + for (const activationId of [0, 4, 9]) { + const continuation = new LinkedForkContinuation( + memory, + linkedFormat(), + owner.allocate, + owner.deallocate, + `${label}: activation ${activationId}`, + ); + coordinator.prepareActivation({ + activationId, + continuation, + ...(activationId === 0 + ? { + publishProcessLaunchRoot: (root: number) => { + roots.set(0, root); + }, + readProcessLaunchRoot: () => roots.get(0) ?? 0, + } + : {}), + }); + const activation = fakeActivation(activationId, calls); + coordinator.registerActivation(activation.registration, [{ + functionOrdinal: activationId === 0 ? 11 : activationId === 4 ? 8 : 3, + thunk: wasmThunk(), + }]); + } + return { + coordinator, + arena: new ForkModuleStateArena( + memory, + 4, + owner.allocate, + owner.deallocate, + `${label}: arena`, + ), + }; +} + +function writeOrdinal( + memory: WebAssembly.Memory, + payload: number | bigint, + ordinal: number, +): void { + new DataView(memory.buffer).setUint32(Number(payload), ordinal, true); +} + +describe("ForkProcessContinuationCoordinator", () => { + it("reconstructs cross-activation frame order in a fresh child", () => { + const parentMemory = new WebAssembly.Memory({ initial: 16 }); + const parentOwner = allocationOwner(parentMemory); + const parentCalls: string[] = []; + const parentRoots = new Map(); + const parent = makeCoordinator( + parentMemory, + parentOwner, + parentCalls, + parentRoots, + "parent", + ); + const arenaRoot = parent.arena.begin(); + parent.coordinator.beginCapture(parent.arena); + + const sideImports = parent.coordinator.continuationImports(4); + const sidePayload = ( + sideImports.__wpk_fork_frame_reserve as (size: number) => number + )(16); + writeOrdinal(parentMemory, sidePayload, 8); + ( + sideImports.__wpk_fork_frame_commit as (payload: number) => void + )(sidePayload); + + const mainImports = parent.coordinator.continuationImports(0); + const mainPayload = ( + mainImports.__wpk_fork_frame_reserve as (size: number) => number + )(16); + writeOrdinal(parentMemory, mainPayload, 11); + ( + mainImports.__wpk_fork_frame_commit as (payload: number) => void + )(mainPayload); + parent.coordinator.sealCapture(); + + // Activation 9 exists in the process but was not on this thread's stack. + // Its runtime prefix is discarded before memory is copied. + expect(parent.coordinator.rootFor(9)).toBe(0); + expect(parent.coordinator.rootFor(0)).toBeGreaterThan(0); + expect(parent.coordinator.rootFor(4)).toBeGreaterThan(0); + // Only one process launch root crosses through the channel anchor. Side + // roots are reconstructed from the copied KFMS manifest, not JS/archive + // auxiliary state. + expect([...parentRoots.keys()]).toEqual([0]); + + const copiedBytes = new Uint8Array(parentMemory.buffer).slice(); + const copiedRoots = new Map(parentRoots); + const childMemory = new WebAssembly.Memory({ + initial: copiedBytes.byteLength / PAGE_SIZE, + }); + new Uint8Array(childMemory.buffer).set(copiedBytes); + const childOwner = allocationOwner(childMemory); + const childCalls: string[] = []; + const child = makeCoordinator( + childMemory, + childOwner, + childCalls, + copiedRoots, + "child", + ); + child.arena.attach(arenaRoot); + child.coordinator.attachChild(child.arena); + + const childMainImports = child.coordinator.continuationImports(0); + const childSideImports = child.coordinator.continuationImports(4); + const mainSlot = ( + childMainImports.__wpk_fork_resume_peek as (diagnostic: number) => number + )(0); + expect(mainSlot).toBeGreaterThan(0); + expect( + Number(( + childMainImports.__wpk_fork_frame_peek as (size: number) => number + )(16)), + ).toBe(Number(mainPayload)); + ( + childMainImports.__wpk_fork_frame_next as (size: number) => number + )(16); + + const sideSlot = ( + childSideImports.__wpk_fork_resume_peek as (diagnostic: number) => number + )(0); + expect(sideSlot).toBeGreaterThan(0); + ( + childSideImports.__wpk_fork_frame_peek as (size: number) => number + )(16); + ( + childSideImports.__wpk_fork_frame_next as (size: number) => number + )(16); + expect(( + childSideImports.__wpk_fork_resume_peek as (diagnostic: number) => number + )(0)).toBe(0); + child.coordinator.finishReplay(); + + expect(childCalls.filter((call) => call.startsWith("restore:"))).toEqual([ + "restore:0", + "restore:4", + "restore:9", + ]); + expect(copiedRoots.get(0)).toBe(0); + expect(child.coordinator.phaseName()).toBe("idle"); + }); + + it("does not consume a frame when the selected activation is wrong", () => { + const memory = new WebAssembly.Memory({ initial: 16 }); + const owner = allocationOwner(memory); + const roots = new Map(); + const fixture = makeCoordinator(memory, owner, [], roots, "mismatch"); + fixture.arena.begin(); + fixture.coordinator.beginCapture(fixture.arena); + const sideImports = fixture.coordinator.continuationImports(4); + const payload = ( + sideImports.__wpk_fork_frame_reserve as (size: number) => number + )(16); + writeOrdinal(memory, payload, 8); + ( + sideImports.__wpk_fork_frame_commit as (payload: number) => void + )(payload); + fixture.coordinator.sealCapture(); + fixture.coordinator.beginParentReplay(); + + const mainImports = fixture.coordinator.continuationImports(0); + ( + mainImports.__wpk_fork_resume_peek as (diagnostic: number) => number + )(0); + expect(() => ( + mainImports.__wpk_fork_frame_next as (size: number) => number + )(16)).toThrow("cannot consume frame for activation 4"); + + ( + sideImports.__wpk_fork_frame_next as (size: number) => number + )(16); + fixture.coordinator.finishReplay(); + }); + + it("launches a fresh child from a side-only continuation manifest", () => { + const parentMemory = new WebAssembly.Memory({ initial: 16 }); + const parentOwner = allocationOwner(parentMemory); + const parentRoots = new Map(); + const parent = makeCoordinator( + parentMemory, + parentOwner, + [], + parentRoots, + "side-only parent", + ); + const arenaRoot = parent.arena.begin(); + parent.coordinator.beginCapture(parent.arena); + const sideImports = parent.coordinator.continuationImports(4); + const payload = ( + sideImports.__wpk_fork_frame_reserve as (size: number) => number + )(16); + writeOrdinal(parentMemory, payload, 8); + ( + sideImports.__wpk_fork_frame_commit as (payload: number) => void + )(payload); + parent.coordinator.sealCapture(); + + const sideRoot = parent.coordinator.rootFor(4); + expect(parent.coordinator.rootFor(0)).toBe(0); + expect(sideRoot).toBeGreaterThan(0); + expect(parentRoots.get(0)).toBe(sideRoot); + expect([...parentRoots.keys()]).toEqual([0]); + + const copiedBytes = new Uint8Array(parentMemory.buffer).slice(); + const copiedRoots = new Map(parentRoots); + const childMemory = new WebAssembly.Memory({ + initial: copiedBytes.byteLength / PAGE_SIZE, + }); + new Uint8Array(childMemory.buffer).set(copiedBytes); + const child = makeCoordinator( + childMemory, + allocationOwner(childMemory), + [], + copiedRoots, + "side-only child", + ); + child.arena.attach(arenaRoot); + child.coordinator.attachChild(child.arena); + + expect(child.coordinator.rootFor(0)).toBe(0); + expect(child.coordinator.rootFor(4)).toBe(sideRoot); + ( + child.coordinator.continuationImports(4) + .__wpk_fork_frame_next as (size: number) => number + )(16); + child.coordinator.finishReplay(); + expect(copiedRoots.get(0)).toBe(0); + }); + + it("replays a partial unwind after continuation allocation failure", () => { + const memory = new WebAssembly.Memory({ initial: 16 }); + const owner = allocationOwner(memory); + const calls: string[] = []; + const roots = new Map(); + const fixture = makeCoordinator(memory, owner, calls, roots, "partial abort"); + fixture.arena.begin(); + fixture.coordinator.beginCapture(fixture.arena); + + const sideImports = fixture.coordinator.continuationImports(4); + const payload = ( + sideImports.__wpk_fork_frame_reserve as (size: number) => number + )(16); + writeOrdinal(memory, payload, 8); + ( + sideImports.__wpk_fork_frame_commit as (payload: number) => void + )(payload); + + fixture.coordinator.beginCaptureAbort(12); + expect(fixture.coordinator.phaseName()).toBe("abort-replay"); + expect(calls.filter((call) => call.startsWith("abort-begin:"))).toEqual([ + "abort-begin:0", + "abort-begin:4", + "abort-begin:9", + ]); + expect( + (sideImports.__wpk_fork_resume_peek as (diagnostic: number) => number)(0), + ).toBeGreaterThan(0); + ( + sideImports.__wpk_fork_frame_next as (size: number) => number + )(16); + fixture.coordinator.finishAbortReplay(); + + expect(calls.filter((call) => call.startsWith("abort-end:"))).toEqual([ + "abort-end:0", + "abort-end:4", + "abort-end:9", + ]); + expect(roots.get(0)).toBe(0); + expect([...roots.keys()]).toEqual([0]); + expect(fixture.coordinator.phaseName()).toBe("idle"); + }); + + it("replays an arbitrarily nested main-to-side-to-side stack", () => { + const parentMemory = new WebAssembly.Memory({ initial: 16 }); + const parentOwner = allocationOwner(parentMemory); + const parentRoots = new Map(); + const parent = makeCoordinator( + parentMemory, + parentOwner, + [], + parentRoots, + "nested parent", + ); + const arenaRoot = parent.arena.begin(); + parent.coordinator.beginCapture(parent.arena); + + const commit = (activationId: number, functionOrdinal: number): void => { + const imports = parent.coordinator.continuationImports(activationId); + const payload = ( + imports.__wpk_fork_frame_reserve as (size: number) => number + )(16); + writeOrdinal(parentMemory, payload, functionOrdinal); + ( + imports.__wpk_fork_frame_commit as (payload: number) => void + )(payload); + }; + // Unwind walks from the fork leaf outward. + commit(9, 3); + commit(4, 8); + commit(0, 11); + parent.coordinator.sealCapture(); + + const copiedBytes = new Uint8Array(parentMemory.buffer).slice(); + const copiedRoots = new Map(parentRoots); + const childMemory = new WebAssembly.Memory({ + initial: copiedBytes.byteLength / PAGE_SIZE, + }); + new Uint8Array(childMemory.buffer).set(copiedBytes); + const child = makeCoordinator( + childMemory, + allocationOwner(childMemory), + [], + copiedRoots, + "nested child", + ); + child.arena.attach(arenaRoot); + child.coordinator.attachChild(child.arena); + + for (const activationId of [0, 4, 9]) { + const imports = child.coordinator.continuationImports(activationId); + expect( + (imports.__wpk_fork_resume_peek as (diagnostic: number) => number)(0), + ).toBeGreaterThan(0); + ( + imports.__wpk_fork_frame_next as (size: number) => number + )(16); + } + expect(( + child.coordinator.continuationImports(9) + .__wpk_fork_resume_peek as (diagnostic: number) => number + )(0)).toBe(0); + child.coordinator.finishReplay(); + + expect(copiedRoots.get(0)).toBe(0); + expect([...copiedRoots.keys()]).toEqual([0]); + }); + + it("replays a continuation spanning multiple event pages in a fresh child", () => { + const eventCount = FORK_REPLAY_EVENT_SEGMENT_CAPACITY + 2; + const parentMemory = new WebAssembly.Memory({ initial: 16 }); + const parentOwner = allocationOwner(parentMemory); + const parentRoots = new Map(); + const parent = makeCoordinator( + parentMemory, + parentOwner, + [], + parentRoots, + "paged parent", + ); + const arenaRoot = parent.arena.begin(); + parent.coordinator.beginCapture(parent.arena); + const sideImports = parent.coordinator.continuationImports(4); + for (let index = 0; index < eventCount; index++) { + const payload = ( + sideImports.__wpk_fork_frame_reserve as (size: number) => number + )(16); + writeOrdinal(parentMemory, payload, 8); + ( + sideImports.__wpk_fork_frame_commit as (payload: number) => void + )(payload); + } + parent.coordinator.sealCapture(); + + const copiedBytes = new Uint8Array(parentMemory.buffer).slice(); + const childMemory = new WebAssembly.Memory({ + initial: copiedBytes.byteLength / PAGE_SIZE, + }); + new Uint8Array(childMemory.buffer).set(copiedBytes); + const child = makeCoordinator( + childMemory, + allocationOwner(childMemory), + [], + new Map(parentRoots), + "paged child", + ); + child.arena.attach(arenaRoot); + child.coordinator.attachChild(child.arena); + const childSideImports = child.coordinator.continuationImports(4); + for (let index = 0; index < eventCount; index++) { + expect(( + childSideImports.__wpk_fork_resume_peek as (diagnostic: number) => number + )(0)).toBeGreaterThan(0); + ( + childSideImports.__wpk_fork_frame_next as (size: number) => number + )(16); + } + expect(( + childSideImports.__wpk_fork_resume_peek as (diagnostic: number) => number + )(0)).toBe(0); + child.coordinator.finishReplay(); + }); + + it("aborts a partial capture spanning multiple event pages", () => { + const eventCount = FORK_REPLAY_EVENT_SEGMENT_CAPACITY + 1; + const memory = new WebAssembly.Memory({ initial: 16 }); + const owner = allocationOwner(memory); + const roots = new Map(); + const fixture = makeCoordinator(memory, owner, [], roots, "paged abort"); + fixture.arena.begin(); + fixture.coordinator.beginCapture(fixture.arena); + const sideImports = fixture.coordinator.continuationImports(4); + for (let index = 0; index < eventCount; index++) { + const payload = ( + sideImports.__wpk_fork_frame_reserve as (size: number) => number + )(16); + writeOrdinal(memory, payload, 8); + ( + sideImports.__wpk_fork_frame_commit as (payload: number) => void + )(payload); + } + fixture.coordinator.beginCaptureAbort(12); + for (let index = 0; index < eventCount; index++) { + ( + sideImports.__wpk_fork_resume_peek as (diagnostic: number) => number + )(0); + ( + sideImports.__wpk_fork_frame_next as (size: number) => number + )(16); + } + fixture.coordinator.finishAbortReplay(); + expect(fixture.coordinator.phaseName()).toBe("idle"); + }); +}); diff --git a/host/test/fork-reference-broker.test.ts b/host/test/fork-reference-broker.test.ts new file mode 100644 index 0000000000..6881f87f61 --- /dev/null +++ b/host/test/fork-reference-broker.test.ts @@ -0,0 +1,333 @@ +import { describe, expect, it } from "vitest"; +import { + ForkExternrefBroker, + ForkExternrefTokenRecipeProvider, + ForkExternrefTokenCache, +} from "../src/fork-reference-broker"; + +describe("ForkExternrefBroker", () => { + it("owns aliases once per execution generation and leases them once on fork", () => { + const broker = new ForkExternrefBroker(); + const parent = broker.createGeneration(11); + const child = broker.createGeneration(12); + const value = { opaque: true }; + const first = broker.register(parent, value); + const alias = broker.register(parent, value); + expect(alias).toBe(first); + expect(broker.holderCount(first, parent)).toBe(1); + + const lease = broker.acquireFork(parent, child, [first, first, first]); + expect(lease.handleCount).toBe(1); + expect(broker.authorize(child, first)).toBe(value); + expect(broker.holderCount(first, child)).toBe(1); + + expect(broker.releaseGeneration(parent)).toBe(true); + expect(() => broker.authorize(parent, first)).toThrow("stale"); + expect(broker.authorize(child, first)).toBe(value); + lease.release(); + expect(() => lease.release()).toThrow("already released"); + expect(() => broker.authorize(child, first)).toThrow("retired"); + }); + + it("publishes no partial child ownership when a fork recipe is invalid", () => { + const broker = new ForkExternrefBroker(); + const parent = broker.createGeneration(21); + const child = broker.createGeneration(22); + const handle = broker.register(parent, Symbol("opaque")); + expect(() => + broker.acquireFork(parent, child, [handle, handle + 1]) + ).toThrow("unknown externref handle"); + expect(broker.holderCount(handle, child)).toBe(0); + }); + + it("rolls back every child handle when fork publication fails mid-mutation", () => { + const broker = new ForkExternrefBroker(); + const parent = broker.createGeneration(23); + const child = broker.createGeneration(24); + const first = broker.register(parent, "first"); + const second = broker.register(parent, "second"); + const state = ( + broker as unknown as { + generations: WeakMap< + object, + { forkHandleCounts: Map } + >; + } + ).generations.get(child)!; + const originalSet = state.forkHandleCounts.set.bind( + state.forkHandleCounts, + ); + let writes = 0; + state.forkHandleCounts.set = (handle, count) => { + writes++; + if (writes === 2) throw new Error("injected fork publication failure"); + return originalSet(handle, count); + }; + + expect(() => broker.acquireFork(parent, child, [first, second])).toThrow( + "injected fork publication failure", + ); + expect(broker.holderCount(first, child)).toBe(0); + expect(broker.holderCount(second, child)).toBe(0); + expect(broker.authorize(parent, first)).toBe("first"); + expect(broker.authorize(parent, second)).toBe("second"); + }); + + it("tracks primitive externrefs without conflating distinct values", () => { + const broker = new ForkExternrefBroker(); + const generation = broker.createGeneration(31); + const one = broker.register(generation, 1); + const oneAlias = broker.register(generation, 1); + const text = broker.register(generation, "1"); + expect(oneAlias).toBe(one); + expect(text).not.toBe(one); + + const positiveZero = broker.register(generation, 0); + const negativeZero = broker.register(generation, -0); + expect(negativeZero).not.toBe(positiveZero); + expect(Object.is(broker.authorize(generation, positiveZero), 0)).toBe(true); + expect(Object.is(broker.authorize(generation, negativeZero), -0)).toBe(true); + + const firstNanBytes = new ArrayBuffer(8); + const firstNanView = new DataView(firstNanBytes); + firstNanView.setBigUint64(0, 0x7ff8_0000_0000_0001n, true); + const secondNanBytes = new ArrayBuffer(8); + const secondNanView = new DataView(secondNanBytes); + secondNanView.setBigUint64(0, 0x7ff8_0000_0000_0002n, true); + const firstNan = firstNanView.getFloat64(0, true); + const secondNan = secondNanView.getFloat64(0, true); + const firstNanHandle = broker.register(generation, firstNan); + const firstNanAlias = broker.register(generation, firstNan); + const secondNanHandle = broker.register(generation, secondNan); + expect(firstNanAlias).toBe(firstNanHandle); + expect(secondNanHandle).not.toBe(firstNanHandle); + const resultBits = (handle: number): bigint => { + const bytes = new ArrayBuffer(8); + const view = new DataView(bytes); + view.setFloat64( + 0, + broker.authorize(generation, handle) as number, + true, + ); + return view.getBigUint64(0, true); + }; + expect(resultBits(firstNanHandle)).toBe(0x7ff8_0000_0000_0001n); + expect(resultBits(secondNanHandle)).toBe(0x7ff8_0000_0000_0002n); + }); + + it("accepts every ordinary JavaScript externref shape", () => { + const broker = new ForkExternrefBroker(); + const generation = broker.createGeneration(32); + const values = [ + undefined, + null, + true, + 7, + 8n, + "opaque", + Symbol("opaque"), + () => 1, + { opaque: true }, + ]; + const handles = values.map((value) => broker.register(generation, value)); + expect(new Set(handles).size).toBe(values.length); + values.forEach((value, index) => { + expect(broker.authorize(generation, handles[index]!)).toBe(value); + }); + }); + + it("tombstones a replaced generation even when its PID is reused", () => { + const broker = new ForkExternrefBroker(); + const oldGeneration = broker.createGeneration(41); + const value = { image: "old" }; + const oldHandle = broker.register(oldGeneration, value); + + const replacement = broker.createGeneration(41); + expect(replacement.id).toBeGreaterThan(oldGeneration.id); + expect(() => broker.authorize(oldGeneration, oldHandle)).toThrow("stale"); + expect(broker.holderCount(oldHandle, oldGeneration)).toBe(0); + expect(() => broker.authorize(replacement, oldHandle)).toThrow("retired"); + + const replacementHandle = broker.register(replacement, value); + expect(replacementHandle).toBeGreaterThan(oldHandle); + expect(broker.authorize(replacement, replacementHandle)).toBe(value); + }); + + it("keeps independent fork leases without multiplying graph aliases", () => { + const broker = new ForkExternrefBroker(); + const parent = broker.createGeneration(51); + const child = broker.createGeneration(52); + const handle = broker.register(parent, { opaque: true }); + const first = broker.acquireFork(parent, child, [handle, handle]); + const second = broker.acquireFork(parent, child, [handle]); + + first.release(); + expect(broker.holderCount(handle, child)).toBe(1); + second.release(); + expect(broker.holderCount(handle, child)).toBe(0); + expect(broker.holderCount(handle, parent)).toBe(1); + }); + + it("verifies a complete fork lease before releasing any handle", () => { + const broker = new ForkExternrefBroker(); + const parent = broker.createGeneration(53); + const child = broker.createGeneration(54); + const first = broker.register(parent, "first"); + const second = broker.register(parent, "second"); + const lease = broker.acquireFork(parent, child, [first, second]); + const state = ( + broker as unknown as { + generations: WeakMap< + object, + { forkHandleCounts: Map } + >; + } + ).generations.get(child)!; + state.forkHandleCounts.delete(second); + + expect(() => lease.release()).toThrow("no longer owns fork lease"); + expect(broker.holderCount(first, child)).toBe(1); + expect(broker.holderCount(second, child)).toBe(1); + + state.forkHandleCounts.set(second, 1); + lease.release(); + expect(broker.holderCount(first, child)).toBe(0); + expect(broker.holderCount(second, child)).toBe(0); + }); + + it("does not let a fork lease release a direct generation lease", () => { + const broker = new ForkExternrefBroker(); + const parent = broker.createGeneration(61); + const child = broker.createGeneration(62); + const value = { opaque: true }; + const handle = broker.register(parent, value); + broker.acquire(child, handle); + const forkLease = broker.acquireFork(parent, child, [handle]); + + forkLease.release(); + expect(broker.authorize(child, handle)).toBe(value); + broker.release(child, handle); + expect(() => broker.authorize(child, handle)).toThrow("not authorized"); + }); + + it("permanently tombstones explicitly closed handles in every generation", () => { + const broker = new ForkExternrefBroker(); + const parent = broker.createGeneration(71); + const child = broker.createGeneration(72); + const value = { resource: "closed" }; + const handle = broker.register(parent, value); + const lease = broker.acquireFork(parent, child, [handle]); + + broker.tombstone(child, handle); + expect(() => broker.authorize(parent, handle)).toThrow("retired"); + expect(() => broker.authorize(child, handle)).toThrow("retired"); + lease.release(); + + const replacement = broker.register(parent, value); + expect(replacement).toBeGreaterThan(handle); + expect(broker.authorize(parent, replacement)).toBe(value); + }); + + it("never reuses wire handles and fails before overflowing u32", () => { + const broker = new ForkExternrefBroker({ maxHandle: 2 }); + const generation = broker.createGeneration(81); + const first = broker.register(generation, "first"); + const second = broker.register(generation, "second"); + expect([first, second]).toEqual([1, 2]); + broker.release(generation, first); + expect(() => broker.register(generation, "third")).toThrow( + "handle space exhausted", + ); + expect(() => broker.authorize(generation, first)).toThrow("retired"); + }); + + it("checks generation exhaustion before replacing a live generation", () => { + const broker = new ForkExternrefBroker({ maxGeneration: 1 }); + const generation = broker.createGeneration(91); + const handle = broker.register(generation, "still-owned"); + expect(() => broker.createGeneration(91)).toThrow( + "generation space exhausted", + ); + expect(broker.authorize(generation, handle)).toBe("still-owned"); + }); + + it("rejects a generation token issued by another broker", () => { + const firstBroker = new ForkExternrefBroker(); + const secondBroker = new ForkExternrefBroker(); + const foreign = firstBroker.createGeneration(101); + expect(() => secondBroker.register(foreign, "opaque")).toThrow( + "another broker", + ); + }); +}); + +describe("ForkExternrefTokenCache", () => { + it("reconstructs one worker-local identity per stable handle", () => { + const parent = new ForkExternrefTokenCache(11); + const child = new ForkExternrefTokenCache(12); + + const parentValue = parent.materialize(7); + const childValue = child.materialize(7); + expect(parent.materialize(7)).toBe(parentValue); + expect(child.materialize(7)).toBe(childValue); + expect(childValue).not.toBe(parentValue); + expect(parent.encode(parentValue)).toBe(7); + expect(child.encode(childValue)).toBe(7); + expect(parent.encode(childValue)).toBeNull(); + expect(child.encode(parentValue)).toBeNull(); + expect(child.encode({})).toBeNull(); + }); + + it("rejects handles that cannot round-trip through the u32 recipe contract", () => { + const cache = new ForkExternrefTokenCache(13); + expect(() => cache.materialize(0x1_0000_0000)).toThrow( + "invalid externref handle", + ); + }); + + it("rejects worker generation ids outside the u32 wire contract", () => { + expect(() => new ForkExternrefTokenCache(0)).toThrow( + "externref worker generation", + ); + expect(() => new ForkExternrefTokenCache(0x1_0000_0000)).toThrow( + "externref worker generation", + ); + }); +}); + +describe("ForkExternrefTokenRecipeProvider", () => { + it("round-trips owner handles through canonical worker tokens", () => { + const cache = new ForkExternrefTokenCache(14); + const provider = new ForkExternrefTokenRecipeProvider(cache); + const token = provider.materialize(41); + expect(provider.capture(token)).toBe(41); + expect(provider.materialize(41)).toBe(token); + }); + + it("adopts a raw Worker externref only when fork capture needs a child recipe", () => { + const cache = new ForkExternrefTokenCache(15); + const raw = Object.freeze({ workerLocal: true }); + const token = cache.materialize(43); + const normalized: unknown[] = []; + const provider = new ForkExternrefTokenRecipeProvider( + cache, + (value) => { + normalized.push(value); + return token; + }, + ); + + expect(provider.capture(raw)).toBe(43); + expect(normalized).toEqual([raw]); + expect(provider.materialize(43)).toBe(token); + }); + + it("detects a host import that bypassed process ownership", () => { + const provider = new ForkExternrefTokenRecipeProvider( + new ForkExternrefTokenCache(16), + ); + expect(() => provider.capture({ raw: true })).toThrow( + /without passing through the process reference owner/, + ); + }); +}); diff --git a/host/test/fork-reference-recipes.test.ts b/host/test/fork-reference-recipes.test.ts new file mode 100644 index 0000000000..064d5faf1c --- /dev/null +++ b/host/test/fork-reference-recipes.test.ts @@ -0,0 +1,719 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { ForkFunctionCatalog } from "../src/fork-function-catalog"; +import { ForkExternrefBroker } from "../src/fork-reference-broker"; +import { ForkStaticRootCatalog } from "../src/fork-static-root-catalog"; +import { + DEFAULT_FORK_REFERENCE_RECIPE_LIMITS, + FORK_REFERENCE_RECIPE_VERSION, + ForkReferenceRecipeCoordinator, + ForkReferenceTypeCatalog, + decodeForkReferenceRecipes, + encodeForkReferenceRecipes, + type ForkReferenceRecipeGraph, + type ForkReferenceReplayArena, + type ForkReferenceReplayTarget, +} from "../src/fork-reference-recipes"; + +interface MaterializedNode { + kind: "externref" | "exnref" | "i31" | "struct" | "array"; + coordinate?: string; + value?: number; + edges: unknown[]; +} + +class RecordingArena implements ForkReferenceReplayArena { + readonly staged = new Set(); + readonly externrefs = new Map(); + committedRoots: readonly unknown[] | undefined; + aborted = false; + failAt: "connect" | "commit" | undefined; + + materializeExternref(handle: number): unknown { + let value = this.externrefs.get(handle); + if (!value) { + value = { kind: "externref", value: handle, edges: [] }; + this.externrefs.set(handle, value); + this.staged.add(value); + } + return value; + } + + materializeI31(value: number): unknown { + return this.add({ kind: "i31", value, edges: [] }); + } + + allocateException( + moduleActivation: number, + tagOrdinal: number, + payloadCount: number, + ): unknown { + return this.add({ + kind: "exnref", + coordinate: `${moduleActivation}:${tagOrdinal}`, + edges: new Array(payloadCount), + }); + } + + allocateStruct( + moduleActivation: number, + typeOrdinal: number, + fieldCount: number, + ): unknown { + return this.add({ + kind: "struct", + coordinate: `${moduleActivation}:${typeOrdinal}`, + edges: new Array(fieldCount), + }); + } + + allocateArray( + moduleActivation: number, + typeOrdinal: number, + length: number, + ): unknown { + return this.add({ + kind: "array", + coordinate: `${moduleActivation}:${typeOrdinal}`, + edges: new Array(length), + }); + } + + setExceptionPayload(exception: unknown, index: number, value: unknown): void { + this.connect(exception, index, value); + } + + setStructField(struct: unknown, index: number, value: unknown): void { + this.connect(struct, index, value); + } + + setArrayElement(array: unknown, index: number, value: unknown): void { + this.connect(array, index, value); + } + + commit(roots: readonly unknown[]): void { + if (this.failAt === "commit") throw new Error("injected commit failure"); + this.committedRoots = [...roots]; + this.staged.clear(); + } + + abort(): void { + this.aborted = true; + this.committedRoots = undefined; + this.staged.clear(); + this.externrefs.clear(); + } + + private add(node: MaterializedNode): MaterializedNode { + this.staged.add(node); + return node; + } + + private connect(container: unknown, index: number, value: unknown): void { + if (this.failAt === "connect") throw new Error("injected connect failure"); + (container as MaterializedNode).edges[index] = value; + } +} + +class RecordingTarget implements ForkReferenceReplayTarget { + beginCount = 0; + readonly arenas: RecordingArena[] = []; + + constructor( + readonly functions: ForkFunctionCatalog, + readonly types: ForkReferenceTypeCatalog, + private readonly failAt?: "connect" | "commit", + readonly staticRoots?: ForkStaticRootCatalog, + ) {} + + beginReferenceReplay(_nodeCount: number): RecordingArena { + this.beginCount++; + const arena = new RecordingArena(); + arena.failAt = this.failAt; + this.arenas.push(arena); + return arena; + } +} + +function emptyFunctions(): ForkFunctionCatalog { + return new ForkFunctionCatalog(); +} + +function referenceTypes(): ForkReferenceTypeCatalog { + const types = new ForkReferenceTypeCatalog(); + types.register(7, { + tags: [{ ordinal: 5, payloadCount: 2 }], + structs: [{ ordinal: 2, fieldCount: 3 }], + arrays: [{ ordinal: 3 }], + }); + return types; +} + +function graphWithEveryKind(handle: number): ForkReferenceRecipeGraph { + return { + roots: [100, 100, 60, 80, 90, 30], + nodes: [ + { + id: 100, + node: { + kind: "struct", + moduleActivation: 7, + typeOrdinal: 2, + layoutId: 12, + scalars: Uint8Array.of(0x78, 0x56, 0x34, 0x12), + fields: [20, 30, 60], + }, + }, + { + id: 20, + node: { + kind: "array", + moduleActivation: 7, + typeOrdinal: 3, + layoutId: 13, + scalars: Uint8Array.of(0xaa, 0xbb), + elements: [100, 60], + }, + }, + { id: 30, node: { kind: "externref", handle } }, + { + id: 60, + node: { + kind: "exnref", + moduleActivation: 7, + tagOrdinal: 5, + layoutId: 15, + scalars: Uint8Array.of(0, 1, 2, 3, 4, 5, 6, 7), + payloads: [100, 70], + }, + }, + { id: 70, node: { kind: "i31", value: -17 } }, + { + id: 80, + node: { + kind: "funcref", + moduleActivation: 7, + functionOrdinal: 0, + }, + }, + { id: 90, node: { kind: "null" } }, + ], + }; +} + +function catalogModule(): WebAssembly.Module { + const dir = mkdtempSync(join(tmpdir(), "kandelo-reference-recipes-")); + const wat = join(dir, "catalog.wat"); + const wasm = join(dir, "catalog.wasm"); + writeFileSync(wat, `(module + (table $catalog (export "__wpk_fork_function_catalog") 1 1 funcref) + (func $value (result i32) i32.const 43) + (elem (table $catalog) (i32.const 0) func $value) + )`); + execFileSync("wat2wasm", [wat, "-o", wasm]); + return new WebAssembly.Module(readFileSync(wasm)); +} + +function freshFunctionCatalogs(): { + source: ForkFunctionCatalog; + target: ForkFunctionCatalog; + sourceFunction: CallableFunction; + targetFunction: CallableFunction; +} { + const module = catalogModule(); + const sourceTable = new WebAssembly.Instance(module).exports + .__wpk_fork_function_catalog as WebAssembly.Table; + const targetTable = new WebAssembly.Instance(module).exports + .__wpk_fork_function_catalog as WebAssembly.Table; + const source = new ForkFunctionCatalog(); + const target = new ForkFunctionCatalog(); + source.register(7, sourceTable); + target.register(7, targetTable); + return { + source, + target, + sourceFunction: sourceTable.get(0) as CallableFunction, + targetFunction: targetTable.get(0) as CallableFunction, + }; +} + +describe("fork reference recipe wire codec", () => { + it("uses wire-format bounds rather than arbitrary production quotas", () => { + expect(DEFAULT_FORK_REFERENCE_RECIPE_LIMITS).toEqual({ + maxWireBytes: 0xffff_ffff, + maxNodes: 0xffff_ffff, + maxRoots: 0xffff_ffff, + maxEdges: 0xffff_ffff, + }); + }); + + it("canonicalizes input IDs and preserves cycles, aliases, and every kind", () => { + const graph = graphWithEveryKind(9); + const reversed: ForkReferenceRecipeGraph = { + roots: graph.roots, + nodes: [...graph.nodes].reverse(), + }; + + const first = encodeForkReferenceRecipes(graph); + const second = encodeForkReferenceRecipes(reversed); + expect(second).toEqual(first); + expect(new DataView(first.buffer).getUint16(4, true)).toBe( + FORK_REFERENCE_RECIPE_VERSION, + ); + + const decoded = decodeForkReferenceRecipes( + new Uint8Array(first.buffer, first.byteOffset, first.byteLength), + ); + expect(decoded.nodes.map(({ node }) => node.kind)).toEqual([ + "array", + "externref", + "exnref", + "i31", + "funcref", + "null", + "struct", + ]); + const array = decoded.nodes[0]!.node; + const exception = decoded.nodes[2]!.node; + const struct = decoded.nodes[6]!.node; + expect(array.kind === "array" && array.elements[0]).toBe(6); + expect(array.kind === "array" && array.scalars).toEqual( + Uint8Array.of(0xaa, 0xbb), + ); + expect(exception.kind === "exnref" && exception.layoutId).toBe(15); + expect(exception.kind === "exnref" && exception.scalars).toEqual( + Uint8Array.of(0, 1, 2, 3, 4, 5, 6, 7), + ); + expect(struct.kind === "struct" && struct.fields).toEqual([0, 1, 2]); + expect(struct.kind === "struct" && struct.layoutId).toBe(12); + expect(decoded.roots[0]).toBe(decoded.roots[1]); + expect(encodeForkReferenceRecipes(decoded)).toEqual(first); + }); + + it("rejects malformed versions, kinds, reserved fields, edges, and reachability", () => { + const scalar = encodeForkReferenceRecipes({ + roots: [0], + nodes: [{ id: 0, node: { kind: "null" } }], + }); + const mutate = (offset: number, value: number, width: 1 | 2 | 4): Uint8Array => { + const bytes = scalar.slice(); + const view = new DataView(bytes.buffer); + if (width === 1) view.setUint8(offset, value); + else if (width === 2) view.setUint16(offset, value, true); + else view.setUint32(offset, value, true); + return bytes; + }; + expect(() => decodeForkReferenceRecipes(mutate(4, 99, 2))).toThrow( + "unsupported reference recipe version", + ); + expect(() => decodeForkReferenceRecipes(mutate(40, 99, 1))).toThrow( + "unknown kind", + ); + expect(() => decodeForkReferenceRecipes(mutate(41, 1, 1))).toThrow( + "nonzero flags", + ); + expect(() => decodeForkReferenceRecipes(mutate(56, 1, 4))).toThrow( + "edge range exceeds", + ); + expect(() => decodeForkReferenceRecipes(mutate(32, 1, 4))).toThrow( + "reserved header", + ); + + const aggregate = encodeForkReferenceRecipes({ + roots: [0], + nodes: [ + { + id: 0, + node: { + kind: "array", + moduleActivation: 1, + typeOrdinal: 0, + elements: [1], + }, + }, + { id: 1, node: { kind: "null" } }, + ], + }); + const badEdge = aggregate.slice(); + const edgeOffset = 40 + 2 * 32 + 4; + new DataView(badEdge.buffer).setUint32(edgeOffset, 2, true); + expect(() => decodeForkReferenceRecipes(badEdge)).toThrow( + "targets missing node", + ); + + const reachable = encodeForkReferenceRecipes({ + roots: [0, 1], + nodes: [ + { id: 0, node: { kind: "null" } }, + { id: 1, node: { kind: "i31", value: 1 } }, + ], + }); + const unreachable = reachable.slice(); + const rootOffset = 40 + 2 * 32; + new DataView(unreachable.buffer).setUint32(rootOffset + 4, 0, true); + expect(() => decodeForkReferenceRecipes(unreachable)).toThrow( + "unreachable from every root", + ); + }); + + it("enforces bounded, exact layouts and i31/handle domains", () => { + expect(() => + encodeForkReferenceRecipes( + { + roots: [0], + nodes: [{ id: 0, node: { kind: "i31", value: 0x4000_0000 } }], + }, + ) + ).toThrow("invalid i31"); + expect(() => + encodeForkReferenceRecipes({ + roots: [0], + nodes: [{ id: 0, node: { kind: "externref", handle: 0 } }], + }) + ).toThrow("positive unsigned 32-bit integer"); + expect(() => + encodeForkReferenceRecipes({ + roots: [0], + nodes: [{ + id: 0, + node: { kind: "externref", handle: 0x1_0000_0000 }, + }], + }) + ).toThrow("positive unsigned 32-bit integer"); + const externref = encodeForkReferenceRecipes({ + roots: [0], + nodes: [{ id: 0, node: { kind: "externref", handle: 1 } }], + }); + const nonU32Externref = externref.slice(); + new DataView(nonU32Externref.buffer).setUint32(48, 1, true); + expect(() => decodeForkReferenceRecipes(nonU32Externref)).toThrow( + "positive unsigned 32-bit integer", + ); + + const i31 = encodeForkReferenceRecipes({ + roots: [0, 1], + nodes: [ + { id: 0, node: { kind: "i31", value: -0x4000_0000 } }, + { id: 1, node: { kind: "i31", value: 0x3fff_ffff } }, + ], + }); + expect( + decodeForkReferenceRecipes(i31).nodes.map(({ node }) => + node.kind === "i31" ? node.value : undefined + ), + ).toEqual([-0x4000_0000, 0x3fff_ffff]); + const noncanonicalI31 = i31.slice(); + new DataView(noncanonicalI31.buffer).setUint32(44, 0x4000_0000, true); + expect(() => decodeForkReferenceRecipes(noncanonicalI31)).toThrow( + "invalid i31", + ); + + const bytes = encodeForkReferenceRecipes({ + roots: [], + nodes: [], + }); + const trailing = new Uint8Array(bytes.length + 1); + trailing.set(bytes); + new DataView(trailing.buffer).setUint32(8, trailing.length, true); + expect(() => decodeForkReferenceRecipes(trailing)).toThrow( + "layout needs", + ); + expect(() => + decodeForkReferenceRecipes(bytes, { + maxWireBytes: 40, + maxNodes: 0, + maxRoots: 0, + maxEdges: 0, + }) + ).not.toThrow(); + expect(() => + encodeForkReferenceRecipes( + { roots: [0], nodes: [{ id: 0, node: { kind: "null" } }] }, + { + maxWireBytes: 40, + maxNodes: 1, + maxRoots: 1, + maxEdges: 0, + }, + ) + ).toThrow("needs"); + }); +}); + +describe("ForkReferenceRecipeCoordinator", () => { + it("resolves static-root recipes against the fresh child activation", () => { + const sourceValue = Object.freeze({ instance: "source" }); + const targetValue = Object.freeze({ instance: "target" }); + const sourceTable = new WebAssembly.Table({ + element: "externref", + initial: 1, + maximum: 1, + }); + const targetTable = new WebAssembly.Table({ + element: "externref", + initial: 1, + maximum: 1, + }); + sourceTable.set(0, sourceValue); + targetTable.set(0, targetValue); + const sourceRoots = new ForkStaticRootCatalog(); + const targetRoots = new ForkStaticRootCatalog(); + sourceRoots.register(6, sourceTable); + targetRoots.register(6, targetTable); + const target = new RecordingTarget( + emptyFunctions(), + new ForkReferenceTypeCatalog(), + undefined, + targetRoots, + ); + const broker = new ForkExternrefBroker(); + const parentGeneration = broker.createGeneration(80); + const childGeneration = broker.createGeneration(81); + const coordinator = new ForkReferenceRecipeCoordinator( + emptyFunctions(), + new ForkReferenceTypeCatalog(), + broker, + DEFAULT_FORK_REFERENCE_RECIPE_LIMITS, + sourceRoots, + ); + const ownership = coordinator.replay({ + parentGeneration, + childGeneration, + wire: encodeForkReferenceRecipes({ + roots: [0], + nodes: [{ + id: 0, + node: { + kind: "static-root", + moduleActivation: 6, + staticRootOrdinal: 0, + }, + }], + }), + target, + }); + expect(target.arenas[0]!.committedRoots).toEqual([targetValue]); + expect(target.arenas[0]!.committedRoots![0]).not.toBe(sourceValue); + ownership.release(); + }); + + it("reconstructs fresh-instance identities and cyclic typed graphs transactionally", () => { + const functions = freshFunctionCatalogs(); + expect(functions.targetFunction).not.toBe(functions.sourceFunction); + const sourceTypes = referenceTypes(); + const targetTypes = referenceTypes(); + const broker = new ForkExternrefBroker(); + const parentGeneration = broker.createGeneration(41); + const childGeneration = broker.createGeneration(42); + const opaque = { owned: "outside workers" }; + const handle = broker.register(parentGeneration, opaque); + expect(broker.register(parentGeneration, opaque)).toBe(handle); + + const target = new RecordingTarget(functions.target, targetTypes); + const coordinator = new ForkReferenceRecipeCoordinator( + functions.source, + sourceTypes, + broker, + ); + const ownership = coordinator.replay({ + parentGeneration, + childGeneration, + wire: encodeForkReferenceRecipes(graphWithEveryKind(handle)), + target, + }); + + const roots = target.arenas[0]!.committedRoots!; + const struct = roots[0] as MaterializedNode; + const exception = roots[2] as MaterializedNode; + expect(roots[1]).toBe(struct); + expect((struct.edges[0] as MaterializedNode).edges[0]).toBe(struct); + expect(struct.edges[2]).toBe(exception); + expect(exception.edges[0]).toBe(struct); + expect((exception.edges[1] as MaterializedNode).kind).toBe("i31"); + expect(roots[3]).toBe(functions.targetFunction); + expect(roots[4]).toBeNull(); + expect(roots[5]).toBe(struct.edges[1]); + expect(broker.holderCount(handle, childGeneration)).toBe(1); + expect(target.arenas[0]!.staged.size).toBe(0); + + ownership.release(); + expect(broker.holderCount(handle, childGeneration)).toBe(0); + expect(() => ownership.release()).toThrow("already released"); + }); + + it.each(["connect", "commit"] as const)( + "aborts the arena and releases every acquired handle after a %s failure", + (failAt) => { + const broker = new ForkExternrefBroker(); + const parentGeneration = broker.createGeneration(51); + const childGeneration = broker.createGeneration(52); + const handle = broker.register(parentGeneration, { opaque: true }); + const sourceTypes = new ForkReferenceTypeCatalog(); + const targetTypes = new ForkReferenceTypeCatalog(); + sourceTypes.register(0, { arrays: [{ ordinal: 0 }] }); + targetTypes.register(0, { arrays: [{ ordinal: 0 }] }); + const target = new RecordingTarget( + emptyFunctions(), + targetTypes, + failAt, + ); + const coordinator = new ForkReferenceRecipeCoordinator( + emptyFunctions(), + sourceTypes, + broker, + ); + const wire = encodeForkReferenceRecipes({ + roots: [0], + nodes: [ + { + id: 0, + node: { + kind: "array", + moduleActivation: 0, + typeOrdinal: 0, + elements: [1], + }, + }, + { id: 1, node: { kind: "externref", handle } }, + ], + }); + + expect(() => + coordinator.replay({ + parentGeneration, + childGeneration, + wire, + target, + }) + ).toThrow(`injected ${failAt} failure`); + expect(target.arenas[0]!.aborted).toBe(true); + expect(target.arenas[0]!.staged.size).toBe(0); + expect(target.arenas[0]!.externrefs.size).toBe(0); + expect(broker.holderCount(handle, childGeneration)).toBe(0); + expect(broker.resolve(parentGeneration, handle)).toEqual({ opaque: true }); + }, + ); + + it("validates source and target coordinate ownership before acquisition", () => { + const broker = new ForkExternrefBroker(); + const parentGeneration = broker.createGeneration(61); + const childGeneration = broker.createGeneration(62); + const handle = broker.register(parentGeneration, "opaque"); + const sourceTypes = new ForkReferenceTypeCatalog(); + const targetTypes = new ForkReferenceTypeCatalog(); + sourceTypes.register(1, { structs: [{ ordinal: 2, fieldCount: 1 }] }); + targetTypes.register(1, { structs: [{ ordinal: 9, fieldCount: 1 }] }); + const target = new RecordingTarget(emptyFunctions(), targetTypes); + const coordinator = new ForkReferenceRecipeCoordinator( + emptyFunctions(), + sourceTypes, + broker, + ); + const wire = encodeForkReferenceRecipes({ + roots: [0], + nodes: [ + { + id: 0, + node: { + kind: "struct", + moduleActivation: 1, + typeOrdinal: 2, + fields: [1], + }, + }, + { id: 1, node: { kind: "externref", handle } }, + ], + }); + + expect(() => + coordinator.replay({ + parentGeneration, + childGeneration, + wire, + target, + }) + ).toThrow("target catalog rejected"); + expect(target.beginCount).toBe(0); + expect(broker.holderCount(handle, childGeneration)).toBe(0); + }); + + it("acquires one host lease across every aliased occurrence in the graph", () => { + const broker = new ForkExternrefBroker(); + const parentGeneration = broker.createGeneration(66); + const childGeneration = broker.createGeneration(67); + const handle = broker.register(parentGeneration, { opaque: true }); + const target = new RecordingTarget( + emptyFunctions(), + new ForkReferenceTypeCatalog(), + ); + const coordinator = new ForkReferenceRecipeCoordinator( + emptyFunctions(), + new ForkReferenceTypeCatalog(), + broker, + ); + + const ownership = coordinator.replay({ + parentGeneration, + childGeneration, + wire: encodeForkReferenceRecipes({ + roots: [0, 0], + nodes: [{ id: 0, node: { kind: "externref", handle } }], + }), + target, + }); + expect(target.arenas[0]!.committedRoots![0]).toBe( + target.arenas[0]!.committedRoots![1], + ); + expect(broker.holderCount(handle, childGeneration)).toBe(1); + ownership.release(); + expect(broker.holderCount(handle, childGeneration)).toBe(0); + }); + + it("releases all replay leases when the child generation is retired", () => { + const broker = new ForkExternrefBroker(); + const parentGeneration = broker.createGeneration(71); + const childGeneration = broker.createGeneration(73); + const first = broker.register(parentGeneration, "first"); + const second = broker.register(parentGeneration, "second"); + + const coordinator = new ForkReferenceRecipeCoordinator( + emptyFunctions(), + new ForkReferenceTypeCatalog(), + broker, + ); + const replayOwnership = coordinator.replay({ + parentGeneration, + childGeneration, + wire: encodeForkReferenceRecipes({ + roots: [0, 1], + nodes: [ + { id: 0, node: { kind: "externref", handle: first } }, + { id: 1, node: { kind: "externref", handle: second } }, + ], + }), + target: new RecordingTarget( + emptyFunctions(), + new ForkReferenceTypeCatalog(), + ), + }); + expect(broker.releaseGeneration(childGeneration)).toBe(true); + expect(broker.holderCount(first, childGeneration)).toBe(0); + expect(broker.holderCount(second, childGeneration)).toBe(0); + expect(() => replayOwnership.release()).toThrow("already released"); + }); +}); + +describe("ForkReferenceTypeCatalog", () => { + it("binds tag and aggregate arity to one module activation", () => { + const types = referenceTypes(); + expect(() => types.validateTag(7, 5, 2)).not.toThrow(); + expect(() => types.validateTag(7, 5, 1)).toThrow("expects 2"); + expect(() => types.validateStruct(7, 2, 2)).toThrow("expects 3"); + expect(() => types.validateArray(7, 3)).not.toThrow(); + expect(() => types.validateArray(8, 3)).toThrow("not registered"); + expect(() => types.register(7, {})).toThrow("already registered"); + }); +}); diff --git a/host/test/fork-reference-segments.test.ts b/host/test/fork-reference-segments.test.ts new file mode 100644 index 0000000000..4b841d0c0c --- /dev/null +++ b/host/test/fork-reference-segments.test.ts @@ -0,0 +1,359 @@ +import { describe, expect, it } from "vitest"; +import { + ForkModuleStateRecordKind, + type ForkModuleStateRecord, +} from "../src/fork-module-state"; +import type { + ForkReferenceRecipeEntry, +} from "../src/fork-reference-recipes"; +import { + advanceForkReferenceLogicalOffset, + decodeSegmentedForkReferenceTransaction, + encodeSegmentedForkReferenceRecords, + findForkReferenceVectorOrdinal, + forkReferenceVectorFrom, + ForkReferenceDirectoryOverlay, + PagedForkReferenceVector, + scanSegmentedForkReferenceExternrefHandles, +} from "../src/fork-reference-segments"; +import { + FORK_REFERENCE_TRANSACTION_OWNER_ID, +} from "../src/fork-reference-transaction"; + +function graph(): ForkReferenceRecipeEntry[] { + return [ + { id: 0, node: { kind: "null" } }, + { id: 1, node: { kind: "externref", handle: 17 } }, + { + id: 2, + node: { + kind: "struct", + moduleActivation: 4, + typeOrdinal: 9, + layoutId: 12, + scalars: Uint8Array.of(1, 2, 3, 4, 5, 6, 7, 8, 9), + fields: [1, 2], + }, + }, + { + id: 3, + node: { + kind: "exnref", + moduleActivation: 4, + tagOrdinal: 3, + layoutId: 8, + scalars: Uint8Array.of(0xaa, 0xbb, 0xcc), + payloads: [2, 1], + }, + }, + { + id: 4, + node: { + kind: "funcref", + moduleActivation: 4, + functionOrdinal: 21, + }, + }, + { id: 5, node: { kind: "i31", value: -37 } }, + { + id: 6, + node: { + kind: "static-root", + moduleActivation: 4, + staticRootOrdinal: 5, + }, + }, + ]; +} + +function vectors(): [ + PagedForkReferenceVector, + PagedForkReferenceVector, + PagedForkReferenceVector, +] { + return [ + PagedForkReferenceVector.empty, + forkReferenceVectorFrom([1, 2, 0, 3], 4), + forkReferenceVectorFrom([6, 4, 5], 3), + ]; +} + +function records(segmentDataBytes = 7): ForkModuleStateRecord[] { + return encodeSegmentedForkReferenceRecords( + FORK_REFERENCE_TRANSACTION_OWNER_ID, + graph(), + vectors(), + { segmentDataBytes }, + ); +} + +function cloneRecords( + source: readonly ForkModuleStateRecord[], +): ForkModuleStateRecord[] { + return source.map((record) => ({ + ...record, + payload: record.payload.slice(), + })); +} + +function segmentRecords( + source: readonly ForkModuleStateRecord[], +): ForkModuleStateRecord[] { + return source.filter( + ({ kind }) => kind === ForkModuleStateRecordKind.ReferenceRecipeSegment, + ); +} + +function manifestRecord( + source: readonly ForkModuleStateRecord[], +): ForkModuleStateRecord { + return source.find( + ({ kind }) => kind === ForkModuleStateRecordKind.ReferenceRecipe, + )!; +} + +function sectionSegment( + source: readonly ForkModuleStateRecord[], + section: number, +): ForkModuleStateRecord { + return segmentRecords(source).find((record) => + new DataView( + record.payload.buffer, + record.payload.byteOffset, + record.payload.byteLength, + ).getUint16(8, true) === section + )!; +} + +describe("segmented KFRV v2", () => { + it("decodes fields split across many records without concatenating them", () => { + const encoded = records(); + expect(segmentRecords(encoded).length).toBeGreaterThan(40); + + const decoded = decodeSegmentedForkReferenceTransaction( + encoded, + FORK_REFERENCE_TRANSACTION_OWNER_ID, + ); + expect(decoded.graph.roots).toEqual([]); + expect(decoded.graph.nodes.length).toBe(7); + expect(decoded.graph.nodes.get(1)?.node).toEqual({ + kind: "externref", + handle: 17, + }); + expect(decoded.graph.nodes.get(2)?.node).toEqual({ + kind: "struct", + moduleActivation: 4, + typeOrdinal: 9, + layoutId: 12, + scalars: Uint8Array.of(1, 2, 3, 4, 5, 6, 7, 8, 9), + fields: [1, 2], + }); + expect(decoded.graph.nodes.get(3)?.node).toEqual({ + kind: "exnref", + moduleActivation: 4, + tagOrdinal: 3, + layoutId: 8, + scalars: Uint8Array.of(0xaa, 0xbb, 0xcc), + payloads: [2, 1], + }); + expect([...decoded.vectors.get(1)!]).toEqual([1, 2, 0, 3]); + expect([...decoded.vectors.get(2)!]).toEqual([6, 4, 5]); + expect(scanSegmentedForkReferenceExternrefHandles( + encoded, + FORK_REFERENCE_TRANSACTION_OWNER_ID, + )).toEqual(new Set([17])); + }); + + it("preserves deep cycles and paged vectors across hundreds of segments", () => { + const nodeCount = 2_000; + const nodes: ForkReferenceRecipeEntry[] = [ + { id: 0, node: { kind: "null" } }, + ]; + for (let id = 1; id < nodeCount; id++) { + nodes.push({ + id, + node: { + kind: "struct", + moduleActivation: 1, + typeOrdinal: 0, + fields: [id + 1 === nodeCount ? 1 : id + 1, 1], + }, + }); + } + const vectorValues = Array.from( + { length: 10_000 }, + (_, index) => index % nodeCount, + ); + const encoded = encodeSegmentedForkReferenceRecords( + FORK_REFERENCE_TRANSACTION_OWNER_ID, + nodes, + [ + PagedForkReferenceVector.empty, + forkReferenceVectorFrom(vectorValues, vectorValues.length), + ], + { segmentDataBytes: 127 }, + ); + expect(segmentRecords(encoded).length).toBeGreaterThan(800); + + const decoded = decodeSegmentedForkReferenceTransaction( + encoded, + FORK_REFERENCE_TRANSACTION_OWNER_ID, + ); + expect(decoded.graph.nodes.length).toBe(nodeCount); + expect(decoded.graph.nodes.get(nodeCount - 1)?.node).toMatchObject({ + kind: "struct", + fields: [1, 1], + }); + const vector = decoded.vectors.get(1)!; + expect(vector.length).toBe(10_000); + expect(vector.get(0)).toBe(0); + expect(vector.get(4_096)).toBe(96); + expect(vector.get(9_999)).toBe(1_999); + }); + + it("shares decoded vectors through an append-only replay overlay", () => { + const decoded = decodeSegmentedForkReferenceTransaction( + records(), + FORK_REFERENCE_TRANSACTION_OWNER_ID, + ); + const overlay = new ForkReferenceDirectoryOverlay(decoded.vectors); + expect(overlay.get(1)).toBe(decoded.vectors.get(1)); + expect(findForkReferenceVectorOrdinal( + [decoded.vectorIntern], + overlay, + forkReferenceVectorFrom([1, 2, 0, 3]), + )).toBe(1); + + const appended = forkReferenceVectorFrom([5, 4], 2); + const ordinal = overlay.length; + overlay.push(appended); + expect(overlay.get(ordinal)).toBe(appended); + expect(decoded.vectors.length).toBe(3); + }); + + it.each([ + { + name: "a missing segment", + mutate(source: ForkModuleStateRecord[]) { + source.splice(source.indexOf(segmentRecords(source)[2]!), 1); + }, + message: "ordinal", + }, + { + name: "a duplicate or reordered ordinal", + mutate(source: ForkModuleStateRecord[]) { + const segments = segmentRecords(source); + const firstIndex = source.indexOf(segments[0]!); + const secondIndex = source.indexOf(segments[1]!); + [source[firstIndex], source[secondIndex]] = [ + source[secondIndex]!, + source[firstIndex]!, + ]; + }, + message: "ordinal", + }, + { + name: "a gap", + mutate(source: ForkModuleStateRecord[]) { + const segment = segmentRecords(source)[1]!; + const view = new DataView(segment.payload.buffer); + view.setBigUint64(24, view.getBigUint64(24, true) + 1n, true); + }, + message: "gap, overlap, or duplicate", + }, + { + name: "an overlap", + mutate(source: ForkModuleStateRecord[]) { + const segment = segmentRecords(source)[1]!; + const view = new DataView(segment.payload.buffer); + view.setBigUint64(24, view.getBigUint64(24, true) - 1n, true); + }, + message: "gap, overlap, or duplicate", + }, + { + name: "trailing segment bytes", + mutate(source: ForkModuleStateRecord[]) { + const segment = segmentRecords(source)[0]!; + const payload = new Uint8Array(segment.payload.byteLength + 1); + payload.set(segment.payload); + segment.payload = payload; + }, + message: "invalid data length", + }, + { + name: "a segment after the manifest", + mutate(source: ForkModuleStateRecord[]) { + const manifest = manifestRecord(source); + const segment = source.splice( + source.indexOf(manifest) - 1, + 1, + )[0]!; + source.push(segment); + }, + message: "follows its final manifest", + }, + ])("rejects $name before materialization", ({ mutate, message }) => { + const malformed = cloneRecords(records()); + mutate(malformed); + expect(() => decodeSegmentedForkReferenceTransaction( + malformed, + FORK_REFERENCE_TRANSACTION_OWNER_ID, + )).toThrow(message); + }); + + it("rejects invalid semantic edges and noncanonical vector indexes", () => { + const badEdge = cloneRecords(records(64)); + const edge = sectionSegment(badEdge, 2); + new DataView( + edge.payload.buffer, + edge.payload.byteOffset, + edge.payload.byteLength, + ).setUint32(40, 0xffff_ffff, true); + expect(() => decodeSegmentedForkReferenceTransaction( + badEdge, + FORK_REFERENCE_TRANSACTION_OWNER_ID, + )).toThrow("missing recipe"); + + const badIndex = cloneRecords(records(64)); + const index = sectionSegment(badIndex, 4); + new DataView( + index.payload.buffer, + index.payload.byteOffset, + index.payload.byteLength, + ).setBigUint64(40 + 16, 99n, true); + expect(() => decodeSegmentedForkReferenceTransaction( + badIndex, + FORK_REFERENCE_TRANSACTION_OWNER_ID, + )).toThrow("expected"); + }); + + it("rejects wrong ownership and a duplicate canonical vector", () => { + const wrongOwner = cloneRecords(records()); + wrongOwner[0]!.ownerId++; + expect(() => decodeSegmentedForkReferenceTransaction( + wrongOwner, + FORK_REFERENCE_TRANSACTION_OWNER_ID, + )).toThrow("invalid process ownership"); + + expect(() => encodeSegmentedForkReferenceRecords( + FORK_REFERENCE_TRANSACTION_OWNER_ID, + graph(), + [ + PagedForkReferenceVector.empty, + forkReferenceVectorFrom([1, 2], 2), + forkReferenceVectorFrom([1, 2], 2), + ], + )).toThrow("duplicates canonical vector"); + }); + + it("uses u64 logical offsets without a 4-GiB allocation", () => { + expect(advanceForkReferenceLogicalOffset( + 0xffff_fff0n, + 0x40, + )).toBe(0x1_0000_0030n); + expect(() => advanceForkReferenceLogicalOffset( + 0xffff_ffff_ffff_fff0n, + 0x40, + )).toThrow("exceeds u64"); + }); +}); diff --git a/host/test/fork-reference-transaction.test.ts b/host/test/fork-reference-transaction.test.ts new file mode 100644 index 0000000000..3267ab020b --- /dev/null +++ b/host/test/fork-reference-transaction.test.ts @@ -0,0 +1,936 @@ +import { describe, expect, it } from "vitest"; +import { ForkFunctionCatalog } from "../src/fork-function-catalog"; +import { + ForkReferenceTransaction, + type ForkExternrefRecipeProvider, +} from "../src/fork-reference-transaction"; +import { + ForkModuleStateArena, + ForkModuleStateRecordKind, + type ForkModuleStateRecord, +} from "../src/fork-module-state"; +import { ForkStaticRootCatalog } from "../src/fork-static-root-catalog"; +import { + FORK_GC_FIELD_ALLOCATION_DEPENDENCY, + FORK_GC_FIELD_MUTABLE, + FORK_GC_FIELD_NULLABLE, + FORK_GC_FIELD_REFERENCE, + FORK_GC_LAYOUT_DEFAULTABLE_SHELL, + FORK_GC_LAYOUT_REQUIRES_PROVENANCE, + ForkGcCodecDescriptor, + ForkGcConstructorKind, + ForkGcLayoutKind, + type ForkGcCodecProvider, + type ForkGcLayoutDescriptor, +} from "../src/fork-gc-codec"; + +function makeFunctionCatalog( + moduleActivation: number, + functions: readonly CallableFunction[], +): ForkFunctionCatalog { + const table = new WebAssembly.Table({ + element: "anyfunc", + initial: functions.length, + maximum: functions.length, + }); + functions.forEach((fn, index) => table.set(index, fn)); + const catalog = new ForkFunctionCatalog(); + catalog.register(moduleActivation, table); + return catalog; +} + +function makeExternrefs(): { + provider: ForkExternrefRecipeProvider; + values: Map; +} { + let next = 1; + const values = new Map(); + const handles = new WeakMap(); + const provider: ForkExternrefRecipeProvider = { + capture(value) { + if ((typeof value === "object" && value !== null) || typeof value === "function") { + const known = handles.get(value as object); + if (known) return known; + const handle = next++; + handles.set(value as object, handle); + values.set(handle, value); + return handle; + } + const handle = next++; + values.set(handle, value); + return handle; + }, + materialize(handle) { + if (!values.has(handle)) throw new Error(`missing handle ${handle}`); + return values.get(handle); + }, + }; + return { provider, values }; +} + +function withArena( + run: (arena: ForkModuleStateArena) => void, +): ForkModuleStateRecord[] { + const memory = new WebAssembly.Memory({ initial: 16 }); + let next = 0x1_0000; + const arena = new ForkModuleStateArena( + memory, + 4, + (size) => { + const addr = next; + next += Number(size); + return addr; + }, + () => {}, + "reference transaction test", + ); + arena.begin(); + arena.appendModule({ + activationId: 0, + templateId: new Uint8Array(32), + }); + run(arena); + arena.seal(); + return arena.records(); +} + +describe("ForkReferenceTransaction", () => { + it("returns original identities in the parent and fresh catalog identities in the child", () => { + const parentFunction = new WebAssembly.Instance( + new WebAssembly.Module( + Uint8Array.from([ + 0, 97, 115, 109, 1, 0, 0, 0, + 1, 4, 1, 96, 0, 0, + 3, 2, 1, 0, + 7, 5, 1, 1, 102, 0, 0, + 10, 4, 1, 2, 0, 11, + ]), + ), + ).exports.f as CallableFunction; + const childFunction = new WebAssembly.Instance( + new WebAssembly.Module( + Uint8Array.from([ + 0, 97, 115, 109, 1, 0, 0, 0, + 1, 4, 1, 96, 0, 0, + 3, 2, 1, 0, + 7, 5, 1, 1, 102, 0, 0, + 10, 4, 1, 2, 0, 11, + ]), + ), + ).exports.f as CallableFunction; + const extern = Object.freeze({ owner: "process" }); + const parentExternrefs = makeExternrefs(); + const parent = new ForkReferenceTransaction( + makeFunctionCatalog(0, [parentFunction]), + parentExternrefs.provider, + ); + parent.beginCapture(); + const functionId = parent.encodeFuncref(parentFunction); + const functionAsExternId = parent.encodeExternref(parentFunction); + const externId = parent.encodeExternref(extern); + expect(functionAsExternId).toBe(functionId); + + const records = withArena((arena) => parent.sealInto(arena)); + parent.beginParentReplay(); + expect(parent.decodeFuncref(functionId)).toBe(parentFunction); + expect(parent.decodeExternref(functionAsExternId)).toBe(parentFunction); + expect(parent.decodeExternref(externId)).toBe(extern); + parent.finishReplay(); + + const childTokens = new Map(); + const child = new ForkReferenceTransaction( + makeFunctionCatalog(0, [childFunction]), + { + capture() { + throw new Error("child must not capture parent externrefs"); + }, + materialize(handle) { + let token = childTokens.get(handle); + if (!token) { + token = Object.freeze({ handle }); + childTokens.set(handle, token); + } + return token; + }, + }, + ); + child.attachChild(records); + expect(child.decodeFuncref(functionId)).toBe(childFunction); + expect(child.decodeFuncref(functionId)).not.toBe(parentFunction); + expect(child.decodeExternref(functionAsExternId)).toBe(childFunction); + expect(child.decodeExternref(externId)).toBe(child.decodeExternref(externId)); + expect(child.decodeExternref(externId)).not.toBe(extern); + child.finishReplay(); + }); + + it("deduplicates aliases across typed slots and reserves zero for null", () => { + const externrefs = makeExternrefs(); + const transaction = new ForkReferenceTransaction( + makeFunctionCatalog(0, []), + externrefs.provider, + ); + const shared = { value: 1 }; + transaction.beginCapture(); + expect(transaction.encodeExternref(null)).toBe(0); + expect(transaction.encodeExternref(shared)).toBe(1); + expect(transaction.encodeExternref(shared)).toBe(1); + const records = withArena((arena) => transaction.sealInto(arena)); + transaction.beginParentReplay(); + expect(transaction.decodeExternref(0)).toBeNull(); + expect(transaction.decodeExternref(1)).toBe(shared); + transaction.finishReplay(); + expect(records.filter( + ({ kind }) => kind === ForkModuleStateRecordKind.ReferenceRecipe, + )).toHaveLength(1); + expect(records.some( + ({ kind }) => kind === ForkModuleStateRecordKind.ReferenceRecipeSegment, + )).toBe(true); + }); + + it("round-trips compact call-specific recipe vectors with O(1) lookup", () => { + const parent = new ForkReferenceTransaction( + makeFunctionCatalog(0, []), + makeExternrefs().provider, + ); + parent.beginCapture(); + const first = parent.encodeExternref({ value: 1 }); + const second = parent.encodeExternref({ value: 2 }); + const builder = parent.beginReferenceVector(2); + expect(builder).toBe(1); + parent.appendReferenceVector(builder, first); + parent.appendReferenceVector(builder, second); + const vector = parent.finishReferenceVector(builder); + expect(vector).toBe(1); + const duplicateBuilder = parent.beginReferenceVector(2); + // Completed builder handles are reused, while the frame-visible result is + // the canonical content ordinal. + expect(duplicateBuilder).toBe(builder); + parent.appendReferenceVector(duplicateBuilder, first); + parent.appendReferenceVector(duplicateBuilder, second); + expect(parent.finishReferenceVector(duplicateBuilder)).toBe(vector); + const records = withArena((arena) => parent.sealInto(arena)); + parent.beginParentReplay(); + expect(parent.getReferenceVector(vector, 0)).toBe(first); + expect(parent.getReferenceVector(vector, 1)).toBe(second); + + const child = new ForkReferenceTransaction( + makeFunctionCatalog(0, []), + makeExternrefs().provider, + ); + child.attachChild(records); + expect((child as unknown as { + decodedReferenceVectors: Array; + }).decodedReferenceVectors).toHaveLength(2); + expect(child.getReferenceVector(vector, 0)).toBe(first); + expect(child.getReferenceVector(vector, 1)).toBe(second); + expect(() => child.getReferenceVector(vector, 2)).toThrow(/out of bounds/); + child.finishReplay(); + parent.finishReplay(); + }); + + it("does not seal a partially appended reference vector", () => { + const transaction = new ForkReferenceTransaction( + makeFunctionCatalog(0, []), + makeExternrefs().provider, + ); + transaction.beginCapture(); + const recipe = transaction.encodeExternref({ value: 1 }); + const builder = transaction.beginReferenceVector(2); + transaction.appendReferenceVector(builder, recipe); + expect(() => transaction.finishReferenceVector(builder)).toThrow( + /expected 2/, + ); + expect(() => withArena((arena) => transaction.sealInto(arena))).toThrow( + /unfinished reference vector/, + ); + transaction.abort(); + }); + + it("encodes a module-static root before opaque capture and resolves the child root", () => { + const parentRoot = Object.freeze({ instance: "parent" }); + const parentTable = new WebAssembly.Table({ + element: "externref", + initial: 2, + maximum: 2, + }); + parentTable.set(0, parentRoot); + parentTable.set(1, parentRoot); + const parentRoots = new ForkStaticRootCatalog(); + parentRoots.register(5, parentTable); + const parentExternrefs = makeExternrefs(); + const parent = new ForkReferenceTransaction( + makeFunctionCatalog(0, []), + parentExternrefs.provider, + undefined, + undefined, + undefined, + "static-root parent", + parentRoots, + ); + parent.beginCapture(); + const recipeId = parent.encodeExternref(parentRoot); + const builder = parent.beginReferenceVector(1); + parent.appendReferenceVector(builder, recipeId); + const vector = parent.finishReferenceVector(builder); + expect(parentExternrefs.values.size).toBe(0); + const records = withArena((arena) => parent.sealInto(arena)); + + const childRoot = Object.freeze({ instance: "child" }); + const childTable = new WebAssembly.Table({ + element: "externref", + initial: 2, + maximum: 2, + }); + childTable.set(0, childRoot); + childTable.set(1, childRoot); + const childRoots = new ForkStaticRootCatalog(); + childRoots.register(5, childTable); + const child = new ForkReferenceTransaction( + makeFunctionCatalog(0, []), + makeExternrefs().provider, + undefined, + undefined, + undefined, + "static-root child", + childRoots, + ); + child.attachChild(records); + const restoredRecipeId = child.getReferenceVector(vector, 0); + expect(restoredRecipeId).toBe(recipeId); + expect(child.decodeExternref(restoredRecipeId)).toBe(childRoot); + expect(child.decodeExternref(restoredRecipeId)).not.toBe(parentRoot); + child.finishReplay(); + parent.abort(); + }); + + it("upgrades an earlier external view to one structural exception identity", () => { + const memory = new WebAssembly.Memory({ initial: 2 }); + const tag = new WebAssembly.Tag({ parameters: ["i32"] }); + const thrown = new WebAssembly.Exception(tag, [29]); + const transaction = new ForkReferenceTransaction( + makeFunctionCatalog(0, []), + makeExternrefs().provider, + memory, + ); + const provider = { + throwSlot(_slot: number): never { + throw thrown; + }, + clearSlots(): void {}, + }; + transaction.beginCapture(); + const externalView = transaction.encodeExternref(thrown); + expect(transaction.lookupExceptionSlot(0, provider)).toBe(0); + const exceptionView = transaction.claimExceptionSlot(0, provider); + expect(exceptionView).toBe(externalView); + expect(transaction.encodeExternref(thrown)).toBe(externalView); + transaction.defineException( + exceptionView, + 8, + 3, + 4, + 0, + 0, + 0, + 0, + ); + expect(transaction.exceptionOwner(externalView)).toBe(8); + withArena((arena) => transaction.sealInto(arena)); + transaction.abort(); + }); + + it("drops strong temporary roots after abort", () => { + const externrefs = makeExternrefs(); + const transaction = new ForkReferenceTransaction( + makeFunctionCatalog(0, []), + externrefs.provider, + ); + transaction.beginCapture(); + const reused = { value: 1 }; + transaction.encodeExternref(reused); + transaction.abort(); + transaction.beginCapture(); + expect(transaction.encodeExternref(reused)).toBe(1); + transaction.abort(); + }); + + it("owns reentrant shared-memory scratch with LIFO release and zeroing", () => { + const memory = new WebAssembly.Memory({ initial: 8 }); + let next = 0x1_0000; + const released: Array<[number, number]> = []; + const transaction = new ForkReferenceTransaction( + makeFunctionCatalog(0, []), + makeExternrefs().provider, + memory, + (size) => { + const addr = next; + next += size; + return addr; + }, + (addr, size) => released.push([addr, size]), + "scratch test", + ); + transaction.beginCapture(); + const outer = transaction.reserveScratch(24); + const inner = transaction.reserveScratch(32); + expect(inner).toBe(outer + 32); + new Uint8Array(memory.buffer, outer, 24).fill(0xaa); + new Uint8Array(memory.buffer, inner, 32).fill(0xbb); + + expect(() => transaction.releaseScratch(outer, 24)).toThrow( + /most recent reservation/, + ); + transaction.releaseScratch(inner, 32); + expect(new Uint8Array(memory.buffer, inner, 32)).toEqual(new Uint8Array(32)); + transaction.releaseScratch(outer, 24); + expect(new Uint8Array(memory.buffer, outer, 32)).toEqual(new Uint8Array(32)); + + // The common page remains transaction-owned for reuse, then is cleared + // and returned exactly once on abort. + expect(transaction.reserveScratch(16)).toBe(outer); + new Uint8Array(memory.buffer, outer, 16).fill(0xcc); + transaction.abort(); + expect(new Uint8Array(memory.buffer, outer, 16)).toEqual(new Uint8Array(16)); + expect(released).toEqual([[0x1_0000, 65_536]]); + }); + + it("interns Wasm-only exception identity and transfers exact scalar/reference payloads", () => { + const memory = new WebAssembly.Memory({ initial: 2 }); + const thrown = new WebAssembly.Exception( + new WebAssembly.Tag({ parameters: ["i32"] }), + [17], + ); + let cleared = 0; + const externrefs = makeExternrefs(); + const parent = new ForkReferenceTransaction( + makeFunctionCatalog(0, []), + externrefs.provider, + memory, + ); + parent.setExceptionSlotProvider({ + throwSlot(slot): never { + if (slot !== 3 && slot !== 4) throw new Error(`invalid slot ${slot}`); + throw thrown; + }, + clearSlots() { + cleared++; + }, + }); + parent.beginCapture(); + expect(parent.lookupExceptionSlot(3)).toBe(0); + const exceptionId = parent.claimExceptionSlot(3); + expect(exceptionId).toBe(1); + expect(parent.lookupExceptionSlot(4)).toBe(exceptionId); + expect(parent.claimExceptionSlot(4)).toBe(exceptionId); + + const sourceScalars = Uint8Array.of( + 0x78, 0x56, 0x34, 0x12, + 0, 1, 2, 3, 4, 5, 6, 7, + 8, 9, 10, 11, 12, 13, 14, 15, + ); + new Uint8Array(memory.buffer, 0x100, sourceScalars.length).set(sourceScalars); + new DataView(memory.buffer).setUint32(0x200, 0, true); + parent.defineException( + exceptionId, + 7, + 5, + 9, + 0x100, + sourceScalars.length, + 0x200, + 1, + ); + const records = withArena((arena) => parent.sealInto(arena)); + parent.beginParentReplay(); + expect(parent.routeException(exceptionId, 7)).toBe(9); + expect(parent.routeException(exceptionId, 8)).toBe(-1); + expect( + parent.loadException( + exceptionId, + 7, + 5, + 9, + 0x300, + sourceScalars.length, + 0x400, + 1, + ), + ).toBe(1); + expect(new Uint8Array(memory.buffer, 0x300, sourceScalars.length)).toEqual( + sourceScalars, + ); + expect(new DataView(memory.buffer).getUint32(0x400, true)).toBe(0); + parent.finishReplay(); + expect(cleared).toBe(1); + + const childMemory = new WebAssembly.Memory({ initial: 2 }); + const child = new ForkReferenceTransaction( + makeFunctionCatalog(0, []), + makeExternrefs().provider, + childMemory, + ); + child.attachChild(records); + expect(child.routeException(exceptionId, 7)).toBe(9); + expect( + child.loadException( + exceptionId, + 7, + 5, + 9, + 0x100, + sourceScalars.length, + 0x200, + 1, + ), + ).toBe(1); + expect( + new Uint8Array(childMemory.buffer, 0x100, sourceScalars.length), + ).toEqual(sourceScalars); + expect(() => + child.loadException( + exceptionId, + 7, + 5, + 10, + 0x100, + sourceScalars.length, + 0x200, + 1, + ) + ).toThrow(/coordinate does not match/); + child.finishReplay(); + }); +}); + +function gcStructDescriptor(options: { + defaultable?: boolean; + mutable?: boolean; + nullable?: boolean; + dependency?: boolean; +}): ForkGcCodecDescriptor { + const fieldFlags = + FORK_GC_FIELD_REFERENCE + | (options.mutable ? FORK_GC_FIELD_MUTABLE : 0) + | (options.nullable ? FORK_GC_FIELD_NULLABLE : 0) + | (options.dependency ? FORK_GC_FIELD_ALLOCATION_DEPENDENCY : 0); + const layout: ForkGcLayoutDescriptor = { + id: 1, + typeOrdinal: 0, + kind: ForkGcLayoutKind.Struct, + constructor: ForkGcConstructorKind.Struct, + flags: options.defaultable ? FORK_GC_LAYOUT_DEFAULTABLE_SHELL : 0, + scalarLengthOrStride: 0, + fields: [{ + storage: 8, + flags: fieldFlags, + scalarOffset: null, + referenceOrdinal: 0, + }], + superTypeOrdinal: null, + baseLayoutId: 1, + auxiliary: 0, + provenanceScalarLength: 0, + provenanceReferenceCount: 0, + }; + return new ForkGcCodecDescriptor([layout]); +} + +function captureGcStructGraph(options: { + descriptor: ForkGcCodecDescriptor; + edges: readonly (readonly number[])[]; +}): ForkModuleStateRecord[] { + const memory = new WebAssembly.Memory({ initial: 2 }); + const transaction = new ForkReferenceTransaction( + makeFunctionCatalog(0, []), + makeExternrefs().provider, + memory, + ); + const table = new WebAssembly.Table({ + element: "externref", + initial: 1, + }); + transaction.beginCapture(); + const recipeIds = options.edges.map((_, index) => { + table.set(0, { index }); + return transaction.claimGcSlot(table, 0); + }); + options.edges.forEach((edgeIndexes, index) => { + const builder = transaction.beginReferenceVector(edgeIndexes.length); + edgeIndexes.forEach((edgeIndex) => { + const edgeRecipe = edgeIndex === -1 ? 0 : recipeIds[edgeIndex]; + if (edgeRecipe === undefined) { + throw new Error(`test GC edge ${edgeIndex} is out of bounds`); + } + transaction.appendReferenceVector(builder, edgeRecipe); + }); + const vector = transaction.finishReferenceVector(builder); + transaction.defineGc( + recipeIds[index]!, + 0, + 0, + 1, + ForkGcLayoutKind.Struct, + 0, + 0, + vector, + options.descriptor, + null, + ); + }); + return withArena((arena) => transaction.sealInto(arena)); +} + +describe("ForkReferenceTransaction typed replay barrier", () => { + it("reuses canonical GC edge vectors without rebuilding the vector directory", () => { + const descriptor = gcStructDescriptor({ + defaultable: true, + mutable: true, + nullable: true, + }); + const records = captureGcStructGraph({ + descriptor, + edges: [[0], [1], [2]], + }); + const child = new ForkReferenceTransaction( + makeFunctionCatalog(0, []), + makeExternrefs().provider, + new WebAssembly.Memory({ initial: 2 }), + ); + child.attachChild(records); + + const internal = child as unknown as { + decodedReferenceVectors: Array; + }; + const directory = internal.decodedReferenceVectors; + const firstOrdinal = child.loadGc( + 1, + 0, + 0, + 1, + ForkGcLayoutKind.Struct, + 0, + 0, + ); + const secondOrdinal = child.loadGc( + 2, + 0, + 0, + 1, + ForkGcLayoutKind.Struct, + 0, + 0, + ); + const thirdOrdinal = child.loadGc( + 3, + 0, + 0, + 1, + ForkGcLayoutKind.Struct, + 0, + 0, + ); + + expect(internal.decodedReferenceVectors).toBe(directory); + expect([firstOrdinal, secondOrdinal, thirdOrdinal]).toEqual([1, 2, 3]); + expect(child.loadGc( + 1, + 0, + 0, + 1, + ForkGcLayoutKind.Struct, + 0, + 0, + )).toBe(firstOrdinal); + expect(internal.decodedReferenceVectors).toHaveLength(4); + child.abort(); + }); + + it("materializes a deep immutable dependency chain without host stack recursion", () => { + const nodeCount = 6_000; + const descriptor = gcStructDescriptor({ + nullable: true, + dependency: true, + }); + const records = captureGcStructGraph({ + descriptor, + edges: Array.from( + { length: nodeCount }, + (_, index) => [index + 1 === nodeCount ? -1 : index + 1], + ), + }); + let allocations = 0; + let fills = 0; + let firstAllocated = 0; + let lastAllocated = 0; + const provider: ForkGcCodecProvider = { + activationId: 0, + descriptor, + probe: () => 0n, + encodeSlot: () => 0, + allocate(recipeId) { + if (allocations === 0) firstAllocated = recipeId; + lastAllocated = recipeId; + allocations++; + }, + fill: () => { fills++; }, + publishExternref: () => {}, + }; + const child = new ForkReferenceTransaction( + makeFunctionCatalog(0, []), + makeExternrefs().provider, + new WebAssembly.Memory({ initial: 2 }), + undefined, + undefined, + "deep typed child", + undefined, + { + prepareTransit: () => {}, + publishTransit: () => {}, + publishExternref: () => {}, + provider: () => provider, + providers: () => [provider], + validateExceptionOwner: () => {}, + materializeException: () => {}, + }, + ); + child.attachChild(records); + child.materializeAllTyped(); + + expect(allocations).toBe(nodeCount); + expect(fills).toBe(nodeCount); + expect(firstAllocated).toBe(nodeCount); + expect(lastAllocated).toBe(1); + child.finishReplay(); + }); + + it("publishes fresh static roots before dynamic GC constructors consume them", () => { + const descriptor = gcStructDescriptor({ dependency: true }); + const parentRoot = Object.freeze({ instance: "parent-static-root" }); + const parentRoots = new ForkStaticRootCatalog(); + const parentCatalogTable = new WebAssembly.Table({ + element: "externref", + initial: 1, + maximum: 1, + }); + parentCatalogTable.set(0, parentRoot); + parentRoots.register(5, parentCatalogTable); + const captureTable = new WebAssembly.Table({ + element: "externref", + initial: 1, + }); + const memory = new WebAssembly.Memory({ initial: 2 }); + const parent = new ForkReferenceTransaction( + makeFunctionCatalog(0, []), + makeExternrefs().provider, + memory, + undefined, + undefined, + "static-root graph parent", + parentRoots, + ); + parent.beginCapture(); + captureTable.set(0, parentRoot); + const staticRecipe = parent.lookupGcSlot(captureTable, 0); + captureTable.set(0, { dynamic: true }); + const dynamicRecipe = parent.claimGcSlot(captureTable, 0); + const builder = parent.beginReferenceVector(1); + parent.appendReferenceVector(builder, staticRecipe); + const vector = parent.finishReferenceVector(builder); + parent.defineGc( + dynamicRecipe, + 0, + 0, + 1, + ForkGcLayoutKind.Struct, + 0, + 0, + vector, + descriptor, + null, + ); + const records = withArena((arena) => parent.sealInto(arena)); + + const childRoot = Object.freeze({ instance: "child-static-root" }); + const childRoots = new ForkStaticRootCatalog(); + const childCatalogTable = new WebAssembly.Table({ + element: "externref", + initial: 1, + maximum: 1, + }); + childCatalogTable.set(0, childRoot); + childRoots.register(5, childCatalogTable); + const transit = new Map(); + const calls: string[] = []; + const provider: ForkGcCodecProvider = { + activationId: 0, + descriptor, + probe: () => 0n, + encodeSlot: () => 0, + allocate(recipeId) { + expect(transit.get(staticRecipe)).toBe(childRoot); + expect(transit.get(staticRecipe)).not.toBe(parentRoot); + calls.push(`allocate:${recipeId}`); + }, + fill: (recipeId) => { calls.push(`fill:${recipeId}`); }, + publishExternref: () => {}, + }; + const child = new ForkReferenceTransaction( + makeFunctionCatalog(0, []), + makeExternrefs().provider, + new WebAssembly.Memory({ initial: 2 }), + undefined, + undefined, + "static-root graph child", + childRoots, + { + prepareTransit: (max) => { calls.push(`prepare:${max}`); }, + publishTransit(recipeId, value) { + transit.set(recipeId, value); + calls.push(`publish:${recipeId}`); + }, + publishExternref: () => {}, + provider: () => provider, + providers: () => [provider], + validateExceptionOwner: () => {}, + materializeException: () => {}, + }, + ); + child.attachChild(records); + child.materializeAllTyped(); + expect(calls).toEqual([ + `prepare:${dynamicRecipe}`, + `publish:${staticRecipe}`, + `allocate:${dynamicRecipe}`, + `fill:${dynamicRecipe}`, + ]); + child.finishReplay(); + parent.abort(); + }); + + it("allocates all defaultable shells before filling cyclic mutable edges", () => { + const descriptor = gcStructDescriptor({ + defaultable: true, + mutable: true, + nullable: true, + }); + const records = captureGcStructGraph({ + descriptor, + edges: [[0]], + }); + const calls: string[] = []; + const provider: ForkGcCodecProvider = { + activationId: 0, + descriptor, + probe: () => 0n, + encodeSlot: () => 0, + allocate: (recipeId) => { calls.push(`allocate:${recipeId}`); }, + fill: (recipeId) => { calls.push(`fill:${recipeId}`); }, + publishExternref: () => {}, + }; + const memory = new WebAssembly.Memory({ initial: 2 }); + const child = new ForkReferenceTransaction( + makeFunctionCatalog(0, []), + makeExternrefs().provider, + memory, + undefined, + undefined, + "typed child", + undefined, + { + prepareTransit: (max) => { calls.push(`prepare:${max}`); }, + publishTransit: () => {}, + publishExternref: () => {}, + provider: () => provider, + providers: () => [provider], + validateExceptionOwner: () => {}, + materializeException: () => {}, + }, + ); + child.attachChild(records); + child.materializeAllTyped(); + expect(calls).toEqual(["prepare:1", "allocate:1", "fill:1"]); + child.finishReplay(); + }); + + it("rejects an immutable constructor cycle before allocating any object", () => { + const descriptor = gcStructDescriptor({ + dependency: true, + }); + const records = captureGcStructGraph({ + descriptor, + edges: [[1], [0]], + }); + const calls: string[] = []; + const provider: ForkGcCodecProvider = { + activationId: 0, + descriptor, + probe: () => 0n, + encodeSlot: () => 0, + allocate: (recipeId) => { calls.push(`allocate:${recipeId}`); }, + fill: (recipeId) => { calls.push(`fill:${recipeId}`); }, + publishExternref: () => {}, + }; + const child = new ForkReferenceTransaction( + makeFunctionCatalog(0, []), + makeExternrefs().provider, + new WebAssembly.Memory({ initial: 2 }), + undefined, + undefined, + "typed cycle", + undefined, + { + prepareTransit: () => {}, + publishTransit: () => {}, + publishExternref: () => {}, + provider: () => provider, + providers: () => [provider], + validateExceptionOwner: () => {}, + materializeException: () => {}, + }, + ); + child.attachChild(records); + expect(() => child.materializeAllTyped()).toThrow( + /unallocatable constructor cycle/, + ); + expect(calls).toEqual([]); + child.abort(); + }); + + it("requires constructor provenance when the selected layout declares it", () => { + const base = gcStructDescriptor({}).require(1); + const descriptor = new ForkGcCodecDescriptor([{ + ...base, + flags: FORK_GC_LAYOUT_REQUIRES_PROVENANCE, + provenanceReferenceCount: 1, + }]); + const memory = new WebAssembly.Memory({ initial: 2 }); + const transaction = new ForkReferenceTransaction( + makeFunctionCatalog(0, []), + makeExternrefs().provider, + memory, + ); + const table = new WebAssembly.Table({ + element: "externref", + initial: 1, + }); + table.set(0, {}); + transaction.beginCapture(); + const recipe = transaction.claimGcSlot(table, 0); + const builder = transaction.beginReferenceVector(1); + transaction.appendReferenceVector(builder, 0); + const vector = transaction.finishReferenceVector(builder); + expect(() => transaction.defineGc( + recipe, + 0, + 0, + 1, + ForkGcLayoutKind.Struct, + 0, + 0, + vector, + descriptor, + null, + )).toThrow(/missing constructor provenance/); + transaction.abort(); + }); +}); diff --git a/host/test/fork-replay-events.test.ts b/host/test/fork-replay-events.test.ts new file mode 100644 index 0000000000..18ada8c0c5 --- /dev/null +++ b/host/test/fork-replay-events.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it } from "vitest"; +import { + FORK_REPLAY_EVENT_SEGMENT_CAPACITY, + type ForkReplayEventWire, + encodeForkReplayEventManifest, + encodeForkReplayEventSegment, + ForkReplayEventJournal, + ForkResumeTable, + validateForkReplayEventWire, +} from "../src/fork-replay-events"; + +function wasmFunction(): CallableFunction { + const module = new WebAssembly.Module(new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + 0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7f, + 0x03, 0x02, 0x01, 0x00, + 0x07, 0x05, 0x01, 0x01, 0x66, 0x00, 0x00, + 0x0a, 0x06, 0x01, 0x04, 0x00, 0x41, 0x07, 0x0b, + ])); + return new WebAssembly.Instance(module).exports.f as CallableFunction; +} + +function sealedWire( + journal: ForkReplayEventJournal, +): ForkReplayEventWire & { segments: Uint8Array[] } { + journal.sealCapture(); + return { + manifest: journal.capturedManifestPayload(), + segments: [...journal.capturedSegmentPayloads()], + }; +} + +describe("ForkReplayEventJournal", () => { + it("replays the exact reverse of cross-module frame commit order", () => { + const parent = new ForkReplayEventJournal(); + parent.beginCapture(); + parent.recordCommit(3, 8); + parent.recordCommit(3, 4); + parent.recordCommit(0, 11); + const wire = sealedWire(parent); + parent.beginParentReplay(); + + expect(parent.peek()).toEqual({ activationId: 0, functionOrdinal: 11 }); + parent.consume(0, 11); + expect(parent.peek()).toEqual({ activationId: 3, functionOrdinal: 4 }); + parent.consume(3, 4); + expect(parent.peek()).toEqual({ activationId: 3, functionOrdinal: 8 }); + parent.consume(3, 8); + expect(parent.peek()).toBeNull(); + parent.finishReplay(); + + const child = new ForkReplayEventJournal(); + child.attachChild(wire); + expect(child.peek()).toEqual({ activationId: 0, functionOrdinal: 11 }); + }); + + it("streams multi-page capture and child replay without concatenation", () => { + const eventCount = FORK_REPLAY_EVENT_SEGMENT_CAPACITY + 3; + const parent = new ForkReplayEventJournal(); + parent.beginCapture(); + for (let index = 0; index < eventCount; index++) { + parent.recordCommit(index % 3, index); + } + const wire = sealedWire(parent); + const summary = validateForkReplayEventWire(wire); + expect(summary.eventCount).toBe(BigInt(eventCount)); + expect(summary.segmentCount).toBe(2n); + expect(summary.activationIds).toEqual(new Set([0, 1, 2])); + expect(wire.segments).toHaveLength(2); + + parent.beginParentReplay(); + const child = new ForkReplayEventJournal(); + child.attachChild(wire); + for (let index = eventCount - 1; index >= 0; index--) { + const expected = { activationId: index % 3, functionOrdinal: index }; + expect(parent.peek()).toEqual(expected); + parent.consume(expected.activationId, expected.functionOrdinal); + expect(child.peek()).toEqual(expected); + child.consume(expected.activationId, expected.functionOrdinal); + } + expect(parent.peek()).toBeNull(); + expect(child.peek()).toBeNull(); + parent.finishReplay(); + child.finishReplay(); + }); + + it("requires peek and consume to name the same frame atomically", () => { + const journal = new ForkReplayEventJournal(); + journal.beginCapture(); + journal.recordCommit(1, 2); + sealedWire(journal); + journal.beginParentReplay(); + expect(() => journal.consume(1, 2)).toThrow("without selecting"); + journal.peek(); + expect(() => journal.consume(1, 3)).toThrow("expected 1:2"); + }); + + it("drops every page on abort and can begin another capture", () => { + const journal = new ForkReplayEventJournal(); + journal.beginCapture(); + for ( + let index = 0; + index < FORK_REPLAY_EVENT_SEGMENT_CAPACITY + 1; + index++ + ) { + journal.recordCommit(7, index); + } + journal.abort(); + expect(journal.phaseName()).toBe("idle"); + journal.beginCapture(); + journal.recordCommit(1, 2); + expect(validateForkReplayEventWire(sealedWire(journal)).eventCount).toBe(1n); + }); +}); + +describe("fork replay event segmented wire", () => { + function twoPageWire(): ForkReplayEventWire & { segments: Uint8Array[] } { + const journal = new ForkReplayEventJournal(); + journal.beginCapture(); + for ( + let index = 0; + index < FORK_REPLAY_EVENT_SEGMENT_CAPACITY + 2; + index++ + ) { + journal.recordCommit(index % 2, index); + } + return sealedWire(journal); + } + + it("rejects out-of-order segment sequence numbers", () => { + const wire = twoPageWire(); + const segments = wire.segments.map((segment) => segment.slice()); + new DataView(segments[1]!.buffer).setBigUint64(8, 0n, true); + expect(() => + validateForkReplayEventWire({ manifest: wire.manifest, segments }) + ).toThrow("out of order"); + }); + + it("rejects reordered, duplicated, missing, gapped, and trailing segments", () => { + const wire = twoPageWire(); + expect(() => + validateForkReplayEventWire({ + manifest: wire.manifest, + segments: [wire.segments[1]!, wire.segments[0]!], + }) + ).toThrow("out of order"); + expect(() => + validateForkReplayEventWire({ + manifest: wire.manifest, + segments: [wire.segments[0]!, wire.segments[0]!], + }) + ).toThrow("out of order"); + expect(() => + validateForkReplayEventWire({ + manifest: wire.manifest, + segments: wire.segments.slice(0, -1), + }) + ).toThrow("expected 2"); + + const gapped = wire.segments.map((segment) => segment.slice()); + new DataView(gapped[1]!.buffer).setBigUint64(8, 2n, true); + expect(() => + validateForkReplayEventWire({ manifest: wire.manifest, segments: gapped }) + ).toThrow("out of order"); + + const trailingWords = new Uint32Array([8, 13]); + const trailing = encodeForkReplayEventSegment(trailingWords, 1, 2n); + expect(() => + validateForkReplayEventWire({ + manifest: wire.manifest, + segments: [...wire.segments, trailing], + }) + ).toThrow("after its declared segment count 2"); + }); + + it("requires every non-final page to be full", () => { + const wire = twoPageWire(); + const segments = wire.segments.map((segment) => segment.slice()); + new DataView(segments[0]!.buffer).setUint32( + 16, + FORK_REPLAY_EVENT_SEGMENT_CAPACITY - 1, + true, + ); + expect(() => + validateForkReplayEventWire({ manifest: wire.manifest, segments }) + ).toThrow(`expected ${FORK_REPLAY_EVENT_SEGMENT_CAPACITY}`); + }); + + it("requires the final page count and bounds to match the manifest", () => { + const wire = twoPageWire(); + const segments = wire.segments.map((segment) => segment.slice()); + new DataView(segments[1]!.buffer).setUint32(16, 1, true); + expect(() => + validateForkReplayEventWire({ manifest: wire.manifest, segments }) + ).toThrow("expected 2"); + + const truncated = wire.segments.map((segment, index) => + index === 1 ? segment.subarray(0, segment.byteLength - 1) : segment + ); + expect(() => + validateForkReplayEventWire({ + manifest: wire.manifest, + segments: truncated, + }) + ).toThrow("inconsistent bounds"); + }); + + it("rejects manifest trailing bytes and nonzero reserved fields", () => { + const wire = twoPageWire(); + const trailing = new Uint8Array(wire.manifest.byteLength + 1); + trailing.set(wire.manifest); + expect(() => + validateForkReplayEventWire({ manifest: trailing, segments: wire.segments }) + ).toThrow("inconsistent bounds"); + const reserved = wire.manifest.slice(); + new DataView(reserved.buffer).setUint32(20, 1, true); + expect(() => + validateForkReplayEventWire({ manifest: reserved, segments: wire.segments }) + ).toThrow("reserved"); + }); + + it("represents event totals beyond the old contiguous u32 boundary", () => { + const eventCount = 0x1_0000_0001n; + const capacity = BigInt(FORK_REPLAY_EVENT_SEGMENT_CAPACITY); + const segmentCount = (eventCount + capacity - 1n) / capacity; + const manifest = encodeForkReplayEventManifest(eventCount, segmentCount); + const view = new DataView(manifest.buffer); + expect(view.getBigUint64(24, true)).toBe(segmentCount); + expect(view.getBigUint64(32, true)) + .toBe(eventCount); + }); + + it("rejects unavailable u64 segment totals without lossy number conversion", () => { + const segmentCount = 0x1_0000_0000n; + const eventCount = + (segmentCount - 1n) * BigInt(FORK_REPLAY_EVENT_SEGMENT_CAPACITY) + 1n; + const manifest = encodeForkReplayEventManifest(eventCount, segmentCount); + expect(() => + validateForkReplayEventWire({ manifest, segments: [] }) + ).toThrow(`expected ${segmentCount}`); + }); + + it("rejects inexact numeric u64 inputs instead of rounding them", () => { + expect(() => + encodeForkReplayEventManifest( + BigInt(FORK_REPLAY_EVENT_SEGMENT_CAPACITY), + Number.MAX_SAFE_INTEGER + 1, + ) + ).toThrow("exact nonnegative integer"); + expect(() => + encodeForkReplayEventSegment( + new Uint32Array([1, 2]), + 1, + Number.MAX_SAFE_INTEGER + 1, + ) + ).toThrow("exact nonnegative integer"); + }); +}); + +describe("ForkResumeTable", () => { + it("reconstructs slots from activation coordinates", () => { + const first = wasmFunction(); + const second = wasmFunction(); + const table = new ForkResumeTable(); + table.registerActivation(4, [ + { functionOrdinal: 9, thunk: second }, + { functionOrdinal: 3, thunk: first }, + ]); + const firstSlot = table.slotFor({ activationId: 4, functionOrdinal: 3 }); + expect(firstSlot).toBeGreaterThan(0); + expect(table.table.get(firstSlot)).toBe(first); + expect(table.slotFor(null)).toBe(0); + expect(table.slotFor({ activationId: 4, functionOrdinal: 9 })).toBeGreaterThan(0); + }); + + it("clears unloaded activation roots and reuses private slots", () => { + const table = new ForkResumeTable(); + table.registerActivation(1, [ + { functionOrdinal: 1, thunk: wasmFunction() }, + ]); + const slot = table.slotFor({ activationId: 1, functionOrdinal: 1 }); + table.unregisterActivation(1); + expect(table.table.get(slot)).toBeNull(); + table.registerActivation(2, [ + { functionOrdinal: 7, thunk: wasmFunction() }, + ]); + expect(table.slotFor({ activationId: 2, functionOrdinal: 7 })).toBe(slot); + }); +}); diff --git a/host/test/fork-replay-gate.test.ts b/host/test/fork-replay-gate.test.ts new file mode 100644 index 0000000000..9277030bdc --- /dev/null +++ b/host/test/fork-replay-gate.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from "vitest"; +import { + cancelForkReplayGate, + commitForkReplayGate, + createForkReplayGate, + ForkReplayGateCoordinator, + observeForkReplayWorker, +} from "../src/fork-replay-gate"; +import { MockWorkerAdapter } from "../src/worker-adapter"; + +describe("fork replay two-phase gate", () => { + it("commits a pending child exactly once", () => { + const gate = createForkReplayGate(); + expect(Atomics.load(new Int32Array(gate), 0)).toBe(0); + commitForkReplayGate(gate); + expect(Atomics.load(new Int32Array(gate), 0)).toBe(1); + expect(() => commitForkReplayGate(gate)).toThrow(/no longer pending/); + }); + + it("cancels only while reconstruction is pending", () => { + const pending = createForkReplayGate(); + cancelForkReplayGate(pending); + expect(Atomics.load(new Int32Array(pending), 0)).toBe(-1); + + const committed = createForkReplayGate(); + commitForkReplayGate(committed); + cancelForkReplayGate(committed); + expect(Atomics.load(new Int32Array(committed), 0)).toBe(1); + }); + + it("rejects malformed gate storage before publishing state", () => { + expect(() => commitForkReplayGate(new SharedArrayBuffer(8))) + .toThrow(/one shared i32/); + }); +}); + +describe("fork replay readiness coordinator", () => { + it("keeps the child blocked after readiness until the host commits", async () => { + const coordinator = new ForkReplayGateCoordinator("pid=41"); + const waiting = coordinator.waitUntilReady(); + + coordinator.ready(); + await waiting; + + expect(coordinator.currentPhase).toBe("ready"); + expect(Atomics.load(new Int32Array(coordinator.gate), 0)).toBe(0); + coordinator.commit(); + expect(coordinator.currentPhase).toBe("committed"); + expect(Atomics.load(new Int32Array(coordinator.gate), 0)).toBe(1); + }); + + it("does not allow commit before the Worker proves replay readiness", () => { + const coordinator = new ForkReplayGateCoordinator("pid=41"); + expect(() => coordinator.commit()).toThrow( + /cannot commit fork replay while pending/, + ); + expect(Atomics.load(new Int32Array(coordinator.gate), 0)).toBe(0); + }); + + it("cancels a deferred launch and rejects its pending readiness", async () => { + const coordinator = new ForkReplayGateCoordinator("pid=41"); + const waiting = coordinator.waitUntilReady(); + + coordinator.cancel(new Error("deferred Worker launch was cancelled")); + + await expect(waiting).rejects.toThrow(/deferred Worker launch was cancelled/); + expect(coordinator.currentPhase).toBe("cancelled"); + expect(Atomics.load(new Int32Array(coordinator.gate), 0)).toBe(-1); + }); + + it("preserves cancellation for a waiter attached after launch rollback", async () => { + const coordinator = new ForkReplayGateCoordinator("pid=41"); + coordinator.cancel(new Error("Worker constructor failed")); + + await expect(coordinator.waitUntilReady()).rejects.toThrow( + /Worker constructor failed/, + ); + expect(Atomics.load(new Int32Array(coordinator.gate), 0)).toBe(-1); + }); + + it("cancellation between ready and commit wins the transaction", async () => { + const coordinator = new ForkReplayGateCoordinator("pid=41"); + coordinator.ready(); + await coordinator.waitUntilReady(); + + coordinator.cancel(new Error("generation was replaced")); + + expect(() => coordinator.commit()).toThrow(/generation was replaced/); + expect(Atomics.load(new Int32Array(coordinator.gate), 0)).toBe(-1); + }); +}); + +describe("fork replay Worker lifecycle observer", () => { + function observed(isCurrentGeneration = () => true) { + const adapter = new MockWorkerAdapter(); + const worker = adapter.createWorker({ pid: 41 }); + const coordinator = new ForkReplayGateCoordinator("fork child pid=41"); + observeForkReplayWorker( + coordinator, + worker, + 41, + isCurrentGeneration, + ); + return { coordinator, worker: adapter.lastWorker! }; + } + + it("accepts readiness only from the exact current child generation", async () => { + const current = observed(); + current.worker.simulateMessage({ type: "fork_replay_ready", pid: 41 }); + await current.coordinator.waitUntilReady(); + expect(current.coordinator.currentPhase).toBe("ready"); + + const stale = observed(() => false); + const staleWaiting = stale.coordinator.waitUntilReady(); + stale.worker.simulateMessage({ type: "fork_replay_ready", pid: 41 }); + await expect(staleWaiting).rejects.toThrow(/stale Worker generation/); + expect(Atomics.load(new Int32Array(stale.coordinator.gate), 0)).toBe(-1); + + const wrongPid = observed(); + const wrongPidWaiting = wrongPid.coordinator.waitUntilReady(); + wrongPid.worker.simulateMessage({ type: "fork_replay_ready", pid: 99 }); + await expect(wrongPidWaiting).rejects.toThrow(/expected pid=41/); + }); + + it.each([ + { + label: "worker-main error message", + fire: (worker: ReturnType["worker"]) => + worker.simulateMessage({ + type: "error", + pid: 41, + message: "instantiation failed", + }), + diagnostic: /instantiation failed/, + }, + { + label: "worker-main exit message", + fire: (worker: ReturnType["worker"]) => + worker.simulateMessage({ type: "exit", pid: 41, status: 7 }), + diagnostic: /status=7/, + }, + { + label: "Worker error event", + fire: (worker: ReturnType["worker"]) => + worker.simulateError(new Error("worker crashed")), + diagnostic: /worker crashed/, + }, + { + label: "Worker exit event", + fire: (worker: ReturnType["worker"]) => + worker.simulateExit(9), + diagnostic: /code=9/, + }, + ])("cancels on $label before readiness", async ({ fire, diagnostic }) => { + const { coordinator, worker } = observed(); + const waiting = coordinator.waitUntilReady(); + fire(worker); + await expect(waiting).rejects.toThrow(diagnostic); + expect(coordinator.currentPhase).toBe("cancelled"); + expect(Atomics.load(new Int32Array(coordinator.gate), 0)).toBe(-1); + }); + + it("ignores later terminal events after a committed replay", async () => { + const { coordinator, worker } = observed(); + worker.simulateMessage({ type: "fork_replay_ready", pid: 41 }); + await coordinator.waitUntilReady(); + coordinator.commit(); + + worker.simulateMessage({ type: "exit", pid: 41, status: 0 }); + worker.simulateExit(0); + + expect(coordinator.currentPhase).toBe("committed"); + expect(Atomics.load(new Int32Array(coordinator.gate), 0)).toBe(1); + }); +}); diff --git a/host/test/fork-replay-host-parity.test.ts b/host/test/fork-replay-host-parity.test.ts new file mode 100644 index 0000000000..b3ed5ccf33 --- /dev/null +++ b/host/test/fork-replay-host-parity.test.ts @@ -0,0 +1,94 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const testDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(testDir, "..", ".."); + +function forkHandlerSource(relativePath: string): string { + const path = join(repoRoot, relativePath); + const source = readFileSync(path, "utf8"); + const start = source.indexOf("async function handleFork("); + const end = source.indexOf("\nasync function handleExec(", start); + expect(start, `${relativePath} must define handleFork`).toBeGreaterThanOrEqual(0); + expect(end, `${relativePath} must define handleExec after handleFork`) + .toBeGreaterThan(start); + return source.slice(start, end); +} + +describe.each([ + ["Node", "host/src/node-kernel-worker-entry.ts"], + ["browser", "host/src/browser-kernel-worker-entry.ts"], +])("%s fork replay launch transaction", (_host, relativePath) => { + it("waits for the exact child generation before committing and resolving", () => { + const handler = forkHandlerSource(relativePath); + const wait = handler.indexOf("await forkReplay.waitUntilReady()"); + const generationCheck = handler.indexOf( + "processes.get(childPid)?.worker !== launchedWorker", + wait, + ); + const commit = handler.indexOf("forkReplay.commit()", generationCheck); + const resolve = handler.lastIndexOf("return [childChannelOffset]"); + + expect(handler).toContain("forkReplayGate: forkReplay.gate"); + expect(handler).toContain("observeForkReplayWorker("); + expect(wait).toBeGreaterThanOrEqual(0); + expect(generationCheck).toBeGreaterThan(wait); + expect(commit).toBeGreaterThan(generationCheck); + expect(resolve).toBeGreaterThan(commit); + }); + + it("cancels both a deferred launch and the rollback path", () => { + const handler = forkHandlerSource(relativePath); + const launchGate = handler.indexOf("startProcessWorkerWhenRunnable("); + const launchCancellation = handler.indexOf("forkReplay.cancel(", launchGate); + const rollback = handler.indexOf("} catch (error)"); + const rollbackCancellation = handler.indexOf("forkReplay.cancel(error)", rollback); + + expect(launchGate).toBeGreaterThanOrEqual(0); + expect(launchCancellation).toBeGreaterThan(launchGate); + expect(launchCancellation).toBeLessThan(rollback); + expect(rollbackCancellation).toBeGreaterThan(rollback); + expect( + handler.indexOf( + "await terminateTrackedWorker(childWorker)", + rollbackCancellation, + ), + ) + .toBeGreaterThan(rollbackCancellation); + }); + + it("grants the exact copied externref graph before launch and retires rollback", () => { + const handler = forkHandlerSource(relativePath); + const grant = handler.indexOf( + "externrefProcessOwner.forkGenerationFromContinuation(", + ); + const childInit = handler.indexOf( + "const childInitData: CentralizedWorkerInitMessage", + grant, + ); + const start = handler.indexOf("startProcessWorkerWhenRunnable(", childInit); + const rollback = handler.indexOf("} catch (error)", start); + const terminate = handler.indexOf( + "await terminateTrackedWorker(childWorker)", + rollback, + ); + const release = handler.indexOf( + "externrefProcessOwner.releaseGeneration(childExternrefGeneration)", + rollback, + ); + + expect(grant).toBeGreaterThanOrEqual(0); + expect(childInit).toBeGreaterThan(grant); + expect(handler.slice(childInit, start)).toContain( + "externrefGenerationId: externrefGrant.generation.id", + ); + expect(start).toBeGreaterThan(childInit); + expect(handler.slice(grant, childInit)).toContain( + "childExternrefGeneration = externrefGrant.generation", + ); + expect(terminate).toBeGreaterThan(rollback); + expect(release).toBeGreaterThan(terminate); + }); +}); diff --git a/host/test/fork-resume-catalog.test.ts b/host/test/fork-resume-catalog.test.ts new file mode 100644 index 0000000000..037def0b9e --- /dev/null +++ b/host/test/fork-resume-catalog.test.ts @@ -0,0 +1,194 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + FORK_RESUME_CATALOG_EXPORT, + FORK_RESUME_CATALOG_HEADER_SIZE, + FORK_RESUME_CATALOG_SECTION, + FORK_RESUME_CATALOG_VERSION, + forkResumeTargetsFromInstance, + readForkResumeCatalog, +} from "../src/fork-resume-catalog"; + +function uleb128(value: number): number[] { + const bytes: number[] = []; + do { + let byte = value & 0x7f; + value >>>= 7; + if (value !== 0) byte |= 0x80; + bytes.push(byte); + } while (value !== 0); + return bytes; +} + +function appendCustomSection( + wasm: Uint8Array, + name: string, + payload: Uint8Array, +): Uint8Array { + const nameBytes = new TextEncoder().encode(name); + const contents = new Uint8Array( + uleb128(nameBytes.byteLength).length + + nameBytes.byteLength + + payload.byteLength, + ); + const encodedNameLength = uleb128(nameBytes.byteLength); + contents.set(encodedNameLength, 0); + contents.set(nameBytes, encodedNameLength.length); + contents.set(payload, encodedNameLength.length + nameBytes.byteLength); + const encodedSectionLength = uleb128(contents.byteLength); + const result = new Uint8Array( + wasm.byteLength + 1 + encodedSectionLength.length + contents.byteLength, + ); + result.set(wasm, 0); + result[wasm.byteLength] = 0; + result.set(encodedSectionLength, wasm.byteLength + 1); + result.set(contents, wasm.byteLength + 1 + encodedSectionLength.length); + return result; +} + +function descriptor( + records: readonly { + functionOrdinal: number; + localCatalogSlot: number; + }[], +): Uint8Array { + const bytes = new Uint8Array( + FORK_RESUME_CATALOG_HEADER_SIZE + records.length * 8, + ); + const view = new DataView(bytes.buffer); + bytes.set(new TextEncoder().encode("KFRC"), 0); + view.setUint16(4, FORK_RESUME_CATALOG_VERSION, true); + view.setUint16(6, FORK_RESUME_CATALOG_HEADER_SIZE, true); + view.setUint32(8, records.length, true); + records.forEach((record, index) => { + const offset = FORK_RESUME_CATALOG_HEADER_SIZE + index * 8; + view.setUint32(offset, record.functionOrdinal, true); + view.setUint32(offset + 4, record.localCatalogSlot, true); + }); + return bytes; +} + +function baseCatalogBytes(tableSize = 2): Uint8Array { + const directory = mkdtempSync(join(tmpdir(), "kandelo-resume-catalog-")); + const wat = join(directory, "catalog.wat"); + const wasm = join(directory, "catalog.wasm"); + const elements = tableSize === 0 ? "" : `(elem (i32.const 0) ${[ + "$first", + "$second", + ].slice(0, tableSize).join(" ")})`; + writeFileSync(wat, `(module + (table $catalog (export "${FORK_RESUME_CATALOG_EXPORT}") ${tableSize} ${tableSize} funcref) + (func $first (result i32) i32.const 17) + (func $second (result i32) i32.const 29) + ${elements} + )`); + execFileSync("wat2wasm", [wat, "-o", wasm]); + return readFileSync(wasm); +} + +function moduleWithDescriptor( + records: readonly { + functionOrdinal: number; + localCatalogSlot: number; + }[], + tableSize = 2, +): WebAssembly.Module { + return new WebAssembly.Module( + appendCustomSection( + baseCatalogBytes(tableSize), + FORK_RESUME_CATALOG_SECTION, + descriptor(records), + ), + ); +} + +describe("fork resume catalog", () => { + it("pairs deterministic ordinals with fresh-instance thunk objects", () => { + const module = moduleWithDescriptor([ + { functionOrdinal: 3, localCatalogSlot: 0 }, + { functionOrdinal: 9, localCatalogSlot: 1 }, + ]); + const first = new WebAssembly.Instance(module); + const second = new WebAssembly.Instance(module); + const firstTargets = forkResumeTargetsFromInstance(module, first); + const secondTargets = forkResumeTargetsFromInstance(module, second); + + expect(firstTargets.map(({ functionOrdinal, localCatalogSlot }) => ({ + functionOrdinal, + localCatalogSlot, + }))).toEqual([ + { functionOrdinal: 3, localCatalogSlot: 0 }, + { functionOrdinal: 9, localCatalogSlot: 1 }, + ]); + expect(firstTargets[0]!.thunk).not.toBe(secondTargets[0]!.thunk); + expect((firstTargets[0]!.thunk as () => number)()).toBe(17); + expect((secondTargets[1]!.thunk as () => number)()).toBe(29); + }); + + it("rejects malformed or ambiguous KFRC metadata", () => { + const base = baseCatalogBytes(); + expect(() => readForkResumeCatalog(new WebAssembly.Module(base))) + .toThrow(`expected one ${FORK_RESUME_CATALOG_SECTION}`); + + const valid = descriptor([ + { functionOrdinal: 3, localCatalogSlot: 0 }, + { functionOrdinal: 9, localCatalogSlot: 1 }, + ]); + const duplicate = appendCustomSection( + appendCustomSection(base, FORK_RESUME_CATALOG_SECTION, valid), + FORK_RESUME_CATALOG_SECTION, + valid, + ); + expect(() => readForkResumeCatalog(new WebAssembly.Module(duplicate))) + .toThrow("found 2"); + + const badMagic = valid.slice(); + badMagic[0] = 0; + expect(() => readForkResumeCatalog(new WebAssembly.Module( + appendCustomSection(base, FORK_RESUME_CATALOG_SECTION, badMagic), + ))).toThrow("invalid magic"); + + const truncated = valid.slice(0, valid.byteLength - 1); + expect(() => readForkResumeCatalog(new WebAssembly.Module( + appendCustomSection(base, FORK_RESUME_CATALOG_SECTION, truncated), + ))).toThrow("invalid size"); + + expect(() => readForkResumeCatalog(moduleWithDescriptor([ + { functionOrdinal: 9, localCatalogSlot: 0 }, + { functionOrdinal: 3, localCatalogSlot: 1 }, + ]))).toThrow("not strictly ordered"); + + expect(() => readForkResumeCatalog(moduleWithDescriptor([ + { functionOrdinal: 3, localCatalogSlot: 0 }, + { functionOrdinal: 9, localCatalogSlot: 0 }, + ]))).toThrow("repeats local slot"); + }); + + it("rejects metadata that cannot resolve against the instance table", () => { + const wrongLength = moduleWithDescriptor([ + { functionOrdinal: 3, localCatalogSlot: 0 }, + ]); + expect(() => forkResumeTargetsFromInstance( + wrongLength, + new WebAssembly.Instance(wrongLength), + )).toThrow("length 2, expected 1"); + + const outOfBounds = moduleWithDescriptor([ + { functionOrdinal: 3, localCatalogSlot: 0 }, + { functionOrdinal: 9, localCatalogSlot: 2 }, + ]); + expect(() => forkResumeTargetsFromInstance( + outOfBounds, + new WebAssembly.Instance(outOfBounds), + )).toThrow("out of bounds"); + + const nullModule = moduleWithDescriptor([], 0); + expect(forkResumeTargetsFromInstance( + nullModule, + new WebAssembly.Instance(nullModule), + )).toEqual([]); + }); +}); diff --git a/host/test/fork-save-buffer-overrun.test.ts b/host/test/fork-save-buffer-overrun.test.ts index 7b51742bb8..6ac6360629 100644 --- a/host/test/fork-save-buffer-overrun.test.ts +++ b/host/test/fork-save-buffer-overrun.test.ts @@ -19,16 +19,10 @@ * without implying that current linked continuations have the old ceiling. */ import { describe, it, expect } from "vitest"; -import type { SideModuleForkState } from "../src/dylink"; -import { - finalizeSideModuleForkUnwind, - forkSaveBufferOverrun, -} from "../src/worker-main"; +import { forkSaveBufferOverrun } from "../src/worker-main"; import { FORK_SAVE_BUFFER_SIZE } from "../src/process-memory"; -import type { LinkedForkContinuation } from "../src/fork-continuation"; const FORK_BUF_ADDR = 65536; // arbitrary page-aligned buffer base for the test -const SIDE_FORK_BUF_ADDR = 32768; // separate from the process-main test buffer function writeCurrentPos( memory: WebAssembly.Memory, @@ -41,31 +35,6 @@ function writeCurrentPos( else view.setUint32(addr, value, true); } -function createSideForkState( - name: string, - forkBufAddr: number, - finishUnwind: () => void = () => {}, -): { state: SideModuleForkState; runtimeState: () => number } { - let value = 1; // UNWINDING - const instance = { - exports: { - wpk_fork_state: () => value, - wpk_fork_unwind_end: () => { - value = 0; // NORMAL - }, - }, - } as unknown as WebAssembly.Instance; - return { - state: { - name, - instance, - forkBufAddr, - continuation: { finishUnwind } as unknown as LinkedForkContinuation, - }, - runtimeState: () => value, - }; -} - describe("forkSaveBufferOverrun", () => { it("reports no overrun when the save fits within the buffer", () => { const memory = new WebAssembly.Memory({ initial: 3 }); @@ -131,32 +100,4 @@ describe("forkSaveBufferOverrun", () => { forkSaveBufferOverrun(memory, FORK_BUF_ADDR, 8, FORK_SAVE_BUFFER_SIZE), ).toBe(1); }); - - it("finalizes the side-module linked continuation", () => { - const memory = new WebAssembly.Memory({ initial: 3 }); - let finalized = false; - const side = createSideForkState( - "libintl.so", - SIDE_FORK_BUF_ADDR, - () => { finalized = true; }, - ); - - expect(() => finalizeSideModuleForkUnwind(memory, side.state, 4)) - .not.toThrow(); - expect(finalized).toBe(true); - expect(side.runtimeState()).toBe(0); - }); - - it("propagates linked continuation validation before fork dispatch", () => { - const memory = new WebAssembly.Memory({ initial: 3 }); - const side = createSideForkState( - "libintl.so", - SIDE_FORK_BUF_ADDR, - () => { throw new Error("uncommitted linked frame"); }, - ); - - expect(() => finalizeSideModuleForkUnwind(memory, side.state, 4)) - .toThrow("uncommitted linked frame"); - expect(side.runtimeState()).toBe(0); - }); }); diff --git a/host/test/fork-static-root-catalog.test.ts b/host/test/fork-static-root-catalog.test.ts new file mode 100644 index 0000000000..47df1b77b1 --- /dev/null +++ b/host/test/fork-static-root-catalog.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { + ForkStaticRootCatalog, +} from "../src/fork-static-root-catalog"; + +function externrefTable(values: readonly unknown[]): WebAssembly.Table { + const table = new WebAssembly.Table({ + element: "externref", + initial: values.length, + maximum: values.length, + }); + values.forEach((value, index) => table.set(index, value)); + return table; +} + +describe("ForkStaticRootCatalog", () => { + it("canonicalizes aliases and resolves the fresh child's root", () => { + const parentRoot = Object.freeze({ activation: "parent" }); + const parent = new ForkStaticRootCatalog(); + const parentHarvest = externrefTable([parentRoot, parentRoot]); + parent.register(4, parentHarvest); + expect(parentHarvest.get(0)).toBeNull(); + expect(parentHarvest.get(1)).toBeNull(); + + expect(parent.encode(parentRoot)).toEqual({ + moduleActivation: 4, + ordinal: 0, + }); + + const childRoot = Object.freeze({ activation: "child" }); + const child = new ForkStaticRootCatalog(); + child.register(4, externrefTable([childRoot, childRoot])); + const decoded = child.decode(parent.encode(parentRoot)!); + + expect(decoded).toBe(childRoot); + expect(decoded).not.toBe(parentRoot); + }); + + it("uses the first activation coordinate for an imported shared root", () => { + const imported = Object.freeze({ imported: true }); + const catalogs = new ForkStaticRootCatalog(); + catalogs.register(2, externrefTable([imported])); + catalogs.register(7, externrefTable([imported])); + + expect(catalogs.encode(imported)).toEqual({ + moduleActivation: 2, + ordinal: 0, + }); + catalogs.unregister(2); + expect(catalogs.encode(imported)).toEqual({ + moduleActivation: 7, + ordinal: 0, + }); + }); + + it("rejects duplicate activation registration and invalid recipes", () => { + const catalogs = new ForkStaticRootCatalog(); + catalogs.register(3, externrefTable([null])); + expect(() => catalogs.register(3, externrefTable([]))).toThrow( + /already registered/, + ); + expect(() => + catalogs.decode({ moduleActivation: 3, ordinal: 1 }) + ).toThrow(/out of bounds/); + expect(() => + catalogs.decode({ moduleActivation: 9, ordinal: 0 }) + ).toThrow(/not registered/); + }); +}); diff --git a/host/test/fork-unwind-transport.test.ts b/host/test/fork-unwind-transport.test.ts new file mode 100644 index 0000000000..be72d84007 --- /dev/null +++ b/host/test/fork-unwind-transport.test.ts @@ -0,0 +1,62 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + createForkUnwindTag, + FORK_UNWIND_TAG_IMPORT_MODULE, + FORK_UNWIND_TAG_IMPORT_NAME, + isForkUnwindException, +} from "../src/fork-unwind-transport"; + +function throwingModule(): WebAssembly.Module { + const dir = mkdtempSync(join(tmpdir(), "kandelo-unwind-tag-")); + const wat = join(dir, "transport.wat"); + const wasm = join(dir, "transport.wasm"); + writeFileSync(wat, `(module + (tag $unwind (import "${FORK_UNWIND_TAG_IMPORT_MODULE}" "${FORK_UNWIND_TAG_IMPORT_NAME}")) + (func (export "throw_unwind") throw $unwind) + )`); + execFileSync("wat2wasm", ["--enable-exceptions", wat, "-o", wasm]); + return new WebAssembly.Module(readFileSync(wasm)); +} + +describe.skipIf( + typeof WebAssembly.Tag !== "function" + || typeof WebAssembly.Exception !== "function", +)("fork unwind transport", () => { + it("recognizes only the exact process-owned tag identity", () => { + const tag = createForkUnwindTag(); + const other = createForkUnwindTag(); + const instance = new WebAssembly.Instance(throwingModule(), { + env: { [FORK_UNWIND_TAG_IMPORT_NAME]: tag }, + }); + + let thrown: unknown; + try { + (instance.exports.throw_unwind as () => void)(); + } catch (error) { + thrown = error; + } + expect(isForkUnwindException(thrown, tag)).toBe(true); + expect(isForkUnwindException(thrown, other)).toBe(false); + }); + + it("shares one identity across independently instantiated modules", () => { + const tag = createForkUnwindTag(); + const module = throwingModule(); + const imports = { env: { [FORK_UNWIND_TAG_IMPORT_NAME]: tag } }; + const first = new WebAssembly.Instance(module, imports); + const second = new WebAssembly.Instance(module, imports); + + for (const instance of [first, second]) { + expect(() => (instance.exports.throw_unwind as () => void)()).toThrow(); + try { + (instance.exports.throw_unwind as () => void)(); + } catch (error) { + expect(isForkUnwindException(error, tag)).toBe(true); + } + } + }); +}); diff --git a/host/test/fork-worker-import-exceptions.test.ts b/host/test/fork-worker-import-exceptions.test.ts new file mode 100644 index 0000000000..2071fe03fb --- /dev/null +++ b/host/test/fork-worker-import-exceptions.test.ts @@ -0,0 +1,454 @@ +import { Worker } from "node:worker_threads"; +import { describe, expect, it } from "vitest"; +import { + createForkExternrefImportMailbox, + defineForkExternrefImport, + type ForkExternrefImportBinding, + type ForkExternrefImportWake, + ForkExternrefImportOwnerCatalog, + ForkExternrefImportOwnerEndpoint, + ForkExternrefImportWorkerCaller, +} from "../src/fork-externref-import-mailbox"; +import { + ForkWorkerExceptionCapabilityOwner, + ForkWorkerLocalImportExceptionNormalizer, + FORK_WORKER_EXCEPTION_BEGIN_DESCRIPTOR, + FORK_WORKER_EXCEPTION_FORK_CAPTURE_ORDINAL, +} from "../src/fork-worker-import-exceptions"; +import { + isForkWorkerExceptionCapability, + unwrapForkWorkerExceptionCapability, +} from "../src/fork-worker-exception-capability"; +import { + ForkExternrefBroker, + type ForkExternrefGeneration, + ForkExternrefTokenCache, +} from "../src/fork-reference-broker"; + +class TestAuthority { + constructor( + readonly broker: ForkExternrefBroker, + readonly generation: ForkExternrefGeneration, + ) {} + + authorizeForWire( + pid: number, + generationId: number, + handle: number, + ): unknown { + this.assertBinding(pid, generationId); + return this.broker.authorize(this.generation, handle); + } + + registerForWire( + pid: number, + generationId: number, + value: unknown, + ): number { + this.assertBinding(pid, generationId); + return this.broker.register(this.generation, value); + } + + private assertBinding(pid: number, generationId: number): void { + expect(pid).toBe(this.generation.pid); + expect(generationId).toBe(this.generation.id); + } +} + +function harness() { + const broker = new ForkExternrefBroker(); + const generation = broker.createGeneration(301); + const tokens = new ForkExternrefTokenCache(generation.id); + const binding: ForkExternrefImportBinding = { + pid: generation.pid, + generationId: generation.id, + senderId: 41, + }; + const catalog = new ForkExternrefImportOwnerCatalog(); + const exceptionOwner = new ForkWorkerExceptionCapabilityOwner(); + exceptionOwner.install(catalog); + const mailbox = createForkExternrefImportMailbox( + catalog.mailboxCapacity, + ); + const endpoint = new ForkExternrefImportOwnerEndpoint( + mailbox, + binding, + catalog, + new TestAuthority(broker, generation), + { authorizeSender: () => {} }, + ); + const caller = new ForkExternrefImportWorkerCaller( + mailbox, + binding, + tokens, + (wake) => { + expect(endpoint.dispatch(wake, binding)).toBe(true); + }, + ); + const normalizer = new ForkWorkerLocalImportExceptionNormalizer( + caller, + tokens, + ); + return { + broker, + generation, + tokens, + binding, + exceptionOwner, + endpoint, + caller, + normalizer, + }; +} + +function thrownBy(fn: () => unknown): unknown { + let didThrow = false; + let thrown: unknown; + try { + fn(); + } catch (value) { + didThrow = true; + thrown = value; + } + expect(didThrow).toBe(true); + return thrown; +} + +function numberBits(value: number): bigint { + const bytes = new ArrayBuffer(8); + const view = new DataView(bytes); + view.setFloat64(0, value, true); + return view.getBigUint64(0, true); +} + +describe("Worker-local import exception normalization", () => { + it("preserves every primitive before fork and normalizes it exactly for a child", () => { + const state = harness(); + const customNanBytes = new ArrayBuffer(8); + const customNanView = new DataView(customNanBytes); + customNanView.setBigUint64(0, 0x7ff8_0000_0000_0042n, true); + const customNan = customNanView.getFloat64(0, true); + const longString = + `prefix-\ud800-${"reference-state-".repeat(20)}-\udfff-suffix`; + const hugeBigInt = (1n << 1000n) + 0x1234_5678_9abcn; + const globalSymbol = Symbol.for( + `kandelo-${"global-symbol-".repeat(12)}`, + ); + const localSymbol = Symbol(`local-${"symbol-".repeat(20)}`); + const values: unknown[] = [ + undefined, + null, + false, + true, + -0, + customNan, + 17.25, + hugeBigInt, + longString, + globalSymbol, + localSymbol, + ]; + + for (const [ordinal, original] of values.entries()) { + const wrapped = state.normalizer.wrap(ordinal, () => { + throw original; + }); + const importThrown = thrownBy(wrapped); + expect(Object.is(importThrown, original)).toBe(true); + + const token = + state.normalizer.normalizeUnclaimedForkException(importThrown); + const handle = state.tokens.encode(token); + expect(handle).not.toBeNull(); + const capability = state.broker.authorize( + state.generation, + handle!, + ); + expect(isForkWorkerExceptionCapability(capability)).toBe(true); + const boundary = unwrapForkWorkerExceptionCapability(capability); + if (typeof original === "number" && Number.isNaN(original)) { + expect(Number.isNaN(boundary)).toBe(true); + expect(numberBits(boundary as number)).toBe(numberBits(original)); + } else if (typeof original === "symbol") { + if (Symbol.keyFor(original) !== undefined) { + expect(Symbol.keyFor(boundary as symbol)).toBe( + Symbol.keyFor(original), + ); + } else { + expect((boundary as symbol).description).toBe(original.description); + } + } else { + expect(Object.is(boundary, original)).toBe(true); + } + } + expect(state.exceptionOwner.activeSessionCount).toBe(0); + }); + + it("preserves object/function/symbol rethrows and interns one child token", () => { + const state = harness(); + const values: unknown[] = [ + { workerOnly: true }, + function workerOnlyFunction() {}, + Symbol("worker-only-symbol"), + ]; + + for (const [ordinal, original] of values.entries()) { + const wrapped = state.normalizer.wrap(100 + ordinal, () => { + throw original; + }); + const first = thrownBy(wrapped); + const second = thrownBy(wrapped); + expect(first).toBe(original); + expect(second).toBe(original); + expect(state.tokens.encode(first)).toBeNull(); + + const firstToken = + state.normalizer.normalizeUnclaimedForkException(first); + const secondToken = + state.normalizer.normalizeUnclaimedForkException(second); + expect(secondToken).toBe(firstToken); + expect(state.tokens.encode(secondToken)).toBe( + state.tokens.encode(firstToken), + ); + + const capability = state.broker.authorize( + state.generation, + state.tokens.encode(firstToken)!, + ); + if (typeof original === "symbol") { + expect( + (unwrapForkWorkerExceptionCapability(capability) as symbol) + .description, + ).toBe(original.description); + } else { + expect(unwrapForkWorkerExceptionCapability(capability)).toBe( + capability, + ); + } + } + }); + + it("keeps complete Error name/message fields on the opaque capability", () => { + const state = harness(); + const error = new TypeError( + `bad-reference-${"payload-".repeat(30)}`, + ); + const wrapped = state.normalizer.wrap(207, () => { + throw error; + }); + const importThrown = thrownBy(wrapped); + expect(importThrown).toBe(error); + const token = + state.normalizer.normalizeUnclaimedForkException(importThrown); + const capability = state.broker.authorize( + state.generation, + state.tokens.encode(token)!, + ); + + expect(isForkWorkerExceptionCapability(capability)).toBe(true); + expect(capability).toMatchObject({ + sourceImportOrdinal: FORK_WORKER_EXCEPTION_FORK_CAPTURE_ORDINAL, + kind: "error", + name: "TypeError", + message: error.message, + }); + expect(unwrapForkWorkerExceptionCapability(capability)).toBe(capability); + }); + + it("preserves tagged catches before fork and normalizes only an unclaimed tag", () => { + const state = harness(); + const tag = new WebAssembly.Tag({ parameters: ["i32"] }); + const exception = new WebAssembly.Exception(tag, [37]); + + const ordinary = state.normalizer.wrap(208, () => { + throw exception; + }); + expect(thrownBy(ordinary)).toBe(exception); + + // ForkExceptionBroker calls this only after every activation-local exact + // tag codec has declined the exception. + const normalized = + state.normalizer.normalizeUnclaimedForkException(exception); + expect(normalized).not.toBe(exception); + const capability = state.broker.authorize( + state.generation, + state.tokens.encode(normalized)!, + ); + expect(capability).toMatchObject({ + sourceImportOrdinal: FORK_WORKER_EXCEPTION_FORK_CAPTURE_ORDINAL, + kind: "object", + }); + }); + + it("keeps nested Wasm traps fatal rather than turning them into JSTag values", () => { + const state = harness(); + const original = new WebAssembly.RuntimeError("nested Wasm trap"); + const wrapped = state.normalizer.wrap(210, () => { + throw original; + }); + + const trapped = thrownBy(wrapped); + expect(trapped).toBeInstanceOf(WebAssembly.RuntimeError); + expect(trapped).not.toBe(original); + expect(state.tokens.encode(trapped)).toBeNull(); + }); + + it("recreates a distinct child token for the same durable capability", () => { + const state = harness(); + const workerOnly = Object.freeze({ cannotClone: () => 1 }); + const parentToken = + state.normalizer.normalizeUnclaimedForkException(workerOnly); + const handle = state.tokens.encode(parentToken)!; + + const child = state.broker.createGeneration(302); + state.broker.acquireFork(state.generation, child, [handle]); + const childTokens = new ForkExternrefTokenCache(child.id); + const childToken = childTokens.materialize(handle); + + expect(childToken).not.toBe(parentToken); + expect(childTokens.encode(childToken)).toBe(handle); + expect(state.broker.authorize(child, handle)).toBe( + state.broker.authorize(state.generation, handle), + ); + }); + + it("drops incomplete scalar sessions when the exact Worker is torn down", () => { + const state = harness(); + state.caller.call(FORK_WORKER_EXCEPTION_BEGIN_DESCRIPTOR, [ + 1, + 9, + 6, + 0, + 0n, + 100, + 0, + ]); + expect(state.exceptionOwner.activeSessionCount).toBe(1); + + state.exceptionOwner.clearBinding(state.binding); + state.endpoint.close(); + expect(state.exceptionOwner.activeSessionCount).toBe(0); + }); + + it("replays a Worker-only opaque exception through a real fresh child Worker", async () => { + const broker = new ForkExternrefBroker(); + const parentGeneration = broker.createGeneration(501); + const parentBinding: ForkExternrefImportBinding = { + pid: parentGeneration.pid, + generationId: parentGeneration.id, + senderId: 51, + }; + const catalog = new ForkExternrefImportOwnerCatalog(); + const exceptionOwner = new ForkWorkerExceptionCapabilityOwner(); + exceptionOwner.install(catalog); + const echo = defineForkExternrefImport( + 77, + ["externref"], + ["externref"], + ); + catalog.register(echo, (_context, value) => value); + + const runWorker = async ( + mode: "parent" | "child", + generation: ForkExternrefGeneration, + binding: ForkExternrefImportBinding, + inheritedHandle?: number, + ): Promise => { + const mailbox = createForkExternrefImportMailbox( + catalog.mailboxCapacity, + ); + const endpoint = new ForkExternrefImportOwnerEndpoint( + mailbox, + binding, + catalog, + new TestAuthority(broker, generation), + { authorizeSender: () => {} }, + ); + const worker = new Worker( + new URL( + "./fixtures/fork-worker-import-exception-worker.ts", + import.meta.url, + ), + { + execArgv: ["--import", "tsx"], + workerData: { + mode, + binding, + inheritedHandle, + init: { + mailbox, + senderId: binding.senderId, + ownerImports: [{ + module: "host", + name: "echo", + descriptor: echo, + }], + }, + }, + }, + ); + try { + return await new Promise((resolve, reject) => { + const watchdog = setTimeout( + () => reject(new Error(`${mode} Worker watchdog expired`)), + 5_000, + ); + const settle = (action: () => void) => { + clearTimeout(watchdog); + action(); + }; + worker.on("message", (message: { + type: string; + wake?: ForkExternrefImportWake; + handle?: number; + message?: string; + }) => { + if (message.type === "wake") { + if (!endpoint.dispatch(message.wake!, binding)) { + settle(() => + reject(new Error(`${mode} Worker wake was not claimed`)) + ); + } + } else if (message.type === "complete") { + settle(() => resolve(message.handle!)); + } else if (message.type === "failed") { + settle(() => reject(new Error(message.message))); + } + }); + worker.once("error", (error) => settle(() => reject(error))); + }); + } finally { + exceptionOwner.clearBinding(binding); + endpoint.close(); + await worker.terminate(); + } + }; + + const parentHandle = await runWorker( + "parent", + parentGeneration, + parentBinding, + ); + const capability = broker.authorize(parentGeneration, parentHandle); + expect(isForkWorkerExceptionCapability(capability)).toBe(true); + expect(capability).toMatchObject({ + kind: "object", + sourceImportOrdinal: FORK_WORKER_EXCEPTION_FORK_CAPTURE_ORDINAL, + }); + + const childGeneration = broker.createGeneration(502); + broker.acquireFork(parentGeneration, childGeneration, [parentHandle]); + const childHandle = await runWorker( + "child", + childGeneration, + { + pid: childGeneration.pid, + generationId: childGeneration.id, + senderId: 52, + }, + parentHandle, + ); + expect(childHandle).toBe(parentHandle); + expect(broker.authorize(childGeneration, childHandle)).toBe(capability); + }, 12_000); +}); diff --git a/host/test/framebuffer-integration.test.ts b/host/test/framebuffer-integration.test.ts index 487f1a3f4f..cda924e191 100644 --- a/host/test/framebuffer-integration.test.ts +++ b/host/test/framebuffer-integration.test.ts @@ -24,7 +24,11 @@ import { NodePlatformIO } from "../src/platform/node"; import { NodeWorkerAdapter } from "../src/worker-adapter"; import { detectPtrWidth } from "../src/constants"; import { tryResolveBinary } from "../src/binary-resolver"; -import type { CentralizedWorkerInitMessage } from "../src/worker-protocol"; +import type { + CentralizedWorkerInitMessage, + WorkerToHostMessage, +} from "../src/worker-protocol"; +import { TestProcessReferenceOwners } from "./process-reference-owner-helper"; const fbtestBinary = tryResolveBinary("programs/fbtest.wasm") ?? ""; const kernelBinary = tryResolveBinary("kernel.wasm") ?? ""; @@ -55,15 +59,22 @@ describe.skipIf(!existsSync(fbtestBinary))("framebuffer integration", () => { const io = new NodePlatformIO(); const workerAdapter = new NodeWorkerAdapter(); - const workers = new Map>(); + const referenceOwners = new TestProcessReferenceOwners(); + const workers = new Map< + number, + ReturnType + >(); let pid = 0; let stdout = ""; + let stderr = ""; let stdoutResolved = false; let resolveOk: () => void; - const okPromise = new Promise((resolve) => { + let rejectOk: (reason: Error) => void; + const okPromise = new Promise((resolve, reject) => { resolveOk = resolve; + rejectOk = reject; }); let resolveExit: (status: number) => void; const exitPromise = new Promise((resolve) => { @@ -71,11 +82,17 @@ describe.skipIf(!existsSync(fbtestBinary))("framebuffer integration", () => { }); const kernel = new CentralizedKernelWorker( - { maxWorkers: 4, dataBufferSize: 65536, useSharedMemory: true, enableSyscallLog: false }, + { + maxWorkers: 4, + dataBufferSize: 65536, + useSharedMemory: true, + enableSyscallLog: !!process.env.KERNEL_SYSCALL_LOG, + }, io, { onExit: (exitPid, exitStatus) => { if (exitPid === pid) { + referenceOwners.release(exitPid); kernel.unregisterProcess(exitPid); const w = workers.get(exitPid); if (w) { @@ -96,7 +113,9 @@ describe.skipIf(!existsSync(fbtestBinary))("framebuffer integration", () => { resolveOk(); } }, - onStderr: () => {}, + onStderr: (data: Uint8Array) => { + stderr += new TextDecoder().decode(data); + }, }); await kernel.init(kernelWasmBytes); @@ -109,6 +128,7 @@ describe.skipIf(!existsSync(fbtestBinary))("framebuffer integration", () => { new Uint8Array(memory.buffer, channelOffset, CH_TOTAL_SIZE).fill(0); kernel.registerProcess(pid, memory, [channelOffset], { ptrWidth }); + const referenceInit = referenceOwners.start(pid); const initData: CentralizedWorkerInitMessage = { type: "centralized_init", @@ -119,9 +139,28 @@ describe.skipIf(!existsSync(fbtestBinary))("framebuffer integration", () => { argv: ["fbtest"], env: [], ptrWidth, + ...referenceInit, }; const mainWorker = workerAdapter.createWorker(initData); + referenceOwners.attach(pid, mainWorker); + mainWorker.on("error", (error) => rejectOk(error)); + mainWorker.on("message", (raw: unknown) => { + const message = raw as WorkerToHostMessage; + if (message.type === "error" && message.pid === pid) { + rejectOk(new Error(message.message)); + } + }); + mainWorker.on("exit", (code) => { + if (!stdoutResolved) { + rejectOk( + new Error( + `fbtest worker exited with status ${code} before readiness` + + (stderr ? `: ${stderr}` : ""), + ), + ); + } + }); workers.set(pid, mainWorker); try { @@ -129,7 +168,16 @@ describe.skipIf(!existsSync(fbtestBinary))("framebuffer integration", () => { await Promise.race([ okPromise, new Promise((_, reject) => - setTimeout(() => reject(new Error("fbtest didn't print 'ok' in 10s")), 10_000), + setTimeout( + () => + reject( + new Error( + "fbtest didn't print 'ok' in 10s" + + (stderr ? `: ${stderr}` : ""), + ), + ), + 10_000, + ), ), ]); @@ -154,7 +202,7 @@ describe.skipIf(!existsSync(fbtestBinary))("framebuffer integration", () => { // bit of `r << 16` ORs into the alpha byte; the test simply // recomputes the formula so it stays self-consistent. const expected = (r: number, c: number) => - ((0xff000000 | (r << 16) | c) >>> 0); + (0xff000000 | (r << 16) | c) >>> 0; expect(sample(0, 0)).toBe(expected(0, 0)); expect(sample(10, 20)).toBe(expected(10, 20)); expect(sample(255, 255)).toBe(expected(255, 255)); @@ -167,6 +215,7 @@ describe.skipIf(!existsSync(fbtestBinary))("framebuffer integration", () => { // test harness in main-thread mode. } finally { for (const [, w] of workers) await w.terminate().catch(() => {}); + referenceOwners.close(); // Avoid an unhandled-promise warning if the program never exits. void exitPromise.catch(() => {}); } diff --git a/host/test/gc-reference-state-fresh-worker.test.ts b/host/test/gc-reference-state-fresh-worker.test.ts new file mode 100644 index 0000000000..13aaa3cc08 --- /dev/null +++ b/host/test/gc-reference-state-fresh-worker.test.ts @@ -0,0 +1,58 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { runCentralizedProgram } from "./centralized-test-helper"; +import { + RAW_GC_REFERENCE_STATE_FRESH_WORKER_HEX, +} from "./fixtures/gc-reference-state-fresh-worker-bytes"; + +const testDir = dirname(fileURLToPath(import.meta.url)); +const fixtureSource = resolve( + testDir, + "fixtures/gc-reference-state-fresh-worker.wat", +); +const instrumenter = resolve( + testDir, + "../../tools/bin/wasm-fork-instrument", +); + +describe("Wasm GC reference state in a fresh process Worker", () => { + let workDir = ""; + let programPath = ""; + + beforeAll(() => { + workDir = mkdtempSync(join(tmpdir(), "kandelo-gc-reference-worker-")); + const rawPath = join(workDir, "gc-reference-state.raw.wasm"); + programPath = join(workDir, "gc-reference-state.wasm"); + // Keep the source path live in the test contract even though the checked + // byte fixture is required for WABT compatibility. + expect(fixtureSource).toMatch(/gc-reference-state-fresh-worker\.wat$/); + writeFileSync( + rawPath, + Buffer.from(RAW_GC_REFERENCE_STATE_FRESH_WORKER_HEX, "hex"), + ); + execFileSync(instrumenter, [rawPath, "-o", programPath]); + }); + + afterAll(() => { + if (workDir) rmSync(workDir, { recursive: true, force: true }); + }); + + it("preserves one cyclic identity across params, carryovers, globals, and tables", async () => { + const result = await runCentralizedProgram({ + programPath, + argv: ["gc-reference-state-fresh-worker"], + timeout: 30_000, + useDefaultRootfs: false, + }); + + expect( + result.exitCode, + `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ).toBe(0); + expect(result.stderr).toBe(""); + }); +}); diff --git a/host/test/generated-abi.test.ts b/host/test/generated-abi.test.ts index 22a480b015..5391ce188c 100644 --- a/host/test/generated-abi.test.ts +++ b/host/test/generated-abi.test.ts @@ -16,6 +16,8 @@ import { CH_DATA_SIZE, CH_ERRNO, CH_HEADER_SIZE, + CH_REQUEST_FLAGS, + CH_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY, CH_RETURN, CH_SIG_BASE, CH_SIG_FLAGS, @@ -75,8 +77,53 @@ import { WPK_FORK_LINKED_FRAME_POINTER_WIDTHS, WPK_FORK_LINKED_FRAME_RECORD_ALIGNMENT, WPK_FORK_LINKED_FRAME_REQUIRED_FLAGS, + WPK_FORK_MODULE_STATE_ARENA_VERSION, + WPK_FORK_MODULE_STATE_CHUNK_FLAG_ROOT, + WPK_FORK_MODULE_STATE_CHUNK_FLAG_SEALED, + WPK_FORK_MODULE_STATE_CHUNK_KNOWN_FLAGS, + WPK_FORK_MODULE_STATE_CHUNK_MAGIC, + WPK_FORK_MODULE_STATE_DESCRIPTOR_SIZE, + WPK_FORK_MODULE_STATE_ELEMENT_SEGMENT_HEADER_SIZE, + WPK_FORK_MODULE_STATE_FLAG_EXPLICIT_OWNERS, + WPK_FORK_MODULE_STATE_FLAG_ROOT_PREFIX_POINTER, + WPK_FORK_MODULE_STATE_FLAG_SPARSE_TABLES, + WPK_FORK_MODULE_STATE_FORMAT_MAGIC, + WPK_FORK_MODULE_STATE_FORMAT_SECTION, + WPK_FORK_MODULE_STATE_FORMAT_VERSION, + WPK_FORK_MODULE_STATE_KNOWN_FLAGS, + WPK_FORK_MODULE_STATE_MAX_TABLE_PAGE_SHIFT, + WPK_FORK_MODULE_STATE_MIN_TABLE_PAGE_SHIFT, + WPK_FORK_MODULE_STATE_TABLE_PAGE_SHIFT, + WPK_FORK_MODULE_STATE_MODULE_RECORD_KNOWN_FLAGS, + WPK_FORK_MODULE_STATE_MODULE_RECORD_PAYLOAD_SIZE, + WPK_FORK_MODULE_STATE_MODULE_TEMPLATE_ID_SIZE, + WPK_FORK_MODULE_STATE_POINTER_WIDTHS, + WPK_FORK_MODULE_STATE_RECORD_ALIGNMENT, + WPK_FORK_MODULE_STATE_RECORD_HEADER_SIZE, + WPK_FORK_MODULE_STATE_RECORD_KINDS, + WPK_FORK_MODULE_STATE_RECORD_MAGIC, + WPK_FORK_MODULE_STATE_RECORD_VERSION, + WPK_FORK_MODULE_STATE_REQUIRED_FLAGS, + WPK_FORK_MODULE_STATE_ROOT_POINTER_WORD_OFFSET, + WPK_FORK_MODULE_STATE_TABLE_BASELINE_FINGERPRINT_SIZE, + WPK_FORK_MODULE_STATE_TABLE_DESCRIPTOR_PAYLOAD_SIZE, + WPK_FORK_MODULE_STATE_TABLE_FLAG_SPARSE_OVERRIDES, + WPK_FORK_MODULE_STATE_TABLE_KNOWN_FLAGS, + WPK_FORK_MODULE_STATE_TABLE_PAGE_HEADER_SIZE, + WPK_FORK_MODULE_STATE_TABLE_RUN_HEADER_SIZE, WPK_FORK_REQUIRED_EXPORTS, WPK_FORK_REQUIRED_IMPORTS, + WPK_FORK_REQUIRED_TABLE_IMPORTS, + WPK_FORK_STATIC_ROOT_CATALOG_EXPORT, + WPK_FORK_STATIC_ROOT_CATALOG_HEADER_SIZE, + WPK_FORK_STATIC_ROOT_CATALOG_MAGIC, + WPK_FORK_STATIC_ROOT_CATALOG_SECTION, + WPK_FORK_STATIC_ROOT_CATALOG_VERSION, + WPK_FORK_UNWIND_TAG_IMPORT_MODULE, + WPK_FORK_UNWIND_TAG_IMPORT_NAME, + WPK_FORK_UNWIND_TRANSPORT_PAYLOAD_ARITY, + WPK_FORK_UNWIND_TRANSPORT_SECTION, + WPK_FORK_UNWIND_TRANSPORT_VERSION, } from "../src/generated/abi"; const snapshot = JSON.parse( @@ -100,6 +147,14 @@ function statusNumber(name: string): number { return status.number; } +function requestFlag(name: string): number { + const flag = snapshot.channel_request_flags.find( + (entry: { name: string }) => entry.name === name, + ); + if (!flag) throw new Error(`missing channel_request_flags entry ${name}`); + return flag.bit; +} + function signalOffset(name: string): number { const slot = snapshot.channel_signal_area.slots.find((s: { name: string }) => s.name === name); if (!slot) throw new Error(`missing channel_signal_area slot ${name}`); @@ -121,6 +176,8 @@ describe("generated host ABI bindings", () => { const fork = snapshot.program_artifact.fork_instrumentation; const capabilities = fork.capabilities; const descriptor = fork.linked_frame_descriptor; + const staticRoots = fork.static_root_catalog; + const unwind = fork.unwind_transport; expect(WPK_FORK_CAPABILITIES_SECTION).toBe(capabilities.section); expect(WPK_FORK_CAPABILITIES_VERSION).toBe(capabilities.version); expect(WPK_FORK_CAP_KNOWN_MASK).toBe(capabilities.known_mask); @@ -139,6 +196,20 @@ describe("generated host ABI bindings", () => { expect(WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE).toBe(descriptor.descriptor_size); expect(WPK_FORK_LINKED_FRAME_RECORD_ALIGNMENT).toBe(descriptor.alignment); expect(WPK_FORK_LINKED_FRAME_REQUIRED_FLAGS).toBe(descriptor.required_flags); + expect(WPK_FORK_STATIC_ROOT_CATALOG_EXPORT).toBe(staticRoots.export); + expect(WPK_FORK_STATIC_ROOT_CATALOG_HEADER_SIZE).toBe( + staticRoots.header_size, + ); + expect(WPK_FORK_STATIC_ROOT_CATALOG_MAGIC).toEqual(staticRoots.magic_bytes); + expect(WPK_FORK_STATIC_ROOT_CATALOG_SECTION).toBe(staticRoots.section); + expect(WPK_FORK_STATIC_ROOT_CATALOG_VERSION).toBe(staticRoots.version); + expect(WPK_FORK_UNWIND_TAG_IMPORT_MODULE).toBe(unwind.import.module); + expect(WPK_FORK_UNWIND_TAG_IMPORT_NAME).toBe(unwind.import.name); + expect(WPK_FORK_UNWIND_TRANSPORT_PAYLOAD_ARITY).toBe( + unwind.payload_arity, + ); + expect(WPK_FORK_UNWIND_TRANSPORT_SECTION).toBe(unwind.section); + expect(WPK_FORK_UNWIND_TRANSPORT_VERSION).toBe(unwind.version); expect(WPK_FORK_LINKED_FRAME_POINTER_WIDTHS).toEqual( descriptor.pointer_widths.map( (format: { @@ -152,11 +223,129 @@ describe("generated host ABI bindings", () => { }), ), ); - expect(WPK_FORK_REQUIRED_IMPORTS).toEqual( - fork.required_imports.map(({ kind: _kind, ...requirement }: { kind: string }) => - requirement + + const moduleState = fork.module_state; + const moduleStateDescriptor = moduleState.descriptor; + expect(WPK_FORK_MODULE_STATE_FORMAT_SECTION).toBe(moduleStateDescriptor.section); + expect(WPK_FORK_MODULE_STATE_FORMAT_VERSION).toBe(moduleStateDescriptor.version); + expect(WPK_FORK_MODULE_STATE_FORMAT_MAGIC).toEqual(moduleStateDescriptor.magic_bytes); + expect(WPK_FORK_MODULE_STATE_DESCRIPTOR_SIZE).toBe( + moduleStateDescriptor.descriptor_size, + ); + expect(WPK_FORK_MODULE_STATE_RECORD_ALIGNMENT).toBe( + moduleStateDescriptor.alignment, + ); + expect(WPK_FORK_MODULE_STATE_KNOWN_FLAGS).toBe(moduleStateDescriptor.known_flags); + expect(WPK_FORK_MODULE_STATE_REQUIRED_FLAGS).toBe( + moduleStateDescriptor.required_flags, + ); + expect(WPK_FORK_MODULE_STATE_ROOT_POINTER_WORD_OFFSET).toBe( + moduleStateDescriptor.root_pointer_word_offset, + ); + expect([ + { + bit: WPK_FORK_MODULE_STATE_FLAG_ROOT_PREFIX_POINTER, + name: "root_prefix_pointer", + }, + { + bit: WPK_FORK_MODULE_STATE_FLAG_EXPLICIT_OWNERS, + name: "explicit_owners", + }, + { + bit: WPK_FORK_MODULE_STATE_FLAG_SPARSE_TABLES, + name: "sparse_tables", + }, + ]).toEqual(moduleStateDescriptor.flags); + + const moduleStateArena = moduleState.arena; + expect(WPK_FORK_MODULE_STATE_ARENA_VERSION).toBe(moduleStateArena.version); + expect(WPK_FORK_MODULE_STATE_CHUNK_MAGIC).toEqual( + moduleStateArena.chunk_magic_bytes, + ); + expect(WPK_FORK_MODULE_STATE_CHUNK_KNOWN_FLAGS).toBe( + moduleStateArena.known_chunk_flags, + ); + expect([ + { bit: WPK_FORK_MODULE_STATE_CHUNK_FLAG_ROOT, name: "root" }, + { bit: WPK_FORK_MODULE_STATE_CHUNK_FLAG_SEALED, name: "sealed" }, + ]).toEqual(moduleStateArena.chunk_flags); + expect(WPK_FORK_MODULE_STATE_POINTER_WIDTHS).toEqual( + moduleStateArena.pointer_widths.map( + (format: { bytes: number; chunk_header_size: number }) => ({ + bytes: format.bytes, + chunkHeaderSize: format.chunk_header_size, + }), ), ); + expect(WPK_FORK_MODULE_STATE_RECORD_VERSION).toBe( + moduleStateArena.record.version, + ); + expect(WPK_FORK_MODULE_STATE_RECORD_MAGIC).toEqual( + moduleStateArena.record.magic_bytes, + ); + expect(WPK_FORK_MODULE_STATE_RECORD_HEADER_SIZE).toBe( + moduleStateArena.record.header_size, + ); + expect(WPK_FORK_MODULE_STATE_RECORD_ALIGNMENT).toBe( + moduleStateArena.record.alignment, + ); + expect(WPK_FORK_MODULE_STATE_RECORD_KINDS).toEqual( + moduleStateArena.record.kinds, + ); + + const modulePayload = moduleState.record_payloads.module; + expect(WPK_FORK_MODULE_STATE_MODULE_TEMPLATE_ID_SIZE).toBe( + modulePayload.template_id_size, + ); + expect(WPK_FORK_MODULE_STATE_MODULE_RECORD_PAYLOAD_SIZE).toBe( + modulePayload.payload_size, + ); + expect(WPK_FORK_MODULE_STATE_MODULE_RECORD_KNOWN_FLAGS).toBe( + modulePayload.known_flags, + ); + const tablePayload = moduleState.record_payloads.table; + expect(WPK_FORK_MODULE_STATE_TABLE_BASELINE_FINGERPRINT_SIZE).toBe( + tablePayload.baseline_fingerprint_size, + ); + expect(WPK_FORK_MODULE_STATE_TABLE_DESCRIPTOR_PAYLOAD_SIZE).toBe( + tablePayload.descriptor_payload_size, + ); + expect(WPK_FORK_MODULE_STATE_TABLE_KNOWN_FLAGS).toBe(tablePayload.known_flags); + expect([ + { + bit: WPK_FORK_MODULE_STATE_TABLE_FLAG_SPARSE_OVERRIDES, + name: "sparse_overrides", + }, + ]).toEqual(tablePayload.flags); + expect(WPK_FORK_MODULE_STATE_TABLE_PAGE_HEADER_SIZE).toBe( + tablePayload.page_header_size, + ); + expect(WPK_FORK_MODULE_STATE_TABLE_RUN_HEADER_SIZE).toBe( + tablePayload.run_header_size, + ); + expect(WPK_FORK_MODULE_STATE_MIN_TABLE_PAGE_SHIFT).toBe( + tablePayload.min_page_shift, + ); + expect(WPK_FORK_MODULE_STATE_MAX_TABLE_PAGE_SHIFT).toBe( + tablePayload.max_page_shift, + ); + expect(WPK_FORK_MODULE_STATE_TABLE_PAGE_SHIFT).toBe( + tablePayload.page_shift, + ); + expect(WPK_FORK_MODULE_STATE_ELEMENT_SEGMENT_HEADER_SIZE).toBe( + moduleState.record_payloads.element_segments.header_size, + ); + + expect(WPK_FORK_REQUIRED_IMPORTS).toEqual( + fork.required_imports + .filter(({ kind }: { kind: string }) => kind === "func") + .map(({ kind: _kind, ...requirement }: { kind: string }) => requirement), + ); + expect(WPK_FORK_REQUIRED_TABLE_IMPORTS).toEqual( + fork.required_imports + .filter(({ kind }: { kind: string }) => kind === "table") + .map(({ kind: _kind, ...requirement }: { kind: string }) => requirement), + ); expect(WPK_FORK_REQUIRED_EXPORTS).toEqual( fork.required_exports.map(({ kind: _kind, ...requirement }: { kind: string }) => requirement @@ -170,6 +359,7 @@ describe("generated host ABI bindings", () => { it("match the ABI version and channel layout snapshot", () => { expect(ABI_VERSION).toBe(snapshot.abi_version); expect(snapshot.custom_sections).toContain(ABI_CUSTOM_SECTION); + expect(snapshot.custom_sections).toContain(WPK_FORK_MODULE_STATE_FORMAT_SECTION); expect(snapshot.kernel_exports.some((e: { name: string }) => e.name === ABI_KERNEL_EXPORT)).toBe(true); expect(CH_STATUS).toBe(fieldOffset("status")); @@ -177,6 +367,10 @@ describe("generated host ABI bindings", () => { expect(CH_ARGS).toBe(fieldOffset("args")); expect(CH_RETURN).toBe(fieldOffset("ret")); expect(CH_ERRNO).toBe(fieldOffset("errno")); + expect(CH_REQUEST_FLAGS).toBe(fieldOffset("request_flags")); + expect(CH_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY).toBe( + requestFlag("defer_signal_delivery"), + ); expect(CH_ARG_SIZE).toBe(8); expect(CH_ARGS_COUNT).toBe(6); diff --git a/host/test/mouse-integration.test.ts b/host/test/mouse-integration.test.ts index c12e2b6e20..a9b266fa04 100644 --- a/host/test/mouse-integration.test.ts +++ b/host/test/mouse-integration.test.ts @@ -25,7 +25,11 @@ import { CAPTURED_STDIO, CentralizedKernelWorker } from "../src/kernel-worker"; import { NodePlatformIO } from "../src/platform/node"; import { NodeWorkerAdapter } from "../src/worker-adapter"; import { detectPtrWidth } from "../src/constants"; -import type { CentralizedWorkerInitMessage } from "../src/worker-protocol"; +import type { + CentralizedWorkerInitMessage, + WorkerToHostMessage, +} from "../src/worker-protocol"; +import { TestProcessReferenceOwners } from "./process-reference-owner-helper"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -69,26 +73,40 @@ describe.skipIf(!existsSync(mousetestBinary))("mouse integration", () => { const io = new NodePlatformIO(); const workerAdapter = new NodeWorkerAdapter(); - const workers = new Map>(); + const referenceOwners = new TestProcessReferenceOwners(); + const workers = new Map< + number, + ReturnType + >(); let pid = 0; let stdout = ""; let resolveReady: () => void; - const readyPromise = new Promise((resolve) => { + let rejectReady: (reason: Error) => void; + const readyPromise = new Promise((resolve, reject) => { resolveReady = resolve; + rejectReady = reject; }); let resolveExit: (status: number) => void; - const exitPromise = new Promise((resolve) => { + let rejectExit: (reason: Error) => void; + const exitPromise = new Promise((resolve, reject) => { resolveExit = resolve; + rejectExit = reject; }); const kernel = new CentralizedKernelWorker( - { maxWorkers: 4, dataBufferSize: 65536, useSharedMemory: true, enableSyscallLog: false }, + { + maxWorkers: 4, + dataBufferSize: 65536, + useSharedMemory: true, + enableSyscallLog: false, + }, io, { onExit: (exitPid, exitStatus) => { if (exitPid === pid) { + referenceOwners.release(exitPid); kernel.unregisterProcess(exitPid); const w = workers.get(exitPid); if (w) { @@ -122,6 +140,7 @@ describe.skipIf(!existsSync(mousetestBinary))("mouse integration", () => { new Uint8Array(memory.buffer, channelOffset, CH_TOTAL_SIZE).fill(0); kernel.registerProcess(pid, memory, [channelOffset], { ptrWidth }); + const referenceInit = referenceOwners.start(pid); const initData: CentralizedWorkerInitMessage = { type: "centralized_init", @@ -132,16 +151,32 @@ describe.skipIf(!existsSync(mousetestBinary))("mouse integration", () => { argv: ["mousetest", "3"], env: [], ptrWidth, + ...referenceInit, }; const mainWorker = workerAdapter.createWorker(initData); + referenceOwners.attach(pid, mainWorker); + const rejectWorker = (error: Error): void => { + rejectReady(error); + rejectExit(error); + }; + mainWorker.on("error", rejectWorker); + mainWorker.on("message", (raw: unknown) => { + const message = raw as WorkerToHostMessage; + if (message.type === "error" && message.pid === pid) { + rejectWorker(new Error(message.message)); + } + }); workers.set(pid, mainWorker); try { await Promise.race([ readyPromise, new Promise((_, reject) => - setTimeout(() => reject(new Error("mousetest didn't print 'ready' in 10s")), 10_000), + setTimeout( + () => reject(new Error("mousetest didn't print 'ready' in 10s")), + 10_000, + ), ), ]); @@ -161,7 +196,11 @@ describe.skipIf(!existsSync(mousetestBinary))("mouse integration", () => { const exitCode = await Promise.race([ exitPromise, new Promise((_, reject) => - setTimeout(() => reject(new Error("mousetest didn't exit after 3 packets in 10s")), 10_000), + setTimeout( + () => + reject(new Error("mousetest didn't exit after 3 packets in 10s")), + 10_000, + ), ), ]); expect(exitCode).toBe(0); @@ -173,11 +212,15 @@ describe.skipIf(!existsSync(mousetestBinary))("mouse integration", () => { expect(lines.slice(1)).toHaveLength(3); for (let i = 0; i < 3; i++) { const ev = events[i]; - const b0 = expectedByte0(ev.dx, ev.dy, ev.buttons).toString(16).padStart(2, "0"); + const b0 = expectedByte0(ev.dx, ev.dy, ev.buttons) + .toString(16) + .padStart(2, "0"); expect(lines[1 + i]).toBe(`pkt ${b0} ${ev.dx} ${ev.dy}`); } } finally { for (const [, w] of workers) await w.terminate().catch(() => {}); + referenceOwners.close(); + void readyPromise.catch(() => {}); void exitPromise.catch(() => {}); } }, 30_000); diff --git a/host/test/patch-wasm-for-thread-gc.test.ts b/host/test/patch-wasm-for-thread-gc.test.ts new file mode 100644 index 0000000000..2fa2f2400f --- /dev/null +++ b/host/test/patch-wasm-for-thread-gc.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { patchWasmForThread } from "../src/worker-main"; + +function uleb(value: number): number[] { + const bytes: number[] = []; + do { + let byte = value & 0x7f; + value >>>= 7; + if (value !== 0) byte |= 0x80; + bytes.push(byte); + } while (value !== 0); + return bytes; +} + +function name(value: string): number[] { + const bytes = new TextEncoder().encode(value); + return [...uleb(bytes.byteLength), ...bytes]; +} + +function section(id: number, payload: number[]): number[] { + return [id, ...uleb(payload.length), ...payload]; +} + +function body(instructions: number[], locals: number[] = []): number[] { + const payload = [ + ...uleb(locals.length === 0 ? 0 : 1), + ...(locals.length === 0 ? [] : [...uleb(1), ...locals]), + ...instructions, + 0x0b, + ]; + return [...uleb(payload.length), ...payload]; +} + +/** + * Current core-GC encoding, built directly because the repository's WABT + * release still emits an older experimental GC binary format. + */ +function gcThreadStartFixture(): ArrayBuffer { + const types = [ + ...uleb(1), // one recursive group + 0x4e, + ...uleb(4), + // type 0: (struct (field (mut (ref null 0)))) + 0x5f, ...uleb(1), 0x63, 0x00, 0x01, + // type 1: (array (mut i32)) + 0x5e, 0x7f, 0x01, + // type 2: (func) + 0x60, 0x00, 0x00, + // type 3: (func (param i32)), valid exception-tag type + 0x60, 0x01, 0x7f, 0x00, + ]; + const imports = [ + ...uleb(4), + ...name("env"), ...name("imported_function"), 0x00, ...uleb(2), + ...name("env"), ...name("gc_table"), 0x01, + 0x63, 0x00, // concrete nullable reference + 0x00, ...uleb(1), // limits: min 1 + ...name("env"), ...name("gc_global"), 0x03, + 0x63, 0x00, // concrete nullable reference + 0x01, // mutable + ...name("env"), ...name("event"), 0x04, + 0x00, ...uleb(3), // tag attribute + function type + ]; + const functions = [ + ...uleb(4), + ...uleb(2), // ctor + ...uleb(2), // __abi_version + ...uleb(2), // __get_channel_base_addr + ...uleb(2), // _start + ]; + const exports = [ + ...uleb(3), + ...name("__abi_version"), 0x00, ...uleb(2), + ...name("__get_channel_base_addr"), 0x00, ...uleb(3), + ...name("_start"), 0x00, ...uleb(4), + ]; + const code = [ + ...uleb(4), + ...body([]), + ...body([0x10, ...uleb(1)], [0x63, 0x00]), + ...body([0x10, ...uleb(1)], [0x63, 0x00]), + ...body([0x10, ...uleb(1)], [0x63, 0x00]), + ]; + return new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, + 0x01, 0x00, 0x00, 0x00, + ...section(1, types), + ...section(2, imports), + ...section(3, functions), + ...section(7, exports), + ...section(8, uleb(1)), + ...section(10, code), + ]).buffer; +} + +describe("patchWasmForThread GC/reference parsing", () => { + it("keeps section and function indexes aligned across concrete ref and tag imports", () => { + const source = gcThreadStartFixture(); + expect(WebAssembly.validate(source)).toBe(true); + const patched = patchWasmForThread(source); + expect(WebAssembly.validate(patched)).toBe(true); + expect(patched.byteLength).toBeLessThan(source.byteLength); + }); +}); diff --git a/host/test/plain-catch-payload-lifetime.test.ts b/host/test/plain-catch-payload-lifetime.test.ts index 510cc299b1..192308b90e 100644 --- a/host/test/plain-catch-payload-lifetime.test.ts +++ b/host/test/plain-catch-payload-lifetime.test.ts @@ -8,6 +8,11 @@ import { LinkedForkContinuation, readLinkedFrameFormat, } from "../src/fork-continuation"; +import { + ForkModuleStateArena, + readForkModuleStateRoot, +} from "../src/fork-module-state"; +import { SingleActivationForkRuntime } from "./fork-instrument-runtime-harness"; const FIRST_PAYLOAD = 0x1234; const SECOND_PAYLOAD = 0x5678; @@ -54,7 +59,8 @@ describe("plain-catch payload lifetime", () => { ); execFileSync(instrumenterPath, [rawPath, "-o", instrumentedPath]); - const module = new WebAssembly.Module(readFileSync(instrumentedPath)); + const instrumentedBytes = readFileSync(instrumentedPath); + const module = new WebAssembly.Module(instrumentedBytes); const memory = new WebAssembly.Memory({ initial: 4 }); const view = new DataView(memory.buffer); let instance: WebAssembly.Instance; @@ -70,6 +76,25 @@ describe("plain-catch payload lifetime", () => { () => {}, "plain-catch-lifetime", ); + let nextArenaAddress = 2 * 65_536; + const runtime = new SingleActivationForkRuntime({ + module, + moduleBytes: instrumentedBytes, + memory, + continuation, + newArena: () => new ForkModuleStateArena( + memory, + 4, + (size) => { + const address = nextArenaAddress; + nextArenaAddress += size; + return address; + }, + () => {}, + "plain-catch-lifetime module state", + ), + label: "plain-catch-lifetime", + }); const scratchIsSentinel = (base: number): boolean => view.getUint32(base + SCRATCH_ARM_OFFSET, true) === SENTINEL && @@ -82,19 +107,12 @@ describe("plain-catch payload lifetime", () => { instance = new WebAssembly.Instance(module, { env: { memory, - __wpk_fork_frame_reserve: (size: number) => - continuation.reserveFrame(size), - __wpk_fork_frame_commit: (payload: number) => - continuation.commitFrame(payload), - __wpk_fork_frame_next: (size: number) => - continuation.nextFrame(size), + ...runtime.envImports, }, kernel: { kernel_fork: () => { - const state = (instance.exports.wpk_fork_state as () => number)(); - if (state === 2) { - (instance.exports.wpk_fork_rewind_end as () => void)(); - continuation.finishReplayAndRelease(); + if (runtime.coordinator.phaseName() === "parent-replay") { + runtime.coordinator.finishReplay(); return normalForkCalls === 1 ? 7 : 11; } @@ -106,26 +124,21 @@ describe("plain-catch payload lifetime", () => { secondCaptureKeptReleasedMemory = scratchIsSentinel(moduleBuffer); } - moduleBuffer = Number(continuation.beginUnwind()); - (instance.exports.wpk_fork_unwind_begin as (addr: number) => void)( - moduleBuffer, - ); + runtime.beginCapture(); + moduleBuffer = runtime.coordinator.rootFor(0); return 0; }, }, }); + runtime.register(instance); const run = instance.exports.run as (payload: number) => number; fillScratch(0); - // The first pass drains frames and returns the function's result default. - expect(run(FIRST_PAYLOAD)).toBe(0); - (instance.exports.wpk_fork_unwind_end as () => void)(); - continuation.finishUnwind(); - continuation.beginReplay(); - (instance.exports.wpk_fork_rewind_begin as (addr: number) => void)( - moduleBuffer, - ); + // The first pass transports the private unwind to the worker boundary. + runtime.expectCaptureTransport(() => run(FIRST_PAYLOAD)); + runtime.coordinator.sealCapture(); + runtime.coordinator.beginParentReplay(); expect(run(FIRST_PAYLOAD)).toBe(FIRST_PAYLOAD + 7); // Once replay releases the continuation, its former bytes are no longer @@ -133,13 +146,9 @@ describe("plain-catch payload lifetime", () => { // mutate either that retired mapping or low memory before its fork call. fillScratch(0); fillScratch(moduleBuffer); - expect(run(SECOND_PAYLOAD)).toBe(0); - (instance.exports.wpk_fork_unwind_end as () => void)(); - continuation.finishUnwind(); - continuation.beginReplay(); - (instance.exports.wpk_fork_rewind_begin as (addr: number) => void)( - moduleBuffer, - ); + runtime.expectCaptureTransport(() => run(SECOND_PAYLOAD)); + runtime.coordinator.sealCapture(); + runtime.coordinator.beginParentReplay(); expect(run(SECOND_PAYLOAD)).toBe(SECOND_PAYLOAD + 11); expect({ @@ -212,11 +221,11 @@ describe("plain-catch payload lifetime", () => { ); execFileSync(instrumenterPath, [rawPath, "-o", instrumentedPath]); - const module = new WebAssembly.Module(readFileSync(instrumentedPath)); + const instrumentedBytes = readFileSync(instrumentedPath); + const module = new WebAssembly.Module(instrumentedBytes); const memory = new WebAssembly.Memory({ initial: 4 }); const view = new DataView(memory.buffer); let instance: WebAssembly.Instance; - let moduleBuffer = 0; let normalForkCalls = 0; let recursiveCaptureKeptLowMemory = false; const continuation = new LinkedForkContinuation( @@ -226,48 +235,49 @@ describe("plain-catch payload lifetime", () => { () => {}, "plain-catch-recursion", ); + const runtime = new SingleActivationForkRuntime({ + module, + moduleBytes: instrumentedBytes, + memory, + continuation, + newArena: () => new ForkModuleStateArena( + memory, + 4, + () => 2 * 65_536, + () => {}, + "plain-catch-recursion module state", + ), + label: "plain-catch-recursion", + }); instance = new WebAssembly.Instance(module, { env: { memory, - __wpk_fork_frame_reserve: (size: number) => - continuation.reserveFrame(size), - __wpk_fork_frame_commit: (payload: number) => - continuation.commitFrame(payload), - __wpk_fork_frame_next: (size: number) => - continuation.nextFrame(size), + ...runtime.envImports, }, kernel: { kernel_fork: () => { - const state = (instance.exports.wpk_fork_state as () => number)(); - if (state === 2) { - (instance.exports.wpk_fork_rewind_end as () => void)(); - continuation.finishReplayAndRelease(); + if (runtime.coordinator.phaseName() === "parent-replay") { + runtime.coordinator.finishReplay(); return 7; } normalForkCalls++; recursiveCaptureKeptLowMemory = view.getUint32(SCRATCH_ARM_OFFSET, true) === SENTINEL && view.getUint32(SCRATCH_PAYLOAD_OFFSET, true) === SENTINEL; - moduleBuffer = Number(continuation.beginUnwind()); - (instance.exports.wpk_fork_unwind_begin as (addr: number) => void)( - moduleBuffer, - ); + runtime.beginCapture(); return 0; }, }, }); + runtime.register(instance); const run = instance.exports.run as (depth: number) => number; view.setUint32(SCRATCH_ARM_OFFSET, SENTINEL, true); view.setUint32(SCRATCH_PAYLOAD_OFFSET, SENTINEL, true); - expect(run(2)).toBe(0); - (instance.exports.wpk_fork_unwind_end as () => void)(); - continuation.finishUnwind(); - continuation.beginReplay(); - (instance.exports.wpk_fork_rewind_begin as (addr: number) => void)( - moduleBuffer, - ); + runtime.expectCaptureTransport(() => run(2)); + runtime.coordinator.sealCapture(); + runtime.coordinator.beginParentReplay(); // WHY: all three calls execute the same static catch arm, but each // activation owns a distinct payload (102, 101, 100). The result slots @@ -342,12 +352,14 @@ describe("plain-catch payload lifetime", () => { new URL("../../tools/bin/wasm-fork-instrument", import.meta.url), ); execFileSync(instrumenterPath, [rawPath, "-o", instrumentedPath]); - const module = new WebAssembly.Module(readFileSync(instrumentedPath)); + const instrumentedBytes = readFileSync(instrumentedPath); + const module = new WebAssembly.Module(instrumentedBytes); const runOrder = (modes: readonly number[]): void => { const parentMemory = new WebAssembly.Memory({ initial: 4 }); let parentInstance: WebAssembly.Instance; let moduleBuffer = 0; + let nextParentArenaAddress = 2 * 65_536; const parentContinuation = new LinkedForkContinuation( parentMemory, readLinkedFrameFormat(module), @@ -355,40 +367,52 @@ describe("plain-catch payload lifetime", () => { () => {}, "mixed-catch-parent", ); + const parentRuntime = new SingleActivationForkRuntime({ + module, + moduleBytes: instrumentedBytes, + memory: parentMemory, + continuation: parentContinuation, + newArena: () => new ForkModuleStateArena( + parentMemory, + 4, + (size) => { + const address = nextParentArenaAddress; + nextParentArenaAddress += size; + return address; + }, + () => {}, + "mixed-catch-parent module state", + ), + label: "mixed-catch-parent", + }); parentInstance = new WebAssembly.Instance(module, { env: { memory: parentMemory, - __wpk_fork_frame_reserve: (size: number) => - parentContinuation.reserveFrame(size), - __wpk_fork_frame_commit: (payload: number) => - parentContinuation.commitFrame(payload), - __wpk_fork_frame_next: (size: number) => - parentContinuation.nextFrame(size), + ...parentRuntime.envImports, }, kernel: { kernel_fork: () => { - moduleBuffer = Number(parentContinuation.beginUnwind()); - (parentInstance.exports.wpk_fork_unwind_begin as (addr: number) => void)( - moduleBuffer, - ); + parentRuntime.beginCapture(); + moduleBuffer = parentRuntime.coordinator.rootFor(0); return 0; }, }, }); + parentRuntime.register(parentInstance); const parentRun = parentInstance.exports.run as (takePlain: number) => number; for (const mode of modes) { - expect(parentRun(mode)).toBe(0); - (parentInstance.exports.wpk_fork_unwind_end as () => void)(); - parentContinuation.finishUnwind(); + parentRuntime.expectCaptureTransport(() => parentRun(mode)); + parentRuntime.coordinator.sealCapture(); // Model the actual worker boundary: the child receives only copied // linear memory and instantiates an otherwise fresh Wasm module. - const childMemory = new WebAssembly.Memory({ initial: 4 }); + const childMemory = new WebAssembly.Memory({ + initial: parentMemory.buffer.byteLength / 65_536, + }); new Uint8Array(childMemory.buffer).set( new Uint8Array(parentMemory.buffer), ); - parentContinuation.cancelUnwindAndRelease(); const childContinuation = new LinkedForkContinuation( childMemory, @@ -399,34 +423,54 @@ describe("plain-catch payload lifetime", () => { () => {}, "mixed-catch-child", ); - childContinuation.attachForReplay(moduleBuffer); + const childRuntime = new SingleActivationForkRuntime({ + module, + moduleBytes: instrumentedBytes, + memory: childMemory, + continuation: childContinuation, + newArena: () => { + throw new Error( + "fresh child replay must attach copied module state", + ); + }, + label: "mixed-catch-child", + }); let childInstance: WebAssembly.Instance; childInstance = new WebAssembly.Instance(module, { env: { memory: childMemory, - __wpk_fork_frame_reserve: (size: number) => - childContinuation.reserveFrame(size), - __wpk_fork_frame_commit: (payload: number) => - childContinuation.commitFrame(payload), - __wpk_fork_frame_next: (size: number) => - childContinuation.nextFrame(size), + ...childRuntime.envImports, }, kernel: { kernel_fork: () => { - expect( - (childInstance.exports.wpk_fork_state as () => number)(), - ).toBe(2); - (childInstance.exports.wpk_fork_rewind_end as () => void)(); - childContinuation.finishReplayAndRelease(); + expect(childRuntime.coordinator.phaseName()).toBe( + "child-replay", + ); + childRuntime.coordinator.finishReplay(); return 7; }, }, }); - (childInstance.exports.wpk_fork_rewind_begin as (addr: number) => void)( - moduleBuffer, + childRuntime.register(childInstance, { bootstrap: false }); + childRuntime.setCopiedProcessLaunchRoot(moduleBuffer); + const copiedArena = new ForkModuleStateArena( + childMemory, + 4, + () => { + throw new Error( + "fresh child replay must not allocate module-state storage", + ); + }, + () => {}, + "mixed-catch-child module state", + ); + copiedArena.attach( + readForkModuleStateRoot(childMemory, moduleBuffer, 4), ); + childRuntime.coordinator.attachChild(copiedArena); const childRun = childInstance.exports.run as (takePlain: number) => number; expect(childRun(mode)).toBe((mode ? 41 : 42) + 7); + parentRuntime.coordinator.abort(); } }; diff --git a/host/test/process-reference-owner-helper.ts b/host/test/process-reference-owner-helper.ts new file mode 100644 index 0000000000..8c11e4d130 --- /dev/null +++ b/host/test/process-reference-owner-helper.ts @@ -0,0 +1,130 @@ +import { + ForkHostImportOwnerRuntime, + type ForkHostImportOwnerWorker, + type ForkHostImportWorkerInit, +} from "../src/fork-host-import-runtime"; +import { ForkExternrefProcessOwner } from "../src/fork-externref-process-owner"; +import type { ForkExternrefGeneration } from "../src/fork-reference-broker"; +import type { WorkerHandle } from "../src/worker-adapter"; +import type { WorkerToHostMessage } from "../src/worker-protocol"; + +export interface TestProcessReferenceInit { + readonly externrefGenerationId: number; + readonly forkHostImports: ForkHostImportWorkerInit; +} + +interface TestProcessReferenceRecord { + readonly generation: ForkExternrefGeneration; + readonly imports: ForkHostImportOwnerWorker; + worker?: WorkerHandle; + messageHandler?: (message: unknown) => void; +} + +/** + * Process-owned reference authority for tests that spawn process Workers + * directly instead of using NodeKernelHost or BrowserKernelHost. + */ +export class TestProcessReferenceOwners { + private readonly owner = new ForkExternrefProcessOwner(); + private readonly runtime = new ForkHostImportOwnerRuntime(this.owner); + private readonly records = new Map(); + + start(pid: number): TestProcessReferenceInit { + return this.install(this.owner.startGeneration(pid)); + } + + fork( + parentPid: number, + childPid: number, + memory: WebAssembly.Memory, + ptrWidth: 4 | 8, + moduleBufferAddress: number, + ): TestProcessReferenceInit { + const parent = this.records.get(parentPid); + if (!parent) { + throw new Error( + `missing test reference owner for fork parent ${parentPid}`, + ); + } + const child = this.owner.forkGenerationFromContinuation( + parent.generation, + childPid, + memory, + ptrWidth, + moduleBufferAddress, + `direct-worker test fork child pid=${childPid}`, + ).generation; + return this.install(child); + } + + attach(pid: number, worker: WorkerHandle): void { + const record = this.requireRecord(pid); + if (record.worker !== undefined) { + throw new Error( + `test reference owner for pid=${pid} is already attached`, + ); + } + record.worker = worker; + const messageHandler = (raw: unknown): void => { + const message = raw as WorkerToHostMessage; + if ( + message.type === "fork_host_import" && + this.records.get(pid) === record && + record.worker === worker + ) { + record.imports.dispatch(message.wake); + } + }; + record.messageHandler = messageHandler; + worker.on("message", messageHandler); + } + + release(pid: number): void { + const record = this.records.get(pid); + if (!record) return; + this.records.delete(pid); + if (record.worker && record.messageHandler) { + record.worker.off("message", record.messageHandler); + } + record.imports.close(); + this.owner.releaseGeneration(record.generation); + } + + close(): void { + for (const pid of [...this.records.keys()]) this.release(pid); + } + + private install( + generation: ForkExternrefGeneration, + ): TestProcessReferenceInit { + const pid = generation.pid; + if (this.records.has(pid)) { + this.owner.releaseGeneration(generation); + throw new Error(`duplicate test reference owner for pid=${pid}`); + } + let record: TestProcessReferenceRecord; + const imports = this.runtime.createWorker({ + pid, + generationId: generation.id, + authorizeSender: () => { + // WHY: numeric PID/generation/sender fields arrive through shared + // memory. The independently observed Worker object is the authority. + if (this.records.get(pid) !== record || record.worker === undefined) { + throw new Error(`stale direct-worker test sender for pid=${pid}`); + } + }, + }); + record = { generation, imports }; + this.records.set(pid, record); + return { + externrefGenerationId: generation.id, + forkHostImports: imports.init, + }; + } + + private requireRecord(pid: number): TestProcessReferenceRecord { + const record = this.records.get(pid); + if (!record) throw new Error(`missing test reference owner for pid=${pid}`); + return record; + } +} diff --git a/host/test/process-table-replication.test.ts b/host/test/process-table-replication.test.ts new file mode 100644 index 0000000000..78f5ffae70 --- /dev/null +++ b/host/test/process-table-replication.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from "vitest"; +import type { DlopenSupport } from "../src/worker-main"; +import { + __testCreateProcessTableReplicationOwner, +} from "../src/worker-main"; +import { + DylinkForkArchive, + type DylinkForkTablePatch, +} from "../src/dylink-fork-archive"; +import type { ForkActivationRegistry } from "../src/fork-activation-registry"; +import type { ForkModuleStateArena } from "../src/fork-module-state"; + +interface TestTableReplicationOwner { + beginMutation(): bigint; + commit( + activationId: number, + ownerId: number, + firstIndex: number | bigint, + length: number | bigint, + ): void; + reconcileNow(): number; +} + +function archiveFixture() { + const memory = new WebAssembly.Memory({ initial: 8, maximum: 8 }); + let head = 0; + let next = 4_096; + const archive = new DylinkForkArchive( + memory, + 4, + () => head, + (value) => { head = value; }, + (size) => { + const address = next; + next += Math.ceil(size / 8) * 8; + return { address, size }; + }, + () => {}, + "process table test archive", + ); + archive.sync({ nextHandle: 2, libraries: [] }); + return { archive, memory }; +} + +function dlopenFixture(archive: DylinkForkArchive): DlopenSupport { + let writerDepth = 0; + let readerDepth = 0; + let writerObserver = () => {}; + return { + imports: {}, + readForkState: () => archive.read(), + replayDlopens: () => {}, + resetForkChildLock: () => {}, + archive, + acquireArchiveWriter: () => { + if (writerDepth++ === 0) writerObserver(); + }, + releaseArchiveWriter: () => { + if (writerDepth <= 0) throw new Error("writer underflow"); + writerDepth--; + }, + acquireArchiveReader: () => { readerDepth++; }, + releaseArchiveReader: () => { + if (readerDepth <= 0) throw new Error("reader underflow"); + readerDepth--; + }, + withArchiveWriter: (operation: () => T): T => { + if (writerDepth++ === 0) writerObserver(); + try { + return operation(); + } finally { + writerDepth--; + } + }, + withArchiveReader: (operation: () => T): T => { + readerDepth++; + try { + return operation(); + } finally { + readerDepth--; + } + }, + writerOwned: () => writerDepth > 0, + setWriterAcquireObserver: (observer) => { writerObserver = observer; }, + setOperationAbortObserver: () => {}, + setCommitObserver: () => {}, + }; +} + +function arenaFixture(root: number): ForkModuleStateArena { + let active = false; + return { + begin: () => { + active = true; + return root; + }, + attach: () => { active = true; }, + release: () => { active = false; }, + hasActiveArena: () => active, + } as unknown as ForkModuleStateArena; +} + +function patch(generation?: number): DylinkForkTablePatch { + return { + ...(generation === undefined ? {} : { generation }), + activationId: 0, + ownerId: 1, + start: 0, + tableLength: 1, + runs: [{ + length: 1, + function: { activationId: 0, ordinal: 0 }, + }], + }; +} + +describe("process table replication publication", () => { + it("uses patches normally and transparently compacts at the journal bound", () => { + const { archive } = archiveFixture(); + const dlopen = dlopenFixture(archive); + let checkpoints = 0; + let typedFallback = false; + const registry = { + captureFuncrefTablePatch: () => typedFallback ? null : patch(), + captureTableState: () => { + checkpoints++; + return 512; + }, + restoreTableState: () => {}, + applyFuncrefTablePatch: () => {}, + } as unknown as ForkActivationRegistry; + const owner = __testCreateProcessTableReplicationOwner({ + generationAddress: 64, + registry, + dlopen, + newArena: () => arenaFixture(512), + materializeModules: () => {}, + restoreSnapshots: true, + label: "patch writer", + }) as TestTableReplicationOwner; + + for (let index = 0; index < 256; index++) { + owner.beginMutation(); + owner.commit(0, 1, 0, 1); + } + expect(archive.read().tablePatches).toHaveLength(256); + expect(checkpoints).toBe(0); + + owner.beginMutation(); + owner.commit(0, 1, 0, 1); + expect(checkpoints).toBe(1); + expect(archive.read()).toMatchObject({ + tableStateRoot: 512, + tablePatches: [], + }); + + typedFallback = true; + owner.beginMutation(); + owner.commit(0, 1, 0, 1); + expect(checkpoints).toBe(2); + expect(archive.read().tablePatches).toEqual([]); + }); + + it("skips only the fork child's copied baseline and applies later patches", () => { + const { archive } = archiveFixture(); + archive.publishTablePatch(patch()); + const applied: number[] = []; + const registry = { + applyFuncrefTablePatch: (value: DylinkForkTablePatch) => { + applied.push(value.generation!); + }, + restoreTableState: () => { + throw new Error("fork child must use its normal KFMS capture"); + }, + } as unknown as ForkActivationRegistry; + const child = __testCreateProcessTableReplicationOwner({ + generationAddress: 64, + registry, + dlopen: dlopenFixture(archive), + newArena: () => arenaFixture(512), + materializeModules: () => {}, + restoreSnapshots: false, + label: "fork child patch reader", + }) as TestTableReplicationOwner; + + child.reconcileNow(); + expect(applied).toEqual([]); + archive.publishTablePatch(patch()); + child.reconcileNow(); + expect(applied).toEqual([3]); + }); +}); diff --git a/host/test/signal-accept-livelock.test.ts b/host/test/signal-accept-livelock.test.ts index 1dd35fce10..b82c5771ab 100644 --- a/host/test/signal-accept-livelock.test.ts +++ b/host/test/signal-accept-livelock.test.ts @@ -23,7 +23,10 @@ import { CH_ARGS, CH_ARG_SIZE, CH_ERRNO, + CH_REQUEST_FLAGS, + CH_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY, CH_RETURN, + CH_SIG_SIGNUM, CH_SYSCALL, } from "../src/generated/abi"; @@ -220,6 +223,38 @@ describe("signal delivery to a process blocked in accept()", () => { expect(dequeueSignal).toHaveBeenCalledWith(pid, pid, expect.any(Number)); }); + it("hands a deferred signal from a JavaScript completion to the next guest checkpoint", () => { + const worker = createWorkerHarness(); + const pid = 48; + const channel = createChannel(pid, 0); + worker.channelTids.set(`${pid}:${channel.channelOffset}`, pid); + const channelView = new DataView(channel.memory.buffer); + channelView.setUint32( + CH_REQUEST_FLAGS, + CH_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY, + true, + ); + channelView.setUint32(CH_SIG_SIGNUM, 0, true); + const dequeueSignal = vi.fn(() => 10); + worker.kernelInstance.exports.kernel_dequeue_signal = dequeueSignal; + + expect(worker.dequeueSignalForDelivery(channel)).toBe(0); + expect(dequeueSignal).not.toHaveBeenCalled(); + expect(channelView.getUint32(CH_SIG_SIGNUM, true)).toBe(0); + + // Model libc's ordinary post-import checkpoint. Only that guest-owned + // completion may consume the signal into the trampoline's channel record. + channelView.setUint32(CH_REQUEST_FLAGS, 0, true); + new DataView(worker.kernelMemory.buffer).setUint32( + worker.scratchOffset + CH_SIG_SIGNUM, + 10, + true, + ); + expect(worker.dequeueSignalForDelivery(channel)).toBe(10); + expect(dequeueSignal).toHaveBeenCalledOnce(); + expect(channelView.getUint32(CH_SIG_SIGNUM, true)).toBe(10); + }); + it("fails closed when Rust rejects an exact signal dequeue task", () => { const worker = createWorkerHarness(); const pid = 48; diff --git a/host/test/sjlj-noexcept-boundary.test.ts b/host/test/sjlj-noexcept-boundary.test.ts index 55a252fd68..475b166054 100644 --- a/host/test/sjlj-noexcept-boundary.test.ts +++ b/host/test/sjlj-noexcept-boundary.test.ts @@ -13,11 +13,9 @@ const rawWasm64Fixture = join( repoRoot, "local-binaries/test-fixtures/wasm64/sjlj_noexcept_boundary.raw.wasm", ); -const unsupportedForkFixture = join( - repoRoot, - "local-binaries/test-fixtures/wasm32/unsupported-abi43/sjlj_noexcept_boundary.raw.wasm", +const instrumentedForkFixture = resolveBinary( + "programs/sjlj_noexcept_boundary.wasm", ); -const unsupportedForkDiagnostic = `${unsupportedForkFixture}.instrument-error.txt`; const sigchldFixture = resolveBinary("programs/sigchld_sjlj.wasm"); const TERMINATED_BY_SIGABRT = 128 + 6; @@ -30,16 +28,31 @@ describe("LLVM Wasm SjLj across a noexcept boundary", () => { expect(exportNames(rawModule)).not.toContain("wpk_fork_state"); }); - it("keeps the fork-bearing compiler output outside the ABI 43 resolver", () => { - expect(readFileSync(unsupportedForkFixture).byteLength).toBeGreaterThan(0); - expect(readFileSync(unsupportedForkDiagnostic, "utf8")).toMatch( - /reference local\/parameter|uses CatchAll|uses CatchAllRef/, + it("admits the fork-bearing compiler output through ABI 43 instrumentation", () => { + const module = new WebAssembly.Module( + readFileSync(instrumentedForkFixture), ); + const exportNames = WebAssembly.Module.exports(module) + .map(({ name }) => name); + + expect(exportNames).toContain("wpk_fork_state"); + }); + + it("forks through the instrumented compiler-EH artifact", async () => { + const result = await runCentralizedProgram({ + programPath: instrumentedForkFixture, + argv: ["sjlj_noexcept_boundary", "--fork-instrumentation-anchor"], + timeout: 10_000, + useDefaultRootfs: false, + }); + + expect(result.exitCode).toBe(0); }); it.each([ ["raw wasm32", rawWasm32Fixture], ["raw wasm64", rawWasm64Fixture], + ["instrumented wasm32", instrumentedForkFixture], ])("documents the pinned LLVM failure in the %s control", async (_, path) => { const result = await runCentralizedProgram({ programPath: path, @@ -54,9 +67,12 @@ describe("LLVM Wasm SjLj across a noexcept boundary", () => { expect(result.stdout).not.toContain("LANDING: siglongjmp resumed"); }); - it("resumes the same SjLj tag when it does not cross noexcept", async () => { + it.each([ + ["raw wasm32", rawWasm32Fixture], + ["instrumented wasm32", instrumentedForkFixture], + ])("resumes the same SjLj tag in the %s permissive boundary", async (_, path) => { const result = await runCentralizedProgram({ - programPath: rawWasm32Fixture, + programPath: path, argv: ["sjlj_noexcept_boundary", "--permissive"], timeout: 10_000, useDefaultRootfs: false, diff --git a/host/test/wasm-binary-parse.test.ts b/host/test/wasm-binary-parse.test.ts index 731c5b39ad..9d14b2c90a 100644 --- a/host/test/wasm-binary-parse.test.ts +++ b/host/test/wasm-binary-parse.test.ts @@ -19,6 +19,26 @@ import { WPK_FORK_CAPABILITIES_VERSION, WPK_FORK_CAP_ACTIVATION_STATE_SAFE, WPK_FORK_CAP_KNOWN_MASK, + WPK_FORK_EXCEPTION_CODEC_HEADER_SIZE, + WPK_FORK_EXCEPTION_CODEC_SECTION, + WPK_FORK_EXCEPTION_CODEC_TAG_RECORD_SIZE, + WPK_FORK_EXCEPTION_CODEC_VERSION, + WPK_FORK_EXCEPTION_CODEC_IMPORT_MODULE, + WPK_FORK_EXCEPTION_IMPORT_ACTIVATION, + WPK_FORK_GLOBAL_CATALOG_EXPORT_PREFIX, + WPK_FORK_IMPORTED_GLOBALS_FLAG_MUTABLE, + WPK_FORK_IMPORTED_GLOBALS_FLAG_SHARED, + WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE, + WPK_FORK_IMPORTED_GLOBALS_MAGIC, + WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE, + WPK_FORK_IMPORTED_GLOBALS_SECTION, + WPK_FORK_IMPORTED_GLOBALS_VERSION, + WPK_FORK_IMPORTED_TABLES_FLAG_TABLE64, + WPK_FORK_IMPORTED_TABLES_HEADER_SIZE, + WPK_FORK_IMPORTED_TABLES_MAGIC, + WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE, + WPK_FORK_IMPORTED_TABLES_SECTION, + WPK_FORK_IMPORTED_TABLES_VERSION, WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE, WPK_FORK_LINKED_FRAME_FORMAT_MAGIC, WPK_FORK_LINKED_FRAME_FORMAT_SECTION, @@ -26,8 +46,27 @@ import { WPK_FORK_LINKED_FRAME_POINTER_WIDTHS, WPK_FORK_LINKED_FRAME_RECORD_ALIGNMENT, WPK_FORK_LINKED_FRAME_REQUIRED_FLAGS, + WPK_FORK_MODULE_STATE_ARENA_VERSION, + WPK_FORK_MODULE_STATE_DESCRIPTOR_SIZE, + WPK_FORK_MODULE_STATE_FORMAT_MAGIC, + WPK_FORK_MODULE_STATE_FORMAT_SECTION, + WPK_FORK_MODULE_STATE_FORMAT_VERSION, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I32, + WPK_FORK_MODULE_STATE_RECORD_ALIGNMENT, + WPK_FORK_MODULE_STATE_RECORD_VERSION, + WPK_FORK_MODULE_STATE_REQUIRED_FLAGS, + WPK_FORK_MODULE_STATE_ROOT_POINTER_WORD_OFFSET, WPK_FORK_REQUIRED_EXPORTS, WPK_FORK_REQUIRED_IMPORTS, + WPK_FORK_REQUIRED_TABLE_IMPORTS, + WPK_FORK_STATIC_ROOT_CATALOG_EXPORT, + WPK_FORK_STATIC_ROOT_CATALOG_HEADER_SIZE, + WPK_FORK_STATIC_ROOT_CATALOG_MAGIC, + WPK_FORK_STATIC_ROOT_CATALOG_SECTION, + WPK_FORK_STATIC_ROOT_CATALOG_VERSION, + WPK_FORK_TABLE_CATALOG_EXPORT_PREFIX, } from "../src/generated/abi"; import { describeWasmArtifactPolicyFailures, @@ -42,6 +81,13 @@ import { wasmHasCompleteForkInstrumentation, wasmImportsKernelFork, } from "../src/constants"; +import { + FORK_UNWIND_TAG_IMPORT_MODULE, + FORK_UNWIND_TAG_IMPORT_NAME, + FORK_UNWIND_TRANSPORT_PAYLOAD_ARITY, + FORK_UNWIND_TRANSPORT_SECTION, + FORK_UNWIND_TRANSPORT_VERSION, +} from "../src/fork-unwind-transport"; import { tryResolveBinary } from "../src/binary-resolver"; // --------------------------------------------------------------------------- @@ -96,20 +142,40 @@ function nameBytes(s: string): number[] { return [...uleb128(enc.length), ...enc]; } -interface GlobalImport { module: string; name: string; valType: 0x7F | 0x7E; mut: 0 | 1; } +interface GlobalImport { + module: string; + name: string; + valType: number; + mut: 0 | 1; + shared?: boolean; +} interface FuncImport { module: string; name: string; typeIdx: number; } +interface TableImport { + module: string; + name: string; + elementType: number; + table64: boolean; + minimum: number; + maximum: number | null; +} +type DefinedTable = Omit; +interface TagImport { module: string; name: string; typeIdx: number; } interface DefinedGlobal { valType: 0x7F | 0x7E; mut: 0 | 1; init: number[]; } interface ExportEntry { name: string; kind: 0 | 1 | 2 | 3; index: number; } interface FuncBody { locals: number[]; instructions: number[]; } function buildWasm(opts: { funcImports?: FuncImport[]; + tagImports?: TagImport[]; + tableImports?: TableImport[]; + tables?: DefinedTable[]; globalImports?: GlobalImport[]; types?: { params: number[]; results: number[] }[]; funcTypes?: number[]; // type index per defined function memoryPointerWidths?: Array<4 | 8>; globals?: DefinedGlobal[]; exports?: ExportEntry[]; + startFunctionIndex?: number; funcBodies?: FuncBody[]; customSections?: { name: string; data?: number[] }[]; }): ArrayBuffer { @@ -138,14 +204,45 @@ function buildWasm(opts: { // Import section (id=2) const fImps = opts.funcImports ?? []; + const tImps = opts.tagImports ?? []; + const tableImps = opts.tableImports ?? []; const gImps = opts.globalImports ?? []; - if (fImps.length + gImps.length > 0) { - const payload: number[] = [...uleb128(fImps.length + gImps.length)]; + if (fImps.length + tImps.length + tableImps.length + gImps.length > 0) { + const payload: number[] = [ + ...uleb128(fImps.length + tImps.length + tableImps.length + gImps.length), + ]; for (const fi of fImps) { payload.push(...nameBytes(fi.module), ...nameBytes(fi.name), 0x00, ...uleb128(fi.typeIdx)); } + for (const ti of tImps) { + payload.push( + ...nameBytes(ti.module), + ...nameBytes(ti.name), + 0x04, + 0x00, + ...uleb128(ti.typeIdx), + ); + } + for (const table of tableImps) { + const flags = (table.maximum === null ? 0 : 1) | (table.table64 ? 4 : 0); + payload.push( + ...nameBytes(table.module), + ...nameBytes(table.name), + 0x01, + table.elementType, + ...uleb128(flags), + ...uleb128(table.minimum), + ...(table.maximum === null ? [] : uleb128(table.maximum)), + ); + } for (const gi of gImps) { - payload.push(...nameBytes(gi.module), ...nameBytes(gi.name), 0x03, gi.valType, gi.mut); + payload.push( + ...nameBytes(gi.module), + ...nameBytes(gi.name), + 0x03, + gi.valType, + gi.mut | (gi.shared ? 0b10 : 0), + ); } bytes.push(...section(2, payload)); } @@ -158,6 +255,21 @@ function buildWasm(opts: { bytes.push(...section(3, payload)); } + const tables = opts.tables ?? []; + if (tables.length > 0) { + const payload: number[] = [...uleb128(tables.length)]; + for (const table of tables) { + const flags = (table.maximum === null ? 0 : 1) | (table.table64 ? 4 : 0); + payload.push( + table.elementType, + ...uleb128(flags), + ...uleb128(table.minimum), + ...(table.maximum === null ? [] : uleb128(table.maximum)), + ); + } + bytes.push(...section(4, payload)); + } + const memoryPointerWidths = opts.memoryPointerWidths ?? []; if (memoryPointerWidths.length > 0) { const payload = [...uleb128(memoryPointerWidths.length)]; @@ -187,6 +299,10 @@ function buildWasm(opts: { bytes.push(...section(7, payload)); } + if (opts.startFunctionIndex !== undefined) { + bytes.push(...section(8, uleb128(opts.startFunctionIndex))); + } + // Code section (id=10) const bodies = opts.funcBodies ?? []; if (bodies.length > 0) { @@ -223,37 +339,289 @@ function linkedFrameDescriptor(pointerWidth: 4 | 8): number[] { return [...bytes]; } +function moduleStateDescriptor(pointerWidth: 4 | 8): number[] { + const bytes = new Uint8Array(WPK_FORK_MODULE_STATE_DESCRIPTOR_SIZE); + bytes.set(WPK_FORK_MODULE_STATE_FORMAT_MAGIC, 0); + const view = new DataView(bytes.buffer); + view.setUint16(4, WPK_FORK_MODULE_STATE_FORMAT_VERSION, true); + view.setUint16(6, WPK_FORK_MODULE_STATE_DESCRIPTOR_SIZE, true); + view.setUint8(8, pointerWidth); + view.setUint8(9, WPK_FORK_MODULE_STATE_RECORD_ALIGNMENT); + view.setUint16(10, WPK_FORK_MODULE_STATE_REQUIRED_FLAGS, true); + view.setUint16(12, WPK_FORK_MODULE_STATE_ARENA_VERSION, true); + view.setUint16(14, WPK_FORK_MODULE_STATE_RECORD_VERSION, true); + view.setUint32(16, WPK_FORK_MODULE_STATE_ROOT_POINTER_WORD_OFFSET, true); + return [...bytes]; +} + +function exceptionCodecDescriptor( + tags: Array<{ + ordinal: number; + layoutId: number; + scalarByteLength: number; + referenceCount: number; + }> = [], +): number[] { + const bytes = new Uint8Array( + WPK_FORK_EXCEPTION_CODEC_HEADER_SIZE + + tags.length * WPK_FORK_EXCEPTION_CODEC_TAG_RECORD_SIZE, + ); + const view = new DataView(bytes.buffer); + view.setUint8(0, WPK_FORK_EXCEPTION_CODEC_VERSION); + view.setUint32(4, tags.length, true); + for (let index = 0; index < tags.length; index++) { + const tag = tags[index]!; + const offset = WPK_FORK_EXCEPTION_CODEC_HEADER_SIZE + + index * WPK_FORK_EXCEPTION_CODEC_TAG_RECORD_SIZE; + view.setUint32(offset, tag.ordinal, true); + view.setUint32(offset + 4, tag.layoutId, true); + view.setUint32(offset + 8, tag.scalarByteLength, true); + view.setUint32(offset + 12, tag.referenceCount, true); + } + return [...bytes]; +} + +function emptyImportedGlobalsDescriptor(): number[] { + return importedGlobalsDescriptor([]); +} + +const COMPLETE_FORK_SOURCE_TABLE_IMPORT_ORDINAL = + 1 + WPK_FORK_REQUIRED_IMPORTS.length + 1; +const COMPLETE_FORK_SOURCE_GLOBAL_IMPORT_ORDINAL = + COMPLETE_FORK_SOURCE_TABLE_IMPORT_ORDINAL + + WPK_FORK_REQUIRED_TABLE_IMPORTS.length; + +function importedGlobalsDescriptor( + records: Array<{ + owner: number; + typeCode: number; + flags: number; + module: string; + name: string; + importOrdinal?: number; + }>, +): number[] { + const encoded = records.map((record, index) => ({ + ...record, + importOrdinal: + record.importOrdinal ?? COMPLETE_FORK_SOURCE_GLOBAL_IMPORT_ORDINAL + index, + moduleBytes: new TextEncoder().encode(record.module), + nameBytes: new TextEncoder().encode(record.name), + })); + const byteLength = WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE + + encoded.reduce( + (total, record) => + total + + WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE + + record.moduleBytes.length + + record.nameBytes.length, + 0, + ); + const bytes = new Uint8Array(byteLength); + bytes.set(WPK_FORK_IMPORTED_GLOBALS_MAGIC, 0); + const view = new DataView(bytes.buffer); + view.setUint16(4, WPK_FORK_IMPORTED_GLOBALS_VERSION, true); + view.setUint16(6, WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE, true); + view.setUint32(8, encoded.length, true); + let offset = WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE; + for (const record of encoded) { + const recordSize = WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE + + record.moduleBytes.length + + record.nameBytes.length; + view.setUint32(offset, recordSize, true); + view.setUint32(offset + 4, record.owner, true); + view.setUint8(offset + 8, record.typeCode); + view.setUint8(offset + 9, record.flags); + view.setUint32(offset + 12, record.moduleBytes.length, true); + view.setUint32(offset + 16, record.nameBytes.length, true); + view.setUint32(offset + 20, record.importOrdinal, true); + bytes.set( + record.moduleBytes, + offset + WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE, + ); + bytes.set( + record.nameBytes, + offset + + WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE + + record.moduleBytes.length, + ); + offset += recordSize; + } + return [...bytes]; +} + +function importedTablesDescriptor( + records: Array<{ + owner: number; + typeCode: number; + flags: number; + module: string; + name: string; + importOrdinal?: number; + }>, +): number[] { + const encoded = records.map((record, index) => ({ + ...record, + importOrdinal: + record.importOrdinal ?? COMPLETE_FORK_SOURCE_TABLE_IMPORT_ORDINAL + index, + moduleBytes: new TextEncoder().encode(record.module), + nameBytes: new TextEncoder().encode(record.name), + })); + const byteLength = WPK_FORK_IMPORTED_TABLES_HEADER_SIZE + + encoded.reduce( + (total, record) => + total + + WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE + + record.moduleBytes.length + + record.nameBytes.length, + 0, + ); + const bytes = new Uint8Array(byteLength); + bytes.set(WPK_FORK_IMPORTED_TABLES_MAGIC, 0); + const view = new DataView(bytes.buffer); + view.setUint16(4, WPK_FORK_IMPORTED_TABLES_VERSION, true); + view.setUint16(6, WPK_FORK_IMPORTED_TABLES_HEADER_SIZE, true); + view.setUint32(8, encoded.length, true); + let offset = WPK_FORK_IMPORTED_TABLES_HEADER_SIZE; + for (const record of encoded) { + const recordSize = WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE + + record.moduleBytes.length + + record.nameBytes.length; + view.setUint32(offset, recordSize, true); + view.setUint32(offset + 4, record.owner, true); + view.setUint8(offset + 8, record.typeCode); + view.setUint8(offset + 9, record.flags); + view.setUint32(offset + 12, record.moduleBytes.length, true); + view.setUint32(offset + 16, record.nameBytes.length, true); + view.setUint32(offset + 20, record.importOrdinal, true); + bytes.set( + record.moduleBytes, + offset + WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE, + ); + bytes.set( + record.nameBytes, + offset + + WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE + + record.moduleBytes.length, + ); + offset += recordSize; + } + return [...bytes]; +} + +function staticRootCatalogDescriptor(count = 0): number[] { + const bytes = new Uint8Array(WPK_FORK_STATIC_ROOT_CATALOG_HEADER_SIZE); + bytes.set(WPK_FORK_STATIC_ROOT_CATALOG_MAGIC, 0); + const view = new DataView(bytes.buffer); + view.setUint16(4, WPK_FORK_STATIC_ROOT_CATALOG_VERSION, true); + view.setUint16(6, WPK_FORK_STATIC_ROOT_CATALOG_HEADER_SIZE, true); + view.setUint32(8, count, true); + return [...bytes]; +} + +type ForkArtifactValueType = + | "ptr" + | "i32" + | "i64" + | "anyref" + | "exnref" + | "externref" + | "funcref"; + +function wasmValueType( + value: ForkArtifactValueType, + pointerWidth: 4 | 8, +): number { + switch (value) { + case "ptr": + return pointerWidth === 8 ? I64 : I32; + case "i32": + return I32; + case "i64": + return I64; + case "anyref": + return 0x6e; + case "exnref": + return 0x69; + case "externref": + return 0x6f; + case "funcref": + return 0x70; + } +} + function completeForkWasm(options: { pointerWidth?: 4 | 8; memoryPointerWidth?: 4 | 8; exportPointerWidth?: 4 | 8; capabilityFlags?: number | null; capabilityPayloads?: number[][]; + unwindTransportPayloads?: number[][]; + moduleStatePayloads?: number[][]; + exceptionCodecPayloads?: number[][]; + importedGlobalsPayloads?: number[][]; + importedTablesPayloads?: number[][]; + includeActivationImport?: boolean; + sourceGlobalImports?: GlobalImport[]; + sourceTableImports?: TableImport[]; + includeGlobalCatalog?: boolean; + includeTableCatalog?: boolean; + includeResumeTable?: boolean; + staticRootPayloads?: number[][]; + includeStaticRootTable?: boolean; + staticRootCount?: number; + includeUnwindTag?: boolean; + includeLegacyDlopenImport?: boolean; + includeNativeStart?: boolean; abiVersion?: number; includeAbiMarker?: boolean; } = {}): ArrayBuffer { const pointerWidth = options.pointerWidth ?? 4; - const pointerType = pointerWidth === 8 ? I64 : I32; - const exportPointerType = (options.exportPointerWidth ?? pointerWidth) === 8 ? I64 : I32; - const types = [ - { params: [], results: [I32] }, - { params: [exportPointerType], results: [] }, - { params: [], results: [] }, - { params: [pointerType], results: [pointerType] }, - { params: [pointerType], results: [] }, - ]; + const exportPointerWidth = options.exportPointerWidth ?? pointerWidth; + const types: Array<{ params: number[]; results: number[] }> = []; + const typeIndices = new Map(); + const internType = ( + params: readonly ForkArtifactValueType[], + results: readonly ForkArtifactValueType[], + width: 4 | 8, + ): number => { + const type = { + params: params.map((value) => wasmValueType(value, width)), + results: results.map((value) => wasmValueType(value, width)), + }; + const key = `${type.params.join(",")}=>${type.results.join(",")}`; + const existing = typeIndices.get(key); + if (existing !== undefined) return existing; + const index = types.length; + types.push(type); + typeIndices.set(key, index); + return index; + }; + const kernelForkType = internType([], ["i32"], pointerWidth); + const emptyType = internType([], [], pointerWidth); const funcImports: FuncImport[] = [ - { module: "kernel", name: "kernel_fork", typeIdx: 0 }, + { module: "kernel", name: "kernel_fork", typeIdx: kernelForkType }, + ...(options.includeLegacyDlopenImport === true + ? [{ + module: "env", + name: "__wasm_dlopen", + typeIdx: internType( + ["ptr", "i32", "ptr", "i32", "i32"], + ["i32"], + pointerWidth, + ), + }] + : []), ...WPK_FORK_REQUIRED_IMPORTS.map((requirement) => ({ module: requirement.module, name: requirement.name, - typeIdx: requirement.results.length === 1 ? 3 : 4, + typeIdx: internType(requirement.params, requirement.results, pointerWidth), })), ]; - const forkTypeIndices = WPK_FORK_REQUIRED_EXPORTS.map((requirement) => { - if (requirement.results.length === 1) return 0; - return requirement.params.length === 1 ? 1 : 2; - }); + const forkTypeIndices = WPK_FORK_REQUIRED_EXPORTS.map((requirement) => + internType(requirement.params, requirement.results, exportPointerWidth) + ); + const abiType = internType([], ["i32"], pointerWidth); const firstDefinedFunction = funcImports.length; const capabilityFlags = options.capabilityFlags === undefined @@ -263,6 +631,33 @@ function completeForkWasm(options: { (capabilityFlags === null ? [] : [[WPK_FORK_CAPABILITIES_VERSION, capabilityFlags]]); + const sourceGlobalImports = options.sourceGlobalImports ?? []; + const sourceTableImports = options.sourceTableImports ?? []; + const activationGlobalImports: GlobalImport[] = + options.includeActivationImport === false ? [] : [{ + module: WPK_FORK_EXCEPTION_CODEC_IMPORT_MODULE, + name: WPK_FORK_EXCEPTION_IMPORT_ACTIVATION, + valType: I32, + mut: 0, + }]; + const requiredTableImports = options.includeResumeTable === false + ? [] + : WPK_FORK_REQUIRED_TABLE_IMPORTS.map((requirement) => ({ + module: requirement.module, + name: requirement.name, + elementType: wasmValueType(requirement.element, pointerWidth), + table64: requirement.table64, + minimum: requirement.minimum, + maximum: requirement.maximum, + })); + const staticRootCount = options.staticRootCount ?? 0; + const nativeStartLocalIndex = WPK_FORK_REQUIRED_EXPORTS.findIndex( + (requirement) => + requirement.params.length === 0 && requirement.results.length === 0, + ); + if (options.includeNativeStart === true && nativeStartLocalIndex < 0) { + throw new Error("fork fixture has no () -> () function for its native start"); + } return buildWasm({ customSections: [ ...capabilityPayloads.map((data) => ({ @@ -273,10 +668,56 @@ function completeForkWasm(options: { name: WPK_FORK_LINKED_FRAME_FORMAT_SECTION, data: linkedFrameDescriptor(pointerWidth), }, + ...(options.exceptionCodecPayloads ?? + [exceptionCodecDescriptor()]).map((data) => ({ + name: WPK_FORK_EXCEPTION_CODEC_SECTION, + data, + })), + ...(options.importedGlobalsPayloads ?? + [emptyImportedGlobalsDescriptor()]).map((data) => ({ + name: WPK_FORK_IMPORTED_GLOBALS_SECTION, + data, + })), + ...(options.importedTablesPayloads ?? + [importedTablesDescriptor([])]).map((data) => ({ + name: WPK_FORK_IMPORTED_TABLES_SECTION, + data, + })), + ...(options.moduleStatePayloads ?? [moduleStateDescriptor(pointerWidth)]).map( + (data) => ({ + name: WPK_FORK_MODULE_STATE_FORMAT_SECTION, + data, + }), + ), + ...(options.staticRootPayloads ?? + [staticRootCatalogDescriptor(staticRootCount)]).map((data) => ({ + name: WPK_FORK_STATIC_ROOT_CATALOG_SECTION, + data, + })), + ...(options.unwindTransportPayloads ?? [[ + FORK_UNWIND_TRANSPORT_VERSION, + FORK_UNWIND_TRANSPORT_PAYLOAD_ARITY, + ]]).map((data) => ({ + name: FORK_UNWIND_TRANSPORT_SECTION, + data, + })), ], types, funcImports, - funcTypes: [...forkTypeIndices, 0], + globalImports: [...sourceGlobalImports, ...activationGlobalImports], + tableImports: [...sourceTableImports, ...requiredTableImports], + tables: options.includeStaticRootTable === false ? [] : [{ + elementType: wasmValueType("anyref", pointerWidth), + table64: false, + minimum: staticRootCount, + maximum: staticRootCount, + }], + tagImports: options.includeUnwindTag === false ? [] : [{ + module: FORK_UNWIND_TAG_IMPORT_MODULE, + name: FORK_UNWIND_TAG_IMPORT_NAME, + typeIdx: emptyType, + }], + funcTypes: [...forkTypeIndices, abiType], memoryPointerWidths: [options.memoryPointerWidth ?? pointerWidth], exports: [ ...WPK_FORK_REQUIRED_EXPORTS.map((requirement, index) => ({ @@ -289,11 +730,39 @@ function completeForkWasm(options: { kind: 0 as const, index: firstDefinedFunction + forkTypeIndices.length, }]), + ...(options.includeGlobalCatalog === false + ? [] + : sourceGlobalImports.map((_global, index) => ({ + name: `${WPK_FORK_GLOBAL_CATALOG_EXPORT_PREFIX}${index + 1}`, + kind: 3 as const, + index, + }))), + ...(options.includeTableCatalog === false + ? [] + : sourceTableImports.map((_table, index) => ({ + name: `${WPK_FORK_TABLE_CATALOG_EXPORT_PREFIX}${index + 1}`, + kind: 1 as const, + index, + }))), + ...(options.includeStaticRootTable === false ? [] : [{ + name: WPK_FORK_STATIC_ROOT_CATALOG_EXPORT, + kind: 1 as const, + index: sourceTableImports.length + requiredTableImports.length, + }]), ], + ...(options.includeNativeStart === true + ? { startFunctionIndex: firstDefinedFunction + nativeStartLocalIndex } + : {}), funcBodies: [ ...WPK_FORK_REQUIRED_EXPORTS.map((requirement) => ({ locals: [0], - instructions: requirement.results.length === 1 ? [0x41, 0] : [], + instructions: requirement.results.length === 0 + ? [] + : requirement.results[0] === "i32" + ? [0x41, 0] + : requirement.results[0] === "i64" + ? [0x42, 0] + : [0x00], })), { locals: [0], @@ -537,15 +1006,19 @@ describe("wasm artifact policy helpers", () => { expect(wasmHasCompleteForkInstrumentation(wasm)).toBe(false); const failures = describeWasmArtifactPolicyFailures(wasm, { expectedAbi: 12 }); - expect(failures).toContain( - "incomplete wasm-fork-instrument exports; missing wpk_fork_abort_begin, wpk_fork_abort_end, wpk_fork_rewind_begin, wpk_fork_rewind_end, wpk_fork_unwind_begin, wpk_fork_unwind_end", - ); + expect(failures.some((failure) => + failure.startsWith("incomplete wasm-fork-instrument exports; missing ") + && failure.includes("__wpk_fork_ref_decode_exnref") + && failure.includes("wpk_fork_unwind_end") + )).toBe(true); expect(failures).toContain( `missing required ${WPK_FORK_LINKED_FRAME_FORMAT_SECTION} descriptor`, ); - expect(failures).toContain( - "incomplete ABI 43 linked-frame imports; missing env.__wpk_fork_frame_commit, env.__wpk_fork_frame_next, env.__wpk_fork_frame_reserve", - ); + expect(failures.some((failure) => + failure.startsWith("incomplete ABI 43 fork-runtime imports; missing ") + && failure.includes("env.__wpk_fork_frame_commit") + && failure.includes("env.__wpk_fork_ref_exn_define") + )).toBe(true); }); it("accepts the complete ABI 43 contract for wasm32 and wasm64", () => { @@ -556,6 +1029,459 @@ describe("wasm artifact policy helpers", () => { } }); + it("requires the exact private exception transport before accepting ABI 43 safety", () => { + const cases: Array<{ + label: string; + options: Parameters[0]; + diagnostic: string; + }> = [ + { + label: "missing tag", + options: { includeUnwindTag: false }, + diagnostic: "missing required private fork-unwind tag import", + }, + { + label: "missing descriptor", + options: { unwindTransportPayloads: [] }, + diagnostic: `missing required ${FORK_UNWIND_TRANSPORT_SECTION} descriptor`, + }, + { + label: "wrong descriptor", + options: { unwindTransportPayloads: [[FORK_UNWIND_TRANSPORT_VERSION, 1]] }, + diagnostic: `${FORK_UNWIND_TRANSPORT_SECTION} must be`, + }, + { + label: "duplicate descriptor", + options: { + unwindTransportPayloads: [ + [FORK_UNWIND_TRANSPORT_VERSION, FORK_UNWIND_TRANSPORT_PAYLOAD_ARITY], + [FORK_UNWIND_TRANSPORT_VERSION, FORK_UNWIND_TRANSPORT_PAYLOAD_ARITY], + ], + }, + diagnostic: "descriptors, expected exactly one", + }, + ]; + + for (const { label, options, diagnostic } of cases) { + const wasm = completeForkWasm(options); + expect(wasmHasCompleteForkInstrumentation(wasm), label).toBe(false); + expect(describeWasmArtifactPolicyFailures(wasm).join("\n"), label) + .toContain(diagnostic); + } + }); + + it("requires module-state ownership metadata in the same pointer-width epoch", () => { + const missing = completeForkWasm({ moduleStatePayloads: [] }); + expect(describeWasmArtifactPolicyFailures(missing).join("\n")) + .toContain(`missing required ${WPK_FORK_MODULE_STATE_FORMAT_SECTION} descriptor`); + + const mismatched = completeForkWasm({ + pointerWidth: 8, + moduleStatePayloads: [moduleStateDescriptor(4)], + }); + expect(describeWasmArtifactPolicyFailures(mismatched).join("\n")) + .toContain("pointer width 4 does not match linked frames 8"); + }); + + it("accepts shape-neutral exact-tag exception codec catalogs", () => { + const descriptor = exceptionCodecDescriptor([ + { + ordinal: 0, + layoutId: 17, + scalarByteLength: 40, + referenceCount: 0, + }, + { + ordinal: 1, + layoutId: 29, + scalarByteLength: 24, + referenceCount: 7, + }, + ]); + const wasm = completeForkWasm({ exceptionCodecPayloads: [descriptor] }); + + expect(wasmHasCompleteForkInstrumentation(wasm)).toBe(true); + expect(describeWasmArtifactPolicyFailures(wasm, { + expectedAbi: ABI_VERSION, + })).toEqual([]); + }); + + it("rejects malformed exception reconstruction metadata, not exception shapes", () => { + const noncanonical = exceptionCodecDescriptor([ + { + ordinal: 1, + layoutId: 4, + scalarByteLength: 0, + referenceCount: 0, + }, + ]); + const duplicateLayout = exceptionCodecDescriptor([ + { + ordinal: 0, + layoutId: 9, + scalarByteLength: 0, + referenceCount: 0, + }, + { + ordinal: 1, + layoutId: 9, + scalarByteLength: 0, + referenceCount: 2, + }, + ]); + const reserved = exceptionCodecDescriptor(); + reserved[1] = 1; + const cases = [ + { + label: "missing", + payloads: [] as number[][], + diagnostic: `missing required ${WPK_FORK_EXCEPTION_CODEC_SECTION} descriptor`, + }, + { + label: "duplicate", + payloads: [exceptionCodecDescriptor(), exceptionCodecDescriptor()], + diagnostic: "descriptors, expected exactly one", + }, + { + label: "truncated", + payloads: [[WPK_FORK_EXCEPTION_CODEC_VERSION]], + diagnostic: "descriptor is truncated", + }, + { + label: "reserved", + payloads: [reserved], + diagnostic: "reserved fields are nonzero", + }, + { + label: "noncanonical ordinal", + payloads: [noncanonical], + diagnostic: "tag ordinal 1 is noncanonical at 0", + }, + { + label: "duplicate layout", + payloads: [duplicateLayout], + diagnostic: "layout id 9 is invalid or duplicated", + }, + ]; + + for (const { label, payloads, diagnostic } of cases) { + const failures = describeWasmArtifactPolicyFailures( + completeForkWasm({ exceptionCodecPayloads: payloads }), + ); + expect(failures.join("\n"), label).toContain(diagnostic); + } + }); + + it("requires pre-instantiation global recipes and private codec bindings", () => { + const missingGlobals = completeForkWasm({ importedGlobalsPayloads: [] }); + expect(describeWasmArtifactPolicyFailures(missingGlobals).join("\n")) + .toContain(`missing required ${WPK_FORK_IMPORTED_GLOBALS_SECTION} descriptor`); + + const missingTables = completeForkWasm({ importedTablesPayloads: [] }); + expect(describeWasmArtifactPolicyFailures(missingTables).join("\n")) + .toContain(`missing required ${WPK_FORK_IMPORTED_TABLES_SECTION} descriptor`); + + const missingActivation = completeForkWasm({ includeActivationImport: false }); + expect(describeWasmArtifactPolicyFailures(missingActivation).join("\n")) + .toContain("missing required immutable exception-codec activation import"); + + const missingResumeTable = completeForkWasm({ includeResumeTable: false }); + expect(describeWasmArtifactPolicyFailures(missingResumeTable).join("\n")) + .toContain("missing required ABI 43 fork-runtime table import"); + }); + + it("binds duplicate and table64 import recipes one declaration at a time", () => { + const sourceTableImports: TableImport[] = [ + { + module: "env", + name: "dispatch", + elementType: 0x70, + table64: false, + minimum: 1, + maximum: 8, + }, + { + module: "env", + name: "dispatch", + elementType: 0x70, + table64: false, + minimum: 1, + maximum: 8, + }, + { + module: "state", + name: "objects", + elementType: 0x6f, + table64: true, + minimum: 0, + maximum: null, + }, + ]; + const descriptor = importedTablesDescriptor([ + { + owner: 1, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + flags: 0, + module: "env", + name: "dispatch", + }, + { + owner: 2, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + flags: 0, + module: "env", + name: "dispatch", + }, + { + owner: 3, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + flags: WPK_FORK_IMPORTED_TABLES_FLAG_TABLE64, + module: "state", + name: "objects", + }, + ]); + const wasm = completeForkWasm({ + sourceTableImports, + importedTablesPayloads: [descriptor], + }); + + expect(wasmHasCompleteForkInstrumentation(wasm)).toBe(true); + expect(describeWasmArtifactPolicyFailures(wasm, { + expectedAbi: ABI_VERSION, + })).toEqual([]); + }); + + it("rejects copied or incomplete imported-table ownership claims", () => { + const sourceTableImports: TableImport[] = [{ + module: "env", + name: "dispatch", + elementType: 0x70, + table64: false, + minimum: 1, + maximum: 8, + }]; + const correctRecord = { + owner: 1, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + flags: 0, + module: "env", + name: "dispatch", + }; + const cases = [ + { + label: "empty copied descriptor", + options: { + sourceTableImports, + importedTablesPayloads: [importedTablesDescriptor([])], + }, + diagnostic: "omits imported table env.dispatch at index 0", + }, + { + label: "wrong declaration type", + options: { + sourceTableImports, + importedTablesPayloads: [importedTablesDescriptor([{ + ...correctRecord, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + }])], + }, + diagnostic: "owner 1 does not match its imported table declaration", + }, + { + label: "missing catalog owner", + options: { + sourceTableImports, + importedTablesPayloads: [ + importedTablesDescriptor([correctRecord]), + ], + includeTableCatalog: false, + }, + diagnostic: "owner 1 lacks exactly one table catalog export", + }, + ]; + + for (const { label, options, diagnostic } of cases) { + expect( + describeWasmArtifactPolicyFailures( + completeForkWasm(options), + ).join("\n"), + label, + ).toContain(diagnostic); + } + }); + + it("binds duplicate, reference, mutable, and shared global recipes one-for-one", () => { + const sourceGlobalImports: GlobalImport[] = [ + { + module: "env", + name: "callback", + valType: 0x6f, + mut: 0, + }, + { + module: "env", + name: "callback", + valType: 0x6f, + mut: 0, + }, + { + module: "state", + name: "epoch", + valType: I32, + mut: 1, + shared: true, + }, + { + // This fresh-child process binding is intentionally catalogued but + // reconstructed by the host before instantiation, not by KFIG. + module: "env", + name: "__channel_base", + valType: I32, + mut: 0, + }, + ]; + const descriptor = importedGlobalsDescriptor([ + { + owner: 1, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + flags: 0, + module: "env", + name: "callback", + }, + { + owner: 2, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + flags: 0, + module: "env", + name: "callback", + }, + { + owner: 3, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I32, + flags: + WPK_FORK_IMPORTED_GLOBALS_FLAG_MUTABLE + | WPK_FORK_IMPORTED_GLOBALS_FLAG_SHARED, + module: "state", + name: "epoch", + }, + ]); + const wasm = completeForkWasm({ + sourceGlobalImports, + importedGlobalsPayloads: [descriptor], + }); + + expect(wasmHasCompleteForkInstrumentation(wasm)).toBe(true); + expect(describeWasmArtifactPolicyFailures(wasm, { + expectedAbi: ABI_VERSION, + })).toEqual([]); + }); + + it("rejects copied or incomplete imported-global ownership claims", () => { + const sourceGlobalImports: GlobalImport[] = [{ + module: "env", + name: "callback", + valType: 0x6f, + mut: 1, + }]; + const correctRecord = { + owner: 1, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + flags: WPK_FORK_IMPORTED_GLOBALS_FLAG_MUTABLE, + module: "env", + name: "callback", + }; + const cases = [ + { + label: "empty copied descriptor", + options: { + sourceGlobalImports, + importedGlobalsPayloads: [emptyImportedGlobalsDescriptor()], + }, + diagnostic: "omits imported global env.callback at index 0", + }, + { + label: "wrong declaration type", + options: { + sourceGlobalImports, + importedGlobalsPayloads: [importedGlobalsDescriptor([{ + ...correctRecord, + typeCode: WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I32, + }])], + }, + diagnostic: "owner 1 does not match its imported global declaration", + }, + { + label: "missing catalog owner", + options: { + sourceGlobalImports, + importedGlobalsPayloads: [ + importedGlobalsDescriptor([correctRecord]), + ], + includeGlobalCatalog: false, + }, + diagnostic: "owner 1 lacks exactly one global catalog export", + }, + ]; + + for (const { label, options, diagnostic } of cases) { + expect( + describeWasmArtifactPolicyFailures( + completeForkWasm(options), + ).join("\n"), + label, + ).toContain(diagnostic); + } + }); + + it("requires an exact fixed instance-local static-root catalog", () => { + const badMagic = staticRootCatalogDescriptor(); + badMagic[0] ^= 0xff; + const cases = [ + { + label: "missing descriptor", + options: { staticRootPayloads: [] }, + diagnostic: `missing required ${WPK_FORK_STATIC_ROOT_CATALOG_SECTION} descriptor`, + }, + { + label: "duplicate descriptor", + options: { + staticRootPayloads: [ + staticRootCatalogDescriptor(), + staticRootCatalogDescriptor(), + ], + }, + diagnostic: "descriptors, expected exactly one", + }, + { + label: "invalid magic", + options: { staticRootPayloads: [badMagic] }, + diagnostic: "has invalid magic", + }, + { + label: "missing table", + options: { includeStaticRootTable: false }, + diagnostic: "missing exactly one table export", + }, + { + label: "descriptor/table length drift", + options: { + staticRootCount: 2, + staticRootPayloads: [staticRootCatalogDescriptor(3)], + }, + diagnostic: "fixed table32 anyref catalog of length 3", + }, + ]; + + for (const { label, options, diagnostic } of cases) { + expect( + describeWasmArtifactPolicyFailures( + completeForkWasm(options), + ).join("\n"), + label, + ).toContain(diagnostic); + } + }); + it("rejects every malformed or unsafe activation-state capability shape", () => { const cases: Array<{ label: string; @@ -628,6 +1554,24 @@ describe("wasm artifact policy helpers", () => { })).toContain(`ABI ${ABI_VERSION - 1}, expected ${ABI_VERSION}`); }); + it("does not let a reentrant legacy loader import carry the ABI 43 safety claim", () => { + const wasm = completeForkWasm({ includeLegacyDlopenImport: true }); + expect(wasmHasCompleteForkInstrumentation(wasm)).toBe(false); + expect(describeWasmArtifactPolicyFailures(wasm)).toContain( + "ABI 43 fork artifact retains reentrant env.__wasm_dlopen; " + + "rebuild and reinstrument it with the staged loader lowering", + ); + }); + + it("does not let a native start section carry the ABI 43 safety claim", () => { + const wasm = completeForkWasm({ includeNativeStart: true }); + expect(wasmHasCompleteForkInstrumentation(wasm)).toBe(false); + expect(describeWasmArtifactPolicyFailures(wasm)).toContain( + "ABI 43 fork artifact retains 1 native Wasm start section; rebuild and " + + "reinstrument it so initialization is owned by wpk_fork_module_bootstrap", + ); + }); + it("does not accept a fork capability without an ABI epoch marker", () => { const wasm = completeForkWasm({ includeAbiMarker: false }); expect(describeWasmArtifactPolicyFailures(wasm, { @@ -776,6 +1720,21 @@ function hasWasmObjdump(): boolean { } } +function wasmObjdumpCanDecode(path: string): boolean { + try { + execFileSync("wasm-objdump", ["-j", "Global", "-x", path], { + stdio: "ignore", + }); + return true; + } catch { + // WHY: ABI 43 uses typed-reference and GC encodings that older WABT + // releases reject before they can associate __heap_base with its export. + // This optional parity probe must not mistake an obsolete external decoder + // for an invalid artifact; the mandatory parser cases above still run. + return false; + } +} + function objdumpHeapBase(path: string): bigint | null { const out = execFileSync("wasm-objdump", ["-j", "Global", "-x", path], { encoding: "utf-8" }); const m = out.match(/<__heap_base>\s*-\s*init\s+i(?:32|64)=(-?\d+)/); @@ -798,11 +1757,23 @@ function findCachedBinary(name: string, arch = "wasm32"): string | null { return null; } -const localDashBinary = tryResolveBinary("programs/dash.wasm"); +// This is optional cross-check coverage, not a package-index policy test. +// A concurrently edited source projection must skip the cached-binary probe +// without preventing the pure artifact-parser cases above from collecting. +let localDashBinary: string | null = null; +try { + localDashBinary = tryResolveBinary("programs/dash.wasm"); +} catch { + localDashBinary = null; +} const dashBinary = (localDashBinary && existsSync(localDashBinary)) ? localDashBinary : findCachedBinary("dash.wasm"); -const haveTooling = hasWasmObjdump() && !!dashBinary && existsSync(dashBinary); +const haveTooling = + hasWasmObjdump() + && !!dashBinary + && existsSync(dashBinary) + && wasmObjdumpCanDecode(dashBinary); describe.skipIf(!haveTooling)("extractHeapBase against cached binaries", () => { it("matches wasm-objdump for dash.wasm", () => { diff --git a/libc/glue/channel_syscall.c b/libc/glue/channel_syscall.c index 08b8d8c464..34da18261f 100644 --- a/libc/glue/channel_syscall.c +++ b/libc/glue/channel_syscall.c @@ -12,7 +12,7 @@ * 8 48B arguments (6 x i64) * 56 8B return value (i64) * 64 4B errno (i32) - * 68 4B reserved/pad + * 68 4B request flags * 72 64KB data transfer buffer * * Each thread has its own channel region within the process's shared @@ -85,6 +85,7 @@ int *__errno_location(void); #define CH_ARG_SIZE 8 #define CH_RETURN 56 #define CH_ERRNO 64 +#define CH_REQUEST_FLAGS 68 #define CH_DATA 72 #define CH_DATA_SIZE 65536 @@ -160,6 +161,7 @@ uintptr_t __get_channel_base_addr(void) { /* SYS_EXIT needs special handling */ #define SYS_EXIT 34 +#define SYS_GETPID 28 /* SYS_FORK/VFORK — kernel_fork import is the fork-continuation boundary. * wasm-fork-instrument rewrites the call graph around kernel.kernel_fork, enabling @@ -182,6 +184,31 @@ int32_t kernel_fork(void); __attribute__((import_module("kernel"), import_name("kernel_exit"))) _Noreturn void kernel_exit(int32_t status); +static long __do_syscall(long n, long long a1, long long a2, long long a3, + long long a4, long long a5, long long a6); + +/* + * Complete one ordinary guest-owned channel request after a host import that + * performed channel work in JavaScript. Those host-owned completions leave + * caught signals kernel-pending because they cannot invoke this file's signal + * trampoline. GETPID is side-effect-free and gives the pending signal an exact + * libc-owned completion without introducing a host-to-Wasm callback. + */ +/* + * The fork instrumenter uses this stable local entry when it lowers the + * historical monolithic __wasm_dlopen import to ABI 43's staged protocol. + * Exporting it lets the generated adapter hand deferred signal delivery back + * to libc after each host-owned loader request, without a host-to-Wasm + * callback or a second signal implementation in the instrumenter. + */ +__attribute__((used)) +__attribute__((retain)) +__attribute__((export_name("__wasm_posix_signal_checkpoint"))) +void __wasm_posix_signal_checkpoint(void) +{ + (void)__do_syscall(SYS_GETPID, 0, 0, 0, 0, 0, 0); +} + /* Direct fork/vfork/_Fork — call kernel_fork without going through the * general syscall dispatcher. This ensures fork instrumentation only covers * fork callers, not every function that makes any syscall. */ @@ -203,13 +230,22 @@ __attribute__((noinline)) int _Fork(void) { long ret = (long)kernel_fork(); + if (ret == 0) { + __wasm_posix_after_fork_child(); + } else { + /* + * WHY: fork transaction allocation and cleanup are consumed by the + * process Worker rather than this libc trampoline, so the host leaves + * caught signals kernel-pending. Re-enter through one ordinary channel + * completion before returning to user code; this invokes any handler + * without a reentrant host-to-Wasm call. + */ + __wasm_posix_signal_checkpoint(); + } if (ret < 0) { *__errno_location() = (int)(-ret); return -1; } - if (ret == 0) { - __wasm_posix_after_fork_child(); - } return (int)ret; } @@ -232,9 +268,6 @@ int vfork(void) /* Signal delivery — invoked after each syscall if a signal is pending */ /* ------------------------------------------------------------------ */ -/* Forward declaration */ -static long __do_syscall(long n, long long a1, long long a2, long long a3, - long long a4, long long a5, long long a6); extern long __syscall_cp_check(long r); extern int __syscall_cp_cancel_pending_disabled(void); @@ -458,6 +491,7 @@ static long __do_syscall_impl(long n, long long a1, long long a2, long long a3, *(int64_t *)(uintptr_t)(base + CH_ARGS + 3 * CH_ARG_SIZE) = (int64_t)a4; *(int64_t *)(uintptr_t)(base + CH_ARGS + 4 * CH_ARG_SIZE) = (int64_t)a5; *(int64_t *)(uintptr_t)(base + CH_ARGS + 5 * CH_ARG_SIZE) = (int64_t)a6; + *(uint32_t *)(uintptr_t)(base + CH_REQUEST_FLAGS) = 0; /* Set status to PENDING and wake the kernel worker. * Use inline asm to read __channel_base directly from the wasm global, diff --git a/libc/glue/dlopen.c b/libc/glue/dlopen.c index 4c73bd3b28..47a25cf37f 100644 --- a/libc/glue/dlopen.c +++ b/libc/glue/dlopen.c @@ -7,12 +7,14 @@ * * Flow: * 1. dlopen() reads the .so file via normal open/read/close syscalls - * 2. Calls __wasm_dlopen() host import with the bytes in memory - * 3. Host compiles the Wasm side module, instantiates it into the - * process's memory/table space, returns a handle - * 4. dlsym() calls __wasm_dlsym() host import to look up symbols - * 5. For functions: returns the table index (== C function pointer) - * 6. For data: returns the relocated memory address + * 2. Calls __wasm_dlopen_prepare() with the bytes in memory + * 3. Host returns a private transaction token without entering guest code + * 4. Each next step performs host-only compilation/instantiation as needed, + * then libc calls the returned initialization entry through the process + * table until the host atomically returns the public handle + * 5. dlsym() calls __wasm_dlsym() host import to look up symbols + * 6. For functions: returns the table index (== C function pointer) + * 7. For data: returns the relocated memory address */ #include @@ -21,13 +23,17 @@ #include #include #include +#include /* Host imports — implemented in worker-main.ts */ -extern int __wasm_dlopen(const void *bytes, int len, - const char *name, int name_len); +extern int __wasm_dlopen_main(void); +extern int __wasm_dlopen_prepare(const void *bytes, int len, + const char *name, int name_len, int flags); +extern int __wasm_dlopen_next(int transaction, int *handle); extern int __wasm_dlsym(int handle, const char *name, int name_len); extern int __wasm_dlclose(int handle); extern int __wasm_dlerror(char *buf, int buf_max); +extern void __wasm_posix_signal_checkpoint(void); /* RTLD flags (match musl dlfcn.h) */ #ifndef RTLD_LAZY @@ -50,12 +56,10 @@ static void set_dl_error(const char *msg) { } void *dlopen(const char *path, int flags) { - (void)flags; - if (!path) { /* An empty host request returns an opaque handle for the main * program's global symbol scope. */ - int handle = __wasm_dlopen(NULL, 0, NULL, 0); + int handle = __wasm_dlopen_main(); if (handle <= 0) { int elen = __wasm_dlerror(dl_error_buf, (int)sizeof(dl_error_buf) - 1); if (elen > 0) { @@ -113,11 +117,19 @@ void *dlopen(const char *path, int flags) { return NULL; } - /* Call host to compile + instantiate the Wasm side module */ - int handle = __wasm_dlopen(buf, (int)st.st_size, path, (int)strlen(path)); + /* + * The host owns module compilation and instance construction, but it must + * not call back into Wasm while this import frame is active. Each returned + * table entry has the canonical void(void) shape, so bootstrap, + * relocations, constructors, and any fork continuation beneath them remain + * an ordinary Wasm-to-Wasm call chain. + */ + int transaction = __wasm_dlopen_prepare( + buf, (int)st.st_size, path, (int)strlen(path), flags); + __wasm_posix_signal_checkpoint(); free(buf); - if (handle <= 0) { + if (transaction <= 0) { /* Get detailed error from host */ int elen = __wasm_dlerror(dl_error_buf, (int)sizeof(dl_error_buf) - 1); if (elen > 0) { @@ -129,6 +141,42 @@ void *dlopen(const char *path, int flags) { return NULL; } + int handle = 0; + for (;;) { + int entry = __wasm_dlopen_next(transaction, &handle); + /* + * WHY: the host may have used mmap or VFS channel requests while + * advancing this transaction. Their completions are JavaScript-owned, + * so deliver any caught signal only now, after the import returned and + * before entering a guest initializer. + */ + __wasm_posix_signal_checkpoint(); + if (entry < 0) { + int elen = __wasm_dlerror( + dl_error_buf, (int)sizeof(dl_error_buf) - 1); + if (elen > 0) { + dl_error_buf[elen] = '\0'; + dl_error_set = 1; + } else { + set_dl_error("wasm initialization failed"); + } + return NULL; + } + if (entry == 0) break; + ((void (*)(void))(uintptr_t)(unsigned int)entry)(); + } + + if (handle <= 0) { + int elen = __wasm_dlerror(dl_error_buf, (int)sizeof(dl_error_buf) - 1); + if (elen > 0) { + dl_error_buf[elen] = '\0'; + dl_error_set = 1; + } else { + set_dl_error("wasm loader commit failed"); + } + return NULL; + } + dl_error_set = 0; return (void *)(long)handle; } @@ -163,6 +211,7 @@ int dlclose(void *handle) { if (!handle) return 0; int h = (int)(long)handle; int ret = __wasm_dlclose(h); + __wasm_posix_signal_checkpoint(); if (ret != 0) { set_dl_error("dlclose failed"); } else { diff --git a/libc/musl-overlay/src/thread/wasm32posix/clone.c b/libc/musl-overlay/src/thread/wasm32posix/clone.c index 6e1475e48a..691ff5a501 100644 --- a/libc/musl-overlay/src/thread/wasm32posix/clone.c +++ b/libc/musl-overlay/src/thread/wasm32posix/clone.c @@ -17,6 +17,7 @@ extern int32_t kernel_clone(uint32_t fn_ptr, uint32_t stack_ptr, uint32_t flags, uint32_t arg, uint32_t ptid_ptr, uint32_t tls_ptr, uint32_t ctid_ptr); +extern void __wasm_posix_signal_checkpoint(void); int __clone(int (*fn)(void *), void *stack, int flags, void *arg, ...) { @@ -35,7 +36,7 @@ int __clone(int (*fn)(void *), void *stack, int flags, void *arg, ...) */ uintptr_t stack_ptr = (uintptr_t)stack & ~(uintptr_t)15; - return kernel_clone( + int result = kernel_clone( (uint32_t)(uintptr_t)fn, (uint32_t)stack_ptr, (uint32_t)flags, @@ -44,4 +45,11 @@ int __clone(int (*fn)(void *), void *stack, int flags, void *arg, ...) (uint32_t)(uintptr_t)tls, (uint32_t)(uintptr_t)ctid ); + /* + * kernel_clone's channel completion is consumed by process-worker + * JavaScript. Run caught handlers only after that import has returned, + * through the same libc-owned checkpoint used by fork and staged dlopen. + */ + __wasm_posix_signal_checkpoint(); + return result; } diff --git a/programs/p_11_fork_continuation_enomem.c b/programs/p_11_fork_continuation_enomem.c index 416792f528..91236563e4 100644 --- a/programs/p_11_fork_continuation_enomem.c +++ b/programs/p_11_fork_continuation_enomem.c @@ -100,8 +100,8 @@ int main(void) { release_fillers(filler_count); return 1; } - if (filler_count == 0) { - printf("FAIL: no filler mapping was available\n"); + if (filler_count < 2) { + printf("FAIL: fewer than two filler mappings were available\n"); return 1; } @@ -161,15 +161,21 @@ int main(void) { return 1; } - // WHY: one free page lets beginUnwind allocate its root chunk. The deep - // call chain then needs another chunk, so failure occurs after frames have - // been committed and exercises ABORT_UNWINDING rather than the simpler - // root-allocation error path. - filler_count--; - if (munmap(filler_mappings[filler_count], WASM_PAGE_BYTES) != 0) { - printf("FAIL: could not make one continuation page available errno=%d\n", errno); - release_fillers(filler_count); - return 1; + // WHY: ABI 43 owns process/module/reference metadata separately from the + // linked stack. Two free pages let capture allocate the metadata arena and + // the continuation root. The deep call chain then needs a third page, so + // failure occurs after frames have committed and exercises + // ABORT_UNWINDING rather than the simpler root-allocation error path. + for (int i = 0; i < 2; i++) { + filler_count--; + if (munmap(filler_mappings[filler_count], WASM_PAGE_BYTES) != 0) { + printf( + "FAIL: could not make fork transaction page available errno=%d\n", + errno + ); + release_fillers(filler_count); + return 1; + } } errno = 0; @@ -201,25 +207,34 @@ int main(void) { } printf("NO_PHANTOM_CHILD: ok\n"); - // The abort replay must unmap its partial chain. Prove that the one free - // page is reusable before relying on it for the recovery fork. - void *probe = mmap( - NULL, - WASM_PAGE_BYTES, - PROT_READ | PROT_WRITE, - MAP_PRIVATE | MAP_ANONYMOUS, - -1, - 0 - ); - if (probe == MAP_FAILED) { - printf("FAIL: continuation allocation leaked errno=%d\n", errno); - release_fillers(filler_count); - return 1; + // Abort replay must unmap both transaction roots and the partial linked + // chain. Hold two probe mappings concurrently to prove both pages are + // reusable before relying on them for the recovery fork. + void *probes[2] = {MAP_FAILED, MAP_FAILED}; + for (int i = 0; i < 2; i++) { + probes[i] = mmap( + NULL, + WASM_PAGE_BYTES, + PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, + -1, + 0 + ); + if (probes[i] == MAP_FAILED) { + printf("FAIL: fork transaction allocation leaked errno=%d\n", errno); + for (int j = 0; j < i; j++) { + munmap(probes[j], WASM_PAGE_BYTES); + } + release_fillers(filler_count); + return 1; + } } - if (munmap(probe, WASM_PAGE_BYTES) != 0) { - printf("FAIL: probe cleanup errno=%d\n", errno); - release_fillers(filler_count); - return 1; + for (int i = 0; i < 2; i++) { + if (munmap(probes[i], WASM_PAGE_BYTES) != 0) { + printf("FAIL: probe cleanup errno=%d\n", errno); + release_fillers(filler_count); + return 1; + } } printf("CONTINUATION_PAGE_REUSED: ok\n"); From c122cb69bb2b3d17bec98f9cf47ec1ce2361ab8e Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sun, 26 Jul 2026 01:17:41 -0400 Subject: [PATCH 03/82] Packages: Bind replay artifacts and rollout to ABI 43 Require ABI 43 activation-state-safe capability, exact role metadata, zero native start sections, and staged-loader inventory across package receipts, indexes, sidecars, Homebrew validation, and shell guards. Build valid compiler-EH fixtures normally instead of routing them to an unsupported bucket. Select the declared LLVM archiver on Darwin so final-key source rebuilds remain reproducible. Treat executable kernel.kernel_fork and side-module env.fork as fork-entry evidence in the common binary inventory. Keep the current Homebrew program projection and standalone resolver unchanged; regenerate both once from the coherent batch tip. --- .../scripts/test-merge-candidate-workflows.sh | 4 + .github/workflows/prepare-merge.yml | 4 + .github/workflows/staging-build.yml | 4 + crates/fork-instrument/README.md | 30 +- crates/fork-instrument/fuzz/Cargo.lock | 76 +- .../fuzz/fuzz_targets/generator.rs | 5 +- .../fork-instrument/src/contract_inventory.rs | 480 +++- crates/fork-instrument/src/main.rs | 60 +- .../tests/contract_inventory.rs | 204 +- crates/shared/src/lib.rs | 9 +- docs/abi-versioning.md | 101 +- docs/architecture.md | 152 +- docs/fork-instrumentation.md | 559 +++-- docs/package-management.md | 9 +- ...i-43-activation-state-safe-rebuild-plan.md | 156 ++ docs/porting-guide.md | 2 +- docs/posix-status.md | 26 +- flake.nix | 7 + host/test/fork-function-catalog.test.ts | 2 +- packages/registry/program-packages.json | 730 +++--- scripts/build-programs.sh | 57 +- scripts/check-abi-version.sh | 5 +- scripts/check-dev-shell-tools.sh | 11 + scripts/ci-run-test-suite.sh | 36 +- scripts/homebrew-validate-wasm-artifact.sh | 239 +- scripts/test-wasm-artifact-guards.sh | 303 ++- scripts/wasm-artifact-guards.sh | 292 ++- .../program-resolver-literals.test.ts | 16 +- .../scripts/ci-run-test-suite-groups.test.sh | 66 +- tools/xtask/src/archive_stage_cli.rs | 10 +- tools/xtask/src/build_deps.rs | 385 ++- tools/xtask/src/build_index.rs | 128 +- tools/xtask/src/dump_abi.rs | 2191 ++++++++++++++++- tools/xtask/src/homebrew_sidecars.rs | 65 +- tools/xtask/src/homebrew_validate.rs | 28 +- tools/xtask/src/package_output_receipt.rs | 2 +- 36 files changed, 5194 insertions(+), 1260 deletions(-) create mode 100644 docs/plans/2026-07-25-abi-43-activation-state-safe-rebuild-plan.md diff --git a/.github/scripts/test-merge-candidate-workflows.sh b/.github/scripts/test-merge-candidate-workflows.sh index 5660d37968..fb3bf464f8 100755 --- a/.github/scripts/test-merge-candidate-workflows.sh +++ b/.github/scripts/test-merge-candidate-workflows.sh @@ -1083,6 +1083,10 @@ for workflow in "$STAGING_WORKFLOW" "$PREPARE"; do grep -Fq 'run: bash scripts/dev-shell.sh npm ci --no-audit --no-fund' \ <<<"$root_install_step" || fail "$(basename "$workflow") materialization validation must install the root esbuild dependency" + materialization_step="$(step_block "$workflow" "Test binary materialization flow")" + grep -Fq 'bash scripts/dev-shell.sh bash scripts/build-fork-instrument-tool.sh' \ + <<<"$materialization_step" || + fail "$(basename "$workflow") materialization validation must build the fork contract inventory tool" done grep -Fq 'cleanup-merge-candidates.sh' "$CLEANUP_WORKFLOW" || \ diff --git a/.github/workflows/prepare-merge.yml b/.github/workflows/prepare-merge.yml index 17bfbfd7be..c45ff3ecfd 100644 --- a/.github/workflows/prepare-merge.yml +++ b/.github/workflows/prepare-merge.yml @@ -1578,6 +1578,10 @@ jobs: - name: Test binary materialization flow if: env.BINARY_MATERIALIZATION_CHANGED == 'true' run: | + # WHY: artifact-guard integration tests query the ABI 43 binary + # contract inventory. This source-only job does not download the + # package-toolchain artifact, so prepare its host tool explicitly. + bash scripts/dev-shell.sh bash scripts/build-fork-instrument-tool.sh bash scripts/dev-shell.sh npx --prefix host vitest run --root . tests/package-system test-suite-early: diff --git a/.github/workflows/staging-build.yml b/.github/workflows/staging-build.yml index 6878bb6bd1..7013ee6122 100644 --- a/.github/workflows/staging-build.yml +++ b/.github/workflows/staging-build.yml @@ -811,6 +811,10 @@ jobs: - name: Test binary materialization flow if: env.BINARY_MATERIALIZATION_CHANGED == 'true' run: | + # WHY: artifact-guard integration tests query the ABI 43 binary + # contract inventory. This source-only job does not download the + # package-toolchain artifact, so prepare its host tool explicitly. + bash scripts/dev-shell.sh bash scripts/build-fork-instrument-tool.sh bash scripts/dev-shell.sh npx --prefix host vitest run --root . tests/package-system test-suite-early: diff --git a/crates/fork-instrument/README.md b/crates/fork-instrument/README.md index ecdda3e0b4..f321c50391 100644 --- a/crates/fork-instrument/README.md +++ b/crates/fork-instrument/README.md @@ -24,13 +24,33 @@ for the current design, ABI, save-buffer layout, and operating limits. wasm-fork-instrument -o [--entry kernel.kernel_fork] ``` +Artifact publication guards use the same wasmparser-backed binary decoder +instead of depending on a text disassembler understanding every proposal used +by the transformed module: + +```sh +wasm-fork-instrument --contract-inventory +wasm-fork-instrument --fork-capability-hex +wasm-fork-instrument --linked-frame-descriptor-hex +``` + +The inventory is one stable tab-separated row covering fork imports, control +exports, metadata counts, memory width, and ABI signature mismatches. The two +metadata modes require exactly one matching custom section and fail on missing +or duplicate sections. + ## Status -PR #307 (`fierce-wire`) replaces the old Binaryen Asyncify fork path in the -build scripts. The tool instruments direct + indirect fork-path callers, -spills scalar and supported ref-typed locals, survives modern `try_table` -catch-handler rewind, and preserves module validity. Remaining unsupported -patterns are documented in `docs/fork-instrumentation.md`. +The tool instruments direct, indirect, reference-call, tail-call, exception, +and cross-module fork paths. ABI 43 stores scalars in linked activation frames +and reconstructs reference locals/carryovers, typed GC graphs, complete +exceptions, mutable reference globals, tables, and dynamic-link activations +from versioned process-owned recipes in copied linear memory. It emits no +module-static reference stash. `Catch`, `CatchRef`, `CatchAll`, and +`CatchAllRef` replay through fresh-instance exceptions. Engine proposal +availability, stale ABI artifacts, and userspace stack-switching primitives +are documented as platform boundaries in +`docs/fork-instrumentation.md`. ## Build diff --git a/crates/fork-instrument/fuzz/Cargo.lock b/crates/fork-instrument/fuzz/Cargo.lock index 4de46e7419..1346e3c0bc 100644 --- a/crates/fork-instrument/fuzz/Cargo.lock +++ b/crates/fork-instrument/fuzz/Cargo.lock @@ -73,6 +73,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -143,6 +152,25 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -154,6 +182,16 @@ dependencies = [ "syn", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -184,6 +222,7 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "sha2", "walrus", "wasm-posix-shared", "wasmparser 0.247.0", @@ -201,6 +240,16 @@ dependencies = [ "wat", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -391,6 +440,17 @@ dependencies = [ "syn", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -420,6 +480,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -438,11 +504,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walrus" -version = "0.26.1" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e151599d689dac80e85c66a7cfa6ffd1b2ab79220517f9161040a87a5041aee3" +checksum = "3bfa49767bb3a9e1afb02aa95bbcbde8d82f2db4ca377afae94d688f14f62378" dependencies = [ "anyhow", "gimli", diff --git a/crates/fork-instrument/fuzz/fuzz_targets/generator.rs b/crates/fork-instrument/fuzz/fuzz_targets/generator.rs index e9cc80adce..1cc24de915 100644 --- a/crates/fork-instrument/fuzz/fuzz_targets/generator.rs +++ b/crates/fork-instrument/fuzz/fuzz_targets/generator.rs @@ -13,8 +13,9 @@ use arbitrary::{Arbitrary, Unstructured}; /// Which supported tagged catch shape the generated try_table uses. /// -/// CatchAll and CatchAllRef have no deterministic tag reconstruction recipe -/// and have precise rejection tests outside this successful-output fuzzer. +/// This compact generator focuses on tagged Catch and CatchRef shapes. +/// Deterministic tests separately cover CatchAll and CatchAllRef through the +/// complete-exception reconstruction recipe. #[derive(Debug, Clone, Copy, arbitrary::Arbitrary)] enum ClauseVariant { /// (catch_ref $exn $handler) — handler receives exnref; try_table result is exnref. diff --git a/crates/fork-instrument/src/contract_inventory.rs b/crates/fork-instrument/src/contract_inventory.rs index 519a679cf7..c8d2409df8 100644 --- a/crates/fork-instrument/src/contract_inventory.rs +++ b/crates/fork-instrument/src/contract_inventory.rs @@ -1,28 +1,39 @@ //! Structural inventory for the fork-artifact publication guards. //! -//! This deliberately inspects only the sections that define the artifact -//! contract. In particular, code bodies are not decoded: large package -//! executables should not need a text disassembly just to verify their imports, -//! exports, memories, and metadata. +//! The fork-contract inventory inspects only sections. Artifact identity also +//! verifies the exact constant ABI thunk, but decodes only that function and +//! its optional delegate: large package executables should not need a full text +//! disassembly or full-module instruction decode for publication checks. use anyhow::{Context, Result, bail}; use std::fmt::{self, Write}; use wasm_posix_shared::abi::{ - WPK_FORK_CAPABILITIES_SECTION, WPK_FORK_EXPORT_ABORT_BEGIN, WPK_FORK_EXPORT_ABORT_END, - WPK_FORK_EXPORT_REWIND_BEGIN, WPK_FORK_EXPORT_REWIND_END, WPK_FORK_EXPORT_STATE, - WPK_FORK_EXPORT_UNWIND_BEGIN, WPK_FORK_EXPORT_UNWIND_END, WPK_FORK_FRAME_IMPORT_COMMIT, - WPK_FORK_FRAME_IMPORT_MODULE, WPK_FORK_FRAME_IMPORT_NEXT, WPK_FORK_FRAME_IMPORT_RESERVE, - WPK_FORK_LINKED_FRAME_FORMAT_SECTION, + ABI_KERNEL_EXPORT, WPK_FORK_CAPABILITIES_SECTION, WPK_FORK_EXPORT_ABORT_BEGIN, + WPK_FORK_EXPORT_ABORT_END, WPK_FORK_EXPORT_REWIND_BEGIN, WPK_FORK_EXPORT_REWIND_END, + WPK_FORK_EXPORT_STATE, WPK_FORK_EXPORT_UNWIND_BEGIN, WPK_FORK_EXPORT_UNWIND_END, + WPK_FORK_FRAME_IMPORT_COMMIT, WPK_FORK_FRAME_IMPORT_MODULE, WPK_FORK_FRAME_IMPORT_NEXT, + WPK_FORK_FRAME_IMPORT_RESERVE, WPK_FORK_LINKED_FRAME_FORMAT_SECTION, }; use wasmparser::{ - CompositeInnerType, Encoding, ExternalKind, FuncType, Parser, Payload, TypeRef, ValType, + CompositeInnerType, Encoding, ExternalKind, FuncType, FunctionBody, Operator, Parser, Payload, + TypeRef, ValType, }; +/// One import from Kandelo's libc/host-reserved `env.__wasm_posix_*` +/// namespace. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReservedEnvImport { + pub kind: &'static str, + pub identity: String, +} + /// The exact tab-separated inventory consumed by `wasm-artifact-guards.sh`. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct ForkContractInventory { pub relocatable: usize, + pub imports_fork_entry: usize, pub imports_kernel_fork: usize, + pub imports_side_fork: usize, pub frame_reserve: usize, pub frame_commit: usize, pub frame_next: usize, @@ -48,7 +59,7 @@ impl fmt::Display for ForkContractInventory { f, "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", self.relocatable, - self.imports_kernel_fork, + self.imports_fork_entry, self.frame_reserve, self.frame_commit, self.frame_next, @@ -70,6 +81,55 @@ impl fmt::Display for ForkContractInventory { } } +/// Strict status of the optional `__abi_version` artifact export. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ArtifactAbiVersion { + Missing, + Invalid, + Present(u32), +} + +/// Structural identity needed by executable and side-module publication guards. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ArtifactIdentity { + pub relocatable: usize, + pub memory_count: usize, + pub memory64_count: usize, + pub abi_version: ArtifactAbiVersion, + pub imports_kernel_fork: usize, + pub imports_side_fork: usize, + pub has_fork_exports: usize, + pub dylink_section_count: usize, + pub dylink_is_first_section: usize, + pub env_memory_count: usize, + pub unsupported_side_import_count: usize, +} + +impl fmt::Display for ArtifactIdentity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let (status, version) = match self.abi_version { + ArtifactAbiVersion::Missing => ("missing", "-".to_string()), + ArtifactAbiVersion::Invalid => ("invalid", "-".to_string()), + ArtifactAbiVersion::Present(version) => ("present", version.to_string()), + }; + write!( + f, + "{}\t{}\t{}\t{status}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", + self.relocatable, + self.memory_count, + self.memory64_count, + version, + self.imports_kernel_fork, + self.imports_side_fork, + self.has_fork_exports, + self.dylink_section_count, + self.dylink_is_first_section, + self.env_memory_count, + self.unsupported_side_import_count, + ) + } +} + #[derive(Debug, Clone, Copy)] enum ExpectedSignature { PointerToPointer, @@ -113,8 +173,21 @@ pub fn fork_contract_inventory(bytes: &[u8]) -> Result { TypeRef::Func(type_index) | TypeRef::FuncExact(type_index) => { let function_index = function_type_indices.len() as u32; function_type_indices.push(type_index); + if (import.module == "kernel" && import.name == "kernel_fork") + || (import.module == "env" && import.name == "fork") + { + // WHY: this inventory feeds the common + // executable/side-module publication guard. + // Both imports seed the same linked-frame + // contract, despite belonging to different + // loader roles. + inventory.imports_fork_entry = 1; + } if import.module == "kernel" && import.name == "kernel_fork" { - inventory.imports_kernel_fork = 1; + inventory.imports_kernel_fork += 1; + } + if import.module == "env" && import.name == "fork" { + inventory.imports_side_fork += 1; } if import.module == "env" && import.name == "__wasm_dlopen" { inventory.legacy_dlopen += 1; @@ -235,6 +308,389 @@ pub fn fork_contract_inventory(bytes: &[u8]) -> Result { Ok(inventory) } +/// Inspect the object kind, memory width, and strict constant ABI export in +/// one wasmparser-backed CLI request. +/// +/// WHY: ABI 43's generated reference/exception helpers use proposal features +/// that older WABT releases cannot disassemble. Publication must not confuse a +/// text-decoder limitation with an unsafe artifact, and it must not weaken the +/// exact constant-return ABI contract to work around that limitation. +pub fn artifact_identity(bytes: &[u8]) -> Result { + let contract = fork_contract_inventory(bytes)?; + let loader = artifact_loader_identity(bytes)?; + let has_fork_exports = usize::from( + contract.abort_begin + + contract.abort_end + + contract.rewind_begin + + contract.rewind_end + + contract.state + + contract.unwind_begin + + contract.unwind_end + != 0, + ); + Ok(ArtifactIdentity { + relocatable: contract.relocatable, + memory_count: contract.memory_count, + memory64_count: contract.memory64_count, + abi_version: artifact_abi_version(bytes)?, + imports_kernel_fork: contract.imports_kernel_fork, + imports_side_fork: contract.imports_side_fork, + has_fork_exports, + dylink_section_count: loader.dylink_section_count, + dylink_is_first_section: loader.dylink_is_first_section, + env_memory_count: loader.env_memory_count, + unsupported_side_import_count: loader.unsupported_side_import_count, + }) +} + +/// Inventory imports from Kandelo's reserved libc/host namespace without +/// decoding function bodies. +/// +/// WHY: compiler-generated ABI 43 reference types are newer than the WABT +/// decoder available on package builders. Publication still has to reject a +/// private libc helper that escaped as an import, so the same wasmparser +/// boundary used for artifact identity owns this structural check as well. +pub fn reserved_env_imports(bytes: &[u8]) -> Result> { + let mut reserved = Vec::new(); + for payload in Parser::new(0).parse_all(bytes) { + match payload.context("parsing wasm structure for reserved import inventory")? { + Payload::Version { encoding, .. } => { + if encoding != Encoding::Module { + bail!("reserved import inventory requires a core wasm module"); + } + } + Payload::ImportSection(imports) => { + for import in imports.into_imports() { + let import = import.context("parsing reserved import section")?; + if import.module != "env" || !import.name.starts_with("__wasm_posix_") { + continue; + } + if import.name.bytes().any(|byte| matches!(byte, b'\t' | b'\n' | b'\r')) { + bail!("reserved import name contains a control separator"); + } + let kind = match import.ty { + TypeRef::Func(_) | TypeRef::FuncExact(_) => "func", + TypeRef::Table(_) => "table", + TypeRef::Memory(_) => "memory", + TypeRef::Global(_) => "global", + TypeRef::Tag(_) => "tag", + }; + reserved.push(ReservedEnvImport { + kind, + identity: format!("{}.{}", import.module, import.name), + }); + } + } + _ => {} + } + } + Ok(reserved) +} + +#[derive(Debug, Default, Clone, Copy)] +struct ArtifactLoaderIdentity { + dylink_section_count: usize, + dylink_is_first_section: usize, + env_memory_count: usize, + unsupported_side_import_count: usize, +} + +fn artifact_loader_identity(bytes: &[u8]) -> Result { + let mut identity = ArtifactLoaderIdentity::default(); + let mut section_count = 0usize; + + for payload in Parser::new(0).parse_all(bytes) { + let payload = payload.context("parsing wasm structure for artifact loader identity")?; + if payload.as_section().is_some() { + section_count += 1; + } + match payload { + Payload::Version { encoding, .. } => { + if encoding != Encoding::Module { + bail!("artifact loader identity requires a core wasm module"); + } + } + Payload::ImportSection(imports) => { + for import in imports.into_imports() { + let import = import.context("parsing artifact loader import section")?; + if matches!(import.ty, TypeRef::Memory(_)) + && import.module == "env" + && import.name == "memory" + { + identity.env_memory_count += 1; + } + if !matches!(import.module, "env" | "GOT.mem" | "GOT.func") { + identity.unsupported_side_import_count += 1; + } + } + } + Payload::CustomSection(section) if section.name() == "dylink.0" => { + identity.dylink_section_count += 1; + if section_count == 1 { + identity.dylink_is_first_section = 1; + } + } + _ => {} + } + } + + Ok(identity) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AbiOperator { + I32Const(i32), + Call(u32), + Return, + End, +} + +#[derive(Debug, Clone, Copy)] +struct AbiBody { + operators: [Option; 3], + operator_count: usize, + exact: bool, +} + +impl AbiBody { + fn pure_constant(self) -> Option { + let value = match (self.exact, self.operator_count, self.operators) { + ( + true, + 2, + [Some(AbiOperator::I32Const(value)), Some(AbiOperator::End), None], + ) + | ( + true, + 3, + [ + Some(AbiOperator::I32Const(value)), + Some(AbiOperator::Return), + Some(AbiOperator::End), + ], + ) => value, + _ => return None, + }; + u32::try_from(value).ok() + } +} + +fn parse_abi_body(body: FunctionBody<'_>) -> Result { + for local in body + .get_locals_reader() + .context("reading ABI candidate locals")? + { + local.context("reading ABI candidate local")?; + } + + let mut operators = [None; 3]; + let mut operator_count = 0usize; + let mut exact = true; + let mut reader = body + .get_operators_reader() + .context("reading ABI candidate operators")?; + while !reader.eof() { + let operator = match reader + .read() + .context("decoding ABI candidate operator")? + { + Operator::I32Const { value } => Some(AbiOperator::I32Const(value)), + Operator::Call { function_index } => Some(AbiOperator::Call(function_index)), + Operator::Return => Some(AbiOperator::Return), + Operator::End => Some(AbiOperator::End), + _ => None, + }; + if operator_count < operators.len() { + operators[operator_count] = operator; + } else { + exact = false; + } + exact &= operator.is_some(); + operator_count += 1; + } + Ok(AbiBody { + operators, + operator_count, + exact, + }) +} + +fn function_signature<'a>( + types: &'a [Option], + function_type_indices: &[u32], + function_index: u32, +) -> Option<&'a FuncType> { + function_type_indices + .get(function_index as usize) + .and_then(|type_index| types.get(*type_index as usize)) + .and_then(Option::as_ref) +} + +fn signature_is(signature: Option<&FuncType>, params: &[ValType], results: &[ValType]) -> bool { + signature.is_some_and(|signature| { + signature.params() == params && signature.results() == results + }) +} + +fn body_for( + bytes: &[u8], + imported_function_count: usize, + function_index: u32, +) -> Result> { + let Some(local_index) = (function_index as usize).checked_sub(imported_function_count) else { + return Ok(None); + }; + let mut current_local_index = 0usize; + for payload in Parser::new(0).parse_all(bytes) { + if let Payload::CodeSectionEntry(body) = + payload.context("parsing wasm structure for ABI candidate")? + { + if current_local_index == local_index { + return parse_abi_body(body).map(Some); + } + current_local_index += 1; + } + } + Ok(None) +} + +fn artifact_abi_version(bytes: &[u8]) -> Result { + let mut types: Vec> = Vec::new(); + let mut function_type_indices = Vec::new(); + let mut imported_function_count = 0usize; + let mut code_body_count = None; + let mut abi_export_count = 0usize; + let mut abi_function = None; + + for payload in Parser::new(0).parse_all(bytes) { + match payload.context("parsing wasm structure for artifact identity")? { + Payload::Version { encoding, .. } => { + if encoding != Encoding::Module { + bail!("artifact identity requires a core wasm module"); + } + } + Payload::TypeSection(groups) => { + for group in groups { + let group = group.context("parsing artifact type section")?; + types.extend(group.into_types().map( + |subtype| match subtype.composite_type.inner { + CompositeInnerType::Func(function) => Some(function), + CompositeInnerType::Array(_) + | CompositeInnerType::Struct(_) + | CompositeInnerType::Cont(_) => None, + }, + )); + } + } + Payload::ImportSection(imports) => { + for import in imports.into_imports() { + let import = import.context("parsing artifact import section")?; + if let TypeRef::Func(type_index) | TypeRef::FuncExact(type_index) = import.ty { + function_type_indices.push(type_index); + imported_function_count += 1; + } + } + } + Payload::FunctionSection(functions) => { + for type_index in functions { + function_type_indices + .push(type_index.context("parsing artifact function section")?); + } + } + Payload::ExportSection(exports) => { + for export in exports { + let export = export.context("parsing artifact export section")?; + if export.name != ABI_KERNEL_EXPORT { + continue; + } + abi_export_count += 1; + if export.kind == ExternalKind::Func { + abi_function = Some(export.index); + } + } + } + Payload::CodeSectionStart { count, .. } => { + if code_body_count.replace(count as usize).is_some() { + return Ok(ArtifactAbiVersion::Invalid); + } + } + _ => {} + } + } + + if abi_export_count == 0 { + return Ok(ArtifactAbiVersion::Missing); + } + if abi_export_count != 1 { + return Ok(ArtifactAbiVersion::Invalid); + } + let Some(target) = abi_function else { + return Ok(ArtifactAbiVersion::Invalid); + }; + if code_body_count.unwrap_or(0) + imported_function_count != function_type_indices.len() + || !signature_is( + function_signature(&types, &function_type_indices, target), + &[], + &[ValType::I32], + ) + { + return Ok(ArtifactAbiVersion::Invalid); + } + + // WHY: publication checks run over very large package executables. The + // export and signatures are known before the code section, so decode only + // the ABI thunk rather than every unrelated compiler-generated body. + let Some(target_body) = body_for(bytes, imported_function_count, target)? else { + return Ok(ArtifactAbiVersion::Invalid); + }; + if let Some(version) = target_body.pure_constant() { + return Ok(ArtifactAbiVersion::Present(version)); + } + + match ( + target_body.exact, + target_body.operator_count, + target_body.operators, + ) { + ( + true, + 3, + [ + Some(AbiOperator::Call(leading)), + Some(AbiOperator::I32Const(version)), + Some(AbiOperator::End), + ], + ) if signature_is( + function_signature(&types, &function_type_indices, leading), + &[], + &[], + ) => Ok(u32::try_from(version) + .map(ArtifactAbiVersion::Present) + .unwrap_or(ArtifactAbiVersion::Invalid)), + ( + true, + 3, + [ + Some(AbiOperator::Call(leading)), + Some(AbiOperator::Call(delegate)), + Some(AbiOperator::End), + ], + ) if signature_is( + function_signature(&types, &function_type_indices, leading), + &[], + &[], + ) && signature_is( + function_signature(&types, &function_type_indices, delegate), + &[], + &[ValType::I32], + ) => Ok(body_for(bytes, imported_function_count, delegate)? + .and_then(AbiBody::pure_constant) + .map(ArtifactAbiVersion::Present) + .unwrap_or(ArtifactAbiVersion::Invalid)), + _ => Ok(ArtifactAbiVersion::Invalid), + } +} + /// Return the raw custom-section payload used by `wasm-objdump -s -j`, /// including the encoded section name before its data. pub fn fork_capability_section_hex(bytes: &[u8]) -> Result { diff --git a/crates/fork-instrument/src/main.rs b/crates/fork-instrument/src/main.rs index a8a21bb15a..e99fc9a891 100644 --- a/crates/fork-instrument/src/main.rs +++ b/crates/fork-instrument/src/main.rs @@ -20,7 +20,8 @@ use std::path::{Path, PathBuf}; use fork_instrument::{ Options, analyze, contract_inventory::{ - fork_capability_section_hex, fork_contract_inventory, linked_frame_descriptor_section_hex, + artifact_identity, fork_capability_section_hex, fork_contract_inventory, + linked_frame_descriptor_section_hex, reserved_env_imports, }, instrument, }; @@ -56,16 +57,41 @@ struct Cli { /// Print the fork-artifact structural inventory as one TSV row. /// This mode performs no instrumentation and emits no output file. - #[arg(long, conflicts_with_all = ["discover_only", "output"])] + #[arg( + long, + conflicts_with_all = [ + "discover_only", + "artifact_identity", + "reserved_env_imports", + "output" + ] + )] contract_inventory: bool, + /// Print relocatable, memory, and strict ABI-export identity as one TSV row. + /// This mode performs no instrumentation and emits no output file. + #[arg( + long, + conflicts_with_all = [ + "discover_only", + "contract_inventory", + "fork_capability_hex", + "linked_frame_descriptor_hex", + "reserved_env_imports", + "output" + ] + )] + artifact_identity: bool, + /// Print the unique fork-capability custom section as lowercase hex. #[arg( long, conflicts_with_all = [ "discover_only", "contract_inventory", + "artifact_identity", "linked_frame_descriptor_hex", + "reserved_env_imports", "output" ] )] @@ -77,11 +103,27 @@ struct Cli { conflicts_with_all = [ "discover_only", "contract_inventory", + "artifact_identity", "fork_capability_hex", + "reserved_env_imports", "output" ] )] linked_frame_descriptor_hex: bool, + + /// Print reserved env imports as `\t.` rows. + #[arg( + long, + conflicts_with_all = [ + "discover_only", + "contract_inventory", + "artifact_identity", + "fork_capability_hex", + "linked_frame_descriptor_hex", + "output" + ] + )] + reserved_env_imports: bool, } fn main() -> Result<()> { @@ -96,6 +138,12 @@ fn main() -> Result<()> { println!("{inventory}"); return Ok(()); } + if cli.artifact_identity { + let identity = artifact_identity(&input) + .with_context(|| format!("inspecting artifact identity: {}", cli.input.display()))?; + println!("{identity}"); + return Ok(()); + } if cli.fork_capability_hex { let hex = fork_capability_section_hex(&input) .with_context(|| format!("reading fork capability: {}", cli.input.display()))?; @@ -108,6 +156,14 @@ fn main() -> Result<()> { println!("{hex}"); return Ok(()); } + if cli.reserved_env_imports { + let imports = reserved_env_imports(&input) + .with_context(|| format!("inventorying reserved imports: {}", cli.input.display()))?; + for import in imports { + println!("{}\t{}", import.kind, import.identity); + } + return Ok(()); + } let opts = Options { entry_import: cli.entry, diff --git a/crates/fork-instrument/tests/contract_inventory.rs b/crates/fork-instrument/tests/contract_inventory.rs index 946fcfdb0c..d40b3961b9 100644 --- a/crates/fork-instrument/tests/contract_inventory.rs +++ b/crates/fork-instrument/tests/contract_inventory.rs @@ -1,6 +1,6 @@ use fork_instrument::contract_inventory::{ - ForkContractInventory, fork_capability_section_hex, fork_contract_inventory, - linked_frame_descriptor_section_hex, + ArtifactAbiVersion, ForkContractInventory, artifact_identity, fork_capability_section_hex, + fork_contract_inventory, linked_frame_descriptor_section_hex, reserved_env_imports, }; use std::fs; use std::path::PathBuf; @@ -55,6 +55,23 @@ fn inventories_gc_and_exception_modules_without_decoding_code_bodies() { ); } +#[test] +fn inventories_the_side_module_fork_entry() { + let bytes = wat::parse_str(contract_wat("i32", "(memory 1)").replace( + r#"(import "kernel" "kernel_fork" (func $kernel_fork))"#, + r#"(import "env" "fork" (func $kernel_fork))"#, + )) + .expect("compile side-module contract WAT"); + let inventory = fork_contract_inventory(&bytes).expect("inventory side module"); + + assert_eq!(inventory.imports_fork_entry, 1); + assert_eq!(inventory.imports_side_fork, 1); + assert_eq!( + inventory.to_string(), + "0\t1\t1\t1\t1\t1\t1\t1\t1\t1\t1\t1\t1\t1\t1\t0\t0\t0\t0" + ); +} + #[test] fn imported_memory64_selects_i64_pointer_signatures() { let inventory = parse_contract("i64", r#"(import "env" "memory" (memory i64 1))"#); @@ -104,6 +121,132 @@ fn counts_duplicate_contract_sections_and_function_exports() { assert_eq!(inventory.signature_mismatch, 0); } +#[test] +fn artifact_identity_reads_abi_thunks_without_decoding_modern_helpers_as_text() { + let bytes = wat::parse_str( + r#" + (module + (type $cell (struct (field (mut i32)))) + (memory i64 1) + (func $modern_helper (result (ref null $cell)) + ref.null $cell) + (func $ctors) + (func $abi_actual (result i32) + i32.const 43) + (func (export "__abi_version") (result i32) + call $ctors + call $abi_actual)) + "#, + ) + .expect("compile artifact identity WAT"); + let identity = artifact_identity(&bytes).expect("inspect artifact identity"); + + assert_eq!(identity.relocatable, 0); + assert_eq!(identity.memory_count, 1); + assert_eq!(identity.memory64_count, 1); + assert_eq!(identity.abi_version, ArtifactAbiVersion::Present(43)); + assert_eq!(identity.imports_kernel_fork, 0); + assert_eq!(identity.imports_side_fork, 0); + assert_eq!(identity.has_fork_exports, 0); + assert_eq!(identity.dylink_section_count, 0); + assert_eq!(identity.dylink_is_first_section, 0); + assert_eq!(identity.env_memory_count, 0); + assert_eq!(identity.unsupported_side_import_count, 0); + assert_eq!( + identity.to_string(), + "0\t1\t1\tpresent\t43\t0\t0\t0\t0\t0\t0\t0" + ); +} + +#[test] +fn artifact_identity_captures_side_module_loader_contract() { + let module = wat::parse_str( + r#" + (module + (import "env" "memory" (memory 1)) + (import "env" "fork" (func (result i32))) + (import "GOT.mem" "state" (global i32)) + (import "GOT.func" "callback" (global i32))) + "#, + ) + .expect("compile side-module WAT"); + let dylink = [ + 0x00, 0x0f, 0x08, b'd', b'y', b'l', b'i', b'n', b'k', b'.', b'0', 0x01, 0x04, 0x00, + 0x00, 0x00, 0x00, + ]; + let mut side_module = Vec::with_capacity(module.len() + dylink.len()); + side_module.extend_from_slice(&module[..8]); + side_module.extend_from_slice(&dylink); + side_module.extend_from_slice(&module[8..]); + + let identity = artifact_identity(&side_module).expect("inspect side-module identity"); + assert_eq!(identity.dylink_section_count, 1); + assert_eq!(identity.dylink_is_first_section, 1); + assert_eq!(identity.env_memory_count, 1); + assert_eq!(identity.unsupported_side_import_count, 0); + assert_eq!(identity.imports_side_fork, 1); + assert_eq!(identity.imports_kernel_fork, 0); +} + +#[test] +fn inventories_reserved_env_imports_in_modern_modules() { + let bytes = wat::parse_str( + r#" + (module + (type $cell (struct (field (mut i32)))) + (import "env" "__wasm_posix_after_fork_child" (func)) + (import "env" "__wasm_posix_vm_interrupt_after" + (func (param i32 i32 i32))) + (import "env" "__wasm_posix_private_memory" (memory 1)) + (import "env" "package_callback" (func)) + (func (result (ref null $cell)) + ref.null $cell)) + "#, + ) + .expect("compile modern reserved-import WAT"); + + let imports = reserved_env_imports(&bytes).expect("inventory reserved imports"); + assert_eq!( + imports + .iter() + .map(|import| (import.kind, import.identity.as_str())) + .collect::>(), + vec![ + ("func", "env.__wasm_posix_after_fork_child"), + ("func", "env.__wasm_posix_vm_interrupt_after"), + ("memory", "env.__wasm_posix_private_memory"), + ] + ); +} + +#[test] +fn artifact_identity_distinguishes_missing_and_invalid_abi_exports() { + let missing = wat::parse_str(r#"(module (memory 1))"#).expect("compile missing ABI WAT"); + assert_eq!( + artifact_identity(&missing) + .expect("inspect missing ABI") + .abi_version, + ArtifactAbiVersion::Missing, + ); + + let dynamic = wat::parse_str( + r#" + (module + (memory 1) + (global $version i32 (i32.const 43)) + (func (export "__abi_version") (result i32) + global.get $version)) + "#, + ) + .expect("compile dynamic ABI WAT"); + assert_eq!( + artifact_identity(&dynamic) + .expect("inspect invalid ABI") + .abi_version, + ArtifactAbiVersion::Invalid, + ); +} + #[test] fn cli_contract_inventory_emits_only_the_stable_tsv_row() { let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed); @@ -149,6 +292,21 @@ fn cli_contract_inventory_emits_only_the_stable_tsv_row() { "1d6b616e64656c6f2e77706b5f666f726b2e6361706162696c69746965730104\n" ); + let identity = Command::new(env!("CARGO_BIN_EXE_wasm-fork-instrument")) + .arg("--artifact-identity") + .arg(&path) + .output() + .expect("run artifact identity CLI"); + assert!( + identity.status.success(), + "artifact identity CLI failed: {}", + String::from_utf8_lossy(&identity.stderr) + ); + assert_eq!( + String::from_utf8(identity.stdout).expect("UTF-8 artifact identity"), + "0\t1\t0\tmissing\t-\t1\t0\t1\t0\t0\t0\t1\n" + ); + let descriptor = Command::new(env!("CARGO_BIN_EXE_wasm-fork-instrument")) .arg("--linked-frame-descriptor-hex") .arg(&path) @@ -169,6 +327,48 @@ fn cli_contract_inventory_emits_only_the_stable_tsv_row() { ); } +#[test] +fn cli_reserved_import_inventory_emits_typed_rows() { + let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed); + let path: PathBuf = std::env::temp_dir().join(format!( + "kandelo-reserved-import-inventory-{}-{id}.wasm", + std::process::id() + )); + fs::write( + &path, + wat::parse_str( + r#" + (module + (import "env" "__wasm_posix_after_fork_child" (func)) + (import "env" "__wasm_posix_private_global" (global i32))) + "#, + ) + .expect("compile reserved-import WAT"), + ) + .expect("write reserved-import module"); + + let output = Command::new(env!("CARGO_BIN_EXE_wasm-fork-instrument")) + .arg("--reserved-env-imports") + .arg(&path) + .output() + .expect("run reserved import inventory CLI"); + let _ = fs::remove_file(path); + + assert!( + output.status.success(), + "reserved import CLI failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + String::from_utf8(output.stdout).expect("UTF-8 reserved imports"), + concat!( + "func\tenv.__wasm_posix_after_fork_child\n", + "global\tenv.__wasm_posix_private_global\n", + ) + ); + assert!(output.stderr.is_empty()); +} + #[test] fn inventories_the_reentrant_legacy_loader_import() { let bytes = wat::parse_str( diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index ddfa3346ee..5324ff0db8 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -84,11 +84,10 @@ pub mod host_abi; /// and fork exports return kernel-allocated identities; instrumented /// modules declare the continuation format and import reserve, commit, and /// replay hooks. -/// 43: fork artifacts prove activation-state safety explicitly. Tagged -/// CatchRef replay reconstructs an instance-local exception from -/// frame-owned tag identity and scalar payloads; non-transferable -/// reference, mutable-global, and mutable-table state is rejected before -/// launch. +/// 43: fork artifacts prove activation-state safety explicitly. Activation +/// references, complete exceptions, mutable reference globals, and mutable +/// tables are serialized as versioned process-owned recipes and rebuilt +/// with fresh instance-local identities before continuation replay. pub const ABI_VERSION: u32 = 43; /// Byte width of Kandelo's Linux-compatible kernel CPU-affinity mask. diff --git a/docs/abi-versioning.md b/docs/abi-versioning.md index 7c0023d35c..819aa51e76 100644 --- a/docs/abi-versioning.md +++ b/docs/abi-versioning.md @@ -329,37 +329,88 @@ exports, linked-frame imports, or fork metadata. This prevents a transformed ABI 42 module from being run through the ABI 43 tool merely to acquire the new safety claim; package builds must instrument raw linker output. -The frame contract remains version 1 and keeps its existing size and offsets, -but the formerly reference-stash-related word at frame offset `+12` is now -reserved zero. The instrumenter no longer creates +The frame contract remains version 1 and keeps its existing 16-byte header. +Offset `+8` carries the exact dynamic catch selector and the formerly +reference-stash-related word at `+12` carries a process reference-vector +ordinal. The instrumenter no longer creates `_wpk_fork_funcref_stash`, `_wpk_fork_externref_stash`, or -`_wpk_fork_exnref_stash`. Supported statically tagged `Catch` and `CatchRef` -arms serialize their exact arm and scalar tag operands in each activation's -linked frame. During rewind the tool executes `throw` with that reconstructed -tag payload; the original `CatchRef` clause creates a fresh child-instance -exnref. - -Until a transferable representation or explicit versioned reconstruction -owner exists, the instrumenter rejects fork-reachable reference locals and -parameters, reference signatures and call carryovers, reference global reads, -reference operand-stack carryovers, reference-typed catch payloads, -`CatchAll`/`CatchAllRef`, and unsupported non-nullable, concrete, and Wasm-GC -references. References outside the conservative fork closure remain legal. -Mutable reference globals and guest table mutation are module-wide rejection -boundaries in a fork-using artifact; static element initialization remains -legal because instantiation recreates it. Dlopen is the explicit table-state -exception: host replay preserves the exact table base and re-instantiates each -side module's static element initialization in the child. +`_wpk_fork_exnref_stash`. + +Live reference locals, parameters, call operands/results, `call_ref` callees, +mutable reference globals, typed table entries, and complete exceptions use +one process-owned KFRV (Kandelo Fork Reference Vectors) recipe transaction +inside the KFMS (Kandelo Fork Module State) arena copied through linear memory. +Function/static-root catalogs reconstruct fresh instance-local identities; +typed GC recipes preserve concrete layout, cycles, aliases, and externalized +views. Materialization also re-registers weak constructor provenance for the +new object, including packed segment operands and nullable recipe-zero seeds, +so that the child can itself become the parent of a later fork. Durable +process-image handles represent opaque `externref` values. +Generated module-state helpers restore globals, table length/content, and +segment lifetime before frame replay. A generation-published sparse table +journal keeps pthread and late-dlopen replicas coherent without copying +WebAssembly functions or `exnref` values through JavaScript. + +ABI 43's POSIX dynamic-loader path is staged and non-reentrant. +`__wasm_dlopen_prepare` validates and owns a private transaction without +entering Wasm. Each `__wasm_dlopen_next` advances host-only +compilation/instantiation as needed and returns one initializer table entry. +Instrumentation removes the native start section and exposes its initialization +as an explicit bootstrap stage, so instance construction cannot run that guest +path; libc invokes each returned entry only after the import returns. +Instrumentation lowers the historical canonical two-, four-, and five-argument +`__wasm_dlopen` imports to the same protocol before computing fork +reachability. The two-argument form retains its historical +`dlopen::` identity. The original imported +function identity becomes a local tail adapter, preserving table and `ref.func` +aliases without leaving a host callback under initialization. +Artifact publication and host launch reject an ABI 43 safety claim if the +legacy import or a native start section remains. Input modules may use a start +section, but an accepted completed transform must expose it only through +`wpk_fork_module_bootstrap`. The lower-level `DynamicLinker.dlopenSync()` +driver is an embedder API, not an accepted process import. The process Worker +must perform final instantiation and Store-local function/tag registration +even when kernel policy coordinates the load, because those identities cannot +be cloned from the kernel Worker. + +ABI 43 also assigns channel-header offset 68 to `request_flags`. +`REQUEST_FLAG_DEFER_SIGNAL_DELIVERY` marks a request whose completion is +consumed by process-worker JavaScript rather than libc's ordinary post-syscall +signal trampoline. The kernel leaves a caught signal pending for such a +completion instead of dequeuing it into a channel record that JavaScript +cannot deliver. After `fork`, `clone`, or a staged-loader import returns, libc +issues a side-effect-free `getpid` checkpoint through the ordinary channel +path; that completion owns normal handler delivery and signal-mask restoration. +The flag changes neither the continuation encoding nor any activation's frame +size. + +Statically tagged scalar `Catch`/`CatchRef` arms serialize their exact selector +and maximum live scalar tag tuple. During rewind the tool executes `throw` with +that reconstructed payload; the original clause creates a fresh +child-instance exnref. Reference/vector payloads, `CatchAll`, `CatchAllRef`, +JSTag ingress, and normalized legacy-EH cleanup paths use the +complete-exception recipe and likewise throw inside Wasm. Transaction cleanup +clears temporary tables, roots, and owner leases after replay or abort. + +The capability therefore attests to present reconstruction machinery, not a +conservative source-shape rejection pass. Valid reference-bearing code outside +the fork closure remains unmodified; valid reference-bearing code inside the +closure receives the typed ownership path. Artifact validation still rejects +malformed/version-mismatched contracts and pre-instrumented ABI 42 input before +execution. This is an incompatible artifact epoch even though the linked-frame descriptor version is unchanged. All fork-instrumented programs, side modules, package bottles, binary indexes, shell closures, and VFS images must be rebuilt from source. Existing C++ modern-EH outputs that retain exnref locals or use -`CatchAllRef` are truthful rebuild blockers, not candidates for metadata -relabeling or package-specific bypasses. The current Dash build is likewise -blocked by a fork-reachable exnref local in `expandstr`, so no ABI 43 shell -closure or rootfs/VFS image is presently publishable. Broad bottle, index, -shell, and image publication requires explicit release coordination. +`CatchAllRef`, and the Dash `expandstr` cleanup path, are supported rebuild +inputs through those recipes; they are not candidates for metadata relabeling +or package-specific bypasses. The ABI 43 development shell/rootfs closure can +be rebuilt from source. Broad bottle, index, shell, and image publication still +requires explicit release coordination. The exact archive-generation, +rootfs/image, and Homebrew sequencing and isolation boundary is recorded in +the [ABI 43 activation-state-safe artifact rebuild +plan](plans/2026-07-25-abi-43-activation-state-safe-rebuild-plan.md). ## The snapshot diff --git a/docs/architecture.md b/docs/architecture.md index 04695c210f..60f00a0c41 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -280,7 +280,7 @@ Offset Size Field 8 48 arguments (6 × i64) 56 8 return_value (i64) 64 4 errno_value (i32) -68 4 reserved/padding +68 4 request_flags (i32) 72 65536 data_buffer (for path strings, read/write buffers, etc.) ``` @@ -292,6 +292,15 @@ because that is the C calling convention its callers use. The non-variadic `__syscallN` and cancellation-point `__syscall_cp` paths widen values to 64 bits before calling the glue layer so offsets and lengths are not truncated. +Bit 0 of `request_flags`, +`REQUEST_FLAG_DEFER_SIGNAL_DELIVERY`, identifies a completion consumed by +process-worker JavaScript instead of libc's post-syscall signal trampoline. +The kernel leaves caught signals pending on those completions. Fork, clone, +continuation allocation/cleanup, and staged-loader VFS/memory requests set the +bit and clear it before returning control to guest code. Libc then uses an +ordinary side-effect-free `getpid` syscall as the signal-delivery checkpoint +after the owning import returns. Ordinary guest syscalls clear the flags word. + ### Status Values | Value | Name | Meaning | @@ -324,6 +333,13 @@ Process Worker Kernel Worker (host) 15. Return to caller ``` +Steps 10–15 normally include caught-signal publication and libc handler +dispatch. A request marked `REQUEST_FLAG_DEFER_SIGNAL_DELIVERY` deliberately +omits that publication because JavaScript owns its completion and has no +signal-handler trampoline. The next explicit guest checkpoint performs the +same delivery only after the host import has returned; this avoids both signal +loss and a reentrant host-to-Wasm callback. + ### Blocking Syscalls and Retry Some syscalls (read from empty pipe, accept on socket, poll with timeout) cannot complete immediately. The kernel returns `-EAGAIN` and the host enters a retry loop: @@ -432,13 +448,19 @@ embedded ABI version, linked-frame contract, control exports, and ABI 43 apply the same policy. 1. User calls `fork()` → musl → `__syscall(SYS_clone, ...)` → glue -2. The host's `kernel_fork` override maps a root continuation chunk and calls `wpk_fork_unwind_begin(root + chunk_header_size)`. The tool-injected export sets state to UNWINDING and snapshots every mutable scalar global (including `__tls_base` and `__stack_pointer`) into the root's fixed prefix. +2. The host's `kernel_fork` override begins one process continuation + transaction. It captures activation catalogs and module state, maps each + participating activation's root continuation chunk, and calls + `wpk_fork_unwind_begin(root + chunk_header_size)`. The tool-injected export + sets state to UNWINDING and snapshots every mutable scalar global (including + `__tls_base` and `__stack_pointer`) into that activation's fixed prefix. 3. The return-to-caller chain unwinds. After each fork-path call returns in the unwinding state, the caller asks the host to reserve a complete node before its first frame write; its postamble commits the node only after all - activation-owned scalar state has been saved. The host maps additional - page-rounded chunks when necessary. No accepted frame names a - module-instance reference-table slot. + activation-owned scalar state has been saved. Live references are interned + into one process recipe graph and the frame stores only its reference-vector + ordinal. The host maps additional page-rounded chunks when necessary. No + accepted frame names a module-instance reference-table slot. 4. Once `_start` returns (top-of-stack), the host sends SYS_FORK through the channel. 5. Kernel's `kernel_fork_process(parent_pid, caller_tid)` validates the caller, allocates the child PID from the global task-ID sequence, and copies process @@ -450,7 +472,16 @@ apply the same policy. inherited with the process state. The worker creates a fresh Wasm instance: mutable globals, tables, exception references, and Store-owned references are not copied and are not evidence that replay state survived. -7. Child worker attaches to the copied root and calls `wpk_fork_rewind_begin(buf)` — the tool's export restores all saved globals. The host then calls `setupChannelBase(...)` (which reads the now-correct `__tls_base`) and invokes `_start`. +7. The child validates the copied KFRV/KFMS arena, instantiates every required + main/side activation, materializes static roots, typed GC objects, opaque + owner tokens, and complete exceptions, then restores reference globals, + table contents/length, and segment lifetime. Only after those owners are + ready does it call `wpk_fork_rewind_begin(buf)` to restore scalar globals. + Typed object allocation also installs the child's own weak constructor + provenance, so a later nested fork encodes child-local objects rather than + depending on identities retained from the original parent. + The host then calls `setupChannelBase(...)` (which reads the now-correct + `__tls_base`) and invokes the selected main or pthread resume root. 8. Each instrumented function's preamble requests and validates the next committed frame, then re-enters the call site where the parent was interrupted. Eventually it reaches the `kernel_fork` call site in the leaf function, which returns 0. Libc then refreshes the copied pthread TID from the kernel through `set_tid_address` before returning to user code. 9. `wpk_fork_rewind_end` resets state; parent and child independently unmap their continuation chunks; fork returns 0 in child and the child PID in the parent. @@ -482,21 +513,23 @@ errno. A negative `SYS_FORK` result after step 4 instead uses the complete parent rewind. These resource failures create no child and leave the parent in `NORMAL`, able to continue or retry `fork()`. -ABI 43 accepts statically tagged `Catch` and `CatchRef` handlers with scalar tag -payloads. Their exact arm and operands are activation-owned bytes; rewind -rethrows the tag so the original `CatchRef` clause creates a fresh -instance-local exnref. Fork-reachable reference locals, `CatchAllRef`, reference -payloads and carryovers, mutable reference globals, and arbitrary guest table -mutation are rejected during instrumentation. Current LLVM C++ output that -uses cleanup exnref locals or `CatchAllRef` therefore remains an explicit -unsupported artifact boundary rather than appearing to work through -parent-instance scratch. See -[fork-instrumentation.md](fork-instrumentation.md) for the exact accepted and -rejected shapes. - -A fork reached directly inside an instrumented dlopened side module uses two -ordered state machines and two linked continuations: side then main during unwind, main -then side during rewind. Versioned fork-instrument capability metadata lets +ABI 43 reconstructs reference locals/parameters/carryovers, concrete and +abstract GC objects, mutable reference globals, and complete exception state. +Statically tagged scalar `Catch`/`CatchRef` arms keep their exact selector and +maximum live operand tuple in activation-owned bytes; rewind rethrows the tag +so the original clause creates a fresh instance-local exnref. +Reference/vector payloads, `CatchAll`/`CatchAllRef`, JSTag ingress, and modern +C++ cleanup exnrefs use the complete-exception recipe and likewise re-enter the +original Wasm handler without parent-instance scratch. See +[fork-instrumentation.md](fork-instrumentation.md) for the ownership formats +and cleanup ordering. + +A fork reached inside instrumented dlopened side modules uses one process-wide +event journal plus one linked continuation per active module. Unwind records +the exact leaf-to-root activation/function order; fresh-child replay consumes +the reverse order. This supports nested main-to-side-to-side stacks without +assuming that the main activation is present at the leaf. Versioned +fork-instrument capability metadata lets marker-present artifacts prove their role, and ABI 43 additionally requires `FORK_CAP_ACTIVATION_STATE_SAFE` before launch. ABI 16 defines the historical five-export fallback, while ABI 18 and later require role claims and reject @@ -504,9 +537,9 @@ stale call-graph artifacts. The ABI 36 epoch combines that contract with side-module replay state and concurrent pthread-fork arbitration. Dlopen replay records both the parent's memory base and exact table base, including null gaps left by failed loads, then re-instantiates each side module's static element -initialization at that exact base in the child. This host-owned, versioned replay -path is the explicit reconstruction owner for supported dlopen table state; -guest `table.set`/`fill`/`copy`/`init`/`grow` effects are not accepted. +initialization at that exact base in the child. The process table journal then +applies later loader and guest `table.set`/`fill`/`copy`/`init`/`grow` effects +through typed recipes after every referenced activation catalog is present. TLS-bearing side modules additionally record their live, positive `__tls_base`. A child restores the pointer-width-correct mutable global without calling `__wasm_init_tls`, because copied memory already holds @@ -515,21 +548,66 @@ and application `thread_local` values. C++ exceptions and longjmp use one canonical pointer-width tag identity across the main image and all side modules; a main-exported tag wins over the host-created fallback. +ABI 43 libc drives dynamic initialization as a non-reentrant transaction. +`__wasm_dlopen_prepare` validates and owns a private transaction without +entering guest code. Each `__wasm_dlopen_next` advances host-only compilation +and instantiation of the `DT_NEEDED` closure as needed. Instrumentation has +removed the native start section and converted active segments plus the +original start function into an explicit bootstrap, so +`new WebAssembly.Instance(...)` cannot run that guest path inside `next`. +The call publishes one canonical initializer table entry and returns; libc +then calls that entry as ordinary Wasm before requesting the next stage. A +constructor that calls `fork()` thus has a normal instrumentable Wasm call +chain rather than a suspended host import beneath it. Before reachability +analysis, the instrumenter turns the +historical two-, four-, and five-argument private `__wasm_dlopen` function +imports into in-place local adapters that prepare the transaction and +tail-call an ordinary Wasm driver for their initializers. This retains all +aliases of the old function without retaining the reentrant host boundary. +The two-argument form retains its original +`dlopen::` identity. ABI 43 artifact and launch +validation require both the legacy import and the completed artifact's native +start section to be absent. Valid source modules may contain a start section; +instrumentation transfers it to `wpk_fork_module_bootstrap` before admission. +The lower-level `DynamicLinker.dlopenSync()` driver remains available to +standalone embedders, but it is not a process import. + +The kernel cannot replace the process-local half of this protocol. It can own +path authorization, loader scheduling, and replay policy, but the kernel +Worker cannot inject Store-local functions, exception tags, or GC identities +into the process Worker's tables. Core Wasm cannot instantiate arbitrary +runtime module bytes itself, and those JavaScript references are not +structured-clonable between Workers. A kernel syscall would therefore need a +request/yield/resume protocol that still delegates instantiation and catalog +registration to the process Worker; it would relocate, rather than remove, the +boundary. In particular, using the generic syscall import instead of a named +loader import would not itself change reentrancy. The contract is that every +process-local host operation returns before guest initialization begins. + +The process worker can issue VFS and mapping requests while a staged loader +import is active, but those JavaScript-owned channel completions cannot invoke +libc's signal trampoline. They set +`REQUEST_FLAG_DEFER_SIGNAL_DELIVERY`, leaving any caught signal in the kernel, +and libc performs an ordinary checkpoint after every `prepare`/`next` return +and after `dlclose`. Constructors still begin only after both the import and +checkpoint return, so signal delivery and dynamic initialization add no +host-to-Wasm reentrancy. + The dlopen replay list and its atomic pthread-fork lock live in a transient, host-private control record. The same host build writes and reads that record during one process lifetime; guest code and persisted artifacts never interpret it. Changing that record's size is therefore not a guest ABI change, while the public ABI snapshot/classifier remains authoritative. -Pthread workers have separate Wasm instances, tables, and exception tags, none -of which can be structured-cloned from the process worker. `dlopen()` from a -pthread therefore fails normally with `dlerror()`. If the process has loaded a -side module, `fork()` from a pthread returns `ENOTSUP`; an atomic process lock -excludes a racing main-worker dlopen across the pthread's archive check, -unwind, memory copy, and parent rewind. The supported -direct-main-to-side boundary and the remaining opaque cross-side callback -limitation are specified in -[fork-instrumentation.md](fork-instrumentation.md#fork-from-a-dlopened-side-module). +Pthread workers have separate Wasm instances, tables, and exception tags, so +no JavaScript reference is structured-cloned from the process worker. Each +pthread owns a local dynamic-linker replica. Under the process archive lock it +compares a shared generation, instantiates missing side modules at their exact +bases, registers fresh function/exception catalogs, and applies the typed table +journal. The same mechanism supports `dlopen`/`dlsym` from a pthread and fork +from a pthread after dynamic loading; the fork child reconstructs only the +calling thread but receives the process module/table recipe state. The +generation fast path avoids reparsing or reinstantiating unchanged state. Fork and non-forking spawn still copy each process's fd and OFD metadata. The objects whose mutable state must remain identical across those copies use @@ -1379,6 +1457,14 @@ Signals are delivered at syscall boundaries. When a process has a pending signal Features: RT signal queuing with `si_value`, cross-process `kill`/`killpg`, `sigaltstack` with shadow stack swap, `sigsuspend`, `sigtimedwait`, `setitimer`/`alarm` via host timers. +The exception is a channel request whose completion is owned by +process-worker JavaScript. Its ABI 43 request flag tells the kernel not to +dequeue a caught signal into a record that JavaScript cannot consume. The +signal remains pending until libc makes the explicit ordinary-channel +checkpoint after `fork`, `clone`, or a staged-loader import. Handler invocation +and `rt_sigreturn` therefore still occur on the guest side after the host +import has returned. + Exact-thread delivery never degrades into process-wide delivery. `tkill` and `tgkill` resolve their target against retained live task records in the calling process; TID 0 and unknown or exited TIDs return `ESRCH`. Cross-process diff --git a/docs/fork-instrumentation.md b/docs/fork-instrumentation.md index aefe445beb..1d92d03e76 100644 --- a/docs/fork-instrumentation.md +++ b/docs/fork-instrumentation.md @@ -37,9 +37,12 @@ ABI version: `43` (see linked continuation or the output of a versioned deterministic reconstruction recipe in the fresh child. Module globals and tables are instance state, not evidence that a value survived `fork()`. -- ABI 43 fork artifacts must carry the activation-state-safe capability. An - unsupported reference or table shape fails during instrumentation or - pre-launch artifact validation; it must not become a child-only trap. +- ABI 43 fork artifacts must carry the activation-state-safe capability. The + capability means that activation references, exceptions, mutable reference + globals, and mutable table state have versioned reconstruction owners. An + incomplete, malformed, or old-ABI ownership contract fails during + instrumentation or pre-launch artifact validation; it must not become a + child-only trap. - Binaries exporting legacy `asyncify_*` symbols are stale and must be rebuilt. Do not add host support for them. - Do not keep compiler/linker flags solely for the retired legacy path. The @@ -160,8 +163,9 @@ section `kandelo.wpk_fork.capabilities`. Its two-byte payload is functions and conservatively instrumented every `call_indirect` boundary plus its direct callers; - bit 2 (`0x04`, `WPK_FORK_CAP_ACTIVATION_STATE_SAFE`): the instrumenter - validated the complete fork closure and rejected state that cannot be - reconstructed in a fresh module instance. + emitted and validated the complete ABI 43 activation, reference, exception, + module-state, and table-reconstruction contracts required by a fresh module + instance. ABI 43 requires exactly one two-byte capability section, version 1, with bit 2 set. Unknown bits, missing/duplicate/malformed sections, or a missing safety bit @@ -191,13 +195,18 @@ linker output so the new validation sees the original activation and table state. The source package projection may be regenerated while developing this epoch, but broad bottle/index/VFS publication requires explicit release coordination. -Modern C++ exception artifacts that contain fork-reachable `exnref` locals or -`CatchAllRef` are currently rebuild blockers: the ABI 43 instrumenter rejects -them truthfully until a sound liveness or reconstruction design exists. -The current source build of Dash is blocked for the same reason in -`expandstr`, where LLVM emits a fork-reachable `exnref` local. Consequently, -the ABI 43 shell closure and canonical rootfs/VFS images cannot be rebuilt or -published yet; ABI 42 images must not be relabeled for this epoch. +Reference-bearing modern C++ exception output and Dash's fork-reachable +`exnref` cleanup state are reconstruction inputs, not package-specific +exceptions. The ABI 43 source-build path now accepts those shapes through the +typed reference and complete-exception recipes. A development rootfs containing +the configured shell closure can therefore be rebuilt, but it is not a +published release artifact. Existing ABI 42 packages, bottles, indexes, shell +closures, and VFS images must still be rebuilt through their normal source +paths and must never be relabeled. Broad publication remains a separately +coordinated release action. See the [ABI 43 activation-state-safe artifact +rebuild plan](plans/2026-07-25-abi-43-activation-state-safe-rebuild-plan.md) +for the exact registry generation count, derived-image order, and ABI 42 +Homebrew-proof isolation boundary. `ptr` is `i32` on wasm32 user programs and `i64` on wasm64 user programs. The tool picks the pointer width from the module's primary memory — a memory64 @@ -276,42 +285,96 @@ P-06 (`pthread_create` worker calls `fork`), and K-03 ## Fork from a dlopened side module -The supported dynamic-linking shape is a direct main-module `call_indirect` -into one side-module instance whose call stack reaches `env.fork`: - -1. Instrument the main program normally. If it imports Kandelo's dlopen host - functions, the tool marks and preserves all possible dynamic indirect-call - boundaries. -2. Instrument the fork-capable side module with `--entry env.fork`. It receives - its own linked continuation and versioned side-entry capability. -3. The process worker unwinds the side module, then the main module. Fork replay - restores dlopen instances at their exact memory and table bases, rewinds the - main module, then rewinds the active side module. +Dynamic linking participates in the same process-wide activation protocol as +the main module. The supported stack is not limited to one main-to-side call: +the event journal records arbitrary main-to-side-to-side nesting, including +calls through shared-table function pointers and side-originated +`dlopen`/`dlsym`. Each participating module owns a separate linked +continuation; the journal supplies their exact leaf-to-root +activation/function order. + +The ABI 43 POSIX `dlopen()` path deliberately separates host-owned +instantiation from guest initialization: + +1. Libc reads the side-module bytes through ordinary file operations and calls + `__wasm_dlopen_prepare`. +2. The process worker copies and validates the request, claims loader + ownership, creates a private transaction, and returns its token without + entering guest Wasm. +3. Each `__wasm_dlopen_next` advances host-only compilation/instantiation of + the complete `DT_NEEDED` closure as needed, recording provisional module + identities, memory/table bases, symbol visibility, dependency/provider + edges, and rollback ownership. ABI 43 instrumentation has removed every + native start section and converted active segments plus the original start + function into an explicit bootstrap, so instantiation cannot enter that + guest path. The call publishes at most one canonical `() -> ()` bootstrap, + relocation, or constructor entry in a transaction-owned table slot and + returns its index. +4. Libc calls that entry only after the import has returned. The initializer is + therefore an ordinary Wasm-to-Wasm activation and may call another side + module or `fork()`. The next host call acknowledges the completed stage and + either returns another entry or atomically commits the public handle. + +This staged path is non-reentrant: no host import calls back into Wasm before +returning. ABI 43 libc uses only the staged `prepare`/`next` path. Before call +graph discovery, the instrumenter also replaces either historical canonical +two-, four-, or five-argument `env.__wasm_dlopen` import in place with a local +adapter. Retaining the original function identity preserves direct calls, +exports, table elements, and `ref.func` aliases. The adapter prepares the load +and then tail-calls a generated driver; after the import has returned, that +driver invokes each initializer through the process function table and commits +the transaction. The original two-argument form had no pathname and retains +its deterministic historical `dlopen::` module +identity. ABI 43 host and publication guards require the legacy import count +and native start-section count to be zero, so stale or forged safety metadata +cannot expose either the monolithic callback or instantiation-time guest +reentry. Source modules may contain a start section; the zero-count rule +applies to the completed instrumented artifact after its start has become an +explicit bootstrap. `DynamicLinker.dlopenSync()` remains a lower-level embedder +API, not an import reachable from an accepted ABI 43 process artifact. + +The staged host can issue internal VFS and mapping channel requests while an +import is active. Those completions set the ABI 43 +`REQUEST_FLAG_DEFER_SIGNAL_DELIVERY` bit because process-worker JavaScript +cannot run libc's signal trampoline. The kernel leaves a caught signal pending, +and libc performs an ordinary `getpid` checkpoint after each staged import +returns and before it calls a guest initializer. Fork and clone use the same +ownership handoff. This preserves signal delivery without calling back into a +suspended Wasm import frame and adds no continuation or activation-frame +bytes. + +Moving preparation behind a kernel syscall would not eliminate the +process-worker portion of loading. Core Wasm cannot compile arbitrary module +bytes or manufacture the fresh Store-local function, exception-tag, and GC +identities needed by the process table. Those JavaScript objects also cannot +be structured-cloned from the kernel Worker. The kernel may own pathname +authorization, process policy, and serialization, but the process Worker must +still instantiate and register each side module. A syscall-based loader would +therefore require a loader request/yield/resume protocol around the same +process-local work. It could replace the named loader import with the generic +syscall import, but it would not remove the host transition. Safety comes from +returning to Wasm before any initializer is called, not from which import +performs the process-local work. ABI 43 uses the smaller staged-import +protocol. The main fork trampoline is captured before side exports enter the symbol table, so a later extension cannot interpose the coordinator's `fork` target. -Failed dlopen attempts may leave non-shrinkable null table gaps; each successful -archive entry records its exact parent table base, and child replay pads to and -validates that base. - -The loader preserves ordinary independent multi-extension loading. When a -fork-capable extension participates, it rejects statically visible -side-to-side function/GOT linkage and side-originated `dlopen`/`dlsym`, because -an intervening side-module frame would need a third ordered unwind. Opaque -function pointers passed through main-module memory or the shared table cannot -currently be attributed to their originating module; using such a pointer to -create a side A -> side B -> fork path is unsupported and is not yet guaranteed -to fail before control-flow corruption. A future module-activation protocol is -required to close that residual. - -Pthread workers do not own the process worker's side-module instances, table, -or exception-tag identities. `dlopen()` from a pthread consequently returns -NULL with a precise `dlerror()`. Once the process main worker has published a -dlopen archive entry, `fork()` from a pthread returns `ENOTSUP` without -creating a child. A host-private atomic lock prevents main-worker dlopen from -racing the pthread's archive check and is held through unwind, SYS_FORK/memory -copy, and parent rewind; the child clears its copied lock before replay. Fork -from a pthread remains supported while that process-wide archive is empty. +Failed loads may leave non-shrinkable null table gaps. Successful archive +events retain exact parent memory/table bases, handle values, +`RTLD_LOCAL`/`RTLD_GLOBAL` visibility, dependency/provider edges, and nested +transaction rollback state. A fresh child recreates modules in dependency +order, pads to and validates their exact bases, registers their function and +exception catalogs, and only then applies the process table journal and +activation replay. + +Pthread workers have distinct Wasm instances, tables, tags, and Stores; no +JavaScript reference is copied between them. Each pthread therefore owns a +local dynamic-linker replica driven by the process archive's generation +journal. A host-private loader-owner lease serializes staged initialization +across workers, while a shorter archive lock publishes complete records. +Unchanged generations take a fast path. This supports `dlopen`/`dlsym` from a +pthread and `fork()` after dynamic loading; the fork child recreates the +calling thread's local replica and process module/table state. For TLS-bearing side modules, each archive entry also preserves the live positive `__tls_base`. Replay restores only that mutable global using the @@ -391,11 +454,13 @@ Parent and child independently walk and unmap their copies after rewind. The linked format makes chunk boundaries explicit, but version 1 does not rebase internal pointers or relocate the chain in the child. -Mutable reference globals (`funcref` / `externref` / `exnref`) are not stored -in the linear-memory header. A fork-capable module containing one is rejected -before runtime injection because its current value belongs to the parent module -instance. The inert runtime injected into a module with no fork seed may leave -unrelated reference globals alone because that module never replays. +Mutable reference globals (`funcref`, `externref`, `exnref`, and typed GC +references) are not stored in the scalar linear-memory header. Generated +module-state helpers encode them into the process reference graph during +capture and restore them in the fresh activation before any continuation frame +executes. Immutable imported references use the same activation/template +catalog during early instantiation. A global outside the fork closure remains +ordinary Wasm state and does not pay activation-frame overhead. ## Frame format @@ -407,31 +472,58 @@ format version, transactional state, previous-node pointer, payload size, and total aligned node size. That header costs 24 bytes on wasm32 and 32 bytes on wasm64 before alignment. -| Offset | Size | Field | Purpose | -|--------|------|-------------------|------------------------------------------| -| `+0` | 4 | `func_index` | Ordinal assigned at instrument time | -| `+4` | 4 | `call_index` | Which call site within the function | -| `+8` | 4 | `catch_region_id` | 0 in normal flow; non-zero for catches | -| `+12` | 4 | reserved | Deterministic zero in ABI 43 | -| `+16` | var | `saved_locals[]` | User and synthetic scalars, aligned | - -Every value in a frame is scalar. Fork-reachable reference locals, parameters, -signatures, global reads, call carryovers, and reference-typed catch payloads -are rejected before rewriting; the instrumenter never substitutes a -module-instance table slot for a transferable value. - -Synthetic frame locals include call-argument and operand-stack carryover -spills. For each supported tagged-catch region they also include one -`active_arm` i32 and typed scalar operand locals for every static arm. Capture -therefore belongs to one function activation, and recursive activations -serialize distinct values in distinct linked frames. - -`catch_region_id` is zero in the common case (the frame was captured outside -any catch handler). When non-zero, it identifies the `try_table` whose catch -handler the frame lives in. The restored `active_arm` and operand locals select -the exact static `Catch` or `CatchRef` clause. Rewind throws that arm's tag and -scalar payload; for `CatchRef`, normal Wasm exception dispatch creates a fresh -child-instance exnref. See [Catch-handler resume](#catch-handler-resume). +| Offset | Size | Field | Purpose | +|--------|------|----------------------------|---------| +| `+0` | 4 | `func_index` | Ordinal assigned at instrument time | +| `+4` | 4 | `call_index` | Which call site within the function | +| `+8` | 4 | exact catch selector | Zero outside reconstructed catch flow; otherwise the exact region/arm | +| `+12` | 4 | reference-vector ordinal | Process-transaction recipe vector for this landing; zero when none | +| `+16` | var | `saved_scalars[]` | User/synthetic scalars and scalar catch payload union, aligned | + +References are deliberately not copied into the frame and never name a +module-static stash slot. Existing live reference locals and parameters are +encoded into a call-specific process recipe vector; the frame owns only the +ordinal in its existing header word. Definitely-null values need no recipe. +The child decodes each recipe against its own activation, function/static-root +catalog, imported-global owner, GC layout, exception codec, or durable +externref owner. + +This constant-per-frame reference representation is a stack-depth requirement, +not only a space optimization. The standalone PR #701 V8 reproducer measured +an instrumented recursive function falling from 9,959 surviving calls to 6,639 +when its declaration grew from four to twelve locals. PR #713 reduced the +generated-local count to eight and recovered 8,536 calls in the same +measurement context; PR #714 replayed pure scalar inputs and restored the +fixture's original four-local declaration. ABI 43 therefore does not add a +generated local or linked-frame field per live reference, recipe, catch arm, or +catch region. Reference-vector entries live in the process transaction arena, +and catch scratch is pooled by simultaneously live type/width rather than +static source count. Absolute engine limits remain platform- and tier-specific, +so these historical measurements are constraints on generated shape, not a +current performance claim. + +Synthetic scalar locals include only call arguments and operand-stack +carryovers that cannot be replayed directly. Catch code uses one exact-arm +selector per function and one typed operand-scratch union sized to the maximum +simultaneously selected payload, not one tuple per static arm or region. +Reference-bearing and untagged exceptions are retained through the complete +exception recipe and do not add linked-frame payload bytes. Rewind either +rethrows a saved scalar tag payload or asks the exception codec to materialize +the complete exception before the original `Catch`, `CatchRef`, `CatchAll`, or +`CatchAllRef` control path resumes. + +Rewind also avoids inserting a no-argument resume thunk in front of every +materialized direct activation. When the event journal proves that the next +activation is the direct lexical callee, the caller executes the original call +with its reconstructed arguments; the callee preamble validates the expected +activation and function through `frame_next` before consuming it. A universal +thunk would add a second native engine frame for each recursive Wasm +activation and can exhaust the engine stack well before the continuation +chain is exhausted. Indirect/reference calls, cross-module or +tail-transparent boundaries, and targets whose lexical identity is not proven +still use the process resume catalog. The lexical fast path adds no +ordinary-activation local and no continuation bytes; its second +non-consuming event lookup runs only during replay. ## Dispatch schemes @@ -845,10 +937,10 @@ The SubRegion spill list is computed by `analyze_subregion_spill_types` tracks the typed operand stack as `Vec>` and reports the full list of values to spill per landing — covering both the SubRegion's declared type-params AND any extra carryover above them on the parent -stack. `seq_has_unsupported_carryover` runs first as a gate; post-2.6c -it rejects only IfElse-with-carryover and SubRegions with unsupported -result types (multi-value RESULTs are still gated, though body PARAMS -are now supported). +stack. The current analyser covers scalar, vector, reference/GC, direct, +indirect, and `call_ref` producers as well as multi-value structured-control +parameters and results. Scalar/vector spill locals join the linked payload; +reference spills join the landing's process recipe vector. **Multi-value-params bodies (sub-commit 2.6c).** When a SubRegion is a multi-value `Block`/`Loop`/`TryTable` whose body uses its declared input @@ -873,27 +965,23 @@ replayed from an empty stack. The whitelist is deliberately small: - non-trapping i32/i64 binary arithmetic, bit operations, shifts, rotates, and integer comparisons. -The whitelist excludes calls, memory/table operations, globals, reference -operations, integer div/rem, floating-point operators, `local.set`/`local.tee`, -and any instruction that needs stack input from before the suffix. Unsupported -or type-mismatched suffixes fall back to the existing spill-local path. This -keeps REWIND behavior tied to the same post-call/post-landing sequence while -avoiding frame locals for common compiler shapes like recursive -`walk(depth - 1)` arguments and `eqz(depth)` branch conditions. - -**Function-level analyser gate.** When `walk_seq_for_carryovers` or -`compute_nested_carryover_types` encounters a producer whose pushed type -the analyser can't statically track (Unop, Cmpxchg, ref-typed -CallIndirect/CallRef, multi-value structured control), the unknown slot -is tracked as `None` and tolerated as long as it's consumed before any -fork-path call. Only if a `None` slot ends up IN a carryover does the -analyser fail the switch-dispatch classification for that shape. -The same `Option` policy applies to the top-level -`compute_carryover_types` for switch-dispatch (top-level) routing. If a -function still reaches an unsupported carryover shape, the tool rejects that -shape loudly; there is no guard-dispatch fallback after the mega-PR cleanup. - -## Reference and table-state validation +The materialization whitelist excludes calls, memory/table operations, globals, +reference operations, integer div/rem, floating-point operators, +`local.set`/`local.tee`, and any instruction that needs stack input from before +the suffix. A suffix outside that replay-safe optimization is still supported: +the typed spill/recipe path preserves it instead. This keeps REWIND behavior +tied to the same post-call/post-landing sequence while avoiding frame locals +for common compiler shapes like recursive `walk(depth - 1)` arguments and +`eqz(depth)` branch conditions. + +**Function-level analyser invariant.** `walk_seq_for_carryovers`, +`compute_carryover_types`, and `compute_nested_carryover_types` must determine +the exact pushed types for every valid producer that reaches a fork landing. +An unknown slot consumed earlier is irrelevant; an unknown live carryover is an +instrumenter typing defect to fix, not an accepted source-program limitation. +There is no guard-dispatch fallback after the mega-PR cleanup. + +## Reference and table-state ownership ABI 43 retires `_wpk_fork_funcref_stash`, `_wpk_fork_externref_stash`, and `_wpk_fork_exnref_stash`. The tool never @@ -903,40 +991,72 @@ instance whose tables are empty. JavaScript cannot generically transfer `funcref`/`externref` across workers or Stores, and the Table API cannot copy `exnref`. -Validation runs after fork-closure discovery and before runtime injection. In a -fork-reachable function it rejects: - -- reference locals, parameters, function signatures, global reads, call - signatures, and operand-stack carryovers; -- `CallRef`/`ReturnCallRef`, unsupported nonnullable/concrete/GC reference - instructions, and reference-typed catch tag payloads; -- `CatchAll` and `CatchAllRef`, because there is no static tag identity to save. - -Reference-bearing functions outside the fork closure remain legal and are not -rewritten. Mutable reference globals are rejected module-wide in a -fork-capable module because code outside the closure may mutate them before a -later call into `fork()`. - -Wasm table mutation is likewise module-instance state. If a module can fork, -the presence of `table.set`, `table.fill`, `table.copy`, `table.init`, or -`table.grow` anywhere in its local functions rejects the artifact. Active -static element initialization remains legal because instantiation recreates -it. Dynamic linking is an explicit host-owned reconstruction boundary rather -than an exception to this rule: the dlopen archive preserves each side -module's exact table base, the child replays libraries in order, and normal -side-module instantiation recreates their static elements. A different table -mutation owner must define and test an equally deterministic recipe before -the instrumenter may accept it. +Closure and liveness analysis runs before rewriting so functions wholly outside +the fork closure remain untouched. Within a live activation, the generated +representation is selected by value class: + +- scalar locals, parameters, arguments, and carryovers use the linked frame; +- `funcref` values use an activation-scoped immutable function catalog; +- static references use the fresh instance's static-root catalog; +- concrete and abstract GC references use versioned typed struct/array/i31 + recipes with graph identity established before recursive fields, preserving + cycles and aliases; +- externalized GC values pass through Wasm's `any.convert_extern` / + `extern.convert_any` bridge so their typed identity is not mistaken for an + opaque host object; +- opaque `externref` values use a process-image owner handle. Each Worker has a + generation-branded canonical token; imports resolve the token at the owner + boundary rather than transferring the JavaScript object; +- complete Wasm/JSTag exceptions use an exception recipe whose payload + references the same process graph. + +Reference-bearing function signatures, `call_ref`/`return_call_ref`, nullable +and non-null concrete types, reference arguments/results, and reference +operand-stack carryovers all use those same recipes. A fresh child materializes +providers first, restores module state second, then consumes continuation +frames. Capture, successful replay, abort replay, process-image replacement, +and worker teardown clear transaction-local tables and leases so +instrumentation does not retain stale GC roots. + +Constructor provenance is recreated as part of typed GC materialization. +Immutable arrays and mutable aggregates with non-defaultable reference seeds +cannot always be allocated from their final field snapshot alone, so the +recipe records the exact constructor layout, up to sixteen scalar operand +bytes, and typed seed edges. The generated allocate helper registers that same +weak provenance for the fresh child object before releasing its staging +record. Consequently a child can fork again and reconstruct an equivalent +grandchild; it never needs a weak-map entry keyed by the parent's Store-local +object. Nullable constructor seeds, including the unobservable seed of a +zero-length array, remain canonical recipe zero. + +Mutable reference globals and tables are module-state, not activation-frame +fields. Generated KFMS helpers save mutable globals, table length, sparse dirty +pages, element/data segment lifetime, and typed entries. Static initialization +is recreated by instantiation; runtime `table.set`, `table.fill`, `table.copy`, +`table.init`, and `table.grow` effects are restored from the process-owned +state. A generation-published table journal brings pthread replicas to the +same state before indirect/table-reference use. The dynamic-link archive first +recreates side modules at their exact memory/table bases and registers their +function catalogs; table-state replay then resolves entries against those +fresh functions. Publication writes records before the generation fence, so a +reader can never treat a partially initialized recipe as current. ## Catch-handler resume -Catch-handler resume saves a reconstruction recipe, never an exception -reference. Normal handler entry records the lexical region, exact catch-list -arm, and that static tag's scalar payload in activation-owned locals. Those -locals serialize with the function frame. Rewind dispatches inside the same -`try_table` body, restores the selected scalar tuple, and executes the -selected arm's `throw $tag`. Normal Wasm exception dispatch reaches the -original clause; `CatchRef` receives a new exnref owned by the child instance. +Catch-handler resume saves a reconstruction recipe, never a parent-instance +exception reference. Normal handler entry records one function-wide exact +region/arm selector. A statically tagged scalar arm stores its tag payload in a +typed scratch union that overlays the maximum active tuple in the linked frame. +Rewind dispatches inside the same `try_table` body, restores the selected tuple, +and executes the selected arm's `throw $tag`. Normal Wasm exception dispatch +reaches the original clause; `CatchRef` receives a new exnref owned by the child +instance. + +Reference-bearing tag payloads, vector payloads, `CatchAll`, `CatchAllRef`, and +legacy-EH cleanup handlers use the complete-exception codec. The codec retains +the caught value only for the activation lifetime needed to encode its recipe, +then reconstructs and throws it inside Wasm during replay. It never asks +JavaScript to return an `exnref`. ``` ┌────────────────────────────────────────────────────────────────────┐ @@ -961,7 +1081,7 @@ original clause; `CatchRef` receives a new exnref owned by the child instance. │ state == REWINDING, load our frame │ │ │ │ try_table body rewind-throw stub: │ -│ state == REWINDING && catch_region_id == K → │ +│ state == REWINDING && catch_selector == (K, A) → │ │ validate arm A; push saved scalar payload; throw $tag_A │ │ ← caught by the original Catch/CatchRef clause; CatchRef │ │ creates a fresh child-instance exnref. │ @@ -973,11 +1093,12 @@ original clause; `CatchRef` receives a new exnref owned by the child instance. └────────────────────────────────────────────────────────────────────┘ ``` -Mixed `Catch`/`CatchRef` lists, multiple arms, and distinct target labels use -the same exact catch-list index. An unknown restored index executes -`unreachable`; it cannot fall back to old instance state. `CatchAllRef` is -rejected, as are reference-typed tag payloads and a caught exnref that remains -live in a local or operand-stack carryover at the fork call. See +Mixed `Catch`/`CatchRef` lists, multiple arms, distinct target labels, +`CatchAll`/`CatchAllRef`, reference-bearing payloads, recursion, loop re-entry, +and catches followed by an ordinary merged fork all use the same exact +activation selector and process recipe graph. An unknown selector, malformed +recipe, stale process generation, or catalog mismatch traps before child code +can consume partial state; replay cannot fall back to old instance state. See [Fork from a tagged catch](#fork-from-a-tagged-catch) under "Maintainer notes" for the implementation. @@ -1050,17 +1171,30 @@ K-04, and K-07 cover the current behavior. `wpk_fork_unwind_begin` and restored in `wpk_fork_rewind_begin`. Includes `__stack_pointer`, `__tls_base`, and any program-declared mutable globals. -- **try_table context.** Frames captured inside a supported fork-path catch - handler carry the active `catch_region_id`, exact catch-list arm, and scalar - tag operands. Rewind rethrows the restored tag and operands through the - original `Catch` or `CatchRef` clause. A `CatchRef` clause therefore creates - a fresh exnref in the child instance; no parent-instance reference is - serialized or consulted. -- **No retained replay references.** The instrumenter emits none of the - historical `_wpk_fork_*ref_stash` tables. The temporary exnref used while a - `CatchRef` handler enters is cleared immediately after the original handler - value has been re-pushed, so normal completion, rewind, and abort do not - retain it as an instrumentation-owned GC root. +- **Reference activation state.** Live reference locals, parameters, + reference call arguments/results, call-ref callees, and operand-stack + carryovers are represented by typed process recipes and decoded into the + fresh activation. Definitely-null references consume no recipe entry. +- **Mutable reference globals and tables.** KFMS module-state helpers restore + reference globals, sparse table contents and length, and passive-segment + lifetime before frame replay. Process generation fencing keeps pthread + replicas and late dynamic-link consumers coherent. +- **Exception context.** Frames captured inside a catch handler carry the + exact dynamic region/arm selector. Scalar tagged payloads occupy an overlaid + maximum-size tuple; reference/vector payloads and untagged catches use a + complete-exception recipe. Replay throws inside Wasm so `CatchRef` and + `CatchAllRef` receive fresh child-instance exnrefs. +- **No stale replay roots.** The instrumenter emits none of the historical + `_wpk_fork_*ref_stash` tables. Temporary codec slots, retained caught + exceptions, anyref transit entries, and externref handle leases are cleared + on normal completion, successful replay, abort, process-image replacement, + and worker teardown. +- **Frame-pressure bounds.** Ordinary reference recipes add no source-function + local and no bytes beyond the existing 16-byte linked-frame header: + catch selector and vector ordinal reuse `+8` and `+12`. Catch operand storage + is colored by maximum simultaneously live type tuple rather than static arm + count. Generated helper-function locals and the process recipe arena are + outside every ordinary native activation. - **Kernel-side-effect calls don't re-fire during REWIND.** Switch-dispatch (the only live scheme post-commit-4) skips the body chunks before the matching `POST_K` entirely on REWIND, so non-fork-path direct calls @@ -1068,42 +1202,18 @@ K-04, and K-07 cover the current behavior. side-effect ops in those chunks run exactly once, on the parent's NORMAL pass. No per-call or per-op gating is needed. -### Not guaranteed (unsupported patterns) +### Boundaries outside activation replay - **`makecontext` / `swapcontext` / `getcontext` / `setcontext`.** Userspace stack-switching primitives are unsupported and not on any roadmap. See [posix-status.md](posix-status.md) for rationale. -- **Reference activation state.** Reference-typed locals or parameters, - reference function signatures and call carryovers, reference global reads, - reference operand-stack carryovers, and reference-typed catch payloads are - rejected when they are in the conservative fork closure. `CatchAll` and - `CatchAllRef` are also rejected there because they provide no statically - tagged scalar reconstruction recipe. References in functions outside the - fork closure remain legal. -- **Module-owned mutable reference state.** A mutable reference global is - rejected whenever a module has a fork closure. The child receives a fresh - module instance, so copying linear memory cannot reproduce that global. -- **Mutable table state.** Guest `table.set`, `table.fill`, `table.copy`, - `table.init`, and `table.grow` are rejected in a fork-using module. Static - element initialization is recreated by instantiation and remains supported. - Host-owned dlopen replay is a separate explicit reconstruction boundary: it - preserves the exact table base and re-instantiates the side module's static - elements in the child. -- **IfElse with operand-stack carryover.** A fork-bearing `if/else` - enclosing a stack value that survives across the branch is rejected by - `seq_has_unsupported_carryover` — the cond rewrite via `select` (see - §IfElse cond rewrite) doesn't currently compose with carryover spilling. - Rare in LLVM output; not tracked as a current blocker. -- **Non-nullable, concrete, and Wasm-GC refs.** Unsupported reference - construction and GC operations in the fork closure are rejected before - rewriting. Support requires an activation-owned byte representation or a - deterministic fresh-instance reconstruction recipe, not a new module-static - table. -- **Current C++ cleanup-EH shapes.** LLVM output that keeps exnref locals live - across fork or lowers cleanup regions to `CatchAllRef` is intentionally - rejected. Those programs must remain unavailable in ABI 43 until the - compiler output or replay design satisfies the ownership invariant; a - capability stamp or package patch must not hide this boundary. +- **Host engine proposal support.** The input must be a valid module for both + the transform's parser and the target Node/browser engine. The ABI does not + emulate a WebAssembly proposal that the selected engine itself cannot + instantiate. +- **Stale or incomplete artifacts.** ABI 42 fork artifacts, copied capability + bytes, malformed recipe metadata, and mixed-version host/module contracts + fail before execution. They are rebuild inputs, not compatibility modes. #### Closed since the mega-PR's 2.5/2.6 sub-commits @@ -1173,12 +1283,17 @@ encoding. The fixed 60 KiB host-reserved control-region geometry remains in place from ABI 42, but it is no longer continuation capacity: only its anchor word is used to find the dynamically allocated root chunk. -A function with supported tagged catches adds one i32 `active_arm` local per -region plus typed scalar operand locals for every supported `Catch` or -`CatchRef` arm to each activation's frame payload. This can use more aggregate -continuation bytes than one module-global tuple, but distinct activation -storage is required for recursion and reentrancy correctness and avoids any -module-global replay tuple. +The ABI 43 deferred-signal request flag occupies the channel header, and the +post-import checkpoint is an ordinary syscall. Neither adds bytes to the +linked continuation, its 16-byte frame header, or an activation payload. + +A function with tagged catches uses one function-wide exact-arm selector and +one typed scalar operand union colored to the maximum simultaneously live +payload. The fixed frame header remains 16 bytes, references add no frame +bytes, and additional catch arms do not each allocate a tuple. A scalar catch +payload can still enlarge that function's frame by the maximum live tuple; +activation-owned storage is required for recursion and reentrancy correctness +and replaces the unsafe module-global tuple. As a narrow size check, instrumenting the P-10 deep-recursion fixture from the same 27,886-byte raw Wasm produced 50,873 bytes with the ABI 41 instrumenter @@ -1281,7 +1396,7 @@ Do not add a module-static reference stash. A fresh fork child has a new Wasm instance, table, Store, and exception-tag identity, so a slot number is not a transferable value even if it happens to fix same-instance recursion. -Support for a new reference shape requires one of two complete designs: +Support for a new reference shape extends one of two complete designs: 1. Encode every value needed by replay as versioned activation-owned bytes in the linked continuation, then reconstruct the reference deterministically @@ -1289,10 +1404,12 @@ Support for a new reference shape requires one of two complete designs: 2. Name an explicit host reconstruction owner, version its recipe, and prove Node, browser, pthread, and side-module parity. -Add rejection tests first, then fresh-instance replay tests that would fail if -the parent module's globals or tables were consulted. Update the capability -contract and bump the ABI if the accepted artifact surface or reconstruction -format changes. +Add a positive fresh-instance replay test that would fail if the parent +module's globals or tables were consulted, plus malformed/version-mismatch +tests for the ownership contract. A valid source shape is not converted into +an instrumentation rejection merely because its reconstruction provider is +new work. Update the capability contract and bump the ABI if the accepted +artifact surface or reconstruction format changes. ### Extending side-effect coverage @@ -1307,41 +1424,49 @@ tests in `crates/fork-instrument/tests/coverage_wat.rs`. `Catch` arms unwrap the thrown exception's operand tuple at handler entry. `CatchRef` arms additionally push an instance-local exnref. Neither reference identity nor module scratch is available in a fresh child, so both forms replay -from the same statically tagged scalar recipe. +from activation-owned selectors and typed recipes. The implementation adds that path without accessing continuation memory during ordinary catch execution: -1. **Static discovery (`plan_plain_catches`).** Walk each fork-path - function and collect each supported `Catch` or `CatchRef` arm's tag, target - label, exact catch-list index, kind, and scalar operand types. This plan has - no runtime addresses or activation state. -2. **Activation allocation.** Allocate one region-local i32 arm selector plus - typed scalar operand locals for every supported arm. A `CatchRef` arm also - gets one temporary nullable exnref local used only while entering the - capture block. -3. **Frame ownership.** Append the selector and scalar operands before frame - offsets are assigned. Each recursive or reentrant activation therefore - owns a distinct serialized catch recipe. -4. **Capture.** A generated block stores the incoming scalar tuple and exact - arm index, then restores the original handler stack. For `CatchRef`, it - temporarily stores the exnref, pushes it back as non-null for the original - target, and clears the synthetic local immediately so instrumentation does - not retain a stale GC root. -5. **Replay.** `inject_rewind_throw_stubs` dispatches on the restored exact arm - index, pushes its scalar tuple, and executes `throw` with the original tag. - The original clause then reconstructs either the plain payload or a fresh - child-local exnref. An unknown arm traps instead of consulting old instance - state. +1. **Static discovery (`plan_plain_catches`).** Walk each fork-path function + and collect every `Catch`, `CatchRef`, `CatchAll`, and `CatchAllRef` arm's + tag when present, target label, exact catch-list index, kind, and operand + types. Legacy `try` handlers are normalized to the same modern-EH control + representation. This plan has no runtime addresses or activation state. +2. **Activation allocation.** Allocate one function-wide exact-arm selector + and a typed operand-scratch union sized by maximum simultaneous use. + Scalar `CatchRef` forwarding uses one short-lived nullable exnref scratch; + complete-exception arms retain only the liveness-colored recipe roots + required at a fork landing. +3. **Frame ownership.** Header word `+8` stores the selector. Scalar payload + types overlay one maximum-size frame range; reference/vector payloads are + edges in the process recipe graph. Each recursive or reentrant activation + therefore owns a distinct recipe without cost proportional to static arm + count. +4. **Capture.** A generated block records the incoming tuple or complete + exception and exact selector, then restores the original handler stack. + Short-lived forwarding scratch is cleared before user code; retained recipe + roots are cleared by transaction completion or abort. +5. **Replay.** `inject_rewind_throw_stubs` dispatches on the restored selector. + Scalar arms push their tuple and execute `throw` with the original tag. + Complete-exception arms materialize and throw inside Wasm. The original + clause then reconstructs its payload and, for reference clauses, a fresh + child-local exnref. An unknown selector traps instead of consulting old + instance state. The lifetime boundary is load-bearing: a catch can run before any fork or after a prior continuation has been released. Its normal capture path must therefore never dereference `_wpk_fork_buf`. -C-08/C-09 in -`crates/fork-instrument/tests/coverage_wat.rs` verify that funcref and -externref catch operands are rejected precisely rather than serialized as -scalars or placed in a module-static table. +C-08/C-09 verify the transformed funcref/externref catch shapes. The Node +`catch-ref-fresh-worker` test and Chromium continuation gate additionally +execute non-null funcref and nullable externref payloads through `CatchRef` in +new process Workers; the child calls the reconstructed funcref and receives a +fresh child-local exnref. The module-exception, GC-codec, process-owner, and +mailbox suites separately cover vector payloads and non-null opaque externref +ownership. Together these gates prove that catch operands use typed recipes +rather than being misclassified as scalars or placed in a module-static table. ## See also diff --git a/docs/package-management.md b/docs/package-management.md index a80e72a091..25b15f3a7f 100644 --- a/docs/package-management.md +++ b/docs/package-management.md @@ -875,9 +875,12 @@ parsed `DepsManifest` at load time) and defaults to 1 when Program packages that use fork instrumentation also hash the fork-instrument host tool inputs (`crates/fork-instrument`, the -workspace Cargo lockfile, and the wrapper/build scripts). Programs -that declare `fork_instrumentation = "disabled"` do not hash that -tooling. +target-unfiltered non-dev Cargo dependency closure selected from the +workspace lockfile, and the wrapper/build scripts). The dependency closure is +the union across build-host target predicates: package cache paths do not have +a build-host dimension, so filtering through the current macOS or Linux host +would give identical source trees different identities. Programs that declare +`fork_instrumentation = "disabled"` do not hash that tooling. The global toolchain/sysroot fingerprint covers the reproducible build environment and sysroot recipe: the Nix flake, Rust toolchain file, diff --git a/docs/plans/2026-07-25-abi-43-activation-state-safe-rebuild-plan.md b/docs/plans/2026-07-25-abi-43-activation-state-safe-rebuild-plan.md new file mode 100644 index 0000000000..ccd2161c72 --- /dev/null +++ b/docs/plans/2026-07-25-abi-43-activation-state-safe-rebuild-plan.md @@ -0,0 +1,156 @@ +# ABI 43 activation-state-safe artifact rebuild plan + +Status: development plan only. No canonical package, bottle, index, shell, or +VFS publication is authorized by this document. + +## Why + +ABI 43 changes the ownership contract for fork continuations. A fresh child +must reconstruct reference locals, exceptions, mutable reference globals, and +mutable tables from activation/process-owned state; an ABI 42 artifact cannot +be made safe by relabeling it. The ABI number and +`FORK_CAP_ACTIVATION_STATE_SAFE` capability must therefore move together +through every executable, archive, index, and derived image. + +This rebuild must not delay, mutate, reuse, or publish over the separate ABI 42 +Bash/Homebrew proof. In particular, do not modify the +`emdash/homebrew-complete-qk044` worktree, PR #1094, its branch, its commits, or +its publication namespaces. + +## Frozen-input gate + +Do not start a publishable rebuild until all of the following are true: + +1. The instrumenter, host imports, table journal, pthread path, side-module + replay, and cleanup contracts have stopped changing. +2. `ABI_VERSION` is 43, `abi/snapshot.json` and generated TypeScript constants + are regenerated, and both ABI checks pass. +3. The source-controlled program-package projection is regenerated after the + final instrumenter/tool digest. That digest must use the target-unfiltered + non-dev Cargo dependency union; a current-host-filtered graph gives macOS + and Linux different keys in a cache namespace that has no host dimension. + A stale projection must fail the limited rootfs-scope derivation instead of + selecting an incomplete rebuild. +4. Fresh-instance Node and browser tests, pthread fork, side-module/dlopen + replay, artifact guards, and the selected POSIX/package gates are green. +5. Brandon has explicitly approved the exact final head for kernel/fork + integration. This plan does not authorize merge or publication. + +## Kandelo package archive scope + +The guest ABI is an input to every library/program cache key. The registry +currently contains 77 ABI-bound packages: + +- 10 libraries, producing 14 architecture generations: + `icu`, `libcurl`, `libcxx`, `libiconv`, `libpng`, `libxml2`, `libzip`, + `openssl`, `sqlite`, and `zlib`; +- 67 programs, producing 69 architecture generations. The committed program + projection covers 65 of those packages/67 generations; the special `kernel` + and `userspace` packages add one wasm32 generation each. + +That is 83 ABI-bound `(package, architecture)` archive generations. The +source-only `pcre2-source` package is not itself a guest-ABI artifact; it is +rebuilt only if its own source-package identity changes. Do not bump package +`revision` merely for the ABI epoch: ABI 43 already changes the cache key. + +Of the 65 projected program packages, 57 packages/59 generations contain an +output whose fork-instrumentation policy is `auto`. These outputs must be +rebuilt from raw linker output with the ABI 43 instrumenter. The eight +all-disabled packages (`homebrew-bootstrap`, `nginx-php-vfs`, `nginx-vfs`, +`node`, `node-vfs`, `redis-vfs`, `spidermonkey`, and +`spidermonkey-node`) still need ABI 43 archive generations because the archive +ledger and any embedded ABI-bound dependencies are single-epoch; disabling +fork instrumentation is not permission to reuse an ABI 42 archive. + +Build libraries before their transitive program consumers, then publish only +to an isolated PR-staging or run-specific merge-candidate ledger. The complete +candidate index must have top-level ABI 43, contain only `-abi43-` archive +identities, and pass archive/artifact guards before it can be considered for +canonical activation. + +### Current isolated staging evidence + +Exact-head run `30193794024` is prepublication evidence, not a complete +candidate. All 14 library generations and 56 of 62 attempted program +generations built successfully. The six attempted failures are `shell` plus +its five direct consumers (`lamp`, `nginx-php-vfs`, `nginx-vfs`, `node-vfs`, +and `wordpress`); every one failed closed because the available Homebrew VFS +metadata declares ABI 41 while the candidate requires ABI 43. This proves one +coordinated missing-input boundary rather than six independent transform +failures. + +Seven further ABI-bound program generations were not attempted by the +inherited staging workflow: the expensive-package exclusion covers +`erlang-vfs`, `perl`, `perl-vfs`, `python-vfs`, `redis`, and `texlive`, while +`sqlite-cli` has no staging build block. The isolated `pr-1096-staging` index +therefore contains 70 successful entries and six failed entries, not all 83 +required generations. It must not be promoted, used as a complete test-gate +input, or described as a publication candidate. The successful independent +rootfs job built all 336 declared paths into a 16,787,687-byte ABI 43 image, +which is useful closure evidence but does not fill the missing generations. + +## Rootfs and derived image scope + +The exact current wasm32 rootfs closure is 15 package generations: + +`bash`, `bc`, `coreutils`, `dash`, `diffutils`, `file`, `findutils`, `gawk`, +`grep`, `m4`, `make`, `ncurses`, `posix-utils-lite`, `sed`, and `rootfs`. + +A local source build of this closure is useful early evidence, but the +`stage-rootfs-closure-only` path is deliberately incomplete and cannot be used +for prepare-merge. After the final tool digest, regenerate the package +projection, derive the scope mechanically, and require it to select these +generations with their new cache keys. + +After the full dependency archive set is available, rebuild every composite +runtime/image output whose cache key or embedded executable changes. The +current projection includes: + +- `rootfs.vfs`, `shell.vfs.zst`, and `kandelo-sdk.vfs.zst`; +- `erlang-vfs`, `lamp`, `mariadb-test`, `mariadb-vfs`, `nginx-php-vfs`, + `nginx-vfs`, `node-vfs`, `perl-vfs`, `python-vfs`, `redis-vfs`, and + `wordpress` VFS outputs; +- CPython/Ruby runtime archives, Nethack/Vim browser bundles, and the Texlive + bundle where their owning package generation changes. + +The browser-facing checked or published images must be created from the exact +ABI 43 candidate index and tested as immutable candidate bytes in Node and +Chromium/Firefox/WebKit. Do not copy an ABI 42 executable into a new image or +rewrite its metadata. + +## Homebrew scope and isolation + +Homebrew uses the separate `bottles-abi-v43` namespace and Formula-controlled +bottle identities. An ABI 43 acceptance run must rebuild the 36 direct roots +in `homebrew/main-shell.Brewfile` plus the exact transitive bottle closure, +regenerate sidecars/provenance, and build a new content-addressed Homebrew VFS +acceptance image. Formula revision/bottle-rebuild changes belong to that +coordinated run; do not make speculative bumps in the fork implementation PR. + +The existing ABI 42 Bash/Homebrew proof remains an independent input and +historical result. Do not edit its worktree, commits, sidecars, bottle +namespace, index, shell lock files, or VFS image to make ABI 43 validation pass. +ABI 43 must succeed from its own rebuilt bytes. + +## Ordered execution + +1. Freeze code and generated ABI/package metadata. +2. Run focused instrumenter/host/fresh-instance tests and the full required + dev-shell validation on the implementation head. +3. Rebuild the 15-generation rootfs closure locally as an early source-build + proof; do not publish it. +4. Build all 83 ABI-bound registry generations into isolated staging, + dependency order first, and seal one complete ABI 43 candidate index. +5. Build all derived VFS/runtime/bundle artifacts from that exact candidate. +6. Run Node, browser, pthread, side-module/dlopen, libc/POSIX/Sortix, Bash, + shell, package-guard, and lifecycle coverage against the candidate bytes. +7. In a separate coordinated Homebrew run, build the ABI 43 bottle closure, + sidecars, shell closure, and content-addressed VFS evidence without touching + the ABI 42 proof. +8. Report exact successful, failed, and unrun generations/tests. Canonical + index activation, bottle publication, VFS publication, and merge require + explicit coordination and Brandon's approval of the exact head. + +ABI 42 releases remain immutable historical state. If ABI 43 validation fails, +leave its candidate/staging evidence isolated and fix the platform or rebuild +input; do not fall back to a mixed-ABI index or relabeled artifact. diff --git a/docs/porting-guide.md b/docs/porting-guide.md index 32a1be1702..0d9631f411 100644 --- a/docs/porting-guide.md +++ b/docs/porting-guide.md @@ -599,7 +599,7 @@ library dep) for canonical references; the schema reference is in kind = "program" # or "library" or "source" name = "myprog" version = "1.2.3" -kernel_abi = 42 # current ABI_VERSION; required for packages with a [build] block +kernel_abi = 43 # current ABI_VERSION; required for packages with a [build] block depends_on = ["zlib@1.3.1"] # transitive deps the resolver will pull first [source] diff --git a/docs/posix-status.md b/docs/posix-status.md index 255a07b720..1d1e682013 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -121,7 +121,7 @@ same final-OFD lifetime rules. | Function | Status | Notes | |----------|--------|-------| -| `fork()` | Partial | The kernel validates the calling task, allocates the child PID, and copies process state; the host starts a child Worker with copied Memory. The child inherits the calling task's blocked signal mask, and libc refreshes a copied pthread TID from the kernel before returning from `fork()`. Initial launch mirrors the environment into kernel-owned process state; fork copies that metadata while instrumented rewind preserves the live libc `environ` in copied Memory, and `execve()` replaces both from its supplied `envp`. `wasm-fork-instrument` resumes the child at the call site with preserved stack locals and mutable globals. Root or later continuation-allocation failure and a negative `SYS_FORK` result unwind transactionally, create no child, and return the failure to the still-running parent. Main-thread and pthread fork are supported, as is the documented direct main-to-one-side-module path; nested/opaque cross-side callbacks and fork from a pthread inside a side module remain unsupported. Pipes, sockets, PTYs, eventfd/timerfd/signalfd, memfd, procfs snapshots, and shared mappings retain their existing backings; signal and wait lifecycle state is copied/coordinated by the kernel. An inherited directory drops the parent's process-local host iterator and lazily reopens at the copied next-record cookie, so handles cannot alias, but later parent/child cursor movement is not shared. Ordinary regular-file OFD seek positions/status flags have the same copied rather than shared boundary. See [fork-instrumentation.md](fork-instrumentation.md) and the known OFD gap below. | +| `fork()` | Partial | The kernel validates the calling task, allocates the child PID, and copies process state; the host starts a child Worker with copied Memory. The child inherits the calling task's blocked signal mask, and libc refreshes a copied pthread TID from the kernel before returning from `fork()`. Host-owned continuation and fork channel requests leave caught signals kernel-pending; after the import returns, libc performs an ordinary syscall checkpoint so the guest signal trampoline owns handler invocation and mask restoration without host-to-Wasm reentrancy. Initial launch mirrors the environment into kernel-owned process state; fork copies that metadata while instrumented rewind preserves the live libc `environ` in copied Memory, and `execve()` replaces both from its supplied `envp`. `wasm-fork-instrument` resumes the child at the call site with scalar locals in linked frames and versioned reconstruction recipes for references, exceptions, globals, tables, and dynamic-link activations. Root or later continuation-allocation failure and a negative `SYS_FORK` result unwind transactionally, create no child, and return the failure to the still-running parent. Main-thread and pthread fork are supported, including nested main/side-module stacks and process-owned dynamic-link/table replay. Pipes, sockets, PTYs, eventfd/timerfd/signalfd, memfd, procfs snapshots, and shared mappings retain their existing backings; signal and wait lifecycle state is copied/coordinated by the kernel. An inherited directory drops the parent's process-local host iterator and lazily reopens at the copied next-record cookie, so handles cannot alias, but later parent/child cursor movement is not shared. Ordinary regular-file OFD seek positions/status flags have the same copied rather than shared boundary. See [fork-instrumentation.md](fork-instrumentation.md) and the known OFD gap below. | | `exec()` | Partial | Kernel-initiated via SYS_EXECVE (syscall 211). The host preflights the module, ABI, replacement memory, caller, deferred file actions, and a 4 MiB combined argv/environment representation (strings, terminators, and pointer entries) before replacing the image in place; individual strings are limited to 64 KiB and oversize returns `E2BIG` without truncation. Preserves PID, non-CLOEXEC fds and their exact kernel-backed object state, new argv/envp (including an explicitly empty environment), CWD, the calling pthread's signal mask and directed queue, terminal queues, and `alarm()`/`ITIMER_REAL`; closes directory streams, deletes `timer_create()` timers, publishes and detaches old mappings, terminates sibling threads, and resets the program break before installing the new `__heap_base`. File mappings retain a stable writeback handle even after their original fd closes. Remaining gaps: POSIX message-queue descriptors are not process-owned and therefore cannot yet be closed on exec; epoll registrations track numeric fds rather than OFD identity, so close/dup and same-number replacement cases are incomplete; and main-thread-directed signals share the process-pending queue and therefore cannot be distinguished from process-directed signals when a worker pthread execs. | | `wait()` / `waitpid()` / `wait4()` / `waitid()` | Partial | Rust-owned child status covers stop, continue, normal exit, and signal death. New status replaces older unconsumed status; `waitid(WNOWAIT)` preserves the current record. `WNOHANG`, `WUNTRACED`/`WSTOPPED`, `WEXITED`, and `WCONTINUED` are supported, as are specific-PID, any-child, same-process-group, and specific-process-group selection. Stop/continue reports do not reap; consuming exit status does. A top-level host launch has `ppid=0`; its status is consumed by the host API, and the host asks Rust to reap it only after its Workers can issue no more syscalls. `wait4()` returns the zero-filled resource-usage wire record described under `getrusage()`. Remaining gap: a blocked `pid == 0` / `P_PGID,id == 0` wait currently re-evaluates the caller's process group on each host retry instead of freezing it at call entry. | | `exit()` / `_exit()` | Partial | Closes all fds and dir streams, releases locks and mapping/backing ownership, and retains the low eight status bits. Normal codes 128–255 remain distinct from signal termination, which is stored separately. SIGCHLD is delivered to a guest parent and guest-child zombie state remains until `waitpid()` reaps it. The host separately reaps only exited direct children of `ppid=0` after Worker teardown. Orphan adoption is not yet implemented when a guest parent exits. | @@ -144,7 +144,7 @@ same final-OFD lifetime rules. | `futex()` | Partial | FUTEX_WAIT, FUTEX_WAKE, FUTEX_REQUEUE, FUTEX_CMP_REQUEUE, and FUTEX_WAKE_OP operate on one process's shared memory. Main-process WAIT uses host `Atomics.waitAsync`; pthread workers use direct `Atomics.wait`. Separate processes have separate `SharedArrayBuffer` objects, so these operations do not wake or synchronize a peer PID even when the futex word lies in a host-coordinated MAP_SHARED mapping. | | `execve()` | Partial | Delegates to the in-place `exec()` path and has the same remaining descriptor/signal/mapping limitations described above. | | `execveat()` | Partial | SYS_EXECVEAT (386). Resolves fd path via `kernel_get_fd_path`, supports AT_EMPTY_PATH for `fexecve()`, and resolves relative paths against process CWD; otherwise has the same remaining `exec()` limitations. | -| `fork()` (syscall) | Partial | Glue traps through channel IPC; the kernel copies process state, the host starts a child Worker, and `wasm-fork-instrument` replays the supported call stack so parent/child receive the POSIX return values. ABI 43 requires the activation-state-safe artifact capability before launch and rejects non-reconstructible reference or mutable table state during instrumentation. Negative results replay to the caller without terminating the parent, including continuation-allocation failure before or during unwind. The reference-shape, side-module, and ordinary-OFD limitations in the main `fork()` row still apply. | +| `fork()` (syscall) | Partial | Glue traps through channel IPC; the kernel copies process state, the host starts a child Worker, and `wasm-fork-instrument` replays the call stack so parent/child receive the POSIX return values. ABI 43 requires the activation-state-safe artifact capability before launch and validates the linked-frame, reference/exception recipe, mutable module-state, table-journal, and activation-catalog contracts. Unsafe ABI 42, malformed, or mixed-version artifacts fail before execution. Negative results replay to the caller without terminating the parent, including continuation-allocation failure before or during unwind. The ordinary-OFD limitations in the main `fork()` row still apply. | | `vfork()` | Partial | Alias for `fork()` and therefore has the same continuation/OFD limitations. It neither suspends the calling parent thread nor shares that process memory with the child until `exec()` or `_exit()`, so it cannot avoid Kandelo's eager fork-memory copy. | | `posix_spawn()` | Partial | **Non-forking implementation** (this kernel's invention; no Linux equivalent). Glue issues `SYS_SPAWN` (500) with a marshalled blob (argv + envp + file actions + spawn attrs). The host passes the calling TID to `kernel_spawn_process`; the Rust `ProcessTable` validates that task, allocates the child PID from the global task-ID sequence, and builds the child Process descriptor before `onSpawn` launches a fresh Worker. The child inherits the calling task's signal mask unless `POSIX_SPAWN_SETSIGMASK` replaces it. No fork, no `wpk_fork_*` rewind, no exec replay. Supports POSIX_SPAWN_SETSID / SETPGROUP / SETSIGMASK / SETSIGDEF and FDOP_OPEN / CLOSE / DUP2 / CHDIR / FCHDIR. SIG_IGN dispositions persist across the implicit exec; custom handlers reset to SIG_DFL (POSIX exec semantics). An inherited directory never aliases the parent's live host iterator: spawn preserves its next-record cookie and lazily reopens a child-owned iterator there. Its later cursor movement and ordinary-file OFD metadata are still process-local rather than shared; see the known OFD gap below. Regression-guarded: `kernel_get_fork_count` exposes a per-process counter the test suite asserts is unchanged across SYS_SPAWN. See `docs/plans/2026-05-04-non-forking-posix-spawn-design.md`. | | `posix_spawnp()` | Partial | PATH search lives in libc (`libc/musl-overlay/src/process/wasm32posix/posix_spawnp.c`); resolves the absolute path then delegates to `posix_spawn()`. Empty PATH entries are treated as `.` and EACCES is deferred per `__execvpe` policy. It inherits `posix_spawn()`'s cross-process open-file-description limitation. | @@ -169,7 +169,7 @@ same final-OFD lifetime rules. | `ioperm()` / `iopl()` | Stub | Returns EPERM. No I/O port access. | | `remap_file_pages()` | Stub | Returns ENOSYS. | | `getcontext()` / `setcontext()` / `makecontext()` / `swapcontext()` | Unsupported | Userspace stack-switching primitives, deprecated in POSIX.1-2008, not planned. See the "ucontext API unsupported" row under [Wasm-Inherent gaps](#wasm-inherent--gaps-that-cannot-be-fully-resolved-in-wasm) for rationale. | -| `fork()` called from an exception catch handler | Partial | ABI 43 supports statically tagged `Catch` and `CatchRef` arms with scalar payloads. Exact arm identity and operands are serialized per activation; rewind rethrows the tag so the original `CatchRef` clause creates a fresh child-instance exnref. Mixed arms, multiple targets, recursion, and real fresh-instance replay are covered without module-static reference stashes. Fork-reachable reference locals or carryovers, reference-typed tag payloads, `CatchAll`/`CatchAllRef`, mutable reference globals, and guest table mutations are rejected before execution. Current LLVM C++ modern-EH cleanup output commonly contains exnref locals or `CatchAllRef` and therefore remains unsupported until it has a deterministic reconstruction design. The current Dash `expandstr` build also contains a fork-reachable exnref local, so the ABI 43 shell/rootfs rebuild is blocked rather than relabeled from ABI 42. References wholly outside the fork closure remain legal. See [docs/fork-instrumentation.md §Not guaranteed](fork-instrumentation.md#not-guaranteed-unsupported-patterns). | +| `fork()` called from an exception catch handler | Partial | ABI 43 supports mixed `Catch`, `CatchRef`, `CatchAll`, and `CatchAllRef` arms, including scalar, vector, reference, JSTag, and modern C++ cleanup payloads. Scalar tagged arms serialize one exact activation selector and maximum live operand tuple; complete exceptions use the process reference graph and are thrown inside the fresh Wasm instance so reference clauses receive child-local exnrefs. Multiple arms/targets, recursion, loop re-entry, nested catches, later merged-flow forks, reference locals/carryovers, mutable reference globals, and mutated tables use the same versioned ownership machinery without module-static stashes. Dash and the configured shell/rootfs closure rebuild through this path. This row remains Partial only because `fork()` retains the ordinary open-file-description gaps in the main row, not because catch/reference replay is intentionally excluded. See [fork-instrumentation.md](fork-instrumentation.md). | ## Signals @@ -652,9 +652,23 @@ These PHP needs are well-handled by the current kernel: RTLD_DEFAULT), dlclose, dlerror (Wasm dylink on the process worker) for both wasm32 and wasm64 processes. The wasm64 path uses memory64 pointer globals, GOT entries, and table64 indices without narrowing them to JavaScript - numbers at the Wasm boundary. RTLD_NEXT lookup is not currently supported. - Pthread workers cannot share the process's Wasm table/tag graph, so pthread - `dlopen` fails and pthread `fork` after a process dlopen returns `ENOTSUP`. + numbers at the Wasm boundary. `DT_NEEDED`, `RTLD_LOCAL`/`RTLD_GLOBAL`, + dependency/provider lifetimes, nested loader transactions, pthread + `dlopen`/`dlsym`, and fork after dynamic loading use a process archive plus a + fresh local linker replica in each Worker. ABI 43 libc stages + prepare/initialization so a host import never calls back into Wasm before + returning; constructors and relocation helpers run as ordinary + Wasm-to-Wasm calls. Instrumentation removes native start sections from + accepted ABI 43 modules and lowers the historical canonical two-, four-, and + five-argument `env.__wasm_dlopen` forms to that same staged path while + preserving direct, table, export, and `ref.func` aliases. The earliest + two-argument form retains its deterministic historical buffer-derived module + name. ABI 43 publication and launch guards reject a remaining monolithic + import or native start section in a completed instrumented artifact; source + start sections remain supported through the explicit module bootstrap. + Loader-owned VFS/mapping completions leave caught signals pending, and libc + performs an ordinary signal-delivery checkpoint after each staged import + returns and after `dlclose`. RTLD_NEXT lookup is not currently supported. - POSIX timers: `SIGEV_SIGNAL`, `SIGEV_NONE`, and `SIGEV_THREAD` timer creation, timer_settime, timer_gettime, overrun reporting, and deletion. Timer timing remains host-scheduled at millisecond granularity, and direct wasm64 diff --git a/flake.nix b/flake.nix index 6397ce90d7..5cddedfe45 100644 --- a/flake.nix +++ b/flake.nix @@ -207,6 +207,13 @@ export LLVM_BIN=${llvmTree}/bin export LLVM_PREFIX=${llvmTree} export LLVM_VERSION=${llvmVersion} + # mkShell's generic AR=ar/RANLIB=ranlib names fall through to + # /usr/bin on Darwin because LLVM exposes llvm-* names. Apple ar + # exits 255 when cc-rs sets ZERO_AR_DATE=1 for reproducible native + # Rust archives, so bind these variables to the declared LLVM + # tools instead of ambient host binaries. + export AR="$LLVM_BIN/llvm-ar" + export RANLIB="$LLVM_BIN/llvm-ranlib" export WASM_POSIX_LLVM_LIBCXX_SOURCE=${llvmPkg.libcxx.src} export WASM_POSIX_LLVM_LIBUNWIND_SOURCE=${llvmPkg.libunwind.src} # CA bundle for HTTPS — pure-shell strips the user's diff --git a/host/test/fork-function-catalog.test.ts b/host/test/fork-function-catalog.test.ts index e027f9daf2..99313ca73f 100644 --- a/host/test/fork-function-catalog.test.ts +++ b/host/test/fork-function-catalog.test.ts @@ -98,7 +98,7 @@ describe("ForkFunctionCatalog", () => { expect((reconstructed as () => number)()).toBe(29); }); - it("rejects values that have no deterministic module recipe", () => { + it("rejects an unregistered foreign-instance function instead of encoding the wrong module", () => { const module = catalogModule(); const first = new WebAssembly.Instance(module); const second = new WebAssembly.Instance(module); diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index 1ee5cef5cc..fb125bc8e8 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -4,344 +4,344 @@ "bash": { "manifestSha256": "5dc4d97dc4f154f265ff57e9d972e7d5a64a2e15fadedfa246733b830dda3497", "cacheKeys": { - "wasm32": "92f97de3257bfa8bb8b2a5b6a43f679d0da99835fdeff5b8edb12bb94ae30a8a", - "wasm64": "c8b4174aab1d05266e6259beff13fcfa68322c568a4dc2290db37ecd1235b05b" + "wasm32": "f45b7eb0ff367bac6331bedd038598b0708ca5f3400ffdc97064fb73d23982f6", + "wasm64": "e388eda3d16e01b1ef4cd7b3394dad13b189e49196eca5741e30a98d8d6cfeb9" } }, "bc": { "manifestSha256": "43b61e92c29098720bc7f4871a3b611cc01ffd124024290d42967cebb6ffd459", "cacheKeys": { - "wasm32": "5bd6386e4d291fef9ee23066706cd34265e0b1211262df055ddd85a882547d40", - "wasm64": "936723ab86a70560f34d97e3325089d2fd515213947634f31529d410c61e393e" + "wasm32": "b9f425f193548cb0585c55bebdab2e7f7e22c4bc6aa5a407682874ecb6448795", + "wasm64": "b6fae2a06ad7e254e7e10dc0c747290f0f09a96b6dfb70c96cfeaa903ae7f98e" } }, "bzip2": { "manifestSha256": "59e1e53f5675e7c9148634933ed0f4c7192743727025cae4e843b6924c489abf", "cacheKeys": { - "wasm32": "98694757e34a63efc98738395c8870e61d1a8ce36ed3603483a996f53de52945", - "wasm64": "5de184527c4619aa745713d73c127920645d13e95258bf78423051aee6eb95c7" + "wasm32": "15d8ef2530cf8a5cdf384890dfca7a328b66b7041ee31c6cc52e66050a6389a2", + "wasm64": "69c0d7b9fc2999d82dbbf06d764867a6119edd4cbc1a3b1ee5b16f412e8302fc" } }, "coreutils": { "manifestSha256": "72fc9c3818fb11cebaa047a623743252395dc729163fa804a7272d59f87fe82d", "cacheKeys": { - "wasm32": "16504e48b4275764b5ec40e659ea47e7427310af75751ef7d58c5254833d3d9b", - "wasm64": "738dae9d496b5aff2d4215ab75aa3dd141bcf1543f54963604f5cc896a8c1fc9" + "wasm32": "025c1324e522da0331d6145a0adcc5205c70368fe1e35cb15cc25bb13e8616c3", + "wasm64": "258bfcc358a310c5b86a4b48d05163840083ff2a38713118be92208e932522d8" } }, "cpython": { "manifestSha256": "7dd4f446697a73941ec940c2ccba4d53be73fe6947f1e701031a4d0aa964c4ff", "cacheKeys": { - "wasm32": "bd8f4e5251c181cf921cb0d7cb2ad13e3e4b02bd3a1b301a04311eb7286e4e41", - "wasm64": "a0b9f80833525b9934b80c86d2ce4bd40ee2b951571d0cb742963f2837c64cbc" + "wasm32": "4bd6e5948f8b59105b5159709dfbc7b1e6b1851acc4b34ceb4c23c51fab643c1", + "wasm64": "55cedd53b74378ecbcad641455c1e2419e6cbd16029ecda31a1ae0e308cab38a" } }, "curl": { "manifestSha256": "55523d50261f46dc4aaaff458d1cd87c6f96eaecd687a7540ead35c96906366e", "cacheKeys": { - "wasm32": "bf159d013349cdce705f202b56236225fa388ada560b786d0fc6ea56bf5eb1fd", - "wasm64": "62e983296c24945287ea5cc1d013a08f1da4373a94091fb54856dc816686910a" + "wasm32": "60fdd5816eeda424dc84875008821348c87f45ce44af03755514eaf184fae225", + "wasm64": "282ebeb16d5b27a0b962b5a39d944e3536929f12f907a0fc60da586f52a712b0" } }, "dash": { "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", "cacheKeys": { - "wasm32": "37014abeb2626a580840d8fc3cadf61b5970c95e77cecc07091c7962d1410665", - "wasm64": "58a8182735bae92e3ce2c582a31b1eea78a80228b4ed18d8b60fbdf769ad4549" + "wasm32": "f0373b19c4e22a4359403372bdc43ed56f35cebbc939c3e34b2c34560ab466c8", + "wasm64": "4050007a409933f2b5988c77ac7e8ff1b1a06be2a3d5a6b5b627539e790c1ec1" } }, "diffutils": { "manifestSha256": "2286203eb762eca487939963e38ea1b901ac2dc9a830d3260c2fb291757df208", "cacheKeys": { - "wasm32": "6d0e55c63433a88fc34f00d534ced18f21941fe4fd1a6e2235049a0e8792ab10", - "wasm64": "ad00c7cfaaf9f396269cac8d42d4b1c8a956b79ebaf7ed21fd62637681d41def" + "wasm32": "c5390d8a92f04562466236d79a225c7c611f075ed02d1d21fb5b080fb7f01a0f", + "wasm64": "c2b20fe9e29f6c0b39ed483099cadd139cea50c5813f9a692be1b7c32faef79a" } }, "dinit": { "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", "cacheKeys": { - "wasm32": "0ce5e4f73eee78b62a9dd8985de1bdaa2a88f2e557b5d02664874f8c09f66c4f", - "wasm64": "ea4ae28552bf09c4d587066752615ef0eb1e59f640571259e0b0161b14031887" + "wasm32": "3f86ef60f36863f0437258c43d10333a65c9412c8899688242fb16207361fa6b", + "wasm64": "d390cee24c29c28de3914085cb36459d4265e4f3a538ddf08096d460ec442ac5" } }, "erlang": { "manifestSha256": "475d6037e73a0f1e2f4381067194b2422751206979ea17209e10efa5a2a93bbb", "cacheKeys": { - "wasm32": "ef8b91b4f14f0622973f44bf05c6c41bfaea828376d56c33a5c0c924f81f6a4a", - "wasm64": "6644475e17154f5ac42c2b8ebe62e8fd95b68fca834fdebef43a71cc48f5dfc9" + "wasm32": "7240df3edd54fd8ca538f915c4a49eb66fb06df6eb9bda51678c5c30fe97f0a4", + "wasm64": "1f1fa44946d45d8e99a9a6743064d886497c9ec3daf84cd2925e259fc9447de7" } }, "erlang-vfs": { "manifestSha256": "15efb74f1825e2b85bedfeb8d98c19fa648c4be39cb9defb65330a8f21eb9141", "cacheKeys": { - "wasm32": "4b47d0fe3935e11b74fa6630b7698980d8b75f60e1eed3d83ef57a4b6fe7f574", - "wasm64": "1743e8183325f58f32b365bbb8c19b8f90451491b48aca8a69a74c9d60f4799a" + "wasm32": "948a07db1bd03c14d9ea6b13f0efa01e5decb92cade7750ecd319107008ef80d", + "wasm64": "819c179c21719df86a5d4792dc8818b469e0af641203f6018b0bb4131fbeba8b" } }, "fbdoom": { "manifestSha256": "a00e0d9c84fcdbb3bd95f296cb3422d60b86dcff4c40734eea1bb0bec4c7d902", "cacheKeys": { - "wasm32": "8eccbbbdc04705bbfaa81421ac7ea00da9133400ac36597f99e045c153ffa359", - "wasm64": "7a25d27189ee6252f1a5f3209185833a7f9e8a3d66b862d920e65605d2fcbdaa" + "wasm32": "0b567960b1b6b829bdd14304dafab6a508e23dfbc114a999892e1c411f2c63a6", + "wasm64": "3b18c5f787c15a5beb5c2c82f504d897e84add456a6f30f7fd48d4dffdda3c49" } }, "file": { "manifestSha256": "7874c1affcbbf2c8ab8c8d4beb087f275af57b5caae6a8947bf5a63a181552b2", "cacheKeys": { - "wasm32": "d1da488931fb5bc21c84a35baecf432d4be24b588998f052bbf85ae8937cdd52", - "wasm64": "c367ff2b98796cda1bb4d72a19aa61b815679c8b9e8ef1670bb26ee3be5c816c" + "wasm32": "8112d62ebb8c99a5a1714799f366be25fecf6b4c81f1e4e07aacd57c91535e47", + "wasm64": "48902f4ee247661b59cc4ada31455957ad1028383f26e5de4677a3a6c71b2fcd" } }, "findutils": { "manifestSha256": "a9969cb102403cef105e758ef602692500fddd75ec96e4e3f168c50094b6c931", "cacheKeys": { - "wasm32": "059e682906024abce917ce48a7bc43723126cfc9462467c0658f725ea57a9e12", - "wasm64": "f0940242ffc5b9c26820a59c7585b2772343a8c471150bb8a074b95f6ed3341d" + "wasm32": "8e97787e868fd0e18a9855b9f82bb410cb2e358d1f585cfc1e87cf21985737ee", + "wasm64": "b2f16c41fe1012115cdf1aebeb25b818743e88a95ed11419e623fcb08e7383a6" } }, "gawk": { "manifestSha256": "b788f3de724cd363ea68d08826586ddf544a75fb5dd9df1369ffafe06d8681b7", "cacheKeys": { - "wasm32": "4e801b5d5856d43e6e98b8ffde839bc0f44f23d3251b3ae313d25b2e09c39a64", - "wasm64": "a4e34f8fa2e4c8fa1a0cec688e8836ea9c8231e786d867437debf0246292b4f5" + "wasm32": "c388eef100553698e390f5a207c418afff2a075547ec5f3536460748b7df55e6", + "wasm64": "0a3abc9b7cdbac13255dcedf2a3674f4212c3e185fa5796132eb77e28d8005e6" } }, "git": { "manifestSha256": "c2bc79e62c9a4e840ae94a16af0f41070460eaf3976a990dc60d2db977bee952", "cacheKeys": { - "wasm32": "13173bbf8d6c5497f88945954b2e276ae1d1eba1d9325813cd2158fdfe166f0f", - "wasm64": "84eb72b33de503f8ded31c22a4ec6971db752a31411332fea8f399a7666cd08b" + "wasm32": "3c1a3fe79b8f32a26d67e407f10c9c34a60035179024bc4b4d6064e52f6948a3", + "wasm64": "bcf2dfd3b2b19516cea40cc48f1f04b63bef4fc6b787c8a5c0a3221590be6d9c" } }, "grep": { "manifestSha256": "7c61b894807b831c7e8816cb1f39c78e3a69a4ced2fa3d18f0abdf00bf18334b", "cacheKeys": { - "wasm32": "9b2b98713c4f7e2e68abe0eb8f5839eceefdbccaa3db3dc7e0bddf0110ff7acb", - "wasm64": "b83a7515b018399380dc4c11b4d81b45c0b2442bd9f994b9b5168825289ac86d" + "wasm32": "3e0a00c54f9d2086dc1efbd06bcf54b6bc5d35919d58dc4267bdfd32cfb838a2", + "wasm64": "23c3722692f3301df220915062af2ae3d7a3b06ac830402f6bc0f752cdd8d50f" } }, "gzip": { "manifestSha256": "1485add843bbcd744244e707c353208fe11192b7b248feed1550875d3b75a917", "cacheKeys": { - "wasm32": "76e34816990e548711eb857ca9bfd7a6730912fd1197e356fbdee2bb80db745d", - "wasm64": "09dfef592e330049b830bf2ad73fa83b2f91c1e1d8621e8f3363c4f2d40cb1d9" + "wasm32": "96085853b6c4c622d3b60acbdc0c982b1c51b079b79e942bdc02aa684c4321a2", + "wasm64": "d64ae559c979df527f91c0fa9795564a6c2efabbc23e13b6ac9cf4299eb62a5f" } }, "homebrew-bootstrap": { "manifestSha256": "183004a8385ab5afc388cd43401341b82812c67f97b08ad349ebd7f4dbbebd0f", "cacheKeys": { - "wasm32": "682057447474e1bad8146fb1df2c585152ee12eb56bc472cd60ea768bd53ce4d", - "wasm64": "533d5e964b5857a44b4197e9f3260eebf8ab8f6b4f0c8425511714f8d94143ab" + "wasm32": "d3865a95335c0f22ea155825dd8d1b1f680bf90ca00d4024b59c4e0928459912", + "wasm64": "52f7769154a133f64aa5d3ebb1daf567eb8932f03cf936ffcc147bdc17005cbe" } }, "icu": { "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", "cacheKeys": { - "wasm32": "52d441be75809dc07ceaee197d8d477a99aa3e29e2f7f149d9bfe795aa684630", - "wasm64": "3bc7d552a71d0e2600cfc3340ddba5879c09bd87cd82279eba998f613f83e169" + "wasm32": "d129add0d6c230b17232aef83127a269c5c1c11349d9331d06fcc6889b56f5d0", + "wasm64": "1b350f7d1d9411561a0f7fece1ab0927f2774d22e7c41f3ab5b12bfe4e5288b5" } }, "kandelo-sdk": { "manifestSha256": "c081879f1becd855917cff96f17429bcf2b9fd61bf95211eca9ff8fb625bb2eb", "cacheKeys": { - "wasm32": "1d7cd9ad27fd7edef080d32a7134ff17ac9aff70c6d2cae8f62e0c2844e77cb1", - "wasm64": "ba7314b2a13009b44df586fa76cae75ff56519692e2b933c6a5873563d70b7f2" + "wasm32": "2d67aa6a7aaca3edfb622a3888ffe8b3282ba212e12e814116550357245e2748", + "wasm64": "ea12e46306452389af056b145ad25ecc5b1b7fe7467e1fcf8909573024193913" } }, "kernel": { "manifestSha256": "db1d66db8575562ac7b3e720dbaa06d7dd5eef577d80220ea4167dc2d69b863b", "cacheKeys": { - "wasm32": "973496ab604cc30423a02e6ada802f3e26601b88820fc019068cf7fbf3098456", - "wasm64": "f2c8948fc828041d87682eda100f43a67a86d60fbc7997b7b3d5f61aa76e1e4f" + "wasm32": "a135dbbd66f558b2f6d41a16db4352b97e58d7e61f3afe9b3eed8accaab4564d", + "wasm64": "011b52b75f0a7e1bac867e5469c03c7db40dfaa22500005b0c942bce4e88b4b0" } }, "lamp": { "manifestSha256": "250b64635d64f8537178188fb5488dbfd2980d79a1c2ffbf346af67008088ba0", "cacheKeys": { - "wasm32": "cb86e4941859d9c052b173b6b2f8e3c7223c5fc5b396ba550f9e3bdd4df25757", - "wasm64": "4f00e7940263ad2f530aff18d240ed32ad5b18b79348efdf79a2d4fe7ab92590" + "wasm32": "c156d707a51eef48fdc4764ec7a07fbe35a70ec0ebc172284582ad65b924ccf6", + "wasm64": "ee089e939f439872026712b41defad4bfbd3e3b6ad511eae23ffcd67c324725b" } }, "less": { "manifestSha256": "996d61545cfe83dcb663a33e8763e0edbd4db4a605fd69a91b243cf79b1f9b17", "cacheKeys": { - "wasm32": "6b8c04496ad17846200a05046f11d796fc192eae207d1be79ac7cbab85571a18", - "wasm64": "4fc532602137332873dfbe0c61f64587a6345b741824f32fb21781e229b31781" + "wasm32": "7b4ae641cb4f9310e48636f98f955cb3f79557159bdb1e80b3fe6badfaf5689a", + "wasm64": "98ae78164d78e1cef5d6efc0323252cfa3e8956450148b39171b96b6df31e371" } }, "libcurl": { "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", "cacheKeys": { - "wasm32": "ca63d1caa19b66e88726af7c0d5944a17621603fefdc8e84af46945b10a560e2", - "wasm64": "84588ec1485e28de3449e65dde18aa565c062e5c634f22ccd0b61a56f3272d78" + "wasm32": "2c986bb0488a9ff05b1ef2ec3c0c44be940504ff9b7f33a4e235f981dc016e17", + "wasm64": "80713e045d536147a60d892c18230a85a41e32877a3107d081d7ea48e2efcdc2" } }, "libcxx": { "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", "cacheKeys": { - "wasm32": "8f435f7638a234c3327375ba1055d0c1e33372c8c4375169043d9fff4fd03ed2", - "wasm64": "cf90c73f1e47f4241a40b2fb7fff30a3ac1d1e8e6e691f82c2a30d752e5647f6" + "wasm32": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877", + "wasm64": "a8535610a175d2c3c443596be03f123f2c7a007a26bba163bd09d4fba10d8b42" } }, "libiconv": { "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", "cacheKeys": { - "wasm32": "6b805ee5c087b70ff6d4068b247626733daaa5551a45fcc9510373455a3d4d33", - "wasm64": "e3c93b68e33c63f631693a8e2928daf6cc3b04d77ad1b73cc5048d2c61ee60fc" + "wasm32": "23113207cb8ec3b213b054284770c9ad92d738b225044d01563f1bc3a677622c", + "wasm64": "a6dcc2c2fd20afea3ba0f289664a7c4a98df91b322519b02ddbb7d88cdebaed4" } }, "libpng": { "manifestSha256": "79ed5c5c072a0267cce5c7a2fe37d8494571b17e44b952f619e04ec1c4a9db10", "cacheKeys": { - "wasm32": "f161992c0090455792d881778b9b7f39f05b54ccf40881c949597be4b4a91638", - "wasm64": "5b7b474acc7b376dcb20ebe9e57d3f644d8e1fa961e81fb6647df053f3bf385e" + "wasm32": "6415b2ef14cf038d99c24521c5f7272ce81e929cff5e94ffeb3031b5c9140195", + "wasm64": "1158eba3e006f03e0863be9b883d3cd96a7bebab57b09db106054f73f780a0c9" } }, "libxml2": { "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", "cacheKeys": { - "wasm32": "7e0bdce4d06f6d95bd25c1378843945c27c50907cf4b2767bba785356e858d94", - "wasm64": "10d6bc02228d7a2a4833768cd35b371cdd38b55d00b61700ceeb36ccc10ed5d0" + "wasm32": "70356b9298ebe5b6d58e7d08093d71b1137c2ff99617beb3d8134f3bc919b088", + "wasm64": "ac76eaa8521d7cf511b4c13fdb3d31afddd657d0a0f276bc62fe8e603b1b6a61" } }, "libzip": { "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", "cacheKeys": { - "wasm32": "ca39fcc365bc853e1e839a5aebc5e52c16b3783caa966a5c42de29d5b5fa36b5", - "wasm64": "9567b0d41c320df08779669d639f8e2f2738028c334abe7293ca44f571561389" + "wasm32": "4aab2056a613043f30ebf493e466227b595740de70ba210e80919a6d3c1069f5", + "wasm64": "b5e87ca26e9d446c840029d840dad4457380a6b890f2b768fd9fda00bc059e6d" } }, "lsof": { "manifestSha256": "cfd199bbe082435f7a2e703e2738a368af1eeb5b76b6e93c376054f21ce94419", "cacheKeys": { - "wasm32": "952a5f69fd8a7c66e43a6b33e2beb4595f4ebb06a72af39f5535965375f8ad4a", - "wasm64": "10ea24525a21b87f8c7a534fc0f4f01f10d34901f4454541ffa81f2250bebaaa" + "wasm32": "5a380c75d0d25b820bc140e5b9e4d01ffa4439679b5b5b8fe0dfe9ca8e67b00a", + "wasm64": "17c5eba50177599c535cb3b60d810c5412258f064c7872300e8a38b1e6950a40" } }, "m4": { "manifestSha256": "9b5838bdde8531079f23a89e135aa3832bbbbb7ad6099dd1503db877d52afcad", "cacheKeys": { - "wasm32": "ac71d07cc5e79156d53c5fde3577989149947340b149d0c950413119501b4827", - "wasm64": "398292aa80bd99c35a3a4df06959ffe6e33f3159d52bfdef01c1bbf9782563a4" + "wasm32": "13c48d5828987e67dc653829de378a172f8a486e1427c888168526e2536747ce", + "wasm64": "798ba69e59627b5e9ce7a753728f2ccafb3b19bf7d9b72d0727b3351ef231015" } }, "make": { "manifestSha256": "62e8b36a6df7e981d191061a5bb7f2db85b2deaf365352a7d590a6c6c06f2b1e", "cacheKeys": { - "wasm32": "6c21668c1c4a49a349c57e420586c8d19b1e49ad9b41d59bc92b31882db10cae", - "wasm64": "d0549692ee616a0df1b646efc875911073eef9e6a2c444b8ac2a765fdca4536b" + "wasm32": "b326ee0fce5b1d3f5ed8c0d865a6f351d162b3848bec1084baef1de028418a82", + "wasm64": "b07fb4d5ce35772d1e9683a9464af917118c23e37162d3c59377002daa909b86" } }, "mariadb": { "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", "cacheKeys": { - "wasm32": "80ee9fa351e72db098b9cde6d0eb87811e2af5b1e01c97a2a985278e17a7e728", - "wasm64": "2b125feab56eba39c9c8d61e612a37db374aa72afca0e58f8fc74a21852e03b5" + "wasm32": "141ed56befae2c701961c53a06f1d3dcd1949ce5012e6c62209c45092b13e3ff", + "wasm64": "ee47227abc6a8059e1e4501515ac18656cca0501e087018708fd15c9f38aebab" } }, "mariadb-test": { "manifestSha256": "d4ccce161d659a5a87c80fa1117054bbccea870e0d4a46bd8a070d3930ad0e3f", "cacheKeys": { - "wasm32": "2a6c758205bdb36b0e310a4256a8cc878225ba40cc9f9c53cd274225c6d9f3a5", - "wasm64": "a584a0304d63c615e7007035f5bb26faf2678bfd11628d0be21604bdd5cc8c1d" + "wasm32": "784f34c97f112d3348013e6a7450975c631cd7c5de12907c924ca4167f074e37", + "wasm64": "5ee6bfea76fa5225e28c8ee29e853b7451d90cb15c9447438f741446d9ae2a16" } }, "mariadb-vfs": { "manifestSha256": "26e74ac84d89b2a839ed72783437061a23022ef2f88ad0f52b6aacd0442ee1cf", "cacheKeys": { - "wasm32": "c3d7418fbe548b092751eea9ed8305b231950935b1b14fd1a2cafad3fd868a1b", - "wasm64": "9db60c614d7b3607720d7e8c6a5209f35fb1db9b17d0afa6d5545a68b7301de6" + "wasm32": "4a858c3f8add0dc39eb56a08b7041ccd84e47fd6fb14a526efdd87539a77468b", + "wasm64": "6a3b1cf6fa442cf7868b8f4beddce967f623f19fe83a7e483f8532523b8472ab" } }, "modeset": { "manifestSha256": "36a8683c1c7309701d361c5f77e543a6c1531397b26c72dbb864528c59c42954", "cacheKeys": { - "wasm32": "6bfba518ec9c63568dffd82204bf4f572e92210a0ff769469c94292074567089", - "wasm64": "5b31d0788213943f24cfe49382359b66b65bbf06f5dd8252ce88d30345495c3c" + "wasm32": "d1c2c44f0bea233667911c79cc5a5ef8fc9b41210b2913526cec1888647b71ec", + "wasm64": "8cbc1083bfff0976338ee38c652d5b23e9a6d5b9611b8996f0cbfe09ea1335e2" } }, "msmtpd": { "manifestSha256": "686ff665b5e7907e67618790b1a3eddce2ff47ed665595d17209bdcda1e0b274", "cacheKeys": { - "wasm32": "880dbcb9760918e21e6a4d936655de2a01b94f10fa495a87af467b5bb474c3f7", - "wasm64": "4d744e690f9f51d720ffa2fdade7c5b100c991eb3d04743e7af396a13edbba5b" + "wasm32": "ea09f8a7c8a43ddeed1fef59c3a9a7b987e0bda7be881d4d1ae4019d73a7ef30", + "wasm64": "806cdbf16860e704425233d997efcece113925eb0ec32b21e22e8ddbfd1da6b7" } }, "nano": { "manifestSha256": "c5a52f0c37e08b475bb815367b1f0d63d0d2b06649d99fe9f461b72d0b9cff51", "cacheKeys": { - "wasm32": "0748563a592c1281e68db48d4dcfe5a95f8137ba6c73e5da9a0727634870c13e", - "wasm64": "035a41872e149514ec7ae9e0615a25e8b7b8bea8a9c6750d9da75b7100647125" + "wasm32": "f4b8af0d2e6dfdd05c495ed26d2fdb80bb3987ac1eb58e324e17272ea6060bd9", + "wasm64": "ac2dfa7aaf8016d4370782ff807bd884816079e6142f5b8a54582aa5b4290095" } }, "ncurses": { "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", "cacheKeys": { - "wasm32": "ee1e8ed6f49dc30a0bcf4b4ae1e11a3a4ecb28e688ad91f217446dba85b3369a", - "wasm64": "7faded04a0840a437954638d11fec802a19c6eb7cb23557d3a908c3e6549983e" + "wasm32": "9c9b20d1316df92a12cc0e0a5fb327b10278220cecd94a2adc3ed8ac9a29ecd5", + "wasm64": "6f4760c0721d6fd8dbe935ca3baf60823df66d8c35d0e3e2d7f39b1b0ce2b2d9" } }, "netcat": { "manifestSha256": "4cf33cd1ab768b3ad0108da8b68c2ff72e98469cd86a0bf2d0da54a7d4f6fa16", "cacheKeys": { - "wasm32": "d83482fd416c7ee3d595e4d09bd51c488652416fa31a8edde3f7e50a60e2e211", - "wasm64": "4581a6d58f6ba6f716b57302a65d6c969d2d7719d768b9dd0d4166972c64ded1" + "wasm32": "1827799c81052b95dbc22e913e556ffd801c1ee2e3cb4435673f1d342fded3d4", + "wasm64": "0559f1cb68123265fd3a3cb3a57dcd1ff0459aa00c5b57e94bf61d471bebaf36" } }, "nethack": { "manifestSha256": "1a2f12ec2770bd8d40006d6c878a3493b97c71c2bb63ad08c7f6ad99bfd53504", "cacheKeys": { - "wasm32": "3bd42cc6245eebed0c013769f6aaa491be96c714565fcce4ab9907ef3702ba32", - "wasm64": "7279ff6fd891fe2ad0fc12ad8024e7075ba3f8cf5ae0a1707161db94571833ae" + "wasm32": "be86dd34e94fcc32fd7c067f81df5685df966e3a8e957292b2ab09d8f6bd159d", + "wasm64": "08cb42ff41eaee8c545e3aa19022ec01b700be46adbfa669b1c9316651847eaa" } }, "nethack-browser-bundle": { "manifestSha256": "b8294981da7ce4299dfb271d4aecb90ababa97a4d1acc9b716ae05bbe52beb53", "cacheKeys": { - "wasm32": "037432e4c25d61c42c8c80c7f9ac25d39c0aa1199bf1ae04fc0ec45efbb9c449", - "wasm64": "271ffcd356e47824352ff721ea64e965b190010a80bd1d89c62203ac8a621c66" + "wasm32": "2f2221697fcd9c2e54dfa6cb38ed9c307dd6e018fc871530a1d307cbd5cfadb7", + "wasm64": "baab49df68ba8b41ddd0fe950b69f837de789b724787cdb80d6cef9b9a02ef47" } }, "nginx": { "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", "cacheKeys": { - "wasm32": "e3a71f5b5d417de5430375ca766f22bc78a46a92c90df8c6fcfdb9fcb6d0051c", - "wasm64": "79198d53bd25bfe84d1fa8c9be7504f10b73ab098bda27e73dc44c8d618d9148" + "wasm32": "cd58a900319c46bbc268ac664900527414e9c6ffa40683b3be6bcd97d4d47283", + "wasm64": "7b13bf7c29feeb2d6b27aa951f0d90784cde35db279639202c75f5815b106331" } }, "nginx-php-vfs": { "manifestSha256": "97976410cb02f8ba710d856b4ac904bcf976d677b50eefdee39fb64176070d4b", "cacheKeys": { - "wasm32": "eb8776b6a2a4458f1d621e9487f434761dbfc0bff319037c913b9ed496da765d", - "wasm64": "f4c364e49cf25dca764c6bf400754c88784d23598435298db06c6f6f6ea96236" + "wasm32": "abe26676465686f26ddfe4f83d5763d72521a6a94e0735af17a93b94307d643c", + "wasm64": "bf401007a0447bcf58bf952ffc8f4be9c6b3b8f28ac7b410edf153633b7a141f" } }, "nginx-vfs": { "manifestSha256": "46aa2d3250ac5cd0f102a85c3a36a086c7a50ba1f9e05fa1c2aae3d07ad16f10", "cacheKeys": { - "wasm32": "fc8561d6175b19f3808e5f8bb3b70981d3b1af5bab7df3cd0991a21683562579", - "wasm64": "0fffa81c6cbbf3a0c04e61710038302fd54fda988dc6e76619e8340377a46858" + "wasm32": "505ca1af0a7383a40519de0982e53852d66ad50f164d12e08e815d2d36187985", + "wasm64": "c288069581585615b2433700e37e8c795996e5af8955f66899623b2cfb91b268" } }, "node": { "manifestSha256": "2131a24dfda8587860e57fc65b573086477d376b065b3b9ca4e1dfe4d79f5790", "cacheKeys": { - "wasm32": "88be95a99c566c966012b355c439352e09f5c020eadf5a95ecc2f13cb72638c1", - "wasm64": "9c31e1ce4821b951f0db444b7f0da3c0834184863ed79014ac993eaa343d7c08" + "wasm32": "59917f32e95cf3c65e899cf230ed19154321d18280246fe9c0e75e970af77e47", + "wasm64": "1c03b2b72c7ab0de8ece40eb3c75fd6934fade6e9b2a0f7ee0c30d9b6b190076" } }, "node-vfs": { "manifestSha256": "977f219defe57e18017ecc963159df32efdd21c53d6fea05943703d04739d8de", "cacheKeys": { - "wasm32": "c4b8f33443c5bd6581fd3d8a7738863f461ccfc41c7c86161c1434e6da6ddc67", - "wasm64": "fac78aba64ec8d387fcf4d41f8a9f880b726c547744366dc0a127baa47d572da" + "wasm32": "cba98a6b26b1f906c3db99c2e9da398f3a8251cd62915b3b0d62e879f4e5ab9f", + "wasm64": "f8c52ad33c11057fce64da56c21dd167e70bcfa587a5b84641e1b2ebf9659ff3" } }, "openssl": { "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", "cacheKeys": { - "wasm32": "cc22419dce43a129c42182ebb85e7b27b96ff2c4eb390ea9babcc3a352678ac7", - "wasm64": "e1a6ad9ed290d5c39d1ba27ddb1e822e30bc5c76fe23ecacd6a3f04ff52edd06" + "wasm32": "7b7c878fcf8eadc0689030d9a8f4e12a15e34933c2aef7192136be4d51e42db2", + "wasm64": "eea1a203ec2dd0578f2237c9d04fd1c74c93f420d32073e12ec8b56598bce89a" } }, "pcre2-source": { @@ -354,197 +354,197 @@ "perl": { "manifestSha256": "6cdc4dbc54d0e4008cff41f82ec8918c0e44b4aef23903539c1bc0f538fe3ad2", "cacheKeys": { - "wasm32": "4ba31fed7ec2636c654ce9b803660e80ea755d413391ad544d55d951ec032ca7", - "wasm64": "63c0de5375a058421562eddd451df911207978edb777f810057905058936dc90" + "wasm32": "3f6af76550825313cdc09758c3cf2480b65a1a1c64e0f4a3cee95fff09f467d8", + "wasm64": "2da511425e8d8feba01275eca44738e24fc03fe12b6839d95d8f97b170f8c61d" } }, "perl-vfs": { "manifestSha256": "1345cc102bc8c24a6bc276410c00b4fcc99ba5f6adc9b031f882aaa35d53c8bc", "cacheKeys": { - "wasm32": "e0a0ab7f1c95d9dd87e3fce1428e4c4620de0d986b48f80cfee04cf76569e386", - "wasm64": "c2a8341aa56c8f3b125dde0ab382cdcaf48f471a6038d8c6bf9f13513c1f491a" + "wasm32": "1c6a0edb1823313d0cc6538e668a6778ade866347392adb8ffbedb1054628128", + "wasm64": "f3cd909068a0627334a9f4f5515e32116fc1864b37587e04c5d1fc32e8d96a0a" } }, "php": { "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", "cacheKeys": { - "wasm32": "c6e278891e21d9265b0a91fe8deca055e492afb0e02e4d69b13dea28240055c4", - "wasm64": "324c6a5c4fe2646694652313c282979d2284cd767389ce898d37dd4a6ca96e23" + "wasm32": "cba27c5df3f7ce7a296d1e672d5ce6671052bcb19e2893b73f926cf7e498d6ae", + "wasm64": "dc015cd2f3d8120109fe2fa2161e450c7a43f8daf74559965ad3b04e6c1de267" } }, "posix-utils-lite": { "manifestSha256": "8fd7190b2848ef80143adc7b9268c79e13cd4db3430f7105faf49a4b4407a06f", "cacheKeys": { - "wasm32": "4dbc5c888a29eb71039e433f5a2d74733f9cb79caf9cfba23df997e1d0544f8b", - "wasm64": "36be79106075159bc5e7f27e06cb24df2fee3ec2f8af32c93f2f44b71581ca04" + "wasm32": "971093bac83c0c75229da3992210c599002943dd6b21d1e98ac332a22379b221", + "wasm64": "470d1e020c536930dde028f7db846e5566fe32a8706caa9fdf3230ecad9e1871" } }, "python-vfs": { "manifestSha256": "d1aa99feae65f06c0ca8cf52c2176534b2723fd4ee6eba6e72c205245e1c9c26", "cacheKeys": { - "wasm32": "2e5120bdf53f362d422c61ea6fe9bbb7bd2f7e25ac2a73a899de006575eeb383", - "wasm64": "b939d432bfade97cb94020a9f5f1080fe94942e16107effe78d87c01a5be215d" + "wasm32": "413df2ad2183a8750dd0792d526fdc8644f2d26a8a44ae47e950dc41a0fcd86c", + "wasm64": "d903447e95920158a8b8c899bd83e967df0bb924b6a68394b1673da2b492da9e" } }, "redis": { "manifestSha256": "151fb507de953ba2ba94b8f881bc7d66b4c741c1359a2f590cc07f9a4cd368a3", "cacheKeys": { - "wasm32": "21dee6bca95d930cd02bb1f0db35f69bad9983fa5b0d0c0c3917ff95b5922431", - "wasm64": "37b56d94ed1191209242a1cb911f75b4c34f04a3c38d553af603eabf1036ffb2" + "wasm32": "0b6e849447c2d14076954fcd4eefd2794d5dd31b220c23a6aa8dc042292e0496", + "wasm64": "a1b37b8f8805e0639b81ea5bfa16ed7cdd78fedcbd7685a3cf768b6dc313fed4" } }, "redis-vfs": { "manifestSha256": "234c45c40f94e2a89f98295313b82cb9fce15a412ac829d9e87394e0dc731813", "cacheKeys": { - "wasm32": "e373cb46f82ced398324a466e8e5d0076172eac8094d837b20f3bf28fbbb20a1", - "wasm64": "9c0b7e6cea5f158d0ce4b71bde75484b75f3a8546f1dda87b64a5128aa2641c0" + "wasm32": "ac89c13d91d1f3fd452d8fb3e1da15b1d2d1dc6402b776cb9b87dcc1c2e44ebd", + "wasm64": "8ab5e8085c6cfa645aa833745085b4ebcff0f25a8e7d2de3833da7076a8232d0" } }, "rootfs": { "manifestSha256": "0420201025170f48c32949ac62707d4577cdc13a53ad1f01f59d318ec07c8d6e", "cacheKeys": { - "wasm32": "fe8ad25d00880687479abe16b7b09a8591bb7e4a8c67a4b8d28c6927768f8d86", - "wasm64": "f52f3e155933ebe098f635e4bb511b02f5d7a42f81b506adb76d9b70b5e6d6b7" + "wasm32": "fc7c012003326b1ba75eb8acc08b62b38d000a8d1f03e179a6103430b8cc3c58", + "wasm64": "10c99ab5ab2643bb59a7e56e0288d30ef05d1851edb8b5b167bfbd72ffeda864" } }, "ruby": { "manifestSha256": "6ea67a246c29cd927a19decbb36ae4c4d478ff6642d2c77e08a6b2cfeb8251d2", "cacheKeys": { - "wasm32": "51c9a0cd4b5ea534e2cd2a7aaa944f0f91aced8c7b0db45ab37e44c56cb09c6a", - "wasm64": "614bd9719c45efdaef9a22f2e90deed21d0816681312a6223279721ba78865ad" + "wasm32": "3e8b426cf871a3ba7e040d2ca161466440a4f2b4ad961cf8d35ac62e974302b6", + "wasm64": "bd76e5b2112fa18e47d7c9cec112012cd845640fc6d919e64de95112be0a8481" } }, "sed": { "manifestSha256": "0c0d82d53907c19825941501f833252cf9adf35ebcebf6786baae28e5eb6958b", "cacheKeys": { - "wasm32": "1e298d0a946f3c3c0c8f13f6d2b11a029258625fe2f37fc33472bb4646c187f4", - "wasm64": "8675767db16dfe7be599d4dccaee6ce096e2303d889946978a990a4b6a186500" + "wasm32": "23b9bbdb70b972dd95e51ec76d4e96b102a872e4424ec192d38fe1153279bbec", + "wasm64": "01348196bfccec61e51d8d59660f51a2aff1c368f3520f4f7cfcbef70e6d07b9" } }, "shell": { "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", "cacheKeys": { - "wasm32": "ef40010ab7393e1309ff9fa2ca1fae82aac500b3d3be8dbfa9301119d8c90d58", - "wasm64": "eea2f920f91a6871278df5ab4dce49220d9d83268f333813b1696185a5bd2838" + "wasm32": "735c2c0ecb8edde3797fb59fd6e38f5f8c86a81fced93e3f24e87db5aafcc02b", + "wasm64": "c519186db48ee230f06cd9638e489a95297855c08d4590662e3bcf312f96b59d" } }, "spidermonkey": { "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", "cacheKeys": { - "wasm32": "b44e8ffaa8e9fdf7b51cb5ae5830070fa1b5959441da550d80a3d0aefd2504d2", - "wasm64": "8991bc8a4007c175da6eae01a2012961bec06cd19837b97e2c7b03116009df6c" + "wasm32": "703273c0bfc90bf5db63384125331fd60b5ea8dd8910ffd49be251503bce2d79", + "wasm64": "d49e05a0bb553594e2904cc1ad5e44b851a12276d153e37765db14ddf3849d2b" } }, "spidermonkey-node": { "manifestSha256": "f156c4c5044d2a9447324e7a2a35f8c3e6fa367b3e44f674dcc30ab6b946fae7", "cacheKeys": { - "wasm32": "0d2b9fa365a73127f7cd4f6c714e3382fe3808fdc1163d8dde42d2c68da35244", - "wasm64": "054985b78d25e89d58783fea1606040a35c9253186c02e80c1d93e373108841a" + "wasm32": "68906120c4f5bd6aa5212078f54eb64c038148c34ccdad65eae0b8c97906d397", + "wasm64": "10bf45f75df20ed6ba00f53f7232d354d35b00c5e7dde54032364b6547f99c1b" } }, "sqlite": { "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", "cacheKeys": { - "wasm32": "acdf72794b5e77a94280af5edae99a9ccc92e5093d42332881c66e23571c20b1", - "wasm64": "31baa92fda57fa25fb7a659deceb60ccdd3bc4f9769774d8b7f24482548ad73d" + "wasm32": "63f8f0bc003c78665088d6ebdfbd495325454537fba1e6a9df9c1c8a9857cf1b", + "wasm64": "a4202a397c9abdb33ebf2df8fc9187ac9923c2a45a5ace25c802832432b1aa0b" } }, "sqlite-cli": { "manifestSha256": "1cebb768f9473dc58fc6e72c9b24565760aedaec4cbdb29ad43b22a0a5fd7030", "cacheKeys": { - "wasm32": "daa008c55b87322dcc1af8534695748b1394e7e336c97c967475ff9b029218d9", - "wasm64": "690657a59757d22cff4d62d4ee1a45b4a1db187fbae119dd940201a09009cd67" + "wasm32": "2496e0db5e6a54dffbc592517d1e185c5ea13b2f6642ee5dda39837d6af5c750", + "wasm64": "5f6b1ffa1139ca96feb02834fff8ca62af81b75506b7bf0a82a116e28b1d2e0a" } }, "tar": { "manifestSha256": "4502355c246362a2035e52aa7b35d274882fad4b4404c03470458baee723ae68", "cacheKeys": { - "wasm32": "d1fb92dfbd6b5939d070fff6393adf82adf2997175bbb704e098bdca30c0fca5", - "wasm64": "1d65938520bd9bd7e62b406eaa93fdf96af36c2576dc7647a34fe1a41b29a373" + "wasm32": "ce558d30e4aa291b0b83c51e095925c2e3a680b6a2bb3064bd681b616dd92271", + "wasm64": "b8b3ed3975eb2f4ec641fade3afa11158300776aa7b08576c665e9c253d91557" } }, "tcl": { "manifestSha256": "b98f237f741b11f5f5da55db75530b421755ba4c8a88d314553106faa1cca1ff", "cacheKeys": { - "wasm32": "571475afa0828fc7620072b8da5e97f04e963d230e36841a4c22c9c360f49e36", - "wasm64": "7de6f0f42286ab4299abb8954f2164ac769f06d3c3e8c4c52d8f2e31f386945e" + "wasm32": "ecea22d76a1820ffcd5505b296e17beb353389ba0149fc26c8a5dd9ff17b7dfa", + "wasm64": "81a36b370a61de2315122ec2a152a343a96af99148a6164a838700ac28465b00" } }, "texlive": { "manifestSha256": "028ada023f8a8f9966c8a28575242a339aaaf0e8870fe4a221986133b381c3c0", "cacheKeys": { - "wasm32": "3796d63b69f62b4abd66fd6503e03db263c971a5e6d2c030a9984229090c634f", - "wasm64": "84de7a1b0a37a45339cdb515daf38dd138a2174010bc61b28df5d65c7de8306d" + "wasm32": "75b47041b034c633195921d54ef957186261e02e40fc3ca22c48f2a8de91b88e", + "wasm64": "9f366d3724f41f069b7ac801d2431bafe5e78b52ec5b87caad330e65c3aedad3" } }, "unzip": { "manifestSha256": "ef4e5e34258827ab3cbc1930da3fd9514f08f2d7d56c7c3e0d5c495a4d3a20e9", "cacheKeys": { - "wasm32": "57245e347ef400ee85c6170feda810cb58e81cf2129d0e1b66695be78de89a06", - "wasm64": "e31555b6d09c9a0ff1311531f599500a573eeb74aeecf64a879fef596055fd22" + "wasm32": "c2b4f93663a414b7ff31235fa6e9cade4955e6fcf39a027a3da1bea53f93a375", + "wasm64": "f38e84db74cf56c11d944a6593dea3037808380d7358a20dba857ca47ca4802d" } }, "userspace": { "manifestSha256": "221176f2a096dee19bbefd89da5c7f50138ecd92d37ec9d7d6b35dbb25e86924", "cacheKeys": { - "wasm32": "e49c11f0d3cb94addb54280d26f12dd9d3381840824c8a406d74ad30a6c8a32f", - "wasm64": "1c209d708965bd2f3d3aa376fd4997394479633bba8b2da6bdfd37bd81ade5b1" + "wasm32": "c8be6ea7d13eba7dddf88594522a447aa4be183a9e8c115f639465ff95f7f032", + "wasm64": "e342beaa5f5d88e3b578f4dd713d9ef2d4c132f5ce63eb31c10837efc8b4d9eb" } }, "vim": { "manifestSha256": "c211660ef41d01f95f3fbdf675b74891e897e3f304e04f1bf5586f326007382c", "cacheKeys": { - "wasm32": "93b8c5035eb78d0e15e6b35208a9be2d5fff048ab3f6a3e763d9866bece72037", - "wasm64": "d59015188551ba2447313f492339bf567c881d06b2e62711e4cf1d325f12c8fe" + "wasm32": "7312cc6859af08e6f5d3676cc2edc6476bd198a76ceb81ba5cb19ac01cdb8026", + "wasm64": "123c03848b099f46455792fc38163683d7f36c126d24f86571f42da4d4fc8d12" } }, "vim-browser-bundle": { "manifestSha256": "e31d84057db49c3534381604d0c8f3c7d765e33ede560a7ea7b01a5a8f1aa0d3", "cacheKeys": { - "wasm32": "ffa56f2bd9e2a3eb7e7a5601d7d3b69f95a50f9252d7fa1007b3964897f9b544", - "wasm64": "cec9e296ec5f224db05e0017698b2f45c40fb1252c07ede37ac05687df44332b" + "wasm32": "6659da0384bbb9555a57c0036bded1f84e335e73993d80afe10645002fb6d47a", + "wasm64": "fff7c3b3d2197334172970cdd437f356b48db15fedbb9159c3fbfec95455886b" } }, "wget": { "manifestSha256": "61420b6cb1be12471f425b0aebf5aec58c49d0b43d939dd54508b305accbf54e", "cacheKeys": { - "wasm32": "8f7ed7d5134490162de1e3a45cbac11544f700a704bf414902b8b234439d1fa4", - "wasm64": "a34675a988aba253bb9f5004f9e8a8207ae3a5d09d4298e194adbdcdb0cd9bb4" + "wasm32": "ae40de1e39e8cfac1fa45208a3c703f287b45aec4c779bdf4b326272675e3d2d", + "wasm64": "fbb4e62ed79ac4abae7c4727b6efabd498451b38b23a1537635b2c6baa3a557a" } }, "wordpress": { "manifestSha256": "36465e0596a06e855a8524eecfd87da98643bc13b37ee12939f5aab44bf33418", "cacheKeys": { - "wasm32": "e868273fd45c5672987c6c5f6ae87ef6e912eb46efab67067f27b718c2c6378b", - "wasm64": "9f6feee165f9af03d6b60e7468a313df36b95e8e22bb262756819a9aa7ab2161" + "wasm32": "3aa00b98f514b8dcb37d7f0fc573be579181e2974f0f14d8ce7b4858dfa34ffe", + "wasm64": "5d614572fb99c1c4e468ff3ae807701cc16363eb4ecabebd50923af96c3cbeb2" } }, "xz": { "manifestSha256": "8707d35b8647b1af8fa165dcb6ce7b2a6a007e19ab2daca2680c39395864a737", "cacheKeys": { - "wasm32": "9c30dbd3ebd950e6a014bfbfc6d90f765fb86bd38a34a3ca7f2e4139b258ffce", - "wasm64": "db08c8eaa0861bd3118482a605ec000078d5e27ee1a2439b245ec8521cbcf7b9" + "wasm32": "8fc65e59b93272069a522a4cf231aa0ade69f6eafe5a28b1000930f7fa0a1c33", + "wasm64": "30d4bc446fdad29c373bf49dc62406d47994d12dff8c1f54da1a6aa31748db68" } }, "zip": { "manifestSha256": "9c9cfa8220aaeaea26736b55f7cd0a7517b8853c07f9f0d241ad67dd43592e36", "cacheKeys": { - "wasm32": "cc287ee8a27f944c5c3196b47647d2df3073b6c94b4bc93ef061fee8ec1f5ba2", - "wasm64": "fc648e12af81c93a7869ceacfdc3ce4a281e93c4e5a1154d67f283ae32d60df9" + "wasm32": "bb1192b88018259570833ea86709ab549c82a2417ba284f6749a08e194c4d95d", + "wasm64": "321f40bd6d677e55cf556bc6ef3c9d68cf0c50d3cc1b4da211c31d0196053f55" } }, "zlib": { "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", "cacheKeys": { - "wasm32": "45f5d1d8385fcb2f5c568b57ea2840808423502205bef8b300b3ce37d7077ca7", - "wasm64": "1f3550646d43fbd1c34fd3bb00274c1a174f8c83587af1cdaa62a0ff4745ff05" + "wasm32": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc", + "wasm64": "4e2a72e052a8280b6445cb73cd1db388237be137c41c06f89ae9f442d8ad6d3f" } }, "zstd": { "manifestSha256": "89f266938c2c253700a838e6352c9de66c976c84883325aaa979f72b732357e4", "cacheKeys": { - "wasm32": "7efd9e2f41b270a7e26284bdcd73d1abf6e7122077dd8f835e10dcc5b727b4bf", - "wasm64": "ad6e0695b92454f0041f461f815e3eb5dd1b66881769e276bc77c091e98d7c19" + "wasm32": "2f8312b61940bc336be5489d90f21fdf59dc314a956e1fdf903017159312e7cd", + "wasm64": "5f420fdcabee2d8c05f7277f96af0f258e2850f2c7574ac07af019c23abbea93" } } }, @@ -555,14 +555,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "92f97de3257bfa8bb8b2a5b6a43f679d0da99835fdeff5b8edb12bb94ae30a8a" + "wasm32": "f45b7eb0ff367bac6331bedd038598b0708ca5f3400ffdc97064fb73d23982f6" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "ee1e8ed6f49dc30a0bcf4b4ae1e11a3a4ecb28e688ad91f217446dba85b3369a" + "cacheKey": "9c9b20d1316df92a12cc0e0a5fb327b10278220cecd94a2adc3ed8ac9a29ecd5" } ] }, @@ -582,7 +582,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "5bd6386e4d291fef9ee23066706cd34265e0b1211262df055ddd85a882547d40" + "wasm32": "b9f425f193548cb0585c55bebdab2e7f7e22c4bc6aa5a407682874ecb6448795" }, "dependencyClosures": { "wasm32": [] @@ -603,7 +603,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "98694757e34a63efc98738395c8870e61d1a8ce36ed3603483a996f53de52945" + "wasm32": "15d8ef2530cf8a5cdf384890dfca7a328b66b7041ee31c6cc52e66050a6389a2" }, "dependencyClosures": { "wasm32": [] @@ -624,7 +624,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "16504e48b4275764b5ec40e659ea47e7427310af75751ef7d58c5254833d3d9b" + "wasm32": "025c1324e522da0331d6145a0adcc5205c70368fe1e35cb15cc25bb13e8616c3" }, "dependencyClosures": { "wasm32": [] @@ -645,14 +645,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "bd8f4e5251c181cf921cb0d7cb2ad13e3e4b02bd3a1b301a04311eb7286e4e41" + "wasm32": "4bd6e5948f8b59105b5159709dfbc7b1e6b1851acc4b34ceb4c23c51fab643c1" }, "dependencyClosures": { "wasm32": [ { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "45f5d1d8385fcb2f5c568b57ea2840808423502205bef8b300b3ce37d7077ca7" + "cacheKey": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc" } ] }, @@ -679,19 +679,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "bf159d013349cdce705f202b56236225fa388ada560b786d0fc6ea56bf5eb1fd" + "wasm32": "60fdd5816eeda424dc84875008821348c87f45ce44af03755514eaf184fae225" }, "dependencyClosures": { "wasm32": [ { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "cc22419dce43a129c42182ebb85e7b27b96ff2c4eb390ea9babcc3a352678ac7" + "cacheKey": "7b7c878fcf8eadc0689030d9a8f4e12a15e34933c2aef7192136be4d51e42db2" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "45f5d1d8385fcb2f5c568b57ea2840808423502205bef8b300b3ce37d7077ca7" + "cacheKey": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc" } ] }, @@ -711,7 +711,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "37014abeb2626a580840d8fc3cadf61b5970c95e77cecc07091c7962d1410665" + "wasm32": "f0373b19c4e22a4359403372bdc43ed56f35cebbc939c3e34b2c34560ab466c8" }, "dependencyClosures": { "wasm32": [] @@ -732,7 +732,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "6d0e55c63433a88fc34f00d534ced18f21941fe4fd1a6e2235049a0e8792ab10" + "wasm32": "c5390d8a92f04562466236d79a225c7c611f075ed02d1d21fb5b080fb7f01a0f" }, "dependencyClosures": { "wasm32": [] @@ -774,14 +774,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0ce5e4f73eee78b62a9dd8985de1bdaa2a88f2e557b5d02664874f8c09f66c4f" + "wasm32": "3f86ef60f36863f0437258c43d10333a65c9412c8899688242fb16207361fa6b" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8f435f7638a234c3327375ba1055d0c1e33372c8c4375169043d9fff4fd03ed2" + "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" } ] }, @@ -815,7 +815,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ef8b91b4f14f0622973f44bf05c6c41bfaea828376d56c33a5c0c924f81f6a4a" + "wasm32": "7240df3edd54fd8ca538f915c4a49eb66fb06df6eb9bda51678c5c30fe97f0a4" }, "dependencyClosures": { "wasm32": [] @@ -843,14 +843,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "4b47d0fe3935e11b74fa6630b7698980d8b75f60e1eed3d83ef57a4b6fe7f574" + "wasm32": "948a07db1bd03c14d9ea6b13f0efa01e5decb92cade7750ecd319107008ef80d" }, "dependencyClosures": { "wasm32": [ { "packageName": "erlang", "manifestSha256": "475d6037e73a0f1e2f4381067194b2422751206979ea17209e10efa5a2a93bbb", - "cacheKey": "ef8b91b4f14f0622973f44bf05c6c41bfaea828376d56c33a5c0c924f81f6a4a" + "cacheKey": "7240df3edd54fd8ca538f915c4a49eb66fb06df6eb9bda51678c5c30fe97f0a4" } ] }, @@ -870,7 +870,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "8eccbbbdc04705bbfaa81421ac7ea00da9133400ac36597f99e045c153ffa359" + "wasm32": "0b567960b1b6b829bdd14304dafab6a508e23dfbc114a999892e1c411f2c63a6" }, "dependencyClosures": { "wasm32": [] @@ -891,7 +891,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "d1da488931fb5bc21c84a35baecf432d4be24b588998f052bbf85ae8937cdd52" + "wasm32": "8112d62ebb8c99a5a1714799f366be25fecf6b4c81f1e4e07aacd57c91535e47" }, "dependencyClosures": { "wasm32": [] @@ -919,7 +919,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "059e682906024abce917ce48a7bc43723126cfc9462467c0658f725ea57a9e12" + "wasm32": "8e97787e868fd0e18a9855b9f82bb410cb2e358d1f585cfc1e87cf21985737ee" }, "dependencyClosures": { "wasm32": [] @@ -947,7 +947,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "4e801b5d5856d43e6e98b8ffde839bc0f44f23d3251b3ae313d25b2e09c39a64" + "wasm32": "c388eef100553698e390f5a207c418afff2a075547ec5f3536460748b7df55e6" }, "dependencyClosures": { "wasm32": [] @@ -968,7 +968,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "13173bbf8d6c5497f88945954b2e276ae1d1eba1d9325813cd2158fdfe166f0f" + "wasm32": "3c1a3fe79b8f32a26d67e407f10c9c34a60035179024bc4b4d6064e52f6948a3" }, "dependencyClosures": { "wasm32": [] @@ -996,7 +996,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "9b2b98713c4f7e2e68abe0eb8f5839eceefdbccaa3db3dc7e0bddf0110ff7acb" + "wasm32": "3e0a00c54f9d2086dc1efbd06bcf54b6bc5d35919d58dc4267bdfd32cfb838a2" }, "dependencyClosures": { "wasm32": [] @@ -1017,7 +1017,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "76e34816990e548711eb857ca9bfd7a6730912fd1197e356fbdee2bb80db745d" + "wasm32": "96085853b6c4c622d3b60acbdc0c982b1c51b079b79e942bdc02aa684c4321a2" }, "dependencyClosures": { "wasm32": [] @@ -1038,7 +1038,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "682057447474e1bad8146fb1df2c585152ee12eb56bc472cd60ea768bd53ce4d" + "wasm32": "d3865a95335c0f22ea155825dd8d1b1f680bf90ca00d4024b59c4e0928459912" }, "dependencyClosures": { "wasm32": [] @@ -1066,14 +1066,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "1d7cd9ad27fd7edef080d32a7134ff17ac9aff70c6d2cae8f62e0c2844e77cb1" + "wasm32": "2d67aa6a7aaca3edfb622a3888ffe8b3282ba212e12e814116550357245e2748" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8f435f7638a234c3327375ba1055d0c1e33372c8c4375169043d9fff4fd03ed2" + "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" } ] }, @@ -1093,64 +1093,64 @@ "wasm32" ], "cacheKeys": { - "wasm32": "cb86e4941859d9c052b173b6b2f8e3c7223c5fc5b396ba550f9e3bdd4df25757" + "wasm32": "c156d707a51eef48fdc4764ec7a07fbe35a70ec0ebc172284582ad65b924ccf6" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "0ce5e4f73eee78b62a9dd8985de1bdaa2a88f2e557b5d02664874f8c09f66c4f" + "cacheKey": "3f86ef60f36863f0437258c43d10333a65c9412c8899688242fb16207361fa6b" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "52d441be75809dc07ceaee197d8d477a99aa3e29e2f7f149d9bfe795aa684630" + "cacheKey": "d129add0d6c230b17232aef83127a269c5c1c11349d9331d06fcc6889b56f5d0" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "ca63d1caa19b66e88726af7c0d5944a17621603fefdc8e84af46945b10a560e2" + "cacheKey": "2c986bb0488a9ff05b1ef2ec3c0c44be940504ff9b7f33a4e235f981dc016e17" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8f435f7638a234c3327375ba1055d0c1e33372c8c4375169043d9fff4fd03ed2" + "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "6b805ee5c087b70ff6d4068b247626733daaa5551a45fcc9510373455a3d4d33" + "cacheKey": "23113207cb8ec3b213b054284770c9ad92d738b225044d01563f1bc3a677622c" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "7e0bdce4d06f6d95bd25c1378843945c27c50907cf4b2767bba785356e858d94" + "cacheKey": "70356b9298ebe5b6d58e7d08093d71b1137c2ff99617beb3d8134f3bc919b088" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "ca39fcc365bc853e1e839a5aebc5e52c16b3783caa966a5c42de29d5b5fa36b5" + "cacheKey": "4aab2056a613043f30ebf493e466227b595740de70ba210e80919a6d3c1069f5" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "80ee9fa351e72db098b9cde6d0eb87811e2af5b1e01c97a2a985278e17a7e728" + "cacheKey": "141ed56befae2c701961c53a06f1d3dcd1949ce5012e6c62209c45092b13e3ff" }, { "packageName": "msmtpd", - "manifestSha256": "686ff665b5e7907e67618790b1a3eddce2ff47ed665595d17209bdcda1e0b274", - "cacheKey": "880dbcb9760918e21e6a4d936655de2a01b94f10fa495a87af467b5bb474c3f7" + "manifestSha256": "09de04a422ddf631a29a259d816e081f8d317d1077808ede55d8c500baae748f", + "cacheKey": "ea09f8a7c8a43ddeed1fef59c3a9a7b987e0bda7be881d4d1ae4019d73a7ef30" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "e3a71f5b5d417de5430375ca766f22bc78a46a92c90df8c6fcfdb9fcb6d0051c" + "cacheKey": "cd58a900319c46bbc268ac664900527414e9c6ffa40683b3be6bcd97d4d47283" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "cc22419dce43a129c42182ebb85e7b27b96ff2c4eb390ea9babcc3a352678ac7" + "cacheKey": "7b7c878fcf8eadc0689030d9a8f4e12a15e34933c2aef7192136be4d51e42db2" }, { "packageName": "pcre2-source", @@ -1160,22 +1160,22 @@ { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "c6e278891e21d9265b0a91fe8deca055e492afb0e02e4d69b13dea28240055c4" + "cacheKey": "cba27c5df3f7ce7a296d1e672d5ce6671052bcb19e2893b73f926cf7e498d6ae" }, { "packageName": "shell", - "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", - "cacheKey": "ef40010ab7393e1309ff9fa2ca1fae82aac500b3d3be8dbfa9301119d8c90d58" + "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", + "cacheKey": "735c2c0ecb8edde3797fb59fd6e38f5f8c86a81fced93e3f24e87db5aafcc02b" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "acdf72794b5e77a94280af5edae99a9ccc92e5093d42332881c66e23571c20b1" + "cacheKey": "63f8f0bc003c78665088d6ebdfbd495325454537fba1e6a9df9c1c8a9857cf1b" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "45f5d1d8385fcb2f5c568b57ea2840808423502205bef8b300b3ce37d7077ca7" + "cacheKey": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc" } ] }, @@ -1195,7 +1195,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "6b8c04496ad17846200a05046f11d796fc192eae207d1be79ac7cbab85571a18" + "wasm32": "7b4ae641cb4f9310e48636f98f955cb3f79557159bdb1e80b3fe6badfaf5689a" }, "dependencyClosures": { "wasm32": [] @@ -1216,7 +1216,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "952a5f69fd8a7c66e43a6b33e2beb4595f4ebb06a72af39f5535965375f8ad4a" + "wasm32": "5a380c75d0d25b820bc140e5b9e4d01ffa4439679b5b5b8fe0dfe9ca8e67b00a" }, "dependencyClosures": { "wasm32": [] @@ -1237,7 +1237,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ac71d07cc5e79156d53c5fde3577989149947340b149d0c950413119501b4827" + "wasm32": "13c48d5828987e67dc653829de378a172f8a486e1427c888168526e2536747ce" }, "dependencyClosures": { "wasm32": [] @@ -1258,7 +1258,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "6c21668c1c4a49a349c57e420586c8d19b1e49ad9b41d59bc92b31882db10cae" + "wasm32": "b326ee0fce5b1d3f5ed8c0d865a6f351d162b3848bec1084baef1de028418a82" }, "dependencyClosures": { "wasm32": [] @@ -1280,15 +1280,15 @@ "wasm64" ], "cacheKeys": { - "wasm32": "80ee9fa351e72db098b9cde6d0eb87811e2af5b1e01c97a2a985278e17a7e728", - "wasm64": "2b125feab56eba39c9c8d61e612a37db374aa72afca0e58f8fc74a21852e03b5" + "wasm32": "141ed56befae2c701961c53a06f1d3dcd1949ce5012e6c62209c45092b13e3ff", + "wasm64": "ee47227abc6a8059e1e4501515ac18656cca0501e087018708fd15c9f38aebab" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8f435f7638a234c3327375ba1055d0c1e33372c8c4375169043d9fff4fd03ed2" + "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" }, { "packageName": "pcre2-source", @@ -1300,7 +1300,7 @@ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "cf90c73f1e47f4241a40b2fb7fff30a3ac1d1e8e6e691f82c2a30d752e5647f6" + "cacheKey": "a8535610a175d2c3c443596be03f123f2c7a007a26bba163bd09d4fba10d8b42" }, { "packageName": "pcre2-source", @@ -1332,34 +1332,34 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2a6c758205bdb36b0e310a4256a8cc878225ba40cc9f9c53cd274225c6d9f3a5" + "wasm32": "784f34c97f112d3348013e6a7450975c631cd7c5de12907c924ca4167f074e37" }, "dependencyClosures": { "wasm32": [ { "packageName": "coreutils", - "manifestSha256": "72fc9c3818fb11cebaa047a623743252395dc729163fa804a7272d59f87fe82d", - "cacheKey": "16504e48b4275764b5ec40e659ea47e7427310af75751ef7d58c5254833d3d9b" + "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", + "cacheKey": "025c1324e522da0331d6145a0adcc5205c70368fe1e35cb15cc25bb13e8616c3" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "37014abeb2626a580840d8fc3cadf61b5970c95e77cecc07091c7962d1410665" + "cacheKey": "f0373b19c4e22a4359403372bdc43ed56f35cebbc939c3e34b2c34560ab466c8" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "0ce5e4f73eee78b62a9dd8985de1bdaa2a88f2e557b5d02664874f8c09f66c4f" + "cacheKey": "3f86ef60f36863f0437258c43d10333a65c9412c8899688242fb16207361fa6b" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8f435f7638a234c3327375ba1055d0c1e33372c8c4375169043d9fff4fd03ed2" + "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "80ee9fa351e72db098b9cde6d0eb87811e2af5b1e01c97a2a985278e17a7e728" + "cacheKey": "141ed56befae2c701961c53a06f1d3dcd1949ce5012e6c62209c45092b13e3ff" }, { "packageName": "pcre2-source", @@ -1385,35 +1385,35 @@ "wasm64" ], "cacheKeys": { - "wasm32": "c3d7418fbe548b092751eea9ed8305b231950935b1b14fd1a2cafad3fd868a1b", - "wasm64": "9db60c614d7b3607720d7e8c6a5209f35fb1db9b17d0afa6d5545a68b7301de6" + "wasm32": "4a858c3f8add0dc39eb56a08b7041ccd84e47fd6fb14a526efdd87539a77468b", + "wasm64": "6a3b1cf6fa442cf7868b8f4beddce967f623f19fe83a7e483f8532523b8472ab" }, "dependencyClosures": { "wasm32": [ { "packageName": "coreutils", - "manifestSha256": "72fc9c3818fb11cebaa047a623743252395dc729163fa804a7272d59f87fe82d", - "cacheKey": "16504e48b4275764b5ec40e659ea47e7427310af75751ef7d58c5254833d3d9b" + "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", + "cacheKey": "025c1324e522da0331d6145a0adcc5205c70368fe1e35cb15cc25bb13e8616c3" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "37014abeb2626a580840d8fc3cadf61b5970c95e77cecc07091c7962d1410665" + "cacheKey": "f0373b19c4e22a4359403372bdc43ed56f35cebbc939c3e34b2c34560ab466c8" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "0ce5e4f73eee78b62a9dd8985de1bdaa2a88f2e557b5d02664874f8c09f66c4f" + "cacheKey": "3f86ef60f36863f0437258c43d10333a65c9412c8899688242fb16207361fa6b" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8f435f7638a234c3327375ba1055d0c1e33372c8c4375169043d9fff4fd03ed2" + "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "80ee9fa351e72db098b9cde6d0eb87811e2af5b1e01c97a2a985278e17a7e728" + "cacheKey": "141ed56befae2c701961c53a06f1d3dcd1949ce5012e6c62209c45092b13e3ff" }, { "packageName": "pcre2-source", @@ -1424,28 +1424,28 @@ "wasm64": [ { "packageName": "coreutils", - "manifestSha256": "72fc9c3818fb11cebaa047a623743252395dc729163fa804a7272d59f87fe82d", - "cacheKey": "738dae9d496b5aff2d4215ab75aa3dd141bcf1543f54963604f5cc896a8c1fc9" + "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", + "cacheKey": "258bfcc358a310c5b86a4b48d05163840083ff2a38713118be92208e932522d8" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "58a8182735bae92e3ce2c582a31b1eea78a80228b4ed18d8b60fbdf769ad4549" + "cacheKey": "4050007a409933f2b5988c77ac7e8ff1b1a06be2a3d5a6b5b627539e790c1ec1" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "ea4ae28552bf09c4d587066752615ef0eb1e59f640571259e0b0161b14031887" + "cacheKey": "d390cee24c29c28de3914085cb36459d4265e4f3a538ddf08096d460ec442ac5" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "cf90c73f1e47f4241a40b2fb7fff30a3ac1d1e8e6e691f82c2a30d752e5647f6" + "cacheKey": "a8535610a175d2c3c443596be03f123f2c7a007a26bba163bd09d4fba10d8b42" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "2b125feab56eba39c9c8d61e612a37db374aa72afca0e58f8fc74a21852e03b5" + "cacheKey": "ee47227abc6a8059e1e4501515ac18656cca0501e087018708fd15c9f38aebab" }, { "packageName": "pcre2-source", @@ -1470,7 +1470,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "6bfba518ec9c63568dffd82204bf4f572e92210a0ff769469c94292074567089" + "wasm32": "d1c2c44f0bea233667911c79cc5a5ef8fc9b41210b2913526cec1888647b71ec" }, "dependencyClosures": { "wasm32": [] @@ -1491,7 +1491,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "880dbcb9760918e21e6a4d936655de2a01b94f10fa495a87af467b5bb474c3f7" + "wasm32": "ea09f8a7c8a43ddeed1fef59c3a9a7b987e0bda7be881d4d1ae4019d73a7ef30" }, "dependencyClosures": { "wasm32": [] @@ -1512,7 +1512,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0748563a592c1281e68db48d4dcfe5a95f8137ba6c73e5da9a0727634870c13e" + "wasm32": "f4b8af0d2e6dfdd05c495ed26d2fdb80bb3987ac1eb58e324e17272ea6060bd9" }, "dependencyClosures": { "wasm32": [] @@ -1533,7 +1533,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ee1e8ed6f49dc30a0bcf4b4ae1e11a3a4ecb28e688ad91f217446dba85b3369a" + "wasm32": "9c9b20d1316df92a12cc0e0a5fb327b10278220cecd94a2adc3ed8ac9a29ecd5" }, "dependencyClosures": { "wasm32": [] @@ -1617,7 +1617,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "d83482fd416c7ee3d595e4d09bd51c488652416fa31a8edde3f7e50a60e2e211" + "wasm32": "1827799c81052b95dbc22e913e556ffd801c1ee2e3cb4435673f1d342fded3d4" }, "dependencyClosures": { "wasm32": [] @@ -1638,14 +1638,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "3bd42cc6245eebed0c013769f6aaa491be96c714565fcce4ab9907ef3702ba32" + "wasm32": "be86dd34e94fcc32fd7c067f81df5685df966e3a8e957292b2ab09d8f6bd159d" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "ee1e8ed6f49dc30a0bcf4b4ae1e11a3a4ecb28e688ad91f217446dba85b3369a" + "cacheKey": "9c9b20d1316df92a12cc0e0a5fb327b10278220cecd94a2adc3ed8ac9a29ecd5" } ] }, @@ -1665,19 +1665,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "037432e4c25d61c42c8c80c7f9ac25d39c0aa1199bf1ae04fc0ec45efbb9c449" + "wasm32": "2f2221697fcd9c2e54dfa6cb38ed9c307dd6e018fc871530a1d307cbd5cfadb7" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "ee1e8ed6f49dc30a0bcf4b4ae1e11a3a4ecb28e688ad91f217446dba85b3369a" + "cacheKey": "9c9b20d1316df92a12cc0e0a5fb327b10278220cecd94a2adc3ed8ac9a29ecd5" }, { "packageName": "nethack", "manifestSha256": "1a2f12ec2770bd8d40006d6c878a3493b97c71c2bb63ad08c7f6ad99bfd53504", - "cacheKey": "3bd42cc6245eebed0c013769f6aaa491be96c714565fcce4ab9907ef3702ba32" + "cacheKey": "be86dd34e94fcc32fd7c067f81df5685df966e3a8e957292b2ab09d8f6bd159d" } ] }, @@ -1697,7 +1697,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e3a71f5b5d417de5430375ca766f22bc78a46a92c90df8c6fcfdb9fcb6d0051c" + "wasm32": "cd58a900319c46bbc268ac664900527414e9c6ffa40683b3be6bcd97d4d47283" }, "dependencyClosures": { "wasm32": [] @@ -1718,79 +1718,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "eb8776b6a2a4458f1d621e9487f434761dbfc0bff319037c913b9ed496da765d" + "wasm32": "abe26676465686f26ddfe4f83d5763d72521a6a94e0735af17a93b94307d643c" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "0ce5e4f73eee78b62a9dd8985de1bdaa2a88f2e557b5d02664874f8c09f66c4f" + "cacheKey": "3f86ef60f36863f0437258c43d10333a65c9412c8899688242fb16207361fa6b" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "52d441be75809dc07ceaee197d8d477a99aa3e29e2f7f149d9bfe795aa684630" + "cacheKey": "d129add0d6c230b17232aef83127a269c5c1c11349d9331d06fcc6889b56f5d0" }, { "packageName": "kernel", "manifestSha256": "db1d66db8575562ac7b3e720dbaa06d7dd5eef577d80220ea4167dc2d69b863b", - "cacheKey": "973496ab604cc30423a02e6ada802f3e26601b88820fc019068cf7fbf3098456" + "cacheKey": "a135dbbd66f558b2f6d41a16db4352b97e58d7e61f3afe9b3eed8accaab4564d" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "ca63d1caa19b66e88726af7c0d5944a17621603fefdc8e84af46945b10a560e2" + "cacheKey": "2c986bb0488a9ff05b1ef2ec3c0c44be940504ff9b7f33a4e235f981dc016e17" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8f435f7638a234c3327375ba1055d0c1e33372c8c4375169043d9fff4fd03ed2" + "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "6b805ee5c087b70ff6d4068b247626733daaa5551a45fcc9510373455a3d4d33" + "cacheKey": "23113207cb8ec3b213b054284770c9ad92d738b225044d01563f1bc3a677622c" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "7e0bdce4d06f6d95bd25c1378843945c27c50907cf4b2767bba785356e858d94" + "cacheKey": "70356b9298ebe5b6d58e7d08093d71b1137c2ff99617beb3d8134f3bc919b088" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "ca39fcc365bc853e1e839a5aebc5e52c16b3783caa966a5c42de29d5b5fa36b5" + "cacheKey": "4aab2056a613043f30ebf493e466227b595740de70ba210e80919a6d3c1069f5" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "e3a71f5b5d417de5430375ca766f22bc78a46a92c90df8c6fcfdb9fcb6d0051c" + "cacheKey": "cd58a900319c46bbc268ac664900527414e9c6ffa40683b3be6bcd97d4d47283" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "cc22419dce43a129c42182ebb85e7b27b96ff2c4eb390ea9babcc3a352678ac7" + "cacheKey": "7b7c878fcf8eadc0689030d9a8f4e12a15e34933c2aef7192136be4d51e42db2" }, { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "c6e278891e21d9265b0a91fe8deca055e492afb0e02e4d69b13dea28240055c4" + "cacheKey": "cba27c5df3f7ce7a296d1e672d5ce6671052bcb19e2893b73f926cf7e498d6ae" }, { "packageName": "shell", - "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", - "cacheKey": "ef40010ab7393e1309ff9fa2ca1fae82aac500b3d3be8dbfa9301119d8c90d58" + "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", + "cacheKey": "735c2c0ecb8edde3797fb59fd6e38f5f8c86a81fced93e3f24e87db5aafcc02b" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "acdf72794b5e77a94280af5edae99a9ccc92e5093d42332881c66e23571c20b1" + "cacheKey": "63f8f0bc003c78665088d6ebdfbd495325454537fba1e6a9df9c1c8a9857cf1b" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "45f5d1d8385fcb2f5c568b57ea2840808423502205bef8b300b3ce37d7077ca7" + "cacheKey": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc" } ] }, @@ -1810,29 +1810,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "fc8561d6175b19f3808e5f8bb3b70981d3b1af5bab7df3cd0991a21683562579" + "wasm32": "505ca1af0a7383a40519de0982e53852d66ad50f164d12e08e815d2d36187985" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "0ce5e4f73eee78b62a9dd8985de1bdaa2a88f2e557b5d02664874f8c09f66c4f" + "cacheKey": "3f86ef60f36863f0437258c43d10333a65c9412c8899688242fb16207361fa6b" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8f435f7638a234c3327375ba1055d0c1e33372c8c4375169043d9fff4fd03ed2" + "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "e3a71f5b5d417de5430375ca766f22bc78a46a92c90df8c6fcfdb9fcb6d0051c" + "cacheKey": "cd58a900319c46bbc268ac664900527414e9c6ffa40683b3be6bcd97d4d47283" }, { "packageName": "shell", - "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", - "cacheKey": "ef40010ab7393e1309ff9fa2ca1fae82aac500b3d3be8dbfa9301119d8c90d58" + "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", + "cacheKey": "735c2c0ecb8edde3797fb59fd6e38f5f8c86a81fced93e3f24e87db5aafcc02b" } ] }, @@ -1852,29 +1852,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "88be95a99c566c966012b355c439352e09f5c020eadf5a95ecc2f13cb72638c1" + "wasm32": "59917f32e95cf3c65e899cf230ed19154321d18280246fe9c0e75e970af77e47" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8f435f7638a234c3327375ba1055d0c1e33372c8c4375169043d9fff4fd03ed2" + "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "cc22419dce43a129c42182ebb85e7b27b96ff2c4eb390ea9babcc3a352678ac7" + "cacheKey": "7b7c878fcf8eadc0689030d9a8f4e12a15e34933c2aef7192136be4d51e42db2" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "b44e8ffaa8e9fdf7b51cb5ae5830070fa1b5959441da550d80a3d0aefd2504d2" + "cacheKey": "703273c0bfc90bf5db63384125331fd60b5ea8dd8910ffd49be251503bce2d79" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "45f5d1d8385fcb2f5c568b57ea2840808423502205bef8b300b3ce37d7077ca7" + "cacheKey": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc" } ] }, @@ -1894,39 +1894,39 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c4b8f33443c5bd6581fd3d8a7738863f461ccfc41c7c86161c1434e6da6ddc67" + "wasm32": "cba98a6b26b1f906c3db99c2e9da398f3a8251cd62915b3b0d62e879f4e5ab9f" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8f435f7638a234c3327375ba1055d0c1e33372c8c4375169043d9fff4fd03ed2" + "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" }, { "packageName": "node", "manifestSha256": "2131a24dfda8587860e57fc65b573086477d376b065b3b9ca4e1dfe4d79f5790", - "cacheKey": "88be95a99c566c966012b355c439352e09f5c020eadf5a95ecc2f13cb72638c1" + "cacheKey": "59917f32e95cf3c65e899cf230ed19154321d18280246fe9c0e75e970af77e47" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "cc22419dce43a129c42182ebb85e7b27b96ff2c4eb390ea9babcc3a352678ac7" + "cacheKey": "7b7c878fcf8eadc0689030d9a8f4e12a15e34933c2aef7192136be4d51e42db2" }, { "packageName": "shell", - "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", - "cacheKey": "ef40010ab7393e1309ff9fa2ca1fae82aac500b3d3be8dbfa9301119d8c90d58" + "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", + "cacheKey": "735c2c0ecb8edde3797fb59fd6e38f5f8c86a81fced93e3f24e87db5aafcc02b" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "b44e8ffaa8e9fdf7b51cb5ae5830070fa1b5959441da550d80a3d0aefd2504d2" + "cacheKey": "703273c0bfc90bf5db63384125331fd60b5ea8dd8910ffd49be251503bce2d79" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "45f5d1d8385fcb2f5c568b57ea2840808423502205bef8b300b3ce37d7077ca7" + "cacheKey": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc" } ] }, @@ -1946,7 +1946,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "4ba31fed7ec2636c654ce9b803660e80ea755d413391ad544d55d951ec032ca7" + "wasm32": "3f6af76550825313cdc09758c3cf2480b65a1a1c64e0f4a3cee95fff09f467d8" }, "dependencyClosures": { "wasm32": [] @@ -1967,14 +1967,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e0a0ab7f1c95d9dd87e3fce1428e4c4620de0d986b48f80cfee04cf76569e386" + "wasm32": "1c6a0edb1823313d0cc6538e668a6778ade866347392adb8ffbedb1054628128" }, "dependencyClosures": { "wasm32": [ { "packageName": "perl", "manifestSha256": "6cdc4dbc54d0e4008cff41f82ec8918c0e44b4aef23903539c1bc0f538fe3ad2", - "cacheKey": "4ba31fed7ec2636c654ce9b803660e80ea755d413391ad544d55d951ec032ca7" + "cacheKey": "3f6af76550825313cdc09758c3cf2480b65a1a1c64e0f4a3cee95fff09f467d8" } ] }, @@ -1994,54 +1994,54 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c6e278891e21d9265b0a91fe8deca055e492afb0e02e4d69b13dea28240055c4" + "wasm32": "cba27c5df3f7ce7a296d1e672d5ce6671052bcb19e2893b73f926cf7e498d6ae" }, "dependencyClosures": { "wasm32": [ { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "52d441be75809dc07ceaee197d8d477a99aa3e29e2f7f149d9bfe795aa684630" + "cacheKey": "d129add0d6c230b17232aef83127a269c5c1c11349d9331d06fcc6889b56f5d0" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "ca63d1caa19b66e88726af7c0d5944a17621603fefdc8e84af46945b10a560e2" + "cacheKey": "2c986bb0488a9ff05b1ef2ec3c0c44be940504ff9b7f33a4e235f981dc016e17" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8f435f7638a234c3327375ba1055d0c1e33372c8c4375169043d9fff4fd03ed2" + "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "6b805ee5c087b70ff6d4068b247626733daaa5551a45fcc9510373455a3d4d33" + "cacheKey": "23113207cb8ec3b213b054284770c9ad92d738b225044d01563f1bc3a677622c" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "7e0bdce4d06f6d95bd25c1378843945c27c50907cf4b2767bba785356e858d94" + "cacheKey": "70356b9298ebe5b6d58e7d08093d71b1137c2ff99617beb3d8134f3bc919b088" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "ca39fcc365bc853e1e839a5aebc5e52c16b3783caa966a5c42de29d5b5fa36b5" + "cacheKey": "4aab2056a613043f30ebf493e466227b595740de70ba210e80919a6d3c1069f5" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "cc22419dce43a129c42182ebb85e7b27b96ff2c4eb390ea9babcc3a352678ac7" + "cacheKey": "7b7c878fcf8eadc0689030d9a8f4e12a15e34933c2aef7192136be4d51e42db2" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "acdf72794b5e77a94280af5edae99a9ccc92e5093d42332881c66e23571c20b1" + "cacheKey": "63f8f0bc003c78665088d6ebdfbd495325454537fba1e6a9df9c1c8a9857cf1b" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "45f5d1d8385fcb2f5c568b57ea2840808423502205bef8b300b3ce37d7077ca7" + "cacheKey": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc" } ] }, @@ -2117,7 +2117,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "4dbc5c888a29eb71039e433f5a2d74733f9cb79caf9cfba23df997e1d0544f8b" + "wasm32": "971093bac83c0c75229da3992210c599002943dd6b21d1e98ac332a22379b221" }, "dependencyClosures": { "wasm32": [] @@ -2390,19 +2390,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2e5120bdf53f362d422c61ea6fe9bbb7bd2f7e25ac2a73a899de006575eeb383" + "wasm32": "413df2ad2183a8750dd0792d526fdc8644f2d26a8a44ae47e950dc41a0fcd86c" }, "dependencyClosures": { "wasm32": [ { "packageName": "cpython", "manifestSha256": "7dd4f446697a73941ec940c2ccba4d53be73fe6947f1e701031a4d0aa964c4ff", - "cacheKey": "bd8f4e5251c181cf921cb0d7cb2ad13e3e4b02bd3a1b301a04311eb7286e4e41" + "cacheKey": "4bd6e5948f8b59105b5159709dfbc7b1e6b1851acc4b34ceb4c23c51fab643c1" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "45f5d1d8385fcb2f5c568b57ea2840808423502205bef8b300b3ce37d7077ca7" + "cacheKey": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc" } ] }, @@ -2422,7 +2422,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "21dee6bca95d930cd02bb1f0db35f69bad9983fa5b0d0c0c3917ff95b5922431" + "wasm32": "0b6e849447c2d14076954fcd4eefd2794d5dd31b220c23a6aa8dc042292e0496" }, "dependencyClosures": { "wasm32": [] @@ -2450,24 +2450,24 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e373cb46f82ced398324a466e8e5d0076172eac8094d837b20f3bf28fbbb20a1" + "wasm32": "ac89c13d91d1f3fd452d8fb3e1da15b1d2d1dc6402b776cb9b87dcc1c2e44ebd" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "0ce5e4f73eee78b62a9dd8985de1bdaa2a88f2e557b5d02664874f8c09f66c4f" + "cacheKey": "3f86ef60f36863f0437258c43d10333a65c9412c8899688242fb16207361fa6b" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8f435f7638a234c3327375ba1055d0c1e33372c8c4375169043d9fff4fd03ed2" + "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" }, { "packageName": "redis", "manifestSha256": "151fb507de953ba2ba94b8f881bc7d66b4c741c1359a2f590cc07f9a4cd368a3", - "cacheKey": "21dee6bca95d930cd02bb1f0db35f69bad9983fa5b0d0c0c3917ff95b5922431" + "cacheKey": "0b6e849447c2d14076954fcd4eefd2794d5dd31b220c23a6aa8dc042292e0496" } ] }, @@ -2487,79 +2487,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "fe8ad25d00880687479abe16b7b09a8591bb7e4a8c67a4b8d28c6927768f8d86" + "wasm32": "fc7c012003326b1ba75eb8acc08b62b38d000a8d1f03e179a6103430b8cc3c58" }, "dependencyClosures": { "wasm32": [ { "packageName": "bash", - "manifestSha256": "5dc4d97dc4f154f265ff57e9d972e7d5a64a2e15fadedfa246733b830dda3497", - "cacheKey": "92f97de3257bfa8bb8b2a5b6a43f679d0da99835fdeff5b8edb12bb94ae30a8a" + "manifestSha256": "6478060f28d430d18a6ebe7c603392d35a41d9bcf6bc74322351d1450b9c5335", + "cacheKey": "f45b7eb0ff367bac6331bedd038598b0708ca5f3400ffdc97064fb73d23982f6" }, { "packageName": "bc", - "manifestSha256": "43b61e92c29098720bc7f4871a3b611cc01ffd124024290d42967cebb6ffd459", - "cacheKey": "5bd6386e4d291fef9ee23066706cd34265e0b1211262df055ddd85a882547d40" + "manifestSha256": "a65661463bb7047b91ff00153bd99fd962c96e571934ab4e923f5215fb6ecfbd", + "cacheKey": "b9f425f193548cb0585c55bebdab2e7f7e22c4bc6aa5a407682874ecb6448795" }, { "packageName": "coreutils", - "manifestSha256": "72fc9c3818fb11cebaa047a623743252395dc729163fa804a7272d59f87fe82d", - "cacheKey": "16504e48b4275764b5ec40e659ea47e7427310af75751ef7d58c5254833d3d9b" + "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", + "cacheKey": "025c1324e522da0331d6145a0adcc5205c70368fe1e35cb15cc25bb13e8616c3" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "37014abeb2626a580840d8fc3cadf61b5970c95e77cecc07091c7962d1410665" + "cacheKey": "f0373b19c4e22a4359403372bdc43ed56f35cebbc939c3e34b2c34560ab466c8" }, { "packageName": "diffutils", - "manifestSha256": "2286203eb762eca487939963e38ea1b901ac2dc9a830d3260c2fb291757df208", - "cacheKey": "6d0e55c63433a88fc34f00d534ced18f21941fe4fd1a6e2235049a0e8792ab10" + "manifestSha256": "3a78f0a46bae43ce5ea235c6638c2cbd1d8559b0b62b1fb42443b35968c1aca3", + "cacheKey": "c5390d8a92f04562466236d79a225c7c611f075ed02d1d21fb5b080fb7f01a0f" }, { "packageName": "file", "manifestSha256": "7874c1affcbbf2c8ab8c8d4beb087f275af57b5caae6a8947bf5a63a181552b2", - "cacheKey": "d1da488931fb5bc21c84a35baecf432d4be24b588998f052bbf85ae8937cdd52" + "cacheKey": "8112d62ebb8c99a5a1714799f366be25fecf6b4c81f1e4e07aacd57c91535e47" }, { "packageName": "findutils", - "manifestSha256": "a9969cb102403cef105e758ef602692500fddd75ec96e4e3f168c50094b6c931", - "cacheKey": "059e682906024abce917ce48a7bc43723126cfc9462467c0658f725ea57a9e12" + "manifestSha256": "cceedf52aea67fbb0da03cfce6f2e9b1a7b5561fa1656c2762b43c651a625d02", + "cacheKey": "8e97787e868fd0e18a9855b9f82bb410cb2e358d1f585cfc1e87cf21985737ee" }, { "packageName": "gawk", - "manifestSha256": "b788f3de724cd363ea68d08826586ddf544a75fb5dd9df1369ffafe06d8681b7", - "cacheKey": "4e801b5d5856d43e6e98b8ffde839bc0f44f23d3251b3ae313d25b2e09c39a64" + "manifestSha256": "a2567b8b0778805e385e1d3a41ac8b5d74aaf9d293b222001b17d09b11e5a0ba", + "cacheKey": "c388eef100553698e390f5a207c418afff2a075547ec5f3536460748b7df55e6" }, { "packageName": "grep", - "manifestSha256": "7c61b894807b831c7e8816cb1f39c78e3a69a4ced2fa3d18f0abdf00bf18334b", - "cacheKey": "9b2b98713c4f7e2e68abe0eb8f5839eceefdbccaa3db3dc7e0bddf0110ff7acb" + "manifestSha256": "59270d3bfb33167b32d13246bf855b49308f8c9c0a66d9635c804f702421fbc6", + "cacheKey": "3e0a00c54f9d2086dc1efbd06bcf54b6bc5d35919d58dc4267bdfd32cfb838a2" }, { "packageName": "m4", - "manifestSha256": "9b5838bdde8531079f23a89e135aa3832bbbbb7ad6099dd1503db877d52afcad", - "cacheKey": "ac71d07cc5e79156d53c5fde3577989149947340b149d0c950413119501b4827" + "manifestSha256": "2c6582d99d6eabfb9da52badf49b899e9fdcba76a728536b25bc3741f3331543", + "cacheKey": "13c48d5828987e67dc653829de378a172f8a486e1427c888168526e2536747ce" }, { "packageName": "make", - "manifestSha256": "62e8b36a6df7e981d191061a5bb7f2db85b2deaf365352a7d590a6c6c06f2b1e", - "cacheKey": "6c21668c1c4a49a349c57e420586c8d19b1e49ad9b41d59bc92b31882db10cae" + "manifestSha256": "f878d2d730f36a4c6dfe1fff1b4ccc704757ea22d8c4c8ccb95b11cd641e5fed", + "cacheKey": "b326ee0fce5b1d3f5ed8c0d865a6f351d162b3848bec1084baef1de028418a82" }, { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "ee1e8ed6f49dc30a0bcf4b4ae1e11a3a4ecb28e688ad91f217446dba85b3369a" + "cacheKey": "9c9b20d1316df92a12cc0e0a5fb327b10278220cecd94a2adc3ed8ac9a29ecd5" }, { "packageName": "posix-utils-lite", "manifestSha256": "8fd7190b2848ef80143adc7b9268c79e13cd4db3430f7105faf49a4b4407a06f", - "cacheKey": "4dbc5c888a29eb71039e433f5a2d74733f9cb79caf9cfba23df997e1d0544f8b" + "cacheKey": "971093bac83c0c75229da3992210c599002943dd6b21d1e98ac332a22379b221" }, { "packageName": "sed", - "manifestSha256": "0c0d82d53907c19825941501f833252cf9adf35ebcebf6786baae28e5eb6958b", - "cacheKey": "1e298d0a946f3c3c0c8f13f6d2b11a029258625fe2f37fc33472bb4646c187f4" + "manifestSha256": "2a08e9c5dacc5facc8983c1ff35ff2a72db328405e9a1f464cc7ad155c4d08af", + "cacheKey": "23b9bbdb70b972dd95e51ec76d4e96b102a872e4424ec192d38fe1153279bbec" } ] }, @@ -2579,14 +2579,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "51c9a0cd4b5ea534e2cd2a7aaa944f0f91aced8c7b0db45ab37e44c56cb09c6a" + "wasm32": "3e8b426cf871a3ba7e040d2ca161466440a4f2b4ad961cf8d35ac62e974302b6" }, "dependencyClosures": { "wasm32": [ { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "45f5d1d8385fcb2f5c568b57ea2840808423502205bef8b300b3ce37d7077ca7" + "cacheKey": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc" } ] }, @@ -2613,7 +2613,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "1e298d0a946f3c3c0c8f13f6d2b11a029258625fe2f37fc33472bb4646c187f4" + "wasm32": "23b9bbdb70b972dd95e51ec76d4e96b102a872e4424ec192d38fe1153279bbec" }, "dependencyClosures": { "wasm32": [] @@ -2634,7 +2634,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ef40010ab7393e1309ff9fa2ca1fae82aac500b3d3be8dbfa9301119d8c90d58" + "wasm32": "735c2c0ecb8edde3797fb59fd6e38f5f8c86a81fced93e3f24e87db5aafcc02b" }, "dependencyClosures": { "wasm32": [] @@ -2655,24 +2655,24 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b44e8ffaa8e9fdf7b51cb5ae5830070fa1b5959441da550d80a3d0aefd2504d2" + "wasm32": "703273c0bfc90bf5db63384125331fd60b5ea8dd8910ffd49be251503bce2d79" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8f435f7638a234c3327375ba1055d0c1e33372c8c4375169043d9fff4fd03ed2" + "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "cc22419dce43a129c42182ebb85e7b27b96ff2c4eb390ea9babcc3a352678ac7" + "cacheKey": "7b7c878fcf8eadc0689030d9a8f4e12a15e34933c2aef7192136be4d51e42db2" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "45f5d1d8385fcb2f5c568b57ea2840808423502205bef8b300b3ce37d7077ca7" + "cacheKey": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc" } ] }, @@ -2692,29 +2692,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0d2b9fa365a73127f7cd4f6c714e3382fe3808fdc1163d8dde42d2c68da35244" + "wasm32": "68906120c4f5bd6aa5212078f54eb64c038148c34ccdad65eae0b8c97906d397" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8f435f7638a234c3327375ba1055d0c1e33372c8c4375169043d9fff4fd03ed2" + "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "cc22419dce43a129c42182ebb85e7b27b96ff2c4eb390ea9babcc3a352678ac7" + "cacheKey": "7b7c878fcf8eadc0689030d9a8f4e12a15e34933c2aef7192136be4d51e42db2" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "b44e8ffaa8e9fdf7b51cb5ae5830070fa1b5959441da550d80a3d0aefd2504d2" + "cacheKey": "703273c0bfc90bf5db63384125331fd60b5ea8dd8910ffd49be251503bce2d79" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "45f5d1d8385fcb2f5c568b57ea2840808423502205bef8b300b3ce37d7077ca7" + "cacheKey": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc" } ] }, @@ -2734,7 +2734,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "daa008c55b87322dcc1af8534695748b1394e7e336c97c967475ff9b029218d9" + "wasm32": "2496e0db5e6a54dffbc592517d1e185c5ea13b2f6642ee5dda39837d6af5c750" }, "dependencyClosures": { "wasm32": [] @@ -2755,7 +2755,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "d1fb92dfbd6b5939d070fff6393adf82adf2997175bbb704e098bdca30c0fca5" + "wasm32": "ce558d30e4aa291b0b83c51e095925c2e3a680b6a2bb3064bd681b616dd92271" }, "dependencyClosures": { "wasm32": [] @@ -2776,7 +2776,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "571475afa0828fc7620072b8da5e97f04e963d230e36841a4c22c9c360f49e36" + "wasm32": "ecea22d76a1820ffcd5505b296e17beb353389ba0149fc26c8a5dd9ff17b7dfa" }, "dependencyClosures": { "wasm32": [] @@ -2797,19 +2797,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "3796d63b69f62b4abd66fd6503e03db263c971a5e6d2c030a9984229090c634f" + "wasm32": "75b47041b034c633195921d54ef957186261e02e40fc3ca22c48f2a8de91b88e" }, "dependencyClosures": { "wasm32": [ { "packageName": "libpng", "manifestSha256": "79ed5c5c072a0267cce5c7a2fe37d8494571b17e44b952f619e04ec1c4a9db10", - "cacheKey": "f161992c0090455792d881778b9b7f39f05b54ccf40881c949597be4b4a91638" + "cacheKey": "6415b2ef14cf038d99c24521c5f7272ce81e929cff5e94ffeb3031b5c9140195" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "45f5d1d8385fcb2f5c568b57ea2840808423502205bef8b300b3ce37d7077ca7" + "cacheKey": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc" } ] }, @@ -2836,7 +2836,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "57245e347ef400ee85c6170feda810cb58e81cf2129d0e1b66695be78de89a06" + "wasm32": "c2b4f93663a414b7ff31235fa6e9cade4955e6fcf39a027a3da1bea53f93a375" }, "dependencyClosures": { "wasm32": [] @@ -2857,7 +2857,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "93b8c5035eb78d0e15e6b35208a9be2d5fff048ab3f6a3e763d9866bece72037" + "wasm32": "7312cc6859af08e6f5d3676cc2edc6476bd198a76ceb81ba5cb19ac01cdb8026" }, "dependencyClosures": { "wasm32": [] @@ -2878,14 +2878,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ffa56f2bd9e2a3eb7e7a5601d7d3b69f95a50f9252d7fa1007b3964897f9b544" + "wasm32": "6659da0384bbb9555a57c0036bded1f84e335e73993d80afe10645002fb6d47a" }, "dependencyClosures": { "wasm32": [ { "packageName": "vim", "manifestSha256": "c211660ef41d01f95f3fbdf675b74891e897e3f304e04f1bf5586f326007382c", - "cacheKey": "93b8c5035eb78d0e15e6b35208a9be2d5fff048ab3f6a3e763d9866bece72037" + "cacheKey": "7312cc6859af08e6f5d3676cc2edc6476bd198a76ceb81ba5cb19ac01cdb8026" } ] }, @@ -2905,7 +2905,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "8f7ed7d5134490162de1e3a45cbac11544f700a704bf414902b8b234439d1fa4" + "wasm32": "ae40de1e39e8cfac1fa45208a3c703f287b45aec4c779bdf4b326272675e3d2d" }, "dependencyClosures": { "wasm32": [] @@ -2926,79 +2926,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e868273fd45c5672987c6c5f6ae87ef6e912eb46efab67067f27b718c2c6378b" + "wasm32": "3aa00b98f514b8dcb37d7f0fc573be579181e2974f0f14d8ce7b4858dfa34ffe" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "0ce5e4f73eee78b62a9dd8985de1bdaa2a88f2e557b5d02664874f8c09f66c4f" + "cacheKey": "3f86ef60f36863f0437258c43d10333a65c9412c8899688242fb16207361fa6b" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "52d441be75809dc07ceaee197d8d477a99aa3e29e2f7f149d9bfe795aa684630" + "cacheKey": "d129add0d6c230b17232aef83127a269c5c1c11349d9331d06fcc6889b56f5d0" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "ca63d1caa19b66e88726af7c0d5944a17621603fefdc8e84af46945b10a560e2" + "cacheKey": "2c986bb0488a9ff05b1ef2ec3c0c44be940504ff9b7f33a4e235f981dc016e17" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8f435f7638a234c3327375ba1055d0c1e33372c8c4375169043d9fff4fd03ed2" + "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "6b805ee5c087b70ff6d4068b247626733daaa5551a45fcc9510373455a3d4d33" + "cacheKey": "23113207cb8ec3b213b054284770c9ad92d738b225044d01563f1bc3a677622c" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "7e0bdce4d06f6d95bd25c1378843945c27c50907cf4b2767bba785356e858d94" + "cacheKey": "70356b9298ebe5b6d58e7d08093d71b1137c2ff99617beb3d8134f3bc919b088" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "ca39fcc365bc853e1e839a5aebc5e52c16b3783caa966a5c42de29d5b5fa36b5" + "cacheKey": "4aab2056a613043f30ebf493e466227b595740de70ba210e80919a6d3c1069f5" }, { "packageName": "msmtpd", - "manifestSha256": "686ff665b5e7907e67618790b1a3eddce2ff47ed665595d17209bdcda1e0b274", - "cacheKey": "880dbcb9760918e21e6a4d936655de2a01b94f10fa495a87af467b5bb474c3f7" + "manifestSha256": "09de04a422ddf631a29a259d816e081f8d317d1077808ede55d8c500baae748f", + "cacheKey": "ea09f8a7c8a43ddeed1fef59c3a9a7b987e0bda7be881d4d1ae4019d73a7ef30" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "e3a71f5b5d417de5430375ca766f22bc78a46a92c90df8c6fcfdb9fcb6d0051c" + "cacheKey": "cd58a900319c46bbc268ac664900527414e9c6ffa40683b3be6bcd97d4d47283" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "cc22419dce43a129c42182ebb85e7b27b96ff2c4eb390ea9babcc3a352678ac7" + "cacheKey": "7b7c878fcf8eadc0689030d9a8f4e12a15e34933c2aef7192136be4d51e42db2" }, { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "c6e278891e21d9265b0a91fe8deca055e492afb0e02e4d69b13dea28240055c4" + "cacheKey": "cba27c5df3f7ce7a296d1e672d5ce6671052bcb19e2893b73f926cf7e498d6ae" }, { "packageName": "shell", - "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", - "cacheKey": "ef40010ab7393e1309ff9fa2ca1fae82aac500b3d3be8dbfa9301119d8c90d58" + "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", + "cacheKey": "735c2c0ecb8edde3797fb59fd6e38f5f8c86a81fced93e3f24e87db5aafcc02b" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "acdf72794b5e77a94280af5edae99a9ccc92e5093d42332881c66e23571c20b1" + "cacheKey": "63f8f0bc003c78665088d6ebdfbd495325454537fba1e6a9df9c1c8a9857cf1b" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "45f5d1d8385fcb2f5c568b57ea2840808423502205bef8b300b3ce37d7077ca7" + "cacheKey": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc" } ] }, @@ -3018,7 +3018,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "9c30dbd3ebd950e6a014bfbfc6d90f765fb86bd38a34a3ca7f2e4139b258ffce" + "wasm32": "8fc65e59b93272069a522a4cf231aa0ade69f6eafe5a28b1000930f7fa0a1c33" }, "dependencyClosures": { "wasm32": [] @@ -3039,7 +3039,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "cc287ee8a27f944c5c3196b47647d2df3073b6c94b4bc93ef061fee8ec1f5ba2" + "wasm32": "bb1192b88018259570833ea86709ab549c82a2417ba284f6749a08e194c4d95d" }, "dependencyClosures": { "wasm32": [] @@ -3060,7 +3060,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "7efd9e2f41b270a7e26284bdcd73d1abf6e7122077dd8f835e10dcc5b727b4bf" + "wasm32": "2f8312b61940bc336be5489d90f21fdf59dc314a956e1fdf903017159312e7cd" }, "dependencyClosures": { "wasm32": [] diff --git a/scripts/build-programs.sh b/scripts/build-programs.sh index bc71100709..a0585ff545 100755 --- a/scripts/build-programs.sh +++ b/scripts/build-programs.sh @@ -234,28 +234,6 @@ build_program() { rm -f "$raw_wasm" } -cpp_requires_activation_state_rejection() { - case "$1" in - c_01_fork_in_try_no_throw|\ - c_02_fork_in_catch|\ - c_03_fork_in_multi_arm_catch|\ - c_04_fork_in_catch_external_throw|\ - c_05_fork_modern_eh_single|\ - c_06_fork_modern_eh_multi_ref|\ - c_07_fork_modern_eh_multi_plain|\ - c_10_fork_in_try_and_catch|\ - c_11_post_catch_fork|\ - k_06_fork_from_dtor|\ - s_08_external_throw_fork_in_catch|\ - sjlj_noexcept_boundary) - return 0 - ;; - *) - return 1 - ;; - esac -} - # Build a C++ program via the SDK's wasm32posix-c++ wrapper. The SDK # injects the toolchain's standard compile + link flags, the channel # syscall glue, the C++ runtime stubs (cxxrt.c), and the sysroot path. @@ -270,14 +248,6 @@ build_cpp_program() { local wasm="$out_dir/${name}.wasm" local raw_wasm="$out_dir/${name}.raw.wasm" local next_wasm="$out_dir/${name}.next.wasm" - local rejection_expected=false - - if cpp_requires_activation_state_rejection "$name"; then - rejection_expected=true - mkdir -p "$TEST_FIXTURE_DIR/wasm32/unsupported-abi43" - raw_wasm="$TEST_FIXTURE_DIR/wasm32/unsupported-abi43/${name}.raw.wasm" - next_wasm="$TEST_FIXTURE_DIR/wasm32/unsupported-abi43/${name}.unexpected.wasm" - fi echo " Compiling $name (C++)..." rm -f "$wasm" "$raw_wasm" "$next_wasm" @@ -293,14 +263,10 @@ build_cpp_program() { -lc++ -lc++abi \ -o "$raw_wasm" - # Preserve a launchable no-fork control for issue #918. The fork-bearing - # compiler output is retained under unsupported-abi43 and must fail the - # instrumenter rather than enter the resolver's programs tree. + # Preserve a raw no-fork control for issue #918 independently of the + # normally instrumented fork-bearing program. if [ "$name" = "sjlj_noexcept_boundary" ]; then mkdir -p "$TEST_FIXTURE_DIR/wasm32" - # Keep the unrelated SjLj/noexcept control launchable under ABI 43 by - # omitting its dormant fork anchor. The fork-bearing compiler output is - # retained separately above as explicit unsupported-artifact evidence. wasm32posix-c++ \ -O2 \ -fwasm-exceptions \ @@ -310,25 +276,6 @@ build_cpp_program() { -o "$TEST_FIXTURE_DIR/wasm32/${name}.raw.wasm" fi - if [ "$rejection_expected" = true ]; then - local diagnostic="$raw_wasm.instrument-error.txt" - rm -f "$diagnostic" - if "$FORK_INSTRUMENT" "$raw_wasm" -o "$next_wasm" 2>"$diagnostic"; then - echo "Error: $name unexpectedly became activation-state safe; update its ABI 43 coverage before publishing it." >&2 - rm -f "$next_wasm" - exit 1 - fi - if ! grep -Eq \ - 'reference local/parameter|uses CatchAll|uses CatchAllRef|reference-typed catch payload' \ - "$diagnostic"; then - echo "Error: $name failed instrumentation for an unexpected reason:" >&2 - cat "$diagnostic" >&2 - exit 1 - fi - echo " Expected ABI 43 rejection: $name (see $diagnostic)" - return 0 - fi - # Publish the resolver-visible path only after instrumentation and its # complete ABI 43 artifact contract succeed. "$FORK_INSTRUMENT" "$raw_wasm" -o "$next_wasm" diff --git a/scripts/check-abi-version.sh b/scripts/check-abi-version.sh index 373b785a4f..4c3ed1e9e8 100755 --- a/scripts/check-abi-version.sh +++ b/scripts/check-abi-version.sh @@ -87,8 +87,11 @@ version_bumped=0 snapshot_changed=0 if git rev-parse --verify --quiet "$base_ref" >/dev/null ; then if ! git diff --quiet "$base_ref" -- crates/shared/src/lib.rs 2>/dev/null ; then + # Do not use `grep -q` here: with pipefail, an early match can close + # the pipe while a large ABI diff is still being written, turning + # git's SIGPIPE into a false "version was not bumped" result. if git diff "$base_ref" -- crates/shared/src/lib.rs \ - | grep -qE '^\+pub const ABI_VERSION: u32 = ' ; then + | grep -E '^\+pub const ABI_VERSION: u32 = ' >/dev/null ; then version_bumped=1 fi fi diff --git a/scripts/check-dev-shell-tools.sh b/scripts/check-dev-shell-tools.sh index beeb3fe3b4..a3b2bfd9db 100755 --- a/scripts/check-dev-shell-tools.sh +++ b/scripts/check-dev-shell-tools.sh @@ -37,3 +37,14 @@ for tool in cmake make; do esac "$tool" --version >/dev/null done + +for tool_path in "${AR:-}" "${RANLIB:-}"; do + case "$tool_path" in + "$nix_store"/*/bin/llvm-ar | "$nix_store"/*/bin/llvm-ranlib) ;; + *) + echo "ERROR: archive tool resolved outside the declared LLVM tool set: ${tool_path:-}" >&2 + exit 1 + ;; + esac + "$tool_path" --version >/dev/null +done diff --git a/scripts/ci-run-test-suite.sh b/scripts/ci-run-test-suite.sh index 74dbe31d6c..f4f5eea724 100755 --- a/scripts/ci-run-test-suite.sh +++ b/scripts/ci-run-test-suite.sh @@ -18,14 +18,6 @@ host_target() { rustc -vV | awk '/^host/ {print $2}' } -# Prepared CI workspaces transport fetched programs as relative links into a -# repo-local copy of the exact content-addressed cache generations. Activate -# their shared cache/checker identity before any suite can read `binaries/`. -# Direct post-suite consumers use the same helper so the binding cannot be -# lost merely because GitHub starts a new workflow step. -source "$REPO_ROOT/scripts/activate-ci-test-workspace.sh" -activate_ci_test_workspace - suite="${1:-}" if [ -z "$suite" ]; then echo "usage: $0 [group]" >&2 @@ -33,6 +25,33 @@ if [ -z "$suite" ]; then fi group="${2:-${TEST_GROUP:-all}}" +# Prepared CI workspaces transport fetched programs as relative links into a +# repo-local copy of the exact content-addressed cache generations. Point both +# the Rust and TypeScript resolvers at that identity before any suite can read +# `binaries/`; otherwise the copied cache would look like an unrelated tier. +portable_cache="$REPO_ROOT/.ci-test-binary-cache" +if [ -d "$portable_cache/programs" ]; then + export WASM_POSIX_BINARY_CACHE_ROOT="$portable_cache" +fi + +# WHY: every conformance case starts a fresh Node resolver under a short +# timeout. Prepare one exact worktree-local checker before parallel cases +# begin; each process still executes the source-freshness check, but none +# starts a competing Cargo build or waits on Cargo's target-directory lock. +case "$suite" in + vitest|browser|libc|posix|sortix) + prepared_xtask="$REPO_ROOT/target/$(host_target)/release/xtask" + if [ ! -d "$portable_cache/programs" ]; then + cargo build --release -p xtask --target "$(host_target)" --quiet + fi + if [ ! -f "$prepared_xtask" ] || [ ! -x "$prepared_xtask" ]; then + echo "ci-run-test-suite: missing executable prepared package checker: $prepared_xtask" >&2 + exit 1 + fi + export WASM_POSIX_XTASK_BIN="$prepared_xtask" + ;; +esac + invalid_group() { echo "unknown $suite test group: $group" >&2 exit 2 @@ -526,6 +545,7 @@ case "$suite" in test/coi.spec.ts \ test/package-deferred-tree-browser.spec.ts \ test/vfs-import-seal-boundary.spec.ts \ + test/wasm-gc-reference-transport.spec.ts \ test/wasm-trap-signal.spec.ts \ --project=chromium --project=firefox --project=webkit ) diff --git a/scripts/homebrew-validate-wasm-artifact.sh b/scripts/homebrew-validate-wasm-artifact.sh index e994b3b39e..f315d51bd6 100755 --- a/scripts/homebrew-validate-wasm-artifact.sh +++ b/scripts/homebrew-validate-wasm-artifact.sh @@ -39,25 +39,107 @@ fi wasm_require_no_legacy_asyncify "$wasm_path" -relocatable_status=0 -wasm_is_relocatable_object "$wasm_path" || relocatable_status=$? -case "$relocatable_status" in - 0) +artifact_identity_row="" +identity_status=0 +used_artifact_identity=0 +artifact_imports_kernel_fork=0 +artifact_imports_side_fork=0 +artifact_has_fork_exports=0 +artifact_identity_row="$(wasm_artifact_identity "$wasm_path")" || identity_status=$? +if [ "$identity_status" -eq 0 ]; then + # WHY: ABI 43 helpers use Wasm reference and exception proposals that + # older WABT releases cannot disassemble. One bounded wasmparser request + # owns executable and side-module classification, memory width, import + # boundaries, and the exact constant ABI export on every release host. + artifact_relocatable="" + artifact_memory_count="" + artifact_memory64_count="" + artifact_abi_state="" + artifact_abi="" + artifact_imports_kernel_fork="" + artifact_imports_side_fork="" + artifact_has_fork_exports="" + artifact_dylink_count="" + artifact_dylink_first="" + artifact_env_memory_count="" + artifact_unsupported_side_import_count="" + extra="" + IFS=$'\t' read -r artifact_relocatable artifact_memory_count \ + artifact_memory64_count artifact_abi_state artifact_abi \ + artifact_imports_kernel_fork artifact_imports_side_fork \ + artifact_has_fork_exports artifact_dylink_count \ + artifact_dylink_first artifact_env_memory_count \ + artifact_unsupported_side_import_count extra \ + <<<"$artifact_identity_row" + if [ -n "$extra" ] || + [[ ! "$artifact_relocatable" =~ ^[01]$ ]] || + [[ ! "$artifact_memory_count" =~ ^[0-9]+$ ]] || + [[ ! "$artifact_memory64_count" =~ ^[0-9]+$ ]] || + [[ ! "$artifact_imports_kernel_fork" =~ ^[0-9]+$ ]] || + [[ ! "$artifact_imports_side_fork" =~ ^[0-9]+$ ]] || + [[ ! "$artifact_has_fork_exports" =~ ^[01]$ ]] || + [[ ! "$artifact_dylink_count" =~ ^[0-9]+$ ]] || + [[ ! "$artifact_dylink_first" =~ ^[01]$ ]] || + [[ ! "$artifact_env_memory_count" =~ ^[0-9]+$ ]] || + [[ ! "$artifact_unsupported_side_import_count" =~ ^[0-9]+$ ]]; then + echo "homebrew-validate-wasm-artifact.sh: cannot inspect Wasm object kind: $wasm_path" >&2 + exit 1 + fi + case "$artifact_abi_state" in + present) + [[ "$artifact_abi" =~ ^[0-9]+$ ]] || { + echo "homebrew-validate-wasm-artifact.sh: cannot validate __abi_version: $wasm_path" >&2 + exit 1 + } + ;; + missing|invalid) + [ "$artifact_abi" = - ] || { + echo "homebrew-validate-wasm-artifact.sh: cannot validate __abi_version: $wasm_path" >&2 + exit 1 + } + ;; + *) + echo "homebrew-validate-wasm-artifact.sh: cannot validate __abi_version: $wasm_path" >&2 + exit 1 + ;; + esac + used_artifact_identity=1 + if [ "$artifact_relocatable" = 1 ]; then echo "homebrew-validate-wasm-artifact.sh: artifact is a relocatable Wasm object: $wasm_path" >&2 exit 1 - ;; - 1) ;; - *) - echo "homebrew-validate-wasm-artifact.sh: cannot inspect Wasm object kind: $wasm_path" >&2 + fi + if [ "$artifact_dylink_count" = 0 ] && [ "$artifact_dylink_first" = 0 ]; then + artifact_role=executable + elif [ "$artifact_dylink_count" = 1 ] && [ "$artifact_dylink_first" = 1 ]; then + artifact_role=side-module + else + echo "homebrew-validate-wasm-artifact.sh: malformed or misplaced dylink.0 artifact role: $wasm_path" >&2 exit 1 - ;; -esac + fi +elif [ "$identity_status" -eq 127 ]; then + relocatable_status=0 + wasm_is_relocatable_object "$wasm_path" || relocatable_status=$? + case "$relocatable_status" in + 0) + echo "homebrew-validate-wasm-artifact.sh: artifact is a relocatable Wasm object: $wasm_path" >&2 + exit 1 + ;; + 1) ;; + *) + echo "homebrew-validate-wasm-artifact.sh: cannot inspect Wasm object kind: $wasm_path" >&2 + exit 1 + ;; + esac -artifact_role="" -role_status=0 -artifact_role="$(wasm_artifact_role "$wasm_path")" || role_status=$? -if [ "$role_status" -ne 0 ]; then - echo "homebrew-validate-wasm-artifact.sh: malformed or misplaced dylink.0 artifact role: $wasm_path" >&2 + artifact_role="" + role_status=0 + artifact_role="$(wasm_artifact_role "$wasm_path")" || role_status=$? + if [ "$role_status" -ne 0 ]; then + echo "homebrew-validate-wasm-artifact.sh: malformed or misplaced dylink.0 artifact role: $wasm_path" >&2 + exit 1 + fi +else + echo "homebrew-validate-wasm-artifact.sh: cannot inspect Wasm object kind: $wasm_path" >&2 exit 1 fi @@ -77,7 +159,26 @@ fi artifact_arch="" arch_status=0 -if [ "$artifact_role" = "side-module" ]; then +if [ "$used_artifact_identity" -eq 1 ]; then + if [ "$artifact_role" = "side-module" ]; then + if [ "$artifact_memory_count" != 1 ] || + { [ "$artifact_memory64_count" != 0 ] && [ "$artifact_memory64_count" != 1 ]; } || + [ "$artifact_env_memory_count" != 1 ] || + [ "$artifact_unsupported_side_import_count" != 0 ]; then + echo "homebrew-validate-wasm-artifact.sh: side module has an unsupported memory or import contract: $wasm_path" >&2 + exit 1 + fi + elif [ "$artifact_memory_count" != 1 ] || + { [ "$artifact_memory64_count" != 0 ] && [ "$artifact_memory64_count" != 1 ]; }; then + echo "homebrew-validate-wasm-artifact.sh: executable must define or import exactly one inspectable memory: $wasm_path" >&2 + exit 1 + fi + if [ "$artifact_memory64_count" = 1 ]; then + artifact_arch=wasm64 + else + artifact_arch=wasm32 + fi +elif [ "$artifact_role" = "side-module" ]; then artifact_arch="$(wasm_validate_side_module_imports "$wasm_path")" || arch_status=$? if [ "$arch_status" -ne 0 ]; then echo "homebrew-validate-wasm-artifact.sh: side module has an unsupported memory or import contract: $wasm_path" >&2 @@ -96,61 +197,87 @@ if [ "$artifact_arch" != "$expected_arch" ]; then fi if [ "$artifact_role" = "executable" ]; then - if wasm_imports_side_module_fork "$wasm_path"; then + if { [ "$used_artifact_identity" -eq 1 ] && [ "$artifact_imports_side_fork" != 0 ]; } || + { [ "$used_artifact_identity" -eq 0 ] && wasm_imports_side_module_fork "$wasm_path"; }; then echo "homebrew-validate-wasm-artifact.sh: executable imports side-module-only env.fork: $wasm_path" >&2 exit 1 fi - artifact_abi="" - abi_status=0 - artifact_abi="$(wasm_extract_abi_version "$wasm_path")" || abi_status=$? - case "$abi_status" in - 0) ;; - 1) - echo "homebrew-validate-wasm-artifact.sh: executable lacks __abi_version: $wasm_path" >&2 - exit 1 - ;; - *) - echo "homebrew-validate-wasm-artifact.sh: cannot validate __abi_version: $wasm_path" >&2 - exit 1 - ;; - esac + if [ "$used_artifact_identity" -eq 1 ]; then + case "$artifact_abi_state" in + present) ;; + missing) + echo "homebrew-validate-wasm-artifact.sh: executable lacks __abi_version: $wasm_path" >&2 + exit 1 + ;; + *) + echo "homebrew-validate-wasm-artifact.sh: cannot validate __abi_version: $wasm_path" >&2 + exit 1 + ;; + esac + else + artifact_abi="" + abi_status=0 + artifact_abi="$(wasm_extract_abi_version "$wasm_path")" || abi_status=$? + case "$abi_status" in + 0) ;; + 1) + echo "homebrew-validate-wasm-artifact.sh: executable lacks __abi_version: $wasm_path" >&2 + exit 1 + ;; + *) + echo "homebrew-validate-wasm-artifact.sh: cannot validate __abi_version: $wasm_path" >&2 + exit 1 + ;; + esac + fi if [ "$artifact_abi" != "$expected_abi" ]; then echo "homebrew-validate-wasm-artifact.sh: executable ABI $artifact_abi does not match expected ABI $expected_abi: $wasm_path" >&2 exit 1 fi -elif wasm_imports_kernel_fork "$wasm_path"; then - echo "homebrew-validate-wasm-artifact.sh: side module imports executable-only kernel.kernel_fork: $wasm_path" >&2 - exit 1 +else + if { [ "$used_artifact_identity" -eq 1 ] && [ "$artifact_imports_kernel_fork" != 0 ]; } || + { [ "$used_artifact_identity" -eq 0 ] && wasm_imports_kernel_fork "$wasm_path"; }; then + echo "homebrew-validate-wasm-artifact.sh: side module imports executable-only kernel.kernel_fork: $wasm_path" >&2 + exit 1 + fi fi wasm_require_fork_instrumentation_if_needed "$wasm_path" fork_required=0 -predicate_status=0 -if [ "$artifact_role" = "side-module" ]; then - wasm_imports_side_module_fork "$wasm_path" || predicate_status=$? +if [ "$used_artifact_identity" -eq 1 ]; then + if { [ "$artifact_role" = "side-module" ] && [ "$artifact_imports_side_fork" != 0 ]; } || + { [ "$artifact_role" = "executable" ] && [ "$artifact_imports_kernel_fork" != 0 ]; } || + [ "$artifact_has_fork_exports" = 1 ]; then + fork_required=1 + fi else - wasm_imports_kernel_fork "$wasm_path" || predicate_status=$? + predicate_status=0 + if [ "$artifact_role" = "side-module" ]; then + wasm_imports_side_module_fork "$wasm_path" || predicate_status=$? + else + wasm_imports_kernel_fork "$wasm_path" || predicate_status=$? + fi + case "$predicate_status" in + 0) fork_required=1 ;; + 1) ;; + *) + echo "homebrew-validate-wasm-artifact.sh: cannot inspect $artifact_role fork import: $wasm_path" >&2 + exit 1 + ;; + esac + predicate_status=0 + wasm_has_any_wpk_fork_export "$wasm_path" || predicate_status=$? + case "$predicate_status" in + 0) fork_required=1 ;; + 1) ;; + *) + echo "homebrew-validate-wasm-artifact.sh: cannot inspect fork exports: $wasm_path" >&2 + exit 1 + ;; + esac fi -case "$predicate_status" in - 0) fork_required=1 ;; - 1) ;; - *) - echo "homebrew-validate-wasm-artifact.sh: cannot inspect $artifact_role fork import: $wasm_path" >&2 - exit 1 - ;; -esac -predicate_status=0 -wasm_has_any_wpk_fork_export "$wasm_path" || predicate_status=$? -case "$predicate_status" in - 0) fork_required=1 ;; - 1) ;; - *) - echo "homebrew-validate-wasm-artifact.sh: cannot inspect fork exports: $wasm_path" >&2 - exit 1 - ;; -esac if [ "$fork_required" -eq 1 ]; then printf 'required\n' diff --git a/scripts/test-wasm-artifact-guards.sh b/scripts/test-wasm-artifact-guards.sh index 146ff6543a..f4e5562ed7 100755 --- a/scripts/test-wasm-artifact-guards.sh +++ b/scripts/test-wasm-artifact-guards.sh @@ -23,6 +23,7 @@ wat2wasm --debug-names "$work/abi.wat" -o "$work/abi.wasm" real_objdump="$(command -v wasm-objdump)" mkdir "$work/bin" +missing_structural_tool="$work/bin/missing-wasm-fork-instrument" cat >"$work/bin/wasm-objdump" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = "-d" ] && [ "${2:-}" = "${FAIL_WASM_OBJDUMP_PATH:-}" ]; then @@ -43,8 +44,9 @@ assert_extracts_abi() { exit 1 } actual="$( - PATH="$work/bin:$PATH" REAL_WASM_OBJDUMP="$real_objdump" FAIL_WASM_OBJDUMP_PATH="$path" \ - wasm_extract_abi_version "$path" + WASM_POSIX_FORK_INSTRUMENT="$missing_structural_tool" \ + PATH="$work/bin:$PATH" REAL_WASM_OBJDUMP="$real_objdump" \ + FAIL_WASM_OBJDUMP_PATH="$path" wasm_extract_abi_version "$path" )" [ "$actual" = 18 ] || { echo "ERROR: Binaryen ABI extraction returned $actual for $description" >&2 @@ -60,7 +62,9 @@ assert_rejects_abi() { echo "ERROR: primary ABI extraction accepted $description" >&2 exit 1 fi - if PATH="$work/bin:$PATH" REAL_WASM_OBJDUMP="$real_objdump" FAIL_WASM_OBJDUMP_PATH="$path" \ + if WASM_POSIX_FORK_INSTRUMENT="$missing_structural_tool" \ + PATH="$work/bin:$PATH" REAL_WASM_OBJDUMP="$real_objdump" \ + FAIL_WASM_OBJDUMP_PATH="$path" \ wasm_extract_abi_version "$path" >/dev/null 2>&1; then echo "ERROR: Binaryen ABI extraction accepted $description" >&2 exit 1 @@ -83,19 +87,107 @@ assert_classifies_unsafe_abi() { fi extract_status=0 - PATH="$work/bin:$PATH" REAL_WASM_OBJDUMP="$real_objdump" FAIL_WASM_OBJDUMP_PATH="$path" \ + WASM_POSIX_FORK_INSTRUMENT="$missing_structural_tool" \ + PATH="$work/bin:$PATH" REAL_WASM_OBJDUMP="$real_objdump" \ + FAIL_WASM_OBJDUMP_PATH="$path" \ wasm_extract_abi_version "$path" >/dev/null 2>&1 || extract_status=$? [ "$extract_status" -gt 1 ] || { echo "ERROR: fallback ABI extraction classified $description as absent (status $extract_status)" >&2 exit 1 } - if ! PATH="$work/bin:$PATH" REAL_WASM_OBJDUMP="$real_objdump" FAIL_WASM_OBJDUMP_PATH="$path" \ - wasm_has_stale_abi "$path" 18; then + if ! WASM_POSIX_FORK_INSTRUMENT="$missing_structural_tool" \ + PATH="$work/bin:$PATH" REAL_WASM_OBJDUMP="$real_objdump" \ + FAIL_WASM_OBJDUMP_PATH="$path" wasm_has_stale_abi "$path" 18; then echo "ERROR: stale-ABI predicate accepted $description after the primary decoder failed" >&2 exit 1 fi } +mkdir "$work/no-objdump-bin" +cat >"$work/no-objdump-bin/wasm-objdump" <<'SH' +#!/usr/bin/env bash +exit 99 +SH +chmod +x "$work/no-objdump-bin/wasm-objdump" + +cat >"$work/bin/structural-identity-tool" <<'SH' +#!/usr/bin/env bash +case "${1:-}" in + --artifact-identity) + [ "$#" -eq 2 ] || exit 64 + if [ -n "${MOCK_IDENTITY_RECORD:-}" ]; then + printf '%s\n' "$MOCK_IDENTITY_RECORD" + exit 0 + fi + state="${MOCK_ABI_STATE:-present}" + case "$state" in + present) version="${MOCK_ABI_VERSION:-18}" ;; + missing|invalid) version=- ;; + *) exit 65 ;; + esac + printf '0\t1\t0\t%s\t%s\t%s\t0\n' \ + "$state" "$version" "${MOCK_IMPORTS_FORK:-1}" + ;; + --reserved-env-imports) + [ "$#" -eq 2 ] || exit 64 + [ "${MOCK_RESERVED_IMPORT_STATUS:-0}" -eq 0 ] || \ + exit "$MOCK_RESERVED_IMPORT_STATUS" + [ -z "${MOCK_RESERVED_IMPORTS:-}" ] || \ + printf '%s\n' "$MOCK_RESERVED_IMPORTS" + ;; + *) exit 64 ;; +esac +SH +chmod +x "$work/bin/structural-identity-tool" + +# The structural identity decoder owns these predicates when installed. A +# deliberately unusable WABT binary proves neither helper silently falls back +# to full-module text decoding for a large ABI 43 artifact. +structural_path="$work/bin/structural-identity-tool" +actual="$( + WASM_POSIX_FORK_INSTRUMENT="$structural_path" \ + PATH="$work/no-objdump-bin:$PATH" wasm_extract_abi_version "$work/abi.wasm" +)" +[ "$actual" = 18 ] || { + echo "ERROR: structural ABI extraction returned $actual" >&2 + exit 1 +} +if ! WASM_POSIX_FORK_INSTRUMENT="$structural_path" \ + PATH="$work/no-objdump-bin:$PATH" wasm_imports_kernel_fork "$work/abi.wasm"; then + echo "ERROR: structural identity lost the kernel_fork import" >&2 + exit 1 +fi +if WASM_POSIX_FORK_INSTRUMENT="$structural_path" MOCK_IMPORTS_FORK=0 \ + PATH="$work/no-objdump-bin:$PATH" wasm_imports_kernel_fork "$work/abi.wasm"; then + echo "ERROR: structural identity invented a kernel_fork import" >&2 + exit 1 +fi + +structural_status=0 +WASM_POSIX_FORK_INSTRUMENT="$structural_path" MOCK_ABI_STATE=missing \ + PATH="$work/no-objdump-bin:$PATH" \ + wasm_extract_abi_version "$work/abi.wasm" >/dev/null 2>&1 || structural_status=$? +[ "$structural_status" -eq 1 ] || { + echo "ERROR: structural identity returned $structural_status for a missing ABI export" >&2 + exit 1 +} +structural_status=0 +WASM_POSIX_FORK_INSTRUMENT="$structural_path" MOCK_ABI_STATE=invalid \ + PATH="$work/no-objdump-bin:$PATH" \ + wasm_extract_abi_version "$work/abi.wasm" >/dev/null 2>&1 || structural_status=$? +[ "$structural_status" -gt 1 ] || { + echo "ERROR: structural identity returned $structural_status for an invalid ABI export" >&2 + exit 1 +} +structural_status=0 +WASM_POSIX_FORK_INSTRUMENT="$structural_path" MOCK_IDENTITY_RECORD=malformed \ + PATH="$work/no-objdump-bin:$PATH" \ + wasm_extract_abi_version "$work/abi.wasm" >/dev/null 2>&1 || structural_status=$? +[ "$structural_status" -eq 2 ] || { + echo "ERROR: malformed structural identity returned $structural_status instead of 2" >&2 + exit 1 +} + assert_extracts_abi "$work/abi.wasm" "an implicit return" cat >"$work/folded-command-wrapper-abi.wat" <<'WAT' @@ -271,7 +363,8 @@ WAT wat2wasm "$work/unapproved-reserved-import.wat" \ -o "$work/unapproved-reserved-import.wasm" reserved_import_error="$work/unapproved-reserved-import.error" -if wasm_require_approved_reserved_env_imports \ +if WASM_POSIX_FORK_INSTRUMENT="$missing_structural_tool" \ + wasm_require_approved_reserved_env_imports \ "$work/unapproved-reserved-import.wasm" 2>"$reserved_import_error"; then echo "ERROR: unapproved reserved env import was accepted" >&2 exit 1 @@ -292,7 +385,8 @@ cat >"$work/approved-reserved-import.wat" <<'WAT' WAT wat2wasm "$work/approved-reserved-import.wat" \ -o "$work/approved-reserved-import.wasm" -wasm_require_approved_reserved_env_imports \ +WASM_POSIX_FORK_INSTRUMENT="$missing_structural_tool" \ + wasm_require_approved_reserved_env_imports \ "$work/approved-reserved-import.wasm" cat >"$work/nonreserved-import.wat" <<'WAT' @@ -301,7 +395,41 @@ cat >"$work/nonreserved-import.wat" <<'WAT' (func (export "_start"))) WAT wat2wasm "$work/nonreserved-import.wat" -o "$work/nonreserved-import.wasm" -wasm_require_approved_reserved_env_imports "$work/nonreserved-import.wasm" +WASM_POSIX_FORK_INSTRUMENT="$missing_structural_tool" \ + wasm_require_approved_reserved_env_imports "$work/nonreserved-import.wasm" + +# Modern ABI 43 modules use the wasmparser-backed structural decoder. An +# unusable WABT fallback proves this check does not reinterpret a decoder +# limitation as either approval or rejection. +if ! WASM_POSIX_FORK_INSTRUMENT="$structural_path" \ + MOCK_RESERVED_IMPORTS=$'func\tenv.__wasm_posix_vm_interrupt_after' \ + PATH="$work/no-objdump-bin:$PATH" \ + wasm_require_approved_reserved_env_imports "$work/approved-reserved-import.wasm"; then + echo "ERROR: structural reserved-import guard rejected its approved host API" >&2 + exit 1 +fi +structural_reserved_error="$work/structural-reserved-import.error" +if WASM_POSIX_FORK_INSTRUMENT="$structural_path" \ + MOCK_RESERVED_IMPORTS=$'func\tenv.__wasm_posix_after_fork_child' \ + PATH="$work/no-objdump-bin:$PATH" \ + wasm_require_approved_reserved_env_imports \ + "$work/unapproved-reserved-import.wasm" 2>"$structural_reserved_error"; then + echo "ERROR: structural reserved-import guard accepted a private libc helper" >&2 + exit 1 +fi +grep -Fqx ' env.__wasm_posix_after_fork_child' \ + "$structural_reserved_error" || { + echo "ERROR: structural reserved-import rejection lost its exact identity" >&2 + cat "$structural_reserved_error" >&2 + exit 1 +} +if WASM_POSIX_FORK_INSTRUMENT="$structural_path" \ + MOCK_RESERVED_IMPORT_STATUS=1 PATH="$work/no-objdump-bin:$PATH" \ + wasm_require_approved_reserved_env_imports \ + "$work/unapproved-reserved-import.wasm" >/dev/null 2>&1; then + echo "ERROR: reserved-import guard fell back after a structural decoder failure" >&2 + exit 1 +fi # The bottle inspector limits each validator child to 16 MiB of regular-file # output. Large programs such as Ruby legitimately produce more structural @@ -364,6 +492,11 @@ limit = 16 * 1024 * 1024 environment = os.environ.copy() environment["PATH"] = f"{inflated_bin}:{environment['PATH']}" environment["REAL_WASM_OBJDUMP"] = real_objdump +# Exercise the bounded source-only decoder under the file-size limit instead +# of letting the installed structural decoder make this fallback test vacuous. +environment["WASM_POSIX_FORK_INSTRUMENT"] = os.path.join( + os.path.dirname(inflated_bin), "missing-wasm-fork-instrument" +) def set_file_limit() -> None: @@ -459,6 +592,70 @@ if wasm_has_missing_fork_instrumentation "$work/complete-fork.wasm"; then fi wasm_require_fork_instrumentation_if_needed "$work/complete-fork.wasm" +awk ' + { print } + /\(import "kernel" "kernel_fork"/ { + print " (import \"env\" \"__wasm_dlopen\"" + print " (func (param i32 i32 i32 i32 i32) (result i32)))" + } +' "$work/complete-fork.wat" >"$work/legacy-loader-fork.wat" +wat2wasm --enable-annotations \ + "$work/legacy-loader-fork.wat" -o "$work/legacy-loader-fork.wasm" +if wasm_has_complete_fork_instrumentation "$work/legacy-loader-fork.wasm"; then + echo "ERROR: complete-fork predicate accepted the reentrant legacy loader import" >&2 + exit 1 +fi +if ! wasm_has_missing_fork_instrumentation "$work/legacy-loader-fork.wasm"; then + echo "ERROR: missing-fork predicate accepted the reentrant legacy loader import" >&2 + exit 1 +fi +if wasm_require_fork_instrumentation_if_needed \ + "$work/legacy-loader-fork.wasm" 2>"$work/legacy-loader-fork.error"; then + echo "ERROR: fork guard accepted the reentrant legacy loader import" >&2 + exit 1 +fi +grep -F ' loader: retains reentrant env.__wasm_dlopen' \ + "$work/legacy-loader-fork.error" >/dev/null || { + echo "ERROR: fork guard did not identify the legacy loader failure" >&2 + cat "$work/legacy-loader-fork.error" >&2 + exit 1 +} + +awk ' + /\(func \(export "_start"/ { + sub(/\(func /, "(func $native_start ") + } + { + line[NR] = $0 + } + END { + if (sub(/\)\)$/, ")", line[NR]) != 1) exit 2 + for (row = 1; row <= NR; row++) print line[row] + print " (start $native_start))" + } +' "$work/complete-fork.wat" >"$work/native-start-fork.wat" +wat2wasm --enable-annotations \ + "$work/native-start-fork.wat" -o "$work/native-start-fork.wasm" +if wasm_has_complete_fork_instrumentation "$work/native-start-fork.wasm"; then + echo "ERROR: complete-fork predicate accepted a retained native start section" >&2 + exit 1 +fi +if ! wasm_has_missing_fork_instrumentation "$work/native-start-fork.wasm"; then + echo "ERROR: missing-fork predicate accepted a retained native start section" >&2 + exit 1 +fi +if wasm_require_fork_instrumentation_if_needed \ + "$work/native-start-fork.wasm" 2>"$work/native-start-fork.error"; then + echo "ERROR: fork guard accepted a retained native start section" >&2 + exit 1 +fi +grep -F ' start: retains a native Wasm start section' \ + "$work/native-start-fork.error" >/dev/null || { + echo "ERROR: fork guard did not identify the native start failure" >&2 + cat "$work/native-start-fork.error" >&2 + exit 1 +} + assert_rejects_fork_capability() { local wat_path="$1" local description="$2" @@ -715,25 +912,89 @@ mkdir "$work/counting-bin" cat >"$work/counting-bin/wasm-objdump" <<'SH' #!/usr/bin/env bash printf '%s\n' "${1:-}" >> "$WASM_OBJDUMP_COUNT_FILE" +if [ "${FAIL_WASM_OBJDUMP_DETAILS:-0}" = 1 ] && [ "${1:-}" = "-x" ]; then + exit 1 +fi exec "$REAL_WASM_OBJDUMP" "$@" SH chmod +x "$work/counting-bin/wasm-objdump" + +real_inventory_tool="$REPO_ROOT/tools/bin/wasm-fork-instrument" +[ -x "$real_inventory_tool" ] || { + echo "ERROR: shell guard test requires the built wasm-fork-instrument tool" >&2 + exit 1 +} +cat >"$work/counting-bin/wasm-fork-instrument" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$WASM_FORK_INVENTORY_COUNT_FILE" +exec "$REAL_WASM_FORK_INSTRUMENT" "$@" +SH +chmod +x "$work/counting-bin/wasm-fork-instrument" + count_file="$work/wasm-objdump.count" +inventory_count_file="$work/wasm-fork-instrument.count" +: >"$count_file" +: >"$inventory_count_file" +( + export PATH="$work/counting-bin:$PATH" + export REAL_WASM_OBJDUMP="$real_objdump" + export WASM_OBJDUMP_COUNT_FILE="$count_file" + export REAL_WASM_FORK_INSTRUMENT="$real_inventory_tool" + export WASM_FORK_INVENTORY_COUNT_FILE="$inventory_count_file" + export WASM_POSIX_FORK_INSTRUMENT="$work/counting-bin/wasm-fork-instrument" + export FAIL_WASM_OBJDUMP_DETAILS=1 + wasm_require_fork_instrumentation_if_needed "$work/complete-fork.wasm" +) +[ "$(grep -c '^-x$' "$count_file" || true)" = 0 ] && + [ "$(grep -c '^-s$' "$count_file" || true)" = 0 ] && + [ "$(wc -l <"$count_file" | tr -d ' ')" = 0 ] && + [ "$(grep -c -- '--contract-inventory' "$inventory_count_file")" = 1 ] && + [ "$(grep -c -- '--fork-capability-hex' "$inventory_count_file")" = 1 ] && + [ "$(grep -c -- '--linked-frame-descriptor-hex' "$inventory_count_file")" = 1 ] && + [ "$(wc -l <"$inventory_count_file" | tr -d ' ')" = 3 ] || { + echo "ERROR: fork validation did not use three binary contract passes" >&2 + cat "$count_file" >&2 + cat "$inventory_count_file" >&2 + exit 1 +} + +# The standalone guard remains usable before the Rust tool is installed. Its +# WABT compatibility path must still perform one structural pass and must not +# mistake an absent configured tool for successful validation. : >"$count_file" ( export PATH="$work/counting-bin:$PATH" export REAL_WASM_OBJDUMP="$real_objdump" export WASM_OBJDUMP_COUNT_FILE="$count_file" + export WASM_POSIX_FORK_INSTRUMENT="$work/not-installed/wasm-fork-instrument" wasm_require_fork_instrumentation_if_needed "$work/complete-fork.wasm" ) [ "$(grep -c '^-x$' "$count_file")" = 1 ] && [ "$(grep -c '^-s$' "$count_file")" = 2 ] && [ "$(wc -l <"$count_file" | tr -d ' ')" = 3 ] || { - echo "ERROR: fork validation did not use one structure and two metadata passes" >&2 + echo "ERROR: fork validation did not preserve the truthful WABT fallback" >&2 cat "$count_file" >&2 exit 1 } +if ( + export PATH="$work/counting-bin:$PATH" + export REAL_WASM_OBJDUMP="$real_objdump" + export WASM_OBJDUMP_COUNT_FILE="$count_file" + export WASM_POSIX_FORK_INSTRUMENT="$work/not-installed/wasm-fork-instrument" + wasm_require_fork_instrumentation_if_needed \ + "$work/native-start-fork.wasm" 2>"$work/native-start-fallback.error" +); then + echo "ERROR: WABT fork guard accepted a retained native start section" >&2 + exit 1 +fi +grep -F ' start: retains a native Wasm start section' \ + "$work/native-start-fallback.error" >/dev/null || { + echo "ERROR: WABT fork guard did not identify the native start failure" >&2 + cat "$work/native-start-fallback.error" >&2 + exit 1 +} + mkdir "$work/failing-bin" cat >"$work/failing-bin/wasm-objdump" <<'SH' #!/usr/bin/env bash @@ -742,7 +1003,9 @@ SH chmod +x "$work/failing-bin/wasm-objdump" decoder_path="$work/failing-bin:$PATH" -if ! PATH="$decoder_path" wasm_has_stale_abi "$work/abi.wasm" 18; then +missing_inventory_tool="$work/not-installed/wasm-fork-instrument" +if ! WASM_POSIX_FORK_INSTRUMENT="$missing_structural_tool" \ + PATH="$decoder_path" wasm_has_stale_abi "$work/abi.wasm" 18; then echo "ERROR: stale-ABI predicate accepted an artifact after decoder failure" >&2 exit 1 fi @@ -754,15 +1017,20 @@ if PATH="$decoder_path" wasm_require_exports "$work/abi.wasm" __abi_version >/de echo "ERROR: required-export guard accepted an artifact after decoder failure" >&2 exit 1 fi -if ! PATH="$decoder_path" wasm_has_missing_fork_instrumentation "$work/abi.wasm"; then +if ! WASM_POSIX_FORK_INSTRUMENT="$missing_inventory_tool" \ + PATH="$decoder_path" wasm_has_missing_fork_instrumentation "$work/abi.wasm"; then echo "ERROR: fork predicate accepted an artifact after decoder failure" >&2 exit 1 fi -if PATH="$decoder_path" wasm_require_fork_instrumentation_if_needed "$work/abi.wasm" >/dev/null 2>&1; then +if WASM_POSIX_FORK_INSTRUMENT="$missing_inventory_tool" \ + PATH="$decoder_path" \ + wasm_require_fork_instrumentation_if_needed "$work/abi.wasm" >/dev/null 2>&1; then echo "ERROR: fork guard accepted an artifact after decoder failure" >&2 exit 1 fi -if PATH="$decoder_path" wasm_require_no_fork_instrumentation "$work/abi.wasm" >/dev/null 2>&1; then +if WASM_POSIX_FORK_INSTRUMENT="$missing_inventory_tool" \ + PATH="$decoder_path" \ + wasm_require_no_fork_instrumentation "$work/abi.wasm" >/dev/null 2>&1; then echo "ERROR: disabled-fork guard accepted an artifact after decoder failure" >&2 exit 1 fi @@ -781,11 +1049,14 @@ if ! wasm_has_missing_fork_instrumentation "$work/fake-fork-exports.wasm"; then echo "ERROR: fork guard accepted data-segment strings as instrumentation exports" >&2 exit 1 fi -if ! PATH=/usr/bin:/bin wasm_has_missing_fork_instrumentation "$work/fake-fork-exports.wasm"; then +if ! WASM_POSIX_FORK_INSTRUMENT="$missing_inventory_tool" \ + PATH=/usr/bin:/bin \ + wasm_has_missing_fork_instrumentation "$work/fake-fork-exports.wasm"; then echo "ERROR: decoder-free fork predicate accepted raw export-name strings" >&2 exit 1 fi -if PATH=/usr/bin:/bin wasm_require_fork_instrumentation_if_needed \ +if WASM_POSIX_FORK_INSTRUMENT="$missing_inventory_tool" \ + PATH=/usr/bin:/bin wasm_require_fork_instrumentation_if_needed \ "$work/fake-fork-exports.wasm" >/dev/null 2>&1; then echo "ERROR: decoder-free fork guard accepted raw export-name strings" >&2 exit 1 diff --git a/scripts/wasm-artifact-guards.sh b/scripts/wasm-artifact-guards.sh index eeaf3ffa85..f0d65f3e8f 100644 --- a/scripts/wasm-artifact-guards.sh +++ b/scripts/wasm-artifact-guards.sh @@ -31,17 +31,44 @@ wasm_require_no_legacy_asyncify() { # this boundary, an up-to-date glue object linked against a stale sysroot can # turn a private libc helper into an env import; the generic host stub then # lets the program instantiate and traps only when the helper is called. +_wasm_reserved_env_import_inventory() { + local path="${1:-}" + wasm_is_binary "$path" || return 2 + + local inventory_tool + inventory_tool="$(_wasm_fork_contract_inventory_tool)" || return 127 + "$inventory_tool" --reserved-env-imports "$path" 2>/dev/null || return 2 +} + wasm_require_approved_reserved_env_imports() { local path="${1:-}" wasm_is_binary "$path" || return 0 - if ! command -v wasm-objdump >/dev/null 2>&1; then - echo "ERROR: cannot inspect reserved Wasm imports without wasm-objdump: $path" >&2 - return 1 - fi - local rejected - if ! rejected="$( - _wasm_stream_awk ' + local inventory inventory_status=0 rejected + inventory="$(_wasm_reserved_env_import_inventory "$path")" || inventory_status=$? + if [ "$inventory_status" -eq 0 ]; then + if [ -z "$inventory" ]; then + rejected="" + elif ! rejected="$( + awk -F '\t' ' + NF != 2 || ($1 != "func" && $1 != "table" && + $1 != "memory" && $1 != "global" && $1 != "tag") { + exit 2 + } + $1 == "func" && $2 == "env.__wasm_posix_vm_interrupt_after" { next } + { print $2 } + ' <<<"$inventory" + )"; then + echo "ERROR: cannot inspect reserved Wasm imports: $path" >&2 + return 1 + fi + elif [ "$inventory_status" -eq 127 ]; then + if ! command -v wasm-objdump >/dev/null 2>&1; then + echo "ERROR: cannot inspect reserved Wasm imports without a structural decoder: $path" >&2 + return 1 + fi + if ! rejected="$( + _wasm_stream_awk ' / <- env\.__wasm_posix_/ { identity = $0 sub(/^.* <- /, "", identity) @@ -50,8 +77,12 @@ wasm_require_approved_reserved_env_imports() { $0 ~ /^ - func\[/) next print identity } - ' wasm-objdump -x "$path" - )"; then + ' wasm-objdump -x "$path" + )"; then + echo "ERROR: cannot inspect reserved Wasm imports: $path" >&2 + return 1 + fi + else echo "ERROR: cannot inspect reserved Wasm imports: $path" >&2 return 1 fi @@ -347,12 +378,100 @@ wasm_extract_abi_version_with_binaryen() { printf '%s\n' "$abi" } +# Validate and print the stable structural-identity record. Return 127 only +# when the Rust decoder is unavailable so callers can distinguish a truthful +# source-only fallback from a decoder failure that must remain fail-closed. +_wasm_structural_artifact_identity() { + local path="${1:-}" + local identity identity_status=0 + local relocatable memory_count memory64_count abi_state abi_version + local imports_fork has_fork_exports + local imports_side_fork dylink_count dylink_first env_memory_count + local unsupported_side_import_count + local -a identity_fields + + identity="$(wasm_artifact_identity "$path")" || identity_status=$? + [ "$identity_status" -eq 0 ] || return "$identity_status" + IFS=$'\t' read -r -a identity_fields <<< "$identity" + case "${#identity_fields[@]}" in + 7) + relocatable="${identity_fields[0]}" + memory_count="${identity_fields[1]}" + memory64_count="${identity_fields[2]}" + abi_state="${identity_fields[3]}" + abi_version="${identity_fields[4]}" + imports_fork="${identity_fields[5]}" + has_fork_exports="${identity_fields[6]}" + ;; + 12) + relocatable="${identity_fields[0]}" + memory_count="${identity_fields[1]}" + memory64_count="${identity_fields[2]}" + abi_state="${identity_fields[3]}" + abi_version="${identity_fields[4]}" + imports_fork="${identity_fields[5]}" + imports_side_fork="${identity_fields[6]}" + has_fork_exports="${identity_fields[7]}" + dylink_count="${identity_fields[8]}" + dylink_first="${identity_fields[9]}" + env_memory_count="${identity_fields[10]}" + unsupported_side_import_count="${identity_fields[11]}" + [[ "$imports_side_fork" =~ ^[0-9]+$ ]] && + [[ "$dylink_count" =~ ^[0-9]+$ ]] && + [[ "$dylink_first" =~ ^[01]$ ]] && + [[ "$env_memory_count" =~ ^[0-9]+$ ]] && + [[ "$unsupported_side_import_count" =~ ^[0-9]+$ ]] || return 2 + ;; + *) return 2 ;; + esac + + [[ "$relocatable" =~ ^[01]$ ]] && + [[ "$memory_count" =~ ^[0-9]+$ ]] && + [[ "$memory64_count" =~ ^[0-9]+$ ]] && + [[ "$imports_fork" =~ ^[0-9]+$ ]] && + [[ "$has_fork_exports" =~ ^[01]$ ]] || return 2 + case "$abi_state" in + present) [[ "$abi_version" =~ ^[0-9]+$ ]] || return 2 ;; + missing|invalid) [ "$abi_version" = - ] || return 2 ;; + *) return 2 ;; + esac + [ "$imports_fork" = 0 ] || imports_fork=1 + + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$relocatable" "$memory_count" "$memory64_count" "$abi_state" \ + "$abi_version" "$imports_fork" "$has_fork_exports" +} + # Print a constant ABI export and return 0. Return 1 only when a valid Wasm # module genuinely has no optional ABI export; all inspection or semantic # failures return a status greater than 1 so resolver predicates fail closed. wasm_extract_abi_version() { local path="${1:-}" wasm_is_binary "$path" || return 2 + + local identity identity_status=0 + local relocatable memory_count memory64_count abi_state abi_version + local imports_fork has_fork_exports + identity="$(_wasm_structural_artifact_identity "$path")" || identity_status=$? + if [ "$identity_status" -eq 0 ]; then + IFS=$'\t' read -r relocatable memory_count memory64_count abi_state abi_version \ + imports_fork has_fork_exports <<< "$identity" + case "$abi_state" in + present) + printf '%s\n' "$abi_version" + return 0 + ;; + missing) return 1 ;; + invalid) return 3 ;; + *) return 2 ;; + esac + elif [ "$identity_status" -ne 127 ]; then + return "$identity_status" + fi + + # WHY: source-only callers may not have the Rust decoder yet. Preserve the + # bounded WABT/Binaryen compatibility path, but never fall back after an + # installed structural decoder reports malformed or undecodable bytes. command -v wasm-objdump >/dev/null 2>&1 || return 2 # The export name and the function's optional debug name are separate Wasm # concepts. SDK binaries export the internal function @@ -677,6 +796,20 @@ wasm_has_stale_abi() { wasm_imports_kernel_fork() { local path="${1:-}" wasm_is_binary "$path" || return 1 + + local identity identity_status=0 + local relocatable memory_count memory64_count abi_state abi_version + local imports_fork has_fork_exports + identity="$(_wasm_structural_artifact_identity "$path")" || identity_status=$? + if [ "$identity_status" -eq 0 ]; then + IFS=$'\t' read -r relocatable memory_count memory64_count abi_state abi_version \ + imports_fork has_fork_exports <<< "$identity" + [ "$imports_fork" = 1 ] + return + elif [ "$identity_status" -ne 127 ]; then + return "$identity_status" + fi + if command -v wasm-objdump >/dev/null 2>&1; then _wasm_stream_awk ' /<- kernel\.kernel_fork/ { found = 1 } @@ -774,21 +907,80 @@ wasm_validate_side_module_imports() { ' wasm-objdump -x "$path" } +# Resolve the in-tree instrumenter without building it as a side effect of an +# artifact policy check. Release/package jobs install this binary alongside the +# guard; source-only environments can still use the WABT fallback below. +_wasm_fork_contract_inventory_tool() { + local configured="${WASM_POSIX_FORK_INSTRUMENT:-}" + if [ -n "$configured" ]; then + if [ -x "$configured" ]; then + printf '%s\n' "$configured" + return 0 + fi + if [[ "$configured" != */* ]] && command -v "$configured" >/dev/null 2>&1; then + command -v "$configured" + return 0 + fi + # An explicit tool selection is an ownership boundary. Do not silently + # substitute a different binary when that exact path is unavailable. + return 1 + fi + + local repo_root repo_tool + repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd)" || return 1 + repo_tool="$repo_root/tools/bin/wasm-fork-instrument" + if [ -x "$repo_tool" ]; then + printf '%s\n' "$repo_tool" + return 0 + fi + command -v wasm-fork-instrument 2>/dev/null +} + +wasm_artifact_identity() { + local path="${1:-}" + wasm_is_binary "$path" || return 2 + + local inventory_tool + inventory_tool="$(_wasm_fork_contract_inventory_tool)" || return 127 + "$inventory_tool" --artifact-identity "$path" 2>/dev/null || return 2 +} + +_wasm_fork_contract_inventory_decoder_available() { + _wasm_fork_contract_inventory_tool >/dev/null || + command -v wasm-objdump >/dev/null 2>&1 +} + # Inspect the complete fork-instrumentation contract with one structural -# decoder pass. Large programs such as Ruby produce tens of megabytes of -# `wasm-objdump -x` output; decoding that output once also keeps a transient -# decoder failure from being misreported as one arbitrarily missing export. +# decoder pass. The wasmparser-backed tool emits only the stable TSV record, so +# large programs do not materialize tens of megabytes of `wasm-objdump -x` +# text. Keep the WABT parser as a truthful compatibility fallback when the +# instrumenter binary is not installed. # # Output fields are, in order: # relocatable, imports a main or side-module fork entry, # frame reserve/commit/next imports, # linked-frame descriptor and capability counts, abort begin/end, # rewind begin/end, state, -# unwind begin/end exports, module-memory count, memory64 count, and -# signature mismatches against the module memory's pointer type. +# unwind begin/end exports, module-memory count, memory64 count, +# signature mismatches against the module memory's pointer type, and the +# count of reentrant legacy env.__wasm_dlopen imports, and native start +# sections retained by the final artifact. _wasm_fork_contract_inventory() { local path="${1:-}" wasm_is_binary "$path" || return 1 + + local inventory_tool inventory_status=0 + if inventory_tool="$(_wasm_fork_contract_inventory_tool)"; then + "$inventory_tool" --contract-inventory "$path" 2>/dev/null || + inventory_status=$? + if [ "$inventory_status" -eq 1 ]; then + # Preserve the tri-state contract: status 1 means "predicate did + # not match", while a decoder failure must fail artifact policy. + return 2 + fi + return "$inventory_status" + fi + command -v wasm-objdump >/dev/null 2>&1 || return 2 _wasm_stream_awk ' @@ -818,6 +1010,7 @@ _wasm_fork_contract_inventory() { function_signatures[function_index($0)] = function_types[signature_index($0)] } /^ - func\[.* <- (kernel\.kernel_fork|env\.fork)$/ { imports_fork = 1 } + /^ - func\[.* <- env\.__wasm_dlopen$/ { legacy_dlopen++ } /^ - func\[.* <- env\.__wpk_fork_frame_reserve$/ { frame_reserve++ frame_reserve_signatures[frame_reserve] = function_signatures[function_index($0)] @@ -832,6 +1025,7 @@ _wasm_fork_contract_inventory() { } /^ - name: "kandelo\.wpk_fork\.linked_frames"$/ { linked_descriptor++ } /^ - name: "kandelo\.wpk_fork\.capabilities"$/ { fork_capability++ } + /^Start:$/ { native_start++ } /^ - memory\[[0-9]+\] pages:/ { memory_count++ if ($0 ~ / i64( |$)/) memory64_count++ @@ -890,14 +1084,15 @@ _wasm_fork_contract_inventory() { for (i = 1; i <= unwind_end; i++) if (unwind_end_signatures[i] != nil_to_nil) signature_mismatch++ - printf "%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\n", + printf "%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\n", relocatable + 0, imports_fork + 0, frame_reserve + 0, frame_commit + 0, frame_next + 0, linked_descriptor + 0, fork_capability + 0, abort_begin + 0, abort_end + 0, rewind_begin + 0, rewind_end + 0, state + 0, unwind_begin + 0, unwind_end + 0, - memory_count + 0, memory64_count + 0, signature_mismatch + 0 + memory_count + 0, memory64_count + 0, signature_mismatch + 0, + legacy_dlopen + 0, native_start + 0 } ' wasm-objdump -x "$path" } @@ -905,6 +1100,13 @@ _wasm_fork_contract_inventory() { _wasm_fork_capability_hex() { local path="${1:-}" wasm_is_binary "$path" || return 2 + + local inventory_tool + if inventory_tool="$(_wasm_fork_contract_inventory_tool)"; then + "$inventory_tool" --fork-capability-hex "$path" 2>/dev/null + return + fi + command -v wasm-objdump >/dev/null 2>&1 || return 2 _wasm_stream_awk ' @@ -949,6 +1151,13 @@ wasm_has_activation_state_safe_capability() { _wasm_linked_frame_descriptor_hex() { local path="${1:-}" wasm_is_binary "$path" || return 2 + + local inventory_tool + if inventory_tool="$(_wasm_fork_contract_inventory_tool)"; then + "$inventory_tool" --linked-frame-descriptor-hex "$path" 2>/dev/null + return + fi + command -v wasm-objdump >/dev/null 2>&1 || return 2 # `wasm-objdump -x` reports a custom section's name but not its payload. @@ -1082,12 +1291,12 @@ wasm_has_complete_fork_instrumentation() { local inventory inventory_status=0 local relocatable imports_fork frame_reserve frame_commit frame_next linked_descriptor fork_capability local abort_begin abort_end rewind_begin rewind_end state unwind_begin unwind_end - local memory_count memory64_count signature_mismatch extra + local memory_count memory64_count signature_mismatch legacy_dlopen native_start extra inventory="$(_wasm_fork_contract_inventory "$path")" || inventory_status=$? [ "$inventory_status" -eq 0 ] || return "$inventory_status" IFS=$'\t' read -r relocatable imports_fork frame_reserve frame_commit frame_next \ linked_descriptor fork_capability abort_begin abort_end rewind_begin rewind_end state \ - unwind_begin unwind_end memory_count memory64_count signature_mismatch extra <<< "$inventory" + unwind_begin unwind_end memory_count memory64_count signature_mismatch legacy_dlopen native_start extra <<< "$inventory" [ -z "$extra" ] || return 2 [ "$frame_reserve$frame_commit$frame_next" = 111 ] || return 1 [ "$linked_descriptor" = 1 ] || return 1 @@ -1095,7 +1304,8 @@ wasm_has_complete_fork_instrumentation() { wasm_has_activation_state_safe_capability "$path" || return $? [ "$abort_begin$abort_end$rewind_begin$rewind_end$state$unwind_begin$unwind_end" = 1111111 ] || return 1 - [ "$memory_count" = 1 ] && [ "$signature_mismatch" = 0 ] || return 1 + [ "$memory_count" = 1 ] && [ "$signature_mismatch" = 0 ] && + [ "$legacy_dlopen" = 0 ] && [ "$native_start" = 0 ] || return 1 local descriptor_pointer_width descriptor_pointer_width="$(wasm_linked_frame_descriptor_pointer_width "$path")" || return $? [ "$descriptor_pointer_width" = 8 ] && [ "$memory64_count" = 1 ] && return 0 @@ -1139,7 +1349,7 @@ wasm_has_any_wpk_fork_export() { local inventory inventory_status=0 local relocatable imports_fork frame_reserve frame_commit frame_next linked_descriptor fork_capability local abort_begin abort_end rewind_begin rewind_end state unwind_begin unwind_end - local memory_count memory64_count signature_mismatch extra + local memory_count memory64_count signature_mismatch legacy_dlopen native_start extra inventory="$(_wasm_fork_contract_inventory "$path")" || inventory_status=$? case "$inventory_status" in 0) ;; @@ -1148,7 +1358,7 @@ wasm_has_any_wpk_fork_export() { esac IFS=$'\t' read -r relocatable imports_fork frame_reserve frame_commit frame_next \ linked_descriptor fork_capability abort_begin abort_end rewind_begin rewind_end state \ - unwind_begin unwind_end memory_count memory64_count signature_mismatch extra <<< "$inventory" + unwind_begin unwind_end memory_count memory64_count signature_mismatch legacy_dlopen native_start extra <<< "$inventory" [ -z "$extra" ] || return 0 [ "$abort_begin$abort_end$rewind_begin$rewind_end$state$unwind_begin$unwind_end" != 0000000 ] } @@ -1158,7 +1368,7 @@ wasm_has_any_fork_instrumentation() { local inventory inventory_status=0 local relocatable imports_fork frame_reserve frame_commit frame_next linked_descriptor fork_capability local abort_begin abort_end rewind_begin rewind_end state unwind_begin unwind_end - local memory_count memory64_count signature_mismatch extra + local memory_count memory64_count signature_mismatch legacy_dlopen native_start extra inventory="$(_wasm_fork_contract_inventory "$path")" || inventory_status=$? case "$inventory_status" in 0) ;; @@ -1167,7 +1377,7 @@ wasm_has_any_fork_instrumentation() { esac IFS=$'\t' read -r relocatable imports_fork frame_reserve frame_commit frame_next \ linked_descriptor fork_capability abort_begin abort_end rewind_begin rewind_end state \ - unwind_begin unwind_end memory_count memory64_count signature_mismatch extra <<< "$inventory" + unwind_begin unwind_end memory_count memory64_count signature_mismatch legacy_dlopen native_start extra <<< "$inventory" [ -z "$extra" ] || return 0 [ "$frame_reserve$frame_commit$frame_next" != 000 ] || [ "$linked_descriptor" != 0 ] || @@ -1180,10 +1390,10 @@ wasm_has_missing_fork_instrumentation() { local inventory inventory_status=0 local relocatable imports_fork frame_reserve frame_commit frame_next linked_descriptor fork_capability local abort_begin abort_end rewind_begin rewind_end state unwind_begin unwind_end - local memory_count memory64_count signature_mismatch extra + local memory_count memory64_count signature_mismatch legacy_dlopen native_start extra wasm_is_binary "$path" || return 1 - if ! command -v wasm-objdump >/dev/null 2>&1; then + if ! _wasm_fork_contract_inventory_decoder_available; then case "$path" in *.o) return 1 ;; *) return 0 ;; @@ -1194,7 +1404,7 @@ wasm_has_missing_fork_instrumentation() { [ "$inventory_status" -eq 0 ] || return 0 # Decoder failure: unsafe. IFS=$'\t' read -r relocatable imports_fork frame_reserve frame_commit frame_next \ linked_descriptor fork_capability abort_begin abort_end rewind_begin rewind_end state \ - unwind_begin unwind_end memory_count memory64_count signature_mismatch extra <<< "$inventory" + unwind_begin unwind_end memory_count memory64_count signature_mismatch legacy_dlopen native_start extra <<< "$inventory" [ -z "$extra" ] || return 0 [ "$relocatable" = 1 ] && return 1 @@ -1210,7 +1420,8 @@ wasm_has_missing_fork_instrumentation() { local descriptor_pointer_width descriptor_pointer_width="$(wasm_linked_frame_descriptor_pointer_width "$path")" || return 0 [ "$exports" = 1111111 ] || return 0 - [ "$memory_count" = 1 ] && [ "$signature_mismatch" = 0 ] || return 0 + [ "$memory_count" = 1 ] && [ "$signature_mismatch" = 0 ] && + [ "$legacy_dlopen" = 0 ] && [ "$native_start" = 0 ] || return 0 if [ "$descriptor_pointer_width" = 8 ]; then [ "$memory64_count" = 1 ] || return 0 else @@ -1230,31 +1441,31 @@ wasm_require_fork_instrumentation_if_needed() { local path="${1:-}" wasm_is_binary "$path" || return 0 - if ! command -v wasm-objdump >/dev/null 2>&1; then + if ! _wasm_fork_contract_inventory_decoder_available; then case "$path" in *.o) return 0 ;; esac echo "ERROR: unable to inspect fork instrumentation: $path" >&2 - echo " wasm-objdump is required for structural export validation." >&2 + echo " wasm-fork-instrument or wasm-objdump is required for structural validation." >&2 return 1 fi local inventory inventory_status=0 local relocatable imports_fork frame_reserve frame_commit frame_next linked_descriptor fork_capability local abort_begin abort_end rewind_begin rewind_end state unwind_begin unwind_end - local memory_count memory64_count signature_mismatch extra + local memory_count memory64_count signature_mismatch legacy_dlopen native_start extra inventory="$(_wasm_fork_contract_inventory "$path")" || inventory_status=$? if [ "$inventory_status" -ne 0 ]; then echo "ERROR: unable to inspect fork instrumentation: $path" >&2 - echo " wasm-objdump failed with status $inventory_status." >&2 + echo " structural decoder failed with status $inventory_status." >&2 return 1 fi IFS=$'\t' read -r relocatable imports_fork frame_reserve frame_commit frame_next \ linked_descriptor fork_capability abort_begin abort_end rewind_begin rewind_end state \ - unwind_begin unwind_end memory_count memory64_count signature_mismatch extra <<< "$inventory" + unwind_begin unwind_end memory_count memory64_count signature_mismatch legacy_dlopen native_start extra <<< "$inventory" if [ -n "$extra" ]; then echo "ERROR: unable to inspect fork instrumentation: $path" >&2 - echo " wasm-objdump returned an invalid fork-contract inventory." >&2 + echo " structural decoder returned an invalid fork-contract inventory." >&2 return 1 fi [ "$relocatable" = 1 ] && return 0 @@ -1335,11 +1546,18 @@ wasm_require_fork_instrumentation_if_needed() { local signature_error="" [ "$signature_mismatch" = 0 ] || signature_error="$signature_mismatch ABI 43 fork import/export signatures do not match module memory" + local legacy_loader_error="" + [ "$legacy_dlopen" = 0 ] || + legacy_loader_error="retains reentrant env.__wasm_dlopen instead of the staged loader lowering" + local native_start_error="" + [ "$native_start" = 0 ] || + native_start_error="retains a native Wasm start section instead of deferring initialization to wpk_fork_module_bootstrap" if [ ${#missing[@]} -eq 0 ] && [ ${#duplicates[@]} -eq 0 ] && [ -z "$descriptor_error" ] && [ -z "$capability_error" ] && [ -z "$memory_error" ] && - [ -z "$signature_error" ]; then + [ -z "$signature_error" ] && [ -z "$legacy_loader_error" ] && + [ -z "$native_start_error" ]; then return 0 fi @@ -1350,6 +1568,8 @@ wasm_require_fork_instrumentation_if_needed() { [ -z "$capability_error" ] || printf ' capability: %s\n' "$capability_error" >&2 [ -z "$memory_error" ] || printf ' memory: %s\n' "$memory_error" >&2 [ -z "$signature_error" ] || printf ' signatures: %s\n' "$signature_error" >&2 + [ -z "$legacy_loader_error" ] || printf ' loader: %s\n' "$legacy_loader_error" >&2 + [ -z "$native_start_error" ] || printf ' start: %s\n' "$native_start_error" >&2 echo " Fork-capable binaries must be processed with scripts/run-wasm-fork-instrument.sh from the current ABI." >&2 return 1 } @@ -1360,7 +1580,7 @@ wasm_require_no_fork_instrumentation() { local inventory inventory_status=0 local relocatable imports_fork frame_reserve frame_commit frame_next linked_descriptor fork_capability local abort_begin abort_end rewind_begin rewind_end state unwind_begin unwind_end - local memory_count memory64_count signature_mismatch extra + local memory_count memory64_count signature_mismatch legacy_dlopen native_start extra inventory="$(_wasm_fork_contract_inventory "$path")" || inventory_status=$? if [ "$inventory_status" -ne 0 ]; then echo "ERROR: unable to inspect fork instrumentation policy: $path" >&2 @@ -1368,7 +1588,7 @@ wasm_require_no_fork_instrumentation() { fi IFS=$'\t' read -r relocatable imports_fork frame_reserve frame_commit frame_next \ linked_descriptor fork_capability abort_begin abort_end rewind_begin rewind_end state \ - unwind_begin unwind_end memory_count memory64_count signature_mismatch extra <<< "$inventory" + unwind_begin unwind_end memory_count memory64_count signature_mismatch legacy_dlopen native_start extra <<< "$inventory" if [ -n "$extra" ]; then echo "ERROR: unable to inspect fork instrumentation policy: $path" >&2 return 1 diff --git a/tests/package-system/program-resolver-literals.test.ts b/tests/package-system/program-resolver-literals.test.ts index d5d2a108e2..0e5b934fa1 100644 --- a/tests/package-system/program-resolver-literals.test.ts +++ b/tests/package-system/program-resolver-literals.test.ts @@ -1,8 +1,4 @@ -import { - existsSync, - readFileSync, - readdirSync, -} from "node:fs"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; import { basename, extname, join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; @@ -177,10 +173,7 @@ function sourceFilesUnder(relPath: string): string[] { if (entry.isSymbolicLink()) return []; const child = join(relPath, entry.name); if (entry.isDirectory()) { - if ( - excludedDirectories.has(entry.name) - || child === "docs/plans" - ) { + if (excludedDirectories.has(entry.name) || child === "docs/plans") { return []; } return sourceFilesUnder(child); @@ -239,6 +232,7 @@ function stalePathsInLine( function staleLiteralFailures( candidates: ReadonlyMap, ): string[] { + if (candidates.size === 0) return []; const failures: string[] = []; const trie = stalePathTrie(candidates); for (const relPath of auditedSourceFiles()) { @@ -269,8 +263,8 @@ function staleLiteralFailures( .map((packageName) => JSON.stringify(packageName)) .join(", "); failures.push( - `${relPath}:${index + 1}: ${JSON.stringify(stalePath)} is a stale flat ` - + `resolver path owned by package ${packages}; use ${replacements}`, + `${relPath}:${index + 1}: ${JSON.stringify(stalePath)} is a stale flat ` + + `resolver path owned by package ${packages}; use ${replacements}`, ); } } diff --git a/tests/scripts/ci-run-test-suite-groups.test.sh b/tests/scripts/ci-run-test-suite-groups.test.sh index bea9c3ba0f..dab253010d 100755 --- a/tests/scripts/ci-run-test-suite-groups.test.sh +++ b/tests/scripts/ci-run-test-suite-groups.test.sh @@ -454,6 +454,12 @@ fi exit 2 EOF +cat > "$FIXTURE/bin/cargo" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$CARGO_CAPTURE" +exit 0 +EOF + cat > "$FIXTURE/run.sh" <<'EOF' #!/usr/bin/env bash printf '%s\n' "$*" > "$RUN_CAPTURE" @@ -494,8 +500,25 @@ fi EOF chmod +x "$FIXTURE/scripts/$runner" done + +prepared_xtask="$FIXTURE/target/fixture-host/release/xtask" +mkdir -p "$(dirname "$prepared_xtask")" +cat > "$prepared_xtask" <<'EOF' +#!/usr/bin/env bash +if [ "${1:-}" = "build-deps" ] && [ "${2:-}" = "cache-root" ] && + [ "$#" -eq 2 ]; then + case "${WASM_POSIX_BINARY_CACHE_ROOT:-}" in + /*) printf '%s\n' "$WASM_POSIX_BINARY_CACHE_ROOT" ;; + *) printf '%s\n' "$PWD/${WASM_POSIX_BINARY_CACHE_ROOT:-.cache/kandelo}" ;; + esac + exit 0 +fi +exit 2 +EOF + chmod +x \ "$FIXTURE/bin/bun" \ + "$FIXTURE/bin/cargo" \ "$FIXTURE/bin/npm" \ "$FIXTURE/bin/npx" \ "$FIXTURE/bin/rustc" \ @@ -503,7 +526,8 @@ chmod +x \ "$FIXTURE/run.sh" \ "$FIXTURE/scripts/ci-check-browser-assets.sh" \ "$FIXTURE/scripts/resolve-binary.sh" \ - "$FIXTURE/scripts/materialize-ci-publication-blockers.sh" + "$FIXTURE/scripts/materialize-ci-publication-blockers.sh" \ + "$prepared_xtask" git -C "$FIXTURE" init -q git -C "$FIXTURE" config user.name "Kandelo CI fixture" @@ -614,6 +638,9 @@ then exit 1 fi +CARGO_CAPTURE="$TMP_DIR/cargo-build.args" +export CARGO_CAPTURE + run_group() { local suite="$1" local group="$2" @@ -1127,6 +1154,20 @@ grep -Fq \ "prepared browser workspace lacks Homebrew mirror state" \ "$TMP_DIR/browser-missing-mirror-state.out" +if ! awk ' + $0 != "build --release -p xtask --target fixture-host --quiet" { + exit 1 + } +' "$CARGO_CAPTURE"; then + echo "ci-run-test-suite.sh used an unexpected package-checker build command:" >&2 + cat "$CARGO_CAPTURE" >&2 + exit 1 +fi +[ -s "$CARGO_CAPTURE" ] || { + echo "ci-run-test-suite.sh did not prepare the source-workspace package checker" >&2 + exit 1 +} + for workflow in \ "$REPO_ROOT/.github/workflows/staging-build.yml" \ "$REPO_ROOT/.github/workflows/prepare-merge.yml"; do @@ -1343,24 +1384,7 @@ chmod +x "$prepared_xtask" mkdir -p "$FIXTURE/.ci-test-binary-cache/programs" cache_capture="$TMP_DIR/portable-cache-root" xtask_capture="$TMP_DIR/portable-xtask" -direct_cache_capture="$TMP_DIR/direct-portable-cache-root" -direct_xtask_capture="$TMP_DIR/direct-portable-xtask" -PATH="$FIXTURE/bin:$PATH" \ - WASM_POSIX_BINARY_CACHE_ROOT="$TMP_DIR/wrong-direct-cache" \ - WASM_POSIX_XTASK_BIN="$TMP_DIR/wrong-direct-xtask" \ - bash "$FIXTURE/scripts/activate-ci-test-workspace.sh" \ - bash -c ' - printf "%s\n" "$WASM_POSIX_BINARY_CACHE_ROOT" > "$1" - printf "%s\n" "$WASM_POSIX_XTASK_BIN" > "$2" - ' bash "$direct_cache_capture" "$direct_xtask_capture" -grep -Fxq "$FIXTURE/.ci-test-binary-cache" "$direct_cache_capture" || { - echo "direct prepared-workspace consumer did not select the transported program cache" >&2 - exit 1 -} -grep -Fxq "$prepared_xtask" "$direct_xtask_capture" || { - echo "direct prepared-workspace consumer did not select the transported package checker" >&2 - exit 1 -} +: > "$CARGO_CAPTURE" PATH="$FIXTURE/bin:$PATH" \ TEST_CAPTURE="$TMP_DIR/portable-cache-suite.args" \ CACHE_CAPTURE="$cache_capture" \ @@ -1376,6 +1400,10 @@ grep -Fxq "$prepared_xtask" "$xtask_capture" || { echo "ci-run-test-suite.sh did not select the transported package checker" >&2 exit 1 } +[ ! -s "$CARGO_CAPTURE" ] || { + echo "ci-run-test-suite.sh rebuilt a transported package checker" >&2 + exit 1 +} missing_xtask_capture="$TMP_DIR/missing-xtask-suite.args" chmod -x "$prepared_xtask" if PATH="$FIXTURE/bin:$PATH" \ diff --git a/tools/xtask/src/archive_stage_cli.rs b/tools/xtask/src/archive_stage_cli.rs index 98dcf0e195..3769680fd2 100644 --- a/tools/xtask/src/archive_stage_cli.rs +++ b/tools/xtask/src/archive_stage_cli.rs @@ -16,7 +16,7 @@ use std::fs; use std::path::{Path, PathBuf}; use crate::archive_stage::{self, StageOptions}; -use crate::build_deps::{self, default_cache_root, parse_target_arch, Registry, ResolveOpts}; +use crate::build_deps::{self, Registry, ResolveOpts, default_cache_root, parse_target_arch}; use crate::pkg_manifest::{BuildToml, DepsManifest, ManifestKind, TargetArch}; use crate::publication_policy::PublicationPolicy; use crate::repo_root; @@ -905,9 +905,11 @@ built_by = "test" let suffix = ".tar.zst"; let short = &name[prefix.len()..name.len() - suffix.len()]; assert_eq!(short.len(), 8, "short_sha slot must be 8 chars: {short:?}"); - assert!(short - .chars() - .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase())); + assert!( + short + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()) + ); let index_path = dir.join("index.toml"); crate::build_index::run(vec![ diff --git a/tools/xtask/src/build_deps.rs b/tools/xtask/src/build_deps.rs index 5e709a3836..bd083ae294 100644 --- a/tools/xtask/src/build_deps.rs +++ b/tools/xtask/src/build_deps.rs @@ -1958,8 +1958,7 @@ const FORK_INSTRUMENT_TOOL_INPUTS: &[&str] = &[ "scripts/run-wasm-fork-instrument.sh", ]; -type RootDigestCache = - OnceLock, String>>>>; +type RootDigestCache = OnceLock, String>>>>; static GLOBAL_PACKAGE_TOOLCHAIN_DIGESTS: RootDigestCache = OnceLock::new(); static FORK_INSTRUMENT_TOOL_DIGESTS: RootDigestCache = OnceLock::new(); @@ -2034,16 +2033,17 @@ struct CargoLockPackage { checksum: Option, } +const FORK_INSTRUMENT_CARGO_METADATA_ARGS: &[&str] = + &["metadata", "--format-version=1", "--locked"]; + fn fork_instrument_cargo_dependency_digest(root: &Path) -> Result<[u8; 32], String> { - let host_target = host_target_triple()?; + // WHY: program cache paths have no build-host dimension. Filtering this + // graph through the current macOS or Linux host made one source tree + // compute different identities. Cargo's unfiltered graph is the stable + // union, so any dependency that can build the instrumenter invalidates the + // shared generation without making the key host-specific. let output = Command::new("cargo") - .args([ - "metadata", - "--format-version=1", - "--locked", - "--filter-platform", - &host_target, - ]) + .args(FORK_INSTRUMENT_CARGO_METADATA_ARGS) .current_dir(root) .output() .map_err(|e| format!("run cargo metadata for fork-instrument cache key: {e}"))?; @@ -2314,7 +2314,7 @@ fn fork_instrument_cargo_dependency_digest_from_metadata( entries.sort_by(|a, b| a.0.cmp(&b.0)); let mut h = Sha256::new(); - h.update(b"fork-instrument-cargo-build-deps-v1\n"); + h.update(b"fork-instrument-cargo-build-deps-v2-host-union\n"); for (stable_id, features, deps, checksum) in entries { h.update(b"package\0"); h.update(stable_id.as_bytes()); @@ -2674,9 +2674,7 @@ fn split_registry_build_input<'a>( } }; let package_name = package_component.to_str().ok_or_else(|| { - format!( - "canonical registry build input has a non-UTF-8 package name: {authored_input:?}", - ) + format!("canonical registry build input has a non-UTF-8 package name: {authored_input:?}",) })?; Ok((package_name, components.as_path())) } @@ -5051,6 +5049,7 @@ struct WasmArtifactFacts { memory_pointer_widths: Vec, fork_capabilities: Vec>, linked_frame_descriptors: Vec>, + native_start_count: usize, is_relocatable_object: bool, } @@ -5181,6 +5180,9 @@ fn wasm_artifact_facts(bytes: &[u8]) -> Result { } } } + Payload::StartSection { .. } => { + facts.native_start_count += 1; + } Payload::CustomSection(c) => { let name = c.name(); if name == "linking" || name.starts_with("reloc.") { @@ -5358,6 +5360,11 @@ fn program_artifact_signature_matches( _ => false, }, ProgramArtifactValueType::I32 => *actual == ValType::I32, + ProgramArtifactValueType::I64 => *actual == ValType::I64, + ProgramArtifactValueType::FuncRef => *actual == ValType::FUNCREF, + ProgramArtifactValueType::ExternRef => *actual == ValType::EXTERNREF, + ProgramArtifactValueType::ExnRef => *actual == ValType::EXNREF, + ProgramArtifactValueType::AnyRef => *actual == ValType::Ref(wasmparser::RefType::ANYREF), }; actual.params().len() == params.len() @@ -5385,6 +5392,11 @@ fn program_artifact_signature_text( ProgramArtifactValueType::Pointer if pointer_width == 8 => "i64", ProgramArtifactValueType::Pointer => "i32", ProgramArtifactValueType::I32 => "i32", + ProgramArtifactValueType::I64 => "i64", + ProgramArtifactValueType::FuncRef => "funcref", + ProgramArtifactValueType::ExternRef => "externref", + ProgramArtifactValueType::ExnRef => "exnref", + ProgramArtifactValueType::AnyRef => "anyref", }; let params = params.iter().map(value_name).collect::>().join(","); let results = results.iter().map(value_name).collect::>().join(","); @@ -5475,6 +5487,29 @@ fn wasm_artifact_policy_failures_for( } let contract_failure_start = failures.len(); + if facts.native_start_count != 0 { + // WHY: staged dlopen instantiates modules inside a host import. ABI 43 + // moves the source start function behind an explicit bootstrap so + // instantiation itself cannot reenter guest Wasm. + failures.push(format!( + "retains {} native Wasm start section{} instead of deferring initialization to wpk_fork_module_bootstrap", + facts.native_start_count, + if facts.native_start_count == 1 { "" } else { "s" }, + )); + } + if facts + .function_imports + .contains_key(&("env".to_string(), "__wasm_dlopen".to_string())) + { + // ABI 43's instrumenter rewrites this monolithic callback into local + // prepare/next/commit control flow. Seeing the import beside the safety + // claim therefore proves that publication received stale or forged + // instrumentation metadata. + failures.push( + "retains reentrant env.__wasm_dlopen instead of the ABI 43 staged loader lowering" + .to_string(), + ); + } let missing_exports = fork_exports .iter() .filter(|requirement| !facts.function_exports.contains_key(requirement.name)) @@ -8928,9 +8963,10 @@ impl LocalFileTransaction { if self.published || self.yielded_to_other_writer || !self.old_moved - || self.backup_snapshot.as_ref().is_none_or(|snapshot| { - validate_local_mirror_entry(&self.backup, snapshot).is_err() - }) + || self + .backup_snapshot + .as_ref() + .is_none_or(|snapshot| validate_local_mirror_entry(&self.backup, snapshot).is_err()) { return; } @@ -10490,13 +10526,7 @@ index_url = "https://example.test/releases/download/binaries-abi-v{{abi}}/index. .unwrap(); } - fn write_build_with_input( - dir: &Path, - name: &str, - revision: u32, - input: &str, - contents: &str, - ) { + fn write_build_with_input(dir: &Path, name: &str, revision: u32, input: &str, contents: &str) { let input_path = dir.join(name).join(input); fs::write(&input_path, contents).unwrap(); fs::write( @@ -11493,23 +11523,60 @@ wasm = "second.wasm" ty } + fn wasm_contract_value_type( + value: wasm_posix_shared::abi::ProgramArtifactValueType, + pointer_width: u8, + ) -> u8 { + use wasm_posix_shared::abi::ProgramArtifactValueType; + + match value { + ProgramArtifactValueType::Pointer if pointer_width == 4 => 0x7f, + ProgramArtifactValueType::Pointer if pointer_width == 8 => 0x7e, + ProgramArtifactValueType::Pointer => { + panic!("unsupported fixture pointer width {pointer_width}") + } + ProgramArtifactValueType::I32 => 0x7f, + ProgramArtifactValueType::I64 => 0x7e, + ProgramArtifactValueType::FuncRef => 0x70, + ProgramArtifactValueType::ExternRef => 0x6f, + ProgramArtifactValueType::ExnRef => 0x69, + ProgramArtifactValueType::AnyRef => 0x6e, + } + } + + fn wasm_contract_function_type( + params: &[wasm_posix_shared::abi::ProgramArtifactValueType], + results: &[wasm_posix_shared::abi::ProgramArtifactValueType], + pointer_width: u8, + ) -> Vec { + let params = params + .iter() + .copied() + .map(|value| wasm_contract_value_type(value, pointer_width)) + .collect::>(); + let results = results + .iter() + .copied() + .map(|value| wasm_contract_value_type(value, pointer_width)) + .collect::>(); + wasm_function_type(¶ms, &results) + } + fn wasm_fork_artifact_with_capabilities( - descriptor_pointer_width: u8, + _descriptor_pointer_width: u8, signature_pointer_width: u8, memory_pointer_width: u8, include_kernel_fork: bool, - frame_imports: &[&str], + fork_imports: &[&str], fork_exports: &[&str], descriptors: &[Vec], capabilities: &[Vec], + include_legacy_dlopen: bool, + include_native_start: bool, ) -> Vec { use wasm_posix_shared::abi; + use wasm_posix_shared::abi::ProgramArtifactValueType::{I32, Pointer}; - let pointer_type = match signature_pointer_width { - 4 => 0x7f, // i32 - 8 => 0x7e, // i64 - other => panic!("unsupported fixture pointer width {other}"), - }; let mut bytes = b"\0asm\x01\0\0\0".to_vec(); for capability in capabilities { bytes.extend(wasm_custom_section( @@ -11524,30 +11591,67 @@ wasm = "second.wasm" )); } - let types = [ - wasm_function_type(&[], &[0x7f]), - wasm_function_type(&[pointer_type], &[pointer_type]), - wasm_function_type(&[pointer_type], &[]), - wasm_function_type(&[], &[]), + // Derive this raw-Wasm fixture from the same declarations enforced by + // publication policy. ABI 43 deliberately has a larger private + // surface than the original three linked-frame hooks, and a + // hand-maintained type-index switch silently went stale as reference + // ownership was added. + let mut types = vec![ + wasm_contract_function_type(&[], &[I32], signature_pointer_width), + wasm_contract_function_type(&[], &[], signature_pointer_width), ]; + let kernel_fork_type = 0u32; + let empty_function_type = 1u32; + + let mut imports = Vec::new(); + if include_kernel_fork { + imports.push(("kernel", "kernel_fork", kernel_fork_type)); + } + if include_legacy_dlopen { + let type_index = types.len() as u32; + types.push(wasm_contract_function_type( + &[Pointer, I32, Pointer, I32, I32], + &[I32], + signature_pointer_width, + )); + imports.push(("env", "__wasm_dlopen", type_index)); + } + for name in fork_imports { + let requirement = abi::WPK_FORK_REQUIRED_IMPORTS + .iter() + .find(|requirement| requirement.name == *name) + .unwrap_or_else(|| panic!("unknown ABI 43 fork import fixture {name}")); + let type_index = types.len() as u32; + types.push(wasm_contract_function_type( + requirement.params, + requirement.results, + signature_pointer_width, + )); + imports.push((requirement.module, requirement.name, type_index)); + } + + // Keep a local function for every required export even in a + // missing-export fixture. Removing an export must not renumber the + // remaining functions or turn a policy test into malformed Wasm. + let mut local_functions = Vec::new(); + for requirement in abi::WPK_FORK_REQUIRED_EXPORTS { + let type_index = types.len() as u32; + types.push(wasm_contract_function_type( + requirement.params, + requirement.results, + signature_pointer_width, + )); + local_functions.push((requirement.name, type_index, requirement.results)); + } + local_functions.push(("__abi_version", kernel_fork_type, &[I32])); + local_functions.push(("_start", empty_function_type, &[])); + let mut type_section = uleb(types.len() as u32); for ty in types { type_section.extend(ty); } bytes.extend(wasm_section(1, type_section)); - let mut imports = Vec::new(); - if include_kernel_fork { - imports.push(("kernel", "kernel_fork", 0u32)); - } - for name in frame_imports { - let type_index = match *name { - abi::WPK_FORK_FRAME_IMPORT_COMMIT => 2, - abi::WPK_FORK_FRAME_IMPORT_NEXT | abi::WPK_FORK_FRAME_IMPORT_RESERVE => 1, - other => panic!("unknown linked-frame import fixture {other}"), - }; - imports.push((abi::WPK_FORK_FRAME_IMPORT_MODULE, *name, type_index)); - } if !imports.is_empty() { let mut import_section = uleb(imports.len() as u32); for (module, name, type_index) in &imports { @@ -11559,14 +11663,9 @@ wasm = "second.wasm" bytes.extend(wasm_section(2, import_section)); } - // Seven control functions plus __abi_version and _start. Keeping every - // local function present lets negative fixtures remove one export - // without changing function indices or accidentally testing malformed - // Wasm instead of the publication contract. - let function_types = [2u32, 3, 2, 3, 0, 2, 3, 0, 3]; - let mut function_section = uleb(function_types.len() as u32); - for type_index in function_types { - function_section.extend(uleb(type_index)); + let mut function_section = uleb(local_functions.len() as u32); + for (_, type_index, _) in &local_functions { + function_section.extend(uleb(*type_index)); } bytes.extend(wasm_section(3, function_section)); @@ -11577,38 +11676,37 @@ wasm = "second.wasm" }; bytes.extend(wasm_section(5, vec![0x01, memory_flags, 0x01])); - let local_exports = [ - (abi::WPK_FORK_EXPORT_ABORT_BEGIN, 0u32), - (abi::WPK_FORK_EXPORT_ABORT_END, 1), - (abi::WPK_FORK_EXPORT_REWIND_BEGIN, 2), - (abi::WPK_FORK_EXPORT_REWIND_END, 3), - (abi::WPK_FORK_EXPORT_STATE, 4), - (abi::WPK_FORK_EXPORT_UNWIND_BEGIN, 5), - (abi::WPK_FORK_EXPORT_UNWIND_END, 6), - ("__abi_version", 7), - ("_start", 8), - ]; - let exported = local_exports + let exported = local_functions .iter() - .filter(|(name, _)| { + .enumerate() + .filter(|(_, (name, _, _))| { *name == "__abi_version" || *name == "_start" || fork_exports.contains(name) }) .collect::>(); let mut export_section = uleb(exported.len() as u32); - for (name, local_index) in exported { + for (local_index, (name, _, _)) in exported { export_section.extend(wasm_name(name)); export_section.push(0x00); // function export - export_section.extend(uleb(imports.len() as u32 + *local_index)); + export_section.extend(uleb(imports.len() as u32 + local_index as u32)); } bytes.extend(wasm_section(7, export_section)); - let mut code_section = uleb(function_types.len() as u32); - for type_index in function_types { - let body = if type_index == 0 { - vec![0x00, 0x41, descriptor_pointer_width, 0x0b] - } else { - vec![0x00, 0x0b] - }; + if include_native_start { + let start_index = imports.len() as u32 + local_functions.len() as u32 - 1; + bytes.extend(wasm_section(8, uleb(start_index))); + } + + let mut code_section = uleb(local_functions.len() as u32); + for (_, _, results) in local_functions { + let mut body = vec![0x00]; // no local declarations + for result in results { + match wasm_contract_value_type(*result, signature_pointer_width) { + 0x7f => body.extend([0x41, 0x00]), // i32.const 0 + 0x7e => body.extend([0x42, 0x00]), // i64.const 0 + heap_type => body.extend([0xd0, heap_type]), // ref.null + } + } + body.push(0x0b); code_section.extend(uleb(body.len() as u32)); code_section.extend(body); } @@ -11639,6 +11737,8 @@ wasm = "second.wasm" abi::WPK_FORK_CAPABILITIES_VERSION, abi::WPK_FORK_CAP_ACTIVATION_STATE_SAFE, ]], + false, + false, ) } @@ -12463,6 +12563,19 @@ version = "0.1.0" assert!(error.contains("outside the producer checkout"), "{error}"); } + #[test] + fn fork_instrument_dependency_metadata_is_not_build_host_filtered() { + assert_eq!( + FORK_INSTRUMENT_CARGO_METADATA_ARGS, + ["metadata", "--format-version=1", "--locked"], + "shared package cache keys must hash Cargo's cross-host dependency union" + ); + assert!( + !FORK_INSTRUMENT_CARGO_METADATA_ARGS.contains(&"--filter-platform"), + "a host-filtered dependency graph gives macOS and Linux different package identities" + ); + } + #[test] fn fork_instrument_cargo_dependency_digest_ignores_unrelated_lockfile_entries() { let root = tempdir("fork-cargo-closure"); @@ -12783,7 +12896,8 @@ index_url = "https://example.test/releases/download/binaries-abi-v{abi}/index.to .unwrap_err(); assert!( missing_selected_input.contains("first-hit registry package") - && missing_selected_input.contains("lower-priority package roots were not consulted"), + && missing_selected_input + .contains("lower-priority package roots were not consulted"), "a selected external package must not be completed with a lower package's file: {missing_selected_input}", ); @@ -18887,6 +19001,8 @@ wasm = "bad.wasm" &exports, &[linked_frame_descriptor(4)], &capabilities, + false, + false, ); let failures = wasm_artifact_policy_failures(&bytes, ForkInstrumentationPolicy::Auto); assert!( @@ -18896,6 +19012,82 @@ wasm = "bad.wasm" } } + #[test] + fn program_artifact_policy_rejects_reentrant_legacy_loader_claims() { + use wasm_posix_shared::abi; + + let imports = abi::WPK_FORK_REQUIRED_IMPORTS + .iter() + .map(|requirement| requirement.name) + .collect::>(); + let exports = abi::WPK_FORK_REQUIRED_EXPORTS + .iter() + .map(|requirement| requirement.name) + .collect::>(); + let bytes = wasm_fork_artifact_with_capabilities( + 4, + 4, + 4, + true, + &imports, + &exports, + &[linked_frame_descriptor(4)], + &[vec![ + abi::WPK_FORK_CAPABILITIES_VERSION, + abi::WPK_FORK_CAP_ACTIVATION_STATE_SAFE, + ]], + true, + false, + ); + let failures = + wasm_artifact_policy_failures(&bytes, ForkInstrumentationPolicy::Auto); + assert!( + failures.iter().any(|failure| { + failure.contains("reentrant env.__wasm_dlopen") + && failure.contains("staged loader lowering") + }), + "got: {failures:?}", + ); + } + + #[test] + fn program_artifact_policy_rejects_native_start_claims() { + use wasm_posix_shared::abi; + + let imports = abi::WPK_FORK_REQUIRED_IMPORTS + .iter() + .map(|requirement| requirement.name) + .collect::>(); + let exports = abi::WPK_FORK_REQUIRED_EXPORTS + .iter() + .map(|requirement| requirement.name) + .collect::>(); + let bytes = wasm_fork_artifact_with_capabilities( + 4, + 4, + 4, + true, + &imports, + &exports, + &[linked_frame_descriptor(4)], + &[vec![ + abi::WPK_FORK_CAPABILITIES_VERSION, + abi::WPK_FORK_CAP_ACTIVATION_STATE_SAFE, + ]], + false, + true, + ); + let failures = + wasm_artifact_policy_failures(&bytes, ForkInstrumentationPolicy::Auto); + assert!( + failures.iter().any(|failure| { + failure.contains("native Wasm start section") + && failure.contains("wpk_fork_module_bootstrap") + }), + "got: {failures:?}", + ); + } + #[test] fn program_artifact_policy_rejects_pointer_width_signature_drift() { let imports = wasm_posix_shared::abi::WPK_FORK_REQUIRED_IMPORTS @@ -20152,16 +20344,20 @@ libs = ["lib/libF3b.a"] #[test] fn extract_source_repo_root_flag_rejects_missing_or_duplicate_values() { - assert!(extract_source_repo_root_flag(vec!["--source-repo-root".into()]) + assert!( + extract_source_repo_root_flag(vec!["--source-repo-root".into()]) + .unwrap_err() + .contains("requires a path") + ); + assert!( + extract_source_repo_root_flag(vec![ + "--source-repo-root=/a".into(), + "--source-repo-root".into(), + "/b".into(), + ]) .unwrap_err() - .contains("requires a path")); - assert!(extract_source_repo_root_flag(vec![ - "--source-repo-root=/a".into(), - "--source-repo-root".into(), - "/b".into(), - ]) - .unwrap_err() - .contains("more than once")); + .contains("more than once") + ); } #[test] @@ -20230,19 +20426,14 @@ libs = ["lib/libF3b.a"] fs::write(first.join("identity.txt"), "first source projection").unwrap(); fs::write(second.join("identity.txt"), "second source projection").unwrap(); let cache: RootDigestCache = OnceLock::new(); - let compute = |root: &Path| { - global_package_build_input_digests_for(root, &["identity.txt"]) - }; + let compute = |root: &Path| global_package_build_input_digests_for(root, &["identity.txt"]); - let first_digest = - root_scoped_build_input_digests(&cache, &first, compute).unwrap(); - let second_digest = - root_scoped_build_input_digests(&cache, &second, compute).unwrap(); + let first_digest = root_scoped_build_input_digests(&cache, &first, compute).unwrap(); + let second_digest = root_scoped_build_input_digests(&cache, &second, compute).unwrap(); assert_ne!(first_digest[0].digest, second_digest[0].digest); fs::write(first.join("identity.txt"), "changed after memoization").unwrap(); - let first_cached = - root_scoped_build_input_digests(&cache, &first, compute).unwrap(); + let first_cached = root_scoped_build_input_digests(&cache, &first, compute).unwrap(); assert_eq!(first_cached[0].digest, first_digest[0].digest); } diff --git a/tools/xtask/src/build_index.rs b/tools/xtask/src/build_index.rs index 0d8e59a3b5..224dd5e37e 100644 --- a/tools/xtask/src/build_index.rs +++ b/tools/xtask/src/build_index.rs @@ -505,12 +505,7 @@ build_timestamp = "2026-05-05T12:34:56Z" .as_ref() .expect("test archive manifest must have compatibility") .target_arch; - let fname = crate::package_archive_name::render( - &manifest, - target_arch, - abi, - cache_key_sha, - ); + let fname = crate::package_archive_name::render(&manifest, target_arch, abi, cache_key_sha); let path = dir.join(&fname); fs::write(&path, &bytes).unwrap(); path @@ -535,42 +530,10 @@ build_timestamp = "2026-05-05T12:34:56Z" fs::create_dir_all(&archives).unwrap(); let out = dir.join("index.toml"); - write_real_archive( - &archives, - "alpha", - "1.0.0", - 1, - 6, - "wasm32", - &"a".repeat(64), - ); - write_real_archive( - &archives, - "alpha", - "1.0.0", - 1, - 6, - "wasm64", - &"b".repeat(64), - ); - write_real_archive( - &archives, - "beta", - "2.0.0", - 1, - 6, - "wasm32", - &"c".repeat(64), - ); - write_real_archive( - &archives, - "beta", - "2.0.0", - 1, - 6, - "wasm64", - &"d".repeat(64), - ); + write_real_archive(&archives, "alpha", "1.0.0", 1, 6, "wasm32", &"a".repeat(64)); + write_real_archive(&archives, "alpha", "1.0.0", 1, 6, "wasm64", &"b".repeat(64)); + write_real_archive(&archives, "beta", "2.0.0", 1, 6, "wasm32", &"c".repeat(64)); + write_real_archive(&archives, "beta", "2.0.0", 1, 6, "wasm64", &"d".repeat(64)); super::run(vec![ "--abi".into(), @@ -671,15 +634,7 @@ build_timestamp = "2026-05-05T12:34:56Z" fs::create_dir_all(&archives).unwrap(); let out = dir.join("index.toml"); - write_real_archive( - &archives, - "solo", - "1.0.0", - 1, - 6, - "wasm32", - &"e".repeat(64), - ); + write_real_archive(&archives, "solo", "1.0.0", 1, 6, "wasm32", &"e".repeat(64)); super::run(vec![ "--abi".into(), @@ -716,33 +671,9 @@ build_timestamp = "2026-05-05T12:34:56Z" let archives = dir.join("archives"); fs::create_dir_all(&archives).unwrap(); - write_real_archive( - &archives, - "alpha", - "1.0.0", - 1, - 6, - "wasm32", - &"a".repeat(64), - ); - write_real_archive( - &archives, - "alpha", - "1.0.0", - 1, - 6, - "wasm64", - &"b".repeat(64), - ); - write_real_archive( - &archives, - "beta", - "2.3.4", - 7, - 6, - "wasm32", - &"c".repeat(64), - ); + write_real_archive(&archives, "alpha", "1.0.0", 1, 6, "wasm32", &"a".repeat(64)); + write_real_archive(&archives, "alpha", "1.0.0", 1, 6, "wasm64", &"b".repeat(64)); + write_real_archive(&archives, "beta", "2.3.4", 7, 6, "wasm32", &"c".repeat(64)); let common = |out: PathBuf| { super::run(vec![ @@ -782,24 +713,8 @@ build_timestamp = "2026-05-05T12:34:56Z" fs::create_dir_all(&archives).unwrap(); let out = dir.join("index.toml"); - write_real_archive( - &archives, - "x", - "1.0.0", - 1, - 6, - "wasm32", - &"a".repeat(64), - ); - write_real_archive( - &archives, - "x", - "1.0.1", - 1, - 6, - "wasm64", - &"b".repeat(64), - ); + write_real_archive(&archives, "x", "1.0.0", 1, 6, "wasm32", &"a".repeat(64)); + write_real_archive(&archives, "x", "1.0.1", 1, 6, "wasm64", &"b".repeat(64)); let err = super::run(vec![ "--abi".into(), @@ -823,15 +738,7 @@ build_timestamp = "2026-05-05T12:34:56Z" fs::create_dir_all(&archives).unwrap(); let out = dir.join("index.toml"); - write_real_archive( - &archives, - "x", - "1.0.0", - 1, - 5, - "wasm32", - &"a".repeat(64), - ); + write_real_archive(&archives, "x", "1.0.0", 1, 5, "wasm32", &"a".repeat(64)); let err = super::run(vec![ "--abi".into(), @@ -902,10 +809,8 @@ build_timestamp = "2026-05-05T12:34:56Z" fs::create_dir_all(&archives).unwrap(); let out = dir.join("index.toml"); - let first_key = - "0f5290453e6ea7f68e5ee1e50bd6dbf23221368e7aeb7a54c34953cef453920d"; - let second_key = - "a88651d0cd72a9100a67c90fa4b5600659258b10890c852ff10ab125cf770212"; + let first_key = "0f5290453e6ea7f68e5ee1e50bd6dbf23221368e7aeb7a54c34953cef453920d"; + let second_key = "a88651d0cd72a9100a67c90fa4b5600659258b10890c852ff10ab125cf770212"; let first = write_real_archive( &archives, "spidermonkey-node", @@ -952,7 +857,10 @@ build_timestamp = "2026-05-05T12:34:56Z" ] { assert!(err.contains(value), "missing {value:?} from: {err}"); } - assert!(!out.exists(), "a rejected inventory must not write an index"); + assert!( + !out.exists(), + "a rejected inventory must not write an index" + ); } #[test] diff --git a/tools/xtask/src/dump_abi.rs b/tools/xtask/src/dump_abi.rs index e513e70a9b..b6213784a8 100644 --- a/tools/xtask/src/dump_abi.rs +++ b/tools/xtask/src/dump_abi.rs @@ -249,6 +249,500 @@ fn render_ts_module() -> String { )); } out.push_str("] as const;\n"); + out.push_str(&format!( + "export const WPK_FORK_MODULE_STATE_FORMAT_SECTION = {:?} as const;\n", + shared::abi::WPK_FORK_MODULE_STATE_FORMAT_SECTION + )); + out.push_str(&format!( + "export const WPK_FORK_MODULE_STATE_FORMAT_VERSION = {} as const;\n", + shared::abi::WPK_FORK_MODULE_STATE_FORMAT_VERSION + )); + out.push_str(&format!( + "export const WPK_FORK_MODULE_STATE_FORMAT_MAGIC = {:?} as const;\n", + shared::abi::WPK_FORK_MODULE_STATE_FORMAT_MAGIC + )); + out.push_str(&format!( + "export const WPK_FORK_MODULE_STATE_DESCRIPTOR_SIZE = {} as const;\n", + shared::abi::WPK_FORK_MODULE_STATE_DESCRIPTOR_SIZE + )); + out.push_str(&format!( + "export const WPK_FORK_MODULE_STATE_RECORD_ALIGNMENT = {} as const;\n", + shared::abi::WPK_FORK_MODULE_STATE_RECORD_ALIGNMENT + )); + out.push_str(&format!( + "export const WPK_FORK_MODULE_STATE_FLAG_ROOT_PREFIX_POINTER = {} as const;\n", + shared::abi::WPK_FORK_MODULE_STATE_FLAG_ROOT_PREFIX_POINTER + )); + out.push_str(&format!( + "export const WPK_FORK_MODULE_STATE_FLAG_EXPLICIT_OWNERS = {} as const;\n", + shared::abi::WPK_FORK_MODULE_STATE_FLAG_EXPLICIT_OWNERS + )); + out.push_str(&format!( + "export const WPK_FORK_MODULE_STATE_FLAG_SPARSE_TABLES = {} as const;\n", + shared::abi::WPK_FORK_MODULE_STATE_FLAG_SPARSE_TABLES + )); + out.push_str(&format!( + "export const WPK_FORK_MODULE_STATE_REQUIRED_FLAGS = {} as const;\n", + shared::abi::WPK_FORK_MODULE_STATE_REQUIRED_FLAGS + )); + out.push_str(&format!( + "export const WPK_FORK_MODULE_STATE_KNOWN_FLAGS = {} as const;\n", + shared::abi::WPK_FORK_MODULE_STATE_KNOWN_FLAGS + )); + out.push_str(&format!( + "export const WPK_FORK_MODULE_STATE_ARENA_VERSION = {} as const;\n", + shared::abi::WPK_FORK_MODULE_STATE_ARENA_VERSION + )); + out.push_str(&format!( + "export const WPK_FORK_MODULE_STATE_RECORD_VERSION = {} as const;\n", + shared::abi::WPK_FORK_MODULE_STATE_RECORD_VERSION + )); + out.push_str(&format!( + "export const WPK_FORK_MODULE_STATE_ROOT_POINTER_WORD_OFFSET = {} as const;\n", + shared::abi::WPK_FORK_MODULE_STATE_ROOT_POINTER_WORD_OFFSET + )); + out.push_str(&format!( + "export const WPK_FORK_MODULE_STATE_CHUNK_MAGIC = {:?} as const;\n", + shared::abi::WPK_FORK_MODULE_STATE_CHUNK_MAGIC + )); + out.push_str(&format!( + "export const WPK_FORK_MODULE_STATE_CHUNK_FLAG_ROOT = {} as const;\n", + shared::abi::WPK_FORK_MODULE_STATE_CHUNK_FLAG_ROOT + )); + out.push_str(&format!( + "export const WPK_FORK_MODULE_STATE_CHUNK_FLAG_SEALED = {} as const;\n", + shared::abi::WPK_FORK_MODULE_STATE_CHUNK_FLAG_SEALED + )); + out.push_str(&format!( + "export const WPK_FORK_MODULE_STATE_CHUNK_KNOWN_FLAGS = {} as const;\n", + shared::abi::WPK_FORK_MODULE_STATE_CHUNK_KNOWN_FLAGS + )); + out.push_str(&format!( + "export const WPK_FORK_MODULE_STATE_RECORD_MAGIC = {:?} as const;\n", + shared::abi::WPK_FORK_MODULE_STATE_RECORD_MAGIC + )); + out.push_str(&format!( + "export const WPK_FORK_MODULE_STATE_RECORD_HEADER_SIZE = {} as const;\n", + shared::abi::WPK_FORK_MODULE_STATE_RECORD_HEADER_SIZE + )); + for (name, value) in [ + ( + "MODULE", + shared::abi::WPK_FORK_MODULE_STATE_RECORD_KIND_MODULE, + ), + ( + "REFERENCE_RECIPE", + shared::abi::WPK_FORK_MODULE_STATE_RECORD_KIND_REFERENCE_RECIPE, + ), + ( + "MUTABLE_GLOBAL", + shared::abi::WPK_FORK_MODULE_STATE_RECORD_KIND_MUTABLE_GLOBAL, + ), + ( + "TABLE", + shared::abi::WPK_FORK_MODULE_STATE_RECORD_KIND_TABLE, + ), + ( + "TABLE_PAGE", + shared::abi::WPK_FORK_MODULE_STATE_RECORD_KIND_TABLE_PAGE, + ), + ( + "ELEMENT_SEGMENTS", + shared::abi::WPK_FORK_MODULE_STATE_RECORD_KIND_ELEMENT_SEGMENTS, + ), + ( + "DATA_SEGMENTS", + shared::abi::WPK_FORK_MODULE_STATE_RECORD_KIND_DATA_SEGMENTS, + ), + ( + "REPLAY_EVENTS", + shared::abi::WPK_FORK_MODULE_STATE_RECORD_KIND_REPLAY_EVENTS, + ), + ( + "IMPORTED_GLOBAL_BINDINGS", + shared::abi::WPK_FORK_MODULE_STATE_RECORD_KIND_IMPORTED_GLOBAL_BINDINGS, + ), + ( + "ACTIVATION_CONTINUATIONS", + shared::abi::WPK_FORK_MODULE_STATE_RECORD_KIND_ACTIVATION_CONTINUATIONS, + ), + ( + "IMPORTED_TABLE_BINDINGS", + shared::abi::WPK_FORK_MODULE_STATE_RECORD_KIND_IMPORTED_TABLE_BINDINGS, + ), + ( + "REFERENCE_RECIPE_SEGMENT", + shared::abi::WPK_FORK_MODULE_STATE_RECORD_KIND_REFERENCE_RECIPE_SEGMENT, + ), + ( + "REPLAY_EVENT_SEGMENT", + shared::abi::WPK_FORK_MODULE_STATE_RECORD_KIND_REPLAY_EVENT_SEGMENT, + ), + ] { + out.push_str(&format!( + "export const WPK_FORK_MODULE_STATE_RECORD_KIND_{name} = {value} as const;\n" + )); + } + out.push_str("export const WPK_FORK_MODULE_STATE_RECORD_KINDS = [\n"); + for kind in shared::abi::WPK_FORK_MODULE_STATE_RECORD_KINDS { + out.push_str(&format!( + " {{ number: {}, name: {:?} }},\n", + kind.number, kind.name + )); + } + out.push_str("] as const;\n"); + out.push_str("export const WPK_FORK_MODULE_STATE_POINTER_WIDTHS = [\n"); + for pointer_width in shared::abi::WPK_FORK_MODULE_STATE_POINTER_WIDTHS { + out.push_str(&format!( + " {{ bytes: {}, chunkHeaderSize: {} }},\n", + pointer_width, + shared::abi::wpk_fork_module_state_chunk_header_size(*pointer_width) + .expect("supported pointer width must have a module-state chunk header"), + )); + } + out.push_str("] as const;\n"); + out.push_str(&format!( + "export const WPK_FORK_MODULE_STATE_REPLAY_EVENTS_MAGIC = {:?} as const;\n", + shared::abi::WPK_FORK_MODULE_STATE_REPLAY_EVENTS_MAGIC, + )); + out.push_str(&format!( + "export const WPK_FORK_REFERENCE_TRANSACTION_MAGIC = {:?} as const;\n", + shared::abi::WPK_FORK_REFERENCE_TRANSACTION_MAGIC, + )); + out.push_str(&format!( + "export const WPK_FORK_REFERENCE_SEGMENT_MAGIC = {:?} as const;\n", + shared::abi::WPK_FORK_REFERENCE_SEGMENT_MAGIC, + )); + for (name, value) in [ + ( + "TRANSACTION_OWNER", + shared::abi::WPK_FORK_REFERENCE_TRANSACTION_OWNER, + ), + ( + "TRANSACTION_VERSION", + u32::from(shared::abi::WPK_FORK_REFERENCE_TRANSACTION_VERSION), + ), + ( + "TRANSACTION_MANIFEST_SIZE", + u32::from(shared::abi::WPK_FORK_REFERENCE_TRANSACTION_MANIFEST_SIZE), + ), + ( + "TRANSACTION_FLAG_SEALED", + shared::abi::WPK_FORK_REFERENCE_TRANSACTION_FLAG_SEALED, + ), + ( + "TRANSACTION_KNOWN_FLAGS", + shared::abi::WPK_FORK_REFERENCE_TRANSACTION_KNOWN_FLAGS, + ), + ( + "SEGMENT_HEADER_SIZE", + u32::from(shared::abi::WPK_FORK_REFERENCE_SEGMENT_HEADER_SIZE), + ), + ( + "SEGMENT_KNOWN_FLAGS", + u32::from(shared::abi::WPK_FORK_REFERENCE_SEGMENT_KNOWN_FLAGS), + ), + ( + "NODE_RECORD_SIZE", + u32::from(shared::abi::WPK_FORK_REFERENCE_NODE_RECORD_SIZE), + ), + ( + "VECTOR_INDEX_SIZE", + u32::from(shared::abi::WPK_FORK_REFERENCE_VECTOR_INDEX_SIZE), + ), + ( + "SECTION_NODES", + u32::from(shared::abi::WPK_FORK_REFERENCE_SECTION_NODES), + ), + ( + "SECTION_EDGES", + u32::from(shared::abi::WPK_FORK_REFERENCE_SECTION_EDGES), + ), + ( + "SECTION_SCALARS", + u32::from(shared::abi::WPK_FORK_REFERENCE_SECTION_SCALARS), + ), + ( + "SECTION_VECTOR_INDEX", + u32::from(shared::abi::WPK_FORK_REFERENCE_SECTION_VECTOR_INDEX), + ), + ( + "SECTION_VECTOR_ENTRIES", + u32::from(shared::abi::WPK_FORK_REFERENCE_SECTION_VECTOR_ENTRIES), + ), + ] { + out.push_str(&format!( + "export const WPK_FORK_REFERENCE_{name} = {value} as const;\n" + )); + } + for (name, value) in [ + ( + "MODULE_TEMPLATE_ID_SIZE", + u32::from(shared::abi::WPK_FORK_MODULE_STATE_MODULE_TEMPLATE_ID_SIZE), + ), + ( + "MODULE_RECORD_PAYLOAD_SIZE", + u32::from(shared::abi::WPK_FORK_MODULE_STATE_MODULE_RECORD_PAYLOAD_SIZE), + ), + ( + "MODULE_RECORD_KNOWN_FLAGS", + shared::abi::WPK_FORK_MODULE_STATE_MODULE_RECORD_KNOWN_FLAGS, + ), + ( + "GLOBAL_HEADER_SIZE", + u32::from(shared::abi::WPK_FORK_MODULE_STATE_GLOBAL_HEADER_SIZE), + ), + ( + "TABLE_BASELINE_FINGERPRINT_SIZE", + u32::from(shared::abi::WPK_FORK_MODULE_STATE_TABLE_BASELINE_FINGERPRINT_SIZE), + ), + ( + "TABLE_DESCRIPTOR_PAYLOAD_SIZE", + u32::from(shared::abi::WPK_FORK_MODULE_STATE_TABLE_DESCRIPTOR_PAYLOAD_SIZE), + ), + ( + "TABLE_FLAG_SPARSE_OVERRIDES", + shared::abi::WPK_FORK_MODULE_STATE_TABLE_FLAG_SPARSE_OVERRIDES, + ), + ( + "TABLE_KNOWN_FLAGS", + shared::abi::WPK_FORK_MODULE_STATE_TABLE_KNOWN_FLAGS, + ), + ( + "TABLE_PAGE_HEADER_SIZE", + u32::from(shared::abi::WPK_FORK_MODULE_STATE_TABLE_PAGE_HEADER_SIZE), + ), + ( + "TABLE_RUN_HEADER_SIZE", + u32::from(shared::abi::WPK_FORK_MODULE_STATE_TABLE_RUN_HEADER_SIZE), + ), + ( + "ELEMENT_SEGMENT_HEADER_SIZE", + u32::from(shared::abi::WPK_FORK_MODULE_STATE_ELEMENT_SEGMENT_HEADER_SIZE), + ), + ( + "DATA_SEGMENT_HEADER_SIZE", + u32::from(shared::abi::WPK_FORK_MODULE_STATE_DATA_SEGMENT_HEADER_SIZE), + ), + ( + "REPLAY_EVENTS_OWNER", + shared::abi::WPK_FORK_MODULE_STATE_REPLAY_EVENTS_OWNER, + ), + ( + "REPLAY_EVENTS_VERSION", + u32::from(shared::abi::WPK_FORK_MODULE_STATE_REPLAY_EVENTS_VERSION), + ), + ( + "REPLAY_EVENTS_HEADER_SIZE", + u32::from(shared::abi::WPK_FORK_MODULE_STATE_REPLAY_EVENTS_HEADER_SIZE), + ), + ( + "REPLAY_EVENT_SIZE", + u32::from(shared::abi::WPK_FORK_MODULE_STATE_REPLAY_EVENT_SIZE), + ), + ( + "REPLAY_EVENTS_KNOWN_FLAGS", + u32::from(shared::abi::WPK_FORK_MODULE_STATE_REPLAY_EVENTS_KNOWN_FLAGS), + ), + ( + "REPLAY_EVENT_SEGMENT_VERSION", + u32::from(shared::abi::WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_VERSION), + ), + ( + "REPLAY_EVENT_SEGMENT_HEADER_SIZE", + u32::from(shared::abi::WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_HEADER_SIZE), + ), + ( + "REPLAY_EVENT_SEGMENT_CAPACITY", + shared::abi::WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_CAPACITY, + ), + ( + "REPLAY_EVENT_SEGMENT_KNOWN_FLAGS", + u32::from(shared::abi::WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_KNOWN_FLAGS), + ), + ( + "MIN_TABLE_PAGE_SHIFT", + u32::from(shared::abi::WPK_FORK_MODULE_STATE_MIN_TABLE_PAGE_SHIFT), + ), + ( + "MAX_TABLE_PAGE_SHIFT", + u32::from(shared::abi::WPK_FORK_MODULE_STATE_MAX_TABLE_PAGE_SHIFT), + ), + ( + "TABLE_PAGE_SHIFT", + u32::from(shared::abi::WPK_FORK_MODULE_STATE_TABLE_PAGE_SHIFT), + ), + ] { + out.push_str(&format!( + "export const WPK_FORK_MODULE_STATE_{name} = {value} as const;\n" + )); + } + out.push_str(&format!( + "export const WPK_FORK_IMPORTED_GLOBAL_BINDINGS_MAGIC = {:?} as const;\n", + shared::abi::WPK_FORK_IMPORTED_GLOBAL_BINDINGS_MAGIC, + )); + for (name, value) in [ + ( + "OWNER", + shared::abi::WPK_FORK_IMPORTED_GLOBAL_BINDINGS_OWNER, + ), + ( + "VERSION", + u32::from(shared::abi::WPK_FORK_IMPORTED_GLOBAL_BINDINGS_VERSION), + ), + ( + "HEADER_SIZE", + u32::from(shared::abi::WPK_FORK_IMPORTED_GLOBAL_BINDINGS_HEADER_SIZE), + ), + ( + "ENTRY_SIZE", + u32::from(shared::abi::WPK_FORK_IMPORTED_GLOBAL_BINDINGS_ENTRY_SIZE), + ), + ( + "KNOWN_FLAGS", + u32::from(shared::abi::WPK_FORK_IMPORTED_GLOBAL_BINDINGS_KNOWN_FLAGS), + ), + ] { + out.push_str(&format!( + "export const WPK_FORK_IMPORTED_GLOBAL_BINDINGS_{name} = {value} as const;\n" + )); + } + for (name, value) in [ + ( + "RAW_NUMBER", + shared::abi::WPK_FORK_IMPORTED_GLOBAL_BINDING_RAW_NUMBER, + ), + ( + "RAW_BIGINT", + shared::abi::WPK_FORK_IMPORTED_GLOBAL_BINDING_RAW_BIGINT, + ), + ( + "RAW_REFERENCE", + shared::abi::WPK_FORK_IMPORTED_GLOBAL_BINDING_RAW_REFERENCE, + ), + ( + "ACTIVATION_GLOBAL", + shared::abi::WPK_FORK_IMPORTED_GLOBAL_BINDING_ACTIVATION_GLOBAL, + ), + ( + "BASE_IMPORT", + shared::abi::WPK_FORK_IMPORTED_GLOBAL_BINDING_BASE_IMPORT, + ), + ] { + out.push_str(&format!( + "export const WPK_FORK_IMPORTED_GLOBAL_BINDING_{name} = {value} as const;\n" + )); + } + out.push_str(&format!( + "export const WPK_FORK_GLOBAL_CATALOG_EXPORT_PREFIX = {:?} as const;\n", + shared::abi::WPK_FORK_GLOBAL_CATALOG_EXPORT_PREFIX, + )); + out.push_str(&format!( + "export const WPK_FORK_ACTIVATION_CONTINUATIONS_MAGIC = {:?} as const;\n", + shared::abi::WPK_FORK_ACTIVATION_CONTINUATIONS_MAGIC, + )); + for (name, value) in [ + ( + "OWNER", + u32::from(shared::abi::WPK_FORK_ACTIVATION_CONTINUATIONS_OWNER), + ), + ( + "VERSION", + u32::from(shared::abi::WPK_FORK_ACTIVATION_CONTINUATIONS_VERSION), + ), + ( + "HEADER_SIZE", + u32::from(shared::abi::WPK_FORK_ACTIVATION_CONTINUATIONS_HEADER_SIZE), + ), + ( + "ENTRY_SIZE", + u32::from(shared::abi::WPK_FORK_ACTIVATION_CONTINUATION_ENTRY_SIZE), + ), + ( + "KNOWN_FLAGS", + u32::from(shared::abi::WPK_FORK_ACTIVATION_CONTINUATIONS_KNOWN_FLAGS), + ), + ( + "ENTRY_KNOWN_FLAGS", + shared::abi::WPK_FORK_ACTIVATION_CONTINUATION_ENTRY_KNOWN_FLAGS, + ), + ] { + out.push_str(&format!( + "export const WPK_FORK_ACTIVATION_CONTINUATIONS_{name} = {value} as const;\n" + )); + } + out.push_str(&format!( + "export const WPK_FORK_IMPORTED_TABLE_BINDINGS_MAGIC = {:?} as const;\n", + shared::abi::WPK_FORK_IMPORTED_TABLE_BINDINGS_MAGIC, + )); + for (name, value) in [ + ("OWNER", shared::abi::WPK_FORK_IMPORTED_TABLE_BINDINGS_OWNER), + ( + "VERSION", + u32::from(shared::abi::WPK_FORK_IMPORTED_TABLE_BINDINGS_VERSION), + ), + ( + "HEADER_SIZE", + u32::from(shared::abi::WPK_FORK_IMPORTED_TABLE_BINDINGS_HEADER_SIZE), + ), + ( + "ENTRY_SIZE", + u32::from(shared::abi::WPK_FORK_IMPORTED_TABLE_BINDINGS_ENTRY_SIZE), + ), + ( + "KNOWN_FLAGS", + u32::from(shared::abi::WPK_FORK_IMPORTED_TABLE_BINDINGS_KNOWN_FLAGS), + ), + ] { + out.push_str(&format!( + "export const WPK_FORK_IMPORTED_TABLE_BINDINGS_{name} = {value} as const;\n" + )); + } + for (name, value) in [ + ( + "ACTIVATION_TABLE", + shared::abi::WPK_FORK_IMPORTED_TABLE_BINDING_ACTIVATION_TABLE, + ), + ( + "BASE_IMPORT", + shared::abi::WPK_FORK_IMPORTED_TABLE_BINDING_BASE_IMPORT, + ), + ] { + out.push_str(&format!( + "export const WPK_FORK_IMPORTED_TABLE_BINDING_{name} = {value} as const;\n" + )); + } + out.push_str(&format!( + "export const WPK_FORK_TABLE_CATALOG_EXPORT_PREFIX = {:?} as const;\n", + shared::abi::WPK_FORK_TABLE_CATALOG_EXPORT_PREFIX, + )); + for (name, value) in [ + ("I32", shared::abi::WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I32), + ("I64", shared::abi::WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I64), + ("F32", shared::abi::WPK_FORK_MODULE_STATE_GLOBAL_TYPE_F32), + ("F64", shared::abi::WPK_FORK_MODULE_STATE_GLOBAL_TYPE_F64), + ("V128", shared::abi::WPK_FORK_MODULE_STATE_GLOBAL_TYPE_V128), + ( + "FUNCREF", + shared::abi::WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, + ), + ( + "EXTERNREF", + shared::abi::WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + ), + ( + "EXNREF", + shared::abi::WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF, + ), + ( + "ANYREF", + shared::abi::WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF, + ), + ] { + out.push_str(&format!( + "export const WPK_FORK_MODULE_STATE_GLOBAL_TYPE_{name} = {value} as const;\n" + )); + } out.push_str(&format!( "export const WPK_FORK_CAPABILITIES_SECTION = {:?} as const;\n", shared::abi::WPK_FORK_CAPABILITIES_SECTION @@ -277,6 +771,475 @@ fn render_ts_module() -> String { "export const WPK_FORK_CAP_REQUIRED_FLAGS = {} as const;\n", shared::abi::WPK_FORK_CAP_REQUIRED_FLAGS )); + out.push_str(&format!( + "export const WPK_FORK_EXCEPTION_CODEC_SECTION = {:?} as const;\n", + shared::abi::WPK_FORK_EXCEPTION_CODEC_SECTION + )); + for (name, value) in [ + ( + "VERSION", + u32::from(shared::abi::WPK_FORK_EXCEPTION_CODEC_VERSION), + ), + ( + "HEADER_SIZE", + u32::from(shared::abi::WPK_FORK_EXCEPTION_CODEC_HEADER_SIZE), + ), + ( + "TAG_RECORD_SIZE", + u32::from(shared::abi::WPK_FORK_EXCEPTION_CODEC_TAG_RECORD_SIZE), + ), + ] { + out.push_str(&format!( + "export const WPK_FORK_EXCEPTION_CODEC_{name} = {value} as const;\n" + )); + } + out.push_str(&format!( + "export const WPK_FORK_GC_CODEC_SECTION = {:?} as const;\n", + shared::abi::WPK_FORK_GC_CODEC_SECTION + )); + out.push_str(&format!( + "export const WPK_FORK_GC_CODEC_MAGIC = {:?} as const;\n", + shared::abi::WPK_FORK_GC_CODEC_MAGIC, + )); + for (name, value) in [ + ("VERSION", u32::from(shared::abi::WPK_FORK_GC_CODEC_VERSION)), + ( + "HEADER_SIZE", + u32::from(shared::abi::WPK_FORK_GC_CODEC_HEADER_SIZE), + ), + ( + "LAYOUT_RECORD_SIZE", + u32::from(shared::abi::WPK_FORK_GC_CODEC_LAYOUT_RECORD_SIZE), + ), + ( + "FIELD_RECORD_SIZE", + u32::from(shared::abi::WPK_FORK_GC_CODEC_FIELD_RECORD_SIZE), + ), + ] { + out.push_str(&format!( + "export const WPK_FORK_GC_CODEC_{name} = {value} as const;\n" + )); + } + for (name, value) in [ + ( + "WPK_FORK_UNWIND_TAG_IMPORT_MODULE", + shared::abi::WPK_FORK_UNWIND_TAG_IMPORT_MODULE, + ), + ( + "WPK_FORK_UNWIND_TAG_IMPORT_NAME", + shared::abi::WPK_FORK_UNWIND_TAG_IMPORT_NAME, + ), + ( + "WPK_FORK_UNWIND_TRANSPORT_SECTION", + shared::abi::WPK_FORK_UNWIND_TRANSPORT_SECTION, + ), + ( + "WPK_FORK_STATIC_ROOT_CATALOG_EXPORT", + shared::abi::WPK_FORK_STATIC_ROOT_CATALOG_EXPORT, + ), + ( + "WPK_FORK_STATIC_ROOT_CATALOG_SECTION", + shared::abi::WPK_FORK_STATIC_ROOT_CATALOG_SECTION, + ), + ( + "WPK_FORK_STATIC_ROOT_HARVEST_EXPORT", + shared::abi::WPK_FORK_STATIC_ROOT_HARVEST_EXPORT, + ), + ] { + out.push_str(&format!("export const {name} = {value:?} as const;\n")); + } + for (name, value) in [ + ( + "WPK_FORK_UNWIND_TRANSPORT_VERSION", + u32::from(shared::abi::WPK_FORK_UNWIND_TRANSPORT_VERSION), + ), + ( + "WPK_FORK_UNWIND_TRANSPORT_PAYLOAD_ARITY", + u32::from(shared::abi::WPK_FORK_UNWIND_TRANSPORT_PAYLOAD_ARITY), + ), + ( + "WPK_FORK_STATIC_ROOT_CATALOG_VERSION", + u32::from(shared::abi::WPK_FORK_STATIC_ROOT_CATALOG_VERSION), + ), + ( + "WPK_FORK_STATIC_ROOT_CATALOG_HEADER_SIZE", + u32::from(shared::abi::WPK_FORK_STATIC_ROOT_CATALOG_HEADER_SIZE), + ), + ] { + out.push_str(&format!("export const {name} = {value} as const;\n")); + } + out.push_str(&format!( + "export const WPK_FORK_STATIC_ROOT_CATALOG_MAGIC = {:?} as const;\n", + shared::abi::WPK_FORK_STATIC_ROOT_CATALOG_MAGIC, + )); + for (name, value) in [ + ( + "WPK_FORK_IMPORTED_GLOBALS_SECTION", + shared::abi::WPK_FORK_IMPORTED_GLOBALS_SECTION, + ), + ( + "WPK_FORK_FRAME_IMPORT_COMMIT", + shared::abi::WPK_FORK_FRAME_IMPORT_COMMIT, + ), + ( + "WPK_FORK_FRAME_IMPORT_NEXT", + shared::abi::WPK_FORK_FRAME_IMPORT_NEXT, + ), + ( + "WPK_FORK_FRAME_IMPORT_PEEK", + shared::abi::WPK_FORK_FRAME_IMPORT_PEEK, + ), + ( + "WPK_FORK_FRAME_IMPORT_RESERVE", + shared::abi::WPK_FORK_FRAME_IMPORT_RESERVE, + ), + ( + "WPK_FORK_RESUME_IMPORT_PEEK", + shared::abi::WPK_FORK_RESUME_IMPORT_PEEK, + ), + ( + "WPK_FORK_RESUME_IMPORT_TABLE", + shared::abi::WPK_FORK_RESUME_IMPORT_TABLE, + ), + ] { + out.push_str(&format!("export const {name} = {value:?} as const;\n")); + } + out.push_str(&format!( + "export const WPK_FORK_IMPORTED_GLOBALS_MAGIC = {:?} as const;\n", + shared::abi::WPK_FORK_IMPORTED_GLOBALS_MAGIC, + )); + for (name, value) in [ + ( + "VERSION", + u32::from(shared::abi::WPK_FORK_IMPORTED_GLOBALS_VERSION), + ), + ( + "HEADER_SIZE", + u32::from(shared::abi::WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE), + ), + ( + "RECORD_HEADER_SIZE", + u32::from(shared::abi::WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE), + ), + ( + "FLAG_MUTABLE", + u32::from(shared::abi::WPK_FORK_IMPORTED_GLOBAL_FLAG_MUTABLE), + ), + ( + "FLAG_SHARED", + u32::from(shared::abi::WPK_FORK_IMPORTED_GLOBAL_FLAG_SHARED), + ), + ( + "KNOWN_FLAGS", + u32::from(shared::abi::WPK_FORK_IMPORTED_GLOBAL_KNOWN_FLAGS), + ), + ] { + out.push_str(&format!( + "export const WPK_FORK_IMPORTED_GLOBALS_{name} = {value} as const;\n" + )); + } + out.push_str(&format!( + "export const WPK_FORK_IMPORTED_TABLES_SECTION = {:?} as const;\n", + shared::abi::WPK_FORK_IMPORTED_TABLES_SECTION, + )); + out.push_str(&format!( + "export const WPK_FORK_IMPORTED_TABLES_MAGIC = {:?} as const;\n", + shared::abi::WPK_FORK_IMPORTED_TABLES_MAGIC, + )); + for (name, value) in [ + ( + "VERSION", + u32::from(shared::abi::WPK_FORK_IMPORTED_TABLES_VERSION), + ), + ( + "HEADER_SIZE", + u32::from(shared::abi::WPK_FORK_IMPORTED_TABLES_HEADER_SIZE), + ), + ( + "RECORD_HEADER_SIZE", + u32::from(shared::abi::WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE), + ), + ( + "FLAG_TABLE64", + u32::from(shared::abi::WPK_FORK_IMPORTED_TABLE_FLAG_TABLE64), + ), + ( + "KNOWN_FLAGS", + u32::from(shared::abi::WPK_FORK_IMPORTED_TABLE_KNOWN_FLAGS), + ), + ] { + out.push_str(&format!( + "export const WPK_FORK_IMPORTED_TABLES_{name} = {value} as const;\n" + )); + } + for (name, value) in [ + ( + "WPK_FORK_EXCEPTION_CODEC_IMPORT_MODULE", + shared::abi::WPK_FORK_EXCEPTION_CODEC_IMPORT_MODULE, + ), + ( + "WPK_FORK_EXCEPTION_IMPORT_ACTIVATION", + shared::abi::WPK_FORK_EXCEPTION_IMPORT_ACTIVATION, + ), + ( + "WPK_FORK_EXCEPTION_IMPORT_BROKER_ENCODE", + shared::abi::WPK_FORK_EXCEPTION_IMPORT_BROKER_ENCODE, + ), + ( + "WPK_FORK_EXCEPTION_IMPORT_BROKER_THROW_RECIPE", + shared::abi::WPK_FORK_EXCEPTION_IMPORT_BROKER_THROW_RECIPE, + ), + ( + "WPK_FORK_EXCEPTION_IMPORT_CACHE_INDEX", + shared::abi::WPK_FORK_EXCEPTION_IMPORT_CACHE_INDEX, + ), + ( + "WPK_FORK_EXCEPTION_IMPORT_CLAIM", + shared::abi::WPK_FORK_EXCEPTION_IMPORT_CLAIM, + ), + ( + "WPK_FORK_EXCEPTION_IMPORT_DEFINE", + shared::abi::WPK_FORK_EXCEPTION_IMPORT_DEFINE, + ), + ( + "WPK_FORK_EXCEPTION_IMPORT_INGRESS_THROW", + shared::abi::WPK_FORK_EXCEPTION_IMPORT_INGRESS_THROW, + ), + ( + "WPK_FORK_EXCEPTION_IMPORT_LOAD", + shared::abi::WPK_FORK_EXCEPTION_IMPORT_LOAD, + ), + ( + "WPK_FORK_EXCEPTION_IMPORT_LOOKUP", + shared::abi::WPK_FORK_EXCEPTION_IMPORT_LOOKUP, + ), + ( + "WPK_FORK_EXCEPTION_IMPORT_ROUTE", + shared::abi::WPK_FORK_EXCEPTION_IMPORT_ROUTE, + ), + ( + "WPK_FORK_EXCEPTION_EXPORT_ABORT", + shared::abi::WPK_FORK_EXCEPTION_EXPORT_ABORT, + ), + ( + "WPK_FORK_EXCEPTION_EXPORT_CLEAR", + shared::abi::WPK_FORK_EXCEPTION_EXPORT_CLEAR, + ), + ( + "WPK_FORK_EXCEPTION_EXPORT_DECODE", + shared::abi::WPK_FORK_EXCEPTION_EXPORT_DECODE, + ), + ( + "WPK_FORK_EXCEPTION_EXPORT_ENCODE", + shared::abi::WPK_FORK_EXCEPTION_EXPORT_ENCODE, + ), + ( + "WPK_FORK_EXCEPTION_EXPORT_ENCODE_INGRESS", + shared::abi::WPK_FORK_EXCEPTION_EXPORT_ENCODE_INGRESS, + ), + ( + "WPK_FORK_EXCEPTION_EXPORT_MATERIALIZE", + shared::abi::WPK_FORK_EXCEPTION_EXPORT_MATERIALIZE, + ), + ( + "WPK_FORK_EXCEPTION_EXPORT_THROW_RECIPE", + shared::abi::WPK_FORK_EXCEPTION_EXPORT_THROW_RECIPE, + ), + ( + "WPK_FORK_EXCEPTION_EXPORT_THROW_SLOT", + shared::abi::WPK_FORK_EXCEPTION_EXPORT_THROW_SLOT, + ), + ( + "WPK_FORK_MODULE_STATE_IMPORT_RECORD_COMMIT", + shared::abi::WPK_FORK_MODULE_STATE_IMPORT_RECORD_COMMIT, + ), + ( + "WPK_FORK_MODULE_STATE_IMPORT_RECORD_FIND", + shared::abi::WPK_FORK_MODULE_STATE_IMPORT_RECORD_FIND, + ), + ( + "WPK_FORK_MODULE_STATE_IMPORT_RECORD_RESERVE", + shared::abi::WPK_FORK_MODULE_STATE_IMPORT_RECORD_RESERVE, + ), + ( + "WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_COUNT", + shared::abi::WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_COUNT, + ), + ( + "WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_MARK", + shared::abi::WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_MARK, + ), + ( + "WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_PAGE", + shared::abi::WPK_FORK_MODULE_STATE_IMPORT_TABLE_DIRTY_PAGE, + ), + ( + "WPK_FORK_MODULE_STATE_IMPORT_TABLE_STATE_OWNED", + shared::abi::WPK_FORK_MODULE_STATE_IMPORT_TABLE_STATE_OWNED, + ), + ( + "WPK_FORK_EXPORT_MODULE_BOOTSTRAP", + shared::abi::WPK_FORK_EXPORT_MODULE_BOOTSTRAP, + ), + ( + "WPK_FORK_EXPORT_MODULE_STATE_FINISH_RESTORE", + shared::abi::WPK_FORK_EXPORT_MODULE_STATE_FINISH_RESTORE, + ), + ( + "WPK_FORK_EXPORT_MODULE_STATE_RESTORE", + shared::abi::WPK_FORK_EXPORT_MODULE_STATE_RESTORE, + ), + ( + "WPK_FORK_EXPORT_MODULE_STATE_SAVE", + shared::abi::WPK_FORK_EXPORT_MODULE_STATE_SAVE, + ), + ( + "WPK_FORK_EXPORT_MODULE_THREAD_BOOTSTRAP", + shared::abi::WPK_FORK_EXPORT_MODULE_THREAD_BOOTSTRAP, + ), + ( + "WPK_FORK_EXPORT_RESUME_START", + shared::abi::WPK_FORK_EXPORT_RESUME_START, + ), + ( + "WPK_FORK_EXPORT_RESUME_THREAD", + shared::abi::WPK_FORK_EXPORT_RESUME_THREAD, + ), + ( + "WPK_FORK_REFERENCE_IMPORT_DECODE_ANYREF", + shared::abi::WPK_FORK_REFERENCE_IMPORT_DECODE_ANYREF, + ), + ( + "WPK_FORK_REFERENCE_IMPORT_DECODE_EXNREF", + shared::abi::WPK_FORK_REFERENCE_IMPORT_DECODE_EXNREF, + ), + ( + "WPK_FORK_REFERENCE_IMPORT_DECODE_EXTERNREF", + shared::abi::WPK_FORK_REFERENCE_IMPORT_DECODE_EXTERNREF, + ), + ( + "WPK_FORK_REFERENCE_IMPORT_DECODE_FUNCREF", + shared::abi::WPK_FORK_REFERENCE_IMPORT_DECODE_FUNCREF, + ), + ( + "WPK_FORK_REFERENCE_IMPORT_ENCODE_ANYREF", + shared::abi::WPK_FORK_REFERENCE_IMPORT_ENCODE_ANYREF, + ), + ( + "WPK_FORK_REFERENCE_IMPORT_ENCODE_EXNREF", + shared::abi::WPK_FORK_REFERENCE_IMPORT_ENCODE_EXNREF, + ), + ( + "WPK_FORK_REFERENCE_IMPORT_ENCODE_EXTERNREF", + shared::abi::WPK_FORK_REFERENCE_IMPORT_ENCODE_EXTERNREF, + ), + ( + "WPK_FORK_REFERENCE_IMPORT_ENCODE_FUNCREF", + shared::abi::WPK_FORK_REFERENCE_IMPORT_ENCODE_FUNCREF, + ), + ( + "WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE", + shared::abi::WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE, + ), + ( + "WPK_FORK_REFERENCE_IMPORT_GC_BROKER_ENCODE", + shared::abi::WPK_FORK_REFERENCE_IMPORT_GC_BROKER_ENCODE, + ), + ( + "WPK_FORK_REFERENCE_IMPORT_GC_CAPTURE_LAYOUT", + shared::abi::WPK_FORK_REFERENCE_IMPORT_GC_CAPTURE_LAYOUT, + ), + ( + "WPK_FORK_REFERENCE_IMPORT_GC_CLAIM", + shared::abi::WPK_FORK_REFERENCE_IMPORT_GC_CLAIM, + ), + ( + "WPK_FORK_REFERENCE_IMPORT_GC_DEFINE", + shared::abi::WPK_FORK_REFERENCE_IMPORT_GC_DEFINE, + ), + ( + "WPK_FORK_REFERENCE_IMPORT_GC_I31", + shared::abi::WPK_FORK_REFERENCE_IMPORT_GC_I31, + ), + ( + "WPK_FORK_REFERENCE_IMPORT_GC_LOAD", + shared::abi::WPK_FORK_REFERENCE_IMPORT_GC_LOAD, + ), + ( + "WPK_FORK_REFERENCE_IMPORT_GC_LOOKUP", + shared::abi::WPK_FORK_REFERENCE_IMPORT_GC_LOOKUP, + ), + ( + "WPK_FORK_REFERENCE_IMPORT_GC_PAYLOAD_LEN", + shared::abi::WPK_FORK_REFERENCE_IMPORT_GC_PAYLOAD_LEN, + ), + ( + "WPK_FORK_REFERENCE_IMPORT_GC_PROVENANCE_BEGIN", + shared::abi::WPK_FORK_REFERENCE_IMPORT_GC_PROVENANCE_BEGIN, + ), + ( + "WPK_FORK_REFERENCE_IMPORT_GC_PROVENANCE_END", + shared::abi::WPK_FORK_REFERENCE_IMPORT_GC_PROVENANCE_END, + ), + ( + "WPK_FORK_REFERENCE_IMPORT_GC_PROVENANCE_REF", + shared::abi::WPK_FORK_REFERENCE_IMPORT_GC_PROVENANCE_REF, + ), + ( + "WPK_FORK_REFERENCE_IMPORT_GC_ROUTE", + shared::abi::WPK_FORK_REFERENCE_IMPORT_GC_ROUTE, + ), + ( + "WPK_FORK_REFERENCE_IMPORT_GC_TRANSIT", + shared::abi::WPK_FORK_REFERENCE_IMPORT_GC_TRANSIT, + ), + ( + "WPK_FORK_REFERENCE_EXPORT_GC_ALLOCATE", + shared::abi::WPK_FORK_REFERENCE_EXPORT_GC_ALLOCATE, + ), + ( + "WPK_FORK_REFERENCE_EXPORT_GC_ENCODE_SLOT", + shared::abi::WPK_FORK_REFERENCE_EXPORT_GC_ENCODE_SLOT, + ), + ( + "WPK_FORK_REFERENCE_EXPORT_GC_FILL", + shared::abi::WPK_FORK_REFERENCE_EXPORT_GC_FILL, + ), + ( + "WPK_FORK_REFERENCE_EXPORT_GC_PUBLISH_EXTERNREF", + shared::abi::WPK_FORK_REFERENCE_EXPORT_GC_PUBLISH_EXTERNREF, + ), + ( + "WPK_FORK_REFERENCE_EXPORT_GC_PROBE", + shared::abi::WPK_FORK_REFERENCE_EXPORT_GC_PROBE, + ), + ( + "WPK_FORK_REFERENCE_IMPORT_SCRATCH_RELEASE", + shared::abi::WPK_FORK_REFERENCE_IMPORT_SCRATCH_RELEASE, + ), + ( + "WPK_FORK_REFERENCE_IMPORT_SCRATCH_RESERVE", + shared::abi::WPK_FORK_REFERENCE_IMPORT_SCRATCH_RESERVE, + ), + ( + "WPK_FORK_REFERENCE_IMPORT_VECTOR_APPEND", + shared::abi::WPK_FORK_REFERENCE_IMPORT_VECTOR_APPEND, + ), + ( + "WPK_FORK_REFERENCE_IMPORT_VECTOR_BEGIN", + shared::abi::WPK_FORK_REFERENCE_IMPORT_VECTOR_BEGIN, + ), + ( + "WPK_FORK_REFERENCE_IMPORT_VECTOR_FINISH", + shared::abi::WPK_FORK_REFERENCE_IMPORT_VECTOR_FINISH, + ), + ( + "WPK_FORK_REFERENCE_IMPORT_VECTOR_GET", + shared::abi::WPK_FORK_REFERENCE_IMPORT_VECTOR_GET, + ), + ] { + out.push_str(&format!("export const {name} = {value:?} as const;\n")); + } out.push_str("export const WPK_FORK_REQUIRED_IMPORTS = [\n"); for requirement in shared::abi::WPK_FORK_REQUIRED_IMPORTS { out.push_str(&format!( @@ -288,6 +1251,21 @@ fn render_ts_module() -> String { )); } out.push_str("] as const;\n"); + out.push_str("export const WPK_FORK_REQUIRED_TABLE_IMPORTS = [\n"); + for requirement in shared::abi::WPK_FORK_REQUIRED_TABLE_IMPORTS { + out.push_str(&format!( + " {{ module: {:?}, name: {:?}, table64: {}, element: {:?}, minimum: {}, maximum: {} }},\n", + requirement.module, + requirement.name, + requirement.table64, + program_artifact_type_name(requirement.element), + requirement.minimum, + requirement + .maximum + .map_or_else(|| "null".to_owned(), |maximum| maximum.to_string()), + )); + } + out.push_str("] as const;\n"); out.push_str("export const WPK_FORK_REQUIRED_EXPORTS = [\n"); for requirement in shared::abi::WPK_FORK_REQUIRED_EXPORTS { out.push_str(&format!( @@ -407,6 +1385,14 @@ fn render_ts_module() -> String { "export const CH_ERRNO = {} as const;\n", channel::ERRNO_OFFSET )); + out.push_str(&format!( + "export const CH_REQUEST_FLAGS = {} as const;\n", + channel::REQUEST_FLAGS_OFFSET + )); + out.push_str(&format!( + "export const CH_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY = {} as const;\n", + channel::REQUEST_FLAG_DEFER_SIGNAL_DELIVERY + )); out.push_str(&format!( "export const CH_DATA = {} as const;\n", channel::DATA_OFFSET @@ -700,19 +1686,27 @@ fn render_ts_module() -> String { } fn render_ts_program_artifact_types(values: &[shared::abi::ProgramArtifactValueType]) -> String { - use shared::abi::ProgramArtifactValueType; - let values = values .iter() - .map(|value| match value { - ProgramArtifactValueType::Pointer => "\"ptr\"", - ProgramArtifactValueType::I32 => "\"i32\"", - }) + .map(|value| format!("{:?}", program_artifact_type_name(*value))) .collect::>() .join(", "); format!("[{values}]") } +fn program_artifact_type_name(value: shared::abi::ProgramArtifactValueType) -> &'static str { + use shared::abi::ProgramArtifactValueType; + match value { + ProgramArtifactValueType::Pointer => "ptr", + ProgramArtifactValueType::I32 => "i32", + ProgramArtifactValueType::I64 => "i64", + ProgramArtifactValueType::FuncRef => "funcref", + ProgramArtifactValueType::ExternRef => "externref", + ProgramArtifactValueType::ExnRef => "exnref", + ProgramArtifactValueType::AnyRef => "anyref", + } +} + fn ts_syscall_arg_desc(desc: &shared::host_abi::SyscallArgDesc) -> String { let mut s = format!( "{{ argIndex: {}, direction: {:?}, size: {}", @@ -884,6 +1878,7 @@ fn build_snapshot(kernel_wasm: &std::path::Path) -> Result { root.insert("abi_version".into(), json!(shared::ABI_VERSION)); root.insert("channel_header".into(), channel_header()); + root.insert("channel_request_flags".into(), channel_request_flags()); root.insert("channel_signal_area".into(), channel_signal_area()); root.insert("channel_buffers".into(), channel_buffers()); @@ -926,6 +1921,7 @@ fn channel_header() -> Value { ("args", ARGS_OFFSET, ARGS_COUNT * ARG_SIZE, "[i64; 6]"), ("ret", RETURN_OFFSET, 8, "i64"), ("errno", ERRNO_OFFSET, 4, "i32"), + ("request_flags", REQUEST_FLAGS_OFFSET, 4, "u32"), ]; let mut covered: usize = 0; @@ -957,6 +1953,19 @@ fn channel_header() -> Value { Value::Object(m.into_iter().collect()) } +fn channel_request_flags() -> Value { + let mut flag: JsonMap = BTreeMap::new(); + flag.insert( + "name".into(), + json!("defer_signal_delivery"), + ); + flag.insert( + "bit".into(), + json!(shared::channel::REQUEST_FLAG_DEFER_SIGNAL_DELIVERY), + ); + Value::Array(vec![Value::Object(flag.into_iter().collect())]) +} + fn channel_buffers() -> Value { use shared::channel::*; let mut m: JsonMap = BTreeMap::new(); @@ -1117,17 +2126,16 @@ fn channel_signal_area() -> Value { fn marshalled_structs() -> Value { use shared::dri::{ WpkDrmBindForeignTexture, WpkDrmEventVblank, WpkDrmGemClose, WpkDrmGetCap, - WpkDrmGpuBoCreate, WpkDrmModeCardRes, WpkDrmModeCreateDumb, - WpkDrmModeCrtcPageFlip, WpkDrmModeDestroyDumb, WpkDrmModeFbCmd2, - WpkDrmModeGetConnector, WpkDrmModeGetCrtc, WpkDrmModeGetEncoder, - WpkDrmModeMapDumb, WpkDrmModeModeinfo, WpkDrmPrimeHandle, WpkDrmVersion, - WpkDrmWaitVblankReply, WpkDrmWaitVblankRequest, + WpkDrmGpuBoCreate, WpkDrmModeCardRes, WpkDrmModeCreateDumb, WpkDrmModeCrtcPageFlip, + WpkDrmModeDestroyDumb, WpkDrmModeFbCmd2, WpkDrmModeGetConnector, WpkDrmModeGetCrtc, + WpkDrmModeGetEncoder, WpkDrmModeMapDumb, WpkDrmModeModeinfo, WpkDrmPrimeHandle, + WpkDrmVersion, WpkDrmWaitVblankReply, WpkDrmWaitVblankRequest, }; use shared::fbdev::{FbBitfield, FbFixScreenInfo, FbVarScreenInfo}; use shared::gl::{GlContextAttrs, GlQueryInfo, GlSubmitInfo, GlSurfaceAttrs}; use shared::{ - KernelWaitResult, WasmDirent, WasmFlock, WasmPollFd, WasmRusageWire, WasmStat, - WasmStatfs, WasmTimespec, + KernelWaitResult, WasmDirent, WasmFlock, WasmPollFd, WasmRusageWire, WasmStat, WasmStatfs, + WasmTimespec, }; let mut structs: JsonMap = BTreeMap::new(); @@ -1358,11 +2366,7 @@ fn marshalled_structs() -> Value { ); structs.insert( "WpkDrmPrimeHandle".into(), - struct_layout!(WpkDrmPrimeHandle { - handle, - flags, - fd - }), + struct_layout!(WpkDrmPrimeHandle { handle, flags, fd }), ); structs.insert( "WpkDrmGetCap".into(), @@ -1563,10 +2567,7 @@ fn wait_contract() -> Value { for (name, value) in [ ("WAIT_EVENT_EXITED", json!(shared::wait::EVENT_EXITED)), ("WAIT_EVENT_STOPPED", json!(shared::wait::EVENT_STOPPED)), - ( - "WAIT_EVENT_CONTINUED", - json!(shared::wait::EVENT_CONTINUED), - ), + ("WAIT_EVENT_CONTINUED", json!(shared::wait::EVENT_CONTINUED)), ("WAIT_WNOHANG", json!(shared::wait::WNOHANG)), ("WAIT_WUNTRACED", json!(shared::wait::WUNTRACED)), ("WAIT_WSTOPPED", json!(shared::wait::WSTOPPED)), @@ -1576,10 +2577,7 @@ fn wait_contract() -> Value { ("WAIT_CLD_EXITED", json!(shared::wait::CLD_EXITED)), ("WAIT_CLD_KILLED", json!(shared::wait::CLD_KILLED)), ("WAIT_CLD_STOPPED", json!(shared::wait::CLD_STOPPED)), - ( - "WAIT_CLD_CONTINUED", - json!(shared::wait::CLD_CONTINUED), - ), + ("WAIT_CLD_CONTINUED", json!(shared::wait::CLD_CONTINUED)), ( "PROCESS_STATE_RUNNING", json!(shared::wait::PROCESS_STATE_RUNNING), @@ -1903,7 +2901,14 @@ fn custom_sections() -> Value { let mut sections = vec![ shared::abi::ABI_CUSTOM_SECTION, shared::abi::WPK_FORK_CAPABILITIES_SECTION, + shared::abi::WPK_FORK_EXCEPTION_CODEC_SECTION, + shared::abi::WPK_FORK_GC_CODEC_SECTION, shared::abi::WPK_FORK_LINKED_FRAME_FORMAT_SECTION, + shared::abi::WPK_FORK_IMPORTED_GLOBALS_SECTION, + shared::abi::WPK_FORK_IMPORTED_TABLES_SECTION, + shared::abi::WPK_FORK_MODULE_STATE_FORMAT_SECTION, + shared::abi::WPK_FORK_STATIC_ROOT_CATALOG_SECTION, + shared::abi::WPK_FORK_UNWIND_TRANSPORT_SECTION, ]; sections.sort(); Value::Array(sections.into_iter().map(Value::from).collect()) @@ -1917,15 +2922,94 @@ fn process_expected_globals() -> Value { fn program_artifact() -> Value { use shared::abi::{ - ProgramArtifactValueType, WPK_FORK_CAP_ACTIVATION_STATE_SAFE, WPK_FORK_CAP_DYLINK_MAIN, - WPK_FORK_CAP_KNOWN_MASK, WPK_FORK_CAP_REQUIRED_FLAGS, WPK_FORK_CAP_SIDE_ENTRY, - WPK_FORK_CAPABILITIES_SECTION, WPK_FORK_CAPABILITIES_VERSION, + ProgramArtifactValueType, WPK_FORK_ACTIVATION_CONTINUATION_ENTRY_KNOWN_FLAGS, + WPK_FORK_ACTIVATION_CONTINUATION_ENTRY_SIZE, WPK_FORK_ACTIVATION_CONTINUATIONS_HEADER_SIZE, + WPK_FORK_ACTIVATION_CONTINUATIONS_KNOWN_FLAGS, WPK_FORK_ACTIVATION_CONTINUATIONS_MAGIC, + WPK_FORK_ACTIVATION_CONTINUATIONS_OWNER, WPK_FORK_ACTIVATION_CONTINUATIONS_VERSION, + WPK_FORK_CAP_ACTIVATION_STATE_SAFE, WPK_FORK_CAP_DYLINK_MAIN, WPK_FORK_CAP_KNOWN_MASK, + WPK_FORK_CAP_REQUIRED_FLAGS, WPK_FORK_CAP_SIDE_ENTRY, WPK_FORK_CAPABILITIES_SECTION, + WPK_FORK_CAPABILITIES_VERSION, WPK_FORK_EXCEPTION_CODEC_HEADER_SIZE, + WPK_FORK_EXCEPTION_CODEC_IMPORT_MODULE, WPK_FORK_EXCEPTION_CODEC_SECTION, + WPK_FORK_EXCEPTION_CODEC_TAG_RECORD_SIZE, WPK_FORK_EXCEPTION_CODEC_VERSION, + WPK_FORK_EXCEPTION_IMPORT_ACTIVATION, WPK_FORK_GC_CODEC_FIELD_RECORD_SIZE, + WPK_FORK_GC_CODEC_HEADER_SIZE, WPK_FORK_GC_CODEC_LAYOUT_RECORD_SIZE, + WPK_FORK_GC_CODEC_MAGIC, WPK_FORK_GC_CODEC_SECTION, WPK_FORK_GC_CODEC_VERSION, + WPK_FORK_IMPORTED_GLOBAL_BINDING_ACTIVATION_GLOBAL, + WPK_FORK_IMPORTED_GLOBAL_BINDING_BASE_IMPORT, WPK_FORK_IMPORTED_GLOBAL_BINDING_RAW_BIGINT, + WPK_FORK_IMPORTED_GLOBAL_BINDING_RAW_NUMBER, + WPK_FORK_IMPORTED_GLOBAL_BINDING_RAW_REFERENCE, + WPK_FORK_IMPORTED_GLOBAL_BINDINGS_ENTRY_SIZE, + WPK_FORK_IMPORTED_GLOBAL_BINDINGS_HEADER_SIZE, + WPK_FORK_IMPORTED_GLOBAL_BINDINGS_KNOWN_FLAGS, WPK_FORK_IMPORTED_GLOBAL_BINDINGS_MAGIC, + WPK_FORK_IMPORTED_GLOBAL_BINDINGS_OWNER, WPK_FORK_IMPORTED_GLOBAL_BINDINGS_VERSION, + WPK_FORK_IMPORTED_GLOBAL_FLAG_MUTABLE, WPK_FORK_IMPORTED_GLOBAL_FLAG_SHARED, + WPK_FORK_IMPORTED_GLOBAL_KNOWN_FLAGS, WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE, + WPK_FORK_IMPORTED_GLOBALS_MAGIC, WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE, + WPK_FORK_IMPORTED_GLOBALS_SECTION, WPK_FORK_IMPORTED_GLOBALS_VERSION, + WPK_FORK_IMPORTED_TABLE_BINDING_ACTIVATION_TABLE, + WPK_FORK_IMPORTED_TABLE_BINDING_BASE_IMPORT, WPK_FORK_IMPORTED_TABLE_BINDINGS_ENTRY_SIZE, + WPK_FORK_IMPORTED_TABLE_BINDINGS_HEADER_SIZE, WPK_FORK_IMPORTED_TABLE_BINDINGS_KNOWN_FLAGS, + WPK_FORK_IMPORTED_TABLE_BINDINGS_MAGIC, WPK_FORK_IMPORTED_TABLE_BINDINGS_OWNER, + WPK_FORK_IMPORTED_TABLE_BINDINGS_VERSION, WPK_FORK_IMPORTED_TABLE_FLAG_TABLE64, + WPK_FORK_IMPORTED_TABLE_KNOWN_FLAGS, WPK_FORK_IMPORTED_TABLES_HEADER_SIZE, + WPK_FORK_IMPORTED_TABLES_MAGIC, WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE, + WPK_FORK_IMPORTED_TABLES_SECTION, WPK_FORK_IMPORTED_TABLES_VERSION, WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE, WPK_FORK_LINKED_FRAME_FLAG_ABORT_UNWINDING, WPK_FORK_LINKED_FRAME_FLAG_TRANSACTIONAL_NODES, WPK_FORK_LINKED_FRAME_FORMAT_MAGIC, WPK_FORK_LINKED_FRAME_FORMAT_SECTION, WPK_FORK_LINKED_FRAME_FORMAT_VERSION, WPK_FORK_LINKED_FRAME_POINTER_WIDTHS, WPK_FORK_LINKED_FRAME_RECORD_ALIGNMENT, - WPK_FORK_LINKED_FRAME_REQUIRED_FLAGS, WPK_FORK_REQUIRED_EXPORTS, WPK_FORK_REQUIRED_IMPORTS, - wpk_fork_linked_chunk_header_size, wpk_fork_linked_node_header_size, + WPK_FORK_LINKED_FRAME_REQUIRED_FLAGS, WPK_FORK_MODULE_STATE_ARENA_VERSION, + WPK_FORK_MODULE_STATE_CHUNK_FLAG_ROOT, WPK_FORK_MODULE_STATE_CHUNK_FLAG_SEALED, + WPK_FORK_MODULE_STATE_CHUNK_KNOWN_FLAGS, WPK_FORK_MODULE_STATE_CHUNK_MAGIC, + WPK_FORK_MODULE_STATE_DATA_SEGMENT_HEADER_SIZE, WPK_FORK_MODULE_STATE_DESCRIPTOR_SIZE, + WPK_FORK_MODULE_STATE_ELEMENT_SEGMENT_HEADER_SIZE, + WPK_FORK_MODULE_STATE_FLAG_EXPLICIT_OWNERS, WPK_FORK_MODULE_STATE_FLAG_ROOT_PREFIX_POINTER, + WPK_FORK_MODULE_STATE_FLAG_SPARSE_TABLES, WPK_FORK_MODULE_STATE_FORMAT_MAGIC, + WPK_FORK_MODULE_STATE_FORMAT_SECTION, WPK_FORK_MODULE_STATE_FORMAT_VERSION, + WPK_FORK_MODULE_STATE_GLOBAL_HEADER_SIZE, WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF, WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_F32, WPK_FORK_MODULE_STATE_GLOBAL_TYPE_F64, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I32, + WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I64, WPK_FORK_MODULE_STATE_GLOBAL_TYPE_V128, + WPK_FORK_MODULE_STATE_KNOWN_FLAGS, WPK_FORK_MODULE_STATE_MAX_TABLE_PAGE_SHIFT, + WPK_FORK_MODULE_STATE_MIN_TABLE_PAGE_SHIFT, + WPK_FORK_MODULE_STATE_MODULE_RECORD_KNOWN_FLAGS, + WPK_FORK_MODULE_STATE_MODULE_RECORD_PAYLOAD_SIZE, + WPK_FORK_MODULE_STATE_MODULE_TEMPLATE_ID_SIZE, WPK_FORK_MODULE_STATE_POINTER_WIDTHS, + WPK_FORK_MODULE_STATE_RECORD_ALIGNMENT, WPK_FORK_MODULE_STATE_RECORD_HEADER_SIZE, + WPK_FORK_MODULE_STATE_RECORD_KINDS, WPK_FORK_MODULE_STATE_RECORD_MAGIC, + WPK_FORK_MODULE_STATE_RECORD_VERSION, WPK_FORK_MODULE_STATE_REPLAY_EVENT_SIZE, + WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_CAPACITY, + WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_HEADER_SIZE, + WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_KNOWN_FLAGS, + WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_VERSION, + WPK_FORK_MODULE_STATE_REPLAY_EVENTS_HEADER_SIZE, + WPK_FORK_MODULE_STATE_REPLAY_EVENTS_KNOWN_FLAGS, WPK_FORK_MODULE_STATE_REPLAY_EVENTS_MAGIC, + WPK_FORK_MODULE_STATE_REPLAY_EVENTS_OWNER, + WPK_FORK_MODULE_STATE_REPLAY_EVENTS_VERSION, WPK_FORK_MODULE_STATE_REQUIRED_FLAGS, + WPK_FORK_MODULE_STATE_ROOT_POINTER_WORD_OFFSET, + WPK_FORK_MODULE_STATE_TABLE_BASELINE_FINGERPRINT_SIZE, + WPK_FORK_MODULE_STATE_TABLE_DESCRIPTOR_PAYLOAD_SIZE, + WPK_FORK_MODULE_STATE_TABLE_FLAG_SPARSE_OVERRIDES, WPK_FORK_MODULE_STATE_TABLE_KNOWN_FLAGS, + WPK_FORK_MODULE_STATE_TABLE_PAGE_HEADER_SIZE, WPK_FORK_MODULE_STATE_TABLE_PAGE_SHIFT, + WPK_FORK_MODULE_STATE_TABLE_RUN_HEADER_SIZE, WPK_FORK_REFERENCE_NODE_RECORD_SIZE, + WPK_FORK_REFERENCE_SECTION_EDGES, WPK_FORK_REFERENCE_SECTION_NODES, + WPK_FORK_REFERENCE_SECTION_SCALARS, WPK_FORK_REFERENCE_SECTION_VECTOR_ENTRIES, + WPK_FORK_REFERENCE_SECTION_VECTOR_INDEX, WPK_FORK_REFERENCE_SEGMENT_HEADER_SIZE, + WPK_FORK_REFERENCE_SEGMENT_KNOWN_FLAGS, WPK_FORK_REFERENCE_SEGMENT_MAGIC, + WPK_FORK_REFERENCE_TRANSACTION_FLAG_SEALED, + WPK_FORK_REFERENCE_TRANSACTION_KNOWN_FLAGS, + WPK_FORK_REFERENCE_TRANSACTION_MAGIC, WPK_FORK_REFERENCE_TRANSACTION_MANIFEST_SIZE, + WPK_FORK_REFERENCE_TRANSACTION_OWNER, WPK_FORK_REFERENCE_TRANSACTION_VERSION, + WPK_FORK_REFERENCE_VECTOR_INDEX_SIZE, WPK_FORK_REQUIRED_EXPORTS, + WPK_FORK_REQUIRED_IMPORTS, WPK_FORK_REQUIRED_TABLE_IMPORTS, + WPK_FORK_STATIC_ROOT_CATALOG_EXPORT, WPK_FORK_STATIC_ROOT_CATALOG_HEADER_SIZE, + WPK_FORK_STATIC_ROOT_CATALOG_MAGIC, WPK_FORK_STATIC_ROOT_CATALOG_SECTION, + WPK_FORK_STATIC_ROOT_CATALOG_VERSION, WPK_FORK_STATIC_ROOT_HARVEST_EXPORT, + WPK_FORK_UNWIND_TAG_IMPORT_MODULE, WPK_FORK_UNWIND_TAG_IMPORT_NAME, + WPK_FORK_UNWIND_TRANSPORT_PAYLOAD_ARITY, WPK_FORK_UNWIND_TRANSPORT_SECTION, + WPK_FORK_UNWIND_TRANSPORT_VERSION, wpk_fork_linked_chunk_header_size, + wpk_fork_linked_node_header_size, wpk_fork_module_state_chunk_header_size, }; let value_types = |values: &[ProgramArtifactValueType]| { @@ -1936,98 +3020,811 @@ fn program_artifact() -> Value { Value::from(match value { ProgramArtifactValueType::Pointer => "ptr", ProgramArtifactValueType::I32 => "i32", + ProgramArtifactValueType::I64 => "i64", + ProgramArtifactValueType::FuncRef => "funcref", + ProgramArtifactValueType::ExternRef => "externref", + ProgramArtifactValueType::ExnRef => "exnref", + ProgramArtifactValueType::AnyRef => "anyref", }) }) .collect(), ) }; - let imports = WPK_FORK_REQUIRED_IMPORTS - .iter() - .map(|requirement| { - let mut item: JsonMap = BTreeMap::new(); - item.insert("kind".into(), json!("func")); - item.insert("module".into(), json!(requirement.module)); - item.insert("name".into(), json!(requirement.name)); - item.insert("params".into(), value_types(requirement.params)); - item.insert("results".into(), value_types(requirement.results)); - Value::Object(item.into_iter().collect()) - }) - .collect(); + let mut imports: Vec = WPK_FORK_REQUIRED_IMPORTS + .iter() + .map(|requirement| { + let mut item: JsonMap = BTreeMap::new(); + item.insert("kind".into(), json!("func")); + item.insert("module".into(), json!(requirement.module)); + item.insert("name".into(), json!(requirement.name)); + item.insert("params".into(), value_types(requirement.params)); + item.insert("results".into(), value_types(requirement.results)); + Value::Object(item.into_iter().collect()) + }) + .collect(); + imports.extend(WPK_FORK_REQUIRED_TABLE_IMPORTS.iter().map(|requirement| { + let mut item: JsonMap = BTreeMap::new(); + item.insert("kind".into(), json!("table")); + item.insert("module".into(), json!(requirement.module)); + item.insert("name".into(), json!(requirement.name)); + item.insert("table64".into(), json!(requirement.table64)); + item.insert( + "element".into(), + Value::from(match requirement.element { + ProgramArtifactValueType::FuncRef => "funcref", + ProgramArtifactValueType::ExternRef => "externref", + ProgramArtifactValueType::ExnRef => "exnref", + ProgramArtifactValueType::AnyRef => "anyref", + other => panic!("table element requirement is not a reference: {other:?}"), + }), + ); + item.insert("minimum".into(), json!(requirement.minimum)); + item.insert("maximum".into(), json!(requirement.maximum)); + Value::Object(item.into_iter().collect()) + })); + + let exports = WPK_FORK_REQUIRED_EXPORTS + .iter() + .map(|requirement| { + let mut item: JsonMap = BTreeMap::new(); + item.insert("kind".into(), json!("func")); + item.insert("name".into(), json!(requirement.name)); + item.insert("params".into(), value_types(requirement.params)); + item.insert("results".into(), value_types(requirement.results)); + Value::Object(item.into_iter().collect()) + }) + .collect(); + + let pointer_widths = WPK_FORK_LINKED_FRAME_POINTER_WIDTHS + .iter() + .map(|pointer_width| { + let mut item: JsonMap = BTreeMap::new(); + item.insert("bytes".into(), json!(pointer_width)); + item.insert( + "chunk_header_size".into(), + json!( + wpk_fork_linked_chunk_header_size(*pointer_width) + .expect("supported pointer width must have a chunk header") + ), + ); + item.insert( + "node_header_size".into(), + json!( + wpk_fork_linked_node_header_size(*pointer_width) + .expect("supported pointer width must have a node header") + ), + ); + Value::Object(item.into_iter().collect()) + }) + .collect(); + + let mut descriptor: JsonMap = BTreeMap::new(); + descriptor.insert( + "alignment".into(), + json!(WPK_FORK_LINKED_FRAME_RECORD_ALIGNMENT), + ); + descriptor.insert( + "descriptor_size".into(), + json!(WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE), + ); + descriptor.insert( + "flags".into(), + json!([ + { + "bit": WPK_FORK_LINKED_FRAME_FLAG_ABORT_UNWINDING, + "name": "abort_unwinding" + }, + { + "bit": WPK_FORK_LINKED_FRAME_FLAG_TRANSACTIONAL_NODES, + "name": "transactional_nodes" + } + ]), + ); + descriptor.insert( + "magic_bytes".into(), + json!(WPK_FORK_LINKED_FRAME_FORMAT_MAGIC), + ); + descriptor.insert("pointer_widths".into(), Value::Array(pointer_widths)); + descriptor.insert( + "required_flags".into(), + json!(WPK_FORK_LINKED_FRAME_REQUIRED_FLAGS), + ); + descriptor.insert( + "section".into(), + json!(WPK_FORK_LINKED_FRAME_FORMAT_SECTION), + ); + descriptor.insert( + "version".into(), + json!(WPK_FORK_LINKED_FRAME_FORMAT_VERSION), + ); + + let module_state_pointer_widths = WPK_FORK_MODULE_STATE_POINTER_WIDTHS + .iter() + .map(|pointer_width| { + let mut item: JsonMap = BTreeMap::new(); + item.insert("bytes".into(), json!(pointer_width)); + item.insert( + "chunk_header_size".into(), + json!( + wpk_fork_module_state_chunk_header_size(*pointer_width) + .expect("supported pointer width must have a module-state chunk header") + ), + ); + Value::Object(item.into_iter().collect()) + }) + .collect(); + let module_state_record_kinds = WPK_FORK_MODULE_STATE_RECORD_KINDS + .iter() + .map(|kind| { + let mut item: JsonMap = BTreeMap::new(); + item.insert("name".into(), json!(kind.name)); + item.insert("number".into(), json!(kind.number)); + Value::Object(item.into_iter().collect()) + }) + .collect(); + + let mut module_state_descriptor: JsonMap = BTreeMap::new(); + module_state_descriptor.insert( + "alignment".into(), + json!(WPK_FORK_MODULE_STATE_RECORD_ALIGNMENT), + ); + module_state_descriptor.insert( + "descriptor_size".into(), + json!(WPK_FORK_MODULE_STATE_DESCRIPTOR_SIZE), + ); + module_state_descriptor.insert( + "flags".into(), + json!([ + { + "bit": WPK_FORK_MODULE_STATE_FLAG_ROOT_PREFIX_POINTER, + "name": "root_prefix_pointer" + }, + { + "bit": WPK_FORK_MODULE_STATE_FLAG_EXPLICIT_OWNERS, + "name": "explicit_owners" + }, + { + "bit": WPK_FORK_MODULE_STATE_FLAG_SPARSE_TABLES, + "name": "sparse_tables" + } + ]), + ); + module_state_descriptor.insert( + "known_flags".into(), + json!(WPK_FORK_MODULE_STATE_KNOWN_FLAGS), + ); + module_state_descriptor.insert( + "magic_bytes".into(), + json!(WPK_FORK_MODULE_STATE_FORMAT_MAGIC), + ); + module_state_descriptor.insert( + "required_flags".into(), + json!(WPK_FORK_MODULE_STATE_REQUIRED_FLAGS), + ); + module_state_descriptor.insert( + "root_pointer_word_offset".into(), + json!(WPK_FORK_MODULE_STATE_ROOT_POINTER_WORD_OFFSET), + ); + module_state_descriptor.insert( + "section".into(), + json!(WPK_FORK_MODULE_STATE_FORMAT_SECTION), + ); + module_state_descriptor.insert( + "version".into(), + json!(WPK_FORK_MODULE_STATE_FORMAT_VERSION), + ); + + let mut module_state_record: JsonMap = BTreeMap::new(); + module_state_record.insert( + "alignment".into(), + json!(WPK_FORK_MODULE_STATE_RECORD_ALIGNMENT), + ); + module_state_record.insert( + "header_size".into(), + json!(WPK_FORK_MODULE_STATE_RECORD_HEADER_SIZE), + ); + module_state_record.insert("kinds".into(), Value::Array(module_state_record_kinds)); + module_state_record.insert( + "magic_bytes".into(), + json!(WPK_FORK_MODULE_STATE_RECORD_MAGIC), + ); + module_state_record.insert( + "version".into(), + json!(WPK_FORK_MODULE_STATE_RECORD_VERSION), + ); + + let mut module_state_arena: JsonMap = BTreeMap::new(); + module_state_arena.insert( + "chunk_flags".into(), + json!([ + {"bit": WPK_FORK_MODULE_STATE_CHUNK_FLAG_ROOT, "name": "root"}, + {"bit": WPK_FORK_MODULE_STATE_CHUNK_FLAG_SEALED, "name": "sealed"} + ]), + ); + module_state_arena.insert( + "chunk_magic_bytes".into(), + json!(WPK_FORK_MODULE_STATE_CHUNK_MAGIC), + ); + module_state_arena.insert( + "known_chunk_flags".into(), + json!(WPK_FORK_MODULE_STATE_CHUNK_KNOWN_FLAGS), + ); + module_state_arena.insert( + "pointer_widths".into(), + Value::Array(module_state_pointer_widths), + ); + module_state_arena.insert( + "record".into(), + Value::Object(module_state_record.into_iter().collect()), + ); + module_state_arena.insert("version".into(), json!(WPK_FORK_MODULE_STATE_ARENA_VERSION)); + + let mut module_payload: JsonMap = BTreeMap::new(); + module_payload.insert( + "known_flags".into(), + json!(WPK_FORK_MODULE_STATE_MODULE_RECORD_KNOWN_FLAGS), + ); + module_payload.insert( + "payload_size".into(), + json!(WPK_FORK_MODULE_STATE_MODULE_RECORD_PAYLOAD_SIZE), + ); + module_payload.insert( + "template_id_size".into(), + json!(WPK_FORK_MODULE_STATE_MODULE_TEMPLATE_ID_SIZE), + ); + + let mut mutable_global_payload: JsonMap = BTreeMap::new(); + mutable_global_payload.insert( + "header_size".into(), + json!(WPK_FORK_MODULE_STATE_GLOBAL_HEADER_SIZE), + ); + mutable_global_payload.insert( + "value_types".into(), + json!([ + {"number": WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I32, "name": "i32", "bytes": 4}, + {"number": WPK_FORK_MODULE_STATE_GLOBAL_TYPE_I64, "name": "i64", "bytes": 8}, + {"number": WPK_FORK_MODULE_STATE_GLOBAL_TYPE_F32, "name": "f32", "bytes": 4}, + {"number": WPK_FORK_MODULE_STATE_GLOBAL_TYPE_F64, "name": "f64", "bytes": 8}, + {"number": WPK_FORK_MODULE_STATE_GLOBAL_TYPE_V128, "name": "v128", "bytes": 16}, + {"number": WPK_FORK_MODULE_STATE_GLOBAL_TYPE_FUNCREF, "name": "funcref_recipe", "bytes": 4}, + {"number": WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXTERNREF, "name": "externref_recipe", "bytes": 4}, + {"number": WPK_FORK_MODULE_STATE_GLOBAL_TYPE_EXNREF, "name": "exnref_recipe", "bytes": 4}, + {"number": WPK_FORK_MODULE_STATE_GLOBAL_TYPE_ANYREF, "name": "anyref_recipe", "bytes": 4} + ]), + ); + + let mut table_payload: JsonMap = BTreeMap::new(); + table_payload.insert( + "baseline_fingerprint_size".into(), + json!(WPK_FORK_MODULE_STATE_TABLE_BASELINE_FINGERPRINT_SIZE), + ); + table_payload.insert( + "descriptor_payload_size".into(), + json!(WPK_FORK_MODULE_STATE_TABLE_DESCRIPTOR_PAYLOAD_SIZE), + ); + table_payload.insert( + "flags".into(), + json!([ + { + "bit": WPK_FORK_MODULE_STATE_TABLE_FLAG_SPARSE_OVERRIDES, + "name": "sparse_overrides" + } + ]), + ); + table_payload.insert( + "known_flags".into(), + json!(WPK_FORK_MODULE_STATE_TABLE_KNOWN_FLAGS), + ); + table_payload.insert( + "max_page_shift".into(), + json!(WPK_FORK_MODULE_STATE_MAX_TABLE_PAGE_SHIFT), + ); + table_payload.insert( + "min_page_shift".into(), + json!(WPK_FORK_MODULE_STATE_MIN_TABLE_PAGE_SHIFT), + ); + table_payload.insert( + "page_shift".into(), + json!(WPK_FORK_MODULE_STATE_TABLE_PAGE_SHIFT), + ); + table_payload.insert( + "page_header_size".into(), + json!(WPK_FORK_MODULE_STATE_TABLE_PAGE_HEADER_SIZE), + ); + table_payload.insert( + "run_header_size".into(), + json!(WPK_FORK_MODULE_STATE_TABLE_RUN_HEADER_SIZE), + ); + + let mut element_segments_payload: JsonMap = BTreeMap::new(); + element_segments_payload.insert( + "header_size".into(), + json!(WPK_FORK_MODULE_STATE_ELEMENT_SEGMENT_HEADER_SIZE), + ); + + let mut data_segments_payload: JsonMap = BTreeMap::new(); + data_segments_payload.insert( + "header_size".into(), + json!(WPK_FORK_MODULE_STATE_DATA_SEGMENT_HEADER_SIZE), + ); + + let mut replay_events_payload: JsonMap = BTreeMap::new(); + replay_events_payload.insert( + "entry_size".into(), + json!(WPK_FORK_MODULE_STATE_REPLAY_EVENT_SIZE), + ); + replay_events_payload.insert( + "magic".into(), + json!(WPK_FORK_MODULE_STATE_REPLAY_EVENTS_MAGIC), + ); + replay_events_payload.insert( + "header_size".into(), + json!(WPK_FORK_MODULE_STATE_REPLAY_EVENTS_HEADER_SIZE), + ); + replay_events_payload.insert( + "known_flags".into(), + json!(WPK_FORK_MODULE_STATE_REPLAY_EVENTS_KNOWN_FLAGS), + ); + replay_events_payload.insert( + "owner".into(), + json!(WPK_FORK_MODULE_STATE_REPLAY_EVENTS_OWNER), + ); + replay_events_payload.insert( + "version".into(), + json!(WPK_FORK_MODULE_STATE_REPLAY_EVENTS_VERSION), + ); + replay_events_payload.insert( + "segment_capacity".into(), + json!(WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_CAPACITY), + ); + replay_events_payload.insert( + "segment_header_size".into(), + json!(WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_HEADER_SIZE), + ); + replay_events_payload.insert( + "segment_known_flags".into(), + json!(WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_KNOWN_FLAGS), + ); + replay_events_payload.insert( + "segment_version".into(), + json!(WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_VERSION), + ); - let exports = WPK_FORK_REQUIRED_EXPORTS - .iter() - .map(|requirement| { - let mut item: JsonMap = BTreeMap::new(); - item.insert("kind".into(), json!("func")); - item.insert("name".into(), json!(requirement.name)); - item.insert("params".into(), value_types(requirement.params)); - item.insert("results".into(), value_types(requirement.results)); - Value::Object(item.into_iter().collect()) - }) - .collect(); + let mut reference_transaction_payload: JsonMap = BTreeMap::new(); + reference_transaction_payload.insert( + "known_flags".into(), + json!(WPK_FORK_REFERENCE_TRANSACTION_KNOWN_FLAGS), + ); + reference_transaction_payload.insert( + "magic".into(), + json!(WPK_FORK_REFERENCE_TRANSACTION_MAGIC), + ); + reference_transaction_payload.insert( + "manifest_size".into(), + json!(WPK_FORK_REFERENCE_TRANSACTION_MANIFEST_SIZE), + ); + reference_transaction_payload.insert( + "node_record_size".into(), + json!(WPK_FORK_REFERENCE_NODE_RECORD_SIZE), + ); + reference_transaction_payload.insert( + "owner".into(), + json!(WPK_FORK_REFERENCE_TRANSACTION_OWNER), + ); + reference_transaction_payload.insert( + "sealed_flag".into(), + json!(WPK_FORK_REFERENCE_TRANSACTION_FLAG_SEALED), + ); + reference_transaction_payload.insert( + "sections".into(), + json!([ + {"number": WPK_FORK_REFERENCE_SECTION_NODES, "name": "nodes"}, + {"number": WPK_FORK_REFERENCE_SECTION_EDGES, "name": "edges"}, + {"number": WPK_FORK_REFERENCE_SECTION_SCALARS, "name": "scalars"}, + {"number": WPK_FORK_REFERENCE_SECTION_VECTOR_INDEX, "name": "vector_index"}, + {"number": WPK_FORK_REFERENCE_SECTION_VECTOR_ENTRIES, "name": "vector_entries"} + ]), + ); + reference_transaction_payload.insert( + "segment_header_size".into(), + json!(WPK_FORK_REFERENCE_SEGMENT_HEADER_SIZE), + ); + reference_transaction_payload.insert( + "segment_known_flags".into(), + json!(WPK_FORK_REFERENCE_SEGMENT_KNOWN_FLAGS), + ); + reference_transaction_payload.insert( + "segment_magic".into(), + json!(WPK_FORK_REFERENCE_SEGMENT_MAGIC), + ); + reference_transaction_payload.insert( + "vector_index_size".into(), + json!(WPK_FORK_REFERENCE_VECTOR_INDEX_SIZE), + ); + reference_transaction_payload.insert( + "version".into(), + json!(WPK_FORK_REFERENCE_TRANSACTION_VERSION), + ); - let pointer_widths = WPK_FORK_LINKED_FRAME_POINTER_WIDTHS - .iter() - .map(|pointer_width| { - let mut item: JsonMap = BTreeMap::new(); - item.insert("bytes".into(), json!(pointer_width)); - item.insert( - "chunk_header_size".into(), - json!( - wpk_fork_linked_chunk_header_size(*pointer_width) - .expect("supported pointer width must have a chunk header") - ), - ); - item.insert( - "node_header_size".into(), - json!( - wpk_fork_linked_node_header_size(*pointer_width) - .expect("supported pointer width must have a node header") - ), - ); - Value::Object(item.into_iter().collect()) - }) - .collect(); + let mut imported_global_bindings_payload: JsonMap = BTreeMap::new(); + imported_global_bindings_payload.insert( + "binding_kinds".into(), + json!([ + {"number": WPK_FORK_IMPORTED_GLOBAL_BINDING_RAW_NUMBER, "name": "raw_number"}, + {"number": WPK_FORK_IMPORTED_GLOBAL_BINDING_RAW_BIGINT, "name": "raw_bigint"}, + {"number": WPK_FORK_IMPORTED_GLOBAL_BINDING_RAW_REFERENCE, "name": "raw_reference"}, + {"number": WPK_FORK_IMPORTED_GLOBAL_BINDING_ACTIVATION_GLOBAL, "name": "activation_global"}, + {"number": WPK_FORK_IMPORTED_GLOBAL_BINDING_BASE_IMPORT, "name": "base_import"} + ]), + ); + imported_global_bindings_payload.insert( + "entry_fields".into(), + json!([ + {"name": "consumer_activation", "offset": 0, "size": 4}, + {"name": "consumer_owner", "offset": 4, "size": 4}, + {"name": "source_activation", "offset": 8, "size": 4}, + {"name": "source_owner", "offset": 12, "size": 4}, + {"name": "reserved", "offset": 16, "size": 4}, + {"name": "recipe_id", "offset": 20, "size": 4}, + {"name": "raw_bits", "offset": 24, "size": 8}, + {"name": "binding_kind", "offset": 32, "size": 1}, + {"name": "import_flags", "offset": 33, "size": 1}, + {"name": "value_type", "offset": 34, "size": 1}, + {"name": "reserved", "offset": 35, "size": 5} + ]), + ); + imported_global_bindings_payload.insert( + "entry_size".into(), + json!(WPK_FORK_IMPORTED_GLOBAL_BINDINGS_ENTRY_SIZE), + ); + imported_global_bindings_payload.insert( + "header_size".into(), + json!(WPK_FORK_IMPORTED_GLOBAL_BINDINGS_HEADER_SIZE), + ); + imported_global_bindings_payload.insert( + "known_flags".into(), + json!(WPK_FORK_IMPORTED_GLOBAL_BINDINGS_KNOWN_FLAGS), + ); + imported_global_bindings_payload.insert( + "magic_bytes".into(), + json!(WPK_FORK_IMPORTED_GLOBAL_BINDINGS_MAGIC), + ); + imported_global_bindings_payload.insert( + "owner".into(), + json!(WPK_FORK_IMPORTED_GLOBAL_BINDINGS_OWNER), + ); + imported_global_bindings_payload.insert( + "version".into(), + json!(WPK_FORK_IMPORTED_GLOBAL_BINDINGS_VERSION), + ); - let mut descriptor: JsonMap = BTreeMap::new(); - descriptor.insert( - "alignment".into(), - json!(WPK_FORK_LINKED_FRAME_RECORD_ALIGNMENT), + let mut activation_continuations_payload: JsonMap = BTreeMap::new(); + activation_continuations_payload.insert( + "entry_fields".into(), + json!([ + {"name": "activation_id", "offset": 0, "size": 4}, + {"name": "flags", "offset": 4, "size": 4}, + {"name": "root", "offset": 8, "size": 8} + ]), ); - descriptor.insert( - "descriptor_size".into(), - json!(WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE), + activation_continuations_payload.insert( + "entry_known_flags".into(), + json!(WPK_FORK_ACTIVATION_CONTINUATION_ENTRY_KNOWN_FLAGS), ); - descriptor.insert( - "flags".into(), + activation_continuations_payload.insert( + "entry_size".into(), + json!(WPK_FORK_ACTIVATION_CONTINUATION_ENTRY_SIZE), + ); + activation_continuations_payload.insert( + "header_size".into(), + json!(WPK_FORK_ACTIVATION_CONTINUATIONS_HEADER_SIZE), + ); + activation_continuations_payload.insert( + "known_flags".into(), + json!(WPK_FORK_ACTIVATION_CONTINUATIONS_KNOWN_FLAGS), + ); + activation_continuations_payload.insert( + "magic_bytes".into(), + json!(WPK_FORK_ACTIVATION_CONTINUATIONS_MAGIC), + ); + activation_continuations_payload.insert( + "owner".into(), + json!(WPK_FORK_ACTIVATION_CONTINUATIONS_OWNER), + ); + activation_continuations_payload.insert( + "version".into(), + json!(WPK_FORK_ACTIVATION_CONTINUATIONS_VERSION), + ); + + let mut imported_table_bindings_payload: JsonMap = BTreeMap::new(); + imported_table_bindings_payload.insert( + "binding_kinds".into(), json!([ { - "bit": WPK_FORK_LINKED_FRAME_FLAG_ABORT_UNWINDING, - "name": "abort_unwinding" + "number": WPK_FORK_IMPORTED_TABLE_BINDING_ACTIVATION_TABLE, + "name": "activation_table" }, { - "bit": WPK_FORK_LINKED_FRAME_FLAG_TRANSACTIONAL_NODES, - "name": "transactional_nodes" + "number": WPK_FORK_IMPORTED_TABLE_BINDING_BASE_IMPORT, + "name": "base_import" } ]), ); - descriptor.insert( + imported_table_bindings_payload.insert( + "entry_fields".into(), + json!([ + {"name": "consumer_activation", "offset": 0, "size": 4}, + {"name": "consumer_owner", "offset": 4, "size": 4}, + {"name": "source_activation", "offset": 8, "size": 4}, + {"name": "source_owner", "offset": 12, "size": 4}, + {"name": "reserved", "offset": 16, "size": 4}, + {"name": "binding_kind", "offset": 20, "size": 1}, + {"name": "reserved", "offset": 21, "size": 3} + ]), + ); + imported_table_bindings_payload.insert( + "entry_size".into(), + json!(WPK_FORK_IMPORTED_TABLE_BINDINGS_ENTRY_SIZE), + ); + imported_table_bindings_payload.insert( + "header_size".into(), + json!(WPK_FORK_IMPORTED_TABLE_BINDINGS_HEADER_SIZE), + ); + imported_table_bindings_payload.insert( + "known_flags".into(), + json!(WPK_FORK_IMPORTED_TABLE_BINDINGS_KNOWN_FLAGS), + ); + imported_table_bindings_payload.insert( "magic_bytes".into(), - json!(WPK_FORK_LINKED_FRAME_FORMAT_MAGIC), + json!(WPK_FORK_IMPORTED_TABLE_BINDINGS_MAGIC), ); - descriptor.insert("pointer_widths".into(), Value::Array(pointer_widths)); - descriptor.insert( - "required_flags".into(), - json!(WPK_FORK_LINKED_FRAME_REQUIRED_FLAGS), + imported_table_bindings_payload.insert( + "owner".into(), + json!(WPK_FORK_IMPORTED_TABLE_BINDINGS_OWNER), ); - descriptor.insert( + imported_table_bindings_payload.insert( + "version".into(), + json!(WPK_FORK_IMPORTED_TABLE_BINDINGS_VERSION), + ); + + let mut module_state_payloads: JsonMap = BTreeMap::new(); + module_state_payloads.insert( + "activation_continuations".into(), + Value::Object(activation_continuations_payload.into_iter().collect()), + ); + module_state_payloads.insert( + "data_segments".into(), + Value::Object(data_segments_payload.into_iter().collect()), + ); + module_state_payloads.insert( + "element_segments".into(), + Value::Object(element_segments_payload.into_iter().collect()), + ); + module_state_payloads.insert( + "imported_global_bindings".into(), + Value::Object(imported_global_bindings_payload.into_iter().collect()), + ); + module_state_payloads.insert( + "imported_table_bindings".into(), + Value::Object(imported_table_bindings_payload.into_iter().collect()), + ); + module_state_payloads.insert( + "module".into(), + Value::Object(module_payload.into_iter().collect()), + ); + module_state_payloads.insert( + "mutable_global".into(), + Value::Object(mutable_global_payload.into_iter().collect()), + ); + module_state_payloads.insert( + "replay_events".into(), + Value::Object(replay_events_payload.into_iter().collect()), + ); + module_state_payloads.insert( + "reference_transaction".into(), + Value::Object(reference_transaction_payload.into_iter().collect()), + ); + module_state_payloads.insert( + "table".into(), + Value::Object(table_payload.into_iter().collect()), + ); + + let mut module_state: JsonMap = BTreeMap::new(); + module_state.insert( + "arena".into(), + Value::Object(module_state_arena.into_iter().collect()), + ); + module_state.insert( + "descriptor".into(), + Value::Object(module_state_descriptor.into_iter().collect()), + ); + module_state.insert( + "record_payloads".into(), + Value::Object(module_state_payloads.into_iter().collect()), + ); + + let mut imported_globals: JsonMap = BTreeMap::new(); + imported_globals.insert( + "header_size".into(), + json!(WPK_FORK_IMPORTED_GLOBALS_HEADER_SIZE), + ); + imported_globals.insert( + "known_flags".into(), + json!(WPK_FORK_IMPORTED_GLOBAL_KNOWN_FLAGS), + ); + imported_globals.insert("magic_bytes".into(), json!(WPK_FORK_IMPORTED_GLOBALS_MAGIC)); + imported_globals.insert( + "mutable_flag".into(), + json!(WPK_FORK_IMPORTED_GLOBAL_FLAG_MUTABLE), + ); + imported_globals.insert( + "shared_flag".into(), + json!(WPK_FORK_IMPORTED_GLOBAL_FLAG_SHARED), + ); + imported_globals.insert( + "record_header_size".into(), + json!(WPK_FORK_IMPORTED_GLOBALS_RECORD_HEADER_SIZE), + ); + imported_globals.insert( + "record_fields".into(), + json!([ + {"name": "record_size", "offset": 0, "size": 4}, + {"name": "owner", "offset": 4, "size": 4}, + {"name": "value_type", "offset": 8, "size": 1}, + {"name": "flags", "offset": 9, "size": 1}, + {"name": "reserved", "offset": 10, "size": 2}, + {"name": "module_name_length", "offset": 12, "size": 4}, + {"name": "field_name_length", "offset": 16, "size": 4}, + {"name": "import_ordinal", "offset": 20, "size": 4} + ]), + ); + imported_globals.insert("section".into(), json!(WPK_FORK_IMPORTED_GLOBALS_SECTION)); + imported_globals.insert("version".into(), json!(WPK_FORK_IMPORTED_GLOBALS_VERSION)); + + let mut imported_tables: JsonMap = BTreeMap::new(); + imported_tables.insert( + "header_size".into(), + json!(WPK_FORK_IMPORTED_TABLES_HEADER_SIZE), + ); + imported_tables.insert( + "known_flags".into(), + json!(WPK_FORK_IMPORTED_TABLE_KNOWN_FLAGS), + ); + imported_tables.insert("magic_bytes".into(), json!(WPK_FORK_IMPORTED_TABLES_MAGIC)); + imported_tables.insert( + "record_header_size".into(), + json!(WPK_FORK_IMPORTED_TABLES_RECORD_HEADER_SIZE), + ); + imported_tables.insert( + "record_fields".into(), + json!([ + {"name": "record_size", "offset": 0, "size": 4}, + {"name": "owner", "offset": 4, "size": 4}, + {"name": "element_type", "offset": 8, "size": 1}, + {"name": "flags", "offset": 9, "size": 1}, + {"name": "reserved", "offset": 10, "size": 2}, + {"name": "module_name_length", "offset": 12, "size": 4}, + {"name": "field_name_length", "offset": 16, "size": 4}, + {"name": "import_ordinal", "offset": 20, "size": 4} + ]), + ); + imported_tables.insert("section".into(), json!(WPK_FORK_IMPORTED_TABLES_SECTION)); + imported_tables.insert( + "table64_flag".into(), + json!(WPK_FORK_IMPORTED_TABLE_FLAG_TABLE64), + ); + imported_tables.insert("version".into(), json!(WPK_FORK_IMPORTED_TABLES_VERSION)); + + let mut exception_codec: JsonMap = BTreeMap::new(); + exception_codec.insert( + "activation_import".into(), + json!({ + "module": WPK_FORK_EXCEPTION_CODEC_IMPORT_MODULE, + "name": WPK_FORK_EXCEPTION_IMPORT_ACTIVATION, + "type": "i32", + "mutable": false + }), + ); + exception_codec.insert( + "header_size".into(), + json!(WPK_FORK_EXCEPTION_CODEC_HEADER_SIZE), + ); + exception_codec.insert("section".into(), json!(WPK_FORK_EXCEPTION_CODEC_SECTION)); + exception_codec.insert( + "tag_record_size".into(), + json!(WPK_FORK_EXCEPTION_CODEC_TAG_RECORD_SIZE), + ); + exception_codec.insert("version".into(), json!(WPK_FORK_EXCEPTION_CODEC_VERSION)); + + let mut gc_codec: JsonMap = BTreeMap::new(); + gc_codec.insert( + "field_record".into(), + json!({ + "size": WPK_FORK_GC_CODEC_FIELD_RECORD_SIZE, + "fields": [ + {"name": "storage", "offset": 0, "size": 1}, + {"name": "flags", "offset": 1, "size": 1}, + {"name": "reserved", "offset": 2, "size": 2}, + {"name": "scalar_offset_or_none", "offset": 4, "size": 4}, + {"name": "reference_ordinal_or_none", "offset": 8, "size": 4} + ] + }), + ); + gc_codec.insert("header_size".into(), json!(WPK_FORK_GC_CODEC_HEADER_SIZE)); + gc_codec.insert( + "layout_record".into(), + json!({ + "size": WPK_FORK_GC_CODEC_LAYOUT_RECORD_SIZE, + "fields": [ + {"name": "layout_id", "offset": 0, "size": 4}, + {"name": "type_ordinal", "offset": 4, "size": 4}, + {"name": "kind", "offset": 8, "size": 1}, + {"name": "constructor", "offset": 9, "size": 1}, + {"name": "flags", "offset": 10, "size": 2}, + {"name": "snapshot_scalar_len_or_stride", "offset": 12, "size": 4}, + {"name": "field_start", "offset": 16, "size": 4}, + {"name": "field_count", "offset": 20, "size": 4}, + {"name": "super_type_ordinal_or_none", "offset": 24, "size": 4}, + {"name": "base_layout_id", "offset": 28, "size": 4}, + {"name": "auxiliary", "offset": 32, "size": 4}, + {"name": "provenance_scalar_len", "offset": 36, "size": 4}, + {"name": "provenance_ref_count", "offset": 40, "size": 4} + ] + }), + ); + gc_codec.insert("magic_bytes".into(), json!(WPK_FORK_GC_CODEC_MAGIC)); + gc_codec.insert("section".into(), json!(WPK_FORK_GC_CODEC_SECTION)); + gc_codec.insert( + "transit_table".into(), + json!({ + "module": shared::abi::WPK_FORK_REFERENCE_CODEC_IMPORT_MODULE, + "name": shared::abi::WPK_FORK_REFERENCE_IMPORT_GC_TRANSIT, + "table64": false, + "element": "anyref", + "minimum": 1, + "maximum": null + }), + ); + gc_codec.insert("version".into(), json!(WPK_FORK_GC_CODEC_VERSION)); + + let mut unwind_transport: JsonMap = BTreeMap::new(); + unwind_transport.insert( + "import".into(), + json!({ + "module": WPK_FORK_UNWIND_TAG_IMPORT_MODULE, + "name": WPK_FORK_UNWIND_TAG_IMPORT_NAME, + "kind": "tag" + }), + ); + unwind_transport.insert( + "payload_arity".into(), + json!(WPK_FORK_UNWIND_TRANSPORT_PAYLOAD_ARITY), + ); + unwind_transport.insert("section".into(), json!(WPK_FORK_UNWIND_TRANSPORT_SECTION)); + unwind_transport.insert("version".into(), json!(WPK_FORK_UNWIND_TRANSPORT_VERSION)); + + let mut static_root_catalog: JsonMap = BTreeMap::new(); + static_root_catalog.insert("export".into(), json!(WPK_FORK_STATIC_ROOT_CATALOG_EXPORT)); + static_root_catalog.insert( + "harvest_export".into(), + json!(WPK_FORK_STATIC_ROOT_HARVEST_EXPORT), + ); + static_root_catalog.insert( + "header_size".into(), + json!(WPK_FORK_STATIC_ROOT_CATALOG_HEADER_SIZE), + ); + static_root_catalog.insert( + "magic_bytes".into(), + json!(WPK_FORK_STATIC_ROOT_CATALOG_MAGIC), + ); + static_root_catalog.insert( "section".into(), - json!(WPK_FORK_LINKED_FRAME_FORMAT_SECTION), + json!(WPK_FORK_STATIC_ROOT_CATALOG_SECTION), ); - descriptor.insert( + static_root_catalog.insert( "version".into(), - json!(WPK_FORK_LINKED_FRAME_FORMAT_VERSION), + json!(WPK_FORK_STATIC_ROOT_CATALOG_VERSION), ); let mut capabilities: JsonMap = BTreeMap::new(); @@ -2052,10 +3849,38 @@ fn program_artifact() -> Value { "capabilities".into(), Value::Object(capabilities.into_iter().collect()), ); + fork.insert( + "exception_codec".into(), + Value::Object(exception_codec.into_iter().collect()), + ); + fork.insert( + "gc_codec".into(), + Value::Object(gc_codec.into_iter().collect()), + ); + fork.insert( + "static_root_catalog".into(), + Value::Object(static_root_catalog.into_iter().collect()), + ); + fork.insert( + "unwind_transport".into(), + Value::Object(unwind_transport.into_iter().collect()), + ); fork.insert( "linked_frame_descriptor".into(), Value::Object(descriptor.into_iter().collect()), ); + fork.insert( + "imported_globals".into(), + Value::Object(imported_globals.into_iter().collect()), + ); + fork.insert( + "imported_tables".into(), + Value::Object(imported_tables.into_iter().collect()), + ); + fork.insert( + "module_state".into(), + Value::Object(module_state.into_iter().collect()), + ); fork.insert("required_exports".into(), Value::Array(exports)); fork.insert("required_imports".into(), Value::Array(imports)); @@ -2611,6 +4436,24 @@ mod tests { ] }) ); + assert_eq!( + fork["exception_codec"], + json!({ + "activation_import": { + "module": "env", + "name": "__wpk_fork_module_activation", + "type": "i32", + "mutable": false + }, + "header_size": 8, + "section": "kandelo.wpk_fork.exception_codec", + "tag_record_size": 16, + "version": 1 + }) + ); + assert_eq!(fork["imported_globals"]["record_header_size"], json!(24)); + assert_eq!(fork["imported_globals"]["known_flags"], json!(3)); + assert_eq!(fork["imported_globals"]["shared_flag"], json!(2)); assert_eq!( descriptor["pointer_widths"], json!([ @@ -2618,9 +4461,60 @@ mod tests { {"bytes": 8, "chunk_header_size": 56, "node_header_size": 32} ]) ); + assert_eq!( + fork["module_state"]["descriptor"]["section"], + json!("kandelo.wpk_fork.module_state") + ); + let record_kinds = fork["module_state"]["arena"]["record"]["kinds"] + .as_array() + .unwrap(); + assert_eq!(record_kinds.len(), 13); + assert_eq!( + record_kinds[11], + json!({"name": "reference_recipe_segment", "number": 12}) + ); + assert_eq!( + record_kinds[12], + json!({"name": "replay_event_segment", "number": 13}) + ); + assert_eq!( + fork["module_state"]["record_payloads"]["mutable_global"]["header_size"], + json!(8) + ); + assert_eq!( + fork["module_state"]["record_payloads"]["replay_events"]["owner"], + json!(1) + ); + assert_eq!( + fork["module_state"]["record_payloads"]["imported_global_bindings"]["entry_size"], + json!(40) + ); + assert_eq!( + fork["module_state"]["record_payloads"]["imported_global_bindings"]["binding_kinds"] + .as_array() + .unwrap() + .len(), + 5 + ); + assert_eq!( + fork["module_state"]["record_payloads"]["activation_continuations"]["entry_size"], + json!(16) + ); + assert_eq!( + fork["module_state"]["record_payloads"]["imported_table_bindings"]["entry_size"], + json!(24) + ); + assert_eq!(fork["imported_tables"]["record_header_size"], json!(24)); + assert_eq!(fork["gc_codec"]["magic_bytes"], json!([75, 70, 71, 67])); + assert_eq!(fork["gc_codec"]["layout_record"]["size"], json!(44)); + assert_eq!(fork["gc_codec"]["field_record"]["size"], json!(12)); + assert_eq!( + fork["gc_codec"]["transit_table"]["element"], + json!("anyref") + ); let imports = fork["required_imports"].as_array().unwrap(); - assert_eq!(imports.len(), 3); + assert_eq!(imports.len(), 47); assert_eq!( imports[0], json!({ @@ -2631,9 +4525,75 @@ mod tests { "results": [] }) ); + assert!(imports.iter().any(|entry| { + entry["name"] == json!("__wpk_fork_module_state_record_reserve") + && entry["params"] == json!(["i32", "i32", "i32", "ptr"]) + && entry["results"] == json!(["ptr"]) + })); + assert!(!imports.iter().any(|entry| { + entry["name"] == json!("__wpk_fork_ref_encode_anyref") + || entry["name"] == json!("__wpk_fork_ref_decode_anyref") + })); + assert!(imports.iter().any(|entry| { + entry["name"] == json!("__wpk_fork_ref_exn_define") + && entry["params"] + == json!(["i32", "i32", "i32", "i32", "ptr", "i32", "ptr", "i32"]) + && entry["results"] == json!([]) + })); + assert!(imports.iter().any(|entry| { + entry["name"] == json!("__wpk_fork_ref_gc_define") + && entry["params"] + == json!(["i32", "i32", "i32", "i32", "i32", "ptr", "i32", "i32"]) + && entry["results"] == json!([]) + })); + assert!(imports.iter().any(|entry| { + entry["kind"] == json!("table") + && entry["name"] == json!("__wpk_fork_ref_gc_transit") + && entry["element"] == json!("anyref") + })); + assert!(imports.iter().any(|entry| { + entry["name"] == json!("__wpk_fork_ref_gc_provenance_begin") + && entry["params"] == json!(["i32", "i32", "i32", "i32", "i64", "i64", "i32"]) + && entry["results"] == json!(["i32"]) + })); + assert!(imports.iter().any(|entry| { + entry["name"] == json!("__wpk_fork_ref_gc_provenance_ref") + && entry["params"] == json!(["i32", "i32", "i32"]) + && entry["results"] == json!([]) + })); + assert!(imports.iter().any(|entry| { + entry["name"] == json!("__wpk_fork_ref_gc_provenance_end") + && entry["params"] == json!(["i32"]) + && entry["results"] == json!([]) + })); let exports = fork["required_exports"].as_array().unwrap(); - assert_eq!(exports.len(), 7); + assert_eq!(exports.len(), 28); + assert!(exports.iter().any(|entry| { + entry["name"] == json!("__wpk_fork_exception_materialize") + && entry["params"] == json!(["i32"]) + && entry["results"] == json!([]) + })); + assert!(exports.iter().any(|entry| { + entry["name"] == json!("__wpk_fork_ref_decode_exnref") + && entry["params"] == json!(["i32"]) + && entry["results"] == json!(["exnref"]) + })); + assert!(exports.iter().any(|entry| { + entry["name"] == json!("__wpk_fork_ref_gc_probe") + && entry["params"] == json!(["i32"]) + && entry["results"] == json!(["i64"]) + })); + assert!(exports.iter().any(|entry| { + entry["name"] == json!("__wpk_fork_ref_gc_publish_externref") + && entry["params"] == json!(["i32", "externref"]) + && entry["results"] == json!([]) + })); + assert!(exports.iter().any(|entry| { + entry["name"] == json!("__wpk_fork_static_root_harvest") + && entry["params"] == json!([]) + && entry["results"] == json!([]) + })); assert!(exports.iter().any(|entry| { entry["name"] == json!("wpk_fork_abort_begin") && entry["params"] == json!(["ptr"]) @@ -2644,19 +4604,45 @@ mod tests { && entry["params"] == json!([]) && entry["results"] == json!(["i32"]) })); + assert!(exports.iter().any(|entry| { + entry["name"] == json!("wpk_fork_module_state_finish_restore") + && entry["params"] == json!(["i32"]) + && entry["results"] == json!([]) + })); + assert!(exports.iter().any(|entry| { + entry["name"] == json!("wpk_fork_module_state_restore") + && entry["params"] == json!(["i32"]) + && entry["results"] == json!([]) + })); assert_eq!( custom_sections(), json!([ "kandelo.wpk_fork.capabilities", + "kandelo.wpk_fork.exception_codec", + "kandelo.wpk_fork.gc_codec", + "kandelo.wpk_fork.imported_globals", + "kandelo.wpk_fork.imported_tables", "kandelo.wpk_fork.linked_frames", + "kandelo.wpk_fork.module_state", + "kandelo.wpk_fork.static_root_catalog", + "kandelo.wpk_fork.unwind_transport", "wasm-posix-abi" ]) ); let rendered = render_ts_module(); for expected in [ "export const WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE = 24 as const;", + "export const WPK_FORK_MODULE_STATE_FORMAT_SECTION = \"kandelo.wpk_fork.module_state\" as const;", + "export const WPK_FORK_MODULE_STATE_RECORD_KIND_TABLE_PAGE = 5 as const;", + "export const WPK_FORK_MODULE_STATE_TABLE_DESCRIPTOR_PAYLOAD_SIZE = 56 as const;", + "export const WPK_FORK_EXPORT_MODULE_STATE_FINISH_RESTORE = \"wpk_fork_module_state_finish_restore\" as const;", "export const WPK_FORK_CAP_ACTIVATION_STATE_SAFE = 4 as const;", + "export const WPK_FORK_EXCEPTION_CODEC_SECTION = \"kandelo.wpk_fork.exception_codec\" as const;", + "export const WPK_FORK_GC_CODEC_SECTION = \"kandelo.wpk_fork.gc_codec\" as const;", + "export const WPK_FORK_GC_CODEC_LAYOUT_RECORD_SIZE = 44 as const;", + "export const WPK_FORK_FRAME_IMPORT_COMMIT = \"__wpk_fork_frame_commit\" as const;", + "export const WPK_FORK_EXPORT_RESUME_START = \"wpk_fork_resume_start\" as const;", "name: \"__wpk_fork_frame_reserve\", params: [\"ptr\"], results: [\"ptr\"]", "name: \"wpk_fork_abort_end\", params: [], results: []", ] { @@ -2852,8 +4838,7 @@ mod tests { fn adding_optional_host_adapter_export_is_compatible() { let old = base_snapshot(); let mut new = old.clone(); - new["host_adapter"]["optional_kernel_exports"] = - json!(["kernel_get_process_exit_signal",]); + new["host_adapter"]["optional_kernel_exports"] = json!(["kernel_get_process_exit_signal",]); let report = classify_compat_change(&old, &new).unwrap(); assert!(report.breaking.is_empty(), "{report:?}"); diff --git a/tools/xtask/src/homebrew_sidecars.rs b/tools/xtask/src/homebrew_sidecars.rs index 65d78bad13..522259056a 100644 --- a/tools/xtask/src/homebrew_sidecars.rs +++ b/tools/xtask/src/homebrew_sidecars.rs @@ -562,10 +562,7 @@ impl Generator<'_> { link_outputs: &mut Vec<(String, Value)>, ) -> Result { require_relative_path(&package.formula_path, "formula_path")?; - require_sha256( - &package.formula_source_sha256, - "formula_source_sha256", - )?; + require_sha256(&package.formula_source_sha256, "formula_source_sha256")?; sha256_file(&self.options.tap_root.join(&package.formula_path))?; let formula_sidecar_path = format!("Kandelo/formula/{}.json", package.name); require_relative_path(&formula_sidecar_path, "formula sidecar path")?; @@ -577,12 +574,8 @@ impl Generator<'_> { let mut provenance_reports = 0; for bottle in &bottles { - let bottle_value = self.generate_bottle( - package, - bottle, - &formula_sidecar_path, - link_outputs, - )?; + let bottle_value = + self.generate_bottle(package, bottle, &formula_sidecar_path, link_outputs)?; if bottle_status(bottle) == "success" { link_manifests += 1; provenance_reports += 1; @@ -803,10 +796,7 @@ impl Generator<'_> { bottle, "archived_formula_sha256", )?; - require_sha256( - expected_archived_formula_sha, - "archived_formula_sha256", - )?; + require_sha256(expected_archived_formula_sha, "archived_formula_sha256")?; let payload_root = required_field(&bottle.payload_root, package, bottle, "payload_root")?; let build = bottle.build.as_ref().ok_or_else(|| { bottle_error(package, bottle, "success bottle requires build evidence") @@ -1304,7 +1294,11 @@ fn verify_bottle_payload( ) -> Result { require_relative_path(payload_root, "payload_root")?; let formula_receipt = format!(".brew/{}.rb", package.name); - if !bottle.receipts.iter().any(|receipt| receipt == &formula_receipt) { + if !bottle + .receipts + .iter() + .any(|receipt| receipt == &formula_receipt) + { return Err(bottle_error( package, bottle, @@ -1312,13 +1306,15 @@ fn verify_bottle_payload( )); } let (entries, archived_formula_sha) = - tar_gz_entries_and_formula_sha(bottle_path, payload_root, &formula_receipt).map_err(|e| { - bottle_error( - package, - bottle, - &format!("cannot inspect bottle payload: {e}"), - ) - })?; + tar_gz_entries_and_formula_sha(bottle_path, payload_root, &formula_receipt).map_err( + |e| { + bottle_error( + package, + bottle, + &format!("cannot inspect bottle payload: {e}"), + ) + }, + )?; for link in &bottle.links { require_relative_path(&link.source, "link source")?; @@ -1387,9 +1383,9 @@ fn tar_gz_entries_and_formula_sha( let mut hasher = Sha256::new(); let mut buffer = [0_u8; 64 * 1024]; loop { - let read = entry.read(&mut buffer).map_err(|e| { - format!("read {} formula receipt: {e}", path.display()) - })?; + let read = entry + .read(&mut buffer) + .map_err(|e| format!("read {} formula receipt: {e}", path.display()))?; if read == 0 { break; } @@ -1580,8 +1576,7 @@ mod tests { }); if status == "success" { bottle["bottle_file"] = json!(bottle_file); - bottle["archived_formula_sha256"] = - json!(sha256_bytes(FORMULA_TEXT.as_bytes())); + bottle["archived_formula_sha256"] = json!(sha256_bytes(FORMULA_TEXT.as_bytes())); bottle["url"] = json!(repository_bottle_url( "kandelo-dev/homebrew-tap-core", "hello", @@ -1673,10 +1668,7 @@ mod tests { let tap_root = dir.path().join("tap"); let input_dir = dir.path().join("inputs"); fs::create_dir_all(&input_dir).unwrap(); - write_text( - &tap_root.join("Formula/hello.rb"), - FORMULA_TEXT, - ); + write_text(&tap_root.join("Formula/hello.rb"), FORMULA_TEXT); let bottle_path = input_dir.join("hello.bottle.tar.gz"); write_bottle(&bottle_path, FORMULA_TEXT); let bottle_sha256 = sha256_file_and_len(&bottle_path).unwrap().0; @@ -1951,9 +1943,8 @@ mod tests { previous.run(None); let previous_metadata: Value = load_json(&previous.tap_root.join("Kandelo/metadata.json")).unwrap(); - let previous_built_from = previous_metadata["packages"][0]["bottles"][0] - ["built_from"] - .clone(); + let previous_built_from = + previous_metadata["packages"][0]["bottles"][0]["built_from"].clone(); let previous_formula_sha = previous_built_from["formula_sha256"] .as_str() .unwrap() @@ -1998,11 +1989,7 @@ mod tests { current.run(Some(&previous.tap_root.join("Kandelo/metadata.json"))); let metadata: Value = load_json(¤t.tap_root.join("Kandelo/metadata.json")).unwrap(); - write_formula_from_metadata( - ¤t.tap_root, - CURRENT_ARCHIVED_FORMULA_TEXT, - &metadata, - ); + write_formula_from_metadata(¤t.tap_root, CURRENT_ARCHIVED_FORMULA_TEXT, &metadata); let bottles = metadata["packages"][0]["bottles"].as_array().unwrap(); let arches: Vec<_> = bottles .iter() diff --git a/tools/xtask/src/homebrew_validate.rs b/tools/xtask/src/homebrew_validate.rs index 4416f6bd72..22276a4ca1 100644 --- a/tools/xtask/src/homebrew_validate.rs +++ b/tools/xtask/src/homebrew_validate.rs @@ -611,12 +611,7 @@ impl Validator<'_> { } } - fn validate_formula_file( - &mut self, - package_name: &str, - package: &Value, - metadata: &Value, - ) { + fn validate_formula_file(&mut self, package_name: &str, package: &Value, metadata: &Value) { let Some(formula_path_rel) = string_at(package, "/formula_path") else { return; }; @@ -981,14 +976,8 @@ impl Validator<'_> { "/repositories/kandelo_repository", "/built_from/kandelo_repository", ), - ( - "/repositories/kandelo_commit", - "/built_from/kandelo_commit", - ), - ( - "/repositories/tap_repository", - "/built_from/tap_repository", - ), + ("/repositories/kandelo_commit", "/built_from/kandelo_commit"), + ("/repositories/tap_repository", "/built_from/tap_repository"), ("/repositories/tap_commit", "/built_from/tap_commit"), ("/formula/sha256", "/built_from/formula_sha256"), ] { @@ -2720,12 +2709,7 @@ mod tests { ); let report = fixture.validate(); - assert!( - report - .errors - .join("\n") - .contains("Formula bottle tags") - ); + assert!(report.errors.join("\n").contains("Formula bottle tags")); } #[test] @@ -2880,9 +2864,7 @@ mod tests { let source_without_class_end = source.strip_suffix("end\n").unwrap(); write_text( &path, - &format!( - "{source_without_class_end} bottle {{ system \"false\" }}\nend\n" - ), + &format!("{source_without_class_end} bottle {{ system \"false\" }}\nend\n"), ); let report = fixture.validate(); diff --git a/tools/xtask/src/package_output_receipt.rs b/tools/xtask/src/package_output_receipt.rs index 92048483b4..8bf9fa8f65 100644 --- a/tools/xtask/src/package_output_receipt.rs +++ b/tools/xtask/src/package_output_receipt.rs @@ -15,9 +15,9 @@ use sha2::{Digest, Sha256}; use crate::build_deps::{Registry, compute_cache_key_sha_for_package, resolve_relative_url}; use crate::index_toml::{EntryStatus, IndexToml}; -use crate::pkg_manifest::{BuildToml, DepsManifest, ManifestKind, TargetArch}; #[cfg(test)] use crate::pkg_manifest::write_cache_provenance; +use crate::pkg_manifest::{BuildToml, DepsManifest, ManifestKind, TargetArch}; use crate::remote_fetch; use crate::util::hex; From cec6aec581e101a75b2889ad962c59a2e87b4853 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sat, 25 Jul 2026 10:29:14 -0400 Subject: [PATCH 04/82] Host: Bound kernel scratch transfers Move host-to-kernel structured transfers into bounded, ABI-described scratch regions owned by the kernel. Replace guest-pointer-dependent marshalling for process, IPC, socket, spawn, and signal state with checked wire formats and exact pointer-width layouts. Generate matching Rust, C, and TypeScript metadata, enforce allocation and copy-back bounds, and cover native, wasm32, wasm64, Node, and browser paths. This is the foundational ABI 43 change; later commits close additional ownership and publication seams. --- Cargo.lock | 7 + abi/snapshot.json | 1397 ++- apps/browser-demos/pages/benchmark/main.ts | 179 +- .../pages/benchmark/optional-urls.ts | 127 + .../pages/test-runner/exec-binaries.ts | 115 + apps/browser-demos/pages/test-runner/main.ts | 159 +- apps/browser-demos/test/epoll-repro.ts | 237 +- .../browser-demos/test/fifo-lifecycle.spec.ts | 19 +- .../opfs-advisory-lock-client-worker.ts | 160 +- .../test/kernel-scratch-runtime.spec.ts | 128 + .../test/path-resolution.spec.ts | 2 +- .../test/process-native-layout.spec.ts | 151 + apps/browser-demos/test/rlimit-fsize.spec.ts | 2 +- .../test/scm-rights-semantics.spec.ts | 131 + .../test/terminal-attributes-api.spec.ts | 4 +- .../browser-demos/test/wait-lifecycle.spec.ts | 4 +- benchmarks/artifact-selection.test.ts | 136 + benchmarks/artifact-selection.ts | 61 +- benchmarks/browser/run-browser.ts | 3 +- benchmarks/programs/spawn-bench.c | 147 +- benchmarks/run.ts | 48 +- benchmarks/spawn-scratch-evidence.ts | 98 + benchmarks/suites/process-lifecycle.ts | 15 - benchmarks/suites/spawn-scratch.ts | 55 + crates/kernel/Cargo.toml | 1 + crates/kernel/src/channel_scratch.rs | 1176 +++ crates/kernel/src/descriptor_backing.rs | 37 +- crates/kernel/src/fork.rs | 82 +- crates/kernel/src/ipc.rs | 314 +- crates/kernel/src/ipc_wire.rs | 722 ++ crates/kernel/src/lib.rs | 5 + crates/kernel/src/mqueue.rs | 72 +- crates/kernel/src/pipe.rs | 902 +- crates/kernel/src/process.rs | 249 +- crates/kernel/src/process_table.rs | 96 +- crates/kernel/src/process_wire.rs | 1346 +++ crates/kernel/src/procfs.rs | 95 +- crates/kernel/src/scratch_alloc.rs | 45 + crates/kernel/src/signal.rs | 202 +- crates/kernel/src/socket.rs | 117 +- crates/kernel/src/socket_wire.rs | 249 + crates/kernel/src/spawn.rs | 876 +- crates/kernel/src/syscalls.rs | 5278 +++++++---- crates/kernel/src/terminal.rs | 27 +- crates/kernel/src/wasm_api.rs | 4030 +++++--- .../wasm_api_channel_pointer_contract.rs | 152 + crates/shared/src/host_abi.rs | 1187 ++- crates/shared/src/ioctl_contract.rs | 255 + crates/shared/src/lib.rs | 604 +- crates/shared/src/process_layout.rs | 277 + docs/abi-versioning.md | 244 +- docs/architecture.md | 444 +- docs/compromising-xfails.md | 7 +- ...26-05-04-non-forking-posix-spawn-design.md | 58 +- ...026-07-25-kernel-scratch-transfer-audit.md | 1319 +++ docs/posix-status.md | 29 +- docs/profiling.md | 133 +- examples/kernel_scratch_browser_test.c | 164 + examples/process_native_layout_test.c | 629 ++ examples/sysv_ipc_test.c | 75 + examples/terminal_attributes_api_test.c | 121 +- examples/timerfd_signalfd_scratch_test.c | 80 + host/src/browser-kernel-host.ts | 18 + host/src/browser-kernel-protocol.ts | 7 + host/src/browser-kernel-worker-entry.ts | 33 +- host/src/compiled-worker-entry.ts | 92 + host/src/generated/abi.ts | 649 +- host/src/kernel-scratch.ts | 2238 +++++ host/src/kernel-worker.ts | 8149 ++++++++++++----- host/src/kernel.ts | 2023 ++-- host/src/node-kernel-host.ts | 31 +- host/src/node-kernel-protocol.ts | 7 + host/src/node-kernel-worker-entry.ts | 19 +- host/src/pathconf.ts | 7 +- host/src/wasi-shim.ts | 86 +- host/src/wasm-guest-pointer.ts | 20 +- host/test/advisory-lock-kernel.test.ts | 127 +- host/test/advisory-lock-retry.test.ts | 10 +- host/test/browser-kernel.test.ts | 36 + host/test/centralized-test-helper.ts | 23 +- host/test/clone-tid-authority.test.ts | 28 +- host/test/compiled-worker-entry.test.ts | 93 + host/test/connect-pending-retry.test.ts | 14 +- host/test/datagram-wakeup.test.ts | 9 +- host/test/deferred-worker-start.test.ts | 23 +- host/test/exec-state-tracking.test.ts | 120 +- host/test/file-shared-memory.test.ts | 44 +- host/test/generated-abi.test.ts | 70 + host/test/global-setup.ts | 111 +- host/test/host-adapter-manifest.test.ts | 18 + host/test/host-process-pointer-width.test.ts | 408 + .../kernel-initialization-lifetime.test.ts | 260 + host/test/kernel-public-scratch.test.ts | 1021 +++ host/test/kernel-scratch-contract.test.ts | 641 ++ host/test/kernel-scratch-region.test.ts | 1696 ++++ ...kernel-scratch-transfer-boundaries.test.ts | 5487 +++++++++++ host/test/kernel-wasm-input-snapshot.test.ts | 133 + host/test/kernel-worker-copyback.test.ts | 129 +- host/test/kernel-worker-test-scratch.ts | 36 + host/test/kernel.test.ts | 345 + host/test/multi-worker.test.ts | 120 +- host/test/pathconf.test.ts | 10 +- host/test/process-native-layout.test.ts | 34 + host/test/process-wait-lifecycle.test.ts | 1138 ++- host/test/program-fixture-freshness.test.ts | 175 + host/test/program-fixture-freshness.ts | 270 + host/test/readdir-atomicity.test.ts | 10 +- host/test/readiness-deadline.test.ts | 24 +- host/test/scm-rights-pipe-lifetime.test.ts | 36 +- host/test/scm-rights-semantics.test.ts | 78 + host/test/select-signal-outcome.test.ts | 7 +- host/test/shared-memory-coherence.test.ts | 90 +- host/test/signal-accept-livelock.test.ts | 26 +- host/test/spawn-blob-transport.test.ts | 1103 ++- host/test/spawn-pid-authority.test.ts | 15 +- host/test/support/kernel-scratch-instance.ts | 277 + host/test/support/wasm-memory-write-audit.ts | 4342 +++++++++ host/test/sysv-ipc.test.ts | 51 +- host/test/timerfd-signalfd-scratch.test.ts | 34 + host/test/wasm-guest-pointer.test.ts | 62 + host/test/wasm-memory-write-audit.test.ts | 2807 ++++++ host/test/wasm64-example-fixture.ts | 35 +- host/tsup.config.ts | 13 +- libc/glue/abi_constants.h | 334 + libc/glue/channel_syscall.c | 217 +- libc/glue/syscall_glue.c | 92 +- libc/glue/syscall_imports.h | 19 +- .../musl-overlay/arch/wasm32posix/bits/stat.h | 22 +- libc/musl-overlay/arch/wasm32posix/kstat.h | 28 +- .../musl-overlay/arch/wasm64posix/bits/stat.h | 22 +- libc/musl-overlay/arch/wasm64posix/kstat.h | 28 +- .../include/bits/kandelo_limits.h | 10 + .../include/bits/kandelo_process_layouts.h | 83 + libc/musl-overlay/include/limits.h | 7 +- .../src/process/wasm32posix/posix_spawn.c | 289 +- .../src/process/wasm32posix/spawn_contract.h | 47 + libc/musl-overlay/src/time/timer_create.c | 41 +- programs/scm-rights-pipe-lifetime.c | 64 +- programs/scm-rights-semantics.c | 1026 +++ scripts/build-musl.sh | 9 + scripts/build-programs.sh | 2 + scripts/check-abi-version.sh | 10 +- scripts/check-fixed-process-layouts.sh | 23 + scripts/check-process-native-layouts.sh | 23 + scripts/check-sysv-ipc-layouts.sh | 23 + tests/abi/fixed-process-layouts.c | 69 + tests/abi/process-native-layouts.c | 303 + tests/abi/sysv-ipc-layouts.c | 104 + tools/xtask/src/dump_abi.rs | 1843 +++- 149 files changed, 55738 insertions(+), 9102 deletions(-) create mode 100644 apps/browser-demos/pages/benchmark/optional-urls.ts create mode 100644 apps/browser-demos/pages/test-runner/exec-binaries.ts create mode 100644 apps/browser-demos/test/kernel-scratch-runtime.spec.ts create mode 100644 apps/browser-demos/test/process-native-layout.spec.ts create mode 100644 apps/browser-demos/test/scm-rights-semantics.spec.ts create mode 100644 benchmarks/spawn-scratch-evidence.ts create mode 100644 benchmarks/suites/spawn-scratch.ts create mode 100644 crates/kernel/src/channel_scratch.rs create mode 100644 crates/kernel/src/ipc_wire.rs create mode 100644 crates/kernel/src/process_wire.rs create mode 100644 crates/kernel/src/scratch_alloc.rs create mode 100644 crates/kernel/src/socket_wire.rs create mode 100644 crates/kernel/tests/wasm_api_channel_pointer_contract.rs create mode 100644 crates/shared/src/ioctl_contract.rs create mode 100644 crates/shared/src/process_layout.rs create mode 100644 docs/plans/2026-07-25-kernel-scratch-transfer-audit.md create mode 100644 examples/kernel_scratch_browser_test.c create mode 100644 examples/process_native_layout_test.c create mode 100644 examples/timerfd_signalfd_scratch_test.c create mode 100644 host/src/compiled-worker-entry.ts create mode 100644 host/src/kernel-scratch.ts create mode 100644 host/test/compiled-worker-entry.test.ts create mode 100644 host/test/host-process-pointer-width.test.ts create mode 100644 host/test/kernel-initialization-lifetime.test.ts create mode 100644 host/test/kernel-public-scratch.test.ts create mode 100644 host/test/kernel-scratch-contract.test.ts create mode 100644 host/test/kernel-scratch-region.test.ts create mode 100644 host/test/kernel-scratch-transfer-boundaries.test.ts create mode 100644 host/test/kernel-wasm-input-snapshot.test.ts create mode 100644 host/test/kernel-worker-test-scratch.ts create mode 100644 host/test/process-native-layout.test.ts create mode 100644 host/test/program-fixture-freshness.test.ts create mode 100644 host/test/program-fixture-freshness.ts create mode 100644 host/test/scm-rights-semantics.test.ts create mode 100644 host/test/support/kernel-scratch-instance.ts create mode 100644 host/test/support/wasm-memory-write-audit.ts create mode 100644 host/test/timerfd-signalfd-scratch.test.ts create mode 100644 host/test/wasm-memory-write-audit.test.ts create mode 100644 libc/musl-overlay/include/bits/kandelo_limits.h create mode 100644 libc/musl-overlay/include/bits/kandelo_process_layouts.h create mode 100644 libc/musl-overlay/src/process/wasm32posix/spawn_contract.h create mode 100644 programs/scm-rights-semantics.c create mode 100755 scripts/check-fixed-process-layouts.sh create mode 100755 scripts/check-process-native-layouts.sh create mode 100755 scripts/check-sysv-ipc-layouts.sh create mode 100644 tests/abi/fixed-process-layouts.c create mode 100644 tests/abi/process-native-layouts.c create mode 100644 tests/abi/sysv-ipc-layouts.c diff --git a/Cargo.lock b/Cargo.lock index d603c8835f..86021156b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -748,6 +748,7 @@ name = "kandelo" version = "0.1.0" dependencies = [ "dlmalloc", + "spin", "wasm-posix-shared", ] @@ -1263,6 +1264,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "spin" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8abadc99fd9c7bbb7d0ca2b31d72a067d0c0dcd7aad25ab8cac71ba91417694b" + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/abi/snapshot.json b/abi/snapshot.json index 1f14930e44..2f167529b4 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -53,30 +53,69 @@ } ], "channel_signal_area": { - "base": 65560, + "area_size": 56, + "base": 65552, + "delivery_size": 56, + "reserved_tail_size": 0, "slots": [ { "meaning": "u32, signal number (0=none)", "name": "SIG_SIGNUM", - "offset": 65560, + "offset": 65552, "size": 4 }, { "meaning": "u32, handler table index", "name": "SIG_HANDLER", - "offset": 65564, + "offset": 65556, "size": 4 }, { "meaning": "u32, sa_flags", "name": "SIG_FLAGS", - "offset": 65568, + "offset": 65560, "size": 4 }, + { + "meaning": "raw u64 sigval bits (wasm32 uses low 32 bits)", + "name": "SIG_SI_VALUE", + "offset": 65564, + "size": 8 + }, { "meaning": "u64 (LE), saved blocked mask", "name": "SIG_OLD_MASK", - "offset": 65576, + "offset": 65572, + "size": 8 + }, + { + "meaning": "i32, siginfo si_code", + "name": "SIG_SI_CODE", + "offset": 65580, + "size": 4 + }, + { + "meaning": "i32, pid or SI_TIMER timer ID", + "name": "SIGINFO_WORD_1", + "offset": 65584, + "size": 4 + }, + { + "meaning": "raw u32 uid bits or i32 SI_TIMER overrun", + "name": "SIGINFO_WORD_2", + "offset": 65588, + "size": 4 + }, + { + "meaning": "u64, caller-native alternate stack pointer or zero", + "name": "SIG_ALT_SP", + "offset": 65592, + "size": 8 + }, + { + "meaning": "u64, caller-native alternate stack size", + "name": "SIG_ALT_SIZE", + "offset": 65600, "size": 8 } ] @@ -206,15 +245,14 @@ "optional_kernel_exports": [ "kernel_reserve_host_region", "kernel_reserve_host_region_at", - "kernel_set_cwd", "kernel_set_max_addr", - "kernel_set_mmap_base", - "kernel_set_process_argv" + "kernel_set_mmap_base" ], "optional_kernel_features": 0, "required_kernel_exports": [ "__abi_version", "kernel_alloc_scratch", + "kernel_clear_process_metadata", "kernel_create_process", "kernel_create_process_with_stdio", "kernel_dequeue_signal", @@ -233,13 +271,25 @@ "kernel_ipc_shmdt_for_process", "kernel_ipc_shmdt_for_task", "kernel_mark_process_signaled", + "kernel_msqid_ds_bytes", "kernel_pipe_has_readers", "kernel_posix_timer_fire", "kernel_prepare_write_operation", + "kernel_push_process_metadata_entry", "kernel_reap_exited_child", "kernel_remove_process", + "kernel_semctl_array_bytes", + "kernel_semid_ds_bytes", "kernel_set_current_tid", + "kernel_set_cwd", + "kernel_shmid_ds_bytes", "kernel_spawn_process", + "kernel_spawn_reserved_process", + "kernel_spawn_scratch_begin", + "kernel_spawn_scratch_cancel", + "kernel_spawn_scratch_capacity", + "kernel_spawn_scratch_pointer", + "kernel_spawn_scratch_retained_capacity", "kernel_thread_exit", "kernel_validate_task", "kernel_wait_child_poll" @@ -283,6 +333,410 @@ "number": 386 } ], + "ioctl_request_contracts": { + "1074025521": { + "argKind": "pointer", + "direction": "in", + "wasm32Size": 4, + "wasm64Size": 4 + }, + "1074291721": { + "argKind": "pointer", + "direction": "in", + "wasm32Size": 8, + "wasm64Size": 8 + }, + "17920": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 160, + "wasm64Size": 160 + }, + "17921": { + "argKind": "pointer", + "direction": "in", + "wasm32Size": 160, + "wasm64Size": 160 + }, + "17922": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 80, + "wasm64Size": 80 + }, + "17926": { + "argKind": "pointer", + "direction": "in", + "wasm32Size": 160, + "wasm64Size": 160 + }, + "19251": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 1, + "wasm64Size": 1 + }, + "19268": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 4, + "wasm64Size": 4 + }, + "19269": { + "argKind": "scalar-i32", + "direction": "none", + "wasm32Size": 0, + "wasm64Size": 0 + }, + "20480": { + "argKind": "none", + "direction": "none", + "wasm32Size": 0, + "wasm64Size": 0 + }, + "20481": { + "argKind": "none", + "direction": "none", + "wasm32Size": 0, + "wasm64Size": 0 + }, + "2147766283": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 4, + "wasm64Size": 4 + }, + "2147767344": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 4, + "wasm64Size": 4 + }, + "21505": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 60, + "wasm64Size": 60 + }, + "21506": { + "argKind": "pointer", + "direction": "in", + "wasm32Size": 60, + "wasm64Size": 60 + }, + "21507": { + "argKind": "pointer", + "direction": "in", + "wasm32Size": 60, + "wasm64Size": 60 + }, + "21508": { + "argKind": "pointer", + "direction": "in", + "wasm32Size": 60, + "wasm64Size": 60 + }, + "21513": { + "argKind": "scalar-i32", + "direction": "none", + "wasm32Size": 0, + "wasm64Size": 0 + }, + "21514": { + "argKind": "scalar-i32", + "direction": "none", + "wasm32Size": 0, + "wasm64Size": 0 + }, + "21515": { + "argKind": "scalar-i32", + "direction": "none", + "wasm32Size": 0, + "wasm64Size": 0 + }, + "21518": { + "argKind": "scalar-i32", + "direction": "none", + "wasm32Size": 0, + "wasm64Size": 0 + }, + "21519": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 4, + "wasm64Size": 4 + }, + "21520": { + "argKind": "pointer", + "direction": "in", + "wasm32Size": 4, + "wasm64Size": 4 + }, + "21523": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 8, + "wasm64Size": 8 + }, + "21524": { + "argKind": "pointer", + "direction": "in", + "wasm32Size": 8, + "wasm64Size": 8 + }, + "21531": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 4, + "wasm64Size": 4 + }, + "21537": { + "argKind": "pointer", + "direction": "in", + "wasm32Size": 4, + "wasm64Size": 4 + }, + "21538": { + "argKind": "none", + "direction": "none", + "wasm32Size": 0, + "wasm64Size": 0 + }, + "21545": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 4, + "wasm64Size": 4 + }, + "21584": { + "argKind": "none", + "direction": "none", + "wasm32Size": 0, + "wasm64Size": 0 + }, + "21585": { + "argKind": "none", + "direction": "none", + "wasm32Size": 0, + "wasm64Size": 0 + }, + "21586": { + "argKind": "pointer", + "direction": "in", + "wasm32Size": 4, + "wasm64Size": 4 + }, + "25630": { + "argKind": "none", + "direction": "none", + "wasm32Size": 0, + "wasm64Size": 0 + }, + "25631": { + "argKind": "none", + "direction": "none", + "wasm32Size": 0, + "wasm64Size": 0 + }, + "3221508098": { + "argKind": "pointer", + "direction": "inout", + "wasm32Size": 4, + "wasm64Size": 4 + }, + "3221508099": { + "argKind": "pointer", + "direction": "inout", + "wasm32Size": 4, + "wasm64Size": 4 + }, + "3221508101": { + "argKind": "pointer", + "direction": "inout", + "wasm32Size": 4, + "wasm64Size": 4 + }, + "3221508102": { + "argKind": "pointer", + "direction": "inout", + "wasm32Size": 4, + "wasm64Size": 4 + }, + "3221508106": { + "argKind": "pointer", + "direction": "inout", + "wasm32Size": 4, + "wasm64Size": 4 + }, + "3221513391": { + "argKind": "pointer", + "direction": "in", + "wasm32Size": 4, + "wasm64Size": 4 + }, + "3221513396": { + "argKind": "pointer", + "direction": "in", + "wasm32Size": 4, + "wasm64Size": 4 + }, + "3222037549": { + "argKind": "pointer", + "direction": "inout", + "wasm32Size": 12, + "wasm64Size": 12 + }, + "3222037550": { + "argKind": "pointer", + "direction": "inout", + "wasm32Size": 12, + "wasm64Size": 12 + }, + "3222299660": { + "argKind": "pointer", + "direction": "inout", + "wasm32Size": 16, + "wasm64Size": 16 + }, + "3222299706": { + "argKind": "pointer", + "direction": "inout", + "wasm32Size": 16, + "wasm64Size": 16 + }, + "3222299827": { + "argKind": "pointer", + "direction": "inout", + "wasm32Size": 16, + "wasm64Size": 16 + }, + "3222561958": { + "argKind": "pointer", + "direction": "inout", + "wasm32Size": 20, + "wasm64Size": 20 + }, + "3222824112": { + "argKind": "pointer", + "direction": "in", + "wasm32Size": 24, + "wasm64Size": 24 + }, + "3223348402": { + "argKind": "pointer", + "direction": "inout", + "wasm32Size": 32, + "wasm64Size": 32 + }, + "3223610368": { + "argKind": "pointer", + "direction": "inout", + "wasm32Size": 36, + "wasm64Size": null + }, + "3225445376": { + "argKind": "pointer", + "direction": "inout", + "wasm32Size": null, + "wasm64Size": 64 + }, + "3225445536": { + "argKind": "pointer", + "direction": "inout", + "wasm32Size": 64, + "wasm64Size": 64 + }, + "3226494119": { + "argKind": "pointer", + "direction": "inout", + "wasm32Size": 80, + "wasm64Size": 80 + }, + "3228066977": { + "argKind": "pointer", + "direction": "inout", + "wasm32Size": 104, + "wasm64Size": 104 + }, + "3228066978": { + "argKind": "pointer", + "direction": "in", + "wasm32Size": 104, + "wasm64Size": 104 + }, + "3228067000": { + "argKind": "pointer", + "direction": "inout", + "wasm32Size": 104, + "wasm64Size": 104 + }, + "35077": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 4, + "wasm64Size": 4 + }, + "64": { + "argKind": "pointer", + "direction": "in", + "wasm32Size": 4, + "wasm64Size": 4 + }, + "65": { + "argKind": "none", + "direction": "none", + "wasm32Size": 0, + "wasm64Size": 0 + }, + "66": { + "argKind": "pointer", + "direction": "in", + "wasm32Size": 16, + "wasm64Size": 16 + }, + "67": { + "argKind": "none", + "direction": "none", + "wasm32Size": 0, + "wasm64Size": 0 + }, + "68": { + "argKind": "pointer", + "direction": "in", + "wasm32Size": 32, + "wasm64Size": 32 + }, + "69": { + "argKind": "none", + "direction": "none", + "wasm32Size": 0, + "wasm64Size": 0 + }, + "70": { + "argKind": "none", + "direction": "none", + "wasm32Size": 0, + "wasm64Size": 0 + }, + "71": { + "argKind": "pointer", + "direction": "in", + "wasm32Size": 8, + "wasm64Size": 8 + }, + "72": { + "argKind": "none", + "direction": "none", + "wasm32Size": 0, + "wasm64Size": 0 + }, + "73": { + "argKind": "pointer", + "direction": "in", + "wasm32Size": 24, + "wasm64Size": null + } + }, "kernel_exports": [ { "kind": "func", @@ -432,7 +886,7 @@ { "kind": "func", "name": "kernel_dequeue_signal", - "signature": "(i32,i32,i32) -> (i32)" + "signature": "(i32,i32,i32,i32) -> (i32)" }, { "kind": "func", @@ -607,7 +1061,7 @@ { "kind": "func", "name": "kernel_fstatfs", - "signature": "(i32,i32) -> (i32)" + "signature": "(i32,i32,i64) -> (i32)" }, { "kind": "func", @@ -777,12 +1231,12 @@ { "kind": "func", "name": "kernel_getgroups", - "signature": "(i32,i32) -> (i32)" + "signature": "(i32,i32,i32) -> (i32)" }, { "kind": "func", "name": "kernel_getitimer", - "signature": "(i32,i32) -> (i32)" + "signature": "(i32,i32,i64) -> (i32)" }, { "kind": "func", @@ -847,7 +1301,7 @@ { "kind": "func", "name": "kernel_getsockopt", - "signature": "(i32,i32,i32,i32,i32) -> (i32)" + "signature": "(i32,i32,i32,i32,i32,i32,i32) -> (i32)" }, { "kind": "func", @@ -867,7 +1321,7 @@ { "kind": "func", "name": "kernel_handle_channel", - "signature": "(i32,i32) -> (i32)" + "signature": "(i32,i32,i32) -> (i32)" }, { "kind": "func", @@ -907,7 +1361,7 @@ { "kind": "func", "name": "kernel_ioctl", - "signature": "(i32,i32,i32,i32) -> (i32)" + "signature": "(i32,i32,i32,i32,i32) -> (i32)" }, { "kind": "func", @@ -1047,7 +1501,7 @@ { "kind": "func", "name": "kernel_mq_drain_notification", - "signature": "(i32) -> (i32)" + "signature": "(i32,i32) -> (i32)" }, { "kind": "func", @@ -1059,6 +1513,11 @@ "name": "kernel_mremap", "signature": "(i32,i32,i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_msqid_ds_bytes", + "signature": "(i32) -> (i32)" + }, { "kind": "func", "name": "kernel_munmap", @@ -1107,7 +1566,7 @@ { "kind": "func", "name": "kernel_pipe2", - "signature": "(i32,i32) -> (i32)" + "signature": "(i32,i32,i32) -> (i32)" }, { "kind": "func", @@ -1147,7 +1606,7 @@ { "kind": "func", "name": "kernel_poll", - "signature": "(i32,i32,i32) -> (i32)" + "signature": "(i32,i32,i32,i32) -> (i32)" }, { "kind": "func", @@ -1347,7 +1806,17 @@ { "kind": "func", "name": "kernel_select", - "signature": "(i32,i32,i32,i32,i32) -> (i32)" + "signature": "(i32,i32,i32,i32,i32,i32,i32,i32) -> (i32)" + }, + { + "kind": "func", + "name": "kernel_semctl_array_bytes", + "signature": "(i32,i32,i32,i32) -> (i32)" + }, + { + "kind": "func", + "name": "kernel_semid_ds_bytes", + "signature": "(i32) -> (i32)" }, { "kind": "func", @@ -1457,7 +1926,7 @@ { "kind": "func", "name": "kernel_setitimer", - "signature": "(i32,i32,i32) -> (i32)" + "signature": "(i32,i32,i32,i64) -> (i32)" }, { "kind": "func", @@ -1494,6 +1963,11 @@ "name": "kernel_setuid", "signature": "(i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_shmid_ds_bytes", + "signature": "(i32) -> (i32)" + }, { "kind": "func", "name": "kernel_shutdown", @@ -1507,7 +1981,7 @@ { "kind": "func", "name": "kernel_sigaltstack", - "signature": "(i32,i32) -> (i32)" + "signature": "(i32,i32,i64) -> (i32)" }, { "kind": "func", @@ -1537,13 +2011,43 @@ { "kind": "func", "name": "kernel_socketpair", - "signature": "(i32,i32,i32,i32) -> (i32)" + "signature": "(i32,i32,i32,i32,i32) -> (i32)" }, { "kind": "func", "name": "kernel_spawn_process", "signature": "(i32,i32,i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_spawn_reserved_process", + "signature": "(i32,i32,i64,i32) -> (i32)" + }, + { + "kind": "func", + "name": "kernel_spawn_scratch_begin", + "signature": "(i32) -> (i64)" + }, + { + "kind": "func", + "name": "kernel_spawn_scratch_cancel", + "signature": "(i64) -> (i32)" + }, + { + "kind": "func", + "name": "kernel_spawn_scratch_capacity", + "signature": "(i64) -> (i32)" + }, + { + "kind": "func", + "name": "kernel_spawn_scratch_pointer", + "signature": "(i64) -> (i32)" + }, + { + "kind": "func", + "name": "kernel_spawn_scratch_retained_capacity", + "signature": "() -> (i32)" + }, { "kind": "func", "name": "kernel_stat", @@ -1552,7 +2056,7 @@ { "kind": "func", "name": "kernel_statfs", - "signature": "(i32,i32,i32) -> (i32)" + "signature": "(i32,i32,i32,i64) -> (i32)" }, { "kind": "func", @@ -1612,7 +2116,7 @@ { "kind": "func", "name": "kernel_timer_create", - "signature": "(i32,i32,i32) -> (i32)" + "signature": "(i32,i32,i32,i64) -> (i32)" }, { "kind": "func", @@ -1712,7 +2216,7 @@ { "kind": "func", "name": "kernel_wait_child_poll", - "signature": "(i32,i32,i32,i32,i32,i32) -> (i32)" + "signature": "(i32,i32,i32,i32,i32,i32,i32) -> (i32)" }, { "kind": "func", @@ -2085,6 +2589,81 @@ ], "size": 32 }, + "KernelCmsghdrWire": { + "fields": [ + { + "name": "cmsg_len", + "offset": 0, + "span": 4 + }, + { + "name": "cmsg_level", + "offset": 4, + "span": 4 + }, + { + "name": "cmsg_type", + "offset": 8, + "span": 4 + } + ], + "size": 12 + }, + "KernelIovecWire": { + "fields": [ + { + "name": "base", + "offset": 0, + "span": 4 + }, + { + "name": "len", + "offset": 4, + "span": 4 + } + ], + "size": 8 + }, + "KernelMsghdrWire": { + "fields": [ + { + "name": "name", + "offset": 0, + "span": 4 + }, + { + "name": "name_len", + "offset": 4, + "span": 4 + }, + { + "name": "iov", + "offset": 8, + "span": 4 + }, + { + "name": "iov_len", + "offset": 12, + "span": 4 + }, + { + "name": "control", + "offset": 16, + "span": 4 + }, + { + "name": "control_len", + "offset": 20, + "span": 4 + }, + { + "name": "flags", + "offset": 24, + "span": 4 + } + ], + "size": 28 + }, "KernelWaitResult": { "fields": [ { @@ -2135,6 +2714,26 @@ ], "size": 16 }, + "WasmEpollEvent": { + "fields": [ + { + "name": "events", + "offset": 0, + "span": 4 + }, + { + "name": "_pad", + "offset": 4, + "span": 4 + }, + { + "name": "data", + "offset": 8, + "span": 8 + } + ], + "size": 16 + }, "WasmFlock": { "fields": [ { @@ -2430,6 +3029,16 @@ ], "size": 72 }, + "WasmSysvMessageHeader": { + "fields": [ + { + "name": "mtype", + "offset": 0, + "span": 8 + } + ], + "size": 8 + }, "WasmTimespec": { "fields": [ { @@ -3167,6 +3776,13 @@ "TIMESTAMP_RESOLUTION": 23, "VDISABLE": 8 }, + "platform_limits": { + "arg_max_bytes": 4194304, + "fd_set_bytes": 128, + "fd_setsize": 1024, + "iov_max": 1024, + "path_max_bytes": 4096 + }, "process_expected_globals": [ "__channel_base", "__tls_base" @@ -3235,6 +3851,108 @@ }, "wasm_page_size": 65536 }, + "process_native_layouts": { + "cmsghdr": { + "wasm32": { + "align": 4, + "data_offset": 12, + "len_offset": 0, + "level_offset": 4, + "size": 12, + "type_offset": 8 + }, + "wasm64": { + "align": 8, + "data_offset": 16, + "len_offset": 0, + "level_offset": 8, + "size": 16, + "type_offset": 12 + } + }, + "iovec": { + "wasm32": { + "base_offset": 0, + "len_offset": 4, + "size": 8 + }, + "wasm64": { + "base_offset": 0, + "len_offset": 8, + "size": 16 + } + }, + "kernel_message_wire": { + "flattened_iovec_count": 1 + }, + "msghdr": { + "wasm32": { + "control_offset": 16, + "controllen_offset": 20, + "flags_offset": 24, + "iov_offset": 8, + "iovlen_offset": 12, + "name_offset": 0, + "namelen_offset": 4, + "size": 28 + }, + "wasm64": { + "control_offset": 32, + "controllen_offset": 40, + "flags_offset": 48, + "iov_offset": 16, + "iovlen_offset": 24, + "name_offset": 0, + "namelen_offset": 8, + "size": 56 + } + }, + "scm_rights": { + "fd_bytes": 4, + "level": 1, + "type": 1 + }, + "sigevent": { + "wasm32": { + "notify_offset": 8, + "payload_offset": 12, + "signo_offset": 4, + "size": 64, + "value_offset": 0, + "value_size": 4 + }, + "wasm64": { + "notify_offset": 12, + "payload_offset": 16, + "signo_offset": 8, + "size": 64, + "value_offset": 0, + "value_size": 8 + } + }, + "siginfo": { + "code_offset": 8, + "errno_offset": 4, + "signo_offset": 0, + "wasm32": { + "pid_offset": 12, + "size": 128, + "uid_offset": 16, + "value_offset": 20, + "value_size": 4 + }, + "wasm64": { + "pid_offset": 16, + "size": 128, + "uid_offset": 20, + "value_offset": 24, + "value_size": 8 + } + }, + "socket_message_flags": { + "trunc": 32 + } + }, "program_artifact": { "fork_instrumentation": { "capabilities": { @@ -4770,11 +5488,68 @@ } } }, + "spawn_contract": { + "action_record": { + "bytes": 28, + "offsets": { + "fd": 4, + "mode": 24, + "newfd": 8, + "oflag": 20, + "op": 0, + "path_len": 16, + "path_off": 12 + } + }, + "attribute_bits": { + "resetids": 1, + "setpgroup": 2, + "setschedparam": 16, + "setscheduler": 32, + "setsid": 128, + "setsigdef": 4, + "setsigmask": 8, + "usevfork": 64 + }, + "count_caps": { + "actions": 1024, + "argv": 4096, + "envp": 4096 + }, + "header": { + "bytes": 40, + "offsets": { + "action_count": 8, + "argc": 0, + "attr_flags": 12, + "envc": 4, + "pad": 20, + "pgrp": 16, + "sigdef": 24, + "sigmask": 32 + } + }, + "opcodes": { + "chdir": 3, + "close": 1, + "dup2": 2, + "fchdir": 4, + "open": 0 + }, + "platform_aliases": { + "arg_max_bytes": 4194304, + "path_max_bytes": 4096 + }, + "string_offset_bytes": 4, + "syscall_number": 500, + "wire_max_bytes": 8417320 + }, "syscall_arg_descriptors": { "1": [ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -4784,6 +5559,7 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -4791,6 +5567,7 @@ { "argIndex": 3, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -4800,6 +5577,7 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -4807,6 +5585,7 @@ { "argIndex": 2, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -4816,6 +5595,7 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -4823,6 +5603,7 @@ { "argIndex": 2, "direction": "out", + "required": true, "size": { "argIndex": 3, "type": "arg" @@ -4844,6 +5625,7 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -4851,6 +5633,7 @@ { "argIndex": 1, "direction": "out", + "required": true, "size": { "argIndex": 2, "type": "arg" @@ -4861,6 +5644,7 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -4868,8 +5652,9 @@ { "argIndex": 1, "direction": "out", + "required": true, "size": { - "size": 88, + "size": 112, "type": "fixed" } } @@ -4878,6 +5663,7 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "size": 8, "type": "fixed" @@ -4888,6 +5674,7 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -4917,6 +5704,7 @@ { "argIndex": 1, "direction": "out", + "required": true, "size": { "argIndex": 2, "type": "deref" @@ -4925,6 +5713,7 @@ { "argIndex": 2, "direction": "inout", + "required": true, "size": { "size": 4, "type": "fixed" @@ -4935,6 +5724,7 @@ { "argIndex": 1, "direction": "out", + "required": true, "size": { "argIndex": 2, "type": "deref" @@ -4943,6 +5733,7 @@ { "argIndex": 2, "direction": "inout", + "required": true, "size": { "size": 4, "type": "fixed" @@ -4953,6 +5744,7 @@ { "argIndex": 3, "direction": "out", + "required": true, "size": { "size": 8, "type": "fixed" @@ -4963,6 +5755,7 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -4970,8 +5763,9 @@ { "argIndex": 1, "direction": "out", + "required": true, "size": { - "size": 88, + "size": 112, "type": "fixed" } } @@ -4980,6 +5774,7 @@ { "argIndex": 0, "direction": "out", + "required": true, "size": { "argIndex": 1, "type": "arg" @@ -4990,6 +5785,7 @@ { "argIndex": 1, "direction": "out", + "required": true, "size": { "argIndex": 2, "type": "arg" @@ -5000,6 +5796,7 @@ { "argIndex": 1, "direction": "out", + "nullable": true, "size": { "size": 16, "type": "fixed" @@ -5010,6 +5807,7 @@ { "argIndex": 2, "direction": "in", + "required": true, "size": { "size": 16, "type": "fixed" @@ -5028,6 +5826,7 @@ { "argIndex": 2, "direction": "in", + "nullable": true, "size": { "size": 32, "type": "fixed" @@ -5038,6 +5837,7 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5045,9 +5845,11 @@ { "argIndex": 2, "direction": "out", + "required": true, "size": { - "size": 72, - "type": "fixed" + "type": "process-layout", + "wasm32Size": 88, + "wasm64Size": 120 } } ], @@ -5055,6 +5857,7 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5064,9 +5867,11 @@ { "argIndex": 2, "direction": "out", + "required": true, "size": { - "size": 72, - "type": "fixed" + "type": "process-layout", + "wasm32Size": 88, + "wasm64Size": 120 } } ], @@ -5074,6 +5879,7 @@ { "argIndex": 0, "direction": "out", + "required": true, "size": { "size": 4, "type": "fixed" @@ -5082,6 +5888,7 @@ { "argIndex": 1, "direction": "out", + "required": true, "size": { "size": 4, "type": "fixed" @@ -5090,6 +5897,7 @@ { "argIndex": 2, "direction": "out", + "required": true, "size": { "size": 4, "type": "fixed" @@ -5100,6 +5908,7 @@ { "argIndex": 0, "direction": "out", + "required": true, "size": { "size": 4, "type": "fixed" @@ -5108,6 +5917,7 @@ { "argIndex": 1, "direction": "out", + "required": true, "size": { "size": 4, "type": "fixed" @@ -5116,36 +5926,30 @@ { "argIndex": 2, "direction": "out", + "required": true, "size": { "size": 4, "type": "fixed" } } ], - "137": [ + "136": [ { "argIndex": 1, "direction": "in", + "required": true, "size": { - "argIndex": 2, + "argIndex": 0, + "multiplier": 4, "type": "arg" } } ], - "138": [ + "139": [ { "argIndex": 1, - "direction": "inout", - "size": { - "argIndex": 2, - "type": "arg" - } - } - ], - "139": [ - { - "argIndex": 1, - "direction": "out", + "direction": "out", + "nullable": true, "size": { "size": 4, "type": "fixed" @@ -5154,6 +5958,7 @@ { "argIndex": 3, "direction": "out", + "nullable": true, "size": { "size": 144, "type": "fixed" @@ -5164,6 +5969,7 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5173,6 +5979,7 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5180,8 +5987,9 @@ { "argIndex": 1, "direction": "out", + "required": true, "size": { - "size": 256, + "size": 4, "type": "fixed" } } @@ -5190,6 +5998,7 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5199,6 +6008,7 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5206,6 +6016,7 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5215,6 +6026,7 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5222,6 +6034,7 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5231,6 +6044,7 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5238,6 +6052,7 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5247,6 +6062,7 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5254,6 +6070,7 @@ { "argIndex": 1, "direction": "out", + "required": true, "size": { "argIndex": 2, "type": "arg" @@ -5264,6 +6081,7 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5273,9 +6091,11 @@ { "argIndex": 2, "direction": "in", + "required": true, "size": { - "size": 128, - "type": "fixed" + "type": "process-layout", + "wasm32Size": 128, + "wasm64Size": 128 } } ], @@ -5283,6 +6103,7 @@ { "argIndex": 0, "direction": "out", + "required": true, "size": { "size": 8, "type": "fixed" @@ -5293,6 +6114,7 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "size": 8, "type": "fixed" @@ -5301,14 +6123,17 @@ { "argIndex": 1, "direction": "out", + "nullable": true, "size": { - "size": 128, - "type": "fixed" + "type": "process-layout", + "wasm32Size": 128, + "wasm64Size": 128 } }, { "argIndex": 2, "direction": "in", + "nullable": true, "size": { "size": 16, "type": "fixed" @@ -5319,17 +6144,21 @@ { "argIndex": 0, "direction": "in", + "nullable": true, "size": { - "size": 12, - "type": "fixed" + "type": "process-layout", + "wasm32Size": 12, + "wasm64Size": 24 } }, { "argIndex": 1, "direction": "out", + "nullable": true, "size": { - "size": 12, - "type": "fixed" + "type": "process-layout", + "wasm32Size": 12, + "wasm64Size": 24 } } ], @@ -5337,6 +6166,7 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5346,6 +6176,7 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5355,28 +6186,21 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } } ], - "223": [ - { - "argIndex": 1, - "direction": "inout", - "size": { - "size": 16, - "type": "fixed" - } - } - ], "224": [ { "argIndex": 1, "direction": "out", + "required": true, "size": { - "size": 16, - "type": "fixed" + "type": "process-layout", + "wasm32Size": 16, + "wasm64Size": 32 } } ], @@ -5384,17 +6208,21 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { - "size": 16, - "type": "fixed" + "type": "process-layout", + "wasm32Size": 16, + "wasm64Size": 32 } }, { "argIndex": 2, "direction": "out", + "nullable": true, "size": { - "size": 16, - "type": "fixed" + "type": "process-layout", + "wasm32Size": 16, + "wasm64Size": 32 } } ], @@ -5402,6 +6230,7 @@ { "argIndex": 0, "direction": "out", + "required": true, "size": { "argIndex": 1, "type": "arg" @@ -5412,8 +6241,31 @@ { "argIndex": 1, "direction": "out", + "required": true, + "size": { + "size": 48, + "type": "fixed" + } + } + ], + "231": [ + { + "argIndex": 1, + "direction": "in", + "required": true, + "size": { + "size": 48, + "type": "fixed" + } + } + ], + "233": [ + { + "argIndex": 2, + "direction": "in", + "required": true, "size": { - "size": 36, + "size": 48, "type": "fixed" } } @@ -5422,6 +6274,7 @@ { "argIndex": 1, "direction": "out", + "required": true, "size": { "size": 16, "type": "fixed" @@ -5443,15 +6296,59 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } } ], + "244": [ + { + "argIndex": 2, + "direction": "in", + "required": true, + "size": { + "size": 32, + "type": "fixed" + } + }, + { + "argIndex": 3, + "direction": "out", + "nullable": true, + "size": { + "size": 32, + "type": "fixed" + } + } + ], + "245": [ + { + "argIndex": 1, + "direction": "out", + "required": true, + "size": { + "size": 32, + "type": "fixed" + } + } + ], + "246": [ + { + "argIndex": 1, + "direction": "in", + "required": true, + "size": { + "size": 8, + "type": "fixed" + } + } + ], "25": [ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5461,6 +6358,7 @@ { "argIndex": 2, "direction": "in", + "nullable": true, "size": { "size": 16, "type": "fixed" @@ -5469,6 +6367,7 @@ { "argIndex": 3, "direction": "out", + "nullable": true, "size": { "size": 16, "type": "fixed" @@ -5479,6 +6378,7 @@ { "argIndex": 0, "direction": "inout", + "required": true, "size": { "argIndex": 1, "multiplier": 8, @@ -5486,10 +6386,21 @@ } } ], + "256": [ + { + "argIndex": 0, + "direction": "in", + "required": true, + "size": { + "type": "cstring" + } + } + ], "26": [ { "argIndex": 1, "direction": "out", + "required": true, "size": { "size": 16, "type": "fixed" @@ -5498,6 +6409,7 @@ { "argIndex": 2, "direction": "out", + "required": true, "size": { "argIndex": 3, "type": "arg" @@ -5508,6 +6420,7 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5515,16 +6428,30 @@ { "argIndex": 4, "direction": "out", + "required": true, "size": { "size": 256, "type": "fixed" } } ], + "269": [ + { + "argIndex": 0, + "direction": "out", + "required": true, + "size": { + "type": "process-layout", + "wasm32Size": 312, + "wasm64Size": 368 + } + } + ], "271": [ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5534,6 +6461,7 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5545,8 +6473,9 @@ "direction": "out", "required": true, "size": { - "size": 128, - "type": "fixed" + "type": "process-layout", + "wasm32Size": 128, + "wasm64Size": 128 } }, { @@ -5559,10 +6488,62 @@ } } ], + "290": [ + { + "argIndex": 1, + "direction": "inout", + "nullable": true, + "size": { + "size": 8, + "type": "fixed" + } + }, + { + "argIndex": 3, + "direction": "inout", + "nullable": true, + "size": { + "size": 8, + "type": "fixed" + } + } + ], + "291": [ + { + "argIndex": 1, + "direction": "inout", + "nullable": true, + "size": { + "size": 8, + "type": "fixed" + } + }, + { + "argIndex": 3, + "direction": "inout", + "nullable": true, + "size": { + "size": 8, + "type": "fixed" + } + } + ], + "294": [ + { + "argIndex": 2, + "direction": "inout", + "nullable": true, + "size": { + "size": 8, + "type": "fixed" + } + } + ], "299": [ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5572,24 +6553,66 @@ { "argIndex": 1, "direction": "out", + "required": true, "size": { "argIndex": 2, "type": "arg" } } ], - "326": [ + "306": [ { "argIndex": 1, "direction": "in", + "required": true, "size": { - "size": 16, + "type": "cstring" + } + }, + { + "argIndex": 3, + "direction": "in", + "required": true, + "size": { + "type": "cstring" + } + } + ], + "325": [ + { + "argIndex": 0, + "direction": "out", + "nullable": true, + "size": { + "size": 4, + "type": "fixed" + } + }, + { + "argIndex": 1, + "direction": "out", + "nullable": true, + "size": { + "size": 4, "type": "fixed" } + } + ], + "326": [ + { + "argIndex": 1, + "direction": "in", + "nullable": true, + "size": { + "type": "process-layout", + "wasm32Size": 64, + "wasm64Size": 64 + } }, { "argIndex": 2, "direction": "out", + "required": true, "size": { "size": 4, "type": "fixed" @@ -5600,6 +6623,7 @@ { "argIndex": 2, "direction": "in", + "required": true, "size": { "size": 32, "type": "fixed" @@ -5608,6 +6632,7 @@ { "argIndex": 3, "direction": "out", + "nullable": true, "size": { "size": 32, "type": "fixed" @@ -5618,6 +6643,7 @@ { "argIndex": 1, "direction": "out", + "required": true, "size": { "size": 32, "type": "fixed" @@ -5628,6 +6654,7 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5635,9 +6662,11 @@ { "argIndex": 3, "direction": "in", + "nullable": true, "size": { - "size": 32, - "type": "fixed" + "type": "process-layout", + "wasm32Size": 32, + "wasm64Size": 64 } } ], @@ -5645,6 +6674,7 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5654,6 +6684,7 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { "argIndex": 2, "type": "arg" @@ -5662,6 +6693,7 @@ { "argIndex": 4, "direction": "in", + "nullable": true, "size": { "size": 16, "type": "fixed" @@ -5672,6 +6704,7 @@ { "argIndex": 1, "direction": "out", + "required": true, "size": { "argIndex": 2, "type": "arg" @@ -5680,6 +6713,7 @@ { "argIndex": 3, "direction": "out", + "nullable": true, "size": { "size": 4, "type": "fixed" @@ -5688,6 +6722,7 @@ { "argIndex": 4, "direction": "in", + "nullable": true, "size": { "size": 16, "type": "fixed" @@ -5698,9 +6733,11 @@ { "argIndex": 1, "direction": "in", + "nullable": true, "size": { - "size": 16, - "type": "fixed" + "type": "process-layout", + "wasm32Size": 64, + "wasm64Size": 64 } } ], @@ -5708,50 +6745,21 @@ { "argIndex": 1, "direction": "in", + "nullable": true, "size": { - "size": 32, - "type": "fixed" + "type": "process-layout", + "wasm32Size": 32, + "wasm64Size": 64 } }, { "argIndex": 2, "direction": "out", + "nullable": true, "size": { - "size": 32, - "type": "fixed" - } - } - ], - "338": [ - { - "argIndex": 1, - "copyRetvalAdd": 4, - "direction": "out", - "size": { - "add": 4, - "argIndex": 2, - "type": "arg" - } - } - ], - "339": [ - { - "argIndex": 1, - "direction": "in", - "size": { - "add": 4, - "argIndex": 2, - "type": "arg" - } - } - ], - "340": [ - { - "argIndex": 2, - "direction": "inout", - "size": { - "size": 96, - "type": "fixed" + "type": "process-layout", + "wasm32Size": 32, + "wasm64Size": 64 } } ], @@ -5759,6 +6767,7 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { "argIndex": 2, "multiplier": 6, @@ -5766,20 +6775,11 @@ } } ], - "347": [ - { - "argIndex": 2, - "direction": "inout", - "size": { - "size": 88, - "type": "fixed" - } - } - ], "36": [ { "argIndex": 1, "direction": "in", + "nullable": true, "size": { "size": 16, "type": "fixed" @@ -5788,6 +6788,7 @@ { "argIndex": 2, "direction": "out", + "nullable": true, "size": { "size": 16, "type": "fixed" @@ -5798,6 +6799,7 @@ { "argIndex": 1, "direction": "in", + "nullable": true, "size": { "size": 8, "type": "fixed" @@ -5806,6 +6808,18 @@ { "argIndex": 2, "direction": "out", + "nullable": true, + "size": { + "size": 8, + "type": "fixed" + } + } + ], + "377": [ + { + "argIndex": 1, + "direction": "in", + "required": true, "size": { "size": 8, "type": "fixed" @@ -5816,6 +6830,7 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5825,6 +6840,7 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5834,6 +6850,7 @@ { "argIndex": 1, "direction": "out", + "nullable": true, "size": { "argIndex": 2, "type": "deref" @@ -5842,6 +6859,7 @@ { "argIndex": 2, "direction": "inout", + "nullable": true, "size": { "size": 4, "type": "fixed" @@ -5852,6 +6870,7 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { "argIndex": 2, "type": "arg" @@ -5862,6 +6881,7 @@ { "argIndex": 1, "direction": "out", + "required": true, "size": { "size": 16, "type": "fixed" @@ -5872,6 +6892,7 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "size": 16, "type": "fixed" @@ -5882,6 +6903,7 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5889,6 +6911,7 @@ { "argIndex": 1, "direction": "out", + "required": true, "size": { "argIndex": 2, "type": "arg" @@ -5899,6 +6922,7 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5906,6 +6930,7 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5915,6 +6940,7 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -5924,6 +6950,7 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { "argIndex": 2, "type": "arg" @@ -5934,6 +6961,7 @@ { "argIndex": 1, "direction": "out", + "nullable": true, "size": { "argIndex": 2, "type": "deref" @@ -5942,6 +6970,7 @@ { "argIndex": 2, "direction": "inout", + "nullable": true, "size": { "size": 4, "type": "fixed" @@ -5952,6 +6981,7 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { "argIndex": 2, "type": "arg" @@ -5962,6 +6992,7 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { "argIndex": 2, "type": "arg" @@ -5972,6 +7003,7 @@ { "argIndex": 1, "direction": "out", + "required": true, "size": { "argIndex": 2, "type": "arg" @@ -5982,6 +7014,7 @@ { "argIndex": 3, "direction": "out", + "required": true, "size": { "argIndex": 4, "type": "deref" @@ -5990,6 +7023,7 @@ { "argIndex": 4, "direction": "inout", + "required": true, "size": { "size": 4, "type": "fixed" @@ -6000,6 +7034,7 @@ { "argIndex": 3, "direction": "in", + "required": true, "size": { "argIndex": 4, "type": "arg" @@ -6010,8 +7045,9 @@ { "argIndex": 1, "direction": "out", + "required": true, "size": { - "size": 88, + "size": 112, "type": "fixed" } } @@ -6020,6 +7056,7 @@ { "argIndex": 0, "direction": "inout", + "required": true, "size": { "argIndex": 1, "multiplier": 8, @@ -6031,6 +7068,7 @@ { "argIndex": 3, "direction": "out", + "required": true, "size": { "size": 8, "type": "fixed" @@ -6041,6 +7079,7 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { "argIndex": 2, "type": "arg" @@ -6049,6 +7088,7 @@ { "argIndex": 4, "direction": "in", + "required": true, "size": { "argIndex": 5, "type": "arg" @@ -6059,6 +7099,7 @@ { "argIndex": 1, "direction": "out", + "required": true, "size": { "argIndex": 2, "type": "arg" @@ -6067,6 +7108,7 @@ { "argIndex": 4, "direction": "out", + "nullable": true, "size": { "argIndex": 5, "type": "deref" @@ -6075,6 +7117,7 @@ { "argIndex": 5, "direction": "inout", + "nullable": true, "size": { "size": 4, "type": "fixed" @@ -6085,6 +7128,7 @@ { "argIndex": 1, "direction": "out", + "required": true, "size": { "argIndex": 2, "type": "arg" @@ -6095,6 +7139,7 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { "argIndex": 2, "type": "arg" @@ -6105,6 +7150,7 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -6114,8 +7160,9 @@ { "argIndex": 1, "direction": "out", + "required": true, "size": { - "size": 256, + "size": 60, "type": "fixed" } } @@ -6124,18 +7171,9 @@ { "argIndex": 2, "direction": "in", + "required": true, "size": { - "size": 256, - "type": "fixed" - } - } - ], - "72": [ - { - "argIndex": 2, - "direction": "inout", - "size": { - "size": 256, + "size": 60, "type": "fixed" } } @@ -6144,6 +7182,7 @@ { "argIndex": 0, "direction": "out", + "required": true, "size": { "size": 390, "type": "fixed" @@ -6154,6 +7193,7 @@ { "argIndex": 0, "direction": "out", + "required": true, "size": { "size": 8, "type": "fixed" @@ -6164,6 +7204,7 @@ { "argIndex": 1, "direction": "out", + "required": true, "size": { "size": 16, "type": "fixed" @@ -6174,6 +7215,7 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { "size": 16, "type": "fixed" @@ -6184,6 +7226,7 @@ { "argIndex": 0, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -6193,6 +7236,7 @@ { "argIndex": 0, "direction": "out", + "required": true, "size": { "size": 8, "type": "fixed" @@ -6203,6 +7247,7 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -6210,8 +7255,9 @@ { "argIndex": 2, "direction": "out", + "required": true, "size": { - "size": 88, + "size": 112, "type": "fixed" } } @@ -6220,6 +7266,7 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -6229,6 +7276,7 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -6238,6 +7286,7 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -6245,6 +7294,7 @@ { "argIndex": 3, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -6254,6 +7304,7 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -6263,6 +7314,7 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -6272,6 +7324,7 @@ { "argIndex": 1, "direction": "in", + "required": true, "size": { "type": "cstring" } @@ -6855,6 +7908,10 @@ "name": "SetTidAddress", "number": 203 }, + { + "name": "Tkill", + "number": 204 + }, { "name": "RtSigqueueinfo", "number": 205 @@ -6911,6 +7968,14 @@ "name": "SchedGetparam", "number": 230 }, + { + "name": "SchedSetparam", + "number": 231 + }, + { + "name": "SchedSetscheduler", + "number": 233 + }, { "name": "SchedRrGetInterval", "number": 236 @@ -6931,6 +7996,22 @@ "name": "EpollPwait", "number": 241 }, + { + "name": "TimerfdCreate", + "number": 243 + }, + { + "name": "TimerfdSettime", + "number": 244 + }, + { + "name": "TimerfdGettime", + "number": 245 + }, + { + "name": "Signalfd4", + "number": 246 + }, { "name": "Prlimit64", "number": 250 @@ -6943,6 +8024,10 @@ "name": "Pselect6", "number": 252 }, + { + "name": "MemfdCreate", + "number": 256 + }, { "name": "Statx", "number": 260 @@ -6955,6 +8040,10 @@ "name": "GetRobustList", "number": 262 }, + { + "name": "Sysinfo", + "number": 269 + }, { "name": "Mknod", "number": 271 @@ -6971,6 +8060,14 @@ "name": "Waitid", "number": 288 }, + { + "name": "CopyFileRange", + "number": 290 + }, + { + "name": "Splice", + "number": 291 + }, { "name": "Sendfile", "number": 294 @@ -6983,14 +8080,30 @@ "name": "Pwritev", "number": 296 }, + { + "name": "Preadv2", + "number": 297 + }, + { + "name": "Pwritev2", + "number": 298 + }, { "name": "Lchown", "number": 299 }, + { + "name": "Renameat2", + "number": 306 + }, { "name": "Fallocate", "number": 308 }, + { + "name": "Getcpu", + "number": 325 + }, { "name": "TimerCreate", "number": 326 @@ -7079,6 +8192,10 @@ "name": "Shmctl", "number": 347 }, + { + "name": "Signalfd", + "number": 377 + }, { "name": "EpollCreate", "number": 378 diff --git a/apps/browser-demos/pages/benchmark/main.ts b/apps/browser-demos/pages/benchmark/main.ts index 73ae0bb3cb..e81d12eb77 100644 --- a/apps/browser-demos/pages/benchmark/main.ts +++ b/apps/browser-demos/pages/benchmark/main.ts @@ -7,6 +7,7 @@ * Supported suites: * - "syscall-io": pipe/file throughput and syscall latency * - "process-lifecycle": hello start, fork, clone + * - "spawn-scratch": ordinary/large spawn and retained scratch capacity * - "wordpress": nginx + PHP-FPM boot with WordPress page load * - "mariadb-aria": MariaDB with Aria engine * - "mariadb-innodb": MariaDB with InnoDB engine @@ -21,6 +22,7 @@ import { } from "../../lib/kernel-owned-boot"; import { writeVfsFile } from "../../lib/init/vfs-utils"; import { MySqlBrowserClient } from "../../lib/mysql-client"; +import { collectSpawnScratchEvidence } from "../../../../benchmarks/spawn-scratch-evidence"; // Micro-benchmark wasm URLs (always built by scripts/build-programs.sh) import pipeWasmUrl from "../../../../benchmarks/wasm/pipe-throughput.wasm?url"; @@ -34,140 +36,27 @@ import helloWasmUrl from "../../../../benchmarks/wasm/hello.wasm?url"; // Kernel import kernelWasmUrl from "@kernel-wasm?url"; -/** - * Optional application-binary URL imports are resolved via `import.meta.glob`. - * Static top-level `?url` imports fail the whole page load if any file is - * missing, which hangs every suite on the Playwright harness — even - * micro-benchmarks that don't need the missing binary. `import.meta.glob` - * returns an empty map for missing files, so missing binaries surface as a - * per-suite failure with a helpful build hint instead of blocking page load. - */ -// Paths are relative to this file (apps/browser-demos/pages/benchmark/main.ts). -// Vite normalizes glob result keys, so callers must use the same relative -// strings declared here. -const OPTIONAL_URLS = { - ...import.meta.glob("../../../../packages/registry/erlang/bin/beam.wasm", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../packages/registry/erlang/beam.wasm", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../packages/registry/nginx/nginx.wasm", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../local-binaries/programs/wasm32/nginx.wasm", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../binaries/programs/wasm32/nginx.wasm", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../binaries/programs/wasm32/php/php-fpm.wasm", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../local-binaries/programs/wasm32/php/php-fpm.wasm", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../packages/registry/coreutils/bin/coreutils.wasm", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../local-binaries/programs/wasm32/coreutils.wasm", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../binaries/programs/wasm32/coreutils.wasm", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../packages/registry/grep/bin/grep.wasm", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../local-binaries/programs/wasm32/grep.wasm", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../binaries/programs/wasm32/grep.wasm", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../packages/registry/sed/bin/sed.wasm", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../local-binaries/programs/wasm32/sed.wasm", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../binaries/programs/wasm32/sed.wasm", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../packages/registry/mariadb/mariadb-install/bin/mariadbd.wasm", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../local-binaries/programs/wasm32/mariadb/mariadbd.wasm", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../binaries/programs/wasm32/mariadb/mariadbd.wasm", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../packages/registry/mariadb/mariadb-install/share/mysql/mysql_system_tables.sql", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../packages/registry/mariadb/mariadb-install/share/mysql/mysql_system_tables_data.sql", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../packages/registry/mariadb/mariadb-install-64/bin/mariadbd.wasm", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../local-binaries/programs/wasm64/mariadb/mariadbd.wasm", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../binaries/programs/wasm64/mariadb/mariadbd.wasm", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../packages/registry/mariadb/mariadb-install-64/share/mysql/mysql_system_tables.sql", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../packages/registry/mariadb/mariadb-install-64/share/mysql/mysql_system_tables_data.sql", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../local-binaries/programs/wasm32/erlang-vfs.vfs.zst", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../binaries/programs/wasm32/erlang-vfs.vfs.zst", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../public/erlang.vfs.zst", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../local-binaries/programs/wasm32/wordpress.vfs.zst", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../binaries/programs/wasm32/wordpress.vfs.zst", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../public/wordpress.vfs.zst", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../public/mariadb.vfs.zst", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../public/mariadb-64.vfs.zst", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../local-binaries/programs/wasm32/mariadb-vfs.vfs.zst", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../binaries/programs/wasm32/mariadb-vfs.vfs.zst", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../local-binaries/programs/wasm64/mariadb-vfs.vfs.zst", { - query: "?url", import: "default", - }), - ...import.meta.glob("../../../../binaries/programs/wasm64/mariadb-vfs.vfs.zst", { - query: "?url", import: "default", - }), -} as Record Promise>; +type OptionalUrlLoaders = Record Promise>; + +let optionalUrlLoadersPromise: Promise | undefined; + +function optionalUrlLoaders(): Promise { + // WHY: process/syscall micro-benchmarks do not consume application packages. + // Loading this graph only for application suites keeps an unrelated missing + // package from blocking those measurements; the application suite still + // resolves the same policy-checked URL when it actually requests the asset. + optionalUrlLoadersPromise ??= import("./optional-urls").then( + ({ OPTIONAL_URLS }) => OPTIONAL_URLS, + ); + return optionalUrlLoadersPromise; +} async function loadOptionalUrl( relPath: string, label: string, buildHint: string, ): Promise { - const loader = OPTIONAL_URLS[relPath]; + const loader = (await optionalUrlLoaders())[relPath]; if (!loader) { throw new Error(`${label} is not built. Run: ${buildHint}`); } @@ -179,9 +68,10 @@ async function loadOptionalUrlFrom( label: string, buildHint: string, ): Promise { + const loaders = await optionalUrlLoaders(); for (const relPath of relPaths) { - const loader = OPTIONAL_URLS[relPath]; - if (loader) return loadOptionalUrl(relPath, label, buildHint); + const loader = loaders[relPath]; + if (loader) return loader(); } throw new Error(`${label} is not built. Run: ${buildHint}`); } @@ -258,7 +148,12 @@ async function runProgramWithExecMap( programBytes: ArrayBuffer, argv: string[], execMap: Array<{ path: string; url: string; size: number }>, -): Promise<{ exitCode: number; stdout: string }> { +): Promise<{ + exitCode: number; + stdout: string; + spawnScratchCapacity: number; + kernelMemoryPages: number; +}> { let stdout = ""; // Bake the exec-map entries into the image as lazy files; the worker fetches // them on demand when the child execs them. @@ -276,7 +171,12 @@ async function runProgramWithExecMap( try { await kernel.initFromImage({ vfsImage }); const exitCode = await kernel.spawn(programBytes, argv); - return { exitCode, stdout }; + return { + exitCode, + stdout, + spawnScratchCapacity: await kernel.getSpawnScratchCapacity(), + kernelMemoryPages: await kernel.getKernelMemoryPages(), + }; } finally { try { await kernel.destroy(); } catch {} await settleWebKitReclaim(); @@ -333,6 +233,12 @@ async function runProcessLifecycle(): Promise> { if (clone.exitCode !== 0) throw new Error("clone-bench failed"); Object.assign(results, parseMetrics(clone.stdout)); + return results; +} + +// ─── spawn-scratch ───────────────────────────────────────────────────────── + +async function runSpawnScratch(): Promise> { log(" Running spawn-bench..."); // posix_spawn — non-forking SYS_SPAWN fast path. The child is the // existing hello.wasm; pre-stage it at /bin/hello via registerLazyFiles @@ -345,9 +251,11 @@ async function runProcessLifecycle(): Promise> { { path: "/bin/hello", url: helloWasmUrl, size: helloSize }, ]); if (spawn.exitCode !== 0) throw new Error(`spawn-bench failed: ${spawn.stdout}`); - Object.assign(results, parseMetrics(spawn.stdout)); - - return results; + return collectSpawnScratchEvidence({ + stdout: spawn.stdout, + retainedCapacity: spawn.spawnScratchCapacity, + kernelMemoryPages: spawn.kernelMemoryPages, + }); } // ─── erlang-ring ──────────────────────────────────────────────────────────── @@ -984,6 +892,7 @@ async function runMariaDbWithEngine(engine: string, arch: MariaDbArch = "wasm32" const SUITES: Record Promise>> = { "syscall-io": runSyscallIo, "process-lifecycle": runProcessLifecycle, + "spawn-scratch": runSpawnScratch, "wordpress": runWordPress, "mariadb-aria": () => runMariaDbWithEngine("Aria", "wasm32"), "mariadb-aria-64": () => runMariaDbWithEngine("Aria", "wasm64"), diff --git a/apps/browser-demos/pages/benchmark/optional-urls.ts b/apps/browser-demos/pages/benchmark/optional-urls.ts new file mode 100644 index 0000000000..00c3b6bebf --- /dev/null +++ b/apps/browser-demos/pages/benchmark/optional-urls.ts @@ -0,0 +1,127 @@ +/** + * Optional application-binary URL imports are resolved via `import.meta.glob`. + * + * This module is loaded only by application benchmark suites. Static top-level + * `?url` imports in the main benchmark module would fail every suite if an + * unrelated application file were missing. `import.meta.glob` returns an empty + * map for a missing file, so the requesting suite reports a focused build hint. + */ +// Paths are relative to this file +// (apps/browser-demos/pages/benchmark/optional-urls.ts). Vite normalizes glob +// result keys, so callers must use the same relative strings declared here. +export const OPTIONAL_URLS = { + ...import.meta.glob("../../../../packages/registry/erlang/bin/beam.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../packages/registry/erlang/beam.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../packages/registry/nginx/nginx.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../local-binaries/programs/wasm32/nginx.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../binaries/programs/wasm32/nginx.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../binaries/programs/wasm32/php/php-fpm.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../local-binaries/programs/wasm32/php/php-fpm.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../packages/registry/coreutils/bin/coreutils.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../local-binaries/programs/wasm32/coreutils.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../binaries/programs/wasm32/coreutils.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../packages/registry/grep/bin/grep.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../local-binaries/programs/wasm32/grep.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../binaries/programs/wasm32/grep.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../packages/registry/sed/bin/sed.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../local-binaries/programs/wasm32/sed.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../binaries/programs/wasm32/sed.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../packages/registry/mariadb/mariadb-install/bin/mariadbd.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../local-binaries/programs/wasm32/mariadb/mariadbd.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../binaries/programs/wasm32/mariadb/mariadbd.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../packages/registry/mariadb/mariadb-install/share/mysql/mysql_system_tables.sql", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../packages/registry/mariadb/mariadb-install/share/mysql/mysql_system_tables_data.sql", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../packages/registry/mariadb/mariadb-install-64/bin/mariadbd.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../local-binaries/programs/wasm64/mariadb/mariadbd.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../binaries/programs/wasm64/mariadb/mariadbd.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../packages/registry/mariadb/mariadb-install-64/share/mysql/mysql_system_tables.sql", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../packages/registry/mariadb/mariadb-install-64/share/mysql/mysql_system_tables_data.sql", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../local-binaries/programs/wasm32/erlang-vfs.vfs.zst", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../binaries/programs/wasm32/erlang-vfs.vfs.zst", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../public/erlang.vfs.zst", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../local-binaries/programs/wasm32/wordpress.vfs.zst", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../binaries/programs/wasm32/wordpress.vfs.zst", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../public/wordpress.vfs.zst", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../public/mariadb.vfs.zst", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../public/mariadb-64.vfs.zst", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../local-binaries/programs/wasm32/mariadb-vfs.vfs.zst", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../binaries/programs/wasm32/mariadb-vfs.vfs.zst", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../local-binaries/programs/wasm64/mariadb-vfs.vfs.zst", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../binaries/programs/wasm64/mariadb-vfs.vfs.zst", { + query: "?url", import: "default", + }), +} as Record Promise>; diff --git a/apps/browser-demos/pages/test-runner/exec-binaries.ts b/apps/browser-demos/pages/test-runner/exec-binaries.ts new file mode 100644 index 0000000000..405161f573 --- /dev/null +++ b/apps/browser-demos/pages/test-runner/exec-binaries.ts @@ -0,0 +1,115 @@ +import type { MemoryFileSystem } from "@host/vfs/memory-fs"; +import dashWasmUrl from "@binaries/programs/wasm32/dash.wasm?url"; +import coreutilsWasmUrl from "@binaries/programs/wasm32/coreutils.wasm?url"; +import grepWasmUrl from "@binaries/programs/wasm32/grep.wasm?url"; +import sedWasmUrl from "@binaries/programs/wasm32/sed.wasm?url"; +import genCatWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/gencat.wasm?url"; + +const COREUTILS_NAMES = [ + "arch", "b2sum", "base32", "base64", "basename", "basenc", "cat", + "chcon", "chgrp", "chmod", "chown", "chroot", "cksum", "comm", "cp", + "csplit", "cut", "date", "dd", "df", "dir", "dircolors", "dirname", + "du", "echo", "env", "expand", "expr", "factor", "false", "fmt", + "fold", "groups", "head", "hostid", "id", "install", "join", "link", + "ln", "logname", "ls", "md5sum", "mkdir", "mkfifo", "mknod", "mktemp", + "mv", "nice", "nl", "nohup", "nproc", "numfmt", "od", "paste", + "pathchk", "pr", "printenv", "printf", "ptx", "pwd", "readlink", + "realpath", "rm", "rmdir", "runcon", "seq", "sha1sum", "sha224sum", + "sha256sum", "sha384sum", "sha512sum", "shred", "shuf", "sleep", + "sort", "split", "stat", "stty", "sum", "sync", "tac", "tail", + "tee", "test", "timeout", "touch", "tr", "true", "truncate", "tsort", + "tty", "uname", "unexpand", "uniq", "unlink", "vdir", "wc", "whoami", + "yes", +]; + +interface ExecBinaries { + dash: ArrayBuffer | null; + coreutils: ArrayBuffer | null; + grep: ArrayBuffer | null; + sed: ArrayBuffer | null; + genCat: ArrayBuffer | null; +} + +export interface ExecBinarySupport { + populate(fs: MemoryFileSystem): void; +} + +/** Write a binary file to the virtual filesystem. */ +function writeFileToFs( + fs: MemoryFileSystem, + path: string, + data: ArrayBuffer, +): void { + const bytes = new Uint8Array(data); + const fd = fs.open(path, 0x241 /* O_WRONLY|O_CREAT|O_TRUNC */, 0o755); + fs.write(fd, bytes, null, bytes.length); + fs.close(fd); +} + +/** Populate VFS with actual executable binaries and symlinks for exec. */ +function populateExecBinaries( + fs: MemoryFileSystem, + binaries: ExecBinaries, +): void { + for (const dir of ["/bin", "/usr", "/usr/bin", "/usr/local", "/usr/local/bin"]) { + try { fs.mkdir(dir, 0o755); } catch { /* exists */ } + } + + if (binaries.dash) { + writeFileToFs(fs, "/bin/dash", binaries.dash); + try { fs.symlink("/bin/dash", "/bin/sh"); } catch { /* exists */ } + try { fs.symlink("/bin/dash", "/usr/bin/dash"); } catch { /* exists */ } + try { fs.symlink("/bin/dash", "/usr/bin/sh"); } catch { /* exists */ } + } + + if (binaries.coreutils) { + writeFileToFs(fs, "/bin/coreutils", binaries.coreutils); + for (const name of COREUTILS_NAMES) { + try { fs.symlink("/bin/coreutils", `/bin/${name}`); } catch { /* exists */ } + try { fs.symlink("/bin/coreutils", `/usr/bin/${name}`); } catch { /* exists */ } + } + try { fs.symlink("/bin/coreutils", "/bin/["); } catch { /* exists */ } + try { fs.symlink("/bin/coreutils", "/usr/bin/["); } catch { /* exists */ } + } + + if (binaries.grep) { + writeFileToFs(fs, "/bin/grep", binaries.grep); + try { fs.symlink("/bin/grep", "/bin/egrep"); } catch { /* exists */ } + try { fs.symlink("/bin/grep", "/bin/fgrep"); } catch { /* exists */ } + try { fs.symlink("/bin/grep", "/usr/bin/grep"); } catch { /* exists */ } + try { fs.symlink("/bin/grep", "/usr/bin/egrep"); } catch { /* exists */ } + try { fs.symlink("/bin/grep", "/usr/bin/fgrep"); } catch { /* exists */ } + } + + if (binaries.sed) { + writeFileToFs(fs, "/bin/sed", binaries.sed); + try { fs.symlink("/bin/sed", "/usr/bin/sed"); } catch { /* exists */ } + } + + if (binaries.genCat) { + writeFileToFs(fs, "/bin/gencat", binaries.genCat); + try { fs.symlink("/bin/gencat", "/usr/bin/gencat"); } catch { /* exists */ } + } +} + +export async function loadExecBinarySupport(): Promise { + const fetches = await Promise.allSettled([ + fetch(dashWasmUrl).then((response) => response.arrayBuffer()), + fetch(coreutilsWasmUrl).then((response) => response.arrayBuffer()), + fetch(grepWasmUrl).then((response) => response.arrayBuffer()), + fetch(sedWasmUrl).then((response) => response.arrayBuffer()), + fetch(genCatWasmUrl).then((response) => response.arrayBuffer()), + ]); + const binaries: ExecBinaries = { + dash: fetches[0].status === "fulfilled" ? fetches[0].value : null, + coreutils: fetches[1].status === "fulfilled" ? fetches[1].value : null, + grep: fetches[2].status === "fulfilled" ? fetches[2].value : null, + sed: fetches[3].status === "fulfilled" ? fetches[3].value : null, + genCat: fetches[4].status === "fulfilled" ? fetches[4].value : null, + }; + return { + populate(fs) { + populateExecBinaries(fs, binaries); + }, + }; +} diff --git a/apps/browser-demos/pages/test-runner/main.ts b/apps/browser-demos/pages/test-runner/main.ts index 4b45fcc850..07608c136f 100644 --- a/apps/browser-demos/pages/test-runner/main.ts +++ b/apps/browser-demos/pages/test-runner/main.ts @@ -13,11 +13,7 @@ import { settleWebKitReclaim, } from "../../lib/kernel-owned-boot"; import kernelWasmUrl from "@kernel-wasm?url"; -import dashWasmUrl from "@binaries/programs/wasm32/dash.wasm?url"; -import coreutilsWasmUrl from "@binaries/programs/wasm32/coreutils.wasm?url"; -import grepWasmUrl from "@binaries/programs/wasm32/grep.wasm?url"; -import sedWasmUrl from "@binaries/programs/wasm32/sed.wasm?url"; -import genCatWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/gencat.wasm?url"; +import type { ExecBinarySupport } from "./exec-binaries"; interface DataFile { path: string; @@ -25,6 +21,11 @@ interface DataFile { useWasmBytes?: boolean; // if true, use the wasmBytes as file content } +interface PtyInput { + data: Uint8Array; + readyMarker: string; +} + declare global { interface Window { __testRunnerReady: boolean; @@ -36,6 +37,7 @@ declare global { dataFiles?: DataFile[]; cwd?: string; env?: string[]; + ptyInput?: PtyInput; }, ) => Promise<{ exitCode: number; @@ -48,103 +50,32 @@ declare global { } } -// --- Tool binaries (pre-fetched at init) --- let kernelWasmBytes: ArrayBuffer | null = null; -let dashBytes: ArrayBuffer | null = null; -let coreutilsBytes: ArrayBuffer | null = null; -let grepBytes: ArrayBuffer | null = null; -let sedBytes: ArrayBuffer | null = null; -let genCatBytes: ArrayBuffer | null = null; +let execBinarySupport: ExecBinarySupport | null = null; const corsProxyUrl = new URL( `${import.meta.env.BASE_URL}__kandelo_cors_proxy?url=`, window.location.href, ).href; -const COREUTILS_NAMES = [ - "arch", "b2sum", "base32", "base64", "basename", "basenc", "cat", - "chcon", "chgrp", "chmod", "chown", "chroot", "cksum", "comm", "cp", - "csplit", "cut", "date", "dd", "df", "dir", "dircolors", "dirname", - "du", "echo", "env", "expand", "expr", "factor", "false", "fmt", - "fold", "groups", "head", "hostid", "id", "install", "join", "link", - "ln", "logname", "ls", "md5sum", "mkdir", "mkfifo", "mknod", "mktemp", - "mv", "nice", "nl", "nohup", "nproc", "numfmt", "od", "paste", - "pathchk", "pr", "printenv", "printf", "ptx", "pwd", "readlink", - "realpath", "rm", "rmdir", "runcon", "seq", "sha1sum", "sha224sum", - "sha256sum", "sha384sum", "sha512sum", "shred", "shuf", "sleep", - "sort", "split", "stat", "stty", "sum", "sync", "tac", "tail", - "tee", "test", "timeout", "touch", "tr", "true", "truncate", "tsort", - "tty", "uname", "unexpand", "uniq", "unlink", "vdir", "wc", "whoami", - "yes", -]; - -/** Write a binary file to the virtual filesystem. */ -function writeFileToFs(fs: import("@host/browser-kernel-host").BrowserKernel["fs"], path: string, data: ArrayBuffer): void { - const bytes = new Uint8Array(data); - const fd = fs.open(path, 0x241 /* O_WRONLY|O_CREAT|O_TRUNC */, 0o755); - fs.write(fd, bytes, null, bytes.length); - fs.close(fd); -} - -/** Populate VFS with actual executable binaries and symlinks for exec. */ -function populateExecBinaries(fs: import("@host/vfs/memory-fs").MemoryFileSystem): void { - for (const dir of ["/bin", "/usr", "/usr/bin", "/usr/local", "/usr/local/bin"]) { - try { fs.mkdir(dir, 0o755); } catch { /* exists */ } - } - - if (dashBytes) { - writeFileToFs(fs, "/bin/dash", dashBytes); - try { fs.symlink("/bin/dash", "/bin/sh"); } catch { /* exists */ } - try { fs.symlink("/bin/dash", "/usr/bin/dash"); } catch { /* exists */ } - try { fs.symlink("/bin/dash", "/usr/bin/sh"); } catch { /* exists */ } - } - - if (coreutilsBytes) { - writeFileToFs(fs, "/bin/coreutils", coreutilsBytes); - for (const name of COREUTILS_NAMES) { - try { fs.symlink("/bin/coreutils", `/bin/${name}`); } catch { /* exists */ } - try { fs.symlink("/bin/coreutils", `/usr/bin/${name}`); } catch { /* exists */ } - } - try { fs.symlink("/bin/coreutils", "/bin/["); } catch { /* exists */ } - try { fs.symlink("/bin/coreutils", "/usr/bin/["); } catch { /* exists */ } - } - - if (grepBytes) { - writeFileToFs(fs, "/bin/grep", grepBytes); - try { fs.symlink("/bin/grep", "/bin/egrep"); } catch { /* exists */ } - try { fs.symlink("/bin/grep", "/bin/fgrep"); } catch { /* exists */ } - try { fs.symlink("/bin/grep", "/usr/bin/grep"); } catch { /* exists */ } - try { fs.symlink("/bin/grep", "/usr/bin/egrep"); } catch { /* exists */ } - try { fs.symlink("/bin/grep", "/usr/bin/fgrep"); } catch { /* exists */ } - } - - if (sedBytes) { - writeFileToFs(fs, "/bin/sed", sedBytes); - try { fs.symlink("/bin/sed", "/usr/bin/sed"); } catch { /* exists */ } - } - - if (genCatBytes) { - writeFileToFs(fs, "/bin/gencat", genCatBytes); - try { fs.symlink("/bin/gencat", "/usr/bin/gencat"); } catch { /* exists */ } - } -} - async function init() { - // Fetch kernel wasm and tool binaries in parallel - const fetches = await Promise.allSettled([ - fetch(kernelWasmUrl).then((r) => r.arrayBuffer()), - fetch(dashWasmUrl).then((r) => r.arrayBuffer()), - fetch(coreutilsWasmUrl).then((r) => r.arrayBuffer()), - fetch(grepWasmUrl).then((r) => r.arrayBuffer()), - fetch(sedWasmUrl).then((r) => r.arrayBuffer()), - fetch(genCatWasmUrl).then((r) => r.arrayBuffer()), + const minimal = new URLSearchParams(window.location.search).get("minimal") === "1"; + /* + * WHY: tests that never exec shell tools must not activate unrelated + * optional package generations. The default path still imports the checked + * tool module; minimal mode simply never requests those bytes. + */ + const execBinarySupportPromise = minimal + ? Promise.resolve(null) + : import("./exec-binaries").then((module) => + module.loadExecBinarySupport() + ); + [kernelWasmBytes, execBinarySupport] = await Promise.all([ + fetch(kernelWasmUrl) + .then((response) => response.arrayBuffer()) + .catch(() => null), + execBinarySupportPromise, ]); - kernelWasmBytes = fetches[0].status === "fulfilled" ? fetches[0].value : null; - dashBytes = fetches[1].status === "fulfilled" ? fetches[1].value : null; - coreutilsBytes = fetches[2].status === "fulfilled" ? fetches[2].value : null; - grepBytes = fetches[3].status === "fulfilled" ? fetches[3].value : null; - sedBytes = fetches[4].status === "fulfilled" ? fetches[4].value : null; - genCatBytes = fetches[5].status === "fulfilled" ? fetches[5].value : null; if (!kernelWasmBytes) { throw new Error("Failed to fetch kernel wasm"); @@ -160,6 +91,7 @@ async function init() { dataFiles?: DataFile[]; cwd?: string; env?: string[]; + ptyInput?: PtyInput; }, ) => { let stdout = ""; @@ -171,7 +103,7 @@ async function init() { // transient build FS, then hand ownership to the kernel worker so the main // thread holds no VFS SharedArrayBuffer across the per-test loop. const buildFs = await createBuildFsWithEtc(); - populateExecBinaries(buildFs); + execBinarySupport?.populate(buildFs); if (options?.dataFiles) { for (const file of options.dataFiles) { // Ensure parent directories exist @@ -219,9 +151,46 @@ async function init() { // Run the test with a timeout const cwd = options?.cwd; - const spawnOpts: { cwd?: string; env?: string[] } = {}; + const ptyInput = options?.ptyInput; + const spawnOpts: { + cwd?: string; + env?: string[]; + pty?: boolean; + onStarted?: (pid: number) => Promise; + } = {}; if (cwd) spawnOpts.cwd = cwd; if (options?.env) spawnOpts.env = options.env; + if (ptyInput) { + if (!(ptyInput.data instanceof Uint8Array)) { + throw new TypeError("ptyInput.data must be a Uint8Array"); + } + if (ptyInput.readyMarker.length === 0) { + throw new TypeError("ptyInput.readyMarker must not be empty"); + } + spawnOpts.pty = true; + spawnOpts.onStarted = async (pid) => { + let observed = ""; + let markReady: (() => void) | null = null; + const ready = new Promise((resolve) => { + markReady = resolve; + }); + kernel.onPtyOutput(pid, (data) => { + const text = new TextDecoder().decode(data); + stdout += text; + combined += text; + observed += text; + if (observed.includes(ptyInput.readyMarker)) markReady?.(); + }); + /* + * WHY: ptyWrite enters the kernel's current line discipline + * synchronously. Wait until the guest confirms its terminal mode, + * and register the callback first so output buffered before the + * spawn acknowledgement cannot lose the readiness transition. + */ + await ready; + kernel.ptyWrite(pid, ptyInput.data); + }; + } const exitCode = await Promise.race([ kernel.spawn(wasmBytes, argv ?? ["test"], spawnOpts), new Promise((_, reject) => diff --git a/apps/browser-demos/test/epoll-repro.ts b/apps/browser-demos/test/epoll-repro.ts index 469df41f14..7ad966d8c4 100644 --- a/apps/browser-demos/test/epoll-repro.ts +++ b/apps/browser-demos/test/epoll-repro.ts @@ -3,21 +3,45 @@ * Run: npx tsx test/epoll-repro.ts */ import { CAPTURED_STDIO, CentralizedKernelWorker } from "../../../host/src/kernel-worker.ts"; +import { resolveBinary } from "../../../host/src/binary-resolver.ts"; +import { + CH_ARGS, + CH_ARG_SIZE, + CH_DATA, + CH_ERRNO, + CH_RETURN, + CH_SYSCALL, + CH_TOTAL_SIZE, + STRUCT_SIZE_WASM_EPOLL_EVENT, + WASM_EPOLL_EVENT_DATA_OFFSET, +} from "../../../host/src/generated/abi.ts"; +import type { KernelScratchRegion } from "../../../host/src/kernel-scratch.ts"; import { VirtualPlatformIO, MemoryFileSystem, DeviceFileSystem } from "../../../host/src/vfs/index.ts"; import { readFileSync } from "fs"; -const CH_SYSCALL = 4; -const CH_ARGS = 8; -const CH_ARG_SIZE = 8; // each arg is i64 (8 bytes) -const CH_RETURN = 56; -const CH_ERRNO = 64; -const CH_DATA = 72; -const CH_TOTAL_SIZE = 72 + 65536; const MAX_PAGES = 16384; const PAGE_SIZE = 65536; +interface KernelWorkerInternals { + scratchRegion: KernelScratchRegion; + kernelInstance: WebAssembly.Instance; + kernelMemory: WebAssembly.Memory; +} + +interface ScratchPointerArgument { + readonly offset: number; + readonly length: number; +} + +type ChannelArgument = bigint | ScratchPointerArgument; + +interface EpollEventPreparation { + readonly events: number; + readonly data: bigint; +} + async function main() { - const kernelWasm = readFileSync("/Users/brandon/ai-src/kandelo/host/wasm/kandelo-kernel.wasm"); + const kernelWasm = readFileSync(resolveBinary("kernel.wasm")); const memfs = MemoryFileSystem.create(new SharedArrayBuffer(16 * 1024 * 1024)); const devfs = new DeviceFileSystem(); @@ -34,11 +58,14 @@ async function main() { const kw = new CentralizedKernelWorker({ maxWorkers: 4, dataBufferSize: PAGE_SIZE, useSharedMemory: true }, io); await kw.init(kernelWasm); - const ki = (kw as any).kernelInstance!; - const km = (kw as any).kernelMemory!; - const scratchOffset = (kw as any).scratchOffset as number; + const internals = kw as unknown as KernelWorkerInternals; + const ki = internals.kernelInstance; + const km = internals.kernelMemory; + const scratchRegion = internals.scratchRegion; - console.log(`scratchOffset=${scratchOffset}, memPages=${km.grow(0)}`); + console.log( + `scratchCapacity=${scratchRegion.capacity}, memPages=${km.grow(0)}`, + ); // Register a fake process const procMem = new WebAssembly.Memory({ initial: 17, maximum: MAX_PAGES, shared: true }); @@ -51,89 +78,127 @@ async function main() { const getSP = ki.exports.kernel_get_stack_pointer as () => number; console.log(`SP initial: ${getSP()}`); - // Directly call kernel_handle_channel to set up epoll - const kernelView = new DataView(km.buffer, scratchOffset); - const handleChannel = ki.exports.kernel_handle_channel as (off: bigint, pid: number) => number; - const setCurrentTid = ki.exports.kernel_set_current_tid as (pid: number, tid: number) => number; - const handleBoundChannel = (): number => { + const setCurrentTid = ki.exports.kernel_set_current_tid as ( + pid: number, + tid: number, + ) => number; + const bindCurrentTid = (): void => { const bindResult = setCurrentTid(pid, pid); if (bindResult !== 0) { - throw new Error(`kernel_set_current_tid(${pid}, ${pid}) failed: ${bindResult}`); + throw new Error( + `kernel_set_current_tid(${pid}, ${pid}) failed: ${bindResult}`, + ); } - return handleChannel(BigInt(scratchOffset), pid); }; + const issueChannel = ( + syscall: number, + args: readonly ChannelArgument[], + event?: EpollEventPreparation, + ) => + scratchRegion.withLease((scratch) => { + // WHY: preparation is inert data rather than a callback receiving the + // lease. No promise or helper can retain scratch authority after this + // synchronous callback returns. + const kernelView = scratch.dataView(0, CH_TOTAL_SIZE); + if (event !== undefined) { + kernelView.setUint32(CH_DATA, event.events, true); + kernelView.setUint32(CH_DATA + 4, 0, true); + kernelView.setBigUint64( + CH_DATA + WASM_EPOLL_EVENT_DATA_OFFSET, + event.data, + true, + ); + } + kernelView.setUint32(CH_SYSCALL, syscall, true); + for (let index = 0; index < 6; index++) { + const argument = args[index] ?? 0n; + if (typeof argument === "object") { + // Keep the kernel pointer opaque and encode it losslessly into the + // channel's fixed u64 syscall slot. + scratch.writeAddress( + CH_ARGS + index * CH_ARG_SIZE, + argument.offset, + argument.length, + "u64-le", + ); + } else { + kernelView.setBigInt64( + CH_ARGS + index * CH_ARG_SIZE, + argument, + true, + ); + } + } + bindCurrentTid(); + scratch.invokeKernelExport("kernel_handle_channel", [ + scratch.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + pid, + ]); + return { + result: kernelView.getBigInt64(CH_RETURN, true), + errno: kernelView.getUint32(CH_ERRNO, true), + data0: kernelView.getInt32(CH_DATA, true), + data1: kernelView.getInt32(CH_DATA + 4, true), + }; + }); // 1. epoll_create1(0) - kernelView.setUint32(CH_SYSCALL, 239, true); - kernelView.setBigInt64(CH_ARGS, 0n, true); - for (let i = 1; i < 6; i++) kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); - handleBoundChannel(); - const epfd = Number(kernelView.getBigInt64(CH_RETURN, true)); + const epfd = issueChannel(239, [0n]).result; console.log(`epoll_create1(0) = ${epfd}, SP=${getSP()}`); // 2. pipe2() - kernelView.setUint32(CH_SYSCALL, 165, true); - kernelView.setBigInt64(CH_ARGS, BigInt(scratchOffset + CH_DATA), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, 0n, true); - for (let i = 2; i < 6; i++) kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); - handleBoundChannel(); - const pipeRet = Number(kernelView.getBigInt64(CH_RETURN, true)); - const pipeR = new DataView(km.buffer).getInt32(scratchOffset + CH_DATA, true); - const pipeW = new DataView(km.buffer).getInt32(scratchOffset + CH_DATA + 4, true); - console.log(`pipe2() = ${pipeRet}, fds=[${pipeR}, ${pipeW}], SP=${getSP()}`); - - // 3. epoll_ctl(epfd, EPOLL_CTL_ADD=1, pipeR, event) - const evtOff = scratchOffset + CH_DATA; - new DataView(km.buffer).setUint32(evtOff, 1, true); // EPOLLIN - new DataView(km.buffer).setBigUint64(evtOff + 4, BigInt(pipeR), true); - kernelView.setUint32(CH_SYSCALL, 240, true); - kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(epfd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, 1n, true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(pipeR), true); - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(evtOff), true); - for (let i = 4; i < 6; i++) kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); - handleBoundChannel(); - console.log(`epoll_ctl = ${Number(kernelView.getBigInt64(CH_RETURN, true))}, SP=${getSP()}`); - - // 4. epoll_pwait(epfd, events, 1, 0, NULL, 8) — timeout=0 for immediate - const eventsOff = scratchOffset + CH_DATA; - kernelView.setUint32(CH_SYSCALL, 241, true); - kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(epfd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(eventsOff), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, 1n, true); - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, 0n, true); // timeout=0 - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, 0n, true); // sigmask=NULL - kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, 8n, true); - - console.log(`\nCalling epoll_pwait... SP before=${getSP()}`); - try { - handleBoundChannel(); - const ret = Number(kernelView.getBigInt64(CH_RETURN, true)); - const err = kernelView.getUint32(CH_ERRNO, true); - console.log(`epoll_pwait = ${ret}, errno=${err}, SP=${getSP()}`); - } catch (e) { - console.error(`CRASHED: ${e}`); - console.log(`SP after crash: ${getSP()}, memPages=${km.grow(0)}`); - } - - // Try with timeout=1000 (what PHP-FPM uses) - kernelView.setUint32(CH_SYSCALL, 241, true); - kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(epfd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(eventsOff), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, 1n, true); - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, 1000n, true); - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, 0n, true); - kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, 8n, true); - - console.log(`\nCalling epoll_pwait(timeout=1000)... SP before=${getSP()}`); - try { - handleBoundChannel(); - const ret = Number(kernelView.getBigInt64(CH_RETURN, true)); - const err = kernelView.getUint32(CH_ERRNO, true); - console.log(`epoll_pwait(1000) = ${ret}, errno=${err}, SP=${getSP()}`); - } catch (e) { - console.error(`CRASHED: ${e}`); - console.log(`SP after crash: ${getSP()}, memPages=${km.grow(0)}`); + const pipe = issueChannel(165, [ + { offset: CH_DATA, length: 8 }, + 0n, + ]); + console.log( + `pipe2() = ${pipe.result}, fds=[${pipe.data0}, ${pipe.data1}], SP=${getSP()}`, + ); + + // 3. epoll_ctl(epfd, EPOLL_CTL_ADD=1, pipe.data0, event) + const ctlResult = issueChannel( + 240, + [ + epfd, + 1n, + BigInt(pipe.data0), + { + offset: CH_DATA, + length: STRUCT_SIZE_WASM_EPOLL_EVENT, + }, + ], + { + events: 1, // EPOLLIN + data: BigInt(pipe.data0), + }, + ).result; + console.log(`epoll_ctl = ${ctlResult}, SP=${getSP()}`); + + // 4. timeout=0 for an immediate result, then use PHP-FPM's 1s timeout. + for (const timeout of [0, 1000]) { + console.log( + `\nCalling epoll_pwait(timeout=${timeout})... SP before=${getSP()}`, + ); + try { + const result = issueChannel(241, [ + epfd, + { + offset: CH_DATA, + length: STRUCT_SIZE_WASM_EPOLL_EVENT, + }, + 1n, + BigInt(timeout), + 0n, + 8n, + ]); + console.log( + `epoll_pwait(${timeout}) = ${result.result}, errno=${result.errno}, SP=${getSP()}`, + ); + } catch (error) { + console.error(`CRASHED: ${error}`); + console.log(`SP after crash: ${getSP()}, memPages=${km.grow(0)}`); + } } } diff --git a/apps/browser-demos/test/fifo-lifecycle.spec.ts b/apps/browser-demos/test/fifo-lifecycle.spec.ts index 79407253e5..b4ea1e6d2f 100644 --- a/apps/browser-demos/test/fifo-lifecycle.spec.ts +++ b/apps/browser-demos/test/fifo-lifecycle.spec.ts @@ -25,13 +25,26 @@ const guests = [ ], }, { - name: "SCM_RIGHTS pipe and FIFO reference lifetime", + name: "SCM_RIGHTS pipe and FIFO reference lifetime (wasm32)", programPath: resolve( __dirname, "../../../local-binaries/programs/wasm32/scm-rights-pipe-lifetime.wasm", ), argv: ["scm-rights-pipe-lifetime"], markers: [ + "SCM_RIGHTS_SOCKET_REJECTION_PASS", + "PASS: SCM_RIGHTS owns pipe and FIFO references in flight and after receipt", + ], + }, + { + name: "SCM_RIGHTS pipe and FIFO reference lifetime (memory64)", + programPath: resolve( + __dirname, + "../../../local-binaries/programs/wasm64/scm-rights-pipe-lifetime.wasm", + ), + argv: ["scm-rights-pipe-lifetime"], + markers: [ + "SCM_RIGHTS_SOCKET_REJECTION_PASS", "PASS: SCM_RIGHTS owns pipe and FIFO references in flight and after receipt", ], }, @@ -61,7 +74,9 @@ for (const guest of guests) { ); }); - await page.goto(new URL("/pages/test-runner/", baseURL).href); + await page.goto( + new URL("/pages/test-runner/?minimal=1", baseURL).href, + ); await page.waitForFunction(() => (window as any).__testRunnerReady === true); const programUrl = new URL(`/@fs/${guest.programPath}`, baseURL).href; diff --git a/apps/browser-demos/test/fixtures/opfs-advisory-lock-client-worker.ts b/apps/browser-demos/test/fixtures/opfs-advisory-lock-client-worker.ts index 9f8db0d84f..69371425d5 100644 --- a/apps/browser-demos/test/fixtures/opfs-advisory-lock-client-worker.ts +++ b/apps/browser-demos/test/fixtures/opfs-advisory-lock-client-worker.ts @@ -16,6 +16,7 @@ import { createProcessMemory, type ProcessMemoryLayout, } from "../../../../host/src/process-memory"; +import type { KernelScratchRegion } from "../../../../host/src/kernel-scratch"; import { OpfsFileSystem } from "../../../../host/src/vfs/opfs"; import { BrowserTimeProvider } from "../../../../host/src/vfs/time"; import { VirtualPlatformIO } from "../../../../host/src/vfs/vfs"; @@ -44,6 +45,31 @@ interface SyscallResult { errno: number; } +interface ScratchArgument { + kind: "scratch"; + offset: number; + length: number; +} + +type SyscallArgument = number | bigint | ScratchArgument; + +type ScratchPreparation = + | { + kind: "copy"; + source: Uint8Array; + destinationOffset: number; + } + | { + kind: "flock"; + start: bigint; + len: bigint; + type: number; + }; + +function scratchArgument(offset: number, length: number): ScratchArgument { + return { kind: "scratch", offset, length }; +} + interface ChannelInfoForTest { pid: number; memory: WebAssembly.Memory; @@ -51,8 +77,7 @@ interface ChannelInfoForTest { } interface KernelWorkerInternals { - kernelMemory: WebAssembly.Memory; - scratchOffset: number; + scratchRegion: KernelScratchRegion; kernelInstance: WebAssembly.Instance; processes: Map; pendingAdvisoryLockRetries: Map; @@ -101,38 +126,70 @@ function issue( worker: CentralizedKernelWorker, pid: number, syscall: number, - args: Array, + args: readonly SyscallArgument[], + preparation?: ScratchPreparation, ): SyscallResult { const state = internals(worker); - const channel = new DataView(state.kernelMemory.buffer, state.scratchOffset); - channel.setUint32(CH_SYSCALL, syscall, true); - channel.setUint32(CH_ERRNO, 0, true); - channel.setBigInt64(CH_RETURN, 0n, true); - for (let index = 0; index < 6; index++) { - channel.setBigInt64( - CH_ARGS + index * CH_ARG_SIZE, - BigInt(args[index] ?? 0), - true, - ); - } + return state.scratchRegion.withLease((scratch) => { + // WHY: preparation is data, not a callback receiving the lease. Keeping + // every scratch operation in this synchronous callback prevents a helper + // from retaining the lease after withLease revokes it. + if (preparation?.kind === "copy") { + scratch.copyFrom( + preparation.source, + preparation.destinationOffset, + ); + } else if (preparation?.kind === "flock") { + scratch.fill(0, CH_DATA, 32); + const flock = scratch.dataView(CH_DATA, 32); + flock.setInt16(0, preparation.type, true); + flock.setInt16(2, 0, true); // SEEK_SET + flock.setBigInt64(8, preparation.start, true); + flock.setBigInt64(16, preparation.len, true); + } + const channel = scratch.dataView(0, CH_TOTAL_SIZE); + channel.setUint32(CH_SYSCALL, syscall, true); + channel.setUint32(CH_ERRNO, 0, true); + channel.setBigInt64(CH_RETURN, 0n, true); + for (let index = 0; index < 6; index++) { + const argument = args[index] ?? 0; + if (typeof argument === "object") { + // WHY: the descriptor carries only an offset and capacity. The + // lease writes its checked address without exposing a primitive that + // could outlive revocation. + scratch.writeAddress( + CH_ARGS + index * CH_ARG_SIZE, + argument.offset, + argument.length, + "u64-le", + ); + } else { + channel.setBigInt64( + CH_ARGS + index * CH_ARG_SIZE, + BigInt(argument), + true, + ); + } + } - const handleChannel = state.kernelInstance.exports.kernel_handle_channel as ( - offset: number | bigint, - pid: number, - ) => number; - const setCurrentTid = state.kernelInstance.exports.kernel_set_current_tid as ( - pid: number, - tid: number, - ) => number; - const bindResult = setCurrentTid(pid, pid); - if (bindResult !== 0) { - throw new Error(`kernel_set_current_tid(${pid}, ${pid}) failed: ${bindResult}`); - } - handleChannel(worker.toKernelPtr(state.scratchOffset), pid); - return { - value: Number(channel.getBigInt64(CH_RETURN, true)), - errno: channel.getUint32(CH_ERRNO, true), - }; + const setCurrentTid = state.kernelInstance.exports.kernel_set_current_tid as ( + pid: number, + tid: number, + ) => number; + const bindResult = setCurrentTid(pid, pid); + if (bindResult !== 0) { + throw new Error(`kernel_set_current_tid(${pid}, ${pid}) failed: ${bindResult}`); + } + scratch.invokeKernelExport("kernel_handle_channel", [ + scratch.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + pid, + ]); + return { + value: Number(channel.getBigInt64(CH_RETURN, true)), + errno: channel.getUint32(CH_ERRNO, true), + }; + }); } function openFile( @@ -140,13 +197,22 @@ function openFile( pid: number, path: string, ): number { - const state = internals(worker); - const pathPtr = state.scratchOffset + CH_DATA; - new Uint8Array(state.kernelMemory.buffer).set( - new TextEncoder().encode(`${path}\0`), - pathPtr, + const pathBytes = new TextEncoder().encode(`${path}\0`); + const result = issue( + worker, + pid, + ABI_SYSCALLS.Open, + [ + scratchArgument(CH_DATA, pathBytes.byteLength), + O_RDWR, + 0, + ], + { + kind: "copy", + source: pathBytes, + destinationOffset: CH_DATA, + }, ); - const result = issue(worker, pid, ABI_SYSCALLS.Open, [pathPtr, O_RDWR, 0]); if (result.errno !== 0 || result.value < 3) { throw new Error( `kernel open failed for pid ${pid}: value=${result.value} errno=${result.errno}`, @@ -192,10 +258,22 @@ function lock( type = F_WRLCK, command = F_SETLK64, ): SyscallResult { - const state = internals(worker); - const flockPtr = state.scratchOffset + CH_DATA; - writeFlock(state.kernelMemory, start, len, type, flockPtr); - return issue(worker, pid, ABI_SYSCALLS.Fcntl, [fd, command, flockPtr]); + return issue( + worker, + pid, + ABI_SYSCALLS.Fcntl, + [ + fd, + command, + scratchArgument(CH_DATA, 32), + ], + { + kind: "flock", + start, + len, + type, + }, + ); } function prepareProcessFcntl( diff --git a/apps/browser-demos/test/kernel-scratch-runtime.spec.ts b/apps/browser-demos/test/kernel-scratch-runtime.spec.ts new file mode 100644 index 0000000000..2aeae3b9cc --- /dev/null +++ b/apps/browser-demos/test/kernel-scratch-runtime.spec.ts @@ -0,0 +1,128 @@ +import { expect, test } from "@playwright/test"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + CH_DATA_SIZE, + CH_TOTAL_SIZE, + KERNEL_IOVEC_WIRE_ALIGN, + POSIX_IOV_MAX, + STRUCT_SIZE_KERNEL_IOVEC_WIRE, +} from "../../../host/src/generated/abi"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const programPath = resolve( + __dirname, + "../../../examples/kernel_scratch_browser_test.wasm", +); +const ptyByte = 0x51; +const ptyLength = CH_TOTAL_SIZE + 1; +const readvDataBytes = + CH_DATA_SIZE - POSIX_IOV_MAX * STRUCT_SIZE_KERNEL_IOVEC_WIRE; +const readvBytesPerIovec = readvDataBytes / POSIX_IOV_MAX; + +if ( + !Number.isInteger(readvBytesPerIovec) || + readvBytesPerIovec <= 0 || + readvBytesPerIovec % KERNEL_IOVEC_WIRE_ALIGN !== 0 +) { + throw new Error("generated readv scratch layout cannot form an exact boundary"); +} + +test("owned kernel scratch carries exact-boundary readv and chunked PTY input in Chromium", async ({ + page, + baseURL, + browserName, +}) => { + test.skip( + browserName !== "chromium", + "the aggregate browser gate uses Chromium", + ); + expect(baseURL).toBeTruthy(); + + const runtimeErrors: string[] = []; + page.on("pageerror", (error) => { + runtimeErrors.push(`pageerror: ${error.message}`); + }); + page.on("console", (message) => { + if (message.type() === "error") { + runtimeErrors.push(`console: ${message.text()}`); + } + }); + page.on("requestfailed", (request) => { + runtimeErrors.push( + `requestfailed: ${request.url()} ${request.failure()?.errorText ?? "failed"}`, + ); + }); + + await page.goto(new URL("/pages/test-runner/?minimal=1", baseURL).href); + await page.waitForFunction(() => (window as any).__testRunnerReady === true); + + const programUrl = new URL(`/@fs/${programPath}`, baseURL).href; + const results = await page.evaluate( + async ({ + programUrl, + iovecCount, + bytesPerIovec, + ptyInputLength, + ptyInputByte, + }) => { + const response = await fetch(programUrl); + if (!response.ok) { + throw new Error( + `program fetch failed: ${response.status} ${response.url}`, + ); + } + const program = await response.arrayBuffer(); + const readv = await (window as any).__runTest( + program.slice(0), + [ + "kernel-scratch-browser-test", + "readv", + String(iovecCount), + String(bytesPerIovec), + ], + 30_000, + ); + const pty = await (window as any).__runTest( + program.slice(0), + [ + "kernel-scratch-browser-test", + "pty", + String(ptyInputLength), + String(ptyInputByte), + ], + 30_000, + { + ptyInput: { + data: new Uint8Array(ptyInputLength).fill(ptyInputByte), + readyMarker: "KERNEL_SCRATCH_PTY_READY", + }, + }, + ); + return { readv, pty }; + }, + { + programUrl, + iovecCount: POSIX_IOV_MAX, + bytesPerIovec: readvBytesPerIovec, + ptyInputLength: ptyLength, + ptyInputByte: ptyByte, + }, + ); + + expect(results.readv.exitCode, results.readv.stderr).toBe(0); + expect(results.readv.stdout).toContain( + `KERNEL_SCRATCH_READV_PASS iovecs=${POSIX_IOV_MAX} bytes=${readvDataBytes}`, + ); + expect(results.readv.stderr).toBe(""); + expect(results.readv.hostDiagnostics).toEqual([]); + + expect(results.pty.exitCode, results.pty.stderr).toBe(0); + expect(results.pty.stdout).toContain("KERNEL_SCRATCH_PTY_READY"); + expect(results.pty.stdout).toContain( + `KERNEL_SCRATCH_PTY_PASS bytes=${ptyLength}`, + ); + expect(results.pty.stderr).toBe(""); + expect(results.pty.hostDiagnostics).toEqual([]); + expect(runtimeErrors).toEqual([]); +}); diff --git a/apps/browser-demos/test/path-resolution.spec.ts b/apps/browser-demos/test/path-resolution.spec.ts index 02766dbe10..e106c7dae5 100644 --- a/apps/browser-demos/test/path-resolution.spec.ts +++ b/apps/browser-demos/test/path-resolution.spec.ts @@ -19,7 +19,7 @@ test("browser resolves pathname components and rejects an invalid initial cwd", ); expect(baseURL).toBeTruthy(); - await page.goto(new URL("/pages/test-runner/", baseURL).href); + await page.goto(new URL("/pages/test-runner/?minimal=1", baseURL).href); await page.waitForFunction(() => (window as any).__testRunnerReady === true); const programUrl = new URL(`/@fs/${programPath}`, baseURL).href; diff --git a/apps/browser-demos/test/process-native-layout.spec.ts b/apps/browser-demos/test/process-native-layout.spec.ts new file mode 100644 index 0000000000..e424bd23be --- /dev/null +++ b/apps/browser-demos/test/process-native-layout.spec.ts @@ -0,0 +1,151 @@ +import { expect, test } from "@playwright/test"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const programs = [ + { + name: "process layouts wasm32", + path: resolve( + __dirname, + "../../../examples/process_native_layout_test.wasm", + ), + argv0: "process-native-layout-test", + markers: ["PROCESS NATIVE LAYOUTS PASSED"], + }, + { + name: "process layouts memory64", + path: resolve( + __dirname, + "../../../examples/process_native_layout_test.wasm64.wasm", + ), + argv0: "process-native-layout-test", + markers: ["PROCESS NATIVE LAYOUTS PASSED"], + }, + { + name: "timerfd/signalfd wasm32", + path: resolve( + __dirname, + "../../../examples/timerfd_signalfd_scratch_test.wasm", + ), + argv0: "timerfd-signalfd-scratch-test", + markers: [ + "timerfd scratch guards: PASS", + "signalfd scratch mask: PASS", + "ALL TESTS PASSED", + ], + }, + { + name: "timerfd/signalfd memory64", + path: resolve( + __dirname, + "../../../examples/timerfd_signalfd_scratch_test.wasm64.wasm", + ), + argv0: "timerfd-signalfd-scratch-test", + markers: [ + "timerfd scratch guards: PASS", + "signalfd scratch mask: PASS", + "ALL TESTS PASSED", + ], + }, + { + name: "System V IPC wasm32", + path: resolve(__dirname, "../../../examples/sysv_ipc_test.wasm"), + argv0: "sysv-ipc-test", + markers: [ + "msgctl IPC_SET: mode=0600 qbytes=4096", + "msgq: PASS", + "semctl post-RMID IPC_STAT: EINVAL", + "semctl post-RMID GETALL: EINVAL", + "semctl post-RMID SETALL: EINVAL", + "semctl post-RMID GETVAL: EINVAL", + "sem: PASS", + "shmctl IPC_SET: mode=0600 segsz=4096", + "shm: PASS", + "ALL TESTS PASSED", + ], + }, + { + name: "System V IPC memory64", + path: resolve(__dirname, "../../../examples/sysv_ipc_test.wasm64.wasm"), + argv0: "sysv-ipc-test", + markers: [ + "msgctl IPC_SET: mode=0600 qbytes=4096", + "msgq: PASS", + "semctl post-RMID IPC_STAT: EINVAL", + "semctl post-RMID GETALL: EINVAL", + "semctl post-RMID SETALL: EINVAL", + "semctl post-RMID GETVAL: EINVAL", + "sem: PASS", + "shmctl IPC_SET: mode=0600 segsz=4096", + "shm: PASS", + "ALL TESTS PASSED", + ], + }, +] as const; + +for (const program of programs) { + test( + `caller-native scratch layouts match in Chromium (${program.name})`, + async ({ page, baseURL, browserName }) => { + test.skip( + browserName !== "chromium", + "the aggregate browser gate uses Chromium", + ); + expect(baseURL).toBeTruthy(); + + const runtimeErrors: string[] = []; + page.on("pageerror", (error) => { + runtimeErrors.push(`pageerror: ${error.message}`); + }); + page.on("console", (message) => { + if (message.type() === "error") { + runtimeErrors.push(`console: ${message.text()}`); + } + }); + page.on("requestfailed", (request) => { + runtimeErrors.push( + `requestfailed: ${request.url()} ${ + request.failure()?.errorText ?? "failed" + }`, + ); + }); + + // WHY: these ABI fixtures are self-contained. The minimal runner avoids + // importing unrelated packages without weakening checks for any artifact + // the test actually requests. + await page.goto( + new URL("/pages/test-runner/?minimal=1", baseURL).href, + ); + await page.waitForFunction( + () => (window as any).__testRunnerReady === true, + ); + + const programUrl = new URL(`/@fs/${program.path}`, baseURL).href; + const result = await page.evaluate( + async ({ programUrl, argv0 }) => { + const response = await fetch(programUrl); + if (!response.ok) { + throw new Error( + `program fetch failed: ${response.status} ${response.url}`, + ); + } + return (window as any).__runTest( + await response.arrayBuffer(), + [argv0], + 30_000, + ); + }, + { programUrl, argv0: program.argv0 }, + ); + + expect(result.exitCode, result.stderr).toBe(0); + for (const marker of program.markers) { + expect(result.stdout).toContain(marker); + } + expect(result.stderr).toBe(""); + expect(result.hostDiagnostics).toEqual([]); + expect(runtimeErrors).toEqual([]); + }, + ); +} diff --git a/apps/browser-demos/test/rlimit-fsize.spec.ts b/apps/browser-demos/test/rlimit-fsize.spec.ts index f0a7f1d548..f9a6b24c6f 100644 --- a/apps/browser-demos/test/rlimit-fsize.spec.ts +++ b/apps/browser-demos/test/rlimit-fsize.spec.ts @@ -37,7 +37,7 @@ test("RLIMIT_FSIZE keeps one operation boundary in Chromium", async ({ } }); - await page.goto(new URL("/pages/test-runner/", baseURL).href); + await page.goto(new URL("/pages/test-runner/?minimal=1", baseURL).href); await page.waitForFunction(() => (window as any).__testRunnerReady === true); const programUrl = new URL(`/@fs/${programPath}`, baseURL).href; diff --git a/apps/browser-demos/test/scm-rights-semantics.spec.ts b/apps/browser-demos/test/scm-rights-semantics.spec.ts new file mode 100644 index 0000000000..17dec33fa1 --- /dev/null +++ b/apps/browser-demos/test/scm-rights-semantics.spec.ts @@ -0,0 +1,131 @@ +import { expect, test } from "@playwright/test"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const guests = [ + { + name: "wasm32", + programPath: resolve( + __dirname, + "../../../local-binaries/programs/wasm32/scm-rights-semantics.wasm", + ), + }, + { + name: "wasm64", + programPath: resolve( + __dirname, + "../../../local-binaries/programs/wasm64/scm-rights-semantics.wasm", + ), + }, +] as const; + +const semanticCases = [ + { + name: "stream", + markers: ["SCM_RIGHTS_STREAM_BARRIER_PASS"], + }, + { + name: "peek", + markers: ["SCM_RIGHTS_STREAM_PEEK_PASS"], + }, + { + name: "datagram", + markers: ["SCM_RIGHTS_DGRAM_ZERO_AND_PEEK_PASS"], + }, + { + name: "trunc", + markers: ["SCM_RIGHTS_DGRAM_TRUNC_PASS"], + }, + { + name: "domain", + markers: ["SCM_RIGHTS_NON_UNIX_REJECTION_PASS"], + }, + { + name: "representability", + markers: ["SCM_RIGHTS_UNREPRESENTABLE_REJECTION_PASS"], + }, + { + name: "zero-iov-stream", + markers: ["SCM_RIGHTS_STREAM_ZERO_IOV_PASS"], + }, + { + name: "cloexec", + markers: [ + "SCM_RIGHTS_CLOEXEC_FLAG_PASS", + "SCM_RIGHTS_CLOEXEC_EXEC_PASS", + "SCM_RIGHTS_SEMANTICS_PASS", + ], + }, +] as const; + +for (const guest of guests) { + for (const semanticCase of semanticCases) { + test(`SCM_RIGHTS ${semanticCase.name} semantics use the actual ${guest.name} binary in Chromium`, async ({ + page, + baseURL, + browserName, + }) => { + test.skip( + browserName !== "chromium", + "the aggregate browser gate uses Chromium", + ); + expect(baseURL).toBeTruthy(); + + const runtimeErrors: string[] = []; + page.on("pageerror", (error) => { + runtimeErrors.push(`pageerror: ${error.message}`); + }); + page.on("console", (message) => { + if (message.type() === "error") { + runtimeErrors.push(`console: ${message.text()}`); + } + }); + page.on("requestfailed", (request) => { + runtimeErrors.push( + `requestfailed: ${request.url()} ${request.failure()?.errorText ?? "failed"}`, + ); + }); + + await page.goto( + new URL("/pages/test-runner/?minimal=1", baseURL).href, + ); + await page.waitForFunction( + () => (window as any).__testRunnerReady === true, + ); + + const programUrl = new URL(`/@fs/${guest.programPath}`, baseURL).href; + const result = await page.evaluate( + async ({ programUrl, caseName }) => { + const response = await fetch(programUrl); + if (!response.ok) { + throw new Error( + `program fetch failed: ${response.status} ${response.url}`, + ); + } + return (window as any).__runTest( + await response.arrayBuffer(), + ["/bin/scm-rights-semantics", "--case", caseName], + 30_000, + { + dataFiles: [ + { + path: "/bin/scm-rights-semantics", + useWasmBytes: true, + }, + ], + }, + ); + }, + { programUrl, caseName: semanticCase.name }, + ); + + expect(result.exitCode, result.stderr).toBe(0); + for (const marker of semanticCase.markers) { + expect(result.stdout).toContain(marker); + } + expect(result.stderr).toBe(""); + expect(runtimeErrors).toEqual([]); + }); + } +} diff --git a/apps/browser-demos/test/terminal-attributes-api.spec.ts b/apps/browser-demos/test/terminal-attributes-api.spec.ts index 6befb05d0d..c8a535ce1c 100644 --- a/apps/browser-demos/test/terminal-attributes-api.spec.ts +++ b/apps/browser-demos/test/terminal-attributes-api.spec.ts @@ -40,7 +40,9 @@ for (const program of programs) { runtimeErrors.push(`pageerror: ${error.message}`); }); - await page.goto(new URL("/pages/test-runner/", baseURL).href); + await page.goto( + new URL("/pages/test-runner/?minimal=1", baseURL).href, + ); await page.waitForFunction( () => (window as any).__testRunnerReady === true, ); diff --git a/apps/browser-demos/test/wait-lifecycle.spec.ts b/apps/browser-demos/test/wait-lifecycle.spec.ts index 970c23141d..1714e7ac58 100644 --- a/apps/browser-demos/test/wait-lifecycle.spec.ts +++ b/apps/browser-demos/test/wait-lifecycle.spec.ts @@ -51,7 +51,9 @@ for (const program of programs) } }); - await page.goto(new URL("/pages/test-runner/", baseURL).href); + await page.goto( + new URL("/pages/test-runner/?minimal=1", baseURL).href, + ); await page.waitForFunction( () => (window as any).__testRunnerReady === true, ); diff --git a/benchmarks/artifact-selection.test.ts b/benchmarks/artifact-selection.test.ts index b931717fbb..6354c3c19c 100644 --- a/benchmarks/artifact-selection.test.ts +++ b/benchmarks/artifact-selection.test.ts @@ -7,11 +7,16 @@ import { BENCHMARK_STATIC_ARTIFACTS, RUNNABLE_BENCHMARK_SUITES, benchmarkInputEvidenceFlags, + benchmarkRuntimeArtifactEvidenceFlags, benchmarkStaticArtifactEvidenceFlags, selectBrowserBenchmarkRuntimeArtifacts, selectNodeBenchmarkRuntimeArtifacts, } from "./artifact-selection.js"; import { assertRequiredBenchmarkArtifacts } from "./artifact-evidence.js"; +import { + collectSpawnScratchEvidence, + SPAWN_SCRATCH_LARGE_WIRE_BYTES, +} from "./spawn-scratch-evidence.js"; import type { BenchmarkArtifacts } from "./types.js"; import { resolveRootfsArtifact } from "../host/src/node-kernel-host.js"; @@ -59,6 +64,21 @@ test("browser selection uses the same policy-aware kernel resolver as Vite", () assert.equal(selections.rootfs, undefined); }); +test("Node selection does not resolve a rootfs for a self-contained suite", () => { + let rootfsCalls = 0; + const selections = selectNodeBenchmarkRuntimeArtifacts({ + resolveOptional: () => "/selected/kernel.wasm", + resolveRootfs() { + rootfsCalls++; + throw new Error("must not be called"); + }, + includeRootfs: false, + }); + + assert.equal(rootfsCalls, 0); + assert.equal(selections.rootfs, undefined); +}); + test("runtime and static evidence are required only for workloads that consume them", () => { assert.deepEqual( benchmarkInputEvidenceFlags({ @@ -135,6 +155,9 @@ test("static Wasm evidence follows the selected suite and host", () => { "benchmarks/wasm/fork-bench.wasm", "benchmarks/wasm/exec-bench.wasm", "benchmarks/wasm/clone-bench.wasm", + ]); + assert.deepEqual(usedPaths("node", "spawn-scratch"), [ + "benchmarks/wasm/hello.wasm", "benchmarks/wasm/spawn-bench.wasm", ]); assert.deepEqual(usedPaths("browser", "process-lifecycle"), [ @@ -153,6 +176,119 @@ test("static Wasm evidence follows the selected suite and host", () => { assert.deepEqual(usedPaths("node", "wordpress"), []); }); +test("only the dedicated spawn scratch benchmark opts out of the Node rootfs", () => { + assert.deepEqual( + benchmarkRuntimeArtifactEvidenceFlags({ + host: "node", + suiteFilter: "process-lifecycle", + artifactName: "rootfs", + }), + { required: true, used: true }, + ); + assert.deepEqual( + benchmarkRuntimeArtifactEvidenceFlags({ + host: "node", + suiteFilter: "spawn-scratch", + artifactName: "rootfs", + }), + { required: false, used: false }, + ); + assert.deepEqual( + benchmarkRuntimeArtifactEvidenceFlags({ + host: "node", + suiteFilter: "syscall-io", + artifactName: "rootfs", + }), + { required: true, used: true }, + ); + assert.deepEqual( + benchmarkRuntimeArtifactEvidenceFlags({ + host: "browser", + suiteFilter: "process-lifecycle", + artifactName: "rootfs", + }), + { required: false, used: false }, + ); +}); + +test("Node process metrics keep default-rootfs semantics", () => { + const processSource = readFileSync( + resolve(__dirname, "suites/process-lifecycle.ts"), + "utf8", + ); + const spawnSource = readFileSync( + resolve(__dirname, "suites/spawn-scratch.ts"), + "utf8", + ); + + assert.doesNotMatch(processSource, /useDefaultRootfs\s*:\s*false/); + assert.match(spawnSource, /useDefaultRootfs\s*:\s*false/); +}); + +test("spawn scratch workload fixes its ordinary env and validates child exit", () => { + const source = readFileSync( + resolve(__dirname, "programs/spawn-bench.c"), + "utf8", + ); + + assert.doesNotMatch(source, /extern\s+char\s+\*\*environ/); + assert.match(source, /spawn_and_wait\(ordinary_envp,\s*&ordinary_us\)/); + assert.match(source, /"LANG=C"/); + assert.match(source, /"PATH=\/bin"/); + assert.match(source, /if\s*\(!WIFEXITED\(status\)\)/); + assert.match(source, /if\s*\(WEXITSTATUS\(status\)\s*!=\s*0\)/); +}); + +test("spawn scratch evidence rejects missing timings and unexercised capacity", () => { + const stdout = [ + "spawn_ms=1.25", + `spawn_large_wire_bytes=${SPAWN_SCRATCH_LARGE_WIRE_BYTES}`, + "spawn_large_first_ms=2.5", + "spawn_large_repeat_ms=2.25", + ].join("\n"); + + assert.equal(SPAWN_SCRATCH_LARGE_WIRE_BYTES, 84_386); + assert.deepEqual( + collectSpawnScratchEvidence({ + stdout, + retainedCapacity: SPAWN_SCRATCH_LARGE_WIRE_BYTES, + kernelMemoryPages: 270, + }), + { + spawn_ms: 1.25, + spawn_large_wire_bytes: SPAWN_SCRATCH_LARGE_WIRE_BYTES, + spawn_large_first_ms: 2.5, + spawn_large_repeat_ms: 2.25, + spawn_scratch_retained_bytes: SPAWN_SCRATCH_LARGE_WIRE_BYTES, + spawn_scratch_kernel_bytes: 17_694_720, + }, + ); + assert.throws( + () => collectSpawnScratchEvidence({ + stdout: stdout.replace("spawn_large_first_ms=2.5\n", ""), + retainedCapacity: SPAWN_SCRATCH_LARGE_WIRE_BYTES, + kernelMemoryPages: 270, + }), + /spawn_large_first_ms/, + ); + assert.throws( + () => collectSpawnScratchEvidence({ + stdout, + retainedCapacity: SPAWN_SCRATCH_LARGE_WIRE_BYTES - 1, + kernelMemoryPages: 270, + }), + /did not retain enough scratch/, + ); + assert.throws( + () => collectSpawnScratchEvidence({ + stdout, + retainedCapacity: SPAWN_SCRATCH_LARGE_WIRE_BYTES, + kernelMemoryPages: 0, + }), + /kernel memory pages/, + ); +}); + test("browser static evidence matches the benchmark page's top-level Wasm imports", () => { const pageSource = readFileSync( resolve(__dirname, "../apps/browser-demos/pages/benchmark/main.ts"), diff --git a/benchmarks/artifact-selection.ts b/benchmarks/artifact-selection.ts index fc299b33e9..4532e95b81 100644 --- a/benchmarks/artifact-selection.ts +++ b/benchmarks/artifact-selection.ts @@ -40,7 +40,7 @@ export const BENCHMARK_STATIC_ARTIFACTS: BenchmarkStaticArtifactSelection[] = [ }, { path: "benchmarks/wasm/hello.wasm", - suites: ["process-lifecycle"], + suites: ["process-lifecycle", "spawn-scratch"], }, { path: "benchmarks/wasm/fork-bench.wasm", @@ -57,13 +57,14 @@ export const BENCHMARK_STATIC_ARTIFACTS: BenchmarkStaticArtifactSelection[] = [ }, { path: "benchmarks/wasm/spawn-bench.wasm", - suites: ["process-lifecycle"], + suites: ["spawn-scratch"], }, ]; export const RUNNABLE_BENCHMARK_SUITES = [ "syscall-io", "process-lifecycle", + "spawn-scratch", "wordpress", "mariadb-aria", "mariadb-aria-64", @@ -103,6 +104,25 @@ export function benchmarkStaticArtifactEvidenceFlags(options: { }); } +export function benchmarkRuntimeArtifactEvidenceFlags(options: { + host: "node" | "browser"; + suiteFilter?: string; + artifactName: string; +}): { required: boolean; used: boolean } { + const isRootfs = options.artifactName === "rootfs"; + return benchmarkInputEvidenceFlags({ + host: options.host, + suiteFilter: options.suiteFilter, + // The dedicated spawn-scratch suite supplies both executables and opts out + // of the default rootfs. The established process-lifecycle metrics retain + // their normal default-rootfs prerequisite and timing semantics. + suites: isRootfs + ? ["syscall-io", "process-lifecycle"] + : RUNNABLE_BENCHMARK_SUITES, + ...(isRootfs ? { hosts: ["node" as const] } : {}), + }); +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -111,28 +131,31 @@ export function selectNodeBenchmarkRuntimeArtifacts( options: { resolveOptional?: OptionalResolver; resolveRootfs?: () => ResolvedRootfsArtifact; + includeRootfs?: boolean; } = {}, ): BenchmarkRuntimeArtifactSelections { const resolveOptional = options.resolveOptional ?? tryResolveBinary; const resolveRootfs = options.resolveRootfs ?? resolveRootfsArtifact; const kernelPath = resolveOptional("kernel.wasm"); - let rootfs: BenchmarkRuntimeArtifactSelection; - try { - const resolved = resolveRootfs(); - rootfs = { - logicalPath: "rootfs.vfs", - selectedPath: resolved.selectedPath, - resolverRequest: resolved.resolverRequest, - resolverSelectedPath: resolved.selectedPath, - }; - } catch (error) { - rootfs = { - logicalPath: "rootfs.vfs", - selectedPath: null, - resolverRequest: "rootfs.vfs -> programs/rootfs.vfs", - error: errorMessage(error), - }; + let rootfs: BenchmarkRuntimeArtifactSelection | undefined; + if (options.includeRootfs !== false) { + try { + const resolved = resolveRootfs(); + rootfs = { + logicalPath: "rootfs.vfs", + selectedPath: resolved.selectedPath, + resolverRequest: resolved.resolverRequest, + resolverSelectedPath: resolved.selectedPath, + }; + } catch (error) { + rootfs = { + logicalPath: "rootfs.vfs", + selectedPath: null, + resolverRequest: "rootfs.vfs -> programs/rootfs.vfs", + error: errorMessage(error), + }; + } } return { @@ -145,7 +168,7 @@ export function selectNodeBenchmarkRuntimeArtifacts( ? { error: "tryResolveBinary(\"kernel.wasm\") returned no usable artifact" } : {}), }, - rootfs, + ...(rootfs ? { rootfs } : {}), }; } diff --git a/benchmarks/browser/run-browser.ts b/benchmarks/browser/run-browser.ts index 8eaea5db51..69e9aa5ccf 100644 --- a/benchmarks/browser/run-browser.ts +++ b/benchmarks/browser/run-browser.ts @@ -34,7 +34,7 @@ declare global { /** Suites available in the browser benchmark page. */ const BROWSER_SUITES = [ - "syscall-io", "process-lifecycle", "wordpress", + "syscall-io", "process-lifecycle", "spawn-scratch", "wordpress", "mariadb-aria", "mariadb-aria-64", "mariadb-innodb", "mariadb-innodb-64", ]; @@ -47,6 +47,7 @@ const DISABLED_BROWSER_SUITES: Record = { const SUITE_TIMEOUTS: Record = { "syscall-io": 60_000, "process-lifecycle": 60_000, + "spawn-scratch": 60_000, "wordpress": 300_000, "mariadb-aria": 600_000, "mariadb-aria-64": 600_000, diff --git a/benchmarks/programs/spawn-bench.c b/benchmarks/programs/spawn-bench.c index dafe5241b5..f4eccffa9e 100644 --- a/benchmarks/programs/spawn-bench.c +++ b/benchmarks/programs/spawn-bench.c @@ -1,10 +1,13 @@ /* spawn-bench.c — measure posix_spawn + child exit latency. * - * posix_spawn's a child running /bin/hello, waits for it, prints the - * elapsed wall-clock as `spawn_ms`. The TypeScript suite wrapper picks - * up that line. Mirrors `fork-bench.c` (which times fork()) and - * `exec-bench.c` (which times execve()) — `spawn_ms` exists to catch - * the spawn fast-path's contribution that those don't measure. + * Measures an ordinary spawn, the first approximately 84 KiB Homebrew-like + * environment transfer, and repeated transfers at that high-water mark. + * The TypeScript suite wrapper picks up the printed metrics. Mirrors + * `fork-bench.c` (which times fork()) and `exec-bench.c` (which times + * execve()) — these metrics exist to catch the spawn fast-path's contribution + * that those don't measure. The ordinary environment is fixed rather than + * inherited from the benchmark runner, and a sample is valid only when the + * waited child exits normally with status zero. * * Loaded via execPrograms-mapped /bin/hello (the same binary * exec-bench targets). The harness sets execPrograms[/bin/hello] to @@ -14,10 +17,12 @@ */ #include #include +#include +#include #include #include -extern char **environ; +#include "../../libc/musl-overlay/src/process/wasm32posix/spawn_contract.h" static long long now_us(void) { struct timeval tv; @@ -25,25 +30,141 @@ static long long now_us(void) { return (long long)tv.tv_sec * 1000000LL + tv.tv_usec; } -int main(void) { +static int spawn_and_wait(char *const envp[], long long *elapsed_us) { long long t0 = now_us(); char *argv[] = { "hello", NULL }; pid_t pid; - int rc = posix_spawn(&pid, "/bin/hello", NULL, NULL, argv, environ); + int rc = posix_spawn(&pid, "/bin/hello", NULL, NULL, argv, envp); if (rc != 0) { fprintf(stderr, "posix_spawn: %d\n", rc); - return 1; + return rc; } int status; - if (waitpid(pid, &status, 0) < 0) { + pid_t waited = waitpid(pid, &status, 0); + if (waited < 0) { perror("waitpid"); + return -1; + } + if (waited != pid) { + fprintf(stderr, "waitpid returned unexpected child %d\n", (int)waited); + return -1; + } + if (!WIFEXITED(status)) { + if (WIFSIGNALED(status)) { + fprintf( + stderr, + "spawn child terminated by signal %d\n", + WTERMSIG(status) + ); + } else { + fprintf(stderr, "spawn child did not exit normally: status=%d\n", status); + } + return -1; + } + if (WEXITSTATUS(status) != 0) { + fprintf(stderr, "spawn child exited with status %d\n", WEXITSTATUS(status)); + return -1; + } + + *elapsed_us = now_us() - t0; + return 0; +} + +enum { + LARGE_ENV_COUNT = 84, + LARGE_ENV_ENTRY_BYTES = 1000, + LARGE_REPEAT_COUNT = 5, + LARGE_ARG_COUNT = 1, + LARGE_ARG_STRING_BYTES = sizeof("hello"), + LARGE_WIRE_BYTES = + WASM_POSIX_SPAWN_HEADER_BYTES + + WASM_POSIX_SPAWN_STRING_OFFSET_BYTES + * (LARGE_ARG_COUNT + LARGE_ENV_COUNT) + + LARGE_ARG_STRING_BYTES + + LARGE_ENV_COUNT * LARGE_ENV_ENTRY_BYTES, +}; + +static char ordinary_lang[] = "LANG=C"; +static char ordinary_path[] = "PATH=/bin"; +static char *const ordinary_envp[] = { + ordinary_lang, + ordinary_path, + NULL, +}; + +static char **make_large_environment(void) { + char **envp = calloc(LARGE_ENV_COUNT + 1, sizeof(*envp)); + if (!envp) return NULL; + + for (size_t i = 0; i < LARGE_ENV_COUNT; i++) { + envp[i] = malloc(LARGE_ENV_ENTRY_BYTES); + if (!envp[i]) { + while (i > 0) free(envp[--i]); + free(envp); + return NULL; + } + int prefix = snprintf( + envp[i], + LARGE_ENV_ENTRY_BYTES, + "K%03zu=", + i + ); + if (prefix < 0 || prefix >= LARGE_ENV_ENTRY_BYTES) { + for (size_t j = 0; j <= i; j++) free(envp[j]); + free(envp); + return NULL; + } + memset( + envp[i] + prefix, + 'x', + LARGE_ENV_ENTRY_BYTES - (size_t)prefix - 1 + ); + envp[i][LARGE_ENV_ENTRY_BYTES - 1] = '\0'; + } + return envp; +} + +static void free_large_environment(char **envp) { + if (!envp) return; + for (size_t i = 0; i < LARGE_ENV_COUNT; i++) free(envp[i]); + free(envp); +} + +int main(void) { + long long ordinary_us; + if (spawn_and_wait(ordinary_envp, &ordinary_us) != 0) return 1; + printf("spawn_ms=%f\n", ordinary_us / 1000.0); + + char **large_envp = make_large_environment(); + if (!large_envp) { + perror("large spawn environment"); return 2; } + printf("spawn_large_wire_bytes=%u\n", (unsigned)LARGE_WIRE_BYTES); + + long long first_large_us; + if (spawn_and_wait(large_envp, &first_large_us) != 0) { + free_large_environment(large_envp); + return 3; + } + printf("spawn_large_first_ms=%f\n", first_large_us / 1000.0); + + long long repeated_us = 0; + for (int i = 0; i < LARGE_REPEAT_COUNT; i++) { + long long sample_us; + if (spawn_and_wait(large_envp, &sample_us) != 0) { + free_large_environment(large_envp); + return 4; + } + repeated_us += sample_us; + } + printf( + "spawn_large_repeat_ms=%f\n", + repeated_us / (1000.0 * LARGE_REPEAT_COUNT) + ); - long long t1 = now_us(); - double ms = (t1 - t0) / 1000.0; - printf("spawn_ms=%f\n", ms); + free_large_environment(large_envp); return 0; } diff --git a/benchmarks/run.ts b/benchmarks/run.ts index 8393c4bf8b..6a2fb435f5 100644 --- a/benchmarks/run.ts +++ b/benchmarks/run.ts @@ -36,12 +36,14 @@ import { BENCHMARK_STATIC_ARTIFACTS, RUNNABLE_BENCHMARK_SUITES, benchmarkInputEvidenceFlags, + benchmarkRuntimeArtifactEvidenceFlags, benchmarkStaticArtifactEvidenceFlags, selectBrowserBenchmarkRuntimeArtifacts, selectNodeBenchmarkRuntimeArtifacts, type BenchmarkRuntimeArtifactSelections, } from "./artifact-selection.js"; import { assertRequiredBenchmarkArtifacts } from "./artifact-evidence.js"; +import { compiledWorkerEntryIsCurrent } from "../host/src/compiled-worker-entry.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, ".."); @@ -453,12 +455,9 @@ function collectRuntimeArtifacts( options: { host: "node" | "browser"; suiteFilter?: string }, ): void { for (const [name, selection] of Object.entries(selections)) { - const evidenceFlags = benchmarkInputEvidenceFlags({ + const evidenceFlags = benchmarkRuntimeArtifactEvidenceFlags({ ...options, - suites: name === "rootfs" - ? ["syscall-io", "process-lifecycle"] - : RUNNABLE_BENCHMARK_SUITES, - ...(name === "rootfs" ? { hosts: ["node" as const] } : {}), + artifactName: name, }); files[`runtime.${name}`] = fingerprintSelectedFile( selection.logicalPath, @@ -503,9 +502,45 @@ export async function collectBenchmarkArtifacts(options: { }), ); const directories: Record = {}; + const hostSourceRoot = resolve(repoRoot, "host/src"); + directories["runtime.hostSources"] = fingerprintDirectory( + "host/src", + hostSourceRoot, + [], + ); + if (options.host === "node") { + const workerSource = resolve( + hostSourceRoot, + "node-kernel-worker-entry.ts", + ); + const workerBundle = resolve( + repoRoot, + "host/dist/node-kernel-worker-entry.js", + ); + const bundleEvidence = fingerprintSelectedFile( + "host/dist/node-kernel-worker-entry.js", + workerBundle, + { required: true, used: true }, + ); + if ( + bundleEvidence.missing !== true + && !compiledWorkerEntryIsCurrent(workerSource, workerBundle) + ) { + bundleEvidence.missing = true; + bundleEvidence.error = + "worker bundle is older than at least one host/src TypeScript input"; + } + files["runtime.nodeWorkerBundle"] = bundleEvidence; + } const runtimeSelections = options.runtimeSelections ?? ( options.host === "node" - ? selectNodeBenchmarkRuntimeArtifacts() + ? selectNodeBenchmarkRuntimeArtifacts({ + includeRootfs: benchmarkRuntimeArtifactEvidenceFlags({ + host: options.host, + suiteFilter: options.suiteFilter, + artifactName: "rootfs", + }).used, + }) : selectBrowserBenchmarkRuntimeArtifacts() ); collectRuntimeArtifacts(runtimeSelections, files, options); @@ -573,6 +608,7 @@ function logArtifacts(artifacts: BenchmarkArtifacts) { const SUITE_MODULES: Record = { "syscall-io": "./suites/syscall-io.js", "process-lifecycle": "./suites/process-lifecycle.js", + "spawn-scratch": "./suites/spawn-scratch.js", "erlang-ring": "./suites/erlang-ring.js", "wordpress": "./suites/wordpress.js", "mariadb-aria": "./suites/mariadb-aria.js", diff --git a/benchmarks/spawn-scratch-evidence.ts b/benchmarks/spawn-scratch-evidence.ts new file mode 100644 index 0000000000..79c2798e8d --- /dev/null +++ b/benchmarks/spawn-scratch-evidence.ts @@ -0,0 +1,98 @@ +import { + SPAWN_WIRE_HEADER_BYTES, + SPAWN_WIRE_STRING_OFFSET_BYTES, +} from "../host/src/generated/abi.js"; + +const LARGE_ENV_COUNT = 84; +const LARGE_ENV_ENTRY_BYTES = 1000; +const LARGE_ARG_COUNT = 1; +const LARGE_ARG_STRING_BYTES = "hello".length + 1; + +export const SPAWN_SCRATCH_LARGE_WIRE_BYTES = + SPAWN_WIRE_HEADER_BYTES + + SPAWN_WIRE_STRING_OFFSET_BYTES * (LARGE_ARG_COUNT + LARGE_ENV_COUNT) + + LARGE_ARG_STRING_BYTES + + LARGE_ENV_COUNT * LARGE_ENV_ENTRY_BYTES; + +const TIMING_KEYS = [ + "spawn_ms", + "spawn_large_first_ms", + "spawn_large_repeat_ms", +] as const; + +function parseMetrics(stdout: string): Record { + const metrics: Record = {}; + for (const line of stdout.split("\n")) { + const match = line.match(/^(\w+)=([\d.eE+-]+)$/); + if (match) { + metrics[match[1]] = Number(match[2]); + } + } + return metrics; +} + +function requireNonnegativeFinite( + value: number | undefined, + label: string, +): number { + if (value === undefined || !Number.isFinite(value) || value < 0) { + throw new Error(`spawn-bench returned invalid ${label}: ${String(value)}`); + } + return value; +} + +function requirePositiveSafeInteger(value: number, label: string): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`spawn-bench returned invalid ${label}: ${String(value)}`); + } + return value; +} + +export function collectSpawnScratchEvidence(options: { + stdout: string; + retainedCapacity: number; + kernelMemoryPages: number; +}): Record { + const metrics = parseMetrics(options.stdout); + for (const key of TIMING_KEYS) { + requireNonnegativeFinite(metrics[key], key); + } + + const reportedWireBytes = requirePositiveSafeInteger( + metrics.spawn_large_wire_bytes, + "spawn_large_wire_bytes", + ); + if (reportedWireBytes !== SPAWN_SCRATCH_LARGE_WIRE_BYTES) { + throw new Error( + "spawn-bench large wire size drifted: " + + `expected ${SPAWN_SCRATCH_LARGE_WIRE_BYTES}, got ${reportedWireBytes}`, + ); + } + + const retainedCapacity = requirePositiveSafeInteger( + options.retainedCapacity, + "retained scratch capacity", + ); + if (retainedCapacity < SPAWN_SCRATCH_LARGE_WIRE_BYTES) { + throw new Error( + "spawn-bench did not retain enough scratch for the exercised large blob: " + + `${retainedCapacity} < ${SPAWN_SCRATCH_LARGE_WIRE_BYTES}`, + ); + } + const kernelMemoryPages = requirePositiveSafeInteger( + options.kernelMemoryPages, + "kernel memory pages", + ); + const kernelMemoryBytes = kernelMemoryPages * 65_536; + if (!Number.isSafeInteger(kernelMemoryBytes)) { + throw new Error( + `spawn-bench kernel memory byte count is unsafe: ${kernelMemoryBytes}`, + ); + } + + return { + ...metrics, + spawn_scratch_retained_bytes: retainedCapacity, + spawn_scratch_kernel_bytes: kernelMemoryBytes, + }; +} diff --git a/benchmarks/suites/process-lifecycle.ts b/benchmarks/suites/process-lifecycle.ts index 0fd850ac40..315db9da39 100644 --- a/benchmarks/suites/process-lifecycle.ts +++ b/benchmarks/suites/process-lifecycle.ts @@ -79,21 +79,6 @@ const suite: BenchmarkSuite = { if (clone.exitCode !== 0) throw new Error(`clone-bench failed: ${clone.stderr}`); Object.assign(results, parseMetrics(clone.stdout)); - // posix_spawn — the non-forking SYS_SPAWN fast path. Distinct from - // exec_ms (which times execve replacing the same process) and - // fork_ms (which times fork rewind). spawn_ms exists - // because popen / system / shell pipelines all go through - // posix_spawn, and that path used to cost a fork-instrument unwind - // we no longer pay. - const spawnBench = await runCentralizedProgram({ - programPath: resolve(wasmDir, "spawn-bench.wasm"), - argv: ["spawn-bench"], - execPrograms, - timeout: 30_000, - }); - if (spawnBench.exitCode !== 0) throw new Error(`spawn-bench failed: ${spawnBench.stderr}`); - Object.assign(results, parseMetrics(spawnBench.stdout)); - return results; }, }; diff --git a/benchmarks/suites/spawn-scratch.ts b/benchmarks/suites/spawn-scratch.ts new file mode 100644 index 0000000000..7d266e1e2e --- /dev/null +++ b/benchmarks/suites/spawn-scratch.ts @@ -0,0 +1,55 @@ +/** + * Spawn Scratch + * + * Measures the ordinary and large posix_spawn transport paths plus retained + * scratch capacity. This is intentionally separate from process-lifecycle: + * the established hello/fork/exec/clone metrics continue to boot the default + * rootfs, while this fully supplied workload can run against an empty VFS. + */ +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; +import { runCentralizedProgram } from "../../host/test/centralized-test-helper.js"; +import { collectSpawnScratchEvidence } from "../spawn-scratch-evidence.js"; +import type { BenchmarkSuite } from "../types.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const wasmDir = resolve(__dirname, "../wasm"); + +const suite: BenchmarkSuite = { + name: "spawn-scratch", + + async run(): Promise> { + const helloPath = resolve(wasmDir, "hello.wasm"); + const execPrograms = new Map([ + ["/bin/hello", helloPath], + ]); + const spawnBench = await runCentralizedProgram({ + // WHY: both executables are supplied explicitly. Keeping this isolated + // from process-lifecycle avoids changing that suite's default-rootfs + // timing semantics merely to gather spawn scratch evidence. + useDefaultRootfs: false, + programPath: resolve(wasmDir, "spawn-bench.wasm"), + argv: ["spawn-bench"], + execPrograms, + timeout: 30_000, + captureSpawnScratchStats: true, + }); + if (spawnBench.exitCode !== 0) { + throw new Error(`spawn-bench failed: ${spawnBench.stderr}`); + } + if ( + spawnBench.spawnScratchCapacity === undefined || + spawnBench.kernelMemoryPages === undefined + ) { + throw new Error("spawn-bench did not return kernel scratch telemetry"); + } + + return collectSpawnScratchEvidence({ + stdout: spawnBench.stdout, + retainedCapacity: spawnBench.spawnScratchCapacity, + kernelMemoryPages: spawnBench.kernelMemoryPages, + }); + }, +}; + +export default suite; diff --git a/crates/kernel/Cargo.toml b/crates/kernel/Cargo.toml index 80eb22c03c..ca28eb6f5d 100644 --- a/crates/kernel/Cargo.toml +++ b/crates/kernel/Cargo.toml @@ -8,6 +8,7 @@ name = "kandelo_kernel" crate-type = ["cdylib", "rlib"] [dependencies] +spin = { version = "=0.12.2", default-features = false, features = ["mutex", "spin_mutex"] } wasm-posix-shared = { path = "../shared" } [target.'cfg(any(target_arch = "wasm32", target_arch = "wasm64"))'.dependencies] diff --git a/crates/kernel/src/channel_scratch.rs b/crates/kernel/src/channel_scratch.rs new file mode 100644 index 0000000000..c2f2f2da48 --- /dev/null +++ b/crates/kernel/src/channel_scratch.rs @@ -0,0 +1,1176 @@ +//! Capacity-carrying bounds for one live widened-syscall channel allocation. +//! +//! This module is target-independent so the pure ownership checks used by the +//! Wasm dispatcher are exercised by the ordinary native kernel test suite. + +use core::mem::{offset_of, size_of}; + +use wasm_posix_shared::abi::extended_syscalls; +use wasm_posix_shared::host_abi::{ + PROCESS_POINTER_WIDTH_ARG_INDEX, SYSCALL_ARG_DESCRIPTORS, SyscallArgDesc, SyscallArgSize, +}; +use wasm_posix_shared::{ + Errno, KernelIovecWire, KernelMsghdrWire, Syscall, WasmEpollEvent, WasmSysvMessageHeader, + kernel_scratch_wire, platform_limits, prctl, +}; + +const SCRATCH_ALIGNMENT: usize = 8; +const KERNEL_WIRE_ALIGNMENT: usize = 4; +const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991; + +/// Numeric bounds of the data area in one live kernel channel allocation. +/// +/// Keep the allocation capacity beside its address. A pointer being somewhere +/// in kernel linear memory does not prove that the bytes after it belong to the +/// channel currently being dispatched. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ChannelScratchRegion { + start: usize, + capacity: usize, +} + +/// One already-proven subrange of a live channel scratch allocation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ChannelScratchRange { + start: usize, + length: usize, +} + +impl ChannelScratchRange { + pub(crate) const fn start(self) -> usize { + self.start + } +} + +impl ChannelScratchRegion { + pub(crate) fn new(start: usize, capacity: usize) -> Result { + start.checked_add(capacity).ok_or(Errno::EFAULT)?; + Ok(Self { start, capacity }) + } + + pub(crate) fn for_channel(channel_offset: usize) -> Result { + use wasm_posix_shared::channel::{DATA_OFFSET, DATA_SIZE}; + + let start = channel_offset + .checked_add(DATA_OFFSET) + .ok_or(Errno::EFAULT)?; + Self::new(start, DATA_SIZE) + } + + pub(crate) const fn start(self) -> usize { + self.start + } + + pub(crate) fn end(self) -> Result { + self.start + .checked_add(self.capacity) + .ok_or(Errno::EFAULT) + } + + /// Prove a complete byte range against this allocation, independently of + /// whether it also happens to fit in the kernel's total linear memory. + pub(crate) fn checked_range( + self, + pointer: usize, + length: usize, + ) -> Result { + if pointer < self.start { + return Err(Errno::EFAULT); + } + let allocation_end = self.end()?; + let range_end = pointer.checked_add(length).ok_or(Errno::EFAULT)?; + if pointer > allocation_end || range_end > allocation_end { + return Err(Errno::EFAULT); + } + if length > 0 && pointer == 0 { + return Err(Errno::EFAULT); + } + Ok(ChannelScratchRange { + start: pointer, + length, + }) + } + + fn remaining_from(self, pointer: usize) -> Result { + if pointer == 0 || pointer < self.start { + return Err(Errno::EFAULT); + } + let end = self.start.checked_add(self.capacity).ok_or(Errno::EFAULT)?; + if pointer >= end { + return Err(Errno::EFAULT); + } + end.checked_sub(pointer).ok_or(Errno::EFAULT) + } +} + +/// Per-argument evidence produced before channel dispatch dereferences scratch. +/// +/// `described[index]` distinguishes a reviewed null pointer from an argument +/// which no descriptor or bespoke wire validator proved. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ValidatedChannelScratchArgs { + described: [bool; 6], + ranges: [Option; 6], +} + +impl ValidatedChannelScratchArgs { + const fn new() -> Self { + Self { + described: [false; 6], + ranges: [None; 6], + } + } + + fn mark_null(&mut self, index: usize) -> Result<(), Errno> { + if index >= self.ranges.len() { + return Err(Errno::EINVAL); + } + self.described[index] = true; + self.ranges[index] = None; + Ok(()) + } + + fn mark_range(&mut self, index: usize, range: ChannelScratchRange) -> Result<(), Errno> { + if index >= self.ranges.len() { + return Err(Errno::EINVAL); + } + self.described[index] = true; + self.ranges[index] = Some(range); + Ok(()) + } + + /// Return a pointer only if the corresponding descriptor or reviewed + /// bespoke-wire validator proved its exact allocation-owned subrange. + pub(crate) fn pointer(self, index: usize) -> Result { + if index >= self.ranges.len() || !self.described[index] { + return Err(Errno::EFAULT); + } + Ok(self.ranges[index].map_or(0, ChannelScratchRange::start)) + } +} + +fn checked_pointer(raw: i64) -> Result { + usize::try_from(raw as u64).map_err(|_| Errno::EFAULT) +} + +fn checked_size_scalar(raw: i64) -> Result { + if !(0..=MAX_SAFE_INTEGER).contains(&raw) { + return Err(Errno::EINVAL); + } + usize::try_from(raw).map_err(|_| Errno::EINVAL) +} + +fn align_up(value: usize, alignment: usize) -> Result { + if !alignment.is_power_of_two() { + return Err(Errno::EINVAL); + } + value + .checked_add(alignment - 1) + .map(|value| value & !(alignment - 1)) + .ok_or(Errno::EFAULT) +} + +unsafe fn read_u32(pointer: usize, region: ChannelScratchRegion) -> Result { + let range = region.checked_range(pointer, size_of::())?; + let bytes = unsafe { core::slice::from_raw_parts(range.start as *const u8, range.length) }; + Ok(u32::from_le_bytes( + bytes.try_into().map_err(|_| Errno::EFAULT)?, + )) +} + +unsafe fn descriptor_size( + descriptor: &SyscallArgDesc, + args: &[i64; 6], + region: ChannelScratchRegion, +) -> Result { + match descriptor.size { + SyscallArgSize::CString => { + let pointer = checked_pointer(args[descriptor.arg_index as usize])?; + let length = unsafe { checked_cstr_len(pointer as *const u8, region) }?; + usize::try_from(length) + .ok() + .and_then(|length| length.checked_add(1)) + .ok_or(Errno::EFAULT) + } + SyscallArgSize::Arg { + arg_index, + multiplier, + add, + } => checked_size_scalar(args[arg_index as usize])? + .checked_mul(multiplier as usize) + .and_then(|length| length.checked_add(add as usize)) + .ok_or(Errno::EINVAL), + SyscallArgSize::Deref { arg_index } => { + let pointer = checked_pointer(args[arg_index as usize])?; + if pointer == 0 { + return Err(Errno::EFAULT); + } + Ok(unsafe { read_u32(pointer, region) }? as usize) + } + SyscallArgSize::Fixed { size } => Ok(size as usize), + SyscallArgSize::ProcessLayout { + wasm32_size, + wasm64_size, + } => match args[PROCESS_POINTER_WIDTH_ARG_INDEX as usize] { + 4 => Ok(wasm32_size as usize), + 8 => Ok(wasm64_size as usize), + _ => Err(Errno::EINVAL), + }, + } +} + +unsafe fn validate_descriptor_layout( + args: &[i64; 6], + descriptors: &[SyscallArgDesc], + region: ChannelScratchRegion, +) -> Result { + let mut validated = ValidatedChannelScratchArgs::new(); + let mut cursor = region.start; + + for descriptor in descriptors { + let index = descriptor.arg_index as usize; + if index >= args.len() { + return Err(Errno::EINVAL); + } + let pointer = checked_pointer(args[index])?; + if pointer == 0 { + if let SyscallArgSize::Arg { + arg_index, + multiplier, + add, + } = descriptor.size + { + let length = checked_size_scalar(args[arg_index as usize])? + .checked_mul(multiplier as usize) + .and_then(|length| length.checked_add(add as usize)) + .ok_or(Errno::EINVAL)?; + if length == 0 { + // Preserve the syscall's null-plus-zero semantics while + // giving Rust a valid empty-slice address. The normal host + // already writes this canonical address itself. + validated.mark_range(index, region.checked_range(region.start, 0)?)?; + continue; + } + } + if !descriptor.nullable { + // Shared metadata explicitly classifies every positive-extent + // pointer as required or nullable. Enforce that classification + // independently in Rust before dispatch can form a slice. + return Err(Errno::EFAULT); + } + validated.mark_null(index)?; + continue; + } + + let length = unsafe { descriptor_size(descriptor, args, region) }?; + if length == 0 { + // WHY: the host deliberately canonicalizes every empty borrow to + // the allocation start. Accepting an arbitrary address here would + // let a process pointer cross the host/kernel boundary merely + // because the associated count happened to be zero. + if pointer != region.start { + return Err(Errno::EFAULT); + } + validated.mark_range(index, region.checked_range(pointer, 0)?)?; + continue; + } + if pointer != cursor { + return Err(Errno::EFAULT); + } + let range = region.checked_range(pointer, length)?; + validated.mark_range(index, range)?; + cursor = align_up( + pointer.checked_add(length).ok_or(Errno::EFAULT)?, + SCRATCH_ALIGNMENT, + )?; + if cursor > region.end()? { + return Err(Errno::EFAULT); + } + } + Ok(validated) +} + +fn checked_exact_range( + validated: &mut ValidatedChannelScratchArgs, + args: &[i64; 6], + index: usize, + expected_pointer: usize, + length: usize, + region: ChannelScratchRegion, +) -> Result { + let pointer = checked_pointer(args[index])?; + if pointer != expected_pointer { + return Err(Errno::EFAULT); + } + let range = region.checked_range(pointer, length)?; + validated.mark_range(index, range)?; + Ok(range) +} + +fn checked_nullable_exact_range( + validated: &mut ValidatedChannelScratchArgs, + args: &[i64; 6], + index: usize, + expected_pointer: usize, + length: usize, + region: ChannelScratchRegion, +) -> Result<(), Errno> { + let pointer = checked_pointer(args[index])?; + if pointer == 0 { + return validated.mark_null(index); + } + checked_exact_range( + validated, + args, + index, + expected_pointer, + length, + region, + )?; + Ok(()) +} + +unsafe fn validate_iovec_layout( + args: &[i64; 6], + region: ChannelScratchRegion, +) -> Result { + let count = checked_size_scalar(args[2])?; + if count == 0 || count > platform_limits::IOV_MAX { + return Err(Errno::EINVAL); + } + let table_bytes = count + .checked_mul(size_of::()) + .ok_or(Errno::EINVAL)?; + let mut validated = ValidatedChannelScratchArgs::new(); + let table = checked_exact_range( + &mut validated, + args, + 1, + region.start, + table_bytes, + region, + )?; + let table_bytes = + unsafe { core::slice::from_raw_parts(table.start as *const u8, table.length) }; + let mut cursor = table.start.checked_add(table.length).ok_or(Errno::EFAULT)?; + for entry in table_bytes.chunks_exact(size_of::()) { + let base = + read_wire_u32(entry, offset_of!(KernelIovecWire, base))? as usize; + let length = + read_wire_u32(entry, offset_of!(KernelIovecWire, len))? as usize; + if base != cursor { + return Err(Errno::EFAULT); + } + region.checked_range(base, length)?; + cursor = align_up( + base.checked_add(length).ok_or(Errno::EFAULT)?, + KERNEL_WIRE_ALIGNMENT, + )?; + if cursor > region.end()? { + return Err(Errno::EFAULT); + } + } + Ok(validated) +} + +fn read_wire_u32(bytes: &[u8], offset: usize) -> Result { + let end = offset.checked_add(size_of::()).ok_or(Errno::EFAULT)?; + let bytes = bytes.get(offset..end).ok_or(Errno::EFAULT)?; + Ok(u32::from_le_bytes( + bytes.try_into().map_err(|_| Errno::EFAULT)?, + )) +} + +unsafe fn validate_message_layout( + args: &[i64; 6], + region: ChannelScratchRegion, +) -> Result { + let mut validated = ValidatedChannelScratchArgs::new(); + let header = checked_exact_range( + &mut validated, + args, + 1, + region.start, + size_of::(), + region, + )?; + let header = + unsafe { core::slice::from_raw_parts(header.start as *const u8, header.length) }; + let name = read_wire_u32(header, offset_of!(KernelMsghdrWire, name))? as usize; + let name_len = read_wire_u32(header, offset_of!(KernelMsghdrWire, name_len))? as usize; + let iov = read_wire_u32(header, offset_of!(KernelMsghdrWire, iov))? as usize; + let iov_len = read_wire_u32(header, offset_of!(KernelMsghdrWire, iov_len))? as usize; + let control = read_wire_u32(header, offset_of!(KernelMsghdrWire, control))? as usize; + let control_len = + read_wire_u32(header, offset_of!(KernelMsghdrWire, control_len))? as usize; + + let mut cursor = region + .start + .checked_add(size_of::()) + .ok_or(Errno::EFAULT)?; + let mut append = |pointer: usize, length: usize| -> Result<(), Errno> { + if length == 0 { + return if pointer == 0 { + Ok(()) + } else { + Err(Errno::EFAULT) + }; + } + if pointer != cursor { + return Err(Errno::EFAULT); + } + region.checked_range(pointer, length)?; + cursor = align_up( + pointer.checked_add(length).ok_or(Errno::EFAULT)?, + KERNEL_WIRE_ALIGNMENT, + )?; + if cursor > region.end()? { + return Err(Errno::EFAULT); + } + Ok(()) + }; + + append(name, name_len)?; + append(control, control_len)?; + if iov_len > wasm_posix_shared::socket::KERNEL_MESSAGE_WIRE_FLATTENED_IOVEC_COUNT as usize { + return Err(Errno::EINVAL); + } + let iov_bytes = iov_len + .checked_mul(size_of::()) + .ok_or(Errno::EINVAL)?; + append(iov, iov_bytes)?; + if iov_len == 1 { + let iovec = + unsafe { core::slice::from_raw_parts(iov as *const u8, size_of::()) }; + let base = read_wire_u32(iovec, offset_of!(KernelIovecWire, base))? as usize; + let length = read_wire_u32(iovec, offset_of!(KernelIovecWire, len))? as usize; + if length == 0 { + if base != 0 { + return Err(Errno::EFAULT); + } + } else { + append(base, length)?; + } + } + Ok(validated) +} + +fn validate_select_layout( + args: &[i64; 6], + region: ChannelScratchRegion, + has_mask: bool, +) -> Result { + let mut validated = ValidatedChannelScratchArgs::new(); + let fd_set_bytes = wasm_posix_shared::select::FD_SET_BYTES; + for index in 1usize..=3 { + let offset = (index - 1) + .checked_mul(fd_set_bytes) + .ok_or(Errno::EFAULT)?; + checked_nullable_exact_range( + &mut validated, + args, + index, + region.start.checked_add(offset).ok_or(Errno::EFAULT)?, + fd_set_bytes, + region, + )?; + } + if has_mask { + let mask_pointer = region + .start + .checked_add(3 * fd_set_bytes) + .ok_or(Errno::EFAULT)?; + checked_nullable_exact_range( + &mut validated, + args, + 5, + mask_pointer, + kernel_scratch_wire::SIGNAL_MASK_BYTES as usize, + region, + )?; + } + Ok(validated) +} + +fn validate_ioctl_layout( + args: &[i64; 6], + region: ChannelScratchRegion, +) -> Result { + use wasm_posix_shared::ioctl_contract::IoctlArgKind; + + let mut validated = ValidatedChannelScratchArgs::new(); + let request = args[1] as u32; + let Some(contract) = wasm_posix_shared::ioctl_contract::request_contract(request) else { + return Ok(validated); + }; + if contract.arg_kind != IoctlArgKind::Pointer { + return Ok(validated); + } + let width = u8::try_from(args[5]).map_err(|_| Errno::EINVAL)?; + let size = contract + .size_for_pointer_width(width) + .ok_or(Errno::EINVAL)? as usize; + if checked_size_scalar(args[3])? != size { + return Err(Errno::EINVAL); + } + checked_exact_range( + &mut validated, + args, + 2, + region.start, + size, + region, + )?; + Ok(validated) +} + +fn validate_ipc_control_layout( + args: &[i64; 6], + region: ChannelScratchRegion, + syscall_number: u32, +) -> Result { + let mut validated = ValidatedChannelScratchArgs::new(); + let command = (args[1] as i32) & !0x100; + if !matches!(command, 1 | 2) { + validated.mark_null(2)?; + return Ok(validated); + } + let width = u32::try_from(args[5]).map_err(|_| Errno::EINVAL)?; + let size = if syscall_number == extended_syscalls::SYS_MSGCTL { + crate::ipc_wire::msqid_ds_size(width)? + } else { + crate::ipc_wire::shmid_ds_size(width)? + }; + checked_exact_range( + &mut validated, + args, + 2, + region.start, + size, + region, + )?; + Ok(validated) +} + +fn validate_special_layout( + syscall_number: u32, + args: &[i64; 6], + region: ChannelScratchRegion, +) -> Result { + match syscall_number { + number if number == Syscall::Fcntl as u32 => { + let mut validated = ValidatedChannelScratchArgs::new(); + if matches!(args[1] as u32, 5 | 6 | 7 | 12 | 13 | 14 | 36 | 37 | 38) { + checked_exact_range( + &mut validated, + args, + 2, + region.start, + kernel_scratch_wire::FCNTL_FLOCK_BYTES as usize, + region, + )?; + } + Ok(validated) + } + number if number == Syscall::Ioctl as u32 => validate_ioctl_layout(args, region), + number + if matches!( + number, + x if x == Syscall::Writev as u32 + || x == Syscall::Readv as u32 + || x == extended_syscalls::SYS_PREADV + || x == extended_syscalls::SYS_PWRITEV + || x == extended_syscalls::SYS_PREADV2 + || x == extended_syscalls::SYS_PWRITEV2 + ) => + { + unsafe { validate_iovec_layout(args, region) } + } + number if number == Syscall::Sendmsg as u32 || number == Syscall::Recvmsg as u32 => { + unsafe { validate_message_layout(args, region) } + } + number if number == Syscall::Getgroups as u32 => { + let mut validated = ValidatedChannelScratchArgs::new(); + let count = checked_size_scalar(args[0])?; + if count == 0 { + if checked_pointer(args[1])? != 0 || args[2] != 0 { + return Err(Errno::EFAULT); + } + validated.mark_null(1)?; + } else { + if args[2] != size_of::() as i64 { + return Err(Errno::EINVAL); + } + checked_exact_range( + &mut validated, + args, + 1, + region.start, + size_of::(), + region, + )?; + } + Ok(validated) + } + number if number == Syscall::Select as u32 => validate_select_layout(args, region, false), + extended_syscalls::SYS_PSELECT6 => validate_select_layout(args, region, true), + extended_syscalls::SYS_MSGRCV | extended_syscalls::SYS_MSGSND => { + if !matches!(args[5], 4 | 8) { + return Err(Errno::EINVAL); + } + let payload = checked_size_scalar(args[2])?; + let length = size_of::() + .checked_add(payload) + .ok_or(Errno::EINVAL)?; + let mut validated = ValidatedChannelScratchArgs::new(); + checked_exact_range( + &mut validated, + args, + 1, + region.start, + length, + region, + )?; + Ok(validated) + } + extended_syscalls::SYS_MSGCTL | extended_syscalls::SYS_SHMCTL => { + validate_ipc_control_layout(args, region, syscall_number) + } + extended_syscalls::SYS_EPOLL_CTL => { + let mut validated = ValidatedChannelScratchArgs::new(); + checked_nullable_exact_range( + &mut validated, + args, + 3, + region.start, + size_of::(), + region, + )?; + Ok(validated) + } + extended_syscalls::SYS_EPOLL_PWAIT | extended_syscalls::SYS_EPOLL_WAIT => { + let count = checked_size_scalar(args[2])?; + let length = count + .checked_mul(size_of::()) + .ok_or(Errno::EINVAL)?; + let mut validated = ValidatedChannelScratchArgs::new(); + checked_exact_range( + &mut validated, + args, + 1, + region.start, + length, + region, + )?; + if syscall_number == extended_syscalls::SYS_EPOLL_PWAIT { + let mask_pointer = align_up( + region.start.checked_add(length).ok_or(Errno::EFAULT)?, + SCRATCH_ALIGNMENT, + )?; + checked_nullable_exact_range( + &mut validated, + args, + 4, + mask_pointer, + kernel_scratch_wire::SIGNAL_MASK_BYTES as usize, + region, + )?; + } + Ok(validated) + } + _ => Ok(ValidatedChannelScratchArgs::new()), + } +} + +fn validate_prctl_layout( + args: &[i64; 6], + region: ChannelScratchRegion, +) -> Result { + let mut validated = ValidatedChannelScratchArgs::new(); + if args[0] == i64::from(prctl::PR_SET_NAME) || args[0] == i64::from(prctl::PR_GET_NAME) { + checked_exact_range( + &mut validated, + args, + 1, + region.start, + kernel_scratch_wire::PRCTL_NAME_BYTES as usize, + region, + )?; + } + Ok(validated) +} + +/// Validate every ordinary descriptor-backed channel suballocation and the +/// reviewed nested/manual wire formats before syscall dispatch. +/// +/// # Safety +/// +/// `region` must describe the complete live kernel-owned allocation and no +/// concurrent host operation may replace its bytes during this call or the +/// immediately following synchronous dispatch. +pub(crate) unsafe fn validate_channel_scratch_arguments( + syscall_number: u32, + args: &[i64; 6], + region: ChannelScratchRegion, +) -> Result { + // PR_SET_NAME and PR_GET_NAME use arg 1 as the generated fixed-size name + // pointer, while other prctl options use the same slot as a scalar. A + // generic pointer descriptor would either dereference a scalar or fail to + // prove the name allocation, so keep this option-dependent contract + // explicit. + if syscall_number == extended_syscalls::SYS_PRCTL { + return validate_prctl_layout(args, region); + } + if let Ok(index) = SYSCALL_ARG_DESCRIPTORS + .binary_search_by_key(&syscall_number, |descriptor| descriptor.syscall_number) + { + return unsafe { + validate_descriptor_layout(args, SYSCALL_ARG_DESCRIPTORS[index].args, region) + }; + } + validate_special_layout(syscall_number, args, region) +} + +/// Compute the length of a bounded, null-terminated C string in kernel memory. +/// +/// The host stages channel strings into a live kernel-owned allocation before +/// synchronous dispatch. This scanner proves the exact remaining allocation +/// capacity before each dereference; semantic limits such as `PATH_MAX` remain +/// the responsibility of the syscall consuming the string. +/// +/// # Safety +/// +/// `region` must describe the live channel allocation for this synchronous +/// dispatch. +pub(crate) unsafe fn checked_cstr_len( + ptr: *const u8, + region: ChannelScratchRegion, +) -> Result { + let remaining = region.remaining_from(ptr as usize)?; + for len in 0..remaining { + if unsafe { *ptr.add(len) } == 0 { + return u32::try_from(len).map_err(|_| Errno::EFAULT); + } + } + Err(Errno::EFAULT) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + use wasm_posix_shared::platform_limits; + + fn pointer_arg(pointer: usize) -> i64 { + i64::try_from(pointer).expect("native test pointer fits widened channel slot") + } + + #[test] + fn checked_range_accepts_exact_end_and_rejects_capacity_plus_one() { + let region = ChannelScratchRegion::new(0x1000, 16).unwrap(); + assert_eq!( + region.checked_range(0x1008, 8), + Ok(ChannelScratchRange { + start: 0x1008, + length: 8, + }), + ); + assert_eq!(region.checked_range(0x1008, 9), Err(Errno::EFAULT)); + assert_eq!(region.checked_range(usize::MAX, 1), Err(Errno::EFAULT)); + assert_eq!(region.checked_range(0x1010, 0).unwrap().length, 0); + } + + #[test] + fn dynamic_buffers_reject_positive_null_and_canonicalize_empty_null() { + let bytes = vec![0u8; 16]; + let start = bytes.as_ptr() as usize; + let region = ChannelScratchRegion::new(start, bytes.len()).unwrap(); + + for syscall in [Syscall::Read as u32, Syscall::Write as u32] { + let mut positive = [0i64; 6]; + positive[2] = 1; + assert_eq!( + unsafe { validate_channel_scratch_arguments(syscall, &positive, region) }, + Err(Errno::EFAULT), + ); + + let empty = [0i64; 6]; + let validated = + unsafe { validate_channel_scratch_arguments(syscall, &empty, region) }.unwrap(); + assert_eq!(validated.pointer(1), Ok(start)); + } + } + + #[test] + fn fixed_buffers_require_explicit_nullable_metadata() { + let bytes = vec![0u8; 512]; + let start = bytes.as_ptr() as usize; + let region = ChannelScratchRegion::new(start, bytes.len()).unwrap(); + let args = [0i64; 6]; + + for syscall in [Syscall::Pipe as u32, Syscall::Uname as u32] { + assert_eq!( + unsafe { validate_channel_scratch_arguments(syscall, &args, region) }, + Err(Errno::EFAULT), + ); + } + + let nullable = unsafe { + validate_channel_scratch_arguments(extended_syscalls::SYS_SENDFILE, &args, region) + } + .unwrap(); + assert_eq!(nullable.pointer(2), Ok(0)); + } + + #[test] + fn prctl_proves_only_name_buffers_as_scratch() { + let bytes = vec![0u8; kernel_scratch_wire::PRCTL_NAME_BYTES as usize]; + let start = bytes.as_ptr() as usize; + let region = ChannelScratchRegion::new(start, bytes.len()).unwrap(); + let mut args = [0i64; 6]; + args[0] = i64::from(prctl::PR_SET_NAME); + args[1] = pointer_arg(start); + + let validated = unsafe { + validate_channel_scratch_arguments(extended_syscalls::SYS_PRCTL, &args, region) + } + .unwrap(); + assert_eq!(validated.pointer(1), Ok(start)); + + args[1] = 0; + assert_eq!( + unsafe { + validate_channel_scratch_arguments( + extended_syscalls::SYS_PRCTL, + &args, + region, + ) + }, + Err(Errno::EFAULT), + ); + + args[0] = 999; + args[1] = i64::MAX; + let scalar = unsafe { + validate_channel_scratch_arguments(extended_syscalls::SYS_PRCTL, &args, region) + } + .unwrap(); + assert_eq!(scalar.pointer(1), Err(Errno::EFAULT)); + + let short_region = ChannelScratchRegion::new(start, bytes.len() - 1).unwrap(); + args[0] = i64::from(prctl::PR_GET_NAME); + args[1] = pointer_arg(start); + assert_eq!( + unsafe { + validate_channel_scratch_arguments( + extended_syscalls::SYS_PRCTL, + &args, + short_region, + ) + }, + Err(Errno::EFAULT), + ); + } + + #[test] + fn raw_channel_pointer_allowlist_contains_only_process_addresses() { + let dispatcher = include_str!("wasm_api.rs"); + + assert!( + !dispatcher.contains("channel_pointer!("), + "ambiguous raw channel pointer bypasses the scratch proof" + ); + + // WHY: a count alone lets a newly added raw pointer hide behind removal + // of an existing use. Pin each reviewed process-address context, then + // also pin the total so every addition, removal, or relocation requires + // an explicit ownership review. + let reviewed_process_address_contexts = [ + ( + "73 => kernel_signal(a1 as u32, process_address!(1)), // SYS_SIGNAL", + 1, + ), + ( + r#"let result = match syscalls::sys_mmap( + proc, + &mut host, + process_address!(0),"#, + 1, + ), + ( + "47 => kernel_munmap(process_address!(0), channel_i32_scalar_usize(a2)), // SYS_MUNMAP", + 1, + ), + ( + "48 => kernel_brk(process_address!(0)) as i32, // SYS_BRK", + 1, + ), + ( + "49 => kernel_mprotect(process_address!(0), channel_i32_scalar_usize(a2), a3 as u32), // SYS_MPROTECT", + 1, + ), + ( + r#"126 => kernel_mremap( + process_address!(0),"#, + 1, + ), + ( + "128 => kernel_madvise(process_address!(0), channel_i32_scalar_usize(a2), a3 as u32), // SYS_MADVISE", + 1, + ), + ( + r#"201 => kernel_clone( + 0, + process_address!(1), + a1 as u32, + 0, + process_address!(2), + process_address!(3), + process_address!(4),"#, + 4, + ), + ( + r#"200 => kernel_futex( + process_address!(0), + a2 as u32, + a3 as u32, + a4 as u32, + process_address!(4),"#, + 2, + ), + ( + "203 => kernel_set_tid_address(process_address!(0)), // SYS_SET_TID_ADDRESS", + 1, + ), + ( + "261 => kernel_set_robust_list(process_address!(0), channel_u32_scalar_usize(a2)), // SYS_SET_ROBUST_LIST", + 1, + ), + ( + "262 => kernel_get_robust_list(a1 as u32, process_address!(1), process_address!(2)), // SYS_GET_ROBUST_LIST", + 2, + ), + ( + r#"let _shmaddr = process_address!(1); + kernel_ipc_shmat(a1, a2, a3)"#, + 1, + ), + ( + r#"let _shmaddr = process_address!(0); + kernel_ipc_shmdt(a1)"#, + 1, + ), + ( + r#"279 | 280 => { + // mlock, mlock2: (addr, len, ...) + let addr = process_address!(0);"#, + 1, + ), + ( + r#"281 => { + // munlock: (addr, len) + let addr = process_address!(0);"#, + 1, + ), + ]; + + let mut reviewed_uses = 0; + for (context, expected_uses) in reviewed_process_address_contexts { + assert_eq!( + dispatcher.matches(context).count(), + 1, + "reviewed raw process-address context changed:\n{context}" + ); + reviewed_uses += expected_uses; + } + assert_eq!( + dispatcher.matches("process_address!(").count(), + reviewed_uses, + "review every new raw process-address use; scratch must use a validated pointer macro" + ); + } + + #[test] + fn descriptor_range_accepts_capacity_and_rejects_capacity_plus_one() { + let bytes = vec![0u8; 16]; + let start = bytes.as_ptr() as usize; + let region = ChannelScratchRegion::new(start, bytes.len()).unwrap(); + let mut args = [0i64; 6]; + args[1] = pointer_arg(start); + args[2] = bytes.len() as i64; + + let validated = unsafe { + validate_channel_scratch_arguments(Syscall::Read as u32, &args, region) + } + .unwrap(); + assert_eq!(validated.pointer(1), Ok(start)); + + args[2] += 1; + assert_eq!( + unsafe { + validate_channel_scratch_arguments(Syscall::Read as u32, &args, region) + }, + Err(Errno::EFAULT), + ); + } + + #[test] + fn descriptor_rejects_negative_and_overflowing_dynamic_lengths() { + let bytes = vec![0u8; 16]; + let start = bytes.as_ptr() as usize; + let region = ChannelScratchRegion::new(start, bytes.len()).unwrap(); + let mut args = [0i64; 6]; + args[1] = pointer_arg(start); + + args[2] = -1; + assert_eq!( + unsafe { + validate_channel_scratch_arguments(Syscall::Write as u32, &args, region) + }, + Err(Errno::EINVAL), + ); + args[2] = MAX_SAFE_INTEGER + 1; + assert_eq!( + unsafe { + validate_channel_scratch_arguments(Syscall::Write as u32, &args, region) + }, + Err(Errno::EINVAL), + ); + } + + #[test] + fn dereferenced_length_must_preserve_the_canonical_following_slot() { + let mut bytes = vec![0u8; 64]; + let start = bytes.as_mut_ptr() as usize; + assert_eq!(start % SCRATCH_ALIGNMENT, 0); + let region = ChannelScratchRegion::new(start, bytes.len()).unwrap(); + let mut args = [0i64; 6]; + args[1] = pointer_arg(start); + args[2] = 16; + args[4] = pointer_arg(start + 16); + args[5] = pointer_arg(start + 24); + bytes[24..28].copy_from_slice(&4u32.to_le_bytes()); + + assert!( + unsafe { + validate_channel_scratch_arguments(Syscall::Recvfrom as u32, &args, region) + } + .is_ok() + ); + + // This models a second/torn socklen observation after the host sized + // the address subregion. Growing across the alignment boundary moves + // the canonical length slot and must be rejected before recvfrom can + // form its output slice. + bytes[24..28].copy_from_slice(&12u32.to_le_bytes()); + assert_eq!( + unsafe { + validate_channel_scratch_arguments(Syscall::Recvfrom as u32, &args, region) + }, + Err(Errno::EFAULT), + ); + } + + #[test] + fn dereferenced_region_rejects_data_without_a_length_pointer() { + let bytes = vec![0u8; 64]; + let start = bytes.as_ptr() as usize; + let region = ChannelScratchRegion::new(start, bytes.len()).unwrap(); + let mut args = [0i64; 6]; + args[1] = pointer_arg(start); + args[2] = 8; + args[4] = pointer_arg(start + 8); + args[5] = 0; + + assert_eq!( + unsafe { + validate_channel_scratch_arguments(Syscall::Recvfrom as u32, &args, region) + }, + Err(Errno::EFAULT), + ); + } + + #[test] + fn select_requires_each_nonnull_fdset_at_its_fixed_disjoint_slot() { + let bytes = vec![0u8; 3 * wasm_posix_shared::select::FD_SET_BYTES]; + let start = bytes.as_ptr() as usize; + let region = ChannelScratchRegion::new(start, bytes.len()).unwrap(); + let mut args = [0i64; 6]; + args[1] = pointer_arg(start); + args[2] = pointer_arg(start + wasm_posix_shared::select::FD_SET_BYTES); + args[3] = pointer_arg(start + 2 * wasm_posix_shared::select::FD_SET_BYTES); + + assert!( + unsafe { + validate_channel_scratch_arguments(Syscall::Select as u32, &args, region) + } + .is_ok() + ); + args[2] = args[1]; + assert_eq!( + unsafe { + validate_channel_scratch_arguments(Syscall::Select as u32, &args, region) + }, + Err(Errno::EFAULT), + ); + } + + #[test] + fn cstr_accepts_a_nul_at_the_last_region_byte() { + let bytes = b"abc\0"; + let region = ChannelScratchRegion::new(bytes.as_ptr() as usize, bytes.len()).unwrap(); + assert_eq!(unsafe { checked_cstr_len(bytes.as_ptr(), region) }, Ok(3)); + } + + #[test] + fn cstr_does_not_read_a_sentinel_outside_the_region() { + let bytes = b"ab\0"; + let shorter_region = + ChannelScratchRegion::new(bytes.as_ptr() as usize, bytes.len() - 1).unwrap(); + assert_eq!( + unsafe { checked_cstr_len(bytes.as_ptr(), shorter_region) }, + Err(Errno::EFAULT), + ); + } + + #[test] + fn cstr_rejects_pointers_outside_or_overflowing_the_region() { + let bytes = b"abc\0"; + let start = bytes.as_ptr() as usize; + let region = ChannelScratchRegion::new(start, bytes.len()).unwrap(); + + assert_eq!( + unsafe { checked_cstr_len(core::ptr::null(), region) }, + Err(Errno::EFAULT), + ); + assert_eq!( + unsafe { checked_cstr_len((start - 1) as *const u8, region) }, + Err(Errno::EFAULT), + ); + assert_eq!( + unsafe { checked_cstr_len((start + bytes.len()) as *const u8, region) }, + Err(Errno::EFAULT), + ); + assert_eq!( + unsafe { checked_cstr_len(usize::MAX as *const u8, region) }, + Err(Errno::EFAULT), + ); + assert_eq!(ChannelScratchRegion::new(usize::MAX, 1), Err(Errno::EFAULT)); + assert_eq!( + ChannelScratchRegion::for_channel(usize::MAX), + Err(Errno::EFAULT), + ); + } + + #[test] + fn cstr_accepts_non_path_strings_larger_than_path_max() { + let mut bytes = vec![b'a'; platform_limits::PATH_MAX_BYTES + 2]; + *bytes.last_mut().unwrap() = 0; + let region = ChannelScratchRegion::new(bytes.as_ptr() as usize, bytes.len()).unwrap(); + + assert_eq!( + unsafe { checked_cstr_len(bytes.as_ptr(), region) }, + Ok((platform_limits::PATH_MAX_BYTES + 1) as u32), + ); + } +} diff --git a/crates/kernel/src/descriptor_backing.rs b/crates/kernel/src/descriptor_backing.rs index cb9ab02786..94a9f23cb2 100644 --- a/crates/kernel/src/descriptor_backing.rs +++ b/crates/kernel/src/descriptor_backing.rs @@ -217,8 +217,7 @@ static TIMERFDS: GlobalBackingTable = GlobalBackingTable::new(); static SIGNALFDS: GlobalBackingTable = GlobalBackingTable::new(); static MEMFDS: GlobalBackingTable = GlobalBackingTable::new(); static PROCFS_BUFS: GlobalBackingTable = GlobalBackingTable::new(); -static SYNTHETIC_REGULARS: GlobalBackingTable = - GlobalBackingTable::new(); +static SYNTHETIC_REGULARS: GlobalBackingTable = GlobalBackingTable::new(); // Keep synthetic backing handles disjoint from the small negative sentinels // used by pipes, devices, and procfs. @@ -302,6 +301,36 @@ pub fn manages_ofd(file_type: FileType, host_handle: i64) -> bool { || is_synthetic_regular_handle(host_handle))) } +/// Whether an encoded handle currently names a live backing owned here. +/// +/// Shape recognition alone is insufficient at trust boundaries: a stale +/// negative index has the right bit pattern but cannot reconstruct an open +/// file description. +pub(crate) fn is_live_managed_ofd(file_type: FileType, host_handle: i64) -> bool { + match file_type { + FileType::EventFd => negative_handle_idx(host_handle) + .is_ok_and(|idx| with_eventfds(|table| table.get(idx).is_some())), + FileType::TimerFd => negative_handle_idx(host_handle) + .is_ok_and(|idx| with_timerfds(|table| table.get(idx).is_some())), + FileType::SignalFd => negative_handle_idx(host_handle) + .is_ok_and(|idx| with_signalfds(|table| table.get(idx).is_some())), + FileType::MemFd => negative_handle_idx(host_handle) + .is_ok_and(|idx| with_memfds(|table| table.get(idx).is_some())), + FileType::Regular if crate::procfs::is_procfs_buf_handle(host_handle) => { + with_procfs_bufs(|table| { + table + .get(crate::procfs::procfs_buf_idx(host_handle)) + .is_some() + }) + } + FileType::Regular if is_synthetic_regular_handle(host_handle) => { + synthetic_regular_idx(host_handle) + .is_ok_and(|idx| with_synthetic_regulars(|table| table.get(idx).is_some())) + } + _ => false, + } +} + /// Plan ownership transfer for the legacy serialize/init exec ABI. Surviving /// OFDs take over the old process's existing ownership reference; old OFDs /// omitted by CLOEXEC filtering must be released exactly once. A replacement @@ -470,9 +499,7 @@ pub fn release_for_ofd(file_type: FileType, host_handle: i64) -> bool { FileType::MemFd => negative_handle_idx(host_handle) .is_ok_and(|idx| with_memfds(|table| table.release(idx))), FileType::Regular if crate::procfs::is_procfs_buf_handle(host_handle) => { - with_procfs_bufs(|table| { - table.release(crate::procfs::procfs_buf_idx(host_handle)) - }) + with_procfs_bufs(|table| table.release(crate::procfs::procfs_buf_idx(host_handle))) } FileType::Regular if is_synthetic_regular_handle(host_handle) => { synthetic_regular_idx(host_handle) diff --git a/crates/kernel/src/fork.rs b/crates/kernel/src/fork.rs index 70ffc501ed..369f0ca67b 100644 --- a/crates/kernel/src/fork.rs +++ b/crates/kernel/src/fork.rs @@ -37,10 +37,11 @@ use crate::terminal::{NCCS, TerminalState, WinSize}; const FORK_MAGIC: u32 = 0x464F524B; // "FORK" #[cfg(test)] const EXEC_MAGIC: u32 = 0x45584543; // "EXEC" -// v13 preserves the terminal's authoritative foreground process group across -// fork and the test-only serialized exec format instead of reconstructing it -// as synthetic PID 1. -const FORK_VERSION: u32 = 13; +// This header version is also shared by the cfg(test) legacy exec-state +// fixture. v14 widens that fixture's directed-signal metadata to complete raw +// `union sigval` bits plus sender credentials. Production fork serialization +// still clears and omits every pending directed signal. +const FORK_VERSION: u32 = 14; // Bounds for deserialization to prevent OOM from malformed buffers. const MAX_FDS: u32 = 65536; @@ -257,8 +258,10 @@ fn write_directed_signal_state( w.write_u32(count)?; for entry in &state.rt_queue { w.write_u32(entry.signum)?; - w.write_i32(entry.si_value)?; + w.write_u64(entry.si_value_bits)?; w.write_i32(entry.si_code)?; + w.write_u32(entry.sender_pid)?; + w.write_u32(entry.sender_uid)?; w.write_i32(entry.timer_id.map(|id| id as i32).unwrap_or(-1))?; } Ok(()) @@ -280,8 +283,10 @@ fn read_directed_signal_state(r: &mut Reader<'_>) -> Result None, id if id >= 0 => Some(id as u32), @@ -289,8 +294,10 @@ fn read_directed_signal_state(r: &mut Reader<'_>) -> Result, - dri: &crate::ofd::DriFdState, -) -> Result<(), Errno> { +fn write_dri_fd_state(w: &mut Writer<'_>, dri: &crate::ofd::DriFdState) -> Result<(), Errno> { w.write_u32(dri.handles.len() as u32)?; for (handle, bo_id) in &dri.handles { w.write_u32(*handle)?; @@ -1522,7 +1526,8 @@ fn deserialize_fork_state_into(buf: &[u8], child: &mut Process) -> Result<(), Er child.rlimits = rlimits; child.alarm_deadline_ns = 0; child.alarm_interval_ns = 0; - child.thread_name = [0u8; 16]; + child.thread_name = + [0u8; wasm_posix_shared::kernel_scratch_wire::PRCTL_NAME_BYTES as usize]; child.fork_child = true; child.sigsuspend_saved_mask = None; child.fork_exec_path = fork_exec_path; @@ -1982,7 +1987,8 @@ pub fn deserialize_exec_state(buf: &[u8], pid: u32) -> Result { process.rlimits = rlimits; process.alarm_deadline_ns = 0; process.alarm_interval_ns = 0; - process.thread_name = [0u8; 16]; + process.thread_name = + [0u8; wasm_posix_shared::kernel_scratch_wire::PRCTL_NAME_BYTES as usize]; process.fork_child = false; process.sigsuspend_saved_mask = None; process.fork_exec_path = None; @@ -2071,10 +2077,7 @@ mod tests { 700, b"/dir".to_vec(), ); - parent - .fd_table - .alloc(OpenFileDescRef(ofd_idx), 0) - .unwrap(); + parent.fd_table.alloc(OpenFileDescRef(ofd_idx), 0).unwrap(); { let ofd = parent.ofd_table.get_mut(ofd_idx).unwrap(); ofd.offset = 4; @@ -2101,7 +2104,10 @@ mod tests { let parent_ofd = parent.ofd_table.get(ofd_idx).unwrap(); assert_eq!(parent_ofd.dir_host_handle, 701); - assert_eq!(parent_ofd.dir_pending_entry.as_ref().unwrap().name, b"pending"); + assert_eq!( + parent_ofd.dir_pending_entry.as_ref().unwrap().name, + b"pending" + ); } #[test] @@ -2294,7 +2300,8 @@ mod tests { let mut proc = Process::new(1); proc.main_thread_signals.raise(25); proc.main_thread_signals.raise_with_value(32, 101); - proc.main_thread_signals.raise_with_value(32, 202); + proc.main_thread_signals + .raise_with_metadata(32, 0x0123_4567_89ab_cdef, -1, 77, 88); proc.main_thread_signals.raise_timer(10, 303, 7); let serialized = serialize_exec_state_with_growing_buffer(&proc).unwrap(); @@ -2308,10 +2315,10 @@ mod tests { restored.main_thread_signals.consume_one(32), Some((101, -1)) ); - assert_eq!( - restored.main_thread_signals.consume_one(32), - Some((202, -1)) - ); + let full_width = restored.main_thread_signals.consume_one_info(32); + assert_eq!(full_width.si_value_bits, 0x0123_4567_89ab_cdef); + assert_eq!(full_width.si_code, -1); + assert_eq!((full_width.sender_pid, full_width.sender_uid), (77, 88)); assert_eq!(restored.main_thread_signals.pending, 0); } @@ -2319,9 +2326,7 @@ mod tests { fn test_exec_rejects_oversized_main_directed_queue() { let mut proc = Process::new(1); for value in 0..=MAX_DIRECTED_SIGNAL_QUEUE { - assert!(proc - .main_thread_signals - .raise_with_value(32, value as i32)); + assert!(proc.main_thread_signals.raise_with_value(32, value as u64)); } assert_eq!( @@ -2743,7 +2748,10 @@ mod tests { // For fork-from-pthread, the host then retains only the caller slot // with `kernel_reserve_host_region_at`. assert!(child.memory.reserved_regions().is_empty()); - assert_eq!(child.memory.reserve_host_region_at(caller, slot_len), caller); + assert_eq!( + child.memory.reserve_host_region_at(caller, slot_len), + caller + ); assert_eq!(child.memory.reserved_regions().len(), 1); assert!(child.memory.overlaps_host_reserved_region(caller, slot_len)); @@ -2909,9 +2917,12 @@ mod tests { handles: &[(u32, crate::dri::BoId)], ) -> usize { use crate::ofd::{DriFdState, DriOfdState}; - let ofd_idx = - proc.ofd_table - .create(crate::ofd::FileType::CharDevice, 0, host_handle, path.to_vec()); + let ofd_idx = proc.ofd_table.create( + crate::ofd::FileType::CharDevice, + 0, + host_handle, + path.to_vec(), + ); let mut dri = DriFdState::default(); for &(h, bo) in handles { dri.handles.insert(h, bo); @@ -3092,12 +3103,13 @@ mod tests { crate::ofd::FileType::Regular, 0, -200, - alloc::format!("/dev/dri/prime-{bo}-{cookie:x}") - .into_bytes(), - ); - proc.ofd_table.get_mut(ofd_idx).unwrap().dri_state = Some( - alloc::boxed::Box::new(DriOfdState::PrimeBo(PrimeBoState { bo_id: bo, cookie })), + alloc::format!("/dev/dri/prime-{bo}-{cookie:x}").into_bytes(), ); + proc.ofd_table.get_mut(ofd_idx).unwrap().dri_state = + Some(alloc::boxed::Box::new(DriOfdState::PrimeBo(PrimeBoState { + bo_id: bo, + cookie, + }))); proc.fd_table .alloc(crate::fd::OpenFileDescRef(ofd_idx), 0) .unwrap(); diff --git a/crates/kernel/src/ipc.rs b/crates/kernel/src/ipc.rs index 2b03200c9e..d26c9c5d8b 100644 --- a/crates/kernel/src/ipc.rs +++ b/crates/kernel/src/ipc.rs @@ -85,7 +85,7 @@ fn ipc_check_owner(uid: u32, owner_uid: u32, creator_uid: u32) -> Result<(), Err /// A single message in a SysV message queue. struct MsgEntry { - mtype: i32, + mtype: i64, data: Vec, } @@ -132,7 +132,7 @@ pub struct MsgQueueInfo { /// Result of msgrcv. #[derive(Debug)] pub struct MsgRcvResult { - pub mtype: i32, + pub mtype: i64, pub data: Vec, } @@ -329,7 +329,7 @@ impl IpcTable { pub fn msgsnd( &mut self, qid: i32, - mtype: i32, + mtype: i64, data: &[u8], flags: u32, pid: u32, @@ -371,7 +371,35 @@ impl IpcTable { &mut self, qid: i32, max_size: u32, - msgtype: i32, + msgtype: i64, + flags: u32, + pid: u32, + uid: u32, + gid: u32, + ) -> Result { + self.msgrcv_with_mtype_max( + qid, + max_size, + msgtype, + i64::MAX, + flags, + pid, + uid, + gid, + ) + } + + /// Receive while proving the selected mtype fits the caller's native long. + /// + /// A Kandelo queue can be shared by wasm32 and wasm64 processes. Reject + /// before removal when an LP64 sender's type cannot be represented by an + /// ILP32 receiver, so the host never has to truncate a consumed message. + pub fn msgrcv_with_mtype_max( + &mut self, + qid: i32, + max_size: u32, + msgtype: i64, + max_output_mtype: i64, flags: u32, pid: u32, uid: u32, @@ -397,7 +425,7 @@ impl IpcTable { } } else { // msgtype < 0: first message with type <= |msgtype| - let abs_type = -msgtype; + let abs_type = msgtype.saturating_abs(); q.messages.iter().position(|m| m.mtype <= abs_type) }; @@ -413,6 +441,10 @@ impl IpcTable { let msg = &q.messages[idx]; + if msg.mtype > max_output_mtype { + return Err(Errno::EOVERFLOW); + } + // Check size if msg.data.len() > max_size as usize { if !noerror { @@ -476,15 +508,37 @@ impl IpcTable { Ok(None) } IPC_SET => { - let q = self.msg_queues.get_mut(&qid).ok_or(Errno::EINVAL)?; - ipc_check_owner(uid, q.uid, q.cuid)?; - q.ctime = crate::current_time_secs(); - Ok(None) + // IPC_SET carries a target-width msqid_ds and is applied by + // msgctl_set after the wire layer has parsed permitted fields. + Err(Errno::EINVAL) } _ => Err(Errno::EINVAL), } } + /// Apply the fields Linux permits msgctl IPC_SET to replace. + pub fn msgctl_set( + &mut self, + qid: i32, + new_uid: u32, + new_gid: u32, + new_mode: u32, + new_qbytes: u32, + uid: u32, + ) -> Result<(), Errno> { + let q = self.msg_queues.get_mut(&qid).ok_or(Errno::EINVAL)?; + ipc_check_owner(uid, q.uid, q.cuid)?; + if new_qbytes > MSGMNB && uid != 0 { + return Err(Errno::EPERM); + } + q.uid = new_uid; + q.gid = new_gid; + q.mode = new_mode & IPC_PERM_MASK; + q.qbytes = new_qbytes; + q.ctime = crate::current_time_secs(); + Ok(()) + } + // ═══════════════════════════════════════════════════════════════ // Semaphores // ═══════════════════════════════════════════════════════════════ @@ -708,6 +762,26 @@ impl IpcTable { } } + /// Return the exact byte length of a GETALL/SETALL value array after + /// applying the same permission check as the requested command. + pub fn semctl_array_bytes( + &self, + semid: i32, + cmd: i32, + uid: u32, + gid: u32, + ) -> Result { + let set = self.sem_sets.get(&semid).ok_or(Errno::EINVAL)?; + let permission = match cmd { + GETALL => IPC_R, + SETALL => IPC_W, + _ => return Err(Errno::EINVAL), + }; + ipc_check_perm(uid, gid, set.uid, set.gid, set.mode, permission)?; + set.values.len().checked_mul(core::mem::size_of::()) + .ok_or(Errno::EOVERFLOW) + } + /// Set all semaphore values in a set (SETALL command). pub fn semctl_set_all( &mut self, @@ -728,6 +802,32 @@ impl IpcTable { Ok(()) } + /// Decode and apply the little-endian `unsigned short[]` used by SETALL. + /// + /// WHY: SETALL requires write permission only. Discovering the array + /// length through IPC_STAT would incorrectly add a read-permission + /// requirement before the actual write. + pub fn semctl_set_all_bytes( + &mut self, + semid: i32, + bytes: &[u8], + uid: u32, + gid: u32, + ) -> Result<(), Errno> { + let expected = self.semctl_array_bytes(semid, SETALL, uid, gid)?; + if bytes.len() != expected { + return Err(Errno::EINVAL); + } + let mut values = Vec::new(); + values + .try_reserve_exact(expected / core::mem::size_of::()) + .map_err(|_| Errno::ENOMEM)?; + for value in bytes.chunks_exact(core::mem::size_of::()) { + values.push(u16::from_le_bytes([value[0], value[1]])); + } + self.semctl_set_all(semid, &values, uid, gid) + } + // ═══════════════════════════════════════════════════════════════ // Shared Memory // ═══════════════════════════════════════════════════════════════ @@ -889,14 +989,31 @@ impl IpcTable { Ok(None) } IPC_SET => { - let seg = self.shm_segments.get_mut(&shmid).ok_or(Errno::EINVAL)?; - ipc_check_owner(uid, seg.uid, seg.cuid)?; - seg.ctime = crate::current_time_secs(); - Ok(None) + // IPC_SET carries a target-width shmid_ds and is applied by + // shmctl_set after the wire layer has parsed permitted fields. + Err(Errno::EINVAL) } _ => Err(Errno::EINVAL), } } + + /// Apply the fields Linux permits shmctl IPC_SET to replace. + pub fn shmctl_set( + &mut self, + shmid: i32, + new_uid: u32, + new_gid: u32, + new_mode: u32, + uid: u32, + ) -> Result<(), Errno> { + let seg = self.shm_segments.get_mut(&shmid).ok_or(Errno::EINVAL)?; + ipc_check_owner(uid, seg.uid, seg.cuid)?; + seg.uid = new_uid; + seg.gid = new_gid; + seg.mode = new_mode & IPC_PERM_MASK; + seg.ctime = crate::current_time_secs(); + Ok(()) + } } // ── Global singleton ── @@ -999,6 +1116,46 @@ mod tests { assert_eq!(msg.data, b"two"); } + #[test] + fn test_wasm64_message_type_round_trips_without_i32_truncation() { + let mut t = IpcTable::new(); + let qid = t.msgget(IPC_PRIVATE, IPC_CREAT | 0o666, 1, 0, 0).unwrap(); + let mtype = i32::MAX as i64 + 0x1020_3040; + + t.msgsnd(qid, mtype, b"wide", 0, 1, 0, 0).unwrap(); + let msg = t.msgrcv(qid, 100, mtype, 0, 1, 0, 0).unwrap(); + + assert_eq!(msg.mtype, mtype); + assert_eq!(msg.data, b"wide"); + } + + #[test] + fn test_wasm32_receive_rejects_wide_type_before_queue_removal() { + let mut t = IpcTable::new(); + let qid = t.msgget(IPC_PRIVATE, IPC_CREAT | 0o666, 1, 0, 0).unwrap(); + let mtype = i32::MAX as i64 + 1; + + t.msgsnd(qid, mtype, b"wide", 0, 1, 0, 0).unwrap(); + assert_eq!( + t.msgrcv_with_mtype_max( + qid, + 100, + 0, + i32::MAX as i64, + 0, + 1, + 0, + 0, + ) + .unwrap_err(), + Errno::EOVERFLOW, + ); + + let msg = t.msgrcv(qid, 100, 0, 0, 1, 0, 0).unwrap(); + assert_eq!(msg.mtype, mtype); + assert_eq!(msg.data, b"wide"); + } + #[test] fn test_msgrcv_negative_type() { let mut t = IpcTable::new(); @@ -1081,6 +1238,41 @@ mod tests { assert_eq!(info.lspid, 42); } + #[test] + fn test_msgctl_set_applies_permitted_fields_and_permissions() { + let mut t = IpcTable::new(); + let qid = t + .msgget(IPC_PRIVATE, IPC_CREAT | 0o666, 1, 1000, 1000) + .unwrap(); + + t.msgctl_set(qid, 2000, 2001, 0o1764, 8192, 1000) + .unwrap(); + let info = t.msgctl(qid, IPC_STAT, 1, 0, 0).unwrap().unwrap(); + assert_eq!(info.uid, 2000); + assert_eq!(info.gid, 2001); + assert_eq!(info.cuid, 1000); + assert_eq!(info.cgid, 1000); + assert_eq!(info.mode, 0o764); + assert_eq!(info.qbytes, 8192); + + assert_eq!( + t.msgctl_set(qid, 3000, 3001, 0o600, 4096, 3000), + Err(Errno::EPERM) + ); + assert_eq!( + t.msgctl_set(qid, 2000, 2001, 0o600, MSGMNB + 1, 2000), + Err(Errno::EPERM) + ); + + t.msgctl_set(qid, 0, 0, 0o600, MSGMNB + 1, 0) + .unwrap(); + let info = t.msgctl(qid, IPC_STAT, 1, 0, 0).unwrap().unwrap(); + assert_eq!(info.uid, 0); + assert_eq!(info.gid, 0); + assert_eq!(info.mode, 0o600); + assert_eq!(info.qbytes, MSGMNB + 1); + } + #[test] fn test_msgctl_rmid() { let mut t = IpcTable::new(); @@ -1339,6 +1531,76 @@ mod tests { assert_eq!(vals, vec![10, 20, 30]); } + #[test] + fn test_semctl_array_bytes_matches_size_and_command_permissions() { + let mut t = IpcTable::new(); + let read_only = t + .semget(IPC_PRIVATE, 3, IPC_CREAT | 0o400, 1, 1000, 1000) + .unwrap(); + assert_eq!( + t.semctl_array_bytes(read_only, GETALL, 1000, 1000), + Ok(3 * core::mem::size_of::()) + ); + assert_eq!( + t.semctl_array_bytes(read_only, SETALL, 1000, 1000), + Err(Errno::EACCES) + ); + + let write_only = t + .semget(IPC_PRIVATE, 2, IPC_CREAT | 0o200, 1, 1000, 1000) + .unwrap(); + assert_eq!( + t.semctl_array_bytes(write_only, SETALL, 1000, 1000), + Ok(2 * core::mem::size_of::()) + ); + assert_eq!( + t.semctl_array_bytes(write_only, GETALL, 1000, 1000), + Err(Errno::EACCES) + ); + assert_eq!( + t.semctl_array_bytes(write_only, IPC_STAT, 1000, 1000), + Err(Errno::EINVAL) + ); + } + + #[test] + fn test_semctl_set_all_bytes_accepts_write_only_sets_without_ipc_stat() { + let mut t = IpcTable::new(); + let write_only = t + .semget(IPC_PRIVATE, 2, IPC_CREAT | 0o200, 1, 1000, 1000) + .unwrap(); + + t.semctl_set_all_bytes(write_only, &[10, 0, 20, 0], 1000, 1000) + .unwrap(); + assert!(matches!( + t.semctl(write_only, 0, GETALL, 1, 0, 1000, 1000), + Err(Errno::EACCES) + )); + // Root reads the values only to verify the write; the operation above + // succeeded using the owning process's write-only permission. + let values = match t + .semctl(write_only, 0, GETALL, 1, 0, 0, 0) + .unwrap() + { + SemCtlResult::All(values) => values, + _ => panic!("expected all semaphore values"), + }; + assert_eq!(values, vec![10, 20]); + + assert_eq!( + t.semctl_set_all_bytes(write_only, &[30, 0], 1000, 1000), + Err(Errno::EINVAL) + ); + + let read_only = t + .semget(IPC_PRIVATE, 1, IPC_CREAT | 0o400, 1, 1000, 1000) + .unwrap(); + assert_eq!( + t.semctl_set_all_bytes(read_only, &[40, 0], 1000, 1000), + Err(Errno::EACCES) + ); + } + #[test] fn test_semctl_stat() { let mut t = IpcTable::new(); @@ -1500,6 +1762,32 @@ mod tests { assert_eq!(info.mode, 0o666); } + #[test] + fn test_shmctl_set_applies_permitted_fields_and_permissions() { + let mut t = IpcTable::new(); + let id = t + .shmget(IPC_PRIVATE, 4096, IPC_CREAT | 0o666, 1, 1000, 1000) + .unwrap(); + + t.shmctl_set(id, 2000, 2001, 0o1640, 1000).unwrap(); + let info = t.shmctl(id, IPC_STAT, 1, 0, 0).unwrap().unwrap(); + assert_eq!(info.uid, 2000); + assert_eq!(info.gid, 2001); + assert_eq!(info.cuid, 1000); + assert_eq!(info.cgid, 1000); + assert_eq!(info.mode, 0o640); + assert_eq!(info.segsz, 4096); + + assert_eq!( + t.shmctl_set(id, 3000, 3001, 0o600, 3000), + Err(Errno::EPERM) + ); + let info = t.shmctl(id, IPC_STAT, 1, 0, 0).unwrap().unwrap(); + assert_eq!(info.uid, 2000); + assert_eq!(info.gid, 2001); + assert_eq!(info.mode, 0o640); + } + #[test] fn test_shmctl_rmid() { let mut t = IpcTable::new(); diff --git a/crates/kernel/src/ipc_wire.rs b/crates/kernel/src/ipc_wire.rs new file mode 100644 index 0000000000..49a19312da --- /dev/null +++ b/crates/kernel/src/ipc_wire.rs @@ -0,0 +1,722 @@ +//! Target-process wire layouts for System V IPC structures. +//! +//! A kernel Wasm instance can serve both wasm32 and wasm64 processes, so these +//! layouts must be selected from the calling process data model rather than +//! from the kernel crate's own pointer width. + +use crate::ipc::{MsgQueueInfo, SemSetInfo, ShmSegInfo}; +use core::mem::size_of; +use wasm_posix_shared::WasmSysvMessageHeader; +use wasm_posix_shared::Errno; + +#[derive(Clone, Copy)] +struct SemidDsLayout { + size: usize, + otime_offset: usize, + ctime_offset: usize, + nsems_offset: usize, +} + +#[derive(Clone, Copy)] +struct MsqidDsLayout { + size: usize, + stime_offset: usize, + rtime_offset: usize, + ctime_offset: usize, + cbytes_offset: usize, + qnum_offset: usize, + qbytes_offset: usize, + lspid_offset: usize, + lrpid_offset: usize, + ulong_bytes: usize, +} + +#[derive(Clone, Copy)] +struct ShmidDsLayout { + size: usize, + segsz_offset: usize, + atime_offset: usize, + dtime_offset: usize, + ctime_offset: usize, + cpid_offset: usize, + lpid_offset: usize, + nattch_offset: usize, + ulong_bytes: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct MsgctlSetFields { + pub uid: u32, + pub gid: u32, + pub mode: u32, + pub qbytes: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ShmctlSetFields { + pub uid: u32, + pub gid: u32, + pub mode: u32, +} + +const SEMID_DS_WASM32_LAYOUT: SemidDsLayout = SemidDsLayout { + size: 72, + otime_offset: 40, + ctime_offset: 48, + nsems_offset: 56, +}; + +const SEMID_DS_WASM64_LAYOUT: SemidDsLayout = SemidDsLayout { + size: 88, + otime_offset: 48, + ctime_offset: 56, + nsems_offset: 64, +}; + +const MSQID_DS_WASM32_LAYOUT: MsqidDsLayout = MsqidDsLayout { + size: 96, + stime_offset: 40, + rtime_offset: 48, + ctime_offset: 56, + cbytes_offset: 64, + qnum_offset: 68, + qbytes_offset: 72, + lspid_offset: 76, + lrpid_offset: 80, + ulong_bytes: 4, +}; + +const MSQID_DS_WASM64_LAYOUT: MsqidDsLayout = MsqidDsLayout { + size: 120, + stime_offset: 48, + rtime_offset: 56, + ctime_offset: 64, + cbytes_offset: 72, + qnum_offset: 80, + qbytes_offset: 88, + lspid_offset: 96, + lrpid_offset: 100, + ulong_bytes: 8, +}; + +const SHMID_DS_WASM32_LAYOUT: ShmidDsLayout = ShmidDsLayout { + size: 88, + segsz_offset: 36, + atime_offset: 40, + dtime_offset: 48, + ctime_offset: 56, + cpid_offset: 64, + lpid_offset: 68, + nattch_offset: 72, + ulong_bytes: 4, +}; + +const SHMID_DS_WASM64_LAYOUT: ShmidDsLayout = ShmidDsLayout { + size: 112, + segsz_offset: 48, + atime_offset: 56, + dtime_offset: 64, + ctime_offset: 72, + cpid_offset: 80, + lpid_offset: 84, + nattch_offset: 88, + ulong_bytes: 8, +}; + +const _: () = { + assert!(SEMID_DS_WASM32_LAYOUT.nsems_offset + 2 <= SEMID_DS_WASM32_LAYOUT.size); + assert!(SEMID_DS_WASM64_LAYOUT.nsems_offset + 2 <= SEMID_DS_WASM64_LAYOUT.size); + assert!(MSQID_DS_WASM32_LAYOUT.lrpid_offset + 4 <= MSQID_DS_WASM32_LAYOUT.size); + assert!(MSQID_DS_WASM64_LAYOUT.lrpid_offset + 4 <= MSQID_DS_WASM64_LAYOUT.size); + assert!( + SHMID_DS_WASM32_LAYOUT.nattch_offset + SHMID_DS_WASM32_LAYOUT.ulong_bytes + <= SHMID_DS_WASM32_LAYOUT.size + ); + assert!( + SHMID_DS_WASM64_LAYOUT.nattch_offset + SHMID_DS_WASM64_LAYOUT.ulong_bytes + <= SHMID_DS_WASM64_LAYOUT.size + ); +}; + +fn semid_ds_layout(pointer_width: u32) -> Result { + match pointer_width { + 4 => Ok(SEMID_DS_WASM32_LAYOUT), + 8 => Ok(SEMID_DS_WASM64_LAYOUT), + _ => Err(Errno::EINVAL), + } +} + +fn msqid_ds_layout(pointer_width: u32) -> Result { + match pointer_width { + 4 => Ok(MSQID_DS_WASM32_LAYOUT), + 8 => Ok(MSQID_DS_WASM64_LAYOUT), + _ => Err(Errno::EINVAL), + } +} + +fn shmid_ds_layout(pointer_width: u32) -> Result { + match pointer_width { + 4 => Ok(SHMID_DS_WASM32_LAYOUT), + 8 => Ok(SHMID_DS_WASM64_LAYOUT), + _ => Err(Errno::EINVAL), + } +} + +/// Byte size of the target musl `struct semid_ds`. +pub(crate) fn semid_ds_size(pointer_width: u32) -> Result { + Ok(semid_ds_layout(pointer_width)?.size) +} + +/// Byte size of the target musl `struct msqid_ds`. +pub(crate) fn msqid_ds_size(pointer_width: u32) -> Result { + Ok(msqid_ds_layout(pointer_width)?.size) +} + +/// Byte size of the target musl `struct shmid_ds`. +pub(crate) fn shmid_ds_size(pointer_width: u32) -> Result { + Ok(shmid_ds_layout(pointer_width)?.size) +} + +/// Size of the width-independent header used for message data in kernel +/// scratch. The host translates the caller's native `long` to this i64. +pub(crate) const SYSV_MESSAGE_HEADER_SIZE: usize = + size_of::(); + +pub(crate) fn sysv_message_wire_size(text_bytes: usize) -> Result { + SYSV_MESSAGE_HEADER_SIZE + .checked_add(text_bytes) + .ok_or(Errno::EINVAL) +} + +pub(crate) fn read_sysv_message_type(input: &[u8]) -> Result { + let bytes = input + .get(..SYSV_MESSAGE_HEADER_SIZE) + .ok_or(Errno::EFAULT)?; + Ok(i64::from_le_bytes( + bytes.try_into().map_err(|_| Errno::EFAULT)?, + )) +} + +pub(crate) fn write_sysv_message( + out: &mut [u8], + mtype: i64, + text: &[u8], +) -> Result { + let total = sysv_message_wire_size(text.len())?; + let out = out.get_mut(..total).ok_or(Errno::EFAULT)?; + out[..SYSV_MESSAGE_HEADER_SIZE].copy_from_slice(&mtype.to_le_bytes()); + out[SYSV_MESSAGE_HEADER_SIZE..].copy_from_slice(text); + Ok(total) +} + +/// Read the fields Linux permits msgctl IPC_SET to replace. +pub(crate) fn read_msqid_ds_set_fields( + input: &[u8], + pointer_width: u32, +) -> Result { + let layout = msqid_ds_layout(pointer_width)?; + if input.len() < layout.size { + return Err(Errno::EFAULT); + } + let qbytes = read_ulong(input, layout.qbytes_offset, layout.ulong_bytes)?; + Ok(MsgctlSetFields { + uid: read_u32(input, 4)?, + gid: read_u32(input, 8)?, + mode: read_u32(input, 20)?, + qbytes: u32::try_from(qbytes).map_err(|_| Errno::EOVERFLOW)?, + }) +} + +/// Read the fields Linux permits shmctl IPC_SET to replace. +pub(crate) fn read_shmid_ds_set_fields( + input: &[u8], + pointer_width: u32, +) -> Result { + let layout = shmid_ds_layout(pointer_width)?; + if input.len() < layout.size { + return Err(Errno::EFAULT); + } + Ok(ShmctlSetFields { + uid: read_u32(input, 4)?, + gid: read_u32(input, 8)?, + mode: read_u32(input, 20)?, + }) +} + +/// Serialize one `struct semid_ds` for the target process data model. +/// +/// Only the layout-sized prefix is replaced. The caller may pass a larger +/// kernel-owned region without risking writes into the following transfer. +pub(crate) fn write_semid_ds( + out: &mut [u8], + info: &SemSetInfo, + pointer_width: u32, +) -> Result { + let layout = semid_ds_layout(pointer_width)?; + if out.len() < layout.size { + return Err(Errno::EFAULT); + } + let nsems = u16::try_from(info.nsems).map_err(|_| Errno::EOVERFLOW)?; + let out = &mut out[..layout.size]; + out.fill(0); + + write_ipc_perm( + out, info.key, info.uid, info.gid, info.cuid, info.cgid, info.mode, info.seq, + ); + + // WHY: musl's wasm32 time64 layout follows the 36-byte ipc_perm with + // four bytes of padding, while wasm64's LP64 ipc_perm ends at offset 48. + write_i64(out, layout.otime_offset, info.otime); + write_i64(out, layout.ctime_offset, info.ctime); + out[layout.nsems_offset..layout.nsems_offset + 2] + .copy_from_slice(&nsems.to_le_bytes()); + Ok(layout.size) +} + +/// Serialize one `struct msqid_ds` for the target process data model. +pub(crate) fn write_msqid_ds( + out: &mut [u8], + info: &MsgQueueInfo, + pointer_width: u32, +) -> Result { + let layout = msqid_ds_layout(pointer_width)?; + if out.len() < layout.size { + return Err(Errno::EFAULT); + } + let out = &mut out[..layout.size]; + out.fill(0); + + write_ipc_perm( + out, info.key, info.uid, info.gid, info.cuid, info.cgid, info.mode, info.seq, + ); + write_i64(out, layout.stime_offset, info.stime); + write_i64(out, layout.rtime_offset, info.rtime); + write_i64(out, layout.ctime_offset, info.ctime); + write_ulong(out, layout.cbytes_offset, info.cbytes, layout.ulong_bytes); + write_ulong(out, layout.qnum_offset, info.qnum, layout.ulong_bytes); + write_ulong(out, layout.qbytes_offset, info.qbytes, layout.ulong_bytes); + write_i32(out, layout.lspid_offset, info.lspid); + write_i32(out, layout.lrpid_offset, info.lrpid); + Ok(layout.size) +} + +/// Serialize one `struct shmid_ds` for the target process data model. +pub(crate) fn write_shmid_ds( + out: &mut [u8], + info: &ShmSegInfo, + pointer_width: u32, +) -> Result { + let layout = shmid_ds_layout(pointer_width)?; + if out.len() < layout.size { + return Err(Errno::EFAULT); + } + let out = &mut out[..layout.size]; + out.fill(0); + + write_ipc_perm( + out, info.key, info.uid, info.gid, info.cuid, info.cgid, info.mode, info.seq, + ); + write_ulong(out, layout.segsz_offset, info.segsz, layout.ulong_bytes); + write_i64(out, layout.atime_offset, info.atime); + write_i64(out, layout.dtime_offset, info.dtime); + write_i64(out, layout.ctime_offset, info.ctime); + write_i32(out, layout.cpid_offset, info.cpid); + write_i32(out, layout.lpid_offset, info.lpid); + write_ulong(out, layout.nattch_offset, info.nattch, layout.ulong_bytes); + Ok(layout.size) +} + +#[allow(clippy::too_many_arguments)] +fn write_ipc_perm( + out: &mut [u8], + key: i32, + uid: u32, + gid: u32, + cuid: u32, + cgid: u32, + mode: u32, + seq: i32, +) { + write_i32(out, 0, key); + write_u32(out, 4, uid); + write_u32(out, 8, gid); + write_u32(out, 12, cuid); + write_u32(out, 16, cgid); + write_u32(out, 20, mode); + write_i32(out, 24, seq); +} + +fn write_u32(out: &mut [u8], offset: usize, value: u32) { + out[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); +} + +fn write_i32(out: &mut [u8], offset: usize, value: i32) { + out[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); +} + +fn write_i64(out: &mut [u8], offset: usize, value: i64) { + out[offset..offset + 8].copy_from_slice(&value.to_le_bytes()); +} + +fn write_ulong(out: &mut [u8], offset: usize, value: u32, width: usize) { + match width { + 4 => write_u32(out, offset, value), + 8 => out[offset..offset + 8].copy_from_slice(&(value as u64).to_le_bytes()), + _ => unreachable!("validated System V IPC unsigned-long width"), + } +} + +fn read_u32(input: &[u8], offset: usize) -> Result { + let bytes = input.get(offset..offset + 4).ok_or(Errno::EFAULT)?; + Ok(u32::from_le_bytes( + bytes.try_into().map_err(|_| Errno::EFAULT)?, + )) +} + +fn read_u64(input: &[u8], offset: usize) -> Result { + let bytes = input.get(offset..offset + 8).ok_or(Errno::EFAULT)?; + Ok(u64::from_le_bytes( + bytes.try_into().map_err(|_| Errno::EFAULT)?, + )) +} + +fn read_ulong(input: &[u8], offset: usize, width: usize) -> Result { + match width { + 4 => Ok(read_u32(input, offset)? as u64), + 8 => read_u64(input, offset), + _ => Err(Errno::EINVAL), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + + fn sample_info() -> SemSetInfo { + SemSetInfo { + key: 0x1234_5678, + uid: 0x0102_0304, + gid: 0x1112_1314, + cuid: 0x2122_2324, + cgid: 0x3132_3334, + mode: 0x4142_4344, + seq: 0x5152_5354, + nsems: 0x6162, + otime: 0x0102_0304_0506_0708, + ctime: 0x1112_1314_1516_1718, + } + } + + fn sample_msg_info() -> MsgQueueInfo { + MsgQueueInfo { + key: 0x1234_5678, + uid: 0x0102_0304, + gid: 0x1112_1314, + cuid: 0x2122_2324, + cgid: 0x3132_3334, + mode: 0x4142_4344, + seq: 0x5152_5354, + stime: 0x0102_0304_0506_0708, + rtime: 0x1112_1314_1516_1718, + ctime: 0x2122_2324_2526_2728, + cbytes: 0x6162_6364, + qnum: 0x7172_7374, + qbytes: 0x0103_0507, + lspid: 0x1122_3344, + lrpid: 0x5566_7788, + } + } + + fn sample_shm_info() -> ShmSegInfo { + ShmSegInfo { + key: 0x1234_5678, + uid: 0x0102_0304, + gid: 0x1112_1314, + cuid: 0x2122_2324, + cgid: 0x3132_3334, + mode: 0x4142_4344, + seq: 0x5152_5354, + segsz: 0x6162_6364, + cpid: 0x1122_3344, + lpid: 0x5566_7788, + nattch: 0x7172_7374, + atime: 0x0102_0304_0506_0708, + dtime: 0x1112_1314_1516_1718, + ctime: 0x2122_2324_2526_2728, + } + } + + fn assert_sample_ipc_perm(out: &[u8]) { + assert_eq!(&out[0..4], &0x1234_5678i32.to_le_bytes()); + assert_eq!(&out[4..8], &0x0102_0304u32.to_le_bytes()); + assert_eq!(&out[8..12], &0x1112_1314u32.to_le_bytes()); + assert_eq!(&out[12..16], &0x2122_2324u32.to_le_bytes()); + assert_eq!(&out[16..20], &0x3132_3334u32.to_le_bytes()); + assert_eq!(&out[20..24], &0x4142_4344u32.to_le_bytes()); + assert_eq!(&out[24..28], &0x5152_5354i32.to_le_bytes()); + } + + #[test] + fn sysv_ipc_sizes_follow_the_process_pointer_width() { + assert_eq!(semid_ds_size(4), Ok(72)); + assert_eq!(semid_ds_size(8), Ok(88)); + assert_eq!(semid_ds_size(0), Err(Errno::EINVAL)); + assert_eq!(semid_ds_size(16), Err(Errno::EINVAL)); + assert_eq!(msqid_ds_size(4), Ok(96)); + assert_eq!(msqid_ds_size(8), Ok(120)); + assert_eq!(msqid_ds_size(16), Err(Errno::EINVAL)); + assert_eq!(shmid_ds_size(4), Ok(88)); + assert_eq!(shmid_ds_size(8), Ok(112)); + assert_eq!(shmid_ds_size(16), Err(Errno::EINVAL)); + } + + #[test] + fn canonical_message_wire_preserves_i64_type_and_exact_capacity() { + let mtype = 0x0102_0304_0506_0708i64; + let mut out = vec![0xa5; SYSV_MESSAGE_HEADER_SIZE + 4]; + assert_eq!( + write_sysv_message(&mut out, mtype, b"abc"), + Ok(SYSV_MESSAGE_HEADER_SIZE + 3) + ); + assert_eq!(read_sysv_message_type(&out), Ok(mtype)); + assert_eq!( + &out[SYSV_MESSAGE_HEADER_SIZE..SYSV_MESSAGE_HEADER_SIZE + 3], + b"abc" + ); + assert_eq!(out[SYSV_MESSAGE_HEADER_SIZE + 3], 0xa5); + } + + #[test] + fn canonical_message_wire_rejects_capacity_minus_one_without_writing() { + let mut out = vec![0xa5; SYSV_MESSAGE_HEADER_SIZE + 2]; + assert_eq!( + write_sysv_message(&mut out, 7, b"abc"), + Err(Errno::EFAULT) + ); + assert!(out.iter().all(|byte| *byte == 0xa5)); + assert_eq!( + read_sysv_message_type(&out[..SYSV_MESSAGE_HEADER_SIZE - 1]), + Err(Errno::EFAULT) + ); + } + + #[test] + fn wasm32_semid_ds_uses_time64_ilp32_offsets_without_overwrite() { + let mut out = vec![0xa5; 73]; + assert_eq!(write_semid_ds(&mut out, &sample_info(), 4), Ok(72)); + + assert_sample_ipc_perm(&out); + assert_eq!(&out[40..48], &0x0102_0304_0506_0708i64.to_le_bytes()); + assert_eq!(&out[48..56], &0x1112_1314_1516_1718i64.to_le_bytes()); + assert_eq!(&out[56..58], &0x6162u16.to_le_bytes()); + assert_eq!(out[71], 0); + assert_eq!(out[72], 0xa5); + } + + #[test] + fn wasm64_semid_ds_uses_lp64_offsets_without_overwrite() { + let mut out = vec![0xa5; 89]; + assert_eq!(write_semid_ds(&mut out, &sample_info(), 8), Ok(88)); + + assert_sample_ipc_perm(&out); + assert_eq!(&out[48..56], &0x0102_0304_0506_0708i64.to_le_bytes()); + assert_eq!(&out[56..64], &0x1112_1314_1516_1718i64.to_le_bytes()); + assert_eq!(&out[64..66], &0x6162u16.to_le_bytes()); + assert_eq!(out[87], 0); + assert_eq!(out[88], 0xa5); + } + + #[test] + fn semid_ds_rejects_short_output_and_invalid_width_without_partial_write() { + let mut short = vec![0xa5; 71]; + assert_eq!( + write_semid_ds(&mut short, &sample_info(), 4), + Err(Errno::EFAULT) + ); + assert!(short.iter().all(|byte| *byte == 0xa5)); + + let mut invalid = vec![0xa5; 88]; + assert_eq!( + write_semid_ds(&mut invalid, &sample_info(), 16), + Err(Errno::EINVAL) + ); + assert!(invalid.iter().all(|byte| *byte == 0xa5)); + } + + #[test] + fn semid_ds_rejects_unrepresentable_nsems_without_partial_write() { + let mut info = sample_info(); + info.nsems = u16::MAX as u32 + 1; + let mut out = vec![0xa5; 72]; + assert_eq!( + write_semid_ds(&mut out, &info, 4), + Err(Errno::EOVERFLOW) + ); + assert!(out.iter().all(|byte| *byte == 0xa5)); + } + + #[test] + fn msqid_ds_offsets_cover_wasm32_and_wasm64_without_overwrite() { + let info = sample_msg_info(); + let mut wasm32 = vec![0xa5; 97]; + assert_eq!(write_msqid_ds(&mut wasm32, &info, 4), Ok(96)); + assert_sample_ipc_perm(&wasm32); + assert_eq!(&wasm32[40..48], &info.stime.to_le_bytes()); + assert_eq!(&wasm32[48..56], &info.rtime.to_le_bytes()); + assert_eq!(&wasm32[56..64], &info.ctime.to_le_bytes()); + assert_eq!(&wasm32[64..68], &info.cbytes.to_le_bytes()); + assert_eq!(&wasm32[68..72], &info.qnum.to_le_bytes()); + assert_eq!(&wasm32[72..76], &info.qbytes.to_le_bytes()); + assert_eq!(&wasm32[76..80], &info.lspid.to_le_bytes()); + assert_eq!(&wasm32[80..84], &info.lrpid.to_le_bytes()); + assert_eq!(wasm32[95], 0); + assert_eq!(wasm32[96], 0xa5); + + let mut wasm64 = vec![0xa5; 121]; + assert_eq!(write_msqid_ds(&mut wasm64, &info, 8), Ok(120)); + assert_sample_ipc_perm(&wasm64); + assert_eq!(&wasm64[48..56], &info.stime.to_le_bytes()); + assert_eq!(&wasm64[56..64], &info.rtime.to_le_bytes()); + assert_eq!(&wasm64[64..72], &info.ctime.to_le_bytes()); + assert_eq!(&wasm64[72..80], &(info.cbytes as u64).to_le_bytes()); + assert_eq!(&wasm64[80..88], &(info.qnum as u64).to_le_bytes()); + assert_eq!(&wasm64[88..96], &(info.qbytes as u64).to_le_bytes()); + assert_eq!(&wasm64[96..100], &info.lspid.to_le_bytes()); + assert_eq!(&wasm64[100..104], &info.lrpid.to_le_bytes()); + assert_eq!(wasm64[119], 0); + assert_eq!(wasm64[120], 0xa5); + } + + #[test] + fn shmid_ds_offsets_cover_wasm32_and_wasm64_without_overwrite() { + let info = sample_shm_info(); + let mut wasm32 = vec![0xa5; 89]; + assert_eq!(write_shmid_ds(&mut wasm32, &info, 4), Ok(88)); + assert_sample_ipc_perm(&wasm32); + assert_eq!(&wasm32[36..40], &info.segsz.to_le_bytes()); + assert_eq!(&wasm32[40..48], &info.atime.to_le_bytes()); + assert_eq!(&wasm32[48..56], &info.dtime.to_le_bytes()); + assert_eq!(&wasm32[56..64], &info.ctime.to_le_bytes()); + assert_eq!(&wasm32[64..68], &info.cpid.to_le_bytes()); + assert_eq!(&wasm32[68..72], &info.lpid.to_le_bytes()); + assert_eq!(&wasm32[72..76], &info.nattch.to_le_bytes()); + assert_eq!(wasm32[87], 0); + assert_eq!(wasm32[88], 0xa5); + + let mut wasm64 = vec![0xa5; 113]; + assert_eq!(write_shmid_ds(&mut wasm64, &info, 8), Ok(112)); + assert_sample_ipc_perm(&wasm64); + assert_eq!(&wasm64[48..56], &(info.segsz as u64).to_le_bytes()); + assert_eq!(&wasm64[56..64], &info.atime.to_le_bytes()); + assert_eq!(&wasm64[64..72], &info.dtime.to_le_bytes()); + assert_eq!(&wasm64[72..80], &info.ctime.to_le_bytes()); + assert_eq!(&wasm64[80..84], &info.cpid.to_le_bytes()); + assert_eq!(&wasm64[84..88], &info.lpid.to_le_bytes()); + assert_eq!(&wasm64[88..96], &(info.nattch as u64).to_le_bytes()); + assert_eq!(wasm64[111], 0); + assert_eq!(wasm64[112], 0xa5); + } + + #[test] + fn msqid_and_shmid_serializers_reject_short_outputs_without_partial_write() { + let mut msg = vec![0xa5; 119]; + assert_eq!( + write_msqid_ds(&mut msg, &sample_msg_info(), 8), + Err(Errno::EFAULT) + ); + assert!(msg.iter().all(|byte| *byte == 0xa5)); + + let mut shm = vec![0xa5; 111]; + assert_eq!( + write_shmid_ds(&mut shm, &sample_shm_info(), 8), + Err(Errno::EFAULT) + ); + assert!(shm.iter().all(|byte| *byte == 0xa5)); + + let mut invalid_msg = vec![0xa5; 120]; + assert_eq!( + write_msqid_ds(&mut invalid_msg, &sample_msg_info(), 16), + Err(Errno::EINVAL) + ); + assert!(invalid_msg.iter().all(|byte| *byte == 0xa5)); + + let mut invalid_shm = vec![0xa5; 112]; + assert_eq!( + write_shmid_ds(&mut invalid_shm, &sample_shm_info(), 16), + Err(Errno::EINVAL) + ); + assert!(invalid_shm.iter().all(|byte| *byte == 0xa5)); + } + + #[test] + fn ipc_set_parsers_read_only_permitted_fields_at_both_target_widths() { + let expected_msg = MsgctlSetFields { + uid: 1001, + gid: 1002, + mode: 0o764, + qbytes: 32_768, + }; + for (pointer_width, size, qbytes_offset) in + [(4, 96, 72), (8, 120, 88)] + { + let mut input = vec![0xa5; size]; + write_u32(&mut input, 4, expected_msg.uid); + write_u32(&mut input, 8, expected_msg.gid); + write_u32(&mut input, 20, expected_msg.mode); + if pointer_width == 4 { + write_u32(&mut input, qbytes_offset, expected_msg.qbytes); + } else { + write_i64( + &mut input, + qbytes_offset, + expected_msg.qbytes as i64, + ); + } + assert_eq!( + read_msqid_ds_set_fields(&input, pointer_width), + Ok(expected_msg) + ); + } + + let expected_shm = ShmctlSetFields { + uid: 2001, + gid: 2002, + mode: 0o640, + }; + for (pointer_width, size) in [(4, 88), (8, 112)] { + let mut input = vec![0xa5; size]; + write_u32(&mut input, 4, expected_shm.uid); + write_u32(&mut input, 8, expected_shm.gid); + write_u32(&mut input, 20, expected_shm.mode); + assert_eq!( + read_shmid_ds_set_fields(&input, pointer_width), + Ok(expected_shm) + ); + } + } + + #[test] + fn ipc_set_parsers_reject_short_invalid_or_unrepresentable_inputs() { + assert_eq!( + read_msqid_ds_set_fields(&vec![0; 95], 4), + Err(Errno::EFAULT) + ); + assert_eq!( + read_shmid_ds_set_fields(&vec![0; 111], 8), + Err(Errno::EFAULT) + ); + assert_eq!( + read_msqid_ds_set_fields(&vec![0; 120], 16), + Err(Errno::EINVAL) + ); + + let mut oversized_qbytes = vec![0; 120]; + oversized_qbytes[88..96] + .copy_from_slice(&(u32::MAX as u64 + 1).to_le_bytes()); + assert_eq!( + read_msqid_ds_set_fields(&oversized_qbytes, 8), + Err(Errno::EOVERFLOW) + ); + } +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 39bf3e1b2c..6bae3b2477 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -6,6 +6,7 @@ extern crate alloc; extern crate wasm_posix_shared; pub mod audio; +pub(crate) mod channel_scratch; pub(crate) mod descriptor_backing; pub mod devfs; pub mod dri; @@ -13,6 +14,7 @@ pub mod fd; pub mod fifo; pub mod fork; pub mod ipc; +pub(crate) mod ipc_wire; pub mod lock; pub mod memory; pub mod mouse; @@ -22,11 +24,14 @@ pub mod path; pub mod pipe; pub mod process; pub mod process_table; +pub(crate) mod process_wire; pub mod procfs; pub mod pshared; pub mod pty; +pub(crate) mod scratch_alloc; pub mod signal; pub mod socket; +pub(crate) mod socket_wire; pub mod spawn; pub mod syscalls; pub mod terminal; diff --git a/crates/kernel/src/mqueue.rs b/crates/kernel/src/mqueue.rs index 80a0745aff..3f4bf49b52 100644 --- a/crates/kernel/src/mqueue.rs +++ b/crates/kernel/src/mqueue.rs @@ -7,7 +7,7 @@ use alloc::collections::BTreeMap; use alloc::string::String; use alloc::vec::Vec; -use wasm_posix_shared::Errno; +use wasm_posix_shared::{signal::NSIG, Errno}; // Access mode flags const O_RDONLY: u32 = 0; @@ -56,11 +56,13 @@ struct MqDescriptor { nonblock: bool, } -/// Notification registration (pid + signal number). +/// One-shot signal notification registration. #[derive(Clone, Copy, Debug)] pub struct MqNotification { pub pid: u32, pub signo: u32, + /// Raw registering process `union sigval` bits. + pub value_bits: u64, } /// Queue attributes returned to userspace. @@ -310,6 +312,7 @@ impl MqueueTable { pid: u32, sigev_notify: Option, // None = unregister (sev ptr was NULL) signo: u32, + value_bits: u64, ) -> Result<(), Errno> { let desc = self.descriptors.get(&mqd).ok_or(Errno::EBADF)?; let queue = self.queues.get_mut(&desc.queue_name).ok_or(Errno::EBADF)?; @@ -325,14 +328,25 @@ impl MqueueTable { return Err(Errno::EBUSY); } // Register sentinel (blocks others, no actual signal) - queue.notification = Some(MqNotification { pid, signo: 0 }); + queue.notification = Some(MqNotification { + pid, + signo: 0, + value_bits, + }); Ok(()) } Some(SIGEV_SIGNAL) => { + if signo == 0 || signo >= NSIG { + return Err(Errno::EINVAL); + } if queue.notification.is_some() { return Err(Errno::EBUSY); } - queue.notification = Some(MqNotification { pid, signo }); + queue.notification = Some(MqNotification { + pid, + signo, + value_bits, + }); Ok(()) } Some(_) => Err(Errno::EINVAL), @@ -522,30 +536,61 @@ mod tests { .unwrap(); // Register notification - t.mq_notify(mqd, 42, Some(SIGEV_SIGNAL), 10).unwrap(); + t.mq_notify( + mqd, + 42, + Some(SIGEV_SIGNAL), + 10, + 0x0123_4567_89ab_cdef, + ) + .unwrap(); // Second registration should EBUSY assert_eq!( - t.mq_notify(mqd, 43, Some(SIGEV_SIGNAL), 11), + t.mq_notify(mqd, 43, Some(SIGEV_SIGNAL), 11, 0), + Err(Errno::EBUSY) + ); + assert_eq!( + t.mq_notify(mqd, 43, Some(SIGEV_NONE), 0, 0), Err(Errno::EBUSY) ); - assert_eq!(t.mq_notify(mqd, 43, Some(SIGEV_NONE), 0), Err(Errno::EBUSY)); // Send to empty queue should fire notification let result = t.mq_send(mqd, b"hello", 1).unwrap(); let notif = result.notification.unwrap(); assert_eq!(notif.pid, 42); assert_eq!(notif.signo, 10); + assert_eq!(notif.value_bits, 0x0123_4567_89ab_cdef); // Auto-unregistered: second send should NOT fire let result = t.mq_send(mqd, b"world", 1).unwrap(); assert!(result.notification.is_none()); // Unregister with NULL sev - t.mq_notify(mqd, 42, Some(SIGEV_SIGNAL), 10).unwrap(); - t.mq_notify(mqd, 42, None, 0).unwrap(); + t.mq_notify(mqd, 42, Some(SIGEV_SIGNAL), 10, 0).unwrap(); + t.mq_notify(mqd, 42, None, 0, 0).unwrap(); // Now registration should work again - t.mq_notify(mqd, 42, Some(SIGEV_SIGNAL), 10).unwrap(); + t.mq_notify(mqd, 42, Some(SIGEV_SIGNAL), 10, 0).unwrap(); + } + + #[test] + fn test_signal_notification_rejects_invalid_signums_without_registering() { + let mut t = MqueueTable::new(); + let mqd = t + .mq_open("/invalid-notify", O_CREAT | O_RDWR, 0o644, 10, 64, true) + .unwrap(); + + for signo in [0, NSIG, u32::MAX] { + assert_eq!( + t.mq_notify(mqd, 42, Some(SIGEV_SIGNAL), signo, 0), + Err(Errno::EINVAL) + ); + } + + // WHY: a rejected registration must not occupy the queue's one-shot + // notification slot; a later valid registration must still succeed. + t.mq_notify(mqd, 42, Some(SIGEV_SIGNAL), NSIG - 1, 0) + .unwrap(); } #[test] @@ -580,13 +625,13 @@ mod tests { .mq_open("/cleanup", O_CREAT | O_RDWR, 0o644, 10, 64, true) .unwrap(); - t.mq_notify(mqd, 100, Some(SIGEV_SIGNAL), 10).unwrap(); + t.mq_notify(mqd, 100, Some(SIGEV_SIGNAL), 10, 0).unwrap(); // Cleanup pid 100 should remove notification t.cleanup_process(100); // Now registration should succeed - t.mq_notify(mqd, 200, Some(SIGEV_SIGNAL), 11).unwrap(); + t.mq_notify(mqd, 200, Some(SIGEV_SIGNAL), 11, 0).unwrap(); } #[test] @@ -621,7 +666,8 @@ mod tests { ); assert_eq!(t.mq_receive(MQD_BASE + 999, 64).unwrap_err(), Errno::EBADF); assert_eq!( - t.mq_notify(MQD_BASE + 999, 1, Some(0), 1).unwrap_err(), + t.mq_notify(MQD_BASE + 999, 1, Some(0), 1, 0) + .unwrap_err(), Errno::EBADF ); assert_eq!( diff --git a/crates/kernel/src/pipe.rs b/crates/kernel/src/pipe.rs index 15e4d6f391..3a8d012ede 100644 --- a/crates/kernel/src/pipe.rs +++ b/crates/kernel/src/pipe.rs @@ -61,8 +61,6 @@ pub struct InFlightFd { /// For kernel-backed pipe FDs: the exact reference transferred to the /// receiver. Non-pipe descriptors leave this as `None`. pub pipe_ref_kind: Option, - /// For socket FDs: serialized socket state. - pub socket: Option, /// True after this queued payload has acquired its one machine-wide /// backing and OfdId reference. Ownership transfers to the receiver or is /// released through the deferred queue on drop. @@ -88,7 +86,6 @@ impl InFlightFd { offset, path, pipe_ref_kind: None, - socket: None, owns_reference: false, } } @@ -99,14 +96,6 @@ impl InFlightFd { file_type: self.file_type, host_handle: self.host_handle, pipe_ref_kind: self.pipe_ref_kind, - socket_send_idx: self.socket.as_ref().and_then(|socket| socket.send_buf_idx), - socket_recv_idx: self.socket.as_ref().and_then(|socket| socket.recv_buf_idx), - socket_domain: self.socket.as_ref().map(|socket| socket.domain), - socket_type: self.socket.as_ref().map(|socket| socket.sock_type), - socket_global_pipes: self - .socket - .as_ref() - .is_some_and(|socket| socket.global_pipes), } } @@ -116,6 +105,10 @@ impl InFlightFd { if self.owns_reference { return Ok(()); } + // Keep the lowest ownership primitive closed too. Callers must not be + // able to bypass sendmsg's complete-batch validation and retain a + // description the receiver cannot reconstruct. + crate::syscalls::validate_scm_rights_in_flight_fd(self)?; reserve_deferred_in_flight_release()?; if let Err(err) = crate::ofd::retain_in_flight_ofd(self.ofd_id) { cancel_deferred_in_flight_release(); @@ -142,14 +135,18 @@ impl InFlightFd { } } - #[cfg(test)] - pub(crate) fn owns_reference(&self) -> bool { - self.owns_reference - } -} - -impl Clone for InFlightFd { - fn clone(&self) -> Self { + /// Clone one queued descriptor while acquiring an independent in-flight + /// reference. + /// + /// `MSG_PEEK` installs a new descriptor for each successful peek while + /// leaving the original message queued. Keep that allocation fallible so + /// resource pressure returns an errno instead of panicking after the + /// caller has observed a partial ancillary result. + pub(crate) fn try_clone_retained(&self) -> Result { + let mut path = Vec::new(); + path.try_reserve_exact(self.path.len()) + .map_err(|_| Errno::ENOMEM)?; + path.extend_from_slice(&self.path); let mut cloned = Self { ofd_id: self.ofd_id, file_id: self.file_id, @@ -157,17 +154,18 @@ impl Clone for InFlightFd { status_flags: self.status_flags, host_handle: self.host_handle, offset: self.offset, - path: self.path.clone(), + path, pipe_ref_kind: self.pipe_ref_kind, - socket: self.socket.clone(), owns_reference: false, }; if self.owns_reference { - cloned - .retain_reference() - .expect("failed to retain cloned in-flight OFD reference"); + cloned.retain_reference()?; } - cloned + Ok(cloned) + } + + pub(crate) fn owns_reference(&self) -> bool { + self.owns_reference } } @@ -182,24 +180,6 @@ impl Drop for InFlightFd { } } -/// Serialized socket state for SCM_RIGHTS FD passing. -#[derive(Clone)] -pub struct InFlightSocket { - pub domain: u8, // 0=Unix, 1=Inet, 2=Inet6 - pub sock_type: u8, // 0=Stream, 1=Dgram - pub protocol: u32, - pub state: u8, // 0=Unbound, ..., 4=Closed - pub send_buf_idx: Option, - pub recv_buf_idx: Option, - pub global_pipes: bool, - pub shut_rd: bool, - pub shut_wr: bool, - pub bind_addr: [u8; 4], - pub bind_port: u16, - pub peer_addr: [u8; 4], - pub peer_port: u16, -} - /// Fixed cleanup metadata queued by `InFlightFd::drop`. Drop never re-enters /// the pipe, PTY, or descriptor-backing globals because it may itself be /// running while one of those tables is mutably borrowed. @@ -209,11 +189,6 @@ pub(crate) struct DeferredInFlightFdRelease { file_type: FileType, host_handle: i64, pipe_ref_kind: Option, - socket_send_idx: Option, - socket_recv_idx: Option, - socket_domain: Option, - socket_type: Option, - socket_global_pipes: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -223,6 +198,29 @@ pub(crate) struct ReleasedInFlightFd { pub host_close: Option, } +/// One SCM_RIGHTS batch attached to an exact byte range in a stream pipe. +/// +/// Positions use the pipe's absolute byte sequence so ordinary reads stay +/// O(1) regardless of how many later descriptor messages are queued. +struct StreamAncillary { + start: u64, + end: u64, + fds: Vec, +} + +/// Result of one message-aware stream read. +pub(crate) struct PipeMessageRead { + pub bytes_read: usize, + pub hit_ancillary_barrier: bool, + pub ancillary_fds: Option>, +} + +/// Result of a stream read that cannot return ancillary data. +pub(crate) struct PipePlainRead { + pub bytes_read: usize, + pub hit_ancillary_barrier: bool, +} + struct DeferredInFlightReleaseQueue { records: Vec, /// Capacity promised to live `InFlightFd` values whose destructor may @@ -275,10 +273,23 @@ pub(crate) fn pop_deferred_in_flight_release() -> Option bool { + !deferred_in_flight_releases().records.is_empty() +} + #[cfg(test)] pub(crate) fn deferred_in_flight_release_state() -> (usize, usize, usize) { let queue = deferred_in_flight_releases(); - (queue.records.len(), queue.reserved, queue.records.capacity()) + ( + queue.records.len(), + queue.reserved, + queue.records.capacity(), + ) } fn retain_in_flight_resource(release: DeferredInFlightFdRelease) -> Result<(), Errno> { @@ -303,24 +314,6 @@ fn retain_in_flight_resource(release: DeferredInFlightFdRelease) -> Result<(), E let kind = release.pipe_ref_kind.ok_or(Errno::EINVAL)?; pipe.retain_in_flight_reference(kind); } - FileType::Socket if release.socket_global_pipes => { - let pipes = unsafe { global_pipe_table() }; - if release - .socket_send_idx - .is_some_and(|idx| pipes.get(idx).is_none()) - || release - .socket_recv_idx - .is_some_and(|idx| pipes.get(idx).is_none()) - { - return Err(Errno::EBADF); - } - if let Some(idx) = release.socket_send_idx { - pipes.get_mut(idx).unwrap().retain_in_flight_writer(); - } - if let Some(idx) = release.socket_recv_idx { - pipes.get_mut(idx).unwrap().retain_in_flight_reader(); - } - } FileType::PtyMaster | FileType::PtySlave => { let pty = crate::pty::get_pty(release.host_handle as usize).ok_or(Errno::EBADF)?; if release.file_type == FileType::PtyMaster { @@ -329,7 +322,7 @@ fn retain_in_flight_resource(release: DeferredInFlightFdRelease) -> Result<(), E pty.slave_refs = pty.slave_refs.checked_add(1).ok_or(Errno::EOVERFLOW)?; } } - FileType::Epoll => return Err(Errno::EINVAL), + FileType::Socket | FileType::Epoll => return Err(Errno::EOPNOTSUPP), _ => {} } Ok(()) @@ -348,19 +341,6 @@ fn transfer_in_flight_resource(release: DeferredInFlightFdRelease) { pipe.adopt_in_flight_reference(kind); } } - FileType::Socket if release.socket_global_pipes => { - let pipes = unsafe { global_pipe_table() }; - if let Some(idx) = release.socket_send_idx { - if let Some(pipe) = pipes.get_mut(idx) { - pipe.adopt_in_flight_writer(); - } - } - if let Some(idx) = release.socket_recv_idx { - if let Some(pipe) = pipes.get_mut(idx) { - pipe.adopt_in_flight_reader(); - } - } - } _ => {} } } @@ -402,27 +382,6 @@ pub(crate) fn release_deferred_in_flight_resource( } pipes.free_if_closed(pipe_idx); } - FileType::Socket if release.socket_global_pipes => { - let pipes = unsafe { global_pipe_table() }; - if let Some(idx) = release.socket_send_idx { - if let Some(pipe) = pipes.get_mut(idx) { - pipe.release_in_flight_writer(); - } - pipes.free_if_closed(idx); - } - if let Some(idx) = release.socket_recv_idx { - if let Some(pipe) = pipes.get_mut(idx) { - let orderly_tcp_close = release.socket_type == Some(0) - && matches!(release.socket_domain, Some(1 | 2)); - if orderly_tcp_close { - pipe.release_in_flight_reader_orderly(); - } else { - pipe.release_in_flight_reader(); - } - } - pipes.free_if_closed(idx); - } - } FileType::PtyMaster | FileType::PtySlave => { let pty_idx = release.host_handle as usize; if let Some(pty) = crate::pty::get_pty(pty_idx) { @@ -460,11 +419,13 @@ pub struct PipeBuffer { head: usize, tail: usize, len: usize, + /// Absolute sequence number of the byte at `head`. + stream_position: u64, read_count: u32, write_count: u32, /// Endpoint references owned by descriptors queued in SCM_RIGHTS messages. /// These are included in read_count/write_count, but tracked separately so - /// unreachable cycles of queued socket descriptors can be collected. + /// a carrier queue with no externally owned reader can be discarded. in_flight_read_count: u32, in_flight_write_count: u32, /// The receive half of a normally closed TCP endpoint remains as an @@ -499,9 +460,14 @@ pub struct PipeBuffer { /// prevents the counterpart from returning into an apparent zero-reader /// or zero-writer pipe before this thread gets scheduled again. fifo_open_waiters: BTreeMap, - /// Ancillary data queue for SCM_RIGHTS FD passing. - /// Each entry is a batch of FDs sent with one sendmsg call. - ancillary_fds: VecDeque>, + /// SCM_RIGHTS batches attached to exact stream byte ranges. + /// + /// WHY: a detached FIFO of descriptor batches can deliver rights while + /// reading earlier plain bytes, or leave stale rights after `read(2)` + /// consumes their carrier. The byte range makes each sendmsg an + /// explicit receive barrier and keeps ordinary reads and peeks coherent. + /// Absolute positions avoid walking every later record on each read. + ancillary_fds: VecDeque, } impl PipeBuffer { @@ -514,6 +480,7 @@ impl PipeBuffer { head: 0, tail: 0, len: 0, + stream_position: 0, read_count: 1, write_count: 1, in_flight_read_count: 0, @@ -853,6 +820,19 @@ impl PipeBuffer { /// Performs a partial write if the buffer does not have enough free space /// for all of `data`. Returns 0 if the buffer is full. pub fn write(&mut self, data: &[u8]) -> usize { + let previous_len = self.len; + let bytes_written = self.write_without_wakeup(data); + if self.len != previous_len { + crate::wakeup::push(self.pipe_idx, crate::wakeup::WAKE_READABLE); + } + bytes_written + } + + /// Mutate buffered bytes without publishing a readable wakeup. + /// + /// SCM_RIGHTS uses this so the carrier bytes and their ownership record + /// become visible as one transaction before a waiter may run. + fn write_without_wakeup(&mut self, data: &[u8]) -> usize { if self.read_count == 0 { return if self.orphaned_read { data.len() } else { 0 }; } @@ -861,6 +841,7 @@ impl PipeBuffer { if n == 0 { return 0; } + self.ensure_stream_sequence_room(n); let first = cap - self.tail; if n <= first { self.buf[self.tail..self.tail + n].copy_from_slice(&data[..n]); @@ -870,11 +851,219 @@ impl PipeBuffer { } self.tail = (self.tail + n) % cap; self.len += n; - // Data written → pipe became readable - crate::wakeup::push(self.pipe_idx, crate::wakeup::WAKE_READABLE); n } + /// Write stream bytes and attach one retained SCM_RIGHTS batch to the + /// exact successfully written range. + /// + /// Queue capacity is reserved before bytes become visible. This prevents + /// a successful data write followed by a failed best-effort ancillary + /// enqueue, which would silently separate the descriptors from their + /// carrier bytes. + pub(crate) fn write_with_ancillary( + &mut self, + data: &[u8], + fds: Vec, + ) -> Result { + if fds.is_empty() { + return Ok(self.write(data)); + } + if data.is_empty() { + return Ok(0); + } + self.ancillary_fds + .try_reserve(1) + .map_err(|_| Errno::ENOMEM)?; + let previous_len = self.len; + let bytes_written = self.write_without_wakeup(data); + if bytes_written == 0 { + return Ok(0); + } + if self.len == previous_len { + // An orphaned TCP read side is an explicit discard sink. Data may + // report success there, but there is no readable carrier range to + // which descriptors could safely remain attached. + return Ok(bytes_written); + } + let start = self + .stream_position + .checked_add(previous_len as u64) + .expect("stream sequence was rebased before write"); + let end = start + .checked_add(bytes_written as u64) + .expect("stream sequence was rebased before write"); + self.ancillary_fds + .push_back(StreamAncillary { start, end, fds }); + // WHY: publish only after both the bytes and their descriptor ownership + // are installed, so a callback or nested operation cannot observe one + // half of the message. + crate::wakeup::push(self.pipe_idx, crate::wakeup::WAKE_READABLE); + Ok(bytes_written) + } + + /// Maximum bytes one receive may cross without passing a descriptor + /// barrier. + fn message_read_len(&self, requested: usize) -> usize { + let mut bytes = requested.min(self.len); + if let Some(ancillary) = self.ancillary_fds.front() { + let requested_end = self + .stream_position + .checked_add(bytes as u64) + .expect("live pipe range fits in stream sequence"); + if ancillary.start < requested_end { + let barrier_len = ancillary + .end + .checked_sub(self.stream_position) + .expect("ancillary barrier cannot precede read head"); + bytes = bytes.min(barrier_len as usize); + } + } + bytes + } + + /// Whether the currently available prefix reaches the first descriptor + /// barrier for a receive of at most `requested` bytes. + pub(crate) fn ancillary_barrier_within(&self, requested: usize) -> bool { + let bytes = requested.min(self.len); + let read_end = self + .stream_position + .checked_add(bytes as u64) + .expect("live pipe range fits in stream sequence"); + self.ancillary_fds + .front() + .is_some_and(|ancillary| ancillary.start < read_end) + } + + /// Rebase the absolute stream sequence only at the practically unreachable + /// u64 boundary. The ordinary hot path never walks queued records. + fn ensure_stream_sequence_room(&mut self, additional: usize) { + let buffered_and_new = self + .len + .checked_add(additional) + .expect("pipe capacity bounds buffered stream bytes"); + if self + .stream_position + .checked_add(buffered_and_new as u64) + .is_some() + { + return; + } + let old_position = self.stream_position; + for ancillary in &mut self.ancillary_fds { + ancillary.start -= old_position; + ancillary.end -= old_position; + } + self.stream_position = 0; + } + + fn copy_from_head(&self, buf: &mut [u8], bytes: usize) { + if bytes == 0 { + return; + } + let cap = self.capacity(); + let first = (cap - self.head).min(bytes); + buf[..first].copy_from_slice(&self.buf[self.head..self.head + first]); + if first < bytes { + buf[first..bytes].copy_from_slice(&self.buf[..bytes - first]); + } + } + + fn consume_from_head(&mut self, bytes: usize) { + if bytes == 0 { + return; + } + self.ensure_stream_sequence_room(0); + self.head = (self.head + bytes) % self.capacity(); + self.len -= bytes; + self.stream_position = self + .stream_position + .checked_add(bytes as u64) + .expect("stream sequence was rebased before consume"); + crate::wakeup::push(self.pipe_idx, crate::wakeup::WAKE_WRITABLE); + } + + /// Read one stream segment and return the first ancillary batch crossed. + /// + /// A non-peek receive moves the queued batch. A peek fallibly clones its + /// retained references and leaves both bytes and the original batch in + /// place, matching Linux's repeated-peek descriptor semantics. + pub(crate) fn recv_message( + &mut self, + buf: &mut [u8], + peek: bool, + ) -> Result { + let bytes_read = self.message_read_len(buf.len()); + let read_end = self + .stream_position + .checked_add(bytes_read as u64) + .expect("live pipe range fits in stream sequence"); + let crosses_ancillary = self + .ancillary_fds + .front() + .is_some_and(|ancillary| ancillary.start < read_end); + let ancillary_fds = if !crosses_ancillary { + None + } else if peek { + let mut cloned = Vec::new(); + let original = &self + .ancillary_fds + .front() + .expect("crossed ancillary record exists") + .fds; + cloned + .try_reserve_exact(original.len()) + .map_err(|_| Errno::ENOMEM)?; + for fd in original { + cloned.push(fd.try_clone_retained()?); + } + Some(cloned) + } else { + Some( + self.ancillary_fds + .pop_front() + .expect("crossed ancillary record exists") + .fds, + ) + }; + + self.copy_from_head(buf, bytes_read); + if !peek { + self.consume_from_head(bytes_read); + } + Ok(PipeMessageRead { + bytes_read, + hit_ancillary_barrier: crosses_ancillary, + ancillary_fds, + }) + } + + /// Read stream bytes for `read(2)`, `recv(2)`, or `recvfrom(2)`, none of + /// which can return ancillary data. A consuming call discards a crossed + /// descriptor batch; a peek leaves the batch queued. + pub(crate) fn recv_plain(&mut self, buf: &mut [u8], peek: bool) -> PipePlainRead { + let bytes_read = self.message_read_len(buf.len()); + let read_end = self + .stream_position + .checked_add(bytes_read as u64) + .expect("live pipe range fits in stream sequence"); + let hit_ancillary_barrier = self + .ancillary_fds + .front() + .is_some_and(|ancillary| ancillary.start < read_end); + self.copy_from_head(buf, bytes_read); + if !peek { + if hit_ancillary_barrier { + drop(self.ancillary_fds.pop_front()); + } + self.consume_from_head(bytes_read); + } + PipePlainRead { + bytes_read, + hit_ancillary_barrier, + } + } + /// Read data from the ring buffer without consuming it, returning the /// number of bytes read. /// @@ -883,19 +1072,9 @@ impl PipeBuffer { /// /// Returns 0 if the buffer is empty. pub fn peek(&self, buf: &mut [u8]) -> usize { - let cap = self.capacity(); - let n = buf.len().min(self.len); - if n == 0 { - return 0; - } - let first = cap - self.head; - if n <= first { - buf[..n].copy_from_slice(&self.buf[self.head..self.head + n]); - } else { - buf[..first].copy_from_slice(&self.buf[self.head..self.head + first]); - buf[first..n].copy_from_slice(&self.buf[0..n - first]); - } - n + let bytes_read = self.message_read_len(buf.len()); + self.copy_from_head(buf, bytes_read); + bytes_read } /// Read data from the ring buffer into `buf`, returning the number of @@ -903,23 +1082,7 @@ impl PipeBuffer { /// /// Returns 0 if the buffer is empty. pub fn read(&mut self, buf: &mut [u8]) -> usize { - let cap = self.capacity(); - let n = buf.len().min(self.len); - if n == 0 { - return 0; - } - let first = cap - self.head; - if n <= first { - buf[..n].copy_from_slice(&self.buf[self.head..self.head + n]); - } else { - buf[..first].copy_from_slice(&self.buf[self.head..self.head + first]); - buf[first..n].copy_from_slice(&self.buf[0..n - first]); - } - self.head = (self.head + n) % cap; - self.len -= n; - // Data consumed → pipe became writable - crate::wakeup::push(self.pipe_idx, crate::wakeup::WAKE_WRITABLE); - n + self.recv_plain(buf, false).bytes_read } /// Close one read end of the pipe. Decrements the read reference count. @@ -934,6 +1097,9 @@ impl PipeBuffer { // them only enqueues fixed cleanup metadata; resource tables are // drained after this PipeBuffer borrow ends. self.discard_unreceivable_ancillary(); + if self.ancillary_fds.is_empty() { + self.stream_position = 0; + } } // Read end closed → pipe became writable (writers get EPIPE/SIGPIPE) crate::wakeup::push(self.pipe_idx, crate::wakeup::WAKE_WRITABLE); @@ -953,6 +1119,9 @@ impl PipeBuffer { self.len = 0; self.orphaned_read = self.write_count > 0; self.discard_unreceivable_ancillary(); + if self.ancillary_fds.is_empty() { + self.stream_position = 0; + } } crate::wakeup::push(self.pipe_idx, crate::wakeup::WAKE_WRITABLE); } @@ -986,16 +1155,6 @@ impl PipeBuffer { crate::wakeup::push(self.pipe_idx, crate::wakeup::WAKE_READABLE); } - fn retain_in_flight_reader(&mut self) { - self.add_reader(); - self.in_flight_read_count += 1; - } - - fn retain_in_flight_writer(&mut self) { - self.add_writer(); - self.in_flight_write_count += 1; - } - fn adopt_in_flight_reader(&mut self) { debug_assert!(self.in_flight_read_count > 0); self.in_flight_read_count = self.in_flight_read_count.saturating_sub(1); @@ -1006,24 +1165,6 @@ impl PipeBuffer { self.in_flight_write_count = self.in_flight_write_count.saturating_sub(1); } - fn release_in_flight_reader(&mut self) { - debug_assert!(self.in_flight_read_count > 0); - self.in_flight_read_count = self.in_flight_read_count.saturating_sub(1); - self.close_read_end(); - } - - fn release_in_flight_reader_orderly(&mut self) { - debug_assert!(self.in_flight_read_count > 0); - self.in_flight_read_count = self.in_flight_read_count.saturating_sub(1); - self.close_read_end_orderly(); - } - - fn release_in_flight_writer(&mut self) { - debug_assert!(self.in_flight_write_count > 0); - self.in_flight_write_count = self.in_flight_write_count.saturating_sub(1); - self.close_write_end(); - } - fn has_external_reader(&self) -> bool { self.read_count > self.in_flight_read_count } @@ -1033,7 +1174,7 @@ impl PipeBuffer { if self .ancillary_fds .iter() - .flatten() + .flat_map(|record| &record.fds) .any(|fd| !fd.owns_reference) { // Local PipeTable unit fixtures exercise the lower-level reference @@ -1097,18 +1238,6 @@ impl PipeBuffer { && (!self.is_fifo || (self.fifo_names == 0 && self.fifo_path_refs == 0)) } - /// Push ancillary FDs (SCM_RIGHTS) to be delivered with the next recvmsg. - fn push_ancillary(&mut self, fds: Vec) { - if !fds.is_empty() { - self.ancillary_fds.push_back(fds); - } - } - - /// Pop ancillary FDs (SCM_RIGHTS) for the next recvmsg call. - pub fn pop_ancillary(&mut self) -> Option> { - self.ancillary_fds.pop_front() - } - /// Returns true if there are ancillary FDs pending delivery. pub fn has_ancillary(&self) -> bool { !self.ancillary_fds.is_empty() @@ -1190,21 +1319,22 @@ impl PipeTable { self.pipes.get_mut(idx).and_then(|p| p.as_mut()) } - /// Queue SCM_RIGHTS entries that already own their machine-wide references. - /// The message is collected immediately if no live or reachable socket - /// endpoint can ever receive it. - pub fn queue_retained_ancillary( + /// Atomically publish stream data and its already-retained SCM_RIGHTS + /// descriptors, then discard any carrier queue that has no external + /// receiver. + pub(crate) fn write_retained_ancillary( &mut self, carrier_idx: usize, + data: &[u8], fds: Vec, - ) -> bool { + ) -> Result { debug_assert!(fds.iter().all(|fd| fd.owns_reference)); - if self.get(carrier_idx).is_none() { - return false; - } - self.get_mut(carrier_idx).unwrap().push_ancillary(fds); + let bytes_written = self + .get_mut(carrier_idx) + .ok_or(Errno::EBADF)? + .write_with_ancillary(data, fds)?; self.collect_unreachable_ancillary(); - true + Ok(bytes_written) } /// Lower-level resource accounting used by the local PipeTable tests. @@ -1213,7 +1343,13 @@ impl PipeTable { if self.get(carrier_idx).is_none() || !self.retain_ancillary_resources(&fds) { return false; } - self.get_mut(carrier_idx).unwrap().push_ancillary(fds); + let result = self + .get_mut(carrier_idx) + .unwrap() + .write_with_ancillary(b"x", fds); + if result != Ok(1) { + return false; + } self.collect_unreachable_ancillary(); true } @@ -1263,7 +1399,7 @@ impl PipeTable { } /// Complete a popped SCM_RIGHTS batch after every reference was adopted or - /// released, then collect cycles made unreachable by removing that queue. + /// released, then discard carrier queues that no external reader can reach. pub fn finish_ancillary_transition(&mut self) { self.collect_unreachable_ancillary(); } @@ -1279,32 +1415,6 @@ impl PipeTable { return false; }; pipe.retain_in_flight_reference(kind); - } else if fd.file_type == FileType::Socket { - let Some(socket) = fd.socket.as_ref() else { - return true; - }; - if !socket.global_pipes { - return true; - } - - if let Some(send_idx) = socket.send_buf_idx { - let Some(pipe) = self.get_mut(send_idx) else { - return false; - }; - pipe.retain_in_flight_writer(); - } - if let Some(recv_idx) = socket.recv_buf_idx { - let Some(pipe) = self.get_mut(recv_idx) else { - if let Some(send_idx) = socket.send_buf_idx { - if let Some(pipe) = self.get_mut(send_idx) { - pipe.release_in_flight_writer(); - } - self.free_fully_closed_inner(send_idx); - } - return false; - }; - pipe.retain_in_flight_reader(); - } } true } @@ -1318,23 +1428,6 @@ impl PipeTable { pipe.adopt_in_flight_reference(kind); } } - } else if fd.file_type == FileType::Socket { - let Some(socket) = fd.socket.as_ref() else { - return; - }; - if !socket.global_pipes { - return; - } - if let Some(send_idx) = socket.send_buf_idx { - if let Some(pipe) = self.get_mut(send_idx) { - pipe.adopt_in_flight_writer(); - } - } - if let Some(recv_idx) = socket.recv_buf_idx { - if let Some(pipe) = self.get_mut(recv_idx) { - pipe.adopt_in_flight_reader(); - } - } } } @@ -1348,32 +1441,6 @@ impl PipeTable { } } self.free_fully_closed_inner(pipe_idx); - } else if fd.file_type == FileType::Socket { - let Some(socket) = fd.socket.as_ref() else { - return; - }; - if !socket.global_pipes { - return; - } - - if let Some(send_idx) = socket.send_buf_idx { - if let Some(pipe) = self.get_mut(send_idx) { - pipe.release_in_flight_writer(); - } - self.free_fully_closed_inner(send_idx); - } - if let Some(recv_idx) = socket.recv_buf_idx { - if let Some(pipe) = self.get_mut(recv_idx) { - let orderly_tcp_close = socket.sock_type == 0 - && matches!(socket.domain, 1 | 2); - if orderly_tcp_close { - pipe.release_in_flight_reader_orderly(); - } else { - pipe.release_in_flight_reader(); - } - } - self.free_fully_closed_inner(recv_idx); - } } } @@ -1395,76 +1462,38 @@ impl PipeTable { let pipe = self.pipes[idx].take().unwrap(); self.free_list.push(idx); - for fds in pipe.ancillary_fds { + for record in pipe.ancillary_fds { #[cfg(test)] - for fd in &fds { + for fd in &record.fds { if !fd.owns_reference { self.release_ancillary_resource_inner(fd); } } - drop(fds); + drop(record); } } - /// Collect ancillary queues that cannot be reached from an externally - /// owned receive endpoint. Queued socket descriptors form graph edges to - /// their receive pipes; the mark phase preserves every transitively - /// receivable cycle and the sweep drops only components with no root. + /// Drop ancillary queues whose carrier has no externally owned reader. + /// + /// Socket descriptors are rejected before retain, so queued rights no + /// longer form graph edges between carrier pipes. A direct sweep is both + /// sufficient and less likely to imply that lossy socket snapshots exist. fn collect_unreachable_ancillary(&mut self) { - let mut reachable = Vec::new(); - reachable.resize(self.pipes.len(), false); - let mut work = VecDeque::new(); - for (idx, pipe) in self.pipes.iter().enumerate() { - if pipe.as_ref().is_some_and(PipeBuffer::has_external_reader) { - reachable[idx] = true; - work.push_back(idx); - } - } - - while let Some(carrier_idx) = work.pop_front() { - let Some(pipe) = self.get(carrier_idx) else { - continue; - }; - for batch in &pipe.ancillary_fds { - for fd in batch { - if fd.file_type != FileType::Socket { - continue; - } - let Some(socket) = fd.socket.as_ref() else { - continue; - }; - if !socket.global_pipes { - continue; - } - let Some(recv_idx) = socket.recv_buf_idx else { - continue; - }; - if recv_idx < reachable.len() && !reachable[recv_idx] { - reachable[recv_idx] = true; - work.push_back(recv_idx); - } - } - } - } - let mut dropped = Vec::new(); - for (idx, pipe) in self.pipes.iter_mut().enumerate() { - if !reachable[idx] { - if let Some(pipe) = pipe.as_mut() { - dropped.extend(pipe.ancillary_fds.drain(..)); - } + for pipe in self.pipes.iter_mut().flatten() { + if !pipe.has_external_reader() { + dropped.extend(pipe.ancillary_fds.drain(..)); } } for batch in dropped { #[cfg(test)] - for fd in &batch { + for fd in &batch.fds { if !fd.owns_reference { self.release_ancillary_resource_inner(fd); } } drop(batch); } - } /// Release both endpoints of a newly allocated buffer that was never @@ -1487,12 +1516,7 @@ impl PipeTable { self.free_if_closed(idx); } - pub fn remove_fifo_name_at( - &mut self, - idx: usize, - ctime_sec: u64, - ctime_nsec: u32, - ) { + pub fn remove_fifo_name_at(&mut self, idx: usize, ctime_sec: u64, ctime_nsec: u32) { if let Some(pipe) = self.get_mut(idx) { pipe.remove_fifo_name_at(ctime_sec, ctime_nsec); } @@ -1507,10 +1531,7 @@ impl PipeTable { }) } - pub fn take_ready_fifo_open( - &mut self, - owner: u64, - ) -> Option<(usize, FifoOpenWaiter)> { + pub fn take_ready_fifo_open(&mut self, owner: u64) -> Option<(usize, FifoOpenWaiter)> { let idx = self.find_fifo_open(owner)?; let waiter = self.get_mut(idx)?.take_ready_fifo_open(owner)?; Some((idx, waiter)) @@ -1572,6 +1593,18 @@ pub unsafe fn global_pipe_table() -> &'static mut PipeTable { mod tests { use super::*; + fn test_ancillary_fd(id: u64) -> InFlightFd { + InFlightFd::new( + OfdId(id), + None, + FileType::Regular, + wasm_posix_shared::flags::O_RDONLY, + id as i64, + 0, + b"/tmp/right".to_vec(), + ) + } + fn fifo_metadata() -> WasmStat { WasmStat { st_dev: 1, @@ -1615,17 +1648,141 @@ mod tests { assert_eq!(&buf[..11], b"firstsecond"); } + #[test] + fn stream_ancillary_follows_its_carrier_byte_range() { + let mut pipe = PipeBuffer::new(32); + assert_eq!(pipe.write(b"AAAA"), 4); + assert_eq!( + pipe.write_with_ancillary(b"B", vec![test_ancillary_fd(1)]), + Ok(1) + ); + assert_eq!(pipe.write(b"CCCC"), 4); + + let mut first = [0u8; 1]; + let received = pipe.recv_message(&mut first, false).unwrap(); + assert_eq!(received.bytes_read, 1); + assert!(!received.hit_ancillary_barrier); + assert!(received.ancillary_fds.is_none()); + assert_eq!(&first, b"A"); + + let mut through_carrier = [0u8; 8]; + let received = pipe.recv_message(&mut through_carrier, false).unwrap(); + assert_eq!(received.bytes_read, 4); + assert!(received.hit_ancillary_barrier); + assert_eq!(received.ancillary_fds.as_ref().unwrap()[0].ofd_id, OfdId(1)); + assert_eq!(&through_carrier[..4], b"AAAB"); + + let mut tail = [0u8; 8]; + assert_eq!(pipe.read(&mut tail), 4); + assert_eq!(&tail[..4], b"CCCC"); + } + + #[test] + fn stream_receive_stops_at_each_ancillary_barrier() { + let mut pipe = PipeBuffer::new(8); + assert_eq!( + pipe.write_with_ancillary(b"A", vec![test_ancillary_fd(1)]), + Ok(1) + ); + assert_eq!( + pipe.write_with_ancillary(b"B", vec![test_ancillary_fd(2)]), + Ok(1) + ); + + let mut bytes = [0u8; 2]; + let first = pipe.recv_message(&mut bytes, false).unwrap(); + assert_eq!(first.bytes_read, 1); + assert_eq!(first.ancillary_fds.unwrap()[0].ofd_id, OfdId(1)); + assert_eq!(bytes[0], b'A'); + + let second = pipe.recv_message(&mut bytes, false).unwrap(); + assert_eq!(second.bytes_read, 1); + assert_eq!(second.ancillary_fds.unwrap()[0].ofd_id, OfdId(2)); + assert_eq!(bytes[0], b'B'); + } + + #[test] + fn plain_read_discards_crossed_ancillary_batch() { + let mut pipe = PipeBuffer::new(8); + assert_eq!( + pipe.write_with_ancillary(b"A", vec![test_ancillary_fd(1)]), + Ok(1) + ); + let mut byte = [0u8; 1]; + assert_eq!(pipe.read(&mut byte), 1); + assert_eq!(&byte, b"A"); + assert!(!pipe.has_ancillary()); + } + + #[test] + fn stream_ancillary_peek_is_repeatable_and_non_consuming() { + let mut pipe = PipeBuffer::new(8); + assert_eq!( + pipe.write_with_ancillary(b"P", vec![test_ancillary_fd(1)]), + Ok(1) + ); + let mut byte = [0u8; 1]; + for _ in 0..2 { + let peeked = pipe.recv_message(&mut byte, true).unwrap(); + assert_eq!(peeked.bytes_read, 1); + assert_eq!(peeked.ancillary_fds.unwrap()[0].ofd_id, OfdId(1)); + assert!(pipe.has_ancillary()); + } + let consumed = pipe.recv_message(&mut byte, false).unwrap(); + assert_eq!(consumed.ancillary_fds.unwrap()[0].ofd_id, OfdId(1)); + assert!(!pipe.has_ancillary()); + } + + #[test] + fn stream_sequence_rebases_queued_ancillary_near_u64_max() { + let mut pipe = PipeBuffer::new(16); + pipe.stream_position = u64::MAX - 5; + assert_eq!(pipe.write(b"A"), 1); + assert_eq!( + pipe.write_with_ancillary(b"B", vec![test_ancillary_fd(7)]), + Ok(1) + ); + + // This ordinary write would overflow stream_position + buffered bytes. + // It performs the rare checked rebase and keeps the queued range exact. + assert_eq!(pipe.write(b"CDEF"), 4); + assert_eq!(pipe.stream_position, 0); + + let mut bytes = [0u8; 8]; + let received = pipe.recv_message(&mut bytes, false).unwrap(); + assert_eq!(received.bytes_read, 2); + assert_eq!(&bytes[..2], b"AB"); + assert_eq!(received.ancillary_fds.unwrap()[0].ofd_id, OfdId(7)); + assert_eq!(pipe.read(&mut bytes), 4); + assert_eq!(&bytes[..4], b"CDEF"); + } + + #[test] + fn failed_or_partial_stream_send_never_detaches_rights() { + let mut full = PipeBuffer::new(1); + assert_eq!(full.write(b"X"), 1); + assert_eq!( + full.write_with_ancillary(b"Y", vec![test_ancillary_fd(1)]), + Ok(0) + ); + assert!(!full.has_ancillary()); + + let mut partial = PipeBuffer::new(2); + assert_eq!( + partial.write_with_ancillary(b"ABC", vec![test_ancillary_fd(2)]), + Ok(2) + ); + let mut bytes = [0u8; 3]; + let received = partial.recv_message(&mut bytes, false).unwrap(); + assert_eq!(received.bytes_read, 2); + assert_eq!(&bytes[..2], b"AB"); + assert_eq!(received.ancillary_fds.unwrap()[0].ofd_id, OfdId(2)); + } + #[test] fn fifo_reader_observes_writer_close_before_open_resumes() { let mut pipe = PipeBuffer::new_fifo(DEFAULT_PIPE_CAPACITY, fifo_metadata()); - assert!(pipe.reserve_fifo_open( - 1, - FifoOpenSide::Reader, - b"/tmp/fifo".to_vec(), - 0, - 0, - 3, - )); + assert!(pipe.reserve_fifo_open(1, FifoOpenSide::Reader, b"/tmp/fifo".to_vec(), 0, 0, 3,)); pipe.add_fifo_endpoint_ref(FifoOpenSide::Writer); pipe.publish_fifo_open(FifoOpenSide::Writer); @@ -1873,11 +2030,7 @@ mod tests { fd } - fn in_flight_fifo( - pipe_idx: usize, - status_flags: u32, - kind: InFlightPipeRefKind, - ) -> InFlightFd { + fn in_flight_fifo(pipe_idx: usize, status_flags: u32, kind: InFlightPipeRefKind) -> InFlightFd { let mut fd = InFlightFd::new( OfdId(1), None, @@ -1891,44 +2044,6 @@ mod tests { fd } - fn in_flight_socket(send_idx: usize, recv_idx: usize) -> InFlightFd { - let mut fd = InFlightFd::new( - OfdId(1), - None, - FileType::Socket, - wasm_posix_shared::flags::O_RDWR, - -1, - 0, - b"socket".to_vec(), - ); - fd.socket = Some(InFlightSocket { - domain: 0, - sock_type: 0, - protocol: 0, - state: 3, - send_buf_idx: Some(send_idx), - recv_buf_idx: Some(recv_idx), - global_pipes: true, - shut_rd: false, - shut_wr: false, - bind_addr: [0; 4], - bind_port: 0, - peer_addr: [0; 4], - peer_port: 0, - }); - fd - } - - fn close_external_endpoints(table: &mut PipeTable, indices: &[usize]) { - for &idx in indices { - if let Some(pipe) = table.get_mut(idx) { - pipe.close_read_end(); - pipe.close_write_end(); - } - table.free_if_closed(idx); - } - } - #[test] fn scm_rights_reference_becomes_received_pipe_endpoint() { let mut table = PipeTable::new(); @@ -1939,7 +2054,10 @@ mod tests { table.adopt_ancillary_resource(&right); table.finish_ancillary_transition(); table.get_mut(pipe_idx).unwrap().close_read_end(); - assert_eq!(table.get_mut(pipe_idx).unwrap().write(b"still connected"), 15); + assert_eq!( + table.get_mut(pipe_idx).unwrap().write(b"still connected"), + 15 + ); // Receiving transfers the retained reference to the new OFD. Its final // close, not installation, consumes that same reference. @@ -2050,44 +2168,4 @@ mod tests { table.free_if_closed(pipe_idx); assert!(table.get(pipe_idx).is_none()); } - - #[test] - fn unreachable_self_socket_right_cycle_is_collected_and_slots_reused() { - let mut table = PipeTable::new(); - - for _ in 0..32 { - let (carrier_idx, peer_send_idx) = - table.alloc_pair(PipeBuffer::new(64), PipeBuffer::new(64)); - assert_eq!((carrier_idx, peer_send_idx), (0, 1)); - - // The peer socket receives from carrier_idx. Queuing that peer on - // carrier_idx creates the canonical SCM_RIGHTS self-cycle. - let peer = in_flight_socket(peer_send_idx, carrier_idx); - assert!(table.queue_ancillary(carrier_idx, vec![peer])); - assert!(table.get(carrier_idx).unwrap().has_ancillary()); - - close_external_endpoints(&mut table, &[carrier_idx, peer_send_idx]); - assert_eq!(table.count_active(), 0); - } - } - - #[test] - fn unreachable_cross_socket_right_cycle_is_collected() { - let mut table = PipeTable::new(); - let (a_recv_idx, a_send_idx) = - table.alloc_pair(PipeBuffer::new(64), PipeBuffer::new(64)); - let (b_recv_idx, b_send_idx) = - table.alloc_pair(PipeBuffer::new(64), PipeBuffer::new(64)); - - let a = in_flight_socket(a_send_idx, a_recv_idx); - let b = in_flight_socket(b_send_idx, b_recv_idx); - assert!(table.queue_ancillary(a_recv_idx, vec![b])); - assert!(table.queue_ancillary(b_recv_idx, vec![a])); - - close_external_endpoints( - &mut table, - &[a_recv_idx, a_send_idx, b_recv_idx, b_send_idx], - ); - assert_eq!(table.count_active(), 0); - } } diff --git a/crates/kernel/src/process.rs b/crates/kernel/src/process.rs index f84796b0ef..d32e3303fc 100644 --- a/crates/kernel/src/process.rs +++ b/crates/kernel/src/process.rs @@ -558,7 +558,8 @@ pub struct TimerFdState { pub struct PosixTimerState { pub clock_id: u32, pub sigev_signo: u32, - pub sigev_value: i32, + /// Raw `union sigval` bits, zero-extended when supplied by wasm32. + pub sigev_value_bits: u64, /// Kernel-facing notification mode (`SIGEV_SIGNAL`, `SIGEV_NONE`, or /// Linux's `SIGEV_THREAD_ID`). musl implements POSIX `SIGEV_THREAD` by /// creating a helper pthread and asking the kernel to target that TID. @@ -607,7 +608,10 @@ fn posix_timer_notification_validates_and_normalizes_signals() { assert_eq!(normalize_posix_timer_signo(SIGEV_SIGNAL, 64).unwrap(), 64); assert!(normalize_posix_timer_signo(SIGEV_SIGNAL, 0).is_err()); assert!(normalize_posix_timer_signo(SIGEV_SIGNAL, 65).is_err()); - assert_eq!(normalize_posix_timer_signo(SIGEV_THREAD_ID, 14).unwrap(), 14); + assert_eq!( + normalize_posix_timer_signo(SIGEV_THREAD_ID, 14).unwrap(), + 14 + ); assert!(normalize_posix_timer_signo(SIGEV_THREAD_ID, 0).is_err()); } @@ -715,7 +719,8 @@ pub struct Process { pub rlimits: [[u64; 2]; 16], // [soft, hard] pairs for each resource pub alarm_deadline_ns: u64, pub alarm_interval_ns: u64, - pub thread_name: [u8; 16], + pub thread_name: + [u8; wasm_posix_shared::kernel_scratch_wire::PRCTL_NAME_BYTES as usize], /// True if this process is a fork child that should exec on startup. pub fork_child: bool, /// Saved signal mask during sigsuspend host retry. @@ -741,9 +746,9 @@ pub struct Process { /// POSIX timers (timer_create / timer_settime). pub posix_timers: Vec>, /// Alternate signal stack (sigaltstack): ss_sp, ss_flags, ss_size. - pub alt_stack_sp: usize, + pub alt_stack_sp: u64, pub alt_stack_flags: u32, - pub alt_stack_size: usize, + pub alt_stack_size: u64, /// Number of nested signal handlers running with SA_ONSTACK on alt stack. /// When > 0, SS_ONSTACK is set in alt_stack_flags. pub alt_stack_depth: u32, @@ -947,7 +952,8 @@ impl Process { rlimits, alarm_deadline_ns: 0, alarm_interval_ns: 0, - thread_name: [0u8; 16], + thread_name: + [0u8; wasm_posix_shared::kernel_scratch_wire::PRCTL_NAME_BYTES as usize], fork_child: false, sigsuspend_saved_mask: None, fork_exec_path: None, @@ -991,13 +997,7 @@ impl Process { self.fork_count += 1; } - fn set_wait_event( - &mut self, - event_mask: u32, - wait_status: i32, - si_code: i32, - si_status: i32, - ) { + fn set_wait_event(&mut self, event_mask: u32, wait_status: i32, si_code: i32, si_status: i32) { self.wait_event = Some(ChildWaitEvent { event_mask, wait_status, @@ -1092,9 +1092,7 @@ impl Process { /// Apply process-control effects when a signal is generated, before the /// signal is queued or tested against a mask/disposition. fn prepare_signal_generation(&mut self, signum: u32) { - use wasm_posix_shared::signal::{ - NSIG, SIGCONT, SIGSTOP, SIGTSTP, SIGTTIN, SIGTTOU, - }; + use wasm_posix_shared::signal::{NSIG, SIGCONT, SIGSTOP, SIGTSTP, SIGTTIN, SIGTTOU}; if signum == 0 || signum >= NSIG { return; @@ -1116,9 +1114,9 @@ impl Process { self.signals.raise(signum) } - pub fn raise_signal_with_value(&mut self, signum: u32, si_value: i32) -> bool { + pub fn raise_signal_with_value(&mut self, signum: u32, si_value_bits: u64) -> bool { self.prepare_signal_generation(signum); - self.signals.raise_with_value(signum, si_value) + self.signals.raise_with_value(signum, si_value_bits) } /// Queue a process-directed signal with the generation metadata exposed @@ -1127,15 +1125,19 @@ impl Process { pub(crate) fn raise_signal_with_metadata( &mut self, signum: u32, - si_value: i32, + si_value_bits: u64, si_code: i32, + sender_pid: u32, + sender_uid: u32, ) -> bool { - debug_assert!(matches!(si_code, 0 | -1)); - if si_code == 0 { - self.raise_signal(signum) - } else { - self.raise_signal_with_value(signum, si_value) - } + self.prepare_signal_generation(signum); + self.signals.raise_with_metadata( + signum, + si_value_bits, + si_code, + sender_pid, + sender_uid, + ) } /// Compatibility helper for the legacy pipe slot vector, reusing the first @@ -1454,7 +1456,7 @@ impl Process { &mut self, tid: u32, signum: u32, - si_value: i32, + si_value_bits: u64, ) -> bool { if signum == 0 || signum >= wasm_posix_shared::signal::NSIG { return false; @@ -1469,9 +1471,51 @@ impl Process { } if self.is_main_thread(tid) { self.main_thread_signals - .raise_with_value(signum, si_value) + .raise_with_value(signum, si_value_bits) + } else if let Some(thread) = self.get_thread_mut(tid) { + thread.signals.raise_with_value(signum, si_value_bits) + } else { + false + } + } + + /// Queue a directed signal with authoritative sender metadata. + pub(crate) fn raise_for_thread_with_metadata( + &mut self, + tid: u32, + signum: u32, + si_value_bits: u64, + si_code: i32, + sender_pid: u32, + sender_uid: u32, + ) -> bool { + if signum == 0 || signum >= wasm_posix_shared::signal::NSIG { + return false; + } + if !self.is_main_thread(tid) && self.get_thread(tid).is_none() { + return false; + } + self.prepare_signal_generation(signum); + let handler = self.signals.get_handler(signum); + if crate::signal::should_discard_pending(signum, &handler) { + return true; + } + if self.is_main_thread(tid) { + self.main_thread_signals.raise_with_metadata( + signum, + si_value_bits, + si_code, + sender_pid, + sender_uid, + ) } else if let Some(thread) = self.get_thread_mut(tid) { - thread.signals.raise_with_value(signum, si_value) + thread.signals.raise_with_metadata( + signum, + si_value_bits, + si_code, + sender_pid, + sender_uid, + ) } else { false } @@ -1483,7 +1527,7 @@ impl Process { &mut self, tid: u32, signum: u32, - si_value: i32, + si_value_bits: u64, timer_id: u32, ) -> bool { if signum == 0 || signum >= wasm_posix_shared::signal::NSIG { @@ -1499,9 +1543,11 @@ impl Process { } if self.is_main_thread(tid) { self.main_thread_signals - .raise_timer(signum, si_value, timer_id) + .raise_timer(signum, si_value_bits, timer_id) } else if let Some(thread) = self.get_thread_mut(tid) { - thread.signals.raise_timer(signum, si_value, timer_id) + thread + .signals + .raise_timer(signum, si_value_bits, timer_id) } else { false } @@ -1614,9 +1660,7 @@ impl Process { /// Purge a deleted timer's queued notification before its slot is reused. pub fn remove_posix_timer_notification(&mut self, timer_id: u32) -> bool { let mut removed = self.signals.remove_timer_notification(timer_id); - removed |= self - .main_thread_signals - .remove_timer_notification(timer_id); + removed |= self.main_thread_signals.remove_timer_notification(timer_id); for thread in &mut self.identity.threads { removed |= thread .state_mut() @@ -1905,8 +1949,7 @@ mod tests { fn child_status_record_is_replaced_by_each_new_transition() { use wasm_posix_shared::signal::{SIGCONT, SIGTERM, SIGTSTP}; use wasm_posix_shared::wait::{ - CLD_CONTINUED, CLD_KILLED, CLD_STOPPED, EVENT_CONTINUED, EVENT_EXITED, - EVENT_STOPPED, + CLD_CONTINUED, CLD_KILLED, CLD_STOPPED, EVENT_CONTINUED, EVENT_EXITED, EVENT_STOPPED, }; let mut proc = Process::new(41); @@ -2020,7 +2063,7 @@ mod tests { proc.posix_timers.push(Some(PosixTimerState { clock_id: 1, sigev_signo: 32, - sigev_value: 7, + sigev_value_bits: 7, sigev_notify: 0, sigev_tid: 0, interval_sec: 0, @@ -2092,7 +2135,7 @@ mod tests { proc.posix_timers.push(Some(PosixTimerState { clock_id: 1, sigev_signo: 10, - sigev_value: 7, + sigev_value_bits: 7, sigev_notify: 0, sigev_tid: 0, interval_sec: 0, @@ -2121,7 +2164,7 @@ mod tests { proc.posix_timers.push(Some(PosixTimerState { clock_id: 1, sigev_signo: 10, - sigev_value: 7, + sigev_value_bits: 7, sigev_notify: 4, sigev_tid: 2, interval_sec: 0, @@ -2132,13 +2175,16 @@ mod tests { overrun_current: 2, overrun_last: 0, })); - proc.get_thread_mut(2).unwrap().signals.raise_timer(10, 7, 0); + proc.get_thread_mut(2) + .unwrap() + .signals + .raise_timer(10, 7, 0); assert!(proc.remove_posix_timer_notification(0)); proc.posix_timers[0] = Some(PosixTimerState { clock_id: 1, sigev_signo: 10, - sigev_value: 8, + sigev_value_bits: 8, sigev_notify: 0, sigev_tid: 0, interval_sec: 0, @@ -2196,7 +2242,8 @@ mod tests { let mut host = test_host::NoopHost; let child_pid = table .spawn_child_for_caller( - parent_pid, parent_pid, + parent_pid, + parent_pid, &[b"/bin/echo".as_slice(), b"hi".as_slice()], &[b"PATH=/bin".as_slice()], &[], @@ -2254,7 +2301,8 @@ mod tests { let mut host = test_host::NoopHost; let _child_pid = table .spawn_child_for_caller( - parent_pid, parent_pid, + parent_pid, + parent_pid, &[b"a".as_slice()], &[], &[], @@ -2270,7 +2318,9 @@ mod tests { ); // Same slot should also bump on fork — the helper is shared. - table.fork_process_for_caller(parent_pid, parent_pid).expect("fork_process"); + table + .fork_process_for_caller(parent_pid, parent_pid) + .expect("fork_process"); let after_fork = unsafe { shared_listener_backlog_table().entries[backlog_idx].ref_count }; assert_eq!( after_fork, 3, @@ -2311,7 +2361,8 @@ mod tests { let mut host = test_host::NoopHost; let _child = table .spawn_child_for_caller( - parent_pid, parent_pid, + parent_pid, + parent_pid, &[b"a".as_slice()], &[], &[], @@ -2326,7 +2377,9 @@ mod tests { ); // Forking again bumps once more. - table.fork_process_for_caller(parent_pid, parent_pid).expect("fork_process"); + table + .fork_process_for_caller(parent_pid, parent_pid) + .expect("fork_process"); assert_eq!( host_net_handle_ref_count(HANDLE), 3, @@ -2397,7 +2450,8 @@ mod tests { let mut host = test_host::NoopHost; let child_pid = table .spawn_child_for_caller( - parent_pid, parent_pid, + parent_pid, + parent_pid, &[b"a".as_slice()], &[], &[], @@ -2463,7 +2517,8 @@ mod tests { let mut host = test_host::NoopHost; let spawn_child = table .spawn_child_for_caller( - parent_pid, parent_pid, + parent_pid, + parent_pid, &[b"a".as_slice()], &[], &[], @@ -2484,7 +2539,9 @@ mod tests { ); // Fork child must NOT inherit them either. - let fork_child = table.fork_process_for_caller(parent_pid, parent_pid).expect("fork_process"); + let fork_child = table + .fork_process_for_caller(parent_pid, parent_pid) + .expect("fork_process"); assert!( table .get(fork_child) @@ -2538,7 +2595,8 @@ mod tests { let mut host = test_host::NoopHost; let child_pid = table .spawn_child_for_caller( - parent_pid, parent_pid, + parent_pid, + parent_pid, &[b"a".as_slice()], &[], &[], @@ -2596,7 +2654,9 @@ mod tests { .alloc(crate::fd::OpenFileDescRef(ofd_idx), 0) .unwrap(); - let child_pid = table.fork_process_for_caller(parent_pid, parent_pid).expect("fork_process"); + let child_pid = table + .fork_process_for_caller(parent_pid, parent_pid) + .expect("fork_process"); assert_eq!(host_handle_ref_count(HANDLE), 2); let child = table.remove_process(child_pid).expect("remove child"); @@ -2674,7 +2734,8 @@ mod tests { let mut host = test_host::NoopHost; let child_pid = table .spawn_child_for_caller( - parent_pid, parent_pid, + parent_pid, + parent_pid, &[b"a".as_slice()], &[], &[FileAction::Close { fd: 5 }], @@ -2728,7 +2789,8 @@ mod tests { let mut host = test_host::NoopHost; let child_pid = table .spawn_child_for_caller( - parent_pid, parent_pid, + parent_pid, + parent_pid, &[b"a".as_slice()], &[], &[FileAction::Dup2 { srcfd: 5, fd: 1 }], @@ -2773,7 +2835,8 @@ mod tests { let mut host = test_host::NoopHost; let err = table .spawn_child_for_caller( - parent_pid, parent_pid, + parent_pid, + parent_pid, &[b"a".as_slice()], &[], &[FileAction::Dup2 { srcfd: 999, fd: 1 }], @@ -2812,7 +2875,15 @@ mod tests { }; let mut host = test_host::NoopHost; let cpid = table - .spawn_child_for_caller(parent_pid, parent_pid, &[b"a".as_slice()], &[], &[], &attrs, &mut host) + .spawn_child_for_caller( + parent_pid, + parent_pid, + &[b"a".as_slice()], + &[], + &[], + &attrs, + &mut host, + ) .unwrap(); let child = table.get(cpid).unwrap(); @@ -2840,7 +2911,15 @@ mod tests { }; let mut host = test_host::NoopHost; let cpid = table - .spawn_child_for_caller(parent_pid, parent_pid, &[b"a".as_slice()], &[], &[], &attrs, &mut host) + .spawn_child_for_caller( + parent_pid, + parent_pid, + &[b"a".as_slice()], + &[], + &[], + &attrs, + &mut host, + ) .unwrap(); assert_eq!( table.get(cpid).unwrap().pgid, @@ -2865,7 +2944,15 @@ mod tests { }; let mut host = test_host::NoopHost; let cpid = table - .spawn_child_for_caller(parent_pid, parent_pid, &[b"a".as_slice()], &[], &[], &attrs, &mut host) + .spawn_child_for_caller( + parent_pid, + parent_pid, + &[b"a".as_slice()], + &[], + &[], + &attrs, + &mut host, + ) .unwrap(); assert_eq!(table.get(cpid).unwrap().pgid, 42); } @@ -2893,7 +2980,15 @@ mod tests { }; let mut host = test_host::NoopHost; let cpid = table - .spawn_child_for_caller(parent_pid, parent_pid, &[b"a".as_slice()], &[], &[], &attrs, &mut host) + .spawn_child_for_caller( + parent_pid, + parent_pid, + &[b"a".as_slice()], + &[], + &[], + &attrs, + &mut host, + ) .unwrap(); assert_eq!( table.get(cpid).unwrap().signals.blocked, @@ -2920,7 +3015,8 @@ mod tests { let mut host = test_host::NoopHost; let cpid = table .spawn_child_for_caller( - parent_pid, parent_pid, + parent_pid, + parent_pid, &[b"a".as_slice()], &[], &[], @@ -2955,7 +3051,8 @@ mod tests { let mut host = test_host::NoopHost; let cpid = table .spawn_child_for_caller( - parent_pid, parent_pid, + parent_pid, + parent_pid, &[b"a".as_slice()], &[], &[], @@ -3007,7 +3104,15 @@ mod tests { }; let mut host = test_host::NoopHost; let cpid = table - .spawn_child_for_caller(parent_pid, parent_pid, &[b"a".as_slice()], &[], &[], &attrs, &mut host) + .spawn_child_for_caller( + parent_pid, + parent_pid, + &[b"a".as_slice()], + &[], + &[], + &attrs, + &mut host, + ) .unwrap(); let child = table.get(cpid).unwrap(); @@ -3023,10 +3128,18 @@ mod tests { // Sanity: counter starts at 0. assert_eq!(table.get(100).unwrap().fork_count(), 0); - assert_eq!(table.fork_process_for_caller(100, 100).expect("first fork"), 101); + assert_eq!( + table.fork_process_for_caller(100, 100).expect("first fork"), + 101 + ); assert_eq!(table.get(100).unwrap().fork_count(), 1); - assert_eq!(table.fork_process_for_caller(100, 100).expect("second fork"), 102); + assert_eq!( + table + .fork_process_for_caller(100, 100) + .expect("second fork"), + 102 + ); assert_eq!(table.get(100).unwrap().fork_count(), 2); // Children's counters are independent and start at 0 — they have not @@ -3102,14 +3215,16 @@ mod tests { use wasm_posix_shared::signal::SIGUSR1; let mut proc = Process::new(100); - proc.raise_signal_with_metadata(SIGUSR1, 0, 0); + proc.raise_signal_with_metadata(SIGUSR1, 0, 0, 41, 42); let plain = proc.consume_signal_for(proc.pid, SIGUSR1).unwrap(); assert_eq!(plain.si_code, 0); - assert_eq!(plain.si_value, 0); + assert_eq!(plain.si_value_bits, 0); + assert_eq!((plain.sender_pid, plain.sender_uid), (41, 42)); - proc.raise_signal_with_metadata(SIGUSR1, 0x1234, -1); + proc.raise_signal_with_metadata(SIGUSR1, 0x1234, -1, 51, 52); let queued = proc.consume_signal_for(proc.pid, SIGUSR1).unwrap(); assert_eq!(queued.si_code, -1); - assert_eq!(queued.si_value, 0x1234); + assert_eq!(queued.si_value_bits, 0x1234); + assert_eq!((queued.sender_pid, queued.sender_uid), (51, 52)); } } diff --git a/crates/kernel/src/process_table.rs b/crates/kernel/src/process_table.rs index 4b2f25ad53..9e43668879 100644 --- a/crates/kernel/src/process_table.rs +++ b/crates/kernel/src/process_table.rs @@ -422,7 +422,7 @@ impl ProcessTable { if pid == SYNTHETIC_INIT_PID { return None; } - let proc = self.processes.remove(&pid)?; + let mut proc = self.processes.remove(&pid)?; let _ = unsafe { crate::pipe::global_pipe_table().cancel_fifo_opens_for_process(pid) }; let mut host_closes: Vec = Vec::new(); let mut host_dir_closes: Vec = Vec::new(); @@ -602,6 +602,17 @@ impl ProcessTable { } } + // WHY: AF_UNIX datagrams can own retained SCM_RIGHTS descriptors. + // `proc` remains live until this function returns, but the deferred + // release queue is drained below. Drop every queued datagram now so + // crash/forced-removal cleanup observes those releases in this same + // transaction rather than leaving them for an unrelated later syscall. + for sock_idx in 0..proc.sockets.len() { + if let Some(sock) = proc.sockets.get_mut(sock_idx) { + sock.dgram_queue.clear(); + } + } + // Clean up mqueue notifications for this process let mq_table = unsafe { crate::mqueue::global_mqueue_table() }; mq_table.cleanup_process(pid); @@ -631,10 +642,8 @@ impl ProcessTable { host_closes.push(handle); } if released.final_ofd_reference { - deferred_lock_state_changed |= self - .advisory_locks - .remove_ofd(released.ofd_id) - .changed; + deferred_lock_state_changed |= + self.advisory_locks.remove_ofd(released.ofd_id).changed; } } @@ -887,8 +896,11 @@ impl ProcessTable { &self.advisory_locks } - #[cfg(test)] - pub fn advisory_locks_mut(&mut self) -> &mut AdvisoryLockManager { + /// Borrow the machine lock manager for resource cleanup that has no live + /// process owner, such as a direct host-pipe operation dropping queued + /// SCM_RIGHTS. This is crate-private so callers cannot bypass the + /// process-and-lock paired access used by ordinary syscalls. + pub(crate) fn advisory_locks_mut(&mut self) -> &mut AdvisoryLockManager { &mut self.advisory_locks } @@ -1146,12 +1158,7 @@ impl ProcessTable { match action { FileAction::Close { fd } => { // POSIX: close errors are silently ignored for spawn. - let _ = crate::syscalls::sys_close_with_locks( - child, - advisory_locks, - host, - *fd, - ); + let _ = crate::syscalls::sys_close_with_locks(child, advisory_locks, host, *fd); } FileAction::Dup2 { srcfd, fd } => { if srcfd == fd { @@ -1225,8 +1232,7 @@ impl ProcessTable { for fd in cloexec_fds { // POSIX: close errors here are silently ignored — same policy // as the FileAction::Close handler above. - let _ = - crate::syscalls::sys_close_with_locks(child, advisory_locks, host, fd); + let _ = crate::syscalls::sys_close_with_locks(child, advisory_locks, host, fd); } Ok(()) @@ -1293,9 +1299,7 @@ impl ProcessTable { /// Keeping lifecycle filtering here prevents kernel subsystems from /// treating the immutable synthetic init record or retained exited records /// as runnable processes while scanning machine-wide state. - pub(crate) fn live_processes_descending( - &self, - ) -> impl Iterator { + pub(crate) fn live_processes_descending(&self) -> impl Iterator { self.processes.iter().rev().filter_map(|(&pid, process)| { if pid == SYNTHETIC_INIT_PID || matches!(process.state, ProcessState::Exited | ProcessState::Limbo) @@ -1372,20 +1376,14 @@ impl ProcessTable { event_mask: u32, flags: u32, ) -> Result, Errno> { - use wasm_posix_shared::wait::{ - EVENT_CONTINUED, EVENT_EXITED, EVENT_STOPPED, WNOWAIT, - }; + use wasm_posix_shared::wait::{EVENT_CONTINUED, EVENT_EXITED, EVENT_STOPPED, WNOWAIT}; let valid_events = EVENT_EXITED | EVENT_STOPPED | EVENT_CONTINUED; if event_mask == 0 || event_mask & !valid_events != 0 || flags & !WNOWAIT != 0 { return Err(Errno::EINVAL); } - let parent_pgid = self - .processes - .get(&parent_pid) - .ok_or(Errno::ESRCH)? - .pgid; + let parent_pgid = self.processes.get(&parent_pid).ok_or(Errno::ESRCH)?.pgid; let mut saw_matching_child = false; for (&child_pid, child) in &mut self.processes { @@ -1463,11 +1461,14 @@ mod wait_tests { let tid = table .create_thread(parent_pid, parent_pid, 0x1000, 0, 0) .unwrap(); - let fork_pid = table.fork_process_for_caller(parent_pid, parent_pid).unwrap(); + let fork_pid = table + .fork_process_for_caller(parent_pid, parent_pid) + .unwrap(); let mut host = NoopHost; let spawn_pid = table .spawn_child_for_caller( - parent_pid, parent_pid, + parent_pid, + parent_pid, &[b"/bin/child".as_slice()], &[], &[], @@ -1835,9 +1836,10 @@ mod tests { let read_ofd = child .ofd_table .create(FileType::Pipe, O_RDONLY, -1, b"pipe-read".to_vec()); - let write_ofd = child - .ofd_table - .create(FileType::Pipe, O_WRONLY, -1, b"pipe-write".to_vec()); + let write_ofd = + child + .ofd_table + .create(FileType::Pipe, O_WRONLY, -1, b"pipe-write".to_vec()); let read_fd = child .fd_table .alloc_at_min(OpenFileDescRef(read_ofd), 0, 2048) @@ -1937,10 +1939,7 @@ mod tests { d_type: 8, name: b"pending".to_vec(), }); - parent - .fd_table - .alloc(OpenFileDescRef(ofd_idx), 0) - .unwrap() + parent.fd_table.alloc(OpenFileDescRef(ofd_idx), 0).unwrap() }; let mut host = NoopHost; @@ -1956,7 +1955,12 @@ mod tests { ) .unwrap(); - let child_entry = table.get(child_pid).unwrap().fd_table.get(inherited_fd).unwrap(); + let child_entry = table + .get(child_pid) + .unwrap() + .fd_table + .get(inherited_fd) + .unwrap(); let child_ofd = table .get(child_pid) .unwrap() @@ -1982,7 +1986,10 @@ mod tests { .get(parent_entry.ofd_ref.0) .unwrap(); assert_eq!(parent_ofd.dir_host_handle, ITERATOR_HANDLE); - assert_eq!(parent_ofd.dir_pending_entry.as_ref().unwrap().name, b"pending"); + assert_eq!( + parent_ofd.dir_pending_entry.as_ref().unwrap().name, + b"pending" + ); // Crash/rollback-style child cleanup must not close the iterator that // remains owned by the parent. Parent cleanup releases it exactly once. @@ -2028,10 +2035,7 @@ mod tests { d_type: 8, name: b"next".to_vec(), }); - parent - .fd_table - .alloc(OpenFileDescRef(ofd_idx), 0) - .unwrap() + parent.fd_table.alloc(OpenFileDescRef(ofd_idx), 0).unwrap() }; let child_pid = table @@ -2070,7 +2074,7 @@ mod tests { #[test] fn process_exit_closes_tcp_pipes_orderly() { - use crate::pipe::{global_pipe_table, PipeBuffer, DEFAULT_PIPE_CAPACITY}; + use crate::pipe::{DEFAULT_PIPE_CAPACITY, PipeBuffer, global_pipe_table}; use crate::socket::{SocketDomain, SocketInfo, SocketState, SocketType}; let pipe_table = unsafe { global_pipe_table() }; @@ -2200,7 +2204,10 @@ mod tests { assert_eq!(table.create_process().unwrap(), PARENT); let sock_idx = install_bound_udp4_socket(&mut table, PARENT, PORT); - assert_eq!(table.fork_process_for_caller(PARENT, PARENT).unwrap(), CHILD); + assert_eq!( + table.fork_process_for_caller(PARENT, PARENT).unwrap(), + CHILD + ); assert_udp_owner(PORT, PARENT, sock_idx, true); assert_udp_owner(PORT, CHILD, sock_idx, true); @@ -2243,7 +2250,8 @@ mod tests { let child_pid = table .spawn_child_for_caller( - PARENT, PARENT, + PARENT, + PARENT, &[b"/bin/child".as_slice()], &[], &[], diff --git a/crates/kernel/src/process_wire.rs b/crates/kernel/src/process_wire.rs new file mode 100644 index 0000000000..8d7e8d1ec6 --- /dev/null +++ b/crates/kernel/src/process_wire.rs @@ -0,0 +1,1346 @@ +//! Caller-native syscall structure parsing and serialization. +//! +//! These helpers operate on already capacity-bounded kernel scratch slices. +//! They keep the process data model explicit so a wasm32 kernel cannot +//! accidentally parse a wasm64 caller using its own target layout. + +use core::convert::TryFrom; + +use wasm_posix_shared::{Errno, WasmStat, WasmStatfs, process_layout}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ProcessDataModel { + Wasm32, + Wasm64, +} + +impl ProcessDataModel { + pub(crate) fn from_width(width: i64) -> Result { + match width { + value if value == process_layout::WASM32_POINTER_WIDTH as i64 => Ok(Self::Wasm32), + value if value == process_layout::WASM64_POINTER_WIDTH as i64 => Ok(Self::Wasm64), + _ => Err(Errno::EINVAL), + } + } + + pub(crate) const fn width(self) -> u32 { + match self { + Self::Wasm32 => process_layout::WASM32_POINTER_WIDTH, + Self::Wasm64 => process_layout::WASM64_POINTER_WIDTH, + } + } + + pub(crate) const fn max_pointer(self) -> u64 { + match self { + Self::Wasm32 => u32::MAX as u64, + Self::Wasm64 => u64::MAX, + } + } + + const fn native_long_bytes(self) -> usize { + self.width() as usize + } + + pub(crate) const fn sigaltstack_size(self) -> usize { + match self { + Self::Wasm32 => process_layout::sigaltstack::WASM32_SIZE as usize, + Self::Wasm64 => process_layout::sigaltstack::WASM64_SIZE as usize, + } + } + + pub(crate) const fn itimerval_size(self) -> usize { + match self { + Self::Wasm32 => process_layout::itimerval::WASM32_SIZE as usize, + Self::Wasm64 => process_layout::itimerval::WASM64_SIZE as usize, + } + } + + pub(crate) const fn mq_attr_size(self) -> usize { + match self { + Self::Wasm32 => process_layout::mq_attr::WASM32_SIZE as usize, + Self::Wasm64 => process_layout::mq_attr::WASM64_SIZE as usize, + } + } + + pub(crate) const fn sigevent_size(self) -> usize { + match self { + Self::Wasm32 => process_layout::sigevent::WASM32_SIZE as usize, + Self::Wasm64 => process_layout::sigevent::WASM64_SIZE as usize, + } + } + + pub(crate) const fn statfs_size(self) -> usize { + match self { + Self::Wasm32 => process_layout::statfs::WASM32_SIZE as usize, + Self::Wasm64 => process_layout::statfs::WASM64_SIZE as usize, + } + } + + pub(crate) const fn sysinfo_size(self) -> usize { + match self { + Self::Wasm32 => process_layout::sysinfo::WASM32_SIZE as usize, + Self::Wasm64 => process_layout::sysinfo::WASM64_SIZE as usize, + } + } + + pub(crate) const fn siginfo_size(self) -> usize { + match self { + Self::Wasm32 => process_layout::rt_sigqueueinfo::WASM32_SIZE as usize, + Self::Wasm64 => process_layout::rt_sigqueueinfo::WASM64_SIZE as usize, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct NativeSigaltstack { + pub(crate) sp: u64, + pub(crate) flags: u32, + pub(crate) size: u64, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct NativeMqAttr { + pub(crate) flags: u32, + pub(crate) maxmsg: u32, + pub(crate) msgsize: u32, + pub(crate) curmsgs: u32, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct NativeSigevent { + /// Raw caller-native `union sigval` bits, zero-extended for wasm32. + pub(crate) value_bits: u64, + pub(crate) signo: u32, + pub(crate) notify: u32, + /// `sigev_notify_thread_id` when `notify` is `SIGEV_THREAD_ID`. + pub(crate) thread_id: u32, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct NativeRtSigqueueinfo { + pub(crate) pid: i32, + pub(crate) uid: u32, + /// Raw caller-native `union sigval` bits, zero-extended for wasm32. + pub(crate) value_bits: u64, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct NativeSiginfo { + pub(crate) signo: i32, + pub(crate) code: i32, + pub(crate) word_1: i32, + pub(crate) word_2_bits: u32, + /// Raw caller-native `union sigval` bits, zero-extended for wasm32. + pub(crate) value_bits: u64, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct SignalDeliveryRecord { + pub(crate) signum: u32, + pub(crate) handler: u32, + pub(crate) flags: u32, + /// Raw `union sigval` bits. The delivery wire always uses the widest + /// supported pointer width so wasm64 values are never narrowed. + pub(crate) si_value_bits: u64, + pub(crate) old_mask: u64, + pub(crate) si_code: i32, + pub(crate) siginfo_word_1: i32, + pub(crate) siginfo_word_2: i32, + pub(crate) alt_sp: u64, + pub(crate) alt_size: u64, +} + +pub(crate) const SIGALTSTACK_SS_DISABLE: u32 = 2; + +pub(crate) fn validate_sigaltstack_range( + stack: NativeSigaltstack, + model: ProcessDataModel, +) -> Result<(), Errno> { + if stack.flags & SIGALTSTACK_SS_DISABLE != 0 { + return Ok(()); + } + let end = stack.sp.checked_add(stack.size).ok_or(Errno::EOVERFLOW)?; + // WHY: state is stored without narrowing, but libc eventually converts + // both fields back to the caller's pointer and size types and computes the + // exclusive stack top. Prove each conversion and the addition now, before + // any output or process state is replaced. + if stack.sp > model.max_pointer() + || stack.size > model.max_pointer() + || end > model.max_pointer() + { + return Err(Errno::EOVERFLOW); + } + Ok(()) +} + +pub(crate) fn validate_signal_delivery_output( + out_ptr: *mut u8, + out_capacity: u32, +) -> Result<(), Errno> { + if out_ptr.is_null() { + return Err(Errno::EFAULT); + } + if out_capacity != wasm_posix_shared::kernel_scratch_wire::SIGNAL_DELIVERY_BYTES { + return Err(Errno::EINVAL); + } + Ok(()) +} + +pub(crate) fn encode_signal_delivery_record( + record: SignalDeliveryRecord, +) -> [u8; wasm_posix_shared::kernel_scratch_wire::SIGNAL_DELIVERY_BYTES as usize] { + use wasm_posix_shared::kernel_scratch_wire as wire; + + fn write_field(buf: &mut [u8], offset: usize, bytes: [u8; N]) { + buf[offset..offset + N].copy_from_slice(&bytes); + } + + let mut buf = [0; wire::SIGNAL_DELIVERY_BYTES as usize]; + write_field(&mut buf, wire::SIGNAL_SIGNUM_OFFSET, record.signum.to_le_bytes()); + write_field(&mut buf, wire::SIGNAL_HANDLER_OFFSET, record.handler.to_le_bytes()); + write_field(&mut buf, wire::SIGNAL_FLAGS_OFFSET, record.flags.to_le_bytes()); + write_field( + &mut buf, + wire::SIGNAL_SI_VALUE_OFFSET, + record.si_value_bits.to_le_bytes(), + ); + write_field(&mut buf, wire::SIGNAL_OLD_MASK_OFFSET, record.old_mask.to_le_bytes()); + write_field(&mut buf, wire::SIGNAL_SI_CODE_OFFSET, record.si_code.to_le_bytes()); + write_field( + &mut buf, + wire::SIGNAL_SIGINFO_WORD_1_OFFSET, + record.siginfo_word_1.to_le_bytes(), + ); + write_field( + &mut buf, + wire::SIGNAL_SIGINFO_WORD_2_OFFSET, + record.siginfo_word_2.to_le_bytes(), + ); + write_field(&mut buf, wire::SIGNAL_ALT_SP_OFFSET, record.alt_sp.to_le_bytes()); + write_field( + &mut buf, + wire::SIGNAL_ALT_SIZE_OFFSET, + record.alt_size.to_le_bytes(), + ); + buf +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct NativeSchedParam { + pub(crate) priority: i32, + pub(crate) ss_max_repl: i32, + pub(crate) ss_repl_period_sec: i64, + pub(crate) ss_repl_period_nsec: i64, + pub(crate) ss_init_budget_sec: i64, + pub(crate) ss_init_budget_nsec: i64, + pub(crate) ss_low_priority: i32, +} + +fn require_len(bytes: &[u8], expected: usize) -> Result<(), Errno> { + if bytes.len() == expected { + Ok(()) + } else { + Err(Errno::EINVAL) + } +} + +fn require_len_mut(bytes: &mut [u8], expected: usize) -> Result<(), Errno> { + if bytes.len() == expected { + Ok(()) + } else { + Err(Errno::EINVAL) + } +} + +fn read_i32(bytes: &[u8], offset: usize) -> Result { + let field = bytes.get(offset..offset + 4).ok_or(Errno::EINVAL)?; + Ok(i32::from_le_bytes( + field.try_into().map_err(|_| Errno::EINVAL)?, + )) +} + +fn read_u32(bytes: &[u8], offset: usize) -> Result { + let field = bytes.get(offset..offset + 4).ok_or(Errno::EINVAL)?; + Ok(u32::from_le_bytes( + field.try_into().map_err(|_| Errno::EINVAL)?, + )) +} + +fn read_i64(bytes: &[u8], offset: usize) -> Result { + let field = bytes.get(offset..offset + 8).ok_or(Errno::EINVAL)?; + Ok(i64::from_le_bytes( + field.try_into().map_err(|_| Errno::EINVAL)?, + )) +} + +fn read_u64(bytes: &[u8], offset: usize) -> Result { + let field = bytes.get(offset..offset + 8).ok_or(Errno::EINVAL)?; + Ok(u64::from_le_bytes( + field.try_into().map_err(|_| Errno::EINVAL)?, + )) +} + +fn write_i32(bytes: &mut [u8], offset: usize, value: i32) -> Result<(), Errno> { + let field = bytes.get_mut(offset..offset + 4).ok_or(Errno::EINVAL)?; + field.copy_from_slice(&value.to_le_bytes()); + Ok(()) +} + +fn write_u16(bytes: &mut [u8], offset: usize, value: u16) -> Result<(), Errno> { + let field = bytes.get_mut(offset..offset + 2).ok_or(Errno::EINVAL)?; + field.copy_from_slice(&value.to_le_bytes()); + Ok(()) +} + +fn write_u32(bytes: &mut [u8], offset: usize, value: u32) -> Result<(), Errno> { + let field = bytes.get_mut(offset..offset + 4).ok_or(Errno::EINVAL)?; + field.copy_from_slice(&value.to_le_bytes()); + Ok(()) +} + +fn write_i64(bytes: &mut [u8], offset: usize, value: i64) -> Result<(), Errno> { + let field = bytes.get_mut(offset..offset + 8).ok_or(Errno::EINVAL)?; + field.copy_from_slice(&value.to_le_bytes()); + Ok(()) +} + +fn write_u64(bytes: &mut [u8], offset: usize, value: u64) -> Result<(), Errno> { + let field = bytes.get_mut(offset..offset + 8).ok_or(Errno::EINVAL)?; + field.copy_from_slice(&value.to_le_bytes()); + Ok(()) +} + +fn read_native_long(bytes: &[u8], offset: usize, model: ProcessDataModel) -> Result { + match model { + ProcessDataModel::Wasm32 => Ok(read_i32(bytes, offset)? as i64), + ProcessDataModel::Wasm64 => read_i64(bytes, offset), + } +} + +fn write_native_long( + bytes: &mut [u8], + offset: usize, + value: i64, + model: ProcessDataModel, +) -> Result<(), Errno> { + match model { + ProcessDataModel::Wasm32 => { + let narrowed = i32::try_from(value).map_err(|_| Errno::EOVERFLOW)?; + write_i32(bytes, offset, narrowed) + } + ProcessDataModel::Wasm64 => write_i64(bytes, offset, value), + } +} + +fn write_native_ulong( + bytes: &mut [u8], + offset: usize, + value: u64, + model: ProcessDataModel, +) -> Result<(), Errno> { + match model { + ProcessDataModel::Wasm32 => { + let narrowed = u32::try_from(value).map_err(|_| Errno::EOVERFLOW)?; + write_u32(bytes, offset, narrowed) + } + ProcessDataModel::Wasm64 => write_u64(bytes, offset, value), + } +} + +fn read_native_sigval( + bytes: &[u8], + offset: usize, + model: ProcessDataModel, +) -> Result { + match model { + ProcessDataModel::Wasm32 => Ok(read_u32(bytes, offset)? as u64), + ProcessDataModel::Wasm64 => read_u64(bytes, offset), + } +} + +fn write_native_sigval( + bytes: &mut [u8], + offset: usize, + value_bits: u64, + model: ProcessDataModel, +) -> Result<(), Errno> { + match model { + // A mixed-data-model kernel can deliver a wasm64 sender's sigval to a + // wasm32 recipient. The recipient's native union is four bytes, so + // Linux-compatible target-native semantics expose the low 32 bits. + ProcessDataModel::Wasm32 => write_u32(bytes, offset, value_bits as u32), + ProcessDataModel::Wasm64 => write_u64(bytes, offset, value_bits), + } +} + +pub(crate) fn read_sigaltstack( + bytes: &[u8], + model: ProcessDataModel, +) -> Result { + require_len(bytes, model.sigaltstack_size())?; + let (flags_offset, size_offset) = match model { + ProcessDataModel::Wasm32 => ( + process_layout::sigaltstack::WASM32_FLAGS_OFFSET as usize, + process_layout::sigaltstack::WASM32_STACK_SIZE_OFFSET as usize, + ), + ProcessDataModel::Wasm64 => ( + process_layout::sigaltstack::WASM64_FLAGS_OFFSET as usize, + process_layout::sigaltstack::WASM64_STACK_SIZE_OFFSET as usize, + ), + }; + let (sp, size) = match model { + ProcessDataModel::Wasm32 => ( + read_u32( + bytes, + process_layout::sigaltstack::WASM32_SP_OFFSET as usize, + )? as u64, + read_u32(bytes, size_offset)? as u64, + ), + ProcessDataModel::Wasm64 => ( + read_u64( + bytes, + process_layout::sigaltstack::WASM64_SP_OFFSET as usize, + )?, + read_u64(bytes, size_offset)?, + ), + }; + Ok(NativeSigaltstack { + sp, + flags: read_u32(bytes, flags_offset)?, + size, + }) +} + +pub(crate) fn write_sigaltstack( + bytes: &mut [u8], + stack: NativeSigaltstack, + model: ProcessDataModel, +) -> Result<(), Errno> { + require_len_mut(bytes, model.sigaltstack_size())?; + bytes.fill(0); + match model { + ProcessDataModel::Wasm32 => { + write_u32( + bytes, + process_layout::sigaltstack::WASM32_SP_OFFSET as usize, + u32::try_from(stack.sp).map_err(|_| Errno::EOVERFLOW)?, + )?; + write_u32( + bytes, + process_layout::sigaltstack::WASM32_FLAGS_OFFSET as usize, + stack.flags, + )?; + write_u32( + bytes, + process_layout::sigaltstack::WASM32_STACK_SIZE_OFFSET as usize, + u32::try_from(stack.size).map_err(|_| Errno::EOVERFLOW)?, + ) + } + ProcessDataModel::Wasm64 => { + write_u64( + bytes, + process_layout::sigaltstack::WASM64_SP_OFFSET as usize, + stack.sp, + )?; + write_u32( + bytes, + process_layout::sigaltstack::WASM64_FLAGS_OFFSET as usize, + stack.flags, + )?; + write_u64( + bytes, + process_layout::sigaltstack::WASM64_STACK_SIZE_OFFSET as usize, + stack.size, + ) + } + } +} + +pub(crate) fn read_itimerval(bytes: &[u8], model: ProcessDataModel) -> Result<[i64; 4], Errno> { + require_len(bytes, model.itimerval_size())?; + let stride = model.native_long_bytes(); + Ok([ + read_native_long(bytes, 0, model)?, + read_native_long(bytes, stride, model)?, + read_native_long(bytes, stride * 2, model)?, + read_native_long(bytes, stride * 3, model)?, + ]) +} + +pub(crate) fn write_itimerval( + bytes: &mut [u8], + values: [i64; 4], + model: ProcessDataModel, +) -> Result<(), Errno> { + require_len_mut(bytes, model.itimerval_size())?; + bytes.fill(0); + let stride = model.native_long_bytes(); + for (index, value) in values.into_iter().enumerate() { + write_native_long(bytes, index * stride, value, model)?; + } + Ok(()) +} + +fn nonnegative_u32(value: i64) -> Result { + u32::try_from(value).map_err(|_| Errno::EINVAL) +} + +pub(crate) fn read_mq_attr(bytes: &[u8], model: ProcessDataModel) -> Result { + require_len(bytes, model.mq_attr_size())?; + let stride = model.native_long_bytes(); + Ok(NativeMqAttr { + flags: nonnegative_u32(read_native_long(bytes, 0, model)?)?, + maxmsg: nonnegative_u32(read_native_long(bytes, stride, model)?)?, + msgsize: nonnegative_u32(read_native_long(bytes, stride * 2, model)?)?, + curmsgs: nonnegative_u32(read_native_long(bytes, stride * 3, model)?)?, + }) +} + +pub(crate) fn write_mq_attr( + bytes: &mut [u8], + attr: NativeMqAttr, + model: ProcessDataModel, +) -> Result<(), Errno> { + require_len_mut(bytes, model.mq_attr_size())?; + bytes.fill(0); + let stride = model.native_long_bytes(); + for (index, value) in [attr.flags, attr.maxmsg, attr.msgsize, attr.curmsgs] + .into_iter() + .enumerate() + { + write_native_long(bytes, index * stride, value as i64, model)?; + } + Ok(()) +} + +pub(crate) fn read_sigevent( + bytes: &[u8], + model: ProcessDataModel, +) -> Result { + require_len(bytes, model.sigevent_size())?; + let (value_offset, signo_offset, notify_offset, payload_offset) = match model { + ProcessDataModel::Wasm32 => ( + process_layout::sigevent::WASM32_VALUE_OFFSET as usize, + process_layout::sigevent::WASM32_SIGNO_OFFSET as usize, + process_layout::sigevent::WASM32_NOTIFY_OFFSET as usize, + process_layout::sigevent::WASM32_PAYLOAD_OFFSET as usize, + ), + ProcessDataModel::Wasm64 => ( + process_layout::sigevent::WASM64_VALUE_OFFSET as usize, + process_layout::sigevent::WASM64_SIGNO_OFFSET as usize, + process_layout::sigevent::WASM64_NOTIFY_OFFSET as usize, + process_layout::sigevent::WASM64_PAYLOAD_OFFSET as usize, + ), + }; + Ok(NativeSigevent { + value_bits: read_native_sigval(bytes, value_offset, model)?, + signo: read_i32(bytes, signo_offset)? as u32, + notify: read_i32(bytes, notify_offset)? as u32, + thread_id: read_i32(bytes, payload_offset)? as u32, + }) +} + +pub(crate) fn write_statfs( + bytes: &mut [u8], + statfs: &WasmStatfs, + model: ProcessDataModel, +) -> Result<(), Errno> { + require_len_mut(bytes, model.statfs_size())?; + bytes.fill(0); + match model { + ProcessDataModel::Wasm32 => { + use process_layout::statfs::*; + write_u32(bytes, WASM32_TYPE_OFFSET as usize, statfs.f_type)?; + write_u32(bytes, WASM32_BSIZE_OFFSET as usize, statfs.f_bsize)?; + write_u64(bytes, WASM32_BLOCKS_OFFSET as usize, statfs.f_blocks)?; + write_u64(bytes, WASM32_BFREE_OFFSET as usize, statfs.f_bfree)?; + write_u64(bytes, WASM32_BAVAIL_OFFSET as usize, statfs.f_bavail)?; + write_u64(bytes, WASM32_FILES_OFFSET as usize, statfs.f_files)?; + write_u64(bytes, WASM32_FFREE_OFFSET as usize, statfs.f_ffree)?; + write_u64(bytes, WASM32_FSID_OFFSET as usize, statfs.f_fsid)?; + write_u32(bytes, WASM32_NAMELEN_OFFSET as usize, statfs.f_namelen)?; + write_u32(bytes, WASM32_FRSIZE_OFFSET as usize, statfs.f_frsize)?; + write_u32(bytes, WASM32_FLAGS_OFFSET as usize, statfs.f_flags) + } + ProcessDataModel::Wasm64 => { + use process_layout::statfs::*; + write_u64(bytes, WASM64_TYPE_OFFSET as usize, statfs.f_type as u64)?; + write_u64(bytes, WASM64_BSIZE_OFFSET as usize, statfs.f_bsize as u64)?; + write_u64(bytes, WASM64_BLOCKS_OFFSET as usize, statfs.f_blocks)?; + write_u64(bytes, WASM64_BFREE_OFFSET as usize, statfs.f_bfree)?; + write_u64(bytes, WASM64_BAVAIL_OFFSET as usize, statfs.f_bavail)?; + write_u64(bytes, WASM64_FILES_OFFSET as usize, statfs.f_files)?; + write_u64(bytes, WASM64_FFREE_OFFSET as usize, statfs.f_ffree)?; + write_u64(bytes, WASM64_FSID_OFFSET as usize, statfs.f_fsid)?; + write_u64( + bytes, + WASM64_NAMELEN_OFFSET as usize, + statfs.f_namelen as u64, + )?; + write_u64(bytes, WASM64_FRSIZE_OFFSET as usize, statfs.f_frsize as u64)?; + write_u64(bytes, WASM64_FLAGS_OFFSET as usize, statfs.f_flags as u64) + } + } +} + +pub(crate) fn write_sysinfo( + bytes: &mut [u8], + info: &crate::syscalls::KernelSysinfo, + model: ProcessDataModel, +) -> Result<(), Errno> { + require_len_mut(bytes, model.sysinfo_size())?; + bytes.fill(0); + let ( + uptime, + loads, + totalram, + freeram, + sharedram, + bufferram, + totalswap, + freeswap, + procs, + totalhigh, + freehigh, + mem_unit, + ) = match model { + ProcessDataModel::Wasm32 => ( + process_layout::sysinfo::WASM32_UPTIME_OFFSET, + process_layout::sysinfo::WASM32_LOADS_OFFSET, + process_layout::sysinfo::WASM32_TOTALRAM_OFFSET, + process_layout::sysinfo::WASM32_FREERAM_OFFSET, + process_layout::sysinfo::WASM32_SHAREDRAM_OFFSET, + process_layout::sysinfo::WASM32_BUFFERRAM_OFFSET, + process_layout::sysinfo::WASM32_TOTALSWAP_OFFSET, + process_layout::sysinfo::WASM32_FREESWAP_OFFSET, + process_layout::sysinfo::WASM32_PROCS_OFFSET, + process_layout::sysinfo::WASM32_TOTALHIGH_OFFSET, + process_layout::sysinfo::WASM32_FREEHIGH_OFFSET, + process_layout::sysinfo::WASM32_MEM_UNIT_OFFSET, + ), + ProcessDataModel::Wasm64 => ( + process_layout::sysinfo::WASM64_UPTIME_OFFSET, + process_layout::sysinfo::WASM64_LOADS_OFFSET, + process_layout::sysinfo::WASM64_TOTALRAM_OFFSET, + process_layout::sysinfo::WASM64_FREERAM_OFFSET, + process_layout::sysinfo::WASM64_SHAREDRAM_OFFSET, + process_layout::sysinfo::WASM64_BUFFERRAM_OFFSET, + process_layout::sysinfo::WASM64_TOTALSWAP_OFFSET, + process_layout::sysinfo::WASM64_FREESWAP_OFFSET, + process_layout::sysinfo::WASM64_PROCS_OFFSET, + process_layout::sysinfo::WASM64_TOTALHIGH_OFFSET, + process_layout::sysinfo::WASM64_FREEHIGH_OFFSET, + process_layout::sysinfo::WASM64_MEM_UNIT_OFFSET, + ), + }; + + write_native_ulong(bytes, uptime as usize, info.uptime, model)?; + for (index, load) in info.loads.iter().copied().enumerate() { + write_native_ulong( + bytes, + loads as usize + index * model.native_long_bytes(), + load, + model, + )?; + } + for (offset, value) in [ + (totalram, info.totalram), + (freeram, info.freeram), + (sharedram, info.sharedram), + (bufferram, info.bufferram), + (totalswap, info.totalswap), + (freeswap, info.freeswap), + (totalhigh, info.totalhigh), + (freehigh, info.freehigh), + ] { + write_native_ulong(bytes, offset as usize, value, model)?; + } + write_u16(bytes, procs as usize, info.procs)?; + write_u32(bytes, mem_unit as usize, info.mem_unit) +} + +pub(crate) fn read_rt_sigqueueinfo( + bytes: &[u8], + model: ProcessDataModel, +) -> Result { + require_len(bytes, model.siginfo_size())?; + let (pid_offset, uid_offset, value_offset) = siginfo_union_offsets(model); + Ok(NativeRtSigqueueinfo { + pid: read_i32(bytes, pid_offset)?, + uid: read_u32(bytes, uid_offset)?, + value_bits: read_native_sigval(bytes, value_offset, model)?, + }) +} + +fn siginfo_union_offsets(model: ProcessDataModel) -> (usize, usize, usize) { + match model { + ProcessDataModel::Wasm32 => ( + process_layout::rt_sigqueueinfo::WASM32_PID_OFFSET as usize, + process_layout::rt_sigqueueinfo::WASM32_UID_OFFSET as usize, + process_layout::rt_sigqueueinfo::WASM32_VALUE_OFFSET as usize, + ), + ProcessDataModel::Wasm64 => ( + process_layout::rt_sigqueueinfo::WASM64_PID_OFFSET as usize, + process_layout::rt_sigqueueinfo::WASM64_UID_OFFSET as usize, + process_layout::rt_sigqueueinfo::WASM64_VALUE_OFFSET as usize, + ), + } +} + +/// Serialize a complete caller-native `siginfo_t` without retaining scratch +/// bytes in padding or inactive union members. +pub(crate) fn write_siginfo( + bytes: &mut [u8], + info: NativeSiginfo, + model: ProcessDataModel, +) -> Result<(), Errno> { + require_len_mut(bytes, model.siginfo_size())?; + bytes.fill(0); + let (word_1_offset, word_2_offset, value_offset) = siginfo_union_offsets(model); + write_i32( + bytes, + process_layout::rt_sigqueueinfo::SIGNO_OFFSET as usize, + info.signo, + )?; + write_i32( + bytes, + process_layout::rt_sigqueueinfo::CODE_OFFSET as usize, + info.code, + )?; + write_i32(bytes, word_1_offset, info.word_1)?; + write_u32(bytes, word_2_offset, info.word_2_bits)?; + write_native_sigval(bytes, value_offset, info.value_bits, model) +} + +/// Serialize the complete guest-native stat record. +/// +/// WHY: [`WasmStat`] is the kernel/host canonical metadata record and stops at +/// `st_ctime_nsec`. A stat syscall instead returns musl's larger native +/// `struct kstat`; copying only the canonical prefix would publish reused +/// kernel-scratch bytes as rdev/block metadata. +pub(crate) fn write_stat(bytes: &mut [u8], stat: &WasmStat) -> Result<(), Errno> { + require_len_mut(bytes, process_layout::stat::SIZE as usize)?; + bytes.fill(0); + use process_layout::stat::*; + write_u64(bytes, DEV_OFFSET as usize, stat.st_dev)?; + write_u64(bytes, INO_OFFSET as usize, stat.st_ino)?; + write_u32(bytes, MODE_OFFSET as usize, stat.st_mode)?; + write_u32(bytes, NLINK_OFFSET as usize, stat.st_nlink)?; + write_u32(bytes, UID_OFFSET as usize, stat.st_uid)?; + write_u32(bytes, GID_OFFSET as usize, stat.st_gid)?; + write_u64(bytes, SIZE_OFFSET as usize, stat.st_size)?; + write_u64(bytes, ATIME_SEC_OFFSET as usize, stat.st_atime_sec)?; + write_u32(bytes, ATIME_NSEC_OFFSET as usize, stat.st_atime_nsec)?; + write_u64(bytes, MTIME_SEC_OFFSET as usize, stat.st_mtime_sec)?; + write_u32(bytes, MTIME_NSEC_OFFSET as usize, stat.st_mtime_nsec)?; + write_u64(bytes, CTIME_SEC_OFFSET as usize, stat.st_ctime_sec)?; + write_u32(bytes, CTIME_NSEC_OFFSET as usize, stat.st_ctime_nsec) +} + +pub(crate) fn read_sched_param(bytes: &[u8]) -> Result { + require_len(bytes, process_layout::sched_param::SIZE as usize)?; + use process_layout::sched_param::*; + Ok(NativeSchedParam { + priority: read_i32(bytes, PRIORITY_OFFSET as usize)?, + ss_max_repl: read_i32(bytes, SS_MAX_REPL_OFFSET as usize)?, + ss_repl_period_sec: read_i64(bytes, SS_REPL_PERIOD_SEC_OFFSET as usize)?, + ss_repl_period_nsec: read_i64(bytes, SS_REPL_PERIOD_NSEC_OFFSET as usize)?, + ss_init_budget_sec: read_i64(bytes, SS_INIT_BUDGET_SEC_OFFSET as usize)?, + ss_init_budget_nsec: read_i64(bytes, SS_INIT_BUDGET_NSEC_OFFSET as usize)?, + ss_low_priority: read_i32(bytes, SS_LOW_PRIORITY_OFFSET as usize)?, + }) +} + +pub(crate) fn write_sched_param(bytes: &mut [u8], param: NativeSchedParam) -> Result<(), Errno> { + require_len_mut(bytes, process_layout::sched_param::SIZE as usize)?; + bytes.fill(0); + use process_layout::sched_param::*; + write_i32(bytes, PRIORITY_OFFSET as usize, param.priority)?; + write_i32(bytes, SS_MAX_REPL_OFFSET as usize, param.ss_max_repl)?; + write_i64( + bytes, + SS_REPL_PERIOD_SEC_OFFSET as usize, + param.ss_repl_period_sec, + )?; + write_i64( + bytes, + SS_REPL_PERIOD_NSEC_OFFSET as usize, + param.ss_repl_period_nsec, + )?; + write_i64( + bytes, + SS_INIT_BUDGET_SEC_OFFSET as usize, + param.ss_init_budget_sec, + )?; + write_i64( + bytes, + SS_INIT_BUDGET_NSEC_OFFSET as usize, + param.ss_init_budget_nsec, + )?; + write_i32( + bytes, + SS_LOW_PRIORITY_OFFSET as usize, + param.ss_low_priority, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_statfs() -> WasmStatfs { + WasmStatfs { + f_type: 0x1122_3344, + f_bsize: 0x5566_7788, + f_blocks: 0x0102_0304_0506_0708, + f_bfree: 0x1112_1314_1516_1718, + f_bavail: 0x2122_2324_2526_2728, + f_files: 0x3132_3334_3536_3738, + f_ffree: 0x4142_4344_4546_4748, + f_fsid: 0x5152_5354_5556_5758, + f_namelen: 255, + f_frsize: 4096, + f_flags: 0xa5a5_5a5a, + _pad: 0, + } + } + + #[test] + fn rejects_unknown_process_width() { + assert_eq!(ProcessDataModel::from_width(0), Err(Errno::EINVAL)); + assert_eq!(ProcessDataModel::from_width(16), Err(Errno::EINVAL)); + } + + #[test] + fn signal_delivery_output_requires_nonnull_exact_capacity() { + use wasm_posix_shared::kernel_scratch_wire as wire; + + let mut byte = 0u8; + let pointer = &mut byte as *mut u8; + let exact = wire::SIGNAL_DELIVERY_BYTES; + + assert_eq!(validate_signal_delivery_output(pointer, exact), Ok(())); + assert_eq!( + validate_signal_delivery_output(core::ptr::null_mut(), exact), + Err(Errno::EFAULT), + ); + assert_eq!( + validate_signal_delivery_output(pointer, exact - 1), + Err(Errno::EINVAL), + ); + assert_eq!( + validate_signal_delivery_output(pointer, exact + 1), + Err(Errno::EINVAL), + ); + } + + #[test] + fn signal_delivery_record_serializes_every_field_at_the_shared_offsets() { + use wasm_posix_shared::kernel_scratch_wire as wire; + + let record = SignalDeliveryRecord { + signum: 17, + handler: 23, + flags: 0x0800_0004, + si_value_bits: 0x0123_4567_89ab_cdef, + old_mask: 0x0102_0304_0506_0708, + si_code: -2, + siginfo_word_1: -31, + siginfo_word_2: 37, + alt_sp: 0x1_2345_6789, + alt_size: 0x2_3456_789a, + }; + let bytes = encode_signal_delivery_record(record); + let expected: [u8; wire::SIGNAL_DELIVERY_BYTES as usize] = [ + 0x11, 0x00, 0x00, 0x00, // signum + 0x17, 0x00, 0x00, 0x00, // handler + 0x04, 0x00, 0x00, 0x08, // flags + 0xef, 0xcd, 0xab, 0x89, 0x67, 0x45, 0x23, 0x01, // raw sigval bits + 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01, // old_mask + 0xfe, 0xff, 0xff, 0xff, // si_code = -2 + 0xe1, 0xff, 0xff, 0xff, // siginfo word 1 = -31 + 0x25, 0x00, 0x00, 0x00, // siginfo word 2 = 37 + 0x89, 0x67, 0x45, 0x23, 0x01, 0x00, 0x00, 0x00, // alt_sp + 0x9a, 0x78, 0x56, 0x34, 0x02, 0x00, 0x00, 0x00, // alt_size + ]; + + assert_eq!(bytes, expected); + } + + #[test] + fn sigaltstack_validates_exclusive_top_in_each_pointer_domain() { + let stack = |sp, size| NativeSigaltstack { sp, flags: 0, size }; + let wasm32_exact_top = stack(u32::MAX as u64 - 4096, 4096); + assert_eq!( + validate_sigaltstack_range(wasm32_exact_top, ProcessDataModel::Wasm32), + Ok(()), + ); + assert_eq!( + validate_sigaltstack_range( + stack(wasm32_exact_top.sp, wasm32_exact_top.size + 1), + ProcessDataModel::Wasm32, + ), + Err(Errno::EOVERFLOW), + ); + assert_eq!( + validate_sigaltstack_range( + stack(0, u32::MAX as u64 + 1), + ProcessDataModel::Wasm32, + ), + Err(Errno::EOVERFLOW), + ); + assert_eq!( + validate_sigaltstack_range( + stack(u32::MAX as u64 + 4096, 8192), + ProcessDataModel::Wasm64, + ), + Ok(()), + ); + assert_eq!( + validate_sigaltstack_range( + stack(u64::MAX - 1, 2), + ProcessDataModel::Wasm64, + ), + Err(Errno::EOVERFLOW), + ); + } + + #[test] + fn disabled_sigaltstack_ignores_nonsemantic_pointer_fields() { + let disabled = NativeSigaltstack { + sp: u64::MAX, + flags: SIGALTSTACK_SS_DISABLE, + size: u64::MAX, + }; + assert_eq!( + validate_sigaltstack_range(disabled, ProcessDataModel::Wasm32), + Ok(()), + ); + assert_eq!( + validate_sigaltstack_range(disabled, ProcessDataModel::Wasm64), + Ok(()), + ); + } + + #[test] + fn sigaltstack_round_trips_each_native_layout() { + let stack = NativeSigaltstack { + sp: 0x1020_3040, + flags: 2, + size: 0x5060_7080, + }; + for model in [ProcessDataModel::Wasm32, ProcessDataModel::Wasm64] { + let mut bytes = alloc::vec![0xcc; model.sigaltstack_size()]; + write_sigaltstack(&mut bytes, stack, model).unwrap(); + assert_eq!(read_sigaltstack(&bytes, model).unwrap(), stack); + assert!(bytes.iter().any(|byte| *byte == 0)); + + let mut short = alloc::vec![0; model.sigaltstack_size() - 1]; + assert_eq!( + write_sigaltstack(&mut short, stack, model), + Err(Errno::EINVAL) + ); + let mut long = alloc::vec![0; model.sigaltstack_size() + 1]; + assert_eq!( + write_sigaltstack(&mut long, stack, model), + Err(Errno::EINVAL) + ); + } + + let high = NativeSigaltstack { + sp: u32::MAX as u64 + 0x1_001, + flags: 0, + size: u32::MAX as u64 + 0x2_002, + }; + let mut wasm64 = alloc::vec![0; ProcessDataModel::Wasm64.sigaltstack_size()]; + write_sigaltstack(&mut wasm64, high, ProcessDataModel::Wasm64).unwrap(); + assert_eq!( + read_sigaltstack(&wasm64, ProcessDataModel::Wasm64), + Ok(high), + ); + + let mut wasm32 = alloc::vec![0; ProcessDataModel::Wasm32.sigaltstack_size()]; + assert_eq!( + write_sigaltstack(&mut wasm32, high, ProcessDataModel::Wasm32), + Err(Errno::EOVERFLOW), + ); + } + + #[test] + fn itimerval_uses_four_i32_or_four_i64_fields() { + let values = [1, 2, 3, 4]; + for model in [ProcessDataModel::Wasm32, ProcessDataModel::Wasm64] { + let mut bytes = alloc::vec![0; model.itimerval_size()]; + write_itimerval(&mut bytes, values, model).unwrap(); + assert_eq!(read_itimerval(&bytes, model).unwrap(), values); + + let mut short = alloc::vec![0; model.itimerval_size() - 1]; + assert_eq!( + write_itimerval(&mut short, values, model), + Err(Errno::EINVAL) + ); + assert_eq!(read_itimerval(&short, model), Err(Errno::EINVAL)); + let mut long = alloc::vec![0; model.itimerval_size() + 1]; + assert_eq!( + write_itimerval(&mut long, values, model), + Err(Errno::EINVAL) + ); + assert_eq!(read_itimerval(&long, model), Err(Errno::EINVAL)); + } + + let mut wasm32 = [0u8; process_layout::itimerval::WASM32_SIZE as usize]; + assert_eq!( + write_itimerval( + &mut wasm32, + [i32::MAX as i64 + 1, 0, 0, 0], + ProcessDataModel::Wasm32, + ), + Err(Errno::EOVERFLOW) + ); + } + + #[test] + fn mq_attr_and_sigevent_follow_process_long_width() { + let attr = NativeMqAttr { + flags: 0x800, + maxmsg: 17, + msgsize: 8192, + curmsgs: 3, + }; + for model in [ProcessDataModel::Wasm32, ProcessDataModel::Wasm64] { + let mut bytes = alloc::vec![0xcc; model.mq_attr_size()]; + write_mq_attr(&mut bytes, attr, model).unwrap(); + assert_eq!(read_mq_attr(&bytes, model).unwrap(), attr); + assert!( + bytes[model.native_long_bytes() * 4..] + .iter() + .all(|byte| *byte == 0) + ); + + let mut short_attr = alloc::vec![0; model.mq_attr_size() - 1]; + assert_eq!( + write_mq_attr(&mut short_attr, attr, model), + Err(Errno::EINVAL) + ); + assert_eq!(read_mq_attr(&short_attr, model), Err(Errno::EINVAL)); + let mut long_attr = alloc::vec![0; model.mq_attr_size() + 1]; + assert_eq!( + write_mq_attr(&mut long_attr, attr, model), + Err(Errno::EINVAL) + ); + assert_eq!(read_mq_attr(&long_attr, model), Err(Errno::EINVAL)); + + let mut event = alloc::vec![0; model.sigevent_size()]; + let (value_offset, value_bits, signo_offset, notify_offset, payload_offset) = + match model { + ProcessDataModel::Wasm32 => (0, 0x89ab_cdef_u64, 4, 8, 12), + ProcessDataModel::Wasm64 => { + (0, 0x0123_4567_89ab_cdef_u64, 8, 12, 16) + } + }; + match model { + ProcessDataModel::Wasm32 => event[value_offset..value_offset + 4] + .copy_from_slice(&(value_bits as u32).to_le_bytes()), + ProcessDataModel::Wasm64 => event[value_offset..value_offset + 8] + .copy_from_slice(&value_bits.to_le_bytes()), + }; + event[signo_offset..signo_offset + 4].copy_from_slice(&10i32.to_le_bytes()); + event[notify_offset..notify_offset + 4].copy_from_slice(&4i32.to_le_bytes()); + event[payload_offset..payload_offset + 4].copy_from_slice(&42i32.to_le_bytes()); + assert_eq!( + read_sigevent(&event, model).unwrap(), + NativeSigevent { + value_bits, + signo: 10, + notify: 4, + thread_id: 42, + } + ); + assert_eq!( + read_sigevent(&event[..event.len() - 1], model), + Err(Errno::EINVAL) + ); + event.push(0); + assert_eq!(read_sigevent(&event, model), Err(Errno::EINVAL)); + } + } + + #[test] + fn statfs_serializes_exact_native_sizes_and_zeroes_spares() { + let statfs = sample_statfs(); + for model in [ProcessDataModel::Wasm32, ProcessDataModel::Wasm64] { + let mut bytes = alloc::vec![0xcc; model.statfs_size()]; + write_statfs(&mut bytes, &statfs, model).unwrap(); + let spare = match model { + ProcessDataModel::Wasm32 => process_layout::statfs::WASM32_SPARE_OFFSET, + ProcessDataModel::Wasm64 => process_layout::statfs::WASM64_SPARE_OFFSET, + } as usize; + assert!(bytes[spare..].iter().all(|byte| *byte == 0)); + + let mut short = alloc::vec![0; model.statfs_size() - 1]; + assert_eq!(write_statfs(&mut short, &statfs, model), Err(Errno::EINVAL)); + let mut long = alloc::vec![0; model.statfs_size() + 1]; + assert_eq!(write_statfs(&mut long, &statfs, model), Err(Errno::EINVAL)); + } + } + + #[test] + fn sysinfo_serializes_exact_native_sizes_and_zeroes_reserved_bytes() { + let info = crate::syscalls::sys_sysinfo(); + for model in [ProcessDataModel::Wasm32, ProcessDataModel::Wasm64] { + let size = model.sysinfo_size(); + let mut guarded = alloc::vec![0x5a; size + 2]; + let bytes = &mut guarded[1..size + 1]; + write_sysinfo(bytes, &info, model).unwrap(); + + let (uptime, totalram, freeram, procs, mem_unit, reserved) = match model { + ProcessDataModel::Wasm32 => ( + read_u32( + bytes, + process_layout::sysinfo::WASM32_UPTIME_OFFSET as usize, + ) + .unwrap() as u64, + read_u32( + bytes, + process_layout::sysinfo::WASM32_TOTALRAM_OFFSET as usize, + ) + .unwrap() as u64, + read_u32( + bytes, + process_layout::sysinfo::WASM32_FREERAM_OFFSET as usize, + ) + .unwrap() as u64, + process_layout::sysinfo::WASM32_PROCS_OFFSET, + process_layout::sysinfo::WASM32_MEM_UNIT_OFFSET, + process_layout::sysinfo::WASM32_RESERVED_OFFSET, + ), + ProcessDataModel::Wasm64 => ( + read_u64( + bytes, + process_layout::sysinfo::WASM64_UPTIME_OFFSET as usize, + ) + .unwrap(), + read_u64( + bytes, + process_layout::sysinfo::WASM64_TOTALRAM_OFFSET as usize, + ) + .unwrap(), + read_u64( + bytes, + process_layout::sysinfo::WASM64_FREERAM_OFFSET as usize, + ) + .unwrap(), + process_layout::sysinfo::WASM64_PROCS_OFFSET, + process_layout::sysinfo::WASM64_MEM_UNIT_OFFSET, + process_layout::sysinfo::WASM64_RESERVED_OFFSET, + ), + }; + assert_eq!(uptime, info.uptime); + assert_eq!(totalram, info.totalram); + assert_eq!(freeram, info.freeram); + assert_eq!( + u16::from_le_bytes( + bytes[procs as usize..procs as usize + 2] + .try_into() + .unwrap() + ), + info.procs + ); + assert_eq!(read_u32(bytes, mem_unit as usize).unwrap(), info.mem_unit); + assert!(bytes[reserved as usize..].iter().all(|byte| *byte == 0)); + assert_eq!(guarded[0], 0x5a); + assert_eq!(guarded[size + 1], 0x5a); + + let mut short = alloc::vec![0; size - 1]; + assert_eq!(write_sysinfo(&mut short, &info, model), Err(Errno::EINVAL)); + let mut long = alloc::vec![0; size + 1]; + assert_eq!(write_sysinfo(&mut long, &info, model), Err(Errno::EINVAL)); + } + } + + #[test] + fn siginfo_reads_and_completely_writes_each_native_layout() { + for (model, pid_offset, uid_offset, value_offset) in [ + ( + ProcessDataModel::Wasm32, + process_layout::rt_sigqueueinfo::WASM32_PID_OFFSET as usize, + process_layout::rt_sigqueueinfo::WASM32_UID_OFFSET as usize, + process_layout::rt_sigqueueinfo::WASM32_VALUE_OFFSET as usize, + ), + ( + ProcessDataModel::Wasm64, + process_layout::rt_sigqueueinfo::WASM64_PID_OFFSET as usize, + process_layout::rt_sigqueueinfo::WASM64_UID_OFFSET as usize, + process_layout::rt_sigqueueinfo::WASM64_VALUE_OFFSET as usize, + ), + ] { + let value_bits = match model { + ProcessDataModel::Wasm32 => 0xefdf_cfc0, + ProcessDataModel::Wasm64 => 0x0123_4567_89ab_cdef, + }; + let info = NativeSiginfo { + signo: 12, + code: -1, + word_1: 1234, + word_2_bits: 0xf123_4567, + value_bits, + }; + let size = model.siginfo_size(); + let mut guarded = alloc::vec![0x5a; size + 2]; + write_siginfo(&mut guarded[1..size + 1], info, model).unwrap(); + let bytes = &guarded[1..size + 1]; + + let mut expected = alloc::vec![0; size]; + expected[process_layout::rt_sigqueueinfo::SIGNO_OFFSET as usize + ..process_layout::rt_sigqueueinfo::SIGNO_OFFSET as usize + 4] + .copy_from_slice(&info.signo.to_le_bytes()); + expected[process_layout::rt_sigqueueinfo::CODE_OFFSET as usize + ..process_layout::rt_sigqueueinfo::CODE_OFFSET as usize + 4] + .copy_from_slice(&info.code.to_le_bytes()); + expected[pid_offset..pid_offset + 4].copy_from_slice(&info.word_1.to_le_bytes()); + expected[uid_offset..uid_offset + 4].copy_from_slice(&info.word_2_bits.to_le_bytes()); + match model { + ProcessDataModel::Wasm32 => expected[value_offset..value_offset + 4] + .copy_from_slice(&(info.value_bits as u32).to_le_bytes()), + ProcessDataModel::Wasm64 => expected[value_offset..value_offset + 8] + .copy_from_slice(&info.value_bits.to_le_bytes()), + } + assert_eq!(bytes, expected); + assert_eq!(guarded[0], 0x5a); + assert_eq!(guarded[size + 1], 0x5a); + + assert_eq!( + read_rt_sigqueueinfo(bytes, model).unwrap(), + NativeRtSigqueueinfo { + pid: 1234, + uid: 0xf123_4567, + value_bits, + } + ); + assert_eq!( + read_rt_sigqueueinfo(&bytes[..bytes.len() - 1], model), + Err(Errno::EINVAL) + ); + let mut long = bytes.to_vec(); + long.push(0); + assert_eq!(read_rt_sigqueueinfo(&long, model), Err(Errno::EINVAL)); + + let mut short_output = alloc::vec![0xa5; size - 1]; + assert_eq!( + write_siginfo(&mut short_output, info, model), + Err(Errno::EINVAL), + ); + assert!(short_output.iter().all(|byte| *byte == 0xa5)); + let mut long_output = alloc::vec![0xa5; size + 1]; + assert_eq!( + write_siginfo(&mut long_output, info, model), + Err(Errno::EINVAL), + ); + assert!(long_output.iter().all(|byte| *byte == 0xa5)); + } + + let mut wasm32 = alloc::vec![0xa5; ProcessDataModel::Wasm32.siginfo_size()]; + let mixed_model = NativeSiginfo { + signo: 12, + code: -1, + word_1: 1, + word_2_bits: 2, + value_bits: 0x0123_4567_89ab_cdef, + }; + assert_eq!( + write_siginfo(&mut wasm32, mixed_model, ProcessDataModel::Wasm32), + Ok(()), + ); + let offset = process_layout::rt_sigqueueinfo::WASM32_VALUE_OFFSET as usize; + assert_eq!( + u32::from_le_bytes(wasm32[offset..offset + 4].try_into().unwrap()), + 0x89ab_cdef, + ); + } + + fn sample_stat() -> WasmStat { + WasmStat { + st_dev: 0x0102_0304_0506_0708, + st_ino: 0x1112_1314_1516_1718, + st_mode: 0x2122_2324, + st_nlink: 0x3132_3334, + st_uid: 0x4142_4344, + st_gid: 0x5152_5354, + st_size: 0x6162_6364_6566_6768, + st_atime_sec: 0x7172_7374_7576_7778, + st_atime_nsec: 0x0102_0304, + st_mtime_sec: 0x1112_1314_1516_1718, + st_mtime_nsec: 0x2122_2324, + st_ctime_sec: 0x3132_3334_3536_3738, + st_ctime_nsec: 0x4142_4344, + _pad: 0, + } + } + + #[test] + fn stat_serializes_all_112_bytes_without_touching_canaries() { + let stat = sample_stat(); + let size = process_layout::stat::SIZE as usize; + let mut guarded = alloc::vec![0x5a; size + 2]; + write_stat(&mut guarded[1..size + 1], &stat).unwrap(); + assert_eq!(guarded[0], 0x5a); + assert_eq!(guarded[size + 1], 0x5a); + + let bytes = &guarded[1..size + 1]; + assert_eq!( + read_u64(bytes, process_layout::stat::DEV_OFFSET as usize).unwrap(), + stat.st_dev + ); + assert_eq!( + read_u64(bytes, process_layout::stat::CTIME_SEC_OFFSET as usize).unwrap(), + stat.st_ctime_sec + ); + assert_eq!( + read_u32(bytes, process_layout::stat::CTIME_NSEC_OFFSET as usize).unwrap(), + stat.st_ctime_nsec + ); + assert!( + bytes[process_layout::stat::RDEV_OFFSET as usize..] + .iter() + .all(|byte| *byte == 0), + "unsupported rdev/blksize/blocks and their padding must be initialized" + ); + + let mut short = alloc::vec![0; size - 1]; + assert_eq!(write_stat(&mut short, &stat), Err(Errno::EINVAL)); + let mut long = alloc::vec![0; size + 1]; + assert_eq!(write_stat(&mut long, &stat), Err(Errno::EINVAL)); + } + + #[test] + fn sched_param_round_trips_all_fields_and_zeroes_trailing_padding() { + let param = NativeSchedParam { + priority: 1, + ss_max_repl: 2, + ss_repl_period_sec: 3, + ss_repl_period_nsec: 4, + ss_init_budget_sec: 5, + ss_init_budget_nsec: 6, + ss_low_priority: 7, + }; + let size = process_layout::sched_param::SIZE as usize; + let mut guarded = alloc::vec![0x7c; size + 2]; + write_sched_param(&mut guarded[1..size + 1], param).unwrap(); + assert_eq!(guarded[0], 0x7c); + assert_eq!(guarded[size + 1], 0x7c); + let bytes = &guarded[1..size + 1]; + assert_eq!(read_sched_param(bytes).unwrap(), param); + assert!(bytes[44..48].iter().all(|byte| *byte == 0)); + + let mut short = alloc::vec![0; size - 1]; + assert_eq!(write_sched_param(&mut short, param), Err(Errno::EINVAL)); + assert_eq!(read_sched_param(&short), Err(Errno::EINVAL)); + let mut long = alloc::vec![0; size + 1]; + assert_eq!(write_sched_param(&mut long, param), Err(Errno::EINVAL)); + assert_eq!(read_sched_param(&long), Err(Errno::EINVAL)); + + let mut zero = alloc::vec![0xa5; size]; + write_sched_param(&mut zero, NativeSchedParam::default()).unwrap(); + assert!(zero.iter().all(|byte| *byte == 0)); + } +} diff --git a/crates/kernel/src/procfs.rs b/crates/kernel/src/procfs.rs index 4eb39710e6..768cf7e53f 100644 --- a/crates/kernel/src/procfs.rs +++ b/crates/kernel/src/procfs.rs @@ -25,8 +25,7 @@ pub const PROCFS_BUF_BASE: i64 = 200; /// Check if a host_handle is a procfs buffer handle. #[inline] pub fn is_procfs_buf_handle(h: i64) -> bool { - h <= -PROCFS_BUF_BASE - && h > -crate::descriptor_backing::SYNTHETIC_REGULAR_HANDLE_BASE + h <= -PROCFS_BUF_BASE && h > -crate::descriptor_backing::SYNTHETIC_REGULAR_HANDLE_BASE } /// Decode a procfs buffer index from a host_handle. @@ -49,25 +48,25 @@ fn procfs_buf_handle(idx: usize) -> i64 { /// A parsed procfs path entry. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ProcfsEntry { - Root, // /proc - Mounts, // /proc/mounts - SelfLink, // /proc/self (symlink → /proc/) - ThreadSelfLink, // /proc/thread-self (symlink) - PidDir(u32), // /proc/ - PidMounts(u32), // /proc//mounts - PidMountinfo(u32), // /proc//mountinfo - FdDir(u32), // /proc//fd - FdLink(u32, i32), // /proc//fd/ (symlink) - FdInfoDir(u32), // /proc//fdinfo - FdInfo(u32, i32), // /proc//fdinfo/ - Stat(u32), // /proc//stat - Status(u32), // /proc//status - Cmdline(u32), // /proc//cmdline - Environ(u32), // /proc//environ - Maps(u32), // /proc//maps - Cwd(u32), // /proc//cwd (symlink) - Exe(u32), // /proc//exe (symlink) - Root_(u32), // /proc//root (symlink) + Root, // /proc + Mounts, // /proc/mounts + SelfLink, // /proc/self (symlink → /proc/) + ThreadSelfLink, // /proc/thread-self (symlink) + PidDir(u32), // /proc/ + PidMounts(u32), // /proc//mounts + PidMountinfo(u32), // /proc//mountinfo + FdDir(u32), // /proc//fd + FdLink(u32, i32), // /proc//fd/ (symlink) + FdInfoDir(u32), // /proc//fdinfo + FdInfo(u32, i32), // /proc//fdinfo/ + Stat(u32), // /proc//stat + Status(u32), // /proc//status + Cmdline(u32), // /proc//cmdline + Environ(u32), // /proc//environ + Maps(u32), // /proc//maps + Cwd(u32), // /proc//cwd (symlink) + Exe(u32), // /proc//exe (symlink) + Root_(u32), // /proc//root (symlink) NetDir(Option), // /proc/net or /proc//net NetTcp(Option), // /proc/net/tcp or /proc//net/tcp NetUnix(Option), // /proc/net/unix or /proc//net/unix @@ -336,9 +335,7 @@ pub fn generate_status(proc: &Process) -> Vec { let state_str = match proc.state { crate::process::ProcessState::Running => "R (running)", crate::process::ProcessState::Stopped => "T (stopped)", - crate::process::ProcessState::Exited | crate::process::ProcessState::Limbo => { - "Z (zombie)" - } + crate::process::ProcessState::Exited | crate::process::ProcessState::Limbo => "Z (zombie)", }; let content = format!( @@ -673,8 +670,11 @@ pub fn procfs_open( /// Generate content for a procfs regular file entry. fn generate_content(proc: &Process, entry: &ProcfsEntry) -> Result, Errno> { match entry { - ProcfsEntry::Stat(pid) | ProcfsEntry::Status(pid) | ProcfsEntry::Cmdline(pid) - | ProcfsEntry::Environ(pid) | ProcfsEntry::Maps(pid) => { + ProcfsEntry::Stat(pid) + | ProcfsEntry::Status(pid) + | ProcfsEntry::Cmdline(pid) + | ProcfsEntry::Environ(pid) + | ProcfsEntry::Maps(pid) => { validate_pid(proc, *pid)?; if *pid == proc.pid { match entry { @@ -687,9 +687,13 @@ fn generate_content(proc: &Process, entry: &ProcfsEntry) -> Result, Errn } } else { #[cfg(any(target_arch = "wasm32", target_arch = "wasm64"))] - { crate::wasm_api::procfs_generate_for_pid(*pid, entry).ok_or(Errno::ENOENT) } + { + crate::wasm_api::procfs_generate_for_pid(*pid, entry).ok_or(Errno::ENOENT) + } #[cfg(not(any(target_arch = "wasm32", target_arch = "wasm64")))] - { Err(Errno::ENOENT) } + { + Err(Errno::ENOENT) + } } } ProcfsEntry::FdInfo(pid, fd) => { @@ -698,9 +702,14 @@ fn generate_content(proc: &Process, entry: &ProcfsEntry) -> Result, Errn generate_fdinfo(proc, *fd).ok_or(Errno::ENOENT) } else { #[cfg(any(target_arch = "wasm32", target_arch = "wasm64"))] - { crate::wasm_api::procfs_generate_for_pid(*pid, entry).ok_or(Errno::ENOENT) } + { + crate::wasm_api::procfs_generate_for_pid(*pid, entry).ok_or(Errno::ENOENT) + } #[cfg(not(any(target_arch = "wasm32", target_arch = "wasm64")))] - { let _ = fd; Err(Errno::ENOENT) } + { + let _ = fd; + Err(Errno::ENOENT) + } } } ProcfsEntry::Mounts => Ok(MOUNTS_CONTENT.to_vec()), @@ -1043,7 +1052,11 @@ fn dir_entries( /// Get the process name from argv[0] or thread_name. fn process_name(proc: &Process) -> &str { // Try thread_name first (set by prctl PR_SET_NAME) - let name_len = proc.thread_name.iter().position(|&b| b == 0).unwrap_or(16); + let name_len = proc + .thread_name + .iter() + .position(|&b| b == 0) + .unwrap_or(wasm_posix_shared::kernel_scratch_wire::PRCTL_NAME_BYTES as usize); if name_len > 0 { if let Ok(s) = core::str::from_utf8(&proc.thread_name[..name_len]) { if !s.is_empty() { @@ -1449,14 +1462,8 @@ mod tests { assert!(!exhausted); let mut suffix_buf = [0u8; 4096]; - let (suffix_bytes, resumed_end, exhausted) = procfs_getdents64( - &proc, - b"/proc", - &mut suffix_buf, - resume_cookie, - &pids, - ) - .unwrap(); + let (suffix_bytes, resumed_end, exhausted) = + procfs_getdents64(&proc, b"/proc", &mut suffix_buf, resume_cookie, &pids).unwrap(); assert!(exhausted); assert_eq!(resumed_end, end_cookie); @@ -1478,14 +1485,8 @@ mod tests { let last_name = entries.last().unwrap().0.clone(); let mut exact = vec![0u8; dirent_len(&last_name)]; - let (bytes, cookie, exhausted) = procfs_getdents64( - &proc, - b"/proc", - &mut exact, - end_cookie - 1, - &pids, - ) - .unwrap(); + let (bytes, cookie, exhausted) = + procfs_getdents64(&proc, b"/proc", &mut exact, end_cookie - 1, &pids).unwrap(); assert_eq!(bytes, exact.len()); assert_eq!(cookie, end_cookie); assert!(exhausted); diff --git a/crates/kernel/src/scratch_alloc.rs b/crates/kernel/src/scratch_alloc.rs new file mode 100644 index 0000000000..aadc11fcaa --- /dev/null +++ b/crates/kernel/src/scratch_alloc.rs @@ -0,0 +1,45 @@ +//! Kernel-owned host scratch allocation constraints. + +use alloc::alloc::Layout; + +const SCRATCH_ALIGNMENT: usize = 16; + +pub(crate) fn layout(size: usize) -> Option { + // WHY: `Layout` requires the allocation, including alignment padding, to + // fit in `isize::MAX`. Large u32 requests are therefore outside the + // allocator domain on wasm32. A host-facing allocation request must report + // that as failure instead of trapping while it constructs the layout. + if size == 0 { + return None; + } + Layout::from_size_align(size, SCRATCH_ALIGNMENT).ok() +} + +#[cfg(test)] +mod tests { + use super::{SCRATCH_ALIGNMENT, layout}; + + #[test] + fn rejects_zero_and_sizes_outside_the_allocator_domain() { + assert!(layout(0).is_none()); + assert!(layout(usize::MAX).is_none()); + } + + #[test] + fn accepts_the_exact_aligned_allocator_boundary() { + let maximum = (isize::MAX as usize) & !(SCRATCH_ALIGNMENT - 1); + assert_eq!(layout(maximum).expect("maximum layout").size(), maximum); + assert!(layout(maximum + 1).is_none()); + } + + #[test] + fn accepts_aligned_and_unaligned_ordinary_sizes() { + let aligned = layout(64 * 1024).expect("ordinary channel scratch"); + assert_eq!(aligned.size(), 64 * 1024); + assert_eq!(aligned.align(), SCRATCH_ALIGNMENT); + + let unaligned = layout(65_609).expect("unaligned scratch"); + assert_eq!(unaligned.size(), 65_609); + assert_eq!(unaligned.align(), SCRATCH_ALIGNMENT); + } +} diff --git a/crates/kernel/src/signal.rs b/crates/kernel/src/signal.rs index 98d282c199..16894f8138 100644 --- a/crates/kernel/src/signal.rs +++ b/crates/kernel/src/signal.rs @@ -82,11 +82,7 @@ pub(crate) enum DefaultSignalOutcome { /// Finish a signal-caused process exit, including resource cleanup, before /// publishing the parent-visible exit record. -pub(crate) fn terminate_process_by_signal( - proc: &mut Process, - host: &mut dyn HostIO, - signum: u32, -) { +pub(crate) fn terminate_process_by_signal(proc: &mut Process, host: &mut dyn HostIO, signum: u32) { terminate_process_by_signal_impl(proc, None, host, signum); } @@ -161,7 +157,7 @@ pub(crate) fn dequeue_signal_for( proc: &mut Process, tid: u32, signum: u32, -) -> (u32, i32, i32, i32, i32) { +) -> (u32, u64, i32, i32, i32) { if proc.state == ProcessState::Stopped && signum == wasm_posix_shared::signal::SIGKILL { proc.clear_signal_everywhere(signum); return (signum, 0, 0, proc.pid as i32, proc.uid as i32); @@ -172,9 +168,9 @@ pub(crate) fn dequeue_signal_for( timer_id as i32, proc.accept_posix_timer_notification(timer_id).unwrap_or(0), ), - None => (proc.pid as i32, proc.uid as i32), + None => (info.sender_pid as i32, info.sender_uid as i32), }; - (signum, info.si_value, info.si_code, word_1, word_2) + (signum, info.si_value_bits, info.si_code, word_1, word_2) } /// Consume default/ignored pending signals at a syscall boundary. Caught @@ -259,17 +255,25 @@ pub(crate) fn should_discard_pending(signum: u32, handler: &SignalHandler) -> bo #[derive(Debug, Clone, Copy)] pub struct RtSigEntry { pub signum: u32, - pub si_value: i32, + /// Raw `union sigval` bits, zero-extended when supplied by wasm32. + pub si_value_bits: u64, /// SI_QUEUE (-1) if sent via sigqueue(), SI_USER (0) if via kill()/raise(). pub si_code: i32, + /// Authoritative kernel-derived sender credentials for non-timer signals. + pub sender_pid: u32, + pub sender_uid: u32, /// Owning POSIX timer for SI_TIMER notifications. pub timer_id: Option, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct PendingSignalInfo { - pub si_value: i32, + /// Raw `union sigval` bits, zero-extended when supplied by wasm32. + pub si_value_bits: u64, pub si_code: i32, + /// Authoritative kernel-derived sender credentials for non-timer signals. + pub sender_pid: u32, + pub sender_uid: u32, pub timer_id: Option, } @@ -283,10 +287,14 @@ fn consume_pending_info( } let info = if let Some(pos) = queue.iter().position(|entry| entry.signum == signum) { - let entry = queue.remove(pos).expect("queued signal index remains valid"); + let entry = queue + .remove(pos) + .expect("queued signal index remains valid"); PendingSignalInfo { - si_value: entry.si_value, + si_value_bits: entry.si_value_bits, si_code: entry.si_code, + sender_pid: entry.sender_pid, + sender_uid: entry.sender_uid, timer_id: entry.timer_id, } } else { @@ -299,11 +307,7 @@ fn consume_pending_info( info } -fn remove_timer_info( - pending: &mut u64, - queue: &mut VecDeque, - timer_id: u32, -) -> bool { +fn remove_timer_info(pending: &mut u64, queue: &mut VecDeque, timer_id: u32) -> bool { let signum = match queue .iter() .find(|entry| entry.timer_id == Some(timer_id)) @@ -360,15 +364,39 @@ impl PerThreadSignalState { /// Mark a signal as pending on this thread (via tkill/tgkill/pthread_kill). /// Returns true on success, false for invalid signal numbers. pub fn raise(&mut self, signum: u32) -> bool { - self.raise_internal(signum, 0, 0) // SI_USER + self.raise_internal(signum, 0, 0, 0, 0) // kernel-originated SI_USER } /// Mark a signal as pending with an si_value (sigqueue-style). - pub fn raise_with_value(&mut self, signum: u32, si_value: i32) -> bool { - self.raise_internal(signum, si_value, -1) // SI_QUEUE + pub fn raise_with_value(&mut self, signum: u32, si_value_bits: u64) -> bool { + self.raise_internal(signum, si_value_bits, -1, 0, 0) // SI_QUEUE + } + + pub(crate) fn raise_with_metadata( + &mut self, + signum: u32, + si_value_bits: u64, + si_code: i32, + sender_pid: u32, + sender_uid: u32, + ) -> bool { + self.raise_internal( + signum, + si_value_bits, + si_code, + sender_pid, + sender_uid, + ) } - fn raise_internal(&mut self, signum: u32, si_value: i32, si_code: i32) -> bool { + fn raise_internal( + &mut self, + signum: u32, + si_value_bits: u64, + si_code: i32, + sender_pid: u32, + sender_uid: u32, + ) -> bool { if signum == 0 || signum >= NSIG { return false; } @@ -376,8 +404,10 @@ impl PerThreadSignalState { // RT signals: always queue (multiple instances allowed) self.rt_queue.push_back(RtSigEntry { signum, - si_value, + si_value_bits, si_code, + sender_pid, + sender_uid, timer_id: None, }); } else if let Some(entry) = self @@ -388,14 +418,18 @@ impl PerThreadSignalState { // Standard non-timer signals coalesce independently of timer // notifications using the same signal number. if si_code != 0 { - entry.si_value = si_value; + entry.si_value_bits = si_value_bits; entry.si_code = si_code; + entry.sender_pid = sender_pid; + entry.sender_uid = sender_uid; } } else { self.rt_queue.push_back(RtSigEntry { signum, - si_value, + si_value_bits, si_code, + sender_pid, + sender_uid, timer_id: None, }); } @@ -403,7 +437,12 @@ impl PerThreadSignalState { true } - pub(crate) fn raise_timer(&mut self, signum: u32, si_value: i32, timer_id: u32) -> bool { + pub(crate) fn raise_timer( + &mut self, + signum: u32, + si_value_bits: u64, + timer_id: u32, + ) -> bool { if signum == 0 || signum >= NSIG { return false; } @@ -413,16 +452,20 @@ impl PerThreadSignalState { { self.rt_queue.push_back(RtSigEntry { signum, - si_value: 0, + si_value_bits: 0, si_code: 0, + sender_pid: 0, + sender_uid: 0, timer_id: None, }); } self.pending |= sig_bit(signum); self.rt_queue.push_back(RtSigEntry { signum, - si_value, + si_value_bits, si_code: -2, + sender_pid: 0, + sender_uid: 0, timer_id: Some(timer_id), }); true @@ -447,15 +490,12 @@ impl PerThreadSignalState { /// Consume one directed instance of `signum` regardless of its blocked /// state. Standard signals coalesce; RT signals retain the pending bit /// until their final queued instance is consumed. - pub fn consume_one(&mut self, signum: u32) -> Option<(i32, i32)> { - if signum == 0 - || signum >= NSIG - || self.pending & sig_bit(signum) == 0 - { + pub fn consume_one(&mut self, signum: u32) -> Option<(u64, i32)> { + if signum == 0 || signum >= NSIG || self.pending & sig_bit(signum) == 0 { return None; } let info = self.consume_one_info(signum); - Some((info.si_value, info.si_code)) + Some((info.si_value_bits, info.si_code)) } pub fn is_pending(&self, signum: u32) -> bool { @@ -472,7 +512,7 @@ impl PerThreadSignalState { /// Dequeue the lowest-numbered deliverable signal on this thread. /// Returns (signum, si_value, si_code) or None. - pub fn dequeue(&mut self) -> Option<(u32, i32, i32)> { + pub fn dequeue(&mut self) -> Option<(u32, u64, i32)> { let deliverable = self.pending & !self.blocked; if deliverable == 0 { return None; @@ -597,15 +637,39 @@ impl SignalState { /// Standard signals (1-31) are coalesced. RT signals (32-63) are queued. /// Bit position = signum - 1 (musl convention: signal N uses bit N-1). pub fn raise(&mut self, signum: u32) -> bool { - self.raise_internal(signum, 0, 0) // SI_USER + self.raise_internal(signum, 0, 0, 0, 0) // kernel-originated SI_USER } /// Mark a signal as pending with an si_value (for sigqueue — SI_QUEUE). - pub fn raise_with_value(&mut self, signum: u32, si_value: i32) -> bool { - self.raise_internal(signum, si_value, -1) // SI_QUEUE + pub fn raise_with_value(&mut self, signum: u32, si_value_bits: u64) -> bool { + self.raise_internal(signum, si_value_bits, -1, 0, 0) // SI_QUEUE + } + + pub(crate) fn raise_with_metadata( + &mut self, + signum: u32, + si_value_bits: u64, + si_code: i32, + sender_pid: u32, + sender_uid: u32, + ) -> bool { + self.raise_internal( + signum, + si_value_bits, + si_code, + sender_pid, + sender_uid, + ) } - fn raise_internal(&mut self, signum: u32, si_value: i32, si_code: i32) -> bool { + fn raise_internal( + &mut self, + signum: u32, + si_value_bits: u64, + si_code: i32, + sender_pid: u32, + sender_uid: u32, + ) -> bool { if signum == 0 || signum >= 65 { return false; } @@ -616,8 +680,10 @@ impl SignalState { // RT signals: always queue (multiple instances allowed) self.rt_queue.push_back(RtSigEntry { signum, - si_value, + si_value_bits, si_code, + sender_pid, + sender_uid, timer_id: None, }); } else if let Some(entry) = self @@ -628,14 +694,18 @@ impl SignalState { // Standard non-timer signals coalesce independently of timer // notifications using the same signal number. if si_code != 0 { - entry.si_value = si_value; + entry.si_value_bits = si_value_bits; entry.si_code = si_code; + entry.sender_pid = sender_pid; + entry.sender_uid = sender_uid; } } else { self.rt_queue.push_back(RtSigEntry { signum, - si_value, + si_value_bits, si_code, + sender_pid, + sender_uid, timer_id: None, }); } @@ -643,7 +713,12 @@ impl SignalState { true } - pub(crate) fn raise_timer(&mut self, signum: u32, si_value: i32, timer_id: u32) -> bool { + pub(crate) fn raise_timer( + &mut self, + signum: u32, + si_value_bits: u64, + timer_id: u32, + ) -> bool { if signum == 0 || signum >= NSIG { return false; } @@ -653,16 +728,20 @@ impl SignalState { { self.rt_queue.push_back(RtSigEntry { signum, - si_value: 0, + si_value_bits: 0, si_code: 0, + sender_pid: 0, + sender_uid: 0, timer_id: None, }); } self.pending |= sig_bit(signum); self.rt_queue.push_back(RtSigEntry { signum, - si_value, + si_value_bits, si_code: -2, + sender_pid: 0, + sender_uid: 0, timer_id: Some(timer_id), }); true @@ -747,7 +826,7 @@ impl SignalState { /// RT signals (32-63) are dequeued from the queue; the pending bit is /// only cleared when no more instances of that signal remain in the queue. /// Returns (signum, si_value, si_code). si_value and si_code are 0 for standard signals. - pub fn dequeue(&mut self) -> Option<(u32, i32, i32)> { + pub fn dequeue(&mut self) -> Option<(u32, u64, i32)> { let deliverable = self.pending & !self.blocked; if deliverable == 0 { return None; @@ -755,7 +834,7 @@ impl SignalState { // trailing_zeros gives 0-based bit position; signal number = bit + 1 let signum = deliverable.trailing_zeros() + 1; let info = self.consume_one(signum); - Some((signum, info.si_value, info.si_code)) + Some((signum, info.si_value_bits, info.si_code)) } /// Check if the next deliverable signal has SA_RESTART set. @@ -813,8 +892,10 @@ impl SignalState { if (pending & sig_bit(sig)) != 0 { rt_queue.push_back(RtSigEntry { signum: sig, - si_value: 0, + si_value_bits: 0, si_code: 0, + sender_pid: 0, + sender_uid: 0, timer_id: None, }); } @@ -960,24 +1041,13 @@ mod tests { let mut locks = AdvisoryLockManager::new(); let mut host = NoopHost; assert_eq!( - deliver_pending_signals_for_tid_with_locks( - &mut proc, - &mut locks, - &mut host, - 70, - ), + deliver_pending_signals_for_tid_with_locks(&mut proc, &mut locks, &mut host, 70,), Err(Errno::ESRCH), ); assert_eq!(proc.state, ProcessState::Running); assert!(proc.signals.is_pending(SIGTERM)); - deliver_pending_signals_for_tid_with_locks( - &mut proc, - &mut locks, - &mut host, - 61, - ) - .unwrap(); + deliver_pending_signals_for_tid_with_locks(&mut proc, &mut locks, &mut host, 61).unwrap(); assert_eq!(proc.state, ProcessState::Exited); } @@ -1214,13 +1284,13 @@ mod tests { let first = state.consume_one(SIGUSR1); assert_eq!(first.si_code, -2); - assert_eq!(first.si_value, 41); + assert_eq!(first.si_value_bits, 41); assert_eq!(first.timer_id, Some(3)); assert!(state.is_pending(SIGUSR1)); let second = state.consume_one(SIGUSR1); assert_eq!(second.si_code, -2); - assert_eq!(second.si_value, 42); + assert_eq!(second.si_value_bits, 42); assert_eq!(second.timer_id, Some(4)); assert!(!state.is_pending(SIGUSR1)); } @@ -1235,7 +1305,7 @@ mod tests { assert!(state.is_pending(SIGUSR1)); let remaining = state.consume_one(SIGUSR1); assert_eq!(remaining.timer_id, Some(4)); - assert_eq!(remaining.si_value, 42); + assert_eq!(remaining.si_value_bits, 42); assert!(!state.is_pending(SIGUSR1)); } @@ -1248,13 +1318,13 @@ mod tests { let timer = state.consume_one(SIGUSR1); assert_eq!(timer.timer_id, Some(3)); assert_eq!(timer.si_code, -2); - assert_eq!(timer.si_value, 41); + assert_eq!(timer.si_value_bits, 41); assert!(state.is_pending(SIGUSR1)); let queued = state.consume_one(SIGUSR1); assert_eq!(queued.timer_id, None); assert_eq!(queued.si_code, -1); - assert_eq!(queued.si_value, 99); + assert_eq!(queued.si_value_bits, 99); assert!(!state.is_pending(SIGUSR1)); } @@ -1269,7 +1339,7 @@ mod tests { let plain = state.consume_one(SIGUSR1); assert_eq!(plain.timer_id, None); assert_eq!(plain.si_code, 0); - assert_eq!(plain.si_value, 0); + assert_eq!(plain.si_value_bits, 0); assert!(!state.is_pending(SIGUSR1)); } diff --git a/crates/kernel/src/socket.rs b/crates/kernel/src/socket.rs index 0bf5795d99..33e87a499b 100644 --- a/crates/kernel/src/socket.rs +++ b/crates/kernel/src/socket.rs @@ -87,7 +87,6 @@ pub enum SocketState { } /// A received UDP datagram. -#[derive(Clone)] pub struct Datagram { pub data: Vec, pub src_addr: [u8; 4], @@ -539,12 +538,7 @@ pub fn tcp6_can_bind(pid: u32, sock_idx: usize, addr: [u8; 16], port: u16) -> bo }) } -pub fn tcp6_register( - pid: u32, - sock_idx: usize, - addr: [u8; 16], - port: u16, -) -> Result<(), Errno> { +pub fn tcp6_register(pid: u32, sock_idx: usize, addr: [u8; 16], port: u16) -> Result<(), Errno> { if port == 0 { return Err(Errno::EINVAL); } @@ -1092,32 +1086,34 @@ mod tests { // A dual-stack wildcard listener reserves the same socket identity in // both protocol-family tables. tcp6_register(PARENT, DUAL_STACK_TCP_IDX, [0; 16], TCP_PORT).unwrap(); - tcp_register( - PARENT, - DUAL_STACK_TCP_IDX, - [0, 0, 0, 0], - TCP_PORT, - ) - .unwrap(); + tcp_register(PARENT, DUAL_STACK_TCP_IDX, [0, 0, 0, 0], TCP_PORT).unwrap(); inherit_inet_binding_owners(PARENT, CHILD, UDP4_IDX); inherit_inet_binding_owners(PARENT, CHILD, UDP6_IDX); inherit_inet_binding_owners(PARENT, CHILD, DUAL_STACK_TCP_IDX); let udp4_targets = udp_lookup([127, 0, 0, 1], UDP4_PORT); - assert!(udp4_targets - .iter() - .any(|target| target.pid == PARENT && target.sock_idx == UDP4_IDX)); - assert!(udp4_targets - .iter() - .any(|target| target.pid == CHILD && target.sock_idx == UDP4_IDX)); + assert!( + udp4_targets + .iter() + .any(|target| target.pid == PARENT && target.sock_idx == UDP4_IDX) + ); + assert!( + udp4_targets + .iter() + .any(|target| target.pid == CHILD && target.sock_idx == UDP4_IDX) + ); let udp6_targets = udp6_lookup(LOOPBACK6, UDP6_PORT); - assert!(udp6_targets - .iter() - .any(|target| target.pid == PARENT && target.sock_idx == UDP6_IDX)); - assert!(udp6_targets - .iter() - .any(|target| target.pid == CHILD && target.sock_idx == UDP6_IDX)); + assert!( + udp6_targets + .iter() + .any(|target| target.pid == PARENT && target.sock_idx == UDP6_IDX) + ); + assert!( + udp6_targets + .iter() + .any(|target| target.pid == CHILD && target.sock_idx == UDP6_IDX) + ); // Parent close removes only the parent's process-local identity. udp_unregister(PARENT, UDP4_IDX); @@ -1127,10 +1123,16 @@ mod tests { let udp4_targets = udp_lookup([127, 0, 0, 1], UDP4_PORT); assert_eq!(udp4_targets.len(), 1); - assert_eq!((udp4_targets[0].pid, udp4_targets[0].sock_idx), (CHILD, UDP4_IDX)); + assert_eq!( + (udp4_targets[0].pid, udp4_targets[0].sock_idx), + (CHILD, UDP4_IDX) + ); let udp6_targets = udp6_lookup(LOOPBACK6, UDP6_PORT); assert_eq!(udp6_targets.len(), 1); - assert_eq!((udp6_targets[0].pid, udp6_targets[0].sock_idx), (CHILD, UDP6_IDX)); + assert_eq!( + (udp6_targets[0].pid, udp6_targets[0].sock_idx), + (CHILD, UDP6_IDX) + ); assert!(!udp_can_bind( CONTENDER, 1, @@ -1138,19 +1140,8 @@ mod tests { UDP4_PORT, false )); - assert!(!udp6_can_bind( - CONTENDER, - 2, - LOOPBACK6, - UDP6_PORT, - false - )); - assert!(!tcp_can_bind( - CONTENDER, - 3, - [0, 0, 0, 0], - TCP_PORT - )); + assert!(!udp6_can_bind(CONTENDER, 2, LOOPBACK6, UDP6_PORT, false)); + assert!(!tcp_can_bind(CONTENDER, 3, [0, 0, 0, 0], TCP_PORT)); assert!(!tcp6_can_bind(CONTENDER, 3, [0; 16], TCP_PORT)); // Final close drops each logical reservation. @@ -1160,26 +1151,9 @@ mod tests { tcp6_unregister(CHILD, DUAL_STACK_TCP_IDX); assert!(udp_lookup([127, 0, 0, 1], UDP4_PORT).is_empty()); assert!(udp6_lookup(LOOPBACK6, UDP6_PORT).is_empty()); - assert!(udp_can_bind( - CONTENDER, - 1, - [127, 0, 0, 1], - UDP4_PORT, - false - )); - assert!(udp6_can_bind( - CONTENDER, - 2, - LOOPBACK6, - UDP6_PORT, - false - )); - assert!(tcp_can_bind( - CONTENDER, - 3, - [0, 0, 0, 0], - TCP_PORT - )); + assert!(udp_can_bind(CONTENDER, 1, [127, 0, 0, 1], UDP4_PORT, false)); + assert!(udp6_can_bind(CONTENDER, 2, LOOPBACK6, UDP6_PORT, false)); + assert!(tcp_can_bind(CONTENDER, 3, [0, 0, 0, 0], TCP_PORT)); assert!(tcp6_can_bind(CONTENDER, 3, [0; 16], TCP_PORT)); } @@ -1199,21 +1173,22 @@ mod tests { inherit_inet_binding_owners(PARENT, CHILD, FIRST_IDX); let targets = udp_lookup([127, 0, 0, 1], PORT); assert_eq!(targets.len(), 3); - assert!(targets - .iter() - .any(|target| target.pid == CHILD && target.sock_idx == FIRST_IDX)); - assert!(!targets - .iter() - .any(|target| target.pid == CHILD && target.sock_idx == SECOND_IDX)); + assert!( + targets + .iter() + .any(|target| target.pid == CHILD && target.sock_idx == FIRST_IDX) + ); + assert!( + !targets + .iter() + .any(|target| target.pid == CHILD && target.sock_idx == SECOND_IDX) + ); udp_unregister(PARENT, FIRST_IDX); udp_unregister(CHILD, FIRST_IDX); let targets = udp_lookup([127, 0, 0, 1], PORT); assert_eq!(targets.len(), 1); - assert_eq!( - (targets[0].pid, targets[0].sock_idx), - (PARENT, SECOND_IDX) - ); + assert_eq!((targets[0].pid, targets[0].sock_idx), (PARENT, SECOND_IDX)); udp_unregister(PARENT, SECOND_IDX); } diff --git a/crates/kernel/src/socket_wire.rs b/crates/kernel/src/socket_wire.rs new file mode 100644 index 0000000000..bfe18e3e62 --- /dev/null +++ b/crates/kernel/src/socket_wire.rs @@ -0,0 +1,249 @@ +use core::mem::{align_of, offset_of, size_of}; + +use wasm_posix_shared::socket::{ + KERNEL_MESSAGE_WIRE_FLATTENED_IOVEC_COUNT, SCM_RIGHTS, SCM_RIGHTS_FD_BYTES, SOL_SOCKET, +}; +use wasm_posix_shared::{Errno, KernelCmsghdrWire}; + +// The parser below intentionally reads one flattened record. Make a protocol +// constant change fail compilation until that parser is updated in lockstep. +const _: [(); 1] = [(); KERNEL_MESSAGE_WIRE_FLATTENED_IOVEC_COUNT as usize]; + +fn read_wire_u32(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes( + bytes[offset..offset + size_of::()] + .try_into() + .expect("fixed wire u32 range"), + ) +} + +fn canonical_control_record_space(cmsg_len: usize, remaining: usize) -> Result { + let header_size = size_of::(); + let alignment = align_of::(); + if cmsg_len < header_size || cmsg_len > remaining { + return Err(Errno::EINVAL); + } + let aligned_len = cmsg_len.checked_add(alignment - 1).ok_or(Errno::EINVAL)? & !(alignment - 1); + if aligned_len > remaining { + return Err(Errno::EINVAL); + } + Ok(aligned_len) +} + +/// Visit every descriptor in a canonical kernel SCM_RIGHTS control wire. +/// +/// The visitor owns descriptor lookup and resource serialization. Keeping +/// those process operations outside this pure parser lets both native tests +/// and the Wasm export enforce exactly the same record bounds and alignment. +pub(crate) fn for_each_canonical_scm_rights_fd( + control: &[u8], + mut visit: impl FnMut(i32) -> Result<(), Errno>, +) -> Result<(), Errno> { + let header_size = size_of::(); + let len_offset = offset_of!(KernelCmsghdrWire, cmsg_len); + let level_offset = offset_of!(KernelCmsghdrWire, cmsg_level); + let type_offset = offset_of!(KernelCmsghdrWire, cmsg_type); + let mut offset = 0; + + while offset < control.len() { + let remaining = &control[offset..]; + if remaining.len() < header_size { + return Err(Errno::EINVAL); + } + let cmsg_len = read_wire_u32(remaining, len_offset) as usize; + let cmsg_level = read_wire_u32(remaining, level_offset); + let cmsg_type = read_wire_u32(remaining, type_offset); + // WHY: prove the complete aligned record fits before slicing or + // advancing, so an attacker-controlled length cannot wrap or leave a + // truncated final record accepted as a valid prefix. + let record_space = canonical_control_record_space(cmsg_len, remaining.len())?; + let record = &remaining[..cmsg_len]; + + if cmsg_level == SOL_SOCKET && cmsg_type == SCM_RIGHTS { + let data = &record[header_size..]; + if data.is_empty() || data.len() % SCM_RIGHTS_FD_BYTES != 0 { + return Err(Errno::EINVAL); + } + for encoded_fd in data.chunks_exact(SCM_RIGHTS_FD_BYTES) { + let fd = i32::from_le_bytes( + encoded_fd + .try_into() + .expect("validated SCM_RIGHTS fd width"), + ); + visit(fd)?; + } + } + + offset += record_space; + } + + Ok(()) +} + +pub(crate) fn validate_canonical_message_iov_len(iov_len: u32) -> Result<(), Errno> { + match iov_len { + 0 | KERNEL_MESSAGE_WIRE_FLATTENED_IOVEC_COUNT => Ok(()), + _ => Err(Errno::EINVAL), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::{vec, vec::Vec}; + + fn control_record(cmsg_len: u32, storage_len: usize, level: u32, control_type: u32) -> Vec { + let mut control = vec![0; storage_len]; + if storage_len >= size_of::() { + control[offset_of!(KernelCmsghdrWire, cmsg_len) + ..offset_of!(KernelCmsghdrWire, cmsg_len) + size_of::()] + .copy_from_slice(&cmsg_len.to_le_bytes()); + control[offset_of!(KernelCmsghdrWire, cmsg_level) + ..offset_of!(KernelCmsghdrWire, cmsg_level) + size_of::()] + .copy_from_slice(&level.to_le_bytes()); + control[offset_of!(KernelCmsghdrWire, cmsg_type) + ..offset_of!(KernelCmsghdrWire, cmsg_type) + size_of::()] + .copy_from_slice(&control_type.to_le_bytes()); + } + control + } + + fn scm_rights_control(fds: &[i32]) -> Vec { + let header_size = size_of::(); + let cmsg_len = header_size + fds.len() * SCM_RIGHTS_FD_BYTES; + let mut control = control_record(cmsg_len as u32, cmsg_len, SOL_SOCKET, SCM_RIGHTS); + for (index, fd) in fds.iter().enumerate() { + let offset = header_size + index * SCM_RIGHTS_FD_BYTES; + control[offset..offset + SCM_RIGHTS_FD_BYTES].copy_from_slice(&fd.to_le_bytes()); + } + control + } + + #[test] + fn canonical_control_rejects_wrapped_record_space() { + assert_eq!( + canonical_control_record_space(usize::MAX, usize::MAX), + Err(Errno::EINVAL), + ); + + let wrapped_wire = control_record( + u32::MAX, + size_of::(), + SOL_SOCKET, + SCM_RIGHTS, + ); + assert_eq!( + for_each_canonical_scm_rights_fd(&wrapped_wire, |_| Ok(())), + Err(Errno::EINVAL), + ); + } + + #[test] + fn canonical_control_rejects_short_headers_and_records() { + let partial_header = vec![0; size_of::() - 1]; + assert_eq!( + for_each_canonical_scm_rights_fd(&partial_header, |_| Ok(())), + Err(Errno::EINVAL), + ); + + let short_record = control_record( + (size_of::() - 1) as u32, + size_of::(), + SOL_SOCKET, + SCM_RIGHTS, + ); + assert_eq!( + for_each_canonical_scm_rights_fd(&short_record, |_| Ok(())), + Err(Errno::EINVAL), + ); + + let empty_rights = control_record( + size_of::() as u32, + size_of::(), + SOL_SOCKET, + SCM_RIGHTS, + ); + assert_eq!( + for_each_canonical_scm_rights_fd(&empty_rights, |_| Ok(())), + Err(Errno::EINVAL), + ); + + let partial_fd_len = size_of::() + 1; + let partial_fd = control_record( + partial_fd_len as u32, + (partial_fd_len + align_of::() - 1) + & !(align_of::() - 1), + SOL_SOCKET, + SCM_RIGHTS, + ); + assert_eq!( + for_each_canonical_scm_rights_fd(&partial_fd, |_| Ok(())), + Err(Errno::EINVAL), + ); + } + + #[test] + fn canonical_control_rejects_overlong_records_and_trailing_bytes() { + let overlong = control_record( + (size_of::() + SCM_RIGHTS_FD_BYTES) as u32, + size_of::(), + SOL_SOCKET, + SCM_RIGHTS, + ); + assert_eq!( + for_each_canonical_scm_rights_fd(&overlong, |_| Ok(())), + Err(Errno::EINVAL), + ); + + let trailing = control_record( + size_of::() as u32, + size_of::() + 1, + 0, + 0, + ); + assert_eq!( + for_each_canonical_scm_rights_fd(&trailing, |_| Ok(())), + Err(Errno::EINVAL), + ); + } + + #[test] + fn canonical_control_visits_all_scm_rights_descriptors() { + let mut control = scm_rights_control(&[3, 17]); + control.extend_from_slice(&scm_rights_control(&[23])); + let mut visited = Vec::new(); + for_each_canonical_scm_rights_fd(&control, |fd| { + visited.push(fd); + Ok(()) + }) + .unwrap(); + assert_eq!(visited, vec![3, 17, 23]); + } + + #[test] + fn invalid_descriptor_lookup_error_is_ebadf() { + let control = scm_rights_control(&[123]); + assert_eq!( + for_each_canonical_scm_rights_fd(&control, |fd| { + assert_eq!(fd, 123); + Err(Errno::EBADF) + }), + Err(Errno::EBADF), + ); + } + + #[test] + fn canonical_message_iov_len_accepts_only_zero_or_one() { + let exact = KERNEL_MESSAGE_WIRE_FLATTENED_IOVEC_COUNT; + assert_eq!(validate_canonical_message_iov_len(0), Ok(())); + assert_eq!(validate_canonical_message_iov_len(exact), Ok(())); + assert_eq!( + validate_canonical_message_iov_len(exact + 1), + Err(Errno::EINVAL), + ); + assert_eq!( + validate_canonical_message_iov_len(u32::MAX), + Err(Errno::EINVAL), + ); + } +} diff --git a/crates/kernel/src/spawn.rs b/crates/kernel/src/spawn.rs index d2fd011c10..6afb0e34c5 100644 --- a/crates/kernel/src/spawn.rs +++ b/crates/kernel/src/spawn.rs @@ -7,37 +7,216 @@ extern crate alloc; use alloc::vec::Vec; -use wasm_posix_shared::Errno; +use spin::Mutex; +use wasm_posix_shared::{Errno, spawn_contract}; -/// Bit flags from `posix_spawnattr_t::__flags`. Values match POSIX / musl -/// (`libc/musl/include/spawn.h`): +/// Kernel-owned reusable transport for SYS_SPAWN blobs larger than the normal +/// syscall-channel scratch region. /// -/// ```text -/// POSIX_SPAWN_RESETIDS = 1 -/// POSIX_SPAWN_SETPGROUP = 2 -/// POSIX_SPAWN_SETSIGDEF = 4 -/// POSIX_SPAWN_SETSIGMASK = 8 -/// POSIX_SPAWN_SETSCHEDPARAM = 16 -/// POSIX_SPAWN_SETSCHEDULER = 32 -/// POSIX_SPAWN_USEVFORK = 64 -/// POSIX_SPAWN_SETSID = 128 -/// ``` +/// A positive token represents the one reservation whose bytes the host may +/// currently replace. Growing is allowed only while idle, and parsing consumes +/// the matching token before any process-table or host-import work begins. +struct SpawnScratchBuffer { + bytes: Vec, + reservation: Option, + next_token: Option, +} + +impl SpawnScratchBuffer { + const fn new() -> Self { + Self { + bytes: Vec::new(), + reservation: None, + next_token: Some(1), + } + } + + fn begin(&mut self, minimum_capacity: usize) -> Result { + self.begin_with_reserve(minimum_capacity, |bytes, additional| { + bytes + .try_reserve_exact(additional) + .map_err(|_| Errno::ENOMEM) + }) + } + + fn begin_with_reserve( + &mut self, + minimum_capacity: usize, + reserve: impl FnOnce(&mut Vec, usize) -> Result<(), Errno>, + ) -> Result { + if minimum_capacity == 0 { + return Err(Errno::EINVAL); + } + if minimum_capacity > spawn_contract::WIRE_MAX_BYTES { + return Err(Errno::E2BIG); + } + if self.reservation.is_some() { + return Err(Errno::EBUSY); + } + let token = self.next_token.ok_or(Errno::EOVERFLOW)?; + if self.bytes.len() < minimum_capacity { + let additional = minimum_capacity - self.bytes.len(); + reserve(&mut self.bytes, additional)?; + // Expose only initialized bytes to the host. `try_reserve_exact` + // has already proven this resize cannot allocate or fail. + let capacity = self.bytes.capacity(); + self.bytes.resize(capacity, 0); + } + self.reservation = Some(token); + self.next_token = token.checked_add(1); + Ok(token) + } + + fn pointer(&mut self, token: i64) -> Result { + if token <= 0 || self.reservation != Some(token) { + return Err(Errno::EINVAL); + } + Ok(self.bytes.as_mut_ptr() as usize) + } + + fn capacity(&self, token: i64) -> Result { + if token <= 0 || self.reservation != Some(token) { + return Err(Errno::EINVAL); + } + Ok(self.bytes.len()) + } + + fn retained_capacity(&self) -> usize { + self.bytes.len() + } + + fn cancel(&mut self, token: i64) -> Result<(), Errno> { + if token <= 0 || self.reservation != Some(token) { + return Err(Errno::EINVAL); + } + self.reservation = None; + Ok(()) + } + + fn parse_reserved(&mut self, token: i64, length: usize) -> Result { + if token <= 0 || self.reservation != Some(token) { + return Err(Errno::EINVAL); + } + + // WHY: a matching commit consumes the reservation even when its + // length or wire bytes are malformed. A failed caller cannot strand + // the reusable allocation in Busy forever, while a stale token cannot + // cancel or consume the current operation. + self.reservation = None; + let bytes = self.bytes.get(..length).ok_or(Errno::E2BIG)?; + parse_blob(bytes) + } +} + +struct GlobalSpawnScratch { + inner: Mutex, +} + +impl GlobalSpawnScratch { + const fn new() -> Self { + Self { + inner: Mutex::new(SpawnScratchBuffer::new()), + } + } + + fn begin(&self, minimum_capacity: usize) -> Result { + self.inner + .try_lock() + .ok_or(Errno::EBUSY)? + .begin(minimum_capacity) + } + + fn pointer(&self, token: i64) -> Result { + self.inner.try_lock().ok_or(Errno::EBUSY)?.pointer(token) + } + + fn capacity(&self, token: i64) -> Result { + self.inner.try_lock().ok_or(Errno::EBUSY)?.capacity(token) + } + + fn retained_capacity(&self) -> Result { + Ok(self + .inner + .try_lock() + .ok_or(Errno::EBUSY)? + .retained_capacity()) + } + + fn cancel(&self, token: i64) -> Result<(), Errno> { + // WHY: cancellation is the fail-safe that releases host write + // authority. This critical section performs no host import or callback, + // so a blocking lock cannot re-enter this mutex and cannot strand a + // matching reservation merely because another Wasm thread contended. + self.inner.lock().cancel(token) + } + + fn parse_reserved(&self, token: i64, length: usize) -> Result { + // WHY: commit must either consume the matching token or establish that + // the token is stale. Nothing under this guard imports host code; keep + // that no-callback property so blocking contention cannot deadlock or + // return before the host has a definitive reservation state. + self.inner.lock().parse_reserved(token, length) + } +} + +static SPAWN_SCRATCH: GlobalSpawnScratch = GlobalSpawnScratch::new(); + +/// Begin one exclusive host-write reservation for a complete spawn blob. +pub fn begin_spawn_scratch(minimum_capacity: usize) -> Result { + SPAWN_SCRATCH.begin(minimum_capacity) +} + +/// Pointer owned by exactly the reservation named by `token`. +pub fn spawn_scratch_pointer(token: i64) -> Result { + SPAWN_SCRATCH.pointer(token) +} + +/// Writable byte capacity of exactly the reservation named by `token`. +pub fn spawn_scratch_capacity(token: i64) -> Result { + SPAWN_SCRATCH.capacity(token) +} + +/// Retained byte capacity of the reusable allocation. /// -/// `posix_spawn.c` passes `a->__flags` into the SYS_SPAWN blob unmodified, -/// so these values must align byte-for-byte with the libc constants. +/// This diagnostic reveals no pointer and grants no authority to mutate the +/// allocation. Active reservation access remains token-gated. +pub fn spawn_scratch_retained_capacity() -> Result { + SPAWN_SCRATCH.retained_capacity() +} + +/// Cancel exactly the reservation named by `token`. +pub fn cancel_spawn_scratch(token: i64) -> Result<(), Errno> { + SPAWN_SCRATCH.cancel(token) +} + +/// Consume and parse exactly the reservation named by `token`. +/// +/// The returned representation owns all argv, environment, and action bytes, +/// so the scratch mutex is released before callers enter the process table or +/// invoke any host import. +pub fn parse_reserved_spawn_blob(token: i64, length: usize) -> Result { + SPAWN_SCRATCH.parse_reserved(token, length) +} + +/// Implemented bits from `posix_spawnattr_t::__flags`. +/// +/// `posix_spawn.c` transports every musl flag bit unmodified, and the complete +/// numeric contract lives in `wasm_posix_shared::spawn_contract`. Reexport only +/// the subset the process table actually interprets so a transport constant +/// cannot be mistaken for implemented POSIX behavior. pub mod attr_flags { - pub const SETPGROUP: u32 = 0x02; - pub const SETSIGDEF: u32 = 0x04; - pub const SETSIGMASK: u32 = 0x08; - pub const SETSID: u32 = 0x80; + pub use wasm_posix_shared::spawn_contract::{ + ATTR_SETPGROUP as SETPGROUP, ATTR_SETSID as SETSID, ATTR_SETSIGDEF as SETSIGDEF, + ATTR_SETSIGMASK as SETSIGMASK, + }; } /// Attributes carried by `posix_spawnattr_t`, parsed out of the SYS_SPAWN /// blob by the host and handed to the kernel. /// -/// Only the attribute kinds we currently support land here. POSIX defines -/// additional ones (SETSCHEDPARAM, SETSCHEDULER, RESETIDS) that we don't -/// need yet — the host-side parser ignores them. +/// Only the attribute kinds we currently support are interpreted. The +/// transported RESETIDS, SETSCHEDPARAM, SETSCHEDULER, and USEVFORK bits remain +/// visible in `flags`, but the process table does not implement their behavior. #[derive(Debug, Clone, Copy)] pub struct SpawnAttrs { pub flags: u32, @@ -91,12 +270,12 @@ pub enum FileAction { // Wire format (little-endian, from // `docs/plans/2026-05-04-non-forking-posix-spawn-design.md` Section 1): // -// header (40 bytes): +// header (`spawn_contract::WIRE_HEADER_BYTES`): // argc:u32 envc:u32 n_actions:u32 attr_flags:u32 // pgrp:i32 _pad:u32 sigdef:u64 sigmask:u64 // argv_offsets: u32 × argc (offsets into strings[]) // envp_offsets: u32 × envc -// actions: action_record × n_actions (28 bytes each) +// actions: action_record × n_actions // strings: u8[] (null-terminated entries) // // `action_record = { op:u32, fd:i32, newfd:i32, path_off:u32, path_len:u32, @@ -107,16 +286,12 @@ pub enum FileAction { /// File-action `op` codes shared with `libc/glue/posix_spawn.c`. pub mod fdop { - pub const OPEN: u32 = 0; - pub const CLOSE: u32 = 1; - pub const DUP2: u32 = 2; - pub const CHDIR: u32 = 3; - pub const FCHDIR: u32 = 4; + pub use wasm_posix_shared::spawn_contract::{ + WIRE_OP_CHDIR as CHDIR, WIRE_OP_CLOSE as CLOSE, WIRE_OP_DUP2 as DUP2, + WIRE_OP_FCHDIR as FCHDIR, WIRE_OP_OPEN as OPEN, + }; } -const HEADER_LEN: usize = 40; -const ACTION_RECORD_LEN: usize = 28; - /// Parsed view over a SYS_SPAWN blob. argv/envp/path bytes are owned (copied /// out of the blob) so the caller is free to drop the underlying buffer /// before feeding this into `ProcessTable::spawn_child`. @@ -145,73 +320,91 @@ fn read_u64(bytes: &[u8], off: usize) -> Result { Ok(u64::from_le_bytes(buf)) } -/// Resolve a `(off, len)` pair against the strings region — both must be -/// in-bounds and `len` must include exactly one trailing NUL or no NUL at -/// all (the parser strips trailing NUL). -fn read_string(strings: &[u8], off: u32, len: u32) -> Result, Errno> { +/// Resolve an action-path `(off, len)` pair against the strings region. +/// +/// `len` is musl's `strlen(path) + 1`, so the referenced range must contain +/// exactly one terminal NUL. WHY: accepting an absent or interior terminator +/// gives the producer and parser different path boundaries for the same wire +/// record. +fn read_action_path(strings: &[u8], off: u32, len: u32) -> Result, Errno> { let off = off as usize; let len = len as usize; let raw = strings .get(off..off.checked_add(len).ok_or(Errno::EINVAL)?) .ok_or(Errno::EINVAL)?; - // Permit either a trailing NUL or no NUL. - let trimmed = if raw.last() == Some(&0u8) { - &raw[..raw.len() - 1] - } else { - raw - }; - Ok(trimmed.to_vec()) + let (&terminator, path) = raw.split_last().ok_or(Errno::EINVAL)?; + if terminator != 0 || path.contains(&0) { + return Err(Errno::EINVAL); + } + if path.len() >= spawn_contract::POSIX_PATH_MAX_BYTES { + return Err(Errno::ENAMETOOLONG); + } + Ok(path.to_vec()) } /// Parse a SYS_SPAWN blob. Bails with `Errno::EINVAL` on any malformed /// offset, length, or op code. pub fn parse_blob(bytes: &[u8]) -> Result { - if bytes.len() < HEADER_LEN { + if bytes.len() > spawn_contract::WIRE_MAX_BYTES { + return Err(Errno::E2BIG); + } + if bytes.len() < spawn_contract::WIRE_HEADER_BYTES { return Err(Errno::EINVAL); } - let argc = read_u32(bytes, 0)? as usize; - let envc = read_u32(bytes, 4)? as usize; - let n_actions = read_u32(bytes, 8)? as usize; - let attr_flags = read_u32(bytes, 12)?; - let pgrp = read_i32(bytes, 16)?; - // bytes 20..24 = _pad - let sigdef = read_u64(bytes, 24)?; - let sigmask = read_u64(bytes, 32)?; + let argc = read_u32(bytes, spawn_contract::WIRE_HEADER_ARGC_OFFSET)? as usize; + let envc = read_u32(bytes, spawn_contract::WIRE_HEADER_ENVC_OFFSET)? as usize; + let n_actions = read_u32(bytes, spawn_contract::WIRE_HEADER_ACTION_COUNT_OFFSET)? as usize; + let attr_flags = read_u32(bytes, spawn_contract::WIRE_HEADER_ATTR_FLAGS_OFFSET)?; + let pgrp = read_i32(bytes, spawn_contract::WIRE_HEADER_PGRP_OFFSET)?; + // WIRE_HEADER_PAD_OFFSET names the reserved u32; readers intentionally + // ignore its value until a later ABI gives that field semantics. + let sigdef = read_u64(bytes, spawn_contract::WIRE_HEADER_SIGDEF_OFFSET)?; + let sigmask = read_u64(bytes, spawn_contract::WIRE_HEADER_SIGMASK_OFFSET)?; // Cap counts to avoid pathological allocations on malformed input. // Real callers would never approach these limits. - const MAX_ARGV: usize = 4096; - const MAX_ENVP: usize = 4096; - const MAX_ACTIONS: usize = 1024; - if argc > MAX_ARGV || envc > MAX_ENVP || n_actions > MAX_ACTIONS { + if argc > spawn_contract::MAX_ARGV_COUNT + || envc > spawn_contract::MAX_ENVP_COUNT + || n_actions > spawn_contract::MAX_ACTION_COUNT + { return Err(Errno::EINVAL); } - let mut cursor = HEADER_LEN; + let mut cursor = spawn_contract::WIRE_HEADER_BYTES; // Argv offsets table. - let argv_offsets_size = argc.checked_mul(4).ok_or(Errno::EINVAL)?; + let argv_offsets_size = argc + .checked_mul(spawn_contract::WIRE_STRING_OFFSET_BYTES) + .ok_or(Errno::EINVAL)?; let argv_offsets_end = cursor.checked_add(argv_offsets_size).ok_or(Errno::EINVAL)?; let argv_offsets_bytes = bytes.get(cursor..argv_offsets_end).ok_or(Errno::EINVAL)?; let mut argv_offsets: Vec = Vec::with_capacity(argc); for i in 0..argc { - argv_offsets.push(read_u32(argv_offsets_bytes, i * 4)?); + argv_offsets.push(read_u32( + argv_offsets_bytes, + i * spawn_contract::WIRE_STRING_OFFSET_BYTES, + )?); } cursor = argv_offsets_end; // Envp offsets table. - let envp_offsets_size = envc.checked_mul(4).ok_or(Errno::EINVAL)?; + let envp_offsets_size = envc + .checked_mul(spawn_contract::WIRE_STRING_OFFSET_BYTES) + .ok_or(Errno::EINVAL)?; let envp_offsets_end = cursor.checked_add(envp_offsets_size).ok_or(Errno::EINVAL)?; let envp_offsets_bytes = bytes.get(cursor..envp_offsets_end).ok_or(Errno::EINVAL)?; let mut envp_offsets: Vec = Vec::with_capacity(envc); for i in 0..envc { - envp_offsets.push(read_u32(envp_offsets_bytes, i * 4)?); + envp_offsets.push(read_u32( + envp_offsets_bytes, + i * spawn_contract::WIRE_STRING_OFFSET_BYTES, + )?); } cursor = envp_offsets_end; // Action records. let actions_size = n_actions - .checked_mul(ACTION_RECORD_LEN) + .checked_mul(spawn_contract::WIRE_ACTION_RECORD_BYTES) .ok_or(Errno::EINVAL)?; let actions_end = cursor.checked_add(actions_size).ok_or(Errno::EINVAL)?; let actions_bytes = bytes.get(cursor..actions_end).ok_or(Errno::EINVAL)?; @@ -220,28 +413,59 @@ pub fn parse_blob(bytes: &[u8]) -> Result { // Everything left is the strings region. let strings = bytes.get(cursor..).ok_or(Errno::EINVAL)?; - // Decode argv + envp using the offset tables. Each offset points at a - // null-terminated string in `strings`. A length isn't carried in the - // table, so we walk to the next NUL — but bounded by `strings.len()` so - // a malformed blob can't read past the end. - let argv = decode_strings_by_offset(&argv_offsets, strings)?; - let envp = decode_strings_by_offset(&envp_offsets, strings)?; + // ARG_MAX accounts for the source pointer arrays as well as the string + // bytes. Four-byte pointers are the smaller supported representation, so + // this rejects a blob that could not have been valid on either wasm32 or + // wasm64 while leaving the host's source-width check authoritative. + let pointer_bytes = argc + .checked_add(envc) + .and_then(|count| count.checked_add(2)) + .and_then(|count| count.checked_mul(core::mem::size_of::())) + .ok_or(Errno::E2BIG)?; + if pointer_bytes > spawn_contract::POSIX_ARG_MAX_BYTES { + return Err(Errno::E2BIG); + } + // First measure every referenced string against one incremental budget, + // then allocate owned values. WHY: decoding first lets thousands of + // duplicate offsets copy the same multi-megabyte tail tens of gigabytes + // before ARG_MAX is checked. Since every scan contributes to this budget, + // adversarial work and eventual allocations are both bounded by ARG_MAX. + let mut represented_bytes = pointer_bytes; + let argv_ranges = measure_strings_by_offset(&argv_offsets, strings, &mut represented_bytes)?; + let envp_ranges = measure_strings_by_offset(&envp_offsets, strings, &mut represented_bytes)?; + let argv = decode_measured_strings(&argv_ranges, strings); + let envp = decode_measured_strings(&envp_ranges, strings); // Decode action records. let mut file_actions: Vec = Vec::with_capacity(n_actions); for i in 0..n_actions { - let base = i * ACTION_RECORD_LEN; - let op = read_u32(actions_bytes, base)?; - let fd = read_i32(actions_bytes, base + 4)?; - let newfd = read_i32(actions_bytes, base + 8)?; - let path_off = read_u32(actions_bytes, base + 12)?; - let path_len = read_u32(actions_bytes, base + 16)?; - let oflag = read_i32(actions_bytes, base + 20)?; - let mode = read_u32(actions_bytes, base + 24)?; + let base = i * spawn_contract::WIRE_ACTION_RECORD_BYTES; + let op = read_u32(actions_bytes, base + spawn_contract::WIRE_ACTION_OP_OFFSET)?; + let fd = read_i32(actions_bytes, base + spawn_contract::WIRE_ACTION_FD_OFFSET)?; + let newfd = read_i32( + actions_bytes, + base + spawn_contract::WIRE_ACTION_NEWFD_OFFSET, + )?; + let path_off = read_u32( + actions_bytes, + base + spawn_contract::WIRE_ACTION_PATH_OFF_OFFSET, + )?; + let path_len = read_u32( + actions_bytes, + base + spawn_contract::WIRE_ACTION_PATH_LEN_OFFSET, + )?; + let oflag = read_i32( + actions_bytes, + base + spawn_contract::WIRE_ACTION_OFLAG_OFFSET, + )?; + let mode = read_u32( + actions_bytes, + base + spawn_contract::WIRE_ACTION_MODE_OFFSET, + )?; let action = match op { x if x == fdop::OPEN => FileAction::Open { fd, - path: read_string(strings, path_off, path_len)?, + path: read_action_path(strings, path_off, path_len)?, oflag, mode, }, @@ -251,7 +475,7 @@ pub fn parse_blob(bytes: &[u8]) -> Result { fd: newfd, }, x if x == fdop::CHDIR => FileAction::Chdir { - path: read_string(strings, path_off, path_len)?, + path: read_action_path(strings, path_off, path_len)?, }, x if x == fdop::FCHDIR => FileAction::Fchdir { fd }, _ => return Err(Errno::EINVAL), @@ -272,28 +496,305 @@ pub fn parse_blob(bytes: &[u8]) -> Result { }) } -/// Decode a list of NUL-terminated strings out of `strings` at the given -/// byte offsets. Each offset must be in range; the string runs to the next -/// NUL within `strings` (and a missing terminator is permitted as long as -/// the slice ends at the buffer end). -fn decode_strings_by_offset(offsets: &[u32], strings: &[u8]) -> Result>, Errno> { - let mut out: Vec> = Vec::with_capacity(offsets.len()); +/// Measure NUL-terminated string references without allocating their bytes. +fn measure_strings_by_offset( + offsets: &[u32], + strings: &[u8], + represented_bytes: &mut usize, +) -> Result, Errno> { + let mut ranges = Vec::with_capacity(offsets.len()); for &off in offsets { let off = off as usize; if off > strings.len() { return Err(Errno::EINVAL); } let tail = &strings[off..]; - let end = tail.iter().position(|&b| b == 0).unwrap_or(tail.len()); - out.push(tail[..end].to_vec()); + let length = tail.iter().position(|&b| b == 0).ok_or(Errno::EINVAL)?; + *represented_bytes = represented_bytes + .checked_add(length) + .and_then(|total| total.checked_add(1)) + .ok_or(Errno::E2BIG)?; + if *represented_bytes > spawn_contract::POSIX_ARG_MAX_BYTES { + return Err(Errno::E2BIG); + } + ranges.push((off, off + length)); } - Ok(out) + Ok(ranges) +} + +fn decode_measured_strings(ranges: &[(usize, usize)], strings: &[u8]) -> Vec> { + ranges + .iter() + .map(|&(start, end)| strings[start..end].to_vec()) + .collect() } #[cfg(test)] mod parser_tests { use super::*; + #[test] + fn scratch_queries_reject_invalid_bounds_and_lock_contention() { + let scratch = GlobalSpawnScratch::new(); + assert_eq!(scratch.begin(0), Err(Errno::EINVAL)); + assert_eq!( + scratch.begin(spawn_contract::WIRE_MAX_BYTES + 1), + Err(Errno::E2BIG) + ); + + let guard = scratch.inner.try_lock().expect("hold scratch lock"); + assert_eq!(scratch.begin(84 * 1024), Err(Errno::EBUSY)); + assert_eq!(scratch.pointer(1), Err(Errno::EBUSY)); + assert_eq!(scratch.capacity(1), Err(Errno::EBUSY)); + assert_eq!(scratch.retained_capacity(), Err(Errno::EBUSY)); + // Commit and cancellation deliberately block instead of returning + // EBUSY. Calling either while this test owns the lock would deadlock; + // the next two threaded tests prove their settlement under contention. + drop(guard); + } + + #[test] + fn scratch_cancel_waits_for_contention_then_definitively_releases_token() { + use std::sync::{Arc, mpsc}; + use std::time::Duration; + + let scratch = Arc::new(GlobalSpawnScratch::new()); + let token = scratch + .begin(spawn_contract::WIRE_HEADER_BYTES) + .expect("begin reservation"); + let guard = scratch.inner.lock(); + let (started_tx, started_rx) = mpsc::sync_channel(0); + let (result_tx, result_rx) = mpsc::sync_channel(0); + let cancel_scratch = Arc::clone(&scratch); + let cancel_thread = std::thread::spawn(move || { + started_tx.send(()).expect("announce cancellation"); + let result = cancel_scratch.cancel(token); + result_tx.send(result).expect("report cancellation"); + }); + + started_rx + .recv_timeout(Duration::from_secs(1)) + .expect("cancellation thread started"); + assert!( + result_rx.recv_timeout(Duration::from_millis(50)).is_err(), + "cancellation must not return before the contended lock settles", + ); + drop(guard); + assert_eq!( + result_rx + .recv_timeout(Duration::from_secs(1)) + .expect("cancellation settled"), + Ok(()), + ); + cancel_thread.join().expect("cancellation thread joined"); + assert_eq!(scratch.pointer(token), Err(Errno::EINVAL)); + + let retry = scratch + .begin(spawn_contract::WIRE_HEADER_BYTES) + .expect("reservation reusable after cancellation"); + scratch.cancel(retry).expect("cancel retry"); + } + + #[test] + fn scratch_commit_waits_for_contention_then_definitively_consumes_token() { + use std::sync::{Arc, mpsc}; + use std::time::Duration; + + let scratch = Arc::new(GlobalSpawnScratch::new()); + let blob = build_basic_blob(); + let token = scratch.begin(blob.len()).expect("begin reservation"); + { + let mut writable = scratch.inner.lock(); + writable.bytes[..blob.len()].copy_from_slice(&blob); + } + + let guard = scratch.inner.lock(); + let (started_tx, started_rx) = mpsc::sync_channel(0); + let (result_tx, result_rx) = mpsc::sync_channel(0); + let commit_scratch = Arc::clone(&scratch); + let commit_thread = std::thread::spawn(move || { + started_tx.send(()).expect("announce commit"); + let result = commit_scratch + .parse_reserved(token, blob.len()) + .map(|parsed| parsed.argv); + result_tx.send(result).expect("report commit"); + }); + + started_rx + .recv_timeout(Duration::from_secs(1)) + .expect("commit thread started"); + assert!( + result_rx.recv_timeout(Duration::from_millis(50)).is_err(), + "commit must not return before the contended lock settles", + ); + drop(guard); + assert_eq!( + result_rx + .recv_timeout(Duration::from_secs(1)) + .expect("commit settled"), + Ok(alloc::vec![b"/bin/ls".to_vec()]), + ); + commit_thread.join().expect("commit thread joined"); + assert_eq!(scratch.pointer(token), Err(Errno::EINVAL)); + assert_eq!(scratch.cancel(token), Err(Errno::EINVAL)); + + let retry = scratch + .begin(spawn_contract::WIRE_HEADER_BYTES) + .expect("reservation reusable after commit"); + scratch.cancel(retry).expect("cancel retry"); + } + + #[test] + fn scratch_reserve_failure_preserves_idle_state_and_token() { + let mut scratch = SpawnScratchBuffer::new(); + let requested = 84 * 1024; + + assert_eq!( + scratch.begin_with_reserve(requested, |bytes, additional| { + assert!(bytes.is_empty()); + assert_eq!(bytes.capacity(), 0); + assert_eq!(additional, requested); + Err(Errno::ENOMEM) + }), + Err(Errno::ENOMEM), + ); + assert_eq!(scratch.reservation, None); + assert_eq!(scratch.next_token, Some(1)); + assert_eq!(scratch.retained_capacity(), 0); + assert_eq!(scratch.bytes.capacity(), 0); + + let token = scratch + .begin(spawn_contract::WIRE_HEADER_BYTES) + .expect("retry after reserve failure"); + assert_eq!(token, 1); + assert_eq!(scratch.reservation, Some(token)); + assert_eq!(scratch.next_token, Some(2)); + assert!(scratch.retained_capacity() >= spawn_contract::WIRE_HEADER_BYTES,); + scratch.cancel(token).expect("cancel successful retry"); + } + + #[test] + fn scratch_reservation_parses_exact_capacity_and_releases_on_capacity_plus_one() { + let scratch = GlobalSpawnScratch::new(); + let blob = build_basic_blob(); + let token = scratch.begin(blob.len()).expect("begin exact"); + let pointer = scratch.pointer(token).expect("reservation pointer"); + let capacity = scratch.capacity(token).expect("reservation capacity"); + assert_ne!(pointer, 0); + assert!(capacity >= blob.len()); + + let mut padded = blob; + padded.resize(capacity, 0); + scratch.inner.try_lock().expect("write reservation").bytes[..capacity] + .copy_from_slice(&padded); + let parsed = scratch + .parse_reserved(token, capacity) + .expect("parse exact capacity"); + assert_eq!(parsed.argv, alloc::vec![b"/bin/ls".to_vec()]); + assert_eq!(scratch.pointer(token), Err(Errno::EINVAL)); + assert_eq!(scratch.capacity(token), Err(Errno::EINVAL)); + assert_eq!(scratch.retained_capacity(), Ok(capacity)); + + let overflow_token = scratch.begin(capacity).expect("begin overflow case"); + assert!(matches!( + scratch.parse_reserved(overflow_token, capacity + 1), + Err(Errno::E2BIG) + )); + assert_eq!(scratch.pointer(overflow_token), Err(Errno::EINVAL)); + assert!(scratch.begin(capacity).is_ok()); + } + + #[test] + fn scratch_reservation_reuses_then_grows_one_owned_allocation() { + let scratch = GlobalSpawnScratch::new(); + let first_token = scratch.begin(84 * 1024).expect("first begin"); + let first_pointer = scratch.pointer(first_token).expect("first pointer"); + let first_capacity = scratch.capacity(first_token).expect("first capacity"); + assert_ne!(first_pointer, 0); + assert!(first_capacity >= 84 * 1024); + scratch.cancel(first_token).expect("cancel first"); + + let reused_token = scratch.begin(80 * 1024).expect("reuse begin"); + let reused_pointer = scratch.pointer(reused_token).expect("reused pointer"); + let reused_capacity = scratch.capacity(reused_token).expect("reused capacity"); + assert_eq!(reused_pointer, first_pointer); + assert_eq!(reused_capacity, first_capacity); + scratch.cancel(reused_token).expect("cancel reuse"); + + let grown_token = scratch.begin(first_capacity + 1).expect("grow begin"); + let grown_pointer = scratch.pointer(grown_token).expect("grown pointer"); + let grown_capacity = scratch.capacity(grown_token).expect("grown capacity"); + assert_ne!(grown_pointer, 0); + assert!(grown_capacity > first_capacity); + scratch.cancel(grown_token).expect("cancel grown"); + assert_eq!(scratch.retained_capacity(), Ok(grown_capacity)); + } + + #[test] + fn scratch_reservation_rejects_overlap_and_stale_tokens() { + let scratch = GlobalSpawnScratch::new(); + let first = scratch.begin(84 * 1024).expect("first begin"); + assert_eq!(scratch.begin(84 * 1024), Err(Errno::EBUSY)); + assert_eq!(scratch.pointer(first + 1), Err(Errno::EINVAL)); + assert_eq!(scratch.capacity(first + 1), Err(Errno::EINVAL)); + assert_eq!(scratch.cancel(0), Err(Errno::EINVAL)); + scratch.cancel(first).expect("cancel first"); + + let second = scratch.begin(84 * 1024).expect("second begin"); + assert!(second > first); + assert_eq!(scratch.cancel(first), Err(Errno::EINVAL)); + assert!(matches!( + scratch.parse_reserved(first, spawn_contract::WIRE_HEADER_BYTES), + Err(Errno::EINVAL) + )); + assert_eq!(scratch.pointer(first), Err(Errno::EINVAL)); + assert_eq!(scratch.capacity(first), Err(Errno::EINVAL)); + assert_ne!(scratch.pointer(second).expect("current pointer"), 0,); + assert_eq!( + scratch.capacity(second).expect("current capacity"), + scratch.retained_capacity().expect("retained capacity"), + ); + scratch.cancel(second).expect("cancel second"); + } + + #[test] + fn malformed_reserved_blob_releases_the_matching_reservation() { + let scratch = GlobalSpawnScratch::new(); + let token = scratch + .begin(spawn_contract::WIRE_HEADER_BYTES) + .expect("begin malformed"); + assert!(matches!( + scratch.parse_reserved(token, spawn_contract::WIRE_HEADER_BYTES - 1), + Err(Errno::EINVAL) + )); + assert_eq!(scratch.pointer(token), Err(Errno::EINVAL)); + + let retry = scratch + .begin(spawn_contract::WIRE_HEADER_BYTES) + .expect("retry after malformed parse"); + scratch.cancel(retry).expect("cancel retry"); + } + + #[test] + fn reservation_tokens_exhaust_without_wrapping_to_a_stale_value() { + let scratch = GlobalSpawnScratch::new(); + scratch + .inner + .try_lock() + .expect("set token cursor") + .next_token = Some(i64::MAX); + + let last = scratch + .begin(spawn_contract::WIRE_HEADER_BYTES) + .expect("last token"); + assert_eq!(last, i64::MAX); + scratch.cancel(last).expect("cancel last token"); + assert_eq!( + scratch.begin(spawn_contract::WIRE_HEADER_BYTES), + Err(Errno::EOVERFLOW) + ); + } + /// Build a well-formed blob with a single argv entry, a single envp /// entry, one Close action, and SETPGROUP attrs. Used to anchor the /// happy-path round-trip test. @@ -366,6 +867,19 @@ mod parser_tests { assert!(matches!(parse_blob(&blob), Err(Errno::EINVAL))); } + #[test] + fn parse_blob_rejects_unterminated_argv_and_environment_strings() { + for (argc, envc) in [(1, 0), (0, 1)] { + let mut blob = header(argc, envc, 0); + blob.extend_from_slice(&0u32.to_le_bytes()); + blob.extend_from_slice(b"unterminated"); + assert!( + matches!(parse_blob(&blob), Err(Errno::EINVAL)), + "argc={argc}, envc={envc}", + ); + } + } + #[test] fn parse_blob_rejects_action_path_out_of_bounds() { // n_actions=1, FDOP_CHDIR with path_off=999 (out of range). @@ -402,7 +916,7 @@ mod parser_tests { blob.extend_from_slice(&0u64.to_le_bytes()); // sigdef blob.extend_from_slice(&0u64.to_le_bytes()); // sigmask blob.extend_from_slice(&99u32.to_le_bytes()); // op = 99 (unknown) - blob.extend_from_slice(&[0u8; ACTION_RECORD_LEN - 4]); + blob.extend_from_slice(&[0u8; spawn_contract::WIRE_ACTION_RECORD_BYTES - 4]); assert!(matches!(parse_blob(&blob), Err(Errno::EINVAL))); } @@ -413,7 +927,191 @@ mod parser_tests { blob.extend_from_slice(&u32::MAX.to_le_bytes()); blob.extend_from_slice(&0u32.to_le_bytes()); blob.extend_from_slice(&0u32.to_le_bytes()); - blob.extend_from_slice(&[0u8; 28]); + blob.extend_from_slice(&[0u8; spawn_contract::WIRE_HEADER_BYTES - 12]); assert!(matches!(parse_blob(&blob), Err(Errno::EINVAL))); } + + fn header(argc: u32, envc: u32, n_actions: u32) -> Vec { + let mut blob = Vec::new(); + blob.extend_from_slice(&argc.to_le_bytes()); + blob.extend_from_slice(&envc.to_le_bytes()); + blob.extend_from_slice(&n_actions.to_le_bytes()); + blob.extend_from_slice(&[0u8; spawn_contract::WIRE_HEADER_BYTES - 12]); + blob + } + + fn action_path_blob(op: u32, path_len: u32, strings: &[u8]) -> Vec { + let mut blob = header(0, 0, 1); + blob.extend_from_slice(&op.to_le_bytes()); + blob.extend_from_slice(&0i32.to_le_bytes()); // fd + blob.extend_from_slice(&0i32.to_le_bytes()); // newfd + blob.extend_from_slice(&0u32.to_le_bytes()); // path_off + blob.extend_from_slice(&path_len.to_le_bytes()); + blob.extend_from_slice(&0i32.to_le_bytes()); // oflag + blob.extend_from_slice(&0u32.to_le_bytes()); // mode + blob.extend_from_slice(strings); + blob + } + + fn exact_count_blob(argc: usize, envc: usize, n_actions: usize) -> Vec { + let mut blob = header(argc as u32, envc as u32, n_actions as u32); + blob.resize( + spawn_contract::WIRE_HEADER_BYTES + + (argc + envc) * spawn_contract::WIRE_STRING_OFFSET_BYTES, + 0, + ); + for _ in 0..n_actions { + let mut record = [0u8; spawn_contract::WIRE_ACTION_RECORD_BYTES]; + record + [spawn_contract::WIRE_ACTION_OP_OFFSET..spawn_contract::WIRE_ACTION_OP_OFFSET + 4] + .copy_from_slice(&fdop::CLOSE.to_le_bytes()); + blob.extend_from_slice(&record); + } + if argc + envc > 0 { + // Every zero offset deliberately shares one empty NUL-terminated + // string. Offset aliasing is valid and keeps this boundary test + // focused on count admission rather than allocation volume. + blob.push(0); + } + blob + } + + #[test] + fn parse_blob_accepts_each_exact_count_cap() { + let argv = parse_blob(&exact_count_blob(spawn_contract::MAX_ARGV_COUNT, 0, 0)) + .expect("exact argv cap"); + assert_eq!(argv.argv.len(), spawn_contract::MAX_ARGV_COUNT); + + let envp = parse_blob(&exact_count_blob(0, spawn_contract::MAX_ENVP_COUNT, 0)) + .expect("exact envp cap"); + assert_eq!(envp.envp.len(), spawn_contract::MAX_ENVP_COUNT); + + let actions = parse_blob(&exact_count_blob(0, 0, spawn_contract::MAX_ACTION_COUNT)) + .expect("exact action cap"); + assert_eq!(actions.file_actions.len(), spawn_contract::MAX_ACTION_COUNT,); + } + + #[test] + fn parse_blob_rejects_each_count_at_limit_plus_one() { + for (argc, envc, n_actions) in [ + ((spawn_contract::MAX_ARGV_COUNT + 1) as u32, 0, 0), + (0, (spawn_contract::MAX_ENVP_COUNT + 1) as u32, 0), + (0, 0, (spawn_contract::MAX_ACTION_COUNT + 1) as u32), + ] { + assert!(matches!( + parse_blob(&header(argc, envc, n_actions)), + Err(Errno::EINVAL) + )); + } + } + + #[test] + fn parse_blob_rejects_truncated_tables_at_each_exact_count_cap() { + for (argc, envc, n_actions) in [ + (spawn_contract::MAX_ARGV_COUNT as u32, 0, 0), + (0, spawn_contract::MAX_ENVP_COUNT as u32, 0), + (0, 0, spawn_contract::MAX_ACTION_COUNT as u32), + ] { + assert!(matches!( + parse_blob(&header(argc, envc, n_actions)), + Err(Errno::EINVAL) + )); + } + } + + #[test] + fn parse_blob_rejects_duplicate_max_count_offsets_before_copying_the_tail() { + let argc = spawn_contract::MAX_ARGV_COUNT; + let mut blob = header(argc as u32, 0, 0); + blob.extend(core::iter::repeat_n( + 0u8, + argc * spawn_contract::WIRE_STRING_OFFSET_BYTES, + )); + blob.extend(core::iter::repeat_n( + b'a', + spawn_contract::POSIX_ARG_MAX_BYTES - 1, + )); + blob.push(0); + + // Decoding before aggregate accounting would try to allocate this + // approximately four-megabyte string once for every argv entry. + assert!(matches!(parse_blob(&blob), Err(Errno::E2BIG))); + } + + #[test] + fn parse_blob_accepts_exact_arg_max_and_rejects_arg_max_plus_one() { + // One argv pointer, one envp pointer, and both terminators consume + // sixteen bytes of the minimum wasm32 source representation. + let string_bytes = spawn_contract::POSIX_ARG_MAX_BYTES - 16; + let argv_bytes = string_bytes / 2; + let envp_bytes = string_bytes - argv_bytes; + let mut exact = header(1, 1, 0); + exact.extend_from_slice(&0u32.to_le_bytes()); + exact.extend_from_slice(&(argv_bytes as u32).to_le_bytes()); + exact.extend(core::iter::repeat_n(b'a', argv_bytes - 1)); + exact.push(0); + exact.extend(core::iter::repeat_n(b'b', envp_bytes - 1)); + exact.push(0); + assert!(parse_blob(&exact).is_ok()); + + let mut oversized = exact; + oversized.insert(oversized.len() - 1, b'a'); + assert!(matches!(parse_blob(&oversized), Err(Errno::E2BIG))); + } + + #[test] + fn parse_blob_bounds_action_paths_by_path_max() { + fn action_blob(path_bytes: usize) -> Vec { + let mut blob = header(0, 0, 1); + blob.extend_from_slice(&fdop::CHDIR.to_le_bytes()); + blob.extend_from_slice(&0i32.to_le_bytes()); + blob.extend_from_slice(&0i32.to_le_bytes()); + blob.extend_from_slice(&0u32.to_le_bytes()); + blob.extend_from_slice(&(path_bytes as u32).to_le_bytes()); + blob.extend_from_slice(&0i32.to_le_bytes()); + blob.extend_from_slice(&0u32.to_le_bytes()); + blob.extend(core::iter::repeat_n(b'a', path_bytes - 1)); + blob.push(0); + blob + } + + assert!(parse_blob(&action_blob(spawn_contract::POSIX_PATH_MAX_BYTES)).is_ok()); + assert!(matches!( + parse_blob(&action_blob(spawn_contract::POSIX_PATH_MAX_BYTES + 1)), + Err(Errno::ENAMETOOLONG) + )); + } + + #[test] + fn parse_blob_action_paths_require_exactly_one_terminal_nul() { + for op in [fdop::OPEN, fdop::CHDIR] { + let parsed = + parse_blob(&action_path_blob(op, 9, b"relative\0")).expect("one terminal NUL"); + let path = match &parsed.file_actions[0] { + FileAction::Open { path, .. } | FileAction::Chdir { path } => path, + _ => panic!("expected path-bearing action"), + }; + assert_eq!(path, b"relative"); + + for (case, path_len, strings) in [ + ("zero length", 0, &b"\0"[..]), + ("missing terminator", 3, &b"abc\0"[..]), + ("interior NUL", 4, &b"a\0b\0"[..]), + ] { + assert!( + matches!( + parse_blob(&action_path_blob(op, path_len, strings)), + Err(Errno::EINVAL) + ), + "{case} must be rejected for action op {op}", + ); + } + } + } + + #[test] + fn parse_blob_rejects_whole_blob_limit_plus_one() { + let blob = alloc::vec![0; spawn_contract::WIRE_MAX_BYTES + 1]; + assert!(matches!(parse_blob(&blob), Err(Errno::E2BIG))); + } } diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 73fffebe9d..2fc9b7caf3 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -83,13 +83,13 @@ fn parse_ascii_usize(bytes: &[u8]) -> Option { /// Virtual character devices handled entirely in-kernel. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum VirtualDevice { - Null, // /dev/null host_handle = -1 - Zero, // /dev/zero host_handle = -2 - Urandom, // /dev/urandom host_handle = -3 - Full, // /dev/full host_handle = -4 - Fb0, // /dev/fb0 host_handle = -5 - Mice, // /dev/input/mice host_handle = -6 - Dsp, // /dev/dsp host_handle = -7 + Null, // /dev/null host_handle = -1 + Zero, // /dev/zero host_handle = -2 + Urandom, // /dev/urandom host_handle = -3 + Full, // /dev/full host_handle = -4 + Fb0, // /dev/fb0 host_handle = -5 + Mice, // /dev/input/mice host_handle = -6 + Dsp, // /dev/dsp host_handle = -7 DriRenderD128, // /dev/dri/renderD128 host_handle = -8 DriCard0, // /dev/dri/card0 host_handle = -9 } @@ -573,10 +573,7 @@ fn dri_state(proc: &Process, ofd_idx: usize) -> Result<&crate::ofd::DriFdState, .ok_or(Errno::EBADF) } -fn dri_state_mut( - proc: &mut Process, - ofd_idx: usize, -) -> Result<&mut crate::ofd::DriFdState, Errno> { +fn dri_state_mut(proc: &mut Process, ofd_idx: usize) -> Result<&mut crate::ofd::DriFdState, Errno> { proc.ofd_table .get_mut(ofd_idx) .and_then(|o| o.dri_mut()) @@ -590,10 +587,7 @@ fn kms_state(proc: &Process, ofd_idx: usize) -> Result<&crate::ofd::KmsFdState, .ok_or(Errno::EBADF) } -fn kms_state_mut( - proc: &mut Process, - ofd_idx: usize, -) -> Result<&mut crate::ofd::KmsFdState, Errno> { +fn kms_state_mut(proc: &mut Process, ofd_idx: usize) -> Result<&mut crate::ofd::KmsFdState, Errno> { proc.ofd_table .get_mut(ofd_idx) .and_then(|o| o.kms_mut()) @@ -610,11 +604,7 @@ fn dri_fd_has_bo_handle(dri: &crate::ofd::DriFdState, bo_id: crate::dri::BoId) - dri.handles.values().any(|&candidate| candidate == bo_id) } -fn clear_dri_fd_cmdbuf_in_range( - dri: &mut crate::ofd::DriFdState, - addr: usize, - len: usize, -) -> bool { +fn clear_dri_fd_cmdbuf_in_range(dri: &mut crate::ofd::DriFdState, addr: usize, len: usize) -> bool { let Some(gl) = dri.gl.as_mut() else { return false; }; @@ -628,12 +618,7 @@ fn clear_dri_fd_cmdbuf_in_range( true } -fn unbind_gl_cmdbufs_in_range( - proc: &mut Process, - host: &mut dyn HostIO, - addr: usize, - len: usize, -) { +fn unbind_gl_cmdbufs_in_range(proc: &mut Process, host: &mut dyn HostIO, addr: usize, len: usize) { let mut released = false; for (_ofd_idx, ofd) in proc.ofd_table.iter_mut() { match ofd.dri_state.as_deref_mut() { @@ -766,7 +751,8 @@ fn commit_exec_state_impl( proc.state = lifecycle_state; proc.exit_status = 0; proc.exit_signal = 0; - proc.thread_name = [0; 16]; + proc.thread_name = + [0; wasm_posix_shared::kernel_scratch_wire::PRCTL_NAME_BYTES as usize]; proc.clear_threads(); proc.sigsuspend_saved_mask = None; proc.alt_stack_sp = 0; @@ -809,6 +795,45 @@ fn release_dri_handle( Ok(()) } +fn handle_drm_version(request: u32, buf: &mut [u8]) -> Result<(), Errno> { + use wasm_posix_shared::dri::{DRM_IOCTL_VERSION, DRM_IOCTL_VERSION_WASM64}; + + let (size, name_ptr_offset, date_ptr_offset, desc_ptr_offset, pointer_size) = + if request == DRM_IOCTL_VERSION { + (36usize, 16usize, 24usize, 32usize, 4usize) + } else if request == DRM_IOCTL_VERSION_WASM64 { + (64usize, 24usize, 40usize, 56usize, 8usize) + } else { + return Err(Errno::EINVAL); + }; + if buf.len() < size { + return Err(Errno::EINVAL); + } + + let mut name_ptr = [0u8; 8]; + let mut date_ptr = [0u8; 8]; + let mut desc_ptr = [0u8; 8]; + name_ptr[..pointer_size].copy_from_slice(&buf[name_ptr_offset..name_ptr_offset + pointer_size]); + date_ptr[..pointer_size].copy_from_slice(&buf[date_ptr_offset..date_ptr_offset + pointer_size]); + desc_ptr[..pointer_size].copy_from_slice(&buf[desc_ptr_offset..desc_ptr_offset + pointer_size]); + + buf[..size].fill(0); + buf[0..4].copy_from_slice(&1i32.to_le_bytes()); + buf[name_ptr_offset..name_ptr_offset + pointer_size].copy_from_slice(&name_ptr[..pointer_size]); + buf[date_ptr_offset..date_ptr_offset + pointer_size].copy_from_slice(&date_ptr[..pointer_size]); + buf[desc_ptr_offset..desc_ptr_offset + pointer_size].copy_from_slice(&desc_ptr[..pointer_size]); + Ok(()) +} + +/// Convert a fixed-width DRM UAPI pointer to the process-memory bridge. +/// +/// WHY: KMS structs use `u64` pointers even for wasm32 compatibility, while +/// the current host bridge accepts only a lossless `u32` process address. +/// Truncation would redirect a wasm64 pointer into unrelated low memory. +fn checked_dri_process_pointer(pointer: u64) -> Result { + u32::try_from(pointer).map_err(|_| Errno::EFAULT) +} + /// Shared render-node ioctls: probe (VERSION / GET_CAP), the dumb-buffer /// quartet (CREATE / MAP / DESTROY / GEM_CLOSE), PRIME export/import, and /// GLES2 session ioctls. Unknown requests return `ENOSYS` so libdrm @@ -824,28 +849,7 @@ fn handle_dri_ioctl( use wasm_posix_shared::gl; let pid = proc.pid as i32; match request { - DRM_IOCTL_VERSION => { - if buf.len() < core::mem::size_of::() { - return Err(Errno::EINVAL); - } - let v_in: WpkDrmVersion = - unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const _) }; - let v_out = WpkDrmVersion { - version_major: 1, - version_minor: 0, - version_patchlevel: 0, - name_len: 0, - name_ptr: v_in.name_ptr, - date_len: 0, - date_ptr: v_in.date_ptr, - desc_len: 0, - desc_ptr: v_in.desc_ptr, - }; - unsafe { - core::ptr::write_unaligned(buf.as_mut_ptr() as *mut _, v_out); - } - Ok(()) - } + DRM_IOCTL_VERSION | DRM_IOCTL_VERSION_WASM64 => handle_drm_version(request, buf), DRM_IOCTL_GET_CAP => { if buf.len() < core::mem::size_of::() { return Err(Errno::EINVAL); @@ -973,8 +977,8 @@ fn handle_dri_ioctl( // Materialise the cookie (idempotent — re-export reuses // the existing one). Bump the bo refcount for the new OFD // that will hold the prime fd. - let cookie = crate::dri::with_registry(|r| r.ensure_prime_cookie(bo_id)) - .ok_or(Errno::EINVAL)?; + let cookie = + crate::dri::with_registry(|r| r.ensure_prime_cookie(bo_id)).ok_or(Errno::EINVAL)?; crate::dri::with_registry(|r| r.incref(bo_id)); // Allocate a fresh OFD with the prime-bo sidecar. The @@ -989,9 +993,9 @@ fn handle_dri_ioctl( path, ); if let Some(new_ofd) = proc.ofd_table.get_mut(prime_ofd) { - new_ofd.dri_state = Some(alloc::boxed::Box::new( - crate::ofd::DriOfdState::PrimeBo(crate::ofd::PrimeBoState { bo_id, cookie }), - )); + new_ofd.dri_state = Some(alloc::boxed::Box::new(crate::ofd::DriOfdState::PrimeBo( + crate::ofd::PrimeBoState { bo_id, cookie }, + ))); } let fd_flags = if req.flags & wasm_posix_shared::flags::O_CLOEXEC != 0 { wasm_posix_shared::fd_flags::FD_CLOEXEC @@ -1033,10 +1037,9 @@ fn handle_dri_ioctl( // one stored on the prime-fd OFD. A stale cookie (bo // destroyed + new bo took its id) fails with EACCES, // matching Linux. - let bo_cookie = crate::dri::with_registry(|r| { - r.get(prime.bo_id).and_then(|b| b.prime_cookie) - }) - .ok_or(Errno::EACCES)?; + let bo_cookie = + crate::dri::with_registry(|r| r.get(prime.bo_id).and_then(|b| b.prime_cookie)) + .ok_or(Errno::EACCES)?; if bo_cookie != prime.cookie { return Err(Errno::EACCES); } @@ -1236,9 +1239,7 @@ fn handle_dri_ioctl( } let info: gl::GlQueryInfo = unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const _) }; - if info.in_buf_len > gl::MAX_QUERY_IN_LEN - || info.out_buf_len > gl::MAX_QUERY_OUT_LEN - { + if info.in_buf_len > gl::MAX_QUERY_IN_LEN || info.out_buf_len > gl::MAX_QUERY_OUT_LEN { return Err(Errno::EINVAL); } { @@ -1259,8 +1260,7 @@ fn handle_dri_ioctl( return Err(Errno::EFAULT); } } - let mut out_buf: alloc::vec::Vec = - alloc::vec![0u8; info.out_buf_len as usize]; + let mut out_buf: alloc::vec::Vec = alloc::vec![0u8; info.out_buf_len as usize]; let written = host.gl_query(pid, info.op, &in_buf, &mut out_buf); if written < 0 { return Err(Errno::EIO); @@ -1316,24 +1316,32 @@ fn handle_dri_card_ioctl( } let req: WpkDrmModeCardRes = unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const _) }; - if req.count_crtcs >= 1 && req.crtc_id_ptr != 0 { - let rc = host.proc_write_bytes(pid, req.crtc_id_ptr as u32, &1u32.to_le_bytes()); + // Validate every nested address before the first write so a later + // unrepresentable wasm64 pointer cannot leave earlier outputs + // partially updated. + let crtc_id_ptr = (req.count_crtcs >= 1 && req.crtc_id_ptr != 0) + .then(|| checked_dri_process_pointer(req.crtc_id_ptr)) + .transpose()?; + let connector_id_ptr = (req.count_connectors >= 1 && req.connector_id_ptr != 0) + .then(|| checked_dri_process_pointer(req.connector_id_ptr)) + .transpose()?; + let encoder_id_ptr = (req.count_encoders >= 1 && req.encoder_id_ptr != 0) + .then(|| checked_dri_process_pointer(req.encoder_id_ptr)) + .transpose()?; + if let Some(pointer) = crtc_id_ptr { + let rc = host.proc_write_bytes(pid, pointer, &1u32.to_le_bytes()); if rc < 0 { return Err(Errno::EFAULT); } } - if req.count_connectors >= 1 && req.connector_id_ptr != 0 { - let rc = host.proc_write_bytes( - pid, - req.connector_id_ptr as u32, - &1u32.to_le_bytes(), - ); + if let Some(pointer) = connector_id_ptr { + let rc = host.proc_write_bytes(pid, pointer, &1u32.to_le_bytes()); if rc < 0 { return Err(Errno::EFAULT); } } - if req.count_encoders >= 1 && req.encoder_id_ptr != 0 { - let rc = host.proc_write_bytes(pid, req.encoder_id_ptr as u32, &1u32.to_le_bytes()); + if let Some(pointer) = encoder_id_ptr { + let rc = host.proc_write_bytes(pid, pointer, &1u32.to_le_bytes()); if rc < 0 { return Err(Errno::EFAULT); } @@ -1401,7 +1409,13 @@ fn handle_dri_card_ioctl( if req.connector_id != 1 { return Err(Errno::ENOENT); } - if req.count_modes >= 1 && req.modes_ptr != 0 { + let modes_ptr = (req.count_modes >= 1 && req.modes_ptr != 0) + .then(|| checked_dri_process_pointer(req.modes_ptr)) + .transpose()?; + let encoders_ptr = (req.count_encoders >= 1 && req.encoders_ptr != 0) + .then(|| checked_dri_process_pointer(req.encoders_ptr)) + .transpose()?; + if let Some(pointer) = modes_ptr { let mode = host.kms_mode_info(1); let mode_bytes = unsafe { core::slice::from_raw_parts( @@ -1409,14 +1423,13 @@ fn handle_dri_card_ioctl( core::mem::size_of::(), ) }; - let rc = host.proc_write_bytes(pid, req.modes_ptr as u32, mode_bytes); + let rc = host.proc_write_bytes(pid, pointer, mode_bytes); if rc < 0 { return Err(Errno::EFAULT); } } - if req.count_encoders >= 1 && req.encoders_ptr != 0 { - let rc = - host.proc_write_bytes(pid, req.encoders_ptr as u32, &1u32.to_le_bytes()); + if let Some(pointer) = encoders_ptr { + let rc = host.proc_write_bytes(pid, pointer, &1u32.to_le_bytes()); if rc < 0 { return Err(Errno::EFAULT); } @@ -1568,18 +1581,15 @@ fn handle_dri_card_ioctl( // Best-effort stats: a clock-read failure leaves the flip // queued and just skips the counter bump. The host reads // the running totals via the kernel_kms_* exports. - let (tv_sec, tv_usec) = match host.host_clock_gettime( - wasm_posix_shared::clock::CLOCK_MONOTONIC, - ) { - Ok((sec, nsec)) => { - let now_us = (sec as u64) - .wrapping_mul(1_000_000) - + (nsec as u64) / 1000; - crate::dri::record_kms_commit(req.crtc_id, now_us); - (sec as u32, (nsec / 1000) as u32) - } - Err(_) => (0u32, 0u32), - }; + let (tv_sec, tv_usec) = + match host.host_clock_gettime(wasm_posix_shared::clock::CLOCK_MONOTONIC) { + Ok((sec, nsec)) => { + let now_us = (sec as u64).wrapping_mul(1_000_000) + (nsec as u64) / 1000; + crate::dri::record_kms_commit(req.crtc_id, now_us); + (sec as u32, (nsec / 1000) as u32) + } + Err(_) => (0u32, 0u32), + }; let sequence = crate::dri::vblank_tick(); let kms_mut = kms_state_mut(proc, ofd_idx)?; kms_mut.pending_flips.push(crate::ofd::PendingFlip { @@ -1673,12 +1683,7 @@ pub(crate) fn dri_release_ofd_state( // Drop master if this OFD held it — otherwise a // dangling holder would block future SET_MASTER calls // by other processes. - crate::dri::master::release_if_held( - kms.holds_master, - pid, - ofd_idx, - host, - ); + crate::dri::master::release_if_held(kms.holds_master, pid, ofd_idx, host); if dri.gl.is_some() { host.gl_unbind(pid); } @@ -1774,7 +1779,7 @@ fn check_access_for_ids( /// canonical paths may be longer when resolving a short relative pathname /// from a deep CWD. Component limits are byte limits as required by the guest /// ABI, not JavaScript UTF-16 code-unit limits in a host backend. -const NAMESPACE_PATH_MAX: usize = 4096; +const NAMESPACE_PATH_MAX: usize = wasm_posix_shared::platform_limits::PATH_MAX_BYTES; const NAMESPACE_NAME_MAX: usize = 255; #[derive(Clone, Copy)] @@ -2089,10 +2094,7 @@ fn resolve_namespace_path_from( // Preserve final fd-link metadata for procfs handling instead of // feeding its descriptive OFD path to a filesystem backend. // Directory-fd traversal through procfs remains a documented gap. - if matches!( - procfs_entry, - Some(crate::procfs::ProcfsEntry::FdLink(_, _)) - ) { + if matches!(procfs_entry, Some(crate::procfs::ProcfsEntry::FdLink(_, _))) { if !is_final || require_directory { return Err(Errno::ENOTDIR); } @@ -2101,8 +2103,7 @@ fn resolve_namespace_path_from( continue; } let target = namespace_readlink_raw(proc, host, &candidate)?; - if substituted_path_len(&target, &pending) - .ok_or(Errno::ENAMETOOLONG)? + if substituted_path_len(&target, &pending).ok_or(Errno::ENAMETOOLONG)? >= NAMESPACE_PATH_MAX { return Err(Errno::ENAMETOOLONG); @@ -2468,12 +2469,9 @@ fn try_open_fifo( unsafe { crate::pipe::global_pipe_table().get_mut(pipe_idx) } .ok_or(Errno::EIO)? .add_reference(kind); - let ofd_idx = proc.ofd_table.create( - FileType::Pipe, - status_flags, - pipe_handle, - resolved.to_vec(), - ); + let ofd_idx = + proc.ofd_table + .create(FileType::Pipe, status_flags, pipe_handle, resolved.to_vec()); return match proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags) { Ok(fd) => Ok(Some(fd)), Err(error) => { @@ -2498,8 +2496,8 @@ fn try_open_fifo( let fd_flags = oflags_to_fd_flags(oflags); let mut reserved_side = None; { - let pipe = unsafe { crate::pipe::global_pipe_table().get_mut(pipe_idx) } - .ok_or(Errno::EIO)?; + let pipe = + unsafe { crate::pipe::global_pipe_table().get_mut(pipe_idx) }.ok_or(Errno::EIO)?; let ready = match access_mode { O_RDONLY if nonblocking => true, O_RDONLY => pipe.is_write_end_open(), @@ -2558,12 +2556,9 @@ fn try_open_fifo( if access_mode == O_RDWR { let pipe_handle = -((pipe_idx as i64) + 1); - let ofd_idx = proc.ofd_table.create( - FileType::Pipe, - status_flags, - pipe_handle, - resolved.to_vec(), - ); + let ofd_idx = + proc.ofd_table + .create(FileType::Pipe, status_flags, pipe_handle, resolved.to_vec()); return match proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags) { Ok(fd) => { let Some(pipe) = (unsafe { crate::pipe::global_pipe_table().get_mut(pipe_idx) }) @@ -2739,9 +2734,7 @@ pub fn sys_open( } // Devfs (/dev, /dev/pts, etc.) — in-kernel directory listing - if !is_host_backed_devfs_path(&resolved) - && crate::devfs::match_devfs_dir(&resolved).is_some() - { + if !is_host_backed_devfs_path(&resolved) && crate::devfs::match_devfs_dir(&resolved).is_some() { return crate::devfs::devfs_open_dir(proc, resolved, oflags); } @@ -2757,12 +2750,9 @@ pub fn sys_open( let status_flags = oflags & !CREATION_FLAGS; let object_id = synthetic_file_object_id(&resolved).ok_or(Errno::EINVAL)?; let host_handle = crate::descriptor_backing::alloc_synthetic_regular(); - let ofd_idx = proc.ofd_table.create( - FileType::Regular, - status_flags, - host_handle, - resolved, - ); + let ofd_idx = proc + .ofd_table + .create(FileType::Regular, status_flags, host_handle, resolved); proc.ofd_table.get_mut(ofd_idx).unwrap().file_id = Some(FileId::Kernel { kind: KernelFileKind::SyntheticRegular, object_id, @@ -2851,6 +2841,168 @@ pub(crate) fn drain_deferred_scm_rights_releases( } } +/// Complete deferred SCM_RIGHTS cleanup at an exported operation boundary. +/// +/// Queue mutation cannot synchronously re-enter the global backing tables, so +/// an operation that can consume or discard ancillary data must call this +/// after those table borrows end and before publishing its result. +pub(crate) fn finish_scm_rights_cleanup(locks: &mut AdvisoryLockManager, host: &mut dyn HostIO) { + drain_deferred_scm_rights_releases(locks, host); +} + +/// Run an outer cleanup boundary only when an ownership drop queued work. +/// +/// The callback must acquire resource-table borrows only after the operation +/// that may have queued the release has ended them. Keeping the pending probe +/// here gives exported host-pipe and channel boundaries one exact, testable +/// empty-path contract without walking or borrowing machine state. +pub(crate) fn finish_scm_rights_cleanup_if_pending(finish: impl FnOnce()) { + if crate::pipe::has_deferred_in_flight_releases() { + finish(); + } +} + +/// Reject open-file-description metadata that SCM_RIGHTS cannot reconstruct. +/// +/// Keep this match exhaustive: adding a new [`FileType`] must require an +/// explicit transferability decision instead of silently treating the new +/// backing as copyable. DRI sidecars are checked separately while the source +/// OFD is still available. +pub(crate) fn validate_scm_rights_transfer_metadata( + file_type: FileType, + host_handle: i64, +) -> Result<(), Errno> { + match file_type { + FileType::Socket | FileType::Epoll => Err(Errno::EOPNOTSUPP), + FileType::Regular if host_handle < 0 => { + crate::descriptor_backing::is_live_managed_ofd(file_type, host_handle) + .then_some(()) + .ok_or(Errno::EOPNOTSUPP) + } + FileType::Directory if host_handle < 0 => matches!( + host_handle, + crate::procfs::PROCFS_DIR_HANDLE | crate::devfs::DEVFS_DIR_HANDLE + ) + .then_some(()) + .ok_or(Errno::EOPNOTSUPP), + FileType::CharDevice if host_handle < 0 => { + match VirtualDevice::from_host_handle(host_handle) { + Some( + VirtualDevice::Null + | VirtualDevice::Zero + | VirtualDevice::Urandom + | VirtualDevice::Full, + ) => Ok(()), + // Framebuffer, input, audio, and DRI virtual handles name state + // owned by the sending process. Copying the integer would + // create a descriptor that cannot observe the same open + // description. + _ => Err(Errno::EOPNOTSUPP), + } + } + FileType::EventFd | FileType::TimerFd | FileType::SignalFd | FileType::MemFd => { + crate::descriptor_backing::is_live_managed_ofd(file_type, host_handle) + .then_some(()) + .ok_or(Errno::EOPNOTSUPP) + } + FileType::PtyMaster | FileType::PtySlave => usize::try_from(host_handle) + .ok() + .and_then(crate::pty::get_pty) + .map(|_| ()) + .ok_or(Errno::EOPNOTSUPP), + FileType::Regular | FileType::Directory | FileType::Pipe | FileType::CharDevice => Ok(()), + } +} + +fn decode_scm_rights_kernel_pipe_handle(host_handle: i64) -> Result { + if host_handle >= 0 { + return Err(Errno::EOPNOTSUPP); + } + host_handle + .checked_add(1) + .and_then(i64::checked_neg) + .and_then(|index| usize::try_from(index).ok()) + .ok_or(Errno::EOPNOTSUPP) +} + +/// Build one non-owning SCM_RIGHTS snapshot after proving it is reconstructible. +/// +/// Ownership is intentionally acquired later by [`sys_sendmsg`], after the +/// complete control message has validated. That ordering prevents a malformed +/// later record from leaving an earlier descriptor retained. +pub(crate) fn snapshot_scm_rights_fd( + proc: &Process, + fd_num: i32, +) -> Result { + let fd_entry = proc.fd_table.get(fd_num).map_err(|_| Errno::EBADF)?; + let ofd = proc.ofd_table.get(fd_entry.ofd_ref.0).ok_or(Errno::EBADF)?; + + // DRI open-file descriptions carry GEM/KMS namespaces in a sidecar that + // InFlightFd cannot reproduce. + if ofd.dri_state.is_some() { + return Err(Errno::EOPNOTSUPP); + } + validate_scm_rights_transfer_metadata(ofd.file_type, ofd.host_handle)?; + + let mut path = Vec::new(); + path.try_reserve_exact(ofd.path.len()) + .map_err(|_| Errno::ENOMEM)?; + path.extend_from_slice(&ofd.path); + let mut in_flight = crate::pipe::InFlightFd::new( + ofd.ofd_id, + ofd.file_id, + ofd.file_type, + ofd.status_flags, + ofd.host_handle, + ofd.offset, + path, + ); + + if ofd.file_type == FileType::Pipe && ofd.host_handle < 0 { + let pipe_idx = decode_scm_rights_kernel_pipe_handle(ofd.host_handle)?; + in_flight.pipe_ref_kind = unsafe { crate::pipe::global_pipe_table().get(pipe_idx) } + .and_then(|pipe| pipe.reference_kind(ofd.status_flags)); + if in_flight.pipe_ref_kind.is_none() { + return Err(Errno::EOPNOTSUPP); + } + } + + Ok(in_flight) +} + +/// Validate every field that controls reconstruction of a queued descriptor. +/// +/// This does not acquire ownership. It is safe to run on non-owning send +/// snapshots and is repeated at receive installation as a fail-closed guard +/// against malformed or legacy queue entries. +pub(crate) fn validate_scm_rights_in_flight_fd( + entry: &crate::pipe::InFlightFd, +) -> Result<(), Errno> { + validate_scm_rights_transfer_metadata(entry.file_type, entry.host_handle)?; + match entry.file_type { + FileType::Pipe if entry.host_handle < 0 => { + let pipe_idx = decode_scm_rights_kernel_pipe_handle(entry.host_handle)?; + let expected = unsafe { crate::pipe::global_pipe_table().get(pipe_idx) } + .and_then(|pipe| pipe.reference_kind(entry.status_flags)) + .ok_or(Errno::EOPNOTSUPP)?; + if entry.pipe_ref_kind != Some(expected) { + return Err(Errno::EOPNOTSUPP); + } + } + FileType::Pipe => { + if entry.pipe_ref_kind.is_some() { + return Err(Errno::EOPNOTSUPP); + } + } + _ => { + if entry.pipe_ref_kind.is_some() { + return Err(Errno::EOPNOTSUPP); + } + } + } + Ok(()) +} + /// Install queued SCM_RIGHTS descriptors in a receiver while transferring, /// rather than recreating, each machine-wide open-file-description identity. /// This is kernel logic rather than a Wasm binding concern so native runtimes @@ -2859,73 +3011,52 @@ pub(crate) fn install_scm_rights_fds( proc: &mut Process, in_flight: Vec, ) -> Vec { - use crate::socket::{SocketDomain, SocketInfo, SocketState, SocketType}; + install_scm_rights_fds_with_flags(proc, in_flight, 0) +} +/// Install queued descriptors with receiver-specified descriptor flags. +/// +/// `MSG_CMSG_CLOEXEC` must be applied in the same `FdTable::alloc` operation +/// that publishes the fd; setting it afterward would expose a race with exec. +pub(crate) fn install_scm_rights_fds_with_flags( + proc: &mut Process, + in_flight: Vec, + fd_flags: u32, +) -> Vec { let mut new_fds = Vec::new(); + if new_fds.try_reserve_exact(in_flight.len()).is_err() { + // The recvmsg wrapper reports the dropped batch as MSG_CTRUNC. Do not + // panic or install a prefix that cannot itself be reported. + return new_fds; + } for mut entry in in_flight { - let mut installed_socket_idx = None; - let receiver_host_handle = match entry.file_type { - FileType::Socket => { - // Socket state is process-local, while its retained global - // pipe references and OfdId transfer without duplication. - let Some(ref socket_data) = entry.socket else { - continue; - }; - let domain = match socket_data.domain { - 0 => SocketDomain::Unix, - 1 => SocketDomain::Inet, - _ => SocketDomain::Inet6, - }; - let socket_type = match socket_data.sock_type { - 0 => SocketType::Stream, - _ => SocketType::Dgram, - }; - let state = match socket_data.state { - 0 => SocketState::Unbound, - 1 => SocketState::Bound, - 2 => SocketState::Listening, - 3 => SocketState::Connected, - _ => SocketState::Closed, - }; - - let mut socket = SocketInfo::new(domain, socket_type, socket_data.protocol); - socket.state = state; - socket.send_buf_idx = socket_data.send_buf_idx; - socket.recv_buf_idx = socket_data.recv_buf_idx; - socket.global_pipes = socket_data.global_pipes; - socket.shut_rd = socket_data.shut_rd; - socket.shut_wr = socket_data.shut_wr; - socket.bind_addr = socket_data.bind_addr; - socket.bind_port = socket_data.bind_port; - socket.peer_addr = socket_data.peer_addr; - socket.peer_port = socket_data.peer_port; - - let socket_idx = proc.sockets.alloc(socket); - installed_socket_idx = Some(socket_idx); - -((socket_idx as i64) + 1) - } - _ => entry.host_handle, - }; + // Production validates before retain. Keep the receive boundary closed + // too. Only a genuinely owned, completely reconstructible queue entry + // can publish a receiver fd; dropping a rejected owned entry rolls its + // retained backing reference through the deferred cleanup path. + if !entry.owns_reference() || validate_scm_rights_in_flight_fd(&entry).is_err() { + continue; + } let ofd_idx = proc.ofd_table.create_transferred( entry.ofd_id, entry.file_id, entry.file_type, entry.status_flags, - receiver_host_handle, + entry.host_handle, entry.offset, - entry.path.clone(), + // The receiver becomes the only consumer of this snapshot path; + // moving it avoids an infallible allocation in a receive path + // whose resource failures must remain recoverable. + core::mem::take(&mut entry.path), ); - match proc.fd_table.alloc(OpenFileDescRef(ofd_idx), 0) { + match proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags) { Ok(new_fd) => { entry.transfer_reference(); new_fds.push(new_fd); } Err(_) => { proc.ofd_table.dec_ref(ofd_idx); - if let Some(socket_idx) = installed_socket_idx { - proc.sockets.free(socket_idx); - } // `entry` still owns the queued resource reference; Drop // releases it through the deferred cleanup path. } @@ -3048,15 +3179,14 @@ fn sys_close_impl( } FileType::Socket => { let sock_idx = (-(host_handle + 1)) as usize; - let unix_dgram_send_state_changed = proc.sockets.get(sock_idx).is_some_and(|sock| { - sock.domain == crate::socket::SocketDomain::Unix - && sock.sock_type == crate::socket::SocketType::Dgram - }); + let unix_dgram_send_state_changed = + proc.sockets.get(sock_idx).is_some_and(|sock| { + sock.domain == crate::socket::SocketDomain::Unix + && sock.sock_type == crate::socket::SocketType::Dgram + }); if let Some(sock) = proc.sockets.get(sock_idx) { if let Some(path) = sock.bind_path.as_deref() { - let registry = unsafe { - crate::unix_socket::global_unix_socket_registry() - }; + let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; registry.remove_owner(path, proc.pid, sock_idx); } if sock.domain == crate::socket::SocketDomain::Inet @@ -3270,7 +3400,6 @@ pub fn sys_read( if ofd.is_path_only() || access_mode == O_WRONLY { return Err(Errno::EBADF); } - let host_handle = ofd.host_handle; let file_type = ofd.file_type; let status_flags = ofd.status_flags; @@ -3304,6 +3433,13 @@ pub fn sys_read( let domain = sock.domain; let sock_type = sock.sock_type; let shut_rd = sock.shut_rd; + // A zero-count socket read validates the descriptor and socket + // backing but performs no receive. In particular it must not + // consume an empty AF_UNIX datagram (or the SCM_RIGHTS control + // message atomically attached to that datagram). + if buf.is_empty() { + return Ok(0); + } if shut_rd { return Ok(0); } @@ -3389,9 +3525,7 @@ pub fn sys_read( } // Find lowest matching signal (bit N = signal N+1, musl 0-based convention) let signo = matching.trailing_zeros() + 1; - let info = proc - .consume_signal_for(tid, signo) - .ok_or(Errno::EAGAIN)?; + let info = proc.consume_signal_for(tid, signo).ok_or(Errno::EAGAIN)?; let (sender_pid, sender_uid, timer_id, overrun) = match info.timer_id { Some(timer_id) => ( 0, @@ -3399,7 +3533,7 @@ pub fn sys_read( timer_id, proc.accept_posix_timer_notification(timer_id).unwrap_or(0), ), - None => (proc.pid, proc.uid, 0, 0), + None => (info.sender_pid, info.sender_uid, 0, 0), }; // Write the signal-specific fields in Linux signalfd_siginfo. for b in buf[..128].iter_mut() { @@ -3411,8 +3545,11 @@ pub fn sys_read( buf[16..20].copy_from_slice(&sender_uid.to_le_bytes()); buf[24..28].copy_from_slice(&timer_id.to_le_bytes()); buf[32..36].copy_from_slice(&overrun.to_le_bytes()); - buf[44..48].copy_from_slice(&info.si_value.to_le_bytes()); - buf[48..56].copy_from_slice(&(info.si_value as u32 as u64).to_le_bytes()); + // Linux exposes the same union payload through a low 32-bit + // integer member and a full 64-bit pointer member. Preserve the + // raw bits rather than deriving the pointer from a narrowed int. + buf[44..48].copy_from_slice(&(info.si_value_bits as u32).to_le_bytes()); + buf[48..56].copy_from_slice(&info.si_value_bits.to_le_bytes()); Ok(128) } FileType::EventFd => { @@ -3999,10 +4136,7 @@ pub fn sys_lseek( return Err(Errno::EIO); } let name = &name_buf[..name_len]; - if name == b"." - || name == b".." - || is_root_virtual_dirent(&path, name) - { + if name == b"." || name == b".." || is_root_virtual_dirent(&path, name) { continue; } skipped += 1; @@ -4091,11 +4225,7 @@ pub fn sys_lseek( if new_pos < 0 { return Err(Errno::EINVAL); } - crate::descriptor_backing::set_current_offset( - ofd.file_type, - ofd.host_handle, - new_pos, - )?; + crate::descriptor_backing::set_current_offset(ofd.file_type, ofd.host_handle, new_pos)?; return Ok(new_pos); } @@ -4112,11 +4242,7 @@ pub fn sys_lseek( if new_pos < 0 { return Err(Errno::EINVAL); } - crate::descriptor_backing::set_current_offset( - ofd.file_type, - ofd.host_handle, - new_pos, - )?; + crate::descriptor_backing::set_current_offset(ofd.file_type, ofd.host_handle, new_pos)?; return Ok(new_pos); } @@ -4366,10 +4492,7 @@ pub(crate) fn write_operation_budget( /// Output RLIMIT checks must not hide an invalid input descriptor. fn validate_transfer_input(proc: &Process, fd: i32, offset: Option) -> Result<(), Errno> { let entry = proc.fd_table.get(fd)?; - let ofd = proc - .ofd_table - .get(entry.ofd_ref.0) - .ok_or(Errno::EBADF)?; + let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; if ofd.is_path_only() || ofd.status_flags & O_ACCMODE == O_WRONLY { return Err(Errno::EBADF); } @@ -4513,15 +4636,14 @@ pub fn sys_pwritev( offset: i64, ) -> Result { let requested_len = checked_iovec_len(iovecs)?; - let writable_len = - write_operation_budget( - proc, - host, - crate::process_table::current_tid(), - fd, - Some(offset), - requested_len, - )?; + let writable_len = write_operation_budget( + proc, + host, + crate::process_table::current_tid(), + fd, + Some(offset), + requested_len, + )?; let mut total = 0usize; let mut cur_offset = offset; for buf in iovecs { @@ -5287,10 +5409,7 @@ fn advisory_file_id( }, _ => return Err(Errno::EINVAL), }; - proc.ofd_table - .get_mut(ofd_idx) - .ok_or(Errno::EBADF)? - .file_id = Some(file_id); + proc.ofd_table.get_mut(ofd_idx).ok_or(Errno::EBADF)?.file_id = Some(file_id); Ok(file_id) } @@ -5380,18 +5499,19 @@ fn sys_fcntl_lock_with_owner( } value if value == SEEK_END as i32 => { let size = match file_type { - FileType::Regular if host_handle >= 0 => host - .host_fstat(host_handle) - .map_err(|err| { - // Reserve EAGAIN from a blocking lock request for an - // actual manager conflict; setup failures complete. - if err == Errno::EAGAIN { - Errno::EIO - } else { - err - } - })? - .st_size, + FileType::Regular if host_handle >= 0 => { + host.host_fstat(host_handle) + .map_err(|err| { + // Reserve EAGAIN from a blocking lock request for an + // actual manager conflict; setup failures complete. + if err == Errno::EAGAIN { + Errno::EIO + } else { + err + } + })? + .st_size + } // Synthetic and procfs regular files have stable kernel // identities and a truthful fstat size despite using a // negative backing handle. Resolve SEEK_END through the same @@ -5577,8 +5697,7 @@ fn fifo_path_stat_raw( }; st.st_mode = wasm_posix_shared::mode::S_IFIFO | (st.st_mode & 0o7777); st.st_size = 0; - let pipe = unsafe { crate::pipe::global_pipe_table().get_mut(pipe_idx) } - .ok_or(Errno::EIO)?; + let pipe = unsafe { crate::pipe::global_pipe_table().get_mut(pipe_idx) }.ok_or(Errno::EIO)?; st.st_nlink = pipe.fifo_name_count(); pipe.update_fifo_metadata(st); Ok(Some(st)) @@ -5616,8 +5735,7 @@ fn update_fifo_metadata( pipe_idx: usize, update: impl FnOnce(&mut WasmStat) -> Result<(), Errno>, ) -> Result<(), Errno> { - let pipe = unsafe { crate::pipe::global_pipe_table().get_mut(pipe_idx) } - .ok_or(Errno::EIO)?; + let pipe = unsafe { crate::pipe::global_pipe_table().get_mut(pipe_idx) }.ok_or(Errno::EIO)?; let mut metadata = pipe.fifo_metadata().ok_or(Errno::EIO)?; update(&mut metadata)?; metadata.st_mode = S_IFIFO | (metadata.st_mode & 0o7777); @@ -5628,8 +5746,7 @@ fn update_fifo_metadata( } fn realtime_timestamp(host: &mut dyn HostIO) -> Result<(u64, u32), Errno> { - let (sec, nsec) = - host.host_clock_gettime(wasm_posix_shared::clock::CLOCK_REALTIME)?; + let (sec, nsec) = host.host_clock_gettime(wasm_posix_shared::clock::CLOCK_REALTIME)?; Ok(( u64::try_from(sec).map_err(|_| Errno::EINVAL)?, u32::try_from(nsec).map_err(|_| Errno::EINVAL)?, @@ -5797,8 +5914,7 @@ pub fn sys_mkfifo( path: &[u8], mode: u32, ) -> Result<(), Errno> { - let resolved = - resolve_namespace_path(proc, host, path, PathResolveOptions::CREATE_ENTRY)?; + let resolved = resolve_namespace_path(proc, host, path, PathResolveOptions::CREATE_ENTRY)?; make_fifo(proc, host, resolved, mode) } @@ -5810,13 +5926,7 @@ pub fn sys_mkfifoat( path: &[u8], mode: u32, ) -> Result<(), Errno> { - let resolved = resolve_at_path( - proc, - host, - dirfd, - path, - PathResolveOptions::CREATE_ENTRY, - )?; + let resolved = resolve_at_path(proc, host, dirfd, path, PathResolveOptions::CREATE_ENTRY)?; make_fifo(proc, host, resolved, mode) } @@ -5854,10 +5964,7 @@ fn make_fifo( }; metadata.st_mode = wasm_posix_shared::mode::S_IFIFO | (metadata.st_mode & 0o7777); metadata.st_size = 0; - let pipe = crate::pipe::PipeBuffer::new_fifo( - crate::pipe::DEFAULT_PIPE_CAPACITY, - metadata, - ); + let pipe = crate::pipe::PipeBuffer::new_fifo(crate::pipe::DEFAULT_PIPE_CAPACITY, metadata); let pipe_idx = unsafe { crate::pipe::global_pipe_table().alloc(pipe) }; if !unsafe { crate::fifo::global_fifo_table() }.register(resolved.path.clone(), pipe_idx) { unsafe { crate::pipe::global_pipe_table().remove_fifo_name(pipe_idx) }; @@ -5873,8 +5980,7 @@ fn unlink_host_entry(host: &mut dyn HostIO, resolved: &[u8]) -> Result<(), Errno // Linux returns EISDIR when unlinking a directory; macOS returns EPERM. // musl's remove() depends on EISDIR to fall through to rmdir(). if let Ok(st) = host.host_stat(resolved) { - if st.st_mode & wasm_posix_shared::mode::S_IFMT - == wasm_posix_shared::mode::S_IFDIR + if st.st_mode & wasm_posix_shared::mode::S_IFMT == wasm_posix_shared::mode::S_IFDIR { return Err(Errno::EISDIR); } @@ -6049,8 +6155,7 @@ pub fn sys_symlink( linkpath: &[u8], ) -> Result<(), Errno> { // Note: symlink target is stored as-is (not resolved), but linkpath is resolved - let link = - resolve_namespace_path(proc, host, linkpath, PathResolveOptions::CREATE_ENTRY)?.path; + let link = resolve_namespace_path(proc, host, linkpath, PathResolveOptions::CREATE_ENTRY)?.path; ensure_host_mutable_namespace_path(&link)?; check_parent_writable(proc, host, &link)?; host.host_symlink(target, &link) @@ -6109,9 +6214,7 @@ fn prepare_chown_ids( // GID here. The unchanged sentinels preserve their corresponding IDs. if proc.euid != st.st_uid || (uid != CHOWN_ID_UNCHANGED && uid != st.st_uid) - || (gid != CHOWN_ID_UNCHANGED - && gid != proc.egid - && gid != proc.gid) + || (gid != CHOWN_ID_UNCHANGED && gid != proc.egid && gid != proc.gid) { return Err(Errno::EPERM); } @@ -6217,11 +6320,7 @@ pub fn sys_fchdir(proc: &mut Process, fd: i32) -> Result<(), Errno> { /// Get the current working directory. /// Writes the cwd path to `buf` and returns the number of bytes written. /// Returns ERANGE if the buffer is too small. -pub fn sys_getcwd( - proc: &Process, - host: &mut dyn HostIO, - buf: &mut [u8], -) -> Result { +pub fn sys_getcwd(proc: &Process, host: &mut dyn HostIO, buf: &mut [u8]) -> Result { let stat = namespace_lstat_raw(proc, host, &proc.cwd)?; if stat.st_mode & S_IFMT != S_IFDIR { return Err(Errno::ENOENT); @@ -6246,8 +6345,7 @@ pub fn sys_opendir(proc: &mut Process, host: &mut dyn HostIO, path: &[u8]) -> Re } check_access(proc, &st, R_OK | X_OK)?; if is_procfs_namespace_path(&resolved.path) - || (is_devfs_namespace_path(&resolved.path) - && !is_host_backed_devfs_path(&resolved.path)) + || (is_devfs_namespace_path(&resolved.path) && !is_host_backed_devfs_path(&resolved.path)) { // Kernel-owned procfs/devfs directory iteration is implemented by // open(O_DIRECTORY)+getdents64. This legacy directory-stream API has @@ -6494,8 +6592,8 @@ pub fn sys_getdents64( } let path = ofd.path.clone(); - let reopen_cookie = (ofd.dir_host_handle == -1 && ofd.dir_entry_offset > 2) - .then_some(ofd.dir_entry_offset); + let reopen_cookie = + (ofd.dir_host_handle == -1 && ofd.dir_entry_offset > 2).then_some(ofd.dir_entry_offset); // Process boundaries cannot carry a live host iterator safely. Fork, // non-forking spawn, retained legacy exec, and SCM_RIGHTS preserve the @@ -6685,14 +6783,8 @@ pub fn sys_getdents64( let mut next_synth_state = synth_state; for name in synth_entries { entry_offset += 1; - let written = crate::procfs::write_dirent64( - buf, - pos, - 1, - entry_offset, - 4, /* DT_DIR */ - name, - ); + let written = + crate::procfs::write_dirent64(buf, pos, 1, entry_offset, 4 /* DT_DIR */, name); if written == 0 { if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { ofd.dir_entry_offset = entry_offset - 1; // didn't emit this one @@ -6972,7 +7064,11 @@ pub fn sys_kill(proc: &mut Process, pid: i32, sig: u32) -> Result<(), Errno> { if sig == 0 { return Ok(()); } - proc.raise_signal(sig); + // WHY: signal dequeue now trusts queued credentials instead of + // synthesizing the recipient's identity. This process-local compatibility + // path is self-delivery, so retain the same SI_USER metadata observable + // through SA_SIGINFO as ordinary kill(getpid(), sig). + proc.raise_signal_with_metadata(sig, 0, 0, proc.pid, proc.uid); Ok(()) } @@ -7171,7 +7267,7 @@ pub fn sys_sigtimedwait( _host: &mut dyn HostIO, mask: u64, _timeout_ms: i32, -) -> Result<(u32, i32, i32, i32, i32), Errno> { +) -> Result<(u32, u64, i32, i32, i32), Errno> { use wasm_posix_shared::signal::NSIG; let tid = crate::process_table::current_tid(); @@ -7181,17 +7277,21 @@ pub fn sys_sigtimedwait( if pending_in_mask != 0 { let signum = pending_in_mask.trailing_zeros() + 1; if signum < NSIG { - let info = proc - .consume_signal_for(tid, signum) - .ok_or(Errno::EAGAIN)?; + let info = proc.consume_signal_for(tid, signum).ok_or(Errno::EAGAIN)?; let (word_1, word_2) = match info.timer_id { Some(timer_id) => ( timer_id as i32, proc.accept_posix_timer_notification(timer_id).unwrap_or(0), ), - None => (proc.pid as i32, proc.uid as i32), + None => (info.sender_pid as i32, info.sender_uid as i32), }; - return Ok((signum, info.si_value, info.si_code, word_1, word_2)); + return Ok(( + signum, + info.si_value_bits, + info.si_code, + word_1, + word_2, + )); } } @@ -7448,8 +7548,11 @@ fn host_clock_id(clock_id: u32) -> Result { use wasm_posix_shared::clock::*; match clock_id { - CLOCK_REALTIME | CLOCK_MONOTONIC | CLOCK_PROCESS_CPUTIME_ID - | CLOCK_THREAD_CPUTIME_ID | CLOCK_BOOTTIME => Ok(clock_id), + CLOCK_REALTIME + | CLOCK_MONOTONIC + | CLOCK_PROCESS_CPUTIME_ID + | CLOCK_THREAD_CPUTIME_ID + | CLOCK_BOOTTIME => Ok(clock_id), // Linux exposes coarse variants to libc consumers such as MariaDB. // Kandelo's hosts do not maintain separate coarse clock sources, so // preserve the clock domain while using the corresponding canonical @@ -7571,16 +7674,13 @@ fn check_utimens_permissions( st: &WasmStat, times: Option<&[WasmTimespec; 2]>, ) -> Result { - let both_omit = times.is_some_and(|ts| { - ts[0].tv_nsec == UTIME_OMIT && ts[1].tv_nsec == UTIME_OMIT - }); + let both_omit = + times.is_some_and(|ts| ts[0].tv_nsec == UTIME_OMIT && ts[1].tv_nsec == UTIME_OMIT); if both_omit { return Ok(false); } - let both_now = times.is_some_and(|ts| { - ts[0].tv_nsec == UTIME_NOW && ts[1].tv_nsec == UTIME_NOW - }); + let both_now = times.is_some_and(|ts| ts[0].tv_nsec == UTIME_NOW && ts[1].tv_nsec == UTIME_NOW); if times.is_none() || both_now { if proc.euid == 0 || proc.euid == st.st_uid || has_access(proc, st, W_OK) { return Ok(true); @@ -7626,13 +7726,7 @@ pub fn sys_utimensat( if let Some(live_path) = unsafe { crate::fifo::global_fifo_table() }.path_for_pipe(pipe_idx) { - host.host_utimensat( - &live_path, - atime_sec, - atime_nsec, - mtime_sec, - mtime_nsec, - )?; + host.host_utimensat(&live_path, atime_sec, atime_nsec, mtime_sec, mtime_nsec)?; let refreshed = host.host_stat(&live_path)?; return update_fifo_metadata(pipe_idx, |metadata| { metadata.st_atime_sec = refreshed.st_atime_sec; @@ -8029,8 +8123,8 @@ pub fn sys_mmap( return Err(Errno::EINVAL); } let bo_id = bo_id_u64 as crate::dri::BoId; - let bo_size = crate::dri::with_registry(|r| r.get(bo_id).map(|b| b.size)) - .ok_or(Errno::EINVAL)?; + let bo_size = + crate::dri::with_registry(|r| r.get(bo_id).map(|b| b.size)).ok_or(Errno::EINVAL)?; let has_local_handle = ofd .dri() .map(|dri| dri_fd_has_bo_handle(dri, bo_id)) @@ -8192,10 +8286,7 @@ pub(crate) fn listener_accept_wake_for_entry( sock.accept_wake_idx } -pub(crate) fn find_listener_fd_by_accept_wake( - proc: &Process, - wake_idx: u32, -) -> Option { +pub(crate) fn find_listener_fd_by_accept_wake(proc: &Process, wake_idx: u32) -> Option { proc.fd_table.iter().find_map(|(fd, entry)| { (listener_accept_wake_for_entry(proc, entry) == Some(wake_idx)).then_some(fd) }) @@ -8251,7 +8342,7 @@ pub fn sys_socket( Ok(fd) } -/// Create a connected pair of Unix domain stream sockets. +/// Create a connected pair of Unix domain stream or datagram sockets. pub fn sys_socketpair( proc: &mut Process, _host: &mut dyn HostIO, @@ -8269,28 +8360,37 @@ pub fn sys_socketpair( let base_type = sock_type & !(SOCK_NONBLOCK | SOCK_CLOEXEC); let stype = match base_type { SOCK_STREAM => SocketType::Stream, + SOCK_DGRAM => SocketType::Dgram, _ => return Err(Errno::EPROTOTYPE), }; - // Allocate two ring buffers in the GLOBAL pipe table so they survive fork. - // After fork, both parent and child share these buffers (like POSIX). - let pipe_table = unsafe { crate::pipe::global_pipe_table() }; - let buf_ab_idx = pipe_table.alloc(PipeBuffer::new(DEFAULT_PIPE_CAPACITY)); - let buf_ba_idx = pipe_table.alloc(PipeBuffer::new(DEFAULT_PIPE_CAPACITY)); + // Stream payloads use machine-wide pipes so they survive fork. Datagram + // socketpairs retain their message boundaries in each peer's atomic queue + // and therefore must not acquire unrelated stream buffers. + let pipe_indices = if stype == SocketType::Stream { + let pipe_table = unsafe { crate::pipe::global_pipe_table() }; + Some(pipe_table.alloc_pair( + PipeBuffer::new(DEFAULT_PIPE_CAPACITY), + PipeBuffer::new(DEFAULT_PIPE_CAPACITY), + )) + } else { + None + }; - // Socket A: sends to buf_ab, receives from buf_ba let mut sock_a = SocketInfo::new(SocketDomain::Unix, stype, 0); sock_a.state = SocketState::Connected; - sock_a.send_buf_idx = Some(buf_ab_idx); - sock_a.recv_buf_idx = Some(buf_ba_idx); - sock_a.global_pipes = true; - // Socket B: sends to buf_ba, receives from buf_ab let mut sock_b = SocketInfo::new(SocketDomain::Unix, stype, 0); sock_b.state = SocketState::Connected; - sock_b.send_buf_idx = Some(buf_ba_idx); - sock_b.recv_buf_idx = Some(buf_ab_idx); - sock_b.global_pipes = true; + if let Some((buf_ab_idx, buf_ba_idx)) = pipe_indices { + // Socket A sends to A→B and receives from B→A; B is the inverse. + sock_a.send_buf_idx = Some(buf_ab_idx); + sock_a.recv_buf_idx = Some(buf_ba_idx); + sock_a.global_pipes = true; + sock_b.send_buf_idx = Some(buf_ba_idx); + sock_b.recv_buf_idx = Some(buf_ab_idx); + sock_b.global_pipes = true; + } let sock_a_idx = proc.sockets.alloc(sock_a); let sock_b_idx = proc.sockets.alloc(sock_b); @@ -8441,27 +8541,18 @@ fn ipv6_v6only(sock: &crate::socket::SocketInfo) -> bool { value != 0 } -fn bind_device_allows_ipv4( - sock: &crate::socket::SocketInfo, - addr: [u8; 4], - binding: bool, -) -> bool { +fn bind_device_allows_ipv4(sock: &crate::socket::SocketInfo, addr: [u8; 4], binding: bool) -> bool { match sock.bind_device.as_deref() { None => true, Some(b"lo") => { - addr == [0; 4] - || is_loopback_addr(addr) - || (!binding && is_ipv4_multicast_addr(addr)) + addr == [0; 4] || is_loopback_addr(addr) || (!binding && is_ipv4_multicast_addr(addr)) } Some(b"eth0") => addr == [0; 4] || !is_loopback_addr(addr), Some(_) => false, } } -fn bind_device_allows_ipv6( - sock: &crate::socket::SocketInfo, - addr: [u8; 16], -) -> bool { +fn bind_device_allows_ipv6(sock: &crate::socket::SocketInfo, addr: [u8; 16]) -> bool { match sock.bind_device.as_deref() { None => true, Some(b"lo") => is_unspecified_addr6(addr) || is_loopback_addr6(addr), @@ -8626,11 +8717,7 @@ fn udp_bind_socket( if !is_supported_udp_bind_addr(addr) { return Err(Errno::EADDRNOTAVAIL); } - if !bind_device_allows_ipv4( - proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?, - addr, - true, - ) { + if !bind_device_allows_ipv4(proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?, addr, true) { return Err(Errno::EADDRNOTAVAIL); } @@ -8701,7 +8788,7 @@ fn udp_queue_datagram( fn unix_queue_datagram( sock: &mut crate::socket::SocketInfo, - make_datagram: impl FnOnce() -> crate::socket::Datagram, + make_datagram: impl FnOnce() -> Result, ) -> Result<(), Errno> { // AF_UNIX datagrams are reliable. EAGAIN enters the host's ordinary // blocking-write retry path, while O_NONBLOCK or MSG_DONTWAIT exposes it @@ -8709,7 +8796,8 @@ fn unix_queue_datagram( if sock.dgram_queue.len() >= UDP_DATAGRAM_QUEUE_LIMIT { return Err(Errno::EAGAIN); } - sock.dgram_queue.push(make_datagram()); + sock.dgram_queue.try_reserve(1).map_err(|_| Errno::ENOMEM)?; + sock.dgram_queue.push(make_datagram()?); Ok(()) } @@ -8956,10 +9044,7 @@ fn udp_send_datagram( let (loop_enabled, outgoing_interface) = { let sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; - let loop_enabled = sock - .get_option(IPPROTO_IP, IP_MULTICAST_LOOP) - .unwrap_or(1) - != 0; + let loop_enabled = sock.get_option(IPPROTO_IP, IP_MULTICAST_LOOP).unwrap_or(1) != 0; let configured = sock .get_option(IPPROTO_IP, IP_MULTICAST_IF) .unwrap_or(0) @@ -8968,9 +9053,7 @@ fn udp_send_datagram( configured } else if sock.bind_device.as_deref() == Some(b"lo") { [127, 0, 0, 1] - } else if is_loopback_addr(sock.bind_addr) - || is_virtual_network_addr(sock.bind_addr) - { + } else if is_loopback_addr(sock.bind_addr) || is_virtual_network_addr(sock.bind_addr) { sock.bind_addr } else { [0; 4] @@ -9084,20 +9167,20 @@ fn unix_dgram_send_to_sock( src_sock_idx: usize, dst_sock_idx: usize, buf: &[u8], + ancillary_fds: Vec, ) -> Result { use crate::socket::Datagram; - let shut_wr = proc - .sockets - .get(src_sock_idx) - .ok_or(Errno::EBADF)? - .shut_wr; + let shut_wr = proc.sockets.get(src_sock_idx).ok_or(Errno::EBADF)?.shut_wr; if shut_wr { return Err(Errno::EPIPE); } let (src_pid, src_uid, src_gid) = (proc.pid, proc.uid, proc.gid); - let target = proc.sockets.get_mut(dst_sock_idx).ok_or(Errno::ECONNREFUSED)?; + let target = proc + .sockets + .get_mut(dst_sock_idx) + .ok_or(Errno::ECONNREFUSED)?; if target.domain != crate::socket::SocketDomain::Unix || target.sock_type != crate::socket::SocketType::Dgram || !matches!( @@ -9116,19 +9199,28 @@ fn unix_dgram_send_to_sock( if target.shut_rd { return Err(Errno::EPIPE); } - unix_queue_datagram(target, || Datagram { - data: buf.to_vec(), - src_addr: [0; 4], - src_addr6: [0; 16], - dst_addr: [0; 4], - dst_addr6: [0; 16], - src_port: 0, - src_sock_idx: Some(src_sock_idx), - ipv6_tclass: 0, - src_pid, - src_uid, - src_gid, - ancillary_fds: Vec::new(), + unix_queue_datagram(target, || { + // Reserve queue capacity before allocating or copying a payload. A + // full reliable queue must fail with EAGAIN without constructing a + // detached data/control message. + let mut data = Vec::new(); + data.try_reserve_exact(buf.len()) + .map_err(|_| Errno::ENOMEM)?; + data.extend_from_slice(buf); + Ok(Datagram { + data, + src_addr: [0; 4], + src_addr6: [0; 16], + dst_addr: [0; 4], + dst_addr6: [0; 16], + src_port: 0, + src_sock_idx: Some(src_sock_idx), + ipv6_tclass: 0, + src_pid, + src_uid, + src_gid, + ancillary_fds, + }) })?; Ok(buf.len()) } @@ -9138,9 +9230,7 @@ fn finish_datagram_send( flags: u32, result: Result, ) -> Result { - if matches!(result, Err(Errno::EPIPE)) - && flags & wasm_posix_shared::socket::MSG_NOSIGNAL == 0 - { + if matches!(result, Err(Errno::EPIPE)) && flags & wasm_posix_shared::socket::MSG_NOSIGNAL == 0 { proc.signals.raise(wasm_posix_shared::signal::SIGPIPE); } result @@ -9201,11 +9291,7 @@ fn udp6_bind_socket( Ok(()) } -fn udp6_ensure_bound( - proc: &mut Process, - sock_idx: usize, - addr: [u8; 16], -) -> Result<(), Errno> { +fn udp6_ensure_bound(proc: &mut Process, sock_idx: usize, addr: [u8; 16]) -> Result<(), Errno> { let already_bound = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?.bind_port != 0; if already_bound { return Ok(()); @@ -9535,6 +9621,13 @@ pub fn sys_shutdown( && sock.sock_type == crate::socket::SocketType::Dgram; let was_shut_rd = sock.shut_rd; let was_shut_wr = sock.shut_wr; + if is_unix_dgram && matches!(how, SHUT_RD | SHUT_RDWR) { + // WHY: a read-shut datagram socket returns EOF without inspecting + // its receive queue. Keeping queued messages here would make their + // payload permanently unreachable and retain every SCM_RIGHTS + // backing until the socket itself is closed. + sock.dgram_queue.clear(); + } match how { SHUT_RD => { sock.shut_rd = true; @@ -9643,7 +9736,7 @@ pub fn sys_send( SocketDomain::Unix if sock.state == SocketState::Connected => { let peer_idx = sock.peer_idx; if let Some(peer_idx) = peer_idx { - let result = unix_dgram_send_to_sock(proc, sock_idx, peer_idx, buf); + let result = unix_dgram_send_to_sock(proc, sock_idx, peer_idx, buf, Vec::new()); return finish_datagram_send(proc, flags, result); } return Err(Errno::ECONNREFUSED); @@ -9795,17 +9888,16 @@ pub fn sys_recv( .ok_or(Errno::EBADF)?; if waitall { let remaining = buf.len().saturating_sub(total); - if pipe.available() < remaining && pipe.is_write_end_open() { + if pipe.available() < remaining + && pipe.is_write_end_open() + && !pipe.ancillary_barrier_within(remaining) + { return Err(Errno::EAGAIN); } } - let n = if peek { - pipe.peek(&mut buf[total..]) - } else { - pipe.read(&mut buf[total..]) - }; - total += n; - if total >= buf.len() || (total > 0 && !waitall) { + let read = pipe.recv_plain(&mut buf[total..], peek); + total += read.bytes_read; + if total >= buf.len() || read.hit_ancillary_barrier || (total > 0 && !waitall) { return Ok(total); } if !pipe.is_write_end_open() { @@ -9858,10 +9950,13 @@ pub fn sys_getsockopt(proc: &mut Process, fd: i32, level: u32, optname: u32) -> 0 }), SO_RCVBUF | SO_SNDBUF => Ok(DEFAULT_PIPE_CAPACITY as u32), - SO_REUSEADDR | SO_REUSEPORT | SO_KEEPALIVE | SO_BROADCAST | SO_PASSCRED - | SO_ATTACH_REUSEPORT_CBPF | SO_ZEROCOPY => { - Ok(sock.get_option(level, optname).unwrap_or(0)) - } + SO_REUSEADDR + | SO_REUSEPORT + | SO_KEEPALIVE + | SO_BROADCAST + | SO_PASSCRED + | SO_ATTACH_REUSEPORT_CBPF + | SO_ZEROCOPY => Ok(sock.get_option(level, optname).unwrap_or(0)), // SO_LINGER and SO_BINDTODEVICE are structured/string-valued and // handled by dedicated wasm ABI wrappers. SO_LINGER | SO_BINDTODEVICE => Err(Errno::ENOPROTOOPT), @@ -9869,23 +9964,33 @@ pub fn sys_getsockopt(proc: &mut Process, fd: i32, level: u32, optname: u32) -> _ => Err(Errno::ENOPROTOOPT), }, IPPROTO_IP => match optname { - IP_TOS | IP_PKTINFO | IP_MTU_DISCOVER | IP_MULTICAST_IF | IP_MULTICAST_TTL - | IP_MULTICAST_LOOP | IP_MULTICAST_ALL - | MCAST_JOIN_GROUP | MCAST_LEAVE_GROUP - | MCAST_BLOCK_SOURCE | MCAST_UNBLOCK_SOURCE | MCAST_JOIN_SOURCE_GROUP + IP_TOS + | IP_PKTINFO + | IP_MTU_DISCOVER + | IP_MULTICAST_IF + | IP_MULTICAST_TTL + | IP_MULTICAST_LOOP + | IP_MULTICAST_ALL + | MCAST_JOIN_GROUP + | MCAST_LEAVE_GROUP + | MCAST_BLOCK_SOURCE + | MCAST_UNBLOCK_SOURCE + | MCAST_JOIN_SOURCE_GROUP | MCAST_LEAVE_SOURCE_GROUP => { - Ok(sock.get_option(level, optname).unwrap_or_else(|| match optname { - IP_MULTICAST_TTL | IP_MULTICAST_LOOP => 1, - _ => 0, - })) + Ok(sock + .get_option(level, optname) + .unwrap_or_else(|| match optname { + IP_MULTICAST_TTL | IP_MULTICAST_LOOP => 1, + _ => 0, + })) } IP_MTU => Ok(1500), _ => Err(Errno::ENOPROTOOPT), }, IPPROTO_IPV6 => match optname { IPV6_V6ONLY => Ok(u32::from(ipv6_v6only(sock))), - IPV6_MULTICAST_IF | IPV6_MULTICAST_HOPS | IPV6_MULTICAST_LOOP - | IPV6_RECVPKTINFO | IPV6_RECVTCLASS | IPV6_DONTFRAG | IPV6_TCLASS => { + IPV6_MULTICAST_IF | IPV6_MULTICAST_HOPS | IPV6_MULTICAST_LOOP | IPV6_RECVPKTINFO + | IPV6_RECVTCLASS | IPV6_DONTFRAG | IPV6_TCLASS => { Ok(sock.get_option(level, optname).unwrap_or(0)) } _ => Err(Errno::ENOPROTOOPT), @@ -9968,7 +10073,7 @@ pub fn sys_getsockopt_tcp_info(proc: &Process, fd: i32) -> Result<[u8; TCP_INFO_ /// architecture-neutral time64 constants. pub(crate) fn canonical_socket_timeout_optname(level: u32, optname: u32) -> Option { use wasm_posix_shared::socket::{ - SOL_SOCKET, SO_RCVTIMEO, SO_RCVTIMEO_OLD, SO_SNDTIMEO, SO_SNDTIMEO_OLD, + SO_RCVTIMEO, SO_RCVTIMEO_OLD, SO_SNDTIMEO, SO_SNDTIMEO_OLD, SOL_SOCKET, }; if level != SOL_SOCKET { @@ -10178,11 +10283,18 @@ pub fn sys_setsockopt( match level { SOL_SOCKET => match optname { - SO_REUSEADDR | SO_REUSEPORT | SO_KEEPALIVE | SO_RCVBUF | SO_SNDBUF - | SO_BROADCAST | SO_PASSCRED | SO_ATTACH_REUSEPORT_CBPF | SO_ZEROCOPY => { - sock.set_option(level, optname, value); - Ok(()) - } + SO_REUSEADDR + | SO_REUSEPORT + | SO_KEEPALIVE + | SO_RCVBUF + | SO_SNDBUF + | SO_BROADCAST + | SO_PASSCRED + | SO_ATTACH_REUSEPORT_CBPF + | SO_ZEROCOPY => { + sock.set_option(level, optname, value); + Ok(()) + } SO_LINGER | SO_BINDTODEVICE => Err(Errno::ENOPROTOOPT), // SO_RCVTIMEO/SO_SNDTIMEO handled by sys_setsockopt_timeout _ => Err(Errno::ENOPROTOOPT), @@ -10207,9 +10319,15 @@ pub fn sys_setsockopt( sock.set_option(level, optname, u32::from(value != 0)); Ok(()) } - IP_TOS | IP_PKTINFO | IP_MTU_DISCOVER | IP_MULTICAST_ALL - | MCAST_JOIN_GROUP | MCAST_LEAVE_GROUP - | MCAST_BLOCK_SOURCE | MCAST_UNBLOCK_SOURCE | MCAST_JOIN_SOURCE_GROUP + IP_TOS + | IP_PKTINFO + | IP_MTU_DISCOVER + | IP_MULTICAST_ALL + | MCAST_JOIN_GROUP + | MCAST_LEAVE_GROUP + | MCAST_BLOCK_SOURCE + | MCAST_UNBLOCK_SOURCE + | MCAST_JOIN_SOURCE_GROUP | MCAST_LEAVE_SOURCE_GROUP => { sock.set_option(level, optname, value); Ok(()) @@ -10231,9 +10349,8 @@ pub fn sys_setsockopt( sock.set_option(level, optname, if value != 0 { 1 } else { 0 }); Ok(()) } - IPV6_MULTICAST_IF | IPV6_MULTICAST_HOPS | IPV6_MULTICAST_LOOP - | IPV6_PKTINFO | IPV6_RECVPKTINFO | IPV6_RECVTCLASS | IPV6_DONTFRAG - | IPV6_TCLASS => { + IPV6_MULTICAST_IF | IPV6_MULTICAST_HOPS | IPV6_MULTICAST_LOOP | IPV6_PKTINFO + | IPV6_RECVPKTINFO | IPV6_RECVTCLASS | IPV6_DONTFRAG | IPV6_TCLASS => { sock.set_option(level, optname, value); Ok(()) } @@ -10450,11 +10567,7 @@ pub fn sys_bind( // filtered through the creating process's umask. Abstract sockets // have no backing VFS inode at all. let socket_mode = 0o777 & !proc.umask; - let h = match host.host_open( - &resolved, - O_CREAT | O_EXCL | O_WRONLY, - socket_mode, - ) { + let h = match host.host_open(&resolved, O_CREAT | O_EXCL | O_WRONLY, socket_mode) { Ok(h) => h, Err(Errno::EEXIST) => return Err(Errno::EADDRINUSE), Err(e) => return Err(e), @@ -10585,8 +10698,7 @@ fn discard_accepted_socket_without_fd(proc: &mut Process, sock_idx: usize) { let Some(sock) = proc.sockets.get(sock_idx) else { return; }; - let (recv_idx, send_idx, peer_idx) = - (sock.recv_buf_idx, sock.send_buf_idx, sock.peer_idx); + let (recv_idx, send_idx, peer_idx) = (sock.recv_buf_idx, sock.send_buf_idx, sock.peer_idx); if let Some(peer_idx) = peer_idx { if let Some(peer) = proc.sockets.get_mut(peer_idx) { if peer.peer_idx == Some(sock_idx) { @@ -10984,9 +11096,7 @@ pub fn sys_connect( && s.bind_port == port && s.sock_type == SocketType::Stream && match s.domain { - SocketDomain::Inet => { - s.bind_addr == [0; 4] || s.bind_addr == ip - } + SocketDomain::Inet => s.bind_addr == [0; 4] || s.bind_addr == ip, SocketDomain::Inet6 => { is_unspecified_addr6(s.bind_addr6) && !ipv6_v6only(s) } @@ -11031,8 +11141,7 @@ pub fn sys_connect( let listener_addr = listener.bind_addr; let listener_addr6 = listener.bind_addr6; let listener_port = listener.bind_port; - let mut accepted_sock = - SocketInfo::new(listener_domain, SocketType::Stream, 0); + let mut accepted_sock = SocketInfo::new(listener_domain, SocketType::Stream, 0); accepted_sock.state = SocketState::Connected; accepted_sock.recv_buf_idx = Some(pipe_a_idx); // reads from pipe_a (client's writes) accepted_sock.send_buf_idx = Some(pipe_b_idx); // writes to pipe_b (client's reads) @@ -11165,8 +11274,8 @@ pub fn sys_connect( let owner_pid = proc.pid; let send_state_changed = { let sock = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; - let association_changed = sock.state != SocketState::Connected - || sock.peer_idx != Some(peer_idx); + let association_changed = + sock.state != SocketState::Connected || sock.peer_idx != Some(peer_idx); sock.peer_idx = Some(peer_idx); sock.state = SocketState::Connected; let queued_before = sock.dgram_queue.len(); @@ -11267,8 +11376,7 @@ pub fn sys_connect( } else { // Defensive compatibility for manually restored listener state // without a shared queue. - let mut accepted_sock = - SocketInfo::new(SocketDomain::Unix, SocketType::Stream, 0); + let mut accepted_sock = SocketInfo::new(SocketDomain::Unix, SocketType::Stream, 0); accepted_sock.state = SocketState::Connected; accepted_sock.recv_buf_idx = Some(pipe_a_idx); accepted_sock.send_buf_idx = Some(pipe_b_idx); @@ -11311,13 +11419,57 @@ pub fn sys_getaddrinfo( if result_buf.len() < 4 { return Err(Errno::EINVAL); } - host.host_getaddrinfo(name, result_buf) + let written = host.host_getaddrinfo(name, result_buf)?; + // WHY: a host-reported producer count is not proof that those bytes fit + // the kernel-owned destination it was lent. + if written > result_buf.len() { + return Err(Errno::EIO); + } + Ok(written) } /// Send a message on a socket to a specific address. /// /// For AF_INET DGRAM sockets with a loopback destination, finds the target /// bound DGRAM socket and pushes the datagram to its queue. +fn resolve_unix_datagram_destination( + proc: &mut Process, + host: &mut dyn HostIO, + addr: &[u8], +) -> Result { + if addr.len() < 3 { + return Err(Errno::EINVAL); + } + let path_bytes = &addr[2..]; + let resolved = if path_bytes.first().copied() == Some(0) { + if path_bytes.len() < 2 { + return Err(Errno::EINVAL); + } + path_bytes.to_vec() + } else { + let path_end = path_bytes + .iter() + .position(|&b| b == 0) + .unwrap_or(path_bytes.len()); + if path_end == 0 { + return Err(Errno::EINVAL); + } + resolve_namespace_path( + proc, + host, + &path_bytes[..path_end], + PathResolveOptions::FOLLOW, + )? + .path + }; + let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; + let peer = registry.lookup(&resolved).ok_or(Errno::ECONNREFUSED)?; + if peer.pid != proc.pid { + return Err(Errno::ECONNREFUSED); + } + Ok(peer.sock_idx) +} + pub fn sys_sendto( proc: &mut Process, host: &mut dyn HostIO, @@ -11357,54 +11509,47 @@ pub fn sys_sendto( udp6_send_datagram(proc, sock_idx, buf, dst_ip, dst_port) } SocketDomain::Unix => { - if addr.len() < 3 { - return Err(Errno::EINVAL); - } - let path_bytes = &addr[2..]; - let resolved = if path_bytes.first().copied() == Some(0) { - if path_bytes.len() < 2 { - return Err(Errno::EINVAL); - } - path_bytes.to_vec() - } else { - let path_end = path_bytes - .iter() - .position(|&b| b == 0) - .unwrap_or(path_bytes.len()); - if path_end == 0 { - return Err(Errno::EINVAL); - } - resolve_namespace_path( - proc, - host, - &path_bytes[..path_end], - PathResolveOptions::FOLLOW, - )? - .path - }; - let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; - let peer = registry.lookup(&resolved).ok_or(Errno::ECONNREFUSED)?; - if peer.pid != proc.pid { - return Err(Errno::ECONNREFUSED); - } - let peer_idx = peer.sock_idx; - unix_dgram_send_to_sock(proc, sock_idx, peer_idx, buf) + let peer_idx = resolve_unix_datagram_destination(proc, host, addr)?; + unix_dgram_send_to_sock(proc, sock_idx, peer_idx, buf, Vec::new()) } }; finish_datagram_send(proc, flags, result) } -/// Receive a message from a socket with sender address. +/// Data and control metadata produced by one message-aware receive. +pub(crate) struct MessageReceive { + pub return_len: usize, + pub addr_len: usize, + pub output_flags: u32, + pub ancillary_fds: Vec, +} + +fn try_clone_ancillary_fds( + fds: &[crate::pipe::InFlightFd], +) -> Result, Errno> { + let mut cloned = Vec::new(); + cloned + .try_reserve_exact(fds.len()) + .map_err(|_| Errno::ENOMEM)?; + for fd in fds { + cloned.push(fd.try_clone_retained()?); + } + Ok(cloned) +} + +/// Receive one atomic datagram, optionally transferring its control message. /// -/// For AF_INET DGRAM sockets, dequeues a datagram and writes the sender address. -pub fn sys_recvfrom( +/// A peek copies data and fallibly retains independent descriptor references +/// before changing any state. A consuming non-recvmsg path drops the original +/// descriptors with their carrier instead of leaving them for a later call. +fn recv_datagram_message( proc: &mut Process, - _host: &mut dyn HostIO, fd: i32, buf: &mut [u8], flags: u32, addr_buf: &mut [u8], -) -> Result<(usize, usize), Errno> { + receive_ancillary: bool, +) -> Result { use crate::socket::{SocketDomain, SocketState, SocketType}; use wasm_posix_shared::socket::{MSG_PEEK, MSG_TRUNC}; @@ -11416,10 +11561,8 @@ pub fn sys_recvfrom( let sock_idx = (-(ofd.host_handle + 1)) as usize; let sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; - // For STREAM sockets, delegate to sys_recv (musl routes recv→recvfrom) if sock.sock_type == SocketType::Stream { - let n = sys_recv(proc, _host, fd, buf, flags)?; - return Ok((n, 0)); + return Err(Errno::EOPNOTSUPP); } if !matches!( sock.domain, @@ -11428,7 +11571,12 @@ pub fn sys_recvfrom( return Err(Errno::EOPNOTSUPP); } if sock.shut_rd { - return Ok((0, 0)); + return Ok(MessageReceive { + return_len: 0, + addr_len: 0, + output_flags: 0, + ancillary_fds: Vec::new(), + }); } let pid = proc.pid; @@ -11448,35 +11596,61 @@ pub fn sys_recvfrom( let datagram_idx = datagram_idx.unwrap(); let peek = flags & MSG_PEEK != 0; let was_full = sock.dgram_queue.len() >= UDP_DATAGRAM_QUEUE_LIMIT; - let datagram = if peek { - sock.dgram_queue[datagram_idx].clone() + let domain = sock.domain; + + let (full_len, src_addr, src_addr6, src_port, ancillary_fds) = if peek { + let datagram = &sock.dgram_queue[datagram_idx]; + // Clone ownership first so allocation/retain failure cannot leave + // caller-visible data in the scratch result of a failed peek. + let ancillary_fds = if receive_ancillary { + try_clone_ancillary_fds(&datagram.ancillary_fds)? + } else { + Vec::new() + }; + let copy_len = buf.len().min(datagram.data.len()); + buf[..copy_len].copy_from_slice(&datagram.data[..copy_len]); + ( + datagram.data.len(), + datagram.src_addr, + datagram.src_addr6, + datagram.src_port, + ancillary_fds, + ) } else { - sock.dgram_queue.remove(datagram_idx) + let mut datagram = sock.dgram_queue.remove(datagram_idx); + let copy_len = buf.len().min(datagram.data.len()); + buf[..copy_len].copy_from_slice(&datagram.data[..copy_len]); + let ancillary_fds = if receive_ancillary { + core::mem::take(&mut datagram.ancillary_fds) + } else { + Vec::new() + }; + ( + datagram.data.len(), + datagram.src_addr, + datagram.src_addr6, + datagram.src_port, + ancillary_fds, + ) }; - if !peek && was_full && sock.domain == SocketDomain::Unix { + if !peek && was_full && domain == SocketDomain::Unix { crate::wakeup::push_datagram_writable(); } - // Copy data to buffer - let copy_len = buf.len().min(datagram.data.len()); - buf[..copy_len].copy_from_slice(&datagram.data[..copy_len]); - // Linux exposes the complete message length for Internet and Unix - // datagrams when MSG_TRUNC is requested, while still copying only what - // fits and discarding the unread tail of a non-peeked message. - let received_len = if flags & MSG_TRUNC != 0 { - datagram.data.len() + let copy_len = buf.len().min(full_len); + let return_len = if flags & MSG_TRUNC != 0 { + full_len } else { copy_len }; + let output_flags = if full_len > buf.len() { MSG_TRUNC } else { 0 }; - // Write sender sockaddr to addr_buf - let mut addr_written = 0; - if !addr_buf.is_empty() { - addr_written = match sock.domain { - SocketDomain::Inet => write_sockaddr_in(addr_buf, datagram.src_addr, datagram.src_port), - SocketDomain::Inet6 => { - write_sockaddr_in6(addr_buf, datagram.src_addr6, datagram.src_port) - } + let addr_len = if addr_buf.is_empty() { + 0 + } else { + match domain { + SocketDomain::Inet => write_sockaddr_in(addr_buf, src_addr, src_port), + SocketDomain::Inet6 => write_sockaddr_in6(addr_buf, src_addr6, src_port), SocketDomain::Unix => { if addr_buf.len() >= 2 { addr_buf[0] = 1; @@ -11484,10 +11658,44 @@ pub fn sys_recvfrom( } 2 } - }; + } + }; + + Ok(MessageReceive { + return_len, + addr_len, + output_flags, + ancillary_fds, + }) +} + +/// Receive a message from a socket with sender address. +/// +/// For AF_INET DGRAM sockets, dequeues a datagram and writes the sender address. +pub fn sys_recvfrom( + proc: &mut Process, + host: &mut dyn HostIO, + fd: i32, + buf: &mut [u8], + flags: u32, + addr_buf: &mut [u8], +) -> Result<(usize, usize), Errno> { + use crate::socket::SocketType; + + let entry = proc.fd_table.get(fd)?; + let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + if ofd.file_type != FileType::Socket { + return Err(Errno::ENOTSOCK); + } + let sock_idx = (-(ofd.host_handle + 1)) as usize; + let socket_type = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?.sock_type; + if socket_type == SocketType::Stream { + let n = sys_recv(proc, host, fd, buf, flags)?; + return Ok((n, 0)); } - Ok((received_len, addr_written)) + let received = recv_datagram_message(proc, fd, buf, flags, addr_buf, false)?; + Ok((received.return_len, received.addr_len)) } /// Poll file descriptors for I/O readiness. @@ -11680,9 +11888,7 @@ fn poll_check(proc: &mut Process, host: &mut dyn HostIO, fds: &mut [WasmPollFd]) O_WRONLY => { if !pipe.has_readers() { revents |= POLLERR; - } else if pollfd.events & POLLOUT != 0 - && pipe.free_space() > 0 - { + } else if pollfd.events & POLLOUT != 0 && pipe.free_space() > 0 { revents |= POLLOUT; } } @@ -11750,8 +11956,7 @@ fn poll_check(proc: &mut Process, host: &mut dyn HostIO, fds: &mut [WasmPollFd]) { revents |= POLLIN; } - let unix_peer_queue_full = sock.domain - == crate::socket::SocketDomain::Unix + let unix_peer_queue_full = sock.domain == crate::socket::SocketDomain::Unix && sock.state == SocketState::Connected && sock .peer_idx @@ -11767,10 +11972,7 @@ fn poll_check(proc: &mut Process, host: &mut dyn HostIO, fds: &mut [WasmPollFd]) && unix_dgram_target_accepts_sender(peer, sock_idx) && peer.dgram_queue.len() >= UDP_DATAGRAM_QUEUE_LIMIT }); - if pollfd.events & POLLOUT != 0 - && !sock.shut_wr - && !unix_peer_queue_full - { + if pollfd.events & POLLOUT != 0 && !sock.shut_wr && !unix_peer_queue_full { revents |= POLLOUT; } } @@ -12061,9 +12263,7 @@ pub fn sys_openat( } // Devfs (/dev, /dev/pts, etc.) — in-kernel directory listing - if !is_host_backed_devfs_path(&resolved) - && crate::devfs::match_devfs_dir(&resolved).is_some() - { + if !is_host_backed_devfs_path(&resolved) && crate::devfs::match_devfs_dir(&resolved).is_some() { return crate::devfs::devfs_open_dir(proc, resolved, oflags); } @@ -12079,12 +12279,9 @@ pub fn sys_openat( let status_flags = oflags & !CREATION_FLAGS; let object_id = synthetic_file_object_id(&resolved).ok_or(Errno::EINVAL)?; let host_handle = crate::descriptor_backing::alloc_synthetic_regular(); - let ofd_idx = proc.ofd_table.create( - FileType::Regular, - status_flags, - host_handle, - resolved, - ); + let ofd_idx = proc + .ofd_table + .create(FileType::Regular, status_flags, host_handle, resolved); proc.ofd_table.get_mut(ofd_idx).unwrap().file_id = Some(FileId::Kernel { kind: KernelFileKind::SyntheticRegular, object_id, @@ -12217,12 +12414,7 @@ pub fn sys_fstatat( if let Some(st) = synthetic_file_stat(&resolved, proc.euid, proc.egid) { return Ok(st); } - if let Some(st) = fifo_path_stat( - proc, - host, - &resolved, - flags & AT_SYMLINK_NOFOLLOW == 0, - )? { + if let Some(st) = fifo_path_stat(proc, host, &resolved, flags & AT_SYMLINK_NOFOLLOW == 0)? { return Ok(st); } if let Some(st) = @@ -12252,8 +12444,7 @@ pub fn sys_unlinkat( ) -> Result<(), Errno> { use wasm_posix_shared::flags::AT_REMOVEDIR; - let resolved = - resolve_at_path(proc, host, dirfd, path, PathResolveOptions::NOFOLLOW)?.path; + let resolved = resolve_at_path(proc, host, dirfd, path, PathResolveOptions::NOFOLLOW)?.path; ensure_host_mutable_namespace_path(&resolved)?; check_parent_writable(proc, host, &resolved)?; if flags & AT_REMOVEDIR != 0 { @@ -12288,8 +12479,14 @@ pub fn sys_mkdirat( path: &[u8], mode: u32, ) -> Result<(), Errno> { - let resolved = - resolve_at_path(proc, host, dirfd, path, PathResolveOptions::CREATE_DIRECTORY)?.path; + let resolved = resolve_at_path( + proc, + host, + dirfd, + path, + PathResolveOptions::CREATE_DIRECTORY, + )? + .path; ensure_host_mutable_namespace_path(&resolved)?; let effective_mode = mode & !proc.umask; check_parent_writable(proc, host, &resolved)?; @@ -12306,8 +12503,7 @@ pub fn sys_renameat( newdirfd: i32, newpath: &[u8], ) -> Result<(), Errno> { - let old_entry = - resolve_at_path(proc, host, olddirfd, oldpath, PathResolveOptions::NOFOLLOW)?; + let old_entry = resolve_at_path(proc, host, olddirfd, oldpath, PathResolveOptions::NOFOLLOW)?; let new_options = if old_entry .stat .is_some_and(|stat| stat.st_mode & S_IFMT == S_IFDIR) @@ -12326,8 +12522,7 @@ pub fn sys_renameat( if host.host_lstat(&new_resolved).is_ok() { check_sticky_child(proc, host, &new_resolved)?; } - let displaced_ctime = - refresh_displaced_fifo_before_rename(host, &old_resolved, &new_resolved)?; + let displaced_ctime = refresh_displaced_fifo_before_rename(host, &old_resolved, &new_resolved)?; host.host_rename(&old_resolved, &new_resolved)?; rekey_fifo_names_after_rename(&old_resolved, &new_resolved, displaced_ctime); let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; @@ -12338,7 +12533,8 @@ pub fn sys_renameat( } /// tcgetattr -- get terminal attributes (custom syscall 70). -/// Uses kernel's 48-byte format for backward compat: 4×u32 flags + c_cc[32]. +/// +/// The custom syscall uses the same exact 60-byte musl layout as TCGETS. pub fn sys_tcgetattr(proc: &mut Process, fd: i32, buf: &mut [u8]) -> Result<(), Errno> { let entry = proc.fd_table.get(fd)?; let ofd_idx = entry.ofd_ref.0; @@ -12349,7 +12545,7 @@ pub fn sys_tcgetattr(proc: &mut Process, fd: i32, buf: &mut [u8]) -> Result<(), ) { return Err(Errno::ENOTTY); } - if buf.len() < 48 { + if buf.len() != crate::terminal::TERMIOS_SIZE { return Err(Errno::EINVAL); } let ts = match ofd.file_type { @@ -12362,16 +12558,13 @@ pub fn sys_tcgetattr(proc: &mut Process, fd: i32, buf: &mut [u8]) -> Result<(), }; // Safety: we hold &mut proc, and PTY table is kernel-global with single-threaded access let ts = unsafe { &*ts }; - buf[0..4].copy_from_slice(&ts.c_iflag.to_le_bytes()); - buf[4..8].copy_from_slice(&ts.c_oflag.to_le_bytes()); - buf[8..12].copy_from_slice(&ts.c_cflag.to_le_bytes()); - buf[12..16].copy_from_slice(&ts.c_lflag.to_le_bytes()); - buf[16..48].copy_from_slice(&ts.c_cc); + ts.write_termios(buf); Ok(()) } /// tcsetattr -- set terminal attributes (custom syscall 71). -/// Uses kernel's 48-byte format for backward compat: 4×u32 flags + c_cc[32]. +/// +/// The custom syscall uses the same exact 60-byte musl layout as TCSETS. pub fn sys_tcsetattr(proc: &mut Process, fd: i32, action: u32, buf: &[u8]) -> Result<(), Errno> { let entry = proc.fd_table.get(fd)?; let ofd_idx = entry.ofd_ref.0; @@ -12382,14 +12575,12 @@ pub fn sys_tcsetattr(proc: &mut Process, fd: i32, action: u32, buf: &[u8]) -> Re ) { return Err(Errno::ENOTTY); } - if buf.len() < 48 { + if buf.len() != crate::terminal::TERMIOS_SIZE { return Err(Errno::EINVAL); } if !matches!( action, - crate::terminal::TCSANOW - | crate::terminal::TCSADRAIN - | crate::terminal::TCSAFLUSH + crate::terminal::TCSANOW | crate::terminal::TCSADRAIN | crate::terminal::TCSAFLUSH ) { return Err(Errno::EINVAL); } @@ -12400,20 +12591,12 @@ pub fn sys_tcsetattr(proc: &mut Process, fd: i32, action: u32, buf: &[u8]) -> Re let pty_idx = ofd.host_handle as usize; let pty = crate::pty::get_pty(pty_idx).ok_or(Errno::EIO)?; pty.prepare_termios_change(next_lflag, discard_input); - pty.terminal.c_iflag = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); - pty.terminal.c_oflag = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]); - pty.terminal.c_cflag = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]); - pty.terminal.c_lflag = next_lflag; - pty.terminal.c_cc.copy_from_slice(&buf[16..48]); + pty.terminal.read_termios(buf); } _ => { proc.terminal .prepare_termios_change(next_lflag, discard_input); - proc.terminal.c_iflag = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); - proc.terminal.c_oflag = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]); - proc.terminal.c_cflag = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]); - proc.terminal.c_lflag = next_lflag; - proc.terminal.c_cc.copy_from_slice(&buf[16..48]); + proc.terminal.read_termios(buf); } } Ok(()) @@ -12911,30 +13094,30 @@ pub fn sys_ioctl( } /// prctl — process control operations. -/// PR_SET_NAME (15) stores thread name, PR_GET_NAME (16) returns it. +/// PR_SET_NAME stores thread name, PR_GET_NAME returns it. /// All other operations are no-ops returning success. pub fn sys_prctl(proc: &mut Process, option: u32, _arg2: u32, buf: &mut [u8]) -> Result<(), Errno> { - const PR_SET_NAME: u32 = 15; - const PR_GET_NAME: u32 = 16; + use wasm_posix_shared::{kernel_scratch_wire, prctl}; match option { - PR_SET_NAME => { + prctl::PR_SET_NAME => { // arg2 is a pointer to the name string in our buffer - // The name comes in via buf (up to 16 bytes, null-terminated) + // The name comes in via the fixed-size, null-terminated buffer. let name_len = buf .iter() .position(|&b| b == 0) .unwrap_or(buf.len()) - .min(15); - proc.thread_name = [0u8; 16]; + .min(kernel_scratch_wire::PRCTL_NAME_BYTES as usize - 1); + proc.thread_name = [0u8; kernel_scratch_wire::PRCTL_NAME_BYTES as usize]; proc.thread_name[..name_len].copy_from_slice(&buf[..name_len]); Ok(()) } - PR_GET_NAME => { - if buf.len() < 16 { + prctl::PR_GET_NAME => { + if buf.len() < kernel_scratch_wire::PRCTL_NAME_BYTES as usize { return Err(Errno::EINVAL); } - buf[..16].copy_from_slice(&proc.thread_name); + buf[..kernel_scratch_wire::PRCTL_NAME_BYTES as usize] + .copy_from_slice(&proc.thread_name); Ok(()) } _ => Ok(()), // no-op for unrecognized operations @@ -13776,7 +13959,8 @@ pub fn sys_uname(buf: &mut [u8]) -> Result<(), Errno> { /// sysconf — get configurable system variables pub fn sys_sysconf(name: i32) -> Result { match name { - 0 => Ok(4 * 1024 * 1024), // _SC_ARG_MAX: host exec argv+env aggregate cap + // _SC_ARG_MAX: host exec argv+env aggregate cap. + 0 => Ok(wasm_posix_shared::platform_limits::ARG_MAX_BYTES as i64), 1 => Ok(0), // _SC_CHILD_MAX (unspecified) 2 => Ok(100), // _SC_CLK_TCK 4 => Ok(1024), // _SC_OPEN_MAX @@ -13934,8 +14118,7 @@ pub fn sys_fpathconf( let path = ofd.path.clone(); if name == pc::ASYNC_IO - && (file_type == FileType::MemFd - || (file_type == FileType::Regular && host_handle < 0)) + && (file_type == FileType::MemFd || (file_type == FileType::Regular && host_handle < 0)) { return Ok(Some(1)); } @@ -13967,7 +14150,10 @@ pub fn sys_fpathconf( FileType::MemFd => filesystem_pathconf_value(name, false, None), FileType::Regular | FileType::Directory | FileType::CharDevice => { if file_type == FileType::CharDevice - && matches!(path.as_slice(), b"/dev/stdin" | b"/dev/stdout" | b"/dev/stderr") + && matches!( + path.as_slice(), + b"/dev/stdin" | b"/dev/stdout" | b"/dev/stderr" + ) { terminal_pathconf_value(name) } else if is_procfs_namespace_path(&path) @@ -14392,8 +14578,7 @@ pub fn sys_fchmodat( mode: u32, _flags: u32, ) -> Result<(), Errno> { - let resolved = - resolve_at_path(proc, host, dirfd, path, PathResolveOptions::FOLLOW)?.path; + let resolved = resolve_at_path(proc, host, dirfd, path, PathResolveOptions::FOLLOW)?.path; ensure_host_mutable_namespace_path(&resolved)?; check_search_path(proc, host, &resolved)?; let st = host.host_stat(&resolved)?; @@ -14451,8 +14636,14 @@ pub fn sys_linkat( PathResolveOptions::NOFOLLOW }; let old_resolved = resolve_at_path(proc, host, olddirfd, oldpath, old_options)?.path; - let new_resolved = - resolve_at_path(proc, host, newdirfd, newpath, PathResolveOptions::CREATE_ENTRY)?.path; + let new_resolved = resolve_at_path( + proc, + host, + newdirfd, + newpath, + PathResolveOptions::CREATE_ENTRY, + )? + .path; ensure_host_mutable_namespace_path(&old_resolved)?; ensure_host_mutable_namespace_path(&new_resolved)?; check_search_path(proc, host, &old_resolved)?; @@ -14472,8 +14663,14 @@ pub fn sys_symlinkat( newdirfd: i32, linkpath: &[u8], ) -> Result<(), Errno> { - let resolved_link = - resolve_at_path(proc, host, newdirfd, linkpath, PathResolveOptions::CREATE_ENTRY)?.path; + let resolved_link = resolve_at_path( + proc, + host, + newdirfd, + linkpath, + PathResolveOptions::CREATE_ENTRY, + )? + .path; ensure_host_mutable_namespace_path(&resolved_link)?; check_parent_writable(proc, host, &resolved_link)?; host.host_symlink(target, &resolved_link) @@ -14487,8 +14684,7 @@ pub fn sys_readlinkat( path: &[u8], buf: &mut [u8], ) -> Result { - let resolved = - resolve_at_path(proc, host, dirfd, path, PathResolveOptions::NOFOLLOW)?.path; + let resolved = resolve_at_path(proc, host, dirfd, path, PathResolveOptions::NOFOLLOW)?.path; // Procfs symlinks — /proc/self, /proc/self/fd/N, /proc/self/cwd, etc. if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { @@ -14506,7 +14702,8 @@ pub fn sys_readlinkat( /// select -- synchronous I/O multiplexing. /// /// Wraps poll() by converting fd_set bitmasks into pollfd entries. -/// Each fd_set is FD_SETSIZE/8 = 128 bytes. Null sets are allowed. +/// Each fd_set has the shared generated FD_SET_BYTES width. Null sets are +/// allowed. pub fn sys_select( proc: &mut Process, host: &mut dyn HostIO, @@ -14517,8 +14714,9 @@ pub fn sys_select( timeout_ms: i32, ) -> Result { use wasm_posix_shared::poll::{POLLERR, POLLHUP, POLLIN, POLLOUT, POLLPRI}; + use wasm_posix_shared::select::{FD_SET_BYTES, FD_SETSIZE}; - if nfds < 0 || nfds > 1024 { + if nfds < 0 || nfds as usize > FD_SETSIZE { return Err(Errno::EINVAL); } @@ -14529,10 +14727,9 @@ pub fn sys_select( let mut ready = 0i32; // Save input fd_sets before clearing (we need to know which fds were requested) - // Use a fixed-size buffer: nfds <= 1024, so max 128 bytes per set. - let mut in_read_buf = [0u8; 128]; - let mut in_write_buf = [0u8; 128]; - let mut in_except_buf = [0u8; 128]; + let mut in_read_buf = [0u8; FD_SET_BYTES]; + let mut in_write_buf = [0u8; FD_SET_BYTES]; + let mut in_except_buf = [0u8; FD_SET_BYTES]; let set_bytes = nfds.div_ceil(8); if let Some(ref s) = readfds { @@ -14839,8 +15036,7 @@ fn virtual_statfs_for_path(resolved: &[u8], pid: u32) -> Option { { return Some(procfs_statfs()); } - if (!is_host_backed_devfs_path(resolved) - && crate::devfs::match_devfs_dir(resolved).is_some()) + if (!is_host_backed_devfs_path(resolved) && crate::devfs::match_devfs_dir(resolved).is_some()) || match_virtual_device(resolved).is_some() || resolved == b"/dev/ptmx" || resolved == b"/dev/tty" @@ -14967,26 +15163,219 @@ pub fn sys_setgroups(proc: &mut Process, _size: u32) -> Result<(), Errno> { Ok(()) } -/// sendmsg — send a message on a socket (minimal: extracts iov[0] and delegates to send). -pub fn sys_sendmsg( +/// Send one message and its SCM_RIGHTS descriptors as a single owned +/// transaction. +/// +/// Descriptor metadata enters non-owning. This function validates the complete +/// descriptor batch and carrier, then retains every resource before atomically +/// publishing bytes and ownership to the destination. +pub(crate) fn sys_sendmsg( proc: &mut Process, host: &mut dyn HostIO, fd: i32, iov_base: &[u8], flags: u32, + addr: Option<&[u8]>, + mut ancillary_fds: Vec, ) -> Result { - sys_send(proc, host, fd, iov_base, flags) + use crate::socket::{SocketDomain, SocketState, SocketType}; + use wasm_posix_shared::socket::{MSG_NOSIGNAL, MSG_OOB}; + + if ancillary_fds.is_empty() { + return if let Some(addr) = addr { + sys_sendto(proc, host, fd, iov_base, flags, addr) + } else { + sys_send(proc, host, fd, iov_base, flags) + }; + } + + // WHY: this is the ownership acquisition boundary. Validate the complete + // batch before the first retain so one unrepresentable descriptor cannot + // leave an earlier descriptor held or publish carrier bytes without all + // requested rights. + ancillary_fds + .iter() + .try_for_each(validate_scm_rights_in_flight_fd)?; + + let entry = proc.fd_table.get(fd)?; + let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + if ofd.file_type != FileType::Socket { + return Err(Errno::ENOTSOCK); + } + let sock_idx = (-(ofd.host_handle + 1)) as usize; + let (domain, socket_type, state, shut_wr, send_buf_idx, peer_idx) = { + let socket = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; + ( + socket.domain, + socket.sock_type, + socket.state, + socket.shut_wr, + socket.send_buf_idx, + socket.peer_idx, + ) + }; + + // SCM_RIGHTS is a Unix-domain control contract. Rejecting other families + // before publishing payload bytes prevents a successful data send whose + // requested descriptors were silently discarded. + if domain != SocketDomain::Unix { + return Err(Errno::EINVAL); + } + ancillary_fds + .iter_mut() + .try_for_each(crate::pipe::InFlightFd::retain_reference)?; + + match socket_type { + SocketType::Dgram => { + let peer_idx = if let Some(addr) = addr { + resolve_unix_datagram_destination(proc, host, addr)? + } else { + if state != SocketState::Connected { + return Err(Errno::EDESTADDRREQ); + } + peer_idx.ok_or(Errno::ECONNREFUSED)? + }; + let result = unix_dgram_send_to_sock(proc, sock_idx, peer_idx, iov_base, ancillary_fds); + finish_datagram_send(proc, flags, result) + } + SocketType::Stream => { + if addr.is_some() { + return Err(Errno::EOPNOTSUPP); + } + if state != SocketState::Connected { + return Err(Errno::ENOTCONN); + } + if shut_wr { + if flags & MSG_NOSIGNAL == 0 { + proc.signals.raise(wasm_posix_shared::signal::SIGPIPE); + } + return Err(Errno::EPIPE); + } + // A zero-byte Unix stream send validates its control records but + // has no carrier byte. Linux reports success without queuing the + // descriptors, while datagrams above can carry zero-byte messages. + if iov_base.is_empty() { + return Ok(0); + } + if flags & MSG_OOB != 0 { + return Err(Errno::EINVAL); + } + let send_buf_idx = send_buf_idx.ok_or(Errno::ENOTCONN)?; + let pipes = unsafe { crate::pipe::global_pipe_table() }; + if !pipes + .get(send_buf_idx) + .ok_or(Errno::EBADF)? + .is_read_end_open() + { + if flags & MSG_NOSIGNAL == 0 { + proc.signals.raise(wasm_posix_shared::signal::SIGPIPE); + } + return Err(Errno::EPIPE); + } + let written = pipes.write_retained_ancillary(send_buf_idx, iov_base, ancillary_fds)?; + if written == 0 { + Err(Errno::EAGAIN) + } else { + Ok(written) + } + } + } } -/// recvmsg — receive a message from a socket (minimal: extracts iov[0] and delegates to recv). -pub fn sys_recvmsg( +/// Receive one stream segment or datagram together with its descriptor batch. +pub(crate) fn sys_recvmsg( proc: &mut Process, host: &mut dyn HostIO, fd: i32, iov_buf: &mut [u8], flags: u32, -) -> Result { - sys_recv(proc, host, fd, iov_buf, flags) + addr_buf: &mut [u8], +) -> Result { + use crate::socket::{SocketDomain, SocketState, SocketType}; + use wasm_posix_shared::socket::{MSG_CMSG_CLOEXEC, MSG_OOB, MSG_PEEK}; + const MSG_WAITALL: u32 = 0x100; + + let entry = proc.fd_table.get(fd)?; + let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + if ofd.file_type != FileType::Socket { + return Err(Errno::ENOTSOCK); + } + let sock_idx = (-(ofd.host_handle + 1)) as usize; + let (domain, socket_type, state, shut_rd, recv_buf_idx) = { + let socket = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; + ( + socket.domain, + socket.sock_type, + socket.state, + socket.shut_rd, + socket.recv_buf_idx, + ) + }; + let reflected_flags = flags & MSG_CMSG_CLOEXEC; + + if socket_type == SocketType::Dgram { + let mut received = recv_datagram_message(proc, fd, iov_buf, flags, addr_buf, true)?; + received.output_flags |= reflected_flags; + return Ok(received); + } + + if domain != SocketDomain::Unix || flags & MSG_OOB != 0 { + let received = sys_recv(proc, host, fd, iov_buf, flags & !MSG_CMSG_CLOEXEC)?; + return Ok(MessageReceive { + return_len: received, + addr_len: 0, + output_flags: reflected_flags, + ancillary_fds: Vec::new(), + }); + } + if state != SocketState::Connected { + return Err(Errno::ENOTCONN); + } + if shut_rd { + return Ok(MessageReceive { + return_len: 0, + addr_len: 0, + output_flags: reflected_flags, + ancillary_fds: Vec::new(), + }); + } + + let recv_buf_idx = recv_buf_idx.ok_or(Errno::ENOTCONN)?; + let peek = flags & MSG_PEEK != 0; + let waitall = flags & MSG_WAITALL != 0 && !peek; + let mut total = 0usize; + loop { + let pipe = unsafe { crate::pipe::global_pipe_table().get_mut(recv_buf_idx) } + .ok_or(Errno::EBADF)?; + if waitall { + let remaining = iov_buf.len().saturating_sub(total); + if pipe.available() < remaining + && pipe.is_write_end_open() + && !pipe.ancillary_barrier_within(remaining) + { + return Err(Errno::EAGAIN); + } + } + let received = pipe.recv_message(&mut iov_buf[total..], peek)?; + total += received.bytes_read; + if received.hit_ancillary_barrier || total >= iov_buf.len() || (total > 0 && !waitall) { + return Ok(MessageReceive { + return_len: total, + addr_len: 0, + output_flags: reflected_flags, + ancillary_fds: received.ancillary_fds.unwrap_or_default(), + }); + } + if !pipe.is_write_end_open() { + return Ok(MessageReceive { + return_len: total, + addr_len: 0, + output_flags: reflected_flags, + ancillary_fds: Vec::new(), + }); + } + return Err(Errno::EAGAIN); + } } /// wait4 — wait for a child process. Delegates to host. @@ -14999,38 +15388,39 @@ pub fn sys_waitpid( host.host_waitpid(pid, options) } -/// sysinfo — return system information. -/// -/// Fills a struct sysinfo buffer with plausible values. -/// On wasm32, unsigned long is 4 bytes. Layout: -/// uptime(4) loads[3](12) totalram(4) freeram(4) sharedram(4) -/// bufferram(4) totalswap(4) freeswap(4) procs(2) pad(2) -/// totalhigh(4) freehigh(4) mem_unit(4) __reserved(256) -/// Total: 312 bytes -pub fn sys_sysinfo(buf: &mut [u8]) -> Result<(), Errno> { - const SYSINFO_SIZE: usize = 312; - if buf.len() < SYSINFO_SIZE { - return Err(Errno::EFAULT); +/// Width-independent system information serialized at the Wasm boundary. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct KernelSysinfo { + pub uptime: u64, + pub loads: [u64; 3], + pub totalram: u64, + pub freeram: u64, + pub sharedram: u64, + pub bufferram: u64, + pub totalswap: u64, + pub freeswap: u64, + pub procs: u16, + pub totalhigh: u64, + pub freehigh: u64, + pub mem_unit: u32, +} + +/// sysinfo — return width-independent system information. +pub fn sys_sysinfo() -> KernelSysinfo { + KernelSysinfo { + uptime: 1, + loads: [0; 3], + totalram: 512 * 1024 * 1024, + freeram: 256 * 1024 * 1024, + sharedram: 0, + bufferram: 0, + totalswap: 0, + freeswap: 0, + procs: 1, + totalhigh: 0, + freehigh: 0, + mem_unit: 1, } - // Zero the buffer first - for b in buf[..SYSINFO_SIZE].iter_mut() { - *b = 0; - } - let total_ram: u32 = 512 * 1024 * 1024; // 512 MB - let free_ram: u32 = 256 * 1024 * 1024; // 256 MB - - // uptime = 1 second - buf[0..4].copy_from_slice(&1u32.to_le_bytes()); - // loads[0..3] = 0 (already zeroed) - // totalram @ offset 16 - buf[16..20].copy_from_slice(&total_ram.to_le_bytes()); - // freeram @ offset 20 - buf[20..24].copy_from_slice(&free_ram.to_le_bytes()); - // procs @ offset 40 (u16) - buf[40..42].copy_from_slice(&1u16.to_le_bytes()); - // mem_unit @ offset 52 - buf[52..56].copy_from_slice(&1u32.to_le_bytes()); - Ok(()) } /// memfd_create — create an anonymous file backed by in-memory storage. @@ -15168,30 +15558,20 @@ mod tests { fn read_slave(&mut self, len: usize) -> Result, Errno> { let mut bytes = vec![0; len]; - let n = sys_read( - &mut self.proc, - &mut self.host, - self.slave_fd, - &mut bytes, - )?; + let n = sys_read(&mut self.proc, &mut self.host, self.slave_fd, &mut bytes)?; bytes.truncate(n); Ok(bytes) } fn read_master(&mut self, len: usize) -> Result, Errno> { let mut bytes = vec![0; len]; - let n = sys_read( - &mut self.proc, - &mut self.host, - self.master_fd, - &mut bytes, - )?; + let n = sys_read(&mut self.proc, &mut self.host, self.master_fd, &mut bytes)?; bytes.truncate(n); Ok(bytes) } - fn custom_attrs(&mut self, fd: i32) -> [u8; 48] { - let mut attrs = [0; 48]; + fn custom_attrs(&mut self, fd: i32) -> [u8; crate::terminal::TERMIOS_SIZE] { + let mut attrs = [0; crate::terminal::TERMIOS_SIZE]; sys_tcgetattr(&mut self.proc, fd, &mut attrs).unwrap(); attrs } @@ -15284,8 +15664,8 @@ mod tests { #[test] fn socket_timeout_options_accept_time64_and_long64_numbers() { use wasm_posix_shared::socket::{ - AF_INET, IPPROTO_TCP, SOCK_STREAM, SOL_SOCKET, SO_RCVTIMEO, - SO_RCVTIMEO_OLD, SO_SNDTIMEO, SO_SNDTIMEO_OLD, + AF_INET, IPPROTO_TCP, SO_RCVTIMEO, SO_RCVTIMEO_OLD, SO_SNDTIMEO, SO_SNDTIMEO_OLD, + SOCK_STREAM, SOL_SOCKET, }; assert_eq!( @@ -15313,10 +15693,8 @@ mod tests { let mut proc = Process::new(9037); let mut host = MockHostIO::new(); let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); - let recv_opt = - canonical_socket_timeout_optname(SOL_SOCKET, SO_RCVTIMEO_OLD).unwrap(); - let send_opt = - canonical_socket_timeout_optname(SOL_SOCKET, SO_SNDTIMEO_OLD).unwrap(); + let recv_opt = canonical_socket_timeout_optname(SOL_SOCKET, SO_RCVTIMEO_OLD).unwrap(); + let send_opt = canonical_socket_timeout_optname(SOL_SOCKET, SO_SNDTIMEO_OLD).unwrap(); sys_setsockopt_timeout(&mut proc, fd, recv_opt, 1_250_000).unwrap(); sys_setsockopt_timeout(&mut proc, fd, send_opt, 2_500_000).unwrap(); @@ -15461,6 +15839,7 @@ mod tests { gbm_bo_unbind_calls: Vec<(i32, u32, usize, usize)>, /// Recorded pid for every `gl_unbind` call. gl_unbind_calls: Vec, + proc_write_calls: Vec<(i32, u32, Vec)>, /// Override for `gbm_bo_bind`'s return value (0 = success, negative /// = errno). Defaults to 0. gbm_bo_bind_rc: i32, @@ -15472,6 +15851,8 @@ mod tests { net_send_result: Result, net_connect_calls: Vec<(i32, Vec, u16)>, net_listen_calls: Vec<(i32, u16, [u8; 4])>, + getaddrinfo_bytes: Option>, + getaddrinfo_reported: usize, chown_calls: Vec<(Vec, u32, u32)>, lchown_calls: Vec<(Vec, u32, u32)>, fchown_calls: Vec<(i64, u32, u32)>, @@ -15519,6 +15900,7 @@ mod tests { gbm_bo_bind_calls: Vec::new(), gbm_bo_unbind_calls: Vec::new(), gl_unbind_calls: Vec::new(), + proc_write_calls: Vec::new(), gbm_bo_bind_rc: 0, gl_submit_rc: 0, net_connect_result: Err(Errno::ECONNREFUSED), @@ -15526,6 +15908,8 @@ mod tests { net_send_result: Err(Errno::ENOTCONN), net_connect_calls: Vec::new(), net_listen_calls: Vec::new(), + getaddrinfo_bytes: None, + getaddrinfo_reported: 0, chown_calls: Vec::new(), lchown_calls: Vec::new(), fchown_calls: Vec::new(), @@ -15576,8 +15960,7 @@ mod tests { fn set_symlink(&mut self, path: &[u8], target: &[u8]) { self.missing_paths.remove(path); self.file_modes.insert(path.to_vec(), S_IFLNK | 0o777); - self.symlink_targets - .insert(path.to_vec(), target.to_vec()); + self.symlink_targets.insert(path.to_vec(), target.to_vec()); } fn set_statfs(&mut self, path: &[u8], statfs: WasmStatfs) { @@ -15689,11 +16072,8 @@ mod tests { .copied() .unwrap_or_else(|| test_default_mode(path)); let (uid, gid) = self.file_owners.get(path).copied().unwrap_or((0, 0)); - let (atime_sec, atime_nsec, mtime_sec, mtime_nsec) = self - .file_times - .get(path) - .copied() - .unwrap_or((0, 0, 0, 0)); + let (atime_sec, atime_nsec, mtime_sec, mtime_nsec) = + self.file_times.get(path).copied().unwrap_or((0, 0, 0, 0)); Ok(WasmStat { st_dev: 0, st_ino: 1, @@ -15725,11 +16105,8 @@ mod tests { }; let mode = self.file_modes.get(path).copied().unwrap_or(mode); let (uid, gid) = self.file_owners.get(path).copied().unwrap_or((0, 0)); - let (atime_sec, atime_nsec, mtime_sec, mtime_nsec) = self - .file_times - .get(path) - .copied() - .unwrap_or((0, 0, 0, 0)); + let (atime_sec, atime_nsec, mtime_sec, mtime_nsec) = + self.file_times.get(path).copied().unwrap_or((0, 0, 0, 0)); Ok(WasmStat { st_dev: 0, st_ino: 2, @@ -15764,11 +16141,7 @@ mod tests { self.pathconf_result } - fn host_fpathconf( - &mut self, - handle: i64, - name: i32, - ) -> Result, Errno> { + fn host_fpathconf(&mut self, handle: i64, name: i32) -> Result, Errno> { self.fpathconf_calls.push((handle, name)); self.fpathconf_result } @@ -15957,12 +16330,7 @@ mod tests { .as_ref() .and_then(|names| names.get(idx)) .map(Vec::as_slice) - .unwrap_or_else(|| { - default_names - .get(idx) - .copied() - .unwrap_or(b"test.txt") - }); + .unwrap_or_else(|| default_names.get(idx).copied().unwrap_or(b"test.txt")); let n = name_buf.len().min(name.len()); name_buf[..n].copy_from_slice(&name[..n]); Ok(Some(((42 + idx as u64), 8, n))) // d_ino varies, d_type=DT_REG=8 @@ -16091,21 +16459,14 @@ mod tests { }; let atime = normalize(current.0, current.1, atime_sec, atime_nsec)?; let mtime = normalize(current.2, current.3, mtime_sec, mtime_nsec)?; - self.file_times.insert( - path.to_vec(), - (atime.0, atime.1, mtime.0, mtime.1), - ); + self.file_times + .insert(path.to_vec(), (atime.0, atime.1, mtime.0, mtime.1)); Ok(()) } fn host_waitpid(&mut self, _pid: i32, _options: u32) -> Result<(i32, i32), Errno> { Err(Errno::ECHILD) } - fn host_net_connect( - &mut self, - handle: i32, - addr: &[u8], - port: u16, - ) -> Result<(), Errno> { + fn host_net_connect(&mut self, handle: i32, addr: &[u8], port: u16) -> Result<(), Errno> { self.net_connect_calls.push((handle, addr.to_vec(), port)); self.net_connect_result } @@ -16136,8 +16497,13 @@ mod tests { self.net_listen_calls.push((fd, port, *addr)); Ok(()) } - fn host_getaddrinfo(&mut self, _name: &[u8], _result: &mut [u8]) -> Result { - Err(Errno::ENOENT) + fn host_getaddrinfo(&mut self, _name: &[u8], result: &mut [u8]) -> Result { + let Some(bytes) = self.getaddrinfo_bytes.as_deref() else { + return Err(Errno::ENOENT); + }; + let copied = bytes.len().min(result.len()); + result[..copied].copy_from_slice(&bytes[..copied]); + Ok(self.getaddrinfo_reported) } fn host_futex_wait( &mut self, @@ -16192,6 +16558,10 @@ mod tests { fn gl_submit(&mut self, _pid: i32, _offset: usize, _length: usize) -> i32 { self.gl_submit_rc } + fn proc_write_bytes(&mut self, pid: i32, ptr: u32, bytes: &[u8]) -> i32 { + self.proc_write_calls.push((pid, ptr, bytes.to_vec())); + 0 + } } fn user_process(pid: u32) -> Process { @@ -16510,7 +16880,10 @@ mod tests { Err(Errno::EINVAL) ); let ofd = proc.ofd_table.get(dir_ofd).unwrap(); - assert_eq!((ofd.offset, ofd.dir_synth_state, ofd.dir_entry_offset), (4, 2, 4)); + assert_eq!( + (ofd.offset, ofd.dir_synth_state, ofd.dir_entry_offset), + (4, 2, 4) + ); let (proc_fd, proc_ofd) = install_fd( &mut proc, @@ -16531,10 +16904,7 @@ mod tests { VirtualDevice::Fb0.host_handle(), b"/dev/fb0", ); - assert_eq!( - sys_lseek(&mut proc, &mut host, fb_fd, 7, SEEK_SET), - Ok(7) - ); + assert_eq!(sys_lseek(&mut proc, &mut host, fb_fd, 7, SEEK_SET), Ok(7)); assert_eq!( sys_lseek(&mut proc, &mut host, fb_fd, i64::MAX, SEEK_CUR), Err(Errno::EOVERFLOW) @@ -16542,24 +16912,14 @@ mod tests { assert_eq!(proc.ofd_table.get(fb_ofd).unwrap().offset, 7); let synthetic_handle = crate::descriptor_backing::alloc_synthetic_regular(); - let (synthetic_fd, synthetic_ofd) = install_fd( - &mut proc, - FileType::Regular, - synthetic_handle, - b"/etc/mtab", - ); + let (synthetic_fd, synthetic_ofd) = + install_fd(&mut proc, FileType::Regular, synthetic_handle, b"/etc/mtab"); assert_eq!( sys_lseek(&mut proc, &mut host, synthetic_fd, 2, SEEK_SET), Ok(2) ); assert_eq!( - sys_lseek( - &mut proc, - &mut host, - synthetic_fd, - i64::MAX, - SEEK_CUR, - ), + sys_lseek(&mut proc, &mut host, synthetic_fd, i64::MAX, SEEK_CUR,), Err(Errno::EOVERFLOW) ); assert_eq!( @@ -16574,18 +16934,12 @@ mod tests { let memfd = sys_memfd_create(&mut proc, b"seek-overflow", 0).unwrap(); sys_write(&mut proc, &mut host, memfd, b"abcdef").unwrap(); - assert_eq!( - sys_lseek(&mut proc, &mut host, memfd, 2, SEEK_SET), - Ok(2) - ); + assert_eq!(sys_lseek(&mut proc, &mut host, memfd, 2, SEEK_SET), Ok(2)); assert_eq!( sys_lseek(&mut proc, &mut host, memfd, i64::MAX, SEEK_END), Err(Errno::EOVERFLOW) ); - assert_eq!( - sys_lseek(&mut proc, &mut host, memfd, 0, SEEK_CUR), - Ok(2) - ); + assert_eq!(sys_lseek(&mut proc, &mut host, memfd, 0, SEEK_CUR), Ok(2)); } #[test] @@ -16695,11 +17049,20 @@ mod tests { ); assert_eq!(sys_pwritev(proc, host, fd, &[b"x"], 0), Err(Errno::EBADF)); assert_eq!(sys_writev(proc, host, fd, &[b"x"]), Err(Errno::EBADF)); - assert_eq!(sys_getdents64(proc, host, fd, &mut [0u8; 64]), Err(Errno::EBADF)); + assert_eq!( + sys_getdents64(proc, host, fd, &mut [0u8; 64]), + Err(Errno::EBADF) + ); let peer_fd = sys_open(proc, host, b"/tmp/path-only-io-peer", O_RDWR, 0).unwrap(); - assert_eq!(sys_sendfile(proc, host, peer_fd, fd, 0, 1), Err(Errno::EBADF)); - assert_eq!(sys_sendfile(proc, host, fd, peer_fd, -1, 1), Err(Errno::EBADF)); + assert_eq!( + sys_sendfile(proc, host, peer_fd, fd, 0, 1), + Err(Errno::EBADF) + ); + assert_eq!( + sys_sendfile(proc, host, fd, peer_fd, -1, 1), + Err(Errno::EBADF) + ); assert_eq!( sys_copy_file_range(proc, host, fd, Some(0), peer_fd, Some(0), 1), Err(Errno::EBADF), @@ -16770,7 +17133,11 @@ mod tests { let rfd = sys_open(&mut proc, &mut host, fifo, O_RDONLY | O_NONBLOCK, 0).unwrap(); let mut buf = [0u8; 8]; assert_eq!(sys_read(&mut proc, &mut host, rfd, &mut buf), Ok(0)); - let mut pollfd = [WasmPollFd { fd: rfd, events: POLLIN, revents: 0 }]; + let mut pollfd = [WasmPollFd { + fd: rfd, + events: POLLIN, + revents: 0, + }]; assert_eq!(sys_poll(&mut proc, &mut host, &mut pollfd, 0), Ok(0)); assert_eq!(pollfd[0].revents & POLLHUP, 0); @@ -16809,12 +17176,22 @@ mod tests { S_IFIFO, ); assert_path_only_io_rejected(&mut proc, &mut host, fd); - let mut pollfd = [WasmPollFd { fd, events: POLLIN, revents: 0 }]; + let mut pollfd = [WasmPollFd { + fd, + events: POLLIN, + revents: 0, + }]; assert_eq!(sys_poll(&mut proc, &mut host, &mut pollfd, 0), Ok(1)); assert_eq!(pollfd[0].revents, POLLNVAL); let times = [ - WasmTimespec { tv_sec: 1, tv_nsec: 2 }, - WasmTimespec { tv_sec: 3, tv_nsec: 4 }, + WasmTimespec { + tv_sec: 1, + tv_nsec: 2, + }, + WasmTimespec { + tv_sec: 3, + tv_nsec: 4, + }, ]; assert_eq!( sys_fchmod(&mut proc, &mut host, fd, 0o600), @@ -16854,8 +17231,14 @@ mod tests { let mut host = MockHostIO::new(); let fd = sys_open(&mut proc, &mut host, b"/tmp/path-only", O_PATH, 0).unwrap(); let times = [ - WasmTimespec { tv_sec: 1, tv_nsec: 2 }, - WasmTimespec { tv_sec: 3, tv_nsec: 4 }, + WasmTimespec { + tv_sec: 1, + tv_nsec: 2, + }, + WasmTimespec { + tv_sec: 3, + tv_nsec: 4, + }, ]; assert!(sys_fstat(&mut proc, &mut host, fd).is_ok()); @@ -16892,14 +17275,23 @@ mod tests { proc.euid = 1000; proc.egid = 1000; host.clock_time = (1_500_000_010, 123_456_789); - assert_eq!(sys_utimensat(&mut proc, &mut host, fd, b"", None, 0), Ok(())); + assert_eq!( + sys_utimensat(&mut proc, &mut host, fd, b"", None, 0), + Ok(()) + ); let updated = sys_fstat(&mut proc, &mut host, fd).unwrap(); assert_eq!(updated.st_atime_sec, 1_500_000_010); assert_eq!(updated.st_mtime_sec, 1_500_000_010); let explicit = [ - WasmTimespec { tv_sec: 11, tv_nsec: 12 }, - WasmTimespec { tv_sec: 21, tv_nsec: 22 }, + WasmTimespec { + tv_sec: 11, + tv_nsec: 12, + }, + WasmTimespec { + tv_sec: 21, + tv_nsec: 22, + }, ]; assert_eq!( sys_utimensat(&mut proc, &mut host, fd, b"", Some(&explicit), 0), @@ -16907,8 +17299,14 @@ mod tests { ); let omitted = [ - WasmTimespec { tv_sec: -1, tv_nsec: UTIME_OMIT }, - WasmTimespec { tv_sec: -1, tv_nsec: UTIME_OMIT }, + WasmTimespec { + tv_sec: -1, + tv_nsec: UTIME_OMIT, + }, + WasmTimespec { + tv_sec: -1, + tv_nsec: UTIME_OMIT, + }, ]; assert_eq!( sys_utimensat(&mut proc, &mut host, fd, b"", Some(&omitted), 0), @@ -16999,13 +17397,7 @@ mod tests { Err(Errno::EMFILE), ); assert_eq!( - sys_open( - &mut writer, - &mut host, - fifo, - O_WRONLY | O_NONBLOCK, - 0, - ), + sys_open(&mut writer, &mut host, fifo, O_WRONLY | O_NONBLOCK, 0,), Err(Errno::ENXIO), ); @@ -17015,8 +17407,7 @@ mod tests { Err(Errno::EAGAIN), ); set_test_current_tid(81_099); - let other_fd = - sys_open(&mut reader, &mut host, b"/tmp/other", O_RDONLY, 0).unwrap(); + let other_fd = sys_open(&mut reader, &mut host, b"/tmp/other", O_RDONLY, 0).unwrap(); assert_eq!(other_fd, 4); set_test_current_tid(0); @@ -17053,8 +17444,14 @@ mod tests { sys_fchmod(&mut proc, &mut host, fd, 0o620).unwrap(); sys_fchown(&mut proc, &mut host, fd, 123, 456).unwrap(); let first_times = [ - WasmTimespec { tv_sec: 11, tv_nsec: 12 }, - WasmTimespec { tv_sec: 21, tv_nsec: 22 }, + WasmTimespec { + tv_sec: 11, + tv_nsec: 12, + }, + WasmTimespec { + tv_sec: 21, + tv_nsec: 22, + }, ]; sys_utimensat(&mut proc, &mut host, fd, b"", Some(&first_times), 0).unwrap(); sys_rename(&mut proc, &mut host, old, new).unwrap(); @@ -17082,8 +17479,14 @@ mod tests { assert_eq!(chowned.st_ctime_nsec, 444_555_666); let second_times = [ - WasmTimespec { tv_sec: 31, tv_nsec: 32 }, - WasmTimespec { tv_sec: 41, tv_nsec: 42 }, + WasmTimespec { + tv_sec: 31, + tv_nsec: 32, + }, + WasmTimespec { + tv_sec: 41, + tv_nsec: 42, + }, ]; sys_utimensat(&mut proc, &mut host, fd, b"", Some(&second_times), 0).unwrap(); let unlinked = sys_fstat(&mut proc, &mut host, fd).unwrap(); @@ -17117,25 +17520,12 @@ mod tests { .unwrap(); sys_close(&mut proc, &mut host, regular_fd).unwrap(); create_test_fifo(&mut proc, &mut host, fifo_destination, 0o600); - let displaced_fd = sys_open( - &mut proc, - &mut host, - fifo_destination, - O_RDWR, - 0, - ) - .unwrap(); + let displaced_fd = sys_open(&mut proc, &mut host, fifo_destination, O_RDWR, 0).unwrap(); sys_chmod(&mut proc, &mut host, fifo_destination, 0o612).unwrap(); host.clock_time = (1_600_000_001, 123_456_789); let clock_calls = host.clock_gettime_calls; - sys_rename( - &mut proc, - &mut host, - regular_source, - fifo_destination, - ) - .unwrap(); + sys_rename(&mut proc, &mut host, regular_source, fifo_destination).unwrap(); let displaced = sys_fstat(&mut proc, &mut host, displaced_fd).unwrap(); assert_eq!(host.clock_gettime_calls, clock_calls + 1); assert_eq!(displaced.st_mode & 0o7777, 0o612); @@ -17148,14 +17538,7 @@ mod tests { let fifo_source = b"/tmp/fifo_source"; create_test_fifo(&mut proc, &mut host, fifo_source, 0o600); create_test_fifo(&mut proc, &mut host, fifo_destination, 0o600); - let displaced_fd = sys_open( - &mut proc, - &mut host, - fifo_destination, - O_RDWR, - 0, - ) - .unwrap(); + let displaced_fd = sys_open(&mut proc, &mut host, fifo_destination, O_RDWR, 0).unwrap(); sys_chmod(&mut proc, &mut host, fifo_destination, 0o624).unwrap(); host.clock_time = (1_600_000_002, 234_567_890); @@ -17183,14 +17566,7 @@ mod tests { .unwrap(); sys_close(&mut proc, &mut host, regular_fd).unwrap(); create_test_fifo(&mut proc, &mut host, fifo_destination_at, 0o600); - let displaced_fd = sys_open( - &mut proc, - &mut host, - fifo_destination_at, - O_RDWR, - 0, - ) - .unwrap(); + let displaced_fd = sys_open(&mut proc, &mut host, fifo_destination_at, O_RDWR, 0).unwrap(); sys_chmod(&mut proc, &mut host, fifo_destination_at, 0o642).unwrap(); host.clock_time = (1_600_000_003, 345_678_901); @@ -17344,7 +17720,10 @@ mod tests { create_test_fifo(&mut proc, &mut host, original, 0o640); host.set_missing_path(alias); sys_link(&mut proc, &mut host, original, alias).unwrap(); - assert_eq!(sys_stat(&mut proc, &mut host, original).unwrap().st_nlink, 2); + assert_eq!( + sys_stat(&mut proc, &mut host, original).unwrap().st_nlink, + 2 + ); assert_eq!(sys_stat(&mut proc, &mut host, alias).unwrap().st_nlink, 2); sys_unlink(&mut proc, &mut host, original).unwrap(); @@ -17352,14 +17731,7 @@ mod tests { create_test_fifo(&mut proc, &mut host, original, 0o600); let old_fd = sys_open(&mut proc, &mut host, alias, O_RDWR | O_NONBLOCK, 0).unwrap(); - let new_fd = sys_open( - &mut proc, - &mut host, - original, - O_RDWR | O_NONBLOCK, - 0, - ) - .unwrap(); + let new_fd = sys_open(&mut proc, &mut host, original, O_RDWR | O_NONBLOCK, 0).unwrap(); assert_eq!(sys_write(&mut proc, &mut host, old_fd, b"old"), Ok(3)); let mut buf = [0u8; 3]; assert_eq!( @@ -17399,28 +17771,18 @@ mod tests { Ok(1), ); assert_eq!(&name_buf[..8], b"test.txt"); - assert_eq!( - u32::from_le_bytes(dirent_buf[8..12].try_into().unwrap()), - 1, - ); + assert_eq!(u32::from_le_bytes(dirent_buf[8..12].try_into().unwrap()), 1,); sys_closedir(&mut proc, &mut host, dir).unwrap(); host.dir_entry_index = 0; - let fd = sys_open( - &mut proc, - &mut host, - b"/tmp", - O_RDONLY | O_DIRECTORY, - 0, - ) - .unwrap(); + let fd = sys_open(&mut proc, &mut host, b"/tmp", O_RDONLY | O_DIRECTORY, 0).unwrap(); let mut entries = [0u8; 512]; let len = sys_getdents64(&mut proc, &mut host, fd, &mut entries).unwrap(); let mut pos = 0usize; let mut found_fifo = false; while pos < len { - let reclen = u16::from_le_bytes(entries[pos + 16..pos + 18].try_into().unwrap()) - as usize; + let reclen = + u16::from_le_bytes(entries[pos + 16..pos + 18].try_into().unwrap()) as usize; assert!(reclen >= 20 && pos + reclen <= len); let name_end = entries[pos + 19..pos + reclen] .iter() @@ -17448,7 +17810,10 @@ mod tests { let record_len = u16::from_le_bytes(buf[pos + 16..pos + 18].try_into().unwrap()) as usize; assert!(record_len >= 24 && record_len % 8 == 0); - assert!(pos + record_len <= len, "dirent extends beyond returned bytes"); + assert!( + pos + record_len <= len, + "dirent extends beyond returned bytes" + ); let name_bytes = &buf[pos + 19..pos + record_len]; let nul = name_bytes .iter() @@ -17466,22 +17831,21 @@ mod tests { let mut proc = Process::new(81_071); let mut host = MockHostIO::new(); host.dir_entry_count = 0; - let fd = sys_open( - &mut proc, - &mut host, - b"/tmp", - O_RDONLY | O_DIRECTORY, - 0, - ) - .unwrap(); + let fd = sys_open(&mut proc, &mut host, b"/tmp", O_RDONLY | O_DIRECTORY, 0).unwrap(); let mut one_record = [0u8; 24]; let first = sys_getdents64(&mut proc, &mut host, fd, &mut one_record).unwrap(); assert_eq!(parse_linux_dirents64(&one_record, first)[0].2, b"."); let second = sys_getdents64(&mut proc, &mut host, fd, &mut one_record).unwrap(); assert_eq!(parse_linux_dirents64(&one_record, second)[0].2, b".."); - assert_eq!(sys_getdents64(&mut proc, &mut host, fd, &mut one_record), Ok(0)); - assert_eq!(sys_getdents64(&mut proc, &mut host, fd, &mut one_record), Ok(0)); + assert_eq!( + sys_getdents64(&mut proc, &mut host, fd, &mut one_record), + Ok(0) + ); + assert_eq!( + sys_getdents64(&mut proc, &mut host, fd, &mut one_record), + Ok(0) + ); } #[test] @@ -17490,14 +17854,7 @@ mod tests { let mut proc = Process::new(81_072); let mut host = MockHostIO::new(); host.dir_entry_count = HOST_ENTRY_COUNT; - let fd = sys_open( - &mut proc, - &mut host, - b"/tmp", - O_RDONLY | O_DIRECTORY, - 0, - ) - .unwrap(); + let fd = sys_open(&mut proc, &mut host, b"/tmp", O_RDONLY | O_DIRECTORY, 0).unwrap(); let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; // Exactly two 24-byte synthetic records fit. Fetching the first @@ -17532,12 +17889,13 @@ mod tests { proc.fd_table.get(fd).unwrap().ofd_ref, ); sys_close(&mut proc, &mut host, fd).unwrap(); - assert!(proc - .ofd_table - .get(ofd_idx) - .unwrap() - .dir_pending_entry - .is_some()); + assert!( + proc.ofd_table + .get(ofd_idx) + .unwrap() + .dir_pending_entry + .is_some() + ); // One byte less than the pending record needs returns EINVAL without // advancing either the host cursor or the guest-visible d_off cookie. @@ -17555,8 +17913,7 @@ mod tests { let mut seen_inodes = Vec::new(); let mut one_entry = [0u8; 32]; loop { - let len = - sys_getdents64(&mut proc, &mut host, duplicate_fd, &mut one_entry).unwrap(); + let len = sys_getdents64(&mut proc, &mut host, duplicate_fd, &mut one_entry).unwrap(); if len == 0 { break; } @@ -17583,8 +17940,7 @@ mod tests { let mut repeated = [0u8; 64]; let mut rewound_inodes = Vec::new(); loop { - let len = - sys_getdents64(&mut proc, &mut host, duplicate_fd, &mut repeated).unwrap(); + let len = sys_getdents64(&mut proc, &mut host, duplicate_fd, &mut repeated).unwrap(); if len == 0 { break; } @@ -17604,31 +17960,31 @@ mod tests { Ok(0), ); let mut exact_prefix = [0u8; 80]; - let len = - sys_getdents64(&mut proc, &mut host, duplicate_fd, &mut exact_prefix).unwrap(); + let len = sys_getdents64(&mut proc, &mut host, duplicate_fd, &mut exact_prefix).unwrap(); let entries = parse_linux_dirents64(&exact_prefix, len); assert_eq!(entries.len(), 3); assert_eq!(entries[2].0, 42); let cookie = entries[2].1; assert_eq!(cookie, 3); - assert!(proc - .ofd_table - .get(ofd_idx) - .unwrap() - .dir_pending_entry - .is_some()); + assert!( + proc.ofd_table + .get(ofd_idx) + .unwrap() + .dir_pending_entry + .is_some() + ); assert_eq!( sys_lseek(&mut proc, &mut host, duplicate_fd, cookie, SEEK_SET), Ok(cookie), ); - assert!(proc - .ofd_table - .get(ofd_idx) - .unwrap() - .dir_pending_entry - .is_none()); - let len = - sys_getdents64(&mut proc, &mut host, duplicate_fd, &mut one_entry).unwrap(); + assert!( + proc.ofd_table + .get(ofd_idx) + .unwrap() + .dir_pending_entry + .is_none() + ); + let len = sys_getdents64(&mut proc, &mut host, duplicate_fd, &mut one_entry).unwrap(); assert_eq!(parse_linux_dirents64(&one_entry, len)[0].0, 43); // Final close drops the staged allocation with the OFD. Reusing the @@ -17641,23 +17997,17 @@ mod tests { sys_getdents64(&mut proc, &mut host, duplicate_fd, &mut prefix), Ok(prefix.len()), ); - assert!(proc - .ofd_table - .get(ofd_idx) - .unwrap() - .dir_pending_entry - .is_some()); + assert!( + proc.ofd_table + .get(ofd_idx) + .unwrap() + .dir_pending_entry + .is_some() + ); sys_close(&mut proc, &mut host, duplicate_fd).unwrap(); assert!(proc.ofd_table.get(ofd_idx).is_none()); - let reopened = sys_open( - &mut proc, - &mut host, - b"/tmp", - O_RDONLY | O_DIRECTORY, - 0, - ) - .unwrap(); + let reopened = sys_open(&mut proc, &mut host, b"/tmp", O_RDONLY | O_DIRECTORY, 0).unwrap(); let reopened_ofd_idx = proc.fd_table.get(reopened).unwrap().ofd_ref.0; assert_eq!(reopened_ofd_idx, ofd_idx); let reopened_ofd = proc.ofd_table.get(reopened_ofd_idx).unwrap(); @@ -17672,14 +18022,7 @@ mod tests { let _guard = FIFO_REGISTRY_LOCK.lock().unwrap(); let mut proc = Process::new(81_073); let mut host = MockHostIO::new(); - let fd = sys_open( - &mut proc, - &mut host, - b"/tmp", - O_RDONLY | O_DIRECTORY, - 0, - ) - .unwrap(); + let fd = sys_open(&mut proc, &mut host, b"/tmp", O_RDONLY | O_DIRECTORY, 0).unwrap(); // The exact-fit prefix makes getdents64 read "test.txt" from the // host, classify it as DT_REG, and stage it for the next call. @@ -17976,7 +18319,10 @@ mod tests { let mut one = [0u8; 32]; for cookie in 0..=expected_names.len() as i64 { - assert_eq!(sys_lseek(&mut proc, &mut host, fd, cookie, SEEK_SET), Ok(cookie)); + assert_eq!( + sys_lseek(&mut proc, &mut host, fd, cookie, SEEK_SET), + Ok(cookie) + ); let len = sys_getdents64(&mut proc, &mut host, fd, &mut one).unwrap(); if let Some(expected_name) = expected_names.get(cookie as usize) { let records = parse_linux_dirents64(&one, len); @@ -18005,7 +18351,12 @@ mod tests { assert_eq!(collect_root(&host_names, 512), expected); assert_root_seeks( &host_names, - &[b".".as_slice(), b"..".as_slice(), b"dev".as_slice(), b"proc".as_slice()], + &[ + b".".as_slice(), + b"..".as_slice(), + b"dev".as_slice(), + b"proc".as_slice(), + ], ); } @@ -18115,9 +18466,11 @@ mod tests { sys_truncate(&mut proc, &mut host, fifo, 0), Err(Errno::EINVAL), ); - assert!(unsafe { crate::pipe::global_pipe_table() } - .find_fifo_open(fifo_open_owner(&proc)) - .is_none()); + assert!( + unsafe { crate::pipe::global_pipe_table() } + .find_fifo_open(fifo_open_owner(&proc)) + .is_none() + ); sys_unlink(&mut proc, &mut host, fifo).unwrap(); } @@ -18283,10 +18636,7 @@ mod tests { ); proc.euid = 0; - assert_eq!( - prepare_chown_ids(&proc, &st, 5000, 6000), - Ok((5000, 6000)), - ); + assert_eq!(prepare_chown_ids(&proc, &st, 5000, 6000), Ok((5000, 6000)),); } #[test] @@ -18299,40 +18649,14 @@ mod tests { let mut host = MockHostIO::new(); host.set_file_with_owner(b"/owned", 1000, 4000, 0o755, b"hi"); - sys_chown( - &mut proc, - &mut host, - b"/owned", - CHOWN_ID_UNCHANGED, - 1000, - ) - .unwrap(); - sys_chown( - &mut proc, - &mut host, - b"/owned", - 1000, - 2000, - ) - .unwrap(); + sys_chown(&mut proc, &mut host, b"/owned", CHOWN_ID_UNCHANGED, 1000).unwrap(); + sys_chown(&mut proc, &mut host, b"/owned", 1000, 2000).unwrap(); assert_eq!( - sys_chown( - &mut proc, - &mut host, - b"/owned", - CHOWN_ID_UNCHANGED, - 5000, - ), + sys_chown(&mut proc, &mut host, b"/owned", CHOWN_ID_UNCHANGED, 5000,), Err(Errno::EPERM), ); assert_eq!( - sys_chown( - &mut proc, - &mut host, - b"/owned", - 1001, - CHOWN_ID_UNCHANGED, - ), + sys_chown(&mut proc, &mut host, b"/owned", 1001, CHOWN_ID_UNCHANGED,), Err(Errno::EPERM), ); assert_eq!( @@ -18350,22 +18674,8 @@ mod tests { let mut host = MockHostIO::new(); host.set_file_with_owner(b"/owned", 1000, 2000, 0o644, b"hi"); - sys_chown( - &mut proc, - &mut host, - b"/owned", - CHOWN_ID_UNCHANGED, - 3000, - ) - .unwrap(); - sys_chown( - &mut proc, - &mut host, - b"/owned", - 4000, - CHOWN_ID_UNCHANGED, - ) - .unwrap(); + sys_chown(&mut proc, &mut host, b"/owned", CHOWN_ID_UNCHANGED, 3000).unwrap(); + sys_chown(&mut proc, &mut host, b"/owned", 4000, CHOWN_ID_UNCHANGED).unwrap(); // An explicit request for the current IDs is not the (-1, -1) // sentinel case: it must still reach the backend for ctime, set-ID, // and filesystem error semantics. @@ -18405,10 +18715,7 @@ mod tests { CHOWN_ID_UNCHANGED, ) .unwrap(); - assert_eq!( - host.chown_calls, - vec![(b"/owned".to_vec(), 1000, 2000)], - ); + assert_eq!(host.chown_calls, vec![(b"/owned".to_vec(), 1000, 2000)],); proc.euid = 2000; assert_eq!( @@ -18426,13 +18733,7 @@ mod tests { Err(Errno::EPERM), ); assert_eq!( - sys_chown( - &mut proc, - &mut host, - b"/owned", - CHOWN_ID_UNCHANGED, - 2000, - ), + sys_chown(&mut proc, &mut host, b"/owned", CHOWN_ID_UNCHANGED, 2000,), Err(Errno::EPERM), ); @@ -18463,10 +18764,7 @@ mod tests { let target = sys_stat(&mut proc, &mut host, b"/link").unwrap(); assert_eq!((link.st_uid, link.st_gid), (50, 60)); assert_eq!((target.st_uid, target.st_gid), (10, 20)); - assert_eq!( - host.lchown_calls, - vec![(b"/link".to_vec(), 50, 60)], - ); + assert_eq!(host.lchown_calls, vec![(b"/link".to_vec(), 50, 60)],); assert!(host.chown_calls.is_empty()); } @@ -18483,10 +18781,7 @@ mod tests { sys_chown(&mut proc, &mut host, b"/dangling", 1, 2), Err(Errno::ENOENT), ); - assert_eq!( - host.lchown_calls, - vec![(b"/dangling".to_vec(), 90, 100)], - ); + assert_eq!(host.lchown_calls, vec![(b"/dangling".to_vec(), 90, 100)],); } #[test] @@ -18495,8 +18790,7 @@ mod tests { proc.euid = 123; let mut host = MockHostIO::new(); host.set_symlink(b"/owned-link", b"/target"); - host.file_owners - .insert(b"/owned-link".to_vec(), (123, 456)); + host.file_owners.insert(b"/owned-link".to_vec(), (123, 456)); host.set_file_with_owner(b"/target", 999, 888, 0o644, b"target"); sys_lchown( @@ -18507,10 +18801,7 @@ mod tests { CHOWN_ID_UNCHANGED, ) .unwrap(); - assert_eq!( - host.lchown_calls, - vec![(b"/owned-link".to_vec(), 123, 456)], - ); + assert_eq!(host.lchown_calls, vec![(b"/owned-link".to_vec(), 123, 456)],); assert_eq!( sys_lchown( &mut proc, @@ -18535,29 +18826,16 @@ mod tests { host.file_owners.insert(b"/link".to_vec(), (1000, 4000)); host.set_file_with_owner(b"/target", 9999, 9999, 0o755, b"target"); - sys_lchown( - &mut proc, - &mut host, - b"/link", - CHOWN_ID_UNCHANGED, - 2000, - ) - .unwrap(); + sys_lchown(&mut proc, &mut host, b"/link", CHOWN_ID_UNCHANGED, 2000).unwrap(); assert_eq!( - sys_lchown( - &mut proc, - &mut host, - b"/link", - CHOWN_ID_UNCHANGED, - 5000, - ), + sys_lchown(&mut proc, &mut host, b"/link", CHOWN_ID_UNCHANGED, 5000,), Err(Errno::EPERM), ); + assert_eq!(host.lchown_calls, vec![(b"/link".to_vec(), 1000, 2000)],); assert_eq!( - host.lchown_calls, - vec![(b"/link".to_vec(), 1000, 2000)], + host.file_owners.get(b"/target".as_slice()), + Some(&(9999, 9999)) ); - assert_eq!(host.file_owners.get(b"/target".as_slice()), Some(&(9999, 9999))); } /// sys_fchown must propagate uid/gid into the host VFS via the open file @@ -18625,30 +18903,10 @@ mod tests { proc.gid = 2000; proc.egid = 3000; - sys_fchown( - &mut proc, - &mut host, - fd, - CHOWN_ID_UNCHANGED, - 3000, - ) - .unwrap(); - sys_fchown( - &mut proc, - &mut host, - fd, - CHOWN_ID_UNCHANGED, - 2000, - ) - .unwrap(); + sys_fchown(&mut proc, &mut host, fd, CHOWN_ID_UNCHANGED, 3000).unwrap(); + sys_fchown(&mut proc, &mut host, fd, CHOWN_ID_UNCHANGED, 2000).unwrap(); assert_eq!( - sys_fchown( - &mut proc, - &mut host, - fd, - CHOWN_ID_UNCHANGED, - 5000, - ), + sys_fchown(&mut proc, &mut host, fd, CHOWN_ID_UNCHANGED, 5000,), Err(Errno::EPERM), ); assert_eq!(host.fchown_calls.len(), 2); @@ -18746,16 +19004,8 @@ mod tests { host.set_dir_with_owner(b"/dir", 0, 0, 0o755); host.set_file_with_owner(b"/dir/target", 1000, 4000, 0o755, b"target"); host.set_symlink(b"/dir/link", b"target"); - host.file_owners - .insert(b"/dir/link".to_vec(), (1000, 4000)); - let dirfd = sys_open( - &mut proc, - &mut host, - b"/dir", - O_RDONLY | O_DIRECTORY, - 0, - ) - .unwrap(); + host.file_owners.insert(b"/dir/link".to_vec(), (1000, 4000)); + let dirfd = sys_open(&mut proc, &mut host, b"/dir", O_RDONLY | O_DIRECTORY, 0).unwrap(); proc.uid = 1000; proc.euid = 1000; proc.gid = 2000; @@ -18793,10 +19043,7 @@ mod tests { ), Err(Errno::EPERM), ); - assert_eq!( - host.lchown_calls, - vec![(b"/dir/link".to_vec(), 1000, 2000)], - ); + assert_eq!(host.lchown_calls, vec![(b"/dir/link".to_vec(), 1000, 2000)],); assert_eq!( host.chown_calls, vec![(b"/dir/target".to_vec(), 1000, 3000)], @@ -18926,7 +19173,11 @@ mod tests { host.set_file_with_owner(b"/etc/file", 0, 0, 0o644, b""); sys_stat(&mut proc, &mut host, b"/tmp/../etc/file").unwrap(); - assert!(host.lstat_paths.iter().all(|path| !path.windows(2).any(|w| w == b".."))); + assert!( + host.lstat_paths + .iter() + .all(|path| !path.windows(2).any(|w| w == b"..")) + ); assert!(host.lstat_paths.iter().any(|path| path == b"/etc/file")); } @@ -18979,15 +19230,7 @@ mod tests { assert!(sys_access(&mut proc, &mut host, b"/root-only/file", R_OK).is_ok()); assert!( - sys_faccessat( - &mut proc, - &mut host, - AT_FDCWD, - b"/root-only/file", - R_OK, - 0, - ) - .is_ok() + sys_faccessat(&mut proc, &mut host, AT_FDCWD, b"/root-only/file", R_OK, 0,).is_ok() ); assert_eq!( sys_faccessat( @@ -19077,28 +19320,28 @@ mod tests { let server = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); sys_bind(&mut proc, &mut host, server, &aliased).unwrap(); - assert!(unsafe { crate::unix_socket::global_unix_socket_registry() } - .contains(b"/tmp/socket")); - assert!(!unsafe { crate::unix_socket::global_unix_socket_registry() } - .contains(b"/tmp/a/../socket")); + assert!( + unsafe { crate::unix_socket::global_unix_socket_registry() }.contains(b"/tmp/socket") + ); + assert!( + !unsafe { crate::unix_socket::global_unix_socket_registry() } + .contains(b"/tmp/a/../socket") + ); let client = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); sys_connect(&mut proc, &mut host, client, &canonical).unwrap(); host.set_missing_path(b"/tmp/renamed-socket"); - sys_rename( - &mut proc, - &mut host, - b"/tmp/socket", - b"/tmp/renamed-socket", - ) - .unwrap(); + sys_rename(&mut proc, &mut host, b"/tmp/socket", b"/tmp/renamed-socket").unwrap(); host.set_missing_path(b"/tmp/socket"); host.missing_paths.remove(b"/tmp/renamed-socket".as_slice()); - assert!(!unsafe { crate::unix_socket::global_unix_socket_registry() } - .contains(b"/tmp/socket")); - assert!(unsafe { crate::unix_socket::global_unix_socket_registry() } - .contains(b"/tmp/renamed-socket")); + assert!( + !unsafe { crate::unix_socket::global_unix_socket_registry() }.contains(b"/tmp/socket") + ); + assert!( + unsafe { crate::unix_socket::global_unix_socket_registry() } + .contains(b"/tmp/renamed-socket") + ); let renamed = test_unix_addr(b"/tmp/renamed-socket"); let second_client = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); @@ -19375,8 +19618,14 @@ mod tests { let mut proc = Process::new(1); let mut host = MockHostIO::new(); sys_setrlimit(&mut proc, 7, 4096, 4096).unwrap(); - let low_fd = - sys_open(&mut proc, &mut host, b"/tmp/high-fd", O_RDWR | O_CREAT, 0o644).unwrap(); + let low_fd = sys_open( + &mut proc, + &mut host, + b"/tmp/high-fd", + O_RDWR | O_CREAT, + 0o644, + ) + .unwrap(); let high_fd = sys_fcntl(&mut proc, low_fd, F_DUPFD, 2048).unwrap(); sys_close(&mut proc, &mut host, low_fd).unwrap(); @@ -19444,6 +19693,19 @@ mod tests { assert!(proc.signals.is_pending(2)); } + #[test] + fn test_raise_preserves_self_sender_metadata() { + let mut proc = Process::new(17); + proc.uid = 29; + + sys_raise(&mut proc, 10).unwrap(); + let info = proc.consume_signal_for(17, 10).unwrap(); + + assert_eq!(info.si_code, 0); + assert_eq!(info.si_value_bits, 0); + assert_eq!((info.sender_pid, info.sender_uid), (17, 29)); + } + #[test] fn test_process_local_kill_does_not_infer_group_from_pid_zero() { let mut proc = Process::new(1); @@ -19475,21 +19737,25 @@ mod tests { fn test_sigaction_ignore_accepts_pending_timer_notification() { let mut proc = Process::new(1); proc.add_thread(crate::process::ThreadInfo::new(2, 0, 0, 0)); - proc.posix_timers.push(Some(crate::process::PosixTimerState { - clock_id: 1, - sigev_signo: 10, - sigev_value: 7, - sigev_notify: 4, - sigev_tid: 2, - interval_sec: 0, - interval_nsec: 1, - value_sec: 0, - value_nsec: 1, - notification_pending: true, - overrun_current: 2, - overrun_last: 0, - })); - proc.get_thread_mut(2).unwrap().signals.raise_timer(10, 7, 0); + proc.posix_timers + .push(Some(crate::process::PosixTimerState { + clock_id: 1, + sigev_signo: 10, + sigev_value_bits: 7, + sigev_notify: 4, + sigev_tid: 2, + interval_sec: 0, + interval_nsec: 1, + value_sec: 0, + value_nsec: 1, + notification_pending: true, + overrun_current: 2, + overrun_last: 0, + })); + proc.get_thread_mut(2) + .unwrap() + .signals + .raise_timer(10, 7, 0); sys_sigaction(&mut proc, 10, SIG_IGN, 0, 0).unwrap(); @@ -19693,7 +19959,13 @@ mod tests { host.fstat_error = Some(Errno::EIO); assert_eq!( - sys_open(&mut proc, &mut host, b"/tmp/identity", O_RDWR | O_CREAT, 0o644), + sys_open( + &mut proc, + &mut host, + b"/tmp/identity", + O_RDWR | O_CREAT, + 0o644 + ), Err(Errno::EIO) ); assert_eq!(host.closed_handles, vec![100]); @@ -19730,8 +20002,14 @@ mod tests { let mut proc = Process::new(1); let mut locks = AdvisoryLockManager::new(); let mut host = MockHostIO::new(); - let fd = - sys_open(&mut proc, &mut host, b"/tmp/identity", O_RDWR | O_CREAT, 0o644).unwrap(); + let fd = sys_open( + &mut proc, + &mut host, + b"/tmp/identity", + O_RDWR | O_CREAT, + 0o644, + ) + .unwrap(); let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; proc.ofd_table.get_mut(ofd_idx).unwrap().file_id = None; host.fstat_error = Some(Errno::EAGAIN); @@ -19757,8 +20035,14 @@ mod tests { let mut proc = Process::new(1); let mut locks = AdvisoryLockManager::new(); let mut host = MockHostIO::new(); - let fd = - sys_open(&mut proc, &mut host, b"/tmp/identity", O_RDWR | O_CREAT, 0o644).unwrap(); + let fd = sys_open( + &mut proc, + &mut host, + b"/tmp/identity", + O_RDWR | O_CREAT, + 0o644, + ) + .unwrap(); host.fstat_error = Some(Errno::EAGAIN); let mut flock = WasmFlock { l_type: F_WRLCK as i16, @@ -19782,11 +20066,15 @@ mod tests { let mut proc = Process::new(1); let mut locks = AdvisoryLockManager::new(); let mut host = MockHostIO::new(); - let first = - sys_open(&mut proc, &mut host, b"/tmp/flock-convert", O_RDWR | O_CREAT, 0o644) - .unwrap(); - let second = - sys_open(&mut proc, &mut host, b"/tmp/flock-convert", O_RDWR, 0).unwrap(); + let first = sys_open( + &mut proc, + &mut host, + b"/tmp/flock-convert", + O_RDWR | O_CREAT, + 0o644, + ) + .unwrap(); + let second = sys_open(&mut proc, &mut host, b"/tmp/flock-convert", O_RDWR, 0).unwrap(); let first_ofd = proc .ofd_table .get(proc.fd_table.get(first).unwrap().ofd_ref.0) @@ -19823,24 +20111,11 @@ mod tests { // non-atomic conversion rule without entering the retry path. sys_flock(&mut proc, &mut locks, first, LOCK_SH, &mut host).unwrap(); assert_eq!( - sys_flock( - &mut proc, - &mut locks, - first, - LOCK_EX | LOCK_NB, - &mut host, - ), + sys_flock(&mut proc, &mut locks, first, LOCK_EX | LOCK_NB, &mut host,), Err(Errno::EAGAIN) ); assert_eq!(locks.flock_type(file, first_ofd), None); - sys_flock( - &mut proc, - &mut locks, - second, - LOCK_EX | LOCK_NB, - &mut host, - ) - .unwrap(); + sys_flock(&mut proc, &mut locks, second, LOCK_EX | LOCK_NB, &mut host).unwrap(); assert_eq!( locks.flock_type(file, second_ofd), Some(AdvisoryLockType::Write) @@ -19858,8 +20133,7 @@ mod tests { for path in [b"/etc/mtab".as_slice(), b"/proc/self/stat".as_slice()] { let fd = sys_open(&mut proc, &mut host, path, O_RDONLY, 0).unwrap(); - let size = i64::try_from(sys_fstat(&mut proc, &mut host, fd).unwrap().st_size) - .unwrap(); + let size = i64::try_from(sys_fstat(&mut proc, &mut host, fd).unwrap().st_size).unwrap(); assert!(size > 0); let file = proc .ofd_table @@ -19944,14 +20218,7 @@ mod tests { l_pid: 0, _pad2: 0, }; - let result = sys_fcntl_lock( - &mut proc, - &mut locks, - fd, - F_SETLK, - &mut flock, - &mut host, - ); + let result = sys_fcntl_lock(&mut proc, &mut locks, fd, F_SETLK, &mut flock, &mut host); assert_eq!(result, Err(Errno::EBADF)); } @@ -20036,9 +20303,20 @@ mod tests { let backing_idx = descriptor_backing_idx(table.get(PARENT).unwrap(), inherited_fd); let backing_generation = descriptor_backing_generation(FileType::EventFd, backing_idx).unwrap(); - assert_eq!(table.fork_process_for_caller(PARENT, PARENT).unwrap(), FORK_CHILD); + assert_eq!( + table.fork_process_for_caller(PARENT, PARENT).unwrap(), + FORK_CHILD + ); let spawn_child = table - .spawn_child_for_caller(PARENT, PARENT, &[], &[], &[], &SpawnAttrs::empty(), &mut host) + .spawn_child_for_caller( + PARENT, + PARENT, + &[], + &[], + &[], + &SpawnAttrs::empty(), + &mut host, + ) .unwrap(); assert_eq!( descriptor_backing_ref_count(FileType::EventFd, backing_idx), @@ -20127,9 +20405,20 @@ mod tests { let backing_idx = descriptor_backing_idx(table.get(PARENT).unwrap(), inherited_fd); let backing_generation = descriptor_backing_generation(FileType::TimerFd, backing_idx).unwrap(); - assert_eq!(table.fork_process_for_caller(PARENT, PARENT).unwrap(), FORK_CHILD); + assert_eq!( + table.fork_process_for_caller(PARENT, PARENT).unwrap(), + FORK_CHILD + ); let spawn_child = table - .spawn_child_for_caller(PARENT, PARENT, &[], &[], &[], &SpawnAttrs::empty(), &mut host) + .spawn_child_for_caller( + PARENT, + PARENT, + &[], + &[], + &[], + &SpawnAttrs::empty(), + &mut host, + ) .unwrap(); sys_timerfd_settime( @@ -20202,9 +20491,20 @@ mod tests { let backing_idx = descriptor_backing_idx(table.get(PARENT).unwrap(), inherited_fd); let backing_generation = descriptor_backing_generation(FileType::SignalFd, backing_idx).unwrap(); - assert_eq!(table.fork_process_for_caller(PARENT, PARENT).unwrap(), FORK_CHILD); + assert_eq!( + table.fork_process_for_caller(PARENT, PARENT).unwrap(), + FORK_CHILD + ); let spawn_child = table - .spawn_child_for_caller(PARENT, PARENT, &[], &[], &[], &SpawnAttrs::empty(), &mut host) + .spawn_child_for_caller( + PARENT, + PARENT, + &[], + &[], + &[], + &SpawnAttrs::empty(), + &mut host, + ) .unwrap(); let usr1_mask = 1u64 << (SIGUSR1 - 1); @@ -20293,9 +20593,20 @@ mod tests { SEEK_SET, ) .unwrap(); - assert_eq!(table.fork_process_for_caller(PARENT, PARENT).unwrap(), FORK_CHILD); + assert_eq!( + table.fork_process_for_caller(PARENT, PARENT).unwrap(), + FORK_CHILD + ); let spawn_child = table - .spawn_child_for_caller(PARENT, PARENT, &[], &[], &[], &SpawnAttrs::empty(), &mut host) + .spawn_child_for_caller( + PARENT, + PARENT, + &[], + &[], + &[], + &SpawnAttrs::empty(), + &mut host, + ) .unwrap(); let mut pair = [0u8; 2]; @@ -20410,7 +20721,10 @@ mod tests { let fd = sys_memfd_create(table.get_mut(PARENT).unwrap(), b"cursor-lock", 0).unwrap(); sys_write(table.get_mut(PARENT).unwrap(), &mut host, fd, b"abcdefgh").unwrap(); sys_lseek(table.get_mut(PARENT).unwrap(), &mut host, fd, 0, SEEK_SET).unwrap(); - assert_eq!(table.fork_process_for_caller(PARENT, PARENT).unwrap(), CHILD); + assert_eq!( + table.fork_process_for_caller(PARENT, PARENT).unwrap(), + CHILD + ); let mut prefix = [0u8; 3]; sys_read(table.get_mut(CHILD).unwrap(), &mut host, fd, &mut prefix).unwrap(); @@ -20432,15 +20746,7 @@ mod tests { }; { let (parent, locks) = table.process_and_advisory_locks(PARENT).unwrap(); - sys_fcntl_lock( - parent, - locks, - fd, - F_SETLK, - &mut parent_lock, - &mut host, - ) - .unwrap(); + sys_fcntl_lock(parent, locks, fd, F_SETLK, &mut parent_lock, &mut host).unwrap(); } let mut query = WasmFlock { @@ -20496,10 +20802,14 @@ mod tests { ) .unwrap(); assert_eq!(&first, &expected[..7]); - assert_eq!(table.fork_process_for_caller(PARENT, PARENT).unwrap(), FORK_CHILD); + assert_eq!( + table.fork_process_for_caller(PARENT, PARENT).unwrap(), + FORK_CHILD + ); let spawn_child = table .spawn_child_for_caller( - PARENT, PARENT, + PARENT, + PARENT, &[b"spawn-program"], &[], &[], @@ -20594,7 +20904,10 @@ mod tests { .unwrap() .fd_flags |= FD_CLOFORK; - assert_eq!(table.fork_process_for_caller(PARENT, PARENT).unwrap(), CHILD); + assert_eq!( + table.fork_process_for_caller(PARENT, PARENT).unwrap(), + CHILD + ); let child = table.get(CHILD).unwrap(); assert!(child.fd_table.get(clo_fork_fd).is_err()); let child_entry = child.fd_table.get(inherited_alias).unwrap(); @@ -20675,7 +20988,15 @@ mod tests { }); let child = table - .spawn_child_for_caller(PARENT, PARENT, &[], &[], &[], &SpawnAttrs::empty(), &mut host) + .spawn_child_for_caller( + PARENT, + PARENT, + &[], + &[], + &[], + &SpawnAttrs::empty(), + &mut host, + ) .unwrap(); for (fd, file_type, idx, _) in tracked { assert!(table.get(child).unwrap().fd_table.get(fd).is_err()); @@ -20684,7 +21005,8 @@ mod tests { let err = table .spawn_child_for_caller( - PARENT, PARENT, + PARENT, + PARENT, &[], &[], &[FileAction::Dup2 { srcfd: 999, fd: 1 }], @@ -20774,9 +21096,320 @@ mod tests { } fn retain_fd_for_scm_rights(proc: &Process, fd: i32) -> crate::pipe::InFlightFd { + let mut in_flight = snapshot_scm_rights_fd(proc, fd).unwrap(); + in_flight.retain_reference().unwrap(); + in_flight + } + + fn scm_rights_test_ofd_id(proc: &Process, fd: i32) -> OfdId { let fd_entry = proc.fd_table.get(fd).unwrap(); - let ofd = proc.ofd_table.get(fd_entry.ofd_ref.0).unwrap(); - let mut in_flight = crate::pipe::InFlightFd::new( + proc.ofd_table.get(fd_entry.ofd_ref.0).unwrap().ofd_id + } + + fn assert_scm_rights_snapshot_rejected_without_retain(proc: &Process, fd: i32) { + let ofd_id = scm_rights_test_ofd_id(proc, fd); + assert!(!crate::ofd::has_in_flight_ofd(ofd_id)); + assert!(matches!( + snapshot_scm_rights_fd(proc, fd), + Err(Errno::EOPNOTSUPP) + )); + assert!(!crate::ofd::has_in_flight_ofd(ofd_id)); + } + + #[test] + fn scm_rights_transfer_metadata_has_an_explicit_backing_contract() { + for file_type in [ + FileType::Regular, + FileType::Directory, + FileType::Pipe, + FileType::CharDevice, + ] { + assert_eq!(validate_scm_rights_transfer_metadata(file_type, 17), Ok(())); + } + for device in [ + VirtualDevice::Null, + VirtualDevice::Zero, + VirtualDevice::Urandom, + VirtualDevice::Full, + ] { + assert_eq!( + validate_scm_rights_transfer_metadata(FileType::CharDevice, device.host_handle()), + Ok(()) + ); + } + for handle in [ + crate::procfs::PROCFS_DIR_HANDLE, + crate::devfs::DEVFS_DIR_HANDLE, + ] { + assert_eq!( + validate_scm_rights_transfer_metadata(FileType::Directory, handle), + Ok(()) + ); + } + + let eventfd = crate::descriptor_backing::with_eventfds(|table| { + table.alloc(crate::process::EventFdState { + counter: 0, + semaphore: false, + }) + }); + let timerfd = crate::descriptor_backing::with_timerfds(|table| { + table.alloc(crate::process::TimerFdState { + clock_id: 0, + interval_sec: 0, + interval_nsec: 0, + value_sec: 0, + value_nsec: 0, + expirations: 0, + }) + }); + let signalfd = crate::descriptor_backing::with_signalfds(|table| { + table.alloc(crate::process::SignalFdState { mask: 0 }) + }); + let memfd = crate::descriptor_backing::with_memfds(|table| { + table.alloc(crate::descriptor_backing::MemFdBacking::new()) + }); + for (file_type, index) in [ + (FileType::EventFd, eventfd), + (FileType::TimerFd, timerfd), + (FileType::SignalFd, signalfd), + (FileType::MemFd, memfd), + ] { + let handle = -((index as i64) + 1); + assert_eq!( + validate_scm_rights_transfer_metadata(file_type, handle), + Ok(()) + ); + assert!(crate::descriptor_backing::release_for_ofd( + file_type, handle + )); + } + + let synthetic_regular = crate::descriptor_backing::alloc_synthetic_regular(); + assert_eq!( + validate_scm_rights_transfer_metadata(FileType::Regular, synthetic_regular), + Ok(()) + ); + assert!(crate::descriptor_backing::release_for_ofd( + FileType::Regular, + synthetic_regular + )); + let procfs_index = crate::descriptor_backing::with_procfs_bufs(|table| { + table.alloc(crate::descriptor_backing::ProcfsBacking::new( + b"proc".to_vec(), + )) + }); + let procfs_regular = -(crate::procfs::PROCFS_BUF_BASE + procfs_index as i64); + assert_eq!( + validate_scm_rights_transfer_metadata(FileType::Regular, procfs_regular), + Ok(()) + ); + assert!(crate::descriptor_backing::release_for_ofd( + FileType::Regular, + procfs_regular + )); + + let pty_table = crate::pty::test_table_lock(); + let pty_index = crate::pty::alloc_pty().unwrap(); + for file_type in [FileType::PtyMaster, FileType::PtySlave] { + assert_eq!( + validate_scm_rights_transfer_metadata(file_type, pty_index as i64), + Ok(()) + ); + } + crate::pty::free_pty(pty_index); + drop(pty_table); + + for file_type in [FileType::Socket, FileType::Epoll] { + assert_eq!( + validate_scm_rights_transfer_metadata(file_type, 17), + Err(Errno::EOPNOTSUPP) + ); + } + for device in [ + VirtualDevice::Fb0, + VirtualDevice::Mice, + VirtualDevice::Dsp, + VirtualDevice::DriRenderD128, + VirtualDevice::DriCard0, + ] { + assert_eq!( + validate_scm_rights_transfer_metadata(FileType::CharDevice, device.host_handle()), + Err(Errno::EOPNOTSUPP) + ); + } + assert_eq!( + validate_scm_rights_transfer_metadata(FileType::CharDevice, -10), + Err(Errno::EOPNOTSUPP) + ); + for (file_type, handle) in [ + (FileType::Regular, -42), + ( + FileType::Regular, + -(crate::descriptor_backing::SYNTHETIC_REGULAR_HANDLE_BASE + 999_999), + ), + ( + FileType::Regular, + -(crate::procfs::PROCFS_BUF_BASE + 999_999), + ), + (FileType::Directory, -42), + (FileType::EventFd, -1_000_000), + (FileType::TimerFd, -1_000_000), + (FileType::SignalFd, -1_000_000), + (FileType::MemFd, -1_000_000), + (FileType::PtyMaster, -1), + (FileType::PtySlave, -1), + ] { + assert_eq!( + validate_scm_rights_transfer_metadata(file_type, handle), + Err(Errno::EOPNOTSUPP) + ); + } + } + + #[test] + fn scm_rights_snapshot_rejects_lossy_descriptions_before_retain() { + use crate::ofd::{DriFdState, DriOfdState}; + use wasm_posix_shared::socket::{AF_INET, AF_INET6, AF_UNIX, SOCK_DGRAM, SOCK_STREAM}; + + let _guard = SCM_RIGHTS_LIFETIME_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut proc = Process::new(70_890); + let mut host = MockHostIO::new(); + let mut locks = AdvisoryLockManager::new(); + + let (unix_stream_a, unix_stream_b) = + sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + let unix_dgram = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + let inet_dgram = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); + let inet6_stream = sys_socket(&mut proc, &mut host, AF_INET6, SOCK_STREAM, 0).unwrap(); + let epoll = sys_epoll_create1(&mut proc, 0).unwrap(); + + for fd in [ + unix_stream_a, + unix_stream_b, + unix_dgram, + inet_dgram, + inet6_stream, + epoll, + ] { + assert_scm_rights_snapshot_rejected_without_retain(&proc, fd); + } + + for device in [ + VirtualDevice::Fb0, + VirtualDevice::Mice, + VirtualDevice::Dsp, + VirtualDevice::DriRenderD128, + VirtualDevice::DriCard0, + ] { + let ofd_idx = proc.ofd_table.create( + FileType::CharDevice, + O_RDWR, + device.host_handle(), + b"/dev/stateful".to_vec(), + ); + let fd = proc.fd_table.alloc(OpenFileDescRef(ofd_idx), 0).unwrap(); + assert_scm_rights_snapshot_rejected_without_retain(&proc, fd); + } + + // The sidecar itself is disqualifying, even if the nominal file type + // and handle would otherwise be transferable. + let dri_ofd = + proc.ofd_table + .create(FileType::Regular, O_RDWR, 41, b"/dri-sidecar".to_vec()); + proc.ofd_table.get_mut(dri_ofd).unwrap().dri_state = + Some(Box::new(DriOfdState::RenderNode(DriFdState::default()))); + let dri_fd = proc.fd_table.alloc(OpenFileDescRef(dri_ofd), 0).unwrap(); + assert_scm_rights_snapshot_rejected_without_retain(&proc, dri_fd); + + for fd in [ + unix_stream_a, + unix_stream_b, + unix_dgram, + inet_dgram, + inet6_stream, + epoll, + ] { + sys_close_with_locks(&mut proc, &mut locks, &mut host, fd).unwrap(); + } + } + + #[test] + fn scm_rights_send_rejects_lossy_entry_before_retain_or_publication() { + use wasm_posix_shared::socket::{AF_UNIX, MSG_DONTWAIT, SOCK_DGRAM, SOCK_STREAM}; + + let _guard = SCM_RIGHTS_LIFETIME_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut proc = Process::new(70_891); + let mut host = MockHostIO::new(); + let mut locks = AdvisoryLockManager::new(); + let (sender, receiver) = + sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + let rejected_fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + let rejected_entry = { + let fd_entry = proc.fd_table.get(rejected_fd).unwrap(); + let ofd = proc.ofd_table.get(fd_entry.ofd_ref.0).unwrap(); + crate::pipe::InFlightFd::new( + ofd.ofd_id, + ofd.file_id, + ofd.file_type, + ofd.status_flags, + ofd.host_handle, + ofd.offset, + ofd.path.clone(), + ) + }; + let rejected_ofd_id = rejected_entry.ofd_id; + + assert_eq!( + sys_sendmsg( + &mut proc, + &mut host, + sender, + b"must-not-publish", + 0, + None, + vec![rejected_entry], + ), + Err(Errno::EOPNOTSUPP) + ); + assert!(!crate::ofd::has_in_flight_ofd(rejected_ofd_id)); + let mut byte = [0u8; 1]; + assert!(matches!( + sys_recvmsg( + &mut proc, + &mut host, + receiver, + &mut byte, + MSG_DONTWAIT, + &mut [], + ), + Err(Errno::EAGAIN) + )); + + for fd in [rejected_fd, sender, receiver] { + sys_close_with_locks(&mut proc, &mut locks, &mut host, fd).unwrap(); + } + } + + #[test] + fn scm_rights_receive_rejects_legacy_lossy_entry_without_installing() { + let _guard = SCM_RIGHTS_LIFETIME_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut sender = Process::new(70_892); + let mut receiver = Process::new(70_893); + let mut host = MockHostIO::new(); + let ofd_idx = + sender + .ofd_table + .create(FileType::Socket, O_RDWR, -1, b"/legacy-socket".to_vec()); + let ofd = sender.ofd_table.get(ofd_idx).unwrap(); + let ofd_id = ofd.ofd_id; + let mut queued = crate::pipe::InFlightFd::new( ofd.ofd_id, ofd.file_id, ofd.file_type, @@ -20785,8 +21418,306 @@ mod tests { ofd.offset, ofd.path.clone(), ); - in_flight.retain_reference().unwrap(); - in_flight + assert_eq!(queued.retain_reference(), Err(Errno::EOPNOTSUPP)); + assert!(!queued.owns_reference()); + assert!(!crate::ofd::has_in_flight_ofd(ofd_id)); + + let receiver_fd_count = receiver.fd_table.iter().count(); + assert!(install_scm_rights_fds(&mut receiver, vec![queued]).is_empty()); + assert_eq!(receiver.fd_table.iter().count(), receiver_fd_count); + assert!(!crate::ofd::has_in_flight_ofd(ofd_id)); + let mut locks = AdvisoryLockManager::new(); + drain_deferred_scm_rights_releases(&mut locks, &mut host); + assert_eq!(crate::pipe::deferred_in_flight_release_state().0, 0); + } + + #[test] + fn scm_rights_retain_rejects_malformed_pipe_reference_and_rolls_back_install_failure() { + let _guard = SCM_RIGHTS_LIFETIME_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut sender = Process::new(70_895); + let mut receiver = Process::new(70_896); + let mut host = MockHostIO::new(); + let mut locks = AdvisoryLockManager::new(); + let (pipe_reader, pipe_writer) = sys_pipe(&mut sender).unwrap(); + // Neither malformed form can cross the lowest ownership boundary. + let mut missing_kind = snapshot_scm_rights_fd(&sender, pipe_reader).unwrap(); + missing_kind.pipe_ref_kind = None; + assert_eq!(missing_kind.retain_reference(), Err(Errno::EOPNOTSUPP)); + assert!(!missing_kind.owns_reference()); + let receiver_fd_count = receiver.fd_table.iter().count(); + assert!(install_scm_rights_fds(&mut receiver, vec![missing_kind]).is_empty()); + assert_eq!(receiver.fd_table.iter().count(), receiver_fd_count); + + let mut wrong_kind = snapshot_scm_rights_fd(&sender, pipe_reader).unwrap(); + wrong_kind.pipe_ref_kind = Some(crate::pipe::InFlightPipeRefKind::Write); + assert_eq!(wrong_kind.retain_reference(), Err(Errno::EOPNOTSUPP)); + assert!(!wrong_kind.owns_reference()); + assert!(install_scm_rights_fds(&mut receiver, vec![wrong_kind]).is_empty()); + assert_eq!(receiver.fd_table.iter().count(), receiver_fd_count); + + // A valid queued writer that cannot allocate a receiver fd must roll + // back its exact endpoint reference. Otherwise closing the original + // writer below would leave the reader unable to observe EOF. + let mut queued_writer = snapshot_scm_rights_fd(&sender, pipe_writer).unwrap(); + let writer_ofd_id = queued_writer.ofd_id; + queued_writer.retain_reference().unwrap(); + assert!(queued_writer.owns_reference()); + assert!(crate::ofd::has_in_flight_ofd(writer_ofd_id)); + receiver.fd_table.set_max_fds(0); + assert!(install_scm_rights_fds(&mut receiver, vec![queued_writer]).is_empty()); + assert_eq!(receiver.fd_table.iter().count(), receiver_fd_count); + assert!(!crate::ofd::has_in_flight_ofd(writer_ofd_id)); + drain_deferred_scm_rights_releases(&mut locks, &mut host); + + sys_close_with_locks(&mut sender, &mut locks, &mut host, pipe_writer).unwrap(); + let mut byte = [0u8; 1]; + assert_eq!( + sys_read(&mut sender, &mut host, pipe_reader, &mut byte), + Ok(0) + ); + sys_close_with_locks(&mut sender, &mut locks, &mut host, pipe_reader).unwrap(); + } + + #[test] + fn zero_iovec_stream_recvmsg_preserves_carrier_and_rights() { + use wasm_posix_shared::socket::{AF_UNIX, MSG_DONTWAIT, SOCK_STREAM}; + + let _guard = SCM_RIGHTS_LIFETIME_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut proc = Process::new(70_894); + let mut host = MockHostIO::new(); + let mut locks = AdvisoryLockManager::new(); + let (sender, receiver) = + sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + let (pipe_reader, pipe_writer) = sys_pipe(&mut proc).unwrap(); + let pipe_ofd_id = scm_rights_test_ofd_id(&proc, pipe_reader); + let snapshot = snapshot_scm_rights_fd(&proc, pipe_reader).unwrap(); + assert!(!snapshot.owns_reference()); + assert!(!crate::ofd::has_in_flight_ofd(pipe_ofd_id)); + + assert_eq!( + sys_sendmsg(&mut proc, &mut host, sender, b"Z", 0, None, vec![snapshot],), + Ok(1) + ); + assert!(crate::ofd::has_in_flight_ofd(pipe_ofd_id)); + + let empty = sys_recvmsg( + &mut proc, + &mut host, + receiver, + &mut [], + MSG_DONTWAIT, + &mut [], + ) + .unwrap(); + assert_eq!(empty.return_len, 0); + assert!(empty.ancillary_fds.is_empty()); + assert!(crate::ofd::has_in_flight_ofd(pipe_ofd_id)); + + let mut byte = [0u8; 1]; + let received = sys_recvmsg( + &mut proc, + &mut host, + receiver, + &mut byte, + MSG_DONTWAIT, + &mut [], + ) + .unwrap(); + assert_eq!(received.return_len, 1); + assert_eq!(byte, *b"Z"); + assert_eq!(received.ancillary_fds.len(), 1); + let installed = install_scm_rights_fds(&mut proc, received.ancillary_fds); + assert_eq!(installed.len(), 1); + assert!(!crate::ofd::has_in_flight_ofd(pipe_ofd_id)); + + assert_eq!(sys_write(&mut proc, &mut host, pipe_writer, b"R"), Ok(1)); + let mut pipe_byte = [0u8; 1]; + assert_eq!( + sys_read(&mut proc, &mut host, installed[0], &mut pipe_byte), + Ok(1) + ); + assert_eq!(pipe_byte, *b"R"); + + for fd in [installed[0], pipe_reader, pipe_writer, sender, receiver] { + sys_close_with_locks(&mut proc, &mut locks, &mut host, fd).unwrap(); + } + } + + #[test] + fn zero_count_read_preserves_zero_datagram_and_rights_for_recvmsg() { + let _guard = SCM_RIGHTS_LIFETIME_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut proc = Process::new(70_900); + let mut host = MockHostIO::new(); + let mut locks = AdvisoryLockManager::new(); + let (sender, receiver) = sys_socketpair( + &mut proc, + &mut host, + wasm_posix_shared::socket::AF_UNIX, + wasm_posix_shared::socket::SOCK_DGRAM, + 0, + ) + .unwrap(); + let carried_fd = sys_memfd_create(&mut proc, b"zero-datagram", 0).unwrap(); + let (carried_ofd_id, in_flight) = { + let carried_entry = proc.fd_table.get(carried_fd).unwrap(); + let carried_ofd = proc.ofd_table.get(carried_entry.ofd_ref.0).unwrap(); + ( + carried_ofd.ofd_id, + crate::pipe::InFlightFd::new( + carried_ofd.ofd_id, + carried_ofd.file_id, + carried_ofd.file_type, + carried_ofd.status_flags, + carried_ofd.host_handle, + carried_ofd.offset, + carried_ofd.path.clone(), + ), + ) + }; + + assert_eq!( + sys_sendmsg(&mut proc, &mut host, sender, &[], 0, None, vec![in_flight],), + Ok(0), + ); + assert_eq!(sys_read(&mut proc, &mut host, receiver, &mut []), Ok(0)); + + let received = sys_recvmsg( + &mut proc, + &mut host, + receiver, + &mut [], + wasm_posix_shared::socket::MSG_DONTWAIT, + &mut [], + ) + .unwrap(); + assert_eq!(received.return_len, 0); + assert_eq!(received.ancillary_fds.len(), 1); + assert_eq!(received.ancillary_fds[0].ofd_id, carried_ofd_id); + drop(received); + drain_deferred_scm_rights_releases(&mut locks, &mut host); + + for fd in [carried_fd, sender, receiver] { + sys_close_with_locks(&mut proc, &mut locks, &mut host, fd).unwrap(); + } + } + + #[test] + fn full_unix_datagram_sendmsg_rolls_back_rights_atomically() { + let _guard = SCM_RIGHTS_LIFETIME_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut proc = Process::new(70_901); + let mut host = MockHostIO::new(); + let mut locks = AdvisoryLockManager::new(); + let (sender, receiver) = sys_socketpair( + &mut proc, + &mut host, + wasm_posix_shared::socket::AF_UNIX, + wasm_posix_shared::socket::SOCK_DGRAM, + 0, + ) + .unwrap(); + for sequence in 0..UDP_DATAGRAM_QUEUE_LIMIT { + sys_send( + &mut proc, + &mut host, + sender, + &(sequence as u32).to_le_bytes(), + 0, + ) + .unwrap(); + } + let receiver_idx = test_socket_idx(&proc, receiver); + assert_eq!( + proc.sockets.get(receiver_idx).unwrap().dgram_queue.len(), + UDP_DATAGRAM_QUEUE_LIMIT, + ); + + let carried_fd = sys_memfd_create(&mut proc, b"full-datagram", 0).unwrap(); + let (carried_ofd_id, in_flight) = { + let carried_entry = proc.fd_table.get(carried_fd).unwrap(); + let carried_ofd = proc.ofd_table.get(carried_entry.ofd_ref.0).unwrap(); + ( + carried_ofd.ofd_id, + crate::pipe::InFlightFd::new( + carried_ofd.ofd_id, + carried_ofd.file_id, + carried_ofd.file_type, + carried_ofd.status_flags, + carried_ofd.host_handle, + carried_ofd.offset, + carried_ofd.path.clone(), + ), + ) + }; + let deferred_before = crate::pipe::deferred_in_flight_release_state(); + assert_eq!( + sys_sendmsg( + &mut proc, + &mut host, + sender, + b"must-not-publish", + wasm_posix_shared::socket::MSG_DONTWAIT, + None, + vec![in_flight], + ), + Err(Errno::EAGAIN), + ); + let receiver_socket = proc.sockets.get(receiver_idx).unwrap(); + assert_eq!(receiver_socket.dgram_queue.len(), UDP_DATAGRAM_QUEUE_LIMIT,); + assert!( + receiver_socket + .dgram_queue + .iter() + .all(|datagram| datagram.ancillary_fds.is_empty()), + ); + assert!(!crate::ofd::has_in_flight_ofd(carried_ofd_id)); + let deferred_failed = crate::pipe::deferred_in_flight_release_state(); + assert_eq!(deferred_failed.0, deferred_before.0 + 1); + assert_eq!(deferred_failed.1, deferred_before.1); + drain_deferred_scm_rights_releases(&mut locks, &mut host); + assert_eq!( + crate::pipe::deferred_in_flight_release_state().0, + deferred_before.0, + ); + + for fd in [carried_fd, sender, receiver] { + sys_close_with_locks(&mut proc, &mut locks, &mut host, fd).unwrap(); + } + } + + #[test] + fn scm_rights_install_publishes_cloexec_with_the_new_fd() { + let _guard = SCM_RIGHTS_LIFETIME_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut sender = Process::new(70_902); + let mut receiver = Process::new(70_903); + let mut host = MockHostIO::new(); + let mut locks = AdvisoryLockManager::new(); + let sender_fd = sys_memfd_create(&mut sender, b"cloexec-right", 0).unwrap(); + let queued = retain_fd_for_scm_rights(&sender, sender_fd); + + let received = install_scm_rights_fds_with_flags( + &mut receiver, + vec![queued], + wasm_posix_shared::fd_flags::FD_CLOEXEC, + ); + assert_eq!(received.len(), 1); + assert_ne!( + receiver.fd_table.get(received[0]).unwrap().fd_flags + & wasm_posix_shared::fd_flags::FD_CLOEXEC, + 0, + ); + + sys_close_with_locks(&mut receiver, &mut locks, &mut host, received[0]).unwrap(); + sys_close_with_locks(&mut sender, &mut locks, &mut host, sender_fd).unwrap(); } fn set_whole_file_ofd_lock( @@ -20815,6 +21746,42 @@ mod tests { .unwrap(); } + fn open_locked_host_scm_fd( + proc: &mut Process, + locks: &mut AdvisoryLockManager, + host: &mut MockHostIO, + path: &[u8], + ) -> (i32, OfdId, i64) { + let fd = sys_open(proc, host, path, O_RDWR, 0).unwrap(); + set_whole_file_ofd_lock(proc, locks, host, fd); + let entry = proc.fd_table.get(fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + (fd, ofd.ofd_id, ofd.host_handle) + } + + fn assert_finished_host_scm_release( + locks: &AdvisoryLockManager, + host: &MockHostIO, + ofd_id: OfdId, + host_handle: i64, + deferred_before: (usize, usize, usize), + ) { + assert!(!crate::ofd::has_in_flight_ofd(ofd_id)); + assert_eq!(crate::ofd::host_handle_ref_count(host_handle), 0); + assert!(locks.is_empty()); + assert_eq!( + host.closed_handles + .iter() + .filter(|&&handle| handle == host_handle) + .count(), + 1, + ); + let deferred_after = crate::pipe::deferred_in_flight_release_state(); + assert_eq!(deferred_after.0, deferred_before.0); + assert_eq!(deferred_after.1, deferred_before.1); + assert!(deferred_after.2 >= deferred_before.2); + } + #[test] fn scm_rights_directory_reopens_at_the_snapshot_cookie() { let _guard = SCM_RIGHTS_LIFETIME_LOCK @@ -20825,14 +21792,8 @@ mod tests { let mut host = MockHostIO::new(); host.dir_entry_count = 3; - let sender_fd = sys_open( - &mut sender, - &mut host, - b"/tmp", - O_RDONLY | O_DIRECTORY, - 0, - ) - .unwrap(); + let sender_fd = + sys_open(&mut sender, &mut host, b"/tmp", O_RDONLY | O_DIRECTORY, 0).unwrap(); let mut prefix = [0u8; 80]; assert_eq!( sys_getdents64(&mut sender, &mut host, sender_fd, &mut prefix), @@ -20850,7 +21811,10 @@ mod tests { assert_eq!(sender_ofd.dir_host_handle, 200); assert_eq!(sender_ofd.dir_entry_offset, 3); assert_eq!(sender_ofd.offset, 3); - assert_eq!(sender_ofd.dir_pending_entry.as_ref().unwrap().name, b"foo.txt"); + assert_eq!( + sender_ofd.dir_pending_entry.as_ref().unwrap().name, + b"foo.txt" + ); let queued = retain_fd_for_scm_rights(&sender, sender_fd); let received = install_scm_rights_fds(&mut receiver, vec![queued]); @@ -20894,7 +21858,10 @@ mod tests { let sender_ofd = sender.ofd_table.get(sender_ofd_idx).unwrap(); assert_eq!(sender_ofd.dir_host_handle, 200); assert_eq!(sender_ofd.dir_entry_offset, 3); - assert_eq!(sender_ofd.dir_pending_entry.as_ref().unwrap().name, b"foo.txt"); + assert_eq!( + sender_ofd.dir_pending_entry.as_ref().unwrap().name, + b"foo.txt" + ); sys_close(&mut receiver, &mut host, received_fd).unwrap(); sys_close(&mut sender, &mut host, sender_fd).unwrap(); @@ -20986,13 +21953,8 @@ mod tests { assert!(child_ofd.dir_pending_entry.is_none()); let mut one = [0u8; 32]; - let len = sys_getdents64( - table.get_mut(pid).unwrap(), - &mut host, - parent_fd, - &mut one, - ) - .unwrap(); + let len = sys_getdents64(table.get_mut(pid).unwrap(), &mut host, parent_fd, &mut one) + .unwrap(); assert_eq!(parse_linux_dirents64(&one, len)[0].2, b"foo.txt"); } @@ -21006,7 +21968,10 @@ mod tests { .unwrap(); assert_eq!(parent_ofd.dir_host_handle, 200); assert_eq!(parent_ofd.dir_entry_offset, 3); - assert_eq!(parent_ofd.dir_pending_entry.as_ref().unwrap().name, b"foo.txt"); + assert_eq!( + parent_ofd.dir_pending_entry.as_ref().unwrap().name, + b"foo.txt" + ); for pid in [fork_child, spawn_child, parent_pid] { sys_close(table.get_mut(pid).unwrap(), &mut host, parent_fd).unwrap(); @@ -21064,10 +22029,7 @@ mod tests { assert_eq!(deferred_transferred.1, deferred_before.1); assert!(deferred_transferred.2 >= deferred_retained.2); let received_entry = receiver.fd_table.get(received[0]).unwrap(); - let received_ofd = receiver - .ofd_table - .get(received_entry.ofd_ref.0) - .unwrap(); + let received_ofd = receiver.ofd_table.get(received_entry.ofd_ref.0).unwrap(); assert_eq!(received_ofd.ofd_id, expected_ofd_id); assert_eq!(received_ofd.file_id, expected_file_id); assert_eq!(received_ofd.file_type, FileType::MemFd); @@ -21161,9 +22123,7 @@ mod tests { let sender_fd = sys_memfd_create(table.get_mut(SENDER_PID).unwrap(), b"scm-crash", 0).unwrap(); { - let (sender, locks) = table - .process_and_advisory_locks(SENDER_PID) - .unwrap(); + let (sender, locks) = table.process_and_advisory_locks(SENDER_PID).unwrap(); set_whole_file_ofd_lock(sender, locks, &mut host, sender_fd); } let queued = retain_fd_for_scm_rights(table.get(SENDER_PID).unwrap(), sender_fd); @@ -21181,21 +22141,642 @@ mod tests { assert!(table.advisory_locks().is_empty()); } + #[test] + fn forced_removal_drains_scm_rights_queued_in_unix_datagrams() { + use wasm_posix_shared::socket::{AF_UNIX, SOCK_DGRAM}; + + const SENDER_PID: u32 = 100; + + let _guard = SCM_RIGHTS_LIFETIME_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut table = crate::process_table::ProcessTable::new(); + assert_eq!(table.create_process().unwrap(), SENDER_PID); + let mut host = MockHostIO::new(); + let carried_fd = sys_open( + table.get_mut(SENDER_PID).unwrap(), + &mut host, + b"/tmp/scm-forced-remove", + O_RDWR, + 0, + ) + .unwrap(); + { + let (sender, locks) = table.process_and_advisory_locks(SENDER_PID).unwrap(); + set_whole_file_ofd_lock(sender, locks, &mut host, carried_fd); + } + let (carried_ofd_id, carried_host_handle) = { + let proc = table.get(SENDER_PID).unwrap(); + let entry = proc.fd_table.get(carried_fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + (ofd.ofd_id, ofd.host_handle) + }; + let (carrier_sender, carrier_receiver) = sys_socketpair( + table.get_mut(SENDER_PID).unwrap(), + &mut host, + AF_UNIX, + SOCK_DGRAM, + 0, + ) + .unwrap(); + let receiver_idx = test_socket_idx(table.get(SENDER_PID).unwrap(), carrier_receiver); + let queued = snapshot_scm_rights_fd(table.get(SENDER_PID).unwrap(), carried_fd).unwrap(); + let deferred_before = crate::pipe::deferred_in_flight_release_state(); + + assert_eq!( + sys_sendmsg( + table.get_mut(SENDER_PID).unwrap(), + &mut host, + carrier_sender, + b"Q", + 0, + None, + vec![queued], + ), + Ok(1), + ); + assert_eq!( + table + .get(SENDER_PID) + .unwrap() + .sockets + .get(receiver_idx) + .unwrap() + .dgram_queue + .len(), + 1, + ); + assert!(crate::ofd::has_in_flight_ofd(carried_ofd_id)); + assert_eq!(crate::ofd::host_handle_ref_count(carried_host_handle), 2); + assert_eq!(table.advisory_locks().len(), 1); + + let removed = table.remove_process(SENDER_PID).unwrap(); + + assert!(!crate::ofd::has_in_flight_ofd(carried_ofd_id)); + assert_eq!(crate::ofd::host_handle_ref_count(carried_host_handle), 0); + assert!(table.advisory_locks().is_empty()); + assert_eq!( + removed + .host_closes + .iter() + .filter(|&&handle| handle == carried_host_handle) + .count(), + 1, + ); + let deferred_after = crate::pipe::deferred_in_flight_release_state(); + assert_eq!(deferred_after.0, deferred_before.0); + assert_eq!(deferred_after.1, deferred_before.1); + assert!(deferred_after.2 >= deferred_before.2); + } + + #[test] + fn unix_datagram_reconnect_drops_and_drains_scm_rights_ownership() { + use wasm_posix_shared::socket::{AF_UNIX, SOCK_DGRAM}; + + let _scm_guard = SCM_RIGHTS_LIFETIME_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let _registry_guard = UNIX_REGISTRY_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut proc = Process::new(70_904); + let mut host = MockHostIO::new(); + let mut locks = AdvisoryLockManager::new(); + let carried_fd = sys_open(&mut proc, &mut host, b"/tmp/scm-reconnect", O_RDWR, 0).unwrap(); + set_whole_file_ofd_lock(&mut proc, &mut locks, &mut host, carried_fd); + let (carried_ofd_id, carried_host_handle) = { + let entry = proc.fd_table.get(carried_fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + (ofd.ofd_id, ofd.host_handle) + }; + let (source, old_peer) = + sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + let (new_peer, new_peer_addr) = + bind_test_unix_dgram(&mut proc, &mut host, b"/tmp/scm-reconnect-peer"); + let source_idx = test_socket_idx(&proc, source); + let queued = snapshot_scm_rights_fd(&proc, carried_fd).unwrap(); + let deferred_before = crate::pipe::deferred_in_flight_release_state(); + + assert_eq!( + sys_sendmsg(&mut proc, &mut host, old_peer, b"Q", 0, None, vec![queued],), + Ok(1), + ); + assert_eq!(proc.sockets.get(source_idx).unwrap().dgram_queue.len(), 1,); + sys_close_with_locks(&mut proc, &mut locks, &mut host, carried_fd).unwrap(); + assert_eq!(crate::ofd::host_handle_ref_count(carried_host_handle), 1); + assert_eq!( + host.closed_handles + .iter() + .filter(|&&handle| handle == carried_host_handle) + .count(), + 0, + ); + assert_eq!(locks.len(), 1); + + assert_eq!( + sys_connect(&mut proc, &mut host, source, &new_peer_addr), + Ok(()), + ); + assert!(proc.sockets.get(source_idx).unwrap().dgram_queue.is_empty()); + assert!(!crate::ofd::has_in_flight_ofd(carried_ofd_id)); + let deferred_dropped = crate::pipe::deferred_in_flight_release_state(); + assert_eq!(deferred_dropped.0, deferred_before.0 + 1); + assert_eq!(deferred_dropped.1, deferred_before.1); + assert_eq!(crate::ofd::host_handle_ref_count(carried_host_handle), 1); + assert_eq!(locks.len(), 1); + + // Exercise the exact post-operation helper used by the exported + // connect wrapper and the channel-dispatch cleanup boundary. + finish_scm_rights_cleanup(&mut locks, &mut host); + let deferred_drained = crate::pipe::deferred_in_flight_release_state(); + assert_eq!(deferred_drained.0, deferred_before.0); + assert_eq!(deferred_drained.1, deferred_before.1); + assert_eq!(crate::ofd::host_handle_ref_count(carried_host_handle), 0); + assert!(locks.is_empty()); + assert_eq!( + host.closed_handles + .iter() + .filter(|&&handle| handle == carried_host_handle) + .count(), + 1, + ); + + for fd in [source, old_peer, new_peer] { + sys_close_with_locks(&mut proc, &mut locks, &mut host, fd).unwrap(); + } + } + + #[test] + fn unix_datagram_shutdown_modes_preserve_or_discard_scm_rights_correctly() { + use wasm_posix_shared::socket::{ + AF_UNIX, MSG_DONTWAIT, SHUT_RD, SHUT_RDWR, SHUT_WR, SOCK_DGRAM, + }; + + let _guard = SCM_RIGHTS_LIFETIME_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + for (case, how) in [("read", SHUT_RD), ("read-write", SHUT_RDWR)] { + let mut proc = Process::new(if how == SHUT_RD { 70_905 } else { 70_906 }); + let mut host = MockHostIO::new(); + let mut locks = AdvisoryLockManager::new(); + let path = alloc::format!("/tmp/scm-shutdown-{case}").into_bytes(); + let (carried_fd, carried_ofd_id, carried_host_handle) = + open_locked_host_scm_fd(&mut proc, &mut locks, &mut host, &path); + let (sender, receiver) = + sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + let receiver_idx = test_socket_idx(&proc, receiver); + let deferred_before = crate::pipe::deferred_in_flight_release_state(); + let queued = snapshot_scm_rights_fd(&proc, carried_fd).unwrap(); + + assert_eq!( + sys_sendmsg( + &mut proc, + &mut host, + sender, + b"must-not-survive-shutdown", + 0, + None, + vec![queued], + ), + Ok(25), + "{case}", + ); + assert_eq!( + proc.sockets.get(receiver_idx).unwrap().dgram_queue.len(), + 1, + "{case}", + ); + sys_close_with_locks(&mut proc, &mut locks, &mut host, carried_fd).unwrap(); + + sys_shutdown(&mut proc, &mut host, receiver, how).unwrap(); + assert!( + proc.sockets + .get(receiver_idx) + .unwrap() + .dgram_queue + .is_empty(), + "{case}", + ); + let mut unread = [0xa5; 4]; + assert_eq!( + sys_recv(&mut proc, &mut host, receiver, &mut unread, 0), + Ok(0), + "{case}", + ); + assert_eq!( + unread, [0xa5; 4], + "{case} shutdown must not publish queued bytes", + ); + assert!(!crate::ofd::has_in_flight_ofd(carried_ofd_id)); + assert!(crate::pipe::has_deferred_in_flight_releases()); + assert_eq!(crate::ofd::host_handle_ref_count(carried_host_handle), 1); + assert_eq!(locks.len(), 1); + + // Exercise the same post-operation boundary used by + // kernel_shutdown. + finish_scm_rights_cleanup(&mut locks, &mut host); + assert_finished_host_scm_release( + &locks, + &host, + carried_ofd_id, + carried_host_handle, + deferred_before, + ); + + for fd in [sender, receiver] { + sys_close_with_locks(&mut proc, &mut locks, &mut host, fd).unwrap(); + } + } + + // Write-only shutdown must not consume the receive queue. The carried + // descriptor remains owned and can still be received and installed. + let mut proc = Process::new(70_907); + let mut host = MockHostIO::new(); + let mut locks = AdvisoryLockManager::new(); + let (carried_fd, carried_ofd_id, carried_host_handle) = + open_locked_host_scm_fd(&mut proc, &mut locks, &mut host, b"/tmp/scm-shutdown-write"); + let (sender, receiver) = + sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + let receiver_idx = test_socket_idx(&proc, receiver); + let deferred_before = crate::pipe::deferred_in_flight_release_state(); + let queued = snapshot_scm_rights_fd(&proc, carried_fd).unwrap(); + assert_eq!( + sys_sendmsg( + &mut proc, + &mut host, + sender, + b"still-receivable", + 0, + None, + vec![queued], + ), + Ok(16), + ); + sys_close_with_locks(&mut proc, &mut locks, &mut host, carried_fd).unwrap(); + + sys_shutdown(&mut proc, &mut host, receiver, SHUT_WR).unwrap(); + finish_scm_rights_cleanup(&mut locks, &mut host); + assert_eq!(proc.sockets.get(receiver_idx).unwrap().dgram_queue.len(), 1,); + assert!(crate::ofd::has_in_flight_ofd(carried_ofd_id)); + assert_eq!(crate::ofd::host_handle_ref_count(carried_host_handle), 1); + assert_eq!(locks.len(), 1); + let after_write_shutdown = crate::pipe::deferred_in_flight_release_state(); + assert_eq!(after_write_shutdown.0, deferred_before.0); + assert_eq!(after_write_shutdown.1, deferred_before.1 + 1); + + let mut received_payload = [0u8; 16]; + let received = sys_recvmsg( + &mut proc, + &mut host, + receiver, + &mut received_payload, + MSG_DONTWAIT, + &mut [], + ) + .unwrap(); + assert_eq!(received.return_len, received_payload.len()); + assert_eq!(&received_payload, b"still-receivable"); + assert_eq!(received.ancillary_fds.len(), 1); + assert_eq!(received.ancillary_fds[0].ofd_id, carried_ofd_id); + let installed = install_scm_rights_fds(&mut proc, received.ancillary_fds); + assert_eq!(installed.len(), 1); + assert!(!crate::ofd::has_in_flight_ofd(carried_ofd_id)); + sys_close_with_locks(&mut proc, &mut locks, &mut host, installed[0]).unwrap(); + assert_finished_host_scm_release( + &locks, + &host, + carried_ofd_id, + carried_host_handle, + deferred_before, + ); + for fd in [sender, receiver] { + sys_close_with_locks(&mut proc, &mut locks, &mut host, fd).unwrap(); + } + } + + #[test] + fn failed_accept_discards_and_drains_preaccepted_stream_scm_rights() { + use wasm_posix_shared::socket::{AF_UNIX, SOCK_STREAM}; + + let _scm_guard = SCM_RIGHTS_LIFETIME_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let _registry_guard = UNIX_REGISTRY_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut proc = Process::new(70_906); + let mut host = MockHostIO::new(); + let mut locks = AdvisoryLockManager::new(); + let path = b"/tmp/scm-failed-accept.sock"; + let resolved = crate::path::resolve_path(path, &proc.cwd); + unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); + + let listener = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + let addr = test_unix_addr(path); + sys_bind(&mut proc, &mut host, listener, &addr).unwrap(); + sys_listen(&mut proc, &mut host, listener, 1).unwrap(); + let client = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + sys_connect(&mut proc, &mut host, client, &addr).unwrap(); + let (carried_fd, carried_ofd_id, carried_host_handle) = open_locked_host_scm_fd( + &mut proc, + &mut locks, + &mut host, + b"/tmp/scm-failed-accept-carried", + ); + let deferred_before = crate::pipe::deferred_in_flight_release_state(); + let queued = snapshot_scm_rights_fd(&proc, carried_fd).unwrap(); + assert_eq!( + sys_sendmsg( + &mut proc, + &mut host, + client, + b"unaccepted", + 0, + None, + vec![queued], + ), + Ok(10), + ); + sys_close_with_locks(&mut proc, &mut locks, &mut host, carried_fd).unwrap(); + let fd_count_before = proc.fd_table.iter().count(); + let ofd_count_before = proc.ofd_table.iter().count(); + proc.fd_table.set_max_fds(0); + + assert_eq!( + sys_accept(&mut proc, &mut host, listener), + Err(Errno::EMFILE), + ); + assert_eq!( + proc.fd_table.iter().count(), + fd_count_before, + "failed accept must not publish a descriptor", + ); + assert_eq!(proc.ofd_table.iter().count(), ofd_count_before); + assert_eq!( + sys_send( + &mut proc, + &mut host, + client, + b"accepted-side-was-discarded", + wasm_posix_shared::socket::MSG_NOSIGNAL, + ), + Err(Errno::EPIPE), + ); + assert!(!crate::ofd::has_in_flight_ofd(carried_ofd_id)); + assert!(crate::pipe::has_deferred_in_flight_releases()); + assert_eq!(crate::ofd::host_handle_ref_count(carried_host_handle), 1); + assert_eq!(locks.len(), 1); + + // Exercise the exact cleanup boundary used by kernel_accept4 on both + // success and failure returns. + finish_scm_rights_cleanup(&mut locks, &mut host); + assert_finished_host_scm_release( + &locks, + &host, + carried_ofd_id, + carried_host_handle, + deferred_before, + ); + + proc.fd_table.set_max_fds(1024); + for fd in [client, listener] { + sys_close_with_locks(&mut proc, &mut locks, &mut host, fd).unwrap(); + } + unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); + } + + #[test] + fn plain_transfer_syscalls_discard_and_drain_crossed_stream_scm_rights() { + use wasm_posix_shared::socket::{AF_UNIX, SOCK_STREAM}; + + let _guard = SCM_RIGHTS_LIFETIME_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + for operation in 0..3 { + let operation_name = ["sendfile", "copy_file_range", "splice"][operation]; + let mut proc = Process::new(70_910 + operation as u32); + let mut host = MockHostIO::new(); + let mut locks = AdvisoryLockManager::new(); + let carried_path = alloc::format!("/tmp/scm-{operation_name}-carried").into_bytes(); + let (carried_fd, carried_ofd_id, carried_host_handle) = + open_locked_host_scm_fd(&mut proc, &mut locks, &mut host, &carried_path); + let (sender, receiver) = + sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + let output = sys_memfd_create(&mut proc, operation_name.as_bytes(), 0).unwrap(); + let deferred_before = crate::pipe::deferred_in_flight_release_state(); + let queued = snapshot_scm_rights_fd(&proc, carried_fd).unwrap(); + + assert_eq!( + sys_sendmsg(&mut proc, &mut host, sender, b"Q", 0, None, vec![queued],), + Ok(1), + "{operation_name}", + ); + sys_close_with_locks(&mut proc, &mut locks, &mut host, carried_fd).unwrap(); + let copied = match operation { + 0 => sys_sendfile(&mut proc, &mut host, output, receiver, -1, 1), + 1 => sys_copy_file_range(&mut proc, &mut host, receiver, None, output, None, 1), + 2 => sys_splice(&mut proc, &mut host, receiver, None, output, None, 1, 0), + _ => unreachable!(), + }; + assert_eq!(copied, Ok(1), "{operation_name}"); + assert!(!crate::ofd::has_in_flight_ofd(carried_ofd_id)); + assert!(crate::pipe::has_deferred_in_flight_releases()); + assert_eq!(crate::ofd::host_handle_ref_count(carried_host_handle), 1); + assert_eq!(locks.len(), 1); + sys_lseek(&mut proc, &mut host, output, 0, SEEK_SET).unwrap(); + let mut copied_byte = [0u8; 1]; + assert_eq!( + sys_read(&mut proc, &mut host, output, &mut copied_byte), + Ok(1), + "{operation_name}", + ); + assert_eq!(copied_byte, *b"Q", "{operation_name}"); + + // sendfile's direct export and the channel-only copy/splice arms + // all use this same post-operation cleanup primitive. + finish_scm_rights_cleanup(&mut locks, &mut host); + assert_finished_host_scm_release( + &locks, + &host, + carried_ofd_id, + carried_host_handle, + deferred_before, + ); + + for fd in [output, sender, receiver] { + sys_close_with_locks(&mut proc, &mut locks, &mut host, fd).unwrap(); + } + } + } + + #[test] + fn direct_host_pipe_read_and_close_read_detect_deferred_scm_cleanup() { + let _guard = SCM_RIGHTS_LIFETIME_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + for close_read in [false, true] { + let boundary = if close_read { "close-read" } else { "read" }; + let mut proc = Process::new(if close_read { 70_921 } else { 70_920 }); + let mut host = MockHostIO::new(); + let mut locks = AdvisoryLockManager::new(); + let carried_path = alloc::format!("/tmp/scm-direct-pipe-{boundary}").into_bytes(); + let (carried_fd, carried_ofd_id, carried_host_handle) = + open_locked_host_scm_fd(&mut proc, &mut locks, &mut host, &carried_path); + let deferred_before = crate::pipe::deferred_in_flight_release_state(); + let mut queued = snapshot_scm_rights_fd(&proc, carried_fd).unwrap(); + queued.retain_reference().unwrap(); + let pipe_idx = + unsafe { crate::pipe::global_pipe_table().alloc(crate::pipe::PipeBuffer::new(16)) }; + assert_eq!( + unsafe { crate::pipe::global_pipe_table() }.write_retained_ancillary( + pipe_idx, + b"Q", + vec![queued] + ), + Ok(1), + "{boundary}", + ); + sys_close_with_locks(&mut proc, &mut locks, &mut host, carried_fd).unwrap(); + + if close_read { + let pipes = unsafe { crate::pipe::global_pipe_table() }; + assert!(pipes.get(pipe_idx).unwrap().has_ancillary()); + pipes.get_mut(pipe_idx).unwrap().close_read_end(); + pipes.free_if_closed(pipe_idx); + assert!(!pipes.get(pipe_idx).unwrap().has_ancillary()); + } else { + let mut byte = [0u8; 1]; + let read = unsafe { crate::pipe::global_pipe_table() } + .get_mut(pipe_idx) + .unwrap() + .recv_plain(&mut byte, false); + assert_eq!(read.bytes_read, 1); + assert!(read.hit_ancillary_barrier); + assert_eq!(byte, *b"Q"); + } + + // These are the exact post-operation predicate and cleanup helper + // used by the direct host pipe exports. + assert!(crate::pipe::has_deferred_in_flight_releases()); + assert_eq!(crate::ofd::host_handle_ref_count(carried_host_handle), 1); + assert_eq!(locks.len(), 1); + finish_scm_rights_cleanup_if_pending(|| { + finish_scm_rights_cleanup(&mut locks, &mut host); + }); + assert_finished_host_scm_release( + &locks, + &host, + carried_ofd_id, + carried_host_handle, + deferred_before, + ); + + let pipes = unsafe { crate::pipe::global_pipe_table() }; + if !close_read { + pipes.get_mut(pipe_idx).unwrap().close_read_end(); + } + pipes.get_mut(pipe_idx).unwrap().close_write_end(); + pipes.free_if_closed(pipe_idx); + assert!(pipes.get(pipe_idx).is_none()); + } + } + + #[test] + fn direct_host_pipe_close_write_detects_recursive_ancillary_collection() { + let _guard = SCM_RIGHTS_LIFETIME_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut proc = Process::new(70_922); + let mut host = MockHostIO::new(); + let mut locks = AdvisoryLockManager::new(); + let (carried_fd, carried_ofd_id, carried_host_handle) = open_locked_host_scm_fd( + &mut proc, + &mut locks, + &mut host, + b"/tmp/scm-direct-pipe-close-write", + ); + let deferred_before = crate::pipe::deferred_in_flight_release_state(); + let pipe_idx = + unsafe { crate::pipe::global_pipe_table().alloc(crate::pipe::PipeBuffer::new(16)) }; + + // Build the minimal cycle that free_if_closed() is responsible for + // collecting: the carrier's only remaining reader is the read end + // carried by its own ancillary record. + let self_ofd_id = OfdId(u64::MAX - 70_922); + let mut self_reader = crate::pipe::InFlightFd::new( + self_ofd_id, + None, + FileType::Pipe, + O_RDONLY, + -((pipe_idx as i64) + 1), + 0, + b"/dev/pipe".to_vec(), + ); + self_reader.pipe_ref_kind = Some(crate::pipe::InFlightPipeRefKind::Read { + fifo_read_only: false, + }); + self_reader.retain_reference().unwrap(); + let mut carried = snapshot_scm_rights_fd(&proc, carried_fd).unwrap(); + carried.retain_reference().unwrap(); + assert_eq!( + unsafe { crate::pipe::global_pipe_table() }.write_retained_ancillary( + pipe_idx, + b"Q", + vec![self_reader, carried], + ), + Ok(1), + ); + sys_close_with_locks(&mut proc, &mut locks, &mut host, carried_fd).unwrap(); + + { + let pipes = unsafe { crate::pipe::global_pipe_table() }; + // Remove the external reader without sweeping yet; the in-flight + // self-reference keeps read_count nonzero. + pipes.get_mut(pipe_idx).unwrap().close_read_end(); + assert!(pipes.get(pipe_idx).unwrap().has_ancillary()); + assert!(!crate::pipe::has_deferred_in_flight_releases()); + + // This mirrors kernel_pipe_close_write. Its free_if_closed sweep, + // not close_write_end itself, discovers the unreachable cycle. + pipes.get_mut(pipe_idx).unwrap().close_write_end(); + pipes.free_if_closed(pipe_idx); + } + assert!(crate::pipe::has_deferred_in_flight_releases()); + assert!(!crate::ofd::has_in_flight_ofd(self_ofd_id)); + assert!(!crate::ofd::has_in_flight_ofd(carried_ofd_id)); + assert_eq!(crate::ofd::host_handle_ref_count(carried_host_handle), 1); + assert_eq!(locks.len(), 1); + + finish_scm_rights_cleanup_if_pending(|| { + finish_scm_rights_cleanup(&mut locks, &mut host); + }); + assert_finished_host_scm_release( + &locks, + &host, + carried_ofd_id, + carried_host_handle, + deferred_before, + ); + assert!( + unsafe { crate::pipe::global_pipe_table() } + .get(pipe_idx) + .is_none() + ); + } + #[test] fn synthetic_regular_ofd_lock_survives_fork_until_final_close() { let mut parent = Process::new(86); let mut host = MockHostIO::new(); let first = sys_open(&mut parent, &mut host, b"/etc/mtab", O_RDONLY, 0).unwrap(); - let independent = - sys_open(&mut parent, &mut host, b"/etc/mtab", O_RDONLY, 0).unwrap(); + let independent = sys_open(&mut parent, &mut host, b"/etc/mtab", O_RDONLY, 0).unwrap(); let (first_ofd_id, first_file_id, independent_ofd_id, independent_file_id) = { let first_entry = parent.fd_table.get(first).unwrap(); let first_ofd = parent.ofd_table.get(first_entry.ofd_ref.0).unwrap(); let independent_entry = parent.fd_table.get(independent).unwrap(); - let independent_ofd = parent - .ofd_table - .get(independent_entry.ofd_ref.0) - .unwrap(); + let independent_ofd = parent.ofd_table.get(independent_entry.ofd_ref.0).unwrap(); ( first_ofd.ofd_id, first_ofd.file_id, @@ -21323,16 +22904,14 @@ mod tests { let first_idx = first .ofd_table .create(FileType::Regular, O_RDWR, 101, b"/first".to_vec()); - let second_idx = second - .ofd_table - .create(FileType::Regular, O_RDWR, 202, b"/second".to_vec()); + let second_idx = + second + .ofd_table + .create(FileType::Regular, O_RDWR, 202, b"/second".to_vec()); assert_eq!(first_idx, second_idx); first.ofd_table.get_mut(first_idx).unwrap().file_id = Some(file); second.ofd_table.get_mut(second_idx).unwrap().file_id = Some(file); - let first_fd = first - .fd_table - .alloc(OpenFileDescRef(first_idx), 0) - .unwrap(); + let first_fd = first.fd_table.alloc(OpenFileDescRef(first_idx), 0).unwrap(); let second_fd = second .fd_table .alloc(OpenFileDescRef(second_idx), 0) @@ -21400,12 +22979,9 @@ mod tests { let proc = Process::new(1); let mut host = MockHostIO::new(); - assert!(sys_clock_gettime( - &proc, - &mut host, - wasm_posix_shared::clock::CLOCK_BOOTTIME, - ) - .is_ok()); + assert!( + sys_clock_gettime(&proc, &mut host, wasm_posix_shared::clock::CLOCK_BOOTTIME,).is_ok() + ); // Linux's clock_getcpuclockid() encoding for PID 1. assert!(sys_clock_gettime(&proc, &mut host, (-2_i32 * 8 + 2) as u32).is_ok()); } @@ -21448,10 +23024,7 @@ mod tests { sys_clock_gettime(&proc, &mut host, 10), Err(Errno::EINVAL), )); - assert!(matches!( - sys_clock_getres(&proc, 10), - Err(Errno::EINVAL), - )); + assert!(matches!(sys_clock_getres(&proc, 10), Err(Errno::EINVAL),)); } #[test] @@ -21642,12 +23215,9 @@ mod tests { flags: u32, host_handle: i64, ) -> i32 { - let ofd = proc.ofd_table.create( - file_type, - flags, - host_handle, - b"/mapped".to_vec(), - ); + let ofd = proc + .ofd_table + .create(file_type, flags, host_handle, b"/mapped".to_vec()); proc.fd_table.alloc(OpenFileDescRef(ofd), 0).unwrap() } @@ -21917,8 +23487,7 @@ mod tests { let mut proc = Process::new(PID); let mut host = MockHostIO::new(); let cloexec_listener = - sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0) - .unwrap(); + sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0).unwrap(); let addr = test_unix_addr(path); sys_bind(&mut proc, &mut host, cloexec_listener, &addr).unwrap(); sys_listen(&mut proc, &mut host, cloexec_listener, 4).unwrap(); @@ -21946,17 +23515,22 @@ mod tests { assert!(proc.fd_table.get(cloexec_listener).is_err()); assert_eq!(test_socket_idx(&proc, listener), listener_sock_idx); - let backlog = &unsafe { crate::socket::shared_listener_backlog_table() }.entries - [shared_idx]; + let backlog = + &unsafe { crate::socket::shared_listener_backlog_table() }.entries[shared_idx]; assert!(backlog.in_use); assert_eq!(backlog.ref_count, 1); assert_eq!(backlog.queue.len(), 1); - assert!(unsafe { crate::unix_socket::global_unix_socket_registry() } - .lookup(&resolved) - .is_some()); + assert!( + unsafe { crate::unix_socket::global_unix_socket_registry() } + .lookup(&resolved) + .is_some() + ); let accepted = sys_accept(&mut proc, &mut host, listener).unwrap(); - assert_eq!(sys_send(&mut proc, &mut host, client, b"after exec", 0), Ok(10)); + assert_eq!( + sys_send(&mut proc, &mut host, client, b"after exec", 0), + Ok(10) + ); let mut buf = [0u8; 10]; assert_eq!( sys_recv(&mut proc, &mut host, accepted, &mut buf, 0), @@ -21984,8 +23558,7 @@ mod tests { let mut proc = Process::new(PID); let mut host = MockHostIO::new(); let listener = - sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0) - .unwrap(); + sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0).unwrap(); let addr = test_unix_addr(path); sys_bind(&mut proc, &mut host, listener, &addr).unwrap(); sys_listen(&mut proc, &mut host, listener, 4).unwrap(); @@ -22000,8 +23573,8 @@ mod tests { .shared_backlog_idx .unwrap(); let (recv_pipe_idx, send_pipe_idx) = { - let backlog = &unsafe { crate::socket::shared_listener_backlog_table() }.entries - [shared_idx]; + let backlog = + &unsafe { crate::socket::shared_listener_backlog_table() }.entries[shared_idx]; assert!(backlog.in_use); assert_eq!(backlog.ref_count, 1); assert_eq!(backlog.queue.len(), 1); @@ -22015,11 +23588,13 @@ mod tests { assert!(proc.fd_table.get(listener).is_err()); assert!(proc.sockets.get(listener_sock_idx).is_none()); - assert!(unsafe { crate::unix_socket::global_unix_socket_registry() } - .lookup(&resolved) - .is_none()); - let backlog = &unsafe { crate::socket::shared_listener_backlog_table() }.entries - [shared_idx]; + assert!( + unsafe { crate::unix_socket::global_unix_socket_registry() } + .lookup(&resolved) + .is_none() + ); + let backlog = + &unsafe { crate::socket::shared_listener_backlog_table() }.entries[shared_idx]; assert!(!backlog.in_use); assert_eq!(backlog.ref_count, 0); assert!(backlog.queue.is_empty()); @@ -22067,8 +23642,7 @@ mod tests { let pid = proc.pid; commit_exec_state(&mut proc, &mut host, pid).unwrap(); - let (count, events) = - sys_epoll_pwait(&mut proc, &mut host, epollfd, 1, 0, None).unwrap(); + let (count, events) = sys_epoll_pwait(&mut proc, &mut host, epollfd, 1, 0, None).unwrap(); assert_eq!(count, 1); assert_eq!(events[0].1, 0xfeed); @@ -22083,7 +23657,10 @@ mod tests { let mut signal_info = [0u8; 128]; sys_read(&mut proc, &mut host, signalfd, &mut signal_info).unwrap(); - assert_eq!(u32::from_le_bytes(signal_info[0..4].try_into().unwrap()), SIGINT); + assert_eq!( + u32::from_le_bytes(signal_info[0..4].try_into().unwrap()), + SIGINT + ); let mut suffix = [0u8; 4]; sys_read(&mut proc, &mut host, memfd, &mut suffix).unwrap(); @@ -22168,7 +23745,7 @@ mod tests { proc.posix_timers.push(Some(PosixTimerState { clock_id: 0, sigev_signo: SIGINT, - sigev_value: 0, + sigev_value_bits: 0, sigev_notify: 0, sigev_tid: 0, interval_sec: 1, @@ -22207,20 +23784,21 @@ mod tests { let mut host = MockHostIO::new(); let pid = proc.pid; proc.main_thread_signals.raise_with_value(32, 77); - proc.posix_timers.push(Some(crate::process::PosixTimerState { - clock_id: 1, - sigev_signo: 10, - sigev_value: 88, - sigev_notify: 4, - sigev_tid: pid, - interval_sec: 0, - interval_nsec: 0, - value_sec: 0, - value_nsec: 1, - notification_pending: true, - overrun_current: 0, - overrun_last: 0, - })); + proc.posix_timers + .push(Some(crate::process::PosixTimerState { + clock_id: 1, + sigev_signo: 10, + sigev_value_bits: 88, + sigev_notify: 4, + sigev_tid: pid, + interval_sec: 0, + interval_nsec: 0, + value_sec: 0, + value_nsec: 1, + notification_pending: true, + overrun_current: 0, + overrun_last: 0, + })); proc.main_thread_signals.raise_timer(10, 88, 0); proc.signals.raise(SIGTERM); @@ -22248,9 +23826,7 @@ mod tests { table.mutex_lock(owned_mutex, EXEC_PID).unwrap(); table.mutex_lock(cond_mutex, EXEC_PID).unwrap(); - table - .cond_wait_begin(cond, cond_mutex, EXEC_PID) - .unwrap(); + table.cond_wait_begin(cond, cond_mutex, EXEC_PID).unwrap(); assert_eq!(table.barrier_wait(barrier, EXEC_PID), Err(Errno::EAGAIN)); (owned_mutex, cond_mutex, cond, barrier) }; @@ -22282,12 +23858,9 @@ mod tests { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let ofd_idx = proc.ofd_table.create( - FileType::Directory, - O_RDONLY, - 55, - b"/tmp".to_vec(), - ); + let ofd_idx = proc + .ofd_table + .create(FileType::Directory, O_RDONLY, 55, b"/tmp".to_vec()); { let ofd = proc.ofd_table.get_mut(ofd_idx).unwrap(); ofd.dir_host_handle = 77; @@ -22299,10 +23872,7 @@ mod tests { name: b"pending.txt".to_vec(), }); } - let dir_fd = proc - .fd_table - .alloc(OpenFileDescRef(ofd_idx), 0) - .unwrap(); + let dir_fd = proc.fd_table.alloc(OpenFileDescRef(ofd_idx), 0).unwrap(); proc.dir_streams.push(Some(DirStream { host_handle: 88, path: b"/tmp".to_vec(), @@ -22321,7 +23891,9 @@ mod tests { assert_eq!(ofd.dir_synth_state, 2); assert_eq!(ofd.dir_entry_offset, 9); assert_eq!( - ofd.dir_pending_entry.as_ref().map(|entry| entry.name.as_slice()), + ofd.dir_pending_entry + .as_ref() + .map(|entry| entry.name.as_slice()), Some(b"pending.txt".as_slice()), ); @@ -22524,7 +24096,11 @@ mod tests { .unwrap(); let sock_idx = (-(ofd.host_handle + 1)) as usize; let sock = proc.sockets.get(sock_idx).unwrap(); - (sock_idx, sock.send_buf_idx.unwrap(), sock.recv_buf_idx.unwrap()) + ( + sock_idx, + sock.send_buf_idx.unwrap(), + sock.recv_buf_idx.unwrap(), + ) }; sys_shutdown(&mut proc, &mut host, fd0, SHUT_RDWR).unwrap(); @@ -23034,10 +24610,10 @@ mod tests { #[test] fn test_external_nonblocking_connect_reports_pending_errnos_once_then_writable() { + use wasm_posix_shared::WasmPollFd; use wasm_posix_shared::fcntl_cmd::F_SETFL; use wasm_posix_shared::poll::POLLOUT; use wasm_posix_shared::socket::*; - use wasm_posix_shared::WasmPollFd; let mut proc = Process::new(1); let mut host = MockHostIO::new(); @@ -23072,26 +24648,23 @@ mod tests { revents: 0, }; assert_eq!( - sys_poll( - &mut proc, - &mut host, - core::slice::from_mut(&mut pollfd), - 0, - ) - .unwrap(), + sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0,).unwrap(), 1, ); assert_ne!(pollfd.revents & POLLOUT, 0); - assert_eq!(sys_getsockopt(&mut proc, fd, SOL_SOCKET, SO_ERROR).unwrap(), 0); + assert_eq!( + sys_getsockopt(&mut proc, fd, SOL_SOCKET, SO_ERROR).unwrap(), + 0 + ); assert_eq!(host.net_connect_calls.len(), 1); } #[test] fn test_external_nonblocking_connect_poll_failure_caches_and_clears_so_error() { + use wasm_posix_shared::WasmPollFd; use wasm_posix_shared::fcntl_cmd::F_SETFL; use wasm_posix_shared::poll::{POLLERR, POLLOUT}; use wasm_posix_shared::socket::*; - use wasm_posix_shared::WasmPollFd; let mut proc = Process::new(1); let mut host = MockHostIO::new(); @@ -23113,13 +24686,7 @@ mod tests { revents: 0, }; assert_eq!( - sys_poll( - &mut proc, - &mut host, - core::slice::from_mut(&mut pollfd), - 0, - ) - .unwrap(), + sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0,).unwrap(), 1, ); assert_ne!(pollfd.revents & POLLERR, 0); @@ -23207,9 +24774,9 @@ mod tests { fn test_poll_connected_inet6_datagram_matches_recv_filter() { let mut proc = Process::new(9032); let mut host = MockHostIO::new(); + use wasm_posix_shared::WasmPollFd; use wasm_posix_shared::poll::POLLIN; use wasm_posix_shared::socket::*; - use wasm_posix_shared::WasmPollFd; let fd = sys_socket(&mut proc, &mut host, AF_INET6, SOCK_DGRAM, 0).unwrap(); let entry = proc.fd_table.get(fd).unwrap(); @@ -23220,7 +24787,7 @@ mod tests { sock.state = crate::socket::SocketState::Connected; sock.peer_addr6 = loopback; sock.peer_port = 7000; - let mut wrong = crate::socket::Datagram { + sock.dgram_queue.push(crate::socket::Datagram { data: b"wrong".to_vec(), src_addr: [0; 4], src_addr6: [0; 16], @@ -23233,8 +24800,7 @@ mod tests { src_uid: 0, src_gid: 0, ancillary_fds: Vec::new(), - }; - sock.dgram_queue.push(wrong.clone()); + }); let mut pfd = WasmPollFd { fd, @@ -23245,8 +24811,24 @@ mod tests { sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pfd), 0).unwrap(), 0 ); - wrong.src_addr6 = loopback; - proc.sockets.get_mut(idx).unwrap().dgram_queue.push(wrong); + proc.sockets + .get_mut(idx) + .unwrap() + .dgram_queue + .push(crate::socket::Datagram { + data: b"right".to_vec(), + src_addr: [0; 4], + src_addr6: loopback, + dst_addr: [0; 4], + dst_addr6: loopback, + src_port: 7000, + src_sock_idx: None, + ipv6_tclass: 0, + src_pid: 0, + src_uid: 0, + src_gid: 0, + ancillary_fds: Vec::new(), + }); assert_eq!( sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pfd), 0).unwrap(), 1 @@ -23257,10 +24839,11 @@ mod tests { #[test] fn test_poll_connected_unix_datagram_includes_peer_pid() { let mut proc = Process::new(9033); + let owner_pid = proc.pid; let mut host = MockHostIO::new(); + use wasm_posix_shared::WasmPollFd; use wasm_posix_shared::poll::POLLIN; use wasm_posix_shared::socket::*; - use wasm_posix_shared::WasmPollFd; let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); let entry = proc.fd_table.get(fd).unwrap(); @@ -23269,7 +24852,7 @@ mod tests { let sock = proc.sockets.get_mut(idx).unwrap(); sock.state = crate::socket::SocketState::Connected; sock.peer_idx = Some(7); - let mut datagram = crate::socket::Datagram { + sock.dgram_queue.push(crate::socket::Datagram { data: b"peer".to_vec(), src_addr: [0; 4], src_addr6: [0; 16], @@ -23282,8 +24865,7 @@ mod tests { src_uid: 0, src_gid: 0, ancillary_fds: Vec::new(), - }; - sock.dgram_queue.push(datagram.clone()); + }); let mut pfd = WasmPollFd { fd, events: POLLIN, @@ -23293,8 +24875,24 @@ mod tests { sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pfd), 0).unwrap(), 0 ); - datagram.src_pid = proc.pid; - proc.sockets.get_mut(idx).unwrap().dgram_queue.push(datagram); + proc.sockets + .get_mut(idx) + .unwrap() + .dgram_queue + .push(crate::socket::Datagram { + data: b"peer".to_vec(), + src_addr: [0; 4], + src_addr6: [0; 16], + dst_addr: [0; 4], + dst_addr6: [0; 16], + src_port: 0, + src_sock_idx: Some(7), + ipv6_tclass: 0, + src_pid: owner_pid, + src_uid: 0, + src_gid: 0, + ancillary_fds: Vec::new(), + }); assert_eq!( sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pfd), 0).unwrap(), 1 @@ -23562,8 +25160,7 @@ mod tests { host.set_file_with_owner(b"/tmp/file", 123, 456, 0o640, b"data"); let fd = sys_open(&mut proc, &mut host, b"/tmp/file", O_RDONLY, 0).unwrap(); - let via_empty = - sys_fstatat(&mut proc, &mut host, fd, b"", AT_EMPTY_PATH).unwrap(); + let via_empty = sys_fstatat(&mut proc, &mut host, fd, b"", AT_EMPTY_PATH).unwrap(); let via_fstat = sys_fstat(&mut proc, &mut host, fd).unwrap(); assert_eq!(via_empty.st_ino, via_fstat.st_ino); assert_eq!(via_empty.st_mode, via_fstat.st_mode); @@ -23572,8 +25169,7 @@ mod tests { assert_eq!(via_empty.st_size, via_fstat.st_size); let (pipe_fd, _write_fd) = sys_pipe(&mut proc).unwrap(); - let pipe = - sys_fstatat(&mut proc, &mut host, pipe_fd, b"", AT_EMPTY_PATH).unwrap(); + let pipe = sys_fstatat(&mut proc, &mut host, pipe_fd, b"", AT_EMPTY_PATH).unwrap(); assert_eq!(pipe.st_mode & S_IFMT, S_IFIFO); proc.cwd = b"/home/user".to_vec(); @@ -23620,14 +25216,8 @@ mod tests { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - assert!( - sys_statx(&mut proc, &mut host, AT_FDCWD, b"/tmp/file", 0x2000, 0) - .is_ok() - ); - assert!( - sys_statx(&mut proc, &mut host, AT_FDCWD, b"/tmp/file", 0x4000, 0) - .is_ok() - ); + assert!(sys_statx(&mut proc, &mut host, AT_FDCWD, b"/tmp/file", 0x2000, 0).is_ok()); + assert!(sys_statx(&mut proc, &mut host, AT_FDCWD, b"/tmp/file", 0x4000, 0).is_ok()); assert!(matches!( sys_statx(&mut proc, &mut host, AT_FDCWD, b"/tmp/file", 0x6000, 0), Err(Errno::EINVAL) @@ -23786,25 +25376,22 @@ mod tests { } #[test] - fn test_legacy_termios_round_trip_preserves_all_flags_and_control_bytes() { + fn test_custom_termios_round_trip_preserves_full_musl_layout() { let mut proc = terminal_process(1); - let mut attrs = [0u8; 48]; + let mut attrs = [0u8; crate::terminal::TERMIOS_SIZE]; attrs[0..4].copy_from_slice(&0x0102_0304u32.to_le_bytes()); attrs[4..8].copy_from_slice(&0x1112_1314u32.to_le_bytes()); attrs[8..12].copy_from_slice(&0x2122_2324u32.to_le_bytes()); attrs[12..16].copy_from_slice(&0x3132_3334u32.to_le_bytes()); - for (index, byte) in attrs[16..48].iter_mut().enumerate() { + attrs[16] = 7; + for (index, byte) in attrs[17..49].iter_mut().enumerate() { *byte = index as u8; } + attrs[52..56].copy_from_slice(&0x4142_4344u32.to_le_bytes()); + attrs[56..60].copy_from_slice(&0x5152_5354u32.to_le_bytes()); - sys_tcsetattr( - &mut proc, - 0, - crate::terminal::TCSANOW, - &attrs, - ) - .unwrap(); - let mut observed = [0u8; 48]; + sys_tcsetattr(&mut proc, 0, crate::terminal::TCSANOW, &attrs).unwrap(); + let mut observed = [0u8; crate::terminal::TERMIOS_SIZE]; sys_tcgetattr(&mut proc, 0, &mut observed).unwrap(); assert_eq!(observed, attrs); } @@ -23825,14 +25412,7 @@ mod tests { attrs[52..56].copy_from_slice(&0x4142_4344u32.to_le_bytes()); attrs[56..60].copy_from_slice(&0x5152_5354u32.to_le_bytes()); - sys_ioctl( - &mut proc, - &mut host, - 0, - crate::terminal::TCSETS, - &mut attrs, - ) - .unwrap(); + sys_ioctl(&mut proc, &mut host, 0, crate::terminal::TCSETS, &mut attrs).unwrap(); let mut observed = [0u8; crate::terminal::TERMIOS_SIZE]; sys_ioctl( &mut proc, @@ -23852,16 +25432,23 @@ mod tests { let bad_fd = 99; let regular = sys_open(&mut proc, &mut host, b"/tmp/file", O_RDWR, 0).unwrap(); - // The legacy API requires its complete 48-byte layout for get and - // every set action. - assert_eq!(sys_tcgetattr(&mut proc, 0, &mut [0; 47]), Err(Errno::EINVAL)); + // Both entry points require the complete native musl layout. + assert_eq!( + sys_tcgetattr(&mut proc, 0, &mut [0; crate::terminal::TERMIOS_SIZE - 1],), + Err(Errno::EINVAL), + ); for action in [ crate::terminal::TCSANOW, crate::terminal::TCSADRAIN, crate::terminal::TCSAFLUSH, ] { assert_eq!( - sys_tcsetattr(&mut proc, 0, action, &[0; 47]), + sys_tcsetattr( + &mut proc, + 0, + action, + &[0; crate::terminal::TERMIOS_SIZE - 1], + ), Err(Errno::EINVAL), ); } @@ -23905,10 +25492,10 @@ mod tests { Err(Errno::EINVAL), ); - // Bad descriptors are symmetric across legacy get/set and ioctl + // Bad descriptors are symmetric across custom get/set and ioctl // get/set/flush. assert_eq!( - sys_tcgetattr(&mut proc, bad_fd, &mut [0; 48]), + sys_tcgetattr(&mut proc, bad_fd, &mut [0; crate::terminal::TERMIOS_SIZE],), Err(Errno::EBADF), ); for action in [ @@ -23917,7 +25504,12 @@ mod tests { crate::terminal::TCSAFLUSH, ] { assert_eq!( - sys_tcsetattr(&mut proc, bad_fd, action, &[0; 48]), + sys_tcsetattr( + &mut proc, + bad_fd, + action, + &[0; crate::terminal::TERMIOS_SIZE], + ), Err(Errno::EBADF), ); } @@ -23960,7 +25552,7 @@ mod tests { // A valid regular descriptor is consistently ENOTTY. assert_eq!( - sys_tcgetattr(&mut proc, regular, &mut [0; 48]), + sys_tcgetattr(&mut proc, regular, &mut [0; crate::terminal::TERMIOS_SIZE],), Err(Errno::ENOTTY), ); for action in [ @@ -23969,7 +25561,12 @@ mod tests { crate::terminal::TCSAFLUSH, ] { assert_eq!( - sys_tcsetattr(&mut proc, regular, action, &[0; 48]), + sys_tcsetattr( + &mut proc, + regular, + action, + &[0; crate::terminal::TERMIOS_SIZE], + ), Err(Errno::ENOTTY), ); } @@ -24024,7 +25621,7 @@ mod tests { proc.terminal.process_input_byte(byte); } - let mut attrs = [0; 48]; + let mut attrs = [0; crate::terminal::TERMIOS_SIZE]; sys_tcgetattr(&mut proc, 0, &mut attrs).unwrap(); let lflag = u32::from_le_bytes(attrs[12..16].try_into().unwrap()); attrs[12..16].copy_from_slice(&(lflag & !crate::terminal::ICANON).to_le_bytes()); @@ -24036,29 +25633,14 @@ mod tests { // Exercise the destructive action in the reverse direction, // with bytes accepted before raw mode and still unread there. - attrs[12..16] - .copy_from_slice(&(lflag | crate::terminal::ICANON).to_le_bytes()); - sys_tcsetattr( - &mut proc, - 0, - crate::terminal::TCSANOW, - &attrs, - ) - .unwrap(); + attrs[12..16].copy_from_slice(&(lflag | crate::terminal::ICANON).to_le_bytes()); + sys_tcsetattr(&mut proc, 0, crate::terminal::TCSANOW, &attrs).unwrap(); for &byte in b"raw" { proc.terminal.process_input_byte(byte); } - attrs[12..16] - .copy_from_slice(&(lflag & !crate::terminal::ICANON).to_le_bytes()); - sys_tcsetattr( - &mut proc, - 0, - crate::terminal::TCSANOW, - &attrs, - ) - .unwrap(); - attrs[12..16] - .copy_from_slice(&(lflag | crate::terminal::ICANON).to_le_bytes()); + attrs[12..16].copy_from_slice(&(lflag & !crate::terminal::ICANON).to_le_bytes()); + sys_tcsetattr(&mut proc, 0, crate::terminal::TCSANOW, &attrs).unwrap(); + attrs[12..16].copy_from_slice(&(lflag | crate::terminal::ICANON).to_le_bytes()); sys_tcsetattr(&mut proc, 0, action, &attrs).unwrap(); assert!(proc.terminal.cooked_buffer.is_empty()); continue; @@ -24084,7 +25666,7 @@ mod tests { } let cooked_before = proc.terminal.cooked_buffer.clone(); let line_before = proc.terminal.line_buffer.clone(); - let mut attrs_before = [0; 48]; + let mut attrs_before = [0; crate::terminal::TERMIOS_SIZE]; sys_tcgetattr(&mut proc, 0, &mut attrs_before).unwrap(); let mut changed = attrs_before; let lflag = u32::from_le_bytes(changed[12..16].try_into().unwrap()); @@ -24094,7 +25676,7 @@ mod tests { sys_tcsetattr(&mut proc, 0, u32::MAX, &changed), Err(Errno::EINVAL), ); - let mut attrs_after = [0; 48]; + let mut attrs_after = [0; crate::terminal::TERMIOS_SIZE]; sys_tcgetattr(&mut proc, 0, &mut attrs_after).unwrap(); assert_eq!(attrs_after, attrs_before); assert_eq!(proc.terminal.cooked_buffer, cooked_before); @@ -24113,13 +25695,7 @@ mod tests { let line_before = proc.terminal.line_buffer.clone(); let mut arg = queue.to_le_bytes(); - let result = sys_ioctl( - &mut proc, - &mut host, - 0, - crate::terminal::TCFLSH, - &mut arg, - ); + let result = sys_ioctl(&mut proc, &mut host, 0, crate::terminal::TCFLSH, &mut arg); if queue == 99 { assert_eq!(result, Err(Errno::EINVAL)); } else { @@ -24153,14 +25729,7 @@ mod tests { } let mut attrs = [0; crate::terminal::TERMIOS_SIZE]; - sys_ioctl( - &mut proc, - &mut host, - 0, - crate::terminal::TCGETS, - &mut attrs, - ) - .unwrap(); + sys_ioctl(&mut proc, &mut host, 0, crate::terminal::TCGETS, &mut attrs).unwrap(); let lflag = u32::from_le_bytes(attrs[12..16].try_into().unwrap()); attrs[12..16].copy_from_slice(&(lflag & !crate::terminal::ICANON).to_le_bytes()); sys_ioctl(&mut proc, &mut host, 0, request, &mut attrs).unwrap(); @@ -24169,31 +25738,14 @@ mod tests { assert!(proc.terminal.cooked_buffer.is_empty()); assert!(proc.terminal.line_buffer.is_empty()); - attrs[12..16] - .copy_from_slice(&(lflag | crate::terminal::ICANON).to_le_bytes()); - sys_ioctl( - &mut proc, - &mut host, - 0, - crate::terminal::TCSETS, - &mut attrs, - ) - .unwrap(); + attrs[12..16].copy_from_slice(&(lflag | crate::terminal::ICANON).to_le_bytes()); + sys_ioctl(&mut proc, &mut host, 0, crate::terminal::TCSETS, &mut attrs).unwrap(); for &byte in b"raw" { proc.terminal.process_input_byte(byte); } - attrs[12..16] - .copy_from_slice(&(lflag & !crate::terminal::ICANON).to_le_bytes()); - sys_ioctl( - &mut proc, - &mut host, - 0, - crate::terminal::TCSETS, - &mut attrs, - ) - .unwrap(); - attrs[12..16] - .copy_from_slice(&(lflag | crate::terminal::ICANON).to_le_bytes()); + attrs[12..16].copy_from_slice(&(lflag & !crate::terminal::ICANON).to_le_bytes()); + sys_ioctl(&mut proc, &mut host, 0, crate::terminal::TCSETS, &mut attrs).unwrap(); + attrs[12..16].copy_from_slice(&(lflag | crate::terminal::ICANON).to_le_bytes()); sys_ioctl(&mut proc, &mut host, 0, request, &mut attrs).unwrap(); assert!(proc.terminal.cooked_buffer.is_empty()); continue; @@ -24246,8 +25798,7 @@ mod tests { assert_eq!(pty.read_slave(1), Ok(b"x".to_vec())); assert_eq!(pty.available(), 2); - pty.set_custom_canonical(terminal_fd, true, action) - .unwrap(); + pty.set_custom_canonical(terminal_fd, true, action).unwrap(); if action == crate::terminal::TCSAFLUSH { assert_eq!(pty.available(), 0); assert_eq!(pty.slave_revents() & POLLIN, 0); @@ -24290,8 +25841,7 @@ mod tests { pty.write_input(b"xyz"); assert_eq!(pty.read_slave(1), Ok(b"x".to_vec())); - pty.set_ioctl_canonical(terminal_fd, true, request) - .unwrap(); + pty.set_ioctl_canonical(terminal_fd, true, request).unwrap(); if request == crate::terminal::TCSETSF { assert_eq!(pty.available(), 0); @@ -24342,8 +25892,7 @@ mod tests { let before = pty.custom_attrs(terminal_fd); let mut changed = before; let lflag = u32::from_le_bytes(changed[12..16].try_into().unwrap()); - changed[12..16] - .copy_from_slice(&(lflag & !crate::terminal::ICANON).to_le_bytes()); + changed[12..16].copy_from_slice(&(lflag & !crate::terminal::ICANON).to_le_bytes()); assert_eq!( sys_tcsetattr(&mut pty.proc, terminal_fd, u32::MAX, &changed), @@ -24357,12 +25906,8 @@ mod tests { // The partial canonical line was not lost or made readable. assert_eq!(pty.available(), 0); - pty.set_custom_canonical( - terminal_fd, - false, - crate::terminal::TCSANOW, - ) - .unwrap(); + pty.set_custom_canonical(terminal_fd, false, crate::terminal::TCSANOW) + .unwrap(); assert_eq!(pty.read_slave(8), Ok(b"q".to_vec())); } } @@ -24393,10 +25938,7 @@ mod tests { let input_preserved = queue == 1 || queue == 99; let output_preserved = queue == 0 || queue == 99; assert_eq!(pty.available(), if input_preserved { 5 } else { 0 }); - assert_eq!( - pty.slave_revents() & POLLIN != 0, - input_preserved, - ); + assert_eq!(pty.slave_revents() & POLLIN != 0, input_preserved,); if output_preserved { assert_eq!(pty.read_master(16), Ok(b"screen".to_vec())); } else { @@ -24405,12 +25947,8 @@ mod tests { if input_preserved { assert_eq!(pty.read_slave(16), Ok(b"line\n".to_vec())); - pty.set_ioctl_canonical( - terminal_fd, - false, - crate::terminal::TCSETS, - ) - .unwrap(); + pty.set_ioctl_canonical(terminal_fd, false, crate::terminal::TCSETS) + .unwrap(); assert_eq!(pty.read_slave(16), Ok(b"partial".to_vec())); } else { assert_eq!(pty.read_slave(16), Err(Errno::EAGAIN)); @@ -24523,7 +26061,7 @@ mod tests { sys_write(&mut proc, &mut host, write_fd, b"hello").unwrap(); // FIONREAD should return 5 let mut buf = [0u8; 4]; - sys_ioctl(&mut proc, &mut host,read_fd, 0x541B, &mut buf).unwrap(); + sys_ioctl(&mut proc, &mut host, read_fd, 0x541B, &mut buf).unwrap(); let avail = i32::from_le_bytes(buf); assert_eq!(avail, 5); } @@ -24551,7 +26089,7 @@ mod tests { // No OOB pending: SIOCATMARK returns 0 let mut iobuf = [0u8; 4]; - sys_ioctl(&mut proc, &mut host,fd1, 0x8905, &mut iobuf).unwrap(); + sys_ioctl(&mut proc, &mut host, fd1, 0x8905, &mut iobuf).unwrap(); assert_eq!(i32::from_le_bytes(iobuf), 0); // Send OOB byte from fd0 @@ -24560,7 +26098,7 @@ mod tests { // SIOCATMARK on fd1 returns 1 let mut iobuf = [0u8; 4]; - sys_ioctl(&mut proc, &mut host,fd1, 0x8905, &mut iobuf).unwrap(); + sys_ioctl(&mut proc, &mut host, fd1, 0x8905, &mut iobuf).unwrap(); assert_eq!(i32::from_le_bytes(iobuf), 1); // Recv OOB byte from fd1 @@ -24571,7 +26109,7 @@ mod tests { // After reading OOB, SIOCATMARK returns 0 let mut iobuf = [0u8; 4]; - sys_ioctl(&mut proc, &mut host,fd1, 0x8905, &mut iobuf).unwrap(); + sys_ioctl(&mut proc, &mut host, fd1, 0x8905, &mut iobuf).unwrap(); assert_eq!(i32::from_le_bytes(iobuf), 0); } @@ -24612,22 +26150,28 @@ mod tests { sys_send(&mut proc, &mut host, sender_fd, b"X", MSG_OOB).unwrap_err(), Errno::EPIPE, ); - assert!(proc - .signals - .is_pending(wasm_posix_shared::signal::SIGPIPE)); - assert!(proc.sockets.get(replacement_idx).unwrap().oob_byte.is_none()); + assert!(proc.signals.is_pending(wasm_posix_shared::signal::SIGPIPE)); + assert!( + proc.sockets + .get(replacement_idx) + .unwrap() + .oob_byte + .is_none() + ); } // ---- prctl tests ---- #[test] fn test_prctl_set_get_name() { + use wasm_posix_shared::{kernel_scratch_wire, prctl}; + let mut proc = Process::new(1); - let mut buf = [0u8; 16]; + let mut buf = [0u8; kernel_scratch_wire::PRCTL_NAME_BYTES as usize]; buf[..5].copy_from_slice(b"hello"); - sys_prctl(&mut proc, 15, 0, &mut buf).unwrap(); // PR_SET_NAME - let mut out = [0u8; 16]; - sys_prctl(&mut proc, 16, 0, &mut out).unwrap(); // PR_GET_NAME + sys_prctl(&mut proc, prctl::PR_SET_NAME, 0, &mut buf).unwrap(); + let mut out = [0u8; kernel_scratch_wire::PRCTL_NAME_BYTES as usize]; + sys_prctl(&mut proc, prctl::PR_GET_NAME, 0, &mut out).unwrap(); assert_eq!(&out[..5], b"hello"); assert_eq!(out[5], 0); } @@ -24635,7 +26179,8 @@ mod tests { #[test] fn test_prctl_unknown_is_noop() { let mut proc = Process::new(1); - let mut buf = [0u8; 16]; + let mut buf = + [0u8; wasm_posix_shared::kernel_scratch_wire::PRCTL_NAME_BYTES as usize]; assert!(sys_prctl(&mut proc, 999, 0, &mut buf).is_ok()); } @@ -24817,7 +26362,10 @@ mod tests { #[test] fn test_sysconf_arg_max_matches_exec_metadata_boundary() { - assert_eq!(sys_sysconf(0), Ok(4 * 1024 * 1024)); // _SC_ARG_MAX + assert_eq!( + sys_sysconf(0), + Ok(wasm_posix_shared::platform_limits::ARG_MAX_BYTES as i64) + ); // _SC_ARG_MAX } #[test] @@ -24863,7 +26411,10 @@ mod tests { sys_pathconf(&proc, &mut host, b"/tmp/foo", pc::NAME_MAX), Ok(Some(123)) ); - assert_eq!(host.pathconf_calls, vec![(b"/tmp/foo".to_vec(), pc::NAME_MAX)]); + assert_eq!( + host.pathconf_calls, + vec![(b"/tmp/foo".to_vec(), pc::NAME_MAX)] + ); } #[test] @@ -25016,10 +26567,7 @@ mod tests { ); assert_eq!( host.pathconf_calls, - vec![ - (proc.cwd.clone(), pc::PATH_MAX), - (child, pc::PATH_MAX), - ] + vec![(proc.cwd.clone(), pc::PATH_MAX), (child, pc::PATH_MAX),] ); } @@ -25077,10 +26625,7 @@ mod tests { let proc = Process::new(1); let mut host = MockHostIO::new(); - assert_eq!( - sys_fpathconf(&proc, &mut host, 0, pc::PIPE_BUF), - Ok(None) - ); + assert_eq!(sys_fpathconf(&proc, &mut host, 0, pc::PIPE_BUF), Ok(None)); assert!(host.fpathconf_calls.is_empty()); } @@ -25117,10 +26662,7 @@ mod tests { let proc = terminal_process(1); let mut host = MockHostIO::new(); - assert_eq!( - sys_fpathconf(&proc, &mut host, 0, pc::MAX_CANON), - Ok(None) - ); + assert_eq!(sys_fpathconf(&proc, &mut host, 0, pc::MAX_CANON), Ok(None)); assert_eq!( sys_fpathconf(&proc, &mut host, 0, pc::VDISABLE), Ok(Some(0)) @@ -25682,7 +27224,10 @@ mod tests { assert_eq!(sys_writev(&mut proc, &mut host, write_fd, iovecs), Ok(5)); assert!(!fsize_signal_pending(&proc)); - assert_eq!(sys_write(&mut proc, &mut host, write_fd, b"x"), Err(Errno::EFBIG)); + assert_eq!( + sys_write(&mut proc, &mut host, write_fd, b"x"), + Err(Errno::EFBIG) + ); assert!(fsize_signal_pending(&proc)); clear_fsize_signal(&mut proc); @@ -25717,7 +27262,13 @@ mod tests { assert_eq!(sys_write(&mut proc, &mut host, fd, b"abcde"), Ok(2)); let entry = proc.fd_table.get(fd).unwrap(); assert_eq!(proc.ofd_table.get(entry.ofd_ref.0).unwrap().offset, 10); - assert_eq!(host.seek_calls.iter().filter(|call| call.2 == SEEK_END).count(), 1); + assert_eq!( + host.seek_calls + .iter() + .filter(|call| call.2 == SEEK_END) + .count(), + 1 + ); assert!(!fsize_signal_pending(&proc)); } @@ -25743,10 +27294,16 @@ mod tests { clear_fsize_signal(&mut proc); assert_eq!(sys_pwrite(&mut proc, &mut host, fd, b"xyz", 4), Ok(1)); assert!(!fsize_signal_pending(&proc)); - assert_eq!(sys_pwrite(&mut proc, &mut host, fd, b"x", 5), Err(Errno::EFBIG)); + assert_eq!( + sys_pwrite(&mut proc, &mut host, fd, b"x", 5), + Err(Errno::EFBIG) + ); clear_fsize_signal(&mut proc); - assert_eq!(sys_ftruncate(&mut proc, &mut host, fd, 6), Err(Errno::EFBIG)); + assert_eq!( + sys_ftruncate(&mut proc, &mut host, fd, 6), + Err(Errno::EFBIG) + ); assert!(fsize_signal_pending(&proc)); assert_eq!(sys_fstat(&mut proc, &mut host, fd).unwrap().st_size, 5); } @@ -25810,31 +27367,14 @@ mod tests { let input = sys_memfd_create(&mut proc, b"invalid-offset-input", 0).unwrap(); assert_eq!( - sys_copy_file_range( - &mut proc, - &mut host, - input, - Some(-1), - output, - None, - 1, - ), + sys_copy_file_range(&mut proc, &mut host, input, Some(-1), output, None, 1,), Err(Errno::EINVAL) ); assert_eq!(sys_lseek(&mut proc, &mut host, input, 0, SEEK_CUR), Ok(0)); assert!(!fsize_signal_pending(&proc)); assert!(host.seek_calls.is_empty()); assert_eq!( - sys_splice( - &mut proc, - &mut host, - input, - Some(-1), - output, - None, - 1, - 0, - ), + sys_splice(&mut proc, &mut host, input, Some(-1), output, None, 1, 0,), Err(Errno::EINVAL) ); assert_eq!(sys_lseek(&mut proc, &mut host, input, 0, SEEK_CUR), Ok(0)); @@ -25850,7 +27390,10 @@ mod tests { sys_sendfile(&mut proc, &mut host, output, empty_input, -1, 4), Ok(0) ); - assert_eq!(proc.ofd_table.get(output_ofd).unwrap().offset, original_offset); + assert_eq!( + proc.ofd_table.get(output_ofd).unwrap().offset, + original_offset + ); assert!(host.seek_calls.is_empty()); assert!(!fsize_signal_pending(&proc)); } @@ -25871,11 +27414,17 @@ mod tests { sys_setrlimit(&mut proc, RLIMIT_FSIZE, 100, 100).unwrap(); assert_eq!(sys_ftruncate(&mut proc, &mut host, fd, 50), Ok(())); - assert_eq!(sys_ftruncate(&mut proc, &mut host, fd, 200), Err(Errno::EFBIG)); + assert_eq!( + sys_ftruncate(&mut proc, &mut host, fd, 200), + Err(Errno::EFBIG) + ); assert!(fsize_signal_pending(&proc)); clear_fsize_signal(&mut proc); - assert_eq!(sys_fallocate(&mut proc, &mut host, fd, 0, 2048), Err(Errno::EFBIG)); + assert_eq!( + sys_fallocate(&mut proc, &mut host, fd, 0, 2048), + Err(Errno::EFBIG) + ); assert!(fsize_signal_pending(&proc)); clear_fsize_signal(&mut proc); @@ -26214,7 +27763,7 @@ mod tests { assert_eq!(fd, 3); // Set fd 3 in readfds - let mut readfds = [0u8; 128]; + let mut readfds = [0u8; wasm_posix_shared::select::FD_SET_BYTES]; readfds[0] = 0b1000; // bit 3 let result = sys_select(&mut proc, &mut host, 4, Some(&mut readfds), None, None, 0); assert_eq!(result, Ok(1)); @@ -26246,7 +27795,7 @@ mod tests { // Write data to pipe sys_write(&mut proc, &mut host, wfd, b"hello").unwrap(); - let mut readfds = [0u8; 128]; + let mut readfds = [0u8; wasm_posix_shared::select::FD_SET_BYTES]; let byte = rfd as usize / 8; let bit = rfd as usize % 8; readfds[byte] = 1 << bit; @@ -26271,7 +27820,7 @@ mod tests { let (_rfd, wfd) = sys_pipe(&mut proc).unwrap(); - let mut writefds = [0u8; 128]; + let mut writefds = [0u8; wasm_posix_shared::select::FD_SET_BYTES]; let byte = wfd as usize / 8; let bit = wfd as usize % 8; writefds[byte] = 1 << bit; @@ -26297,7 +27846,7 @@ mod tests { let fd1 = sys_open(&mut proc, &mut host, b"/file1", 0, 0o644).unwrap(); let fd2 = sys_open(&mut proc, &mut host, b"/file2", 0, 0o644).unwrap(); - let mut readfds = [0u8; 128]; + let mut readfds = [0u8; wasm_posix_shared::select::FD_SET_BYTES]; readfds[fd1 as usize / 8] |= 1 << (fd1 as usize % 8); readfds[fd2 as usize / 8] |= 1 << (fd2 as usize % 8); @@ -27329,7 +28878,10 @@ mod tests { .unwrap(); sys_listen(table.get_mut(PARENT).unwrap(), &mut host, server_fd, 5).unwrap(); - assert_eq!(table.fork_process_for_caller(PARENT, PARENT).unwrap(), CHILD); + assert_eq!( + table.fork_process_for_caller(PARENT, PARENT).unwrap(), + CHILD + ); let client_fd = { let parent = table.get_mut(PARENT).unwrap(); @@ -27500,18 +29052,8 @@ mod tests { addr[2..2 + path.len()].copy_from_slice(path); sys_bind(&mut proc, &mut host, fd, &addr[..2 + path.len() + 1]).unwrap(); - assert_socket_metadata( - sys_stat(&mut proc, &mut host, path).unwrap(), - 0o750, - 0, - 0, - ); - assert_socket_metadata( - sys_lstat(&mut proc, &mut host, path).unwrap(), - 0o750, - 0, - 0, - ); + assert_socket_metadata(sys_stat(&mut proc, &mut host, path).unwrap(), 0o750, 0, 0); + assert_socket_metadata(sys_lstat(&mut proc, &mut host, path).unwrap(), 0o750, 0, 0); assert_socket_metadata( sys_fstatat(&mut proc, &mut host, AT_FDCWD, path, 0).unwrap(), 0o750, @@ -27686,6 +29228,27 @@ mod tests { assert_eq!(err, Errno::ENOENT); } + #[test] + fn test_getaddrinfo_checks_exact_four_byte_capacity_and_host_count() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.getaddrinfo_bytes = Some(vec![10, 88, 0, 7]); + host.getaddrinfo_reported = 4; + let mut guarded = [0xa5, 0, 0, 0, 0, 0x5a]; + + let written = sys_getaddrinfo(&mut proc, &mut host, b"example.test", &mut guarded[1..5]) + .expect("exact IPv4 result"); + assert_eq!(written, 4); + assert_eq!(&guarded, &[0xa5, 10, 88, 0, 7, 0x5a]); + + host.getaddrinfo_reported = 5; + let err = + sys_getaddrinfo(&mut proc, &mut host, b"example.test", &mut guarded[1..5]).unwrap_err(); + assert_eq!(err, Errno::EIO); + assert_eq!(guarded[0], 0xa5); + assert_eq!(guarded[5], 0x5a); + } + #[test] fn test_inet_write_after_connect_succeeds() { // Create a mock that accepts connect and send @@ -28755,15 +30318,7 @@ mod tests { recv_addr[4..8].copy_from_slice(&[127, 0, 0, 1]); let send_fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); - sys_sendto( - &mut proc, - &mut host, - send_fd, - b"0123456789", - 0, - &recv_addr, - ) - .unwrap(); + sys_sendto(&mut proc, &mut host, send_fd, b"0123456789", 0, &recv_addr).unwrap(); let mut storage = [0xa5u8; 6]; let mut from = [0u8; 16]; @@ -28792,15 +30347,7 @@ mod tests { assert_eq!(&storage[..4], b"0123"); assert_eq!(&storage[4..], &[0xa5, 0xa5]); - sys_sendto( - &mut proc, - &mut host, - send_fd, - b"abcdefghij", - 0, - &recv_addr, - ) - .unwrap(); + sys_sendto(&mut proc, &mut host, send_fd, b"abcdefghij", 0, &recv_addr).unwrap(); let (copied, _) = sys_recvfrom( &mut proc, &mut host, @@ -28814,23 +30361,10 @@ mod tests { assert_eq!(&storage[..4], b"abcd"); assert_eq!(&storage[4..], &[0xa5, 0xa5]); - sys_sendto( - &mut proc, - &mut host, - send_fd, - b"zero-buffer", - 0, - &recv_addr, - ) - .unwrap(); + sys_sendto(&mut proc, &mut host, send_fd, b"zero-buffer", 0, &recv_addr).unwrap(); let mut empty = []; let (zero_buffer_len, _) = sys_recvfrom( - &mut proc, - &mut host, - recv_fd, - &mut empty, - MSG_TRUNC, - &mut from, + &mut proc, &mut host, recv_fd, &mut empty, MSG_TRUNC, &mut from, ) .unwrap(); assert_eq!(zero_buffer_len, 11); @@ -28848,14 +30382,8 @@ mod tests { &recv_addr, ) .unwrap(); - let connected_len = sys_recv( - &mut proc, - &mut host, - recv_fd, - &mut storage[..4], - MSG_TRUNC, - ) - .unwrap(); + let connected_len = + sys_recv(&mut proc, &mut host, recv_fd, &mut storage[..4], MSG_TRUNC).unwrap(); assert_eq!(connected_len, 14); assert_eq!(&storage[..4], b"conn"); } @@ -28886,12 +30414,7 @@ mod tests { let mut buf = [0u8; 4]; let mut from = [0u8; 28]; let (received, from_len) = sys_recvfrom( - &mut proc, - &mut host, - recv_fd, - &mut buf, - MSG_TRUNC, - &mut from, + &mut proc, &mut host, recv_fd, &mut buf, MSG_TRUNC, &mut from, ) .unwrap(); assert_eq!(received, 14); @@ -28911,14 +30434,7 @@ mod tests { let (recv_fd, recv_addr) = bind_test_unix_dgram(&mut proc, &mut host, recv_path); let (send_fd, _) = bind_test_unix_dgram(&mut proc, &mut host, send_path); sys_connect(&mut proc, &mut host, send_fd, &recv_addr).unwrap(); - sys_send( - &mut proc, - &mut host, - send_fd, - b"reliable-unix", - 0, - ) - .unwrap(); + sys_send(&mut proc, &mut host, send_fd, b"reliable-unix", 0).unwrap(); let mut buf = [0u8; 4]; let mut from = [0u8; 16]; @@ -28934,12 +30450,7 @@ mod tests { assert_eq!(peeked, 13); assert_eq!(&buf, b"reli"); let (received, from_len) = sys_recvfrom( - &mut proc, - &mut host, - recv_fd, - &mut buf, - MSG_TRUNC, - &mut from, + &mut proc, &mut host, recv_fd, &mut buf, MSG_TRUNC, &mut from, ) .unwrap(); assert_eq!(received, 13); @@ -28984,13 +30495,7 @@ mod tests { revents: 0, }; assert_eq!( - sys_poll( - &mut proc, - &mut host, - core::slice::from_mut(&mut pollfd), - 0, - ) - .unwrap(), + sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0,).unwrap(), 0, ); let mut byte = [0u8; 1]; @@ -29006,35 +30511,18 @@ mod tests { .unwrap(); assert_eq!(peeked, 4); assert_eq!( - sys_poll( - &mut proc, - &mut host, - core::slice::from_mut(&mut pollfd), - 0, - ) - .unwrap(), + sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0,).unwrap(), 0, "MSG_PEEK must neither consume capacity nor wake the sender", ); let (received, _) = sys_recvfrom( - &mut proc, - &mut host, - recv_fd, - &mut byte, - MSG_TRUNC, - &mut from, + &mut proc, &mut host, recv_fd, &mut byte, MSG_TRUNC, &mut from, ) .unwrap(); assert_eq!(received, 4); assert_eq!( - sys_poll( - &mut proc, - &mut host, - core::slice::from_mut(&mut pollfd), - 0, - ) - .unwrap(), + sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0,).unwrap(), 1, ); assert_ne!(pollfd.revents & POLLOUT, 0); @@ -29070,15 +30558,8 @@ mod tests { let group = [224, 0, 0, 23]; let lo = [127, 0, 0, 1]; - sys_setsockopt_ipv4_multicast( - &mut proc, - recv_fd, - MCAST_JOIN_GROUP, - group, - lo, - None, - ) - .unwrap(); + sys_setsockopt_ipv4_multicast(&mut proc, recv_fd, MCAST_JOIN_GROUP, group, lo, None) + .unwrap(); let mut dest = [0u8; 16]; dest[0] = 2; @@ -29091,20 +30572,12 @@ mod tests { let mut buf = [0u8; 32]; let mut from = [0u8; 16]; - let (n, _) = - sys_recvfrom(&mut proc, &mut host, recv_fd, &mut buf, 0, &mut from).unwrap(); + let (n, _) = sys_recvfrom(&mut proc, &mut host, recv_fd, &mut buf, 0, &mut from).unwrap(); assert_eq!(&buf[..n], b"initial"); assert_eq!(&from[4..8], &lo); - sys_setsockopt_ipv4_multicast( - &mut proc, - recv_fd, - MCAST_BLOCK_SOURCE, - group, - lo, - Some(lo), - ) - .unwrap(); + sys_setsockopt_ipv4_multicast(&mut proc, recv_fd, MCAST_BLOCK_SOURCE, group, lo, Some(lo)) + .unwrap(); assert_eq!( sys_sendto(&mut proc, &mut host, send_fd, b"blocked", 0, &dest).unwrap(), 7 @@ -29115,11 +30588,7 @@ mod tests { (-(ofd.host_handle + 1)) as usize }; assert!( - proc.sockets - .get(recv_idx) - .unwrap() - .dgram_queue - .is_empty(), + proc.sockets.get(recv_idx).unwrap().dgram_queue.is_empty(), "blocked multicast source should not enqueue a datagram" ); @@ -29136,8 +30605,7 @@ mod tests { sys_sendto(&mut proc, &mut host, send_fd, b"unblocked", 0, &dest).unwrap(), 9 ); - let (n, _) = - sys_recvfrom(&mut proc, &mut host, recv_fd, &mut buf, 0, &mut from).unwrap(); + let (n, _) = sys_recvfrom(&mut proc, &mut host, recv_fd, &mut buf, 0, &mut from).unwrap(); assert_eq!(&buf[..n], b"unblocked"); sys_setsockopt(&mut proc, send_fd, IPPROTO_IP, IP_MULTICAST_LOOP, 0).unwrap(); @@ -29146,34 +30614,19 @@ mod tests { 7 ); assert!( - proc.sockets - .get(recv_idx) - .unwrap() - .dgram_queue - .is_empty(), + proc.sockets.get(recv_idx).unwrap().dgram_queue.is_empty(), "IP_MULTICAST_LOOP=0 must suppress local delivery" ); sys_setsockopt(&mut proc, send_fd, IPPROTO_IP, IP_MULTICAST_LOOP, 1).unwrap(); - sys_setsockopt_ipv4_multicast( - &mut proc, - recv_fd, - MCAST_LEAVE_GROUP, - group, - lo, - None, - ) - .unwrap(); + sys_setsockopt_ipv4_multicast(&mut proc, recv_fd, MCAST_LEAVE_GROUP, group, lo, None) + .unwrap(); assert_eq!( sys_sendto(&mut proc, &mut host, send_fd, b"ignored", 0, &dest).unwrap(), 7 ); assert!( - proc.sockets - .get(recv_idx) - .unwrap() - .dgram_queue - .is_empty(), + proc.sockets.get(recv_idx).unwrap().dgram_queue.is_empty(), "leaving a multicast group should stop group delivery" ); } @@ -29274,13 +30727,7 @@ mod tests { let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); (-(ofd.host_handle + 1)) as usize }; - assert!( - proc.sockets - .get(recv_idx) - .unwrap() - .dgram_queue - .is_empty() - ); + assert!(proc.sockets.get(recv_idx).unwrap().dgram_queue.is_empty()); sys_setsockopt( &mut proc, @@ -29316,14 +30763,7 @@ mod tests { assert_eq!(&selected_buf[..selected_len], b"selected-loopback"); assert_eq!(&selected_from[4..8], &lo); - sys_setsockopt( - &mut proc, - default_sender, - IPPROTO_IP, - IP_MULTICAST_IF, - 0, - ) - .unwrap(); + sys_setsockopt(&mut proc, default_sender, IPPROTO_IP, IP_MULTICAST_IF, 0).unwrap(); sys_setsockopt_bindtodevice(&mut proc, default_sender, b"lo\0").unwrap(); assert_eq!( sys_sendto( @@ -29355,8 +30795,7 @@ mod tests { ); let mut buf = [0u8; 32]; let mut from = [0u8; 16]; - let (n, _) = - sys_recvfrom(&mut proc, &mut host, recv_fd, &mut buf, 0, &mut from).unwrap(); + let (n, _) = sys_recvfrom(&mut proc, &mut host, recv_fd, &mut buf, 0, &mut from).unwrap(); assert_eq!(&buf[..n], b"source-match"); sys_setsockopt_ipv4_multicast( @@ -29372,13 +30811,7 @@ mod tests { sys_sendto(&mut proc, &mut host, loop_sender, b"left-source", 0, &dest).unwrap(), 11 ); - assert!( - proc.sockets - .get(recv_idx) - .unwrap() - .dgram_queue - .is_empty() - ); + assert!(proc.sockets.get(recv_idx).unwrap().dgram_queue.is_empty()); } #[test] @@ -29446,7 +30879,10 @@ mod tests { let client_fd = sys_socket(&mut proc, &mut host, AF_INET6, SOCK_DGRAM, 0).unwrap(); sys_connect(&mut proc, &mut host, client_fd, &server_addr).unwrap(); - assert_eq!(sys_write(&mut proc, &mut host, client_fd, b"udp6").unwrap(), 4); + assert_eq!( + sys_write(&mut proc, &mut host, client_fd, b"udp6").unwrap(), + 4 + ); let mut buf = [0u8; 8]; let mut from = [0u8; 28]; @@ -29454,7 +30890,10 @@ mod tests { sys_recvfrom(&mut proc, &mut host, server_fd, &mut buf, 0, &mut from).unwrap(); assert_eq!(&buf[..n], b"udp6"); assert_eq!(from_len, 28); - assert_eq!(&from[8..24], &[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]); + assert_eq!( + &from[8..24], + &[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1] + ); } #[test] @@ -29565,13 +31004,7 @@ mod tests { (-(ofd.host_handle + 1)) as usize }; assert_eq!( - sys_write( - &mut proc, - &mut host, - send_fd, - b"peer-before-connect", - ) - .unwrap(), + sys_write(&mut proc, &mut host, send_fd, b"peer-before-connect",).unwrap(), 19, ); assert_eq!(proc.sockets.get(recv_idx).unwrap().dgram_queue.len(), 2); @@ -29634,28 +31067,19 @@ mod tests { revents: 0, }; assert_eq!( - sys_poll( - &mut proc, - &mut host, - core::slice::from_mut(&mut pollfd), - 0, - ) - .unwrap(), + sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0,).unwrap(), 0, ); assert_eq!(pollfd.revents, 0); let mut buf = [0u8; 4]; - assert_eq!(sys_read(&mut proc, &mut host, recv_fd, &mut buf).unwrap(), 4); + assert_eq!( + sys_read(&mut proc, &mut host, recv_fd, &mut buf).unwrap(), + 4 + ); assert_eq!(u32::from_le_bytes(buf), 0); assert_eq!( - sys_poll( - &mut proc, - &mut host, - core::slice::from_mut(&mut pollfd), - 0, - ) - .unwrap(), + sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0,).unwrap(), 1, ); assert_ne!(pollfd.revents & POLLOUT, 0); @@ -29671,7 +31095,10 @@ mod tests { ); for expected in 1..=UDP_DATAGRAM_QUEUE_LIMIT { - assert_eq!(sys_read(&mut proc, &mut host, recv_fd, &mut buf).unwrap(), 4); + assert_eq!( + sys_read(&mut proc, &mut host, recv_fd, &mut buf).unwrap(), + 4 + ); assert_eq!(u32::from_le_bytes(buf), expected as u32); } @@ -29773,7 +31200,11 @@ mod tests { for fd in [first_fd, selected_fd, recv_fd] { sys_close(&mut proc, &mut host, fd).unwrap(); } - for path in [first_path.as_slice(), selected_path.as_slice(), recv_path.as_slice()] { + for path in [ + first_path.as_slice(), + selected_path.as_slice(), + recv_path.as_slice(), + ] { sys_unlink(&mut proc, &mut host, path).unwrap(); } } @@ -29810,25 +31241,13 @@ mod tests { revents: 0, }; assert_eq!( - sys_poll( - &mut proc, - &mut host, - core::slice::from_mut(&mut pollfd), - 0, - ) - .unwrap(), + sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0,).unwrap(), 0, ); sys_shutdown(&mut proc, &mut host, recv_fd, SHUT_RD).unwrap(); assert_eq!( - sys_poll( - &mut proc, - &mut host, - core::slice::from_mut(&mut pollfd), - 0, - ) - .unwrap(), + sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0,).unwrap(), 1, ); assert_ne!(pollfd.revents & POLLOUT, 0); @@ -29837,20 +31256,17 @@ mod tests { Errno::EPIPE, ); assert!(proc.signals.is_pending(SIGPIPE)); - assert_eq!( - proc.sockets.get(recv_idx).unwrap().dgram_queue.len(), - UDP_DATAGRAM_QUEUE_LIMIT, + assert!( + proc.sockets + .get(recv_idx) + .unwrap() + .dgram_queue + .is_empty(), + "read shutdown must discard datagrams that can no longer be received", ); proc.signals.clear(SIGPIPE); assert_eq!( - sys_send( - &mut proc, - &mut host, - send_fd, - b"quiet", - MSG_NOSIGNAL, - ) - .unwrap_err(), + sys_send(&mut proc, &mut host, send_fd, b"quiet", MSG_NOSIGNAL,).unwrap_err(), Errno::EPIPE, ); assert!(!proc.signals.is_pending(SIGPIPE)); @@ -29892,24 +31308,12 @@ mod tests { revents: 0, }; assert_eq!( - sys_poll( - &mut proc, - &mut host, - core::slice::from_mut(&mut pollfd), - 0, - ) - .unwrap(), + sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0,).unwrap(), 0, ); sys_close(&mut proc, &mut host, recv_fd).unwrap(); assert_eq!( - sys_poll( - &mut proc, - &mut host, - core::slice::from_mut(&mut pollfd), - 0, - ) - .unwrap(), + sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0,).unwrap(), 1, ); assert_ne!(pollfd.revents & POLLOUT, 0); @@ -29971,8 +31375,15 @@ mod tests { // enqueue into the sender's unrelated slot-zero socket. let send_fd = sys_socket(&mut sender, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); assert_eq!( - sys_sendto(&mut sender, &mut host, send_fd, b"must not misdeliver", 0, addr) - .unwrap_err(), + sys_sendto( + &mut sender, + &mut host, + send_fd, + b"must not misdeliver", + 0, + addr + ) + .unwrap_err(), Errno::ECONNREFUSED, ); let send_entry = sender.fd_table.get(send_fd).unwrap(); @@ -30024,12 +31435,13 @@ mod tests { sys_write(&mut proc, &mut host, client_fd, b"must not redirect").unwrap_err(), Errno::ECONNREFUSED, ); - assert!(proc - .sockets - .get(replacement_idx) - .unwrap() - .dgram_queue - .is_empty()); + assert!( + proc.sockets + .get(replacement_idx) + .unwrap() + .dgram_queue + .is_empty() + ); unsafe { crate::unix_socket::global_unix_socket_registry() }.cleanup_process(9039); } @@ -31202,8 +32614,8 @@ mod tests { #[test] fn test_signalfd4_reads_main_directed_signal_without_exposing_it_to_workers() { - use wasm_posix_shared::signal::SIGXFSZ; use wasm_posix_shared::WasmPollFd; + use wasm_posix_shared::signal::SIGXFSZ; let _guard = THREAD_IDENTITY_LOCK.lock().unwrap(); set_test_current_tid(0); @@ -31237,21 +32649,23 @@ mod tests { fn test_signalfd4_accepts_timer_metadata_and_overrun() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - proc.posix_timers.push(Some(crate::process::PosixTimerState { - clock_id: 1, - sigev_signo: 10, - sigev_value: 77, - sigev_notify: 0, - sigev_tid: 0, - interval_sec: 0, - interval_nsec: 1, - value_sec: 0, - value_nsec: 1, - notification_pending: true, - overrun_current: 3, - overrun_last: 0, - })); - proc.signals.raise_timer(10, 77, 0); + proc.posix_timers + .push(Some(crate::process::PosixTimerState { + clock_id: 1, + sigev_signo: 10, + sigev_value_bits: 0x0123_4567_89ab_cdef, + sigev_notify: 0, + sigev_tid: 0, + interval_sec: 0, + interval_nsec: 1, + value_sec: 0, + value_nsec: 1, + notification_pending: true, + overrun_current: 3, + overrun_last: 0, + })); + proc.signals + .raise_timer(10, 0x0123_4567_89ab_cdef, 0); let fd = sys_signalfd4(&mut proc, -1, crate::signal::sig_bit(10), O_NONBLOCK).unwrap(); let mut buf = [0u8; 128]; @@ -31260,7 +32674,14 @@ mod tests { assert_eq!(i32::from_le_bytes(buf[8..12].try_into().unwrap()), -2); assert_eq!(u32::from_le_bytes(buf[24..28].try_into().unwrap()), 0); assert_eq!(i32::from_le_bytes(buf[32..36].try_into().unwrap()), 3); - assert_eq!(i32::from_le_bytes(buf[44..48].try_into().unwrap()), 77); + assert_eq!( + u32::from_le_bytes(buf[44..48].try_into().unwrap()), + 0x89ab_cdef, + ); + assert_eq!( + u64::from_le_bytes(buf[48..56].try_into().unwrap()), + 0x0123_4567_89ab_cdef, + ); let timer = proc.posix_timers[0].as_ref().unwrap(); assert!(!timer.notification_pending); assert_eq!(timer.overrun_last, 3); @@ -31426,7 +32847,7 @@ mod tests { // Open a regular file let fd = sys_open(&mut proc, &mut host, b"/tmp/test", O_RDWR | O_CREAT, 0o644).unwrap(); - let result = sys_ioctl(&mut proc, &mut host,fd, 0x540F, &mut buf); + let result = sys_ioctl(&mut proc, &mut host, fd, 0x540F, &mut buf); assert_eq!(result, Err(Errno::ENOTTY)); } @@ -32175,30 +33596,13 @@ mod tests { #[test] fn test_sysinfo() { - let mut buf = [0u8; 312]; - sys_sysinfo(&mut buf).unwrap(); - // uptime = 1 - assert_eq!(u32::from_le_bytes(buf[0..4].try_into().unwrap()), 1); - // totalram = 512 MB - assert_eq!( - u32::from_le_bytes(buf[16..20].try_into().unwrap()), - 512 * 1024 * 1024 - ); - // freeram = 256 MB - assert_eq!( - u32::from_le_bytes(buf[20..24].try_into().unwrap()), - 256 * 1024 * 1024 - ); - // procs = 1 - assert_eq!(u16::from_le_bytes(buf[40..42].try_into().unwrap()), 1); - // mem_unit = 1 - assert_eq!(u32::from_le_bytes(buf[52..56].try_into().unwrap()), 1); - } - - #[test] - fn test_sysinfo_buffer_too_small() { - let mut buf = [0u8; 100]; - assert_eq!(sys_sysinfo(&mut buf).unwrap_err(), Errno::EFAULT); + let info = sys_sysinfo(); + assert_eq!(info.uptime, 1); + assert_eq!(info.loads, [0; 3]); + assert_eq!(info.totalram, 512 * 1024 * 1024); + assert_eq!(info.freeram, 256 * 1024 * 1024); + assert_eq!(info.procs, 1); + assert_eq!(info.mem_unit, 1); } #[test] @@ -32218,14 +33622,7 @@ mod tests { l_pid: 0, _pad2: 0, }; - let result = sys_fcntl_lock( - &mut proc, - &mut locks, - fd1, - F_GETLK, - &mut flock, - &mut host, - ); + let result = sys_fcntl_lock(&mut proc, &mut locks, fd1, F_GETLK, &mut flock, &mut host); assert_eq!(result.unwrap_err(), Errno::EINVAL); } @@ -32843,8 +34240,7 @@ mod tests { let followed = sys_stat(&mut proc, &mut host, path.as_bytes()).unwrap(); assert_same_stat(&followed, &expected); - let followed_at = - sys_fstatat(&mut proc, &mut host, AT_FDCWD, path.as_bytes(), 0).unwrap(); + let followed_at = sys_fstatat(&mut proc, &mut host, AT_FDCWD, path.as_bytes(), 0).unwrap(); assert_same_stat(&followed_at, &expected); let followed_statx = sys_statx(&mut proc, &mut host, AT_FDCWD, path.as_bytes(), 0, 0).unwrap(); @@ -33037,7 +34433,7 @@ mod tests { let mut host = MockHostIO::new(); let fd = sys_open(&mut proc, &mut host, b"/dev/fb0", O_RDWR, 0).unwrap(); let mut buf = [0u8; 160]; - sys_ioctl(&mut proc, &mut host,fd, FBIOGET_VSCREENINFO, &mut buf).unwrap(); + sys_ioctl(&mut proc, &mut host, fd, FBIOGET_VSCREENINFO, &mut buf).unwrap(); let v: FbVarScreenInfo = unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const _) }; assert_eq!(v.xres, 640); assert_eq!(v.yres, 400); @@ -33064,7 +34460,7 @@ mod tests { let mut host = MockHostIO::new(); let fd = sys_open(&mut proc, &mut host, b"/dev/fb0", O_RDWR, 0).unwrap(); let mut buf = [0u8; 80]; - sys_ioctl(&mut proc, &mut host,fd, FBIOGET_FSCREENINFO, &mut buf).unwrap(); + sys_ioctl(&mut proc, &mut host, fd, FBIOGET_FSCREENINFO, &mut buf).unwrap(); let f: FbFixScreenInfo = unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const _) }; assert_eq!(&f.id[..6], b"wasmfb"); assert_eq!(f.smem_len, 640 * 400 * 4); @@ -33085,7 +34481,7 @@ mod tests { let mut host = MockHostIO::new(); let fd = sys_open(&mut proc, &mut host, b"/dev/fb0", O_RDWR, 0).unwrap(); let mut buf = [0u8; 160]; - sys_ioctl(&mut proc, &mut host,fd, FBIOPAN_DISPLAY, &mut buf).unwrap(); + sys_ioctl(&mut proc, &mut host, fd, FBIOPAN_DISPLAY, &mut buf).unwrap(); sys_close(&mut proc, &mut host, fd).unwrap(); } @@ -33106,7 +34502,7 @@ mod tests { unsafe { core::ptr::write_unaligned(buf.as_mut_ptr() as *mut _, v); } - let err = sys_ioctl(&mut proc, &mut host,fd, FBIOPUT_VSCREENINFO, &mut buf).unwrap_err(); + let err = sys_ioctl(&mut proc, &mut host, fd, FBIOPUT_VSCREENINFO, &mut buf).unwrap_err(); assert_eq!(err, Errno::EINVAL); // Matching geometry succeeds. @@ -33117,7 +34513,7 @@ mod tests { unsafe { core::ptr::write_unaligned(buf.as_mut_ptr() as *mut _, v); } - sys_ioctl(&mut proc, &mut host,fd, FBIOPUT_VSCREENINFO, &mut buf).unwrap(); + sys_ioctl(&mut proc, &mut host, fd, FBIOPUT_VSCREENINFO, &mut buf).unwrap(); sys_close(&mut proc, &mut host, fd).unwrap(); } @@ -33131,7 +34527,7 @@ mod tests { let mut host = MockHostIO::new(); let fd = sys_open(&mut proc, &mut host, b"/dev/fb0", O_RDWR, 0).unwrap(); let mut buf = [0u8; 160]; // big enough that ENOTTY is the only failure mode - let err = sys_ioctl(&mut proc, &mut host,fd, 0x46FF, &mut buf).unwrap_err(); + let err = sys_ioctl(&mut proc, &mut host, fd, 0x46FF, &mut buf).unwrap_err(); assert_eq!(err, Errno::ENOTTY); sys_close(&mut proc, &mut host, fd).unwrap(); } @@ -33551,8 +34947,7 @@ mod tests { let mut proc = Process::new(1); let mut host = MockHostIO::new(); sys_setrlimit(&mut proc, 7, 4096, 4096).unwrap(); - let cloexec_fd = - sys_open(&mut proc, &mut host, b"/dev/input/mice", O_RDONLY, 0).unwrap(); + let cloexec_fd = sys_open(&mut proc, &mut host, b"/dev/input/mice", O_RDONLY, 0).unwrap(); proc.fd_table.get_mut(cloexec_fd).unwrap().fd_flags = FD_CLOEXEC; let retained_fd = sys_fcntl(&mut proc, cloexec_fd, F_DUPFD, 2048).unwrap(); assert_eq!(retained_fd, 2048); @@ -33691,7 +35086,7 @@ mod tests { let fd = sys_open(&mut proc, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); let mut arg = 44100i32.to_le_bytes(); - sys_ioctl(&mut proc, &mut host,fd, SNDCTL_DSP_SPEED, &mut arg).unwrap(); + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_SPEED, &mut arg).unwrap(); // The kernel echoes back the rate it actually configured. assert_eq!(i32::from_le_bytes(arg), 44100); assert_eq!(crate::audio::current_config().0, 44100); @@ -33710,11 +35105,11 @@ mod tests { let fd = sys_open(&mut proc, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); let mut arg = 0x08u32.to_le_bytes(); // AFMT_U8 — unsupported - let err = sys_ioctl(&mut proc, &mut host,fd, SNDCTL_DSP_SETFMT, &mut arg).unwrap_err(); + let err = sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_SETFMT, &mut arg).unwrap_err(); assert_eq!(err, Errno::EINVAL); let mut arg = AFMT_S16_LE.to_le_bytes(); - sys_ioctl(&mut proc, &mut host,fd, SNDCTL_DSP_SETFMT, &mut arg).unwrap(); + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_SETFMT, &mut arg).unwrap(); sys_close(&mut proc, &mut host, fd).unwrap(); } @@ -33729,7 +35124,7 @@ mod tests { let fd = sys_open(&mut proc, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); let mut arg = [0u8; 4]; - sys_ioctl(&mut proc, &mut host,fd, SNDCTL_DSP_GETFMTS, &mut arg).unwrap(); + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_GETFMTS, &mut arg).unwrap(); assert_eq!(u32::from_le_bytes(arg), AFMT_S16_LE); sys_close(&mut proc, &mut host, fd).unwrap(); @@ -33749,7 +35144,7 @@ mod tests { assert_eq!(crate::audio::pending_bytes(), 4); let mut arg = [0u8; 0]; - sys_ioctl(&mut proc, &mut host,fd, SNDCTL_DSP_RESET, &mut arg).unwrap(); + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_RESET, &mut arg).unwrap(); assert_eq!(crate::audio::pending_bytes(), 0); sys_close(&mut proc, &mut host, fd).unwrap(); @@ -33863,9 +35258,10 @@ mod tests { desc_ptr: 0xabad_1dea, ..Default::default() }; - let mut buf = [0u8; core::mem::size_of::()]; + let mut buf = [0u8; core::mem::size_of::() + 1]; + *buf.last_mut().unwrap() = 0xa5; unsafe { core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmVersion, v_in) }; - sys_ioctl(&mut proc, &mut host,fd, DRM_IOCTL_VERSION, &mut buf).unwrap(); + sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_VERSION, &mut buf).unwrap(); let v_out: WpkDrmVersion = unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const WpkDrmVersion) }; assert_eq!(v_out.version_major, 1); @@ -33877,6 +35273,110 @@ mod tests { assert_eq!(v_out.name_ptr, 0xdead_beef); assert_eq!(v_out.date_ptr, 0xcafe_1234); assert_eq!(v_out.desc_ptr, 0xabad_1dea); + assert_eq!(*buf.last().unwrap(), 0xa5); + } + + #[test] + fn dri_ioctl_version_uses_native_wasm64_layout_without_pointer_truncation() { + use wasm_posix_shared::dri::DRM_IOCTL_VERSION_WASM64; + + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/dri/renderD128", O_RDWR, 0).unwrap(); + let mut buf = [0u8; 65]; + let name_ptr = 0x0000_0001_dead_beefu64; + let date_ptr = 0x0000_0002_cafe_1234u64; + let desc_ptr = 0x0000_0003_abad_1deau64; + buf[16..24].copy_from_slice(&64u64.to_le_bytes()); + buf[24..32].copy_from_slice(&name_ptr.to_le_bytes()); + buf[32..40].copy_from_slice(&64u64.to_le_bytes()); + buf[40..48].copy_from_slice(&date_ptr.to_le_bytes()); + buf[48..56].copy_from_slice(&64u64.to_le_bytes()); + buf[56..64].copy_from_slice(&desc_ptr.to_le_bytes()); + buf[64] = 0xa5; + + sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_VERSION_WASM64, &mut buf).unwrap(); + + assert_eq!(i32::from_le_bytes(buf[0..4].try_into().unwrap()), 1); + assert_eq!(u64::from_le_bytes(buf[16..24].try_into().unwrap()), 0); + assert_eq!( + u64::from_le_bytes(buf[24..32].try_into().unwrap()), + name_ptr, + ); + assert_eq!(u64::from_le_bytes(buf[32..40].try_into().unwrap()), 0); + assert_eq!( + u64::from_le_bytes(buf[40..48].try_into().unwrap()), + date_ptr, + ); + assert_eq!(u64::from_le_bytes(buf[48..56].try_into().unwrap()), 0); + assert_eq!( + u64::from_le_bytes(buf[56..64].try_into().unwrap()), + desc_ptr, + ); + assert_eq!(buf[64], 0xa5); + } + + #[test] + fn dri_nested_u64_pointer_rejects_instead_of_aliasing_low_memory() { + use wasm_posix_shared::dri::{ + DRM_IOCTL_MODE_GETCONNECTOR, DRM_IOCTL_MODE_GETRESOURCES, WpkDrmModeCardRes, + WpkDrmModeGetConnector, + }; + + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/dri/card0", O_RDWR, 0).unwrap(); + + let resources = WpkDrmModeCardRes { + crtc_id_ptr: 0x1234, + connector_id_ptr: 0x0000_0001_0000_5678, + count_crtcs: 1, + count_connectors: 1, + ..Default::default() + }; + let mut resource_buf = [0u8; core::mem::size_of::()]; + unsafe { + core::ptr::write_unaligned( + resource_buf.as_mut_ptr() as *mut WpkDrmModeCardRes, + resources, + ); + } + assert_eq!( + sys_ioctl( + &mut proc, + &mut host, + fd, + DRM_IOCTL_MODE_GETRESOURCES, + &mut resource_buf, + ), + Err(Errno::EFAULT), + ); + assert!(host.proc_write_calls.is_empty()); + + let connector = WpkDrmModeGetConnector { + modes_ptr: 0x0000_0001_0000_5678, + count_modes: 1, + connector_id: 1, + ..Default::default() + }; + let mut connector_buf = [0u8; core::mem::size_of::()]; + unsafe { + core::ptr::write_unaligned( + connector_buf.as_mut_ptr() as *mut WpkDrmModeGetConnector, + connector, + ); + } + assert_eq!( + sys_ioctl( + &mut proc, + &mut host, + fd, + DRM_IOCTL_MODE_GETCONNECTOR, + &mut connector_buf, + ), + Err(Errno::EFAULT), + ); + assert!(host.proc_write_calls.is_empty()); } #[test] @@ -33892,7 +35392,7 @@ mod tests { value: 0, }; unsafe { core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmGetCap, cap_in) }; - sys_ioctl(&mut proc, &mut host,fd, DRM_IOCTL_GET_CAP, &mut buf).unwrap(); + sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_GET_CAP, &mut buf).unwrap(); let cap_out: WpkDrmGetCap = unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const WpkDrmGetCap) }; assert_eq!(cap_out.value, 1); @@ -33902,7 +35402,7 @@ mod tests { value: 0, }; unsafe { core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmGetCap, cap_in) }; - sys_ioctl(&mut proc, &mut host,fd, DRM_IOCTL_GET_CAP, &mut buf).unwrap(); + sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_GET_CAP, &mut buf).unwrap(); let cap_out: WpkDrmGetCap = unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const WpkDrmGetCap) }; assert_eq!(cap_out.value, DRM_PRIME_CAP_IMPORT | DRM_PRIME_CAP_EXPORT); @@ -33912,7 +35412,7 @@ mod tests { value: 0, }; unsafe { core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmGetCap, cap_in) }; - sys_ioctl(&mut proc, &mut host,fd, DRM_IOCTL_GET_CAP, &mut buf).unwrap(); + sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_GET_CAP, &mut buf).unwrap(); let cap_out: WpkDrmGetCap = unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const WpkDrmGetCap) }; // Unknown capabilities return value=0, errno=0 — matches Linux. @@ -33975,7 +35475,14 @@ mod tests { }; let mut buf = [0u8; core::mem::size_of::()]; unsafe { core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmModeCreateDumb, req) }; - sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_MODE_CREATE_DUMB, &mut buf).unwrap(); + sys_ioctl( + &mut proc, + &mut host, + fd, + DRM_IOCTL_MODE_CREATE_DUMB, + &mut buf, + ) + .unwrap(); let out: WpkDrmModeCreateDumb = unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const WpkDrmModeCreateDumb) }; @@ -34001,7 +35508,14 @@ mod tests { let mut buf = [0u8; core::mem::size_of::()]; unsafe { core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmModeCreateDumb, req) }; assert_eq!( - sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_MODE_CREATE_DUMB, &mut buf).unwrap_err(), + sys_ioctl( + &mut proc, + &mut host, + fd, + DRM_IOCTL_MODE_CREATE_DUMB, + &mut buf + ) + .unwrap_err(), Errno::EINVAL ); @@ -34014,7 +35528,14 @@ mod tests { }; unsafe { core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmModeCreateDumb, req) }; assert_eq!( - sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_MODE_CREATE_DUMB, &mut buf).unwrap_err(), + sys_ioctl( + &mut proc, + &mut host, + fd, + DRM_IOCTL_MODE_CREATE_DUMB, + &mut buf + ) + .unwrap_err(), Errno::EINVAL ); @@ -34028,7 +35549,14 @@ mod tests { }; unsafe { core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmModeCreateDumb, req) }; assert_eq!( - sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_MODE_CREATE_DUMB, &mut buf).unwrap_err(), + sys_ioctl( + &mut proc, + &mut host, + fd, + DRM_IOCTL_MODE_CREATE_DUMB, + &mut buf + ) + .unwrap_err(), Errno::EINVAL ); } @@ -34048,8 +35576,17 @@ mod tests { ..Default::default() }; let mut buf = [0u8; core::mem::size_of::()]; - unsafe { core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmModeCreateDumb, create) }; - sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_MODE_CREATE_DUMB, &mut buf).unwrap(); + unsafe { + core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmModeCreateDumb, create) + }; + sys_ioctl( + &mut proc, + &mut host, + fd, + DRM_IOCTL_MODE_CREATE_DUMB, + &mut buf, + ) + .unwrap(); let created: WpkDrmModeCreateDumb = unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const WpkDrmModeCreateDumb) }; let handle = created.handle; @@ -34183,8 +35720,17 @@ mod tests { ..Default::default() }; let mut buf = [0u8; core::mem::size_of::()]; - unsafe { core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmModeCreateDumb, create) }; - sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_MODE_CREATE_DUMB, &mut buf).unwrap(); + unsafe { + core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmModeCreateDumb, create) + }; + sys_ioctl( + &mut proc, + &mut host, + fd, + DRM_IOCTL_MODE_CREATE_DUMB, + &mut buf, + ) + .unwrap(); let created: WpkDrmModeCreateDumb = unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const WpkDrmModeCreateDumb) }; @@ -34227,8 +35773,17 @@ mod tests { ..Default::default() }; let mut buf = [0u8; core::mem::size_of::()]; - unsafe { core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmModeCreateDumb, create) }; - sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_MODE_CREATE_DUMB, &mut buf).unwrap(); + unsafe { + core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmModeCreateDumb, create) + }; + sys_ioctl( + &mut proc, + &mut host, + fd, + DRM_IOCTL_MODE_CREATE_DUMB, + &mut buf, + ) + .unwrap(); let created: WpkDrmModeCreateDumb = unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const WpkDrmModeCreateDumb) }; @@ -34412,8 +35967,17 @@ mod tests { ..Default::default() }; let mut buf = [0u8; core::mem::size_of::()]; - unsafe { core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmModeCreateDumb, create) }; - sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_MODE_CREATE_DUMB, &mut buf).unwrap(); + unsafe { + core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmModeCreateDumb, create) + }; + sys_ioctl( + &mut proc, + &mut host, + fd, + DRM_IOCTL_MODE_CREATE_DUMB, + &mut buf, + ) + .unwrap(); let created: WpkDrmModeCreateDumb = unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const WpkDrmModeCreateDumb) }; @@ -34450,8 +36014,17 @@ mod tests { ..Default::default() }; let mut buf = [0u8; core::mem::size_of::()]; - unsafe { core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmModeCreateDumb, create) }; - sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_MODE_CREATE_DUMB, &mut buf).unwrap(); + unsafe { + core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmModeCreateDumb, create) + }; + sys_ioctl( + &mut proc, + &mut host, + fd, + DRM_IOCTL_MODE_CREATE_DUMB, + &mut buf, + ) + .unwrap(); let created: WpkDrmModeCreateDumb = unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const WpkDrmModeCreateDumb) }; @@ -34462,7 +36035,14 @@ mod tests { }; let mut pbuf = [0u8; core::mem::size_of::()]; unsafe { core::ptr::write_unaligned(pbuf.as_mut_ptr() as *mut WpkDrmPrimeHandle, req) }; - sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_PRIME_HANDLE_TO_FD, &mut pbuf).unwrap(); + sys_ioctl( + &mut proc, + &mut host, + fd, + DRM_IOCTL_PRIME_HANDLE_TO_FD, + &mut pbuf, + ) + .unwrap(); let exported: WpkDrmPrimeHandle = unsafe { core::ptr::read_unaligned(pbuf.as_ptr() as *const WpkDrmPrimeHandle) }; @@ -34499,10 +36079,25 @@ mod tests { let mut buf = [0u8; 16]; sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_SET_MASTER, &mut buf).unwrap(); let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; - assert!(proc.ofd_table.get(ofd_idx).unwrap().kms().unwrap().holds_master); + assert!( + proc.ofd_table + .get(ofd_idx) + .unwrap() + .kms() + .unwrap() + .holds_master + ); sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_DROP_MASTER, &mut buf).unwrap(); - assert!(!proc.ofd_table.get(ofd_idx).unwrap().kms().unwrap().holds_master); + assert!( + !proc + .ofd_table + .get(ofd_idx) + .unwrap() + .kms() + .unwrap() + .holds_master + ); } #[test] @@ -34557,7 +36152,14 @@ mod tests { let req = WpkDrmModeCardRes::default(); let mut buf = [0u8; core::mem::size_of::()]; unsafe { core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmModeCardRes, req) }; - sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_MODE_GETRESOURCES, &mut buf).unwrap(); + sys_ioctl( + &mut proc, + &mut host, + fd, + DRM_IOCTL_MODE_GETRESOURCES, + &mut buf, + ) + .unwrap(); let out: WpkDrmModeCardRes = unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const WpkDrmModeCardRes) }; assert_eq!(out.count_crtcs, 1); @@ -34615,7 +36217,14 @@ mod tests { // Acquire master. let mut master_buf = [0u8; 16]; - sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_SET_MASTER, &mut master_buf).unwrap(); + sys_ioctl( + &mut proc, + &mut host, + fd, + DRM_IOCTL_SET_MASTER, + &mut master_buf, + ) + .unwrap(); // CREATE_DUMB. let create = WpkDrmModeCreateDumb { @@ -34625,8 +36234,17 @@ mod tests { ..Default::default() }; let mut cbuf = [0u8; core::mem::size_of::()]; - unsafe { core::ptr::write_unaligned(cbuf.as_mut_ptr() as *mut WpkDrmModeCreateDumb, create) }; - sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_MODE_CREATE_DUMB, &mut cbuf).unwrap(); + unsafe { + core::ptr::write_unaligned(cbuf.as_mut_ptr() as *mut WpkDrmModeCreateDumb, create) + }; + sys_ioctl( + &mut proc, + &mut host, + fd, + DRM_IOCTL_MODE_CREATE_DUMB, + &mut cbuf, + ) + .unwrap(); let created: WpkDrmModeCreateDumb = unsafe { core::ptr::read_unaligned(cbuf.as_ptr() as *const WpkDrmModeCreateDumb) }; @@ -34653,8 +36271,17 @@ mod tests { ..Default::default() }; let mut crtcbuf = [0u8; core::mem::size_of::()]; - unsafe { core::ptr::write_unaligned(crtcbuf.as_mut_ptr() as *mut WpkDrmModeGetCrtc, crtc_req) }; - sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_MODE_SETCRTC, &mut crtcbuf).unwrap(); + unsafe { + core::ptr::write_unaligned(crtcbuf.as_mut_ptr() as *mut WpkDrmModeGetCrtc, crtc_req) + }; + sys_ioctl( + &mut proc, + &mut host, + fd, + DRM_IOCTL_MODE_SETCRTC, + &mut crtcbuf, + ) + .unwrap(); // PAGE_FLIP enqueues + bumps the commit counter. let commits_before = crate::dri::kms_commit_count(1); @@ -34666,8 +36293,17 @@ mod tests { user_data: 0x42, }; let mut flipbuf = [0u8; core::mem::size_of::()]; - unsafe { core::ptr::write_unaligned(flipbuf.as_mut_ptr() as *mut WpkDrmModeCrtcPageFlip, flip) }; - sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_MODE_PAGE_FLIP, &mut flipbuf).unwrap(); + unsafe { + core::ptr::write_unaligned(flipbuf.as_mut_ptr() as *mut WpkDrmModeCrtcPageFlip, flip) + }; + sys_ioctl( + &mut proc, + &mut host, + fd, + DRM_IOCTL_MODE_PAGE_FLIP, + &mut flipbuf, + ) + .unwrap(); assert_eq!(crate::dri::kms_commit_count(1), commits_before + 1); let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; @@ -34686,8 +36322,17 @@ mod tests { // A back-to-back second PAGE_FLIP succeeds (the previous flip // already retired synchronously) and appends another 32-byte // record to the event_ring. - unsafe { core::ptr::write_unaligned(flipbuf.as_mut_ptr() as *mut WpkDrmModeCrtcPageFlip, flip) }; - sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_MODE_PAGE_FLIP, &mut flipbuf).unwrap(); + unsafe { + core::ptr::write_unaligned(flipbuf.as_mut_ptr() as *mut WpkDrmModeCrtcPageFlip, flip) + }; + sys_ioctl( + &mut proc, + &mut host, + fd, + DRM_IOCTL_MODE_PAGE_FLIP, + &mut flipbuf, + ) + .unwrap(); let kms = proc.ofd_table.get(ofd_idx).unwrap().kms().unwrap(); assert!(kms.pending_flips.is_empty()); assert_eq!(kms.event_ring.len(), 64); @@ -34709,10 +36354,18 @@ mod tests { ..Default::default() }; let mut crtcbuf = [0u8; core::mem::size_of::()]; - unsafe { core::ptr::write_unaligned(crtcbuf.as_mut_ptr() as *mut WpkDrmModeGetCrtc, crtc_req) }; + unsafe { + core::ptr::write_unaligned(crtcbuf.as_mut_ptr() as *mut WpkDrmModeGetCrtc, crtc_req) + }; assert_eq!( - sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_MODE_SETCRTC, &mut crtcbuf) - .unwrap_err(), + sys_ioctl( + &mut proc, + &mut host, + fd, + DRM_IOCTL_MODE_SETCRTC, + &mut crtcbuf + ) + .unwrap_err(), Errno::EACCES ); } @@ -34737,8 +36390,17 @@ mod tests { ..Default::default() }; let mut cbuf = [0u8; core::mem::size_of::()]; - unsafe { core::ptr::write_unaligned(cbuf.as_mut_ptr() as *mut WpkDrmModeCreateDumb, create) }; - sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_MODE_CREATE_DUMB, &mut cbuf).unwrap(); + unsafe { + core::ptr::write_unaligned(cbuf.as_mut_ptr() as *mut WpkDrmModeCreateDumb, create) + }; + sys_ioctl( + &mut proc, + &mut host, + fd, + DRM_IOCTL_MODE_CREATE_DUMB, + &mut cbuf, + ) + .unwrap(); let created: WpkDrmModeCreateDumb = unsafe { core::ptr::read_unaligned(cbuf.as_ptr() as *const WpkDrmModeCreateDumb) }; @@ -34773,7 +36435,15 @@ mod tests { // Per-fd kms.fbs map is empty. let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; - assert!(proc.ofd_table.get(ofd_idx).unwrap().kms().unwrap().fbs.is_empty()); + assert!( + proc.ofd_table + .get(ofd_idx) + .unwrap() + .kms() + .unwrap() + .fbs + .is_empty() + ); // Second RMFB on the same id → ENOENT. let mut rmbuf = fb_out.fb_id.to_le_bytes(); @@ -34802,13 +36472,31 @@ mod tests { }; let mut buf = [0u8; core::mem::size_of::()]; - unsafe { core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmModeCreateDumb, create) }; - sys_ioctl(&mut proc, &mut host, fd_a, DRM_IOCTL_MODE_CREATE_DUMB, &mut buf).unwrap(); + unsafe { + core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmModeCreateDumb, create) + }; + sys_ioctl( + &mut proc, + &mut host, + fd_a, + DRM_IOCTL_MODE_CREATE_DUMB, + &mut buf, + ) + .unwrap(); let on_a: WpkDrmModeCreateDumb = unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const WpkDrmModeCreateDumb) }; - unsafe { core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmModeCreateDumb, create) }; - sys_ioctl(&mut proc, &mut host, fd_b, DRM_IOCTL_MODE_CREATE_DUMB, &mut buf).unwrap(); + unsafe { + core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmModeCreateDumb, create) + }; + sys_ioctl( + &mut proc, + &mut host, + fd_b, + DRM_IOCTL_MODE_CREATE_DUMB, + &mut buf, + ) + .unwrap(); let on_b: WpkDrmModeCreateDumb = unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const WpkDrmModeCreateDumb) }; @@ -34899,7 +36587,10 @@ mod tests { // The host saw a single bind for our pid + the bo whose id we // can recover from the encoded offset. let bo_id = (offset >> 12) as u32; - assert_eq!(host.gbm_bo_bind_calls, vec![(proc.pid as i32, bo_id, addr, aligned_len)]); + assert_eq!( + host.gbm_bo_bind_calls, + vec![(proc.pid as i32, bo_id, addr, aligned_len)] + ); // Process tracks the binding so munmap can locate it later. assert_eq!(proc.dri_bindings.len(), 1); @@ -35122,8 +36813,17 @@ mod tests { ..Default::default() }; let mut cbuf = [0u8; core::mem::size_of::()]; - unsafe { core::ptr::write_unaligned(cbuf.as_mut_ptr() as *mut WpkDrmModeCreateDumb, create) }; - sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_MODE_CREATE_DUMB, &mut cbuf).unwrap(); + unsafe { + core::ptr::write_unaligned(cbuf.as_mut_ptr() as *mut WpkDrmModeCreateDumb, create) + }; + sys_ioctl( + &mut proc, + &mut host, + fd, + DRM_IOCTL_MODE_CREATE_DUMB, + &mut cbuf, + ) + .unwrap(); let created: WpkDrmModeCreateDumb = unsafe { core::ptr::read_unaligned(cbuf.as_ptr() as *const WpkDrmModeCreateDumb) }; @@ -35170,7 +36870,15 @@ mod tests { let err = sys_ioctl(&mut proc, &mut host, fd, gl::GLIO_INIT, &mut buf).unwrap_err(); assert_eq!(err, Errno::ENOSYS); let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; - assert!(proc.ofd_table.get(ofd_idx).unwrap().dri().unwrap().gl.is_none()); + assert!( + proc.ofd_table + .get(ofd_idx) + .unwrap() + .dri() + .unwrap() + .gl + .is_none() + ); } #[test] @@ -35213,13 +36921,24 @@ mod tests { ver_buf.copy_from_slice(&gl::OP_VERSION.to_le_bytes()); sys_ioctl(&mut proc, &mut host, fd, gl::GLIO_INIT, &mut ver_buf).unwrap(); - let attrs = gl::GlContextAttrs { client_version: 2, reserved: [0; 3] }; + let attrs = gl::GlContextAttrs { + client_version: 2, + reserved: [0; 3], + }; let mut buf = [0u8; core::mem::size_of::()]; unsafe { core::ptr::write_unaligned(buf.as_mut_ptr() as *mut gl::GlContextAttrs, attrs) }; sys_ioctl(&mut proc, &mut host, fd, gl::GLIO_CREATE_CONTEXT, &mut buf).unwrap(); let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; - let gls = proc.ofd_table.get(ofd_idx).unwrap().dri().unwrap().gl.as_ref().unwrap(); + let gls = proc + .ofd_table + .get(ofd_idx) + .unwrap() + .dri() + .unwrap() + .gl + .as_ref() + .unwrap(); assert_eq!(gls.context_id, Some(1)); // Second CREATE_CONTEXT on the same fd must fail until the @@ -35232,7 +36951,14 @@ mod tests { // After DESTROY_CONTEXT a fresh CREATE_CONTEXT succeeds. let mut nullbuf = [0u8; 0]; - sys_ioctl(&mut proc, &mut host, fd, gl::GLIO_DESTROY_CONTEXT, &mut nullbuf).unwrap(); + sys_ioctl( + &mut proc, + &mut host, + fd, + gl::GLIO_DESTROY_CONTEXT, + &mut nullbuf, + ) + .unwrap(); unsafe { core::ptr::write_unaligned(buf.as_mut_ptr() as *mut gl::GlContextAttrs, attrs) }; sys_ioctl(&mut proc, &mut host, fd, gl::GLIO_CREATE_CONTEXT, &mut buf).unwrap(); } @@ -35275,11 +37001,22 @@ mod tests { ); // A valid sub-range succeeds and bumps the submit counter. - let info_ok = gl::GlSubmitInfo { offset: 0, length: 64 }; + let info_ok = gl::GlSubmitInfo { + offset: 0, + length: 64, + }; unsafe { core::ptr::write_unaligned(buf.as_mut_ptr() as *mut gl::GlSubmitInfo, info_ok) }; sys_ioctl(&mut proc, &mut host, fd, gl::GLIO_SUBMIT, &mut buf).unwrap(); let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; - let gls = proc.ofd_table.get(ofd_idx).unwrap().dri().unwrap().gl.as_ref().unwrap(); + let gls = proc + .ofd_table + .get(ofd_idx) + .unwrap() + .dri() + .unwrap() + .gl + .as_ref() + .unwrap(); assert_eq!(gls.cmdbuf.unwrap().submit_seq, 1); } @@ -35307,7 +37044,10 @@ mod tests { .unwrap(); host.gl_submit_rc = -(Errno::EINVAL as i32); - let info = gl::GlSubmitInfo { offset: 0, length: 64 }; + let info = gl::GlSubmitInfo { + offset: 0, + length: 64, + }; let mut buf = [0u8; core::mem::size_of::()]; unsafe { core::ptr::write_unaligned(buf.as_mut_ptr() as *mut gl::GlSubmitInfo, info) }; assert_eq!( @@ -35316,7 +37056,15 @@ mod tests { ); let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; - let gls = proc.ofd_table.get(ofd_idx).unwrap().dri().unwrap().gl.as_ref().unwrap(); + let gls = proc + .ofd_table + .get(ofd_idx) + .unwrap() + .dri() + .unwrap() + .gl + .as_ref() + .unwrap(); assert_eq!(gls.cmdbuf.unwrap().submit_seq, 0); } @@ -35359,7 +37107,15 @@ mod tests { .unwrap(); assert_ne!(addr, MAP_FAILED); let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; - let gls = proc.ofd_table.get(ofd_idx).unwrap().dri().unwrap().gl.as_ref().unwrap(); + let gls = proc + .ofd_table + .get(ofd_idx) + .unwrap() + .dri() + .unwrap() + .gl + .as_ref() + .unwrap(); let cb = gls.cmdbuf.expect("cmdbuf binding recorded"); assert_eq!(cb.addr, addr); assert_eq!(cb.len, gl::CMDBUF_LEN); @@ -35406,7 +37162,15 @@ mod tests { sys_munmap(&mut proc, &mut host, addr + 0x10000, 0x10000).unwrap(); let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; - let gls = proc.ofd_table.get(ofd_idx).unwrap().dri().unwrap().gl.as_ref().unwrap(); + let gls = proc + .ofd_table + .get(ofd_idx) + .unwrap() + .dri() + .unwrap() + .gl + .as_ref() + .unwrap(); assert!(gls.cmdbuf.is_none()); assert_eq!(host.gl_unbind_calls, vec![proc.pid as i32]); } @@ -35488,7 +37252,15 @@ mod tests { ); assert_eq!(host.gl_unbind_calls, vec![proc.pid as i32]); let ofd_idx = proc.fd_table.get(gl_fd).unwrap().ofd_ref.0; - assert!(proc.ofd_table.get(ofd_idx).unwrap().dri().unwrap().gl.is_none()); + assert!( + proc.ofd_table + .get(ofd_idx) + .unwrap() + .dri() + .unwrap() + .gl + .is_none() + ); } #[test] diff --git a/crates/kernel/src/terminal.rs b/crates/kernel/src/terminal.rs index 67e6883815..f714f77668 100644 --- a/crates/kernel/src/terminal.rs +++ b/crates/kernel/src/terminal.rs @@ -51,28 +51,15 @@ pub const TCSANOW: u32 = 0; pub const TCSADRAIN: u32 = 1; pub const TCSAFLUSH: u32 = 2; -/// ioctl commands — terminal -pub const TCGETS: u32 = 0x5401; -pub const TCSETS: u32 = 0x5402; -pub const TCSETSW: u32 = 0x5403; -pub const TCSETSF: u32 = 0x5404; -pub const TCSBRK: u32 = 0x5409; -pub const TCXONC: u32 = 0x540A; -pub const TCFLSH: u32 = 0x540B; -pub const TIOCSCTTY: u32 = 0x540E; -pub const TIOCGPGRP: u32 = 0x540F; -pub const TIOCSPGRP: u32 = 0x5410; -pub const TIOCGWINSZ: u32 = 0x5413; -pub const TIOCSWINSZ: u32 = 0x5414; -pub const TIOCNOTTY: u32 = 0x5422; -pub const TIOCGSID: u32 = 0x5429; - -/// ioctl commands — PTY -pub const TIOCGPTN: u32 = 0x80045430; -pub const TIOCSPTLCK: u32 = 0x40045431; +// WHY: these values also select host scratch direction and capacity. Re-export +// the shared contract instead of maintaining a kernel-only mirror. +pub use wasm_posix_shared::ioctl_contract::{ + TCGETS, TCSETS, TCSETSF, TCSETSW, TCFLSH, TCSBRK, TIOCGPGRP, TIOCGPTN, TIOCGSID, + TIOCGWINSZ, TIOCNOTTY, TIOCSPGRP, TIOCSPTLCK, TIOCSCTTY, TIOCSWINSZ, TCXONC, +}; /// musl struct termios size: 4 flags (16) + c_line (1) + c_cc (32) + pad (3) + speeds (8) = 60 -pub const TERMIOS_SIZE: usize = 60; +pub const TERMIOS_SIZE: usize = wasm_posix_shared::ioctl_contract::TERMIOS_SIZE as usize; /// Window size structure #[repr(C)] diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index d7e8a5cd65..e9a6e2d520 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -15,12 +15,16 @@ extern crate alloc; use alloc::vec::Vec; +use core::mem::{align_of, offset_of, size_of}; use core::slice; use wasm_posix_shared::{ - Errno, KernelWaitResult, WasmDirent, WasmStat, WasmStatfs, WasmTimespec, + Errno, KernelWaitResult, WasmDirent, WasmStat, WasmStatfs, WasmTimespec, platform_limits, }; +use crate::channel_scratch::{ + ChannelScratchRegion, checked_cstr_len, validate_channel_scratch_arguments, +}; use crate::ofd::FileType; use crate::process::{ HostIO, Process, ProcessState, StdioConfig, StdioKind, normalize_posix_timer_signo, @@ -30,6 +34,7 @@ use crate::signal::{ deliver_pending_signals_for_tid_with_locks, deliver_pending_signals_with_locks, dequeue_signal_for, terminate_process_by_signal_with_locks, }; +use crate::socket_wire::validate_canonical_message_iov_len; use crate::syscalls; // --------------------------------------------------------------------------- @@ -175,9 +180,12 @@ unsafe extern "C" { fn host_gl_submit(pid: i32, offset: usize, length: usize) -> i32; fn host_gl_present(pid: i32); fn host_gl_query( - pid: i32, op: u32, - in_ptr: *const u8, in_len: usize, - out_ptr: *mut u8, out_len: usize, + pid: i32, + op: u32, + in_ptr: *const u8, + in_len: usize, + out_ptr: *mut u8, + out_len: usize, ) -> i32; fn host_kms_set_master(pid: i32); fn host_kms_drop_master(pid: i32); @@ -929,9 +937,12 @@ impl HostIO for WasmHostIO { fn gl_query(&mut self, pid: i32, op: u32, input: &[u8], out: &mut [u8]) -> i32 { unsafe { host_gl_query( - pid, op, - input.as_ptr(), input.len(), - out.as_mut_ptr(), out.len(), + pid, + op, + input.as_ptr(), + input.len(), + out.as_mut_ptr(), + out.len(), ) } } @@ -952,10 +963,7 @@ impl HostIO for WasmHostIO { unsafe { host_proc_read_bytes(pid, addr, dst.as_mut_ptr(), dst.len() as u32) } } - fn kms_mode_info( - &mut self, - connector_id: u32, - ) -> wasm_posix_shared::dri::WpkDrmModeModeinfo { + fn kms_mode_info(&mut self, connector_id: u32) -> wasm_posix_shared::dri::WpkDrmModeModeinfo { let mut info = wasm_posix_shared::dri::WpkDrmModeModeinfo::default(); unsafe { host_kms_mode_info(connector_id, &mut info as *mut _ as *mut u8) } info @@ -1129,6 +1137,26 @@ unsafe fn get_process_and_advisory_locks() -> ( } } +/// Finish machine-wide SCM_RIGHTS releases, if any, at an exported operation +/// boundary. +/// +/// The caller must end every pipe-table borrow before entering this helper: +/// cleanup can release nested pipe references and invoke host close callbacks. +/// The pending check keeps ordinary channel dispatches and host-TCP pipe +/// operations to one empty-queue branch and avoids walking an empty release +/// queue. +fn finish_machine_scm_rights_cleanup_if_pending() { + syscalls::finish_scm_rights_cleanup_if_pending(|| { + let _gkl = GklGuard::acquire(); + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let mut host = WasmHostIO; + syscalls::finish_scm_rights_cleanup( + table.advisory_locks_mut(), + &mut host, + ); + }); +} + fn current_pid_eids() -> (u32, u32, u32) { let table = unsafe { &*PROCESS_TABLE.0.get() }; let pid = table.current_pid(); @@ -1205,7 +1233,9 @@ pub extern "C" fn kernel_host_adapter_manifest_len() -> u32 { #[unsafe(no_mangle)] pub extern "C" fn kernel_alloc_scratch(size: u32) -> usize { extern crate alloc; - let layout = alloc::alloc::Layout::from_size_align(size as usize, 16).unwrap(); + let Some(layout) = crate::scratch_alloc::layout(size as usize) else { + return 0; + }; let ptr = unsafe { alloc::alloc::alloc_zeroed(layout) }; if ptr.is_null() { return 0; @@ -1213,6 +1243,53 @@ pub extern "C" fn kernel_alloc_scratch(size: u32) -> usize { ptr as usize } +/// Begin one exclusive host-write reservation for a complete SYS_SPAWN blob. +/// +/// Returns a positive opaque token on success or a negated errno on failure. +/// The host must read the pointer and capacity after this call, then either +/// consume the token with `kernel_spawn_reserved_process` or cancel it. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_spawn_scratch_begin(minimum_capacity: usize) -> i64 { + match crate::spawn::begin_spawn_scratch(minimum_capacity) { + Ok(token) => token, + Err(error) => -(error as i64), + } +} + +/// Pointer owned by exactly the SYS_SPAWN reservation named by `token`, or +/// zero for a stale token or if a reentrant query cannot acquire the mutex. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_spawn_scratch_pointer(token: i64) -> usize { + crate::spawn::spawn_scratch_pointer(token).unwrap_or(0) +} + +/// Writable byte capacity of exactly the SYS_SPAWN reservation named by +/// `token`, or zero for a stale token or lock contention. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_spawn_scratch_capacity(token: i64) -> usize { + crate::spawn::spawn_scratch_capacity(token).unwrap_or(0) +} + +/// Retained allocation capacity for diagnostics. This export reveals no +/// pointer and grants no authority to modify an active reservation. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_spawn_scratch_retained_capacity() -> usize { + crate::spawn::spawn_scratch_retained_capacity().unwrap_or(0) +} + +/// Cancel exactly the current SYS_SPAWN reservation. +/// +/// Cancellation waits for the reservation mutex instead of returning a +/// transient EBUSY. The guarded Rust path performs no host imports, so the +/// matching token cannot be stranded by contention. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_spawn_scratch_cancel(token: i64) -> i32 { + match crate::spawn::cancel_spawn_scratch(token) { + Ok(()) => 0, + Err(error) => -(error as i32), + } +} + /// Read the approximate Wasm stack pointer for debugging. /// Returns the address of a stack variable, which is close to the current SP. #[unsafe(no_mangle)] @@ -1577,9 +1654,13 @@ pub extern "C" fn kernel_fork_process(parent_pid: u32, caller_tid: u32) -> i32 { /// responsible for actually launching the new process worker after this /// call returns success — see Task 11. /// -/// SAFETY: caller must ensure the byte range -/// `blob_ptr..blob_ptr + blob_len` lies inside the kernel's linear -/// memory and stays valid for the duration of this call. +/// SAFETY: this ordinary-size entry point is only for the host's checked +/// channel-scratch lease. The caller must prove independently that `blob_ptr` +/// names that kernel-owned allocation, `blob_len` is within its explicit +/// capacity, the complete range is inside current kernel linear memory, and no +/// reentrant operation can replace the bytes for the duration of this call. +/// Merely fitting somewhere in total linear memory is not sufficient. Larger +/// blobs must use the tokenized reservation entry point below. #[unsafe(no_mangle)] pub extern "C" fn kernel_spawn_process( parent_pid: u32, @@ -1587,11 +1668,50 @@ pub extern "C" fn kernel_spawn_process( blob_ptr: usize, blob_len: usize, ) -> i32 { + if blob_len == 0 { + return -(Errno::EINVAL as i32); + } + if blob_len > wasm_posix_shared::channel::MIN_CHANNEL_SIZE { + return -(Errno::E2BIG as i32); + } + if blob_ptr == 0 || blob_ptr.checked_add(blob_len).is_none() { + return -(Errno::EFAULT as i32); + } let bytes = unsafe { core::slice::from_raw_parts(blob_ptr as *const u8, blob_len) }; let parsed = match crate::spawn::parse_blob(bytes) { Ok(p) => p, Err(e) => return -(e as i32), }; + spawn_parsed_for_caller(parent_pid, caller_tid, parsed) +} + +/// Consume one tokenized kernel-owned SYS_SPAWN reservation. +/// +/// Unlike `kernel_spawn_process`, this entry point never accepts a host-chosen +/// pointer. The reservation validates its token and byte count, parses into an +/// owned representation, restores its Idle state even on malformed input, and +/// releases its mutex before this function enters the process table. Commit +/// waits through mutex contention; no host import occurs while that lock is +/// held, so every matching token is consumed before this export returns. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_spawn_reserved_process( + parent_pid: u32, + caller_tid: u32, + token: i64, + blob_len: usize, +) -> i32 { + let parsed = match crate::spawn::parse_reserved_spawn_blob(token, blob_len) { + Ok(parsed) => parsed, + Err(error) => return -(error as i32), + }; + spawn_parsed_for_caller(parent_pid, caller_tid, parsed) +} + +fn spawn_parsed_for_caller( + parent_pid: u32, + caller_tid: u32, + parsed: crate::spawn::ParsedBlob, +) -> i32 { // Borrow argv/envp as &[&[u8]] for the spawn_child API. let argv_refs: alloc::vec::Vec<&[u8]> = parsed.argv.iter().map(|v| v.as_slice()).collect(); let envp_refs: alloc::vec::Vec<&[u8]> = parsed.envp.iter().map(|v| v.as_slice()).collect(); @@ -1687,9 +1807,7 @@ pub extern "C" fn kernel_get_process_exit_status(pid: u32) -> i32 { pub extern "C" fn kernel_get_process_exit_signal(pid: u32) -> i32 { let table = unsafe { &*PROCESS_TABLE.0.get() }; match table.get(pid) { - Some(proc) if proc.state == crate::process::ProcessState::Exited => { - proc.exit_signal as i32 - } + Some(proc) if proc.state == crate::process::ProcessState::Exited => proc.exit_signal as i32, Some(_) => -1, None => -(Errno::ESRCH as i32), } @@ -1751,10 +1869,14 @@ pub extern "C" fn kernel_wait_child_poll( event_mask: u32, flags: u32, out_ptr: *mut KernelWaitResult, + out_capacity: u32, ) -> i32 { if out_ptr.is_null() { return -(Errno::EFAULT as i32); } + if out_capacity != wasm_posix_shared::KERNEL_WAIT_RESULT_SIZE { + return -(Errno::EINVAL as i32); + } let selected = { let table = unsafe { &mut *PROCESS_TABLE.0.get() }; @@ -1901,11 +2023,7 @@ pub extern "C" fn kernel_thread_has_deliverable(pid: u32, tid: u32) -> i32 { if !proc.is_live_explicit_tid(tid) { return -(Errno::ESRCH as i32); } - if proc.deliverable_for(tid) != 0 { - 1 - } else { - 0 - } + if proc.deliverable_for(tid) != 0 { 1 } else { 0 } } None => -(Errno::ESRCH as i32), } @@ -2154,14 +2272,24 @@ fn process_name_bytes(proc: &crate::process::Process) -> Vec { } /// Dequeue one pending Handler signal for an exact live task. -/// Writes signal delivery info to `out_ptr` (24 bytes): -/// [0..4] signum (u32), [4..8] handler_index (u32), [8..12] sa_flags (u32), -/// [16..24] old_blocked_mask (u64) +/// Writes the shared `kernel_scratch_wire` signal-delivery record to `out_ptr`. /// Applies sa_mask | sig_bit(signum) to the process's blocked mask (POSIX). /// Returns signum (>0) if a signal was dequeued, 0 if none pending. #[unsafe(no_mangle)] -pub extern "C" fn kernel_dequeue_signal(pid: u32, tid: u32, out_ptr: *mut u8) -> i32 { +pub extern "C" fn kernel_dequeue_signal( + pid: u32, + tid: u32, + out_ptr: *mut u8, + out_capacity: u32, +) -> i32 { use crate::signal::{SignalHandler, sig_bit}; + use wasm_posix_shared::kernel_scratch_wire as signal_wire; + + if let Err(error) = + crate::process_wire::validate_signal_delivery_output(out_ptr, out_capacity) + { + return -(error as i32); + } let table = unsafe { &mut *PROCESS_TABLE.0.get() }; let (proc, advisory_locks) = match table.task_and_advisory_locks(pid, tid) { Some(pair) => pair, @@ -2209,33 +2337,49 @@ pub extern "C" fn kernel_dequeue_signal(pid: u32, tid: u32, out_ptr: *mut u8) -> proc.alt_stack_depth += 1; proc.alt_stack_flags |= SS_ONSTACK; } - // Write to output buffer: - // [0..4] signum, [4..8] handler_idx, [8..12] flags, - // [12..16] si_value, [16..24] old_mask, - // [24..28] si_code, [28..32] first siginfo union word, - // [32..36] second siginfo union word, - // [36..40] alt_sp (0 if no switch), [40..44] alt_size - let buf = unsafe { slice::from_raw_parts_mut(out_ptr, 44) }; - buf[0..4].copy_from_slice(&signum.to_le_bytes()); - buf[4..8].copy_from_slice(&idx.to_le_bytes()); - buf[8..12].copy_from_slice(&action.flags.to_le_bytes()); - buf[12..16].copy_from_slice(&si_value.to_le_bytes()); - buf[16..24].copy_from_slice(&old_mask.to_le_bytes()); - buf[24..28].copy_from_slice(&si_code.to_le_bytes()); - buf[28..32].copy_from_slice(&siginfo_word_1.to_le_bytes()); - buf[32..36].copy_from_slice(&siginfo_word_2.to_le_bytes()); - if switch_to_alt_stack { - buf[36..40].copy_from_slice(&(proc.alt_stack_sp as u32).to_le_bytes()); - buf[40..44].copy_from_slice(&(proc.alt_stack_size as u32).to_le_bytes()); - } else { - buf[36..44].fill(0); - } + // Encode into owned bytes before touching the destination. + // The host publishes this complete record from one exclusive + // lease, so no observer can see a partially replaced wire. + let encoded = crate::process_wire::encode_signal_delivery_record( + crate::process_wire::SignalDeliveryRecord { + signum, + handler: idx, + flags: action.flags, + si_value_bits: si_value, + old_mask, + si_code, + siginfo_word_1, + siginfo_word_2, + alt_sp: if switch_to_alt_stack { + proc.alt_stack_sp + } else { + 0 + }, + alt_size: if switch_to_alt_stack { + proc.alt_stack_size + } else { + 0 + }, + }, + ); + let buf = unsafe { + slice::from_raw_parts_mut( + out_ptr, + signal_wire::SIGNAL_DELIVERY_BYTES as usize, + ) + }; + buf.copy_from_slice(&encoded); return signum as i32; } SignalHandler::Default => { let _ = dequeue_signal_for(proc, tid, signum); let mut host = WasmHostIO; - match apply_default_signal_action_with_locks(proc, advisory_locks, &mut host, signum) { + match apply_default_signal_action_with_locks( + proc, + advisory_locks, + &mut host, + signum, + ) { DefaultSignalOutcome::Continue => continue, DefaultSignalOutcome::Stopped | DefaultSignalOutcome::Exited => return 0, } @@ -2286,13 +2430,7 @@ fn prepare_exec_state(pid: u32, caller_tid: u32) -> Result<(), Errno> { use crate::process::FdAction; match action { FdAction::Dup2 { old_fd, new_fd } => { - syscalls::sys_dup2_with_locks( - proc, - advisory_locks, - &mut host, - old_fd, - new_fd, - )?; + syscalls::sys_dup2_with_locks(proc, advisory_locks, &mut host, old_fd, new_fd)?; } FdAction::Close { fd } => { syscalls::sys_close_with_locks(proc, advisory_locks, &mut host, fd)?; @@ -2306,19 +2444,9 @@ fn prepare_exec_state(pid: u32, caller_tid: u32) -> Result<(), Errno> { let opened_fd = syscalls::sys_open(proc, &mut host, path, flags as u32, mode as u32)?; if opened_fd != fd { - syscalls::sys_dup2_with_locks( - proc, - advisory_locks, - &mut host, - opened_fd, - fd, - )?; - let _ = syscalls::sys_close_with_locks( - proc, - advisory_locks, - &mut host, - opened_fd, - ); + syscalls::sys_dup2_with_locks(proc, advisory_locks, &mut host, opened_fd, fd)?; + let _ = + syscalls::sys_close_with_locks(proc, advisory_locks, &mut host, opened_fd); } } } @@ -2398,15 +2526,30 @@ fn mq_would_block_result(timeout_ptr: usize, table: &crate::mqueue::MqueueTable, } /// Dispatches to the appropriate kernel function, then writes: -/// - return value at offset+32 /// - return value (i64) at offset+56 /// - errno (i32) at offset+64 /// /// Returns the raw syscall result (also written to channel). #[unsafe(no_mangle)] -pub extern "C" fn kernel_handle_channel(offset: usize, pid: u32) -> i32 { +pub extern "C" fn kernel_handle_channel( + offset: usize, + capacity: u32, + pid: u32, +) -> i32 { use wasm_posix_shared::channel::*; + if capacity as usize != MIN_CHANNEL_SIZE { + unsafe { &mut *PROCESS_TABLE.0.get() }.clear_current_tid_binding(); + return -(Errno::EINVAL as i32); + } + let scratch_region = match ChannelScratchRegion::for_channel(offset) { + Ok(region) => region, + Err(error) => { + unsafe { &mut *PROCESS_TABLE.0.get() }.clear_current_tid_binding(); + return -(error as i32); + } + }; + // Every mailbox call consumes an explicit kernel-validated task binding // installed by kernel_set_current_tid. Missing or stale ambient state must // not silently become main-thread authority. @@ -2417,33 +2560,37 @@ pub extern "C" fn kernel_handle_channel(offset: usize, pid: u32) -> i32 { // Read syscall number and args from kernel memory let base = offset; - let mem = unsafe { - let ptr = base as *const u8; - core::slice::from_raw_parts(ptr, MIN_CHANNEL_SIZE) - }; - - let syscall_nr = u32::from_le_bytes([ - mem[SYSCALL_OFFSET], - mem[SYSCALL_OFFSET + 1], - mem[SYSCALL_OFFSET + 2], - mem[SYSCALL_OFFSET + 3], - ]); - - // Read i64 args (each arg is 8 bytes in the widened channel layout) - let mut args = [0i64; ARGS_COUNT]; - for i in 0..ARGS_COUNT { - let off = ARGS_OFFSET + i * ARG_SIZE; - args[i] = i64::from_le_bytes([ - mem[off], - mem[off + 1], - mem[off + 2], - mem[off + 3], - mem[off + 4], - mem[off + 5], - mem[off + 6], - mem[off + 7], + let (syscall_nr, args) = { + // Keep this immutable view scoped to header decoding. Dispatch can + // mutate the same channel allocation through rewritten pointer args. + let mem = unsafe { + let ptr = base as *const u8; + core::slice::from_raw_parts(ptr, MIN_CHANNEL_SIZE) + }; + let syscall_nr = u32::from_le_bytes([ + mem[SYSCALL_OFFSET], + mem[SYSCALL_OFFSET + 1], + mem[SYSCALL_OFFSET + 2], + mem[SYSCALL_OFFSET + 3], ]); - } + + // Read i64 args (each arg is 8 bytes in the widened channel layout) + let mut args = [0i64; ARGS_COUNT]; + for (i, arg) in args.iter_mut().enumerate() { + let off = ARGS_OFFSET + i * ARG_SIZE; + *arg = i64::from_le_bytes([ + mem[off], + mem[off + 1], + mem[off + 2], + mem[off + 3], + mem[off + 4], + mem[off + 5], + mem[off + 6], + mem[off + 7], + ]); + } + (syscall_nr, args) + }; // Pointer args in the channel reference kernel memory (JS copies data // into the data buffer at offset + DATA_OFFSET). Convert relative @@ -2453,11 +2600,19 @@ pub extern "C" fn kernel_handle_channel(offset: usize, pid: u32) -> i32 { // kernel-memory addresses, so we pass them through unchanged. let result = if has_task_binding { - dispatch_channel_syscall(syscall_nr, &args) + dispatch_channel_syscall(syscall_nr, &args, scratch_region) } else { -(Errno::ESRCH as i32) }; + // Consume ambient task authority before cleanup can invoke a host close + // callback. Cleanup needs no process identity, and a callback trap must + // not leave a stale binding available to a later dispatch. unsafe { &mut *PROCESS_TABLE.0.get() }.clear_current_tid_binding(); + // WHY: queue mutation cannot re-enter global resource tables while those + // tables are borrowed. This conditional outer boundary makes every + // channel syscall fail-safe against a newly introduced ancillary drop, + // while the ordinary hot path pays only an empty-queue check. + finish_machine_scm_rights_cleanup_if_pending(); // Write result back to channel let out = unsafe { @@ -2482,30 +2637,92 @@ pub extern "C" fn kernel_handle_channel(offset: usize, pid: u32) -> i32 { result } -/// Compute the length of a null-terminated C string at `ptr` in kernel memory. -/// Safety: `ptr` must point to valid kernel memory containing a null terminator. -unsafe fn cstr_len(ptr: *const u8) -> u32 { - if ptr.is_null() { - return 0; - } - let mut len = 0u32; - while unsafe { *ptr.add(len as usize) } != 0 && len < 4096 { - len += 1; +/// Convert a raw widened-channel pointer without first narrowing it through +/// `i32`. +/// +/// WHY: the channel stores all arguments as signed `i64`, including pointer +/// bit patterns. A valid wasm64 pointer with bit 63 set therefore arrives as a +/// negative `i64`; interpreting the value as signed, or routing it through the +/// scalar `a1..a6` aliases below, would either reject it or discard its upper +/// 32 bits. Reinterpreting the bits as `u64` first preserves the pointer, while +/// the checked `usize` conversion rejects values that cannot fit the kernel +/// Wasm target (notably a wasm64 value presented to a wasm32 kernel). +fn checked_channel_pointer_bits(raw: i64, pointer_bits: u32) -> Result { + let pointer = raw as u64; + let max_pointer = match pointer_bits { + 32 => u32::MAX as u64, + 64 => u64::MAX, + _ => return Err(Errno::EFAULT), + }; + if pointer > max_pointer { + Err(Errno::EFAULT) + } else { + Ok(pointer) } - len } +fn checked_channel_pointer(raw: i64) -> Result { + let pointer = checked_channel_pointer_bits(raw, usize::BITS)?; + usize::try_from(pointer).map_err(|_| Errno::EFAULT) +} + +/// Preserve the dispatcher's established i32 interpretation for scalar +/// count/length fields that are subsequently passed to a `usize` API. +/// +/// Pointer fields must never use this helper. +fn channel_i32_scalar_usize(value: i32) -> usize { + value as usize +} + +/// Zero-extend a scalar u32 count/length into the kernel target's `usize`. +/// +/// Pointer fields must never use this helper. +fn channel_u32_scalar_usize(value: i32) -> usize { + usize::try_from(value as u32).expect("all supported kernel targets represent u32") +} + +fn checked_channel_usize_scalar(raw: i64) -> Result { + usize::try_from(raw).map_err(|_| Errno::EINVAL) +} + +// WHY: these exports gained explicit capacities together. Keep their exact +// ABI-visible signatures compile-checked beside the channel dispatcher so a +// future pointer-only call or partial signature migration cannot compile. +const _: extern "C" fn(u32, *mut i32, u32) -> i32 = kernel_pipe2; +const _: extern "C" fn(u32, u32, u32, *mut i32, u32) -> i32 = kernel_socketpair; +const _: extern "C" fn(i32, u32, u32, *mut u8, u32, *mut u32, u32) -> i32 = + kernel_getsockopt; +const _: extern "C" fn(*mut u8, u32, u32, i32) -> i32 = kernel_poll; +const _: extern "C" fn( + i32, + *mut u8, + u32, + *mut u8, + u32, + *mut u8, + u32, + i32, +) -> i32 = kernel_select; + /// Dispatch a syscall by number with raw musl arguments. /// /// IMPORTANT: The args are in musl's raw format, NOT the kernel_* export format. -/// For path syscalls, musl passes null-terminated string pointers without explicit -/// lengths. This function computes string lengths via cstr_len() because the JS -/// layer has already copied the strings into kernel memory. +/// For path syscalls, musl passes null-terminated string pointers without +/// explicit lengths. This function computes bounded, terminated string +/// lengths because the JS layer has already copied the strings into kernel +/// memory. /// /// Returns the raw kernel result (negative = -errno, non-negative = success value). -fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { - // Most syscalls use i32 args (addresses, fds, flags). Truncate here. - // Syscalls needing full i64 (pread/pwrite offsets, truncate lengths) use args[] directly. +fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScratchRegion) -> i32 { + let validated_scratch = + match unsafe { validate_channel_scratch_arguments(nr, args, scratch_region) } { + Ok(validated) => validated, + Err(error) => return -(error as i32), + }; + + // Scalar arguments retain the syscall ABI's existing i32 interpretation. + // Pointer arguments must instead use the checked macros below so wasm64 + // address bits are never lost through these scalar aliases. let a1 = args[0] as i32; let a2 = args[1] as i32; let a3 = args[2] as i32; @@ -2513,6 +2730,43 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { let a5 = args[4] as i32; let a6 = args[5] as i32; + // Raw pointers below are process-space addresses or signal-handler values, + // never kernel scratch. Scratch dereferences must use the validated + // const/mut macros so allocation capacity remains part of their proof. + macro_rules! process_address { + ($index:literal) => { + match checked_channel_pointer(args[$index]) { + Ok(pointer) => pointer, + Err(error) => return -(error as i32), + } + }; + } + macro_rules! channel_const_ptr { + ($index:literal, $pointee:ty) => { + match validated_scratch.pointer($index) { + Ok(pointer) => pointer as *const $pointee, + Err(error) => return -(error as i32), + } + }; + } + macro_rules! channel_mut_ptr { + ($index:literal, $pointee:ty) => { + match validated_scratch.pointer($index) { + Ok(pointer) => pointer as *mut $pointee, + Err(error) => return -(error as i32), + } + }; + } + macro_rules! channel_cstr_len { + ($pointer:expr) => {{ + let pointer = $pointer; + match unsafe { checked_cstr_len(pointer, scratch_region) } { + Ok(length) => length, + Err(error) => return -(error as i32), + } + }}; + } + // Syscall number constants (must match libc/glue/syscall_glue.c) match nr { // Process info (0-arg) @@ -2539,8 +2793,8 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { // File operations — musl: (path, flags, mode) 1 => { // SYS_OPEN - let p = a1 as *const u8; - let len = unsafe { cstr_len(p) }; + let p = channel_const_ptr!(0, u8); + let len = channel_cstr_len!(p); kernel_open(p, len, a2 as u32, a3 as u32) } 2 => { @@ -2556,8 +2810,8 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { kernel_close(a1) } } - 3 => kernel_read(a1, a2 as *mut u8, a3 as u32), // SYS_READ: (fd, buf, count) - 4 => kernel_write(a1, a2 as *const u8, a3 as u32), // SYS_WRITE: (fd, buf, count) + 3 => kernel_read(a1, channel_mut_ptr!(1, u8), a3 as u32), // SYS_READ: (fd, buf, count) + 4 => kernel_write(a1, channel_const_ptr!(1, u8), a3 as u32), // SYS_WRITE: (fd, buf, count) 5 => kernel_lseek(a1, a2 as u32, a3, a4 as u32) as i32, // SYS_LSEEK: (fd, off_lo, off_hi, whence) 119 => { // SYS__LLSEEK: (fd, off_hi, off_lo, result_ptr, whence) @@ -2566,7 +2820,7 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { result as i32 } else { // Write 64-bit result to result_ptr - let ptr = a4 as usize as *mut u8; + let ptr = channel_mut_ptr!(3, u8); unsafe { let bytes = result.to_le_bytes(); for i in 0..8 { @@ -2576,16 +2830,23 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { 0 } } - 6 => kernel_fstat(a1, a2 as *mut u8), // SYS_FSTAT: (fd, stat_ptr) - 64 => kernel_pread(a1, a2 as *mut u8, a3 as u32, args[3]), // SYS_PREAD: (fd, buf, count, offset) - 65 => kernel_pwrite(a1, a2 as *const u8, a3 as u32, args[3]), // SYS_PWRITE: (fd, buf, count, offset) + 6 => { + let stat_pointer = channel_mut_ptr!(1, u8); + kernel_fstat(a1, stat_pointer) + } + 64 => kernel_pread(a1, channel_mut_ptr!(1, u8), a3 as u32, args[3]), // SYS_PREAD: (fd, buf, count, offset) + 65 => kernel_pwrite(a1, channel_const_ptr!(1, u8), a3 as u32, args[3]), // SYS_PWRITE: (fd, buf, count, offset) // FD operations - 7 => kernel_dup(a1), // SYS_DUP - 8 => kernel_dup2(a1, a2), // SYS_DUP2 - 77 => kernel_dup3(a1, a2, a3 as u32), // SYS_DUP3 - 9 => kernel_pipe(a1 as *mut i32), // SYS_PIPE: (pipefd_ptr) - 78 => kernel_pipe2(a2 as u32, a1 as *mut i32), // SYS_PIPE2: (pipefd_ptr, flags) → kernel wants (flags, pipefd_ptr) + 7 => kernel_dup(a1), // SYS_DUP + 8 => kernel_dup2(a1, a2), // SYS_DUP2 + 77 => kernel_dup3(a1, a2, a3 as u32), // SYS_DUP3 + 9 => kernel_pipe(channel_mut_ptr!(0, i32)), // SYS_PIPE: (pipefd_ptr) + 78 => kernel_pipe2( + a2 as u32, + channel_mut_ptr!(0, i32), + wasm_posix_shared::kernel_scratch_wire::FD_PAIR_BYTES, + ), // SYS_PIPE2: (pipefd_ptr, flags) → kernel wants (flags, pipefd_ptr, capacity) 10 => { // SYS_FCNTL: (fd, cmd, arg) match a2 as u32 { @@ -2593,7 +2854,7 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { // POSIX: 5=F_GETLK, 6=F_SETLK, 7=F_SETLKW; 12-14=64-bit variants // OFD: 36=F_OFD_GETLK, 37=F_OFD_SETLK, 38=F_OFD_SETLKW 5 | 6 | 7 | 12 | 13 | 14 | 36 | 37 | 38 => { - kernel_fcntl_lock(a1, a2 as u32, a3 as *mut u8) + kernel_fcntl_lock(a1, a2 as u32, channel_mut_ptr!(2, u8)) } _ => kernel_fcntl(a1, a2 as u32, a3 as u32), } @@ -2603,101 +2864,109 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { // Stat — musl: (path, stat_buf) / (path, stat_buf) / (dirfd, path, stat_buf, flags) 11 => { // SYS_STAT - let p = a1 as *const u8; - let len = unsafe { cstr_len(p) }; - kernel_stat(p, len, a2 as *mut u8) + let p = channel_const_ptr!(0, u8); + let len = channel_cstr_len!(p); + let stat_pointer = channel_mut_ptr!(1, u8); + kernel_stat(p, len, stat_pointer) } 12 => { // SYS_LSTAT - let p = a1 as *const u8; - let len = unsafe { cstr_len(p) }; - kernel_lstat(p, len, a2 as *mut u8) + let p = channel_const_ptr!(0, u8); + let len = channel_cstr_len!(p); + let stat_pointer = channel_mut_ptr!(1, u8); + kernel_lstat(p, len, stat_pointer) } 93 => { // SYS_FSTATAT: (dirfd, path, stat_buf, flags) - let p = a2 as *const u8; - let len = unsafe { cstr_len(p) }; - kernel_fstatat(a1, p, len, a3 as *mut u8, a4 as u32) + let p = channel_const_ptr!(1, u8); + let len = channel_cstr_len!(p); + let stat_pointer = channel_mut_ptr!(2, u8); + kernel_fstatat(a1, p, len, stat_pointer, a4 as u32) } // Directory operations — musl passes null-terminated paths 13 => { // SYS_MKDIR: (path, mode) - let p = a1 as *const u8; - let len = unsafe { cstr_len(p) }; + let p = channel_const_ptr!(0, u8); + let len = channel_cstr_len!(p); kernel_mkdir(p, len, a2 as u32) } 14 => { // SYS_RMDIR: (path) - let p = a1 as *const u8; - let len = unsafe { cstr_len(p) }; + let p = channel_const_ptr!(0, u8); + let len = channel_cstr_len!(p); kernel_rmdir(p, len) } 15 => { // SYS_UNLINK: (path) - let p = a1 as *const u8; - let len = unsafe { cstr_len(p) }; + let p = channel_const_ptr!(0, u8); + let len = channel_cstr_len!(p); kernel_unlink(p, len) } 16 => { // SYS_RENAME: (old_path, new_path) - let old = a1 as *const u8; - let new = a2 as *const u8; - kernel_rename(old, unsafe { cstr_len(old) }, new, unsafe { cstr_len(new) }) + let old = channel_const_ptr!(0, u8); + let new = channel_const_ptr!(1, u8); + kernel_rename(old, channel_cstr_len!(old), new, channel_cstr_len!(new)) } 17 => { // SYS_LINK: (old_path, new_path) - let old = a1 as *const u8; - let new = a2 as *const u8; - kernel_link(old, unsafe { cstr_len(old) }, new, unsafe { cstr_len(new) }) + let old = channel_const_ptr!(0, u8); + let new = channel_const_ptr!(1, u8); + kernel_link(old, channel_cstr_len!(old), new, channel_cstr_len!(new)) } 18 => { // SYS_SYMLINK: (target, linkpath) - let tgt = a1 as *const u8; - let lnk = a2 as *const u8; - kernel_symlink(tgt, unsafe { cstr_len(tgt) }, lnk, unsafe { cstr_len(lnk) }) + let tgt = channel_const_ptr!(0, u8); + let lnk = channel_const_ptr!(1, u8); + kernel_symlink(tgt, channel_cstr_len!(tgt), lnk, channel_cstr_len!(lnk)) } 19 => { // SYS_READLINK: (path, buf, bufsiz) - let p = a1 as *const u8; - let len = unsafe { cstr_len(p) }; - kernel_readlink(p, len, a2 as *mut u8, a3 as u32) + let p = channel_const_ptr!(0, u8); + let len = channel_cstr_len!(p); + kernel_readlink(p, len, channel_mut_ptr!(1, u8), a3 as u32) } 20 => { // SYS_CHMOD: (path, mode) - let p = a1 as *const u8; - let len = unsafe { cstr_len(p) }; + let p = channel_const_ptr!(0, u8); + let len = channel_cstr_len!(p); kernel_chmod(p, len, a2 as u32) } 21 => { // SYS_CHOWN: (path, uid, gid) - let p = a1 as *const u8; - let len = unsafe { cstr_len(p) }; + let p = channel_const_ptr!(0, u8); + let len = channel_cstr_len!(p); kernel_chown(p, len, a2 as u32, a3 as u32) } 22 => { // SYS_ACCESS: (path, mode) - let p = a1 as *const u8; - let len = unsafe { cstr_len(p) }; + let p = channel_const_ptr!(0, u8); + let len = channel_cstr_len!(p); kernel_access(p, len, a2 as u32) } - 23 => kernel_getcwd(a1 as *mut u8, a2 as u32), // SYS_GETCWD: (buf, size) + 23 => kernel_getcwd(channel_mut_ptr!(0, u8), a2 as u32), // SYS_GETCWD: (buf, size) 24 => { // SYS_CHDIR: (path) - let p = a1 as *const u8; - let len = unsafe { cstr_len(p) }; + let p = channel_const_ptr!(0, u8); + let len = channel_cstr_len!(p); kernel_chdir(p, len) } 127 => kernel_fchdir(a1), // SYS_FCHDIR 25 => { // SYS_OPENDIR: (path) - let p = a1 as *const u8; - let len = unsafe { cstr_len(p) }; + let p = channel_const_ptr!(0, u8); + let len = channel_cstr_len!(p); kernel_opendir(p, len) } - 26 => kernel_readdir(a1, a2 as *mut u8, a3 as *mut u8, a4 as u32), // SYS_READDIR - 27 => kernel_closedir(a1), // SYS_CLOSEDIR - 122 => kernel_getdents64(a1, a2 as *mut u8, a3 as u32), // SYS_GETDENTS64 + 26 => kernel_readdir( + a1, + channel_mut_ptr!(1, u8), + channel_mut_ptr!(2, u8), + a4 as u32, + ), // SYS_READDIR + 27 => kernel_closedir(a1), // SYS_CLOSEDIR + 122 => kernel_getdents64(a1, channel_mut_ptr!(1, u8), a3 as u32), // SYS_GETDENTS64 // Process control 34 => { @@ -2712,15 +2981,19 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { 38 => kernel_raise(a1 as u32), // SYS_RAISE // Signals - 36 => kernel_sigaction(a1 as u32, a2 as *const u8, a3 as *mut u8), // SYS_SIGACTION + 36 => kernel_sigaction( + a1 as u32, + channel_const_ptr!(1, u8), + channel_mut_ptr!(2, u8), + ), // SYS_SIGACTION 37 => { // SYS_SIGPROCMASK: (how, set_ptr, oldset_ptr, sigsetsize) // musl passes pointers to sigset_t (8 bytes). Read set from pointer, // call kernel, write old set to output pointer. // POSIX: if set is NULL, the signal mask is not changed (query only). let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; - if a2 != 0 { - let ptr = a2 as usize as *const u32; + if args[1] != 0 { + let ptr = channel_const_ptr!(1, u32); let (set_lo, set_hi) = unsafe { (*ptr, *ptr.add(1)) }; let set = ((set_hi as u64) << 32) | (set_lo as u64); // Call sys_sigprocmask directly — kernel_sigprocmask returns @@ -2730,8 +3003,8 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { Ok(old) => old, Err(e) => return -(e as i32), }; - if a3 != 0 { - let ptr = a3 as usize as *mut u8; + if args[2] != 0 { + let ptr = channel_mut_ptr!(2, u8); unsafe { let bytes = old_mask.to_le_bytes(); for i in 0..8 { @@ -2744,8 +3017,8 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { 0 } else { // set is NULL: just read the current mask without modifying - if a3 != 0 { - let ptr = a3 as usize as *mut u8; + if args[2] != 0 { + let ptr = channel_mut_ptr!(2, u8); unsafe { let bytes = proc.signals.blocked.to_le_bytes(); for i in 0..8 { @@ -2756,12 +3029,12 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { 0 } } - 73 => kernel_signal(a1 as u32, a2 as u32 as usize), // SYS_SIGNAL - 39 => kernel_alarm(a1 as u32), // SYS_ALARM + 73 => kernel_signal(a1 as u32, process_address!(1)), // SYS_SIGNAL + 39 => kernel_alarm(a1 as u32), // SYS_ALARM 110 => { // SYS_SIGSUSPEND: (mask_ptr, sigsetsize) - let (mask_lo, mask_hi) = if a1 != 0 { - let ptr = a1 as usize as *const u32; + let (mask_lo, mask_hi) = if args[0] != 0 { + let ptr = channel_const_ptr!(0, u32); unsafe { (*ptr, *ptr.add(1)) } } else { (0u32, 0u32) @@ -2772,8 +3045,8 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { 206 => { // SYS_RT_SIGPENDING: (set_ptr, sigsetsize) let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; - if a1 != 0 { - let ptr = a1 as usize as *mut u8; + if args[0] != 0 { + let ptr = channel_mut_ptr!(0, u8); unsafe { let bytes = proc .pending_for(crate::process_table::current_tid()) @@ -2787,11 +3060,15 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { } 207 => { // SYS_RT_SIGTIMEDWAIT: (mask_ptr, info_ptr, timeout_ptr, sigsetsize) + let model = match crate::process_wire::ProcessDataModel::from_width(args[5]) { + Ok(model) => model, + Err(error) => return -(error as i32), + }; let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; // Read the 64-bit signal mask from the pointer - let mask = if a1 != 0 { - let p = a1 as usize as *const u8; + let mask = if args[0] != 0 { + let p = channel_const_ptr!(0, u8); let mut bytes = [0u8; 8]; unsafe { for i in 0..8 { @@ -2803,8 +3080,8 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { 0 }; // Read timeout from timespec pointer (time64: i64 sec + i64 nsec) - let timeout_ms = if a3 != 0 { - let p = a3 as usize as *const u8; + let timeout_ms = if args[2] != 0 { + let p = channel_const_ptr!(2, u8); let mut sec_bytes = [0u8; 8]; let mut nsec_bytes = [0u8; 8]; unsafe { @@ -2824,34 +3101,31 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { let result = match syscalls::sys_sigtimedwait(proc, &mut host, mask, timeout_ms) { Ok((sig, si_value, si_code, siginfo_word_1, siginfo_word_2)) => { // Write siginfo_t if pointer is non-null - if a2 != 0 { - let p = a2 as usize as *mut u8; - // Fixed channel siginfo transport: si_signo(0), - // si_errno(4), si_code(8), first union words (12/16), - // and sival_int(20). The host expands these fields to - // musl's eight-byte-aligned wasm64 siginfo_t layout. - let sig_bytes = (sig as i32).to_le_bytes(); - let code_bytes = si_code.to_le_bytes(); - let word_1_bytes = siginfo_word_1.to_le_bytes(); - let word_2_bytes = siginfo_word_2.to_le_bytes(); - let val_bytes = si_value.to_le_bytes(); - unsafe { - for i in 0..4 { - *p.add(i) = sig_bytes[i]; - } - for i in 0..4 { - *p.add(8 + i) = code_bytes[i]; - } - for i in 0..4 { - *p.add(12 + i) = word_1_bytes[i]; - } - for i in 0..4 { - *p.add(16 + i) = word_2_bytes[i]; - } - for i in 0..4 { - *p.add(20 + i) = val_bytes[i]; - } + if args[1] != 0 { + let p = channel_mut_ptr!(1, u8); + let mut encoded = alloc::vec![0; model.siginfo_size()]; + if let Err(error) = crate::process_wire::write_siginfo( + &mut encoded, + crate::process_wire::NativeSiginfo { + signo: sig as i32, + code: si_code, + word_1: siginfo_word_1, + // Keep uid/timer-overrun bits lossless across + // the signed/unsigned siginfo union views. + word_2_bits: siginfo_word_2 as u32, + value_bits: si_value, + }, + model, + ) { + return -(error as i32); } + // WHY: encode the complete native object before + // replacing the capacity-checked scratch destination. + // The host holds one exclusive synchronous lease. + let output = unsafe { + slice::from_raw_parts_mut(p, model.siginfo_size()) + }; + output.copy_from_slice(&encoded); } sig as i32 } @@ -2862,20 +3136,20 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { } // Time - 40 => kernel_clock_gettime(a1 as u32, a2 as *mut u8), // SYS_CLOCK_GETTIME - 41 => kernel_nanosleep(a1 as *const u8), // SYS_NANOSLEEP - 123 => kernel_clock_getres(a1 as u32, a2 as *mut u8), // SYS_CLOCK_GETRES - 124 => kernel_clock_nanosleep(a1 as u32, a2 as u32, a3 as *const u8), // SYS_CLOCK_NANOSLEEP + 40 => kernel_clock_gettime(a1 as u32, channel_mut_ptr!(1, u8)), // SYS_CLOCK_GETTIME + 41 => kernel_nanosleep(channel_const_ptr!(0, u8)), // SYS_NANOSLEEP + 123 => kernel_clock_getres(a1 as u32, channel_mut_ptr!(1, u8)), // SYS_CLOCK_GETRES + 124 => kernel_clock_nanosleep(a1 as u32, a2 as u32, channel_const_ptr!(2, u8)), // SYS_CLOCK_NANOSLEEP 125 => { // SYS_UTIMENSAT: (dirfd, path, times, flags) // path can be NULL (0) for futimens(fd, times) → utimensat(fd, NULL, times, 0) - let (p, len) = if a2 == 0 { + let (p, len) = if args[1] == 0 { (core::ptr::null(), 0u32) } else { - let p = a2 as *const u8; - (p, unsafe { cstr_len(p) }) + let p = channel_const_ptr!(1, u8); + (p, channel_cstr_len!(p)) }; - kernel_utimensat(a1, p, len, a3 as *const u8, a4 as u32) + kernel_utimensat(a1, p, len, channel_const_ptr!(2, u8), a4 as u32) } 66 => kernel_time() as i32, // SYS_TIME 68 => kernel_usleep(a1 as u32), // SYS_USLEEP @@ -2896,8 +3170,8 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { let result = match syscalls::sys_mmap( proc, &mut host, - a1 as usize, - a2 as usize, + process_address!(0), + channel_i32_scalar_usize(a2), a3 as u32, a4 as u32, a5, @@ -2905,7 +3179,7 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { ) { Ok(addr) => { if a3 as u32 != 0 { - let end = addr.saturating_add(a2 as usize); + let end = addr.saturating_add(channel_i32_scalar_usize(a2)); ensure_memory_covers(end); } addr as i32 @@ -2915,113 +3189,204 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); result } - 47 => kernel_munmap(a1 as usize, a2 as usize), // SYS_MUNMAP - 48 => kernel_brk(a1 as usize) as i32, // SYS_BRK - 49 => kernel_mprotect(a1 as usize, a2 as usize, a3 as u32), // SYS_MPROTECT - 126 => kernel_mremap(a1 as usize, a2 as usize, a3 as usize, a4 as u32) as i32, // SYS_MREMAP - 128 => kernel_madvise(a1 as usize, a2 as usize, a3 as u32), // SYS_MADVISE + 47 => kernel_munmap(process_address!(0), channel_i32_scalar_usize(a2)), // SYS_MUNMAP + 48 => kernel_brk(process_address!(0)) as i32, // SYS_BRK + 49 => kernel_mprotect(process_address!(0), channel_i32_scalar_usize(a2), a3 as u32), // SYS_MPROTECT + 126 => kernel_mremap( + process_address!(0), + channel_i32_scalar_usize(a2), + channel_i32_scalar_usize(a3), + a4 as u32, + ) as i32, // SYS_MREMAP + 128 => kernel_madvise(process_address!(0), channel_i32_scalar_usize(a2), a3 as u32), // SYS_MADVISE // Environment — musl: name/value are null-terminated strings 42 => kernel_isatty(a1), // SYS_ISATTY 43 => { // SYS_GETENV: (name, buf, buf_len) - let p = a1 as *const u8; - let len = unsafe { cstr_len(p) }; - kernel_getenv(p, len, a2 as *mut u8, a3 as u32) + let p = channel_const_ptr!(0, u8); + let len = channel_cstr_len!(p); + kernel_getenv(p, len, channel_mut_ptr!(1, u8), a3 as u32) } 44 => { // SYS_SETENV: (name, value, overwrite) - let n = a1 as *const u8; - let v = a2 as *const u8; - kernel_setenv( - n, - unsafe { cstr_len(n) }, - v, - unsafe { cstr_len(v) }, - a3 as u32, - ) + let n = channel_const_ptr!(0, u8); + let v = channel_const_ptr!(1, u8); + kernel_setenv(n, channel_cstr_len!(n), v, channel_cstr_len!(v), a3 as u32) } 45 => { // SYS_UNSETENV: (name) - let p = a1 as *const u8; - let len = unsafe { cstr_len(p) }; + let p = channel_const_ptr!(0, u8); + let len = channel_cstr_len!(p); kernel_unsetenv(p, len) } - 74 => kernel_umask(a1 as u32) as i32, // SYS_UMASK - 75 => kernel_uname(a1 as *mut u8, 390), // SYS_UNAME (musl passes 1 arg; struct utsname = 6x65 = 390) - 76 => kernel_sysconf(a1) as i32, // SYS_SYSCONF - 120 => kernel_getrandom(a1 as *mut u8, a2 as u32, a3 as u32), // SYS_GETRANDOM + 74 => kernel_umask(a1 as u32) as i32, // SYS_UMASK + 75 => kernel_uname(channel_mut_ptr!(0, u8), 390), // SYS_UNAME (musl passes 1 arg; struct utsname = 6x65 = 390) + 76 => kernel_sysconf(a1) as i32, // SYS_SYSCONF + 120 => kernel_getrandom(channel_mut_ptr!(0, u8), a2 as u32, a3 as u32), // SYS_GETRANDOM 109 => { // SYS_REALPATH: (path, buf, buf_len) - let p = a1 as *const u8; - let len = unsafe { cstr_len(p) }; - kernel_realpath(p, len, a2 as *mut u8, a3 as u32) + let p = channel_const_ptr!(0, u8); + let len = channel_cstr_len!(p); + kernel_realpath(p, len, channel_mut_ptr!(1, u8), a3 as u32) } // Sockets 50 => kernel_socket(a1 as u32, a2 as u32, a3 as u32), // SYS_SOCKET - 61 => kernel_socketpair(a1 as u32, a2 as u32, a3 as u32, a4 as *mut i32), // SYS_SOCKETPAIR - 51 => kernel_bind(a1, a2 as *const u8, a3 as u32), // SYS_BIND - 52 => kernel_listen(a1, a2 as u32), // SYS_LISTEN - 53 => kernel_accept4(a1, a2 as *mut u8, a3 as *mut u8, 0), // SYS_ACCEPT - 384 => kernel_accept4(a1, a2 as *mut u8, a3 as *mut u8, a4 as u32), // SYS_ACCEPT4 - 54 => kernel_connect(a1, a2 as *const u8, a3 as u32), // SYS_CONNECT - 55 => kernel_send(a1, a2 as *const u8, a3 as u32, a4 as u32), // SYS_SEND - 56 => kernel_recv(a1, a2 as *mut u8, a3 as u32, a4 as u32), // SYS_RECV - 57 => kernel_shutdown(a1, a2 as u32), // SYS_SHUTDOWN - 58 => kernel_getsockopt(a1, a2 as u32, a3 as u32, a4 as *mut u8, a5 as *mut u32), // SYS_GETSOCKOPT - 59 => kernel_setsockopt(a1, a2 as u32, a3 as u32, a4 as *const u8, a5 as u32), // SYS_SETSOCKOPT - 114 => kernel_getsockname(a1, a2 as *mut u8, a3 as *mut u32), // SYS_GETSOCKNAME - 115 => kernel_getpeername(a1, a2 as *mut u8, a3 as *mut u32), // SYS_GETPEERNAME + 61 => kernel_socketpair( + a1 as u32, + a2 as u32, + a3 as u32, + channel_mut_ptr!(3, i32), + wasm_posix_shared::kernel_scratch_wire::FD_PAIR_BYTES, + ), // SYS_SOCKETPAIR + 51 => kernel_bind(a1, channel_const_ptr!(1, u8), a3 as u32), // SYS_BIND + 52 => kernel_listen(a1, a2 as u32), // SYS_LISTEN + 53 => kernel_accept4(a1, channel_mut_ptr!(1, u8), channel_mut_ptr!(2, u8), 0), // SYS_ACCEPT + 384 => kernel_accept4( + a1, + channel_mut_ptr!(1, u8), + channel_mut_ptr!(2, u8), + a4 as u32, + ), // SYS_ACCEPT4 + 54 => kernel_connect(a1, channel_const_ptr!(1, u8), a3 as u32), // SYS_CONNECT + 55 => kernel_send(a1, channel_const_ptr!(1, u8), a3 as u32, a4 as u32), // SYS_SEND + 56 => kernel_recv(a1, channel_mut_ptr!(1, u8), a3 as u32, a4 as u32), // SYS_RECV + 57 => kernel_shutdown(a1, a2 as u32), // SYS_SHUTDOWN + 58 => { + let optval_pointer = channel_mut_ptr!(3, u8); + let optlen_pointer = channel_mut_ptr!(4, u32); + let optval_capacity = if optval_pointer.is_null() || optlen_pointer.is_null() { + 0 + } else { + // The descriptor validator proved this exact four-byte slot + // before the staged value is used as a destination capacity. + unsafe { core::ptr::read_unaligned(optlen_pointer) } + }; + kernel_getsockopt( + a1, + a2 as u32, + a3 as u32, + optval_pointer, + optval_capacity, + optlen_pointer, + if optlen_pointer.is_null() { + 0 + } else { + wasm_posix_shared::kernel_scratch_wire::SOCKLEN_BYTES + }, + ) + } // SYS_GETSOCKOPT + 59 => kernel_setsockopt( + a1, + a2 as u32, + a3 as u32, + channel_const_ptr!(3, u8), + a5 as u32, + ), // SYS_SETSOCKOPT + 114 => kernel_getsockname(a1, channel_mut_ptr!(1, u8), channel_mut_ptr!(2, u32)), // SYS_GETSOCKNAME + 115 => kernel_getpeername(a1, channel_mut_ptr!(1, u8), channel_mut_ptr!(2, u32)), // SYS_GETPEERNAME 140 => { // SYS_GETADDRINFO: (name, result_buf) - let p = a1 as *const u8; - let len = unsafe { cstr_len(p) }; - kernel_getaddrinfo(p, len, a2 as *mut u8) + let p = channel_const_ptr!(0, u8); + let len = channel_cstr_len!(p); + kernel_getaddrinfo(p, len, channel_mut_ptr!(1, u8)) } - 137 => kernel_sendmsg(a1, a2 as *const u8, a3 as u32), // SYS_SENDMSG - 138 => kernel_recvmsg(a1, a2 as *mut u8, a3 as u32), // SYS_RECVMSG + 137 => kernel_sendmsg(a1, channel_const_ptr!(1, u8), a3 as u32), // SYS_SENDMSG + 138 => kernel_recvmsg(a1, channel_mut_ptr!(1, u8), a3 as u32), // SYS_RECVMSG 62 => kernel_sendto( a1, - a2 as *const u8, + channel_const_ptr!(1, u8), a3 as u32, a4 as u32, - a5 as *const u8, + channel_const_ptr!(4, u8), a6 as u32, ), // SYS_SENDTO 63 => kernel_recvfrom( a1, - a2 as *mut u8, + channel_mut_ptr!(1, u8), a3 as u32, a4 as u32, - a5 as *mut u8, - a6 as *mut u32, + channel_mut_ptr!(4, u8), + channel_mut_ptr!(5, u32), ), // SYS_RECVFROM // Poll/select - 60 => kernel_poll(a1 as *mut u8, a2 as u32, a3), // SYS_POLL + 60 => { + let Some(capacity) = (a2 as u32) + .checked_mul(core::mem::size_of::() as u32) + else { + return -(Errno::EOVERFLOW as i32); + }; + kernel_poll(channel_mut_ptr!(0, u8), capacity, a2 as u32, a3) + } // SYS_POLL 251 => kernel_ppoll( - a1 as *mut u8, + channel_mut_ptr!(0, u8), a2 as u32, a3, a4 as u32, a5 as u32, a6 as u32, ), // SYS_PPOLL - 103 => kernel_select(a1, a2 as *mut u8, a3 as *mut u8, a4 as *mut u8, a5 as i32), // SYS_SELECT + 103 => { + let read_pointer = channel_mut_ptr!(1, u8); + let write_pointer = channel_mut_ptr!(2, u8); + let except_pointer = channel_mut_ptr!(3, u8); + let fd_set_capacity = |pointer: *mut u8| { + if pointer.is_null() { + 0 + } else { + wasm_posix_shared::select::FD_SET_BYTES as u32 + } + }; + kernel_select( + a1, + read_pointer, + fd_set_capacity(read_pointer), + write_pointer, + fd_set_capacity(write_pointer), + except_pointer, + fd_set_capacity(except_pointer), + a5 as i32, + ) + } // SYS_SELECT // Terminal - 70 => kernel_tcgetattr(a1, a2 as *mut u8, 256), // SYS_TCGETATTR - 71 => kernel_tcsetattr(a1, a2 as u32, a3 as *const u8, 256), // SYS_TCSETATTR - 72 => kernel_ioctl(a1, a2 as u32, a3 as *mut u8, 256), // SYS_IOCTL + 70 => kernel_tcgetattr( + a1, + channel_mut_ptr!(1, u8), + wasm_posix_shared::ioctl_contract::TERMIOS_SIZE, + ), // SYS_TCGETATTR + 71 => kernel_tcsetattr( + a1, + a2 as u32, + channel_const_ptr!(2, u8), + wasm_posix_shared::ioctl_contract::TERMIOS_SIZE, + ), // SYS_TCSETATTR + 72 => { + let request = a2 as u32; + let argument = match wasm_posix_shared::ioctl_contract::request_contract(request) + .map(|contract| contract.arg_kind) + { + Some(wasm_posix_shared::ioctl_contract::IoctlArgKind::Pointer) => { + channel_mut_ptr!(2, u8) + } + // WHY: scalar ioctl values share the pointer slot. The + // host normalizes them to their low i32 bits, and None or + // unknown requests carry zero. Pointer validation here + // would misclassify a negative scalar as an address. + _ => channel_u32_scalar_usize(a3) as *mut u8, + }; + kernel_ioctl(a1, request, argument, a4 as u32, a6 as u32) + } // SYS_IOCTL // File system 79 => kernel_ftruncate(a1, args[1]), // SYS_FTRUNCATE: (fd, length) 80 => kernel_fsync(a1), // SYS_FSYNC 85 => { // SYS_TRUNCATE: (path, length) - let p = a1 as *const u8; - let plen = unsafe { cstr_len(p) }; + let p = channel_const_ptr!(0, u8); + let plen = channel_cstr_len!(p); kernel_truncate(p, plen, args[1]) } 86 => kernel_fdatasync(a1), // SYS_FDATASYNC @@ -3029,103 +3394,111 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { 88 => kernel_fchown(a1, a2 as u32, a3 as u32), // SYS_FCHOWN 129 => { // SYS_STATFS64: (path, sizeof, statfs_buf) - let p = a1 as *const u8; - let len = unsafe { cstr_len(p) }; - kernel_statfs(p, len, a3 as *mut u8) - } - 130 => kernel_fstatfs(a1, a3 as *mut u8), // SYS_FSTATFS64: (fd, sizeof, buf) - 81 => kernel_writev(a1, a2 as *const u8, a3), // SYS_WRITEV - 82 => kernel_readv(a1, a2 as *mut u8, a3), // SYS_READV - 295 => kernel_preadv(a1, a2 as *mut u8, a3, a4 as u32, a5), // SYS_PREADV - 296 => kernel_pwritev(a1, a2 as *const u8, a3, a4 as u32, a5), // SYS_PWRITEV - 294 => kernel_sendfile(a1, a2, a3 as *mut u8, a4 as u32), // SYS_SENDFILE + let p = channel_const_ptr!(0, u8); + let len = channel_cstr_len!(p); + let output = channel_mut_ptr!(2, u8); + kernel_statfs(p, len, output, args[5]) + } + 130 => { + // SYS_FSTATFS64: (fd, sizeof, buf) + let output = channel_mut_ptr!(2, u8); + kernel_fstatfs(a1, output, args[5]) + } + 81 => kernel_writev(a1, channel_const_ptr!(1, u8), a3), // SYS_WRITEV + 82 => kernel_readv(a1, channel_mut_ptr!(1, u8), a3), // SYS_READV + 295 => kernel_preadv(a1, channel_mut_ptr!(1, u8), a3, a4 as u32, a5), // SYS_PREADV + 296 => kernel_pwritev(a1, channel_const_ptr!(1, u8), a3, a4 as u32, a5), // SYS_PWRITEV + 294 => kernel_sendfile(a1, a2, channel_mut_ptr!(2, u8), a4 as u32), // SYS_SENDFILE // *at variants — musl: (dirfd, path, ...) without explicit path_len 69 => { // SYS_OPENAT: (dirfd, path, flags, mode) - let p = a2 as *const u8; - let len = unsafe { cstr_len(p) }; + let p = channel_const_ptr!(1, u8); + let len = channel_cstr_len!(p); kernel_openat(a1, p, len, a3 as u32, a4 as u32) } 94 => { // SYS_UNLINKAT: (dirfd, path, flags) - let p = a2 as *const u8; - let len = unsafe { cstr_len(p) }; + let p = channel_const_ptr!(1, u8); + let len = channel_cstr_len!(p); kernel_unlinkat(a1, p, len, a3 as u32) } 95 => { // SYS_MKDIRAT: (dirfd, path, mode) - let p = a2 as *const u8; - let len = unsafe { cstr_len(p) }; + let p = channel_const_ptr!(1, u8); + let len = channel_cstr_len!(p); kernel_mkdirat(a1, p, len, a3 as u32) } 96 => { // SYS_RENAMEAT: (olddirfd, oldpath, newdirfd, newpath) - let old = a2 as *const u8; - let new = a4 as *const u8; - kernel_renameat(a1, old, unsafe { cstr_len(old) }, a3, new, unsafe { - cstr_len(new) - }) + let old = channel_const_ptr!(1, u8); + let new = channel_const_ptr!(3, u8); + kernel_renameat( + a1, + old, + channel_cstr_len!(old), + a3, + new, + channel_cstr_len!(new), + ) } 97 => { // SYS_FACCESSAT: (dirfd, path, mode, flags) - let p = a2 as *const u8; - let len = unsafe { cstr_len(p) }; + let p = channel_const_ptr!(1, u8); + let len = channel_cstr_len!(p); kernel_faccessat(a1, p, len, a3 as u32, a4 as u32) } 98 => { // SYS_FCHMODAT: (dirfd, path, mode, flags) - let p = a2 as *const u8; - let len = unsafe { cstr_len(p) }; + let p = channel_const_ptr!(1, u8); + let len = channel_cstr_len!(p); kernel_fchmodat(a1, p, len, a3 as u32, a4 as u32) } 99 => { // SYS_FCHOWNAT: (dirfd, path, uid, gid, flags) - let p = a2 as *const u8; - let len = unsafe { cstr_len(p) }; + let p = channel_const_ptr!(1, u8); + let len = channel_cstr_len!(p); kernel_fchownat(a1, p, len, a3 as u32, a4 as u32, a5 as u32) } 100 => { // SYS_LINKAT: (olddirfd, oldpath, newdirfd, newpath, flags) - let old = a2 as *const u8; - let new = a4 as *const u8; + let old = channel_const_ptr!(1, u8); + let new = channel_const_ptr!(3, u8); kernel_linkat( a1, old, - unsafe { cstr_len(old) }, + channel_cstr_len!(old), a3, new, - unsafe { cstr_len(new) }, + channel_cstr_len!(new), a5 as u32, ) } 101 => { // SYS_SYMLINKAT: (target, newdirfd, linkpath) - let tgt = a1 as *const u8; - let lnk = a3 as *const u8; - kernel_symlinkat(tgt, unsafe { cstr_len(tgt) }, a2, lnk, unsafe { - cstr_len(lnk) - }) + let tgt = channel_const_ptr!(0, u8); + let lnk = channel_const_ptr!(2, u8); + kernel_symlinkat(tgt, channel_cstr_len!(tgt), a2, lnk, channel_cstr_len!(lnk)) } 102 => { // SYS_READLINKAT: (dirfd, path, buf, bufsiz) - let p = a2 as *const u8; - let len = unsafe { cstr_len(p) }; - kernel_readlinkat(a1, p, len, a3 as *mut u8, a4 as u32) + let p = channel_const_ptr!(1, u8); + let len = channel_cstr_len!(p); + kernel_readlinkat(a1, p, len, channel_mut_ptr!(2, u8), a4 as u32) } // Resource limits - 83 => kernel_getrlimit(a1 as u32, a2 as *mut u8), // SYS_GETRLIMIT - 84 => kernel_setrlimit(a1 as u32, a2 as *const u8), // SYS_SETRLIMIT + 83 => kernel_getrlimit(a1 as u32, channel_mut_ptr!(1, u8)), // SYS_GETRLIMIT + 84 => kernel_setrlimit(a1 as u32, channel_const_ptr!(1, u8)), // SYS_SETRLIMIT 250 => { // SYS_PRLIMIT64: (pid, resource, new_rlim_ptr, old_rlim_ptr) // Get old limits first, then set new let mut ret = 0i32; - if a4 != 0 { - ret = kernel_getrlimit(a2 as u32, a4 as *mut u8); + if args[3] != 0 { + ret = kernel_getrlimit(a2 as u32, channel_mut_ptr!(3, u8)); } - if ret >= 0 && a3 != 0 { - ret = kernel_setrlimit(a2 as u32, a3 as *const u8); + if ret >= 0 && args[2] != 0 { + ret = kernel_setrlimit(a2 as u32, channel_const_ptr!(2, u8)); } ret } @@ -3142,20 +3515,14 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { if effective_pid == caller_pid { // Self — use syscalls::sys_setpgid match table.current_process_and_advisory_locks() { - Some((proc, advisory_locks)) => { - match syscalls::sys_setpgid(proc, pid, pgid) { - Ok(()) => { - let mut host = WasmHostIO; - deliver_pending_signals_with_locks( - proc, - advisory_locks, - &mut host, - ); - 0 - } - Err(e) => -(e as i32), + Some((proc, advisory_locks)) => match syscalls::sys_setpgid(proc, pid, pgid) { + Ok(()) => { + let mut host = WasmHostIO; + deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); + 0 } - } + Err(e) => -(e as i32), + }, None => -(Errno::ESRCH as i32), } } else if (pgid as i32) < 0 { @@ -3207,30 +3574,50 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { 107 => kernel_setegid(a1 as u32), // SYS_SETEGID 108 => kernel_getrusage( a1, - a2 as *mut u8, + channel_mut_ptr!(1, u8), wasm_posix_shared::WASM_RUSAGE_WIRE_SIZE, ), // SYS_GETRUSAGE (musl passes 2 args) 131 => kernel_setresuid(a1 as u32, a2 as u32, a3 as u32), // SYS_SETRESUID - 132 => kernel_getresuid(a1 as *mut u32, a2 as *mut u32, a3 as *mut u32), // SYS_GETRESUID + 132 => kernel_getresuid( + channel_mut_ptr!(0, u32), + channel_mut_ptr!(1, u32), + channel_mut_ptr!(2, u32), + ), // SYS_GETRESUID 133 => kernel_setresgid(a1 as u32, a2 as u32, a3 as u32), // SYS_SETRESGID - 134 => kernel_getresgid(a1 as *mut u32, a2 as *mut u32, a3 as *mut u32), // SYS_GETRESGID - 135 => kernel_getgroups(a1 as u32, a2 as *mut u32), // SYS_GETGROUPS - 136 => kernel_setgroups(a1 as u32, a2 as *const u32), // SYS_SETGROUPS + 134 => kernel_getresgid( + channel_mut_ptr!(0, u32), + channel_mut_ptr!(1, u32), + channel_mut_ptr!(2, u32), + ), // SYS_GETRESGID + 135 => { + let list_pointer = if a1 == 0 { + core::ptr::null_mut() + } else { + channel_mut_ptr!(1, u32) + }; + kernel_getgroups(a1 as u32, list_pointer, a3 as u32) + } // SYS_GETGROUPS + 136 => kernel_setgroups(a1 as u32, channel_const_ptr!(1, u32)), // SYS_SETGROUPS // Wait - 139 => kernel_wait4(a1, a2 as *mut i32, a3 as u32, a4 as *mut u8), // SYS_WAIT4 + 139 => kernel_wait4( + a1, + channel_mut_ptr!(1, i32), + a3 as u32, + channel_mut_ptr!(3, u8), + ), // SYS_WAIT4 // Fork/exec/clone 211 => { // SYS_EXECVE: (path, ...) - let p = a1 as *const u8; - let len = unsafe { cstr_len(p) }; + let p = channel_const_ptr!(0, u8); + let len = channel_cstr_len!(p); kernel_execve(p, len) } 386 => { // SYS_EXECVEAT: (dirfd, path, argv, envp, flags) - let p = a2 as *const u8; - let len = unsafe { cstr_len(p) }; + let p = channel_const_ptr!(1, u8); + let len = channel_cstr_len!(p); kernel_execveat(a1 as i32, p, len, a5 as u32) } // The centralized host must intercept fork and ask ProcessTable to @@ -3239,49 +3626,64 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { 212 | 213 => -(Errno::ENOSYS as i32), // SYS_FORK / SYS_VFORK 201 => kernel_clone( 0, - a2 as usize, + process_address!(1), a1 as u32, 0, - a3 as usize, - a4 as usize, - a5 as usize, + process_address!(2), + process_address!(3), + process_address!(4), ), // SYS_CLONE // Futex 200 => kernel_futex( - a1 as u32 as usize, + process_address!(0), a2 as u32, a3 as u32, a4 as u32, - a5 as u32 as usize, + process_address!(4), a6 as u32, ), // SYS_FUTEX // Thread - 202 => kernel_gettid(), // SYS_GETTID - 203 => kernel_set_tid_address(a1 as u32 as usize), // SYS_SET_TID_ADDRESS - 261 => kernel_set_robust_list(a1 as u32 as usize, a2 as u32 as usize), // SYS_SET_ROBUST_LIST - 262 => kernel_get_robust_list(a1 as u32, a2 as u32 as usize, a3 as u32 as usize), // SYS_GET_ROBUST_LIST + 202 => kernel_gettid(), // SYS_GETTID + 203 => kernel_set_tid_address(process_address!(0)), // SYS_SET_TID_ADDRESS + 261 => kernel_set_robust_list(process_address!(0), channel_u32_scalar_usize(a2)), // SYS_SET_ROBUST_LIST + 262 => kernel_get_robust_list(a1 as u32, process_address!(1), process_address!(2)), // SYS_GET_ROBUST_LIST // prctl - 223 => kernel_prctl(a1 as u32, a2 as u32, a3 as *mut u8, a4 as u32), // SYS_PRCTL + 223 => { + let option = a1 as u32; + let arg2 = if option == 15 || option == 16 { + channel_mut_ptr!(1, u8) as usize + } else { + channel_u32_scalar_usize(a2) + }; + kernel_prctl_from_channel(option, arg2, core::ptr::null_mut(), a4 as u32) + } // SYS_PRCTL // pathconf 112 => { // SYS_PATHCONF - let p = a1 as *const u8; - let len = unsafe { cstr_len(p) }; - kernel_pathconf(p, len as u32, a2, a3 as *mut i64) + let p = channel_const_ptr!(0, u8); + let len = channel_cstr_len!(p); + kernel_pathconf(p, len as u32, a2, channel_mut_ptr!(2, i64)) } - 113 => kernel_fpathconf(a1, a2, a3 as *mut i64), // SYS_FPATHCONF + 113 => kernel_fpathconf(a1, a2, channel_mut_ptr!(2, i64)), // SYS_FPATHCONF // setreuid/setregid — map to setresuid/setresgid with -1 for saved ID 215 => kernel_setresuid(a1 as u32, a2 as u32, 0xFFFFFFFF), // SYS_SETREUID 216 => kernel_setresgid(a1 as u32, a2 as u32, 0xFFFFFFFF), // SYS_SETREGID // Timer - 225 => kernel_setitimer(a1 as u32, a2 as *const u8, a3 as *mut u8), // SYS_SETITIMER - 224 => kernel_getitimer(a1 as u32, a2 as *mut u8), // SYS_GETITIMER + 225 => { + let new_pointer = channel_const_ptr!(1, u8); + let old_pointer = channel_mut_ptr!(2, u8); + kernel_setitimer(a1 as u32, new_pointer, old_pointer, args[5]) + } + 224 => { + let current_pointer = channel_mut_ptr!(1, u8); + kernel_getitimer(a1 as u32, current_pointer, args[5]) + } // clock_settime — always return EPERM (cannot set clock in Wasm sandbox) 226 => -(Errno::EPERM as i32), // SYS_CLOCK_SETTIME // sched_yield — no-op in Wasm (single-threaded per worker) @@ -3289,9 +3691,9 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { // statx — musl: (dirfd, path, flags, mask, statxbuf) 260 => { - let p = a2 as *const u8; - let len = unsafe { cstr_len(p) }; - kernel_statx(a1, p, len, a3 as u32, a4 as u32, a5 as *mut u8) + let p = channel_const_ptr!(1, u8); + let len = channel_cstr_len!(p); + kernel_statx(a1, p, len, a3 as u32, a4 as u32, channel_mut_ptr!(4, u8)) } // SysV IPC — handled by kernel IpcTable @@ -3308,18 +3710,45 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { // SYS_MSGRCV: (qid, msgp, msgsz, msgtyp, flags) let ipc = unsafe { crate::ipc::global_ipc_table() }; let (pid, uid, gid) = current_pid_eids(); - match ipc.msgrcv(a1, a3 as u32, a4, a5 as u32, pid, uid, gid) { + let pointer_width = match args[5] { + 4 => 4, + 8 => 8, + _ => return -(Errno::EINVAL as i32), + }; + let msgp = channel_mut_ptr!(1, u8); + if msgp.is_null() { + return -(Errno::EFAULT as i32); + } + let msgsz = match u32::try_from(args[2]) { + Ok(size) => size, + Err(_) => return -(Errno::EINVAL as i32), + }; + let max_output_mtype = if pointer_width == 4 { + i32::MAX as i64 + } else { + i64::MAX + }; + match ipc.msgrcv_with_mtype_max( + a1, + msgsz, + args[3], + max_output_mtype, + args[4] as u32, + pid, + uid, + gid, + ) { Ok(result) => { - // Write {mtype (4B), mtext} to msgp (kernel scratch) - let out = a2 as *mut u8; - let mtype_bytes = result.mtype.to_le_bytes(); - unsafe { - for i in 0..4 { - *out.add(i) = mtype_bytes[i]; - } - for (i, &b) in result.data.iter().enumerate() { - *out.add(4 + i) = b; - } + let wire_size = match crate::ipc_wire::sysv_message_wire_size(result.data.len()) + { + Ok(size) => size, + Err(error) => return -(error as i32), + }; + let out = unsafe { core::slice::from_raw_parts_mut(msgp, wire_size) }; + if let Err(error) = + crate::ipc_wire::write_sysv_message(out, result.mtype, &result.data) + { + return -(error as i32); } result.data.len() as i32 } @@ -3330,13 +3759,28 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { // SYS_MSGSND: (qid, msgp, msgsz, flags) let ipc = unsafe { crate::ipc::global_ipc_table() }; let (pid, uid, gid) = current_pid_eids(); - // Read mtype from msgp, mtext from msgp+4 - let msgp = a2 as *const u8; - let msgsz = a3 as usize; - let mtype = - unsafe { i32::from_le_bytes([*msgp, *msgp.add(1), *msgp.add(2), *msgp.add(3)]) }; - let data = unsafe { core::slice::from_raw_parts(msgp.add(4), msgsz) }; - match ipc.msgsnd(a1, mtype, data, a4 as u32, pid, uid, gid) { + if !matches!(args[5], 4 | 8) { + return -(Errno::EINVAL as i32); + } + let msgp = channel_const_ptr!(1, u8); + if msgp.is_null() { + return -(Errno::EFAULT as i32); + } + let msgsz = match checked_channel_usize_scalar(args[2]) { + Ok(size) => size, + Err(error) => return -(error as i32), + }; + let wire_size = match crate::ipc_wire::sysv_message_wire_size(msgsz) { + Ok(size) => size, + Err(error) => return -(error as i32), + }; + let message = unsafe { core::slice::from_raw_parts(msgp, wire_size) }; + let mtype = match crate::ipc_wire::read_sysv_message_type(message) { + Ok(mtype) => mtype, + Err(error) => return -(error as i32), + }; + let data = &message[crate::ipc_wire::SYSV_MESSAGE_HEADER_SIZE..]; + match ipc.msgsnd(a1, mtype, data, args[3] as u32, pid, uid, gid) { Ok(()) => 0, Err(e) => -(e as i32), } @@ -3346,13 +3790,62 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { let ipc = unsafe { crate::ipc::global_ipc_table() }; let (pid, uid, gid) = current_pid_eids(); let cmd = a2 & !0x100; // strip IPC_64 + // The host-only sixth slot names the caller data model; it may + // differ from the kernel Wasm's own pointer width. + let wire_transfer = if cmd == 1 || cmd == 2 { + let pointer_width = match args[5] { + 4 => 4, + 8 => 8, + _ => return -(Errno::EINVAL as i32), + }; + match crate::ipc_wire::msqid_ds_size(pointer_width) { + Ok(size) => Some((size, pointer_width)), + Err(error) => return -(error as i32), + } + } else { + None + }; + if cmd == 1 { + if args[2] == 0 { + return -(Errno::EFAULT as i32); + } + let Some((size, pointer_width)) = wire_transfer else { + return -(Errno::EINVAL as i32); + }; + let input_pointer = channel_const_ptr!(2, u8); + // SAFETY: the ABI-43 host copied this exact caller-width + // structure into its checked channel-scratch lease. + let input = unsafe { core::slice::from_raw_parts(input_pointer, size) }; + let fields = match crate::ipc_wire::read_msqid_ds_set_fields(input, pointer_width) { + Ok(fields) => fields, + Err(error) => return -(error as i32), + }; + return match ipc.msgctl_set( + a1, + fields.uid, + fields.gid, + fields.mode, + fields.qbytes, + uid, + ) { + Ok(()) => 0, + Err(error) => -(error as i32), + }; + } match ipc.msgctl(a1, cmd, pid, uid, gid) { Ok(Some(info)) => { - if a3 != 0 { - let out = a3 as *mut u8; - unsafe { - write_msqid_ds(out, &info); - } + if args[2] == 0 { + return -(Errno::EFAULT as i32); + } + let Some((size, pointer_width)) = wire_transfer else { + return -(Errno::EINVAL as i32); + }; + let output_pointer = channel_mut_ptr!(2, u8); + // SAFETY: the ABI-43 host stages this exact-sized output + // in its checked, kernel-owned channel-scratch lease. + let out = unsafe { core::slice::from_raw_parts_mut(output_pointer, size) }; + if let Err(error) = crate::ipc_wire::write_msqid_ds(out, &info, pointer_width) { + return -(error as i32); } 0 } @@ -3373,8 +3866,8 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { // SYS_SEMOP: (semid, sops_ptr, nsops) let ipc = unsafe { crate::ipc::global_ipc_table() }; let (pid, uid, gid) = current_pid_eids(); - let nsops = a3 as usize; - let sops_ptr = a2 as *const u8; + let nsops = channel_i32_scalar_usize(a3); + let sops_ptr = channel_const_ptr!(1, u8); let mut sops = alloc::vec::Vec::with_capacity(nsops); for i in 0..nsops { let base = unsafe { sops_ptr.add(i * 6) }; @@ -3393,50 +3886,102 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { let ipc = unsafe { crate::ipc::global_ipc_table() }; let (pid, uid, gid) = current_pid_eids(); let cmd = a3 & !0x100; // strip IPC_64 + // WHY: the host-only sixth channel slot carries the caller's + // pointer width. The kernel Wasm width is not authoritative + // because one kernel may serve both wasm32 and wasm64 processes. + let stat_transfer = if cmd == 2 { + let pointer_width = match args[5] { + 4 => 4, + 8 => 8, + _ => return -(Errno::EINVAL as i32), + }; + match crate::ipc_wire::semid_ds_size(pointer_width) { + Ok(size) => Some((size, pointer_width)), + Err(error) => return -(error as i32), + } + } else { + None + }; // SETALL (17): arg points to u16[] in scratch if cmd == 17 { - // First get nsems via IPC_STAT - match ipc.semctl(a1, 0, 2, pid, 0, uid, gid) { - // IPC_STAT=2 - Ok(crate::ipc::SemCtlResult::Stat(info)) => { - let nsems = info.nsems as usize; - let ptr = a4 as *const u8; - let mut vals = alloc::vec::Vec::with_capacity(nsems); - for i in 0..nsems { - let base = unsafe { ptr.add(i * 2) }; - vals.push(unsafe { u16::from_le_bytes([*base, *base.add(1)]) }); - } - match ipc.semctl_set_all(a1, &vals, uid, gid) { - Ok(()) => 0, - Err(e) => -(e as i32), - } - } - _ => -(Errno::EINVAL as i32), + if args[3] == 0 { + return -(Errno::EFAULT as i32); + } + let bytes = match ipc.semctl_array_bytes(a1, 17, uid, gid) { + Ok(bytes) => bytes, + Err(error) => return -(error as i32), + }; + let values_pointer = match checked_channel_pointer(args[3]) { + Ok(pointer) if pointer == scratch_region.start() => pointer as *const u8, + Ok(_) => return -(Errno::EFAULT as i32), + Err(error) => return -(error as i32), + }; + if let Err(error) = scratch_region.checked_range(values_pointer as usize, bytes) { + return -(error as i32); + } + // SAFETY: the ABI-43 host obtained the same permission-checked + // byte count before copying into its channel-scratch lease. + let values = unsafe { core::slice::from_raw_parts(values_pointer, bytes) }; + match ipc.semctl_set_all_bytes(a1, values, uid, gid) { + Ok(()) => 0, + Err(error) => -(error as i32), } } else { match ipc.semctl(a1, a2, cmd, pid, a4, uid, gid) { Ok(crate::ipc::SemCtlResult::Ok) => 0, Ok(crate::ipc::SemCtlResult::Value(v)) => v, Ok(crate::ipc::SemCtlResult::Stat(info)) => { - if a4 != 0 { - let out = a4 as *mut u8; - unsafe { - write_semid_ds(out, &info); - } + if args[3] == 0 { + return -(Errno::EFAULT as i32); + } + let Some((size, pointer_width)) = stat_transfer else { + return -(Errno::EINVAL as i32); + }; + let output_pointer = match checked_channel_pointer(args[3]) { + Ok(pointer) if pointer == scratch_region.start() => pointer as *mut u8, + Ok(_) => return -(Errno::EFAULT as i32), + Err(error) => return -(error as i32), + }; + if let Err(error) = + scratch_region.checked_range(output_pointer as usize, size) + { + return -(error as i32); + } + // SAFETY: the ABI-43 host stages this exact-sized + // output in its checked channel-scratch lease. + let out = unsafe { core::slice::from_raw_parts_mut(output_pointer, size) }; + if let Err(error) = + crate::ipc_wire::write_semid_ds(out, &info, pointer_width) + { + return -(error as i32); } 0 } Ok(crate::ipc::SemCtlResult::All(vals)) => { // GETALL: write u16[] to arg pointer - if a4 != 0 { - let out = a4 as *mut u8; - for (i, &v) in vals.iter().enumerate() { - let bytes = v.to_le_bytes(); - unsafe { - *out.add(i * 2) = bytes[0]; - *out.add(i * 2 + 1) = bytes[1]; - } - } + if args[3] == 0 { + return -(Errno::EFAULT as i32); + } + let byte_len = match vals.len().checked_mul(core::mem::size_of::()) { + Some(byte_len) => byte_len, + None => return -(Errno::EOVERFLOW as i32), + }; + let output_pointer = match checked_channel_pointer(args[3]) { + Ok(pointer) if pointer == scratch_region.start() => pointer as *mut u8, + Ok(_) => return -(Errno::EFAULT as i32), + Err(error) => return -(error as i32), + }; + if let Err(error) = + scratch_region.checked_range(output_pointer as usize, byte_len) + { + return -(error as i32); + } + // SAFETY: the ABI-43 host obtained this exact byte + // count before reserving the channel-scratch lease. + let out = + unsafe { core::slice::from_raw_parts_mut(output_pointer, byte_len) }; + for (chunk, value) in out.chunks_exact_mut(2).zip(vals.iter()) { + chunk.copy_from_slice(&value.to_le_bytes()); } 0 } @@ -3454,21 +3999,72 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { } } // SYS_SHMAT (345), SYS_SHMDT (346): intercepted by host for process memory management - 345 => kernel_ipc_shmat(a1, a2, a3), - 346 => kernel_ipc_shmdt(a1), + 345 => { + // The current kernel implementation ignores the requested attach + // address, but still validate the complete raw pointer so a future + // implementation cannot inherit the old i32 truncation. + let _shmaddr = process_address!(1); + kernel_ipc_shmat(a1, a2, a3) + } + 346 => { + let _shmaddr = process_address!(0); + kernel_ipc_shmdt(a1) + } 347 => { // SYS_SHMCTL: (shmid, cmd, buf_ptr) let ipc = unsafe { crate::ipc::global_ipc_table() }; let (pid, uid, gid) = current_pid_eids(); let cmd = a2 & !0x100; // strip IPC_64 - match ipc.shmctl(a1, cmd, pid, uid, gid) { - Ok(Some(info)) => { - if a3 != 0 { - let out = a3 as *mut u8; - unsafe { - write_shmid_ds(out, &info); - } - } + // The host-only sixth slot names the caller data model; it may + // differ from the kernel Wasm's own pointer width. + let wire_transfer = if cmd == 1 || cmd == 2 { + let pointer_width = match args[5] { + 4 => 4, + 8 => 8, + _ => return -(Errno::EINVAL as i32), + }; + match crate::ipc_wire::shmid_ds_size(pointer_width) { + Ok(size) => Some((size, pointer_width)), + Err(error) => return -(error as i32), + } + } else { + None + }; + if cmd == 1 { + if args[2] == 0 { + return -(Errno::EFAULT as i32); + } + let Some((size, pointer_width)) = wire_transfer else { + return -(Errno::EINVAL as i32); + }; + let input_pointer = channel_const_ptr!(2, u8); + // SAFETY: the ABI-43 host copied this exact caller-width + // structure into its checked channel-scratch lease. + let input = unsafe { core::slice::from_raw_parts(input_pointer, size) }; + let fields = match crate::ipc_wire::read_shmid_ds_set_fields(input, pointer_width) { + Ok(fields) => fields, + Err(error) => return -(error as i32), + }; + return match ipc.shmctl_set(a1, fields.uid, fields.gid, fields.mode, uid) { + Ok(()) => 0, + Err(error) => -(error as i32), + }; + } + match ipc.shmctl(a1, cmd, pid, uid, gid) { + Ok(Some(info)) => { + if args[2] == 0 { + return -(Errno::EFAULT as i32); + } + let Some((size, pointer_width)) = wire_transfer else { + return -(Errno::EINVAL as i32); + }; + let output_pointer = channel_mut_ptr!(2, u8); + // SAFETY: the ABI-43 host stages this exact-sized output + // in its checked, kernel-owned channel-scratch lease. + let out = unsafe { core::slice::from_raw_parts_mut(output_pointer, size) }; + if let Err(error) = crate::ipc_wire::write_shmid_ds(out, &info, pointer_width) { + return -(error as i32); + } 0 } Ok(None) => 0, @@ -3479,9 +4075,19 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { // epoll 239 => kernel_epoll_create1(a1 as u32), // SYS_EPOLL_CREATE1: (flags) 378 => kernel_epoll_create1(0), // SYS_EPOLL_CREATE: (size) — flags=0 - 240 => kernel_epoll_ctl(a1, a2, a3, a4 as *const u8), // SYS_EPOLL_CTL: (epfd, op, fd, event_ptr) - 241 => kernel_epoll_pwait(a1, a2 as *mut u8, a3, a4, a5 as *const u8), // SYS_EPOLL_PWAIT: (epfd, events, maxevents, timeout, sigmask_ptr) - 379 => kernel_epoll_pwait(a1, a2 as *mut u8, a3, a4, core::ptr::null()), // SYS_EPOLL_WAIT: (epfd, events, maxevents, timeout) + 240 => { + let event_ptr = channel_const_ptr!(3, u8); + kernel_epoll_ctl(a1, a2, a3, event_ptr) + } + 241 => { + let events_ptr = channel_mut_ptr!(1, u8); + let sigmask_ptr = channel_const_ptr!(4, u8); + kernel_epoll_pwait(a1, events_ptr, a3, a4, sigmask_ptr) + } + 379 => { + let events_ptr = channel_mut_ptr!(1, u8); + kernel_epoll_pwait(a1, events_ptr, a3, a4, core::ptr::null()) + } // eventfd 242 => kernel_eventfd2(a1 as u32, a2 as u32), // SYS_EVENTFD2: (initval, flags) @@ -3489,30 +4095,44 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { // timerfd 243 => kernel_timerfd_create(a1 as u32, a2 as u32), // SYS_TIMERFD_CREATE: (clockid, flags) - 244 => kernel_timerfd_settime(a1, a2 as u32, a3 as *const u8, a4 as *mut u8), // SYS_TIMERFD_SETTIME - 245 => kernel_timerfd_gettime(a1, a2 as *mut u8), // SYS_TIMERFD_GETTIME + 244 => kernel_timerfd_settime( + a1, + a2 as u32, + channel_const_ptr!(2, u8), + channel_mut_ptr!(3, u8), + ), // SYS_TIMERFD_SETTIME + 245 => kernel_timerfd_gettime(a1, channel_mut_ptr!(1, u8)), // SYS_TIMERFD_GETTIME // signalfd - 246 => kernel_signalfd4(a1, a2 as *const u8, a3 as u32, a4 as u32), // SYS_SIGNALFD4: (fd, mask_ptr, sigsetsize, flags) - 377 => kernel_signalfd4(a1, a2 as *const u8, a3 as u32, 0), // SYS_SIGNALFD: (fd, mask_ptr, sigsetsize) + 246 => kernel_signalfd4(a1, channel_const_ptr!(1, u8), a3 as u32, a4 as u32), // SYS_SIGNALFD4: (fd, mask_ptr, sigsetsize, flags) + 377 => kernel_signalfd4(a1, channel_const_ptr!(1, u8), a3 as u32, 0), // SYS_SIGNALFD: (fd, mask_ptr, sigsetsize) // tkill — directed (per-thread) signal delivery. (wasm32 musl // uses __NR_tkill for pthread_kill too; __NR_tgkill isn't wired up.) 204 => kernel_tkill(a1 as u32, a2 as u32), // SYS_TKILL (tid, sig) - // SYS_RT_SIGQUEUEINFO: send signal with si_value (sigqueue) - // a1=pid, a2=sig, a3=siginfo_ptr (copied to CH_DATA by host) + // SYS_RT_SIGQUEUEINFO: send signal with si_value (sigqueue). + // The complete siginfo_t is staged by a generated process-layout + // descriptor because LP64 alignment moves the common fields. 205 => { - // Extract si_value.sival_int from siginfo_t at offset 20 - // Layout: si_signo(4), si_errno(4), si_code(4), si_pid(4), si_uid(4), si_value(4) - let si_value = if a3 != 0 { - let info_ptr = a3 as *const u8; - let info = unsafe { slice::from_raw_parts(info_ptr, 24) }; - i32::from_le_bytes(info[20..24].try_into().unwrap()) - } else { - 0 + let model = match crate::process_wire::ProcessDataModel::from_width(args[5]) { + Ok(model) => model, + Err(error) => return -(error as i32), + }; + let info_pointer = channel_const_ptr!(2, u8); + if info_pointer.is_null() { + return -(Errno::EFAULT as i32); + } + let info_bytes = + unsafe { slice::from_raw_parts(info_pointer, model.siginfo_size()) }; + let info = match crate::process_wire::read_rt_sigqueueinfo(info_bytes, model) { + Ok(info) => info, + Err(error) => return -(error as i32), }; - kernel_kill_with_metadata(a1, a2 as u32, si_value, -1) + // WHY: si_pid/si_uid are parsed to keep the native layout honest, + // but caller-provided credentials are never authority. The signal + // path derives sender identity from the current kernel process. + kernel_kill_with_metadata(a1, a2 as u32, info.value_bits, -1) } // SYS_RT_SIGRETURN: signal handler return — clean up alt stack state @@ -3529,7 +4149,11 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { } // SYS_SIGALTSTACK: store/retrieve alternate stack state - 209 => kernel_sigaltstack(a1 as *const u8, a2 as *mut u8), + 209 => { + let stack_pointer = channel_const_ptr!(0, u8); + let old_stack_pointer = channel_mut_ptr!(1, u8); + kernel_sigaltstack(stack_pointer, old_stack_pointer, args[5]) + } // SYS_SCHED_GET_PRIORITY_MAX: POSIX requires at least 32 levels for SCHED_RR/SCHED_FIFO 234 => { @@ -3550,14 +4174,21 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { } } - // SYS_SCHED_GETPARAM: write sched_priority=0 to param struct - 230 => kernel_sched_getparam(a1, a2 as *mut u8), - // SYS_SCHED_SETPARAM: no-op (return 0 for valid pid) - 231 => kernel_sched_validate_pid(a1), + // Scheduler parameters use the complete native 48-byte structure. + 230 => { + let param_pointer = channel_mut_ptr!(1, u8); + kernel_sched_getparam(a1, param_pointer) + } + 231 => { + let param_pointer = channel_const_ptr!(1, u8); + kernel_sched_setparam(a1, param_pointer) + } // SYS_SCHED_GETSCHEDULER: always SCHED_OTHER (0) for valid PIDs 232 => kernel_sched_validate_pid(a1), - // SYS_SCHED_SETSCHEDULER: no-op (return 0 for valid pid) - 233 => kernel_sched_validate_pid(a1), + 233 => { + let param_pointer = channel_const_ptr!(2, u8); + kernel_sched_setscheduler(a1, a2, param_pointer) + } // SYS_SCHED_RR_GET_INTERVAL: (pid, timespec_ptr) 236 => { @@ -3565,11 +4196,11 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { let pid = a1 as u32; if pid != 0 && pid != proc.pid { -(Errno::ESRCH as i32) - } else if a2 == 0 { + } else if args[1] == 0 { -(Errno::EFAULT as i32) } else { // Write a reasonable RR interval: 100ms - let p = a2 as usize as *mut u8; + let p = channel_mut_ptr!(1, u8); let sec: i64 = 0; let nsec: i64 = 100_000_000; // 100ms unsafe { @@ -3590,8 +4221,8 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { // Return ENOMEM for addresses beyond Wasm memory bounds 279 | 280 => { // mlock, mlock2: (addr, len, ...) - let addr = a1 as u32; - let len = a2 as u32; + let addr = process_address!(0); + let len = channel_u32_scalar_usize(a2); if addr .checked_add(len) .map_or(true, |end| end > 1_073_741_824) @@ -3603,8 +4234,8 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { } 281 => { // munlock: (addr, len) - let addr = a1 as u32; - let len = a2 as u32; + let addr = process_address!(0); + let len = channel_u32_scalar_usize(a2); if addr .checked_add(len) .map_or(true, |end| end > 1_073_741_824) @@ -3635,7 +4266,7 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { 252 => { // SYS_PSELECT6_TIME64: (nfds, readfds, writefds, exceptfds, timeout_ms, mask_ptr) // Args pre-decoded by host: timeout → ms, mask stored at mask_ptr (8 bytes: lo+hi) - let mask_ptr = a6 as *const u8; + let mask_ptr = channel_const_ptr!(5, u8); let (has_mask, mask_lo, mask_hi) = if mask_ptr.is_null() { (0u32, 0u32, 0u32) } else { @@ -3648,9 +4279,9 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { }; kernel_pselect6( a1, - a2 as *mut u8, - a3 as *mut u8, - a4 as *mut u8, + channel_mut_ptr!(1, u8), + channel_mut_ptr!(2, u8), + channel_mut_ptr!(3, u8), a5, has_mask, mask_lo, @@ -3659,34 +4290,54 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { } 299 => { // SYS_LCHOWN: (path, uid, gid) - let p = a1 as *const u8; - let len = unsafe { cstr_len(p) }; + let p = channel_const_ptr!(0, u8); + let len = channel_cstr_len!(p); kernel_lchown(p, len, a2 as u32, a3 as u32) } 307 => 0, // SYS_FADVISE64: advisory, always succeed // POSIX timers - 326 => kernel_timer_create(a1 as u32, a2 as *const u8, a3 as *mut i32), // SYS_TIMER_CREATE - 327 => kernel_timer_settime(a1 as i32, a2 as i32, a3 as *const u8, a4 as *mut u8), // SYS_TIMER_SETTIME - 328 => kernel_timer_gettime(a1 as i32, a2 as *mut u8), // SYS_TIMER_GETTIME - 329 => kernel_timer_getoverrun(a1 as i32), // SYS_TIMER_GETOVERRUN - 330 => kernel_timer_delete(a1 as i32), // SYS_TIMER_DELETE - - 208 => { + 326 => kernel_timer_create( + a1 as u32, + channel_const_ptr!(1, u8), + channel_mut_ptr!(2, i32), + args[5], + ), // SYS_TIMER_CREATE + 327 => kernel_timer_settime(a1, a2, channel_const_ptr!(2, u8), channel_mut_ptr!(3, u8)), // SYS_TIMER_SETTIME + 328 => kernel_timer_gettime(a1, channel_mut_ptr!(1, u8)), // SYS_TIMER_GETTIME + 329 => kernel_timer_getoverrun(a1 as i32), // SYS_TIMER_GETOVERRUN + 330 => kernel_timer_delete(a1 as i32), // SYS_TIMER_DELETE + + 269 => { // SYS_SYSINFO - let buf_ptr = a1 as *mut u8; - let buf = unsafe { core::slice::from_raw_parts_mut(buf_ptr, 312) }; - match syscalls::sys_sysinfo(buf) { - Ok(()) => 0, - Err(e) => -(e as i32), - } + let model = match crate::process_wire::ProcessDataModel::from_width(args[5]) { + Ok(model) => model, + Err(error) => return -(error as i32), + }; + let output_pointer = channel_mut_ptr!(0, u8); + if output_pointer.is_null() { + return -(Errno::EFAULT as i32); + } + let info = syscalls::sys_sysinfo(); + let mut encoded = alloc::vec![0; model.sysinfo_size()]; + if let Err(error) = crate::process_wire::write_sysinfo(&mut encoded, &info, model) { + return -(error as i32); + } + // WHY: the host allocated and checked exactly this caller-native + // record. A fixed wasm32 length would truncate wasm64 sysinfo and + // leave its tail as stale scratch bytes. Serializing first also + // prevents a narrowing error from publishing a partial record. + let output = + unsafe { core::slice::from_raw_parts_mut(output_pointer, model.sysinfo_size()) }; + output.copy_from_slice(&encoded); + 0 } 256 => { // SYS_MEMFD_CREATE: (name, flags) let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; - let name_ptr = a1 as *const u8; - let name_len = unsafe { cstr_len(name_ptr) } as usize; + let name_ptr = channel_const_ptr!(0, u8); + let name_len = channel_cstr_len!(name_ptr) as usize; let name = if name_ptr.is_null() || name_len == 0 { &[] } else { @@ -3701,8 +4352,8 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { // SYS_COPY_FILE_RANGE: (fd_in, off_in*, fd_out, off_out*, len, flags) let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; - let off_in_ptr = a2 as *mut u8; - let off_out_ptr = a4 as *mut u8; + let off_in_ptr = channel_mut_ptr!(1, u8); + let off_out_ptr = channel_mut_ptr!(3, u8); let off_in = if off_in_ptr.is_null() { None } else { @@ -3722,7 +4373,7 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { off_in, a3, off_out, - a5 as usize, + channel_i32_scalar_usize(a5), ) { Ok(n) => { // Update offset pointers if provided @@ -3752,8 +4403,8 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { // SYS_SPLICE: (fd_in, off_in*, fd_out, off_out*, len, flags) let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; - let off_in_ptr = a2 as *mut u8; - let off_out_ptr = a4 as *mut u8; + let off_in_ptr = channel_mut_ptr!(1, u8); + let off_out_ptr = channel_mut_ptr!(3, u8); let off_in = if off_in_ptr.is_null() { None } else { @@ -3773,7 +4424,7 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { off_in, a3, off_out, - a5 as usize, + channel_i32_scalar_usize(a5), a6 as u32, ) { Ok(n) => { @@ -3797,12 +4448,12 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { result } 293 => 0, // SYS_READAHEAD: advisory, always succeed - 297 => kernel_preadv(a1, a2 as *mut u8, a3, a4 as u32, a5), // SYS_PREADV2 (ignore flags in a6) - 298 => kernel_pwritev(a1, a2 as *const u8, a3, a4 as u32, a5), // SYS_PWRITEV2 (ignore flags in a6) + 297 => kernel_preadv(a1, channel_mut_ptr!(1, u8), a3, a4 as u32, a5), // SYS_PREADV2 (ignore flags in a6) + 298 => kernel_pwritev(a1, channel_const_ptr!(1, u8), a3, a4 as u32, a5), // SYS_PWRITEV2 (ignore flags in a6) // -- Scheduling stubs (single-CPU Wasm) -- 237 => 0, // SYS_SCHED_SETAFFINITY: no-op (single CPU) - 238 => kernel_sched_getaffinity(a1, args[1] as u32, a3 as *mut u8), + 238 => kernel_sched_getaffinity(a1, args[1] as u32, channel_mut_ptr!(2, u8)), // -- Memory/sync stubs -- 257 => 0, // SYS_MEMBARRIER: no-op (single-threaded per process in Wasm) @@ -3818,11 +4469,16 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { 306 => { // SYS_RENAMEAT2: (olddirfd, oldpath, newdirfd, newpath, flags) // Ignore flags (RENAME_NOREPLACE, RENAME_EXCHANGE) — delegate to renameat - let old = a2 as *const u8; - let new = a4 as *const u8; - kernel_renameat(a1, old, unsafe { cstr_len(old) }, a3, new, unsafe { - cstr_len(new) - }) + let old = channel_const_ptr!(1, u8); + let new = channel_const_ptr!(3, u8); + kernel_renameat( + a1, + old, + channel_cstr_len!(old), + a3, + new, + channel_cstr_len!(new), + ) } 308 => { // SYS_FALLOCATE: (fd, mode, offset, len) @@ -3845,8 +4501,8 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { 323 => 0, // SYS_SYNC_FILE_RANGE: advisory, no-op 325 => { // SYS_GETCPU: (cpu*, node*, unused) - let cpu_ptr = a1 as *mut u32; - let node_ptr = a2 as *mut u32; + let cpu_ptr = channel_mut_ptr!(0, u32); + let node_ptr = channel_mut_ptr!(1, u32); if !cpu_ptr.is_null() { unsafe { *cpu_ptr = 0; @@ -3889,13 +4545,13 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { // --- faccessat2/fchmodat2: delegate to existing implementations --- 382 => { // SYS_FACCESSAT2: (dirfd, path, mode, flags) - let path = a2 as *const u8; - kernel_faccessat(a1, path, unsafe { cstr_len(path) }, a3 as u32, a4 as u32) + let path = channel_const_ptr!(1, u8); + kernel_faccessat(a1, path, channel_cstr_len!(path), a3 as u32, a4 as u32) } 383 => { // SYS_FCHMODAT2: (dirfd, path, mode, flags) - let path = a2 as *const u8; - kernel_fchmodat(a1, path, unsafe { cstr_len(path) }, a3 as u32, a4 as u32) + let path = channel_const_ptr!(1, u8); + kernel_fchmodat(a1, path, channel_cstr_len!(path), a3 as u32, a4 as u32) } // --- inotify stubs: create eventfd-like fd --- @@ -3915,61 +4571,62 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { // types fall through to a regular-file marker. 271 => { // SYS_MKNOD: (path, mode, dev) - let path = a1 as *const u8; + let path = channel_const_ptr!(0, u8); let mode = a2 as u32; let file_type = mode & 0o170000; if file_type == 0o010000 { - kernel_mkfifo(path, unsafe { cstr_len(path) }, mode & 0o7777) + kernel_mkfifo(path, channel_cstr_len!(path), mode & 0o7777) } else if file_type != 0 && file_type != 0o100000 { -(Errno::EPERM as i32) } else { - kernel_mknod(path, unsafe { cstr_len(path) }, mode & 0o7777) + kernel_mknod(path, channel_cstr_len!(path), mode & 0o7777) } } 272 => { // SYS_MKNODAT: (dirfd, path, mode, dev) - let path = a2 as *const u8; + let path = channel_const_ptr!(1, u8); let mode = a3 as u32; let file_type = mode & 0o170000; if file_type == 0o010000 { - kernel_mkfifoat(a1, path, unsafe { cstr_len(path) }, mode & 0o7777) + kernel_mkfifoat(a1, path, channel_cstr_len!(path), mode & 0o7777) } else if file_type != 0 && file_type != 0o100000 { -(Errno::EPERM as i32) } else { - kernel_mknodat(a1, path, unsafe { cstr_len(path) }, mode & 0o7777) + kernel_mknodat(a1, path, channel_cstr_len!(path), mode & 0o7777) } } // POSIX message queues 331 => { // SYS_MQ_OPEN: (name_ptr, flags, mode, attr_ptr) - let p = a1 as *const u8; - let name_len = unsafe { cstr_len(p) }; + let model = match crate::process_wire::ProcessDataModel::from_width(args[5]) { + Ok(model) => model, + Err(error) => return -(error as i32), + }; + let p = channel_const_ptr!(0, u8); + let name_len = channel_cstr_len!(p); let name = unsafe { core::str::from_utf8_unchecked(core::slice::from_raw_parts(p, name_len as usize)) }; let flags = a2 as u32; let mode = a3 as u32; - let has_attr = a4 != 0 && (flags & 0o100) != 0; // O_CREAT + let has_attr = args[3] != 0 && (flags & 0o100) != 0; // O_CREAT + let attr_pointer = if has_attr { + channel_const_ptr!(3, u8) + } else { + core::ptr::null() + }; let (maxmsg, msgsize) = if has_attr { - let attr_ptr = a4 as *const u8; - let maxmsg = unsafe { - i32::from_le_bytes([ - *attr_ptr.add(4), - *attr_ptr.add(5), - *attr_ptr.add(6), - *attr_ptr.add(7), - ]) - } as u32; - let msgsize = unsafe { - i32::from_le_bytes([ - *attr_ptr.add(8), - *attr_ptr.add(9), - *attr_ptr.add(10), - *attr_ptr.add(11), - ]) - } as u32; - (maxmsg, msgsize) + // SAFETY: the generated process-layout descriptor copied this + // exact caller-native structure into capacity-checked scratch. + let input = unsafe { + core::slice::from_raw_parts(attr_pointer, model.mq_attr_size()) + }; + let attr = match crate::process_wire::read_mq_attr(input, model) { + Ok(attr) => attr, + Err(error) => return -(error as i32), + }; + (attr.maxmsg, attr.msgsize) } else { (0, 0) }; @@ -3981,8 +4638,8 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { } 332 => { // SYS_MQ_UNLINK: (name_ptr) - let p = a1 as *const u8; - let name_len = unsafe { cstr_len(p) }; + let p = channel_const_ptr!(0, u8); + let name_len = channel_cstr_len!(p); let name = unsafe { core::str::from_utf8_unchecked(core::slice::from_raw_parts(p, name_len as usize)) }; @@ -3994,16 +4651,46 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { } 333 => { // SYS_MQ_TIMEDSEND: (mqd, msg_ptr, msg_len, priority, timeout_ptr) - let data = unsafe { core::slice::from_raw_parts(a2 as *const u8, a3 as usize) }; + let data_len = channel_i32_scalar_usize(a3); + // WHY: zero-length POSIX messages are valid and lend no bytes. + // Do not make their ignored pointer satisfy Rust's stronger + // non-null slice requirement. + let data = if data_len == 0 { + &[] + } else { + // SAFETY: the host copied exactly data_len caller bytes into + // capacity-checked kernel scratch before dispatch. + unsafe { core::slice::from_raw_parts(channel_const_ptr!(1, u8), data_len) } + }; let table = unsafe { crate::mqueue::global_mqueue_table() }; match table.mq_send(a1 as u32, data, a4 as u32) { Ok(result) => { if let Some(notif) = result.notification { - table.set_pending_notification(notif); + let process_table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let sender_pid = process_table.current_pid(); + let sender_uid = process_table + .get(sender_pid) + .map(|process| process.uid) + .unwrap_or(0); + if queue_mqueue_signal_notification( + process_table, + notif, + sender_pid, + sender_uid, + ) { + // The host consumes only detached pid/signo wake + // metadata. Rust already owns the SI_MESGQ queue + // entry and its full-width sigval. + table.set_pending_notification(notif); + } } 0 } - Err(Errno::EAGAIN) => mq_would_block_result(a5 as usize, table, a1 as u32), + Err(Errno::EAGAIN) => mq_would_block_result( + channel_const_ptr!(4, u8) as usize, + table, + a1 as u32, + ), Err(e) => -(e as i32), } } @@ -4012,14 +4699,24 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { let table = unsafe { crate::mqueue::global_mqueue_table() }; match table.mq_receive(a1 as u32, a3 as u32) { Ok(result) => { - // Write message data to kernel memory (msg_ptr adjusted by host) - let dst = unsafe { - core::slice::from_raw_parts_mut(a2 as *mut u8, result.data.len()) - }; - dst.copy_from_slice(&result.data); + // WHY: a queued zero-length message has no destination + // bytes, so do not make an ignored pointer satisfy Rust's + // stronger non-null slice requirement. + if !result.data.is_empty() { + // SAFETY: mq_receive proved the message fits the + // caller-supplied capacity, which the host staged in + // checked kernel scratch before dispatch. + let dst = unsafe { + core::slice::from_raw_parts_mut( + channel_mut_ptr!(1, u8), + result.data.len(), + ) + }; + dst.copy_from_slice(&result.data); + } // Write priority if pointer provided - if a4 != 0 { - let prio_ptr = a4 as *mut u8; + if args[3] != 0 { + let prio_ptr = channel_mut_ptr!(3, u8); let prio_bytes = result.priority.to_le_bytes(); unsafe { *prio_ptr = prio_bytes[0]; @@ -4030,7 +4727,11 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { } result.data.len() as i32 } - Err(Errno::EAGAIN) => mq_would_block_result(a5 as usize, table, a1 as u32), + Err(Errno::EAGAIN) => mq_would_block_result( + channel_const_ptr!(4, u8) as usize, + table, + a1 as u32, + ), Err(e) => -(e as i32), } } @@ -4038,31 +4739,34 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { // SYS_MQ_NOTIFY: (mqd, sev_ptr) let table = unsafe { crate::mqueue::global_mqueue_table() }; let pid = unsafe { &*PROCESS_TABLE.0.get() }.current_pid(); - if a2 == 0 { + let event_pointer = channel_const_ptr!(1, u8); + if event_pointer.is_null() { // NULL sigevent = unregister - match table.mq_notify(a1 as u32, pid, None, 0) { + match table.mq_notify(a1 as u32, pid, None, 0, 0) { Ok(()) => 0, Err(e) => -(e as i32), } } else { - let sev_ptr = a2 as *const u8; - let signo = unsafe { - i32::from_le_bytes([ - *sev_ptr.add(4), - *sev_ptr.add(5), - *sev_ptr.add(6), - *sev_ptr.add(7), - ]) - } as u32; - let notify = unsafe { - i32::from_le_bytes([ - *sev_ptr.add(8), - *sev_ptr.add(9), - *sev_ptr.add(10), - *sev_ptr.add(11), - ]) - } as u32; - match table.mq_notify(a1 as u32, pid, Some(notify), signo) { + let model = match crate::process_wire::ProcessDataModel::from_width(args[5]) { + Ok(model) => model, + Err(error) => return -(error as i32), + }; + // SAFETY: the host stages the complete native sigevent under + // the generated pointer-width-dependent descriptor. + let input = unsafe { + core::slice::from_raw_parts(event_pointer, model.sigevent_size()) + }; + let event = match crate::process_wire::read_sigevent(input, model) { + Ok(event) => event, + Err(error) => return -(error as i32), + }; + match table.mq_notify( + a1 as u32, + pid, + Some(event.notify), + event.signo, + event.value_bits, + ) { Ok(()) => 0, Err(e) => -(e as i32), } @@ -4070,41 +4774,45 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { } 336 => { // SYS_MQ_GETSETATTR: (mqd, new_attr_ptr, old_attr_ptr) + let model = match crate::process_wire::ProcessDataModel::from_width(args[5]) { + Ok(model) => model, + Err(error) => return -(error as i32), + }; let table = unsafe { crate::mqueue::global_mqueue_table() }; - let new_flags = if a2 != 0 { - let ptr = a2 as *const u8; - let flags = - unsafe { i32::from_le_bytes([*ptr, *ptr.add(1), *ptr.add(2), *ptr.add(3)]) }; - Some(flags as u32) + let new_pointer = channel_const_ptr!(1, u8); + let old_pointer = channel_mut_ptr!(2, u8); + let new_flags = if !new_pointer.is_null() { + // SAFETY: generated host metadata copied the exact native + // mq_attr size into this checked scratch allocation. + let input = unsafe { + core::slice::from_raw_parts(new_pointer, model.mq_attr_size()) + }; + let attr = match crate::process_wire::read_mq_attr(input, model) { + Ok(attr) => attr, + Err(error) => return -(error as i32), + }; + Some(attr.flags) } else { None }; match table.mq_getsetattr(a1 as u32, new_flags) { Ok(attr) => { - if a3 != 0 { - let out = a3 as *mut u8; - unsafe { - // Write struct mq_attr: { long mq_flags, mq_maxmsg, mq_msgsize, mq_curmsgs, __unused[4] } - let f = (attr.flags as i32).to_le_bytes(); - let mm = (attr.maxmsg as i32).to_le_bytes(); - let ms = (attr.msgsize as i32).to_le_bytes(); - let cm = (attr.curmsgs as i32).to_le_bytes(); - for i in 0..4 { - *out.add(i) = f[i]; - } - for i in 0..4 { - *out.add(4 + i) = mm[i]; - } - for i in 0..4 { - *out.add(8 + i) = ms[i]; - } - for i in 0..4 { - *out.add(12 + i) = cm[i]; - } - // Zero __unused[4] - for i in 16..32 { - *out.add(i) = 0; - } + if !old_pointer.is_null() { + // SAFETY: the host reserved exactly this caller-native + // output size in its checked scratch lease. + let output = unsafe { + core::slice::from_raw_parts_mut(old_pointer, model.mq_attr_size()) + }; + let native = crate::process_wire::NativeMqAttr { + flags: attr.flags, + maxmsg: attr.maxmsg, + msgsize: attr.msgsize, + curmsgs: attr.curmsgs, + }; + if let Err(error) = + crate::process_wire::write_mq_attr(output, native, model) + { + return -(error as i32); } } 0 @@ -4246,7 +4954,6 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { } 253..=254 - | 262 | 265..=268 | 289 | 292 @@ -4256,8 +4963,7 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { | 324 | 348..=349 | 362..=369 - | 373..=376 - | 386 => { + | 373..=376 => { // Remaining stubs: return ENOSYS -(Errno::ENOSYS as i32) } @@ -4266,6 +4972,70 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { } } +#[cfg(test)] +mod channel_pointer_tests { + use super::*; + + #[test] + fn raw_pointer_conversion_models_both_wasm_widths_without_signed_narrowing() { + assert_eq!(checked_channel_pointer_bits(0, 32), Ok(0)); + assert_eq!( + checked_channel_pointer_bits(u32::MAX as i64, 32), + Ok(u32::MAX as u64) + ); + assert_eq!( + checked_channel_pointer_bits((u32::MAX as i64) + 1, 32), + Err(Errno::EFAULT) + ); + assert_eq!(checked_channel_pointer_bits(i64::MIN, 64), Ok(1u64 << 63)); + assert_eq!(checked_channel_pointer_bits(-1, 64), Ok(u64::MAX)); + assert_eq!(checked_channel_pointer_bits(0, 16), Err(Errno::EFAULT)); + } + + #[test] + fn target_pointer_conversion_is_lossless_or_rejected() { + assert_eq!(checked_channel_pointer(0), Ok(0)); + assert_eq!( + checked_channel_pointer(u32::MAX as i64), + Ok(u32::MAX as usize) + ); + + #[cfg(target_pointer_width = "32")] + assert_eq!( + checked_channel_pointer((u32::MAX as i64) + 1), + Err(Errno::EFAULT) + ); + + #[cfg(target_pointer_width = "64")] + { + assert_eq!( + checked_channel_pointer((u32::MAX as i64) + 1), + Ok((u32::MAX as usize) + 1) + ); + assert_eq!(checked_channel_pointer(-1), Ok(usize::MAX)); + } + } + + #[test] + fn channel_dispatch_propagates_cstr_scan_errors_before_syscall_use() { + let unterminated = b"unterminated"; + let region = + ChannelScratchRegion::new(unterminated.as_ptr() as usize, unterminated.len()).unwrap(); + let mut args = [0i64; 6]; + args[0] = unterminated.as_ptr() as usize as i64; + assert_eq!( + dispatch_channel_syscall(43, &args, region), + -(Errno::EFAULT as i32), + ); + + args[0] = 0; + assert_eq!( + dispatch_channel_syscall(43, &args, region), + -(Errno::EFAULT as i32), + ); + } +} + // --------------------------------------------------------------------------- // SysV IPC kernel exports // --------------------------------------------------------------------------- @@ -4432,159 +5202,117 @@ pub extern "C" fn kernel_ipc_shm_write_chunk( } } -// --------------------------------------------------------------------------- -// SysV IPC struct serialization helpers (wasm32 layout) -// --------------------------------------------------------------------------- - -/// Write struct ipc_perm to kernel memory (36 bytes). -unsafe fn write_ipc_perm( - out: *mut u8, - key: i32, - uid: u32, - gid: u32, - cuid: u32, - cgid: u32, - mode: u32, - seq: i32, -) { - let write_i32 = |ptr: *mut u8, off: usize, val: i32| { - let bytes = val.to_le_bytes(); - for i in 0..4 { - *ptr.add(off + i) = bytes[i]; - } - }; - let write_u32 = |ptr: *mut u8, off: usize, val: u32| { - let bytes = val.to_le_bytes(); - for i in 0..4 { - *ptr.add(off + i) = bytes[i]; - } - }; - write_i32(out, 0, key); - write_u32(out, 4, uid); - write_u32(out, 8, gid); - write_u32(out, 12, cuid); - write_u32(out, 16, cgid); - write_u32(out, 20, mode); - write_i32(out, 24, seq); - // Padding bytes 28-35 - for i in 28..36 { - *out.add(i) = 0; +/// Byte size of the target musl `struct semid_ds`. +/// +/// `pointer_width` is the caller process width in bytes, not the kernel Wasm +/// width: one kernel may serve wasm32 and wasm64 processes. wasm32 uses its +/// time64 ILP32 layout; wasm64 uses the LP64 layout. The host queries this +/// before validating or allocating the IPC_STAT transfer. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_semid_ds_bytes(pointer_width: u32) -> i32 { + match crate::ipc_wire::semid_ds_size(pointer_width) { + Ok(size) => size as i32, + Err(error) => -(error as i32), } } -/// Write i64 as two i32 halves (little-endian) at offset. -unsafe fn write_time(out: *mut u8, offset: usize, secs: i64) { - let lo = (secs & 0xFFFF_FFFF) as i32; - let hi = (secs >> 32) as i32; - let lo_bytes = lo.to_le_bytes(); - let hi_bytes = hi.to_le_bytes(); - for i in 0..4 { - *out.add(offset + i) = lo_bytes[i]; - } - for i in 0..4 { - *out.add(offset + 4 + i) = hi_bytes[i]; +/// Byte size of the target musl `struct msqid_ds`. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_msqid_ds_bytes(pointer_width: u32) -> i32 { + match crate::ipc_wire::msqid_ds_size(pointer_width) { + Ok(size) => size as i32, + Err(error) => -(error as i32), } } -/// Write struct msqid_ds to kernel memory (96 bytes). -unsafe fn write_msqid_ds(out: *mut u8, info: &crate::ipc::MsgQueueInfo) { - // Zero the whole struct first - for i in 0..96 { - *out.add(i) = 0; +/// Byte size of the target musl `struct shmid_ds`. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_shmid_ds_bytes(pointer_width: u32) -> i32 { + match crate::ipc_wire::shmid_ds_size(pointer_width) { + Ok(size) => size as i32, + Err(error) => -(error as i32), } - write_ipc_perm( - out, info.key, info.uid, info.gid, info.cuid, info.cgid, info.mode, info.seq, - ); - // 36-39: padding - write_time(out, 40, info.stime); - write_time(out, 48, info.rtime); - write_time(out, 56, info.ctime); - let write_u32 = |ptr: *mut u8, off: usize, val: u32| { - let bytes = val.to_le_bytes(); - for i in 0..4 { - *ptr.add(off + i) = bytes[i]; - } - }; - let write_i32 = |ptr: *mut u8, off: usize, val: i32| { - let bytes = val.to_le_bytes(); - for i in 0..4 { - *ptr.add(off + i) = bytes[i]; - } - }; - write_u32(out, 64, info.cbytes); - write_u32(out, 68, info.qnum); - write_u32(out, 72, info.qbytes); - write_i32(out, 76, info.lspid); - write_i32(out, 80, info.lrpid); } -/// Write struct semid_ds to kernel memory (72 bytes). -unsafe fn write_semid_ds(out: *mut u8, info: &crate::ipc::SemSetInfo) { - for i in 0..72 { - *out.add(i) = 0; +/// Return the exact kernel-owned array size used by semctl GETALL/SETALL. +/// +/// The host validates the caller range against this value before moving any +/// bytes into or out of kernel scratch. Permission checking happens here so +/// the sizing preflight cannot disclose metadata the command itself could not +/// access. PID and TID are explicit because a sizing query must not install or +/// consume the one-shot ambient binding reserved for `kernel_handle_channel`. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_semctl_array_bytes(pid: u32, tid: u32, semid: i32, cmd: i32) -> i32 { + let table = unsafe { &*PROCESS_TABLE.0.get() }; + if let Err(error) = table.validate_task(pid, tid) { + return -(error as i32); } - write_ipc_perm( - out, info.key, info.uid, info.gid, info.cuid, info.cgid, info.mode, info.seq, - ); - // 36-39: padding - write_time(out, 40, info.otime); - write_time(out, 48, info.ctime); - // nsems at 56 as u16 - let nsems_bytes = (info.nsems as u16).to_le_bytes(); - *out.add(56) = nsems_bytes[0]; - *out.add(57) = nsems_bytes[1]; -} - -/// Write struct shmid_ds to kernel memory (88 bytes). -unsafe fn write_shmid_ds(out: *mut u8, info: &crate::ipc::ShmSegInfo) { - for i in 0..88 { - *out.add(i) = 0; - } - write_ipc_perm( - out, info.key, info.uid, info.gid, info.cuid, info.cgid, info.mode, info.seq, - ); - let write_u32 = |ptr: *mut u8, off: usize, val: u32| { - let bytes = val.to_le_bytes(); - for i in 0..4 { - *ptr.add(off + i) = bytes[i]; - } - }; - let write_i32 = |ptr: *mut u8, off: usize, val: i32| { - let bytes = val.to_le_bytes(); - for i in 0..4 { - *ptr.add(off + i) = bytes[i]; - } + let (uid, gid) = match table.get(pid) { + Some(process) => (process.euid, process.egid), + None => return -(Errno::ESRCH as i32), }; - write_u32(out, 36, info.segsz); - write_time(out, 40, info.atime); - write_time(out, 48, info.dtime); - write_time(out, 56, info.ctime); - write_i32(out, 64, info.cpid); - write_i32(out, 68, info.lpid); - write_u32(out, 72, info.nattch); + let ipc = unsafe { crate::ipc::global_ipc_table() }; + match ipc.semctl_array_bytes(semid, cmd & !0x100, uid, gid) { + Ok(bytes) => i32::try_from(bytes).unwrap_or(-(Errno::EOVERFLOW as i32)), + Err(error) => -(error as i32), + } } // --------------------------------------------------------------------------- // POSIX mqueue kernel exports // --------------------------------------------------------------------------- +fn queue_mqueue_signal_notification( + process_table: &mut crate::process_table::ProcessTable, + notification: crate::mqueue::MqNotification, + sender_pid: u32, + sender_uid: u32, +) -> bool { + const SI_MESGQ: i32 = -3; + + if notification.signo == 0 { + return false; + } + let Some(target) = process_table.get_mut(notification.pid) else { + return false; + }; + target.raise_signal_with_metadata( + notification.signo, + notification.value_bits, + SI_MESGQ, + sender_pid, + sender_uid, + ) +} + /// Drain pending mqueue notification. Writes (pid: u32, signo: u32) to out_ptr. +/// +/// The signal and its full-width `sigev_value` have already been queued in +/// Rust. This detached record gives the host only the process/signum needed to +/// wake blocked work; it must not synthesize a second signal. /// Returns 1 if a notification was pending, 0 otherwise. #[unsafe(no_mangle)] -pub extern "C" fn kernel_mq_drain_notification(out_ptr: *mut u8) -> i32 { +pub extern "C" fn kernel_mq_drain_notification( + out_ptr: *mut u8, + out_capacity: u32, +) -> i32 { + if out_ptr.is_null() { + return -(Errno::EFAULT as i32); + } + if out_capacity != wasm_posix_shared::kernel_scratch_wire::MQUEUE_NOTIFICATION_BYTES { + return -(Errno::EINVAL as i32); + } let table = unsafe { crate::mqueue::global_mqueue_table() }; match table.take_pending_notification() { Some(notif) => { - if !out_ptr.is_null() { - let pid_bytes = notif.pid.to_le_bytes(); - let signo_bytes = notif.signo.to_le_bytes(); - unsafe { - for i in 0..4 { - *out_ptr.add(i) = pid_bytes[i]; - } - for i in 0..4 { - *out_ptr.add(4 + i) = signo_bytes[i]; - } + let pid_bytes = notif.pid.to_le_bytes(); + let signo_bytes = notif.signo.to_le_bytes(); + unsafe { + for i in 0..4 { + *out_ptr.add(i) = pid_bytes[i]; + } + for i in 0..4 { + *out_ptr.add(4 + i) = signo_bytes[i]; } } 1 @@ -4682,8 +5410,7 @@ fn kernel_mknod(path_ptr: *const u8, path_len: u32, mode: u32) -> i32 { let flags = O_CREAT | O_EXCL | O_WRONLY; match syscalls::sys_open(proc, &mut host, path, flags, mode) { Ok(fd) => { - let _ = - syscalls::sys_close_with_locks(proc, advisory_locks, &mut host, fd); + let _ = syscalls::sys_close_with_locks(proc, advisory_locks, &mut host, fd); 0 } Err(e) => -(e as i32), @@ -4721,8 +5448,7 @@ fn kernel_mknodat(dirfd: i32, path_ptr: *const u8, path_len: u32, mode: u32) -> let flags = O_CREAT | O_EXCL | O_WRONLY; match syscalls::sys_openat(proc, &mut host, dirfd, path, flags, mode) { Ok(fd) => { - let _ = - syscalls::sys_close_with_locks(proc, advisory_locks, &mut host, fd); + let _ = syscalls::sys_close_with_locks(proc, advisory_locks, &mut host, fd); 0 } Err(e) => -(e as i32), @@ -4734,8 +5460,7 @@ fn kernel_mknodat(dirfd: i32, path_ptr: *const u8, path_len: u32, mode: u32) -> pub extern "C" fn kernel_close(fd: i32) -> i32 { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; - let result = - match syscalls::sys_close_with_locks(proc, advisory_locks, &mut host, fd) { + let result = match syscalls::sys_close_with_locks(proc, advisory_locks, &mut host, fd) { Ok(()) => 0, Err(e) => -(e as i32), }; @@ -4753,6 +5478,7 @@ pub extern "C" fn kernel_read(fd: i32, buf_ptr: *mut u8, buf_len: u32) -> i32 { Ok(n) => n as i32, Err(e) => -(e as i32), }; + syscalls::drain_deferred_scm_rights_releases(advisory_locks, &mut host); deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); result } @@ -4805,12 +5531,7 @@ pub extern "C" fn kernel_prepare_write_operation( Ok(len) => len as i64, Err(e) => -(e as i64), }; - let _ = deliver_pending_signals_for_tid_with_locks( - proc, - advisory_locks, - &mut host, - tid, - ); + let _ = deliver_pending_signals_for_tid_with_locks(proc, advisory_locks, &mut host, tid); result } @@ -4877,13 +5598,8 @@ pub extern "C" fn kernel_dup(fd: i32) -> i32 { pub extern "C" fn kernel_dup2(oldfd: i32, newfd: i32) -> i32 { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; - let result = match syscalls::sys_dup2_with_locks( - proc, - advisory_locks, - &mut host, - oldfd, - newfd, - ) { + let result = match syscalls::sys_dup2_with_locks(proc, advisory_locks, &mut host, oldfd, newfd) + { Ok(fd) => fd, Err(e) => -(e as i32), }; @@ -4918,17 +5634,10 @@ pub extern "C" fn kernel_dup3(oldfd: i32, newfd: i32, flags: u32) -> i32 { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; let result = - match syscalls::sys_dup3_with_locks( - proc, - advisory_locks, - &mut host, - oldfd, - newfd, - flags, - ) { - Ok(fd) => fd, - Err(e) => -(e as i32), - }; + match syscalls::sys_dup3_with_locks(proc, advisory_locks, &mut host, oldfd, newfd, flags) { + Ok(fd) => fd, + Err(e) => -(e as i32), + }; deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); result } @@ -4936,7 +5645,20 @@ pub extern "C" fn kernel_dup3(oldfd: i32, newfd: i32, flags: u32) -> i32 { /// Create a pipe with flags. Writes [read_fd, write_fd] to the pointer. /// Returns 0 on success, or negative errno on error. #[unsafe(no_mangle)] -pub extern "C" fn kernel_pipe2(flags: u32, fd_ptr: *mut i32) -> i32 { +pub extern "C" fn kernel_pipe2( + flags: u32, + fd_ptr: *mut i32, + fd_capacity: u32, +) -> i32 { + if fd_ptr.is_null() { + return -(Errno::EFAULT as i32); + } + if fd_capacity != wasm_posix_shared::kernel_scratch_wire::FD_PAIR_BYTES { + return -(Errno::EINVAL as i32); + } + if (fd_ptr as usize) % core::mem::align_of::() != 0 { + return -(Errno::EFAULT as i32); + } let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let result = match syscalls::sys_pipe2(proc, flags) { Ok((r, w)) => { @@ -4987,19 +5709,13 @@ pub extern "C" fn kernel_epoll_create1(flags: u32) -> i32 { pub extern "C" fn kernel_epoll_ctl(epfd: i32, op: i32, fd: i32, event_ptr: *const u8) -> i32 { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; - // Read epoll_event struct from memory: { events: u32, data: u64 } - // On wasm32 without packing, u64 may be at offset 4 or 8 depending on alignment. - // musl's epoll_event on non-x86_64: events at offset 0 (4B), data at offset 4 (8B) = 12B total. - // But wasm32 aligns u64 to 8 bytes, so it's likely: events at 0, pad at 4, data at 8 = 16B. - // We'll try reading from offset 4 (packed) since musl doesn't use __packed__ on non-x86_64. - // Actually, for epoll_data_t which is a union, the alignment depends on the platform. - // On wasm32, the union has 4-byte alignment if the ABI is ILP32, making epoll_event 12 bytes. let (events, data) = if !event_ptr.is_null() { - unsafe { - let events = core::ptr::read_unaligned(event_ptr as *const u32); - let data = core::ptr::read_unaligned(event_ptr.add(4) as *const u64); - (events, data) - } + // The shared record is compiler-checked against both Kandelo musl + // targets: 16-byte stride, with data at offset 8. + let event = unsafe { + core::ptr::read_unaligned(event_ptr.cast::()) + }; + (event.events, event.data) } else { (0u32, 0u64) }; @@ -5036,13 +5752,19 @@ pub extern "C" fn kernel_epoll_pwait( let result = match syscalls::sys_epoll_pwait(proc, &mut host, epfd, maxevents, timeout, sigmask) { Ok((count, events)) => { - // Write events to output buffer - // Each epoll_event: { events: u32, data: u64 } = 12 bytes (packed on wasm32) for (i, (ev, data)) in events.iter().enumerate() { - let offset = i * 12; + let event = wasm_posix_shared::WasmEpollEvent { + events: *ev, + _pad: 0, + data: *data, + }; unsafe { - core::ptr::write_unaligned(events_ptr.add(offset) as *mut u32, *ev); - core::ptr::write_unaligned(events_ptr.add(offset + 4) as *mut u64, *data); + core::ptr::write_unaligned( + events_ptr + .add(i * core::mem::size_of::()) + .cast::(), + event, + ); } } count @@ -5135,17 +5857,21 @@ pub extern "C" fn kernel_timerfd_gettime(fd: i32, cur_ptr: *mut u8) -> i32 { pub extern "C" fn kernel_signalfd4( fd: i32, mask_ptr: *const u8, - _sigsetsize: u32, + sigsetsize: u32, flags: u32, ) -> i32 { + if sigsetsize != core::mem::size_of::() as u32 { + return -(Errno::EINVAL as i32); + } + if mask_ptr.is_null() { + return -(Errno::EFAULT as i32); + } let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; - // Read signal mask from pointer - let mask = if !mask_ptr.is_null() { - unsafe { *(mask_ptr as *const u64) } - } else { - 0u64 - }; + // The descriptor lends exactly one checked sigset word. Use an unaligned + // read so the Rust access contract does not silently demand more from the + // scratch allocator than the eight bytes declared by the host. + let mask = unsafe { core::ptr::read_unaligned(mask_ptr.cast::()) }; let result = match syscalls::sys_signalfd4(proc, fd, mask, flags) { Ok(fd) => fd, @@ -5156,25 +5882,30 @@ pub extern "C" fn kernel_signalfd4( result } -/// Get file status. Writes a `WasmStat` struct to the pointer. +fn write_process_stat(stat_ptr: *mut u8, stat: &WasmStat) -> Result<(), Errno> { + if stat_ptr.is_null() { + return Err(Errno::EFAULT); + } + let bytes = unsafe { + slice::from_raw_parts_mut( + stat_ptr, + wasm_posix_shared::process_layout::stat::SIZE as usize, + ) + }; + crate::process_wire::write_stat(bytes, stat) +} + +/// Get file status. Writes a complete native musl `struct kstat`. /// Returns 0 on success, or negative errno on error. #[unsafe(no_mangle)] pub extern "C" fn kernel_fstat(fd: i32, stat_ptr: *mut u8) -> i32 { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; let result = match syscalls::sys_fstat(proc, &mut host, fd) { - Ok(stat) => { - let stat_bytes = unsafe { - slice::from_raw_parts( - &stat as *const WasmStat as *const u8, - core::mem::size_of::(), - ) - }; - unsafe { - core::ptr::copy_nonoverlapping(stat_bytes.as_ptr(), stat_ptr, stat_bytes.len()); - } - 0 - } + Ok(stat) => match write_process_stat(stat_ptr, &stat) { + Ok(()) => 0, + Err(error) => -(error as i32), + }, Err(e) => -(e as i32), }; deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); @@ -5204,14 +5935,7 @@ pub extern "C" fn kernel_fcntl_lock(fd: i32, cmd: u32, flock_ptr: *mut u8) -> i3 let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let flock = unsafe { &mut *(flock_ptr as *mut wasm_posix_shared::WasmFlock) }; let mut host = WasmHostIO; - let result = match syscalls::sys_fcntl_lock( - proc, - advisory_locks, - fd, - cmd, - flock, - &mut host, - ) { + let result = match syscalls::sys_fcntl_lock(proc, advisory_locks, fd, cmd, flock, &mut host) { Ok(()) => 0, Err(e) => -(e as i32), }; @@ -5225,8 +5949,7 @@ pub extern "C" fn kernel_fcntl_lock(fd: i32, cmd: u32, flock_ptr: *mut u8) -> i3 pub extern "C" fn kernel_flock(fd: i32, operation: u32) -> i32 { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; - let result = - match syscalls::sys_flock(proc, advisory_locks, fd, operation, &mut host) { + let result = match syscalls::sys_flock(proc, advisory_locks, fd, operation, &mut host) { Ok(()) => 0, Err(e) => -(e as i32), }; @@ -5234,7 +5957,7 @@ pub extern "C" fn kernel_flock(fd: i32, operation: u32) -> i32 { result } -/// Stat a file by path. Writes a `WasmStat` struct to the pointer. +/// Stat a file by path. Writes a complete native musl `struct kstat`. /// Returns 0 on success, or negative errno on error. #[unsafe(no_mangle)] pub extern "C" fn kernel_stat(path_ptr: *const u8, path_len: u32, stat_ptr: *mut u8) -> i32 { @@ -5242,25 +5965,17 @@ pub extern "C" fn kernel_stat(path_ptr: *const u8, path_len: u32, stat_ptr: *mut let path = unsafe { slice::from_raw_parts(path_ptr, path_len as usize) }; let mut host = WasmHostIO; let result = match syscalls::sys_stat(proc, &mut host, path) { - Ok(stat) => { - let stat_bytes = unsafe { - slice::from_raw_parts( - &stat as *const WasmStat as *const u8, - core::mem::size_of::(), - ) - }; - unsafe { - core::ptr::copy_nonoverlapping(stat_bytes.as_ptr(), stat_ptr, stat_bytes.len()); - } - 0 - } + Ok(stat) => match write_process_stat(stat_ptr, &stat) { + Ok(()) => 0, + Err(error) => -(error as i32), + }, Err(e) => -(e as i32), }; deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); result } -/// Lstat a file by path (does not follow symlinks). Writes a `WasmStat` struct. +/// Lstat a file by path (does not follow symlinks). Writes native `kstat`. /// Returns 0 on success, or negative errno on error. #[unsafe(no_mangle)] pub extern "C" fn kernel_lstat(path_ptr: *const u8, path_len: u32, stat_ptr: *mut u8) -> i32 { @@ -5268,18 +5983,10 @@ pub extern "C" fn kernel_lstat(path_ptr: *const u8, path_len: u32, stat_ptr: *mu let path = unsafe { slice::from_raw_parts(path_ptr, path_len as usize) }; let mut host = WasmHostIO; let result = match syscalls::sys_lstat(proc, &mut host, path) { - Ok(stat) => { - let stat_bytes = unsafe { - slice::from_raw_parts( - &stat as *const WasmStat as *const u8, - core::mem::size_of::(), - ) - }; - unsafe { - core::ptr::copy_nonoverlapping(stat_bytes.as_ptr(), stat_ptr, stat_bytes.len()); - } - 0 - } + Ok(stat) => match write_process_stat(stat_ptr, &stat) { + Ok(()) => 0, + Err(error) => -(error as i32), + }, Err(e) => -(e as i32), }; deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); @@ -5437,12 +6144,7 @@ pub extern "C" fn kernel_chown(path_ptr: *const u8, path_len: u32, uid: u32, gid } /// Change symlink ownership without following the final link. -fn kernel_lchown( - path_ptr: *const u8, - path_len: u32, - uid: u32, - gid: u32, -) -> i32 { +fn kernel_lchown(path_ptr: *const u8, path_len: u32, uid: u32, gid: u32) -> i32 { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let path = unsafe { slice::from_raw_parts(path_ptr, path_len as usize) }; let mut host = WasmHostIO; @@ -5738,7 +6440,7 @@ pub extern "C" fn kernel_kill(pid: i32, sig: u32) -> i32 { } /// Send a process-directed signal with its siginfo metadata. -fn kernel_kill_with_metadata(pid: i32, sig: u32, si_value: i32, si_code: i32) -> i32 { +fn kernel_kill_with_metadata(pid: i32, sig: u32, si_value_bits: u64, si_code: i32) -> i32 { use wasm_posix_shared::signal::NSIG; let _gkl = GklGuard::acquire(); let table = unsafe { &mut *PROCESS_TABLE.0.get() }; @@ -5753,15 +6455,9 @@ fn kernel_kill_with_metadata(pid: i32, sig: u32, si_value: i32, si_code: i32) -> Some(_) => return -(Errno::ESRCH as i32), }; - let deliver_caller = |table: &mut crate::process_table::ProcessTable, - host: &mut WasmHostIO| { + let deliver_caller = |table: &mut crate::process_table::ProcessTable, host: &mut WasmHostIO| { if let Some((caller, locks)) = table.process_and_advisory_locks(caller_pid) { - let _ = deliver_pending_signals_for_tid_with_locks( - caller, - locks, - host, - caller_tid, - ); + let _ = deliver_pending_signals_for_tid_with_locks(caller, locks, host, caller_tid); } }; @@ -5781,19 +6477,20 @@ fn kernel_kill_with_metadata(pid: i32, sig: u32, si_value: i32, si_code: i32) -> -(Errno::EPERM as i32) } Some(_) if target_pid == crate::process_table::SYNTHETIC_INIT_PID => 0, - Some(target) if !target.is_live_explicit_tid(target.pid) => { - -(Errno::ESRCH as i32) - } + Some(target) if !target.is_live_explicit_tid(target.pid) => -(Errno::ESRCH as i32), Some(_) => { if sig > 0 { if let Some((target, locks)) = table.process_and_advisory_locks(target_pid) { - target.raise_signal_with_metadata(sig, si_value, si_code); + target.raise_signal_with_metadata( + sig, + si_value_bits, + si_code, + caller_pid, + sender_uid, + ); if let Some(target_tid) = target.pick_thread_for_shared_signal(sig) { let _ = deliver_pending_signals_for_tid_with_locks( - target, - locks, - &mut host, - target_tid, + target, locks, &mut host, target_tid, ); } } @@ -5831,13 +6528,16 @@ fn kernel_kill_with_metadata(pid: i32, sig: u32, si_value: i32, si_code: i32) -> delivered = true; if sig > 0 { if let Some((target, locks)) = table.process_and_advisory_locks(target_pid) { - target.raise_signal_with_metadata(sig, si_value, si_code); + target.raise_signal_with_metadata( + sig, + si_value_bits, + si_code, + caller_pid, + sender_uid, + ); if let Some(target_tid) = target.pick_thread_for_shared_signal(sig) { let _ = deliver_pending_signals_for_tid_with_locks( - target, - locks, - &mut host, - target_tid, + target, locks, &mut host, target_tid, ); } } @@ -5885,16 +6585,17 @@ fn kernel_kill_with_metadata(pid: i32, sig: u32, si_value: i32, si_code: i32) -> } delivered = true; if sig > 0 { - if let Some((target, locks)) = - table.process_and_advisory_locks(target_pid) - { - target.raise_signal_with_metadata(sig, si_value, si_code); + if let Some((target, locks)) = table.process_and_advisory_locks(target_pid) { + target.raise_signal_with_metadata( + sig, + si_value_bits, + si_code, + caller_pid, + sender_uid, + ); if let Some(target_tid) = target.pick_thread_for_shared_signal(sig) { let _ = deliver_pending_signals_for_tid_with_locks( - target, - locks, - &mut host, - target_tid, + target, locks, &mut host, target_tid, ); } } @@ -5921,50 +6622,87 @@ fn kernel_kill_with_metadata(pid: i32, sig: u32, si_value: i32, si_code: i32) -> return -(Errno::ESRCH as i32); }; if sig > 0 { - caller.raise_signal_with_metadata(sig, si_value, si_code); + caller.raise_signal_with_metadata( + sig, + si_value_bits, + si_code, + caller_pid, + sender_uid, + ); } - let _ = deliver_pending_signals_for_tid_with_locks( - caller, - locks, - &mut host, - caller_tid, - ); + let _ = deliver_pending_signals_for_tid_with_locks(caller, locks, &mut host, caller_tid); 0 } /// sigaltstack — get/set alternate signal stack state. -/// ss_ptr points to stack_t (12 bytes on wasm32: u32 ss_sp, u32 ss_flags, u32 ss_size). -/// oss_ptr receives the previous state (may be null). +/// +/// `ss_ptr` and `oss_ptr` name caller-native `stack_t` records in bounded +/// kernel scratch. `process_pointer_width` selects the wasm32 or wasm64 layout. #[unsafe(no_mangle)] -pub extern "C" fn kernel_sigaltstack(ss_ptr: *const u8, oss_ptr: *mut u8) -> i32 { - let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; +pub extern "C" fn kernel_sigaltstack( + ss_ptr: *const u8, + oss_ptr: *mut u8, + process_pointer_width: i64, +) -> i32 { + use crate::process_wire::{ + NativeSigaltstack, ProcessDataModel, SIGALTSTACK_SS_DISABLE, + validate_sigaltstack_range, + }; - // Write old state to oss_ptr if non-null - if !oss_ptr.is_null() { - let buf = unsafe { slice::from_raw_parts_mut(oss_ptr, 12) }; - buf[0..4].copy_from_slice(&(proc.alt_stack_sp as u32).to_le_bytes()); - buf[4..8].copy_from_slice(&proc.alt_stack_flags.to_le_bytes()); - buf[8..12].copy_from_slice(&(proc.alt_stack_size as u32).to_le_bytes()); - } + let model = match ProcessDataModel::from_width(process_pointer_width) { + Ok(model) => model, + Err(error) => return -(error as i32), + }; + let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; - // Read new state from ss_ptr if non-null - if !ss_ptr.is_null() { + let new_stack = if ss_ptr.is_null() { + None + } else { // POSIX: cannot modify alt stack while executing on it (SS_ONSTACK) const SS_ONSTACK: u32 = 1; - const SS_DISABLE: u32 = 2; if proc.alt_stack_flags & SS_ONSTACK != 0 { return -(Errno::EPERM as i32); } - let buf = unsafe { slice::from_raw_parts(ss_ptr, 12) }; - let flags = u32::from_le_bytes(buf[4..8].try_into().unwrap()); - if flags & SS_DISABLE != 0 { + // WHY: the host proved this exact record fits the kernel-owned scratch + // allocation; using the native size here keeps that capacity proof + // aligned with the parser instead of relying on total Wasm memory. + let bytes = unsafe { slice::from_raw_parts(ss_ptr, model.sigaltstack_size()) }; + match crate::process_wire::read_sigaltstack(bytes, model) { + Ok(stack) => { + if let Err(error) = validate_sigaltstack_range(stack, model) { + return -(error as i32); + } + Some(stack) + } + Err(error) => return -(error as i32), + } + }; + + if !oss_ptr.is_null() { + let old_stack = NativeSigaltstack { + sp: proc.alt_stack_sp, + flags: proc.alt_stack_flags, + size: proc.alt_stack_size, + }; + let mut encoded = alloc::vec![0; model.sigaltstack_size()]; + if let Err(error) = crate::process_wire::write_sigaltstack(&mut encoded, old_stack, model) { + return -(error as i32); + } + // WHY: serialize before touching the caller-visible destination so a + // narrowing failure cannot leave a partially replaced record. + let output = unsafe { slice::from_raw_parts_mut(oss_ptr, model.sigaltstack_size()) }; + output.copy_from_slice(&encoded); + } + + if let Some(stack) = new_stack { + if stack.flags & SIGALTSTACK_SS_DISABLE != 0 { proc.alt_stack_sp = 0; - proc.alt_stack_flags = SS_DISABLE; + proc.alt_stack_flags = SIGALTSTACK_SS_DISABLE; proc.alt_stack_size = 0; } else { - proc.alt_stack_sp = u32::from_le_bytes(buf[0..4].try_into().unwrap()) as usize; - proc.alt_stack_flags = flags; - proc.alt_stack_size = u32::from_le_bytes(buf[8..12].try_into().unwrap()) as usize; + proc.alt_stack_sp = stack.sp; + proc.alt_stack_flags = stack.flags; + proc.alt_stack_size = stack.size; } } @@ -6056,21 +6794,64 @@ fn kernel_sched_getaffinity(pid: i32, cpusetsize: u32, mask_ptr: *mut u8) -> i32 MASK_SIZE as i32 } -/// sched_getparam — write scheduling parameters (sched_priority = 0) to param_ptr. -/// struct sched_param starts with int sched_priority at offset 0. +/// `sched_getparam` writes the complete native scheduling-parameter record. +/// +/// Kandelo currently exposes SCHED_OTHER only, so every field is zero. Filling +/// all 48 bytes is required: the host copies the descriptor's complete output +/// capacity back to the caller, and a four-byte write would expose stale +/// scratch bytes in the POSIX sporadic-server fields. #[unsafe(no_mangle)] pub extern "C" fn kernel_sched_getparam(pid: i32, param_ptr: *mut u8) -> i32 { if param_ptr.is_null() { - return -(Errno::EINVAL as i32); + return -(Errno::EFAULT as i32); } let validate = kernel_sched_validate_pid(pid); if validate < 0 { return validate; } - // Set sched_priority = 0 (SCHED_OTHER always has priority 0) - let buf = unsafe { slice::from_raw_parts_mut(param_ptr, 4) }; - buf.copy_from_slice(&0i32.to_le_bytes()); - 0 + let bytes = unsafe { + slice::from_raw_parts_mut( + param_ptr, + wasm_posix_shared::process_layout::sched_param::SIZE as usize, + ) + }; + match crate::process_wire::write_sched_param( + bytes, + crate::process_wire::NativeSchedParam::default(), + ) { + Ok(()) => 0, + Err(error) => -(error as i32), + } +} + +fn kernel_sched_setparam(pid: i32, param_ptr: *const u8) -> i32 { + kernel_sched_accept_param(pid, param_ptr) +} + +fn kernel_sched_setscheduler(pid: i32, _policy: i32, param_ptr: *const u8) -> i32 { + kernel_sched_accept_param(pid, param_ptr) +} + +fn kernel_sched_accept_param(pid: i32, param_ptr: *const u8) -> i32 { + if param_ptr.is_null() { + return -(Errno::EFAULT as i32); + } + let validate = kernel_sched_validate_pid(pid); + if validate < 0 { + return validate; + } + let bytes = unsafe { + slice::from_raw_parts( + param_ptr, + wasm_posix_shared::process_layout::sched_param::SIZE as usize, + ) + }; + match crate::process_wire::read_sched_param(bytes) { + // Scheduling remains a truthful no-op in the one-CPU Wasm model, but + // the complete caller-owned record is still validated and staged. + Ok(_param) => 0, + Err(error) => -(error as i32), + } } /// Send a signal to the current process. Returns 0 on success, or negative errno. @@ -6120,7 +6901,7 @@ pub extern "C" fn kernel_tgkill(tgid: u32, tid: u32, sig: u32) -> i32 { /// Shared implementation of tkill/tgkill/rt_tgsigqueueinfo. /// When `si_value != 0` or `si_code != 0` the signal is queued with /// `sigqueue`-style metadata. -fn kernel_tkill_with_value(tid: u32, sig: u32, si_value: i32, si_code: i32) -> i32 { +fn kernel_tkill_with_value(tid: u32, sig: u32, si_value_bits: u64, si_code: i32) -> i32 { use wasm_posix_shared::signal::NSIG; let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; @@ -6138,14 +6919,20 @@ fn kernel_tkill_with_value(tid: u32, sig: u32, si_value: i32, si_code: i32) -> i return -(Errno::ESRCH as i32); } + let sender_pid = proc.pid; + let sender_uid = proc.uid; + // Main thread: use its directed queue rather than the process-shared set. if proc.is_main_thread(tid) { if sig > 0 { - if si_code != 0 || si_value != 0 { - proc.raise_for_thread_with_value(tid, sig, si_value); - } else { - proc.raise_for_thread(tid, sig); - } + proc.raise_for_thread_with_metadata( + tid, + sig, + si_value_bits, + si_code, + sender_pid, + sender_uid, + ); } deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); return 0; @@ -6153,11 +6940,14 @@ fn kernel_tkill_with_value(tid: u32, sig: u32, si_value: i32, si_code: i32) -> i // Worker thread: direct deliver to that thread's own pending queue. if sig > 0 { - if si_code != 0 || si_value != 0 { - proc.raise_for_thread_with_value(tid, sig, si_value) - } else { - proc.raise_for_thread(tid, sig) - }; + proc.raise_for_thread_with_metadata( + tid, + sig, + si_value_bits, + si_code, + sender_pid, + sender_uid, + ); } deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); 0 @@ -6337,7 +7127,17 @@ pub extern "C" fn kernel_utimensat( flags: u32, ) -> i32 { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; - let path = unsafe { slice::from_raw_parts(path_ptr, path_len as usize) }; + // WHY: a null path with length zero is the futimens form of utimensat. + // Rust still requires raw slice pointers to be non-null when empty. + let path = if path_len == 0 { + &[] + } else if path_ptr.is_null() { + return -(Errno::EFAULT as i32); + } else { + // SAFETY: the host staged the complete NUL-terminated caller path in + // capacity-checked kernel scratch and supplied its measured length. + unsafe { slice::from_raw_parts(path_ptr, path_len as usize) } + }; let times = if times_ptr.is_null() { None } else { @@ -6384,29 +7184,37 @@ pub extern "C" fn kernel_madvise(addr: usize, len: usize, advice: u32) -> i32 { result } -/// statfs — get filesystem statistics. Writes WasmStatfs struct to buf_ptr. +/// statfs — get filesystem statistics in the caller's native `struct statfs`. /// Returns 0 on success. #[unsafe(no_mangle)] -pub extern "C" fn kernel_statfs(path_ptr: *const u8, path_len: u32, buf_ptr: *mut u8) -> i32 { +pub extern "C" fn kernel_statfs( + path_ptr: *const u8, + path_len: u32, + buf_ptr: *mut u8, + process_pointer_width: i64, +) -> i32 { + use crate::process_wire::ProcessDataModel; + + let model = match ProcessDataModel::from_width(process_pointer_width) { + Ok(model) => model, + Err(error) => return -(error as i32), + }; + if path_ptr.is_null() || buf_ptr.is_null() { + return -(Errno::EFAULT as i32); + } let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; let path = unsafe { slice::from_raw_parts(path_ptr, path_len as usize) }; let result = match syscalls::sys_statfs(proc, &mut host, path) { Ok(statfs) => { - let buf = unsafe { - slice::from_raw_parts_mut( - buf_ptr, - core::mem::size_of::(), - ) - }; - let bytes = unsafe { - core::slice::from_raw_parts( - &statfs as *const _ as *const u8, - core::mem::size_of::(), - ) - }; - buf.copy_from_slice(bytes); - 0 + // WHY: this exact native size is also the allocation capacity the + // host checked. The kernel's total memory size says nothing about + // whether bytes past this scratch record belong to this syscall. + let output = unsafe { slice::from_raw_parts_mut(buf_ptr, model.statfs_size()) }; + match crate::process_wire::write_statfs(output, &statfs, model) { + Ok(()) => 0, + Err(error) => -(error as i32), + } } Err(e) => -(e as i32), }; @@ -6417,25 +7225,25 @@ pub extern "C" fn kernel_statfs(path_ptr: *const u8, path_len: u32, buf_ptr: *mu /// fstatfs — get filesystem statistics for an open fd. /// Returns 0 on success, negative errno on error. #[unsafe(no_mangle)] -pub extern "C" fn kernel_fstatfs(fd: i32, buf_ptr: *mut u8) -> i32 { +pub extern "C" fn kernel_fstatfs(fd: i32, buf_ptr: *mut u8, process_pointer_width: i64) -> i32 { + use crate::process_wire::ProcessDataModel; + + let model = match ProcessDataModel::from_width(process_pointer_width) { + Ok(model) => model, + Err(error) => return -(error as i32), + }; + if buf_ptr.is_null() { + return -(Errno::EFAULT as i32); + } let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; let result = match syscalls::sys_fstatfs(proc, &mut host, fd) { Ok(statfs) => { - let buf = unsafe { - slice::from_raw_parts_mut( - buf_ptr, - core::mem::size_of::(), - ) - }; - let bytes = unsafe { - core::slice::from_raw_parts( - &statfs as *const _ as *const u8, - core::mem::size_of::(), - ) - }; - buf.copy_from_slice(bytes); - 0 + let output = unsafe { slice::from_raw_parts_mut(buf_ptr, model.statfs_size()) }; + match crate::process_wire::write_statfs(output, &statfs, model) { + Ok(()) => 0, + Err(error) => -(error as i32), + } } Err(e) => -(e as i32), }; @@ -6511,12 +7319,30 @@ pub extern "C" fn kernel_getresgid( /// getgroups — get supplementary group IDs. /// Returns count on success, negative errno on error. +fn validate_getgroups_destination( + size: u32, + list_ptr: *mut u32, + list_capacity_bytes: u32, +) -> Result<(), Errno> { + if size > 0 && (list_ptr.is_null() || list_capacity_bytes < core::mem::size_of::() as u32) + { + return Err(Errno::EFAULT); + } + Ok(()) +} + #[unsafe(no_mangle)] -pub extern "C" fn kernel_getgroups(size: u32, list_ptr: *mut u32) -> i32 { +pub extern "C" fn kernel_getgroups(size: u32, list_ptr: *mut u32, list_capacity_bytes: u32) -> i32 { + if let Err(error) = validate_getgroups_destination(size, list_ptr, list_capacity_bytes) { + return -(error as i32); + } let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let result = match syscalls::sys_getgroups(proc, size) { Ok((count, gid)) => { if size > 0 { + // WHY: the host lends exactly the declared allocation + // capacity. Kernel linear-memory bounds alone do not prove + // that the following four bytes belong to that allocation. unsafe { *list_ptr = gid; } @@ -6530,6 +7356,34 @@ pub extern "C" fn kernel_getgroups(size: u32, list_ptr: *mut u32) -> i32 { result } +#[cfg(test)] +mod getgroups_destination_tests { + use super::*; + + #[test] + fn count_query_does_not_require_a_destination() { + assert_eq!( + validate_getgroups_destination(0, core::ptr::null_mut(), 0), + Ok(()) + ); + } + + #[test] + fn positive_request_requires_the_explicit_gid_capacity() { + let pointer = core::ptr::NonNull::::dangling().as_ptr(); + assert_eq!( + validate_getgroups_destination(1, core::ptr::null_mut(), 4), + Err(Errno::EFAULT) + ); + assert_eq!( + validate_getgroups_destination(1, pointer, 3), + Err(Errno::EFAULT) + ); + assert_eq!(validate_getgroups_destination(1, pointer, 4), Ok(())); + assert_eq!(validate_getgroups_destination(1, pointer, 5), Ok(())); + } +} + /// setgroups — set supplementary group IDs (no-op). #[unsafe(no_mangle)] pub extern "C" fn kernel_setgroups(size: u32, _list_ptr: *const u32) -> i32 { @@ -6543,272 +7397,150 @@ pub extern "C" fn kernel_setgroups(size: u32, _list_ptr: *const u32) -> i32 { result } -/// Extract SCM_RIGHTS ancillary FDs from msg_control, returning InFlightFd entries. -/// Each FD is looked up in the sender's process and serialized for cross-process delivery. +fn read_wire_u32(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes( + bytes[offset..offset + size_of::()] + .try_into() + .expect("fixed wire u32 range"), + ) +} + +fn write_wire_u32(bytes: &mut [u8], offset: usize, value: u32) { + bytes[offset..offset + size_of::()].copy_from_slice(&value.to_le_bytes()); +} + +/// Extract SCM_RIGHTS descriptors from one canonical kernel control wire. +/// +/// Malformed records and invalid descriptors are errors. Silently skipping +/// either would let the queued message disagree with the sender's request. fn extract_scm_rights( proc: &crate::process::Process, control_ptr: usize, control_len: usize, -) -> Vec { - use crate::pipe::{InFlightFd, InFlightSocket}; - use crate::socket::{SocketDomain, SocketState, SocketType}; - use wasm_posix_shared::socket::SCM_RIGHTS; - use wasm_posix_shared::socket::SOL_SOCKET; - +) -> Result, Errno> { let mut result = Vec::new(); - if control_ptr == 0 || control_len == 0 { - return result; + if control_len == 0 { + return Ok(result); } - - let control = unsafe { slice::from_raw_parts(control_ptr as *const u8, control_len) }; - - // Walk cmsg list: each cmsghdr is { cmsg_len: u32, cmsg_level: u32, cmsg_type: u32 } - // followed by data. Alignment is 4 bytes on wasm32. - let mut offset = 0; - while offset + 12 <= control_len { - let cmsg_len = u32::from_le_bytes([ - control[offset], - control[offset + 1], - control[offset + 2], - control[offset + 3], - ]) as usize; - let cmsg_level = u32::from_le_bytes([ - control[offset + 4], - control[offset + 5], - control[offset + 6], - control[offset + 7], - ]); - let cmsg_type = u32::from_le_bytes([ - control[offset + 8], - control[offset + 9], - control[offset + 10], - control[offset + 11], - ]); - - if cmsg_len < 12 || offset + cmsg_len > control_len { - break; - } - - if cmsg_level == SOL_SOCKET && cmsg_type == SCM_RIGHTS { - // Data starts at offset + 12, length = cmsg_len - 12 - let data_len = cmsg_len - 12; - let num_fds = data_len / 4; - for i in 0..num_fds { - let fd_offset = offset + 12 + i * 4; - let fd_num = i32::from_le_bytes([ - control[fd_offset], - control[fd_offset + 1], - control[fd_offset + 2], - control[fd_offset + 3], - ]); - - // Look up this FD in the sender's process - if let Ok(fd_entry) = proc.fd_table.get(fd_num) { - if let Some(ofd) = proc.ofd_table.get(fd_entry.ofd_ref.0) { - let mut in_flight = InFlightFd::new( - ofd.ofd_id, - ofd.file_id, - ofd.file_type, - ofd.status_flags, - ofd.host_handle, - ofd.offset, - ofd.path.clone(), - ); - - // Serialize the resource ownership while the sender's - // OFD and backing pipe are both authoritative. FIFO - // path and read-cohort ownership cannot be recovered - // from O_ACCMODE at receive or discard time. - if ofd.file_type == crate::ofd::FileType::Pipe - && ofd.host_handle < 0 - { - let pipe_idx = (-(ofd.host_handle + 1)) as usize; - in_flight.pipe_ref_kind = unsafe { - crate::pipe::global_pipe_table().get(pipe_idx) - } - .and_then(|pipe| pipe.reference_kind(ofd.status_flags)); - } - - // For socket FDs, serialize socket state - if ofd.file_type == crate::ofd::FileType::Socket { - let sock_idx = (-(ofd.host_handle + 1)) as usize; - if let Some(sock) = proc.sockets.get(sock_idx) { - in_flight.socket = Some(InFlightSocket { - domain: match sock.domain { - SocketDomain::Unix => 0, - SocketDomain::Inet => 1, - SocketDomain::Inet6 => 2, - }, - sock_type: match sock.sock_type { - SocketType::Stream => 0, - SocketType::Dgram => 1, - }, - protocol: sock.protocol, - state: match sock.state { - SocketState::Unbound => 0, - SocketState::Bound => 1, - SocketState::Listening => 2, - SocketState::Connected => 3, - SocketState::Closed => 4, - // See fork.rs serialise_fork_state. - SocketState::Connecting => 4, - }, - send_buf_idx: sock.send_buf_idx, - recv_buf_idx: sock.recv_buf_idx, - global_pipes: sock.global_pipes, - shut_rd: sock.shut_rd, - shut_wr: sock.shut_wr, - bind_addr: sock.bind_addr, - bind_port: sock.bind_port, - peer_addr: sock.peer_addr, - peer_port: sock.peer_port, - }); - } - } - - result.push(in_flight); - } - } - } - } - - // Advance to next cmsg (aligned to 4 bytes) - offset += (cmsg_len + 3) & !3; + if control_ptr == 0 { + return Err(Errno::EINVAL); } - result -} - -/// Queue one successfully sent SCM_RIGHTS message and retain the resources -/// represented by its serialized descriptors until receive or discard. -fn queue_scm_rights_fds( - proc: &crate::process::Process, - socket_fd: i32, - fds: Vec, -) -> bool { - let send_idx = { - let Ok(fd_entry) = proc.fd_table.get(socket_fd) else { - return false; - }; - let Some(ofd) = proc.ofd_table.get(fd_entry.ofd_ref.0) else { - return false; - }; - if ofd.file_type != crate::ofd::FileType::Socket { - return false; - } - let sock_idx = (-(ofd.host_handle + 1)) as usize; - let Some(socket) = proc.sockets.get(sock_idx) else { - return false; - }; - let Some(send_idx) = socket.send_buf_idx else { - return false; - }; - send_idx - }; + let control = unsafe { slice::from_raw_parts(control_ptr as *const u8, control_len) }; + // WHY: this visitor only serializes non-owning entries. The caller retains + // their resources after the complete wire validates, so a malformed later + // record cannot leave an earlier descriptor partially transferred. + crate::socket_wire::for_each_canonical_scm_rights_fd(control, |fd_num| { + result.try_reserve(1).map_err(|_| Errno::ENOMEM)?; + result.push(crate::syscalls::snapshot_scm_rights_fd(proc, fd_num)?); + Ok(()) + })?; - let pipes = unsafe { crate::pipe::global_pipe_table() }; - pipes.queue_retained_ancillary(send_idx, fds) + Ok(result) } -/// sendmsg — send a message on a socket. -/// Parses msghdr to extract iov[0] and delegates to sys_sendmsg. -/// Handles SCM_RIGHTS ancillary data by serializing FDs into the pipe's ancillary queue. +/// sendmsg — send one canonical host-staged message on a socket. +/// +/// The host flattens every caller-native iovec into one contiguous leased +/// buffer. Keeping the fixed wire at zero or one iovec preserves datagram +/// atomicity without a second kernel allocation and payload copy. #[unsafe(no_mangle)] pub extern "C" fn kernel_sendmsg(fd: i32, msg_ptr: *const u8, flags: u32) -> i32 { + use wasm_posix_shared::{KernelIovecWire, KernelMsghdrWire}; + let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; - // Parse msghdr: msg_name(0), msg_namelen(4), msg_iov(8), msg_iovlen(12), - // msg_control(16), msg_controllen(20), msg_flags(24) - let msg = unsafe { slice::from_raw_parts(msg_ptr, 28) }; - let name_ptr = u32::from_le_bytes([msg[0], msg[1], msg[2], msg[3]]) as usize; - let name_len = u32::from_le_bytes([msg[4], msg[5], msg[6], msg[7]]) as usize; - let iov_ptr = u32::from_le_bytes([msg[8], msg[9], msg[10], msg[11]]) as usize; - let iov_len = u32::from_le_bytes([msg[12], msg[13], msg[14], msg[15]]); - let control_ptr = u32::from_le_bytes([msg[16], msg[17], msg[18], msg[19]]) as usize; - let control_len = u32::from_le_bytes([msg[20], msg[21], msg[22], msg[23]]) as usize; + let msg = unsafe { slice::from_raw_parts(msg_ptr, size_of::()) }; + let name_ptr = read_wire_u32(msg, offset_of!(KernelMsghdrWire, name)) as usize; + let name_len = read_wire_u32(msg, offset_of!(KernelMsghdrWire, name_len)) as usize; + let iov_ptr = read_wire_u32(msg, offset_of!(KernelMsghdrWire, iov)) as usize; + let iov_len = read_wire_u32(msg, offset_of!(KernelMsghdrWire, iov_len)); + let control_ptr = read_wire_u32(msg, offset_of!(KernelMsghdrWire, control)) as usize; + let control_len = read_wire_u32(msg, offset_of!(KernelMsghdrWire, control_len)) as usize; - if iov_len == 0 { - deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); - return 0; - } - - // Extract SCM_RIGHTS ancillary FDs before sending data - let mut ancillary_fds = extract_scm_rights(proc, control_ptr, control_len); - if let Err(err) = ancillary_fds - .iter_mut() - .try_for_each(crate::pipe::InFlightFd::retain_reference) - { - drop(ancillary_fds); - syscalls::drain_deferred_scm_rights_releases(advisory_locks, &mut host); + if let Err(err) = validate_canonical_message_iov_len(iov_len) { deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); return -(err as i32); } - // Parse first iovec: iov_base at offset 0, iov_len at offset 4 - let iov = unsafe { slice::from_raw_parts(iov_ptr as *const u8, 8) }; - let base = u32::from_le_bytes([iov[0], iov[1], iov[2], iov[3]]) as usize; - let len = u32::from_le_bytes([iov[4], iov[5], iov[6], iov[7]]) as usize; - - let buf = unsafe { slice::from_raw_parts(base as *const u8, len) }; - - // If msg_name is set, use sendto with the destination address - let result = if name_ptr != 0 && name_len > 0 { - let addr = unsafe { slice::from_raw_parts(name_ptr as *const u8, name_len) }; - match syscalls::sys_sendto(proc, &mut host, fd, buf, flags, addr) { - Ok(n) => n as i32, - Err(e) => -(e as i32), + let ancillary_fds = match extract_scm_rights(proc, control_ptr, control_len) { + Ok(fds) => fds, + Err(err) => { + deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); + return -(err as i32); } + }; + + let (base, len) = if iov_len == 0 { + (0, 0) } else { - match syscalls::sys_sendmsg(proc, &mut host, fd, buf, flags) { - Ok(n) => n as i32, - Err(e) => -(e as i32), - } + let iov = + unsafe { slice::from_raw_parts(iov_ptr as *const u8, size_of::()) }; + ( + read_wire_u32(iov, offset_of!(KernelIovecWire, base)) as usize, + read_wire_u32(iov, offset_of!(KernelIovecWire, len)) as usize, + ) }; - let mut ancillary_fds = Some(ancillary_fds); - if result > 0 && ancillary_fds.as_ref().is_some_and(|fds| !fds.is_empty()) { - // The entries already own their backing and OfdId references. Queue - // them without a second retain; failure drops them into deferred cleanup. - let _ = queue_scm_rights_fds(proc, fd, ancillary_fds.take().unwrap()); - } + let buf = if len == 0 { + &[] + } else { + // SAFETY: the host copied the complete positive-length iovec into the + // live kernel-owned channel allocation before this synchronous call. + unsafe { slice::from_raw_parts(base as *const u8, len) } + }; - // A failed send or a socket without a deliverable pipe drops the retained - // entries here. Their destructors only enqueue fixed cleanup metadata. - drop(ancillary_fds); + let addr = if name_ptr != 0 && name_len > 0 { + Some(unsafe { slice::from_raw_parts(name_ptr as *const u8, name_len) }) + } else { + None + }; + let result = match syscalls::sys_sendmsg(proc, &mut host, fd, buf, flags, addr, ancillary_fds) { + Ok(n) => n as i32, + Err(e) => -(e as i32), + }; syscalls::drain_deferred_scm_rights_releases(advisory_locks, &mut host); deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); result } -/// recvmsg — receive a message from a socket. -/// Parses msghdr to extract iov[0]. For DGRAM sockets, uses recvfrom to fill msg_name. -/// Delivers SCM_RIGHTS ancillary data by installing FDs in the receiver's process. +/// recvmsg — receive into one canonical host-staged contiguous buffer. #[unsafe(no_mangle)] pub extern "C" fn kernel_recvmsg(fd: i32, msg_ptr: *mut u8, flags: u32) -> i32 { + use wasm_posix_shared::fd_flags::FD_CLOEXEC; + use wasm_posix_shared::socket::{ + MSG_CMSG_CLOEXEC, MSG_CTRUNC, SCM_RIGHTS, SCM_RIGHTS_FD_BYTES, SOL_SOCKET, + }; + use wasm_posix_shared::{KernelCmsghdrWire, KernelIovecWire, KernelMsghdrWire}; + let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; - // Parse msghdr: msg_name(0), msg_namelen(4), msg_iov(8), msg_iovlen(12), - // msg_control(16), msg_controllen(20), msg_flags(24) - let msg = unsafe { slice::from_raw_parts(msg_ptr, 28) }; - let name_ptr = u32::from_le_bytes([msg[0], msg[1], msg[2], msg[3]]) as usize; - let name_len = u32::from_le_bytes([msg[4], msg[5], msg[6], msg[7]]) as usize; - let iov_ptr = u32::from_le_bytes([msg[8], msg[9], msg[10], msg[11]]) as usize; - let iov_len = u32::from_le_bytes([msg[12], msg[13], msg[14], msg[15]]); - let control_ptr = u32::from_le_bytes([msg[16], msg[17], msg[18], msg[19]]) as usize; - let control_len = u32::from_le_bytes([msg[20], msg[21], msg[22], msg[23]]) as usize; + let msg = unsafe { slice::from_raw_parts(msg_ptr, size_of::()) }; + let name_ptr = read_wire_u32(msg, offset_of!(KernelMsghdrWire, name)) as usize; + let name_len = read_wire_u32(msg, offset_of!(KernelMsghdrWire, name_len)) as usize; + let iov_ptr = read_wire_u32(msg, offset_of!(KernelMsghdrWire, iov)) as usize; + let iov_len = read_wire_u32(msg, offset_of!(KernelMsghdrWire, iov_len)); + let control_ptr = read_wire_u32(msg, offset_of!(KernelMsghdrWire, control)) as usize; + let control_len = read_wire_u32(msg, offset_of!(KernelMsghdrWire, control_len)) as usize; - if iov_len == 0 { + if let Err(err) = validate_canonical_message_iov_len(iov_len) { deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); - return 0; + return -(err as i32); } - // Parse first iovec - let iov = unsafe { slice::from_raw_parts(iov_ptr as *const u8, 8) }; - let base = u32::from_le_bytes([iov[0], iov[1], iov[2], iov[3]]) as usize; - let len = u32::from_le_bytes([iov[4], iov[5], iov[6], iov[7]]) as usize; + let (base, len) = if iov_len == 0 { + (0, 0) + } else { + let iov = + unsafe { slice::from_raw_parts(iov_ptr as *const u8, size_of::()) }; + ( + read_wire_u32(iov, offset_of!(KernelIovecWire, base)) as usize, + read_wire_u32(iov, offset_of!(KernelIovecWire, len)) as usize, + ) + }; let buf = if len == 0 { &mut [] @@ -6816,64 +7548,41 @@ pub extern "C" fn kernel_recvmsg(fd: i32, msg_ptr: *mut u8, flags: u32) -> i32 { unsafe { slice::from_raw_parts_mut(base as *mut u8, len) } }; - // Use recvfrom if msg_name is provided (to fill source address) - let result = if name_ptr != 0 && name_len > 0 { - let addr_buf = unsafe { slice::from_raw_parts_mut(name_ptr as *mut u8, name_len) }; - match syscalls::sys_recvfrom(proc, &mut host, fd, buf, flags, addr_buf) { - Ok((data_len, addr_written)) => { - // Update msg_namelen with actual address length - let msg_mut = unsafe { slice::from_raw_parts_mut(msg_ptr, 28) }; - msg_mut[4..8].copy_from_slice(&(addr_written as u32).to_le_bytes()); - data_len as i32 - } - Err(e) => -(e as i32), - } + let addr_buf = if name_ptr != 0 && name_len > 0 { + unsafe { slice::from_raw_parts_mut(name_ptr as *mut u8, name_len) } } else { - match syscalls::sys_recvmsg(proc, &mut host, fd, buf, flags) { - Ok(n) => n as i32, - Err(e) => -(e as i32), - } + &mut [] }; - // Check for SCM_RIGHTS ancillary data on the recv pipe. - // Pop ancillary data in a limited scope to avoid holding &mut pipe across - // install_scm_rights_fds (which modifies proc). - let mut ancillary_delivered = false; - let mut output_msg_flags = 0u32; - if result > 0 { - let popped = 'pop: { - let recv_idx = { - let fd_entry = match proc.fd_table.get(fd) { - Ok(e) => e, - _ => break 'pop None, - }; - let ofd = match proc.ofd_table.get(fd_entry.ofd_ref.0) { - Some(o) if o.file_type == crate::ofd::FileType::Socket => o, - _ => break 'pop None, - }; - let sock_idx = (-(ofd.host_handle + 1)) as usize; - match proc.sockets.get(sock_idx) { - Some(s) => s.recv_buf_idx, - None => break 'pop None, - } - }; - let recv_idx = match recv_idx { - Some(i) => i, - None => break 'pop None, - }; - let pipe = unsafe { crate::pipe::global_pipe_table().get_mut(recv_idx) }; - match pipe { - Some(p) => p.pop_ancillary(), - None => None, - } + let (result, mut received) = + match syscalls::sys_recvmsg(proc, &mut host, fd, buf, flags, addr_buf) { + Ok(received) => (received.return_len as i32, Some(received)), + Err(err) => (-(err as i32), None), }; - if let Some(mut in_flight) = popped { - let control_fd_capacity = if control_ptr != 0 && control_len >= 16 { - (control_len - 12) / 4 - } else { - 0 - }; + // Publish all result metadata even for a zero-byte datagram: a zero-length + // message can still carry descriptors and output flags. + let mut ancillary_delivered = false; + let mut output_msg_flags = received + .as_ref() + .map_or(0, |received| received.output_flags); + if let Some(received) = received.as_mut() { + let msg_mut = unsafe { slice::from_raw_parts_mut(msg_ptr, size_of::()) }; + write_wire_u32( + msg_mut, + offset_of!(KernelMsghdrWire, name_len), + received.addr_len as u32, + ); + + if !received.ancillary_fds.is_empty() { + let mut in_flight = core::mem::take(&mut received.ancillary_fds); + let control_header_size = size_of::(); + let control_fd_capacity = + if control_ptr != 0 && control_len >= control_header_size + SCM_RIGHTS_FD_BYTES { + (control_len - control_header_size) / SCM_RIGHTS_FD_BYTES + } else { + 0 + }; let install_count = control_fd_capacity.min(in_flight.len()); let excess = in_flight.split_off(install_count); let had_excess = !excess.is_empty(); @@ -6885,50 +7594,63 @@ pub extern "C" fn kernel_recvmsg(fd: i32, msg_ptr: *mut u8, flags: u32) -> i32 { let new_fds = if attempted == 0 { Vec::new() } else { - syscalls::install_scm_rights_fds(proc, in_flight) + let fd_flags = if flags & MSG_CMSG_CLOEXEC != 0 { + FD_CLOEXEC + } else { + 0 + }; + syscalls::install_scm_rights_fds_with_flags(proc, in_flight, fd_flags) }; unsafe { crate::pipe::global_pipe_table().finish_ancillary_transition(); } if had_excess || new_fds.len() < attempted { - output_msg_flags |= wasm_posix_shared::socket::MSG_CTRUNC; + output_msg_flags |= MSG_CTRUNC; } - // Build cmsg response in msg_control buffer. if !new_fds.is_empty() { - let cmsg_data_len = new_fds.len() * 4; - let cmsg_len = 12 + cmsg_data_len; // cmsghdr + FD data - let cmsg_space = (cmsg_len + 3) & !3; // aligned - let ctrl = unsafe { - slice::from_raw_parts_mut(control_ptr as *mut u8, control_len) - }; - // cmsg_len - ctrl[0..4].copy_from_slice(&(cmsg_len as u32).to_le_bytes()); - // cmsg_level = SOL_SOCKET - ctrl[4..8].copy_from_slice(&1u32.to_le_bytes()); - // cmsg_type = SCM_RIGHTS - ctrl[8..12].copy_from_slice(&1u32.to_le_bytes()); - // FD numbers + let cmsg_data_len = new_fds.len() * SCM_RIGHTS_FD_BYTES; + let cmsg_len = control_header_size + cmsg_data_len; + let alignment = align_of::(); + let cmsg_space = (cmsg_len + alignment - 1) & !(alignment - 1); + debug_assert!(cmsg_space <= control_len); + let ctrl = + unsafe { slice::from_raw_parts_mut(control_ptr as *mut u8, control_len) }; + ctrl[..cmsg_space].fill(0); + write_wire_u32( + ctrl, + offset_of!(KernelCmsghdrWire, cmsg_len), + cmsg_len as u32, + ); + write_wire_u32(ctrl, offset_of!(KernelCmsghdrWire, cmsg_level), SOL_SOCKET); + write_wire_u32(ctrl, offset_of!(KernelCmsghdrWire, cmsg_type), SCM_RIGHTS); for (i, &new_fd) in new_fds.iter().enumerate() { - let off = 12 + i * 4; - ctrl[off..off + 4].copy_from_slice(&new_fd.to_le_bytes()); + let off = control_header_size + i * SCM_RIGHTS_FD_BYTES; + ctrl[off..off + SCM_RIGHTS_FD_BYTES].copy_from_slice(&new_fd.to_le_bytes()); } - // Set msg_controllen - let msg_mut = unsafe { slice::from_raw_parts_mut(msg_ptr, 28) }; - msg_mut[20..24].copy_from_slice(&(cmsg_space as u32).to_le_bytes()); + let msg_mut = + unsafe { slice::from_raw_parts_mut(msg_ptr, size_of::()) }; + write_wire_u32( + msg_mut, + offset_of!(KernelMsghdrWire, control_len), + cmsg_space as u32, + ); ancillary_delivered = true; } } } if !ancillary_delivered { - // Zero out msg_controllen to indicate no ancillary data - let msg_mut = unsafe { slice::from_raw_parts_mut(msg_ptr, 28) }; - msg_mut[20..24].copy_from_slice(&0u32.to_le_bytes()); - } - let msg_mut = unsafe { slice::from_raw_parts_mut(msg_ptr, 28) }; - msg_mut[24..28].copy_from_slice(&output_msg_flags.to_le_bytes()); + let msg_mut = unsafe { slice::from_raw_parts_mut(msg_ptr, size_of::()) }; + write_wire_u32(msg_mut, offset_of!(KernelMsghdrWire, control_len), 0); + } + let msg_mut = unsafe { slice::from_raw_parts_mut(msg_ptr, size_of::()) }; + write_wire_u32( + msg_mut, + offset_of!(KernelMsghdrWire, flags), + output_msg_flags, + ); syscalls::drain_deferred_scm_rights_releases(advisory_locks, &mut host); @@ -7246,7 +7968,17 @@ pub extern "C" fn kernel_socketpair( sock_type: u32, protocol: u32, sv_ptr: *mut i32, + sv_capacity: u32, ) -> i32 { + if sv_ptr.is_null() { + return -(Errno::EFAULT as i32); + } + if sv_capacity != wasm_posix_shared::kernel_scratch_wire::FD_PAIR_BYTES { + return -(Errno::EINVAL as i32); + } + if (sv_ptr as usize) % core::mem::align_of::() != 0 { + return -(Errno::EFAULT as i32); + } let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; let result = match syscalls::sys_socketpair(proc, &mut host, domain, sock_type, protocol) { @@ -7312,93 +8044,100 @@ pub extern "C" fn kernel_accept4( let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; - if !accept4_flags_are_valid(flags) { - deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); - return -(Errno::EINVAL as i32); - } - let result = match syscalls::sys_accept(proc, &mut host, fd) { - Ok(new_fd) => { - // Apply SOCK_CLOEXEC flag - if flags & SOCK_CLOEXEC != 0 { - if let Ok(entry) = proc.fd_table.get_mut(new_fd) { - entry.fd_flags |= FD_CLOEXEC; + let result = if !accept4_flags_are_valid(flags) { + -(Errno::EINVAL as i32) + } else { + match syscalls::sys_accept(proc, &mut host, fd) { + Ok(new_fd) => { + // Apply SOCK_CLOEXEC flag + if flags & SOCK_CLOEXEC != 0 { + if let Ok(entry) = proc.fd_table.get_mut(new_fd) { + entry.fd_flags |= FD_CLOEXEC; + } } - } - // Apply SOCK_NONBLOCK flag - if flags & SOCK_NONBLOCK != 0 { - if let Ok(entry) = proc.fd_table.get(new_fd) { - if let Some(ofd) = proc.ofd_table.get_mut(entry.ofd_ref.0) { - ofd.status_flags |= O_NONBLOCK; + // Apply SOCK_NONBLOCK flag + if flags & SOCK_NONBLOCK != 0 { + if let Ok(entry) = proc.fd_table.get(new_fd) { + if let Some(ofd) = proc.ofd_table.get_mut(entry.ofd_ref.0) { + ofd.status_flags |= O_NONBLOCK; + } } } - } - // Write peer address if buffers provided - if !addr_ptr.is_null() && !addrlen_ptr.is_null() { - let addrlen_buf = unsafe { slice::from_raw_parts_mut(addrlen_ptr, 4) }; - let max_len = u32::from_le_bytes(addrlen_buf.try_into().unwrap_or([0; 4])) as usize; - // Get the accepted socket's peer address - let entry = proc.fd_table.get(new_fd); - if let Ok(entry) = entry { - let ofd = proc.ofd_table.get(entry.ofd_ref.0); - if let Some(ofd) = ofd { - let sock_idx = (-(ofd.host_handle + 1)) as usize; - if let Some(sock) = proc.sockets.get(sock_idx) { - match sock.domain { - crate::socket::SocketDomain::Unix => { - // Write AF_UNIX sockaddr - let n = max_len.min(2); - if n >= 2 { - let addr_buf = - unsafe { slice::from_raw_parts_mut(addr_ptr, max_len) }; - addr_buf[0] = 1; // AF_UNIX - addr_buf[1] = 0; - for i in 2..max_len { - addr_buf[i] = 0; + // Write peer address if buffers provided + if !addr_ptr.is_null() && !addrlen_ptr.is_null() { + let addrlen_buf = unsafe { slice::from_raw_parts_mut(addrlen_ptr, 4) }; + let max_len = + u32::from_le_bytes(addrlen_buf.try_into().unwrap_or([0; 4])) as usize; + // Get the accepted socket's peer address + let entry = proc.fd_table.get(new_fd); + if let Ok(entry) = entry { + let ofd = proc.ofd_table.get(entry.ofd_ref.0); + if let Some(ofd) = ofd { + let sock_idx = (-(ofd.host_handle + 1)) as usize; + if let Some(sock) = proc.sockets.get(sock_idx) { + match sock.domain { + crate::socket::SocketDomain::Unix => { + // Write AF_UNIX sockaddr + let n = max_len.min(2); + if n >= 2 { + let addr_buf = unsafe { + slice::from_raw_parts_mut(addr_ptr, max_len) + }; + addr_buf[0] = 1; // AF_UNIX + addr_buf[1] = 0; + for i in 2..max_len { + addr_buf[i] = 0; + } + addrlen_buf.copy_from_slice(&2u32.to_le_bytes()); } - addrlen_buf.copy_from_slice(&2u32.to_le_bytes()); } - } - crate::socket::SocketDomain::Inet => { - let mut sa = [0u8; 16]; - sa[0] = 2; // AF_INET - let port_be = sock.peer_port.to_be_bytes(); - sa[2] = port_be[0]; - sa[3] = port_be[1]; - sa[4] = sock.peer_addr[0]; - sa[5] = sock.peer_addr[1]; - sa[6] = sock.peer_addr[2]; - sa[7] = sock.peer_addr[3]; - let n = max_len.min(16); - let addr_buf = - unsafe { slice::from_raw_parts_mut(addr_ptr, n) }; - addr_buf.copy_from_slice(&sa[..n]); - addrlen_buf.copy_from_slice(&16u32.to_le_bytes()); - } - crate::socket::SocketDomain::Inet6 => { - let mut sa = [0u8; 28]; - sa[0] = 10; // AF_INET6 - let port_be = sock.peer_port.to_be_bytes(); - sa[2] = port_be[0]; - sa[3] = port_be[1]; - sa[8..24].copy_from_slice(&sock.peer_addr6); - let n = max_len.min(28); - let addr_buf = - unsafe { slice::from_raw_parts_mut(addr_ptr, n) }; - addr_buf.copy_from_slice(&sa[..n]); - addrlen_buf.copy_from_slice(&28u32.to_le_bytes()); + crate::socket::SocketDomain::Inet => { + let mut sa = [0u8; 16]; + sa[0] = 2; // AF_INET + let port_be = sock.peer_port.to_be_bytes(); + sa[2] = port_be[0]; + sa[3] = port_be[1]; + sa[4] = sock.peer_addr[0]; + sa[5] = sock.peer_addr[1]; + sa[6] = sock.peer_addr[2]; + sa[7] = sock.peer_addr[3]; + let n = max_len.min(16); + let addr_buf = + unsafe { slice::from_raw_parts_mut(addr_ptr, n) }; + addr_buf.copy_from_slice(&sa[..n]); + addrlen_buf.copy_from_slice(&16u32.to_le_bytes()); + } + crate::socket::SocketDomain::Inet6 => { + let mut sa = [0u8; 28]; + sa[0] = 10; // AF_INET6 + let port_be = sock.peer_port.to_be_bytes(); + sa[2] = port_be[0]; + sa[3] = port_be[1]; + sa[8..24].copy_from_slice(&sock.peer_addr6); + let n = max_len.min(28); + let addr_buf = + unsafe { slice::from_raw_parts_mut(addr_ptr, n) }; + addr_buf.copy_from_slice(&sa[..n]); + addrlen_buf.copy_from_slice(&28u32.to_le_bytes()); + } } } } } + } else if !addrlen_ptr.is_null() { + let buf = unsafe { slice::from_raw_parts_mut(addrlen_ptr, 4) }; + buf.copy_from_slice(&0u32.to_le_bytes()); } - } else if !addrlen_ptr.is_null() { - let buf = unsafe { slice::from_raw_parts_mut(addrlen_ptr, 4) }; - buf.copy_from_slice(&0u32.to_le_bytes()); + new_fd } - new_fd + Err(e) => -(e as i32), } - Err(e) => -(e as i32), }; + // WHY: an accepted connection can already carry stream SCM_RIGHTS before + // accept allocates its fd. If that allocation fails, discarding the + // accepted socket drops those rights and must finish their deferred + // backing/lock cleanup in this same exported operation. + syscalls::finish_scm_rights_cleanup(advisory_locks, &mut host); deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); result } @@ -7453,6 +8192,11 @@ pub extern "C" fn kernel_connect(fd: i32, addr_ptr: *const u8, addr_len: u32) -> Err(e) => -(e as i32), }; if let Some((proc, advisory_locks)) = table.process_and_advisory_locks(pid) { + // WHY: reconnecting an AF_UNIX datagram socket can discard queued + // messages and their retained SCM_RIGHTS descriptors. Complete those + // deferred releases in this dispatch before locks or host handles can + // remain live until an unrelated later syscall. + syscalls::finish_scm_rights_cleanup(advisory_locks, &mut host); deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); } result @@ -7486,9 +8230,7 @@ fn cross_process_loopback_connect( } let sock_idx = (-(ofd.host_handle + 1)) as usize; let client_sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; - if client_sock.domain != SocketDomain::Inet - || client_sock.sock_type != SocketType::Stream - { + if client_sock.domain != SocketDomain::Inet || client_sock.sock_type != SocketType::Stream { return Err(Errno::ECONNREFUSED); } sock_idx @@ -7514,9 +8256,7 @@ fn cross_process_loopback_connect( && s.bind_port == port && s.sock_type == SocketType::Stream && match s.domain { - SocketDomain::Inet => { - s.bind_addr == [0, 0, 0, 0] || s.bind_addr == ip - } + SocketDomain::Inet => s.bind_addr == [0, 0, 0, 0] || s.bind_addr == ip, SocketDomain::Inet6 => { s.bind_addr6 == [0; 16] && s.get_option(IPPROTO_IPV6, IPV6_V6ONLY).unwrap_or(0) == 0 @@ -7642,8 +8382,7 @@ fn cross_process_loopback_connect6( } let sock_idx = (-(ofd.host_handle + 1)) as usize; let client_sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; - if client_sock.domain != SocketDomain::Inet6 - || client_sock.sock_type != SocketType::Stream + if client_sock.domain != SocketDomain::Inet6 || client_sock.sock_type != SocketType::Stream { return Err(Errno::ECONNREFUSED); } @@ -7810,9 +8549,7 @@ fn cross_process_unix_connect( { return Err(Errno::ECONNREFUSED); } - let shared_idx = listener - .shared_backlog_idx - .ok_or(Errno::ECONNREFUSED)?; + let shared_idx = listener.shared_backlog_idx.ok_or(Errno::ECONNREFUSED)?; let accept_wake_idx = listener.accept_wake_idx; // Allocate pipes only after both endpoints have been validated, so a @@ -7941,8 +8678,8 @@ pub extern "C" fn kernel_send(fd: i32, buf_ptr: *const u8, buf_len: u32, flags: pub extern "C" fn kernel_recv(fd: i32, buf_ptr: *mut u8, buf_len: u32, flags: u32) -> i32 { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; - // A zero-length output is not assigned scratch space by the host. Avoid - // treating the unchanged guest pointer as a kernel-memory pointer. + // WHY: a zero-length receive has no destination bytes. Avoid imposing + // Rust's non-null raw-slice requirement on its semantically unused pointer. let buf = if buf_len == 0 { &mut [] } else { @@ -7952,6 +8689,7 @@ pub extern "C" fn kernel_recv(fd: i32, buf_ptr: *mut u8, buf_len: u32, flags: u3 Ok(n) => n as i32, Err(e) => -(e as i32), }; + syscalls::finish_scm_rights_cleanup(advisory_locks, &mut host); deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); result } @@ -7965,7 +8703,7 @@ pub extern "C" fn kernel_shutdown(fd: i32, how: u32) -> i32 { Ok(()) => 0, Err(e) => -(e as i32), }; - syscalls::drain_deferred_scm_rights_releases(advisory_locks, &mut host); + syscalls::finish_scm_rights_cleanup(advisory_locks, &mut host); deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); result } @@ -7977,14 +8715,22 @@ pub extern "C" fn kernel_shutdown(fd: i32, how: u32) -> i32 { /// Both pointers are required for these option shapes. fn write_getsockopt_bytes( optval_ptr: *mut u8, + optval_capacity: u32, optlen_ptr: *mut u32, + optlen_capacity: u32, value: &[u8], ) -> Result<(), Errno> { if optval_ptr.is_null() || optlen_ptr.is_null() { return Err(Errno::EFAULT); } + if optlen_capacity != wasm_posix_shared::kernel_scratch_wire::SOCKLEN_BYTES { + return Err(Errno::EINVAL); + } let available = unsafe { core::ptr::read_unaligned(optlen_ptr) as usize }; + if available > optval_capacity as usize { + return Err(Errno::EFAULT); + } let write_len = available.min(value.len()); if write_len > 0 { let out = unsafe { slice::from_raw_parts_mut(optval_ptr, write_len) }; @@ -8003,7 +8749,9 @@ pub extern "C" fn kernel_getsockopt( level: u32, optname: u32, optval_ptr: *mut u8, + optval_capacity: u32, optlen_ptr: *mut u32, + optlen_capacity: u32, ) -> i32 { use wasm_posix_shared::socket::*; let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; @@ -8011,7 +8759,13 @@ pub extern "C" fn kernel_getsockopt( // Handle struct tcp_info (TCP_INFO) if level == IPPROTO_TCP && optname == TCP_INFO { let result = match syscalls::sys_getsockopt_tcp_info(proc, fd) { - Ok(info_buf) => match write_getsockopt_bytes(optval_ptr, optlen_ptr, &info_buf) { + Ok(info_buf) => match write_getsockopt_bytes( + optval_ptr, + optval_capacity, + optlen_ptr, + optlen_capacity, + &info_buf, + ) { Ok(()) => 0, Err(e) => -(e as i32), }, @@ -8029,7 +8783,13 @@ pub extern "C" fn kernel_getsockopt( let mut tmp = [0u8; 8]; tmp[0..4].copy_from_slice(&l_onoff.to_le_bytes()); tmp[4..8].copy_from_slice(&l_linger.to_le_bytes()); - match write_getsockopt_bytes(optval_ptr, optlen_ptr, &tmp) { + match write_getsockopt_bytes( + optval_ptr, + optval_capacity, + optlen_ptr, + optlen_capacity, + &tmp, + ) { Ok(()) => 0, Err(e) => -(e as i32), } @@ -8049,7 +8809,13 @@ pub extern "C" fn kernel_getsockopt( if !value.is_empty() { value.push(0); } - match write_getsockopt_bytes(optval_ptr, optlen_ptr, &value) { + match write_getsockopt_bytes( + optval_ptr, + optval_capacity, + optlen_ptr, + optlen_capacity, + &value, + ) { Ok(()) => 0, Err(e) => -(e as i32), } @@ -8066,7 +8832,13 @@ pub extern "C" fn kernel_getsockopt( let result = match syscalls::sys_getsockopt_tcp_congestion(proc, fd) { Ok(mut name) => { name.push(0); - match write_getsockopt_bytes(optval_ptr, optlen_ptr, &name) { + match write_getsockopt_bytes( + optval_ptr, + optval_capacity, + optlen_ptr, + optlen_capacity, + &name, + ) { Ok(()) => 0, Err(e) => -(e as i32), } @@ -8088,7 +8860,13 @@ pub extern "C" fn kernel_getsockopt( let mut value = [0u8; 16]; value[0..8].copy_from_slice(&tv_sec.to_le_bytes()); value[8..16].copy_from_slice(&tv_usec.to_le_bytes()); - match write_getsockopt_bytes(optval_ptr, optlen_ptr, &value) { + match write_getsockopt_bytes( + optval_ptr, + optval_capacity, + optlen_ptr, + optlen_capacity, + &value, + ) { Ok(()) => 0, Err(e) => -(e as i32), } @@ -8101,12 +8879,16 @@ pub extern "C" fn kernel_getsockopt( } let result = match syscalls::sys_getsockopt(proc, fd, level, optname) { - Ok(val) => { - match write_getsockopt_bytes(optval_ptr, optlen_ptr, &val.to_le_bytes()) { - Ok(()) => 0, - Err(e) => -(e as i32), - } - } + Ok(val) => match write_getsockopt_bytes( + optval_ptr, + optval_capacity, + optlen_ptr, + optlen_capacity, + &val.to_le_bytes(), + ) { + Ok(()) => 0, + Err(e) => -(e as i32), + }, Err(e) => -(e as i32), }; let mut host = WasmHostIO; @@ -8286,8 +9068,7 @@ pub extern "C" fn kernel_setsockopt( } } MCAST_JOIN_GROUP | MCAST_LEAVE_GROUP => { - let (group_offset, _) = - syscalls::multicast_group_request_offsets(buf, false)?; + let (group_offset, _) = syscalls::multicast_group_request_offsets(buf, false)?; Ok(( parse_sockaddr_in_at(group_offset)?, parse_ifindex_at(0)?, @@ -8354,7 +9135,26 @@ pub extern "C" fn kernel_setsockopt( /// Poll file descriptors. Returns number of ready fds, or negative errno. /// fds_ptr points to an array of WasmPollFd structs (8 bytes each: i32 fd, i16 events, i16 revents). #[unsafe(no_mangle)] -pub extern "C" fn kernel_poll(fds_ptr: *mut u8, nfds: u32, timeout: i32) -> i32 { +pub extern "C" fn kernel_poll( + fds_ptr: *mut u8, + fds_capacity: u32, + nfds: u32, + timeout: i32, +) -> i32 { + let Some(required_capacity) = nfds.checked_mul( + core::mem::size_of::() as u32, + ) else { + return -(Errno::EOVERFLOW as i32); + }; + if fds_capacity != required_capacity { + return -(Errno::EINVAL as i32); + } + if fds_ptr.is_null() { + return -(Errno::EFAULT as i32); + } + if (fds_ptr as usize) % core::mem::align_of::() != 0 { + return -(Errno::EFAULT as i32); + } let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let fds = unsafe { slice::from_raw_parts_mut(fds_ptr as *mut wasm_posix_shared::WasmPollFd, nfds as usize) @@ -8407,9 +9207,8 @@ pub extern "C" fn kernel_recvfrom( ) -> i32 { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; - // The generic host marshaller leaves a zero-sized pointer unadjusted. - // Construct a real empty slice instead of interpreting that guest-memory - // address inside the kernel instance. + // WHY: a zero-length receive has no destination bytes. Avoid imposing + // Rust's non-null raw-slice requirement on its semantically unused pointer. let buf = if buf_len == 0 { &mut [] } else { @@ -8437,6 +9236,7 @@ pub extern "C" fn kernel_recvfrom( } Err(e) => -(e as i32), }; + syscalls::drain_deferred_scm_rights_releases(advisory_locks, &mut host); deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); result } @@ -8523,18 +9323,10 @@ pub extern "C" fn kernel_fstatat( let mut host = WasmHostIO; let path = unsafe { slice::from_raw_parts(path_ptr, path_len as usize) }; let result = match syscalls::sys_fstatat(proc, &mut host, dirfd, path, flags) { - Ok(stat) => { - let stat_bytes = unsafe { - slice::from_raw_parts( - &stat as *const WasmStat as *const u8, - core::mem::size_of::(), - ) - }; - unsafe { - core::ptr::copy_nonoverlapping(stat_bytes.as_ptr(), stat_ptr, stat_bytes.len()); - } - 0 - } + Ok(stat) => match write_process_stat(stat_ptr, &stat) { + Ok(()) => 0, + Err(error) => -(error as i32), + }, Err(e) => -(e as i32), }; deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); @@ -8607,6 +9399,12 @@ pub extern "C" fn kernel_renameat( /// Get terminal attributes. Returns 0 on success, or negative errno on error. #[unsafe(no_mangle)] pub extern "C" fn kernel_tcgetattr(fd: i32, buf_ptr: *mut u8, buf_len: u32) -> i32 { + if buf_len != wasm_posix_shared::ioctl_contract::TERMIOS_SIZE { + return -(Errno::EINVAL as i32); + } + if buf_ptr.is_null() { + return -(Errno::EFAULT as i32); + } let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let buf = unsafe { core::slice::from_raw_parts_mut(buf_ptr, buf_len as usize) }; let result = match syscalls::sys_tcgetattr(proc, fd, buf) { @@ -8621,6 +9419,12 @@ pub extern "C" fn kernel_tcgetattr(fd: i32, buf_ptr: *mut u8, buf_len: u32) -> i /// Set terminal attributes. Returns 0 on success, or negative errno on error. #[unsafe(no_mangle)] pub extern "C" fn kernel_tcsetattr(fd: i32, action: u32, buf_ptr: *const u8, buf_len: u32) -> i32 { + if buf_len != wasm_posix_shared::ioctl_contract::TERMIOS_SIZE { + return -(Errno::EINVAL as i32); + } + if buf_ptr.is_null() { + return -(Errno::EFAULT as i32); + } let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let buf = unsafe { core::slice::from_raw_parts(buf_ptr, buf_len as usize) }; let result = match syscalls::sys_tcsetattr(proc, fd, action, buf) { @@ -8633,12 +9437,67 @@ pub extern "C" fn kernel_tcsetattr(fd: i32, action: u32, buf_ptr: *const u8, buf } /// Perform an ioctl operation. Returns 0 on success, or negative errno on error. +/// +/// `buf_len` is part of the request contract, not an allocation hint. The +/// process pointer width selects native request layouts such as drm_version. #[unsafe(no_mangle)] -pub extern "C" fn kernel_ioctl(fd: i32, request: u32, buf_ptr: *mut u8, buf_len: u32) -> i32 { +pub extern "C" fn kernel_ioctl( + fd: i32, + request: u32, + buf_ptr: *mut u8, + buf_len: u32, + process_pointer_width: u32, +) -> i32 { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; - let buf = unsafe { core::slice::from_raw_parts_mut(buf_ptr, buf_len as usize) }; let mut host = WasmHostIO; - let result = match syscalls::sys_ioctl(proc, &mut host, fd, request, buf) { + let result = match wasm_posix_shared::ioctl_contract::request_contract(request) { + Some(contract) => (|| { + let pointer_width = u8::try_from(process_pointer_width) + .ok() + .filter(|width| *width == 4 || *width == 8) + .ok_or(Errno::EINVAL)?; + let expected_size = contract + .size_for_pointer_width(pointer_width) + .ok_or(Errno::EOVERFLOW)?; + use wasm_posix_shared::ioctl_contract::IoctlArgKind; + match contract.arg_kind { + IoctlArgKind::None => { + if buf_len != 0 { + Err(Errno::EINVAL) + } else { + syscalls::sys_ioctl(proc, &mut host, fd, request, &mut []) + } + } + IoctlArgKind::ScalarI32 => { + if buf_len != 0 { + Err(Errno::EINVAL) + } else { + // WHY: scalar ioctl arguments occupy the same channel + // slot as pointers. Decode the value without ever + // treating it as an address. + let mut scalar = (buf_ptr as usize as u32).to_le_bytes(); + syscalls::sys_ioctl(proc, &mut host, fd, request, &mut scalar) + } + } + IoctlArgKind::Pointer => { + if buf_len != expected_size { + Err(Errno::EINVAL) + } else if buf_ptr.is_null() { + Err(Errno::EFAULT) + } else { + let buf = unsafe { + core::slice::from_raw_parts_mut(buf_ptr, expected_size as usize) + }; + syscalls::sys_ioctl(proc, &mut host, fd, request, buf) + } + } + } + })(), + // Unknown requests stage no caller pointer. Let the fd/device path + // choose EBADF/ENOTTY/ENOSYS using an empty slice. + None => syscalls::sys_ioctl(proc, &mut host, fd, request, &mut []), + }; + let result = match result { Ok(()) => 0, Err(e) => -(e as i32), }; @@ -8650,18 +9509,31 @@ pub extern "C" fn kernel_ioctl(fd: i32, request: u32, buf_ptr: *mut u8, buf_len: /// buf_ptr is used for PR_SET_NAME (read name from buf) and PR_GET_NAME (write name to buf). #[unsafe(no_mangle)] pub extern "C" fn kernel_prctl(option: u32, arg2: u32, _arg3: *mut u8, _arg4: u32) -> i32 { + kernel_prctl_from_channel(option, arg2 as usize, _arg3, _arg4) +} + +/// Channel dispatcher implementation with the complete target-width `arg2` +/// value retained for the PR_SET_NAME/PR_GET_NAME pointer cases. +fn kernel_prctl_from_channel(option: u32, arg2: usize, _arg3: *mut u8, _arg4: u32) -> i32 { + use wasm_posix_shared::{kernel_scratch_wire, prctl}; + let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; - // For PR_SET_NAME (15) and PR_GET_NAME (16), arg2 is the pointer to - // a 16-byte name buffer. The other prctl args are option-specific and - // may be garbage for options that don't use them. - const PR_SET_NAME: u32 = 15; - const PR_GET_NAME: u32 = 16; - let buf = if (option == PR_SET_NAME || option == PR_GET_NAME) && arg2 != 0 { - unsafe { core::slice::from_raw_parts_mut(arg2 as *mut u8, 16) } + // For the two thread-name operations, arg2 is the pointer to the + // generated fixed-size name buffer. The other prctl args are + // option-specific and may be garbage for options that don't use them. + let is_name_operation = + option == prctl::PR_SET_NAME || option == prctl::PR_GET_NAME; + let buf = if is_name_operation && arg2 != 0 { + unsafe { + core::slice::from_raw_parts_mut( + arg2 as *mut u8, + kernel_scratch_wire::PRCTL_NAME_BYTES as usize, + ) + } } else { &mut [] }; - let result = match syscalls::sys_prctl(proc, option, arg2, buf) { + let result = match syscalls::sys_prctl(proc, option, arg2 as u32, buf) { Ok(()) => 0, Err(e) => -(e as i32), }; @@ -8786,8 +9658,18 @@ pub extern "C" fn kernel_fchown(fd: i32, uid: u32, gid: u32) -> i32 { result } -/// Write data from multiple buffers (scatter-gather I/O). -/// iov_ptr points to an array of iovec structs, each 8 bytes: (iov_base: u32, iov_len: u32). +unsafe fn kernel_iovec_wire_at(iov_ptr: *const u8, index: usize) -> (usize, usize) { + use wasm_posix_shared::KernelIovecWire; + + let offset = index * size_of::(); + let iov = unsafe { slice::from_raw_parts(iov_ptr.add(offset), size_of::()) }; + ( + read_wire_u32(iov, offset_of!(KernelIovecWire, base)) as usize, + read_wire_u32(iov, offset_of!(KernelIovecWire, len)) as usize, + ) +} + +/// Write data from multiple fixed kernel-wire buffers. /// Returns total bytes written (>= 0) or negative errno. #[unsafe(no_mangle)] pub extern "C" fn kernel_writev(fd: i32, iov_ptr: *const u8, iovcnt: i32) -> i32 { @@ -8795,21 +9677,18 @@ pub extern "C" fn kernel_writev(fd: i32, iov_ptr: *const u8, iovcnt: i32) -> i32 let mut host = WasmHostIO; let result = 'done: { - if iovcnt <= 0 || iovcnt > 1024 { + if iovcnt <= 0 || iovcnt as usize > platform_limits::IOV_MAX { break 'done -(Errno::EINVAL as i32); } let mut buffers = Vec::with_capacity(iovcnt as usize); for i in 0..iovcnt as usize { - let iov = unsafe { iov_ptr.add(i * 8) }; - let base = unsafe { u32::from_le_bytes([*iov, *iov.add(1), *iov.add(2), *iov.add(3)]) }; - let len = - unsafe { u32::from_le_bytes([*iov.add(4), *iov.add(5), *iov.add(6), *iov.add(7)]) }; + let (base, len) = unsafe { kernel_iovec_wire_at(iov_ptr, i) }; if len == 0 { continue; } - buffers.push(unsafe { slice::from_raw_parts(base as *const u8, len as usize) }); + buffers.push(unsafe { slice::from_raw_parts(base as *const u8, len) }); } match syscalls::sys_writev(proc, &mut host, fd, &buffers) { Ok(n) => n as i32, @@ -8820,8 +9699,7 @@ pub extern "C" fn kernel_writev(fd: i32, iov_ptr: *const u8, iovcnt: i32) -> i32 result } -/// Read data into multiple buffers (scatter-gather I/O). -/// iov_ptr points to an array of iovec structs, each 8 bytes: (iov_base: u32, iov_len: u32). +/// Read data into multiple fixed kernel-wire buffers. /// Returns total bytes read (>= 0) or negative errno. #[unsafe(no_mangle)] pub extern "C" fn kernel_readv(fd: i32, iov_ptr: *mut u8, iovcnt: i32) -> i32 { @@ -8829,21 +9707,18 @@ pub extern "C" fn kernel_readv(fd: i32, iov_ptr: *mut u8, iovcnt: i32) -> i32 { let mut host = WasmHostIO; let result = 'done: { - if iovcnt <= 0 || iovcnt > 1024 { + if iovcnt <= 0 || iovcnt as usize > platform_limits::IOV_MAX { break 'done -(Errno::EINVAL as i32); } let mut total: usize = 0; for i in 0..iovcnt as usize { - let iov = unsafe { iov_ptr.add(i * 8) }; - let base = unsafe { u32::from_le_bytes([*iov, *iov.add(1), *iov.add(2), *iov.add(3)]) }; - let len = - unsafe { u32::from_le_bytes([*iov.add(4), *iov.add(5), *iov.add(6), *iov.add(7)]) }; + let (base, len) = unsafe { kernel_iovec_wire_at(iov_ptr, i) }; if len == 0 { continue; } - let buf = unsafe { slice::from_raw_parts_mut(base as *mut u8, len as usize) }; + let buf = unsafe { slice::from_raw_parts_mut(base as *mut u8, len) }; match syscalls::sys_read(proc, &mut host, fd, buf) { Ok(n) => { total += n; @@ -8861,12 +9736,12 @@ pub extern "C" fn kernel_readv(fd: i32, iov_ptr: *mut u8, iovcnt: i32) -> i32 { } total as i32 }; + syscalls::drain_deferred_scm_rights_releases(advisory_locks, &mut host); deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); result } -/// preadv -- scatter-gather read at offset. -/// iov_ptr points to iovec array (8 bytes each: base u32, len u32). +/// preadv -- scatter-gather read from fixed kernel-wire buffers at offset. /// offset is split into (lo, hi) u32 pair. /// Returns total bytes read or negative errno. #[unsafe(no_mangle)] @@ -8882,22 +9757,19 @@ pub extern "C" fn kernel_preadv( let offset = ((offset_hi as i64) << 32) | (offset_lo as u64 as i64); let result = 'done: { - if iovcnt <= 0 || iovcnt > 1024 { + if iovcnt <= 0 || iovcnt as usize > platform_limits::IOV_MAX { break 'done -(Errno::EINVAL as i32); } let mut total: usize = 0; let mut cur_offset = offset; for i in 0..iovcnt as usize { - let iov = unsafe { iov_ptr.add(i * 8) }; - let base = unsafe { u32::from_le_bytes([*iov, *iov.add(1), *iov.add(2), *iov.add(3)]) }; - let len = - unsafe { u32::from_le_bytes([*iov.add(4), *iov.add(5), *iov.add(6), *iov.add(7)]) }; + let (base, len) = unsafe { kernel_iovec_wire_at(iov_ptr, i) }; if len == 0 { continue; } - let buf = unsafe { slice::from_raw_parts_mut(base as *mut u8, len as usize) }; + let buf = unsafe { slice::from_raw_parts_mut(base as *mut u8, len) }; match syscalls::sys_pread(proc, &mut host, fd, buf, cur_offset) { Ok(n) => { total += n; @@ -8920,8 +9792,7 @@ pub extern "C" fn kernel_preadv( result } -/// pwritev -- scatter-gather write at offset. -/// iov_ptr points to iovec array (8 bytes each: base u32, len u32). +/// pwritev -- scatter-gather write from fixed kernel-wire buffers at offset. /// offset is split into (lo, hi) u32 pair. /// Returns total bytes written or negative errno. #[unsafe(no_mangle)] @@ -8937,21 +9808,18 @@ pub extern "C" fn kernel_pwritev( let offset = ((offset_hi as i64) << 32) | (offset_lo as u64 as i64); let result = 'done: { - if iovcnt <= 0 || iovcnt > 1024 { + if iovcnt <= 0 || iovcnt as usize > platform_limits::IOV_MAX { break 'done -(Errno::EINVAL as i32); } let mut buffers = Vec::with_capacity(iovcnt as usize); for i in 0..iovcnt as usize { - let iov = unsafe { iov_ptr.add(i * 8) }; - let base = unsafe { u32::from_le_bytes([*iov, *iov.add(1), *iov.add(2), *iov.add(3)]) }; - let len = - unsafe { u32::from_le_bytes([*iov.add(4), *iov.add(5), *iov.add(6), *iov.add(7)]) }; + let (base, len) = unsafe { kernel_iovec_wire_at(iov_ptr, i) }; if len == 0 { continue; } - buffers.push(unsafe { slice::from_raw_parts(base as *const u8, len as usize) }); + buffers.push(unsafe { slice::from_raw_parts(base as *const u8, len) }); } match syscalls::sys_pwritev(proc, &mut host, fd, &buffers, offset) { Ok(n) => n as i32, @@ -8990,6 +9858,10 @@ pub extern "C" fn kernel_sendfile(out_fd: i32, in_fd: i32, offset_ptr: *mut u8, } Err(e) => -(e as i32), }; + // WHY: sendfile without an explicit input offset consumes through the + // ordinary read path. Crossing stream ancillary data discards its + // SCM_RIGHTS, so direct callers need the same cleanup as channel dispatch. + syscalls::finish_scm_rights_cleanup(advisory_locks, &mut host); deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); result } @@ -9239,32 +10111,78 @@ pub extern "C" fn kernel_readlinkat( /// select — synchronous I/O multiplexing. /// -/// Each fd_set is 128 bytes (1024 bits). Null pointer means that set is not used. +/// Each fd_set uses the shared generated width. Null means the set is unused. /// Returns number of ready fds, or negative errno on error. #[unsafe(no_mangle)] pub extern "C" fn kernel_select( nfds: i32, readfds_ptr: *mut u8, + readfds_capacity: u32, writefds_ptr: *mut u8, + writefds_capacity: u32, exceptfds_ptr: *mut u8, + exceptfds_capacity: u32, timeout_ms: i32, ) -> i32 { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; + let fd_set_bytes = wasm_posix_shared::select::FD_SET_BYTES; + let pointer_range = |ptr: *mut u8, capacity: u32| -> Result, Errno> { + if ptr.is_null() { + return if capacity == 0 { + Ok(None) + } else { + Err(Errno::EINVAL) + }; + } + if capacity as usize != fd_set_bytes { + return Err(Errno::EINVAL); + } + let start = ptr as usize; + let end = start.checked_add(fd_set_bytes).ok_or(Errno::EFAULT)?; + Ok(Some((start, end))) + }; + let read_range = match pointer_range(readfds_ptr, readfds_capacity) { + Ok(range) => range, + Err(error) => return -(error as i32), + }; + let write_range = match pointer_range(writefds_ptr, writefds_capacity) { + Ok(range) => range, + Err(error) => return -(error as i32), + }; + let except_range = match pointer_range(exceptfds_ptr, exceptfds_capacity) { + Ok(range) => range, + Err(error) => return -(error as i32), + }; + let ranges = [read_range, write_range, except_range]; + for left in 0..ranges.len() { + let Some((left_start, left_end)) = ranges[left] else { + continue; + }; + for right in left + 1..ranges.len() { + let Some((right_start, right_end)) = ranges[right] else { + continue; + }; + if left_start < right_end && left_end > right_start { + return -(Errno::EINVAL as i32); + } + } + } + let readfds = if readfds_ptr.is_null() { None } else { - Some(unsafe { core::slice::from_raw_parts_mut(readfds_ptr, 128) }) + Some(unsafe { core::slice::from_raw_parts_mut(readfds_ptr, fd_set_bytes) }) }; let writefds = if writefds_ptr.is_null() { None } else { - Some(unsafe { core::slice::from_raw_parts_mut(writefds_ptr, 128) }) + Some(unsafe { core::slice::from_raw_parts_mut(writefds_ptr, fd_set_bytes) }) }; let exceptfds = if exceptfds_ptr.is_null() { None } else { - Some(unsafe { core::slice::from_raw_parts_mut(exceptfds_ptr, 128) }) + Some(unsafe { core::slice::from_raw_parts_mut(exceptfds_ptr, fd_set_bytes) }) }; let mut host = WasmHostIO; @@ -9584,19 +10502,14 @@ pub extern "C" fn kernel_apply_fork_fd_actions() -> i32 { for action in actions { match action { crate::process::FdAction::Dup2 { old_fd, new_fd } => { - if let Err(e) = syscalls::sys_dup2_with_locks( - proc, - advisory_locks, - &mut host, - old_fd, - new_fd, - ) { + if let Err(e) = + syscalls::sys_dup2_with_locks(proc, advisory_locks, &mut host, old_fd, new_fd) + { return -(e as i32); } } crate::process::FdAction::Close { fd } => { - if let Err(e) = - syscalls::sys_close_with_locks(proc, advisory_locks, &mut host, fd) + if let Err(e) = syscalls::sys_close_with_locks(proc, advisory_locks, &mut host, fd) { return -(e as i32); } @@ -9669,27 +10582,39 @@ pub extern "C" fn kernel_alarm(seconds: u32) -> i32 { // --------------------------------------------------------------------------- /// setitimer -- set interval timer. -/// new_ptr points to an array of 4 longs (16 bytes on wasm32): -/// { interval_sec, interval_usec, value_sec, value_usec } -/// This matches musl's time64 path which packs values as long[4]. -/// old_ptr receives the previous values as 4 longs (may be null). +/// +/// `new_ptr` and `old_ptr` name the kernel-facing four-native-`long` timer +/// record. wasm32 musl translates its public time64 `itimerval` to this record; +/// wasm64 passes its 32-byte native record directly. /// Returns 0 on success, negative errno on error. #[unsafe(no_mangle)] -pub extern "C" fn kernel_setitimer(which: u32, new_ptr: *const u8, old_ptr: *mut u8) -> i32 { +pub extern "C" fn kernel_setitimer( + which: u32, + new_ptr: *const u8, + old_ptr: *mut u8, + process_pointer_width: i64, +) -> i32 { + use crate::process_wire::ProcessDataModel; + + let model = match ProcessDataModel::from_width(process_pointer_width) { + Ok(model) => model, + Err(error) => return -(error as i32), + }; + if new_ptr.is_null() { + return -(Errno::EFAULT as i32); + } let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; - // Parse new itimerval from memory (4 x i32 longs on wasm32) - let (interval_sec, interval_usec, value_sec, value_usec) = if new_ptr.is_null() { - (0i64, 0i64, 0i64, 0i64) - } else { - let new_bytes = unsafe { slice::from_raw_parts(new_ptr, 16) }; - let interval_sec = i32::from_le_bytes(new_bytes[0..4].try_into().unwrap()) as i64; - let interval_usec = i32::from_le_bytes(new_bytes[4..8].try_into().unwrap()) as i64; - let value_sec = i32::from_le_bytes(new_bytes[8..12].try_into().unwrap()) as i64; - let value_usec = i32::from_le_bytes(new_bytes[12..16].try_into().unwrap()) as i64; - (interval_sec, interval_usec, value_sec, value_usec) + // WHY: consume exactly the capacity described to the host. A fixed + // 16-byte parse would truncate wasm64 and a fixed 32-byte parse would + // overrun the wasm32 scratch record. + let bytes = unsafe { slice::from_raw_parts(new_ptr, model.itimerval_size()) }; + let new_values = match crate::process_wire::read_itimerval(bytes, model) { + Ok(values) => values, + Err(error) => return -(error as i32), }; + let [interval_sec, interval_usec, value_sec, value_usec] = new_values; let result = match syscalls::sys_setitimer( proc, @@ -9702,11 +10627,16 @@ pub extern "C" fn kernel_setitimer(which: u32, new_ptr: *const u8, old_ptr: *mut ) { Ok((old_isec, old_iusec, old_vsec, old_vusec)) => { if !old_ptr.is_null() { - let old_bytes = unsafe { slice::from_raw_parts_mut(old_ptr, 16) }; - old_bytes[0..4].copy_from_slice(&(old_isec as i32).to_le_bytes()); - old_bytes[4..8].copy_from_slice(&(old_iusec as i32).to_le_bytes()); - old_bytes[8..12].copy_from_slice(&(old_vsec as i32).to_le_bytes()); - old_bytes[12..16].copy_from_slice(&(old_vusec as i32).to_le_bytes()); + let mut encoded = alloc::vec![0; model.itimerval_size()]; + if let Err(error) = crate::process_wire::write_itimerval( + &mut encoded, + [old_isec, old_iusec, old_vsec, old_vusec], + model, + ) { + return -(error as i32); + } + let output = unsafe { slice::from_raw_parts_mut(old_ptr, model.itimerval_size()) }; + output.copy_from_slice(&encoded); } 0 } @@ -9717,22 +10647,41 @@ pub extern "C" fn kernel_setitimer(which: u32, new_ptr: *const u8, old_ptr: *mut } /// getitimer -- get current value of interval timer. -/// curr_ptr receives the current values as 4 longs (16 bytes on wasm32). +/// `curr_ptr` receives the caller's four-native-`long` kernel-facing record. /// Returns 0 on success, negative errno on error. #[unsafe(no_mangle)] -pub extern "C" fn kernel_getitimer(which: u32, curr_ptr: *mut u8) -> i32 { +pub extern "C" fn kernel_getitimer( + which: u32, + curr_ptr: *mut u8, + process_pointer_width: i64, +) -> i32 { + use crate::process_wire::ProcessDataModel; + + let model = match ProcessDataModel::from_width(process_pointer_width) { + Ok(model) => model, + Err(error) => return -(error as i32), + }; + if curr_ptr.is_null() { + return -(Errno::EFAULT as i32); + } let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; let result = match syscalls::sys_getitimer(proc, &mut host, which) { Ok((isec, iusec, vsec, vusec)) => { - if !curr_ptr.is_null() { - let buf = unsafe { slice::from_raw_parts_mut(curr_ptr, 16) }; - buf[0..4].copy_from_slice(&(isec as i32).to_le_bytes()); - buf[4..8].copy_from_slice(&(iusec as i32).to_le_bytes()); - buf[8..12].copy_from_slice(&(vsec as i32).to_le_bytes()); - buf[12..16].copy_from_slice(&(vusec as i32).to_le_bytes()); + let mut encoded = alloc::vec![0; model.itimerval_size()]; + match crate::process_wire::write_itimerval( + &mut encoded, + [isec, iusec, vsec, vusec], + model, + ) { + Ok(()) => { + let output = + unsafe { slice::from_raw_parts_mut(curr_ptr, model.itimerval_size()) }; + output.copy_from_slice(&encoded); + 0 + } + Err(error) => -(error as i32), } - 0 } Err(e) => -(e as i32), }; @@ -9768,7 +10717,10 @@ mod posix_timer_tests { #[test] fn boottime_timers_use_monotonic_host_clock() { - assert_eq!(timer_clock_to_host_clock(CLOCK_BOOTTIME), Some(CLOCK_MONOTONIC)); + assert_eq!( + timer_clock_to_host_clock(CLOCK_BOOTTIME), + Some(CLOCK_MONOTONIC) + ); } #[test] @@ -9781,7 +10733,7 @@ mod posix_timer_tests { crate::process::PosixTimerState { clock_id: CLOCK_MONOTONIC, sigev_signo: 10, - sigev_value: 77, + sigev_value_bits: 77, sigev_notify: SIGEV_THREAD_ID, sigev_tid: target_tid, interval_sec: 0, @@ -9817,21 +10769,22 @@ mod posix_timer_tests { proc.remove_thread(42); assert_eq!(queue_posix_timer_fire(&mut proc, 0), -(Errno::ESRCH as i32)); - assert!(!proc.posix_timers[0] - .as_ref() - .unwrap() - .notification_pending); + assert!(!proc.posix_timers[0].as_ref().unwrap().notification_pending); } } -/// timer_create(clock_id, sigevent_ptr, timerid_ptr) -/// musl sends ksigevent = {sigev_value(i32), sigev_signo(i32), sigev_notify(i32), sigev_tid(i32)} = 16 bytes. +/// timer_create(clock_id, sigevent_ptr, timerid_ptr, process_pointer_width) +/// +/// `sigevent_ptr` names the complete caller-native structure staged in bounded +/// kernel scratch. The explicit process data model keeps `union sigval` +/// pointer-width lossless on both wasm32 and wasm64. /// Returns 0 on success, negative errno. #[unsafe(no_mangle)] pub extern "C" fn kernel_timer_create( clock_id: u32, sevp_ptr: *const u8, timerid_ptr: *mut i32, + process_pointer_width: i64, ) -> i32 { use crate::process::PosixTimerState; @@ -9843,16 +10796,28 @@ pub extern "C" fn kernel_timer_create( }; // Parse sigevent (default: SIGEV_SIGNAL with SIGALRM) - let (sigev_signo, sigev_value, sigev_notify, sigev_tid) = if sevp_ptr.is_null() { - (14u32, 0i32, SIGEV_SIGNAL, 0u32) // default: SIGALRM - } else { - let buf = unsafe { slice::from_raw_parts(sevp_ptr, 16) }; - let value = i32::from_le_bytes(buf[0..4].try_into().unwrap()); - let signo = i32::from_le_bytes(buf[4..8].try_into().unwrap()) as u32; - let notify = i32::from_le_bytes(buf[8..12].try_into().unwrap()) as u32; - let tid = i32::from_le_bytes(buf[12..16].try_into().unwrap()) as u32; - (signo, value, notify, tid) - }; + let (sigev_signo, sigev_value_bits, sigev_notify, sigev_tid) = + if sevp_ptr.is_null() { + (14u32, 0u64, SIGEV_SIGNAL, 0u32) // default: SIGALRM + } else { + let model = match crate::process_wire::ProcessDataModel::from_width( + process_pointer_width, + ) { + Ok(model) => model, + Err(error) => return -(error as i32), + }; + let input = unsafe { slice::from_raw_parts(sevp_ptr, model.sigevent_size()) }; + let event = match crate::process_wire::read_sigevent(input, model) { + Ok(event) => event, + Err(error) => return -(error as i32), + }; + ( + event.signo, + event.value_bits, + event.notify, + event.thread_id, + ) + }; let sigev_signo = match normalize_posix_timer_signo(sigev_notify, sigev_signo) { Ok(signo) => signo, @@ -9884,7 +10849,7 @@ pub extern "C" fn kernel_timer_create( proc.posix_timers[timer_id] = Some(PosixTimerState { clock_id: host_clock_id, sigev_signo, - sigev_value, + sigev_value_bits, sigev_notify, sigev_tid, interval_sec: 0, @@ -9949,7 +10914,7 @@ fn queue_posix_timer_fire(proc: &mut Process, timer_id: u32) -> i32 { timer.sigev_notify, timer.sigev_tid, timer.sigev_signo, - timer.sigev_value, + timer.sigev_value_bits, ) }; @@ -10229,8 +11194,19 @@ pub extern "C" fn kernel_getsockname(fd: i32, buf_ptr: *mut u8, addrlen_ptr: *mu } else { 0 }; - let buf = unsafe { core::slice::from_raw_parts_mut(buf_ptr, addrlen as usize) }; - let result = match syscalls::sys_getsockname(proc, fd, buf) { + let result = if addrlen == 0 { + // WHY: callers may use a zero-capacity address buffer. Do not pass its + // semantically unused pointer to Rust's non-null raw-slice API. + syscalls::sys_getsockname(proc, fd, &mut []) + } else if buf_ptr.is_null() { + Err(Errno::EFAULT) + } else { + // SAFETY: the host staged exactly addrlen output bytes in + // capacity-checked kernel scratch. + let buf = unsafe { core::slice::from_raw_parts_mut(buf_ptr, addrlen as usize) }; + syscalls::sys_getsockname(proc, fd, buf) + }; + let result = match result { Ok(n) => { // Write actual addrlen back if !addrlen_ptr.is_null() { @@ -10256,8 +11232,19 @@ pub extern "C" fn kernel_getpeername(fd: i32, buf_ptr: *mut u8, addrlen_ptr: *mu } else { 0 }; - let buf = unsafe { core::slice::from_raw_parts_mut(buf_ptr, addrlen as usize) }; - let result = match syscalls::sys_getpeername(proc, fd, buf) { + let result = if addrlen == 0 { + // WHY: callers may use a zero-capacity address buffer. Do not pass its + // semantically unused pointer to Rust's non-null raw-slice API. + syscalls::sys_getpeername(proc, fd, &mut []) + } else if buf_ptr.is_null() { + Err(Errno::EFAULT) + } else { + // SAFETY: the host staged exactly addrlen output bytes in + // capacity-checked kernel scratch. + let buf = unsafe { core::slice::from_raw_parts_mut(buf_ptr, addrlen as usize) }; + syscalls::sys_getpeername(proc, fd, buf) + }; + let result = match result { Ok(n) => { if !addrlen_ptr.is_null() { unsafe { @@ -10280,10 +11267,14 @@ pub extern "C" fn kernel_getaddrinfo( name_len: u32, result_ptr: *mut u8, ) -> i32 { + const IPV4_RESULT_BYTES: usize = 4; let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; let name = unsafe { slice::from_raw_parts(name_ptr, name_len as usize) }; - let result_buf = unsafe { slice::from_raw_parts_mut(result_ptr, 16) }; + // WHY: the musl caller owns exactly four bytes for this IPv4-only private + // syscall. Treating the following scratch bytes as capacity would recreate + // the allocation-vs-total-memory bug even if today's host writes only IPv4. + let result_buf = unsafe { slice::from_raw_parts_mut(result_ptr, IPV4_RESULT_BYTES) }; match syscalls::sys_getaddrinfo(proc, &mut host, name, result_buf) { Ok(n) => n as i32, Err(e) => -(e as i32), @@ -10452,17 +11443,23 @@ pub extern "C" fn kernel_pselect6( let readfds = if readfds_ptr.is_null() { None } else { - Some(unsafe { core::slice::from_raw_parts_mut(readfds_ptr, 128) }) + Some(unsafe { + core::slice::from_raw_parts_mut(readfds_ptr, wasm_posix_shared::select::FD_SET_BYTES) + }) }; let writefds = if writefds_ptr.is_null() { None } else { - Some(unsafe { core::slice::from_raw_parts_mut(writefds_ptr, 128) }) + Some(unsafe { + core::slice::from_raw_parts_mut(writefds_ptr, wasm_posix_shared::select::FD_SET_BYTES) + }) }; let exceptfds = if exceptfds_ptr.is_null() { None } else { - Some(unsafe { core::slice::from_raw_parts_mut(exceptfds_ptr, 128) }) + Some(unsafe { + core::slice::from_raw_parts_mut(exceptfds_ptr, wasm_posix_shared::select::FD_SET_BYTES) + }) }; let mask = if has_mask != 0 { @@ -10652,12 +11649,20 @@ pub extern "C" fn kernel_pipe_read( buf_len: u32, ) -> i32 { let buf = unsafe { slice::from_raw_parts_mut(buf_ptr, buf_len as usize) }; - let pipe_table = unsafe { crate::pipe::global_pipe_table() }; - let pipe = match pipe_table.get_mut(pipe_idx as usize) { - Some(p) => p, - None => return -(Errno::EBADF as i32), + let read = { + let pipe_table = unsafe { crate::pipe::global_pipe_table() }; + let pipe = match pipe_table.get_mut(pipe_idx as usize) { + Some(p) => p, + None => return -(Errno::EBADF as i32), + }; + pipe.recv_plain(buf, false) }; - pipe.read(buf) as i32 + // WHY: this trusted host path normally addresses only host-injected TCP + // pipes, but using the message-aware primitive keeps a future caller from + // silently leaking SCM_RIGHTS if that ownership boundary broadens. The + // table borrow above must end before cleanup re-enters it. + finish_machine_scm_rights_cleanup_if_pending(); + read.bytes_read as i32 } /// Write data from kernel memory into a pipe buffer. @@ -10688,13 +11693,21 @@ pub extern "C" fn kernel_pipe_write( /// pipe table. #[unsafe(no_mangle)] pub extern "C" fn kernel_pipe_close_write(_pid: u32, pipe_idx: u32) -> i32 { - let pipe_table = unsafe { crate::pipe::global_pipe_table() }; - let pipe = match pipe_table.get_mut(pipe_idx as usize) { - Some(p) => p, - None => return -(Errno::EBADF as i32), - }; - pipe.close_write_end(); - pipe_table.free_if_closed(pipe_idx as usize); + { + let pipe_table = unsafe { crate::pipe::global_pipe_table() }; + let pipe = match pipe_table.get_mut(pipe_idx as usize) { + Some(p) => p, + None => return -(Errno::EBADF as i32), + }; + pipe.close_write_end(); + // free_if_closed also collects ancillary queues whose only remaining + // readers are themselves in flight. + pipe_table.free_if_closed(pipe_idx as usize); + } + // WHY: collection can recursively drop SCM_RIGHTS even though closing a + // write end does not directly consume a message. Re-enter resource tables + // only after the pipe-table borrow above has ended. + finish_machine_scm_rights_cleanup_if_pending(); 0 } @@ -10705,13 +11718,19 @@ pub extern "C" fn kernel_pipe_close_write(_pid: u32, pipe_idx: u32) -> i32 { /// pipe table. #[unsafe(no_mangle)] pub extern "C" fn kernel_pipe_close_read(_pid: u32, pipe_idx: u32) -> i32 { - let pipe_table = unsafe { crate::pipe::global_pipe_table() }; - let pipe = match pipe_table.get_mut(pipe_idx as usize) { - Some(p) => p, - None => return -(Errno::EBADF as i32), - }; - pipe.close_read_end(); - pipe_table.free_if_closed(pipe_idx as usize); + { + let pipe_table = unsafe { crate::pipe::global_pipe_table() }; + let pipe = match pipe_table.get_mut(pipe_idx as usize) { + Some(p) => p, + None => return -(Errno::EBADF as i32), + }; + pipe.close_read_end(); + pipe_table.free_if_closed(pipe_idx as usize); + } + // WHY: close_read_end drops any stream rights that can no longer be + // received. Defer host/backing cleanup until the pipe-table borrow has + // ended, while keeping the ordinary host-TCP path to one queue check. + finish_machine_scm_rights_cleanup_if_pending(); 0 } @@ -11033,12 +12052,7 @@ pub extern "C" fn kernel_pty_create(pid: u32) -> i32 { let mut host = WasmHostIO; for fd in 0..3i32 { if proc.fd_table.get(fd).is_ok() { - if let Err(err) = syscalls::sys_close_with_locks( - proc, - advisory_locks, - &mut host, - fd, - ) { + if let Err(err) = syscalls::sys_close_with_locks(proc, advisory_locks, &mut host, fd) { // The close operation may already have consumed the old fd, // just as close(2) may report a late I/O failure. Drop the // not-yet-installed PTY OFD and its resource references. diff --git a/crates/kernel/tests/wasm_api_channel_pointer_contract.rs b/crates/kernel/tests/wasm_api_channel_pointer_contract.rs new file mode 100644 index 0000000000..e89a6f578f --- /dev/null +++ b/crates/kernel/tests/wasm_api_channel_pointer_contract.rs @@ -0,0 +1,152 @@ +#[test] +fn dispatcher_does_not_cast_narrowed_scalar_aliases_as_pointers() { + let source = include_str!("../src/wasm_api.rs"); + let start = source + .find("fn dispatch_channel_syscall(") + .expect("dispatcher start"); + let end = source[start..] + .find("\n// ---------------------------------------------------------------------------\n// SysV IPC kernel exports") + .expect("dispatcher end"); + let dispatcher = &source[start..start + end]; + + for alias in ["a1", "a2", "a3", "a4", "a5", "a6"] { + for suffix in [" as *const", " as *mut", " as usize", " as u32 as usize"] { + let forbidden = format!("{alias}{suffix}"); + assert!( + !dispatcher.contains(&forbidden), + "channel pointer bypasses checked conversion via `{forbidden}`" + ); + } + } + + for index in 0..6 { + for suffix in [" as *const", " as *mut", " as usize"] { + let forbidden = format!("args[{index}]{suffix}"); + assert!( + !dispatcher.contains(&forbidden), + "raw channel pointer bypasses checked conversion via `{forbidden}`" + ); + } + let forbidden = format!("usize::try_from(args[{index}])"); + assert!( + !dispatcher.contains(&forbidden), + "signed pointer conversion bypasses bit-preserving helper via `{forbidden}`" + ); + } + + assert!( + dispatcher.contains("checked_channel_pointer(args[$index])"), + "dispatcher must retain the checked raw-pointer conversion gate" + ); +} + +#[test] +fn sendmsg_zero_length_null_iovec_never_constructs_a_raw_slice() { + let source = include_str!("../src/wasm_api.rs"); + let start = source + .find("pub extern \"C\" fn kernel_sendmsg(") + .expect("kernel_sendmsg start"); + let end = source[start..] + .find("\n/// recvmsg") + .expect("kernel_sendmsg end"); + let sendmsg = &source[start..start + end]; + + let empty_guard = sendmsg + .find("let buf = if len == 0 {\n &[]") + .expect("zero-length iovec must select a valid empty slice"); + let raw_slice = sendmsg + .find("slice::from_raw_parts(base as *const u8, len)") + .expect("positive-length iovec must retain the bounded slice"); + assert!( + empty_guard < raw_slice, + "the zero-length guard must precede raw-slice construction" + ); +} + +#[test] +fn mqueue_zero_length_message_never_constructs_a_null_raw_slice() { + let source = include_str!("../src/wasm_api.rs"); + let send_start = source + .find("// SYS_MQ_TIMEDSEND:") + .expect("mq_timedsend dispatcher start"); + let receive_start = source[send_start..] + .find("// SYS_MQ_TIMEDRECEIVE:") + .map(|offset| send_start + offset) + .expect("mq_timedreceive dispatcher start"); + let receive_end = source[receive_start..] + .find("// SYS_MQ_NOTIFY:") + .map(|offset| receive_start + offset) + .expect("mq_timedreceive dispatcher end"); + let send = &source[send_start..receive_start]; + let receive = &source[receive_start..receive_end]; + + let send_empty_guard = send + .find("let data = if data_len == 0 {\n &[]") + .expect("zero-length message must select a valid empty slice"); + let send_raw_slice = send + .find("core::slice::from_raw_parts(channel_const_ptr!(1, u8), data_len)") + .expect("positive-length message must retain the bounded slice"); + assert!( + send_empty_guard < send_raw_slice, + "the zero-length send guard must precede raw-slice construction" + ); + + let receive_empty_guard = receive + .find("if !result.data.is_empty() {") + .expect("empty received message must skip destination construction"); + let receive_raw_slice = receive + .find("core::slice::from_raw_parts_mut(") + .expect("non-empty received message must retain the bounded slice"); + assert!( + receive_empty_guard < receive_raw_slice, + "the empty receive guard must precede raw-slice construction" + ); +} + +#[test] +fn nullable_zero_length_dispatch_paths_never_construct_null_raw_slices() { + let source = include_str!("../src/wasm_api.rs"); + + let utimensat_start = source + .find("pub extern \"C\" fn kernel_utimensat(") + .expect("kernel_utimensat start"); + let utimensat_end = source[utimensat_start..] + .find("\n/// Remap memory.") + .map(|offset| utimensat_start + offset) + .expect("kernel_utimensat end"); + let utimensat = &source[utimensat_start..utimensat_end]; + let path_guard = utimensat + .find("let path = if path_len == 0 {") + .expect("zero-length utimensat path guard"); + let empty_slice = utimensat + .find("&[]") + .expect("zero-length utimensat path must select a valid empty slice"); + let path_raw_slice = utimensat + .find("slice::from_raw_parts(path_ptr, path_len as usize)") + .expect("positive-length utimensat path must retain the bounded slice"); + assert!(path_guard < empty_slice && empty_slice < path_raw_slice); + + for (function, next_marker) in [ + ("kernel_getsockname(", "\n/// getpeername"), + ("kernel_getpeername(", "\n/// Resolve a hostname"), + ] { + let start = source + .find(&format!("pub extern \"C\" fn {function}")) + .unwrap_or_else(|| panic!("{function} start")); + let end = source[start..] + .find(next_marker) + .map(|offset| start + offset) + .unwrap_or_else(|| panic!("{function} end")); + let body = &source[start..end]; + let empty_guard = body + .find("let result = if addrlen == 0 {") + .unwrap_or_else(|| panic!("{function} zero-length guard")); + let null_guard = body + .find("} else if buf_ptr.is_null() {") + .unwrap_or_else(|| panic!("{function} null positive-length guard")); + let raw_slice = body + .find("core::slice::from_raw_parts_mut(buf_ptr, addrlen as usize)") + .unwrap_or_else(|| panic!("{function} positive-length slice")); + assert!(empty_guard < null_guard && null_guard < raw_slice); + } +} diff --git a/crates/shared/src/host_abi.rs b/crates/shared/src/host_abi.rs index d96092b04a..c8256dcaf7 100644 --- a/crates/shared/src/host_abi.rs +++ b/crates/shared/src/host_abi.rs @@ -8,10 +8,17 @@ use core::mem::size_of; use crate::abi::extended_syscalls as extra_syscalls; +use crate::process_layout; use crate::{ - SCHED_AFFINITY_MASK_SIZE, Syscall, WASM_RUSAGE_WIRE_SIZE, WasmStat, WasmStatfs, WasmTimespec, + SCHED_AFFINITY_MASK_SIZE, Syscall, WASM_RUSAGE_WIRE_SIZE, WasmTimespec, kernel_scratch_wire, }; +/// Private channel argument used to carry the calling process's pointer width. +/// +/// WHY: one kernel Wasm instance may serve wasm32 and wasm64 processes, so its +/// own compilation target cannot select a caller-native structure layout. +pub const PROCESS_POINTER_WIDTH_ARG_INDEX: u8 = 5; + /// Direction of a marshalled pointer argument. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SyscallArgDirection { @@ -36,6 +43,10 @@ pub enum SyscallArgSize { Deref { arg_index: u8 }, /// Fixed byte length. Fixed { size: u32 }, + /// Fixed native structure size selected from the calling process's data + /// model. Encountering this form also makes the host write the process + /// pointer width to [`PROCESS_POINTER_WIDTH_ARG_INDEX`]. + ProcessLayout { wasm32_size: u32, wasm64_size: u32 }, } /// One pointer argument descriptor for host-side marshalling. @@ -45,13 +56,13 @@ pub struct SyscallArgDesc { pub direction: SyscallArgDirection, pub size: SyscallArgSize, /// Whether a null pointer is a valid request to omit this argument. + /// + /// Exactly one of `nullable` and `required` must be true. A zero-length + /// [`SyscallArgSize::Arg`] still lends no caller bytes and is canonicalized + /// to an empty kernel-owned region regardless of its raw pointer bits. pub nullable: bool, - /// Whether a non-C-string pointer must be non-null. + /// Whether a positive-sized pointer must be non-null. pub required: bool, - /// Extra bytes to copy back when an output `Arg`-sized buffer's copied - /// length is based on the syscall return value. `msgrcv` returns only - /// `mtext` length, but the scratch buffer also includes the leading mtype. - pub copy_retval_add: u32, } /// All pointer argument descriptors for one syscall number. @@ -105,17 +116,16 @@ macro_rules! fixed { }; } -macro_rules! desc { - ($arg_index:expr, $direction:ident, $size:expr) => { - SyscallArgDesc { - arg_index: $arg_index, - direction: SyscallArgDirection::$direction, - size: $size, - nullable: false, - required: false, - copy_retval_add: 0, +macro_rules! process_layout { + ($wasm32_size:expr, $wasm64_size:expr) => { + SyscallArgSize::ProcessLayout { + wasm32_size: $wasm32_size, + wasm64_size: $wasm64_size, } }; +} + +macro_rules! desc { ($arg_index:expr, $direction:ident, $size:expr, nullable) => { SyscallArgDesc { arg_index: $arg_index, @@ -123,7 +133,6 @@ macro_rules! desc { size: $size, nullable: true, required: false, - copy_retval_add: 0, } }; ($arg_index:expr, $direction:ident, $size:expr, required) => { @@ -133,17 +142,6 @@ macro_rules! desc { size: $size, nullable: false, required: true, - copy_retval_add: 0, - } - }; - ($arg_index:expr, $direction:ident, $size:expr, copy_retval_add $copy_retval_add:expr) => { - SyscallArgDesc { - arg_index: $arg_index, - direction: SyscallArgDirection::$direction, - size: $size, - nullable: false, - required: false, - copy_retval_add: $copy_retval_add, } }; } @@ -157,166 +155,276 @@ macro_rules! entry { }; } -const WASM_STAT_SIZE: u32 = size_of::() as u32; const WASM_TIMESPEC_SIZE: u32 = size_of::() as u32; -const WASM_STATFS_SIZE: u32 = size_of::() as u32; -const ITIMERVAL_SIZE: u32 = 16; const RLIMIT_SIZE: u32 = 16; -const STACK_T_SIZE: u32 = 12; /// Host-side syscall pointer argument descriptors. /// /// The values are sorted by syscall number for deterministic codegen and /// snapshot output. pub const SYSCALL_ARG_DESCRIPTORS: &[SyscallArgDescriptor] = &[ - entry!(Syscall::Open as u32, [desc!(0, In, cstring!())]), - entry!(Syscall::Read as u32, [desc!(1, Out, arg!(2))]), - entry!(Syscall::Write as u32, [desc!(1, In, arg!(2))]), + entry!(Syscall::Open as u32, [desc!(0, In, cstring!(), required)]), + entry!(Syscall::Read as u32, [desc!(1, Out, arg!(2), required)]), + entry!(Syscall::Write as u32, [desc!(1, In, arg!(2), required)]), entry!( Syscall::Fstat as u32, - [desc!(1, Out, fixed!(WASM_STAT_SIZE))] + [desc!(1, Out, fixed!(process_layout::stat::SIZE), required)] ), - entry!(Syscall::Pipe as u32, [desc!(0, Out, fixed!(8))]), + entry!(Syscall::Pipe as u32, [desc!(0, Out, fixed!(8), required)]), entry!( Syscall::Stat as u32, [ - desc!(0, In, cstring!()), - desc!(1, Out, fixed!(WASM_STAT_SIZE)), + desc!(0, In, cstring!(), required), + desc!(1, Out, fixed!(process_layout::stat::SIZE), required), ] ), entry!( Syscall::Lstat as u32, [ - desc!(0, In, cstring!()), - desc!(1, Out, fixed!(WASM_STAT_SIZE)), + desc!(0, In, cstring!(), required), + desc!(1, Out, fixed!(process_layout::stat::SIZE), required), ] ), - entry!(Syscall::Mkdir as u32, [desc!(0, In, cstring!())]), - entry!(Syscall::Rmdir as u32, [desc!(0, In, cstring!())]), - entry!(Syscall::Unlink as u32, [desc!(0, In, cstring!())]), + entry!(Syscall::Mkdir as u32, [desc!(0, In, cstring!(), required)]), + entry!(Syscall::Rmdir as u32, [desc!(0, In, cstring!(), required)]), + entry!(Syscall::Unlink as u32, [desc!(0, In, cstring!(), required)]), entry!( Syscall::Rename as u32, - [desc!(0, In, cstring!()), desc!(1, In, cstring!()),] + [ + desc!(0, In, cstring!(), required), + desc!(1, In, cstring!(), required), + ] ), entry!( Syscall::Link as u32, - [desc!(0, In, cstring!()), desc!(1, In, cstring!()),] + [ + desc!(0, In, cstring!(), required), + desc!(1, In, cstring!(), required), + ] ), entry!( Syscall::Symlink as u32, - [desc!(0, In, cstring!()), desc!(1, In, cstring!()),] + [ + desc!(0, In, cstring!(), required), + desc!(1, In, cstring!(), required), + ] ), entry!( Syscall::Readlink as u32, - [desc!(0, In, cstring!()), desc!(1, Out, arg!(2)),] + [ + desc!(0, In, cstring!(), required), + desc!(1, Out, arg!(2), required), + ] + ), + entry!(Syscall::Chmod as u32, [desc!(0, In, cstring!(), required)]), + entry!(Syscall::Chown as u32, [desc!(0, In, cstring!(), required)]), + entry!(Syscall::Access as u32, [desc!(0, In, cstring!(), required)]), + entry!(Syscall::Getcwd as u32, [desc!(0, Out, arg!(1), required)]), + entry!(Syscall::Chdir as u32, [desc!(0, In, cstring!(), required)]), + entry!( + Syscall::Opendir as u32, + [desc!(0, In, cstring!(), required)] ), - entry!(Syscall::Chmod as u32, [desc!(0, In, cstring!())]), - entry!(Syscall::Chown as u32, [desc!(0, In, cstring!())]), - entry!(Syscall::Access as u32, [desc!(0, In, cstring!())]), - entry!(Syscall::Getcwd as u32, [desc!(0, Out, arg!(1))]), - entry!(Syscall::Chdir as u32, [desc!(0, In, cstring!())]), - entry!(Syscall::Opendir as u32, [desc!(0, In, cstring!())]), entry!( Syscall::Readdir as u32, - [desc!(1, Out, fixed!(16)), desc!(2, Out, arg!(3)),] + [ + desc!(1, Out, fixed!(16), required), + desc!(2, Out, arg!(3), required), + ] ), entry!( Syscall::Sigaction as u32, - [desc!(1, In, fixed!(16)), desc!(2, Out, fixed!(16)),] + [ + desc!(1, In, fixed!(16), nullable), + desc!(2, Out, fixed!(16), nullable), + ] ), entry!( Syscall::Sigprocmask as u32, - [desc!(1, In, fixed!(8)), desc!(2, Out, fixed!(8)),] + [ + desc!( + 1, + In, + fixed!(kernel_scratch_wire::SIGNAL_MASK_BYTES), + nullable + ), + desc!( + 2, + Out, + fixed!(kernel_scratch_wire::SIGNAL_MASK_BYTES), + nullable + ), + ] ), entry!( Syscall::ClockGettime as u32, - [desc!(1, Out, fixed!(WASM_TIMESPEC_SIZE))] + [desc!(1, Out, fixed!(WASM_TIMESPEC_SIZE), required)] ), entry!( Syscall::Nanosleep as u32, - [desc!(0, In, fixed!(WASM_TIMESPEC_SIZE))] + [desc!(0, In, fixed!(WASM_TIMESPEC_SIZE), required)] ), entry!( Syscall::GetEnv as u32, - [desc!(0, In, cstring!()), desc!(1, Out, arg!(2)),] + [ + desc!(0, In, cstring!(), required), + desc!(1, Out, arg!(2), required), + ] ), entry!( Syscall::SetEnv as u32, - [desc!(0, In, cstring!()), desc!(1, In, cstring!()),] + [ + desc!(0, In, cstring!(), required), + desc!(1, In, cstring!(), required), + ] + ), + entry!( + Syscall::UnsetEnv as u32, + [desc!(0, In, cstring!(), required)] ), - entry!(Syscall::UnsetEnv as u32, [desc!(0, In, cstring!())]), - entry!(Syscall::Bind as u32, [desc!(1, In, arg!(2))]), + entry!(Syscall::Bind as u32, [desc!(1, In, arg!(2), required)]), entry!( Syscall::Accept as u32, - [desc!(1, Out, deref!(2)), desc!(2, InOut, fixed!(4)),] + [ + // Linux permits omitting the peer address only as a nullable + // address/length pair. A non-null address still requires the + // length pointer because it defines the staged output capacity. + desc!(1, Out, deref!(2), nullable), + desc!(2, InOut, fixed!(4), nullable), + ] ), - entry!(Syscall::Connect as u32, [desc!(1, In, arg!(2))]), - entry!(Syscall::Send as u32, [desc!(1, In, arg!(2))]), - entry!(Syscall::Recv as u32, [desc!(1, Out, arg!(2))]), + entry!(Syscall::Connect as u32, [desc!(1, In, arg!(2), required)]), + entry!(Syscall::Send as u32, [desc!(1, In, arg!(2), required)]), + entry!(Syscall::Recv as u32, [desc!(1, Out, arg!(2), required)]), entry!( Syscall::Getsockopt as u32, - [desc!(3, Out, deref!(4)), desc!(4, InOut, fixed!(4)),] + [ + desc!(3, Out, deref!(4), required), + desc!(4, InOut, fixed!(4), required), + ] + ), + entry!( + Syscall::Setsockopt as u32, + [desc!(3, In, arg!(4), required)] + ), + entry!( + Syscall::Poll as u32, + [desc!(0, InOut, arg!(1, mul 8), required)] + ), + entry!( + Syscall::Socketpair as u32, + [desc!(3, Out, fixed!(8), required)] ), - entry!(Syscall::Setsockopt as u32, [desc!(3, In, arg!(4))]), - entry!(Syscall::Poll as u32, [desc!(0, InOut, arg!(1, mul 8))]), - entry!(Syscall::Socketpair as u32, [desc!(3, Out, fixed!(8))]), entry!( Syscall::Sendto as u32, - [desc!(1, In, arg!(2)), desc!(4, In, arg!(5)),] + [ + desc!(1, In, arg!(2), required), + desc!(4, In, arg!(5), required), + ] ), entry!( Syscall::Recvfrom as u32, [ - desc!(1, Out, arg!(2)), - desc!(4, Out, deref!(5)), - desc!(5, InOut, fixed!(4)), + desc!(1, Out, arg!(2), required), + // As with accept(2), the source address and its length are an + // optional pair, while a supplied address requires its length. + desc!(4, Out, deref!(5), nullable), + desc!(5, InOut, fixed!(4), nullable), ] ), - entry!(Syscall::Pread as u32, [desc!(1, Out, arg!(2))]), - entry!(Syscall::Pwrite as u32, [desc!(1, In, arg!(2))]), - entry!(Syscall::Openat as u32, [desc!(1, In, cstring!())]), - entry!(Syscall::Tcgetattr as u32, [desc!(1, Out, fixed!(256))]), - entry!(Syscall::Tcsetattr as u32, [desc!(2, In, fixed!(256))]), - entry!(Syscall::Ioctl as u32, [desc!(2, InOut, fixed!(256))]), - entry!(Syscall::Uname as u32, [desc!(0, Out, fixed!(390))]), - entry!(Syscall::Pipe2 as u32, [desc!(0, Out, fixed!(8))]), + entry!(Syscall::Pread as u32, [desc!(1, Out, arg!(2), required)]), + entry!(Syscall::Pwrite as u32, [desc!(1, In, arg!(2), required)]), + entry!(Syscall::Openat as u32, [desc!(1, In, cstring!(), required)]), + entry!( + Syscall::Tcgetattr as u32, + [desc!( + 1, + Out, + fixed!(crate::ioctl_contract::TERMIOS_SIZE), + required + )] + ), + entry!( + Syscall::Tcsetattr as u32, + [desc!( + 2, + In, + fixed!(crate::ioctl_contract::TERMIOS_SIZE), + required + )] + ), + entry!( + Syscall::Uname as u32, + [desc!(0, Out, fixed!(390), required)] + ), + entry!(Syscall::Pipe2 as u32, [desc!(0, Out, fixed!(8), required)]), entry!( Syscall::Getrlimit as u32, - [desc!(1, Out, fixed!(RLIMIT_SIZE))] + [desc!(1, Out, fixed!(RLIMIT_SIZE), required)] ), entry!( Syscall::Setrlimit as u32, - [desc!(1, In, fixed!(RLIMIT_SIZE))] + [desc!(1, In, fixed!(RLIMIT_SIZE), required)] + ), + entry!( + Syscall::Truncate as u32, + [desc!(0, In, cstring!(), required)] ), - entry!(Syscall::Truncate as u32, [desc!(0, In, cstring!())]), entry!( Syscall::Fstatat as u32, [ - desc!(1, In, cstring!()), - desc!(2, Out, fixed!(WASM_STAT_SIZE)), + desc!(1, In, cstring!(), required), + desc!(2, Out, fixed!(process_layout::stat::SIZE), required), ] ), - entry!(Syscall::Unlinkat as u32, [desc!(1, In, cstring!())]), - entry!(Syscall::Mkdirat as u32, [desc!(1, In, cstring!())]), + entry!( + Syscall::Unlinkat as u32, + [desc!(1, In, cstring!(), required)] + ), + entry!( + Syscall::Mkdirat as u32, + [desc!(1, In, cstring!(), required)] + ), entry!( Syscall::Renameat as u32, - [desc!(1, In, cstring!()), desc!(3, In, cstring!()),] + [ + desc!(1, In, cstring!(), required), + desc!(3, In, cstring!(), required), + ] + ), + entry!( + Syscall::Faccessat as u32, + [desc!(1, In, cstring!(), required)] + ), + entry!( + Syscall::Fchmodat as u32, + [desc!(1, In, cstring!(), required)] + ), + entry!( + Syscall::Fchownat as u32, + [desc!(1, In, cstring!(), required)] ), - entry!(Syscall::Faccessat as u32, [desc!(1, In, cstring!())]), - entry!(Syscall::Fchmodat as u32, [desc!(1, In, cstring!())]), - entry!(Syscall::Fchownat as u32, [desc!(1, In, cstring!())]), entry!( Syscall::Linkat as u32, - [desc!(1, In, cstring!()), desc!(3, In, cstring!()),] + [ + desc!(1, In, cstring!(), required), + desc!(3, In, cstring!(), required), + ] ), entry!( Syscall::Symlinkat as u32, - [desc!(0, In, cstring!()), desc!(2, In, cstring!()),] + [ + desc!(0, In, cstring!(), required), + desc!(2, In, cstring!(), required), + ] ), entry!( Syscall::Readlinkat as u32, - [desc!(1, In, cstring!()), desc!(2, Out, arg!(3)),] + [ + desc!(1, In, cstring!(), required), + desc!(2, Out, arg!(3), required), + ] ), entry!( Syscall::Getrusage as u32, @@ -324,12 +432,26 @@ pub const SYSCALL_ARG_DESCRIPTORS: &[SyscallArgDescriptor] = &[ ), entry!( Syscall::Realpath as u32, - [desc!(0, In, cstring!()), desc!(1, Out, arg!(2)),] + [ + desc!(0, In, cstring!(), required), + desc!(1, Out, arg!(2), required), + ] + ), + entry!( + Syscall::Sigsuspend as u32, + [desc!( + 0, + In, + fixed!(kernel_scratch_wire::SIGNAL_MASK_BYTES), + required + )] ), - entry!(Syscall::Sigsuspend as u32, [desc!(0, In, fixed!(8))]), entry!( Syscall::Pathconf as u32, - [desc!(0, In, cstring!()), desc!(2, Out, fixed!(8), required),] + [ + desc!(0, In, cstring!(), required), + desc!(2, Out, fixed!(8), required), + ] ), entry!( Syscall::Fpathconf as u32, @@ -337,195 +459,501 @@ pub const SYSCALL_ARG_DESCRIPTORS: &[SyscallArgDescriptor] = &[ ), entry!( Syscall::Getsockname as u32, - [desc!(1, Out, deref!(2)), desc!(2, InOut, fixed!(4)),] + [ + desc!(1, Out, deref!(2), required), + desc!(2, InOut, fixed!(4), required), + ] ), entry!( Syscall::Getpeername as u32, - [desc!(1, Out, deref!(2)), desc!(2, InOut, fixed!(4)),] + [ + desc!(1, Out, deref!(2), required), + desc!(2, InOut, fixed!(4), required), + ] + ), + entry!( + extra_syscalls::SYS_LLSEEK, + [desc!(3, Out, fixed!(8), required)] + ), + entry!( + extra_syscalls::SYS_GETRANDOM, + [desc!(0, Out, arg!(1), required)] + ), + entry!( + Syscall::Getdents64 as u32, + [desc!(1, Out, arg!(2), required)] ), - entry!(extra_syscalls::SYS_LLSEEK, [desc!(3, Out, fixed!(8))]), - entry!(extra_syscalls::SYS_GETRANDOM, [desc!(0, Out, arg!(1))]), - entry!(Syscall::Getdents64 as u32, [desc!(1, Out, arg!(2))]), entry!( Syscall::ClockGetres as u32, - [desc!(1, Out, fixed!(WASM_TIMESPEC_SIZE))] + // POSIX and Linux permit querying clock validity without storing its + // resolution. + [desc!(1, Out, fixed!(WASM_TIMESPEC_SIZE), nullable)] ), entry!( Syscall::ClockNanosleep as u32, - [desc!(2, In, fixed!(WASM_TIMESPEC_SIZE))] + [desc!(2, In, fixed!(WASM_TIMESPEC_SIZE), required)] ), entry!( Syscall::Utimensat as u32, [ desc!(1, In, cstring!(), nullable), - desc!(2, In, fixed!(WASM_TIMESPEC_SIZE * 2)), + // A null times pointer requests setting both timestamps to now. + desc!(2, In, fixed!(WASM_TIMESPEC_SIZE * 2), nullable), ] ), entry!( Syscall::Statfs as u32, [ - desc!(0, In, cstring!()), - desc!(2, Out, fixed!(WASM_STATFS_SIZE)), + desc!(0, In, cstring!(), required), + desc!( + 2, + Out, + process_layout!( + process_layout::statfs::WASM32_SIZE, + process_layout::statfs::WASM64_SIZE + ), + required + ), ] ), entry!( Syscall::Fstatfs as u32, - [desc!(2, Out, fixed!(WASM_STATFS_SIZE))] + [desc!( + 2, + Out, + process_layout!( + process_layout::statfs::WASM32_SIZE, + process_layout::statfs::WASM64_SIZE + ), + required + )] ), entry!( Syscall::Getresuid as u32, [ - desc!(0, Out, fixed!(4)), - desc!(1, Out, fixed!(4)), - desc!(2, Out, fixed!(4)), + // Linux writes each result with put_user; unlike getcpu(2), none + // of these three destinations is optional. + desc!(0, Out, fixed!(4), required), + desc!(1, Out, fixed!(4), required), + desc!(2, Out, fixed!(4), required), ] ), entry!( Syscall::Getresgid as u32, [ - desc!(0, Out, fixed!(4)), - desc!(1, Out, fixed!(4)), - desc!(2, Out, fixed!(4)), + desc!(0, Out, fixed!(4), required), + desc!(1, Out, fixed!(4), required), + desc!(2, Out, fixed!(4), required), ] ), - entry!(Syscall::Sendmsg as u32, [desc!(1, In, arg!(2))]), - entry!(Syscall::Recvmsg as u32, [desc!(1, InOut, arg!(2))]), + entry!( + Syscall::Setgroups as u32, + [desc!(1, In, arg!(0, mul 4), required)] + ), entry!( Syscall::Wait4 as u32, [ - desc!(1, Out, fixed!(4)), - desc!(3, Out, fixed!(WASM_RUSAGE_WIRE_SIZE)), + desc!(1, Out, fixed!(4), nullable), + desc!(3, Out, fixed!(WASM_RUSAGE_WIRE_SIZE), nullable), ] ), entry!( Syscall::Getaddrinfo as u32, - [desc!(0, In, cstring!()), desc!(1, Out, fixed!(256)),] + [ + desc!(0, In, cstring!(), required), + // WHY: musl's lookup_name.c supplies exactly one four-byte IPv4 + // result. A larger copy-out contract overwrites caller-owned bytes + // even though the kernel produces only this meaningful address. + desc!(1, Out, fixed!(4), required), + ] ), entry!( extra_syscalls::SYS_RT_SIGQUEUEINFO, - [desc!(2, In, fixed!(128))] + [desc!( + 2, + In, + process_layout!( + process_layout::rt_sigqueueinfo::WASM32_SIZE, + process_layout::rt_sigqueueinfo::WASM64_SIZE + ), + required + )] ), entry!( extra_syscalls::SYS_RT_SIGPENDING, - [desc!(0, Out, fixed!(8))] + [desc!( + 0, + Out, + fixed!(kernel_scratch_wire::SIGNAL_MASK_BYTES), + required + )] ), entry!( extra_syscalls::SYS_RT_SIGTIMEDWAIT, [ - desc!(0, In, fixed!(8)), - desc!(1, Out, fixed!(128)), - desc!(2, In, fixed!(WASM_TIMESPEC_SIZE)), + desc!( + 0, + In, + fixed!(kernel_scratch_wire::SIGNAL_MASK_BYTES), + required + ), + desc!( + 1, + Out, + process_layout!( + process_layout::rt_sigqueueinfo::WASM32_SIZE, + process_layout::rt_sigqueueinfo::WASM64_SIZE + ), + nullable + ), + desc!(2, In, fixed!(WASM_TIMESPEC_SIZE), nullable), ] ), entry!( extra_syscalls::SYS_SIGALTSTACK, [ - desc!(0, In, fixed!(STACK_T_SIZE)), - desc!(1, Out, fixed!(STACK_T_SIZE)), + desc!( + 0, + In, + process_layout!( + process_layout::sigaltstack::WASM32_SIZE, + process_layout::sigaltstack::WASM64_SIZE + ), + nullable + ), + desc!( + 1, + Out, + process_layout!( + process_layout::sigaltstack::WASM32_SIZE, + process_layout::sigaltstack::WASM64_SIZE + ), + nullable + ), ] ), entry!( crate::abi::host_intercepted::SYS_EXECVE, - [desc!(0, In, cstring!())] + [desc!(0, In, cstring!(), required)] ), - entry!(extra_syscalls::SYS_PRCTL, [desc!(1, InOut, fixed!(16))]), entry!( extra_syscalls::SYS_GETITIMER, - [desc!(1, Out, fixed!(ITIMERVAL_SIZE))] + [desc!( + 1, + Out, + process_layout!( + process_layout::itimerval::WASM32_SIZE, + process_layout::itimerval::WASM64_SIZE + ), + required + )] ), entry!( extra_syscalls::SYS_SETITIMER, [ - desc!(1, In, fixed!(ITIMERVAL_SIZE)), - desc!(2, Out, fixed!(ITIMERVAL_SIZE)), + desc!( + 1, + In, + process_layout!( + process_layout::itimerval::WASM32_SIZE, + process_layout::itimerval::WASM64_SIZE + ), + required + ), + desc!( + 2, + Out, + process_layout!( + process_layout::itimerval::WASM32_SIZE, + process_layout::itimerval::WASM64_SIZE + ), + nullable + ), ] ), entry!( extra_syscalls::SYS_SCHED_GETPARAM, - [desc!(1, Out, fixed!(36))] + [desc!( + 1, + Out, + fixed!(process_layout::sched_param::SIZE), + required + )] + ), + entry!( + extra_syscalls::SYS_SCHED_SETPARAM, + [desc!( + 1, + In, + fixed!(process_layout::sched_param::SIZE), + required + )] + ), + entry!( + extra_syscalls::SYS_SCHED_SETSCHEDULER, + [desc!( + 2, + In, + fixed!(process_layout::sched_param::SIZE), + required + )] ), entry!( extra_syscalls::SYS_SCHED_RR_GET_INTERVAL, - [desc!(1, Out, fixed!(WASM_TIMESPEC_SIZE))] + [desc!(1, Out, fixed!(WASM_TIMESPEC_SIZE), required)] ), entry!( extra_syscalls::SYS_SCHED_GETAFFINITY, [desc!(2, Out, fixed!(SCHED_AFFINITY_MASK_SIZE), required)] ), + entry!( + extra_syscalls::SYS_TIMERFD_SETTIME, + [ + desc!(2, In, fixed!(32), required), + desc!(3, Out, fixed!(32), nullable), + ] + ), + entry!( + extra_syscalls::SYS_TIMERFD_GETTIME, + [desc!(1, Out, fixed!(32), required)] + ), + entry!( + extra_syscalls::SYS_SIGNALFD4, + [desc!( + 1, + In, + fixed!(kernel_scratch_wire::SIGNAL_MASK_BYTES), + required + )] + ), entry!( extra_syscalls::SYS_PRLIMIT64, - [desc!(2, In, fixed!(16)), desc!(3, Out, fixed!(16)),] + [ + // prlimit64 is a query, a mutation, or both; each record is + // independently optional. + desc!(2, In, fixed!(16), nullable), + desc!(3, Out, fixed!(16), nullable), + ] + ), + entry!( + extra_syscalls::SYS_PPOLL, + [desc!(0, InOut, arg!(1, mul 8), required)] + ), + entry!( + extra_syscalls::SYS_MEMFD_CREATE, + [desc!(0, In, cstring!(), required)] ), - entry!(extra_syscalls::SYS_PPOLL, [desc!(0, InOut, arg!(1, mul 8))]), entry!( extra_syscalls::SYS_STATX, - [desc!(1, In, cstring!()), desc!(4, Out, fixed!(256)),] + [ + desc!(1, In, cstring!(), required), + desc!(4, Out, fixed!(256), required), + ] + ), + entry!( + extra_syscalls::SYS_SYSINFO, + [desc!( + 0, + Out, + process_layout!( + process_layout::sysinfo::WASM32_SIZE, + process_layout::sysinfo::WASM64_SIZE + ), + required + )] + ), + entry!( + extra_syscalls::SYS_MKNOD, + [desc!(0, In, cstring!(), required)] + ), + entry!( + extra_syscalls::SYS_MKNODAT, + [desc!(1, In, cstring!(), required)] ), - entry!(extra_syscalls::SYS_MKNOD, [desc!(0, In, cstring!())]), - entry!(extra_syscalls::SYS_MKNODAT, [desc!(1, In, cstring!())]), entry!( extra_syscalls::SYS_WAITID, [ - desc!(2, Out, fixed!(128), required), + desc!( + 2, + Out, + process_layout!( + process_layout::rt_sigqueueinfo::WASM32_SIZE, + process_layout::rt_sigqueueinfo::WASM64_SIZE + ), + required + ), desc!(4, Out, fixed!(WASM_RUSAGE_WIRE_SIZE), nullable), ] ), - entry!(extra_syscalls::SYS_LCHOWN, [desc!(0, In, cstring!())]), + entry!( + extra_syscalls::SYS_COPY_FILE_RANGE, + [ + desc!(1, InOut, fixed!(8), nullable), + desc!(3, InOut, fixed!(8), nullable), + ] + ), + entry!( + extra_syscalls::SYS_SPLICE, + [ + desc!(1, InOut, fixed!(8), nullable), + desc!(3, InOut, fixed!(8), nullable), + ] + ), + entry!( + extra_syscalls::SYS_SENDFILE, + [desc!(2, InOut, fixed!(8), nullable)] + ), + entry!( + extra_syscalls::SYS_LCHOWN, + [desc!(0, In, cstring!(), required)] + ), + entry!( + extra_syscalls::SYS_RENAMEAT2, + [ + desc!(1, In, cstring!(), required), + desc!(3, In, cstring!(), required), + ] + ), + entry!( + extra_syscalls::SYS_GETCPU, + [ + desc!(0, Out, fixed!(4), nullable), + desc!(1, Out, fixed!(4), nullable), + ] + ), entry!( extra_syscalls::SYS_TIMER_CREATE, - [desc!(1, In, fixed!(16)), desc!(2, Out, fixed!(4)),] + [ + // A null sigevent requests the standard SIGALRM notification. + desc!( + 1, + In, + process_layout!( + process_layout::sigevent::WASM32_SIZE, + process_layout::sigevent::WASM64_SIZE + ), + nullable + ), + desc!(2, Out, fixed!(4), required), + ] ), entry!( extra_syscalls::SYS_TIMER_SETTIME, - [desc!(2, In, fixed!(32)), desc!(3, Out, fixed!(32)),] + [ + desc!(2, In, fixed!(32), required), + desc!(3, Out, fixed!(32), nullable), + ] ), entry!( extra_syscalls::SYS_TIMER_GETTIME, - [desc!(1, Out, fixed!(32))] + [desc!(1, Out, fixed!(32), required)] ), entry!( extra_syscalls::SYS_MQ_OPEN, - [desc!(0, In, cstring!()), desc!(3, In, fixed!(32)),] + [ + desc!(0, In, cstring!(), required), + desc!( + 3, + In, + process_layout!( + process_layout::mq_attr::WASM32_SIZE, + process_layout::mq_attr::WASM64_SIZE + ), + nullable + ), + ] + ), + entry!( + extra_syscalls::SYS_MQ_UNLINK, + [desc!(0, In, cstring!(), required)] ), - entry!(extra_syscalls::SYS_MQ_UNLINK, [desc!(0, In, cstring!())]), entry!( extra_syscalls::SYS_MQ_TIMEDSEND, [ - desc!(1, In, arg!(2)), - desc!(4, In, fixed!(WASM_TIMESPEC_SIZE)), + desc!(1, In, arg!(2), required), + // mq_send(3) reaches this syscall with a null timeout. + desc!(4, In, fixed!(WASM_TIMESPEC_SIZE), nullable), ] ), entry!( extra_syscalls::SYS_MQ_TIMEDRECEIVE, [ - desc!(1, Out, arg!(2)), - desc!(3, Out, fixed!(4)), - desc!(4, In, fixed!(WASM_TIMESPEC_SIZE)), + desc!(1, Out, arg!(2), required), + desc!(3, Out, fixed!(4), nullable), + // mq_receive(3) reaches this syscall with a null timeout. + desc!(4, In, fixed!(WASM_TIMESPEC_SIZE), nullable), ] ), - entry!(extra_syscalls::SYS_MQ_NOTIFY, [desc!(1, In, fixed!(16))]), + entry!( + extra_syscalls::SYS_MQ_NOTIFY, + [desc!( + 1, + In, + process_layout!( + process_layout::sigevent::WASM32_SIZE, + process_layout::sigevent::WASM64_SIZE + ), + nullable + )] + ), entry!( extra_syscalls::SYS_MQ_GETSETATTR, - [desc!(1, In, fixed!(32)), desc!(2, Out, fixed!(32)),] + [ + desc!( + 1, + In, + process_layout!( + process_layout::mq_attr::WASM32_SIZE, + process_layout::mq_attr::WASM64_SIZE + ), + nullable + ), + desc!( + 2, + Out, + process_layout!( + process_layout::mq_attr::WASM32_SIZE, + process_layout::mq_attr::WASM64_SIZE + ), + nullable + ), + ] + ), + entry!( + extra_syscalls::SYS_SEMOP, + [desc!(1, In, arg!(2, mul 6), required)] ), entry!( - extra_syscalls::SYS_MSGRCV, - [desc!(1, Out, arg!(2, add 4), copy_retval_add 4)] + extra_syscalls::SYS_SIGNALFD, + [desc!( + 1, + In, + fixed!(kernel_scratch_wire::SIGNAL_MASK_BYTES), + required + )] + ), + entry!( + extra_syscalls::SYS_FACCESSAT2, + [desc!(1, In, cstring!(), required)] + ), + entry!( + extra_syscalls::SYS_FCHMODAT2, + [desc!(1, In, cstring!(), required)] ), - entry!(extra_syscalls::SYS_MSGSND, [desc!(1, In, arg!(2, add 4))]), - entry!(extra_syscalls::SYS_MSGCTL, [desc!(2, InOut, fixed!(96))]), - entry!(extra_syscalls::SYS_SEMOP, [desc!(1, In, arg!(2, mul 6))]), - entry!(extra_syscalls::SYS_SHMCTL, [desc!(2, InOut, fixed!(88))]), - entry!(extra_syscalls::SYS_FACCESSAT2, [desc!(1, In, cstring!())]), - entry!(extra_syscalls::SYS_FCHMODAT2, [desc!(1, In, cstring!())]), entry!( extra_syscalls::SYS_ACCEPT4, - [desc!(1, Out, deref!(2)), desc!(2, InOut, fixed!(4)),] + [ + desc!(1, Out, deref!(2), nullable), + desc!(2, InOut, fixed!(4), nullable), + ] ), ]; #[cfg(test)] mod tests { + extern crate std; + + use self::std::vec::Vec; use super::*; #[test] @@ -542,6 +970,108 @@ mod tests { } } + #[test] + fn pointer_nullability_is_explicit_and_exhaustive() { + let mut actual_nullable = Vec::new(); + for entry in SYSCALL_ARG_DESCRIPTORS { + for arg in entry.args { + assert_ne!( + arg.nullable, arg.required, + "syscall {} arg {} must be explicitly nullable or required", + entry.syscall_number, arg.arg_index + ); + match arg.size { + SyscallArgSize::Arg { multiplier, .. } => assert_ne!( + multiplier, 0, + "syscall {} arg {} has a zero size multiplier", + entry.syscall_number, arg.arg_index + ), + SyscallArgSize::Fixed { size } => assert_ne!( + size, 0, + "syscall {} arg {} has an empty fixed record", + entry.syscall_number, arg.arg_index + ), + SyscallArgSize::ProcessLayout { + wasm32_size, + wasm64_size, + } => { + assert_ne!( + wasm32_size, 0, + "syscall {} arg {} has an empty wasm32 record", + entry.syscall_number, arg.arg_index + ); + assert_ne!( + wasm64_size, 0, + "syscall {} arg {} has an empty wasm64 record", + entry.syscall_number, arg.arg_index + ); + } + SyscallArgSize::CString | SyscallArgSize::Deref { .. } => {} + } + if arg.nullable { + actual_nullable.push((entry.syscall_number, arg.arg_index)); + } + } + } + + let mut expected_nullable = std::vec![ + (Syscall::Sigaction as u32, 1), + (Syscall::Sigaction as u32, 2), + (Syscall::Sigprocmask as u32, 1), + (Syscall::Sigprocmask as u32, 2), + (Syscall::Accept as u32, 1), + (Syscall::Accept as u32, 2), + (Syscall::Recvfrom as u32, 4), + (Syscall::Recvfrom as u32, 5), + (Syscall::ClockGetres as u32, 1), + (Syscall::Utimensat as u32, 1), + (Syscall::Utimensat as u32, 2), + (Syscall::Wait4 as u32, 1), + (Syscall::Wait4 as u32, 3), + (extra_syscalls::SYS_RT_SIGTIMEDWAIT, 1), + (extra_syscalls::SYS_RT_SIGTIMEDWAIT, 2), + (extra_syscalls::SYS_SIGALTSTACK, 0), + (extra_syscalls::SYS_SIGALTSTACK, 1), + (extra_syscalls::SYS_SETITIMER, 2), + (extra_syscalls::SYS_TIMERFD_SETTIME, 3), + (extra_syscalls::SYS_PRLIMIT64, 2), + (extra_syscalls::SYS_PRLIMIT64, 3), + (extra_syscalls::SYS_WAITID, 4), + (extra_syscalls::SYS_COPY_FILE_RANGE, 1), + (extra_syscalls::SYS_COPY_FILE_RANGE, 3), + (extra_syscalls::SYS_SPLICE, 1), + (extra_syscalls::SYS_SPLICE, 3), + (extra_syscalls::SYS_SENDFILE, 2), + (extra_syscalls::SYS_GETCPU, 0), + (extra_syscalls::SYS_GETCPU, 1), + (extra_syscalls::SYS_TIMER_CREATE, 1), + (extra_syscalls::SYS_TIMER_SETTIME, 3), + (extra_syscalls::SYS_MQ_OPEN, 3), + (extra_syscalls::SYS_MQ_TIMEDSEND, 4), + (extra_syscalls::SYS_MQ_TIMEDRECEIVE, 3), + (extra_syscalls::SYS_MQ_TIMEDRECEIVE, 4), + (extra_syscalls::SYS_MQ_NOTIFY, 1), + (extra_syscalls::SYS_MQ_GETSETATTR, 1), + (extra_syscalls::SYS_MQ_GETSETATTR, 2), + (extra_syscalls::SYS_ACCEPT4, 1), + (extra_syscalls::SYS_ACCEPT4, 2), + ]; + actual_nullable.sort_unstable(); + expected_nullable.sort_unstable(); + assert_eq!( + actual_nullable, expected_nullable, + "review syscall semantics before changing the explicit nullable set" + ); + } + + #[test] + fn option_sensitive_prctl_stays_out_of_generic_pointer_metadata() { + assert!( + maybe_find(extra_syscalls::SYS_PRCTL).is_none(), + "PR_SET_NAME/PR_GET_NAME use a pointer in arg 1, but other prctl options use a scalar" + ); + } + #[test] fn high_risk_size_adjustments_are_metadata_owned() { let poll = find(Syscall::Poll as u32).args[0].size; @@ -554,16 +1084,13 @@ mod tests { } ); - let msgrcv = find(extra_syscalls::SYS_MSGRCV).args[0]; - assert_eq!( - msgrcv.size, - SyscallArgSize::Arg { - arg_index: 2, - multiplier: 1, - add: 4, - } + assert!( + SYSCALL_ARG_DESCRIPTORS + .iter() + .all(|entry| entry.syscall_number != extra_syscalls::SYS_MSGRCV + && entry.syscall_number != extra_syscalls::SYS_MSGSND), + "native-long SysV messages must use the width-aware host handler" ); - assert_eq!(msgrcv.copy_retval_add, 4); let lchown = find(extra_syscalls::SYS_LCHOWN).args[0]; assert_eq!(lchown.arg_index, 0); @@ -606,10 +1133,26 @@ mod tests { } ); + let sigtimedwait = find(extra_syscalls::SYS_RT_SIGTIMEDWAIT).args; + assert_eq!( + sigtimedwait[1].size, + SyscallArgSize::ProcessLayout { + wasm32_size: process_layout::rt_sigqueueinfo::WASM32_SIZE, + wasm64_size: process_layout::rt_sigqueueinfo::WASM64_SIZE, + } + ); + assert!(sigtimedwait[1].nullable); + let waitid = find(extra_syscalls::SYS_WAITID).args; assert_eq!(waitid[0].arg_index, 2); assert_eq!(waitid[0].direction, SyscallArgDirection::Out); - assert_eq!(waitid[0].size, SyscallArgSize::Fixed { size: 128 }); + assert_eq!( + waitid[0].size, + SyscallArgSize::ProcessLayout { + wasm32_size: process_layout::rt_sigqueueinfo::WASM32_SIZE, + wasm64_size: process_layout::rt_sigqueueinfo::WASM64_SIZE, + } + ); assert!(waitid[0].required); assert_eq!(waitid[1].arg_index, 4); assert_eq!(waitid[1].direction, SyscallArgDirection::Out); @@ -621,6 +1164,15 @@ mod tests { ); assert!(waitid[1].nullable); + let getaddrinfo = find(Syscall::Getaddrinfo as u32).args; + assert_eq!(getaddrinfo[1].arg_index, 1); + assert_eq!(getaddrinfo[1].direction, SyscallArgDirection::Out); + assert_eq!( + getaddrinfo[1].size, + SyscallArgSize::Fixed { size: 4 }, + "musl gives SYS_getaddrinfo exactly one four-byte IPv4 result" + ); + let sched_getaffinity = find(extra_syscalls::SYS_SCHED_GETAFFINITY).args[0]; assert_eq!(sched_getaffinity.arg_index, 2); assert_eq!(sched_getaffinity.direction, SyscallArgDirection::Out); @@ -632,6 +1184,87 @@ mod tests { ); assert!(sched_getaffinity.required); + let timerfd_settime = find(extra_syscalls::SYS_TIMERFD_SETTIME).args; + assert_eq!(timerfd_settime[0].arg_index, 2); + assert_eq!(timerfd_settime[0].direction, SyscallArgDirection::In); + assert_eq!(timerfd_settime[0].size, SyscallArgSize::Fixed { size: 32 }); + assert!(timerfd_settime[0].required); + assert_eq!(timerfd_settime[1].arg_index, 3); + assert_eq!(timerfd_settime[1].direction, SyscallArgDirection::Out); + assert_eq!(timerfd_settime[1].size, SyscallArgSize::Fixed { size: 32 }); + assert!(timerfd_settime[1].nullable); + + let timerfd_gettime = find(extra_syscalls::SYS_TIMERFD_GETTIME).args[0]; + assert_eq!(timerfd_gettime.arg_index, 1); + assert_eq!(timerfd_gettime.direction, SyscallArgDirection::Out); + assert_eq!(timerfd_gettime.size, SyscallArgSize::Fixed { size: 32 }); + assert!(timerfd_gettime.required); + + for syscall in [extra_syscalls::SYS_SIGNALFD4, extra_syscalls::SYS_SIGNALFD] { + let mask = find(syscall).args[0]; + assert_eq!(mask.arg_index, 1); + assert_eq!(mask.direction, SyscallArgDirection::In); + assert_eq!(mask.size, SyscallArgSize::Fixed { size: 8 }); + assert!(mask.required); + } + + let memfd_name = find(extra_syscalls::SYS_MEMFD_CREATE).args[0]; + assert_eq!(memfd_name.arg_index, 0); + assert_eq!(memfd_name.direction, SyscallArgDirection::In); + assert_eq!(memfd_name.size, SyscallArgSize::CString); + assert!(memfd_name.required); + + for syscall in [ + extra_syscalls::SYS_COPY_FILE_RANGE, + extra_syscalls::SYS_SPLICE, + ] { + let offsets = find(syscall).args; + assert_eq!(offsets.len(), 2); + for (offset, arg_index) in offsets.iter().zip([1, 3]) { + assert_eq!(offset.arg_index, arg_index); + assert_eq!(offset.direction, SyscallArgDirection::InOut); + assert_eq!(offset.size, SyscallArgSize::Fixed { size: 8 }); + assert!(offset.nullable); + } + } + + let sendfile_offset = find(extra_syscalls::SYS_SENDFILE).args[0]; + assert_eq!(sendfile_offset.arg_index, 2); + assert_eq!(sendfile_offset.direction, SyscallArgDirection::InOut); + assert_eq!(sendfile_offset.size, SyscallArgSize::Fixed { size: 8 }); + assert!(sendfile_offset.nullable); + + let renameat2 = find(extra_syscalls::SYS_RENAMEAT2).args; + assert_eq!(renameat2.len(), 2); + for (path, arg_index) in renameat2.iter().zip([1, 3]) { + assert_eq!(path.arg_index, arg_index); + assert_eq!(path.direction, SyscallArgDirection::In); + assert_eq!(path.size, SyscallArgSize::CString); + assert!(!path.nullable); + } + + let getcpu = find(extra_syscalls::SYS_GETCPU).args; + assert_eq!(getcpu.len(), 2); + for (output, arg_index) in getcpu.iter().zip([0, 1]) { + assert_eq!(output.arg_index, arg_index); + assert_eq!(output.direction, SyscallArgDirection::Out); + assert_eq!(output.size, SyscallArgSize::Fixed { size: 4 }); + assert!(output.nullable); + } + + let setgroups = find(Syscall::Setgroups as u32).args[0]; + assert_eq!(setgroups.arg_index, 1); + assert_eq!(setgroups.direction, SyscallArgDirection::In); + assert_eq!( + setgroups.size, + SyscallArgSize::Arg { + arg_index: 0, + multiplier: 4, + add: 0, + } + ); + assert!(setgroups.required); + let semop = find(extra_syscalls::SYS_SEMOP).args[0].size; assert_eq!( semop, @@ -643,10 +1276,214 @@ mod tests { ); } + #[test] + fn process_native_layouts_carry_both_widths_and_width_slot() { + assert_eq!(PROCESS_POINTER_WIDTH_ARG_INDEX, 5); + + fn assert_layout( + syscall: u32, + arg_index: u8, + direction: SyscallArgDirection, + wasm32_size: u32, + wasm64_size: u32, + ) { + let arg = find(syscall) + .args + .iter() + .find(|arg| arg.arg_index == arg_index) + .expect("missing process-layout argument"); + assert_eq!(arg.direction, direction); + assert_eq!( + arg.size, + SyscallArgSize::ProcessLayout { + wasm32_size, + wasm64_size, + } + ); + } + + assert_layout( + Syscall::Statfs as u32, + 2, + SyscallArgDirection::Out, + process_layout::statfs::WASM32_SIZE, + process_layout::statfs::WASM64_SIZE, + ); + assert_layout( + Syscall::Fstatfs as u32, + 2, + SyscallArgDirection::Out, + process_layout::statfs::WASM32_SIZE, + process_layout::statfs::WASM64_SIZE, + ); + for (arg_index, direction) in [(0, SyscallArgDirection::In), (1, SyscallArgDirection::Out)] + { + assert_layout( + extra_syscalls::SYS_SIGALTSTACK, + arg_index, + direction, + process_layout::sigaltstack::WASM32_SIZE, + process_layout::sigaltstack::WASM64_SIZE, + ); + } + assert_layout( + extra_syscalls::SYS_GETITIMER, + 1, + SyscallArgDirection::Out, + process_layout::itimerval::WASM32_SIZE, + process_layout::itimerval::WASM64_SIZE, + ); + for (arg_index, direction) in [(1, SyscallArgDirection::In), (2, SyscallArgDirection::Out)] + { + assert_layout( + extra_syscalls::SYS_SETITIMER, + arg_index, + direction, + process_layout::itimerval::WASM32_SIZE, + process_layout::itimerval::WASM64_SIZE, + ); + } + for (syscall, arg_index) in [ + (Syscall::Statfs as u32, 2), + (Syscall::Fstatfs as u32, 2), + (extra_syscalls::SYS_GETITIMER, 1), + (extra_syscalls::SYS_SETITIMER, 1), + ] { + assert!( + find(syscall) + .args + .iter() + .find(|arg| arg.arg_index == arg_index) + .expect("missing required process-layout argument") + .required + ); + } + assert_layout( + extra_syscalls::SYS_SYSINFO, + 0, + SyscallArgDirection::Out, + process_layout::sysinfo::WASM32_SIZE, + process_layout::sysinfo::WASM64_SIZE, + ); + assert_layout( + extra_syscalls::SYS_MQ_OPEN, + 3, + SyscallArgDirection::In, + process_layout::mq_attr::WASM32_SIZE, + process_layout::mq_attr::WASM64_SIZE, + ); + assert_layout( + extra_syscalls::SYS_TIMER_CREATE, + 1, + SyscallArgDirection::In, + process_layout::sigevent::WASM32_SIZE, + process_layout::sigevent::WASM64_SIZE, + ); + assert!(find(extra_syscalls::SYS_TIMER_CREATE).args[0].nullable); + assert_layout( + extra_syscalls::SYS_MQ_NOTIFY, + 1, + SyscallArgDirection::In, + process_layout::sigevent::WASM32_SIZE, + process_layout::sigevent::WASM64_SIZE, + ); + for (arg_index, direction) in [(1, SyscallArgDirection::In), (2, SyscallArgDirection::Out)] + { + assert_layout( + extra_syscalls::SYS_MQ_GETSETATTR, + arg_index, + direction, + process_layout::mq_attr::WASM32_SIZE, + process_layout::mq_attr::WASM64_SIZE, + ); + } + } + + #[test] + fn fixed_native_records_use_complete_required_buffers() { + for (syscall, arg_index) in [ + (Syscall::Fstat as u32, 1), + (Syscall::Stat as u32, 1), + (Syscall::Lstat as u32, 1), + (Syscall::Fstatat as u32, 2), + ] { + let stat = find(syscall) + .args + .iter() + .find(|arg| arg.arg_index == arg_index) + .expect("missing stat output"); + assert_eq!(stat.direction, SyscallArgDirection::Out); + assert_eq!( + stat.size, + SyscallArgSize::Fixed { + size: process_layout::stat::SIZE, + } + ); + assert!(stat.required); + } + + let queued = find(extra_syscalls::SYS_RT_SIGQUEUEINFO).args[0]; + assert_eq!(queued.arg_index, 2); + assert_eq!(queued.direction, SyscallArgDirection::In); + assert_eq!( + queued.size, + SyscallArgSize::ProcessLayout { + wasm32_size: process_layout::rt_sigqueueinfo::WASM32_SIZE, + wasm64_size: process_layout::rt_sigqueueinfo::WASM64_SIZE, + } + ); + assert!(queued.required); + + for (syscall, arg_index, direction) in [ + ( + extra_syscalls::SYS_SCHED_GETPARAM, + 1, + SyscallArgDirection::Out, + ), + ( + extra_syscalls::SYS_SCHED_SETPARAM, + 1, + SyscallArgDirection::In, + ), + ( + extra_syscalls::SYS_SCHED_SETSCHEDULER, + 2, + SyscallArgDirection::In, + ), + ] { + let param = find(syscall).args[0]; + assert_eq!(param.arg_index, arg_index); + assert_eq!(param.direction, direction); + assert_eq!( + param.size, + SyscallArgSize::Fixed { + size: process_layout::sched_param::SIZE, + } + ); + assert!(param.required); + } + } + #[test] fn nested_pointer_syscalls_stay_out_of_simple_descriptors() { - assert!(maybe_find(Syscall::Writev as u32).is_none()); - assert!(maybe_find(Syscall::Readv as u32).is_none()); + for syscall in [ + Syscall::Writev as u32, + Syscall::Readv as u32, + extra_syscalls::SYS_PREADV, + extra_syscalls::SYS_PWRITEV, + extra_syscalls::SYS_PREADV2, + extra_syscalls::SYS_PWRITEV2, + Syscall::Sendmsg as u32, + Syscall::Recvmsg as u32, + Syscall::Getgroups as u32, + extra_syscalls::SYS_MSGRCV, + extra_syscalls::SYS_MSGSND, + ] { + assert!( + maybe_find(syscall).is_none(), + "nested iovec syscall {syscall} needs its reviewed host handler" + ); + } } fn find(syscall_number: u32) -> &'static SyscallArgDescriptor { diff --git a/crates/shared/src/ioctl_contract.rs b/crates/shared/src/ioctl_contract.rs new file mode 100644 index 0000000000..59545603fe --- /dev/null +++ b/crates/shared/src/ioctl_contract.rs @@ -0,0 +1,255 @@ +//! Request-aware `ioctl(2)` marshalling contract. +//! +//! Unlike ordinary syscalls, the third ioctl argument is selected by the +//! request number: it may be absent, an integer value, or a pointer to a +//! request-specific structure. Keeping that distinction in one table lets +//! both the JavaScript host and Rust dispatcher prove the exact scratch +//! capacity before either side treats the argument as memory. + +/// Linux/musl `struct termios` size for Kandelo's wasm32 and wasm64 targets. +/// +/// musl uses four `tcflag_t` words, `cc_t c_line`, 32 control characters, +/// three bytes of alignment, and two `speed_t` words. +pub const TERMIOS_SIZE: u32 = 60; + +// Generic fd ioctls. +pub const FIONREAD: u32 = 0x541b; +pub const FIONBIO: u32 = 0x5421; +pub const FIONCLEX: u32 = 0x5450; +pub const FIOCLEX: u32 = 0x5451; +pub const FIOASYNC: u32 = 0x5452; +pub const SIOCATMARK: u32 = 0x8905; + +// Linux terminal and PTY ioctls. +pub const TCGETS: u32 = 0x5401; +pub const TCSETS: u32 = 0x5402; +pub const TCSETSW: u32 = 0x5403; +pub const TCSETSF: u32 = 0x5404; +pub const TCSBRK: u32 = 0x5409; +pub const TCXONC: u32 = 0x540a; +pub const TCFLSH: u32 = 0x540b; +pub const TIOCSCTTY: u32 = 0x540e; +pub const TIOCGPGRP: u32 = 0x540f; +pub const TIOCSPGRP: u32 = 0x5410; +pub const TIOCGWINSZ: u32 = 0x5413; +pub const TIOCSWINSZ: u32 = 0x5414; +pub const TIOCNOTTY: u32 = 0x5422; +pub const TIOCGSID: u32 = 0x5429; +pub const TIOCGPTN: u32 = 0x8004_5430; +pub const TIOCSPTLCK: u32 = 0x4004_5431; + +// Linux VT keyboard ioctls. +pub const KDGKBTYPE: u32 = 0x4b33; +pub const KDGKBMODE: u32 = 0x4b44; +pub const KDSKBMODE: u32 = 0x4b45; + +/// How the request's third argument is represented by the caller. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IoctlArgKind { + None, + ScalarI32, + Pointer, +} + +/// Bytes the host must copy for a pointer request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IoctlDirection { + None, + In, + Out, + InOut, +} + +/// One supported ioctl request and its caller-data-model-specific wire size. +/// +/// A `None` size means that the request number is known but that caller data +/// model is unsupported. This is intentionally distinct from an unknown +/// request so the dispatcher can reject a would-be lossy conversion. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct IoctlRequestContract { + pub request: u32, + pub arg_kind: IoctlArgKind, + pub direction: IoctlDirection, + pub wasm32_size: Option, + pub wasm64_size: Option, +} + +impl IoctlRequestContract { + pub const fn size_for_pointer_width(self, pointer_width: u8) -> Option { + match pointer_width { + 4 => self.wasm32_size, + 8 => self.wasm64_size, + _ => None, + } + } +} + +macro_rules! no_arg { + ($request:expr) => { + IoctlRequestContract { + request: $request, + arg_kind: IoctlArgKind::None, + direction: IoctlDirection::None, + wasm32_size: Some(0), + wasm64_size: Some(0), + } + }; +} + +macro_rules! scalar_i32 { + ($request:expr) => { + IoctlRequestContract { + request: $request, + arg_kind: IoctlArgKind::ScalarI32, + direction: IoctlDirection::None, + wasm32_size: Some(0), + wasm64_size: Some(0), + } + }; +} + +macro_rules! pointer { + ($request:expr, $direction:ident, $size:expr) => { + IoctlRequestContract { + request: $request, + arg_kind: IoctlArgKind::Pointer, + direction: IoctlDirection::$direction, + wasm32_size: Some($size), + wasm64_size: Some($size), + } + }; + ($request:expr, $direction:ident, wasm32 $size:expr) => { + IoctlRequestContract { + request: $request, + arg_kind: IoctlArgKind::Pointer, + direction: IoctlDirection::$direction, + wasm32_size: Some($size), + wasm64_size: None, + } + }; + ($request:expr, $direction:ident, wasm64 $size:expr) => { + IoctlRequestContract { + request: $request, + arg_kind: IoctlArgKind::Pointer, + direction: IoctlDirection::$direction, + wasm32_size: None, + wasm64_size: Some($size), + } + }; +} + +/// Ioctls that may reach the Rust kernel dispatcher. +/// +/// Keep entries sorted by unsigned request number. Network-interface ioctls +/// are deliberately absent: the host intercepts those before kernel scratch +/// because their outer structures contain process-memory pointers. +pub const IOCTL_REQUEST_CONTRACTS: &[IoctlRequestContract] = &[ + // Kandelo GLES requests use small private request numbers. + pointer!(crate::gl::GLIO_INIT, In, 4), + no_arg!(crate::gl::GLIO_TERMINATE), + pointer!(crate::gl::GLIO_CREATE_CONTEXT, In, 16), + no_arg!(crate::gl::GLIO_DESTROY_CONTEXT), + pointer!(crate::gl::GLIO_CREATE_SURFACE, In, 32), + no_arg!(crate::gl::GLIO_DESTROY_SURFACE), + no_arg!(crate::gl::GLIO_MAKE_CURRENT), + pointer!(crate::gl::GLIO_SUBMIT, In, 8), + no_arg!(crate::gl::GLIO_PRESENT), + // WHY: GlQueryInfo contains wasm32 pointers. Until a native wasm64 wire + // structure exists, rejecting the known request is safer than truncating. + pointer!(crate::gl::GLIO_QUERY, In, wasm32 24), + pointer!(crate::fbdev::FBIOGET_VSCREENINFO, Out, 160), + pointer!(crate::fbdev::FBIOPUT_VSCREENINFO, In, 160), + pointer!(crate::fbdev::FBIOGET_FSCREENINFO, Out, 80), + pointer!(crate::fbdev::FBIOPAN_DISPLAY, In, 160), + pointer!(KDGKBTYPE, Out, 1), + pointer!(KDGKBMODE, Out, 4), + scalar_i32!(KDSKBMODE), + no_arg!(crate::oss::SNDCTL_DSP_RESET), + no_arg!(crate::oss::SNDCTL_DSP_SYNC), + pointer!(TCGETS, Out, TERMIOS_SIZE), + pointer!(TCSETS, In, TERMIOS_SIZE), + pointer!(TCSETSW, In, TERMIOS_SIZE), + pointer!(TCSETSF, In, TERMIOS_SIZE), + scalar_i32!(TCSBRK), + scalar_i32!(TCXONC), + scalar_i32!(TCFLSH), + scalar_i32!(TIOCSCTTY), + pointer!(TIOCGPGRP, Out, 4), + pointer!(TIOCSPGRP, In, 4), + pointer!(TIOCGWINSZ, Out, 8), + pointer!(TIOCSWINSZ, In, 8), + pointer!(FIONREAD, Out, 4), + pointer!(FIONBIO, In, 4), + no_arg!(TIOCNOTTY), + pointer!(TIOCGSID, Out, 4), + no_arg!(FIONCLEX), + no_arg!(FIOCLEX), + pointer!(FIOASYNC, In, 4), + no_arg!(crate::dri::DRM_IOCTL_SET_MASTER), + no_arg!(crate::dri::DRM_IOCTL_DROP_MASTER), + pointer!(SIOCATMARK, Out, 4), + pointer!(TIOCSPTLCK, In, 4), + pointer!(crate::dri::DRM_IOCTL_GEM_CLOSE, In, 8), + pointer!(crate::oss::SNDCTL_DSP_GETFMTS, Out, 4), + pointer!(TIOCGPTN, Out, 4), + pointer!(crate::oss::SNDCTL_DSP_SPEED, InOut, 4), + pointer!(crate::oss::SNDCTL_DSP_STEREO, InOut, 4), + pointer!(crate::oss::SNDCTL_DSP_SETFMT, InOut, 4), + pointer!(crate::oss::SNDCTL_DSP_CHANNELS, InOut, 4), + pointer!(crate::oss::SNDCTL_DSP_SETFRAGMENT, InOut, 4), + pointer!(crate::dri::DRM_IOCTL_MODE_RMFB, In, 4), + pointer!(crate::dri::DRM_IOCTL_MODE_DESTROY_DUMB, In, 4), + pointer!(crate::dri::DRM_IOCTL_PRIME_HANDLE_TO_FD, InOut, 12), + pointer!(crate::dri::DRM_IOCTL_PRIME_FD_TO_HANDLE, InOut, 12), + pointer!(crate::dri::DRM_IOCTL_GET_CAP, InOut, 16), + pointer!(crate::dri::DRM_IOCTL_WAIT_VBLANK, InOut, 16), + pointer!(crate::dri::DRM_IOCTL_MODE_MAP_DUMB, InOut, 16), + pointer!(crate::dri::DRM_IOCTL_MODE_GETENCODER, InOut, 20), + pointer!(crate::dri::DRM_IOCTL_MODE_PAGE_FLIP, In, 24), + pointer!(crate::dri::DRM_IOCTL_MODE_CREATE_DUMB, InOut, 32), + // Linux encodes pointer-sized fields into DRM_IOCTL_VERSION itself. + pointer!(crate::dri::DRM_IOCTL_VERSION, InOut, wasm32 36), + pointer!(crate::dri::DRM_IOCTL_VERSION_WASM64, InOut, wasm64 64), + pointer!(crate::dri::DRM_IOCTL_MODE_GETRESOURCES, InOut, 64), + pointer!(crate::dri::DRM_IOCTL_MODE_GETCONNECTOR, InOut, 80), + pointer!(crate::dri::DRM_IOCTL_MODE_GETCRTC, InOut, 104), + pointer!(crate::dri::DRM_IOCTL_MODE_SETCRTC, In, 104), + pointer!(crate::dri::DRM_IOCTL_MODE_ADDFB2, InOut, 104), +]; + +pub fn request_contract(request: u32) -> Option<&'static IoctlRequestContract> { + IOCTL_REQUEST_CONTRACTS + .binary_search_by_key(&request, |entry| entry.request) + .ok() + .map(|index| &IOCTL_REQUEST_CONTRACTS[index]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn contracts_are_strictly_sorted_and_unique() { + assert!(IOCTL_REQUEST_CONTRACTS + .windows(2) + .all(|pair| pair[0].request < pair[1].request)); + } + + #[test] + fn native_drm_version_layouts_are_request_specific() { + let wasm32 = request_contract(crate::dri::DRM_IOCTL_VERSION).unwrap(); + assert_eq!(wasm32.size_for_pointer_width(4), Some(36)); + assert_eq!(wasm32.size_for_pointer_width(8), None); + + let wasm64 = request_contract(crate::dri::DRM_IOCTL_VERSION_WASM64).unwrap(); + assert_eq!(wasm64.size_for_pointer_width(4), None); + assert_eq!(wasm64.size_for_pointer_width(8), Some(64)); + } + + #[test] + fn gl_query_refuses_wasm64_until_it_has_a_native_wire() { + let query = request_contract(crate::gl::GLIO_QUERY).unwrap(); + assert_eq!(query.size_for_pointer_width(4), Some(24)); + assert_eq!(query.size_for_pointer_width(8), None); + } +} diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 5324ff0db8..d7fb57676a 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -1,6 +1,8 @@ #![no_std] pub mod host_abi; +pub mod ioctl_contract; +pub mod process_layout; /// Kernel ABI version. /// @@ -88,6 +90,11 @@ pub mod host_abi; /// references, complete exceptions, mutable reference globals, and mutable /// tables are serialized as versioned process-owned recipes and rebuilt /// with fresh instance-local identities before continuation replay. +/// Variable-sized host writes into reusable kernel spawn storage use a +/// tokenized begin/copy/commit transaction, and System V IPC control +/// transfers plus caller-native signal-stack, interval-timer, POSIX +/// message-queue, filesystem-statistics, and system-information records +/// use required pointer-width-aware kernel structure sizes. pub const ABI_VERSION: u32 = 43; /// Byte width of Kandelo's Linux-compatible kernel CPU-affinity mask. @@ -97,6 +104,201 @@ pub const ABI_VERSION: u32 = 43; /// changing this width is an ABI change. pub const SCHED_AFFINITY_MASK_SIZE: u32 = 4; +/// Kandelo's advertised cross-layer POSIX limits. +/// +/// Keep these outside any one syscall protocol: libc headers, Rust syscall +/// implementations, and TypeScript host validation all consume the generated +/// values. Parser-specific defensive limits belong with their parser instead. +pub mod platform_limits { + pub const ARG_MAX_BYTES: usize = 4 * 1024 * 1024; + pub const PATH_MAX_BYTES: usize = 4096; + pub const IOV_MAX: usize = 1024; +} + +/// Cross-layer layout values and defensive limits for the non-forking spawn +/// protocol. +/// +/// The `POSIX_*` aliases deliberately refer to the advertised platform +/// limits. The count caps are defensive parser limits for this wire +/// representation, not additional POSIX limits on applications. +pub mod spawn_contract { + use core::mem::size_of; + + use super::platform_limits; + + pub const POSIX_ARG_MAX_BYTES: usize = platform_limits::ARG_MAX_BYTES; + pub const POSIX_PATH_MAX_BYTES: usize = platform_limits::PATH_MAX_BYTES; + + pub const WIRE_STRING_OFFSET_BYTES: usize = size_of::(); + + pub const WIRE_HEADER_ARGC_OFFSET: usize = 0; + pub const WIRE_HEADER_ENVC_OFFSET: usize = WIRE_HEADER_ARGC_OFFSET + size_of::(); + pub const WIRE_HEADER_ACTION_COUNT_OFFSET: usize = WIRE_HEADER_ENVC_OFFSET + size_of::(); + pub const WIRE_HEADER_ATTR_FLAGS_OFFSET: usize = + WIRE_HEADER_ACTION_COUNT_OFFSET + size_of::(); + pub const WIRE_HEADER_PGRP_OFFSET: usize = WIRE_HEADER_ATTR_FLAGS_OFFSET + size_of::(); + pub const WIRE_HEADER_PAD_OFFSET: usize = WIRE_HEADER_PGRP_OFFSET + size_of::(); + pub const WIRE_HEADER_SIGDEF_OFFSET: usize = WIRE_HEADER_PAD_OFFSET + size_of::(); + pub const WIRE_HEADER_SIGMASK_OFFSET: usize = WIRE_HEADER_SIGDEF_OFFSET + size_of::(); + pub const WIRE_HEADER_BYTES: usize = WIRE_HEADER_SIGMASK_OFFSET + size_of::(); + + pub const WIRE_ACTION_OP_OFFSET: usize = 0; + pub const WIRE_ACTION_FD_OFFSET: usize = WIRE_ACTION_OP_OFFSET + size_of::(); + pub const WIRE_ACTION_NEWFD_OFFSET: usize = WIRE_ACTION_FD_OFFSET + size_of::(); + pub const WIRE_ACTION_PATH_OFF_OFFSET: usize = WIRE_ACTION_NEWFD_OFFSET + size_of::(); + pub const WIRE_ACTION_PATH_LEN_OFFSET: usize = WIRE_ACTION_PATH_OFF_OFFSET + size_of::(); + pub const WIRE_ACTION_OFLAG_OFFSET: usize = WIRE_ACTION_PATH_LEN_OFFSET + size_of::(); + pub const WIRE_ACTION_MODE_OFFSET: usize = WIRE_ACTION_OFLAG_OFFSET + size_of::(); + pub const WIRE_ACTION_RECORD_BYTES: usize = WIRE_ACTION_MODE_OFFSET + size_of::(); + + pub const WIRE_OP_OPEN: u32 = 0; + pub const WIRE_OP_CLOSE: u32 = 1; + pub const WIRE_OP_DUP2: u32 = 2; + pub const WIRE_OP_CHDIR: u32 = 3; + pub const WIRE_OP_FCHDIR: u32 = 4; + + // These are every musl flag bit transported byte-for-byte in the blob. + // Kernel support remains an explicit subset in `kernel::spawn::attr_flags`; + // defining a transport value here does not claim the behavior exists. + pub const ATTR_RESETIDS: u32 = 0x01; + pub const ATTR_SETPGROUP: u32 = 0x02; + pub const ATTR_SETSIGDEF: u32 = 0x04; + pub const ATTR_SETSIGMASK: u32 = 0x08; + pub const ATTR_SETSCHEDPARAM: u32 = 0x10; + pub const ATTR_SETSCHEDULER: u32 = 0x20; + pub const ATTR_USEVFORK: u32 = 0x40; + pub const ATTR_SETSID: u32 = 0x80; + pub const MAX_ARGV_COUNT: usize = 4096; + pub const MAX_ENVP_COUNT: usize = 4096; + pub const MAX_ACTION_COUNT: usize = 1024; + + /// Complete transport ceiling: POSIX argv/environment budget plus the + /// defensive maximum number of PATH_MAX-sized file actions. + pub const WIRE_MAX_BYTES: usize = POSIX_ARG_MAX_BYTES + + WIRE_HEADER_BYTES + + MAX_ACTION_COUNT * (WIRE_ACTION_RECORD_BYTES + POSIX_PATH_MAX_BYTES); + + // WHY: counts, string offsets, and action path lengths are serialized as + // u32. Keep the complete representation within that address domain so a + // future platform-limit change cannot make the C encoder truncate a + // `size_t` cursor while Rust and TypeScript continue accepting the blob. + const _: () = { + assert!(MAX_ARGV_COUNT <= u32::MAX as usize); + assert!(MAX_ENVP_COUNT <= u32::MAX as usize); + assert!(MAX_ACTION_COUNT <= u32::MAX as usize); + assert!(POSIX_PATH_MAX_BYTES <= u32::MAX as usize); + assert!(WIRE_MAX_BYTES <= u32::MAX as usize); + }; + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn wire_layout_fields_are_contiguous_and_cover_the_records() { + assert_eq!(WIRE_STRING_OFFSET_BYTES, size_of::()); + + assert_eq!(WIRE_HEADER_ARGC_OFFSET, 0); + assert_eq!( + WIRE_HEADER_ENVC_OFFSET, + WIRE_HEADER_ARGC_OFFSET + size_of::() + ); + assert_eq!( + WIRE_HEADER_ACTION_COUNT_OFFSET, + WIRE_HEADER_ENVC_OFFSET + size_of::() + ); + assert_eq!( + WIRE_HEADER_ATTR_FLAGS_OFFSET, + WIRE_HEADER_ACTION_COUNT_OFFSET + size_of::() + ); + assert_eq!( + WIRE_HEADER_PGRP_OFFSET, + WIRE_HEADER_ATTR_FLAGS_OFFSET + size_of::() + ); + assert_eq!( + WIRE_HEADER_PAD_OFFSET, + WIRE_HEADER_PGRP_OFFSET + size_of::() + ); + assert_eq!( + WIRE_HEADER_SIGDEF_OFFSET, + WIRE_HEADER_PAD_OFFSET + size_of::() + ); + assert_eq!( + WIRE_HEADER_SIGMASK_OFFSET, + WIRE_HEADER_SIGDEF_OFFSET + size_of::() + ); + assert_eq!( + WIRE_HEADER_BYTES, + WIRE_HEADER_SIGMASK_OFFSET + size_of::() + ); + assert_eq!(WIRE_HEADER_BYTES, 40); + + assert_eq!(WIRE_ACTION_OP_OFFSET, 0); + assert_eq!( + WIRE_ACTION_FD_OFFSET, + WIRE_ACTION_OP_OFFSET + size_of::() + ); + assert_eq!( + WIRE_ACTION_NEWFD_OFFSET, + WIRE_ACTION_FD_OFFSET + size_of::() + ); + assert_eq!( + WIRE_ACTION_PATH_OFF_OFFSET, + WIRE_ACTION_NEWFD_OFFSET + size_of::() + ); + assert_eq!( + WIRE_ACTION_PATH_LEN_OFFSET, + WIRE_ACTION_PATH_OFF_OFFSET + size_of::() + ); + assert_eq!( + WIRE_ACTION_OFLAG_OFFSET, + WIRE_ACTION_PATH_LEN_OFFSET + size_of::() + ); + assert_eq!( + WIRE_ACTION_MODE_OFFSET, + WIRE_ACTION_OFLAG_OFFSET + size_of::() + ); + assert_eq!( + WIRE_ACTION_RECORD_BYTES, + WIRE_ACTION_MODE_OFFSET + size_of::() + ); + assert_eq!(WIRE_ACTION_RECORD_BYTES, 28); + } + + #[test] + fn transported_attr_bits_cover_musls_complete_flag_byte() { + assert_eq!(ATTR_RESETIDS, 0x01); + assert_eq!(ATTR_SETPGROUP, 0x02); + assert_eq!(ATTR_SETSIGDEF, 0x04); + assert_eq!(ATTR_SETSIGMASK, 0x08); + assert_eq!(ATTR_SETSCHEDPARAM, 0x10); + assert_eq!(ATTR_SETSCHEDULER, 0x20); + assert_eq!(ATTR_USEVFORK, 0x40); + assert_eq!(ATTR_SETSID, 0x80); + assert_eq!( + ATTR_RESETIDS + | ATTR_SETPGROUP + | ATTR_SETSIGDEF + | ATTR_SETSIGMASK + | ATTR_SETSCHEDPARAM + | ATTR_SETSCHEDULER + | ATTR_USEVFORK + | ATTR_SETSID, + 0xff + ); + } + + #[test] + fn wire_counts_lengths_and_offsets_are_u32_representable() { + assert!(MAX_ARGV_COUNT <= u32::MAX as usize); + assert!(MAX_ENVP_COUNT <= u32::MAX as usize); + assert!(MAX_ACTION_COUNT <= u32::MAX as usize); + assert!(POSIX_PATH_MAX_BYTES <= u32::MAX as usize); + assert!(WIRE_MAX_BYTES <= u32::MAX as usize); + } + } +} + /// Syscall numbers for the POSIX kernel interface. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u32)] @@ -458,12 +660,16 @@ pub enum ChannelStatus { impl ChannelStatus { /// Convert a raw u32 value to a ChannelStatus variant. pub fn from_u32(val: u32) -> Option { - match val { - 0 => Some(ChannelStatus::Idle), - 1 => Some(ChannelStatus::Pending), - 2 => Some(ChannelStatus::Complete), - 3 => Some(ChannelStatus::Error), - _ => None, + if val == Self::Idle as u32 { + Some(Self::Idle) + } else if val == Self::Pending as u32 { + Some(Self::Pending) + } else if val == Self::Complete as u32 { + Some(Self::Complete) + } else if val == Self::Error as u32 { + Some(Self::Error) + } else { + None } } } @@ -650,6 +856,12 @@ pub mod fcntl_cmd { pub const F_OFD_SETLKW: u32 = 38; } +/// `prctl(2)` operation constants implemented by the kernel. +pub mod prctl { + pub const PR_SET_NAME: u32 = 15; + pub const PR_GET_NAME: u32 = 16; +} + /// Lock type constants for advisory record locking. pub mod lock_type { pub const F_RDLCK: u32 = 0; @@ -695,6 +907,15 @@ pub mod socket { pub const SOCK_CLOEXEC: u32 = 0o2000000; pub const SOL_SOCKET: u32 = 1; pub const SCM_RIGHTS: u32 = 1; + /// Serialized width of one file descriptor in SCM_RIGHTS payload data. + pub const SCM_RIGHTS_FD_BYTES: usize = 4; + /// Exact iovec-record count in a nonempty flattened kernel message wire. + /// + /// WHY: public sendmsg/recvmsg still accept IOV_MAX native entries. The + /// host flattens or scatters those entries through one canonical scratch + /// iovec so Rust never interprets a caller-width table. An empty caller + /// list uses zero records; every nonempty list uses exactly this count. + pub const KERNEL_MESSAGE_WIRE_FLATTENED_IOVEC_COUNT: u32 = 1; pub const SCM_CREDENTIALS: u32 = 2; pub const SO_REUSEADDR: u32 = 2; pub const SO_ERROR: u32 = 4; @@ -772,6 +993,7 @@ pub mod socket { pub const MSG_TRUNC: u32 = 0x20; pub const MSG_DONTWAIT: u32 = 64; pub const MSG_NOSIGNAL: u32 = 0x4000; + pub const MSG_CMSG_CLOEXEC: u32 = 0x4000_0000; } /// Poll constants. @@ -854,49 +1076,125 @@ pub mod mode { /// 68 4B request flags /// 72 64KB data transfer buffer pub mod channel { + use super::kernel_scratch_wire; + use core::mem::size_of; + /// Byte offset of the status field (i32, atomic). pub const STATUS_OFFSET: usize = 0; + pub const STATUS_SIZE: usize = size_of::(); /// Byte offset of the syscall number field (i32). - pub const SYSCALL_OFFSET: usize = 4; + pub const SYSCALL_OFFSET: usize = STATUS_OFFSET + STATUS_SIZE; + pub const SYSCALL_SIZE: usize = size_of::(); /// Byte offset of the first argument slot (i64 each, 8 bytes). - pub const ARGS_OFFSET: usize = 8; + pub const ARGS_OFFSET: usize = SYSCALL_OFFSET + SYSCALL_SIZE; /// Number of argument slots. pub const ARGS_COUNT: usize = 6; /// Size of each argument slot in bytes. - pub const ARG_SIZE: usize = 8; + pub const ARG_SIZE: usize = size_of::(); /// Byte offset of the return value field (i64). - pub const RETURN_OFFSET: usize = 56; + pub const RETURN_OFFSET: usize = ARGS_OFFSET + ARGS_COUNT * ARG_SIZE; + pub const RETURN_SIZE: usize = size_of::(); /// Byte offset of the errno field (i32). - pub const ERRNO_OFFSET: usize = 64; + pub const ERRNO_OFFSET: usize = RETURN_OFFSET + RETURN_SIZE; + pub const ERRNO_SIZE: usize = size_of::(); /// Byte offset of host/process request flags (u32). - pub const REQUEST_FLAGS_OFFSET: usize = 68; + pub const REQUEST_FLAGS_OFFSET: usize = ERRNO_OFFSET + ERRNO_SIZE; + pub const REQUEST_FLAGS_SIZE: usize = size_of::(); /// The request completion is consumed by process-worker JavaScript, not /// the libc channel trampoline. Caught signals must remain kernel-pending /// until an explicit guest checkpoint can invoke the handler after the /// owning host transition returns. pub const REQUEST_FLAG_DEFER_SIGNAL_DELIVERY: u32 = 1 << 0; + /// Total header size before data buffer. + pub const HEADER_SIZE: usize = REQUEST_FLAGS_OFFSET + REQUEST_FLAGS_SIZE; /// Byte offset of the data buffer region. - pub const DATA_OFFSET: usize = 72; + pub const DATA_OFFSET: usize = HEADER_SIZE; /// Size of the data buffer. pub const DATA_SIZE: usize = 65536; - /// Total header size before data buffer. - pub const HEADER_SIZE: usize = 72; /// Minimum total size of a channel in bytes (header + 64 KiB data buffer). pub const MIN_CHANNEL_SIZE: usize = HEADER_SIZE + DATA_SIZE; - // Signal delivery area — last 48 bytes of the data buffer. + // Signal delivery area — reserved at the end of the data buffer. // After each syscall, if a signal with a Handler disposition is pending, // the kernel writes delivery info here so the glue code can invoke it. + /// Bytes populated by the kernel signal-delivery wire. + pub const SIG_DELIVERY_SIZE: usize = kernel_scratch_wire::SIGNAL_DELIVERY_BYTES as usize; + /// Keep the reserved tail naturally aligned even though the wire is packed. + pub const SIG_AREA_ALIGNMENT: usize = core::mem::align_of::(); + /// Complete reserved signal area, including any trailing alignment pad. + pub const SIG_AREA_SIZE: usize = + (SIG_DELIVERY_SIZE + SIG_AREA_ALIGNMENT - 1) / SIG_AREA_ALIGNMENT * SIG_AREA_ALIGNMENT; /// Base offset of signal delivery area. - pub const SIG_BASE: usize = DATA_OFFSET + DATA_SIZE - 48; + pub const SIG_BASE: usize = DATA_OFFSET + DATA_SIZE - SIG_AREA_SIZE; /// Signal number to deliver (u32). 0 = no signal. - pub const SIG_SIGNUM: usize = SIG_BASE; + pub const SIG_SIGNUM: usize = SIG_BASE + kernel_scratch_wire::SIGNAL_SIGNUM_OFFSET; /// Handler function table index (u32). - pub const SIG_HANDLER: usize = SIG_BASE + 4; + pub const SIG_HANDLER: usize = SIG_BASE + kernel_scratch_wire::SIGNAL_HANDLER_OFFSET; /// sa_flags from sigaction (u32). - pub const SIG_FLAGS: usize = SIG_BASE + 8; + pub const SIG_FLAGS: usize = SIG_BASE + kernel_scratch_wire::SIGNAL_FLAGS_OFFSET; + /// Raw `union sigval` payload bits (u64). + /// + /// wasm32 callers use the low 32 bits. Keeping the channel field at the + /// widest supported pointer width lets wasm64 `sival_ptr` values survive + /// delivery without narrowing. + pub const SIG_SI_VALUE: usize = SIG_BASE + kernel_scratch_wire::SIGNAL_SI_VALUE_OFFSET; /// Saved blocked mask before handler (u64, little-endian). - pub const SIG_OLD_MASK: usize = SIG_BASE + 16; + pub const SIG_OLD_MASK: usize = SIG_BASE + kernel_scratch_wire::SIGNAL_OLD_MASK_OFFSET; + /// siginfo si_code (i32). + pub const SIG_SI_CODE: usize = SIG_BASE + kernel_scratch_wire::SIGNAL_SI_CODE_OFFSET; + /// First siginfo union word: pid for ordinary signals, timer ID for SI_TIMER. + pub const SIGINFO_WORD_1: usize = SIG_BASE + kernel_scratch_wire::SIGNAL_SIGINFO_WORD_1_OFFSET; + /// Second siginfo union word: uid for ordinary signals, overrun for SI_TIMER. + pub const SIGINFO_WORD_2: usize = SIG_BASE + kernel_scratch_wire::SIGNAL_SIGINFO_WORD_2_OFFSET; + /// Alternate signal-stack pointer, or zero when no switch is needed. + pub const SIG_ALT_SP: usize = SIG_BASE + kernel_scratch_wire::SIGNAL_ALT_SP_OFFSET; + /// Alternate signal-stack size. + pub const SIG_ALT_SIZE: usize = SIG_BASE + kernel_scratch_wire::SIGNAL_ALT_SIZE_OFFSET; +} + +#[cfg(test)] +mod channel_abi_tests { + use super::{channel, kernel_scratch_wire}; + + #[test] + fn signal_delivery_wire_fits_the_reserved_channel_tail() { + assert_eq!(channel::SYSCALL_OFFSET, channel::STATUS_SIZE); + assert_eq!( + channel::ARGS_OFFSET, + channel::SYSCALL_OFFSET + channel::SYSCALL_SIZE, + ); + assert_eq!( + channel::RETURN_OFFSET, + channel::ARGS_OFFSET + channel::ARGS_COUNT * channel::ARG_SIZE, + ); + assert_eq!( + channel::ERRNO_OFFSET, + channel::RETURN_OFFSET + channel::RETURN_SIZE, + ); + assert_eq!( + channel::REQUEST_FLAGS_OFFSET, + channel::ERRNO_OFFSET + channel::ERRNO_SIZE, + ); + assert_eq!( + channel::DATA_OFFSET, + channel::REQUEST_FLAGS_OFFSET + channel::REQUEST_FLAGS_SIZE, + ); + assert_eq!( + channel::SIG_BASE + channel::SIG_AREA_SIZE, + channel::DATA_OFFSET + channel::DATA_SIZE, + ); + assert_eq!( + channel::SIG_ALT_SIZE + kernel_scratch_wire::SIGNAL_ALT_SIZE_BYTES, + channel::SIG_BASE + channel::SIG_DELIVERY_SIZE, + ); + assert_eq!( + channel::SIG_DELIVERY_SIZE, + kernel_scratch_wire::SIGNAL_DELIVERY_BYTES as usize, + ); + assert!(channel::SIG_DELIVERY_SIZE <= channel::SIG_AREA_SIZE); + assert_eq!(channel::SIG_AREA_SIZE - channel::SIG_DELIVERY_SIZE, 0); + assert_eq!(channel::SIG_BASE % channel::SIG_AREA_ALIGNMENT, 0); + } } /// Stat structure for the Wasm POSIX interface. @@ -1106,6 +1404,41 @@ pub struct KernelWaitResult { pub const KERNEL_WAIT_RESULT_SIZE: u32 = core::mem::size_of::() as u32; +/// Fixed host/kernel records borrowed through kernel-owned scratch. +/// +/// These are representation limits, not public POSIX limits. Keeping them in +/// the shared ABI source lets Rust validate every explicit export capacity and +/// lets generated host code size the matching opaque borrow without copying +/// protocol literals across languages. +pub mod kernel_scratch_wire { + use super::WasmFlock; + use core::mem::size_of; + + pub const SIGNAL_WORD_BYTES: usize = size_of::(); + pub const SIGNAL_SI_VALUE_BYTES: usize = size_of::(); + pub const SIGNAL_OLD_MASK_BYTES: usize = size_of::(); + pub const SIGNAL_ALT_SP_BYTES: usize = size_of::(); + pub const SIGNAL_ALT_SIZE_BYTES: usize = size_of::(); + pub const SIGNAL_SIGNUM_OFFSET: usize = 0; + pub const SIGNAL_HANDLER_OFFSET: usize = SIGNAL_SIGNUM_OFFSET + SIGNAL_WORD_BYTES; + pub const SIGNAL_FLAGS_OFFSET: usize = SIGNAL_HANDLER_OFFSET + SIGNAL_WORD_BYTES; + pub const SIGNAL_SI_VALUE_OFFSET: usize = SIGNAL_FLAGS_OFFSET + SIGNAL_WORD_BYTES; + pub const SIGNAL_OLD_MASK_OFFSET: usize = SIGNAL_SI_VALUE_OFFSET + SIGNAL_SI_VALUE_BYTES; + pub const SIGNAL_SI_CODE_OFFSET: usize = SIGNAL_OLD_MASK_OFFSET + SIGNAL_OLD_MASK_BYTES; + pub const SIGNAL_SIGINFO_WORD_1_OFFSET: usize = SIGNAL_SI_CODE_OFFSET + SIGNAL_WORD_BYTES; + pub const SIGNAL_SIGINFO_WORD_2_OFFSET: usize = + SIGNAL_SIGINFO_WORD_1_OFFSET + SIGNAL_WORD_BYTES; + pub const SIGNAL_ALT_SP_OFFSET: usize = SIGNAL_SIGINFO_WORD_2_OFFSET + SIGNAL_WORD_BYTES; + pub const SIGNAL_ALT_SIZE_OFFSET: usize = SIGNAL_ALT_SP_OFFSET + SIGNAL_ALT_SP_BYTES; + pub const SIGNAL_DELIVERY_BYTES: u32 = (SIGNAL_ALT_SIZE_OFFSET + SIGNAL_ALT_SIZE_BYTES) as u32; + pub const FD_PAIR_BYTES: u32 = 8; + pub const MQUEUE_NOTIFICATION_BYTES: u32 = 8; + pub const SOCKLEN_BYTES: u32 = 4; + pub const PRCTL_NAME_BYTES: u32 = 16; + pub const FCNTL_FLOCK_BYTES: u32 = size_of::() as u32; + pub const SIGNAL_MASK_BYTES: u32 = size_of::() as u32; +} + #[cfg(test)] mod wait_abi_tests { use super::{KERNEL_WAIT_RESULT_SIZE, KernelWaitResult, WASM_RUSAGE_WIRE_SIZE, WasmRusageWire}; @@ -1174,6 +1507,69 @@ pub struct WasmPollFd { pub revents: i16, } +/// Fixed u32-pointer iovec used only inside kernel-owned scratch. +/// +/// Guest wasm64 `struct iovec` is wider. The host validates and translates +/// caller-native records before Rust receives this width-independent wire. +#[repr(C)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct KernelIovecWire { + pub base: u32, + pub len: u32, +} + +/// Fixed u32-pointer `msghdr` used only inside kernel-owned scratch. +/// +/// The pointed-to name, control, iovec, and data ranges all live within the +/// same synchronously leased kernel allocation. +#[repr(C)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct KernelMsghdrWire { + pub name: u32, + pub name_len: u32, + pub iov: u32, + pub iov_len: u32, + pub control: u32, + pub control_len: u32, + pub flags: u32, +} + +/// Fixed ancillary-message header used only inside kernel-owned scratch. +/// +/// This matches the wasm32 C layout by design, but it is not a caller-native +/// structure. The host translates wasm64 headers and eight-byte CMSG +/// alignment before and after the synchronous kernel call. +#[repr(C)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct KernelCmsghdrWire { + pub cmsg_len: u32, + pub cmsg_level: u32, + pub cmsg_type: u32, +} + +/// Canonical `struct epoll_event` layout used by both Kandelo musl targets. +/// +/// The C ABI aligns `epoll_data_t` to eight bytes on wasm32 and wasm64, so +/// bytes 4..8 are padding. Keep this explicit: treating the record as packed +/// changes both its stride and the location of `data`. +#[repr(C)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct WasmEpollEvent { + pub events: u32, + pub _pad: u32, + pub data: u64, +} + +/// Fixed kernel-scratch header for System V message payloads. +/// +/// Guest `long` is four bytes on wasm32 and eight on wasm64. The host converts +/// either native prefix to this width-independent record before invoking Rust. +#[repr(C)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct WasmSysvMessageHeader { + pub mtype: i64, +} + /// Statfs structure for the Wasm POSIX interface. /// /// Uses `repr(C)` for a stable, predictable memory layout matching @@ -1195,6 +1591,68 @@ pub struct WasmStatfs { pub _pad: u32, } +#[cfg(test)] +mod native_wire_layout_tests { + use super::{ + KernelCmsghdrWire, KernelIovecWire, KernelMsghdrWire, WasmEpollEvent, WasmFlock, + WasmSysvMessageHeader, kernel_scratch_wire, prctl, + }; + use core::mem::{align_of, offset_of, size_of}; + + #[test] + fn epoll_event_layout_matches_both_kandelo_musl_targets() { + assert_eq!(size_of::(), 16); + assert_eq!(offset_of!(WasmEpollEvent, events), 0); + assert_eq!(offset_of!(WasmEpollEvent, _pad), 4); + assert_eq!(offset_of!(WasmEpollEvent, data), 8); + } + + #[test] + fn sysv_message_header_is_one_canonical_i64() { + assert_eq!(size_of::(), 8); + assert_eq!(offset_of!(WasmSysvMessageHeader, mtype), 0); + } + + #[test] + fn kernel_socket_scratch_wires_use_fixed_u32_fields() { + assert_eq!(size_of::(), 8); + assert_eq!(align_of::(), 4); + assert_eq!(offset_of!(KernelIovecWire, base), 0); + assert_eq!(offset_of!(KernelIovecWire, len), 4); + + assert_eq!(size_of::(), 28); + assert_eq!(align_of::(), 4); + assert_eq!(offset_of!(KernelMsghdrWire, name), 0); + assert_eq!(offset_of!(KernelMsghdrWire, name_len), 4); + assert_eq!(offset_of!(KernelMsghdrWire, iov), 8); + assert_eq!(offset_of!(KernelMsghdrWire, iov_len), 12); + assert_eq!(offset_of!(KernelMsghdrWire, control), 16); + assert_eq!(offset_of!(KernelMsghdrWire, control_len), 20); + assert_eq!(offset_of!(KernelMsghdrWire, flags), 24); + + assert_eq!(size_of::(), 12); + assert_eq!(align_of::(), 4); + assert_eq!(offset_of!(KernelCmsghdrWire, cmsg_len), 0); + assert_eq!(offset_of!(KernelCmsghdrWire, cmsg_level), 4); + assert_eq!(offset_of!(KernelCmsghdrWire, cmsg_type), 8); + } + + #[test] + fn special_scratch_contracts_derive_record_sizes_once() { + assert_eq!(prctl::PR_SET_NAME, 15); + assert_eq!(prctl::PR_GET_NAME, 16); + assert_eq!(kernel_scratch_wire::PRCTL_NAME_BYTES, 16); + assert_eq!( + kernel_scratch_wire::FCNTL_FLOCK_BYTES, + size_of::() as u32, + ); + assert_eq!( + kernel_scratch_wire::SIGNAL_MASK_BYTES, + size_of::() as u32, + ); + } +} + /// Process memory layout ABI metadata. /// /// Rust owns this declaration so the structural ABI snapshot, generated host @@ -2303,6 +2761,7 @@ pub mod abi { pub const HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS: &[&str] = &[ "__abi_version", "kernel_alloc_scratch", + "kernel_clear_process_metadata", "kernel_create_process", "kernel_create_process_with_stdio", "kernel_dequeue_signal", @@ -2321,13 +2780,25 @@ pub mod abi { "kernel_ipc_shmdt_for_process", "kernel_ipc_shmdt_for_task", "kernel_mark_process_signaled", + "kernel_msqid_ds_bytes", "kernel_pipe_has_readers", "kernel_posix_timer_fire", "kernel_prepare_write_operation", + "kernel_push_process_metadata_entry", "kernel_reap_exited_child", "kernel_remove_process", + "kernel_semctl_array_bytes", + "kernel_semid_ds_bytes", "kernel_set_current_tid", + "kernel_set_cwd", + "kernel_shmid_ds_bytes", "kernel_spawn_process", + "kernel_spawn_reserved_process", + "kernel_spawn_scratch_begin", + "kernel_spawn_scratch_cancel", + "kernel_spawn_scratch_capacity", + "kernel_spawn_scratch_pointer", + "kernel_spawn_scratch_retained_capacity", "kernel_thread_exit", "kernel_validate_task", "kernel_wait_child_poll", @@ -2336,10 +2807,8 @@ pub mod abi { pub const HOST_ADAPTER_OPTIONAL_KERNEL_EXPORTS: &[&str] = &[ "kernel_reserve_host_region", "kernel_reserve_host_region_at", - "kernel_set_cwd", "kernel_set_max_addr", "kernel_set_mmap_base", - "kernel_set_process_argv", ]; pub static HOST_ADAPTER_MANIFEST: HostAdapterManifest = HostAdapterManifest { @@ -2380,6 +2849,7 @@ pub mod abi { pub const SYS_CLONE: u32 = 201; pub const SYS_GETTID: u32 = 202; pub const SYS_SET_TID_ADDRESS: u32 = 203; + pub const SYS_TKILL: u32 = 204; pub const SYS_RT_SIGQUEUEINFO: u32 = 205; pub const SYS_RT_SIGPENDING: u32 = 206; pub const SYS_RT_SIGTIMEDWAIT: u32 = 207; @@ -2394,26 +2864,40 @@ pub mod abi { pub const SYS_CLOCK_SETTIME: u32 = 226; pub const SYS_SCHED_YIELD: u32 = 229; pub const SYS_SCHED_GETPARAM: u32 = 230; + pub const SYS_SCHED_SETPARAM: u32 = 231; + pub const SYS_SCHED_SETSCHEDULER: u32 = 233; pub const SYS_SCHED_RR_GET_INTERVAL: u32 = 236; pub const SYS_SCHED_GETAFFINITY: u32 = 238; pub const SYS_EPOLL_CREATE1: u32 = 239; pub const SYS_EPOLL_CTL: u32 = 240; pub const SYS_EPOLL_PWAIT: u32 = 241; + pub const SYS_TIMERFD_CREATE: u32 = 243; + pub const SYS_TIMERFD_SETTIME: u32 = 244; + pub const SYS_TIMERFD_GETTIME: u32 = 245; + pub const SYS_SIGNALFD4: u32 = 246; pub const SYS_PRLIMIT64: u32 = 250; pub const SYS_PPOLL: u32 = 251; pub const SYS_PSELECT6: u32 = 252; + pub const SYS_MEMFD_CREATE: u32 = 256; pub const SYS_STATX: u32 = 260; pub const SYS_SET_ROBUST_LIST: u32 = 261; pub const SYS_GET_ROBUST_LIST: u32 = 262; + pub const SYS_SYSINFO: u32 = 269; pub const SYS_MKNOD: u32 = 271; pub const SYS_MKNODAT: u32 = 272; pub const SYS_MSYNC: u32 = 278; pub const SYS_WAITID: u32 = 288; + pub const SYS_COPY_FILE_RANGE: u32 = 290; + pub const SYS_SPLICE: u32 = 291; pub const SYS_SENDFILE: u32 = 294; pub const SYS_PREADV: u32 = 295; pub const SYS_PWRITEV: u32 = 296; + pub const SYS_PREADV2: u32 = 297; + pub const SYS_PWRITEV2: u32 = 298; pub const SYS_LCHOWN: u32 = 299; + pub const SYS_RENAMEAT2: u32 = 306; pub const SYS_FALLOCATE: u32 = 308; + pub const SYS_GETCPU: u32 = 325; pub const SYS_TIMER_CREATE: u32 = 326; pub const SYS_TIMER_SETTIME: u32 = 327; pub const SYS_TIMER_GETTIME: u32 = 328; @@ -2436,6 +2920,7 @@ pub mod abi { pub const SYS_SHMAT: u32 = 345; pub const SYS_SHMDT: u32 = 346; pub const SYS_SHMCTL: u32 = 347; + pub const SYS_SIGNALFD: u32 = 377; pub const SYS_EPOLL_CREATE: u32 = 378; pub const SYS_EPOLL_WAIT: u32 = 379; pub const SYS_FACCESSAT2: u32 = 382; @@ -2473,6 +2958,10 @@ pub mod abi { name: "SetTidAddress", number: SYS_SET_TID_ADDRESS, }, + AbiSyscallNumber { + name: "Tkill", + number: SYS_TKILL, + }, AbiSyscallNumber { name: "RtSigqueueinfo", number: SYS_RT_SIGQUEUEINFO, @@ -2529,6 +3018,14 @@ pub mod abi { name: "SchedGetparam", number: SYS_SCHED_GETPARAM, }, + AbiSyscallNumber { + name: "SchedSetparam", + number: SYS_SCHED_SETPARAM, + }, + AbiSyscallNumber { + name: "SchedSetscheduler", + number: SYS_SCHED_SETSCHEDULER, + }, AbiSyscallNumber { name: "SchedRrGetInterval", number: SYS_SCHED_RR_GET_INTERVAL, @@ -2549,6 +3046,22 @@ pub mod abi { name: "EpollPwait", number: SYS_EPOLL_PWAIT, }, + AbiSyscallNumber { + name: "TimerfdCreate", + number: SYS_TIMERFD_CREATE, + }, + AbiSyscallNumber { + name: "TimerfdSettime", + number: SYS_TIMERFD_SETTIME, + }, + AbiSyscallNumber { + name: "TimerfdGettime", + number: SYS_TIMERFD_GETTIME, + }, + AbiSyscallNumber { + name: "Signalfd4", + number: SYS_SIGNALFD4, + }, AbiSyscallNumber { name: "Prlimit64", number: SYS_PRLIMIT64, @@ -2561,6 +3074,10 @@ pub mod abi { name: "Pselect6", number: SYS_PSELECT6, }, + AbiSyscallNumber { + name: "MemfdCreate", + number: SYS_MEMFD_CREATE, + }, AbiSyscallNumber { name: "Statx", number: SYS_STATX, @@ -2573,6 +3090,10 @@ pub mod abi { name: "GetRobustList", number: SYS_GET_ROBUST_LIST, }, + AbiSyscallNumber { + name: "Sysinfo", + number: SYS_SYSINFO, + }, AbiSyscallNumber { name: "Mknod", number: SYS_MKNOD, @@ -2589,6 +3110,14 @@ pub mod abi { name: "Waitid", number: SYS_WAITID, }, + AbiSyscallNumber { + name: "CopyFileRange", + number: SYS_COPY_FILE_RANGE, + }, + AbiSyscallNumber { + name: "Splice", + number: SYS_SPLICE, + }, AbiSyscallNumber { name: "Sendfile", number: SYS_SENDFILE, @@ -2601,14 +3130,30 @@ pub mod abi { name: "Pwritev", number: SYS_PWRITEV, }, + AbiSyscallNumber { + name: "Preadv2", + number: SYS_PREADV2, + }, + AbiSyscallNumber { + name: "Pwritev2", + number: SYS_PWRITEV2, + }, AbiSyscallNumber { name: "Lchown", number: SYS_LCHOWN, }, + AbiSyscallNumber { + name: "Renameat2", + number: SYS_RENAMEAT2, + }, AbiSyscallNumber { name: "Fallocate", number: SYS_FALLOCATE, }, + AbiSyscallNumber { + name: "Getcpu", + number: SYS_GETCPU, + }, AbiSyscallNumber { name: "TimerCreate", number: SYS_TIMER_CREATE, @@ -2697,6 +3242,10 @@ pub mod abi { name: "Shmctl", number: SYS_SHMCTL, }, + AbiSyscallNumber { + name: "Signalfd", + number: SYS_SIGNALFD, + }, AbiSyscallNumber { name: "EpollCreate", number: SYS_EPOLL_CREATE, @@ -3456,9 +4005,15 @@ pub mod dri { /// `_IOWR('d', 0x00, drm_version)` — driver name / date / desc query. /// `struct drm_version` is 36 bytes on wasm32 (ilp32: 3 × `int` + 3 × /// `__kernel_size_t` + 3 × `char *`, all 4-byte). Ioctl number encodes - /// 36 → `0xc0246400`. Linux x86_64's 60-byte layout is not us. + /// 36 → `0xc0246400`. pub const DRM_IOCTL_VERSION: u32 = 0xc024_6400; + /// Native wasm64 `_IOWR('d', 0x00, drm_version)`. + /// + /// wasm64 follows the 64-bit Linux UAPI layout: three `int` fields, + /// four bytes of alignment, then three `(size_t, pointer)` pairs. + pub const DRM_IOCTL_VERSION_WASM64: u32 = 0xc040_6400; + /// `_IOWR('d', 0x0c, drm_get_cap)` — feature capability query. pub const DRM_IOCTL_GET_CAP: u32 = 0xc010_640c; @@ -3879,6 +4434,7 @@ mod dri_tests { DRM_IOCTL_VERSION, ioc(iowr, 'd' as u32, 0x00, size_of::() as u32) ); + assert_eq!(DRM_IOCTL_VERSION_WASM64, ioc(iowr, 'd' as u32, 0x00, 64)); assert_eq!( DRM_IOCTL_GET_CAP, ioc(iowr, 'd' as u32, 0x0c, size_of::() as u32) diff --git a/crates/shared/src/process_layout.rs b/crates/shared/src/process_layout.rs new file mode 100644 index 0000000000..23ecd58b8b --- /dev/null +++ b/crates/shared/src/process_layout.rs @@ -0,0 +1,277 @@ +//! Native musl layouts selected by the calling process's data model. +//! +//! One kernel Wasm instance may serve both wasm32 and wasm64 processes. These +//! layouts therefore must never be selected from the kernel Wasm's own target +//! width. The host carries the process pointer width in the private sixth +//! channel argument for syscalls whose native structures differ. + +/// wasm32 process pointers and C `long` values are four bytes. +pub const WASM32_POINTER_WIDTH: u32 = 4; +/// wasm64 process pointers and C `long` values are eight bytes. +pub const WASM64_POINTER_WIDTH: u32 = 8; + +/// Return a target-dependent value for a supported process pointer width. +pub const fn select(pointer_width: u32, wasm32: u32, wasm64: u32) -> Option { + match pointer_width { + WASM32_POINTER_WIDTH => Some(wasm32), + WASM64_POINTER_WIDTH => Some(wasm64), + _ => None, + } +} + +/// Caller-native musl `struct iovec`. +/// +/// This is deliberately distinct from the kernel-scratch [`KernelIovecWire`] +/// record. A single wasm32 kernel can serve wasm32 and wasm64 callers, so the +/// host must decode the caller-native table before constructing the fixed +/// kernel wire. +/// +/// [`KernelIovecWire`]: crate::KernelIovecWire +pub mod iovec { + pub const WASM32_SIZE: u32 = 8; + pub const WASM32_BASE_OFFSET: u32 = 0; + pub const WASM32_LEN_OFFSET: u32 = 4; + + pub const WASM64_SIZE: u32 = 16; + pub const WASM64_BASE_OFFSET: u32 = 0; + pub const WASM64_LEN_OFFSET: u32 = 8; +} + +/// Caller-native musl `struct msghdr`. +/// +/// musl keeps `msg_iovlen` and `msg_controllen` 32-bit on wasm64 and places +/// explicit little-endian padding after each field. These offsets therefore +/// cannot be derived from pointer width alone at a TypeScript call site. +pub mod msghdr { + pub const WASM32_SIZE: u32 = 28; + pub const WASM32_NAME_OFFSET: u32 = 0; + pub const WASM32_NAMELEN_OFFSET: u32 = 4; + pub const WASM32_IOV_OFFSET: u32 = 8; + pub const WASM32_IOVLEN_OFFSET: u32 = 12; + pub const WASM32_CONTROL_OFFSET: u32 = 16; + pub const WASM32_CONTROLLEN_OFFSET: u32 = 20; + pub const WASM32_FLAGS_OFFSET: u32 = 24; + + pub const WASM64_SIZE: u32 = 56; + pub const WASM64_NAME_OFFSET: u32 = 0; + pub const WASM64_NAMELEN_OFFSET: u32 = 8; + pub const WASM64_IOV_OFFSET: u32 = 16; + pub const WASM64_IOVLEN_OFFSET: u32 = 24; + pub const WASM64_CONTROL_OFFSET: u32 = 32; + pub const WASM64_CONTROLLEN_OFFSET: u32 = 40; + pub const WASM64_FLAGS_OFFSET: u32 = 48; +} + +/// Caller-native musl `struct cmsghdr` and CMSG record alignment. +/// +/// The wasm64 header has a four-byte pad after `cmsg_len`, and successive +/// records are aligned to eight bytes. Kernel scratch instead uses the fixed +/// [`KernelCmsghdrWire`] layout, so host translation must use these generated +/// values in both directions. +/// +/// [`KernelCmsghdrWire`]: crate::KernelCmsghdrWire +pub mod cmsghdr { + pub const WASM32_SIZE: u32 = 12; + pub const WASM32_ALIGN: u32 = 4; + pub const WASM32_LEN_OFFSET: u32 = 0; + pub const WASM32_LEVEL_OFFSET: u32 = 4; + pub const WASM32_TYPE_OFFSET: u32 = 8; + pub const WASM32_DATA_OFFSET: u32 = 12; + + pub const WASM64_SIZE: u32 = 16; + pub const WASM64_ALIGN: u32 = 8; + pub const WASM64_LEN_OFFSET: u32 = 0; + pub const WASM64_LEVEL_OFFSET: u32 = 8; + pub const WASM64_TYPE_OFFSET: u32 = 12; + pub const WASM64_DATA_OFFSET: u32 = 16; +} + +/// `stack_t` / `struct sigaltstack`. +pub mod sigaltstack { + pub const WASM32_SIZE: u32 = 12; + pub const WASM32_SP_OFFSET: u32 = 0; + pub const WASM32_FLAGS_OFFSET: u32 = 4; + pub const WASM32_STACK_SIZE_OFFSET: u32 = 8; + + pub const WASM64_SIZE: u32 = 24; + pub const WASM64_SP_OFFSET: u32 = 0; + pub const WASM64_FLAGS_OFFSET: u32 = 8; + pub const WASM64_STACK_SIZE_OFFSET: u32 = 16; +} + +/// Kernel-facing `setitimer`/`getitimer` record. +/// +/// wasm32 musl deliberately translates its public 32-byte time64 +/// `struct itimerval` to the kernel's historical four-`long` time32 record. +/// wasm64 has no translation and sends its native four-i64 record directly. +pub mod itimerval { + pub const WASM32_SIZE: u32 = 16; + pub const WASM64_SIZE: u32 = 32; + + pub const INTERVAL_SEC_INDEX: u32 = 0; + pub const INTERVAL_USEC_INDEX: u32 = 1; + pub const VALUE_SEC_INDEX: u32 = 2; + pub const VALUE_USEC_INDEX: u32 = 3; +} + +/// Native POSIX message-queue attributes. +pub mod mq_attr { + pub const WASM32_SIZE: u32 = 32; + pub const WASM32_FLAGS_OFFSET: u32 = 0; + pub const WASM32_MAXMSG_OFFSET: u32 = 4; + pub const WASM32_MSGSIZE_OFFSET: u32 = 8; + pub const WASM32_CURMSGS_OFFSET: u32 = 12; + + pub const WASM64_SIZE: u32 = 64; + pub const WASM64_FLAGS_OFFSET: u32 = 0; + pub const WASM64_MAXMSG_OFFSET: u32 = 8; + pub const WASM64_MSGSIZE_OFFSET: u32 = 16; + pub const WASM64_CURMSGS_OFFSET: u32 = 24; +} + +/// Native `struct sigevent` used by `mq_notify` and `timer_create`. +pub mod sigevent { + pub const WASM32_SIZE: u32 = 64; + pub const WASM32_VALUE_OFFSET: u32 = 0; + pub const WASM32_VALUE_SIZE: u32 = 4; + pub const WASM32_SIGNO_OFFSET: u32 = 4; + pub const WASM32_NOTIFY_OFFSET: u32 = 8; + pub const WASM32_PAYLOAD_OFFSET: u32 = 12; + + pub const WASM64_SIZE: u32 = 64; + pub const WASM64_VALUE_OFFSET: u32 = 0; + pub const WASM64_VALUE_SIZE: u32 = 8; + pub const WASM64_SIGNO_OFFSET: u32 = 8; + pub const WASM64_NOTIFY_OFFSET: u32 = 12; + pub const WASM64_PAYLOAD_OFFSET: u32 = 16; +} + +/// Native musl `struct statfs`. +pub mod statfs { + pub const WASM32_SIZE: u32 = 88; + pub const WASM32_TYPE_OFFSET: u32 = 0; + pub const WASM32_BSIZE_OFFSET: u32 = 4; + pub const WASM32_BLOCKS_OFFSET: u32 = 8; + pub const WASM32_BFREE_OFFSET: u32 = 16; + pub const WASM32_BAVAIL_OFFSET: u32 = 24; + pub const WASM32_FILES_OFFSET: u32 = 32; + pub const WASM32_FFREE_OFFSET: u32 = 40; + pub const WASM32_FSID_OFFSET: u32 = 48; + pub const WASM32_NAMELEN_OFFSET: u32 = 56; + pub const WASM32_FRSIZE_OFFSET: u32 = 60; + pub const WASM32_FLAGS_OFFSET: u32 = 64; + pub const WASM32_SPARE_OFFSET: u32 = 68; + + pub const WASM64_SIZE: u32 = 120; + pub const WASM64_TYPE_OFFSET: u32 = 0; + pub const WASM64_BSIZE_OFFSET: u32 = 8; + pub const WASM64_BLOCKS_OFFSET: u32 = 16; + pub const WASM64_BFREE_OFFSET: u32 = 24; + pub const WASM64_BAVAIL_OFFSET: u32 = 32; + pub const WASM64_FILES_OFFSET: u32 = 40; + pub const WASM64_FFREE_OFFSET: u32 = 48; + pub const WASM64_FSID_OFFSET: u32 = 56; + pub const WASM64_NAMELEN_OFFSET: u32 = 64; + pub const WASM64_FRSIZE_OFFSET: u32 = 72; + pub const WASM64_FLAGS_OFFSET: u32 = 80; + pub const WASM64_SPARE_OFFSET: u32 = 88; +} + +/// Native Linux-compatible `struct sysinfo`. +pub mod sysinfo { + pub const WASM32_SIZE: u32 = 312; + pub const WASM32_UPTIME_OFFSET: u32 = 0; + pub const WASM32_LOADS_OFFSET: u32 = 4; + pub const WASM32_TOTALRAM_OFFSET: u32 = 16; + pub const WASM32_FREERAM_OFFSET: u32 = 20; + pub const WASM32_SHAREDRAM_OFFSET: u32 = 24; + pub const WASM32_BUFFERRAM_OFFSET: u32 = 28; + pub const WASM32_TOTALSWAP_OFFSET: u32 = 32; + pub const WASM32_FREESWAP_OFFSET: u32 = 36; + pub const WASM32_PROCS_OFFSET: u32 = 40; + pub const WASM32_TOTALHIGH_OFFSET: u32 = 44; + pub const WASM32_FREEHIGH_OFFSET: u32 = 48; + pub const WASM32_MEM_UNIT_OFFSET: u32 = 52; + pub const WASM32_RESERVED_OFFSET: u32 = 56; + + pub const WASM64_SIZE: u32 = 368; + pub const WASM64_UPTIME_OFFSET: u32 = 0; + pub const WASM64_LOADS_OFFSET: u32 = 8; + pub const WASM64_TOTALRAM_OFFSET: u32 = 32; + pub const WASM64_FREERAM_OFFSET: u32 = 40; + pub const WASM64_SHAREDRAM_OFFSET: u32 = 48; + pub const WASM64_BUFFERRAM_OFFSET: u32 = 56; + pub const WASM64_TOTALSWAP_OFFSET: u32 = 64; + pub const WASM64_FREESWAP_OFFSET: u32 = 72; + pub const WASM64_PROCS_OFFSET: u32 = 80; + pub const WASM64_TOTALHIGH_OFFSET: u32 = 88; + pub const WASM64_FREEHIGH_OFFSET: u32 = 96; + pub const WASM64_MEM_UNIT_OFFSET: u32 = 104; + pub const WASM64_RESERVED_OFFSET: u32 = 108; +} + +/// Caller-native `siginfo_t` used by signal queue, wait, and delivery paths. +/// +/// Both targets reserve 128 bytes, but LP64 alignment moves the common +/// pid/uid/value-or-status fields. The full record size is part of every copy +/// contract so no caller bytes outside `siginfo_t` enter kernel scratch and no +/// host write can replace bytes beyond the caller-owned object. +pub mod rt_sigqueueinfo { + pub const SIGNO_OFFSET: u32 = 0; + pub const ERRNO_OFFSET: u32 = 4; + pub const CODE_OFFSET: u32 = 8; + + pub const WASM32_SIZE: u32 = 128; + pub const WASM32_PID_OFFSET: u32 = 12; + pub const WASM32_UID_OFFSET: u32 = 16; + pub const WASM32_VALUE_OFFSET: u32 = 20; + pub const WASM32_VALUE_SIZE: u32 = 4; + + pub const WASM64_SIZE: u32 = 128; + pub const WASM64_PID_OFFSET: u32 = 16; + pub const WASM64_UID_OFFSET: u32 = 20; + pub const WASM64_VALUE_OFFSET: u32 = 24; + pub const WASM64_VALUE_SIZE: u32 = 8; +} + +/// Native musl `struct kstat` used by stat/fstat/lstat/fstatat syscalls. +/// +/// This is deliberately separate from the kernel's 88-byte [`crate::WasmStat`] +/// host-import record. The guest record includes the otherwise-zero rdev, +/// block-size, and block-count fields, and every byte is initialized before +/// the host copies it back to process memory. +pub mod stat { + pub const SIZE: u32 = 112; + pub const DEV_OFFSET: u32 = 0; + pub const INO_OFFSET: u32 = 8; + pub const MODE_OFFSET: u32 = 16; + pub const NLINK_OFFSET: u32 = 20; + pub const UID_OFFSET: u32 = 24; + pub const GID_OFFSET: u32 = 28; + pub const SIZE_OFFSET: u32 = 32; + pub const ATIME_SEC_OFFSET: u32 = 40; + pub const ATIME_NSEC_OFFSET: u32 = 48; + pub const MTIME_SEC_OFFSET: u32 = 56; + pub const MTIME_NSEC_OFFSET: u32 = 64; + pub const CTIME_SEC_OFFSET: u32 = 72; + pub const CTIME_NSEC_OFFSET: u32 = 80; + pub const RDEV_OFFSET: u32 = 88; + pub const BLKSIZE_OFFSET: u32 = 96; + pub const BLOCKS_OFFSET: u32 = 104; +} + +/// Native POSIX `struct sched_param`. +/// +/// Kandelo exposes the POSIX sporadic-server fields even though its current +/// SCHED_OTHER implementation returns an all-zero record. `timespec` is +/// 16 bytes on both declared targets, so the complete structure is 48 bytes. +pub mod sched_param { + pub const SIZE: u32 = 48; + pub const PRIORITY_OFFSET: u32 = 0; + pub const SS_MAX_REPL_OFFSET: u32 = 4; + pub const SS_REPL_PERIOD_SEC_OFFSET: u32 = 8; + pub const SS_REPL_PERIOD_NSEC_OFFSET: u32 = 16; + pub const SS_INIT_BUDGET_SEC_OFFSET: u32 = 24; + pub const SS_INIT_BUDGET_NSEC_OFFSET: u32 = 32; + pub const SS_LOW_PRIORITY_OFFSET: u32 = 40; +} diff --git a/docs/abi-versioning.md b/docs/abi-versioning.md index 819aa51e76..7717bb5734 100644 --- a/docs/abi-versioning.md +++ b/docs/abi-versioning.md @@ -254,10 +254,11 @@ while fixed kernels return through their shadow-stack epilogue. All host-initiated guest mutations that previously depended on such a selector now carry their authority explicitly. `kernel_dequeue_signal(pid, tid, -out_ptr)`, `kernel_wait_child_poll(parent_pid, caller_tid, target_pid, -event_mask, flags, out_ptr)`, and `kernel_prepare_write_operation(pid, tid, -fd, offset, len, positioned)` validate the exact live caller before consuming -signal or wait state or applying write-limit side effects. Guest SysV shared +out_ptr, out_capacity)`, `kernel_wait_child_poll(parent_pid, caller_tid, +target_pid, event_mask, flags, out_ptr, out_capacity)`, and +`kernel_prepare_write_operation(pid, tid, fd, offset, len, positioned)` +validate the exact live caller before consuming signal or wait state or +applying write-limit side effects. Guest SysV shared memory calls use `kernel_ipc_shmat_for_task(pid, tid, ...)` and `kernel_ipc_shmdt_for_task(pid, tid, ...)`; lifecycle-only inheritance, rollback, and teardown use the separate explicit-process @@ -311,10 +312,12 @@ original `fork()` call without terminating the parent. ### ABI 43 activation-owned fork replay -ABI 43 closes the remaining dependency on mutable state in the parent Wasm -instance. A fork child receives copied linear memory but a newly instantiated -module, globals, tables, exception tags, and host Store. Module-static -reference tables therefore cannot prove that a replay value survived fork. +ABI 43 batches two incompatible platform contracts: activation-owned fork +replay and capacity-bound kernel scratch transfers. The replay contract closes +the remaining dependency on mutable state in the parent Wasm instance. A fork +child receives copied linear memory but a newly instantiated module, globals, +tables, exception tags, and host Store. Module-static reference tables +therefore cannot prove that a replay value survived fork. Every ABI 43 fork artifact carries the version-1 `kandelo.wpk_fork.capabilities` section with @@ -412,6 +415,168 @@ rootfs/image, and Homebrew sequencing and isolation boundary is recorded in the [ABI 43 activation-state-safe artifact rebuild plan](plans/2026-07-25-abi-43-activation-state-safe-rebuild-plan.md). +### ABI 43 capacity-bound kernel scratch transfers + +PR #1097 merged as +`c7d039794a43788acfa0b0aea30a700c257f57cb` with ABI 42, and this work is +based on that exact merged result. ABI 43 is therefore required for the actual +incompatible export and wire changes below, including the added +`kernel_wait_child_poll` output-capacity argument. The version change is not +bookkeeping for generated constants. Exact final-head validation is a PR +readiness gate recorded with the commit SHA it actually exercised; this section +records the durable ABI contract rather than a mutable readiness claim. + +ABI 43 makes variable-size host writes into reusable kernel scratch an +explicit ownership protocol. A host write is valid only after it +independently proves the caller source range, the kernel-owned destination +allocation, the allocation's declared capacity, the current kernel-memory +range, the allocation lifetime, exclusion of overlapping replacement, and +lossless wasm32/wasm64 pointer conversion. The fact that a destination range +fits somewhere in the kernel's total WebAssembly linear memory does not prove +that the Rust allocator assigned those bytes to the destination object. + +Ordinary channel-sized transfers carry the kernel pointer and capacity +together in a host-side `KernelScratchRegion` and can be accessed only through +a synchronous lease. The `kernel_handle_channel` export now takes +`(channel_offset, channel_capacity, pid)`; Rust rejects a capacity other than +the canonical complete channel allocation before decoding it. This signature +change is incompatible with an ABI-42 host or kernel. + +Every generated pointer descriptor is explicitly and exclusively `required` +or `nullable`. Positive-extent null pointers fail unless the shared descriptor +permits null; an argument-sized null pointer with zero extent is canonicalized +to an allocator-owned empty range. The host pre-captures every caller-owned +`u32` used by a `Deref` size before planning any suballocation, then uses that +one value for both the dynamic buffer and its staged length record. Rust +validates the canonical ordered, aligned, non-overlapping descriptor layout +and the complete allocation range before dispatch. Because the generic wire +does not encode an unpadded capacity beside every descriptor, Rust cannot +independently detect a hypothetical staged-length change that stays within one +eight-byte alignment bucket; the exact capacity comes from the host's +pre-captured value under the single synchronous, non-reentrant lease. Adding a +second per-descriptor capacity would itself be a future ABI design change. + +`prctl` deliberately has no generic pointer descriptor. Only `PR_SET_NAME` and +`PR_GET_NAME` interpret argument 1 as a required exact 16-byte scratch buffer; +other options preserve its low 32-bit scalar value. Treating that slot as one +shape for every option would either dereference a scalar or replace it with an +unrelated scratch pointer. + +Large `SYS_SPAWN` blobs use a Rust-owned reusable +`Vec` with a tokenized transaction: + +1. `kernel_spawn_scratch_begin(minimum_capacity)` returns a fresh positive + reservation token or a negated errno. Begin is nonblocking; mutex + contention returns `EBUSY`. +2. `kernel_spawn_scratch_pointer(token)` and + `kernel_spawn_scratch_capacity(token)` are read after begin; both return + zero for a stale or non-current token or for mutex contention. The separate + pointer-free + `kernel_spawn_scratch_retained_capacity()` export reports the retained + high-water allocation for diagnostics without granting write authority and + likewise returns zero on contention. +3. The host proves the complete pointer-plus-capacity range and copies without + yielding. +4. `kernel_spawn_reserved_process(parent_pid, caller_tid, token, blob_len)` + consumes that exact token, parses into Rust-owned data, and releases the + scratch lock before process-table work or host imports. +5. After every successful begin, the host calls + `kernel_spawn_scratch_cancel(token)` in a `finally` block, including setup + and copy failures. Success releases an unconsumed matching token; `EINVAL` + means the never-reused token was already consumed or is stale. For the + just-issued in-contract token after commit, the consumed case is expected. + Commit and cancellation wait on the same no-host-import critical section, + so both return with a definitive token state instead of stranding authority + on transient contention. + +Every large operation begins a new reservation even when the retained vector +already has enough capacity. Stale tokens, concurrent reservations, and +reentrant host operations cannot replace bytes being consumed. The previous +pointer-returning `kernel_spawn_scratch_reserve` interface and fixed +worst-case compatibility fallback are not part of ABI 43. + +ABI 43 also makes System V IPC control-structure sizing explicit. Required +pointer-width queries report the target musl layouts: `msqid_ds` is 96 bytes +on wasm32 time64 and 120 bytes on wasm64 LP64, `semid_ds` is 72/88 bytes, and +`shmid_ds` is 88/112 bytes. The process width is authoritative even when it +differs from the kernel Wasm width. The host stages `msgctl`/`shmctl` +`IPC_STAT` and `IPC_SET` according to the command and carries that width in its +private sixth kernel-dispatch slot. The required +`kernel_semctl_array_bytes(pid, tid, semid, command)` export performs the +permission-aware GETALL/SETALL size preflight; the host does not substitute a +read-only `IPC_STAT` query for a write-only SETALL operation. + +Generated process-layout descriptors apply the same caller-width rule to +`stack_t` (12/24 bytes), the kernel-facing four-native-`long` `itimerval` +(16/32), `mq_attr` (32/64), `sigevent` (64/64), `statfs` (88/120), and +`sysinfo` (312/368), and `siginfo_t` for `rt_sigqueueinfo` (128/128). The host +stages exactly the selected record and carries the process width in its +private sixth dispatch slot. Rust rejects any other width and parses or +serializes the exact bounded slice; padding and reserved output bytes are +initialized. This prevents the kernel Wasm's own wasm32 data model from +truncating a wasm64 process record. Fixed generated descriptors separately +carry `stat` (112 bytes) and `sched_param` (48 bytes); those records do not use +width selection or the private process-width slot. + +Signal and timer transport also change incompatibly in ABI 43. The +`kernel_timer_create` export grows from three arguments to +`(clock_id, sigevent_ptr, timerid_ptr, process_pointer_width)`, and its second +argument names the complete generated caller-native 64-byte `sigevent` instead +of a private four-`i32` prefix. The channel signal-delivery record grows from +44 to 56 bytes, while its reserved area grows from 48 to 56 bytes. Its +`si_value` slot is an unaligned eight-byte raw `union sigval`; wasm64 delivery +preserves all bits and wasm32 delivery uses the target-native low 32 bits. +`kernel_dequeue_signal` and `kernel_wait_child_poll` each gain an explicit +output-capacity argument so validation happens before either operation consumes +kernel state. POSIX message-queue notification now queues the authoritative +`SI_MESGQ` record in Rust, including the full raw value and sender credentials; +the eight-byte host record only tells the host which task to wake. These are +observable export and wire changes, not generation-only bookkeeping. + +The ABI 43 required host-adapter export set retains the ABI 42-required +`kernel_spawn_process` and adds +`kernel_clear_process_metadata`, +`kernel_msqid_ds_bytes`, `kernel_semctl_array_bytes`, +`kernel_semid_ds_bytes`, `kernel_shmid_ds_bytes`, +`kernel_push_process_metadata_entry`, `kernel_set_cwd`, +`kernel_spawn_reserved_process`, +`kernel_spawn_scratch_begin`, +`kernel_spawn_scratch_cancel`, `kernel_spawn_scratch_capacity`, +`kernel_spawn_scratch_pointer`, and +`kernel_spawn_scratch_retained_capacity`. The required capabilities and large-spawn +semantics changed, so this is incompatible rather than bookkeeping around +additive constants. Kernels, hosts, packages, guest binaries, and VFS images +from ABI 42 must be rebuilt rather than mixed with ABI 43 artifacts. + +The metadata pair and cwd setter are required because process registration +uses them unconditionally. A same-version kernel may not fall back to the +historical aggregate argv setter or silently ignore initial cwd: either path +would accept boot while losing the bounded, capacity-owned transfer contract +that ABI 43 advertises. + +The authoritative platform and spawn-wire constants remain generated from the +Rust ABI sources. Moving identical constants to that generation path would not +by itself require a bump; the required transactional exports and semantics do. +Making each `WasmPosixKernel` wrapper a one-generation, one-shot initializer is +host-side lifetime hardening for cached scratch allocations. It changes no +kernel export, wire layout, manifest capability, or accepted guest limit, so it +does not require an additional ABI epoch beyond 43. +The option-sensitive `prctl` operation values and the fixed scratch widths for +thread names, Fcntl lock records, and signal masks likewise have one shared +Rust authority and generated TypeScript consumers. Centralizing those +unchanged values is bookkeeping, not another ABI change. +The channel-handler signature, exhaustive pointer-nullability semantics, and +option-sensitive `prctl` marshalling are also incompatible contract changes +within ABI 43, not bookkeeping-only generation changes. The historical +fixed-buffer baseline is exact #1094-based evidence. The growable design has +also been measured after retargeting onto #1097 in Node.js and real Chromium: +the dirty-source result retained 84,386 scratch bytes and ended at 17,694,720 +bytes of kernel linear memory in both hosts, with complete source and runtime +artifact fingerprints. It is historical evidence rather than the mutable +exact-head result. Exact-head Node.js and Chromium measurements belong in the +draft PR ledger after the commit is frozen, and the three-round timing samples +establish neither a latency improvement nor broad performance no-regression. + ## The snapshot `abi/snapshot.json` is generated by `cargo xtask dump-abi` from the @@ -419,6 +584,13 @@ authoritative Rust sources and the freshly-built kernel `.wasm`. It captures: - `abi_version` — the integer [`ABI_VERSION`](../crates/shared/src/lib.rs). +- `platform_limits` — the advertised `ARG_MAX`, `PATH_MAX`, and `IOV_MAX` + values generated into the TypeScript host and public musl headers. +- `spawn_contract` — the complete non-forking spawn wire contract: syscall + number, header and action layouts, opcodes, transported attribute bits, + defensive count caps, public-limit aliases, and derived whole-blob ceiling. + Any change to either this section or `platform_limits` is classified as + breaking unless the ABI epoch changes. - `channel_header` — field offsets and sizes in the channel header, read from `shared::channel::*` constants. - `channel_signal_area` — signal-delivery slot offsets in the trailing @@ -429,6 +601,10 @@ captures: with `name`, `offset`, `span`). `span` is bytes until the next field (or end of struct), so it includes alignment padding and catches any layout shift. +- `process_native_layouts` — the generated wasm32/wasm64 musl layouts used + when the host reads native process records, including `iovec`, `msghdr`, + `cmsghdr`, `siginfo_t`, and `sigevent`, plus the shared socket constants + needed to interpret `SCM_RIGHTS`. - `syscalls` — every syscall number named by the shared ABI metadata: the core `Syscall::from_u32` table plus `abi::extended_syscalls` entries for host-visible kernel/control syscalls that are not yet in @@ -436,7 +612,10 @@ captures: - `syscall_arg_descriptors` — host marshalling descriptors for pointer arguments, including direction, size source, size multipliers/additions, fixed byte lengths, pointer nullability/requiredness, and any - return-value-based copy-back adjustment. + return-value-based copy-back adjustment. Generation tests require every + pointer descriptor to select exactly one of nullable or required, compare + the complete reviewed nullable set, and keep option-sensitive `prctl` out of + this generic table. - `pathconf_names` — the shared numeric `_PC_*` vocabulary consumed by the kernel, generated host bindings, and libc wrappers. - `host_adapter` — Rust-owned boot manifest metadata consumed by host @@ -478,6 +657,39 @@ Fields are sorted alphabetically at every level, and the generator writes the same bytes for the same input — the snapshot is a pure function of the checked-in source. +The same generator also owns the cross-language consumers of these snapshotted +constants. Advertised `ARG_MAX`, `PATH_MAX`, and `IOV_MAX` live in +`crates/shared/src/lib.rs::platform_limits`; `cargo xtask dump-abi` writes +their TypeScript consumer and the public musl +`bits/kandelo_limits.h`. The non-forking spawn wire contract lives separately +in `crates/shared/src/lib.rs::spawn_contract`; the generator writes its C +consumer to +`libc/musl-overlay/src/process/wasm32posix/spawn_contract.h`. The private spawn +header aliases the public generated limits and adds the four-byte string-offset +width; all field offsets in the 40-byte header and 28-byte action record; the +five action opcodes; musl's complete transported attribute byte; the +argv/environment/action count caps; and the derived 8,417,320-byte whole-blob +ceiling. Rust, TypeScript, and C therefore consume the same numeric wire +contract. Transporting all eight attribute bits is distinct from implementing +them: the kernel currently acts on `SETPGROUP`, `SETSIGDEF`, `SETSIGMASK`, and +`SETSID`, while `RESETIDS`, `SETSCHEDPARAM`, `SETSCHEDULER`, and `USEVFORK` +remain uninterpreted. The count and complete-wire caps are defensive +parser/transport limits, not new POSIX promises. + +Native process layouts and fixed kernel wires follow the same ownership rule. +`crates/shared/src/process_layout.rs` owns the wasm32/wasm64 native +`iovec`/`msghdr`/`cmsghdr`, `pollfd`, and `fd_set` values; the generator writes +TypeScript plus `bits/kandelo_process_layouts.h`, and the dual-width C layout +test checks the installed musl sysroots. The fixed `KernelIovecWire`, +`KernelMsghdrWire`, and `KernelCmsghdrWire` structures remain snapshotted +`repr(C)` ABI records. The same generated/snapshotted contract carries the +one-record flattened kernel-iovec count and socket-message constants consumed +by the host, including `MSG_TRUNC`; Rust refuses a different flattened count +until its parser is changed in lockstep. Generating identical native constants +is bookkeeping and does not itself require a bump; changing an existing fixed +wire or observable accepted layout is evaluated under the normal +incompatible-change rules. + ## Developer workflow On a change: @@ -486,19 +698,19 @@ On a change: # 1. Make your change to kernel / shared / glue as needed. # 2. Regenerate the snapshot. This rebuilds the kernel wasm first so # a stale binary can't defeat the check. -bash scripts/check-abi-version.sh update +scripts/dev-shell.sh bash scripts/check-abi-version.sh update # 3. Inspect the diff. If it's empty, the change didn't touch the ABI. # If it is only an additive-compatible change, commit the snapshot # without bumping ABI_VERSION. If it changes existing ABI surface, # bump ABI_VERSION in crates/shared/src/lib.rs in the same commit. # 4. Verify. -bash scripts/check-abi-version.sh +scripts/dev-shell.sh bash scripts/check-abi-version.sh ``` In CI: ```bash -bash scripts/check-abi-version.sh +scripts/dev-shell.sh bash scripts/check-abi-version.sh ``` Fails if the committed snapshot drifts from the source. If the snapshot @@ -570,3 +782,11 @@ so additive kernel API growth does not force every package to rebuild. Packages built after an additive change may depend on the new syscall or export; those packages should be resolved with the matching current kernel, even though the ABI epoch did not change. + +An additive export is compatible only while existing required capabilities and +existing semantics remain unchanged. ABI 43's scratch work is deliberately not +such an addition: it expands the required host-adapter export set, removes the +older large-spawn reservation/fallback contract, and changes the synchronization +semantics of reusable storage. By contrast, identical generated spawn/native +layout constants and an internal TypeScript pointer-plus-capacity value would +not by themselves require an ABI version bump. diff --git a/docs/architecture.md b/docs/architecture.md index 60f00a0c41..3062005589 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -76,17 +76,28 @@ kernel_set_current_tid(pid, tid) → 0 | -errno kernel_fork_process(parent_pid, caller_tid) → assigned_child_pid | -errno kernel_spawn_process(parent_pid, caller_tid, blob_ptr, blob_len) → assigned_child_pid | -errno kernel_remove_process(pid) → 0 -kernel_handle_channel(channel_offset, pid) → result +kernel_handle_channel(channel_offset, channel_capacity, pid) → result kernel_exec_prepare(pid, caller_tid) → 0 | -errno kernel_exec_setup_for_thread(pid, caller_tid) → 0 | -errno kernel_thread_exit(pid, tid) → 0 | -errno -kernel_dequeue_signal(pid, tid, out_ptr) → 0 | signum | -errno -kernel_wait_child_poll(parent_pid, caller_tid, target_pid, event_mask, flags, out_ptr) → child_pid | 0 | -errno +kernel_dequeue_signal(pid, tid, out_ptr, out_capacity) → 0 | signum | -errno +kernel_wait_child_poll(parent_pid, caller_tid, target_pid, event_mask, flags, out_ptr, out_capacity) → child_pid | 0 | -errno kernel_prepare_write_operation(pid, tid, fd, offset, len, positioned) → allowed_len | -errno kernel_ipc_shmat_for_task(pid, tid, shmid, addr, flags) → segment_size | -errno kernel_ipc_shmdt_for_task(pid, tid, shmid) → 0 | -errno kernel_ipc_shmat_for_process(pid, shmid, addr, flags) → segment_size | -errno kernel_ipc_shmdt_for_process(pid, shmid) → 0 | -errno +kernel_alloc_scratch(size) → kernel_owned_pointer | 0 +kernel_spawn_scratch_begin(minimum_capacity) → reservation_token | -errno +kernel_spawn_scratch_pointer(reservation_token) → kernel_owned_pointer | 0 +kernel_spawn_scratch_capacity(reservation_token) → reservation_capacity | 0 +kernel_spawn_scratch_retained_capacity() → retained_capacity +kernel_spawn_scratch_cancel(reservation_token) → 0 | -errno +kernel_spawn_reserved_process(parent_pid, caller_tid, reservation_token, blob_len) → assigned_child_pid | -errno +kernel_msqid_ds_bytes(process_pointer_width) → bytes | -errno +kernel_semctl_array_bytes(pid, tid, semid, command) → bytes | -errno +kernel_semid_ds_bytes(process_pointer_width) → bytes | -errno +kernel_shmid_ds_bytes(process_pointer_width) → bytes | -errno kernel_get_cwd(pid, buf, len) → bytes_written kernel_set_max_addr(pid, addr) → 0 kernel_set_brk_base(pid, addr) → 0 @@ -147,6 +158,287 @@ Key host components: | NodeWorkerAdapter | `worker-adapter.ts` | Creates Node.js worker_threads | | BrowserWorkerAdapter | `worker-adapter-browser.ts` | Creates Web Workers | +Kernel module instantiation snapshots the caller's exact intrinsic +`ArrayBuffer`, typed-array, or `DataView` byte window before inspecting it. +Pointer-width detection and `WebAssembly.compile` consume that same detached +snapshot. This prevents an overridden view getter or a later caller mutation +from making the host configure wasm32 imports for a different wasm64 module, +or vice versa. + +A `WasmPosixKernel` wrapper owns exactly one instantiated kernel generation. +`init` and `initWithMemory` are mutually exclusive one-shot entry points: +concurrent or post-success calls reject before changing pointer width, memory, +instance, or cached scratch authority. A failed first attempt clears its +partially published state and may be retried. This matters because a +`KernelScratchRegion` is bound to the exact allocator, instance, memory, +pointer, and capacity that created it; replacing the wrapper generation while +retaining an audio or public-API region would make the old allocation appear +valid under unrelated new state. + +### Kernel-owned scratch transfers + +The host moves syscall payloads through allocations owned by the Rust kernel. +`host/src/kernel-scratch.ts::KernelScratchRegion` carries each allocation's +pointer and declared capacity together. The exported region, lease, and +guarded-data-view names are structural interfaces; their concrete classes are +module-private. A compiler-backed contract inventories every direct or aliased +factory call, so repository runtime code cannot add a callback that merely +claims an arbitrary pointer/capacity pair without a new exact review entry. +Callers can read or write a region only inside a synchronous +`KernelScratchLease`, which checks: + +1. safe-integer and non-negative offsets and lengths; +2. lossless wasm32/wasm64 pointer conversion; +3. the requested range against the allocation capacity; and +4. the resulting address against the current kernel `Memory.buffer`. + +This contract is ABI 43 on top of PR #1097's merged ABI-42 result, +`c7d039794a43788acfa0b0aea30a700c257f57cb`. The bump is required by actual +incompatible exports and wires: among them, `kernel_handle_channel`, +`kernel_dequeue_signal`, and `kernel_wait_child_poll` gain capacity arguments, +and the signal-delivery record changes size. Centralizing unchanged constants +alone would not require a bump. Exact final-head validation remains a PR +readiness gate and must be recorded with the commit SHA it actually tested. + +The capacity check and memory-buffer check answer different questions. The +second proves that an address exists in linear memory. It does not prove that +the allocator assigned all of those bytes to this scratch region. For example, +a 70 KiB write may fit comfortably in a multi-megabyte `Memory` while crossing +the end of a 65 KiB scratch allocation and corrupting the next Rust heap +object. + +The central worker owns separate main-syscall and TCP regions for the kernel +lifetime. Sequential operations reuse them cheaply. A lease rejects nested or +promise-returning work, and a guarded data view is revoked when the lease ends, +so a callback or retry cannot observe bytes replaced by another operation. +The lease is revoked before the callback's return value is inspected for a +promise/thenable, so even an adversarial `then` getter cannot perform one last +scratch access. Bulk copies read intrinsic typed-array slots and detach output; +constructors, slot getters, `set`, `fill`, DataView methods, and +`Reflect.apply` are captured at module initialization. Each bulk write receiver +spans exactly the checked allocation range, so a replaced live prototype or +subclass override cannot widen or intercept the write, reenter the lease, or +retain a live kernel view. +Scalar access checks the lease on every operation. The native `DataView` stays +private and may be reused only while `Memory.buffer` has the same identity; a +`memory.grow()` replaces that buffer and forces the complete capacity and +current-memory proof to run again before the view is refreshed. +Async syscall preparation detaches caller data into host-owned arrays; the +final stage, Rust call, and output snapshot happen in one lease without an +`await`. Retry, timeout, stopped-process, and signal completion state carries +only those detached writes; `completeChannel` never rereads reusable scratch +after the lease has ended. + +The Rust channel dispatcher carries the same ownership boundary numerically as +`ChannelScratchRegion { start, capacity }`. The host passes the complete +channel capacity to `kernel_handle_channel`; Rust rejects any value other than +the canonical allocation size before decoding the header. Generated pointer +descriptors classify every argument as exactly one of required or nullable. +A null pointer with positive extent is accepted only when that shared +descriptor explicitly permits null. An argument-sized null pointer with zero +extent is instead canonicalized to a non-null, allocator-owned empty range, so +an arbitrary process-space address never crosses into the kernel merely +because its byte count is zero. + +Before laying out any descriptor, the host captures every `Deref`-derived +`u32` length, such as a `socklen_t`, from the validated caller range. The same +captured value sizes the destination and stages the length record, independent +of generated descriptor order. A non-null outer buffer with no length pointer +fails before dispatch. Rust then validates descriptor order, eight-byte +alignment, non-overlap, and every complete subrange against the same channel +allocation. Bespoke vector, message, polling, System V IPC, message-queue, and +other manual wire layouts have corresponding Rust validators rather than a +raw channel-pointer escape. + +The generic aligned wire does not carry a second unpadded suballocation +capacity for each descriptor. Rust therefore recomputes a dynamic range from +the staged length and cannot independently distinguish a hypothetical +post-staging length change that remains within the same eight-byte alignment +bucket. The host's pre-captured value is the exact-capacity authority, and the +stage, Rust call, and output snapshot occur in one non-reentrant synchronous +lease, so repository runtime code has no interval in which to make that +change. A stronger independent cross-check would require an additional ABI +capacity field or a wire layout without alignment slack; the existing Rust +proof should not be described as reconstructing information the wire does not +encode. + +`prctl` is kept out of generic pointer metadata because its second argument is +option-sensitive. `PR_SET_NAME` and `PR_GET_NAME` stage one required, exact +16-byte buffer; every other supported option preserves the low 32-bit scalar +value and stages no scratch pointer. The two option numbers, name-buffer size, +fixed Fcntl lock-record size, and fixed signal-mask size live in shared Rust +ABI modules and are generated into the TypeScript host, so the bespoke +validators cannot drift by repeating protocol literals. + +A C-string argument is accepted only when its pointer is inside the exact +channel data allocation and a NUL terminator occurs before the allocation +ends. This allocation bound is not a substitute for a syscall's semantic +limit: pathname consumers still apply the generated `PATH_MAX`, while generic +C-string consumers may validly use more than `PATH_MAX` when the complete +string fits channel scratch. + +Vector-message syscalls add a width-translation boundary. Musl's native +`iovec`, `msghdr`, and `cmsghdr` layouts differ between wasm32 and wasm64, so +their sizes, offsets, and alignments are generated from the shared Rust ABI +source into TypeScript and a musl contract header. The kernel scratch wire is +deliberately fixed: an eight-byte `KernelIovecWire`, a 28-byte +`KernelMsghdrWire`, and a 12-byte-aligned `KernelCmsghdrWire`. These are +separate contracts; copying a native wasm64 header and hoping the fixed parser +interprets it is invalid even when the bytes fit in linear memory. + +For `sendmsg`, the host validates the complete native header and iovec table, +every nested caller range, `IOV_MAX`, and the complete fixed-wire footprint. +It translates each ancillary record, flattens all caller iovecs in order into +one capacity-owned payload, and invokes Rust with a zero-or-one-iovec wire +inside one synchronous lease. Rust validates the complete aligned ancillary +stream and the receiver-reconstructibility of every requested `SCM_RIGHTS` +description before retaining any reference or publishing carrier bytes. Socket +descriptions are not reconstructible from a process-local socket snapshot, so +an ancillary batch containing one fails atomically with `EOPNOTSUPP`; Kandelo +does not pretend that a copied socket record is the original endpoint. The +exact flattened-iovec count is generated from the shared protocol contract, +and a Rust compile-time guard makes changing that count fail until the fixed +parser changes with it. +For `recvmsg`, the host +derives fixed-wire control capacity from the caller-native data capacity, +snapshots the result, validates the entire returned record, expands it with +zeroed native padding, and scatters payload bytes across every caller iovec. +A retry or malformed kernel result publishes none of those detached outputs. +This flatten/scatter design preserves the public multi-iovec behavior while +keeping the ordinary transport allocation fixed and cheap. + +Large spawn blobs use a different kernel-owned high-water region in +`crates/kernel/src/spawn.rs::SpawnScratchBuffer`. Every large operation calls +`kernel_spawn_scratch_begin`, which may grow the Rust `Vec` only while no +reservation is active and returns a fresh positive token. Begin and the +pointer/capacity queries are nonblocking: mutex contention makes begin return +`EBUSY` and makes query exports return zero. The host then reads the token's +pointer and capacity together, proves that the complete blob fits both that +allocation and the current `Memory.buffer`, and copies under one synchronous +lease. `kernel_spawn_reserved_process` accepts no host-selected pointer: it +consumes the matching token, parses the selected prefix into Rust-owned +vectors, and releases the scratch mutex before entering the process table or +any host import. After every successful begin, including setup or copy failure, +the host invokes cancellation in a `finally` block. Commit and cancellation +wait through mutex contention; neither guarded path can call a host import, so +each returns with a definitive token state. Cancellation success releases an +unconsumed matching token; `EINVAL` means the never-reused token was already +consumed or is stale. For the just-issued in-contract token after commit, the +consumed case is expected. +Stale tokens, overlapping reservations, and reentrant large-spawn attempts +fail without replacing live bytes. The reservation-derived host region is +single-use and is revoked after the attempt, so a later Rust-owned `Vec` +growth cannot revive its old pointer/capacity pair. + +The allocation lives until the kernel instance ends and may retain the +largest accepted blob seen. A three-round historical comparison used the +fixed-buffer kernel from exact #1094 head plus a fingerprinted host-only +telemetry shim. A post-retarget dirty-worktree rerun exercised the same +deterministic Homebrew-like workload on Node.js and real Chromium. In both +hosts, the growable design reported 84,386 bytes of retained scratch capacity +instead of 8,417,320 bytes, and post-run kernel linear memory was 17,694,720 +rather than the fixed design's 26,017,792 bytes. Because WebAssembly memory +cannot shrink, post-run memory was also that workload's peak. The dirty-source +run has complete source and runtime-artifact fingerprints but is historical +evidence. The three-round timing sample and baseline-harness provenance +establish neither a speedup nor broad no-regression. The exact +workload, fingerprints, host-specific medians, and remaining +application-suite block are recorded in +`docs/plans/2026-07-25-kernel-scratch-transfer-audit.md`. ABI 43 requires the +complete transactional export set. There is no older-kernel fixed-buffer +fallback under the same ABI version. Exact-head Node.js and Chromium results +belong in the draft PR ledger after the commit is frozen, so the evidence names +the head it actually exercised. + +Rust-lent host-import destinations are deliberately separate. Rust supplies a +pointer and capacity valid for that synchronous import, so +`checkedWasmImportMemoryRange` normalizes the raw wasm32/wasm64 import value +and `WasmPosixKernel.writeKernelBytes` checks that range and the producer's +intrinsic byte span without claiming it came from the scratch allocator. +Process memory, +framebuffers, and explicit shared-memory mappings keep their own ownership +models. A TypeScript-compiler-backed JavaScript/TypeScript repository audit +follows kernel-memory ownership through aliases, parameters, and returns and +reports raw views, writes, escapes, and allocator/reservation calls. Its exact +multiset allowlist names every necessary exception with a reason and fails if +an occurrence is added, duplicated, or removed. This keeps framebuffer, +process-memory, and shared-memory paths explicit without conflating their +ownership with kernel scratch. Because untyped JavaScript can erase a receiver +type, the audit also treats a zero-argument `.getMemory()` call in JavaScript +source as the documented raw kernel-memory accessor and follows its result into +aliases, helper parameters, views, and writes. Same-named non-kernel APIs need +an exact reviewed allowance. This narrow backstop is not a claim of sound +general JavaScript taint analysis. + +The Rust dispatcher has a separate source-contract test for raw process +addresses. It rejects the former raw-channel-pointer macro, matches every +remaining `process_address!` use against its exact reviewed syscall context, +and also checks the total use count. Those sites represent guest virtual +addresses for memory-management, clone, futex, and related operations; they +are not authority to dereference kernel scratch. A new site, a removed site +paired with an unrelated replacement, or a reintroduced bare channel pointer +therefore requires an explicit review instead of passing a count-only +allowlist. + +The low-level `WasmPosixKernel.getMemory/getInstance` and +`CentralizedKernelWorker.getKernel/getKernelInstance` accessors are unsafe +trusted-embedder/debug escape hatches, not scratch-transfer APIs. A consumer +that calls raw pointer-returning exports and mutates the returned memory has +opted out of the capacity and lease guarantees above. Kandelo's own runtime +does not use those accessors for transfers, and the compiler-backed audit +covers repository source rather than arbitrary downstream mutations. + +Current host-adapter admission requires `kernel_set_cwd`, +`kernel_clear_process_metadata`, and +`kernel_push_process_metadata_entry`. Initial cwd and process argv/environment +therefore cannot silently fall back to an older pointer-only aggregate setter +or a no-op after the runtime has negotiated the capacity-owned scratch +contract. + +System V control operations use the same capacity-bearing main region, but +their wire sizes also depend on the caller. The required structure-size exports +select musl's target structure from the process pointer width: + +| Structure | wasm32 time64 | wasm64 LP64 | +|---|---:|---:| +| `msqid_ds` | 96 bytes | 120 bytes | +| `semid_ds` | 72 bytes | 88 bytes | +| `shmid_ds` | 88 bytes | 112 bytes | + +The host stages `msgctl`/`shmctl` `IPC_STAT` and `IPC_SET` according to the +command and passes that process pointer width in the otherwise host-private +sixth dispatch slot. The kernel Wasm's own width is not a valid substitute +because one kernel may serve both guest widths. +`kernel_semctl_array_bytes(pid, tid, semid, command)` separately performs the +permission-aware GETALL/SETALL size preflight. All four sizing exports are +required in ABI 43. There is no `IPC_STAT` sizing fallback for semaphore +arrays: a process may have permission to write a semaphore set without +permission to read its metadata. + +Other caller-native records use the generated +`SyscallArgSize::ProcessLayout` descriptor. Encountering that descriptor makes +the host select the exact size from the process width and carry the same width +in the private sixth dispatch slot: + +| Record | wasm32 | wasm64 | +|---|---:|---:| +| `stack_t` | 12 bytes | 24 bytes | +| kernel-facing `itimerval` | 16 bytes | 32 bytes | +| `mq_attr` | 32 bytes | 64 bytes | +| `sigevent` | 64 bytes | 64 bytes | +| `statfs` | 88 bytes | 120 bytes | +| `sysinfo` | 312 bytes | 368 bytes | +| `siginfo_t` for `rt_sigqueueinfo` | 128 bytes | 128 bytes | + +The timer distinction is intentional: wasm32 musl translates its public +time64 `itimerval` to four native `long` values before entering the kernel. +Rust parses or serializes each complete caller-native record into a +capacity-bounded scratch slice and initializes padding and reserved bytes. +The kernel Wasm's own pointer width is never used to infer the process layout. +The generated fixed-size descriptors separately carry `stat` (112 bytes) and +`sched_param` (48 bytes); those two records do not use width selection or the +private process-width dispatch slot. + ### Kernel heap lifetime The Rust kernel uses a reclaiming `dlmalloc` heap inside its own Wasm linear @@ -201,19 +493,47 @@ capacity checks all happen in Rust. Conflicts are reported as `EAGAIN` before capacity is considered; a mutation that would exceed the record limit or cannot reserve its final capacity returns `ENOLCK` without partial state. -An `SCM_RIGHTS` queue entry retains the source description's `OfdId`, `FileId`, -and real backing reference. It therefore participates in final-reference -checks even after the sender closes its descriptor. Successful receipt -transfers that retained reference into the receiver without changing lock -ownership; a discarded message or failed receiver fd allocation releases it -and removes OFD/`flock()` records only if it was the true final reference. -Destructors enqueue fixed cleanup metadata into pre-reserved, high-water -storage, and cleanup runs after pipe-table borrows end. The host schedules the -syscall but never stores or examines lock state. Ordinary regular-file offsets -and status flags still live in per-process OFD records, so their sharing across -fork and `SCM_RIGHTS` remains the separate global-OFD gap documented in +For a supported `SCM_RIGHTS` description, the queue entry retains the source +description's `OfdId`, `FileId`, and a live reconstructible backing reference. +Transfer validation is repeated before retain and before receiver +installation; stale, non-owning, structurally incomplete, socket, epoll, and +other process-owned descriptions fail with `EOPNOTSUPP` instead of becoming a +lossy snapshot. In particular, a batch containing a socket fails before its +carrier data or rights become visible. Supporting socket transfer requires one +authoritative machine-wide socket backing and is not approximated here with a +copied process-local record. + +A valid queued reference participates in final-reference checks even after the +sender closes its descriptor. Successful receipt transfers that retained +reference into the receiver without changing lock ownership; a discarded +message or failed receiver fd allocation releases it and removes +OFD/`flock()` records only if it was the true final reference. Destructors +enqueue fixed cleanup metadata into pre-reserved, high-water storage, and +cleanup runs after pipe-table borrows end. The host schedules the syscall but +never stores or examines lock state. Ordinary regular-file offsets and status +flags still live in per-process OFD records, so their sharing across fork and +`SCM_RIGHTS` remains the separate global-OFD gap documented in [future-improvements.md](future-improvements.md). +On an AF_UNIX stream, retained rights are associated with absolute byte ranges +in the stream rather than a separate first-in/first-out side queue. A receive +cannot observe a descriptor before it reaches that record's carrier bytes, and +`MSG_WAITALL` stops at an ancillary boundary instead of consuming bytes beyond +the rights it can return. Ordinary `read()` discards rights only when it +consumes their carrier range. `MSG_PEEK` fallibly duplicates the retained +references without consuming either bytes or rights, so a short control buffer +can report `MSG_CTRUNC` repeatedly and a later full receive still obtains the +descriptors. + +An AF_UNIX datagram stores payload, source address, and retained rights in one +queue entry. Publication is atomic: a full queue or failed descriptor retain +publishes neither bytes nor rights. Zero-length datagrams remain real messages, +so `recvmsg()` with no iovecs can consume one and receive its control records; +ordinary `read(fd, ..., 0)` remains a no-op and cannot consume it. Addressed +and connected same-process AF_UNIX datagram sends use this same ownership +path. Cross-process AF_UNIX datagram routing remains unsupported as documented +in the networking section. + ABI 40 removes the required `host_fcntl_lock` import and the public `SharedLockTable` host-package export. This is an intentional host API break: embedders must not register or manipulate lock storage. The unchanged guest @@ -322,7 +642,8 @@ Process Worker Kernel Worker (host) 4. Atomics.wait(status, SYSCALL_READY) ─── blocks ─── 5. Atomics.waitAsync detects change 6. Read channel: syscall + args - 7. Call kernel_handle_channel(offset, pid) + 7. Call kernel_handle_channel(offset, + capacity, pid) 8. Kernel reads args from process memory 9. Kernel executes syscall logic 10. Kernel writes return_value + errno @@ -623,7 +944,7 @@ remaining POSIX gap is tracked in [posix-status.md](posix-status.md) and 1. User calls `execve(path, argv, envp)` → kernel returns exec request to host 2. Host resolves `path` to a Wasm binary (via filesystem or program map) -3. The host compiles the replacement module, checks its ABI marker, and preallocates its fresh `WebAssembly.Memory` before the irreversible transition. It also validates a 4 MiB combined argv/environment representation (UTF-8 strings, NUL terminators, and caller-width pointer entries, with each string limited to one 64 KiB scratch transfer); oversized metadata returns `E2BIG` to the old image. After commit, argv and environment entries cross into the kernel one at a time, so the fixed host scratch allocation is never overrun and an empty environment explicitly clears the prior one. +3. The host compiles the replacement module, checks its ABI marker, and preallocates its fresh `WebAssembly.Memory` before the irreversible transition. It also validates a 4 MiB combined argv/environment representation (UTF-8 strings, NUL terminators, and caller-width pointer entries). Independently, each string must fit the current 64 KiB process-metadata transfer; that is an implementation transport limit, not part of the public aggregate `ARG_MAX` definition. Oversized metadata returns `E2BIG` to the old image. After commit, argv and environment entries cross into the kernel one at a time, so the fixed host scratch allocation is never overrun and an empty environment explicitly clears the prior one. 4. The host calls `kernel_exec_prepare(pid, caller_tid)` while the old image is still live. The kernel validates that the exact caller is a live task owned by the process and applies deferred `posix_spawn` file actions; any failure @@ -670,13 +991,45 @@ caller now take. `docs/plans/2026-05-04-non-forking-posix-spawn-design.md` Section 1. 2. Host (`handleSpawn` in `kernel-worker.ts`) reads the blob from caller memory, validates argv + envp against the same 4 MiB `ARG_MAX` - contract as `execve`, copies it to bounded kernel-owned scratch, and calls - `kernel_spawn_process(parent_pid, caller_tid, blob_ptr, blob_len)`. Ordinary - blobs reuse the channel-sized syscall scratch. A blob above that size - lazily allocates one whole-spawn buffer and reuses it for the kernel - lifetime. Keeping both paths kernel-owned prevents a large environment or - file-action list from overwriting adjacent Rust heap state; the explicit - whole-blob ceiling also bounds data that `ARG_MAX` does not count. + contract as `execve`, and copies it to bounded kernel-owned scratch. + Each argv/environment entry also has the separate 64 KiB + process-metadata transport limit described for `execve`; this + implementation ceiling is not `ARG_MAX`. + Ordinary blobs reuse the channel-sized syscall region and call + `kernel_spawn_process(parent_pid, caller_tid, blob_ptr, blob_len)` while its + lease is active. A blob above that size begins an exclusive tokenized + reservation in the Rust-owned reusable region, reads its pointer and + capacity, copies under a lease, and commits with + `kernel_spawn_reserved_process(parent_pid, caller_tid, token, blob_len)`. + Begin and the pointer/capacity queries are nonblocking and report + contention as `EBUSY` or zero. Commit consumes the token before parsing. + After every successful begin, the host unconditionally calls cancellation + from a `finally` block, including setup and copy failures. Cancellation + success releases an unconsumed matching token; `EINVAL` means the + never-reused token was already consumed or is stale. For the just-issued + in-contract token after commit, consumption is expected. Commit and + cancellation use a blocking critical section that performs no host imports, + so both return with a definitive token state. + Host-side reentry protection rejects a second large spawn while the first + reservation is active. Keeping both paths kernel-owned prevents a + large environment or file-action list from overwriting adjacent Rust heap + state. Merely fitting within the total kernel `Memory` would not establish + this allocation-ownership fact. The explicit 8,417,320-byte whole-blob + ceiling also bounds file-action data that `ARG_MAX` does not count. The + advertised 4 MiB `ARG_MAX`, + 4,096-byte `PATH_MAX`, and 1,024-entry `IOV_MAX` live in + `crates/shared/src/lib.rs::platform_limits` and generate the Rust, + TypeScript, and musl consumers. The separate authoritative spawn wire + contract generates the four-byte string-offset width; every offset in the + 40-byte header and 28-byte action record; the `OPEN`, `CLOSE`, `DUP2`, + `CHDIR`, and `FCHDIR` opcodes; musl's complete transported spawn-attribute + byte; the 4,096 argv and environment entry caps; 1,024 actions; and the + complete ceiling. Transporting an attribute bit does not claim its + behavior is implemented: the kernel currently interprets only + `SETPGROUP`, `SETSIGDEF`, `SETSIGMASK`, and `SETSID`; `RESETIDS`, + `SETSCHEDPARAM`, `SETSCHEDULER`, and `USEVFORK` remain unimplemented. Count + caps are defensive parser limits; they are not additional POSIX platform + limits. 3. Kernel parses the blob (`crates/kernel/src/spawn.rs::parse_blob` — the trust boundary; bails with EINVAL on any malformed offset), validates `caller_tid` as a live task belonging to the parent, and calls @@ -1312,8 +1665,12 @@ extension reports the original datagram length while copying no more than the supplied buffer. Without `MSG_PEEK`, the datagram is consumed and any uncopied suffix is discarded; with `MSG_PEEK`, it remains queued. This receive-side truncation does not weaken AF_UNIX send reliability or its full-queue -backpressure contract above. The current `recvmsg()` wrapper does not populate -output `msg_flags`, so it cannot report output `MSG_TRUNC` there. +backpressure contract above. `recvmsg()` independently reports output +`MSG_TRUNC` whenever the datagram was longer than the supplied payload +capacity; the input flag controls only whether its return value is the copied +prefix length or the full datagram length. `MSG_CMSG_CLOEXEC` installs every +received descriptor with `FD_CLOEXEC`, and that reflected flag is published +atomically with the returned control data. Loopback addresses are scoped to one Kandelo machine, but not every socket path is machine-wide yet. IPv4 and IPv6 loopback TCP and AF_UNIX streams have explicit cross-process paths. Current in-kernel IPv4/IPv6 loopback datagrams, AF_UNIX datagrams, and IPv4 multicast delivery are confined to the sending process. Forked sockets retain their kernel-local bind reservations and local lookup targets, but host-backed UDP endpoint registrations are not yet shared or transferred between processes. AF_INET6 represents `sockaddr_in6`, supports `::`/`::1`, and models dual-stack wildcard stream-port reservation, but it has no external or virtual-network IPv6 transport and no IPv6 multicast delivery. AF_INET6 datagrams therefore report `IPV6_V6ONLY=1`; disabling it fails until dual-stack datagram routing exists. @@ -1481,10 +1838,21 @@ not synthesize timer signals or fall back to a process-wide notification. Musl implements POSIX `SIGEV_THREAD` with a detached helper pthread and an exact-thread kernel notification. The helper retains the callback and native -`union sigval` locally—including a full-width wasm64 pointer—while the -kernel-facing `sigevent` remains a fixed four-i32 wire. Direct wasm64 signal -notifications therefore remain limited to `sival_int` until that wire is -extended in a later ABI. +`union sigval` locally. Other timer notifications stage the complete generated +caller-native 64-byte `sigevent`; the process pointer width selects the union +width, and Rust retains its raw bits in a `u64`. The fixed channel delivery +record also carries eight raw value bytes. A wasm64 recipient therefore +receives the complete `sival_ptr`; a wasm32 recipient receives the +target-native low 32 bits. The glue reconstructs the recipient's native union +with `memcpy` so it does not select the wrong union member. + +POSIX message-queue notification uses the same Rust-owned signal metadata. +Registration accepts a `SIGEV_SIGNAL` notification only when +`1 <= signo < NSIG`; the first message sent to an empty queue queues one +`SI_MESGQ` record with the registering process's complete `union sigval` and +authoritative sender credentials. The small host drain record is only a wake +instruction—the host does not synthesize a second signal or replace the queued +metadata. Normal exit status and signal termination are stored separately. `_exit()` and `exit_group()` retain the low eight status bits, including values 128 through @@ -1712,12 +2080,12 @@ For schema, resolver behavior, and the build-script contract see [docs/package-m ## Test Suites -| Suite | Command | What it tests | -|-------|---------|---------------| -| Cargo | `cargo test -p kandelo --target aarch64-apple-darwin --lib` | Kernel unit tests (610+) | -| Vitest | `cd host && npx vitest run` | Host integration tests (227+) — runs real Wasm programs | -| libc-test | `scripts/run-libc-tests.sh` | musl libc conformance (C standard library) | -| POSIX | `scripts/run-posix-tests.sh` | Open POSIX Test Suite (POSIX API conformance) | -| Sortix | `scripts/run-sortix-tests.sh --all` | Sortix os-test suite (4817+ tests, most comprehensive) | - -All five suites must pass with 0 unexpected failures before merging changes. +Validation always runs through `scripts/dev-shell.sh`. Kernel, host, ABI, +libc, Open POSIX, Sortix, fork-instrument, and real-browser suites prove +different contracts; there is no fixed short list whose success establishes +every change. Use the CI-shaped commands and change-to-suite matrix in +[`docs/agent-guidance/validation.md`](agent-guidance/validation.md), including +generated-file/snapshot checks for ABI-adjacent work and real Chromium +evidence for shared browser runtime changes. Report exact commands, counts, +unexpected failures, skipped suites, and environmental blocks rather than +using a narrow passing suite as a broad readiness claim. diff --git a/docs/compromising-xfails.md b/docs/compromising-xfails.md index 0dd710b397..96750ffd15 100644 --- a/docs/compromising-xfails.md +++ b/docs/compromising-xfails.md @@ -172,9 +172,10 @@ If the caller inserts even a short `nanosleep` between `aio_read` and `sigsuspen `sigprocmask`, `sigsuspend`, `rt_sigpending`, `ppoll`, `pselect6`, and `sigtimedwait` therefore operate on the calling task rather than a PID-only selector. -3. `kernel_dequeue_signal(pid, tid, out_ptr)` validates the target task and - dequeues only signals deliverable to it. Unknown, foreign, stale, or exited - tasks return `ESRCH` without consuming signal state. +3. `kernel_dequeue_signal(pid, tid, out_ptr, out_capacity)` validates the + target task and output allocation before dequeuing only signals deliverable + to it. Unknown, foreign, stale, or exited tasks return `ESRCH` without + consuming signal state. 4. `tkill` and `tgkill` write only to an exact live task's directed queue. 5. Fork receives the validated caller TID, copies only that task's signal mask, and no longer uses the obsolete host-driven `kernel_reset_signal_mask` path. diff --git a/docs/plans/2026-05-04-non-forking-posix-spawn-design.md b/docs/plans/2026-05-04-non-forking-posix-spawn-design.md index e80fb24a03..8740e52768 100644 --- a/docs/plans/2026-05-04-non-forking-posix-spawn-design.md +++ b/docs/plans/2026-05-04-non-forking-posix-spawn-design.md @@ -1,9 +1,23 @@ # Non-forking `posix_spawn` syscall — design **Date:** 2026-05-04 -**Status:** Design validated, ready for implementation planning -**Branch:** `non-forking-posix_spawn-syscall-implementation` -**ABI bump:** 7 → 8 +**Status:** Historical design; implemented and subsequently superseded in +details described below +**Original branch:** `non-forking-posix_spawn-syscall-implementation` +**Original proposed ABI bump:** 7 → 8 + +> **Historical contract notice (updated 2026-07-26):** This document preserves +> the original proposal, including the superseded syscall number 141 and ABI +> 7 → 8 rollout below. The implemented syscall is the host-intercepted +> `SYS_SPAWN = 500`. PR #1097 merged as +> `c7d039794a43788acfa0b0aea30a700c257f57cb` with ABI 42; the capacity-bound +> transport was retargeted to that exact merge result and intentionally +> advances the incompatible contract to ABI 43. +> Do not copy numeric contracts from this historical body. The authoritative +> current values live in `crates/shared/src/lib.rs::spawn_contract` and are +> generated for Rust, TypeScript, and C consumers. The validation commands +> below are archival too; current verification runs through +> `scripts/dev-shell.sh` under `docs/agent-guidance/validation.md`. ## Goal @@ -102,14 +116,46 @@ strings: u8[] // packed null-termina covering FDOP_OPEN, FDOP_CLOSE, FDOP_DUP2, FDOP_CHDIR, FDOP_FCHDIR. Strings (open paths, chdir paths) interned in `strings[]` at `path_off`. -`attr_flags` mirrors POSIX: `SETSIGDEF=0x10`, `SETSID=0x80`, -`SETPGROUP=0x02`, `SETSIGMASK=0x08`. `pgrp/sigdef/sigmask` are unused unless -the corresponding flag bit is set. +`attr_flags` transports musl's complete POSIX flag byte without translation: +`RESETIDS=0x01`, `SETPGROUP=0x02`, `SETSIGDEF=0x04`, +`SETSIGMASK=0x08`, `SETSCHEDPARAM=0x10`, `SETSCHEDULER=0x20`, +`USEVFORK=0x40`, and `SETSID=0x80`. Transporting a bit does not imply that +Kandelo implements its behavior. The current kernel acts on `SETPGROUP`, +`SETSIGDEF`, `SETSIGMASK`, and `SETSID`; the other values remain explicit +unsupported-contract entries. `pgrp/sigdef/sigmask` are unused unless the +corresponding implemented flag bit is set. The blob is a transient request: the kernel copies it to scratch like every other syscall, parses it once, and never references it again. The descriptor the kernel constructs for the child lives in `ProcessTable`. +Implementation update (2026-07-25): the original design above predates ABI +43's capacity-bound scratch contract. Small blobs still use the 65,608-byte +kernel-owned syscall region under one synchronous pointer-plus-capacity lease. +Every larger operation now begins a fresh exclusive reservation on a +Rust-owned reusable `Vec`, receives an opaque token, reads the token's +pointer and capacity, copies under a lease, and either commits or cancels that +same token. Commit accepts no host-selected pointer, consumes the reservation +before parsing into Rust-owned data, and releases the scratch lock before +process-table work or host imports. The host also calls cancellation after +every commit attempt; success releases a pre-consume failure and `EINVAL` +means the never-reused token was already consumed. Commit and cancellation +wait through a no-host-import Rust critical section. Host reentry protection +and the Rust reservation state prevent a second operation from replacing live +bytes. + +The authoritative advertised `ARG_MAX` and `PATH_MAX` live in +`crates/shared/src/lib.rs::platform_limits`. The separate 40-byte header, +28-byte action record, count caps, and 8,417,320-byte complete ceiling live in +`crates/shared/src/lib.rs::spawn_contract`; together they generate the C and +TypeScript consumers. See +`docs/plans/2026-07-25-kernel-scratch-transfer-audit.md` for the ownership +audit and the focused rehearsal Node.js and Chromium measurements, including +their baseline-harness provenance and final-base limitations. +Those focused results establish the retained-capacity and kernel-memory effect +for their deterministic workload, but do not establish a speedup or broad +performance no-regression claim. + ## Section 2 — Kernel side ### `ProcessTable::spawn_child` diff --git a/docs/plans/2026-07-25-kernel-scratch-transfer-audit.md b/docs/plans/2026-07-25-kernel-scratch-transfer-audit.md new file mode 100644 index 0000000000..6290a026f8 --- /dev/null +++ b/docs/plans/2026-07-25-kernel-scratch-transfer-audit.md @@ -0,0 +1,1319 @@ +# Kernel Scratch Transfer Audit + +Status: this audit began on exact PR #1094 head +`6d923c6454dd7174082f25c3d3991d03f86f5ddb`; that historical evidence is +preserved below. PR #1094 closed without merging. Its main-first replacement, +PR #1097, merged as +`c7d039794a43788acfa0b0aea30a700c257f57cb`, and this branch has been +retargeted to that merge result. PR #1097 shipped ABI 42; the incompatible +export changes documented here intentionally use ABI 43. The pre-retarget and +dirty-worktree results below remain historical or interim evidence, not a +readiness claim. Because recording a commit's own SHA in a tracked document +would change that SHA, the mutable exact-PR-head validation ledger belongs in +the draft PR description after the head is frozen. Brandon's approval must be +requested only when that ledger names the current head and its exact results. + +## Scope and method + +This audit covers every host write or copy into the kernel WebAssembly +`Memory` and every scratch readback, especially pointers returned by +`kernel_alloc_scratch`. It records process-memory, framebuffer, and +shared-memory copies too, but keeps them out of the kernel-scratch abstraction +when a different owner and lifetime apply. + +The inventory was built from all occurrences of: + +- `kernelMem.set`, `Uint8Array.set`/`fill`, `DataView` writes, and equivalent + typed views over kernel memory; +- `scratchOffset`, `tcpScratchOffset`, `largeSpawnScratchOffset`, and + `kernel_alloc_scratch`; +- Rust exports that receive a host-selected kernel pointer; +- host imports through which Rust lends a pointer and capacity to TypeScript; +- promises, worker messages, callbacks, and retry state that might outlive a + scratch use. + +The unsafe PTY, cwd, vector-I/O, and message-I/O paths were also present in +base `df8fd9`, an ancestor of the audited #1094 head. They are pre-existing +defects and are not attributed to #1094. + +The tables distinguish three source-safety dispositions: + +- **Confirmed unsafe** means the old source admits the transfer and the focused + reproduction demonstrates the missing ownership, capacity, range, or + conversion proof. +- **Safe in current source** means the current source contains the stated proof + and focused coverage exists. +- **Uncertain** means source inspection has not yet been paired with executable + evidence sufficient for a safe disposition. Uncertainty is not treated as + safety. + +Validation status is tracked independently from source safety. Legacy table +cells that say **Implemented; validation pending** or **final-head rerun +pending** record the pre-freeze audit state and mean **Safe in current source** +for the safety disposition; they do not make an exact-head validation claim. +The draft PR's exact-head ledger supersedes those mutable status labels only +when it names the current commit. Historical and interim commands later in +this document never substitute for that ledger. + +## Required invariant + +A host transfer into kernel Wasm memory is accepted only after proving these +facts independently: + +1. The source range belongs to the caller. +2. The destination is a kernel-owned allocation. +3. The requested bytes fit the allocation's declared capacity. +4. The destination range fits the current kernel `Memory.buffer`. +5. The allocation remains live through the complete use. +6. Reentrant or concurrent work cannot observe partially replaced bytes. +7. wasm32/wasm64 pointer conversion is checked and lossless. + +The third and fourth checks are deliberately separate. A pointer may be well +inside the current linear memory while the range following it crosses from a +65 KiB allocation into an adjacent Rust heap object. + +## Ownership abstraction + +`host/src/kernel-scratch.ts` owns the common contract: + +- `KernelScratchRegion`, `KernelScratchLease`, and `KernelScratchDataView` are + exported structural interfaces only. Their concrete classes are + module-private, so JavaScript or a TypeScript `any` cast cannot invoke an + erased `private` constructor. The implementation privately carries + `memory`, `pointer`, pointer width, capacity, and a diagnostic label. + Production regions come only from `allocateKernelScratchRegion` or + `reserveKernelScratchRegion`; the compiler-backed contract inventories + every direct or aliased factory call and admits only the five exact + kernel-export-backed production sites. + Reservation-derived regions are single-use and explicitly revoked when + their Rust token is consumed or cancelled, so a later `Vec` growth cannot + revive a stale pointer/capacity pair. +- `KernelScratchLease` is the only read/write interface. Every operation + rechecks non-negative safe-integer offsets and lengths, allocation capacity, + pointer arithmetic, pointer width, and the current memory buffer. Bulk + sources are normalized through intrinsic typed-array slots before the native + `set`; subclass getters and `subarray` overrides cannot lie about their span. + Constructors, slot getters, `set`, `fill`, every exposed `DataView` method, + and `Reflect.apply` are captured when the module loads. Bulk write receivers + span exactly the checked owned range rather than the full linear memory. + Replacing live prototypes therefore cannot widen a write, intercept it, or + turn a detached result into a view of reusable kernel memory. +- `withLease` permits sequential reuse but rejects nested/reentrant use and + promise escape. It invalidates the lease before inspecting the returned + value for a promise/thenable, so even a hostile `then` getter cannot extend + the callback lifetime. A guarded `KernelScratchDataView` checks the active + lease on every scalar access, keeps its native `DataView` private, and + reuses it only while `Memory.buffer` has the same identity. After an + in-lease `memory.grow()`, it repeats the complete owned-range proof before + caching a replacement view. +- `checkedMemoryRange` handles Rust-lent destinations and caller-owned process + ranges without pretending that they are allocator-owned scratch. Address + zero is allowed only when the specific caller-memory contract permits it. + `checkedWasmImportMemoryRange` separately normalizes raw signed wasm32 import + values (or wasm64 `bigint` values) before applying the same lossless + pointer, length, overflow, null, and current-memory checks. + `checkedKernelExportPointer` performs the analogous signed-bit normalization + only for raw allocator/reservation export results; caller-supplied negative + pointers remain invalid. + +`WasmPosixKernel.getMemory/getInstance` and +`CentralizedKernelWorker.getKernel/getKernelInstance` remain explicit unsafe +trusted-embedder/debug escape hatches. A downstream embedder can use them to +call a pointer-returning export and mutate arbitrary kernel memory, so neither +the type nor the repository audit claims to protect that external code. They +are not used for repository-owned runtime transfers. Their API documentation +warns that direct mutation is outside the checked contract; narrowing or +removing these long-standing low-level APIs would be a separate public-host +API decision. + +`host/test/support/wasm-memory-write-audit.ts` and +`host/test/kernel-scratch-contract.test.ts` form the static contract. The +TypeScript compiler and type checker discover production JavaScript, +TypeScript, and selected diagnostic sources recursively, seed their ownership +roots, and propagate kernel-memory ownership through aliases, helper parameters +and returns, spreads, destructuring, logical/comma expressions, loop bindings, +and common intrinsic array element/callback methods. Kernel instance/export +namespace ownership is followed through `getInstance().exports.memory`, not +only the sibling `getMemory()` escape. Typed-array receiver methods use a +positive non-retaining whitelist; callback container arguments and retained +iterators remain visible to the analysis. They report raw +typed/DataView construction, scalar and bulk writes, escapes, persistent +stores, returned views, allocator calls, scratch-region factory calls, and +spawn-reservation calls. The manually reviewed +`KERNEL_SCRATCH_EXPORT_NAMES` capability list supplies the pointer-export +contract. It is not a fail-closed classification of every present or future +kernel export: a newly named direct export omitted from that list would not be +recognized by the pointer-export finding alone. Listed exports and +computed/unknown export access are tracked through direct members, +destructuring, aliases, `call`/`apply`/`bind`, and `Reflect.apply`; invoking or +escaping them outside `KernelScratchLease.invokeKernelExport` is a contract +finding even when every argument is an untainted primitive. Independently, +the ownership audit still rejects any repository-owned raw kernel-memory +view/write and any unreviewed allocator, reservation, or region-factory call. +That independent fail-closed check is the future direct-variable-write +contract. The narrow exact pointer-export allowances are scalar/no-pointer +manifest, ABI, IPC-size, and ioctl queries plus the scratch core's captured +arity inspection. Token-only reserved spawn is not classified as a +pointer-bearing export. A separate exact multiset allowlist admits only named +reviewed occurrences with inline reasons; +adding or duplicating an occurrence fails, and deleting one makes its allowance +stale +and also fails. Framebuffer, process-memory, Rust-lent, and explicit +shared-backing roots remain separately classified because their owner is not +the kernel scratch allocator. A `.set` or `.decode` call counts as a +synchronous reader only when TypeScript resolves it to the native typed-array +or `TextDecoder` declaration; a same-named custom method remains an escape. +JavaScript-family sources have one additional narrow syntax backstop: +zero-argument `.getMemory()` and `.getInstance()` calls are treated as the +documented raw kernel-memory authorities even when an untyped receiver prevents +the checker from recovering its class. The normal ownership analysis then +follows those values +through aliases and helper parameters into raw typed-array or `DataView` +writes. An unrelated JavaScript API with the same spelling requires an exact +site allowance; no file is excluded to suppress it. +This is a compiler-backed contract over the reviewed repository source, not a +claim of a sound general-purpose JavaScript taint analysis or control over raw +writes performed by a downstream consumer through the unsafe trusted-embedder +accessors. + +Rust has a separate source-contract guard for dispatcher pointers. +`crates/kernel/src/channel_scratch.rs` test +`raw_channel_pointer_allowlist_contains_only_process_addresses` rejects the +former raw-channel-pointer macro, matches every remaining +`process_address!` occurrence against its exact reviewed syscall context, and +checks the total occurrence count. The surviving sites carry guest virtual +addresses for memory-management, clone, futex, and related operations; they do +not authorize kernel-scratch dereferences. Exact contexts plus the count are +both required so removing one approved site and adding an unrelated one cannot +evade review. + +The option-sensitive `prctl` numbers and name width, the Fcntl lock-record +width, and the signal-mask width are also cross-layer marshalling contracts. +They are defined once in the `prctl` and `kernel_scratch_wire` modules in +`crates/shared/src/lib.rs`, emitted by `tools/xtask/src/dump_abi.rs`, and +consumed from `host/src/generated/abi.ts`; the host and Rust validators do not +repeat numeric literals. This generation-only deduplication does not itself +require an ABI bump. + +For generic descriptor staging, the host first captures every `Deref`-derived +caller `u32`, then uses that same value to size the companion buffer and stage +the length record. This makes planning independent of generated descriptor +order and leaves no second guest-memory read that another process thread could +replace. Rust independently proves canonical pointer order, alignment, +non-overlap, and allocation bounds from the staged descriptor values. The +generic aligned wire does not, however, carry a separate unpadded capacity for +each descriptor. Rust therefore cannot reconstruct a hypothetical +host-planned capacity change that stays inside one eight-byte alignment +bucket. The exact capacity authority is the host's captured value under the +single synchronous, non-reentrant lease. Adding an independent +per-suballocation capacity would require a further ABI field (or removing the +alignment slack); this audit does not overstate the information available to +the current Rust validator. + +## Allocation inventory + +The last column records current source safety and a coverage pointer only; it +does not record validation readiness. Reviewed exclusions are outside this +abstraction because they have a different owner or lifetime, not because they +are assumed safe. Mutable exact-head validation status is recorded in the +draft PR ledger, independently of these source-safety rows. + +| Region and symbols | Allocating owner; pointer/capacity | Maximum accepted source | Lifetime and overlap | Hosts / widths | Historical audited-head finding | Current safety disposition and coverage notes | +|---|---|---|---|---|---|---| +| Raw allocator boundary, `crates/kernel/src/wasm_api.rs::kernel_alloc_scratch`; `crates/kernel/src/scratch_alloc.rs::layout` | Rust global allocator; successful pointer owns exactly the validated `Layout` size | The export accepts a `u32` request, but a successful allocation is further bounded by the aligned Rust `Layout`/`isize::MAX` domain | Allocation is retained for the kernel lifetime; no host-side free or growth workaround | Node/browser; wasm32/64 kernel | **Unsafe failure boundary.** Invalid `Layout` construction could trap instead of reporting allocation failure | **Implemented; validation pending.** Zero/invalid layouts and allocator-null return zero; the host rejects an invalid zero or out-of-memory-range result before constructing a region | +| Main syscall scratch, `CentralizedKernelWorker.scratchRegion` | Rust `kernel_alloc_scratch`; `KernelScratchRegion`, 65,608 bytes (`CH_TOTAL_SIZE`) | Each layout is checked against the region; ordinary data payload is at most 65,536 bytes (`CH_DATA_SIZE`) | Kernel lifetime; one synchronous lease per dispatch/copy; nested leases fail | Node and browser; wasm32/64 kernel | **Unsafe contract.** Bare `scratchOffset`; several live overflows | **Implemented; validation pending.** All allocator-owned access is lease-mediated | +| TCP/pipe scratch, `tcpScratchRegion`, `requireTcpScratchRegion` | Rust `kernel_alloc_scratch`; `KernelScratchRegion`, 65,536 bytes | One checked network/pipe chunk, at most 65,536 bytes | Kernel lifetime; worker callbacks/messages detach bytes before yielding | Node/browser; wasm32/64 kernel | **Safe sizes, weak contract.** Private pointer reached other code | **Implemented; validation pending.** Region stays private and all access is synchronously leased | +| Large spawn scratch, `beginLargeSpawnScratch`, `SpawnScratchBuffer` | Rust `Vec` through required `kernel_spawn_scratch_begin/pointer/capacity/cancel`; the returned token gates both pointer and capacity, while separate pointer-free retained-capacity telemetry grants no write authority | Complete blob at most 8,417,320 bytes; ordinary blobs use main scratch | Kernel-lifetime high-water allocation, but a fresh exclusive token and single-use host region per operation. Begin and queries are nonblocking; begin may move only while idle. After every successful begin, host cleanup runs in `finally`. Commit/cancel wait on the same no-import lock and return with a definitive token state; cleanup failure is fatal and leaves the host reentry guard closed | Node/browser; wasm32/64 kernel | **Safe after #1094, weak contract.** Fixed 8,417,320-byte allocation retained after first large use | **Safe in current source.** `kernel_spawn_reserved_process` accepts token+length rather than a bare pointer, with no ABI-42 fallback. The focused Node/Chromium sizing measurements are historical pre-retarget evidence; the frozen final-head rerun remains pending | +| Audio drain, `WasmPosixKernel.audioScratchRegion` | Rust `kernel_alloc_scratch`; 65,536-byte `KernelScratchRegion` bound to the exact Wasm instance and memory that allocated it | `min(out.byteLength, capacity)` and checked Rust return count | One kernel-wrapper generation; one synchronous drain lease. `init` and `initWithMemory` are mutually exclusive one-shot entry points, so a cached region cannot survive an instance replacement | Node/browser; wasm32/64 kernel | **Confirmed unsafe/uncertain.** Pointer/range and producer count were incomplete, and a later second initialization could leave the cached region bound to the old generation | **Safe in current source.** Allocation, requested bytes, current range, returned count, and one-generation lifetime are checked | +| Public wrapper temporary storage, `apiScratchRegion` | Rust `kernel_alloc_scratch`; 65,536-byte `KernelScratchRegion` bound to one exact kernel generation | Each socket/poll/terminal/ioctl/uname/pipe/rusage/select request must fit | One kernel-wrapper generation; synchronous public-call lease. Concurrent or post-success initialization rejects before state mutation; a failed first attempt clears partial state and remains retryable | Node/browser; wasm32/64 kernel | **Confirmed unsafe.** Hard-coded addresses 4 and 16 were not allocations, and later reinitialization could pair an old cached region with a new memory/instance | **Safe in current source.** All temporary public API storage is allocator-owned and cannot outlive its generation | +| Rust-lent host-import destinations, `checkedWasmImportMemoryRange`, `readKernelBytes`, `writeKernelBytes` | Rust slice/local/struct; pointer plus explicit capacity, or a generated authoritative fixed-format size such as the 68-byte KMS mode record, for one import call | Genuine producer span no larger than the Rust-supplied or generated capacity | Only the synchronous import; backend data is staged in host-owned memory and no kernel view is lent or retained | Node/browser; wasm32/64 kernel | **Valid ownership, incomplete checks.** Lossy conversions, live-view lending, and clamping writes existed | **Implemented; validation pending.** Signed-wasm32/wasm64 pointer normalization, complete range, intrinsic producer span, detached/staged backend I/O, and producer/result length precede one publish | +| Unsafe trusted-embedder accessors, `WasmPosixKernel::{getMemory,getInstance}` and `CentralizedKernelWorker::{getKernel,getKernelInstance}` | Exposes the complete raw kernel memory/instance, not a capacity-bearing allocation | Unrestricted by design; consumers are trusted to uphold the kernel ABI | Repository transfer code does not use this path; external direct mutation has no lease or overlap guarantee | Node/browser; wasm32/64 kernel | Existing public low-level/debug API | **Reviewed out-of-contract boundary.** Explicitly documented as unsafe; the static repository audit does not claim to control downstream raw-memory writes | + +## Transfer inventory + +“Synchronous” below means that no promise, worker-message yield, or callback +boundary can occur while Rust is expected to consume the staged bytes. +Single-threaded event-loop execution is not used as a substitute for capacity +or range validation. The last column records current source safety and coverage +only. Its mutable exact-head validation status is recorded in the draft PR +ledger. + +| File / exact symbols | Owner; pointer and declared capacity | Maximum accepted source and origin | Capacity, range, and pointer proof | Synchronous use / overlap | Hosts / widths | Historical audited-head finding | Current safety disposition and coverage notes | +|---|---|---|---|---|---|---|---| +| `host/src/kernel-worker.ts::pollWaitableChild`; `crates/kernel/src/wasm_api.rs::kernel_wait_child_poll`; `crates/shared/src/lib.rs::{KernelWaitResult,KERNEL_WAIT_RESULT_SIZE}` | Rust main allocation; one `KernelScratchLease` lends `STRUCT_SIZE_KERNEL_WAIT_RESULT` bytes (160) and passes the same explicit capacity to Rust | Exactly one fixed 160-byte wait-result record generated from the shared `KernelWaitResult` layout | The lease proves allocator ownership, allocation capacity, current-memory bounds, and lossless kernel-width conversion. Rust rejects pointer zero with `EFAULT` and every capacity other than 160 with `EINVAL` before task validation or waitable-child selection | One synchronous poll and detached decode inside the lease. Rejected output ranges cannot select or consume the sole event; a successful non-`WNOWAIT` call publishes the complete record and reaps atomically | Node/browser shared path; kernel wasm32/64. The shipped real-Wasm regression executes wasm32, and host mocks exercise bigint pointer handling | **Unsafe ABI-42 contract.** The export accepted a bare result pointer, so the host/Rust boundary could not prove that 160 writable bytes belonged to the allocation before selecting the child event | **Safe in current source.** The real-Wasm regression covers pointer zero, capacities 159/160/161, canaries, rejected-call non-consumption, exact-capacity reap, and the following `ECHILD` result | +| `host/src/kernel.ts::{intrinsicBufferSourceSpan,bufferSourceToArrayBuffer,init,initWithMemory,initialize}` | Caller supplies kernel module bytes; the host immediately owns one detached `ArrayBuffer` snapshot, then publishes one exact instance/memory generation | Exact intrinsic `ArrayBuffer`, typed-array, or `DataView` byte window accepted by the WebAssembly compiler | Captured native internal-slot getters reject non-genuine/detached sources and ignore subclass span getters; pointer-width detection and compilation consume the same snapshot. An explicit initialization state rejects a concurrent or post-success initializer before it mutates width, memory, instance, or cached scratch authority | Snapshot completes before the asynchronous compile; later caller mutation cannot replace either consumer's bytes. A failed first instantiation clears partial state and permits one clean retry; a successful wrapper is one-shot | Node/browser; kernel wasm32/64 | **Confirmed pointer-width and generation-lifetime defects.** A view subclass could make width detection parse decoy bytes while the engine compiled its intrinsic bytes; a second init could leave cached scratch authorized against the old instance | **Safe in current source.** Spoofed-input, wasm32/wasm64 cached public/audio scratch, rejected reinit, concurrent init, and failed-init retry regressions cover the contract | +| `host/src/kernel-worker.ts::replaceProcessMetadata` | Rust main allocation; private `scratchRegion`, 65,608 bytes; payload begins at `CH_DATA` | One metadata entry at most `CH_DATA_SIZE` (65,536); exec argv/environment aggregate at most generated `ARG_MAX` | Detached caller bytes; lease proves owned allocation and current memory; Rust return count is bounded | One lease and Rust call per entry; view is reacquired after possible growth; no overlap | Node/browser; kernel and guest wasm32/64 | Sizes fit, but a bare pointer represented ownership | **Implemented; validation pending.** Lease-mediated staging | +| `host/src/kernel-worker.ts::{handleExec,handleExecveat,readExecPathFromProcess,readStringArrayFromProcess,resolveExecPathAgainstCwd,checkedScratchProducerByteLength}` | Exec pathname/argv/environment are detached JS strings read from caller process memory; only CWD/fd-path queries use the 65,608-byte main allocation | Path scan is bounded by generated `PATH_MAX` 4,096; each string by 65,536; complete argv/environment representation, including pointers and NULs, by generated `ARG_MAX` 4 MiB; CWD/fd-path output by 4,096 | Native pointer-array entries are read at guest width and wasm64 values must be losslessly representable; every string must terminate in its caller range; each direct `withLease` query passes exact pointer/capacity, validates Rust's count with `checkedScratchProducerByteLength`, and detaches with `copyOut` before releasing the lease | No scratch view crosses `callbacks.onExec`'s promise; only detached strings/arrays do. Each CWD/fd-path query completes its lease before the callback | Node/browser; guest wasm32/64 independent of kernel width | **Unsafe/uncertain edge.** Async exec and bounded-string paths used bare scratch queries and lossy/incomplete pointer scans | **Implemented; validation pending.** Explicit `PATH_MAX`/`ARG_MAX`, lossless native-pointer, checked producer count, and no-view-across-promise contract | +| `host/src/kernel-worker.ts::{ptyMasterWrite,ptyMasterRead}` | Rust main allocation, full 65,608-byte region | Write chunks are `min(remaining, lease.capacity)`; read request is `min(4,096, lease.capacity)` | Write source slice and destination are independently checked; returned write/read count must be a safe integer no larger than the offered chunk/request | One lease per chunk/call; read bytes are detached before `drainPtyOutput`; a second operation cannot enter the active lease | Node/browser; kernel wasm32/64 | **Confirmed unsafe.** `ptyMasterWrite` copied arbitrary `data.length` into the allocation; read trusted the producer count | **Implemented; validation pending.** Exact 65,608 and 65,609 regression | +| `host/src/kernel-worker.ts::setCwd` | Rust main allocation, 65,608 bytes | Encoded path must be shorter than generated `POSIX_PATH_MAX_BYTES` (4,096, including the NUL contract) | Length is rejected before acquiring/copying; lease then proves allocation and current-memory bounds | One synchronous lease and `kernel_set_cwd` call; no retained view | Node/browser; kernel wasm32/64 | **Confirmed unsafe.** Copy happened before Rust's `PATH_MAX` rejection | **Implemented; validation pending.** Pre-copy oversized-CWD regression | +| `host/src/kernel-worker.ts::{enumProcs,readProcMaps,checkedScratchProducerByteLength}`; Rust exports `kernel_get_cwd`, `kernel_get_fd_path`, and wait/wake/mqueue query helpers | Rust main allocation, 65,608 bytes | Fixed or explicit producer requests, presently no more than 4,096 bytes for paths and 1,280 bytes for listed fixed records | Requested capacity is passed to Rust; returned byte/count value must be safe and fit that capacity before the same lease calls `copyOut` | Producer runs inside one direct checked lease; detached bytes cross any callback/retry boundary | Node/browser; kernel/guest wasm32/64 | Fixed requests fit; several producer counts were trusted | **Implemented; validation pending.** Inline checked leases and `checkedScratchProducerByteLength` replace the removed aggregate helper | +| `host/src/kernel-worker.ts::CentralizedKernelWorker::_handleSyscallInner`; `host/src/generated/abi.ts::SYSCALL_ARGS`; `crates/shared/src/host_abi.rs::{SyscallArgDesc,SyscallArgSize}`; `crates/kernel/src/channel_scratch.rs::{ChannelScratchRegion,validate_channel_scratch_arguments,validate_prctl_layout,checked_cstr_len}`; `crates/kernel/src/wasm_api.rs::dispatch_channel_syscall` | Rust main allocation; channel is 72 bytes and data capacity is exactly 65,536; `kernel_handle_channel(offset, capacity, pid)` carries that complete capacity through dispatch | Sum of all descriptor-sized arguments, including alignment, must fit `CH_DATA_SIZE`; every pointer descriptor is explicitly required or nullable; size expressions originate in generated shared ABI metadata and raw syscall counts; every C string must terminate inside the remaining channel allocation | The host rejects negative, fractional, unsafe-integer, multiplication/addition overflow, positive null unless explicitly nullable, and a non-null `Deref` outer buffer without its length pointer. Null argument-sized zero-length buffers become a non-null owned empty range. All `Deref` lengths are captured before planning, then used for both buffer sizing and staged length independent of descriptor order. Rust verifies canonical pointer order, alignment, non-overlap, allocation bounds, descriptor nullability, and bespoke layouts before a checked pointer can reach dispatch. It also rejects a C-string pointer outside the numeric region or a missing in-region NUL; pathname exports separately retain generated `PATH_MAX` semantics. `prctl` uses an option-sensitive validator: name operations receive one exact required 16-byte range and scalar options receive no scratch pointer | Planning retains host-owned copies only; one lease stages, dispatches, detaches output inline in `_handleSyscallInner`, and releases; nested or promise-escaping lease use fails. Rust recomputes a dynamic range from the staged length but cannot reconstruct a separate unpadded host capacity within one alignment bucket because the wire does not encode one; the pre-captured host value under this lease remains the exact-capacity authority | Node/browser; guest wasm32/64 independent of kernel width | **Confirmed unsafe/uncertain domain edges.** Some raw pointers bypassed descriptors, fixed outputs such as `pipe(NULL)` were implicitly treated as nullable, `prctl` scalars were treated as pointers, `Deref` planning could reread mutable lengths, staging was not ownership-bearing, and Rust's bare-pointer scanner used `PATH_MAX` as both an allocation and semantic bound | **Implemented; focused validation passed.** Exact/capacity+1, positive-null and owned-empty, explicit-nullability drift, option-sensitive `prctl`, reordered/mutated `Deref`, exact raw-process-address allowlist, bounded C-string EFAULT, and non-path strings above `PATH_MAX` | +| `host/src/kernel-worker.ts::{_handleSyscallInner,completeChannel,handleBlockingRetry,handleSleepDelay}`; `PreparedChannelCompletion` | Output belongs to the just-completed main-scratch lease, but the only state allowed to outlive it is a detached `Uint8Array` plus its already-validated process destination | Exactly the output descriptors and successful byte counts detached inline in `_handleSyscallInner` before lease release; error and interrupted completions publish no staged output | `completeChannel` has no scratch-read fallback. Retry, timeout, stopped-process, signal, and teardown state accept only explicit detached writes; absent output means an empty list | Detachment occurs synchronously in the dispatch lease; later callbacks may overlap another scratch use without observing its bytes | Node/browser; guest/kernel wasm32/64 | **Confirmed lifetime defect.** Deferred completion could reread the shared allocation after another operation replaced it | **Implemented; validation pending.** Immediate-timeout poll, EAGAIN `recvmsg`, interrupted sleep, and stale-scratch regressions | +| `host/src/kernel-worker.ts::{PreparedChannelCompletion.deferredClone,failDeferredCloneLaunch}` | Caller process mailbox, not kernel scratch; the original four-byte parent-TID destination is validated and retained as a scalar | Exactly one `pid_t` word when the original clone requested `CLONE_PARENT_SETTID` | Rollback uses the captured `parentTidPointer`; it never rereads mutable flags or a replacement pointer from a parked mailbox | Parked completion may span worker construction and stop/continue callbacks, but retains no process or scratch view | Node/browser; guest wasm32/64 | **Confirmed deferred-lifetime defect.** Failure rollback reread mutable mailbox metadata and could clear a replacement address | **Implemented; validation pending.** Mailbox-replacement regression proves only the original word is cleared | +| `crates/shared/src/process_layout.rs`; `crates/shared/src/host_abi.rs::SyscallArgSize::ProcessLayout`; `crates/kernel/src/process_wire.rs::{read_*,write_*}` | Main data capacity 65,536; exact width-selected native record is the capacity passed to Rust | `stack_t` 12/24, kernel-facing `itimerval` 16/32, `mq_attr` 32/64, `sigevent` 64/64, `statfs` 88/120, `sysinfo` 312/368, and `siginfo_t` 128/128 | Host selects by guest pointer width in private slot 5, validates the full caller range, and stages exactly that size; Rust rejects widths other than 4/8 and non-exact slices; output padding/reserved bytes are zeroed | One dispatch lease; Rust serializes into the complete lent slice before copy-back | Node/browser; guest wasm32/64 independent of kernel width | **Confirmed unsafe mixed-width/native-layout contract.** Fixed wasm32 or partial records truncated wasm64/full native records; stale `sysinfo` syscall 208 conflicted with musl 269 | **Safe in current source.** Historical C-layout and Rust boundary coverage plus the current dirty-tree Node and real-Chromium wasm32/wasm64 process-native fixtures pass; the exact-final-head rerun remains pending | +| `host/src/kernel-worker.ts::dequeueSignalForDelivery`; `crates/kernel/src/wasm_api.rs::kernel_dequeue_signal`; `crates/kernel/src/process_wire.rs::{validate_signal_delivery_output,encode_signal_delivery_record}`; `libc/glue/channel_syscall.c` signal delivery | Rust main allocation; `KernelScratchLease.exportPointer(CH_SIG_BASE, 56)` lends exactly the generated 56-byte signal-delivery record and passes capacity 56 separately | Exactly one generated signal record: signum, handler, flags, raw eight-byte `si_value`, saved mask, `si_code`, two sender/timer metadata words, and alternate-stack pointer/size | The host lease proves the owned allocation and current-memory range; Rust rejects null and any capacity other than 56 before writing. Rust first encodes all 56 bytes into an owned array, then publishes once. The host detaches all 56 bytes before releasing the lease and copies them to the process channel only after a nonnegative result | One synchronous lease per dequeue; the detached record is published only after the lease ends, so a wake or second channel cannot observe partially replaced scratch bytes. The C trampoline reconstructs a native `siginfo_t` and copies only the target-width `union sigval` bytes: four for wasm32 and eight for wasm64 | Node/browser; kernel wasm32/64 and guest wasm32/64 | **Weak capacity and metadata contract.** The old export accepted only a bare output pointer, and its 44-byte payload inside a 48-byte reserved channel area did not carry complete `si_value`, sender/timer metadata, or one authoritative delivery size | **Implemented; focused Node and real-Chromium validation passed on the current dirty tree.** Rust exact-capacity/serialization tests and the rebuilt real-musl process-native fixture cover 56-byte delivery, `SA_SIGINFO`, sender metadata, and target-width C reconstruction on wasm32 and wasm64; the exact-final-head rerun remains pending | +| `host/src/kernel-worker.ts::drainMqueueNotification`; `crates/kernel/src/wasm_api.rs::{queue_mqueue_signal_notification,kernel_mq_drain_notification}`; `crates/kernel/src/mqueue.rs::mq_notify` | Rust main allocation; one leased pointer plus explicit capacity 8 for the wake-only `{ pid: u32, signo: u32 }` record. The full notification value remains in Rust's signal queue rather than this scratch record | At most one eight-byte wake record. `mq_notify(SIGEV_SIGNAL)` accepts only signums satisfying `1 <= signo < NSIG`; zero, `NSIG`, and a negative native value represented as `u32::MAX` are rejected before registration | The lease proves the owned allocation/current memory and Rust requires capacity 8 before writing. The host accepts only safe-integer results 0 or 1; a negative errno, fractional/unsafe value, or value above 1 fails closed before unchanged reusable bytes can be decoded. Rust queues raw eight-byte `si_value`, `SI_MESGQ`, sender PID, and UID before publishing the wake record | The eight bytes are detached inside one synchronous lease. The lease is released before wake/signal processing can reenter main scratch. A rejected registration does not occupy the queue's one-shot notification slot | Node/browser; kernel wasm32/64 and guest wasm32/64 | **Weak capacity and error contract.** The old drain export accepted a bare pointer, and a negative errno was truthy in JavaScript and could decode stale scratch as a fabricated notification; invalid signal registrations were not rejected before occupying the slot | **Implemented; focused Node and real-Chromium validation passed on the current dirty tree.** Native tests cover invalid signums without registration and a valid retry; the rebuilt process-native fixture covers `SI_MESGQ` plus full-width value/sender metadata, and the host regressions prove both fail-closed negative-result handling and the real export's null/7/8/9 boundary with exact eight-byte output canaries; the exact-final-head rerun remains pending | +| `crates/shared/src/host_abi.rs` `timer_create` process-layout descriptor; `host/src/kernel-worker.ts::_handleSyscallInner`; `crates/kernel/src/wasm_api.rs::kernel_timer_create`; `crates/kernel/src/process_wire::read_sigevent` | The caller-native 64-byte `sigevent` is staged in the 65,536-byte main data allocation; the timer ID output has its separately described caller and scratch capacity | Null selects the POSIX default; otherwise exactly 64 bytes. `union sigval` contributes four meaningful bytes for a wasm32 caller or eight for wasm64, while the containing native structure remains 64 bytes on both | Generic descriptor planning proves the complete caller range and main-allocation capacity. The private process-pointer-width slot is passed losslessly to `kernel_timer_create`; Rust accepts only width 4 or 8, selects that exact native layout, and preserves the parsed value as raw `u64` bits through timer state and delivery | One synchronous dispatch lease covers staging, parsing, timer creation, and detached timer-ID copy-back. No native view or scratch pointer survives the call | Node/browser; guest wasm32/64 independent of kernel width | **Confirmed mixed-width value defect.** The old export had no caller-width argument and parsed only a partial `sigevent`, so a wasm64 `sival_ptr` could be narrowed | **Implemented; focused native, Node, and real-Chromium validation passed on the current dirty tree.** Exact/short native-layout tests and the rebuilt process-native fixture cover wasm32 low-32-bit and wasm64 full-64-bit timer values; the exact-final-head rerun remains pending | +| `crates/shared/src/host_abi.rs::SyscallArgSize::Fixed`; `crates/kernel/src/process_wire.rs::{write_stat,read_sched_param,write_sched_param}` | Main data capacity 65,536; the fixed native record size is part of the generated syscall descriptor | `stat` 112 bytes and `sched_param` 48 bytes on both supported caller widths | The descriptor proves the complete caller range and exact fixed capacity; these records do not use width selection or private slot 5 | One dispatch lease; Rust consumes or fills the complete fixed slice | Node/browser; guest wasm32/64 | **Confirmed partial-record contract.** Earlier descriptors did not name the complete musl object | **Implemented; validation pending.** Fixed-layout C drift checks and Rust exact/short tests | +| Generated `timerfd_settime`, `timerfd_gettime`, `signalfd`, and `signalfd4` descriptors; Rust checked channel-pointer consumers | Main data allocation; native timer records are 32 bytes and the signal mask is exactly eight bytes | Fixed generated record sizes; nullable old-timer output is the only optional timer pointer | Complete caller range, direction, nullability, allocation capacity/current memory, and Rust channel-pointer checks; the raw caller pointer never enters the kernel namespace | One dispatch lease; all input/output is detached at the normal completion boundary | Node/browser; guest wasm32/64 independent of kernel width | **Confirmed address-domain defect.** Caller pointers were passed as kernel pointers | **Safe in current source.** Historical pre-retarget coverage rebuilt the ABI-43 kernel/host artifacts and passed guarded caller-object cases for wasm32 and wasm64; final-head Node and browser reruns remain pending | +| `crates/shared/src/host_abi.rs` `Getaddrinfo` descriptor; `host/src/kernel-worker.ts::_handleSyscallInner`; `host/src/kernel.ts::hostGetaddrinfo` | Main data allocation; output capacity exactly four bytes, matching musl's private syscall result | Input is a required NUL-terminated name; name plus four-byte output must fit 65,536; host backend result must be exactly/fewer than the lent four bytes | Full caller name and four-byte output ranges; descriptor capacity and current memory; Rust and host import both receive explicit four-byte capacity | One dispatch lease and synchronous host import; four detached bytes are copied back | Node/browser; guest/kernel wasm32/64 | **Confirmed live caller overwrite.** Fixed 256-byte copy-back wrote 252 bytes beyond musl's four-byte result object | **Implemented; validation pending.** Four-byte result plus 252-byte canary regression | +| `host/src/kernel-worker.ts::handleGetgroups`; `crates/kernel/src/wasm_api.rs::kernel_getgroups(size,list_ptr,list_capacity_bytes)` | Rust main allocation; positive request lends one explicit four-byte gid slot; count query lends pointer/capacity zero | Kandelo currently returns exactly one supplementary gid; `size` accepts 0 through `INT_MAX`, but positive size never increases the lent capacity beyond four | Positive caller output range is exactly four bytes; kernel pointer and capacity are staged together; Rust rejects null or capacity below four; returned count must be safe, `<= size`, and `<= 1` | One lease; output is detached before reuse; zero-count query performs no pointer conversion | Node/browser; guest/kernel wasm32/64 | **Confirmed unsafe.** A raw process pointer crossed into the kernel address space and Rust wrote one `u32` without an allocation-capacity contract | **Implemented; validation pending.** Capacity 0/3/4/5, null, count-query, and detached-copy regressions | +| `crates/shared/src/host_abi.rs` `Setgroups` descriptor; `host/src/kernel-worker.ts::_handleSyscallInner` | Rust main data allocation, exactly 65,536 bytes | Count times four bytes; maximum one-call source is 16,384 gids from `CH_DATA_SIZE / sizeof(gid_t)` | Checked integer multiplication, complete caller source, descriptor layout, allocation capacity, current memory; count zero ignores the caller pointer and resolves a checked non-null empty scratch address under the final lease | One dispatch lease; no scratch view survives | Node/browser; guest/kernel wasm32/64 | **Unsafe address-domain contract, not a demonstrated live overwrite.** Bare caller pointer could enter the kernel namespace; current Rust did not dereference it | **Implemented; validation pending.** 16,384/16,385, zero-count high pointer, and positive null regressions | +| `crates/shared/src/ioctl_contract.rs::IOCTL_REQUEST_CONTRACTS`; `host/src/kernel-worker.ts::_handleSyscallInner` ioctl branch; `crates/kernel/src/wasm_api.rs::kernel_ioctl` | Rust main data allocation; pointer requests receive exact request-specific capacity; scalar/no-argument/unknown requests receive no scratch pointer | Pointer sizes are table-selected: 1–160 bytes in the current table, including `termios` 60 and `DRM_IOCTL_VERSION` 36 for wasm32 or 64 for wasm64 | Unsigned request lookup; exact guest-width size/direction; complete caller range; null and one-byte-short rejection; explicit `buf_len`; Rust repeats kind, width, exact length, null, and current-memory checks. `ScalarI32` requests canonicalize only their low 32 transport bits, so unspecified upper wasm64 C-vararg bytes neither become a pointer nor reach Rust. Known width-incompatible pointer requests return `EOVERFLOW` | One dispatch lease; no pointer is manufactured for scalar/no-arg/unknown requests | Node/browser; guest wasm32/64 independent of kernel width | **Confirmed unsafe/incorrect.** Generic 256-byte staging/copy-back overran small caller objects and scalar values were treated as pointers; width-specific DRM layout was not represented | **Implemented; generated/runtime validation pending.** FIONREAD four-byte canary, exact 4/36/64, short/null, every scalar request with signed/unsigned and dirty-high-bit inputs, no-arg/unknown, and unsupported-width regressions | +| `host/src/kernel-worker.ts::{checkedNetworkIoctlProcessRange,handleIoctlIfconf,handleIoctlIfname,handleIoctlIfhwaddr,handleIoctlIfaddr,handleIoctlIfindex}` | Caller process memory, not kernel scratch; required outer `ifconf`/`ifreq` and the nested process buffer are caller-owned | Command-specific 8/16-byte `ifconf`, 32/40-byte `ifreq`, and `ifconf.ifc_len`; no shared-table maximum substitutes for the nested length | The shared checked process-range proof rejects a null/short outer object and checks the complete nested output range; the wasm64 nested pointer remains `bigint` until lossless conversion. Only `ifc_buf == 0` after a valid outer structure retains Linux size-query semantics | Synchronous host-side handling; no kernel scratch lease or retained view | Node/browser shared code; guest wasm32/64 | **Confirmed unsafe caller-boundary defect.** The former ad-hoc total-memory check accepted outer address zero, and the nested wasm64 pointer was narrowed before its proof | **Implemented; focused Node validation passed.** Exact and one-byte-short outer/nested ranges, capacity+1 output canaries, every network `ifreq` handler, null outer objects, and high/unsafe wasm64 non-aliasing are included in the current 189-test focused transfer-boundary file; the exact-final-head browser rerun remains pending | +| `host/src/kernel-worker.ts::handleFcntlLock` | Rust main data allocation; 32-byte `struct flock` | Exactly 32 bytes | Full caller range, owned scratch range, current memory | One synchronous lease | Node/browser; guest/kernel wasm32/64 | Fixed size fit, bare pointer | **Implemented; validation pending** | +| `host/src/kernel-worker.ts::{handleSelect,handlePselect6}` | Rust main data allocation; three optional generated 128-byte fd sets plus timeout/mask records | Generated `FD_SETSIZE` 1,024 and `fd_set` size 128; optional eight-byte kernel mask and native timeout inputs | `nfds` is bounded by the generated set width; every optional fd set, timeout, outer pselect sigmask descriptor, and nested mask range is checked before staging | Each attempt is synchronous; retry owns copies/scalars and no scratch view | Node/browser; guest/kernel wasm32/64 | **Confirmed unsafe caller-range paths and duplicated layout constants** | **Implemented; focused Node validation passed.** Select/pselect count/range boundaries use the generated contract | +| `host/src/kernel-worker.ts` generic `ppoll` descriptor planning and retry conversion | Main allocation/channel; 16-byte caller timespec and optional eight-byte signal mask become scalar kernel arguments | Fixed native records from syscall contract | Raw pointers remain bigint until lossless conversion; both complete caller ranges are proved on the first attempt and retry | Only final dispatch lease contains scratch bytes; retry retains scalars, never a view | Node/browser; guest wasm32/64 | **Unsafe/uncertain.** Special pointers were outside generated descriptors | **Implemented; validation pending.** Out-of-range and unrepresentable wasm64 regressions | +| `host/src/kernel-worker.ts::{handleEpollCtl,handleEpollPwait}`; `crates/shared/src/lib.rs::WasmEpollEvent` | Caller process memory for events plus main scratch for the internal poll request; native epoll event is exactly 16 bytes | One `epoll_ctl` event or checked `maxevents * 16`; fields are events at offset 0, zero/ignored pad at 4–7, data at offset 8 | Checked multiplication and complete caller input/output ranges; exact 16-byte records; copy-out explicitly zeroes padding and writes `u64` data at offset 8 | One synchronous attempt; retry/interest state stores values, not process or scratch views | Node/browser; guest wasm32/64 | **Confirmed unsafe caller/output range handling and stale 12-byte assumption** | **Implemented; validation pending.** Exact-end, one-byte-short, padding, and offset-eight regressions | +| `host/src/kernel-worker.ts::{checkedProcessIovecs,kernelIovecFootprint,handleWritev}` | Rust main data allocation; kernel table is 8 bytes per entry and payload follows with four-byte alignment after every entry | Count 1..generated `IOV_MAX` (1,024); full footprint is `8*count + Σ align4(iov_len)` and must be `<= CH_DATA_SIZE` | Native table is 8 bytes/entry on wasm32 or 16 on wasm64; table and every nested source are range-checked losslessly; total is `<= SSIZE_MAX`; result cannot exceed staged payload. Caller linear-memory address zero is valid for a table or data base when the complete positive-length range fits; `{ base: 0, len: 0 }` performs no data access. Positioned offsets remain exact signed `bigint` values across slow chunks | Fast path one lease; slow path sends one checked chunk of at most `CH_DATA_SIZE-8`; no view survives between calls | Node/browser; guest wasm32/64 | **Confirmed live allocation overflow and adjacent offset defect.** Admission omitted per-entry padding and could write 3,072 bytes past the 65,536-byte data area; slow `pwritev` rounded offsets above `Number.MAX_SAFE_INTEGER` | **Implemented; focused Node validation passed.** Exact footprint, address-zero semantics, and exact `2^53+1` slow-path offsets | +| `host/src/kernel-worker.ts::{checkedProcessIovecs,kernelIovecFootprint,handleReadv}` | Rust main data allocation; same 8-byte kernel table/alignment model | Count 1..1,024; table plus requested data must fit 65,536 for fast path; slow chunks reserve the eight-byte table first | Complete native table and every output buffer are checked; returned count must be safe and no larger than offered total; each copy-back uses checked destination capacity. Address zero is caller-owned process memory here, so bounded positive output and zero-length entries may begin there. Positioned offsets remain exact signed `bigint` values across slow chunks | Fast path one lease; slow path one bounded iovec chunk per lease; copy-back bytes are detached | Node/browser; guest wasm32/64 | **Confirmed live allocation overflow and adjacent offset defect.** Fast path subtracted only eight bytes and did not enforce `IOV_MAX`, reaching 8,184 bytes past the data allocation; slow `preadv` rounded offsets above `Number.MAX_SAFE_INTEGER` | **Implemented; focused Node validation passed.** Full-table/count/address-zero and exact `2^53+1` slow-path offset regressions | +| `host/src/kernel-worker.ts::{handleLargeWrite,handleLargeRead}` | Rust main data allocation; one data chunk at most 65,536 bytes | Requested scalar count may be larger, but each scratch transfer is `min(remaining, CH_DATA_SIZE)` | Complete caller source/destination range is proved before the first Rust call; each Rust count is safe and bounded by the offered chunk | One lease per chunk; no view survives | Node/browser; guest/kernel wasm32/64 | Scratch capacity fit; complete caller range was unsafe | **Implemented; validation pending.** Large-I/O source/destination regressions | +| `host/src/kernel-worker.ts::{_handleSyscallInner,handleLargeWrite,handleLargeRead,handleSharedMappingsAfterFileSyscall}` ordinary and large `pread`/`pwrite` | Main scratch for transfer; the positioned file offset is a signed i64 scalar and shared-mapping state has a separate host owner | Ordinary request at most 65,536 bytes; larger requests use checked chunks | The raw channel offset remains `bigint` through ordinary dispatch, large-operation preflight, chunk addition, and kernel argument encoding. Shared-mapping updates use the exact offset only when it is safely indexable; otherwise they refresh from the authoritative file instead of aliasing a rounded JS number | One lease per dispatch/chunk; mapping refresh owns no scratch view | Node/browser; guest wasm32/64 | **Confirmed precision defect adjacent to scratch dispatch.** Ordinary and large `pread`/`pwrite` rounded `2^53+1`, and a rounded shared-map offset could update the wrong page | **Implemented; focused Node validation passed.** Ordinary/large wasm32/wasm64 exact-i64 tests plus shared-map non-aliasing | +| `host/src/kernel-worker.ts::{checkedProcessMessage,nativeControlToKernelWire,kernelMessageLayout,handleSendmsg}`; `crates/kernel/src/socket_wire.rs`; fixed `Kernel{Msghdr,Iovec,Cmsghdr}Wire` | Rust main data allocation; one generated 28-byte fixed header, optional name/control, one generated eight-byte canonical iovec, and flattened payload share exactly 65,536 bytes | Caller-native `msghdr` is generated as 28 bytes on wasm32 or 56 on wasm64; native iovec count 0..generated `IOV_MAX` 1,024; the complete canonical footprint must fit | Full native header/table and every nested range are checked losslessly. Native `cmsghdr` records are validated and translated to a generated 12-byte-header/alignment-4 wire; all caller iovecs are flattened into one owned payload. Rust revalidates the complete canonical ancillary stream and accepts only the zero/one-iovec host wire. Returned count cannot exceed staged data | One synchronous lease covers header/control/flatten/call; only owned parsed metadata exists before it and no view survives | Node/browser; guest wasm32/64 | **Confirmed live allocation overflow plus mixed-width protocol defect.** Count/layout capacity was incomplete, only the first caller iovec reached Rust, and wasm64 ancillary headers were interpreted as wasm32 | **Implemented; focused Node validation passed.** `IOV_MAX+1`, exact/capacity+1 layout, multi-iovec/zero-entry flattening, malformed/wrapped control records, invalid descriptor propagation, sequential reuse, and wasm32/64 native-wire translation | +| `host/src/kernel-worker.ts::{checkedProcessMessage,kernelControlCapacityForRecv,kernelControlToNative,kernelMessageLayout,handleRecvmsg}`; `crates/kernel/src/wasm_api.rs::kernel_recvmsg` | Same fixed-wire main allocation; caller name, native control, and every native iovec destination retain their own separately checked capacities | Native header 28/56; count 0..1,024; one canonical contiguous receive payload plus name and the caller-representable canonical control capacity must fit 65,536 | Complete caller table/destination ranges are proved before dispatch. Canonical ancillary capacity is derived from native data capacity rather than total native header space; returned wire length, alignment, type, and descriptor width are validated before expansion. Payload is detached and scattered across all caller iovecs, skipping zero-length entries; native padding is zeroed. `MSG_TRUNC` may report the full datagram while only the bounded prefix is copied | One synchronous lease snapshots all output; caller publication uses detached arrays after release, and retry/error paths publish nothing | Node/browser; guest wasm32/64 | **Confirmed live allocation overwrite plus mixed-width/first-iovec defects.** Complete count/footprint was unproven, only one destination received bytes, and wasm64 `cmsghdr` capacity could install descriptors that could not be represented on copy-back | **Implemented; focused Node validation passed.** Exact/capacity+1, multi-iovec scatter with a zero middle entry, EAGAIN/no-publish, malformed canonical output, `MSG_CTRUNC`, wasm32/64 capacity matrices, flags, and padding | +| `crates/kernel/src/{pipe.rs,process_table.rs,socket.rs,syscalls.rs,wasm_api.rs}` AF_UNIX `SCM_RIGHTS`; `programs/scm-rights-semantics.c` | Stream ancillary records own retained descriptors at absolute carrier-byte ranges; each datagram queue entry atomically owns payload, source address, and retained descriptors | Generated control-record limits plus the fixed one-record host wire; receiver installation is additionally bounded by the caller control capacity and fd-table capacity | Stream reads cannot observe rights before their carrier bytes and stop `MSG_WAITALL` at a rights boundary. PEEK clones retained references fallibly without consuming them. Datagram enqueue rolls back all retained references if publication fails. Zero-iovec receive can consume a zero-byte datagram and its rights, while ordinary `read(...,0)` consumes nothing. Output `MSG_TRUNC`, input `MSG_TRUNC`, `MSG_CTRUNC`, and `MSG_CMSG_CLOEXEC` are independent. Snapshot, retain, complete-batch send, and receive installation each reject non-owning or non-reconstructible metadata; any socket in the batch returns `EOPNOTSUPP` before carrier publication | Pipe/datagram queues retain supported ownership until one consuming receive, ordinary carrier-byte discard, or close. Forced process removal, AF_UNIX datagram reconnect, and `SHUT_RD`/`SHUT_RDWR` first make every discarded queue entry visible to the one deferred-release drain; `SHUT_WR` preserves the readable queue. Accept failure and plain transfer syscalls finish any ownership they discard. Every channel dispatch clears its temporary task identity, then conditionally drains deferred ownership after all resource-table borrows end and before publishing the result. Direct host-pipe exports use the same one-check boundary, so a future ancillary-capable input cannot strand ownership. PEEK owns temporary fallible clones only. Data and ownership become visible atomically before readiness wakeup; rejected batches publish neither | Shared Rust kernel on Node/browser; real guest wasm32/64; AF_UNIX datagram routing remains same-process; socket-descriptor transfer is an explicit unsupported boundary | **Confirmed live semantic and cleanup-boundary defects.** In addition to the seven transport defects, forced removal and reconnect could discard queued datagram rights after the sole drain, read shutdown made queued rights unreachable, failed accept discarded a preaccepted stream carrying rights, and `sendfile`/`copy_file_range`/`splice` consumed plain bytes while silently discarding ancillary ownership. Direct host-pipe exports were a latent future boundary rather than an existing public ancillary input | **Safe in current source.** Historical pre-retarget evidence includes native kernel and 18/18 real-musl Node cases across wasm32/wasm64. Current dirty-tree real-Chromium evidence passes the same 16 semantics cases plus two pipe-lifetime cases; the exact-final-head browser rerun remains pending | +| `host/src/kernel-worker.ts::{handleSpawn,decodeSpawnBlobStrings,handleSpawnAfterResolve,beginLargeSpawnScratch,cancelLargeSpawnScratch}`; `crates/kernel/src/spawn.rs::{SpawnScratchBuffer,measure_strings_by_offset,decode_measured_strings}`; `crates/kernel/src/wasm_api.rs::kernel_spawn_reserved_process` | Ordinary blob uses main allocation; large blob uses token-bound Rust `Vec` whose pointer and actual capacity are returned only while reserved | Complete blob at most generated 8,417,320; argv/environment representation at most 4 MiB; path/action/count caps from generated contracts | Caller ranges, parsed counts, paths, complete blob length, allocation capacity, current memory, pointer width, token, and reservation state are independent checks. Host and Rust first measure every referenced string against one aggregate budget, then allocate/decode | Async lookup owns a JS copy; begin/copy/commit have no await. Begin and pointer/capacity queries fail without waiting on contention. After every successful begin, cancellation runs in `finally`, including setup/copy failure. Commit and cancellation wait on the same no-host-import mutex and return only after the token is consumed, released, or shown stale; host/Rust guards reject overlap. Duplicate maximum-count offsets cannot amplify allocations before rejection | Node/browser; guest/kernel wasm32/64 | #1094 spawn fix was capacity-safe but retained a fixed 8,417,320-byte region after first large use; decoding still admitted allocation amplification from duplicate offsets | **Safe in current source.** Growable Rust-owned tokenized reservation, pre-allocation aggregate accounting, and exact-count/`ARG_MAX` boundaries are covered. The real Node/Chromium workload is historical pre-retarget evidence; the frozen final-head rerun remains pending | +| `host/src/kernel-worker.ts::{populateMmapFromFile,pwriteFromProcessMemory,readSysvShmRange,writeSysvShmRange}` | Main data allocation for transit, 65,536 bytes per chunk; mapped/shared bytes have separate owners | One `CH_DATA_SIZE` chunk; overall mapping/segment size comes from checked mapping/kernel state | Complete process/mapping range and each Rust producer/consumer count; transit lease separately proves scratch capacity/current memory | One synchronous lease per chunk; authoritative shared bytes/snapshots live outside scratch | Node/browser; guest/kernel wasm32/64 | Capacity fit, bare pointer contract | **Implemented; validation pending** | +| `host/src/kernel-worker.ts::{handleIpcShmat,handleIpcShmdt}` | Process `Memory` mapping and host `SysvShmMapping.snapshot`, not kernel scratch; address key is the checked native guest pointer | Segment size returned by the kernel attachment operation; full mapped range must fit process memory | Raw bigint address is checked losslessly for guest width before attachment/map lookup; mmap result and full mapped range are checked; failure rolls back attachment; shmdt uses the exact checked key | Coherence/attach/detach steps are synchronous; snapshot owns bytes between boundaries; no kernel scratch view is retained | Node/browser; guest wasm32/64 | **Confirmed high-address alias defect.** `>>> 0` narrowed wasm64 hints/detach keys so an address above 4 GiB could alias a low mapping | **Implemented; validation pending.** High hint, unsafe integer, and non-aliasing detach regressions | +| `host/src/kernel-worker.ts::handleSysvMessage`; `crates/kernel/src/ipc_wire.rs` System V message header conversion | Main data allocation; fixed kernel wire header plus payload; caller message begins with native `long` (4 wasm32, 8 wasm64) | Payload is syscall `msgsz`; header plus payload must fit 65,536 for one operation | Exact native mtype field and payload range; checked addition; width passed explicitly; Rust sees fixed wire format only | One synchronous lease; blocking retry retains owned parameters, not a scratch view | Node/browser; guest wasm32/64 | **Unsafe mixed-width/native-long and aggregate-capacity contract** | **Implemented; validation pending.** Exact capacity/capacity+1 and wasm32/64 mtype coverage | +| `host/src/kernel-worker.ts::handleIpcControl`; `crates/kernel/src/wasm_api.rs::{kernel_msqid_ds_bytes,kernel_shmid_ds_bytes}`; `crates/kernel/src/ipc_wire.rs::{read_*,write_*}` | Main data allocation; `msqid_ds` 96/120 and `shmid_ds` 88/112 for wasm32/64 | Exact layout size returned by required Rust query for `IPC_SET`/`IPC_STAT`; pointerless commands stage zero bytes | Width query, command direction, full caller range, allocation capacity, exact Rust slice, and narrowing checks; no fixed fallback | One synchronous lease; outputs serialize completely before copy-back | Node/browser; guest wasm32/64 independent of kernel width | **Confirmed unsafe mixed-width contract.** Fixed wasm32 descriptors proved/staged the wrong LP64 ranges | **Implemented; validation pending.** Exact/short/null/unsupported-width tests | +| `host/src/kernel-worker.ts::handleSemctl`; `crates/kernel/src/wasm_api.rs::{kernel_semid_ds_bytes,kernel_semctl_array_bytes}`; `crates/kernel/src/ipc_wire.rs::write_semid_ds` | Main data allocation; `semid_ds` 72/88 or exact `2 * sem_nsems` array bytes | Rust permission-aware query is authoritative for GETALL/SETALL; structure query is authoritative for IPC commands | PID/TID, command kind, guest width, exact length, caller range, allocation capacity, and Rust slice bounds; missing/invalid required query fails closed | One synchronous lease; no `IPC_STAT` compatibility call is used to infer writable array capacity | Node/browser; guest wasm32/64 | **Confirmed unsafe.** Host assumed 1,024 array bytes and wasm32-only 72-byte structure | **Implemented; validation pending.** Exact/capacity+1, permissions, and missing-export regressions | +| `host/src/kernel-worker.ts::requireTcpScratchRegion` users: TCP, virtual network, UDP, browser-pipe bridges | Separate Rust allocation; private `tcpScratchRegion`, 65,536 bytes | One chunk at most 65,536; oversized UDP datagrams are rejected | Source/backend length, region capacity/current memory, and Rust producer count | Each callback/worker message enters one synchronous lease and detaches output before returning | Node/browser; kernel wasm32/64 | Sizes fit, but pointer escaped ownership value | **Implemented; validation pending.** Private capacity-bearing region | +| `host/src/kernel.ts` public socket/poll/terminal/ioctl/uname/pipe/rusage/select methods | Separate Rust allocation; private `apiScratchRegion`, 65,536 bytes | Call-specific exact fixed record or bounded payload; public poll accepts exactly `capacity / generated sizeof(pollfd)` = 8,192 entries and select uses generated 1,024-bit sets | Lease proves allocation and current memory; call validates the complete caller/result length and derives aggregate limits from the actual owned capacity rather than an unrelated protocol count | One synchronous public call; nested use fails | Node/browser; kernel wasm32/64 | **Confirmed unsafe ownership and artificial poll cap.** Temporary addresses 4 and 16 named no Rust allocation, while public poll reused `IOV_MAX` instead of its allocation capacity | **Implemented; focused Node validation passed.** Allocator-owned public scratch, poll exact-capacity/capacity+1, and generated select layout | +| `host/src/kernel.ts::{hostRead,readKernelBytes,writeKernelBytes}` and VFS (`stat`, `statfs`, `pathconf`, `readlink`, `readdir`), clock, random, waitpid, network/getaddrinfo, GL, proc, and KMS import callers | Rust-owned slice/local/struct lent as pointer plus explicit capacity for one import; `host_kms_mode_info` instead derives its exact 68-byte capacity from generated `WpkDrmModeModeinfo`; producer backends receive host-owned staging buffers rather than a live kernel view | Genuine intrinsic backend span no larger than the Rust-supplied or generated capacity; fixed formats use their exact generated/Rust size | Raw signed wasm32 or bigint wasm64 import pointer is normalized losslessly; nonnegative safe length, complete current-memory range, detached/staged producer data, and producer count precede one `writeKernelBytes` publish; no typed-array clamping or subclass getter counts as validation | Synchronous import only; neither backend nor callback receives a kernel-memory view | Node/browser; kernel wasm32/64 | Correct owner but incomplete conversions/result checks and live-view lending | **Implemented; validation pending.** Checked Rust-lent range plus host staging; high-bit wasm32 KMS and hostile-producer regressions; raw sink is explicitly allowlisted below | +| `apps/browser-demos/test/fixtures/opfs-advisory-lock-client-worker.ts::issue`; `apps/browser-demos/test/epoll-repro.ts::main` | Test kernel allocations represented as `KernelScratchRegion`; one complete channel | Fixed diagnostic channel and event records | Same lease capacity/current-memory rules as production | One lease covers stage/dispatch/snapshot | OPFS: real Chromium wasm32; epoll diagnostic: Node wasm32 | Sizes fit, bare diagnostic pointers/views | **Implemented; the historical authored-application Chromium run was blocked.** An earlier pre-final OPFS run passed, and the static contract includes both selected diagnostic sources. That broader historical run stopped before assertions because its program graph rejected an ABI-42 `bzip2.wasm`; it does not conflict with or count toward the later 28 focused minimal-runner Chromium passes | +| `host/src/{node,browser}-kernel-worker-entry.ts` clone/transport; process-worker argv/environment | Process `Memory`, `ArrayBuffer`, or `SharedArrayBuffer`, not kernel scratch | Process layout/worker-protocol limits | Process-owner and transport-specific validation | Worker/process lifetime, not a scratch lease | Node/browser; guest wasm32/64 | Outside allocator model | **Reviewed exclusion; final transport tests pending** | +| `host/src/framebuffer/**`; `host/src/dri/**`; GL command buffers; mmap/SysV backing views | Framebuffer/process memory or explicit host shared backing, never a pointer returned by `kernel_alloc_scratch` | Mapping/device-specific dimensions and buffer sizes | Subsystem owner/range contracts; static ownership seeds prevent reclassification as kernel scratch | Device/mapping lifetime; may be asynchronous by design, so no allocator-scratch view may enter these objects | Node/browser; guest wasm32/64 | Outside allocator model | **Reviewed exclusion, not declared globally safe.** Static contract covers ownership boundaries; subsystem-specific runtime validation remains required | + +## Explicit write sinks and raw-write allowlist + +All allocator-owned bulk transfers and scalar channel writes converge on the +following sinks. `KernelScratchDataView` is a guarded part of the abstraction, +not an allowance for a caller-created native `DataView`. +`KernelScratchLease.copyFrom` and `fill` are likewise abstraction-internal +guarded sinks. The only raw variable-size kernel-memory write outside that +abstraction is `WasmPosixKernel.writeKernelBytes`, whose pointer and capacity +are lent by Rust for one synchronous host import. All variable-size +allocator-owned syscall staging must call `KernelScratchLease`. The other +allowlisted occurrences in `host/test/kernel-scratch-contract.test.ts` are +private core getters/stores/factories, read-only views, revocable checked +views, allocator/reservation control calls, or atomic futex control; none +authorizes a call-site raw write. + +| Exact write sink | Destination owner and capacity proof | Source/result proof | Lifetime / disposition | +|---|---|---|---| +| `host/src/kernel-scratch.ts::KernelScratchDataView::{setBigInt64,setBigUint64,setFloat32,setFloat64,setInt8,setInt16,setInt32,setUint8,setUint16,setUint32}` | The native `DataView` is private and spans only the range proved by `KernelScratchLease.dataView`; a `Memory.buffer` replacement triggers the full proof again | Native `DataView` bounds-checks each scalar width and offset inside that exact range | Every setter calls `currentView`, which rechecks the active lease; **guarded scratch-core sink, not a raw allowance** | +| `host/src/kernel-scratch.ts::KernelScratchLease.copyFrom` — `Uint8Array(...).set(...)` | `ownedRange` proves the private region pointer, explicit allocation capacity, pointer width, and current `Memory.buffer` range | Source offset/length are safe integers and fit the source's intrinsic typed-array slots; the exact native base-class view prevents a subclass override from widening the write | Synchronous active lease only; **guarded scratch-core sink, not a raw allowance** | +| `host/src/kernel-scratch.ts::KernelScratchLease.fill` — `Uint8Array(...).fill(...)` | `ownedRange` supplies exact checked start/end inside the allocation and current memory | Fill length/value validation occurs before construction | Synchronous active lease only; **guarded scratch-core sink, not a raw allowance** | +| `host/src/kernel.ts::WasmPosixKernel.writeKernelBytes` — `getMemoryBuffer().set(...)` | `checkedWasmImportMemoryRange` normalizes the raw import pointer and proves the Rust-lent pointer, explicit/generated capacity, width, and current memory | The producer's intrinsic byte span must not exceed the supplied capacity; a typed-array subclass cannot under-report its real span | Complete synchronous import; **Rust-lent allowance** | + +## Executable reproductions and regression coverage + +The focused regressions are executable in +`host/test/kernel-scratch-transfer-boundaries.test.ts` and are designed to +fail at the old admission or sentinel condition. The old-source evidence below +is the exact pointer/capacity arithmetic from audited head `6d923c6`; the +current tests exercise the corrected boundary. The regressions were not +transplanted into and run from the historical head, so this table does not +claim a separate old-head test execution. + +| Confirmed old path and executable regression | Exact unsafe evidence on `6d923c6` | Current boundary asserted | +|---|---|---| +| `kernel_wait_child_poll` result pointer/capacity and child-state consumption | The ABI-42 export accepted only a bare output pointer, so the interface could not prove that the destination owned the complete 160-byte `KernelWaitResult` before selecting the sole waitable child event | The real compiled kernel rejects pointer zero and capacities 159/161 without changing either canary or consuming the event. Exact capacity 160 publishes the complete result and reaps the child, and the next exact-capacity call returns `ECHILD` | +| `WasmPosixKernel` cached public/audio scratch across `init`/`initWithMemory` replacement | The wrapper cached allocator-owned regions containing the first generation's instance, memory, pointer, and capacity, but a later initializer replaced only the wrapper's current instance/memory. Subsequent public/audio calls could select the old region and old snapshotted export while other wrapper state named the replacement generation; a failed second instantiate could also leave new memory paired with the old instance | `kernel-initialization-lifetime.test.ts` allocates and uses both cached regions on wasm32 and wasm64, rejects cross-entry-point reinitialization before compile/instantiate or state mutation, proves the original generation still works, rejects a concurrent initializer, and proves a failed first attempt clears partial state before one clean retry | +| `ptyMasterWrite` — “chunks PTY input at the exact scratch capacity and capacity + 1” | The old single `.set(data, scratchOffset)` accepts more than the 65,608-byte allocation because only total linear memory constrains the typed-array write | 65,608 is one call; 65,609 becomes calls of 65,608 and 1; 16 KiB sentinel after the allocation is unchanged | +| `setCwd` — “rejects an oversized initial cwd before copying it” | `CH_TOTAL_SIZE + 1` encoded bytes are copied first; only the later Rust call applies `PATH_MAX` | Host rejects before `kernel_set_cwd` and before scratch mutation | +| `handleWritev` — “accounts for every writev table and alignment byte” | 1,024 iovecs contain 57,344 payload bytes, so the old `57,344 + 8,192 == 65,536` admission passes. Per-entry four-byte alignment makes the real footprint 68,608, writing 3,072 bytes beyond the data allocation | Complete `8 * count + Σ align4(len)` footprint is rejected or chunked; tail sentinel stays intact | +| `handleReadv` — “subtracts the complete readv iovec table from data capacity” | 1,024 entries request 65,528 data bytes. The old fast limit subtracts only one eight-byte entry, while the actual table is 8,192 bytes; the footprint is 73,720, or 8,184 bytes beyond the 65,536-byte data allocation | Complete table is included, `IOV_MAX` is enforced, returned bytes are bounded, sentinel is unchanged | +| `handleSendmsg` / `handleRecvmsg` — `IOV_MAX + 1` cases | The old path calls Rust instead of rejecting count 1,025 with `EINVAL` | Count 1,025 is rejected before scratch mutation or Rust dispatch | +| `handleSendmsg` / `handleRecvmsg` — complete-layout boundary and historical allocation-sized table cases | Count 8,192 alone consumes 65,536 kernel-table bytes, but the old path also writes the 28-byte kernel message header and aligned optional/data sections. That historical count is above `IOV_MAX`, so current code rejects it at the count check rather than exercising the capacity boundary | One 65,500-byte iovec makes the complete header/table/data layout exactly 65,536 bytes and is accepted; 65,501 is rejected before dispatch. Exactly 1,024 zero-length entries are accepted, while the historical 8,192-entry case is rejected by `IOV_MAX`; the sentinel stays unchanged | +| `handleSendmsg` / `handleRecvmsg` multi-iovec behavior | The old kernel-facing path serialized the caller's full count but Rust read only the first table entry, so later payload sources/destinations were silently ignored even when all ranges fit | wasm32 and wasm64 send flatten every entry in order; receive scatters the detached result across every entry, including correctly skipping a zero-length middle entry | +| wasm64 ancillary translation and capacity | The old path copied a native 16-byte-aligned wasm64 `cmsghdr` into a kernel parser expecting the 12-byte wasm32 shape. On receive, `CMSG_SPACE(sizeof(int)) == 24` could be misread as canonical room for two FDs even though one native record has only one logical FD of data | Generated native layouts translate to/from the fixed 12-byte canonical wire. A native `CMSG_LEN(sizeof(int)) == 20` maps to exactly one canonical FD; exact native capacity matrices, poisoned padding, `MSG_CTRUNC`, and sequential shorter reuse preserve caller canaries | +| Malformed ancillary length and canonical output | A wrapped/overlong length could reach unchecked pointer arithmetic or allow a returned record to publish partial guest output | Wrapped native input fails before scratch mutation. Partial, overlong, misaligned, or capacity-plus-one canonical output becomes `EIO` before any payload/name/control/header publication | +| Kernel channel C-string allocation boundary | The old Rust scanner accepted a bare kernel pointer, stopped at a duplicated 4,096-byte constant, and did not carry the channel allocation capacity that authorized each dereference | Pointer zero, before-start, exact-end, overflow, and a missing NUL before the allocation end return `EFAULT`; a NUL in the last owned byte succeeds, and a generic non-path string larger than `PATH_MAX` remains valid | +| Positive-count null dynamic buffers and zero-count null buffers | The old generic host path did not make a positive `Arg` extent independently imply a non-null source/destination, while a raw zero-count process pointer could cross address spaces even though no caller bytes were borrowed | wasm32 and wasm64 `read`/`write` with count 1 and pointer 0 fail with `EFAULT` before dispatch. Count 0 with pointer 0 reaches Rust only as the allocation start with zero extent, never as the caller address | +| Positive-size fixed output nullability (`pipe(NULL)` and `uname(NULL)`) | Absence of `required` metadata was interpreted as permission for null, so fixed outputs could reach Rust without an owned destination and write through kernel address zero | Every generated pointer descriptor is exactly one of required or nullable; the reviewed nullable set is asserted exactly. wasm32 and wasm64 `pipe`/`uname` null outputs fail before kernel dispatch | +| Non-null `Deref` outer buffer with a null length pointer | `accept`, `accept4`, `recvfrom`, `getsockname`, and `getpeername` derive output capacity from a separate `socklen_t *`. The old host could leave the non-null caller outer pointer in adjusted kernel args when that capacity pointer was null, crossing address spaces before later rejection | wasm32 and wasm64 `recvfrom` reject the malformed pair before scratch mutation or dispatch; the same shared planner covers every `Deref` descriptor | +| One-snapshot, order-independent `Deref` planning | The old planner could size a destination from one `socklen_t` read and stage a later, mutated value; it also depended on the generated dynamic descriptor preceding the fixed length descriptor. A larger staged value could authorize Rust to use bytes the host had not reserved | The regressions mutate 4 to 28 between hypothetical reads and reverse the generated `recvfrom` descriptor order. The host performs one caller-memory read, stages that same value, and leaves the adjacent canary unchanged. Rust validates allocation order/range, with the documented alignment-bucket limitation because no separate unpadded capacity is encoded | +| Option-sensitive `prctl` argument 1 | The generic descriptor treated argument 1 as a fixed 16-byte pointer for every option, so scalar operations such as `PR_SET_NO_NEW_PRIVS` had their value replaced by a scratch address | wasm32/wasm64 scalar options preserve the canonical low-32-bit value and stage no buffer; `PR_SET_NAME`/`PR_GET_NAME` stage exactly 16 bytes in the correct direction and reject null before dispatch | +| `SCM_RIGHTS` stream carrier position and `MSG_WAITALL` | The old side queue could return a descriptor before the byte with which it was sent, and a wait-all read could cross more than one rights boundary | The real-musl fixture queues `A`, rights with `B`, then `C`; a nonblocking wait-all receive returns exactly `AB` with the descriptor, and the next receive returns `C` | +| `SCM_RIGHTS` stream `MSG_PEEK` with short control | The old PEEK path removed the sole retained descriptor ownership even though it left stream bytes queued | A no-control peek and a `CMSG_LEN(0)` peek report no installed fd/`MSG_CTRUNC` as appropriate; two full peeks and the final consume all see a valid descriptor without sender ownership | +| Addressed and connected AF_UNIX datagram rights | Datagram queue entries previously dropped control ownership, and non-Unix destinations could reach partial data publication | Abstract-address `sendmsg.msg_name` and connected sends deliver payload and rights atomically; the sender alias may close before receipt. Non-AF_UNIX ancillary use returns `EINVAL` before data becomes visible | +| Zero-byte and zero-iovec rights | Fast exits bypassed the message ownership path, so zero-byte datagrams could lose rights or be consumed by an ordinary zero-length read | A datagram `sendmsg`/`recvmsg` with `msg_iov == NULL` and `msg_iovlen == 0` transports rights. `read(fd, ..., 0)` leaves that message queued for the later receive; a zero-byte stream send queues nothing and releases its temporary retain | +| Datagram input/output `MSG_TRUNC` separation | The old receive path used the input flag only and did not report output truncation in `msg_flags` | A short receive always reports output `MSG_TRUNC`; without input `MSG_TRUNC` it returns the copied prefix, and with the input flag it returns the full datagram length | +| `MSG_CMSG_CLOEXEC` | The old receive path installed transferred descriptors without applying the requested close-on-exec flag | The received descriptor has `FD_CLOEXEC`, is reflected in output flags, and is absent after the fixture execs itself; installation failure publishes no partial control result | +| `SCM_RIGHTS` descriptor transferability | A process-local socket snapshot preserved scalar metadata but not the authoritative endpoint or queue backing, so reporting successful socket transfer created a descriptor that could not preserve the source object | The real-musl wasm32/wasm64 fixture first proves pipe transfer still succeeds, then attempts AF_UNIX stream/datagram and AF_INET/AF_INET6 socket descriptors. Every socket batch returns `EOPNOTSUPP`, publishes no carrier byte, and retains no hidden reference. Native tests also reject stale, structurally incomplete, and non-owning in-flight records before installation | +| Forced process removal with queued datagram rights | `remove_process_inner` performed its sole deferred-release drain before dropping each socket's datagram queue, so the queue could enqueue backing work after the last cleanup boundary | `forced_removal_drains_scm_rights_queued_in_unix_datagrams` closes the sender alias, forces removal, and proves the in-flight OFD, host handle, and advisory lock all reach their final state | +| AF_UNIX datagram reconnect with queued rights | Replacing the datagram peer cleared the old queue only after the enclosing operation's cleanup opportunity, leaving its retained descriptors stranded | `unix_datagram_reconnect_drops_and_drains_scm_rights_ownership` proves reconnect discards and finishes the old queue before publishing success | +| Datagram shutdown modes with queued rights | `SHUT_RD` and `SHUT_RDWR` made queued messages permanently unreadable without releasing their `SCM_RIGHTS`; `SHUT_WR` must not destroy still-readable data | `unix_datagram_shutdown_modes_preserve_or_discard_scm_rights_correctly` covers both discarding modes plus `SHUT_WR` preservation and later receipt | +| Failed accept of a preaccepted stream carrying rights | An error after selecting a preaccepted AF_UNIX stream dropped its pending ancillary state without finishing the retained backing | `failed_accept_discards_and_drains_preaccepted_stream_scm_rights` injects the failure and proves no OFD, host handle, or lock ownership remains hidden | +| `sendfile` / `copy_file_range` / `splice` crossing a stream rights boundary | These plain-data transfers call the ordinary read path. It correctly refuses to return ancillary ownership, but the wrappers did not finish the ownership discarded at that boundary | `plain_transfer_syscalls_discard_and_drain_crossed_stream_scm_rights` executes all three operations and verifies both the copied byte and final retained-resource state | +| Direct host pipe read/close boundaries | Today these exports normally address host-injected TCP pipes, but message-aware read and unreachable-cycle collection can enqueue deferred ownership if that trusted input boundary broadens | `direct_host_pipe_read_and_close_read_detect_deferred_scm_cleanup` and `direct_host_pipe_close_write_detects_recursive_ancillary_collection` exercise the exact pending predicate and cleanup helper used after the pipe-table borrow ends | +| Systematic channel-dispatch cleanup order | A per-syscall list can miss a new transitive `Drop`, early return, or replacement that queues ancillary cleanup; cleanup while a task identity or resource-table borrow remains live can also re-enter with stale authority | Every channel result crosses one conditional machine-owned cleanup boundary after dispatch clears the current-TID binding and before result publication. The empty path performs one O(1) pending check; the pending path drains only after resource borrows have ended | +| Vector/message wasm64 nested pointers | `Number(bigint)` loses an unrepresentable iovec/base/header pointer and permits an aliased range to reach Rust | Raw bigint remains intact through guest-width and safe-integer checks; failure precedes mutation | +| Public poll exact allocation boundary | The public wrapper used the unrelated `IOV_MAX` value 1,024 even though its owned 65,536-byte region can hold 8,192 generated eight-byte `pollfd` records | Exactly 8,192 records are admitted and 8,193 is rejected before mutation; readiness parsing uses generated offsets | +| Slow `preadv` / `pwritev` positioned offset | Reassembling the signed high and unsigned low words as a JavaScript `Number` rounds `2^53 + 1` down to `2^53`, so the chunked path re-emits the wrong low word | Offset assembly, per-chunk addition, write-budget preflight, and low/high re-emission remain `bigint`; wasm64 ingress normalizes the complete low slot before any Number conversion | +| Ordinary and large `pread` / `pwrite` positioned offset | The generic ordinary path converted the signed i64 channel slot to `Number`, and the large path reused that rounded value across preflight and chunks. Shared-mapping follow-up could then index the rounded page | Both caller widths preserve `2^53+1` on the ordinary path and increment it exactly across a 65,536-byte chunk; an offset that cannot be indexed losslessly triggers authoritative mapping refresh instead of an aliased update | +| `Getaddrinfo` generic descriptor — “copies only getaddrinfo's four-byte result before the caller canary” | The old fixed 256-byte output descriptor copies 252 bytes beyond musl's four-byte result object | Detached copy-back is exactly four bytes and preserves a 252-byte canary | +| `handleGetgroups` and `kernel_getgroups` | The old generic call passes the caller's process pointer as if it were a kernel pointer; Rust writes one `u32` without receiving the owned destination capacity | Size zero lends pointer/capacity zero; positive size lends exactly four bytes; Rust rejects capacity 0/3 and accepts 4/5; detached gid copy precedes reuse | +| `Setgroups` generated descriptor | The old raw pointer crosses address spaces. The current Rust implementation does not dereference it, so this is an unsafe contract rather than a claimed observed overwrite | Exactly 16,384 gids fit; 16,385 is rejected; count zero ignores even an unrepresentable pointer; positive null returns `EFAULT` | +| Request-aware `ioctl` — FIONREAD canary and exact capacities | The old generic 256-byte argument copies back 252 bytes beyond a four-byte FIONREAD object; it also cannot distinguish scalar/no-argument requests from pointer requests | FIONREAD copies exactly 4; wasm32 TIOCGPTN is 4, wasm32 DRM VERSION is 36, wasm64 DRM VERSION is 64; one-byte-short/null fail before mutation; scalar/no-arg/unknown stage no pointer | +| Width-incompatible `ioctl` requests | The old contract has no lossless distinction for pointer-bearing wasm32-only layouts such as `GLIO_QUERY` and wasm32 DRM VERSION | wasm64 rejects those known requests with `EOVERFLOW` before conversion or copy | +| Caller-native process records | Fixed wasm32/partial descriptors under-copy or copy back the wrong layout for wasm64; `sigevent` was treated as 16 rather than 64 bytes; `sysinfo` used stale syscall 208 instead of musl 269 | `tests/abi/{process-native-layouts,fixed-process-layouts}.c`, Rust exact/short tests, and host `sysinfo` exact-end/one-byte-short tests cover the enumerated 12/24, 16/32, 32/64, 64, 88/120, 312/368, 128, 112, and 48-byte records | +| Signal dequeue output capacity and complete `SA_SIGINFO` record | The old `kernel_dequeue_signal(pid, tid, out_ptr)` accepted a bare kernel pointer, while its 44-byte payload inside a 48-byte reserved channel area omitted a complete raw `si_value` plus sender/timer metadata | `signal_delivery_output_requires_nonnull_exact_capacity` rejects null, 55, and 57 while accepting exactly 56; `signal_delivery_record_serializes_every_field_at_the_shared_offsets` covers the full generated record. The rebuilt real-musl `process-native-layout` Node cases pass on wasm32 and wasm64 and exercise handler-side C reconstruction, raw value width, `si_code`, PID, and UID | +| Mqueue notification drain and registration validation | The old one-argument drain export had no allocation-capacity proof. A negative Rust errno is truthy in JavaScript, so the old host could parse unchanged reusable bytes as a pending `{pid, signo}` notification. `mq_notify` also admitted invalid signal numbers into the one-shot registration slot | Source requires an exact eight-byte destination and queues the full raw value with `SI_MESGQ` and sender metadata independently of the wake record. The host regression accepts only integer results 0/1 and fails closed on `-EINVAL` without waking or signaling. Native coverage rejects 0, `NSIG`, and `u32::MAX` without occupying the slot, then proves a valid registration succeeds. The real-Wasm export regression rejects pointer zero and capacities 7/9 without consuming or mutating the pending record, then accepts capacity 8 and preserves both destination canaries | +| POSIX timer `sigevent` pointer width and full `sigval` | The old three-argument `kernel_timer_create` could not distinguish a wasm32 from wasm64 caller and parsed only a partial event, narrowing a wasm64 pointer value | `mq_attr_and_sigevent_follow_process_long_width` covers exact 64-byte wasm32/wasm64 layouts, short/long rejection, and raw four/eight-byte values. The rebuilt real-musl `process-native-layout` Node cases verify the low 32 bits for wasm32 and all 64 bits for wasm64 through `timer_create`, expiration, and `sigtimedwait` | +| Signal sender metadata for plain raise/kill versus queued sources | Plain self-raise previously reached the metadata-bearing queue with PID/UID zero, making handler `siginfo_t` inconsistent with the authoritative process identity | `test_raise_preserves_self_sender_metadata` and `process_signal_metadata_distinguishes_kill_from_sigqueue` distinguish SI_USER/SI_QUEUE while preserving sender PID/UID and raw queued value. The same rebuilt real-musl Node fixture checks the handler-visible fields | +| `handleEpollCtl` / `handleEpollPwait` native event layout | A stale 12-byte assumption cannot represent musl's required padding before 64-bit `data` and proves the wrong output range | Exact record is 16 bytes: events offset 0, padding 4–7, data offset 8; exact-end and one-byte-short input/output tests verify padding and data | +| wasm64 `handleIpcShmat` | `shmaddr >>> 0` aliases a hint above 4 GiB to its low 32 bits before mmap/attachment logic | `0x1_0000_0000n` reaches mmap unchanged; values above `Number.MAX_SAFE_INTEGER` fail before attachment | +| wasm64 `handleIpcShmdt` | `args[0] >>> 0` can select and detach an unrelated low mapping for a high native pointer | A high address equal to an existing low key plus 4 GiB neither aliases nor detaches the low mapping | +| wasm64 `msgctl` `IPC_STAT`/`IPC_SET` | Fixed 96-byte wasm32 descriptor validates/stages the wrong range instead of the 120-byte LP64 structure | Required size query selects 96/120 and exact/short ranges | +| wasm64 `semctl` `IPC_STAT` | Fixed 72-byte wasm32 layout is selected instead of the 88-byte LP64 structure | Required size query selects 72/88; array size comes from permission-aware Rust preflight | +| wasm64 `shmctl` `IPC_STAT`/`IPC_SET` | Fixed 88-byte wasm32 descriptor validates/stages the wrong range instead of the 112-byte LP64 structure | Required size query selects 88/112 and exact/short ranges | + +The expanded parameterized coverage includes those failures plus caller +address zero, adjacent vector slow paths, large read/write, select/pselect, +`ppoll` special-pointer conversion, epoll, wasm32/wasm64 System V IPC control +layouts, generic descriptor invalid lengths, lease-time staging, and +Linux-compatible `MSG_TRUNC`. Final case counts belong in the post-retarget +validation report, not this in-progress rehearsal record. + +Additional focused files cover the complete abstraction: + +- `host/test/kernel-scratch-region.test.ts`: exact capacity/capacity+1, + negative/fractional/unsafe integer inputs, pointer arithmetic, null, + end-of-memory, allocation failure, invalid allocator range, wasm32/64, + signed-high allocator/reservation results, sequential/nested/async use, + single-use revocation, hostile `then`/typed-array methods, escaped views, and + `memory.grow()`. +- `host/test/kernel-public-scratch.test.ts`: removal of low-address scratch, + public capacity, audio counts, signed-high raw import pointers, hostile + producer views, exact KMS mode-info size, and Rust-lent network output + bounds. +- `host/test/kernel-initialization-lifetime.test.ts`: both kernel pointer + widths, cached public/audio scratch, post-success cross-entry-point + initialization rejection, concurrent initialization, original-generation + preservation, and failed-first-attempt cleanup/retry. +- `host/test/spawn-blob-transport.test.ts`: ordinary and tokenized large + transport, fresh reservation on reuse, begin/allocation failure and retry, + invalid pointer/capacity, copy-failure cancellation, stale/missing exports, + host reentry exclusion, exact whole-blob ceiling, exact and + maximum-plus-one argv/environment/action counts, exact aggregate `ARG_MAX` + and `ARG_MAX + 1` for both caller pointer widths, and wasm64 + pointer/token/capacity. +- `crates/kernel/src/spawn.rs` unit tests: malformed maximum counts, exact and + maximum-plus-one counts, exact and oversized argv/environment + representation, action-path `PATH_MAX`, complete wire ceiling, + duplicate-offset amplification rejection before allocation, injected + reserve failure with no state/token mutation and successful retry, + growth/reuse, nonblocking begin/query contention, threaded blocking + commit/cancel settlement, exact token cancellation/consumption, stale + tokens, capacity+1, and token exhaustion without wraparound. +- `crates/kernel/src/scratch_alloc.rs` unit tests: zero, ordinary, exact + aligned layout ceiling, and ceiling-plus-one allocator-layout inputs return + a value/error instead of trapping. Host region tests separately cover a + zero allocator result and an allocator-returned range outside current + memory. +- `crates/kernel/src/ipc_wire.rs` unit tests: wasm32 time64 and wasm64 LP64 + `msqid_ds` (96/120 bytes), `semid_ds` (72/88), and `shmid_ds` (88/112) + offsets, exact no-overwrite boundaries, width-aware `IPC_SET` field reads, + invalid widths, short inputs/outputs, oversized LP64 fields, and an + unrepresentable semaphore count. +- `tests/abi/process-native-layouts.c` and + `scripts/check-process-native-layouts.sh`: executable wasm32/wasm64 musl + size-and-offset drift checks for signal-stack, signal-information, + signal-event, interval-timer, message-queue, filesystem-statistics, and + system-information records, including the native `union sigval` width. +- `tests/abi/fixed-process-layouts.c`, + `scripts/check-fixed-process-layouts.sh`, + `tests/abi/sysv-ipc-layouts.c`, and + `scripts/check-sysv-ipc-layouts.sh`: fixed-record and System V + structure-size/offset checks for both caller widths. +- `crates/kernel/src/process_wire.rs` and + `host/test/process-native-layout.test.ts`: exact-size and capacity+1 parsing + and serialization tests, zeroed padding/reserved bytes, and end-to-end + wasm32/wasm64 syscall round trips. The current real-musl fixture also + installs an `SA_SIGINFO` handler and verifies the generated 56-byte delivery + record reconstructs native `si_value`, `si_code`, PID, and UID; its timer + and mqueue cases preserve four-byte wasm32 and eight-byte wasm64 values and + reject invalid `mq_notify` signums. Focused host boundary tests separately + prove `sysinfo` exact-end admission, one-byte-short rejection, and that a + negative mqueue drain result cannot decode stale scratch. The two runtime + cases are self-contained and set `useDefaultRootfs: false`; both passed on + Node against the rebuilt ABI-43 kernel and host artifacts. Chromium + execution of this latest fixture remains pending. +- `crates/kernel/src/mqueue.rs`, `crates/kernel/src/signal.rs`, + `crates/kernel/src/syscalls.rs`, and + `host/test/kernel-scratch-transfer-boundaries.test.ts`: invalid mqueue + signums leave the registration slot free, `SI_MESGQ` and sender metadata + remain in the Rust-owned signal queue, plain self-raise reports the real + sender, and the host publishes no notification after a negative drain + result. +- `host/test/sysv-ipc.test.ts`: end-to-end wasm32/wasm64 message-queue, + semaphore, and shared-memory control operations, including `IPC_SET`. Its + two self-contained runtime cases also set `useDefaultRootfs: false` and + both passed against the rebuilt ABI-43 kernel and host artifacts. +- `crates/shared/src/ioctl_contract.rs`, the ioctl tests in + `crates/kernel/src/wasm_api.rs`, and + `host/test/kernel-scratch-transfer-boundaries.test.ts`: sorted + request-table uniqueness, argument-kind/direction agreement, exact Rust + slice lengths, exact wasm32/wasm64 capacities, caller canaries, and + unsupported-width rejection. +- `host/test/shared-memory-coherence.test.ts`: high-address `shmat` hint + preservation, rejection of a non-lossless hint before kernel attachment, + and proof that high `shmdt` does not alias a low mapping. +- `host/test/kernel-worker-copyback.test.ts`: detached poll output survives an + immediate timeout without rereading reused scratch; error/retry paths do not + publish another operation's bytes. +- `host/test/deferred-worker-start.test.ts`: a deferred clone failure clears + the originally validated parent-TID word even when the guest replaces its + mutable mailbox before worker construction fails. +- `host/test/timerfd-signalfd-scratch.test.ts`: guarded caller objects for the + exact wasm32/wasm64 timer and signal-mask records. Both focused cases passed + after the current ABI-43 kernel and host artifacts were rebuilt. Like the + other self-contained native-layout fixtures, it sets + `useDefaultRootfs: false`. +- `host/test/kernel-scratch-contract.test.ts` and + `host/test/wasm-memory-write-audit.test.ts`: compiler-backed repository drift + guard plus focused ownership-propagation, write-kind, escape, exact-allowlist, + direct/aliased/destructured/computed pointer-export invocation, wrapped + callable escape, `call`/`apply`/`bind`, and reflective invocation fixtures. +- `crates/shared/src/host_abi.rs`, + `crates/kernel/src/channel_scratch.rs`, + `host/test/generated-abi.test.ts`, and + `host/test/kernel-scratch-transfer-boundaries.test.ts`: every generated + descriptor selects exactly one of required or nullable, the complete + nullable set is reviewed explicitly, and `prctl` remains absent from generic + pointer metadata. Host and Rust cases cover positive-null and canonical + owned-empty `Arg` buffers, required fixed outputs, exact 16-byte + `PR_SET_NAME`/`PR_GET_NAME`, scalar `prctl`, null nested `socklen_t` + pointers, one-snapshot `Deref` staging under descriptor reordering, and + wasm32/wasm64 parity. The Rust dispatcher source guard also matches each raw + process-address context exactly instead of approving only an occurrence + count. +- `host/test/kernel-scratch-transfer-boundaries.test.ts` plus + `crates/kernel/src/socket_wire.rs`: a zero-length `sendmsg` iovec is + transported as `{ base: 0, len: 0 }`, while the Rust consumer selects a + valid empty slice before any `from_raw_parts` call. Pure Rust tests execute + malformed canonical control lengths, partial/trailing records, invalid-FD + propagation, and the zero/one-iovec wire limit on the native test target. +- `programs/scm-rights-pipe-lifetime.c`, + `host/test/scm-rights-pipe-lifetime.test.ts`, and + `apps/browser-demos/test/fifo-lifecycle.spec.ts`: the same real musl + `sendmsg`/`recvmsg` and `SCM_RIGHTS` workload is built for both wasm32 and + wasm64. Its one-FD receive uses the exact native `CMSG_LEN` capacity, making + wasm64's 20-byte logical record distinct from its 24-byte aligned storage. + Node and Chromium results are recorded only after those commands run. +- `programs/scm-rights-semantics.c`, + `host/test/scm-rights-semantics.test.ts`, and + `apps/browser-demos/test/scm-rights-semantics.spec.ts`: eight independent + real-musl cases cover stream carrier barriers/`MSG_WAITALL`, non-consuming + short/full stream `MSG_PEEK`, addressed/connected and zero-iovec AF_UNIX + datagrams versus ordinary `read(...,0)`, independent input/output + `MSG_TRUNC`, non-Unix rejection, an unrepresentable descriptor batch, + zero-iovec stream behavior, and `MSG_CMSG_CLOEXEC` across exec. The + post-retarget Node matrix passed all 16 wasm32/wasm64 semantic cases; the two + existing pipe-lifetime cases also passed, for 18 total Node cases. The + focused real-Chromium runs passed the same 16 semantic cases and both + pipe-lifetime cases. These are dirty-worktree results and still require the + frozen-head rerun described below. + +## Evidence boundaries and external gaps + +These validation gaps prevent a “ready” disposition. They do not erase a +source-safety proof, and a source-safety proof does not substitute for these +missing runs. Any row still marked **Uncertain** separately remains without a +safe source disposition. + +1. Retargeting to merged PR #1097 is complete. Exact-head readiness is a + per-PR gate: the draft PR ledger must name the current head and the complete + rerun performed on it. Test presence, an earlier source fingerprint, or a + pre-retarget run is never substituted for that evidence. +2. Framebuffer, Direct Rendering Manager (DRM), OpenGL, shared-mapping, and + process-worker transfers are deliberately excluded from allocator scratch. + The static ownership audit can prove that no kernel scratch view escapes + into those objects; it cannot by itself prove every subsystem's mapping + dimensions, callback lifetime, or browser behavior. +3. Focused post-retarget Chromium execution now covers the scratch runtime, + native wasm32/wasm64 process layouts, both child-wait widths, all 16 + wasm32/wasm64 `SCM_RIGHTS` semantic cases, both pipe-lifetime cases, and the + adjacent path, file-limit, and terminal fixtures: 28 assertions passed in + real Chromium. Those self-contained fixtures explicitly select the test + runner's minimal dependency set; they do not bypass validation for any + artifact they request. Vite's optional application dependency pre-scan + still warns about unavailable ABI-43 tools, and the complete browser + application suite remains blocked by those package artifacts. Focused + browser evidence does not establish every device/shared-memory exclusion or + the unexecuted application graph. +4. The normal conformance runners were reprobed after retargeting and all stop + before the guest reaches the kernel because no one provenance tier contains + the complete ABI-43 program closure. Open POSIX `sigqueue sigtimedwait` + reported 0 pass, 17 fail, and 1 timeout. libc-test `functional spawn` + reported 0 pass and 1 fail. Sortix `signal` reported 0 pass, 14 fail, and 18 + timeouts; a separately enumerated complete 24-test `basic/spawn/*` surface + reported 0 pass and 24 fail. A direct launch shows the exact cause: + `local-binaries` is not one direct immutable generation, `binaries` is not + one canonical program-cache generation, and the installed package lacks + `programs/wasm32/git/git.wasm` plus `git-remote-http.wasm`. No resolver + bypass, mixed-provenance selection, or test-only exception was added. +5. Comparable post-retarget dirty-worktree measurements establish reported + retained scratch capacity and post-run/peak kernel linear memory for the + deterministic workload on Node and real Chromium. Exact-PR-head result + files and fingerprints belong in the mutable PR ledger. Three-round timing + samples and the baseline-harness provenance are insufficient for a speed or + broad no-regression claim. The performance guide's complete application + suites remain blocked by unavailable ABI-43 PHP, WordPress, and MariaDB + artifacts. +6. The declared development shell does not provide its pinned + `rustfmt`/`cargo-fmt`; the only discovered formatter is an undeclared + Homebrew binary that produces unrelated repository-wide churn. Rust + formatting validation is blocked until the declared toolchain supplies the + formatter. +7. PR #1097 merged as + `c7d039794a43788acfa0b0aea30a700c257f57cb`, and retargeting is complete. + The draft must remain unapproved and unmerged whenever its validation ledger + does not match its current exact head. + +## Platform and spawn contract sources of truth + +`crates/shared/src/lib.rs::platform_limits` is authoritative for Kandelo's +advertised `ARG_MAX`, `PATH_MAX`, and `IOV_MAX`. The ABI generator writes those +values to TypeScript and a musl header; the public `limits.h`, musl's compiled +`sysconf`, the TypeScript host, and Rust consume those generated or shared +values. `crates/shared/src/lib.rs::spawn_contract` separately owns the wire +layout and defensive parser count caps. Its generated private C header aliases +the generated platform limits instead of repeating their numbers. Compile-time +assertions require every wire count, length, offset, and the derived complete +ceiling to remain representable by the protocol's `u32` fields. Rust unit +tests and generated-file checks make that representability and cross-language +freshness executable drift contracts. + +| Value | Meaning | Classification | +|---|---|---| +| 4,194,304 | Combined argv/environment bytes, including representation overhead | Advertised `ARG_MAX` platform limit | +| 4,096 | Path bytes including terminating NUL | Advertised `PATH_MAX` platform limit | +| 1,024 | Maximum iovec entries | Advertised `IOV_MAX` platform limit | +| 4 bytes (`u32`) | Width of each argv/environment offset into the trailing strings region | Cross-layer wire layout | +| 40 bytes | Complete spawn header | Cross-layer wire layout | +| 28 bytes | Complete file-action record | Cross-layer wire layout | +| 4,096 argv, 4,096 env, 1,024 actions | Defensive parser count caps | Implementation limits, not extra POSIX limits | +| 8,417,320 | Derived complete blob ceiling: `ARG_MAX + header + actions * (record + PATH_MAX)` | Transport/parser safety ceiling, not `ARG_MAX` | + +Header fields are little-endian and contiguous: + +| Header field | Type | Byte offset | +|---|---|---:| +| `argc` | `u32` | 0 | +| `envc` | `u32` | 4 | +| `action_count` | `u32` | 8 | +| `attr_flags` | `u32` | 12 | +| `pgrp` | `i32` | 16 | +| reserved pad | `u32` | 20 | +| `sigdef` | `u64` | 24 | +| `sigmask` | `u64` | 32 | + +Each little-endian action record uses the following generated layout: + +| Action field | Type | Byte offset | +|---|---|---:| +| `op` | `u32` | 0 | +| `fd` | `i32` | 4 | +| `newfd` | `i32` | 8 | +| `path_off` | `u32` | 12 | +| `path_len` | `u32` | 16 | +| `oflag` | `i32` | 20 | +| `mode` | `u32` | 24 | + +The generated action opcodes are: + +| Opcode | Operation | +|---:|---| +| 0 | `OPEN` | +| 1 | `CLOSE` | +| 2 | `DUP2` | +| 3 | `CHDIR` | +| 4 | `FCHDIR` | + +The wire transports musl's complete flag byte unchanged. Transport does not +claim implementation: the kernel deliberately reexports and interprets only +the supported subset. + +| Bit | Transported flag | Kernel behavior | +|---:|---|---| +| `0x01` | `POSIX_SPAWN_RESETIDS` | Transported; not implemented | +| `0x02` | `POSIX_SPAWN_SETPGROUP` | Implemented | +| `0x04` | `POSIX_SPAWN_SETSIGDEF` | Implemented | +| `0x08` | `POSIX_SPAWN_SETSIGMASK` | Implemented | +| `0x10` | `POSIX_SPAWN_SETSCHEDPARAM` | Transported; not implemented | +| `0x20` | `POSIX_SPAWN_SETSCHEDULER` | Transported; not implemented | +| `0x40` | `POSIX_SPAWN_USEVFORK` | Transported; not implemented | +| `0x80` | `POSIX_SPAWN_SETSID` | Implemented | + +`scripts/check-abi-version.sh` checks the generated TypeScript, public musl +limits header, and private spawn header for freshness. Parser, host, and libc +tests also assert the formula and boundary behavior. The static scratch +contract checks that generated TypeScript values match the public musl limits +header and private spawn header, that `limits.h` consumes the generated +platform header, and that the musl build stages both headers before compiling +`sysconf`. Values deliberately classified differently therefore cannot +silently drift. + +## Historical pre-retarget spawn buffer sizing evidence + +All numeric results in this section are historical #1094-baseline or +pre-retarget dirty-worktree measurements. They are retained to explain the +buffer-design decision; they are not current final-head performance evidence. +Exact-PR-head retained-memory and timing results are recorded in the mutable +draft PR ledger under the recording contract at the end of this section. + +Three designs were evaluated: + +1. The #1094 fixed 8,417,320-byte kernel allocation is simple and safe, but + first use just above channel size retains the complete worst case. +2. A Rust-owned reusable `Vec` can grow to the requested high-water mark. + A fresh token must bind every operation even when the existing capacity is + reused. `try_reserve_exact` reports allocation failure before publishing a + reservation; begin is rejected while another reservation is active. +3. Repeated/geometric host calls to `kernel_alloc_scratch` have no free + operation and would permanently leak every older region. That design was + rejected. ABI 43 has no host-allocation or older-kernel fixed-buffer + fallback. + +The tokenized Rust-owned reusable region is the chosen current-source design +because Rust remains the sole allocation owner and no pointer +can be used without an active exclusive reservation. Begin and the +pointer/capacity queries are nonblocking; contention returns `EBUSY` or zero. +After every successful begin, host cancellation runs in a `finally` block, +including setup and copy failures. Commit and cancellation wait on the same +no-host-import critical section and return with a definitive token state. +Commit parses the selected prefix into owned vectors and drops the scratch +lock before process-table work or host imports, so the allocation lifetime and +reentrancy rules are mechanically enforced rather than inferred from +JavaScript event-loop behavior. + +The workload performs one ordinary spawn with the fixed environment `LANG=C`, +`PATH=/bin`, one spawn whose complete wire blob is exactly 84,386 bytes, and +five more spawns at that size. It fails a sample unless every waited child +exits normally with status zero. Each round starts a fresh dedicated kernel +worker. The fixed-buffer baseline was built from an isolated archive of exact +#1094 head `6d923c6454dd7174082f25c3d3991d03f86f5ddb`; its temporary +host-only telemetry reported the existing fixed constant after program +completion and did not change kernel allocation or copy behavior. The +hardened measurements used the tokenized ABI-43 kernel and host artifacts at +the fingerprinted rehearsal state below. Subsequent descriptor and dispatcher +hardening means those fingerprints are not the current source head. + +Earlier diagnostic samples used a workload whose ordinary environment was +host-derived and which did not reject a nonzero or abnormal child exit. They +are superseded and are not presented as evidence for the hardened workload +described above. + +The comparable rehearsal measurements completed on July 25, 2026. Values are +medians of three fresh-worker rounds; times are milliseconds and memory is +bytes: + +The measured toolchain was Node.js `v24.15.0`, Playwright `1.61.0`, and +Chromium `149.0.7827.55`. + +| Host and design | Ordinary spawn | First 84,386-byte spawn | Five repeated 84,386-byte spawns, per spawn | Reported retained scratch capacity | Kernel linear-memory high-water mark | +|---|---:|---:|---:|---:|---:| +| Node, #1094 fixed buffer | 51.378 | 47.488 | 46.3876 | 8,417,320 | 26,017,792 (397 pages) | +| Node, tokenized growable buffer | 46.8 | 44.08 | 43.28 | 84,386 | 17,694,720 (270 pages) | +| Chromium, #1094 fixed buffer | 14 | 12 | 11 | 8,417,320 | 26,017,792 (397 pages) | +| Chromium, tokenized growable buffer | 14 | 11 | 10.2 | 84,386 | 17,694,720 (270 pages) | + +The focused workload therefore measured 8,332,934 fewer bytes of reported +retained scratch capacity, a 98.997% reduction. Whole kernel linear memory was +8,323,072 bytes, or 127 64-KiB pages, smaller after the workload (31.990%). +Those are memory measurements, not an allocator-rounding claim. The three +timing samples are too small and noisy to support a speedup or no-regression +claim; no such claim is made. + +For this design, post-run kernel memory equals peak kernel memory only because +WebAssembly memory grows monotonically and cannot shrink. Post-run Rust +`Vec` capacity is the retained scratch high-water mark only because the +kernel intentionally keeps that reusable allocation and does not shrink it +between spawns. These implementation properties make the final samples valid +for this workload; they are not a general substitute for peak-memory +instrumentation. + +The prepared hardened workload source has SHA-256 +`53556d1ad905c92b70b0f5cff29babcf5c0b3183185cdd6a86303eac18f14cc5`. +The exact-#1094 and measurement-time hardened workload Wasm files have SHA-256 +values +`b207969191ac8132150d43a84f0f2857db4326e7108b93ff60be7159de835514` +and +`e0738d4e6f87e099aa843ae562b03f14b1e16dd30b92abed2302a429c8119cfc`. +The exact baseline kernel Git tree is +`6a8721697edbfa5f4fbd22cb21b41d8ccdcc4a2e` and its built kernel SHA-256 is +`e6979f1fa7fdec68959c7f735c3c16ea91060c61cd203e86fd02eaf9a00326bd`; +the measurement-time hardened built-kernel SHA-256 is +`db2835a4905023c81a3eecaa6861feb955ea0610eb34763a8de65983b8a96ddb`. +The measurement-time hardened Node worker bundle is +`f3e1ae982b9c85fffa8caf85907e9c73e52db1cd24e7a9a2da2a132af279dfdb`, +and the measurement-time `host/src` fingerprint is +`89c24dba492309f0196059184e9af0ffaca4e91f1faa139ed0caa0be6574ac21`. +The fixed-baseline Node and Chromium raw logs have SHA-256 values +`f4374b0df0a66bbbd56f19c5637542fa8f40bbfe5f552b23af055c43fcb18dcc` +and +`a0a4b52088ab19ad71bc88ee48c514e1bc5b0c5c58f33410c66a2bcf5d44e814`. +The measurement-time rehearsal result files are +`benchmark-node-1785010575047.json` with SHA-256 +`78ccaac5f4b737b34b934f68ae808769512c3da824414452dd06d6a325b42fc8` +and `benchmark-browser-1785010588397.json` with SHA-256 +`bbef0b4bb0f82edc3d7f4fcfbc07d8e4fcc88ded464f989a99d2888e96794ede`. +Those result files fingerprint the exact runtime inputs above, but their Git +metadata records older committed head +`08620d9233a2812eb1098fe6e7b53a7fba58afb4` while the measured implementation +was still uncommitted. Later scratch-contract changes also postdate those +fingerprints. The files are evidence for the stated rehearsal workload only, +not a substitute for exact committed-head and post-retarget reruns. + +### Historical pre-retarget dirty-worktree evidence + +The later pre-retarget same-worktree focused results were produced on July 26, +2026 with: + +```bash +scripts/dev-shell.sh npx tsx benchmarks/run.ts --host=node \ + --suite=spawn-scratch --rounds=3 +scripts/dev-shell.sh npx tsx benchmarks/run.ts --host=browser \ + --suite=spawn-scratch --rounds=3 +``` + +Their digests were computed through the declared development shell with: + +```bash +scripts/dev-shell.sh sha256sum \ + benchmarks/results/benchmark-node-1785057474317.json \ + benchmarks/results/benchmark-browser-1785057482750.json +``` + +The Node result is +`benchmarks/results/benchmark-node-1785057474317.json`, SHA-256 +`dfea628c210f77430266d83ec8a48d92387a24870d8b427dd22021354591619d`. +It records timestamp `2026-07-26T09:17:54.316Z`, host `node`, Darwin arm64, +Node.js `v24.15.0`, three rounds, Git head +`b840bf2f145b264512a169dacdee21df1d4ea36b`, and Git ref +`remotes/origin/fix/kernel-scratch-capacity-j5u66-draft`. +The Chromium result is +`benchmarks/results/benchmark-browser-1785057482750.json`, SHA-256 +`dfd44e7b810be14afd7af86e8625b7f860600bfdd855abc4052d91c566851f88`. +It records timestamp `2026-07-26T09:18:02.750Z`, host `browser`, the same +platform, architecture, Node.js harness version, round count, Git head, and +Git ref. The result format does not record the browser executable version. A +same-tree declared-shell inspection reported Playwright `1.61.0` and Google +Chrome for Testing `149.0.7827.55`; those versions are environment metadata, +not fields authenticated by the result-file digests. + +Both files fingerprint the same measured inputs: + +- `local-binaries/kernel.wasm`: 642,109 bytes, SHA-256 + `e2abb9bf9d1b88e47e46f7971036fc44a417b6f4a43819b3319a90b0880b4df8`; +- `host/src`: 111 files and 2,781,747 bytes, SHA-256 + `d5fbfab41983255f2cf0dc0141d2dd82295dd15782b4cbee276abed46004cb64`; +- `benchmarks/wasm/spawn-bench.wasm`: 38,317 bytes, SHA-256 + `e0738d4e6f87e099aa843ae562b03f14b1e16dd30b92abed2302a429c8119cfc`; +- `benchmarks/wasm/hello.wasm`: 7,158 bytes, SHA-256 + `4c059e672853793fe2b0177c205de28d88cd7e8e84dabb79f30353708dce2741`. + +The Node file additionally fingerprints the selected +`host/dist/node-kernel-worker-entry.js`: 1,260,964 bytes, SHA-256 +`80f648f79f4b1020bd8f08e6f0bc545696de66a093b65a2566821661996b3cea`. + +| Host | Ordinary spawn | Wire bytes | First large spawn | Repeated large spawn | Retained scratch | Kernel memory | +|---|---:|---:|---:|---:|---:|---:| +| Node | 49.8 ms | 84,386 | 48.19 ms | 46.94 ms | 84,386 bytes | 17,694,720 bytes | +| Chromium | 20 ms | 84,386 | 17 ms | 15.4 ms | 84,386 bytes | 17,694,720 bytes | + +These are historical pre-retarget dirty-worktree observations, not +committed-head evidence: the JSON Git metadata identifies the checked-out +commit, while the artifact hashes identify the uncommitted runtime inputs +actually measured. They preserve the historical fixed-buffer comparison above +rather than replacing it; no contemporaneous fixed-buffer rerun was made. +Three-round timings do not support a latency improvement or no-regression +claim, and none is made. Retargeting is complete, but the exact frozen final +head still requires its own Node and Chromium reruns. + +The baseline archive was exact #1094 plus a host-only telemetry diff, with +SHA-256 +`f78fbd452f1b758aa9494998e00816aae521d1fc4d66e5c2a0d7de8062ebe73e`; +that diff reported the already-retained fixed capacity and did not change the +kernel allocator or copy path. Its Node worker bundle was +`dd9e9e03c84d80df116448594727f2e12af2957bac3b1e55b3fa9c7b27df5e35`. +The older Chromium wrapper labels the combined run `process-lifecycle`, while +the hardened measurement wrapper labels the isolated component +`spawn-scratch`; both +created a fresh browser kernel for each round and ran the same hardened spawn +workload. This provenance limitation is why the focused results are evidence +for buffer sizing, not broad application performance. + +The browser benchmark now loads optional application URL graphs only when an +application suite asks for them. The dedicated Node `spawn-scratch` suite uses +an empty filesystem because it supplies both executables; the established +`process-lifecycle` suite still requires its default rootfs. Those dependency +declarations let the focused scratch measurement run without weakening the +resolver or silently changing existing process metrics. Application suites +still resolve and enforce the same package policy. Broader Node/browser +application measurements remain blocked by unavailable ABI-43 package +artifacts. The focused workload and all broader measurements must be rerun +on the frozen post-retarget final head. + +### Interim post-retarget dirty-worktree measurements + +The focused workload was rerun after retargeting onto the actual #1097 merge, +but before the implementation was committed and frozen: + +```bash +scripts/dev-shell.sh -- npx tsx benchmarks/run.ts --host=node \ + --suite=spawn-scratch --rounds=3 +scripts/dev-shell.sh -- npx tsx benchmarks/run.ts --host=browser \ + --suite=spawn-scratch --rounds=3 +``` + +The environment reported Node.js `v24.15.0`, Playwright `1.61.0`, and Google +Chrome for Testing `149.0.7827.55`. + +| Host | Ordinary spawn | Wire bytes | First large spawn | Repeated large spawn | Retained scratch | Kernel memory | +|---|---:|---:|---:|---:|---:|---:| +| Node | 51.24 ms | 84,386 | 49.24 ms | 48.25 ms | 84,386 bytes | 17,694,720 bytes | +| Chromium | 17 ms | 84,386 | 15 ms | 14 ms | 84,386 bytes | 17,694,720 bytes | + +The Node result is +`benchmarks/results/benchmark-node-1785070891966.json`, SHA-256 +`2f5c6ec009829d2710f0106d1a0166fa3371e80810935d0bfeef553cee117026`. +The Chromium result is +`benchmarks/results/benchmark-browser-1785070900428.json`, SHA-256 +`29aa9ff74f3a7c81905e308eb01e715ce39430648a00e9bd51bd5b89722e03a4`. +Both identify checked-out Git head +`2e0b32d3e1620c8eb68c41999148824ceb3ccea8`, but the measured source was +dirty. The runtime fingerprints therefore identify the actual inputs: + +- kernel Wasm: 644,386 bytes, SHA-256 + `691a1ceedce21e9bce4c1eda09f646092f6c5c1c89311d70e3cc5debc5ada6a8`; +- `host/src`: 109 files, 2,775,329 bytes, SHA-256 + `1add5d34a592498552e50b9cbe64fc15008890cbe215c092cba61caefd0d4d0f`; +- spawn fixture: 38,373 bytes, SHA-256 + `b7dc5d5bc37aaafd5f384750efbfe10c89cf84de416b55cc40edc6a61c009de0`; +- Node worker bundle: 1,264,520 bytes, SHA-256 + `764ee4d1fdb22965bc6b1270ab1c3d04a250a931bf9a17439cfb617eac4824ea`. + +This post-retarget run again retained only 84,386 scratch bytes instead of the +historical fixed 8,417,320 bytes, and kernel linear memory remained 127 pages +below the comparable fixed-buffer workload. It is memory-sizing evidence for +the measured inputs, not exact committed-head evidence and not a latency +claim. + +### Exact-PR-head measurement recording contract + +After the PR head is frozen, its external validation ledger must record the +exact commit SHA, fresh source/artifact fingerprints, and the same Node and +real-Chromium measurements. Keeping that mutable evidence in the PR description +avoids changing the commit merely to embed its own SHA here. The ledger must +distinguish the historical exact-#1094 baseline from the final ABI-43 +implementation and must not claim a timing improvement from three-round +samples. + +## ABI decision + +`ABI_VERSION` is 43 in the post-retarget implementation: + +- Generated spawn wire values and accepted limits remain byte-for-byte + identical. Moving those constants to the existing generation path would not + require a bump by itself. +- `kernel_handle_channel` now accepts the complete channel capacity as its + second argument and rejects any value other than the canonical allocation + size. Its signature changes from the ABI-42 two-argument form to + `(channel_offset, channel_capacity, pid)`, so old hosts and kernels cannot be + mixed. +- The process-channel signal area is one generated 56-byte delivery record for + both caller widths, replacing the ABI-42 44-byte delivery payload inside a + 48-byte reserved channel area. It carries raw eight-byte `si_value` bits, + `si_code`, sender or + timer metadata, and the alternate-stack fields. The C trampoline copies only + the generated target-native `union sigval` width when constructing + `siginfo_t`, so wasm32 observes the low four bytes and wasm64 observes all + eight. This changes the process-channel layout and handler-visible metadata, + so it is an incompatible ABI change rather than generated bookkeeping. +- `kernel_dequeue_signal` changes from + `(pid, tid, out_ptr)` to `(pid, tid, out_ptr, out_capacity)` and accepts + exactly the generated 56-byte capacity. `kernel_mq_drain_notification` + changes from `(out_ptr)` to `(out_ptr, out_capacity)` and accepts exactly the + eight-byte wake-record capacity. Old hosts cannot safely call either new + export, and new hosts fail closed rather than interpreting a negative mqueue + errno as a pending record. +- `kernel_wait_child_poll` changes from the ABI-42 six-argument form + `(parent_pid, caller_tid, target_pid, event_mask, flags, out_ptr)` to a + seven-argument form with `out_capacity`. It accepts exactly + `KERNEL_WAIT_RESULT_SIZE` (160 bytes), rejects pointer zero with `EFAULT`, + and rejects every nonexact capacity with `EINVAL` before validating the + task or selecting a waitable child. The export-signature and child-state + consumption boundary are incompatible ABI changes covered by ABI 43. +- `kernel_timer_create` changes from + `(clock_id, sigevent_ptr, timerid_ptr)` to + `(clock_id, sigevent_ptr, timerid_ptr, process_pointer_width)`. The fourth + parameter is an `i64` host-private dispatch value in the Wasm export + signature. It selects the complete 64-byte caller-native `sigevent` layout + and preserves `union sigval` as raw `u64` bits, including a wasm64 pointer. + Timer, queued-signal, plain sender, and `SI_MESGQ` metadata now remain intact + through dequeue and native `siginfo_t` reconstruction. +- Generated pointer descriptors now classify every argument as exactly one of + required or nullable, positive-extent null handling follows that explicit + classification, and zero-length `Arg` buffers use a canonical owned empty + range. `prctl` is removed from generic pointer metadata and validated by + option: only its two name operations use a required 16-byte buffer. These + pointer interpretations are incompatible semantic marshalling changes, not + generated-file bookkeeping. +- The large-spawn host/kernel contract is incompatible. The old + pointer-returning reserve/fixed-fallback model is replaced with required + begin, pointer, capacity, cancel, and token-consuming commit exports. +- The host-adapter manifest continues to require `kernel_spawn_process` and + now also requires `kernel_spawn_reserved_process`, + `kernel_clear_process_metadata`, + `kernel_push_process_metadata_entry`, `kernel_set_cwd`, + `kernel_spawn_scratch_begin`, `kernel_spawn_scratch_pointer`, + `kernel_spawn_scratch_capacity`, + `kernel_spawn_scratch_retained_capacity`, `kernel_spawn_scratch_cancel`, + `kernel_msqid_ds_bytes`, `kernel_semid_ds_bytes`, + `kernel_semctl_array_bytes`, and `kernel_shmid_ds_bytes`. A same-version + kernel missing them fails loudly rather than entering a legacy path. +- The three `*_ds_bytes(process_pointer_width)` exports and the host-private + sixth dispatch slot make the caller's wasm32/wasm64 data model authoritative + for `msqid_ds` (96/120 bytes), `semid_ds` (72/88), and `shmid_ds` (88/112). + Semaphore GETALL/SETALL use the separate + permission-aware exact-size export; there is no read-only `IPC_STAT` + compatibility fallback. +- `KernelScratchRegion` remains an internal TypeScript value, but that fact + does not neutralize the required export and synchronization changes. + +PR #1097 merged as +`c7d039794a43788acfa0b0aea30a700c257f57cb` with ABI 42. Retargeting is +complete, so ABI 43 is the decided epoch for these incompatible changes. The +current Rust source, generated TypeScript consumer, and ABI snapshot declare +ABI 43, and generated TypeScript includes the request-aware ioctl table. +Generated-file freshness, the ABI classifier, and the snapshot must pass in +check mode on the exact PR head named by the external validation ledger. + +## Historical and interim validation evidence + +All commands recorded in this ledger ran through `scripts/dev-shell.sh`. +The subsection labels distinguish historical pre-retarget evidence from +current post-retarget dirty-tree evidence. Results inside either category do +not all describe one source fingerprint, and artifact-sensitive results +identify their inputs above. None is presented as an exact frozen final-head +run. + +### Interim post-retarget evidence, not final + +- `scripts/dev-shell.sh -- bash build.sh` passed the complete declared build: + the kernel, both-width program fixtures, host bundles, and an ABI-43 root + filesystem. Before that final run, both + `scripts/build-musl.sh` and `scripts/build-musl.sh --arch wasm64posix` + passed, as did `scripts/build-programs.sh`. +- `scripts/dev-shell.sh -- cargo build --release -p kandelo --target + wasm64-unknown-unknown -Z build-std=core,alloc` passed the explicit wasm64 + kernel build. It emitted existing target/conditional dead-code and + unused-variable warnings, not build errors. +- `scripts/dev-shell.sh -- bash scripts/check-abi-version.sh` passed check mode. + It matched the IPC, native-process, and fixed-process layouts for wasm32 and + wasm64; confirmed all six generated ABI outputs are fresh; and verified that + the snapshot change accompanies the `ABI_VERSION` bump to 43. +- The focused scratch/runtime Node matrix passed 11 files and 425 tests: + + ```bash + scripts/dev-shell.sh -- npm --prefix host exec vitest -- run \ + test/generated-abi.test.ts \ + test/kernel-scratch-contract.test.ts \ + test/wasm-memory-write-audit.test.ts \ + test/kernel-scratch-region.test.ts \ + test/kernel-public-scratch.test.ts \ + test/kernel-scratch-transfer-boundaries.test.ts \ + test/kernel.test.ts \ + test/process-native-layout.test.ts \ + test/timerfd-signalfd-scratch.test.ts \ + test/scm-rights-pipe-lifetime.test.ts \ + test/scm-rights-semantics.test.ts + ``` + + The 189 transfer-boundary cases cover both pointer widths. The four + real-compiled-kernel cases include mqueue and child-wait exact-capacity + contracts. The wait-child case rejects null and capacities 159/161 without + consuming the child or changing canaries, accepts exact capacity 160 and + reaps the child, then receives `ECHILD`. +- A separate canonical host-directory process/spawn batch passed 8 files and + 181 tests: + + ```bash + scripts/dev-shell.sh -- bash -lc 'cd host && npm test -- --run \ + test/spawn-blob-transport.test.ts \ + test/exec-state-tracking.test.ts \ + test/process-wait-lifecycle.test.ts \ + test/readiness-deadline.test.ts \ + test/advisory-lock-kernel.test.ts \ + test/signal-accept-livelock.test.ts \ + test/multi-worker.test.ts \ + test/host-adapter-manifest.test.ts' + ``` + + An initial noncanonical invocation ran `multi-worker.test.ts` from the + repository root and failed two relative `../Cargo.toml` opens with `ENOENT`; + the exact canonical rerun above passed both. That invocation-context failure + is not a runtime or scratch failure. +- After the adversarial review found the wrapper-generation defect, + `kernel-initialization-lifetime.test.ts` passed all four wasm32/wasm64, + reinit, concurrency, and failed-retry cases. The accompanying focused + public-scratch/input-snapshot/lifetime batch passed 54 tests. +- After closing the direct pointer-export audit gap, + `wasm-memory-write-audit.test.ts` passed 67 focused analyzer cases. The + repository-wide `kernel-scratch-contract.test.ts` audit passed its selected + contract case in 26.56 seconds with a 60-second CI timeout. It now traces + wrapped callable provenance to the one reviewed lease-core raw invocation; + no production pointer-bearing export bypass remains allowlisted. +- `scripts/dev-shell.sh -- npm --prefix host run typecheck` passed declaration + generation. +- `scripts/dev-shell.sh -- cargo test --target aarch64-apple-darwin -p + kandelo` passed 1,344 unit tests, four pointer-contract integration tests, + and six compile-fail documentation tests. +- `scripts/dev-shell.sh -- cargo test --target aarch64-apple-darwin -p + wasm-posix-shared` passed 37 shared-contract tests, and + `scripts/dev-shell.sh -- cargo check -p wasm-posix-shared` passed with only + the toolchain's unstable-atomics target-feature warning. +- `scripts/dev-shell.sh -- cargo test -p xtask --target + aarch64-apple-darwin dump_abi::tests` passed 21 generator tests, and + `scripts/dev-shell.sh -- bash scripts/test-resolve-binary-bundle.sh` passed + the standalone generated-bundle freshness check. +- Two focused Playwright commands drove real Chromium with one worker. The + scratch-runtime, path, file-limit, native-layout, terminal, and 16-case + two-width `SCM_RIGHTS` semantic group passed 23 tests. The two-width + child-wait and FIFO/SCM pipe-lifetime group passed another five. Total + focused browser evidence is eight spec files and 28 passed tests. Vite + reported that it could not prebundle optional application imports, but the + minimal self-contained test runner loaded and every listed assertion ran. +- The libc, Open POSIX, and Sortix runners were attempted through their normal + entry points. The exact pre-kernel artifact-closure results are recorded in + “Open evidence gaps” above; none is counted as a conformance pass or a + scratch test failure. +- `scripts/dev-shell.sh -- cargo fmt --all -- --check` remains blocked with + `error: no such command: fmt`; no undeclared host formatter was used. +- The post-retarget Node and real-Chromium spawn-scratch measurements passed + three fresh-worker rounds each. Their values and runtime fingerprints are in + “Interim post-retarget dirty-worktree measurements.” + +Every result in this subsection was produced from the current dirty +post-retarget source, not a frozen commit. It must be repeated as appropriate +on the exact final head. + +### Historical pre-retarget and dirty-worktree evidence + +- `bash scripts/dev-shell.sh bash build.sh`: passed the complete declared build, + including the wasm32 kernel, wasm32/wasm64 guest programs, the TypeScript + host, and the root filesystem. +- `bash scripts/dev-shell.sh cargo build --release -p kandelo --target + wasm64-unknown-unknown -Z build-std=core,alloc`: passed an explicit wasm64 + kernel build from the frozen Rust source. +- `scripts/dev-shell.sh -- cargo test --target aarch64-apple-darwin -p + kandelo`: the historical pre-retarget dirty-worktree run passed all 1,343 + native kernel unit + tests, four integration tests, and six documentation tests. This includes + the exact 56-byte signal record, invalid mqueue notification signums, + full-width signal metadata, and self-sender metadata regressions. +- `bash scripts/dev-shell.sh cargo test --target aarch64-apple-darwin -p + wasm-posix-shared` passed all 36 shared-crate unit tests, and + `bash scripts/dev-shell.sh cargo check -p wasm-posix-shared` passed. +- `bash scripts/dev-shell.sh cargo check -p kandelo --target + wasm32-unknown-unknown -Z build-std=core,alloc` passed the explicit wasm32 + kernel check. These are source/crate checks, not browser or full runtime + evidence. +- `bash scripts/dev-shell.sh cargo test --target aarch64-apple-darwin -p kandelo + --test wasm_api_channel_pointer_contract`: four + integration tests passed. These are source-contract checks over the Wasm API + dispatcher and zero-length `sendmsg` guard, not a wasm-target runtime + execution. +- An earlier `bash scripts/dev-shell.sh bash scripts/check-abi-version.sh` run + passed the ABI classifier and snapshot, generated Rust/TypeScript/C freshness + checks, and the wasm32/wasm64 native-layout checks. After the signal export + and channel-layout changes, `scripts/dev-shell.sh -- bash + scripts/check-abi-version.sh update` again passed both native-layout checks, + the kernel build, and regeneration. Check mode still requires a frozen-head + rerun; update mode is not substituted for that final freshness/classifier + evidence. +- `scripts/dev-shell.sh -- cargo test --target aarch64-apple-darwin -p xtask + dump_abi::tests::generated_native_process_layout_contract_matches_both_musl_targets` + and the corresponding + `dump_abi::tests::generated_channel_contract_covers_status_layout_and_signal_wire` + case passed against the generated native layouts and 56-byte channel signal + wire. +- `scripts/dev-shell.sh -- npm --prefix host test -- --run + test/kernel-scratch-transfer-boundaries.test.ts -t "fails closed when the + mqueue notification drain returns an errno"` passed its one selected case. + It proves `-EINVAL` publishes neither a wake nor a signal; the other 188 + cases in that file were intentionally skipped by the name filter. +- `scripts/dev-shell.sh -- npm --prefix host test -- --run test/kernel.test.ts + -t "requires a nonnull exact-capacity mqueue notification destination"` + passed its one selected real-Wasm case. It seeds one live notification + through `mq_notify`/`mq_timedsend`, rejects pointer zero and capacities 7/9 + without consuming or mutating it, then accepts capacity 8, returns the + expected PID/signum, and preserves both destination canaries. +- `scripts/dev-shell.sh -- npm --prefix host test -- --run + test/process-native-layout.test.ts` passed both wasm32 and wasm64 cases after + the latest full rebuild. Those cases now exercise 56-byte `SA_SIGINFO` + delivery, native C reconstruction, queued/timer/mqueue values, sender + metadata, and invalid `mq_notify` signums. This is Node evidence only; + Chromium remains pending. +- `bash scripts/dev-shell.sh npm --prefix host exec vitest -- run + test/generated-abi.test.ts + test/kernel-scratch-transfer-boundaries.test.ts` passed 195 tests on the + regenerated TypeScript ABI consumer. Of those, 188 are the transfer-boundary + cases covering wasm32/wasm64 positive null, owned empty, fixed-output null, + option-sensitive `prctl`, null nested length, reordered `Deref`, vector and + message capacity, shared-memory allocator identity, and the other + caller/allocation boundaries inventoried above. +- The historical broader focused host matrix passed 255 tests: + + ```bash + scripts/dev-shell.sh npm --prefix host exec vitest -- run \ + test/wasm-memory-write-audit.test.ts \ + test/kernel-scratch-contract.test.ts \ + test/kernel-scratch-region.test.ts \ + test/kernel-public-scratch.test.ts \ + test/spawn-blob-transport.test.ts \ + test/centralized-spawn.test.ts \ + test/spawn-host-parity.test.ts \ + test/host-process-pointer-width.test.ts + ``` + + This matrix includes the 64-case compiler-backed analyzer and the + three-case repository scratch contract, plus region, public wrapper, spawn + transport, spawn lifecycle/parity, and pointer-width coverage. It is focused + Node evidence, not the complete host suite or a browser claim. Both focused + commands remain rehearsal evidence and must be repeated on the frozen + post-retarget head. +- The historical broader integration matrix passed 29/29 files and 679/679 + tests: + + ```bash + scripts/dev-shell.sh npm --prefix host exec vitest -- run \ + test/kernel-scratch-contract.test.ts \ + test/wasm-memory-write-audit.test.ts \ + test/kernel-scratch-region.test.ts \ + test/kernel-scratch-transfer-boundaries.test.ts \ + test/kernel-public-scratch.test.ts \ + test/spawn-blob-transport.test.ts \ + test/pathconf.test.ts \ + test/file-shared-memory.test.ts \ + test/process-native-layout.test.ts \ + test/sysv-ipc.test.ts \ + test/timerfd-signalfd-scratch.test.ts \ + test/kernel-worker-copyback.test.ts \ + test/deferred-worker-start.test.ts \ + test/kernel-wasm-input-snapshot.test.ts \ + test/host-process-pointer-width.test.ts \ + test/program-fixture-freshness.test.ts \ + test/compiled-worker-entry.test.ts \ + test/clone-tid-authority.test.ts \ + test/exec-state-tracking.test.ts \ + test/process-wait-lifecycle.test.ts \ + test/shared-memory-coherence.test.ts \ + test/generated-abi.test.ts \ + test/abi-version.test.ts \ + test/host-adapter-manifest.test.ts \ + test/terminal-attributes-api.test.ts \ + test/centralized-spawn.test.ts \ + test/spawn-host-parity.test.ts \ + test/spawn-pid-authority.test.ts \ + test/advisory-lock-kernel.test.ts + ``` + + This exact rerun includes the shared-memory allocator-identity regression + and every repaired wait-result pointer-plus-capacity expectation. It is + historical dirty-worktree Node evidence, not the complete host suite or a + browser claim. +- `scripts/dev-shell.sh npm --prefix host run typecheck` passed the + pre-retarget dirty-worktree host declaration build. +- `bash scripts/dev-shell.sh npx tsx --test + benchmarks/artifact-selection.test.ts benchmarks/timeout.test.ts`: 13 + benchmark artifact-selection, timeout, and spawn-evidence contract tests + passed. +- The generated package-index projection and its source-context check passed + through the worktree's declared native `xtask` with: + + ```bash + bash scripts/dev-shell.sh target/aarch64-apple-darwin/release/xtask \ + build-deps program-index packages/registry \ + packages/registry/program-packages.json + bash scripts/dev-shell.sh target/aarch64-apple-darwin/release/xtask \ + build-deps program-index-context-check --source-repo-root "$PWD" + ``` + + The exact-source projection has SHA-256 + `538c269f8a4e86305929db6358176a38f4855cb6be9d83e324b1c4028db20fa0` + and is committed because leaving the base projection in place makes the + package-build-root contract fail as stale after the ABI/source changes. +- `bash scripts/dev-shell.sh bash scripts/test-package-build-roots.sh`: passed + after regenerating the projection. The first CI run exposed the stale + projection honestly; no freshness bypass or test exception was added. +- `bash scripts/dev-shell.sh npx tsx benchmarks/run.ts --host=node + --suite=spawn-scratch --rounds=3` and the corresponding `--host=browser` + command both passed. The browser command drove real Chromium. This evidence + covers only the self-contained spawn workload; the exact result files and + fingerprints are recorded in the sizing section. + +Before the fixtures opted out of the default root filesystem, artifact policy +correctly rejected an ABI-mismatched rootfs before any assertion ran: first for +the timer/signalfd cases, and later for the two process-native-layout plus two +System V IPC cases. These tests execute self-contained binaries and require no +rootfs contents, so they now pass `useDefaultRootfs: false`. The default-rootfs +policy itself was not weakened; tests that request that artifact still require +an ABI-matching image. + +### Exact final-head gates + +No dirty-worktree result above is substituted for these gates. The mutable +draft PR ledger must record: + +- exact final head SHA plus source, generated-file, kernel Wasm, worker-bundle, + guest-fixture, and benchmark-input fingerprints; +- the complete declared build and selected native/shared kernel suites; +- ABI check mode, generated-file freshness, ABI classifier, and committed + snapshot checks; +- the focused and broader Node matrices, including the real-Wasm + `kernel_wait_child_poll` exact-capacity regression; +- real Chromium execution of the exact relevant specs, including scratch + runtime, process-native layout, wait lifecycle, and all 16 `SCM_RIGHTS` + semantics cases plus the two pipe-lifetime cases; +- another normal attempt at the blocked Sortix, libc, and Open POSIX coverage + if the ABI-43 package closure becomes available; +- final Node and real-Chromium retained-memory and timing measurements described + in the recording contract above. + +### Historical blockers and uncompleted coverage to reprobe + +- From `apps/browser-demos`, + `../../scripts/dev-shell.sh env CI=1 KANDELO_PLAYWRIGHT_PORT=15466 + npx playwright test test/terminal-attributes-api.spec.ts + test/wait-lifecycle.spec.ts test/environment-lifecycle.spec.ts + test/opfs-advisory-lock.spec.ts --project=chromium` stopped during Vite + startup because the program graph rejected stale ABI-42 `bzip2.wasm`. + No assertion ran; after the blocked setup was interrupted, one test was + reported interrupted and five did not run. This is neither a Chromium pass + nor a changed-runtime failure. +- The historical pre-retarget `SCM_RIGHTS` Chromium attempt stopped before + guest launch on the authored-application graph. The post-retarget focused + minimal-dependency command supersedes that narrow gap: all 16 semantic and + both pipe-lifetime cases now pass. It does not unblock the broad application + graph or make the historical stopped run a pass. +- Sortix, libc-test, and Open POSIX were each reprobed normally and stop before + guest execution on the same incomplete one-tier ABI-43 artifact closure. + Exact counts and the direct resolver diagnostic are recorded above. No + resolver bypass or test-only exception was used. +- The performance guide's complete application suites remain blocked by + unavailable ABI-43 PHP, WordPress, and MariaDB artifacts. The focused timing + sample is not substituted for those suites. +- `bash scripts/dev-shell.sh cargo fmt --all -- --check` could not start: + the declared shell reports `error: no such command: fmt`. The discovered + Homebrew formatter is undeclared and was not used. +PR #1097 is merged and retargeting is complete. The focused Chromium and +retained-capacity results above remain historical/interim evidence rather than +complete-application evidence. No approval may be requested and no merge may +occur unless the draft PR's validation ledger names its current exact head and +reports the required reruns and external blocks truthfully. diff --git a/docs/posix-status.md b/docs/posix-status.md index 1d1e682013..8a1d3dfa4d 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -38,7 +38,8 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve - `kernel_fork_process(parent, caller_tid)` — validate the calling task, allocate a child PID, and copy inherited state including that task's signal mask - `kernel_spawn_process(parent, caller_tid, blob_ptr, blob_len)` — validate the calling task, allocate the child PID, and apply spawn attributes and file actions - `kernel_remove_process(pid)` — clean up on exit -- `kernel_handle_channel(offset, pid)` — dispatch a syscall from a process's channel +- `kernel_handle_channel(offset, capacity, pid)` — dispatch a syscall from a + process's capacity-bounded channel allocation --- @@ -122,7 +123,7 @@ same final-OFD lifetime rules. | Function | Status | Notes | |----------|--------|-------| | `fork()` | Partial | The kernel validates the calling task, allocates the child PID, and copies process state; the host starts a child Worker with copied Memory. The child inherits the calling task's blocked signal mask, and libc refreshes a copied pthread TID from the kernel before returning from `fork()`. Host-owned continuation and fork channel requests leave caught signals kernel-pending; after the import returns, libc performs an ordinary syscall checkpoint so the guest signal trampoline owns handler invocation and mask restoration without host-to-Wasm reentrancy. Initial launch mirrors the environment into kernel-owned process state; fork copies that metadata while instrumented rewind preserves the live libc `environ` in copied Memory, and `execve()` replaces both from its supplied `envp`. `wasm-fork-instrument` resumes the child at the call site with scalar locals in linked frames and versioned reconstruction recipes for references, exceptions, globals, tables, and dynamic-link activations. Root or later continuation-allocation failure and a negative `SYS_FORK` result unwind transactionally, create no child, and return the failure to the still-running parent. Main-thread and pthread fork are supported, including nested main/side-module stacks and process-owned dynamic-link/table replay. Pipes, sockets, PTYs, eventfd/timerfd/signalfd, memfd, procfs snapshots, and shared mappings retain their existing backings; signal and wait lifecycle state is copied/coordinated by the kernel. An inherited directory drops the parent's process-local host iterator and lazily reopens at the copied next-record cookie, so handles cannot alias, but later parent/child cursor movement is not shared. Ordinary regular-file OFD seek positions/status flags have the same copied rather than shared boundary. See [fork-instrumentation.md](fork-instrumentation.md) and the known OFD gap below. | -| `exec()` | Partial | Kernel-initiated via SYS_EXECVE (syscall 211). The host preflights the module, ABI, replacement memory, caller, deferred file actions, and a 4 MiB combined argv/environment representation (strings, terminators, and pointer entries) before replacing the image in place; individual strings are limited to 64 KiB and oversize returns `E2BIG` without truncation. Preserves PID, non-CLOEXEC fds and their exact kernel-backed object state, new argv/envp (including an explicitly empty environment), CWD, the calling pthread's signal mask and directed queue, terminal queues, and `alarm()`/`ITIMER_REAL`; closes directory streams, deletes `timer_create()` timers, publishes and detaches old mappings, terminates sibling threads, and resets the program break before installing the new `__heap_base`. File mappings retain a stable writeback handle even after their original fd closes. Remaining gaps: POSIX message-queue descriptors are not process-owned and therefore cannot yet be closed on exec; epoll registrations track numeric fds rather than OFD identity, so close/dup and same-number replacement cases are incomplete; and main-thread-directed signals share the process-pending queue and therefore cannot be distinguished from process-directed signals when a worker pthread execs. | +| `exec()` | Partial | Kernel-initiated via SYS_EXECVE (syscall 211). The host preflights the module, ABI, replacement memory, caller, deferred file actions, and a 4 MiB combined argv/environment representation (strings, terminators, and pointer entries) before replacing the image in place. Independently, each string is limited to the current 64 KiB process-metadata transfer; this is an implementation transport ceiling, not part of aggregate `ARG_MAX`, and oversize returns `E2BIG` without truncation. Preserves PID, non-CLOEXEC fds and their exact kernel-backed object state, new argv/envp (including an explicitly empty environment), CWD, the calling pthread's signal mask and directed queue, terminal queues, and `alarm()`/`ITIMER_REAL`; closes directory streams, deletes `timer_create()` timers, publishes and detaches old mappings, terminates sibling threads, and resets the program break before installing the new `__heap_base`. File mappings retain a stable writeback handle even after their original fd closes. Remaining gaps: POSIX message-queue descriptors are not process-owned and therefore cannot yet be closed on exec; epoll registrations track numeric fds rather than OFD identity, so close/dup and same-number replacement cases are incomplete; and main-thread-directed signals share the process-pending queue and therefore cannot be distinguished from process-directed signals when a worker pthread execs. | | `wait()` / `waitpid()` / `wait4()` / `waitid()` | Partial | Rust-owned child status covers stop, continue, normal exit, and signal death. New status replaces older unconsumed status; `waitid(WNOWAIT)` preserves the current record. `WNOHANG`, `WUNTRACED`/`WSTOPPED`, `WEXITED`, and `WCONTINUED` are supported, as are specific-PID, any-child, same-process-group, and specific-process-group selection. Stop/continue reports do not reap; consuming exit status does. A top-level host launch has `ppid=0`; its status is consumed by the host API, and the host asks Rust to reap it only after its Workers can issue no more syscalls. `wait4()` returns the zero-filled resource-usage wire record described under `getrusage()`. Remaining gap: a blocked `pid == 0` / `P_PGID,id == 0` wait currently re-evaluates the caller's process group on each host retry instead of freezing it at call entry. | | `exit()` / `_exit()` | Partial | Closes all fds and dir streams, releases locks and mapping/backing ownership, and retains the low eight status bits. Normal codes 128–255 remain distinct from signal termination, which is stored separately. SIGCHLD is delivered to a guest parent and guest-child zombie state remains until `waitpid()` reaps it. The host separately reaps only exited direct children of `ppid=0` after Worker teardown. Orphan adoption is not yet implemented when a guest parent exits. | | `getpid()` | Full | Returns pid from Process struct. | @@ -137,7 +138,7 @@ same final-OFD lifetime rules. | `setpgid()` | Partial | Sets process group ID. pid=0 means self. pgid=0 means use target pid. Only supports setting own pgid; other processes return ESRCH. | | `getsid()` | Full | Returns session ID (simulated, defaults to pid). pid=0 means self. | | `setsid()` | Full | Creates new session. Sets sid=pid, pgid=pid. Returns new session ID. Returns EPERM if already session leader (POSIX-compliant). | -| `prctl()` | Partial | PR_SET_NAME and PR_GET_NAME store/retrieve thread name (16 bytes). All other operations return success (no-op). Syscall number fixed to 223 (Batch 3). | +| `prctl()` | Partial | `PR_SET_NAME` and `PR_GET_NAME` store/retrieve one required, exact 16-byte thread-name buffer; null fails with `EFAULT` on wasm32 and wasm64. Other operations preserve argument 1 as a low-32-bit scalar, stage no scratch pointer, and currently return success as a no-op. Syscall number fixed to 223 (Batch 3). | | `gettid()` | Partial | Returns pid for the main thread and the host-bound worker TID for pthread workers. Remaining limitation: this is Linux-compatible rather than POSIX-standard, and not all signal/thread APIs consume TID-specific state yet. | | `set_tid_address()` | Partial | Returns the calling TID and stores the clear-TID pointer for thread exit notification. Host thread cleanup writes 0 and futex-wakes the address for normal pthread exit and forced cleanup paths. Robust-list handling remains deferred. | | `set_robust_list()` | Stub | No-op. Robust futex list tracking deferred until threading is fully tested. | @@ -146,7 +147,7 @@ same final-OFD lifetime rules. | `execveat()` | Partial | SYS_EXECVEAT (386). Resolves fd path via `kernel_get_fd_path`, supports AT_EMPTY_PATH for `fexecve()`, and resolves relative paths against process CWD; otherwise has the same remaining `exec()` limitations. | | `fork()` (syscall) | Partial | Glue traps through channel IPC; the kernel copies process state, the host starts a child Worker, and `wasm-fork-instrument` replays the call stack so parent/child receive the POSIX return values. ABI 43 requires the activation-state-safe artifact capability before launch and validates the linked-frame, reference/exception recipe, mutable module-state, table-journal, and activation-catalog contracts. Unsafe ABI 42, malformed, or mixed-version artifacts fail before execution. Negative results replay to the caller without terminating the parent, including continuation-allocation failure before or during unwind. The ordinary-OFD limitations in the main `fork()` row still apply. | | `vfork()` | Partial | Alias for `fork()` and therefore has the same continuation/OFD limitations. It neither suspends the calling parent thread nor shares that process memory with the child until `exec()` or `_exit()`, so it cannot avoid Kandelo's eager fork-memory copy. | -| `posix_spawn()` | Partial | **Non-forking implementation** (this kernel's invention; no Linux equivalent). Glue issues `SYS_SPAWN` (500) with a marshalled blob (argv + envp + file actions + spawn attrs). The host passes the calling TID to `kernel_spawn_process`; the Rust `ProcessTable` validates that task, allocates the child PID from the global task-ID sequence, and builds the child Process descriptor before `onSpawn` launches a fresh Worker. The child inherits the calling task's signal mask unless `POSIX_SPAWN_SETSIGMASK` replaces it. No fork, no `wpk_fork_*` rewind, no exec replay. Supports POSIX_SPAWN_SETSID / SETPGROUP / SETSIGMASK / SETSIGDEF and FDOP_OPEN / CLOSE / DUP2 / CHDIR / FCHDIR. SIG_IGN dispositions persist across the implicit exec; custom handlers reset to SIG_DFL (POSIX exec semantics). An inherited directory never aliases the parent's live host iterator: spawn preserves its next-record cookie and lazily reopens a child-owned iterator there. Its later cursor movement and ordinary-file OFD metadata are still process-local rather than shared; see the known OFD gap below. Regression-guarded: `kernel_get_fork_count` exposes a per-process counter the test suite asserts is unchanged across SYS_SPAWN. See `docs/plans/2026-05-04-non-forking-posix-spawn-design.md`. | +| `posix_spawn()` | Partial | **Non-forking implementation** (this kernel's invention; no Linux equivalent). Glue issues `SYS_SPAWN` (500) with a marshalled blob (argv + envp + file actions + spawn attrs). Generated platform limits supply the advertised 4 MiB combined argv/environment `ARG_MAX` and 4,096-byte `PATH_MAX` including NUL; the separate generated wire contract defines a 40-byte header, 28-byte action records, defensive caps of 4,096 argv entries, 4,096 environment entries, and 1,024 actions, and an 8,417,320-byte complete transport ceiling. The count and transport caps defend this representation; they are not additional POSIX limits. Independently, each argv/environment string must fit the current 64 KiB process-metadata transfer. That host implementation ceiling is separate from aggregate `ARG_MAX`. The host proves caller ranges, parsed limits, the selected kernel-owned allocation capacity, and the current kernel-memory range independently; fitting inside total kernel Wasm memory is not proof that the destination allocation owns those bytes. Ordinary blobs reuse channel scratch. Each larger blob begins a fresh exclusive reservation on a Rust-owned reusable high-water buffer, reads its pointer and capacity, copies under one synchronous lease, and commits with the matching opaque token. Begin and pointer/capacity queries are nonblocking; commit and cancellation wait on a no-host-import critical section. After every successful begin, the host cancels in a `finally` block, including setup and copy failures, so it returns with either a released unconsumed token or a definitive already-consumed/stale result. Overlapping or reentrant large-spawn attempts cannot replace live bytes. The host passes the calling TID to `kernel_spawn_process` or `kernel_spawn_reserved_process`; the Rust `ProcessTable` validates that task, allocates the child PID from the global task-ID sequence, and builds the child Process descriptor before `onSpawn` launches a fresh Worker. The child inherits the calling task's signal mask unless `POSIX_SPAWN_SETSIGMASK` replaces it. No fork, no `wpk_fork_*` rewind, no exec replay. Supports POSIX_SPAWN_SETSID / SETPGROUP / SETSIGMASK / SETSIGDEF and FDOP_OPEN / CLOSE / DUP2 / CHDIR / FCHDIR. SIG_IGN dispositions persist across the implicit exec; custom handlers reset to SIG_DFL (POSIX exec semantics). An inherited directory never aliases the parent's live host iterator: spawn preserves its next-record cookie and lazily reopens a child-owned iterator there. Its later cursor movement and ordinary-file OFD metadata are still process-local rather than shared; see the known OFD gap below. Regression-guarded: `kernel_get_fork_count` exposes a per-process counter the test suite asserts is unchanged across SYS_SPAWN. See `docs/plans/2026-05-04-non-forking-posix-spawn-design.md`. | | `posix_spawnp()` | Partial | PATH search lives in libc (`libc/musl-overlay/src/process/wasm32posix/posix_spawnp.c`); resolves the absolute path then delegates to `posix_spawn()`. Empty PATH entries are treated as `.` and EACCES is deferred per `__execvpe` policy. It inherits `posix_spawn()`'s cross-process open-file-description limitation. | | `clone()` | Partial | Thread-style clone (CLONE_VM\|CLONE_THREAD) supported. The Rust `ProcessTable` allocates the TID from the same global task-ID sequence as every PID, and the host spawns a thread Worker sharing the parent's Memory. Normal pthread return, pthread_exit, and cancellation cleanup remain per-thread and wake join/clear-TID waiters; uncaught fatal Wasm traps in a pthread worker terminate the whole process with signal-style wait status. | | `personality()` | Stub | Returns 0 (PER_LINUX). | @@ -272,7 +273,7 @@ shortcuts. | `connect()` | Partial | AF_UNIX streams support same- and cross-process pathname or abstract-namespace listeners; pathname lookup uses the same canonical component walker as bind, including cross-process retries. AF_UNIX datagrams deliver to a registered peer only within the same process; a missing, wrong-type, or cross-process peer returns ECONNREFUSED until machine-wide datagram routing exists. AF_INET TCP is host-backed and works over Node external TCP or the browser local virtual-network backend. For an external non-blocking TCP handshake, the first pending call reports EINPROGRESS, a repeat while it remains pending reports EALREADY, and poll reports writable when completion or failure can be collected through SO_ERROR; blocking callers wait through the same host connection. AF_INET UDP connect stores the peer, auto-binds an ephemeral local port when needed, filters receives to the connected peer, and supports AF_UNSPEC unconnect. AF_INET6 streams support same- and cross-process `::1`; AF_INET6 datagrams are process-local and report `IPV6_V6ONLY=1` because dual-stack datagram routing is not implemented. Non-loopback IPv6 fails with EADDRNOTAVAIL for streams and ENETUNREACH for datagrams. External raw UDP also returns ENETUNREACH without another HostIO transport. | | `send()` / `recv()` | Partial | Unix domain streams and datagrams, AF_INET/AF_INET6 TCP streams, and connected AF_INET/AF_INET6 UDP preserve their socket-family addressing and datagram boundaries. TCP send/recv works over Node external TCP and the local virtual-network backend. Datagram MSG_PEEK and MSG_DONTWAIT are handled through recvfrom. Normal TCP close drains queued bytes before FIN and EOF; no transport invents a fixed post-FIN write count. A send rejected by a closed/reset stream returns EPIPE and raises SIGPIPE, while direct host/virtual handles may preserve ECONNRESET; accepted pipe-bridged resets currently surface as EOF/EPIPE. MSG_NOSIGNAL suppresses SIGPIPE without changing the errno. | | `sendto()` / `recvfrom()` | Partial | AF_INET, AF_INET6, and AF_UNIX datagrams support connected and unconnected send, receive queues, and connected-peer filtering. IPv4/IPv6 return sender addresses; AF_UNIX currently returns only the family. IPv4 limited-broadcast sends to `255.255.255.255` require `SO_BROADCAST` and fail with `EACCES` without it; enabling the option passes that permission gate, after which the send reaches the active routing/backend boundary. Kandelo does not itself model broadcast delivery. On AF_INET, AF_INET6, and AF_UNIX datagrams, Linux's input `MSG_TRUNC` extension returns the full datagram length while copying at most the caller's buffer; ordinary consume/`MSG_PEEK` behavior is unchanged. IPv4/IPv6 UDP receive queues hold 128 datagrams and drop a new arrival once full, preserving the accepted queue's order; `SO_RCVBUF` requests do not size that fixed queue, and `getsockopt` reports the fixed default capacity. AF_UNIX uses the same bound but preserves reliable delivery: a full queue blocks a blocking send through host retry and returns EAGAIN for `O_NONBLOCK`/`MSG_DONTWAIT`; capacity, association, shutdown, close, and pathname changes wake blocked sends and writable readiness waits to observe capacity or the new immediate error. In-kernel IPv4/IPv6 loopback, AF_UNIX datagram, and IPv4 multicast delivery currently reaches sockets in the sender's process only; machine-wide cross-process datagram routing remains unimplemented. Fork preserves kernel-local bind reservations and lookup ownership, but it does not yet share or transfer a host-backed UDP registration. The `10.88.*` LocalVirtualNetwork path can route IPv4 datagrams between attached Kandelo machines through HostIO for the process that registered the endpoint. IPv4 multicast supports interface selection, loop suppression, membership, and source filtering only; IPv6 multicast and external raw UDP are not implemented. | -| `sendmsg()` / `recvmsg()` | Partial | Minimal first-iovec wrappers are implemented. `SCM_RIGHTS` on AF_UNIX streams preserves kernel pipe and socket endpoints while descriptors are queued, including exact named-FIFO path-only, read-only cohort, write-only, and read-write ownership. It transfers each reference to a successfully installed fd, releases descriptors that cannot be returned, and collects unreachable self-referential or mutually referential socket-rights cycles. Closing the sender's fd cannot invalidate an in-flight or received reference. `recvmsg()` installs the descriptor prefix that fits the caller's control buffer, releases the excess, and reports `MSG_CTRUNC`. Input `MSG_TRUNC` reaches the datagram receive behavior above, but `recvmsg()` does not yet populate that output flag. | +| `sendmsg()` / `recvmsg()` | Partial | The host validates every native wasm32/wasm64 iovec and enforces the generated `IOV_MAX` of 1,024. It flattens the complete send list into one fixed-wire kernel buffer and scatters a received prefix across the complete caller list; zero-length entries remain valid. The fixed header, optional name, translated control records, one canonical iovec, and payload must fit the 65,536-byte owned transport or the call fails with `EMSGSIZE`. This transport ceiling is an implementation boundary, not an alternate `IOV_MAX`. Native `cmsghdr` records are translated between the generated wasm32/wasm64 layouts and a fixed kernel wire, so receive capacity reflects the descriptors the caller layout can actually represent. `SCM_RIGHTS` preserves owned, receiver-reconstructible non-socket descriptions while they are queued. A batch containing a socket, epoll instance, stale backing, or other process-owned description that cannot be reconstructed fails atomically with `EOPNOTSUPP` before carrier bytes are published; a copied socket snapshot is never reported as successful transfer. AF_UNIX stream rights remain associated with their carrier-byte positions, `MSG_WAITALL` stops at a rights boundary, ordinary reads discard only rights whose bytes they consume, and repeated `MSG_PEEK` does not consume bytes or rights. AF_UNIX datagrams queue payload/address/rights atomically for connected and addressed same-process sends, including zero-byte messages received with `msg_iovlen == 0`; ordinary `read(..., 0)` remains a no-op. Closing the sender's fd cannot invalidate a supported in-flight or received reference. `recvmsg()` installs the descriptor prefix that fits the caller's control buffer, releases the excess, reports `MSG_CTRUNC`, applies `MSG_CMSG_CLOEXEC` atomically, and reports output `MSG_TRUNC` independently of the input flag that selects full-length return behavior. Cross-process AF_UNIX datagram routing, socket-descriptor transfer, and other socket-family ancillary messages remain unsupported, so this surface is still partial. | | `setsockopt()` / `getsockopt()` | Partial | SOL_SOCKET exposes SO_TYPE, SO_DOMAIN, SO_ERROR, SO_ACCEPTCONN, SO_RCVBUF, and SO_SNDBUF; SO_REUSEADDR affects UDP bind conflicts. `SO_RCVTIMEO`/`SO_SNDTIMEO` accept musl's wasm32 time64 option numbers (66/67) and wasm64 long64 numbers (20/21), canonicalizing both to the same stored timeout state; `struct timeval` is 16 bytes on both ABIs. `SO_RCVBUF`/`SO_SNDBUF` requests are accepted and stored but do not resize kernel queues or pipe buffers; `getsockopt()` reports the fixed default. `SO_BROADCAST` controls only the IPv4 limited-broadcast permission gate and does not provide broadcast delivery. SO_LINGER uses `struct linger`; its disabled form is stored, while enabling timed or reset-style linger returns EOPNOTSUPP until every transport supports the close mode. SO_BINDTODEVICE validates `lo`/`eth0`, supports empty-name unbind, and constrains bind/connect/send routing. TCP_CONGESTION uses a string layout and accepts only the modeled `cubic` policy; selecting unimplemented algorithms fails. IPv4 multicast membership/source-filter options drive process-local loopback delivery. IPV6_V6ONLY controls pre-bind stream dual-stack behavior; AF_INET6 datagrams truthfully remain V6-only. Other accepted IPv6 multicast options are stored but do not provide IPv6 multicast transport. | | `shutdown()` | Partial | SHUT_RD, SHUT_WR, and SHUT_RDWR transitions are idempotent within a process and release each owned pipe/host reference once. UDP write shutdown returns EPIPE on datagram send; read shutdown is EOF-like for recv/poll. Sending to a read-shut AF_UNIX datagram peer returns EPIPE (and SIGPIPE unless MSG_NOSIGNAL is used), and the transition wakes blocked sends/readiness waits. Fork-inherited sockets still clone shutdown flags per process instead of sharing one socket-wide shutdown state, and the external host ABI has no half-shutdown operation. | | `select()` | Partial | Wrapper around poll(). Converts fd_set bitmasks to pollfd array. Timeout supported via a host retry loop. A caught signal interrupts a would-block retry, including the no-fd sleep path, with EINTR; ignored signals leave it parked and a concurrently ready result is preserved. | @@ -326,7 +327,7 @@ shortcuts. | `inotify_init()` / `inotify_init1()` | Stub | Returns ENOSYS. | | `inotify_add_watch()` / `inotify_rm_watch()` | Stub | Returns EBADF. | | `fanotify_init()` / `fanotify_mark()` | Stub | Returns ENOSYS. | -| `timer_create()` | Partial | Supports `CLOCK_REALTIME`, `CLOCK_MONOTONIC`, and monotonic-equivalent `CLOCK_BOOTTIME` with `SIGEV_SIGNAL`, `SIGEV_NONE`, Linux `SIGEV_THREAD_ID`, and POSIX `SIGEV_THREAD` through musl's exact-thread helper. Expirations preserve `SI_TIMER`, `si_value`, timer ID, and overrun metadata on Node and browser hosts. The fixed kernel wire carries `sival_int`; on wasm64, direct signal notifications cannot carry a wider `sival_ptr`, while `SIGEV_THREAD` retains the native-width callback value locally in the helper. | +| `timer_create()` | Partial | Supports `CLOCK_REALTIME`, `CLOCK_MONOTONIC`, and monotonic-equivalent `CLOCK_BOOTTIME` with `SIGEV_SIGNAL`, `SIGEV_NONE`, Linux `SIGEV_THREAD_ID`, and POSIX `SIGEV_THREAD` through musl's exact-thread helper. Expirations preserve `SI_TIMER`, timer ID, overrun metadata, and the complete caller-native `union sigval` on Node and browser hosts. The generated 64-byte `sigevent` layout and explicit process pointer width preserve a wasm64 `sival_ptr`; wasm32 delivery uses its target-native 32-bit union width. | | `timer_settime()` / `timer_gettime()` | Partial | Absolute (`TIMER_ABSTIME`) and relative timers and automatic interval rearming use host timers with millisecond granularity. `timer_gettime()` and `timer_settime()`'s old-value result currently report the last configured value rather than decreasing remaining time. | | `timer_getoverrun()` | Full | Tracks overruns per timer while its notification remains pending and reports the count associated with the most recently accepted notification. | | `timer_delete()` | Full | Cancels the host timer, removes its queued notification before slot reuse, and removes it from the per-process table. | @@ -335,13 +336,13 @@ shortcuts. | Function | Status | Notes | |----------|--------|-------| -| `msgget()` / `msgsnd()` / `msgrcv()` / `msgctl()` | Full | Host-side SysV message queues via SharedIpcTable. Key-based creation, blocking send/recv with message types, IPC_STAT/IPC_SET/IPC_RMID control. | -| `semget()` / `semop()` / `semctl()` / `semtimedop()` | Full | Host-side SysV semaphore sets. Atomic multi-semaphore operations, SEM_UNDO support, IPC_STAT/SETVAL/GETVAL/SETALL/GETALL. | -| `shmget()` / `shmat()` / `shmdt()` / `shmctl()` | Partial | Host-side SysV shared-memory segments support IPC_STAT/IPC_RMID, fork inheritance, and exact attach/detach accounting. Separate process memories merge changed attachment bytes and import peer changes at syscall boundaries. Direct stores are not immediately visible and cross-process futex synchronization over an attachment is unsupported. | +| `msgget()` / `msgsnd()` / `msgrcv()` / `msgctl()` | Full | Host-side SysV message queues via SharedIpcTable. Key-based creation, blocking send/recv with message types, IPC_STAT/IPC_SET/IPC_RMID control. IPC_STAT/IPC_SET stage the caller-width `msqid_ds`: 96 bytes for wasm32 time64 and 120 bytes for wasm64 LP64. | +| `semget()` / `semop()` / `semctl()` / `semtimedop()` | Full | Host-side SysV semaphore sets. Atomic multi-semaphore operations, SEM_UNDO support, IPC_STAT/SETVAL/GETVAL/SETALL/GETALL. Before a pointer transfer, required kernel preflights return the exact allocation demand: permission-aware array bytes for GETALL/SETALL and the caller-layout `semid_ds` size for IPC_STAT. Musl's wasm32 time64 structure is 72 bytes and its wasm64 LP64 structure is 88 bytes; the process pointer width, not the kernel Wasm width, selects the layout. | +| `shmget()` / `shmat()` / `shmdt()` / `shmctl()` | Partial | Host-side SysV shared-memory segments support IPC_STAT/IPC_SET/IPC_RMID, fork inheritance, and exact attach/detach accounting. IPC_STAT/IPC_SET stage the caller-width `shmid_ds`: 88 bytes for wasm32 time64 and 112 bytes for wasm64 LP64. Separate process memories merge changed attachment bytes and import peer changes at syscall boundaries. Direct stores are not immediately visible and cross-process futex synchronization over an attachment is unsupported. | | `ftok()` | Full | Standard ftok algorithm using stat inode + proj_id. | | `mq_open()` / `mq_close()` / `mq_unlink()` | Full | Host-side POSIX message queues via PosixMqueueTable. O_CREAT/O_EXCL/O_RDONLY/O_WRONLY/O_RDWR/O_NONBLOCK. Descriptor range 0x40000000+. | | `mq_timedsend()` / `mq_timedreceive()` | Full | Priority-ordered message delivery. Blocking with timeout support. O_NONBLOCK returns EAGAIN. | -| `mq_notify()` | Full | SIGEV_SIGNAL notification on message arrival to empty queue. One registration per queue. | +| `mq_notify()` | Full | One `SIGEV_SIGNAL` notification on the first message sent to an empty queue, with one registration per queue. Signal numbers outside `1..NSIG` fail with `EINVAL`. Rust queues authoritative `SI_MESGQ`, full-width `union sigval`, and sender metadata before the host wake; the host does not synthesize a second signal. | | `mq_getattr()` / `mq_setattr()` | Full | Get/set queue attributes (mq_flags, mq_maxmsg, mq_msgsize, mq_curmsgs). | ## Extended Attributes @@ -357,7 +358,7 @@ shortcuts. | Function | Status | Notes | |----------|--------|-------| | `isatty()` | Full | Returns 1 for CharDevice, PtyMaster, and PtySlave fds; ENOTTY for others. | -| `tcgetattr()` / `tcsetattr()` | Partial | CharDevice and PTY fds round-trip musl's 60-byte termios layout, including all four flag words, `c_line`, `c_cc`, and input/output speeds; custom syscalls 70/71 retain the older 48-byte flags-plus-`c_cc` layout. `TCSANOW` and `TCSADRAIN` preserve unread input across `ICANON` transitions: completed lines and the current edited partial line become raw-readable in byte order, while unread raw bytes become immediately readable if the mode changes back, matching Linux EOF-push behavior. `TCSAFLUSH` discards unread input before applying the change. PTY writes synchronously enter the output queue, so there is no deferred device transmission to await. Implemented line discipline includes `VERASE`, `VKILL`, non-empty-line `VEOF`, ICRNL/INLCR/IGNCR, and ECHO/ECHOE/ECHOK/ECHONL. Remaining gaps: `VMIN`/`VTIME` values round-trip but raw-read timing is approximated, an empty canonical `VEOF` does not create a queued EOF event, a canonical `read()` can return bytes from multiple completed lines instead of stopping after one line, `VWERASE` is not implemented, and exposed input/output flags outside the listed subset do not all have data-path semantics. | +| `tcgetattr()` / `tcsetattr()` | Partial | CharDevice and PTY fds round-trip musl's exact 60-byte termios layout, including all four flag words, `c_line`, `c_cc`, and input/output speeds; custom syscalls 70/71 use that same layout and no longer expose a second shortened format. `TCSANOW` and `TCSADRAIN` preserve unread input across `ICANON` transitions: completed lines and the current edited partial line become raw-readable in byte order, while unread raw bytes become immediately readable if the mode changes back, matching Linux EOF-push behavior. `TCSAFLUSH` discards unread input before applying the change. PTY writes synchronously enter the output queue, so there is no deferred device transmission to await. Implemented line discipline includes `VERASE`, `VKILL`, non-empty-line `VEOF`, ICRNL/INLCR/IGNCR, and ECHO/ECHOE/ECHOK/ECHONL. Remaining gaps: `VMIN`/`VTIME` values round-trip but raw-read timing is approximated, an empty canonical `VEOF` does not create a queued EOF event, a canonical `read()` can return bytes from multiple completed lines instead of stopping after one line, `VWERASE` is not implemented, and exposed input/output flags outside the listed subset do not all have data-path semantics. | | `ioctl()` | Full | 16 terminal ioctls: TCGETS/TCSETS/TCSETSW/TCSETSF (termios), TIOCGPTN (PTY number), TIOCSPTLCK (unlock PTY), TIOCGPGRP/TIOCSPGRP (foreground pgid), TIOCGWINSZ/TIOCSWINSZ (window size + SIGWINCH), TCSBRK/TCXONC/TCFLSH, TIOCGSID/TIOCSCTTY/TIOCNOTTY (session/controlling terminal). Generic: FIONREAD, FIONBIO, FIOCLEX/FIONCLEX, FIOASYNC. Works on CharDevice, PtyMaster, and PtySlave fds. | | `posix_openpt()` | Full | Opens `/dev/ptmx`, allocates PTY pair, returns master fd. | | `grantpt()` / `unlockpt()` | Full | `grantpt()` is a no-op (no permissions to set). `unlockpt()` clears the lock flag on the PTY pair. | @@ -428,7 +429,7 @@ Systematic audit of all subsystems against POSIX specifications. Gaps are catego | Gap | Subsystem | Description | |-----|-----------|-------------| -| **fork, posix_spawn, and SCM_RIGHTS recipients have independent ordinary-file OFD metadata** | fork / spawn / fd / sockets | POSIX requires inherited and transferred descriptors to refer to the same open file description. Kandelo retains exact global backings for pipes, sockets, PTYs, eventfd/timerfd/signalfd, memfd, and procfs snapshots. `SCM_RIGHTS` also preserves `OfdId`/`FileId` while queued and after receipt, so OFD and `flock()` ownership survives sender close and ends only on the true final reference. The per-process `OfdTable` still copies ordinary regular-file seek positions, status flags, and related metadata. At fork, non-forking spawn, retained legacy exec, and `SCM_RIGHTS` receipt, a directory copy drops the source's live process-local iterator and lazily reconstructs its own iterator at the snapshot next-record cookie; this prevents handle aliasing, duplicate restart from cookie zero, and double-close. Parent and child or sender and receiver still do not advance one authoritative ordinary-file or directory cursor after that boundary. The global-OFD redesign remains tracked in [future-improvements.md](future-improvements.md). | +| **fork, posix_spawn, and SCM_RIGHTS recipients have independent ordinary-file OFD metadata** | fork / spawn / fd / sockets | POSIX requires inherited and transferred descriptors to refer to the same open file description. Kandelo retains exact global backings for pipes, sockets inherited through process creation, PTYs, eventfd/timerfd/signalfd, memfd, and procfs snapshots. For supported non-socket descriptions, `SCM_RIGHTS` also preserves `OfdId`/`FileId` while queued and after receipt, so OFD and `flock()` ownership survives sender close and ends only on the true final reference; socket-descriptor transfer is explicitly rejected until one machine-wide socket backing can preserve the endpoint. The per-process `OfdTable` still copies ordinary regular-file seek positions, status flags, and related metadata. At fork, non-forking spawn, retained legacy exec, and `SCM_RIGHTS` receipt, a directory copy drops the source's live process-local iterator and lazily reconstructs its own iterator at the snapshot next-record cookie; this prevents handle aliasing, duplicate restart from cookie zero, and double-close. Parent and child or sender and receiver still do not advance one authoritative ordinary-file or directory cursor after that boundary. The global-OFD redesign remains tracked in [future-improvements.md](future-improvements.md). | ### High — Missing features that affect common programs @@ -671,8 +672,8 @@ These PHP needs are well-handled by the current kernel: returns and after `dlclose`. RTLD_NEXT lookup is not currently supported. - POSIX timers: `SIGEV_SIGNAL`, `SIGEV_NONE`, and `SIGEV_THREAD` timer creation, timer_settime, timer_gettime, overrun reporting, and deletion. Timer timing - remains host-scheduled at millisecond granularity, and direct wasm64 - signal-notification values are limited to the fixed-width `sival_int` wire. + remains host-scheduled at millisecond granularity. Direct wasm64 + notifications preserve the complete native `union sigval`. - System info: uname, sysconf, umask, getrlimit/setrlimit --- diff --git a/docs/profiling.md b/docs/profiling.md index a815e96c74..50ccce8ac0 100644 --- a/docs/profiling.md +++ b/docs/profiling.md @@ -11,7 +11,8 @@ The host runtime includes a built-in syscall profiler that measures every syscal Set the `WASM_POSIX_PROFILE` environment variable before starting the kernel: ```bash -WASM_POSIX_PROFILE=1 npx tsx examples/run-example.ts hello +scripts/dev-shell.sh env WASM_POSIX_PROFILE=1 \ + npx tsx examples/run-example.ts hello ``` ### Collecting Results @@ -79,8 +80,11 @@ The benchmark suite runs reproducible workloads on both Node.js and browser host Build the kernel and benchmark programs: ```bash -bash build.sh -scripts/build-programs.sh +# Required first whenever libc/musl-overlay or libc/glue changed. +scripts/dev-shell.sh bash scripts/build-musl.sh + +scripts/dev-shell.sh bash build.sh +scripts/dev-shell.sh bash scripts/build-programs.sh ``` Some suites require additional binaries: @@ -89,6 +93,7 @@ Some suites require additional binaries: |-------|----------| | `syscall-io` | Base sysroot + benchmark programs | | `process-lifecycle` | Base sysroot + benchmark programs | +| `spawn-scratch` | Benchmark programs; supplies both executables and uses an empty VFS | | `erlang-ring` | Pre-built Erlang binary | | `wordpress` | Pre-built PHP, nginx, WordPress | | `mariadb` | Pre-built MariaDB | @@ -101,19 +106,23 @@ the prerequisite before running it. ```bash # All suites on Node.js (3 rounds each, reports median) -npx tsx benchmarks/run.ts +scripts/dev-shell.sh npx tsx benchmarks/run.ts # All suites in the browser (via Playwright) -npx tsx benchmarks/run.ts --host=browser +scripts/dev-shell.sh npx tsx benchmarks/run.ts --host=browser # Single suite -npx tsx benchmarks/run.ts --suite=syscall-io +scripts/dev-shell.sh npx tsx benchmarks/run.ts --suite=syscall-io # More rounds for stability -npx tsx benchmarks/run.ts --rounds=5 +scripts/dev-shell.sh npx tsx benchmarks/run.ts --rounds=5 # Combine options -npx tsx benchmarks/run.ts --host=browser --suite=process-lifecycle --rounds=5 +scripts/dev-shell.sh npx tsx benchmarks/run.ts --host=browser --suite=process-lifecycle --rounds=5 + +# Focused host-to-kernel spawn scratch evidence +scripts/dev-shell.sh npx tsx benchmarks/run.ts --suite=spawn-scratch --rounds=3 +scripts/dev-shell.sh npx tsx benchmarks/run.ts --host=browser --suite=spawn-scratch --rounds=3 ``` Results are saved as JSON in `benchmarks/results/`. @@ -128,17 +137,27 @@ Resolver-selected paths are retained alongside the logical artifact names; browser VFS evidence reflects the public asset that the benchmark page selects first. Kernel fingerprints use the same policy-aware binary resolver as each host. Node rootfs evidence records which of the runtime's `rootfs.vfs` then -`programs/rootfs.vfs` fallback requests won, and is required only for the -syscall/process suites that boot that default image. Browser benchmarks do not -record or require the default rootfs because they boot generated empty or app -images. -Node static benchmark Wasm inputs are required only by the syscall or process -suite that consumes them. The browser benchmark page imports its seven micro -Wasm URLs at module load, so every runnable browser suite requires all seven; +`programs/rootfs.vfs` fallback requests won, and is required for the +`syscall-io` and established `process-lifecycle` suites that boot that default +image. The Node `spawn-scratch` suite supplies both of its executables and +explicitly uses an empty VFS, so it neither resolves nor records a rootfs. +Browser benchmarks do not record or require the default rootfs because they +boot generated empty or app images. +Node static benchmark Wasm inputs are required only by the selected suite that +consumes them. The browser benchmark page imports its seven micro Wasm URLs at +module load, so every runnable browser suite requires all seven; `exec-bench.wasm` remains Node-only. After printing the artifact report, the runner stops before workloads when a required, selected input is missing. +Results also fingerprint `host/src`. Node results fingerprint the compiled +worker bundle and reject a bundle whose recorded TypeScript input content does +not match the current source. `gitHead` and `gitRef` do not record worktree +dirtiness, so benchmark-relevant sources and rebuilt artifacts must match the +exact committed final head. Preserve and report unrelated user-owned worktree +or submodule changes rather than cleaning them to manufacture a globally clean +status. + ### Available Suites #### syscall-io @@ -175,8 +194,8 @@ artifact for each side's ABI epoch; an ABI 39 benchmark must not be reused with an ABI 40 kernel, or vice versa: ```bash -npx tsx benchmarks/run.ts --suite=syscall-io --rounds=3 -npx tsx benchmarks/run.ts --host=browser --suite=syscall-io --rounds=3 +scripts/dev-shell.sh npx tsx benchmarks/run.ts --suite=syscall-io --rounds=3 +scripts/dev-shell.sh npx tsx benchmarks/run.ts --host=browser --suite=syscall-io --rounds=3 ``` The complete Node and browser suite is still required before making a broad @@ -185,7 +204,9 @@ that the lock-manager migration is faster, slower, or neutral. #### process-lifecycle -Measures process management primitives. +Measures process management primitives against the default Node rootfs. The +suite keeps that established environment so a focused scratch measurement +cannot silently change the meaning of the hello/fork/exec/clone results. | Metric | Unit | What it measures | |--------|------|------------------| @@ -194,6 +215,32 @@ Measures process management primitives. | `exec_ms` | ms | Exec a new program | | `clone_ms` | ms | Create a thread via clone | +#### spawn-scratch + +Measures `posix_spawn` transport latency and the retained kernel-owned scratch +high-water mark. The workload supplies `spawn-bench.wasm` and `/bin/hello` +directly and uses an empty VFS, independently of the default-rootfs +`process-lifecycle` suite. Its ordinary spawn uses the fixed environment +`LANG=C`, `PATH=/bin`; the large cases use a deterministic 84,386-byte complete +wire blob. Every sample is accepted only after `waitpid` reports that the child +exited normally with status zero. + +| Metric | Unit | What it measures | +|--------|------|------------------| +| `spawn_ms` | ms | One ordinary spawn plus successful child wait | +| `spawn_large_wire_bytes` | bytes | Complete deterministic large-spawn wire size asserted by both host wrappers | +| `spawn_large_first_ms` | ms | First spawn with an 84,386-byte complete wire blob | +| `spawn_large_repeat_ms` | ms | Mean of five subsequent 84,386-byte spawns in the same kernel | +| `spawn_scratch_retained_bytes` | bytes | Rust-owned spawn scratch capacity retained after the workload | +| `spawn_scratch_kernel_bytes` | bytes | Kernel WebAssembly memory size after the workload | + +For this workload, post-run kernel memory is also peak kernel memory only +because WebAssembly memory grows monotonically and cannot shrink. Likewise, +post-run Rust `Vec` capacity is the retained scratch high-water mark because +the kernel keeps that reusable allocation and does not shrink it between +spawns. These are properties of the measured implementation, not a general +claim that a final sample can substitute for peak-memory instrumentation. + #### erlang-ring Runs the Erlang/OTP BEAM VM, spawning 1000 lightweight processes in a ring topology and passing a token around 100 times. @@ -207,11 +254,14 @@ Runs the Erlang/OTP BEAM VM, spawning 1000 lightweight processes in a ring topol | Component | Path | Build command | |-----------|------|---------------| -| BEAM VM | `packages/registry/erlang/bin/beam.wasm` | `bash packages/registry/erlang/build-erlang.sh` | +| BEAM VM | `packages/registry/erlang/bin/beam.wasm` | `scripts/dev-shell.sh bash packages/registry/erlang/build-erlang.sh` | | OTP libraries | `packages/registry/erlang/erlang-install/` | (built by same script) | | Ring program | `packages/registry/erlang/demo/ring.beam` | (included in repo) | -Build requirements: host Erlang/OTP 28 (`brew install erlang`), `wasm32posix-cc` SDK (`cd sdk && npm link`). +Build requirements: Erlang/OTP 28 and the `wasm32posix-cc` SDK supplied through +the declared development shell. If a required tool is absent there, report the +block and update the declared environment rather than substituting an +undeclared Homebrew binary for validation. #### wordpress @@ -237,7 +287,7 @@ benchmark processes, or worktrees. | Component | Path | Build command | |-----------|------|---------------| -| PHP CLI | `packages/registry/php/php-src/sapi/cli/php` | `bash packages/registry/php/build-php.sh` | +| PHP CLI | `packages/registry/php/php-src/sapi/cli/php` | `scripts/dev-shell.sh bash packages/registry/php/build-php.sh` | | WordPress | `packages/registry/wordpress/wordpress/wp-settings.php` | See below | | Router script | `packages/registry/wordpress/demo/router.php` | (included in repo) | @@ -270,12 +320,17 @@ Set `MARIADB_BENCH_VERBOSE=1` to forward mariadbd stdout/stderr to the shell (us | Component | Path | Build command | |-----------|------|---------------| -| MariaDB server (wasm32) | `packages/registry/mariadb/mariadb-install/bin/mariadbd.wasm` | `bash packages/registry/mariadb/build-mariadb.sh` | -| MariaDB server (wasm64) | `packages/registry/mariadb/mariadb-install-64/bin/mariadbd.wasm` | `bash packages/registry/mariadb/build-mariadb.sh --wasm64` | +| MariaDB server (wasm32) | `packages/registry/mariadb/mariadb-install/bin/mariadbd.wasm` | `scripts/dev-shell.sh bash packages/registry/mariadb/build-mariadb.sh` | +| MariaDB server (wasm64) | `packages/registry/mariadb/mariadb-install-64/bin/mariadbd.wasm` | `scripts/dev-shell.sh bash packages/registry/mariadb/build-mariadb.sh --wasm64` | | mysqltest client | `/bin/mysqltest.wasm` | (built by same script) | | System table SQL | `/share/mysql/mysql_system_tables*.sql` | (built by same script) | -Build requirements: `cmake` (`brew install cmake`), `wasm32posix-cc` / `wasm64posix-cc` SDK. The build is a two-phase cross-compilation (host build for code generators, then wasm cross-compile). The wasm64 build uses `-O1` instead of `-O2` to avoid an LLVM 21 wasm64 backend miscompilation in table-lookup sign-extension. +Build requirements: `cmake` and the `wasm32posix-cc` / `wasm64posix-cc` SDK +supplied through the declared development shell. The build is a two-phase +cross-compilation (host build for code generators, then wasm cross-compile). +The wasm64 build uses `-O1` instead of `-O2` to avoid an LLVM 21 wasm64 backend +miscompilation in table-lookup sign-extension. A missing declared tool is a +reported environment block, not permission to use an undeclared host binary. ### Building All Suite Prerequisites @@ -283,21 +338,21 @@ To run the complete benchmark suite, build all prerequisites in order: ```bash # 1. SDK toolchain (required by all application suites) -cd sdk && npm link && cd .. +scripts/dev-shell.sh bash -lc 'cd sdk && npm link' -# 2. Base benchmark programs (syscall-io, process-lifecycle) -scripts/build-programs.sh +# 2. Base benchmark programs (syscall-io, process-lifecycle, spawn-scratch) +scripts/dev-shell.sh bash scripts/build-programs.sh -# 3. Erlang/OTP (requires: brew install erlang) -bash packages/registry/erlang/build-erlang.sh +# 3. Erlang/OTP +scripts/dev-shell.sh bash packages/registry/erlang/build-erlang.sh # 4. PHP + WordPress (PHP build includes SQLite, zlib, OpenSSL, libxml2) -bash packages/registry/php/build-php.sh +scripts/dev-shell.sh bash packages/registry/php/build-php.sh # Download WordPress into packages/registry/wordpress/wordpress/ -# 5. MariaDB (requires: brew install cmake) -bash packages/registry/mariadb/build-mariadb.sh # wasm32 -bash packages/registry/mariadb/build-mariadb.sh --wasm64 # wasm64 (optional, for dual-arch comparison) +# 5. MariaDB +scripts/dev-shell.sh bash packages/registry/mariadb/build-mariadb.sh # wasm32 +scripts/dev-shell.sh bash packages/registry/mariadb/build-mariadb.sh --wasm64 # wasm64 (optional) ``` ### Running the Complete Suite for Performance Work @@ -310,17 +365,17 @@ micro-benchmarks miss. ```bash # Full comparison workflow: # 1. Run baseline on both hosts -npx tsx benchmarks/run.ts --rounds=3 -npx tsx benchmarks/run.ts --host=browser --rounds=3 +scripts/dev-shell.sh npx tsx benchmarks/run.ts --rounds=3 +scripts/dev-shell.sh npx tsx benchmarks/run.ts --host=browser --rounds=3 # 2. Apply changes # 3. Run again on both hosts -npx tsx benchmarks/run.ts --rounds=3 -npx tsx benchmarks/run.ts --host=browser --rounds=3 +scripts/dev-shell.sh npx tsx benchmarks/run.ts --rounds=3 +scripts/dev-shell.sh npx tsx benchmarks/run.ts --host=browser --rounds=3 # 4. Compare -npx tsx benchmarks/compare.ts benchmarks/results/.json benchmarks/results/.json +scripts/dev-shell.sh npx tsx benchmarks/compare.ts benchmarks/results/.json benchmarks/results/.json ``` When a required binary is missing, the runner prints the artifact report and @@ -332,7 +387,7 @@ before drawing conclusions about performance impact. Use the comparison tool to diff two benchmark runs: ```bash -npx tsx benchmarks/compare.ts benchmarks/results/before.json benchmarks/results/after.json +scripts/dev-shell.sh npx tsx benchmarks/compare.ts benchmarks/results/before.json benchmarks/results/after.json ``` Output is a markdown table with percentage change for each metric. Regressions (>5% worse) are bolded: diff --git a/examples/kernel_scratch_browser_test.c b/examples/kernel_scratch_browser_test.c new file mode 100644 index 0000000000..afe8383fdd --- /dev/null +++ b/examples/kernel_scratch_browser_test.c @@ -0,0 +1,164 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static void fail(const char *step) +{ + fprintf(stderr, "KERNEL_SCRATCH_BROWSER_FAIL: %s: %s\n", + step, strerror(errno)); + exit(1); +} + +static size_t parse_size(const char *text, const char *field) +{ + char *end = NULL; + errno = 0; + unsigned long long value = strtoull(text, &end, 10); + if (errno != 0 || end == text || *end != '\0' || + value == 0 || value > SIZE_MAX) { + errno = EINVAL; + fail(field); + } + return (size_t)value; +} + +static void write_all(int fd, const unsigned char *bytes, size_t length, + const char *step) +{ + size_t offset = 0; + while (offset < length) { + ssize_t written = write(fd, bytes + offset, length - offset); + if (written < 0) + fail(step); + if (written == 0) { + errno = EIO; + fail(step); + } + offset += (size_t)written; + } +} + +static int test_readv(size_t iovec_count, size_t bytes_per_iovec) +{ + if (iovec_count > IOV_MAX || + bytes_per_iovec > SIZE_MAX / iovec_count) { + errno = EINVAL; + fail("readv dimensions"); + } + size_t total = iovec_count * bytes_per_iovec; + unsigned char *expected = malloc(total); + unsigned char *actual = calloc(total, 1); + struct iovec *iovecs = calloc(iovec_count, sizeof(*iovecs)); + if (expected == NULL || actual == NULL || iovecs == NULL) + fail("readv allocation"); + + for (size_t index = 0; index < total; index++) + expected[index] = (unsigned char)(index * 131u + 17u); + for (size_t index = 0; index < iovec_count; index++) { + iovecs[index].iov_base = actual + index * bytes_per_iovec; + iovecs[index].iov_len = bytes_per_iovec; + } + + FILE *file = tmpfile(); + if (file == NULL) + fail("tmpfile"); + int fd = fileno(file); + write_all(fd, expected, total, "seed readv file"); + if (lseek(fd, 0, SEEK_SET) < 0) + fail("rewind readv file"); + + ssize_t amount = readv(fd, iovecs, (int)iovec_count); + if (amount < 0) + fail("readv"); + if ((size_t)amount != total || memcmp(actual, expected, total) != 0) { + errno = EIO; + fail("readv result"); + } + + printf("KERNEL_SCRATCH_READV_PASS iovecs=%zu bytes=%zu\n", + iovec_count, total); + fclose(file); + free(iovecs); + free(actual); + free(expected); + return 0; +} + +static int test_pty(size_t expected_length, unsigned char expected_byte) +{ + struct termios attributes; + if (tcgetattr(STDIN_FILENO, &attributes) < 0) + fail("tcgetattr"); + attributes.c_lflag &= ~(ICANON | ECHO | ECHONL); + attributes.c_cc[VMIN] = 1; + attributes.c_cc[VTIME] = 0; + if (tcsetattr(STDIN_FILENO, TCSANOW, &attributes) < 0) + fail("tcsetattr"); + + /* + * The browser harness waits for this marker before calling ptyWrite(). + * That ordering proves the guest has installed raw mode, so bytes cannot + * be consumed by the prior canonical line discipline during worker races. + */ + static const unsigned char ready[] = "KERNEL_SCRATCH_PTY_READY\n"; + write_all(STDOUT_FILENO, ready, sizeof(ready) - 1, "PTY ready marker"); + + unsigned char *input = malloc(expected_length); + if (input == NULL) + fail("PTY input allocation"); + size_t received = 0; + while (received < expected_length) { + ssize_t amount = read(STDIN_FILENO, input + received, + expected_length - received); + if (amount < 0) + fail("PTY input read"); + if (amount == 0) { + errno = EIO; + fail("PTY input EOF"); + } + received += (size_t)amount; + } + for (size_t index = 0; index < expected_length; index++) { + if (input[index] != expected_byte) { + errno = EIO; + fail("PTY input contents"); + } + } + + printf("KERNEL_SCRATCH_PTY_PASS bytes=%zu\n", received); + free(input); + return 0; +} + +int main(int argc, char **argv) +{ + if (argc == 4 && strcmp(argv[1], "readv") == 0) { + return test_readv( + parse_size(argv[2], "readv iovec count"), + parse_size(argv[3], "readv bytes per iovec")); + } + if (argc == 4 && strcmp(argv[1], "pty") == 0) { + size_t byte_value = parse_size(argv[3], "PTY byte value"); + if (byte_value > UCHAR_MAX) { + errno = EINVAL; + fail("PTY byte value"); + } + return test_pty( + parse_size(argv[2], "PTY expected length"), + (unsigned char)byte_value); + } + + fprintf(stderr, + "usage: %s readv IOVEC_COUNT BYTES_PER_IOVEC | " + "pty EXPECTED_LENGTH EXPECTED_BYTE\n", + argv[0]); + return 2; +} diff --git a/examples/process_native_layout_test.c b/examples/process_native_layout_test.c new file mode 100644 index 0000000000..303c262658 --- /dev/null +++ b/examples/process_native_layout_test.c @@ -0,0 +1,629 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static unsigned char alternate_stack[SIGSTKSZ]; +static volatile sig_atomic_t queued_handler_seen; +static volatile sig_atomic_t queued_handler_valid; +static uintptr_t queued_handler_expected_value; +static pid_t queued_handler_expected_pid; + +static void queued_siginfo_handler(int signo, siginfo_t *info, void *context) +{ + uintptr_t observed_value; + + (void)context; + if (info == NULL) { + queued_handler_valid = 0; + queued_handler_seen = 1; + return; + } +#if UINTPTR_MAX > UINT32_MAX + observed_value = (uintptr_t)info->si_value.sival_ptr; +#else + observed_value = (uintptr_t)(uint32_t)info->si_value.sival_int; +#endif + queued_handler_valid = + signo == SIGUSR2 && + info->si_signo == SIGUSR2 && + info->si_code == SI_QUEUE && + info->si_pid == queued_handler_expected_pid && + observed_value == queued_handler_expected_value; + queued_handler_seen = 1; +} + +static int test_sigaltstack_layout(void) +{ + stack_t requested = { + .ss_sp = alternate_stack, + .ss_flags = 0, + .ss_size = sizeof(alternate_stack), + }; + stack_t previous; + stack_t observed; + stack_t disabled = { + .ss_sp = NULL, + .ss_flags = SS_DISABLE, + .ss_size = 0, + }; + + if (sigaltstack(&requested, &previous) < 0) { + perror("sigaltstack set"); + return 1; + } + if (sigaltstack(NULL, &observed) < 0) { + perror("sigaltstack get"); + return 1; + } + if (observed.ss_sp != requested.ss_sp || + observed.ss_size != requested.ss_size || + observed.ss_flags != requested.ss_flags) { + fprintf(stderr, "sigaltstack native record did not round trip\n"); + return 1; + } + if (sigaltstack(&disabled, NULL) < 0) { + perror("sigaltstack disable"); + return 1; + } + +#if UINTPTR_MAX > UINT32_MAX + /* + * WHY: this state is queried and disabled before any signal can use it. + * The deliberately non-dereferenced address proves that the wasm64 + * host/kernel/glue round trip does not narrow a caller-native stack pointer + * merely because the kernel itself is wasm32. + */ + requested.ss_sp = (void *)((uintptr_t)UINT32_MAX + 0x2001u); + requested.ss_size = 4096; + if (sigaltstack(&requested, NULL) < 0) { + perror("sigaltstack high wasm64 set"); + return 1; + } + if (sigaltstack(NULL, &observed) < 0) { + perror("sigaltstack high wasm64 get"); + return 1; + } + if (observed.ss_sp != requested.ss_sp || + observed.ss_size != requested.ss_size || + observed.ss_flags != requested.ss_flags) { + fprintf(stderr, "sigaltstack high wasm64 pointer was narrowed\n"); + return 1; + } + if (sigaltstack(&disabled, NULL) < 0) { + perror("sigaltstack high wasm64 disable"); + return 1; + } +#endif + return 0; +} + +static int test_itimerval_layout(void) +{ + struct itimerval requested; + struct itimerval previous; + struct itimerval observed; + struct itimerval disabled; + + memset(&requested, 0, sizeof(requested)); + requested.it_interval.tv_sec = 2; + requested.it_value.tv_sec = 5; + if (setitimer(ITIMER_REAL, &requested, &previous) < 0) { + perror("setitimer"); + return 1; + } + if (getitimer(ITIMER_REAL, &observed) < 0) { + perror("getitimer"); + return 1; + } + if (observed.it_interval.tv_sec != 2 || + observed.it_interval.tv_usec != 0 || + observed.it_value.tv_sec < 0 || + observed.it_value.tv_sec > 5) { + fprintf(stderr, "itimerval native record did not round trip\n"); + return 1; + } + memset(&disabled, 0, sizeof(disabled)); + if (setitimer(ITIMER_REAL, &disabled, NULL) < 0) { + perror("setitimer disable"); + return 1; + } + return 0; +} + +static int test_siginfo_layout(void) +{ + sigset_t set; + sigset_t previous; + union sigval queued_value; + struct timespec timeout = { .tv_sec = 1, .tv_nsec = 0 }; + siginfo_t info; + int received; + int failed = 0; + + memset(&queued_value, 0, sizeof(queued_value)); +#if UINTPTR_MAX > UINT32_MAX + queued_value.sival_ptr = (void *)(uintptr_t)0x012345678abcdefULL; +#else + queued_value.sival_int = 0x12345678; +#endif + sigemptyset(&set); + sigaddset(&set, SIGUSR1); + if (sigprocmask(SIG_BLOCK, &set, &previous) < 0) { + perror("sigprocmask block"); + return 1; + } + if (sigqueue(getpid(), SIGUSR1, queued_value) < 0) { + perror("sigqueue"); + failed = 1; + goto restore_mask; + } + memset(&info, 0xa5, sizeof(info)); + received = sigtimedwait(&set, &info, &timeout); + if (received < 0) { + perror("sigtimedwait"); + failed = 1; + goto restore_mask; + } + if (received != SIGUSR1 || info.si_signo != SIGUSR1 || + info.si_code != SI_QUEUE || info.si_pid != getpid()) { + fprintf(stderr, "siginfo_t native output has wrong fields\n"); + failed = 1; + } +#if UINTPTR_MAX > UINT32_MAX + if (info.si_value.sival_ptr != queued_value.sival_ptr) { + fprintf(stderr, "wasm64 sigqueue pointer value was narrowed\n"); + failed = 1; + } +#else + if (info.si_value.sival_int != queued_value.sival_int) { + fprintf(stderr, "wasm32 sigqueue integer value changed\n"); + failed = 1; + } +#endif + +restore_mask: + if (sigprocmask(SIG_SETMASK, &previous, NULL) < 0) { + perror("sigprocmask restore"); + return 1; + } + return failed; +} + +static int test_siginfo_handler_delivery_layout(void) +{ + struct sigaction action; + struct sigaction previous_action; + sigset_t set; + sigset_t previous_mask; + union sigval queued_value; + int failed = 0; + + memset(&action, 0, sizeof(action)); + action.sa_sigaction = queued_siginfo_handler; + action.sa_flags = SA_SIGINFO; + sigemptyset(&action.sa_mask); + if (sigaction(SIGUSR2, &action, &previous_action) < 0) { + perror("siginfo handler sigaction"); + return 1; + } + + sigemptyset(&set); + sigaddset(&set, SIGUSR2); + if (sigprocmask(SIG_UNBLOCK, &set, &previous_mask) < 0) { + perror("siginfo handler unblock"); + (void)sigaction(SIGUSR2, &previous_action, NULL); + return 1; + } + + memset(&queued_value, 0, sizeof(queued_value)); +#if UINTPTR_MAX > UINT32_MAX + queued_handler_expected_value = (uintptr_t)0x012345678abcdefULL; + queued_value.sival_ptr = (void *)queued_handler_expected_value; +#else + queued_handler_expected_value = 0x456789abU; + queued_value.sival_int = (int)queued_handler_expected_value; +#endif + queued_handler_expected_pid = getpid(); + queued_handler_seen = 0; + queued_handler_valid = 0; + if (sigqueue(queued_handler_expected_pid, SIGUSR2, queued_value) < 0) { + perror("siginfo handler sigqueue"); + failed = 1; + } else if (!queued_handler_seen || !queued_handler_valid) { + fprintf(stderr, + "SA_SIGINFO delivery did not preserve the native siginfo record\n"); + failed = 1; + } + + if (sigprocmask(SIG_SETMASK, &previous_mask, NULL) < 0) { + perror("siginfo handler restore mask"); + failed = 1; + } + if (sigaction(SIGUSR2, &previous_action, NULL) < 0) { + perror("siginfo handler restore action"); + failed = 1; + } + return failed; +} + +static int test_posix_timer_sigval_layout(void) +{ + sigset_t set; + sigset_t previous; + struct sigevent event; + struct itimerspec requested; + struct timespec timeout = { .tv_sec = 2, .tv_nsec = 0 }; + siginfo_t info; + timer_t timer; + int received; + int failed = 0; + + memset(&event, 0, sizeof(event)); + event.sigev_notify = SIGEV_SIGNAL; + event.sigev_signo = SIGUSR2; +#if UINTPTR_MAX > UINT32_MAX + event.sigev_value.sival_ptr = + (void *)(uintptr_t)0x012345678abcdefULL; +#else + event.sigev_value.sival_int = 0x23456789; +#endif + sigemptyset(&set); + sigaddset(&set, SIGUSR2); + if (sigprocmask(SIG_BLOCK, &set, &previous) < 0) { + perror("timer sigprocmask block"); + return 1; + } + if (timer_create(CLOCK_MONOTONIC, &event, &timer) < 0) { + perror("timer_create sigval"); + failed = 1; + goto restore_mask; + } + memset(&requested, 0, sizeof(requested)); + requested.it_value.tv_nsec = 1000000; + if (timer_settime(timer, 0, &requested, NULL) < 0) { + perror("timer_settime sigval"); + failed = 1; + goto delete_timer; + } + memset(&info, 0xa5, sizeof(info)); + received = sigtimedwait(&set, &info, &timeout); + if (received < 0) { + perror("timer sigtimedwait"); + failed = 1; + goto delete_timer; + } + if (received != SIGUSR2 || info.si_signo != SIGUSR2 || + info.si_code != SI_TIMER) { + fprintf(stderr, "timer siginfo_t has wrong fields\n"); + failed = 1; + } +#if UINTPTR_MAX > UINT32_MAX + if (info.si_value.sival_ptr != event.sigev_value.sival_ptr) { + fprintf(stderr, "wasm64 timer pointer value was narrowed\n"); + failed = 1; + } +#else + if (info.si_value.sival_int != event.sigev_value.sival_int) { + fprintf(stderr, "wasm32 timer integer value changed\n"); + failed = 1; + } +#endif + +delete_timer: + if (timer_delete(timer) < 0) { + perror("timer_delete sigval"); + failed = 1; + } +restore_mask: + if (sigprocmask(SIG_SETMASK, &previous, NULL) < 0) { + perror("timer sigprocmask restore"); + return 1; + } + return failed; +} + +static int test_mqueue_layouts(void) +{ + char name[64]; + struct mq_attr requested; + struct mq_attr observed; + struct mq_attr new_attr; + struct mq_attr old_attr; + struct sigevent event; + char empty_message_buffer[64]; + unsigned empty_message_priority = 0; + ssize_t empty_message_size; + mqd_t queue; + + snprintf(name, sizeof(name), "/kandelo-native-layout-%ld", (long)getpid()); + (void)mq_unlink(name); + memset(&requested, 0, sizeof(requested)); + requested.mq_maxmsg = 3; + requested.mq_msgsize = 64; + queue = mq_open(name, O_CREAT | O_EXCL | O_RDWR, 0600, &requested); + if (queue < 0) { + perror("mq_open"); + return 1; + } + + memset(&observed, 0xa5, sizeof(observed)); + if (mq_getattr(queue, &observed) < 0) { + perror("mq_getattr"); + goto fail; + } + if (observed.mq_maxmsg != 3 || observed.mq_msgsize != 64 || + observed.mq_curmsgs != 0) { + fprintf(stderr, "mq_attr native output has wrong fields\n"); + goto fail; + } + + memset(&new_attr, 0, sizeof(new_attr)); + new_attr.mq_flags = O_NONBLOCK; + memset(&old_attr, 0xa5, sizeof(old_attr)); + if (mq_setattr(queue, &new_attr, &old_attr) < 0) { + perror("mq_setattr"); + goto fail; + } + if ((old_attr.mq_flags & O_NONBLOCK) != 0 || + old_attr.mq_maxmsg != 3 || old_attr.mq_msgsize != 64) { + fprintf(stderr, "mq_setattr native old record has wrong fields\n"); + goto fail; + } + + memset(&event, 0, sizeof(event)); + event.sigev_notify = SIGEV_NONE; + if (mq_notify(queue, &event) < 0) { + perror("mq_notify register"); + goto fail; + } + if (mq_notify(queue, NULL) < 0) { + perror("mq_notify unregister"); + goto fail; + } + memset(&event, 0, sizeof(event)); + event.sigev_notify = SIGEV_SIGNAL; + event.sigev_signo = 0; + errno = 0; + if (mq_notify(queue, &event) != -1 || errno != EINVAL) { + fprintf(stderr, "mq_notify accepted signal zero\n"); + goto fail; + } + event.sigev_signo = -1; + errno = 0; + if (mq_notify(queue, &event) != -1 || errno != EINVAL) { + fprintf(stderr, "mq_notify accepted a negative signal number\n"); + goto fail; + } + + /* + * WHY: a zero-length POSIX message is valid. The host stages no bytes and + * substitutes an allocator-owned empty address without inspecting the + * ignored caller pointer. This covers that contract on both caller widths. + */ + if (mq_send(queue, "", 0, 7) < 0) { + perror("mq_send zero length"); + goto fail; + } + memset(empty_message_buffer, 0xa5, sizeof(empty_message_buffer)); + empty_message_size = mq_receive(queue, empty_message_buffer, + sizeof(empty_message_buffer), &empty_message_priority); + if (empty_message_size < 0) { + perror("mq_receive zero length"); + goto fail; + } + if (empty_message_size != 0 || empty_message_priority != 7 || + empty_message_buffer[0] != (char)0xa5) { + fprintf(stderr, "zero-length mqueue message did not round trip\n"); + goto fail; + } + + if (mq_close(queue) < 0) { + perror("mq_close"); + (void)mq_unlink(name); + return 1; + } + if (mq_unlink(name) < 0) { + perror("mq_unlink"); + return 1; + } + return 0; + +fail: + (void)mq_close(queue); + (void)mq_unlink(name); + return 1; +} + +static int test_mqueue_sigval_layout(void) +{ + char name[64]; + struct mq_attr attributes; + struct sigevent event; + struct timespec timeout = { .tv_sec = 2, .tv_nsec = 0 }; + sigset_t set; + sigset_t previous; + siginfo_t info; + mqd_t queue = (mqd_t)-1; + int received; + int failed = 0; + + snprintf(name, sizeof(name), "/kandelo-mq-sigval-%ld", (long)getpid()); + (void)mq_unlink(name); + sigemptyset(&set); + sigaddset(&set, SIGUSR1); + if (sigprocmask(SIG_BLOCK, &set, &previous) < 0) { + perror("mq sigprocmask block"); + return 1; + } + + memset(&attributes, 0, sizeof(attributes)); + attributes.mq_maxmsg = 2; + attributes.mq_msgsize = 8; + queue = mq_open(name, O_CREAT | O_EXCL | O_RDWR, 0600, &attributes); + if (queue < 0) { + perror("mq sigval open"); + failed = 1; + goto restore_mask; + } + + memset(&event, 0, sizeof(event)); + event.sigev_notify = SIGEV_SIGNAL; + event.sigev_signo = SIGUSR1; +#if UINTPTR_MAX > UINT32_MAX + event.sigev_value.sival_ptr = + (void *)(uintptr_t)0x012345678abcdefULL; +#else + event.sigev_value.sival_int = 0x3456789a; +#endif + if (mq_notify(queue, &event) < 0) { + perror("mq sigval notify"); + failed = 1; + goto close_queue; + } + if (mq_send(queue, "x", 1, 0) < 0) { + perror("mq sigval send"); + failed = 1; + goto close_queue; + } + + memset(&info, 0xa5, sizeof(info)); + received = sigtimedwait(&set, &info, &timeout); + if (received < 0) { + perror("mq sigtimedwait"); + failed = 1; + goto close_queue; + } + if (received != SIGUSR1 || info.si_signo != SIGUSR1 || + info.si_code != SI_MESGQ || info.si_pid != getpid()) { + fprintf(stderr, "mqueue siginfo_t has wrong fields\n"); + failed = 1; + } +#if UINTPTR_MAX > UINT32_MAX + if (info.si_value.sival_ptr != event.sigev_value.sival_ptr) { + fprintf(stderr, "wasm64 mqueue pointer value was narrowed\n"); + failed = 1; + } +#else + if (info.si_value.sival_int != event.sigev_value.sival_int) { + fprintf(stderr, "wasm32 mqueue integer value changed\n"); + failed = 1; + } +#endif + +close_queue: + if (mq_close(queue) < 0) { + perror("mq sigval close"); + failed = 1; + } + if (mq_unlink(name) < 0) { + perror("mq sigval unlink"); + failed = 1; + } +restore_mask: + if (sigprocmask(SIG_SETMASK, &previous, NULL) < 0) { + perror("mq sigprocmask restore"); + return 1; + } + return failed; +} + +static int statfs_record_is_valid(const struct statfs *info) +{ + size_t index; + + if (info->f_bsize == 0 || info->f_namelen == 0) + return 0; + for (index = 0; index < 4; index++) { + if (info->f_spare[index] != 0) + return 0; + } + return 1; +} + +static int test_statfs_layout(void) +{ + struct statfs path_info; + struct statfs fd_info; + int fd; + + memset(&path_info, 0xa5, sizeof(path_info)); + if (statfs("/dev", &path_info) < 0) { + perror("statfs"); + return 1; + } + if (!statfs_record_is_valid(&path_info)) { + fprintf(stderr, "statfs native output has invalid fields\n"); + return 1; + } + + fd = open("/dev/null", O_RDONLY); + if (fd < 0) { + perror("open /dev/null"); + return 1; + } + memset(&fd_info, 0xa5, sizeof(fd_info)); + if (fstatfs(fd, &fd_info) < 0) { + perror("fstatfs"); + close(fd); + return 1; + } + close(fd); + if (!statfs_record_is_valid(&fd_info)) { + fprintf(stderr, "fstatfs native output has invalid fields\n"); + return 1; + } + return 0; +} + +static int test_sysinfo_layout(void) +{ + struct sysinfo info; + size_t index; + + memset(&info, 0xa5, sizeof(info)); + if (sysinfo(&info) < 0) { + perror("sysinfo"); + return 1; + } + if (info.uptime != 1 || info.totalram != 512UL * 1024 * 1024 || + info.freeram != 256UL * 1024 * 1024 || info.procs != 1 || + info.mem_unit != 1) { + fprintf(stderr, "sysinfo native output has invalid fields\n"); + return 1; + } + for (index = 0; index < sizeof(info.__reserved); index++) { + if (info.__reserved[index] != 0) { + fprintf(stderr, "sysinfo reserved bytes were not zeroed\n"); + return 1; + } + } + return 0; +} + +int main(void) +{ + if (test_sigaltstack_layout() || + test_itimerval_layout() || + test_siginfo_layout() || + test_siginfo_handler_delivery_layout() || + test_posix_timer_sigval_layout() || + test_mqueue_layouts() || + test_mqueue_sigval_layout() || + test_statfs_layout() || + test_sysinfo_layout()) + return 1; + + puts("PROCESS NATIVE LAYOUTS PASSED"); + return 0; +} diff --git a/examples/sysv_ipc_test.c b/examples/sysv_ipc_test.c index e2f9dade2f..146ac74d3e 100644 --- a/examples/sysv_ipc_test.c +++ b/examples/sysv_ipc_test.c @@ -9,6 +9,7 @@ #include #include #include +#include /* Use a custom struct for message passing (system msgbuf has mtext[1]) */ struct my_msgbuf { @@ -25,6 +26,44 @@ int test_msgq(void) { } printf("msgget: qid=%d\n", qid); + /* + * Exercise the complete guest -> host scratch -> Rust IPC_SET path. + * The target-width msqid_ds is 96 bytes on wasm32 and 120 bytes on + * wasm64, so running this fixture for both architectures catches layout + * or copy-direction mistakes that IPC_STAT alone cannot. + */ + struct msqid_ds queue_info; + if (msgctl(qid, IPC_STAT, &queue_info) != 0) { + perror("msgctl IPC_STAT before IPC_SET"); + msgctl(qid, IPC_RMID, NULL); + return 1; + } + queue_info.msg_perm.uid = geteuid(); + queue_info.msg_perm.gid = getegid(); + queue_info.msg_perm.mode = + (queue_info.msg_perm.mode & ~0777u) | 0600u; + queue_info.msg_qbytes = 4096; + if (msgctl(qid, IPC_SET, &queue_info) != 0) { + perror("msgctl IPC_SET"); + msgctl(qid, IPC_RMID, NULL); + return 1; + } + memset(&queue_info, 0, sizeof(queue_info)); + if (msgctl(qid, IPC_STAT, &queue_info) != 0) { + perror("msgctl IPC_STAT after IPC_SET"); + msgctl(qid, IPC_RMID, NULL); + return 1; + } + if ((queue_info.msg_perm.mode & 0777u) != 0600u || + queue_info.msg_qbytes != 4096) { + printf("FAIL: msgctl IPC_SET round trip mode=%o qbytes=%lu\n", + (unsigned)(queue_info.msg_perm.mode & 0777u), + (unsigned long)queue_info.msg_qbytes); + msgctl(qid, IPC_RMID, NULL); + return 1; + } + printf("msgctl IPC_SET: mode=0600 qbytes=4096\n"); + /* Send a message */ struct my_msgbuf msg; msg.mtype = 1; @@ -174,6 +213,42 @@ int test_shm(void) { } printf("shmget: shmid=%d\n", shmid); + /* + * Like msgctl above, this proves IPC_SET consumes the caller's complete + * target-width shmid_ds (88 bytes on wasm32, 112 bytes on wasm64) before + * IPC_STAT serializes the updated values back out. + */ + struct shmid_ds segment_info; + if (shmctl(shmid, IPC_STAT, &segment_info) != 0) { + perror("shmctl IPC_STAT before IPC_SET"); + shmctl(shmid, IPC_RMID, NULL); + return 1; + } + segment_info.shm_perm.uid = geteuid(); + segment_info.shm_perm.gid = getegid(); + segment_info.shm_perm.mode = + (segment_info.shm_perm.mode & ~0777u) | 0600u; + if (shmctl(shmid, IPC_SET, &segment_info) != 0) { + perror("shmctl IPC_SET"); + shmctl(shmid, IPC_RMID, NULL); + return 1; + } + memset(&segment_info, 0, sizeof(segment_info)); + if (shmctl(shmid, IPC_STAT, &segment_info) != 0) { + perror("shmctl IPC_STAT after IPC_SET"); + shmctl(shmid, IPC_RMID, NULL); + return 1; + } + if ((segment_info.shm_perm.mode & 0777u) != 0600u || + segment_info.shm_segsz != 4096) { + printf("FAIL: shmctl IPC_SET round trip mode=%o segsz=%lu\n", + (unsigned)(segment_info.shm_perm.mode & 0777u), + (unsigned long)segment_info.shm_segsz); + shmctl(shmid, IPC_RMID, NULL); + return 1; + } + printf("shmctl IPC_SET: mode=0600 segsz=4096\n"); + /* Attach */ void *ptr = shmat(shmid, NULL, 0); if (ptr == (void *)-1) { diff --git a/examples/terminal_attributes_api_test.c b/examples/terminal_attributes_api_test.c index 2488919681..fd9d5c8283 100644 --- a/examples/terminal_attributes_api_test.c +++ b/examples/terminal_attributes_api_test.c @@ -19,10 +19,13 @@ struct pty_pair { enum { KANDELO_SYS_TCGETATTR = 70, KANDELO_SYS_TCSETATTR = 71, - KANDELO_LEGACY_TERMIOS_SIZE = 48, - KANDELO_LEGACY_BUFFER_SIZE = 256, + KANDELO_TERMIOS_SIZE = 60, + KANDELO_CHANNEL_BUFFER_SIZE = KANDELO_TERMIOS_SIZE + 1, }; +_Static_assert(sizeof(struct termios) == KANDELO_TERMIOS_SIZE, + "Kandelo expects musl's 60-byte struct termios"); + static void fail(const char *what) { fprintf(stderr, "TERMINAL_ATTRIBUTES_API_FAIL: %s: %s\n", what, @@ -104,28 +107,29 @@ static void expect_termios(const struct termios *actual, check(actual->__c_ospeed == expected->__c_ospeed, what); } -static int legacy_tcgetattr(int fd, unsigned char buffer[KANDELO_LEGACY_BUFFER_SIZE]) +static int custom_tcgetattr(int fd, + unsigned char buffer[KANDELO_CHANNEL_BUFFER_SIZE]) { - memset(buffer, 0xa5, KANDELO_LEGACY_BUFFER_SIZE); + memset(buffer, 0xa5, KANDELO_CHANNEL_BUFFER_SIZE); return (int)syscall(KANDELO_SYS_TCGETATTR, (long)fd, (long)(uintptr_t)buffer, 0L, 0L, 0L, 0L); } -static int legacy_tcsetattr(int fd, int action, - unsigned char buffer[KANDELO_LEGACY_BUFFER_SIZE]) +static int custom_tcsetattr(int fd, int action, + unsigned char buffer[KANDELO_CHANNEL_BUFFER_SIZE]) { return (int)syscall(KANDELO_SYS_TCSETATTR, (long)fd, (long)action, (long)(uintptr_t)buffer, 0L, 0L, 0L); } -static uint32_t legacy_lflag(const unsigned char *buffer) +static uint32_t custom_lflag(const unsigned char *buffer) { uint32_t value; memcpy(&value, buffer + 12, sizeof(value)); return value; } -static void legacy_set_lflag(unsigned char *buffer, uint32_t value) +static void custom_set_lflag(unsigned char *buffer, uint32_t value) { memcpy(buffer + 12, &value, sizeof(value)); } @@ -346,19 +350,21 @@ static void test_standard_termios_roundtrip(struct pty_pair pair) } } -static void test_legacy_channel(struct pty_pair pair) +static void test_custom_channel(struct pty_pair pair) { const int actions[] = { TCSANOW, TCSADRAIN, TCSAFLUSH }; - unsigned char original[KANDELO_LEGACY_BUFFER_SIZE]; - unsigned char changed[KANDELO_LEGACY_BUFFER_SIZE]; - unsigned char observed[KANDELO_LEGACY_BUFFER_SIZE]; + unsigned char original[KANDELO_CHANNEL_BUFFER_SIZE]; + unsigned char changed[KANDELO_CHANNEL_BUFFER_SIZE]; + unsigned char observed[KANDELO_CHANNEL_BUFFER_SIZE]; size_t action_index; size_t endpoint_index; size_t i; reset_pair(pair); - if (legacy_tcgetattr(pair.master, original) < 0) - fail("legacy tcgetattr roundtrip source"); + if (custom_tcgetattr(pair.master, original) < 0) + fail("custom tcgetattr roundtrip source"); + check(original[KANDELO_TERMIOS_SIZE] == 0xa5, + "custom tcgetattr exact-capacity canary"); memcpy(changed, original, sizeof(changed)); { const uint32_t flags[] = { @@ -369,51 +375,58 @@ static void test_legacy_channel(struct pty_pair pair) }; memcpy(changed, flags, sizeof(flags)); } - for (i = 0; i < NCCS; i++) changed[16 + i] = (unsigned char)(0x40 + i); - if (legacy_tcsetattr(pair.slave, TCSANOW, changed) < 0) - fail("legacy tcsetattr roundtrip"); - if (legacy_tcgetattr(pair.master, observed) < 0) - fail("legacy tcgetattr roundtrip result"); - check(memcmp(observed, changed, KANDELO_LEGACY_TERMIOS_SIZE) == 0, - "legacy 48-byte roundtrip"); - if (legacy_tcsetattr(pair.master, TCSANOW, original) < 0) - fail("restore legacy termios"); + changed[16] = 7; + for (i = 0; i < NCCS; i++) changed[17 + i] = (unsigned char)(0x40 + i); + { + const uint32_t speeds[] = { B57600, B115200 }; + memcpy(changed + 52, speeds, sizeof(speeds)); + } + if (custom_tcsetattr(pair.slave, TCSANOW, changed) < 0) + fail("custom tcsetattr roundtrip"); + if (custom_tcgetattr(pair.master, observed) < 0) + fail("custom tcgetattr roundtrip result"); + check(memcmp(observed, changed, KANDELO_TERMIOS_SIZE) == 0, + "custom 60-byte roundtrip"); + check(observed[KANDELO_TERMIOS_SIZE] == 0xa5, + "custom tcgetattr capacity+1 canary"); + if (custom_tcsetattr(pair.master, TCSANOW, original) < 0) + fail("restore custom termios"); for (action_index = 0; action_index < sizeof(actions) / sizeof(actions[0]); action_index++) { int action = actions[action_index]; reset_pair(pair); - write_all(pair.master, "pending", 7, "seed legacy pending input"); - if (legacy_tcgetattr(pair.slave, changed) < 0) - fail("legacy action tcgetattr"); - legacy_set_lflag(changed, legacy_lflag(changed) & ~ICANON); - if (legacy_tcsetattr(pair.slave, action, changed) < 0) - fail("legacy action tcsetattr"); + write_all(pair.master, "pending", 7, "seed custom pending input"); + if (custom_tcgetattr(pair.slave, changed) < 0) + fail("custom action tcgetattr"); + custom_set_lflag(changed, custom_lflag(changed) & ~ICANON); + if (custom_tcsetattr(pair.slave, action, changed) < 0) + fail("custom action tcsetattr"); if (action == TCSAFLUSH) - expect_eagain(pair.slave, "legacy TCSAFLUSH input"); + expect_eagain(pair.slave, "custom TCSAFLUSH input"); else - expect_read(pair.slave, "pending", 7, "legacy preserved input"); + expect_read(pair.slave, "pending", 7, "custom preserved input"); } for (endpoint_index = 0; endpoint_index < 2; endpoint_index++) { int terminal = endpoint_index == 0 ? pair.master : pair.slave; reset_pair(pair); - write_all(pair.master, "legacy", 6, "seed invalid legacy input"); - if (legacy_tcgetattr(terminal, original) < 0) - fail("legacy invalid source"); + write_all(pair.master, "custom", 6, "seed invalid custom input"); + if (custom_tcgetattr(terminal, original) < 0) + fail("custom invalid source"); memcpy(changed, original, sizeof(changed)); - legacy_set_lflag(changed, legacy_lflag(changed) & ~ICANON); + custom_set_lflag(changed, custom_lflag(changed) & ~ICANON); errno = 0; - check(legacy_tcsetattr(terminal, 99, changed) == -1, - "invalid legacy tcsetattr result"); - check(errno == EINVAL, "invalid legacy tcsetattr errno"); - if (legacy_tcgetattr(terminal, observed) < 0) - fail("legacy invalid result"); - check(memcmp(observed, original, KANDELO_LEGACY_TERMIOS_SIZE) == 0, - "invalid legacy tcsetattr attributes"); + check(custom_tcsetattr(terminal, 99, changed) == -1, + "invalid custom tcsetattr result"); + check(errno == EINVAL, "invalid custom tcsetattr errno"); + if (custom_tcgetattr(terminal, observed) < 0) + fail("custom invalid result"); + check(memcmp(observed, original, KANDELO_TERMIOS_SIZE) == 0, + "invalid custom tcsetattr attributes"); set_canonical(pair.slave, 0, TCSANOW); - expect_read(pair.slave, "legacy", 6, - "invalid legacy tcsetattr preserves input"); + expect_read(pair.slave, "custom", 6, + "invalid custom tcsetattr preserves input"); } } @@ -427,7 +440,7 @@ static void test_error_shapes(struct pty_pair pair) { const char *regular_path = "/tmp/terminal-attributes-api-regular"; struct termios value = attributes(pair.slave); - unsigned char legacy[KANDELO_LEGACY_BUFFER_SIZE]; + unsigned char custom[KANDELO_CHANNEL_BUFFER_SIZE]; int regular = open(regular_path, O_CREAT | O_TRUNC | O_RDWR, 0600); if (regular < 0) fail("open regular error-shape file"); @@ -447,17 +460,17 @@ static void test_error_shapes(struct pty_pair pair) expect_api_error(tcflush(regular, TCIFLUSH), ENOTTY, "tcflush ENOTTY"); errno = 0; - expect_api_error(legacy_tcgetattr(-1, legacy), EBADF, - "legacy tcgetattr EBADF"); + expect_api_error(custom_tcgetattr(-1, custom), EBADF, + "custom tcgetattr EBADF"); errno = 0; - expect_api_error(legacy_tcsetattr(-1, TCSANOW, legacy), EBADF, - "legacy tcsetattr EBADF"); + expect_api_error(custom_tcsetattr(-1, TCSANOW, custom), EBADF, + "custom tcsetattr EBADF"); errno = 0; - expect_api_error(legacy_tcgetattr(regular, legacy), ENOTTY, - "legacy tcgetattr ENOTTY"); + expect_api_error(custom_tcgetattr(regular, custom), ENOTTY, + "custom tcgetattr ENOTTY"); errno = 0; - expect_api_error(legacy_tcsetattr(regular, TCSANOW, legacy), ENOTTY, - "legacy tcsetattr ENOTTY"); + expect_api_error(custom_tcsetattr(regular, TCSANOW, custom), ENOTTY, + "custom tcsetattr ENOTTY"); if (close(regular) < 0) fail("close regular error-shape file"); if (unlink(regular_path) < 0) fail("unlink regular error-shape file"); @@ -491,7 +504,7 @@ int main(void) test_delimited_and_edited_order(pair); test_tcflush_selectors(pair); test_invalid_tcsetattr_is_non_mutating(pair); - test_legacy_channel(pair); + test_custom_channel(pair); test_error_shapes(pair); test_hangup(&pair); diff --git a/examples/timerfd_signalfd_scratch_test.c b/examples/timerfd_signalfd_scratch_test.c new file mode 100644 index 0000000000..6ea6306597 --- /dev/null +++ b/examples/timerfd_signalfd_scratch_test.c @@ -0,0 +1,80 @@ +#include +#include +#include +#include +#include +#include +#include + +struct guarded_timer { + unsigned char before[16]; + struct itimerspec value; + unsigned char after[16]; +}; + +static void init_guard(struct guarded_timer *guard) +{ + memset(guard->before, 0xa5, sizeof(guard->before)); + memset(&guard->value, 0, sizeof(guard->value)); + memset(guard->after, 0x5a, sizeof(guard->after)); +} + +static int guard_is_intact(const struct guarded_timer *guard) +{ + size_t i; + for (i = 0; i < sizeof(guard->before); i++) + if (guard->before[i] != 0xa5) return 0; + for (i = 0; i < sizeof(guard->after); i++) + if (guard->after[i] != 0x5a) return 0; + return 1; +} + +int main(void) +{ + struct guarded_timer next; + struct guarded_timer old; + struct guarded_timer current; + sigset_t mask; + int timer_fd; + int signal_fd; + + init_guard(&next); + init_guard(&old); + init_guard(¤t); + + timer_fd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC); + if (timer_fd < 0) { + perror("timerfd_create"); + return 1; + } + if (timerfd_settime(timer_fd, 0, &next.value, &old.value) != 0) { + perror("timerfd_settime"); + return 1; + } + if (timerfd_gettime(timer_fd, ¤t.value) != 0) { + perror("timerfd_gettime"); + return 1; + } + if (!guard_is_intact(&next) || !guard_is_intact(&old) + || !guard_is_intact(¤t)) { + fprintf(stderr, "timerfd scratch transfer crossed caller capacity\n"); + return 1; + } + close(timer_fd); + puts("timerfd scratch guards: PASS"); + + if (sigemptyset(&mask) != 0 || sigaddset(&mask, SIGUSR1) != 0 + || sigprocmask(SIG_BLOCK, &mask, NULL) != 0) { + perror("signal mask setup"); + return 1; + } + signal_fd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC); + if (signal_fd < 0) { + perror("signalfd"); + return 1; + } + close(signal_fd); + puts("signalfd scratch mask: PASS"); + puts("ALL TESTS PASSED"); + return 0; +} diff --git a/host/src/browser-kernel-host.ts b/host/src/browser-kernel-host.ts index 4f16f391ad..78cad58e32 100644 --- a/host/src/browser-kernel-host.ts +++ b/host/src/browser-kernel-host.ts @@ -705,6 +705,24 @@ export class BrowserKernel { return result; } + /** + * Return the retained capacity of the kernel-owned large-spawn region. + * Zero means no spawn has exceeded the ordinary channel-sized scratch. + */ + async getSpawnScratchCapacity(): Promise { + const requestId = this.nextRequestId++; + const result = await this.request(requestId, { + type: "get_spawn_scratch_capacity", + requestId, + }); + if (!Number.isSafeInteger(result) || result < 0) { + throw new Error( + `kernel worker returned an invalid spawn scratch capacity: ${String(result)}`, + ); + } + return result; + } + /** * Snapshot the kernel's process table — one row per live process. Used * by Kandelo's Inspector → Procs tab. Mirrors `NodeKernelHost.enumProcs`. diff --git a/host/src/browser-kernel-protocol.ts b/host/src/browser-kernel-protocol.ts index 744d56cd09..61f68f499d 100644 --- a/host/src/browser-kernel-protocol.ts +++ b/host/src/browser-kernel-protocol.ts @@ -291,6 +291,12 @@ export interface GetKernelMemoryPagesRequestMessage { requestId: number; } +/** Read the retained capacity of the kernel-owned large-spawn region. */ +export interface GetSpawnScratchCapacityRequestMessage { + type: "get_spawn_scratch_capacity"; + requestId: number; +} + /** Snapshot the kernel's process table. The kernel-worker forwards to * `CentralizedKernelWorker.enumProcs()`; the response carries `ProcessSnapshot[]`. * Used by Kandelo's Inspector → Procs tab. */ @@ -396,6 +402,7 @@ export type MainToKernelMessage = | RegisterLazyArchivesMessage | GetForkCountRequestMessage | GetKernelMemoryPagesRequestMessage + | GetSpawnScratchCapacityRequestMessage | MouseInjectMessage | AudioDrainMessage | EnumProcsRequestMessage diff --git a/host/src/browser-kernel-worker-entry.ts b/host/src/browser-kernel-worker-entry.ts index 68ef9fca2e..3f0e991bc8 100644 --- a/host/src/browser-kernel-worker-entry.ts +++ b/host/src/browser-kernel-worker-entry.ts @@ -1772,8 +1772,6 @@ async function handleExec( ): Promise { const initiatingInfo = processes.get(pid); if (!initiatingInfo) return -3; // ESRCH - if (!kernelWorker.supportsExecMetadataReplacement()) return -38; // ENOSYS - const resolved = await resolveExecutableForLaunch(path, argv); if (!resolved) return -2; // ENOENT if ("errno" in resolved) return -resolved.errno; @@ -2937,24 +2935,21 @@ async function handleTerminateProcess(msg: Extract) { - if (!initReady) { - respond(msg.requestId, uninitializedKernelPipeResult("read")); - return; - } - respond(msg.requestId, kernelWorker.readHostPipe(msg.pid, msg.pipeIdx)); -} - -function handlePipeWrite(msg: Extract) { - if (!initReady) { - respond(msg.requestId, uninitializedKernelPipeResult("write")); - return; - } + if (!kernelInstance) { respond(msg.requestId, null); return; } respond( msg.requestId, - kernelWorker.writeHostPipe(msg.pid, msg.pipeIdx, msg.data), + kernelWorker.readPipeAvailable(msg.pid, msg.pipeIdx), ); } +function handlePipeWrite(msg: Extract) { + if (!kernelInstance) { respond(msg.requestId, -1); return; } + const written = kernelWorker.writePipeData(msg.pid, msg.pipeIdx, msg.data); + // Wake readers + pollers watching this pipe + broad wake. + kernelWorker.notifyPipeReadable(msg.pipeIdx); + respond(msg.requestId, written); +} + function handlePipeCloseRead(msg: Extract) { if (!initReady) return; kernelWorker.closeHostPipeRead(msg.pid, msg.pipeIdx); @@ -3408,6 +3403,14 @@ sw.onmessage = (e: MessageEvent) => { } break; } + case "get_spawn_scratch_capacity": { + try { + respond(msg.requestId, kernelWorker.getSpawnScratchCapacity()); + } catch (err) { + respondError(msg.requestId, (err as Error)?.message ?? String(err)); + } + break; + } case "mouse_inject": handleMouseInject(msg); break; case "audio_drain": handleAudioDrain(msg); break; case "enum_procs": { diff --git a/host/src/compiled-worker-entry.ts b/host/src/compiled-worker-entry.ts new file mode 100644 index 0000000000..50d03794b3 --- /dev/null +++ b/host/src/compiled-worker-entry.ts @@ -0,0 +1,92 @@ +import { createHash } from "node:crypto"; +import { + existsSync, + readFileSync, + readdirSync, +} from "node:fs"; +import { dirname, join, relative } from "node:path"; + +const FINGERPRINT_MARKER = + "kandelo-host-build-inputs-sha256:"; +const HOST_BUILD_FILES = [ + "package-lock.json", + "package.json", + "tsconfig.json", + "tsup.config.ts", +] as const; + +function collectRegularFiles(directory: string, files: string[]): void { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + collectRegularFiles(path, files); + } else if (entry.isFile()) { + files.push(path); + } + } +} + +/** + * Hash every declared input to the host package build. + * + * File names and byte lengths are framed independently before their contents, + * so a rename or boundary shift cannot preserve the digest accidentally. + */ +export function hostBuildInputFingerprint(hostRoot: string): string { + const inputs = HOST_BUILD_FILES.map((path) => join(hostRoot, path)); + collectRegularFiles(join(hostRoot, "src"), inputs); + inputs.sort((left, right) => + relative(hostRoot, left).localeCompare(relative(hostRoot, right)) + ); + + const hash = createHash("sha256"); + for (const input of inputs) { + const name = relative(hostRoot, input); + const bytes = readFileSync(input); + hash.update(String(Buffer.byteLength(name))); + hash.update(":"); + hash.update(name); + hash.update(":"); + hash.update(String(bytes.byteLength)); + hash.update(":"); + hash.update(bytes); + } + return hash.digest("hex"); +} + +/** Build banner consumed by the source-checkout freshness gate. */ +export function hostBuildFingerprintBanner(hostRoot: string): string { + return `/* ${FINGERPRINT_MARKER}${hostBuildInputFingerprint(hostRoot)} */`; +} + +function compiledBuildInputFingerprint(path: string): string | null { + const match = readFileSync(path, "utf8").match( + /kandelo-host-build-inputs-sha256:([0-9a-f]{64})/, + ); + return match?.[1] ?? null; +} + +/** + * Decide whether a bundled worker represents the complete declared host build + * input set, rather than trusting file modification times. + * + * Packaged consumers have no source entry and may use the shipped bundle + * directly. A source checkout fails closed to the TypeScript loader unless the + * bundle embeds the exact source/config/package-lock fingerprint produced by + * the declared tsup build. Touching or copying an old bundle cannot make it + * current. + */ +export function compiledWorkerEntryIsCurrent( + sourceEntryPath: string, + compiledPath: string, +): boolean { + if (!existsSync(compiledPath)) return false; + if (!existsSync(sourceEntryPath)) return true; + try { + const hostRoot = dirname(dirname(sourceEntryPath)); + return compiledBuildInputFingerprint(compiledPath) + === hostBuildInputFingerprint(hostRoot); + } catch { + return false; + } +} diff --git a/host/src/generated/abi.ts b/host/src/generated/abi.ts index 659383a267..8a145f66e4 100644 --- a/host/src/generated/abi.ts +++ b/host/src/generated/abi.ts @@ -343,6 +343,109 @@ export const WPK_FORK_REQUIRED_EXPORTS = [ export const SCHED_AFFINITY_MASK_SIZE = 4 as const; +export const KERNEL_SCRATCH_SIGNAL_DELIVERY_BYTES = 56 as const; +export const KERNEL_SCRATCH_FD_PAIR_BYTES = 8 as const; +export const KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES = 8 as const; +export const KERNEL_SCRATCH_SOCKLEN_BYTES = 4 as const; +export const PR_SET_NAME = 15 as const; +export const PR_GET_NAME = 16 as const; +export const PRCTL_NAME_BYTES = 16 as const; +export const FCNTL_FLOCK_BYTES = 32 as const; +export const SIGNAL_MASK_BYTES = 8 as const; + +export const POSIX_ARG_MAX_BYTES = 4194304 as const; +export const POSIX_PATH_MAX_BYTES = 4096 as const; +export const POSIX_IOV_MAX = 1024 as const; +export const SELECT_FD_SETSIZE = 1024 as const; +export const SELECT_FD_SET_BYTES = 128 as const; +export const PROCESS_IOVEC_WASM32_SIZE = 8 as const; +export const PROCESS_IOVEC_WASM32_BASE_OFFSET = 0 as const; +export const PROCESS_IOVEC_WASM32_LEN_OFFSET = 4 as const; +export const PROCESS_IOVEC_WASM64_SIZE = 16 as const; +export const PROCESS_IOVEC_WASM64_BASE_OFFSET = 0 as const; +export const PROCESS_IOVEC_WASM64_LEN_OFFSET = 8 as const; +export const PROCESS_MSGHDR_WASM32_SIZE = 28 as const; +export const PROCESS_MSGHDR_WASM32_NAME_OFFSET = 0 as const; +export const PROCESS_MSGHDR_WASM32_NAMELEN_OFFSET = 4 as const; +export const PROCESS_MSGHDR_WASM32_IOV_OFFSET = 8 as const; +export const PROCESS_MSGHDR_WASM32_IOVLEN_OFFSET = 12 as const; +export const PROCESS_MSGHDR_WASM32_CONTROL_OFFSET = 16 as const; +export const PROCESS_MSGHDR_WASM32_CONTROLLEN_OFFSET = 20 as const; +export const PROCESS_MSGHDR_WASM32_FLAGS_OFFSET = 24 as const; +export const PROCESS_MSGHDR_WASM64_SIZE = 56 as const; +export const PROCESS_MSGHDR_WASM64_NAME_OFFSET = 0 as const; +export const PROCESS_MSGHDR_WASM64_NAMELEN_OFFSET = 8 as const; +export const PROCESS_MSGHDR_WASM64_IOV_OFFSET = 16 as const; +export const PROCESS_MSGHDR_WASM64_IOVLEN_OFFSET = 24 as const; +export const PROCESS_MSGHDR_WASM64_CONTROL_OFFSET = 32 as const; +export const PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET = 40 as const; +export const PROCESS_MSGHDR_WASM64_FLAGS_OFFSET = 48 as const; +export const PROCESS_CMSGHDR_WASM32_SIZE = 12 as const; +export const PROCESS_CMSGHDR_WASM32_ALIGN = 4 as const; +export const PROCESS_CMSGHDR_WASM32_LEN_OFFSET = 0 as const; +export const PROCESS_CMSGHDR_WASM32_LEVEL_OFFSET = 4 as const; +export const PROCESS_CMSGHDR_WASM32_TYPE_OFFSET = 8 as const; +export const PROCESS_CMSGHDR_WASM32_DATA_OFFSET = 12 as const; +export const PROCESS_CMSGHDR_WASM64_SIZE = 16 as const; +export const PROCESS_CMSGHDR_WASM64_ALIGN = 8 as const; +export const PROCESS_CMSGHDR_WASM64_LEN_OFFSET = 0 as const; +export const PROCESS_CMSGHDR_WASM64_LEVEL_OFFSET = 8 as const; +export const PROCESS_CMSGHDR_WASM64_TYPE_OFFSET = 12 as const; +export const PROCESS_CMSGHDR_WASM64_DATA_OFFSET = 16 as const; +export const PROCESS_SIGINFO_SIGNO_OFFSET = 0 as const; +export const PROCESS_SIGINFO_ERRNO_OFFSET = 4 as const; +export const PROCESS_SIGINFO_CODE_OFFSET = 8 as const; +export const PROCESS_SIGINFO_WASM32_SIZE = 128 as const; +export const PROCESS_SIGINFO_WASM32_PID_OFFSET = 12 as const; +export const PROCESS_SIGINFO_WASM32_UID_OFFSET = 16 as const; +export const PROCESS_SIGINFO_WASM32_VALUE_OFFSET = 20 as const; +export const PROCESS_SIGINFO_WASM32_VALUE_SIZE = 4 as const; +export const PROCESS_SIGINFO_WASM64_SIZE = 128 as const; +export const PROCESS_SIGINFO_WASM64_PID_OFFSET = 16 as const; +export const PROCESS_SIGINFO_WASM64_UID_OFFSET = 20 as const; +export const PROCESS_SIGINFO_WASM64_VALUE_OFFSET = 24 as const; +export const PROCESS_SIGINFO_WASM64_VALUE_SIZE = 8 as const; +export const SOCKET_SOL_SOCKET = 1 as const; +export const SOCKET_SCM_RIGHTS = 1 as const; +export const SOCKET_MSG_TRUNC = 32 as const; +export const SCM_RIGHTS_FD_BYTES = 4 as const; +export const KERNEL_MESSAGE_WIRE_FLATTENED_IOVEC_COUNT = 1 as const; +export const SPAWN_WIRE_HEADER_BYTES = 40 as const; +export const SPAWN_WIRE_STRING_OFFSET_BYTES = 4 as const; +export const SPAWN_WIRE_HEADER_ARGC_OFFSET = 0 as const; +export const SPAWN_WIRE_HEADER_ENVC_OFFSET = 4 as const; +export const SPAWN_WIRE_HEADER_ACTION_COUNT_OFFSET = 8 as const; +export const SPAWN_WIRE_HEADER_ATTR_FLAGS_OFFSET = 12 as const; +export const SPAWN_WIRE_HEADER_PGRP_OFFSET = 16 as const; +export const SPAWN_WIRE_HEADER_PAD_OFFSET = 20 as const; +export const SPAWN_WIRE_HEADER_SIGDEF_OFFSET = 24 as const; +export const SPAWN_WIRE_HEADER_SIGMASK_OFFSET = 32 as const; +export const SPAWN_WIRE_ACTION_RECORD_BYTES = 28 as const; +export const SPAWN_WIRE_ACTION_OP_OFFSET = 0 as const; +export const SPAWN_WIRE_ACTION_FD_OFFSET = 4 as const; +export const SPAWN_WIRE_ACTION_NEWFD_OFFSET = 8 as const; +export const SPAWN_WIRE_ACTION_PATH_OFF_OFFSET = 12 as const; +export const SPAWN_WIRE_ACTION_PATH_LEN_OFFSET = 16 as const; +export const SPAWN_WIRE_ACTION_OFLAG_OFFSET = 20 as const; +export const SPAWN_WIRE_ACTION_MODE_OFFSET = 24 as const; +export const SPAWN_WIRE_OP_OPEN = 0 as const; +export const SPAWN_WIRE_OP_CLOSE = 1 as const; +export const SPAWN_WIRE_OP_DUP2 = 2 as const; +export const SPAWN_WIRE_OP_CHDIR = 3 as const; +export const SPAWN_WIRE_OP_FCHDIR = 4 as const; +export const SPAWN_ATTR_RESETIDS = 1 as const; +export const SPAWN_ATTR_SETPGROUP = 2 as const; +export const SPAWN_ATTR_SETSIGDEF = 4 as const; +export const SPAWN_ATTR_SETSIGMASK = 8 as const; +export const SPAWN_ATTR_SETSCHEDPARAM = 16 as const; +export const SPAWN_ATTR_SETSCHEDULER = 32 as const; +export const SPAWN_ATTR_USEVFORK = 64 as const; +export const SPAWN_ATTR_SETSID = 128 as const; +export const SPAWN_MAX_ARGV_COUNT = 4096 as const; +export const SPAWN_MAX_ENVP_COUNT = 4096 as const; +export const SPAWN_MAX_ACTION_COUNT = 1024 as const; +export const SPAWN_WIRE_MAX_BYTES = 8417320 as const; + export const HOST_ADAPTER_VERSION = 1 as const; export const HOST_ADAPTER_MANIFEST_MAGIC = 1296781399 as const; export const HOST_ADAPTER_MANIFEST_VERSION = 1 as const; @@ -359,6 +462,7 @@ export const HOST_ADAPTER_WORKER_FEATURES = { export const HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS = [ "__abi_version", "kernel_alloc_scratch", + "kernel_clear_process_metadata", "kernel_create_process", "kernel_create_process_with_stdio", "kernel_dequeue_signal", @@ -377,13 +481,25 @@ export const HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS = [ "kernel_ipc_shmdt_for_process", "kernel_ipc_shmdt_for_task", "kernel_mark_process_signaled", + "kernel_msqid_ds_bytes", "kernel_pipe_has_readers", "kernel_posix_timer_fire", "kernel_prepare_write_operation", + "kernel_push_process_metadata_entry", "kernel_reap_exited_child", "kernel_remove_process", + "kernel_semctl_array_bytes", + "kernel_semid_ds_bytes", "kernel_set_current_tid", + "kernel_set_cwd", + "kernel_shmid_ds_bytes", "kernel_spawn_process", + "kernel_spawn_reserved_process", + "kernel_spawn_scratch_begin", + "kernel_spawn_scratch_cancel", + "kernel_spawn_scratch_capacity", + "kernel_spawn_scratch_pointer", + "kernel_spawn_scratch_retained_capacity", "kernel_thread_exit", "kernel_validate_task", "kernel_wait_child_poll", @@ -392,10 +508,8 @@ export const HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS = [ export const HOST_ADAPTER_OPTIONAL_KERNEL_EXPORTS = [ "kernel_reserve_host_region", "kernel_reserve_host_region_at", - "kernel_set_cwd", "kernel_set_max_addr", "kernel_set_mmap_base", - "kernel_set_process_argv", ] as const; export const HOST_ADAPTER_MANIFEST_FIELDS = { @@ -458,11 +572,19 @@ export const PROCESS_MEMORY_THREAD_SLOT_CHANNEL_PRIMARY_PAGE = 2 as const; export const PROCESS_MEMORY_THREAD_SLOT_CHANNEL_SPILL_PAGE = 3 as const; export const PROCESS_MEMORY_PAGES_PER_THREAD_SLOT = 4 as const; -export const CH_SIG_BASE = 65560 as const; -export const CH_SIG_SIGNUM = 65560 as const; -export const CH_SIG_HANDLER = 65564 as const; -export const CH_SIG_FLAGS = 65568 as const; -export const CH_SIG_OLD_MASK = 65576 as const; +export const CH_SIG_BASE = 65552 as const; +export const CH_SIG_AREA_SIZE = 56 as const; +export const CH_SIG_DELIVERY_SIZE = 56 as const; +export const CH_SIG_SIGNUM = 65552 as const; +export const CH_SIG_HANDLER = 65556 as const; +export const CH_SIG_FLAGS = 65560 as const; +export const CH_SIG_SI_VALUE = 65564 as const; +export const CH_SIG_OLD_MASK = 65572 as const; +export const CH_SIG_SI_CODE = 65580 as const; +export const CH_SIGINFO_WORD_1 = 65584 as const; +export const CH_SIGINFO_WORD_2 = 65588 as const; +export const CH_SIG_ALT_SP = 65592 as const; +export const CH_SIG_ALT_SIZE = 65600 as const; export const WAIT_EVENT_EXITED = 1 as const; export const WAIT_EVENT_STOPPED = 2 as const; @@ -487,7 +609,35 @@ export const STRUCT_SIZE_WASM_STAT = 88 as const; export const STRUCT_SIZE_WASM_DIRENT = 16 as const; export const STRUCT_SIZE_WASM_TIMESPEC = 16 as const; export const STRUCT_SIZE_WASM_POLL_FD = 8 as const; +export const WASM_POLL_FD_FD_OFFSET = 0 as const; +export const WASM_POLL_FD_EVENTS_OFFSET = 4 as const; +export const WASM_POLL_FD_REVENTS_OFFSET = 6 as const; +export const STRUCT_SIZE_KERNEL_IOVEC_WIRE = 8 as const; +export const KERNEL_IOVEC_WIRE_ALIGN = 4 as const; +export const KERNEL_IOVEC_WIRE_BASE_OFFSET = 0 as const; +export const KERNEL_IOVEC_WIRE_LEN_OFFSET = 4 as const; +export const STRUCT_SIZE_KERNEL_MSGHDR_WIRE = 28 as const; +export const KERNEL_MSGHDR_WIRE_ALIGN = 4 as const; +export const KERNEL_MSGHDR_WIRE_NAME_OFFSET = 0 as const; +export const KERNEL_MSGHDR_WIRE_NAMELEN_OFFSET = 4 as const; +export const KERNEL_MSGHDR_WIRE_IOV_OFFSET = 8 as const; +export const KERNEL_MSGHDR_WIRE_IOVLEN_OFFSET = 12 as const; +export const KERNEL_MSGHDR_WIRE_CONTROL_OFFSET = 16 as const; +export const KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET = 20 as const; +export const KERNEL_MSGHDR_WIRE_FLAGS_OFFSET = 24 as const; +export const STRUCT_SIZE_KERNEL_CMSGHDR_WIRE = 12 as const; +export const KERNEL_CMSGHDR_WIRE_ALIGN = 4 as const; +export const KERNEL_CMSGHDR_WIRE_LEN_OFFSET = 0 as const; +export const KERNEL_CMSGHDR_WIRE_LEVEL_OFFSET = 4 as const; +export const KERNEL_CMSGHDR_WIRE_TYPE_OFFSET = 8 as const; +export const KERNEL_CMSGHDR_WIRE_DATA_OFFSET = 12 as const; +export const STRUCT_SIZE_WASM_EPOLL_EVENT = 16 as const; +export const WASM_EPOLL_EVENT_EVENTS_OFFSET = 0 as const; +export const WASM_EPOLL_EVENT_PAD_OFFSET = 4 as const; +export const WASM_EPOLL_EVENT_DATA_OFFSET = 8 as const; +export const STRUCT_SIZE_WASM_SYSV_MESSAGE_HEADER = 8 as const; export const STRUCT_SIZE_WASM_STATFS = 72 as const; +export const STRUCT_SIZE_WPK_DRM_MODE_MODEINFO = 68 as const; export const STRUCT_SIZE_WASM_RUSAGE_WIRE = 144 as const; export const STRUCT_SIZE_KERNEL_WAIT_RESULT = 160 as const; export const KERNEL_WAIT_RESULT_WAIT_STATUS_OFFSET = 0 as const; @@ -649,6 +799,7 @@ export const ABI_SYSCALLS = { Clone: 201, Gettid: 202, SetTidAddress: 203, + Tkill: 204, RtSigqueueinfo: 205, RtSigpending: 206, RtSigtimedwait: 207, @@ -663,26 +814,40 @@ export const ABI_SYSCALLS = { ClockSettime: 226, SchedYield: 229, SchedGetparam: 230, + SchedSetparam: 231, + SchedSetscheduler: 233, SchedRrGetInterval: 236, SchedGetaffinity: 238, EpollCreate1: 239, EpollCtl: 240, EpollPwait: 241, + TimerfdCreate: 243, + TimerfdSettime: 244, + TimerfdGettime: 245, + Signalfd4: 246, Prlimit64: 250, Ppoll: 251, Pselect6: 252, + MemfdCreate: 256, Statx: 260, SetRobustList: 261, GetRobustList: 262, + Sysinfo: 269, Mknod: 271, Mknodat: 272, Msync: 278, Waitid: 288, + CopyFileRange: 290, + Splice: 291, Sendfile: 294, Preadv: 295, Pwritev: 296, + Preadv2: 297, + Pwritev2: 298, Lchown: 299, + Renameat2: 306, Fallocate: 308, + Getcpu: 325, TimerCreate: 326, TimerSettime: 327, TimerGettime: 328, @@ -705,6 +870,7 @@ export const ABI_SYSCALLS = { Shmat: 345, Shmdt: 346, Shmctl: 347, + Signalfd: 377, EpollCreate: 378, EpollWait: 379, Faccessat2: 382, @@ -886,6 +1052,7 @@ export const ABI_SYSCALL_NAMES: Record = { 201: "clone", 202: "gettid", 203: "set_tid_address", + 204: "tkill", 205: "rt_sigqueueinfo", 206: "rt_sigpending", 207: "rt_sigtimedwait", @@ -903,26 +1070,40 @@ export const ABI_SYSCALL_NAMES: Record = { 226: "clock_settime", 229: "sched_yield", 230: "sched_getparam", + 231: "sched_setparam", + 233: "sched_setscheduler", 236: "sched_rr_get_interval", 238: "sched_getaffinity", 239: "epoll_create1", 240: "epoll_ctl", 241: "epoll_pwait", + 243: "timerfd_create", + 244: "timerfd_settime", + 245: "timerfd_gettime", + 246: "signalfd4", 250: "prlimit64", 251: "ppoll", 252: "pselect6", + 256: "memfd_create", 260: "statx", 261: "set_robust_list", 262: "get_robust_list", + 269: "sysinfo", 271: "mknod", 272: "mknodat", 278: "msync", 288: "waitid", + 290: "copy_file_range", + 291: "splice", 294: "sendfile", 295: "preadv", 296: "pwritev", + 297: "preadv2", + 298: "pwritev2", 299: "lchown", + 306: "renameat2", 308: "fallocate", + 325: "getcpu", 326: "timer_create", 327: "timer_settime", 328: "timer_gettime", @@ -945,6 +1126,7 @@ export const ABI_SYSCALL_NAMES: Record = { 345: "shmat", 346: "shmdt", 347: "shmctl", + 377: "signalfd", 378: "epoll_create", 379: "epoll_wait", 382: "faccessat2", @@ -962,7 +1144,10 @@ export type SyscallArgSizeSpec = | { type: "cstring" } | { type: "arg"; argIndex: number; multiplier?: number; add?: number } | { type: "deref"; argIndex: number } - | { type: "fixed"; size: number }; + | { type: "fixed"; size: number } + | { type: "process-layout"; wasm32Size: number; wasm64Size: number }; + +export const PROCESS_POINTER_WIDTH_ARG_INDEX = 5 as const; export interface SyscallArgDesc { argIndex: number; @@ -970,404 +1155,506 @@ export interface SyscallArgDesc { size: SyscallArgSizeSpec; nullable?: boolean; required?: boolean; - copyRetvalAdd?: number; } +export type IoctlArgKind = "none" | "scalar-i32" | "pointer"; +export type IoctlDirection = "none" | "in" | "out" | "inout"; + +export interface IoctlRequestContract { + argKind: IoctlArgKind; + direction: IoctlDirection; + wasm32Size: number | null; + wasm64Size: number | null; +} + +export const IOCTL_REQUESTS: Record = { + 64: { argKind: "pointer", direction: "in", wasm32Size: 4, wasm64Size: 4 }, + 65: { argKind: "none", direction: "none", wasm32Size: 0, wasm64Size: 0 }, + 66: { argKind: "pointer", direction: "in", wasm32Size: 16, wasm64Size: 16 }, + 67: { argKind: "none", direction: "none", wasm32Size: 0, wasm64Size: 0 }, + 68: { argKind: "pointer", direction: "in", wasm32Size: 32, wasm64Size: 32 }, + 69: { argKind: "none", direction: "none", wasm32Size: 0, wasm64Size: 0 }, + 70: { argKind: "none", direction: "none", wasm32Size: 0, wasm64Size: 0 }, + 71: { argKind: "pointer", direction: "in", wasm32Size: 8, wasm64Size: 8 }, + 72: { argKind: "none", direction: "none", wasm32Size: 0, wasm64Size: 0 }, + 73: { argKind: "pointer", direction: "in", wasm32Size: 24, wasm64Size: null }, + 17920: { argKind: "pointer", direction: "out", wasm32Size: 160, wasm64Size: 160 }, + 17921: { argKind: "pointer", direction: "in", wasm32Size: 160, wasm64Size: 160 }, + 17922: { argKind: "pointer", direction: "out", wasm32Size: 80, wasm64Size: 80 }, + 17926: { argKind: "pointer", direction: "in", wasm32Size: 160, wasm64Size: 160 }, + 19251: { argKind: "pointer", direction: "out", wasm32Size: 1, wasm64Size: 1 }, + 19268: { argKind: "pointer", direction: "out", wasm32Size: 4, wasm64Size: 4 }, + 19269: { argKind: "scalar-i32", direction: "none", wasm32Size: 0, wasm64Size: 0 }, + 20480: { argKind: "none", direction: "none", wasm32Size: 0, wasm64Size: 0 }, + 20481: { argKind: "none", direction: "none", wasm32Size: 0, wasm64Size: 0 }, + 21505: { argKind: "pointer", direction: "out", wasm32Size: 60, wasm64Size: 60 }, + 21506: { argKind: "pointer", direction: "in", wasm32Size: 60, wasm64Size: 60 }, + 21507: { argKind: "pointer", direction: "in", wasm32Size: 60, wasm64Size: 60 }, + 21508: { argKind: "pointer", direction: "in", wasm32Size: 60, wasm64Size: 60 }, + 21513: { argKind: "scalar-i32", direction: "none", wasm32Size: 0, wasm64Size: 0 }, + 21514: { argKind: "scalar-i32", direction: "none", wasm32Size: 0, wasm64Size: 0 }, + 21515: { argKind: "scalar-i32", direction: "none", wasm32Size: 0, wasm64Size: 0 }, + 21518: { argKind: "scalar-i32", direction: "none", wasm32Size: 0, wasm64Size: 0 }, + 21519: { argKind: "pointer", direction: "out", wasm32Size: 4, wasm64Size: 4 }, + 21520: { argKind: "pointer", direction: "in", wasm32Size: 4, wasm64Size: 4 }, + 21523: { argKind: "pointer", direction: "out", wasm32Size: 8, wasm64Size: 8 }, + 21524: { argKind: "pointer", direction: "in", wasm32Size: 8, wasm64Size: 8 }, + 21531: { argKind: "pointer", direction: "out", wasm32Size: 4, wasm64Size: 4 }, + 21537: { argKind: "pointer", direction: "in", wasm32Size: 4, wasm64Size: 4 }, + 21538: { argKind: "none", direction: "none", wasm32Size: 0, wasm64Size: 0 }, + 21545: { argKind: "pointer", direction: "out", wasm32Size: 4, wasm64Size: 4 }, + 21584: { argKind: "none", direction: "none", wasm32Size: 0, wasm64Size: 0 }, + 21585: { argKind: "none", direction: "none", wasm32Size: 0, wasm64Size: 0 }, + 21586: { argKind: "pointer", direction: "in", wasm32Size: 4, wasm64Size: 4 }, + 25630: { argKind: "none", direction: "none", wasm32Size: 0, wasm64Size: 0 }, + 25631: { argKind: "none", direction: "none", wasm32Size: 0, wasm64Size: 0 }, + 35077: { argKind: "pointer", direction: "out", wasm32Size: 4, wasm64Size: 4 }, + 1074025521: { argKind: "pointer", direction: "in", wasm32Size: 4, wasm64Size: 4 }, + 1074291721: { argKind: "pointer", direction: "in", wasm32Size: 8, wasm64Size: 8 }, + 2147766283: { argKind: "pointer", direction: "out", wasm32Size: 4, wasm64Size: 4 }, + 2147767344: { argKind: "pointer", direction: "out", wasm32Size: 4, wasm64Size: 4 }, + 3221508098: { argKind: "pointer", direction: "inout", wasm32Size: 4, wasm64Size: 4 }, + 3221508099: { argKind: "pointer", direction: "inout", wasm32Size: 4, wasm64Size: 4 }, + 3221508101: { argKind: "pointer", direction: "inout", wasm32Size: 4, wasm64Size: 4 }, + 3221508102: { argKind: "pointer", direction: "inout", wasm32Size: 4, wasm64Size: 4 }, + 3221508106: { argKind: "pointer", direction: "inout", wasm32Size: 4, wasm64Size: 4 }, + 3221513391: { argKind: "pointer", direction: "in", wasm32Size: 4, wasm64Size: 4 }, + 3221513396: { argKind: "pointer", direction: "in", wasm32Size: 4, wasm64Size: 4 }, + 3222037549: { argKind: "pointer", direction: "inout", wasm32Size: 12, wasm64Size: 12 }, + 3222037550: { argKind: "pointer", direction: "inout", wasm32Size: 12, wasm64Size: 12 }, + 3222299660: { argKind: "pointer", direction: "inout", wasm32Size: 16, wasm64Size: 16 }, + 3222299706: { argKind: "pointer", direction: "inout", wasm32Size: 16, wasm64Size: 16 }, + 3222299827: { argKind: "pointer", direction: "inout", wasm32Size: 16, wasm64Size: 16 }, + 3222561958: { argKind: "pointer", direction: "inout", wasm32Size: 20, wasm64Size: 20 }, + 3222824112: { argKind: "pointer", direction: "in", wasm32Size: 24, wasm64Size: 24 }, + 3223348402: { argKind: "pointer", direction: "inout", wasm32Size: 32, wasm64Size: 32 }, + 3223610368: { argKind: "pointer", direction: "inout", wasm32Size: 36, wasm64Size: null }, + 3225445376: { argKind: "pointer", direction: "inout", wasm32Size: null, wasm64Size: 64 }, + 3225445536: { argKind: "pointer", direction: "inout", wasm32Size: 64, wasm64Size: 64 }, + 3226494119: { argKind: "pointer", direction: "inout", wasm32Size: 80, wasm64Size: 80 }, + 3228066977: { argKind: "pointer", direction: "inout", wasm32Size: 104, wasm64Size: 104 }, + 3228066978: { argKind: "pointer", direction: "in", wasm32Size: 104, wasm64Size: 104 }, + 3228067000: { argKind: "pointer", direction: "inout", wasm32Size: 104, wasm64Size: 104 }, +}; + export const SYSCALL_ARGS: Record = { 1: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, ], 3: [ - { argIndex: 1, direction: "out", size: { type: "arg", argIndex: 2 } }, + { argIndex: 1, direction: "out", size: { type: "arg", argIndex: 2 }, required: true }, ], 4: [ - { argIndex: 1, direction: "in", size: { type: "arg", argIndex: 2 } }, + { argIndex: 1, direction: "in", size: { type: "arg", argIndex: 2 }, required: true }, ], 6: [ - { argIndex: 1, direction: "out", size: { type: "fixed", size: 88 } }, + { argIndex: 1, direction: "out", size: { type: "fixed", size: 112 }, required: true }, ], 9: [ - { argIndex: 0, direction: "out", size: { type: "fixed", size: 8 } }, + { argIndex: 0, direction: "out", size: { type: "fixed", size: 8 }, required: true }, ], 11: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, - { argIndex: 1, direction: "out", size: { type: "fixed", size: 88 } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 1, direction: "out", size: { type: "fixed", size: 112 }, required: true }, ], 12: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, - { argIndex: 1, direction: "out", size: { type: "fixed", size: 88 } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 1, direction: "out", size: { type: "fixed", size: 112 }, required: true }, ], 13: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, ], 14: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, ], 15: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, ], 16: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, - { argIndex: 1, direction: "in", size: { type: "cstring" } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, ], 17: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, - { argIndex: 1, direction: "in", size: { type: "cstring" } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, ], 18: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, - { argIndex: 1, direction: "in", size: { type: "cstring" } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, ], 19: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, - { argIndex: 1, direction: "out", size: { type: "arg", argIndex: 2 } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 1, direction: "out", size: { type: "arg", argIndex: 2 }, required: true }, ], 20: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, ], 21: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, ], 22: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, ], 23: [ - { argIndex: 0, direction: "out", size: { type: "arg", argIndex: 1 } }, + { argIndex: 0, direction: "out", size: { type: "arg", argIndex: 1 }, required: true }, ], 24: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, ], 25: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, ], 26: [ - { argIndex: 1, direction: "out", size: { type: "fixed", size: 16 } }, - { argIndex: 2, direction: "out", size: { type: "arg", argIndex: 3 } }, + { argIndex: 1, direction: "out", size: { type: "fixed", size: 16 }, required: true }, + { argIndex: 2, direction: "out", size: { type: "arg", argIndex: 3 }, required: true }, ], 36: [ - { argIndex: 1, direction: "in", size: { type: "fixed", size: 16 } }, - { argIndex: 2, direction: "out", size: { type: "fixed", size: 16 } }, + { argIndex: 1, direction: "in", size: { type: "fixed", size: 16 }, nullable: true }, + { argIndex: 2, direction: "out", size: { type: "fixed", size: 16 }, nullable: true }, ], 37: [ - { argIndex: 1, direction: "in", size: { type: "fixed", size: 8 } }, - { argIndex: 2, direction: "out", size: { type: "fixed", size: 8 } }, + { argIndex: 1, direction: "in", size: { type: "fixed", size: 8 }, nullable: true }, + { argIndex: 2, direction: "out", size: { type: "fixed", size: 8 }, nullable: true }, ], 40: [ - { argIndex: 1, direction: "out", size: { type: "fixed", size: 16 } }, + { argIndex: 1, direction: "out", size: { type: "fixed", size: 16 }, required: true }, ], 41: [ - { argIndex: 0, direction: "in", size: { type: "fixed", size: 16 } }, + { argIndex: 0, direction: "in", size: { type: "fixed", size: 16 }, required: true }, ], 43: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, - { argIndex: 1, direction: "out", size: { type: "arg", argIndex: 2 } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 1, direction: "out", size: { type: "arg", argIndex: 2 }, required: true }, ], 44: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, - { argIndex: 1, direction: "in", size: { type: "cstring" } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, ], 45: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, ], 51: [ - { argIndex: 1, direction: "in", size: { type: "arg", argIndex: 2 } }, + { argIndex: 1, direction: "in", size: { type: "arg", argIndex: 2 }, required: true }, ], 53: [ - { argIndex: 1, direction: "out", size: { type: "deref", argIndex: 2 } }, - { argIndex: 2, direction: "inout", size: { type: "fixed", size: 4 } }, + { argIndex: 1, direction: "out", size: { type: "deref", argIndex: 2 }, nullable: true }, + { argIndex: 2, direction: "inout", size: { type: "fixed", size: 4 }, nullable: true }, ], 54: [ - { argIndex: 1, direction: "in", size: { type: "arg", argIndex: 2 } }, + { argIndex: 1, direction: "in", size: { type: "arg", argIndex: 2 }, required: true }, ], 55: [ - { argIndex: 1, direction: "in", size: { type: "arg", argIndex: 2 } }, + { argIndex: 1, direction: "in", size: { type: "arg", argIndex: 2 }, required: true }, ], 56: [ - { argIndex: 1, direction: "out", size: { type: "arg", argIndex: 2 } }, + { argIndex: 1, direction: "out", size: { type: "arg", argIndex: 2 }, required: true }, ], 58: [ - { argIndex: 3, direction: "out", size: { type: "deref", argIndex: 4 } }, - { argIndex: 4, direction: "inout", size: { type: "fixed", size: 4 } }, + { argIndex: 3, direction: "out", size: { type: "deref", argIndex: 4 }, required: true }, + { argIndex: 4, direction: "inout", size: { type: "fixed", size: 4 }, required: true }, ], 59: [ - { argIndex: 3, direction: "in", size: { type: "arg", argIndex: 4 } }, + { argIndex: 3, direction: "in", size: { type: "arg", argIndex: 4 }, required: true }, ], 60: [ - { argIndex: 0, direction: "inout", size: { type: "arg", argIndex: 1, multiplier: 8 } }, + { argIndex: 0, direction: "inout", size: { type: "arg", argIndex: 1, multiplier: 8 }, required: true }, ], 61: [ - { argIndex: 3, direction: "out", size: { type: "fixed", size: 8 } }, + { argIndex: 3, direction: "out", size: { type: "fixed", size: 8 }, required: true }, ], 62: [ - { argIndex: 1, direction: "in", size: { type: "arg", argIndex: 2 } }, - { argIndex: 4, direction: "in", size: { type: "arg", argIndex: 5 } }, + { argIndex: 1, direction: "in", size: { type: "arg", argIndex: 2 }, required: true }, + { argIndex: 4, direction: "in", size: { type: "arg", argIndex: 5 }, required: true }, ], 63: [ - { argIndex: 1, direction: "out", size: { type: "arg", argIndex: 2 } }, - { argIndex: 4, direction: "out", size: { type: "deref", argIndex: 5 } }, - { argIndex: 5, direction: "inout", size: { type: "fixed", size: 4 } }, + { argIndex: 1, direction: "out", size: { type: "arg", argIndex: 2 }, required: true }, + { argIndex: 4, direction: "out", size: { type: "deref", argIndex: 5 }, nullable: true }, + { argIndex: 5, direction: "inout", size: { type: "fixed", size: 4 }, nullable: true }, ], 64: [ - { argIndex: 1, direction: "out", size: { type: "arg", argIndex: 2 } }, + { argIndex: 1, direction: "out", size: { type: "arg", argIndex: 2 }, required: true }, ], 65: [ - { argIndex: 1, direction: "in", size: { type: "arg", argIndex: 2 } }, + { argIndex: 1, direction: "in", size: { type: "arg", argIndex: 2 }, required: true }, ], 69: [ - { argIndex: 1, direction: "in", size: { type: "cstring" } }, + { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, ], 70: [ - { argIndex: 1, direction: "out", size: { type: "fixed", size: 256 } }, + { argIndex: 1, direction: "out", size: { type: "fixed", size: 60 }, required: true }, ], 71: [ - { argIndex: 2, direction: "in", size: { type: "fixed", size: 256 } }, - ], - 72: [ - { argIndex: 2, direction: "inout", size: { type: "fixed", size: 256 } }, + { argIndex: 2, direction: "in", size: { type: "fixed", size: 60 }, required: true }, ], 75: [ - { argIndex: 0, direction: "out", size: { type: "fixed", size: 390 } }, + { argIndex: 0, direction: "out", size: { type: "fixed", size: 390 }, required: true }, ], 78: [ - { argIndex: 0, direction: "out", size: { type: "fixed", size: 8 } }, + { argIndex: 0, direction: "out", size: { type: "fixed", size: 8 }, required: true }, ], 83: [ - { argIndex: 1, direction: "out", size: { type: "fixed", size: 16 } }, + { argIndex: 1, direction: "out", size: { type: "fixed", size: 16 }, required: true }, ], 84: [ - { argIndex: 1, direction: "in", size: { type: "fixed", size: 16 } }, + { argIndex: 1, direction: "in", size: { type: "fixed", size: 16 }, required: true }, ], 85: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, ], 93: [ - { argIndex: 1, direction: "in", size: { type: "cstring" } }, - { argIndex: 2, direction: "out", size: { type: "fixed", size: 88 } }, + { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 2, direction: "out", size: { type: "fixed", size: 112 }, required: true }, ], 94: [ - { argIndex: 1, direction: "in", size: { type: "cstring" } }, + { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, ], 95: [ - { argIndex: 1, direction: "in", size: { type: "cstring" } }, + { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, ], 96: [ - { argIndex: 1, direction: "in", size: { type: "cstring" } }, - { argIndex: 3, direction: "in", size: { type: "cstring" } }, + { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 3, direction: "in", size: { type: "cstring" }, required: true }, ], 97: [ - { argIndex: 1, direction: "in", size: { type: "cstring" } }, + { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, ], 98: [ - { argIndex: 1, direction: "in", size: { type: "cstring" } }, + { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, ], 99: [ - { argIndex: 1, direction: "in", size: { type: "cstring" } }, + { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, ], 100: [ - { argIndex: 1, direction: "in", size: { type: "cstring" } }, - { argIndex: 3, direction: "in", size: { type: "cstring" } }, + { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 3, direction: "in", size: { type: "cstring" }, required: true }, ], 101: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, - { argIndex: 2, direction: "in", size: { type: "cstring" } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 2, direction: "in", size: { type: "cstring" }, required: true }, ], 102: [ - { argIndex: 1, direction: "in", size: { type: "cstring" } }, - { argIndex: 2, direction: "out", size: { type: "arg", argIndex: 3 } }, + { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 2, direction: "out", size: { type: "arg", argIndex: 3 }, required: true }, ], 108: [ { argIndex: 1, direction: "out", size: { type: "fixed", size: 144 }, required: true }, ], 109: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, - { argIndex: 1, direction: "out", size: { type: "arg", argIndex: 2 } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 1, direction: "out", size: { type: "arg", argIndex: 2 }, required: true }, ], 110: [ - { argIndex: 0, direction: "in", size: { type: "fixed", size: 8 } }, + { argIndex: 0, direction: "in", size: { type: "fixed", size: 8 }, required: true }, ], 112: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, { argIndex: 2, direction: "out", size: { type: "fixed", size: 8 }, required: true }, ], 113: [ { argIndex: 2, direction: "out", size: { type: "fixed", size: 8 }, required: true }, ], 114: [ - { argIndex: 1, direction: "out", size: { type: "deref", argIndex: 2 } }, - { argIndex: 2, direction: "inout", size: { type: "fixed", size: 4 } }, + { argIndex: 1, direction: "out", size: { type: "deref", argIndex: 2 }, required: true }, + { argIndex: 2, direction: "inout", size: { type: "fixed", size: 4 }, required: true }, ], 115: [ - { argIndex: 1, direction: "out", size: { type: "deref", argIndex: 2 } }, - { argIndex: 2, direction: "inout", size: { type: "fixed", size: 4 } }, + { argIndex: 1, direction: "out", size: { type: "deref", argIndex: 2 }, required: true }, + { argIndex: 2, direction: "inout", size: { type: "fixed", size: 4 }, required: true }, ], 119: [ - { argIndex: 3, direction: "out", size: { type: "fixed", size: 8 } }, + { argIndex: 3, direction: "out", size: { type: "fixed", size: 8 }, required: true }, ], 120: [ - { argIndex: 0, direction: "out", size: { type: "arg", argIndex: 1 } }, + { argIndex: 0, direction: "out", size: { type: "arg", argIndex: 1 }, required: true }, ], 122: [ - { argIndex: 1, direction: "out", size: { type: "arg", argIndex: 2 } }, + { argIndex: 1, direction: "out", size: { type: "arg", argIndex: 2 }, required: true }, ], 123: [ - { argIndex: 1, direction: "out", size: { type: "fixed", size: 16 } }, + { argIndex: 1, direction: "out", size: { type: "fixed", size: 16 }, nullable: true }, ], 124: [ - { argIndex: 2, direction: "in", size: { type: "fixed", size: 16 } }, + { argIndex: 2, direction: "in", size: { type: "fixed", size: 16 }, required: true }, ], 125: [ { argIndex: 1, direction: "in", size: { type: "cstring" }, nullable: true }, - { argIndex: 2, direction: "in", size: { type: "fixed", size: 32 } }, + { argIndex: 2, direction: "in", size: { type: "fixed", size: 32 }, nullable: true }, ], 129: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, - { argIndex: 2, direction: "out", size: { type: "fixed", size: 72 } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 2, direction: "out", size: { type: "process-layout", wasm32Size: 88, wasm64Size: 120 }, required: true }, ], 130: [ - { argIndex: 2, direction: "out", size: { type: "fixed", size: 72 } }, + { argIndex: 2, direction: "out", size: { type: "process-layout", wasm32Size: 88, wasm64Size: 120 }, required: true }, ], 132: [ - { argIndex: 0, direction: "out", size: { type: "fixed", size: 4 } }, - { argIndex: 1, direction: "out", size: { type: "fixed", size: 4 } }, - { argIndex: 2, direction: "out", size: { type: "fixed", size: 4 } }, + { argIndex: 0, direction: "out", size: { type: "fixed", size: 4 }, required: true }, + { argIndex: 1, direction: "out", size: { type: "fixed", size: 4 }, required: true }, + { argIndex: 2, direction: "out", size: { type: "fixed", size: 4 }, required: true }, ], 134: [ - { argIndex: 0, direction: "out", size: { type: "fixed", size: 4 } }, - { argIndex: 1, direction: "out", size: { type: "fixed", size: 4 } }, - { argIndex: 2, direction: "out", size: { type: "fixed", size: 4 } }, - ], - 137: [ - { argIndex: 1, direction: "in", size: { type: "arg", argIndex: 2 } }, + { argIndex: 0, direction: "out", size: { type: "fixed", size: 4 }, required: true }, + { argIndex: 1, direction: "out", size: { type: "fixed", size: 4 }, required: true }, + { argIndex: 2, direction: "out", size: { type: "fixed", size: 4 }, required: true }, ], - 138: [ - { argIndex: 1, direction: "inout", size: { type: "arg", argIndex: 2 } }, + 136: [ + { argIndex: 1, direction: "in", size: { type: "arg", argIndex: 0, multiplier: 4 }, required: true }, ], 139: [ - { argIndex: 1, direction: "out", size: { type: "fixed", size: 4 } }, - { argIndex: 3, direction: "out", size: { type: "fixed", size: 144 } }, + { argIndex: 1, direction: "out", size: { type: "fixed", size: 4 }, nullable: true }, + { argIndex: 3, direction: "out", size: { type: "fixed", size: 144 }, nullable: true }, ], 140: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, - { argIndex: 1, direction: "out", size: { type: "fixed", size: 256 } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 1, direction: "out", size: { type: "fixed", size: 4 }, required: true }, ], 205: [ - { argIndex: 2, direction: "in", size: { type: "fixed", size: 128 } }, + { argIndex: 2, direction: "in", size: { type: "process-layout", wasm32Size: 128, wasm64Size: 128 }, required: true }, ], 206: [ - { argIndex: 0, direction: "out", size: { type: "fixed", size: 8 } }, + { argIndex: 0, direction: "out", size: { type: "fixed", size: 8 }, required: true }, ], 207: [ - { argIndex: 0, direction: "in", size: { type: "fixed", size: 8 } }, - { argIndex: 1, direction: "out", size: { type: "fixed", size: 128 } }, - { argIndex: 2, direction: "in", size: { type: "fixed", size: 16 } }, + { argIndex: 0, direction: "in", size: { type: "fixed", size: 8 }, required: true }, + { argIndex: 1, direction: "out", size: { type: "process-layout", wasm32Size: 128, wasm64Size: 128 }, nullable: true }, + { argIndex: 2, direction: "in", size: { type: "fixed", size: 16 }, nullable: true }, ], 209: [ - { argIndex: 0, direction: "in", size: { type: "fixed", size: 12 } }, - { argIndex: 1, direction: "out", size: { type: "fixed", size: 12 } }, + { argIndex: 0, direction: "in", size: { type: "process-layout", wasm32Size: 12, wasm64Size: 24 }, nullable: true }, + { argIndex: 1, direction: "out", size: { type: "process-layout", wasm32Size: 12, wasm64Size: 24 }, nullable: true }, ], 211: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, - ], - 223: [ - { argIndex: 1, direction: "inout", size: { type: "fixed", size: 16 } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, ], 224: [ - { argIndex: 1, direction: "out", size: { type: "fixed", size: 16 } }, + { argIndex: 1, direction: "out", size: { type: "process-layout", wasm32Size: 16, wasm64Size: 32 }, required: true }, ], 225: [ - { argIndex: 1, direction: "in", size: { type: "fixed", size: 16 } }, - { argIndex: 2, direction: "out", size: { type: "fixed", size: 16 } }, + { argIndex: 1, direction: "in", size: { type: "process-layout", wasm32Size: 16, wasm64Size: 32 }, required: true }, + { argIndex: 2, direction: "out", size: { type: "process-layout", wasm32Size: 16, wasm64Size: 32 }, nullable: true }, ], 230: [ - { argIndex: 1, direction: "out", size: { type: "fixed", size: 36 } }, + { argIndex: 1, direction: "out", size: { type: "fixed", size: 48 }, required: true }, + ], + 231: [ + { argIndex: 1, direction: "in", size: { type: "fixed", size: 48 }, required: true }, + ], + 233: [ + { argIndex: 2, direction: "in", size: { type: "fixed", size: 48 }, required: true }, ], 236: [ - { argIndex: 1, direction: "out", size: { type: "fixed", size: 16 } }, + { argIndex: 1, direction: "out", size: { type: "fixed", size: 16 }, required: true }, ], 238: [ { argIndex: 2, direction: "out", size: { type: "fixed", size: 4 }, required: true }, ], + 244: [ + { argIndex: 2, direction: "in", size: { type: "fixed", size: 32 }, required: true }, + { argIndex: 3, direction: "out", size: { type: "fixed", size: 32 }, nullable: true }, + ], + 245: [ + { argIndex: 1, direction: "out", size: { type: "fixed", size: 32 }, required: true }, + ], + 246: [ + { argIndex: 1, direction: "in", size: { type: "fixed", size: 8 }, required: true }, + ], 250: [ - { argIndex: 2, direction: "in", size: { type: "fixed", size: 16 } }, - { argIndex: 3, direction: "out", size: { type: "fixed", size: 16 } }, + { argIndex: 2, direction: "in", size: { type: "fixed", size: 16 }, nullable: true }, + { argIndex: 3, direction: "out", size: { type: "fixed", size: 16 }, nullable: true }, ], 251: [ - { argIndex: 0, direction: "inout", size: { type: "arg", argIndex: 1, multiplier: 8 } }, + { argIndex: 0, direction: "inout", size: { type: "arg", argIndex: 1, multiplier: 8 }, required: true }, + ], + 256: [ + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, ], 260: [ - { argIndex: 1, direction: "in", size: { type: "cstring" } }, - { argIndex: 4, direction: "out", size: { type: "fixed", size: 256 } }, + { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 4, direction: "out", size: { type: "fixed", size: 256 }, required: true }, + ], + 269: [ + { argIndex: 0, direction: "out", size: { type: "process-layout", wasm32Size: 312, wasm64Size: 368 }, required: true }, ], 271: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, ], 272: [ - { argIndex: 1, direction: "in", size: { type: "cstring" } }, + { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, ], 288: [ - { argIndex: 2, direction: "out", size: { type: "fixed", size: 128 }, required: true }, + { argIndex: 2, direction: "out", size: { type: "process-layout", wasm32Size: 128, wasm64Size: 128 }, required: true }, { argIndex: 4, direction: "out", size: { type: "fixed", size: 144 }, nullable: true }, ], + 290: [ + { argIndex: 1, direction: "inout", size: { type: "fixed", size: 8 }, nullable: true }, + { argIndex: 3, direction: "inout", size: { type: "fixed", size: 8 }, nullable: true }, + ], + 291: [ + { argIndex: 1, direction: "inout", size: { type: "fixed", size: 8 }, nullable: true }, + { argIndex: 3, direction: "inout", size: { type: "fixed", size: 8 }, nullable: true }, + ], + 294: [ + { argIndex: 2, direction: "inout", size: { type: "fixed", size: 8 }, nullable: true }, + ], 299: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + ], + 306: [ + { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 3, direction: "in", size: { type: "cstring" }, required: true }, + ], + 325: [ + { argIndex: 0, direction: "out", size: { type: "fixed", size: 4 }, nullable: true }, + { argIndex: 1, direction: "out", size: { type: "fixed", size: 4 }, nullable: true }, ], 326: [ - { argIndex: 1, direction: "in", size: { type: "fixed", size: 16 } }, - { argIndex: 2, direction: "out", size: { type: "fixed", size: 4 } }, + { argIndex: 1, direction: "in", size: { type: "process-layout", wasm32Size: 64, wasm64Size: 64 }, nullable: true }, + { argIndex: 2, direction: "out", size: { type: "fixed", size: 4 }, required: true }, ], 327: [ - { argIndex: 2, direction: "in", size: { type: "fixed", size: 32 } }, - { argIndex: 3, direction: "out", size: { type: "fixed", size: 32 } }, + { argIndex: 2, direction: "in", size: { type: "fixed", size: 32 }, required: true }, + { argIndex: 3, direction: "out", size: { type: "fixed", size: 32 }, nullable: true }, ], 328: [ - { argIndex: 1, direction: "out", size: { type: "fixed", size: 32 } }, + { argIndex: 1, direction: "out", size: { type: "fixed", size: 32 }, required: true }, ], 331: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, - { argIndex: 3, direction: "in", size: { type: "fixed", size: 32 } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 3, direction: "in", size: { type: "process-layout", wasm32Size: 32, wasm64Size: 64 }, nullable: true }, ], 332: [ - { argIndex: 0, direction: "in", size: { type: "cstring" } }, + { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, ], 333: [ - { argIndex: 1, direction: "in", size: { type: "arg", argIndex: 2 } }, - { argIndex: 4, direction: "in", size: { type: "fixed", size: 16 } }, + { argIndex: 1, direction: "in", size: { type: "arg", argIndex: 2 }, required: true }, + { argIndex: 4, direction: "in", size: { type: "fixed", size: 16 }, nullable: true }, ], 334: [ - { argIndex: 1, direction: "out", size: { type: "arg", argIndex: 2 } }, - { argIndex: 3, direction: "out", size: { type: "fixed", size: 4 } }, - { argIndex: 4, direction: "in", size: { type: "fixed", size: 16 } }, + { argIndex: 1, direction: "out", size: { type: "arg", argIndex: 2 }, required: true }, + { argIndex: 3, direction: "out", size: { type: "fixed", size: 4 }, nullable: true }, + { argIndex: 4, direction: "in", size: { type: "fixed", size: 16 }, nullable: true }, ], 335: [ - { argIndex: 1, direction: "in", size: { type: "fixed", size: 16 } }, + { argIndex: 1, direction: "in", size: { type: "process-layout", wasm32Size: 64, wasm64Size: 64 }, nullable: true }, ], 336: [ - { argIndex: 1, direction: "in", size: { type: "fixed", size: 32 } }, - { argIndex: 2, direction: "out", size: { type: "fixed", size: 32 } }, - ], - 338: [ - { argIndex: 1, direction: "out", size: { type: "arg", argIndex: 2, add: 4 }, copyRetvalAdd: 4 }, - ], - 339: [ - { argIndex: 1, direction: "in", size: { type: "arg", argIndex: 2, add: 4 } }, - ], - 340: [ - { argIndex: 2, direction: "inout", size: { type: "fixed", size: 96 } }, + { argIndex: 1, direction: "in", size: { type: "process-layout", wasm32Size: 32, wasm64Size: 64 }, nullable: true }, + { argIndex: 2, direction: "out", size: { type: "process-layout", wasm32Size: 32, wasm64Size: 64 }, nullable: true }, ], 342: [ - { argIndex: 1, direction: "in", size: { type: "arg", argIndex: 2, multiplier: 6 } }, + { argIndex: 1, direction: "in", size: { type: "arg", argIndex: 2, multiplier: 6 }, required: true }, ], - 347: [ - { argIndex: 2, direction: "inout", size: { type: "fixed", size: 88 } }, + 377: [ + { argIndex: 1, direction: "in", size: { type: "fixed", size: 8 }, required: true }, ], 382: [ - { argIndex: 1, direction: "in", size: { type: "cstring" } }, + { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, ], 383: [ - { argIndex: 1, direction: "in", size: { type: "cstring" } }, + { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, ], 384: [ - { argIndex: 1, direction: "out", size: { type: "deref", argIndex: 2 } }, - { argIndex: 2, direction: "inout", size: { type: "fixed", size: 4 } }, + { argIndex: 1, direction: "out", size: { type: "deref", argIndex: 2 }, nullable: true }, + { argIndex: 2, direction: "inout", size: { type: "fixed", size: 4 }, nullable: true }, ], }; diff --git a/host/src/kernel-scratch.ts b/host/src/kernel-scratch.ts new file mode 100644 index 0000000000..ac5f6b855a --- /dev/null +++ b/host/src/kernel-scratch.ts @@ -0,0 +1,2238 @@ +/** + * Capacity-carrying views of kernel-owned WebAssembly scratch allocations. + * + * A pointer being inside WebAssembly.Memory proves only that the host can + * address those bytes. It does not prove that the allocator gave those bytes + * to this caller. Keep the allocation's capacity beside its pointer and check + * both facts independently for every transfer. + */ + +import { + checkedWasmGuestPointerOffset, +} from "./wasm-guest-pointer"; + +export type WasmPointer = number | bigint; +export type WasmPointerWidth = 4 | 8; + +const WASM32_MAX_POINTER = 0xffff_ffff; +const HOST_MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER; +// WHY: scratch leases can cross arbitrary host callbacks even though they +// cannot cross an await. Capture every byte-access intrinsic before those +// callbacks can replace a prototype method and turn a detached copy back into +// a live kernel-memory alias. +const IntrinsicUint8Array = Uint8Array; +const IntrinsicDataView = DataView; +const IntrinsicBigInt = BigInt; +const IntrinsicNumber = Number; +const intrinsicApply = Reflect.apply; +const intrinsicMathFloor = Math.floor; +const intrinsicNumberIsInteger = Number.isInteger; +const intrinsicNumberIsSafeInteger = Number.isSafeInteger; +const intrinsicUint8ArraySet = IntrinsicUint8Array.prototype.set; +const intrinsicUint8ArrayFill = IntrinsicUint8Array.prototype.fill; +const intrinsicObjectCreate = Object.create; +const intrinsicObjectDefineProperty = Object.defineProperty; +const intrinsicObjectFreeze = Object.freeze; +const intrinsicObjectGetOwnPropertyDescriptor = + Object.getOwnPropertyDescriptor; +const intrinsicWeakMapGet = WeakMap.prototype.get; +const intrinsicWeakMapSet = WeakMap.prototype.set; +const intrinsicInstanceExports = intrinsicObjectGetOwnPropertyDescriptor( + WebAssembly.Instance.prototype, + "exports", +)!.get!; +const intrinsicMemoryBuffer = intrinsicObjectGetOwnPropertyDescriptor( + WebAssembly.Memory.prototype, + "buffer", +)!.get!; +const intrinsicArrayBufferByteLength = + intrinsicObjectGetOwnPropertyDescriptor( + ArrayBuffer.prototype, + "byteLength", + )!.get!; +const intrinsicSharedArrayBufferByteLength = + typeof SharedArrayBuffer === "undefined" + ? null + : intrinsicObjectGetOwnPropertyDescriptor( + SharedArrayBuffer.prototype, + "byteLength", + )!.get!; +const intrinsicDataViewPrototype = IntrinsicDataView.prototype; +const intrinsicDataViewByteLength = intrinsicObjectGetOwnPropertyDescriptor( + intrinsicDataViewPrototype, + "byteLength", +)!.get!; +const intrinsicDataViewGetBigInt64 = intrinsicDataViewPrototype.getBigInt64; +const intrinsicDataViewGetBigUint64 = intrinsicDataViewPrototype.getBigUint64; +const intrinsicDataViewGetFloat32 = intrinsicDataViewPrototype.getFloat32; +const intrinsicDataViewGetFloat64 = intrinsicDataViewPrototype.getFloat64; +const intrinsicDataViewGetInt8 = intrinsicDataViewPrototype.getInt8; +const intrinsicDataViewGetInt16 = intrinsicDataViewPrototype.getInt16; +const intrinsicDataViewGetInt32 = intrinsicDataViewPrototype.getInt32; +const intrinsicDataViewGetUint8 = intrinsicDataViewPrototype.getUint8; +const intrinsicDataViewGetUint16 = intrinsicDataViewPrototype.getUint16; +const intrinsicDataViewGetUint32 = intrinsicDataViewPrototype.getUint32; +const intrinsicDataViewSetBigInt64 = intrinsicDataViewPrototype.setBigInt64; +const intrinsicDataViewSetBigUint64 = intrinsicDataViewPrototype.setBigUint64; +const intrinsicDataViewSetFloat32 = intrinsicDataViewPrototype.setFloat32; +const intrinsicDataViewSetFloat64 = intrinsicDataViewPrototype.setFloat64; +const intrinsicDataViewSetInt8 = intrinsicDataViewPrototype.setInt8; +const intrinsicDataViewSetInt16 = intrinsicDataViewPrototype.setInt16; +const intrinsicDataViewSetInt32 = intrinsicDataViewPrototype.setInt32; +const intrinsicDataViewSetUint8 = intrinsicDataViewPrototype.setUint8; +const intrinsicDataViewSetUint16 = intrinsicDataViewPrototype.setUint16; +const intrinsicDataViewSetUint32 = intrinsicDataViewPrototype.setUint32; +const typedArrayPrototype = Object.getPrototypeOf( + IntrinsicUint8Array.prototype, +); +const typedArrayBuffer = intrinsicObjectGetOwnPropertyDescriptor( + typedArrayPrototype, + "buffer", +)!.get!; +const typedArrayByteOffset = intrinsicObjectGetOwnPropertyDescriptor( + typedArrayPrototype, + "byteOffset", +)!.get!; +const typedArrayByteLength = intrinsicObjectGetOwnPropertyDescriptor( + typedArrayPrototype, + "byteLength", +)!.get!; + +/** + * Kernel exports whose raw pointer arguments may name a scratch lease. + * + * WHY: this is deliberately a narrow lifetime allowlist, not a list of every + * kernel export. Each Rust implementation was reviewed to consume or copy its + * borrowed bytes before returning. `kernel_handle_channel` scopes its raw + * mailbox view to decoding/publishing and clears the active task binding; + * `kernel_spawn_process` parses the complete blob into owned Rust values + * before it enters process-table or host work. Adding a name requires the same + * lifetime review and a pointer-position update below. + */ +const KERNEL_SCRATCH_EXPORT_NAMES = intrinsicObjectFreeze([ + "kernel_dequeue_signal", + "kernel_drain_audio", + "kernel_drain_wakeup_events", + "kernel_enum_procs", + "kernel_get_cwd", + "kernel_get_fd_path", + "kernel_getrusage", + "kernel_getsockopt", + "kernel_handle_channel", + "kernel_inject_datagram", + "kernel_ioctl", + "kernel_ipc_shm_read_chunk", + "kernel_ipc_shm_write_chunk", + "kernel_mq_drain_notification", + "kernel_pipe2", + "kernel_pipe_read", + "kernel_pipe_write", + "kernel_poll", + "kernel_pty_master_read", + "kernel_pty_master_write", + "kernel_push_process_metadata_entry", + "kernel_read_proc_maps", + "kernel_recv", + "kernel_select", + "kernel_send", + "kernel_set_cwd", + "kernel_socketpair", + "kernel_spawn_process", + "kernel_tcgetattr", + "kernel_tcsetattr", + "kernel_truncate", + "kernel_uname", + "kernel_wait_child_poll", +] as const); + +export type KernelScratchExportName = + (typeof KERNEL_SCRATCH_EXPORT_NAMES)[number]; + +declare const kernelScratchExportPointerBrand: unique symbol; + +/** + * Opaque relative range substituted with a primitive pointer only inside the + * exact bound WebAssembly export call. + */ +export interface KernelScratchExportPointer { + readonly [kernelScratchExportPointerBrand]: never; +} + +interface KernelScratchExportPointerRecord { + readonly lease: ActiveKernelScratchLease; + readonly offset: number; + readonly length: number; +} + +const kernelScratchExportPointers = new WeakMap< + object, + KernelScratchExportPointerRecord +>(); + +type KernelScratchExportFunction = (...args: never[]) => unknown; +interface KernelScratchExportBinding { + readonly call: KernelScratchExportFunction; + readonly argumentCount: number; +} +type KernelScratchExportSnapshot = Readonly< + Partial> +>; + +const REQUIRED_POINTER_0 = intrinsicObjectFreeze([0] as const); +const REQUIRED_POINTER_1 = intrinsicObjectFreeze([1] as const); +const REQUIRED_POINTER_2 = intrinsicObjectFreeze([2] as const); +const REQUIRED_POINTER_3 = intrinsicObjectFreeze([3] as const); +const REQUIRED_POINTER_3_5 = intrinsicObjectFreeze([3, 5] as const); +const REQUIRED_POINTER_5 = intrinsicObjectFreeze([5] as const); +const REQUIRED_POINTER_11 = intrinsicObjectFreeze([11] as const); +const NULLABLE_POINTER_1_3_5 = intrinsicObjectFreeze([1, 3, 5] as const); + +function kernelScratchRequiredPointerArguments( + name: KernelScratchExportName, +): readonly number[] { + switch (name) { + case "kernel_drain_audio": + case "kernel_drain_wakeup_events": + case "kernel_enum_procs": + case "kernel_handle_channel": + case "kernel_mq_drain_notification": + case "kernel_poll": + case "kernel_truncate": + case "kernel_uname": + return REQUIRED_POINTER_0; + case "kernel_get_cwd": + case "kernel_getrusage": + case "kernel_pipe2": + case "kernel_pty_master_read": + case "kernel_pty_master_write": + case "kernel_read_proc_maps": + case "kernel_recv": + case "kernel_send": + case "kernel_set_cwd": + case "kernel_tcgetattr": + return REQUIRED_POINTER_1; + case "kernel_dequeue_signal": + case "kernel_get_fd_path": + case "kernel_ioctl": + case "kernel_ipc_shm_read_chunk": + case "kernel_ipc_shm_write_chunk": + case "kernel_pipe_read": + case "kernel_pipe_write": + case "kernel_push_process_metadata_entry": + case "kernel_spawn_process": + case "kernel_tcsetattr": + return REQUIRED_POINTER_2; + case "kernel_socketpair": + return REQUIRED_POINTER_3; + case "kernel_getsockopt": + return REQUIRED_POINTER_3_5; + case "kernel_wait_child_poll": + return REQUIRED_POINTER_5; + case "kernel_inject_datagram": + return REQUIRED_POINTER_11; + case "kernel_select": + return []; + } +} + +function kernelScratchNullablePointerArguments( + name: KernelScratchExportName, +): readonly number[] { + return name === "kernel_select" ? NULLABLE_POINTER_1_3_5 : []; +} + +function kernelScratchPointerAlignment( + name: KernelScratchExportName, + pointerIndex: number, +): number { + if ( + (name === "kernel_pipe2" && pointerIndex === 1) + || (name === "kernel_poll" && pointerIndex === 0) + || (name === "kernel_socketpair" && pointerIndex === 3) + ) { + return 4; + } + return 1; +} + +function isKernelScratchExportName( + value: string, +): value is KernelScratchExportName { + switch (value) { + case "kernel_dequeue_signal": + case "kernel_drain_audio": + case "kernel_drain_wakeup_events": + case "kernel_enum_procs": + case "kernel_get_cwd": + case "kernel_get_fd_path": + case "kernel_getrusage": + case "kernel_getsockopt": + case "kernel_handle_channel": + case "kernel_inject_datagram": + case "kernel_ioctl": + case "kernel_ipc_shm_read_chunk": + case "kernel_ipc_shm_write_chunk": + case "kernel_mq_drain_notification": + case "kernel_pipe2": + case "kernel_pipe_read": + case "kernel_pipe_write": + case "kernel_poll": + case "kernel_pty_master_read": + case "kernel_pty_master_write": + case "kernel_push_process_metadata_entry": + case "kernel_read_proc_maps": + case "kernel_recv": + case "kernel_select": + case "kernel_send": + case "kernel_set_cwd": + case "kernel_socketpair": + case "kernel_spawn_process": + case "kernel_tcgetattr": + case "kernel_tcsetattr": + case "kernel_truncate": + case "kernel_uname": + case "kernel_wait_child_poll": + return true; + default: + return false; + } +} + +function snapshotKernelScratchExports( + instance: WebAssembly.Instance, + memory: WebAssembly.Memory, + label: string, + expectedAllocator?: KernelScratchAllocator, +): KernelScratchExportSnapshot { + let exports: WebAssembly.Exports; + try { + // WHY: structural objects with an `exports` field are not sufficient. + // Calling the captured intrinsic getter proves the receiver is a genuine + // WebAssembly.Instance before any allocator or host callback can run. + exports = intrinsicApply( + intrinsicInstanceExports, + instance, + [], + ) as WebAssembly.Exports; + } catch { + throw new KernelScratchError( + `${label} export binding is not a genuine WebAssembly.Instance`, + ); + } + if (exports.memory !== memory) { + throw new KernelScratchError( + `${label} export binding does not own the supplied WebAssembly.Memory`, + ); + } + if ( + expectedAllocator !== undefined + && exports.kernel_alloc_scratch !== expectedAllocator + ) { + throw new KernelScratchError( + `${label} allocator is not the bound instance's kernel allocator`, + ); + } + const snapshot = intrinsicObjectCreate(null) as Partial< + Record + >; + for (let index = 0; index < KERNEL_SCRATCH_EXPORT_NAMES.length; index++) { + const name = KERNEL_SCRATCH_EXPORT_NAMES[index]; + const value = exports[name]; + if (typeof value === "function") { + const lengthDescriptor = intrinsicObjectGetOwnPropertyDescriptor( + value, + "length", + ); + const argumentCount = lengthDescriptor?.value; + if ( + typeof argumentCount !== "number" + || !intrinsicNumberIsSafeInteger(argumentCount) + || argumentCount < 0 + ) { + throw new KernelScratchError( + `${label} kernel export ${name} has an invalid Wasm arity`, + ); + } + snapshot[name] = intrinsicObjectFreeze({ + call: value as KernelScratchExportFunction, + argumentCount, + }); + } + } + return intrinsicObjectFreeze(snapshot); +} + +export class KernelScratchError extends Error { + constructor( + message: string, + readonly errno = 14, + ) { + super(message); + this.name = "KernelScratchError"; + } +} + +function intrinsicWasmMemoryBuffer( + memory: WebAssembly.Memory, + field: string, +): ArrayBufferLike { + try { + // WHY: `Memory.prototype.buffer` is mutable JavaScript state. Invoke the + // captured intrinsic getter both to prove that `memory` is genuine and to + // prevent a callback from substituting a larger fake buffer for the + // current-memory bound. + return intrinsicApply( + intrinsicMemoryBuffer, + memory, + [], + ) as ArrayBufferLike; + } catch { + throw new KernelScratchError( + `${field} does not use a genuine WebAssembly.Memory`, + ); + } +} + +function intrinsicBufferByteLength( + buffer: ArrayBufferLike, + field: string, +): number { + try { + return intrinsicApply( + intrinsicArrayBufferByteLength, + buffer, + [], + ) as number; + } catch { + if (intrinsicSharedArrayBufferByteLength !== null) { + try { + return intrinsicApply( + intrinsicSharedArrayBufferByteLength, + buffer, + [], + ) as number; + } catch { + // Fall through to the one checked error below. + } + } + throw new KernelScratchError( + `${field} WebAssembly.Memory has an invalid buffer`, + ); + } +} + +export interface CheckedMemoryRange { + pointer: number; + length: number; + end: number; +} + +function intrinsicUint8ArraySpan( + value: Uint8Array, + field: string, +): { + buffer: ArrayBufferLike; + byteOffset: number; + byteLength: number; +} { + try { + return { + buffer: intrinsicApply( + typedArrayBuffer, + value, + [], + ) as ArrayBufferLike, + byteOffset: intrinsicApply( + typedArrayByteOffset, + value, + [], + ) as number, + byteLength: intrinsicApply( + typedArrayByteLength, + value, + [], + ) as number, + }; + } catch { + throw new KernelScratchError(`${field} is not a genuine Uint8Array`); + } +} + +/** + * Return a base-class view over the exact intrinsic bytes of a Uint8Array. + * + * WHY: a subclass can override `byteLength`, `length`, or `subarray` while + * native TypedArray#set still consumes its real internal span. Producers at a + * host boundary must therefore be normalized before their size is trusted or + * their bytes are copied into an owned kernel allocation. + */ +export function intrinsicUint8ArrayView( + value: Uint8Array, + field: string, +): Uint8Array { + const span = intrinsicUint8ArraySpan(value, field); + return new IntrinsicUint8Array( + span.buffer, + span.byteOffset, + span.byteLength, + ); +} + +export interface KernelScratchDataView { + readonly byteLength: number; + getBigInt64(byteOffset: number, littleEndian?: boolean): bigint; + getBigUint64(byteOffset: number, littleEndian?: boolean): bigint; + getFloat32(byteOffset: number, littleEndian?: boolean): number; + getFloat64(byteOffset: number, littleEndian?: boolean): number; + getInt8(byteOffset: number): number; + getInt16(byteOffset: number, littleEndian?: boolean): number; + getInt32(byteOffset: number, littleEndian?: boolean): number; + getUint8(byteOffset: number): number; + getUint16(byteOffset: number, littleEndian?: boolean): number; + getUint32(byteOffset: number, littleEndian?: boolean): number; + setBigInt64( + byteOffset: number, + value: bigint, + littleEndian?: boolean, + ): void; + setBigUint64( + byteOffset: number, + value: bigint, + littleEndian?: boolean, + ): void; + setFloat32( + byteOffset: number, + value: number, + littleEndian?: boolean, + ): void; + setFloat64( + byteOffset: number, + value: number, + littleEndian?: boolean, + ): void; + setInt8(byteOffset: number, value: number): void; + setInt16( + byteOffset: number, + value: number, + littleEndian?: boolean, + ): void; + setInt32( + byteOffset: number, + value: number, + littleEndian?: boolean, + ): void; + setUint8(byteOffset: number, value: number): void; + setUint16( + byteOffset: number, + value: number, + littleEndian?: boolean, + ): void; + setUint32( + byteOffset: number, + value: number, + littleEndian?: boolean, + ): void; +} + +/** + * DataView-shaped access that remains tied to one active scratch lease. + * + * A native DataView cannot be revoked after it escapes a callback, so it stays + * private behind methods that assert the lease on every access. Reuse is safe + * only while WebAssembly.Memory exposes the same buffer; memory.grow() replaces + * that buffer and forces a checked refresh before the next access. + */ +const activeKernelScratchDataViewConstructorKey = + intrinsicObjectFreeze(intrinsicObjectCreate(null) as object); + +class ActiveKernelScratchDataView implements KernelScratchDataView { + #activeMemoryBuffer: () => ArrayBufferLike; + #assertReadable: (byteOffset: number, byteLength: number) => void; + #assertWritable: (byteOffset: number, byteLength: number) => void; + #refreshView: () => { + buffer: ArrayBufferLike; + view: DataView; + }; + #cachedBuffer: ArrayBufferLike; + #cachedView: DataView; + + constructor( + constructorKey: object, + activeMemoryBuffer: () => ArrayBufferLike, + assertReadable: ( + byteOffset: number, + byteLength: number, + ) => void, + assertWritable: ( + byteOffset: number, + byteLength: number, + ) => void, + refreshView: () => { + buffer: ArrayBufferLike; + view: DataView; + }, + ) { + if (constructorKey !== activeKernelScratchDataViewConstructorKey) { + throw new KernelScratchError( + "kernel scratch DataView cannot be constructed outside its lease", + ); + } + this.#activeMemoryBuffer = activeMemoryBuffer; + this.#assertReadable = assertReadable; + this.#assertWritable = assertWritable; + this.#refreshView = refreshView; + const initial = refreshView(); + this.#cachedBuffer = initial.buffer; + this.#cachedView = initial.view; + intrinsicObjectFreeze(this); + } + + #currentView(): DataView { + // WHY: checking the lease even on a cache hit is what makes an escaped + // wrapper revocable. Returning the cached native view directly would let + // callers use scratch bytes after a later operation had replaced them. + const buffer = this.#activeMemoryBuffer(); + if (buffer !== this.#cachedBuffer) { + // WHY: WebAssembly memory growth replaces the exposed buffer. Repeat the + // full allocation-capacity and current-memory proof before caching a view + // over the replacement instead of assuming total memory size is enough. + const refreshed = this.#refreshView(); + this.#cachedBuffer = refreshed.buffer; + this.#cachedView = refreshed.view; + } + return this.#cachedView; + } + + #readableView(byteOffset: number, byteLength: number): DataView { + this.#assertReadable(byteOffset, byteLength); + return this.#currentView(); + } + + #writableView(byteOffset: number, byteLength: number): DataView { + this.#assertWritable(byteOffset, byteLength); + return this.#currentView(); + } + + get byteLength(): number { + return intrinsicApply( + intrinsicDataViewByteLength, + this.#currentView(), + [], + ) as number; + } + + getBigInt64(byteOffset: number, littleEndian?: boolean): bigint { + return intrinsicApply( + intrinsicDataViewGetBigInt64, + this.#readableView(byteOffset, 8), + [byteOffset, littleEndian], + ) as bigint; + } + + getBigUint64(byteOffset: number, littleEndian?: boolean): bigint { + return intrinsicApply( + intrinsicDataViewGetBigUint64, + this.#readableView(byteOffset, 8), + [byteOffset, littleEndian], + ) as bigint; + } + + getFloat32(byteOffset: number, littleEndian?: boolean): number { + return intrinsicApply( + intrinsicDataViewGetFloat32, + this.#readableView(byteOffset, 4), + [byteOffset, littleEndian], + ) as number; + } + + getFloat64(byteOffset: number, littleEndian?: boolean): number { + return intrinsicApply( + intrinsicDataViewGetFloat64, + this.#readableView(byteOffset, 8), + [byteOffset, littleEndian], + ) as number; + } + + getInt8(byteOffset: number): number { + return intrinsicApply( + intrinsicDataViewGetInt8, + this.#readableView(byteOffset, 1), + [byteOffset], + ) as number; + } + + getInt16(byteOffset: number, littleEndian?: boolean): number { + return intrinsicApply( + intrinsicDataViewGetInt16, + this.#readableView(byteOffset, 2), + [byteOffset, littleEndian], + ) as number; + } + + getInt32(byteOffset: number, littleEndian?: boolean): number { + return intrinsicApply( + intrinsicDataViewGetInt32, + this.#readableView(byteOffset, 4), + [byteOffset, littleEndian], + ) as number; + } + + getUint8(byteOffset: number): number { + return intrinsicApply( + intrinsicDataViewGetUint8, + this.#readableView(byteOffset, 1), + [byteOffset], + ) as number; + } + + getUint16(byteOffset: number, littleEndian?: boolean): number { + return intrinsicApply( + intrinsicDataViewGetUint16, + this.#readableView(byteOffset, 2), + [byteOffset, littleEndian], + ) as number; + } + + getUint32(byteOffset: number, littleEndian?: boolean): number { + return intrinsicApply( + intrinsicDataViewGetUint32, + this.#readableView(byteOffset, 4), + [byteOffset, littleEndian], + ) as number; + } + + setBigInt64( + byteOffset: number, + value: bigint, + littleEndian?: boolean, + ): void { + if (typeof value !== "bigint") { + throw new KernelScratchError( + "kernel scratch DataView bigint value must be a primitive bigint", + ); + } + intrinsicApply( + intrinsicDataViewSetBigInt64, + this.#writableView(byteOffset, 8), + [byteOffset, value, littleEndian], + ); + } + + setBigUint64( + byteOffset: number, + value: bigint, + littleEndian?: boolean, + ): void { + if (typeof value !== "bigint") { + throw new KernelScratchError( + "kernel scratch DataView bigint value must be a primitive bigint", + ); + } + intrinsicApply( + intrinsicDataViewSetBigUint64, + this.#writableView(byteOffset, 8), + [byteOffset, value, littleEndian], + ); + } + + setFloat32( + byteOffset: number, + value: number, + littleEndian?: boolean, + ): void { + if (typeof value !== "number") { + throw new KernelScratchError( + "kernel scratch DataView number value must be a primitive number", + ); + } + intrinsicApply( + intrinsicDataViewSetFloat32, + this.#writableView(byteOffset, 4), + [byteOffset, value, littleEndian], + ); + } + + setFloat64( + byteOffset: number, + value: number, + littleEndian?: boolean, + ): void { + if (typeof value !== "number") { + throw new KernelScratchError( + "kernel scratch DataView number value must be a primitive number", + ); + } + intrinsicApply( + intrinsicDataViewSetFloat64, + this.#writableView(byteOffset, 8), + [byteOffset, value, littleEndian], + ); + } + + setInt8(byteOffset: number, value: number): void { + if (typeof value !== "number") { + throw new KernelScratchError( + "kernel scratch DataView number value must be a primitive number", + ); + } + intrinsicApply( + intrinsicDataViewSetInt8, + this.#writableView(byteOffset, 1), + [byteOffset, value], + ); + } + + setInt16( + byteOffset: number, + value: number, + littleEndian?: boolean, + ): void { + if (typeof value !== "number") { + throw new KernelScratchError( + "kernel scratch DataView number value must be a primitive number", + ); + } + intrinsicApply( + intrinsicDataViewSetInt16, + this.#writableView(byteOffset, 2), + [byteOffset, value, littleEndian], + ); + } + + setInt32( + byteOffset: number, + value: number, + littleEndian?: boolean, + ): void { + if (typeof value !== "number") { + throw new KernelScratchError( + "kernel scratch DataView number value must be a primitive number", + ); + } + intrinsicApply( + intrinsicDataViewSetInt32, + this.#writableView(byteOffset, 4), + [byteOffset, value, littleEndian], + ); + } + + setUint8(byteOffset: number, value: number): void { + if (typeof value !== "number") { + throw new KernelScratchError( + "kernel scratch DataView number value must be a primitive number", + ); + } + intrinsicApply( + intrinsicDataViewSetUint8, + this.#writableView(byteOffset, 1), + [byteOffset, value], + ); + } + + setUint16( + byteOffset: number, + value: number, + littleEndian?: boolean, + ): void { + if (typeof value !== "number") { + throw new KernelScratchError( + "kernel scratch DataView number value must be a primitive number", + ); + } + intrinsicApply( + intrinsicDataViewSetUint16, + this.#writableView(byteOffset, 2), + [byteOffset, value, littleEndian], + ); + } + + setUint32( + byteOffset: number, + value: number, + littleEndian?: boolean, + ): void { + if (typeof value !== "number") { + throw new KernelScratchError( + "kernel scratch DataView number value must be a primitive number", + ); + } + intrinsicApply( + intrinsicDataViewSetUint32, + this.#writableView(byteOffset, 4), + [byteOffset, value, littleEndian], + ); + } +} + +intrinsicObjectFreeze(ActiveKernelScratchDataView.prototype); +intrinsicObjectFreeze(ActiveKernelScratchDataView); + +function exactNonNegativeInteger( + value: number | bigint, + field: string, +): number { + if (typeof value === "bigint") { + if (value < 0n || value > IntrinsicBigInt(HOST_MAX_SAFE_INTEGER)) { + throw new KernelScratchError( + `${field} is not losslessly representable as a host memory index`, + ); + } + return IntrinsicNumber(value); + } + if (!intrinsicNumberIsSafeInteger(value) || value < 0) { + throw new KernelScratchError( + `${field} must be a non-negative safe integer`, + ); + } + return value; +} + +function exactPointer( + value: WasmPointer, + pointerWidth: WasmPointerWidth, + field: string, +): number { + if (pointerWidth !== 4 && pointerWidth !== 8) { + throw new KernelScratchError( + `${field} pointer width must be exactly 4 or 8`, + ); + } + const pointer = exactNonNegativeInteger(value, field); + if (pointerWidth === 4 && pointer > WASM32_MAX_POINTER) { + throw new KernelScratchError(`${field} does not fit a wasm32 pointer`); + } + return pointer; +} + +/** + * Normalize a raw `usize` returned by a kernel Wasm export. + * + * WebAssembly exposes an i32 result to JavaScript as a signed number even + * though a wasm32 pointer uses the same 32 bits as an unsigned address. Keep + * this normalization confined to allocator/export results: caller-supplied + * negative pointers remain invalid everywhere else. + */ +export function checkedKernelExportPointer( + value: WasmPointer, + pointerWidth: WasmPointerWidth, + field: string, +): number { + if (pointerWidth === 4 && typeof value === "number" && value < 0) { + if (!intrinsicNumberIsInteger(value) || value < -0x8000_0000) { + throw new KernelScratchError( + `${field} is not a valid wasm32 export result`, + ); + } + return exactPointer(value + 0x1_0000_0000, pointerWidth, field); + } + return exactPointer(value, pointerWidth, field); +} + +export function checkedWasmPointer( + value: WasmPointer, + pointerWidth: WasmPointerWidth, + field: string, +): number { + return exactPointer(value, pointerWidth, field); +} + +/** + * Validate a half-open address range against a guest pointer domain. + * + * This is deliberately separate from `checkedMemoryRange`: address-space + * reservations may precede `memory.grow`, while a host byte transfer must + * additionally fit the current Memory buffer. Length is pointer-sized because + * the kernel reservation ABI transports it as `usize`. + */ +export function checkedWasmAddressRange( + pointerValue: WasmPointer, + lengthValue: WasmPointer, + pointerWidth: WasmPointerWidth, + field: string, +): CheckedMemoryRange { + const pointer = checkedWasmPointer( + pointerValue, + pointerWidth, + `${field} pointer`, + ); + const length = checkedWasmPointer( + lengthValue, + pointerWidth, + `${field} length`, + ); + const end = pointer + length; + const exclusiveLimit = pointerWidth === 4 + ? 0x1_0000_0000 + : HOST_MAX_SAFE_INTEGER; + if ( + !intrinsicNumberIsSafeInteger(end) + || end < pointer + || end > exclusiveLimit + ) { + throw new KernelScratchError( + `${field} is outside the wasm${pointerWidth * 8} address range`, + ); + } + return { pointer, length, end }; +} + +function checkedRange( + pointer: number, + length: number, + limit: number, + field: string, +): CheckedMemoryRange { + if (!intrinsicNumberIsSafeInteger(limit) || limit < 0) { + throw new KernelScratchError(`${field} has an invalid range limit`); + } + const end = pointer + length; + if (!intrinsicNumberIsSafeInteger(end) || end < pointer || end > limit) { + throw new KernelScratchError(`${field} is outside its owned range`); + } + return { pointer, length, end }; +} + +/** + * Validate a pointer/length pair against the current WebAssembly.Memory + * buffer. + * + * Wasm linear-memory address zero is caller-addressable, even though a zero + * returned by a kernel allocator means allocation failure. Keep that + * distinction explicit at the call site instead of teaching range checks that + * every address zero is a failed allocation. + */ +export function checkedMemoryRange( + memory: WebAssembly.Memory, + pointerValue: WasmPointer, + lengthValue: number | bigint, + pointerWidth: WasmPointerWidth, + field: string, + allowAddressZero = false, +): CheckedMemoryRange { + const pointer = exactPointer(pointerValue, pointerWidth, `${field} pointer`); + const length = exactNonNegativeInteger(lengthValue, `${field} length`); + if (!allowAddressZero && pointer === 0 && length !== 0) { + throw new KernelScratchError(`${field} uses a null pointer`); + } + const buffer = intrinsicWasmMemoryBuffer(memory, field); + return checkedRange( + pointer, + length, + intrinsicBufferByteLength(buffer, field), + field, + ); +} + +/** + * Validate a raw pointer delivered by a WebAssembly import. + * + * Unlike already-normalized channel values, a memory32 pointer reaches + * JavaScript as a signed i32. Normalize those exact bits first, then perform + * the ordinary null, length, overflow, and current-memory checks. + */ +export function checkedWasmImportMemoryRange( + memory: WebAssembly.Memory, + pointerValue: WasmPointer, + lengthValue: number | bigint, + pointerWidth: WasmPointerWidth, + field: string, + allowAddressZero = false, +): CheckedMemoryRange { + let pointer: number; + try { + pointer = checkedWasmGuestPointerOffset( + pointerValue, + pointerWidth, + `${field} pointer`, + ); + } catch (error) { + throw new KernelScratchError( + `${field} has an invalid raw WebAssembly pointer: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + return checkedMemoryRange( + memory, + pointer, + lengthValue, + pointerWidth, + field, + allowAddressZero, + ); +} + +export type KernelScratchAllocator = (capacity: number) => WasmPointer; +export interface KernelScratchReservation { + pointer: WasmPointer; + capacity: number | bigint; +} +export type KernelScratchReserver = ( + minimumCapacity: number, +) => KernelScratchReservation; + +export interface KernelScratchLease { + /** + * Prove that one range belongs to this allocation without exposing its + * primitive address. + */ + assertRange(offset: number, length: number): void; + /** + * Describe one owned range for substitution into a reviewed kernel export. + * + * The returned token contains no readable address and is valid only for the + * lease that created it. + */ + exportPointer(offset: number, length: number): KernelScratchExportPointer; + /** + * Invoke one reviewed pointer-borrowing export without exposing a primitive + * allocation address to caller code. + */ + invokeKernelExport( + name: KernelScratchExportName, + args: readonly ( + | number + | bigint + | KernelScratchExportPointer + )[], + ): number; + /** + * Encode the checked address of one source range into another owned range. + * + * WHY: writing a primitive address through a general readable DataView lets + * the host read it back and retain it after this lease is revoked. This + * operation never returns the primitive and marks the encoded bytes + * unreadable and immutable for the remainder of the lease. Immutability is + * what prevents later scalar writes from replacing an owned address with an + * arbitrary pointer before Rust consumes it. + */ + writeAddress( + destinationOffset: number, + sourceOffset: number, + sourceLength: number, + encoding: "u32-le" | "u64-le" | "u32-to-u64-le", + ): void; + dataView(offset: number, length: number): KernelScratchDataView; + copyFrom( + source: Uint8Array, + destinationOffset?: number, + sourceOffset?: number, + length?: number, + ): void; + copyTo( + destination: Uint8Array, + sourceOffset?: number, + destinationOffset?: number, + length?: number, + ): void; + copyOut(sourceOffset: number, length: number): Uint8Array; + fill(value: number, offset: number, length: number): void; +} + +/** + * A synchronous lease is the only way to read or write the allocation. + * Transfers check both allocation capacity and the current memory buffer. + * Guarded scalar views retain that proof only while the grow-only memory keeps + * the same buffer identity, and repeat it when growth replaces the buffer. + */ +const activeKernelScratchLeaseConstructorKey = + intrinsicObjectFreeze(intrinsicObjectCreate(null) as object); +const activeKernelScratchLeaseInvalidators = + new WeakMap void>(); + +function invalidateActiveKernelScratchLease( + lease: ActiveKernelScratchLease, +): void { + const invalidate = intrinsicApply( + intrinsicWeakMapGet, + activeKernelScratchLeaseInvalidators, + [lease], + ) as (() => void) | undefined; + if (invalidate === undefined) { + throw new KernelScratchError("kernel scratch lease has no revocation state"); + } + invalidate(); +} + +class ActiveKernelScratchLease implements KernelScratchLease { + #valid = true; + #invokingKernelExport = false; + readonly #writeOnlyAddressRanges = intrinsicObjectCreate(null) as + Record; + #writeOnlyAddressRangeCount = 0; + readonly #label: string; + readonly #rangeForLease: ( + offset: number, + length: number, + ) => CheckedMemoryRange; + readonly #currentMemoryBuffer: () => ArrayBufferLike; + readonly #pointerWidth: WasmPointerWidth; + readonly #kernelExports: KernelScratchExportSnapshot | null; + + constructor( + constructorKey: object, + label: string, + rangeForLease: ( + offset: number, + length: number, + ) => CheckedMemoryRange, + currentMemoryBuffer: () => ArrayBufferLike, + pointerWidth: WasmPointerWidth, + kernelExports: KernelScratchExportSnapshot | null, + ) { + if (constructorKey !== activeKernelScratchLeaseConstructorKey) { + throw new KernelScratchError( + "kernel scratch lease cannot be constructed outside its region", + ); + } + this.#label = label; + this.#rangeForLease = rangeForLease; + this.#currentMemoryBuffer = currentMemoryBuffer; + this.#pointerWidth = pointerWidth; + this.#kernelExports = kernelExports; + intrinsicApply( + intrinsicWeakMapSet, + activeKernelScratchLeaseInvalidators, + [this, () => this.#invalidate()], + ); + intrinsicObjectFreeze(this); + } + + #invalidate(): void { + try { + if (this.#writeOnlyAddressRangeCount > 0) { + const buffer = this.#currentMemoryBuffer(); + for ( + let index = 0; + index < this.#writeOnlyAddressRangeCount; + index++ + ) { + const range = this.#writeOnlyAddressRanges[index]; + const encodedAddress = new IntrinsicUint8Array( + buffer, + range.start, + range.end - range.start, + ); + intrinsicApply( + intrinsicUint8ArrayFill, + encodedAddress, + [0], + ); + delete this.#writeOnlyAddressRanges[index]; + } + } + } finally { + // WHY: a later lease must never inherit either a readable primitive + // address or stale sensitivity state, including when the operation + // throws. Scrub while this lease still owns the allocation, then revoke + // every escaped wrapper even if the scrub itself reports an error. + for ( + let index = 0; + index < this.#writeOnlyAddressRangeCount; + index++ + ) { + delete this.#writeOnlyAddressRanges[index]; + } + this.#writeOnlyAddressRangeCount = 0; + this.#valid = false; + } + } + + #assertActive(): void { + if (!this.#valid) { + throw new KernelScratchError( + `${this.#label} lease is no longer active`, + ); + } + } + + #assertValid(): void { + this.#assertActive(); + if (this.#invokingKernelExport) { + // WHY: WebAssembly exports can call back into JavaScript imports before + // returning. Sealing every escaped lease wrapper prevents reentrant host + // code from observing or replacing the bytes Rust is currently + // borrowing. + throw new KernelScratchError( + `${this.#label} lease is sealed during its kernel export`, + ); + } + } + + #ownedRange(offsetValue: number, lengthValue: number): CheckedMemoryRange { + this.#assertValid(); + return this.#checkedOwnedRange(offsetValue, lengthValue); + } + + #ownedRangeForKernelExport( + offsetValue: number, + lengthValue: number, + ): CheckedMemoryRange { + this.#assertActive(); + return this.#checkedOwnedRange(offsetValue, lengthValue); + } + + #checkedOwnedRange( + offsetValue: number, + lengthValue: number, + ): CheckedMemoryRange { + const offset = exactNonNegativeInteger( + offsetValue, + `${this.#label} offset`, + ); + const length = exactNonNegativeInteger( + lengthValue, + `${this.#label} length`, + ); + return this.#rangeForLease(offset, length); + } + + #readableRange( + offsetValue: number, + lengthValue: number, + ): CheckedMemoryRange { + const range = this.#ownedRange(offsetValue, lengthValue); + const insertionIndex = this.#writeOnlyAddressInsertionIndex(range); + if (this.#writeOnlyAddressRangeOverlaps(range, insertionIndex)) { + throw new KernelScratchError( + `${this.#label} address bytes are write-only for this lease`, + ); + } + return range; + } + + #writableRange( + offsetValue: number, + lengthValue: number, + ): CheckedMemoryRange { + const range = this.#ownedRange(offsetValue, lengthValue); + const insertionIndex = this.#writeOnlyAddressInsertionIndex(range); + if (this.#writeOnlyAddressRangeOverlaps(range, insertionIndex)) { + throw new KernelScratchError( + `${this.#label} encoded address bytes are immutable for this lease`, + ); + } + return range; + } + + /** + * Return the sorted insertion point for an encoded-address interval. + * + * WHY: one readv/writev lease can contain IOV_MAX encoded pointers. Keeping + * the non-overlapping intervals sorted lets the neighboring-range check make + * every scalar/read/write proof O(log IOV_MAX), instead of turning ordinary + * vector setup into quadratic taint scanning. + */ + #writeOnlyAddressInsertionIndex(range: CheckedMemoryRange): number { + let low = 0; + let high = this.#writeOnlyAddressRangeCount; + while (low < high) { + const middle = low + intrinsicMathFloor((high - low) / 2); + if (this.#writeOnlyAddressRanges[middle].start < range.pointer) { + low = middle + 1; + } else { + high = middle; + } + } + return low; + } + + #writeOnlyAddressRangeOverlaps( + range: CheckedMemoryRange, + insertionIndex: number, + ): boolean { + if (range.length === 0) return false; + const previous = insertionIndex > 0 + ? this.#writeOnlyAddressRanges[insertionIndex - 1] + : undefined; + const next = insertionIndex < this.#writeOnlyAddressRangeCount + ? this.#writeOnlyAddressRanges[insertionIndex] + : undefined; + return ( + (previous !== undefined && previous.end > range.pointer) + || (next !== undefined && next.start < range.end) + ); + } + + #recordWriteOnlyAddressRange(range: CheckedMemoryRange): void { + const insertionIndex = this.#writeOnlyAddressInsertionIndex(range); + if (this.#writeOnlyAddressRangeOverlaps(range, insertionIndex)) { + throw new KernelScratchError( + `${this.#label} encoded address bytes are immutable for this lease`, + ); + } + for ( + let index = this.#writeOnlyAddressRangeCount; + index > insertionIndex; + index-- + ) { + intrinsicObjectDefineProperty( + this.#writeOnlyAddressRanges, + index, + { + value: this.#writeOnlyAddressRanges[index - 1], + configurable: true, + }, + ); + } + intrinsicObjectDefineProperty( + this.#writeOnlyAddressRanges, + insertionIndex, + { + value: { + start: range.pointer, + end: range.end, + }, + configurable: true, + }, + ); + this.#writeOnlyAddressRangeCount++; + } + + assertRange(offset: number, length: number): void { + this.#ownedRange(offset, length); + } + + exportPointer( + offset: number, + length: number, + ): KernelScratchExportPointer { + const checkedOffset = exactNonNegativeInteger( + offset, + `${this.#label} offset`, + ); + const range = this.#ownedRange(checkedOffset, length); + const pointer = intrinsicObjectFreeze( + intrinsicObjectCreate(null) as object, + ) as KernelScratchExportPointer; + intrinsicApply( + intrinsicWeakMapSet, + kernelScratchExportPointers, + [ + pointer, + { + lease: this, + offset: checkedOffset, + length: range.length, + }, + ], + ); + return pointer; + } + + invokeKernelExport( + name: KernelScratchExportName, + args: readonly ( + | number + | bigint + | KernelScratchExportPointer + )[], + ): number { + this.#assertValid(); + if (typeof name !== "string" || !isKernelScratchExportName(name)) { + throw new KernelScratchError( + `${this.#label} kernel export is not approved for scratch borrowing`, + ); + } + const kernelExport = this.#kernelExports?.[name]; + if (kernelExport === undefined) { + throw new KernelScratchError( + `${this.#label} kernel export ${name} is unavailable`, + ); + } + + const requiredPointers = kernelScratchRequiredPointerArguments(name); + const nullablePointers = kernelScratchNullablePointerArguments(name); + // WHY: `args` is caller-owned and can be a Proxy. Seal before its first + // property read so getters cannot recursively use this lease while pointer + // primitives are being prepared. All range checks below use a private path + // that preserves the active-token proof without reopening the public lease. + this.#invokingKernelExport = true; + try { + const argumentCount = exactNonNegativeInteger( + args.length, + `${this.#label} kernel export argument count`, + ); + if (argumentCount !== kernelExport.argumentCount) { + throw new KernelScratchError( + `${this.#label} kernel export ${name} expects ` + + `${kernelExport.argumentCount} arguments, received ${argumentCount}`, + ); + } + for ( + let pointerListIndex = 0; + pointerListIndex < requiredPointers.length; + pointerListIndex++ + ) { + const pointerIndex = requiredPointers[pointerListIndex]; + if (pointerIndex + 1 >= argumentCount) { + throw new KernelScratchError( + `${this.#label} kernel export ${name} is missing pointer/capacity ` + + `arguments at ${pointerIndex}/${pointerIndex + 1}`, + ); + } + } + for ( + let pointerListIndex = 0; + pointerListIndex < nullablePointers.length; + pointerListIndex++ + ) { + const pointerIndex = nullablePointers[pointerListIndex]; + if (pointerIndex + 1 >= argumentCount) { + throw new KernelScratchError( + `${this.#label} kernel export ${name} is missing pointer/capacity ` + + `arguments at ${pointerIndex}/${pointerIndex + 1}`, + ); + } + } + + const convertedArgs = intrinsicObjectCreate(null) as { + readonly length: number; + readonly [index: number]: number | bigint; + }; + intrinsicObjectDefineProperty(convertedArgs, "length", { + value: argumentCount, + }); + const borrowedRanges = intrinsicObjectCreate(null) as + Record; + const borrowedRangeList = intrinsicObjectCreate(null) as + Record; + let borrowedRangeCount = 0; + for (let index = 0; index < argumentCount; index++) { + const argument = args[index]; + let pointerKind: "required" | "nullable" | null = null; + for ( + let pointerListIndex = 0; + pointerListIndex < requiredPointers.length; + pointerListIndex++ + ) { + if (requiredPointers[pointerListIndex] === index) { + pointerKind = "required"; + break; + } + } + if (pointerKind === null) { + for ( + let pointerListIndex = 0; + pointerListIndex < nullablePointers.length; + pointerListIndex++ + ) { + if (nullablePointers[pointerListIndex] === index) { + pointerKind = "nullable"; + break; + } + } + } + + let convertedArgument: number | bigint; + if (pointerKind !== null) { + const record = ( + typeof argument === "object" + && argument !== null + ) + ? intrinsicApply( + intrinsicWeakMapGet, + kernelScratchExportPointers, + [argument], + ) as KernelScratchExportPointerRecord | undefined + : undefined; + if (record !== undefined) { + if (record.lease !== this) { + throw new KernelScratchError( + `${this.#label} kernel export received a pointer from another lease`, + ); + } + const range = this.#ownedRangeForKernelExport( + record.offset, + record.length, + ); + const alignment = kernelScratchPointerAlignment(name, index); + if (range.pointer % alignment !== 0) { + throw new KernelScratchError( + `${this.#label} kernel export ${name} pointer argument ${index} ` + + `is not ${alignment}-byte aligned`, + ); + } + for ( + let borrowedIndex = 0; + borrowedIndex < borrowedRangeCount; + borrowedIndex++ + ) { + const prior = borrowedRangeList[borrowedIndex]; + if ( + range.length > 0 + && prior.length > 0 + && range.pointer < prior.end + && range.end > prior.pointer + ) { + throw new KernelScratchError( + `${this.#label} kernel export ${name} has overlapping ` + + "borrowed pointer ranges", + ); + } + } + intrinsicObjectDefineProperty(borrowedRanges, index, { + value: range, + }); + intrinsicObjectDefineProperty( + borrowedRangeList, + borrowedRangeCount, + { value: range }, + ); + borrowedRangeCount++; + convertedArgument = this.#pointerWidth === 4 + ? range.pointer + : IntrinsicBigInt(range.pointer); + } else { + const nullPointer = this.#pointerWidth === 4 ? 0 : 0n; + if (pointerKind !== "nullable" || argument !== nullPointer) { + throw new KernelScratchError( + `${this.#label} kernel export ${name} pointer argument ${index} ` + + "must be an owned range token" + + (pointerKind === "nullable" + ? " or an exact null pointer" + : ""), + ); + } + convertedArgument = nullPointer; + } + } else { + if (typeof argument !== "number" && typeof argument !== "bigint") { + throw new KernelScratchError( + `${this.#label} kernel export ${name} argument ${index} ` + + "must be a primitive scalar", + ); + } + convertedArgument = argument; + } + intrinsicObjectDefineProperty(convertedArgs, index, { + value: convertedArgument, + }); + } + + // WHY: every approved pointer-bearing export places an explicit byte + // capacity immediately after its pointer. Couple that scalar to the + // opaque token here so a one-byte borrow can never be paired with a + // larger Rust slice length. Exact equality also prevents a stale larger + // token from silently authorizing a different call shape. + const validatePointerCapacities = ( + pointerIndexes: readonly number[], + ): void => { + for ( + let pointerListIndex = 0; + pointerListIndex < pointerIndexes.length; + pointerListIndex++ + ) { + const pointerIndex = pointerIndexes[pointerListIndex]; + const declaredCapacity = exactNonNegativeInteger( + convertedArgs[pointerIndex + 1], + `${this.#label} kernel export ${name} pointer ` + + `${pointerIndex} capacity`, + ); + const borrowed = borrowedRanges[pointerIndex]; + const ownedCapacity = borrowed?.length ?? 0; + if (declaredCapacity !== ownedCapacity) { + throw new KernelScratchError( + `${this.#label} kernel export ${name} pointer argument ` + + `${pointerIndex} declares ${declaredCapacity} bytes but ` + + `borrows ${ownedCapacity}`, + ); + } + } + }; + validatePointerCapacities(requiredPointers); + validatePointerCapacities(nullablePointers); + + const result = intrinsicApply( + kernelExport.call, + undefined, + convertedArgs, + ); + if (typeof result !== "number") { + throw new KernelScratchError( + `${this.#label} kernel export ${name} returned a non-number`, + ); + } + return result; + } finally { + this.#invokingKernelExport = false; + } + } + + writeAddress( + destinationOffset: number, + sourceOffset: number, + sourceLength: number, + encoding: "u32-le" | "u64-le" | "u32-to-u64-le", + ): void { + if ( + encoding !== "u32-le" + && encoding !== "u64-le" + && encoding !== "u32-to-u64-le" + ) { + throw new KernelScratchError( + `${this.#label} address encoding must be u32-le, u64-le, or u32-to-u64-le`, + ); + } + const source = this.#ownedRange(sourceOffset, sourceLength); + const encodedLength = encoding === "u32-le" ? 4 : 8; + const destination = this.#ownedRange( + destinationOffset, + encodedLength, + ); + if ( + encoding !== "u64-le" + && source.pointer > WASM32_MAX_POINTER + ) { + throw new KernelScratchError( + `${this.#label} address does not fit the ${encoding} encoding`, + ); + } + + // WHY: once an address is materialized as bytes, a general getter or copy + // could turn it back into an irrevocable primitive, while a later setter + // could replace the checked owned address with an arbitrary kernel-memory + // pointer. Record the exact bytes before writing so every overlapping read + // or later mutation is rejected for this lease. + this.#recordWriteOnlyAddressRange(destination); + const destinationView = new IntrinsicDataView( + this.#currentMemoryBuffer(), + destination.pointer, + destination.length, + ); + if (encoding === "u32-le") { + intrinsicApply( + intrinsicDataViewSetUint32, + destinationView, + [0, source.pointer, true], + ); + } else { + intrinsicApply( + intrinsicDataViewSetBigUint64, + destinationView, + [0, IntrinsicBigInt(source.pointer), true], + ); + } + } + + dataView(offset: number, length: number): KernelScratchDataView { + const refreshView = () => { + const range = this.#ownedRange(offset, length); + const buffer = this.#currentMemoryBuffer(); + return { + buffer, + view: new IntrinsicDataView( + buffer, + range.pointer, + range.length, + ), + }; + }; + return new ActiveKernelScratchDataView( + activeKernelScratchDataViewConstructorKey, + () => { + this.#assertValid(); + return this.#currentMemoryBuffer(); + }, + (viewOffset, viewLength) => { + const checkedViewOffset = exactNonNegativeInteger( + viewOffset, + `${this.#label} DataView offset`, + ); + this.#readableRange(offset + checkedViewOffset, viewLength); + }, + (viewOffset, viewLength) => { + const checkedViewOffset = exactNonNegativeInteger( + viewOffset, + `${this.#label} DataView offset`, + ); + this.#writableRange(offset + checkedViewOffset, viewLength); + }, + refreshView, + ); + } + + copyFrom( + source: Uint8Array, + destinationOffset = 0, + sourceOffset = 0, + length?: number, + ): void { + const sourceSpan = intrinsicUint8ArraySpan( + source, + `${this.#label} source`, + ); + const checkedSourceOffset = exactNonNegativeInteger( + sourceOffset, + `${this.#label} source offset`, + ); + const checkedLength = exactNonNegativeInteger( + length ?? sourceSpan.byteLength - checkedSourceOffset, + `${this.#label} copy length`, + ); + checkedRange( + checkedSourceOffset, + checkedLength, + sourceSpan.byteLength, + `${this.#label} source`, + ); + const destination = this.#writableRange( + destinationOffset, + checkedLength, + ); + // WHY: calling a subclass-overridable `source.subarray()` could return + // more bytes than the range just proved. Construct an exact base-class + // view from the typed array's intrinsic slots instead. + const exactSource = new IntrinsicUint8Array( + sourceSpan.buffer, + sourceSpan.byteOffset + checkedSourceOffset, + checkedLength, + ); + // WHY: make the native receiver itself cover only the owned allocation + // range. The capacity proof is therefore structural even if this helper is + // later refactored and an absolute full-memory offset is accidentally lost. + const exactDestination = new IntrinsicUint8Array( + this.#currentMemoryBuffer(), + destination.pointer, + destination.length, + ); + intrinsicApply( + intrinsicUint8ArraySet, + exactDestination, + [exactSource], + ); + } + + copyTo( + destination: Uint8Array, + sourceOffset = 0, + destinationOffset = 0, + length?: number, + ): void { + const destinationSpan = intrinsicUint8ArraySpan( + destination, + `${this.#label} destination`, + ); + const checkedDestinationOffset = exactNonNegativeInteger( + destinationOffset, + `${this.#label} destination offset`, + ); + const checkedLength = exactNonNegativeInteger( + length ?? destinationSpan.byteLength - checkedDestinationOffset, + `${this.#label} copy length`, + ); + checkedRange( + checkedDestinationOffset, + checkedLength, + destinationSpan.byteLength, + `${this.#label} destination`, + ); + const source = this.#readableRange(sourceOffset, checkedLength); + const kernelSource = new IntrinsicUint8Array( + this.#currentMemoryBuffer(), + source.pointer, + source.length, + ); + const detached = new IntrinsicUint8Array(source.length); + intrinsicApply( + intrinsicUint8ArraySet, + detached, + [kernelSource], + ); + // WHY: invoke the captured intrinsic rather than a subclass/live + // prototype method. A caller-owned destination must not reenter while the + // lease is active or retain a live kernel view. + intrinsicApply( + intrinsicUint8ArraySet, + destination, + [detached, checkedDestinationOffset], + ); + } + + copyOut(sourceOffset: number, length: number): Uint8Array { + const source = this.#readableRange(sourceOffset, length); + const kernelSource = new IntrinsicUint8Array( + this.#currentMemoryBuffer(), + source.pointer, + source.length, + ); + const detached = new IntrinsicUint8Array(source.length); + intrinsicApply( + intrinsicUint8ArraySet, + detached, + [kernelSource], + ); + return detached; + } + + fill(value: number, offset: number, length: number): void { + if (typeof value !== "number") { + throw new KernelScratchError( + `${this.#label} fill value must be a primitive number`, + ); + } + const destination = this.#writableRange(offset, length); + const exactDestination = new IntrinsicUint8Array( + this.#currentMemoryBuffer(), + destination.pointer, + destination.length, + ); + intrinsicApply( + intrinsicUint8ArrayFill, + exactDestination, + [value], + ); + } +} + +intrinsicObjectFreeze(ActiveKernelScratchLease.prototype); +intrinsicObjectFreeze(ActiveKernelScratchLease); + +export interface KernelScratchRegion { + readonly capacity: number; + withLease(operation: (scratch: KernelScratchLease) => T): T; + revoke(): void; +} + +/** + * Pointer plus declared capacity for one kernel-owned allocation. + * + * The constructor is private: production callers obtain regions only by + * passing the kernel allocator to allocateKernelScratchRegion. + */ +const ownedKernelScratchRegionConstructorKey = + intrinsicObjectFreeze(intrinsicObjectCreate(null) as object); + +class OwnedKernelScratchRegion implements KernelScratchRegion { + declare readonly capacity: number; + #activeLeaseToken: object | null = null; + #revoked = false; + #singleUseConsumed = false; + readonly #memory: WebAssembly.Memory; + readonly #pointer: number; + readonly #capacity: number; + readonly #pointerWidth: WasmPointerWidth; + readonly #label: string; + readonly #leaseMode: "reusable" | "single-use"; + readonly #kernelExports: KernelScratchExportSnapshot | null; + + private constructor( + constructorKey: object, + memory: WebAssembly.Memory, + pointer: number, + capacity: number, + pointerWidth: WasmPointerWidth, + label: string, + leaseMode: "reusable" | "single-use", + kernelExports: KernelScratchExportSnapshot | null, + ) { + if (constructorKey !== ownedKernelScratchRegionConstructorKey) { + throw new KernelScratchError( + "kernel scratch region cannot be constructed outside its factory", + ); + } + this.#memory = memory; + this.#pointer = pointer; + this.#capacity = capacity; + this.#pointerWidth = pointerWidth; + this.#label = label; + this.#leaseMode = leaseMode; + this.#kernelExports = kernelExports; + // WHY: callers need the numeric capacity for planning, but an ordinary + // TypeScript readonly field is writable at runtime. Publish a frozen own + // value while all authority-bearing state remains in true private slots. + intrinsicObjectDefineProperty(this, "capacity", { + value: capacity, + enumerable: true, + writable: false, + configurable: false, + }); + intrinsicObjectFreeze(this); + } + + static allocate( + constructorKey: object, + memory: WebAssembly.Memory, + allocator: KernelScratchAllocator, + capacityValue: number, + pointerWidth: WasmPointerWidth, + label: string, + kernelInstance?: WebAssembly.Instance, + ): OwnedKernelScratchRegion { + if (constructorKey !== ownedKernelScratchRegionConstructorKey) { + throw new KernelScratchError( + "kernel scratch allocation factory is not authorized", + ); + } + if (pointerWidth !== 4 && pointerWidth !== 8) { + throw new KernelScratchError( + `${label} pointer width must be exactly 4 or 8`, + ); + } + const kernelExports = kernelInstance === undefined + ? null + : snapshotKernelScratchExports( + kernelInstance, + memory, + label, + allocator, + ); + const capacity = exactNonNegativeInteger( + capacityValue, + `${label} capacity`, + ); + if (capacity === 0) { + throw new KernelScratchError(`${label} capacity must be positive`); + } + if (capacity > 0xffff_ffff) { + throw new KernelScratchError( + `${label} capacity does not fit kernel_alloc_scratch's u32 size`, + ); + } + const pointer = checkedKernelExportPointer( + allocator(capacity), + pointerWidth, + `${label} allocation`, + ); + if (pointer === 0) { + throw new KernelScratchError(`${label} allocation failed`); + } + checkedMemoryRange(memory, pointer, capacity, pointerWidth, label); + return new OwnedKernelScratchRegion( + ownedKernelScratchRegionConstructorKey, + memory, + pointer, + capacity, + pointerWidth, + label, + "reusable", + kernelExports, + ); + } + + static reserve( + constructorKey: object, + memory: WebAssembly.Memory, + reserver: KernelScratchReserver, + minimumCapacityValue: number, + pointerWidth: WasmPointerWidth, + label: string, + kernelInstance?: WebAssembly.Instance, + ): OwnedKernelScratchRegion { + if (constructorKey !== ownedKernelScratchRegionConstructorKey) { + throw new KernelScratchError( + "kernel scratch reservation factory is not authorized", + ); + } + if (pointerWidth !== 4 && pointerWidth !== 8) { + throw new KernelScratchError( + `${label} pointer width must be exactly 4 or 8`, + ); + } + const kernelExports = kernelInstance === undefined + ? null + : snapshotKernelScratchExports(kernelInstance, memory, label); + const minimumCapacity = exactNonNegativeInteger( + minimumCapacityValue, + `${label} minimum capacity`, + ); + if (minimumCapacity === 0) { + throw new KernelScratchError( + `${label} minimum capacity must be positive`, + ); + } + const reservation = reserver(minimumCapacity); + const capacity = exactNonNegativeInteger( + reservation.capacity, + `${label} reserved capacity`, + ); + if (capacity < minimumCapacity) { + throw new KernelScratchError( + `${label} reserved capacity ${capacity} is below ${minimumCapacity}`, + ); + } + const pointer = checkedKernelExportPointer( + reservation.pointer, + pointerWidth, + `${label} reservation`, + ); + if (pointer === 0) { + throw new KernelScratchError(`${label} reservation failed`); + } + checkedMemoryRange(memory, pointer, capacity, pointerWidth, label); + return new OwnedKernelScratchRegion( + ownedKernelScratchRegionConstructorKey, + memory, + pointer, + capacity, + pointerWidth, + label, + "single-use", + kernelExports, + ); + } + + #assertActiveLease(token: object): void { + if (this.#activeLeaseToken !== token) { + throw new KernelScratchError( + `${this.#label} lease is no longer active`, + ); + } + } + + #ownedRangeForLease( + token: object, + offset: number, + length: number, + ): CheckedMemoryRange { + this.#assertActiveLease(token); + checkedRange(offset, length, this.#capacity, this.#label); + return checkedMemoryRange( + this.#memory, + this.#pointer + offset, + length, + this.#pointerWidth, + this.#label, + ); + } + + withLease(operation: (scratch: KernelScratchLease) => T): T { + if (this.#revoked) { + throw new KernelScratchError(`${this.#label} is no longer valid`); + } + if (this.#leaseMode === "single-use" && this.#singleUseConsumed) { + throw new KernelScratchError( + `${this.#label} reservation is single-use`, + ); + } + if (this.#activeLeaseToken !== null) { + throw new KernelScratchError(`${this.#label} is already in use`); + } + // WHY: a reservation-derived pointer can move on the next Rust reserve. + // Consume its one lease before any fallible range/view work so retrying a + // partially failed attempt cannot revive a stale pointer. + if (this.#leaseMode === "single-use") { + this.#singleUseConsumed = true; + } + // Recheck the whole allocation because memory replacement/growth changes + // the backing buffer independently of the allocator's original result. + checkedMemoryRange( + this.#memory, + this.#pointer, + this.#capacity, + this.#pointerWidth, + this.#label, + ); + const token = intrinsicObjectFreeze(intrinsicObjectCreate(null) as object); + this.#activeLeaseToken = token; + const lease = new ActiveKernelScratchLease( + activeKernelScratchLeaseConstructorKey, + this.#label, + (offset, length) => + this.#ownedRangeForLease(token, offset, length), + () => { + this.#assertActiveLease(token); + return intrinsicWasmMemoryBuffer(this.#memory, this.#label); + }, + this.#pointerWidth, + this.#kernelExports, + ); + let result!: T; + try { + result = operation(lease); + } finally { + // WHY: revoke the lease before inspecting an arbitrary return value. + // A hostile `then` getter must not retain scratch access for even the + // property lookup used to reject asynchronous operations. + try { + invalidateActiveKernelScratchLease(lease); + } catch (error) { + // WHY: if sensitive encoded addresses cannot be scrubbed, no later + // operation may treat this allocation as clean reusable scratch. + this.#revoked = true; + throw error; + } finally { + this.#activeLeaseToken = null; + } + } + if ( + ( + typeof result === "object" && + result !== null + ) || + typeof result === "function" + ) { + if (typeof (result as { then?: unknown }).then === "function") { + // WHY: a retained view or callback could otherwise resume after a + // second operation has replaced the shared bytes. + throw new KernelScratchError( + `${this.#label} leases must remain synchronous`, + ); + } + } + return result; + } + + /** + * Permanently invalidate a reservation-derived region when its matching + * kernel token is consumed or cancelled. + */ + revoke(): void { + if (this.#activeLeaseToken !== null) { + throw new KernelScratchError( + `${this.#label} cannot be revoked while in use`, + ); + } + this.#revoked = true; + } +} + +intrinsicObjectFreeze(OwnedKernelScratchRegion.prototype); +intrinsicObjectFreeze(OwnedKernelScratchRegion); + +export function allocateKernelScratchRegion( + memory: WebAssembly.Memory, + allocator: KernelScratchAllocator, + capacity: number, + pointerWidth: WasmPointerWidth, + label: string, + kernelInstance?: WebAssembly.Instance, +): KernelScratchRegion { + return OwnedKernelScratchRegion.allocate( + ownedKernelScratchRegionConstructorKey, + memory, + allocator, + capacity, + pointerWidth, + label, + kernelInstance, + ); +} + +/** + * Create a one-shot capacity-carrying region from a kernel-owned reservation. + * The kernel may move the allocation only while `reserver` runs. The returned + * region permits exactly one lease and should be revoked when the matching + * reservation token is consumed or cancelled. + */ +export function reserveKernelScratchRegion( + memory: WebAssembly.Memory, + reserver: KernelScratchReserver, + minimumCapacity: number, + pointerWidth: WasmPointerWidth, + label: string, + kernelInstance?: WebAssembly.Instance, +): KernelScratchRegion { + return OwnedKernelScratchRegion.reserve( + ownedKernelScratchRegionConstructorKey, + memory, + reserver, + minimumCapacity, + pointerWidth, + label, + kernelInstance, + ); +} diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index 9b8fffd6be..6b2d398a66 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -18,12 +18,26 @@ * 8 48B arguments (6 x i64) * 56 8B return value (i64) * 64 4B errno + * 68 4B request flags * 72 64KB data transfer buffer + * + * The generated `CH_*` constants below are authoritative; this summary is + * explanatory only and generated-file drift tests cover the live values. */ import { negErrno, WasmPosixKernel, type KernelPointer } from "./kernel"; import { - BoundedHttpResponseChunks, + allocateKernelScratchRegion, + checkedKernelExportPointer, + checkedMemoryRange, + checkedWasmAddressRange, + checkedWasmPointer, + intrinsicUint8ArrayView, + KernelScratchError, + reserveKernelScratchRegion, + type KernelScratchRegion, +} from "./kernel-scratch"; +import { buildRawHttpRequest, parseRawHttpResponse, type HttpRequest, @@ -35,7 +49,6 @@ import { ABI_SYSCALL_NAMES, ABI_SYSCALLS, CHANNEL_STATUS_COMPLETE, - CHANNEL_STATUS_IDLE, CHANNEL_STATUS_PENDING, CH_ARG_SIZE, CH_ARGS, @@ -46,15 +59,17 @@ import { CH_REQUEST_FLAGS, CH_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY, CH_RETURN, + CH_SIG_AREA_SIZE, CH_SIG_BASE, - CH_SIG_FLAGS, CH_SIG_HANDLER, - CH_SIG_OLD_MASK, + CH_SIG_SI_CODE, CH_SIG_SIGNUM, CH_STATUS, CH_SYSCALL, CH_TOTAL_SIZE, + FCNTL_FLOCK_BYTES, HOST_INTERCEPTED_SYSCALLS, + IOCTL_REQUESTS, PROCESS_MEMORY_PAGES_PER_THREAD_SLOT, PROCESS_MEMORY_THREAD_SLOT_CHANNEL_PRIMARY_PAGE, KERNEL_WAIT_RESULT_CHILD_UID_OFFSET, @@ -62,13 +77,113 @@ import { KERNEL_WAIT_RESULT_SI_CODE_OFFSET, KERNEL_WAIT_RESULT_SI_STATUS_OFFSET, KERNEL_WAIT_RESULT_WAIT_STATUS_OFFSET, + KERNEL_CMSGHDR_WIRE_ALIGN, + KERNEL_CMSGHDR_WIRE_DATA_OFFSET, + KERNEL_CMSGHDR_WIRE_LEN_OFFSET, + KERNEL_CMSGHDR_WIRE_LEVEL_OFFSET, + KERNEL_CMSGHDR_WIRE_TYPE_OFFSET, + KERNEL_IOVEC_WIRE_ALIGN, + KERNEL_IOVEC_WIRE_BASE_OFFSET, + KERNEL_IOVEC_WIRE_LEN_OFFSET, + KERNEL_MESSAGE_WIRE_FLATTENED_IOVEC_COUNT, + KERNEL_MSGHDR_WIRE_ALIGN, + KERNEL_MSGHDR_WIRE_CONTROL_OFFSET, + KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET, + KERNEL_MSGHDR_WIRE_FLAGS_OFFSET, + KERNEL_MSGHDR_WIRE_IOV_OFFSET, + KERNEL_MSGHDR_WIRE_IOVLEN_OFFSET, + KERNEL_MSGHDR_WIRE_NAME_OFFSET, + KERNEL_MSGHDR_WIRE_NAMELEN_OFFSET, + KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES, + KERNEL_SCRATCH_SIGNAL_DELIVERY_BYTES, + SOCKET_MSG_TRUNC, PROCESS_STATE_EXITED, PROCESS_STATE_RUNNING, PROCESS_STATE_STOPPED, + POSIX_ARG_MAX_BYTES, + POSIX_IOV_MAX, + POSIX_PATH_MAX_BYTES, + PROCESS_CMSGHDR_WASM32_ALIGN, + PROCESS_CMSGHDR_WASM32_DATA_OFFSET, + PROCESS_CMSGHDR_WASM32_LEN_OFFSET, + PROCESS_CMSGHDR_WASM32_LEVEL_OFFSET, + PROCESS_CMSGHDR_WASM32_SIZE, + PROCESS_CMSGHDR_WASM32_TYPE_OFFSET, + PROCESS_CMSGHDR_WASM64_ALIGN, + PROCESS_CMSGHDR_WASM64_DATA_OFFSET, + PROCESS_CMSGHDR_WASM64_LEN_OFFSET, + PROCESS_CMSGHDR_WASM64_LEVEL_OFFSET, + PROCESS_CMSGHDR_WASM64_SIZE, + PROCESS_CMSGHDR_WASM64_TYPE_OFFSET, + PROCESS_IOVEC_WASM32_BASE_OFFSET, + PROCESS_IOVEC_WASM32_LEN_OFFSET, + PROCESS_IOVEC_WASM32_SIZE, + PROCESS_IOVEC_WASM64_BASE_OFFSET, + PROCESS_IOVEC_WASM64_LEN_OFFSET, + PROCESS_IOVEC_WASM64_SIZE, + PROCESS_MSGHDR_WASM32_CONTROL_OFFSET, + PROCESS_MSGHDR_WASM32_CONTROLLEN_OFFSET, + PROCESS_MSGHDR_WASM32_FLAGS_OFFSET, + PROCESS_MSGHDR_WASM32_IOV_OFFSET, + PROCESS_MSGHDR_WASM32_IOVLEN_OFFSET, + PROCESS_MSGHDR_WASM32_NAME_OFFSET, + PROCESS_MSGHDR_WASM32_NAMELEN_OFFSET, + PROCESS_MSGHDR_WASM32_SIZE, + PROCESS_MSGHDR_WASM64_CONTROL_OFFSET, + PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET, + PROCESS_MSGHDR_WASM64_FLAGS_OFFSET, + PROCESS_MSGHDR_WASM64_IOV_OFFSET, + PROCESS_MSGHDR_WASM64_IOVLEN_OFFSET, + PROCESS_MSGHDR_WASM64_NAME_OFFSET, + PROCESS_MSGHDR_WASM64_NAMELEN_OFFSET, + PROCESS_MSGHDR_WASM64_SIZE, + PROCESS_SIGINFO_CODE_OFFSET, + PROCESS_SIGINFO_SIGNO_OFFSET, + PROCESS_SIGINFO_WASM32_PID_OFFSET, + PROCESS_SIGINFO_WASM32_SIZE, + PROCESS_SIGINFO_WASM32_UID_OFFSET, + PROCESS_SIGINFO_WASM32_VALUE_OFFSET, + PROCESS_SIGINFO_WASM64_PID_OFFSET, + PROCESS_SIGINFO_WASM64_SIZE, + PROCESS_SIGINFO_WASM64_UID_OFFSET, + PROCESS_SIGINFO_WASM64_VALUE_OFFSET, + PROCESS_POINTER_WIDTH_ARG_INDEX, + PRCTL_NAME_BYTES, + PR_GET_NAME, + PR_SET_NAME, + SCM_RIGHTS_FD_BYTES, SCHED_AFFINITY_MASK_SIZE, + SELECT_FD_SET_BYTES, + SELECT_FD_SETSIZE, + SIGNAL_MASK_BYTES, + SOCKET_SCM_RIGHTS, + SOCKET_SOL_SOCKET, + SPAWN_MAX_ACTION_COUNT, + SPAWN_MAX_ARGV_COUNT, + SPAWN_MAX_ENVP_COUNT, + SPAWN_WIRE_ACTION_RECORD_BYTES, + SPAWN_WIRE_HEADER_ACTION_COUNT_OFFSET, + SPAWN_WIRE_HEADER_ARGC_OFFSET, + SPAWN_WIRE_HEADER_BYTES, + SPAWN_WIRE_HEADER_ENVC_OFFSET, + SPAWN_WIRE_MAX_BYTES, + SPAWN_WIRE_STRING_OFFSET_BYTES, + STRUCT_SIZE_KERNEL_CMSGHDR_WIRE, + STRUCT_SIZE_KERNEL_IOVEC_WIRE, + STRUCT_SIZE_KERNEL_MSGHDR_WIRE, + STRUCT_SIZE_WASM_EPOLL_EVENT, + STRUCT_SIZE_WASM_POLL_FD, + STRUCT_SIZE_WASM_SYSV_MESSAGE_HEADER, STRUCT_SIZE_KERNEL_WAIT_RESULT, STRUCT_SIZE_WASM_RUSAGE_WIRE, + STRUCT_SIZE_WASM_STAT, SYSCALL_ARGS, + WASM_POLL_FD_EVENTS_OFFSET, + WASM_POLL_FD_FD_OFFSET, + WASM_POLL_FD_REVENTS_OFFSET, + WASM_EPOLL_EVENT_DATA_OFFSET, + WASM_EPOLL_EVENT_EVENTS_OFFSET, + WASM_EPOLL_EVENT_PAD_OFFSET, WAIT_EVENT_CONTINUED, WAIT_EVENT_EXITED, WAIT_EVENT_STOPPED, @@ -145,7 +260,6 @@ export function isCurrentProcessGeneration>> 0); +} + /** Syscall numbers for scatter/gather I/O */ const SYS_WRITEV = ABI_SYSCALLS.Writev; const SYS_READV = ABI_SYSCALLS.Readv; const SYS_PREADV = ABI_SYSCALLS.Preadv; const SYS_PWRITEV = ABI_SYSCALLS.Pwritev; +const SYS_PREADV2 = ABI_SYSCALLS.Preadv2; +const SYS_PWRITEV2 = ABI_SYSCALLS.Pwritev2; +const SYS_GETGROUPS = ABI_SYSCALLS.Getgroups; /** fcntl commands that take a struct flock pointer */ const SYS_FCNTL = ABI_SYSCALLS.Fcntl; -/** SysV IPC syscall numbers (only those still intercepted on host) */ +/** SysV IPC syscall numbers with caller-width-dependent memory layouts. */ +const SYS_MSGRCV = ABI_SYSCALLS.Msgrcv; +const SYS_MSGSND = ABI_SYSCALLS.Msgsnd; +const SYS_MSGCTL = ABI_SYSCALLS.Msgctl; const SYS_SEMCTL = ABI_SYSCALLS.Semctl; const SYS_SHMAT = ABI_SYSCALLS.Shmat; const SYS_SHMDT = ABI_SYSCALLS.Shmdt; +const SYS_SHMCTL = ABI_SYSCALLS.Shmctl; +const IPC_NOWAIT = 0x800; /** POSIX message queue syscall numbers */ const SYS_MQ_TIMEDSEND = ABI_SYSCALLS.MqTimedsend; @@ -488,19 +626,86 @@ function syscallHasMsgDontwait(syscallNr: number, args: number[]): boolean { } return flags !== undefined && (flags & MSG_DONTWAIT) !== 0; } -// Signal delivery area — last 48 bytes of data buffer. -// Written by kernel_dequeue_signal, read by glue channel_syscall.c. -const CH_SIG_SI_VALUE = CH_SIG_BASE + 12; // i32: si_value.sival_int -const CH_SIG_SI_CODE = CH_SIG_BASE + 24; // i32: si_code -const CH_SIG_SI_PID = CH_SIG_BASE + 28; // u32: si_pid -const CH_SIG_SI_UID = CH_SIG_BASE + 32; // u32: si_uid -const CH_SIG_ALT_SP = CH_SIG_BASE + 36; // u32: alt stack sp (0 = no switch) -const CH_SIG_ALT_SIZE = CH_SIG_BASE + 40; // u32: alt stack size - /** Scratch area layout in kernel Memory for kernel_handle_channel. * Same as channel layout but used as the kernel-side buffer. */ const SCRATCH_SIZE = CH_TOTAL_SIZE; +// The Rust parser reads one flattened iovec record. This literal assignment +// makes generated-protocol drift fail TypeScript compilation until both sides +// are deliberately updated. +const FLATTENED_KERNEL_MESSAGE_IOVEC_COUNT: 1 = + KERNEL_MESSAGE_WIRE_FLATTENED_IOVEC_COUNT; + +interface CheckedProcessIovec { + base: number; + len: number; +} + +interface CheckedProcessIovecs { + entries: CheckedProcessIovec[]; + totalData: number; +} + +interface CheckedProcessMessage { + pointerWidth: 4 | 8; + messagePointer: number; + name: { pointer: number; length: number }; + control: { pointer: number; length: number }; + iovecs: CheckedProcessIovecs; +} + +interface KernelMessageLayout { + footprint: number; + nameOffset: number; + controlOffset: number; + controlCapacity: number; + iovecOffset: number; + iovecCount: 0 | typeof FLATTENED_KERNEL_MESSAGE_IOVEC_COUNT; + iovecBytes: number; + dataOffset: number; +} + +interface ProcessIovecLayout { + size: number; + baseOffset: number; + lenOffset: number; +} + +interface ProcessMessageLayout { + size: number; + nameOffset: number; + nameLengthOffset: number; + iovecOffset: number; + iovecCountOffset: number; + controlOffset: number; + controlLengthOffset: number; + flagsOffset: number; +} + +interface ProcessControlMessageLayout { + size: number; + alignment: number; + lengthOffset: number; + levelOffset: number; + typeOffset: number; + dataOffset: number; +} + +interface PlannedChannelScratchArg { + desc: SyscallArgDesc; + processPointer: number; + scratchOffset: number; + size: number; + inputBytes: Uint8Array | null; +} + +interface PlannedScratchWrite { + argIndex: number; + scratchOffset: number; + size: number; + inputBytes: Uint8Array | null; +} + /** * One captured syscall, surfaced by the opt-in trace ring buffer * (enableSyscallTrace + drainSyscallTrace). Used by Kandelo Inspector → @@ -581,28 +786,37 @@ function parseProcSnapshots(mem: Uint8Array): ProcessSnapshot[] { * * Throws on malformed input. Callers should treat the throw as EINVAL. */ -function decodeSpawnBlobStrings(blob: Uint8Array): { argv: string[]; envp: string[] } { - if (blob.byteLength < SPAWN_HEADER_BYTES) { +function decodeSpawnBlobStrings( + blob: Uint8Array, + pointerWidth: 4 | 8, +): { argv: string[]; envp: string[] } { + if (blob.byteLength < SPAWN_WIRE_HEADER_BYTES) { throw new Error("blob too short for header"); } const view = new DataView(blob.buffer, blob.byteOffset, blob.byteLength); - const argc = view.getUint32(0, true); - const envc = view.getUint32(4, true); - const nActions = view.getUint32(8, true); + const argc = view.getUint32(SPAWN_WIRE_HEADER_ARGC_OFFSET, true); + const envc = view.getUint32(SPAWN_WIRE_HEADER_ENVC_OFFSET, true); + const nActions = view.getUint32( + SPAWN_WIRE_HEADER_ACTION_COUNT_OFFSET, + true, + ); // Cap counts to mirror the kernel parser's adversarial-input cap. if ( - argc > SPAWN_MAX_ARGV - || envc > SPAWN_MAX_ENVP - || nActions > SPAWN_MAX_ACTIONS + argc > SPAWN_MAX_ARGV_COUNT + || envc > SPAWN_MAX_ENVP_COUNT + || nActions > SPAWN_MAX_ACTION_COUNT ) { throw new Error("blob count exceeds limit"); } - const argvOffsetsAt = SPAWN_HEADER_BYTES; - const envpOffsetsAt = argvOffsetsAt + argc * 4; - const actionsAt = envpOffsetsAt + envc * 4; - const stringsAt = actionsAt + nActions * SPAWN_ACTION_RECORD_BYTES; + const argvOffsetsAt = SPAWN_WIRE_HEADER_BYTES; + const envpOffsetsAt = + argvOffsetsAt + argc * SPAWN_WIRE_STRING_OFFSET_BYTES; + const actionsAt = + envpOffsetsAt + envc * SPAWN_WIRE_STRING_OFFSET_BYTES; + const stringsAt = + actionsAt + nActions * SPAWN_WIRE_ACTION_RECORD_BYTES; if (stringsAt > blob.byteLength) { throw new Error("blob truncated before strings region"); @@ -610,21 +824,73 @@ function decodeSpawnBlobStrings(blob: Uint8Array): { argv: string[]; envp: strin const stringsLen = blob.byteLength - stringsAt; const decoder = new TextDecoder(); - const decodeAt = (off: number): string => { - if (off > stringsLen) throw new Error("string offset OOB"); - let end = off; - while (end < stringsLen && blob[stringsAt + end] !== 0) end++; - return decoder.decode(blob.slice(stringsAt + off, stringsAt + end)); + // Account for every pointer before scanning or decoding any string. Then + // measure all referenced wire spans against one incremental budget. This + // makes the total scanning and allocation work proportional to ARG_MAX: + // thousands of duplicate offsets into a multi-megabyte tail are rejected + // before TextDecoder can allocate that tail once per entry. + let representedBytes = (argc + envc + 2) * pointerWidth; + if ( + !Number.isSafeInteger(representedBytes) + || representedBytes > POSIX_ARG_MAX_BYTES + ) { + throw new KernelScratchError( + "spawn argv/environment pointer representation exceeds ARG_MAX", + E2BIG, + ); + } + const measure = ( + offsetsAt: number, + count: number, + ): Array<{ start: number; end: number }> => { + const ranges = new Array<{ start: number; end: number }>(count); + for (let i = 0; i < count; i++) { + const off = view.getUint32( + offsetsAt + i * SPAWN_WIRE_STRING_OFFSET_BYTES, + true, + ); + if (off > stringsLen) { + throw new KernelScratchError("spawn string offset is out of bounds", EINVAL); + } + let end = off; + while (end < stringsLen && blob[stringsAt + end] !== 0) end++; + if (end === stringsLen) { + throw new KernelScratchError( + "spawn string is missing its terminating NUL", + EINVAL, + ); + } + const length = end - off; + if (length > PROCESS_METADATA_ENTRY_MAX_BYTES) { + throw new KernelScratchError( + "spawn metadata entry exceeds the process-metadata transport limit", + E2BIG, + ); + } + representedBytes += length + 1; + if ( + !Number.isSafeInteger(representedBytes) + || representedBytes > POSIX_ARG_MAX_BYTES + ) { + throw new KernelScratchError( + "spawn argv/environment representation exceeds ARG_MAX", + E2BIG, + ); + } + ranges[i] = { + start: stringsAt + off, + end: stringsAt + end, + }; + } + return ranges; }; - const argv: string[] = []; - for (let i = 0; i < argc; i++) { - argv.push(decodeAt(view.getUint32(argvOffsetsAt + i * 4, true))); - } - const envp: string[] = []; - for (let i = 0; i < envc; i++) { - envp.push(decodeAt(view.getUint32(envpOffsetsAt + i * 4, true))); - } + const argvRanges = measure(argvOffsetsAt, argc); + const envpRanges = measure(envpOffsetsAt, envc); + const decode = ({ start, end }: { start: number; end: number }): string => + decoder.decode(blob.subarray(start, end)); + const argv = argvRanges.map(decode); + const envp = envpRanges.map(decode); return { argv, envp }; } @@ -816,6 +1082,33 @@ type WaitPollResult = | { kind: "running" } | { kind: "error"; errno: number }; +interface ProcessSiginfoLayout { + readonly size: number; + readonly pidOffset: number; + readonly uidOffset: number; + readonly statusOffset: number; +} + +const PROCESS_SIGINFO_WASM32_LAYOUT: ProcessSiginfoLayout = Object.freeze({ + size: PROCESS_SIGINFO_WASM32_SIZE, + pidOffset: PROCESS_SIGINFO_WASM32_PID_OFFSET, + uidOffset: PROCESS_SIGINFO_WASM32_UID_OFFSET, + statusOffset: PROCESS_SIGINFO_WASM32_VALUE_OFFSET, +}); + +const PROCESS_SIGINFO_WASM64_LAYOUT: ProcessSiginfoLayout = Object.freeze({ + size: PROCESS_SIGINFO_WASM64_SIZE, + pidOffset: PROCESS_SIGINFO_WASM64_PID_OFFSET, + uidOffset: PROCESS_SIGINFO_WASM64_UID_OFFSET, + statusOffset: PROCESS_SIGINFO_WASM64_VALUE_OFFSET, +}); + +function processSiginfoLayout(pointerWidth: number): ProcessSiginfoLayout { + if (pointerWidth === 4) return PROCESS_SIGINFO_WASM32_LAYOUT; + if (pointerWidth === 8) return PROCESS_SIGINFO_WASM64_LAYOUT; + throw new Error(`unsupported process pointer width ${pointerWidth}`); +} + interface WaitingForChild { parentPid: number; channel: ChannelInfo; @@ -825,15 +1118,25 @@ interface WaitingForChild { syscallNr: number; } +type ChannelOutputWrite = { ptr: number; bytes: Uint8Array }; + interface PreparedChannelCompletion { kind: "marshalled" | "raw"; - outputWrites: Array<{ ptr: number; bytes: Uint8Array }>; + outputWrites: ChannelOutputWrite[]; retVal: number; errVal: number; /** Output bytes/shared backing have reached guest-visible memory. */ materialized: boolean; /** Normal completions relisten themselves; raw callers opt in explicitly. */ relistenRequested: boolean; + /** + * Exact clone state needed only if a stopped process's deferred Worker + * constructor fails before this parked success can be published. + */ + deferredClone?: { + tid: number; + parentTidPointer?: number; + }; } interface ParkedChannelCompletion { @@ -991,6 +1294,14 @@ function createThreadChannelAttachment( export type SpawnProgramResolution = ResolvedSpawnProgram | SpawnResolveError; +interface ReservedSpawnScratch { + // A token exists before its pointer/capacity can be validated. Keeping that + // token in the value even when region construction fails guarantees the + // caller still reaches the one cleanup path. + region: KernelScratchRegion | null; + token: bigint; +} + function isSpawnResolveError( resolution: SpawnProgramResolution, ): resolution is SpawnResolveError { @@ -1154,9 +1465,16 @@ export class CentralizedKernelWorker { >(); /** Pids whose old image committed exec but whose replacement has no channel yet. */ private execHandoffPids = new Set(); - private scratchOffset = 0; - /** Kernel-owned transport for spawn blobs larger than one syscall channel. */ - private largeSpawnScratchOffset = 0; + /** Capacity travels with the allocator-owned pointer. */ + private scratchRegion: KernelScratchRegion | null = null; + /** + * Host-side half of the Rust reservation state machine. + * + * WHY: kernel imports can reenter JavaScript while an export is running. + * Keep the complete begin/copy/commit interval exclusive even after Rust + * has parsed the bytes and released its own reservation lock. + */ + private largeSpawnScratchInUse = false; private initialized = false; /** * Maps a pthread syscall mailbox to its kernel/libc thread id. @@ -1285,6 +1603,7 @@ export class CentralizedKernelWorker { origArgs: number[]; retVal: number; errVal: number; + outputWrites: ChannelOutputWrite[]; } >(); /** Threads blocked in rt_sigtimedwait, keyed by `pid:channelOffset`. */ @@ -1315,7 +1634,7 @@ export class CentralizedKernelWorker { /** UDP virtual-network endpoint bindings: "pid:sockIdx" */ private udpBindings = new Set(); /** Separate scratch buffer for TCP data pumping */ - private tcpScratchOffset = 0; + private tcpScratchRegion: KernelScratchRegion | null = null; /** Node.js net module (loaded dynamically for browser compatibility) */ private netModule: typeof import("net") | null = null; /** Deferred waitpid/waitid completions. Child matching/reap state is Rust-owned. */ @@ -1351,8 +1670,6 @@ export class CentralizedKernelWorker { Set >(); /** Cached kernel memory typed array view (invalidated on memory.grow) */ - private cachedKernelMem: Uint8Array | null = null; - private cachedKernelBuffer: ArrayBuffer | null = null; /** Pending poll/ppoll retries keyed by exact channel generation. */ private pendingPollRetries = new Map void; @@ -1708,11 +2024,14 @@ export class CentralizedKernelWorker { // scratch data and kernel heap structures like Vec). const allocScratch = this.kernelInstance.exports.kernel_alloc_scratch as (size: number) => KernelPointer; - this.scratchOffset = Number(allocScratch(SCRATCH_SIZE)); - if (this.scratchOffset === 0) { - throw new Error("Failed to allocate kernel scratch buffer"); - } - + this.scratchRegion = allocateKernelScratchRegion( + this.kernelMemory, + allocScratch, + SCRATCH_SIZE, + this.kernel.getKernelPtrWidth(), + "kernel syscall scratch", + this.kernelInstance, + ); // Try to load Node.js net module for TCP bridging try { const net = await import("net"); @@ -1725,110 +2044,948 @@ export class CentralizedKernelWorker { } // Allocate a separate scratch buffer for TCP data pumping - this.tcpScratchOffset = Number(allocScratch(65536)); - if (this.tcpScratchOffset === 0) { - throw new Error("Failed to allocate TCP scratch buffer"); + this.tcpScratchRegion = allocateKernelScratchRegion( + this.kernelMemory, + allocScratch, + 65536, + this.kernel.getKernelPtrWidth(), + "kernel TCP scratch", + this.kernelInstance, + ); + this.initialized = true; + } + + private requireMainScratchRegion(): KernelScratchRegion { + if (!this.scratchRegion) { + throw new KernelScratchError("kernel syscall scratch is not allocated"); } + return this.scratchRegion; + } - this.initialized = true; + private requireTcpScratchRegion(): KernelScratchRegion { + if (!this.tcpScratchRegion) { + throw new KernelScratchError("kernel TCP scratch is not allocated"); + } + return this.tcpScratchRegion; } - /** - * Ask the Rust kernel to allocate and create a process descriptor. - * - * The returned PID already names authoritative kernel state. Hosts may - * attach memory, channels, and a Worker to it, but never choose the PID. - */ - createProcess(stdio: RegisterProcessStdio): number { - if (!this.initialized) throw new Error("Kernel not initialized"); - const createProcess = this.kernelInstance!.exports.kernel_create_process_with_stdio as - ((stdinKind: number, stdoutKind: number, stderrKind: number) => number) | undefined; - if (!createProcess) { - throw new Error("Kernel missing kernel_create_process_with_stdio export"); + /** Validate one synchronous Rust producer result before copying its output. */ + private checkedScratchProducerByteLength( + result: number, + capacity: number, + label: string, + ): number { + if (!Number.isSafeInteger(result)) { + throw new KernelScratchError( + `${label} returned a non-integer byte count`, + EIO, + ); } - const pid = createProcess( - encodeStdioKind(stdio.stdin), - encodeStdioKind(stdio.stdout), - encodeStdioKind(stdio.stderr), - ); - if (pid <= 0) { - throw new Error(`Failed to create process: errno ${-pid}`); + if (result <= 0) return 0; + if (result > capacity) { + throw new KernelScratchError( + `${label} returned ${result} bytes for capacity ${capacity}`, + EIO, + ); } - return pid; + return result; + } + + private checkedProcessRange( + channel: ChannelInfo, + pointer: number | bigint, + length: number | bigint, + field: string, + allowAddressZero = false, + ): { pointer: number; length: number; end: number } { + return checkedMemoryRange( + channel.memory, + pointer, + length, + this.getPtrWidth(channel.pid), + field, + allowAddressZero, + ); } /** - * Attach process memory and thread channels to an existing kernel Process. - * Each channel is a region in the process's shared Memory. + * Preserve i64 channel slots until a handwritten host path has decided + * which arguments are process addresses. + * + * Generated descriptors perform this proof while planning their transfer. + * The syscalls below bypass that planner or use process addresses again + * during host-side memory bookkeeping. Normalizing only their pointer-sized + * slots keeps signed scalar arguments signed while preventing a wasm64 + * address from being rounded or narrowed to a low wasm32 address. */ - registerProcess( - pid: number, - memory: WebAssembly.Memory, - channelOffsets: number[], - options?: RegisterProcessOptions, + private checkHandwrittenProcessAddressArguments( + channel: ChannelInfo, + syscallNr: number, + args: number[], + rawArgs: readonly bigint[], ): void { - if (!this.initialized) throw new Error("Kernel not initialized"); - if (!Number.isSafeInteger(pid) || pid <= 0 || pid > MAX_KERNEL_TASK_ID) { - throw new Error(`Cannot register invalid kernel process ID ${pid}`); - } - if (channelOffsets.length !== 1) { - throw new Error( - `Process ${pid} must register exactly one main syscall channel`, + const pointerWidth = this.getPtrWidth(channel.pid); + const pointer = (index: number, field: string): void => { + args[index] = checkedWasmPointer( + rawArgs[index] ?? 0n, + pointerWidth, + field, ); - } + }; + const size = (index: number, field: string): void => { + try { + args[index] = checkedWasmPointer( + rawArgs[index] ?? 0n, + pointerWidth, + field, + ); + } catch (error) { + throw new KernelScratchError( + error instanceof Error ? error.message : `${field} is invalid`, + EINVAL, + ); + } + }; + const rangeEnd = ( + pointerIndex: number, + lengthIndex: number, + field: string, + ): void => { + const end = args[pointerIndex] + args[lengthIndex]; + if ( + !Number.isSafeInteger(end) + || end < args[pointerIndex] + ) { + throw new KernelScratchError(`${field} overflows`, EINVAL); + } + const alignedLength = + Math.ceil(args[lengthIndex] / WASM_PAGE_SIZE) * WASM_PAGE_SIZE; + if (!Number.isSafeInteger(alignedLength)) { + throw new KernelScratchError( + `${field} page alignment overflows`, + EINVAL, + ); + } + const alignedEnd = args[pointerIndex] + alignedLength; + if ( + !Number.isSafeInteger(alignedEnd) + || alignedEnd < args[pointerIndex] + ) { + throw new KernelScratchError( + `${field} page-aligned end overflows`, + EINVAL, + ); + } + }; - const getProcessState = this.kernelInstance!.exports.kernel_get_process_state as - ((pid: number) => number) | undefined; - const processState = getProcessState?.(pid); - if (processState === undefined || processState < 0) { - throw new Error(`Cannot register unknown kernel process ${pid}`); - } - if (processState !== PROCESS_STATE_RUNNING && processState !== PROCESS_STATE_STOPPED) { - throw new Error(`Cannot register inactive kernel process ${pid}`); + switch (syscallNr) { + case SYS_MMAP: + pointer(0, "mmap address"); + size(1, "mmap length"); + rangeEnd(0, 1, "mmap range"); + return; + case SYS_MUNMAP: + case SYS_MPROTECT: + case SYS_MSYNC: + pointer(0, `syscall ${syscallNr} address`); + size(1, `syscall ${syscallNr} length`); + rangeEnd(0, 1, `syscall ${syscallNr} range`); + return; + case SYS_MREMAP: { + pointer(0, "mremap old address"); + size(1, "mremap old length"); + size(2, "mremap new length"); + const flags = Number(BigInt.asUintN(32, rawArgs[3] ?? 0n)); + args[3] = flags; + if ((flags & MREMAP_FIXED) !== 0) { + pointer(4, "mremap fixed address"); + rangeEnd(4, 2, "mremap fixed range"); + } + rangeEnd(0, 1, "mremap old range"); + return; + } + case SYS_BRK: + pointer(0, "brk address"); + return; + case SYS_SPAWN: + pointer(0, "spawn path pointer"); + size(1, "spawn path length"); + pointer(2, "spawn blob pointer"); + size(3, "spawn blob length"); + pointer(4, "spawn pid output pointer"); + return; + case SYS_EXECVE: + pointer(0, "execve path pointer"); + pointer(1, "execve argv pointer"); + pointer(2, "execve environment pointer"); + return; + case SYS_EXECVEAT: + pointer(1, "execveat path pointer"); + pointer(2, "execveat argv pointer"); + pointer(3, "execveat environment pointer"); + return; + case SYS_CLONE: { + const flags = Number(BigInt.asUintN(32, rawArgs[0] ?? 0n)); + args[0] = flags; + pointer(1, "clone stack pointer"); + // The attachment handed to the new Worker carries this slot even + // when a future clone variant omits CLONE_SETTLS. + pointer(3, "clone TLS pointer"); + if ((flags & CLONE_PARENT_SETTID) !== 0) { + pointer(2, "clone parent tid pointer"); + } + if ((flags & (CLONE_CHILD_CLEARTID | CLONE_CHILD_SETTID)) !== 0) { + pointer(4, "clone child tid pointer"); + } + return; + } + case SYS_WAIT4: + pointer(1, "wait4 status pointer"); + pointer(3, "wait4 rusage pointer"); + return; + case SYS_WAITID: + pointer(2, "waitid siginfo pointer"); + pointer(4, "waitid rusage pointer"); + return; + case SYS_FUTEX: { + pointer(0, "futex uaddr"); + const op = Number(BigInt.asUintN(32, rawArgs[1] ?? 0n)); + args[1] = op; + const command = + op & ~(FUTEX_PRIVATE_FLAG | FUTEX_CLOCK_REALTIME); + if (command === FUTEX_WAIT || command === FUTEX_WAIT_BITSET) { + pointer(3, "futex timeout pointer"); + } + if ( + command === FUTEX_REQUEUE + || command === FUTEX_CMP_REQUEUE + || command === FUTEX_WAKE_OP + ) { + pointer(4, "futex uaddr2"); + } + return; + } + case SYS_WRITEV: + case SYS_PWRITEV: + case SYS_PWRITEV2: + case SYS_READV: + case SYS_PREADV: + case SYS_PREADV2: + pointer(1, "iovec table pointer"); + return; + case SYS_SENDMSG: + case SYS_RECVMSG: + pointer(1, "message header pointer"); + return; + case SYS_PSELECT6: + pointer(1, "pselect6 read fd_set pointer"); + pointer(2, "pselect6 write fd_set pointer"); + pointer(3, "pselect6 except fd_set pointer"); + pointer(4, "pselect6 timeout pointer"); + pointer(5, "pselect6 mask descriptor pointer"); + return; + case SYS_SELECT: + pointer(1, "select read fd_set pointer"); + pointer(2, "select write fd_set pointer"); + pointer(3, "select except fd_set pointer"); + pointer(4, "select timeout pointer"); + return; + case SYS_FCNTL: { + const command = args[1]; + if ( + command === F_GETLK + || command === F_SETLK + || command === F_SETLKW + || command === F_GETLK64 + || command === F_SETLK64 + || command === F_SETLKW64 + || command === F_OFD_GETLK + || command === F_OFD_SETLK + || command === F_OFD_SETLKW + ) { + pointer(2, "fcntl flock pointer"); + } + return; + } + case SYS_IOCTL: { + const request = Number(BigInt.asUintN(32, rawArgs[1] ?? 0n)); + if ( + request === SIOCGIFCONF + || request === SIOCGIFNAME + || request === SIOCGIFHWADDR + || request === SIOCGIFADDR + || request === SIOCGIFINDEX + ) { + pointer(2, "network ioctl pointer"); + } + return; + } + case SYS_WRITE: + case SYS_PWRITE: + case SYS_READ: + case SYS_PREAD: + if ((rawArgs[2] ?? 0n) > BigInt(CH_DATA_SIZE)) { + pointer(1, "large I/O buffer pointer"); + size(2, "large I/O byte count"); + } + return; } - if (pid === 1) { - throw new Error("Cannot register the kernel-reserved init process"); + } + + private normalizeKernelSyscallResult( + channel: ChannelInfo, + syscallNr: number, + rawRetVal: bigint, + errVal: number, + ): { retVal: number; errVal: number } { + if ( + rawRetVal < 0n + || ( + syscallNr !== SYS_MMAP + && syscallNr !== SYS_MREMAP + && syscallNr !== SYS_BRK + ) + ) { + return { retVal: Number(rawRetVal), errVal }; } - const existingRegistration = this.processes.get(pid); - const replacingExecImage = - options?.preserveProcessState === true - && this.execHandoffPids?.has(pid) === true - && existingRegistration?.channels.length === 0; - if (existingRegistration && !replacingExecImage) { - throw new Error(`Process ${pid} is already registered with the host`); + try { + // WHY: these positive results become process-memory indices. Validate + // the complete i64 before any grow, zero, copy, or mapping mutation can + // reinterpret its low 32 bits. + return { + retVal: checkedWasmPointer( + rawRetVal, + this.getPtrWidth(channel.pid), + `syscall ${syscallNr} returned address`, + ), + errVal, + }; + } catch { + return { retVal: -1, errVal: EOVERFLOW }; } + } - // Registration replaces every channel object for this pid. Exec keeps the - // authoritative stopped state; a genuinely fresh kernel Process does not. - this.discardStoppedChannelStateForProcess(pid, !options?.preserveProcessState); - - if (options?.argv !== undefined || options?.env !== undefined) { - const metadataResult = this.validateExecMetadata( - options.argv ?? [], - options.env ?? [], - options.metadataPtrWidth ?? options.ptrWidth ?? 4, + private checkedProcessIovecs( + channel: ChannelInfo, + iovPointer: number | bigint, + iovCount: number, + allowEmpty: boolean, + ): CheckedProcessIovecs { + if ( + !Number.isSafeInteger(iovCount) || + iovCount < (allowEmpty ? 0 : 1) || + iovCount > POSIX_IOV_MAX + ) { + throw new KernelScratchError( + `iovec count must be ${allowEmpty ? "between 0" : "between 1"} and ${POSIX_IOV_MAX}`, + EINVAL, ); - if (metadataResult < 0) { - throw new Error(`Process argv/environment exceeds exec metadata limits: errno ${-metadataResult}`); - } } - - // Kernel task IDs are never reused. Clear any stale host lifecycle marker - // defensively before installing this task's transport registration. - this.hostReaped.delete(pid); - - if (options?.brkBase !== undefined) { - if (!this.setBrkBase(pid, options.brkBase)) { - throw new Error( - "Kernel export kernel_set_brk_base is required for compact process memory layout", + if (iovCount === 0) return { entries: [], totalData: 0 }; + const pointerWidth = this.getPtrWidth(channel.pid); + const layout = this.processIovecLayout(pointerWidth); + const tableBytes = iovCount * layout.size; + if (!Number.isSafeInteger(tableBytes)) { + throw new KernelScratchError("process iovec table size overflows", EINVAL); + } + const table = this.checkedProcessRange( + channel, + iovPointer, + tableBytes, + "process iovec table", + // WHY: zero is an addressable byte in caller process linear memory. + // It means allocator failure only for kernel allocator/export results, + // so a nonempty caller-owned table at address zero is valid when its + // complete native table range fits. + true, + ); + const processView = new DataView( + channel.memory.buffer, + table.pointer, + table.length, + ); + const entries: CheckedProcessIovec[] = []; + let totalData = 0; + for (let index = 0; index < iovCount; index++) { + const offset = index * layout.size; + const rawBase = pointerWidth === 8 + ? processView.getBigUint64(offset + layout.baseOffset, true) + : processView.getUint32(offset + layout.baseOffset, true); + const rawLength = pointerWidth === 8 + ? processView.getBigUint64(offset + layout.lenOffset, true) + : processView.getUint32(offset + layout.lenOffset, true); + const base = checkedWasmPointer( + rawBase, + pointerWidth, + `iovec[${index}] base`, + ); + const lengthRange = rawLength === 0n || rawLength === 0 + ? { pointer: base, length: 0 } + : this.checkedProcessRange( + channel, + rawBase, + rawLength, + `iovec[${index}] data`, + // WHY: like the table itself, positive-length caller data may + // begin at linear-memory address zero. The complete range proof, + // rather than null-pointer convention, establishes ownership. + true, + ); + const len = lengthRange.length; + totalData += len; + if (!Number.isSafeInteger(totalData) || totalData > 0x7fff_ffff) { + throw new KernelScratchError( + "aggregate iovec length exceeds SSIZE_MAX", + EINVAL, ); } + entries.push({ base, len }); } + return { entries, totalData }; + } - // Set process argv in kernel for /proc//cmdline - if (options?.argv !== undefined) { - this.replaceProcessMetadata(pid, PROCESS_METADATA_ARGV, options.argv); - } + private processIovecLayout(pointerWidth: 4 | 8): ProcessIovecLayout { + return pointerWidth === 8 + ? { + size: PROCESS_IOVEC_WASM64_SIZE, + baseOffset: PROCESS_IOVEC_WASM64_BASE_OFFSET, + lenOffset: PROCESS_IOVEC_WASM64_LEN_OFFSET, + } + : { + size: PROCESS_IOVEC_WASM32_SIZE, + baseOffset: PROCESS_IOVEC_WASM32_BASE_OFFSET, + lenOffset: PROCESS_IOVEC_WASM32_LEN_OFFSET, + }; + } + + private processMessageLayout(pointerWidth: 4 | 8): ProcessMessageLayout { + return pointerWidth === 8 + ? { + size: PROCESS_MSGHDR_WASM64_SIZE, + nameOffset: PROCESS_MSGHDR_WASM64_NAME_OFFSET, + nameLengthOffset: PROCESS_MSGHDR_WASM64_NAMELEN_OFFSET, + iovecOffset: PROCESS_MSGHDR_WASM64_IOV_OFFSET, + iovecCountOffset: PROCESS_MSGHDR_WASM64_IOVLEN_OFFSET, + controlOffset: PROCESS_MSGHDR_WASM64_CONTROL_OFFSET, + controlLengthOffset: PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET, + flagsOffset: PROCESS_MSGHDR_WASM64_FLAGS_OFFSET, + } + : { + size: PROCESS_MSGHDR_WASM32_SIZE, + nameOffset: PROCESS_MSGHDR_WASM32_NAME_OFFSET, + nameLengthOffset: PROCESS_MSGHDR_WASM32_NAMELEN_OFFSET, + iovecOffset: PROCESS_MSGHDR_WASM32_IOV_OFFSET, + iovecCountOffset: PROCESS_MSGHDR_WASM32_IOVLEN_OFFSET, + controlOffset: PROCESS_MSGHDR_WASM32_CONTROL_OFFSET, + controlLengthOffset: PROCESS_MSGHDR_WASM32_CONTROLLEN_OFFSET, + flagsOffset: PROCESS_MSGHDR_WASM32_FLAGS_OFFSET, + }; + } + + private processControlMessageLayout( + pointerWidth: 4 | 8, + ): ProcessControlMessageLayout { + return pointerWidth === 8 + ? { + size: PROCESS_CMSGHDR_WASM64_SIZE, + alignment: PROCESS_CMSGHDR_WASM64_ALIGN, + lengthOffset: PROCESS_CMSGHDR_WASM64_LEN_OFFSET, + levelOffset: PROCESS_CMSGHDR_WASM64_LEVEL_OFFSET, + typeOffset: PROCESS_CMSGHDR_WASM64_TYPE_OFFSET, + dataOffset: PROCESS_CMSGHDR_WASM64_DATA_OFFSET, + } + : { + size: PROCESS_CMSGHDR_WASM32_SIZE, + alignment: PROCESS_CMSGHDR_WASM32_ALIGN, + lengthOffset: PROCESS_CMSGHDR_WASM32_LEN_OFFSET, + levelOffset: PROCESS_CMSGHDR_WASM32_LEVEL_OFFSET, + typeOffset: PROCESS_CMSGHDR_WASM32_TYPE_OFFSET, + dataOffset: PROCESS_CMSGHDR_WASM32_DATA_OFFSET, + }; + } + + private checkedAlignUp( + value: number, + alignment: number, + context: string, + ): number { + if ( + !Number.isSafeInteger(value) || + value < 0 || + !Number.isSafeInteger(alignment) || + alignment <= 0 + ) { + throw new KernelScratchError(`${context} is not representable`, EINVAL); + } + const remainder = value % alignment; + const aligned = remainder === 0 ? value : value + alignment - remainder; + if (!Number.isSafeInteger(aligned)) { + throw new KernelScratchError(`${context} overflows`, EINVAL); + } + return aligned; + } + + private rejectScratchTransfer( + channel: ChannelInfo, + error: unknown, + ): void { + const errno = error instanceof KernelScratchError ? error.errno : EFAULT; + this.completeChannelRaw(channel, -1, errno); + this.relistenChannel(channel); + } + + private kernelIovecFootprint(entries: CheckedProcessIovec[]): number { + let offset = entries.length * STRUCT_SIZE_KERNEL_IOVEC_WIRE; + if (!Number.isSafeInteger(offset)) { + throw new KernelScratchError("kernel iovec table size overflows", EINVAL); + } + for (const entry of entries) { + offset += entry.len; + if (!Number.isSafeInteger(offset)) { + throw new KernelScratchError("kernel iovec layout overflows", EINVAL); + } + offset = this.checkedAlignUp( + offset, + KERNEL_IOVEC_WIRE_ALIGN, + "kernel iovec layout", + ); + } + return offset; + } + + private checkedProcessMessage( + channel: ChannelInfo, + messagePointerValue: number | bigint, + ): CheckedProcessMessage { + const pointerWidth = this.getPtrWidth(channel.pid); + const layout = this.processMessageLayout(pointerWidth); + const message = this.checkedProcessRange( + channel, + messagePointerValue, + layout.size, + "process msghdr", + ); + const view = new DataView( + channel.memory.buffer, + message.pointer, + message.length, + ); + const rawNamePointer = pointerWidth === 8 + ? view.getBigUint64(layout.nameOffset, true) + : view.getUint32(layout.nameOffset, true); + const nameLength = view.getUint32(layout.nameLengthOffset, true); + const rawIovecPointer = pointerWidth === 8 + ? view.getBigUint64(layout.iovecOffset, true) + : view.getUint32(layout.iovecOffset, true); + const iovecCount = view.getUint32(layout.iovecCountOffset, true); + const rawControlPointer = pointerWidth === 8 + ? view.getBigUint64(layout.controlOffset, true) + : view.getUint32(layout.controlOffset, true); + const controlLength = view.getUint32(layout.controlLengthOffset, true); + + const checkedOptionalRange = ( + pointer: number | bigint, + length: number, + field: string, + ): { pointer: number; length: number } => { + if (length === 0) { + return { + pointer: checkedWasmPointer(pointer, pointerWidth, `${field} pointer`), + length: 0, + }; + } + return this.checkedProcessRange(channel, pointer, length, field); + }; + + return { + pointerWidth, + messagePointer: message.pointer, + name: checkedOptionalRange( + rawNamePointer, + nameLength, + "msg_name", + ), + control: checkedOptionalRange( + rawControlPointer, + controlLength, + "msg_control", + ), + iovecs: this.checkedProcessIovecs( + channel, + rawIovecPointer, + iovecCount, + true, + ), + }; + } + + private nativeControlToKernelWire( + processMem: Uint8Array, + message: CheckedProcessMessage, + ): Uint8Array { + if (message.control.length === 0) return new Uint8Array(0); + const native = this.processControlMessageLayout(message.pointerWidth); + const source = new DataView( + processMem.buffer, + processMem.byteOffset + message.control.pointer, + message.control.length, + ); + const records: Array<{ + level: number; + type: number; + data: Uint8Array; + wireLength: number; + wireSpace: number; + }> = []; + let nativeOffset = 0; + let wireBytes = 0; + while (nativeOffset + native.size <= message.control.length) { + const cmsgLength = source.getUint32( + nativeOffset + native.lengthOffset, + true, + ); + if (cmsgLength < native.dataOffset) { + throw new KernelScratchError( + "native control message header is malformed", + EINVAL, + ); + } + const nativeEnd = nativeOffset + cmsgLength; + if ( + !Number.isSafeInteger(nativeEnd) || + nativeEnd > message.control.length + ) { + throw new KernelScratchError( + "native control message exceeds msg_controllen", + EINVAL, + ); + } + const level = source.getUint32(nativeOffset + native.levelOffset, true); + const type = source.getUint32(nativeOffset + native.typeOffset, true); + const dataLength = cmsgLength - native.dataOffset; + if ( + level === SOCKET_SOL_SOCKET && + type === SOCKET_SCM_RIGHTS && + dataLength % SCM_RIGHTS_FD_BYTES !== 0 + ) { + throw new KernelScratchError( + "SCM_RIGHTS payload is not an array of file descriptors", + EINVAL, + ); + } + const wireLength = KERNEL_CMSGHDR_WIRE_DATA_OFFSET + dataLength; + const wireSpace = this.checkedAlignUp( + wireLength, + KERNEL_CMSGHDR_WIRE_ALIGN, + "kernel control message", + ); + wireBytes += wireSpace; + if (!Number.isSafeInteger(wireBytes) || wireBytes > CH_DATA_SIZE) { + throw new KernelScratchError( + "control messages exceed bounded kernel transport", + 90, + ); + } + records.push({ + level, + type, + data: processMem.slice( + message.control.pointer + nativeOffset + native.dataOffset, + message.control.pointer + nativeEnd, + ), + wireLength, + wireSpace, + }); + nativeOffset = this.checkedAlignUp( + nativeEnd, + native.alignment, + "native control message", + ); + } + + const output = new Uint8Array(wireBytes); + const view = new DataView(output.buffer); + let wireOffset = 0; + for (const record of records) { + view.setUint32( + wireOffset + KERNEL_CMSGHDR_WIRE_LEN_OFFSET, + record.wireLength, + true, + ); + view.setUint32( + wireOffset + KERNEL_CMSGHDR_WIRE_LEVEL_OFFSET, + record.level, + true, + ); + view.setUint32( + wireOffset + KERNEL_CMSGHDR_WIRE_TYPE_OFFSET, + record.type, + true, + ); + output.set( + record.data, + wireOffset + KERNEL_CMSGHDR_WIRE_DATA_OFFSET, + ); + wireOffset += record.wireSpace; + } + return output; + } + + private kernelControlCapacityForRecv( + message: CheckedProcessMessage, + ): number { + const native = this.processControlMessageLayout(message.pointerWidth); + if ( + message.control.length < + native.dataOffset + SCM_RIGHTS_FD_BYTES + ) { + return 0; + } + const descriptorCapacity = Math.floor( + (message.control.length - native.dataOffset) / SCM_RIGHTS_FD_BYTES, + ); + // WHY: Rust emits at most one SCM_RIGHTS record. Bound its fixed-wire FD + // capacity by what the wider caller-native header can represent, so it + // cannot install descriptors that expansion back to wasm64 would lose. + return KERNEL_CMSGHDR_WIRE_DATA_OFFSET + + descriptorCapacity * SCM_RIGHTS_FD_BYTES; + } + + private kernelControlToNative( + wireBytes: Uint8Array, + message: CheckedProcessMessage, + ): { bytes: Uint8Array; length: number } { + if (wireBytes.length === 0) { + return { bytes: new Uint8Array(0), length: 0 }; + } + if (wireBytes.length < STRUCT_SIZE_KERNEL_CMSGHDR_WIRE) { + throw new KernelScratchError( + "kernel returned a partial control message header", + EIO, + ); + } + const wire = new DataView( + wireBytes.buffer, + wireBytes.byteOffset, + wireBytes.byteLength, + ); + const cmsgLength = wire.getUint32(KERNEL_CMSGHDR_WIRE_LEN_OFFSET, true); + if ( + cmsgLength < KERNEL_CMSGHDR_WIRE_DATA_OFFSET || + cmsgLength > wireBytes.length || + this.checkedAlignUp( + cmsgLength, + KERNEL_CMSGHDR_WIRE_ALIGN, + "returned kernel control message", + ) !== wireBytes.length + ) { + throw new KernelScratchError( + "kernel returned a malformed control message", + EIO, + ); + } + const level = wire.getUint32(KERNEL_CMSGHDR_WIRE_LEVEL_OFFSET, true); + const type = wire.getUint32(KERNEL_CMSGHDR_WIRE_TYPE_OFFSET, true); + const dataLength = cmsgLength - KERNEL_CMSGHDR_WIRE_DATA_OFFSET; + if ( + level !== SOCKET_SOL_SOCKET || + type !== SOCKET_SCM_RIGHTS || + dataLength === 0 || + dataLength % SCM_RIGHTS_FD_BYTES !== 0 + ) { + throw new KernelScratchError( + "kernel returned an unsupported control message", + EIO, + ); + } + + const native = this.processControlMessageLayout(message.pointerWidth); + const nativeLength = native.dataOffset + dataLength; + if (nativeLength > message.control.length) { + throw new KernelScratchError( + "kernel control message exceeds caller capacity", + EIO, + ); + } + const reportedLength = Math.min( + message.control.length, + this.checkedAlignUp( + nativeLength, + native.alignment, + "native returned control message", + ), + ); + const output = new Uint8Array(reportedLength); + const outputView = new DataView(output.buffer); + outputView.setUint32(native.lengthOffset, nativeLength, true); + outputView.setUint32(native.levelOffset, level, true); + outputView.setUint32(native.typeOffset, type, true); + output.set( + wireBytes.subarray( + KERNEL_CMSGHDR_WIRE_DATA_OFFSET, + KERNEL_CMSGHDR_WIRE_DATA_OFFSET + dataLength, + ), + native.dataOffset, + ); + return { bytes: output, length: reportedLength }; + } + + private kernelMessageLayout( + message: CheckedProcessMessage, + controlCapacity: number, + ): KernelMessageLayout { + let offset: number = STRUCT_SIZE_KERNEL_MSGHDR_WIRE; + const append = (length: number): number => { + const start = offset; + offset += length; + if (!Number.isSafeInteger(offset)) { + throw new KernelScratchError("kernel msghdr layout overflows", EINVAL); + } + offset = this.checkedAlignUp( + offset, + KERNEL_MSGHDR_WIRE_ALIGN, + "kernel msghdr layout", + ); + return start; + }; + const nameOffset = message.name.length > 0 + ? append(message.name.length) + : 0; + const controlOffset = controlCapacity > 0 + ? append(controlCapacity) + : 0; + const iovecCount = message.iovecs.entries.length > 0 + ? FLATTENED_KERNEL_MESSAGE_IOVEC_COUNT + : 0; + const iovecBytes = iovecCount * STRUCT_SIZE_KERNEL_IOVEC_WIRE; + const iovecOffset = iovecBytes > 0 + ? append(iovecBytes) + : 0; + const dataOffset = message.iovecs.totalData > 0 + ? append(message.iovecs.totalData) + : 0; + return { + footprint: offset, + nameOffset, + controlOffset, + controlCapacity, + iovecOffset, + iovecCount, + iovecBytes, + dataOffset, + }; + } + + private checkedKernelWirePointer(pointer: number): number { + if ( + !Number.isSafeInteger(pointer) || + pointer < 0 || + pointer > 0xffff_ffff + ) { + throw new KernelScratchError( + "kernel wire pointer does not fit its u32 field", + EIO, + ); + } + return pointer; + } + + /** + * Ask the Rust kernel to allocate and create a process descriptor. + * + * The returned PID already names authoritative kernel state. Hosts may + * attach memory, channels, and a Worker to it, but never choose the PID. + */ + createProcess(stdio: RegisterProcessStdio): number { + if (!this.initialized) throw new Error("Kernel not initialized"); + const createProcess = this.kernelInstance!.exports.kernel_create_process_with_stdio as + ((stdinKind: number, stdoutKind: number, stderrKind: number) => number) | undefined; + if (!createProcess) { + throw new Error("Kernel missing kernel_create_process_with_stdio export"); + } + const pid = createProcess( + encodeStdioKind(stdio.stdin), + encodeStdioKind(stdio.stdout), + encodeStdioKind(stdio.stderr), + ); + if (pid <= 0) { + throw new Error(`Failed to create process: errno ${-pid}`); + } + return pid; + } + + /** + * Attach process memory and thread channels to an existing kernel Process. + * Each channel is a region in the process's shared Memory. + */ + registerProcess( + pid: number, + memory: WebAssembly.Memory, + channelOffsets: number[], + options?: RegisterProcessOptions, + ): void { + if (!this.initialized) throw new Error("Kernel not initialized"); + if (!Number.isSafeInteger(pid) || pid <= 0 || pid > MAX_KERNEL_TASK_ID) { + throw new Error(`Cannot register invalid kernel process ID ${pid}`); + } + if (channelOffsets.length !== 1) { + throw new Error( + `Process ${pid} must register exactly one main syscall channel`, + ); + } + + const getProcessState = this.kernelInstance!.exports.kernel_get_process_state as + ((pid: number) => number) | undefined; + const processState = getProcessState?.(pid); + if (processState === undefined || processState < 0) { + throw new Error(`Cannot register unknown kernel process ${pid}`); + } + if (processState !== PROCESS_STATE_RUNNING && processState !== PROCESS_STATE_STOPPED) { + throw new Error(`Cannot register inactive kernel process ${pid}`); + } + if (pid === 1) { + throw new Error("Cannot register the kernel-reserved init process"); + } + const existingRegistration = this.processes.get(pid); + const replacingExecImage = + options?.preserveProcessState === true + && this.execHandoffPids?.has(pid) === true + && existingRegistration?.channels.length === 0; + if (existingRegistration && !replacingExecImage) { + throw new Error(`Process ${pid} is already registered with the host`); + } + + // Registration replaces every channel object for this pid. Exec keeps the + // authoritative stopped state; a genuinely fresh kernel Process does not. + this.discardStoppedChannelStateForProcess(pid, !options?.preserveProcessState); + + if (options?.argv !== undefined || options?.env !== undefined) { + const metadataResult = this.validateExecMetadata( + options.argv ?? [], + options.env ?? [], + options.metadataPtrWidth ?? options.ptrWidth ?? 4, + ); + if (metadataResult < 0) { + throw new Error(`Process argv/environment exceeds exec metadata limits: errno ${-metadataResult}`); + } + } + + // Kernel task IDs are never reused. Clear any stale host lifecycle marker + // defensively before installing this task's transport registration. + this.hostReaped.delete(pid); + + if (options?.brkBase !== undefined) { + if (!this.setBrkBase(pid, options.brkBase)) { + throw new Error( + "Kernel export kernel_set_brk_base is required for compact process memory layout", + ); + } + } + + // Set process argv in kernel for /proc//cmdline + if (options?.argv !== undefined) { + this.replaceProcessMetadata(pid, PROCESS_METADATA_ARGV, options.argv); + } // Keep kernel-owned environment state synchronized with the process // worker. This matters for exec even when the replacement envp is empty. @@ -1920,24 +3077,15 @@ export class CentralizedKernelWorker { let totalBytes = 2 * ptrWidth; for (const value of [...argv, ...env]) { const encodedLength = encoder.encode(value).byteLength; - if (encodedLength > CH_DATA_SIZE) return -E2BIG; + if (encodedLength > PROCESS_METADATA_ENTRY_MAX_BYTES) return -E2BIG; totalBytes += ptrWidth + encodedLength + 1; - if (!Number.isSafeInteger(totalBytes) || totalBytes > EXEC_METADATA_MAX_BYTES) { + if (!Number.isSafeInteger(totalBytes) || totalBytes > POSIX_ARG_MAX_BYTES) { return -E2BIG; } } return 0; } - /** Whether this kernel supports lossless bounded argv+environment replacement. */ - supportsExecMetadataReplacement(): boolean { - const exports = this.kernelInstance?.exports; - return ( - typeof exports?.kernel_clear_process_metadata === "function" && - typeof exports?.kernel_push_process_metadata_entry === "function" - ); - } - /** Replace argv or environ using bounded, entry-at-a-time scratch copies. */ private replaceProcessMetadata( pid: number, @@ -1955,38 +3103,14 @@ export class CentralizedKernelWorker { dataLen: number, ) => number) | undefined; - if (!clear || !push) { - // Additive ABI-16 compatibility for ordinary initial registrations: - // older kernels can still receive a small argv through their legacy - // aggregate setter. Exec preflight rejects before commit because that - // legacy surface cannot explicitly replace/clear the environment. - const setArgv = this.kernelInstance!.exports.kernel_set_process_argv as - | ((pid: number, dataPtr: KernelPointer, dataLen: number) => number) - | undefined; - if (kind !== PROCESS_METADATA_ARGV || !setArgv) { - throw new Error("Kernel missing bounded process metadata exports"); - } - const encoded = new TextEncoder().encode(values.join("\0")); - if (encoded.byteLength > CH_DATA_SIZE) { - throw new Error( - `Legacy process argv exceeds bounded scratch transport: errno ${E2BIG}`, - ); - } - new Uint8Array(this.kernelMemory!.buffer).set( - encoded, - this.scratchOffset, - ); - const result = setArgv( - pid, - this.toKernelPtr(this.scratchOffset), - encoded.byteLength, + if (typeof clear !== "function" || typeof push !== "function") { + // WHY: current-ABI admission requires both bounded entry-at-a-time + // exports. Falling back to the historical aggregate argv setter would + // silently lose environment replacement and revive a pointer-only + // transport after the capacity-safe contract was negotiated. + throw new Error( + "Kernel missing required bounded process metadata exports", ); - if (result < 0) { - throw new Error( - `Failed to replace process argv for pid ${pid}: errno ${-result}`, - ); - } - return; } const clearResult = clear(pid, kind); @@ -1999,21 +3123,25 @@ export class CentralizedKernelWorker { const encoder = new TextEncoder(); for (const value of values) { const encoded = encoder.encode(value); - if (encoded.byteLength > CH_DATA_SIZE) { + if (encoded.byteLength > PROCESS_METADATA_ENTRY_MAX_BYTES) { throw new Error( `Process metadata entry exceeds bounded scratch transport: errno ${E2BIG}`, ); } - // A preceding Rust push can allocate and grow kernel Wasm memory, - // detaching the old ArrayBuffer view. Refresh it for every entry. - const kernelMem = new Uint8Array(this.kernelMemory!.buffer); - kernelMem.set(encoded, this.scratchOffset); - const pushResult = push( - pid, - kind, - this.toKernelPtr(this.scratchOffset), - encoded.byteLength, - ); + // A Rust push can grow memory. A fresh lease rechecks the replacement + // buffer and owns the bytes through the complete synchronous parse. + const pushResult = this.requireMainScratchRegion().withLease((scratch) => { + scratch.copyFrom(encoded); + return scratch.invokeKernelExport( + "kernel_push_process_metadata_entry", + [ + pid, + kind, + scratch.exportPointer(0, encoded.byteLength), + encoded.byteLength, + ], + ); + }); if (pushResult < 0) { throw new Error(`Failed to append process metadata for pid ${pid}: errno ${-pushResult}`); } @@ -2089,9 +3217,35 @@ export class CentralizedKernelWorker { const kernelPtyMasterWrite = this.kernelInstance!.exports.kernel_pty_master_write as ((ptyIdx: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; if (!kernelPtyMasterWrite) return; - const buf = new Uint8Array(this.kernelMemory!.buffer); - buf.set(data, this.scratchOffset); - kernelPtyMasterWrite(ptyIdx, this.toKernelPtr(this.scratchOffset), data.length); + const exactData = intrinsicUint8ArrayView(data, "PTY input"); + const scratch = this.requireMainScratchRegion(); + scratch.withLease((lease) => { + let offset = 0; + while (offset < exactData.byteLength) { + const chunkLength = Math.min( + exactData.byteLength - offset, + scratch.capacity, + ); + lease.copyFrom(exactData, 0, offset, chunkLength); + const written = lease.invokeKernelExport( + "kernel_pty_master_write", + [ + ptyIdx, + lease.exportPointer(0, chunkLength), + chunkLength, + ], + ); + if (!Number.isSafeInteger(written) || written > chunkLength) { + throw new KernelScratchError( + "kernel PTY write exceeded its staged input", + EIO, + ); + } + if (written <= 0) break; + offset += written; + if (written < chunkLength) break; + } + }); // Drain echo/output produced by the line discipline this.drainPtyOutput(ptyIdx); // Wake any process blocked on slave read @@ -2106,14 +3260,29 @@ export class CentralizedKernelWorker { const kernelPtyMasterRead = this.kernelInstance!.exports.kernel_pty_master_read as ((ptyIdx: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; if (!kernelPtyMasterRead) return null; - const SCRATCH_READ_SIZE = 4096; - const n = kernelPtyMasterRead(ptyIdx, this.toKernelPtr(this.scratchOffset), SCRATCH_READ_SIZE); - if (n <= 0) return null; - const buf = new Uint8Array(this.kernelMemory!.buffer); - return buf.slice(this.scratchOffset, this.scratchOffset + n); - } - - /** + const scratch = this.requireMainScratchRegion(); + const request = Math.min(4096, scratch.capacity); + return scratch.withLease((lease) => { + const n = lease.invokeKernelExport( + "kernel_pty_master_read", + [ + ptyIdx, + lease.exportPointer(0, request), + request, + ], + ); + if (n <= 0) return null; + if (!Number.isSafeInteger(n) || n > request) { + throw new KernelScratchError( + "kernel PTY read exceeded its requested scratch capacity", + EIO, + ); + } + return lease.copyOut(0, n); + }); + } + + /** * Resize a PTY and send SIGWINCH to the foreground process group. */ ptySetWinsize(ptyIdx: number, rows: number, cols: number): void { @@ -2186,16 +3355,26 @@ export class CentralizedKernelWorker { if (!this.initialized) throw new Error("Kernel not initialized"); const kernelSetCwd = this.kernelInstance!.exports.kernel_set_cwd as ((pid: number, ptr: KernelPointer, len: number) => number) | undefined; - if (!kernelSetCwd) return; // older kernel without this export + if (typeof kernelSetCwd !== "function") { + // WHY: initial cwd is part of the current host/kernel contract. A + // silent older-kernel no-op would report a different cwd than the + // process actually owns after the checked scratch transfer. + throw new Error("Kernel missing required kernel_set_cwd export"); + } const encoded = new TextEncoder().encode(cwd); - // Use the pre-allocated scratch area in kernel memory - const buf = new Uint8Array(this.kernelMemory!.buffer); - buf.set(encoded, this.scratchOffset); - const result = kernelSetCwd( - pid, - this.toKernelPtr(this.scratchOffset), - encoded.length, - ); + // kernel_set_cwd applies PATH_MAX too, but that would be after the host + // copy. Reject first so an oversized pathname never reaches scratch. + if (encoded.byteLength >= POSIX_PATH_MAX_BYTES) { + throw new Error(`setCwd failed for pid ${pid}: cwd exceeds PATH_MAX`); + } + const result = this.requireMainScratchRegion().withLease((scratch) => { + scratch.copyFrom(encoded); + return scratch.invokeKernelExport("kernel_set_cwd", [ + pid, + scratch.exportPointer(0, encoded.byteLength), + encoded.byteLength, + ]); + }); if (result < 0) { throw new Error(`setCwd failed for pid ${pid}: errno ${-result}`); } @@ -2269,17 +3448,23 @@ export class CentralizedKernelWorker { const enumProcs = this.kernelInstance!.exports.kernel_enum_procs as ((ptr: KernelPointer, len: number) => number) | undefined; if (!enumProcs) return []; - const n = enumProcs(this.toKernelPtr(this.scratchOffset), SCRATCH_SIZE); - if (n <= 0) return []; - // The kernel memory is a SharedArrayBuffer; TextDecoder refuses - // shared views. Copy to a regular ArrayBuffer before parsing. - const shared = new Uint8Array( - this.kernelMemory!.buffer, - this.scratchOffset, - n, - ); - const owned = new Uint8Array(n); - owned.set(shared); + const scratch = this.requireMainScratchRegion(); + const owned = scratch.withLease((lease) => { + const request = Math.min(SCRATCH_SIZE, scratch.capacity); + const n = lease.invokeKernelExport("kernel_enum_procs", [ + lease.exportPointer(0, request), + request, + ]); + if (n <= 0) return null; + if (!Number.isSafeInteger(n) || n > request) { + throw new KernelScratchError( + "kernel process enumeration exceeded scratch capacity", + EIO, + ); + } + return lease.copyOut(0, n); + }); + if (!owned) return []; const snapshots = parseProcSnapshots(owned); for (const snapshot of snapshots) { const registration = this.processes.get(snapshot.pid); @@ -2300,18 +3485,25 @@ export class CentralizedKernelWorker { const readMaps = this.kernelInstance!.exports.kernel_read_proc_maps as ((pid: number, ptr: KernelPointer, len: number) => number) | undefined; if (!readMaps) return null; - const n = readMaps(pid, this.toKernelPtr(this.scratchOffset), SCRATCH_SIZE); - if (n < 0) return null; // -ESRCH or similar - if (n === 0) return ""; - // SharedArrayBuffer view → TextDecoder doesn't accept shared views. - // Copy out before decoding. - const shared = new Uint8Array( - this.kernelMemory!.buffer, - this.scratchOffset, - n, - ); - const owned = new Uint8Array(n); - owned.set(shared); + const scratch = this.requireMainScratchRegion(); + const owned = scratch.withLease((lease) => { + const request = Math.min(SCRATCH_SIZE, scratch.capacity); + const n = lease.invokeKernelExport("kernel_read_proc_maps", [ + pid, + lease.exportPointer(0, request), + request, + ]); + if (n < 0) return null; // -ESRCH or similar + if (n === 0) return new Uint8Array(0); + if (!Number.isSafeInteger(n) || n > request) { + throw new KernelScratchError( + "kernel process maps output exceeded scratch capacity", + EIO, + ); + } + return lease.copyOut(0, n); + }); + if (owned === null) return null; return new TextDecoder("utf-8", { fatal: false }).decode(owned); } @@ -3557,7 +4749,7 @@ export class CentralizedKernelWorker { ); channel.i32View = i32View; - const statusIndex = CH_STATUS / 4; + const statusIndex = CH_STATUS / Int32Array.BYTES_PER_ELEMENT; // Check if already pending (process might have sent before we started listening) const currentStatus = Atomics.load(i32View, statusIndex); @@ -3622,21 +4814,12 @@ export class CentralizedKernelWorker { * 1. Read syscall number + args from process Memory * 2. For each pointer arg: copy data from process Memory to kernel scratch * 3. Write adjusted args to kernel scratch channel header - * 4. Call kernel_handle_channel(scratchOffset, pid) + * 4. Call kernel_handle_channel(scratchOffset, scratchCapacity, pid) * 5. For each output pointer arg: copy data from kernel scratch to process Memory * 6. Write return value + errno to process channel * 7. Set status to COMPLETE and notify process * 8. Re-listen for next syscall */ - private getKernelMem(): Uint8Array { - const buf = this.kernelMemory!.buffer; - if (buf !== this.cachedKernelBuffer) { - this.cachedKernelMem = new Uint8Array(buf); - this.cachedKernelBuffer = buf; - } - return this.cachedKernelMem!; - } - /** Get pointer width for a process (4=wasm32, 8=wasm64). */ private getPtrWidth(pid: number): 4 | 8 { return this.processes.get(pid)?.ptrWidth ?? 4; @@ -3681,11 +4864,17 @@ export class CentralizedKernelWorker { const entries: string[] = []; const capped = Math.min(nfds, 8); for (let i = 0; i < capped; i++) { - const off = ptr + i * 8; - if (off + 8 > view.byteLength) break; - const fd = view.getInt32(off, true); - const events = view.getInt16(off + 4, true); - const revents = view.getInt16(off + 6, true); + const off = ptr + i * STRUCT_SIZE_WASM_POLL_FD; + if (off + STRUCT_SIZE_WASM_POLL_FD > view.byteLength) break; + const fd = view.getInt32(off + WASM_POLL_FD_FD_OFFSET, true); + const events = view.getInt16( + off + WASM_POLL_FD_EVENTS_OFFSET, + true, + ); + const revents = view.getInt16( + off + WASM_POLL_FD_REVENTS_OFFSET, + true, + ); entries.push(`{fd:${fd},events:0x${(events & 0xffff).toString(16)},revents:0x${(revents & 0xffff).toString(16)}}`); } if (nfds > capped) entries.push("..."); @@ -3736,11 +4925,11 @@ export class CentralizedKernelWorker { case ABI_SYSCALLS.Fcntl: // fcntl(fd, cmd, arg) return `[${pid}${tidSuffix}] fcntl(${args[0]}, ${args[1]}, ${args[2]})`; case ABI_SYSCALLS.Mmap: // mmap(addr, len, prot, flags, fd, offset) - return `[${pid}${tidSuffix}] mmap(0x${(args[0] >>> 0).toString(16)}, ${args[1] >>> 0}, ${args[2]}, 0x${(args[3] >>> 0).toString(16)}, ${args[4]}, ${args[5] >>> 0})`; + return `[${pid}${tidSuffix}] mmap(0x${args[0].toString(16)}, ${args[1]}, ${args[2]}, 0x${(args[3] >>> 0).toString(16)}, ${args[4]}, ${args[5] >>> 0})`; case ABI_SYSCALLS.Munmap: // munmap(addr, len) - return `[${pid}${tidSuffix}] munmap(0x${(args[0] >>> 0).toString(16)}, ${args[1] >>> 0})`; + return `[${pid}${tidSuffix}] munmap(0x${args[0].toString(16)}, ${args[1]})`; case ABI_SYSCALLS.Brk: // brk(addr) - return `[${pid}${tidSuffix}] brk(0x${(args[0] >>> 0).toString(16)})`; + return `[${pid}${tidSuffix}] brk(0x${args[0].toString(16)})`; case HOST_INTERCEPTED_SYSCALLS.SYS_EXECVE: // execve(path, argv, envp) return `[${pid}${tidSuffix}] execve("${this.readCString(channel.memory, args[0])}")`; case HOST_INTERCEPTED_SYSCALLS.SYS_FORK: return `[${pid}${tidSuffix}] fork()`; @@ -3766,9 +4955,9 @@ export class CentralizedKernelWorker { // Format return value based on syscall type switch (syscallNr) { case ABI_SYSCALLS.Mmap: // mmap - return ` = 0x${(retVal >>> 0).toString(16)}`; + return ` = 0x${retVal.toString(16)}`; case ABI_SYSCALLS.Brk: // brk - return ` = 0x${(retVal >>> 0).toString(16)}`; + return ` = 0x${retVal.toString(16)}`; default: return ` = ${retVal}`; } @@ -3898,17 +5087,44 @@ export class CentralizedKernelWorker { // Read syscall number and args from process channel const syscallNr = processView.getUint32(CH_SYSCALL, true); const origArgs: number[] = []; + const rawArgs: bigint[] = []; + const isPositionedVectorIo = + syscallNr === SYS_PREADV + || syscallNr === SYS_PWRITEV + || syscallNr === SYS_PREADV2 + || syscallNr === SYS_PWRITEV2; for (let i = 0; i < CH_ARGS_COUNT; i++) { const rawArg = processView.getBigInt64(CH_ARGS + i * CH_ARG_SIZE, true); + rawArgs.push(rawArg); // Linux declares sched_getaffinity's length as unsigned int even for a // 64-bit caller. Normalize it while it is still a bigint, before a // memory64 value can lose precision in a JavaScript number. if (syscallNr === SYS_SCHED_GETAFFINITY && i === 1) { origArgs.push(Number(BigInt.asUintN(32, rawArg))); + } else if (isPositionedVectorIo && i === 3) { + // wasm64 musl passes the complete offset in this slot even though the + // kernel ABI consumes only its low word. Normalize before Number can + // discard low bits above 2^53. + origArgs.push(Number(BigInt.asUintN(32, rawArg))); + } else if (isPositionedVectorIo && i === 4) { + origArgs.push(Number(BigInt.asIntN(32, rawArg))); } else { origArgs.push(Number(rawArg)); } } + try { + this.checkHandwrittenProcessAddressArguments( + channel, + syscallNr, + origArgs, + rawArgs, + ); + } catch (error) { + // Reject before syscall logging, shared-mapping synchronization, or + // kernel dispatch can observe an aliased low address. + this.rejectScratchTransfer(channel, error); + return; + } // Track last 30 syscalls per channel for crash diagnostics const ringKey = channel.pid; @@ -3966,8 +5182,8 @@ export class CentralizedKernelWorker { ) { const protectionError = this.prepareFileSharedMappingsForWrite( channel.pid, - origArgs[0] >>> 0, - alignWasmPageLength(origArgs[1] >>> 0), + origArgs[0], + alignWasmPageLength(origArgs[1]), ); if (protectionError !== 0) { this.completeChannel( @@ -4049,16 +5265,16 @@ export class CentralizedKernelWorker { }; const FUTEX_PRIVATE_FLAG = 128; const FUTEX_CLOCK_REALTIME = 256; - const op = origArgs[1] >>> 0; + const op = origArgs[1]; const cmd = op & ~(FUTEX_PRIVATE_FLAG | FUTEX_CLOCK_REALTIME); const opName = FUTEX_OPS[cmd] ?? `op${cmd}`; const flags = (op & FUTEX_PRIVATE_FLAG ? "|PRIVATE" : "") + (op & FUTEX_CLOCK_REALTIME ? "|REALTIME" : ""); const tid = this.channelTids.get(`${channel.pid}:${channel.channelOffset}`); const tidSuffix = tid !== undefined ? `:t${tid}` : ``; - console.error(`[${channel.pid}${tidSuffix}] futex(0x${(origArgs[0] >>> 0).toString(16)}, ${opName}${flags}, val=${origArgs[2]})`); + console.error(`[${channel.pid}${tidSuffix}] futex(0x${origArgs[0].toString(16)}, ${opName}${flags}, val=${origArgs[2]})`); } - this.handleFutex(channel, origArgs); + this.handleFutex(channel, origArgs, rawArgs); return; } @@ -4074,28 +5290,45 @@ export class CentralizedKernelWorker { // --- Scatter/gather I/O (writev/readv/pwritev/preadv) --- // These have nested pointers (iov array → base buffers) that can't be // handled by the simple ArgDesc system. - if (syscallNr === SYS_WRITEV || syscallNr === SYS_PWRITEV) { + if ( + syscallNr === SYS_WRITEV + || syscallNr === SYS_PWRITEV + || syscallNr === SYS_PWRITEV2 + ) { if (logging) console.error(logEntry); this.handleWritev(channel, syscallNr, origArgs); return; } - if (syscallNr === SYS_READV || syscallNr === SYS_PREADV) { + if ( + syscallNr === SYS_READV + || syscallNr === SYS_PREADV + || syscallNr === SYS_PREADV2 + ) { if (logging) console.error(logEntry); this.handleReadv(channel, syscallNr, origArgs); return; } + // --- getgroups: the return value is an entry count, not a byte count --- + // A simple output descriptor cannot express that getgroups(0, list) must + // not touch list while every positive-size call exposes exactly one + // four-byte slot in Kandelo's current single-supplementary-group model. + if (syscallNr === SYS_GETGROUPS) { + this.handleGetgroups(channel, origArgs, rawArgs); + return; + } + // --- Large write/pwrite/read/pread: chunk through scratch buffer --- // When the data exceeds CH_DATA_SIZE, the ArgDesc path returns a short // read/write. Programs like InnoDB that write 1MB+ chunks may exhaust // their retry budget. Handle large I/O by looping on the host side. if ((syscallNr === SYS_WRITE || syscallNr === SYS_PWRITE) && origArgs[2] > CH_DATA_SIZE) { - this.handleLargeWrite(channel, syscallNr, origArgs); + this.handleLargeWrite(channel, syscallNr, origArgs, rawArgs); return; } if ((syscallNr === SYS_READ || syscallNr === SYS_PREAD) && origArgs[2] > CH_DATA_SIZE) { - this.handleLargeRead(channel, syscallNr, origArgs); + this.handleLargeRead(channel, syscallNr, origArgs, rawArgs); return; } @@ -4138,7 +5371,7 @@ export class CentralizedKernelWorker { } // --- fcntl with struct flock pointer --- - // When cmd is a lock operation, arg3 is a pointer to struct flock (32 bytes). + // When cmd is a lock operation, arg3 points to the generated flock wire. // Handle as inout so the kernel can read/write the flock struct. if (syscallNr === SYS_FCNTL) { const cmd = origArgs[1]; @@ -4159,26 +5392,39 @@ export class CentralizedKernelWorker { return; } if (syscallNr === SYS_EPOLL_CTL) { - this.handleEpollCtl(channel, origArgs); + this.handleEpollCtl(channel, origArgs, rawArgs); return; } if (syscallNr === SYS_EPOLL_PWAIT || syscallNr === SYS_EPOLL_WAIT) { - this.handleEpollPwait(channel, syscallNr, origArgs); + this.handleEpollPwait(channel, syscallNr, origArgs, rawArgs); return; } // --- SysV IPC: shmat/shmdt need host-side process memory management --- if (syscallNr === SYS_SHMAT) { - this.handleIpcShmat(channel, origArgs); + this.handleIpcShmat(channel, origArgs, rawArgs); return; } if (syscallNr === SYS_SHMDT) { - this.handleIpcShmdt(channel, origArgs); + this.handleIpcShmdt(channel, origArgs, rawArgs); + return; + } + // --- SysV messages: msgbuf starts with native `long`, which differs + // between wasm32 and wasm64. Translate it to the fixed kernel wire header + // while the caller width is still known. --- + if (syscallNr === SYS_MSGSND || syscallNr === SYS_MSGRCV) { + this.handleSysvMessage(channel, syscallNr, origArgs, rawArgs); + return; + } + // --- SysV IPC: control structures follow the caller's wasm32/wasm64 + // data model and their pointer direction depends on cmd. --- + if (syscallNr === SYS_MSGCTL || syscallNr === SYS_SHMCTL) { + this.handleIpcControl(channel, syscallNr, origArgs, rawArgs); return; } // --- SysV IPC: semctl has cmd-dependent arg types (scalar vs pointer) --- if (syscallNr === SYS_SEMCTL) { - this.handleSemctl(channel, origArgs); + this.handleSemctl(channel, origArgs, rawArgs); return; } @@ -4226,52 +5472,289 @@ export class CentralizedKernelWorker { return; } - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - // Copy raw args to kernel scratch header (will be adjusted below) - const adjustedArgs = [...origArgs]; + const adjustedArgs: Array = [...origArgs]; + if (syscallNr === SYS_PREAD || syscallNr === SYS_PWRITE) { + // WHY: pread/pwrite carry one signed i64 offset. Keep the channel value + // exact instead of round-tripping it through JavaScript Number before + // the checked kernel scratch dispatch. + adjustedArgs[3] = rawArgs[3]!; + } // Process pointer args: copy data between process and kernel memory - const argDescs = SYSCALL_ARGS[syscallNr]; + const pointerWidth = this.getPtrWidth(channel.pid); + let argDescs = SYSCALL_ARGS[syscallNr]; + if (syscallNr === SYS_PRCTL) { + const option = Number(BigInt.asUintN(32, rawArgs[0]!)); + adjustedArgs[0] = option; + if (option === PR_SET_NAME || option === PR_GET_NAME) { + // WHY: only the two thread-name operations interpret arg2 as a + // process pointer. Every other prctl option owns scalar semantics, so + // a generic pointer descriptor would either read an arbitrary caller + // address or replace the scalar with a scratch pointer. + argDescs = [{ + argIndex: 1, + direction: option === PR_SET_NAME ? "in" : "out", + size: { type: "fixed", size: PRCTL_NAME_BYTES }, + required: true, + }]; + } else { + adjustedArgs[1] = Number( + BigInt.asUintN(32, BigInt.asUintN(pointerWidth * 8, rawArgs[1]!)), + ); + argDescs = []; + } + } + if (syscallNr === SYS_IOCTL) { + const request = Number(BigInt.asUintN(32, rawArgs[1]!)); + const contract = IOCTL_REQUESTS[request]; + adjustedArgs[1] = request; + adjustedArgs[3] = 0; + adjustedArgs[PROCESS_POINTER_WIDTH_ARG_INDEX] = pointerWidth; + + if (!contract) { + // WHY: an unknown ioctl must reach the device with no staged process + // pointer. The kernel can then report EBADF/ENOTTY/ENOSYS without an + // unrelated caller-memory read or write. + adjustedArgs[2] = 0; + argDescs = []; + } else { + const size = pointerWidth === 8 + ? contract.wasm64Size + : contract.wasm32Size; + if (size === null) { + // The request is known, but its nested pointer layout cannot be + // represented losslessly for this caller data model. + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EOVERFLOW, + ); + return; + } + + switch (contract.argKind) { + case "none": + adjustedArgs[2] = 0; + argDescs = []; + break; + case "scalar-i32": { + const rawScalar = BigInt.asUintN(pointerWidth * 8, rawArgs[2]!); + // WHY: ScalarI32 defines only the low 32 bits. In particular, + // wasm64 C varargs may leave the wider transport slot's upper + // half unspecified when musl's ioctl wrapper reads an `int` + // argument. Those non-semantic bits must neither trigger a + // pointer-style EOVERFLOW nor reach Rust. No caller range exists + // for this request and no scratch bytes are staged. + adjustedArgs[2] = Number(BigInt.asUintN(32, rawScalar)); + argDescs = []; + break; + } + case "pointer": + if (contract.direction === "none") { + throw new Error( + `ioctl 0x${request.toString(16)} pointer has no direction`, + ); + } + adjustedArgs[3] = size; + argDescs = [{ + argIndex: 2, + direction: contract.direction, + size: { type: "fixed", size }, + required: true, + }]; + break; + } + } + } let dataOffset = 0; // Offset within scratch data area for allocations let schedGetaffinityOutputInvalid = false; + const plannedChannelScratchArgs: PlannedChannelScratchArg[] = []; + const plannedScratchWrites: PlannedScratchWrite[] = []; + const capturedDerefU32Inputs: Array<{ + processPointer: number; + value: number; + } | undefined> = []; + let plannedZeroLengthScratchArgMask = 0; if (argDescs) { // Re-create typed views (memory may have grown) const processMem = new Uint8Array(channel.memory.buffer); - const kernelMem = this.getKernelMem(); - const dataStart = this.scratchOffset + CH_DATA; + if (argDescs.some((desc) => desc.size.type === "process-layout")) { + // WHY: the kernel Wasm target cannot select a native guest structure + // layout because one instance may serve both wasm32 and wasm64. + adjustedArgs[PROCESS_POINTER_WIDTH_ARG_INDEX] = pointerWidth; + } + // Capture every pointer-derived size before planning any subregion. + // WHY: descriptor order is generated ABI data and may change. If the + // four-byte length slot were staged first, rereading the guest later to + // size its companion output would allow another thread to make Rust see + // a larger capacity than the host-owned subregion was planned for. for (const desc of argDescs) { - const ptr = origArgs[desc.argIndex]; - const scalarTcflushArg = - syscallNr === SYS_IOCTL && - (origArgs[1] >>> 0) === TCFLSH && - desc.argIndex === 2; - if (scalarTcflushArg) { - // ioctl's third argument is request-dependent. Most supported - // requests use a pointer and follow the descriptor below, but - // tcflush(fd, queue) sends TCIFLUSH/TCOFLUSH/TCIOFLUSH directly as - // the integer value 0/1/2. Materialize that scalar in kernel scratch - // instead of interpreting the value as a process-memory address. - const kernelPtr = dataStart + dataOffset; - new DataView(this.kernelMemory!.buffer).setInt32( - kernelPtr, - origArgs[2], - true, + if (desc.size.type !== "deref") continue; + const rawOuterPointer = rawArgs[desc.argIndex]!; + if (rawOuterPointer === 0n) continue; + try { + checkedWasmPointer( + rawOuterPointer, + pointerWidth, + `syscall ${syscallNr} arg ${desc.argIndex} pointer`, + ); + } catch { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EFAULT, ); - adjustedArgs[2] = kernelPtr; - dataOffset = (dataOffset + 4 + 7) & ~7; + return; + } + const rawDerefPtr = rawArgs[desc.size.argIndex]!; + if (rawDerefPtr === 0n) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EFAULT, + ); + return; + } + let derefPtr: number; + try { + derefPtr = this.checkedProcessRange( + channel, + rawDerefPtr, + 4, + `syscall ${syscallNr} length pointer`, + ).pointer; + } catch { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EFAULT, + ); + return; + } + const existing = capturedDerefU32Inputs[desc.size.argIndex]; + if (existing !== undefined) { + if (existing.processPointer !== derefPtr) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EINVAL, + ); + return; + } continue; } + capturedDerefU32Inputs[desc.size.argIndex] = { + processPointer: derefPtr, + value: new DataView( + processMem.buffer, + processMem.byteOffset, + processMem.byteLength, + ).getUint32(derefPtr, true), + }; + } + + for (const desc of argDescs) { + let argumentSizedBytes: number | undefined; + if (desc.size.type === "arg") { + const rawCount = rawArgs[desc.size.argIndex]!; + if ( + rawCount < 0n + || rawCount > BigInt(Number.MAX_SAFE_INTEGER) + ) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EINVAL, + ); + return; + } + const multiplier = desc.size.multiplier ?? 1; + const add = desc.size.add ?? 0; + argumentSizedBytes = Number(rawCount) * multiplier + add; + if ( + !Number.isSafeInteger(argumentSizedBytes) + || argumentSizedBytes < 0 + ) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EINVAL, + ); + return; + } + if (argumentSizedBytes === 0) { + // WHY: a zero-length buffer lends no caller bytes, so its raw + // wasm64 pointer bits are intentionally ignored. Resolve it to a + // checked non-null kernel-scratch address under the final lease: + // Rust slices require non-null pointers even at length zero. + adjustedArgs[desc.argIndex] = 0; + plannedZeroLengthScratchArgMask |= 1 << desc.argIndex; + continue; + } + } + const rawPtr = rawArgs[desc.argIndex]!; const deferSchedGetaffinityOutputError = syscallNr === SYS_SCHED_GETAFFINITY && desc.argIndex === 2 && desc.direction === "out"; - if (ptr === 0 && !deferSchedGetaffinityOutputError) { - const required = desc.required === true - || (desc.size.type === "cstring" && desc.nullable !== true); - if (required) { + let ptr: number; + try { + ptr = checkedWasmPointer( + rawPtr, + pointerWidth, + `syscall ${syscallNr} arg ${desc.argIndex} pointer`, + ); + } catch { + if (deferSchedGetaffinityOutputError) { + // WHY: Linux resolves the selected task before copying the mask. + // Keep a lossy/invalid guest pointer out of kernel memory, but + // still dispatch through safe scratch so ESRCH can take precedence + // over the eventual EFAULT. + schedGetaffinityOutputInvalid = true; + ptr = 0; + } else { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EFAULT, + ); + return; + } + } + if (rawPtr === 0n && !deferSchedGetaffinityOutputError) { + if (desc.required === true || desc.nullable !== true) { + // WHY: every descriptor with a positive extent needs an owned + // channel subregion. Null is valid only when the shared contract + // says so explicitly; absence of `required` must not silently + // turn fixed outputs such as pipefd[2] into nullable pointers. + // Arg-sized zero-length buffers were canonicalized above. this.completeChannel( channel, syscallNr, @@ -4306,14 +5789,16 @@ export class CentralizedKernelWorker { } size = result.size; } else if (desc.size.type === "arg") { - size = - origArgs[desc.size.argIndex] * (desc.size.multiplier ?? 1) - + (desc.size.add ?? 0); + size = argumentSizedBytes!; } else if (desc.size.type === "deref") { // Dereference: arg is a pointer to a u32 value (e.g. socklen_t*) - const derefPtr = origArgs[desc.size.argIndex]; - if (derefPtr === 0) continue; - if (!isValidMemoryRange(processMem, derefPtr, 4)) { + const rawDerefPtr = rawArgs[desc.size.argIndex]!; + if (rawDerefPtr === 0n) { + // WHY: the outer pointer is non-null here, so its separate length + // pointer is the only source of the destination capacity. Without + // it no owned channel subregion can be planned; forwarding the + // caller pointer would cross address spaces before Rust rejects + // the malformed pair. this.completeChannel( channel, syscallNr, @@ -4324,32 +5809,115 @@ export class CentralizedKernelWorker { ); return; } - size = processMem[derefPtr] | (processMem[derefPtr + 1] << 8) - | (processMem[derefPtr + 2] << 16) | (processMem[derefPtr + 3] << 24); - } else { + let derefPtr: number; + try { + derefPtr = this.checkedProcessRange( + channel, + rawDerefPtr, + 4, + `syscall ${syscallNr} length pointer`, + ).pointer; + } catch { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EFAULT, + ); + return; + } + const captured = capturedDerefU32Inputs[desc.size.argIndex]; + if ( + captured === undefined + || captured.processPointer !== derefPtr + ) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EINVAL, + ); + return; + } + size = captured.value; + } else if (desc.size.type === "fixed") { size = desc.size.size; + } else { + size = pointerWidth === 8 + ? desc.size.wasm64Size + : desc.size.wasm32Size; } - if (size <= 0) continue; + if (!Number.isSafeInteger(size) || size < 0) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EINVAL, + ); + return; + } + if (size === 0) { + // Never leak a process-space pointer into the kernel address space, + // even when the associated count is zero. The final lease supplies + // a non-null allocator-owned empty address for Rust slice validity. + adjustedArgs[desc.argIndex] = 0; + plannedZeroLengthScratchArgMask |= 1 << desc.argIndex; + continue; + } // Cap size to fit in the channel data buffer. For read/write-like // syscalls where the size comes from another arg, also update that // arg so the kernel uses the capped count. The caller (musl libc) // will see a short read/write and retry for the remainder. if (dataOffset + size > CH_DATA_SIZE) { + const simpleCount = + desc.size.type === "arg" + && (desc.size.multiplier ?? 1) === 1 + && (desc.size.add ?? 0) === 0; + if (!simpleCount) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EINVAL, + ); + return; + } size = CH_DATA_SIZE - dataOffset; - if (size <= 0) continue; if (desc.size.type === "arg") { adjustedArgs[desc.size.argIndex] = size; } + if (size === 0) { + adjustedArgs[desc.argIndex] = 0; + plannedZeroLengthScratchArgMask |= 1 << desc.argIndex; + continue; + } } - if (!isValidMemoryRange(processMem, ptr, size)) { + let processRangeValid = true; + try { + this.checkedProcessRange( + channel, + rawPtr, + size, + `syscall ${syscallNr} arg ${desc.argIndex} data`, + ); + } catch { if (deferSchedGetaffinityOutputError) { // Linux resolves the requested task before copying its affinity // mask. Use safe kernel scratch now, then convert a successful // lookup to EFAULT below; an ESRCH result must take precedence. schedGetaffinityOutputInvalid = true; + processRangeValid = false; } else { this.completeChannel( channel, @@ -4363,18 +5931,53 @@ export class CentralizedKernelWorker { } } - const kernelPtr = dataStart + dataOffset; - - // Copy input data from process to kernel - if (desc.direction === "in" || desc.direction === "inout") { - kernelMem.set(processMem.subarray(ptr, ptr + size), kernelPtr); - } else { - // Output-only: zero the kernel scratch area - kernelMem.fill(0, kernelPtr, kernelPtr + size); + const scratchOffset = CH_DATA + dataOffset; + let inputBytes: Uint8Array | null = null; + if ( + processRangeValid + && (desc.direction === "in" || desc.direction === "inout") + ) { + const captured = capturedDerefU32Inputs[desc.argIndex]; + if (captured !== undefined) { + if (size !== 4 || captured.processPointer !== ptr) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EINVAL, + ); + return; + } + inputBytes = new Uint8Array(4); + new DataView(inputBytes.buffer).setUint32( + 0, + captured.value, + true, + ); + } else { + inputBytes = processMem.slice(ptr, ptr + size); + } } + const plannedArg: PlannedChannelScratchArg = { + desc, + processPointer: ptr, + scratchOffset, + size, + inputBytes, + }; + plannedChannelScratchArgs.push(plannedArg); + plannedScratchWrites.push({ + argIndex: desc.argIndex, + scratchOffset, + size, + inputBytes, + }); - // Update arg to point to kernel memory - adjustedArgs[desc.argIndex] = kernelPtr; + // Install the allocator-selected pointer only while the final + // exclusive lease is active. + adjustedArgs[desc.argIndex] = 0; dataOffset += size; // Kernel exports may dereference i64-bearing structs and scalar output @@ -4382,49 +5985,80 @@ export class CentralizedKernelWorker { // CH_DATA itself is eight-byte aligned. dataOffset = (dataOffset + 7) & ~7; } + // WHY: guest inputs are detached into host-owned bytes during planning. + // File-backed mmap preparation and other callbacks below may re-enter + // host code, so no bytes enter shared kernel scratch until the one lease + // that also writes the header and invokes kernel_handle_channel. } // ppoll: convert timespec pointer and sigset pointer to scalar values. // musl sends: (fds, nfds, timespec_ptr, sigset_ptr, sigset_size) // kernel expects: (fds, nfds, timeout_ms, has_mask, mask_lo, mask_hi) if (syscallNr === SYS_PPOLL) { - const tsPtr = origArgs[2]; - if (tsPtr !== 0) { - // time64: timespec is {int64 sec, int64 nsec} = 16 bytes - const pv = new DataView(channel.memory.buffer, tsPtr); - const sec = Number(pv.getBigInt64(0, true)); - const nsec = Number(pv.getBigInt64(8, true)); - adjustedArgs[2] = sec * 1000 + Math.floor(nsec / 1000000); - } else { - adjustedArgs[2] = -1; // infinite timeout - } - const maskPtr = origArgs[3]; - if (maskPtr !== 0) { - const pv = new DataView(channel.memory.buffer, maskPtr); - adjustedArgs[3] = 1; // has_mask = true - adjustedArgs[4] = pv.getUint32(0, true); // mask_lo - adjustedArgs[5] = pv.getUint32(4, true); // mask_hi - } else { - adjustedArgs[3] = 0; // has_mask = false - adjustedArgs[4] = 0; - adjustedArgs[5] = 0; - } - } - - if ( - channel.readinessFinalCheck === true - && (syscallNr === SYS_POLL || syscallNr === SYS_PPOLL) - ) { - // The Rust poll/ppoll path sees timeout=0 and returns a real readiness - // result. For ppoll, that non-EAGAIN result also restores the saved mask. - adjustedArgs[2] = 0; - channel.readinessFinalCheck = false; + try { + const rawTimespecPointer = rawArgs[2]; + if (rawTimespecPointer !== 0n) { + // time64: timespec is {int64 sec, int64 nsec} = 16 bytes. + // WHY: ppoll's scalar conversion is outside SYSCALL_ARGS, so prove + // this caller-owned source independently before staging its values. + const range = this.checkedProcessRange( + channel, + rawTimespecPointer, + 16, + "ppoll timeout", + ); + const pv = new DataView( + channel.memory.buffer, + range.pointer, + range.length, + ); + const sec = Number(pv.getBigInt64(0, true)); + const nsec = Number(pv.getBigInt64(8, true)); + adjustedArgs[2] = sec * 1000 + Math.floor(nsec / 1000000); + } else { + adjustedArgs[2] = -1; // infinite timeout + } + const rawMaskPointer = rawArgs[3]; + if (rawMaskPointer !== 0n) { + const range = this.checkedProcessRange( + channel, + rawMaskPointer, + SIGNAL_MASK_BYTES, + "ppoll signal mask", + ); + const pv = new DataView( + channel.memory.buffer, + range.pointer, + range.length, + ); + adjustedArgs[3] = 1; // has_mask = true + adjustedArgs[4] = pv.getUint32(0, true); // mask_lo + adjustedArgs[5] = pv.getUint32(4, true); // mask_hi + } else { + adjustedArgs[3] = 0; // has_mask = false + adjustedArgs[4] = 0; + adjustedArgs[5] = 0; + } + } catch (error) { + this.rejectScratchTransfer(channel, error); + return; + } + } + + if ( + channel.readinessFinalCheck === true + && (syscallNr === SYS_POLL || syscallNr === SYS_PPOLL) + ) { + // The Rust poll/ppoll path sees timeout=0 and returns a real readiness + // result. For ppoll, that non-EAGAIN result also restores the saved mask. + adjustedArgs[2] = 0; + channel.readinessFinalCheck = false; } let fileSharedMmapPreparation: FileSharedMmapPreparationResult | null = null; if ( syscallNr === SYS_MMAP - && (origArgs[1] >>> 0) > 0 + && origArgs[1] > 0 && (origArgs[3] & MAP_SHARED) !== 0 && (origArgs[3] & MAP_ANONYMOUS) === 0 && origArgs[4] >= 0 @@ -4486,8 +6120,8 @@ export class CentralizedKernelWorker { // Flush the replaced mapping while its kernel interval and process // bytes are both still intact. const flushedReplacement = this.flushSharedMappings(channel, [ - origArgs[0] >>> 0, - alignWasmPageLength(origArgs[1] >>> 0), + origArgs[0], + alignWasmPageLength(origArgs[1]), ]); if (this.hostReaped?.has(channel.pid)) { if (fileSharedMmapPreparation?.kind === "prepared") { @@ -4513,15 +6147,6 @@ export class CentralizedKernelWorker { } } - // Write adjusted args to kernel scratch - kernelView.setUint32(CH_SYSCALL, syscallNr, true); - for (let i = 0; i < CH_ARGS_COUNT; i++) { - kernelView.setBigInt64( - CH_ARGS + i * CH_ARG_SIZE, - BigInt(adjustedArgs[i]), - true, - ); - } } catch (err) { if (fileSharedMmapPreparation?.kind === "prepared") { this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); @@ -4530,12 +6155,7 @@ export class CentralizedKernelWorker { throw err; } - // Call kernel_handle_channel - const handleChannel = this.kernelInstance!.exports - .kernel_handle_channel as ( - offset: KernelPointer, - pid: number, - ) => number; + // Call kernel_handle_channel through the active scratch lease. try { this.bindKernelTidForChannel(channel); } catch (err) { @@ -4577,8 +6197,118 @@ export class CentralizedKernelWorker { } g.__sysprofLastSeen.set(channel.pid, sysprofStart); } + let kernelResult: { + retVal: number; + errVal: number; + outputWrites: ChannelOutputWrite[]; + sleepDelayMs: number | undefined; + }; try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + kernelResult = this.requireMainScratchRegion().withLease((lease) => { + const kernelView = lease.dataView(0, CH_TOTAL_SIZE); + kernelView.setUint32(CH_SYSCALL, syscallNr, true); + for (let i = 0; i < CH_ARGS_COUNT; i++) { + kernelView.setBigInt64( + CH_ARGS + i * CH_ARG_SIZE, + BigInt(adjustedArgs[i]), + true, + ); + } + if (plannedZeroLengthScratchArgMask !== 0) { + for (let argIndex = 0; argIndex < CH_ARGS_COUNT; argIndex++) { + if ((plannedZeroLengthScratchArgMask & (1 << argIndex)) !== 0) { + // WHY: encode the lease-scoped primitive immediately. Keeping + // it in adjustedArgs would leave a valid-looking address in an + // outer array after this lease has released the allocation. + lease.writeAddress( + CH_ARGS + argIndex * CH_ARG_SIZE, + CH_DATA, + 0, + "u64-le", + ); + } + } + } + for (const write of plannedScratchWrites) { + if (write.inputBytes) { + lease.copyFrom( + write.inputBytes, + write.scratchOffset, + 0, + write.size, + ); + } else { + lease.fill(0, write.scratchOffset, write.size); + } + lease.writeAddress( + CH_ARGS + write.argIndex * CH_ARG_SIZE, + write.scratchOffset, + write.size, + "u64-le", + ); + } + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + ]); + const rawRetVal = kernelView.getBigInt64(CH_RETURN, true); + let { retVal, errVal } = this.normalizeKernelSyscallResult( + channel, + syscallNr, + rawRetVal, + kernelView.getUint32(CH_ERRNO, true), + ); + if ( + syscallNr === SYS_SCHED_GETAFFINITY + && schedGetaffinityOutputInvalid + && retVal >= 0 + ) { + retVal = -1; + errVal = EFAULT; + } + let sleepDelayMs: number | undefined; + if ( + retVal >= 0 + && ( + syscallNr === SYS_NANOSLEEP + || syscallNr === SYS_CLOCK_NANOSLEEP + ) + ) { + const timespec = lease.dataView(CH_DATA, 12); + const sec = timespec.getUint32(0, true); + const nsec = timespec.getUint32(8, true); + sleepDelayMs = + sec * 1000 + Math.floor(nsec / 1_000_000); + } + // Detach every output while this exact lease is active. Passing the + // lease to a helper would obscure that no allocation-backed view or + // address survives the synchronous callback. + const outputWrites: ChannelOutputWrite[] = []; + for (const planned of plannedChannelScratchArgs) { + const { desc } = planned; + if (desc.direction !== "out" && desc.direction !== "inout") { + continue; + } + // Pure output is unspecified on failure; preserve caller bytes. + if (desc.direction === "out" && retVal < 0) continue; + + let copySize = planned.size; + if (desc.direction === "out" && desc.size.type === "arg") { + copySize = Math.min(retVal, copySize); + } + if (copySize <= 0) continue; + + const bytes = lease.copyOut(planned.scratchOffset, copySize); + outputWrites.push({ ptr: planned.processPointer, bytes }); + } + return { + retVal, + errVal, + outputWrites, + sleepDelayMs, + }; + }); } catch (err) { if (fileSharedMmapPreparation?.kind === "prepared") { this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); @@ -4634,9 +6364,10 @@ export class CentralizedKernelWorker { return; } - // Read return value and errno from kernel scratch - let retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - let errVal = kernelView.getUint32(CH_ERRNO, true); + // The kernel result and every descriptor output are host-owned now. + // Postprocessing below may run nested synthetic syscalls or wake another + // channel, so it must never retain or reread the shared allocation. + let { retVal, errVal } = kernelResult; if ( syscallNr === SYS_RT_SIGTIMEDWAIT && !(retVal === -1 && errVal === EAGAIN) @@ -4645,18 +6376,10 @@ export class CentralizedKernelWorker { `${channel.pid}:${channel.channelOffset}`, ); } - if ( - syscallNr === SYS_SCHED_GETAFFINITY - && schedGetaffinityOutputInvalid - && retVal >= 0 - ) { - retVal = -1; - errVal = EFAULT; - } if ( syscallNr === SYS_MMAP && fileSharedMmapPreparation?.kind === "prepared" && - !(retVal > 0 && retVal >>> 0 !== 0xffffffff) + retVal <= 0 ) { this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); fileSharedMmapPreparation = null; @@ -4670,8 +6393,8 @@ export class CentralizedKernelWorker { (origArgs[3] & MAP_FIXED) !== 0 ) { const replacementArgs = [ - retVal >>> 0, - alignWasmPageLength(origArgs[1] >>> 0), + retVal, + alignWasmPageLength(origArgs[1]), ]; this.cleanupSharedMappings( channel.pid, @@ -4681,8 +6404,8 @@ export class CentralizedKernelWorker { } if (syscallNr === SYS_MREMAP && retVal > 0) { this.flushSharedMappings(channel, [ - origArgs[0] >>> 0, - alignWasmPageLength(origArgs[1] >>> 0), + origArgs[0], + alignWasmPageLength(origArgs[1]), ]); if (this.hostReaped?.has(channel.pid)) return; } @@ -4711,25 +6434,24 @@ export class CentralizedKernelWorker { // --- DEBUG: detect memory operations in legacy high control pages --- const highControlFloor = this.highControlFloorForProcess(channel.pid); - if (syscallNr === SYS_MMAP && retVal > 0 && retVal >>> 0 !== 0xffffffff) { - const mmapAddr = retVal >>> 0; - const mmapLen = origArgs[1] >>> 0; + if (syscallNr === SYS_MMAP && retVal > 0) { + const mmapAddr = retVal; + const mmapLen = origArgs[1]; if ( highControlFloor !== null && mmapAddr + mmapLen > highControlFloor ) { console.error( - `[MMAP ALERT] pid=${channel.pid} mmap returned 0x${mmapAddr.toString(16)} len=${mmapLen} — OVERLAPS THREAD REGION! args=[${origArgs.map((a) => "0x" + (a >>> 0).toString(16)).join(",")}]`, + `[MMAP ALERT] pid=${channel.pid} mmap returned 0x${mmapAddr.toString(16)} len=${mmapLen} — OVERLAPS THREAD REGION! args=[${origArgs.map((a) => `${a < 0 ? "-" : ""}0x${Math.abs(a).toString(16)}`).join(",")}]`, ); } } if ( syscallNr === SYS_MREMAP && - retVal > 0 && - retVal >>> 0 !== 0xffffffff + retVal > 0 ) { - const mremapAddr = retVal >>> 0; - const mremapLen = origArgs[2] >>> 0; + const mremapAddr = retVal; + const mremapLen = origArgs[2]; if ( highControlFloor !== null && mremapAddr + mremapLen > highControlFloor @@ -4745,35 +6467,35 @@ export class CentralizedKernelWorker { retVal > highControlFloor ) { console.error( - `[BRK ALERT] pid=${channel.pid} brk returned 0x${(retVal >>> 0).toString(16)} — IN THREAD REGION!`, + `[BRK ALERT] pid=${channel.pid} brk returned 0x${retVal.toString(16)} — IN THREAD REGION!`, ); } // --- mmap backing: populate files and register shared-memory intervals --- - if (syscallNr === SYS_MMAP && retVal > 0 && retVal >>> 0 !== 0xffffffff) { + if (syscallNr === SYS_MMAP && retVal > 0) { const mmapFd = origArgs[4]; const mmapFlags = origArgs[3] >>> 0; if ( (mmapFlags & MAP_SHARED) !== 0 && (mmapFlags & MAP_ANONYMOUS) !== 0 ) { - this.trackAnonymousSharedMapping(channel, retVal >>> 0, origArgs); + this.trackAnonymousSharedMapping(channel, retVal, origArgs); } else if (mmapFd >= 0 && (mmapFlags & MAP_ANONYMOUS) === 0) { if ((mmapFlags & MAP_SHARED) !== 0) { const sharedResult = fileSharedMmapPreparation?.kind === "prepared" ? this.registerPreparedSharedMmap( channel, - retVal >>> 0, + retVal, fileSharedMmapPreparation.context, ) : fileSharedMmapPreparation?.kind === "unsupported" ? fileSharedMmapPreparation - : this.mapSharedMmapFromFile(channel, retVal >>> 0, origArgs); + : this.mapSharedMmapFromFile(channel, retVal, origArgs); fileSharedMmapPreparation = null; if (this.hostReaped?.has(channel.pid)) return; if (sharedResult.kind === "unsupported") { - this.populateMmapFromFile(channel, retVal >>> 0, origArgs); + this.populateMmapFromFile(channel, retVal, origArgs); if (this.hostReaped?.has(channel.pid)) return; } else if (sharedResult.kind === "error") { // The kernel has already reserved the interval. Undo that @@ -4782,8 +6504,8 @@ export class CentralizedKernelWorker { // writes and violate fd-close/fork coherence. try { this.runSyntheticMemorySyscall(channel, SYS_MUNMAP, [ - retVal >>> 0, - alignWasmPageLength(origArgs[1] >>> 0), + retVal, + alignWasmPageLength(origArgs[1]), ]); if (this.hostReaped?.has(channel.pid)) return; } catch { @@ -4794,7 +6516,7 @@ export class CentralizedKernelWorker { errVal = sharedResult.errno; } } else { - this.populateMmapFromFile(channel, retVal >>> 0, origArgs); + this.populateMmapFromFile(channel, retVal, origArgs); if (this.hostReaped?.has(channel.pid)) return; } } @@ -4805,7 +6527,7 @@ export class CentralizedKernelWorker { // delivers the parent's writes to a child across PRIME // export → fork → PRIME import. No-op for non-DRI mmaps. if (retVal > 0) { - const mmapAddr = retVal >>> 0; + const mmapAddr = retVal; const boId = this.kernel.bos.findBindingByAddr(channel.pid, mmapAddr); if (boId !== undefined) { this.kernel.bos.primeBindFromSab(channel.pid, boId, channel.memory); @@ -4825,8 +6547,8 @@ export class CentralizedKernelWorker { // --- munmap: flush + clean up shared mapping tracking --- if (syscallNr === SYS_MUNMAP && retVal === 0) { const unmapArgs = [ - origArgs[0] >>> 0, - alignWasmPageLength(origArgs[1] >>> 0), + origArgs[0], + alignWasmPageLength(origArgs[1]), ]; this.flushSharedMappings(channel, unmapArgs); if (this.hostReaped?.has(channel.pid)) return; @@ -4836,16 +6558,16 @@ export class CentralizedKernelWorker { if (syscallNr === SYS_MREMAP && retVal > 0) { this.remapSharedMapping( channel.pid, - origArgs[0] >>> 0, - retVal >>> 0, - origArgs[2] >>> 0, + origArgs[0], + retVal, + origArgs[2], ); } if (syscallNr === SYS_MPROTECT && retVal === 0) { this.updateSharedMappingProtection( channel.pid, - origArgs[0] >>> 0, - alignWasmPageLength(origArgs[1] >>> 0), + origArgs[0], + alignWasmPageLength(origArgs[1]), (origArgs[2] & PROT_WRITE) !== 0, ); } @@ -4857,6 +6579,7 @@ export class CentralizedKernelWorker { origArgs, retVal, errVal, + syscallNr === SYS_PWRITE ? rawArgs[3] : undefined, ); if (this.hostReaped?.has(channel.pid)) return; } @@ -4912,13 +6635,28 @@ export class CentralizedKernelWorker { if (logging) { console.error(logEntry + " = -1 (EAGAIN, will retry)"); } - this.handleBlockingRetry(channel, syscallNr, origArgs); + this.handleBlockingRetry( + channel, + syscallNr, + origArgs, + kernelResult.outputWrites, + ); return; } // 2. Sleep syscalls: kernel returned success immediately, but we need // to delay the response to simulate the sleep duration. - if (this.handleSleepDelay(channel, syscallNr, origArgs, retVal, errVal)) { + if ( + this.handleSleepDelay( + channel, + syscallNr, + origArgs, + retVal, + errVal, + kernelResult.sleepDelayMs, + kernelResult.outputWrites, + ) + ) { return; } @@ -4986,6 +6724,7 @@ export class CentralizedKernelWorker { argDescs, retVal, errVal, + kernelResult.outputWrites, ); } catch (err) { if (fileSharedMmapPreparation?.kind === "prepared") { @@ -5031,17 +6770,38 @@ export class CentralizedKernelWorker { } const dequeueSignal = this.kernelInstance!.exports.kernel_dequeue_signal as - ((pid: number, tid: number, outPtr: KernelPointer) => number) | undefined; + (( + pid: number, + tid: number, + outPtr: KernelPointer, + outCapacity: number, + ) => number) | undefined; if (!dequeueSignal) return 0; - // Use the signal area in kernel scratch as the output buffer const tid = this.guestTidForChannel(channel); - const sigOutOffset = this.scratchOffset + CH_SIG_BASE; - const sigResult = dequeueSignal( - channel.pid, - tid, - this.toKernelPtr(sigOutOffset), - ); + // Copy the fixed signal record to host-owned bytes before releasing the + // region. Completion can synchronously wake another channel and reuse it. + const snapshot = this.requireMainScratchRegion().withLease((lease) => { + const sigResult = lease.invokeKernelExport("kernel_dequeue_signal", [ + channel.pid, + tid, + lease.exportPointer( + CH_SIG_BASE, + KERNEL_SCRATCH_SIGNAL_DELIVERY_BYTES, + ), + KERNEL_SCRATCH_SIGNAL_DELIVERY_BYTES, + ]); + return { + sigResult, + bytes: sigResult > 0 + ? lease.copyOut( + CH_SIG_BASE, + KERNEL_SCRATCH_SIGNAL_DELIVERY_BYTES, + ) + : new Uint8Array(0), + }; + }); + const { sigResult } = snapshot; if (sigResult < 0) { throw new KernelTaskBindingError( channel.pid, @@ -5051,20 +6811,22 @@ export class CentralizedKernelWorker { ); } if (sigResult > 0) { - // Copy 44 bytes of signal delivery info from kernel scratch to process channel - // Layout: signum(4) + handler(4) + flags(4) + si_value(4) + old_mask(8) - // + si_code(4) + si_pid(4) + si_uid(4) + alt_sp(4) + alt_size(4) = 44 bytes - const kernelMem = this.getKernelMem(); + // Copy the complete generated signal-delivery wire into its reserved + // process-channel slot. const processMem = new Uint8Array(channel.memory.buffer); processMem.set( - kernelMem.subarray(sigOutOffset, sigOutOffset + 44), + snapshot.bytes, channel.channelOffset + CH_SIG_BASE, ); return sigResult; } else { - // Clear entire signal delivery area in process channel (48 bytes) + // Clear the complete reserved area, including its trailing pad. const sigStart = channel.channelOffset + CH_SIG_BASE; - new Uint8Array(channel.memory.buffer, sigStart, 48).fill(0); + new Uint8Array( + channel.memory.buffer, + sigStart, + CH_SIG_AREA_SIZE, + ).fill(0); return 0; } } @@ -5079,23 +6841,23 @@ export class CentralizedKernelWorker { argDescs: SyscallArgDesc[] | undefined, retVal: number, errVal: number, + detachedOutput: ChannelOutputWrite[] = [], + deferredClone?: PreparedChannelCompletion["deferredClone"], ): void { - // Snapshot all scratch-backed output before processing kernel wake events: - // parent notification and waiter completion can re-enter the kernel and - // reuse the one shared scratch buffer. + // WHY: only bytes detached while the allocation lease was active may + // cross this completion boundary. Re-reading shared scratch here would let + // a signal, retry, timeout, or teardown copy bytes from another operation. + void syscallNr; + void origArgs; + void argDescs; const prepared: PreparedChannelCompletion = { kind: "marshalled", - outputWrites: this.snapshotChannelOutput( - channel, - syscallNr, - origArgs, - argDescs, - retVal, - ), + outputWrites: detachedOutput, retVal, errVal, materialized: false, relistenRequested: true, + deferredClone, }; // Output and shared backing belong to the completed syscall before any @@ -5125,111 +6887,6 @@ export class CentralizedKernelWorker { this.publishOrParkChannelCompletion(channel, prepared); } - private snapshotChannelOutput( - channel: ChannelInfo, - syscallNr: number, - origArgs: number[], - argDescs: SyscallArgDesc[] | undefined, - retVal: number, - ): Array<{ ptr: number; bytes: Uint8Array }> { - if (!argDescs) return []; - - const writes: Array<{ ptr: number; bytes: Uint8Array }> = []; - const processMem = new Uint8Array(channel.memory.buffer); - const kernelMem = this.getKernelMem(); - const dataStart = this.scratchOffset + CH_DATA; - let outOffset = 0; - - for (const desc of argDescs) { - const origPtr = origArgs[desc.argIndex]; - if ( - syscallNr === SYS_IOCTL && - (origArgs[1] >>> 0) === TCFLSH && - desc.argIndex === 2 - ) { - // The guest supplied a scalar queue selector, not an output pointer. - continue; - } - if (origPtr === 0) continue; - - let size: number; - if (desc.size.type === "cstring") { - let len = 0; - while ( - len < CH_DATA_SIZE - outOffset - 1 && - processMem[origPtr + len] !== 0 - ) { - len++; - } - size = len + 1; - } else if (desc.size.type === "arg") { - size = - origArgs[desc.size.argIndex] * (desc.size.multiplier ?? 1) + - (desc.size.add ?? 0); - } else if (desc.size.type === "deref") { - const derefPtr = origArgs[desc.size.argIndex]; - if (derefPtr === 0) continue; - size = - processMem[derefPtr] | - (processMem[derefPtr + 1] << 8) | - (processMem[derefPtr + 2] << 16) | - (processMem[derefPtr + 3] << 24); - } else { - size = desc.size.size; - } - - if (size <= 0) continue; - if (outOffset + size > CH_DATA_SIZE) { - size = CH_DATA_SIZE - outOffset; - if (size <= 0) continue; - } - - const kernelPtr = dataStart + outOffset; - if (desc.direction === "out" || desc.direction === "inout") { - // Pure output is unspecified on failure; preserve the caller's bytes. - if (!(desc.direction === "out" && retVal < 0)) { - let copySize = size; - if (desc.direction === "out" && desc.size.type === "arg") { - // For read/recv/getdents-like syscalls, retVal is the number of - // bytes produced. Successful EOF must not copy the zero-filled - // scratch buffer over bytes the caller already owns. Some - // descriptors prepend fixed metadata that is still produced when - // the variable-length result is empty. - const copyRetvalAdd = desc.copyRetvalAdd ?? 0; - if (retVal === 0) { - copySize = Math.min(copyRetvalAdd, size); - } else if (retVal + copyRetvalAdd < size) { - copySize = retVal + copyRetvalAdd; - } - } - let bytes = new Uint8Array(copySize); - bytes.set(kernelMem.subarray(kernelPtr, kernelPtr + copySize)); - if ( - syscallNr === SYS_RT_SIGTIMEDWAIT && - desc.argIndex === 1 && - this.getPtrWidth(channel.pid) === 8 && - copySize >= 32 - ) { - // The kernel channel carries siginfo's meaningful fields in the - // fixed wasm32 layout: header at 0, the first union words at - // 12/16, and sival_int at 20. Musl's wasm64 siginfo_t aligns the - // union to eight bytes, moving those fields to 16/20/24. Expand - // the fixed channel record at the host boundary, where the guest - // pointer width is known. - bytes.copyWithin(16, 12, 24); - bytes.fill(0, 12, 16); - } - writes.push({ ptr: origPtr, bytes }); - } - } - - outOffset += size; - outOffset = (outOffset + 7) & ~7; - } - - return writes; - } - private publishOrParkChannelCompletion( channel: ChannelInfo, prepared: PreparedChannelCompletion, @@ -5286,8 +6943,9 @@ export class CentralizedKernelWorker { channel.memory.buffer, channel.channelOffset, ); - Atomics.store(i32View, CH_STATUS / 4, CH_COMPLETE); - Atomics.notify(i32View, CH_STATUS / 4, 1); + const statusIndex = CH_STATUS / Int32Array.BYTES_PER_ELEMENT; + Atomics.store(i32View, statusIndex, CH_COMPLETE); + Atomics.notify(i32View, statusIndex, 1); if (prepared.relistenRequested && this.isRegisteredChannel(channel)) { this.relistenChannel(channel); } @@ -5557,6 +7215,7 @@ export class CentralizedKernelWorker { sleep.origArgs, sleep.retVal, sleep.errVal, + sleep.outputWrites, ); return true; } @@ -5660,23 +7319,30 @@ export class CentralizedKernelWorker { */ failDeferredCloneLaunch(pid: number, tid: number, errno: number): boolean { for (const [channel, parked] of this.parkedChannelCompletions ?? []) { - if (channel.pid !== pid || parked.prepared.retVal !== tid) continue; - const view = new DataView(channel.memory.buffer, channel.channelOffset); - if (view.getUint32(CH_SYSCALL, true) !== SYS_CLONE) continue; - - const flags = Number(view.getBigInt64(CH_ARGS, true)); - const ptidPtr = Number(view.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true)); - const CLONE_PARENT_SETTID = 0x00100000; - if ( - (flags & CLONE_PARENT_SETTID) !== 0 && - isValidMemoryRange(new Uint8Array(channel.memory.buffer), ptidPtr, 4) - ) { + const clone = parked.prepared.deferredClone; + if (channel.pid !== pid || clone?.tid !== tid) continue; + + // WHY: the guest mailbox is mutable while this completion is parked. + // Clear only the parent-TID address validated for the original clone, + // never flags/pointers re-read from potentially replaced channel bytes. + const ptidPtr = clone.parentTidPointer; + if (ptidPtr !== undefined) { + if ( + !isValidMemoryRange( + new Uint8Array(channel.memory.buffer), + ptidPtr, + 4, + ) + ) { + return false; + } new DataView(channel.memory.buffer).setInt32(ptidPtr, 0, true); } parked.prepared.outputWrites = []; parked.prepared.retVal = -1; parked.prepared.errVal = errno; + parked.prepared.deferredClone = undefined; return true; } return false; @@ -5789,7 +7455,10 @@ export class CentralizedKernelWorker { let status: number; try { const i32 = new Int32Array(channel.memory.buffer, channel.channelOffset); - status = Atomics.load(i32, CH_STATUS / 4); + status = Atomics.load( + i32, + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + ); } catch { continue; } if (status !== CH_PENDING) continue; try { @@ -5985,7 +7654,12 @@ export class CentralizedKernelWorker { channel.channelOffset, ); channel.i32View = stoppedView; - if (Atomics.load(stoppedView, CH_STATUS / 4) === CH_PENDING) { + if ( + Atomics.load( + stoppedView, + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + ) === CH_PENDING + ) { this.deferChannelWhileStopped(channel); } continue; @@ -5997,7 +7671,12 @@ export class CentralizedKernelWorker { channel.channelOffset, ); channel.i32View = i32View; - if (Atomics.load(i32View, 0) === CH_PENDING) { + if ( + Atomics.load( + i32View, + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + ) === CH_PENDING + ) { channel.handling = true; this.handleSyscall(channel); } @@ -6092,11 +7771,17 @@ export class CentralizedKernelWorker { const acceptIndices: number[] = []; const processMem = new DataView(channel.memory.buffer); const POLLIN = 0x001; - // struct pollfd: fd(4) + events(2) + revents(2) = 8 bytes for (let i = 0; i < nfds; i++) { - const fd = processMem.getInt32(fdsPtr + i * 8, true); + const entry = fdsPtr + i * STRUCT_SIZE_WASM_POLL_FD; + const fd = processMem.getInt32( + entry + WASM_POLL_FD_FD_OFFSET, + true, + ); if (fd < 0) continue; - const events = processMem.getInt16(fdsPtr + i * 8 + 4, true); + const events = processMem.getInt16( + entry + WASM_POLL_FD_EVENTS_OFFSET, + true, + ); if (getRecvPipe) { const pipeIdx = getRecvPipe(pid, fd); if (pipeIdx >= 0) { @@ -6303,23 +7988,43 @@ export class CentralizedKernelWorker { // reuse this scratch allocation. const events: OwnedKernelWakeEvent[] = []; for (;;) { - const count = drainFn( - this.toKernelPtr(this.scratchOffset), - bufSize, - MAX_EVENTS, - ); + const batch = this.requireMainScratchRegion().withLease((lease) => { + const count = lease.invokeKernelExport( + "kernel_drain_wakeup_events", + [ + lease.exportPointer(0, bufSize), + bufSize, + MAX_EVENTS, + ], + ); + if ( + !Number.isSafeInteger(count) + || count > MAX_EVENTS + ) { + throw new KernelScratchError( + `kernel wake drain returned invalid event count ${count}`, + EIO, + ); + } + return { + count, + bytes: count > 0 + ? lease.copyOut(0, count * BYTES_PER_EVENT) + : new Uint8Array(0), + }; + }); + const { count } = batch; if (count <= 0) break; - const kernelMem = new Uint8Array(this.kernelMemory!.buffer); for (let i = 0; i < count; i++) { - const off = this.scratchOffset + i * BYTES_PER_EVENT; + const off = i * BYTES_PER_EVENT; events.push({ wakeIdx: - (kernelMem[off] | - (kernelMem[off + 1] << 8) | - (kernelMem[off + 2] << 16) | - (kernelMem[off + 3] << 24)) >>> + (batch.bytes[off] | + (batch.bytes[off + 1] << 8) | + (batch.bytes[off + 2] << 16) | + (batch.bytes[off + 3] << 24)) >>> 0, - wakeType: kernelMem[off + 4], + wakeType: batch.bytes[off + 4], }); } if (count < MAX_EVENTS) break; @@ -6962,22 +8667,14 @@ export class CentralizedKernelWorker { const conns = this.tcpConnections.get(pid); if (!conns || conns.length === 0) return; - const pipeRead = this.kernelInstance!.exports.kernel_pipe_read as - (pid: number, pipeIdx: number, bufPtr: KernelPointer, bufLen: number) => number; - const mem = this.getKernelMem(); - // Injected-connection pipes live in the global pipe table; pid=0 // tells kernel_pipe_read to use it directly. See kernel_inject_connection. for (const conn of conns) { // Drain all available data from the send pipe (not just one chunk) for (;;) { - const readN = checkedKernelPipeTransferCount( - "kernel_pipe_read", - pipeRead(0, conn.sendPipeIdx, this.toKernelPtr(conn.scratchOffset), 65536), - 65536, - ); - if (readN <= 0) break; - const outData = Buffer.from(mem.slice(conn.scratchOffset, conn.scratchOffset + readN)); + const bytes = this.readPipeChunk(0, conn.sendPipeIdx); + if (!bytes) break; + const outData = Buffer.from(bytes); if (!conn.clientSocket.destroyed) { conn.clientSocket.write(outData); } @@ -7115,6 +8812,7 @@ export class CentralizedKernelWorker { channel: ChannelInfo, syscallNr: number, origArgs: number[], + detachedOutput: ChannelOutputWrite[] = [], ): void { if (!this.isRegisteredChannel(channel)) return; @@ -7128,10 +8826,27 @@ export class CentralizedKernelWorker { if (syscallNr === SYS_FUTEX) { const futexOp = origArgs[1] & 0x7f; // mask out FUTEX_PRIVATE_FLAG if (futexOp === 0) { // FUTEX_WAIT - const addr = origArgs[0]; // address in process memory + let addr: number; + try { + addr = this.checkedProcessRange( + channel, + origArgs[0], + 4, + "futex retry uaddr", + ).pointer; + if ((addr & 3) !== 0) { + throw new KernelScratchError( + "futex retry uaddr is not aligned", + EINVAL, + ); + } + } catch (error) { + this.rejectScratchTransfer(channel, error); + return; + } const expectedVal = origArgs[2]; const i32View = new Int32Array(channel.memory.buffer); - const index = addr >>> 2; // convert byte offset to i32 index + const index = addr / 4; // Check if value already changed const currentVal = Atomics.load(i32View, index); @@ -7173,14 +8888,42 @@ export class CentralizedKernelWorker { } else { const tsPtr = origArgs[2]; if (tsPtr !== 0) { - const pv = new DataView(channel.memory.buffer, tsPtr); + // Every retry re-enters _handleSyscallInner, which validates this + // special ppoll source before the kernel returns EAGAIN. Repeat the + // range proof here rather than treating that earlier proof as a + // lifetime guarantee for caller memory. + let range: { pointer: number; length: number }; + try { + range = this.checkedProcessRange( + channel, + tsPtr, + 16, + "ppoll retry timeout", + ); + } catch (error) { + this.rejectScratchTransfer(channel, error); + return; + } + const pv = new DataView( + channel.memory.buffer, + range.pointer, + range.length, + ); const sec = Number(pv.getBigInt64(0, true)); const nsec = Number(pv.getBigInt64(8, true)); timeoutMs = sec * 1000 + Math.floor(nsec / 1000000); } } if (timeoutMs === 0) { - this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], 0, 0); + this.completeChannel( + channel, + syscallNr, + origArgs, + SYSCALL_ARGS[syscallNr], + 0, + 0, + detachedOutput, + ); return; } const deadline = this.getReadinessDeadline(channel, timeoutMs); @@ -7573,22 +9316,18 @@ export class CentralizedKernelWorker { origArgs: number[], retVal: number, errVal: number, + capturedDelayMs?: number, + outputWrites: ChannelOutputWrite[] = [], ): boolean { let delayMs = 0; if (syscallNr === SYS_NANOSLEEP && retVal >= 0) { - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const sec = kernelView.getUint32(CH_DATA, true); - const nsec = kernelView.getUint32(CH_DATA + 8, true); - delayMs = sec * 1000 + Math.floor(nsec / 1_000_000); + delayMs = capturedDelayMs ?? 0; } else if (syscallNr === SYS_USLEEP && retVal >= 0) { const usec = origArgs[0] >>> 0; delayMs = Math.max(1, Math.floor(usec / 1000)); } else if (syscallNr === SYS_CLOCK_NANOSLEEP && retVal >= 0) { - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const sec = kernelView.getUint32(CH_DATA, true); - const nsec = kernelView.getUint32(CH_DATA + 8, true); - delayMs = sec * 1000 + Math.floor(nsec / 1_000_000); + delayMs = capturedDelayMs ?? 0; } if (delayMs > 0) { @@ -7597,10 +9336,25 @@ export class CentralizedKernelWorker { if (pending?.timer !== timer || pending.channel !== channel) return; this.pendingSleeps.delete(channel); if (this.isRegisteredChannel(channel)) { - this.completeSleepWithSignalCheck(channel, syscallNr, origArgs, retVal, errVal); + this.completeSleepWithSignalCheck( + channel, + syscallNr, + origArgs, + retVal, + errVal, + outputWrites, + ); } }, delayMs); - this.pendingSleeps.set(channel, { timer, channel, syscallNr, origArgs, retVal, errVal }); + this.pendingSleeps.set(channel, { + timer, + channel, + syscallNr, + origArgs, + retVal, + errVal, + outputWrites, + }); return true; } @@ -7617,6 +9371,7 @@ export class CentralizedKernelWorker { origArgs: number[], retVal: number, errVal: number, + outputWrites: ChannelOutputWrite[] = [], ): void { // Check if a signal became pending during the sleep this.dequeueSignalForDelivery(channel); @@ -7630,35 +9385,39 @@ export class CentralizedKernelWorker { const EINTR = 4; this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], -1, EINTR); } else { - this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], retVal, errVal); + this.completeChannel( + channel, + syscallNr, + origArgs, + SYSCALL_ARGS[syscallNr], + retVal, + errVal, + outputWrites, + ); } } // ----------------------------------------------------------------------- // Scatter/gather I/O handling (writev/readv/pwritev/preadv) // - // These syscalls use struct iovec arrays with nested pointers: - // struct iovec { void *iov_base; size_t iov_len; } (8 bytes on wasm32) - // Both the iov array AND each iov_base buffer must be in kernel memory. + // These syscalls use caller-width-native struct iovec arrays with nested + // pointers. Sizes and offsets come from the generated musl layout contract. + // Both the array and each iov_base range belong to caller process memory. + // Validate those ranges there before staging capacity-bounded kernel copies. // ----------------------------------------------------------------------- - /** - * Handle writev/pwritev: copy iov array and all data buffers from - * process memory into kernel scratch, then call kernel_handle_channel. - */ /** * Handle fcntl lock operations (F_GETLK, F_SETLK, F_SETLKW). - * Arg3 is a pointer to struct flock (32 bytes) which needs copy in/out. + * Arg3 points to the generated fixed-size flock wire and needs copy in/out. */ private handleFcntlLock(channel: ChannelInfo, origArgs: number[]): void { - const FLOCK_SIZE = 32; const flockPtr = origArgs[2]; const processMem = new Uint8Array(channel.memory.buffer); if ( !Number.isSafeInteger(flockPtr) || flockPtr <= 0 || - flockPtr > processMem.byteLength - FLOCK_SIZE + flockPtr > processMem.byteLength - FCNTL_FLOCK_BYTES ) { this.completeChannel( channel, @@ -7670,36 +9429,61 @@ export class CentralizedKernelWorker { ); return; } - const kernelMem = this.getKernelMem(); - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const dataStart = this.scratchOffset + CH_DATA; - - // Copy flock struct from process → kernel scratch - kernelMem.set(processMem.subarray(flockPtr, flockPtr + FLOCK_SIZE), dataStart); - - // Write syscall header to kernel scratch - kernelView.setUint32(CH_SYSCALL, SYS_FCNTL, true); - kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(origArgs[0]), true); // fd - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(origArgs[1]), true); // cmd - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(dataStart), true); // flock_ptr in kernel memory - for (let i = 3; i < CH_ARGS_COUNT; i++) { - kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, BigInt(origArgs[i]), true); - } - - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; + let result: { retVal: number; errVal: number; flock: Uint8Array | null }; try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } finally { - this.currentHandlePid = 0; + result = this.requireMainScratchRegion().withLease((lease) => { + const kernelView = lease.dataView(0, CH_TOTAL_SIZE); + lease.copyFrom(processMem, CH_DATA, flockPtr, FCNTL_FLOCK_BYTES); + kernelView.setUint32(CH_SYSCALL, SYS_FCNTL, true); + kernelView.setBigInt64(CH_ARGS, BigInt(origArgs[0]), true); + kernelView.setBigInt64( + CH_ARGS + CH_ARG_SIZE, + BigInt(origArgs[1]), + true, + ); + lease.writeAddress( + CH_ARGS + 2 * CH_ARG_SIZE, + CH_DATA, + FCNTL_FLOCK_BYTES, + "u64-le", + ); + for (let i = 3; i < CH_ARGS_COUNT; i++) { + kernelView.setBigInt64( + CH_ARGS + i * CH_ARG_SIZE, + BigInt(origArgs[i]), + true, + ); + } + + this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; + try { + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + ]); + } finally { + this.currentHandlePid = 0; + } + const resultView = lease.dataView(0, CH_TOTAL_SIZE); + const retVal = Number(resultView.getBigInt64(CH_RETURN, true)); + return { + retVal, + errVal: resultView.getUint32(CH_ERRNO, true), + flock: retVal >= 0 + ? lease.copyOut(CH_DATA, FCNTL_FLOCK_BYTES) + : null, + }; + }); + } catch (error) { + this.rejectScratchTransfer(channel, error); + return; } if (this.finishSignalTermination(channel)) return; - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); + const { retVal, errVal } = result; // This marshalling path bypasses the generic syscall completion path, // so it must also dequeue a caught signal itself. A conflicting blocking // request is interruptible: once a handler signal is prepared for this @@ -7708,9 +9492,8 @@ export class CentralizedKernelWorker { if (this.finishSignalTermination(channel)) return; // Copy flock struct back from kernel → process (F_GETLK writes to it) - if (retVal >= 0) { - const freshProcessMem = new Uint8Array(channel.memory.buffer); - freshProcessMem.set(kernelMem.subarray(dataStart, dataStart + FLOCK_SIZE), flockPtr); + if (result.flock) { + new Uint8Array(channel.memory.buffer).set(result.flock, flockPtr); } const cmd = origArgs[1]; @@ -7741,11 +9524,8 @@ export class CentralizedKernelWorker { * Handle pselect6: copy fd_sets (inout), decode timeout/sigmask from * process memory, call kernel_handle_channel, copy fd_sets back. * - * Layout in kernel scratch data area: - * [0..128] readfds (fd_set, 128 bytes) - * [128..256] writefds (fd_set, 128 bytes) - * [256..384] exceptfds (fd_set, 128 bytes) - * [384..392] mask (8 bytes: mask_lo + mask_hi) + * The scratch layout is three contiguous generated-size fd_sets followed by + * one generated-size signal mask. */ /** * select(2) — args (nfds, readfds, writefds, exceptfds, *timeval). @@ -7776,21 +9556,185 @@ export class CentralizedKernelWorker { return false; } - private handleSelect(channel: ChannelInfo, origArgs: number[]): void { - if (this.deferChannelWhileStopped(channel)) return; - const FD_SET_SIZE = 128; - const nfds = origArgs[0]; - const readPtr = origArgs[1]; - const writePtr = origArgs[2]; - const exceptPtr = origArgs[3]; - const tvPtr = origArgs[4]; + /** + * Marshal one select-family attempt while holding the main scratch lease. + * + * WHY: retries may cross timers, but a single attempt copies caller bytes, + * invokes Rust, and snapshots every output synchronously. No scratch view + * survives into the asynchronous retry state. + */ + private runSelectKernelAttempt( + channel: ChannelInfo, + syscallNr: number, + nfds: number, + readPtr: number, + writePtr: number, + exceptPtr: number, + timeoutMs: number, + maskPtr: number, + ): { + retVal: number; + errVal: number; + read: Uint8Array | null; + write: Uint8Array | null; + except: Uint8Array | null; + usedMask: boolean; + } { + const processMem = new Uint8Array(channel.memory.buffer); + const scratch = this.requireMainScratchRegion(); + + return scratch.withLease((lease) => { + const kernelView = lease.dataView(0, CH_TOTAL_SIZE); + const fdSetOffset = (index: number) => + CH_DATA + index * SELECT_FD_SET_BYTES; + for (const [index, pointer] of [readPtr, writePtr, exceptPtr].entries()) { + if (pointer !== 0) { + lease.copyFrom( + processMem, + fdSetOffset(index), + pointer, + SELECT_FD_SET_BYTES, + ); + } else { + lease.fill(0, fdSetOffset(index), SELECT_FD_SET_BYTES); + } + } + const maskOffset = fdSetOffset(3); + if (maskPtr !== 0) { + lease.copyFrom( + processMem, + maskOffset, + maskPtr, + SIGNAL_MASK_BYTES, + ); + } - let timeoutMs = -1; // -1 = infinite (NULL timeval) - if (tvPtr !== 0) { - const ptrWidth = this.getPtrWidth(channel.pid); - const pv = new DataView(channel.memory.buffer, tvPtr); + kernelView.setUint32(CH_SYSCALL, syscallNr, true); + for (let index = 0; index < CH_ARGS_COUNT; index++) { + kernelView.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, 0n, true); + } + kernelView.setBigInt64(CH_ARGS, BigInt(nfds), true); + if (readPtr !== 0) { + lease.writeAddress( + CH_ARGS + CH_ARG_SIZE, + fdSetOffset(0), + SELECT_FD_SET_BYTES, + "u64-le", + ); + } + if (writePtr !== 0) { + lease.writeAddress( + CH_ARGS + 2 * CH_ARG_SIZE, + fdSetOffset(1), + SELECT_FD_SET_BYTES, + "u64-le", + ); + } + if (exceptPtr !== 0) { + lease.writeAddress( + CH_ARGS + 3 * CH_ARG_SIZE, + fdSetOffset(2), + SELECT_FD_SET_BYTES, + "u64-le", + ); + } + kernelView.setBigInt64( + CH_ARGS + 4 * CH_ARG_SIZE, + BigInt(timeoutMs), + true, + ); + if (syscallNr === SYS_PSELECT6 && maskPtr !== 0) { + lease.writeAddress( + CH_ARGS + 5 * CH_ARG_SIZE, + maskOffset, + SIGNAL_MASK_BYTES, + "u64-le", + ); + } + + this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; + try { + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + ]); + } finally { + this.currentHandlePid = 0; + } + + const resultView = lease.dataView(0, CH_TOTAL_SIZE); + const retVal = Number(resultView.getBigInt64(CH_RETURN, true)); + const errVal = resultView.getUint32(CH_ERRNO, true); + return { + retVal, + errVal, + read: retVal >= 0 && readPtr !== 0 + ? lease.copyOut(fdSetOffset(0), SELECT_FD_SET_BYTES) + : null, + write: retVal >= 0 && writePtr !== 0 + ? lease.copyOut(fdSetOffset(1), SELECT_FD_SET_BYTES) + : null, + except: retVal >= 0 && exceptPtr !== 0 + ? lease.copyOut(fdSetOffset(2), SELECT_FD_SET_BYTES) + : null, + usedMask: maskPtr !== 0, + }; + }); + } + + private handleSelect(channel: ChannelInfo, origArgs: number[]): void { + if (this.deferChannelWhileStopped(channel)) return; + const nfds = origArgs[0]; + const readPtr = origArgs[1]; + const writePtr = origArgs[2]; + const exceptPtr = origArgs[3]; + const tvPtr = origArgs[4]; + const pointerWidth = this.getPtrWidth(channel.pid); + try { + if ( + !Number.isSafeInteger(nfds) || + nfds < 0 || + nfds > SELECT_FD_SETSIZE + ) { + throw new KernelScratchError( + `select nfds must be between 0 and ${SELECT_FD_SETSIZE}`, + EINVAL, + ); + } + for (const [pointer, field] of [ + [readPtr, "select read fd_set"], + [writePtr, "select write fd_set"], + [exceptPtr, "select except fd_set"], + ] as const) { + if (pointer !== 0) { + this.checkedProcessRange( + channel, + pointer, + SELECT_FD_SET_BYTES, + field, + ); + } + } + if (tvPtr !== 0) { + this.checkedProcessRange( + channel, + tvPtr, + pointerWidth === 8 ? 16 : 8, + "select timeout", + ); + } + } catch (error) { + this.rejectScratchTransfer(channel, error); + return; + } + + let timeoutMs = -1; // -1 = infinite (NULL timeval) + if (tvPtr !== 0) { + const pv = new DataView(channel.memory.buffer, tvPtr); let sec: number, usec: number; - if (ptrWidth === 8) { + if (pointerWidth === 8) { sec = Number(pv.getBigInt64(0, true)); usec = Number(pv.getBigInt64(8, true)); } else { @@ -7839,70 +9783,27 @@ export class CentralizedKernelWorker { return; } - // General case: dispatch to the kernel's sys_select with timeout_ms in - // arg5. fd_sets are copied via the standard pre-existing scratch flow - // (kernel_select reads readfds_ptr/writefds_ptr/exceptfds_ptr into - // process memory directly, so we copy them in just like handlePselect6). - const processMem = new Uint8Array(channel.memory.buffer); - const kernelMem = this.getKernelMem(); - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const dataStart = this.scratchOffset + CH_DATA; - - if (readPtr !== 0) { - kernelMem.set(processMem.subarray(readPtr, readPtr + FD_SET_SIZE), dataStart); - } else { - kernelMem.fill(0, dataStart, dataStart + FD_SET_SIZE); - } - if (writePtr !== 0) { - kernelMem.set(processMem.subarray(writePtr, writePtr + FD_SET_SIZE), dataStart + FD_SET_SIZE); - } else { - kernelMem.fill(0, dataStart + FD_SET_SIZE, dataStart + 2 * FD_SET_SIZE); - } - if (exceptPtr !== 0) { - kernelMem.set(processMem.subarray(exceptPtr, exceptPtr + FD_SET_SIZE), dataStart + 2 * FD_SET_SIZE); - } else { - kernelMem.fill(0, dataStart + 2 * FD_SET_SIZE, dataStart + 3 * FD_SET_SIZE); - } - - kernelView.setUint32(CH_SYSCALL, SYS_SELECT, true); - kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(nfds), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(readPtr !== 0 ? dataStart : 0), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(writePtr !== 0 ? dataStart + FD_SET_SIZE : 0), true); - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(exceptPtr !== 0 ? dataStart + 2 * FD_SET_SIZE : 0), true); - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(kernelTimeoutMs), true); - - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; + let attempt: ReturnType; try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } finally { - this.currentHandlePid = 0; - } - - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); - - // Copy fd_sets back from kernel → process on success - if (retVal >= 0) { - const freshProcessMem = new Uint8Array(channel.memory.buffer); - if (readPtr !== 0) { - freshProcessMem.set(kernelMem.subarray(dataStart, dataStart + FD_SET_SIZE), readPtr); - } - if (writePtr !== 0) { - freshProcessMem.set( - kernelMem.subarray(dataStart + FD_SET_SIZE, dataStart + 2 * FD_SET_SIZE), - writePtr, - ); - } - if (exceptPtr !== 0) { - freshProcessMem.set( - kernelMem.subarray(dataStart + 2 * FD_SET_SIZE, dataStart + 3 * FD_SET_SIZE), - exceptPtr, - ); - } + attempt = this.runSelectKernelAttempt( + channel, + SYS_SELECT, + nfds, + readPtr, + writePtr, + exceptPtr, + kernelTimeoutMs, + 0, + ); + } catch (error) { + this.rejectScratchTransfer(channel, error); + return; } + const { retVal, errVal } = attempt; + const freshProcessMem = new Uint8Array(channel.memory.buffer); + if (attempt.read) freshProcessMem.set(attempt.read, readPtr); + if (attempt.write) freshProcessMem.set(attempt.write, writePtr); + if (attempt.except) freshProcessMem.set(attempt.except, exceptPtr); if (this.completeSelectSignalOutcome( channel, @@ -7955,14 +9856,7 @@ export class CentralizedKernelWorker { private handlePselect6(channel: ChannelInfo, origArgs: number[]): void { if (this.deferChannelWhileStopped(channel)) return; - const FD_SET_SIZE = 128; const processMem = new Uint8Array(channel.memory.buffer); - const kernelMem = this.getKernelMem(); - const kernelView = new DataView( - this.kernelMemory!.buffer, - this.scratchOffset, - ); - const dataStart = this.scratchOffset + CH_DATA; const nfds = origArgs[0]; const readPtr = origArgs[1]; @@ -7970,22 +9864,68 @@ export class CentralizedKernelWorker { const exceptPtr = origArgs[3]; const tsPtr = origArgs[4]; const maskDataPtr = origArgs[5]; // pointer to {sigset_t *mask, size_t size} - - // Copy fd_sets from process → kernel scratch - if (readPtr !== 0) { - kernelMem.set(processMem.subarray(readPtr, readPtr + FD_SET_SIZE), dataStart); - } else { - kernelMem.fill(0, dataStart, dataStart + FD_SET_SIZE); - } - if (writePtr !== 0) { - kernelMem.set(processMem.subarray(writePtr, writePtr + FD_SET_SIZE), dataStart + FD_SET_SIZE); - } else { - kernelMem.fill(0, dataStart + FD_SET_SIZE, dataStart + 2 * FD_SET_SIZE); - } - if (exceptPtr !== 0) { - kernelMem.set(processMem.subarray(exceptPtr, exceptPtr + FD_SET_SIZE), dataStart + 2 * FD_SET_SIZE); - } else { - kernelMem.fill(0, dataStart + 2 * FD_SET_SIZE, dataStart + 3 * FD_SET_SIZE); + let checkedMaskPtr = 0; + try { + if ( + !Number.isSafeInteger(nfds) || + nfds < 0 || + nfds > SELECT_FD_SETSIZE + ) { + throw new KernelScratchError( + `pselect nfds must be between 0 and ${SELECT_FD_SETSIZE}`, + EINVAL, + ); + } + for (const [pointer, field] of [ + [readPtr, "pselect6 read fd_set"], + [writePtr, "pselect6 write fd_set"], + [exceptPtr, "pselect6 except fd_set"], + ] as const) { + if (pointer !== 0) { + this.checkedProcessRange( + channel, + pointer, + SELECT_FD_SET_BYTES, + field, + ); + } + } + if (tsPtr !== 0) { + this.checkedProcessRange(channel, tsPtr, 16, "pselect6 timeout"); + } + if (maskDataPtr !== 0) { + const pointerWidth = this.getPtrWidth(channel.pid); + const outer = this.checkedProcessRange( + channel, + maskDataPtr, + pointerWidth === 8 ? 16 : 8, + "pselect6 mask descriptor", + ); + const descriptor = new DataView( + channel.memory.buffer, + outer.pointer, + outer.length, + ); + const rawMaskPointer = pointerWidth === 8 + ? descriptor.getBigUint64(0, true) + : descriptor.getUint32(0, true); + checkedMaskPtr = checkedWasmPointer( + rawMaskPointer, + pointerWidth, + "pselect6 mask pointer", + ); + if (checkedMaskPtr !== 0) { + this.checkedProcessRange( + channel, + rawMaskPointer, + SIGNAL_MASK_BYTES, + "pselect6 signal mask", + ); + } + } + } catch (error) { + this.rejectScratchTransfer(channel, error); + return; } // Decode timeout: timespec {i64 sec, i64 nsec} → ms @@ -8016,64 +9956,27 @@ export class CentralizedKernelWorker { // call, letting the next kill(getpid, SIGTERM) fire the main-thread // handler before the dedicated `signal_hand` thread could `sigwait` it // — `wait_for_signal_thread_to_end` then spun forever. - const maskOffset = dataStart + 3 * FD_SET_SIZE; - let kernelMaskPtr = 0; // 0 = no mask swap - if (maskDataPtr !== 0) { - const pw = this.getPtrWidth(channel.pid); - const mdv = new DataView(channel.memory.buffer, maskDataPtr); - const maskPtr = pw === 8 - ? Number(mdv.getBigUint64(0, true)) - : mdv.getUint32(0, true); - if (maskPtr !== 0) { - kernelMem.set(processMem.subarray(maskPtr, maskPtr + 8), maskOffset); - kernelMaskPtr = maskOffset; - } - } - - // Write args: (nfds, readfds_kernel_ptr, writefds_kernel_ptr, - // exceptfds_kernel_ptr, timeout_ms, mask_kernel_ptr) - kernelView.setUint32(CH_SYSCALL, SYS_PSELECT6, true); - kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(nfds), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(readPtr !== 0 ? dataStart : 0), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(writePtr !== 0 ? dataStart + FD_SET_SIZE : 0), true); - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(exceptPtr !== 0 ? dataStart + 2 * FD_SET_SIZE : 0), true); - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(kernelTimeoutMs), true); - kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(kernelMaskPtr), true); - - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; + let attempt: ReturnType; try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } finally { - this.currentHandlePid = 0; - } - - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); - - // pselect6 debug logging disabled - - // Copy fd_sets back from kernel → process - if (retVal >= 0) { - const freshProcessMem = new Uint8Array(channel.memory.buffer); - if (readPtr !== 0) { - freshProcessMem.set(kernelMem.subarray(dataStart, dataStart + FD_SET_SIZE), readPtr); - } - if (writePtr !== 0) { - freshProcessMem.set( - kernelMem.subarray(dataStart + FD_SET_SIZE, dataStart + 2 * FD_SET_SIZE), - writePtr, - ); - } - if (exceptPtr !== 0) { - freshProcessMem.set( - kernelMem.subarray(dataStart + 2 * FD_SET_SIZE, dataStart + 3 * FD_SET_SIZE), - exceptPtr, - ); - } + attempt = this.runSelectKernelAttempt( + channel, + SYS_PSELECT6, + nfds, + readPtr, + writePtr, + exceptPtr, + kernelTimeoutMs, + checkedMaskPtr, + ); + } catch (error) { + this.rejectScratchTransfer(channel, error); + return; } + const { retVal, errVal } = attempt; + const freshProcessMem = new Uint8Array(channel.memory.buffer); + if (attempt.read) freshProcessMem.set(attempt.read, readPtr); + if (attempt.write) freshProcessMem.set(attempt.write, writePtr); + if (attempt.except) freshProcessMem.set(attempt.except, exceptPtr); if (this.completeSelectSignalOutcome( channel, @@ -8096,7 +9999,7 @@ export class CentralizedKernelWorker { // pselect6 with a non-null sigmask pointer has the same late-signal // race as ppoll. See scheduleWakeBlockedRetriesDeferred. - const needsSignalSafeWake = kernelMaskPtr !== 0; + const needsSignalSafeWake = attempt.usedMask; // nfds=0: pure sleep/sigsuspend-like behavior. // With finite timeout: sleep for that duration. @@ -8157,32 +10060,44 @@ export class CentralizedKernelWorker { * then initialise an empty interest list on the host side. */ private handleEpollCreate(channel: ChannelInfo, syscallNr: number, origArgs: number[]): void { - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); const flags = origArgs[0]; // For SYS_EPOLL_CREATE, kernel expects flags=0 (size arg ignored) const actualFlags = syscallNr === SYS_EPOLL_CREATE ? 0 : flags; - - kernelView.setUint32(CH_SYSCALL, syscallNr, true); - kernelView.setBigInt64(CH_ARGS, BigInt(actualFlags), true); - for (let i = 1; i < CH_ARGS_COUNT; i++) { - kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); - } - - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; + let result: { retVal: number; errVal: number }; try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } finally { - this.currentHandlePid = 0; + result = this.requireMainScratchRegion().withLease((lease) => { + const kernelView = lease.dataView(0, CH_TOTAL_SIZE); + kernelView.setUint32(CH_SYSCALL, syscallNr, true); + kernelView.setBigInt64(CH_ARGS, BigInt(actualFlags), true); + for (let i = 1; i < CH_ARGS_COUNT; i++) { + kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); + } + this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; + try { + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + ]); + } finally { + this.currentHandlePid = 0; + } + const resultView = lease.dataView(0, CH_TOTAL_SIZE); + return { + retVal: Number(resultView.getBigInt64(CH_RETURN, true)), + errVal: resultView.getUint32(CH_ERRNO, true), + }; + }); + } catch (error) { + this.rejectScratchTransfer(channel, error); + return; } if (this.finishSignalTermination(channel)) return; - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); + const { retVal, errVal } = result; // If successful, initialise the host-side interest mirror if (retVal >= 0) { @@ -8197,54 +10112,101 @@ export class CentralizedKernelWorker { * Handle epoll_ctl: let the kernel modify its interest list, then mirror * the change on the host side. */ - private handleEpollCtl(channel: ChannelInfo, origArgs: number[]): void { + private handleEpollCtl( + channel: ChannelInfo, + origArgs: number[], + rawArgs?: readonly bigint[], + ): void { const epfd = origArgs[0]; const op = origArgs[1]; const fd = origArgs[2]; - const eventPtr = origArgs[3]; // pointer in process memory + const rawEventPtr = rawArgs?.[3] ?? origArgs[3]; // process pointer + const hasEvent = rawEventPtr !== 0 && rawEventPtr !== 0n; - // Read epoll_event from process memory: { events: u32, data: u64 } = 12 bytes + // Both Kandelo musl targets align epoll_data_t to eight bytes: + // { events: u32, pad: u32, data: u64 } = 16 bytes. let events = 0; let data = 0n; - if (eventPtr !== 0) { - const pv = new DataView(channel.memory.buffer, eventPtr); - events = pv.getUint32(0, true); - data = pv.getBigUint64(4, true); - } - - // Call kernel — copy event struct to scratch - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const kernelMem = this.getKernelMem(); - const dataStart = this.scratchOffset + CH_DATA; - - // Copy 12-byte epoll_event to kernel scratch - if (eventPtr !== 0) { - const processMem = new Uint8Array(channel.memory.buffer); - kernelMem.set(processMem.subarray(eventPtr, eventPtr + 12), dataStart); + let eventPtr = 0; + if (hasEvent) { + let eventRange: { pointer: number; length: number; end: number }; + try { + eventRange = this.checkedProcessRange( + channel, + rawEventPtr, + STRUCT_SIZE_WASM_EPOLL_EVENT, + "epoll_ctl event", + ); + } catch (error) { + this.rejectScratchTransfer(channel, error); + return; + } + eventPtr = eventRange.pointer; + const pv = new DataView( + channel.memory.buffer, + eventRange.pointer, + eventRange.length, + ); + events = pv.getUint32(WASM_EPOLL_EVENT_EVENTS_OFFSET, true); + data = pv.getBigUint64(WASM_EPOLL_EVENT_DATA_OFFSET, true); } - kernelView.setUint32(CH_SYSCALL, SYS_EPOLL_CTL, true); - kernelView.setBigInt64(CH_ARGS, BigInt(epfd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(op), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(fd), true); - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(eventPtr !== 0 ? dataStart : 0), true); - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(0), true); - kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(0), true); - - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; + let result: { retVal: number; errVal: number }; try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } finally { - this.currentHandlePid = 0; + result = this.requireMainScratchRegion().withLease((lease) => { + const kernelView = lease.dataView(0, CH_TOTAL_SIZE); + if (hasEvent) { + lease.copyFrom( + new Uint8Array(channel.memory.buffer), + CH_DATA, + eventPtr, + STRUCT_SIZE_WASM_EPOLL_EVENT, + ); + lease.writeAddress( + CH_ARGS + 3 * CH_ARG_SIZE, + CH_DATA, + STRUCT_SIZE_WASM_EPOLL_EVENT, + "u64-le", + ); + } else { + kernelView.setBigInt64( + CH_ARGS + 3 * CH_ARG_SIZE, + 0n, + true, + ); + } + kernelView.setUint32(CH_SYSCALL, SYS_EPOLL_CTL, true); + kernelView.setBigInt64(CH_ARGS, BigInt(epfd), true); + kernelView.setBigInt64(CH_ARGS + CH_ARG_SIZE, BigInt(op), true); + kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(fd), true); + kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, 0n, true); + kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, 0n, true); + + this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; + try { + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + ]); + } finally { + this.currentHandlePid = 0; + } + const resultView = lease.dataView(0, CH_TOTAL_SIZE); + return { + retVal: Number(resultView.getBigInt64(CH_RETURN, true)), + errVal: resultView.getUint32(CH_ERRNO, true), + }; + }); + } catch (error) { + this.rejectScratchTransfer(channel, error); + return; } if (this.finishSignalTermination(channel)) return; - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); + const { retVal, errVal } = result; // Mirror the change on the host side if the kernel succeeded if (retVal === 0) { @@ -8298,10 +10260,12 @@ export class CentralizedKernelWorker { channel: ChannelInfo, syscallNr: number, origArgs: number[], + rawArgs?: readonly bigint[], ): void { if (this.deferChannelWhileStopped(channel)) return; const epfd = origArgs[0]; - const eventsPtr = origArgs[1]; // output pointer in process memory + const rawEventsPtr = rawArgs?.[1] ?? origArgs[1]; + let eventsPtr = 0; const maxevents = origArgs[2]; const timeoutMs = origArgs[3]; const deadline = this.getReadinessDeadline(channel, timeoutMs); @@ -8312,6 +10276,22 @@ export class CentralizedKernelWorker { this.relistenChannel(channel); return; } + if (!Number.isSafeInteger(maxevents)) { + this.completeChannelRaw(channel, -1, EINVAL); + this.relistenChannel(channel); + return; + } + try { + eventsPtr = this.checkedProcessRange( + channel, + rawEventsPtr, + maxevents * STRUCT_SIZE_WASM_EPOLL_EVENT, + "epoll output events", + ).pointer; + } catch (error) { + this.rejectScratchTransfer(channel, error); + return; + } const key = `${channel.pid}:${epfd}`; const interests = this.epollInterests.get(key); @@ -8368,10 +10348,9 @@ export class CentralizedKernelWorker { const POLLERR = 0x008; const POLLHUP = 0x010; - // Build pollfds in kernel scratch data area - // struct pollfd = { fd: i32, events: i16, revents: i16 } = 8 bytes + // Build fixed pollfd records in kernel scratch data. const nfds = interests.length; - const pollfdSize = nfds * 8; + const pollfdSize = nfds * STRUCT_SIZE_WASM_POLL_FD; if (pollfdSize > CH_DATA_SIZE) { // Too many fds — unlikely but handle gracefully @@ -8380,44 +10359,75 @@ export class CentralizedKernelWorker { return; } - const kernelMem = this.getKernelMem(); - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const dataStart = this.scratchOffset + CH_DATA; - - // Write pollfds to kernel scratch - for (let i = 0; i < nfds; i++) { - const interest = interests[i]; - const off = dataStart + i * 8; - let pollEvents = 0; - if (interest.events & EPOLLIN) pollEvents |= POLLIN; - if (interest.events & EPOLLOUT) pollEvents |= POLLOUT; - new DataView(this.kernelMemory!.buffer).setInt32(off, interest.fd, true); - new DataView(this.kernelMemory!.buffer).setInt16(off + 4, pollEvents, true); - new DataView(this.kernelMemory!.buffer).setInt16(off + 6, 0, true); // revents=0 - } - - // Call kernel with SYS_POLL: (fds_ptr, nfds, timeout_ms=0) - // Always use timeout=0 — we manage blocking/retry on the host side - kernelView.setUint32(CH_SYSCALL, SYS_POLL, true); - kernelView.setBigInt64(CH_ARGS, BigInt(dataStart), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(nfds), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(0), true); // timeout=0 for non-blocking poll - for (let i = 3; i < CH_ARGS_COUNT; i++) { - kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); - } - - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; + let pollResult: { + retVal: number; + errVal: number; + pollfds: Uint8Array; + }; try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } finally { - this.currentHandlePid = 0; + pollResult = this.requireMainScratchRegion().withLease((lease) => { + const kernelView = lease.dataView(0, CH_TOTAL_SIZE); + const pollfdsView = lease.dataView(CH_DATA, pollfdSize); + for (let i = 0; i < nfds; i++) { + const interest = interests[i]!; + const off = i * STRUCT_SIZE_WASM_POLL_FD; + let pollEvents = 0; + if (interest.events & EPOLLIN) pollEvents |= POLLIN; + if (interest.events & EPOLLOUT) pollEvents |= POLLOUT; + pollfdsView.setInt32( + off + WASM_POLL_FD_FD_OFFSET, + interest.fd, + true, + ); + pollfdsView.setInt16( + off + WASM_POLL_FD_EVENTS_OFFSET, + pollEvents, + true, + ); + pollfdsView.setInt16( + off + WASM_POLL_FD_REVENTS_OFFSET, + 0, + true, + ); + } + + kernelView.setUint32(CH_SYSCALL, SYS_POLL, true); + lease.writeAddress( + CH_ARGS, + CH_DATA, + pollfdSize, + "u64-le", + ); + kernelView.setBigInt64(CH_ARGS + CH_ARG_SIZE, BigInt(nfds), true); + kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, 0n, true); + for (let i = 3; i < CH_ARGS_COUNT; i++) { + kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); + } + + this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; + try { + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + ]); + } finally { + this.currentHandlePid = 0; + } + const resultView = lease.dataView(0, CH_TOTAL_SIZE); + return { + retVal: Number(resultView.getBigInt64(CH_RETURN, true)), + errVal: resultView.getUint32(CH_ERRNO, true), + pollfds: lease.copyOut(CH_DATA, pollfdSize), + }; + }); + } catch (error) { + this.rejectScratchTransfer(channel, error); + return; } - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); + const { retVal, errVal, pollfds } = pollResult; // This host-side emulation performs a nonblocking poll and owns the // wait/retry loop, so it must preserve the syscall-boundary signal @@ -8439,9 +10449,17 @@ export class CentralizedKernelWorker { let readyCount = 0; if (retVal > 0) { const processView = new DataView(channel.memory.buffer); + const pollfdsView = new DataView( + pollfds.buffer, + pollfds.byteOffset, + pollfds.byteLength, + ); for (let i = 0; i < nfds && readyCount < maxevents; i++) { - const off = dataStart + i * 8; - const revents = new DataView(this.kernelMemory!.buffer).getInt16(off + 6, true); + const off = i * STRUCT_SIZE_WASM_POLL_FD; + const revents = pollfdsView.getInt16( + off + WASM_POLL_FD_REVENTS_OFFSET, + true, + ); if (revents !== 0) { // Map poll revents back to epoll events let epEvents = 0; @@ -8450,10 +10468,23 @@ export class CentralizedKernelWorker { if (revents & POLLERR) epEvents |= EPOLLERR; if (revents & POLLHUP) epEvents |= EPOLLHUP; - // Write epoll_event to process memory: { events: u32, data: u64 } = 12 bytes - const evOff = eventsPtr + readyCount * 12; - processView.setUint32(evOff, epEvents, true); - processView.setBigUint64(evOff + 4, interests[i].data, true); + const evOff = + eventsPtr + readyCount * STRUCT_SIZE_WASM_EPOLL_EVENT; + processView.setUint32( + evOff + WASM_EPOLL_EVENT_EVENTS_OFFSET, + epEvents, + true, + ); + processView.setUint32( + evOff + WASM_EPOLL_EVENT_PAD_OFFSET, + 0, + true, + ); + processView.setBigUint64( + evOff + WASM_EPOLL_EVENT_DATA_OFFSET, + interests[i].data, + true, + ); readyCount++; } } @@ -8515,18 +10546,21 @@ export class CentralizedKernelWorker { this.relistenChannel(channel); } - private guestRangeIsValid( + private checkedNetworkIoctlProcessRange( channel: ChannelInfo, - ptr: number, - length: number, - ): boolean { - return Number.isSafeInteger(ptr) && - Number.isSafeInteger(length) && - ptr >= 0 && - length >= 0 && - ptr <= channel.memory.buffer.byteLength - length; - } - + pointer: number | bigint, + length: number | bigint, + field: string, + ): { pointer: number; length: number; end: number } | null { + try { + return this.checkedProcessRange(channel, pointer, length, field); + } catch (error) { + if (!(error instanceof KernelScratchError)) throw error; + this.finishNetworkIoctl(channel, -EFAULT, EFAULT); + return null; + } + } + private interfaceAddress( iface: (typeof VIRTUAL_INTERFACES)[number], ): Uint8Array | null { @@ -8544,10 +10578,7 @@ export class CentralizedKernelWorker { return this.getPtrWidth(channel.pid) === 8 ? 40 : 32; } - private readIfreqName(channel: ChannelInfo, ifreqPtr: number): string | null { - if (!this.guestRangeIsValid(channel, ifreqPtr, this.ifreqSize(channel))) { - return null; - } + private readIfreqName(channel: ChannelInfo, ifreqPtr: number): string { const bytes = new Uint8Array(channel.memory.buffer, ifreqPtr, IF_NAMESIZE); let end = 0; while (end < bytes.length && bytes[end] !== 0) end++; @@ -8572,12 +10603,15 @@ export class CentralizedKernelWorker { */ private handleIoctlIfconf(channel: ChannelInfo, origArgs: number[]): void { const pw = this.getPtrWidth(channel.pid); - const ifconfPtr = origArgs[2]; const ifconfSize = pw === 8 ? 16 : 8; - if (!this.guestRangeIsValid(channel, ifconfPtr, ifconfSize)) { - this.finishNetworkIoctl(channel, -EFAULT, EFAULT); - return; - } + const ifconfRange = this.checkedNetworkIoctlProcessRange( + channel, + origArgs[2], + ifconfSize, + "network ioctl ifconf", + ); + if (!ifconfRange) return; + const ifconfPtr = ifconfRange.pointer; const processView = new DataView(channel.memory.buffer); const processMem = new Uint8Array(channel.memory.buffer); @@ -8587,14 +10621,13 @@ export class CentralizedKernelWorker { this.finishNetworkIoctl(channel, -EINVAL, EINVAL); return; } - let ifcBuf: number; - if (pw === 8) { - ifcBuf = Number(processView.getBigUint64(ifconfPtr + 8, true)); - } else { - ifcBuf = processView.getUint32(ifconfPtr + 4, true); - } + const ifcBufValue = pw === 8 + ? processView.getBigUint64(ifconfPtr + 8, true) + : processView.getUint32(ifconfPtr + 4, true); - if (ifcBuf === 0) { + // Linux permits a null nested buffer as a size query. The outer ifconf is + // still a required caller-owned structure and was proved above. + if (ifcBufValue === 0 || ifcBufValue === 0n) { processView.setInt32( ifconfPtr, VIRTUAL_INTERFACES.length * ifreqSize, @@ -8613,10 +10646,17 @@ export class CentralizedKernelWorker { const capacity = Math.floor(ifcLen / ifreqSize); const count = Math.min(capacity, VIRTUAL_INTERFACES.length); const bytesToWrite = count * ifreqSize; - if (!this.guestRangeIsValid(channel, ifcBuf, bytesToWrite)) { - this.finishNetworkIoctl(channel, -EFAULT, EFAULT); - return; - } + // WHY: the nested wasm64 pointer must remain bigint until the complete + // caller-owned output range is proved. Converting first could round an + // unsafe value or let a high address alias unrelated low process bytes. + const ifcBufRange = this.checkedNetworkIoctlProcessRange( + channel, + ifcBufValue, + bytesToWrite, + "network ioctl ifconf output", + ); + if (!ifcBufRange) return; + const ifcBuf = ifcBufRange.pointer; for (let i = 0; i < count; i++) { const iface = VIRTUAL_INTERFACES[i]; @@ -8636,11 +10676,14 @@ export class CentralizedKernelWorker { * struct ifreq at arg[2]: ifr_name[16] + union; ifr_ifindex lives at +16. */ private handleIoctlIfname(channel: ChannelInfo, origArgs: number[]): void { - const ifreqPtr = origArgs[2]; - if (!this.guestRangeIsValid(channel, ifreqPtr, this.ifreqSize(channel))) { - this.finishNetworkIoctl(channel, -EFAULT, EFAULT); - return; - } + const ifreqRange = this.checkedNetworkIoctlProcessRange( + channel, + origArgs[2], + this.ifreqSize(channel), + "network ioctl ifreq", + ); + if (!ifreqRange) return; + const ifreqPtr = ifreqRange.pointer; const processView = new DataView(channel.memory.buffer); const processMem = new Uint8Array(channel.memory.buffer); const ifindex = processView.getInt32(ifreqPtr + 16, true); @@ -8661,12 +10704,15 @@ export class CentralizedKernelWorker { * Returns the virtual MAC in ifr_hwaddr.sa_data[0..5]. */ private handleIoctlIfhwaddr(channel: ChannelInfo, origArgs: number[]): void { - const ifreqPtr = origArgs[2]; + const ifreqRange = this.checkedNetworkIoctlProcessRange( + channel, + origArgs[2], + this.ifreqSize(channel), + "network ioctl ifreq", + ); + if (!ifreqRange) return; + const ifreqPtr = ifreqRange.pointer; const name = this.readIfreqName(channel, ifreqPtr); - if (name === null) { - this.finishNetworkIoctl(channel, -EFAULT, EFAULT); - return; - } const iface = VIRTUAL_INTERFACES.find((candidate) => candidate.name === name); if (!iface) { this.finishNetworkIoctl(channel, -ENODEV, ENODEV); @@ -8698,12 +10744,15 @@ export class CentralizedKernelWorker { * Returns the selected virtual interface's assigned IPv4 address. */ private handleIoctlIfaddr(channel: ChannelInfo, origArgs: number[]): void { - const ifreqPtr = origArgs[2]; + const ifreqRange = this.checkedNetworkIoctlProcessRange( + channel, + origArgs[2], + this.ifreqSize(channel), + "network ioctl ifreq", + ); + if (!ifreqRange) return; + const ifreqPtr = ifreqRange.pointer; const name = this.readIfreqName(channel, ifreqPtr); - if (name === null) { - this.finishNetworkIoctl(channel, -EFAULT, EFAULT); - return; - } const iface = VIRTUAL_INTERFACES.find((candidate) => candidate.name === name); if (!iface) { this.finishNetworkIoctl(channel, -ENODEV, ENODEV); @@ -8733,12 +10782,15 @@ export class CentralizedKernelWorker { * struct ifreq at arg[2]: ifr_name[16] + union; ifr_ifindex lives at +16. */ private handleIoctlIfindex(channel: ChannelInfo, origArgs: number[]): void { - const ifreqPtr = origArgs[2]; + const ifreqRange = this.checkedNetworkIoctlProcessRange( + channel, + origArgs[2], + this.ifreqSize(channel), + "network ioctl ifreq", + ); + if (!ifreqRange) return; + const ifreqPtr = ifreqRange.pointer; const name = this.readIfreqName(channel, ifreqPtr); - if (name === null) { - this.finishNetworkIoctl(channel, -EFAULT, EFAULT); - return; - } const iface = VIRTUAL_INTERFACES.find((candidate) => candidate.name === name); if (!iface) { @@ -8763,7 +10815,7 @@ export class CentralizedKernelWorker { private prepareWriteOperationBudget( channel: ChannelInfo, fd: number, - offset: number, + offset: bigint, requestedLen: number, positioned: boolean, ): number | null { @@ -8781,7 +10833,7 @@ export class CentralizedKernelWorker { this.currentHandlePid = channel.pid; try { result = Number( - prepare(channel.pid, tid, fd, BigInt(offset), requestedLen, positioned ? 1 : 0), + prepare(channel.pid, tid, fd, offset, requestedLen, positioned ? 1 : 0), ); } catch (err) { console.error( @@ -8815,97 +10867,243 @@ export class CentralizedKernelWorker { return result; } - private handleWritev(channel: ChannelInfo, syscallNr: number, origArgs: number[]): void { - const fd = origArgs[0]; - const iovPtr = origArgs[1]; - const iovcnt = origArgs[2]; - - const processMem = new Uint8Array(channel.memory.buffer); - const processView = new DataView(channel.memory.buffer); - const kernelMem = this.getKernelMem(); - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const dataStart = this.scratchOffset + CH_DATA; - - // iovec struct: { void* iov_base, size_t iov_len } - // wasm32: 8 bytes per entry (4+4), wasm64: 16 bytes per entry (8+8) - const pw = this.getPtrWidth(channel.pid); - const iovEntrySize = pw === 8 ? 16 : 8; - - if (iovcnt <= 0 || iovcnt > 1024) { + /** + * Marshal getgroups without treating its entry-count return value as bytes. + * + * WHY: Kandelo currently exposes one supplementary gid. A positive-size + * request therefore lends Rust one exact four-byte destination; a size-zero + * count query lends no pointer at all. Passing the caller pointer directly + * or inferring capacity from total kernel memory would lose both facts. + */ + private handleGetgroups( + channel: ChannelInfo, + origArgs: number[], + rawArgs: readonly bigint[], + ): void { + const rawSize = rawArgs[0] ?? 0n; + if (rawSize < 0n || rawSize > 0x7fff_ffffn) { this.completeChannelRaw(channel, -1, EINVAL); this.relistenChannel(channel); return; } + const size = Number(rawSize); + let processPointer = 0; + if (size > 0) { + try { + processPointer = this.checkedProcessRange( + channel, + rawArgs[1] ?? 0n, + 4, + "getgroups output", + ).pointer; + } catch (error) { + this.rejectScratchTransfer(channel, error); + return; + } + } - // Read iov entries from process memory - interface IovEntry { base: number; len: number } - const entries: IovEntry[] = []; - let totalData = 0; - for (let i = 0; i < iovcnt; i++) { - let base: number, len: number; - if (pw === 8) { - base = Number(processView.getBigUint64(iovPtr + i * iovEntrySize, true)); - len = Number(processView.getBigUint64(iovPtr + i * iovEntrySize + 8, true)); + let result: { + retVal: number; + errVal: number; + output: Uint8Array | null; + }; + try { + result = this.requireMainScratchRegion().withLease((lease) => { + const kernelView = lease.dataView(0, CH_TOTAL_SIZE); + for (let index = 0; index < CH_ARGS_COUNT; index++) { + kernelView.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, 0n, true); + } + kernelView.setUint32(CH_SYSCALL, SYS_GETGROUPS, true); + kernelView.setBigInt64(CH_ARGS, BigInt(size), true); + if (size > 0) { + lease.fill(0, CH_DATA, 4); + lease.writeAddress( + CH_ARGS + CH_ARG_SIZE, + CH_DATA, + 4, + "u64-le", + ); + kernelView.setBigInt64( + CH_ARGS + 2 * CH_ARG_SIZE, + 4n, + true, + ); + } + + this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; + try { + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + ]); + } finally { + this.currentHandlePid = 0; + } + + let retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + let errVal = kernelView.getUint32(CH_ERRNO, true); + let output: Uint8Array | null = null; + if ( + !Number.isSafeInteger(retVal) + || (retVal >= 0 && size > 0 && (retVal > size || retVal > 1)) + ) { + retVal = -1; + errVal = EIO; + } else if (retVal > 0 && size > 0) { + output = lease.copyOut(CH_DATA, retVal * 4); + } + return { retVal, errVal, output }; + }); + } catch (error) { + if (error instanceof KernelScratchError) { + this.rejectScratchTransfer(channel, error); } else { - base = processView.getUint32(iovPtr + i * iovEntrySize, true); - len = processView.getUint32(iovPtr + i * iovEntrySize + 4, true); + this.completeChannelRaw(channel, -1, EIO); + this.relistenChannel(channel); } - entries.push({ base, len }); - totalData += len; - } - if (!Number.isSafeInteger(totalData) || totalData > 0x7FFFFFFF) { - this.completeChannelRaw(channel, -1, EINVAL); - this.relistenChannel(channel); return; } - // Max data that fits in scratch: CH_DATA_SIZE minus space for iov entries - const iovSize = iovcnt * 8; - const maxDataPerCall = CH_DATA_SIZE - iovSize; + this.dequeueSignalForDelivery(channel); + if (this.finishSignalTermination(channel)) return; + this.completeChannel( + channel, + SYS_GETGROUPS, + origArgs, + undefined, + result.retVal, + result.errVal, + result.output + ? [{ ptr: processPointer, bytes: result.output }] + : undefined, + ); + } - if (totalData <= maxDataPerCall) { + /** + * Handle writev/pwritev: validate caller iovecs, stage each bounded chunk + * into owned kernel scratch, and dispatch only the staged kernel pointers. + */ + private handleWritev(channel: ChannelInfo, syscallNr: number, origArgs: number[]): void { + const fd = origArgs[0]; + const iovPtr = origArgs[1]; + const iovcnt = origArgs[2]; + const processMem = new Uint8Array(channel.memory.buffer); + let checkedIovecs: CheckedProcessIovecs; + try { + checkedIovecs = this.checkedProcessIovecs( + channel, + iovPtr, + iovcnt, + false, + ); + } catch (error) { + this.rejectScratchTransfer(channel, error); + return; + } + const { entries, totalData } = checkedIovecs; + const scratch = this.requireMainScratchRegion(); + const footprint = this.kernelIovecFootprint(entries); + const isPwritev = + syscallNr === SYS_PWRITEV || syscallNr === SYS_PWRITEV2; + const isPwritev2 = syscallNr === SYS_PWRITEV2; + if (footprint <= CH_DATA_SIZE) { // Fast path: all data fits in one kernel call - let dataOff = iovSize; - - for (let i = 0; i < iovcnt; i++) { - const kernelBase = dataStart + dataOff; - - if (entries[i].len > 0) { - kernelMem.set(processMem.subarray(entries[i].base, entries[i].base + entries[i].len), kernelBase); + const result = scratch.withLease((lease) => { + lease.assertRange(CH_DATA, footprint); + const tableBytes = + entries.length * STRUCT_SIZE_KERNEL_IOVEC_WIRE; + const kernelIovecs = lease.dataView( + CH_DATA, + tableBytes, + ); + let dataOffset = tableBytes; + for (let index = 0; index < entries.length; index++) { + const entry = entries[index]; + if (entry.len > 0) { + lease.copyFrom( + processMem, + CH_DATA + dataOffset, + entry.base, + entry.len, + ); + } + lease.writeAddress( + CH_DATA + + index * STRUCT_SIZE_KERNEL_IOVEC_WIRE + + KERNEL_IOVEC_WIRE_BASE_OFFSET, + CH_DATA + dataOffset, + entry.len, + "u32-le", + ); + kernelIovecs.setUint32( + index * STRUCT_SIZE_KERNEL_IOVEC_WIRE + + KERNEL_IOVEC_WIRE_LEN_OFFSET, + entry.len, + true, + ); + dataOffset = this.checkedAlignUp( + dataOffset + entry.len, + KERNEL_IOVEC_WIRE_ALIGN, + "kernel writev layout", + ); } - const iovAddr = dataStart + i * 8; - new DataView(kernelMem.buffer).setUint32(iovAddr, kernelBase, true); - new DataView(kernelMem.buffer).setUint32(iovAddr + 4, entries[i].len, true); - - dataOff += entries[i].len; - dataOff = (dataOff + 3) & ~3; // align - } - - kernelView.setUint32(CH_SYSCALL, syscallNr, true); - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(iovcnt), true); - if (syscallNr === SYS_PWRITEV) { - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(origArgs[3]), true); - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(origArgs[4]), true); - } - - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; - try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } finally { - this.currentHandlePid = 0; - } + const kernelView = lease.dataView(0, CH_DATA); + kernelView.setUint32(CH_SYSCALL, syscallNr, true); + kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); + lease.writeAddress( + CH_ARGS + CH_ARG_SIZE, + CH_DATA, + tableBytes, + "u64-le", + ); + kernelView.setBigInt64( + CH_ARGS + 2 * CH_ARG_SIZE, + BigInt(iovcnt), + true, + ); + if (isPwritev) { + kernelView.setBigInt64( + CH_ARGS + 3 * CH_ARG_SIZE, + BigInt(origArgs[3]), + true, + ); + kernelView.setBigInt64( + CH_ARGS + 4 * CH_ARG_SIZE, + BigInt(origArgs[4]), + true, + ); + } + kernelView.setBigInt64( + CH_ARGS + 5 * CH_ARG_SIZE, + isPwritev2 ? BigInt(origArgs[5]) : 0n, + true, + ); + this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; + try { + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + ]); + } finally { + this.currentHandlePid = 0; + } + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + const errVal = kernelView.getUint32(CH_ERRNO, true); + if (!Number.isSafeInteger(retVal) || retVal > totalData) { + return { retVal: -1, errVal: EIO }; + } + return { retVal, errVal }; + }); this.dequeueSignalForDelivery(channel); if (this.finishSignalTermination(channel)) return; - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); + const { retVal, errVal } = result; if (retVal === -1 && errVal === EAGAIN) { this.handleBlockingRetry(channel, syscallNr, origArgs); @@ -8919,12 +11117,9 @@ export class CentralizedKernelWorker { } else { // Slow path: total data exceeds scratch buffer. Issue individual SYS_WRITEV // calls with one iov entry each, chunked to fit in CH_DATA_SIZE. - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - const isPwritev = syscallNr === SYS_PWRITEV; let fileOffset = isPwritev - ? (origArgs[3] >>> 0) + (origArgs[4] | 0) * 0x100000000 - : 0; + ? joinPositionedVectorOffset(origArgs[3], origArgs[4]) + : 0n; const operationLen = this.prepareWriteOperationBudget( channel, fd, @@ -8936,7 +11131,7 @@ export class CentralizedKernelWorker { let totalWritten = 0; let gotEagain = false; let firstError: { retVal: number; errVal: number } | null = null; - const maxChunk = CH_DATA_SIZE - 8; // space for 1 iov entry (8B) + data + const maxChunk = CH_DATA_SIZE - STRUCT_SIZE_KERNEL_IOVEC_WIRE; for (const entry of entries) { if (totalWritten >= operationLen) break; @@ -8949,44 +11144,83 @@ export class CentralizedKernelWorker { maxChunk, operationLen - totalWritten, ); - const kernelBuf = dataStart + 8; // single iov entry at dataStart, data after - - // Copy data from process to kernel scratch - kernelMem.set( - processMem.subarray(entry.base + entryWritten, entry.base + entryWritten + chunkLen), - kernelBuf, - ); - - // Set up single iov entry - new DataView(kernelMem.buffer).setUint32(dataStart, kernelBuf, true); - new DataView(kernelMem.buffer).setUint32(dataStart + 4, chunkLen, true); - - if (isPwritev) { - kernelView.setUint32(CH_SYSCALL, SYS_PWRITEV, true); - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(1), true); - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(fileOffset & 0xFFFFFFFF), true); - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(Math.floor(fileOffset / 0x100000000)), true); - } else { - kernelView.setUint32(CH_SYSCALL, SYS_WRITEV, true); + const result = scratch.withLease((lease) => { + lease.copyFrom( + processMem, + CH_DATA + STRUCT_SIZE_KERNEL_IOVEC_WIRE, + entry.base + entryWritten, + chunkLen, + ); + const kernelIovec = lease.dataView( + CH_DATA, + STRUCT_SIZE_KERNEL_IOVEC_WIRE, + ); + lease.writeAddress( + CH_DATA + KERNEL_IOVEC_WIRE_BASE_OFFSET, + CH_DATA + STRUCT_SIZE_KERNEL_IOVEC_WIRE, + chunkLen, + "u32-le", + ); + kernelIovec.setUint32( + KERNEL_IOVEC_WIRE_LEN_OFFSET, + chunkLen, + true, + ); + const kernelView = lease.dataView(0, CH_DATA); + kernelView.setUint32( + CH_SYSCALL, + isPwritev ? syscallNr : SYS_WRITEV, + true, + ); kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(1), true); - } - - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; - try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } finally { - this.currentHandlePid = 0; - } + lease.writeAddress( + CH_ARGS + CH_ARG_SIZE, + CH_DATA, + STRUCT_SIZE_KERNEL_IOVEC_WIRE, + "u64-le", + ); + kernelView.setBigInt64( + CH_ARGS + 2 * CH_ARG_SIZE, + 1n, + true, + ); + if (isPwritev) { + kernelView.setBigInt64( + CH_ARGS + 3 * CH_ARG_SIZE, + BigInt.asUintN(32, fileOffset), + true, + ); + kernelView.setBigInt64( + CH_ARGS + 4 * CH_ARG_SIZE, + BigInt.asIntN(32, fileOffset >> 32n), + true, + ); + } + kernelView.setBigInt64( + CH_ARGS + 5 * CH_ARG_SIZE, + isPwritev2 ? BigInt(origArgs[5]) : 0n, + true, + ); + this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; + try { + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + ]); + } finally { + this.currentHandlePid = 0; + } + return { + retVal: Number(kernelView.getBigInt64(CH_RETURN, true)), + errVal: kernelView.getUint32(CH_ERRNO, true), + }; + }); if (this.finishSignalTermination(channel)) return; - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); + const { retVal, errVal } = result; if (retVal === -1) { if (errVal === EAGAIN && totalWritten === 0) { @@ -8996,10 +11230,14 @@ export class CentralizedKernelWorker { } break; } + if (!Number.isSafeInteger(retVal) || retVal > chunkLen) { + firstError = { retVal: -1, errVal: EIO }; + break; + } entryWritten += retVal; totalWritten += retVal; - if (isPwritev) fileOffset += retVal; + if (isPwritev) fileOffset += BigInt(retVal); if (retVal < chunkLen) break; // short write (e.g. pipe full) } @@ -9036,7 +11274,12 @@ export class CentralizedKernelWorker { * Handle large write/pwrite where the data exceeds CH_DATA_SIZE. * Loops through CH_DATA_SIZE chunks, issuing individual kernel calls. */ - private handleLargeWrite(channel: ChannelInfo, syscallNr: number, origArgs: number[]): void { + private handleLargeWrite( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + rawArgs: readonly bigint[], + ): void { const fd = origArgs[0]; const bufPtr = origArgs[1]; const totalLen = origArgs[2]; @@ -9049,9 +11292,21 @@ export class CentralizedKernelWorker { this.relistenChannel(channel); return; } + try { + this.checkedProcessRange( + channel, + bufPtr, + totalLen, + "large write source", + ); + } catch (error) { + this.rejectScratchTransfer(channel, error); + return; + } const isPwrite = syscallNr === SYS_PWRITE; // pwrite offset is a single i64 arg (arg index 3) - let fileOffset = isPwrite ? origArgs[3] : 0; + const initialFileOffset = isPwrite ? rawArgs[3]! : 0n; + let fileOffset = initialFileOffset; const operationLen = this.prepareWriteOperationBudget( channel, fd, @@ -9062,41 +11317,65 @@ export class CentralizedKernelWorker { if (operationLen === null) return; const processMem = new Uint8Array(channel.memory.buffer); - const kernelMem = this.getKernelMem(); - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const dataStart = this.scratchOffset + CH_DATA; - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - + const scratch = this.requireMainScratchRegion(); let totalWritten = 0; while (totalWritten < operationLen) { const chunkLen = Math.min(operationLen - totalWritten, CH_DATA_SIZE); - // Copy chunk from process memory to kernel scratch - kernelMem.set( - processMem.subarray(bufPtr + totalWritten, bufPtr + totalWritten + chunkLen), - dataStart, - ); - - // Set up syscall in kernel scratch - kernelView.setUint32(CH_SYSCALL, syscallNr, true); - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(chunkLen), true); - if (isPwrite) { - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(fileOffset), true); - } - - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; + let result: { retVal: number; errVal: number }; try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + result = scratch.withLease((lease) => { + lease.copyFrom( + processMem, + CH_DATA, + bufPtr + totalWritten, + chunkLen, + ); + const kernelView = lease.dataView(0, CH_DATA); + kernelView.setUint32(CH_SYSCALL, syscallNr, true); + kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); + lease.writeAddress( + CH_ARGS + CH_ARG_SIZE, + CH_DATA, + chunkLen, + "u64-le", + ); + kernelView.setBigInt64( + CH_ARGS + 2 * CH_ARG_SIZE, + BigInt(chunkLen), + true, + ); + if (isPwrite) { + kernelView.setBigInt64( + CH_ARGS + 3 * CH_ARG_SIZE, + fileOffset, + true, + ); + } + this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; + try { + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + ]); + } finally { + this.currentHandlePid = 0; + } + const resultView = lease.dataView(0, CH_DATA); + return { + retVal: Number(resultView.getBigInt64(CH_RETURN, true)), + errVal: resultView.getUint32(CH_ERRNO, true), + }; + }); } catch (err) { console.error(`[handleLargeWrite] kernel threw for pid=${channel.pid}:`, err); if (totalWritten > 0) { this.handleSharedMappingsAfterFileSyscall( channel, syscallNr, origArgs, totalWritten, 0, + isPwrite ? initialFileOffset : undefined, ); this.synchronizeSharedMemoryForBoundary(channel); this.completeChannelRaw(channel, totalWritten, 0); @@ -9105,14 +11384,11 @@ export class CentralizedKernelWorker { } this.relistenChannel(channel); return; - } finally { - this.currentHandlePid = 0; } if (this.finishSignalTermination(channel)) return; - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); + const { retVal, errVal } = result; if (retVal === -1 && errVal === EAGAIN) { if (totalWritten > 0) { @@ -9120,6 +11396,7 @@ export class CentralizedKernelWorker { if (this.finishSignalTermination(channel)) return; this.handleSharedMappingsAfterFileSyscall( channel, syscallNr, origArgs, totalWritten, 0, + isPwrite ? initialFileOffset : undefined, ); this.synchronizeSharedMemoryForBoundary(channel); this.completeChannelRaw(channel, totalWritten, 0); @@ -9138,6 +11415,7 @@ export class CentralizedKernelWorker { if (totalWritten > 0) { this.handleSharedMappingsAfterFileSyscall( channel, syscallNr, origArgs, totalWritten, 0, + isPwrite ? initialFileOffset : undefined, ); this.synchronizeSharedMemoryForBoundary(channel); this.completeChannelRaw(channel, totalWritten, 0); @@ -9147,9 +11425,14 @@ export class CentralizedKernelWorker { this.relistenChannel(channel); return; } + if (!Number.isSafeInteger(retVal) || retVal > chunkLen) { + this.completeChannelRaw(channel, -1, EIO); + this.relistenChannel(channel); + return; + } totalWritten += retVal; - if (isPwrite) fileOffset += retVal; + if (isPwrite) fileOffset += BigInt(retVal); // Short write from kernel — return what we have if (retVal < chunkLen) break; @@ -9159,6 +11442,7 @@ export class CentralizedKernelWorker { if (this.finishSignalTermination(channel)) return; this.handleSharedMappingsAfterFileSyscall( channel, syscallNr, origArgs, totalWritten, 0, + isPwrite ? initialFileOffset : undefined, ); this.synchronizeSharedMemoryForBoundary(channel); this.completeChannelRaw(channel, totalWritten, 0); @@ -9169,59 +11453,109 @@ export class CentralizedKernelWorker { * Handle large read/pread where the buffer exceeds CH_DATA_SIZE. * Loops through CH_DATA_SIZE chunks, copying data back to process memory. */ - private handleLargeRead(channel: ChannelInfo, syscallNr: number, origArgs: number[]): void { + private handleLargeRead( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + rawArgs: readonly bigint[], + ): void { const fd = origArgs[0]; const bufPtr = origArgs[1]; const totalLen = origArgs[2]; + if ( + !Number.isSafeInteger(totalLen) || + totalLen < 0 || + totalLen > 0x7fff_ffff + ) { + this.completeChannelRaw(channel, -1, EINVAL); + this.relistenChannel(channel); + return; + } + try { + this.checkedProcessRange( + channel, + bufPtr, + totalLen, + "large read destination", + ); + } catch (error) { + this.rejectScratchTransfer(channel, error); + return; + } const isPread = syscallNr === SYS_PREAD; - let fileOffset = isPread ? origArgs[3] : 0; + let fileOffset = isPread ? rawArgs[3]! : 0n; const processMem = new Uint8Array(channel.memory.buffer); - const kernelMem = this.getKernelMem(); - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const dataStart = this.scratchOffset + CH_DATA; - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - + const scratch = this.requireMainScratchRegion(); let totalRead = 0; while (totalRead < totalLen) { const chunkLen = Math.min(totalLen - totalRead, CH_DATA_SIZE); - // Zero the scratch data area for the read output - kernelMem.fill(0, dataStart, dataStart + chunkLen); - - // Set up syscall in kernel scratch - kernelView.setUint32(CH_SYSCALL, syscallNr, true); - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(chunkLen), true); - if (isPread) { - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(fileOffset), true); - } - - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; + let result: { retVal: number; errVal: number }; try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } catch (err) { - console.error(`[handleLargeRead] kernel threw for pid=${channel.pid}:`, err); - if (totalRead > 0) { - this.synchronizeSharedMemoryForBoundary(channel); + result = scratch.withLease((lease) => { + lease.fill(0, CH_DATA, chunkLen); + const kernelView = lease.dataView(0, CH_DATA); + kernelView.setUint32(CH_SYSCALL, syscallNr, true); + kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); + lease.writeAddress( + CH_ARGS + CH_ARG_SIZE, + CH_DATA, + chunkLen, + "u64-le", + ); + kernelView.setBigInt64( + CH_ARGS + 2 * CH_ARG_SIZE, + BigInt(chunkLen), + true, + ); + if (isPread) { + kernelView.setBigInt64( + CH_ARGS + 3 * CH_ARG_SIZE, + fileOffset, + true, + ); + } + this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; + try { + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + ]); + } finally { + this.currentHandlePid = 0; + } + const resultView = lease.dataView(0, CH_DATA); + const retVal = Number(resultView.getBigInt64(CH_RETURN, true)); + const errVal = resultView.getUint32(CH_ERRNO, true); + if (retVal > 0 && retVal <= chunkLen) { + lease.copyTo( + processMem, + CH_DATA, + bufPtr + totalRead, + retVal, + ); + } + return { retVal, errVal }; + }); + } catch (err) { + console.error(`[handleLargeRead] kernel threw for pid=${channel.pid}:`, err); + if (totalRead > 0) { + this.synchronizeSharedMemoryForBoundary(channel); this.completeChannelRaw(channel, totalRead, 0); } else { this.completeChannelRaw(channel, -5, 5); // -EIO } this.relistenChannel(channel); return; - } finally { - this.currentHandlePid = 0; } if (this.finishSignalTermination(channel)) return; - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); + const { retVal, errVal } = result; if (retVal === -1 && errVal === EAGAIN) { if (totalRead > 0) { @@ -9244,15 +11578,14 @@ export class CentralizedKernelWorker { this.relistenChannel(channel); return; } - - // Copy read data from kernel scratch to process memory - processMem.set( - kernelMem.subarray(dataStart, dataStart + retVal), - bufPtr + totalRead, - ); + if (!Number.isSafeInteger(retVal) || retVal > chunkLen) { + this.completeChannelRaw(channel, -1, EIO); + this.relistenChannel(channel); + return; + } totalRead += retVal; - if (isPread) fileOffset += retVal; + if (isPread) fileOffset += BigInt(retVal); // Short read (EOF or partial) — return what we have if (retVal < chunkLen) break; @@ -9275,109 +11608,150 @@ export class CentralizedKernelWorker { const iovcnt = origArgs[2]; const processMem = new Uint8Array(channel.memory.buffer); - const processView = new DataView(channel.memory.buffer); - const kernelMem = this.getKernelMem(); - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const dataStart = this.scratchOffset + CH_DATA; - - // iovec struct: wasm32 = 8B per entry, wasm64 = 16B per entry - const pw = this.getPtrWidth(channel.pid); - const iovEntrySize = pw === 8 ? 16 : 8; - - // Read iov entries from process memory - interface IovEntry { base: number; len: number } - const entries: IovEntry[] = []; - let totalData = 0; - for (let i = 0; i < iovcnt; i++) { - let base: number, len: number; - if (pw === 8) { - base = Number(processView.getBigUint64(iovPtr + i * iovEntrySize, true)); - len = Number(processView.getBigUint64(iovPtr + i * iovEntrySize + 8, true)); - } else { - base = processView.getUint32(iovPtr + i * iovEntrySize, true); - len = processView.getUint32(iovPtr + i * iovEntrySize + 4, true); - } - entries.push({ base, len }); - totalData += len; + let checkedIovecs: CheckedProcessIovecs; + try { + checkedIovecs = this.checkedProcessIovecs( + channel, + iovPtr, + iovcnt, + false, + ); + } catch (error) { + this.rejectScratchTransfer(channel, error); + return; } - - // Max data that fits in scratch: CH_DATA_SIZE minus space for one iov entry (8 bytes) - const maxDataPerCall = CH_DATA_SIZE - 8; - - if (totalData <= maxDataPerCall && iovcnt <= Math.floor(CH_DATA_SIZE / 8)) { + const { entries } = checkedIovecs; + const footprint = this.kernelIovecFootprint(entries); + const scratch = this.requireMainScratchRegion(); + const maxDataPerCall = + CH_DATA_SIZE - STRUCT_SIZE_KERNEL_IOVEC_WIRE; + const isPreadv = + syscallNr === SYS_PREADV || syscallNr === SYS_PREADV2; + const isPreadv2 = syscallNr === SYS_PREADV2; + if (footprint <= CH_DATA_SIZE) { // Fast path: everything fits in one kernel call - const iovSize = iovcnt * 8; - let dataOff = iovSize; - const kernelEntries: { base: number; kernelBase: number; len: number }[] = []; - - for (let i = 0; i < iovcnt; i++) { - const kernelBase = dataStart + dataOff; - kernelEntries.push({ base: entries[i].base, kernelBase, len: entries[i].len }); - - if (entries[i].len > 0) { - kernelMem.fill(0, kernelBase, kernelBase + entries[i].len); + const iovSize = iovcnt * STRUCT_SIZE_KERNEL_IOVEC_WIRE; + const result = scratch.withLease((lease) => { + lease.assertRange(CH_DATA, footprint); + const kernelIovecs = lease.dataView(CH_DATA, iovSize); + let dataOffset = iovSize; + const kernelEntries: Array<{ + base: number; + scratchOffset: number; + len: number; + }> = []; + for (let index = 0; index < entries.length; index++) { + const entry = entries[index]; + const scratchOffset = CH_DATA + dataOffset; + kernelEntries.push({ + base: entry.base, + scratchOffset, + len: entry.len, + }); + if (entry.len > 0) lease.fill(0, scratchOffset, entry.len); + lease.writeAddress( + CH_DATA + + index * STRUCT_SIZE_KERNEL_IOVEC_WIRE + + KERNEL_IOVEC_WIRE_BASE_OFFSET, + scratchOffset, + entry.len, + "u32-le", + ); + kernelIovecs.setUint32( + index * STRUCT_SIZE_KERNEL_IOVEC_WIRE + + KERNEL_IOVEC_WIRE_LEN_OFFSET, + entry.len, + true, + ); + dataOffset = this.checkedAlignUp( + dataOffset + entry.len, + KERNEL_IOVEC_WIRE_ALIGN, + "kernel readv layout", + ); } - - const iovAddr = dataStart + i * 8; - new DataView(kernelMem.buffer).setUint32(iovAddr, kernelBase, true); - new DataView(kernelMem.buffer).setUint32(iovAddr + 4, entries[i].len, true); - - dataOff += entries[i].len; - dataOff = (dataOff + 3) & ~3; - } - - kernelView.setUint32(CH_SYSCALL, syscallNr, true); - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(iovcnt), true); - if (syscallNr === SYS_PREADV) { - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(origArgs[3]), true); - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(origArgs[4]), true); - } - - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; - try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } finally { - this.currentHandlePid = 0; - } + const kernelView = lease.dataView(0, CH_DATA); + kernelView.setUint32(CH_SYSCALL, syscallNr, true); + kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); + lease.writeAddress( + CH_ARGS + CH_ARG_SIZE, + CH_DATA, + iovSize, + "u64-le", + ); + kernelView.setBigInt64( + CH_ARGS + 2 * CH_ARG_SIZE, + BigInt(iovcnt), + true, + ); + if (isPreadv) { + kernelView.setBigInt64( + CH_ARGS + 3 * CH_ARG_SIZE, + BigInt(origArgs[3]), + true, + ); + kernelView.setBigInt64( + CH_ARGS + 4 * CH_ARG_SIZE, + BigInt(origArgs[4]), + true, + ); + } + kernelView.setBigInt64( + CH_ARGS + 5 * CH_ARG_SIZE, + isPreadv2 ? BigInt(origArgs[5]) : 0n, + true, + ); + this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; + try { + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + ]); + } finally { + this.currentHandlePid = 0; + } + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + const errVal = kernelView.getUint32(CH_ERRNO, true); + if ( + retVal > checkedIovecs.totalData || + !Number.isSafeInteger(retVal) + ) { + return { retVal: -1, errVal: EIO }; + } + if (retVal > 0) { + let remaining = retVal; + for (const entry of kernelEntries) { + if (remaining <= 0) break; + const copyLength = Math.min(entry.len, remaining); + lease.copyTo( + processMem, + entry.scratchOffset, + entry.base, + copyLength, + ); + remaining -= copyLength; + } + } + return { retVal, errVal }; + }); if (this.finishSignalTermination(channel)) return; - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); + const { retVal, errVal } = result; if (retVal === -1 && errVal === EAGAIN) { this.handleBlockingRetry(channel, syscallNr, origArgs); return; } - if (retVal > 0) { - let remaining = retVal; - for (const entry of kernelEntries) { - if (remaining <= 0) break; - const copyLen = Math.min(entry.len, remaining); - processMem.set( - kernelMem.subarray(entry.kernelBase, entry.kernelBase + copyLen), - entry.base, - ); - remaining -= copyLen; - } - } - this.completeChannel(channel, syscallNr, origArgs, undefined, retVal, errVal); } else { // Slow path: total data exceeds scratch buffer. Issue one SYS_READ per iov entry, // chunked to fit in CH_DATA_SIZE. Use pread to maintain file offset for preadv. - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - const isPreadv = syscallNr === SYS_PREADV; let fileOffset = isPreadv - ? (origArgs[3] | 0) + (origArgs[4] | 0) * 0x100000000 - : 0; + ? joinPositionedVectorOffset(origArgs[3], origArgs[4]) + : 0n; let totalRead = 0; let lastErr = 0; let gotEagain = false; @@ -9388,41 +11762,90 @@ export class CentralizedKernelWorker { while (entryRead < entry.len) { const chunkLen = Math.min(entry.len - entryRead, maxDataPerCall); - const kernelBuf = dataStart + 8; // single iov entry at dataStart, data after - - // Set up single iov entry - new DataView(kernelMem.buffer).setUint32(dataStart, kernelBuf, true); - new DataView(kernelMem.buffer).setUint32(dataStart + 4, chunkLen, true); - kernelMem.fill(0, kernelBuf, kernelBuf + chunkLen); - - if (isPreadv) { - // Use preadv with 1 iov - kernelView.setUint32(CH_SYSCALL, SYS_PREADV, true); - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(1), true); - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(fileOffset & 0xFFFFFFFF), true); - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(Math.floor(fileOffset / 0x100000000)), true); - } else { - // Use readv with 1 iov - kernelView.setUint32(CH_SYSCALL, SYS_READV, true); + const result = scratch.withLease((lease) => { + const kernelBufferOffset = + CH_DATA + STRUCT_SIZE_KERNEL_IOVEC_WIRE; + const kernelIovec = lease.dataView( + CH_DATA, + STRUCT_SIZE_KERNEL_IOVEC_WIRE, + ); + lease.writeAddress( + CH_DATA + KERNEL_IOVEC_WIRE_BASE_OFFSET, + kernelBufferOffset, + chunkLen, + "u32-le", + ); + kernelIovec.setUint32( + KERNEL_IOVEC_WIRE_LEN_OFFSET, + chunkLen, + true, + ); + lease.fill(0, kernelBufferOffset, chunkLen); + const kernelView = lease.dataView(0, CH_DATA); + kernelView.setUint32( + CH_SYSCALL, + isPreadv ? syscallNr : SYS_READV, + true, + ); kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(1), true); - } - - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; - try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } finally { - this.currentHandlePid = 0; - } + lease.writeAddress( + CH_ARGS + CH_ARG_SIZE, + CH_DATA, + STRUCT_SIZE_KERNEL_IOVEC_WIRE, + "u64-le", + ); + kernelView.setBigInt64( + CH_ARGS + 2 * CH_ARG_SIZE, + 1n, + true, + ); + if (isPreadv) { + kernelView.setBigInt64( + CH_ARGS + 3 * CH_ARG_SIZE, + BigInt.asUintN(32, fileOffset), + true, + ); + kernelView.setBigInt64( + CH_ARGS + 4 * CH_ARG_SIZE, + BigInt.asIntN(32, fileOffset >> 32n), + true, + ); + } + kernelView.setBigInt64( + CH_ARGS + 5 * CH_ARG_SIZE, + isPreadv2 ? BigInt(origArgs[5]) : 0n, + true, + ); + this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; + try { + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + ]); + } finally { + this.currentHandlePid = 0; + } + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + const errVal = kernelView.getUint32(CH_ERRNO, true); + if (!Number.isSafeInteger(retVal) || retVal > chunkLen) { + return { retVal: -1, errVal: EIO }; + } + if (retVal > 0) { + lease.copyTo( + processMem, + kernelBufferOffset, + entry.base + entryRead, + retVal, + ); + } + return { retVal, errVal }; + }); if (this.finishSignalTermination(channel)) return; - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); + const { retVal, errVal } = result; if (retVal === -1) { if (errVal === EAGAIN && totalRead === 0) { @@ -9435,15 +11858,9 @@ export class CentralizedKernelWorker { if (retVal === 0) break; // EOF - // Copy data to process memory - processMem.set( - kernelMem.subarray(kernelBuf, kernelBuf + retVal), - entry.base + entryRead, - ); - entryRead += retVal; totalRead += retVal; - if (isPreadv) fileOffset += retVal; + if (isPreadv) fileOffset += BigInt(retVal); if (retVal < chunkLen) break; // short read } @@ -9470,120 +11887,193 @@ export class CentralizedKernelWorker { const fd = origArgs[0]; const msgPtr = origArgs[1]; const flags = origArgs[2]; - const processMem = new Uint8Array(channel.memory.buffer); - const processView = new DataView(channel.memory.buffer); - const kernelMem = this.getKernelMem(); - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const dataStart = this.scratchOffset + CH_DATA; - const pw = this.getPtrWidth(channel.pid); + let message: CheckedProcessMessage; + let layout: KernelMessageLayout; + let kernelControl: Uint8Array; + try { + message = this.checkedProcessMessage(channel, msgPtr); + kernelControl = this.nativeControlToKernelWire(processMem, message); + layout = this.kernelMessageLayout(message, kernelControl.length); + if (layout.footprint > CH_DATA_SIZE) { + throw new KernelScratchError( + "sendmsg payload exceeds bounded kernel transport", + 90, + ); + } + } catch (error) { + this.rejectScratchTransfer(channel, error); + return; + } + const scratch = this.requireMainScratchRegion(); + let result: { retVal: number; errVal: number }; + try { + result = scratch.withLease((lease) => { + lease.assertRange(CH_DATA, layout.footprint); + const kernelMessage = lease.dataView( + CH_DATA, + STRUCT_SIZE_KERNEL_MSGHDR_WIRE, + ); - // Parse msghdr from process memory (ptrWidth-aware). - // wasm32 layout (28B): name(4), namelen(4), iov(4), iovlen(4), control(4), controllen(4), flags(4) - // wasm64 layout (48B): name(8), namelen(4), pad(4), iov(8), iovlen(4), pad(4), control(8), controllen(4), flags(4) - let namePtr: number, nameLen: number, iovPtr: number, iovCnt: number; - let controlPtr: number, controlLen: number; - if (pw === 8) { - namePtr = Number(processView.getBigUint64(msgPtr, true)); - nameLen = processView.getUint32(msgPtr + 8, true); - iovPtr = Number(processView.getBigUint64(msgPtr + 16, true)); - iovCnt = processView.getUint32(msgPtr + 24, true); - controlPtr = Number(processView.getBigUint64(msgPtr + 32, true)); - controlLen = processView.getUint32(msgPtr + 40, true); - } else { - namePtr = processView.getUint32(msgPtr, true); - nameLen = processView.getUint32(msgPtr + 4, true); - iovPtr = processView.getUint32(msgPtr + 8, true); - iovCnt = processView.getUint32(msgPtr + 12, true); - controlPtr = processView.getUint32(msgPtr + 16, true); - controlLen = processView.getUint32(msgPtr + 20, true); - } - - // Build kernel-side msghdr in wasm32 (28B) format (kernel uses explicit u32 parsing) - const kMsgPtr = dataStart; - const kv = new DataView(kernelMem.buffer); - kv.setUint32(kMsgPtr, namePtr, true); - kv.setUint32(kMsgPtr + 4, nameLen, true); - kv.setUint32(kMsgPtr + 8, iovPtr, true); // will be updated below - kv.setUint32(kMsgPtr + 12, iovCnt, true); - kv.setUint32(kMsgPtr + 16, controlPtr, true); // will be updated below - kv.setUint32(kMsgPtr + 20, controlLen, true); - kv.setUint32(kMsgPtr + 24, 0, true); // msg_flags - - let dataOff = 28; // after kernel-format msghdr - - // Copy msg_name to kernel scratch - if (namePtr !== 0 && nameLen > 0 && dataOff + nameLen <= CH_DATA_SIZE) { - const kNamePtr = dataStart + dataOff; - kernelMem.set(processMem.subarray(namePtr, namePtr + nameLen), kNamePtr); - kv.setUint32(kMsgPtr, kNamePtr, true); // update msg_name ptr - dataOff += nameLen; - dataOff = (dataOff + 3) & ~3; - } - - // Copy msg_control (ancillary data, e.g. SCM_RIGHTS) to kernel scratch - if (controlPtr !== 0 && controlLen > 0 && dataOff + controlLen <= CH_DATA_SIZE) { - const kCtrlPtr = dataStart + dataOff; - kernelMem.set(processMem.subarray(controlPtr, controlPtr + controlLen), kCtrlPtr); - kv.setUint32(kMsgPtr + 16, kCtrlPtr, true); // update msg_control ptr - dataOff += controlLen; - dataOff = (dataOff + 3) & ~3; - } - - // Copy iov array and iov data to kernel scratch - const iovEntrySize = pw === 8 ? 16 : 8; - if (iovCnt > 0 && iovPtr !== 0) { - const kIovSize = iovCnt * 8; // kernel-side iov is always 8 bytes per entry (u32 base + u32 len) - const kIovPtr = dataStart + dataOff; - dataOff += kIovSize; - dataOff = (dataOff + 3) & ~3; - - kv.setUint32(kMsgPtr + 8, kIovPtr, true); // update msg_iov ptr - - // Copy each iov buffer data - for (let i = 0; i < iovCnt; i++) { - let base: number, len: number; - if (pw === 8) { - base = Number(processView.getBigUint64(iovPtr + i * iovEntrySize, true)); - len = Number(processView.getBigUint64(iovPtr + i * iovEntrySize + 8, true)); - } else { - base = processView.getUint32(iovPtr + i * 8, true); - len = processView.getUint32(iovPtr + i * 8 + 4, true); + if (message.name.length > 0) { + lease.copyFrom( + processMem, + CH_DATA + layout.nameOffset, + message.name.pointer, + message.name.length, + ); } - // Write kernel-format iov entry (always u32 base + u32 len) - kv.setUint32(kIovPtr + i * 8, 0, true); // will be updated if data copied - kv.setUint32(kIovPtr + i * 8 + 4, len, true); - if (len > 0 && dataOff + len <= CH_DATA_SIZE) { - const kBufPtr = dataStart + dataOff; - kernelMem.set(processMem.subarray(base, base + len), kBufPtr); - kv.setUint32(kIovPtr + i * 8, kBufPtr, true); - dataOff += len; - dataOff = (dataOff + 3) & ~3; + if (kernelControl.length > 0) { + lease.copyFrom( + kernelControl, + CH_DATA + layout.controlOffset, + 0, + kernelControl.length, + ); } - } - } - // Call kernel - kernelView.setUint32(CH_SYSCALL, SYS_SENDMSG, true); - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(kMsgPtr), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(flags), true); + if (layout.iovecCount > 0) { + const kernelIovec = lease.dataView( + CH_DATA + layout.iovecOffset, + layout.iovecBytes, + ); + if (message.iovecs.totalData === 0) { + kernelIovec.setUint32(KERNEL_IOVEC_WIRE_BASE_OFFSET, 0, true); + } else { + lease.writeAddress( + CH_DATA + + layout.iovecOffset + + KERNEL_IOVEC_WIRE_BASE_OFFSET, + CH_DATA + layout.dataOffset, + message.iovecs.totalData, + "u32-le", + ); + } + kernelIovec.setUint32( + KERNEL_IOVEC_WIRE_LEN_OFFSET, + message.iovecs.totalData, + true, + ); + } + let stagedData = 0; + for (const entry of message.iovecs.entries) { + if (entry.len > 0) { + lease.copyFrom( + processMem, + CH_DATA + layout.dataOffset + stagedData, + entry.base, + entry.len, + ); + stagedData += entry.len; + } + } - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; - try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } finally { - this.currentHandlePid = 0; + if (layout.nameOffset === 0) { + kernelMessage.setUint32( + KERNEL_MSGHDR_WIRE_NAME_OFFSET, + 0, + true, + ); + } else { + lease.writeAddress( + CH_DATA + KERNEL_MSGHDR_WIRE_NAME_OFFSET, + CH_DATA + layout.nameOffset, + message.name.length, + "u32-le", + ); + } + kernelMessage.setUint32( + KERNEL_MSGHDR_WIRE_NAMELEN_OFFSET, + message.name.length, + true, + ); + if (layout.iovecOffset === 0) { + kernelMessage.setUint32( + KERNEL_MSGHDR_WIRE_IOV_OFFSET, + 0, + true, + ); + } else { + lease.writeAddress( + CH_DATA + KERNEL_MSGHDR_WIRE_IOV_OFFSET, + CH_DATA + layout.iovecOffset, + layout.iovecBytes, + "u32-le", + ); + } + kernelMessage.setUint32( + KERNEL_MSGHDR_WIRE_IOVLEN_OFFSET, + layout.iovecCount, + true, + ); + if (layout.controlOffset === 0) { + kernelMessage.setUint32( + KERNEL_MSGHDR_WIRE_CONTROL_OFFSET, + 0, + true, + ); + } else { + lease.writeAddress( + CH_DATA + KERNEL_MSGHDR_WIRE_CONTROL_OFFSET, + CH_DATA + layout.controlOffset, + layout.controlCapacity, + "u32-le", + ); + } + kernelMessage.setUint32( + KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET, + kernelControl.length, + true, + ); + kernelMessage.setUint32(KERNEL_MSGHDR_WIRE_FLAGS_OFFSET, 0, true); + + const kernelView = lease.dataView(0, CH_DATA); + kernelView.setUint32(CH_SYSCALL, SYS_SENDMSG, true); + for (let index = 0; index < CH_ARGS_COUNT; index++) { + kernelView.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, 0n, true); + } + kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); + lease.writeAddress( + CH_ARGS + CH_ARG_SIZE, + CH_DATA, + STRUCT_SIZE_KERNEL_MSGHDR_WIRE, + "u32-to-u64-le", + ); + kernelView.setBigInt64( + CH_ARGS + 2 * CH_ARG_SIZE, + BigInt(flags), + true, + ); + this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; + try { + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + ]); + } finally { + this.currentHandlePid = 0; + } + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + const errVal = kernelView.getUint32(CH_ERRNO, true); + if ( + !Number.isSafeInteger(retVal) || + retVal > message.iovecs.totalData + ) { + return { retVal: -1, errVal: EIO }; + } + return { retVal, errVal }; + }); + } catch (error) { + this.rejectScratchTransfer(channel, error); + return; } if (this.finishSignalTermination(channel)) return; - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); + const { retVal, errVal } = result; if (retVal === -1 && errVal === EAGAIN) { this.handleBlockingRetry(channel, SYS_SENDMSG, origArgs); @@ -9603,169 +12093,308 @@ export class CentralizedKernelWorker { const flags = origArgs[2]; const processMem = new Uint8Array(channel.memory.buffer); - const processView = new DataView(channel.memory.buffer); - const kernelMem = this.getKernelMem(); - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const dataStart = this.scratchOffset + CH_DATA; - const pw = this.getPtrWidth(channel.pid); - - // Parse msghdr from process memory (ptrWidth-aware) - let namePtr: number, nameLen: number, iovPtr: number, iovCnt: number; - let controlPtr: number, controlLen: number; - if (pw === 8) { - namePtr = Number(processView.getBigUint64(msgPtr, true)); - nameLen = processView.getUint32(msgPtr + 8, true); - iovPtr = Number(processView.getBigUint64(msgPtr + 16, true)); - iovCnt = processView.getUint32(msgPtr + 24, true); - controlPtr = Number(processView.getBigUint64(msgPtr + 32, true)); - controlLen = processView.getUint32(msgPtr + 40, true); - } else { - namePtr = processView.getUint32(msgPtr, true); - nameLen = processView.getUint32(msgPtr + 4, true); - iovPtr = processView.getUint32(msgPtr + 8, true); - iovCnt = processView.getUint32(msgPtr + 12, true); - controlPtr = processView.getUint32(msgPtr + 16, true); - controlLen = processView.getUint32(msgPtr + 20, true); - } - - // Build kernel-side msghdr in wasm32 (28B) format - const kMsgPtr = dataStart; - const kv = new DataView(kernelMem.buffer); - kv.setUint32(kMsgPtr, namePtr, true); - kv.setUint32(kMsgPtr + 4, nameLen, true); - kv.setUint32(kMsgPtr + 8, iovPtr, true); - kv.setUint32(kMsgPtr + 12, iovCnt, true); - kv.setUint32(kMsgPtr + 16, controlPtr, true); - kv.setUint32(kMsgPtr + 20, controlLen, true); - kv.setUint32(kMsgPtr + 24, 0, true); - - let dataOff = 28; - - // Set up msg_name output buffer - let kNamePtr = 0; - if (namePtr !== 0 && nameLen > 0 && dataOff + nameLen <= CH_DATA_SIZE) { - kNamePtr = dataStart + dataOff; - kernelMem.fill(0, kNamePtr, kNamePtr + nameLen); - kv.setUint32(kMsgPtr, kNamePtr, true); - dataOff += nameLen; - dataOff = (dataOff + 3) & ~3; - } - - // Set up msg_control output buffer for ancillary data (SCM_RIGHTS) - let kCtrlPtr = 0; - if (controlPtr !== 0 && controlLen > 0 && dataOff + controlLen <= CH_DATA_SIZE) { - kCtrlPtr = dataStart + dataOff; - kernelMem.fill(0, kCtrlPtr, kCtrlPtr + controlLen); - kv.setUint32(kMsgPtr + 16, kCtrlPtr, true); - dataOff += controlLen; - dataOff = (dataOff + 3) & ~3; - } - - // Set up iov array and output buffers - interface IovEntry { base: number; len: number; kernelBase: number } - const entries: IovEntry[] = []; - const iovEntrySize = pw === 8 ? 16 : 8; - - if (iovCnt > 0 && iovPtr !== 0) { - const kIovSize = iovCnt * 8; // kernel-side iov always 8B per entry - const kIovPtr = dataStart + dataOff; - dataOff += kIovSize; - dataOff = (dataOff + 3) & ~3; - - kv.setUint32(kMsgPtr + 8, kIovPtr, true); - - for (let i = 0; i < iovCnt; i++) { - let base: number, len: number; - if (pw === 8) { - base = Number(processView.getBigUint64(iovPtr + i * iovEntrySize, true)); - len = Number(processView.getBigUint64(iovPtr + i * iovEntrySize + 8, true)); + let message: CheckedProcessMessage; + let layout: KernelMessageLayout; + try { + message = this.checkedProcessMessage(channel, msgPtr); + layout = this.kernelMessageLayout( + message, + this.kernelControlCapacityForRecv(message), + ); + if (layout.footprint > CH_DATA_SIZE) { + throw new KernelScratchError( + "recvmsg buffers exceed bounded kernel transport", + 90, + ); + } + } catch (error) { + this.rejectScratchTransfer(channel, error); + return; + } + const scratch = this.requireMainScratchRegion(); + let result: { + retVal: number; + errVal: number; + nameLength: number; + controlLength: number; + messageFlags: number; + payload: Uint8Array; + name: Uint8Array; + control: Uint8Array; + }; + try { + result = scratch.withLease((lease) => { + lease.assertRange(CH_DATA, layout.footprint); + const kernelMessage = lease.dataView( + CH_DATA, + STRUCT_SIZE_KERNEL_MSGHDR_WIRE, + ); + if (message.name.length > 0) { + lease.fill(0, CH_DATA + layout.nameOffset, message.name.length); + } + if (layout.controlCapacity > 0) { + lease.fill( + 0, + CH_DATA + layout.controlOffset, + layout.controlCapacity, + ); + } + if (layout.iovecCount > 0) { + const kernelIovec = lease.dataView( + CH_DATA + layout.iovecOffset, + layout.iovecBytes, + ); + if (message.iovecs.totalData === 0) { + kernelIovec.setUint32(KERNEL_IOVEC_WIRE_BASE_OFFSET, 0, true); + } else { + lease.writeAddress( + CH_DATA + + layout.iovecOffset + + KERNEL_IOVEC_WIRE_BASE_OFFSET, + CH_DATA + layout.dataOffset, + message.iovecs.totalData, + "u32-le", + ); + } + kernelIovec.setUint32( + KERNEL_IOVEC_WIRE_LEN_OFFSET, + message.iovecs.totalData, + true, + ); + } + if (message.iovecs.totalData > 0) { + lease.fill( + 0, + CH_DATA + layout.dataOffset, + message.iovecs.totalData, + ); + } + if (layout.nameOffset === 0) { + kernelMessage.setUint32( + KERNEL_MSGHDR_WIRE_NAME_OFFSET, + 0, + true, + ); } else { - base = processView.getUint32(iovPtr + i * 8, true); - len = processView.getUint32(iovPtr + i * 8 + 4, true); - } - if (len > 0 && dataOff + len <= CH_DATA_SIZE) { - const kBufPtr = dataStart + dataOff; - kernelMem.fill(0, kBufPtr, kBufPtr + len); - kv.setUint32(kIovPtr + i * 8, kBufPtr, true); - kv.setUint32(kIovPtr + i * 8 + 4, len, true); - entries.push({ base, len, kernelBase: kBufPtr }); - dataOff += len; - dataOff = (dataOff + 3) & ~3; + lease.writeAddress( + CH_DATA + KERNEL_MSGHDR_WIRE_NAME_OFFSET, + CH_DATA + layout.nameOffset, + message.name.length, + "u32-le", + ); + } + kernelMessage.setUint32( + KERNEL_MSGHDR_WIRE_NAMELEN_OFFSET, + message.name.length, + true, + ); + if (layout.iovecOffset === 0) { + kernelMessage.setUint32( + KERNEL_MSGHDR_WIRE_IOV_OFFSET, + 0, + true, + ); } else { - kv.setUint32(kIovPtr + i * 8, 0, true); - kv.setUint32(kIovPtr + i * 8 + 4, len, true); + lease.writeAddress( + CH_DATA + KERNEL_MSGHDR_WIRE_IOV_OFFSET, + CH_DATA + layout.iovecOffset, + layout.iovecBytes, + "u32-le", + ); } - } - } + kernelMessage.setUint32( + KERNEL_MSGHDR_WIRE_IOVLEN_OFFSET, + layout.iovecCount, + true, + ); + if (layout.controlOffset === 0) { + kernelMessage.setUint32( + KERNEL_MSGHDR_WIRE_CONTROL_OFFSET, + 0, + true, + ); + } else { + lease.writeAddress( + CH_DATA + KERNEL_MSGHDR_WIRE_CONTROL_OFFSET, + CH_DATA + layout.controlOffset, + layout.controlCapacity, + "u32-le", + ); + } + kernelMessage.setUint32( + KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET, + layout.controlCapacity, + true, + ); + kernelMessage.setUint32(KERNEL_MSGHDR_WIRE_FLAGS_OFFSET, 0, true); - // Call kernel - kernelView.setUint32(CH_SYSCALL, SYS_RECVMSG, true); - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(kMsgPtr), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(flags), true); + const kernelView = lease.dataView(0, CH_DATA); + kernelView.setUint32(CH_SYSCALL, SYS_RECVMSG, true); + for (let index = 0; index < CH_ARGS_COUNT; index++) { + kernelView.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, 0n, true); + } + kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); + lease.writeAddress( + CH_ARGS + CH_ARG_SIZE, + CH_DATA, + STRUCT_SIZE_KERNEL_MSGHDR_WIRE, + "u32-to-u64-le", + ); + kernelView.setBigInt64( + CH_ARGS + 2 * CH_ARG_SIZE, + BigInt(flags), + true, + ); + this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; + try { + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + ]); + } finally { + this.currentHandlePid = 0; + } - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; - try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } finally { - this.currentHandlePid = 0; + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + const errVal = kernelView.getUint32(CH_ERRNO, true); + if (retVal < 0) { + return { + retVal, + errVal, + nameLength: 0, + controlLength: 0, + messageFlags: 0, + payload: new Uint8Array(0), + name: new Uint8Array(0), + control: new Uint8Array(0), + }; + } + const nameLength = kernelMessage.getUint32( + KERNEL_MSGHDR_WIRE_NAMELEN_OFFSET, + true, + ); + const kernelControlLength = kernelMessage.getUint32( + KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET, + true, + ); + const messageFlags = kernelMessage.getUint32( + KERNEL_MSGHDR_WIRE_FLAGS_OFFSET, + true, + ); + // WHY: MSG_TRUNC deliberately reports the complete datagram length + // even though only the bounded iovec prefix exists to copy back. + if ( + !Number.isSafeInteger(retVal) || + ( + retVal > message.iovecs.totalData && + (flags & SOCKET_MSG_TRUNC) === 0 + ) || + kernelControlLength > layout.controlCapacity + ) { + throw new KernelScratchError( + "kernel returned data outside recvmsg capacities", + EIO, + ); + } + const payloadLength = Math.min(retVal, message.iovecs.totalData); + const payload = payloadLength > 0 + ? lease.copyOut(CH_DATA + layout.dataOffset, payloadLength) + : new Uint8Array(0); + const name = message.name.length > 0 && nameLength > 0 + ? lease.copyOut( + CH_DATA + layout.nameOffset, + Math.min(message.name.length, nameLength), + ) + : new Uint8Array(0); + const nativeControl = kernelControlLength > 0 + ? this.kernelControlToNative( + lease.copyOut( + CH_DATA + layout.controlOffset, + kernelControlLength, + ), + message, + ) + : { bytes: new Uint8Array(0), length: 0 }; + return { + retVal, + errVal, + nameLength, + controlLength: nativeControl.length, + messageFlags, + payload, + name, + control: nativeControl.bytes, + }; + }); + } catch (error) { + this.rejectScratchTransfer(channel, error); + return; } if (this.finishSignalTermination(channel)) return; - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); + const { + retVal, + errVal, + nameLength, + controlLength, + messageFlags, + } = result; if (retVal === -1 && errVal === EAGAIN) { this.handleBlockingRetry(channel, SYS_RECVMSG, origArgs); return; } - // Copy received data back to process memory - if (retVal > 0) { - let remaining = retVal; - for (const entry of entries) { - if (remaining <= 0) break; - const copyLen = Math.min(entry.len, remaining); - processMem.set( - kernelMem.subarray(entry.kernelBase, entry.kernelBase + copyLen), + if (retVal >= 0) { + const publishMemory = new Uint8Array(channel.memory.buffer); + let payloadOffset = 0; + for (const entry of message.iovecs.entries) { + if (payloadOffset >= result.payload.length) break; + if (entry.len === 0) continue; + const copyLength = Math.min( + entry.len, + result.payload.length - payloadOffset, + ); + publishMemory.set( + result.payload.subarray( + payloadOffset, + payloadOffset + copyLength, + ), entry.base, ); - remaining -= copyLen; + payloadOffset += copyLength; } - } - - // Copy msg_name (source address) back to process memory - if (kNamePtr !== 0 && namePtr !== 0 && nameLen > 0) { - processMem.set(kernelMem.subarray(kNamePtr, kNamePtr + nameLen), namePtr); - } - - // Copy msg_control (ancillary data) back to process memory - if (kCtrlPtr !== 0 && controlPtr !== 0) { - const actualControlLen = kv.getUint32(kMsgPtr + 20, true); - if (actualControlLen > 0 && actualControlLen <= controlLen) { - processMem.set( - kernelMem.subarray(kCtrlPtr, kCtrlPtr + actualControlLen), - controlPtr, - ); + if (result.name.length > 0) { + publishMemory.set(result.name, message.name.pointer); + } + if (result.control.length > 0) { + publishMemory.set(result.control, message.control.pointer); } - } - // Copy updated msghdr fields back to process memory (ptrWidth-aware) - const kNamelenVal = kv.getUint32(kMsgPtr + 4, true); - const kControllenVal = kv.getUint32(kMsgPtr + 20, true); - const kMsgflags = kv.getUint32(kMsgPtr + 24, true); - if (pw === 8) { - processView.setUint32(msgPtr + 8, kNamelenVal, true); // msg_namelen - processView.setUint32(msgPtr + 40, kControllenVal, true); // msg_controllen - processView.setUint32(msgPtr + 44, kMsgflags, true); // msg_flags - } else { - processView.setUint32(msgPtr + 4, kNamelenVal, true); // msg_namelen - processView.setUint32(msgPtr + 20, kControllenVal, true); // msg_controllen - processView.setUint32(msgPtr + 24, kMsgflags, true); // msg_flags + const processLayout = this.processMessageLayout(message.pointerWidth); + const processView = new DataView( + channel.memory.buffer, + message.messagePointer, + processLayout.size, + ); + processView.setUint32( + processLayout.nameLengthOffset, + nameLength, + true, + ); + processView.setUint32( + processLayout.controlLengthOffset, + controlLength, + true, + ); + processView.setUint32( + processLayout.flagsOffset, + messageFlags, + true, + ); } this.completeChannel(channel, SYS_RECVMSG, origArgs, undefined, retVal, errVal); @@ -9997,7 +12626,7 @@ export class CentralizedKernelWorker { this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, EINVAL); return; } - if (pathLen >= EXEC_PATH_MAX_BYTES) { + if (pathLen >= POSIX_PATH_MAX_BYTES) { this.completeChannel( channel, SYS_SPAWN, @@ -10008,32 +12637,46 @@ export class CentralizedKernelWorker { ); return; } - if ( - pathLen > 0 && - !isValidMemoryRange(processMem, pathPtr, pathLen) - ) { - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, EFAULT); - return; - } if (blobLen > SPAWN_BLOB_MAX_BYTES) { this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, E2BIG); return; } - if (!isValidMemoryRange(processMem, blobPtr, blobLen)) { - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, EFAULT); - return; - } - if ( - pidOutPtr !== 0 && - !isValidMemoryRange(processMem, pidOutPtr, 4) - ) { + let checkedPathPtr = pathPtr; + let checkedBlobPtr: number; + let checkedPidOutPtr = pidOutPtr; + try { + if (pathLen > 0) { + checkedPathPtr = this.checkedProcessRange( + channel, + pathPtr, + pathLen, + "spawn path", + ).pointer; + } + checkedBlobPtr = this.checkedProcessRange( + channel, + blobPtr, + blobLen, + "spawn blob", + ).pointer; + if (pidOutPtr !== 0) { + checkedPidOutPtr = this.checkedProcessRange( + channel, + pidOutPtr, + 4, + "spawn pid output", + ).pointer; + } + } catch { this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, EFAULT); return; } let path = ""; if (pathLen > 0) { - path = new TextDecoder().decode(processMem.slice(pathPtr, pathPtr + pathLen)); + path = new TextDecoder().decode( + processMem.slice(checkedPathPtr, checkedPathPtr + pathLen), + ); // Strip trailing NUL if the user copied a C string with the terminator. if (path.endsWith("\0")) path = path.slice(0, -1); } @@ -10043,7 +12686,10 @@ export class CentralizedKernelWorker { } // .slice copies into a regular ArrayBuffer (TextDecoder rejects SAB views). - const blobBytes = processMem.slice(blobPtr, blobPtr + blobLen); + const blobBytes = processMem.slice( + checkedBlobPtr, + checkedBlobPtr + blobLen, + ); // ── Decode argv + envp host-side ── // The kernel parses the blob too, but onSpawn needs string[] for the @@ -10052,11 +12698,24 @@ export class CentralizedKernelWorker { let argv: string[]; let envp: string[]; try { - const decoded = decodeSpawnBlobStrings(blobBytes); + const decoded = decodeSpawnBlobStrings( + blobBytes, + this.getPtrWidth(parentPid), + ); argv = decoded.argv; envp = decoded.envp; - } catch (_e) { - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, 22); // EINVAL + } catch (error) { + const errno = error instanceof KernelScratchError + ? error.errno + : EINVAL; + this.completeChannel( + channel, + SYS_SPAWN, + origArgs, + undefined, + -1, + errno, + ); return; } const metadataResult = this.validateExecMetadata( @@ -10109,7 +12768,15 @@ export class CentralizedKernelWorker { return; } this.handleSpawnAfterResolve( - channel, origArgs, parentPid, callerTid, pidOutPtr, blobBytes, blobLen, resolved, envp, + channel, + origArgs, + parentPid, + callerTid, + checkedPidOutPtr, + blobBytes, + blobLen, + resolved, + envp, ); }).catch((err) => { if (!this.isAsyncChannelProcessActive(channel)) return; @@ -10123,25 +12790,93 @@ export class CentralizedKernelWorker { * validated, compiled program. Now safe to ask the kernel to build the * child (which will apply file_actions exactly once). */ - private scratchOffsetForSpawnBlob(blobLen: number): number { - if (blobLen <= SCRATCH_SIZE) return this.scratchOffset; - if ((this.largeSpawnScratchOffset ?? 0) !== 0) { - // WHY: resolution may be asynchronous, but every completion runs on this - // worker's single event loop and copy + kernel_spawn_process contain no - // await. One shared buffer therefore cannot be observed half-written. - return this.largeSpawnScratchOffset; + private beginLargeSpawnScratch( + blobLen: number, + ): { reservation: ReservedSpawnScratch | null; errno: number } { + const kernelExports = this.kernelInstance!.exports; + const begin = kernelExports.kernel_spawn_scratch_begin as + ((minimumCapacity: KernelPointer) => bigint) | undefined; + const pointer = kernelExports.kernel_spawn_scratch_pointer as + ((token: bigint) => KernelPointer) | undefined; + const capacity = kernelExports.kernel_spawn_scratch_capacity as + ((token: bigint) => KernelPointer) | undefined; + const cancel = kernelExports.kernel_spawn_scratch_cancel as + ((token: bigint) => number) | undefined; + if ( + typeof begin !== "function" + || typeof pointer !== "function" + || typeof capacity !== "function" + || typeof cancel !== "function" + ) { + // ABI 43 makes the transactional reservation contract mandatory. A + // same-version kernel missing it is mismatched and must fail loudly. + return { reservation: null, errno: EIO }; } - // WHY: kernel_alloc_scratch owns its allocation for the kernel lifetime - // and has no matching free operation. Allocate the complete bounded - // transport once, then reuse it, instead of leaking one allocation for - // every large spawn or letting the host grow memory behind Rust's heap. - const allocScratch = this.kernelInstance!.exports.kernel_alloc_scratch as - (size: number) => KernelPointer; - this.largeSpawnScratchOffset = Number( - allocScratch(SPAWN_BLOB_MAX_BYTES), + let token: bigint | null = null; + let beginErrno = EIO; + try { + const rawToken = begin(this.toKernelPtr(blobLen)); + if (typeof rawToken !== "bigint") { + throw new KernelScratchError( + "kernel returned a non-i64 spawn scratch token", + EIO, + ); + } + if (rawToken <= 0n) { + const rawErrno = -rawToken; + beginErrno = rawErrno > 0n && rawErrno <= BigInt(0x7fff_ffff) + ? Number(rawErrno) + : EIO; + return { reservation: null, errno: beginErrno }; + } + token = rawToken; + const region = reserveKernelScratchRegion( + this.kernelMemory!, + () => ({ + pointer: pointer(rawToken), + capacity: capacity(rawToken), + }), + blobLen, + this.kernel.getKernelPtrWidth(), + "kernel reserved spawn scratch", + ); + return { reservation: { region, token }, errno: 0 }; + } catch { + // WHY: once begin returns a token, even an invalid allocator pointer or + // capacity must flow through the caller's unconditional cancellation. + // Returning only an errno here would lose the sole cleanup authority. + return { + reservation: token === null ? null : { region: null, token }, + errno: beginErrno, + }; + } + } + + private cancelLargeSpawnScratch( + token: bigint, + ): "cancelled" | "already-consumed" { + const cancel = this.kernelInstance!.exports.kernel_spawn_scratch_cancel as + ((token: bigint) => number) | undefined; + if (typeof cancel !== "function") { + throw new KernelScratchError( + "kernel spawn scratch cancel export is unavailable", + EIO, + ); + } + const result = cancel(token); + if (!Number.isSafeInteger(result)) { + throw new KernelScratchError( + `kernel rejected spawn scratch cancellation: ${String(result)}`, + EIO, + ); + } + if (result === 0) return "cancelled"; + if (result === -EINVAL) return "already-consumed"; + throw new KernelScratchError( + `kernel rejected spawn scratch cancellation: ${String(result)}`, + EIO, ); - return this.largeSpawnScratchOffset; } private handleSpawnAfterResolve( @@ -10165,36 +12900,170 @@ export class CentralizedKernelWorker { this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, errno); return; } - const spawnScratchOffset = this.scratchOffsetForSpawnBlob(blobLen); - if (blobLen > SCRATCH_SIZE && spawnScratchOffset === 0) { - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, ENOMEM); - return; - } - const kernelMem = new Uint8Array(this.kernelMemory!.buffer); - if ( - !Number.isSafeInteger(spawnScratchOffset) || - spawnScratchOffset < 0 || - spawnScratchOffset > kernelMem.byteLength - blobLen - ) { - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, EIO); - return; + let result = -EIO; + if (blobLen <= SCRATCH_SIZE) { + const kernelSpawn = this.kernelInstance!.exports.kernel_spawn_process as + | (( + parentPid: number, + callerTid: number, + blobPtr: KernelPointer, + blobLen: KernelPointer, + ) => number) + | undefined; + if (typeof kernelSpawn !== "function") { + this.completeChannel( + channel, + SYS_SPAWN, + origArgs, + undefined, + -1, + EIO, + ); + return; + } + try { + result = this.requireMainScratchRegion().withLease((scratch) => { + scratch.copyFrom(blobBytes, 0, 0, blobLen); + return scratch.invokeKernelExport("kernel_spawn_process", [ + parentPid, + callerTid, + scratch.exportPointer(0, blobLen), + this.toKernelPtr(blobLen), + ]); + }); + } catch { + this.completeChannel( + channel, + SYS_SPAWN, + origArgs, + undefined, + -1, + EIO, + ); + return; + } + } else { + if (this.largeSpawnScratchInUse) { + this.completeChannel( + channel, + SYS_SPAWN, + origArgs, + undefined, + -1, + EBUSY, + ); + return; + } + const reservedSpawn = this.kernelInstance!.exports + .kernel_spawn_reserved_process as + | (( + parentPid: number, + callerTid: number, + token: bigint, + blobLen: KernelPointer, + ) => number) + | undefined; + if (typeof reservedSpawn !== "function") { + this.completeChannel( + channel, + SYS_SPAWN, + origArgs, + undefined, + -1, + EIO, + ); + return; + } + + this.largeSpawnScratchInUse = true; + let reservation: ReservedSpawnScratch | null = null; + let operationErrno: number | null = null; + let cleanupFailure: unknown = null; + try { + const begun = this.beginLargeSpawnScratch(blobLen); + reservation = begun.reservation; + if (!reservation?.region) { + operationErrno = begun.errno; + } else { + const activeRegion = reservation.region; + const activeToken = reservation.token; + result = activeRegion.withLease((scratch) => { + scratch.copyFrom(blobBytes, 0, 0, blobLen); + const spawnResult = reservedSpawn( + parentPid, + callerTid, + activeToken, + this.toKernelPtr(blobLen), + ); + if ( + !Number.isInteger(spawnResult) + || spawnResult < -0x8000_0000 + || spawnResult > 0x7fff_ffff + ) { + throw new KernelScratchError( + `kernel returned an invalid reserved spawn result: ${ + String(spawnResult) + }`, + EIO, + ); + } + return spawnResult; + }); + } + } catch { + operationErrno = EIO; + } finally { + if (reservation) { + // WHY: the Rust Vec may move on the next reservation. Revoke the + // one-shot host region whether this token was consumed or cancelled + // so no retained object can later lease the stale pointer. + reservation.region?.revoke(); + try { + // WHY: do not infer reservation state from the spawn errno. Rust + // commit and cancel take a blocking, no-import lock, and tokens are + // never reused. Cancelling after every return therefore either + // releases an unconsumed matching token or harmlessly gets EINVAL + // for a token commit already consumed. + const disposition = this.cancelLargeSpawnScratch(reservation.token); + if (result > 0 && disposition === "cancelled") { + throw new KernelScratchError( + "kernel created a child without consuming its spawn reservation", + EIO, + ); + } + } catch (error) { + cleanupFailure = error; + } + } + // A cleanup protocol failure may have left writable authority live. + // Keep the host guard set so no later operation can replace its bytes. + if (cleanupFailure === null) this.largeSpawnScratchInUse = false; + } + + if (cleanupFailure !== null) { + this.terminateForKernelProtocolFailure( + channel, + `could not settle reserved spawn scratch: ${ + cleanupFailure instanceof Error + ? cleanupFailure.message + : String(cleanupFailure) + }`, + ); + return; + } + if (operationErrno !== null) { + this.completeChannel( + channel, + SYS_SPAWN, + origArgs, + undefined, + -1, + operationErrno, + ); + return; + } } - kernelMem.set(blobBytes, spawnScratchOffset); - // ── Ask the kernel to build the child descriptor ── - const kernelSpawn = this.kernelInstance!.exports.kernel_spawn_process as - ( - parentPid: number, - callerTid: number, - blobPtr: KernelPointer, - blobLen: KernelPointer, - ) => number; - const result = kernelSpawn( - parentPid, - callerTid, - this.toKernelPtr(spawnScratchOffset), - this.toKernelPtr(blobLen), - ); if (result <= 0) { const errno = result < 0 ? (-result) >>> 0 : EIO; this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, errno); @@ -10247,7 +13116,7 @@ export class CentralizedKernelWorker { return; } - // ── Launch the worker async (with the precompiled program) ── + // ── Launch the worker async with the preflighted program bytes ── launch.then((rc) => { if (rc < 0) { rollbackSpawn((-rc) >>> 0); @@ -10267,8 +13136,14 @@ export class CentralizedKernelWorker { /** * Read a null-terminated string from process memory at the given pointer. + * The caller supplies the protocol-specific bound; not every C string is a + * path. */ - private readCStringFromProcess(mem: Uint8Array, ptr: number, maxLen = 4096): string { + private readCStringFromProcess( + mem: Uint8Array, + ptr: number, + maxLen: number, + ): string { if (ptr === 0) return ""; let len = 0; while (ptr + len < mem.length && mem[ptr + len] !== 0 && len < maxLen) { @@ -10293,13 +13168,13 @@ export class CentralizedKernelWorker { } const available = mem.byteLength - ptr; - const scanLength = Math.min(available, EXEC_PATH_MAX_BYTES); + const scanLength = Math.min(available, POSIX_PATH_MAX_BYTES); let byteLength = 0; while (byteLength < scanLength && mem[ptr + byteLength] !== 0) { byteLength++; } if (byteLength === scanLength) { - return { errno: available >= EXEC_PATH_MAX_BYTES ? ENAMETOOLONG : EFAULT }; + return { errno: available >= POSIX_PATH_MAX_BYTES ? ENAMETOOLONG : EFAULT }; } return { @@ -10310,9 +13185,10 @@ export class CentralizedKernelWorker { /** * Read a null-terminated exec argv/envp pointer array without truncation. - * Each entry may occupy one bounded scratch transfer. The advertised - * ARG_MAX budget, including pointer entries, bounds the scan without an - * unrelated argument-count limit. + * Each entry may occupy one process-metadata transfer. That implementation + * ceiling is separate from the advertised aggregate ARG_MAX budget, which + * includes pointer entries and bounds the scan without an unrelated + * argument-count limit. */ private readStringArrayFromProcess( mem: Uint8Array, @@ -10326,7 +13202,7 @@ export class CentralizedKernelWorker { // entry consumes at least ptrWidth + one NUL byte, so this byte budget also // provides a finite loop bound for arrays containing empty strings. let representedBytes = ptrWidth; - for (let i = 0; representedBytes <= EXEC_METADATA_MAX_BYTES; i++) { + for (let i = 0; representedBytes <= POSIX_ARG_MAX_BYTES; i++) { const pointerOffset = arrayPtr + i * ptrWidth; if (!Number.isSafeInteger(pointerOffset) || pointerOffset < 0 || pointerOffset + ptrWidth > view.byteLength) { @@ -10343,19 +13219,28 @@ export class CentralizedKernelWorker { if (strPtr === 0) return { values }; if (strPtr < 0 || strPtr >= mem.byteLength) return { errno: EFAULT }; - const scanLength = Math.min(mem.byteLength - strPtr, CH_DATA_SIZE + 1); + const scanLength = Math.min( + mem.byteLength - strPtr, + PROCESS_METADATA_ENTRY_MAX_BYTES + 1, + ); let byteLength = 0; while (byteLength < scanLength && mem[strPtr + byteLength] !== 0) { byteLength++; } if (byteLength === scanLength) { - return { errno: scanLength > CH_DATA_SIZE ? E2BIG : EFAULT }; + return { + errno: scanLength > PROCESS_METADATA_ENTRY_MAX_BYTES + ? E2BIG + : EFAULT, + }; + } + if (byteLength > PROCESS_METADATA_ENTRY_MAX_BYTES) { + return { errno: E2BIG }; } - if (byteLength > CH_DATA_SIZE) return { errno: E2BIG }; representedBytes += ptrWidth + byteLength + 1; if (!Number.isSafeInteger(representedBytes) - || representedBytes > EXEC_METADATA_MAX_BYTES) { + || representedBytes > POSIX_ARG_MAX_BYTES) { return { errno: E2BIG }; } @@ -10444,10 +13329,31 @@ export class CentralizedKernelWorker { const getCwd = this.kernelInstance!.exports.kernel_get_cwd as ((pid: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; if (!getCwd) return path; - const cwdLen = getCwd(pid, this.toKernelPtr(this.scratchOffset), 4096); - if (cwdLen <= 0) return path; - const kernelBuf = new Uint8Array(this.kernelMemory!.buffer); - const cwd = new TextDecoder().decode(kernelBuf.slice(this.scratchOffset, this.scratchOffset + cwdLen)); + let output: { result: number; bytes: Uint8Array }; + try { + output = this.requireMainScratchRegion().withLease((lease) => { + const result = lease.invokeKernelExport("kernel_get_cwd", [ + pid, + lease.exportPointer(0, POSIX_PATH_MAX_BYTES), + POSIX_PATH_MAX_BYTES, + ]); + const byteLength = this.checkedScratchProducerByteLength( + result, + POSIX_PATH_MAX_BYTES, + "kernel_get_cwd", + ); + return { + result, + bytes: byteLength === 0 + ? new Uint8Array(0) + : lease.copyOut(0, byteLength), + }; + }); + } catch { + return path; + } + if (output.result <= 0) return path; + const cwd = new TextDecoder().decode(output.bytes); const joined = cwd.endsWith("/") ? cwd + path : cwd + "/" + path; // Normalize . and .. components (e.g. /data/spawn/./prog → /data/spawn/prog) const parts = joined.split("/"); @@ -10503,14 +13409,39 @@ export class CentralizedKernelWorker { this.completeChannel(channel, SYS_EXECVEAT, origArgs, undefined, -1, 38); // ENOSYS return; } - const result = getFdPath(channel.pid, dirfd, this.toKernelPtr(this.scratchOffset), 4096); - if (result <= 0) { - const errno = result < 0 ? (-result) >>> 0 : 2; // ENOENT + let output: { result: number; bytes: Uint8Array }; + try { + output = this.requireMainScratchRegion().withLease((lease) => { + const result = lease.invokeKernelExport("kernel_get_fd_path", [ + channel.pid, + dirfd, + lease.exportPointer(0, POSIX_PATH_MAX_BYTES), + POSIX_PATH_MAX_BYTES, + ]); + const byteLength = this.checkedScratchProducerByteLength( + result, + POSIX_PATH_MAX_BYTES, + "kernel_get_fd_path", + ); + return { + result, + bytes: byteLength === 0 + ? new Uint8Array(0) + : lease.copyOut(0, byteLength), + }; + }); + } catch (error) { + this.rejectScratchTransfer(channel, error); + return; + } + if (output.result <= 0) { + const errno = output.result < 0 + ? (-output.result) >>> 0 + : 2; // ENOENT this.completeChannel(channel, SYS_EXECVEAT, origArgs, undefined, -1, errno); return; } - const kernelBuf = new Uint8Array(this.kernelMemory!.buffer); - execPath = new TextDecoder().decode(kernelBuf.slice(this.scratchOffset, this.scratchOffset + result)); + execPath = new TextDecoder().decode(output.bytes); } else if (pathStr.startsWith("/")) { execPath = pathStr; } else { @@ -10519,14 +13450,34 @@ export class CentralizedKernelWorker { // The kernel's sys_execveat already resolves this, but since we intercept // host-side, we need to do it ourselves. const getCwd = this.kernelInstance!.exports.kernel_get_cwd as - ((pid: number, bufPtr: number, bufLen: number) => number) | undefined; + ((pid: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; if (getCwd) { - const cwdLen = getCwd(channel.pid, this.scratchOffset, 4096); - if (cwdLen > 0) { - const kernelBuf = new Uint8Array(this.kernelMemory!.buffer); - const cwd = new TextDecoder().decode( - kernelBuf.slice(this.scratchOffset, this.scratchOffset + cwdLen), - ); + let output: { result: number; bytes: Uint8Array }; + try { + output = this.requireMainScratchRegion().withLease((lease) => { + const result = lease.invokeKernelExport("kernel_get_cwd", [ + channel.pid, + lease.exportPointer(0, POSIX_PATH_MAX_BYTES), + POSIX_PATH_MAX_BYTES, + ]); + const byteLength = this.checkedScratchProducerByteLength( + result, + POSIX_PATH_MAX_BYTES, + "kernel_get_cwd", + ); + return { + result, + bytes: byteLength === 0 + ? new Uint8Array(0) + : lease.copyOut(0, byteLength), + }; + }); + } catch (error) { + this.rejectScratchTransfer(channel, error); + return; + } + if (output.result > 0) { + const cwd = new TextDecoder().decode(output.bytes); execPath = cwd.endsWith("/") ? cwd + pathStr : cwd + "/" + pathStr; } else { execPath = pathStr; @@ -10576,8 +13527,6 @@ export class CentralizedKernelWorker { return; } - const CLONE_PARENT_SETTID = 0x00100000; - const CLONE_CHILD_CLEARTID = 0x00200000; const flags = origArgs[0] >>> 0; const ptidPtr = origArgs[2]; const rawCtidPtr = origArgs[4]; @@ -10596,24 +13545,35 @@ export class CentralizedKernelWorker { // Route through kernel_handle_channel — the kernel allocates a TID and // stores ThreadInfo. The dispatch table remaps args correctly. - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - kernelView.setUint32(CH_SYSCALL, SYS_CLONE, true); - for (let i = 0; i < CH_ARGS_COUNT; i++) { - kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, BigInt(origArgs[i]), true); - } - - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; - try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } finally { - this.currentHandlePid = 0; - } - - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); + const { retVal, errVal } = this.requireMainScratchRegion().withLease( + (lease) => { + const kernelView = lease.dataView(0, CH_TOTAL_SIZE); + kernelView.setUint32(CH_SYSCALL, SYS_CLONE, true); + for (let i = 0; i < CH_ARGS_COUNT; i++) { + kernelView.setBigInt64( + CH_ARGS + i * CH_ARG_SIZE, + BigInt(origArgs[i]), + true, + ); + } + this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; + try { + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + ]); + } finally { + this.currentHandlePid = 0; + } + const resultView = lease.dataView(0, CH_TOTAL_SIZE); + return { + retVal: Number(resultView.getBigInt64(CH_RETURN, true)), + errVal: resultView.getUint32(CH_ERRNO, true), + }; + }, + ); if (retVal <= 0) { const errno = retVal < 0 ? errVal : EIO; @@ -10733,7 +13693,19 @@ export class CentralizedKernelWorker { this.completeChannel(channel, SYS_CLONE, origArgs, undefined, -1, 12); return; } - this.completeChannel(channel, SYS_CLONE, origArgs, undefined, tid, 0); + this.completeChannel( + channel, + SYS_CLONE, + origArgs, + undefined, + tid, + 0, + [], + { + tid, + parentTidPointer: parentTidWritten ? ptidPtr : undefined, + }, + ); }).catch((err) => { try { // The callback can reject after performing part of its own transport @@ -10811,15 +13783,19 @@ export class CentralizedKernelWorker { // every short-lived child. The disposable guest Wasm still traps after // this channel handshake, which preserves _exit's non-returning contract. { - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - kernelView.setUint32(CH_SYSCALL, syscallNr, true); - kernelView.setBigInt64(CH_ARGS, BigInt(exitStatus), true); - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; this.bindKernelTidForChannel(channel); this.currentHandlePid = channel.pid; try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + this.requireMainScratchRegion().withLease((lease) => { + const kernelView = lease.dataView(0, CH_TOTAL_SIZE); + kernelView.setUint32(CH_SYSCALL, syscallNr, true); + kernelView.setBigInt64(CH_ARGS, BigInt(exitStatus), true); + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + ]); + }); } catch { // ABI 42 kernels published before the reusable-stack fix deliberately // trap after committing exit. Keep accepting that paired-kernel shape @@ -11202,25 +14178,29 @@ export class CentralizedKernelWorker { eventMask: number, flags: number, resultPtr: KernelPointer, + resultCapacity: number, ) => number; - const result = waitPoll( - channel.pid, - this.guestTidForChannel(channel), - targetPid, - eventMask, - flags, - this.toKernelPtr(this.scratchOffset), - ); - if (result > 0) { - const source = new Uint8Array( - this.kernelMemory!.buffer, - this.scratchOffset, + const output = this.requireMainScratchRegion().withLease((lease) => { + const result = lease.invokeKernelExport("kernel_wait_child_poll", [ + channel.pid, + this.guestTidForChannel(channel), + targetPid, + eventMask, + flags, + lease.exportPointer(0, STRUCT_SIZE_KERNEL_WAIT_RESULT), STRUCT_SIZE_KERNEL_WAIT_RESULT, - ); - const owned = new Uint8Array(STRUCT_SIZE_KERNEL_WAIT_RESULT); - owned.set(source); - const view = new DataView(owned.buffer); - const rusage = owned.subarray( + ]); + return { + result, + bytes: result > 0 + ? lease.copyOut(0, STRUCT_SIZE_KERNEL_WAIT_RESULT) + : new Uint8Array(0), + }; + }); + const { result } = output; + if (result > 0) { + const view = new DataView(output.bytes.buffer); + const rusage = output.bytes.subarray( KERNEL_WAIT_RESULT_RUSAGE_OFFSET, KERNEL_WAIT_RESULT_RUSAGE_OFFSET + STRUCT_SIZE_WASM_RUSAGE_WIRE, ); @@ -11498,6 +14478,7 @@ export class CentralizedKernelWorker { waiter.origArgs[2], waiter.origArgs[4], poll, + processSiginfoLayout(this.getPtrWidth(waiter.channel.pid)), ); this.completeWaitid(waiter.channel, waiter.origArgs, 0, 0); } else { @@ -11574,6 +14555,7 @@ export class CentralizedKernelWorker { const rusagePtr = origArgs[4]; const parentPid = channel.pid; const waitPid = this.waitidToWaitPid(idtype, id); + const siginfoLayout = processSiginfoLayout(this.getPtrWidth(channel.pid)); if (this.pendingCancels.delete(channel)) { this.completeChannelRaw(channel, -EINTR_ERRNO, EINTR_ERRNO); @@ -11598,7 +14580,11 @@ export class CentralizedKernelWorker { return; } if ( - !this.isRequiredGuestOutputRangeValid(channel, siginfoPtr, 128) || + !this.isRequiredGuestOutputRangeValid( + channel, + siginfoPtr, + siginfoLayout.size, + ) || !this.isOptionalGuestOutputRangeValid( channel, rusagePtr, @@ -11620,13 +14606,23 @@ export class CentralizedKernelWorker { return; } if (poll.kind === "event") { - this.writeWaitidResult(channel, siginfoPtr, rusagePtr, poll); + this.writeWaitidResult( + channel, + siginfoPtr, + rusagePtr, + poll, + siginfoLayout, + ); this.completeWaitid(channel, origArgs, 0, 0); return; } if (options & WAIT_WNOHANG) { - new Uint8Array(channel.memory.buffer, siginfoPtr, 128).fill(0); + new Uint8Array( + channel.memory.buffer, + siginfoPtr, + siginfoLayout.size, + ).fill(0); this.completeWaitid(channel, origArgs, 0, 0); return; } @@ -11659,26 +14655,32 @@ export class CentralizedKernelWorker { /** * Write siginfo_t fields for waitid into process memory. - * Layout (wasm32): si_signo(+0), si_errno(+4), si_code(+8), - * si_pid(+12), si_uid(+16), si_status(+20) + * The generated layout describes both caller widths. `si_status` shares the + * siginfo union slot represented by the generated value offset. */ private writeWaitidResult( channel: ChannelInfo, siginfoPtr: number, rusagePtr: number, result: Extract, + layout: ProcessSiginfoLayout, ): void { const processMem = new Uint8Array(channel.memory.buffer); const procView = new DataView(channel.memory.buffer); - processMem.fill(0, siginfoPtr, siginfoPtr + 128); - procView.setInt32(siginfoPtr + 0, SIGCHLD, true); // si_signo - procView.setInt32(siginfoPtr + 8, result.siCode, true); // si_code - // musl aligns siginfo_t's union to `long`: +12 for wasm32, +16 for - // wasm64. The pid/uid pair is followed by the status union member. - const fieldsOffset = this.getPtrWidth(channel.pid) === 8 ? 16 : 12; - procView.setInt32(siginfoPtr + fieldsOffset, result.childPid, true); // si_pid - procView.setUint32(siginfoPtr + fieldsOffset + 4, result.childUid, true); // si_uid - procView.setInt32(siginfoPtr + fieldsOffset + 8, result.siStatus, true); // si_status + processMem.fill(0, siginfoPtr, siginfoPtr + layout.size); + procView.setInt32( + siginfoPtr + PROCESS_SIGINFO_SIGNO_OFFSET, + SIGCHLD, + true, + ); + procView.setInt32( + siginfoPtr + PROCESS_SIGINFO_CODE_OFFSET, + result.siCode, + true, + ); + procView.setInt32(siginfoPtr + layout.pidOffset, result.childPid, true); + procView.setUint32(siginfoPtr + layout.uidOffset, result.childUid, true); + procView.setInt32(siginfoPtr + layout.statusOffset, result.siStatus, true); if (rusagePtr !== 0) processMem.set(result.rusage, rusagePtr); } @@ -11695,25 +14697,62 @@ export class CentralizedKernelWorker { * * FUTEX_WAKE: wake up to `val` waiters on addr. Returns number woken. */ - private handleFutex(channel: ChannelInfo, origArgs: number[]): void { - const addr = origArgs[0]; // uaddr (byte offset in process memory) - const op = origArgs[1]; // futex op (may include PRIVATE flag) + private handleFutex( + channel: ChannelInfo, + origArgs: number[], + rawArgs?: readonly bigint[], + ): void { + const rawOp = rawArgs?.[1] ?? BigInt(origArgs[1]); + const op = Number(BigInt.asUintN(32, rawOp)); const val = origArgs[2]; // value (expected for WAIT, count for WAKE) - - const FUTEX_PRIVATE_FLAG = 128; - const FUTEX_CLOCK_REALTIME = 256; const baseOp = op & ~(FUTEX_PRIVATE_FLAG | FUTEX_CLOCK_REALTIME); - const FUTEX_WAIT = 0; - const FUTEX_WAKE = 1; - const FUTEX_REQUEUE = 3; - const FUTEX_CMP_REQUEUE = 4; - const FUTEX_WAKE_OP = 5; - const FUTEX_WAIT_BITSET = 9; - const FUTEX_WAKE_BITSET = 10; + let addr: number; + let timeoutPtr = 0; + let uaddr2 = 0; + try { + addr = this.checkedProcessRange( + channel, + rawArgs?.[0] ?? origArgs[0], + 4, + "futex uaddr", + ).pointer; + if ((addr & 3) !== 0) { + throw new KernelScratchError("futex uaddr is not aligned", EINVAL); + } + if (baseOp === FUTEX_WAIT || baseOp === FUTEX_WAIT_BITSET) { + const rawTimeout = rawArgs?.[3] ?? origArgs[3]; + if (rawTimeout !== 0n && rawTimeout !== 0) { + timeoutPtr = this.checkedProcessRange( + channel, + rawTimeout, + 16, + "futex timeout", + ).pointer; + } + } + if ( + baseOp === FUTEX_REQUEUE + || baseOp === FUTEX_CMP_REQUEUE + || baseOp === FUTEX_WAKE_OP + ) { + uaddr2 = this.checkedProcessRange( + channel, + rawArgs?.[4] ?? origArgs[4], + 4, + "futex uaddr2", + ).pointer; + if ((uaddr2 & 3) !== 0) { + throw new KernelScratchError("futex uaddr2 is not aligned", EINVAL); + } + } + } catch (error) { + this.rejectScratchTransfer(channel, error); + return; + } const i32View = new Int32Array(channel.memory.buffer); - const index = addr >>> 2; + const index = addr / 4; if (baseOp === FUTEX_WAIT || baseOp === FUTEX_WAIT_BITSET) { // Pre-empt cancel: if SYS_THREAD_CANCEL arrived before we got here @@ -11740,7 +14779,6 @@ export class CentralizedKernelWorker { // Read timeout from origArgs[3] (pointer to struct timespec in process memory). // Layout: { int64 tv_sec; int64 tv_nsec } — 16 bytes, relative timeout. let timeoutMs: number | undefined; - const timeoutPtr = origArgs[3]; if (timeoutPtr !== 0) { const dataView = new DataView(channel.memory.buffer); const tv_sec = Number(dataView.getBigInt64(timeoutPtr, true)); @@ -11844,8 +14882,7 @@ export class CentralizedKernelWorker { // Wake val waiters on uaddr, then conditionally wake val2 on uaddr2. // Simplified: just wake both. const val2 = origArgs[3]; - const uaddr2 = origArgs[4]; - const index2 = uaddr2 >>> 2; + const index2 = uaddr2 / 4; let woken = Atomics.notify(i32View, index, val); woken += Atomics.notify(i32View, index2, val2); this.completeChannelRaw(channel, woken, 0); @@ -11943,7 +14980,7 @@ export class CentralizedKernelWorker { const procView = new DataView(memory.buffer); procView.setInt32(ctidPtr, 0, true); const i32View = new Int32Array(memory.buffer); - Atomics.notify(i32View, ctidPtr >>> 2, 1); + Atomics.notify(i32View, ctidPtr / 4, 1); } } finally { this.threadCtidPtrs.delete(ctidKey); @@ -11984,8 +15021,25 @@ export class CentralizedKernelWorker { ) { return false; } - const maskPtr = entry.origArgs[0] >>> 0; - if (maskPtr === 0 || signum <= 0 || signum > 64) return false; + if ( + entry.origArgs[0] === 0 + || signum <= 0 + || signum > 64 + ) return false; + let maskPtr: number; + try { + // The wait crosses a timer/callback boundary. Re-prove the process + // range instead of narrowing the address retained by the first + // dispatch or treating that earlier proof as a lifetime guarantee. + maskPtr = this.checkedProcessRange( + entry.channel, + entry.origArgs[0], + 8, + "pending signal-wait mask", + ).pointer; + } catch { + return false; + } const mask = new DataView( entry.channel.memory.buffer, ).getBigUint64(maskPtr, true); @@ -12034,23 +15088,6 @@ export class CentralizedKernelWorker { // that expires in that handoff window from being lost. if (queueSignal) { - const kernelView = new DataView( - this.kernelMemory.buffer, - this.scratchOffset, - ); - // Write SYS_KILL into scratch: kill(targetPid, signum) - kernelView.setUint32(CH_SYSCALL, SYS_KILL, true); - kernelView.setBigInt64(CH_ARGS, BigInt(targetPid), true); // arg0 = pid - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(signum), true); // arg1 = sig - for (let i = 2; i < CH_ARGS_COUNT; i++) { - kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); - } - - const handleChannel = this.kernelInstance.exports - .kernel_handle_channel as ( - offset: KernelPointer, - pid: number, - ) => number; // Host-originated process signals are shared deliveries. Bind the exact // kernel-owned leader rather than relying on an implicit main-thread // sentinel or state left over from a prior dispatch. @@ -12061,7 +15098,25 @@ export class CentralizedKernelWorker { } this.currentHandlePid = targetPid; try { - handleChannel(this.toKernelPtr(this.scratchOffset), targetPid); + this.requireMainScratchRegion().withLease((lease) => { + const kernelView = lease.dataView(0, CH_TOTAL_SIZE); + // Write SYS_KILL into scratch: kill(targetPid, signum) + kernelView.setUint32(CH_SYSCALL, SYS_KILL, true); + kernelView.setBigInt64(CH_ARGS, BigInt(targetPid), true); + kernelView.setBigInt64( + CH_ARGS + CH_ARG_SIZE, + BigInt(signum), + true, + ); + for (let i = 2; i < CH_ARGS_COUNT; i++) { + kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); + } + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + targetPid, + ]); + }); } catch (err) { // Non-fatal — signal delivery is best-effort from the host side console.error( @@ -12198,8 +15253,14 @@ export class CentralizedKernelWorker { channel: ChannelInfo, origArgs: number[], ): boolean { - const addr = origArgs[0] >>> 0; - const len = origArgs[1] >>> 0; + const addr = origArgs[0]; + const len = origArgs[1]; + if ( + !Number.isSafeInteger(addr) + || addr < 0 + || !Number.isSafeInteger(len) + || len < 0 + ) return false; const end = addr + len; if (!Number.isSafeInteger(end) || end < addr) return false; const before = channel.memory.buffer.byteLength; @@ -12230,6 +15291,25 @@ export class CentralizedKernelWorker { retVal: number, origArgs: number[], ): void { + if (!Number.isSafeInteger(retVal) || retVal < 0) { + throw new KernelScratchError( + `syscall ${syscallNr} returned an invalid process address`, + EOVERFLOW, + ); + } + const ptrWidth = this.processes.get(pid)?.ptrWidth ?? 4; + if (syscallNr === SYS_MREMAP) { + // The old bytes are a host copy source when a mapping moves. Prove that + // complete caller range before memory.grow or zero-fill mutates the new + // destination. + checkedMemoryRange( + processMemory, + origArgs[0], + origArgs[1], + ptrWidth, + "mremap source range", + ); + } let endAddr = 0; let mmapAddr = 0; let mmapLen = 0; @@ -12256,11 +15336,21 @@ export class CentralizedKernelWorker { endAddr = mmapAddr + mmapLen; } } + if ( + !Number.isSafeInteger(mmapLen) + || mmapLen < 0 + || !Number.isSafeInteger(endAddr) + || endAddr < mmapAddr + ) { + throw new KernelScratchError( + `syscall ${syscallNr} process range overflows`, + EOVERFLOW, + ); + } const currentBytes = processMemory.buffer.byteLength; if (endAddr > 0 && endAddr > currentBytes) { - const ptrWidth = this.processes.get(pid)?.ptrWidth ?? 4; growMemoryToCover(processMemory, endAddr, ptrWidth); this.observeProcessMemoryTarget( processMemory, @@ -12292,8 +15382,8 @@ export class CentralizedKernelWorker { let zeroStart = mmapAddr; const zeroEnd = Math.min(mmapAddr + alignedLen, newBytes); if (syscallNr === SYS_MREMAP) { - const oldAddr = origArgs[0] >>> 0; - const oldLen = origArgs[1] >>> 0; + const oldAddr = origArgs[0]; + const oldLen = origArgs[1]; if (mmapAddr === oldAddr && oldLen > 0) { // In-place grow: prefix [oldAddr, oldAddr + oldLen) must remain // untouched. Only the new tail [oldAddr + oldLen, ...) needs to be @@ -12332,10 +15422,10 @@ export class CentralizedKernelWorker { origArgs[0] !== 0 && origArgs[1] > 0 ) { - const oldAddr = origArgs[0] >>> 0; - const oldLen = origArgs[1] >>> 0; - const newAddr = retVal >>> 0; - const newLen = origArgs[2] >>> 0; + const oldAddr = origArgs[0]; + const oldLen = origArgs[1]; + const newAddr = retVal; + const newLen = origArgs[2]; const copyLen = Math.min(oldLen, newLen); if (copyLen > 0) { const buf = processMemory.buffer; @@ -12353,7 +15443,7 @@ export class CentralizedKernelWorker { mapAddr: number, origArgs: number[], ): void { - const len = origArgs[1] >>> 0; + const len = origArgs[1]; if (len === 0) return; const processMem = new Uint8Array(channel.memory.buffer); if (mapAddr + len > processMem.length) return; @@ -12466,7 +15556,7 @@ export class CentralizedKernelWorker { mapAddr: number, origArgs: number[], ): FileSharedMmapResult { - if ((origArgs[1] >>> 0) === 0) return { kind: "mapped" }; + if (origArgs[1] === 0) return { kind: "mapped" }; const preparation = this.prepareSharedMmapFromFile(channel, origArgs); if (preparation.kind !== "prepared") return preparation; return this.registerPreparedSharedMmap( @@ -12486,7 +15576,7 @@ export class CentralizedKernelWorker { origArgs: number[], ): FileSharedMmapPreparationResult { const fd = origArgs[4]; - const len = origArgs[1] >>> 0; + const len = origArgs[1]; const pageOffset = origArgs[5]; const fileOffset = pageOffset * FILE_PAGE_SIZE; if ( @@ -12626,25 +15716,55 @@ export class CentralizedKernelWorker { channel: Pick, fd: number, ): SharedMmapHostResult { - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - const statPtr = this.scratchOffset + CH_DATA; - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - kernelView.setUint32(CH_SYSCALL, ABI_SYSCALLS.Fstat, true); - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - kernelView.setBigInt64(CH_ARGS + CH_ARG_SIZE, BigInt(statPtr), true); - for (let i = 2; i < CH_ARGS_COUNT; i++) { - kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); - } - const previousPid = this.currentHandlePid; - let hostHandle: number | null = null; + let captured: { + result: number; + errno: number; + dev: bigint; + ino: bigint; + mode: number; + size64: bigint; + hostHandle: number | null; + }; try { this.bindKernelTidForChannel(channel as ChannelInfo); this.currentHandlePid = channel.pid; - hostHandle = this.kernel.withFstatHandleCapture(() => - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid) - ).handle; + captured = this.requireMainScratchRegion().withLease((lease) => { + const kernelView = lease.dataView(0, CH_TOTAL_SIZE); + kernelView.setUint32(CH_SYSCALL, ABI_SYSCALLS.Fstat, true); + kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); + lease.writeAddress( + CH_ARGS + CH_ARG_SIZE, + CH_DATA, + STRUCT_SIZE_WASM_STAT, + "u64-le", + ); + for (let i = 2; i < CH_ARGS_COUNT; i++) { + kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); + } + const captureToken = this.kernel.beginFstatHandleCapture(); + let hostHandle: number | null = null; + try { + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + ]); + } finally { + hostHandle = this.kernel.finishFstatHandleCapture(captureToken); + } + const resultView = lease.dataView(0, CH_TOTAL_SIZE); + const statView = lease.dataView(CH_DATA, STRUCT_SIZE_WASM_STAT); + return { + result: Number(resultView.getBigInt64(CH_RETURN, true)), + errno: resultView.getUint32(CH_ERRNO, true), + dev: statView.getBigUint64(0, true), + ino: statView.getBigUint64(8, true), + mode: statView.getUint32(16, true), + size64: statView.getBigUint64(32, true), + hostHandle, + }; + }); } catch { return { kind: "error", errno: EIO }; } finally { @@ -12653,9 +15773,7 @@ export class CentralizedKernelWorker { if (this.finishSignalTermination(channel as ChannelInfo)) { return { kind: "error", errno: EINTR_ERRNO }; } - const resultView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const result = Number(resultView.getBigInt64(CH_RETURN, true)); - const errno = resultView.getUint32(CH_ERRNO, true); + const { result, errno, dev, ino, mode, size64, hostHandle } = captured; if (result !== 0 || errno !== 0) { return { kind: "error", @@ -12663,11 +15781,6 @@ export class CentralizedKernelWorker { }; } - const statView = new DataView(this.kernelMemory!.buffer, statPtr); - const dev = statView.getBigUint64(0, true); - const ino = statView.getBigUint64(8, true); - const mode = statView.getUint32(16, true); - const size64 = statView.getBigUint64(32, true); return { kind: "ok", value: { @@ -12689,25 +15802,37 @@ export class CentralizedKernelWorker { const getFdPath = this.kernelInstance!.exports.kernel_get_fd_path as ((pid: number, fd: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; if (!getFdPath) return { kind: "error", errno: ENOSYS }; - const ptr = this.scratchOffset + CH_DATA; - let len: number; + let output: { result: number; bytes: Uint8Array }; try { - len = getFdPath( - channel.pid, - fd, - this.toKernelPtr(ptr), - Math.min(4096, CH_DATA_SIZE), - ); + const capacity = Math.min(POSIX_PATH_MAX_BYTES, CH_DATA_SIZE); + output = this.requireMainScratchRegion().withLease((lease) => { + const result = lease.invokeKernelExport("kernel_get_fd_path", [ + channel.pid, + fd, + lease.exportPointer(0, capacity), + capacity, + ]); + const byteLength = this.checkedScratchProducerByteLength( + result, + capacity, + "kernel_get_fd_path", + ); + return { + result, + bytes: byteLength === 0 + ? new Uint8Array(0) + : lease.copyOut(0, byteLength), + }; + }); } catch { return { kind: "error", errno: EIO }; } + const len = output.result; if (len < 0) return { kind: "error", errno: -len }; if (len === 0) return { kind: "error", errno: ENOENT }; return { kind: "ok", - value: new TextDecoder().decode( - new Uint8Array(this.kernelMemory!.buffer).slice(ptr, ptr + len), - ), + value: new TextDecoder().decode(output.bytes), }; } @@ -12715,21 +15840,34 @@ export class CentralizedKernelWorker { channel: Pick, fd: number, ): SharedMmapHostResult { - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - kernelView.setUint32(CH_SYSCALL, SYS_FCNTL, true); - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - kernelView.setBigInt64(CH_ARGS + CH_ARG_SIZE, BigInt(F_GETFL), true); - for (let i = 2; i < CH_ARGS_COUNT; i++) { - kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); - } - const previousPid = this.currentHandlePid; + let captured: { result: number; errno: number }; try { this.bindKernelTidForChannel(channel as ChannelInfo); this.currentHandlePid = channel.pid; - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + captured = this.requireMainScratchRegion().withLease((lease) => { + const kernelView = lease.dataView(0, CH_TOTAL_SIZE); + kernelView.setUint32(CH_SYSCALL, SYS_FCNTL, true); + kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); + kernelView.setBigInt64( + CH_ARGS + CH_ARG_SIZE, + BigInt(F_GETFL), + true, + ); + for (let i = 2; i < CH_ARGS_COUNT; i++) { + kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); + } + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + ]); + const resultView = lease.dataView(0, CH_TOTAL_SIZE); + return { + result: Number(resultView.getBigInt64(CH_RETURN, true)), + errno: resultView.getUint32(CH_ERRNO, true), + }; + }); } catch { return { kind: "error", errno: EIO }; } finally { @@ -12738,9 +15876,7 @@ export class CentralizedKernelWorker { if (this.finishSignalTermination(channel as ChannelInfo)) { return { kind: "error", errno: EINTR_ERRNO }; } - const resultView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const result = Number(resultView.getBigInt64(CH_RETURN, true)); - const errno = resultView.getUint32(CH_ERRNO, true); + const { result, errno } = captured; if (result < 0 || errno !== 0) { return { kind: "error", @@ -13202,10 +16338,12 @@ export class CentralizedKernelWorker { || syscallNr === SYS_PREAD || syscallNr === SYS_READV || syscallNr === SYS_PREADV + || syscallNr === SYS_PREADV2 || syscallNr === SYS_WRITE || syscallNr === SYS_PWRITE || syscallNr === SYS_WRITEV || syscallNr === SYS_PWRITEV + || syscallNr === SYS_PWRITEV2 || syscallNr === SYS_FSYNC || syscallNr === SYS_FDATASYNC || syscallNr === SYS_FTRUNCATE @@ -13238,7 +16376,10 @@ export class CentralizedKernelWorker { if (pathPtr <= 0 || pathPtr >= memory.length) { return { kind: "error", errno: EFAULT }; } - const limit = Math.min(memory.length, pathPtr + 4096); + const limit = Math.min( + memory.length, + pathPtr + POSIX_PATH_MAX_BYTES, + ); let end = pathPtr; while (end < limit && memory[end] !== 0) end++; if (end === limit) return { kind: "error", errno: ENAMETOOLONG }; @@ -13261,17 +16402,29 @@ export class CentralizedKernelWorker { const getCwd = this.kernelInstance!.exports.kernel_get_cwd as ((pid: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; if (!getCwd) return { kind: "error", errno: ENOSYS }; - const cwdLen = getCwd( - channel.pid, - this.toKernelPtr(this.scratchOffset), - Math.min(4096, CH_DATA_SIZE), - ); + const capacity = Math.min(POSIX_PATH_MAX_BYTES, CH_DATA_SIZE); + const output = this.requireMainScratchRegion().withLease((lease) => { + const result = lease.invokeKernelExport("kernel_get_cwd", [ + channel.pid, + lease.exportPointer(0, capacity), + capacity, + ]); + const byteLength = this.checkedScratchProducerByteLength( + result, + capacity, + "kernel_get_cwd", + ); + return { + result, + bytes: byteLength === 0 + ? new Uint8Array(0) + : lease.copyOut(0, byteLength), + }; + }); + const cwdLen = output.result; if (cwdLen < 0) return { kind: "error", errno: -cwdLen }; if (cwdLen === 0) return { kind: "error", errno: ENOENT }; - base = new TextDecoder().decode( - new Uint8Array(this.kernelMemory!.buffer) - .slice(this.scratchOffset, this.scratchOffset + cwdLen), - ); + base = new TextDecoder().decode(output.bytes); } return { kind: "ok", @@ -13331,6 +16484,7 @@ export class CentralizedKernelWorker { origArgs: number[], retVal: number, errVal: number, + positionedOffset?: bigint, ): void { if ((this.sharedMmapBackings?.size ?? 0) === 0) return; if (errVal !== 0) return; @@ -13362,12 +16516,23 @@ export class CentralizedKernelWorker { } } if (syscallNr === SYS_PWRITE && retVal > 0) { + const exactOffset = positionedOffset ?? BigInt(origArgs[3]); + if ( + exactOffset < BigInt(Number.MIN_SAFE_INTEGER) + || exactOffset > BigInt(Number.MAX_SAFE_INTEGER) + ) { + // The shared-mapping cache is indexed with JavaScript numbers. A + // successful pwrite beyond that domain must refresh mapped ranges + // from the authoritative file rather than aliasing a rounded offset. + this.reloadSharedMmapBackingForFd(channel, origArgs[0]); + return; + } this.updateSharedMmapBackingFromProcessBuffer( channel, origArgs[0], - origArgs[1] >>> 0, + origArgs[1], retVal, - origArgs[3], + Number(exactOffset), ); return; } @@ -13375,7 +16540,14 @@ export class CentralizedKernelWorker { this.reloadSharedMmapBackingForFd(channel, origArgs[0]); return; } - if ((syscallNr === SYS_WRITEV || syscallNr === SYS_PWRITEV) && retVal > 0) { + if ( + ( + syscallNr === SYS_WRITEV + || syscallNr === SYS_PWRITEV + || syscallNr === SYS_PWRITEV2 + ) + && retVal > 0 + ) { this.reloadSharedMmapBackingForFd(channel, origArgs[0]); return; } @@ -13776,46 +16948,75 @@ export class CentralizedKernelWorker { const pageOffset = origArgs[5]; let fileOffset = pageOffset * 4096; - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const kernelMem = new Uint8Array(this.kernelMemory!.buffer); - const dataStart = this.scratchOffset + CH_DATA; + const scratch = this.requireMainScratchRegion(); let written = 0; while (written < mapLen) { const chunkSize = Math.min(CH_DATA_SIZE, mapLen - written); - // Set up pread syscall in kernel scratch: - // SYS_PREAD (64): (fd, buf_ptr, count, signed i64 offset) - kernelView.setUint32(CH_SYSCALL, SYS_PREAD, true); - kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(fd), true); // fd - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); // buf_ptr (kernel memory) - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(chunkSize), true); // count - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(fileOffset), true); - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, 0n, true); - kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(0), true); - - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; + let attempt: { bytesRead: number; bytes: Uint8Array }; try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + attempt = scratch.withLease((lease) => { + const kernelView = lease.dataView(0, CH_TOTAL_SIZE); + kernelView.setUint32(CH_SYSCALL, SYS_PREAD, true); + kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); + lease.writeAddress( + CH_ARGS + CH_ARG_SIZE, + CH_DATA, + chunkSize, + "u64-le", + ); + kernelView.setBigInt64( + CH_ARGS + 2 * CH_ARG_SIZE, + BigInt(chunkSize), + true, + ); + kernelView.setBigInt64( + CH_ARGS + 3 * CH_ARG_SIZE, + BigInt(fileOffset), + true, + ); + kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, 0n, true); + kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, 0n, true); + + this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; + try { + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + ]); + } finally { + this.currentHandlePid = 0; + } + const bytesRead = Number( + lease.dataView(0, CH_TOTAL_SIZE).getBigInt64(CH_RETURN, true), + ); + if ( + !Number.isSafeInteger(bytesRead) + || bytesRead < 0 + || bytesRead > chunkSize + ) { + return { bytesRead, bytes: new Uint8Array(0) }; + } + return { + bytesRead, + bytes: lease.copyOut(CH_DATA, bytesRead), + }; + }); } catch { break; // pread failed, leave rest as zeros - } finally { - this.currentHandlePid = 0; } if (this.finishSignalTermination(channel)) return; - const bytesRead = Number(kernelView.getBigInt64(CH_RETURN, true)); - if (bytesRead <= 0) break; // EOF or error + const { bytesRead } = attempt; + if (!Number.isSafeInteger(bytesRead) || bytesRead <= 0) break; + if (bytesRead > chunkSize) break; // Copy from kernel scratch data area to process memory const processMem = new Uint8Array(channel.memory.buffer); - processMem.set( - kernelMem.subarray(dataStart, dataStart + bytesRead), - mmapAddr + written, - ); + processMem.set(attempt.bytes, mmapAddr + written); written += bytesRead; fileOffset += bytesRead; @@ -13841,8 +17042,15 @@ export class CentralizedKernelWorker { return false; } - const syncAddr = origArgs[0] >>> 0; - const syncLen = origArgs[1] >>> 0; + const syncAddr = origArgs[0]; + const syncLen = origArgs[1]; + if ( + !Number.isSafeInteger(syncAddr) + || syncAddr < 0 + || !Number.isSafeInteger(syncLen) + || syncLen < 0 + || !Number.isSafeInteger(syncAddr + syncLen) + ) return false; const pidMap = this.sharedMappings.get(channel.pid); if (!pidMap || pidMap.size === 0) return true; @@ -13893,47 +17101,69 @@ export class CentralizedKernelWorker { len: number, fileOffset: number, ): boolean { - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - const dataStart = this.scratchOffset + CH_DATA; + const scratch = this.requireMainScratchRegion(); - if (processAddr + len > channel.memory.buffer.byteLength) return false; + try { + this.checkedProcessRange( + channel, + processAddr, + len, + "shared mmap writeback source", + ); + } catch { + return false; + } const previousPid = this.currentHandlePid; try { let written = 0; while (written < len) { const chunkSize = Math.min(CH_DATA_SIZE, len - written); - // pwrite can grow an in-kernel Vec and therefore the kernel Wasm - // memory. Reacquire scratch views for every chunk; a view cached - // across kernel_handle_channel may have been detached by memory.grow. - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const kernelMem = new Uint8Array(this.kernelMemory!.buffer); - - // Copy chunk from process memory to kernel scratch data area const processMem = new Uint8Array(channel.memory.buffer); - kernelMem.set( - processMem.subarray(processAddr + written, processAddr + written + chunkSize), - dataStart, - ); - - // Set up pwrite syscall in kernel scratch: - // SYS_PWRITE (65): (fd, buf_ptr, count, signed i64 offset) const curOffset = fileOffset + written; - kernelView.setUint32(CH_SYSCALL, SYS_PWRITE, true); - kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(fd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(chunkSize), true); - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(curOffset), true); - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, 0n, true); - kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(0), true); + const bytesWritten = scratch.withLease((lease) => { + const kernelView = lease.dataView(0, CH_TOTAL_SIZE); + lease.copyFrom( + processMem, + CH_DATA, + processAddr + written, + chunkSize, + ); + kernelView.setUint32(CH_SYSCALL, SYS_PWRITE, true); + kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); + lease.writeAddress( + CH_ARGS + CH_ARG_SIZE, + CH_DATA, + chunkSize, + "u64-le", + ); + kernelView.setBigInt64( + CH_ARGS + 2 * CH_ARG_SIZE, + BigInt(chunkSize), + true, + ); + kernelView.setBigInt64( + CH_ARGS + 3 * CH_ARG_SIZE, + BigInt(curOffset), + true, + ); + kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, 0n, true); + kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, 0n, true); - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + ]); + // A synchronous kernel import may grow its Memory and detach every + // host view created before the call. Reacquire through the lease so + // both current-memory bounds and allocation capacity are rechecked. + return Number( + lease.dataView(0, CH_TOTAL_SIZE).getBigInt64(CH_RETURN, true), + ); + }); if (this.finishSignalTermination(channel)) return false; - - const resultView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const bytesWritten = Number(resultView.getBigInt64(CH_RETURN, true)); if (bytesWritten <= 0 || bytesWritten > chunkSize) return false; written += bytesWritten; @@ -14009,8 +17239,8 @@ export class CentralizedKernelWorker { } private preflightFileSharedMremap(pid: number, origArgs: number[]): number { - const oldAddr = origArgs[0] >>> 0; - const newLen = origArgs[2] >>> 0; + const oldAddr = origArgs[0]; + const newLen = origArgs[2]; const mapping = this.sharedMappings.get(pid)?.get(oldAddr); if ( !mapping @@ -14277,23 +17507,38 @@ export class CentralizedKernelWorker { ((shmid: number, offset: number, outPtr: KernelPointer, maxLen: number) => number) | undefined; if (!readChunk) return null; const result = new Uint8Array(len); + const scratch = this.requireMainScratchRegion(); let transferred = 0; while (transferred < len) { const toRead = Math.min(CH_DATA_SIZE, len - transferred); - const chunkPtr = this.scratchOffset + CH_DATA; - const nRead = readChunk( - segId, - offset + transferred, - this.toKernelPtr(chunkPtr), - toRead, - ); - if (nRead < 0 || nRead > toRead) return null; - if (nRead === 0) break; - result.set( - new Uint8Array(this.kernelMemory!.buffer, chunkPtr, nRead), - transferred, - ); - transferred += nRead; + const attempt = scratch.withLease((lease) => { + const nRead = lease.invokeKernelExport( + "kernel_ipc_shm_read_chunk", + [ + segId, + offset + transferred, + lease.exportPointer(CH_DATA, toRead), + toRead, + ], + ); + if ( + !Number.isSafeInteger(nRead) + || nRead < 0 + || nRead > toRead + ) { + return { nRead, bytes: null }; + } + return { + nRead, + bytes: nRead > 0 ? lease.copyOut(CH_DATA, nRead) : null, + }; + }); + if (attempt.nRead < 0 || attempt.nRead > toRead || !attempt.bytes) { + if (attempt.nRead === 0) break; + return null; + } + result.set(attempt.bytes, transferred); + transferred += attempt.nRead; } return result; } @@ -14302,21 +17547,32 @@ export class CentralizedKernelWorker { const writeChunk = this.kernelInstance!.exports.kernel_ipc_shm_write_chunk as ((shmid: number, offset: number, dataPtr: KernelPointer, dataLen: number) => number) | undefined; if (!writeChunk) return false; + const exactBytes = intrinsicUint8ArrayView( + bytes, + "System V shared-memory input", + ); + const scratch = this.requireMainScratchRegion(); let transferred = 0; - while (transferred < bytes.length) { - const toWrite = Math.min(CH_DATA_SIZE, bytes.length - transferred); - const chunkPtr = this.scratchOffset + CH_DATA; - new Uint8Array(this.kernelMemory!.buffer).set( - bytes.subarray(transferred, transferred + toWrite), - chunkPtr, - ); - const written = writeChunk( - segId, - offset + transferred, - this.toKernelPtr(chunkPtr), - toWrite, - ); - if (written <= 0 || written > toWrite) return false; + while (transferred < exactBytes.byteLength) { + const toWrite = Math.min( + CH_DATA_SIZE, + exactBytes.byteLength - transferred, + ); + const written = scratch.withLease((lease) => { + lease.copyFrom(exactBytes, CH_DATA, transferred, toWrite); + return lease.invokeKernelExport( + "kernel_ipc_shm_write_chunk", + [ + segId, + offset + transferred, + lease.exportPointer(CH_DATA, toWrite), + toWrite, + ], + ); + }); + if (!Number.isSafeInteger(written) || written <= 0 || written > toWrite) { + return false; + } transferred += written; } return true; @@ -14496,9 +17752,37 @@ export class CentralizedKernelWorker { "Kernel export kernel_reserve_host_region is required for dynamic pthread control slots", ); } - const addr = reserveHostRegionFn(pid, this.toKernelPtr(len)); - const n = typeof addr === "bigint" ? Number(addr) : addr; - if (!Number.isSafeInteger(n) || n < 0 || (n >>> 0) === 0xffffffff) { + const guestPointerWidth = this.getPtrWidth(pid); + const kernelPointerWidth = this.kernel.getKernelPtrWidth(); + const checkedLength = checkedWasmPointer( + len, + guestPointerWidth, + "host-region length", + ); + if (checkedLength === 0) { + throw new Error(`failed to reserve ${len} bytes of pthread control memory for pid=${pid}`); + } + const addr = reserveHostRegionFn(pid, this.toKernelPtr(checkedLength)); + let n: number; + try { + // WHY: the export's signed i32/BigInt representation follows the kernel + // Wasm width, but the logical range belongs to the potentially + // different-width guest. Prove both contracts independently. + n = checkedKernelExportPointer( + addr, + kernelPointerWidth, + "reserved host-region address", + ); + checkedWasmAddressRange( + n, + checkedLength, + guestPointerWidth, + "reserved host region", + ); + } catch { + throw new Error(`failed to reserve ${len} bytes of pthread control memory for pid=${pid}`); + } + if (kernelPointerWidth === 4 && n === 0xffff_ffff) { throw new Error(`failed to reserve ${len} bytes of pthread control memory for pid=${pid}`); } return n; @@ -14512,13 +17796,53 @@ export class CentralizedKernelWorker { "Kernel export kernel_reserve_host_region_at is required for fork-from-pthread control slots", ); } + const guestPointerWidth = this.getPtrWidth(pid); + const kernelPointerWidth = this.kernel.getKernelPtrWidth(); + let request: { pointer: number; length: number; end: number }; + try { + request = checkedWasmAddressRange( + addr, + len, + guestPointerWidth, + "fixed host region", + ); + if (request.length === 0) throw new KernelScratchError("fixed host region is empty"); + } catch { + throw new Error( + `failed to reserve pthread control memory at 0x${addr.toString(16)} ` + + `for pid=${pid}`, + ); + } const reserved = reserveHostRegionAtFn( pid, - this.toKernelPtr(addr), - this.toKernelPtr(len), + this.toKernelPtr(request.pointer), + this.toKernelPtr(request.length), ); - const n = typeof reserved === "bigint" ? Number(reserved) : reserved; - if (!Number.isSafeInteger(n) || n < 0 || (n >>> 0) === 0xffffffff || n !== addr) { + let n: number; + try { + // See reserveHostRegion: export representation and guest address-domain + // validity are distinct when kernel and process pointer widths differ. + n = checkedKernelExportPointer( + reserved, + kernelPointerWidth, + "fixed host-region address", + ); + checkedWasmAddressRange( + n, + request.length, + guestPointerWidth, + "fixed reserved host region", + ); + } catch { + throw new Error( + `failed to reserve pthread control memory at 0x${addr.toString(16)} ` + + `for pid=${pid}`, + ); + } + if ( + (kernelPointerWidth === 4 && n === 0xffff_ffff) + || n !== request.pointer + ) { throw new Error( `failed to reserve pthread control memory at 0x${addr.toString(16)} ` + `for pid=${pid}`, @@ -14560,7 +17884,13 @@ export class CentralizedKernelWorker { return setBrkBaseFn(pid, this.toKernelPtr(addr)) >= 0; } - /** Get the underlying kernel instance for direct access. */ + /** + * UNSAFE trusted-embedder/debug access to the low-level wrapper. + * + * Direct allocator calls or memory writes bypass the worker's checked + * scratch regions. Repository runtime code uses this only for observation; + * transfer implementations must stay on the capacity-bearing APIs. + */ getKernel(): WasmPosixKernel { return this.kernel; } @@ -14584,7 +17914,12 @@ export class CentralizedKernelWorker { return this.processes.get(pid)?.memory; } - /** Get the kernel Wasm instance. */ + /** + * UNSAFE trusted-embedder/debug access to the raw kernel instance. + * + * Pointer-returning exports do not themselves carry allocation capacity. + * Do not combine this with raw kernel-memory writes. + */ getKernelInstance(): WebAssembly.Instance | null { return this.kernelInstance; } @@ -14621,6 +17956,30 @@ export class CentralizedKernelWorker { return fn() >>> 0; } + /** + * Retained capacity of the kernel-owned large-spawn reservation in bytes. + * + * Zero means no large spawn has needed a reservation. + */ + getSpawnScratchCapacity(): number { + const fn = this.kernelInstance?.exports + .kernel_spawn_scratch_retained_capacity as + (() => KernelPointer) | undefined; + if (typeof fn !== "function") { + throw new Error( + "kernel_spawn_scratch_retained_capacity export is unavailable", + ); + } + const raw = fn(); + const capacity = typeof raw === "bigint" ? Number(raw) : raw; + if (!Number.isSafeInteger(capacity) || capacity < 0) { + throw new Error( + `kernel returned an invalid spawn scratch capacity: ${String(raw)}`, + ); + } + return capacity; + } + /** * Push a mouse event into the kernel's `/dev/input/mice` queue. The * kernel buffers a 3-byte PS/2 frame; any process blocked in @@ -15046,12 +18405,6 @@ export class CentralizedKernelWorker { const injectConnection = exports.kernel_inject_connection as ( pid: number, fd: number, a: number, b: number, c: number, d: number, port: number, ) => number; - const pipeWrite = exports.kernel_pipe_write as ( - pid: number, pipeIdx: number, bufPtr: KernelPointer, bufLen: number, - ) => number; - const pipeRead = exports.kernel_pipe_read as ( - pid: number, pipeIdx: number, bufPtr: KernelPointer, bufLen: number, - ) => number; const pipeIsWriteOpen = exports.kernel_pipe_is_write_open as ( pid: number, pipeIdx: number, ) => number; @@ -15085,7 +18438,11 @@ export class CentralizedKernelWorker { // Write the request bytes through the TCP scratch buffer. const rawRequest = buildRawHttpRequest(request); - const written = this.writePipeChunked(pipeWrite, GLOBAL_PIPE_PID, recvPipeIdx, rawRequest); + const written = this.writePipeChunked( + GLOBAL_PIPE_PID, + recvPipeIdx, + rawRequest, + ); if (written < rawRequest.length) { // Partial write here would mean the recv pipe filled up before the // server even started reading. Treat as a hard error for the prototype. @@ -15104,7 +18461,6 @@ export class CentralizedKernelWorker { GLOBAL_PIPE_PID, sendPipeIdx, recvPipeIdx, - pipeRead, pipeIsWriteOpen, pipeCloseRead, pipeCloseWrite, @@ -15149,31 +18505,106 @@ export class CentralizedKernelWorker { * pipe stops accepting or we've written everything. Returns total bytes * written. */ + private readPipeChunk( + pid: number, + pipeIdx: number, + ): Uint8Array | null { + const pipeRead = this.kernelInstance!.exports.kernel_pipe_read as + ( + pid: number, + pipeIdx: number, + bufPtr: KernelPointer, + bufLen: number, + ) => number; + const scratch = this.requireTcpScratchRegion(); + return scratch.withLease((lease) => { + const n = lease.invokeKernelExport("kernel_pipe_read", [ + pid, + pipeIdx, + lease.exportPointer(0, scratch.capacity), + scratch.capacity, + ]); + if (n <= 0) return null; + if (!Number.isSafeInteger(n) || n > scratch.capacity) { + throw new KernelScratchError( + "kernel pipe read exceeded TCP scratch capacity", + EIO, + ); + } + return lease.copyOut(0, n); + }); + } + private writePipeChunked( - pipeWrite: (pid: number, pipeIdx: number, bufPtr: KernelPointer, bufLen: number) => number, pid: number, pipeIdx: number, data: Uint8Array, ): number { - const scratchOffset = this.tcpScratchOffset; - const PAGE = 65536; + const pipeWrite = this.kernelInstance!.exports.kernel_pipe_write as + ( + pid: number, + pipeIdx: number, + bufPtr: KernelPointer, + bufLen: number, + ) => number; + const exactData = intrinsicUint8ArrayView(data, "kernel pipe input"); + const scratch = this.requireTcpScratchRegion(); let written = 0; - while (written < data.length) { - const chunk = Math.min(data.length - written, PAGE); - // Re-acquire view each iteration — memory.grow can detach the buffer. - const mem = this.getKernelMem(); - mem.set(data.subarray(written, written + chunk), scratchOffset); - const n = checkedKernelPipeTransferCount( - "kernel_pipe_write", - pipeWrite(pid, pipeIdx, this.toKernelPtr(scratchOffset), chunk), - chunk, - ); - if (n <= 0) break; + while (written < exactData.byteLength) { + const chunk = Math.min( + exactData.byteLength - written, + scratch.capacity, + ); + const n = scratch.withLease((lease) => { + lease.copyFrom(exactData, 0, written, chunk); + return lease.invokeKernelExport("kernel_pipe_write", [ + pid, + pipeIdx, + lease.exportPointer(0, chunk), + chunk, + ]); + }); + if (!Number.isSafeInteger(n) || n <= 0 || n > chunk) break; written += n; } return written; } + /** Read all currently available pipe bytes through the owned TCP region. */ + readPipeAvailable(pid: number, pipeIdx: number): Uint8Array | null { + const pipeRead = this.kernelInstance?.exports.kernel_pipe_read as + | (( + pid: number, + pipeIdx: number, + bufPtr: KernelPointer, + bufLen: number, + ) => number) + | undefined; + if (!pipeRead) return null; + const chunks: Uint8Array[] = []; + for (;;) { + const chunk = this.readPipeChunk(pid, pipeIdx); + if (!chunk) break; + chunks.push(chunk); + } + return chunks.length > 0 ? concatChunksLocal(chunks) : null; + } + + /** Write host bytes to a kernel pipe through the owned TCP region. */ + writePipeData(pid: number, pipeIdx: number, data: Uint8Array): number { + const pipeWrite = this.kernelInstance?.exports.kernel_pipe_write as + | (( + pid: number, + pipeIdx: number, + bufPtr: KernelPointer, + bufLen: number, + ) => number) + | undefined; + return pipeWrite + ? this.writePipeChunked(pid, pipeIdx, data) + : -1; + } + /** * Pump response bytes out of `sendPipeIdx` until the server closes its * write end. Resolves with the parsed response, or with status 504 on @@ -15183,7 +18614,6 @@ export class CentralizedKernelWorker { pid: number, sendPipeIdx: number, recvPipeIdx: number, - pipeRead: (pid: number, pipeIdx: number, bufPtr: KernelPointer, bufLen: number) => number, pipeIsWriteOpen: (pid: number, pipeIdx: number) => number, pipeCloseRead: (pid: number, pipeIdx: number) => number, pipeCloseWrite: (pid: number, pipeIdx: number) => number, @@ -15196,8 +18626,6 @@ export class CentralizedKernelWorker { const chunks = new BoundedHttpResponseChunks(maxResponseBytes); const start = Date.now(); let sawWriteOpen = false; - const scratchOffset = this.tcpScratchOffset; - const PAGE = 65536; const finish = (response: HttpResponse) => { pipeCloseRead(pid, sendPipeIdx); @@ -15224,20 +18652,10 @@ export class CentralizedKernelWorker { // Drain whatever is currently in the pipe. let gotData = false; for (;;) { - try { - const n = checkedKernelPipeTransferCount( - "kernel_pipe_read", - pipeRead(pid, sendPipeIdx, this.toKernelPtr(scratchOffset), PAGE), - PAGE, - ); - if (n <= 0) break; - gotData = true; - const mem = this.getKernelMem(); - chunks.push(mem.slice(scratchOffset, scratchOffset + n)); - } catch (error) { - fail(error); - return; - } + const chunk = this.readPipeChunk(pid, sendPipeIdx); + if (!chunk) break; + gotData = true; + chunks.push(chunk); } if (gotData) { @@ -15308,10 +18726,6 @@ export class CentralizedKernelWorker { // The APIs now always resolve pipe indexes through the global pipe table, // which lets any process sharing the listener accept this connection. const GLOBAL_PIPE_PID = 0; - const pipeWrite = this.kernelInstance!.exports.kernel_pipe_write as - (pid: number, pipeIdx: number, bufPtr: KernelPointer, bufLen: number) => number; - const pipeRead = this.kernelInstance!.exports.kernel_pipe_read as - (pid: number, pipeIdx: number, bufPtr: KernelPointer, bufLen: number) => number; const pipeCloseWrite = this.kernelInstance!.exports.kernel_pipe_close_write as (pid: number, pipeIdx: number) => number; const pipeCloseRead = this.kernelInstance!.exports.kernel_pipe_close_read as @@ -15329,8 +18743,6 @@ export class CentralizedKernelWorker { let pumpPending = false; let cleaned = false; - const scratchOffset = this.tcpScratchOffset; - const pipeIsWriteOpen = this.kernelInstance!.exports.kernel_pipe_is_write_open as (pid: number, pipeIdx: number) => number; @@ -15349,21 +18761,13 @@ export class CentralizedKernelWorker { if (clientEnded) closeRecvPipeWrite(); return; } - const mem = this.getKernelMem(); let wroteAny = false; while (inboundQueue.length > 0) { const chunk = inboundQueue[0]!; - const toWrite = Math.min(chunk.length, 65536); - mem.set(chunk.subarray(0, toWrite), scratchOffset); - const written = checkedKernelPipeTransferCount( - "kernel_pipe_write", - pipeWrite( - GLOBAL_PIPE_PID, - recvPipeIdx, - this.toKernelPtr(scratchOffset), - toWrite, - ), - toWrite, + const written = this.writePipeChunked( + GLOBAL_PIPE_PID, + recvPipeIdx, + chunk, ); if (written <= 0) break; // Pipe full, retry next pump wroteAny = true; @@ -15383,25 +18787,18 @@ export class CentralizedKernelWorker { // Read send pipe → TCP socket (drains all available data) const drainOutbound = () => { - const mem = this.getKernelMem(); let totalRead = 0; // Loop to drain the entire pipe, not just one 65KB chunk. // Responses larger than 65KB (e.g. 662KB site-editor.php) need // multiple reads to fully transfer. for (;;) { - const readN = checkedKernelPipeTransferCount( - "kernel_pipe_read", - pipeRead( - GLOBAL_PIPE_PID, - sendPipeIdx, - this.toKernelPtr(scratchOffset), - 65536, - ), - 65536, + const bytes = this.readPipeChunk( + GLOBAL_PIPE_PID, + sendPipeIdx, ); - if (readN <= 0) break; - totalRead += readN; - const outData = Buffer.from(mem.slice(scratchOffset, scratchOffset + readN)); + if (!bytes) break; + totalRead += bytes.byteLength; + const outData = Buffer.from(bytes); if (!clientSocket.destroyed) { clientSocket.write(outData); } @@ -15499,7 +18896,7 @@ export class CentralizedKernelWorker { conns = []; this.tcpConnections.set(pid, conns); } - const connEntry = { sendPipeIdx, scratchOffset, clientSocket, recvPipeIdx, schedulePump }; + const connEntry = { sendPipeIdx, clientSocket, recvPipeIdx, schedulePump }; conns.push(connEntry); const cleanup = () => { @@ -15556,10 +18953,6 @@ export class CentralizedKernelWorker { const sendPipeIdx = recvPipeIdx + 1; const GLOBAL_PIPE_PID = 0; - const pipeWrite = this.kernelInstance.exports.kernel_pipe_write as - (pid: number, pipeIdx: number, bufPtr: KernelPointer, bufLen: number) => number; - const pipeRead = this.kernelInstance.exports.kernel_pipe_read as - (pid: number, pipeIdx: number, bufPtr: KernelPointer, bufLen: number) => number; const pipeCloseWrite = this.kernelInstance.exports.kernel_pipe_close_write as (pid: number, pipeIdx: number) => number; const pipeCloseRead = this.kernelInstance.exports.kernel_pipe_close_read as @@ -15577,7 +18970,6 @@ export class CentralizedKernelWorker { let guestWriteEnded = false; let pendingInbound: Uint8Array | null = null; let pumpPending = false; - const scratchOffset = this.tcpScratchOffset; const closeRecvPipeWrite = () => { if (recvPipeWriteClosed) return; @@ -15624,7 +19016,11 @@ export class CentralizedKernelWorker { this.notifyPipeReadable(recvPipeIdx); return; } - const written = this.writePipeChunked(pipeWrite, GLOBAL_PIPE_PID, recvPipeIdx, data); + const written = this.writePipeChunked( + GLOBAL_PIPE_PID, + recvPipeIdx, + data, + ); if (written < data.length) { // `peer.recv` consumes bytes, so retain the unwritten suffix while // the guest receive pipe is full and retry it on a later pump tick. @@ -15637,21 +19033,14 @@ export class CentralizedKernelWorker { }; const drainOutbound = () => { - const mem = this.getKernelMem(); for (;;) { - const n = checkedKernelPipeTransferCount( - "kernel_pipe_read", - pipeRead( - GLOBAL_PIPE_PID, - sendPipeIdx, - this.toKernelPtr(scratchOffset), - 65536, - ), - 65536, + const bytes = this.readPipeChunk( + GLOBAL_PIPE_PID, + sendPipeIdx, ); - if (n <= 0) break; + if (!bytes) break; try { - peer.send(mem.slice(scratchOffset, scratchOffset + n), 0); + peer.send(bytes, 0); } catch { cleanup(); return; @@ -15701,7 +19090,16 @@ export class CentralizedKernelWorker { */ private injectUdpDatagram(pid: number, datagram: UdpDatagram): number { if (!this.kernelInstance || !this.processes.has(pid)) return 113; // EHOSTUNREACH - if (datagram.data.length > 65536) return 90; // EMSGSIZE + let exactData: Uint8Array; + try { + exactData = intrinsicUint8ArrayView( + datagram.data, + "virtual UDP datagram", + ); + } catch { + return EIO; + } + if (exactData.byteLength > 65536) return 90; // EMSGSIZE const injectDatagram = this.kernelInstance.exports.kernel_inject_datagram as ((pid: number, @@ -15710,24 +19108,24 @@ export class CentralizedKernelWorker { dataPtr: KernelPointer, dataLen: number) => number) | undefined; if (!injectDatagram) return 38; // ENOSYS - const scratchOffset = this.tcpScratchOffset; - const mem = this.getKernelMem(); - mem.set(datagram.data, scratchOffset); - const result = injectDatagram( - pid, - datagram.dstAddr[0] ?? 0, - datagram.dstAddr[1] ?? 0, - datagram.dstAddr[2] ?? 0, - datagram.dstAddr[3] ?? 0, - datagram.dstPort, - datagram.srcAddr[0] ?? 0, - datagram.srcAddr[1] ?? 0, - datagram.srcAddr[2] ?? 0, - datagram.srcAddr[3] ?? 0, - datagram.srcPort, - this.toKernelPtr(scratchOffset), - datagram.data.length, - ); + const result = this.requireTcpScratchRegion().withLease((scratch) => { + scratch.copyFrom(exactData); + return scratch.invokeKernelExport("kernel_inject_datagram", [ + pid, + datagram.dstAddr[0] ?? 0, + datagram.dstAddr[1] ?? 0, + datagram.dstAddr[2] ?? 0, + datagram.dstAddr[3] ?? 0, + datagram.dstPort, + datagram.srcAddr[0] ?? 0, + datagram.srcAddr[1] ?? 0, + datagram.srcAddr[2] ?? 0, + datagram.srcAddr[3] ?? 0, + datagram.srcPort, + scratch.exportPointer(0, exactData.byteLength), + exactData.byteLength, + ]); + }); if (result < 0) return -result; this.scheduleWakeBlockedRetries(); return 0; @@ -15796,121 +19194,543 @@ export class CentralizedKernelWorker { // Most IPC syscalls now go through the kernel via SYSCALL_ARGS marshalling. // shmat/shmdt are intercepted because they require process memory management // (mmap address allocation, data transfer between kernel and process memory). - // semctl is intercepted because arg[3] is cmd-dependent (scalar vs pointer). + // Control syscalls are intercepted because pointer direction and target + // structure size depend on cmd and the calling process's pointer width. // ========================================================================= - /** semctl: cmd-dependent arg handling — can't use SYSCALL_ARGS since arg[3] - * is a scalar for some commands and a pointer for others. */ - private handleSemctl(channel: ChannelInfo, origArgs: number[]): void { - const [semid, semnum, rawCmd, arg] = origArgs; - const cmd = rawCmd & ~IPC_64; - const IPC_STAT = 2; - const GETALL = 13; - const SETALL = 17; + /** + * Marshal msgsnd/msgrcv without exposing the guest's native `long` layout + * to the fixed kernel scratch protocol. + * + * WHY: wasm32 msgbuf has a four-byte mtype prefix while wasm64 uses eight + * bytes. The process range must be proved against that native prefix, but + * Rust always receives one generated, fixed-width i64 header. The complete + * copy/invoke/snapshot operation stays inside one exclusive scratch lease. + */ + private handleSysvMessage( + channel: ChannelInfo, + syscallNr: typeof SYS_MSGSND | typeof SYS_MSGRCV, + origArgs: number[], + rawArgs?: readonly bigint[], + ): void { + const pointerWidth = this.getPtrWidth(channel.pid); + const rawPointer = rawArgs?.[1] ?? origArgs[1] ?? 0; + const rawMessageSize = rawArgs?.[2] ?? origArgs[2] ?? 0; + const sending = syscallNr === SYS_MSGSND; + const flags = sending ? origArgs[3] : origArgs[4]; - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as (offset: KernelPointer, pid: number) => number; - const kernelMem = this.getKernelMem(); - const dataStart = this.scratchOffset + CH_DATA; - - if (cmd === IPC_STAT && arg !== 0) { - // arg is an output pointer to semid_ds (72 bytes) - kernelView.setUint32(CH_SYSCALL, SYS_SEMCTL, true); - kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(semid), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(semnum), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(rawCmd), true); - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(dataStart), true); // redirect to scratch - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(0), true); - kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(0), true); - kernelMem.fill(0, dataStart, dataStart + 72); + try { + if (rawPointer === 0n || rawPointer === 0) { + throw new KernelScratchError("SysV message pointer is null", EFAULT); + } + if ( + (typeof rawMessageSize === "bigint" && ( + rawMessageSize < 0n + || rawMessageSize > BigInt(Number.MAX_SAFE_INTEGER) + )) + || (typeof rawMessageSize === "number" && ( + !Number.isSafeInteger(rawMessageSize) + || rawMessageSize < 0 + )) + ) { + throw new KernelScratchError("invalid SysV message length", EINVAL); + } + const messageSize = Number(rawMessageSize); + const processBytes = pointerWidth + messageSize; + const scratchBytes = + STRUCT_SIZE_WASM_SYSV_MESSAGE_HEADER + messageSize; + if ( + !Number.isSafeInteger(processBytes) + || !Number.isSafeInteger(scratchBytes) + || scratchBytes > CH_DATA_SIZE + ) { + throw new KernelScratchError( + "SysV message exceeds bounded kernel transport", + EINVAL, + ); + } - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; - try { handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); } finally { this.currentHandlePid = 0; } + const processPointer = this.checkedProcessRange( + channel, + rawPointer, + processBytes, + "SysV message caller buffer", + ).pointer; + const processMemory = new Uint8Array(channel.memory.buffer); + // Detach input before acquiring the shared region. Another synchronous + // host callback cannot then replace half of the staged message. + const input = sending + ? processMemory.slice(processPointer, processPointer + processBytes) + : null; + const nativeType = input + ? ( + pointerWidth === 8 + ? new DataView( + input.buffer, + input.byteOffset, + input.byteLength, + ).getBigInt64(0, true) + : BigInt(new DataView( + input.buffer, + input.byteOffset, + input.byteLength, + ).getInt32(0, true)) + ) + : 0n; + + const result = this.requireMainScratchRegion().withLease((lease) => { + const kernelView = lease.dataView(0, CH_TOTAL_SIZE); + lease.fill(0, CH_DATA, scratchBytes); + if (input) { + lease.dataView( + CH_DATA, + STRUCT_SIZE_WASM_SYSV_MESSAGE_HEADER, + ).setBigInt64(0, nativeType, true); + if (messageSize > 0) { + lease.copyFrom( + input, + CH_DATA + STRUCT_SIZE_WASM_SYSV_MESSAGE_HEADER, + pointerWidth, + messageSize, + ); + } + } - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); - if (retVal >= 0) { - // Copy 72-byte struct back to process memory - const processMem = new Uint8Array(channel.memory.buffer); - processMem.set(kernelMem.subarray(dataStart, dataStart + 72), arg); + kernelView.setUint32(CH_SYSCALL, syscallNr, true); + for (let index = 0; index < CH_ARGS_COUNT; index++) { + kernelView.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, 0n, true); + } + kernelView.setBigInt64(CH_ARGS, BigInt(origArgs[0]), true); + lease.writeAddress( + CH_ARGS + CH_ARG_SIZE, + CH_DATA, + scratchBytes, + "u64-le", + ); + kernelView.setBigInt64( + CH_ARGS + 2 * CH_ARG_SIZE, + BigInt(messageSize), + true, + ); + if (sending) { + kernelView.setBigInt64( + CH_ARGS + 3 * CH_ARG_SIZE, + BigInt(flags), + true, + ); + } else { + const rawMessageType = rawArgs?.[3] ?? origArgs[3] ?? 0; + kernelView.setBigInt64( + CH_ARGS + 3 * CH_ARG_SIZE, + typeof rawMessageType === "bigint" + ? rawMessageType + : BigInt(rawMessageType), + true, + ); + kernelView.setBigInt64( + CH_ARGS + 4 * CH_ARG_SIZE, + BigInt(flags), + true, + ); + } + // Rust uses this only to reject an unrepresentable mtype before a + // mixed-width receive removes the message from the queue. + kernelView.setBigInt64( + CH_ARGS + 5 * CH_ARG_SIZE, + BigInt(pointerWidth), + true, + ); + + this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; + try { + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + ]); + } finally { + this.currentHandlePid = 0; + } + + let retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + let errVal = kernelView.getUint32(CH_ERRNO, true); + let canonicalOutput: Uint8Array | null = null; + if (!sending && retVal >= 0) { + if (!Number.isSafeInteger(retVal) || retVal > messageSize) { + retVal = -1; + errVal = EIO; + } else { + canonicalOutput = lease.copyOut( + CH_DATA, + STRUCT_SIZE_WASM_SYSV_MESSAGE_HEADER + retVal, + ); + } + } + return { retVal, errVal, canonicalOutput }; + }); + + this.dequeueSignalForDelivery(channel); + if (this.finishSignalTermination(channel)) return; + + if ( + result.retVal === -1 + && result.errVal === EAGAIN + && (flags & IPC_NOWAIT) === 0 + ) { + this.handleBlockingRetry(channel, syscallNr, origArgs); + return; } - this.completeChannelRaw(channel, retVal, errVal); - this.relistenChannel(channel); - return; + + let outputWrites: ChannelOutputWrite[] | undefined; + if (result.canonicalOutput) { + const canonical = result.canonicalOutput; + const mtype = new DataView( + canonical.buffer, + canonical.byteOffset, + canonical.byteLength, + ).getBigInt64(0, true); + if ( + pointerWidth === 4 + && BigInt.asIntN(32, mtype) !== mtype + ) { + throw new KernelScratchError( + "kernel returned a message type that does not fit caller long", + EIO, + ); + } + const textBytes = + canonical.byteLength - STRUCT_SIZE_WASM_SYSV_MESSAGE_HEADER; + const output = new Uint8Array(pointerWidth + textBytes); + const outputView = new DataView(output.buffer); + if (pointerWidth === 8) { + outputView.setBigInt64(0, mtype, true); + } else { + outputView.setInt32(0, Number(mtype), true); + } + output.set( + canonical.subarray(STRUCT_SIZE_WASM_SYSV_MESSAGE_HEADER), + pointerWidth, + ); + outputWrites = [{ ptr: processPointer, bytes: output }]; + } + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + result.retVal, + result.errVal, + outputWrites, + ); + } catch (error) { + this.rejectScratchTransfer(channel, error); } + } + + private handleIpcControl( + channel: ChannelInfo, + syscallNr: typeof SYS_MSGCTL | typeof SYS_SHMCTL, + origArgs: number[], + rawArgs?: readonly bigint[], + ): void { + const IPC_RMID = 0; + const IPC_SET = 1; + const IPC_STAT = 2; + const objectId = origArgs[0]; + const rawCmd = origArgs[1]; + const cmd = rawCmd & ~IPC_64; + // The live syscall path supplies the original i64 values. Keeping the + // direct-call fallback as a number lets the checked pointer conversion + // reject fractional or unsafe test inputs instead of BigInt coercion + // throwing before the syscall can report EFAULT. + const rawPointer = rawArgs?.[2] ?? origArgs[2] ?? 0; + const pointerWidth = this.getPtrWidth(channel.pid); + const pointerCommand = cmd === IPC_SET || cmd === IPC_STAT; + const outputCommand = cmd === IPC_STAT; - if (cmd === GETALL && arg !== 0) { - // arg is an output pointer to u16[nsems] — allocate generous space - const maxBytes = 1024; // up to 512 semaphores - kernelView.setUint32(CH_SYSCALL, SYS_SEMCTL, true); - kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(semid), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(semnum), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(rawCmd), true); - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(dataStart), true); - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(0), true); - kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(0), true); - kernelMem.fill(0, dataStart, dataStart + maxBytes); + try { + let transferBytes = 0; + let processPointer = 0; + if (pointerCommand) { + if (rawPointer === 0n || rawPointer === 0) { + throw new KernelScratchError("IPC control pointer is null", EFAULT); + } + const exportName = syscallNr === SYS_MSGCTL + ? "kernel_msqid_ds_bytes" + : "kernel_shmid_ds_bytes"; + const structureBytes = this.kernelInstance!.exports[exportName] as + | ((width: number) => number) + | undefined; + if (typeof structureBytes !== "function") { + throw new KernelScratchError( + `${exportName} export is unavailable`, + EIO, + ); + } + transferBytes = structureBytes(pointerWidth); + if ( + !Number.isSafeInteger(transferBytes) + || transferBytes <= 0 + || transferBytes > CH_DATA_SIZE + ) { + if (Number.isSafeInteger(transferBytes) && transferBytes < 0) { + this.completeChannelRaw(channel, -1, -transferBytes); + this.relistenChannel(channel); + return; + } + throw new KernelScratchError( + "kernel returned an invalid IPC control transfer size", + EIO, + ); + } + processPointer = this.checkedProcessRange( + channel, + rawPointer, + transferBytes, + "IPC control caller buffer", + ).pointer; + } else if (cmd !== IPC_RMID) { + // Unknown commands still dispatch so Rust owns the errno decision, + // but no unchecked process pointer crosses into kernel memory. + processPointer = 0; + } + + const processMemory = new Uint8Array(channel.memory.buffer); + const result = this.requireMainScratchRegion().withLease((lease) => { + const kernelView = lease.dataView(0, CH_TOTAL_SIZE); + if (cmd === IPC_SET) { + lease.copyFrom( + processMemory, + CH_DATA, + processPointer, + transferBytes, + ); + } else if (outputCommand) { + lease.fill(0, CH_DATA, transferBytes); + } - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; - try { handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); } finally { this.currentHandlePid = 0; } + kernelView.setUint32(CH_SYSCALL, syscallNr, true); + kernelView.setBigInt64(CH_ARGS, BigInt(objectId), true); + kernelView.setBigInt64( + CH_ARGS + CH_ARG_SIZE, + BigInt(rawCmd), + true, + ); + if (pointerCommand) { + lease.writeAddress( + CH_ARGS + 2 * CH_ARG_SIZE, + CH_DATA, + transferBytes, + "u64-le", + ); + } else { + kernelView.setBigInt64( + CH_ARGS + 2 * CH_ARG_SIZE, + 0n, + true, + ); + } + kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, 0n, true); + kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, 0n, true); + kernelView.setBigInt64( + CH_ARGS + 5 * CH_ARG_SIZE, + BigInt(pointerWidth), + true, + ); - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); - if (retVal >= 0) { - // Copy written data back — kernel wrote u16[] to scratch - const processMem = new Uint8Array(channel.memory.buffer); - processMem.set(kernelMem.subarray(dataStart, dataStart + maxBytes), arg); + this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; + try { + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + ]); + } finally { + this.currentHandlePid = 0; + } + + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + const errVal = kernelView.getUint32(CH_ERRNO, true); + const output = retVal >= 0 && outputCommand + ? lease.copyOut(CH_DATA, transferBytes) + : null; + return { retVal, errVal, output }; + }); + + if (result.output) { + processMemory.set(result.output, processPointer); } - this.completeChannelRaw(channel, retVal, errVal); + this.completeChannelRaw(channel, result.retVal, result.errVal); this.relistenChannel(channel); - return; + } catch (error) { + this.rejectScratchTransfer(channel, error); } + } + + /** semctl: cmd-dependent arg handling — can't use SYSCALL_ARGS since arg[3] + * is a scalar for some commands and a pointer for others. */ + private handleSemctl( + channel: ChannelInfo, + origArgs: number[], + rawArgs?: readonly bigint[], + ): void { + const [semid, semnum, rawCmd, arg] = origArgs; + const rawArg = rawArgs?.[3] ?? arg; + const cmd = rawCmd & ~IPC_64; + const IPC_STAT = 2; + const GETALL = 13; + const SETALL = 17; + const pointerCommand = cmd === IPC_STAT || cmd === GETALL || cmd === SETALL; + const processPointerWidth = this.getPtrWidth(channel.pid); + let transferBytes = 0; + try { + if (pointerCommand) { + if (rawArg === 0n || rawArg === 0) { + throw new KernelScratchError("semctl pointer is null", EFAULT); + } + if (cmd === IPC_STAT) { + const statBytes = this.kernelInstance!.exports + .kernel_semid_ds_bytes as + | ((pointerWidth: number) => number) + | undefined; + if (typeof statBytes !== "function") { + throw new KernelScratchError( + "kernel semid_ds sizing export is unavailable", + EIO, + ); + } + const result = statBytes(processPointerWidth); + if (result < 0) { + this.completeChannelRaw(channel, -1, -result); + this.relistenChannel(channel); + return; + } + transferBytes = result; + } else { + const arrayBytes = this.kernelInstance!.exports + .kernel_semctl_array_bytes as + | (( + pid: number, + tid: number, + semid: number, + command: number, + ) => number) + | undefined; + if (typeof arrayBytes !== "function") { + throw new KernelScratchError( + "kernel semctl array sizing export is unavailable", + EIO, + ); + } + const result = arrayBytes( + channel.pid, + this.guestTidForChannel(channel), + semid, + rawCmd, + ); + if (result < 0) { + this.completeChannelRaw(channel, -1, -result); + this.relistenChannel(channel); + return; + } + transferBytes = result; + } + if ( + !Number.isSafeInteger(transferBytes) + || transferBytes <= 0 + || transferBytes > CH_DATA_SIZE + ) { + throw new KernelScratchError( + "kernel returned an invalid semctl transfer size", + EIO, + ); + } + this.checkedProcessRange( + channel, + rawArg, + transferBytes, + "semctl caller buffer", + ); + } - if (cmd === SETALL && arg !== 0) { - // arg is an input pointer to u16[nsems] — copy generous amount to scratch - const maxBytes = 1024; const processMem = new Uint8Array(channel.memory.buffer); - kernelMem.set(processMem.subarray(arg, arg + maxBytes), dataStart); + const scratch = this.requireMainScratchRegion(); + const result = scratch.withLease((lease) => { + const kernelView = lease.dataView(0, CH_TOTAL_SIZE); + + if (cmd === SETALL) { + const processPointer = checkedWasmPointer( + rawArg, + processPointerWidth, + "semctl caller pointer", + ); + lease.copyFrom(processMem, CH_DATA, processPointer, transferBytes); + } else if (pointerCommand) { + lease.fill(0, CH_DATA, transferBytes); + } - kernelView.setUint32(CH_SYSCALL, SYS_SEMCTL, true); - kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(semid), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(semnum), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(rawCmd), true); - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(dataStart), true); - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(0), true); - kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(0), true); + kernelView.setUint32(CH_SYSCALL, SYS_SEMCTL, true); + kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(semid), true); + kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(semnum), true); + kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(rawCmd), true); + if (pointerCommand) { + lease.writeAddress( + CH_ARGS + 3 * CH_ARG_SIZE, + CH_DATA, + transferBytes, + "u64-le", + ); + } else { + kernelView.setBigInt64( + CH_ARGS + 3 * CH_ARG_SIZE, + BigInt(arg), + true, + ); + } + kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, 0n, true); + // semid_ds follows the calling process data model, which can differ + // from the kernel Wasm's pointer width in a mixed-width machine. + kernelView.setBigInt64( + CH_ARGS + 5 * CH_ARG_SIZE, + BigInt(processPointerWidth), + true, + ); - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; - try { handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); } finally { this.currentHandlePid = 0; } + this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; + try { + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + ]); + } finally { + this.currentHandlePid = 0; + } - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); - this.completeChannelRaw(channel, retVal, errVal); + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + const errVal = kernelView.getUint32(CH_ERRNO, true); + const output = retVal >= 0 && (cmd === IPC_STAT || cmd === GETALL) + ? lease.copyOut(CH_DATA, transferBytes) + : null; + return { retVal, errVal, output }; + }); + + if (result.output) { + const processPointer = checkedWasmPointer( + rawArg, + processPointerWidth, + "semctl caller pointer", + ); + processMem.set(result.output, processPointer); + } + this.completeChannelRaw(channel, result.retVal, result.errVal); this.relistenChannel(channel); - return; + } catch (error) { + this.rejectScratchTransfer(channel, error); } - - // Scalar commands (SETVAL, GETVAL, GETPID, GETNCNT, GETZCNT, IPC_RMID): - // arg is a scalar value, pass through directly to kernel - kernelView.setUint32(CH_SYSCALL, SYS_SEMCTL, true); - kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(semid), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(semnum), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(rawCmd), true); - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(arg), true); - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(0), true); - kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(0), true); - - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; - try { handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); } finally { this.currentHandlePid = 0; } - - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); - this.completeChannelRaw(channel, retVal, errVal); - this.relistenChannel(channel); } private runSyntheticMemorySyscall( @@ -15918,34 +19738,65 @@ export class CentralizedKernelWorker { syscallNr: number, args: number[], ): { retVal: number; errVal: number } { - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - kernelView.setUint32(CH_SYSCALL, syscallNr, true); - for (let i = 0; i < CH_ARGS_COUNT; i++) { - kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, BigInt(args[i] ?? 0), true); - } - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; const previousPid = this.currentHandlePid; this.bindKernelTidForChannel(channel); this.currentHandlePid = channel.pid; + let captured: { retVal: number; errVal: number }; try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + captured = this.requireMainScratchRegion().withLease((lease) => { + const kernelView = lease.dataView(0, CH_TOTAL_SIZE); + kernelView.setUint32(CH_SYSCALL, syscallNr, true); + for (let i = 0; i < CH_ARGS_COUNT; i++) { + kernelView.setBigInt64( + CH_ARGS + i * CH_ARG_SIZE, + BigInt(args[i] ?? 0), + true, + ); + } + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + ]); + const resultView = lease.dataView(0, CH_TOTAL_SIZE); + const rawRetVal = resultView.getBigInt64(CH_RETURN, true); + return this.normalizeKernelSyscallResult( + channel, + syscallNr, + rawRetVal, + resultView.getUint32(CH_ERRNO, true), + ); + }); } finally { this.currentHandlePid = previousPid; } if (this.finishSignalTermination(channel)) { return { retVal: -EINTR_ERRNO, errVal: EINTR_ERRNO }; } - const resultView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - return { - retVal: Number(resultView.getBigInt64(CH_RETURN, true)), - errVal: resultView.getUint32(CH_ERRNO, true), - }; + return captured; } /** shmat: allocate a process interval and attach it to authoritative bytes. */ - private handleIpcShmat(channel: ChannelInfo, args: number[]): void { + private handleIpcShmat( + channel: ChannelInfo, + args: number[], + rawArgs?: readonly bigint[], + ): void { const [shmid, shmaddr, flags] = args; + let checkedShmaddr: number; + try { + // WHY: Number(rawArg) followed by >>> 0 silently aliases a valid wasm64 + // address to its low 32 bits. Preserve the channel's i64 value until the + // caller-width and lossless host-index checks have both succeeded. + checkedShmaddr = checkedWasmPointer( + rawArgs?.[1] ?? shmaddr, + this.getPtrWidth(channel.pid), + "shmat address", + ); + } catch (error) { + this.rejectScratchTransfer(channel, error); + return; + } const callerTid = this.guestTidForChannel(channel); this.validateKernelTid(channel.pid, callerTid); @@ -15961,7 +19812,9 @@ export class CentralizedKernelWorker { channel.pid, callerTid, shmid, - shmaddr, + // The kernel owns attachment accounting but not the process mapping + // address; this legacy ABI slot is intentionally ignored by Rust. + 0, flags, ); if (sizeOrErr < 0) { @@ -15986,7 +19839,7 @@ export class CentralizedKernelWorker { try { const mmap = this.runSyntheticMemorySyscall(channel, SYS_MMAP, [ - shmaddr >>> 0, + checkedShmaddr, size, prot, 0x22, // MAP_PRIVATE | MAP_ANONYMOUS: host supplies sharing. @@ -16002,9 +19855,13 @@ export class CentralizedKernelWorker { this.relistenChannel(channel); return; } - allocatedAddr = mmap.retVal >>> 0; + allocatedAddr = checkedWasmPointer( + mmap.retVal, + this.getPtrWidth(channel.pid), + "shmat mapped address", + ); // Unlike mmap, a non-null shmat address is not merely a fallback hint. - if (shmaddr !== 0 && allocatedAddr !== (shmaddr >>> 0)) { + if (checkedShmaddr !== 0 && allocatedAddr !== checkedShmaddr) { rollback(); if (this.hostReaped?.has(channel.pid)) return; this.completeChannelRaw(channel, -EINVAL, EINVAL); @@ -16017,11 +19874,21 @@ export class CentralizedKernelWorker { channel.memory, SYS_MMAP, allocatedAddr, - [shmaddr, size, prot, 0x22, -1, 0], + [checkedShmaddr, size, prot, 0x22, -1, 0], ); const snapshot = this.readSysvShmRange(shmid, 0, size); const processMem = new Uint8Array(channel.memory.buffer); - if (!snapshot || allocatedAddr + size > processMem.length) { + let mappedRangeValid = false; + try { + this.checkedProcessRange( + channel, + allocatedAddr, + size, + "shmat mapped range", + ); + mappedRangeValid = true; + } catch {} + if (!snapshot || !mappedRangeValid) { rollback(); if (this.hostReaped?.has(channel.pid)) return; this.completeChannelRaw(channel, -EIO, EIO); @@ -16046,7 +19913,8 @@ export class CentralizedKernelWorker { console.error(`[handleIpcShmat] mmap failed for pid=${channel.pid}:`, err); rollback(); if (this.hostReaped?.has(channel.pid)) return; - this.completeChannelRaw(channel, -ENOMEM, ENOMEM); + const errno = err instanceof KernelScratchError ? EIO : ENOMEM; + this.completeChannelRaw(channel, -errno, errno); this.relistenChannel(channel); return; } @@ -16056,10 +19924,26 @@ export class CentralizedKernelWorker { } /** shmdt: publish this attachment, detach exactly once, and unmap it. */ - private handleIpcShmdt(channel: ChannelInfo, args: number[]): void { + private handleIpcShmdt( + channel: ChannelInfo, + args: number[], + rawArgs?: readonly bigint[], + ): void { + let addr: number; + try { + // WHY: attachment keys are native guest pointers. Narrowing with >>> 0 + // would detach an unrelated low wasm32 mapping for a wasm64 caller. + addr = checkedWasmPointer( + rawArgs?.[0] ?? args[0], + this.getPtrWidth(channel.pid), + "shmdt address", + ); + } catch (error) { + this.rejectScratchTransfer(channel, error); + return; + } const callerTid = this.guestTidForChannel(channel); this.validateKernelTid(channel.pid, callerTid); - const addr = args[0] >>> 0; const pidMappings = this.shmMappings.get(channel.pid); if (!pidMappings) { this.completeChannelRaw(channel, -22, 22); // EINVAL @@ -16118,19 +20002,54 @@ export class CentralizedKernelWorker { */ private drainMqueueNotification(): void { const drain = this.kernelInstance!.exports.kernel_mq_drain_notification as - ((outPtr: KernelPointer) => number) | undefined; + (( + outPtr: KernelPointer, + outCapacity: number, + ) => number) | undefined; if (!drain) return; - // Use kernel scratch as output buffer for (pid: u32, signo: u32) - const outOffset = this.scratchOffset; - const hasPending = drain(this.toKernelPtr(outOffset)); - if (hasPending) { - const dv = new DataView(this.kernelMemory!.buffer, outOffset); - const pid = dv.getUint32(0, true); - const signo = dv.getUint32(4, true); - if (signo > 0) { - this.sendSignalToProcess(pid, signo); + const notification = this.requireMainScratchRegion().withLease((lease) => { + const hasPending = lease.invokeKernelExport( + "kernel_mq_drain_notification", + [ + lease.exportPointer( + 0, + KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES, + ), + KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES, + ], + ); + if ( + !Number.isSafeInteger(hasPending) + || hasPending < 0 + || hasPending > 1 + ) { + // WHY: a negative errno is not a truthy "pending" result. Decoding + // the unchanged reusable bytes would fabricate a notification from a + // previous operation. + throw new KernelScratchError( + `kernel mqueue notification drain returned invalid result ${hasPending}`, + Number.isSafeInteger(hasPending) && hasPending < 0 + ? -hasPending + : EIO, + ); } + if (hasPending === 0) return null; + const view = lease.dataView( + 0, + KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES, + ); + return { + pid: view.getUint32(0, true), + signo: view.getUint32(4, true), + }; + }); + // Rust has already queued SI_MESGQ with its full-width sigval. + // sendSignalToProcess reuses main scratch, so release the output lease + // before waking and processing the detached notification. + if (notification && notification.signo > 0) { + this.wakePendingSignalWaits(notification.pid, notification.signo); + this.sendSignalToProcess(notification.pid, notification.signo, false); } } diff --git a/host/src/kernel.ts b/host/src/kernel.ts index 6393850238..c9cc53f7ab 100644 --- a/host/src/kernel.ts +++ b/host/src/kernel.ts @@ -25,12 +25,75 @@ import { runGlQuery } from "./webgl/query"; import { SubmitQueue } from "./webgl/submit-queue"; import { GlMuxer } from "./webgl/muxer"; import { drainSubmitQueue } from "./webgl/submit-drain"; -import { STRUCT_SIZE_WASM_DIRENT, STRUCT_SIZE_WASM_STAT } from "./generated/abi"; +import { + IOCTL_REQUESTS, + KERNEL_SCRATCH_FD_PAIR_BYTES, + KERNEL_SCRATCH_SOCKLEN_BYTES, + SELECT_FD_SET_BYTES, + SELECT_FD_SETSIZE, + STRUCT_SIZE_WASM_DIRENT, + STRUCT_SIZE_WASM_POLL_FD, + STRUCT_SIZE_WASM_STAT, + STRUCT_SIZE_WASM_STATFS, + STRUCT_SIZE_WPK_DRM_MODE_MODEINFO, + WASM_POLL_FD_EVENTS_OFFSET, + WASM_POLL_FD_FD_OFFSET, + WASM_POLL_FD_REVENTS_OFFSET, +} from "./generated/abi"; import { detectPtrWidth } from "./constants"; +import { + allocateKernelScratchRegion, + checkedWasmImportMemoryRange, + checkedWasmPointer, + intrinsicUint8ArrayView, + KernelScratchError, + type KernelScratchRegion, +} from "./kernel-scratch"; export type KernelPointer = number | bigint; const MAX_U64 = (1n << 64n) - 1n; +const intrinsicApply = Reflect.apply; +const intrinsicArrayBufferIsView = ArrayBuffer.isView; +const intrinsicArrayBufferByteLength = Object.getOwnPropertyDescriptor( + ArrayBuffer.prototype, + "byteLength", +)!.get!; +const intrinsicDataViewBuffer = Object.getOwnPropertyDescriptor( + DataView.prototype, + "buffer", +)!.get!; +const intrinsicDataViewByteOffset = Object.getOwnPropertyDescriptor( + DataView.prototype, + "byteOffset", +)!.get!; +const intrinsicDataViewByteLength = Object.getOwnPropertyDescriptor( + DataView.prototype, + "byteLength", +)!.get!; +const intrinsicTypedArrayPrototype = Object.getPrototypeOf( + Uint8Array.prototype, +); +const intrinsicTypedArrayBuffer = Object.getOwnPropertyDescriptor( + intrinsicTypedArrayPrototype, + "buffer", +)!.get!; +const intrinsicTypedArrayByteOffset = Object.getOwnPropertyDescriptor( + intrinsicTypedArrayPrototype, + "byteOffset", +)!.get!; +const intrinsicTypedArrayByteLength = Object.getOwnPropertyDescriptor( + intrinsicTypedArrayPrototype, + "byteLength", +)!.get!; +const IntrinsicUint8Array = Uint8Array; +const intrinsicUint8ArraySet = Uint8Array.prototype.set; + +interface IntrinsicBufferSourceSpan { + buffer: ArrayBufferLike; + byteOffset: number; + byteLength: number; +} function exactU64(value: number | bigint, field: string): bigint { if (typeof value === "bigint") { @@ -45,13 +108,88 @@ function exactU64(value: number | bigint, field: string): bigint { throw error; } +function intrinsicBufferSourceSpan( + source: BufferSource, +): IntrinsicBufferSourceSpan { + try { + if (!intrinsicArrayBufferIsView(source)) { + return { + buffer: source, + byteOffset: 0, + byteLength: intrinsicApply( + intrinsicArrayBufferByteLength, + source, + [], + ) as number, + }; + } + + try { + return { + buffer: intrinsicApply( + intrinsicDataViewBuffer, + source, + [], + ) as ArrayBufferLike, + byteOffset: intrinsicApply( + intrinsicDataViewByteOffset, + source, + [], + ) as number, + byteLength: intrinsicApply( + intrinsicDataViewByteLength, + source, + [], + ) as number, + }; + } catch { + return { + buffer: intrinsicApply( + intrinsicTypedArrayBuffer, + source, + [], + ) as ArrayBufferLike, + byteOffset: intrinsicApply( + intrinsicTypedArrayByteOffset, + source, + [], + ) as number, + byteLength: intrinsicApply( + intrinsicTypedArrayByteLength, + source, + [], + ) as number, + }; + } + } catch { + throw new TypeError( + "kernel WebAssembly bytes must be an attached, genuine BufferSource", + ); + } +} + function bufferSourceToArrayBuffer(source: BufferSource): ArrayBuffer { - const view = source instanceof ArrayBuffer - ? new Uint8Array(source) - : new Uint8Array(source.buffer, source.byteOffset, source.byteLength); - const copy = new Uint8Array(view.byteLength); - copy.set(view); - return copy.buffer; + const span = intrinsicBufferSourceSpan(source); + let exactView: Uint8Array; + try { + exactView = new IntrinsicUint8Array( + span.buffer, + span.byteOffset, + span.byteLength, + ); + } catch { + throw new TypeError( + "kernel WebAssembly bytes must be an attached, genuine BufferSource", + ); + } + + const snapshot = new IntrinsicUint8Array(span.byteLength); + intrinsicApply(intrinsicUint8ArraySet, snapshot, [exactView]); + return intrinsicApply( + intrinsicTypedArrayBuffer, + snapshot, + [], + ) as ArrayBuffer; } const DEFAULT_KMS_MODE_WIDTH = 1920; @@ -72,7 +210,7 @@ function kmsModeInfoBytes( const vsyncEnd = clampU16(h + 8); const vtotal = clampU16(h + 45); const clock = Math.max(1, Math.min(0xffffffff, Math.round(htotal * vtotal * refreshHz / 1000))); - const out = new Uint8Array(68); + const out = new Uint8Array(STRUCT_SIZE_WPK_DRM_MODE_MODEINFO); const dv = new DataView(out.buffer); dv.setUint32(0, clock, true); dv.setUint16(4, w, true); @@ -211,7 +349,7 @@ export function negErrno(err: unknown): number { const WASM_STAT_SIZE = STRUCT_SIZE_WASM_STAT; /** Size of the WasmStatfs struct in bytes (repr(C) layout). */ -const WASM_STATFS_SIZE = 72; +const WASM_STATFS_SIZE = STRUCT_SIZE_WASM_STATFS; /** Size of the WasmDirent struct: d_ino(u64) + d_type(u32) + d_namlen(u32). */ const WASM_DIRENT_SIZE = STRUCT_SIZE_WASM_DIRENT; @@ -268,6 +406,20 @@ export class WasmPosixKernel { private instance: WebAssembly.Instance | null = null; private memory: WebAssembly.Memory | null = null; private kernelPtrWidth: 4 | 8 = 4; + /** + * One wrapper owns exactly one kernel Wasm generation. + * + * WHY: allocator-owned scratch regions retain the instance, Memory, pointer, + * and capacity that created them. Replacing only `instance`/`memory` would + * leave those regions authorized against the old generation. Rejecting a + * second initialization before it mutates any state keeps that lifetime + * invariant structural instead of relying on every cached region being + * remembered during a future reinitialization. + */ + private initializationState: + | "uninitialized" + | "initializing" + | "initialized" = "uninitialized"; private sharedPipes = new Map(); private signalWakeSab: SharedArrayBuffer | null = null; private programFuncTable: WebAssembly.Table | null = null; @@ -293,7 +445,10 @@ export class WasmPosixKernel { { mappingRefs: number; descriptorClosePending: boolean } >(); /** Active synchronous host_fstat capture used by mmap preflight. */ - private fstatHandleCapture: { handle: number | null } | null = null; + private fstatHandleCapture: { + token: object; + handle: number | null; + } | null = null; isThreadWorker = false; /** * Live `/dev/fb0` mappings the kernel has reported via @@ -378,32 +533,72 @@ export class WasmPosixKernel { } toKernelPtr(value: number | bigint): KernelPointer { - const numberValue = typeof value === "bigint" ? Number(value) : value; - if (!Number.isSafeInteger(numberValue) || numberValue < 0) { - throw new Error(`invalid kernel pointer ${String(value)}`); - } + const numberValue = checkedWasmPointer( + value, + this.kernelPtrWidth, + "kernel export pointer", + ); return this.kernelPtrWidth === 8 ? BigInt(numberValue) : numberValue; } /** - * Capture the concrete host handle used by one synchronous kernel fstat. - * This lets MAP_SHARED retain the open-file capability itself instead of - * reopening a remembered pathname that may already have been unlinked. + * Losslessly convert one kernel `usize` value for a host API that stores an + * address, offset, or length as a JavaScript number. + * + * WHY: `Number(bigint)` silently rounds above MAX_SAFE_INTEGER and bitwise + * operators silently discard every bit above bit 31. Device metadata is not + * scratch, but it still must not alias a different process-memory range. */ - withFstatHandleCapture(operation: () => T): { - result: T; - handle: number | null; - } { + private checkedKernelIndex(value: KernelPointer, field: string): number { + return checkedWasmPointer(value, this.kernelPtrWidth, field); + } + + private checkedKernelSpan( + offsetValue: KernelPointer, + lengthValue: KernelPointer, + limit: number, + field: string, + ): { offset: number; length: number; end: number } { + const offset = this.checkedKernelIndex(offsetValue, `${field} offset`); + const length = this.checkedKernelIndex(lengthValue, `${field} length`); + if (!Number.isSafeInteger(limit) || limit < 0) { + throw new KernelScratchError(`${field} has an invalid capacity`); + } + const end = offset + length; + if (!Number.isSafeInteger(end) || end < offset || end > limit) { + throw new KernelScratchError(`${field} exceeds its declared capacity`); + } + return { offset, length, end }; + } + + /** + * Begin capturing the concrete host handle used by one synchronous fstat. + * + * WHY: the worker must invoke the kernel export directly inside its active + * scratch lease; accepting an opaque callback here would let a primitive + * scratch address cross a boundary that cannot revoke it. The token makes + * the begin/finish pair exact while a `finally` at the caller preserves the + * synchronous capture lifetime. + */ + beginFstatHandleCapture(): object { if (this.fstatHandleCapture) { throw new Error("nested host fstat handle capture"); } - const capture = { handle: null as number | null }; - this.fstatHandleCapture = capture; - try { - return { result: operation(), handle: capture.handle }; - } finally { - this.fstatHandleCapture = null; + const token = {}; + this.fstatHandleCapture = { token, handle: null }; + return token; + } + + /** + * Finish the exact synchronous fstat capture started by the matching token. + */ + finishFstatHandleCapture(token: object): number | null { + const capture = this.fstatHandleCapture; + if (!capture || capture.token !== token) { + throw new Error("mismatched host fstat handle capture"); } + this.fstatHandleCapture = null; + return capture.handle; } /** Retain one mapping-owned reference to an existing host file handle. */ @@ -443,8 +638,8 @@ export class WasmPosixKernel { } } - private createKernelMemory(): WebAssembly.Memory { - if (this.kernelPtrWidth === 8) { + private createKernelMemory(pointerWidth: 4 | 8): WebAssembly.Memory { + if (pointerWidth === 8) { return new WebAssembly.Memory({ initial: 24n, maximum: 16384n, @@ -487,20 +682,54 @@ export class WasmPosixKernel { * memory in processes that never play sound. ~64 KiB is comfortably * larger than any single drain call would ask for. */ - private audioScratchOffset = 0; + private audioScratchRegion: KernelScratchRegion | null = null; private static readonly AUDIO_SCRATCH_SIZE = 65536; + private apiScratchRegion: KernelScratchRegion | null = null; + private static readonly API_SCRATCH_SIZE = 65536; + + private requireApiScratch(): KernelScratchRegion { + if (this.apiScratchRegion) return this.apiScratchRegion; + if (!this.memory) { + throw new Error("kernel memory is not initialized"); + } + const allocator = this.instance?.exports.kernel_alloc_scratch as + | ((size: number) => KernelPointer) + | undefined; + if (!allocator) { + throw new Error("kernel is missing its scratch allocator"); + } + this.apiScratchRegion = allocateKernelScratchRegion( + this.memory, + allocator, + WasmPosixKernel.API_SCRATCH_SIZE, + this.kernelPtrWidth, + "kernel public API scratch", + this.instance!, + ); + return this.apiScratchRegion; + } private ensureAudioScratch(): boolean { - if (this.audioScratchOffset !== 0) return true; + if (this.audioScratchRegion) return true; + if (!this.memory) return false; const exports = this.instance?.exports as Record | undefined; const alloc = exports?.kernel_alloc_scratch as | ((size: number) => bigint | number) | undefined; if (!alloc) return false; - const off = Number(alloc(WasmPosixKernel.AUDIO_SCRATCH_SIZE)); - if (off === 0) return false; - this.audioScratchOffset = off; - return true; + try { + this.audioScratchRegion = allocateKernelScratchRegion( + this.memory, + alloc, + WasmPosixKernel.AUDIO_SCRATCH_SIZE, + this.kernelPtrWidth, + "kernel audio scratch", + this.instance!, + ); + return true; + } catch { + return false; + } } /** @@ -515,20 +744,25 @@ export class WasmPosixKernel { */ drainAudio(out: Uint8Array): number { const exports = this.instance?.exports as Record | undefined; - const drain = exports?.kernel_drain_audio as - | ((ptr: KernelPointer, len: number) => number) - | undefined; - if (!drain || !this.memory || !this.ensureAudioScratch()) return 0; + if ( + typeof exports?.kernel_drain_audio !== "function" + || !this.memory + || !this.ensureAudioScratch() + ) return 0; // Cap the request at our scratch size. Typical drain rates // (~22 ms of stereo S16 @ 44.1 kHz = ~7.7 KiB per call) are well // under the cap; callers needing more invoke drainAudio in a loop. - const want = Math.min(out.byteLength, WasmPosixKernel.AUDIO_SCRATCH_SIZE); - const n = drain(this.toKernelPtr(this.audioScratchOffset), want); - if (n > 0) { - const src = new Uint8Array(this.memory.buffer, this.audioScratchOffset, n); - out.set(src.subarray(0, n)); - } - return n; + const region = this.audioScratchRegion!; + const want = Math.min(out.byteLength, region.capacity); + return region.withLease((scratch) => { + const n = scratch.invokeKernelExport("kernel_drain_audio", [ + scratch.exportPointer(0, want), + want, + ]); + if (!Number.isSafeInteger(n) || n < 0 || n > want) return 0; + if (n > 0) scratch.copyTo(out, 0, 0, n); + return n; + }); } /** @@ -583,117 +817,173 @@ export class WasmPosixKernel { } /** - * Load and instantiate the kernel Wasm module. + * Load and instantiate the kernel Wasm module exactly once. * * @param wasmBytes - The compiled kernel Wasm binary */ async init(wasmBytes: BufferSource): Promise { - this.kernelPtrWidth = detectPtrWidth(bufferSourceToArrayBuffer(wasmBytes)); - const memory = this.createKernelMemory(); - this.memory = memory; - const importObject = this.buildImportObject(memory); - const module = await WebAssembly.compile(wasmBytes as BufferSource); - this.instance = await WebAssembly.instantiate(module, importObject); + this.beginInitialization(); + try { + const { module, pointerWidth } = + await this.compileKernelModule(wasmBytes); + this.kernelPtrWidth = pointerWidth; + const memory = this.createKernelMemory(pointerWidth); + this.memory = memory; + const importObject = this.buildImportObject(memory); + this.instance = await WebAssembly.instantiate(module, importObject); + this.initializationState = "initialized"; + } catch (error) { + this.abortInitialization(error); + } } /** * Like init(), but uses an existing shared WebAssembly.Memory instead of * creating a new one. Used by thread workers that share the parent's memory. + * + * A WasmPosixKernel owns one kernel generation, so this and init() are + * mutually exclusive one-shot entry points. */ - async initWithMemory(wasmBytes: BufferSource, memory: WebAssembly.Memory): Promise { - this.kernelPtrWidth = detectPtrWidth(bufferSourceToArrayBuffer(wasmBytes)); - this.memory = memory; - const importObject = this.buildImportObject(memory); - const module = await WebAssembly.compile(wasmBytes as BufferSource); - this.instance = await WebAssembly.instantiate(module, importObject); + async initWithMemory( + wasmBytes: BufferSource, + memory: WebAssembly.Memory, + ): Promise { + this.beginInitialization(); + try { + const { module, pointerWidth } = + await this.compileKernelModule(wasmBytes); + this.kernelPtrWidth = pointerWidth; + this.memory = memory; + const importObject = this.buildImportObject(memory); + this.instance = await WebAssembly.instantiate(module, importObject); + this.initializationState = "initialized"; + } catch (error) { + this.abortInitialization(error); + } + } + + private beginInitialization(): void { + if (this.initializationState === "initializing") { + throw new Error("kernel initialization is already in progress"); + } + if (this.initializationState === "initialized") { + throw new Error( + "kernel is already initialized; create a new WasmPosixKernel " + + "for a different kernel generation", + ); + } + this.initializationState = "initializing"; + } + + private async compileKernelModule( + wasmBytes: BufferSource, + ): Promise<{ module: WebAssembly.Module; pointerWidth: 4 | 8 }> { + // WHY: view subclasses can spoof public span getters. Pointer-width + // parsing and engine compilation must consume one identical immutable + // snapshot or imports can normalize every pointer for the wrong Wasm ABI. + const wasmSnapshot = bufferSourceToArrayBuffer(wasmBytes); + const pointerWidth = detectPtrWidth(wasmSnapshot); + const module = await WebAssembly.compile(wasmSnapshot); + return { module, pointerWidth }; + } + + private abortInitialization(error: unknown): never { + // A failed first attempt has created no usable kernel generation. Clear + // the partially published import state so callers may retry cleanly. + this.instance = null; + this.memory = null; + this.kernelPtrWidth = 4; + this.initializationState = "uninitialized"; + throw error; } private buildImportObject(memory: WebAssembly.Memory): WebAssembly.Imports { return { env: { memory, - host_debug_log: (ptr: bigint, len: number): void => { - const buf = new Uint8Array(memory.buffer, Number(ptr), len); - const msg = new TextDecoder().decode(buf.slice()); + host_debug_log: (ptr: KernelPointer, len: number): void => { + const msg = new TextDecoder().decode( + this.readKernelBytes(ptr, len), + ); console.log(`[KERNEL] ${msg}`); }, - host_open: (pathPtr: bigint, pathLen: number, flags: number, mode: number): bigint => { - return this.hostOpen(Number(pathPtr), pathLen, flags, mode); + host_open: (pathPtr: KernelPointer, pathLen: number, flags: number, mode: number): bigint => { + return this.hostOpen(pathPtr, pathLen, flags, mode); }, host_close: (handle: bigint): number => { return this.hostClose(handle); }, - host_read: (handle: bigint, bufPtr: bigint, bufLen: number): number => { - return this.hostRead(handle, Number(bufPtr), bufLen); + host_read: (handle: bigint, bufPtr: KernelPointer, bufLen: number): number => { + return this.hostRead(handle, bufPtr, bufLen); }, - host_write: (handle: bigint, bufPtr: bigint, bufLen: number): number => { - return this.hostWrite(handle, Number(bufPtr), bufLen); + host_write: (handle: bigint, bufPtr: KernelPointer, bufLen: number): number => { + return this.hostWrite(handle, bufPtr, bufLen); }, host_seek: (handle: bigint, offsetLo: number, offsetHi: number, whence: number): bigint => { return this.hostSeek(handle, offsetLo, offsetHi, whence); }, - host_fstat: (handle: bigint, statPtr: bigint): number => { - return this.hostFstat(handle, Number(statPtr)); + host_fstat: (handle: bigint, statPtr: KernelPointer): number => { + return this.hostFstat(handle, statPtr); }, - host_stat: (pathPtr: bigint, pathLen: number, statPtr: bigint): number => { - return this.hostStat(Number(pathPtr), pathLen, Number(statPtr)); + host_stat: (pathPtr: KernelPointer, pathLen: number, statPtr: KernelPointer): number => { + return this.hostStat(pathPtr, pathLen, statPtr); }, - host_lstat: (pathPtr: bigint, pathLen: number, statPtr: bigint): number => { - return this.hostLstat(Number(pathPtr), pathLen, Number(statPtr)); + host_lstat: (pathPtr: KernelPointer, pathLen: number, statPtr: KernelPointer): number => { + return this.hostLstat(pathPtr, pathLen, statPtr); }, - host_statfs: (pathPtr: bigint, pathLen: number, statfsPtr: bigint): number => { - return this.hostStatfs(Number(pathPtr), pathLen, Number(statfsPtr)); + host_statfs: (pathPtr: KernelPointer, pathLen: number, statfsPtr: KernelPointer): number => { + return this.hostStatfs(pathPtr, pathLen, statfsPtr); }, - host_pathconf: (pathPtr: bigint, pathLen: number, name: number, valuePtr: bigint): number => { - return this.hostPathconf(Number(pathPtr), pathLen, name, Number(valuePtr)); + host_pathconf: (pathPtr: KernelPointer, pathLen: number, name: number, valuePtr: KernelPointer): number => { + return this.hostPathconf(pathPtr, pathLen, name, valuePtr); }, - host_fpathconf: (handle: bigint, name: number, valuePtr: bigint): number => { - return this.hostFpathconf(handle, name, Number(valuePtr)); + host_fpathconf: (handle: bigint, name: number, valuePtr: KernelPointer): number => { + return this.hostFpathconf(handle, name, valuePtr); }, - host_mkdir: (pathPtr: bigint, pathLen: number, mode: number): number => { - return this.hostMkdir(Number(pathPtr), pathLen, mode); + host_mkdir: (pathPtr: KernelPointer, pathLen: number, mode: number): number => { + return this.hostMkdir(pathPtr, pathLen, mode); }, - host_rmdir: (pathPtr: bigint, pathLen: number): number => { - return this.hostRmdir(Number(pathPtr), pathLen); + host_rmdir: (pathPtr: KernelPointer, pathLen: number): number => { + return this.hostRmdir(pathPtr, pathLen); }, - host_unlink: (pathPtr: bigint, pathLen: number): number => { - return this.hostUnlink(Number(pathPtr), pathLen); + host_unlink: (pathPtr: KernelPointer, pathLen: number): number => { + return this.hostUnlink(pathPtr, pathLen); }, - host_rename: (oldPtr: bigint, oldLen: number, newPtr: bigint, newLen: number): number => { - return this.hostRename(Number(oldPtr), oldLen, Number(newPtr), newLen); + host_rename: (oldPtr: KernelPointer, oldLen: number, newPtr: KernelPointer, newLen: number): number => { + return this.hostRename(oldPtr, oldLen, newPtr, newLen); }, - host_link: (oldPtr: bigint, oldLen: number, newPtr: bigint, newLen: number): number => { - return this.hostLink(Number(oldPtr), oldLen, Number(newPtr), newLen); + host_link: (oldPtr: KernelPointer, oldLen: number, newPtr: KernelPointer, newLen: number): number => { + return this.hostLink(oldPtr, oldLen, newPtr, newLen); }, - host_symlink: (targetPtr: bigint, targetLen: number, linkPtr: bigint, linkLen: number): number => { - return this.hostSymlink(Number(targetPtr), targetLen, Number(linkPtr), linkLen); + host_symlink: (targetPtr: KernelPointer, targetLen: number, linkPtr: KernelPointer, linkLen: number): number => { + return this.hostSymlink(targetPtr, targetLen, linkPtr, linkLen); }, - host_readlink: (pathPtr: bigint, pathLen: number, bufPtr: bigint, bufLen: number): number => { - return this.hostReadlink(Number(pathPtr), pathLen, Number(bufPtr), bufLen); + host_readlink: (pathPtr: KernelPointer, pathLen: number, bufPtr: KernelPointer, bufLen: number): number => { + return this.hostReadlink(pathPtr, pathLen, bufPtr, bufLen); }, - host_chmod: (pathPtr: bigint, pathLen: number, mode: number): number => { - return this.hostChmod(Number(pathPtr), pathLen, mode); + host_chmod: (pathPtr: KernelPointer, pathLen: number, mode: number): number => { + return this.hostChmod(pathPtr, pathLen, mode); }, - host_chown: (pathPtr: bigint, pathLen: number, uid: number, gid: number): number => { - return this.hostChown(Number(pathPtr), pathLen, uid, gid); + host_chown: (pathPtr: KernelPointer, pathLen: number, uid: number, gid: number): number => { + return this.hostChown(pathPtr, pathLen, uid, gid); }, - host_lchown: (pathPtr: bigint, pathLen: number, uid: number, gid: number): number => { - return this.hostLchown(Number(pathPtr), pathLen, uid, gid); + host_lchown: (pathPtr: KernelPointer, pathLen: number, uid: number, gid: number): number => { + return this.hostLchown(pathPtr, pathLen, uid, gid); }, - host_access: (pathPtr: bigint, pathLen: number, amode: number): number => { - return this.hostAccess(Number(pathPtr), pathLen, amode); + host_access: (pathPtr: KernelPointer, pathLen: number, amode: number): number => { + return this.hostAccess(pathPtr, pathLen, amode); }, - host_opendir: (pathPtr: bigint, pathLen: number): bigint => { - return this.hostOpendir(Number(pathPtr), pathLen); + host_opendir: (pathPtr: KernelPointer, pathLen: number): bigint => { + return this.hostOpendir(pathPtr, pathLen); }, - host_readdir: (dirHandle: bigint, direntPtr: bigint, namePtr: bigint, nameLen: number): number => { - return this.hostReaddir(dirHandle, Number(direntPtr), Number(namePtr), nameLen); + host_readdir: (dirHandle: bigint, direntPtr: KernelPointer, namePtr: KernelPointer, nameLen: number): number => { + return this.hostReaddir(dirHandle, direntPtr, namePtr, nameLen); }, host_closedir: (dirHandle: bigint): number => { return this.hostClosedir(dirHandle); }, - host_clock_gettime: (clockId: number, secPtr: bigint, nsecPtr: bigint): number => { - return this.hostClockGettime(clockId, Number(secPtr), Number(nsecPtr)); + host_clock_gettime: (clockId: number, secPtr: KernelPointer, nsecPtr: KernelPointer): number => { + return this.hostClockGettime(clockId, secPtr, nsecPtr); }, host_nanosleep: (sec: bigint, nsec: bigint): number => { return this.hostNanosleep(sec, nsec); @@ -710,8 +1000,8 @@ export class WasmPosixKernel { host_fchown: (handle: bigint, uid: number, gid: number): number => { return this.hostFchown(handle, uid, gid); }, - host_exec: (pathPtr: bigint, pathLen: number): number => { - return this.hostExec(Number(pathPtr), pathLen); + host_exec: (pathPtr: KernelPointer, pathLen: number): number => { + return this.hostExec(pathPtr, pathLen); }, host_set_alarm: (seconds: number): number => { return this.hostSetAlarm(seconds); @@ -748,42 +1038,49 @@ export class WasmPosixKernel { } return -22; // EINVAL }, - host_getrandom: (bufPtr: bigint, bufLen: number): number => { + host_getrandom: (bufPtr: KernelPointer, bufLen: number): number => { try { - const mem = this.getMemoryBuffer(); - const ptr = Number(bufPtr); - const target = mem.subarray(ptr, ptr + bufLen); + const destination = checkedWasmImportMemoryRange( + memory, + bufPtr, + bufLen, + this.kernelPtrWidth, + "host_getrandom destination", + ); + const random = new Uint8Array(destination.length); if (typeof globalThis.crypto !== "undefined" && globalThis.crypto.getRandomValues) { - // crypto.getRandomValues rejects SharedArrayBuffer-backed views in browsers. - // Use a temporary non-shared buffer and copy. - const tmp = new Uint8Array(bufLen); - globalThis.crypto.getRandomValues(tmp); - target.set(tmp); + // crypto.getRandomValues rejects SharedArrayBuffer-backed views + // in browsers. The owned temporary also ensures no host callback + // retains a live view of kernel memory. + globalThis.crypto.getRandomValues(random); } else { - for (let i = 0; i < bufLen; i++) target[i] = (Math.random() * 256) | 0; + for (let i = 0; i < bufLen; i++) { + random[i] = (Math.random() * 256) | 0; + } } + this.writeKernelBytes(bufPtr, bufLen, random); return bufLen; - } catch { - return -5; // EIO + } catch (error) { + return negErrno(error); } }, host_utimensat: ( - pathPtr: bigint, pathLen: number, + pathPtr: KernelPointer, pathLen: number, atimeSec: bigint, atimeNsec: bigint, mtimeSec: bigint, mtimeNsec: bigint, ): number => { - return this.hostUtimensat(Number(pathPtr), pathLen, atimeSec, atimeNsec, mtimeSec, mtimeNsec); + return this.hostUtimensat(pathPtr, pathLen, atimeSec, atimeNsec, mtimeSec, mtimeNsec); }, - host_waitpid: (pid: number, options: number, statusPtr: bigint): number => { - return this.hostWaitpid(pid, options, Number(statusPtr)); + host_waitpid: (pid: number, options: number, statusPtr: KernelPointer): number => { + return this.hostWaitpid(pid, options, statusPtr); }, - host_net_connect: (handle: number, addrPtr: bigint, addrLen: number, port: number): number => { - return this.hostNetConnect(handle, Number(addrPtr), addrLen, port); + host_net_connect: (handle: number, addrPtr: KernelPointer, addrLen: number, port: number): number => { + return this.hostNetConnect(handle, addrPtr, addrLen, port); }, - host_net_send: (handle: number, bufPtr: bigint, bufLen: number, flags: number): number => { - return this.hostNetSend(handle, Number(bufPtr), bufLen, flags); + host_net_send: (handle: number, bufPtr: KernelPointer, bufLen: number, flags: number): number => { + return this.hostNetSend(handle, bufPtr, bufLen, flags); }, - host_net_recv: (handle: number, bufPtr: bigint, bufLen: number, flags: number): number => { - return this.hostNetRecv(handle, Number(bufPtr), bufLen, flags); + host_net_recv: (handle: number, bufPtr: KernelPointer, bufLen: number, flags: number): number => { + return this.hostNetRecv(handle, bufPtr, bufLen, flags); }, host_net_poll: (handle: number, events: number): number => { return this.hostNetPoll(handle, events); @@ -806,22 +1103,22 @@ export class WasmPosixKernel { host_udp_send: ( srcA: number, srcB: number, srcC: number, srcD: number, srcPort: number, dstA: number, dstB: number, dstC: number, dstD: number, dstPort: number, - dataPtr: bigint, dataLen: number, + dataPtr: KernelPointer, dataLen: number, ): number => { return this.hostUdpSend( srcA, srcB, srcC, srcD, srcPort, dstA, dstB, dstC, dstD, dstPort, - Number(dataPtr), dataLen, + dataPtr, dataLen, ); }, - host_getaddrinfo: (namePtr: bigint, nameLen: number, resultPtr: bigint, resultLen: number): number => { - return this.hostGetaddrinfo(Number(namePtr), nameLen, Number(resultPtr), resultLen); + host_getaddrinfo: (namePtr: KernelPointer, nameLen: number, resultPtr: KernelPointer, resultLen: number): number => { + return this.hostGetaddrinfo(namePtr, nameLen, resultPtr, resultLen); }, - host_futex_wait: (addr: bigint, expected: number, timeoutLo: number, timeoutHi: number): number => { - return this.hostFutexWait(Number(addr), expected, timeoutLo, timeoutHi); + host_futex_wait: (addr: KernelPointer, expected: number, timeoutLo: number, timeoutHi: number): number => { + return this.hostFutexWait(addr, expected, timeoutLo, timeoutHi); }, - host_futex_wake: (addr: bigint, count: number): number => { - return this.hostFutexWake(Number(addr), count); + host_futex_wake: (addr: KernelPointer, count: number): number => { + return this.hostFutexWake(addr, count); }, host_is_thread_worker: (): number => { return this.isThreadWorker ? 1 : 0; @@ -831,13 +1128,19 @@ export class WasmPosixKernel { // metadata; whether anything renders is the consuming app's // choice (canvas in browser, no-op in Node tests). host_bind_framebuffer: ( - pid: number, addr: bigint, len: bigint, + pid: number, addr: KernelPointer, len: KernelPointer, w: number, h: number, stride: number, fmt: number, ): void => { + const binding = this.checkedKernelSpan( + addr, + len, + Number.MAX_SAFE_INTEGER, + "host_bind_framebuffer process range", + ); this.framebuffers.bind({ pid, - addr: Number(addr), - len: Number(len), + addr: binding.offset, + len: binding.length, w, h, stride, @@ -851,14 +1154,14 @@ export class WasmPosixKernel { }, host_fb_write: ( pid: number, - offset: bigint, - srcPtr: bigint, - len: bigint, + offset: KernelPointer, + srcPtr: KernelPointer, + len: KernelPointer, ): void => { this.framebuffers.fbWrite( pid, - Number(offset), - this.readKernelBytes(Number(srcPtr), Number(len)), + this.checkedKernelIndex(offset, "host_fb_write offset"), + this.readKernelBytes(srcPtr, len), ); }, // /dev/dri/renderD128 hooks. v1 CpuShared tier: pixel storage @@ -872,8 +1175,21 @@ export class WasmPosixKernel { h: number, stride: number, ): number => { - this.bos.create({ pid, bo_id, size: Number(size), w, h, stride }); - return 0; + let checkedSize: number; + try { + checkedSize = this.checkedKernelIndex( + size, + "host_gbm_bo_create size", + ); + } catch { + return -75; // EOVERFLOW + } + try { + this.bos.create({ pid, bo_id, size: checkedSize, w, h, stride }); + return 0; + } catch { + return -12; // ENOMEM + } }, host_gbm_bo_destroy: (pid: number, bo_id: number): void => { this.bos.destroy(pid, bo_id); @@ -881,16 +1197,35 @@ export class WasmPosixKernel { host_gbm_bo_bind: ( pid: number, bo_id: number, - addr: bigint, - len: bigint, + addr: KernelPointer, + len: KernelPointer, ): number => { - return this.bos.bind(pid, bo_id, Number(addr), Number(len)); + try { + // The worker grows and primes process memory after this callback, + // so current process-memory bounds are not yet meaningful. The + // Rust BO owns this mapping contract; the registry caps later + // copies to the BO's size and rechecks current memory bounds. + const binding = this.checkedKernelSpan( + addr, + len, + Number.MAX_SAFE_INTEGER, + "host_gbm_bo_bind BO range", + ); + return this.bos.bind( + pid, + bo_id, + binding.offset, + binding.length, + ); + } catch { + return -75; // EOVERFLOW + } }, host_gbm_bo_unbind: ( pid: number, bo_id: number, - _addr: bigint, - _len: bigint, + _addr: KernelPointer, + _len: KernelPointer, ): void => { this.bos.unbind(pid, bo_id); }, @@ -900,11 +1235,17 @@ export class WasmPosixKernel { // canvas the create-context call leaves `b.gl = null` and // submit/query become silent no-ops, so kernels that haven't // wired a renderer (Node tests, headless smoke runs) stay safe. - host_gl_bind: (pid: number, addr: bigint, len: bigint): void => { + host_gl_bind: (pid: number, addr: KernelPointer, len: KernelPointer): void => { + const binding = this.checkedKernelSpan( + addr, + len, + Number.MAX_SAFE_INTEGER, + "host_gl_bind process range", + ); this.gl.bind({ pid, - cmdbufAddr: Number(addr), - cmdbufLen: Number(len), + cmdbufAddr: binding.offset, + cmdbufLen: binding.length, }); }, host_gl_unbind: (pid: number): void => { @@ -912,7 +1253,7 @@ export class WasmPosixKernel { }, host_gl_create_context: ( pid: number, ctxId: number, - _attrsPtr: bigint, _attrsLen: bigint, + _attrsPtr: KernelPointer, _attrsLen: KernelPointer, ): void => { const b = this.gl.get(pid); if (!b) return; @@ -979,7 +1320,7 @@ export class WasmPosixKernel { }, host_gl_create_surface: ( pid: number, surfaceId: number, - _attrsPtr: bigint, _attrsLen: bigint, + _attrsPtr: KernelPointer, _attrsLen: KernelPointer, ): void => { const b = this.gl.get(pid); if (b) b.surfaceId = surfaceId; @@ -995,11 +1336,22 @@ export class WasmPosixKernel { // track ctx + surface ids on the binding. }, host_gl_submit: ( - pid: number, offset: bigint, length: bigint, + pid: number, offset: KernelPointer, length: KernelPointer, ): number => { const b = this.gl.get(pid); if (!b) return -5; // EIO: kernel/host GL state diverged. if (!b.forward && !b.gl) return 0; + let submission: { offset: number; length: number; end: number }; + try { + submission = this.checkedKernelSpan( + offset, + length, + b.cmdbufLen, + "host_gl_submit command range", + ); + } catch { + return -22; // EINVAL + } if (!b.cmdbufView) { const memory = this.callbacks.getProcessMemory?.(pid); if (!memory) return -5; // EIO @@ -1023,17 +1375,21 @@ export class WasmPosixKernel { } } if (b.forward) { - const off = Number(offset); - const len = Number(length); - const rc = validateCommandBuffer(b.cmdbufView, off, len); + const rc = validateCommandBuffer( + b.cmdbufView, + submission.offset, + submission.length, + ); if (rc < 0) return rc; - b.forward.onSubmit(b.cmdbufView.slice(off, off + len)); + b.forward.onSubmit( + b.cmdbufView.slice(submission.offset, submission.end), + ); return 0; } this.gl_submit_queue.enqueue(b, { memorySab: b.cmdbufView.buffer as ArrayBufferLike, - off: Number(offset), - len: Number(length), + off: submission.offset, + len: submission.length, }); return drainSubmitQueue( this.gl_submit_queue, @@ -1055,18 +1411,56 @@ export class WasmPosixKernel { }, host_gl_query: ( pid: number, op: number, - inPtr: bigint, inLen: bigint, - outPtr: bigint, outLen: bigint, + inPtr: KernelPointer, inLen: KernelPointer, + outPtr: KernelPointer, outLen: KernelPointer, ): number => { const b = this.gl.get(pid); if (!b || !b.gl) return -1; - const inBuf = inLen > 0n - ? this.readKernelBytes(Number(inPtr), Number(inLen)) - : new Uint8Array(0); - const outBuf = new Uint8Array(Number(outLen)); + let inputLength: number; + let outputLength: number; + try { + inputLength = this.checkedKernelIndex( + inLen, + "host_gl_query input length", + ); + outputLength = this.checkedKernelIndex( + outLen, + "host_gl_query output length", + ); + if (outputLength > 0) { + if (!this.memory) return -5; + // Preflight before touching WebGL state. A bad Rust destination + // must not execute a query and only then discover EFAULT. + checkedWasmImportMemoryRange( + this.memory, + outPtr, + outputLength, + this.kernelPtrWidth, + "host_gl_query destination", + ); + } + } catch { + return -14; // EFAULT + } + let inBuf: Uint8Array; + try { + inBuf = inputLength > 0 + ? this.readKernelBytes(inPtr, inputLength) + : new Uint8Array(0); + } catch { + return -14; // EFAULT + } + const outBuf = new Uint8Array(outputLength); const written = runGlQuery(b, op, inBuf, outBuf); - if (written > 0 && Number(outPtr) !== 0) { - this.writeKernelBytes(Number(outPtr), outBuf.subarray(0, written)); + if (!Number.isSafeInteger(written)) return -5; + if (written < 0) return written; + if (written > outputLength) return -5; + if (written > 0) { + this.writeKernelBytes( + outPtr, + outputLength, + outBuf.subarray(0, written), + ); } return written; }, @@ -1074,15 +1468,31 @@ export class WasmPosixKernel { host_kms_drop_master: (_pid: number): void => { this.kms.dropMaster(); }, host_proc_write_bytes: ( pid: number, - addr: bigint, - src_ptr: bigint, + addr: number, + src_ptr: KernelPointer, len: number, ): number => { const procMem = this.callbacks.getProcessMemory?.(pid); if (!procMem) return -14; try { - const src = this.readKernelBytes(Number(src_ptr), len); - new Uint8Array(procMem.buffer, Number(addr), len).set(src); + checkedWasmImportMemoryRange( + procMem, + addr, + len, + 4, + "host_proc_write_bytes process destination", + ); + const src = this.readKernelBytes(src_ptr, len); + // Reacquire the process buffer after copying the kernel source: + // another process worker may have grown it in the meantime. + const destination = checkedWasmImportMemoryRange( + procMem, + addr, + len, + 4, + "host_proc_write_bytes process destination", + ); + new Uint8Array(procMem.buffer).set(src, destination.pointer); return 0; } catch { return -14; @@ -1090,27 +1500,51 @@ export class WasmPosixKernel { }, host_proc_read_bytes: ( pid: number, - addr: bigint, - dst_ptr: bigint, + addr: number, + dst_ptr: KernelPointer, len: number, ): number => { const procMem = this.callbacks.getProcessMemory?.(pid); if (!procMem) return -14; try { - const src = new Uint8Array(procMem.buffer, Number(addr), len); - const copy = new Uint8Array(len); - copy.set(src); - this.writeKernelBytes(Number(dst_ptr), copy); + if (!this.memory) return -5; + // Prove the Rust-owned destination before reading caller bytes so + // an invalid kernel range cannot consume a source operation. + checkedWasmImportMemoryRange( + this.memory, + dst_ptr, + len, + this.kernelPtrWidth, + "host_proc_read_bytes kernel destination", + ); + const source = checkedWasmImportMemoryRange( + procMem, + addr, + len, + 4, + "host_proc_read_bytes process source", + ); + const copy = new Uint8Array( + procMem.buffer, + source.pointer, + source.length, + ).slice(); + this.writeKernelBytes(dst_ptr, len, copy); return 0; } catch { return -14; } }, - host_kms_mode_info: (connector_id: number, out_ptr: bigint): void => { + host_kms_mode_info: ( + connector_id: number, + out_ptr: KernelPointer, + ): void => { const canvas = this.callbacks.getKmsCanvas?.(connector_id); + const bytes = kmsModeInfoBytes(canvas?.width, canvas?.height); this.writeKernelBytes( - Number(out_ptr), - kmsModeInfoBytes(canvas?.width, canvas?.height), + out_ptr, + STRUCT_SIZE_WPK_DRM_MODE_MODEINFO, + bytes, ); }, host_kms_addfb: ( @@ -1134,14 +1568,21 @@ export class WasmPosixKernel { } /** - * Access the Wasm memory (e.g. for tests or advanced use). + * UNSAFE trusted-embedder escape hatch for tests and low-level diagnostics. + * + * Direct mutation bypasses KernelScratchRegion ownership, capacity, and + * lifetime checks. Runtime transfer code must use the typed public methods + * instead of pairing this memory with allocator exports. */ getMemory(): WebAssembly.Memory | null { return this.memory; } /** - * Access the Wasm instance (e.g. to call exported functions). + * UNSAFE trusted-embedder escape hatch for tests and low-level diagnostics. + * + * Calling pointer-returning exports and writing through getMemory() is + * outside the checked scratch-transfer contract. */ getInstance(): WebAssembly.Instance | null { return this.instance; @@ -1156,29 +1597,52 @@ export class WasmPosixKernel { return new Uint8Array(this.memory.buffer); } - private getMemoryDataView(): DataView { - if (!this.memory) { - throw new Error("Kernel not initialized"); - } - return new DataView(this.memory.buffer); - } - /** Copy `len` bytes from kernel memory at `ptr` into a non-shared * Uint8Array. Used by host imports that consume kernel-scratch * payloads (e.g. host_fb_write). */ - private readKernelBytes(ptr: number, len: number): Uint8Array { - const out = new Uint8Array(len); - out.set(this.getMemoryBuffer().subarray(ptr, ptr + len)); - return out; + private readKernelBytes( + ptr: KernelPointer, + len: number | bigint, + ): Uint8Array { + if (!this.memory) throw new Error("Kernel not initialized"); + const range = checkedWasmImportMemoryRange( + this.memory, + ptr, + len, + this.kernelPtrWidth, + "kernel import source", + ); + return this.getMemoryBuffer().slice(range.pointer, range.end); } /** Write `bytes` into kernel memory at `ptr`. Used by host imports * that return kernel-scratch payloads (e.g. host_gl_query, * host_kms_mode_info, host_proc_read_bytes). */ - private writeKernelBytes(ptr: number, bytes: Uint8Array): void { - this.getMemoryBuffer().set(bytes, ptr); + private writeKernelBytes( + ptr: KernelPointer, + capacity: number | bigint, + bytes: Uint8Array, + ): void { + if (!this.memory) throw new Error("Kernel not initialized"); + const range = checkedWasmImportMemoryRange( + this.memory, + ptr, + capacity, + this.kernelPtrWidth, + "kernel import destination", + ); + const exactBytes = intrinsicUint8ArrayView( + bytes, + "kernel import output", + ); + if (exactBytes.byteLength > range.length) { + throw new Error( + `kernel import output ${exactBytes.byteLength} exceeds capacity ${range.length}`, + ); + } + this.getMemoryBuffer().set(exactBytes, range.pointer); } /** @@ -1191,14 +1655,13 @@ export class WasmPosixKernel { * operations internally, so the promise resolves immediately. */ private hostOpen( - pathPtr: number, + pathPtr: KernelPointer, pathLen: number, flags: number, mode: number, ): bigint { try { - const mem = this.getMemoryBuffer(); - const pathBytes = mem.slice(pathPtr, pathPtr + pathLen); + const pathBytes = this.readKernelBytes(pathPtr, pathLen); const path = new TextDecoder().decode(pathBytes); return BigInt(this.io.open(path, flags, mode)); } catch (e) { @@ -1252,15 +1715,56 @@ export class WasmPosixKernel { * For handle 0 (stdin): return 0 (no stdin support yet). * Other handles: delegate to PlatformIO. */ - private hostRead(handle: bigint, bufPtr: number, bufLen: number): number { + private hostRead( + handle: bigint, + bufPtr: KernelPointer, + bufLen: number, + ): number { const h = Number(handle); + let destinationCapacity: number; + try { + if (!this.memory) return -5; + destinationCapacity = checkedWasmImportMemoryRange( + this.memory, + bufPtr, + bufLen, + this.kernelPtrWidth, + "host_read destination", + ).length; + } catch { + return -14; // EFAULT + } + // WHY: never lend a live view of Rust-owned memory to PlatformIO. A + // backend can accidentally retain that view or reenter the kernel. Stage + // into host memory, validate the producer count, then publish once through + // the pointer-plus-capacity helper. + const staged = new Uint8Array(destinationCapacity); + const publish = (result: number): number => { + if ( + !Number.isSafeInteger(result) + || result < 0 + || result > destinationCapacity + ) { + return -5; + } + if (result > 0) { + try { + this.writeKernelBytes( + bufPtr, + destinationCapacity, + staged.subarray(0, result), + ); + } catch { + return -14; + } + } + return result; + }; // Check shared pipe registry const readEntry = this.sharedPipes.get(h); if (readEntry) { - const mem = this.getMemoryBuffer(); - const dst = new Uint8Array(mem.buffer, bufPtr, bufLen); - return readEntry.pipe.read(dst); + return publish(readEntry.pipe.read(staged)); } // stdin @@ -1268,19 +1772,32 @@ export class WasmPosixKernel { if (this.callbacks.onStdin) { const data = this.callbacks.onStdin(bufLen); if (data === null) return 0; // EOF - if (data.length === 0) return -11; // EAGAIN — no data yet, retry later - const mem = this.getMemoryBuffer(); - const n = Math.min(data.length, bufLen); - mem.set(data.subarray(0, n), bufPtr); - return n; + let exactData: Uint8Array; + try { + exactData = intrinsicUint8ArrayView(data, "stdin callback output"); + } catch { + return -5; // EIO: the callback violated its byte-source contract. + } + if (exactData.byteLength === 0) { + return -11; // EAGAIN — no data yet, retry later + } + const n = Math.min(exactData.byteLength, destinationCapacity); + staged.set( + new Uint8Array( + exactData.buffer, + exactData.byteOffset, + n, + ), + ); + return publish(n); } return 0; // EOF when no stdin callback } try { - const mem = this.getMemoryBuffer(); - const buf = mem.subarray(bufPtr, bufPtr + bufLen); - return this.io.read(h, buf, null, bufLen); + return publish( + this.io.read(h, staged, null, destinationCapacity), + ); } catch (e) { return negErrno(e); } @@ -1293,11 +1810,18 @@ export class WasmPosixKernel { * falls back to process.stdout/stderr (Node.js), then console (browser). * Other handles: delegate to PlatformIO. */ - private hostWrite(handle: bigint, bufPtr: number, bufLen: number): number { + private hostWrite( + handle: bigint, + bufPtr: KernelPointer, + bufLen: number, + ): number { const h = Number(handle); - const mem = this.getMemoryBuffer(); - const data = mem.slice(bufPtr, bufPtr + bufLen); - + let data: Uint8Array; + try { + data = this.readKernelBytes(bufPtr, bufLen); + } catch (error) { + return negErrno(error); + } // Check shared pipe registry const writeEntry = this.sharedPipes.get(h); @@ -1328,7 +1852,12 @@ export class WasmPosixKernel { } try { - return this.io.write(h, data, null, bufLen); + const written = this.io.write(h, data, null, data.byteLength); + return Number.isSafeInteger(written) + && written >= 0 + && written <= data.byteLength + ? written + : -5; } catch (e) { return negErrno(e); } @@ -1380,7 +1909,7 @@ export class WasmPosixKernel { * 80: st_ctime_nsec u32 * 84: _pad u32 */ - private hostFstat(handle: bigint, statPtr: number): number { + private hostFstat(handle: bigint, statPtr: KernelPointer): number { const h = Number(handle); try { @@ -1396,43 +1925,45 @@ export class WasmPosixKernel { /** * Write a StatResult into the WasmStat struct at the given Wasm memory offset. */ - private writeStatToMemory(ptr: number, stat: StatResult): void { - const dv = this.getMemoryDataView(); - - // Zero out the struct first (handles padding bytes). - const mem = this.getMemoryBuffer(); - mem.fill(0, ptr, ptr + WASM_STAT_SIZE); + private writeStatToMemory(ptr: KernelPointer, stat: StatResult): void { + // Build the complete structure in host-owned memory, then publish it only + // after the pointer and Rust-declared fixed capacity have both passed. + const bytes = new Uint8Array(WASM_STAT_SIZE); + const dv = new DataView(bytes.buffer); - dv.setBigUint64(ptr + 0, exactU64(stat.dev, "st_dev"), true); // st_dev - dv.setBigUint64(ptr + 8, exactU64(stat.ino, "st_ino"), true); // st_ino - dv.setUint32(ptr + 16, stat.mode, true); // st_mode - dv.setUint32(ptr + 20, stat.nlink, true); // st_nlink - dv.setUint32(ptr + 24, stat.uid, true); // st_uid - dv.setUint32(ptr + 28, stat.gid, true); // st_gid - dv.setBigUint64(ptr + 32, BigInt(stat.size), true); // st_size + dv.setBigUint64(0, exactU64(stat.dev, "st_dev"), true); // st_dev + dv.setBigUint64(8, exactU64(stat.ino, "st_ino"), true); // st_ino + dv.setUint32(16, stat.mode, true); // st_mode + dv.setUint32(20, stat.nlink, true); // st_nlink + dv.setUint32(24, stat.uid, true); // st_uid + dv.setUint32(28, stat.gid, true); // st_gid + dv.setBigUint64(32, BigInt(stat.size), true); // st_size // Convert millisecond timestamps to seconds + nanoseconds. const atimeSec = Math.floor(stat.atimeMs / 1000); const atimeNsec = Math.floor((stat.atimeMs % 1000) * 1_000_000); - dv.setBigUint64(ptr + 40, BigInt(atimeSec), true); // st_atime_sec - dv.setUint32(ptr + 48, atimeNsec, true); // st_atime_nsec + dv.setBigUint64(40, BigInt(atimeSec), true); // st_atime_sec + dv.setUint32(48, atimeNsec, true); // st_atime_nsec const mtimeSec = Math.floor(stat.mtimeMs / 1000); const mtimeNsec = Math.floor((stat.mtimeMs % 1000) * 1_000_000); - dv.setBigUint64(ptr + 56, BigInt(mtimeSec), true); // st_mtime_sec - dv.setUint32(ptr + 64, mtimeNsec, true); // st_mtime_nsec + dv.setBigUint64(56, BigInt(mtimeSec), true); // st_mtime_sec + dv.setUint32(64, mtimeNsec, true); // st_mtime_nsec const ctimeSec = Math.floor(stat.ctimeMs / 1000); const ctimeNsec = Math.floor((stat.ctimeMs % 1000) * 1_000_000); - dv.setBigUint64(ptr + 72, BigInt(ctimeSec), true); // st_ctime_sec - dv.setUint32(ptr + 80, ctimeNsec, true); // st_ctime_nsec + dv.setBigUint64(72, BigInt(ctimeSec), true); // st_ctime_sec + dv.setUint32(80, ctimeNsec, true); // st_ctime_nsec // _pad at offset 84 already zeroed + this.writeKernelBytes(ptr, WASM_STAT_SIZE, bytes); } - private writeStatfsToMemory(ptr: number, statfs: StatfsResult): void { - const dv = this.getMemoryDataView(); - const mem = this.getMemoryBuffer(); - mem.fill(0, ptr, ptr + WASM_STATFS_SIZE); + private writeStatfsToMemory( + ptr: KernelPointer, + statfs: StatfsResult, + ): void { + const bytes = new Uint8Array(WASM_STATFS_SIZE); + const dv = new DataView(bytes.buffer); const u32 = (value: number): number => { if (!Number.isFinite(value)) return 0; @@ -1443,17 +1974,18 @@ export class WasmPosixKernel { return BigInt(Math.min(Math.floor(value), Number.MAX_SAFE_INTEGER)); }; - dv.setUint32(ptr + 0, u32(statfs.type), true); - dv.setUint32(ptr + 4, u32(statfs.bsize), true); - dv.setBigUint64(ptr + 8, u64(statfs.blocks), true); - dv.setBigUint64(ptr + 16, u64(statfs.bfree), true); - dv.setBigUint64(ptr + 24, u64(statfs.bavail), true); - dv.setBigUint64(ptr + 32, u64(statfs.files), true); - dv.setBigUint64(ptr + 40, u64(statfs.ffree), true); - dv.setBigUint64(ptr + 48, u64(statfs.fsid), true); - dv.setUint32(ptr + 56, u32(statfs.namelen), true); - dv.setUint32(ptr + 60, u32(statfs.frsize), true); - dv.setUint32(ptr + 64, u32(statfs.flags), true); + dv.setUint32(0, u32(statfs.type), true); + dv.setUint32(4, u32(statfs.bsize), true); + dv.setBigUint64(8, u64(statfs.blocks), true); + dv.setBigUint64(16, u64(statfs.bfree), true); + dv.setBigUint64(24, u64(statfs.bavail), true); + dv.setBigUint64(32, u64(statfs.files), true); + dv.setBigUint64(40, u64(statfs.ffree), true); + dv.setBigUint64(48, u64(statfs.fsid), true); + dv.setUint32(56, u32(statfs.namelen), true); + dv.setUint32(60, u32(statfs.frsize), true); + dv.setUint32(64, u32(statfs.flags), true); + this.writeKernelBytes(ptr, WASM_STATFS_SIZE, bytes); } // ---- Phase 2: Path-based and directory host imports ---- @@ -1461,9 +1993,8 @@ export class WasmPosixKernel { /** * Read a UTF-8 path string from Wasm memory. */ - private readPathFromMemory(ptr: number, len: number): string { - const mem = this.getMemoryBuffer(); - const pathBytes = mem.slice(ptr, ptr + len); + private readPathFromMemory(ptr: KernelPointer, len: number): string { + const pathBytes = this.readKernelBytes(ptr, len); return new TextDecoder().decode(pathBytes); } @@ -1471,9 +2002,9 @@ export class WasmPosixKernel { * host_stat(path_ptr, path_len, stat_ptr) -> i32 */ private hostStat( - pathPtr: number, + pathPtr: KernelPointer, pathLen: number, - statPtr: number, + statPtr: KernelPointer, ): number { try { const path = this.readPathFromMemory(pathPtr, pathLen); @@ -1489,9 +2020,9 @@ export class WasmPosixKernel { * host_lstat(path_ptr, path_len, stat_ptr) -> i32 */ private hostLstat( - pathPtr: number, + pathPtr: KernelPointer, pathLen: number, - statPtr: number, + statPtr: KernelPointer, ): number { try { const path = this.readPathFromMemory(pathPtr, pathLen); @@ -1504,9 +2035,9 @@ export class WasmPosixKernel { } private hostStatfs( - pathPtr: number, + pathPtr: KernelPointer, pathLen: number, - statfsPtr: number, + statfsPtr: KernelPointer, ): number { try { const path = this.readPathFromMemory(pathPtr, pathLen); @@ -1519,19 +2050,17 @@ export class WasmPosixKernel { } private hostPathconf( - pathPtr: number, + pathPtr: KernelPointer, pathLen: number, name: number, - valuePtr: number, + valuePtr: KernelPointer, ): number { try { const path = this.readPathFromMemory(pathPtr, pathLen); const value = this.io.pathconf(path, name); - this.getMemoryDataView().setBigInt64( - valuePtr, - BigInt(value ?? -1), - true, - ); + const bytes = new Uint8Array(8); + new DataView(bytes.buffer).setBigInt64(0, BigInt(value ?? -1), true); + this.writeKernelBytes(valuePtr, bytes.byteLength, bytes); return 0; } catch (e) { return negErrno(e); @@ -1541,15 +2070,13 @@ export class WasmPosixKernel { private hostFpathconf( handle: bigint, name: number, - valuePtr: number, + valuePtr: KernelPointer, ): number { try { const value = this.io.fpathconf(Number(handle), name); - this.getMemoryDataView().setBigInt64( - valuePtr, - BigInt(value ?? -1), - true, - ); + const bytes = new Uint8Array(8); + new DataView(bytes.buffer).setBigInt64(0, BigInt(value ?? -1), true); + this.writeKernelBytes(valuePtr, bytes.byteLength, bytes); return 0; } catch (e) { return negErrno(e); @@ -1560,7 +2087,7 @@ export class WasmPosixKernel { * host_mkdir(path_ptr, path_len, mode) -> i32 */ private hostMkdir( - pathPtr: number, + pathPtr: KernelPointer, pathLen: number, mode: number, ): number { @@ -1576,7 +2103,7 @@ export class WasmPosixKernel { /** * host_rmdir(path_ptr, path_len) -> i32 */ - private hostRmdir(pathPtr: number, pathLen: number): number { + private hostRmdir(pathPtr: KernelPointer, pathLen: number): number { try { const path = this.readPathFromMemory(pathPtr, pathLen); this.io.rmdir(path); @@ -1589,7 +2116,7 @@ export class WasmPosixKernel { /** * host_unlink(path_ptr, path_len) -> i32 */ - private hostUnlink(pathPtr: number, pathLen: number): number { + private hostUnlink(pathPtr: KernelPointer, pathLen: number): number { try { const path = this.readPathFromMemory(pathPtr, pathLen); this.io.unlink(path); @@ -1603,9 +2130,9 @@ export class WasmPosixKernel { * host_rename(old_ptr, old_len, new_ptr, new_len) -> i32 */ private hostRename( - oldPtr: number, + oldPtr: KernelPointer, oldLen: number, - newPtr: number, + newPtr: KernelPointer, newLen: number, ): number { try { @@ -1622,9 +2149,9 @@ export class WasmPosixKernel { * host_link(old_ptr, old_len, new_ptr, new_len) -> i32 */ private hostLink( - oldPtr: number, + oldPtr: KernelPointer, oldLen: number, - newPtr: number, + newPtr: KernelPointer, newLen: number, ): number { try { @@ -1641,9 +2168,9 @@ export class WasmPosixKernel { * host_symlink(target_ptr, target_len, link_ptr, link_len) -> i32 */ private hostSymlink( - targetPtr: number, + targetPtr: KernelPointer, targetLen: number, - linkPtr: number, + linkPtr: KernelPointer, linkLen: number, ): number { try { @@ -1662,18 +2189,25 @@ export class WasmPosixKernel { * Returns the number of bytes written to the buffer, or -1 on error. */ private hostReadlink( - pathPtr: number, + pathPtr: KernelPointer, pathLen: number, - bufPtr: number, + bufPtr: KernelPointer, bufLen: number, ): number { try { const path = this.readPathFromMemory(pathPtr, pathLen); + if (!this.memory) return -5; + checkedWasmImportMemoryRange( + this.memory, + bufPtr, + bufLen, + this.kernelPtrWidth, + "host_readlink destination", + ); const target = this.io.readlink(path); const encoded = new TextEncoder().encode(target); const n = Math.min(encoded.length, bufLen); - const mem = this.getMemoryBuffer(); - mem.set(encoded.subarray(0, n), bufPtr); + this.writeKernelBytes(bufPtr, bufLen, encoded.subarray(0, n)); return n; } catch (e) { return negErrno(e); @@ -1684,7 +2218,7 @@ export class WasmPosixKernel { * host_chmod(path_ptr, path_len, mode) -> i32 */ private hostChmod( - pathPtr: number, + pathPtr: KernelPointer, pathLen: number, mode: number, ): number { @@ -1701,7 +2235,7 @@ export class WasmPosixKernel { * host_chown(path_ptr, path_len, uid, gid) -> i32 */ private hostChown( - pathPtr: number, + pathPtr: KernelPointer, pathLen: number, uid: number, gid: number, @@ -1719,7 +2253,7 @@ export class WasmPosixKernel { * host_lchown(path_ptr, path_len, uid, gid) -> i32 */ private hostLchown( - pathPtr: number, + pathPtr: KernelPointer, pathLen: number, uid: number, gid: number, @@ -1737,7 +2271,7 @@ export class WasmPosixKernel { * host_access(path_ptr, path_len, amode) -> i32 */ private hostAccess( - pathPtr: number, + pathPtr: KernelPointer, pathLen: number, amode: number, ): number { @@ -1754,7 +2288,7 @@ export class WasmPosixKernel { * host_utimensat(path_ptr, path_len, atime_sec, atime_nsec, mtime_sec, mtime_nsec) -> i32 */ private hostUtimensat( - pathPtr: number, + pathPtr: KernelPointer, pathLen: number, atimeSec: bigint, atimeNsec: bigint, @@ -1778,8 +2312,27 @@ export class WasmPosixKernel { private hostWaitpid( pid: number, options: number, - statusPtr: number, + statusPtr: KernelPointer, ): number { + const hasStatus = typeof statusPtr === "bigint" + ? statusPtr !== 0n + : statusPtr !== 0; + if (hasStatus) { + try { + if (!this.memory) return -5; + // Validate before either wait backend can consume a child state. The + // final write repeats this proof against the then-current buffer. + checkedWasmImportMemoryRange( + this.memory, + statusPtr, + 4, + this.kernelPtrWidth, + "host_waitpid status destination", + ); + } catch { + return -14; // EFAULT + } + } // If we have a waitpid callback + SAB, use blocking host delegation if (this.waitpidSab && this.callbacks.onWaitpid) { const view = new Int32Array(this.waitpidSab); @@ -1799,9 +2352,14 @@ export class WasmPosixKernel { return resultPid; // negative errno } - if (statusPtr !== 0 && this.memory) { - const dv = new DataView(this.memory.buffer); - dv.setInt32(statusPtr, resultStatus, true); + if (hasStatus) { + const bytes = new Uint8Array(4); + new DataView(bytes.buffer).setInt32(0, resultStatus, true); + try { + this.writeKernelBytes(statusPtr, bytes.byteLength, bytes); + } catch { + return -14; // EFAULT + } } return resultPid; } @@ -1810,16 +2368,22 @@ export class WasmPosixKernel { if (!this.io.waitpid) { return -10; // -ECHILD } + let result: { pid: number; status: number }; try { - const result = this.io.waitpid(pid, options); - if (statusPtr !== 0 && this.memory) { - const view = new DataView(this.memory.buffer); - view.setInt32(statusPtr, result.status, true); - } - return result.pid; + result = this.io.waitpid(pid, options); } catch { return -10; // -ECHILD } + if (hasStatus) { + const bytes = new Uint8Array(4); + new DataView(bytes.buffer).setInt32(0, result.status, true); + try { + this.writeKernelBytes(statusPtr, bytes.byteLength, bytes); + } catch { + return -14; // EFAULT + } + } + return result.pid; } /** @@ -1827,7 +2391,7 @@ export class WasmPosixKernel { * * Returns a directory handle as i64, or -1 on error. */ - private hostOpendir(pathPtr: number, pathLen: number): bigint { + private hostOpendir(pathPtr: KernelPointer, pathLen: number): bigint { try { const path = this.readPathFromMemory(pathPtr, pathLen); const handle = this.io.opendir(path); @@ -1848,8 +2412,8 @@ export class WasmPosixKernel { */ private hostReaddir( dirHandle: bigint, - direntPtr: number, - namePtr: number, + direntPtr: KernelPointer, + namePtr: KernelPointer, nameLen: number, ): number { try { @@ -1862,19 +2426,34 @@ export class WasmPosixKernel { dirEntry = next; } - const dv = this.getMemoryDataView(); - const mem = this.getMemoryBuffer(); - // Write WasmDirent: d_ino(u64) + d_type(u32) + d_namlen(u32) const encoded = new TextEncoder().encode(dirEntry.name); const n = Math.min(encoded.length, nameLen); - - dv.setBigUint64(direntPtr, BigInt(dirEntry.ino), true); - dv.setUint32(direntPtr + 8, dirEntry.type, true); - dv.setUint32(direntPtr + 12, n, true); - - // Write name - mem.set(encoded.subarray(0, n), namePtr); + if (!this.memory) throw new Error("Kernel not initialized"); + // Preflight both destinations before publishing either half of the + // aggregate record. A retry must never observe a new dirent paired with + // stale name bytes. + checkedWasmImportMemoryRange( + this.memory, + direntPtr, + WASM_DIRENT_SIZE, + this.kernelPtrWidth, + "host_readdir dirent destination", + ); + checkedWasmImportMemoryRange( + this.memory, + namePtr, + nameLen, + this.kernelPtrWidth, + "host_readdir name destination", + ); + const dirent = new Uint8Array(WASM_DIRENT_SIZE); + const view = new DataView(dirent.buffer); + view.setBigUint64(0, BigInt(dirEntry.ino), true); + view.setUint32(8, dirEntry.type, true); + view.setUint32(12, n, true); + this.writeKernelBytes(direntPtr, WASM_DIRENT_SIZE, dirent); + this.writeKernelBytes(namePtr, nameLen, encoded.subarray(0, n)); this.pendingDirectoryEntries.delete(h); return 1; @@ -1908,17 +2487,39 @@ export class WasmPosixKernel { */ private hostClockGettime( clockId: number, - secPtr: number, - nsecPtr: number, + secPtr: KernelPointer, + nsecPtr: KernelPointer, ): number { try { const result = this.io.clockGettime(clockId); - const dv = this.getMemoryDataView(); - dv.setBigInt64(secPtr, BigInt(result.sec), true); - dv.setBigInt64(nsecPtr, BigInt(result.nsec), true); + if (!this.memory) throw new Error("Kernel not initialized"); + checkedWasmImportMemoryRange( + this.memory, + secPtr, + 8, + this.kernelPtrWidth, + "host_clock_gettime seconds destination", + ); + checkedWasmImportMemoryRange( + this.memory, + nsecPtr, + 8, + this.kernelPtrWidth, + "host_clock_gettime nanoseconds destination", + ); + const seconds = new Uint8Array(8); + const nanoseconds = new Uint8Array(8); + new DataView(seconds.buffer).setBigInt64(0, BigInt(result.sec), true); + new DataView(nanoseconds.buffer).setBigInt64( + 0, + BigInt(result.nsec), + true, + ); + this.writeKernelBytes(secPtr, seconds.byteLength, seconds); + this.writeKernelBytes(nsecPtr, nanoseconds.byteLength, nanoseconds); return 0; - } catch { - return -1; + } catch (error) { + return negErrno(error); } } @@ -1940,6 +2541,8 @@ export class WasmPosixKernel { // ---- Phase 11: ftruncate/fsync/fchmod/fchown host imports ---- private hostFtruncate(handle: bigint, length: bigint): number { + if (length < 0n) return -22; // EINVAL + if (length > BigInt(Number.MAX_SAFE_INTEGER)) return -75; // EOVERFLOW try { this.io.ftruncate(Number(handle), Number(length)); return 0; @@ -1983,11 +2586,16 @@ export class WasmPosixKernel { // ---- Phase 13e: Exec ---- - private hostExec(pathPtr: number, pathLen: number): number { + private hostExec(pathPtr: KernelPointer, pathLen: number): number { if (this.callbacks.onExec) { - const mem = this.getMemoryBuffer(); - const path = new TextDecoder().decode(mem.slice(pathPtr, pathPtr + pathLen)); - return this.callbacks.onExec(path); + try { + const path = new TextDecoder().decode( + this.readKernelBytes(pathPtr, pathLen), + ); + return this.callbacks.onExec(path); + } catch (error) { + return negErrno(error); + } } return -2; // -ENOENT } @@ -2053,22 +2661,18 @@ export class WasmPosixKernel { * Returns [fd0, fd1]. */ socketpair(domain: number, type: number, protocol: number): [number, number] { - const fn = this.instance!.exports.kernel_socketpair as ( - domain: number, - type: number, - protocol: number, - svPtr: number, - ) => number; - // Use a scratch area in Wasm memory for the two i32 results. - // We use offset 0 of the data buffer (safe for temp use since no - // concurrent host operations touch it). - const dv = this.getMemoryDataView(); - const scratchPtr = 4; // offset 4 to avoid address 0 - const result = fn(domain, type, protocol, scratchPtr); - if (result < 0) throw new Error(`socketpair failed: errno ${-result}`); - const fd0 = dv.getInt32(scratchPtr, true); - const fd1 = dv.getInt32(scratchPtr + 4, true); - return [fd0, fd1]; + return this.requireApiScratch().withLease((scratch) => { + const result = scratch.invokeKernelExport("kernel_socketpair", [ + domain, + type, + protocol, + scratch.exportPointer(0, KERNEL_SCRATCH_FD_PAIR_BYTES), + KERNEL_SCRATCH_FD_PAIR_BYTES, + ]); + if (result < 0) throw new Error(`socketpair failed: errno ${-result}`); + const output = scratch.dataView(0, KERNEL_SCRATCH_FD_PAIR_BYTES); + return [output.getInt32(0, true), output.getInt32(4, true)]; + }); } /** @@ -2087,36 +2691,43 @@ export class WasmPosixKernel { * Send data on a connected socket. Returns bytes sent. */ send(fd: number, data: Uint8Array, flags: number = 0): number { - const fn = this.instance!.exports.kernel_send as ( - fd: number, - bufPtr: number, - bufLen: number, - flags: number, - ) => number; - // Write data into Wasm memory at a temp location - const mem = this.getMemoryBuffer(); - const tmpPtr = 16; // scratch area - mem.set(data, tmpPtr); - const result = fn(fd, tmpPtr, data.length, flags); - if (result < 0) throw new Error(`send failed: errno ${-result}`); - return result; + const exactData = intrinsicUint8ArrayView(data, "socket send input"); + return this.requireApiScratch().withLease((scratch) => { + scratch.copyFrom(exactData); + const result = scratch.invokeKernelExport("kernel_send", [ + fd, + scratch.exportPointer(0, exactData.byteLength), + exactData.byteLength, + flags, + ]); + if (result < 0) throw new Error(`send failed: errno ${-result}`); + if (!Number.isSafeInteger(result) || result > exactData.byteLength) { + throw new Error(`send returned invalid byte count ${result}`); + } + return result; + }); } /** * Receive data from a connected socket. Returns the received data. */ recv(fd: number, maxLen: number, flags: number = 0): Uint8Array { - const fn = this.instance!.exports.kernel_recv as ( - fd: number, - bufPtr: number, - bufLen: number, - flags: number, - ) => number; - const tmpPtr = 16; // scratch area - const result = fn(fd, tmpPtr, maxLen, flags); - if (result < 0) throw new Error(`recv failed: errno ${-result}`); - const mem = this.getMemoryBuffer(); - return mem.slice(tmpPtr, tmpPtr + result); + if (!Number.isSafeInteger(maxLen) || maxLen < 0) { + throw new Error("recv length must be a non-negative safe integer"); + } + return this.requireApiScratch().withLease((scratch) => { + const result = scratch.invokeKernelExport("kernel_recv", [ + fd, + scratch.exportPointer(0, maxLen), + maxLen, + flags, + ]); + if (result < 0) throw new Error(`recv failed: errno ${-result}`); + if (!Number.isSafeInteger(result) || result > maxLen) { + throw new Error(`recv returned invalid byte count ${result}`); + } + return scratch.copyOut(0, result); + }); } /** @@ -2127,45 +2738,98 @@ export class WasmPosixKernel { fds: Array<{ fd: number; events: number }>, timeout: number, ): Array<{ fd: number; events: number; revents: number }> { - const fn = this.instance!.exports.kernel_poll as ( - fdsPtr: number, - nfds: number, - timeout: number, - ) => number; const nfds = fds.length; - const tmpPtr = 16; // scratch area - const dv = this.getMemoryDataView(); - // Write pollfd structs (8 bytes each: i32 fd, i16 events, i16 revents) - for (let i = 0; i < nfds; i++) { - const off = tmpPtr + i * 8; - dv.setInt32(off, fds[i].fd, true); - dv.setInt16(off + 4, fds[i].events, true); - dv.setInt16(off + 6, 0, true); + if (!Number.isSafeInteger(nfds) || nfds < 0) { + throw new Error("poll descriptor count must be a non-negative safe integer"); } - const result = fn(tmpPtr, nfds, timeout); - if (result < 0) throw new Error(`poll failed: errno ${-result}`); - return fds.map((f, i) => ({ - fd: f.fd, - events: f.events, - revents: dv.getInt16(tmpPtr + i * 8 + 6, true), - })); + const scratchRegion = this.requireApiScratch(); + const descriptorCapacity = Math.floor( + scratchRegion.capacity / STRUCT_SIZE_WASM_POLL_FD, + ); + if (nfds > descriptorCapacity) { + throw new Error( + `poll descriptor count ${nfds} exceeds owned scratch capacity ` + + String(descriptorCapacity), + ); + } + return scratchRegion.withLease((scratch) => { + const byteLength = nfds * STRUCT_SIZE_WASM_POLL_FD; + const view = scratch.dataView(0, byteLength); + for (let index = 0; index < nfds; index++) { + const offset = index * STRUCT_SIZE_WASM_POLL_FD; + view.setInt32( + offset + WASM_POLL_FD_FD_OFFSET, + fds[index].fd, + true, + ); + view.setInt16( + offset + WASM_POLL_FD_EVENTS_OFFSET, + fds[index].events, + true, + ); + view.setInt16(offset + WASM_POLL_FD_REVENTS_OFFSET, 0, true); + } + const result = scratch.invokeKernelExport("kernel_poll", [ + scratch.exportPointer(0, byteLength), + byteLength, + nfds, + timeout, + ]); + if (result < 0) throw new Error(`poll failed: errno ${-result}`); + if (!Number.isSafeInteger(result) || result > nfds) { + throw new Error(`poll returned invalid ready count ${result}`); + } + const resultView = scratch.dataView(0, byteLength); + return fds.map((entry, index) => ({ + fd: entry.fd, + events: entry.events, + revents: resultView.getInt16( + index * STRUCT_SIZE_WASM_POLL_FD + + WASM_POLL_FD_REVENTS_OFFSET, + true, + ), + })); + }); } /** * Get a socket option value. */ getsockopt(fd: number, level: number, optname: number): number { - const fn = this.instance!.exports.kernel_getsockopt as ( - fd: number, - level: number, - optname: number, - optvalPtr: number, - ) => number; - const dv = this.getMemoryDataView(); - const scratchPtr = 4; - const result = fn(fd, level, optname, scratchPtr); - if (result < 0) throw new Error(`getsockopt failed: errno ${-result}`); - return dv.getUint32(scratchPtr, true); + return this.requireApiScratch().withLease((scratch) => { + const output = scratch.dataView( + 0, + KERNEL_SCRATCH_SOCKLEN_BYTES * 2, + ); + output.setUint32( + KERNEL_SCRATCH_SOCKLEN_BYTES, + KERNEL_SCRATCH_SOCKLEN_BYTES, + true, + ); + const result = scratch.invokeKernelExport("kernel_getsockopt", [ + fd, + level, + optname, + scratch.exportPointer(0, KERNEL_SCRATCH_SOCKLEN_BYTES), + KERNEL_SCRATCH_SOCKLEN_BYTES, + scratch.exportPointer( + KERNEL_SCRATCH_SOCKLEN_BYTES, + KERNEL_SCRATCH_SOCKLEN_BYTES, + ), + KERNEL_SCRATCH_SOCKLEN_BYTES, + ]); + if (result < 0) throw new Error(`getsockopt failed: errno ${-result}`); + const returnedLength = output.getUint32( + KERNEL_SCRATCH_SOCKLEN_BYTES, + true, + ); + if (returnedLength !== KERNEL_SCRATCH_SOCKLEN_BYTES) { + throw new Error( + `getsockopt returned invalid scalar option length ${returnedLength}`, + ); + } + return output.getUint32(0, true); + }); } /** @@ -2185,19 +2849,18 @@ export class WasmPosixKernel { // ---- Public API: Terminal operations ---- /** - * Get terminal attributes (48 bytes: c_iflag, c_oflag, c_cflag, c_lflag + c_cc). + * Get terminal attributes in musl's exact 60-byte struct termios layout. */ tcgetattr(fd: number): Uint8Array { - const fn = this.instance!.exports.kernel_tcgetattr as ( - fd: number, - bufPtr: number, - bufLen: number, - ) => number; - const tmpPtr = 16; - const result = fn(fd, tmpPtr, 48); - if (result < 0) throw new Error(`tcgetattr failed: errno ${-result}`); - const mem = this.getMemoryBuffer(); - return mem.slice(tmpPtr, tmpPtr + 48); + return this.requireApiScratch().withLease((scratch) => { + const result = scratch.invokeKernelExport("kernel_tcgetattr", [ + fd, + scratch.exportPointer(0, 60), + 60, + ]); + if (result < 0) throw new Error(`tcgetattr failed: errno ${-result}`); + return scratch.copyOut(0, 60); + }); } /** @@ -2205,17 +2868,20 @@ export class WasmPosixKernel { * action: 0=TCSANOW, 1=TCSADRAIN, 2=TCSAFLUSH */ tcsetattr(fd: number, action: number, attrs: Uint8Array): void { - const fn = this.instance!.exports.kernel_tcsetattr as ( - fd: number, - action: number, - bufPtr: number, - bufLen: number, - ) => number; - const mem = this.getMemoryBuffer(); - const tmpPtr = 16; - mem.set(attrs, tmpPtr); - const result = fn(fd, action, tmpPtr, attrs.length); - if (result < 0) throw new Error(`tcsetattr failed: errno ${-result}`); + const exactAttrs = intrinsicUint8ArrayView( + attrs, + "terminal attributes input", + ); + this.requireApiScratch().withLease((scratch) => { + scratch.copyFrom(exactAttrs); + const result = scratch.invokeKernelExport("kernel_tcsetattr", [ + fd, + action, + scratch.exportPointer(0, exactAttrs.byteLength), + exactAttrs.byteLength, + ]); + if (result < 0) throw new Error(`tcsetattr failed: errno ${-result}`); + }); } /** @@ -2223,20 +2889,69 @@ export class WasmPosixKernel { * For TIOCGWINSZ (0x5413): returns 8-byte buffer (ws_row, ws_col, ws_xpixel, ws_ypixel as u16 LE) * For TIOCSWINSZ (0x5414): pass 8-byte buffer to set window size */ - ioctl(fd: number, request: number, buf?: Uint8Array): Uint8Array { + ioctl( + fd: number, + request: number, + arg?: Uint8Array | number, + ): Uint8Array { const fn = this.instance!.exports.kernel_ioctl as ( fd: number, request: number, - bufPtr: number, + bufPtr: KernelPointer, bufLen: number, + processPointerWidth: number, ) => number; - const mem = this.getMemoryBuffer(); - const tmpPtr = 16; - const bufLen = buf ? buf.length : 8; - if (buf) mem.set(buf, tmpPtr); - const result = fn(fd, request, tmpPtr, bufLen); - if (result < 0) throw new Error(`ioctl failed: errno ${-result}`); - return mem.slice(tmpPtr, tmpPtr + bufLen); + const contract = IOCTL_REQUESTS[request >>> 0]; + const wasm32Size = contract?.wasm32Size; + if (contract && wasm32Size === null) { + throw new Error( + `ioctl 0x${(request >>> 0).toString(16)} has no wasm32 layout`, + ); + } + const expectedSize = wasm32Size ?? 0; + return this.requireApiScratch().withLease((scratch) => { + let bufLen = 0; + let scalarArgument = 0; + if (contract?.argKind === "pointer") { + if (typeof arg === "number") { + throw new Error("pointer ioctl requires a byte buffer"); + } + bufLen = expectedSize; + if (arg && arg.byteLength !== bufLen) { + throw new Error( + `ioctl buffer is ${arg.byteLength} bytes; expected ${bufLen}`, + ); + } + if (!arg && contract.direction !== "out") { + throw new Error("input ioctl requires a byte buffer"); + } + if (arg) scratch.copyFrom(arg, 0, 0, bufLen); + else scratch.fill(0, 0, bufLen); + } else if (contract?.argKind === "scalar-i32") { + if (!Number.isInteger(arg) || (arg as number) < -0x8000_0000 || + (arg as number) > 0xffff_ffff) { + throw new Error("scalar ioctl argument must fit in 32 bits"); + } + scalarArgument = (arg as number) >>> 0; + } + const result = contract?.argKind === "pointer" + ? scratch.invokeKernelExport("kernel_ioctl", [ + fd, + request, + scratch.exportPointer(0, bufLen), + bufLen, + 4, + ]) + : fn( + fd, + request, + this.toKernelPtr(scalarArgument), + bufLen, + 4, + ); + if (result < 0) throw new Error(`ioctl failed: errno ${-result}`); + return bufLen === 0 ? new Uint8Array(0) : scratch.copyOut(0, bufLen); + }); } /** @@ -2267,25 +2982,27 @@ export class WasmPosixKernel { * Get system identification. Returns object with sysname, nodename, release, version, machine. */ uname(): { sysname: string; nodename: string; release: string; version: string; machine: string } { - const fn = this.instance!.exports.kernel_uname as (bufPtr: number, bufLen: number) => number; - const tmpPtr = 16; - const result = fn(tmpPtr, 325); - if (result < 0) throw new Error(`uname failed: errno ${-result}`); - const mem = this.getMemoryBuffer(); - const decoder = new TextDecoder(); - const readField = (offset: number): string => { - const start = tmpPtr + offset; - let end = start; - while (end < start + 65 && mem[end] !== 0) end++; - return decoder.decode(mem.slice(start, end)); - }; - return { - sysname: readField(0), - nodename: readField(65), - release: readField(130), - version: readField(195), - machine: readField(260), - }; + return this.requireApiScratch().withLease((scratch) => { + const result = scratch.invokeKernelExport("kernel_uname", [ + scratch.exportPointer(0, 325), + 325, + ]); + if (result < 0) throw new Error(`uname failed: errno ${-result}`); + const bytes = scratch.copyOut(0, 325); + const decoder = new TextDecoder(); + const readField = (offset: number): string => { + let end = offset; + while (end < offset + 65 && bytes[end] !== 0) end++; + return decoder.decode(bytes.subarray(offset, end)); + }; + return { + sysname: readField(0), + nodename: readField(65), + release: readField(130), + version: readField(195), + machine: readField(260), + }; + }); } /** @@ -2313,14 +3030,16 @@ export class WasmPosixKernel { * Create pipe with flags (O_NONBLOCK, O_CLOEXEC). Returns [readFd, writeFd]. */ pipe2(flags: number): [number, number] { - const fn = this.instance!.exports.kernel_pipe2 as ( - flags: number, fdPtr: number - ) => number; - const dv = this.getMemoryDataView(); - const scratchPtr = 4; - const result = fn(flags, scratchPtr); - if (result < 0) throw new Error(`pipe2 failed: errno ${-result}`); - return [dv.getInt32(scratchPtr, true), dv.getInt32(scratchPtr + 4, true)]; + return this.requireApiScratch().withLease((scratch) => { + const result = scratch.invokeKernelExport("kernel_pipe2", [ + flags, + scratch.exportPointer(0, KERNEL_SCRATCH_FD_PAIR_BYTES), + KERNEL_SCRATCH_FD_PAIR_BYTES, + ]); + if (result < 0) throw new Error(`pipe2 failed: errno ${-result}`); + const output = scratch.dataView(0, KERNEL_SCRATCH_FD_PAIR_BYTES); + return [output.getInt32(0, true), output.getInt32(4, true)]; + }); } /** @@ -2350,14 +3069,20 @@ export class WasmPosixKernel { /** * Truncate a file by path to specified length. */ - truncate(pathPtr: number, pathLen: number, length: number): void { - const fn = this.instance!.exports.kernel_truncate as ( - pathPtr: number, pathLen: number, lengthLo: number, lengthHi: number - ) => number; - const lo = length & 0xFFFFFFFF; - const hi = Math.floor(length / 0x100000000); - const result = fn(pathPtr, pathLen, lo, hi); - if (result < 0) throw new Error(`truncate failed: errno ${-result}`); + truncate(path: string, length: number): void { + if (!Number.isSafeInteger(length) || length < 0) { + throw new Error("truncate length must be a non-negative safe integer"); + } + const encodedPath = new TextEncoder().encode(path); + this.requireApiScratch().withLease((scratch) => { + scratch.copyFrom(encodedPath); + const result = scratch.invokeKernelExport("kernel_truncate", [ + scratch.exportPointer(0, encodedPath.byteLength), + encodedPath.byteLength, + BigInt(length), + ]); + if (result < 0) throw new Error(`truncate failed: errno ${-result}`); + }); } /** @@ -2470,14 +3195,15 @@ export class WasmPosixKernel { * Get resource usage. Returns 144-byte rusage struct. */ getrusage(who: number): Uint8Array { - const fn = this.instance!.exports.kernel_getrusage as ( - who: number, bufPtr: number, bufLen: number - ) => number; - const tmpPtr = 16; - const result = fn(who, tmpPtr, 144); - if (result < 0) throw new Error(`getrusage failed: errno ${-result}`); - const mem = this.getMemoryBuffer(); - return mem.slice(tmpPtr, tmpPtr + 144); + return this.requireApiScratch().withLease((scratch) => { + const result = scratch.invokeKernelExport("kernel_getrusage", [ + who, + scratch.exportPointer(0, 144), + 144, + ]); + if (result < 0) throw new Error(`getrusage failed: errno ${-result}`); + return scratch.copyOut(0, 144); + }); } /** @@ -2490,60 +3216,143 @@ export class WasmPosixKernel { writefds: number[] | null, exceptfds: number[] | null, ): { readReady: number[]; writeReady: number[]; exceptReady: number[] } { - const fn = this.instance!.exports.kernel_select as ( - nfds: number, readPtr: number, writePtr: number, exceptPtr: number, timeout: number - ) => number; - - const mem = this.getMemoryBuffer(); - // Allocate 3 fd_sets in Wasm memory (128 bytes each = 384 total) - const basePtr = 16; - const readPtr = readfds ? basePtr : 0; - const writePtr = writefds ? basePtr + 128 : 0; - const exceptPtr = exceptfds ? basePtr + 256 : 0; - - // Initialize fd_sets - if (readfds) { - mem.fill(0, readPtr, readPtr + 128); - for (const fd of readfds) { - mem[readPtr + Math.floor(fd / 8)] |= 1 << (fd % 8); - } - } - if (writefds) { - mem.fill(0, writePtr, writePtr + 128); - for (const fd of writefds) { - mem[writePtr + Math.floor(fd / 8)] |= 1 << (fd % 8); - } + if ( + !Number.isSafeInteger(nfds) || + nfds < 0 || + nfds > SELECT_FD_SETSIZE + ) { + throw new Error( + `select nfds must be between 0 and ${SELECT_FD_SETSIZE}`, + ); } - if (exceptfds) { - mem.fill(0, exceptPtr, exceptPtr + 128); - for (const fd of exceptfds) { - mem[exceptPtr + Math.floor(fd / 8)] |= 1 << (fd % 8); + const validateSet = (set: number[] | null): void => { + for (const fd of set ?? []) { + if (!Number.isSafeInteger(fd) || fd < 0 || fd >= nfds) { + throw new Error(`select fd ${fd} is outside nfds ${nfds}`); + } } - } - - const result = fn(nfds, readPtr, writePtr, exceptPtr, 0); - if (result < 0) throw new Error(`select failed: errno ${-result}`); - - // Extract results - const extractReady = (ptr: number, fds: number[] | null): number[] => { - if (!fds || !ptr) return []; - return fds.filter(fd => (mem[ptr + Math.floor(fd / 8)] >> (fd % 8)) & 1); - }; - - return { - readReady: extractReady(readPtr, readfds), - writeReady: extractReady(writePtr, writefds), - exceptReady: extractReady(exceptPtr, exceptfds), }; + validateSet(readfds); + validateSet(writefds); + validateSet(exceptfds); + + return this.requireApiScratch().withLease((scratch) => { + const totalSetBytes = 3 * SELECT_FD_SET_BYTES; + scratch.fill(0, 0, totalSetBytes); + const readOffset = 0; + const writeOffset = SELECT_FD_SET_BYTES; + const exceptOffset = 2 * SELECT_FD_SET_BYTES; + // WHY: keep every lease operation visibly inside withLease. A local + // helper that closes over scratch could be retained during a refactor and + // resume after another select has replaced the shared allocation bytes. + if (readfds) { + const bytes = scratch.dataView(readOffset, SELECT_FD_SET_BYTES); + for (const fd of readfds) { + const byteOffset = Math.floor(fd / 8); + bytes.setUint8( + byteOffset, + bytes.getUint8(byteOffset) | (1 << (fd % 8)), + ); + } + } + if (writefds) { + const bytes = scratch.dataView(writeOffset, SELECT_FD_SET_BYTES); + for (const fd of writefds) { + const byteOffset = Math.floor(fd / 8); + bytes.setUint8( + byteOffset, + bytes.getUint8(byteOffset) | (1 << (fd % 8)), + ); + } + } + if (exceptfds) { + const bytes = scratch.dataView(exceptOffset, SELECT_FD_SET_BYTES); + for (const fd of exceptfds) { + const byteOffset = Math.floor(fd / 8); + bytes.setUint8( + byteOffset, + bytes.getUint8(byteOffset) | (1 << (fd % 8)), + ); + } + } + const nullPointer = this.toKernelPtr(0); + // WHY: each nullable pointer is immediately followed by the exact extent + // Rust may access. This keeps capacity proof coupled to the pointer across + // the host/kernel boundary and avoids eight subtly different call shapes. + const result = scratch.invokeKernelExport("kernel_select", [ + nfds, + readfds + ? scratch.exportPointer(readOffset, SELECT_FD_SET_BYTES) + : nullPointer, + readfds ? SELECT_FD_SET_BYTES : 0, + writefds + ? scratch.exportPointer(writeOffset, SELECT_FD_SET_BYTES) + : nullPointer, + writefds ? SELECT_FD_SET_BYTES : 0, + exceptfds + ? scratch.exportPointer(exceptOffset, SELECT_FD_SET_BYTES) + : nullPointer, + exceptfds ? SELECT_FD_SET_BYTES : 0, + 0, + ]); + if (result < 0) throw new Error(`select failed: errno ${-result}`); + const readReady: number[] = []; + if (readfds) { + const bytes = scratch.dataView(readOffset, SELECT_FD_SET_BYTES); + for (const fd of readfds) { + if ( + ((bytes.getUint8(Math.floor(fd / 8)) >> (fd % 8)) & 1) !== 0 + ) { + readReady.push(fd); + } + } + } + const writeReady: number[] = []; + if (writefds) { + const bytes = scratch.dataView(writeOffset, SELECT_FD_SET_BYTES); + for (const fd of writefds) { + if ( + ((bytes.getUint8(Math.floor(fd / 8)) >> (fd % 8)) & 1) !== 0 + ) { + writeReady.push(fd); + } + } + } + const exceptReady: number[] = []; + if (exceptfds) { + const bytes = scratch.dataView(exceptOffset, SELECT_FD_SET_BYTES); + for (const fd of exceptfds) { + if ( + ((bytes.getUint8(Math.floor(fd / 8)) >> (fd % 8)) & 1) !== 0 + ) { + exceptReady.push(fd); + } + } + } + return { + readReady, + writeReady, + exceptReady, + }; + }); } // ---- Networking host imports ---- - private hostNetConnect(handle: number, addrPtr: number, addrLen: number, port: number): number { + private hostNetConnect( + handle: number, + addrPtr: KernelPointer, + addrLen: number, + port: number, + ): number { if (!this.io.network) return -111; // -ECONNREFUSED + let addr: Uint8Array; + try { + addr = this.readKernelBytes(addrPtr, addrLen); + } catch { + return -14; // EFAULT + } try { - const mem = new Uint8Array(this.memory!.buffer); - const addr = mem.slice(addrPtr, addrPtr + addrLen); this.io.network.connect(handle, addr, port); return 0; } catch { @@ -2562,27 +3371,72 @@ export class WasmPosixKernel { } } - private hostNetSend(handle: number, bufPtr: number, bufLen: number, flags: number): number { + private hostNetSend( + handle: number, + bufPtr: KernelPointer, + bufLen: number, + flags: number, + ): number { if (!this.io.network) return -107; // -ENOTCONN + let data: Uint8Array; + try { + data = this.readKernelBytes(bufPtr, bufLen); + } catch { + return -14; // EFAULT + } try { - const mem = new Uint8Array(this.memory!.buffer); - const data = mem.slice(bufPtr, bufPtr + bufLen); - return this.io.network.send(handle, data, flags); + const sent = this.io.network.send(handle, data, flags); + return Number.isSafeInteger(sent) + && sent >= 0 + && sent <= data.byteLength + ? sent + : -5; } catch (e: any) { if (e?.errno === 11) return -11; // -EAGAIN return -32; // -EPIPE } } - private hostNetRecv(handle: number, bufPtr: number, bufLen: number, flags: number): number { + private hostNetRecv( + handle: number, + bufPtr: KernelPointer, + bufLen: number, + flags: number, + ): number { if (!this.io.network) return -107; // -ENOTCONN + if (!this.memory) return -5; + let destination: { pointer: number; length: number; end: number }; try { - const data = this.io.network.recv(handle, bufLen, flags); - if (data.length > 0 && this.memory) { - const mem = new Uint8Array(this.memory.buffer); - mem.set(data, bufPtr); + destination = checkedWasmImportMemoryRange( + this.memory, + bufPtr, + bufLen, + this.kernelPtrWidth, + "host_net_recv destination", + ); + } catch { + return -14; // -EFAULT + } + try { + const produced = this.io.network.recv(handle, bufLen, flags); + let data: Uint8Array; + try { + data = intrinsicUint8ArrayView( + produced, + "network receive output", + ); + } catch { + return -5; // EIO: the backend violated its byte-source contract. + } + if (data.byteLength > destination.length) { + return -5; // EIO: backend violated the supplied capacity + } + if (data.byteLength > 0) { + // Recheck after the backend callback in case memory grew while the + // Rust import was suspended in host code. + this.writeKernelBytes(bufPtr, bufLen, data); } - return data.length; + return data.byteLength; } catch (e: any) { if (e?.errno === 11) return -11; // -EAGAIN return -104; // -ECONNRESET @@ -2642,12 +3496,17 @@ export class WasmPosixKernel { dstC: number, dstD: number, dstPort: number, - dataPtr: number, + dataPtr: KernelPointer, dataLen: number, ): number { if (!this.io.network?.sendDatagram) return -101; // -ENETUNREACH + let data: Uint8Array; + try { + data = this.readKernelBytes(dataPtr, dataLen); + } catch { + return -14; // EFAULT + } try { - const mem = this.getMemoryBuffer(); let srcAddr = new Uint8Array([srcA, srcB, srcC, srcD]); if ( srcAddr[0] === 0 && @@ -2658,7 +3517,6 @@ export class WasmPosixKernel { ) { srcAddr = this.io.network.localAddress.slice(); } - const data = mem.slice(dataPtr, dataPtr + dataLen); const result = this.io.network.sendDatagram({ srcAddr, srcPort, @@ -2673,27 +3531,70 @@ export class WasmPosixKernel { } } - private hostGetaddrinfo(namePtr: number, nameLen: number, resultPtr: number, resultLen: number): number { + private hostGetaddrinfo( + namePtr: KernelPointer, + nameLen: number, + resultPtr: KernelPointer, + resultLen: number, + ): number { if (!this.io.network) return -2; // -ENOENT try { - const mem = new Uint8Array(this.memory!.buffer); - const name = new TextDecoder().decode(mem.slice(namePtr, namePtr + nameLen)); - const addr = this.io.network.getaddrinfo(name); - if (addr.length > resultLen) return -22; // -EINVAL - mem.set(addr, resultPtr); - return addr.length; + if (!this.memory) return -5; + checkedWasmImportMemoryRange( + this.memory, + resultPtr, + resultLen, + this.kernelPtrWidth, + "host_getaddrinfo destination", + ); + const name = new TextDecoder().decode( + this.readKernelBytes(namePtr, nameLen), + ); + // WHY: EAGAIN is the backend's asynchronous DNS handoff to the kernel + // retry loop. Keep backend exceptions outside the producer-validation + // catch so a valid retry signal is not mistaken for hostile bytes. + const backendAddr = this.io.network.getaddrinfo(name); + let addr: Uint8Array; + try { + addr = intrinsicUint8ArrayView( + backendAddr, + "getaddrinfo backend output", + ); + } catch { + return -5; // EIO: the backend violated its byte-source contract. + } + if (addr.byteLength > resultLen) return -22; // -EINVAL + this.writeKernelBytes(resultPtr, resultLen, addr); + return addr.byteLength; } catch (e: any) { if (e?.errno === 11) return -11; // -EAGAIN — kernel-worker retries - return -2; // -ENOENT + return negErrno(e); } } - private hostFutexWait(addr: number, expected: number, timeoutLo: number, timeoutHi: number): number { + private hostFutexWait( + addr: KernelPointer, + expected: number, + timeoutLo: number, + timeoutHi: number, + ): number { if (!this.memory) return -22; // -EINVAL - // addr is a byte offset into Wasm shared memory + let index: number; + try { + const range = checkedWasmImportMemoryRange( + this.memory, + addr, + 4, + this.kernelPtrWidth, + "host_futex_wait word", + ); + if (range.pointer % 4 !== 0) return -22; // EINVAL + index = range.pointer / 4; + } catch { + return -14; // EFAULT + } const i32view = new Int32Array(this.memory.buffer); - const index = addr >>> 2; // Reconstruct 64-bit timeout_ns from lo/hi const timeoutNs = BigInt(timeoutHi >>> 0) * 0x100000000n + BigInt(timeoutLo >>> 0); @@ -2708,7 +3609,12 @@ export class WasmPosixKernel { } // signed < 0 → infinite wait (undefined timeout) - const result = Atomics.wait(i32view, index, expected, timeoutMs); + let result: "ok" | "not-equal" | "timed-out"; + try { + result = Atomics.wait(i32view, index, expected, timeoutMs); + } catch { + return -22; // EINVAL: memory was not shared or became unusable + } if (result === "timed-out") { return -110; // -ETIMEDOUT } @@ -2716,11 +3622,28 @@ export class WasmPosixKernel { return 0; // "ok" } - private hostFutexWake(addr: number, count: number): number { + private hostFutexWake(addr: KernelPointer, count: number): number { if (!this.memory) return 0; + let index: number; + try { + const range = checkedWasmImportMemoryRange( + this.memory, + addr, + 4, + this.kernelPtrWidth, + "host_futex_wake word", + ); + if (range.pointer % 4 !== 0) return -22; // EINVAL + index = range.pointer / 4; + } catch { + return -14; // EFAULT + } const i32view = new Int32Array(this.memory.buffer); - const index = addr >>> 2; - return Atomics.notify(i32view, index, count); + try { + return Atomics.notify(i32view, index, count); + } catch { + return -22; // EINVAL + } } } diff --git a/host/src/node-kernel-host.ts b/host/src/node-kernel-host.ts index 2a2fbacb47..7eef03905c 100644 --- a/host/src/node-kernel-host.ts +++ b/host/src/node-kernel-host.ts @@ -12,7 +12,7 @@ * const exitCode = await host.spawn(programBytes, ["hello"], { env: [...] }); * await host.destroy(); */ -import { readFileSync, existsSync, statSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { pathToFileURL } from "node:url"; @@ -28,6 +28,7 @@ import type { import type { ProcessSnapshot, SyscallTraceEvent } from "./kernel-worker"; import type { HttpRequest, HttpResponse } from "./networking/in-kernel-http"; import type { LazyDownloadEvent } from "./vfs/memory-fs"; +import { compiledWorkerEntryIsCurrent } from "./compiled-worker-entry"; import { snapshotClosedLazyAssets, snapshotClosedLazyAssetSources, @@ -599,6 +600,24 @@ export class NodeKernelHost { return result; } + /** + * Return the retained capacity of the kernel-owned large-spawn region. + * Zero means no spawn has exceeded the ordinary channel-sized scratch. + */ + async getSpawnScratchCapacity(): Promise { + const requestId = this._nextRequestId++; + const result = await this.request(requestId, { + type: "get_spawn_scratch_capacity", + requestId, + }); + if (!Number.isSafeInteger(result) || result < 0) { + throw new Error( + `kernel worker returned an invalid spawn scratch capacity: ${String(result)}`, + ); + } + return result; + } + /** * Snapshot the kernel's process table — one row per live process. Used * by Kandelo's Inspector → Procs tab. Mirrors `BrowserKernel.enumProcs`. @@ -1010,10 +1029,10 @@ function spawnKernelWorkerThread(): NodeThreadWorker { const distJs = entryTs.replace(/\/src\/([^/]+)\.ts$/, "/dist/$1.js"); // Check for compiled .js version first (much faster startup) - if (compiledEntryIsCurrent(entryTs, distJs)) { + if (compiledWorkerEntryIsCurrent(entryTs, distJs)) { return new NodeThreadWorker(distJs); } - if (compiledEntryIsCurrent(entryTs, entryJs)) { + if (compiledWorkerEntryIsCurrent(entryTs, entryJs)) { return new NodeThreadWorker(entryJs); } @@ -1029,9 +1048,3 @@ function spawnKernelWorkerThread(): NodeThreadWorker { ].join("\n"); return new NodeThreadWorker(bootstrap, { eval: true }); } - -function compiledEntryIsCurrent(sourcePath: string, compiledPath: string): boolean { - if (!existsSync(compiledPath)) return false; - if (!existsSync(sourcePath)) return true; - return statSync(compiledPath).mtimeMs >= statSync(sourcePath).mtimeMs; -} diff --git a/host/src/node-kernel-protocol.ts b/host/src/node-kernel-protocol.ts index c6f0b23379..675c8d6b88 100644 --- a/host/src/node-kernel-protocol.ts +++ b/host/src/node-kernel-protocol.ts @@ -228,6 +228,12 @@ export interface GetKernelMemoryPagesRequestMessage { requestId: number; } +/** Read the retained capacity of the kernel-owned large-spawn region. */ +export interface GetSpawnScratchCapacityRequestMessage { + type: "get_spawn_scratch_capacity"; + requestId: number; +} + export interface ResolveExecResponseMessage { type: "resolve_exec_response"; requestId: number; @@ -318,6 +324,7 @@ export type MainToKernelMessage = | ReadVfsFileMessage | GetForkCountRequestMessage | GetKernelMemoryPagesRequestMessage + | GetSpawnScratchCapacityRequestMessage | ResolveExecResponseMessage | EnumProcsRequestMessage | ReadProcMapsRequestMessage diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts index c102cefd75..3b507a8674 100644 --- a/host/src/node-kernel-worker-entry.ts +++ b/host/src/node-kernel-worker-entry.ts @@ -1464,8 +1464,6 @@ async function handleExec( ): Promise { const initiatingInfo = processes.get(pid); if (!initiatingInfo) return -3; // ESRCH - if (!kernelWorker.supportsExecMetadataReplacement()) return -38; // ENOSYS - const resolved = await resolveExecutableForLaunch(path, argv); if (!resolved) return -2; // ENOENT if ("errno" in resolved) return -resolved.errno; @@ -2807,6 +2805,23 @@ port.on("message", (msg: MainToKernelMessage) => { } break; } + case "get_spawn_scratch_capacity": { + try { + post({ + type: "response", + requestId: msg.requestId, + result: kernelWorker.getSpawnScratchCapacity(), + }); + } catch (err) { + post({ + type: "response", + requestId: msg.requestId, + result: undefined, + error: (err as Error)?.message ?? String(err), + }); + } + break; + } case "enum_procs": { // Snapshot the kernel's process table for the Inspector → Procs tab. // Mirrors the Browser-side handler in browser-kernel-worker-entry.ts. diff --git a/host/src/pathconf.ts b/host/src/pathconf.ts index 8401568e52..875dedfc7f 100644 --- a/host/src/pathconf.ts +++ b/host/src/pathconf.ts @@ -1,4 +1,7 @@ -import { PATHCONF_NAMES } from "./generated/abi"; +import { + PATHCONF_NAMES, + POSIX_PATH_MAX_BYTES, +} from "./generated/abi"; import type { PathconfValue, StatResult } from "./types"; export interface PathconfProfile { @@ -30,7 +33,7 @@ export function filesystemPathconf( case PATHCONF_NAMES.NAME_MAX: return 255; // enforced in bytes by the common namespace resolver case PATHCONF_NAMES.PATH_MAX: - return 4096; // enforced in bytes by the common namespace resolver + return POSIX_PATH_MAX_BYTES; // enforced by the common namespace resolver case PATHCONF_NAMES.CHOWN_RESTRICTED: // The kernel enforces chown authorization before every backend call, // including backends without persistent ownership metadata. diff --git a/host/src/wasi-shim.ts b/host/src/wasi-shim.ts index e98c6b84b5..a1547ffee0 100644 --- a/host/src/wasi-shim.ts +++ b/host/src/wasi-shim.ts @@ -26,7 +26,14 @@ import { CH_RETURN, CH_STATUS, CH_SYSCALL, + PROCESS_IOVEC_WASM32_BASE_OFFSET, + PROCESS_IOVEC_WASM32_LEN_OFFSET, + PROCESS_IOVEC_WASM32_SIZE, STRUCT_SIZE_WASM_STAT, + STRUCT_SIZE_WASM_POLL_FD, + WASM_POLL_FD_EVENTS_OFFSET, + WASM_POLL_FD_FD_OFFSET, + WASM_POLL_FD_REVENTS_OFFSET, } from "./generated/abi"; // --- Channel layout (must match crates/shared/src/lib.rs + libc/glue/channel_syscall.c) --- @@ -730,7 +737,12 @@ export class WasiShim { // Calculate total read size from iovecs let totalLen = 0; for (let i = 0; i < iovsLen; i++) { - totalLen += view.getUint32(iovsPtr + i * 8 + 4, true); + totalLen += view.getUint32( + iovsPtr + + i * PROCESS_IOVEC_WASM32_SIZE + + PROCESS_IOVEC_WASM32_LEN_OFFSET, + true, + ); } totalLen = Math.min(totalLen, CH_DATA_SIZE - 256); @@ -745,8 +757,15 @@ export class WasiShim { let remaining = result; let srcOff = 0; for (let i = 0; i < iovsLen && remaining > 0; i++) { - const bufPtr = view.getUint32(iovsPtr + i * 8, true); - const bufLen = view.getUint32(iovsPtr + i * 8 + 4, true); + const entry = iovsPtr + i * PROCESS_IOVEC_WASM32_SIZE; + const bufPtr = view.getUint32( + entry + PROCESS_IOVEC_WASM32_BASE_OFFSET, + true, + ); + const bufLen = view.getUint32( + entry + PROCESS_IOVEC_WASM32_LEN_OFFSET, + true, + ); const copyLen = Math.min(bufLen, remaining); mem.copyWithin(bufPtr, this.dataArea + srcOff, this.dataArea + srcOff + copyLen); srcOff += copyLen; @@ -766,8 +785,15 @@ export class WasiShim { // Gather iovec data into data area let totalLen = 0; for (let i = 0; i < iovsLen; i++) { - const bufPtr = view.getUint32(iovsPtr + i * 8, true); - const bufLen = view.getUint32(iovsPtr + i * 8 + 4, true); + const entry = iovsPtr + i * PROCESS_IOVEC_WASM32_SIZE; + const bufPtr = view.getUint32( + entry + PROCESS_IOVEC_WASM32_BASE_OFFSET, + true, + ); + const bufLen = view.getUint32( + entry + PROCESS_IOVEC_WASM32_LEN_OFFSET, + true, + ); const copyLen = Math.min(bufLen, CH_DATA_SIZE - 256 - totalLen); mem.copyWithin(this.dataArea + totalLen, bufPtr, bufPtr + copyLen); totalLen += copyLen; @@ -1337,12 +1363,18 @@ export class WasiShim { } } - // Write pollfd structs: fd(i32) + events(i16) + revents(i16) = 8 bytes + // WASI Preview 1 is wasm32-only here, but poll(2) still consumes the + // generated Kandelo syscall record rather than a private shim layout. const pollfdAddr = this.dataArea; for (let i = 0; i < pollfds.length; i++) { - view.setInt32(pollfdAddr + i * 8, pollfds[i].fd, true); - view.setInt16(pollfdAddr + i * 8 + 4, pollfds[i].events, true); - view.setInt16(pollfdAddr + i * 8 + 6, 0, true); + const entry = pollfdAddr + i * STRUCT_SIZE_WASM_POLL_FD; + view.setInt32(entry + WASM_POLL_FD_FD_OFFSET, pollfds[i].fd, true); + view.setInt16( + entry + WASM_POLL_FD_EVENTS_OFFSET, + pollfds[i].events, + true, + ); + view.setInt16(entry + WASM_POLL_FD_REVENTS_OFFSET, 0, true); } const { errno } = this.doSyscall( @@ -1353,7 +1385,12 @@ export class WasiShim { // Read results and write WASI events let nevents = 0; for (let i = 0; i < pollfds.length; i++) { - const revents = view.getInt16(pollfdAddr + i * 8 + 6, true); + const revents = view.getInt16( + pollfdAddr + + i * STRUCT_SIZE_WASM_POLL_FD + + WASM_POLL_FD_REVENTS_OFFSET, + true, + ); if (revents) { const evBase = outPtr + nevents * 32; view.setBigUint64(evBase, pollfds[i].userdata, true); @@ -1393,7 +1430,12 @@ export class WasiShim { // Gather total size from iovecs, read into data area, then scatter let totalLen = 0; for (let i = 0; i < iovsLen; i++) { - totalLen += view.getUint32(iovsPtr + i * 8 + 4, true); + totalLen += view.getUint32( + iovsPtr + + i * PROCESS_IOVEC_WASM32_SIZE + + PROCESS_IOVEC_WASM32_LEN_OFFSET, + true, + ); } totalLen = Math.min(totalLen, CH_DATA_SIZE - 256); @@ -1406,8 +1448,15 @@ export class WasiShim { let remaining = result; let srcOff = 0; for (let i = 0; i < iovsLen && remaining > 0; i++) { - const bufPtr = view.getUint32(iovsPtr + i * 8, true); - const bufLen = view.getUint32(iovsPtr + i * 8 + 4, true); + const entry = iovsPtr + i * PROCESS_IOVEC_WASM32_SIZE; + const bufPtr = view.getUint32( + entry + PROCESS_IOVEC_WASM32_BASE_OFFSET, + true, + ); + const bufLen = view.getUint32( + entry + PROCESS_IOVEC_WASM32_LEN_OFFSET, + true, + ); const copyLen = Math.min(bufLen, remaining); mem.copyWithin(bufPtr, this.dataArea + srcOff, this.dataArea + srcOff + copyLen); srcOff += copyLen; @@ -1429,8 +1478,15 @@ export class WasiShim { // Gather from iovecs into data area let totalLen = 0; for (let i = 0; i < iovsLen; i++) { - const bufPtr = view.getUint32(iovsPtr + i * 8, true); - const bufLen = view.getUint32(iovsPtr + i * 8 + 4, true); + const entry = iovsPtr + i * PROCESS_IOVEC_WASM32_SIZE; + const bufPtr = view.getUint32( + entry + PROCESS_IOVEC_WASM32_BASE_OFFSET, + true, + ); + const bufLen = view.getUint32( + entry + PROCESS_IOVEC_WASM32_LEN_OFFSET, + true, + ); const copyLen = Math.min(bufLen, CH_DATA_SIZE - 256 - totalLen); mem.copyWithin(this.dataArea + totalLen, bufPtr, bufPtr + copyLen); totalLen += copyLen; diff --git a/host/src/wasm-guest-pointer.ts b/host/src/wasm-guest-pointer.ts index 3993e75754..005ff15f1c 100644 --- a/host/src/wasm-guest-pointer.ts +++ b/host/src/wasm-guest-pointer.ts @@ -1,6 +1,13 @@ export type WasmGuestPointer = number | bigint; -const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER); +// WHY: guest pointers can be normalized after arbitrary host callbacks have +// run. Capture every coercion/validation intrinsic before those callbacks can +// replace mutable globals and make an invalid pointer appear exact. +const IntrinsicBigInt = BigInt; +const IntrinsicNumber = Number; +const intrinsicBigIntAsUintN = IntrinsicBigInt.asUintN; +const intrinsicNumberIsSafeInteger = IntrinsicNumber.isSafeInteger; +const MAX_SAFE_BIGINT = IntrinsicBigInt(IntrinsicNumber.MAX_SAFE_INTEGER); const MIN_SIGNED_WASM32 = -0x8000_0000; const MAX_UNSIGNED_WASM32 = 0xffff_ffff; const MIN_SIGNED_WASM64 = -(1n << 63n); @@ -21,17 +28,20 @@ export function checkedWasmGuestPointerOffset( ptrWidth: 4 | 8, context: string, ): number { + if (ptrWidth !== 4 && ptrWidth !== 8) { + throw new TypeError(`${context}: pointer width must be exactly 4 or 8`); + } let unsigned: bigint; if (ptrWidth === 4) { if ( typeof value !== "number" - || !Number.isSafeInteger(value) + || !intrinsicNumberIsSafeInteger(value) || value < MIN_SIGNED_WASM32 || value > MAX_UNSIGNED_WASM32 ) { throw new TypeError(`${context}: expected an exact memory32 pointer`); } - unsigned = BigInt(value >>> 0); + unsigned = IntrinsicBigInt(value >>> 0); } else { if ( typeof value !== "bigint" @@ -40,11 +50,11 @@ export function checkedWasmGuestPointerOffset( ) { throw new TypeError(`${context}: expected an exact memory64 pointer`); } - unsigned = BigInt.asUintN(64, value); + unsigned = intrinsicBigIntAsUintN(64, value); } if (unsigned > MAX_SAFE_BIGINT) { throw new RangeError(`${context}: pointer exceeds JavaScript's exact address range`); } - return Number(unsigned); + return IntrinsicNumber(unsigned); } diff --git a/host/test/advisory-lock-kernel.test.ts b/host/test/advisory-lock-kernel.test.ts index 985fa20339..ce0b3b7588 100644 --- a/host/test/advisory-lock-kernel.test.ts +++ b/host/test/advisory-lock-kernel.test.ts @@ -16,6 +16,7 @@ import { CAPTURED_STDIO, CentralizedKernelWorker, } from "../src/kernel-worker"; +import type { KernelScratchLease } from "../src/kernel-scratch"; import { NodePlatformIO } from "../src/platform/node"; import type { PlatformIO } from "../src/types"; import { MemoryFileSystem } from "../src/vfs/memory-fs"; @@ -35,6 +36,7 @@ import { CH_ERRNO, CH_RETURN, CH_SYSCALL, + FCNTL_FLOCK_BYTES, } from "../src/generated/abi"; const O_RDWR = 2; @@ -60,6 +62,18 @@ interface SyscallResult { errno: number; } +interface ScratchArgument { + readonly scratchOffset: number; + readonly length: number; +} + +function scratchArgument( + scratchOffset: number, + length: number, +): ScratchArgument { + return { scratchOffset, length }; +} + function loadKernelWasm(): ArrayBuffer { const bytes = readFileSync(resolveBinary("kernel.wasm")); return bytes.buffer.slice( @@ -100,30 +114,59 @@ function issue( syscall: number, args: Array, ): SyscallResult { - const kernelMemory = (worker as any).kernelMemory as WebAssembly.Memory; - const scratchOffset = (worker as any).scratchOffset as number; - const channel = new DataView(kernelMemory.buffer, scratchOffset); - channel.setUint32(CH_SYSCALL, syscall, true); - channel.setUint32(CH_ERRNO, 0, true); - channel.setBigInt64(CH_RETURN, 0n, true); - for (let index = 0; index < 6; index++) { - channel.setBigInt64( - CH_ARGS + index * CH_ARG_SIZE, - BigInt(args[index] ?? 0), - true, - ); - } + return issuePrepared(worker, pid, syscall, () => args); +} - const handleChannel = (worker as any).kernelInstance.exports - .kernel_handle_channel as (offset: number | bigint, pid: number) => number; +function issuePrepared( + worker: CentralizedKernelWorker, + pid: number, + syscall: number, + prepareArgs: ( + lease: KernelScratchLease, + ) => Array, +): SyscallResult { const setCurrentTid = (worker as any).kernelInstance.exports .kernel_set_current_tid as (pid: number, tid: number) => number; expect(setCurrentTid(pid, pid)).toBe(0); - handleChannel(worker.toKernelPtr(scratchOffset), pid); - return { - value: Number(channel.getBigInt64(CH_RETURN, true)), - errno: channel.getUint32(CH_ERRNO, true), - }; + return (worker as any).scratchRegion.withLease( + (lease: KernelScratchLease) => { + const args = prepareArgs(lease); + const channel = lease.dataView(0, CH_TOTAL_SIZE); + channel.setUint32(CH_SYSCALL, syscall, true); + channel.setUint32(CH_ERRNO, 0, true); + channel.setBigInt64(CH_RETURN, 0n, true); + for (let index = 0; index < 6; index++) { + const argument = args[index] ?? 0; + if (typeof argument === "object") { + channel.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, 0n, true); + lease.writeAddress( + CH_ARGS + index * CH_ARG_SIZE, + argument.scratchOffset, + argument.length, + (worker as any).kernel.getKernelPtrWidth() === 8 + ? "u64-le" + : "u32-to-u64-le", + ); + continue; + } + channel.setBigInt64( + CH_ARGS + index * CH_ARG_SIZE, + BigInt(argument), + true, + ); + } + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + pid, + ]); + const result = lease.dataView(0, CH_TOTAL_SIZE); + return { + value: Number(result.getBigInt64(CH_RETURN, true)), + errno: result.getUint32(CH_ERRNO, true), + }; + }, + ); } function openFile( @@ -131,12 +174,20 @@ function openFile( pid: number, path: string, ): number { - const kernelMemory = (worker as any).kernelMemory as WebAssembly.Memory; - const scratchOffset = (worker as any).scratchOffset as number; - const pathPtr = scratchOffset + CH_DATA; const encoded = new TextEncoder().encode(`${path}\0`); - new Uint8Array(kernelMemory.buffer).set(encoded, pathPtr); - const result = issue(worker, pid, ABI_SYSCALLS.Open, [pathPtr, O_RDWR, 0]); + const result = issuePrepared( + worker, + pid, + ABI_SYSCALLS.Open, + (lease) => { + lease.copyFrom(encoded, CH_DATA); + return [ + scratchArgument(CH_DATA, encoded.byteLength), + O_RDWR, + 0, + ]; + }, + ); expect(result.errno).toBe(0); expect(result.value).toBeGreaterThanOrEqual(3); return result.value; @@ -162,17 +213,21 @@ function lock( type = F_WRLCK, command = F_SETLK64, ): SyscallResult { - const kernelMemory = (worker as any).kernelMemory as WebAssembly.Memory; - const scratchOffset = (worker as any).scratchOffset as number; - const flockPtr = scratchOffset + CH_DATA; - const flock = new DataView(kernelMemory.buffer, flockPtr, 32); - new Uint8Array(kernelMemory.buffer, flockPtr, 32).fill(0); - flock.setInt16(0, type, true); - flock.setInt16(2, 0, true); // SEEK_SET - flock.setBigInt64(8, start, true); - flock.setBigInt64(16, len, true); - // l_pid remains zero, as required for F_OFD_* commands. - return issue(worker, pid, ABI_SYSCALLS.Fcntl, [fd, command, flockPtr]); + return issuePrepared( + worker, + pid, + ABI_SYSCALLS.Fcntl, + (lease) => { + lease.fill(0, CH_DATA, FCNTL_FLOCK_BYTES); + const flock = lease.dataView(CH_DATA, FCNTL_FLOCK_BYTES); + flock.setInt16(0, type, true); + flock.setInt16(2, 0, true); // SEEK_SET + flock.setBigInt64(8, start, true); + flock.setBigInt64(16, len, true); + // l_pid remains zero, as required for F_OFD_* commands. + return [fd, command, scratchArgument(CH_DATA, FCNTL_FLOCK_BYTES)]; + }, + ); } async function makeWorker( diff --git a/host/test/advisory-lock-retry.test.ts b/host/test/advisory-lock-retry.test.ts index 3a270e173c..392087ad6e 100644 --- a/host/test/advisory-lock-retry.test.ts +++ b/host/test/advisory-lock-retry.test.ts @@ -7,6 +7,7 @@ import { PROCESS_STATE_RUNNING, } from "../src/generated/abi"; import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; const EAGAIN = 11; const EINTR = 4; @@ -71,6 +72,7 @@ describe("Rust-owned advisory-lock retry scheduling", () => { }); const worker = createWorker({ kernel_drain_wakeup_events: drain }); worker.kernelMemory = kernelMemory; + installKernelWorkerTestScratch(worker, kernelMemory); worker.processes = new Map([[channel.pid, { channels: [channel], memory: processMemory, @@ -392,6 +394,7 @@ function createFcntlHarness( kernel_dequeue_signal: vi.fn(() => caughtSignal), }); worker.kernelMemory = kernelMemory; + installKernelWorkerTestScratch(worker, kernelMemory); worker.processes = new Map([[channel.pid, { channels: [channel], memory: processMemory, @@ -402,7 +405,8 @@ function createFcntlHarness( } function createWorker(exports: Record): any { - return Object.assign(Object.create(CentralizedKernelWorker.prototype), { + const kernelMemory = createSharedMemory(); + const worker = Object.assign(Object.create(CentralizedKernelWorker.prototype), { kernel: { releaseProcessViews: vi.fn(), toKernelPtr: (value: number | bigint) => value, @@ -415,11 +419,13 @@ function createWorker(exports: Record): any { ...exports, }, }, - scratchOffset: 128, + kernelMemory, processes: new Map(), channelTids: new Map(), hostReaped: new Set(), }); + installKernelWorkerTestScratch(worker, kernelMemory); + return worker; } function createSharedMemory(): WebAssembly.Memory { diff --git a/host/test/browser-kernel.test.ts b/host/test/browser-kernel.test.ts index ef6444aad0..54d35037ae 100644 --- a/host/test/browser-kernel.test.ts +++ b/host/test/browser-kernel.test.ts @@ -814,6 +814,42 @@ describe("BrowserKernel", () => { await expect(invalidPromise).rejects.toThrow("invalid memory-page count"); }); + it("reads and validates retained spawn scratch telemetry", async () => { + const BrowserKernel = await loadBrowserKernel(); + const kernel = new BrowserKernel({ kernelOwnedFs: true }); + const initPromise = kernel.initFromImage({ + kernelWasm: new ArrayBuffer(8), + vfsImage: new Uint8Array(0), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const worker = MockWorker.instances[0]!; + worker.simulateMessage({ type: "ready" }); + await initPromise; + + const capacityPromise = kernel.getSpawnScratchCapacity(); + await new Promise((resolve) => setTimeout(resolve, 0)); + const request = worker.lastMessage("get_spawn_scratch_capacity"); + expect(request).toBeDefined(); + worker.simulateMessage({ + type: "response", + requestId: request.requestId, + result: 84_386, + }); + await expect(capacityPromise).resolves.toBe(84_386); + + const invalidPromise = kernel.getSpawnScratchCapacity(); + await new Promise((resolve) => setTimeout(resolve, 0)); + const invalidRequest = worker.lastMessage("get_spawn_scratch_capacity"); + worker.simulateMessage({ + type: "response", + requestId: invalidRequest.requestId, + result: -1, + }); + await expect(invalidPromise).rejects.toThrow( + "invalid spawn scratch capacity", + ); + }); + describe("fetchInKernel", () => { async function bootedKernel() { const BrowserKernel = await loadBrowserKernel(); diff --git a/host/test/centralized-test-helper.ts b/host/test/centralized-test-helper.ts index eaa01e71b4..5eaefe3989 100644 --- a/host/test/centralized-test-helper.ts +++ b/host/test/centralized-test-helper.ts @@ -150,6 +150,9 @@ export interface RunProgramOptions { * non-forking-spawn regression tests. Worker-thread mode uses live samples; * main-thread fixtures still return the final value as `forkCount`. */ captureForkCount?: boolean; + /** Capture kernel-owned large-spawn retention and memory pages immediately + * after program exit, before the dedicated kernel worker is destroyed. */ + captureSpawnScratchStats?: boolean; /** Use the canonical rootfs image in worker-thread mode. Defaults to true. */ useDefaultRootfs?: boolean; /** Exact VFS image for tests that stage package runtime files. Overrides @@ -179,6 +182,8 @@ export interface RunProgramResult { /** Final fork counter captured before main-thread-mode teardown. Main-thread * test fixtures do not use the production host-owned reaping path. */ forkCount?: bigint; + spawnScratchCapacity?: number; + kernelMemoryPages?: number; } /** @@ -302,6 +307,8 @@ async function runInWorkerThread(options: RunProgramOptions): Promise {}); @@ -330,6 +341,8 @@ async function runInWorkerThread(options: RunProgramOptions): Promise(); const processForkHostImports = new Map(); let mainThreadForkCount: bigint | undefined; + let spawnScratchCapacity: number | undefined; + let kernelMemoryPages: number | undefined; let pid = 0; @@ -577,8 +592,6 @@ async function runOnMainThread(options: RunProgramOptions): Promise { const wasmPath = options.execPrograms?.get(path); if (!wasmPath) return -2; - if (!kernelWorker.supportsExecMetadataReplacement()) return -38; - const newProgramBytes = loadProgramWasm(wasmPath); const newPtrWidth = detectPtrWidth(newProgramBytes); const sourcePtrWidth = processPtrWidths.get(execPid) ?? newPtrWidth; @@ -982,6 +995,10 @@ async function runOnMainThread(options: RunProgramOptions): Promise sum + c.length, 0); const stdoutBytes = new Uint8Array(totalLen); @@ -998,5 +1015,7 @@ async function runOnMainThread(options: RunProgramOptions): Promise(), @@ -66,7 +65,8 @@ function makeCloneHarness( exports: { kernel_get_process_exit_signal: vi.fn(() => -1), kernel_validate_task: vi.fn(() => 0), - kernel_handle_channel: vi.fn(() => { + kernel_handle_channel: vi.fn((offset: number) => { + const kernelView = new DataView(kernelMemory.buffer, offset); kernelView.setBigInt64(CH_RETURN, BigInt(kernelTid), true); kernelView.setUint32(CH_ERRNO, 0, true); return 0; @@ -75,6 +75,10 @@ function makeCloneHarness( }, }, ) as CentralizedKernelWorker; + installKernelWorkerTestScratch( + worker as unknown as Record, + kernelMemory, + ); (worker as any).callbacks = { onClone: (attachment: unknown) => { if (autoAttach) { @@ -112,8 +116,7 @@ function makeChannelOwnershipHarness() { }; const validateTask = vi.fn(() => 0); const retireExactChannelAsyncState = vi.fn(); - const kernelMemory = new WebAssembly.Memory({ initial: 1, maximum: 1 }); - const kernelView = new DataView(kernelMemory.buffer); + const kernelMemory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); let nextKernelTid = 0; const worker = Object.assign( Object.create(CentralizedKernelWorker.prototype), @@ -125,7 +128,6 @@ function makeChannelOwnershipHarness() { }, }, kernelMemory, - scratchOffset: 0, currentHandlePid: 0, activeChannels: [mainChannel], channelTids: new Map(), @@ -150,7 +152,8 @@ function makeChannelOwnershipHarness() { exports: { kernel_get_process_exit_signal: vi.fn(() => -1), kernel_validate_task: validateTask, - kernel_handle_channel: vi.fn(() => { + kernel_handle_channel: vi.fn((offset: number) => { + const kernelView = new DataView(kernelMemory.buffer, offset); kernelView.setBigInt64(CH_RETURN, BigInt(nextKernelTid), true); kernelView.setUint32(CH_ERRNO, 0, true); return 0; @@ -159,6 +162,10 @@ function makeChannelOwnershipHarness() { }, }, ) as CentralizedKernelWorker; + installKernelWorkerTestScratch( + worker as unknown as Record, + kernelMemory, + ); return { mainChannel, @@ -333,6 +340,11 @@ describe("kernel TID authority", () => { undefined, KERNEL_TID, 0, + [], + { + tid: KERNEL_TID, + parentTidPointer: undefined, + }, ); }); diff --git a/host/test/compiled-worker-entry.test.ts b/host/test/compiled-worker-entry.test.ts new file mode 100644 index 0000000000..0b45d7357c --- /dev/null +++ b/host/test/compiled-worker-entry.test.ts @@ -0,0 +1,93 @@ +import { + mkdtempSync, + mkdirSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + compiledWorkerEntryIsCurrent, + hostBuildFingerprintBanner, +} from "../src/compiled-worker-entry"; + +const temporaryDirectories: string[] = []; + +function writeAt(path: string, contents: string, seconds: number): void { + writeFileSync(path, contents); + utimesSync(path, seconds, seconds); +} + +function createSourceCheckout(root: string): { + entry: string; + imported: string; +} { + const source = join(root, "src"); + const nested = join(source, "nested"); + mkdirSync(source); + mkdirSync(nested); + for (const file of [ + "package-lock.json", + "package.json", + "tsconfig.json", + "tsup.config.ts", + ]) { + writeAt(join(root, file), `${file}\n`, 100); + } + const entry = join(source, "entry.ts"); + const imported = join(nested, "imported.ts"); + writeAt(entry, "import './nested/imported';", 100); + writeAt(imported, "export const value = 1;", 100); + return { entry, imported }; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("compiled worker freshness", () => { + it("invalidates a bundle when source or build configuration changes", () => { + const root = mkdtempSync(join(tmpdir(), "kandelo-worker-freshness-")); + temporaryDirectories.push(root); + const { entry, imported } = createSourceCheckout(root); + const bundle = join(root, "entry.js"); + + writeAt(bundle, hostBuildFingerprintBanner(root), 200); + expect(compiledWorkerEntryIsCurrent(entry, bundle)).toBe(true); + + writeAt(imported, "export const value = 2;", 100); + expect(compiledWorkerEntryIsCurrent(entry, bundle)).toBe(false); + + writeAt(bundle, hostBuildFingerprintBanner(root), 200); + expect(compiledWorkerEntryIsCurrent(entry, bundle)).toBe(true); + + writeAt(join(root, "tsup.config.ts"), "changed config\n", 100); + expect(compiledWorkerEntryIsCurrent(entry, bundle)).toBe(false); + }); + + it("does not accept a touched stale bundle without an exact marker", () => { + const root = mkdtempSync(join(tmpdir(), "kandelo-worker-touched-")); + temporaryDirectories.push(root); + const { entry } = createSourceCheckout(root); + const bundle = join(root, "entry.js"); + + writeAt(bundle, "/* stale but newly touched */", 10_000); + expect(compiledWorkerEntryIsCurrent(entry, bundle)).toBe(false); + }); + + it("accepts a packaged bundle when source files are absent", () => { + const root = mkdtempSync(join(tmpdir(), "kandelo-worker-package-")); + temporaryDirectories.push(root); + const bundle = join(root, "entry.js"); + writeAt(bundle, "/* packaged */", 100); + + expect( + compiledWorkerEntryIsCurrent(join(root, "entry.ts"), bundle), + ).toBe(true); + }); +}); diff --git a/host/test/connect-pending-retry.test.ts b/host/test/connect-pending-retry.test.ts index 1fa3c7a385..798aaea7b5 100644 --- a/host/test/connect-pending-retry.test.ts +++ b/host/test/connect-pending-retry.test.ts @@ -8,6 +8,7 @@ import { CH_SYSCALL, } from "../src/generated/abi"; import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; const EINPROGRESS = 115; const EALREADY = 114; @@ -45,10 +46,10 @@ function createConnectHarness( new Uint8Array(processMemory.buffer, addrPtr + 4, 4).set([203, 0, 113, 9]); let resultIndex = 0; - const handleChannel = vi.fn(() => { + const handleChannel = vi.fn((offset: number) => { const result = results[Math.min(resultIndex, results.length - 1)]; resultIndex++; - const kernelView = new DataView(kernelMemory.buffer); + const kernelView = new DataView(kernelMemory.buffer, offset); kernelView.setBigInt64(CH_RETURN, BigInt(result.retVal), true); kernelView.setUint32(CH_ERRNO, result.errVal, true); return 0; @@ -63,7 +64,7 @@ function createConnectHarness( }, }, kernelMemory, - scratchOffset: 0, + processes: new Map([[channel.pid, { ptrWidth: 4 }]]), currentHandlePid: 0, config: {}, syscallRing: new Map(), @@ -90,6 +91,7 @@ function createConnectHarness( completeChannelRaw: vi.fn(), relistenChannel: vi.fn(), }); + installKernelWorkerTestScratch(worker, kernelMemory); return { args, channel, completeChannel, handleChannel, worker }; } @@ -132,7 +134,7 @@ describe("pending AF_INET connect routing", () => { expect(harness.handleChannel).toHaveBeenCalledTimes(2); expect(harness.completeChannel).toHaveBeenCalledOnce(); - expect(harness.completeChannel.mock.calls[0].slice(-2)).toEqual([0, 0]); + expect(harness.completeChannel.mock.calls[0].slice(4, 6)).toEqual([0, 0]); expect(harness.worker.pendingPollRetries.size).toBe(0); }); @@ -154,7 +156,7 @@ describe("pending AF_INET connect routing", () => { expect(harness.handleChannel).toHaveBeenCalledTimes(3); expect(harness.completeChannel).toHaveBeenCalledOnce(); - expect(harness.completeChannel.mock.calls[0].slice(-2)).toEqual([-1, ECONNREFUSED]); + expect(harness.completeChannel.mock.calls[0].slice(4, 6)).toEqual([-1, ECONNREFUSED]); expect(harness.worker.pendingPollRetries.size).toBe(0); }); @@ -167,7 +169,7 @@ describe("pending AF_INET connect routing", () => { harness.worker.handleSyscall(harness.channel); expect(harness.completeChannel).toHaveBeenCalledOnce(); - expect(harness.completeChannel.mock.calls[0].slice(-2)).toEqual([-1, EINPROGRESS]); + expect(harness.completeChannel.mock.calls[0].slice(4, 6)).toEqual([-1, EINPROGRESS]); expect(harness.worker.pendingPollRetries.size).toBe(0); }); diff --git a/host/test/datagram-wakeup.test.ts b/host/test/datagram-wakeup.test.ts index 35293d7c05..b3819a22a2 100644 --- a/host/test/datagram-wakeup.test.ts +++ b/host/test/datagram-wakeup.test.ts @@ -1,15 +1,15 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; const WAKE_DATAGRAM_WRITABLE = 8; function createSharedMemory(): WebAssembly.Memory { - return new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); + return new WebAssembly.Memory({ initial: 2, maximum: 2, shared: true }); } function createWorkerHarness(): any { const memory = createSharedMemory(); - const scratchOffset = 128; const drain = (outPtr: number): number => { const bytes = new Uint8Array(memory.buffer, outPtr, 5); bytes.fill(0); @@ -17,11 +17,10 @@ function createWorkerHarness(): any { return 1; }; - return Object.assign(Object.create(CentralizedKernelWorker.prototype), { + const worker = Object.assign(Object.create(CentralizedKernelWorker.prototype), { kernel: { toKernelPtr: (value: number | bigint) => Number(value) }, kernelInstance: { exports: { kernel_drain_wakeup_events: drain } }, kernelMemory: memory, - scratchOffset, processes: new Map(), pendingPollRetries: new Map(), pendingSelectRetries: new Map(), @@ -29,6 +28,8 @@ function createWorkerHarness(): any { pendingPipeWriters: new Map(), wakeScheduled: false, }); + installKernelWorkerTestScratch(worker, memory); + return worker; } afterEach(() => { diff --git a/host/test/deferred-worker-start.test.ts b/host/test/deferred-worker-start.test.ts index 472629fe13..9b4f61640a 100644 --- a/host/test/deferred-worker-start.test.ts +++ b/host/test/deferred-worker-start.test.ts @@ -11,6 +11,7 @@ import { PROCESS_STATE_RUNNING, PROCESS_STATE_STOPPED, } from "../src/generated/abi"; +import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; describe("DeferredWorkerHandle", () => { it("does not construct or dispatch to a Worker before start", () => { @@ -188,7 +189,7 @@ describe("stopped process Worker launch gate", () => { worker.processes.set(41, { memory, channels: [channel] }); worker.kernel = { toKernelPtr: (value: number) => value }; worker.kernelMemory = createSharedMemory(); - worker.scratchOffset = 0; + installKernelWorkerTestScratch(worker, worker.kernelMemory); worker.channelTids = new Map(); worker.kernelInstance.exports.kernel_dequeue_signal = vi.fn(() => { processState = PROCESS_STATE_EXITED; @@ -218,7 +219,7 @@ describe("stopped process Worker launch gate", () => { worker.stoppedPids.add(41); worker.kernel = { toKernelPtr: (value: number) => value }; worker.kernelMemory = createSharedMemory(); - worker.scratchOffset = 0; + installKernelWorkerTestScratch(worker, worker.kernelMemory); worker.kernelInstance.exports.kernel_dequeue_signal = vi.fn(() => { processState = PROCESS_STATE_STOPPED; return 0; @@ -303,6 +304,7 @@ describe("stopped process Worker launch gate", () => { consecutiveSyscalls: 0, }; const ptidPtr = 512; + const replacementPtidPtr = 768; const tid = 99; const view = new DataView(memory.buffer); view.setUint32(CH_SYSCALL, ABI_SYSCALLS.Clone, true); @@ -326,7 +328,12 @@ describe("stopped process Worker launch gate", () => { outputWrites: [], retVal: tid, errVal: 0, + materialized: true, relistenRequested: true, + deferredClone: { + tid, + parentTidPointer: ptidPtr, + }, }, relistenRequested: true, }); @@ -337,12 +344,24 @@ describe("stopped process Worker launch gate", () => { ), ).toBe("deferred"); + // A sibling sharing this process memory may replace mailbox bytes while + // the stopped completion is parked. Rollback must retain the pointer + // validated for the original clone instead of trusting this replacement. + view.setBigInt64(CH_ARGS, BigInt(0x00100000), true); + view.setBigInt64( + CH_ARGS + 2 * CH_ARG_SIZE, + BigInt(replacementPtidPtr), + true, + ); + view.setInt32(replacementPtidPtr, 0x12345678, true); + processState = 0; worker.resumeStoppedProcess(41); expect(cancel).toHaveBeenCalledOnce(); expect(notifyCrash).not.toHaveBeenCalled(); expect(view.getInt32(ptidPtr, true)).toBe(0); + expect(view.getInt32(replacementPtidPtr, true)).toBe(0x12345678); expect(publish).toHaveBeenCalledWith( channel, expect.objectContaining({ retVal: -1, errVal: 12 }), diff --git a/host/test/exec-state-tracking.test.ts b/host/test/exec-state-tracking.test.ts index bb3dfc148b..802b337dc8 100644 --- a/host/test/exec-state-tracking.test.ts +++ b/host/test/exec-state-tracking.test.ts @@ -20,6 +20,7 @@ import { HOST_INTERCEPTED_SYSCALLS, } from "../src/generated/abi"; import { EXEC_RETIRE_SIGNAL_CODE } from "../src/worker-protocol"; +import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; describe("exec host-state transition", () => { it("retires only the exact pending exec generation without relistening", () => { @@ -254,6 +255,14 @@ describe("exec host-state transition", () => { const mainChannel = createChannel(7, memory, 0); const threadChannel = createChannel(7, memory, 0x10000); const completeSleep = vi.fn(); + const mainDetachedOutput = [{ + ptr: 0x800, + bytes: Uint8Array.of(1), + }]; + const threadDetachedOutput = [{ + ptr: 0x900, + bytes: Uint8Array.of(2), + }]; const worker = createWorker({ processes: new Map([[7, { channels: [mainChannel, threadChannel], @@ -263,24 +272,46 @@ describe("exec host-state transition", () => { }); expect(worker.handleSleepDelay( - mainChannel, ABI_SYSCALLS.Usleep, [50_000], 0, 0, + mainChannel, + ABI_SYSCALLS.Usleep, + [50_000], + 0, + 0, + undefined, + mainDetachedOutput, )).toBe(true); expect(worker.handleSleepDelay( - threadChannel, ABI_SYSCALLS.Usleep, [10_000], 0, 0, + threadChannel, + ABI_SYSCALLS.Usleep, + [10_000], + 0, + 0, + undefined, + threadDetachedOutput, )).toBe(true); expect(worker.pendingSleeps.size).toBe(2); await vi.advanceTimersByTimeAsync(10); expect(completeSleep).toHaveBeenCalledTimes(1); expect(completeSleep).toHaveBeenLastCalledWith( - threadChannel, ABI_SYSCALLS.Usleep, [10_000], 0, 0, + threadChannel, + ABI_SYSCALLS.Usleep, + [10_000], + 0, + 0, + threadDetachedOutput, ); expect(worker.pendingSleeps.has(mainChannel)).toBe(true); await vi.advanceTimersByTimeAsync(40); expect(completeSleep).toHaveBeenCalledTimes(2); expect(completeSleep).toHaveBeenLastCalledWith( - mainChannel, ABI_SYSCALLS.Usleep, [50_000], 0, 0, + mainChannel, + ABI_SYSCALLS.Usleep, + [50_000], + 0, + 0, + mainDetachedOutput, ); expect(worker.pendingSleeps.size).toBe(0); } finally { @@ -441,8 +472,7 @@ describe("exec host-state transition", () => { processes: new Map([[7, { channels: [channel], memory }]]), callbacks: { onSpawn }, completeChannel, - kernelMemory: new WebAssembly.Memory({ initial: 1 }), - scratchOffset: 0, + kernelMemory: new WebAssembly.Memory({ initial: 2 }), toKernelPtr: (value: number) => value, kernelInstance: { exports: { @@ -492,8 +522,7 @@ describe("exec host-state transition", () => { processes: new Map([[7, { channels: [channel], memory }]]), callbacks: { onSpawn: vi.fn(() => spawned) }, completeChannel: vi.fn(), - kernelMemory: new WebAssembly.Memory({ initial: 1 }), - scratchOffset: 0, + kernelMemory: new WebAssembly.Memory({ initial: 2 }), toKernelPtr: (value: number) => value, kernelInstance: { exports: { @@ -686,12 +715,10 @@ describe("exec host-state transition", () => { it("replaces metadata entry by entry and clears an empty environment", () => { const kernelMemory = new WebAssembly.Memory({ initial: 2 }); - const scratchOffset = 1024; const clears: Array<[number, number]> = []; const pushes: Array<{ pid: number; kind: number; bytes: Uint8Array }> = []; const worker = createWorker({ kernelMemory, - scratchOffset, toKernelPtr: (value: number) => value, kernelInstance: { exports: { @@ -731,28 +758,30 @@ describe("exec host-state transition", () => { ]); }); - it("feature-detects metadata replacement and retains legacy small argv", () => { - const kernelMemory = new WebAssembly.Memory({ initial: 1 }); - const setArgv = vi.fn((_pid: number, _ptr: number, len: number) => { - expect(new TextDecoder().decode( - new Uint8Array(kernelMemory.buffer, 0, len), - )).toBe("program\0arg"); - return 0; - }); + it.each([ + "kernel_clear_process_metadata", + "kernel_push_process_metadata_entry", + ])("fails loudly when required metadata export %s is absent", (missing) => { + const kernelMemory = new WebAssembly.Memory({ initial: 2 }); + const clear = vi.fn(() => 0); + const push = vi.fn(() => 0); + const exports: Record = { + kernel_clear_process_metadata: clear, + kernel_push_process_metadata_entry: push, + }; + delete exports[missing]; const worker = createWorker({ kernelMemory, - scratchOffset: 0, toKernelPtr: (value: number) => value, - kernelInstance: { - exports: { kernel_set_process_argv: setArgv }, - }, + kernelInstance: { exports }, }); + const scratchBefore = new Uint8Array(kernelMemory.buffer).slice(); - expect(worker.supportsExecMetadataReplacement()).toBe(false); - worker.replaceProcessMetadata(7, 0, ["program", "arg"]); - expect(setArgv).toHaveBeenCalled(); - expect(() => worker.replaceProcessMetadata(7, 1, [])) - .toThrow(/missing bounded process metadata exports/); + expect(() => worker.replaceProcessMetadata(7, 0, ["program", "arg"])) + .toThrow(/required bounded process metadata exports/); + expect(clear).not.toHaveBeenCalled(); + expect(push).not.toHaveBeenCalled(); + expect(new Uint8Array(kernelMemory.buffer)).toEqual(scratchBefore); }); it("flushes file-backed mappings before commit and forgets them afterward", () => { @@ -826,19 +855,18 @@ describe("exec host-state transition", () => { const worker = createWorker({ currentHandlePid: 0, kernelMemory, - scratchOffset: 0, toKernelPtr: (value: number) => value, bindKernelTidForChannel: vi.fn(), kernelInstance: { exports: { - kernel_handle_channel: () => { - const args = new DataView(kernelMemory.buffer); + kernel_handle_channel: (offset: number) => { + const args = new DataView(kernelMemory.buffer, offset); const requested = Number(args.getBigInt64( CH_ARGS + 2 * CH_ARG_SIZE, true, )); kernelMemory.grow(1); - new DataView(kernelMemory.buffer).setBigInt64( + new DataView(kernelMemory.buffer, offset).setBigInt64( CH_RETURN, BigInt(requested), true, @@ -884,7 +912,6 @@ describe("exec host-state transition", () => { shmSegmentVersions: new Map([[3, 0]]), currentHandlePid: 0, kernelMemory, - scratchOffset: 0, getKernelMem: () => new Uint8Array(kernelMemory.buffer), toKernelPtr: (value: number) => value, kernelInstance: { @@ -897,7 +924,12 @@ describe("exec host-state transition", () => { }); expect(worker.prepareAddressSpaceForExec(7)).toBe(0); - expect(writeChunk).toHaveBeenCalledWith(3, 0, 72, 4); + expect(writeChunk).toHaveBeenCalledWith( + 3, + 0, + worker.testScratchPointer + CH_DATA, + 4, + ); expect(detach).not.toHaveBeenCalled(); expect(worker.shmMappings.has(7)).toBe(true); @@ -1227,6 +1259,12 @@ function createWorker(overrides: Record): any { ...(kernelInstance.exports ?? {}), }, }; + if (worker.kernelMemory instanceof WebAssembly.Memory) { + worker.testScratchPointer = installKernelWorkerTestScratch( + worker, + worker.kernelMemory, + ); + } return worker; } @@ -1237,8 +1275,7 @@ function issueThreadAttachment( fnPtr = 1, argPtr = 2, ) { - const kernelMemory = new WebAssembly.Memory({ initial: 1, maximum: 1 }); - const kernelView = new DataView(kernelMemory.buffer); + const kernelMemory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); let attachment: Parameters[0] | undefined; new DataView(channel.memory.buffer, channel.channelOffset) @@ -1259,15 +1296,18 @@ function issueThreadAttachment( releaseProcessViews: vi.fn(), }, kernelMemory, - scratchOffset: 0, currentHandlePid: 0, threadCtidPtrs: (worker as any).threadCtidPtrs ?? new Map(), bindKernelTidForChannel: vi.fn(), }); - (worker as any).kernelInstance.exports.kernel_handle_channel = vi.fn(() => { - kernelView.setBigInt64(CH_RETURN, BigInt(tid), true); - return 0; - }); + installKernelWorkerTestScratch(worker as any, kernelMemory); + (worker as any).kernelInstance.exports.kernel_handle_channel = vi.fn( + (offset: number) => { + const kernelView = new DataView(kernelMemory.buffer, offset); + kernelView.setBigInt64(CH_RETURN, BigInt(tid), true); + return 0; + }, + ); (worker as any).handleClone(channel, [0, 0, 0, 0, 0, 0]); if (!attachment) throw new Error("clone callback did not receive attachment"); return attachment; diff --git a/host/test/file-shared-memory.test.ts b/host/test/file-shared-memory.test.ts index c622bb42db..53aab4054f 100644 --- a/host/test/file-shared-memory.test.ts +++ b/host/test/file-shared-memory.test.ts @@ -11,6 +11,7 @@ import { } from "../src/generated/abi"; import { WasmPosixKernel } from "../src/kernel"; import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; const MAP_SHARED = 1; const MAP_PRIVATE = 2; @@ -199,18 +200,19 @@ type FileHarness = ReturnType; function configureKernelSyscallHarness(h: FileHarness, pid: number) { const kernelHandle = vi.fn(); const completeChannel = vi.fn(); + const kernelMemory = new WebAssembly.Memory({ initial: 2 }); Object.assign(h.kw as any, { config: {}, syscallRing: new Map(), syscallTraceEnabled: false, - kernelMemory: new WebAssembly.Memory({ initial: 2 }), - scratchOffset: 0, + kernelMemory, kernelInstance: { exports: { kernel_handle_channel: kernelHandle } }, formatSyscallEntry: vi.fn(() => "memory syscall"), synchronizeSharedMemoryForBoundary: vi.fn(), flushSharedMappingsBeforeFileSyscall: vi.fn(() => true), completeChannel, }); + installKernelWorkerTestScratch(h.kw as any, kernelMemory); return { completeChannel, kernelHandle }; } @@ -297,18 +299,19 @@ describe("file/POSIX MAP_SHARED page cache", () => { const kernelHandle = vi.fn(); const completeChannel = vi.fn(); + const kernelMemory = new WebAssembly.Memory({ initial: 2 }); Object.assign(h.kw as any, { config: {}, syscallRing: new Map(), syscallTraceEnabled: false, - kernelMemory: new WebAssembly.Memory({ initial: 2 }), - scratchOffset: 0, + kernelMemory, kernelInstance: { exports: { kernel_handle_channel: kernelHandle } }, formatSyscallEntry: vi.fn(() => "mmap"), synchronizeSharedMemoryForBoundary: vi.fn(), flushSharedMappingsBeforeFileSyscall: vi.fn(() => true), completeChannel, }); + installKernelWorkerTestScratch(h.kw as any, kernelMemory); const args = [addr, 4096, PROT_WRITE, MAP_SHARED | MAP_FIXED, 4, 0]; const view = new DataView(channel.memory.buffer, channel.channelOffset); @@ -1193,18 +1196,19 @@ describe("file/POSIX MAP_SHARED page cache", () => { const kernelHandle = vi.fn(); const relistenChannel = vi.fn(); + const kernelMemory = new WebAssembly.Memory({ initial: 2 }); Object.assign(h.kw as any, { config: {}, syscallRing: new Map(), syscallTraceEnabled: false, - kernelMemory: new WebAssembly.Memory({ initial: 2 }), - scratchOffset: 0, + kernelMemory, kernelInstance: { exports: { kernel_handle_channel: kernelHandle } }, clearSocketTimeout: vi.fn(), clearReadinessWait: vi.fn(), pendingCancels: new Set(), relistenChannel, }); + installKernelWorkerTestScratch(h.kw as any, kernelMemory); writeChannelSyscall(channel, ABI_SYSCALLS.Getpid, []); const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); @@ -1281,6 +1285,34 @@ describe("file/POSIX MAP_SHARED page cache", () => { expect(backing.version).toBeGreaterThan(version); }); + it("does not alias an unsafe exact pwrite offset in shared-mapping state", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + expect(h.map(pid, 4, addr)).toBe(true); + const exactOffset = BigInt(Number.MAX_SAFE_INTEGER) + 2n; + const reload = vi.spyOn( + h.kw as any, + "reloadSharedMmapBackingForFd", + ).mockImplementation(() => {}); + const update = vi.spyOn( + h.kw as any, + "updateSharedMmapBackingFromProcessBuffer", + ); + + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), + ABI_SYSCALLS.Pwrite, + [4, 0x7000, 1, Number(exactOffset)], + 1, + 0, + exactOffset, + ); + + expect(update).not.toHaveBeenCalled(); + expect(reload).toHaveBeenCalledWith(h.channels.get(pid), 4); + }); + it("rejects shared memfd mappings deliberately without affecting private mmap", () => { const h = createFileHarness(); (h.kw as any).getFdStatForSharedMapping.mockReturnValue({ diff --git a/host/test/generated-abi.test.ts b/host/test/generated-abi.test.ts index 5391ce188c..dc0a5706ce 100644 --- a/host/test/generated-abi.test.ts +++ b/host/test/generated-abi.test.ts @@ -19,10 +19,18 @@ import { CH_REQUEST_FLAGS, CH_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY, CH_RETURN, + CH_SIG_ALT_SIZE, + CH_SIG_ALT_SP, + CH_SIG_AREA_SIZE, CH_SIG_BASE, + CH_SIG_DELIVERY_SIZE, CH_SIG_FLAGS, CH_SIG_HANDLER, + CH_SIGINFO_WORD_1, + CH_SIGINFO_WORD_2, CH_SIG_OLD_MASK, + CH_SIG_SI_CODE, + CH_SIG_SI_VALUE, CH_SIG_SIGNUM, CH_STATUS, CH_SYSCALL, @@ -57,6 +65,19 @@ import { PROCESS_MEMORY_THREAD_SLOTS_NONE, PROCESS_MEMORY_THREAD_SLOTS_USE_HOST_DEFAULT, PROCESS_MEMORY_WASM_PAGE_SIZE, + PROCESS_SIGINFO_CODE_OFFSET, + PROCESS_SIGINFO_ERRNO_OFFSET, + PROCESS_SIGINFO_SIGNO_OFFSET, + PROCESS_SIGINFO_WASM32_PID_OFFSET, + PROCESS_SIGINFO_WASM32_SIZE, + PROCESS_SIGINFO_WASM32_UID_OFFSET, + PROCESS_SIGINFO_WASM32_VALUE_OFFSET, + PROCESS_SIGINFO_WASM32_VALUE_SIZE, + PROCESS_SIGINFO_WASM64_PID_OFFSET, + PROCESS_SIGINFO_WASM64_SIZE, + PROCESS_SIGINFO_WASM64_UID_OFFSET, + PROCESS_SIGINFO_WASM64_VALUE_OFFSET, + PROCESS_SIGINFO_WASM64_VALUE_SIZE, STRUCT_SIZE_WASM_DIRENT, STRUCT_SIZE_WASM_POLL_FD, STRUCT_SIZE_WASM_STAT, @@ -390,7 +411,20 @@ describe("generated host ABI bindings", () => { expect(CH_SIG_SIGNUM).toBe(signalOffset("SIG_SIGNUM")); expect(CH_SIG_HANDLER).toBe(signalOffset("SIG_HANDLER")); expect(CH_SIG_FLAGS).toBe(signalOffset("SIG_FLAGS")); + expect(CH_SIG_SI_VALUE).toBe(signalOffset("SIG_SI_VALUE")); expect(CH_SIG_OLD_MASK).toBe(signalOffset("SIG_OLD_MASK")); + expect(CH_SIG_SI_CODE).toBe(signalOffset("SIG_SI_CODE")); + expect(CH_SIGINFO_WORD_1).toBe(signalOffset("SIGINFO_WORD_1")); + expect(CH_SIGINFO_WORD_2).toBe(signalOffset("SIGINFO_WORD_2")); + expect(CH_SIG_ALT_SP).toBe(signalOffset("SIG_ALT_SP")); + expect(CH_SIG_ALT_SIZE).toBe(signalOffset("SIG_ALT_SIZE")); + expect(CH_SIG_AREA_SIZE).toBe(snapshot.channel_signal_area.area_size); + expect(CH_SIG_DELIVERY_SIZE).toBe( + snapshot.channel_signal_area.delivery_size, + ); + expect( + CH_SIG_AREA_SIZE - CH_SIG_DELIVERY_SIZE, + ).toBe(snapshot.channel_signal_area.reserved_tail_size); }); it("match Rust-owned syscall and struct metadata", () => { @@ -414,6 +448,42 @@ describe("generated host ABI bindings", () => { expect(SYSCALL_ARGS).toEqual(snapshot.syscall_arg_descriptors); }); + it("match caller-native siginfo layouts for both pointer widths", () => { + const siginfo = snapshot.process_native_layouts.siginfo; + expect(PROCESS_SIGINFO_SIGNO_OFFSET).toBe(siginfo.signo_offset); + expect(PROCESS_SIGINFO_ERRNO_OFFSET).toBe(siginfo.errno_offset); + expect(PROCESS_SIGINFO_CODE_OFFSET).toBe(siginfo.code_offset); + expect({ + size: PROCESS_SIGINFO_WASM32_SIZE, + pid_offset: PROCESS_SIGINFO_WASM32_PID_OFFSET, + uid_offset: PROCESS_SIGINFO_WASM32_UID_OFFSET, + value_offset: PROCESS_SIGINFO_WASM32_VALUE_OFFSET, + value_size: PROCESS_SIGINFO_WASM32_VALUE_SIZE, + }).toEqual(siginfo.wasm32); + expect({ + size: PROCESS_SIGINFO_WASM64_SIZE, + pid_offset: PROCESS_SIGINFO_WASM64_PID_OFFSET, + uid_offset: PROCESS_SIGINFO_WASM64_UID_OFFSET, + value_offset: PROCESS_SIGINFO_WASM64_VALUE_OFFSET, + value_size: PROCESS_SIGINFO_WASM64_VALUE_SIZE, + }).toEqual(siginfo.wasm64); + }); + + it("makes every generated pointer nullability decision explicit", () => { + for (const [syscall, descriptors] of Object.entries(SYSCALL_ARGS)) { + for (const descriptor of descriptors) { + const nullable = descriptor.nullable === true; + const required = descriptor.required === true; + if (nullable === required) { + throw new Error( + `syscall ${syscall} arg ${descriptor.argIndex} must be exactly one of nullable or required`, + ); + } + } + } + expect(SYSCALL_ARGS[ABI_SYSCALLS.Prctl]).toBeUndefined(); + }); + it("match Rust-owned host adapter manifest metadata", () => { expect(HOST_ADAPTER_VERSION).toBe(snapshot.host_adapter.version); expect(HOST_ADAPTER_MANIFEST_MAGIC).toBe(snapshot.host_adapter.manifest.magic); diff --git a/host/test/global-setup.ts b/host/test/global-setup.ts index f5cd197053..f640829fcd 100644 --- a/host/test/global-setup.ts +++ b/host/test/global-setup.ts @@ -4,8 +4,10 @@ * installed before tests run. * * Uses wasm32posix-cc from the SDK for C, and wat2wasm (wabt) for WAT - * fixtures. Outputs are only rebuilt when the source is newer. The - * chromium check is a no-op when the binary is already cached. + * fixtures. C outputs are rebuilt unless their embedded content digest covers + * the exact source, compiler wrapper/version, installed sysroot, and (where + * used) instrumenter binary. The chromium check is a no-op when the binary is + * already cached. */ import { execFileSync } from "node:child_process"; @@ -13,6 +15,12 @@ import { statSync, existsSync, mkdirSync, rmSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { chromium } from "@playwright/test"; +import { + captureProgramFixtureBuildContract, + programFixtureNeedsRebuild, + stampProgramFixture, + type ProgramFixtureBuildContract, +} from "./program-fixture-freshness"; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(__dirname, "../.."); @@ -37,12 +45,37 @@ const C_TEST_FIXTURES = [ /** Program fixtures resolved through the normal local-binaries contract. */ const RESOLVED_PROGRAM_FIXTURES = [ { + arch: "wasm32", src: join(repoRoot, "programs/scm-rights-pipe-lifetime.c"), out: join( repoRoot, "local-binaries/programs/wasm32/scm-rights-pipe-lifetime.wasm", ), }, + { + arch: "wasm64", + src: join(repoRoot, "programs/scm-rights-pipe-lifetime.c"), + out: join( + repoRoot, + "local-binaries/programs/wasm64/scm-rights-pipe-lifetime.wasm", + ), + }, + { + arch: "wasm32", + src: join(repoRoot, "programs/scm-rights-semantics.c"), + out: join( + repoRoot, + "local-binaries/programs/wasm32/scm-rights-semantics.wasm", + ), + }, + { + arch: "wasm64", + src: join(repoRoot, "programs/scm-rights-semantics.c"), + out: join( + repoRoot, + "local-binaries/programs/wasm64/scm-rights-semantics.wasm", + ), + }, ]; /** C programs that tests depend on. */ @@ -60,10 +93,13 @@ const TEST_PROGRAMS = [ "getdents_boundary_test.c", "terminal_attributes_api_test.c", "rlimit_fsize_test.c", + "kernel_scratch_browser_test.c", "socket_timeout_options_test.c", "unix_listener_exec_test.c", "putenv_test.c", "getaddrinfo_test.c", + "process_native_layout_test.c", + "timerfd_signalfd_scratch_test.c", "sysv_ipc_test.c", "wasm_trap_test.c", "oob_trap_test.c", @@ -145,23 +181,76 @@ function compileCTestProgram( } } +function fixtureBuildContract( + arch: "wasm32" | "wasm64", + forkInstrumented: boolean, +): ProgramFixtureBuildContract { + const compiler = `${arch}posix-cc`; + const compilerVersion = execFileSync(compiler, ["--version"], { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + const inputs = [ + join(repoRoot, "sdk/bin"), + join(repoRoot, "sdk/src"), + join(repoRoot, "sdk/package.json"), + join(repoRoot, "sdk/package-lock.json"), + join(repoRoot, arch === "wasm64" ? "sysroot64" : "sysroot"), + ]; + if (forkInstrumented) { + const configuredTool = process.env.WASM_POSIX_FORK_INSTRUMENT; + const instrumenter = configuredTool + ? configuredTool + : join(repoRoot, "tools/bin/wasm-fork-instrument"); + if (!existsSync(instrumenter) && !configuredTool) { + execFileSync( + "bash", + [join(repoRoot, "scripts/build-fork-instrument-tool.sh")], + { cwd: repoRoot, stdio: "pipe" }, + ); + } + inputs.push( + join(repoRoot, "scripts/run-wasm-fork-instrument.sh"), + instrumenter, + ); + } + return captureProgramFixtureBuildContract( + repoRoot, + `${arch}\nfork=${forkInstrumented}\n${compilerVersion}`, + inputs, + ); +} + export async function setup() { + const wasm32Contract = fixtureBuildContract("wasm32", false); + const wasm32ForkContract = fixtureBuildContract("wasm32", true); + const wasm64Contract = fixtureBuildContract("wasm64", false); + for (const { src, out, forkInstrument = false } of C_TEST_FIXTURES) { - if (!needsRebuild(src, out)) continue; + const contract = forkInstrument ? wasm32ForkContract : wasm32Contract; + if (!programFixtureNeedsRebuild(src, out, contract)) continue; - console.log(`[global-setup] Compiling ${src.slice(repoRoot.length + 1)}...`); + console.log( + `[global-setup] Compiling ${src.slice(repoRoot.length + 1)}...`, + ); compileCTestProgram(src, out, forkInstrument); + stampProgramFixture(src, out, contract); } - for (const { src, out } of RESOLVED_PROGRAM_FIXTURES) { - if (!needsRebuild(src, out)) continue; + for (const { arch, src, out } of RESOLVED_PROGRAM_FIXTURES) { + const contract = arch === "wasm64" ? wasm64Contract : wasm32Contract; + if (!programFixtureNeedsRebuild(src, out, contract)) continue; mkdirSync(dirname(out), { recursive: true }); - console.log(`[global-setup] Compiling ${src.slice(repoRoot.length + 1)}...`); - execFileSync("wasm32posix-cc", [src, "-o", out], { + console.log( + `[global-setup] Compiling ${src.slice(repoRoot.length + 1)} (${arch})...`, + ); + execFileSync(`${arch}posix-cc`, [src, "-o", out], { cwd: repoRoot, stdio: "pipe", }); + stampProgramFixture(src, out, contract); } for (const cFile of TEST_PROGRAMS) { @@ -173,10 +262,14 @@ export async function setup() { continue; } - if (!needsRebuild(src, out)) continue; + const contract = FORK_INSTRUMENTED_PROGRAMS.has(cFile) + ? wasm32ForkContract + : wasm32Contract; + if (!programFixtureNeedsRebuild(src, out, contract)) continue; console.log(`[global-setup] Compiling ${cFile}...`); compileCTestProgram(src, out, FORK_INSTRUMENTED_PROGRAMS.has(cFile)); + stampProgramFixture(src, out, contract); } for (const watFile of WAT_FIXTURES) { diff --git a/host/test/host-adapter-manifest.test.ts b/host/test/host-adapter-manifest.test.ts index fe8600fb75..e9e2d7a4ae 100644 --- a/host/test/host-adapter-manifest.test.ts +++ b/host/test/host-adapter-manifest.test.ts @@ -64,6 +64,24 @@ describe("host adapter manifest validation", () => { ).toThrow(/kernel_alloc_scratch/); }); + it.each([ + "kernel_clear_process_metadata", + "kernel_push_process_metadata_entry", + "kernel_set_cwd", + ])("rejects a kernel missing required scratch transfer export %s", (name) => { + const memory = createMemory(); + writeManifest(memory); + const instance = createInstance({ [name]: undefined }); + + expect(() => + validateKernelHostAdapterManifest( + instance, + memory, + HOST_ADAPTER_REQUIRED_WORKER_FEATURES, + ), + ).toThrow(name); + }); + it("rejects unsupported worker feature bits", () => { const memory = createMemory(); writeManifest(memory); diff --git a/host/test/host-process-pointer-width.test.ts b/host/test/host-process-pointer-width.test.ts new file mode 100644 index 0000000000..7fd206a862 --- /dev/null +++ b/host/test/host-process-pointer-width.test.ts @@ -0,0 +1,408 @@ +import { describe, expect, it, vi } from "vitest"; +import { + ABI_SYSCALLS, + CH_ARGS, + CH_ARG_SIZE, + CH_SYSCALL, +} from "../src/generated/abi"; +import { checkedWasmPointer } from "../src/kernel-scratch"; +import { CentralizedKernelWorker } from "../src/kernel-worker"; + +const PID = 73; +const MAP_PRIVATE = 0x02; +const MAP_FIXED = 0x10; +const MAP_ANONYMOUS = 0x20; +const FUTEX_WAIT = 0; +const FUTEX_REQUEUE = 3; +const FUTEX_WAKE_OP = 5; + +function sharedMemory(): WebAssembly.Memory { + return new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); +} + +function channel(memory: WebAssembly.Memory) { + return { + pid: PID, + memory, + channelOffset: 0, + i32View: new Int32Array(memory.buffer, 0, 1), + consecutiveSyscalls: 0, + }; +} + +function workerHarness( + pointerWidth: 4 | 8 = 8, + kernelPointerWidth: 4 | 8 = pointerWidth, +) { + const memory = sharedMemory(); + const processChannel = channel(memory); + const completeChannelRaw = vi.fn(); + const completeChannel = vi.fn(); + const relistenChannel = vi.fn(); + const synchronizeSharedMemoryForBoundary = vi.fn(); + const kernelHandle = vi.fn(); + const worker = Object.assign( + Object.create(CentralizedKernelWorker.prototype), + { + callbacks: {}, + channelTids: new Map(), + completeChannel, + completeChannelRaw, + config: {}, + hostReaped: new Set(), + kernel: { + framebuffers: { rebindMemory: vi.fn() }, + getKernelPtrWidth: () => kernelPointerWidth, + toKernelPtr: (value: number | bigint) => { + const checked = checkedWasmPointer( + value, + kernelPointerWidth, + "test kernel pointer", + ); + return kernelPointerWidth === 8 ? BigInt(checked) : checked; + }, + }, + kernelInstance: { + exports: { kernel_handle_channel: kernelHandle }, + }, + pendingCancels: new Set(), + pendingFutexWaits: new Map(), + processes: new Map([[ + PID, + { + pid: PID, + memory, + ptrWidth: pointerWidth, + channels: [processChannel], + }, + ]]), + relistenChannel, + sharedMmapBackings: new Map(), + syscallRing: new Map(), + syscallTraceEnabled: false, + syscallTraceRing: [], + synchronizeSharedMemoryForBoundary, + }, + ) as CentralizedKernelWorker; + return { + completeChannel, + completeChannelRaw, + kernelHandle, + memory, + processChannel, + relistenChannel, + synchronizeSharedMemoryForBoundary, + worker, + }; +} + +function writeSyscall( + processChannel: ReturnType, + syscallNr: number, + args: readonly bigint[], +): void { + const view = new DataView( + processChannel.memory.buffer, + processChannel.channelOffset, + ); + view.setUint32(CH_SYSCALL, syscallNr, true); + for (let index = 0; index < 6; index++) { + view.setBigInt64( + CH_ARGS + index * CH_ARG_SIZE, + args[index] ?? 0n, + true, + ); + } +} + +describe("handwritten host process-pointer width checks", () => { + it("keeps a lossless wasm64 MAP_FIXED address above 4 GiB out of low memory", () => { + const h = workerHarness(8); + const lowAlias = 0x8000; + const highAddress = 0x1_0000_8000n; + const processBytes = new Uint8Array(h.memory.buffer); + processBytes[lowAlias] = 0xa5; + writeSyscall(h.processChannel, ABI_SYSCALLS.Mmap, [ + highAddress, + 4096n, + 3n, + BigInt(MAP_PRIVATE | MAP_FIXED | MAP_ANONYMOUS), + -1n, + 0n, + ]); + + (h.worker as any)._handleSyscallInner(h.processChannel); + + expect(h.kernelHandle).not.toHaveBeenCalled(); + expect(h.completeChannel).toHaveBeenCalledWith( + h.processChannel, + ABI_SYSCALLS.Mmap, + [Number(highAddress), 4096, 3, MAP_PRIVATE | MAP_FIXED | MAP_ANONYMOUS, -1, 0], + undefined, + -1, + 12, + ); + expect(processBytes[lowAlias]).toBe(0xa5); + }); + + it.each([ + { + name: "wasm32 upper pointer bits", + pointerWidth: 4 as const, + address: 0x1_0000_4000n, + errno: 14, + }, + { + name: "wasm64 address above Number.MAX_SAFE_INTEGER", + pointerWidth: 8 as const, + address: BigInt(Number.MAX_SAFE_INTEGER) + 1n, + errno: 14, + }, + ])("rejects $name before synchronization or dispatch", ({ + pointerWidth, + address, + errno, + }) => { + const h = workerHarness(pointerWidth); + writeSyscall(h.processChannel, ABI_SYSCALLS.Mmap, [ + address, + 4096n, + 3n, + BigInt(MAP_PRIVATE | MAP_FIXED | MAP_ANONYMOUS), + -1n, + 0n, + ]); + + (h.worker as any)._handleSyscallInner(h.processChannel); + + expect(h.synchronizeSharedMemoryForBoundary).not.toHaveBeenCalled(); + expect(h.kernelHandle).not.toHaveBeenCalled(); + expect(h.completeChannelRaw).toHaveBeenCalledWith( + h.processChannel, + -1, + errno, + ); + expect(h.relistenChannel).toHaveBeenCalledWith(h.processChannel); + }); + + it("rejects pointer-plus-length overflow before synchronization or dispatch", () => { + const h = workerHarness(8); + writeSyscall(h.processChannel, ABI_SYSCALLS.Mmap, [ + BigInt(Number.MAX_SAFE_INTEGER - 1024), + 2048n, + 3n, + BigInt(MAP_PRIVATE | MAP_FIXED | MAP_ANONYMOUS), + -1n, + 0n, + ]); + + (h.worker as any)._handleSyscallInner(h.processChannel); + + expect(h.synchronizeSharedMemoryForBoundary).not.toHaveBeenCalled(); + expect(h.kernelHandle).not.toHaveBeenCalled(); + expect(h.completeChannelRaw).toHaveBeenCalledWith( + h.processChannel, + -1, + 22, + ); + }); + + it("accepts only lossless mmap-style returned addresses for the caller", () => { + const wasm64 = workerHarness(8); + const highAddress = 0x1_0000_5000n; + expect((wasm64.worker as any).normalizeKernelSyscallResult( + wasm64.processChannel, + ABI_SYSCALLS.Mmap, + highAddress, + 0, + )).toEqual({ retVal: Number(highAddress), errVal: 0 }); + expect((wasm64.worker as any).normalizeKernelSyscallResult( + wasm64.processChannel, + ABI_SYSCALLS.Mremap, + BigInt(Number.MAX_SAFE_INTEGER) + 1n, + 0, + )).toEqual({ retVal: -1, errVal: 75 }); + + const wasm32 = workerHarness(4); + expect((wasm32.worker as any).normalizeKernelSyscallResult( + wasm32.processChannel, + ABI_SYSCALLS.Brk, + highAddress, + 0, + )).toEqual({ retVal: -1, errVal: 75 }); + }); + + it("does not mistake a high wasm64 host reservation for a low u32 sentinel", () => { + const h = workerHarness(8); + const highAddress = 0x1_ffff_ffffn; + (h.worker as any).toKernelPtr = (value: number | bigint) => value; + (h.worker as any).kernelInstance = { + exports: { + kernel_reserve_host_region: vi.fn(() => highAddress), + }, + }; + + expect(h.worker.reserveHostRegion(PID, 4096)).toBe(Number(highAddress)); + }); + + it("losslessly normalizes signed high-bit wasm32 host reservations", () => { + const h = workerHarness(4); + const highAddress = 0x8000_5000; + const signedExportResult = highAddress | 0; + (h.worker as any).toKernelPtr = (value: number | bigint) => value; + (h.worker as any).kernelInstance = { + exports: { + kernel_reserve_host_region: vi.fn(() => signedExportResult), + kernel_reserve_host_region_at: vi.fn(() => signedExportResult), + }, + }; + + expect(h.worker.reserveHostRegion(PID, 4096)).toBe(highAddress); + expect(h.worker.reserveHostRegionAt(PID, highAddress, 4096)).toBe( + highAddress, + ); + }); + + it("uses the kernel width to normalize a wasm32 export for a wasm64 guest", () => { + const h = workerHarness(8, 4); + const highAddress = 0x8000_5000; + const signedExportResult = highAddress | 0; + const reserve = vi.fn(() => signedExportResult); + const reserveAt = vi.fn(() => signedExportResult); + (h.worker as any).kernelInstance = { + exports: { + kernel_reserve_host_region: reserve, + kernel_reserve_host_region_at: reserveAt, + }, + }; + + expect(h.worker.reserveHostRegion(PID, 4096)).toBe(highAddress); + expect(h.worker.reserveHostRegionAt(PID, highAddress, 4096)).toBe( + highAddress, + ); + expect(reserve).toHaveBeenCalledWith(PID, 4096); + expect(reserveAt).toHaveBeenCalledWith(PID, highAddress, 4096); + }); + + it("rejects a fixed reservation outside a wasm32 guest before a wasm64 export", () => { + const h = workerHarness(4, 8); + const reserveAt = vi.fn(() => 0x1_0000_0000n); + (h.worker as any).kernelInstance = { + exports: { + kernel_reserve_host_region_at: reserveAt, + }, + }; + + expect(() => + h.worker.reserveHostRegionAt(PID, 0x1_0000_0000, 4096) + ).toThrow(/failed to reserve pthread control memory/); + expect(reserveAt).not.toHaveBeenCalled(); + }); + + it("rejects a returned reservation whose end exceeds the wasm32 guest domain", () => { + const h = workerHarness(4, 8); + (h.worker as any).kernelInstance = { + exports: { + kernel_reserve_host_region: vi.fn(() => 0xffff_f000n), + }, + }; + + expect(() => h.worker.reserveHostRegion(PID, 8192)).toThrow( + /failed to reserve 8192 bytes/, + ); + }); + + it("accepts a wasm32 reservation whose exclusive end is exactly 4 GiB", () => { + const h = workerHarness(4, 8); + (h.worker as any).kernelInstance = { + exports: { + kernel_reserve_host_region: vi.fn(() => 0xffff_0000n), + }, + }; + + expect(h.worker.reserveHostRegion(PID, 0x1_0000)).toBe(0xffff_0000); + }); + + it("does not treat a kernel64 0xffffffff address as the wasm32 failure sentinel", () => { + const h = workerHarness(8, 8); + (h.worker as any).kernelInstance = { + exports: { + kernel_reserve_host_region: vi.fn(() => 0xffff_ffffn), + }, + }; + + expect(h.worker.reserveHostRegion(PID, 1)).toBe(0xffff_ffff); + }); + + it("rejects a high wasm64 futex uaddr2 before it can wake a low alias", () => { + const h = workerHarness(8); + const primary = 0x1000; + const highSecond = 0x1_0000_2000n; + const notify = vi.spyOn(Atomics, "notify"); + try { + (h.worker as any).handleFutex( + h.processChannel, + [primary, FUTEX_WAKE_OP, 1, 1, Number(highSecond), 0], + [ + BigInt(primary), + BigInt(FUTEX_WAKE_OP), + 1n, + 1n, + highSecond, + 0n, + ], + ); + + expect(notify).not.toHaveBeenCalled(); + expect(h.completeChannelRaw).toHaveBeenCalledWith( + h.processChannel, + -1, + 14, + ); + } finally { + notify.mockRestore(); + } + }); + + it("treats futex timeout and uaddr2 slots according to the operation", () => { + const h = workerHarness(8); + const primary = 0x1000; + const second = 0x2000; + const truncatedTimeout = h.memory.buffer.byteLength - 8; + + (h.worker as any).handleFutex( + h.processChannel, + [primary, FUTEX_WAIT, 1, truncatedTimeout, 0, 0], + ); + expect(h.completeChannelRaw).toHaveBeenLastCalledWith( + h.processChannel, + -1, + 14, + ); + + h.completeChannelRaw.mockClear(); + (h.worker as any).handleFutex( + h.processChannel, + [primary, FUTEX_REQUEUE, 1, -1, second, 0], + ); + expect(h.completeChannelRaw).toHaveBeenCalledWith( + h.processChannel, + expect.any(Number), + 0, + ); + }); + + it("rejects a futex word that crosses the current memory boundary", () => { + const h = workerHarness(8); + const crossingWord = h.memory.buffer.byteLength - 2; + (h.worker as any).handleFutex( + h.processChannel, + [crossingWord, FUTEX_WAIT, 0, 0, 0, 0], + ); + expect(h.completeChannelRaw).toHaveBeenCalledWith( + h.processChannel, + -1, + 14, + ); + }); +}); diff --git a/host/test/kernel-initialization-lifetime.test.ts b/host/test/kernel-initialization-lifetime.test.ts new file mode 100644 index 0000000000..c2e905b6e2 --- /dev/null +++ b/host/test/kernel-initialization-lifetime.test.ts @@ -0,0 +1,260 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { WasmPosixKernel } from "../src/kernel"; +import { createKernelScratchTestInstance } from "./support/kernel-scratch-instance"; + +const emptyModule = new WebAssembly.Module(new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, + 0x01, 0x00, 0x00, 0x00, +])); + +function memoryImportModule(pointerWidth: 4 | 8): Uint8Array { + return new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, // magic + 0x01, 0x00, 0x00, 0x00, // version + 0x02, 0x08, // import section, eight-byte payload + 0x01, // one import + 0x01, 0x6d, // module "m" + 0x01, 0x6d, // field "m" + 0x02, // memory import + pointerWidth === 8 ? 0x04 : 0x00, // memory64 flag + 0x01, // minimum one page + ]); +} + +function kernel(): WasmPosixKernel { + return new WasmPosixKernel( + { + maxWorkers: 1, + dataBufferSize: 65_536, + useSharedMemory: true, + }, + {} as never, + ); +} + +function installSuccessfulEngine( + pointerWidth: 4 | 8, + implementations: Record, + allocator: (capacity: number) => number | bigint, +): { + compile: ReturnType; + instantiate: ReturnType; + memory: () => WebAssembly.Memory; +} { + const compile = vi + .spyOn(WebAssembly, "compile") + .mockResolvedValue(emptyModule); + let activeMemory: WebAssembly.Memory | null = null; + const instantiate = vi.spyOn(WebAssembly, "instantiate"); + instantiate.mockImplementation((async ( + _module: WebAssembly.Module, + importObject?: WebAssembly.Imports, + ) => { + activeMemory = ( + importObject as { env: { memory: WebAssembly.Memory } } + ).env.memory; + return createKernelScratchTestInstance( + pointerWidth, + activeMemory, + () => implementations, + allocator, + ); + }) as never); + return { + compile, + instantiate, + memory: () => { + if (!activeMemory) throw new Error("kernel memory was not instantiated"); + return activeMemory; + }, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("WasmPosixKernel initialization lifetime", () => { + it.each([4, 8] as const)( + "keeps wasm%d public and audio scratch bound to its first generation", + async (pointerWidth) => { + const instance = kernel(); + let allocationIndex = 0; + let activeMemory: WebAssembly.Memory; + const allocator = vi.fn((_capacity: number) => { + const pointer = allocationIndex++ === 0 ? 4_096 : 131_072; + return pointerWidth === 8 ? BigInt(pointer) : pointer; + }); + const send = vi.fn(( + _fd: number, + pointer: number | bigint, + length: number, + ) => { + expect(new Uint8Array( + activeMemory.buffer, + Number(pointer), + length, + )).toEqual(new Uint8Array([1, 2, 3, 4])); + return length; + }); + const drainAudio = vi.fn(( + pointer: number | bigint, + length: number, + ) => { + const output = new Uint8Array( + activeMemory.buffer, + Number(pointer), + Math.min(length, 4), + ); + output.set([9, 8, 7, 6]); + return output.byteLength; + }); + const engine = installSuccessfulEngine( + pointerWidth, + { + kernel_send: send, + kernel_drain_audio: drainAudio, + }, + allocator, + ); + const suppliedMemory = new WebAssembly.Memory({ + initial: 4, + maximum: 4, + shared: true, + }); + + if (pointerWidth === 4) { + await instance.init(memoryImportModule(pointerWidth)); + } else { + await instance.initWithMemory( + memoryImportModule(pointerWidth), + suppliedMemory, + ); + } + activeMemory = engine.memory(); + const firstMemory = instance.getMemory(); + const firstInstance = instance.getInstance(); + + expect(instance.send(7, new Uint8Array([1, 2, 3, 4]))).toBe(4); + const firstAudio = new Uint8Array(4); + expect(instance.drainAudio(firstAudio)).toBe(4); + expect(firstAudio).toEqual(new Uint8Array([9, 8, 7, 6])); + + const replacementMemory = new WebAssembly.Memory({ + initial: 4, + maximum: 4, + shared: true, + }); + const replacement = pointerWidth === 4 + ? instance.initWithMemory( + memoryImportModule(pointerWidth), + replacementMemory, + ) + : instance.init(memoryImportModule(pointerWidth)); + await expect(replacement).rejects.toThrow(/already initialized/i); + + expect(engine.compile).toHaveBeenCalledOnce(); + expect(engine.instantiate).toHaveBeenCalledOnce(); + expect(instance.getMemory()).toBe(firstMemory); + expect(instance.getInstance()).toBe(firstInstance); + expect(instance.getKernelPtrWidth()).toBe(pointerWidth); + + expect(instance.send(7, new Uint8Array([1, 2, 3, 4]))).toBe(4); + const secondAudio = new Uint8Array(4); + expect(instance.drainAudio(secondAudio)).toBe(4); + expect(secondAudio).toEqual(new Uint8Array([9, 8, 7, 6])); + expect(allocator).toHaveBeenCalledTimes(2); + expect(send).toHaveBeenCalledTimes(2); + expect(drainAudio).toHaveBeenCalledTimes(2); + }, + ); + + it("rejects a concurrent initializer before it can replace candidate state", async () => { + const instance = kernel(); + let releaseCompile!: (module: WebAssembly.Module) => void; + const compileGate = new Promise((resolve) => { + releaseCompile = resolve; + }); + const compile = vi + .spyOn(WebAssembly, "compile") + .mockReturnValue(compileGate); + const instantiate = vi.spyOn(WebAssembly, "instantiate"); + instantiate.mockImplementation((async ( + _module: WebAssembly.Module, + importObject?: WebAssembly.Imports, + ) => { + const memory = ( + importObject as { env: { memory: WebAssembly.Memory } } + ).env.memory; + return createKernelScratchTestInstance( + 4, + memory, + () => ({}), + () => 4_096, + ); + }) as never); + + const first = instance.init(memoryImportModule(4)); + const competingMemory = new WebAssembly.Memory({ + initial: 2, + maximum: 2, + shared: true, + }); + await expect( + instance.initWithMemory(memoryImportModule(4), competingMemory), + ).rejects.toThrow(/already in progress/i); + + releaseCompile(emptyModule); + await expect(first).resolves.toBeUndefined(); + expect(compile).toHaveBeenCalledOnce(); + expect(instantiate).toHaveBeenCalledOnce(); + expect(instance.getMemory()).not.toBe(competingMemory); + }); + + it("clears a failed first instantiation and permits one clean retry", async () => { + const instance = kernel(); + const failure = new Error("synthetic instantiation failure"); + const compile = vi + .spyOn(WebAssembly, "compile") + .mockResolvedValue(emptyModule); + let attempt = 0; + const instantiate = vi.spyOn(WebAssembly, "instantiate"); + instantiate.mockImplementation((async ( + _module: WebAssembly.Module, + importObject?: WebAssembly.Imports, + ) => { + if (attempt++ === 0) throw failure; + const memory = ( + importObject as { env: { memory: WebAssembly.Memory } } + ).env.memory; + return createKernelScratchTestInstance( + 8, + memory, + () => ({}), + () => 4_096n, + ); + }) as never); + const memory = new WebAssembly.Memory({ + initial: 2, + maximum: 2, + shared: true, + }); + + await expect( + instance.initWithMemory(memoryImportModule(8), memory), + ).rejects.toBe(failure); + expect(instance.getMemory()).toBeNull(); + expect(instance.getInstance()).toBeNull(); + expect(instance.getKernelPtrWidth()).toBe(4); + + await expect( + instance.initWithMemory(memoryImportModule(8), memory), + ).resolves.toBeUndefined(); + expect(instance.getMemory()).toBe(memory); + expect(instance.getInstance()).not.toBeNull(); + expect(instance.getKernelPtrWidth()).toBe(8); + expect(compile).toHaveBeenCalledTimes(2); + expect(instantiate).toHaveBeenCalledTimes(2); + }); +}); diff --git a/host/test/kernel-public-scratch.test.ts b/host/test/kernel-public-scratch.test.ts new file mode 100644 index 0000000000..2402691e01 --- /dev/null +++ b/host/test/kernel-public-scratch.test.ts @@ -0,0 +1,1021 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + IOCTL_REQUESTS, + SELECT_FD_SET_BYTES, + SELECT_FD_SETSIZE, + STRUCT_SIZE_WASM_POLL_FD, + STRUCT_SIZE_WPK_DRM_MODE_MODEINFO, +} from "../src/generated/abi"; +import { WasmPosixKernel } from "../src/kernel"; +import { QOP_GET_ERROR } from "../src/webgl/ops"; +import { createKernelScratchTestInstance } from "./support/kernel-scratch-instance"; + +function hostileBytes(length: number, reportedLength = 1): Uint8Array { + class HostileBytes extends Uint8Array {} + const bytes = new HostileBytes(length); + Object.defineProperties(bytes, { + buffer: { get: () => new ArrayBuffer(reportedLength) }, + byteOffset: { get: () => 0 }, + byteLength: { get: () => reportedLength }, + length: { get: () => reportedLength }, + subarray: { value: () => bytes }, + }); + return bytes; +} + +function kernelHarness( + exports: Record, + pointerWidth: 4 | 8 = 4, +): { + kernel: WasmPosixKernel & Record; + memory: WebAssembly.Memory; +} { + const memory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); + const kernel = Object.assign( + Object.create(WasmPosixKernel.prototype), + { + memory, + instance: createKernelScratchTestInstance( + pointerWidth, + memory, + () => exports, + (capacity) => { + const allocator = exports.kernel_alloc_scratch; + if (typeof allocator !== "function") { + throw new Error("missing test implementation for kernel_alloc_scratch"); + } + return Reflect.apply(allocator, undefined, [capacity]) as number | bigint; + }, + ), + kernelPtrWidth: pointerWidth, + apiScratchRegion: null, + callbacks: {}, + sharedPipes: new Map(), + }, + ) as WasmPosixKernel & Record; + return { kernel, memory }; +} + +function fullKernelHarness( + io: Record = {}, + callbacks: Record = {}, +): { + kernel: WasmPosixKernel & Record; + memory: WebAssembly.Memory; +} { + const memory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); + const kernel = new WasmPosixKernel( + {} as any, + io as any, + callbacks as any, + ) as WasmPosixKernel & Record; + Object.assign(kernel, { + memory, + instance: createKernelScratchTestInstance( + 4, + memory, + () => ({}), + () => 4096, + ), + kernelPtrWidth: 4, + apiScratchRegion: null, + }); + return { kernel, memory }; +} + +describe("WasmPosixKernel public API scratch ownership", () => { + it("converts public export pointers losslessly for each Wasm width", () => { + const { kernel } = kernelHarness({}); + + expect(kernel.toKernelPtr(0xffff_ffff)).toBe(0xffff_ffff); + expect(() => kernel.toKernelPtr(0x1_0000_0000)) + .toThrow(/wasm32/i); + expect(() => kernel.toKernelPtr(-1)).toThrow(/non-negative/i); + expect(() => kernel.toKernelPtr(1.5)).toThrow(/integer/i); + + Object.assign(kernel, { kernelPtrWidth: 8 }); + expect(kernel.toKernelPtr(0x1_0000_0000)).toBe(0x1_0000_0000n); + expect(() => + kernel.toKernelPtr(BigInt(Number.MAX_SAFE_INTEGER) + 1n) + ).toThrow(/representable/i); + }); + + it("sends from an allocator-owned region without touching low kernel memory", () => { + const scratchPointer = 4096; + const allocate = vi.fn(() => scratchPointer); + let memory!: WebAssembly.Memory; + const send = vi.fn(( + _fd: number, + pointer: number, + length: number, + _flags: number, + ) => { + expect(pointer).toBe(scratchPointer); + expect( + new Uint8Array(memory.buffer, pointer, length), + ).toEqual(new Uint8Array([1, 2, 3, 4])); + return length; + }); + const harness = kernelHarness({ + kernel_alloc_scratch: allocate, + kernel_send: send, + }); + memory = harness.memory; + new Uint8Array(memory.buffer).fill(0xa5, 0, 64); + + expect(harness.kernel.send(7, new Uint8Array([1, 2, 3, 4]))).toBe(4); + + expect(allocate).toHaveBeenCalledTimes(1); + expect(new Uint8Array(memory.buffer, 0, 64)) + .toEqual(new Uint8Array(64).fill(0xa5)); + }); + + it("never lends stale scratch bytes when public inputs spoof their length", () => { + const scratchPointer = 4096; + const source = hostileBytes(1, 4); + Uint8Array.prototype.set.call(source, [0x5a]); + let memory!: WebAssembly.Memory; + const inspectExactInput = vi.fn(( + _first: number, + _second: number, + pointer: number, + length: number, + ) => { + expect(pointer).toBe(scratchPointer); + expect(length).toBe(1); + expect(new Uint8Array(memory.buffer, pointer, length)) + .toEqual(new Uint8Array([0x5a])); + return 1; + }); + const harness = kernelHarness({ + kernel_alloc_scratch: () => scratchPointer, + kernel_send: ( + fd: number, + pointer: number, + length: number, + flags: number, + ) => inspectExactInput(fd, flags, pointer, length), + kernel_tcsetattr: inspectExactInput, + }); + memory = harness.memory; + new Uint8Array(memory.buffer).fill( + 0xa5, + scratchPointer, + scratchPointer + 8, + ); + + expect(harness.kernel.send(7, source)).toBe(1); + harness.kernel.tcsetattr(7, 0, source); + + expect(inspectExactInput).toHaveBeenCalledTimes(2); + expect(new Uint8Array(memory.buffer, scratchPointer, 4)) + .toEqual(new Uint8Array([0x5a, 0xa5, 0xa5, 0xa5])); + }); + + it("rejects an allocator range outside current kernel memory", () => { + const send = vi.fn(); + const { kernel } = kernelHarness({ + kernel_alloc_scratch: () => 131_056, + kernel_send: send, + }); + + expect(() => kernel.send(7, new Uint8Array(32))) + .toThrow(/outside|scratch|range/i); + expect(send).not.toHaveBeenCalled(); + }); + + it("accepts exact public API capacity and rejects capacity plus one", () => { + const scratchPointer = 4096; + let memory!: WebAssembly.Memory; + const send = vi.fn(( + _fd: number, + pointer: number, + length: number, + ) => { + expect(pointer).toBe(scratchPointer); + expect(length).toBe(65_536); + const bytes = new Uint8Array(memory.buffer); + expect(bytes[pointer]).toBe(0x4d); + expect(bytes[pointer + length - 1]).toBe(0x4d); + return length; + }); + const harness = kernelHarness({ + kernel_alloc_scratch: () => scratchPointer, + kernel_send: send, + }); + memory = harness.memory; + const kernelBytes = new Uint8Array(memory.buffer); + kernelBytes.fill(0xa5, scratchPointer + 65_536, scratchPointer + 65_552); + + expect(harness.kernel.send(7, new Uint8Array(65_536).fill(0x4d))) + .toBe(65_536); + expect(() => harness.kernel.send(7, new Uint8Array(65_537))) + .toThrow(/capacity|owned range|scratch/i); + expect(send).toHaveBeenCalledOnce(); + expect(kernelBytes.subarray(scratchPointer + 65_536, scratchPointer + 65_552)) + .toEqual(new Uint8Array(16).fill(0xa5)); + }); + + it("derives poll admission from exact owned capacity, not IOV_MAX", () => { + const scratchPointer = 4096; + const scratchCapacity = 65_536; + const exactCount = scratchCapacity / STRUCT_SIZE_WASM_POLL_FD; + let memory!: WebAssembly.Memory; + const poll = vi.fn(( + pointer: number, + capacity: number, + count: number, + timeout: number, + ) => { + expect(pointer).toBe(scratchPointer); + expect(capacity).toBe(scratchCapacity); + expect(count).toBe(exactCount); + expect(count).toBeGreaterThan(1024); + expect(timeout).toBe(17); + const view = new DataView( + memory.buffer, + pointer, + count * STRUCT_SIZE_WASM_POLL_FD, + ); + expect(view.getInt32(0, true)).toBe(0); + expect( + view.getInt32( + (count - 1) * STRUCT_SIZE_WASM_POLL_FD, + true, + ), + ).toBe(count - 1); + view.setInt16(6, 1, true); + view.setInt16( + (count - 1) * STRUCT_SIZE_WASM_POLL_FD + 6, + 4, + true, + ); + return 2; + }); + const harness = kernelHarness({ + kernel_alloc_scratch: () => scratchPointer, + kernel_poll: poll, + }); + memory = harness.memory; + const exact = Array.from( + { length: exactCount }, + (_, fd) => ({ fd, events: 1 }), + ); + + const ready = harness.kernel.poll(exact, 17); + expect(ready).toHaveLength(exactCount); + expect(ready[0]?.revents).toBe(1); + expect(ready.at(-1)?.revents).toBe(4); + expect(() => + harness.kernel.poll( + Array.from( + { length: exactCount + 1 }, + (_, fd) => ({ fd, events: 1 }), + ), + 17, + ) + ).toThrow(/owned scratch capacity 8192/i); + expect(poll).toHaveBeenCalledOnce(); + }); + + it("rejects an impossible poll producer count", () => { + const poll = vi.fn(() => 2); + const { kernel } = kernelHarness({ + kernel_alloc_scratch: () => 4096, + kernel_poll: poll, + }); + + expect(() => + kernel.poll([{ fd: 7, events: 1 }], 0) + ).toThrow(/invalid ready count 2/i); + }); + + it("uses the generated fd_set contract for public select", () => { + const scratchPointer = 4096; + let memory!: WebAssembly.Memory; + const select = vi.fn(( + count: number, + readPointer: number, + readCapacity: number, + writePointer: number, + writeCapacity: number, + exceptPointer: number, + exceptCapacity: number, + timeout: number, + ) => { + expect(count).toBe(SELECT_FD_SETSIZE); + expect(timeout).toBe(0); + expect(readPointer).toBe(scratchPointer); + expect(readCapacity).toBe(SELECT_FD_SET_BYTES); + expect(writePointer).toBe(scratchPointer + SELECT_FD_SET_BYTES); + expect(writeCapacity).toBe(SELECT_FD_SET_BYTES); + expect(exceptPointer).toBe( + scratchPointer + 2 * SELECT_FD_SET_BYTES, + ); + expect(exceptCapacity).toBe(SELECT_FD_SET_BYTES); + new Uint8Array(memory.buffer).fill( + 0, + readPointer, + exceptPointer + SELECT_FD_SET_BYTES, + ); + return 0; + }); + const harness = kernelHarness({ + kernel_alloc_scratch: () => scratchPointer, + kernel_select: select, + }); + memory = harness.memory; + + expect(harness.kernel.select( + SELECT_FD_SETSIZE, + [SELECT_FD_SETSIZE - 1], + [0], + [1], + )).toEqual({ + readReady: [], + writeReady: [], + exceptReady: [], + }); + expect(() => + harness.kernel.select(SELECT_FD_SETSIZE + 1, [], [], []) + ).toThrow(new RegExp(String(SELECT_FD_SETSIZE))); + expect(select).toHaveBeenCalledOnce(); + }); + + it.each(Array.from({ length: 8 }, (_, mask) => mask))( + "passes wasm64 public select presence mask %i without pointer coercion", + (mask) => { + const scratchPointer = 4096; + const select = vi.fn(( + count: number, + readPointer: bigint, + readCapacity: number, + writePointer: bigint, + writeCapacity: number, + exceptPointer: bigint, + exceptCapacity: number, + timeout: number, + ) => { + expect(count).toBe(0); + expect(timeout).toBe(0); + expect([ + readCapacity, + writeCapacity, + exceptCapacity, + ]).toEqual([ + (mask & 1) !== 0 ? SELECT_FD_SET_BYTES : 0, + (mask & 2) !== 0 ? SELECT_FD_SET_BYTES : 0, + (mask & 4) !== 0 ? SELECT_FD_SET_BYTES : 0, + ]); + expect([readPointer, writePointer, exceptPointer]).toEqual([ + (mask & 1) !== 0 ? BigInt(scratchPointer) : 0n, + (mask & 2) !== 0 + ? BigInt(scratchPointer + SELECT_FD_SET_BYTES) + : 0n, + (mask & 4) !== 0 + ? BigInt(scratchPointer + 2 * SELECT_FD_SET_BYTES) + : 0n, + ]); + return 0; + }); + const { kernel } = kernelHarness({ + kernel_alloc_scratch: () => BigInt(scratchPointer), + kernel_select: select, + }, 8); + + expect(kernel.select( + 0, + (mask & 1) !== 0 ? [] : null, + (mask & 2) !== 0 ? [] : null, + (mask & 4) !== 0 ? [] : null, + )).toEqual({ + readReady: [], + writeReady: [], + exceptReady: [], + }); + expect(select).toHaveBeenCalledOnce(); + }, + ); + + it("converts public wasm64 scalar and no-argument ioctl values", () => { + const scratchPointer = 4096; + const scalarRequest = 0x540b; + const noArgumentRequest = 0x41; + expect(IOCTL_REQUESTS[scalarRequest]?.argKind).toBe("scalar-i32"); + expect(IOCTL_REQUESTS[noArgumentRequest]?.argKind).toBe("none"); + const ioctl = vi.fn(() => 0); + const { kernel } = kernelHarness({ + kernel_alloc_scratch: () => BigInt(scratchPointer), + kernel_ioctl: ioctl, + }, 8); + + expect(kernel.ioctl(7, scalarRequest, -1)).toEqual(new Uint8Array(0)); + expect(kernel.ioctl(8, noArgumentRequest)).toEqual(new Uint8Array(0)); + + expect(ioctl).toHaveBeenNthCalledWith( + 1, + 7, + scalarRequest, + 0xffff_ffffn, + 0, + 4, + ); + expect(ioctl).toHaveBeenNthCalledWith( + 2, + 8, + noArgumentRequest, + 0n, + 0, + 4, + ); + }); + + it("does not drain audio through an allocator range it does not own", () => { + const drain = vi.fn(() => 1); + const { kernel } = kernelHarness({ + kernel_alloc_scratch: () => 131_056, + kernel_drain_audio: drain, + }); + + expect(kernel.drainAudio(new Uint8Array(32))).toBe(0); + expect(drain).not.toHaveBeenCalled(); + }); + + it("bounds exact and capacity-plus-one audio drains to the audio region", () => { + const scratchPointer = 4096; + let memory!: WebAssembly.Memory; + const drain = vi.fn((pointer: number, length: number) => { + expect(pointer).toBe(scratchPointer); + expect(length).toBe(65_536); + new Uint8Array(memory.buffer, pointer, length).fill(0x6d); + return length; + }); + const harness = kernelHarness({ + kernel_alloc_scratch: () => scratchPointer, + kernel_drain_audio: drain, + }); + memory = harness.memory; + const kernelBytes = new Uint8Array(memory.buffer); + kernelBytes.fill(0xa5, scratchPointer + 65_536, scratchPointer + 65_552); + + const exact = new Uint8Array(65_536); + expect(harness.kernel.drainAudio(exact)).toBe(65_536); + expect(exact.every((byte) => byte === 0x6d)).toBe(true); + + const plusOne = new Uint8Array(65_537).fill(0xa5); + expect(harness.kernel.drainAudio(plusOne)).toBe(65_536); + expect(plusOne.subarray(0, 65_536).every((byte) => byte === 0x6d)) + .toBe(true); + expect(plusOne[65_536]).toBe(0xa5); + expect(drain).toHaveBeenCalledTimes(2); + expect(kernelBytes.subarray(scratchPointer + 65_536, scratchPointer + 65_552)) + .toEqual(new Uint8Array(16).fill(0xa5)); + }); + + it("stages truncate paths through allocator-owned scratch", () => { + const scratchPointer = 4096; + const allocate = vi.fn(() => scratchPointer); + let memory!: WebAssembly.Memory; + const truncate = vi.fn(( + pointer: number, + length: number, + truncateLength: bigint, + ) => { + expect(pointer).toBe(scratchPointer); + expect(new TextDecoder().decode( + new Uint8Array(memory.buffer, pointer, length), + )).toBe("/tmp/example"); + expect(truncateLength).toBe(7n); + return 0; + }); + const harness = kernelHarness({ + kernel_alloc_scratch: allocate, + kernel_truncate: truncate, + }); + memory = harness.memory; + + harness.kernel.truncate("/tmp/example", 7); + + expect(allocate).toHaveBeenCalledTimes(1); + expect(truncate).toHaveBeenCalledTimes(1); + }); +}); + +describe("Rust-owned host import ranges", () => { + it("rejects a truncated kernel source instead of invoking the backend", () => { + const open = vi.fn(() => 7); + const { kernel, memory } = kernelHarness({}); + Object.assign(kernel, { io: { open } }); + const imports = kernel.buildImportObject(memory) as { + env: Record any>; + }; + const pointer = memory.buffer.byteLength - 2; + + expect(imports.env.host_open(pointer, 4, 0, 0)).toBe(-14n); + expect(open).not.toHaveBeenCalled(); + }); + + it("accepts an exact-end source and rejects capacity plus one", () => { + const write = vi.fn(() => 4); + const { kernel, memory } = kernelHarness({}); + Object.assign(kernel, { io: { write } }); + const imports = kernel.buildImportObject(memory) as { + env: Record any>; + }; + const pointer = memory.buffer.byteLength - 4; + new Uint8Array(memory.buffer, pointer, 4).set([1, 2, 3, 4]); + + expect(imports.env.host_write(9n, pointer, 4)).toBe(4); + expect(write).toHaveBeenCalledWith( + 9, + new Uint8Array([1, 2, 3, 4]), + null, + 4, + ); + write.mockClear(); + expect(imports.env.host_write(9n, pointer, 5)).toBe(-14); + expect(write).not.toHaveBeenCalled(); + }); + + it.each([ + ["null pointer", 0, 1], + ["negative length", 4096, -1], + ["fractional length", 4096, 1.5], + ["unsafe length", 4096, Number.MAX_SAFE_INTEGER + 1], + ])("rejects a %s before a kernel-source callback", (_name, pointer, length) => { + const write = vi.fn(() => 0); + const { kernel, memory } = kernelHarness({}); + Object.assign(kernel, { io: { write } }); + const imports = kernel.buildImportObject(memory) as { + env: Record any>; + }; + + expect(imports.env.host_write(9n, pointer, length)).toBe(-14); + expect(write).not.toHaveBeenCalled(); + }); + + it("rejects an unrepresentable wasm64 network source without aliasing", () => { + const send = vi.fn(() => 1); + const { kernel, memory } = kernelHarness({}); + Object.assign(kernel, { + kernelPtrWidth: 8, + io: { network: { send } }, + }); + const imports = kernel.buildImportObject(memory) as { + env: Record any>; + }; + + expect(imports.env.host_net_send( + 1, + BigInt(Number.MAX_SAFE_INTEGER) + 1n, + 1, + 0, + )).toBe(-14); + expect(send).not.toHaveBeenCalled(); + }); + + it("rejects a lossy file-length conversion before PlatformIO", () => { + const ftruncate = vi.fn(); + const { kernel, memory } = kernelHarness({}); + Object.assign(kernel, { io: { ftruncate } }); + const imports = kernel.buildImportObject(memory) as { + env: Record any>; + }; + + expect(imports.env.host_ftruncate( + 9n, + BigInt(Number.MAX_SAFE_INTEGER) + 1n, + )).toBe(-75); + expect(ftruncate).not.toHaveBeenCalled(); + }); + + it("publishes a staged read without lending kernel memory to PlatformIO", () => { + let retained: Uint8Array | undefined; + const read = vi.fn(( + _handle: number, + destination: Uint8Array, + ) => { + retained = destination; + destination.set([0x41, 0x42]); + return 2; + }); + const { kernel, memory } = kernelHarness({}); + Object.assign(kernel, { io: { read } }); + const imports = kernel.buildImportObject(memory) as { + env: Record any>; + }; + + expect(imports.env.host_read(8n, 4096, 4)).toBe(2); + expect(new Uint8Array(memory.buffer, 4096, 4)) + .toEqual(new Uint8Array([0x41, 0x42, 0, 0])); + retained![0] = 0x7f; + expect(new Uint8Array(memory.buffer, 4096, 2)) + .toEqual(new Uint8Array([0x41, 0x42])); + }); + + it("rejects an invalid read destination before consuming backend data", () => { + const read = vi.fn(() => 1); + const { kernel, memory } = kernelHarness({}); + Object.assign(kernel, { io: { read } }); + const imports = kernel.buildImportObject(memory) as { + env: Record any>; + }; + + expect(imports.env.host_read( + 8n, + memory.buffer.byteLength - 2, + 4, + )).toBe(-14); + expect(read).not.toHaveBeenCalled(); + }); + + it("rejects an invalid wait status destination before reaping", () => { + const waitpid = vi.fn(() => ({ pid: 42, status: 0 })); + const { kernel, memory } = kernelHarness({}); + Object.assign(kernel, { io: { waitpid } }); + const imports = kernel.buildImportObject(memory) as { + env: Record any>; + }; + + expect(imports.env.host_waitpid( + 42, + 0, + memory.buffer.byteLength - 2, + )).toBe(-14); + expect(waitpid).not.toHaveBeenCalled(); + }); + + it("rejects network output larger than the Rust-provided capacity", () => { + const { kernel, memory } = kernelHarness({}); + Object.assign(kernel, { + io: { + network: { + recv: () => new Uint8Array(8).fill(0x6b), + }, + }, + }); + const imports = kernel.buildImportObject(memory) as { + env: Record any>; + }; + new Uint8Array(memory.buffer).fill(0xa5, 4096, 4112); + + expect(imports.env.host_net_recv(1, 4096, 4, 0)).toBe(-5); + expect(new Uint8Array(memory.buffer, 4096, 16)) + .toEqual(new Uint8Array(16).fill(0xa5)); + }); + + it("uses a producer's intrinsic byte span instead of overridable length properties", () => { + const output = hostileBytes(20); + output.fill(0x6b); + const { kernel, memory } = kernelHarness({}); + Object.assign(kernel, { + io: { + network: { + recv: () => output, + }, + }, + }); + const imports = kernel.buildImportObject(memory) as { + env: Record any>; + }; + new Uint8Array(memory.buffer).fill(0xa5, 4096, 4120); + + expect(imports.env.host_net_recv(1, 4096, 4, 0)).toBe(-5); + expect(new Uint8Array(memory.buffer, 4096, 24)) + .toEqual(new Uint8Array(24).fill(0xa5)); + expect(() => kernel.writeKernelBytes(4096, 4, output)) + .toThrow(/20 exceeds capacity 4/i); + expect(new Uint8Array(memory.buffer, 4096, 24)) + .toEqual(new Uint8Array(24).fill(0xa5)); + }); + + it("rejects a non-typed-array address producer without touching kernel memory", () => { + const { kernel, memory } = kernelHarness({}); + Object.assign(kernel, { + io: { + network: { + getaddrinfo: () => ({ + byteLength: 1, + length: 20, + 0: 127, + }), + }, + }, + }); + const imports = kernel.buildImportObject(memory) as { + env: Record any>; + }; + new Uint8Array(memory.buffer, 2048, 2).set([0x78, 0]); + new Uint8Array(memory.buffer).fill(0xa5, 4096, 4112); + + expect(imports.env.host_getaddrinfo(2048, 1, 4096, 4)).toBe(-5); + expect(new Uint8Array(memory.buffer, 4096, 16)) + .toEqual(new Uint8Array(16).fill(0xa5)); + }); + + it("preserves an asynchronous DNS EAGAIN before producer validation", () => { + const { kernel, memory } = kernelHarness({}); + Object.assign(kernel, { + io: { + network: { + getaddrinfo: () => { + throw Object.assign(new Error("DNS pending"), { errno: 11 }); + }, + }, + }, + }); + const imports = kernel.buildImportObject(memory) as { + env: Record any>; + }; + new Uint8Array(memory.buffer, 2048, 2).set([0x78, 0]); + new Uint8Array(memory.buffer).fill(0xa5, 4096, 4112); + + expect(imports.env.host_getaddrinfo(2048, 1, 4096, 4)).toBe(-11); + expect(new Uint8Array(memory.buffer, 4096, 16)) + .toEqual(new Uint8Array(16).fill(0xa5)); + }); + + it("clips hostile stdin bytes through a plain exact view and preserves the canary", () => { + const output = hostileBytes(20); + Uint8Array.prototype.set.call(output, [1, 2, 3, 4, 5]); + const { kernel, memory } = kernelHarness({}); + Object.assign(kernel, { + callbacks: { + onStdin: () => output, + }, + }); + const imports = kernel.buildImportObject(memory) as { + env: Record any>; + }; + new Uint8Array(memory.buffer).fill(0xa5, 4096, 4104); + + expect(imports.env.host_read(0n, 4096, 4)).toBe(4); + expect(new Uint8Array(memory.buffer, 4096, 8)) + .toEqual(new Uint8Array([1, 2, 3, 4, 0xa5, 0xa5, 0xa5, 0xa5])); + }); + + it("rejects a null positive-length getrandom pointer", () => { + const { kernel, memory } = kernelHarness({}); + const imports = kernel.buildImportObject(memory) as { + env: Record any>; + }; + + expect(imports.env.host_getrandom(0, 1)).toBe(-14); + }); + + it("rejects an unrepresentable wasm64 process-copy destination before a kernel write", () => { + const processMemory = new WebAssembly.Memory({ initial: 1 }); + const { kernel, memory } = kernelHarness({}); + Object.assign(kernel, { + kernelPtrWidth: 8, + callbacks: { + getProcessMemory: () => processMemory, + }, + }); + const imports = kernel.buildImportObject(memory) as { + env: Record any>; + }; + new Uint8Array(memory.buffer).fill(0xa5, 4096, 4100); + + expect(imports.env.host_proc_read_bytes( + 7, + 1024, + BigInt(Number.MAX_SAFE_INTEGER) + 1n, + 4, + )).toBe(-14); + expect(new Uint8Array(memory.buffer, 4096, 4)) + .toEqual(new Uint8Array(4).fill(0xa5)); + }); + + it("rejects null positive-length process transfer ranges", () => { + const processMemory = new WebAssembly.Memory({ initial: 1 }); + const { kernel, memory } = kernelHarness({}); + Object.assign(kernel, { + callbacks: { + getProcessMemory: () => processMemory, + }, + }); + const imports = kernel.buildImportObject(memory) as { + env: Record any>; + }; + new Uint8Array(memory.buffer, 4096, 4).set([1, 2, 3, 4]); + new Uint8Array(processMemory.buffer, 0, 4).fill(0xa5); + + expect(imports.env.host_proc_write_bytes(7, 0, 4096, 4)).toBe(-14); + expect(new Uint8Array(processMemory.buffer, 0, 4)) + .toEqual(new Uint8Array(4).fill(0xa5)); + expect(imports.env.host_proc_read_bytes(7, 0, 4096, 4)).toBe(-14); + expect(new Uint8Array(memory.buffer, 4096, 4)) + .toEqual(new Uint8Array([1, 2, 3, 4])); + }); + + it("enforces exact process-memory transfer boundaries", () => { + const processMemory = new WebAssembly.Memory({ initial: 1 }); + const { kernel, memory } = kernelHarness({}); + Object.assign(kernel, { + callbacks: { + getProcessMemory: () => processMemory, + }, + }); + const imports = kernel.buildImportObject(memory) as { + env: Record any>; + }; + const processEnd = processMemory.buffer.byteLength; + new Uint8Array(memory.buffer, 4096, 4).set([1, 2, 3, 4]); + + expect(imports.env.host_proc_write_bytes( + 7, + processEnd - 4, + 4096, + 4, + )).toBe(0); + expect(new Uint8Array(processMemory.buffer, processEnd - 4, 4)) + .toEqual(new Uint8Array([1, 2, 3, 4])); + expect(imports.env.host_proc_write_bytes( + 7, + processEnd - 4, + 4096, + 5, + )).toBe(-14); + + new Uint8Array(processMemory.buffer, processEnd - 4, 4) + .set([5, 6, 7, 8]); + expect(imports.env.host_proc_read_bytes( + 7, + processEnd - 4, + 8192, + 4, + )).toBe(0); + expect(new Uint8Array(memory.buffer, 8192, 4)) + .toEqual(new Uint8Array([5, 6, 7, 8])); + expect(imports.env.host_proc_read_bytes( + 7, + processEnd - 4, + 8192, + 5, + )).toBe(-14); + }); + + it("does not wrap a wasm64 futex address onto a low kernel word", () => { + const { kernel, memory } = kernelHarness({}); + Object.assign(kernel, { kernelPtrWidth: 8 }); + const imports = kernel.buildImportObject(memory) as { + env: Record any>; + }; + const notify = vi.spyOn(Atomics, "notify"); + + expect(imports.env.host_futex_wake(0x1_0000_1000n, 1)).toBe(-14); + expect(notify).not.toHaveBeenCalled(); + notify.mockRestore(); + }); + + it("rejects unaligned and end-crossing futex words", () => { + const { kernel, memory } = kernelHarness({}); + const imports = kernel.buildImportObject(memory) as { + env: Record any>; + }; + + expect(imports.env.host_futex_wake(4097, 1)).toBe(-22); + expect(imports.env.host_futex_wake( + memory.buffer.byteLength - 2, + 1, + )).toBe(-14); + }); + + it("rejects lossy device metadata conversions before registration", () => { + const { kernel, memory } = fullKernelHarness(); + Object.assign(kernel, { kernelPtrWidth: 8 }); + const imports = kernel.buildImportObject(memory) as { + env: Record any>; + }; + const invalid = BigInt(Number.MAX_SAFE_INTEGER) + 1n; + + expect(() => + imports.env.host_bind_framebuffer(7, invalid, 4096n, 1, 1, 4, 0) + ).toThrow(/representable|safe/i); + expect(kernel.framebuffers.get(7)).toBeUndefined(); + expect(imports.env.host_gbm_bo_create( + 7, + 1, + invalid, + 1, + 1, + 4, + )).toBe(-75); + expect(() => imports.env.host_gl_bind(7, invalid, 4096n)) + .toThrow(/representable|safe/i); + expect(kernel.gl.get(7)).toBeUndefined(); + }); + + it("reports a host BO allocation failure without publishing an entry", () => { + const { kernel, memory } = fullKernelHarness(); + const create = vi.spyOn(kernel.bos, "create").mockImplementation(() => { + throw new RangeError("allocation failed"); + }); + const imports = kernel.buildImportObject(memory) as { + env: Record any>; + }; + + expect(imports.env.host_gbm_bo_create(7, 1, 4096n, 1, 1, 4)) + .toBe(-12); + expect(create).toHaveBeenCalledTimes(1); + expect(kernel.bos.get(7, 1)).toBeUndefined(); + }); + + it("preflights GL output before executing a query", () => { + const { kernel, memory } = fullKernelHarness(); + kernel.gl.bind({ pid: 7, cmdbufAddr: 4096, cmdbufLen: 4096 }); + const getError = vi.fn(() => 0x1234); + kernel.gl.get(7)!.gl = { + getError, + } as unknown as WebGL2RenderingContext; + const imports = kernel.buildImportObject(memory) as { + env: Record any>; + }; + + expect(imports.env.host_gl_query( + 7, + QOP_GET_ERROR, + 0, + 0, + memory.buffer.byteLength - 2, + 4, + )).toBe(-14); + expect(getError).not.toHaveBeenCalled(); + + expect(imports.env.host_gl_query( + 7, + QOP_GET_ERROR, + 0, + 0, + memory.buffer.byteLength - 4, + 4, + )).toBe(4); + expect(getError).toHaveBeenCalledTimes(1); + expect( + new DataView(memory.buffer).getUint32( + memory.buffer.byteLength - 4, + true, + ), + ).toBe(0x1234); + }); + + it.each([4, 8] as const)( + "writes the generated KMS mode size at the exact wasm%d memory boundary", + (pointerWidth) => { + const { kernel, memory } = fullKernelHarness(); + Object.assign(kernel, { kernelPtrWidth: pointerWidth }); + const imports = kernel.buildImportObject(memory) as { + env: Record any>; + }; + const exactPointer = + memory.buffer.byteLength - STRUCT_SIZE_WPK_DRM_MODE_MODEINFO; + const pointer = (value: number): number | bigint => + pointerWidth === 4 ? value : BigInt(value); + + expect(() => + imports.env.host_kms_mode_info(1, pointer(exactPointer)) + ).not.toThrow(); + expect( + new Uint8Array( + memory.buffer, + exactPointer, + STRUCT_SIZE_WPK_DRM_MODE_MODEINFO, + ).some((byte) => byte !== 0), + ).toBe(true); + expect(() => + imports.env.host_kms_mode_info(1, pointer(exactPointer + 1)) + ).toThrow(/outside|range/i); + }, + ); + + it("restores the unsigned high bit of a raw wasm32 import pointer", () => { + const highMemory = new WebAssembly.Memory({ + initial: 32_769, + maximum: 32_769, + }); + const { kernel } = fullKernelHarness(); + Object.assign(kernel, { + memory: highMemory, + kernelPtrWidth: 4, + }); + const imports = kernel.buildImportObject(highMemory) as { + env: Record any>; + }; + const unsignedPointer = 0x8000_0020; + const signedImportPointer = unsignedPointer | 0; + + expect(signedImportPointer).toBeLessThan(0); + expect(() => + imports.env.host_kms_mode_info(1, signedImportPointer) + ).not.toThrow(); + expect( + new Uint8Array( + highMemory.buffer, + unsignedPointer, + STRUCT_SIZE_WPK_DRM_MODE_MODEINFO, + ).some((byte) => byte !== 0), + ).toBe(true); + }); +}); diff --git a/host/test/kernel-scratch-contract.test.ts b/host/test/kernel-scratch-contract.test.ts new file mode 100644 index 0000000000..5cc447d8b2 --- /dev/null +++ b/host/test/kernel-scratch-contract.test.ts @@ -0,0 +1,641 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + HOST_INTERCEPTED_SYSCALLS, + POSIX_ARG_MAX_BYTES, + POSIX_IOV_MAX, + POSIX_PATH_MAX_BYTES, + SELECT_FD_SET_BYTES, + SELECT_FD_SETSIZE, + SPAWN_ATTR_RESETIDS, + SPAWN_ATTR_SETPGROUP, + SPAWN_ATTR_SETSCHEDPARAM, + SPAWN_ATTR_SETSCHEDULER, + SPAWN_ATTR_SETSID, + SPAWN_ATTR_SETSIGDEF, + SPAWN_ATTR_SETSIGMASK, + SPAWN_ATTR_USEVFORK, + SPAWN_MAX_ACTION_COUNT, + SPAWN_MAX_ARGV_COUNT, + SPAWN_MAX_ENVP_COUNT, + SPAWN_WIRE_ACTION_FD_OFFSET, + SPAWN_WIRE_ACTION_MODE_OFFSET, + SPAWN_WIRE_ACTION_NEWFD_OFFSET, + SPAWN_WIRE_ACTION_OFLAG_OFFSET, + SPAWN_WIRE_ACTION_OP_OFFSET, + SPAWN_WIRE_ACTION_PATH_LEN_OFFSET, + SPAWN_WIRE_ACTION_PATH_OFF_OFFSET, + SPAWN_WIRE_ACTION_RECORD_BYTES, + SPAWN_WIRE_HEADER_ACTION_COUNT_OFFSET, + SPAWN_WIRE_HEADER_ARGC_OFFSET, + SPAWN_WIRE_HEADER_ATTR_FLAGS_OFFSET, + SPAWN_WIRE_HEADER_BYTES, + SPAWN_WIRE_HEADER_ENVC_OFFSET, + SPAWN_WIRE_HEADER_PAD_OFFSET, + SPAWN_WIRE_HEADER_PGRP_OFFSET, + SPAWN_WIRE_HEADER_SIGDEF_OFFSET, + SPAWN_WIRE_HEADER_SIGMASK_OFFSET, + SPAWN_WIRE_MAX_BYTES, + SPAWN_WIRE_OP_CHDIR, + SPAWN_WIRE_OP_CLOSE, + SPAWN_WIRE_OP_DUP2, + SPAWN_WIRE_OP_FCHDIR, + SPAWN_WIRE_OP_OPEN, + SPAWN_WIRE_STRING_OFFSET_BYTES, +} from "../src/generated/abi"; +import { + auditWasmMemoryWrites, + formatAuditFailures, + repositoryRuntimeSourceFiles, + type AuditAllowance, + type OwnershipSeed, +} from "./support/wasm-memory-write-audit"; +const platformLimitsHeader = readFileSync( + new URL( + "../../libc/musl-overlay/include/bits/kandelo_limits.h", + import.meta.url, + ), + "utf8", +); +const publicLimitsHeader = readFileSync( + new URL("../../libc/musl-overlay/include/limits.h", import.meta.url), + "utf8", +); +const muslSelectHeader = readFileSync( + new URL("../../libc/musl/include/sys/select.h", import.meta.url), + "utf8", +); +const spawnContractHeader = readFileSync( + new URL( + "../../libc/musl-overlay/src/process/wasm32posix/spawn_contract.h", + import.meta.url, + ), + "utf8", +); +const buildMuslSource = readFileSync( + new URL("../../scripts/build-musl.sh", import.meta.url), + "utf8", +); +const muslSpawnSource = readFileSync( + new URL( + "../../libc/musl-overlay/src/process/wasm32posix/posix_spawn.c", + import.meta.url, + ), + "utf8", +); +const kernelSpawnSource = readFileSync( + new URL("../../crates/kernel/src/spawn.rs", import.meta.url), + "utf8", +); +const hostKernelWorkerSource = readFileSync( + new URL("../src/kernel-worker.ts", import.meta.url), + "utf8", +); + +const repoRoot = fileURLToPath(new URL("../..", import.meta.url)); + +const ownershipSeeds: OwnershipSeed[] = [ + { + declaration: "host/src/kernel.ts::WasmPosixKernel.memory", + target: "value", + owner: "kernel", + form: "memory", + why: "This private field is the kernel WebAssembly linear memory.", + }, + { + declaration: "host/src/kernel.ts::WasmPosixKernel.instance", + target: "value", + owner: "kernel", + form: "instance", + why: "This private field is the instantiated kernel module whose exported memory aliases the kernel linear memory.", + }, + { + declaration: "host/src/kernel.ts::WasmPosixKernel.createKernelMemory", + target: "return", + owner: "kernel", + form: "memory", + why: "This factory creates only the kernel WebAssembly linear memory.", + }, + { + declaration: + "host/src/kernel-worker.ts::CentralizedKernelWorker.kernelMemory", + target: "value", + owner: "kernel", + form: "memory", + why: "This worker field aliases only the dedicated kernel memory.", + }, + { + declaration: + "host/src/kernel-worker.ts::CentralizedKernelWorker.kernelInstance", + target: "value", + owner: "kernel", + form: "instance", + why: "This private field is the exact instantiated kernel module used by worker-side scratch consumers.", + }, + { + declaration: + "apps/browser-demos/test/epoll-repro.ts::KernelWorkerInternals.kernelInstance", + target: "value", + owner: "kernel", + form: "instance", + why: "This diagnostic-only interface is the reviewed structural view of CentralizedKernelWorker's exact private kernel instance.", + }, + { + declaration: + "apps/browser-demos/test/epoll-repro.ts::KernelWorkerInternals.scratchRegion", + target: "value", + owner: "kernel", + form: "scratch-region", + why: "This diagnostic-only interface is the reviewed structural view of CentralizedKernelWorker's allocator-created main scratch region.", + }, + { + declaration: + "apps/browser-demos/test/fixtures/opfs-advisory-lock-client-worker.ts::KernelWorkerInternals.kernelInstance", + target: "value", + owner: "kernel", + form: "instance", + why: "This browser fixture's structural field is populated only by its reviewed cast of the live CentralizedKernelWorker instance.", + }, + { + declaration: + "apps/browser-demos/test/fixtures/opfs-advisory-lock-client-worker.ts::KernelWorkerInternals.scratchRegion", + target: "value", + owner: "kernel", + form: "scratch-region", + why: "This browser fixture's structural field is populated only by its reviewed cast of the allocator-created main scratch region.", + }, + { + declaration: "host/src/browser-kernel-worker-entry.ts::kernelMemory", + target: "value", + owner: "kernel", + form: "memory", + why: "This browser-worker diagnostic alias points at kernel memory.", + }, + { + declaration: "host/src/process-memory.ts::createProcessMemory", + target: "return", + owner: "process-memory", + form: "memory", + why: "This factory creates caller-owned process memory, not kernel scratch.", + }, + { + declaration: + "apps/browser-demos/pages/network/network-demo-worker.ts::createProcessMemory", + target: "return", + owner: "process-memory", + form: "memory", + why: "This diagnostic factory creates its guest process memory.", + }, + { + declaration: "host/src/kernel-worker.ts::ChannelInfo.memory", + target: "value", + owner: "process-memory", + form: "memory", + why: "A syscall channel is stored in its caller process memory.", + }, + { + declaration: "host/src/kernel-worker.ts::ProcessRegistration.memory", + target: "value", + owner: "process-memory", + form: "memory", + why: "A process registration carries that process's own memory.", + }, + { + declaration: "host/src/kernel-worker.ts::ThreadChannelAttachment.memory", + target: "value", + owner: "process-memory", + form: "memory", + why: "A thread attachment carries its owning process memory.", + }, + { + declaration: + "host/src/kernel-worker.ts::PendingThreadChannelAttachment.memory", + target: "value", + owner: "process-memory", + form: "memory", + why: "A pending thread attachment carries process memory.", + }, + { + declaration: + "host/src/node-kernel-worker-entry.ts::ProcessGenerationOwnership.memory", + target: "value", + owner: "process-memory", + form: "memory", + why: "Each Node process generation owns its exact guest process memory.", + }, + { + declaration: + "host/src/browser-kernel-worker-entry.ts::ProcessGenerationOwnership.memory", + target: "value", + owner: "process-memory", + form: "memory", + why: "Each browser process generation owns its exact guest process memory.", + }, + { + declaration: "host/src/wasi-shim.ts::WasiShim.memory", + target: "value", + owner: "process-memory", + form: "memory", + why: "The WASI shim operates on its guest process memory.", + }, + { + declaration: + "host/src/fork-continuation.ts::LinkedForkContinuation.$param:memory", + target: "value", + owner: "process-memory", + form: "memory", + why: "Fork continuation frames live in process memory.", + }, + { + declaration: "host/src/dylink.ts::LoadSharedLibraryOptions.memory", + target: "value", + owner: "process-memory", + form: "memory", + why: "Dynamic linking writes into the requesting process memory.", + }, + { + declaration: + "host/src/thread-allocator.ts::ThreadPageAllocator.allocate.$param:memory", + target: "value", + owner: "process-memory", + form: "memory", + why: "Thread control pages are allocations inside process memory.", + }, + { + declaration: "host/src/framebuffer/registry.ts::FbBinding.hostBuffer", + target: "value", + owner: "framebuffer", + form: "view", + why: "This view is host-owned framebuffer storage, not kernel memory.", + }, + { + declaration: "host/src/dri/registry.ts::InternalEntry.sab", + target: "value", + owner: "shared-memory", + form: "buffer", + why: "This shared buffer is canonical device storage outside kernel Wasm.", + }, + { + declaration: "host/src/kernel-worker.ts::AnonymousSharedMmapBacking.bytes", + target: "value", + owner: "shared-memory", + form: "view", + why: "This host view owns an anonymous shared mapping backing.", + }, + { + declaration: "host/src/kernel-worker.ts::SharedMmapMapping.snapshot", + target: "value", + owner: "shared-memory", + form: "view", + why: "This host snapshot is separate from allocator-owned scratch.", + }, + { + declaration: "host/src/kernel-worker.ts::SysvShmMapping.snapshot", + target: "value", + owner: "shared-memory", + form: "view", + why: "This host snapshot tracks a System V shared-memory mapping.", + }, +]; + +const auditAllowances: AuditAllowance[] = [ + { + key: "host/src/kernel-scratch.ts::intrinsicWasmMemoryBuffer::kernel-memory-escape::intrinsicApply( intrinsicMemoryBuffer, memory, [], )", + disposition: "scratch-core", + why: "This is the single captured intrinsic access to a genuine WebAssembly.Memory buffer; every caller immediately applies an allocation-capacity or exact fixed-range proof.", + }, + { + key: "host/src/kernel-scratch.ts::OwnedKernelScratchRegion.constructor::kernel-memory-store::this.#memory = memory", + disposition: "scratch-core", + why: "The unforgeable region constructor stores the factory-validated kernel memory in a true private slot so every lease can recheck current bounds.", + }, + { + key: 'host/src/kernel-scratch.ts::snapshotKernelScratchExports::kernel-pointer-export-bypass::intrinsicObjectGetOwnPropertyDescriptor( value, "length", )', + disposition: "scratch-core", + why: "The scratch core passes the selected raw Wasm export only to a captured descriptor intrinsic so it can validate exact arity before privately snapshotting the callable.", + }, + { + key: "host/src/kernel-scratch.ts::ActiveKernelScratchLease.invokeKernelExport::kernel-pointer-export-bypass::intrinsicApply( kernelExport.call, undefined, convertedArgs, )", + disposition: "scratch-core", + why: "This is the sole approved raw invocation after the lease has replaced every pointer argument with a checked owned-range token and matched each adjacent capacity.", + }, + { + key: "host/src/kernel.ts::WasmPosixKernel.buildImportObject::kernel-memory-return::memory", + disposition: "kernel-control", + why: "The env.memory import is the engine-required kernel memory and is consumed only by the two reviewed instantiation sites.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.createKernelMemory::kernel-memory-return::return new WebAssembly.Memory({ initial: 24n, maximum: 16384n, shared: true, address: "i64", } as unknown as WebAssembly.MemoryDescriptor);', + disposition: "kernel-control", + why: "This factory branch creates the dedicated memory64 kernel linear memory before instantiation.", + }, + { + key: "host/src/kernel.ts::WasmPosixKernel.createKernelMemory::kernel-memory-return::return new WebAssembly.Memory({ // 24 pages = 1.5 MiB of initial address space. This must remain above // the kernel Wasm's linker-derived minimum and leaves headroom for // future static data without re-tuning host construction each time. initial: 24, maximum: 16384, shared: true, });", + disposition: "kernel-control", + why: "This factory branch creates the dedicated memory32 kernel linear memory before instantiation.", + }, + { + key: "host/src/kernel.ts::WasmPosixKernel.getMemory::kernel-memory-return::return this.memory;", + disposition: "kernel-read", + why: "This documented unsafe trusted-embedder API intentionally exposes kernel memory for tests and low-level diagnostics.", + }, + { + key: "host/src/kernel.ts::WasmPosixKernel.init::kernel-memory-escape::WebAssembly.instantiate(module, importObject)", + disposition: "kernel-control", + why: "The engine receives the dedicated memory only as the kernel module's reviewed env.memory import.", + }, + { + key: "host/src/kernel.ts::WasmPosixKernel.initWithMemory::kernel-memory-escape::WebAssembly.instantiate(module, importObject)", + disposition: "kernel-control", + why: "The thread-worker path passes its explicitly supplied shared kernel memory only to kernel instantiation.", + }, + { + key: "host/src/host-adapter-manifest.ts::readKernelHostAdapterManifest::kernel-view::new DataView( memory.buffer, pointer, HOST_ADAPTER_MANIFEST_SIZE, )", + disposition: "kernel-read", + why: "The fixed-size adapter manifest is read synchronously after its complete kernel-memory range is checked.", + }, + { + key: "host/src/kernel-scratch.ts::OwnedKernelScratchRegion.allocate::scratch-allocator-call::allocator(capacity)", + disposition: "scratch-core", + why: "This is the sole allocator invocation; the returned pointer remains private and is validated with its requested capacity.", + }, + { + key: 'host/src/kernel-worker.ts::CentralizedKernelWorker.init::scratch-region-factory-call::allocateKernelScratchRegion( this.kernelMemory, allocScratch, SCRATCH_SIZE, this.kernel.getKernelPtrWidth(), "kernel syscall scratch", this.kernelInstance, )', + disposition: "scratch-core", + why: "The main channel region binds its memory, allocator, and reviewed fixed capacity to the exact instantiated kernel module.", + }, + { + key: 'host/src/kernel-worker.ts::CentralizedKernelWorker.init::scratch-region-factory-call::allocateKernelScratchRegion( this.kernelMemory, allocScratch, 65536, this.kernel.getKernelPtrWidth(), "kernel TCP scratch", this.kernelInstance, )', + disposition: "scratch-core", + why: "The TCP region binds its memory, allocator, and reviewed fixed capacity to the exact instantiated kernel module.", + }, + { + key: 'host/src/kernel-worker.ts::CentralizedKernelWorker.beginLargeSpawnScratch::scratch-region-factory-call::reserveKernelScratchRegion( this.kernelMemory!, () => ({ pointer: pointer(rawToken), capacity: capacity(rawToken), }), blobLen, this.kernel.getKernelPtrWidth(), "kernel reserved spawn scratch", )', + disposition: "scratch-core", + why: "The spawn region binds the pointer and capacity returned by one active Rust-owned transactional reservation.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.requireApiScratch::scratch-region-factory-call::allocateKernelScratchRegion( this.memory, allocator, WasmPosixKernel.API_SCRATCH_SIZE, this.kernelPtrWidth, "kernel public API scratch", this.instance!, )', + disposition: "scratch-core", + why: "The public API region binds its memory, allocator, and reviewed fixed capacity to this exact kernel instance.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.ensureAudioScratch::scratch-region-factory-call::allocateKernelScratchRegion( this.memory, alloc, WasmPosixKernel.AUDIO_SCRATCH_SIZE, this.kernelPtrWidth, "kernel audio scratch", this.instance!, )', + disposition: "scratch-core", + why: "The audio region binds its memory, allocator, and reviewed fixed capacity to this exact kernel instance.", + }, + { + key: "host/src/kernel-worker.ts::CentralizedKernelWorker.beginLargeSpawnScratch::spawn-reservation-call::begin(this.toKernelPtr(blobLen))", + disposition: "scratch-core", + why: "This begins one transactional Rust-owned reservation before any pointer or capacity is observed.", + }, + { + key: "host/src/kernel-worker.ts::CentralizedKernelWorker.beginLargeSpawnScratch::spawn-reservation-call::capacity(rawToken)", + disposition: "scratch-core", + why: "The capacity accessor is consumed only by reserveKernelScratchRegion while the matching transaction is active.", + }, + { + key: "host/src/kernel-worker.ts::CentralizedKernelWorker.beginLargeSpawnScratch::spawn-reservation-call::pointer(rawToken)", + disposition: "scratch-core", + why: "The pointer accessor is consumed only by reserveKernelScratchRegion while the matching transaction is active.", + }, + { + key: "host/src/kernel-worker.ts::CentralizedKernelWorker.cancelLargeSpawnScratch::spawn-reservation-call::cancel(token)", + disposition: "scratch-core", + why: "This exact cleanup path releases a reservation that was begun but not consumed by the Rust spawn entry point.", + }, + { + key: "host/src/kernel.ts::WasmPosixKernel.getMemoryBuffer::kernel-view-return::return new Uint8Array(this.memory.buffer);", + disposition: "kernel-read", + why: "This private full-memory view is tracked through every caller; only exact checked read and Rust-lent write sinks are admitted below.", + }, + { + key: "host/src/kernel.ts::WasmPosixKernel.getMemoryBuffer::kernel-view::new Uint8Array(this.memory.buffer)", + disposition: "kernel-read", + why: "This private constructor feeds only the separately inventoried synchronous read and checked Rust-lent write helpers.", + }, + { + key: "host/src/kernel.ts::WasmPosixKernel.hostFutexWait::kernel-view::new Int32Array(this.memory.buffer)", + disposition: "kernel-control", + why: "The futex word's lossless pointer, four-byte range, and alignment are checked before constructing this current-memory atomic view.", + }, + { + key: "host/src/kernel.ts::WasmPosixKernel.hostFutexWake::kernel-view::new Int32Array(this.memory.buffer)", + disposition: "kernel-control", + why: "The futex word's lossless pointer, four-byte range, and alignment are checked before constructing this current-memory atomic view.", + }, + { + key: "host/src/kernel.ts::WasmPosixKernel.writeKernelBytes::kernel-write::this.getMemoryBuffer().set(exactBytes, range.pointer)", + disposition: "rust-lent", + why: "writeKernelBytes proves pointer, explicit capacity, current-memory bounds, and producer length before this write.", + }, + { + key: "host/src/host-adapter-manifest.ts::readKernelHostAdapterManifest::kernel-pointer-export-bypass::ptrFn()", + disposition: "kernel-read", + why: "This exact dynamically selected manifest export returns a scalar offset and accepts no pointer argument.", + }, + { + key: "host/src/host-adapter-manifest.ts::readKernelHostAdapterManifest::kernel-pointer-export-bypass::lenFn()", + disposition: "kernel-read", + why: "This exact dynamically selected manifest export returns a scalar length and accepts no pointer argument.", + }, + { + key: "host/src/kernel-worker.ts::CentralizedKernelWorker.init::kernel-pointer-export-bypass::abiVersionFn()", + disposition: "kernel-control", + why: "This exact generated-name export takes no arguments and returns only the kernel ABI version scalar.", + }, + { + key: "host/src/kernel-worker.ts::CentralizedKernelWorker.handleIpcControl::kernel-pointer-export-bypass::structureBytes(pointerWidth)", + disposition: "kernel-control", + why: "This exact two-name IPC metadata branch passes only pointer width and returns a structure-size scalar.", + }, + { + key: "host/src/kernel.ts::WasmPosixKernel.ioctl::kernel-pointer-export-bypass::fn( fd, request, this.toKernelPtr(scalarArgument), bufLen, 4, )", + disposition: "kernel-control", + why: "This exact non-pointer ioctl branch passes a scalar command argument with zero buffer length; pointer ioctl requests use the scratch lease branch above.", + }, +]; + +describe("kernel scratch static contract", () => { + it("admits only reviewed kernel-memory views, writes, and allocator calls", () => { + const result = auditWasmMemoryWrites({ + rootDir: repoRoot, + sourceFiles: repositoryRuntimeSourceFiles(repoRoot), + ownershipSeeds, + allowances: auditAllowances, + }); + expect(formatAuditFailures(result)).toEqual([]); + // This intentionally builds one TypeScript program for every repository + // runtime source; keep CI headroom above the focused local 25–35 second run. + }, 60_000); + + it("keeps generated platform and spawn contracts wired into musl", () => { + expect(platformLimitsHeader).toContain( + `#define KANDELO_POSIX_ARG_MAX_BYTES ${POSIX_ARG_MAX_BYTES}u`, + ); + expect(platformLimitsHeader).toContain( + `#define KANDELO_POSIX_PATH_MAX_BYTES ${POSIX_PATH_MAX_BYTES}u`, + ); + expect(platformLimitsHeader).toContain( + `#define KANDELO_POSIX_IOV_MAX ${POSIX_IOV_MAX}u`, + ); + + expect(publicLimitsHeader).toContain("#include "); + expect(publicLimitsHeader).toContain( + "#define ARG_MAX KANDELO_POSIX_ARG_MAX_BYTES", + ); + expect(publicLimitsHeader).toContain( + "#define PATH_MAX KANDELO_POSIX_PATH_MAX_BYTES", + ); + expect(publicLimitsHeader).toContain( + "#define IOV_MAX KANDELO_POSIX_IOV_MAX", + ); + expect(muslSelectHeader).toContain( + `#define FD_SETSIZE ${SELECT_FD_SETSIZE}`, + ); + expect(SELECT_FD_SET_BYTES).toBe(SELECT_FD_SETSIZE / 8); + + expect(spawnContractHeader).toContain("#include "); + expect(spawnContractHeader).toContain( + "#define WASM_POSIX_ARG_MAX_BYTES KANDELO_POSIX_ARG_MAX_BYTES", + ); + expect(spawnContractHeader).toContain( + "#define WASM_POSIX_PATH_MAX_BYTES KANDELO_POSIX_PATH_MAX_BYTES", + ); + const exactSpawnWireMacros = [ + ["WASM_POSIX_SYS_SPAWN", HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN], + ["WASM_POSIX_SPAWN_STRING_OFFSET_BYTES", SPAWN_WIRE_STRING_OFFSET_BYTES], + ["WASM_POSIX_SPAWN_HEADER_ARGC_OFFSET", SPAWN_WIRE_HEADER_ARGC_OFFSET], + ["WASM_POSIX_SPAWN_HEADER_ENVC_OFFSET", SPAWN_WIRE_HEADER_ENVC_OFFSET], + [ + "WASM_POSIX_SPAWN_HEADER_ACTION_COUNT_OFFSET", + SPAWN_WIRE_HEADER_ACTION_COUNT_OFFSET, + ], + [ + "WASM_POSIX_SPAWN_HEADER_ATTR_FLAGS_OFFSET", + SPAWN_WIRE_HEADER_ATTR_FLAGS_OFFSET, + ], + ["WASM_POSIX_SPAWN_HEADER_PGRP_OFFSET", SPAWN_WIRE_HEADER_PGRP_OFFSET], + ["WASM_POSIX_SPAWN_HEADER_PAD_OFFSET", SPAWN_WIRE_HEADER_PAD_OFFSET], + [ + "WASM_POSIX_SPAWN_HEADER_SIGDEF_OFFSET", + SPAWN_WIRE_HEADER_SIGDEF_OFFSET, + ], + [ + "WASM_POSIX_SPAWN_HEADER_SIGMASK_OFFSET", + SPAWN_WIRE_HEADER_SIGMASK_OFFSET, + ], + ["WASM_POSIX_SPAWN_ACTION_OP_OFFSET", SPAWN_WIRE_ACTION_OP_OFFSET], + ["WASM_POSIX_SPAWN_ACTION_FD_OFFSET", SPAWN_WIRE_ACTION_FD_OFFSET], + ["WASM_POSIX_SPAWN_ACTION_NEWFD_OFFSET", SPAWN_WIRE_ACTION_NEWFD_OFFSET], + [ + "WASM_POSIX_SPAWN_ACTION_PATH_OFF_OFFSET", + SPAWN_WIRE_ACTION_PATH_OFF_OFFSET, + ], + [ + "WASM_POSIX_SPAWN_ACTION_PATH_LEN_OFFSET", + SPAWN_WIRE_ACTION_PATH_LEN_OFFSET, + ], + ["WASM_POSIX_SPAWN_ACTION_OFLAG_OFFSET", SPAWN_WIRE_ACTION_OFLAG_OFFSET], + ["WASM_POSIX_SPAWN_ACTION_MODE_OFFSET", SPAWN_WIRE_ACTION_MODE_OFFSET], + ["WASM_POSIX_SPAWN_OP_OPEN", SPAWN_WIRE_OP_OPEN], + ["WASM_POSIX_SPAWN_OP_CLOSE", SPAWN_WIRE_OP_CLOSE], + ["WASM_POSIX_SPAWN_OP_DUP2", SPAWN_WIRE_OP_DUP2], + ["WASM_POSIX_SPAWN_OP_CHDIR", SPAWN_WIRE_OP_CHDIR], + ["WASM_POSIX_SPAWN_OP_FCHDIR", SPAWN_WIRE_OP_FCHDIR], + ["WASM_POSIX_SPAWN_ATTR_RESETIDS", SPAWN_ATTR_RESETIDS], + ["WASM_POSIX_SPAWN_ATTR_SETPGROUP", SPAWN_ATTR_SETPGROUP], + ["WASM_POSIX_SPAWN_ATTR_SETSIGDEF", SPAWN_ATTR_SETSIGDEF], + ["WASM_POSIX_SPAWN_ATTR_SETSIGMASK", SPAWN_ATTR_SETSIGMASK], + ["WASM_POSIX_SPAWN_ATTR_SETSCHEDPARAM", SPAWN_ATTR_SETSCHEDPARAM], + ["WASM_POSIX_SPAWN_ATTR_SETSCHEDULER", SPAWN_ATTR_SETSCHEDULER], + ["WASM_POSIX_SPAWN_ATTR_USEVFORK", SPAWN_ATTR_USEVFORK], + ["WASM_POSIX_SPAWN_ATTR_SETSID", SPAWN_ATTR_SETSID], + ] as const; + for (const [name, value] of exactSpawnWireMacros) { + expect(spawnContractHeader).toContain(`#define ${name} ${value}u`); + } + expect(spawnContractHeader).toContain( + `#define WASM_POSIX_SPAWN_HEADER_BYTES ${SPAWN_WIRE_HEADER_BYTES}u`, + ); + expect(spawnContractHeader).toContain( + `#define WASM_POSIX_SPAWN_ACTION_RECORD_BYTES ${SPAWN_WIRE_ACTION_RECORD_BYTES}u`, + ); + expect(spawnContractHeader).toContain( + `#define WASM_POSIX_SPAWN_MAX_ARGV_COUNT ${SPAWN_MAX_ARGV_COUNT}u`, + ); + expect(spawnContractHeader).toContain( + `#define WASM_POSIX_SPAWN_MAX_ENVP_COUNT ${SPAWN_MAX_ENVP_COUNT}u`, + ); + expect(spawnContractHeader).toContain( + `#define WASM_POSIX_SPAWN_MAX_ACTION_COUNT ${SPAWN_MAX_ACTION_COUNT}u`, + ); + expect(spawnContractHeader).toContain( + `#define WASM_POSIX_SPAWN_WIRE_MAX_BYTES ${SPAWN_WIRE_MAX_BYTES}u`, + ); + + // WHY: musl compiles sysconf limits before overlay headers are installed, + // so both generated public headers must be staged into its source tree. + expect(buildMuslSource).toContain( + 'cp "$OVERLAY_DIR/include/limits.h" "$MUSL_DIR/include/limits.h"', + ); + expect(buildMuslSource).toContain( + 'cp "$OVERLAY_DIR/include/bits/kandelo_limits.h" \\\n' + + ' "$MUSL_DIR/include/bits/kandelo_limits.h"', + ); + }); + + it("keeps every requested spawn consumer on authoritative symbols", () => { + expect(muslSpawnSource).toContain('#include "spawn_contract.h"'); + for (const name of [ + "WASM_POSIX_ARG_MAX_BYTES", + "WASM_POSIX_PATH_MAX_BYTES", + "WASM_POSIX_SPAWN_STRING_OFFSET_BYTES", + "WASM_POSIX_SPAWN_HEADER_BYTES", + "WASM_POSIX_SPAWN_ACTION_RECORD_BYTES", + "WASM_POSIX_SPAWN_MAX_ARGV_COUNT", + "WASM_POSIX_SPAWN_MAX_ENVP_COUNT", + "WASM_POSIX_SPAWN_MAX_ACTION_COUNT", + "WASM_POSIX_SPAWN_WIRE_MAX_BYTES", + ]) { + expect(muslSpawnSource).toMatch(new RegExp(`\\b${name}\\b`)); + } + // WHY: accepting a locally redefined copy would let the generated header + // stay fresh while the compiled C consumer silently follows another value. + expect(muslSpawnSource).not.toMatch( + /^\s*#\s*define\s+WASM_POSIX_(?:ARG_MAX|PATH_MAX|SPAWN_)/m, + ); + + expect(kernelSpawnSource).toContain( + "use wasm_posix_shared::{Errno, spawn_contract};", + ); + for (const name of [ + "WIRE_HEADER_BYTES", + "WIRE_ACTION_RECORD_BYTES", + "MAX_ARGV_COUNT", + "MAX_ENVP_COUNT", + "MAX_ACTION_COUNT", + "POSIX_ARG_MAX_BYTES", + "POSIX_PATH_MAX_BYTES", + "WIRE_MAX_BYTES", + ]) { + expect(kernelSpawnSource).toMatch( + new RegExp(`\\bspawn_contract::${name}\\b`), + ); + } + + for (const name of [ + "POSIX_ARG_MAX_BYTES", + "POSIX_PATH_MAX_BYTES", + "SPAWN_MAX_ARGV_COUNT", + "SPAWN_MAX_ENVP_COUNT", + "SPAWN_MAX_ACTION_COUNT", + "SPAWN_WIRE_HEADER_BYTES", + "SPAWN_WIRE_ACTION_RECORD_BYTES", + "SPAWN_WIRE_MAX_BYTES", + ]) { + expect(hostKernelWorkerSource).toMatch(new RegExp(`\\b${name}\\b`)); + } + }); +}); diff --git a/host/test/kernel-scratch-region.test.ts b/host/test/kernel-scratch-region.test.ts new file mode 100644 index 0000000000..1d8f5f0707 --- /dev/null +++ b/host/test/kernel-scratch-region.test.ts @@ -0,0 +1,1696 @@ +import { describe, expect, it, vi } from "vitest"; + +import * as kernelScratchModule from "../src/kernel-scratch"; +import { + allocateKernelScratchRegion, + checkedKernelExportPointer, + checkedMemoryRange, + type KernelScratchDataView, + type KernelScratchLease, + KernelScratchError, + reserveKernelScratchRegion, +} from "../src/kernel-scratch"; + +function memory(pages = 1): WebAssembly.Memory { + return new WebAssembly.Memory({ initial: pages, maximum: pages }); +} + +type WasmValueType = "i32" | "i64"; + +function unsignedLeb128(value: number): number[] { + const bytes: number[] = []; + do { + let byte = value & 0x7f; + value >>>= 7; + if (value !== 0) byte |= 0x80; + bytes.push(byte); + } while (value !== 0); + return bytes; +} + +function wasmString(value: string): number[] { + const bytes = Array.from(new TextEncoder().encode(value)); + return [...unsignedLeb128(bytes.length), ...bytes]; +} + +function wasmSection(id: number, payload: number[]): number[] { + return [id, ...unsignedLeb128(payload.length), ...payload]; +} + +function importedKernelExportInstance( + memory: WebAssembly.Memory, + exportName: string, + parameterTypes: readonly WasmValueType[], + callback: (...args: Array) => number, + allocator?: () => number | bigint, +): WebAssembly.Instance { + const valueType = (type: WasmValueType): number => + type === "i32" ? 0x7f : 0x7e; + const pointerType: WasmValueType = parameterTypes.includes("i64") + ? "i64" + : "i32"; + const allocatorImpl = allocator ?? (() => + pointerType === "i64" ? 4096n : 4096); + const typeSection = [ + 2, + 0x60, + 1, + valueType("i32"), + 1, + valueType(pointerType), + 0x60, + ...unsignedLeb128(parameterTypes.length), + ...parameterTypes.map(valueType), + 1, + 0x7f, + ]; + const importSection = [ + 3, + ...wasmString("host"), + ...wasmString("memory"), + 2, + 0, + 0, + ...wasmString("host"), + ...wasmString("allocator"), + 0, + 0, + ...wasmString("host"), + ...wasmString("callback"), + 0, + 1, + ]; + const exportSection = [ + 3, + ...wasmString("memory"), + 2, + 0, + ...wasmString("kernel_alloc_scratch"), + 0, + 0, + ...wasmString(exportName), + 0, + 1, + ]; + const bytes = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, + 0x01, 0x00, 0x00, 0x00, + ...wasmSection(1, typeSection), + ...wasmSection(2, importSection), + ...wasmSection(7, exportSection), + ]); + const module = new WebAssembly.Module(bytes); + return new WebAssembly.Instance(module, { + host: { + memory, + allocator: allocatorImpl, + callback, + }, + }); +} + +function installDataViewConstructorCounter(): { + count: () => number; + restore: () => void; +} { + const NativeDataView = globalThis.DataView; + let constructions = 0; + class CountingDataView extends NativeDataView { + constructor( + buffer: ArrayBufferLike, + byteOffset?: number, + byteLength?: number, + ) { + super(buffer, byteOffset, byteLength); + constructions++; + } + } + vi.stubGlobal("DataView", CountingDataView); + return { + count: () => constructions, + restore: () => vi.unstubAllGlobals(), + }; +} + +describe("KernelScratchRegion", () => { + it("exports only structural scratch capabilities, not concrete constructors", () => { + expect(Object.hasOwn(kernelScratchModule, "KernelScratchRegion")).toBe(false); + expect(Object.hasOwn(kernelScratchModule, "KernelScratchLease")).toBe(false); + expect(Object.hasOwn(kernelScratchModule, "KernelScratchDataView")).toBe(false); + }); + + it("keeps region capacity and authority in immutable runtime state", () => { + const kernelMemory = memory(); + const region = allocateKernelScratchRegion( + kernelMemory, + () => 4096, + 32, + 4, + "immutable scratch", + ); + const reflected = region as unknown as Record; + + expect(Object.isFrozen(region)).toBe(true); + expect(Reflect.ownKeys(region)).toEqual(["capacity"]); + expect(reflected.pointer).toBeUndefined(); + expect(reflected.memory).toBeUndefined(); + expect(reflected.kernelExports).toBeUndefined(); + expect(Reflect.set(region, "capacity", 96)).toBe(false); + expect(() => Object.defineProperty(region, "capacity", { value: 96 })) + .toThrow(); + expect(region.capacity).toBe(32); + + new Uint8Array(kernelMemory.buffer, 4096, 96).fill(0x5a); + expect(() => region.withLease((scratch) => { + scratch.copyFrom(new Uint8Array(33)); + })).toThrow(KernelScratchError); + expect(new Uint8Array(kernelMemory.buffer, 4096 + 32, 64)) + .toEqual(new Uint8Array(64).fill(0x5a)); + + const ReflectedConstructor = reflected.constructor as new ( + ...args: unknown[] + ) => unknown; + expect(() => new ReflectedConstructor({})).toThrow(/cannot be constructed/i); + }); + + it("exposes no reflectable lease or guarded-view authority", () => { + const region = allocateKernelScratchRegion( + memory(), + () => 4096, + 32, + 4, + "private-state scratch", + ); + + region.withLease((scratch) => { + const reflectedLease = scratch as unknown as Record; + expect(Object.isFrozen(scratch)).toBe(true); + expect(Reflect.ownKeys(scratch)).toEqual([]); + for (const name of [ + "rangeForLease", + "currentMemoryBuffer", + "kernelExports", + "valid", + "invokingKernelExport", + ]) { + expect(reflectedLease[name]).toBeUndefined(); + expect(Reflect.set(scratch, name, false)).toBe(false); + } + const ReflectedLeaseConstructor = reflectedLease.constructor as new ( + ...args: unknown[] + ) => unknown; + expect(() => new ReflectedLeaseConstructor({})) + .toThrow(/cannot be constructed/i); + + const view = scratch.dataView(0, 8); + const reflectedView = view as unknown as Record; + expect(Object.isFrozen(view)).toBe(true); + expect(Reflect.ownKeys(view)).toEqual([]); + expect(reflectedView.cachedView).toBeUndefined(); + expect(reflectedView.cachedBuffer).toBeUndefined(); + const ReflectedViewConstructor = reflectedView.constructor as new ( + ...args: unknown[] + ) => unknown; + expect(() => new ReflectedViewConstructor({})) + .toThrow(/cannot be constructed/i); + }); + }); + + it.each([4, 8] as const)( + "accepts exact-capacity copies and rejects capacity + 1 for wasm%d", + (pointerWidth) => { + const kernelMemory = memory(); + const region = allocateKernelScratchRegion( + kernelMemory, + vi.fn(() => pointerWidth === 8 ? 4096n : 4096), + 32, + pointerWidth, + "test scratch", + ); + + region.withLease((scratch) => { + scratch.copyFrom(new Uint8Array(32).fill(0x5a)); + }); + expect(new Uint8Array(kernelMemory.buffer, 4096, 32)) + .toEqual(new Uint8Array(32).fill(0x5a)); + + expect(() => region.withLease((scratch) => { + scratch.copyFrom(new Uint8Array(33)); + })).toThrow(KernelScratchError); + }, + ); + + it.each([ + ["zero", 0], + ["negative", -1], + ["fractional", 4096.5], + ["unsafe integer", Number.MAX_SAFE_INTEGER + 1], + ["negative bigint", -1n], + ["unrepresentable bigint", BigInt(Number.MAX_SAFE_INTEGER) + 1n], + ])("rejects a %s allocator pointer", (_name, pointer) => { + expect(() => allocateKernelScratchRegion( + memory(), + () => pointer, + 32, + 8, + "test scratch", + )).toThrow(KernelScratchError); + }); + + it("rejects wasm32 pointers outside the wasm32 address space", () => { + expect(() => allocateKernelScratchRegion( + memory(), + () => 0x1_0000_0000n, + 32, + 4, + "test scratch", + )).toThrow(KernelScratchError); + }); + + it("rejects an unsupported runtime pointer width before allocating", () => { + const allocator = vi.fn(() => 4096); + expect(() => allocateKernelScratchRegion( + memory(), + allocator, + 32, + 5 as 4, + "invalid-width scratch", + )).toThrow(/pointer width must be exactly 4 or 8/i); + expect(allocator).not.toHaveBeenCalled(); + }); + + it("normalizes the signed high bit of a wasm32 allocator result", () => { + expect(checkedKernelExportPointer( + -0x8000_0000, + 4, + "high wasm32 allocation", + )).toBe(0x8000_0000); + }); + + it.each([ + ["zero", 0], + ["negative", -1], + ["fractional", 1.5], + ["unsafe", Number.MAX_SAFE_INTEGER + 1], + ["infinite", Number.POSITIVE_INFINITY], + ])("rejects a %s capacity", (_name, capacity) => { + expect(() => allocateKernelScratchRegion( + memory(), + () => 4096, + capacity, + 4, + "test scratch", + )).toThrow(KernelScratchError); + }); + + it("rejects allocator capacity above u32 before calling Wasm", () => { + const allocator = vi.fn(() => 4096); + expect(() => allocateKernelScratchRegion( + memory(), + allocator, + 0x1_0000_0000, + 8, + "oversized allocator request", + )).toThrow(/u32/i); + expect(allocator).not.toHaveBeenCalled(); + }); + + it("rejects allocation failure and an allocation outside current memory", () => { + expect(() => allocateKernelScratchRegion( + memory(), + () => 0, + 32, + 4, + "test scratch", + )).toThrow(KernelScratchError); + expect(() => allocateKernelScratchRegion( + memory(), + () => 65_520, + 32, + 4, + "test scratch", + )).toThrow(KernelScratchError); + }); + + it("revalidates the current memory buffer before each operation", () => { + const kernelMemory = memory(); + const region = allocateKernelScratchRegion( + kernelMemory, + () => 65_504, + 32, + 4, + "test scratch", + ); + + expect(() => region.withLease((scratch) => { + scratch.copyFrom(new Uint8Array(32), 1); + })).toThrow(KernelScratchError); + expect(new Uint8Array(kernelMemory.buffer, 65_504, 32)) + .toEqual(new Uint8Array(32)); + }); + + it("rejects negative, fractional, unsafe, and overflowing copy ranges", () => { + const region = allocateKernelScratchRegion( + memory(), + () => 4096, + 32, + 4, + "test scratch", + ); + for (const bad of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1]) { + expect(() => region.withLease((scratch) => { + scratch.copyFrom(new Uint8Array(1), bad); + })).toThrow(KernelScratchError); + } + expect(() => region.withLease((scratch) => { + scratch.copyFrom(new Uint8Array(8), 28); + })).toThrow(KernelScratchError); + }); + + it("permits sequential leases and rejects nested or asynchronous reuse", async () => { + const region = allocateKernelScratchRegion( + memory(), + () => 4096, + 32, + 4, + "test scratch", + ); + + region.withLease((scratch) => scratch.fill(1, 0, 32)); + region.withLease((scratch) => scratch.fill(2, 0, 32)); + + expect(() => region.withLease(() => { + region.withLease(() => undefined); + })).toThrow(/already in use/i); + + expect(() => region.withLease(async () => undefined)) + .toThrow(/synchronous/i); + await Promise.resolve(); + }); + + it("revokes a lease before inspecting a hostile then getter", () => { + const kernelMemory = memory(); + const region = allocateKernelScratchRegion( + kernelMemory, + () => 4096, + 32, + 4, + "test scratch", + ); + let escaped: KernelScratchLease | undefined; + const hostileResult = Object.defineProperty({}, "then", { + get() { + escaped!.fill(0x7f, 0, 1); + return undefined; + }, + }); + + expect(() => region.withLease((scratch) => { + escaped = scratch; + return hostileResult; + })).toThrow(/no longer active/i); + expect(new Uint8Array(kernelMemory.buffer)[4096]).toBe(0); + + // The rejected return object did not strand the reusable region. + region.withLease((scratch) => scratch.fill(0x2a, 0, 1)); + expect(new Uint8Array(kernelMemory.buffer)[4096]).toBe(0x2a); + }); + + it("uses the captured native data view while revoking post-lease access", () => { + const region = allocateKernelScratchRegion( + memory(), + () => 4096, + 32, + 4, + "test scratch", + ); + const counter = installDataViewConstructorCounter(); + let escaped: KernelScratchDataView | undefined; + try { + region.withLease((scratch) => { + escaped = scratch.dataView(0, 4); + expect(counter.count()).toBe(0); + escaped.setUint32(0, 0x1234_5678, true); + expect(escaped.getUint16(0, true)).toBe(0x5678); + expect(escaped.byteLength).toBe(4); + expect(counter.count()).toBe(0); + }); + + expect(() => escaped!.setUint32(0, 0, true)) + .toThrow(/no longer active/i); + expect(counter.count()).toBe(0); + } finally { + counter.restore(); + } + }); + + it.each([ + ["u32-le", 4, 0x1008n], + ["u64-le", 8, 0x1008n], + ["u32-to-u64-le", 8, 0x1008n], + ] as const)( + "encodes a checked %s address without making its bytes readable", + (encoding, encodedBytes, expectedPointer) => { + const kernelMemory = memory(); + const region = allocateKernelScratchRegion( + kernelMemory, + () => 4096, + 32, + 4, + "test scratch", + ); + + region.withLease((scratch) => { + scratch.writeAddress(0, 8, 8, encoding); + const view = scratch.dataView(0, 32); + const raw = new DataView( + kernelMemory.buffer, + 4096, + encodedBytes, + ); + expect(encodedBytes === 4 + ? BigInt(raw.getUint32(0, true)) + : raw.getBigUint64(0, true) + ).toBe(expectedPointer); + + // The pointer value was written for synchronous Rust consumption, but + // no host read API may turn those bytes back into a retainable number. + expect(() => encoding === "u32-le" + ? view.getUint32(0, true) + : view.getBigUint64(0, true) + ).toThrow(/address bytes.*write-only/i); + expect(() => view.getUint8(encodedBytes - 1)) + .toThrow(/address bytes.*write-only/i); + expect(() => scratch.copyOut(0, encodedBytes)) + .toThrow(/address bytes.*write-only/i); + expect(() => scratch.copyTo(new Uint8Array(encodedBytes), 0)) + .toThrow(/address bytes.*write-only/i); + + // Unrelated bytes in the same lease remain normally readable. + view.setUint32(16, 0x1234_5678, true); + expect(view.getUint32(16, true)).toBe(0x1234_5678); + }); + + expect(new Uint8Array(kernelMemory.buffer, 4096, encodedBytes)) + .toEqual(new Uint8Array(encodedBytes)); + }, + ); + + it("checks both sides and the encoding width of write-only addresses", () => { + const region = allocateKernelScratchRegion( + memory(), + () => 4096, + 32, + 4, + "test scratch", + ); + + expect(() => region.withLease((scratch) => { + scratch.writeAddress(29, 0, 1, "u32-le"); + })).toThrow(KernelScratchError); + expect(() => region.withLease((scratch) => { + scratch.writeAddress(0, 31, 2, "u64-le"); + })).toThrow(KernelScratchError); + expect(() => region.withLease((scratch) => { + scratch.writeAddress(0, 0, 1, "invalid" as "u32-le"); + })).toThrow(/encoding/i); + + expect(checkedKernelExportPointer( + 0x1_0000_0000n, + 8, + "high wasm64 scratch", + )).toBe(0x1_0000_0000); + }); + + it.each(["return", "throw"] as const)( + "scrubs write-only addresses before a sequential lease after callback %s", + (completion) => { + const kernelMemory = memory(); + const region = allocateKernelScratchRegion( + kernelMemory, + () => 4096, + 32, + 4, + "test scratch", + ); + const encode = () => region.withLease((scratch) => { + scratch.writeAddress(0, 8, 8, "u64-le"); + if (completion === "throw") { + throw new Error("synthetic callback failure"); + } + }); + + if (completion === "throw") { + expect(encode).toThrow("synthetic callback failure"); + } else { + encode(); + } + + region.withLease((scratch) => { + expect(scratch.dataView(0, 8).getBigUint64(0, true)).toBe(0n); + expect(scratch.copyOut(0, 8)).toEqual(new Uint8Array(8)); + }); + expect(new Uint8Array(kernelMemory.buffer, 4096, 8)) + .toEqual(new Uint8Array(8)); + }, + ); + + it("cannot reflectively replace the buffer used to scrub encoded addresses", () => { + const kernelMemory = memory(); + const region = allocateKernelScratchRegion( + kernelMemory, + () => 4096, + 32, + 4, + "private scrub scratch", + ); + + region.withLease((scratch) => { + scratch.writeAddress(0, 8, 1, "u64-le"); + expect(Reflect.set( + scratch, + "currentMemoryBuffer", + () => { + throw new Error("scrub sabotage"); + }, + )).toBe(false); + }); + + region.withLease((scratch) => { + expect(scratch.copyOut(0, 8)).toEqual(new Uint8Array(8)); + }); + }); + + it("blocks preexisting and overlapping address views", () => { + const region = allocateKernelScratchRegion( + memory(), + () => 4096, + 32, + 4, + "test scratch", + ); + + region.withLease((scratch) => { + const preexisting = scratch.dataView(0, 16); + scratch.writeAddress(4, 16, 8, "u64-le"); + const overlapping = scratch.dataView(8, 8); + + expect(() => preexisting.getUint8(4)) + .toThrow(/address bytes.*write-only/i); + expect(() => overlapping.getUint32(0, true)) + .toThrow(/address bytes.*write-only/i); + expect(() => preexisting.setUint32(4, 0, true)) + .toThrow(/address bytes.*immutable/i); + }); + }); + + it.each([ + [ + "an exact scalar setter", + (scratch: KernelScratchLease) => + scratch.dataView(0, 32).setBigUint64(8, 0n, true), + ], + [ + "a partially overlapping scalar setter", + (scratch: KernelScratchLease) => + scratch.dataView(0, 32).setUint32(6, 0, true), + ], + [ + "an exact copyFrom", + (scratch: KernelScratchLease) => + scratch.copyFrom(new Uint8Array(8), 8), + ], + [ + "a partially overlapping copyFrom", + (scratch: KernelScratchLease) => + scratch.copyFrom(new Uint8Array(4), 14), + ], + [ + "an exact fill", + (scratch: KernelScratchLease) => scratch.fill(0, 8, 8), + ], + [ + "a partially overlapping fill", + (scratch: KernelScratchLease) => scratch.fill(0, 7, 2), + ], + [ + "a repeated writeAddress", + (scratch: KernelScratchLease) => + scratch.writeAddress(8, 24, 1, "u64-le"), + ], + [ + "a partially overlapping writeAddress", + (scratch: KernelScratchLease) => + scratch.writeAddress(12, 24, 1, "u32-le"), + ], + ] as const)("rejects %s over encoded address bytes", (_label, mutate) => { + const region = allocateKernelScratchRegion( + memory(), + () => 4096, + 32, + 4, + "immutable address scratch", + ); + + expect(() => region.withLease((scratch) => { + scratch.writeAddress(8, 24, 1, "u64-le"); + mutate(scratch); + })).toThrow(/address bytes.*immutable/i); + }); + + it("keeps out-of-order address intervals sorted without blocking gaps", () => { + const region = allocateKernelScratchRegion( + memory(), + () => 4096, + 32, + 4, + "sorted address scratch", + ); + + region.withLease((scratch) => { + const view = scratch.dataView(0, 32); + scratch.writeAddress(16, 28, 1, "u32-le"); + scratch.writeAddress(0, 28, 1, "u32-le"); + view.setUint32(8, 0x1234_5678, true); + expect(view.getUint32(8, true)).toBe(0x1234_5678); + expect(() => view.getUint8(0)).toThrow(/write-only/i); + expect(() => view.getUint8(16)).toThrow(/write-only/i); + }); + }); + + it("rejects coercive scalar writes before they can reenter a lease", () => { + const kernelMemory = memory(); + const region = allocateKernelScratchRegion( + kernelMemory, + () => 4096, + 32, + 4, + "coercion-safe scratch", + ); + let reentered = false; + const coercive = { + valueOf() { + reentered = true; + return 1; + }, + }; + + region.withLease((scratch) => { + const view = scratch.dataView(0, 32); + expect(() => view.setUint32( + 0, + coercive as unknown as number, + true, + )).toThrow(/primitive number/i); + expect(() => view.setBigUint64( + 0, + coercive as unknown as bigint, + true, + )).toThrow(/primitive bigint/i); + expect(() => view.setUint8( + coercive as unknown as number, + 1, + )).toThrow(/non-negative safe integer/i); + expect(() => view.setUint8(0.5, 1)) + .toThrow(/non-negative safe integer/i); + expect(() => scratch.fill( + coercive as unknown as number, + 0, + 1, + )).toThrow(/primitive number/i); + expect(reentered).toBe(false); + expect(new Uint8Array(kernelMemory.buffer, 4096, 1)[0]).toBe(0); + }); + }); + + it("rejects a structural export table before calling the allocator", () => { + const allocator = vi.fn(() => 4096); + expect(() => allocateKernelScratchRegion( + memory(), + allocator, + 32, + 4, + "test scratch", + { + exports: { + kernel_send: vi.fn(() => 0), + }, + } as unknown as WebAssembly.Instance, + )).toThrow(/genuine WebAssembly\.Instance/i); + expect(allocator).not.toHaveBeenCalled(); + }); + + it("binds the allocator, exports, and memory to one genuine instance", () => { + const firstMemory = memory(); + const secondMemory = memory(); + const instance = importedKernelExportInstance( + secondMemory, + "kernel_send", + ["i32", "i32", "i32", "i32"], + () => 0, + ); + const allocator = instance.exports.kernel_alloc_scratch as + (size: number) => number; + + expect(() => allocateKernelScratchRegion( + firstMemory, + allocator, + 32, + 4, + "mismatched memory scratch", + instance, + )).toThrow(/does not own.*Memory/i); + + const wrapper = vi.fn((size: number) => allocator(size)); + expect(() => allocateKernelScratchRegion( + secondMemory, + wrapper, + 32, + 4, + "mismatched allocator scratch", + instance, + )).toThrow(/not the bound instance.*allocator/i); + expect(wrapper).not.toHaveBeenCalled(); + }); + + it("uses captured genuine Memory and buffer bounds after prototype replacement", () => { + const kernelMemory = memory(); + const region = allocateKernelScratchRegion( + kernelMemory, + () => 65_504, + 32, + 4, + "captured memory scratch", + ); + const memoryBufferDescriptor = Object.getOwnPropertyDescriptor( + WebAssembly.Memory.prototype, + "buffer", + )!; + const byteLengthDescriptor = Object.getOwnPropertyDescriptor( + ArrayBuffer.prototype, + "byteLength", + )!; + + try { + Object.defineProperty(WebAssembly.Memory.prototype, "buffer", { + configurable: true, + get: () => new ArrayBuffer(1_000_000), + }); + Object.defineProperty(ArrayBuffer.prototype, "byteLength", { + configurable: true, + get: () => 1_000_000, + }); + + expect(() => checkedMemoryRange( + kernelMemory, + 65_535, + 2, + 4, + "captured memory range", + )).toThrow(/outside.*range/i); + region.withLease((scratch) => scratch.fill(0x6c, 0, 32)); + } finally { + Object.defineProperty( + WebAssembly.Memory.prototype, + "buffer", + memoryBufferDescriptor, + ); + Object.defineProperty( + ArrayBuffer.prototype, + "byteLength", + byteLengthDescriptor, + ); + } + + expect(new Uint8Array(kernelMemory.buffer, 65_504, 32)) + .toEqual(new Uint8Array(32).fill(0x6c)); + }); + + it("rejects structural memory objects even when their reported range fits", () => { + expect(() => checkedMemoryRange( + { buffer: new ArrayBuffer(65_536) } as WebAssembly.Memory, + 4096, + 32, + 4, + "structural memory", + )).toThrow(/genuine WebAssembly\.Memory/i); + }); + + it.each([4, 8] as const)( + "substitutes an opaque checked pointer only inside a genuine wasm%d export", + (pointerWidth) => { + const calls: Array> = []; + const kernelMemory = memory(); + const instance = importedKernelExportInstance( + kernelMemory, + "kernel_send", + pointerWidth === 4 + ? ["i32", "i32", "i32", "i32"] + : ["i32", "i64", "i32", "i32"], + (...args) => { + calls.push(args); + return 7; + }, + ); + const region = allocateKernelScratchRegion( + kernelMemory, + instance.exports.kernel_alloc_scratch as + (size: number) => number | bigint, + 32, + pointerWidth, + "test scratch", + instance, + ); + let escapedLease: KernelScratchLease | undefined; + let escapedPointer: ReturnType + | undefined; + + const result = region.withLease((scratch) => { + escapedLease = scratch; + escapedPointer = scratch.exportPointer(8, 16); + return scratch.invokeKernelExport("kernel_send", [ + 3, + escapedPointer, + 16, + 0, + ]); + }); + + expect(result).toBe(7); + expect(calls).toEqual([[ + 3, + pointerWidth === 4 ? 4104 : 4104n, + 16, + 0, + ]]); + expect(() => escapedLease!.invokeKernelExport("kernel_send", [ + 3, + escapedPointer!, + 16, + 0, + ])).toThrow(/no longer active/i); + }, + ); + + it("checks opaque pointer capacity and current-memory boundaries", () => { + const kernelMemory = memory(); + const instance = importedKernelExportInstance( + kernelMemory, + "kernel_uname", + ["i32", "i32"], + () => 0, + () => 65_504, + ); + const region = allocateKernelScratchRegion( + kernelMemory, + instance.exports.kernel_alloc_scratch as (size: number) => number, + 32, + 4, + "end-of-memory scratch", + instance, + ); + + expect(() => region.withLease((scratch) => { + scratch.invokeKernelExport("kernel_uname", [ + scratch.exportPointer(0, 32), + 32, + ]); + })).not.toThrow(); + expect(() => region.withLease((scratch) => { + scratch.exportPointer(0, 33); + })).toThrow(KernelScratchError); + expect(() => region.withLease((scratch) => { + scratch.exportPointer(1, 32); + })).toThrow(KernelScratchError); + }); + + it("rejects forged, cross-lease, reused, misplaced, and missing export pointers", () => { + const kernelMemory = memory(); + let nextAllocation = 4096; + const instance = importedKernelExportInstance( + kernelMemory, + "kernel_send", + ["i32", "i32", "i32", "i32"], + () => 0, + () => { + const pointer = nextAllocation; + nextAllocation += 4096; + return pointer; + }, + ); + const first = allocateKernelScratchRegion( + kernelMemory, + instance.exports.kernel_alloc_scratch as (size: number) => number, + 32, + 4, + "first scratch", + instance, + ); + const second = allocateKernelScratchRegion( + kernelMemory, + instance.exports.kernel_alloc_scratch as (size: number) => number, + 32, + 4, + "second scratch", + instance, + ); + let retained: ReturnType | undefined; + + first.withLease((firstLease) => { + retained = firstLease.exportPointer(0, 1); + expect(() => second.withLease((secondLease) => { + secondLease.invokeKernelExport("kernel_send", [ + 1, + retained!, + 1, + 0, + ]); + })).toThrow(/another lease/i); + }); + expect(() => first.withLease((scratch) => { + scratch.invokeKernelExport("kernel_send", [ + 1, + retained!, + 1, + 0, + ]); + })).toThrow(/another lease/i); + expect(() => first.withLease((scratch) => { + scratch.invokeKernelExport("kernel_send", [ + 1, + {} as ReturnType, + 1, + 0, + ]); + })).toThrow(/owned range token/i); + expect(() => first.withLease((scratch) => { + scratch.invokeKernelExport("kernel_send", [ + scratch.exportPointer(0, 1), + 0, + 1, + 0, + ]); + })).toThrow(/primitive scalar/i); + expect(() => first.withLease((scratch) => { + scratch.invokeKernelExport( + "kernel_recv", + [1, scratch.exportPointer(0, 1), 1, 0], + ); + })).toThrow(/unavailable/i); + expect(() => first.withLease((scratch) => { + scratch.invokeKernelExport( + "kernel_not_reviewed" as "kernel_send", + [1, scratch.exportPointer(0, 1), 1, 0], + ); + })).toThrow(/not approved/i); + }); + + it("couples every export pointer to an exact non-negative byte capacity", () => { + const calls = vi.fn(() => 0); + const kernelMemory = memory(); + const instance = importedKernelExportInstance( + kernelMemory, + "kernel_send", + ["i32", "i32", "i32", "i32"], + calls, + ); + const region = allocateKernelScratchRegion( + kernelMemory, + instance.exports.kernel_alloc_scratch as (size: number) => number, + 16, + 4, + "capacity-coupled scratch", + instance, + ); + + region.withLease((scratch) => { + const eightBytes = scratch.exportPointer(0, 8); + expect(scratch.invokeKernelExport( + "kernel_send", + [1, eightBytes, 8, 0], + )).toBe(0); + + for (const capacity of [ + 7, + 9, + -1, + 1.5, + Number.MAX_SAFE_INTEGER + 1, + BigInt(Number.MAX_SAFE_INTEGER) + 1n, + ]) { + expect(() => scratch.invokeKernelExport( + "kernel_send", + [1, eightBytes, capacity, 0], + )).toThrow(KernelScratchError); + } + expect(() => scratch.invokeKernelExport( + "kernel_send", + [1, scratch.exportPointer(0, 1), 8, 0], + )).toThrow(/declares 8 bytes but borrows 1/i); + expect(() => scratch.invokeKernelExport( + "kernel_send", + [1, scratch.exportPointer(0, 0), -1, 0], + )).toThrow(/non-negative safe integer/i); + expect(() => scratch.invokeKernelExport( + "kernel_send", + [1, eightBytes, 8], + )).toThrow(/expects 4 arguments, received 3/i); + expect(() => scratch.invokeKernelExport( + "kernel_send", + [1, eightBytes, 8, 0, 0], + )).toThrow(/expects 4 arguments, received 5/i); + }); + + expect(calls).toHaveBeenCalledTimes(1); + }); + + it("rejects misaligned and overlapping mutable export borrows", () => { + const kernelMemory = memory(); + const socketpair = importedKernelExportInstance( + kernelMemory, + "kernel_socketpair", + ["i32", "i32", "i32", "i32", "i32"], + () => 0, + ); + const socketRegion = allocateKernelScratchRegion( + kernelMemory, + socketpair.exports.kernel_alloc_scratch as (size: number) => number, + 32, + 4, + "aligned socketpair scratch", + socketpair, + ); + expect(() => socketRegion.withLease((scratch) => { + scratch.invokeKernelExport("kernel_socketpair", [ + 1, + 1, + 0, + scratch.exportPointer(1, 8), + 8, + ]); + })).toThrow(/not 4-byte aligned/i); + + const select = importedKernelExportInstance( + kernelMemory, + "kernel_select", + ["i32", "i32", "i32", "i32", "i32", "i32", "i32", "i32"], + () => 0, + ); + const selectRegion = allocateKernelScratchRegion( + kernelMemory, + select.exports.kernel_alloc_scratch as (size: number) => number, + 32, + 4, + "non-overlapping select scratch", + select, + ); + expect(() => selectRegion.withLease((scratch) => { + scratch.invokeKernelExport("kernel_select", [ + 4, + scratch.exportPointer(0, 8), + 8, + scratch.exportPointer(4, 8), + 8, + 0, + 0, + 0, + ]); + })).toThrow(/overlapping borrowed pointer ranges/i); + expect(() => selectRegion.withLease((scratch) => { + scratch.invokeKernelExport("kernel_select", [ + 4, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + ]); + })).toThrow(/declares 1 bytes but borrows 0/i); + }); + + it("accepts only exact-width nulls for select's optional pointers", () => { + for (const pointerWidth of [4, 8] as const) { + const calls: Array> = []; + const kernelMemory = memory(); + const instance = importedKernelExportInstance( + kernelMemory, + "kernel_select", + pointerWidth === 4 + ? ["i32", "i32", "i32", "i32", "i32", "i32", "i32", "i32"] + : ["i32", "i64", "i32", "i64", "i32", "i64", "i32", "i32"], + (...args) => { + calls.push(args); + return 0; + }, + ); + const region = allocateKernelScratchRegion( + kernelMemory, + instance.exports.kernel_alloc_scratch as + (size: number) => number | bigint, + 32, + pointerWidth, + "select scratch", + instance, + ); + const nullPointer = pointerWidth === 4 ? 0 : 0n; + + region.withLease((scratch) => { + scratch.invokeKernelExport("kernel_select", [ + 4, + scratch.exportPointer(0, 8), + 8, + nullPointer, + 0, + scratch.exportPointer(8, 8), + 8, + 0, + ]); + }); + expect(calls[0]).toEqual([ + 4, + pointerWidth === 4 ? 4096 : 4096n, + 8, + nullPointer, + 0, + pointerWidth === 4 ? 4104 : 4104n, + 8, + 0, + ]); + expect(() => region.withLease((scratch) => { + scratch.invokeKernelExport("kernel_select", [ + 4, + pointerWidth === 4 ? 0n : 0, + 0, + nullPointer, + 0, + nullPointer, + 0, + 0, + ]); + })).toThrow(/exact null pointer/i); + } + }); + + it("seals escaped lease wrappers during wasm-to-host reentry and unseals after traps", () => { + let escaped: KernelScratchLease | undefined; + let shouldThrow = false; + let reentryError: unknown; + let sealMutationResult: boolean | undefined; + const kernelMemory = memory(); + const instance = importedKernelExportInstance( + kernelMemory, + "kernel_send", + ["i32", "i32", "i32", "i32"], + () => { + sealMutationResult = Reflect.set( + escaped!, + "invokingKernelExport", + false, + ); + try { + escaped!.copyOut(0, 1); + } catch (error) { + reentryError = error; + } + if (shouldThrow) throw new Error("synthetic wasm import trap"); + return 1; + }, + ); + const region = allocateKernelScratchRegion( + kernelMemory, + instance.exports.kernel_alloc_scratch as (size: number) => number, + 32, + 4, + "reentrant scratch", + instance, + ); + + region.withLease((scratch) => { + escaped = scratch; + const pointer = scratch.exportPointer(0, 1); + expect(scratch.invokeKernelExport("kernel_send", [1, pointer, 1, 0])) + .toBe(1); + expect(sealMutationResult).toBe(false); + expect(reentryError).toBeInstanceOf(KernelScratchError); + expect(String(reentryError)).toMatch(/sealed during.*kernel export/i); + + shouldThrow = true; + expect(() => scratch.invokeKernelExport( + "kernel_send", + [1, pointer, 1, 0], + )).toThrow("synthetic wasm import trap"); + shouldThrow = false; + expect(scratch.copyOut(0, 1)).toEqual(new Uint8Array(1)); + expect(scratch.invokeKernelExport("kernel_send", [1, pointer, 1, 0])) + .toBe(1); + }); + }); + + it("seals before reading a caller-owned export argument list", () => { + let calls = 0; + const kernelMemory = memory(); + const instance = importedKernelExportInstance( + kernelMemory, + "kernel_send", + ["i32", "i32", "i32", "i32"], + () => { + calls++; + return 0; + }, + ); + const region = allocateKernelScratchRegion( + kernelMemory, + instance.exports.kernel_alloc_scratch as (size: number) => number, + 32, + 4, + "proxy argument scratch", + instance, + ); + + region.withLease((scratch) => { + const pointer = scratch.exportPointer(0, 1); + let reentryError: unknown; + const args = new Proxy( + [1, pointer, 1, 0] as const, + { + get(target, property, receiver) { + if (property === "length") { + try { + scratch.invokeKernelExport( + "kernel_send", + [1, pointer, 1, 0], + ); + } catch (error) { + reentryError = error; + } + } + return Reflect.get(target, property, receiver); + }, + }, + ); + + expect(scratch.invokeKernelExport("kernel_send", args)).toBe(0); + expect(reentryError).toBeInstanceOf(KernelScratchError); + expect(String(reentryError)).toMatch(/sealed during.*kernel export/i); + expect(calls).toBe(1); + }); + }); + + it("does not use mutable array push or iteration while materializing pointers", () => { + const calls: Array> = []; + const kernelMemory = memory(); + const instance = importedKernelExportInstance( + kernelMemory, + "kernel_send", + ["i32", "i32", "i32", "i32"], + (...args) => { + calls[calls.length] = args; + return 0; + }, + ); + const region = allocateKernelScratchRegion( + kernelMemory, + instance.exports.kernel_alloc_scratch as (size: number) => number, + 32, + 4, + "array intrinsic scratch", + instance, + ); + const originalPush = Array.prototype.push; + const originalSome = Array.prototype.some; + const originalIterator = Array.prototype[Symbol.iterator]; + let observedResult = -1; + try { + Array.prototype.push = () => { + throw new Error("live Array#push must not run"); + }; + Array.prototype.some = () => { + throw new Error("live Array#some must not run"); + }; + Array.prototype[Symbol.iterator] = () => { + throw new Error("live array iterator must not run"); + }; + region.withLease((scratch) => { + scratch.writeAddress(0, 8, 1, "u64-le"); + observedResult = scratch.invokeKernelExport("kernel_send", [ + 1, + scratch.exportPointer(8, 1), + 1, + 0, + ]); + }); + } finally { + Array.prototype.push = originalPush; + Array.prototype.some = originalSome; + Array.prototype[Symbol.iterator] = originalIterator; + } + expect(observedResult).toBe(0); + expect(calls).toEqual([[1, 4104, 1, 0]]); + }); + + it("uses captured numeric intrinsics after callback-visible globals are replaced", () => { + const kernelMemory = memory(); + const instance = importedKernelExportInstance( + kernelMemory, + "kernel_send", + ["i32", "i64", "i32", "i32"], + () => 0, + ); + const region = allocateKernelScratchRegion( + kernelMemory, + instance.exports.kernel_alloc_scratch as (size: number) => bigint, + 32, + 8, + "intrinsic scratch", + instance, + ); + const originalBigInt = globalThis.BigInt; + const originalIsInteger = Number.isInteger; + const originalIsSafeInteger = Number.isSafeInteger; + const retained: unknown[] = []; + try { + globalThis.BigInt = ((value: unknown) => { + retained.push(value); + return originalBigInt(value as never); + }) as BigIntConstructor; + Number.isInteger = () => true; + Number.isSafeInteger = () => true; + + region.withLease((scratch) => { + scratch.writeAddress(0, 8, 1, "u64-le"); + scratch.invokeKernelExport("kernel_send", [ + 1, + scratch.exportPointer(8, 1), + 1, + 0, + ]); + expect(() => scratch.copyFrom(new Uint8Array(1), Number.NaN)) + .toThrow(/safe integer/i); + }); + } finally { + globalThis.BigInt = originalBigInt; + Number.isInteger = originalIsInteger; + Number.isSafeInteger = originalIsSafeInteger; + } + expect(retained).toEqual([]); + }); + + it("uses a detached copy without invoking a caller-owned set override", () => { + const kernelMemory = memory(); + const region = allocateKernelScratchRegion( + kernelMemory, + () => 4096, + 32, + 4, + "test scratch", + ); + new Uint8Array(kernelMemory.buffer, 4096, 32).fill(0x5a); + let overrideCalled = false; + class HostileDestination extends Uint8Array { + override set(): void { + overrideCalled = true; + throw new Error("caller override must not run during a scratch lease"); + } + } + const destination = new HostileDestination(32); + + region.withLease((scratch) => scratch.copyTo(destination)); + + expect(Uint8Array.from(destination)) + .toEqual(new Uint8Array(32).fill(0x5a)); + expect(overrideCalled).toBe(false); + }); + + it("keeps transfers detached after live byte-access prototypes are replaced", () => { + const kernelMemory = memory(); + const region = allocateKernelScratchRegion( + kernelMemory, + () => 4096, + 32, + 4, + "test scratch", + ); + const originalSlice = Uint8Array.prototype.slice; + const originalSet = Uint8Array.prototype.set; + const originalFill = Uint8Array.prototype.fill; + const originalGetUint32 = DataView.prototype.getUint32; + const originalSetUint32 = DataView.prototype.setUint32; + let output: Uint8Array | undefined; + const destination = new Uint8Array(4); + let scalar = 0; + try { + Uint8Array.prototype.slice = function(): Uint8Array { + return this.subarray(); + }; + Uint8Array.prototype.set = function(): void { + throw new Error("live set must not run"); + }; + Uint8Array.prototype.fill = function(): Uint8Array { + throw new Error("live fill must not run"); + }; + DataView.prototype.getUint32 = function(): number { + throw new Error("live getUint32 must not run"); + }; + DataView.prototype.setUint32 = function(): void { + throw new Error("live setUint32 must not run"); + }; + + region.withLease((scratch) => { + scratch.copyFrom(Uint8Array.of(1, 2, 3, 4)); + scratch.copyTo(destination, 0, 0, 4); + const view = scratch.dataView(4, 4); + view.setUint32(0, 0x1234_5678, true); + scalar = view.getUint32(0, true); + scratch.fill(0x5a, 8, 1); + output = scratch.copyOut(0, 9); + }); + region.withLease((scratch) => scratch.fill(0xa5, 0, 9)); + } finally { + Uint8Array.prototype.slice = originalSlice; + Uint8Array.prototype.set = originalSet; + Uint8Array.prototype.fill = originalFill; + DataView.prototype.getUint32 = originalGetUint32; + DataView.prototype.setUint32 = originalSetUint32; + } + + expect(Array.from(destination)).toEqual([1, 2, 3, 4]); + expect(scalar).toBe(0x1234_5678); + expect(Array.from(output!)).toEqual([ + 1, 2, 3, 4, 0x78, 0x56, 0x34, 0x12, 0x5a, + ]); + expect(output!.buffer).not.toBe(kernelMemory.buffer); + }); + + it("ignores a hostile source subarray override after proving length", () => { + const kernelMemory = memory(); + const region = allocateKernelScratchRegion( + kernelMemory, + () => 4096, + 32, + 4, + "test scratch", + ); + const sentinel = new Uint8Array(kernelMemory.buffer, 4096 + 32, 32); + sentinel.fill(0xa5); + class HostileSource extends Uint8Array { + override subarray(): Uint8Array { + return new Uint8Array(64).fill(0xff); + } + } + const source = new HostileSource(32); + source.fill(0x6c); + + region.withLease((scratch) => scratch.copyFrom(source)); + + expect(new Uint8Array(kernelMemory.buffer, 4096, 32)) + .toEqual(new Uint8Array(32).fill(0x6c)); + expect(sentinel).toEqual(new Uint8Array(32).fill(0xa5)); + }); + + it("rechecks and refreshes one guarded view after shared memory growth", () => { + const kernelMemory = new WebAssembly.Memory({ + initial: 1, + maximum: 2, + shared: true, + }); + const region = allocateKernelScratchRegion( + kernelMemory, + () => 4096, + 32, + 4, + "test scratch", + ); + const counter = installDataViewConstructorCounter(); + + try { + region.withLease((scratch) => { + const view = scratch.dataView(0, 8); + view.setUint32(0, 0x1234_5678, true); + expect(counter.count()).toBe(0); + + const previousBuffer = kernelMemory.buffer; + kernelMemory.grow(1); + expect(kernelMemory.buffer).not.toBe(previousBuffer); + + view.setUint32(4, 0x90ab_cdef, true); + expect(view.getUint32(0, true)).toBe(0x1234_5678); + expect(counter.count()).toBe(0); + }); + } finally { + counter.restore(); + } + + const output = new DataView(kernelMemory.buffer, 4096, 8); + expect(output.getUint32(4, true)).toBe(0x90ab_cdef); + }); + + it.each([4, 8] as const)( + "validates a reserved pointer plus capacity for wasm%d", + (pointerWidth) => { + const kernelMemory = memory(); + const region = reserveKernelScratchRegion( + kernelMemory, + vi.fn(() => ({ + pointer: pointerWidth === 8 ? 4096n : 4096, + capacity: pointerWidth === 8 ? 48n : 48, + })), + 32, + pointerWidth, + "reserved scratch", + ); + + region.withLease((scratch) => { + scratch.copyFrom(new Uint8Array(48).fill(0x6b)); + }); + expect(new Uint8Array(kernelMemory.buffer, 4096, 48)) + .toEqual(new Uint8Array(48).fill(0x6b)); + expect(() => region.withLease((scratch) => { + scratch.copyFrom(new Uint8Array(1)); + })).toThrow(/single-use/i); + + const overflowRegion = reserveKernelScratchRegion( + kernelMemory, + () => ({ + pointer: pointerWidth === 8 ? 8192n : 8192, + capacity: pointerWidth === 8 ? 48n : 48, + }), + 32, + pointerWidth, + "reserved overflow scratch", + ); + expect(() => overflowRegion.withLease((scratch) => { + scratch.copyFrom(new Uint8Array(49)); + })).toThrow(KernelScratchError); + }, + ); + + it("normalizes the signed high bit of a wasm32 reservation result", () => { + expect(checkedKernelExportPointer( + -0x8000_0000, + 4, + "high wasm32 reservation", + )).toBe(0x8000_0000); + }); + + it("cannot reuse or revive a reservation-derived region", () => { + const kernelMemory = memory(); + const region = reserveKernelScratchRegion( + kernelMemory, + () => ({ pointer: 4096, capacity: 32 }), + 32, + 4, + "one-shot reservation", + ); + + region.withLease((scratch) => { + scratch.copyFrom(new Uint8Array(32).fill(0x4d)); + }); + expect(() => region.withLease(() => undefined)) + .toThrow(/single-use/i); + region.revoke(); + expect(() => region.withLease(() => undefined)) + .toThrow(/no longer valid/i); + + const cancelled = reserveKernelScratchRegion( + kernelMemory, + () => ({ pointer: 8192, capacity: 32 }), + 32, + 4, + "cancelled reservation", + ); + cancelled.revoke(); + expect(() => cancelled.withLease(() => undefined)) + .toThrow(/no longer valid/i); + }); + + it.each([ + ["zero minimum", 0, { pointer: 4096, capacity: 32 }], + ["negative minimum", -1, { pointer: 4096, capacity: 32 }], + ["fractional minimum", 1.5, { pointer: 4096, capacity: 32 }], + ["failed pointer", 32, { pointer: 0, capacity: 32 }], + ["short capacity", 32, { pointer: 4096, capacity: 31 }], + ["negative capacity", 32, { pointer: 4096, capacity: -1 }], + ["fractional capacity", 32, { pointer: 4096, capacity: 32.5 }], + [ + "unsafe capacity", + 32, + { pointer: 4096, capacity: Number.MAX_SAFE_INTEGER + 1 }, + ], + ["range beyond memory", 32, { pointer: 65_520, capacity: 32 }], + ])("rejects a %s reservation", (_name, minimum, reservation) => { + expect(() => reserveKernelScratchRegion( + memory(), + () => reservation, + minimum, + 4, + "reserved scratch", + )).toThrow(KernelScratchError); + }); +}); + +describe("checkedMemoryRange", () => { + it.each([4, 8] as const)( + "accepts the exact end of memory for wasm%d", + (pointerWidth) => { + const kernelMemory = memory(); + expect(checkedMemoryRange( + kernelMemory, + 65_504, + 32, + pointerWidth, + "Rust-owned output", + )).toEqual({ pointer: 65_504, length: 32, end: 65_536 }); + }, + ); + + it.each([ + ["null positive range", 0, 1], + ["negative pointer", -1, 1], + ["fractional pointer", 1.5, 1], + ["unsafe pointer", Number.MAX_SAFE_INTEGER + 1, 1], + ["negative length", 1, -1], + ["fractional length", 1, 1.5], + ["unsafe length", 1, Number.MAX_SAFE_INTEGER + 1], + ["end beyond memory", 65_535, 2], + ])("rejects %s", (_name, pointer, length) => { + expect(() => checkedMemoryRange( + memory(), + pointer, + length, + 8, + "Rust-owned output", + )).toThrow(KernelScratchError); + }); + + it("allows a null pointer only for an empty range", () => { + expect(checkedMemoryRange( + memory(), + 0, + 0, + 4, + "empty output", + )).toEqual({ pointer: 0, length: 0, end: 0 }); + }); +}); diff --git a/host/test/kernel-scratch-transfer-boundaries.test.ts b/host/test/kernel-scratch-transfer-boundaries.test.ts new file mode 100644 index 0000000000..8841d86cb2 --- /dev/null +++ b/host/test/kernel-scratch-transfer-boundaries.test.ts @@ -0,0 +1,5487 @@ +import { describe, expect, it, vi } from "vitest"; + +import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { allocateKernelScratchRegion } from "../src/kernel-scratch"; +import { WasmPosixKernel } from "../src/kernel"; +import { createKernelScratchTestInstance } from "./support/kernel-scratch-instance"; +import { + ABI_SYSCALLS, + CH_ARGS, + CH_ARG_SIZE, + CH_DATA, + CH_DATA_SIZE, + CH_ERRNO, + CH_RETURN, + CH_SYSCALL, + CH_TOTAL_SIZE, + PRCTL_NAME_BYTES, + PR_GET_NAME, + PR_SET_NAME, + IOCTL_REQUESTS, + KERNEL_CMSGHDR_WIRE_ALIGN, + KERNEL_CMSGHDR_WIRE_DATA_OFFSET, + KERNEL_CMSGHDR_WIRE_LEN_OFFSET, + KERNEL_CMSGHDR_WIRE_LEVEL_OFFSET, + KERNEL_CMSGHDR_WIRE_TYPE_OFFSET, + KERNEL_IOVEC_WIRE_BASE_OFFSET, + KERNEL_IOVEC_WIRE_LEN_OFFSET, + KERNEL_MESSAGE_WIRE_FLATTENED_IOVEC_COUNT, + KERNEL_MSGHDR_WIRE_CONTROL_OFFSET, + KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET, + KERNEL_MSGHDR_WIRE_FLAGS_OFFSET, + KERNEL_MSGHDR_WIRE_IOV_OFFSET, + KERNEL_MSGHDR_WIRE_IOVLEN_OFFSET, + KERNEL_MSGHDR_WIRE_NAME_OFFSET, + KERNEL_MSGHDR_WIRE_NAMELEN_OFFSET, + POSIX_IOV_MAX, + POSIX_PATH_MAX_BYTES, + PROCESS_CMSGHDR_WASM32_ALIGN, + PROCESS_CMSGHDR_WASM32_DATA_OFFSET, + PROCESS_CMSGHDR_WASM32_LEN_OFFSET, + PROCESS_CMSGHDR_WASM32_LEVEL_OFFSET, + PROCESS_CMSGHDR_WASM32_TYPE_OFFSET, + PROCESS_CMSGHDR_WASM64_ALIGN, + PROCESS_CMSGHDR_WASM64_DATA_OFFSET, + PROCESS_CMSGHDR_WASM64_LEN_OFFSET, + PROCESS_CMSGHDR_WASM64_LEVEL_OFFSET, + PROCESS_CMSGHDR_WASM64_TYPE_OFFSET, + PROCESS_MSGHDR_WASM32_CONTROL_OFFSET, + PROCESS_MSGHDR_WASM32_CONTROLLEN_OFFSET, + PROCESS_MSGHDR_WASM32_FLAGS_OFFSET, + PROCESS_MSGHDR_WASM32_IOV_OFFSET, + PROCESS_MSGHDR_WASM32_IOVLEN_OFFSET, + PROCESS_MSGHDR_WASM32_NAME_OFFSET, + PROCESS_MSGHDR_WASM32_NAMELEN_OFFSET, + PROCESS_MSGHDR_WASM64_CONTROL_OFFSET, + PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET, + PROCESS_MSGHDR_WASM64_FLAGS_OFFSET, + PROCESS_MSGHDR_WASM64_IOV_OFFSET, + PROCESS_MSGHDR_WASM64_IOVLEN_OFFSET, + PROCESS_MSGHDR_WASM64_NAME_OFFSET, + PROCESS_MSGHDR_WASM64_NAMELEN_OFFSET, + PROCESS_MSGHDR_WASM64_SIZE, + SCM_RIGHTS_FD_BYTES, + SOCKET_MSG_TRUNC, + SOCKET_SCM_RIGHTS, + SOCKET_SOL_SOCKET, + STRUCT_SIZE_KERNEL_IOVEC_WIRE, + STRUCT_SIZE_KERNEL_MSGHDR_WIRE, + STRUCT_SIZE_WASM_EPOLL_EVENT, + STRUCT_SIZE_WASM_SYSV_MESSAGE_HEADER, + SYSCALL_ARGS, + WASM_EPOLL_EVENT_DATA_OFFSET, + WASM_EPOLL_EVENT_EVENTS_OFFSET, + WASM_EPOLL_EVENT_PAD_OFFSET, +} from "../src/generated/abi"; + +const EFAULT = 14; +const EIO = 5; +const EINVAL = 22; +const EOVERFLOW = 75; +const EAGAIN = 11; +const IPC_NOWAIT = 0x800; +const IOV_MAX = POSIX_IOV_MAX; +const MSG_CTRUNC = 0x08; +const PR_SET_NO_NEW_PRIVS = 38; +const SCALAR_IOCTL_REQUESTS = Object.entries(IOCTL_REQUESTS) + .filter(([, contract]) => contract.argKind === "scalar-i32") + .map(([request]) => Number(request)); +const SIOCGIFNAME = 0x8910; +const SIOCGIFADDR = 0x8915; +const SIOCGIFHWADDR = 0x8927; +const SIOCGIFINDEX = 0x8933; +const NETWORK_IFREQ_HANDLERS = [ + { + request: SIOCGIFNAME, + handler: "handleIoctlIfname", + prepare(bytes: Uint8Array, pointer: number): void { + new DataView(bytes.buffer).setInt32(pointer + 16, 1, true); + }, + }, + { + request: SIOCGIFHWADDR, + handler: "handleIoctlIfhwaddr", + prepare(bytes: Uint8Array, pointer: number): void { + bytes.set(new TextEncoder().encode("lo\0"), pointer); + }, + }, + { + request: SIOCGIFADDR, + handler: "handleIoctlIfaddr", + prepare(bytes: Uint8Array, pointer: number): void { + bytes.set(new TextEncoder().encode("lo\0"), pointer); + }, + }, + { + request: SIOCGIFINDEX, + handler: "handleIoctlIfindex", + prepare(bytes: Uint8Array, pointer: number): void { + bytes.set(new TextEncoder().encode("lo\0"), pointer); + }, + }, +] as const; +const IOVEC_HANDLER_PATHS = [ + { + name: "writev", + handler: "handleWritev", + syscall: ABI_SYSCALLS.Writev, + message: false, + input: true, + }, + { + name: "readv", + handler: "handleReadv", + syscall: ABI_SYSCALLS.Readv, + message: false, + input: false, + }, + { + name: "sendmsg", + handler: "handleSendmsg", + syscall: ABI_SYSCALLS.Sendmsg, + message: true, + input: true, + }, + { + name: "recvmsg", + handler: "handleRecvmsg", + syscall: ABI_SYSCALLS.Recvmsg, + message: true, + input: false, + }, +] as const; + +interface TestChannel { + pid: number; + memory: WebAssembly.Memory; + channelOffset: number; + i32View: Int32Array; + consecutiveSyscalls: number; + handling: boolean; +} + +interface ScratchHarness { + worker: CentralizedKernelWorker & Record; + channel: TestChannel; + kernelBytes: Uint8Array; + processBytes: Uint8Array; + scratchOffset: number; + scratchEnd: number; + handleChannel: ReturnType; + completeChannel: ReturnType; + completeChannelRaw: ReturnType; +} + +function sharedMemory(pages: number): WebAssembly.Memory { + return new WebAssembly.Memory({ + initial: pages, + maximum: pages, + shared: true, + }); +} + +function hostileBytes(length: number, reportedLength: number): Uint8Array { + class HostileBytes extends Uint8Array {} + const bytes = new HostileBytes(length); + Object.defineProperties(bytes, { + buffer: { get: () => new ArrayBuffer(reportedLength) }, + byteOffset: { get: () => 0 }, + byteLength: { get: () => reportedLength }, + length: { get: () => reportedLength }, + subarray: { value: () => bytes }, + }); + return bytes; +} + +function makeScratchHarness(ptrWidth: 4 | 8 = 4): ScratchHarness { + const pid = 41; + const scratchOffset = 4096; + const scratchEnd = scratchOffset + CH_TOTAL_SIZE; + const kernelMemory = new WebAssembly.Memory({ initial: 4, maximum: 4 }); + const processMemory = sharedMemory(4); + const kernelBytes = new Uint8Array(kernelMemory.buffer); + const processBytes = new Uint8Array(processMemory.buffer); + let worker!: CentralizedKernelWorker & Record; + let kernelExports!: Record; + const scratchTestInstance = createKernelScratchTestInstance( + ptrWidth, + kernelMemory, + () => worker?.kernelInstance?.exports ?? kernelExports, + () => ptrWidth === 8 ? BigInt(scratchOffset) : scratchOffset, + ); + const scratchRegion = allocateKernelScratchRegion( + kernelMemory, + scratchTestInstance.exports.kernel_alloc_scratch as (size: number) => number, + CH_TOTAL_SIZE, + ptrWidth, + "test kernel syscall scratch", + scratchTestInstance, + ); + const channel: TestChannel = { + pid, + memory: processMemory, + channelOffset: 0, + i32View: new Int32Array(processMemory.buffer), + consecutiveSyscalls: 0, + handling: true, + }; + const completeChannelRaw = vi.fn(); + const completeChannel = vi.fn(); + const handleChannel = vi.fn(() => { + const view = new DataView(kernelMemory.buffer, scratchOffset); + const iovPtr = Number(view.getBigInt64(CH_ARGS + CH_ARG_SIZE, true)); + const iovLen = iovPtr === 0 + ? 0 + : new DataView(kernelMemory.buffer).getUint32(iovPtr + 4, true); + view.setBigInt64(CH_RETURN, BigInt(iovLen), true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }); + kernelExports = { + kernel_handle_channel: handleChannel, + kernel_prepare_write_operation: ( + _pid: number, + _tid: number, + _fd: number, + _offset: bigint, + len: number, + ) => BigInt(len), + }; + worker = Object.assign( + Object.create(CentralizedKernelWorker.prototype), + { + kernel: { toKernelPtr: (value: number | bigint) => value }, + kernelInstance: { + exports: kernelExports, + }, + scratchTestInstance, + kernelMemory, + scratchOffset, + scratchRegion, + cachedKernelMem: null, + cachedKernelBuffer: null, + currentHandlePid: 0, + getPtrWidth: () => ptrWidth, + guestTidForChannel: () => pid, + bindKernelTidForChannel: () => {}, + finishSignalTermination: () => false, + dequeueSignalForDelivery: () => 0, + handleBlockingRetry: vi.fn(), + deferChannelWhileStopped: () => false, + getReadinessDeadline: () => 0, + pendingSelectRetries: new Map(), + pendingPollRetries: new Map(), + epollInterests: new Map(), + isRegisteredChannel: () => true, + handleSharedMappingsAfterFileSyscall: () => {}, + synchronizeSharedMemoryForBoundary: () => {}, + completeChannel, + completeChannelRaw, + relistenChannel: vi.fn(), + }, + ) as CentralizedKernelWorker & Record; + + kernelBytes.fill(0xa5, scratchEnd, scratchEnd + 16_384); + return { + worker, + channel, + kernelBytes, + processBytes, + scratchOffset, + scratchEnd, + handleChannel, + completeChannel, + completeChannelRaw, + }; +} + +function prepareGenericSyscallHarness( + harness: ScratchHarness, + ptrWidth: 4 | 8, +): void { + Object.assign(harness.worker, { + config: {}, + syscallRing: new Map(), + syscallTraceEnabled: false, + syscallTraceRing: [], + syscallTraceCap: 64, + channelTids: new Map(), + processes: new Map([[harness.channel.pid, { + pid: harness.channel.pid, + memory: harness.channel.memory, + channels: [harness.channel], + ptrWidth, + }]]), + synchronizeSharedMemoryForBoundary: () => {}, + sharedMmapBackings: new Map(), + hostReaped: new Set(), + getProcessExitSignal: () => 0, + }); +} + +function writeChannelSyscall( + harness: ScratchHarness, + syscall: number, + args: bigint[], +): void { + const request = new DataView(harness.channel.memory.buffer); + request.setUint32(CH_SYSCALL, syscall, true); + for (let index = 0; index < 6; index++) { + request.setBigInt64( + CH_ARGS + index * CH_ARG_SIZE, + args[index] ?? 0n, + true, + ); + } +} + +function writeIfconf( + bytes: Uint8Array, + pointerWidth: 4 | 8, + pointer: number, + capacity: number, + outputPointer: number | bigint, +): void { + const view = new DataView(bytes.buffer); + view.setInt32(pointer, capacity, true); + if (pointerWidth === 8) { + view.setBigUint64(pointer + 8, BigInt(outputPointer), true); + } else { + view.setUint32(pointer + 4, Number(outputPointer), true); + } +} + +function invokeNetworkIoctlHandler( + harness: ScratchHarness, + handler: string, + pointer: number, +): void { + harness.worker[handler]( + harness.channel, + [7, 0, pointer, 0, 0, 0], + ); +} + +function writeNativeIovec( + processBytes: Uint8Array, + pointerWidth: 4 | 8, + iovPointer: number, + base: number, + length: number, +): void { + const view = new DataView(processBytes.buffer); + if (pointerWidth === 8) { + view.setBigUint64(iovPointer, BigInt(base), true); + view.setBigUint64(iovPointer + 8, BigInt(length), true); + } else { + view.setUint32(iovPointer, base, true); + view.setUint32(iovPointer + 4, length, true); + } +} + +function alignUp(value: number, alignment: number): number { + return Math.ceil(value / alignment) * alignment; +} + +function writeNativeMessage( + processBytes: Uint8Array, + pointerWidth: 4 | 8, + messagePointer: number, + fields: { + namePointer?: number | bigint; + nameLength?: number; + iovecPointer?: number | bigint; + iovecCount?: number | bigint; + controlPointer?: number | bigint; + controlLength?: number | bigint; + flags?: number; + }, +): void { + const view = new DataView(processBytes.buffer); + if (pointerWidth === 8) { + view.setBigUint64( + messagePointer + PROCESS_MSGHDR_WASM64_NAME_OFFSET, + BigInt(fields.namePointer ?? 0), + true, + ); + view.setUint32( + messagePointer + PROCESS_MSGHDR_WASM64_NAMELEN_OFFSET, + fields.nameLength ?? 0, + true, + ); + view.setBigUint64( + messagePointer + PROCESS_MSGHDR_WASM64_IOV_OFFSET, + BigInt(fields.iovecPointer ?? 0), + true, + ); + view.setUint32( + messagePointer + PROCESS_MSGHDR_WASM64_IOVLEN_OFFSET, + Number(fields.iovecCount ?? 0), + true, + ); + view.setBigUint64( + messagePointer + PROCESS_MSGHDR_WASM64_CONTROL_OFFSET, + BigInt(fields.controlPointer ?? 0), + true, + ); + view.setUint32( + messagePointer + PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET, + Number(fields.controlLength ?? 0), + true, + ); + view.setUint32( + messagePointer + PROCESS_MSGHDR_WASM64_FLAGS_OFFSET, + fields.flags ?? 0, + true, + ); + return; + } + view.setUint32( + messagePointer + PROCESS_MSGHDR_WASM32_NAME_OFFSET, + Number(fields.namePointer ?? 0), + true, + ); + view.setUint32( + messagePointer + PROCESS_MSGHDR_WASM32_NAMELEN_OFFSET, + fields.nameLength ?? 0, + true, + ); + view.setUint32( + messagePointer + PROCESS_MSGHDR_WASM32_IOV_OFFSET, + Number(fields.iovecPointer ?? 0), + true, + ); + view.setUint32( + messagePointer + PROCESS_MSGHDR_WASM32_IOVLEN_OFFSET, + Number(fields.iovecCount ?? 0), + true, + ); + view.setUint32( + messagePointer + PROCESS_MSGHDR_WASM32_CONTROL_OFFSET, + Number(fields.controlPointer ?? 0), + true, + ); + view.setUint32( + messagePointer + PROCESS_MSGHDR_WASM32_CONTROLLEN_OFFSET, + Number(fields.controlLength ?? 0), + true, + ); + view.setUint32( + messagePointer + PROCESS_MSGHDR_WASM32_FLAGS_OFFSET, + fields.flags ?? 0, + true, + ); +} + +function writeNativeRightsRecords( + processBytes: Uint8Array, + pointerWidth: 4 | 8, + controlPointer: number, + records: number[][], + paddingByte = 0x7b, +): number { + const layout = pointerWidth === 8 + ? { + alignment: PROCESS_CMSGHDR_WASM64_ALIGN, + lengthOffset: PROCESS_CMSGHDR_WASM64_LEN_OFFSET, + levelOffset: PROCESS_CMSGHDR_WASM64_LEVEL_OFFSET, + typeOffset: PROCESS_CMSGHDR_WASM64_TYPE_OFFSET, + dataOffset: PROCESS_CMSGHDR_WASM64_DATA_OFFSET, + } + : { + alignment: PROCESS_CMSGHDR_WASM32_ALIGN, + lengthOffset: PROCESS_CMSGHDR_WASM32_LEN_OFFSET, + levelOffset: PROCESS_CMSGHDR_WASM32_LEVEL_OFFSET, + typeOffset: PROCESS_CMSGHDR_WASM32_TYPE_OFFSET, + dataOffset: PROCESS_CMSGHDR_WASM32_DATA_OFFSET, + }; + const view = new DataView(processBytes.buffer); + let offset = 0; + for (const descriptors of records) { + const length = layout.dataOffset + + descriptors.length * SCM_RIGHTS_FD_BYTES; + const space = alignUp(length, layout.alignment); + processBytes.fill( + paddingByte, + controlPointer + offset, + controlPointer + offset + space, + ); + view.setUint32( + controlPointer + offset + layout.lengthOffset, + length, + true, + ); + view.setUint32( + controlPointer + offset + layout.levelOffset, + SOCKET_SOL_SOCKET, + true, + ); + view.setUint32( + controlPointer + offset + layout.typeOffset, + SOCKET_SCM_RIGHTS, + true, + ); + descriptors.forEach((descriptor, index) => { + view.setInt32( + controlPointer + offset + layout.dataOffset + + index * SCM_RIGHTS_FD_BYTES, + descriptor, + true, + ); + }); + offset += space; + } + return offset; +} + +function canonicalRightsBytes(records: number[][]): Uint8Array { + const lengths = records.map((descriptors) => + KERNEL_CMSGHDR_WIRE_DATA_OFFSET + + descriptors.length * SCM_RIGHTS_FD_BYTES + ); + const output = new Uint8Array( + lengths.reduce( + (total, length) => + total + alignUp(length, KERNEL_CMSGHDR_WIRE_ALIGN), + 0, + ), + ); + const view = new DataView(output.buffer); + let offset = 0; + records.forEach((descriptors, recordIndex) => { + const length = lengths[recordIndex]; + view.setUint32( + offset + KERNEL_CMSGHDR_WIRE_LEN_OFFSET, + length, + true, + ); + view.setUint32( + offset + KERNEL_CMSGHDR_WIRE_LEVEL_OFFSET, + SOCKET_SOL_SOCKET, + true, + ); + view.setUint32( + offset + KERNEL_CMSGHDR_WIRE_TYPE_OFFSET, + SOCKET_SCM_RIGHTS, + true, + ); + descriptors.forEach((descriptor, descriptorIndex) => { + view.setInt32( + offset + KERNEL_CMSGHDR_WIRE_DATA_OFFSET + + descriptorIndex * SCM_RIGHTS_FD_BYTES, + descriptor, + true, + ); + }); + offset += alignUp(length, KERNEL_CMSGHDR_WIRE_ALIGN); + }); + return output; +} + +function invokeIovecHandler( + harness: ScratchHarness, + pointerWidth: 4 | 8, + path: (typeof IOVEC_HANDLER_PATHS)[number], + iovPointer: number, +): void { + if (path.message) { + const messagePointer = 128; + writeNativeMessage( + harness.processBytes, + pointerWidth, + messagePointer, + { iovecPointer: iovPointer, iovecCount: 1 }, + ); + harness.worker[path.handler]( + harness.channel, + [7, messagePointer, 0, 0, 0, 0], + ); + return; + } + harness.worker[path.handler]( + harness.channel, + path.syscall, + [7, iovPointer, 1, 0, 0, 0], + ); +} + +function respondToSingleKernelIovec( + harness: ScratchHarness, + path: (typeof IOVEC_HANDLER_PATHS)[number], + payload: Uint8Array, +): void { + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + const argumentPointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + const kernelView = new DataView(harness.kernelBytes.buffer); + const kernelIovecPointer = path.message + ? kernelView.getUint32(argumentPointer + 8, true) + : argumentPointer; + const kernelDataPointer = kernelView.getUint32( + kernelIovecPointer, + true, + ); + expect( + kernelView.getUint32(kernelIovecPointer + 4, true), + path.name, + ).toBe(payload.byteLength); + if (path.input) { + expect( + harness.kernelBytes.slice( + kernelDataPointer, + kernelDataPointer + payload.byteLength, + ), + path.name, + ).toEqual(payload); + } else { + harness.kernelBytes.set(payload, kernelDataPointer); + } + channelView.setBigInt64(CH_RETURN, BigInt(payload.byteLength), true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); +} + +function writeWasm32Iovecs( + processBytes: Uint8Array, + iovPtr: number, + entries: Array<{ base: number; len: number }>, +): void { + const view = new DataView(processBytes.buffer); + entries.forEach(({ base, len }, index) => { + view.setUint32(iovPtr + index * 8, base, true); + view.setUint32(iovPtr + index * 8 + 4, len, true); + }); +} + +function expectScratchTailUntouched(harness: ScratchHarness): void { + const tail = harness.kernelBytes.subarray( + harness.scratchEnd, + harness.scratchEnd + 16_384, + ); + expect(tail.every((byte) => byte === 0xa5)).toBe(true); +} + +describe("kernel scratch transfer capacity regressions", () => { + it("binds allocator authority to the same shared kernel memory", () => { + const kernelMemory = sharedMemory(2); + const instance = createKernelScratchTestInstance( + 4, + kernelMemory, + () => ({}), + () => 4096, + ); + const region = allocateKernelScratchRegion( + kernelMemory, + instance.exports.kernel_alloc_scratch as (size: number) => number, + 32, + 4, + "shared test kernel scratch", + instance, + ); + + region.withLease((scratch) => { + scratch.copyFrom(new Uint8Array([1, 2, 3, 4])); + }); + + expect(instance.exports.memory).toBe(kernelMemory); + expect(new Uint8Array(kernelMemory.buffer, 4096, 4)) + .toEqual(new Uint8Array([1, 2, 3, 4])); + }); + + it("fails closed when the mqueue notification drain returns an errno", () => { + const harness = makeScratchHarness(); + const wakePendingSignalWaits = vi.fn(); + const sendSignalToProcess = vi.fn(); + Object.assign(harness.worker, { + wakePendingSignalWaits, + sendSignalToProcess, + }); + Object.assign(harness.worker.kernelInstance.exports, { + kernel_mq_drain_notification: vi.fn(() => -EINVAL), + }); + + // Seed a plausible stale record. A negative kernel return must not make + // these reusable bytes observable as a fresh notification. + const stale = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + 8, + ); + stale.setUint32(0, 123, true); + stale.setUint32(4, 10, true); + + expect(() => harness.worker.drainMqueueNotification()).toThrow( + /kernel mqueue notification drain returned invalid result -22/, + ); + expect(wakePendingSignalWaits).not.toHaveBeenCalled(); + expect(sendSignalToProcess).not.toHaveBeenCalled(); + }); + + it("captures fstat handles and releases capture after handleChannel throws", () => { + const harness = makeScratchHarness(); + const kernelMemory = harness.worker.kernelMemory as WebAssembly.Memory; + const fstat = vi.fn(() => ({ + dev: 11n, + ino: 22n, + mode: 0o100644, + nlink: 1, + uid: 2, + gid: 3, + size: 4096, + atimeMs: 1000, + mtimeMs: 2000, + ctimeMs: 3000, + })); + const kernel = Object.assign( + Object.create(WasmPosixKernel.prototype), + { + memory: kernelMemory, + kernelPtrWidth: 4, + io: { fstat }, + fstatHandleCapture: null, + }, + ) as WasmPosixKernel & Record; + harness.worker.kernel = kernel; + + let hostHandle = 501; + harness.handleChannel.mockImplementation(( + offset: number | bigint, + ) => { + const channelView = new DataView( + kernelMemory.buffer, + Number(offset), + CH_TOTAL_SIZE, + ); + expect(channelView.getUint32(CH_SYSCALL, true)) + .toBe(ABI_SYSCALLS.Fstat); + const statPointer = channelView.getBigUint64( + CH_ARGS + CH_ARG_SIZE, + true, + ); + expect(kernel.hostFstat( + BigInt(hostHandle), + kernel.toKernelPtr(statPointer), + )).toBe(0); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + const capture = () => + harness.worker.getFdStatForSharedMapping(harness.channel, 7); + + expect(capture()).toEqual({ + kind: "ok", + value: { + dev: 11n, + ino: 22n, + mode: 0o100644, + size: 4096, + hostHandle: 501, + }, + }); + expect(harness.worker.currentHandlePid).toBe(0); + + harness.handleChannel.mockImplementationOnce(() => { + throw new Error("synthetic handleChannel failure"); + }); + expect(capture()).toEqual({ kind: "error", errno: EIO }); + expect(harness.worker.currentHandlePid).toBe(0); + + hostHandle = 502; + expect(capture()).toEqual({ + kind: "ok", + value: { + dev: 11n, + ino: 22n, + mode: 0o100644, + size: 4096, + hostHandle: 502, + }, + }); + expect(fstat).toHaveBeenNthCalledWith(1, 501); + expect(fstat).toHaveBeenNthCalledWith(2, 502); + }); + + it("chunks PTY input at the exact scratch capacity and capacity + 1", () => { + for (const length of [CH_TOTAL_SIZE, CH_TOTAL_SIZE + 1]) { + const harness = makeScratchHarness(); + const ptyWrite = vi.fn(( + _ptyIdx: number, + _pointer: number, + chunkLength: number, + ) => chunkLength); + Object.assign(harness.worker, { + kernelInstance: { + exports: { kernel_pty_master_write: ptyWrite }, + }, + drainPtyOutput: () => {}, + scheduleWakeBlockedRetries: () => {}, + }); + + harness.worker.ptyMasterWrite(3, new Uint8Array(length).fill(0x31)); + + expect(ptyWrite.mock.calls.map((call) => call[2])).toEqual( + length === CH_TOTAL_SIZE ? [CH_TOTAL_SIZE] : [CH_TOTAL_SIZE, 1], + ); + expectScratchTailUntouched(harness); + } + }); + + it("does not lend stale PTY scratch bytes when input spoofs its length", () => { + const harness = makeScratchHarness(); + const input = hostileBytes(1, 4); + Uint8Array.prototype.set.call(input, [0x31]); + harness.kernelBytes.fill( + 0xa5, + harness.scratchOffset, + harness.scratchOffset + 8, + ); + const ptyWrite = vi.fn(( + _ptyIdx: number, + pointer: number, + length: number, + ) => { + expect(pointer).toBe(harness.scratchOffset); + expect(length).toBe(1); + expect(harness.kernelBytes.slice(pointer, pointer + length)) + .toEqual(new Uint8Array([0x31])); + return length; + }); + Object.assign(harness.worker, { + kernelInstance: { + exports: { kernel_pty_master_write: ptyWrite }, + }, + drainPtyOutput: () => {}, + scheduleWakeBlockedRetries: () => {}, + }); + + harness.worker.ptyMasterWrite(3, input); + + expect(ptyWrite).toHaveBeenCalledOnce(); + expect( + harness.kernelBytes.slice( + harness.scratchOffset, + harness.scratchOffset + 4, + ), + ).toEqual(new Uint8Array([0x31, 0xa5, 0xa5, 0xa5])); + expectScratchTailUntouched(harness); + }); + + it("uses intrinsic source spans for System V and pipe chunk staging", () => { + const harness = makeScratchHarness(); + const input = hostileBytes(1, 4); + Uint8Array.prototype.set.call(input, [0x42]); + harness.kernelBytes.fill( + 0xa5, + harness.scratchOffset, + harness.scratchOffset + 8, + ); + const writeShm = vi.fn(( + _segment: number, + _offset: number, + pointer: number, + length: number, + ) => { + expect(length).toBe(1); + expect(harness.kernelBytes[pointer]).toBe(0x42); + return length; + }); + const writePipe = vi.fn(( + _pid: number, + _pipe: number, + pointer: number, + length: number, + ) => { + expect(length).toBe(1); + expect(harness.kernelBytes[pointer]).toBe(0x42); + return length; + }); + Object.assign(harness.worker, { + tcpScratchRegion: (harness.worker as any).scratchRegion, + kernelInstance: { + exports: { + kernel_ipc_shm_write_chunk: writeShm, + kernel_pipe_write: writePipe, + }, + }, + }); + + expect((harness.worker as any).writeSysvShmRange(7, 0, input)).toBe(true); + expect((harness.worker as any).writePipeChunked( + 41, + 9, + input, + )).toBe(1); + + expect(writeShm).toHaveBeenCalledOnce(); + expect(writePipe).toHaveBeenCalledOnce(); + expectScratchTailUntouched(harness); + }); + + it("does not lend stale UDP scratch bytes when a router spoofs length", () => { + const harness = makeScratchHarness(); + const input = hostileBytes(1, 4); + Uint8Array.prototype.set.call(input, [0x55]); + harness.kernelBytes.fill( + 0xa5, + harness.scratchOffset, + harness.scratchOffset + 8, + ); + const inject = vi.fn((...args: number[]) => { + const pointer = args[11]; + const length = args[12]; + expect(pointer).toBe(harness.scratchOffset); + expect(length).toBe(1); + expect(harness.kernelBytes.slice(pointer, pointer + length)) + .toEqual(new Uint8Array([0x55])); + return 0; + }); + Object.assign(harness.worker, { + tcpScratchRegion: (harness.worker as any).scratchRegion, + processes: new Map([[41, {}]]), + scheduleWakeBlockedRetries: vi.fn(), + kernelInstance: { + exports: { kernel_inject_datagram: inject }, + }, + }); + + expect((harness.worker as any).injectUdpDatagram(41, { + srcAddr: new Uint8Array([10, 0, 0, 1]), + srcPort: 1000, + dstAddr: new Uint8Array([10, 0, 0, 2]), + dstPort: 2000, + data: input, + })).toBe(0); + + expect(inject).toHaveBeenCalledOnce(); + expect( + harness.kernelBytes.slice( + harness.scratchOffset, + harness.scratchOffset + 4, + ), + ).toEqual(new Uint8Array([0x55, 0xa5, 0xa5, 0xa5])); + expectScratchTailUntouched(harness); + }); + + it("accepts exact TCP scratch capacity and rejects capacity plus one", () => { + const harness = makeScratchHarness(); + const tcpScratchOffset = 96_000; + const tcpCapacity = 65_536; + const tcpTail = harness.kernelBytes.subarray( + tcpScratchOffset + tcpCapacity, + tcpScratchOffset + tcpCapacity + 16, + ); + tcpTail.fill(0xa5); + const inject = vi.fn((...args: number[]) => { + const pointer = args[11]; + const length = args[12]; + expect(pointer).toBe(tcpScratchOffset); + expect(length).toBe(tcpCapacity); + expect(harness.kernelBytes[pointer]).toBe(0x55); + expect(harness.kernelBytes[pointer + length - 1]).toBe(0x55); + return 0; + }); + const tcpScratchInstance = createKernelScratchTestInstance( + 4, + (harness.worker as any).kernelMemory, + () => ({ kernel_inject_datagram: inject }), + () => tcpScratchOffset, + ); + const tcpScratchRegion = allocateKernelScratchRegion( + (harness.worker as any).kernelMemory, + tcpScratchInstance.exports.kernel_alloc_scratch as + (size: number) => number, + tcpCapacity, + 4, + "test kernel TCP scratch", + tcpScratchInstance, + ); + Object.assign(harness.worker, { + tcpScratchRegion, + processes: new Map([[41, {}]]), + scheduleWakeBlockedRetries: vi.fn(), + kernelInstance: { + exports: { kernel_inject_datagram: inject }, + }, + }); + const datagram = (data: Uint8Array) => ({ + srcAddr: new Uint8Array([10, 0, 0, 1]), + srcPort: 1000, + dstAddr: new Uint8Array([10, 0, 0, 2]), + dstPort: 2000, + data, + }); + + expect((harness.worker as any).injectUdpDatagram( + 41, + datagram(new Uint8Array(tcpCapacity).fill(0x55)), + )).toBe(0); + expect((harness.worker as any).injectUdpDatagram( + 41, + datagram(new Uint8Array(tcpCapacity + 1).fill(0x66)), + )).toBe(90); + + expect(inject).toHaveBeenCalledOnce(); + expect(tcpTail).toEqual(new Uint8Array(16).fill(0xa5)); + expectScratchTailUntouched(harness); + }); + + it("accepts PATH_MAX minus one cwd bytes and rejects PATH_MAX before copying", () => { + const exact = makeScratchHarness(); + const exactPath = "x".repeat(POSIX_PATH_MAX_BYTES - 1); + const exactSetCwd = vi.fn(( + pid: number, + pointer: number, + length: number, + ) => { + expect(pid).toBe(41); + expect(pointer).toBe(exact.scratchOffset); + expect(length).toBe(POSIX_PATH_MAX_BYTES - 1); + expect( + exact.kernelBytes.slice(pointer, pointer + length), + ).toEqual(new TextEncoder().encode(exactPath)); + return 0; + }); + Object.assign(exact.worker, { + initialized: true, + kernelInstance: { exports: { kernel_set_cwd: exactSetCwd } }, + }); + + exact.worker.setCwd(41, exactPath); + + expect(exactSetCwd).toHaveBeenCalledOnce(); + expectScratchTailUntouched(exact); + + const oversized = makeScratchHarness(); + const oversizedSetCwd = vi.fn(() => -36); + Object.assign(oversized.worker, { + initialized: true, + kernelInstance: { exports: { kernel_set_cwd: oversizedSetCwd } }, + }); + const scratchBeforeRejection = oversized.kernelBytes.slice( + oversized.scratchOffset, + oversized.scratchEnd, + ); + + expect(() => + oversized.worker.setCwd(41, "x".repeat(POSIX_PATH_MAX_BYTES)) + ) + .toThrow(/cwd|PATH_MAX|too long/i); + + expect(oversizedSetCwd).not.toHaveBeenCalled(); + expect( + oversized.kernelBytes.slice( + oversized.scratchOffset, + oversized.scratchEnd, + ), + ).toEqual(scratchBeforeRejection); + expectScratchTailUntouched(oversized); + }); + + it("fails loudly when the required bounded cwd export is absent", () => { + const harness = makeScratchHarness(); + Object.assign(harness.worker, { + initialized: true, + kernelInstance: { exports: {} }, + }); + const scratchBefore = harness.kernelBytes.slice( + harness.scratchOffset, + harness.scratchEnd, + ); + + expect(() => harness.worker.setCwd(41, "/tmp")) + .toThrow("Kernel missing required kernel_set_cwd export"); + expect( + harness.kernelBytes.slice( + harness.scratchOffset, + harness.scratchEnd, + ), + ).toEqual(scratchBefore); + expectScratchTailUntouched(harness); + }); + + it("lends getgroups exactly one gid slot and snapshots it before reuse", () => { + const harness = makeScratchHarness(); + const destination = harness.processBytes.byteLength - 4; + const gid = 0x1234_5678; + harness.handleChannel.mockImplementation(() => { + const channelView = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + const outputPointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + const outputCapacity = Number( + channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true), + ); + expect(outputPointer).toBeGreaterThanOrEqual( + harness.scratchOffset + CH_DATA, + ); + expect(outputCapacity).toBe(4); + new DataView(harness.kernelBytes.buffer).setUint32( + outputPointer, + gid, + true, + ); + channelView.setBigInt64(CH_RETURN, 1n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + harness.worker.handleGetgroups( + harness.channel, + [1, destination, 0, 0, 0, 0], + [1n, BigInt(destination), 0n, 0n, 0n, 0n], + ); + + expect(harness.completeChannel).toHaveBeenCalledTimes(1); + const completion = harness.completeChannel.mock.calls[0]; + expect(completion.slice(4, 6)).toEqual([1, 0]); + expect(completion[6]).toEqual([{ + ptr: destination, + bytes: new Uint8Array([0x78, 0x56, 0x34, 0x12]), + }]); + expectScratchTailUntouched(harness); + }); + + it("keeps a getgroups count query pointer-free", () => { + const harness = makeScratchHarness(8); + harness.handleChannel.mockImplementation(() => { + const channelView = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + expect(channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true)).toBe(0n); + expect(channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true)).toBe(0n); + channelView.setBigInt64(CH_RETURN, 1n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + harness.worker.handleGetgroups( + harness.channel, + [0, Number.MAX_SAFE_INTEGER, 0, 0, 0, 0], + [0n, BigInt(Number.MAX_SAFE_INTEGER) + 1n, 0n, 0n, 0n, 0n], + ); + + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + ABI_SYSCALLS.Getgroups, + [0, Number.MAX_SAFE_INTEGER, 0, 0, 0, 0], + undefined, + 1, + 0, + undefined, + ); + expectScratchTailUntouched(harness); + }); + + it.each([ + ["negative size", -1n, 1n, EINVAL], + ["oversized size", 0x8000_0000n, 1n, EINVAL], + ["null output", 1n, 0n, EFAULT], + ] as const)( + "rejects an invalid getgroups %s before kernel dispatch", + (_name, size, pointer, errno) => { + const harness = makeScratchHarness(8); + harness.worker.handleGetgroups( + harness.channel, + [Number(size), Number(pointer), 0, 0, 0, 0], + [size, pointer, 0n, 0n, 0n, 0n], + ); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + errno, + ); + expectScratchTailUntouched(harness); + }, + ); + + it("accepts exact setgroups scratch capacity and rejects capacity plus one", () => { + const exactCount = CH_DATA_SIZE / 4; + for (const count of [exactCount, exactCount + 1]) { + const harness = makeScratchHarness(8); + prepareGenericSyscallHarness(harness, 8); + const source = 4096; + harness.processBytes.fill( + 0x4d, + source, + source + count * 4, + ); + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + const scratchPointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + expect(scratchPointer).toBe(harness.scratchOffset + CH_DATA); + expect( + harness.kernelBytes.slice( + scratchPointer, + scratchPointer + CH_DATA_SIZE, + ), + ).toEqual( + harness.processBytes.slice(source, source + CH_DATA_SIZE), + ); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + writeChannelSyscall(harness, ABI_SYSCALLS.Setgroups, [ + BigInt(count), + BigInt(source), + ]); + + harness.worker._handleSyscallInner(harness.channel); + + expect(harness.handleChannel).toHaveBeenCalledTimes( + count === exactCount ? 1 : 0, + ); + if (count === exactCount) { + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ + 0, + 0, + ]); + } else { + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + ABI_SYSCALLS.Setgroups, + [count, source, 0, 0, 0, 0], + undefined, + -1, + EINVAL, + ); + } + expectScratchTailUntouched(harness); + } + }); + + it("replaces an ignored zero-count pointer with checked non-null scratch", () => { + const harness = makeScratchHarness(8); + prepareGenericSyscallHarness(harness, 8); + const ignoredPointer = BigInt(Number.MAX_SAFE_INTEGER) + 1n; + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + expect( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ).toBe(BigInt(harness.scratchOffset + CH_DATA)); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + writeChannelSyscall(harness, ABI_SYSCALLS.Setgroups, [ + 0n, + ignoredPointer, + ]); + + harness.worker._handleSyscallInner(harness.channel); + + expect(harness.handleChannel).toHaveBeenCalledOnce(); + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ + 0, + 0, + ]); + expectScratchTailUntouched(harness); + }); + + it("rejects a null positive-count setgroups source before kernel dispatch", () => { + const harness = makeScratchHarness(8); + prepareGenericSyscallHarness(harness, 8); + writeChannelSyscall(harness, ABI_SYSCALLS.Setgroups, [1n, 0n]); + + harness.worker._handleSyscallInner(harness.channel); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + ABI_SYSCALLS.Setgroups, + [1, 0, 0, 0, 0, 0], + undefined, + -1, + EFAULT, + ); + expectScratchTailUntouched(harness); + }); + + it.each([ + ["wasm32", 4, "pipe", ABI_SYSCALLS.Pipe], + ["wasm32", 4, "uname", ABI_SYSCALLS.Uname], + ["wasm64", 8, "pipe", ABI_SYSCALLS.Pipe], + ["wasm64", 8, "uname", ABI_SYSCALLS.Uname], + ] as const)( + "rejects a null positive-size fixed %s %s output before kernel dispatch", + (_pointerKind, pointerWidth, _name, syscallNr) => { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + writeChannelSyscall(harness, syscallNr, [0n]); + + harness.worker._handleSyscallInner(harness.channel); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + syscallNr, + [0, 0, 0, 0, 0, 0], + undefined, + -1, + EFAULT, + ); + expectScratchTailUntouched(harness); + }, + ); + + it.each([ + ["wasm32", 4, "read", ABI_SYSCALLS.Read], + ["wasm32", 4, "write", ABI_SYSCALLS.Write], + ["wasm64", 8, "read", ABI_SYSCALLS.Read], + ["wasm64", 8, "write", ABI_SYSCALLS.Write], + ] as const)( + "rejects a null positive-count %s %s buffer before kernel dispatch", + (_pointerKind, pointerWidth, _name, syscallNr) => { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + writeChannelSyscall(harness, syscallNr, [7n, 0n, 1n]); + + harness.worker._handleSyscallInner(harness.channel); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + syscallNr, + [7, 0, 1, 0, 0, 0], + undefined, + -1, + EFAULT, + ); + expectScratchTailUntouched(harness); + }, + ); + + it.each([ + ["wasm32", 4, "read", ABI_SYSCALLS.Read], + ["wasm32", 4, "write", ABI_SYSCALLS.Write], + ["wasm64", 8, "read", ABI_SYSCALLS.Read], + ["wasm64", 8, "write", ABI_SYSCALLS.Write], + ] as const)( + "maps a null zero-count %s %s buffer to owned empty scratch", + (_pointerKind, pointerWidth, _name, syscallNr) => { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); + expect( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ).toBe(BigInt(harness.scratchOffset + CH_DATA)); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + writeChannelSyscall(harness, syscallNr, [7n, 0n, 0n]); + + harness.worker._handleSyscallInner(harness.channel); + + expect(harness.handleChannel).toHaveBeenCalledOnce(); + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ + 0, + 0, + ]); + expectScratchTailUntouched(harness); + }, + ); + + it.each([ + ["wasm32", 4, 1n], + ["wasm64", 8, 0x7fff_ffff_0000_0001n], + ] as const)( + "keeps a scalar %s prctl argument out of scratch", + (_pointerKind, pointerWidth, rawScalar) => { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); + expect(channelView.getBigInt64(CH_ARGS, true)).toBe( + BigInt(PR_SET_NO_NEW_PRIVS), + ); + expect( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ).toBe(1n); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + writeChannelSyscall(harness, ABI_SYSCALLS.Prctl, [ + BigInt(PR_SET_NO_NEW_PRIVS), + rawScalar, + ]); + + harness.worker._handleSyscallInner(harness.channel); + + expect(harness.handleChannel).toHaveBeenCalledOnce(); + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ + 0, + 0, + ]); + expectScratchTailUntouched(harness); + }, + ); + + it.each([ + ["wasm32", "set", 4, PR_SET_NAME, "in"], + ["wasm32", "get", 4, PR_GET_NAME, "out"], + ["wasm64", "set", 8, PR_SET_NAME, "in"], + ["wasm64", "get", 8, PR_GET_NAME, "out"], + ] as const)( + "stages only the exact %s prctl %s-name buffer", + (_pointerKind, _operation, pointerWidth, option, direction) => { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + const processPointer = 4096; + const input = Uint8Array.from( + { length: PRCTL_NAME_BYTES }, + (_, index) => 0x20 + index, + ); + const output = Uint8Array.from( + { length: PRCTL_NAME_BYTES }, + (_, index) => 0x70 + index, + ); + if (direction === "in") { + harness.processBytes.set(input, processPointer); + } + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); + const scratchPointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + expect(scratchPointer).toBe(harness.scratchOffset + CH_DATA); + if (direction === "in") { + expect( + harness.kernelBytes.slice( + scratchPointer, + scratchPointer + PRCTL_NAME_BYTES, + ), + ).toEqual(input); + } else { + harness.kernelBytes.set(output, scratchPointer); + } + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + writeChannelSyscall(harness, ABI_SYSCALLS.Prctl, [ + BigInt(option), + BigInt(processPointer), + ]); + + harness.worker._handleSyscallInner(harness.channel); + + expect(harness.handleChannel).toHaveBeenCalledOnce(); + if (direction === "out") { + const writes = harness.completeChannel.mock.calls[0]?.[6] as + Array<{ ptr: number; bytes: Uint8Array }>; + expect(writes).toEqual([{ ptr: processPointer, bytes: output }]); + } + expectScratchTailUntouched(harness); + }, + ); + + it.each([ + ["wasm32", 4, PR_SET_NAME], + ["wasm32", 4, PR_GET_NAME], + ["wasm64", 8, PR_SET_NAME], + ["wasm64", 8, PR_GET_NAME], + ] as const)( + "rejects a null %s prctl name buffer before kernel dispatch", + (_pointerKind, pointerWidth, option) => { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + writeChannelSyscall(harness, ABI_SYSCALLS.Prctl, [ + BigInt(option), + 0n, + ]); + + harness.worker._handleSyscallInner(harness.channel); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + ABI_SYSCALLS.Prctl, + [option, 0, 0, 0, 0, 0], + undefined, + -1, + EFAULT, + ); + expectScratchTailUntouched(harness); + }, + ); + + it("accounts for every writev table and alignment byte", () => { + const harness = makeScratchHarness(); + const iovPtr = 256; + const dataPtr = 16_384; + const entries = Array.from( + { length: IOV_MAX }, + (_, index) => ({ + base: dataPtr, + len: index === IOV_MAX - 1 ? 56_321 : 1, + }), + ); + writeWasm32Iovecs(harness.processBytes, iovPtr, entries); + harness.processBytes.fill(0x5c, dataPtr, dataPtr + 56_321); + const kernelIovPointers: number[] = []; + const defaultHandleChannel = harness.handleChannel.getMockImplementation()!; + harness.handleChannel.mockImplementation((...args: unknown[]) => { + const view = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + kernelIovPointers.push( + Number(view.getBigInt64(CH_ARGS + CH_ARG_SIZE, true)), + ); + return defaultHandleChannel(...args); + }); + + harness.worker.handleWritev( + harness.channel, + ABI_SYSCALLS.Writev, + [7, iovPtr, entries.length, 0, 0, 0], + ); + + expectScratchTailUntouched(harness); + for (const kernelIov of kernelIovPointers) { + expect(kernelIov).toBeGreaterThanOrEqual(harness.scratchOffset + CH_DATA); + expect(kernelIov + 8).toBeLessThanOrEqual(harness.scratchEnd); + } + }); + + it.each([ + ["writev", "handleWritev", ABI_SYSCALLS.Writev, true], + ["readv", "handleReadv", ABI_SYSCALLS.Readv, false], + ] as const)( + "%s switches from one exact-capacity call to bounded chunks at capacity plus one", + (_name, method, syscallNr, input) => { + const exactDataCapacity = CH_DATA_SIZE - 8; + expect(exactDataCapacity).toBe(65_528); + + for (const length of [exactDataCapacity, exactDataCapacity + 1]) { + const harness = makeScratchHarness(); + const iovPointer = 256; + const dataPointer = 65_536; + const payload = Uint8Array.from( + { length }, + (_, index) => (index * 17 + 3) % 251, + ); + const callerCanary = 0x7e; + writeNativeIovec( + harness.processBytes, + 4, + iovPointer, + dataPointer, + length, + ); + if (input) { + harness.processBytes.set(payload, dataPointer); + } else { + harness.processBytes.fill( + 0x6d, + dataPointer, + dataPointer + length, + ); + } + harness.processBytes[dataPointer + length] = callerCanary; + + let transferred = 0; + const chunkLengths: number[] = []; + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView( + harness.kernelBytes.buffer, + offset, + ); + const kernelIovecPointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + const kernelView = new DataView(harness.kernelBytes.buffer); + const kernelDataPointer = kernelView.getUint32( + kernelIovecPointer, + true, + ); + const chunkLength = kernelView.getUint32( + kernelIovecPointer + 4, + true, + ); + expect(kernelIovecPointer).toBe( + harness.scratchOffset + CH_DATA, + ); + expect(kernelDataPointer).toBe(kernelIovecPointer + 8); + expect(kernelDataPointer + chunkLength) + .toBeLessThanOrEqual(harness.scratchEnd); + chunkLengths.push(chunkLength); + const chunk = payload.subarray( + transferred, + transferred + chunkLength, + ); + if (input) { + expect( + harness.kernelBytes.slice( + kernelDataPointer, + kernelDataPointer + chunkLength, + ), + ).toEqual(chunk); + } else { + harness.kernelBytes.set(chunk, kernelDataPointer); + } + transferred += chunkLength; + channelView.setBigInt64(CH_RETURN, BigInt(chunkLength), true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + harness.worker[method]( + harness.channel, + syscallNr, + [7, iovPointer, 1, 0, 0, 0], + ); + + expect(chunkLengths).toEqual( + length === exactDataCapacity + ? [exactDataCapacity] + : [exactDataCapacity, 1], + ); + expect(transferred).toBe(length); + expect( + harness.processBytes.slice( + dataPointer, + dataPointer + length, + ), + ).toEqual(payload); + expect(harness.processBytes[dataPointer + length]) + .toBe(callerCanary); + if (length === exactDataCapacity) { + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)) + .toEqual([length, 0]); + expect(harness.completeChannelRaw).not.toHaveBeenCalled(); + } else if (input) { + expect(harness.completeChannel).not.toHaveBeenCalled(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + length, + 0, + ); + } else { + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)) + .toEqual([length, 0]); + expect(harness.completeChannelRaw).not.toHaveBeenCalled(); + } + expectScratchTailUntouched(harness); + } + }, + ); + + it.each([4, 8] as const)( + "accepts a bounded wasm%s iovec table at caller address zero", + (pointerWidth) => { + const dataPointer = 2048; + const payload = Uint8Array.from([1, 2, 3, 4]); + for (const path of IOVEC_HANDLER_PATHS) { + const harness = makeScratchHarness(pointerWidth); + writeNativeIovec( + harness.processBytes, + pointerWidth, + 0, + dataPointer, + payload.byteLength, + ); + if (path.input) { + harness.processBytes.set(payload, dataPointer); + } else { + harness.processBytes.fill( + 0x6d, + dataPointer, + dataPointer + payload.byteLength, + ); + } + respondToSingleKernelIovec(harness, path, payload); + + invokeIovecHandler(harness, pointerWidth, path, 0); + + expect(harness.handleChannel, path.name).toHaveBeenCalledOnce(); + expect( + harness.completeChannel.mock.calls[0]?.slice(4, 6), + path.name, + ).toEqual([payload.byteLength, 0]); + if (!path.input) { + expect( + harness.processBytes.slice( + dataPointer, + dataPointer + payload.byteLength, + ), + path.name, + ).toEqual(payload); + } + expectScratchTailUntouched(harness); + } + }, + ); + + it.each([4, 8] as const)( + "accepts bounded positive-length wasm%s iovec data at caller address zero", + (pointerWidth) => { + const iovPointer = 512; + const payload = Uint8Array.from([5, 6, 7, 8]); + for (const path of IOVEC_HANDLER_PATHS) { + const harness = makeScratchHarness(pointerWidth); + writeNativeIovec( + harness.processBytes, + pointerWidth, + iovPointer, + 0, + payload.byteLength, + ); + if (path.input) { + harness.processBytes.set(payload, 0); + } else { + harness.processBytes.fill(0x6d, 0, payload.byteLength); + } + respondToSingleKernelIovec(harness, path, payload); + + invokeIovecHandler(harness, pointerWidth, path, iovPointer); + + expect(harness.handleChannel, path.name).toHaveBeenCalledOnce(); + expect( + harness.completeChannel.mock.calls[0]?.slice(4, 6), + path.name, + ).toEqual([payload.byteLength, 0]); + if (!path.input) { + expect( + harness.processBytes.slice(0, payload.byteLength), + path.name, + ).toEqual(payload); + } + expectScratchTailUntouched(harness); + } + }, + ); + + it.each([4, 8] as const)( + "accepts a zero-length wasm%s iovec with base address zero", + (pointerWidth) => { + const iovPointer = 512; + for (const path of IOVEC_HANDLER_PATHS) { + const harness = makeScratchHarness(pointerWidth); + harness.processBytes.fill(0x6d, 0, 16); + const addressZeroBefore = harness.processBytes.slice(0, 16); + writeNativeIovec( + harness.processBytes, + pointerWidth, + iovPointer, + 0, + 0, + ); + respondToSingleKernelIovec(harness, path, new Uint8Array(0)); + + invokeIovecHandler(harness, pointerWidth, path, iovPointer); + + expect(harness.handleChannel, path.name).toHaveBeenCalledOnce(); + expect( + harness.completeChannel.mock.calls[0]?.slice(4, 6), + path.name, + ).toEqual([0, 0]); + expect(harness.processBytes.slice(0, 16), path.name) + .toEqual(addressZeroBefore); + expectScratchTailUntouched(harness); + } + }, + ); + + it("subtracts the complete readv iovec table from data capacity", () => { + const harness = makeScratchHarness(); + const iovPtr = 256; + const destination = 24_576; + const entries = Array.from( + { length: IOV_MAX }, + (_, index) => ({ + base: destination, + len: index === IOV_MAX - 1 ? 56 : 64, + }), + ); + writeWasm32Iovecs(harness.processBytes, iovPtr, entries); + harness.handleChannel.mockImplementation(() => { + const view = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + view.setBigInt64(CH_RETURN, 0n, true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + harness.worker.handleReadv( + harness.channel, + ABI_SYSCALLS.Readv, + [7, iovPtr, entries.length, 0, 0, 0], + ); + + expectScratchTailUntouched(harness); + }); + + it.each([ + ["sendmsg", "handleSendmsg", ABI_SYSCALLS.Sendmsg], + ["recvmsg", "handleRecvmsg", ABI_SYSCALLS.Recvmsg], + ] as const)( + "rejects %s iovec counts above IOV_MAX before building a kernel table", + (_name, method, _syscallNr) => { + const harness = makeScratchHarness(); + const msgPtr = 128; + const iovPtr = 1024; + const view = new DataView(harness.processBytes.buffer); + view.setUint32(msgPtr + 8, iovPtr, true); + view.setUint32(msgPtr + 12, IOV_MAX + 1, true); + writeWasm32Iovecs( + harness.processBytes, + iovPtr, + Array.from({ length: IOV_MAX + 1 }, () => ({ base: 0, len: 0 })), + ); + + harness.worker[method]( + harness.channel, + [7, msgPtr, 0, 0, 0, 0], + ); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + EINVAL, + ); + expectScratchTailUntouched(harness); + }, + ); + + it.each([ + ["sendmsg", "handleSendmsg", true], + ["recvmsg", "handleRecvmsg", false], + ] as const)( + "accepts an exact-capacity one-entry %s layout and rejects capacity plus one", + (_name, method, input) => { + const exactDataCapacity = CH_DATA_SIZE - + STRUCT_SIZE_KERNEL_MSGHDR_WIRE - + STRUCT_SIZE_KERNEL_IOVEC_WIRE; + expect(exactDataCapacity).toBe(65_500); + + for (const length of [exactDataCapacity, exactDataCapacity + 1]) { + const harness = makeScratchHarness(); + const messagePointer = 128; + const iovecPointer = 512; + const dataPointer = 65_536; + const payload = Uint8Array.from( + { length }, + (_, index) => (index * 19 + 5) % 251, + ); + const callerCanary = 0x7e; + const processView = new DataView(harness.processBytes.buffer); + processView.setUint32(messagePointer + 8, iovecPointer, true); + processView.setUint32(messagePointer + 12, 1, true); + writeNativeIovec( + harness.processBytes, + 4, + iovecPointer, + dataPointer, + length, + ); + if (input) { + harness.processBytes.set(payload, dataPointer); + } else { + harness.processBytes.fill( + 0x6d, + dataPointer, + dataPointer + length, + ); + } + harness.processBytes[dataPointer + length] = callerCanary; + const scratchBeforeRejection = harness.kernelBytes.slice( + harness.scratchOffset, + harness.scratchEnd, + ); + + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView( + harness.kernelBytes.buffer, + offset, + ); + const kernelMessagePointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + const kernelView = new DataView(harness.kernelBytes.buffer); + const kernelIovecPointer = kernelView.getUint32( + kernelMessagePointer + 8, + true, + ); + const kernelDataPointer = kernelView.getUint32( + kernelIovecPointer, + true, + ); + expect(kernelMessagePointer).toBe( + harness.scratchOffset + CH_DATA, + ); + expect(kernelIovecPointer).toBe( + kernelMessagePointer + STRUCT_SIZE_KERNEL_MSGHDR_WIRE, + ); + expect( + kernelView.getUint32(kernelMessagePointer + 12, true), + ).toBe(1); + expect( + kernelView.getUint32(kernelIovecPointer + 4, true), + ).toBe(length); + expect(kernelDataPointer).toBe(kernelIovecPointer + 8); + expect(kernelDataPointer + length).toBe(harness.scratchEnd); + if (input) { + expect( + harness.kernelBytes.slice( + kernelDataPointer, + kernelDataPointer + length, + ), + ).toEqual(payload); + } else { + harness.kernelBytes.set(payload, kernelDataPointer); + } + channelView.setBigInt64(CH_RETURN, BigInt(length), true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + harness.worker[method]( + harness.channel, + [7, messagePointer, 0, 0, 0, 0], + ); + + if (length === exactDataCapacity) { + expect(harness.handleChannel).toHaveBeenCalledOnce(); + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)) + .toEqual([length, 0]); + if (!input) { + expect( + harness.processBytes.slice( + dataPointer, + dataPointer + length, + ), + ).toEqual(payload); + } + } else { + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + 90, + ); + expect( + harness.kernelBytes.slice( + harness.scratchOffset, + harness.scratchEnd, + ), + ).toEqual(scratchBeforeRejection); + } + expect(harness.processBytes[dataPointer + length]) + .toBe(callerCanary); + expectScratchTailUntouched(harness); + } + }, + ); + + it.each([ + ["sendmsg", "handleSendmsg"], + ["recvmsg", "handleRecvmsg"], + ] as const)( + "flattens exactly IOV_MAX zero-length %s entries to one empty wire iovec", + (_name, method) => { + const harness = makeScratchHarness(); + const messagePointer = 128; + const iovecPointer = 1024; + const processView = new DataView(harness.processBytes.buffer); + processView.setUint32(messagePointer + 8, iovecPointer, true); + processView.setUint32(messagePointer + 12, IOV_MAX, true); + writeWasm32Iovecs( + harness.processBytes, + iovecPointer, + Array.from({ length: IOV_MAX }, () => ({ base: 0, len: 0 })), + ); + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView( + harness.kernelBytes.buffer, + offset, + ); + const kernelMessagePointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + const kernelView = new DataView(harness.kernelBytes.buffer); + const kernelIovecPointer = kernelView.getUint32( + kernelMessagePointer + 8, + true, + ); + expect(kernelMessagePointer).toBe( + harness.scratchOffset + CH_DATA, + ); + expect(kernelIovecPointer).toBe( + kernelMessagePointer + STRUCT_SIZE_KERNEL_MSGHDR_WIRE, + ); + expect( + kernelView.getUint32( + kernelMessagePointer + KERNEL_MSGHDR_WIRE_IOVLEN_OFFSET, + true, + ), + ).toBe(KERNEL_MESSAGE_WIRE_FLATTENED_IOVEC_COUNT); + expect( + kernelIovecPointer + STRUCT_SIZE_KERNEL_IOVEC_WIRE, + ).toBeLessThanOrEqual(harness.scratchEnd); + expect( + harness.kernelBytes.slice( + kernelIovecPointer, + kernelIovecPointer + STRUCT_SIZE_KERNEL_IOVEC_WIRE, + ), + ).toEqual(new Uint8Array(STRUCT_SIZE_KERNEL_IOVEC_WIRE)); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + harness.worker[method]( + harness.channel, + [7, messagePointer, 0, 0, 0, 0], + ); + + expect(harness.handleChannel).toHaveBeenCalledOnce(); + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)) + .toEqual([0, 0]); + expectScratchTailUntouched(harness); + }, + ); + + it.each([ + [4, "sendmsg", "handleSendmsg", true], + [8, "sendmsg", "handleSendmsg", true], + [4, "recvmsg", "handleRecvmsg", false], + [8, "recvmsg", "handleRecvmsg", false], + ] as const)( + "uses one canonical wire iovec for wasm%s multi-iovec %s", + (pointerWidth, _name, method, input) => { + const harness = makeScratchHarness(pointerWidth); + const messagePointer = 128; + const iovecPointer = 512; + const firstPointer = 4096; + const secondPointer = 8192; + const payload = Uint8Array.from([1, 2, 3, 4, 5]); + const entrySize = pointerWidth === 8 ? 16 : 8; + writeNativeIovec( + harness.processBytes, + pointerWidth, + iovecPointer, + firstPointer, + 2, + ); + writeNativeIovec( + harness.processBytes, + pointerWidth, + iovecPointer + entrySize, + 0, + 0, + ); + writeNativeIovec( + harness.processBytes, + pointerWidth, + iovecPointer + 2 * entrySize, + secondPointer, + 3, + ); + writeNativeMessage( + harness.processBytes, + pointerWidth, + messagePointer, + { iovecPointer, iovecCount: 3 }, + ); + if (input) { + harness.processBytes.set(payload.subarray(0, 2), firstPointer); + harness.processBytes.set(payload.subarray(2), secondPointer); + } else { + harness.processBytes.fill(0x61, firstPointer, firstPointer + 2); + harness.processBytes.fill(0x62, secondPointer, secondPointer + 3); + } + harness.processBytes[firstPointer + 2] = 0x91; + harness.processBytes[secondPointer + 3] = 0x92; + + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + const kernelMessagePointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + const kernelView = new DataView(harness.kernelBytes.buffer); + expect( + kernelView.getUint32( + kernelMessagePointer + KERNEL_MSGHDR_WIRE_IOVLEN_OFFSET, + true, + ), + ).toBe(KERNEL_MESSAGE_WIRE_FLATTENED_IOVEC_COUNT); + const kernelIovecPointer = kernelView.getUint32( + kernelMessagePointer + KERNEL_MSGHDR_WIRE_IOV_OFFSET, + true, + ); + const kernelDataPointer = kernelView.getUint32( + kernelIovecPointer + KERNEL_IOVEC_WIRE_BASE_OFFSET, + true, + ); + expect( + kernelView.getUint32( + kernelIovecPointer + KERNEL_IOVEC_WIRE_LEN_OFFSET, + true, + ), + ).toBe(payload.length); + if (input) { + expect( + harness.kernelBytes.slice( + kernelDataPointer, + kernelDataPointer + payload.length, + ), + ).toEqual(payload); + } else { + harness.kernelBytes.set(payload, kernelDataPointer); + } + channelView.setBigInt64(CH_RETURN, BigInt(payload.length), true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + harness.worker[method]( + harness.channel, + [7, messagePointer, 0, 0, 0, 0], + ); + + expect(harness.handleChannel).toHaveBeenCalledOnce(); + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)) + .toEqual([payload.length, 0]); + if (!input) { + expect( + harness.processBytes.slice(firstPointer, firstPointer + 2), + ).toEqual(payload.subarray(0, 2)); + expect( + harness.processBytes.slice(secondPointer, secondPointer + 3), + ).toEqual(payload.subarray(2)); + } + expect(harness.processBytes[firstPointer + 2]).toBe(0x91); + expect(harness.processBytes[secondPointer + 3]).toBe(0x92); + expectScratchTailUntouched(harness); + }, + ); + + it.each([ + ["sendmsg", "handleSendmsg"], + ["recvmsg", "handleRecvmsg"], + ] as const)( + "does not let an oversized %s iovec table cross the scratch allocation", + (_name, method) => { + const harness = makeScratchHarness(); + const msgPtr = 128; + const iovPtr = 1024; + const countThatFillsTheDataArea = 8192; + const view = new DataView(harness.processBytes.buffer); + view.setUint32(msgPtr + 8, iovPtr, true); + view.setUint32(msgPtr + 12, countThatFillsTheDataArea, true); + writeWasm32Iovecs( + harness.processBytes, + iovPtr, + Array.from( + { length: countThatFillsTheDataArea }, + () => ({ base: 0, len: 0 }), + ), + ); + + harness.worker[method]( + harness.channel, + [7, msgPtr, 0, 0, 0, 0], + ); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expectScratchTailUntouched(harness); + }, + ); + + it("accepts recvmsg MSG_TRUNC lengths larger than bounded iovec data", () => { + const harness = makeScratchHarness(); + const msgPtr = 128; + const iovPtr = 1024; + const destination = 2048; + const payloadLength = 13; + const view = new DataView(harness.processBytes.buffer); + view.setUint32(msgPtr + 8, iovPtr, true); + view.setUint32(msgPtr + 12, 1, true); + writeWasm32Iovecs( + harness.processBytes, + iovPtr, + [{ base: destination, len: 4 }], + ); + harness.handleChannel.mockImplementation(() => { + const channelView = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + const kernelMessagePointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + const kernelView = new DataView(harness.kernelBytes.buffer); + const kernelIovecPointer = kernelView.getUint32( + kernelMessagePointer + 8, + true, + ); + const kernelDataPointer = kernelView.getUint32( + kernelIovecPointer, + true, + ); + harness.kernelBytes.set(new TextEncoder().encode("recv"), kernelDataPointer); + channelView.setBigInt64(CH_RETURN, BigInt(payloadLength), true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + harness.worker.handleRecvmsg( + harness.channel, + [7, msgPtr, SOCKET_MSG_TRUNC, 0, 0, 0], + ); + + expect(harness.processBytes.slice(destination, destination + 4)).toEqual( + new TextEncoder().encode("recv"), + ); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + ABI_SYSCALLS.Recvmsg, + [7, msgPtr, SOCKET_MSG_TRUNC, 0, 0, 0], + undefined, + payloadLength, + 0, + ); + expectScratchTailUntouched(harness); + }); + + it("does not publish staged recvmsg output while an EAGAIN retry is parked", () => { + const harness = makeScratchHarness(); + const msgPtr = 128; + const iovPtr = 1024; + const dataPtr = 2048; + const namePtr = 4096; + const controlPtr = 8192; + const view = new DataView(harness.processBytes.buffer); + view.setUint32(msgPtr, namePtr, true); + view.setUint32(msgPtr + 4, 16, true); + view.setUint32(msgPtr + 8, iovPtr, true); + view.setUint32(msgPtr + 12, 1, true); + view.setUint32(msgPtr + 16, controlPtr, true); + view.setUint32(msgPtr + 20, 16, true); + writeWasm32Iovecs( + harness.processBytes, + iovPtr, + [{ base: dataPtr, len: 16 }], + ); + harness.processBytes.fill(0x5a, dataPtr, dataPtr + 16); + harness.processBytes.fill(0x6b, namePtr, namePtr + 16); + harness.processBytes.fill(0x7c, controlPtr, controlPtr + 16); + harness.handleChannel.mockImplementation(() => { + const channelView = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + channelView.setBigInt64(CH_RETURN, -1n, true); + channelView.setUint32(CH_ERRNO, EAGAIN, true); + return 0; + }); + + harness.worker.handleRecvmsg( + harness.channel, + [7, msgPtr, 0, 0, 0, 0], + ); + + expect(harness.processBytes.slice(dataPtr, dataPtr + 16)) + .toEqual(new Uint8Array(16).fill(0x5a)); + expect(harness.processBytes.slice(namePtr, namePtr + 16)) + .toEqual(new Uint8Array(16).fill(0x6b)); + expect(harness.processBytes.slice(controlPtr, controlPtr + 16)) + .toEqual(new Uint8Array(16).fill(0x7c)); + expect(view.getUint32(msgPtr + 4, true)).toBe(16); + expect(view.getUint32(msgPtr + 20, true)).toBe(16); + expect(harness.worker.handleBlockingRetry).toHaveBeenCalledWith( + harness.channel, + ABI_SYSCALLS.Recvmsg, + [7, msgPtr, 0, 0, 0, 0], + ); + expectScratchTailUntouched(harness); + }); + + it("rejects a wrapped wasm32 ancillary length before scratch mutation", () => { + const harness = makeScratchHarness(4); + const messagePointer = 128; + const controlPointer = 2048; + const controlLength = 28; + const secondRecordOffset = alignUp( + PROCESS_CMSGHDR_WASM32_DATA_OFFSET, + PROCESS_CMSGHDR_WASM32_ALIGN, + ); + const view = new DataView(harness.processBytes.buffer); + view.setUint32( + controlPointer + PROCESS_CMSGHDR_WASM32_LEN_OFFSET, + PROCESS_CMSGHDR_WASM32_DATA_OFFSET, + true, + ); + view.setUint32( + controlPointer + secondRecordOffset + + PROCESS_CMSGHDR_WASM32_LEN_OFFSET, + 0xffff_fff8, + true, + ); + view.setUint32( + controlPointer + secondRecordOffset + + PROCESS_CMSGHDR_WASM32_LEVEL_OFFSET, + SOCKET_SOL_SOCKET, + true, + ); + view.setUint32( + controlPointer + secondRecordOffset + + PROCESS_CMSGHDR_WASM32_TYPE_OFFSET, + SOCKET_SCM_RIGHTS, + true, + ); + writeNativeMessage(harness.processBytes, 4, messagePointer, { + controlPointer, + controlLength, + }); + const scratchBefore = harness.kernelBytes.slice( + harness.scratchOffset, + harness.scratchEnd, + ); + + harness.worker.handleSendmsg( + harness.channel, + [7, messagePointer, 0, 0, 0, 0], + ); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + EINVAL, + ); + expect( + harness.kernelBytes.slice( + harness.scratchOffset, + harness.scratchEnd, + ), + ).toEqual(scratchBefore); + expectScratchTailUntouched(harness); + }); + + it.each([4, 8] as const)( + "translates wasm%s SCM_RIGHTS records to canonical wire bytes across sequential reuse", + (pointerWidth) => { + const harness = makeScratchHarness(pointerWidth); + const messagePointer = 128; + const controlPointer = 2048; + const calls = [ + [[17], [23, 24]], + [[31]], + ]; + let callIndex = 0; + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const expected = canonicalRightsBytes(calls[callIndex]); + const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + const kernelMessagePointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + const kernelView = new DataView(harness.kernelBytes.buffer); + const kernelControlPointer = kernelView.getUint32( + kernelMessagePointer + KERNEL_MSGHDR_WIRE_CONTROL_OFFSET, + true, + ); + expect( + kernelView.getUint32( + kernelMessagePointer + + KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET, + true, + ), + ).toBe(expected.length); + expect( + harness.kernelBytes.slice( + kernelControlPointer, + kernelControlPointer + expected.length, + ), + ).toEqual(expected); + expect( + kernelView.getUint32( + kernelMessagePointer + KERNEL_MSGHDR_WIRE_IOVLEN_OFFSET, + true, + ), + ).toBe(0); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + callIndex += 1; + return 0; + }); + + for (const records of calls) { + const controlLength = writeNativeRightsRecords( + harness.processBytes, + pointerWidth, + controlPointer, + records, + ); + writeNativeMessage( + harness.processBytes, + pointerWidth, + messagePointer, + { controlPointer, controlLength }, + ); + if (pointerWidth === 8) { + new DataView(harness.processBytes.buffer).setUint32( + messagePointer + PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET + 4, + 0xa5a5_a5a5, + true, + ); + } + harness.worker.handleSendmsg( + harness.channel, + [7, messagePointer, 0, 0, 0, 0], + ); + } + + expect(harness.handleChannel).toHaveBeenCalledTimes(calls.length); + expect(harness.completeChannel).toHaveBeenCalledTimes(calls.length); + if (pointerWidth === 8) { + expect( + new DataView(harness.processBytes.buffer).getUint32( + messagePointer + PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET + 4, + true, + ), + ).toBe(0xa5a5_a5a5); + } + expectScratchTailUntouched(harness); + }, + ); + + it.each([ + { + pointerWidth: 4 as const, + capacity: 15, + wireCapacity: 0, + descriptors: [] as number[], + reportedLength: 0, + flags: MSG_CTRUNC, + }, + { + pointerWidth: 4 as const, + capacity: 16, + wireCapacity: 16, + descriptors: [61], + reportedLength: 16, + flags: 0, + }, + { + pointerWidth: 4 as const, + capacity: 19, + wireCapacity: 16, + descriptors: [62], + reportedLength: 16, + flags: 0, + }, + { + pointerWidth: 4 as const, + capacity: 20, + wireCapacity: 20, + descriptors: [63, 64], + reportedLength: 20, + flags: 0, + }, + { + pointerWidth: 8 as const, + capacity: 19, + wireCapacity: 0, + descriptors: [] as number[], + reportedLength: 0, + flags: MSG_CTRUNC, + }, + { + pointerWidth: 8 as const, + capacity: 20, + wireCapacity: 16, + descriptors: [71], + reportedLength: 20, + flags: 0, + }, + { + pointerWidth: 8 as const, + capacity: 23, + wireCapacity: 16, + descriptors: [72], + reportedLength: 23, + flags: 0, + }, + { + pointerWidth: 8 as const, + capacity: 24, + wireCapacity: 20, + descriptors: [73], + reportedLength: 24, + flags: 0, + }, + { + pointerWidth: 8 as const, + capacity: 24, + wireCapacity: 20, + descriptors: [74, 75], + reportedLength: 24, + flags: 0, + }, + ])( + "maps wasm$pointerWidth recvmsg control capacity $capacity to $wireCapacity canonical bytes", + ({ + pointerWidth, + capacity, + wireCapacity, + descriptors, + reportedLength, + flags, + }) => { + const harness = makeScratchHarness(pointerWidth); + const messagePointer = 128; + const controlPointer = 2048; + const controlCanary = 0x6d; + const native = pointerWidth === 8 + ? { + dataOffset: PROCESS_CMSGHDR_WASM64_DATA_OFFSET, + lengthOffset: PROCESS_CMSGHDR_WASM64_LEN_OFFSET, + levelOffset: PROCESS_CMSGHDR_WASM64_LEVEL_OFFSET, + typeOffset: PROCESS_CMSGHDR_WASM64_TYPE_OFFSET, + } + : { + dataOffset: PROCESS_CMSGHDR_WASM32_DATA_OFFSET, + lengthOffset: PROCESS_CMSGHDR_WASM32_LEN_OFFSET, + levelOffset: PROCESS_CMSGHDR_WASM32_LEVEL_OFFSET, + typeOffset: PROCESS_CMSGHDR_WASM32_TYPE_OFFSET, + }; + const messageControlLengthOffset = pointerWidth === 8 + ? PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET + : PROCESS_MSGHDR_WASM32_CONTROLLEN_OFFSET; + const messageFlagsOffset = pointerWidth === 8 + ? PROCESS_MSGHDR_WASM64_FLAGS_OFFSET + : PROCESS_MSGHDR_WASM32_FLAGS_OFFSET; + harness.processBytes.fill( + controlCanary, + controlPointer, + controlPointer + capacity + 1, + ); + writeNativeMessage(harness.processBytes, pointerWidth, messagePointer, { + controlPointer, + controlLength: capacity, + }); + const processView = new DataView(harness.processBytes.buffer); + if (pointerWidth === 8) { + processView.setUint32( + messagePointer + PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET + 4, + 0xa5a5_a5a5, + true, + ); + } + + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + const kernelMessagePointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + const kernelView = new DataView(harness.kernelBytes.buffer); + const kernelControlPointer = kernelView.getUint32( + kernelMessagePointer + KERNEL_MSGHDR_WIRE_CONTROL_OFFSET, + true, + ); + expect( + kernelView.getUint32( + kernelMessagePointer + + KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET, + true, + ), + ).toBe(wireCapacity); + expect(kernelControlPointer === 0).toBe(wireCapacity === 0); + const wire = descriptors.length > 0 + ? canonicalRightsBytes([descriptors]) + : new Uint8Array(0); + if (wire.length > 0) { + harness.kernelBytes.set(wire, kernelControlPointer); + } + kernelView.setUint32( + kernelMessagePointer + + KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET, + wire.length, + true, + ); + kernelView.setUint32( + kernelMessagePointer + KERNEL_MSGHDR_WIRE_FLAGS_OFFSET, + flags, + true, + ); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + harness.worker.handleRecvmsg( + harness.channel, + [7, messagePointer, 0, 0, 0, 0], + ); + + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)) + .toEqual([0, 0]); + expect( + processView.getUint32( + messagePointer + messageControlLengthOffset, + true, + ), + ).toBe(reportedLength); + if (pointerWidth === 8) { + expect( + processView.getUint32( + messagePointer + PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET + 4, + true, + ), + ).toBe(0xa5a5_a5a5); + } + expect( + processView.getUint32( + messagePointer + messageFlagsOffset, + true, + ), + ).toBe(flags); + if (descriptors.length === 0) { + expect( + harness.processBytes.slice( + controlPointer, + controlPointer + capacity, + ), + ).toEqual(new Uint8Array(capacity).fill(controlCanary)); + } else { + const nativeLength = native.dataOffset + + descriptors.length * SCM_RIGHTS_FD_BYTES; + expect( + processView.getUint32( + controlPointer + native.lengthOffset, + true, + ), + ).toBe(nativeLength); + expect( + processView.getUint32( + controlPointer + native.levelOffset, + true, + ), + ).toBe(SOCKET_SOL_SOCKET); + expect( + processView.getUint32( + controlPointer + native.typeOffset, + true, + ), + ).toBe(SOCKET_SCM_RIGHTS); + if (pointerWidth === 8) { + expect( + harness.processBytes.slice( + controlPointer + PROCESS_CMSGHDR_WASM64_LEN_OFFSET + 4, + controlPointer + PROCESS_CMSGHDR_WASM64_LEVEL_OFFSET, + ), + ).toEqual(new Uint8Array(4)); + } + descriptors.forEach((descriptor, index) => { + expect( + processView.getInt32( + controlPointer + native.dataOffset + + index * SCM_RIGHTS_FD_BYTES, + true, + ), + ).toBe(descriptor); + }); + expect( + harness.processBytes.slice( + controlPointer + nativeLength, + controlPointer + reportedLength, + ), + ).toEqual(new Uint8Array(reportedLength - nativeLength)); + } + expect(harness.processBytes[controlPointer + reportedLength]) + .toBe(controlCanary); + expectScratchTailUntouched(harness); + }, + ); + + it("rejects recvmsg canonical control capacity plus one without guest writes", () => { + const harness = makeScratchHarness(8); + const messagePointer = 128; + const iovecPointer = 512; + const namePointer = 1024; + const controlPointer = 2048; + const dataPointer = 4096; + const controlCapacity = 20; + writeNativeIovec( + harness.processBytes, + 8, + iovecPointer, + dataPointer, + 4, + ); + writeNativeMessage(harness.processBytes, 8, messagePointer, { + namePointer, + nameLength: 4, + iovecPointer, + iovecCount: 1, + controlPointer, + controlLength: controlCapacity, + flags: 0x1122_3344, + }); + harness.processBytes.fill(0x51, namePointer, namePointer + 4); + harness.processBytes.fill( + 0x52, + controlPointer, + controlPointer + controlCapacity, + ); + harness.processBytes.fill(0x53, dataPointer, dataPointer + 4); + const messageBefore = harness.processBytes.slice( + messagePointer, + messagePointer + PROCESS_MSGHDR_WASM64_SIZE, + ); + const nameBefore = harness.processBytes.slice(namePointer, namePointer + 4); + const controlBefore = harness.processBytes.slice( + controlPointer, + controlPointer + controlCapacity, + ); + const dataBefore = harness.processBytes.slice(dataPointer, dataPointer + 4); + + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + const kernelMessagePointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + const kernelView = new DataView(harness.kernelBytes.buffer); + const kernelNamePointer = kernelView.getUint32( + kernelMessagePointer + KERNEL_MSGHDR_WIRE_NAME_OFFSET, + true, + ); + const kernelControlPointer = kernelView.getUint32( + kernelMessagePointer + KERNEL_MSGHDR_WIRE_CONTROL_OFFSET, + true, + ); + const kernelIovecPointer = kernelView.getUint32( + kernelMessagePointer + KERNEL_MSGHDR_WIRE_IOV_OFFSET, + true, + ); + const kernelDataPointer = kernelView.getUint32( + kernelIovecPointer + KERNEL_IOVEC_WIRE_BASE_OFFSET, + true, + ); + harness.kernelBytes.set( + new TextEncoder().encode("name"), + kernelNamePointer, + ); + harness.kernelBytes.set( + canonicalRightsBytes([[81]]), + kernelControlPointer, + ); + harness.kernelBytes.set( + new TextEncoder().encode("data"), + kernelDataPointer, + ); + kernelView.setUint32( + kernelMessagePointer + KERNEL_MSGHDR_WIRE_NAMELEN_OFFSET, + 4, + true, + ); + kernelView.setUint32( + kernelMessagePointer + KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET, + 17, + true, + ); + kernelView.setUint32( + kernelMessagePointer + KERNEL_MSGHDR_WIRE_FLAGS_OFFSET, + 0x40, + true, + ); + channelView.setBigInt64(CH_RETURN, 4n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + harness.worker.handleRecvmsg( + harness.channel, + [7, messagePointer, 0, 0, 0, 0], + ); + + expect(harness.completeChannel).not.toHaveBeenCalled(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + EIO, + ); + expect( + harness.processBytes.slice( + messagePointer, + messagePointer + PROCESS_MSGHDR_WASM64_SIZE, + ), + ).toEqual(messageBefore); + expect(harness.processBytes.slice(namePointer, namePointer + 4)) + .toEqual(nameBefore); + expect( + harness.processBytes.slice( + controlPointer, + controlPointer + controlCapacity, + ), + ).toEqual(controlBefore); + expect(harness.processBytes.slice(dataPointer, dataPointer + 4)) + .toEqual(dataBefore); + expectScratchTailUntouched(harness); + }); + + it("rejects a wasm64 iovec pointer that cannot be represented losslessly", () => { + const harness = makeScratchHarness(8); + const iovPtr = 256; + const view = new DataView(harness.processBytes.buffer); + view.setBigUint64(iovPtr, BigInt(Number.MAX_SAFE_INTEGER) + 1n, true); + view.setBigUint64(iovPtr + 8, 1n, true); + + harness.worker.handleWritev( + harness.channel, + ABI_SYSCALLS.Writev, + [7, iovPtr, 1, 0, 0, 0], + ); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + EFAULT, + ); + expectScratchTailUntouched(harness); + }); + + it.each([ + ["sendmsg", "handleSendmsg"], + ["recvmsg", "handleRecvmsg"], + ] as const)( + "validates the complete 56-byte wasm64 %s msghdr", + (_name, method) => { + const harness = makeScratchHarness(8); + const msgPtr = harness.processBytes.byteLength - 48; + + harness.worker[method]( + harness.channel, + [7, msgPtr, 0, 0, 0, 0], + ); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + EFAULT, + ); + expectScratchTailUntouched(harness); + }, + ); + + it("writes wasm64 recvmsg flags after musl's msg_controllen padding", () => { + const harness = makeScratchHarness(8); + const msgPtr = 128; + const controlPtr = 2048; + const view = new DataView(harness.processBytes.buffer); + writeNativeMessage(harness.processBytes, 8, msgPtr, { + controlPointer: controlPtr, + controlLength: 8, + }); + view.setUint32( + msgPtr + PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET + 4, + 0xa5a5_a5a5, + true, + ); + harness.handleChannel.mockImplementation(() => { + const channelView = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + const kernelMessagePointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + const kernelMessage = new DataView( + harness.kernelBytes.buffer, + kernelMessagePointer, + STRUCT_SIZE_KERNEL_MSGHDR_WIRE, + ); + kernelMessage.setUint32( + KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET, + 0, + true, + ); + kernelMessage.setUint32(KERNEL_MSGHDR_WIRE_FLAGS_OFFSET, 0x40, true); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + harness.worker.handleRecvmsg( + harness.channel, + [7, msgPtr, 0, 0, 0, 0], + ); + + expect( + view.getUint32( + msgPtr + PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET, + true, + ), + ).toBe(0); + expect( + view.getUint32( + msgPtr + PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET + 4, + true, + ), + ).toBe(0xa5a5_a5a5); + expect( + view.getUint32(msgPtr + PROCESS_MSGHDR_WASM64_FLAGS_OFFSET, true), + ).toBe(0x40); + expectScratchTailUntouched(harness); + }); + + it.each([ + ["writev", "handleWritev", ABI_SYSCALLS.Writev], + ["readv", "handleReadv", ABI_SYSCALLS.Readv], + ] as const)( + "rejects an out-of-range nested buffer in the %s slow path", + (_name, method, syscallNr) => { + const harness = makeScratchHarness(); + const iovPtr = 256; + writeWasm32Iovecs(harness.processBytes, iovPtr, [{ + base: harness.processBytes.byteLength - 8, + len: CH_DATA_SIZE + 1, + }]); + + harness.worker[method]( + harness.channel, + syscallNr, + [7, iovPtr, 1, 0, 0, 0], + ); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + EFAULT, + ); + expectScratchTailUntouched(harness); + }, + ); + + it("preserves an unsigned low offset word in the preadv slow path", () => { + const harness = makeScratchHarness(); + const iovPtr = 256; + const destination = 65_536; + writeWasm32Iovecs(harness.processBytes, iovPtr, [{ + base: destination, + len: CH_DATA_SIZE + 1, + }]); + const offsets: Array<{ low: number; high: number }> = []; + harness.handleChannel.mockImplementation(() => { + const view = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + offsets.push({ + low: Number(BigInt.asUintN( + 32, + view.getBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, true), + )), + high: Number(view.getBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, true)), + }); + view.setBigInt64(CH_RETURN, 0n, true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + harness.worker.handleReadv( + harness.channel, + ABI_SYSCALLS.Preadv, + [7, iovPtr, 1, 0x8000_0000, 0, 0], + ); + + expect(offsets).toEqual([{ low: 0x8000_0000, high: 0 }]); + expectScratchTailUntouched(harness); + }); + + it.each([ + ["pwritev", "handleWritev", ABI_SYSCALLS.Pwritev], + ["preadv", "handleReadv", ABI_SYSCALLS.Preadv], + ] as const)( + "preserves a %s slow-path offset above Number.MAX_SAFE_INTEGER", + (_name, method, syscallNr) => { + const harness = makeScratchHarness(); + const iovPtr = 256; + const buffer = 65_536; + writeWasm32Iovecs(harness.processBytes, iovPtr, [{ + base: buffer, + len: CH_DATA_SIZE + 1, + }]); + const offsets: bigint[] = []; + harness.handleChannel.mockImplementation(() => { + const channelView = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + const low = channelView.getBigInt64( + CH_ARGS + 3 * CH_ARG_SIZE, + true, + ); + const high = channelView.getBigInt64( + CH_ARGS + 4 * CH_ARG_SIZE, + true, + ); + offsets.push((high << 32n) | BigInt.asUintN(32, low)); + const kernelIovec = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + const len = new DataView(harness.kernelBytes.buffer).getUint32( + kernelIovec + 4, + true, + ); + channelView.setBigInt64(CH_RETURN, BigInt(len), true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + harness.worker[method]( + harness.channel, + syscallNr, + [7, iovPtr, 1, 1, 0x0020_0000, 0], + ); + + const initialOffset = 9_007_199_254_740_993n; + expect(offsets).toEqual([ + initialOffset, + initialOffset + BigInt(CH_DATA_SIZE - 8), + ]); + expectScratchTailUntouched(harness); + }, + ); + + it("normalizes the wasm64 preadv low word before Number conversion", () => { + const harness = makeScratchHarness(8); + prepareGenericSyscallHarness(harness, 8); + const iovPtr = 256; + const destination = 65_536; + writeNativeIovec( + harness.processBytes, + 8, + iovPtr, + destination, + CH_DATA_SIZE + 1, + ); + const offsets: bigint[] = []; + harness.handleChannel.mockImplementation(() => { + const channelView = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + const low = channelView.getBigInt64( + CH_ARGS + 3 * CH_ARG_SIZE, + true, + ); + const high = channelView.getBigInt64( + CH_ARGS + 4 * CH_ARG_SIZE, + true, + ); + offsets.push((high << 32n) | BigInt.asUintN(32, low)); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + const offset = 9_007_199_254_740_993n; + writeChannelSyscall(harness, ABI_SYSCALLS.Preadv, [ + 7n, + BigInt(iovPtr), + 1n, + offset, + offset >> 32n, + ]); + + harness.worker._handleSyscallInner(harness.channel); + + expect(offsets).toEqual([offset]); + expectScratchTailUntouched(harness); + }); + + it("rejects a writev result larger than the staged caller data", () => { + const harness = makeScratchHarness(); + const iovPtr = 256; + const source = 1024; + writeWasm32Iovecs(harness.processBytes, iovPtr, [{ + base: source, + len: 4, + }]); + harness.processBytes.set([1, 2, 3, 4], source); + harness.handleChannel.mockImplementation(() => { + const view = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + view.setBigInt64(CH_RETURN, 5n, true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + harness.worker.handleWritev( + harness.channel, + ABI_SYSCALLS.Writev, + [7, iovPtr, 1, 0, 0, 0], + ); + + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + ABI_SYSCALLS.Writev, + [7, iovPtr, 1, 0, 0, 0], + undefined, + -1, + EIO, + ); + expectScratchTailUntouched(harness); + }); + + it.each([ + ["pwritev2", "handleWritev", ABI_SYSCALLS.Pwritev2], + ["preadv2", "handleReadv", ABI_SYSCALLS.Preadv2], + ] as const)( + "preserves %s offset words and flags on the fast path", + (_name, method, syscallNr) => { + const harness = makeScratchHarness(); + const iovPtr = 256; + const buffer = 1024; + const flags = 0x8000_0000; + writeWasm32Iovecs(harness.processBytes, iovPtr, [{ + base: buffer, + len: 4, + }]); + harness.processBytes.set([1, 2, 3, 4], buffer); + const calls: Array<{ + syscall: number; + low: bigint; + high: bigint; + flags: bigint; + }> = []; + harness.handleChannel.mockImplementation(() => { + const view = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + calls.push({ + syscall: view.getUint32(CH_SYSCALL, true), + low: view.getBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, true), + high: view.getBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, true), + flags: view.getBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, true), + }); + view.setBigInt64(CH_RETURN, 4n, true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + harness.worker[method]( + harness.channel, + syscallNr, + [7, iovPtr, 1, 0x8000_0000, 1, flags], + ); + + expect(calls).toEqual([{ + syscall: syscallNr, + low: 0x8000_0000n, + high: 1n, + flags: BigInt(flags), + }]); + expectScratchTailUntouched(harness); + }, + ); + + it.each([ + ["pwritev2", "handleWritev", ABI_SYSCALLS.Pwritev2], + ["preadv2", "handleReadv", ABI_SYSCALLS.Preadv2], + ] as const)( + "preserves %s flags across every capacity-bounded slow-path chunk", + (_name, method, syscallNr) => { + const harness = makeScratchHarness(); + const iovPtr = 256; + const buffer = 65_536; + const flags = 0x4000_0000; + writeWasm32Iovecs(harness.processBytes, iovPtr, [{ + base: buffer, + len: CH_DATA_SIZE + 1, + }]); + const calls: Array<{ syscall: number; flags: bigint }> = []; + harness.handleChannel.mockImplementation(() => { + const view = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + calls.push({ + syscall: view.getUint32(CH_SYSCALL, true), + flags: view.getBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, true), + }); + const kernelIovec = Number( + view.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + const len = new DataView(harness.kernelBytes.buffer).getUint32( + kernelIovec + 4, + true, + ); + view.setBigInt64(CH_RETURN, BigInt(len), true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + harness.worker[method]( + harness.channel, + syscallNr, + [7, iovPtr, 1, 0, 0, flags], + ); + + expect(calls.length).toBeGreaterThan(1); + expect(calls).toEqual(calls.map(() => ({ + syscall: syscallNr, + flags: BigInt(flags), + }))); + expectScratchTailUntouched(harness); + }, + ); + + it.each([ + ["wasm32", 4, "pwrite", ABI_SYSCALLS.Pwrite], + ["wasm32", 4, "pread", ABI_SYSCALLS.Pread], + ["wasm64", 8, "pwrite", ABI_SYSCALLS.Pwrite], + ["wasm64", 8, "pread", ABI_SYSCALLS.Pread], + ] as const)( + "preserves a %s %s offset above Number.MAX_SAFE_INTEGER on the ordinary path", + (_widthName, pointerWidth, _name, syscallNr) => { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + const buffer = 65_536; + const offset = 9_007_199_254_740_993n; + const offsets: bigint[] = []; + harness.handleChannel.mockImplementation(() => { + const channelView = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + offsets.push( + channelView.getBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, true), + ); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + writeChannelSyscall(harness, syscallNr, [ + 7n, + BigInt(buffer), + 4n, + offset, + ]); + + harness.worker._handleSyscallInner(harness.channel); + + expect(offsets).toEqual([offset]); + expectScratchTailUntouched(harness); + }, + ); + + it.each([ + ["wasm32", 4, "pwrite", ABI_SYSCALLS.Pwrite], + ["wasm32", 4, "pread", ABI_SYSCALLS.Pread], + ["wasm64", 8, "pwrite", ABI_SYSCALLS.Pwrite], + ["wasm64", 8, "pread", ABI_SYSCALLS.Pread], + ] as const)( + "preserves and increments a %s large %s offset above Number.MAX_SAFE_INTEGER", + (_widthName, pointerWidth, _name, syscallNr) => { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + const buffer = 65_536; + const totalLength = CH_DATA_SIZE + 1; + const initialOffset = 9_007_199_254_740_993n; + const preparedOffsets: bigint[] = []; + Object.assign(harness.worker.kernelInstance.exports, { + kernel_prepare_write_operation: vi.fn(( + _pid: number, + _tid: number, + _fd: number, + offset: bigint, + len: number, + ) => { + preparedOffsets.push(offset); + return BigInt(len); + }), + }); + const offsets: bigint[] = []; + harness.handleChannel.mockImplementation(() => { + const channelView = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + offsets.push( + channelView.getBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, true), + ); + const chunkLength = Number( + channelView.getBigInt64( + CH_ARGS + 2 * CH_ARG_SIZE, + true, + ), + ); + channelView.setBigInt64(CH_RETURN, BigInt(chunkLength), true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + writeChannelSyscall(harness, syscallNr, [ + 7n, + BigInt(buffer), + BigInt(totalLength), + initialOffset, + ]); + + harness.worker._handleSyscallInner(harness.channel); + + expect(offsets).toEqual([ + initialOffset, + initialOffset + BigInt(CH_DATA_SIZE), + ]); + expect(preparedOffsets).toEqual( + syscallNr === ABI_SYSCALLS.Pwrite ? [initialOffset] : [], + ); + expectScratchTailUntouched(harness); + }, + ); + + it("rejects an out-of-range source before a large write kernel call", () => { + const harness = makeScratchHarness(); + const source = harness.processBytes.byteLength - 8; + + harness.worker.handleLargeWrite( + harness.channel, + ABI_SYSCALLS.Write, + [7, source, CH_DATA_SIZE + 1, 0, 0, 0], + [7n, BigInt(source), BigInt(CH_DATA_SIZE + 1), 0n, 0n, 0n], + ); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + EFAULT, + ); + expectScratchTailUntouched(harness); + }); + + it("rejects an out-of-range destination before a large read kernel call", () => { + const harness = makeScratchHarness(); + const destination = harness.processBytes.byteLength - 8; + + harness.worker.handleLargeRead( + harness.channel, + ABI_SYSCALLS.Read, + [7, destination, CH_DATA_SIZE + 1, 0, 0, 0], + [7n, BigInt(destination), BigInt(CH_DATA_SIZE + 1), 0n, 0n, 0n], + ); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + EFAULT, + ); + expectScratchTailUntouched(harness); + }); + + it.each([ + ["select", "handleSelect", ABI_SYSCALLS.Select], + ["pselect6", "handlePselect6", ABI_SYSCALLS.Pselect6], + ] as const)( + "rejects an out-of-range %s fd_set before copying it", + (_name, method, _syscall) => { + const harness = makeScratchHarness(); + const invalidSet = harness.processBytes.byteLength - 4; + + expect(() => harness.worker[method]( + harness.channel, + [1, invalidSet, 0, 0, 0, 0], + )).not.toThrow(); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + EFAULT, + ); + expectScratchTailUntouched(harness); + }, + ); + + it("rejects an out-of-range epoll_ctl event before copying it", () => { + const harness = makeScratchHarness(); + const invalidEvent = + harness.processBytes.byteLength - STRUCT_SIZE_WASM_EPOLL_EVENT + 1; + + expect(() => harness.worker.handleEpollCtl( + harness.channel, + [3, 1, 7, invalidEvent, 0, 0], + )).not.toThrow(); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + EFAULT, + ); + expectScratchTailUntouched(harness); + }); + + it("copies an exact-size epoll_ctl record with data at offset eight", () => { + const harness = makeScratchHarness(); + const eventPointer = + harness.processBytes.byteLength - STRUCT_SIZE_WASM_EPOLL_EVENT; + const processView = new DataView(harness.processBytes.buffer); + const expectedData = 0x0102_0304_0506_0708n; + processView.setUint32( + eventPointer + WASM_EPOLL_EVENT_EVENTS_OFFSET, + 0x1234, + true, + ); + processView.setUint32( + eventPointer + WASM_EPOLL_EVENT_PAD_OFFSET, + 0xa5a5_a5a5, + true, + ); + processView.setBigUint64( + eventPointer + WASM_EPOLL_EVENT_DATA_OFFSET, + expectedData, + true, + ); + harness.handleChannel.mockImplementation(() => { + const channelView = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + const kernelEvent = Number( + channelView.getBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, true), + ); + expect( + harness.kernelBytes.slice( + kernelEvent, + kernelEvent + STRUCT_SIZE_WASM_EPOLL_EVENT, + ), + ).toEqual( + harness.processBytes.slice( + eventPointer, + eventPointer + STRUCT_SIZE_WASM_EPOLL_EVENT, + ), + ); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + harness.worker.handleEpollCtl( + harness.channel, + [3, 1, 7, eventPointer, 0, 0], + [3n, 1n, 7n, BigInt(eventPointer), 0n, 0n], + ); + + expect(harness.handleChannel).toHaveBeenCalledTimes(1); + expect(harness.worker.epollInterests.get("41:3")).toEqual([{ + fd: 7, + events: 0x1234, + data: expectedData, + }]); + expectScratchTailUntouched(harness); + }); + + it("rejects an out-of-range epoll output array before polling", () => { + const harness = makeScratchHarness(); + const invalidEvents = + harness.processBytes.byteLength - STRUCT_SIZE_WASM_EPOLL_EVENT + 1; + harness.worker.epollInterests.set("41:3", [{ + fd: 7, + events: 1, + data: 9n, + }]); + + expect(() => harness.worker.handleEpollPwait( + harness.channel, + ABI_SYSCALLS.EpollPwait, + [3, invalidEvents, 1, 0, 0, 0], + )).not.toThrow(); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + EFAULT, + ); + expectScratchTailUntouched(harness); + }); + + it("writes one exact-size epoll result with zero padding and data at offset eight", () => { + const harness = makeScratchHarness(); + const eventsPointer = + harness.processBytes.byteLength - STRUCT_SIZE_WASM_EPOLL_EVENT; + const expectedData = 0x1122_3344_5566_7788n; + harness.processBytes.fill( + 0xa5, + eventsPointer, + eventsPointer + STRUCT_SIZE_WASM_EPOLL_EVENT, + ); + harness.worker.epollInterests.set("41:3", [{ + fd: 7, + events: 1, + data: expectedData, + }]); + harness.handleChannel.mockImplementation(() => { + const channelView = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + const pollfdsPointer = Number( + channelView.getBigInt64(CH_ARGS, true), + ); + new DataView(harness.kernelBytes.buffer).setInt16( + pollfdsPointer + 6, + 1, + true, + ); + channelView.setBigInt64(CH_RETURN, 1n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + harness.worker.handleEpollPwait( + harness.channel, + ABI_SYSCALLS.EpollPwait, + [3, eventsPointer, 1, 0, 0, 0], + [3n, BigInt(eventsPointer), 1n, 0n, 0n, 0n], + ); + + const output = new DataView( + harness.processBytes.buffer, + eventsPointer, + STRUCT_SIZE_WASM_EPOLL_EVENT, + ); + expect(output.getUint32(WASM_EPOLL_EVENT_EVENTS_OFFSET, true)).toBe(1); + expect(output.getUint32(WASM_EPOLL_EVENT_PAD_OFFSET, true)).toBe(0); + expect(output.getBigUint64(WASM_EPOLL_EVENT_DATA_OFFSET, true)) + .toBe(expectedData); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + 1, + 0, + ); + expectScratchTailUntouched(harness); + }); + + it.each([ + [4, 0x1020_3040n], + [8, 0x0102_0304_0506_0708n], + ] as const)( + "translates an exact-end wasm%s msgsnd buffer to the canonical i64 header", + (pointerWidth, mtype) => { + const harness = makeScratchHarness(pointerWidth); + const text = Uint8Array.of(0x61, 0x62, 0x63); + const messagePointer = + harness.processBytes.byteLength - pointerWidth - text.byteLength; + const processView = new DataView(harness.processBytes.buffer); + if (pointerWidth === 8) { + processView.setBigInt64(messagePointer, mtype, true); + } else { + processView.setInt32(messagePointer, Number(mtype), true); + } + harness.processBytes.set(text, messagePointer + pointerWidth); + harness.handleChannel.mockImplementation(() => { + const channelView = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + const kernelMessage = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + const kernelView = new DataView(harness.kernelBytes.buffer); + expect(kernelView.getBigInt64(kernelMessage, true)).toBe(mtype); + expect( + harness.kernelBytes.slice( + kernelMessage + STRUCT_SIZE_WASM_SYSV_MESSAGE_HEADER, + kernelMessage + STRUCT_SIZE_WASM_SYSV_MESSAGE_HEADER + + text.byteLength, + ), + ).toEqual(text); + expect( + Number(channelView.getBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, true)), + ).toBe(pointerWidth); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + harness.worker.handleSysvMessage( + harness.channel, + ABI_SYSCALLS.Msgsnd, + [3, messagePointer, text.byteLength, 0, 0, 0], + [ + 3n, + BigInt(messagePointer), + BigInt(text.byteLength), + 0n, + 0n, + 0n, + ], + ); + + expect(harness.handleChannel).toHaveBeenCalledTimes(1); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + ABI_SYSCALLS.Msgsnd, + [3, messagePointer, text.byteLength, 0, 0, 0], + undefined, + 0, + 0, + undefined, + ); + expectScratchTailUntouched(harness); + }, + ); + + it.each([ + [4, "msgsnd", ABI_SYSCALLS.Msgsnd], + [8, "msgsnd", ABI_SYSCALLS.Msgsnd], + [4, "msgrcv", ABI_SYSCALLS.Msgrcv], + [8, "msgrcv", ABI_SYSCALLS.Msgrcv], + ] as const)( + "rejects a one-byte-short wasm%s %s caller message range", + (pointerWidth, _name, syscallNr) => { + const harness = makeScratchHarness(pointerWidth); + const textBytes = 3; + const pointer = + harness.processBytes.byteLength - pointerWidth - textBytes + 1; + const origArgs = syscallNr === ABI_SYSCALLS.Msgsnd + ? [3, pointer, textBytes, 0, 0, 0] + : [3, pointer, textBytes, 0, 0, 0]; + + harness.worker.handleSysvMessage( + harness.channel, + syscallNr, + origArgs, + [3n, BigInt(pointer), BigInt(textBytes), 0n, 0n, 0n], + ); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + EFAULT, + ); + expectScratchTailUntouched(harness); + }, + ); + + it.each([ + [4, 0x1234_5678n], + [8, 0x0102_0304_0506_0708n], + ] as const)( + "translates canonical msgrcv output to a wasm%s native-long prefix", + (pointerWidth, mtype) => { + const harness = makeScratchHarness(pointerWidth); + const messagePointer = 4096; + const text = Uint8Array.of(0x71, 0x72, 0x73); + const selectedType = + pointerWidth === 8 ? 0x0102_0304_0506_0708n : 7n; + harness.handleChannel.mockImplementation(() => { + const channelView = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + expect( + channelView.getBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, true), + ).toBe(selectedType); + const kernelMessage = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + const kernelView = new DataView(harness.kernelBytes.buffer); + kernelView.setBigInt64(kernelMessage, mtype, true); + harness.kernelBytes.set( + text, + kernelMessage + STRUCT_SIZE_WASM_SYSV_MESSAGE_HEADER, + ); + channelView.setBigInt64(CH_RETURN, BigInt(text.byteLength), true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + harness.worker.handleSysvMessage( + harness.channel, + ABI_SYSCALLS.Msgrcv, + [3, messagePointer, text.byteLength, Number(selectedType), 0, 0], + [ + 3n, + BigInt(messagePointer), + BigInt(text.byteLength), + selectedType, + 0n, + 0n, + ], + ); + + const outputWrites = harness.completeChannel.mock.calls[0]?.[6] as + | Array<{ ptr: number; bytes: Uint8Array }> + | undefined; + expect(outputWrites).toHaveLength(1); + expect(outputWrites?.[0]?.ptr).toBe(messagePointer); + const output = outputWrites![0]!.bytes; + const outputView = new DataView( + output.buffer, + output.byteOffset, + output.byteLength, + ); + expect( + pointerWidth === 8 + ? outputView.getBigInt64(0, true) + : BigInt(outputView.getInt32(0, true)), + ).toBe(mtype); + expect(output.subarray(pointerWidth)).toEqual(text); + expectScratchTailUntouched(harness); + }, + ); + + it("accepts exact SysV scratch capacity and rejects capacity plus one", () => { + const exact = CH_DATA_SIZE - STRUCT_SIZE_WASM_SYSV_MESSAGE_HEADER; + for (const messageSize of [exact, exact + 1]) { + const harness = makeScratchHarness(8); + const messagePointer = 4096; + new DataView(harness.processBytes.buffer).setBigInt64( + messagePointer, + 1n, + true, + ); + harness.handleChannel.mockImplementation(() => { + const channelView = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + harness.worker.handleSysvMessage( + harness.channel, + ABI_SYSCALLS.Msgsnd, + [3, messagePointer, messageSize, 0, 0, 0], + [3n, BigInt(messagePointer), BigInt(messageSize), 0n, 0n, 0n], + ); + + expect(harness.handleChannel).toHaveBeenCalledTimes( + messageSize === exact ? 1 : 0, + ); + if (messageSize !== exact) { + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + EINVAL, + ); + } + expectScratchTailUntouched(harness); + } + }); + + it("returns IPC_NOWAIT EAGAIN instead of parking a SysV message retry", () => { + const harness = makeScratchHarness(8); + const messagePointer = 4096; + harness.handleChannel.mockImplementation(() => { + const channelView = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + channelView.setBigInt64(CH_RETURN, -1n, true); + channelView.setUint32(CH_ERRNO, EAGAIN, true); + return 0; + }); + + harness.worker.handleSysvMessage( + harness.channel, + ABI_SYSCALLS.Msgrcv, + [3, messagePointer, 0, 0, IPC_NOWAIT, 0], + [3n, BigInt(messagePointer), 0n, 0n, BigInt(IPC_NOWAIT), 0n], + ); + + expect(harness.worker.handleBlockingRetry).not.toHaveBeenCalled(); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + ABI_SYSCALLS.Msgrcv, + [3, messagePointer, 0, 0, IPC_NOWAIT, 0], + undefined, + -1, + EAGAIN, + undefined, + ); + expectScratchTailUntouched(harness); + }); + + it.each([ + ["msgctl wasm32", ABI_SYSCALLS.Msgctl, "kernel_msqid_ds_bytes", 4, 96], + ["msgctl wasm64", ABI_SYSCALLS.Msgctl, "kernel_msqid_ds_bytes", 8, 120], + ["shmctl wasm32", ABI_SYSCALLS.Shmctl, "kernel_shmid_ds_bytes", 4, 88], + ["shmctl wasm64", ABI_SYSCALLS.Shmctl, "kernel_shmid_ds_bytes", 8, 112], + ] as const)( + "rejects a one-byte-short %s IPC_STAT destination before scratch use", + (_name, syscallNr, exportName, pointerWidth, bytes) => { + const harness = makeScratchHarness(pointerWidth); + const invalidBuffer = harness.processBytes.byteLength - bytes + 1; + const statBytes = vi.fn(() => bytes); + Object.assign(harness.worker.kernelInstance.exports, { + [exportName]: statBytes, + }); + + harness.worker.handleIpcControl( + harness.channel, + syscallNr, + [3, 2, invalidBuffer, 0, 0, 0], + [3n, 2n, BigInt(invalidBuffer), 0n, 0n, 0n], + ); + + expect(statBytes).toHaveBeenCalledWith(pointerWidth); + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + EFAULT, + ); + expectScratchTailUntouched(harness); + }, + ); + + it.each([ + ["msgctl wasm32", ABI_SYSCALLS.Msgctl, "kernel_msqid_ds_bytes", 4, 96], + ["msgctl wasm64", ABI_SYSCALLS.Msgctl, "kernel_msqid_ds_bytes", 8, 120], + ["shmctl wasm32", ABI_SYSCALLS.Shmctl, "kernel_shmid_ds_bytes", 4, 88], + ["shmctl wasm64", ABI_SYSCALLS.Shmctl, "kernel_shmid_ds_bytes", 8, 112], + ] as const)( + "copies the exact kernel-sized %s IPC_STAT result", + (_name, syscallNr, exportName, pointerWidth, bytes) => { + const harness = makeScratchHarness(pointerWidth); + const outputPointer = 4096; + const expected = Uint8Array.from( + { length: bytes }, + (_, index) => (index * 13) & 0xff, + ); + const statBytes = vi.fn(() => bytes); + Object.assign(harness.worker.kernelInstance.exports, { + [exportName]: statBytes, + }); + harness.handleChannel.mockImplementation(() => { + const channelView = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + expect(channelView.getUint32(CH_SYSCALL, true)).toBe(syscallNr); + expect( + Number( + channelView.getBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, true), + ), + ).toBe(pointerWidth); + const dataPointer = Number( + channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true), + ); + harness.kernelBytes.set(expected, dataPointer); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + harness.worker.handleIpcControl( + harness.channel, + syscallNr, + [3, 2, outputPointer, 0, 0, 0], + [3n, 2n, BigInt(outputPointer), 0n, 0n, 0n], + ); + + expect(statBytes).toHaveBeenCalledWith(pointerWidth); + expect( + harness.processBytes.slice( + outputPointer, + outputPointer + bytes, + ), + ).toEqual(expected); + expectScratchTailUntouched(harness); + }, + ); + + it("stages IPC_SET input without copying scratch back to the caller", () => { + const harness = makeScratchHarness(8); + const inputPointer = 4096; + const bytes = 120; + const input = Uint8Array.from( + { length: bytes }, + (_, index) => (index * 7) & 0xff, + ); + harness.processBytes.set(input, inputPointer); + Object.assign(harness.worker.kernelInstance.exports, { + kernel_msqid_ds_bytes: vi.fn(() => bytes), + }); + harness.handleChannel.mockImplementation(() => { + const channelView = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + const dataPointer = Number( + channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true), + ); + expect( + harness.kernelBytes.slice(dataPointer, dataPointer + bytes), + ).toEqual(input); + harness.kernelBytes.fill(0xee, dataPointer, dataPointer + bytes); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + harness.worker.handleIpcControl( + harness.channel, + ABI_SYSCALLS.Msgctl, + [3, 1, inputPointer, 0, 0, 0], + [3n, 1n, BigInt(inputPointer), 0n, 0n, 0n], + ); + + expect( + harness.processBytes.slice(inputPointer, inputPointer + bytes), + ).toEqual(input); + expectScratchTailUntouched(harness); + }); + + it("rejects invalid or missing IPC control sizing exports", () => { + for (const configuredSize of [undefined, CH_DATA_SIZE + 1]) { + const harness = makeScratchHarness(); + if (configuredSize !== undefined) { + Object.assign(harness.worker.kernelInstance.exports, { + kernel_msqid_ds_bytes: vi.fn(() => configuredSize), + }); + } + + harness.worker.handleIpcControl( + harness.channel, + ABI_SYSCALLS.Msgctl, + [3, 2, 4096, 0, 0, 0], + [3n, 2n, 4096n, 0n, 0n, 0n], + ); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + EIO, + ); + expectScratchTailUntouched(harness); + } + }); + + it("does not pass an unsafe wasm64 IPC control pointer to Rust", () => { + const harness = makeScratchHarness(8); + Object.assign(harness.worker.kernelInstance.exports, { + kernel_shmid_ds_bytes: vi.fn(() => 112), + }); + const unsafePointer = BigInt(Number.MAX_SAFE_INTEGER) + 1n; + + harness.worker.handleIpcControl( + harness.channel, + ABI_SYSCALLS.Shmctl, + [3, 2, Number(unsafePointer), 0, 0, 0], + [3n, 2n, unsafePointer, 0n, 0n, 0n], + ); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + EFAULT, + ); + expectScratchTailUntouched(harness); + }); + + it("dispatches IPC_RMID without a pointer or sizing query", () => { + const harness = makeScratchHarness(8); + harness.handleChannel.mockImplementation(() => { + const channelView = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + expect( + channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true), + ).toBe(0n); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + harness.worker.handleIpcControl( + harness.channel, + ABI_SYSCALLS.Shmctl, + [3, 0, 0, 0, 0, 0], + [3n, 0n, 0n, 0n, 0n, 0n], + ); + + expect(harness.handleChannel).toHaveBeenCalledOnce(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + 0, + 0, + ); + expectScratchTailUntouched(harness); + }); + + it.each([ + ["wasm32 IPC_STAT output", 2, 72, 4], + ["wasm64 IPC_STAT output", 2, 88, 8], + ["GETALL output", 13, 64, 4], + ["SETALL input", 17, 64, 4], + ] as const)( + "rejects an out-of-range semctl %s before touching kernel scratch", + (_name, command, bytes, pointerWidth) => { + const harness = makeScratchHarness(pointerWidth); + const invalidBuffer = harness.processBytes.byteLength - bytes + 1; + const arrayBytes = vi.fn(() => bytes); + const statBytes = vi.fn(() => bytes); + Object.assign(harness.worker.kernelInstance.exports, { + kernel_semctl_array_bytes: arrayBytes, + kernel_semid_ds_bytes: statBytes, + }); + + expect(() => harness.worker.handleSemctl( + harness.channel, + [3, 0, command, invalidBuffer, 0, 0], + )).not.toThrow(); + + if (command === 2) { + expect(arrayBytes).not.toHaveBeenCalled(); + expect(statBytes).toHaveBeenCalledWith(pointerWidth); + } else { + expect(statBytes).not.toHaveBeenCalled(); + expect(arrayBytes).toHaveBeenCalledWith( + harness.channel.pid, + harness.channel.pid, + 3, + command, + ); + } + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + EFAULT, + ); + expectScratchTailUntouched(harness); + }, + ); + + it("uses the permission-aware kernel export to size semctl arrays", () => { + const harness = makeScratchHarness(); + const outputPointer = 4096; + const semaphoreCount = 32; + const outputBytes = semaphoreCount * 2; + const expected = new Uint8Array(outputBytes).map( + (_, index) => index & 0xff, + ); + const arrayBytes = vi.fn(() => outputBytes); + Object.assign(harness.worker.kernelInstance.exports, { + kernel_semctl_array_bytes: arrayBytes, + kernel_semid_ds_bytes: vi.fn(() => 72), + }); + harness.handleChannel.mockImplementation(() => { + const channelView = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + const command = Number( + channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true), + ) & ~0x100; + const dataPointer = Number( + channelView.getBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, true), + ); + expect(command).toBe(13); + expect( + Number( + channelView.getBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, true), + ), + ).toBe(4); + harness.kernelBytes.set(expected, dataPointer); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + harness.worker.handleSemctl( + harness.channel, + [3, 0, 13, outputPointer, 0, 0], + ); + + expect(arrayBytes).toHaveBeenCalledWith( + harness.channel.pid, + harness.channel.pid, + 3, + 13, + ); + expect(harness.handleChannel).toHaveBeenCalledOnce(); + expect( + harness.processBytes.slice( + outputPointer, + outputPointer + outputBytes, + ), + ).toEqual(expected); + expectScratchTailUntouched(harness); + }); + + it("fails closed when a required semctl sizing export is absent", () => { + const harness = makeScratchHarness(); + + harness.worker.handleSemctl( + harness.channel, + [3, 0, 13, 4096, 0, 0], + ); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + EIO, + ); + expectScratchTailUntouched(harness); + }); + + it("rejects a negative generic descriptor length before scratch mutation", () => { + const harness = makeScratchHarness(); + Object.assign(harness.worker, { + config: {}, + syscallRing: new Map(), + syscallTraceEnabled: false, + channelTids: new Map(), + processes: new Map([[harness.channel.pid, { + pid: harness.channel.pid, + memory: harness.channel.memory, + channels: [harness.channel], + ptrWidth: 4, + }]]), + synchronizeSharedMemoryForBoundary: () => {}, + sharedMmapBackings: new Map(), + getProcessExitSignal: () => 0, + }); + const request = new DataView(harness.channel.memory.buffer); + request.setUint32(CH_SYSCALL, ABI_SYSCALLS.Read, true); + request.setBigInt64(CH_ARGS, 7n, true); + request.setBigInt64(CH_ARGS + CH_ARG_SIZE, 1024n, true); + request.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, -1n, true); + + expect(() => harness.worker._handleSyscallInner(harness.channel)) + .not.toThrow(); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + ABI_SYSCALLS.Read, + [7, 1024, -1, 0, 0, 0], + undefined, + -1, + EINVAL, + ); + expectScratchTailUntouched(harness); + }); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "rejects a non-null %s recvfrom address with no capacity pointer", + (_pointerKind, pointerWidth) => { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + const addressPointer = 4096; + writeChannelSyscall(harness, ABI_SYSCALLS.Recvfrom, [ + 7n, + 0n, + 0n, + 0n, + BigInt(addressPointer), + 0n, + ]); + + harness.worker._handleSyscallInner(harness.channel); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + ABI_SYSCALLS.Recvfrom, + [7, 0, 0, 0, addressPointer, 0], + undefined, + -1, + EFAULT, + ); + expectScratchTailUntouched(harness); + }, + ); + + it("uses one captured socklen for recvfrom sizing and staging", () => { + const harness = makeScratchHarness(); + prepareGenericSyscallHarness(harness, 4); + const dataPointer = 72_000; + const dataLength = 65_520; + const addressPointer = 180_000; + const lengthPointer = 220_000; + const initialAddressCapacity = 4; + const mutatedAddressCapacity = 28; + const nativeDataView = globalThis.DataView; + new nativeDataView(harness.processBytes.buffer).setUint32( + lengthPointer, + initialAddressCapacity, + true, + ); + + let capturedReads = 0; + class MutatingDataView extends nativeDataView { + getUint32(byteOffset: number, littleEndian?: boolean): number { + const value = super.getUint32(byteOffset, littleEndian); + if ( + this.buffer === harness.channel.memory.buffer + && byteOffset === lengthPointer + && capturedReads++ === 0 + ) { + // Model a second guest thread changing socklen_t after the sizing + // read. Before the fix, the later byte copy staged 28 even though + // only four address bytes had been reserved at the scratch tail. + new nativeDataView(harness.processBytes.buffer).setUint32( + lengthPointer, + mutatedAddressCapacity, + true, + ); + } + return value; + } + } + + const observedLengths: number[] = []; + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new nativeDataView( + harness.kernelBytes.buffer, + Number(offset), + ); + const stagedAddressPointer = Number( + channelView.getBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, true), + ); + const stagedLengthPointer = Number( + channelView.getBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, true), + ); + const stagedLength = new nativeDataView( + harness.kernelBytes.buffer, + ).getUint32(stagedLengthPointer, true); + observedLengths.push(stagedLength); + expect(stagedAddressPointer).toBe( + harness.scratchOffset + CH_DATA + dataLength, + ); + expect(stagedLengthPointer).toBe( + harness.scratchOffset + CH_DATA + dataLength + 8, + ); + harness.kernelBytes.fill( + 0x5a, + stagedAddressPointer, + stagedAddressPointer + stagedLength, + ); + new nativeDataView(harness.kernelBytes.buffer).setUint32( + stagedLengthPointer, + mutatedAddressCapacity, + true, + ); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + writeChannelSyscall(harness, ABI_SYSCALLS.Recvfrom, [ + 7n, + BigInt(dataPointer), + BigInt(dataLength), + 0n, + BigInt(addressPointer), + BigInt(lengthPointer), + ]); + + vi.stubGlobal("DataView", MutatingDataView); + try { + expect(() => harness.worker._handleSyscallInner(harness.channel)) + .not.toThrow(); + } finally { + vi.unstubAllGlobals(); + } + + expect(capturedReads).toBe(1); + expect(observedLengths).toEqual([initialAddressCapacity]); + expect( + new nativeDataView(harness.processBytes.buffer).getUint32( + lengthPointer, + true, + ), + ).toBe(mutatedAddressCapacity); + expect(harness.handleChannel).toHaveBeenCalledOnce(); + expectScratchTailUntouched(harness); + }); + + it("captures socklen before planning even when generated descriptors reorder", () => { + const harness = makeScratchHarness(); + prepareGenericSyscallHarness(harness, 4); + const addressPointer = 4096; + const lengthPointer = 8192; + const stagedCapacity = 28; + const plannedCapacity = 4; + const nativeDataView = globalThis.DataView; + const processView = new nativeDataView(harness.processBytes.buffer); + processView.setUint32(lengthPointer, stagedCapacity, true); + + const originalDescriptors = SYSCALL_ARGS[ABI_SYSCALLS.Recvfrom]!; + const reorderedDescriptors = [ + originalDescriptors[0]!, + originalDescriptors[2]!, + originalDescriptors[1]!, + ]; + const stagedAddressPointer = harness.scratchOffset + CH_DATA + 8; + harness.kernelBytes.fill( + 0xa5, + stagedAddressPointer + plannedCapacity, + stagedAddressPointer + stagedCapacity, + ); + + let sizingReads = 0; + class MutatingDataView extends nativeDataView { + getUint32(byteOffset: number, littleEndian?: boolean): number { + if ( + this.buffer === harness.channel.memory.buffer + && byteOffset === lengthPointer + && sizingReads++ === 0 + ) { + // With the old order-dependent planner, the preceding fixed + // descriptor had already staged 28. This mutation then planned only + // four address bytes, allowing Rust to observe the larger value. + new nativeDataView(harness.processBytes.buffer).setUint32( + lengthPointer, + plannedCapacity, + true, + ); + } + return super.getUint32(byteOffset, littleEndian); + } + } + + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new nativeDataView( + harness.kernelBytes.buffer, + Number(offset), + ); + const addressScratch = Number( + channelView.getBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, true), + ); + const lengthScratch = Number( + channelView.getBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, true), + ); + expect(addressScratch).toBe(stagedAddressPointer); + expect(lengthScratch).toBe(harness.scratchOffset + CH_DATA); + const rustVisibleCapacity = new nativeDataView( + harness.kernelBytes.buffer, + ).getUint32(lengthScratch, true); + expect(rustVisibleCapacity).toBe(plannedCapacity); + harness.kernelBytes.fill( + 0x5a, + addressScratch, + addressScratch + rustVisibleCapacity, + ); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + writeChannelSyscall(harness, ABI_SYSCALLS.Recvfrom, [ + 7n, + 0n, + 0n, + 0n, + BigInt(addressPointer), + BigInt(lengthPointer), + ]); + + SYSCALL_ARGS[ABI_SYSCALLS.Recvfrom] = reorderedDescriptors; + vi.stubGlobal("DataView", MutatingDataView); + try { + harness.worker._handleSyscallInner(harness.channel); + } finally { + vi.unstubAllGlobals(); + SYSCALL_ARGS[ABI_SYSCALLS.Recvfrom] = originalDescriptors; + } + + expect(sizingReads).toBe(1); + expect(harness.handleChannel).toHaveBeenCalledOnce(); + expect( + harness.kernelBytes.slice( + stagedAddressPointer + plannedCapacity, + stagedAddressPointer + stagedCapacity, + ), + ).toEqual( + new Uint8Array(stagedCapacity - plannedCapacity).fill(0xa5), + ); + expectScratchTailUntouched(harness); + }); + + it("checks ppoll scalar-conversion sources before scratch mutation", () => { + for (const [ptrWidth, timespecPointer, maskPointer] of [ + [4, BigInt(4 * 65_536 - 8), 0n], + [4, 0n, BigInt(4 * 65_536 - 4)], + [8, BigInt(Number.MAX_SAFE_INTEGER) + 1n, 0n], + ] as const) { + const harness = makeScratchHarness(ptrWidth); + Object.assign(harness.worker, { + config: {}, + syscallRing: new Map(), + syscallTraceEnabled: false, + channelTids: new Map(), + processes: new Map([[harness.channel.pid, { + pid: harness.channel.pid, + memory: harness.channel.memory, + channels: [harness.channel], + ptrWidth, + }]]), + synchronizeSharedMemoryForBoundary: () => {}, + sharedMmapBackings: new Map(), + hostReaped: new Set(), + getProcessExitSignal: () => 0, + }); + const request = new DataView(harness.channel.memory.buffer); + request.setUint32(CH_SYSCALL, ABI_SYSCALLS.Ppoll, true); + request.setBigInt64(CH_ARGS, 0n, true); + request.setBigInt64(CH_ARGS + CH_ARG_SIZE, 0n, true); + request.setBigInt64( + CH_ARGS + 2 * CH_ARG_SIZE, + timespecPointer, + true, + ); + request.setBigInt64( + CH_ARGS + 3 * CH_ARG_SIZE, + maskPointer, + true, + ); + request.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, 8n, true); + + expect(() => harness.worker._handleSyscallInner(harness.channel)) + .not.toThrow(); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + EFAULT, + ); + expectScratchTailUntouched(harness); + } + }); + + it("stages generic descriptor input only in the lease that dispatches it", () => { + const harness = makeScratchHarness(); + const inputPointer = 8192; + const input = Uint8Array.from([0x4b, 0x61, 0x6e, 0x64, 0x65, 0x6c, 0x6f]); + harness.processBytes.set(input, inputPointer); + Object.assign(harness.worker, { + config: {}, + syscallRing: new Map(), + syscallTraceEnabled: false, + channelTids: new Map(), + processes: new Map([[harness.channel.pid, { + pid: harness.channel.pid, + memory: harness.channel.memory, + channels: [harness.channel], + ptrWidth: 4, + }]]), + synchronizeSharedMemoryForBoundary: () => {}, + sharedMmapBackings: new Map(), + hostReaped: new Set(), + getProcessExitSignal: () => 0, + }); + + const observed: Uint8Array[] = []; + harness.worker.bindKernelTidForChannel = () => { + // Model a synchronous nested host operation that reused main scratch + // after descriptor planning but before this syscall's dispatch. + harness.worker.scratchRegion.withLease((lease: any) => { + lease.fill(0xcc, CH_DATA, input.byteLength); + }); + harness.processBytes.fill( + 0xee, + inputPointer, + inputPointer + input.byteLength, + ); + }; + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + const dataPointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + observed.push( + new Uint8Array( + harness.kernelBytes.buffer, + dataPointer, + input.byteLength, + ).slice(), + ); + channelView.setBigInt64(CH_RETURN, BigInt(input.byteLength), true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + const request = new DataView(harness.channel.memory.buffer); + request.setUint32(CH_SYSCALL, ABI_SYSCALLS.Write, true); + request.setBigInt64(CH_ARGS, 7n, true); + request.setBigInt64(CH_ARGS + CH_ARG_SIZE, BigInt(inputPointer), true); + request.setBigInt64( + CH_ARGS + 2 * CH_ARG_SIZE, + BigInt(input.byteLength), + true, + ); + + expect(() => harness.worker._handleSyscallInner(harness.channel)) + .not.toThrow(); + + expect(observed).toEqual([input]); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + ABI_SYSCALLS.Write, + [7, inputPointer, input.byteLength, 0, 0, 0], + expect.any(Array), + input.byteLength, + 0, + [], + ); + expectScratchTailUntouched(harness); + }); + + it("copies only getaddrinfo's four-byte result before the caller canary", () => { + const harness = makeScratchHarness(); + const namePointer = 4096; + const resultPointer = 8192; + const result = Uint8Array.from([10, 88, 0, 7]); + const canary = new Uint8Array(252).fill(0x6d); + harness.processBytes.set( + new TextEncoder().encode("example.test\0"), + namePointer, + ); + harness.processBytes.set(canary, resultPointer + result.byteLength); + + Object.assign(harness.worker, { + config: {}, + syscallRing: new Map(), + syscallTraceEnabled: false, + channelTids: new Map(), + processes: new Map([[harness.channel.pid, { + pid: harness.channel.pid, + memory: harness.channel.memory, + channels: [harness.channel], + ptrWidth: 4, + }]]), + synchronizeSharedMemoryForBoundary: () => {}, + sharedMmapBackings: new Map(), + hostReaped: new Set(), + getProcessExitSignal: () => 0, + }); + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + const outputPointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + harness.kernelBytes.set(result, outputPointer); + channelView.setBigInt64(CH_RETURN, 4n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + harness.completeChannel.mockImplementation( + ( + _channel: TestChannel, + _syscallNr: number, + _origArgs: number[], + _argDescs: unknown, + _retVal: number, + _errVal: number, + writes: Array<{ ptr: number; bytes: Uint8Array }>, + ) => { + for (const write of writes) { + harness.processBytes.set(write.bytes, write.ptr); + } + }, + ); + + const request = new DataView(harness.channel.memory.buffer); + request.setUint32(CH_SYSCALL, ABI_SYSCALLS.Getaddrinfo, true); + request.setBigInt64(CH_ARGS, BigInt(namePointer), true); + request.setBigInt64( + CH_ARGS + CH_ARG_SIZE, + BigInt(resultPointer), + true, + ); + + expect(() => harness.worker._handleSyscallInner(harness.channel)) + .not.toThrow(); + + expect( + harness.processBytes.slice(resultPointer, resultPointer + result.length), + ).toEqual(result); + expect( + harness.processBytes.slice( + resultPointer + result.length, + resultPointer + result.length + canary.length, + ), + ).toEqual(canary); + const detachedWrites = harness.completeChannel.mock.calls[0]?.[6] as + Array<{ ptr: number; bytes: Uint8Array }>; + expect(detachedWrites).toHaveLength(1); + expect(detachedWrites[0]?.bytes).toHaveLength(4); + expectScratchTailUntouched(harness); + }); + + it.each([ + ["sendfile offset", ABI_SYSCALLS.Sendfile, 2, 8, [7n, 8n, 0n, 1n]], + [ + "copy_file_range input offset", + ABI_SYSCALLS.CopyFileRange, + 1, + 8, + [7n, 0n, 8n, 0n, 1n, 0n], + ], + [ + "copy_file_range output offset", + ABI_SYSCALLS.CopyFileRange, + 3, + 8, + [7n, 0n, 8n, 0n, 1n, 0n], + ], + [ + "splice input offset", + ABI_SYSCALLS.Splice, + 1, + 8, + [7n, 0n, 8n, 0n, 1n, 0n], + ], + [ + "splice output offset", + ABI_SYSCALLS.Splice, + 3, + 8, + [7n, 0n, 8n, 0n, 1n, 0n], + ], + ["getcpu cpu output", ABI_SYSCALLS.Getcpu, 0, 4, [0n, 0n]], + ["getcpu node output", ABI_SYSCALLS.Getcpu, 1, 4, [0n, 0n]], + ] as const)( + "rejects a one-byte-short %s caller range before kernel dispatch", + (_name, syscallNr, argIndex, size, originalArgs) => { + const harness = makeScratchHarness(8); + prepareGenericSyscallHarness(harness, 8); + const invalidPointer = + harness.processBytes.byteLength - size + 1; + const args = [...originalArgs]; + args[argIndex] = BigInt(invalidPointer); + writeChannelSyscall(harness, syscallNr, args); + + expect(() => harness.worker._handleSyscallInner(harness.channel)) + .not.toThrow(); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + syscallNr, + Array.from( + { length: 6 }, + (_, index) => Number(args[index] ?? 0n), + ), + undefined, + -1, + EFAULT, + ); + expectScratchTailUntouched(harness); + }, + ); + + it.each([ + ["memfd_create name", ABI_SYSCALLS.MemfdCreate, [0n, 0n]], + [ + "renameat2 old path", + ABI_SYSCALLS.Renameat2, + [-100n, 0n, -100n, 4096n, 0n], + ], + [ + "renameat2 new path", + ABI_SYSCALLS.Renameat2, + [-100n, 4096n, -100n, 0n, 0n], + ], + ] as const)( + "rejects a null required %s before kernel dispatch", + (_name, syscallNr, args) => { + const harness = makeScratchHarness(8); + prepareGenericSyscallHarness(harness, 8); + harness.processBytes.set( + new TextEncoder().encode("valid\0"), + 4096, + ); + writeChannelSyscall(harness, syscallNr, [...args]); + + expect(() => harness.worker._handleSyscallInner(harness.channel)) + .not.toThrow(); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + syscallNr, + Array.from( + { length: 6 }, + (_, index) => Number(args[index] ?? 0n), + ), + undefined, + -1, + EFAULT, + ); + expectScratchTailUntouched(harness); + }, + ); + + it.each([ + [4, 312], + [8, 368], + ] as const)( + "stages an exact-end wasm%s sysinfo output with its native capacity", + (pointerWidth, nativeSize) => { + const harness = makeScratchHarness(pointerWidth); + const outputPointer = harness.processBytes.byteLength - nativeSize; + Object.assign(harness.worker, { + config: {}, + syscallRing: new Map(), + syscallTraceEnabled: false, + channelTids: new Map(), + processes: new Map([[harness.channel.pid, { + pid: harness.channel.pid, + memory: harness.channel.memory, + channels: [harness.channel], + ptrWidth: pointerWidth, + }]]), + synchronizeSharedMemoryForBoundary: () => {}, + sharedMmapBackings: new Map(), + hostReaped: new Set(), + getProcessExitSignal: () => 0, + }); + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + const scratchPointer = Number( + channelView.getBigInt64(CH_ARGS, true), + ); + expect( + Number(channelView.getBigInt64( + CH_ARGS + 5 * CH_ARG_SIZE, + true, + )), + ).toBe(pointerWidth); + harness.kernelBytes.fill( + 0x6b, + scratchPointer, + scratchPointer + nativeSize, + ); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + const request = new DataView(harness.channel.memory.buffer); + request.setUint32(CH_SYSCALL, ABI_SYSCALLS.Sysinfo, true); + request.setBigInt64(CH_ARGS, BigInt(outputPointer), true); + + expect(() => harness.worker._handleSyscallInner(harness.channel)) + .not.toThrow(); + + expect(harness.handleChannel).toHaveBeenCalledOnce(); + const writes = harness.completeChannel.mock.calls[0]?.[6] as + | Array<{ ptr: number; bytes: Uint8Array }> + | undefined; + expect(writes).toHaveLength(1); + expect(writes?.[0]?.ptr).toBe(outputPointer); + expect(writes?.[0]?.bytes).toHaveLength(nativeSize); + expect(writes?.[0]?.bytes.every((byte) => byte === 0x6b)).toBe(true); + expectScratchTailUntouched(harness); + }, + ); + + it.each([ + [4, 312], + [8, 368], + ] as const)( + "rejects a one-byte-short wasm%s sysinfo caller range", + (pointerWidth, nativeSize) => { + const harness = makeScratchHarness(pointerWidth); + Object.assign(harness.worker, { + config: {}, + syscallRing: new Map(), + syscallTraceEnabled: false, + channelTids: new Map(), + processes: new Map([[harness.channel.pid, { + pid: harness.channel.pid, + memory: harness.channel.memory, + channels: [harness.channel], + ptrWidth: pointerWidth, + }]]), + synchronizeSharedMemoryForBoundary: () => {}, + sharedMmapBackings: new Map(), + hostReaped: new Set(), + getProcessExitSignal: () => 0, + }); + const invalidPointer = + harness.processBytes.byteLength - nativeSize + 1; + const request = new DataView(harness.channel.memory.buffer); + request.setUint32(CH_SYSCALL, ABI_SYSCALLS.Sysinfo, true); + request.setBigInt64(CH_ARGS, BigInt(invalidPointer), true); + + expect(() => harness.worker._handleSyscallInner(harness.channel)) + .not.toThrow(); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + ABI_SYSCALLS.Sysinfo, + [invalidPointer, 0, 0, 0, 0, 0], + undefined, + -1, + EFAULT, + ); + expectScratchTailUntouched(harness); + }, + ); + + it.each([4, 8] as const)( + "rejects a null wasm%s sysinfo output before kernel dispatch", + (pointerWidth) => { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + writeChannelSyscall(harness, ABI_SYSCALLS.Sysinfo, [0n]); + + expect(() => harness.worker._handleSyscallInner(harness.channel)) + .not.toThrow(); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + ABI_SYSCALLS.Sysinfo, + [0, 0, 0, 0, 0, 0], + undefined, + -1, + EFAULT, + ); + expectScratchTailUntouched(harness); + }, + ); + + it.each([4, 8] as const)( + "rejects a null wasm%s outer ifconf without touching address zero", + (pointerWidth) => { + const harness = makeScratchHarness(pointerWidth); + const ifconfSize = pointerWidth === 8 ? 16 : 8; + harness.processBytes.fill(0x6d, 0, ifconfSize + 16); + writeIfconf(harness.processBytes, pointerWidth, 0, 0, 0); + const before = harness.processBytes.slice(0, ifconfSize + 16); + + invokeNetworkIoctlHandler(harness, "handleIoctlIfconf", 0); + + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -EFAULT, + EFAULT, + ); + expect(harness.processBytes.slice(0, ifconfSize + 16)).toEqual(before); + expectScratchTailUntouched(harness); + }, + ); + + it.each([4, 8] as const)( + "rejects null wasm%s outer ifreq objects for every network handler", + (pointerWidth) => { + const ifreqSize = pointerWidth === 8 ? 40 : 32; + for (const entry of NETWORK_IFREQ_HANDLERS) { + const harness = makeScratchHarness(pointerWidth); + harness.processBytes.fill(0x6d, 0, ifreqSize + 16); + entry.prepare(harness.processBytes, 0); + const before = harness.processBytes.slice(0, ifreqSize + 16); + + invokeNetworkIoctlHandler(harness, entry.handler, 0); + + expect( + harness.completeChannelRaw, + `ioctl 0x${entry.request.toString(16)}`, + ).toHaveBeenCalledWith(harness.channel, -EFAULT, EFAULT); + expect(harness.processBytes.slice(0, ifreqSize + 16)).toEqual(before); + expectScratchTailUntouched(harness); + } + }, + ); + + it.each([4, 8] as const)( + "accepts an exact wasm%s outer ifconf and rejects one byte short", + (pointerWidth) => { + const ifconfSize = pointerWidth === 8 ? 16 : 8; + const ifreqSize = pointerWidth === 8 ? 40 : 32; + + const exact = makeScratchHarness(pointerWidth); + const exactPointer = exact.processBytes.byteLength - ifconfSize; + exact.processBytes.fill(0x6d, exactPointer - 16, exactPointer); + writeIfconf( + exact.processBytes, + pointerWidth, + exactPointer, + 0, + 0, + ); + const exactPrefix = exact.processBytes.slice( + exactPointer - 16, + exactPointer, + ); + + invokeNetworkIoctlHandler( + exact, + "handleIoctlIfconf", + exactPointer, + ); + + expect(exact.completeChannelRaw).toHaveBeenCalledWith( + exact.channel, + 0, + 0, + ); + expect( + new DataView(exact.processBytes.buffer).getInt32(exactPointer, true), + ).toBe(2 * ifreqSize); + expect( + exact.processBytes.slice(exactPointer - 16, exactPointer), + ).toEqual(exactPrefix); + expectScratchTailUntouched(exact); + + const short = makeScratchHarness(pointerWidth); + const shortPointer = short.processBytes.byteLength - ifconfSize + 1; + short.processBytes.fill(0x6d, shortPointer - 16); + const shortBefore = short.processBytes.slice(shortPointer - 16); + + invokeNetworkIoctlHandler( + short, + "handleIoctlIfconf", + shortPointer, + ); + + expect(short.completeChannelRaw).toHaveBeenCalledWith( + short.channel, + -EFAULT, + EFAULT, + ); + expect(short.processBytes.slice(shortPointer - 16)).toEqual(shortBefore); + expectScratchTailUntouched(short); + }, + ); + + it.each([4, 8] as const)( + "accepts exact wasm%s outer ifreq objects and rejects one byte short", + (pointerWidth) => { + const ifreqSize = pointerWidth === 8 ? 40 : 32; + for (const entry of NETWORK_IFREQ_HANDLERS) { + const exact = makeScratchHarness(pointerWidth); + const exactPointer = exact.processBytes.byteLength - ifreqSize; + exact.processBytes.fill(0x6d, exactPointer - 16, exactPointer); + exact.processBytes.fill(0, exactPointer); + entry.prepare(exact.processBytes, exactPointer); + const exactPrefix = exact.processBytes.slice( + exactPointer - 16, + exactPointer, + ); + + invokeNetworkIoctlHandler(exact, entry.handler, exactPointer); + + expect( + exact.completeChannelRaw, + `ioctl 0x${entry.request.toString(16)}`, + ).toHaveBeenCalledWith(exact.channel, 0, 0); + expect( + exact.processBytes.slice(exactPointer - 16, exactPointer), + ).toEqual(exactPrefix); + expectScratchTailUntouched(exact); + + const short = makeScratchHarness(pointerWidth); + const shortPointer = short.processBytes.byteLength - ifreqSize + 1; + short.processBytes.fill(0x6d, shortPointer - 16); + entry.prepare(short.processBytes, shortPointer); + const shortBefore = short.processBytes.slice(shortPointer - 16); + + invokeNetworkIoctlHandler(short, entry.handler, shortPointer); + + expect( + short.completeChannelRaw, + `ioctl 0x${entry.request.toString(16)}`, + ).toHaveBeenCalledWith(short.channel, -EFAULT, EFAULT); + expect(short.processBytes.slice(shortPointer - 16)).toEqual(shortBefore); + expectScratchTailUntouched(short); + } + }, + ); + + it.each([4, 8] as const)( + "bounds wasm%s nested ifconf output at exact capacity and capacity + 1", + (pointerWidth) => { + const ifreqSize = pointerWidth === 8 ? 40 : 32; + for (const extraCapacity of [0, 1]) { + const harness = makeScratchHarness(pointerWidth); + const ifconfPointer = 4096; + const outputPointer = 8192; + const guardStart = outputPointer - 16; + const guardEnd = outputPointer + ifreqSize + extraCapacity + 16; + harness.processBytes.fill(0x6d, guardStart, guardEnd); + writeIfconf( + harness.processBytes, + pointerWidth, + ifconfPointer, + ifreqSize + extraCapacity, + outputPointer, + ); + const prefix = harness.processBytes.slice(guardStart, outputPointer); + const suffix = harness.processBytes.slice( + outputPointer + ifreqSize, + guardEnd, + ); + + invokeNetworkIoctlHandler( + harness, + "handleIoctlIfconf", + ifconfPointer, + ); + + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + 0, + 0, + ); + expect( + new DataView(harness.processBytes.buffer).getInt32( + ifconfPointer, + true, + ), + ).toBe(ifreqSize); + expect( + new TextDecoder().decode( + harness.processBytes.slice(outputPointer, outputPointer + 2), + ), + ).toBe("lo"); + expect(harness.processBytes.slice(guardStart, outputPointer)) + .toEqual(prefix); + expect( + harness.processBytes.slice(outputPointer + ifreqSize, guardEnd), + ).toEqual(suffix); + expectScratchTailUntouched(harness); + } + }, + ); + + it.each([4, 8] as const)( + "rejects a one-byte-short wasm%s nested ifconf output without mutation", + (pointerWidth) => { + const harness = makeScratchHarness(pointerWidth); + const ifreqSize = pointerWidth === 8 ? 40 : 32; + const ifconfPointer = 4096; + const outputPointer = harness.processBytes.byteLength - ifreqSize + 1; + harness.processBytes.fill(0x6d, outputPointer - 16); + const outputBefore = harness.processBytes.slice(outputPointer - 16); + writeIfconf( + harness.processBytes, + pointerWidth, + ifconfPointer, + ifreqSize, + outputPointer, + ); + + invokeNetworkIoctlHandler( + harness, + "handleIoctlIfconf", + ifconfPointer, + ); + + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -EFAULT, + EFAULT, + ); + expect(harness.processBytes.slice(outputPointer - 16)) + .toEqual(outputBefore); + expect( + new DataView(harness.processBytes.buffer).getInt32( + ifconfPointer, + true, + ), + ).toBe(ifreqSize); + expectScratchTailUntouched(harness); + }, + ); + + it.each([ + ["high", 0x1_0000_2000n], + ["unsafe", 0x20_0000_0000_2000n], + ] as const)( + "rejects wasm64 nested ifconf pointer class %s without a low-address alias", + (_kind, nestedPointer) => { + const harness = makeScratchHarness(8); + const ifconfPointer = 4096; + const lowAlias = Number(nestedPointer & 0xffff_ffffn); + const ifreqSize = 40; + harness.processBytes.fill( + 0x6d, + lowAlias - 16, + lowAlias + ifreqSize + 16, + ); + const lowBefore = harness.processBytes.slice( + lowAlias - 16, + lowAlias + ifreqSize + 16, + ); + writeIfconf( + harness.processBytes, + 8, + ifconfPointer, + ifreqSize, + nestedPointer, + ); + + invokeNetworkIoctlHandler( + harness, + "handleIoctlIfconf", + ifconfPointer, + ); + + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -EFAULT, + EFAULT, + ); + expect( + harness.processBytes.slice( + lowAlias - 16, + lowAlias + ifreqSize + 16, + ), + ).toEqual(lowBefore); + expectScratchTailUntouched(harness); + }, + ); + + it("copies exactly four FIONREAD bytes and preserves the caller canary", () => { + const harness = makeScratchHarness(4); + prepareGenericSyscallHarness(harness, 4); + const outputPointer = 8192; + const result = Uint8Array.from([4, 3, 2, 1]); + const canary = new Uint8Array(32).fill(0x6d); + harness.processBytes.set(canary, outputPointer + result.byteLength); + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + const scratchPointer = Number( + channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true), + ); + expect( + Number(channelView.getBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, true)), + ).toBe(4); + expect( + Number(channelView.getBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, true)), + ).toBe(4); + harness.kernelBytes.set(result, scratchPointer); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + harness.completeChannel.mockImplementation(( + _channel: TestChannel, + _syscallNr: number, + _origArgs: number[], + _argDescs: unknown, + _retVal: number, + _errVal: number, + writes: Array<{ ptr: number; bytes: Uint8Array }>, + ) => { + for (const write of writes) { + harness.processBytes.set(write.bytes, write.ptr); + } + }); + writeChannelSyscall(harness, ABI_SYSCALLS.Ioctl, [ + 7n, + 0x541bn, + BigInt(outputPointer), + ]); + + harness.worker._handleSyscallInner(harness.channel); + + expect( + harness.processBytes.slice(outputPointer, outputPointer + result.length), + ).toEqual(result); + expect( + harness.processBytes.slice( + outputPointer + result.length, + outputPointer + result.length + canary.length, + ), + ).toEqual(canary); + const writes = harness.completeChannel.mock.calls[0]?.[6] as + Array<{ ptr: number; bytes: Uint8Array }>; + expect(writes[0]?.bytes).toHaveLength(4); + expectScratchTailUntouched(harness); + }); + + it.each([ + [4, 0x8004_5430, 4, 0x00], + [4, 0xc024_6400, 36, 0x4b], + [8, 0xc040_6400, 64, 0x4b], + ] as const)( + "stages the exact wasm%s ioctl request 0x%s capacity", + (pointerWidth, request, size, expectedInputByte) => { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + const processPointer = harness.processBytes.byteLength - size; + harness.processBytes.fill( + 0x4b, + processPointer, + processPointer + size, + ); + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + const scratchPointer = Number( + channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true), + ); + expect( + Number(channelView.getBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, true)), + ).toBe(size); + expect( + Number(channelView.getBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, true)), + ).toBe(pointerWidth); + expect( + harness.kernelBytes.slice(scratchPointer, scratchPointer + size), + ).toEqual(new Uint8Array(size).fill(expectedInputByte)); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + writeChannelSyscall(harness, ABI_SYSCALLS.Ioctl, [ + 7n, + BigInt(request), + BigInt(processPointer), + ]); + + harness.worker._handleSyscallInner(harness.channel); + + expect(harness.handleChannel).toHaveBeenCalledOnce(); + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ + 0, + 0, + ]); + expectScratchTailUntouched(harness); + }, + ); + + it("rejects a one-byte-short ioctl caller range before scratch mutation", () => { + const harness = makeScratchHarness(4); + prepareGenericSyscallHarness(harness, 4); + const invalidPointer = harness.processBytes.byteLength - 3; + writeChannelSyscall(harness, ABI_SYSCALLS.Ioctl, [ + 7n, + 0x541bn, + BigInt(invalidPointer), + ]); + + harness.worker._handleSyscallInner(harness.channel); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + ABI_SYSCALLS.Ioctl, + [7, 0x541b, invalidPointer, 0, 0, 0], + undefined, + -1, + EFAULT, + ); + expectScratchTailUntouched(harness); + }); + + it("rejects a null pointer for a pointer-valued ioctl", () => { + const harness = makeScratchHarness(4); + prepareGenericSyscallHarness(harness, 4); + writeChannelSyscall(harness, ABI_SYSCALLS.Ioctl, [ + 7n, + 0x541bn, + 0n, + ]); + + harness.worker._handleSyscallInner(harness.channel); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + ABI_SYSCALLS.Ioctl, + [7, 0x541b, 0, 0, 0, 0], + undefined, + -1, + EFAULT, + ); + expectScratchTailUntouched(harness); + }); + + it("passes scalar and no-argument ioctls without staging a pointer", () => { + for (const [request, argument, expectedArgument] of [ + [0x540b, 2n, 2], + [0x5451, 0x2000_0000_0000n, 0], + ] as const) { + const harness = makeScratchHarness(8); + prepareGenericSyscallHarness(harness, 8); + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + expect( + Number(channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true)), + ).toBe(expectedArgument); + expect( + Number(channelView.getBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, true)), + ).toBe(0); + expect( + Number(channelView.getBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, true)), + ).toBe(8); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + writeChannelSyscall(harness, ABI_SYSCALLS.Ioctl, [ + 7n, + BigInt(request), + argument, + ]); + + harness.worker._handleSyscallInner(harness.channel); + + expect(harness.handleChannel).toHaveBeenCalledOnce(); + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ + 0, + 0, + ]); + expectScratchTailUntouched(harness); + } + }); + + it.each(SCALAR_IOCTL_REQUESTS)( + "normalizes every scalar ioctl 0x%s from its low i32 transport bits", + (request) => { + for (const [argument, expectedArgument] of [ + // Reproduces wasm64 musl's unspecified upper vararg slot bytes for an + // intended zero-valued scalar. + [0x4_0000_0000n, 0], + [0x5_7fff_ffffn, 0x7fff_ffff], + [0x6_8000_0000n, 0x8000_0000], + [0x7_ffff_ffffn, 0xffff_ffff], + [-0x8000_0000n, 0x8000_0000], + [-1n, 0xffff_ffff], + ] as const) { + const harness = makeScratchHarness(8); + prepareGenericSyscallHarness(harness, 8); + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + expect( + Number(channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true)), + ).toBe(expectedArgument); + expect( + Number(channelView.getBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, true)), + ).toBe(0); + expect( + Number(channelView.getBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, true)), + ).toBe(8); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + writeChannelSyscall(harness, ABI_SYSCALLS.Ioctl, [ + 7n, + BigInt(request), + argument, + ]); + + harness.worker._handleSyscallInner(harness.channel); + + expect(harness.handleChannel).toHaveBeenCalledOnce(); + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ + 0, + 0, + ]); + expectScratchTailUntouched(harness); + } + }, + ); + + it("stages no pointer for an unknown ioctl request", () => { + const harness = makeScratchHarness(8); + prepareGenericSyscallHarness(harness, 8); + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + expect( + Number(channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true)), + ).toBe(0); + expect( + Number(channelView.getBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, true)), + ).toBe(0); + channelView.setBigInt64(CH_RETURN, -1n, true); + channelView.setUint32(CH_ERRNO, 25, true); + return 0; + }); + writeChannelSyscall(harness, ABI_SYSCALLS.Ioctl, [ + 7n, + 0xdeadn, + 0x2000_0000_0000n, + ]); + + harness.worker._handleSyscallInner(harness.channel); + + expect(harness.handleChannel).toHaveBeenCalledOnce(); + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ + -1, + 25, + ]); + expectScratchTailUntouched(harness); + }); + + it.each([ + [0x49, 24], + [0xc024_6400, 36], + ] as const)( + "rejects wasm64 ioctl 0x%s before a lossy layout conversion", + (request, _wasm32Size) => { + const harness = makeScratchHarness(8); + prepareGenericSyscallHarness(harness, 8); + writeChannelSyscall(harness, ABI_SYSCALLS.Ioctl, [ + 7n, + BigInt(request), + 4096n, + ]); + + harness.worker._handleSyscallInner(harness.channel); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + ABI_SYSCALLS.Ioctl, + [7, request, 4096, 0, 0, 0], + undefined, + -1, + EOVERFLOW, + ); + expectScratchTailUntouched(harness); + }, + ); +}); diff --git a/host/test/kernel-wasm-input-snapshot.test.ts b/host/test/kernel-wasm-input-snapshot.test.ts new file mode 100644 index 0000000000..5a8d2b7669 --- /dev/null +++ b/host/test/kernel-wasm-input-snapshot.test.ts @@ -0,0 +1,133 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { WasmPosixKernel } from "../src/kernel"; + +function memoryImportModule(pointerWidth: 4 | 8): Uint8Array { + return new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, // magic + 0x01, 0x00, 0x00, 0x00, // version + 0x02, 0x08, // import section, eight-byte payload + 0x01, // one import + 0x01, 0x6d, // module "m" + 0x01, 0x6d, // field "m" + 0x02, // memory import + pointerWidth === 8 ? 0x04 : 0x00, // memory64 flag + 0x01, // minimum one page + ]); +} + +function kernel(): WasmPosixKernel { + return new WasmPosixKernel( + { + maxWorkers: 1, + dataBufferSize: 65_536, + useSharedMemory: true, + }, + {} as never, + ); +} + +function expectBytes(source: BufferSource | undefined, expected: Uint8Array): void { + expect(source).toBeInstanceOf(ArrayBuffer); + expect( + new Uint8Array(source as ArrayBuffer), + ).toEqual(expected); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("kernel WebAssembly input snapshots", () => { + it("uses a Uint8Array subclass's intrinsic bytes for both width detection and init compilation", async () => { + const actual = memoryImportModule(4); + const decoy = memoryImportModule(8); + let getterReads = 0; + + class SpoofedUint8Array extends Uint8Array { + override get buffer(): ArrayBuffer { + getterReads++; + return decoy.buffer as ArrayBuffer; + } + + override get byteOffset(): number { + getterReads++; + return decoy.byteOffset; + } + + override get byteLength(): number { + getterReads++; + return decoy.byteLength; + } + } + + const source = new SpoofedUint8Array(actual); + const compileFailure = new Error("stop after capturing compile input"); + let compiledSource: BufferSource | undefined; + vi.spyOn(WebAssembly, "compile").mockImplementation(async (bytes) => { + compiledSource = bytes; + throw compileFailure; + }); + + const instance = kernel(); + await expect(instance.init(source)).rejects.toBe(compileFailure); + + expect(getterReads).toBe(0); + expect(instance.getKernelPtrWidth()).toBe(4); + expectBytes(compiledSource, actual); + }); + + it("uses a DataView subclass's intrinsic window for both width detection and initWithMemory compilation", async () => { + const actual = memoryImportModule(8); + const decoy = memoryImportModule(4); + const prefixLength = 7; + const backing = new Uint8Array(prefixLength + actual.byteLength + 5); + backing.fill(0xa5); + backing.set(actual, prefixLength); + let getterReads = 0; + + class SpoofedDataView extends DataView { + override get buffer(): ArrayBuffer { + getterReads++; + return decoy.buffer as ArrayBuffer; + } + + override get byteOffset(): number { + getterReads++; + return decoy.byteOffset; + } + + override get byteLength(): number { + getterReads++; + return decoy.byteLength; + } + } + + const source = new SpoofedDataView( + backing.buffer as ArrayBuffer, + prefixLength, + actual.byteLength, + ); + const compileFailure = new Error("stop after capturing compile input"); + let compiledSource: BufferSource | undefined; + vi.spyOn(WebAssembly, "compile").mockImplementation(async (bytes) => { + compiledSource = bytes; + throw compileFailure; + }); + const memory = new WebAssembly.Memory({ + initial: 1, + maximum: 1, + shared: true, + }); + + const instance = kernel(); + await expect(instance.initWithMemory(source, memory)) + .rejects.toBe(compileFailure); + + expect(getterReads).toBe(0); + // A failed first initialization is retryable and publishes no partial + // kernel generation, including its candidate pointer width. + expect(instance.getKernelPtrWidth()).toBe(4); + expectBytes(compiledSource, actual); + }); +}); diff --git a/host/test/kernel-worker-copyback.test.ts b/host/test/kernel-worker-copyback.test.ts index c873488f73..a4a7ea5391 100644 --- a/host/test/kernel-worker-copyback.test.ts +++ b/host/test/kernel-worker-copyback.test.ts @@ -10,6 +10,7 @@ import { type SyscallArgDesc, SYSCALL_ARGS, } from "../src/generated/abi"; +import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; interface TestChannel { pid: number; @@ -28,10 +29,17 @@ interface CopybackHarnessWorker { argDescs: SyscallArgDesc[] | undefined, retVal: number, errVal: number, + detachedOutput?: Array<{ ptr: number; bytes: Uint8Array }>, + ): void; + handleBlockingRetry( + channel: TestChannel, + syscallNr: number, + origArgs: number[], + detachedOutput?: Array<{ ptr: number; bytes: Uint8Array }>, ): void; } -function makeCopybackHarness() { +function makeCopybackHarness(ptrWidth: 4 | 8 = 4) { const pid = 100; const kernelMemory = new WebAssembly.Memory({ initial: 2 }); const processMemory = new WebAssembly.Memory({ @@ -51,7 +59,6 @@ function makeCopybackHarness() { Object.create(CentralizedKernelWorker.prototype), { kernelMemory, - scratchOffset: 0, cachedKernelMem: null, cachedKernelBuffer: null, processes: new Map([ @@ -61,7 +68,7 @@ function makeCopybackHarness() { pid, memory: processMemory, channels: [channel], - ptrWidth: 4, + ptrWidth, explicitMaxAddr: false, }, ], @@ -73,13 +80,18 @@ function makeCopybackHarness() { drainAndProcessWakeupEvents: () => {}, synchronizeSharedMemoryForBoundary: () => {}, relistenChannel: () => {}, + pendingCancels: new Set(), }, ) as CopybackHarnessWorker; + const scratchPointer = installKernelWorkerTestScratch( + worker as unknown as Record, + kernelMemory, + ); return { worker, channel, - kernelMem: new Uint8Array(kernelMemory.buffer), + kernelMem: new Uint8Array(kernelMemory.buffer, scratchPointer), processMem: new Uint8Array(processMemory.buffer), }; } @@ -126,6 +138,7 @@ describe("CentralizedKernelWorker syscall copy-back", () => { SYSCALL_ARGS[ABI_SYSCALLS.Read], 3, 0, + [{ ptr: dest, bytes: Uint8Array.of(1, 2, 3) }], ); expect(Array.from(processMem.slice(dest, dest + original.length))).toEqual([ @@ -136,29 +149,99 @@ describe("CentralizedKernelWorker syscall copy-back", () => { ]); }); - it("copies fixed prefix metadata when a zero-length msgrcv succeeds", () => { - const { worker, channel, kernelMem, processMem } = makeCopybackHarness(); - const dest = 3072; - const original = Uint8Array.from({ length: 12 }, (_, i) => 0xd0 + i); + it.each([4, 8] as const)( + "copies the complete 112-byte stat record for a wasm%s caller", + (ptrWidth) => { + const { worker, channel, kernelMem, processMem } = + makeCopybackHarness(ptrWidth); + const dest = 4096; + const size = 112; + const canary = 0x5a; + const output = Uint8Array.from( + { length: size }, + (_, index) => (index * 29 + 7) & 0xff, + ); + const descriptors = SYSCALL_ARGS[ABI_SYSCALLS.Fstat]; + const statOutput = descriptors?.find((desc) => desc.argIndex === 1); + + expect(statOutput?.size).toEqual({ type: "fixed", size }); + expect(statOutput?.required).toBe(true); + processMem.fill(canary, dest - 1, dest + size + 1); + kernelMem.set(output, CH_DATA); + + worker.completeChannel( + channel, + ABI_SYSCALLS.Fstat, + [3, dest], + descriptors, + 0, + 0, + [{ ptr: dest, bytes: output }], + ); + + expect(processMem.slice(dest, dest + size)).toEqual(output); + expect(processMem[dest - 1]).toBe(canary); + expect(processMem[dest + size]).toBe(canary); + }, + ); + + it.each([4, 8] as const)( + "copies the complete initialized 48-byte sched_param for a wasm%s caller", + (ptrWidth) => { + const { worker, channel, kernelMem, processMem } = + makeCopybackHarness(ptrWidth); + const dest = 8192; + const size = 48; + const canary = 0xa6; + const output = Uint8Array.from( + { length: size }, + (_, index) => (index * 17 + 3) & 0xff, + ); + const descriptors = SYSCALL_ARGS[ABI_SYSCALLS.SchedGetparam]; + const schedOutput = descriptors?.find((desc) => desc.argIndex === 1); + + expect(schedOutput?.size).toEqual({ type: "fixed", size }); + expect(schedOutput?.required).toBe(true); + processMem.fill(canary, dest - 1, dest + size + 1); + kernelMem.set(output, CH_DATA); + + worker.completeChannel( + channel, + ABI_SYSCALLS.SchedGetparam, + [0, dest], + descriptors, + 0, + 0, + [{ ptr: dest, bytes: output }], + ); + + expect(processMem.slice(dest, dest + size)).toEqual(output); + expect(processMem[dest - 1]).toBe(canary); + expect(processMem[dest + size]).toBe(canary); + }, + ); - processMem.set(original, dest); - kernelMem.set([0x11, 0x22, 0x33, 0x44, 0, 0, 0, 0], CH_DATA); + it("carries detached poll output through an immediate timeout without rereading scratch", () => { + const { worker, channel, kernelMem, processMem } = makeCopybackHarness(); + const pollfd = 12_000; + const detached = Uint8Array.of( + 3, 0, 0, 0, + 1, 0, + 0, 0, + ); + processMem.fill(0xa5, pollfd, pollfd + detached.byteLength); + kernelMem.fill(0xee, CH_DATA, CH_DATA + detached.byteLength); - worker.completeChannel( + worker.handleBlockingRetry( channel, - ABI_SYSCALLS.Msgrcv, - [0, dest, 8], - SYSCALL_ARGS[ABI_SYSCALLS.Msgrcv], - 0, - 0, + ABI_SYSCALLS.Poll, + [pollfd, 1, 0], + [{ ptr: pollfd, bytes: detached }], ); - expect(Array.from(processMem.slice(dest, dest + original.length))).toEqual([ - 0x11, - 0x22, - 0x33, - 0x44, - ...original.slice(4), - ]); + expect(processMem.slice(pollfd, pollfd + detached.byteLength)) + .toEqual(detached); + expect(processMem[pollfd]).not.toBe(0xee); }); + }); diff --git a/host/test/kernel-worker-test-scratch.ts b/host/test/kernel-worker-test-scratch.ts new file mode 100644 index 0000000000..d8328b220a --- /dev/null +++ b/host/test/kernel-worker-test-scratch.ts @@ -0,0 +1,36 @@ +import { CH_TOTAL_SIZE } from "../src/generated/abi"; +import { allocateKernelScratchRegion } from "../src/kernel-scratch"; +import { createKernelScratchTestInstance } from "./support/kernel-scratch-instance"; + +/** + * Install the same capacity-carrying main scratch contract that worker.init() + * creates, for white-box tests that intentionally bypass the constructor and + * Wasm allocator. + */ +export function installKernelWorkerTestScratch( + worker: Record, + memory: WebAssembly.Memory, + pointer = 128, + pointerWidth: 4 | 8 = 4, +): number { + worker.kernelMemory = memory; + const scratchTestInstance = createKernelScratchTestInstance( + pointerWidth, + memory, + () => ( + worker.kernelInstance as { exports?: Record } | undefined + )?.exports ?? {}, + () => pointerWidth === 8 ? BigInt(pointer) : pointer, + ); + worker.scratchTestInstance = scratchTestInstance; + worker.scratchRegion = allocateKernelScratchRegion( + memory, + scratchTestInstance.exports.kernel_alloc_scratch as + (size: number) => number | bigint, + CH_TOTAL_SIZE, + pointerWidth, + "test kernel syscall scratch", + scratchTestInstance, + ); + return pointer; +} diff --git a/host/test/kernel.test.ts b/host/test/kernel.test.ts index 6f9c5d9ef3..674af7f6b0 100644 --- a/host/test/kernel.test.ts +++ b/host/test/kernel.test.ts @@ -2,6 +2,25 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import { CAPTURED_STDIO, CentralizedKernelWorker } from "../src/kernel-worker"; import { resolveBinary } from "../src/binary-resolver"; +import { CH_TOTAL_SIZE } from "../src/constants"; +import { + ABI_SYSCALLS, + CH_ARGS, + CH_ARG_SIZE, + CH_DATA, + CH_ERRNO, + CH_RETURN, + CH_SYSCALL, + KERNEL_WAIT_RESULT_SI_CODE_OFFSET, + KERNEL_WAIT_RESULT_SI_STATUS_OFFSET, + KERNEL_WAIT_RESULT_WAIT_STATUS_OFFSET, + KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES, + PROCESS_STATE_EXITED, + STRUCT_SIZE_KERNEL_WAIT_RESULT, + WAIT_CLD_KILLED, + WAIT_EVENT_EXITED, +} from "../src/generated/abi"; +import type { KernelScratchLease } from "../src/kernel-scratch"; import { NodePlatformIO } from "../src/platform/node"; describe("CentralizedKernelWorker", () => { @@ -56,4 +75,330 @@ describe("CentralizedKernelWorker", () => { // Unregister to clean up kernelWorker.unregisterProcess(pid); }); + + it("requires a nonnull exact-capacity mqueue notification destination", async () => { + const wasmBytes = readFileSync(resolveBinary("kernel.wasm")); + const kernelWorker = new CentralizedKernelWorker( + { maxWorkers: 4, dataBufferSize: 65536, useSharedMemory: true }, + new NodePlatformIO(), + ); + await kernelWorker.init( + wasmBytes.buffer.slice( + wasmBytes.byteOffset, + wasmBytes.byteOffset + wasmBytes.byteLength, + ), + ); + + const processMemory = new WebAssembly.Memory({ + initial: 17, + maximum: 256, + shared: true, + }); + const channelOffset = (256 - 2) * 65536; + processMemory.grow(256 - 17); + const pid = kernelWorker.createProcess(CAPTURED_STDIO); + kernelWorker.registerProcess(pid, processMemory, [channelOffset]); + + type ScratchArgument = { + readonly offset: number; + readonly length: number; + }; + const scratchArgument = ( + offset: number, + length: number, + ): ScratchArgument => ({ offset, length }); + const internals = kernelWorker as any; + const pointerWidth = internals.kernel.getKernelPtrWidth() as 4 | 8; + const setCurrentTid = internals.kernelInstance.exports + .kernel_set_current_tid as (pid: number, tid: number) => number; + const issue = ( + syscall: number, + prepare: ( + lease: KernelScratchLease, + ) => Array, + ): { value: number; errno: number } => + internals.scratchRegion.withLease((lease: KernelScratchLease) => { + lease.fill(0, 0, CH_TOTAL_SIZE); + const args = prepare(lease); + const channel = lease.dataView(0, CH_TOTAL_SIZE); + channel.setUint32(CH_SYSCALL, syscall, true); + channel.setUint32(CH_ERRNO, 0, true); + channel.setBigInt64(CH_RETURN, 0n, true); + for (let index = 0; index < 6; index++) { + const argument = args[index] ?? 0; + if (typeof argument === "object") { + channel.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, 0n, true); + lease.writeAddress( + CH_ARGS + index * CH_ARG_SIZE, + argument.offset, + argument.length, + pointerWidth === 8 ? "u64-le" : "u32-to-u64-le", + ); + } else { + channel.setBigInt64( + CH_ARGS + index * CH_ARG_SIZE, + BigInt(argument), + true, + ); + } + } + expect(setCurrentTid(pid, pid)).toBe(0); + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + pid, + ]); + return { + value: Number(channel.getBigInt64(CH_RETURN, true)), + errno: channel.getUint32(CH_ERRNO, true), + }; + }); + + try { + const queueName = new TextEncoder().encode( + `/kernel-mq-drain-capacity-${pid}\0`, + ); + const opened = issue(ABI_SYSCALLS.MqOpen, (lease) => { + lease.copyFrom(queueName, CH_DATA); + return [ + scratchArgument(CH_DATA, queueName.byteLength), + 0o302, // O_RDWR | O_CREAT | O_EXCL + 0o600, + 0, + 0, + 4, + ]; + }); + expect(opened.errno).toBe(0); + expect(opened.value).toBeGreaterThanOrEqual(0x4000_0000); + + const notified = issue(ABI_SYSCALLS.MqNotify, (lease) => { + const sigeventSize = 64; + const event = lease.dataView(CH_DATA, sigeventSize); + event.setUint32(0, 0x89ab_cdef, true); + event.setInt32(4, 10, true); + event.setInt32(8, 0, true); // SIGEV_SIGNAL + return [ + opened.value, + scratchArgument(CH_DATA, sigeventSize), + 0, + 0, + 0, + 4, + ]; + }); + expect(notified).toEqual({ value: 0, errno: 0 }); + + const sent = issue(ABI_SYSCALLS.MqTimedsend, (lease) => { + lease.copyFrom(new Uint8Array([0x51]), CH_DATA); + return [ + opened.value, + scratchArgument(CH_DATA, 1), + 1, + 0, + 0, + 4, + ]; + }); + expect(sent).toEqual({ value: 0, errno: 0 }); + + expect(KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES).toBe(8); + internals.scratchRegion.withLease((lease: KernelScratchLease) => { + const outputOffset = CH_DATA + 64; + const guardedLength = KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES + 2; + lease.fill(0xa5, outputOffset, guardedLength); + const drain = internals.kernelInstance.exports + .kernel_mq_drain_notification as ( + pointer: number | bigint, + capacity: number, + ) => number; + const nullPointer = pointerWidth === 8 ? 0n : 0; + + expect( + drain(nullPointer, KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES), + ).toBe(-14); // EFAULT + expect( + lease.invokeKernelExport("kernel_mq_drain_notification", [ + lease.exportPointer( + outputOffset + 1, + KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES - 1, + ), + KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES - 1, + ]), + ).toBe(-22); // EINVAL + expect( + lease.invokeKernelExport("kernel_mq_drain_notification", [ + lease.exportPointer( + outputOffset + 1, + KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES + 1, + ), + KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES + 1, + ]), + ).toBe(-22); // EINVAL + expect(lease.copyOut(outputOffset, guardedLength)).toEqual( + new Uint8Array(guardedLength).fill(0xa5), + ); + + expect( + lease.invokeKernelExport("kernel_mq_drain_notification", [ + lease.exportPointer( + outputOffset + 1, + KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES, + ), + KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES, + ]), + ).toBe(1); + const output = lease.copyOut(outputOffset, guardedLength); + expect(output[0]).toBe(0xa5); + expect(output[guardedLength - 1]).toBe(0xa5); + const notification = new DataView( + output.buffer, + output.byteOffset + 1, + KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES, + ); + expect(notification.getUint32(0, true)).toBe(pid); + expect(notification.getUint32(4, true)).toBe(10); + }); + } finally { + kernelWorker.unregisterProcess(pid); + } + }); + + it("preserves a waitable child until an exact-capacity result consumes it", async () => { + const ECHILD = 10; + const EFAULT = 14; + const EINVAL = 22; + const ESRCH = 3; + const SIGTERM = 15; + const wasmBytes = readFileSync(resolveBinary("kernel.wasm")); + const kernelWorker = new CentralizedKernelWorker( + { maxWorkers: 4, dataBufferSize: 65536, useSharedMemory: true }, + new NodePlatformIO(), + ); + await kernelWorker.init( + wasmBytes.buffer.slice( + wasmBytes.byteOffset, + wasmBytes.byteOffset + wasmBytes.byteLength, + ), + ); + + const processMemory = new WebAssembly.Memory({ + initial: 17, + maximum: 256, + shared: true, + }); + const channelOffset = (256 - 2) * 65536; + processMemory.grow(256 - 17); + const parentPid = kernelWorker.createProcess(CAPTURED_STDIO); + kernelWorker.registerProcess(parentPid, processMemory, [channelOffset]); + + const internals = kernelWorker as any; + const pointerWidth = internals.kernel.getKernelPtrWidth() as 4 | 8; + const exports = internals.kernelInstance.exports as WebAssembly.Exports; + const forkProcess = exports.kernel_fork_process as ( + parentPid: number, + callerTid: number, + ) => number; + const markProcessSignaled = exports.kernel_mark_process_signaled as ( + pid: number, + signum: number, + ) => number; + const getProcessState = exports.kernel_get_process_state as ( + pid: number, + ) => number; + const removeProcess = exports.kernel_remove_process as ( + pid: number, + ) => number; + const waitChildPoll = exports.kernel_wait_child_poll as ( + parentPid: number, + callerTid: number, + targetPid: number, + eventMask: number, + flags: number, + resultPtr: number | bigint, + resultCapacity: number, + ) => number; + let childPid = 0; + + try { + childPid = forkProcess(parentPid, parentPid); + expect(childPid).toBeGreaterThan(0); + expect(markProcessSignaled(childPid, SIGTERM)).toBe(0); + expect(getProcessState(childPid)).toBe(PROCESS_STATE_EXITED); + + internals.scratchRegion.withLease((lease: KernelScratchLease) => { + const outputOffset = CH_DATA + 256; + const guardedLength = STRUCT_SIZE_KERNEL_WAIT_RESULT + 2; + const nullPointer = pointerWidth === 8 ? 0n : 0; + const pollWithCapacity = (capacity: number): number => + lease.invokeKernelExport("kernel_wait_child_poll", [ + parentPid, + parentPid, + childPid, + WAIT_EVENT_EXITED, + 0, + lease.exportPointer(outputOffset + 1, capacity), + capacity, + ]); + lease.fill(0xa5, outputOffset, guardedLength); + + // WHY: destination rejection must precede event selection, or a bad + // host borrow could silently consume the parent's only wait record. + expect(waitChildPoll( + parentPid, + parentPid, + childPid, + WAIT_EVENT_EXITED, + 0, + nullPointer, + STRUCT_SIZE_KERNEL_WAIT_RESULT, + )).toBe(-EFAULT); + expect(getProcessState(childPid)).toBe(PROCESS_STATE_EXITED); + + expect( + pollWithCapacity(STRUCT_SIZE_KERNEL_WAIT_RESULT - 1), + ).toBe(-EINVAL); + expect(getProcessState(childPid)).toBe(PROCESS_STATE_EXITED); + + expect( + pollWithCapacity(STRUCT_SIZE_KERNEL_WAIT_RESULT + 1), + ).toBe(-EINVAL); + expect(getProcessState(childPid)).toBe(PROCESS_STATE_EXITED); + expect(lease.copyOut(outputOffset, guardedLength)).toEqual( + new Uint8Array(guardedLength).fill(0xa5), + ); + + expect( + pollWithCapacity(STRUCT_SIZE_KERNEL_WAIT_RESULT), + ).toBe(childPid); + const output = lease.copyOut(outputOffset, guardedLength); + expect(output[0]).toBe(0xa5); + expect(output[guardedLength - 1]).toBe(0xa5); + const result = new DataView( + output.buffer, + output.byteOffset + 1, + STRUCT_SIZE_KERNEL_WAIT_RESULT, + ); + expect( + result.getInt32(KERNEL_WAIT_RESULT_WAIT_STATUS_OFFSET, true), + ).toBe(SIGTERM); + expect( + result.getInt32(KERNEL_WAIT_RESULT_SI_CODE_OFFSET, true), + ).toBe(WAIT_CLD_KILLED); + expect( + result.getInt32(KERNEL_WAIT_RESULT_SI_STATUS_OFFSET, true), + ).toBe(SIGTERM); + + expect(getProcessState(childPid)).toBe(-ESRCH); + expect( + pollWithCapacity(STRUCT_SIZE_KERNEL_WAIT_RESULT), + ).toBe(-ECHILD); + }); + } finally { + if (childPid > 0 && getProcessState(childPid) >= 0) { + removeProcess(childPid); + } + kernelWorker.unregisterProcess(parentPid); + } + }); }); diff --git a/host/test/multi-worker.test.ts b/host/test/multi-worker.test.ts index 9a9a294e31..d973aab341 100644 --- a/host/test/multi-worker.test.ts +++ b/host/test/multi-worker.test.ts @@ -33,6 +33,8 @@ import { PROCESS_STATE_EXITED, WPK_FORK_LINKED_FRAME_POINTER_WIDTHS, } from "../src/generated/abi"; +import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; +import type { KernelScratchLease } from "../src/kernel-scratch"; const MAX_PAGES = 1024; // 64 MiB: enough to prove initial < maximum. const WASM32_CONTINUATION_HEADER_SIZE = @@ -101,8 +103,7 @@ function issueThreadAttachment( ) { const channel = (worker as any).processes.get(pid)?.channels[0]; if (!channel) throw new Error(`No main channel for process ${pid}`); - const kernelMemory = new WebAssembly.Memory({ initial: 1, maximum: 1 }); - const kernelView = new DataView(kernelMemory.buffer); + const kernelMemory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); let attachment: Parameters[0] | undefined; new DataView(channel.memory.buffer, channel.channelOffset) @@ -121,11 +122,12 @@ function issueThreadAttachment( toKernelPtr: (value: number | bigint) => Number(value), }; (worker as any).kernelMemory = kernelMemory; - (worker as any).scratchOffset = 0; + installKernelWorkerTestScratch(worker as any, kernelMemory); (worker as any).currentHandlePid = 0; (worker as any).threadCtidPtrs ??= new Map(); (worker as any).bindKernelTidForChannel = vi.fn(); - (worker as any).kernelInstance.exports.kernel_handle_channel = vi.fn(() => { + (worker as any).kernelInstance.exports.kernel_handle_channel = vi.fn((offset: number) => { + const kernelView = new DataView(kernelMemory.buffer, offset); kernelView.setBigInt64(CH_RETURN, BigInt(tid), true); kernelView.setUint32(CH_ERRNO, 0, true); return 0; @@ -144,6 +146,42 @@ function createAndRegisterProcess( return pid; } +function issueDirectKernelOpen( + worker: CentralizedKernelWorker, + pid: number, + path: string, +): { value: number; errno: number } { + const region = (worker as any).scratchRegion; + const encoded = new TextEncoder().encode(`${path}\0`); + const setCurrentTid = (worker as any).kernelInstance.exports + .kernel_set_current_tid as (pid: number, tid: number) => number; + expect(setCurrentTid(pid, pid)).toBe(0); + return region.withLease((lease: KernelScratchLease) => { + lease.copyFrom(encoded, CH_DATA); + const channel = lease.dataView(0, CH_TOTAL_SIZE); + channel.setUint32(CH_SYSCALL, ABI_SYSCALLS.Open, true); + for (let index = 0; index < 6; index++) { + channel.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, 0n, true); + } + lease.writeAddress( + CH_ARGS, + CH_DATA, + encoded.byteLength, + "u32-to-u64-le", + ); + lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + pid, + ]); + const result = lease.dataView(0, CH_TOTAL_SIZE); + return { + value: Number(result.getBigInt64(CH_RETURN, true)), + errno: result.getUint32(CH_ERRNO, true), + }; + }); +} + describe("CentralizedKernelWorker Process Management", () => { it("does not deliver SIGEV_NONE as a signal-zero wakeup", () => { expect(shouldDeliverPosixTimerSignal(0)).toBe(false); @@ -822,10 +860,9 @@ describe("CentralizedKernelWorker Process Management", () => { processView.setUint32(CH_DATA + 4, 22, true); const kernelMemory = new WebAssembly.Memory({ - initial: 1, - maximum: 1, + initial: 2, + maximum: 2, }); - const kernelView = new DataView(kernelMemory.buffer); const threadCtidPtrs = new Map(); let resolveClone!: () => void; let kw!: CentralizedKernelWorker; @@ -846,7 +883,6 @@ describe("CentralizedKernelWorker Process Management", () => { }, }, kernelMemory, - scratchOffset: 0, currentHandlePid: 0, activeChannels: [channel], channelTids: new Map(), @@ -864,7 +900,8 @@ describe("CentralizedKernelWorker Process Management", () => { exports: { kernel_get_process_exit_signal: vi.fn(() => -1), kernel_validate_task: vi.fn(() => 0), - kernel_handle_channel: vi.fn(() => { + kernel_handle_channel: vi.fn((offset: number) => { + const kernelView = new DataView(kernelMemory.buffer, offset); kernelView.setBigInt64(CH_RETURN, BigInt(tid), true); kernelView.setUint32(CH_ERRNO, 0, true); return 0; @@ -872,6 +909,7 @@ describe("CentralizedKernelWorker Process Management", () => { }, }, }); + installKernelWorkerTestScratch(kw as any, kernelMemory); (kw as any).handleClone( channel, @@ -904,8 +942,7 @@ describe("CentralizedKernelWorker Process Management", () => { const processView = new DataView(oldMemory.buffer, channelOffset); processView.setUint32(CH_DATA, 11, true); processView.setUint32(CH_DATA + 4, 22, true); - const kernelMemory = new WebAssembly.Memory({ initial: 1, maximum: 1 }); - const kernelView = new DataView(kernelMemory.buffer); + const kernelMemory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); const threadCtidPtrs = new Map(); let resolveClone!: () => void; const onClone = vi.fn(() => new Promise((resolve) => { @@ -916,7 +953,6 @@ describe("CentralizedKernelWorker Process Management", () => { callbacks: { onClone }, kernel: { toKernelPtr: (value: number | bigint) => Number(value) }, kernelMemory, - scratchOffset: 0, currentHandlePid: 0, processes: new Map([[pid, { channels: [oldChannel] }]]), threadCtidPtrs, @@ -924,7 +960,8 @@ describe("CentralizedKernelWorker Process Management", () => { bindKernelTidForChannel: vi.fn(), kernelInstance: { exports: { - kernel_handle_channel: vi.fn(() => { + kernel_handle_channel: vi.fn((offset: number) => { + const kernelView = new DataView(kernelMemory.buffer, offset); kernelView.setBigInt64(CH_RETURN, BigInt(tid), true); kernelView.setUint32(CH_ERRNO, 0, true); return 0; @@ -932,6 +969,7 @@ describe("CentralizedKernelWorker Process Management", () => { }, }, }); + installKernelWorkerTestScratch(kw as any, kernelMemory); (kw as any).handleClone( oldChannel, @@ -1216,30 +1254,14 @@ describe("CentralizedKernelWorker Process Management", () => { // Issue open(2) directly through the real kernel export so the Rust // Process owns the exact host handle that unregisterProcess must release. - const kernelMemory = (kw as any).kernelMemory as WebAssembly.Memory; - const scratchOffset = (kw as any).scratchOffset as number; - const pathPtr = scratchOffset + CH_DATA; - const path = new TextEncoder().encode( - `${join(process.cwd(), "../Cargo.toml")}\0`, - ); - new Uint8Array(kernelMemory.buffer).set(path, pathPtr); - const channel = new DataView(kernelMemory.buffer, scratchOffset); - channel.setUint32(CH_SYSCALL, ABI_SYSCALLS.Open, true); - for (let i = 0; i < 6; i++) { - channel.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); - } - channel.setBigInt64(CH_ARGS, BigInt(pathPtr), true); - const handleChannel = (kw as any).kernelInstance.exports - .kernel_handle_channel as (offset: number, pid: number) => number; - const setCurrentTid = (kw as any).kernelInstance.exports - .kernel_set_current_tid as (pid: number, tid: number) => number; - expect(setCurrentTid(pid, pid)).toBe(0); - handleChannel(kw.toKernelPtr(scratchOffset) as number, pid); - - expect(channel.getUint32(CH_ERRNO, true)).toBe(0); - expect(Number(channel.getBigInt64(CH_RETURN, true))).toBeGreaterThanOrEqual( - 3, + const opened = issueDirectKernelOpen( + kw, + pid, + join(process.cwd(), "../Cargo.toml"), ); + + expect(opened.errno).toBe(0); + expect(opened.value).toBeGreaterThanOrEqual(3); expect(open).toHaveBeenCalledOnce(); const hostHandle = open.mock.results[0].value; expect(close).not.toHaveBeenCalledWith(hostHandle); @@ -1261,27 +1283,13 @@ describe("CentralizedKernelWorker Process Management", () => { const procMemory = createProcessMemory(); const pid = createAndRegisterProcess(kw, procMemory); - const kernelMemory = (kw as any).kernelMemory as WebAssembly.Memory; - const scratchOffset = (kw as any).scratchOffset as number; - const pathPtr = scratchOffset + CH_DATA; - const path = new TextEncoder().encode( - `${join(process.cwd(), "../Cargo.toml")}\0`, + const opened = issueDirectKernelOpen( + kw, + pid, + join(process.cwd(), "../Cargo.toml"), ); - new Uint8Array(kernelMemory.buffer).set(path, pathPtr); - const channel = new DataView(kernelMemory.buffer, scratchOffset); - channel.setUint32(CH_SYSCALL, ABI_SYSCALLS.Open, true); - for (let i = 0; i < 6; i++) { - channel.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); - } - channel.setBigInt64(CH_ARGS, BigInt(pathPtr), true); - const handleChannel = (kw as any).kernelInstance.exports - .kernel_handle_channel as (offset: number, pid: number) => number; - const setCurrentTid = (kw as any).kernelInstance.exports - .kernel_set_current_tid as (pid: number, tid: number) => number; - expect(setCurrentTid(pid, pid)).toBe(0); - handleChannel(kw.toKernelPtr(scratchOffset) as number, pid); - const guestFd = Number(channel.getBigInt64(CH_RETURN, true)); - expect(channel.getUint32(CH_ERRNO, true)).toBe(0); + const guestFd = opened.value; + expect(opened.errno).toBe(0); expect(guestFd).toBeGreaterThanOrEqual(3); const hostHandle = open.mock.results[0].value; const stat = io.fstat(hostHandle); diff --git a/host/test/pathconf.test.ts b/host/test/pathconf.test.ts index 07e389742c..84d51a130d 100644 --- a/host/test/pathconf.test.ts +++ b/host/test/pathconf.test.ts @@ -3,7 +3,10 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { PATHCONF_NAMES } from "../src/generated/abi"; +import { + PATHCONF_NAMES, + POSIX_PATH_MAX_BYTES, +} from "../src/generated/abi"; import { filesystemPathconf } from "../src/pathconf"; import { DeviceFileSystem } from "../src/vfs/device-fs"; import { HostFileSystem } from "../src/vfs/host-fs"; @@ -64,7 +67,7 @@ describe("pathconf capability values", () => { ).toBe(255); expect( filesystemPathconf(regularStat, PATHCONF_NAMES.PATH_MAX, memoryProfile), - ).toBe(4096); + ).toBe(POSIX_PATH_MAX_BYTES); expect( filesystemPathconf(regularStat, PATHCONF_NAMES.NO_TRUNC, memoryProfile), ).toBe(1); @@ -207,7 +210,8 @@ describe("HostFileSystem fpathconf", () => { const fd = fs.open("/file", O_RDONLY, 0); fs.unlink("/file"); - expect(fs.fpathconf(fd, PATHCONF_NAMES.PATH_MAX)).toBe(4096); + expect(fs.fpathconf(fd, PATHCONF_NAMES.PATH_MAX)) + .toBe(POSIX_PATH_MAX_BYTES); expect(() => fs.pathconf("/file", PATHCONF_NAMES.PATH_MAX)).toThrow(/ENOENT/); fs.close(fd); }); diff --git a/host/test/process-native-layout.test.ts b/host/test/process-native-layout.test.ts new file mode 100644 index 0000000000..9d8e8f6bed --- /dev/null +++ b/host/test/process-native-layout.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { runCentralizedProgram } from "./centralized-test-helper"; +import { ensureWasm64ExampleFixture } from "./wasm64-example-fixture"; + +const testDir = dirname(fileURLToPath(import.meta.url)); +const wasm32Binary = join( + testDir, + "../../examples/process_native_layout_test.wasm", +); + +describe("caller-native syscall layouts", () => { + it.each(["wasm32", "wasm64"] as const)( + "round-trips signal, timer, message-queue, statfs, and sysinfo records (%s)", + async (arch) => { + const programPath = arch === "wasm64" + ? ensureWasm64ExampleFixture("process_native_layout_test.c") + : wasm32Binary; + const result = await runCentralizedProgram({ + programPath, + timeout: 20_000, + useDefaultRootfs: false, + }); + + expect(result.stdout).toContain("PROCESS NATIVE LAYOUTS PASSED"); + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + expect(result.hostDiagnostics).toEqual([]); + }, + 30_000, + ); +}); diff --git a/host/test/process-wait-lifecycle.test.ts b/host/test/process-wait-lifecycle.test.ts index f975b699c7..617383400a 100644 --- a/host/test/process-wait-lifecycle.test.ts +++ b/host/test/process-wait-lifecycle.test.ts @@ -15,8 +15,19 @@ import { KERNEL_WAIT_RESULT_SI_STATUS_OFFSET, KERNEL_WAIT_RESULT_WAIT_STATUS_OFFSET, PROCESS_STATE_EXITED, + PROCESS_SIGINFO_CODE_OFFSET, + PROCESS_SIGINFO_SIGNO_OFFSET, + PROCESS_SIGINFO_WASM32_PID_OFFSET, + PROCESS_SIGINFO_WASM32_SIZE, + PROCESS_SIGINFO_WASM32_UID_OFFSET, + PROCESS_SIGINFO_WASM32_VALUE_OFFSET, + PROCESS_SIGINFO_WASM64_PID_OFFSET, + PROCESS_SIGINFO_WASM64_SIZE, + PROCESS_SIGINFO_WASM64_UID_OFFSET, + PROCESS_SIGINFO_WASM64_VALUE_OFFSET, PROCESS_STATE_RUNNING, PROCESS_STATE_STOPPED, + STRUCT_SIZE_KERNEL_WAIT_RESULT, STRUCT_SIZE_WASM_RUSAGE_WIRE, WAIT_CLD_EXITED, WAIT_CLD_STOPPED, @@ -30,6 +41,7 @@ import { WAKE_PROCESS_STOPPED, } from "../src/generated/abi"; import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; const SIGCHLD = 17; const SIGCONT = 18; @@ -47,44 +59,61 @@ describe("Rust-owned process wait lifecycle", () => { { length: STRUCT_SIZE_WASM_RUSAGE_WIRE }, (_, index) => index & 0xff, ); - const waitChildPoll = vi.fn(( - _parentPid: number, - _callerTid: number, - _targetPid: number, - _eventMask: number, - _flags: number, - resultPtr: number | bigint, - ) => { - writeKernelWaitResult(kernelMemory, Number(resultPtr), { - waitStatus, - siCode: 1, - siStatus: 5, - childUid: 123, - rusage, - }); - return 42; - }); + const waitChildPoll = vi.fn( + ( + _parentPid: number, + _callerTid: number, + _targetPid: number, + _eventMask: number, + _flags: number, + resultPtr: number | bigint, + ) => { + writeKernelWaitResult(kernelMemory, Number(resultPtr), { + waitStatus, + siCode: 1, + siStatus: 5, + childUid: 123, + rusage, + }); + return 42; + }, + ); const reapExitedChild = vi.fn(() => 0); const worker = createWorkerHarness({ kernel_wait_child_poll: waitChildPoll, kernel_reap_exited_child: reapExitedChild, }); worker.kernelMemory = kernelMemory; - worker.scratchOffset = 128; + installKernelWorkerTestScratch(worker, kernelMemory); worker.completeWaitpid = vi.fn(); const rusagePtr = 512; - const channel = registerMainChannel(worker, createChannel(7, processMemory)); + const channel = registerMainChannel( + worker, + createChannel(7, processMemory), + ); worker.handleWaitpid(channel, [-1, statusPtr, 0, rusagePtr]); - expect(waitChildPoll).toHaveBeenCalledWith(7, 7, -1, WAIT_EVENT_EXITED, 0, 128); + expect(waitChildPoll).toHaveBeenCalledWith( + 7, + 7, + -1, + WAIT_EVENT_EXITED, + 0, + 128, + STRUCT_SIZE_KERNEL_WAIT_RESULT, + ); expect(reapExitedChild).not.toHaveBeenCalled(); - expect(new DataView(processMemory.buffer).getInt32(statusPtr, true)).toBe(waitStatus); - expect(new Uint8Array( - processMemory.buffer, - rusagePtr, - STRUCT_SIZE_WASM_RUSAGE_WIRE, - )).toEqual(rusage); + expect(new DataView(processMemory.buffer).getInt32(statusPtr, true)).toBe( + waitStatus, + ); + expect( + new Uint8Array( + processMemory.buffer, + rusagePtr, + STRUCT_SIZE_WASM_RUSAGE_WIRE, + ), + ).toEqual(rusage); expect(worker.completeWaitpid).toHaveBeenCalledWith( expect.any(Object), [-1, statusPtr, 0, rusagePtr], @@ -95,8 +124,11 @@ describe("Rust-owned process wait lifecycle", () => { it("wait4 leaves blocking waits in the host queue when Rust reports a running child", () => { const waitChildPoll = vi.fn(() => 0); - const worker = createWorkerHarness({ kernel_wait_child_poll: waitChildPoll }); + const worker = createWorkerHarness({ + kernel_wait_child_poll: waitChildPoll, + }); worker.kernelMemory = createSharedMemory(); + installKernelWorkerTestScratch(worker, worker.kernelMemory); worker.waitingForChild = []; worker.completeWaitpid = vi.fn(); @@ -121,11 +153,18 @@ describe("Rust-owned process wait lifecycle", () => { const waitChildPoll = vi.fn(() => 0); const processMemory = createSharedMemory(); const channel = createChannel(7, processMemory); - const worker = createWorkerHarness({ kernel_wait_child_poll: waitChildPoll }); - worker.processes = new Map([[7, { - channels: [channel], - memory: processMemory, - }]]); + const worker = createWorkerHarness({ + kernel_wait_child_poll: waitChildPoll, + }); + worker.processes = new Map([ + [ + 7, + { + channels: [channel], + memory: processMemory, + }, + ], + ]); worker.pendingCancels = new Set([channel]); worker.waitingForChild = []; worker.completeChannelRaw = vi.fn(); @@ -148,37 +187,40 @@ describe("Rust-owned process wait lifecycle", () => { worker.completeChannelRaw = vi.fn(); worker.relistenChannel = vi.fn(); - expect(worker.interruptPendingFifoOpenCancellation( - channel, - ABI_SYSCALLS.Getpid, - )).toBe(false); + expect( + worker.interruptPendingFifoOpenCancellation(channel, ABI_SYSCALLS.Getpid), + ).toBe(false); expect(worker.pendingCancels.has(channel)).toBe(true); expect(worker.cancelParkedFifoOpen).not.toHaveBeenCalled(); - expect(worker.interruptPendingFifoOpenCancellation( - channel, - ABI_SYSCALLS.Open, - )).toBe(true); + expect( + worker.interruptPendingFifoOpenCancellation(channel, ABI_SYSCALLS.Open), + ).toBe(true); expect(worker.pendingCancels.has(channel)).toBe(false); expect(worker.cancelParkedFifoOpen).toHaveBeenCalledOnce(); expect(worker.completeChannelRaw).toHaveBeenCalledOnce(); expect(worker.completeChannelRaw).toHaveBeenCalledWith(channel, -4, 4); expect(worker.relistenChannel).toHaveBeenCalledOnce(); - expect(worker.interruptPendingFifoOpenCancellation( - channel, - ABI_SYSCALLS.Open, - )).toBe(false); + expect( + worker.interruptPendingFifoOpenCancellation(channel, ABI_SYSCALLS.Open), + ).toBe(false); expect(worker.completeChannelRaw).toHaveBeenCalledOnce(); }); it("wait4 WNOHANG completes without queuing when Rust reports no event", () => { - const worker = createWorkerHarness({ kernel_wait_child_poll: vi.fn(() => 0) }); + const worker = createWorkerHarness({ + kernel_wait_child_poll: vi.fn(() => 0), + }); worker.kernelMemory = createSharedMemory(); + installKernelWorkerTestScratch(worker, worker.kernelMemory); worker.waitingForChild = []; worker.completeWaitpid = vi.fn(); - const channel = registerMainChannel(worker, createChannel(7, createSharedMemory())); + const channel = registerMainChannel( + worker, + createChannel(7, createSharedMemory()), + ); worker.handleWaitpid(channel, [-1, 0, WAIT_WNOHANG, 0]); expect(worker.waitingForChild).toEqual([]); @@ -192,12 +234,19 @@ describe("Rust-owned process wait lifecycle", () => { it("wait4 passes a bigint status pointer for wasm64 kernels", () => { const waitChildPoll = vi.fn(() => 0); - const worker = createWorkerHarness({ kernel_wait_child_poll: waitChildPoll }, 8); + const worker = createWorkerHarness( + { kernel_wait_child_poll: waitChildPoll }, + 8, + ); worker.kernelMemory = createSharedMemory(); + installKernelWorkerTestScratch(worker, worker.kernelMemory, 128, 8); worker.waitingForChild = []; worker.completeWaitpid = vi.fn(); - const channel = registerMainChannel(worker, createChannel(7, createSharedMemory())); + const channel = registerMainChannel( + worker, + createChannel(7, createSharedMemory()), + ); worker.handleWaitpid(channel, [-1, 0, WAIT_WNOHANG, 0]); expect(waitChildPoll).toHaveBeenCalledWith( @@ -207,6 +256,7 @@ describe("Rust-owned process wait lifecycle", () => { WAIT_EVENT_EXITED, 0, BigInt(128), + STRUCT_SIZE_KERNEL_WAIT_RESULT, ); expect(worker.waitingForChild).toEqual([]); expect(worker.completeWaitpid).toHaveBeenCalledWith( @@ -219,7 +269,9 @@ describe("Rust-owned process wait lifecycle", () => { it("returns EFAULT before polling or consuming an event for invalid wait4 outputs", () => { const waitChildPoll = vi.fn(() => 42); - const worker = createWorkerHarness({ kernel_wait_child_poll: waitChildPoll }); + const worker = createWorkerHarness({ + kernel_wait_child_poll: waitChildPoll, + }); worker.completeWaitpid = vi.fn(); const processMemory = createSharedMemory(); const invalidStatusPtr = processMemory.buffer.byteLength - 2; @@ -242,29 +294,37 @@ describe("Rust-owned process wait lifecycle", () => { const siginfoPtr = 512; const rusagePtr = 1024; const rusage = new Uint8Array(STRUCT_SIZE_WASM_RUSAGE_WIRE).fill(0x5a); - const waitChildPoll = vi.fn(( - _parentPid: number, - _callerTid: number, - _targetPid: number, - _eventMask: number, - _flags: number, - resultPtr: number | bigint, - ) => { - writeKernelWaitResult(kernelMemory, Number(resultPtr), { - waitStatus: (19 << 8) | 0x7f, - siCode: WAIT_CLD_STOPPED, - siStatus: 19, - childUid: 4242, - rusage, - }); - return 44; + const waitChildPoll = vi.fn( + ( + _parentPid: number, + _callerTid: number, + _targetPid: number, + _eventMask: number, + _flags: number, + resultPtr: number | bigint, + ) => { + writeKernelWaitResult(kernelMemory, Number(resultPtr), { + waitStatus: (19 << 8) | 0x7f, + siCode: WAIT_CLD_STOPPED, + siStatus: 19, + childUid: 4242, + rusage, + }); + return 44; + }, + ); + const worker = createWorkerHarness({ + kernel_wait_child_poll: waitChildPoll, }); - const worker = createWorkerHarness({ kernel_wait_child_poll: waitChildPoll }); worker.kernelMemory = kernelMemory; + installKernelWorkerTestScratch(worker, kernelMemory); worker.completeWaitid = vi.fn(); const args = [1, 44, siginfoPtr, WAIT_WSTOPPED | WAIT_WNOWAIT, rusagePtr]; - const channel = registerMainChannel(worker, createChannel(7, processMemory)); + const channel = registerMainChannel( + worker, + createChannel(7, processMemory), + ); worker.handleWaitid(channel, args); expect(waitChildPoll).toHaveBeenCalledWith( @@ -274,18 +334,31 @@ describe("Rust-owned process wait lifecycle", () => { WAIT_EVENT_STOPPED, WAIT_WNOWAIT, 128, + STRUCT_SIZE_KERNEL_WAIT_RESULT, ); const siginfo = new DataView(processMemory.buffer); - expect(siginfo.getInt32(siginfoPtr, true)).toBe(SIGCHLD); - expect(siginfo.getInt32(siginfoPtr + 8, true)).toBe(WAIT_CLD_STOPPED); - expect(siginfo.getInt32(siginfoPtr + 12, true)).toBe(44); - expect(siginfo.getUint32(siginfoPtr + 16, true)).toBe(4242); - expect(siginfo.getInt32(siginfoPtr + 20, true)).toBe(19); - expect(new Uint8Array( - processMemory.buffer, - rusagePtr, - STRUCT_SIZE_WASM_RUSAGE_WIRE, - )).toEqual(rusage); + expect( + siginfo.getInt32(siginfoPtr + PROCESS_SIGINFO_SIGNO_OFFSET, true), + ).toBe(SIGCHLD); + expect( + siginfo.getInt32(siginfoPtr + PROCESS_SIGINFO_CODE_OFFSET, true), + ).toBe(WAIT_CLD_STOPPED); + expect( + siginfo.getInt32(siginfoPtr + PROCESS_SIGINFO_WASM32_PID_OFFSET, true), + ).toBe(44); + expect( + siginfo.getUint32(siginfoPtr + PROCESS_SIGINFO_WASM32_UID_OFFSET, true), + ).toBe(4242); + expect( + siginfo.getInt32(siginfoPtr + PROCESS_SIGINFO_WASM32_VALUE_OFFSET, true), + ).toBe(19); + expect( + new Uint8Array( + processMemory.buffer, + rusagePtr, + STRUCT_SIZE_WASM_RUSAGE_WIRE, + ), + ).toEqual(rusage); expect(worker.completeWaitid).toHaveBeenCalledWith( expect.any(Object), args, @@ -299,72 +372,119 @@ describe("Rust-owned process wait lifecycle", () => { const processMemory = createSharedMemory(); const channel = createChannel(7, processMemory); const siginfoPtr = 512; - const waitChildPoll = vi.fn(( - _parentPid: number, - _callerTid: number, - _targetPid: number, - _eventMask: number, - _flags: number, - resultPtr: number | bigint, - ) => { - writeKernelWaitResult(kernelMemory, Number(resultPtr), { - waitStatus: 9 << 8, - siCode: WAIT_CLD_EXITED, - siStatus: 9, - childUid: 5150, - rusage: new Uint8Array(STRUCT_SIZE_WASM_RUSAGE_WIRE), - }); - return 44; - }); + const waitChildPoll = vi.fn( + ( + _parentPid: number, + _callerTid: number, + _targetPid: number, + _eventMask: number, + _flags: number, + resultPtr: number | bigint, + ) => { + writeKernelWaitResult(kernelMemory, Number(resultPtr), { + waitStatus: 9 << 8, + siCode: WAIT_CLD_EXITED, + siStatus: 9, + childUid: 5150, + rusage: new Uint8Array(STRUCT_SIZE_WASM_RUSAGE_WIRE), + }); + return 44; + }, + ); const worker = createWorkerHarness( { kernel_wait_child_poll: waitChildPoll }, 8, ); worker.kernelMemory = kernelMemory; - worker.processes = new Map([[7, { - channels: [channel], - memory: processMemory, - ptrWidth: 8, - }]]); + installKernelWorkerTestScratch(worker, kernelMemory, 128, 8); + worker.processes = new Map([ + [ + 7, + { + channels: [channel], + memory: processMemory, + ptrWidth: 8, + }, + ], + ]); worker.completeWaitid = vi.fn(); const args = [1, 44, siginfoPtr, WAIT_WEXITED, 0]; + new Uint8Array( + processMemory.buffer, + siginfoPtr, + PROCESS_SIGINFO_WASM64_SIZE, + ).fill(0xa5); worker.handleWaitid(channel, args); const siginfo = new DataView(processMemory.buffer); - expect(siginfo.getInt32(siginfoPtr, true)).toBe(SIGCHLD); - expect(siginfo.getInt32(siginfoPtr + 8, true)).toBe(WAIT_CLD_EXITED); - expect(siginfo.getUint32(siginfoPtr + 12, true)).toBe(0); - expect(siginfo.getInt32(siginfoPtr + 16, true)).toBe(44); - expect(siginfo.getUint32(siginfoPtr + 20, true)).toBe(5150); - expect(siginfo.getInt32(siginfoPtr + 24, true)).toBe(9); + expect( + siginfo.getInt32(siginfoPtr + PROCESS_SIGINFO_SIGNO_OFFSET, true), + ).toBe(SIGCHLD); + expect( + siginfo.getInt32(siginfoPtr + PROCESS_SIGINFO_CODE_OFFSET, true), + ).toBe(WAIT_CLD_EXITED); + expect( + siginfo.getUint32( + siginfoPtr + PROCESS_SIGINFO_CODE_OFFSET + Int32Array.BYTES_PER_ELEMENT, + true, + ), + ).toBe(0); + expect( + siginfo.getInt32(siginfoPtr + PROCESS_SIGINFO_WASM64_PID_OFFSET, true), + ).toBe(44); + expect( + siginfo.getUint32(siginfoPtr + PROCESS_SIGINFO_WASM64_UID_OFFSET, true), + ).toBe(5150); + expect( + siginfo.getInt32(siginfoPtr + PROCESS_SIGINFO_WASM64_VALUE_OFFSET, true), + ).toBe(9); + expect(siginfo.getUint8(siginfoPtr + PROCESS_SIGINFO_WASM64_SIZE - 1)).toBe( + 0, + ); }); it("waitid WNOHANG zeros all siginfo bytes and leaves rusage untouched", () => { const processMemory = createSharedMemory(); const siginfoPtr = 512; const rusagePtr = 1024; - new Uint8Array(processMemory.buffer, siginfoPtr, 128).fill(0xa5); + new Uint8Array( + processMemory.buffer, + siginfoPtr, + PROCESS_SIGINFO_WASM32_SIZE, + ).fill(0xa5); new Uint8Array( processMemory.buffer, rusagePtr, STRUCT_SIZE_WASM_RUSAGE_WIRE, ).fill(0x6b); const waitChildPoll = vi.fn(() => 0); - const worker = createWorkerHarness({ kernel_wait_child_poll: waitChildPoll }); + const worker = createWorkerHarness({ + kernel_wait_child_poll: waitChildPoll, + }); worker.completeWaitid = vi.fn(); const args = [0, 0, siginfoPtr, WAIT_WEXITED | WAIT_WNOHANG, rusagePtr]; - const channel = registerMainChannel(worker, createChannel(7, processMemory)); + const channel = registerMainChannel( + worker, + createChannel(7, processMemory), + ); worker.handleWaitid(channel, args); - expect(new Uint8Array(processMemory.buffer, siginfoPtr, 128)) - .toEqual(new Uint8Array(128)); - expect(new Uint8Array( - processMemory.buffer, - rusagePtr, - STRUCT_SIZE_WASM_RUSAGE_WIRE, - )).toEqual(new Uint8Array(STRUCT_SIZE_WASM_RUSAGE_WIRE).fill(0x6b)); + expect( + new Uint8Array( + processMemory.buffer, + siginfoPtr, + PROCESS_SIGINFO_WASM32_SIZE, + ), + ).toEqual(new Uint8Array(PROCESS_SIGINFO_WASM32_SIZE)); + expect( + new Uint8Array( + processMemory.buffer, + rusagePtr, + STRUCT_SIZE_WASM_RUSAGE_WIRE, + ), + ).toEqual(new Uint8Array(STRUCT_SIZE_WASM_RUSAGE_WIRE).fill(0x6b)); expect(worker.completeWaitid).toHaveBeenCalledWith( expect.any(Object), args, @@ -375,7 +495,9 @@ describe("Rust-owned process wait lifecycle", () => { it("rejects invalid waitid idtypes and required null siginfo before polling", () => { const waitChildPoll = vi.fn(() => 0); - const worker = createWorkerHarness({ kernel_wait_child_poll: waitChildPoll }); + const worker = createWorkerHarness({ + kernel_wait_child_poll: waitChildPoll, + }); worker.completeWaitid = vi.fn(); const channel = createChannel(7, createSharedMemory()); @@ -383,8 +505,9 @@ describe("Rust-owned process wait lifecycle", () => { worker.handleWaitid(channel, [0, 0, 0, WAIT_WEXITED, 0]); expect(waitChildPoll).not.toHaveBeenCalled(); - expect(worker.completeWaitid.mock.calls.map((call: unknown[]) => call[3])) - .toEqual([22, 14]); + expect( + worker.completeWaitid.mock.calls.map((call: unknown[]) => call[3]), + ).toEqual([22, 14]); }); it("owns a drained wake batch before nested SIGCHLD work reuses scratch", () => { @@ -396,6 +519,7 @@ describe("Rust-owned process wait lifecycle", () => { }); const worker = createWorkerHarness({ kernel_drain_wakeup_events: drain }); worker.kernelMemory = kernelMemory; + installKernelWorkerTestScratch(worker, kernelMemory); worker.stoppedPids = new Set(); worker.notifyParentOfChildStateTransition = vi.fn(() => { new Uint8Array(kernelMemory.buffer).fill(0xff); @@ -415,17 +539,12 @@ describe("Rust-owned process wait lifecycle", () => { const drain = vi.fn((outPtr: number) => { if (drained) return 0; drained = true; - writeWakeEvent( - kernelMemory, - outPtr, - 0, - 43, - WAKE_PROCESS_CONTINUED, - ); + writeWakeEvent(kernelMemory, outPtr, 0, 43, WAKE_PROCESS_CONTINUED); return 1; }); const worker = createWorkerHarness({ kernel_drain_wakeup_events: drain }); worker.kernelMemory = kernelMemory; + installKernelWorkerTestScratch(worker, kernelMemory); worker.pendingPipeReaders = new Map(); worker.pendingPipeWriters = new Map(); worker.resumeStoppedProcess = vi.fn(() => false); @@ -444,29 +563,18 @@ describe("Rust-owned process wait lifecycle", () => { let batch = 0; const drain = vi.fn((outPtr: number) => { if (batch++ === 0) { - writeWakeEvent( - kernelMemory, - outPtr, - 0, - 43, - WAKE_PROCESS_CONTINUED, - ); + writeWakeEvent(kernelMemory, outPtr, 0, 43, WAKE_PROCESS_CONTINUED); return 1; } if (batch === 2) { - writeWakeEvent( - kernelMemory, - outPtr, - 0, - 43, - WAKE_PROCESS_STOPPED, - ); + writeWakeEvent(kernelMemory, outPtr, 0, 43, WAKE_PROCESS_STOPPED); return 1; } return 0; }); const worker = createWorkerHarness({ kernel_drain_wakeup_events: drain }); worker.kernelMemory = kernelMemory; + installKernelWorkerTestScratch(worker, kernelMemory); worker.stoppedPids = new Set(); worker.pendingPipeReaders = new Map(); worker.pendingPipeWriters = new Map(); @@ -498,6 +606,7 @@ describe("Rust-owned process wait lifecycle", () => { }); const worker = createWorkerHarness({ kernel_drain_wakeup_events: drain }); worker.kernelMemory = kernelMemory; + installKernelWorkerTestScratch(worker, kernelMemory); worker.stoppedPids = new Set(); worker.pendingPipeReaders = new Map(); worker.pendingPipeWriters = new Map(); @@ -527,10 +636,16 @@ describe("Rust-owned process wait lifecycle", () => { kernel_get_process_exit_signal: vi.fn(() => exitSignal), }); worker.kernelMemory = kernelMemory; - worker.processes = new Map([[42, { - channels: [channel], - memory: processMemory, - }]]); + installKernelWorkerTestScratch(worker, kernelMemory); + worker.processes = new Map([ + [ + 42, + { + channels: [channel], + memory: processMemory, + }, + ], + ]); worker.hostReaped = new Set(); worker.stoppedPids = new Set([42]); worker.parkedChannelCompletions = new Map(); @@ -538,7 +653,9 @@ describe("Rust-owned process wait lifecycle", () => { worker.deferredProcessWorkerStarts = new Map(); worker.pendingSleeps = new Map(); worker.releaseAllSharedMemoryForProcess = vi.fn(); - worker.notifyParentOfExitedProcess = vi.fn(() => { exitSignal = -3; }); + worker.notifyParentOfExitedProcess = vi.fn(() => { + exitSignal = -3; + }); worker.resumeStoppedProcess = vi.fn(); worker.notifyParentOfChildStateTransition = vi.fn(); worker.callbacks = { onExit }; @@ -569,16 +686,22 @@ describe("Rust-owned process wait lifecycle", () => { const waitChildPoll = vi.fn(() => 0); const processMemory = createSharedMemory(); const channel = createChannel(7, processMemory); - const worker = createWorkerHarness({ kernel_wait_child_poll: waitChildPoll }); - worker.processes = new Map([[7, { channels: [channel], memory: processMemory }]]); - worker.waitingForChild = [{ - parentPid: 7, - channel, - origArgs: [0, 0, 0, 0], - pid: 0, - options: 0, - syscallNr: ABI_SYSCALLS.Wait4, - }]; + const worker = createWorkerHarness({ + kernel_wait_child_poll: waitChildPoll, + }); + worker.processes = new Map([ + [7, { channels: [channel], memory: processMemory }], + ]); + worker.waitingForChild = [ + { + parentPid: 7, + channel, + origArgs: [0, 0, 0, 0], + pid: 0, + options: 0, + syscallNr: ABI_SYSCALLS.Wait4, + }, + ]; worker.recheckDeferredWaitpids(); @@ -589,24 +712,32 @@ describe("Rust-owned process wait lifecycle", () => { WAIT_EVENT_EXITED, WAIT_WNOWAIT, 128, + STRUCT_SIZE_KERNEL_WAIT_RESULT, ); }); it("services status that becomes eligible after a process-group change", () => { const channel = createChannel(7, createSharedMemory()); const worker = createWorkerHarness({}); - worker.processes = new Map([[7, { - channels: [channel], - memory: channel.memory, - }]]); - worker.waitingForChild = [{ - parentPid: 7, - channel, - origArgs: [0, 0, 0, 0], - pid: 0, - options: 0, - syscallNr: ABI_SYSCALLS.Wait4, - }]; + worker.processes = new Map([ + [ + 7, + { + channels: [channel], + memory: channel.memory, + }, + ], + ]); + worker.waitingForChild = [ + { + parentPid: 7, + channel, + origArgs: [0, 0, 0, 0], + pid: 0, + options: 0, + syscallNr: ABI_SYSCALLS.Wait4, + }, + ]; worker.pollWaitableChild = vi.fn(() => ({ kind: "event", childPid: 42, @@ -630,30 +761,40 @@ describe("Rust-owned process wait lifecycle", () => { const first = createChannel(7, processMemory, 0); const second = createChannel(7, processMemory, 256); let pollCount = 0; - const waitChildPoll = vi.fn(( - _parentPid: number, - _callerTid: number, - _targetPid: number, - _eventMask: number, - _flags: number, - resultPtr: number, - ) => { - if (pollCount++ > 0) return -10; // ECHILD after the first wait reaps. - writeKernelWaitResult(kernelMemory, resultPtr, { - waitStatus: 3 << 8, - siCode: WAIT_CLD_EXITED, - siStatus: 3, - childUid: 12, - rusage: new Uint8Array(STRUCT_SIZE_WASM_RUSAGE_WIRE), - }); - return 42; + const waitChildPoll = vi.fn( + ( + _parentPid: number, + _callerTid: number, + _targetPid: number, + _eventMask: number, + _flags: number, + resultPtr: number, + ) => { + if (pollCount++ > 0) return -10; // ECHILD after the first wait reaps. + writeKernelWaitResult(kernelMemory, resultPtr, { + waitStatus: 3 << 8, + siCode: WAIT_CLD_EXITED, + siStatus: 3, + childUid: 12, + rusage: new Uint8Array(STRUCT_SIZE_WASM_RUSAGE_WIRE), + }); + return 42; + }, + ); + const worker = createWorkerHarness({ + kernel_wait_child_poll: waitChildPoll, }); - const worker = createWorkerHarness({ kernel_wait_child_poll: waitChildPoll }); worker.kernelMemory = kernelMemory; - worker.processes = new Map([[7, { - channels: [first, second], - memory: processMemory, - }]]); + installKernelWorkerTestScratch(worker, kernelMemory); + worker.processes = new Map([ + [ + 7, + { + channels: [first, second], + memory: processMemory, + }, + ], + ]); worker.channelTids = new Map([["7:256", 8]]); worker.completeWaitpid = vi.fn(); worker.waitingForChild = [ @@ -678,9 +819,15 @@ describe("Rust-owned process wait lifecycle", () => { worker.wakeWaitingParent(7); expect(worker.waitingForChild).toEqual([]); - expect(worker.completeWaitpid.mock.calls.map((call: unknown[]) => call.slice(2))) - .toEqual([[42, 0], [-1, 10]]); - expect(new DataView(processMemory.buffer).getInt32(1024, true)).toBe(3 << 8); + expect( + worker.completeWaitpid.mock.calls.map((call: unknown[]) => call.slice(2)), + ).toEqual([ + [42, 0], + [-1, 10], + ]); + expect(new DataView(processMemory.buffer).getInt32(1024, true)).toBe( + 3 << 8, + ); }); it("completes every matching WNOWAIT waiter while leaving a running waiter blocked", () => { @@ -689,30 +836,40 @@ describe("Rust-owned process wait lifecycle", () => { const first = createChannel(7, processMemory, 0); const second = createChannel(7, processMemory, 256); const running = createChannel(7, processMemory, 512); - const waitChildPoll = vi.fn(( - _parentPid: number, - _callerTid: number, - targetPid: number, - _eventMask: number, - _flags: number, - resultPtr: number, - ) => { - if (targetPid === 43) return 0; - writeKernelWaitResult(kernelMemory, resultPtr, { - waitStatus: 0, - siCode: WAIT_CLD_EXITED, - siStatus: 0, - childUid: 99, - rusage: new Uint8Array(STRUCT_SIZE_WASM_RUSAGE_WIRE), - }); - return 42; + const waitChildPoll = vi.fn( + ( + _parentPid: number, + _callerTid: number, + targetPid: number, + _eventMask: number, + _flags: number, + resultPtr: number, + ) => { + if (targetPid === 43) return 0; + writeKernelWaitResult(kernelMemory, resultPtr, { + waitStatus: 0, + siCode: WAIT_CLD_EXITED, + siStatus: 0, + childUid: 99, + rusage: new Uint8Array(STRUCT_SIZE_WASM_RUSAGE_WIRE), + }); + return 42; + }, + ); + const worker = createWorkerHarness({ + kernel_wait_child_poll: waitChildPoll, }); - const worker = createWorkerHarness({ kernel_wait_child_poll: waitChildPoll }); worker.kernelMemory = kernelMemory; - worker.processes = new Map([[7, { - channels: [first, second, running], - memory: processMemory, - }]]); + installKernelWorkerTestScratch(worker, kernelMemory); + worker.processes = new Map([ + [ + 7, + { + channels: [first, second, running], + memory: processMemory, + }, + ], + ]); worker.channelTids = new Map([ ["7:256", 8], ["7:512", 9], @@ -738,13 +895,40 @@ describe("Rust-owned process wait lifecycle", () => { expect(worker.waitingForChild).toEqual([runningWaiter]); expect(worker.completeWaitid).toHaveBeenCalledTimes(2); - expect(waitChildPoll.mock.calls.filter((call: unknown[]) => call[2] === 42)) - .toEqual([ - [7, 7, 42, WAIT_EVENT_EXITED, WAIT_WNOWAIT, 128], - [7, 8, 42, WAIT_EVENT_EXITED, WAIT_WNOWAIT, 128], - ]); - expect(new DataView(processMemory.buffer).getInt32(1024 + 12, true)).toBe(42); - expect(new DataView(processMemory.buffer).getInt32(1280 + 12, true)).toBe(42); + expect( + waitChildPoll.mock.calls.filter((call: unknown[]) => call[2] === 42), + ).toEqual([ + [ + 7, + 7, + 42, + WAIT_EVENT_EXITED, + WAIT_WNOWAIT, + 128, + STRUCT_SIZE_KERNEL_WAIT_RESULT, + ], + [ + 7, + 8, + 42, + WAIT_EVENT_EXITED, + WAIT_WNOWAIT, + 128, + STRUCT_SIZE_KERNEL_WAIT_RESULT, + ], + ]); + expect( + new DataView(processMemory.buffer).getInt32( + 1024 + PROCESS_SIGINFO_WASM32_PID_OFFSET, + true, + ), + ).toBe(42); + expect( + new DataView(processMemory.buffer).getInt32( + 1280 + PROCESS_SIGINFO_WASM32_PID_OFFSET, + true, + ), + ).toBe(42); }); it("interrupts the exact host-deferred wait thread with its caught signal", () => { @@ -770,18 +954,26 @@ describe("Rust-owned process wait lifecycle", () => { kernel_dequeue_signal: dequeue, }); worker.kernelMemory = kernelMemory; - worker.processes = new Map([[7, { - channels: [channel], - memory: processMemory, - }]]); - worker.waitingForChild = [{ - parentPid: 7, - channel, - origArgs: [-1, 0, 0, 0], - pid: -1, - options: 0, - syscallNr: ABI_SYSCALLS.Wait4, - }]; + installKernelWorkerTestScratch(worker, kernelMemory); + worker.processes = new Map([ + [ + 7, + { + channels: [channel], + memory: processMemory, + }, + ], + ]); + worker.waitingForChild = [ + { + parentPid: 7, + channel, + origArgs: [-1, 0, 0, 0], + pid: -1, + options: 0, + syscallNr: ABI_SYSCALLS.Wait4, + }, + ]; worker.wakeWaitingParent = vi.fn(); worker.finishSignalTermination = vi.fn(() => false); worker.completeChannel = vi.fn(); @@ -807,10 +999,15 @@ describe("Rust-owned process wait lifecycle", () => { const caller = createChannel(7, memory, 0); const target = createChannel(7, memory, 256); const worker = createWorkerHarness({}); - worker.processes = new Map([[7, { - channels: [caller, target], - memory, - }]]); + worker.processes = new Map([ + [ + 7, + { + channels: [caller, target], + memory, + }, + ], + ]); worker.channelTids = new Map([["7:256", 99]]); worker.pendingCancels = new Set(); worker.pendingFutexWaits = new Map(); @@ -818,14 +1015,16 @@ describe("Rust-owned process wait lifecycle", () => { worker.pendingSelectRetries = new Map(); worker.pendingPipeReaders = new Map(); worker.pendingPipeWriters = new Map(); - worker.waitingForChild = [{ - parentPid: 7, - channel: target, - origArgs: [-1, 0, 0, 0], - pid: -1, - options: 0, - syscallNr: ABI_SYSCALLS.Wait4, - }]; + worker.waitingForChild = [ + { + parentPid: 7, + channel: target, + origArgs: [-1, 0, 0, 0], + pid: -1, + options: 0, + syscallNr: ABI_SYSCALLS.Wait4, + }, + ]; worker.runSyntheticMemorySyscall = vi.fn(() => ({ retVal: 0, errVal: 0 })); worker.completeChannelRaw = vi.fn(); worker.relistenChannel = vi.fn(); @@ -840,12 +1039,7 @@ describe("Rust-owned process wait lifecycle", () => { expect(worker.waitingForChild).toEqual([]); expect(worker.pendingCancels.has(target)).toBe(true); expect(worker.completeChannelRaw).toHaveBeenNthCalledWith(1, caller, 0, 0); - expect(worker.completeChannelRaw).toHaveBeenNthCalledWith( - 2, - target, - -4, - 4, - ); + expect(worker.completeChannelRaw).toHaveBeenNthCalledWith(2, target, -4, 4); expect(worker.relistenChannel).toHaveBeenCalledWith(target); }); @@ -857,10 +1051,15 @@ describe("Rust-owned process wait lifecycle", () => { const futexPtr = 4096; new Int32Array(memory.buffer)[futexPtr >>> 2] = 0; const worker = createWorkerHarness({}); - worker.processes = new Map([[7, { - channels: [first, second, waker], - memory, - }]]); + worker.processes = new Map([ + [ + 7, + { + channels: [first, second, waker], + memory, + }, + ], + ]); worker.pendingFutexWaits = new Map(); worker.completeChannelRaw = vi.fn(); worker.relistenChannel = vi.fn(); @@ -901,10 +1100,15 @@ describe("Rust-owned process wait lifecycle", () => { worker.pendingCancels = new Set([channel]); worker.waitingForChild = []; worker.pendingSleeps = new Map(); - worker.pendingFutexWaits = new Map([[channel, { - futexIndex: 1024, - retire, - }]]); + worker.pendingFutexWaits = new Map([ + [ + channel, + { + futexIndex: 1024, + retire, + }, + ], + ]); worker.pendingPollRetries = new Map(); worker.pendingSelectRetries = new Map(); worker.pendingPipeReaders = new Map(); @@ -958,8 +1162,9 @@ describe("Rust-owned process wait lifecycle", () => { expect(readStatus(second)).toBe(CHANNEL_STATUS_PENDING); // A peer mapping the same SharedArrayBuffer observes completed syscall // output even though this stopped process remains parked at CH_PENDING. - expect(new Uint8Array(memory.buffer, 2048, 3)) - .toEqual(Uint8Array.of(1, 2, 3)); + expect(new Uint8Array(memory.buffer, 2048, 3)).toEqual( + Uint8Array.of(1, 2, 3), + ); expect(worker.synchronizeSharedMemoryForBoundary).toHaveBeenCalledTimes(2); worker.resumeStoppedProcess(42); @@ -967,9 +1172,21 @@ describe("Rust-owned process wait lifecycle", () => { expect(worker.parkedChannelCompletions.size).toBe(0); expect(readStatus(first)).toBe(CHANNEL_STATUS_COMPLETE); expect(readStatus(second)).toBe(CHANNEL_STATUS_COMPLETE); - expect(new DataView(memory.buffer, first.channelOffset).getBigInt64(CH_RETURN, true)).toBe(7n); - expect(new DataView(memory.buffer, second.channelOffset).getBigInt64(CH_RETURN, true)).toBe(8n); - expect(new Uint8Array(memory.buffer, 2048, 3)).toEqual(Uint8Array.of(1, 2, 3)); + expect( + new DataView(memory.buffer, first.channelOffset).getBigInt64( + CH_RETURN, + true, + ), + ).toBe(7n); + expect( + new DataView(memory.buffer, second.channelOffset).getBigInt64( + CH_RETURN, + true, + ), + ).toBe(8n); + expect(new Uint8Array(memory.buffer, 2048, 3)).toEqual( + Uint8Array.of(1, 2, 3), + ); expect(worker.relistenChannel).toHaveBeenCalledOnce(); expect(worker.relistenChannel).toHaveBeenCalledWith(first); }); @@ -995,23 +1212,34 @@ describe("Rust-owned process wait lifecycle", () => { kernel_get_process_exit_signal: vi.fn(() => -1), }); worker.kernelMemory = kernelMemory; - worker.processes = new Map([[42, { - channels: [channel], - memory: processMemory, - }]]); + installKernelWorkerTestScratch(worker, kernelMemory); + worker.processes = new Map([ + [ + 42, + { + channels: [channel], + memory: processMemory, + }, + ], + ]); worker.channelTids = new Map(); worker.hostReaped = new Set(); worker.stoppedPids = new Set([42]); - worker.parkedChannelCompletions = new Map([[channel, { - prepared: { - kind: "raw", - outputWrites: [], - retVal: 0, - errVal: 0, - relistenRequested: false, - }, - relistenRequested: false, - }]]); + worker.parkedChannelCompletions = new Map([ + [ + channel, + { + prepared: { + kind: "raw", + outputWrites: [], + retVal: 0, + errVal: 0, + relistenRequested: false, + }, + relistenRequested: false, + }, + ], + ]); worker.deferredStoppedChannels = new Map(); worker.deferredProcessWorkerStarts = new Map(); worker.publishPreparedChannelCompletion = vi.fn(); @@ -1019,8 +1247,9 @@ describe("Rust-owned process wait lifecycle", () => { worker.resumeStoppedProcess(42); expect(dequeue).toHaveBeenCalledOnce(); - expect(new DataView(processMemory.buffer).getUint32(CH_SIG_SIGNUM, true)) - .toBe(SIGCONT); + expect( + new DataView(processMemory.buffer).getUint32(CH_SIG_SIGNUM, true), + ).toBe(SIGCONT); expect(worker.publishPreparedChannelCompletion).toHaveBeenCalledOnce(); }); @@ -1054,10 +1283,16 @@ describe("Rust-owned process wait lifecycle", () => { kernel_get_process_exit_signal: vi.fn(() => -1), }); worker.kernelMemory = kernelMemory; - worker.processes = new Map([[42, { - channels: [first, second], - memory: processMemory, - }]]); + installKernelWorkerTestScratch(worker, kernelMemory); + worker.processes = new Map([ + [ + 42, + { + channels: [first, second], + memory: processMemory, + }, + ], + ]); worker.channelTids = new Map([ ["42:0", 101], ["42:256", 102], @@ -1080,20 +1315,18 @@ describe("Rust-owned process wait lifecycle", () => { const publish = vi.fn(); worker.publishPreparedChannelCompletion = publish; - expect(worker.startProcessWorkerWhenRunnable( - 42, - processMemory, - start, - cancel, - )).toBe("deferred"); + expect( + worker.startProcessWorkerWhenRunnable(42, processMemory, start, cancel), + ).toBe("deferred"); state = PROCESS_STATE_RUNNING; expect(worker.resumeStoppedProcess(42)).toBe(false); expect(start).not.toHaveBeenCalled(); expect(publish).not.toHaveBeenCalled(); expect(worker.parkedChannelCompletions.size).toBe(2); - expect(new DataView(processMemory.buffer).getUint32(CH_SIG_SIGNUM, true)) - .toBe(SIGCONT); + expect( + new DataView(processMemory.buffer).getUint32(CH_SIG_SIGNUM, true), + ).toBe(SIGCONT); state = PROCESS_STATE_RUNNING; expect(worker.resumeStoppedProcess(42)).toBe(true); @@ -1103,8 +1336,9 @@ describe("Rust-owned process wait lifecycle", () => { expect(dequeue).toHaveBeenCalledTimes(3); // The first channel's caught signal was not dequeued/cleared again on the // second resume attempt. - expect(new DataView(processMemory.buffer).getUint32(CH_SIG_SIGNUM, true)) - .toBe(SIGCONT); + expect( + new DataView(processMemory.buffer).getUint32(CH_SIG_SIGNUM, true), + ).toBe(SIGCONT); }); it("interrupts a stopped exact wait thread with its retained caught signal", () => { @@ -1123,18 +1357,26 @@ describe("Rust-owned process wait lifecycle", () => { kernel_get_process_exit_signal: vi.fn(() => -1), }); worker.kernelMemory = kernelMemory; - worker.processes = new Map([[7, { - channels: [channel], - memory: processMemory, - }]]); - worker.waitingForChild = [{ - parentPid: 7, - channel, - origArgs: [-1, 0, 0, 0], - pid: -1, - options: 0, - syscallNr: ABI_SYSCALLS.Wait4, - }]; + installKernelWorkerTestScratch(worker, kernelMemory); + worker.processes = new Map([ + [ + 7, + { + channels: [channel], + memory: processMemory, + }, + ], + ]); + worker.waitingForChild = [ + { + parentPid: 7, + channel, + origArgs: [-1, 0, 0, 0], + pid: -1, + options: 0, + syscallNr: ABI_SYSCALLS.Wait4, + }, + ]; worker.stoppedPids = new Set([7]); worker.parkedChannelCompletions = new Map(); worker.deferredStoppedChannels = new Map(); @@ -1154,41 +1396,56 @@ describe("Rust-owned process wait lifecycle", () => { const sequence: string[] = []; const start = vi.fn(() => sequence.push("start")); const cancel = vi.fn(); - worker.publishPreparedChannelCompletion = vi.fn((_channel: unknown, prepared: { - retVal: number; - errVal: number; - }) => { - sequence.push("publish"); - expect(prepared.retVal).toBe(-1); - expect(prepared.errVal).toBe(4); - }); + worker.publishPreparedChannelCompletion = vi.fn( + ( + _channel: unknown, + prepared: { + retVal: number; + errVal: number; + }, + ) => { + sequence.push("publish"); + expect(prepared.retVal).toBe(-1); + expect(prepared.errVal).toBe(4); + }, + ); - expect(worker.startProcessWorkerWhenRunnable( - 7, - processMemory, - start, - cancel, - )).toBe("deferred"); + expect( + worker.startProcessWorkerWhenRunnable(7, processMemory, start, cancel), + ).toBe("deferred"); state = PROCESS_STATE_RUNNING; expect(worker.resumeStoppedProcess(7)).toBe(true); expect(worker.waitingForChild).toEqual([]); expect(dequeue).toHaveBeenCalledOnce(); expect(sequence).toEqual(["start", "publish"]); - expect(new DataView(processMemory.buffer).getUint32(CH_SIG_SIGNUM, true)) - .toBe(SIGUSR1); + expect( + new DataView(processMemory.buffer).getUint32(CH_SIG_SIGNUM, true), + ).toBe(SIGUSR1); }); - it("materializes stopped descriptor output before wake scratch is reused", () => { + it("materializes detached descriptor output before wake scratch is reused", () => { const kernelMemory = createSharedMemory(); const processMemory = createSharedMemory(); const channel = createChannel(42, processMemory); const outputPtr = 2048; markPending(channel); new Uint8Array(kernelMemory.buffer, 128 + CH_DATA, 4).set([9, 8, 7, 6]); + // WHY: completeChannel consumes bytes detached while the scratch lease is + // still active. It must never reconstruct output later from shared scratch, + // which a lifecycle wake may synchronously reuse. + const detachedOutput = [ + { + ptr: outputPtr, + bytes: new Uint8Array(kernelMemory.buffer, 128 + CH_DATA, 4).slice(), + }, + ]; const worker = createWorkerHarness({}); worker.kernelMemory = kernelMemory; - worker.processes = new Map([[42, { channels: [channel], memory: processMemory }]]); + installKernelWorkerTestScratch(worker, kernelMemory); + worker.processes = new Map([ + [42, { channels: [channel], memory: processMemory }], + ]); worker.stoppedPids = new Set([42]); worker.parkedChannelCompletions = new Map(); worker.deferredStoppedChannels = new Map(); @@ -1203,8 +1460,9 @@ describe("Rust-owned process wait lifecycle", () => { worker.relistenChannel = vi.fn(); worker.drainAndProcessWakeupEvents = vi.fn(() => { sequence.push("drain"); - expect(new Uint8Array(processMemory.buffer, outputPtr, 4)) - .toEqual(Uint8Array.of(9, 8, 7, 6)); + expect(new Uint8Array(processMemory.buffer, outputPtr, 4)).toEqual( + Uint8Array.of(9, 8, 7, 6), + ); new Uint8Array(kernelMemory.buffer, 128 + CH_DATA, 4).fill(0xee); }); @@ -1212,23 +1470,28 @@ describe("Rust-owned process wait lifecycle", () => { channel, ABI_SYSCALLS.Read, [0, outputPtr, 4], - [{ - argIndex: 1, - direction: "out", - size: { type: "arg", argIndex: 2 }, - }], + [ + { + argIndex: 1, + direction: "out", + size: { type: "arg", argIndex: 2 }, + }, + ], 4, 0, + detachedOutput, ); expect(readStatus(channel)).toBe(CHANNEL_STATUS_PENDING); - expect(new Uint8Array(processMemory.buffer, outputPtr, 4)) - .toEqual(Uint8Array.of(9, 8, 7, 6)); + expect(new Uint8Array(processMemory.buffer, outputPtr, 4)).toEqual( + Uint8Array.of(9, 8, 7, 6), + ); expect(worker.synchronizeSharedMemoryForBoundary).toHaveBeenCalledOnce(); expect(sequence).toEqual(["sync", "drain"]); worker.resumeStoppedProcess(42); - expect(new Uint8Array(processMemory.buffer, outputPtr, 4)) - .toEqual(Uint8Array.of(9, 8, 7, 6)); + expect(new Uint8Array(processMemory.buffer, outputPtr, 4)).toEqual( + Uint8Array.of(9, 8, 7, 6), + ); }); it("synchronizes raw completion before lifecycle wake observers", () => { @@ -1261,7 +1524,9 @@ describe("Rust-owned process wait lifecycle", () => { it("defers an exact retry while stopped and re-arms it on continuation", () => { const channel = createChannel(42, createSharedMemory()); const worker = createWorkerHarness({}); - worker.processes = new Map([[42, { channels: [channel], memory: channel.memory }]]); + worker.processes = new Map([ + [42, { channels: [channel], memory: channel.memory }], + ]); worker.stoppedPids = new Set([42]); worker.deferredStoppedChannels = new Map(); worker.parkedChannelCompletions = new Map(); @@ -1290,16 +1555,19 @@ describe("Rust-owned process wait lifecycle", () => { worker.processes = new Map([[42, { channels: [first, second], memory }]]); worker.stoppedPids = new Set([42]); worker.parkedChannelCompletions = new Map([ - [first, { - prepared: { - kind: "raw", - outputWrites: [], - retVal: 1, - errVal: 0, + [ + first, + { + prepared: { + kind: "raw", + outputWrites: [], + retVal: 1, + errVal: 0, + relistenRequested: false, + }, relistenRequested: false, }, - relistenRequested: false, - }], + ], ]); worker.deferredStoppedChannels = new Map([[second, true]]); worker.hostReaped = new Set(); @@ -1385,7 +1653,9 @@ describe("Rust-owned process wait lifecycle", () => { const channel = createChannel(pid, memory); const setCurrentTid = vi.fn(() => -3); const onExit = vi.fn(); - const worker = createWorkerHarness({ kernel_set_current_tid: setCurrentTid }); + const worker = createWorkerHarness({ + kernel_set_current_tid: setCurrentTid, + }); worker.processes = new Map([[pid, { channels: [channel], memory }]]); worker.hostReaped = new Set(); worker.callbacks = { onExit }; @@ -1420,10 +1690,15 @@ describe("Rust-owned process wait lifecycle", () => { const threadChannel = createChannel(pid, memory, 256); const onExit = vi.fn(); const worker = createWorkerHarness(); - worker.processes = new Map([[pid, { - channels: [mainChannel, threadChannel], - memory, - }]]); + worker.processes = new Map([ + [ + pid, + { + channels: [mainChannel, threadChannel], + memory, + }, + ], + ]); worker.channelTids = new Map(); worker.hostReaped = new Set(); worker.callbacks = { onExit }; @@ -1431,7 +1706,8 @@ describe("Rust-owned process wait lifecycle", () => { worker.completeChannelRaw = vi.fn(); worker.relistenChannel = vi.fn(); worker._handleSyscallInner = vi.fn(() => - worker.guestTidForChannel(threadChannel)); + worker.guestTidForChannel(threadChannel), + ); const error = vi.spyOn(console, "error").mockImplementation(() => {}); const expected = `No kernel-validated TID for non-main channel ${threadChannel.channelOffset} ` + @@ -1490,39 +1766,45 @@ describe("Rust-owned process wait lifecycle", () => { it("retires stale pthread transport metadata when deactivating a zombie", () => { const pid = 42; const otherPid = 420; - const worker = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - retireAsyncChannelsForProcess: vi.fn(), - discardStoppedChannelStateForProcess: vi.fn(), - waitingForChild: [], - releaseAllSharedMemoryForProcess: vi.fn(), - activeChannels: [{ pid }, { pid: otherPid }], - channelTids: new Map([ - [`${pid}:1000`, 1001], - [`${otherPid}:2000`, 2001], - ]), - threadForkContexts: new Map([ - [`${pid}:1000`, { fnPtr: 1, argPtr: 2 }], - [`${otherPid}:2000`, { fnPtr: 3, argPtr: 4 }], - ]), - threadCtidPtrs: new Map([ - [`${pid}:1001`, 3000], - [`${otherPid}:2001`, 4000], - ]), - processes: new Map([[pid, {}], [otherPid, {}]]), - execHandoffPids: new Set([pid]), - stdinFinite: new Set([pid]), - stdinBuffers: new Map([[pid, new Uint8Array()]]), - alarmTimers: new Map(), - posixTimers: new Map(), - cancelPendingSleepsForProcess: vi.fn(), - cleanupPendingPollRetries: vi.fn(), - cleanupPendingSelectRetries: vi.fn(), - cleanupPendingSignalWaits: vi.fn(), - cleanupUdpBindings: vi.fn(), - cleanupTcpListeners: vi.fn(), - hostReaped: new Set([pid]), - kernel: { releaseProcessViews: vi.fn() }, - }) as any; + const worker = Object.assign( + Object.create(CentralizedKernelWorker.prototype), + { + retireAsyncChannelsForProcess: vi.fn(), + discardStoppedChannelStateForProcess: vi.fn(), + waitingForChild: [], + releaseAllSharedMemoryForProcess: vi.fn(), + activeChannels: [{ pid }, { pid: otherPid }], + channelTids: new Map([ + [`${pid}:1000`, 1001], + [`${otherPid}:2000`, 2001], + ]), + threadForkContexts: new Map([ + [`${pid}:1000`, { fnPtr: 1, argPtr: 2 }], + [`${otherPid}:2000`, { fnPtr: 3, argPtr: 4 }], + ]), + threadCtidPtrs: new Map([ + [`${pid}:1001`, 3000], + [`${otherPid}:2001`, 4000], + ]), + processes: new Map([ + [pid, {}], + [otherPid, {}], + ]), + execHandoffPids: new Set([pid]), + stdinFinite: new Set([pid]), + stdinBuffers: new Map([[pid, new Uint8Array()]]), + alarmTimers: new Map(), + posixTimers: new Map(), + cancelPendingSleepsForProcess: vi.fn(), + cleanupPendingPollRetries: vi.fn(), + cleanupPendingSelectRetries: vi.fn(), + cleanupPendingSignalWaits: vi.fn(), + cleanupUdpBindings: vi.fn(), + cleanupTcpListeners: vi.fn(), + hostReaped: new Set([pid]), + kernel: { releaseProcessViews: vi.fn() }, + }, + ) as any; worker.deactivateProcess(pid); @@ -1750,9 +2032,14 @@ describe("Rust-owned process wait lifecycle", () => { ); it("uses the explicit termination signal instead of classifying high exit codes", () => { - const exitSignals = new Map([[42, 0], [43, 15]]); + const exitSignals = new Map([ + [42, 0], + [43, 15], + ]); const worker = createWorkerHarness({ - kernel_get_process_exit_signal: vi.fn((pid: number) => exitSignals.get(pid) ?? -1), + kernel_get_process_exit_signal: vi.fn( + (pid: number) => exitSignals.get(pid) ?? -1, + ), }); const normalChannel = createChannel(42, createSharedMemory()); const signaledChannel = createChannel(43, createSharedMemory()); @@ -1767,7 +2054,9 @@ describe("Rust-owned process wait lifecycle", () => { worker.reapKilledProcessesAfterSyscall(); expect(worker.handleProcessTerminated).toHaveBeenCalledOnce(); - expect(worker.handleProcessTerminated).toHaveBeenCalledWith(signaledChannel); + expect(worker.handleProcessTerminated).toHaveBeenCalledWith( + signaledChannel, + ); }); it("SA_NOCLDWAIT auto-reaps through Rust without SIGCHLD", () => { @@ -1791,29 +2080,37 @@ describe("Rust-owned process wait lifecycle", () => { }); }); -function createWorkerHarness(exports: Record, kernelPtrWidth: 4 | 8 = 4): any { - return Object.assign(Object.create(CentralizedKernelWorker.prototype), { - kernel: { - toKernelPtr(value: number | bigint): number | bigint { - const numberValue = typeof value === "bigint" ? Number(value) : value; - return kernelPtrWidth === 8 ? BigInt(numberValue) : numberValue; +function createWorkerHarness( + exports: Record, + kernelPtrWidth: 4 | 8 = 4, +): any { + const kernelMemory = createSharedMemory(); + const worker = Object.assign( + Object.create(CentralizedKernelWorker.prototype), + { + kernel: { + toKernelPtr(value: number | bigint): number | bigint { + const numberValue = typeof value === "bigint" ? Number(value) : value; + return kernelPtrWidth === 8 ? BigInt(numberValue) : numberValue; + }, }, - }, - kernelInstance: { - exports: { - kernel_get_process_exit_signal: vi.fn(() => -1), - kernel_get_process_state: vi.fn(() => PROCESS_STATE_RUNNING), - kernel_set_current_tid: vi.fn(() => 0), - ...exports, + kernelInstance: { + exports: { + kernel_get_process_exit_signal: vi.fn(() => -1), + kernel_get_process_state: vi.fn(() => PROCESS_STATE_RUNNING), + kernel_set_current_tid: vi.fn(() => 0), + ...exports, + }, }, + kernelMemory, + processes: new Map(), + channelTids: new Map(), + pendingCancels: new Set(), + deferredProcessWorkerStarts: new Map(), }, - kernelMemory: createSharedMemory(), - scratchOffset: 128, - processes: new Map(), - channelTids: new Map(), - pendingCancels: new Set(), - deferredProcessWorkerStarts: new Map(), - }); + ); + installKernelWorkerTestScratch(worker, kernelMemory, 128, kernelPtrWidth); + return worker; } function createSharedMemory(): WebAssembly.Memory { @@ -1824,7 +2121,11 @@ function createSharedMemory(): WebAssembly.Memory { }); } -function createChannel(pid: number, memory: WebAssembly.Memory, channelOffset = 0): any { +function createChannel( + pid: number, + memory: WebAssembly.Memory, + channelOffset = 0, +): any { return { pid, memory, @@ -1855,12 +2156,27 @@ function writeKernelWaitResult( }, ): void { const view = new DataView(memory.buffer); - view.setInt32(ptr + KERNEL_WAIT_RESULT_WAIT_STATUS_OFFSET, result.waitStatus, true); + view.setInt32( + ptr + KERNEL_WAIT_RESULT_WAIT_STATUS_OFFSET, + result.waitStatus, + true, + ); view.setInt32(ptr + KERNEL_WAIT_RESULT_SI_CODE_OFFSET, result.siCode, true); - view.setInt32(ptr + KERNEL_WAIT_RESULT_SI_STATUS_OFFSET, result.siStatus, true); - view.setUint32(ptr + KERNEL_WAIT_RESULT_CHILD_UID_OFFSET, result.childUid, true); - new Uint8Array(memory.buffer, ptr + KERNEL_WAIT_RESULT_RUSAGE_OFFSET, result.rusage.length) - .set(result.rusage); + view.setInt32( + ptr + KERNEL_WAIT_RESULT_SI_STATUS_OFFSET, + result.siStatus, + true, + ); + view.setUint32( + ptr + KERNEL_WAIT_RESULT_CHILD_UID_OFFSET, + result.childUid, + true, + ); + new Uint8Array( + memory.buffer, + ptr + KERNEL_WAIT_RESULT_RUSAGE_OFFSET, + result.rusage.length, + ).set(result.rusage); } function writeWakeEvent( diff --git a/host/test/program-fixture-freshness.test.ts b/host/test/program-fixture-freshness.test.ts new file mode 100644 index 0000000000..c7aabb9d93 --- /dev/null +++ b/host/test/program-fixture-freshness.test.ts @@ -0,0 +1,175 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { ABI_VERSION } from "../src/generated/abi"; +import { + captureProgramFixtureBuildContract, + programFixtureNeedsRebuild, + stampProgramFixture, +} from "./program-fixture-freshness"; + +const temporaryDirectories: string[] = []; + +function uleb128(value: number): number[] { + const bytes: number[] = []; + do { + let byte = value & 0x7f; + value = Math.floor(value / 128); + if (value !== 0) byte |= 0x80; + bytes.push(byte); + } while (value !== 0); + return bytes; +} + +function sleb128I32(value: number): number[] { + const bytes: number[] = []; + for (;;) { + let byte = value & 0x7f; + value >>= 7; + const signBit = (byte & 0x40) !== 0; + if ((value === 0 && !signBit) || (value === -1 && signBit)) { + bytes.push(byte); + return bytes; + } + bytes.push(byte | 0x80); + } +} + +function section(id: number, payload: number[]): number[] { + return [id, ...uleb128(payload.length), ...payload]; +} + +function nameBytes(name: string): number[] { + const encoded = new TextEncoder().encode(name); + return [...uleb128(encoded.length), ...encoded]; +} + +function executableWasmWithAbi(abi: number): Uint8Array { + const functionBody = [0x00, 0x41, ...sleb128I32(abi), 0x0b]; + return Uint8Array.from([ + 0x00, 0x61, 0x73, 0x6d, + 0x01, 0x00, 0x00, 0x00, + ...section(1, [0x01, 0x60, 0x00, 0x01, 0x7f]), + ...section(3, [0x01, 0x00]), + ...section(7, [ + 0x01, + ...nameBytes("__abi_version"), + 0x00, + 0x00, + ]), + ...section(10, [ + 0x01, + ...uleb128(functionBody.length), + ...functionBody, + ]), + ]); +} + +function writeAt(path: string, contents: string | Uint8Array): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, contents); + utimesSync(path, 100, 100); +} + +function createFixture(): { + root: string; + source: string; + input: string; + output: string; +} { + const root = mkdtempSync(join(tmpdir(), "kandelo-fixture-freshness-")); + temporaryDirectories.push(root); + const source = join(root, "examples", "fixture.c"); + const input = join(root, "sysroot", "include", "fixture.h"); + const output = join(root, "examples", "fixture.wasm"); + writeAt(source, "int main(void) { return FIXTURE_VALUE; }\n"); + writeAt(input, "#define FIXTURE_VALUE 0\n"); + return { root, source, input, output }; +} + +function capture(root: string, input: string) { + return captureProgramFixtureBuildContract( + root, + "wasm32\ncompiler=test-1", + [input], + ); +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("compiled program fixture freshness", () => { + it("accepts only the exact stamped source content, independent of mtimes", () => { + const { root, source, input, output } = createFixture(); + const contract = capture(root, input); + writeAt(output, executableWasmWithAbi(ABI_VERSION)); + stampProgramFixture(source, output, contract); + + expect(programFixtureNeedsRebuild(source, output, contract)).toBe(false); + + // A newly touched output cannot conceal source bytes changed at the same + // timestamp. + writeAt(source, "int main(void) { return FIXTURE_VALUE + 1; }\n"); + utimesSync(output, 10_000, 10_000); + expect(programFixtureNeedsRebuild(source, output, contract)).toBe(true); + }); + + it("invalidates same-ABI outputs after compiler/sysroot input changes", () => { + const { root, source, input, output } = createFixture(); + const originalContract = capture(root, input); + writeAt(output, executableWasmWithAbi(ABI_VERSION)); + stampProgramFixture(source, output, originalContract); + expect( + programFixtureNeedsRebuild(source, output, originalContract), + ).toBe(false); + + writeAt(input, "#define FIXTURE_VALUE 1\n"); + const changedContract = capture(root, input); + expect(changedContract.inputFingerprint).not.toBe( + originalContract.inputFingerprint, + ); + expect(programFixtureNeedsRebuild(source, output, changedContract)).toBe( + true, + ); + }); + + it("rejects missing, malformed, or duplicate input stamps", () => { + const { root, source, input, output } = createFixture(); + const contract = capture(root, input); + + writeAt(output, executableWasmWithAbi(ABI_VERSION)); + expect(programFixtureNeedsRebuild(source, output, contract)).toBe(true); + + stampProgramFixture(source, output, contract); + const stamped = readFileSync(output); + stamped[stamped.byteLength - 1] = "z".charCodeAt(0); + writeAt(output, stamped); + expect(programFixtureNeedsRebuild(source, output, contract)).toBe(true); + + writeAt(output, executableWasmWithAbi(ABI_VERSION)); + stampProgramFixture(source, output, contract); + stampProgramFixture(source, output, contract); + expect(programFixtureNeedsRebuild(source, output, contract)).toBe(true); + }); + + it("rejects an otherwise current stamp under the wrong ABI", () => { + const { root, source, input, output } = createFixture(); + const contract = capture(root, input); + writeAt(output, executableWasmWithAbi(ABI_VERSION - 1)); + stampProgramFixture(source, output, contract); + + expect(programFixtureNeedsRebuild(source, output, contract)).toBe(true); + }); +}); diff --git a/host/test/program-fixture-freshness.ts b/host/test/program-fixture-freshness.ts new file mode 100644 index 0000000000..59b3827367 --- /dev/null +++ b/host/test/program-fixture-freshness.ts @@ -0,0 +1,270 @@ +import { createHash } from "node:crypto"; +import { + appendFileSync, + existsSync, + lstatSync, + readFileSync, + readlinkSync, + readdirSync, + realpathSync, + statSync, +} from "node:fs"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; + +import { extractAbiVersion } from "../src/constants"; +import { ABI_VERSION } from "../src/generated/abi"; + +const FIXTURE_INPUT_SECTION = "kandelo.test_fixture.inputs"; +const FIXTURE_FINGERPRINT_SCHEMA = "kandelo-test-fixture-inputs-v1"; + +export interface ProgramFixtureBuildContract { + readonly repoRoot: string; + readonly inputFingerprint: string; +} + +interface InputRecord { + readonly name: string; + readonly kind: "file" | "symlink"; + readonly bytes: Uint8Array; +} + +function relativeInputName(repoRoot: string, path: string): string { + const name = relative(resolve(repoRoot), resolve(path)); + if ( + name === "" + || name === ".." + || name.startsWith(`..${sep}`) + || isAbsolute(name) + ) { + throw new Error(`fixture build input is outside the repository: ${path}`); + } + return name.split(sep).join("/"); +} + +function collectInputRecords( + repoRoot: string, + path: string, + records: Map, + activeDirectories: Set, +): void { + const name = relativeInputName(repoRoot, path); + const metadata = lstatSync(path); + if (metadata.isSymbolicLink()) { + records.set(`${name}\0link`, { + name, + kind: "symlink", + bytes: Buffer.from(readlinkSync(path)), + }); + const followed = statSync(path); + if (followed.isFile()) { + records.set(`${name}\0file`, { + name, + kind: "file", + bytes: readFileSync(path), + }); + return; + } + if (!followed.isDirectory()) return; + } else if (metadata.isFile()) { + records.set(`${name}\0file`, { + name, + kind: "file", + bytes: readFileSync(path), + }); + return; + } else if (!metadata.isDirectory()) { + return; + } + + // WHY: sysroots may contain directory symlinks. Guard the followed identity, + // not its logical spelling, so a recursive symlink fails closed instead of + // walking forever. + const directoryIdentity = realpathSync(path); + if (activeDirectories.has(directoryIdentity)) { + throw new Error(`recursive fixture build-input directory: ${path}`); + } + activeDirectories.add(directoryIdentity); + try { + for (const entry of readdirSync(path).sort()) { + collectInputRecords( + repoRoot, + join(path, entry), + records, + activeDirectories, + ); + } + } finally { + activeDirectories.delete(directoryIdentity); + } +} + +function updateFramed( + hash: ReturnType, + value: string | Uint8Array, +): void { + const bytes = typeof value === "string" ? Buffer.from(value) : value; + hash.update(String(bytes.byteLength)); + hash.update(":"); + hash.update(bytes); +} + +/** + * Capture the exact compiler/sysroot/glue input state shared by a family of + * fixtures. The returned digest is content-based; touching an old output or + * preserving an ABI number cannot make it current. + */ +export function captureProgramFixtureBuildContract( + repoRoot: string, + identity: string, + inputPaths: readonly string[], +): ProgramFixtureBuildContract { + const resolvedRepoRoot = resolve(repoRoot); + const records = new Map(); + for (const path of inputPaths) { + collectInputRecords(resolvedRepoRoot, path, records, new Set()); + } + const hash = createHash("sha256"); + // Bump this schema if the framing or record semantics change. + updateFramed(hash, FIXTURE_FINGERPRINT_SCHEMA); + updateFramed(hash, identity); + for (const record of Array.from(records.values()).sort((left, right) => + left.name.localeCompare(right.name) || left.kind.localeCompare(right.kind) + )) { + updateFramed(hash, record.kind); + updateFramed(hash, record.name); + updateFramed(hash, record.bytes); + } + return { + repoRoot: resolvedRepoRoot, + inputFingerprint: hash.digest("hex"), + }; +} + +function fixtureAbiVersion(path: string): number | null { + try { + const bytes = readFileSync(path); + const exact = bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer; + return extractAbiVersion(exact); + } catch { + return null; + } +} + +function readUleb( + bytes: Uint8Array, + offset: number, +): { value: number; next: number } | null { + let value = 0; + let shift = 0; + for (let index = offset; index < bytes.byteLength && shift <= 28; index++) { + const byte = bytes[index]!; + value += (byte & 0x7f) * 2 ** shift; + if ((byte & 0x80) === 0) { + return { value, next: index + 1 }; + } + shift += 7; + } + return null; +} + +function fixtureInputFingerprint(path: string): string | null { + try { + const bytes = readFileSync(path); + if ( + bytes.byteLength < 8 + || !bytes.subarray(0, 4).equals(Buffer.from([0, 0x61, 0x73, 0x6d])) + ) { + return null; + } + const matches: string[] = []; + let offset = 8; + while (offset < bytes.byteLength) { + const sectionId = bytes[offset++]!; + const size = readUleb(bytes, offset); + if (!size) return null; + offset = size.next; + const end = offset + size.value; + if (end > bytes.byteLength) return null; + if (sectionId === 0) { + const nameLength = readUleb(bytes, offset); + if (!nameLength) return null; + const nameStart = nameLength.next; + const nameEnd = nameStart + nameLength.value; + if (nameEnd > end) return null; + const name = bytes.subarray(nameStart, nameEnd).toString("utf8"); + if (name === FIXTURE_INPUT_SECTION) { + matches.push(bytes.subarray(nameEnd, end).toString("ascii")); + } + } + offset = end; + } + return matches.length === 1 && /^[0-9a-f]{64}$/.test(matches[0]!) + ? matches[0]! + : null; + } catch { + return null; + } +} + +function expectedFixtureFingerprint( + sourcePath: string, + contract: ProgramFixtureBuildContract, +): string { + const hash = createHash("sha256"); + updateFramed(hash, contract.inputFingerprint); + updateFramed(hash, relativeInputName(contract.repoRoot, sourcePath)); + updateFramed(hash, readFileSync(sourcePath)); + return hash.digest("hex"); +} + +function encodeUleb(value: number): Uint8Array { + const bytes: number[] = []; + do { + let byte = value & 0x7f; + value = Math.floor(value / 128); + if (value > 0) byte |= 0x80; + bytes.push(byte); + } while (value > 0); + return Uint8Array.from(bytes); +} + +/** Append the exact input digest to a freshly compiled fixture. */ +export function stampProgramFixture( + sourcePath: string, + outputPath: string, + contract: ProgramFixtureBuildContract, +): void { + const name = Buffer.from(FIXTURE_INPUT_SECTION); + const fingerprint = Buffer.from( + expectedFixtureFingerprint(sourcePath, contract), + "ascii", + ); + const payloadLength = encodeUleb(name.byteLength); + const payload = Buffer.concat([payloadLength, name, fingerprint]); + appendFileSync( + outputPath, + Buffer.concat([ + Buffer.from([0]), + encodeUleb(payload.byteLength), + payload, + ]), + ); +} + +/** + * Decide whether a compiled C fixture represents its source, actual linked + * sysroot/glue/compiler inputs, and current process ABI. + */ +export function programFixtureNeedsRebuild( + sourcePath: string, + outputPath: string, + contract: ProgramFixtureBuildContract, +): boolean { + if (!existsSync(outputPath)) return true; + if (fixtureAbiVersion(outputPath) !== ABI_VERSION) return true; + return fixtureInputFingerprint(outputPath) + !== expectedFixtureFingerprint(sourcePath, contract); +} diff --git a/host/test/readdir-atomicity.test.ts b/host/test/readdir-atomicity.test.ts index 1263c1e67d..a06b969c2e 100644 --- a/host/test/readdir-atomicity.test.ts +++ b/host/test/readdir-atomicity.test.ts @@ -48,13 +48,13 @@ describe("host readdir retry atomicity", () => { expect(result).toBeLessThan(0); expect(io.readdir).toHaveBeenCalledTimes(1); - expect(hostReaddir(7n, 0, 128, 64)).toBe(1); + expect(hostReaddir(7n, 16, 128, 64)).toBe(1); expect(io.readdir).toHaveBeenCalledTimes(1); const view = new DataView(memory.buffer); - expect(view.getBigUint64(0, true)).toBe(42n); - expect(view.getUint32(8, true)).toBe(8); - expect(view.getUint32(12, true)).toBe(entry.name.length); + expect(view.getBigUint64(16, true)).toBe(42n); + expect(view.getUint32(24, true)).toBe(8); + expect(view.getUint32(28, true)).toBe(entry.name.length); expect( new TextDecoder().decode( new Uint8Array(memory.buffer, 128, entry.name.length), @@ -83,7 +83,7 @@ describe("host readdir retry atomicity", () => { bridge.hostReaddir(7n, memory.buffer.byteLength - 4, 128, 64), ).toBeLessThan(0); expect(bridge.hostClosedir(7n)).toBe(0); - expect(bridge.hostReaddir(7n, 0, 128, 64)).toBe(1); + expect(bridge.hostReaddir(7n, 16, 128, 64)).toBe(1); expect(io.closedir).toHaveBeenCalledWith(7); expect(io.readdir).toHaveBeenCalledTimes(2); diff --git a/host/test/readiness-deadline.test.ts b/host/test/readiness-deadline.test.ts index 6255f8b578..06bc1315ef 100644 --- a/host/test/readiness-deadline.test.ts +++ b/host/test/readiness-deadline.test.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { ABI_SYSCALLS, CH_SIG_BASE } from "../src/generated/abi"; +import { + ABI_SYSCALLS, + CH_SIG_BASE, + KERNEL_SCRATCH_SIGNAL_DELIVERY_BYTES, +} from "../src/generated/abi"; import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; function createSharedMemory(pages = 1): WebAssembly.Memory { return new WebAssembly.Memory({ initial: pages, maximum: pages, shared: true }); @@ -119,7 +124,8 @@ describe("host-emulated epoll signal delivery", () => { expect(harness.dequeueSignal).toHaveBeenCalledWith( harness.channel.pid, harness.channel.pid, - CH_SIG_BASE, + harness.scratchPointer + CH_SIG_BASE, + KERNEL_SCRATCH_SIGNAL_DELIVERY_BYTES, ); expect( new DataView(harness.processMemory.buffer).getUint32(CH_SIG_BASE, true), @@ -158,7 +164,12 @@ function createEpollSignalHarness( channelOffset: 0, memory: processMemory, }; - const dequeueSignal = vi.fn((_pid: number, _tid: number, outPtr: number) => { + const dequeueSignal = vi.fn(( + _pid: number, + _tid: number, + outPtr: number, + _outCapacity: number, + ) => { if (handlerSignal > 0) { new DataView(kernelMemory.buffer).setUint32(outPtr, handlerSignal, true); } @@ -178,7 +189,10 @@ function createEpollSignalHarness( }, }, kernelMemory, - scratchOffset: 0, + processes: new Map([[channel.pid, { + channels: [channel], + ptrWidth: 4, + }]]), currentHandlePid: 0, channelTids: new Map([["42:0", 42]]), epollInterests: new Map([ @@ -191,6 +205,7 @@ function createEpollSignalHarness( relistenChannel, handleProcessTerminated, }); + const scratchPointer = installKernelWorkerTestScratch(worker, kernelMemory); return { channel, completeChannelRaw, @@ -199,6 +214,7 @@ function createEpollSignalHarness( handleProcessTerminated, processMemory, relistenChannel, + scratchPointer, worker, }; } diff --git a/host/test/scm-rights-pipe-lifetime.test.ts b/host/test/scm-rights-pipe-lifetime.test.ts index b843632c75..27a28ce222 100644 --- a/host/test/scm-rights-pipe-lifetime.test.ts +++ b/host/test/scm-rights-pipe-lifetime.test.ts @@ -2,20 +2,30 @@ import { describe, expect, it } from "vitest"; import { resolveBinary } from "../src/binary-resolver"; import { runCentralizedProgram } from "./centralized-test-helper"; -const program = resolveBinary("programs/scm-rights-pipe-lifetime.wasm"); +const programs = [ + ["wasm32", resolveBinary("programs/wasm32/scm-rights-pipe-lifetime.wasm")], + ["wasm64", resolveBinary("programs/wasm64/scm-rights-pipe-lifetime.wasm")], +] as const; describe("SCM_RIGHTS pipe and FIFO reference lifetime", () => { - it("transfers exact pipe and FIFO ownership and collects rights cycles", async () => { - const result = await runCentralizedProgram({ - programPath: program, - argv: ["scm-rights-pipe-lifetime"], - timeout: 10_000, - useDefaultRootfs: false, - }); + it.each(programs)( + "transfers exact pipe/FIFO ownership and rejects lossy socket rights (%s)", + async (_arch, program) => { + const result = await runCentralizedProgram({ + programPath: program, + argv: ["scm-rights-pipe-lifetime"], + timeout: 10_000, + useDefaultRootfs: false, + }); - expect(result.exitCode, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0); - expect(result.stdout).toContain( - "PASS: SCM_RIGHTS owns pipe and FIFO references in flight and after receipt", - ); - }); + expect( + result.exitCode, + `stderr=${result.stderr}\nstdout=${result.stdout}`, + ).toBe(0); + expect(result.stdout).toContain( + "PASS: SCM_RIGHTS owns pipe and FIFO references in flight and after receipt", + ); + expect(result.stdout).toContain("SCM_RIGHTS_SOCKET_REJECTION_PASS"); + }, + ); }); diff --git a/host/test/scm-rights-semantics.test.ts b/host/test/scm-rights-semantics.test.ts new file mode 100644 index 0000000000..5548149d6b --- /dev/null +++ b/host/test/scm-rights-semantics.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { resolveBinary } from "../src/binary-resolver"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const programs = [ + ["wasm32", resolveBinary("programs/wasm32/scm-rights-semantics.wasm")], + ["wasm64", resolveBinary("programs/wasm64/scm-rights-semantics.wasm")], +] as const; + +const semanticCases = [ + { + name: "stream", + markers: ["SCM_RIGHTS_STREAM_BARRIER_PASS"], + }, + { + name: "peek", + markers: ["SCM_RIGHTS_STREAM_PEEK_PASS"], + }, + { + name: "datagram", + markers: ["SCM_RIGHTS_DGRAM_ZERO_AND_PEEK_PASS"], + }, + { + name: "trunc", + markers: ["SCM_RIGHTS_DGRAM_TRUNC_PASS"], + }, + { + name: "domain", + markers: ["SCM_RIGHTS_NON_UNIX_REJECTION_PASS"], + }, + { + name: "representability", + markers: ["SCM_RIGHTS_UNREPRESENTABLE_REJECTION_PASS"], + }, + { + name: "zero-iov-stream", + markers: ["SCM_RIGHTS_STREAM_ZERO_IOV_PASS"], + }, + { + name: "cloexec", + markers: [ + "SCM_RIGHTS_CLOEXEC_FLAG_PASS", + "SCM_RIGHTS_CLOEXEC_EXEC_PASS", + "SCM_RIGHTS_SEMANTICS_PASS", + ], + }, +] as const; + +const runtimeCases = semanticCases.flatMap(({ name, markers }) => + programs.map( + ([arch, program]) => [name, arch, program, markers] as const, + ), +); + +describe("SCM_RIGHTS message semantics", () => { + it.each(runtimeCases)( + "preserves %s semantics in the actual %s binary", + async (caseName, _arch, program, expectedMarkers) => { + const result = await runCentralizedProgram({ + programPath: program, + argv: ["/bin/scm-rights-semantics", "--case", caseName], + execPrograms: new Map([["/bin/scm-rights-semantics", program]]), + useDefaultRootfs: false, + timeout: 30_000, + }); + + expect( + result.exitCode, + `stderr=${result.stderr}\nstdout=${result.stdout}`, + ).toBe(0); + for (const marker of expectedMarkers) { + expect(result.stdout).toContain(marker); + } + expect(result.stderr).toBe(""); + expect(result.hostDiagnostics).toEqual([]); + }, + ); +}); diff --git a/host/test/select-signal-outcome.test.ts b/host/test/select-signal-outcome.test.ts index 1f82abd557..08aa9fb58e 100644 --- a/host/test/select-signal-outcome.test.ts +++ b/host/test/select-signal-outcome.test.ts @@ -6,6 +6,7 @@ import { CH_SIG_BASE, } from "../src/generated/abi"; import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; const EAGAIN = 11; const EINTR = 4; @@ -31,8 +32,8 @@ function createHarness(options: { channelOffset: 0, memory: processMemory, }; - const handleChannel = vi.fn(() => { - const view = new DataView(kernelMemory.buffer); + const handleChannel = vi.fn((offset: number) => { + const view = new DataView(kernelMemory.buffer, offset); view.setBigInt64(CH_RETURN, BigInt(returnValue), true); view.setUint32(CH_ERRNO, errno, true); return 0; @@ -57,7 +58,6 @@ function createHarness(options: { }, }, kernelMemory, - scratchOffset: 0, currentHandlePid: 0, processes: new Map([ [42, { pid: 42, memory: processMemory, channels: [channel], ptrWidth: 4 }], @@ -72,6 +72,7 @@ function createHarness(options: { completeChannel, handleProcessTerminated, }); + installKernelWorkerTestScratch(worker, kernelMemory); return { channel, diff --git a/host/test/shared-memory-coherence.test.ts b/host/test/shared-memory-coherence.test.ts index 1408e6c6ec..be50ab1b51 100644 --- a/host/test/shared-memory-coherence.test.ts +++ b/host/test/shared-memory-coherence.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; function sharedMemory(): WebAssembly.Memory { return new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); @@ -277,7 +278,6 @@ function sysvHarness() { kernel_validate_task: validateTask, }, }, - scratchOffset: 0, processes, sharedMappings: new Map(), anonymousSharedBackings: new Map(), @@ -287,6 +287,10 @@ function sysvHarness() { ]), shmSegmentVersions: new Map([[segId, 0]]), }) as CentralizedKernelWorker; + installKernelWorkerTestScratch( + kw as unknown as Record, + kernelMemory, + ); return { kw, mapAddr, @@ -417,4 +421,88 @@ describe("SysV SHM coherence and lifecycle", () => { expect(h.shmatForTask).not.toHaveBeenCalled(); expect((h.kw as any).shmMappings.has(h.pids[2])).toBe(false); }); + + it("preserves a wasm64 shmat hint above 4 GiB until mmap rejects it", () => { + const h = sysvHarness(); + const process = (h.kw as any).processes.get(h.pids[2]); + process.ptrWidth = 8; + const complete = vi.fn(); + const relisten = vi.fn(); + const mmap = vi.fn(() => ({ retVal: -1, errVal: 12 })); + Object.assign(h.kw as any, { + completeChannelRaw: complete, + relistenChannel: relisten, + runSyntheticMemorySyscall: mmap, + }); + const channel = process.channels[0]; + const highHint = 0x1_0000_0000n; + + (h.kw as any).handleIpcShmat( + channel, + [h.segId, Number(highHint), 0], + [BigInt(h.segId), highHint, 0n], + ); + + // The legacy kernel attachment helper does not own the process mapping + // address, but the host mmap path must retain every wasm64 pointer bit. + expect(h.shmatForTask).toHaveBeenCalledWith( + h.pids[2], + h.pids[2], + h.segId, + 0, + 0, + ); + expect(mmap.mock.calls[0]?.[2]?.[0]).toBe(Number(highHint)); + expect(complete).toHaveBeenCalledWith(channel, -12, 12); + expect(relisten).toHaveBeenCalledWith(channel); + }); + + it("rejects a non-lossless wasm64 shmat hint before attachment state changes", () => { + const h = sysvHarness(); + const process = (h.kw as any).processes.get(h.pids[2]); + process.ptrWidth = 8; + const complete = vi.fn(); + const relisten = vi.fn(); + Object.assign(h.kw as any, { + completeChannelRaw: complete, + relistenChannel: relisten, + }); + const channel = process.channels[0]; + const unsafeHint = BigInt(Number.MAX_SAFE_INTEGER) + 1n; + + (h.kw as any).handleIpcShmat( + channel, + [h.segId, Number(unsafeHint), 0], + [BigInt(h.segId), unsafeHint, 0n], + ); + + expect(h.shmatForTask).not.toHaveBeenCalled(); + expect(complete).toHaveBeenCalledWith(channel, -1, 14); + expect(relisten).toHaveBeenCalledWith(channel); + }); + + it("does not alias a wasm64 shmdt address to an existing low mapping", () => { + const h = sysvHarness(); + const process = (h.kw as any).processes.get(h.pids[0]); + process.ptrWidth = 8; + const complete = vi.fn(); + const relisten = vi.fn(); + Object.assign(h.kw as any, { + completeChannelRaw: complete, + relistenChannel: relisten, + }); + const channel = process.channels[0]; + const highAddress = BigInt(h.mapAddr) + 0x1_0000_0000n; + + (h.kw as any).handleIpcShmdt( + channel, + [Number(highAddress)], + [highAddress], + ); + + expect((h.kw as any).shmMappings.get(h.pids[0]).has(h.mapAddr)).toBe(true); + expect(h.shmdtForTask).not.toHaveBeenCalled(); + expect(complete).toHaveBeenCalledWith(channel, -22, 22); + expect(relisten).toHaveBeenCalledWith(channel); + }); }); diff --git a/host/test/signal-accept-livelock.test.ts b/host/test/signal-accept-livelock.test.ts index b82c5771ab..2ac7517136 100644 --- a/host/test/signal-accept-livelock.test.ts +++ b/host/test/signal-accept-livelock.test.ts @@ -19,6 +19,7 @@ */ import { describe, expect, it, vi } from "vitest"; import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; import { CH_ARGS, CH_ARG_SIZE, @@ -28,6 +29,7 @@ import { CH_RETURN, CH_SIG_SIGNUM, CH_SYSCALL, + KERNEL_SCRATCH_SIGNAL_DELIVERY_BYTES, } from "../src/generated/abi"; const SIGCHLD = 17; @@ -45,6 +47,7 @@ function createChannel(pid: number, channelOffset: number): any { /** A worker whose kernel exports are all inert — signal delivery is * best-effort host bookkeeping, so the kernel side is a no-op here. */ function createWorkerHarness(): any { + const kernelMemory = createSharedMemory(); const worker: any = Object.assign(Object.create(CentralizedKernelWorker.prototype), { kernel: { toKernelPtr: (v: number | bigint) => (typeof v === "bigint" ? Number(v) : v) }, kernelInstance: { @@ -56,8 +59,7 @@ function createWorkerHarness(): any { kernel_get_process_exit_signal: () => -1, }, }, - kernelMemory: createSharedMemory(), - scratchOffset: 128, + kernelMemory, processes: new Map(), channelTids: new Map(), pendingSleeps: new Map(), @@ -66,6 +68,10 @@ function createWorkerHarness(): any { pendingPollRetries: new Map(), pendingSelectRetries: new Map(), }); + worker.testScratchPointer = installKernelWorkerTestScratch( + worker, + kernelMemory, + ); return worker; } @@ -204,7 +210,12 @@ describe("signal delivery to a process blocked in accept()", () => { worker.completeSleepWithSignalCheck(channel, 1, [], 0, 0); expect(setCurrentTid).not.toHaveBeenCalled(); - expect(dequeueSignal).toHaveBeenCalledWith(pid, tid, expect.any(Number)); + expect(dequeueSignal).toHaveBeenCalledWith( + pid, + tid, + expect.any(Number), + KERNEL_SCRATCH_SIGNAL_DELIVERY_BYTES, + ); }); it("does not rebind an ordinary synchronous signal dequeue", () => { @@ -220,7 +231,12 @@ describe("signal delivery to a process blocked in accept()", () => { worker.dequeueSignalForDelivery(channel); expect(setCurrentTid).not.toHaveBeenCalled(); - expect(dequeueSignal).toHaveBeenCalledWith(pid, pid, expect.any(Number)); + expect(dequeueSignal).toHaveBeenCalledWith( + pid, + pid, + expect.any(Number), + KERNEL_SCRATCH_SIGNAL_DELIVERY_BYTES, + ); }); it("hands a deferred signal from a JavaScript completion to the next guest checkpoint", () => { @@ -424,7 +440,7 @@ describe("signal delivery to a process blocked in accept()", () => { worker.kernelInstance.exports.kernel_handle_channel = vi.fn(() => { const kernelView = new DataView( worker.kernelMemory.buffer, - worker.scratchOffset, + worker.testScratchPointer, ); kernelView.setBigInt64(CH_RETURN, 0n, true); kernelView.setUint32(CH_ERRNO, 0, true); diff --git a/host/test/spawn-blob-transport.test.ts b/host/test/spawn-blob-transport.test.ts index e255b37c91..323d649c43 100644 --- a/host/test/spawn-blob-transport.test.ts +++ b/host/test/spawn-blob-transport.test.ts @@ -8,9 +8,25 @@ import { CH_DATA_SIZE, CH_TOTAL_SIZE, HOST_INTERCEPTED_SYSCALLS, + POSIX_ARG_MAX_BYTES, + POSIX_PATH_MAX_BYTES, + SPAWN_MAX_ACTION_COUNT, + SPAWN_MAX_ARGV_COUNT, + SPAWN_MAX_ENVP_COUNT, + SPAWN_WIRE_ACTION_OP_OFFSET, + SPAWN_WIRE_ACTION_RECORD_BYTES, + SPAWN_WIRE_HEADER_ACTION_COUNT_OFFSET, + SPAWN_WIRE_HEADER_ARGC_OFFSET, + SPAWN_WIRE_HEADER_BYTES, + SPAWN_WIRE_HEADER_ENVC_OFFSET, + SPAWN_WIRE_OP_CLOSE, + SPAWN_WIRE_STRING_OFFSET_BYTES, } from "../src/generated/abi"; +import { allocateKernelScratchRegion } from "../src/kernel-scratch"; +import { createKernelScratchTestInstance } from "./support/kernel-scratch-instance"; const E2BIG = 7; +const EBUSY = 16; const EFAULT = 14; const EINVAL = 22; const EIO = 5; @@ -18,7 +34,245 @@ const ENOMEM = 12; const ENAMETOOLONG = 36; describe("SYS_SPAWN blob transport", () => { - it("uses one reusable kernel-owned buffer for blobs larger than a syscall channel", async () => { + it("reports the Rust-owned retained capacity losslessly", () => { + const worker = createWorker({ + kernelInstance: { + exports: { + kernel_spawn_scratch_retained_capacity: vi.fn(() => 84_386n), + }, + }, + }); + + expect(worker.getSpawnScratchCapacity()).toBe(84_386); + }); + + it("grows one Rust-owned reservation to the requested high-water mark", () => { + const firstBlob = new Uint8Array(CH_TOTAL_SIZE + 1024).fill(0x31); + const reusedBlob = new Uint8Array(firstBlob.byteLength + 512).fill(0x32); + const grownBlob = new Uint8Array(firstBlob.byteLength + 4096).fill(0x33); + const firstPointer = 2 * CH_TOTAL_SIZE; + const grownPointer = firstPointer + firstBlob.byteLength + 8192; + const kernelMemory = new WebAssembly.Memory({ + initial: 8, + maximum: 8, + }); + let reservationPointer = firstPointer; + let reservationCapacity = reusedBlob.byteLength; + let nextToken = 1n; + const beginSpawnScratch = vi.fn((minimum: number) => { + if (minimum > reservationCapacity) { + reservationPointer = grownPointer; + reservationCapacity = minimum; + } + return nextToken++; + }); + const spawnScratchPointer = vi.fn(() => reservationPointer); + const spawnScratchCapacity = vi.fn(() => reservationCapacity); + const cancelSpawnScratch = vi.fn(() => -EINVAL); + const kernelReservedSpawn = vi.fn(( + _parentPid: number, + _callerTid: number, + token: bigint, + length: number, + ) => { + expect(token).toBe(nextToken - 1n); + expect( + new Uint8Array(kernelMemory.buffer).slice( + reservationPointer, + reservationPointer + length, + ), + ).toEqual( + length === firstBlob.byteLength + ? firstBlob + : length === reusedBlob.byteLength + ? reusedBlob + : grownBlob, + ); + return 42; + }); + const worker = createWorker({ + callbacks: { onSpawn: vi.fn(() => new Promise(() => {})) }, + kernelMemory, + scratchPointer: 1024, + kernelInstance: { + exports: { + kernel_spawn_scratch_begin: beginSpawnScratch, + kernel_spawn_scratch_pointer: spawnScratchPointer, + kernel_spawn_scratch_capacity: spawnScratchCapacity, + kernel_spawn_scratch_cancel: cancelSpawnScratch, + kernel_spawn_reserved_process: kernelReservedSpawn, + }, + }, + }); + const invoke = (blob: Uint8Array) => worker.handleSpawnAfterResolve( + createChannel(7, sharedMemoryFor(65_536)), + [0, 0, 0, blob.byteLength, 0, 0], + 7, + 7, + 0, + blob, + blob.byteLength, + resolvedProgram(), + [], + ); + + invoke(firstBlob); + expect(beginSpawnScratch).toHaveBeenCalledWith(firstBlob.byteLength); + expect(kernelReservedSpawn).toHaveBeenLastCalledWith( + 7, + 7, + 1n, + firstBlob.byteLength, + ); + + invoke(reusedBlob); + expect(beginSpawnScratch).toHaveBeenCalledTimes(2); + expect(kernelReservedSpawn).toHaveBeenLastCalledWith( + 7, + 7, + 2n, + reusedBlob.byteLength, + ); + + invoke(grownBlob); + expect(beginSpawnScratch).toHaveBeenCalledTimes(3); + expect(beginSpawnScratch).toHaveBeenLastCalledWith(grownBlob.byteLength); + expect(kernelReservedSpawn).toHaveBeenLastCalledWith( + 7, + 7, + 3n, + grownBlob.byteLength, + ); + expect(spawnScratchPointer).toHaveBeenCalledTimes(3); + expect(cancelSpawnScratch).toHaveBeenCalledTimes(3); + expect(cancelSpawnScratch).toHaveBeenNthCalledWith(1, 1n); + expect(cancelSpawnScratch).toHaveBeenNthCalledWith(2, 2n); + expect(cancelSpawnScratch).toHaveBeenNthCalledWith(3, 3n); + }); + + it("preserves a lossless wasm64 reservation pointer and capacity", () => { + const blob = new Uint8Array(CH_TOTAL_SIZE + 1).fill(0x4a); + const pointer = 8192n; + const kernelMemory = new WebAssembly.Memory({ initial: 4, maximum: 4 }); + const beginSpawnScratch = vi.fn(() => 77n); + const kernelReservedSpawn = vi.fn(() => 42); + const worker = createWorker({ + kernel: { + toKernelPtr: (value: number | bigint) => BigInt(value), + getKernelPtrWidth: () => 8, + }, + callbacks: { onSpawn: vi.fn(() => new Promise(() => {})) }, + kernelMemory, + kernelInstance: { + exports: { + kernel_spawn_scratch_begin: beginSpawnScratch, + kernel_spawn_scratch_pointer: vi.fn(() => pointer), + kernel_spawn_scratch_capacity: vi.fn(() => BigInt(blob.byteLength)), + kernel_spawn_scratch_cancel: vi.fn(() => -EINVAL), + kernel_spawn_reserved_process: kernelReservedSpawn, + }, + }, + }); + + worker.handleSpawnAfterResolve( + createChannel(7, sharedMemoryFor(65_536)), + [0, 0, 0, blob.byteLength, 0, 0], + 7, + 7, + 0, + blob, + blob.byteLength, + resolvedProgram(), + [], + ); + + expect(beginSpawnScratch).toHaveBeenCalledWith(BigInt(blob.byteLength)); + expect(kernelReservedSpawn).toHaveBeenCalledWith( + 7, + 7, + 77n, + BigInt(blob.byteLength), + ); + }); + + it.each([ + { + name: "allocator failure", + beginResult: -12n, + pointer: 0, + capacity: CH_TOTAL_SIZE + 1, + errno: ENOMEM, + expectedCancels: 0, + }, + { + name: "capacity below the request", + beginResult: 1n, + pointer: 4096, + capacity: CH_TOTAL_SIZE, + errno: EIO, + expectedCancels: 1, + }, + { + name: "range beyond kernel memory", + beginResult: 2n, + pointer: 65_536, + capacity: CH_TOTAL_SIZE + 1, + errno: EIO, + expectedCancels: 1, + }, + ])("rejects a growable reservation with $name", ({ + beginResult, + pointer, + capacity, + errno, + expectedCancels, + }) => { + const blob = new Uint8Array(CH_TOTAL_SIZE + 1); + const completeChannel = vi.fn(); + const kernelReservedSpawn = vi.fn(); + const cancelSpawnScratch = vi.fn(() => 0); + const worker = createWorker({ + callbacks: { onSpawn: vi.fn() }, + completeChannel, + kernelMemory: new WebAssembly.Memory({ initial: 2, maximum: 2 }), + kernelInstance: { + exports: { + kernel_spawn_scratch_begin: vi.fn(() => beginResult), + kernel_spawn_scratch_pointer: vi.fn(() => pointer), + kernel_spawn_scratch_capacity: vi.fn(() => capacity), + kernel_spawn_scratch_cancel: cancelSpawnScratch, + kernel_spawn_reserved_process: kernelReservedSpawn, + }, + }, + }); + const channel = createChannel(7, sharedMemoryFor(65_536)); + const args = [0, 0, 0, blob.byteLength, 0, 0]; + + worker.handleSpawnAfterResolve( + channel, + args, + 7, + 7, + 0, + blob, + blob.byteLength, + resolvedProgram(), + [], + ); + + expect(kernelReservedSpawn).not.toHaveBeenCalled(); + expect(cancelSpawnScratch).toHaveBeenCalledTimes(expectedCancels); + expect(completeChannel).toHaveBeenCalledWith( + channel, + HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN, + args, + undefined, + -1, + errno, + ); + }); + + it("uses one exclusive reservation per large spawn while reusing capacity", async () => { const parentPid = 7; const childPid = 42; const path = new TextEncoder().encode("/bin/child"); @@ -37,28 +291,38 @@ describe("SYS_SPAWN blob transport", () => { processBytes.set(path, pathPtr); processBytes.set(blob, blobPtr); - const kernelMemory = new WebAssembly.Memory({ initial: 8, maximum: 8 }); - const kernelBytes = new Uint8Array(kernelMemory.buffer); const generalScratchOffset = 1024; const largeScratchOffset = 2 * CH_TOTAL_SIZE; + const kernelPages = Math.ceil( + (largeScratchOffset + blob.byteLength) / 65_536, + ); + const kernelMemory = new WebAssembly.Memory({ + initial: kernelPages, + maximum: kernelPages, + }); + const kernelBytes = new Uint8Array(kernelMemory.buffer); kernelBytes.fill( 0xa5, generalScratchOffset, generalScratchOffset + CH_TOTAL_SIZE, ); - const allocScratch = vi.fn(() => largeScratchOffset); - const kernelSpawn = vi.fn(( + let token = 0n; + const beginSpawnScratch = vi.fn(() => ++token); + const kernelReservedSpawn = vi.fn(( actualParentPid: number, actualCallerTid: number, - actualBlobPtr: number, + actualToken: bigint, actualBlobLen: number, ) => { expect(actualParentPid).toBe(parentPid); expect(actualCallerTid).toBe(parentPid); - expect(actualBlobPtr).toBe(largeScratchOffset); + expect(actualToken).toBe(token); expect(actualBlobLen).toBe(blob.byteLength); expect( - kernelBytes.slice(actualBlobPtr, actualBlobPtr + actualBlobLen), + kernelBytes.slice( + largeScratchOffset, + largeScratchOffset + actualBlobLen, + ), ).toEqual(blob); return childPid; }); @@ -74,12 +338,14 @@ describe("SYS_SPAWN blob transport", () => { { channels: [channel], memory: processMemory, ptrWidth: 4 }, ]]), kernelMemory, - scratchOffset: generalScratchOffset, - largeSpawnScratchOffset: 0, + scratchPointer: generalScratchOffset, kernelInstance: { exports: { - kernel_alloc_scratch: allocScratch, - kernel_spawn_process: kernelSpawn, + kernel_spawn_scratch_begin: beginSpawnScratch, + kernel_spawn_scratch_pointer: vi.fn(() => largeScratchOffset), + kernel_spawn_scratch_capacity: vi.fn(() => blob.byteLength), + kernel_spawn_scratch_cancel: vi.fn(() => -EINVAL), + kernel_spawn_reserved_process: kernelReservedSpawn, }, }, }); @@ -96,9 +362,9 @@ describe("SYS_SPAWN blob transport", () => { await Promise.resolve(); await Promise.resolve(); - expect(allocScratch).toHaveBeenCalledOnce(); - expect(allocScratch).toHaveBeenCalledWith(SPAWN_BLOB_MAX_BYTES); - expect(kernelSpawn).toHaveBeenCalledOnce(); + expect(beginSpawnScratch).toHaveBeenCalledOnce(); + expect(beginSpawnScratch).toHaveBeenCalledWith(blob.byteLength); + expect(kernelReservedSpawn).toHaveBeenCalledOnce(); expect(onSpawn).toHaveBeenCalledWith( parentPid, childPid, @@ -123,20 +389,20 @@ describe("SYS_SPAWN blob transport", () => { resolvedProgram(), envp, ); - expect(allocScratch).toHaveBeenCalledOnce(); - expect(kernelSpawn).toHaveBeenCalledTimes(2); + expect(beginSpawnScratch).toHaveBeenCalledTimes(2); + expect(kernelReservedSpawn).toHaveBeenCalledTimes(2); }); it("keeps ordinary spawn blobs in the existing channel-sized scratch", () => { const blob = buildSpawnBlob(["child"], ["A=B"]); const kernelMemory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); - const scratchOffset = 1024; + const scratchPointer = 1024; const allocScratch = vi.fn(); const kernelSpawn = vi.fn(() => 42); const worker = createWorker({ callbacks: { onSpawn: vi.fn(() => new Promise(() => {})) }, kernelMemory, - scratchOffset, + scratchPointer, kernelInstance: { exports: { kernel_alloc_scratch: allocScratch, @@ -161,13 +427,13 @@ describe("SYS_SPAWN blob transport", () => { expect(kernelSpawn).toHaveBeenCalledWith( 7, 7, - scratchOffset, + scratchPointer, blob.byteLength, ); expect( new Uint8Array(kernelMemory.buffer).slice( - scratchOffset, - scratchOffset + blob.byteLength, + scratchPointer, + scratchPointer + blob.byteLength, ), ).toEqual(blob); }); @@ -177,32 +443,42 @@ describe("SYS_SPAWN blob transport", () => { name: "the exact channel-size boundary", blobLen: CH_TOTAL_SIZE, expectedOffset: 1024, - expectedAllocations: 0, + expectedReservations: 0, }, { name: "the first byte above the channel-size boundary", blobLen: CH_TOTAL_SIZE + 1, expectedOffset: 2 * CH_TOTAL_SIZE, - expectedAllocations: 1, + expectedReservations: 1, }, ])("selects the bounded transport at $name", ({ blobLen, expectedOffset, - expectedAllocations, + expectedReservations, }) => { const blob = new Uint8Array(blobLen).fill(0x5a); - const kernelMemory = new WebAssembly.Memory({ initial: 4, maximum: 4 }); - const allocScratch = vi.fn(() => 2 * CH_TOTAL_SIZE); + const kernelPages = Math.ceil( + (2 * CH_TOTAL_SIZE + SPAWN_BLOB_MAX_BYTES) / 65_536, + ); + const kernelMemory = new WebAssembly.Memory({ + initial: kernelPages, + maximum: kernelPages, + }); const kernelSpawn = vi.fn(() => 42); + const beginSpawnScratch = vi.fn(() => 1n); + const kernelReservedSpawn = vi.fn(() => 42); const worker = createWorker({ callbacks: { onSpawn: vi.fn(() => new Promise(() => {})) }, kernelMemory, - scratchOffset: 1024, - largeSpawnScratchOffset: 0, + scratchPointer: 1024, kernelInstance: { exports: { - kernel_alloc_scratch: allocScratch, kernel_spawn_process: kernelSpawn, + kernel_spawn_scratch_begin: beginSpawnScratch, + kernel_spawn_scratch_pointer: vi.fn(() => 2 * CH_TOTAL_SIZE), + kernel_spawn_scratch_capacity: vi.fn(() => blobLen), + kernel_spawn_scratch_cancel: vi.fn(() => -EINVAL), + kernel_spawn_reserved_process: kernelReservedSpawn, }, }, }); @@ -219,13 +495,24 @@ describe("SYS_SPAWN blob transport", () => { [], ); - expect(allocScratch).toHaveBeenCalledTimes(expectedAllocations); - expect(kernelSpawn).toHaveBeenCalledWith( - 7, - 7, - expectedOffset, - blobLen, - ); + expect(beginSpawnScratch).toHaveBeenCalledTimes(expectedReservations); + if (expectedReservations === 0) { + expect(kernelSpawn).toHaveBeenCalledWith( + 7, + 7, + expectedOffset, + blobLen, + ); + expect(kernelReservedSpawn).not.toHaveBeenCalled(); + } else { + expect(kernelSpawn).not.toHaveBeenCalled(); + expect(kernelReservedSpawn).toHaveBeenCalledWith( + 7, + 7, + 1n, + blobLen, + ); + } }); it("accepts the exact whole-blob transport maximum", () => { @@ -237,16 +524,17 @@ describe("SYS_SPAWN blob transport", () => { initial: pages, maximum: pages, }); - const kernelSpawn = vi.fn(() => 42); + const kernelReservedSpawn = vi.fn(() => 42); const worker = createWorker({ callbacks: { onSpawn: vi.fn(() => new Promise(() => {})) }, kernelMemory, - scratchOffset: 0, - largeSpawnScratchOffset: 0, kernelInstance: { exports: { - kernel_alloc_scratch: vi.fn(() => largeScratchOffset), - kernel_spawn_process: kernelSpawn, + kernel_spawn_scratch_begin: vi.fn(() => 99n), + kernel_spawn_scratch_pointer: vi.fn(() => largeScratchOffset), + kernel_spawn_scratch_capacity: vi.fn(() => blob.byteLength), + kernel_spawn_scratch_cancel: vi.fn(() => -EINVAL), + kernel_spawn_reserved_process: kernelReservedSpawn, }, }, }); @@ -263,27 +551,34 @@ describe("SYS_SPAWN blob transport", () => { [], ); - expect(kernelSpawn).toHaveBeenCalledWith( + expect(kernelReservedSpawn).toHaveBeenCalledWith( 7, 7, - largeScratchOffset, + 99n, SPAWN_BLOB_MAX_BYTES, ); }); - it("returns ENOMEM without touching the kernel when large transport allocation fails", () => { + it("retries a transient ENOMEM with a fresh reservation", () => { const blob = new Uint8Array(CH_TOTAL_SIZE + 1); const completeChannel = vi.fn(); - const kernelSpawn = vi.fn(); + const kernelReservedSpawn = vi.fn(() => 42); + const beginSpawnScratch = vi.fn() + .mockReturnValueOnce(BigInt(-ENOMEM)) + .mockReturnValueOnce(1n); + const scratchPointer = 4096; const worker = createWorker({ - callbacks: { onSpawn: vi.fn() }, + callbacks: { onSpawn: vi.fn(() => new Promise(() => {})) }, completeChannel, kernelMemory: new WebAssembly.Memory({ initial: 2, maximum: 2 }), - scratchOffset: 1024, + scratchPointer: 1024, kernelInstance: { exports: { - kernel_alloc_scratch: vi.fn(() => 0), - kernel_spawn_process: kernelSpawn, + kernel_spawn_scratch_begin: beginSpawnScratch, + kernel_spawn_scratch_pointer: vi.fn(() => scratchPointer), + kernel_spawn_scratch_capacity: vi.fn(() => blob.byteLength), + kernel_spawn_scratch_cancel: vi.fn(() => -EINVAL), + kernel_spawn_reserved_process: kernelReservedSpawn, }, }, }); @@ -302,7 +597,7 @@ describe("SYS_SPAWN blob transport", () => { [], ); - expect(kernelSpawn).not.toHaveBeenCalled(); + expect(kernelReservedSpawn).not.toHaveBeenCalled(); expect(completeChannel).toHaveBeenCalledWith( channel, HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN, @@ -311,24 +606,43 @@ describe("SYS_SPAWN blob transport", () => { -1, ENOMEM, ); + + completeChannel.mockClear(); + worker.handleSpawnAfterResolve( + channel, + args, + 7, + 7, + 0, + blob, + blob.byteLength, + resolvedProgram(), + [], + ); + + expect(beginSpawnScratch).toHaveBeenCalledTimes(2); + expect(kernelReservedSpawn).toHaveBeenCalledWith( + 7, + 7, + 1n, + blob.byteLength, + ); }); - it("rejects a large transport allocation outside kernel memory", () => { + it("fails closed instead of allocating a fixed legacy fallback", () => { const blob = new Uint8Array(CH_TOTAL_SIZE + 1); const completeChannel = vi.fn(); - const kernelSpawn = vi.fn(); - const kernelMemory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); + const fixedAllocator = vi.fn(() => 4096); + const kernelReservedSpawn = vi.fn(); const worker = createWorker({ callbacks: { onSpawn: vi.fn() }, completeChannel, - kernelMemory, - scratchOffset: 1024, + kernelMemory: new WebAssembly.Memory({ initial: 2, maximum: 2 }), + scratchPointer: 1024, kernelInstance: { exports: { - kernel_alloc_scratch: vi.fn( - () => kernelMemory.buffer.byteLength - blob.byteLength + 1, - ), - kernel_spawn_process: kernelSpawn, + kernel_alloc_scratch: fixedAllocator, + kernel_spawn_reserved_process: kernelReservedSpawn, }, }, }); @@ -347,7 +661,59 @@ describe("SYS_SPAWN blob transport", () => { [], ); - expect(kernelSpawn).not.toHaveBeenCalled(); + expect(fixedAllocator).not.toHaveBeenCalled(); + expect(kernelReservedSpawn).not.toHaveBeenCalled(); + expect(completeChannel).toHaveBeenCalledWith( + channel, + HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN, + args, + undefined, + -1, + EIO, + ); + }); + + it("cancels a reservation when the host copy fails before commit", () => { + const blobLength = CH_TOTAL_SIZE + 1; + // Deliberately pass a structurally compatible but non-genuine producer. + // The public path always supplies a Uint8Array; this fault seam proves a + // post-reservation intrinsic copy failure still releases the Rust token + // without replacing a global prototype method that production captures. + const blob = { byteLength: blobLength } as Uint8Array; + const completeChannel = vi.fn(); + const cancelSpawnScratch = vi.fn(() => 0); + const kernelReservedSpawn = vi.fn(); + const worker = createWorker({ + callbacks: { onSpawn: vi.fn() }, + completeChannel, + kernelMemory: new WebAssembly.Memory({ initial: 2, maximum: 2 }), + kernelInstance: { + exports: { + kernel_spawn_scratch_begin: vi.fn(() => 17n), + kernel_spawn_scratch_pointer: vi.fn(() => 4096), + kernel_spawn_scratch_capacity: vi.fn(() => blobLength), + kernel_spawn_scratch_cancel: cancelSpawnScratch, + kernel_spawn_reserved_process: kernelReservedSpawn, + }, + }, + }); + const channel = createChannel(7, sharedMemoryFor(65_536)); + const args = [0, 0, 0, blobLength, 0, 0]; + worker.handleSpawnAfterResolve( + channel, + args, + 7, + 7, + 0, + blob, + blobLength, + resolvedProgram(), + [], + ); + + expect(kernelReservedSpawn).not.toHaveBeenCalled(); + expect(cancelSpawnScratch).toHaveBeenCalledOnce(); + expect(cancelSpawnScratch).toHaveBeenCalledWith(17n); expect(completeChannel).toHaveBeenCalledWith( channel, HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN, @@ -358,6 +724,196 @@ describe("SYS_SPAWN blob transport", () => { ); }); + it("cancels a commit rejection before admitting the next large spawn", () => { + const blob = new Uint8Array(CH_TOTAL_SIZE + 1); + const completeChannel = vi.fn(); + const beginSpawnScratch = vi.fn() + .mockReturnValueOnce(31n) + .mockReturnValueOnce(32n); + const cancelSpawnScratch = vi.fn() + .mockReturnValueOnce(0) + .mockReturnValueOnce(-EINVAL); + const kernelReservedSpawn = vi.fn() + .mockReturnValueOnce(-EBUSY) + .mockReturnValueOnce(42); + const onSpawn = vi.fn(() => new Promise(() => {})); + const worker = createWorker({ + callbacks: { onSpawn }, + completeChannel, + kernelMemory: new WebAssembly.Memory({ initial: 2, maximum: 2 }), + kernelInstance: { + exports: { + kernel_spawn_scratch_begin: beginSpawnScratch, + kernel_spawn_scratch_pointer: vi.fn(() => 4096), + kernel_spawn_scratch_capacity: vi.fn(() => blob.byteLength), + kernel_spawn_scratch_cancel: cancelSpawnScratch, + kernel_spawn_reserved_process: kernelReservedSpawn, + }, + }, + }); + const channel = createChannel(7, sharedMemoryFor(65_536)); + const args = [0, 0, 0, blob.byteLength, 0, 0]; + const invoke = () => worker.handleSpawnAfterResolve( + channel, + args, + 7, + 7, + 0, + blob, + blob.byteLength, + resolvedProgram(), + [], + ); + + invoke(); + expect(cancelSpawnScratch).toHaveBeenNthCalledWith(1, 31n); + expect(completeChannel).toHaveBeenCalledWith( + channel, + HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN, + args, + undefined, + -1, + EBUSY, + ); + + completeChannel.mockClear(); + invoke(); + expect(beginSpawnScratch).toHaveBeenCalledTimes(2); + expect(kernelReservedSpawn).toHaveBeenCalledTimes(2); + expect(cancelSpawnScratch).toHaveBeenNthCalledWith(2, 32n); + expect(onSpawn).toHaveBeenCalledOnce(); + }); + + it("keeps the large-spawn guard closed after cancellation protocol failure", () => { + const blob = new Uint8Array(CH_TOTAL_SIZE + 1); + const completeChannel = vi.fn(); + const beginSpawnScratch = vi.fn(() => 41n); + const worker = createWorker({ + callbacks: { onSpawn: vi.fn() }, + completeChannel, + kernelMemory: new WebAssembly.Memory({ initial: 2, maximum: 2 }), + kernelInstance: { + exports: { + kernel_spawn_scratch_begin: beginSpawnScratch, + kernel_spawn_scratch_pointer: vi.fn(() => 4096), + kernel_spawn_scratch_capacity: vi.fn(() => blob.byteLength), + kernel_spawn_scratch_cancel: vi.fn(() => -EBUSY), + kernel_spawn_reserved_process: vi.fn(() => -EBUSY), + }, + }, + }); + const protocolFailure = vi.fn(); + worker.terminateForKernelProtocolFailure = protocolFailure; + const channel = createChannel(7, sharedMemoryFor(65_536)); + const args = [0, 0, 0, blob.byteLength, 0, 0]; + const invoke = () => worker.handleSpawnAfterResolve( + channel, + args, + 7, + 7, + 0, + blob, + blob.byteLength, + resolvedProgram(), + [], + ); + + invoke(); + expect(protocolFailure).toHaveBeenCalledOnce(); + expect(completeChannel).not.toHaveBeenCalled(); + + invoke(); + expect(beginSpawnScratch).toHaveBeenCalledOnce(); + expect(completeChannel).toHaveBeenCalledWith( + channel, + HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN, + args, + undefined, + -1, + EBUSY, + ); + }); + + it("rejects reentrant large reservation without replacing outer bytes", () => { + const outerBlob = new Uint8Array(CH_TOTAL_SIZE + 1).fill(0x41); + const nestedBlob = new Uint8Array(CH_TOTAL_SIZE + 2).fill(0x42); + const kernelMemory = new WebAssembly.Memory({ initial: 3, maximum: 3 }); + const scratchPointer = 4096; + const kernelBytes = new Uint8Array(kernelMemory.buffer); + const completeChannel = vi.fn(); + const beginSpawnScratch = vi.fn(() => 23n); + const cancelSpawnScratch = vi.fn(() => -EINVAL); + const channel = createChannel(7, sharedMemoryFor(65_536)); + const outerArgs = [0, 0, 0, outerBlob.byteLength, 0, 0]; + const nestedArgs = [0, 0, 0, nestedBlob.byteLength, 0, 0]; + let worker: any; + const kernelReservedSpawn = vi.fn(() => { + expect( + kernelBytes.slice( + scratchPointer, + scratchPointer + outerBlob.byteLength, + ), + ).toEqual(outerBlob); + worker.handleSpawnAfterResolve( + channel, + nestedArgs, + 7, + 7, + 0, + nestedBlob, + nestedBlob.byteLength, + resolvedProgram(), + [], + ); + expect( + kernelBytes.slice( + scratchPointer, + scratchPointer + outerBlob.byteLength, + ), + ).toEqual(outerBlob); + return 42; + }); + worker = createWorker({ + callbacks: { onSpawn: vi.fn(() => new Promise(() => {})) }, + completeChannel, + kernelMemory, + kernelInstance: { + exports: { + kernel_spawn_scratch_begin: beginSpawnScratch, + kernel_spawn_scratch_pointer: vi.fn(() => scratchPointer), + kernel_spawn_scratch_capacity: vi.fn(() => nestedBlob.byteLength), + kernel_spawn_scratch_cancel: cancelSpawnScratch, + kernel_spawn_reserved_process: kernelReservedSpawn, + }, + }, + }); + + worker.handleSpawnAfterResolve( + channel, + outerArgs, + 7, + 7, + 0, + outerBlob, + outerBlob.byteLength, + resolvedProgram(), + [], + ); + + expect(beginSpawnScratch).toHaveBeenCalledOnce(); + expect(kernelReservedSpawn).toHaveBeenCalledOnce(); + expect(cancelSpawnScratch).toHaveBeenCalledOnce(); + expect(cancelSpawnScratch).toHaveBeenCalledWith(23n); + expect(completeChannel).toHaveBeenCalledWith( + channel, + HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN, + nestedArgs, + undefined, + -1, + EBUSY, + ); + }); + it.each([ { name: "path range", @@ -365,7 +921,19 @@ describe("SYS_SPAWN blob transport", () => { memoryBytes - 2, 4, 256, - 40, + SPAWN_WIRE_HEADER_BYTES, + 128, + 0, + ], + errno: EFAULT, + }, + { + name: "null positive-length path", + args: (_memoryBytes: number) => [ + 0, + 1, + 256, + SPAWN_WIRE_HEADER_BYTES, 128, 0, ], @@ -373,17 +941,38 @@ describe("SYS_SPAWN blob transport", () => { }, { name: "blob range", - args: (memoryBytes: number) => [64, 4, memoryBytes - 8, 40, 128, 0], + args: (memoryBytes: number) => [ + 64, + 4, + memoryBytes - 8, + SPAWN_WIRE_HEADER_BYTES, + 128, + 0, + ], errno: EFAULT, }, { name: "pid output range", - args: (memoryBytes: number) => [64, 4, 256, 40, memoryBytes - 2, 0], + args: (memoryBytes: number) => [ + 64, + 4, + 256, + SPAWN_WIRE_HEADER_BYTES, + memoryBytes - 2, + 0, + ], errno: EFAULT, }, { name: "fractional blob length", - args: (_memoryBytes: number) => [64, 4, 256, 40.5, 128, 0], + args: (_memoryBytes: number) => [ + 64, + 4, + 256, + SPAWN_WIRE_HEADER_BYTES + 0.5, + 128, + 0, + ], errno: EINVAL, }, { @@ -393,12 +982,26 @@ describe("SYS_SPAWN blob transport", () => { }, { name: "truncated blob header", - args: (_memoryBytes: number) => [64, 4, 256, 39, 128, 0], + args: (_memoryBytes: number) => [ + 64, + 4, + 256, + SPAWN_WIRE_HEADER_BYTES - 1, + 128, + 0, + ], errno: EINVAL, }, { name: "PATH_MAX-byte path", - args: (_memoryBytes: number) => [64, 4096, 256, 40, 128, 0], + args: (_memoryBytes: number) => [ + 64, + POSIX_PATH_MAX_BYTES, + 256, + SPAWN_WIRE_HEADER_BYTES, + 128, + 0, + ], errno: ENAMETOOLONG, }, ])("rejects an invalid $name before resolution", ({ args, errno }) => { @@ -454,7 +1057,7 @@ describe("SYS_SPAWN blob transport", () => { ); }); - it("enforces exec's per-entry ARG_MAX transport contract before resolution", () => { + it("enforces the separate per-entry metadata transport limit before resolution", () => { const blob = buildSpawnBlob(["child"], [`A=${"x".repeat(CH_DATA_SIZE)}`]); const memory = sharedMemoryFor(4096 + blob.byteLength); const bytes = new Uint8Array(memory.buffer); @@ -492,11 +1095,240 @@ describe("SYS_SPAWN blob transport", () => { E2BIG, ); }); + + it.each([ + { name: "argv", argv: ["child"], envp: [], pointerWidth: 4 }, + { name: "argv", argv: ["child"], envp: [], pointerWidth: 8 }, + { name: "environment", argv: [], envp: ["A=value"], pointerWidth: 4 }, + { name: "environment", argv: [], envp: ["A=value"], pointerWidth: 8 }, + ] as const)( + "rejects an unterminated $name string with $pointerWidth-byte pointers before resolution", + ({ argv, envp, pointerWidth }) => { + const blob = buildSpawnBlob(argv, envp); + blob[blob.byteLength - 1] = 0x61; + const harness = createSpawnPreflightHarness(blob, pointerWidth); + + harness.worker.handleSpawn(harness.channel, harness.args); + + expect(harness.onResolveSpawn).not.toHaveBeenCalled(); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN, + harness.args, + undefined, + -1, + EINVAL, + ); + }, + ); + + it.each([ + { + name: "argv", + blob: () => buildCountBoundarySpawnBlob( + SPAWN_MAX_ARGV_COUNT, + 0, + 0, + ), + }, + { + name: "environment", + blob: () => buildCountBoundarySpawnBlob( + 0, + SPAWN_MAX_ENVP_COUNT, + 0, + ), + }, + { + name: "file-action", + blob: () => buildCountBoundarySpawnBlob( + 0, + 0, + SPAWN_MAX_ACTION_COUNT, + ), + }, + ])("admits the exact $name count cap before resolution", ({ blob }) => { + const harness = createSpawnPreflightHarness(blob(), 4); + + harness.worker.handleSpawn(harness.channel, harness.args); + + expect(harness.onResolveSpawn).toHaveBeenCalledOnce(); + expect(harness.completeChannel).not.toHaveBeenCalled(); + }); + + it.each([ + { + name: "argv", + blob: () => buildCountBoundarySpawnBlob( + SPAWN_MAX_ARGV_COUNT + 1, + 0, + 0, + ), + }, + { + name: "environment", + blob: () => buildCountBoundarySpawnBlob( + 0, + SPAWN_MAX_ENVP_COUNT + 1, + 0, + ), + }, + { + name: "file-action", + blob: () => buildCountBoundarySpawnBlob( + 0, + 0, + SPAWN_MAX_ACTION_COUNT + 1, + ), + }, + ])("rejects the $name count cap plus one before resolution", ({ blob }) => { + const harness = createSpawnPreflightHarness(blob(), 4); + + harness.worker.handleSpawn(harness.channel, harness.args); + + expect(harness.onResolveSpawn).not.toHaveBeenCalled(); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN, + harness.args, + undefined, + -1, + EINVAL, + ); + }); + + it.each([4, 8] as const)( + "enforces aggregate ARG_MAX exactly for a wasm%s caller", + (pointerWidth) => { + const exact = createSpawnPreflightHarness( + buildArgMaxBoundarySpawnBlob(pointerWidth, 0), + pointerWidth, + ); + + exact.worker.handleSpawn(exact.channel, exact.args); + + expect(exact.onResolveSpawn).toHaveBeenCalledOnce(); + expect(exact.completeChannel).not.toHaveBeenCalled(); + + const oversized = createSpawnPreflightHarness( + buildArgMaxBoundarySpawnBlob(pointerWidth, 1), + pointerWidth, + ); + + oversized.worker.handleSpawn(oversized.channel, oversized.args); + + expect(oversized.onResolveSpawn).not.toHaveBeenCalled(); + expect(oversized.completeChannel).toHaveBeenCalledWith( + oversized.channel, + HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN, + oversized.args, + undefined, + -1, + E2BIG, + ); + }, + ); + + it("rejects duplicate maximum-count offsets before decoding the repeated tail", () => { + const blob = buildDuplicateOffsetSpawnBlob( + SPAWN_MAX_ARGV_COUNT, + POSIX_ARG_MAX_BYTES, + ); + const memory = sharedMemoryFor(4096 + blob.byteLength); + const bytes = new Uint8Array(memory.buffer); + const path = new TextEncoder().encode("/bin/child"); + const pathPtr = 256; + const blobPtr = 4096; + bytes.set(path, pathPtr); + bytes.set(blob, blobPtr); + const channel = createChannel(7, memory); + const completeChannel = vi.fn(); + const onResolveSpawn = vi.fn(); + const worker = createWorker({ + callbacks: { onResolveSpawn, onSpawn: vi.fn() }, + processes: new Map([[7, { channels: [channel], memory, ptrWidth: 4 }]]), + completeChannel, + }); + const args = [ + pathPtr, + path.byteLength, + blobPtr, + blob.byteLength, + 0, + 0, + ]; + + worker.handleSpawn(channel, args); + + expect(onResolveSpawn).not.toHaveBeenCalled(); + expect(completeChannel).toHaveBeenCalledWith( + channel, + HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN, + args, + undefined, + -1, + E2BIG, + ); + }); }); +function createSpawnPreflightHarness( + blob: Uint8Array, + pointerWidth: 4 | 8, +): { + worker: any; + channel: any; + args: number[]; + onResolveSpawn: ReturnType; + completeChannel: ReturnType; +} { + const path = new TextEncoder().encode("/bin/child"); + const pathPtr = 256; + const blobPtr = 4096; + const memory = sharedMemoryFor(blobPtr + blob.byteLength); + const bytes = new Uint8Array(memory.buffer); + bytes.set(path, pathPtr); + bytes.set(blob, blobPtr); + const channel = createChannel(7, memory); + const completeChannel = vi.fn(); + // Leave accepted preflight pending so these boundary tests exercise only + // host parsing and never need a kernel scratch fixture or child launch. + const onResolveSpawn = vi.fn(() => new Promise(() => {})); + const worker = createWorker({ + callbacks: { onResolveSpawn, onSpawn: vi.fn() }, + processes: new Map([[ + 7, + { channels: [channel], memory, ptrWidth: pointerWidth }, + ]]), + completeChannel, + }); + const args = [ + pathPtr, + path.byteLength, + blobPtr, + blob.byteLength, + 0, + 0, + ]; + return { + worker, + channel, + args, + onResolveSpawn, + completeChannel, + }; +} + function createWorker(overrides: Record): any { + const { + scratchPointer, + ...workerOverrides + } = overrides as Record & { scratchPointer?: number }; const worker = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - kernel: { toKernelPtr: (value: number | bigint) => Number(value) }, + kernel: { + toKernelPtr: (value: number | bigint) => Number(value), + getKernelPtrWidth: () => 4, + }, callbacks: {}, processes: new Map(), channelTids: new Map(), @@ -507,7 +1339,7 @@ function createWorker(overrides: Record): any { tcpListeners: new Map(), epollInterests: new Map(), completeChannel: vi.fn(), - ...overrides, + ...workerOverrides, }); const kernelInstance = worker.kernelInstance ?? { exports: {} }; worker.kernelInstance = { @@ -517,6 +1349,29 @@ function createWorker(overrides: Record): any { ...(kernelInstance.exports ?? {}), }, }; + if ( + worker.kernelMemory && + Number.isSafeInteger(scratchPointer) && + scratchPointer! > 0 && + !worker.scratchRegion + ) { + const scratchTestInstance = createKernelScratchTestInstance( + 4, + worker.kernelMemory, + () => worker.kernelInstance?.exports ?? {}, + () => scratchPointer!, + ); + worker.scratchTestInstance = scratchTestInstance; + worker.scratchRegion = allocateKernelScratchRegion( + worker.kernelMemory, + scratchTestInstance.exports.kernel_alloc_scratch as + (size: number) => number, + CH_TOTAL_SIZE, + 4, + "test kernel syscall scratch", + scratchTestInstance, + ); + } return worker; } @@ -543,7 +1398,7 @@ function buildSpawnBlob(argv: readonly string[], envp: readonly string[]): Uint8 const encoder = new TextEncoder(); const argvBytes = argv.map((value) => encoder.encode(`${value}\0`)); const envpBytes = envp.map((value) => encoder.encode(`${value}\0`)); - const headerBytes = 40; + const headerBytes = SPAWN_WIRE_HEADER_BYTES; const offsetsBytes = (argv.length + envp.length) * 4; const stringsBytes = [...argvBytes, ...envpBytes] .reduce((total, value) => total + value.byteLength, 0); @@ -564,6 +1419,112 @@ function buildSpawnBlob(argv: readonly string[], envp: readonly string[]): Uint8 return blob; } +function buildCountBoundarySpawnBlob( + argc: number, + envc: number, + actionCount: number, +): Uint8Array { + const offsetsBytes = + (argc + envc) * SPAWN_WIRE_STRING_OFFSET_BYTES; + const actionsBytes = + actionCount * SPAWN_WIRE_ACTION_RECORD_BYTES; + const stringsBytes = argc + envc > 0 ? 1 : 0; + const blob = new Uint8Array( + SPAWN_WIRE_HEADER_BYTES + + offsetsBytes + + actionsBytes + + stringsBytes, + ); + const view = new DataView(blob.buffer); + view.setUint32(SPAWN_WIRE_HEADER_ARGC_OFFSET, argc, true); + view.setUint32(SPAWN_WIRE_HEADER_ENVC_OFFSET, envc, true); + view.setUint32( + SPAWN_WIRE_HEADER_ACTION_COUNT_OFFSET, + actionCount, + true, + ); + + const actionsAt = SPAWN_WIRE_HEADER_BYTES + offsetsBytes; + for (let index = 0; index < actionCount; index++) { + view.setUint32( + actionsAt + + index * SPAWN_WIRE_ACTION_RECORD_BYTES + + SPAWN_WIRE_ACTION_OP_OFFSET, + SPAWN_WIRE_OP_CLOSE, + true, + ); + } + // Zero-filled offsets share one empty string. Offset aliasing is valid and + // keeps this test focused on the exact count boundary. + return blob; +} + +function buildArgMaxBoundarySpawnBlob( + pointerWidth: 4 | 8, + delta: 0 | 1, +): Uint8Array { + // Split the aggregate across both vectors and keep every individual entry + // within the separate process-metadata transport limit. + const argc = 32; + const envc = 32; + const entryCount = argc + envc; + const pointerBytes = (entryCount + 2) * pointerWidth; + const stringsBytes = POSIX_ARG_MAX_BYTES - pointerBytes + delta; + const offsetsBytes = + entryCount * SPAWN_WIRE_STRING_OFFSET_BYTES; + const stringsAt = SPAWN_WIRE_HEADER_BYTES + offsetsBytes; + const blob = new Uint8Array(stringsAt + stringsBytes); + const view = new DataView(blob.buffer); + view.setUint32(SPAWN_WIRE_HEADER_ARGC_OFFSET, argc, true); + view.setUint32(SPAWN_WIRE_HEADER_ENVC_OFFSET, envc, true); + + let stringsCursor = 0; + let remaining = stringsBytes; + for (let index = 0; index < entryCount; index++) { + view.setUint32( + SPAWN_WIRE_HEADER_BYTES + + index * SPAWN_WIRE_STRING_OFFSET_BYTES, + stringsCursor, + true, + ); + const entriesRemaining = entryCount - index; + const entryBytes = Math.min( + CH_DATA_SIZE + 1, + remaining - (entriesRemaining - 1), + ); + blob.fill( + 0x61, + stringsAt + stringsCursor, + stringsAt + stringsCursor + entryBytes - 1, + ); + stringsCursor += entryBytes; + remaining -= entryBytes; + } + if (remaining !== 0 || stringsCursor !== stringsBytes) { + throw new Error("failed to construct the requested ARG_MAX boundary"); + } + return blob; +} + +function buildDuplicateOffsetSpawnBlob( + argc: number, + stringBytes: number, +): Uint8Array { + const offsetsBytes = argc * 4; + const blob = new Uint8Array( + SPAWN_WIRE_HEADER_BYTES + offsetsBytes + stringBytes, + ); + const view = new DataView(blob.buffer); + view.setUint32(0, argc, true); + // Every zero-filled offset names the same tail. Keep its final byte NUL. + blob.fill( + 0x61, + SPAWN_WIRE_HEADER_BYTES + offsetsBytes, + blob.byteLength - 1, + ); + return blob; +} + function resolvedProgram() { const bytes = Uint8Array.from([ 0x00, 0x61, 0x73, 0x6d, diff --git a/host/test/spawn-pid-authority.test.ts b/host/test/spawn-pid-authority.test.ts index b37590de90..06fbee6952 100644 --- a/host/test/spawn-pid-authority.test.ts +++ b/host/test/spawn-pid-authority.test.ts @@ -15,6 +15,7 @@ import { HOST_INTERCEPTED_SYSCALLS, WPK_FORK_LINKED_FRAME_POINTER_WIDTHS, } from "../src/generated/abi"; +import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const WASM32_CONTINUATION_HEADER_SIZE = @@ -159,7 +160,7 @@ describe("kernel task-ID authority", () => { const parentPid = 77; const memory = new WebAssembly.Memory({ initial: 4, maximum: 4, shared: true }); const channel = { pid: parentPid, channelOffset: WASM_PAGE_SIZE, memory }; - const kernelMemory = new WebAssembly.Memory({ initial: 1, maximum: 1 }); + const kernelMemory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); const completeChannel = vi.fn(); const onSpawn = vi.fn(async () => 0); const kernelSpawnProcess = vi.fn(() => 0); @@ -173,13 +174,16 @@ describe("kernel task-ID authority", () => { }, }, kernelMemory, - scratchOffset: 0, completeChannel, kernelInstance: { exports: { kernel_spawn_process: kernelSpawnProcess }, }, }, ) as CentralizedKernelWorker; + const scratchPointer = installKernelWorkerTestScratch( + kernelWorker as unknown as Record, + kernelMemory, + ); const origArgs = [1, 2, 3, 4, 5, 0]; (kernelWorker as any).handleSpawnAfterResolve( @@ -194,7 +198,12 @@ describe("kernel task-ID authority", () => { [], ); - expect(kernelSpawnProcess).toHaveBeenCalledWith(parentPid, parentPid, 0, 1); + expect(kernelSpawnProcess).toHaveBeenCalledWith( + parentPid, + parentPid, + scratchPointer, + 1, + ); expect(onSpawn).not.toHaveBeenCalled(); expect(completeChannel).toHaveBeenCalledWith( channel, diff --git a/host/test/support/kernel-scratch-instance.ts b/host/test/support/kernel-scratch-instance.ts new file mode 100644 index 0000000000..18a810d548 --- /dev/null +++ b/host/test/support/kernel-scratch-instance.ts @@ -0,0 +1,277 @@ +type WasmValueType = "i32" | "i64"; + +interface WasmFunctionSignature { + readonly parameters: readonly WasmValueType[]; + readonly result: WasmValueType; +} + +function unsignedLeb128(value: number): number[] { + const bytes: number[] = []; + do { + let byte = value & 0x7f; + value >>>= 7; + if (value !== 0) byte |= 0x80; + bytes.push(byte); + } while (value !== 0); + return bytes; +} + +function wasmString(value: string): number[] { + const bytes = Array.from(new TextEncoder().encode(value)); + return [...unsignedLeb128(bytes.length), ...bytes]; +} + +function section(id: number, payload: number[]): number[] { + return [id, ...unsignedLeb128(payload.length), ...payload]; +} + +function signatures( + pointerWidth: 4 | 8, +): Record { + const pointer: WasmValueType = pointerWidth === 4 ? "i32" : "i64"; + const i32 = "i32" as const; + const i64 = "i64" as const; + return { + kernel_alloc_scratch: { + parameters: [i32], + result: pointer, + }, + kernel_dequeue_signal: { + parameters: [i32, i32, pointer, i32], + result: i32, + }, + kernel_drain_audio: { + parameters: [pointer, i32], + result: i32, + }, + kernel_drain_wakeup_events: { + parameters: [pointer, i32, i32], + result: i32, + }, + kernel_enum_procs: { + parameters: [pointer, i32], + result: i32, + }, + kernel_get_cwd: { + parameters: [i32, pointer, i32], + result: i32, + }, + kernel_get_fd_path: { + parameters: [i32, i32, pointer, i32], + result: i32, + }, + kernel_getrusage: { + parameters: [i32, pointer, i32], + result: i32, + }, + kernel_getsockopt: { + parameters: [i32, i32, i32, pointer, i32, pointer, i32], + result: i32, + }, + kernel_handle_channel: { + parameters: [pointer, i32, i32], + result: i32, + }, + kernel_inject_datagram: { + parameters: [ + i32, i32, i32, i32, i32, i32, + i32, i32, i32, i32, i32, + pointer, i32, + ], + result: i32, + }, + kernel_ioctl: { + parameters: [i32, i32, pointer, i32, i32], + result: i32, + }, + kernel_ipc_shm_read_chunk: { + parameters: [i32, i32, pointer, i32], + result: i32, + }, + kernel_ipc_shm_write_chunk: { + parameters: [i32, i32, pointer, i32], + result: i32, + }, + kernel_mq_drain_notification: { + parameters: [pointer, i32], + result: i32, + }, + kernel_pipe2: { + parameters: [i32, pointer, i32], + result: i32, + }, + kernel_pipe_read: { + parameters: [i32, i32, pointer, i32], + result: i32, + }, + kernel_pipe_write: { + parameters: [i32, i32, pointer, i32], + result: i32, + }, + kernel_poll: { + parameters: [pointer, i32, i32, i32], + result: i32, + }, + kernel_pty_master_read: { + parameters: [i32, pointer, i32], + result: i32, + }, + kernel_pty_master_write: { + parameters: [i32, pointer, i32], + result: i32, + }, + kernel_push_process_metadata_entry: { + parameters: [i32, i32, pointer, i32], + result: i32, + }, + kernel_read_proc_maps: { + parameters: [i32, pointer, i32], + result: i32, + }, + kernel_recv: { + parameters: [i32, pointer, i32, i32], + result: i32, + }, + kernel_select: { + parameters: [ + i32, + pointer, i32, + pointer, i32, + pointer, i32, + i32, + ], + result: i32, + }, + kernel_send: { + parameters: [i32, pointer, i32, i32], + result: i32, + }, + kernel_set_cwd: { + parameters: [i32, pointer, i32], + result: i32, + }, + kernel_socketpair: { + parameters: [i32, i32, i32, pointer, i32], + result: i32, + }, + kernel_spawn_process: { + parameters: [i32, i32, pointer, pointer], + result: i32, + }, + kernel_tcgetattr: { + parameters: [i32, pointer, i32], + result: i32, + }, + kernel_tcsetattr: { + parameters: [i32, i32, pointer, i32], + result: i32, + }, + kernel_truncate: { + parameters: [pointer, i32, i64], + result: i32, + }, + kernel_uname: { + parameters: [pointer, i32], + result: i32, + }, + kernel_wait_child_poll: { + parameters: [i32, i32, i32, i32, i32, pointer, i32], + result: i32, + }, + }; +} + +/** + * Build genuine Wasm exports that forward to mutable JavaScript test doubles. + * + * Production scratch regions reject structural `{ exports }` objects. Tests + * keep that invariant honest by importing their mocks into a real module and + * re-exporting the resulting native WebAssembly functions. The resolver is + * intentionally late-bound so a test may replace a mock without mutating the + * non-extensible genuine exports namespace. + */ +export function createKernelScratchTestInstance( + pointerWidth: 4 | 8, + memory: WebAssembly.Memory, + resolveExports: () => Record, + allocator: (capacity: number) => number | bigint, +): WebAssembly.Instance { + const entries = Object.entries(signatures(pointerWidth)); + const memoryIsShared = typeof SharedArrayBuffer !== "undefined" + && memory.buffer instanceof SharedArrayBuffer; + const valueType = (type: WasmValueType): number => + type === "i32" ? 0x7f : 0x7e; + const typePayload: number[] = [ + ...unsignedLeb128(entries.length), + ]; + const importPayload: number[] = [ + ...unsignedLeb128(entries.length + 1), + ...wasmString("scratch"), + ...wasmString("memory"), + 2, // memory import + ...(memoryIsShared + // WHY: Wasm import types distinguish shared and unshared memories. + // Shared memories require an advertised maximum; the broad wasm32 + // ceiling accepts every valid test-memory maximum while preserving the + // exact shared-state bit that instance identity validation relies on. + ? [0x03, 0, ...unsignedLeb128(65_536)] + : [0x00, 0]), + ]; + const exportPayload: number[] = [ + ...unsignedLeb128(entries.length + 1), + ...wasmString("memory"), + 2, + 0, + ]; + const imports: Record) => number | bigint> + = {}; + + entries.forEach(([name, signature], index) => { + typePayload.push( + 0x60, + ...unsignedLeb128(signature.parameters.length), + ...signature.parameters.map(valueType), + 1, + valueType(signature.result), + ); + importPayload.push( + ...wasmString("scratch"), + ...wasmString(name), + 0, + ...unsignedLeb128(index), + ); + exportPayload.push( + ...wasmString(name), + 0, + ...unsignedLeb128(index), + ); + imports[name] = (...args) => { + if (name === "kernel_alloc_scratch") { + return allocator(Number(args[0])); + } + const implementation = resolveExports()[name]; + if (typeof implementation !== "function") { + throw new Error(`missing test implementation for ${name}`); + } + const result = Reflect.apply(implementation, undefined, args); + return signature.result === "i64" + ? BigInt(result as bigint | number) + : Number(result); + }; + }); + + const bytes = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, + 0x01, 0x00, 0x00, 0x00, + ...section(1, typePayload), + ...section(2, importPayload), + ...section(7, exportPayload), + ]); + const module = new WebAssembly.Module(bytes); + return new WebAssembly.Instance(module, { + scratch: { + ...imports, + memory, + }, + }); +} diff --git a/host/test/support/wasm-memory-write-audit.ts b/host/test/support/wasm-memory-write-audit.ts new file mode 100644 index 0000000000..145dfc3769 --- /dev/null +++ b/host/test/support/wasm-memory-write-audit.ts @@ -0,0 +1,4342 @@ +import { + readdirSync, +} from "node:fs"; +import path from "node:path"; +import ts from "typescript"; + +export type MemoryOwner = + | "kernel" + | "process-memory" + | "framebuffer" + | "shared-memory" + | "rust-lent"; + +export type OwnershipForm = + | "memory" + | "buffer" + | "view" + | "instance" + | "scratch-region"; + +export interface OwnershipSeed { + /** + * Exact `repo/relative/file.ts::Qualified.declaration` key. + * + * Wildcards are intentionally unsupported: adding a new owner must produce + * a visible, narrowly reviewed contract change. + */ + declaration: string; + target: "value" | "return"; + owner: MemoryOwner; + form: OwnershipForm; + why: string; +} + +export interface AuditAllowance { + /** Exact key returned in {@link AuditFinding.key}. */ + key: string; + disposition: + | "scratch-core" + | "rust-lent" + | "kernel-read" + | "kernel-control" + | "non-kernel"; + /** Exact number of structurally identical sites admitted by this entry. */ + count?: number; + why: string; +} + +export interface AuditFinding { + key: string; + file: string; + enclosing: string; + kind: + | "kernel-view" + | "kernel-write" + | "kernel-view-escape" + | "kernel-view-return" + | "kernel-view-store" + | "kernel-buffer-escape" + | "kernel-buffer-return" + | "kernel-buffer-store" + | "kernel-memory-escape" + | "kernel-memory-return" + | "kernel-memory-store" + | "kernel-pointer-export-bypass" + | "scratch-address-contract" + | "scratch-allocator-call" + | "scratch-region-factory-call" + | "spawn-reservation-call"; + line: number; + text: string; +} + +export interface AuditResult { + findings: AuditFinding[]; + violations: AuditFinding[]; + unusedAllowances: AuditAllowance[]; + unresolvedSeeds: OwnershipSeed[]; + contractErrors: string[]; + sourceFiles: string[]; +} + +export interface AuditOptions { + rootDir: string; + sourceFiles: string[]; + ownershipSeeds: readonly OwnershipSeed[]; + allowances?: readonly AuditAllowance[]; + compilerOptions?: ts.CompilerOptions; + virtualSources?: ReadonlyMap; +} + +type StateKey = ts.Symbol | ts.FunctionLikeDeclaration; + +interface ValueState { + memory: number; + buffer: number; + view: number; + instance: number; + exportNamespace: number; + kernelExportFunctions: Set; + allocator: boolean; + reserver: boolean; + scratchRegionFactory: boolean; + scratchRegion: boolean; + viewConstructors: number; + properties: Map; + hiddenProperties: Map; + elements: ValueState | null; +} + +type StateProjection = + | { kind: "property"; name: string } + | { kind: "element" }; + +interface Constraint { + target: StateKey; + expression: ts.Expression; + projection?: readonly StateProjection[]; + targetProjection?: readonly StateProjection[]; +} + +interface DeclarationTarget { + value?: ts.Symbol; + returns?: ts.FunctionLikeDeclaration; +} + +const EMPTY_STATE: ValueState = Object.freeze({ + memory: 0, + buffer: 0, + view: 0, + instance: 0, + exportNamespace: 0, + kernelExportFunctions: new Set(), + allocator: false, + reserver: false, + scratchRegionFactory: false, + scratchRegion: false, + viewConstructors: 0, + properties: new Map(), + hiddenProperties: new Map(), + elements: null, +}); + +const OWNER_BITS: Record = { + kernel: 1 << 0, + "process-memory": 1 << 1, + framebuffer: 1 << 2, + "shared-memory": 1 << 3, + "rust-lent": 1 << 4, +}; + +const KERNEL_OWNER = OWNER_BITS.kernel; +const TYPED_ARRAY_CONSTRUCTOR = 1 << 0; +const DATA_VIEW_CONSTRUCTOR = 1 << 1; +const TYPE_PROPERTIES = new WeakMap(); +const INTRINSIC_ARRAY_METHODS = new WeakMap< + ts.CallExpression, + string | null +>(); +const ARRAY_ELEMENT_RETURNING_METHODS = new Set([ + "at", + "find", + "findLast", + "pop", + "shift", +]); +const ARRAY_ELEMENT_CALLBACK_METHODS = new Set([ + "every", + "filter", + "find", + "findIndex", + "findLast", + "findLastIndex", + "forEach", + "map", + "reduce", + "reduceRight", + "some", +]); + +const TYPED_ARRAY_CONSTRUCTORS = new Set([ + "BigInt64Array", + "BigUint64Array", + "Float32Array", + "Float64Array", + "Int8Array", + "Int16Array", + "Int32Array", + "Uint8Array", + "Uint8ClampedArray", + "Uint16Array", + "Uint32Array", +]); + +const TYPED_ARRAY_MUTATORS = new Set([ + "copyWithin", + "fill", + "reverse", + "set", + "sort", +]); + +// Positive list only: these intrinsic methods neither mutate a typed array nor +// pass/retain its live receiver. Callback and iterator methods are deliberately +// absent because they can expose the receiver after a superficially read-only +// call. +const TYPED_ARRAY_NON_RETAINING_METHODS = new Set([ + "at", + "includes", + "indexOf", + "join", + "lastIndexOf", + "slice", + "subarray", + "toLocaleString", + "toReversed", + "toSorted", + "toString", + "with", +]); + +const TYPED_ARRAY_RETAINING_ITERATOR_METHODS = new Set([ + "entries", + "keys", + "values", +]); + +const CONTAINER_CALLBACK_PARAMETER_INDEX = new Map([ + ["every", 2], + ["filter", 2], + ["find", 2], + ["findIndex", 2], + ["findLast", 2], + ["findLastIndex", 2], + ["forEach", 2], + ["map", 2], + ["reduce", 3], + ["reduceRight", 3], + ["some", 2], +]); + +const ATOMIC_MUTATORS = new Set([ + "add", + "and", + "compareExchange", + "exchange", + "or", + "store", + "sub", + "xor", +]); + +const SKIPPED_DIRECTORY_NAMES = new Set([ + ".git", + ".next", + ".turbo", + ".vite", + "coverage", + "dist", + "node_modules", + "target", + "test-results", +]); + +const UNKNOWN_KERNEL_EXPORT = ""; + +function frozenStringArray( + expression: ts.Expression, +): readonly string[] | null { + let value = unwrapExpression(expression); + if (ts.isCallExpression(value) && value.arguments.length === 1) { + const callee = unwrapExpression(value.expression); + const capturedFreeze = ts.isIdentifier(callee) + && callee.text === "intrinsicObjectFreeze"; + const freezeReceiver = ts.isPropertyAccessExpression(callee) + ? unwrapExpression(callee.expression) + : null; + const directFreeze = ts.isPropertyAccessExpression(callee) + && freezeReceiver !== null + && ts.isIdentifier(freezeReceiver) + && freezeReceiver.text === "Object" + && callee.name.text === "freeze"; + if (!capturedFreeze && !directFreeze) return null; + value = unwrapExpression(value.arguments[0]); + } + if (!ts.isArrayLiteralExpression(value)) return null; + const result: string[] = []; + for (const element of value.elements) { + if (!ts.isStringLiteralLike(element)) return null; + result.push(element.text); + } + return result; +} + +function kernelScratchPointerExportContract( + sourceFiles: readonly ts.SourceFile[], +): { + readonly names: ReadonlySet; + readonly errors: readonly string[]; +} { + const contractFiles = sourceFiles.filter((sourceFile) => + toPosix(sourceFile.fileName).endsWith("/host/src/kernel-scratch.ts") + ); + if (contractFiles.length === 0) { + return { names: new Set(), errors: [] }; + } + + const declarations: ts.VariableDeclaration[] = []; + for (const sourceFile of contractFiles) { + const visit = (node: ts.Node): void => { + if ( + ts.isVariableDeclaration(node) + && ts.isIdentifier(node.name) + && node.name.text === "KERNEL_SCRATCH_EXPORT_NAMES" + ) { + declarations.push(node); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + } + if (declarations.length !== 1 || !declarations[0].initializer) { + return { + names: new Set(), + errors: [ + "could not resolve the single authoritative " + + "KERNEL_SCRATCH_EXPORT_NAMES declaration", + ], + }; + } + const values = frozenStringArray(declarations[0].initializer); + if (!values || values.length === 0 || new Set(values).size !== values.length) { + return { + names: new Set(), + errors: [ + "KERNEL_SCRATCH_EXPORT_NAMES must remain a non-empty frozen " + + "string-literal array", + ], + }; + } + return { names: new Set(values), errors: [] }; +} + +function emptyState(): ValueState { + return { + memory: 0, + buffer: 0, + view: 0, + instance: 0, + exportNamespace: 0, + kernelExportFunctions: new Set(), + allocator: false, + reserver: false, + scratchRegionFactory: false, + scratchRegion: false, + viewConstructors: 0, + properties: new Map(), + hiddenProperties: new Map(), + elements: null, + }; +} + +function ownerState(owner: MemoryOwner, form: OwnershipForm): ValueState { + const state = emptyState(); + if (form === "scratch-region") { + state.scratchRegion = true; + } else { + state[form] = OWNER_BITS[owner]; + } + return state; +} + +function cloneState(state: ValueState): ValueState { + const result: ValueState = { + memory: state.memory, + buffer: state.buffer, + view: state.view, + instance: state.instance, + exportNamespace: state.exportNamespace, + kernelExportFunctions: new Set(state.kernelExportFunctions), + allocator: state.allocator, + reserver: state.reserver, + scratchRegionFactory: state.scratchRegionFactory, + scratchRegion: state.scratchRegion, + viewConstructors: state.viewConstructors, + properties: new Map(), + hiddenProperties: new Map(), + elements: state.elements ? cloneState(state.elements) : null, + }; + for (const [name, property] of state.properties) { + result.properties.set(name, cloneState(property)); + } + for (const [name, property] of state.hiddenProperties) { + result.hiddenProperties.set(name, cloneState(property)); + } + return result; +} + +function unionState( + into: ValueState, + other: ValueState, +): boolean { + const beforeMemory = into.memory; + const beforeBuffer = into.buffer; + const beforeView = into.view; + const beforeInstance = into.instance; + const beforeExportNamespace = into.exportNamespace; + const beforeKernelExportFunctionCount = into.kernelExportFunctions.size; + const beforeAllocator = into.allocator; + const beforeReserver = into.reserver; + const beforeScratchRegionFactory = into.scratchRegionFactory; + const beforeScratchRegion = into.scratchRegion; + const beforeViewConstructors = into.viewConstructors; + into.memory |= other.memory; + into.buffer |= other.buffer; + into.view |= other.view; + into.instance |= other.instance; + into.exportNamespace |= other.exportNamespace; + for (const name of other.kernelExportFunctions) { + into.kernelExportFunctions.add(name); + } + into.allocator ||= other.allocator; + into.reserver ||= other.reserver; + into.scratchRegionFactory ||= other.scratchRegionFactory; + into.scratchRegion ||= other.scratchRegion; + into.viewConstructors |= other.viewConstructors; + let changed = ( + beforeMemory !== into.memory + || beforeBuffer !== into.buffer + || beforeView !== into.view + || beforeInstance !== into.instance + || beforeExportNamespace !== into.exportNamespace + || beforeKernelExportFunctionCount !== into.kernelExportFunctions.size + || beforeAllocator !== into.allocator + || beforeReserver !== into.reserver + || beforeScratchRegionFactory !== into.scratchRegionFactory + || beforeScratchRegion !== into.scratchRegion + || beforeViewConstructors !== into.viewConstructors + ); + for (const [name, property] of other.properties) { + const existing = into.properties.get(name); + if (existing) { + changed = unionState(existing, property) || changed; + } else { + into.properties.set(name, cloneState(property)); + changed = true; + } + } + for (const [name, property] of other.hiddenProperties) { + const existing = into.hiddenProperties.get(name); + if (existing) { + changed = unionState(existing, property) || changed; + } else { + into.hiddenProperties.set(name, cloneState(property)); + changed = true; + } + } + if (other.elements) { + if (into.elements) { + changed = unionState(into.elements, other.elements) || changed; + } else { + into.elements = cloneState(other.elements); + changed = true; + } + } + return changed; +} + +function unionMany(states: Iterable): ValueState { + const result = emptyState(); + for (const state of states) unionState(result, state); + return result; +} + +function hasCapability( + state: ValueState, + seen = new Set(), +): boolean { + if (seen.has(state)) return false; + seen.add(state); + if ( + state.memory !== 0 + || state.buffer !== 0 + || state.view !== 0 + || state.instance !== 0 + || state.exportNamespace !== 0 + || state.kernelExportFunctions.size !== 0 + || state.allocator + || state.reserver + || state.scratchRegionFactory + || state.scratchRegion + || state.viewConstructors !== 0 + ) { + return true; + } + for (const property of state.properties.values()) { + if (hasCapability(property, seen)) return true; + } + for (const property of state.hiddenProperties.values()) { + if (hasCapability(property, seen)) return true; + } + return state.elements ? hasCapability(state.elements, seen) : false; +} + +function propertyState(state: ValueState, name: string): ValueState { + const result = cloneState(state.properties.get(name) ?? EMPTY_STATE); + unionState(result, state.hiddenProperties.get(name) ?? EMPTY_STATE); + if (name === "buffer") { + result.buffer |= state.memory | state.view; + } else if (name === "exports") { + result.exportNamespace |= state.instance; + } else if (name === "memory") { + result.memory |= state.exportNamespace; + } + if ( + (state.exportNamespace & KERNEL_OWNER) !== 0 + && name.startsWith("kernel_") + ) { + result.kernelExportFunctions.add(name); + } + if (name === "call" || name === "apply" || name === "bind") { + for (const exportName of state.kernelExportFunctions) { + result.kernelExportFunctions.add(exportName); + } + } + return result; +} + +function elementState(state: ValueState): ValueState { + const result = cloneState(state.elements ?? EMPTY_STATE); + if ((state.exportNamespace & KERNEL_OWNER) !== 0) { + result.kernelExportFunctions.add(UNKNOWN_KERNEL_EXPORT); + } + for (const property of state.properties.values()) { + unionState(result, property); + } + for (const property of state.hiddenProperties.values()) { + unionState(result, property); + } + return result; +} + +function projectState( + state: ValueState, + projections: readonly StateProjection[] | undefined, +): ValueState { + let result = cloneState(state); + for (const projection of projections ?? []) { + result = projection.kind === "property" + ? propertyState(result, projection.name) + : elementState(result); + } + return result; +} + +function unwrapExpression(expression: ts.Expression): ts.Expression { + let current = expression; + while ( + ts.isParenthesizedExpression(current) + || ts.isAsExpression(current) + || ts.isTypeAssertionExpression(current) + || ts.isNonNullExpression(current) + || ts.isSatisfiesExpression(current) + ) { + current = current.expression; + } + return current; +} + +function propertyNameText(name: ts.DeclarationName | undefined): string | null { + if (!name) return null; + if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name)) { + return name.text; + } + if (ts.isStringLiteralLike(name) || ts.isNumericLiteral(name)) { + return name.text; + } + return null; +} + +function accessedPropertyName( + expression: ts.PropertyAccessExpression | ts.ElementAccessExpression, +): string | null { + if (ts.isPropertyAccessExpression(expression)) { + return expression.name.text; + } + const argument = expression.argumentExpression; + return argument + && (ts.isStringLiteralLike(argument) || ts.isNumericLiteral(argument)) + ? argument.text + : null; +} + +function normalizeText(node: ts.Node, sourceFile: ts.SourceFile): string { + return node.getText(sourceFile).replace(/\s+/g, " ").trim(); +} + +function toPosix(value: string): string { + return value.split(path.sep).join("/"); +} + +function relativeFile(rootDir: string, sourceFile: ts.SourceFile): string { + return toPosix(path.relative(rootDir, sourceFile.fileName)); +} + +function namedDeclarationPart(node: ts.Node): string | null { + if ( + ts.isClassDeclaration(node) + || ts.isInterfaceDeclaration(node) + || ts.isTypeAliasDeclaration(node) + || ts.isEnumDeclaration(node) + || ts.isModuleDeclaration(node) + ) { + return node.name?.getText() ?? null; + } + if ( + ts.isFunctionDeclaration(node) + || ts.isMethodDeclaration(node) + || ts.isGetAccessorDeclaration(node) + || ts.isSetAccessorDeclaration(node) + ) { + return propertyNameText(node.name) ?? null; + } + return null; +} + +function enclosingDeclarationParts(node: ts.Node): string[] { + const parts: string[] = []; + for (let current: ts.Node | undefined = node.parent; current; current = current.parent) { + const part = namedDeclarationPart(current); + if (part) parts.push(part); + } + return parts.reverse(); +} + +function declarationName(node: ts.Declaration): string | null { + if ( + ts.isVariableDeclaration(node) + || ts.isPropertyDeclaration(node) + || ts.isPropertySignature(node) + || ts.isParameter(node) + || ts.isBindingElement(node) + ) { + return ts.isIdentifier(node.name) ? node.name.text : null; + } + if ( + ts.isFunctionDeclaration(node) + || ts.isMethodDeclaration(node) + || ts.isGetAccessorDeclaration(node) + || ts.isSetAccessorDeclaration(node) + ) { + return propertyNameText(node.name); + } + return null; +} + +function declarationKey( + rootDir: string, + sourceFile: ts.SourceFile, + declaration: ts.Declaration, +): string | null { + const name = declarationName(declaration); + if (!name) return null; + const parts = enclosingDeclarationParts(declaration); + if (ts.isParameter(declaration)) { + parts.push(`$param:${name}`); + } else if (parts.at(-1) !== name) { + parts.push(name); + } + return `${relativeFile(rootDir, sourceFile)}::${parts.join(".")}`; +} + +function callableName(node: ts.Node): string { + for (let current: ts.Node | undefined = node; current; current = current.parent) { + if (ts.isConstructorDeclaration(current)) { + const container = enclosingDeclarationParts(current).join("."); + return container ? `${container}.constructor` : "constructor"; + } + if ( + ts.isMethodDeclaration(current) + || ts.isGetAccessorDeclaration(current) + || ts.isSetAccessorDeclaration(current) + ) { + const method = propertyNameText(current.name) ?? ""; + const container = enclosingDeclarationParts(current).join("."); + return container ? `${container}.${method}` : method; + } + if (ts.isFunctionDeclaration(current) && current.name) { + const container = enclosingDeclarationParts(current).join("."); + return container + ? `${container}.${current.name.text}` + : current.name.text; + } + if ( + (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) + && ts.isVariableDeclaration(current.parent) + && ts.isIdentifier(current.parent.name) + ) { + const container = enclosingDeclarationParts(current.parent).join("."); + return container + ? `${container}.${current.parent.name.text}` + : current.parent.name.text; + } + } + return ""; +} + +function sourceScriptKind(fileName: string): ts.ScriptKind { + if (fileName.endsWith(".tsx")) return ts.ScriptKind.TSX; + if (fileName.endsWith(".jsx")) return ts.ScriptKind.JSX; + if (fileName.endsWith(".js") || fileName.endsWith(".mjs") || fileName.endsWith(".cjs")) { + return ts.ScriptKind.JS; + } + return ts.ScriptKind.TS; +} + +function createProgram(options: AuditOptions): ts.Program { + const compilerOptions: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + allowJs: true, + jsx: ts.JsxEmit.Preserve, + skipLibCheck: true, + strict: true, + noEmit: true, + ...options.compilerOptions, + }; + if (!options.virtualSources) { + return ts.createProgram({ + rootNames: options.sourceFiles, + options: compilerOptions, + }); + } + + const normalizedVirtualSources = new Map(); + for (const [fileName, source] of options.virtualSources) { + normalizedVirtualSources.set(path.resolve(fileName), source); + } + const baseHost = ts.createCompilerHost(compilerOptions, true); + const host: ts.CompilerHost = { + ...baseHost, + directoryExists(directoryName) { + const resolved = path.resolve(directoryName); + for (const fileName of normalizedVirtualSources.keys()) { + if (fileName.startsWith(`${resolved}${path.sep}`)) return true; + } + return baseHost.directoryExists?.(directoryName) ?? false; + }, + fileExists(fileName) { + return normalizedVirtualSources.has(path.resolve(fileName)) + || baseHost.fileExists(fileName); + }, + readFile(fileName) { + return normalizedVirtualSources.get(path.resolve(fileName)) + ?? baseHost.readFile(fileName); + }, + getSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile) { + const source = normalizedVirtualSources.get(path.resolve(fileName)); + if (source !== undefined) { + return ts.createSourceFile( + fileName, + source, + languageVersion, + true, + sourceScriptKind(fileName), + ); + } + return baseHost.getSourceFile( + fileName, + languageVersion, + onError, + shouldCreateNewSourceFile, + ); + }, + }; + return ts.createProgram({ + rootNames: options.sourceFiles, + options: compilerOptions, + host, + }); +} + +function isParameterProperty( + declaration: ts.Declaration, +): declaration is ts.ParameterDeclaration { + return ts.isParameter(declaration) + && ts.isIdentifier(declaration.name) + && ts.isConstructorDeclaration(declaration.parent) + && Boolean( + declaration.modifiers?.some((modifier) => + modifier.kind === ts.SyntaxKind.PublicKeyword + || modifier.kind === ts.SyntaxKind.PrivateKeyword + || modifier.kind === ts.SyntaxKind.ProtectedKeyword + || modifier.kind === ts.SyntaxKind.ReadonlyKeyword + ), + ); +} + +function canonicalSymbol( + checker: ts.TypeChecker, + symbol: ts.Symbol | undefined, +): ts.Symbol | undefined { + if (!symbol) return undefined; + if ((symbol.flags & ts.SymbolFlags.Alias) !== 0) { + try { + return checker.getAliasedSymbol(symbol); + } catch { + return symbol; + } + } + const parameterProperty = symbol.declarations?.find(isParameterProperty); + if (parameterProperty) { + // TypeScript can materialize distinct symbols for the declaration name, + // the bare constructor parameter, and `this.property`. They are one + // runtime slot, so normalize all three to the declaration-name symbol. + return checker.getSymbolAtLocation(parameterProperty.name) ?? symbol; + } + return symbol; +} + +function symbolAtExpression( + checker: ts.TypeChecker, + expression: ts.Expression, +): ts.Symbol | undefined { + const unwrapped = unwrapExpression(expression); + if (ts.isIdentifier(unwrapped)) { + return canonicalSymbol(checker, checker.getSymbolAtLocation(unwrapped)); + } + if (ts.isPropertyAccessExpression(unwrapped)) { + const direct = canonicalSymbol( + checker, + checker.getSymbolAtLocation(unwrapped.name), + ); + if (direct) return direct; + const receiverType = checker.getTypeAtLocation( + unwrapExpression(unwrapped.expression), + ); + return canonicalSymbol( + checker, + checker.getPropertyOfType(receiverType, unwrapped.name.text), + ); + } + if (ts.isElementAccessExpression(unwrapped)) { + const name = accessedPropertyName(unwrapped); + if (!name) return undefined; + const type = checker.getTypeAtLocation( + unwrapExpression(unwrapped.expression), + ); + return canonicalSymbol(checker, checker.getPropertyOfType(type, name)); + } + return undefined; +} + +function isScratchRegionFactorySymbol( + symbol: ts.Symbol | undefined, +): boolean { + return Boolean( + symbol?.declarations?.some((declaration) => { + const name = declarationName(declaration); + if ( + name !== "allocateKernelScratchRegion" + && name !== "reserveKernelScratchRegion" + ) { + return false; + } + const file = toPosix(declaration.getSourceFile().fileName); + return file.endsWith("/host/src/kernel-scratch.ts"); + }), + ); +} + +const SCRATCH_ADDRESS_OWNERS = new Set([ + "ActiveKernelScratchLease", + "KernelScratchLease", +]); +const SCRATCH_REGION_OWNERS = new Set([ + "KernelScratchRegion", + "OwnedKernelScratchRegion", +]); + +function isKernelScratchMemberDeclaration( + declaration: ts.Declaration, + member: string, + owners: ReadonlySet, +): boolean { + const name = (declaration as ts.NamedDeclaration).name; + if (!name || propertyNameText(name) !== member) return false; + const file = toPosix(declaration.getSourceFile().fileName); + return file.endsWith("/host/src/kernel-scratch.ts") + && owners.has( + signatureOwnerName(declaration as ts.SignatureDeclaration) ?? "", + ); +} + +function isScratchAddressSymbol( + symbol: ts.Symbol | undefined, +): boolean { + return Boolean( + symbol?.declarations?.some((declaration) => + isKernelScratchMemberDeclaration( + declaration, + "address", + SCRATCH_ADDRESS_OWNERS, + ) + ), + ); +} + +function isScratchLeaseMemberSymbol( + symbol: ts.Symbol | undefined, +): boolean { + return Boolean( + symbol?.declarations?.some((declaration) => { + const file = toPosix(declaration.getSourceFile().fileName); + return file.endsWith("/host/src/kernel-scratch.ts") + && SCRATCH_ADDRESS_OWNERS.has( + signatureOwnerName(declaration as ts.SignatureDeclaration) ?? "", + ); + }), + ); +} + +function isScratchWithLeaseSymbol( + symbol: ts.Symbol | undefined, +): boolean { + return Boolean( + symbol?.declarations?.some((declaration) => + isKernelScratchMemberDeclaration( + declaration, + "withLease", + SCRATCH_REGION_OWNERS, + ) + ), + ); +} + +function isScratchRegionMemberSymbol( + symbol: ts.Symbol | undefined, +): boolean { + return Boolean( + symbol?.declarations?.some((declaration) => { + const file = toPosix(declaration.getSourceFile().fileName); + return file.endsWith("/host/src/kernel-scratch.ts") + && SCRATCH_REGION_OWNERS.has( + signatureOwnerName(declaration as ts.SignatureDeclaration) ?? "", + ); + }), + ); +} + +function isKernelScratchWithLeaseCall( + call: ts.CallExpression, + checker: ts.TypeChecker, +): boolean { + if (callPropertyName(call) !== "withLease") return false; + const declaration = checker.getResolvedSignature(call)?.declaration; + return Boolean( + declaration + && isKernelScratchMemberDeclaration( + declaration, + "withLease", + SCRATCH_REGION_OWNERS, + ), + ); +} + +function symbolForDeclaration( + checker: ts.TypeChecker, + declaration: ts.Declaration, +): ts.Symbol | undefined { + const name = (declaration as ts.NamedDeclaration).name; + return name + ? canonicalSymbol(checker, checker.getSymbolAtLocation(name)) + : undefined; +} + +function parameterPropertySymbol( + checker: ts.TypeChecker, + parameter: ts.ParameterDeclaration, +): ts.Symbol | undefined { + if ( + !isParameterProperty(parameter) + || !ts.isIdentifier(parameter.name) + || !ts.isConstructorDeclaration(parameter.parent) + || !ts.isClassLike(parameter.parent.parent) + ) { + return undefined; + } + const classDeclaration = parameter.parent.parent; + const classSymbol = classDeclaration.name + ? canonicalSymbol( + checker, + checker.getSymbolAtLocation(classDeclaration.name), + ) + : undefined; + if (!classSymbol) return undefined; + return canonicalSymbol( + checker, + checker.getPropertyOfType( + checker.getDeclaredTypeOfSymbol(classSymbol), + parameter.name.text, + ), + ); +} + +function hasBody( + declaration: ts.Node | undefined, +): declaration is ts.FunctionLikeDeclaration { + return Boolean( + declaration + && "body" in declaration + && declaration.body, + ); +} + +function callbackDeclarations( + expression: ts.Expression, + checker: ts.TypeChecker, +): ts.FunctionLikeDeclaration[] { + const node = unwrapExpression(expression); + const declarations = new Set(); + if ( + ts.isArrowFunction(node) + || ts.isFunctionExpression(node) + ) { + declarations.add(node); + } + const type = checker.getTypeAtLocation(node); + for ( + const signature of checker.getSignaturesOfType( + type, + ts.SignatureKind.Call, + ) + ) { + if (hasBody(signature.declaration)) { + declarations.add(signature.declaration); + } + } + return [...declarations]; +} + +function isInProgram( + programSourceFiles: ReadonlySet, + node: ts.Node, +): boolean { + return programSourceFiles.has(node.getSourceFile()); +} + +function isAssignmentOperator(kind: ts.SyntaxKind): boolean { + return kind >= ts.SyntaxKind.FirstAssignment + && kind <= ts.SyntaxKind.LastAssignment; +} + +function isSimpleAssignment(node: ts.BinaryExpression): boolean { + return node.operatorToken.kind === ts.SyntaxKind.EqualsToken; +} + +function typedArrayConstructorName(expression: ts.Expression): string | null { + const unwrapped = unwrapExpression(expression); + if (ts.isIdentifier(unwrapped)) return unwrapped.text; + if (ts.isPropertyAccessExpression(unwrapped)) return unwrapped.name.text; + return null; +} + +function isIntrinsicLibDeclaration( + declaration: ts.Declaration, +): boolean { + const sourceFile = declaration.getSourceFile(); + return sourceFile.isDeclarationFile + && /^lib\..*\.d\.ts$/.test(path.basename(sourceFile.fileName)); +} + +function hasIntrinsicLibValueDeclaration( + symbol: ts.Symbol | undefined, +): boolean { + return Boolean( + symbol?.valueDeclaration + && isIntrinsicLibDeclaration(symbol.valueDeclaration), + ); +} + +function isIntrinsicObjectFreezeCall( + call: ts.CallExpression, + checker: ts.TypeChecker, +): boolean { + if (call.arguments.length !== 1) return false; + const callee = unwrapExpression(call.expression); + if (ts.isIdentifier(callee) && callee.text === "intrinsicObjectFreeze") { + return Boolean( + symbolAtExpression(checker, callee)?.declarations?.some((declaration) => + ts.isVariableDeclaration(declaration) + && toPosix(declaration.getSourceFile().fileName) + .endsWith("/host/src/kernel-scratch.ts") + ), + ); + } + if ( + !ts.isPropertyAccessExpression(callee) + || callee.name.text !== "freeze" + ) { + return false; + } + const receiver = unwrapExpression(callee.expression); + const declaration = checker.getResolvedSignature(call)?.declaration; + return ts.isIdentifier(receiver) + && receiver.text === "Object" + && hasIntrinsicLibValueDeclaration(symbolAtExpression(checker, receiver)) + && Boolean( + declaration + && isIntrinsicLibDeclaration(declaration) + && signatureOwnerName(declaration) === "ObjectConstructor", + ); +} + +function intrinsicViewConstructorBits( + expression: ts.Expression, + checker: ts.TypeChecker, +): number { + const name = typedArrayConstructorName(expression); + if ( + name !== "DataView" + && (!name || !TYPED_ARRAY_CONSTRUCTORS.has(name)) + ) { + return 0; + } + const symbol = symbolAtExpression(checker, expression); + if (!hasIntrinsicLibValueDeclaration(symbol)) { + return 0; + } + return name === "DataView" + ? DATA_VIEW_CONSTRUCTOR + : TYPED_ARRAY_CONSTRUCTOR; +} + +function isIntrinsicBufferFrom( + call: ts.CallExpression, + checker: ts.TypeChecker, +): boolean { + const callee = unwrapExpression(call.expression); + if ( + !ts.isPropertyAccessExpression(callee) + || callee.name.text !== "from" + || callee.expression.getText(call.getSourceFile()) !== "Buffer" + ) { + return false; + } + const signatureDeclaration = checker.getResolvedSignature(call)?.declaration; + return Boolean( + signatureDeclaration?.getSourceFile().isDeclarationFile + && signatureOwnerName(signatureDeclaration) === "BufferConstructor", + ); +} + +function intrinsicArrayMethod( + call: ts.CallExpression, + checker: ts.TypeChecker, +): string | null { + const cached = INTRINSIC_ARRAY_METHODS.get(call); + if (cached !== undefined) return cached; + const method = callPropertyName(call); + if ( + !method + || ( + !ARRAY_ELEMENT_RETURNING_METHODS.has(method) + && !ARRAY_ELEMENT_CALLBACK_METHODS.has(method) + ) + ) { + INTRINSIC_ARRAY_METHODS.set(call, null); + return null; + } + const declaration = checker.getResolvedSignature(call)?.declaration; + const owner = signatureOwnerName(declaration); + const result = ( + declaration + && isIntrinsicLibDeclaration(declaration) + && (owner === "Array" || owner === "ReadonlyArray") + ) + ? method + : null; + INTRINSIC_ARRAY_METHODS.set(call, result); + return result; +} + +function intrinsicTypedArrayMethod( + call: ts.CallExpression, + checker: ts.TypeChecker, +): string | null { + const method = callPropertyName(call); + if (!method) return null; + const declaration = checker.getResolvedSignature(call)?.declaration; + const owner = signatureOwnerName(declaration); + return ( + declaration + && isIntrinsicLibDeclaration(declaration) + && owner + && TYPED_ARRAY_CONSTRUCTORS.has(owner) + ) + ? method + : null; +} + +function returnFunction(node: ts.Node): ts.FunctionLikeDeclaration | null { + for (let current: ts.Node | undefined = node.parent; current; current = current.parent) { + if (hasBody(current)) return current; + } + return null; +} + +function isPersistentStoreTarget( + expression: ts.Expression, + checker: ts.TypeChecker, + assignment: ts.Node, +): boolean { + const node = unwrapExpression(expression); + if ( + ts.isPropertyAccessExpression(node) + || ts.isElementAccessExpression(node) + ) { + return true; + } + if (ts.isObjectLiteralExpression(node)) { + return node.properties.some((property) => { + if (ts.isShorthandPropertyAssignment(property)) { + return isPersistentStoreTarget(property.name, checker, assignment); + } + if (ts.isPropertyAssignment(property)) { + return isPersistentStoreTarget( + property.initializer, + checker, + assignment, + ); + } + if (ts.isSpreadAssignment(property)) { + return isPersistentStoreTarget( + property.expression, + checker, + assignment, + ); + } + return false; + }); + } + if (ts.isArrayLiteralExpression(node)) { + return node.elements.some((element) => + !ts.isOmittedExpression(element) + && isPersistentStoreTarget( + ts.isSpreadElement(element) ? element.expression : element, + checker, + assignment, + ) + ); + } + if (!ts.isIdentifier(node)) return false; + const symbol = symbolAtExpression(checker, node); + const assignmentFunction = returnFunction(assignment); + return Boolean( + symbol?.declarations?.some((declaration) => + returnFunction(declaration) !== assignmentFunction + ), + ); +} + +function stateFor( + states: Map, + key: StateKey | undefined, +): ValueState { + return key ? states.get(key) ?? EMPTY_STATE : EMPTY_STATE; +} + +function mergeIntoKey( + states: Map, + key: StateKey, + state: ValueState, + targetProjection: readonly StateProjection[] = [], +): boolean { + let target = states.get(key); + if (!target) { + target = emptyState(); + states.set(key, target); + } + for (const projection of targetProjection) { + if (projection.kind === "element") { + if (!target.elements) target.elements = emptyState(); + target = target.elements; + continue; + } + let property = target.properties.get(projection.name); + if (!property) { + property = emptyState(); + target.properties.set(projection.name, property); + } + target = property; + } + return unionState(target, state); +} + +function hydrateTypeProperties( + state: ValueState, + expression: ts.Expression, + checker: ts.TypeChecker, + states: Map, +): ValueState { + const result = cloneState(state); + const type = checker.getTypeAtLocation(expression); + let properties = TYPE_PROPERTIES.get(type); + if (!properties) { + properties = checker.getPropertiesOfType(type); + TYPE_PROPERTIES.set(type, properties); + } + for (const property of properties) { + const hardPrivate = property.declarations?.some((declaration) => { + const name = (declaration as ts.NamedDeclaration).name; + return Boolean(name && ts.isPrivateIdentifier(name)); + }); + if (hardPrivate) continue; + const hidden = Boolean( + property.declarations?.some((declaration) => + ts.canHaveModifiers(declaration) + && ts.getModifiers(declaration)?.some( + (modifier) => + modifier.kind === ts.SyntaxKind.PrivateKeyword + || modifier.kind === ts.SyntaxKind.ProtectedKeyword, + ) + ), + ); + const propertyValue = cloneState( + stateFor(states, canonicalSymbol(checker, property)), + ); + if (!hasCapability(propertyValue)) continue; + // WHY: private/protected TypeScript slots must remain selectable through + // explicit diagnostic casts, but must not make the whole owning wrapper a + // raw-memory escape. Object spread promotes these ordinary runtime fields. + const target = hidden ? result.hiddenProperties : result.properties; + const existing = target.get(property.name); + if (existing) unionState(existing, propertyValue); + else target.set(property.name, propertyValue); + } + return result; +} + +function propertyIs( + expression: ts.Expression, + expected: string, +): boolean { + const unwrapped = unwrapExpression(expression); + return ( + (ts.isPropertyAccessExpression(unwrapped) + || ts.isElementAccessExpression(unwrapped)) + && accessedPropertyName(unwrapped) === expected + ); +} + +function isJavaScriptKernelMemoryAccessorCall( + node: ts.CallExpression, +): boolean { + const sourceFile = node.getSourceFile(); + if (!/\.(?:c|m)?jsx?$/.test(sourceFile.fileName)) { + return false; + } + // WHY: JavaScript's untyped parameters can erase the receiver type before + // the checker reaches `kernel.getMemory()`. This exact zero-argument method + // is Kandelo's documented raw kernel-memory escape hatch, so seed its result + // syntactically and let the ordinary ownership analysis and exact allowlist + // handle aliases, helper parameters, views, and writes. This is deliberately + // not general JavaScript taint analysis. + return node.arguments.length === 0 && propertyIs(node.expression, "getMemory"); +} + +function isJavaScriptKernelInstanceAccessorCall( + node: ts.CallExpression, +): boolean { + const sourceFile = node.getSourceFile(); + if (!/\.(?:c|m)?jsx?$/.test(sourceFile.fileName)) { + return false; + } + // See getMemory above. JavaScript erases the receiver type, but this exact + // trusted-embedder escape exposes the same kernel memory through + // `getInstance().exports.memory` and must remain visible to the audit. + return node.arguments.length === 0 + && propertyIs(node.expression, "getInstance"); +} + +function isCapturedKernelInstanceExportsCall( + node: ts.CallExpression, +): boolean { + if ( + !toPosix(node.getSourceFile().fileName) + .endsWith("/host/src/kernel-scratch.ts") + ) { + return false; + } + const callee = unwrapExpression(node.expression); + const getter = node.arguments[0] + ? unwrapExpression(node.arguments[0]) + : null; + return ts.isIdentifier(callee) + && callee.text === "intrinsicApply" + && getter !== null + && ts.isIdentifier(getter) + && getter.text === "intrinsicInstanceExports" + && node.arguments.length === 3; +} + +function expressionState( + expression: ts.Expression, + checker: ts.TypeChecker, + states: Map, + programSources: ReadonlySet, +): ValueState { + const node = unwrapExpression(expression); + if (ts.isSpreadElement(node)) { + // A spread call/new argument passes the elements, not the container. + // WHY: dropping this projection lets `opaque(...[kernelView])` hide the + // same live view that `opaque(kernelView)` exposes directly. + return elementState( + expressionState(node.expression, checker, states, programSources), + ); + } + const direct = ts.isIdentifier(node) + && ts.isShorthandPropertyAssignment(node.parent) + && node.parent.name === node + ? canonicalSymbol( + checker, + checker.getShorthandAssignmentValueSymbol(node.parent), + ) + : symbolAtExpression(checker, node); + const directState = cloneState(stateFor(states, direct)); + if (isScratchRegionFactorySymbol(direct)) { + directState.scratchRegionFactory = true; + } + directState.viewConstructors |= intrinsicViewConstructorBits(node, checker); + if ( + (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) + ) { + const property = accessedPropertyName(node); + if (property === "kernel_alloc_scratch") { + directState.allocator = true; + } else if ( + property === "kernel_spawn_scratch_begin" + || property === "kernel_spawn_scratch_pointer" + || property === "kernel_spawn_scratch_capacity" + || property === "kernel_spawn_scratch_cancel" + ) { + directState.reserver = true; + } + } + + if (ts.isConditionalExpression(node)) { + return unionMany([ + directState, + expressionState(node.whenTrue, checker, states, programSources), + expressionState(node.whenFalse, checker, states, programSources), + ]); + } + if (ts.isBinaryExpression(node)) { + if (node.operatorToken.kind === ts.SyntaxKind.CommaToken) { + // The comma expression evaluates to its right operand. + return unionMany([ + directState, + expressionState(node.right, checker, states, programSources), + ]); + } + if ( + node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken + || node.operatorToken.kind === ts.SyntaxKind.BarBarToken + || node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken + ) { + // A logical expression can return either operand without copying it. + return unionMany([ + directState, + expressionState(node.left, checker, states, programSources), + expressionState(node.right, checker, states, programSources), + ]); + } + } + if (ts.isBinaryExpression(node) && isSimpleAssignment(node)) { + return unionMany([ + directState, + expressionState(node.right, checker, states, programSources), + ]); + } + if (ts.isAwaitExpression(node)) { + return unionMany([ + directState, + expressionState(node.expression, checker, states, programSources), + ]); + } + if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) { + return unionMany([directState, stateFor(states, node)]); + } + if (ts.isObjectLiteralExpression(node)) { + const result = cloneState(directState); + for (const property of node.properties) { + if (ts.isPropertyAssignment(property)) { + const name = propertyNameText(property.name); + const value = expressionState( + property.initializer, + checker, + states, + programSources, + ); + if (!hasCapability(value)) continue; + if (!name) { + if (result.elements) unionState(result.elements, value); + else result.elements = cloneState(value); + continue; + } + const existing = result.properties.get(name); + if (existing) unionState(existing, value); + else result.properties.set(name, value); + } else if (ts.isShorthandPropertyAssignment(property)) { + // getSymbolAtLocation(name) denotes the object-literal property. The + // shorthand value symbol is the outer binding that actually carries + // ownership into the new container. + const value = cloneState( + stateFor( + states, + canonicalSymbol( + checker, + checker.getShorthandAssignmentValueSymbol(property), + ), + ), + ); + if (!hasCapability(value)) continue; + const existing = result.properties.get(property.name.text); + if (existing) unionState(existing, value); + else result.properties.set(property.name.text, value); + } else if (ts.isSpreadAssignment(property)) { + const spread = expressionState( + property.expression, + checker, + states, + programSources, + ); + for (const [name, value] of spread.properties) { + const existing = result.properties.get(name); + if (existing) unionState(existing, value); + else result.properties.set(name, cloneState(value)); + } + for (const [name, value] of spread.hiddenProperties) { + const existing = result.properties.get(name); + if (existing) unionState(existing, value); + else result.properties.set(name, cloneState(value)); + } + if (spread.elements) { + if (result.elements) { + unionState(result.elements, spread.elements); + } else { + result.elements = cloneState(spread.elements); + } + } + } else if ( + ts.isMethodDeclaration(property) + || ts.isGetAccessorDeclaration(property) + ) { + const name = propertyNameText(property.name); + if (!name) continue; + const value = cloneState(stateFor(states, property)); + if (!hasCapability(value)) continue; + const existing = result.properties.get(name); + if (existing) unionState(existing, value); + else result.properties.set(name, value); + } + } + return result; + } + if (ts.isArrayLiteralExpression(node)) { + const result = cloneState(directState); + for (const element of node.elements) { + let value: ValueState; + if (ts.isSpreadElement(element)) { + value = elementState( + expressionState(element.expression, checker, states, programSources), + ); + } else if (ts.isOmittedExpression(element)) { + continue; + } else { + value = expressionState(element, checker, states, programSources); + } + if (!hasCapability(value)) continue; + if (result.elements) unionState(result.elements, value); + else result.elements = cloneState(value); + } + return result; + } + if ( + (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) + ) { + const receiver = expressionState( + node.expression, + checker, + states, + programSources, + ); + const property = accessedPropertyName(node); + const numericIndex = ts.isElementAccessExpression(node) + && node.argumentExpression + && ts.isNumericLiteral(node.argumentExpression); + const selected = numericIndex + ? unionMany([ + receiver.elements ?? EMPTY_STATE, + property === null ? EMPTY_STATE : propertyState(receiver, property), + ]) + : property === null + ? elementState(receiver) + : propertyState(receiver, property); + const result = cloneState(directState); + unionState(result, selected); + return result; + } + if (ts.isNewExpression(node)) { + const constructor = expressionState( + node.expression, + checker, + states, + programSources, + ); + if (constructor.viewConstructors !== 0) { + const source = node.arguments?.[0] + ? expressionState(node.arguments[0], checker, states, programSources) + : EMPTY_STATE; + const result = cloneState(directState); + // A TypedArray constructed from another TypedArray copies. A DataView + // or TypedArray constructed from an ArrayBufferLike aliases it. + result.view |= source.buffer; + result.memory = 0; + result.buffer = 0; + result.instance = 0; + result.exportNamespace = 0; + result.properties.clear(); + result.elements = null; + return result; + } + return hydrateTypeProperties(directState, node, checker, states); + } + if (ts.isCallExpression(node)) { + if (isIntrinsicObjectFreezeCall(node, checker)) { + // Object.freeze returns the same object and does not hand it to user + // code. Preserve every nested capability so freezing a private export + // snapshot cannot erase the raw callable before its audited invocation. + return unionMany([ + directState, + expressionState( + node.arguments[0], + checker, + states, + programSources, + ), + ]); + } + if (propertyIs(node.expression, "subarray")) { + const receiver = unwrapExpression(node.expression); + if ( + ts.isPropertyAccessExpression(receiver) + || ts.isElementAccessExpression(receiver) + ) { + const source = expressionState( + receiver.expression, + checker, + states, + programSources, + ); + const result = cloneState(directState); + result.view |= source.view; + result.memory = 0; + result.buffer = 0; + return result; + } + } + if (isIntrinsicBufferFrom(node, checker)) { + const source = node.arguments[0] + ? expressionState(node.arguments[0], checker, states, programSources) + : EMPTY_STATE; + const result = cloneState(directState); + result.view |= source.buffer; + result.memory = 0; + result.buffer = 0; + result.instance = 0; + result.exportNamespace = 0; + return result; + } + const typedArrayMethod = intrinsicTypedArrayMethod(node, checker); + if ( + typedArrayMethod + && TYPED_ARRAY_RETAINING_ITERATOR_METHODS.has(typedArrayMethod) + ) { + const receiver = callReceiver(node); + const result = cloneState(directState); + if (receiver) { + const receiverState = expressionState( + receiver, + checker, + states, + programSources, + ); + // Model the iterator as a retained view capability. It is not itself a + // TypedArray, but keeping the stronger state makes return/store/unknown + // calls fail closed instead of losing the backing view at `.values()`. + result.view |= receiverState.view; + } + return result; + } + const arrayMethod = intrinsicArrayMethod(node, checker); + if ( + arrayMethod + && ( + ARRAY_ELEMENT_RETURNING_METHODS.has(arrayMethod) + || arrayMethod === "filter" + || arrayMethod === "map" + ) + ) { + const receiver = callReceiver(node); + const result = cloneState(directState); + if (receiver) { + const receiverElement = elementState( + expressionState(receiver, checker, states, programSources), + ); + if (ARRAY_ELEMENT_RETURNING_METHODS.has(arrayMethod)) { + unionState(result, receiverElement); + } else if (arrayMethod === "filter") { + if (hasCapability(receiverElement)) { + result.elements = receiverElement; + } + } else if (node.arguments[0]) { + const mappedElement = expressionState( + node.arguments[0], + checker, + states, + programSources, + ); + if (hasCapability(mappedElement)) { + result.elements = mappedElement; + } + } + } + return result; + } + const signature = checker.getResolvedSignature(node); + const declaration = signature?.declaration; + const result = cloneState(directState); + if (directState.scratchRegionFactory) { + // The factory function itself is an audited authority; its return value + // is the nominal provenance witness required before withLease can mint a + // live address capability. + result.scratchRegionFactory = false; + result.scratchRegion = true; + } + if (isJavaScriptKernelMemoryAccessorCall(node)) { + result.memory |= KERNEL_OWNER; + } + if (isJavaScriptKernelInstanceAccessorCall(node)) { + result.instance |= KERNEL_OWNER; + } + if (isCapturedKernelInstanceExportsCall(node) && node.arguments[1]) { + result.exportNamespace |= expressionState( + node.arguments[1], + checker, + states, + programSources, + ).instance; + } + if (propertyIs(node.expression, "slice")) { + const receiver = callReceiver(node); + const owner = signatureOwnerName(declaration); + const provenDetachedTypedArraySlice = Boolean( + declaration + && declaration.getSourceFile().isDeclarationFile + && owner + && TYPED_ARRAY_CONSTRUCTORS.has(owner), + ); + if (receiver && !provenDetachedTypedArraySlice) { + // WHY: Uint8Array#slice copies, but Buffer#slice and arbitrary custom + // methods may alias. Method spelling alone cannot prove detachment. + result.view |= expressionState( + receiver, + checker, + states, + programSources, + ).view; + } + } + if ( + declaration + && isInProgram(programSources, declaration) + && hasBody(declaration) + ) { + unionState(result, stateFor(states, declaration)); + } + const returnedKernelExportFunctions = new Set( + result.kernelExportFunctions, + ); + // Higher-order callbacks retain the return capability in the parameter's + // state. Calling such a parameter yields that capability. + unionState( + result, + expressionState( + node.expression, + checker, + states, + programSources, + ), + ); + if (callPropertyName(node) !== "bind") { + // Calling a raw export returns a scalar; the callable capability itself + // does not flow into that scalar. An analyzed identity/helper return is + // already represented by the declaration state captured above. + result.kernelExportFunctions = returnedKernelExportFunctions; + } + if (result.scratchRegionFactory) { + result.scratchRegionFactory = false; + result.scratchRegion = true; + } + return result; + } + return ts.isIdentifier(node) || node.kind === ts.SyntaxKind.ThisKeyword + ? hydrateTypeProperties(directState, node, checker, states) + : directState; +} + +function assignmentWritesKernelView( + expression: ts.Expression, + checker: ts.TypeChecker, + states: Map, + programSources: ReadonlySet, +): boolean { + const node = unwrapExpression(expression); + if (ts.isElementAccessExpression(node)) { + return isKernelView( + expressionState(node.expression, checker, states, programSources), + ); + } + if (ts.isArrayLiteralExpression(node)) { + return node.elements.some((element) => + !ts.isOmittedExpression(element) + && assignmentWritesKernelView( + ts.isSpreadElement(element) ? element.expression : element, + checker, + states, + programSources, + ) + ); + } + if (ts.isObjectLiteralExpression(node)) { + return node.properties.some((property) => { + if (ts.isPropertyAssignment(property)) { + return assignmentWritesKernelView( + property.initializer, + checker, + states, + programSources, + ); + } + if (ts.isSpreadAssignment(property)) { + return assignmentWritesKernelView( + property.expression, + checker, + states, + programSources, + ); + } + return false; + }); + } + // A default inside an assignment pattern is itself a nested assignment and + // is visited independently, avoiding duplicate findings for one write. + return false; +} + +function findingFor( + rootDir: string, + sourceFile: ts.SourceFile, + node: ts.Node, + kind: AuditFinding["kind"], +): AuditFinding { + const file = relativeFile(rootDir, sourceFile); + const enclosing = callableName(node); + const text = normalizeText(node, sourceFile); + const key = `${file}::${enclosing}::${kind}::${text}`; + const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1; + return { key, file, enclosing, kind, line, text }; +} + +type KernelOwnershipForm = "memory" | "buffer" | "view"; + +function hasKernelOwnership( + state: ValueState, + form: KernelOwnershipForm, +): boolean { + if ((state[form] & KERNEL_OWNER) !== 0) return true; + for (const property of state.properties.values()) { + if (hasKernelOwnership(property, form)) return true; + } + return state.elements + ? hasKernelOwnership(state.elements, form) + : false; +} + +function isKernelView(state: ValueState): boolean { + return (state.view & KERNEL_OWNER) !== 0; +} + +function isKernelBuffer(state: ValueState): boolean { + return (state.buffer & KERNEL_OWNER) !== 0; +} + +function isKernelMemory(state: ValueState): boolean { + return (state.memory & KERNEL_OWNER) !== 0; +} + +function hasPointerBearingKernelExport( + state: ValueState, + pointerBearingKernelExports: ReadonlySet, + seen = new Set(), +): boolean { + if (seen.has(state)) return false; + seen.add(state); + if (state.kernelExportFunctions.has(UNKNOWN_KERNEL_EXPORT)) return true; + for (const name of state.kernelExportFunctions) { + if (pointerBearingKernelExports.has(name)) return true; + } + for (const property of state.properties.values()) { + if ( + hasPointerBearingKernelExport( + property, + pointerBearingKernelExports, + seen, + ) + ) { + return true; + } + } + for (const property of state.hiddenProperties.values()) { + if ( + hasPointerBearingKernelExport( + property, + pointerBearingKernelExports, + seen, + ) + ) { + return true; + } + } + return state.elements + ? hasPointerBearingKernelExport( + state.elements, + pointerBearingKernelExports, + seen, + ) + : false; +} + +function isViewConstructor( + node: ts.Node, + checker: ts.TypeChecker, + states: Map, + programSources: ReadonlySet, +): boolean { + if (ts.isNewExpression(node)) { + return expressionState( + node.expression, + checker, + states, + programSources, + ).viewConstructors !== 0; + } + return ( + ts.isCallExpression(node) + && isIntrinsicBufferFrom(node, checker) + ); +} + +function callPropertyName(call: ts.CallExpression): string | null { + const callee = unwrapExpression(call.expression); + return ( + ts.isPropertyAccessExpression(callee) + || ts.isElementAccessExpression(callee) + ) + ? accessedPropertyName(callee) + : null; +} + +function callReceiver(call: ts.CallExpression): ts.Expression | null { + const callee = unwrapExpression(call.expression); + return ( + ts.isPropertyAccessExpression(callee) + || ts.isElementAccessExpression(callee) + ) + ? callee.expression + : null; +} + +function signatureOwnerName( + declaration: ts.Node | undefined, +): string | undefined { + for (let current = declaration?.parent; current; current = current.parent) { + if ( + (ts.isInterfaceDeclaration(current) || ts.isClassDeclaration(current)) + && current.name + ) { + return current.name.text; + } + } + return undefined; +} + +function isProvenReadOnlyKernelReceiverCall( + call: ts.CallExpression, + receiverState: ValueState, + checker: ts.TypeChecker, +): boolean { + const method = callPropertyName(call); + if (!method) return false; + const declaration = checker.getResolvedSignature(call)?.declaration; + if (!declaration || !isIntrinsicLibDeclaration(declaration)) return false; + const owner = signatureOwnerName(declaration); + + if ( + isKernelView(receiverState) + && owner + && TYPED_ARRAY_CONSTRUCTORS.has(owner) + && TYPED_ARRAY_NON_RETAINING_METHODS.has(method) + ) { + return true; + } + if ( + isKernelView(receiverState) + && owner === "DataView" + && method.startsWith("get") + ) { + return true; + } + if ( + isKernelBuffer(receiverState) + && (owner === "ArrayBuffer" || owner === "SharedArrayBuffer") + && method === "slice" + ) { + return true; + } + return false; +} + +function isKnownReadOnlyKernelViewArgument( + call: ts.CallExpression, + argumentIndex: number, + checker: ts.TypeChecker, +): boolean { + const method = callPropertyName(call); + const signatureDeclaration = checker.getResolvedSignature(call)?.declaration; + const methodOwner = signatureOwnerName(signatureDeclaration); + // WHY: method spelling alone is not a read-only proof. A Map or custom + // object's `set(kernelView)` can retain that live view. Admit only the + // standard typed-array signature whose receiver write consumes arg0 + // synchronously. + if ( + method === "set" + && argumentIndex === 0 + && methodOwner !== undefined + && TYPED_ARRAY_CONSTRUCTORS.has(methodOwner) + && signatureDeclaration !== undefined + && isIntrinsicLibDeclaration(signatureDeclaration) + ) { + return true; + } + // TextDecoder#decode consumes bytes synchronously; a custom `decode` + // method remains an opaque escape. + if ( + method === "decode" + && argumentIndex === 0 + && methodOwner === "TextDecoder" + && signatureDeclaration !== undefined + && isIntrinsicLibDeclaration(signatureDeclaration) + ) { + return true; + } + if ( + argumentIndex === 0 + && ts.isPropertyAccessExpression(call.expression) + && call.expression.expression.getText(call.getSourceFile()) === "Atomics" + && signatureDeclaration !== undefined + && isIntrinsicLibDeclaration(signatureDeclaration) + && hasIntrinsicLibValueDeclaration( + symbolAtExpression(checker, call.expression.expression), + ) + && !ATOMIC_MUTATORS.has(call.expression.name.text) + ) { + return true; + } + return false; +} + +function validateContractEntries( + ownershipSeeds: readonly OwnershipSeed[], + allowances: readonly AuditAllowance[], +): void { + const seedKeys = new Set(); + for (const seed of ownershipSeeds) { + if ( + seed.declaration.includes("*") + || seed.declaration.includes("?") + || seed.declaration.endsWith("::") + ) { + throw new Error(`ownership seed must be exact: ${seed.declaration}`); + } + if (seed.why.trim().length < 12) { + throw new Error(`ownership seed requires a WHY: ${seed.declaration}`); + } + const key = `${seed.declaration}::${seed.target}::${seed.owner}::${seed.form}`; + if (seedKeys.has(key)) throw new Error(`duplicate ownership seed: ${key}`); + seedKeys.add(key); + } + const allowanceKeys = new Set(); + for (const allowance of allowances) { + if (allowance.key.includes("*") || allowance.key.includes("?")) { + throw new Error(`audit allowance must be exact: ${allowance.key}`); + } + if (allowance.why.trim().length < 12) { + throw new Error(`audit allowance requires a WHY: ${allowance.key}`); + } + if ( + allowance.count !== undefined + && (!Number.isSafeInteger(allowance.count) || allowance.count <= 0) + ) { + throw new Error(`audit allowance count must be positive: ${allowance.key}`); + } + if (allowanceKeys.has(allowance.key)) { + throw new Error(`duplicate audit allowance: ${allowance.key}`); + } + allowanceKeys.add(allowance.key); + } +} + +export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { + const allowances = options.allowances ?? []; + validateContractEntries(options.ownershipSeeds, allowances); + const program = createProgram(options); + const checker = program.getTypeChecker(); + const requestedFiles = new Set( + options.sourceFiles.map((fileName) => path.resolve(fileName)), + ); + const sourceFiles = program.getSourceFiles().filter( + (sourceFile) => + requestedFiles.has(path.resolve(sourceFile.fileName)) + && !sourceFile.isDeclarationFile, + ); + const programSources = new Set(sourceFiles); + const kernelScratchExportContract = + kernelScratchPointerExportContract(sourceFiles); + const pointerBearingKernelExports = kernelScratchExportContract.names; + const states = new Map(); + const constraints: Constraint[] = []; + const declarationTargets = new Map(); + const seededReturnStates = new Map(); + const seededValueStates = new Map(); + const leaseOriginCallbacks = new Map< + ts.Symbol, + ts.FunctionLikeDeclaration + >(); + const leaseCallbackCalls = new Map< + ts.FunctionLikeDeclaration, + ts.CallExpression + >(); + const inlineScratchLeaseCallback = ( + call: ts.CallExpression, + ): ts.FunctionLikeDeclaration | null => { + if (!isKernelScratchWithLeaseCall(call, checker) || !call.arguments[0]) { + return null; + } + const callback = unwrapExpression(call.arguments[0]); + if ( + !ts.isArrowFunction(callback) + || callback.asteriskToken + || ( + ts.canHaveModifiers(callback) + && ts.getModifiers(callback)?.some( + (modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword, + ) + ) + || !callback.parameters[0] + || !ts.isIdentifier(callback.parameters[0].name) + ) { + return null; + } + return callback; + }; + + const addDeclarationTarget = ( + sourceFile: ts.SourceFile, + declaration: ts.Declaration, + target: DeclarationTarget, + ): void => { + const key = declarationKey(options.rootDir, sourceFile, declaration); + if (!key) return; + const existing = declarationTargets.get(key) ?? {}; + if (target.value) existing.value = target.value; + if (target.returns) existing.returns = target.returns; + declarationTargets.set(key, existing); + }; + + const addBindingConstraints = ( + name: ts.BindingName, + expression: ts.Expression, + projection: readonly StateProjection[] = [], + ): void => { + if (ts.isIdentifier(name)) { + const target = canonicalSymbol( + checker, + checker.getSymbolAtLocation(name), + ); + if (target) { + constraints.push({ + target, + expression, + projection, + }); + } + return; + } + if (ts.isObjectBindingPattern(name)) { + for (const element of name.elements) { + const property = propertyNameText(element.propertyName) + ?? (ts.isIdentifier(element.name) ? element.name.text : null); + const nextProjection: StateProjection = element.dotDotDotToken + || property === null + ? { kind: "element" } + : { kind: "property", name: property }; + addBindingConstraints( + element.name, + expression, + [...projection, nextProjection], + ); + if (element.initializer) { + addBindingConstraints( + element.name, + element.initializer, + [], + ); + } + } + return; + } + for (const element of name.elements) { + if (ts.isOmittedExpression(element)) continue; + addBindingConstraints( + element.name, + expression, + [...projection, { kind: "element" }], + ); + if (element.initializer) { + addBindingConstraints( + element.name, + element.initializer, + [], + ); + } + } + }; + const addAssignmentConstraints = ( + targetExpression: ts.Expression, + sourceExpression: ts.Expression, + projection: readonly StateProjection[] = [], + ): void => { + const targetNode = unwrapExpression(targetExpression); + if ( + ts.isElementAccessExpression(targetNode) + && accessedPropertyName(targetNode) === null + ) { + const target = symbolAtExpression( + checker, + unwrapExpression(targetNode.expression), + ); + if (target) { + constraints.push({ + target, + expression: sourceExpression, + projection, + targetProjection: [{ kind: "element" }], + }); + return; + } + } + if ( + ts.isIdentifier(targetNode) + || ts.isPropertyAccessExpression(targetNode) + || ts.isElementAccessExpression(targetNode) + ) { + const target = symbolAtExpression(checker, targetNode); + if (target) { + constraints.push({ + target, + expression: sourceExpression, + projection, + }); + } + return; + } + if (ts.isObjectLiteralExpression(targetNode)) { + for (const property of targetNode.properties) { + if (ts.isShorthandPropertyAssignment(property)) { + addAssignmentConstraints( + property.name, + sourceExpression, + [ + ...projection, + { kind: "property", name: property.name.text }, + ], + ); + if (property.objectAssignmentInitializer) { + addAssignmentConstraints( + property.name, + property.objectAssignmentInitializer, + ); + } + } else if (ts.isPropertyAssignment(property)) { + const name = propertyNameText(property.name); + addAssignmentConstraints( + property.initializer, + sourceExpression, + [ + ...projection, + name === null + ? { kind: "element" } + : { kind: "property", name }, + ], + ); + } else if (ts.isSpreadAssignment(property)) { + addAssignmentConstraints( + property.expression, + sourceExpression, + [...projection, { kind: "element" }], + ); + } + } + return; + } + if (ts.isArrayLiteralExpression(targetNode)) { + for (const element of targetNode.elements) { + if (ts.isOmittedExpression(element)) continue; + addAssignmentConstraints( + ts.isSpreadElement(element) ? element.expression : element, + sourceExpression, + [...projection, { kind: "element" }], + ); + } + } + }; + for (const sourceFile of sourceFiles) { + const visit = (node: ts.Node): void => { + if ( + ts.isVariableDeclaration(node) + || ts.isPropertyDeclaration(node) + || ts.isPropertySignature(node) + || ts.isParameter(node) + ) { + const symbol = symbolForDeclaration(checker, node); + if (symbol) addDeclarationTarget(sourceFile, node, { value: symbol }); + } + if ( + ts.isFunctionDeclaration(node) + || ts.isMethodDeclaration(node) + || ts.isGetAccessorDeclaration(node) + || ts.isSetAccessorDeclaration(node) + ) { + const symbol = symbolForDeclaration(checker, node); + addDeclarationTarget(sourceFile, node, { + value: symbol, + returns: node, + }); + } + + if ( + (ts.isVariableDeclaration(node) || ts.isPropertyDeclaration(node)) + && node.initializer + ) { + if (ts.isVariableDeclaration(node)) { + addBindingConstraints(node.name, node.initializer); + } else { + const target = symbolForDeclaration(checker, node); + if (target) { + constraints.push({ target, expression: node.initializer }); + } + } + } else if ( + ts.isBinaryExpression(node) + && isSimpleAssignment(node) + ) { + addAssignmentConstraints(node.left, node.right); + } else if (ts.isReturnStatement(node) && node.expression) { + const fn = returnFunction(node); + if (fn) { + constraints.push({ target: fn, expression: node.expression }); + if (ts.isGetAccessorDeclaration(fn)) { + const target = symbolForDeclaration(checker, fn); + if (target) { + constraints.push({ target, expression: node.expression }); + } + } + } + } else if ( + ts.isArrowFunction(node) + && !ts.isBlock(node.body) + ) { + constraints.push({ target: node, expression: node.body }); + } + + if (ts.isCallExpression(node) || ts.isNewExpression(node)) { + const signature = checker.getResolvedSignature(node); + const declaration = signature?.declaration; + if ( + declaration + && isInProgram(programSources, declaration) + && hasBody(declaration) + ) { + const parameters = declaration.parameters; + const args = node.arguments ?? []; + for (let index = 0; index < args.length; index++) { + const parameter = parameters[Math.min(index, parameters.length - 1)]; + if (!parameter) continue; + addBindingConstraints(parameter.name, args[index]); + } + } + } + if (ts.isCallExpression(node)) { + const leaseCallback = inlineScratchLeaseCallback(node); + if (leaseCallback) { + const parameter = leaseCallback.parameters[0]; + const symbol = symbolAtExpression( + checker, + parameter.name as ts.Identifier, + ); + if (symbol) { + leaseOriginCallbacks.set(symbol, leaseCallback); + leaseCallbackCalls.set(leaseCallback, node); + } + } + const method = intrinsicArrayMethod(node, checker) + ?? intrinsicTypedArrayMethod(node, checker); + const receiver = method ? callReceiver(node) : null; + const callback = node.arguments[0]; + const containerParameterIndex = method + ? CONTAINER_CALLBACK_PARAMETER_INDEX.get(method) + : undefined; + if ( + method + && receiver + && callback + && containerParameterIndex !== undefined + ) { + for (const declaration of callbackDeclarations(callback, checker)) { + if (!isInProgram(programSources, declaration)) continue; + const elementParameter = declaration.parameters[0]; + if (elementParameter) { + addBindingConstraints( + elementParameter.name, + receiver, + [{ kind: "element" }], + ); + } + const containerParameter = + declaration.parameters[containerParameterIndex]; + if (containerParameter) { + addBindingConstraints( + containerParameter.name, + receiver, + [], + ); + } + } + } + } + if (ts.isForOfStatement(node)) { + const projection: readonly StateProjection[] = [{ kind: "element" }]; + if (ts.isVariableDeclarationList(node.initializer)) { + for (const declaration of node.initializer.declarations) { + addBindingConstraints( + declaration.name, + node.expression, + projection, + ); + } + } else { + addAssignmentConstraints( + node.initializer, + node.expression, + projection, + ); + } + } + if (ts.isParameter(node) && ts.isIdentifier(node.name)) { + const property = parameterPropertySymbol(checker, node); + if (property) { + constraints.push({ target: property, expression: node.name }); + } + } + if (ts.isParameter(node) && node.initializer) { + addBindingConstraints(node.name, node.initializer); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + } + + const unresolvedSeeds: OwnershipSeed[] = []; + for (const seed of options.ownershipSeeds) { + const target = declarationTargets.get(seed.declaration); + const key = seed.target === "return" ? target?.returns : target?.value; + if (!key) { + unresolvedSeeds.push(seed); + continue; + } + mergeIntoKey(states, key, ownerState(seed.owner, seed.form)); + if (seed.target === "return" && target?.returns) { + mergeIntoKey( + seededReturnStates, + target.returns, + ownerState(seed.owner, seed.form), + ); + } else if (seed.target === "value" && target?.value) { + mergeIntoKey( + seededValueStates, + target.value, + ownerState(seed.owner, seed.form), + ); + } + } + + // Alias/argument/return propagation reaches a fixed point over the complete + // source set. This is what makes a new helper file or a renamed local alias + // visible to the ownership contract. + let changed = true; + for (let pass = 0; changed && pass < constraints.length + 32; pass++) { + changed = false; + for (const constraint of constraints) { + const state = expressionState( + constraint.expression, + checker, + states, + programSources, + ); + const projected = projectState(state, constraint.projection); + changed = mergeIntoKey( + states, + constraint.target, + projected, + constraint.targetProjection, + ) || changed; + } + } + + const isIntrinsicWebAssemblyInstantiate = ( + expression: ts.Expression, + ): boolean => { + let node = unwrapExpression(expression); + if (ts.isAwaitExpression(node)) node = unwrapExpression(node.expression); + if (!ts.isCallExpression(node)) return false; + const callee = unwrapExpression(node.expression); + if ( + !ts.isPropertyAccessExpression(callee) + || callee.name.text !== "instantiate" + ) { + return false; + } + const receiver = unwrapExpression(callee.expression); + return ts.isIdentifier(receiver) + && receiver.text === "WebAssembly" + && hasIntrinsicLibValueDeclaration( + symbolAtExpression(checker, receiver), + ); + }; + const isNullishSeedInitializer = ( + expression: ts.Expression, + ): boolean => { + const node = unwrapExpression(expression); + return node.kind === ts.SyntaxKind.NullKeyword + || ( + ts.isIdentifier(node) + && node.text === "undefined" + && hasIntrinsicLibValueDeclaration( + symbolAtExpression(checker, node), + ) + ); + }; + const immutableConstInitializer = ( + expression: ts.Expression, + ): ts.Expression | null => { + const node = unwrapExpression(expression); + if (!ts.isIdentifier(node)) return null; + const symbol = canonicalSymbol(checker, checker.getSymbolAtLocation(node)); + const declaration = symbol?.valueDeclaration; + if ( + !declaration + || !ts.isVariableDeclaration(declaration) + || !ts.isIdentifier(declaration.name) + || !declaration.initializer + || !ts.isVariableDeclarationList(declaration.parent) + || (declaration.parent.flags & ts.NodeFlags.Const) === 0 + ) { + return null; + } + return declaration.initializer; + }; + const invalidSeedAssignmentExpressions = new Set(); + const invalidSeededScratchRegions = new Set(); + const constraintsByTarget = new Map(); + for (const constraint of constraints) { + if ((constraint.targetProjection?.length ?? 0) !== 0) continue; + const existing = constraintsByTarget.get(constraint.target); + if (existing) existing.push(constraint); + else constraintsByTarget.set(constraint.target, [constraint]); + } + const declaredPropertySymbol = ( + type: ts.Type, + name: string, + ): ts.Symbol | undefined => { + const property = checker.getPropertyOfType(type, name); + for (const declaration of property?.declarations ?? []) { + const declared = symbolForDeclaration(checker, declaration); + if (declared) return declared; + } + return canonicalSymbol(checker, property); + }; + const isSeededScratchRegionKey = ( + key: StateKey | undefined, + ): boolean => + Boolean(key && stateFor(seededValueStates, key).scratchRegion); + const isDirectScratchRegionFactoryCall = ( + expression: ts.Expression, + ): boolean => { + const node = unwrapExpression(expression); + return ts.isCallExpression(node) + && isScratchRegionFactorySymbol( + symbolAtExpression(checker, node.expression), + ); + }; + const SCRATCH_ORIGIN_UNSAFE = 0; + const SCRATCH_ORIGIN_EMPTY = 1; + const SCRATCH_ORIGIN_EXACT = 2; + type ScratchOriginProof = + | typeof SCRATCH_ORIGIN_UNSAFE + | typeof SCRATCH_ORIGIN_EMPTY + | typeof SCRATCH_ORIGIN_EXACT; + const combineScratchOriginProofs = ( + proofs: readonly ScratchOriginProof[], + ): ScratchOriginProof => { + if ( + proofs.length === 0 + || proofs.some((proof) => proof === SCRATCH_ORIGIN_UNSAFE) + ) { + return SCRATCH_ORIGIN_UNSAFE; + } + return proofs.some((proof) => proof === SCRATCH_ORIGIN_EXACT) + ? SCRATCH_ORIGIN_EXACT + : SCRATCH_ORIGIN_EMPTY; + }; + const scratchOriginSymbolAtExpression = ( + expression: ts.Expression, + ): ts.Symbol | undefined => { + const node = unwrapExpression(expression); + if ( + ts.isIdentifier(node) + && ts.isShorthandPropertyAssignment(node.parent) + && node.parent.name === node + ) { + return canonicalSymbol( + checker, + checker.getShorthandAssignmentValueSymbol(node.parent), + ); + } + return symbolAtExpression(checker, node); + }; + const isExactMethodReceiver = ( + expression: ts.Expression, + method: ts.MethodDeclaration, + seen = new Set(), + ): boolean => { + const node = unwrapExpression(expression); + if (ts.isConditionalExpression(node)) { + return isExactMethodReceiver(node.whenTrue, method, new Set(seen)) + && isExactMethodReceiver(node.whenFalse, method, new Set(seen)); + } + if ( + ts.isBinaryExpression(node) + && node.operatorToken.kind === ts.SyntaxKind.CommaToken + ) { + return isExactMethodReceiver(node.right, method, seen); + } + if ( + ts.isBinaryExpression(node) + && ( + node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken + || node.operatorToken.kind === ts.SyntaxKind.BarBarToken + || node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken + ) + ) { + return isExactMethodReceiver(node.left, method, new Set(seen)) + && isExactMethodReceiver(node.right, method, new Set(seen)); + } + if ( + node.kind === ts.SyntaxKind.ThisKeyword + || ts.isNewExpression(node) + ) { + if (ts.isNewExpression(node)) { + const constructor = unwrapExpression(node.expression); + if ( + ts.isIdentifier(constructor) + && constructor.text === "Proxy" + && hasIntrinsicLibValueDeclaration( + symbolAtExpression(checker, constructor), + ) + ) { + return false; + } + } + const methodName = propertyNameText(method.name); + if (!methodName) return false; + const expected = symbolForDeclaration(checker, method); + const actual = declaredPropertySymbol( + checker.getTypeAtLocation(node), + methodName, + ); + return Boolean(expected && actual === expected); + } + if (ts.isCallExpression(node)) { + const declaration = checker.getResolvedSignature(node)?.declaration; + if ( + !declaration + || !isInProgram(programSources, declaration) + || !hasBody(declaration) + || seen.has(declaration) + ) { + return false; + } + if (ts.isMethodDeclaration(declaration)) { + const receiver = callReceiver(node); + if ( + !receiver + || !isExactMethodReceiver( + receiver, + declaration, + new Set(seen), + ) + ) { + return false; + } + } + const writes = constraintsByTarget.get(declaration) ?? []; + if (writes.length === 0) return false; + const nextSeen = new Set(seen); + nextSeen.add(declaration); + return writes.every( + (constraint) => + (constraint.projection?.length ?? 0) === 0 + && isExactMethodReceiver( + constraint.expression, + method, + new Set(nextSeen), + ), + ); + } + const symbol = scratchOriginSymbolAtExpression(node); + if (!symbol || seen.has(symbol)) return false; + const writes = constraintsByTarget.get(symbol) ?? []; + if (writes.length === 0) return false; + const nextSeen = new Set(seen); + nextSeen.add(symbol); + return writes.every( + (constraint) => + (constraint.projection?.length ?? 0) === 0 + && isExactMethodReceiver( + constraint.expression, + method, + new Set(nextSeen), + ), + ); + }; + const proveExactScratchRegionOrigin = ( + expression: ts.Expression, + projection: readonly StateProjection[] = [], + seen = new Set(), + ): ScratchOriginProof => { + const node = unwrapExpression(expression); + if (isNullishSeedInitializer(node)) return SCRATCH_ORIGIN_EMPTY; + if (isDirectScratchRegionFactoryCall(node)) { + return projection.length === 0 + ? SCRATCH_ORIGIN_EXACT + : SCRATCH_ORIGIN_UNSAFE; + } + if (ts.isConditionalExpression(node)) { + return combineScratchOriginProofs([ + proveExactScratchRegionOrigin( + node.whenTrue, + projection, + new Set(seen), + ), + proveExactScratchRegionOrigin( + node.whenFalse, + projection, + new Set(seen), + ), + ]); + } + if ( + ts.isBinaryExpression(node) + && node.operatorToken.kind === ts.SyntaxKind.CommaToken + ) { + return proveExactScratchRegionOrigin(node.right, projection, seen); + } + if ( + ts.isBinaryExpression(node) + && ( + node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken + || node.operatorToken.kind === ts.SyntaxKind.BarBarToken + || node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken + ) + ) { + return combineScratchOriginProofs([ + proveExactScratchRegionOrigin( + node.left, + projection, + new Set(seen), + ), + proveExactScratchRegionOrigin( + node.right, + projection, + new Set(seen), + ), + ]); + } + if (projection.length > 0 && ts.isObjectLiteralExpression(node)) { + const [head, ...tail] = projection; + if (head.kind !== "property") return SCRATCH_ORIGIN_UNSAFE; + const values: ts.Expression[] = []; + for (const property of node.properties) { + if ( + ts.isPropertyAssignment(property) + && propertyNameText(property.name) === head.name + ) { + values.push(property.initializer); + } else if ( + ts.isShorthandPropertyAssignment(property) + && property.name.text === head.name + ) { + values.push(property.name); + } else if ( + ts.isMethodDeclaration(property) + || ts.isGetAccessorDeclaration(property) + || ts.isSpreadAssignment(property) + ) { + // A getter or spread can compute/replace the projected property at + // runtime. Do not infer provenance from its structural type. + return SCRATCH_ORIGIN_UNSAFE; + } + } + if (values.length === 0) return SCRATCH_ORIGIN_EMPTY; + return combineScratchOriginProofs( + values.map((value) => + proveExactScratchRegionOrigin(value, tail, new Set(seen)) + ), + ); + } + if ( + projection.length > 0 + && ( + node.kind === ts.SyntaxKind.ThisKeyword + || ts.isNewExpression(node) + ) + ) { + if (ts.isNewExpression(node)) { + const constructor = unwrapExpression(node.expression); + if ( + ts.isIdentifier(constructor) + && constructor.text === "Proxy" + && hasIntrinsicLibValueDeclaration( + symbolAtExpression(checker, constructor), + ) + ) { + return SCRATCH_ORIGIN_UNSAFE; + } + } + const [head, ...tail] = projection; + if (head.kind !== "property") return SCRATCH_ORIGIN_UNSAFE; + const property = declaredPropertySymbol( + checker.getTypeAtLocation(node), + head.name, + ); + if (!property || invalidSeededScratchRegions.has(property)) { + return SCRATCH_ORIGIN_UNSAFE; + } + if ( + tail.length === 0 + && isSeededScratchRegionKey(property) + ) { + return SCRATCH_ORIGIN_EXACT; + } + const writes = constraintsByTarget.get(property) ?? []; + if (writes.length === 0) return SCRATCH_ORIGIN_UNSAFE; + const nextSeen = new Set(seen); + nextSeen.add(property); + return combineScratchOriginProofs( + writes.map((constraint) => + proveExactScratchRegionOrigin( + constraint.expression, + [...(constraint.projection ?? []), ...tail], + new Set(nextSeen), + ) + ), + ); + } + if ( + ts.isPropertyAccessExpression(node) + || ts.isElementAccessExpression(node) + ) { + const property = accessedPropertyName(node); + if (property === null) return SCRATCH_ORIGIN_UNSAFE; + return proveExactScratchRegionOrigin( + node.expression, + [{ kind: "property", name: property }, ...projection], + seen, + ); + } + if (ts.isCallExpression(node)) { + const declaration = checker.getResolvedSignature(node)?.declaration; + if ( + declaration + && isInProgram(programSources, declaration) + && hasBody(declaration) + && !seen.has(declaration) + ) { + if (ts.isMethodDeclaration(declaration)) { + const receiver = callReceiver(node); + if ( + !receiver + || !isExactMethodReceiver(receiver, declaration) + ) { + return SCRATCH_ORIGIN_UNSAFE; + } + } + if (invalidSeededScratchRegions.has(declaration)) { + return SCRATCH_ORIGIN_UNSAFE; + } + const writes = constraintsByTarget.get(declaration) ?? []; + if (writes.length === 0) return SCRATCH_ORIGIN_UNSAFE; + const nextSeen = new Set(seen); + nextSeen.add(declaration); + return combineScratchOriginProofs( + writes.map((constraint) => + proveExactScratchRegionOrigin( + constraint.expression, + [...(constraint.projection ?? []), ...projection], + new Set(nextSeen), + ) + ), + ); + } + } + const symbol = scratchOriginSymbolAtExpression(node); + if ( + projection.length === 0 + && symbol + && isSeededScratchRegionKey(symbol) + && !invalidSeededScratchRegions.has(symbol) + ) { + return SCRATCH_ORIGIN_EXACT; + } + if (!symbol || seen.has(symbol)) return SCRATCH_ORIGIN_UNSAFE; + const writes = constraintsByTarget.get(symbol) ?? []; + if (writes.length === 0) return SCRATCH_ORIGIN_UNSAFE; + const nextSeen = new Set(seen); + nextSeen.add(symbol); + // WHY: region provenance is a must-property. Every value ever written to + // a field/local/helper return must be either nullish or independently + // derived from the reviewed allocator. One fake, projected container + // value, or unresolved helper poisons the origin instead of being hidden + // by the general ownership lattice's may-taint. + return combineScratchOriginProofs( + writes.map((constraint) => + proveExactScratchRegionOrigin( + constraint.expression, + [...(constraint.projection ?? []), ...projection], + new Set(nextSeen), + ) + ), + ); + }; + const isExactScratchRegionOrigin = ( + expression: ts.Expression, + ): boolean => + proveExactScratchRegionOrigin(expression) === SCRATCH_ORIGIN_EXACT; + + for (const constraint of constraints) { + const seeded = stateFor(seededValueStates, constraint.target); + const source = projectState( + expressionState( + constraint.expression, + checker, + states, + programSources, + ), + constraint.projection, + ); + if ( + (seeded.instance & KERNEL_OWNER) !== 0 + && (source.instance & KERNEL_OWNER) === 0 + && !isNullishSeedInitializer(constraint.expression) + && !isIntrinsicWebAssemblyInstantiate(constraint.expression) + ) { + invalidSeedAssignmentExpressions.add(constraint.expression); + } + } + + // Scratch-region trust is a must-provenance property. The general ownership + // lattice intentionally records possible capability flow, but a conditional, + // mutable alias, helper return, or container can combine a real factory value + // with a structural fake. Only an exact seed or direct factory result, plus + // immutable const aliases, can mint a lease callback. + let invalidScratchSeedChanged = true; + while (invalidScratchSeedChanged) { + invalidScratchSeedChanged = false; + for (const constraint of constraints) { + if ( + !isSeededScratchRegionKey(constraint.target) + || isNullishSeedInitializer(constraint.expression) + || ( + (constraint.projection?.length ?? 0) === 0 + && isExactScratchRegionOrigin(constraint.expression) + ) + ) { + continue; + } + invalidSeedAssignmentExpressions.add(constraint.expression); + if (!invalidSeededScratchRegions.has(constraint.target)) { + invalidSeededScratchRegions.add(constraint.target); + invalidScratchSeedChanged = true; + } + } + } + + const reflectedSeedMutationCalls = new Set(); + type ReflectiveScratchMutation = + | "assign" + | "defineProperties" + | "defineProperty" + | "set" + | "setPrototypeOf"; + const REFLECTIVE_SCRATCH_MUTATIONS = new Set([ + "assign", + "defineProperties", + "defineProperty", + "set", + "setPrototypeOf", + ]); + const reflectiveMutationFromDeclaration = ( + declaration: ts.Declaration | undefined, + ): ReflectiveScratchMutation | null => { + if (!declaration || !isIntrinsicLibDeclaration(declaration)) return null; + const declarationProperty = (declaration as ts.NamedDeclaration).name; + const name = declarationProperty + && ( + ts.isIdentifier(declarationProperty) + || ts.isStringLiteralLike(declarationProperty) + || ts.isNumericLiteral(declarationProperty) + ) + ? declarationProperty.text + : null; + if ( + !name + || !REFLECTIVE_SCRATCH_MUTATIONS.has( + name as ReflectiveScratchMutation, + ) + ) { + return null; + } + let owner = signatureOwnerName(declaration); + if (!owner) { + for ( + let current: ts.Node | undefined = declaration.parent; + current; + current = current.parent + ) { + if ( + ts.isModuleDeclaration(current) + && ts.isIdentifier(current.name) + ) { + owner = current.name.text; + break; + } + } + } + if ( + (owner === "ObjectConstructor" + && ( + name === "assign" + || name === "defineProperties" + || name === "defineProperty" + || name === "setPrototypeOf" + )) + || ( + owner === "Reflect" + && (name === "set" || name === "setPrototypeOf") + ) + ) { + return name as ReflectiveScratchMutation; + } + return null; + }; + const reflectiveMutationIdentity = ( + expression: ts.Expression, + seen = new Set(), + ): ReflectiveScratchMutation | null => { + const node = unwrapExpression(expression); + if (ts.isCallExpression(node) && callPropertyName(node) === "bind") { + const receiver = callReceiver(node); + return receiver + ? reflectiveMutationIdentity(receiver, seen) + : null; + } + const signatures = checker.getSignaturesOfType( + checker.getTypeAtLocation(node), + ts.SignatureKind.Call, + ); + for (const signature of signatures) { + const mutation = reflectiveMutationFromDeclaration( + signature.declaration, + ); + if (mutation) return mutation; + } + const symbol = scratchOriginSymbolAtExpression(node); + if (!symbol || seen.has(symbol)) return null; + const declaration = symbol.valueDeclaration; + if ( + declaration + && ts.isVariableDeclaration(declaration) + && declaration.initializer + ) { + const nextSeen = new Set(seen); + nextSeen.add(symbol); + return reflectiveMutationIdentity( + declaration.initializer, + nextSeen, + ); + } + return null; + }; + const reflectiveMutationInvocation = ( + call: ts.CallExpression, + ): { + readonly mutation: ReflectiveScratchMutation; + readonly args: readonly ts.Expression[]; + } | null => { + const callee = unwrapExpression(call.expression); + if ( + (ts.isPropertyAccessExpression(callee) + || ts.isElementAccessExpression(callee)) + && ( + accessedPropertyName(callee) === "call" + || accessedPropertyName(callee) === "apply" + ) + ) { + const mutation = reflectiveMutationIdentity(callee.expression); + if (!mutation) return null; + if (accessedPropertyName(callee) === "call") { + return { mutation, args: call.arguments.slice(1) }; + } + const applied = call.arguments[1] + ? unwrapExpression(call.arguments[1]) + : null; + return applied && ts.isArrayLiteralExpression(applied) + ? { + mutation, + args: applied.elements.filter( + (element): element is ts.Expression => + !ts.isOmittedExpression(element) + && !ts.isSpreadElement(element), + ), + } + : null; + } + const mutation = reflectiveMutationIdentity(callee); + return mutation ? { mutation, args: call.arguments } : null; + }; + const trackedScratchProperties = ( + expression: ts.Expression, + ): ts.Symbol[] => { + const type = checker.getTypeAtLocation(unwrapExpression(expression)); + return checker.getPropertiesOfType(type) + .map( + (property) => + declaredPropertySymbol(type, property.name) + ?? canonicalSymbol(checker, property) + ?? property, + ) + .filter( + (symbol) => + isSeededScratchRegionKey(symbol) + || stateFor(states, symbol).scratchRegion + || Boolean( + symbol.declarations?.some( + (declaration) => + hasBody(declaration) + && stateFor(states, declaration).scratchRegion, + ), + ), + ); + }; + const markReflectedSeedMutation = ( + call: ts.CallExpression, + target: ts.Expression | undefined, + property: string | null, + ): void => { + if (!target) return; + const seeded = trackedScratchProperties(target); + const matches = property === null + ? seeded + : seeded.filter((symbol) => symbol.name === property); + if (matches.length === 0) return; + reflectedSeedMutationCalls.add(call); + for (const symbol of matches) { + invalidSeededScratchRegions.add(symbol); + for (const declaration of symbol.declarations ?? []) { + if (hasBody(declaration)) { + invalidSeededScratchRegions.add(declaration); + } + } + } + }; + for (const sourceFile of sourceFiles) { + const findReflectedSeedMutations = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const invocation = reflectiveMutationInvocation(node); + const target = invocation?.args[0]; + if (invocation && target) { + if ( + invocation.mutation === "defineProperty" + || invocation.mutation === "set" + ) { + const propertyArgument = invocation.args[1]; + const property = propertyArgument + && ( + ts.isStringLiteralLike(propertyArgument) + || ts.isNumericLiteral(propertyArgument) + ) + ? propertyArgument.text + : null; + markReflectedSeedMutation(node, target, property); + } else if (invocation.mutation === "setPrototypeOf") { + markReflectedSeedMutation(node, target, null); + } else { + for (const source of invocation.args.slice(1)) { + const value = unwrapExpression(source); + if (!ts.isObjectLiteralExpression(value)) { + markReflectedSeedMutation(node, target, null); + continue; + } + for (const entry of value.properties) { + const property = propertyNameText(entry.name); + markReflectedSeedMutation(node, target, property); + } + } + } + } + } + ts.forEachChild(node, findReflectedSeedMutations); + }; + findReflectedSeedMutations(sourceFile); + } + + for (const [origin, callback] of leaseOriginCallbacks) { + const call = leaseCallbackCalls.get(callback); + const receiver = call ? callReceiver(call) : null; + if ( + !receiver + || !isExactScratchRegionOrigin(receiver) + ) { + leaseOriginCallbacks.delete(origin); + } + } + const activeLeaseCallbacks = new Set(leaseOriginCallbacks.values()); + + const leaseOriginSymbol = ( + expression: ts.Expression, + seen = new Set(), + ): ts.Symbol | undefined => { + const node = unwrapExpression(expression); + const symbol = symbolAtExpression(checker, node); + if (!symbol) return undefined; + if (ts.isIdentifier(node) && !seen.has(symbol)) { + const initializer = immutableConstInitializer(node); + if (initializer) { + seen.add(symbol); + return leaseOriginSymbol(initializer, seen); + } + } + return symbol; + }; + const enclosingFunction = ( + node: ts.Node, + ): ts.FunctionLikeDeclaration | null => { + for ( + let current: ts.Node | undefined = node.parent; + current; + current = current.parent + ) { + if (hasBody(current)) return current; + } + return null; + }; + const isTransparentScratchUseWrapper = ( + parent: ts.Node, + child: ts.Node, + ): parent is + | ts.ParenthesizedExpression + | ts.AsExpression + | ts.TypeAssertion + | ts.NonNullExpression + | ts.SatisfiesExpression => + ( + ts.isParenthesizedExpression(parent) + || ts.isAsExpression(parent) + || ts.isTypeAssertionExpression(parent) + || ts.isNonNullExpression(parent) + || ts.isSatisfiesExpression(parent) + ) + && parent.expression === child; + const directCallForMember = ( + member: ts.Expression, + ): ts.CallExpression | null => { + let value: ts.Expression = member; + while ( + value.parent + && isTransparentScratchUseWrapper(value.parent, value) + ) { + value = value.parent; + } + return value.parent + && ts.isCallExpression(value.parent) + && value.parent.expression === value + ? value.parent + : null; + }; + const typeHasScratchMember = ( + expression: ts.Expression, + member: "address" | "invokeKernelExport" | "withLease", + ): boolean => { + const symbol = canonicalSymbol( + checker, + checker.getPropertyOfType( + checker.getTypeAtLocation(unwrapExpression(expression)), + member, + ), + ); + if (member === "address") return isScratchAddressSymbol(symbol); + if (member === "withLease") return isScratchWithLeaseSymbol(symbol); + return isScratchLeaseMemberSymbol(symbol); + }; + const expressionTypeHasScratchMember = ( + expression: ts.Expression, + member: "address" | "invokeKernelExport" | "withLease", + ): boolean => { + const type = checker.getTypeAtLocation(expression); + const members = type.isUnion() + ? type.types.filter( + (part) => + (part.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) === 0, + ) + : [type]; + return members.length > 0 && members.every((part) => { + const symbol = canonicalSymbol( + checker, + checker.getPropertyOfType(part, member), + ); + if (member === "address") return isScratchAddressSymbol(symbol); + if (member === "withLease") return isScratchWithLeaseSymbol(symbol); + return isScratchLeaseMemberSymbol(symbol); + }); + }; + const isRealScratchWithLeaseCall = ( + call: ts.CallExpression, + ): boolean => { + if (!isKernelScratchWithLeaseCall(call, checker)) return false; + const receiver = callReceiver(call); + return Boolean( + receiver + && isExactScratchRegionOrigin(receiver), + ); + }; + const isRealScratchLeaseMemberCall = ( + call: ts.CallExpression, + ): boolean => { + const callee = unwrapExpression(call.expression); + if (!ts.isPropertyAccessExpression(callee)) return false; + if (!isScratchLeaseMemberSymbol(symbolAtExpression(checker, callee))) { + return false; + } + const origin = leaseOriginSymbol(callee.expression); + const callback = origin ? leaseOriginCallbacks.get(origin) : undefined; + return Boolean( + callback + && enclosingFunction(call) === callback, + ); + }; + const isIdentifierValueReference = ( + identifier: ts.Identifier, + ): boolean => { + const parent = identifier.parent; + if ( + ts.isShorthandPropertyAssignment(parent) + && parent.name === identifier + ) { + return true; + } + if ( + (parent as ts.NamedDeclaration).name === identifier + || ( + ts.isBindingElement(parent) + && ( + parent.name === identifier + || parent.propertyName === identifier + ) + ) + || ( + ts.isPropertyAccessExpression(parent) + && parent.name === identifier + ) + || ( + ts.isPropertyAssignment(parent) + && parent.name === identifier + ) + ) { + return false; + } + return true; + }; + const transparentCapabilityExpression = ( + expression: ts.Expression, + retainsCapability: (candidate: ts.Expression) => boolean, + ): ts.Expression | null => { + let value = expression; + while ( + value.parent + && isTransparentScratchUseWrapper(value.parent, value) + ) { + value = value.parent; + if (!retainsCapability(value)) return null; + } + return value; + }; + const isImmutableCapabilityAlias = ( + expression: ts.Expression, + retainsCapability: (candidate: ts.Expression) => boolean, + ): boolean => { + const value = transparentCapabilityExpression( + expression, + retainsCapability, + ); + if (!value) return false; + const declaration = value.parent; + return Boolean( + declaration + && ts.isVariableDeclaration(declaration) + && declaration.initializer === value + && ts.isIdentifier(declaration.name) + && ts.isVariableDeclarationList(declaration.parent) + && (declaration.parent.flags & ts.NodeFlags.Const) !== 0 + && retainsCapability(declaration.name), + ); + }; + const isAllowedActiveLeaseReference = ( + identifier: ts.Identifier, + callback: ts.FunctionLikeDeclaration, + ): boolean => { + const retainsLease = (candidate: ts.Expression): boolean => + expressionTypeHasScratchMember(candidate, "invokeKernelExport"); + const value = transparentCapabilityExpression(identifier, retainsLease); + if (!value) return false; + const parent = value.parent; + if ( + parent + && ( + ts.isPropertyAccessExpression(parent) + || ts.isElementAccessExpression(parent) + ) + && parent.expression === value + && retainsLease(value) + ) { + const member = symbolAtExpression(checker, parent); + return isScratchLeaseMemberSymbol(member) + && !isScratchAddressSymbol(member) + && !propertyAccessIsMutation(parent) + && enclosingFunction(identifier) === callback; + } + return enclosingFunction(identifier) === callback + && isImmutableCapabilityAlias(identifier, retainsLease); + }; + const propertyAccessIsMutation = ( + access: + | ts.PropertyAccessExpression + | ts.ElementAccessExpression, + ): boolean => { + const parent = access.parent; + return ( + ( + ts.isBinaryExpression(parent) + && parent.left === access + && isAssignmentOperator(parent.operatorToken.kind) + ) + || ( + ( + ts.isPrefixUnaryExpression(parent) + || ts.isPostfixUnaryExpression(parent) + ) + && parent.operand === access + ) + || ( + ts.isDeleteExpression(parent) + && parent.expression === access + ) + ); + }; + const findings: AuditFinding[] = []; + const addFinding = ( + sourceFile: ts.SourceFile, + node: ts.Node, + kind: AuditFinding["kind"], + ): void => { + findings.push(findingFor(options.rootDir, sourceFile, node, kind)); + }; + const ownershipWitness = ( + expression: ts.Expression, + form: KernelOwnershipForm, + ): ts.Expression => { + const node = unwrapExpression(expression); + const state = expressionState(node, checker, states, programSources); + if ((state[form] & KERNEL_OWNER) !== 0) return node; + if (ts.isObjectLiteralExpression(node)) { + for (const property of node.properties) { + let value: ts.Expression | undefined; + if (ts.isPropertyAssignment(property)) { + value = property.initializer; + } else if (ts.isShorthandPropertyAssignment(property)) { + value = property.name; + } else if (ts.isSpreadAssignment(property)) { + value = property.expression; + } + if ( + value + && hasKernelOwnership( + expressionState(value, checker, states, programSources), + form, + ) + ) { + return ownershipWitness(value, form); + } + } + } else if (ts.isArrayLiteralExpression(node)) { + for (const element of node.elements) { + if (ts.isOmittedExpression(element)) continue; + const value = ts.isSpreadElement(element) + ? element.expression + : element; + if ( + hasKernelOwnership( + expressionState(value, checker, states, programSources), + form, + ) + ) { + return ownershipWitness(value, form); + } + } + } else if (ts.isConditionalExpression(node)) { + for (const value of [node.whenTrue, node.whenFalse]) { + if ( + hasKernelOwnership( + expressionState(value, checker, states, programSources), + form, + ) + ) { + return ownershipWitness(value, form); + } + } + } + return node; + }; + const addOwnershipFindings = ( + sourceFile: ts.SourceFile, + node: ts.Node, + state: ValueState, + site: "escape" | "return" | "store", + admitReadOnlyView = false, + seededTarget?: ts.Symbol, + witnessExpression?: ts.Expression, + ): void => { + if ( + !admitReadOnlyView + && hasKernelOwnership(state, "view") + && !( + seededTarget + && hasKernelOwnership( + stateFor(seededValueStates, seededTarget), + "view", + ) + ) + ) { + addFinding( + sourceFile, + witnessExpression && (state.view & KERNEL_OWNER) === 0 + ? ownershipWitness(witnessExpression, "view") + : node, + `kernel-view-${site}`, + ); + } + if ( + hasKernelOwnership(state, "buffer") + && !( + seededTarget + && hasKernelOwnership( + stateFor(seededValueStates, seededTarget), + "buffer", + ) + ) + ) { + addFinding( + sourceFile, + witnessExpression && (state.buffer & KERNEL_OWNER) === 0 + ? ownershipWitness(witnessExpression, "buffer") + : node, + `kernel-buffer-${site}`, + ); + } + if ( + hasKernelOwnership(state, "memory") + && !( + seededTarget + && hasKernelOwnership( + stateFor(seededValueStates, seededTarget), + "memory", + ) + ) + ) { + addFinding( + sourceFile, + witnessExpression && (state.memory & KERNEL_OWNER) === 0 + ? ownershipWitness(witnessExpression, "memory") + : node, + `kernel-memory-${site}`, + ); + } + }; + + for (const sourceFile of sourceFiles) { + const visit = (node: ts.Node): void => { + if ( + ( + ts.isMethodSignature(node) + || ts.isMethodDeclaration(node) + || ts.isPropertySignature(node) + || ts.isPropertyDeclaration(node) + ) + && isScratchAddressSymbol(symbolForDeclaration(checker, node)) + ) { + // The opaque export-pointer token replaced the irrevocable numeric + // address. Reintroducing this member would reopen every primitive-flow + // bypass the contract is intended to eliminate. + addFinding(sourceFile, node, "scratch-address-contract"); + } + if ( + ts.isExpression(node) + && invalidSeedAssignmentExpressions.has(node) + ) { + // A seed is an ownership root, not a permanent blessing for whatever + // value is later assigned to that slot. + addFinding(sourceFile, node, "scratch-address-contract"); + } + if ( + ts.isCallExpression(node) + && reflectedSeedMutationCalls.has(node) + ) { + addFinding(sourceFile, node, "scratch-address-contract"); + } + if (ts.isIdentifier(node) && isIdentifierValueReference(node)) { + const leaseOrigin = leaseOriginSymbol(node); + const leaseCallback = leaseOrigin + ? leaseOriginCallbacks.get(leaseOrigin) + : undefined; + if ( + leaseCallback + && !isAllowedActiveLeaseReference(node, leaseCallback) + ) { + // WHY: only a lease minted by this exact synchronous callback can + // create an opaque export pointer or invoke the bound kernel export. + // Casts, helpers, reflection, and mutable aliases erase that origin. + addFinding(sourceFile, node, "scratch-address-contract"); + } + } + if ( + ts.isVariableDeclaration(node) + && ts.isIdentifier(node.name) + && node.initializer + && expressionState( + node.initializer, + checker, + states, + programSources, + ).scratchRegion + && !expressionTypeHasScratchMember(node.name, "withLease") + ) { + // WHY: callback-origin checking depends on the exact withLease symbol. + // A structurally compatible interface erases that identity and could + // manufacture an untracked lease parameter. + addFinding( + sourceFile, + node.initializer, + "scratch-address-contract", + ); + } + if ( + ts.isBinaryExpression(node) + && isSimpleAssignment(node) + && expressionState( + node.right, + checker, + states, + programSources, + ).scratchRegion + && !expressionTypeHasScratchMember(node.left, "withLease") + ) { + addFinding(sourceFile, node.right, "scratch-address-contract"); + } + if ( + ( + ts.isAsExpression(node) + || ts.isTypeAssertionExpression(node) + || ts.isSatisfiesExpression(node) + ) + && expressionState( + node.expression, + checker, + states, + programSources, + ).scratchRegion + && !expressionTypeHasScratchMember(node, "withLease") + ) { + addFinding(sourceFile, node, "scratch-address-contract"); + } + if ( + ts.isPropertyAccessExpression(node) + || ts.isElementAccessExpression(node) + ) { + const receiverState = expressionState( + node.expression, + checker, + states, + programSources, + ); + const directSymbol = symbolAtExpression(checker, node); + const regionMember = isScratchRegionMemberSymbol(directSymbol); + const regionProperty = accessedPropertyName(node); + const insideScratchLeaseImplementation = + toPosix(sourceFile.fileName).endsWith("/host/src/kernel-scratch.ts"); + const directCall = directCallForMember(node); + const exactDirectRegionCall = Boolean( + directCall + && ts.isPropertyAccessExpression(node) + && regionMember + && isExactScratchRegionOrigin(node.expression), + ); + if ( + receiverState.scratchRegion + && ( + !regionMember + || !isExactScratchRegionOrigin(node.expression) + || propertyAccessIsMutation(node) + || ( + regionProperty !== "capacity" + && !exactDirectRegionCall + ) + ) + ) { + addFinding(sourceFile, node, "scratch-address-contract"); + } + let addressesScratch = isScratchAddressSymbol(directSymbol); + let leasesScratch = isScratchWithLeaseSymbol(directSymbol); + let leaseMember = isScratchLeaseMemberSymbol(directSymbol); + if (ts.isElementAccessExpression(node)) { + const property = accessedPropertyName(node); + if (property === null || property === "address") { + addressesScratch ||= typeHasScratchMember( + node.expression, + "address", + ); + } + if (property === null || property === "withLease") { + leasesScratch ||= typeHasScratchMember( + node.expression, + "withLease", + ); + } + if (property === null) { + leaseMember ||= typeHasScratchMember( + node.expression, + "invokeKernelExport", + ); + } + } + const exactDirectLeaseMemberCall = Boolean( + directCall + && ts.isPropertyAccessExpression(node) + && isRealScratchLeaseMemberCall(directCall), + ); + const exactDirectWithLeaseCall = Boolean( + directCall + && ts.isPropertyAccessExpression(node) + && isRealScratchWithLeaseCall(directCall), + ); + if ( + addressesScratch + || ( + leaseMember + && !insideScratchLeaseImplementation + && ( + !exactDirectLeaseMemberCall + || propertyAccessIsMutation(node) + ) + ) + || (leasesScratch && !exactDirectWithLeaseCall) + ) { + addFinding(sourceFile, node, "scratch-address-contract"); + } + } + if ( + ts.isVariableDeclaration(node) + && ts.isObjectBindingPattern(node.name) + && node.initializer + ) { + const hasAddress = typeHasScratchMember(node.initializer, "address"); + const hasWithLease = typeHasScratchMember(node.initializer, "withLease"); + const hasLease = typeHasScratchMember( + node.initializer, + "invokeKernelExport", + ); + for (const element of node.name.elements) { + const property = propertyNameText(element.propertyName) + ?? (ts.isIdentifier(element.name) ? element.name.text : null); + if ( + (hasAddress && (property === null || property === "address")) + || hasLease + || ( + hasWithLease + && (property === null || property === "withLease") + ) + ) { + addFinding(sourceFile, element, "scratch-address-contract"); + } + } + } + if ( + (ts.isCallExpression(node) || ts.isNewExpression(node)) + && isViewConstructor( + node, + checker, + states, + programSources, + ) + ) { + const state = expressionState(node, checker, states, programSources); + if (isKernelView(state)) { + addFinding(sourceFile, node, "kernel-view"); + } + } + + if (ts.isCallExpression(node)) { + const callee = unwrapExpression(node.expression); + if ( + ts.isIdentifier(callee) + && callee.text === "eval" + && hasIntrinsicLibValueDeclaration( + symbolAtExpression(checker, callee), + ) + && activeLeaseCallbacks.has(enclosingFunction(node)!) + ) { + // Direct eval can name the lexical lease without an identifier node, + // defeating every symbol/provenance check below. + addFinding(sourceFile, node, "scratch-address-contract"); + } + for (const argument of node.arguments) { + if ( + expressionState( + argument, + checker, + states, + programSources, + ).scratchRegion + ) { + // Regions may be stored or returned with their exact type, but + // passing the capability through an arbitrary call makes + // structural erasure, retention, and reflection indistinguishable. + addFinding(sourceFile, argument, "scratch-address-contract"); + } + } + const calleeState = expressionState( + node.expression, + checker, + states, + programSources, + ); + if ( + hasPointerBearingKernelExport( + calleeState, + pointerBearingKernelExports, + ) + ) { + // WHY: primitive arguments carry no allocation-capacity witness. + // Pointer-bearing kernel exports may be invoked only by the lease, + // which substitutes opaque owned-range tokens immediately before the + // synchronous Wasm call. + addFinding(sourceFile, node, "kernel-pointer-export-bypass"); + } + if (calleeState.allocator) { + addFinding(sourceFile, node, "scratch-allocator-call"); + } + if (calleeState.scratchRegionFactory) { + addFinding(sourceFile, node, "scratch-region-factory-call"); + } + if (calleeState.reserver) { + addFinding(sourceFile, node, "spawn-reservation-call"); + } + if ( + isKernelScratchWithLeaseCall(node, checker) + && ( + !isRealScratchWithLeaseCall(node) + || !inlineScratchLeaseCallback(node) + ) + ) { + addFinding(sourceFile, node, "scratch-address-contract"); + } + + const receiver = callReceiver(node); + const method = callPropertyName(node); + if (receiver) { + const receiverState = expressionState( + receiver, + checker, + states, + programSources, + ); + const knownViewWrite = ( + isKernelView(receiverState) + && method !== null + && ( + TYPED_ARRAY_MUTATORS.has(method) + || method.startsWith("set") + || method.startsWith("write") + ) + ); + if (knownViewWrite) { + addFinding(sourceFile, node, "kernel-write"); + } else if ( + ( + hasKernelOwnership(receiverState, "view") + || hasKernelOwnership(receiverState, "buffer") + || hasKernelOwnership(receiverState, "memory") + ) + && !isProvenReadOnlyKernelReceiverCall( + node, + receiverState, + checker, + ) + ) { + // WHY: a computed or custom method can be a disguised `.set`, + // retain the live receiver, or mutate/detach its backing memory. + // Only an exact standard-library method whose contract is + // nonmutating may consume an allocator-owned receiver silently. + addOwnershipFindings( + sourceFile, + node, + receiverState, + "escape", + ); + } + } + if ( + ts.isPropertyAccessExpression(node.expression) + && node.expression.expression.getText(sourceFile) === "Atomics" + && ATOMIC_MUTATORS.has(node.expression.name.text) + && node.arguments[0] + && isKernelView( + expressionState( + node.arguments[0], + checker, + states, + programSources, + ), + ) + ) { + addFinding(sourceFile, node, "kernel-write"); + } + + const signature = checker.getResolvedSignature(node); + const declaration = signature?.declaration; + const analyzedBody = declaration + && isInProgram(programSources, declaration) + && hasBody(declaration); + if ( + !analyzedBody + && !isViewConstructor(node, checker, states, programSources) + ) { + if ( + !isIntrinsicObjectFreezeCall(node, checker) + && node.arguments.some((argument) => + hasPointerBearingKernelExport( + expressionState( + argument, + checker, + states, + programSources, + ), + pointerBearingKernelExports, + ) + ) + ) { + // Reflect.apply and opaque helpers can invoke or retain the raw + // function without leaving a direct call expression for the audit. + addFinding(sourceFile, node, "kernel-pointer-export-bypass"); + } + node.arguments.forEach((argument, index) => { + addOwnershipFindings( + sourceFile, + node, + expressionState(argument, checker, states, programSources), + "escape", + isKnownReadOnlyKernelViewArgument(node, index, checker), + ); + }); + } + } + if ( + ts.isNewExpression(node) + && !isViewConstructor(node, checker, states, programSources) + ) { + const signature = checker.getResolvedSignature(node); + const declaration = signature?.declaration; + const analyzedBody = declaration + && isInProgram(programSources, declaration) + && hasBody(declaration); + if (!analyzedBody) { + for (const argument of node.arguments ?? []) { + addOwnershipFindings( + sourceFile, + node, + expressionState(argument, checker, states, programSources), + "escape", + ); + } + } + } + + if ( + ts.isBinaryExpression(node) + && isAssignmentOperator(node.operatorToken.kind) + ) { + if ( + assignmentWritesKernelView( + node.left, + checker, + states, + programSources, + ) + ) { + addFinding(sourceFile, node, "kernel-write"); + } + } + if ( + (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) + && ts.isElementAccessExpression(unwrapExpression(node.operand)) + ) { + const operand = unwrapExpression(node.operand) as ts.ElementAccessExpression; + if ( + isKernelView( + expressionState(operand.expression, checker, states, programSources), + ) + ) { + addFinding(sourceFile, node, "kernel-write"); + } + } + if (ts.isReturnStatement(node) && node.expression) { + const state = expressionState( + node.expression, + checker, + states, + programSources, + ); + const fn = returnFunction(node); + if (fn) { + unionState( + state, + stateFor(seededReturnStates, fn), + ); + } + addOwnershipFindings( + sourceFile, + node, + state, + "return", + false, + undefined, + node.expression, + ); + } + if (ts.isArrowFunction(node) && !ts.isBlock(node.body)) { + const state = expressionState( + node.body, + checker, + states, + programSources, + ); + addOwnershipFindings( + sourceFile, + node, + state, + "return", + false, + undefined, + node.body, + ); + } + if ( + ts.isBinaryExpression(node) + && isSimpleAssignment(node) + && isPersistentStoreTarget(node.left, checker, node) + ) { + const storedState = expressionState( + node.right, + checker, + states, + programSources, + ); + addOwnershipFindings( + sourceFile, + node, + storedState, + "store", + false, + symbolAtExpression(checker, node.left), + ); + } + if ( + ts.isPropertyDeclaration(node) + && node.initializer + ) { + const storedState = expressionState( + node.initializer, + checker, + states, + programSources, + ); + addOwnershipFindings( + sourceFile, + node, + storedState, + "store", + false, + symbolForDeclaration(checker, node), + ); + } + if ( + ts.isVariableDeclaration(node) + && node.initializer + && returnFunction(node) === null + ) { + const storedState = expressionState( + node.initializer, + checker, + states, + programSources, + ); + addOwnershipFindings( + sourceFile, + node, + storedState, + "store", + false, + symbolForDeclaration(checker, node), + ); + } + if (ts.isParameter(node)) { + const property = parameterPropertySymbol(checker, node); + if (property && ts.isIdentifier(node.name)) { + const parameterState = expressionState( + node.name, + checker, + states, + programSources, + ); + addOwnershipFindings( + sourceFile, + node, + parameterState, + "store", + false, + property, + ); + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + } + + findings.sort((a, b) => a.key.localeCompare(b.key)); + const allowanceByKey = new Map(allowances.map((entry) => [entry.key, entry])); + const consumedAllowanceCounts = new Map(); + const violations: AuditFinding[] = []; + for (const finding of findings) { + const allowance = allowanceByKey.get(finding.key); + const consumed = consumedAllowanceCounts.get(finding.key) ?? 0; + if (allowance && consumed < (allowance.count ?? 1)) { + consumedAllowanceCounts.set(finding.key, consumed + 1); + } else { + violations.push(finding); + } + } + const unusedAllowances = allowances.filter( + (entry) => + (consumedAllowanceCounts.get(entry.key) ?? 0) !== (entry.count ?? 1), + ); + + return { + findings, + violations, + unusedAllowances, + unresolvedSeeds, + contractErrors: [...kernelScratchExportContract.errors], + sourceFiles: sourceFiles + .map((sourceFile) => relativeFile(options.rootDir, sourceFile)) + .sort(), + }; +} + +function isRuntimeSourceFile(fileName: string): boolean { + if ( + fileName.endsWith(".d.ts") + || fileName.endsWith(".d.mts") + || fileName.endsWith(".d.cts") + ) { + return false; + } + return /\.(?:[cm]?[jt]s|[jt]sx)$/.test(fileName); +} + +function isOrdinaryTestHarness(relativePath: string): boolean { + if ( + relativePath === "apps/browser-demos/test/epoll-repro.ts" + || relativePath.startsWith("apps/browser-demos/test/fixtures/") + ) { + return false; + } + return ( + relativePath.startsWith("host/test/") + || relativePath.includes("/test/") + || /\.(?:test|spec)\.(?:[cm]?[jt]s|[jt]sx)$/.test(relativePath) + ); +} + +/** + * Discover JavaScript and TypeScript runtime sources from the repository + * instead of naming a handful of current files. New production/diagnostic + * files are therefore in scope automatically regardless of which source + * language extension they use. + */ +export function repositoryRuntimeSourceFiles(rootDir: string): string[] { + const files: string[] = []; + const visitDirectory = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.isDirectory() && SKIPPED_DIRECTORY_NAMES.has(entry.name)) { + continue; + } + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) { + // These checked-out upstream trees are not Kandelo TypeScript runtime + // sources and can contain their own nested build products. + const relative = toPosix(path.relative(rootDir, absolute)); + if ( + relative === "libc/musl" + || relative === "tests/libc/libc-test" + || relative === "tests/sortix/os-test" + ) { + continue; + } + visitDirectory(absolute); + continue; + } + if (!entry.isFile() || !isRuntimeSourceFile(entry.name)) continue; + const relative = toPosix(path.relative(rootDir, absolute)); + if (!isOrdinaryTestHarness(relative)) files.push(absolute); + } + }; + visitDirectory(rootDir); + return files.sort(); +} + +export function virtualAuditOptions( + sources: Readonly>, + ownershipSeeds: readonly OwnershipSeed[], + allowances: readonly AuditAllowance[] = [], +): AuditOptions { + const rootDir = path.resolve("/virtual"); + const virtualSources = new Map(); + for (const [fileName, source] of Object.entries(sources)) { + virtualSources.set(path.join(rootDir, fileName), source); + } + return { + rootDir, + sourceFiles: [...virtualSources.keys()], + ownershipSeeds, + allowances, + virtualSources, + }; +} + +/** Format failures for one compact Vitest assertion. */ +export function formatAuditFailures(result: AuditResult): string[] { + const failures: string[] = []; + for (const error of result.contractErrors) { + failures.push(`kernel scratch export contract: ${error}`); + } + for (const seed of result.unresolvedSeeds) { + failures.push(`unresolved ownership seed: ${seed.declaration}`); + } + for (const finding of result.violations) { + const advice = finding.kind === "scratch-address-contract" + ? ". Use an exact kernel-owned region with an inline synchronous withLease callback; pass lease.exportPointer(...) only to lease.invokeKernelExport(...), and never reintroduce address(), forge or erase the region/lease, mutate its methods, or pass it through an opaque helper." + : finding.kind === "kernel-pointer-export-bypass" + ? ". Invoke pointer-bearing kernel exports only through KernelScratchLease.invokeKernelExport with opaque exportPointer range tokens." + : ""; + failures.push( + `${finding.file}:${finding.line} ${finding.kind} in ${finding.enclosing}: ${finding.text}${advice}`, + ); + } + for (const allowance of result.unusedAllowances) { + failures.push(`stale audit allowance: ${allowance.key}`); + } + return failures; +} diff --git a/host/test/sysv-ipc.test.ts b/host/test/sysv-ipc.test.ts index 78ce58b44c..35c867df44 100644 --- a/host/test/sysv-ipc.test.ts +++ b/host/test/sysv-ipc.test.ts @@ -4,30 +4,41 @@ */ import { describe, it, expect } from "vitest"; import { join, dirname } from "node:path"; -import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { runCentralizedProgram } from "./centralized-test-helper"; +import { ensureWasm64ExampleFixture } from "./wasm64-example-fixture"; const __dirname = dirname(fileURLToPath(import.meta.url)); const ipcBinary = join(__dirname, "../../examples/sysv_ipc_test.wasm"); -const hasBinary = existsSync(ipcBinary); -describe.skipIf(!hasBinary)("SysV IPC", () => { - it("message queues, semaphores, shared memory", async () => { - const result = await runCentralizedProgram({ - programPath: ipcBinary, - timeout: 10_000, - }); - console.log("stdout:", JSON.stringify(result.stdout)); - console.log("stderr:", JSON.stringify(result.stderr)); - expect(result.stdout).toContain("msgq: PASS"); - expect(result.stdout).toContain("semctl post-RMID IPC_STAT: EINVAL"); - expect(result.stdout).toContain("semctl post-RMID GETALL: EINVAL"); - expect(result.stdout).toContain("semctl post-RMID SETALL: EINVAL"); - expect(result.stdout).toContain("semctl post-RMID GETVAL: EINVAL"); - expect(result.stdout).toContain("sem: PASS"); - expect(result.stdout).toContain("shm: PASS"); - expect(result.stdout).toContain("ALL TESTS PASSED"); - expect(result.exitCode).toBe(0); - }, 15_000); +describe("SysV IPC", () => { + it.each(["wasm32", "wasm64"] as const)( + "message queues, semaphores, and shared memory (%s)", + async (arch) => { + const programPath = arch === "wasm64" + ? ensureWasm64ExampleFixture("sysv_ipc_test.c") + : ipcBinary; + const result = await runCentralizedProgram({ + programPath, + timeout: 20_000, + useDefaultRootfs: false, + }); + console.log("stdout:", JSON.stringify(result.stdout)); + console.log("stderr:", JSON.stringify(result.stderr)); + expect(result.stdout).toContain("msgctl IPC_SET: mode=0600 qbytes=4096"); + expect(result.stdout).toContain("msgq: PASS"); + expect(result.stdout).toContain("semctl post-RMID IPC_STAT: EINVAL"); + expect(result.stdout).toContain("semctl post-RMID GETALL: EINVAL"); + expect(result.stdout).toContain("semctl post-RMID SETALL: EINVAL"); + expect(result.stdout).toContain("semctl post-RMID GETVAL: EINVAL"); + expect(result.stdout).toContain("sem: PASS"); + expect(result.stdout).toContain("shmctl IPC_SET: mode=0600 segsz=4096"); + expect(result.stdout).toContain("shm: PASS"); + expect(result.stdout).toContain("ALL TESTS PASSED"); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.hostDiagnostics).toEqual([]); + }, + 30_000, + ); }); diff --git a/host/test/timerfd-signalfd-scratch.test.ts b/host/test/timerfd-signalfd-scratch.test.ts new file mode 100644 index 0000000000..08a160245a --- /dev/null +++ b/host/test/timerfd-signalfd-scratch.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { runCentralizedProgram } from "./centralized-test-helper"; +import { ensureWasm64ExampleFixture } from "./wasm64-example-fixture"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); + +describe("timerfd and signalfd scratch marshalling", () => { + it.each(["wasm32", "wasm64"] as const)( + "keeps native caller objects bounded (%s)", + async (arch) => { + const programPath = arch === "wasm64" + ? ensureWasm64ExampleFixture("timerfd_signalfd_scratch_test.c") + : join(repoRoot, "examples/timerfd_signalfd_scratch_test.wasm"); + const result = await runCentralizedProgram({ + programPath, + timeout: 20_000, + // This native-layout regression executes a self-contained fixture and + // must not depend on whichever packaged rootfs happens to be installed. + useDefaultRootfs: false, + }); + + expect(result.stdout).toContain("timerfd scratch guards: PASS"); + expect(result.stdout).toContain("signalfd scratch mask: PASS"); + expect(result.stdout).toContain("ALL TESTS PASSED"); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.hostDiagnostics).toEqual([]); + }, + 30_000, + ); +}); diff --git a/host/test/wasm-guest-pointer.test.ts b/host/test/wasm-guest-pointer.test.ts index 4d0a2792b2..2397dfd981 100644 --- a/host/test/wasm-guest-pointer.test.ts +++ b/host/test/wasm-guest-pointer.test.ts @@ -55,4 +55,66 @@ describe("checkedWasmGuestPointerOffset", () => { ), ); }); + + it("rejects runtime pointer widths other than wasm32 and wasm64", () => { + expect(() => checkedWasmGuestPointerOffset( + 0, + 5 as 4, + "invalid-width test", + )).toThrow( + new TypeError( + "invalid-width test: pointer width must be exactly 4 or 8", + ), + ); + }); + + it("uses captured numeric intrinsics after host globals are replaced", () => { + const originalBigInt = globalThis.BigInt; + const originalNumber = globalThis.Number; + const originalAsUintN = originalBigInt.asUintN; + const originalIsSafeInteger = originalNumber.isSafeInteger; + let memory32Result: number | undefined; + let memory64Result: number | undefined; + let invalidMemory32Error: unknown; + + try { + globalThis.BigInt = (() => 0n) as BigIntConstructor; + globalThis.Number = (() => -1) as NumberConstructor; + originalBigInt.asUintN = () => 0n; + originalNumber.isSafeInteger = () => true; + + memory32Result = checkedWasmGuestPointerOffset( + -1, + 4, + "mutated memory32 test", + ); + memory64Result = checkedWasmGuestPointerOffset( + 0x1_0000_0000n, + 8, + "mutated memory64 test", + ); + try { + checkedWasmGuestPointerOffset( + 1.5, + 4, + "mutated invalid memory32 test", + ); + } catch (error) { + invalidMemory32Error = error; + } + } finally { + originalBigInt.asUintN = originalAsUintN; + originalNumber.isSafeInteger = originalIsSafeInteger; + globalThis.BigInt = originalBigInt; + globalThis.Number = originalNumber; + } + + expect(memory32Result).toBe(0xffff_ffff); + expect(memory64Result).toBe(0x1_0000_0000); + expect(invalidMemory32Error).toEqual( + new TypeError( + "mutated invalid memory32 test: expected an exact memory32 pointer", + ), + ); + }); }); diff --git a/host/test/wasm-memory-write-audit.test.ts b/host/test/wasm-memory-write-audit.test.ts new file mode 100644 index 0000000000..9b01ba7405 --- /dev/null +++ b/host/test/wasm-memory-write-audit.test.ts @@ -0,0 +1,2807 @@ +import { + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + auditWasmMemoryWrites, + formatAuditFailures, + repositoryRuntimeSourceFiles, + type AuditAllowance, + type OwnershipSeed, + virtualAuditOptions, +} from "./support/wasm-memory-write-audit"; + +const kernelMemorySeed = ( + declaration = "kernel.ts::Kernel.memory", +): OwnershipSeed => ({ + declaration, + target: "value", + owner: "kernel", + form: "memory", + why: "This field owns the kernel WebAssembly linear memory.", +}); + +const scratchRegionSeed = ( + declaration = "caller.ts::region", +): OwnershipSeed => ({ + declaration, + target: "value", + owner: "kernel", + form: "scratch-region", + why: "This fixture value is a region returned by the kernel-owned factory.", +}); + +function auditVirtual( + sources: Readonly>, + seeds: readonly OwnershipSeed[] = [kernelMemorySeed()], + allowances: readonly AuditAllowance[] = [], +) { + return auditWasmMemoryWrites( + virtualAuditOptions(sources, seeds, allowances), + ); +} + +describe("WebAssembly memory write audit", () => { + it("finds direct, bracketed, and destructured kernel-memory aliases", () => { + const result = auditVirtual({ + "kernel.ts": ` + class Kernel { + memory!: WebAssembly.Memory; + write(data: Uint8Array): void { + const km = this.memory; + const { buffer } = km; + const first = new Uint8Array(buffer); + const alias = first; + alias["set"](data); + const second = new DataView(km["buffer"]); + second.setUint32(0, 1, true); + } + } + `, + }); + + expect(result.unresolvedSeeds).toEqual([]); + expect(result.findings.filter((finding) => finding.kind === "kernel-view")) + .toHaveLength(2); + expect(result.findings.filter((finding) => finding.kind === "kernel-write")) + .toHaveLength(2); + }); + + it("propagates ownership through a helper parameter and return across files", () => { + const result = auditVirtual({ + "view.ts": ` + export function raw(memory: WebAssembly.Memory): Uint8Array { + return new Uint8Array(memory.buffer); + } + `, + "kernel.ts": ` + import { raw } from "./view"; + export class Kernel { + memory!: WebAssembly.Memory; + write(data: Uint8Array): void { + const view = raw(this.memory); + view.set(data); + } + } + `, + }); + + expect(result.unresolvedSeeds).toEqual([]); + expect(result.findings.some( + (finding) => + finding.file === "view.ts" + && finding.kind === "kernel-view-return", + )).toBe(true); + expect(result.findings.some( + (finding) => + finding.file === "kernel.ts" + && finding.kind === "kernel-write", + )).toBe(true); + }); + + it("covers DataView, element, Atomics, Buffer, and subarray writes", () => { + const result = auditVirtual({ + "buffer.d.ts": ` + interface Buffer extends Uint8Array { + slice(): Buffer; + } + interface BufferConstructor { + from(buffer: ArrayBufferLike): Buffer; + } + declare const Buffer: BufferConstructor; + `, + "kernel.ts": ` + class Kernel { + memory!: WebAssembly.Memory; + write(data: Uint8Array): void { + const bytes = new Uint8Array(this.memory.buffer); + bytes[0] = 1; + bytes[1]++; + bytes.subarray(2)["set"](data); + const words = new Int32Array(this.memory.buffer); + Atomics.store(words, 0, 1); + const view = new DataView(this.memory.buffer); + view.setBigInt64(8, 2n, true); + Buffer.from(this.memory.buffer).fill(3); + Buffer.from(this.memory.buffer).slice().fill(4); + } + } + `, + }); + + const writes = result.findings.filter( + (finding) => finding.kind === "kernel-write", + ); + expect(writes.length).toBeGreaterThanOrEqual(7); + expect(writes.some((finding) => finding.text.includes("Atomics.store"))) + .toBe(true); + expect(writes.some((finding) => finding.text.includes("setBigInt64"))) + .toBe(true); + expect(writes.some((finding) => finding.text.includes("Buffer.from"))) + .toBe(true); + }); + + it("treats slice as detached while subarray retains the kernel backing", () => { + const result = auditVirtual({ + "kernel.ts": ` + class Kernel { + memory!: WebAssembly.Memory; + write(data: Uint8Array): void { + const raw = new Uint8Array(this.memory.buffer); + const detached = raw.slice(); + detached.set(data); + const alias = raw.subarray(0); + alias.set(data); + } + } + `, + }); + + const writes = result.findings.filter( + (finding) => finding.kind === "kernel-write", + ); + expect(writes).toHaveLength(1); + expect(writes[0].text).toContain("alias.set"); + }); + + it("rejects computed calls on kernel views while admitting exact read-only intrinsics", () => { + const result = auditVirtual({ + "kernel.ts": ` + class Kernel { + memory!: WebAssembly.Memory; + inspect( + data: Uint8Array, + computedSet: keyof Uint8Array, + computedCallable: keyof Uint8Array, + ): number { + const raw = new Uint8Array(this.memory.buffer); + (raw[computedSet] as (data: Uint8Array) => void)(data); + (raw[computedCallable] as Function)(data); + const detached = raw.slice(); + return detached.byteLength; + } + } + `, + }); + + const escapes = result.findings.filter( + (finding) => finding.kind === "kernel-view-escape", + ); + expect(escapes).toHaveLength(2); + expect(escapes.some( + (finding) => finding.text.includes("raw[computedSet]"), + )).toBe(true); + expect(escapes.some( + (finding) => finding.text.includes("raw[computedCallable]"), + )).toBe(true); + expect(escapes.some( + (finding) => finding.text.includes("raw.slice()"), + )).toBe(false); + }); + + it("finds raw-view returns, persistent stores, and opaque writer escapes", () => { + const result = auditVirtual({ + "kernel.ts": ` + interface Reader { + read(destination: Uint8Array): number; + } + class Kernel { + memory!: WebAssembly.Memory; + retained?: Uint8Array; + expose(reader: Reader): Uint8Array { + const destination = new Uint8Array(this.memory.buffer); + this.retained = destination; + reader.read(destination); + return destination; + } + } + `, + }); + + expect(result.findings.some( + (finding) => finding.kind === "kernel-view-store", + )).toBe(true); + expect(result.findings.some( + (finding) => finding.kind === "kernel-view-escape", + )).toBe(true); + expect(result.findings.some( + (finding) => finding.kind === "kernel-view-return", + )).toBe(true); + }); + + it("propagates ownership through structured containers and destructuring", () => { + const result = auditVirtual({ + "kernel.ts": ` + function wrap(memory: WebAssembly.Memory) { + return { + nested: { memory }, + buffers: [memory.buffer], + }; + } + class Holder { + constructor(readonly memory: WebAssembly.Memory) {} + write(data: Uint8Array): void { + new Uint8Array(this.memory.buffer).set(data); + } + } + class Kernel { + memory!: WebAssembly.Memory; + write(data: Uint8Array): void { + const wrapped = wrap(this.memory); + const { + nested: { memory }, + buffers: [buffer], + } = wrapped; + new Uint8Array(memory.buffer).set(data); + new Uint8Array(buffer).set(data); + new Holder(this.memory).write(data); + } + } + `, + }); + + const writes = result.findings.filter( + (finding) => finding.kind === "kernel-write", + ); + expect(writes).toHaveLength(3); + expect(writes.some((finding) => finding.enclosing === "Holder.write")) + .toBe(true); + expect(result.findings.some( + (finding) => + finding.kind === "kernel-memory-return" + && finding.enclosing === "wrap", + )).toBe(true); + expect(result.findings.some( + (finding) => + finding.kind === "kernel-buffer-return" + && finding.enclosing === "wrap", + )).toBe(true); + }); + + it("tracks callback containers and parameter-property symbol aliases", () => { + const result = auditVirtual({ + "kernel.ts": ` + class ScratchView { + saved?: ArrayBufferLike; + constructor( + private readonly refresh: () => { buffer: ArrayBufferLike }, + ) { + const initial = refresh(); + this.saved = initial.buffer; + } + } + class Kernel { + memory!: WebAssembly.Memory; + create(): void { + new ScratchView(() => ({ buffer: this.memory.buffer })); + } + } + `, + }); + + expect(result.findings.some( + (finding) => + finding.kind === "kernel-buffer-store" + && finding.text === "this.saved = initial.buffer", + )).toBe(true); + }); + + it("tracks destructuring assignments and unknown object properties", () => { + const result = auditVirtual({ + "kernel.ts": ` + declare function opaque(value: unknown): void; + class Kernel { + memory!: WebAssembly.Memory; + write(data: Uint8Array): void { + let alias!: Uint8Array; + ({ view: alias } = { + view: new Uint8Array(this.memory.buffer), + }); + alias.set(data); + const key = "memory"; + const first = { [key]: this.memory }; + opaque({ ...first }); + } + } + `, + }); + + expect(result.findings.some( + (finding) => + finding.kind === "kernel-write" + && finding.text === "alias.set(data)", + )).toBe(true); + expect(result.findings.some( + (finding) => + finding.kind === "kernel-memory-escape" + && finding.text === "opaque({ ...first })", + )).toBe(true); + }); + + it("finds spread-argument escapes and writes in assignment patterns", () => { + const result = auditVirtual({ + "kernel.ts": ` + declare function opaque(...values: unknown[]): void; + class Kernel { + memory!: WebAssembly.Memory; + write(): void { + const view = new Uint8Array(this.memory.buffer); + opaque(...[view]); + [view[0]] = [1]; + ({ value: view[1] } = { value: 2 }); + } + } + `, + }); + + expect(result.findings.some( + (finding) => + finding.kind === "kernel-view-escape" + && finding.text === "opaque(...[view])", + )).toBe(true); + const writes = result.findings.filter( + (finding) => finding.kind === "kernel-write", + ); + expect(writes).toHaveLength(2); + expect(writes.some((finding) => finding.text === "[view[0]] = [1]")) + .toBe(true); + expect(writes.some( + (finding) => + finding.text === "{ value: view[1] } = { value: 2 }", + )).toBe(true); + }); + + it("propagates comma, logical, Array.at, and for-of aliases", () => { + const result = auditVirtual({ + "kernel.ts": ` + class Kernel { + memory!: WebAssembly.Memory; + write(data: Uint8Array, condition: boolean): void { + const view = new Uint8Array(this.memory.buffer); + (0, view).set(data); + const fromAt = [view].at(0)!; + fromAt.set(data); + for (const item of [view]) item.set(data); + const fromLogical = condition && view; + fromLogical?.set(data); + } + } + `, + }); + + const writes = result.findings.filter( + (finding) => finding.kind === "kernel-write", + ); + expect(writes).toHaveLength(4); + expect(writes.some((finding) => finding.text === "(0, view).set(data)")) + .toBe(true); + expect(writes.some((finding) => finding.text === "fromAt.set(data)")) + .toBe(true); + expect(writes.some((finding) => finding.text === "item.set(data)")) + .toBe(true); + expect(writes.some((finding) => finding.text === "fromLogical?.set(data)")) + .toBe(true); + }); + + it("covers common intrinsic Array element and callback flows", () => { + const result = auditVirtual({ + "lib.es2023.array.d.ts": ` + interface Array { + findLast( + predicate: (value: T, index: number, array: T[]) => unknown, + ): T | undefined; + } + `, + "kernel.ts": ` + class Kernel { + memory!: WebAssembly.Memory; + write(data: Uint8Array): void { + const view = new Uint8Array(this.memory.buffer); + [view].forEach(item => item.set(data)); + [view].some(item => { item.set(data); return true; }); + [view].every(item => { item.set(data); return true; }); + const found = [view].find(item => { + item.set(data); + return true; + })!; + found.set(data); + const foundLast = [view].findLast(item => { + item.set(data); + return true; + })!; + foundLast.set(data); + const mapped = [view].map(item => { + item.set(data); + return item; + }); + mapped[0].set(data); + const filtered = [view].filter(item => { + item.set(data); + return true; + }); + filtered[0].set(data); + const popped = [view].pop()!; + popped.set(data); + const shifted = [view].shift()!; + shifted.set(data); + } + } + `, + }); + + const writes = result.findings.filter( + (finding) => finding.kind === "kernel-write", + ); + expect(writes.filter((finding) => finding.text === "item.set(data)")) + .toHaveLength(7); + for (const text of [ + "found.set(data)", + "foundLast.set(data)", + "mapped[0].set(data)", + "filtered[0].set(data)", + "popped.set(data)", + "shifted.set(data)", + ]) { + expect(writes.some((finding) => finding.text === text)).toBe(true); + } + }); + + it("keeps memory ownership on constructor and later-assigned wrappers", () => { + const result = auditVirtual({ + "kernel.ts": ` + declare function opaque(value: unknown): void; + interface Wrapper { + memory?: WebAssembly.Memory; + } + class Holder { + constructor(readonly memory: WebAssembly.Memory) {} + } + class Kernel { + memory!: WebAssembly.Memory; + escape(): void { + opaque(new Holder(this.memory)); + const wrapper: Wrapper = {}; + wrapper.memory = this.memory; + opaque(wrapper); + } + } + `, + }); + + const escapes = result.findings.filter( + (finding) => finding.kind === "kernel-memory-escape", + ); + expect(escapes).toHaveLength(2); + expect(escapes.some( + (finding) => finding.text === "opaque(new Holder(this.memory))", + )).toBe(true); + expect(escapes.some( + (finding) => finding.text === "opaque(wrapper)", + )).toBe(true); + }); + + it("does not let casts hide direct access to private owner slots", () => { + const result = auditVirtual({ + "kernel.ts": ` + declare function opaque(value: unknown): void; + class Kernel { + private memory!: WebAssembly.Memory; + write(data: Uint8Array): void { + const memory = (this as any).memory; + new Uint8Array(memory.buffer).set(data); + const alias: any = this; + new Uint8Array(alias.memory.buffer).set(data); + opaque(this); + opaque({ ...alias }); + } + } + `, + }); + + expect(result.findings.filter( + (finding) => + finding.kind === "kernel-write" + && finding.text.includes(".set(data)"), + )).toHaveLength(2); + expect(result.findings.filter( + (finding) => finding.kind === "kernel-memory-escape", + )).toHaveLength(1); + }); + + it("propagates callable returns through implicit arrows and getters", () => { + const result = auditVirtual({ + "kernel.ts": ` + class Kernel { + memory!: WebAssembly.Memory; + get currentMemory(): WebAssembly.Memory { + return this.memory; + } + write(data: Uint8Array): void { + const currentBuffer = () => this.currentMemory.buffer; + const bytes = new Uint8Array(currentBuffer()); + bytes.set(data); + } + } + `, + }); + + expect(result.findings.filter( + (finding) => finding.kind === "kernel-write", + )).toHaveLength(1); + expect(result.findings.some( + (finding) => + finding.kind === "kernel-memory-return" + && finding.enclosing === "Kernel.currentMemory", + )).toBe(true); + expect(result.findings.some( + (finding) => + finding.kind === "kernel-buffer-return" + && finding.enclosing.endsWith(".currentBuffer"), + )).toBe(true); + }); + + it("does not treat custom slice or from methods as detached copies", () => { + const result = auditVirtual({ + "kernel.ts": ` + class Kernel { + memory!: WebAssembly.Memory; + slice(): Uint8Array { + return new Uint8Array(this.memory.buffer); + } + from(): Uint8Array { + return new Uint8Array(this.memory.buffer); + } + write(data: Uint8Array): void { + this.slice().set(data); + this.from().set(data); + } + } + `, + }); + + expect(result.findings.filter( + (finding) => finding.kind === "kernel-write", + )).toHaveLength(2); + }); + + it("recognizes aliased Uint8Array and DataView constructors", () => { + const result = auditVirtual({ + "kernel.ts": ` + class Kernel { + memory!: WebAssembly.Memory; + write(data: Uint8Array): void { + const Bytes = Uint8Array; + const Words = DataView; + new Bytes(this.memory.buffer).set(data); + new Words(this.memory.buffer).setUint32(0, 1, true); + } + } + `, + }); + + expect(result.findings.filter( + (finding) => finding.kind === "kernel-view", + )).toHaveLength(2); + expect(result.findings.filter( + (finding) => finding.kind === "kernel-write", + )).toHaveLength(2); + }); + + it("does not exempt shadowed view constructors or Buffer.from", () => { + const result = auditVirtual({ + "kernel.ts": ` + export {}; + declare const Uint8Array: new (value: unknown) => unknown; + declare const Buffer: { + from(value: unknown): unknown; + }; + class Kernel { + memory!: WebAssembly.Memory; + escape(): void { + new Uint8Array(this.memory); + Buffer.from(this.memory); + } + } + `, + }); + + expect(result.findings.filter( + (finding) => finding.kind === "kernel-memory-escape", + )).toHaveLength(2); + }); + + it("finds raw memory and buffer calls, returns, and persistent stores", () => { + const result = auditVirtual({ + "kernel.ts": ` + declare function opaque(value: unknown): void; + let retained: WebAssembly.Memory; + class Retainer { + constructor(readonly memory: WebAssembly.Memory) {} + } + class Kernel { + memory!: WebAssembly.Memory; + retainedMemory?: WebAssembly.Memory; + retainedBuffer = this.memory.buffer; + exposeMemory(): WebAssembly.Memory { + return this.memory; + } + exposeBuffer(): ArrayBufferLike { + return this.memory.buffer; + } + escape(): void { + const memory = this.memory; + const buffer = memory.buffer; + opaque({ memory }); + opaque([buffer]); + this.retainedMemory = memory; + retained = memory; + new Retainer(memory); + } + } + `, + }); + + for (const kind of [ + "kernel-memory-escape", + "kernel-memory-return", + "kernel-memory-store", + "kernel-buffer-escape", + "kernel-buffer-return", + "kernel-buffer-store", + ] as const) { + expect( + result.findings.some((finding) => finding.kind === kind), + `expected ${kind}`, + ).toBe(true); + } + expect(result.findings.filter( + (finding) => finding.kind === "kernel-memory-store", + ).length).toBeGreaterThanOrEqual(2); + expect(result.findings.some( + (finding) => + finding.kind === "kernel-memory-store" + && finding.text === "retained = memory", + )).toBe(true); + }); + + it("finds module initializers and parameter-property defaults", () => { + const result = auditVirtual({ + "factory.ts": ` + export function kernelMemory(): WebAssembly.Memory { + throw new Error("fixture"); + } + `, + "kernel.ts": ` + import { kernelMemory } from "./factory"; + const retained = kernelMemory(); + class Holder { + constructor(readonly memory = kernelMemory()) {} + } + `, + }, [{ + declaration: "factory.ts::kernelMemory", + target: "return", + owner: "kernel", + form: "memory", + why: "This fixture factory returns only kernel memory.", + }]); + + expect(result.unresolvedSeeds).toEqual([]); + expect(result.findings.some( + (finding) => + finding.kind === "kernel-memory-store" + && finding.text === "retained = kernelMemory()", + )).toBe(true); + expect(result.findings.some( + (finding) => + finding.kind === "kernel-memory-store" + && finding.enclosing === "Holder.constructor", + )).toBe(true); + }); + + it("does not confuse custom set/decode methods with synchronous platform readers", () => { + const result = auditVirtual({ + "kernel.ts": ` + interface RetainingSink { + set(value: Uint8Array): void; + decode(value: Uint8Array): void; + } + class Kernel { + memory!: WebAssembly.Memory; + expose(sink: RetainingSink): void { + const bytes = new Uint8Array(this.memory.buffer); + sink.set(bytes); + sink.decode(bytes); + } + consume(): void { + const bytes = new Uint8Array(this.memory.buffer); + new Uint8Array(4).set(bytes); + new TextDecoder().decode(bytes); + } + } + `, + }); + + const escapes = result.findings.filter( + (finding) => finding.kind === "kernel-view-escape", + ); + expect(escapes).toHaveLength(2); + expect(escapes.some((finding) => finding.text.includes("sink.set"))) + .toBe(true); + expect(escapes.some((finding) => finding.text.includes("sink.decode"))) + .toBe(true); + expect(escapes.some((finding) => finding.text.includes("TextDecoder"))) + .toBe(false); + }); + + it("does not exempt shadowed typed-array, decoder, or Atomics readers", () => { + const result = auditVirtual({ + "kernel.ts": ` + export {}; + declare class Uint8Array { + set(value: unknown): void; + } + declare class TextDecoder { + decode(value: unknown): string; + } + declare const Atomics: { + load(value: unknown, index: number): number; + }; + class Kernel { + memory!: WebAssembly.Memory; + expose(): void { + const view = new globalThis.Uint8Array(this.memory.buffer); + new Uint8Array().set(view); + new TextDecoder().decode(view); + Atomics.load(view, 0); + } + } + `, + }); + + const escapes = result.findings.filter( + (finding) => finding.kind === "kernel-view-escape", + ); + expect(escapes).toHaveLength(3); + expect(escapes.some((finding) => finding.text.includes("Uint8Array"))) + .toBe(true); + expect(escapes.some((finding) => finding.text.includes("TextDecoder"))) + .toBe(true); + expect(escapes.some((finding) => finding.text.includes("Atomics.load"))) + .toBe(true); + }); + + it("finds direct and multiply-aliased scratch allocator calls", () => { + const result = auditVirtual({ + "kernel.ts": ` + class Kernel { + memory!: WebAssembly.Memory; + allocate(exports: Record): void { + const allocator = exports.kernel_alloc_scratch as (n: number) => number; + const alias = allocator; + alias(64); + const begin = + exports["kernel_spawn_scratch_begin"] as (n: bigint) => bigint; + const beginAlias = begin; + beginAlias(128n); + const pointer = + exports.kernel_spawn_scratch_pointer as () => bigint; + pointer(); + } + } + `, + }); + + expect(result.findings.filter( + (finding) => finding.kind === "scratch-allocator-call", + )).toHaveLength(1); + expect(result.findings.filter( + (finding) => finding.kind === "spawn-reservation-call", + )).toHaveLength(2); + }); + + it("keeps non-scratch ownership roots explicit without treating them as kernel", () => { + const seeds: OwnershipSeed[] = [ + { + declaration: "owners.ts::Process.memory", + target: "value", + owner: "process-memory", + form: "memory", + why: "This field is one user process WebAssembly memory.", + }, + { + declaration: "owners.ts::Framebuffer.bytes", + target: "value", + owner: "framebuffer", + form: "view", + why: "This host-owned view contains framebuffer pixel bytes.", + }, + { + declaration: "owners.ts::Shared.bytes", + target: "value", + owner: "shared-memory", + form: "view", + why: "This host-owned view is an authoritative shared mapping.", + }, + { + declaration: "owners.ts::Lent.destination", + target: "value", + owner: "rust-lent", + form: "view", + why: "Rust lends this checked destination for one synchronous call.", + }, + ]; + const result = auditVirtual({ + "owners.ts": ` + class Process { + memory!: WebAssembly.Memory; + write(data: Uint8Array): void { + new Uint8Array(this.memory.buffer).set(data); + } + } + class Framebuffer { + bytes!: Uint8Array; + write(data: Uint8Array): void { this.bytes.set(data); } + } + class Shared { + bytes!: Uint8Array; + write(data: Uint8Array): void { this.bytes.set(data); } + } + class Lent { + destination!: Uint8Array; + write(data: Uint8Array): void { this.destination.set(data); } + } + `, + }, seeds); + + expect(result.unresolvedSeeds).toEqual([]); + expect(result.findings).toEqual([]); + }); + + it("uses an exact multiset and rejects stale allowances", () => { + const sources = { + "kernel.ts": ` + class Kernel { + memory!: WebAssembly.Memory; + write(data: Uint8Array): void { + const view = new Uint8Array(this.memory.buffer); + view.set(data); + } + } + `, + }; + const initial = auditVirtual(sources); + const allowances: AuditAllowance[] = initial.findings.map((finding) => ({ + key: finding.key, + disposition: "scratch-core", + why: "The focused fixture explicitly admits this one checked site.", + })); + const admitted = auditVirtual(sources, [kernelMemorySeed()], allowances); + expect(formatAuditFailures(admitted)).toEqual([]); + + const duplicate = auditVirtual({ + "kernel.ts": ` + class Kernel { + memory!: WebAssembly.Memory; + write(data: Uint8Array): void { + const view = new Uint8Array(this.memory.buffer); + view.set(data); + view.set(data); + } + } + `, + }, [kernelMemorySeed()], allowances); + expect(duplicate.violations.some( + (finding) => finding.kind === "kernel-write", + )).toBe(true); + + const stale = auditVirtual(sources, [kernelMemorySeed()], [ + ...allowances, + { + key: "new-file.ts::missing::kernel-write::missing()", + disposition: "scratch-core", + why: "This deliberately stale entry must be rejected by the audit.", + }, + ]); + expect(stale.unusedAllowances).toHaveLength(1); + }); + + it("audits a newly introduced source path without a filename allowlist", () => { + const result = auditVirtual({ + "new/subsystem/transfer.ts": ` + export class Kernel { + memory!: WebAssembly.Memory; + write(data: Uint8Array): void { + new Uint8Array(this.memory.buffer).set(data); + } + } + `, + }, [kernelMemorySeed("new/subsystem/transfer.ts::Kernel.memory")]); + + expect(result.sourceFiles).toContain("new/subsystem/transfer.ts"); + expect(result.violations.some( + (finding) => finding.file === "new/subsystem/transfer.ts", + )).toBe(true); + }); + + it.each(["js", "jsx", "mjs", "cjs"])( + "audits a raw write introduced in a .%s runtime source", + (extension) => { + const file = `new/subsystem/transfer.${extension}`; + const result = auditVirtual({ + [file]: ` + export class Kernel { + memory; + write(data) { + new Uint8Array(this.memory.buffer).set(data); + } + } + `, + }, [kernelMemorySeed(`${file}::Kernel.memory`)]); + + expect(result.unresolvedSeeds).toEqual([]); + expect(result.violations.some( + (finding) => + finding.file === file + && finding.kind === "kernel-write", + )).toBe(true); + }, + ); + + it("catches a TypeScript kernel owner written through an untyped JavaScript parameter", () => { + const result = auditVirtual({ + "kernel.ts": ` + export class Kernel { + memory!: WebAssembly.Memory; + getMemory(): WebAssembly.Memory { return this.memory; } + } + `, + "transfer.mjs": ` + export function write(kernel, data) { + new Uint8Array(kernel.getMemory().buffer).set(data); + } + `, + }); + + expect(result.unresolvedSeeds).toEqual([]); + expect(result.violations.some( + (finding) => + finding.file === "transfer.mjs" + && finding.kind === "kernel-write", + )).toBe(true); + }); + + it("tracks JavaScript raw-memory and view aliases", () => { + const result = auditVirtual({ + "transfer.mjs": ` + export function write(kernel, data) { + const memory = kernel.getMemory(); + const buffer = memory.buffer; + const bytes = new Uint8Array(buffer); + bytes.set(data); + } + `, + }, []); + + expect(result.violations.some( + (finding) => + finding.file === "transfer.mjs" + && finding.kind === "kernel-write", + )).toBe(true); + }); + + it("propagates a JavaScript raw-memory argument into a helper parameter", () => { + const result = auditVirtual({ + "transfer.mjs": ` + function publish(memory, data) { + const view = new DataView(memory.buffer); + view.setUint32(0, data.byteLength, true); + } + export function write(kernel, data) { + publish(kernel.getMemory(), data); + } + `, + }, []); + + expect(result.violations.some( + (finding) => + finding.file === "transfer.mjs" + && finding.kind === "kernel-write", + )).toBe(true); + }); + + it("does not turn JavaScript reads or ordinary buffer writes into kernel writes", () => { + const result = auditVirtual({ + "transfer.mjs": ` + export function inspect(kernel, ordinary, data) { + const byteLength = kernel.getMemory().buffer.byteLength; + const raw = new DataView(kernel.getMemory().buffer); + const value = raw.getUint32(0, true); + new Uint8Array(ordinary).set(data); + return { byteLength, value }; + } + `, + }, []); + + expect(result.findings.some( + (finding) => finding.kind === "kernel-view", + )).toBe(true); + expect(result.findings.some( + (finding) => finding.kind === "kernel-write", + )).toBe(false); + }); + + it("requires exact allowances for an unrelated JavaScript getMemory API", () => { + const sources = { + "cache.mjs": ` + export function update(cache, data) { + new Uint8Array(cache.getMemory().buffer).set(data); + } + `, + }; + const initial = auditVirtual(sources, []); + const allowances: AuditAllowance[] = initial.findings.map((finding) => ({ + key: finding.key, + disposition: "non-kernel", + why: "This fixture's unrelated cache API deliberately shares the reviewed getMemory spelling.", + })); + + expect(initial.violations.some( + (finding) => finding.kind === "kernel-write", + )).toBe(true); + expect(formatAuditFailures( + auditVirtual(sources, [], allowances), + )).toEqual([]); + }); + + it("flags direct, aliased, and JavaScript scratch-region factories", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": ` + export function allocateKernelScratchRegion(..._args: unknown[]): object { + return {}; + } + export function reserveKernelScratchRegion(..._args: unknown[]): object { + return {}; + } + `, + "caller.ts": ` + import { + allocateKernelScratchRegion, + reserveKernelScratchRegion as reserve, + } from "./host/src/kernel-scratch"; + allocateKernelScratchRegion({}, () => 4096, 32, 4, "forged"); + const alias = reserve; + alias({}, () => ({ pointer: 4096, capacity: 32 }), 32, 4, "forged"); + `, + "caller.js": ` + import { allocateKernelScratchRegion as make } from "./host/src/kernel-scratch"; + make({}, () => 4096, 32, 4, "forged-js"); + `, + }, []); + + const factoryFindings = result.findings.filter( + (finding) => finding.kind === "scratch-region-factory-call", + ); + expect(factoryFindings).toHaveLength(3); + expect(new Set(factoryFindings.map((finding) => finding.file))).toEqual( + new Set(["caller.ts", "caller.js"]), + ); + }); + + it("tracks kernel instance export memory through TypeScript and JavaScript", () => { + const result = auditVirtual({ + "kernel.ts": ` + export class Kernel { + instance!: WebAssembly.Instance; + getInstance(): WebAssembly.Instance { return this.instance; } + direct(data: Uint8Array): void { + const memory = this.getInstance().exports.memory as WebAssembly.Memory; + new Uint8Array(memory.buffer).set(data); + } + aliased(data: Uint8Array): void { + const instance = this.getInstance(); + const { exports } = instance; + const { memory } = exports as { memory: WebAssembly.Memory }; + new Uint8Array(memory.buffer).set(data); + } + } + `, + "diagnostic.js": ` + export function overwrite(kernel, data) { + const instance = kernel.getInstance(); + const { exports } = instance; + new Uint8Array(exports.memory.buffer).set(data); + } + `, + }, [{ + declaration: "kernel.ts::Kernel.instance", + target: "value", + owner: "kernel", + form: "instance", + why: "This fixture field is the instantiated kernel module.", + }]); + + expect(result.unresolvedSeeds).toEqual([]); + const writes = result.findings.filter( + (finding) => finding.kind === "kernel-write", + ); + expect(writes.filter((finding) => finding.file === "kernel.ts")) + .toHaveLength(2); + expect(writes.filter((finding) => finding.file === "diagnostic.js")) + .toHaveLength(1); + }); + + it("rejects every raw pointer-bearing kernel-export invocation shape", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": ` + const KERNEL_SCRATCH_EXPORT_NAMES = Object.freeze([ + "kernel_ioctl", + "kernel_recv", + ] as const); + `, + "caller.ts": ` + declare function opaque(value: unknown): void; + class Kernel { + instance!: WebAssembly.Instance; + invoke(): void { + const exports = this.instance.exports; + exports.kernel_recv(1, 4096, 8, 0); + exports["kernel_recv"](1, 4096, 8, 0); + const alias = exports.kernel_recv as (...args: number[]) => number; + alias(1, 4096, 8, 0); + const { kernel_recv: destructured } = exports; + (destructured as (...args: number[]) => number)(1, 4096, 8, 0); + const dynamicName = "kernel_recv"; + (exports[dynamicName] as (...args: number[]) => number)( + 1, + 4096, + 8, + 0, + ); + alias.call(undefined, 1, 4096, 8, 0); + alias.apply(undefined, [1, 4096, 8, 0]); + const bound = alias.bind(undefined, 1, 4096, 8, 0); + bound(); + Reflect.apply(alias, undefined, [1, 4096, 8, 0]); + opaque(alias); + opaque({ fn: alias }); + opaque([alias]); + const frozen = Object.freeze({ fn: alias }); + frozen.fn(1, 4096, 8, 0); + } + } + `, + }, [{ + declaration: "caller.ts::Kernel.instance", + target: "value", + owner: "kernel", + form: "instance", + why: "This fixture field is the exact instantiated kernel module.", + }]); + + expect(result.contractErrors).toEqual([]); + const bypasses = result.findings.filter( + (finding) => finding.kind === "kernel-pointer-export-bypass", + ); + for (const snippet of [ + "exports.kernel_recv(", + 'exports["kernel_recv"](', + "alias(", + "destructured as", + "exports[dynamicName]", + "alias.call(", + "alias.apply(", + "alias.bind(", + "bound()", + "Reflect.apply(", + "opaque(alias)", + "opaque({ fn: alias })", + "opaque([alias])", + "frozen.fn(", + ]) { + expect( + bypasses.some((finding) => finding.text.includes(snippet)), + bypasses.map((finding) => finding.text).join("\n"), + ).toBe(true); + } + }); + + it("keeps raw-call exclusions exact and token-only exports out of scope", () => { + const sources = { + "host/src/kernel-scratch.ts": ` + const KERNEL_SCRATCH_EXPORT_NAMES = Object.freeze([ + "kernel_ioctl", + ] as const); + `, + "caller.ts": ` + class Kernel { + instance!: WebAssembly.Instance; + invoke(): void { + const exports = this.instance.exports; + const ioctl = exports.kernel_ioctl as (...args: number[]) => number; + ioctl(1, 2, 3, 0, 4); + const reserved = exports.kernel_spawn_reserved_process as ( + parentPid: number, + callerTid: number, + token: bigint, + length: number, + ) => number; + reserved(1, 2, 3n, 4); + } + } + `, + }; + const seeds: OwnershipSeed[] = [{ + declaration: "caller.ts::Kernel.instance", + target: "value", + owner: "kernel", + form: "instance", + why: "This fixture field is the exact instantiated kernel module.", + }]; + const initial = auditVirtual(sources, seeds); + const ioctlFinding = initial.findings.find( + (finding) => + finding.kind === "kernel-pointer-export-bypass" + && finding.text === "ioctl(1, 2, 3, 0, 4)", + ); + expect(ioctlFinding).toBeDefined(); + expect(initial.findings.some( + (finding) => + finding.kind === "kernel-pointer-export-bypass" + && finding.text.includes("reserved("), + )).toBe(false); + + const allowance: AuditAllowance = { + key: ioctlFinding!.key, + disposition: "kernel-control", + why: "This exact fixture models a reviewed scalar-only ioctl call.", + }; + expect(formatAuditFailures(auditVirtual(sources, seeds, [allowance]))) + .toEqual([]); + + const duplicated = auditVirtual({ + ...sources, + "caller.ts": sources["caller.ts"].replace( + "ioctl(1, 2, 3, 0, 4);", + "ioctl(1, 2, 3, 0, 4); ioctl(1, 2, 3, 0, 4);", + ), + }, seeds, [allowance]); + expect(duplicated.violations.some( + (finding) => finding.kind === "kernel-pointer-export-bypass", + )).toBe(true); + }); + + it("fails closed when the authoritative pointer-export contract disappears", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": "export {};", + }, []); + expect(result.contractErrors).toHaveLength(1); + expect(formatAuditFailures(result)[0]).toContain( + "KERNEL_SCRATCH_EXPORT_NAMES", + ); + }); + + it("flags typed-array callbacks and iterators that retain a kernel view", () => { + const result = auditVirtual({ + "kernel.ts": ` + class Kernel { + raw!: Uint8Array; + use(): void { + this.raw.forEach((_value, _index, whole) => { + whole[0] = 1; + }); + const values = this.raw.values(); + const entries = this.raw.entries(); + opaque(values, entries); + } + } + declare function opaque(...values: unknown[]): void; + `, + }, [{ + declaration: "kernel.ts::Kernel.raw", + target: "value", + owner: "kernel", + form: "view", + why: "This fixture view aliases the kernel linear memory.", + }]); + + expect(result.findings.some( + (finding) => + finding.kind === "kernel-write" + && finding.text.includes("whole[0] = 1"), + )).toBe(true); + expect(result.findings.filter( + (finding) => + finding.kind === "kernel-view-escape" + && ( + finding.text.includes(".values()") + || finding.text.includes(".entries()") + ), + ).length).toBeGreaterThanOrEqual(2); + }); + + it("accepts exact lease operations from a genuine exact region", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": ` + export interface KernelScratchDataView { + setBigInt64(offset: number, value: bigint, littleEndian?: boolean): void; + } + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + assertRange(offset: number, length: number): void; + exportPointer( + offset: number, + length: number, + ): KernelScratchExportPointer; + invokeKernelExport( + name: string, + args: readonly ( + | number + | bigint + | KernelScratchExportPointer + )[], + ): number; + dataView(offset: number, length: number): KernelScratchDataView; + writeAddress( + destinationOffset: number, + sourceOffset: number, + sourceLength: number, + encoding: "u64-le", + ): void; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + } + `, + "caller.ts": ` + import type { + KernelScratchLease, + KernelScratchRegion, + } from "./host/src/kernel-scratch"; + class Kernel { + consume(region: KernelScratchRegion): void { + const exactRegion: KernelScratchRegion = region; + exactRegion.withLease((lease) => { + const exactLease: KernelScratchLease = lease; + const pointer = exactLease.exportPointer(0, 8); + exactLease.invokeKernelExport( + "kernel_recv", + [1, pointer, 8, 0], + ); + exactLease.assertRange(0, 16); + exactLease.dataView(0, 16).setBigInt64(0, 0n, true); + exactLease.writeAddress(8, 8, 8, "u64-le"); + }); + } + } + `, + }, [scratchRegionSeed("caller.ts::Kernel.consume.$param:region")]); + + expect(result.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + )).toEqual([]); + }); + + it("accepts allocator-only fields and exact projected helper returns", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": ` + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + exportPointer(offset: number, length: number): KernelScratchExportPointer; + invokeKernelExport(name: string, args: readonly unknown[]): number; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + revoke(): void; + } + export function allocateKernelScratchRegion(): KernelScratchRegion { + throw new Error("fixture"); + } + export function reserveKernelScratchRegion(): KernelScratchRegion { + throw new Error("fixture"); + } + `, + "caller.ts": ` + import { + allocateKernelScratchRegion, + reserveKernelScratchRegion, + type KernelScratchRegion, + } from "./host/src/kernel-scratch"; + interface Reservation { + region: KernelScratchRegion; + } + class Kernel { + private region: KernelScratchRegion | null = null; + init(): void { + this.region = allocateKernelScratchRegion(); + } + private requireRegion(): KernelScratchRegion { + if (!this.region) throw new Error("not initialized"); + return this.region; + } + private reserve(enabled: boolean): { + reservation: Reservation | null; + } { + if (!enabled) return { reservation: null }; + const region = reserveKernelScratchRegion(); + return { reservation: { region } }; + } + run(enabled: boolean): void { + this.requireRegion().withLease((lease) => { + const pointer = lease.exportPointer(0, 8); + lease.invokeKernelExport( + "kernel_recv", + [1, pointer, 8, 0], + ); + }); + const begun = this.reserve(enabled); + const reservation = begun.reservation; + if (reservation?.region) { + const activeRegion = reservation.region; + activeRegion.withLease((lease) => { + const pointer = lease.exportPointer(8, 8); + lease.invokeKernelExport( + "kernel_recv", + [1, pointer, 8, 0], + ); + }); + reservation.region.revoke(); + } + } + } + const kernel = new Kernel(); + kernel.init(); + const internals = kernel as unknown as { + region: KernelScratchRegion; + }; + const castRegion = internals.region; + castRegion.withLease((lease) => { + const pointer = lease.exportPointer(16, 8); + lease.invokeKernelExport( + "kernel_recv", + [1, pointer, 8, 0], + ); + }); + `, + }, []); + + expect(result.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + )).toEqual([]); + }); + + it("rejects returned and persistently stored scratch-address aliases", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": ` + export interface KernelScratchLease { + address(offset: number, length: number): number; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + } + `, + "caller.ts": ` + import type { KernelScratchRegion } from "./host/src/kernel-scratch"; + declare const region: KernelScratchRegion; + let retained = 0; + + region.withLease((lease) => { + const address = lease.address.bind(lease); + const pointer = address(0, 8); + const alias = pointer + 0; + retained = alias; + return alias; + }); + `, + }, [scratchRegionSeed()]); + + expect(result.findings.some( + (finding) => + finding.kind === "scratch-address-contract" + && finding.text.includes("lease.address"), + )).toBe(true); + }); + + it("rejects a lease retained by a deferred callback", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": ` + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + copyFrom(source: Uint8Array, destinationOffset?: number): void; + exportPointer(offset: number, length: number): KernelScratchExportPointer; + invokeKernelExport( + name: string, + args: readonly ( + | number + | bigint + | KernelScratchExportPointer + )[], + ): number; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + } + `, + "caller.ts": ` + import type { KernelScratchRegion } from "./host/src/kernel-scratch"; + declare const region: KernelScratchRegion; + let deferred: () => void = () => {}; + + region.withLease((lease) => { + const pointer = lease.exportPointer(0, 8); + deferred = () => { + lease.copyFrom(new Uint8Array([1]), 0); + lease.invokeKernelExport( + "kernel_recv", + [1, pointer, 8, 0], + ); + }; + }); + deferred(); + `, + }, [scratchRegionSeed()]); + + const violations = result.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + ); + expect(violations.some( + (finding) => + finding.text.includes("lease.copyFrom"), + ), violations.map((finding) => finding.text).join("\n")).toBe(true); + expect(violations.some( + (finding) => + finding.text.includes("lease.invokeKernelExport"), + ), violations.map((finding) => finding.text).join("\n")).toBe(true); + }); + + it("rejects a lease before it can cross an opaque helper boundary", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": ` + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + exportPointer(offset: number, length: number): KernelScratchExportPointer; + invokeKernelExport( + name: string, + args: readonly ( + | number + | bigint + | KernelScratchExportPointer + )[], + ): number; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + } + `, + "caller.ts": ` + import type { + KernelScratchLease, + KernelScratchRegion, + } from "./host/src/kernel-scratch"; + declare const region: KernelScratchRegion; + + function invokeLater(lease: KernelScratchLease): void { + const pointer = lease.exportPointer(0, 8); + lease.invokeKernelExport( + "kernel_recv", + [1, pointer, 8, 0], + ); + } + + region.withLease((lease) => { + invokeLater(lease); + }); + `, + }, [scratchRegionSeed()]); + + const violations = result.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + ); + expect(violations.some( + (finding) => finding.text === "lease", + )).toBe(true); + }); + + it("rejects an exact-typed lease without a genuine region origin", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": ` + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + exportPointer(offset: number, length: number): KernelScratchExportPointer; + invokeKernelExport( + name: string, + args: readonly ( + | number + | bigint + | KernelScratchExportPointer + )[], + ): number; + } + `, + "caller.ts": ` + import type { KernelScratchLease } from "./host/src/kernel-scratch"; + declare const forged: KernelScratchLease; + const pointer = forged.exportPointer(0, 8); + forged.invokeKernelExport( + "kernel_recv", + [1, pointer, 8, 0], + ); + `, + }, []); + + const violations = result.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + ); + expect(violations.some( + (finding) => finding.text.includes("forged.exportPointer"), + )).toBe(true); + expect(violations.some( + (finding) => finding.text.includes("forged.invokeKernelExport"), + )).toBe(true); + }); + + it("rejects region and lease method extraction, reflection, and helpers", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": ` + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + exportPointer(offset: number, length: number): KernelScratchExportPointer; + invokeKernelExport( + name: string, + args: readonly ( + | number + | bigint + | KernelScratchExportPointer + )[], + ): number; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + } + `, + "caller.ts": ` + import type { + KernelScratchLease, + KernelScratchRegion, + } from "./host/src/kernel-scratch"; + declare const region: KernelScratchRegion; + declare const key: keyof KernelScratchLease; + declare function opaque(value: unknown): void; + + const boundLease = region.withLease.bind(region); + const { withLease } = region; + const reflectedRegion = Reflect.get(region, "withLease"); + void boundLease; + void withLease; + void reflectedRegion; + + region.withLease((lease) => { + const method = lease.exportPointer; + const { invokeKernelExport } = lease; + const bound = lease.invokeKernelExport.bind(lease); + const reflected = Reflect.get(lease, "exportPointer"); + void method; + void invokeKernelExport; + void bound; + void reflected; + lease[key]; + opaque(lease); + }); + `, + }, [scratchRegionSeed()]); + + const violations = result.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + ); + for (const snippet of [ + "region.withLease", + "withLease", + "lease.exportPointer", + "invokeKernelExport", + "lease[key]", + "lease", + ]) { + expect(violations.some((finding) => finding.text.includes(snippet))) + .toBe(true); + } + }); + + it("rejects stored, returned, non-inline, and async leases", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": ` + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + exportPointer(offset: number, length: number): KernelScratchExportPointer; + invokeKernelExport( + name: string, + args: readonly ( + | number + | bigint + | KernelScratchExportPointer + )[], + ): number; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + } + `, + "caller.ts": ` + import type { + KernelScratchLease, + KernelScratchRegion, + } from "./host/src/kernel-scratch"; + declare const region: KernelScratchRegion; + let retained: KernelScratchLease | undefined; + const callback = (lease: KernelScratchLease): void => { + retained = lease; + }; + + region.withLease(callback); + region.withLease(async (lease) => { + lease.exportPointer(0, 1); + }); + region.withLease((lease) => { + retained = lease; + return lease; + }); + `, + }, [scratchRegionSeed()]); + + const violations = result.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + ); + for (const snippet of [ + "region.withLease(callback)", + "region.withLease(async", + "lease", + ]) { + expect( + violations.some((finding) => finding.text.includes(snippet)), + violations.map((finding) => finding.text).join("\n"), + ).toBe(true); + } + }); + + it("rejects structurally erased and forged scratch leases", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": ` + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + exportPointer(offset: number, length: number): KernelScratchExportPointer; + invokeKernelExport( + name: string, + args: readonly ( + | number + | bigint + | KernelScratchExportPointer + )[], + ): number; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + } + `, + "caller.ts": ` + import type { + KernelScratchExportPointer, + KernelScratchLease, + KernelScratchRegion, + } from "./host/src/kernel-scratch"; + interface ErasedLease { + exportPointer(offset: number, length: number): KernelScratchExportPointer; + invokeKernelExport( + name: string, + args: readonly unknown[], + ): number; + } + declare const region: KernelScratchRegion; + + region.withLease((lease) => { + const erased: ErasedLease = lease; + const pointer = erased.exportPointer(0, 8); + erased.invokeKernelExport( + "kernel_recv", + [1, pointer, 8, 0], + ); + }); + + const fake = { + exportPointer: () => ({} as KernelScratchExportPointer), + invokeKernelExport: () => 0, + } as KernelScratchLease; + const args = [ + "kernel_recv", + [1, fake.exportPointer(0, 8), 8, 0], + ] as const; + fake.invokeKernelExport(...args); + `, + }, [scratchRegionSeed()]); + + const violations = result.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + ); + expect(violations.some((finding) => finding.text === "lease")).toBe(true); + expect(violations.some( + (finding) => finding.text.includes("fake.exportPointer"), + )).toBe(true); + expect(violations.some( + (finding) => finding.text.includes("fake.invokeKernelExport"), + )).toBe(true); + }); + + it("rejects reintroducing or using a numeric scratch address member", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": ` + export interface KernelScratchLease { + address(offset: number, length: number): number; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + } + `, + "caller.ts": ` + import type { KernelScratchRegion } from "./host/src/kernel-scratch"; + declare const region: KernelScratchRegion; + region.withLease((lease) => { + lease.address(70, 1); + }); + `, + }, [scratchRegionSeed()]); + + const violations = result.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + ); + expect(violations.some( + (finding) => finding.text.includes("address(offset"), + )).toBe(true); + expect(violations.some( + (finding) => finding.text.includes("lease.address"), + )).toBe(true); + }); + + it("rejects replacement of a seeded scratch region with a structural fake", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": ` + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + exportPointer( + offset: number, + length: number, + ): KernelScratchExportPointer; + invokeKernelExport( + name: string, + args: readonly unknown[], + ): number; + } + export interface KernelScratchRegion { + readonly capacity: number; + withLease(operation: (lease: KernelScratchLease) => T): T; + revoke(): void; + } + `, + "caller.ts": ` + import type { + KernelScratchRegion, + } from "./host/src/kernel-scratch"; + class Kernel { + scratchRegion!: KernelScratchRegion; + run(fakeRegion: KernelScratchRegion): void { + this.scratchRegion = fakeRegion; + this.scratchRegion.withLease((lease) => { + const pointer = lease.exportPointer(0, 1); + lease.invokeKernelExport( + "kernel_recv", + [1, pointer, 1, 0], + ); + }); + } + } + `, + }, [scratchRegionSeed("caller.ts::Kernel.scratchRegion")]); + + const violations = result.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + ); + expect(violations.some( + (finding) => finding.text.includes("this.scratchRegion.withLease"), + ), violations.map((finding) => finding.text).join("\n")).toBe(true); + }); + + it("rejects interposed and structurally forged seeded-field receivers", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": ` + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + exportPointer(offset: number, length: number): KernelScratchExportPointer; + invokeKernelExport(name: string, args: readonly unknown[]): number; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + } + `, + "caller.ts": ` + import type { + KernelScratchRegion, + } from "./host/src/kernel-scratch"; + class Kernel { + region!: KernelScratchRegion; + requireRegion(): KernelScratchRegion { + return this.region; + } + run(fake: KernelScratchRegion): void { + const proxy = new Proxy(this, { + get(target, property, receiver) { + if (property === "region") return fake; + return Reflect.get(target, property, receiver); + }, + }); + proxy.region.withLease((lease) => { + const pointer = lease.exportPointer(0, 1); + lease.invokeKernelExport("kernel_recv", [1, pointer, 1, 0]); + }); + + const holder: Kernel = { region: fake }; + holder.region.withLease((lease) => { + const pointer = lease.exportPointer(1, 1); + lease.invokeKernelExport("kernel_recv", [1, pointer, 1, 0]); + }); + + const castHolder = { region: fake } as Kernel; + castHolder.region.withLease((lease) => { + const pointer = lease.exportPointer(2, 1); + lease.invokeKernelExport("kernel_recv", [1, pointer, 1, 0]); + }); + + const inherited = Object.create(this, { + region: { value: fake }, + }) as Kernel; + inherited.region.withLease((lease) => { + const pointer = lease.exportPointer(3, 1); + lease.invokeKernelExport("kernel_recv", [1, pointer, 1, 0]); + }); + + const cloned = structuredClone({ region: fake }) as Kernel; + cloned.region.withLease((lease) => { + const pointer = lease.exportPointer(4, 1); + lease.invokeKernelExport("kernel_recv", [1, pointer, 1, 0]); + }); + + const proxyMethod = new Proxy(this, { + get(target, property, receiver) { + if (property === "requireRegion") return () => fake; + return Reflect.get(target, property, receiver); + }, + }); + proxyMethod.requireRegion().withLease((lease) => { + const pointer = lease.exportPointer(5, 1); + lease.invokeKernelExport("kernel_recv", [1, pointer, 1, 0]); + }); + + const methodHolder = { + region: fake, + requireRegion: this.requireRegion, + } as Kernel; + methodHolder.requireRegion().withLease((lease) => { + const pointer = lease.exportPointer(6, 1); + lease.invokeKernelExport("kernel_recv", [1, pointer, 1, 0]); + }); + } + } + `, + }, [scratchRegionSeed("caller.ts::Kernel.region")]); + + const violations = result.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + ); + for ( + const receiver of [ + "proxy", + "holder", + "castHolder", + "inherited", + "cloned", + ] + ) { + expect(violations.some( + (finding) => + finding.text.includes(`${receiver}.region.withLease`), + ), violations.map((finding) => finding.text).join("\n")).toBe(true); + } + for (const receiver of ["proxyMethod", "methodHolder"]) { + expect(violations.some( + (finding) => + finding.text.includes(`${receiver}.requireRegion().withLease`), + ), violations.map((finding) => finding.text).join("\n")).toBe(true); + } + }); + + it("rejects immutable and reassigned structural erasure of a scratch lease", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": ` + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + exportPointer(offset: number, length: number): KernelScratchExportPointer; + invokeKernelExport( + name: string, + args: readonly unknown[], + ): number; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + } + `, + "caller.ts": ` + import type { + KernelScratchExportPointer, + KernelScratchLease, + KernelScratchRegion, + } from "./host/src/kernel-scratch"; + interface ErasedLease { + exportPointer(offset: number, length: number): KernelScratchExportPointer; + invokeKernelExport(name: string, args: readonly unknown[]): number; + } + declare const region: KernelScratchRegion; + + region.withLease((lease) => { + const immutable: ErasedLease = lease; + const first = immutable.exportPointer(1, 1); + immutable.invokeKernelExport("kernel_recv", [1, first, 1, 0]); + + let reassigned: ErasedLease = lease; + const second = reassigned.exportPointer(2, 2); + reassigned.invokeKernelExport("kernel_recv", [1, second, 2, 0]); + reassigned = { + exportPointer: () => ({} as KernelScratchExportPointer), + invokeKernelExport: () => 0, + }; + + const exact: KernelScratchLease = lease; + const exactPointer = exact.exportPointer(3, 3); + exact.invokeKernelExport( + "kernel_recv", + [1, exactPointer, 3, 0], + ); + }); + `, + }, [scratchRegionSeed()]); + + const violations = result.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + ); + expect(violations.some( + (finding) => finding.text.includes("lease"), + )).toBe(true); + expect(violations.some( + (finding) => finding.text.includes("exact.exportPointer"), + )).toBe(false); + expect(violations.some( + (finding) => finding.text.includes("exact.invokeKernelExport"), + )).toBe(false); + }); + + it("rejects an inline unknown cast that erases a scratch lease receiver", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": ` + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + exportPointer(offset: number, length: number): KernelScratchExportPointer; + invokeKernelExport( + name: string, + args: readonly unknown[], + ): number; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + } + `, + "caller.ts": ` + import type { + KernelScratchExportPointer, + KernelScratchRegion, + } from "./host/src/kernel-scratch"; + interface ErasedLease { + exportPointer(offset: number, length: number): KernelScratchExportPointer; + invokeKernelExport(name: string, args: readonly unknown[]): number; + } + declare const region: KernelScratchRegion; + + region.withLease((lease) => { + const pointer = ( + lease as unknown as ErasedLease + ).exportPointer(4, 4); + (lease as unknown as ErasedLease).invokeKernelExport( + "kernel_recv", + [1, pointer, 4, 0], + ); + }); + `, + }, [scratchRegionSeed()]); + + expect(result.findings.some( + (finding) => finding.kind === "scratch-address-contract", + )).toBe(true); + }); + + it("rejects destructured lease methods from declarations and assignments", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": ` + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + exportPointer(offset: number, length: number): KernelScratchExportPointer; + invokeKernelExport( + name: string, + args: readonly unknown[], + ): number; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + } + `, + "caller.ts": ` + import type { + KernelScratchLease, + KernelScratchRegion, + } from "./host/src/kernel-scratch"; + declare const region: KernelScratchRegion; + let reassigned!: KernelScratchLease["invokeKernelExport"]; + + region.withLease((lease) => { + const { invokeKernelExport: immutable } = lease; + immutable.call(lease, "kernel_recv", []); + + ({ invokeKernelExport: reassigned } = lease); + reassigned.call(lease, "kernel_recv", []); + }); + `, + }, [scratchRegionSeed()]); + + const violations = result.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + ); + expect(violations.length).toBeGreaterThanOrEqual(2); + }); + + it("rejects Reflect.get and Reflect.apply lease-method extraction", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": ` + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + exportPointer(offset: number, length: number): KernelScratchExportPointer; + invokeKernelExport( + name: string, + args: readonly unknown[], + ): number; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + } + `, + "caller.ts": ` + import type { + KernelScratchLease, + KernelScratchRegion, + } from "./host/src/kernel-scratch"; + declare const region: KernelScratchRegion; + + region.withLease((lease) => { + const immutable = Reflect.get( + lease, + "invokeKernelExport", + ) as KernelScratchLease["invokeKernelExport"]; + Reflect.apply(immutable, lease, ["kernel_recv", []]); + + let reassigned = Reflect.get( + lease, + "exportPointer", + ) as KernelScratchLease["exportPointer"]; + Reflect.apply(reassigned, lease, [8, 8]); + reassigned = () => 0; + }); + `, + }, [scratchRegionSeed()]); + + const violations = result.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + ); + expect(violations.length).toBeGreaterThanOrEqual(2); + }); + + it("rejects scratch leases passed through destructured helper parameters", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": ` + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + exportPointer(offset: number, length: number): KernelScratchExportPointer; + invokeKernelExport( + name: string, + args: readonly unknown[], + ): number; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + } + `, + "caller.ts": ` + import type { + KernelScratchLease, + KernelScratchRegion, + } from "./host/src/kernel-scratch"; + declare const region: KernelScratchRegion; + + function destructuredParameter( + { invokeKernelExport }: KernelScratchLease, + receiver: KernelScratchLease, + ): number { + return invokeKernelExport.call( + receiver, + "kernel_recv", + [], + ); + } + + region.withLease((lease) => { + const immutable = destructuredParameter; + immutable(lease, lease); + + let reassigned = destructuredParameter; + reassigned(lease, lease); + reassigned = () => 0; + }); + `, + }, [scratchRegionSeed()]); + + const violations = result.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + ); + expect(violations.length).toBeGreaterThanOrEqual(2); + }); + + it("rejects structural erasure of the scratch region origin gate", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": ` + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + exportPointer(offset: number, length: number): KernelScratchExportPointer; + invokeKernelExport( + name: string, + args: readonly unknown[], + ): number; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + } + `, + "caller.ts": ` + import type { + KernelScratchExportPointer, + KernelScratchRegion, + } from "./host/src/kernel-scratch"; + interface ErasedLease { + exportPointer(offset: number, length: number): KernelScratchExportPointer; + invokeKernelExport(name: string, args: readonly unknown[]): number; + } + interface ErasedRegion { + withLease(operation: (lease: ErasedLease) => T): T; + } + declare const region: KernelScratchRegion; + + const erasedRegion: ErasedRegion = region; + erasedRegion.withLease((lease) => { + const pointer = lease.exportPointer(20, 1); + lease.invokeKernelExport( + "kernel_recv", + [1, pointer, 1, 0], + ); + }); + `, + }, [scratchRegionSeed()]); + + expect(result.findings.some( + (finding) => finding.kind === "scratch-address-contract", + )).toBe(true); + }); + + it("rejects mutation and reflective interposition of scratch methods", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": ` + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + exportPointer(offset: number, length: number): KernelScratchExportPointer; + invokeKernelExport( + name: string, + args: readonly unknown[], + ): number; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + } + `, + "caller.ts": ` + import type { + KernelScratchExportPointer, + KernelScratchLease, + KernelScratchRegion, + } from "./host/src/kernel-scratch"; + declare const region: KernelScratchRegion; + + region.withLease = () => { + throw new Error("interposed"); + }; + Object.defineProperty(region, "withLease", { + value: () => undefined, + }); + Reflect.set(region, "withLease", () => undefined); + + region.withLease((lease) => { + lease.invokeKernelExport = () => 0; + Object.defineProperty(lease, "exportPointer", { + value: () => ({} as KernelScratchExportPointer), + }); + Reflect.set(lease, "invokeKernelExport", () => 0); + }); + `, + }, [scratchRegionSeed()]); + + const violations = result.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + ); + expect(violations.length).toBeGreaterThanOrEqual(6); + }); + + it("rejects function arguments and direct eval as hidden lease receivers", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": ` + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + exportPointer(offset: number, length: number): KernelScratchExportPointer; + invokeKernelExport( + name: string, + args: readonly unknown[], + ): number; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + } + `, + "caller.ts": ` + import type { + KernelScratchExportPointer, + KernelScratchRegion, + } from "./host/src/kernel-scratch"; + interface ErasedLease { + exportPointer(offset: number, length: number): KernelScratchExportPointer; + invokeKernelExport(name: string, args: readonly unknown[]): number; + } + declare const region: KernelScratchRegion; + + region.withLease(function (lease) { + const hidden = arguments[0] as ErasedLease; + const pointer = hidden.exportPointer(61, 1); + hidden.invokeKernelExport( + "kernel_recv", + [1, pointer, 1, 0], + ); + void lease; + }); + region.withLease((lease) => { + eval( + 'lease.invokeKernelExport("kernel_recv", [])', + ); + }); + `, + }, [scratchRegionSeed()]); + + const violations = result.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + ); + expect(violations.length).toBeGreaterThanOrEqual(2); + }); + + it("rejects a conditional that mixes a real region with a structural fake", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": ` + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + exportPointer( + offset: number, + length: number, + ): KernelScratchExportPointer; + invokeKernelExport( + name: string, + args: readonly unknown[], + ): number; + } + export interface KernelScratchRegion { + readonly capacity: number; + withLease(operation: (lease: KernelScratchLease) => T): T; + revoke(): void; + } + export function allocateKernelScratchRegion(): KernelScratchRegion { + throw new Error("fixture"); + } + `, + "caller.ts": ` + import { + allocateKernelScratchRegion, + type KernelScratchRegion, + } from "./host/src/kernel-scratch"; + declare const chooseFake: boolean; + const real = allocateKernelScratchRegion(); + const fake = {} as KernelScratchRegion; + const selected: KernelScratchRegion = chooseFake ? fake : real; + selected.withLease((lease) => { + const pointer = lease.exportPointer(0, 1); + lease.invokeKernelExport( + "kernel_recv", + [1, pointer, 1, 0], + ); + }); + `, + }, []); + + const violations = result.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + ); + expect(violations.some( + (finding) => finding.text.includes("selected.withLease"), + ), violations.map((finding) => finding.text).join("\n")).toBe(true); + }); + + it("rejects mutable and container contamination of a real scratch region", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": ` + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + exportPointer( + offset: number, + length: number, + ): KernelScratchExportPointer; + invokeKernelExport( + name: string, + args: readonly unknown[], + ): number; + } + export interface KernelScratchRegion { + readonly capacity: number; + withLease(operation: (lease: KernelScratchLease) => T): T; + revoke(): void; + } + export function allocateKernelScratchRegion(): KernelScratchRegion { + throw new Error("fixture"); + } + `, + "caller.ts": ` + import { + allocateKernelScratchRegion, + type KernelScratchRegion, + } from "./host/src/kernel-scratch"; + declare const index: number; + const real = allocateKernelScratchRegion(); + const fake = {} as KernelScratchRegion; + + let reassigned: KernelScratchRegion = real; + reassigned = fake; + reassigned.withLease((lease) => { + const pointer = lease.exportPointer(1, 1); + lease.invokeKernelExport( + "kernel_recv", + [1, pointer, 1, 0], + ); + }); + + const regions: readonly KernelScratchRegion[] = [real, fake]; + regions[index].withLease((lease) => { + const pointer = lease.exportPointer(2, 1); + lease.invokeKernelExport( + "kernel_recv", + [1, pointer, 1, 0], + ); + }); + `, + }, []); + + const violations = result.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + ); + for (const receiver of ["reassigned", "regions[index]"]) { + expect(violations.some( + (finding) => finding.text.includes(`${receiver}.withLease`), + ), violations.map((finding) => finding.text).join("\n")).toBe(true); + } + }); + + it("rejects mutable helper wrappers around a scratch lease", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": ` + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + exportPointer( + offset: number, + length: number, + ): KernelScratchExportPointer; + invokeKernelExport( + name: string, + args: readonly unknown[], + ): number; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + } + `, + "caller.ts": ` + import type { + KernelScratchLease, + KernelScratchRegion, + } from "./host/src/kernel-scratch"; + declare const region: KernelScratchRegion; + let retained: KernelScratchLease | undefined; + let wrapper = (lease: KernelScratchLease): KernelScratchLease => lease; + wrapper = (lease) => { + retained = lease; + return lease; + }; + + region.withLease((lease) => { + const escaped = wrapper(lease); + const pointer = escaped.exportPointer(3, 1); + escaped.invokeKernelExport( + "kernel_recv", + [1, pointer, 1, 0], + ); + }); + `, + }, [scratchRegionSeed()]); + + const violations = result.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + ); + expect(violations.some( + (finding) => finding.text === "lease", + ), violations.map((finding) => finding.text).join("\n")).toBe(true); + expect(violations.some( + (finding) => finding.text.includes("escaped.exportPointer"), + ), violations.map((finding) => finding.text).join("\n")).toBe(true); + }); + + it("rejects reflective replacement of seeded scratch authorities", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": ` + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + exportPointer( + offset: number, + length: number, + ): KernelScratchExportPointer; + invokeKernelExport( + name: string, + args: readonly unknown[], + ): number; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + } + `, + "caller.ts": ` + import type { + KernelScratchRegion, + } from "./host/src/kernel-scratch"; + class Kernel { + helperRegion!: KernelScratchRegion; + region!: KernelScratchRegion; + private requireHelperRegion(): KernelScratchRegion { + return this.helperRegion; + } + run(fakeRegion: KernelScratchRegion): void { + Object.defineProperty(this, "requireHelperRegion", { + value: () => fakeRegion, + }); + this.requireHelperRegion().withLease((lease) => { + const pointer = lease.exportPointer(4, 1); + lease.invokeKernelExport( + "kernel_recv", + [1, pointer, 1, 0], + ); + }); + Reflect.set(this, "region", fakeRegion); + this.region.withLease((lease) => { + const pointer = lease.exportPointer(5, 1); + lease.invokeKernelExport( + "kernel_recv", + [1, pointer, 1, 0], + ); + }); + } + } + `, + }, [ + scratchRegionSeed("caller.ts::Kernel.helperRegion"), + scratchRegionSeed("caller.ts::Kernel.region"), + ]); + + const violations = result.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + ); + expect(violations.some( + (finding) => finding.text.includes("Object.defineProperty"), + ), violations.map((finding) => finding.text).join("\n")).toBe(true); + expect(violations.some( + (finding) => + finding.text.includes("this.requireHelperRegion().withLease"), + ), violations.map((finding) => finding.text).join("\n")).toBe(true); + expect(violations.some( + (finding) => finding.text.includes("Reflect.set"), + ), violations.map((finding) => finding.text).join("\n")).toBe(true); + expect(violations.some( + (finding) => finding.text.includes("this.region.withLease"), + ), violations.map((finding) => finding.text).join("\n")).toBe(true); + }); + + it("rejects aliased, bracketed, and call-wrapped reflective replacement", () => { + const result = auditVirtual({ + "host/src/kernel-scratch.ts": ` + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + exportPointer(offset: number, length: number): KernelScratchExportPointer; + invokeKernelExport(name: string, args: readonly unknown[]): number; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + } + `, + "caller.ts": ` + import type { KernelScratchRegion } from "./host/src/kernel-scratch"; + class Kernel { + region!: KernelScratchRegion; + run(fake: KernelScratchRegion): void { + const defineAlias = Object.defineProperty; + defineAlias(this, "region", { value: fake }); + Object["defineProperty"](this, "region", { value: fake }); + Object.defineProperty.call( + Object, + this, + "region", + { value: fake }, + ); + Reflect["set"](this, "region", fake); + const assignAlias = Object.assign; + assignAlias(this, { region: fake }); + this.region.withLease((lease) => { + const pointer = lease.exportPointer(0, 1); + lease.invokeKernelExport( + "kernel_recv", + [1, pointer, 1, 0], + ); + }); + } + } + `, + }, [scratchRegionSeed("caller.ts::Kernel.region")]); + + const violations = result.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + ); + for ( + const call of [ + "defineAlias(", + 'Object["defineProperty"]', + "Object.defineProperty.call", + 'Reflect["set"]', + "assignAlias(", + ] + ) { + expect(violations.some( + (finding) => finding.text.includes(call), + ), violations.map((finding) => finding.text).join("\n")).toBe(true); + } + expect(violations.some( + (finding) => finding.text.includes("this.region.withLease"), + ), violations.map((finding) => finding.text).join("\n")).toBe(true); + }); + + it("discovers every JavaScript and TypeScript runtime extension", () => { + const root = mkdtempSync(path.join(tmpdir(), "kandelo-memory-audit-")); + try { + const runtime = path.join(root, "runtime"); + mkdirSync(runtime, { recursive: true }); + const expected = [ + "runtime/a.ts", + "runtime/b.tsx", + "runtime/c.mts", + "runtime/d.cts", + "runtime/e.js", + "runtime/f.jsx", + "runtime/g.mjs", + "runtime/h.cjs", + ]; + for (const relative of expected) { + writeFileSync(path.join(root, relative), "export {};\n"); + } + writeFileSync(path.join(runtime, "ignored.d.ts"), "export {};\n"); + writeFileSync(path.join(runtime, "ignored.d.mts"), "export {};\n"); + writeFileSync(path.join(runtime, "ignored.d.cts"), "export {};\n"); + writeFileSync(path.join(runtime, "ignored.test.js"), "export {};\n"); + writeFileSync(path.join(runtime, "ignored.spec.mjs"), "export {};\n"); + mkdirSync(path.join(root, "dist"), { recursive: true }); + writeFileSync(path.join(root, "dist", "ignored.js"), "export {};\n"); + + expect( + repositoryRuntimeSourceFiles(root).map((file) => + path.relative(root, file).split(path.sep).join("/") + ), + ).toEqual(expected); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/host/test/wasm64-example-fixture.ts b/host/test/wasm64-example-fixture.ts index a320119fa6..7939b278ff 100644 --- a/host/test/wasm64-example-fixture.ts +++ b/host/test/wasm64-example-fixture.ts @@ -1,9 +1,38 @@ import { execFileSync } from "node:child_process"; -import { existsSync, statSync } from "node:fs"; +import { existsSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { + captureProgramFixtureBuildContract, + programFixtureNeedsRebuild, + stampProgramFixture, +} from "./program-fixture-freshness"; const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); +let wasm64BuildContract: ReturnType< + typeof captureProgramFixtureBuildContract +> | null = null; + +function fixtureBuildContract() { + if (wasm64BuildContract) return wasm64BuildContract; + const compilerVersion = execFileSync("wasm64posix-cc", ["--version"], { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + wasm64BuildContract = captureProgramFixtureBuildContract( + repoRoot, + `wasm64\nfork=false\n${compilerVersion}`, + [ + join(repoRoot, "sdk/bin"), + join(repoRoot, "sdk/src"), + join(repoRoot, "sdk/package.json"), + join(repoRoot, "sdk/package-lock.json"), + join(repoRoot, "sysroot64"), + ], + ); + return wasm64BuildContract; +} /** Build the memory64 counterpart owned by the test that imports it. */ export function ensureWasm64ExampleFixture(cFile: string): string { @@ -12,12 +41,14 @@ export function ensureWasm64ExampleFixture(cFile: string): string { if (!existsSync(src)) { throw new Error(`Missing wasm64 test source: ${src}`); } - if (!existsSync(out) || statSync(src).mtimeMs > statSync(out).mtimeMs) { + const contract = fixtureBuildContract(); + if (programFixtureNeedsRebuild(src, out, contract)) { console.log(`[fixture] Compiling ${cFile} for wasm64...`); execFileSync("wasm64posix-cc", [src, "-o", out], { cwd: repoRoot, stdio: "pipe", }); + stampProgramFixture(src, out, contract); } return out; } diff --git a/host/tsup.config.ts b/host/tsup.config.ts index 94d96015bb..05033040f8 100644 --- a/host/tsup.config.ts +++ b/host/tsup.config.ts @@ -1,4 +1,10 @@ import { defineConfig } from "tsup"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { hostBuildFingerprintBanner } from "./src/compiled-worker-entry"; + +const hostRoot = dirname(fileURLToPath(import.meta.url)); export default defineConfig({ entry: [ @@ -19,8 +25,7 @@ export default defineConfig({ clean: true, target: "es2022", splitting: false, - // Exact ABI-staging runtime artifacts execute outside this checkout. Keep - // production host dependencies inside the inventory-bound bundles so Node - // cannot resolve an ambient node_modules tree. - noExternal: ["fflate", "fzstd"], + banner: { + js: hostBuildFingerprintBanner(hostRoot), + }, }); diff --git a/libc/glue/abi_constants.h b/libc/glue/abi_constants.h index 8adf6c8cae..de8481c51c 100644 --- a/libc/glue/abi_constants.h +++ b/libc/glue/abi_constants.h @@ -3,13 +3,347 @@ #ifndef WASM_POSIX_ABI_CONSTANTS_H #define WASM_POSIX_ABI_CONSTANTS_H +#include + /* Mirrors wasm_posix_shared::ABI_VERSION. */ #define WASM_POSIX_ABI_VERSION 43u +/* Non-forking spawn syscall number. */ +#define WASM_POSIX_SYS_SPAWN 500u + /* Default process-wasm pthread slot declaration. */ #define WASM_POSIX_THREAD_SLOT_DECL_DEFAULT -1 /* Fixed kernel/musl resource-usage wire record size. */ #define WASM_POSIX_RUSAGE_WIRE_SIZE 144u +/* Exact musl termios wire record size. */ +#define WASM_POSIX_TERMIOS_SIZE 60u + +/* Shared syscall-channel status values. */ +#define WASM_POSIX_CHANNEL_STATUS_IDLE 0u +#define WASM_POSIX_CHANNEL_STATUS_PENDING 1u +#define WASM_POSIX_CHANNEL_STATUS_COMPLETE 2u +#define WASM_POSIX_CHANNEL_STATUS_ERROR 3u + +/* Shared syscall-channel layout. */ +#define WASM_POSIX_CHANNEL_STATUS_OFFSET 0u +#define WASM_POSIX_CHANNEL_STATUS_SIZE 4u +#define WASM_POSIX_CHANNEL_SYSCALL_OFFSET 4u +#define WASM_POSIX_CHANNEL_SYSCALL_SIZE 4u +#define WASM_POSIX_CHANNEL_ARGS_OFFSET 8u +#define WASM_POSIX_CHANNEL_ARGS_COUNT 6u +#define WASM_POSIX_CHANNEL_ARG_SIZE 8u +#define WASM_POSIX_CHANNEL_RETURN_OFFSET 56u +#define WASM_POSIX_CHANNEL_RETURN_SIZE 8u +#define WASM_POSIX_CHANNEL_ERRNO_OFFSET 64u +#define WASM_POSIX_CHANNEL_ERRNO_SIZE 4u +#define WASM_POSIX_CHANNEL_REQUEST_FLAGS_OFFSET 68u +#define WASM_POSIX_CHANNEL_REQUEST_FLAGS_SIZE 4u +#define WASM_POSIX_CHANNEL_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY 1u +#define WASM_POSIX_CHANNEL_DATA_OFFSET 72u +#define WASM_POSIX_CHANNEL_DATA_SIZE 65536u +#define WASM_POSIX_CHANNEL_HEADER_SIZE 72u +#define WASM_POSIX_CHANNEL_MIN_SIZE 65608u + +/* Signal-delivery wire at the end of the channel data buffer. */ +#define WASM_POSIX_CHANNEL_SIG_AREA_SIZE 56u +#define WASM_POSIX_CHANNEL_SIG_DELIVERY_SIZE 56u +#define WASM_POSIX_CHANNEL_SIG_WORD_BYTES 4u +#define WASM_POSIX_CHANNEL_SIG_SI_VALUE_BYTES 8u +#define WASM_POSIX_CHANNEL_SIG_OLD_MASK_BYTES 8u +#define WASM_POSIX_CHANNEL_SIG_ALT_SP_BYTES 8u +#define WASM_POSIX_CHANNEL_SIG_ALT_SIZE_BYTES 8u +#define WASM_POSIX_CHANNEL_SIG_BASE_OFFSET 65552u +#define WASM_POSIX_CHANNEL_SIG_SIGNUM_OFFSET 65552u +#define WASM_POSIX_CHANNEL_SIG_HANDLER_OFFSET 65556u +#define WASM_POSIX_CHANNEL_SIG_FLAGS_OFFSET 65560u +#define WASM_POSIX_CHANNEL_SIG_SI_VALUE_OFFSET 65564u +#define WASM_POSIX_CHANNEL_SIG_OLD_MASK_OFFSET 65572u +#define WASM_POSIX_CHANNEL_SIG_SI_CODE_OFFSET 65580u +#define WASM_POSIX_CHANNEL_SIGINFO_WORD_1_OFFSET 65584u +#define WASM_POSIX_CHANNEL_SIGINFO_WORD_2_OFFSET 65588u +#define WASM_POSIX_CHANNEL_SIG_ALT_SP_OFFSET 65592u +#define WASM_POSIX_CHANNEL_SIG_ALT_SIZE_OFFSET 65600u + +/* A known request without a lossless layout for this caller. */ +#define WASM_POSIX_IOCTL_UNSUPPORTED_SIZE UINT32_MAX + +static inline uint32_t +wasm_posix_ioctl_arg_size(uint32_t request, uint32_t pointer_width) +{ +switch (request) { + case 0x00000040u: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00000041u: +return pointer_width == 4u ? 0u : +pointer_width == 8u ? 0u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00000042u: +return pointer_width == 4u ? 16u : +pointer_width == 8u ? 16u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00000043u: +return pointer_width == 4u ? 0u : +pointer_width == 8u ? 0u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00000044u: +return pointer_width == 4u ? 32u : +pointer_width == 8u ? 32u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00000045u: +return pointer_width == 4u ? 0u : +pointer_width == 8u ? 0u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00000046u: +return pointer_width == 4u ? 0u : +pointer_width == 8u ? 0u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00000047u: +return pointer_width == 4u ? 8u : +pointer_width == 8u ? 8u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00000048u: +return pointer_width == 4u ? 0u : +pointer_width == 8u ? 0u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00000049u: +return pointer_width == 4u ? 24u : +pointer_width == 8u ? WASM_POSIX_IOCTL_UNSUPPORTED_SIZE : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00004600u: +return pointer_width == 4u ? 160u : +pointer_width == 8u ? 160u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00004601u: +return pointer_width == 4u ? 160u : +pointer_width == 8u ? 160u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00004602u: +return pointer_width == 4u ? 80u : +pointer_width == 8u ? 80u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00004606u: +return pointer_width == 4u ? 160u : +pointer_width == 8u ? 160u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00004b33u: +return pointer_width == 4u ? 1u : +pointer_width == 8u ? 1u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00004b44u: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00004b45u: +return pointer_width == 4u ? 0u : +pointer_width == 8u ? 0u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00005000u: +return pointer_width == 4u ? 0u : +pointer_width == 8u ? 0u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00005001u: +return pointer_width == 4u ? 0u : +pointer_width == 8u ? 0u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00005401u: +return pointer_width == 4u ? 60u : +pointer_width == 8u ? 60u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00005402u: +return pointer_width == 4u ? 60u : +pointer_width == 8u ? 60u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00005403u: +return pointer_width == 4u ? 60u : +pointer_width == 8u ? 60u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00005404u: +return pointer_width == 4u ? 60u : +pointer_width == 8u ? 60u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00005409u: +return pointer_width == 4u ? 0u : +pointer_width == 8u ? 0u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x0000540au: +return pointer_width == 4u ? 0u : +pointer_width == 8u ? 0u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x0000540bu: +return pointer_width == 4u ? 0u : +pointer_width == 8u ? 0u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x0000540eu: +return pointer_width == 4u ? 0u : +pointer_width == 8u ? 0u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x0000540fu: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00005410u: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00005413u: +return pointer_width == 4u ? 8u : +pointer_width == 8u ? 8u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00005414u: +return pointer_width == 4u ? 8u : +pointer_width == 8u ? 8u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x0000541bu: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00005421u: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00005422u: +return pointer_width == 4u ? 0u : +pointer_width == 8u ? 0u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00005429u: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00005450u: +return pointer_width == 4u ? 0u : +pointer_width == 8u ? 0u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00005451u: +return pointer_width == 4u ? 0u : +pointer_width == 8u ? 0u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00005452u: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x0000641eu: +return pointer_width == 4u ? 0u : +pointer_width == 8u ? 0u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x0000641fu: +return pointer_width == 4u ? 0u : +pointer_width == 8u ? 0u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00008905u: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x40045431u: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x40086409u: +return pointer_width == 4u ? 8u : +pointer_width == 8u ? 8u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x8004500bu: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x80045430u: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0xc0045002u: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0xc0045003u: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0xc0045005u: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0xc0045006u: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0xc004500au: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0xc00464afu: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0xc00464b4u: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0xc00c642du: +return pointer_width == 4u ? 12u : +pointer_width == 8u ? 12u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0xc00c642eu: +return pointer_width == 4u ? 12u : +pointer_width == 8u ? 12u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0xc010640cu: +return pointer_width == 4u ? 16u : +pointer_width == 8u ? 16u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0xc010643au: +return pointer_width == 4u ? 16u : +pointer_width == 8u ? 16u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0xc01064b3u: +return pointer_width == 4u ? 16u : +pointer_width == 8u ? 16u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0xc01464a6u: +return pointer_width == 4u ? 20u : +pointer_width == 8u ? 20u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0xc01864b0u: +return pointer_width == 4u ? 24u : +pointer_width == 8u ? 24u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0xc02064b2u: +return pointer_width == 4u ? 32u : +pointer_width == 8u ? 32u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0xc0246400u: +return pointer_width == 4u ? 36u : +pointer_width == 8u ? WASM_POSIX_IOCTL_UNSUPPORTED_SIZE : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0xc0406400u: +return pointer_width == 4u ? WASM_POSIX_IOCTL_UNSUPPORTED_SIZE : +pointer_width == 8u ? 64u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0xc04064a0u: +return pointer_width == 4u ? 64u : +pointer_width == 8u ? 64u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0xc05064a7u: +return pointer_width == 4u ? 80u : +pointer_width == 8u ? 80u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0xc06864a1u: +return pointer_width == 4u ? 104u : +pointer_width == 8u ? 104u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0xc06864a2u: +return pointer_width == 4u ? 104u : +pointer_width == 8u ? 104u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0xc06864b8u: +return pointer_width == 4u ? 104u : +pointer_width == 8u ? 104u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + default: +return 0u; +} +} + #endif /* WASM_POSIX_ABI_CONSTANTS_H */ diff --git a/libc/glue/channel_syscall.c b/libc/glue/channel_syscall.c index 34da18261f..e0885d1b99 100644 --- a/libc/glue/channel_syscall.c +++ b/libc/glue/channel_syscall.c @@ -5,15 +5,8 @@ * number and arguments to a shared-memory channel, notifies the kernel * worker, and blocks until the result is ready. * - * The channel layout matches wasm_posix_shared::channel: - * Offset Size Field - * 0 4B status (IDLE=0, PENDING=1, COMPLETE=2, ERROR=3) - * 4 4B syscall number - * 8 48B arguments (6 x i64) - * 56 8B return value (i64) - * 64 4B errno (i32) - * 68 4B request flags - * 72 64KB data transfer buffer + * The exact channel status values, layout, and signal-delivery slots come + * from wasm_posix_shared through the generated abi_constants.h header. * * Each thread has its own channel region within the process's shared * WebAssembly.Memory. The base address is stored in __channel_base, @@ -23,7 +16,10 @@ * User programs compiled with this glue have zero kernel imports. */ +#include #include +#include +#include #include "abi_constants.h" #ifdef __cplusplus @@ -72,38 +68,43 @@ int __wasm_posix_thread_slots(void) { int *__errno_location(void); #define errno (*__errno_location()) -/* Channel status values */ -#define CH_IDLE 0 -#define CH_PENDING 1 -#define CH_COMPLETE 2 -#define CH_ERROR 3 - -/* Channel layout offsets */ -#define CH_STATUS 0 -#define CH_SYSCALL 4 -#define CH_ARGS 8 -#define CH_ARG_SIZE 8 -#define CH_RETURN 56 -#define CH_ERRNO 64 -#define CH_REQUEST_FLAGS 68 -#define CH_DATA 72 -#define CH_DATA_SIZE 65536 - -/* Signal delivery area — last 48 bytes of data buffer */ -#define CH_SIG_BASE (CH_DATA + CH_DATA_SIZE - 48) -#define CH_SIG_SIGNUM (CH_SIG_BASE) -#define CH_SIG_HANDLER (CH_SIG_BASE + 4) -#define CH_SIG_FLAGS (CH_SIG_BASE + 8) -#define CH_SIG_SI_VALUE (CH_SIG_BASE + 12) -#define CH_SIG_OLD_MASK (CH_SIG_BASE + 16) -#define CH_SIG_SI_CODE (CH_SIG_BASE + 24) -#define CH_SIG_SI_PID (CH_SIG_BASE + 28) -#define CH_SIG_SI_UID (CH_SIG_BASE + 32) -#define CH_SIG_ALT_SP (CH_SIG_BASE + 36) -#define CH_SIG_ALT_SIZE (CH_SIG_BASE + 40) - -#define SA_SIGINFO 4 -#define SA_RESTART 0x10000000u +/* Short aliases retain the glue's readable field names without owning values. */ +#define CH_IDLE WASM_POSIX_CHANNEL_STATUS_IDLE +#define CH_PENDING WASM_POSIX_CHANNEL_STATUS_PENDING +#define CH_STATUS WASM_POSIX_CHANNEL_STATUS_OFFSET +#define CH_SYSCALL WASM_POSIX_CHANNEL_SYSCALL_OFFSET +#define CH_ARGS WASM_POSIX_CHANNEL_ARGS_OFFSET +#define CH_ARG_SIZE WASM_POSIX_CHANNEL_ARG_SIZE +#define CH_RETURN WASM_POSIX_CHANNEL_RETURN_OFFSET +#define CH_ERRNO WASM_POSIX_CHANNEL_ERRNO_OFFSET +#define CH_REQUEST_FLAGS WASM_POSIX_CHANNEL_REQUEST_FLAGS_OFFSET +#define CH_SIG_SIGNUM WASM_POSIX_CHANNEL_SIG_SIGNUM_OFFSET +#define CH_SIG_HANDLER WASM_POSIX_CHANNEL_SIG_HANDLER_OFFSET +#define CH_SIG_FLAGS WASM_POSIX_CHANNEL_SIG_FLAGS_OFFSET +#define CH_SIG_SI_VALUE WASM_POSIX_CHANNEL_SIG_SI_VALUE_OFFSET +#define CH_SIG_OLD_MASK WASM_POSIX_CHANNEL_SIG_OLD_MASK_OFFSET +#define CH_SIG_SI_CODE WASM_POSIX_CHANNEL_SIG_SI_CODE_OFFSET +#define CH_SIGINFO_WORD_1 WASM_POSIX_CHANNEL_SIGINFO_WORD_1_OFFSET +#define CH_SIGINFO_WORD_2 WASM_POSIX_CHANNEL_SIGINFO_WORD_2_OFFSET +#define CH_SIG_ALT_SP WASM_POSIX_CHANNEL_SIG_ALT_SP_OFFSET +#define CH_SIG_ALT_SIZE WASM_POSIX_CHANNEL_SIG_ALT_SIZE_OFFSET + +_Static_assert(WASM_POSIX_CHANNEL_ARGS_COUNT == 6u, + "channel syscall glue requires six argument slots"); +_Static_assert(WASM_POSIX_CHANNEL_SIG_DELIVERY_SIZE + <= WASM_POSIX_CHANNEL_SIG_AREA_SIZE, + "signal delivery wire must fit its reserved channel area"); +_Static_assert(sizeof(uint32_t) == WASM_POSIX_CHANNEL_SIG_WORD_BYTES, + "signal delivery word width must match generated ABI"); +_Static_assert(sizeof(uint64_t) == WASM_POSIX_CHANNEL_SIG_SI_VALUE_BYTES, + "signal delivery sigval width must match generated ABI"); +_Static_assert(sizeof(uint64_t) == WASM_POSIX_CHANNEL_SIG_OLD_MASK_BYTES, + "signal delivery mask width must match generated ABI"); +_Static_assert(sizeof(uint64_t) == WASM_POSIX_CHANNEL_SIG_ALT_SP_BYTES, + "signal delivery alt-stack pointer width must match generated ABI"); +_Static_assert(sizeof(uint64_t) == WASM_POSIX_CHANNEL_SIG_ALT_SIZE_BYTES, + "signal delivery alt-stack size width must match generated ABI"); + #define EFAULT 14 #define EINTR 4 #define EINVAL 22 @@ -114,7 +115,6 @@ int *__errno_location(void); #define SYS_WAITID 288 #define SYS_SIGPROCMASK 37 #define SYS_RT_SIGRETURN 208 -#define SIG_SETMASK 2 /* The kernel ABI deliberately keeps sigaction's transport record fixed at * 16 bytes: u32 table index, u32 flags, u64 mask. musl's internal @@ -129,6 +129,55 @@ struct kandelo_sigaction_wire { _Static_assert(sizeof(struct kandelo_sigaction_wire) == 16, "sigaction wire record must stay 16 bytes"); +#if __SIZEOF_POINTER__ == 8 +#define KANDELO_NATIVE_SIGINFO_SIZE KANDELO_PROCESS_SIGINFO_WASM64_SIZE +#define KANDELO_NATIVE_SIGINFO_PID_OFFSET \ + KANDELO_PROCESS_SIGINFO_WASM64_PID_OFFSET +#define KANDELO_NATIVE_SIGINFO_UID_OFFSET \ + KANDELO_PROCESS_SIGINFO_WASM64_UID_OFFSET +#define KANDELO_NATIVE_SIGINFO_VALUE_OFFSET \ + KANDELO_PROCESS_SIGINFO_WASM64_VALUE_OFFSET +#define KANDELO_NATIVE_SIGINFO_VALUE_SIZE \ + KANDELO_PROCESS_SIGINFO_WASM64_VALUE_SIZE +#else +#define KANDELO_NATIVE_SIGINFO_SIZE KANDELO_PROCESS_SIGINFO_WASM32_SIZE +#define KANDELO_NATIVE_SIGINFO_PID_OFFSET \ + KANDELO_PROCESS_SIGINFO_WASM32_PID_OFFSET +#define KANDELO_NATIVE_SIGINFO_UID_OFFSET \ + KANDELO_PROCESS_SIGINFO_WASM32_UID_OFFSET +#define KANDELO_NATIVE_SIGINFO_VALUE_OFFSET \ + KANDELO_PROCESS_SIGINFO_WASM32_VALUE_OFFSET +#define KANDELO_NATIVE_SIGINFO_VALUE_SIZE \ + KANDELO_PROCESS_SIGINFO_WASM32_VALUE_SIZE +#endif + +_Static_assert(sizeof(siginfo_t) == KANDELO_NATIVE_SIGINFO_SIZE, + "generated siginfo_t size must match musl"); +_Static_assert(offsetof(siginfo_t, si_signo) + == KANDELO_PROCESS_SIGINFO_SIGNO_OFFSET, + "generated siginfo_t signo offset must match musl"); +_Static_assert(offsetof(siginfo_t, si_errno) + == KANDELO_PROCESS_SIGINFO_ERRNO_OFFSET, + "generated siginfo_t errno offset must match musl"); +_Static_assert(offsetof(siginfo_t, si_code) + == KANDELO_PROCESS_SIGINFO_CODE_OFFSET, + "generated siginfo_t code offset must match musl"); +_Static_assert(offsetof(siginfo_t, si_pid) == KANDELO_NATIVE_SIGINFO_PID_OFFSET, + "generated siginfo_t pid offset must match musl"); +_Static_assert(offsetof(siginfo_t, si_uid) == KANDELO_NATIVE_SIGINFO_UID_OFFSET, + "generated siginfo_t uid offset must match musl"); +_Static_assert(offsetof(siginfo_t, si_value) + == KANDELO_NATIVE_SIGINFO_VALUE_OFFSET, + "generated siginfo_t value offset must match musl"); +_Static_assert(sizeof(union sigval) == KANDELO_NATIVE_SIGINFO_VALUE_SIZE, + "generated siginfo_t value width must match musl"); +_Static_assert(offsetof(siginfo_t, si_timerid) + == KANDELO_NATIVE_SIGINFO_PID_OFFSET, + "generated siginfo_t timer ID offset must match musl"); +_Static_assert(offsetof(siginfo_t, si_overrun) + == KANDELO_NATIVE_SIGINFO_UID_OFFSET, + "generated siginfo_t timer overrun offset must match musl"); + /* Per-thread channel base address. * * Stored as an imported WebAssembly global — each wasm instance (thread) @@ -176,7 +225,6 @@ uintptr_t __get_channel_base_addr(void) { * in browser web workers. */ #define SYS_FORK 212 #define SYS_VFORK 213 -#define SYS_SPAWN 500 /* non-forking posix_spawn — see docs/plans/2026-05-04-non-forking-posix-spawn-design.md */ __attribute__((import_module("kernel"), import_name("kernel_fork"))) int32_t kernel_fork(void); @@ -310,25 +358,43 @@ static uint32_t __deliver_pending_signal(uintptr_t base, int *delivered) * host terminate() can reclaim its thread + memory. Without this, each image * switch leaks a machine's worth of un-killable worker threads and Safari * OOMs. */ - if (signum == 9 /* SIGKILL */) { + if (signum == SIGKILL) { extern _Noreturn void kernel_exit(int32_t status) __attribute__((import_module("kernel"), import_name("kernel_exit"))); - kernel_exit(128 + 9); + kernel_exit(128 + SIGKILL); } uint32_t handler = *sig_handler_ptr; uint32_t flags = *sig_flags_ptr; - /* Read saved old blocked mask (8 bytes at CH_SIG_OLD_MASK) */ + /* Read the saved old blocked mask from its generated channel slot. */ uint64_t old_mask; - __builtin_memcpy(&old_mask, (void *)(uintptr_t)(base + CH_SIG_OLD_MASK), 8); + __builtin_memcpy(&old_mask, + (void *)(uintptr_t)(base + CH_SIG_OLD_MASK), + sizeof(old_mask)); /* Read alt stack info — non-zero alt_sp means we need to switch * the wasm shadow stack (__stack_pointer) to the alt stack buffer * before calling the handler. This makes &local_var land inside * the alt stack range, matching real sigaltstack behavior. */ - uint32_t alt_sp = *(uint32_t *)(uintptr_t)(base + CH_SIG_ALT_SP); - uint32_t alt_size = *(uint32_t *)(uintptr_t)(base + CH_SIG_ALT_SIZE); + uint64_t alt_sp_wire; + uint64_t alt_size_wire; + __builtin_memcpy(&alt_sp_wire, + (void *)(uintptr_t)(base + CH_SIG_ALT_SP), + sizeof(alt_sp_wire)); + __builtin_memcpy(&alt_size_wire, + (void *)(uintptr_t)(base + CH_SIG_ALT_SIZE), + sizeof(alt_size_wire)); + if (alt_sp_wire > UINTPTR_MAX || + alt_size_wire > SIZE_MAX || + alt_sp_wire > UINTPTR_MAX - alt_size_wire) { + /* The kernel validates this range before storing sigaltstack state. + * Reaching this branch means the shared ABI was violated; trapping is + * safer than wrapping the process shadow-stack pointer. */ + __builtin_trap(); + } + uintptr_t alt_sp = (uintptr_t)alt_sp_wire; + size_t alt_size = (size_t)alt_size_wire; /* Clear signal delivery area before calling handler */ *sig_signum_ptr = 0; @@ -350,25 +416,40 @@ static uint32_t __deliver_pending_signal(uintptr_t base, int *delivered) * handler_index to a function pointer and calling it uses * call_indirect, which looks up the indirect function table. */ if (flags & SA_SIGINFO) { - /* Build a minimal siginfo_t on the stack for SA_SIGINFO handlers */ - int32_t si_value_int = *(int32_t *)(uintptr_t)(base + CH_SIG_SI_VALUE); - int32_t si_code = *(int32_t *)(uintptr_t)(base + CH_SIG_SI_CODE); - int32_t si_pid = *(int32_t *)(uintptr_t)(base + CH_SIG_SI_PID); - int32_t si_uid = *(int32_t *)(uintptr_t)(base + CH_SIG_SI_UID); - /* siginfo_t's payload union aligns to long: offset 12 on wasm32 and - * 16 on wasm64. pid/uid occupy its first pair and si_value/si_status - * occupies the following union member. */ - const uint32_t fields_offset = __SIZEOF_POINTER__ == 8 ? 16 : 12; - char siginfo_buf[128]; - __builtin_memset(siginfo_buf, 0, sizeof(siginfo_buf)); - *(int *)(siginfo_buf + 0) = (int)signum; /* si_signo */ - *(int *)(siginfo_buf + 8) = si_code; /* si_code */ - *(int *)(siginfo_buf + fields_offset) = si_pid; /* si_pid */ - *(int *)(siginfo_buf + fields_offset + 4) = si_uid; /* si_uid */ - *(int *)(siginfo_buf + fields_offset + 8) = si_value_int; - void (*sa)(int, void *, void *) = - (void (*)(int, void *, void *))(uintptr_t)handler; - sa((int)signum, (void *)siginfo_buf, (void *)0); + /* Build the native musl type so its compiler-owned alignment and + * effective type cannot drift from the generated layout assertions. */ + uint64_t si_value_bits; + __builtin_memcpy(&si_value_bits, + (void *)(uintptr_t)(base + CH_SIG_SI_VALUE), + sizeof(si_value_bits)); + int32_t si_code = + *(int32_t *)(uintptr_t)(base + CH_SIG_SI_CODE); + int32_t siginfo_word_1 = + *(int32_t *)(uintptr_t)(base + CH_SIGINFO_WORD_1); + int32_t siginfo_word_2 = + *(int32_t *)(uintptr_t)(base + CH_SIGINFO_WORD_2); + siginfo_t info; + __builtin_memset(&info, 0, sizeof(info)); + info.si_signo = (int)signum; + info.si_code = si_code; + if (si_code == SI_TIMER) { + info.si_timerid = siginfo_word_1; + info.si_overrun = siginfo_word_2; + } else { + info.si_pid = (pid_t)siginfo_word_1; + info.si_uid = (uid_t)(uint32_t)siginfo_word_2; + } + /* + * WHY: union sigval is pointer-width native data. Copying its raw + * bytes preserves both sival_int's low 32 bits and a wasm64 + * sival_ptr without selecting the wrong union member. In a mixed + * wasm32/wasm64 machine, copying the native union width deliberately + * gives a wasm32 recipient the low 32 bits. + */ + __builtin_memcpy(&info.si_value, &si_value_bits, sizeof(info.si_value)); + void (*sa)(int, siginfo_t *, void *) = + (void (*)(int, siginfo_t *, void *))(uintptr_t)handler; + sa((int)signum, &info, (void *)0); } else { void (*sa)(int) = (void (*)(int))(uintptr_t)handler; sa((int)signum); diff --git a/libc/glue/syscall_glue.c b/libc/glue/syscall_glue.c index fbc5dd7213..43a86641ac 100644 --- a/libc/glue/syscall_glue.c +++ b/libc/glue/syscall_glue.c @@ -320,10 +320,6 @@ #define FCNTL_F_SETLK 13 #define FCNTL_F_SETLKW 14 -/* Buffer size hints for ioctl/termios where kernel needs a length */ -#define IOCTL_BUF_SIZE 256 -#define TERMIOS_BUF_SIZE 256 - /* mmap2 page unit — musl divides the byte offset by this before syscall */ #define MMAP2_UNIT 4096U @@ -734,48 +730,18 @@ static long __do_syscall(long n, long a1, long a2, long a3, case SYS_SIGNAL: return (long)kernel_signal((uint32_t)a1, (uint32_t)a2); - /* sigaltstack — (ss, old_ss) - * Store/retrieve alternate signal stack info. - * Note: Wasm cannot truly use alternate stacks, but we track the state - * so sigaltstack queries work and programs don't get ENOSYS. - * struct stack_t { void *ss_sp; int ss_flags; size_t ss_size; } = 12 bytes. */ - case SYS_SIGALTSTACK: { - static uint32_t alt_sp = 0; - static int32_t alt_flags = 2; /* SS_DISABLE initially */ - static uint32_t alt_size = 0; - - const uint32_t *ss_new = (const uint32_t *)(uintptr_t)a1; - uint32_t *ss_old = (uint32_t *)(uintptr_t)a2; - - /* Write old value first */ - if (ss_old) { - ss_old[0] = alt_sp; - ss_old[1] = (uint32_t)alt_flags; - ss_old[2] = alt_size; - } - - /* Set new value */ - if (ss_new) { - int32_t flags = (int32_t)ss_new[1]; - uint32_t size = ss_new[2]; - - /* Validate flags — only SS_DISABLE (2) and 0 are valid */ - if (flags & ~(0x2 | 0x1)) /* ~(SS_DISABLE | SS_ONSTACK) */ - return -22; /* -EINVAL */ - - if (!(flags & 0x2)) { /* not SS_DISABLE */ - /* Check minimum size */ - if (size < 2048) /* MINSIGSTKSZ */ - return -12; /* -ENOMEM */ - } - - alt_sp = ss_new[0]; - alt_flags = flags; - alt_size = size; - } - - return 0; - } + /* + * sigaltstack — (ss, old_ss) + * + * WHY: stack_t contains a pointer and size_t, so parsing it as three + * uint32_t values truncates wasm64 callers. Keep the kernel as the one + * owner of signal-stack state and pass the caller's data model explicitly. + */ + case SYS_SIGALTSTACK: + return (long)kernel_sigaltstack( + (const uint8_t *)(uintptr_t)a1, + (uint8_t *)(uintptr_t)a2, + (int64_t)sizeof(void *)); /* rt_sigsuspend — (set_ptr, sigsetsize) * set_ptr points to unsigned long[2] signal mask */ @@ -830,25 +796,25 @@ static long __do_syscall(long n, long a1, long a2, long a3, case SYS_ISATTY: return (long)kernel_isatty((int32_t)a1); - /* tcgetattr — (fd, termios_ptr) - * kernel needs buf_len; provide generous hint */ + /* tcgetattr — (fd, termios_ptr), exact musl layout */ case SYS_TCGETATTR: return (long)kernel_tcgetattr((int32_t)a1, (uint8_t *)(uintptr_t)a2, - TERMIOS_BUF_SIZE); + WASM_POSIX_TERMIOS_SIZE); /* tcsetattr — (fd, action, termios_ptr) */ case SYS_TCSETATTR: return (long)kernel_tcsetattr((int32_t)a1, (uint32_t)a2, (const uint8_t *)(uintptr_t)a3, - TERMIOS_BUF_SIZE); + WASM_POSIX_TERMIOS_SIZE); - /* ioctl — (fd, request, arg_ptr) - * kernel needs buf_len; provide generous hint */ + /* ioctl — (fd, request, request-specific scalar/pointer argument) */ case SYS_IOCTL: return (long)kernel_ioctl((int32_t)a1, (uint32_t)a2, (uint8_t *)(uintptr_t)a3, - IOCTL_BUF_SIZE); + wasm_posix_ioctl_arg_size( + (uint32_t)a2, sizeof(void *)), + sizeof(void *)); /* ============================================================== */ /* Environment */ @@ -1319,14 +1285,16 @@ static long __do_syscall(long n, long a1, long a2, long a3, const char *p = (const char *)(uintptr_t)a1; uint8_t *buf = a3 ? (uint8_t *)(uintptr_t)a3 : (uint8_t *)(uintptr_t)a2; - return (long)kernel_statfs((const uint8_t *)p, slen(p), buf); + return (long)kernel_statfs((const uint8_t *)p, slen(p), buf, + (int64_t)sizeof(void *)); } /* fstatfs / fstatfs64 — same 3-arg pattern: (fd, sizeof buf, buf) */ case SYS_FSTATFS: { uint8_t *buf = a3 ? (uint8_t *)(uintptr_t)a3 : (uint8_t *)(uintptr_t)a2; - return (long)kernel_fstatfs((int32_t)a1, buf); + return (long)kernel_fstatfs((int32_t)a1, buf, + (int64_t)sizeof(void *)); } /* ============================================================== */ @@ -1355,8 +1323,12 @@ static long __do_syscall(long n, long a1, long a2, long a3, /* getgroups — (size, list_ptr) */ case SYS_GETGROUPS: - return (long)kernel_getgroups((uint32_t)a1, - (uint32_t *)(uintptr_t)a2); + if (a1 < 0) + return -22; /* EINVAL */ + return (long)kernel_getgroups( + (uint32_t)a1, + (uint32_t *)(uintptr_t)a2, + a1 > 0 ? (uint32_t)sizeof(uint32_t) : 0); /* setgroups — (size, list_ptr) */ case SYS_SETGROUPS: @@ -1553,11 +1525,13 @@ static long __do_syscall(long n, long a1, long a2, long a3, case SYS_SETITIMER: return (long)kernel_setitimer((uint32_t)a1, (const uint8_t *)(uintptr_t)a2, - (uint8_t *)(uintptr_t)a3); + (uint8_t *)(uintptr_t)a3, + (int64_t)sizeof(void *)); case SYS_GETITIMER: return (long)kernel_getitimer((uint32_t)a1, - (uint8_t *)(uintptr_t)a2); + (uint8_t *)(uintptr_t)a2, + (int64_t)sizeof(void *)); /* ============================================================== */ /* rt_sigtimedwait — wait for signal from set */ diff --git a/libc/glue/syscall_imports.h b/libc/glue/syscall_imports.h index 3f4f722ed6..c633284996 100644 --- a/libc/glue/syscall_imports.h +++ b/libc/glue/syscall_imports.h @@ -245,15 +245,20 @@ int32_t kernel_signal(uint32_t signum, uint32_t handler); KERNEL_IMPORT(kernel_sigprocmask) int64_t kernel_sigprocmask(uint32_t how, uint32_t set_lo, uint32_t set_hi); +KERNEL_IMPORT(kernel_sigaltstack) +int32_t kernel_sigaltstack(const uint8_t *ss_ptr, uint8_t *old_ss_ptr, + int64_t process_pointer_width); + KERNEL_IMPORT(kernel_alarm) int32_t kernel_alarm(uint32_t seconds); KERNEL_IMPORT(kernel_setitimer) int32_t kernel_setitimer(uint32_t which, const uint8_t *new_ptr, - uint8_t *old_ptr); + uint8_t *old_ptr, int64_t process_pointer_width); KERNEL_IMPORT(kernel_getitimer) -int32_t kernel_getitimer(uint32_t which, uint8_t *curr_ptr); +int32_t kernel_getitimer(uint32_t which, uint8_t *curr_ptr, + int64_t process_pointer_width); KERNEL_IMPORT(kernel_sigsuspend) int32_t kernel_sigsuspend(uint32_t mask_lo, uint32_t mask_hi); @@ -329,7 +334,7 @@ int32_t kernel_tcsetattr(int32_t fd, uint32_t action, const uint8_t *buf_ptr, KERNEL_IMPORT(kernel_ioctl) int32_t kernel_ioctl(int32_t fd, uint32_t request, uint8_t *buf_ptr, - uint32_t buf_len); + uint32_t buf_len, uint32_t process_pointer_width); /* ------------------------------------------------------------------ */ /* Memory */ @@ -683,10 +688,11 @@ int32_t kernel_madvise(uint32_t addr, uint32_t len, uint32_t advice); KERNEL_IMPORT(kernel_statfs) int32_t kernel_statfs(const uint8_t *path_ptr, uint32_t path_len, - uint8_t *buf_ptr); + uint8_t *buf_ptr, int64_t process_pointer_width); KERNEL_IMPORT(kernel_fstatfs) -int32_t kernel_fstatfs(int32_t fd, uint8_t *buf_ptr); +int32_t kernel_fstatfs(int32_t fd, uint8_t *buf_ptr, + int64_t process_pointer_width); /* ------------------------------------------------------------------ */ /* Identity (res* variants) */ @@ -707,7 +713,8 @@ int32_t kernel_getresgid(uint32_t *rgid_ptr, uint32_t *egid_ptr, uint32_t *sgid_ptr); KERNEL_IMPORT(kernel_getgroups) -int32_t kernel_getgroups(uint32_t size, uint32_t *list_ptr); +int32_t kernel_getgroups(uint32_t size, uint32_t *list_ptr, + uint32_t list_capacity_bytes); KERNEL_IMPORT(kernel_setgroups) int32_t kernel_setgroups(uint32_t size, const uint32_t *list_ptr); diff --git a/libc/musl-overlay/arch/wasm32posix/bits/stat.h b/libc/musl-overlay/arch/wasm32posix/bits/stat.h index de94cd332d..eba06084a4 100644 --- a/libc/musl-overlay/arch/wasm32posix/bits/stat.h +++ b/libc/musl-overlay/arch/wasm32posix/bits/stat.h @@ -1,11 +1,11 @@ /* bits/stat.h — wasm32posix struct stat * - * The kernel's WasmStat writes the first 88 bytes of this structure - * (through st_ctim). The remaining fields (st_rdev, st_blksize, - * st_blocks) are populated by musl's fstatat conversion logic or - * remain zero. + * The kernel writes a complete 112-byte native kstat and musl converts it to + * this same-sized public record. The first 88 bytes carry WasmStat's + * filesystem metadata and the final three fields are initialized explicitly, + * even when the filesystem does not yet provide them. * - * Field layout through st_ctim MUST match crates/shared/src/lib.rs. + * The complete layout MUST match crates/shared/src/process_layout.rs. */ struct stat { @@ -19,13 +19,13 @@ struct stat { struct timespec st_atim; /* offset 40 (16 bytes on wasm32) */ struct timespec st_mtim; /* offset 56 (16 bytes) */ struct timespec st_ctim; /* offset 72 (16 bytes) */ - /* --- end of kernel WasmStat (88 bytes) --- */ + /* --- end of the kernel's internal WasmStat prefix (88 bytes) --- */ unsigned long long st_rdev; /* offset 88 */ int st_blksize; /* offset 96 */ - long long st_blocks; /* offset 100 (pad to 104? or 108) */ + long long st_blocks; /* offset 104 */ }; -/* Key kernel-layout offsets must still match */ +_Static_assert(sizeof(struct stat) == 112, "struct stat size mismatch"); _Static_assert(__builtin_offsetof(struct stat, st_size) == 32, "st_size offset mismatch"); _Static_assert(__builtin_offsetof(struct stat, st_atim) == 40, @@ -34,3 +34,9 @@ _Static_assert(__builtin_offsetof(struct stat, st_mtim) == 56, "st_mtim offset mismatch"); _Static_assert(__builtin_offsetof(struct stat, st_ctim) == 72, "st_ctim offset mismatch"); +_Static_assert(__builtin_offsetof(struct stat, st_rdev) == 88, + "st_rdev offset mismatch"); +_Static_assert(__builtin_offsetof(struct stat, st_blksize) == 96, + "st_blksize offset mismatch"); +_Static_assert(__builtin_offsetof(struct stat, st_blocks) == 104, + "st_blocks offset mismatch"); diff --git a/libc/musl-overlay/arch/wasm32posix/kstat.h b/libc/musl-overlay/arch/wasm32posix/kstat.h index 8c78961340..3b43b66c05 100644 --- a/libc/musl-overlay/arch/wasm32posix/kstat.h +++ b/libc/musl-overlay/arch/wasm32posix/kstat.h @@ -1,11 +1,12 @@ /* kstat.h — kernel stat format for wasm32posix. * - * This matches the kernel's WasmStat layout (88 bytes) exactly. - * musl's fstatat.c copies from kstat fields to struct stat fields. + * This is the complete 112-byte native syscall result. The kernel's internal + * WasmStat metadata record supplies the prefix through st_ctime_nsec; its + * native serializer initializes the rdev/blksize/blocks suffix to zero. * - * The kernel fills all 88 bytes. The rdev/blksize/blocks fields are appended - * for musl compatibility, initialized to zero by libc, and not filled by the - * kernel. See #928 for adding truthful filesystem-provided values. + * Keeping the complete allocation explicit prevents a host copy-back sized + * for struct kstat from exposing reused scratch bytes after the 88-byte + * internal prefix. See #928 for truthful filesystem-provided suffix values. */ struct kstat { unsigned long long st_dev; /* offset 0, 8 bytes */ @@ -24,8 +25,17 @@ struct kstat { long long st_ctime_sec; /* offset 72, 8 bytes */ unsigned int st_ctime_nsec; /* offset 80, 4 bytes */ unsigned int __ctime_pad; /* offset 84, 4 bytes */ - /* --- end of 88-byte WasmStat --- */ - unsigned long long st_rdev; /* zero until kernel reports it */ - int st_blksize; /* zero until kernel reports it */ - int st_blocks; /* zero until kernel reports it */ + /* --- end of the internal 88-byte WasmStat prefix --- */ + unsigned long long st_rdev; /* offset 88, zero until reported */ + int st_blksize; /* offset 96, zero until reported */ + int __blocks_pad; /* offset 100, initialized padding */ + long long st_blocks; /* offset 104, zero until reported */ }; + +_Static_assert(sizeof(struct kstat) == 112, "wasm32 kstat size mismatch"); +_Static_assert(__builtin_offsetof(struct kstat, st_rdev) == 88, + "wasm32 kstat st_rdev offset mismatch"); +_Static_assert(__builtin_offsetof(struct kstat, st_blksize) == 96, + "wasm32 kstat st_blksize offset mismatch"); +_Static_assert(__builtin_offsetof(struct kstat, st_blocks) == 104, + "wasm32 kstat st_blocks offset mismatch"); diff --git a/libc/musl-overlay/arch/wasm64posix/bits/stat.h b/libc/musl-overlay/arch/wasm64posix/bits/stat.h index f73cfd4f47..158fb541e2 100644 --- a/libc/musl-overlay/arch/wasm64posix/bits/stat.h +++ b/libc/musl-overlay/arch/wasm64posix/bits/stat.h @@ -1,11 +1,11 @@ /* bits/stat.h — wasm64posix struct stat * - * The kernel's WasmStat writes the first 88 bytes of this structure - * (through st_ctim). The remaining fields (st_rdev, st_blksize, - * st_blocks) are populated by musl's fstatat conversion logic or - * remain zero. + * The kernel writes a complete 112-byte native kstat and musl converts it to + * this same-sized public record. The first 88 bytes carry WasmStat's + * filesystem metadata and the final three fields are initialized explicitly, + * even when the filesystem does not yet provide them. * - * Field layout through st_ctim MUST match crates/shared/src/lib.rs. + * The complete layout MUST match crates/shared/src/process_layout.rs. */ struct stat { @@ -19,13 +19,13 @@ struct stat { struct timespec st_atim; /* offset 40 (16 bytes on wasm64) */ struct timespec st_mtim; /* offset 56 (16 bytes) */ struct timespec st_ctim; /* offset 72 (16 bytes) */ - /* --- end of kernel WasmStat (88 bytes) --- */ + /* --- end of the kernel's internal WasmStat prefix (88 bytes) --- */ unsigned long long st_rdev; /* offset 88 */ int st_blksize; /* offset 96 */ - long long st_blocks; /* offset 100 (pad to 104? or 108) */ + long long st_blocks; /* offset 104 */ }; -/* Key kernel-layout offsets must still match */ +_Static_assert(sizeof(struct stat) == 112, "struct stat size mismatch"); _Static_assert(__builtin_offsetof(struct stat, st_size) == 32, "st_size offset mismatch"); _Static_assert(__builtin_offsetof(struct stat, st_atim) == 40, @@ -34,3 +34,9 @@ _Static_assert(__builtin_offsetof(struct stat, st_mtim) == 56, "st_mtim offset mismatch"); _Static_assert(__builtin_offsetof(struct stat, st_ctim) == 72, "st_ctim offset mismatch"); +_Static_assert(__builtin_offsetof(struct stat, st_rdev) == 88, + "st_rdev offset mismatch"); +_Static_assert(__builtin_offsetof(struct stat, st_blksize) == 96, + "st_blksize offset mismatch"); +_Static_assert(__builtin_offsetof(struct stat, st_blocks) == 104, + "st_blocks offset mismatch"); diff --git a/libc/musl-overlay/arch/wasm64posix/kstat.h b/libc/musl-overlay/arch/wasm64posix/kstat.h index cb6c45ef37..5c5d79687b 100644 --- a/libc/musl-overlay/arch/wasm64posix/kstat.h +++ b/libc/musl-overlay/arch/wasm64posix/kstat.h @@ -1,11 +1,12 @@ /* kstat.h — kernel stat format for wasm64posix. * - * This matches the kernel's WasmStat layout (88 bytes) exactly. - * musl's fstatat.c copies from kstat fields to struct stat fields. + * This is the complete 112-byte native syscall result. The kernel's internal + * WasmStat metadata record supplies the prefix through st_ctime_nsec; its + * native serializer initializes the rdev/blksize/blocks suffix to zero. * - * The kernel fills all 88 bytes. The rdev/blksize/blocks fields are appended - * for musl compatibility, initialized to zero by libc, and not filled by the - * kernel. See #928 for adding truthful filesystem-provided values. + * Keeping the complete allocation explicit prevents a host copy-back sized + * for struct kstat from exposing reused scratch bytes after the 88-byte + * internal prefix. See #928 for truthful filesystem-provided suffix values. */ struct kstat { unsigned long long st_dev; /* offset 0, 8 bytes */ @@ -24,8 +25,17 @@ struct kstat { long long st_ctime_sec; /* offset 72, 8 bytes */ unsigned int st_ctime_nsec; /* offset 80, 4 bytes */ unsigned int __ctime_pad; /* offset 84, 4 bytes */ - /* --- end of 88-byte WasmStat --- */ - unsigned long long st_rdev; /* zero until kernel reports it */ - int st_blksize; /* zero until kernel reports it */ - int st_blocks; /* zero until kernel reports it */ + /* --- end of the internal 88-byte WasmStat prefix --- */ + unsigned long long st_rdev; /* offset 88, zero until reported */ + int st_blksize; /* offset 96, zero until reported */ + int __blocks_pad; /* offset 100, initialized padding */ + long long st_blocks; /* offset 104, zero until reported */ }; + +_Static_assert(sizeof(struct kstat) == 112, "wasm64 kstat size mismatch"); +_Static_assert(__builtin_offsetof(struct kstat, st_rdev) == 88, + "wasm64 kstat st_rdev offset mismatch"); +_Static_assert(__builtin_offsetof(struct kstat, st_blksize) == 96, + "wasm64 kstat st_blksize offset mismatch"); +_Static_assert(__builtin_offsetof(struct kstat, st_blocks) == 104, + "wasm64 kstat st_blocks offset mismatch"); diff --git a/libc/musl-overlay/include/bits/kandelo_limits.h b/libc/musl-overlay/include/bits/kandelo_limits.h new file mode 100644 index 0000000000..578c92b6f6 --- /dev/null +++ b/libc/musl-overlay/include/bits/kandelo_limits.h @@ -0,0 +1,10 @@ +/* GENERATED by `cargo xtask dump-abi`. Do not edit by hand. */ +/* Regenerated by scripts/check-abi-version.sh; drift is a CI failure. */ +#ifndef KANDELO_PLATFORM_LIMITS_H +#define KANDELO_PLATFORM_LIMITS_H + +#define KANDELO_POSIX_ARG_MAX_BYTES 4194304u +#define KANDELO_POSIX_PATH_MAX_BYTES 4096u +#define KANDELO_POSIX_IOV_MAX 1024u + +#endif /* KANDELO_PLATFORM_LIMITS_H */ diff --git a/libc/musl-overlay/include/bits/kandelo_process_layouts.h b/libc/musl-overlay/include/bits/kandelo_process_layouts.h new file mode 100644 index 0000000000..29e2a10456 --- /dev/null +++ b/libc/musl-overlay/include/bits/kandelo_process_layouts.h @@ -0,0 +1,83 @@ +/* GENERATED by `cargo xtask dump-abi`. Do not edit by hand. */ +/* Regenerated by scripts/check-abi-version.sh; drift is a CI failure. */ +#ifndef KANDELO_PROCESS_LAYOUTS_H +#define KANDELO_PROCESS_LAYOUTS_H + +#define KANDELO_PROCESS_IOVEC_WASM32_SIZE 8u +#define KANDELO_PROCESS_IOVEC_WASM32_BASE_OFFSET 0u +#define KANDELO_PROCESS_IOVEC_WASM32_LEN_OFFSET 4u +#define KANDELO_PROCESS_IOVEC_WASM64_SIZE 16u +#define KANDELO_PROCESS_IOVEC_WASM64_BASE_OFFSET 0u +#define KANDELO_PROCESS_IOVEC_WASM64_LEN_OFFSET 8u + +#define KANDELO_PROCESS_MSGHDR_WASM32_SIZE 28u +#define KANDELO_PROCESS_MSGHDR_WASM32_NAME_OFFSET 0u +#define KANDELO_PROCESS_MSGHDR_WASM32_NAMELEN_OFFSET 4u +#define KANDELO_PROCESS_MSGHDR_WASM32_IOV_OFFSET 8u +#define KANDELO_PROCESS_MSGHDR_WASM32_IOVLEN_OFFSET 12u +#define KANDELO_PROCESS_MSGHDR_WASM32_CONTROL_OFFSET 16u +#define KANDELO_PROCESS_MSGHDR_WASM32_CONTROLLEN_OFFSET 20u +#define KANDELO_PROCESS_MSGHDR_WASM32_FLAGS_OFFSET 24u +#define KANDELO_PROCESS_MSGHDR_WASM64_SIZE 56u +#define KANDELO_PROCESS_MSGHDR_WASM64_NAME_OFFSET 0u +#define KANDELO_PROCESS_MSGHDR_WASM64_NAMELEN_OFFSET 8u +#define KANDELO_PROCESS_MSGHDR_WASM64_IOV_OFFSET 16u +#define KANDELO_PROCESS_MSGHDR_WASM64_IOVLEN_OFFSET 24u +#define KANDELO_PROCESS_MSGHDR_WASM64_CONTROL_OFFSET 32u +#define KANDELO_PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET 40u +#define KANDELO_PROCESS_MSGHDR_WASM64_FLAGS_OFFSET 48u + +#define KANDELO_PROCESS_CMSGHDR_WASM32_SIZE 12u +#define KANDELO_PROCESS_CMSGHDR_WASM32_ALIGN 4u +#define KANDELO_PROCESS_CMSGHDR_WASM32_LEN_OFFSET 0u +#define KANDELO_PROCESS_CMSGHDR_WASM32_LEVEL_OFFSET 4u +#define KANDELO_PROCESS_CMSGHDR_WASM32_TYPE_OFFSET 8u +#define KANDELO_PROCESS_CMSGHDR_WASM32_DATA_OFFSET 12u +#define KANDELO_PROCESS_CMSGHDR_WASM64_SIZE 16u +#define KANDELO_PROCESS_CMSGHDR_WASM64_ALIGN 8u +#define KANDELO_PROCESS_CMSGHDR_WASM64_LEN_OFFSET 0u +#define KANDELO_PROCESS_CMSGHDR_WASM64_LEVEL_OFFSET 8u +#define KANDELO_PROCESS_CMSGHDR_WASM64_TYPE_OFFSET 12u +#define KANDELO_PROCESS_CMSGHDR_WASM64_DATA_OFFSET 16u + +#define KANDELO_PROCESS_SIGINFO_SIGNO_OFFSET 0u +#define KANDELO_PROCESS_SIGINFO_ERRNO_OFFSET 4u +#define KANDELO_PROCESS_SIGINFO_CODE_OFFSET 8u +#define KANDELO_PROCESS_SIGINFO_WASM32_SIZE 128u +#define KANDELO_PROCESS_SIGINFO_WASM32_PID_OFFSET 12u +#define KANDELO_PROCESS_SIGINFO_WASM32_UID_OFFSET 16u +#define KANDELO_PROCESS_SIGINFO_WASM32_VALUE_OFFSET 20u +#define KANDELO_PROCESS_SIGINFO_WASM32_VALUE_SIZE 4u +#define KANDELO_PROCESS_SIGINFO_WASM64_SIZE 128u +#define KANDELO_PROCESS_SIGINFO_WASM64_PID_OFFSET 16u +#define KANDELO_PROCESS_SIGINFO_WASM64_UID_OFFSET 20u +#define KANDELO_PROCESS_SIGINFO_WASM64_VALUE_OFFSET 24u +#define KANDELO_PROCESS_SIGINFO_WASM64_VALUE_SIZE 8u + +#define KANDELO_PROCESS_SIGEVENT_WASM32_SIZE 64u +#define KANDELO_PROCESS_SIGEVENT_WASM32_VALUE_OFFSET 0u +#define KANDELO_PROCESS_SIGEVENT_WASM32_VALUE_SIZE 4u +#define KANDELO_PROCESS_SIGEVENT_WASM32_SIGNO_OFFSET 4u +#define KANDELO_PROCESS_SIGEVENT_WASM32_NOTIFY_OFFSET 8u +#define KANDELO_PROCESS_SIGEVENT_WASM32_PAYLOAD_OFFSET 12u +#define KANDELO_PROCESS_SIGEVENT_WASM64_SIZE 64u +#define KANDELO_PROCESS_SIGEVENT_WASM64_VALUE_OFFSET 0u +#define KANDELO_PROCESS_SIGEVENT_WASM64_VALUE_SIZE 8u +#define KANDELO_PROCESS_SIGEVENT_WASM64_SIGNO_OFFSET 8u +#define KANDELO_PROCESS_SIGEVENT_WASM64_NOTIFY_OFFSET 12u +#define KANDELO_PROCESS_SIGEVENT_WASM64_PAYLOAD_OFFSET 16u + +#define KANDELO_SOCKET_SOL_SOCKET 1u +#define KANDELO_SOCKET_SCM_RIGHTS 1u +#define KANDELO_SOCKET_MSG_TRUNC 32u +#define KANDELO_SCM_RIGHTS_FD_BYTES 4u + +#define KANDELO_KERNEL_POLLFD_SIZE 8u +#define KANDELO_KERNEL_POLLFD_FD_OFFSET 0u +#define KANDELO_KERNEL_POLLFD_EVENTS_OFFSET 4u +#define KANDELO_KERNEL_POLLFD_REVENTS_OFFSET 6u + +#define KANDELO_SELECT_FD_SETSIZE 1024u +#define KANDELO_SELECT_FD_SET_BYTES 128u + +#endif /* KANDELO_PROCESS_LAYOUTS_H */ diff --git a/libc/musl-overlay/include/limits.h b/libc/musl-overlay/include/limits.h index 80bb13212b..282104f4d8 100644 --- a/libc/musl-overlay/include/limits.h +++ b/libc/musl-overlay/include/limits.h @@ -38,15 +38,16 @@ || defined(_XOPEN_SOURCE) || defined(_GNU_SOURCE) || defined(_BSD_SOURCE) #include +#include #define PIPE_BUF 4096 #ifndef NAME_MAX #define NAME_MAX 255 #endif -#define PATH_MAX 4096 +#define PATH_MAX KANDELO_POSIX_PATH_MAX_BYTES #define NGROUPS_MAX 32 -#define ARG_MAX 131072 -#define IOV_MAX 1024 +#define ARG_MAX KANDELO_POSIX_ARG_MAX_BYTES +#define IOV_MAX KANDELO_POSIX_IOV_MAX #define SYMLOOP_MAX 40 #define WORD_BIT 32 #define SSIZE_MAX LONG_MAX diff --git a/libc/musl-overlay/src/process/wasm32posix/posix_spawn.c b/libc/musl-overlay/src/process/wasm32posix/posix_spawn.c index 447fc535b1..96f335b526 100644 --- a/libc/musl-overlay/src/process/wasm32posix/posix_spawn.c +++ b/libc/musl-overlay/src/process/wasm32posix/posix_spawn.c @@ -31,21 +31,84 @@ #include #include #include "../fdop.h" +#include "spawn_contract.h" -/* SYS_SPAWN syscall number — keep in lockstep with - * `libc/glue/channel_syscall.c` and `crates/shared/src/lib.rs`. */ -#define SYS_SPAWN 500 +/* The blob carries musl's public spawn flags without translation. Keep the + * separately-owned POSIX header values pinned to the generated wire contract + * so a libc update cannot silently change the transported bits. Only the + * subset documented by the kernel is implemented today. */ +_Static_assert(POSIX_SPAWN_RESETIDS == WASM_POSIX_SPAWN_ATTR_RESETIDS, + "POSIX_SPAWN_RESETIDS drifted from the spawn wire contract"); +_Static_assert(POSIX_SPAWN_SETPGROUP == WASM_POSIX_SPAWN_ATTR_SETPGROUP, + "POSIX_SPAWN_SETPGROUP drifted from the spawn wire contract"); +_Static_assert(POSIX_SPAWN_SETSIGDEF == WASM_POSIX_SPAWN_ATTR_SETSIGDEF, + "POSIX_SPAWN_SETSIGDEF drifted from the spawn wire contract"); +_Static_assert(POSIX_SPAWN_SETSIGMASK == WASM_POSIX_SPAWN_ATTR_SETSIGMASK, + "POSIX_SPAWN_SETSIGMASK drifted from the spawn wire contract"); +_Static_assert(POSIX_SPAWN_SETSCHEDPARAM == WASM_POSIX_SPAWN_ATTR_SETSCHEDPARAM, + "POSIX_SPAWN_SETSCHEDPARAM drifted from the spawn wire contract"); +_Static_assert(POSIX_SPAWN_SETSCHEDULER == WASM_POSIX_SPAWN_ATTR_SETSCHEDULER, + "POSIX_SPAWN_SETSCHEDULER drifted from the spawn wire contract"); +_Static_assert(POSIX_SPAWN_USEVFORK == WASM_POSIX_SPAWN_ATTR_USEVFORK, + "POSIX_SPAWN_USEVFORK drifted from the spawn wire contract"); +_Static_assert(POSIX_SPAWN_SETSID == WASM_POSIX_SPAWN_ATTR_SETSID, + "POSIX_SPAWN_SETSID drifted from the spawn wire contract"); -/* Wire-format file-action op codes. Distinct from the FDOP_* values used - * by musl's internal fdop list (which fdop.h numbers 1..5). */ -#define WIRE_OP_OPEN 0u -#define WIRE_OP_CLOSE 1u -#define WIRE_OP_DUP2 2u -#define WIRE_OP_CHDIR 3u -#define WIRE_OP_FCHDIR 4u +/* WHY: record sizes alone cannot detect a same-size field reorder. These + * relationships pin musl's encoder to every generated byte offset while + * keeping C's exact integer widths independently checked. */ +_Static_assert(sizeof(uint32_t) == WASM_POSIX_SPAWN_STRING_OFFSET_BYTES, + "spawn string-offset width must match uint32_t"); +_Static_assert(WASM_POSIX_SPAWN_HEADER_ARGC_OFFSET == 0, + "spawn header argc must begin the record"); +_Static_assert(WASM_POSIX_SPAWN_HEADER_ENVC_OFFSET + == WASM_POSIX_SPAWN_HEADER_ARGC_OFFSET + sizeof(uint32_t), + "spawn header envc offset drifted"); +_Static_assert(WASM_POSIX_SPAWN_HEADER_ACTION_COUNT_OFFSET + == WASM_POSIX_SPAWN_HEADER_ENVC_OFFSET + sizeof(uint32_t), + "spawn header action-count offset drifted"); +_Static_assert(WASM_POSIX_SPAWN_HEADER_ATTR_FLAGS_OFFSET + == WASM_POSIX_SPAWN_HEADER_ACTION_COUNT_OFFSET + sizeof(uint32_t), + "spawn header attr-flags offset drifted"); +_Static_assert(WASM_POSIX_SPAWN_HEADER_PGRP_OFFSET + == WASM_POSIX_SPAWN_HEADER_ATTR_FLAGS_OFFSET + sizeof(uint32_t), + "spawn header pgrp offset drifted"); +_Static_assert(WASM_POSIX_SPAWN_HEADER_PAD_OFFSET + == WASM_POSIX_SPAWN_HEADER_PGRP_OFFSET + sizeof(int32_t), + "spawn header pad offset drifted"); +_Static_assert(WASM_POSIX_SPAWN_HEADER_SIGDEF_OFFSET + == WASM_POSIX_SPAWN_HEADER_PAD_OFFSET + sizeof(uint32_t), + "spawn header sigdef offset drifted"); +_Static_assert(WASM_POSIX_SPAWN_HEADER_SIGMASK_OFFSET + == WASM_POSIX_SPAWN_HEADER_SIGDEF_OFFSET + sizeof(uint64_t), + "spawn header sigmask offset drifted"); +_Static_assert(WASM_POSIX_SPAWN_HEADER_BYTES + == WASM_POSIX_SPAWN_HEADER_SIGMASK_OFFSET + sizeof(uint64_t), + "spawn header size no longer covers every field"); -#define HEADER_LEN 40 -#define ACTION_RECORD_LEN 28 +_Static_assert(WASM_POSIX_SPAWN_ACTION_OP_OFFSET == 0, + "spawn action op must begin the record"); +_Static_assert(WASM_POSIX_SPAWN_ACTION_FD_OFFSET + == WASM_POSIX_SPAWN_ACTION_OP_OFFSET + sizeof(uint32_t), + "spawn action fd offset drifted"); +_Static_assert(WASM_POSIX_SPAWN_ACTION_NEWFD_OFFSET + == WASM_POSIX_SPAWN_ACTION_FD_OFFSET + sizeof(int32_t), + "spawn action newfd offset drifted"); +_Static_assert(WASM_POSIX_SPAWN_ACTION_PATH_OFF_OFFSET + == WASM_POSIX_SPAWN_ACTION_NEWFD_OFFSET + sizeof(int32_t), + "spawn action path-off offset drifted"); +_Static_assert(WASM_POSIX_SPAWN_ACTION_PATH_LEN_OFFSET + == WASM_POSIX_SPAWN_ACTION_PATH_OFF_OFFSET + sizeof(uint32_t), + "spawn action path-len offset drifted"); +_Static_assert(WASM_POSIX_SPAWN_ACTION_OFLAG_OFFSET + == WASM_POSIX_SPAWN_ACTION_PATH_LEN_OFFSET + sizeof(uint32_t), + "spawn action oflag offset drifted"); +_Static_assert(WASM_POSIX_SPAWN_ACTION_MODE_OFFSET + == WASM_POSIX_SPAWN_ACTION_OFLAG_OFFSET + sizeof(int32_t), + "spawn action mode offset drifted"); +_Static_assert(WASM_POSIX_SPAWN_ACTION_RECORD_BYTES + == WASM_POSIX_SPAWN_ACTION_MODE_OFFSET + sizeof(uint32_t), + "spawn action size no longer covers every field"); /* Matches the definition in libc/glue/channel_syscall.c — all six syscall args * are passed as long long (i64). Declaring them as plain `long` produces @@ -56,20 +119,54 @@ extern long __syscall6(long n, long long a1, long long a2, long long a3, static const posix_spawnattr_t empty_attr; static const posix_spawn_file_actions_t empty_fa; -/* Count entries in a NULL-terminated argv-style array. */ -static unsigned count_strings(char *const *list) { - unsigned n = 0; - if (!list) return 0; - while (list[n]) n++; - return n; +static int checked_add_size(size_t lhs, size_t rhs, size_t *out) +{ + if (rhs > SIZE_MAX - lhs) return E2BIG; + *out = lhs + rhs; + return 0; +} + +static int checked_mul_size(size_t lhs, size_t rhs, size_t *out) +{ + if (lhs != 0 && rhs > SIZE_MAX / lhs) return E2BIG; + *out = lhs * rhs; + return 0; +} + +static void store_u32(uint8_t *bytes, size_t offset, uint32_t value) +{ + memcpy(bytes + offset, &value, sizeof(value)); +} + +static void store_i32(uint8_t *bytes, size_t offset, int32_t value) +{ + memcpy(bytes + offset, &value, sizeof(value)); +} + +static void store_u64(uint8_t *bytes, size_t offset, uint64_t value) +{ + memcpy(bytes + offset, &value, sizeof(value)); } -/* Sum of strlen(str) + 1 over a NULL-terminated array. */ -static size_t total_string_bytes(char *const *list) { +/* Count and size a NULL-terminated argv-style array without allowing either + * arithmetic wraparound or a protocol count beyond the generated parser cap. */ +static int scan_strings(char *const *list, unsigned max_count, + unsigned *out_count, size_t *out_bytes) +{ + unsigned count = 0; size_t total = 0; - if (!list) return 0; - for (unsigned i = 0; list[i]; i++) total += strlen(list[i]) + 1; - return total; + if (list) { + while (list[count]) { + if (count >= max_count) return E2BIG; + size_t len = strlen(list[count]); + if (len == SIZE_MAX || checked_add_size(total, len + 1, &total)) + return E2BIG; + count++; + } + } + *out_count = count; + *out_bytes = total; + return 0; } /* Walk the fdop list to count actions and total path bytes that need to @@ -83,27 +180,35 @@ static size_t total_string_bytes(char *const *list) { * insertion order, so the emit-side walk uses `op->prev` from the tail * — see `emit_actions`. The count/scan walk direction doesn't matter * (we only need totals). */ -static void scan_actions(struct fdop *head, unsigned *out_count, size_t *out_path_bytes) { +static int scan_actions(struct fdop *head, unsigned *out_count, + size_t *out_path_bytes) +{ unsigned n = 0; size_t path_bytes = 0; for (struct fdop *op = head; op; op = op->next) { + if (n >= WASM_POSIX_SPAWN_MAX_ACTION_COUNT) return E2BIG; n++; if (op->cmd == FDOP_OPEN || op->cmd == FDOP_CHDIR) { - path_bytes += strlen(op->path) + 1; + size_t path_len = strlen(op->path); + if (path_len >= WASM_POSIX_PATH_MAX_BYTES) + return ENAMETOOLONG; + if (checked_add_size(path_bytes, path_len + 1, &path_bytes)) + return E2BIG; } } *out_count = n; *out_path_bytes = path_bytes; + return 0; } /* Translate musl's FDOP_* code into the wire-format op code. */ static unsigned wire_op_for(int cmd) { switch (cmd) { - case FDOP_OPEN: return WIRE_OP_OPEN; - case FDOP_CLOSE: return WIRE_OP_CLOSE; - case FDOP_DUP2: return WIRE_OP_DUP2; - case FDOP_CHDIR: return WIRE_OP_CHDIR; - case FDOP_FCHDIR: return WIRE_OP_FCHDIR; + case FDOP_OPEN: return WASM_POSIX_SPAWN_OP_OPEN; + case FDOP_CLOSE: return WASM_POSIX_SPAWN_OP_CLOSE; + case FDOP_DUP2: return WASM_POSIX_SPAWN_OP_DUP2; + case FDOP_CHDIR: return WASM_POSIX_SPAWN_OP_CHDIR; + case FDOP_FCHDIR: return WASM_POSIX_SPAWN_OP_FCHDIR; default: return (unsigned)-1; } } @@ -123,6 +228,7 @@ int posix_spawn(pid_t *restrict res, const char *restrict path, char *const argv[restrict], char *const envp[restrict]) { if (!path) return EINVAL; + if (strlen(path) >= WASM_POSIX_PATH_MAX_BYTES) return ENAMETOOLONG; const posix_spawnattr_t *a = attr ? attr : &empty_attr; const posix_spawn_file_actions_t *f = fa ? fa : &empty_fa; @@ -131,22 +237,55 @@ int posix_spawn(pid_t *restrict res, const char *restrict path, extern char **__environ; char *const *env = envp ? envp : (char *const *)__environ; - unsigned argc = count_strings(argv); - unsigned envc = count_strings(env); + unsigned argc = 0; + unsigned envc = 0; unsigned n_actions = 0; + size_t argv_bytes = 0; + size_t envp_bytes = 0; size_t action_path_bytes = 0; - scan_actions((struct fdop *)f->__actions, &n_actions, &action_path_bytes); + int error = scan_strings(argv, WASM_POSIX_SPAWN_MAX_ARGV_COUNT, + &argc, &argv_bytes); + if (error) return error; + error = scan_strings(env, WASM_POSIX_SPAWN_MAX_ENVP_COUNT, + &envc, &envp_bytes); + if (error) return error; + error = scan_actions((struct fdop *)f->__actions, + &n_actions, &action_path_bytes); + if (error) return error; - size_t argv_bytes = total_string_bytes(argv); - size_t envp_bytes = total_string_bytes(env); + /* ARG_MAX includes both string bytes and the source pointer arrays, + * including their two terminating NULL entries. */ + size_t pointer_count; + size_t pointer_bytes; + size_t metadata_bytes; + if (checked_add_size((size_t)argc, (size_t)envc, &pointer_count) + || checked_add_size(pointer_count, 2, &pointer_count) + || checked_mul_size(pointer_count, sizeof(char *), &pointer_bytes) + || checked_add_size(argv_bytes, envp_bytes, &metadata_bytes) + || checked_add_size(metadata_bytes, pointer_bytes, &metadata_bytes)) + return E2BIG; + if (metadata_bytes > WASM_POSIX_ARG_MAX_BYTES) return E2BIG; - size_t header_bytes = HEADER_LEN; - size_t argv_off_bytes = (size_t)argc * 4; - size_t envp_off_bytes = (size_t)envc * 4; - size_t actions_bytes = (size_t)n_actions * ACTION_RECORD_LEN; - size_t strings_bytes = argv_bytes + envp_bytes + action_path_bytes; - size_t blob_len = header_bytes + argv_off_bytes + envp_off_bytes - + actions_bytes + strings_bytes; + size_t header_bytes = WASM_POSIX_SPAWN_HEADER_BYTES; + size_t argv_off_bytes; + size_t envp_off_bytes; + size_t actions_bytes; + size_t strings_bytes; + size_t blob_len; + if (checked_mul_size((size_t)argc, + WASM_POSIX_SPAWN_STRING_OFFSET_BYTES, &argv_off_bytes) + || checked_mul_size((size_t)envc, + WASM_POSIX_SPAWN_STRING_OFFSET_BYTES, &envp_off_bytes) + || checked_mul_size((size_t)n_actions, + WASM_POSIX_SPAWN_ACTION_RECORD_BYTES, &actions_bytes) + || checked_add_size(argv_bytes, envp_bytes, &strings_bytes) + || checked_add_size(strings_bytes, action_path_bytes, &strings_bytes) + || checked_add_size(header_bytes, argv_off_bytes, &blob_len) + || checked_add_size(blob_len, envp_off_bytes, &blob_len) + || checked_add_size(blob_len, actions_bytes, &blob_len) + || checked_add_size(blob_len, strings_bytes, &blob_len)) + return E2BIG; + if (blob_len > WASM_POSIX_SPAWN_WIRE_MAX_BYTES) return E2BIG; /* Allocate on the heap; alloca() of unbounded size is unsafe and * fork-instrument's switch-dispatch interacts poorly with large @@ -155,25 +294,25 @@ int posix_spawn(pid_t *restrict res, const char *restrict path, if (!blob) return ENOMEM; /* ── Header ── */ - uint32_t *h32 = (uint32_t *)blob; - int32_t *h32s = (int32_t *)blob; - h32[0] = argc; - h32[1] = envc; - h32[2] = n_actions; - h32[3] = (uint32_t)a->__flags; - h32s[4] = (int32_t)a->__pgrp; - h32[5] = 0; /* _pad */ + store_u32(blob, WASM_POSIX_SPAWN_HEADER_ARGC_OFFSET, argc); + store_u32(blob, WASM_POSIX_SPAWN_HEADER_ENVC_OFFSET, envc); + store_u32(blob, WASM_POSIX_SPAWN_HEADER_ACTION_COUNT_OFFSET, n_actions); + store_u32(blob, WASM_POSIX_SPAWN_HEADER_ATTR_FLAGS_OFFSET, + (uint32_t)a->__flags); + store_i32(blob, WASM_POSIX_SPAWN_HEADER_PGRP_OFFSET, + (int32_t)a->__pgrp); + store_u32(blob, WASM_POSIX_SPAWN_HEADER_PAD_OFFSET, 0); uint64_t sigdef = sigset_to_u64(&a->__def); uint64_t sigmask = sigset_to_u64(&a->__mask); - memcpy(blob + 24, &sigdef, 8); - memcpy(blob + 32, &sigmask, 8); + store_u64(blob, WASM_POSIX_SPAWN_HEADER_SIGDEF_OFFSET, sigdef); + store_u64(blob, WASM_POSIX_SPAWN_HEADER_SIGMASK_OFFSET, sigmask); /* ── Offsets tables + strings region ── * * Argv strings come first in `strings`, then envp, then action * paths. Each block is packed: NUL-terminated, no padding. */ - uint32_t *argv_offs = (uint32_t *)(blob + header_bytes); - uint32_t *envp_offs = (uint32_t *)(blob + header_bytes + argv_off_bytes); + uint8_t *argv_offs = blob + header_bytes; + uint8_t *envp_offs = blob + header_bytes + argv_off_bytes; uint8_t *actions = blob + header_bytes + argv_off_bytes + envp_off_bytes; uint8_t *strings = actions + actions_bytes; @@ -181,13 +320,15 @@ int posix_spawn(pid_t *restrict res, const char *restrict path, for (unsigned i = 0; i < argc; i++) { size_t n = strlen(argv[i]) + 1; memcpy(strings + cursor, argv[i], n); - argv_offs[i] = cursor; + store_u32(argv_offs, + (size_t)i * WASM_POSIX_SPAWN_STRING_OFFSET_BYTES, cursor); cursor += (uint32_t)n; } for (unsigned i = 0; i < envc; i++) { size_t n = strlen(env[i]) + 1; memcpy(strings + cursor, env[i], n); - envp_offs[i] = cursor; + store_u32(envp_offs, + (size_t)i * WASM_POSIX_SPAWN_STRING_OFFSET_BYTES, cursor); cursor += (uint32_t)n; } @@ -198,8 +339,8 @@ int posix_spawn(pid_t *restrict res, const char *restrict path, * and the list is reverse-insertion order. To emit insertion * order, walk to the tail first, then iterate via `prev`. * - * Wire-format `record.fd` and `record.newfd` (offsets +4 and +8) map - * to musl's fdop fields differently per op: + * Wire-format `record.fd` and `record.newfd` map to musl's fdop fields + * differently per op: * * DUP2: record.fd = op->srcfd (source) * record.newfd = op->fd (target) * * CLOSE/OPEN/FCHDIR: record.fd = op->fd @@ -215,15 +356,19 @@ int posix_spawn(pid_t *restrict res, const char *restrict path, } unsigned ai = 0; for (struct fdop *op = tail; op; op = op->prev) { - uint32_t *r32 = (uint32_t *)(actions + ai * ACTION_RECORD_LEN); - int32_t *r32s = (int32_t *)(actions + ai * ACTION_RECORD_LEN); - r32[0] = wire_op_for(op->cmd); + uint8_t *record = actions + + ai * WASM_POSIX_SPAWN_ACTION_RECORD_BYTES; + store_u32(record, WASM_POSIX_SPAWN_ACTION_OP_OFFSET, + wire_op_for(op->cmd)); if (op->cmd == FDOP_DUP2) { - r32s[1] = op->srcfd; /* record.fd = source */ - r32s[2] = op->fd; /* record.newfd = target */ + store_i32(record, WASM_POSIX_SPAWN_ACTION_FD_OFFSET, + op->srcfd); /* record.fd = source */ + store_i32(record, WASM_POSIX_SPAWN_ACTION_NEWFD_OFFSET, + op->fd); /* record.newfd = target */ } else { - r32s[1] = op->fd; - r32s[2] = 0; + store_i32(record, WASM_POSIX_SPAWN_ACTION_FD_OFFSET, + op->fd); + store_i32(record, WASM_POSIX_SPAWN_ACTION_NEWFD_OFFSET, 0); } uint32_t path_off = 0; uint32_t path_len = 0; @@ -233,17 +378,21 @@ int posix_spawn(pid_t *restrict res, const char *restrict path, memcpy(strings + cursor, op->path, path_len); cursor += path_len; } - r32[3] = path_off; - r32[4] = path_len; - r32s[5] = op->oflag; - r32[6] = (uint32_t)op->mode; + store_u32(record, WASM_POSIX_SPAWN_ACTION_PATH_OFF_OFFSET, + path_off); + store_u32(record, WASM_POSIX_SPAWN_ACTION_PATH_LEN_OFFSET, + path_len); + store_i32(record, WASM_POSIX_SPAWN_ACTION_OFLAG_OFFSET, + op->oflag); + store_u32(record, WASM_POSIX_SPAWN_ACTION_MODE_OFFSET, + (uint32_t)op->mode); ai++; } /* ── Issue SYS_SPAWN ── */ pid_t pid_out = 0; long ret = __syscall6( - SYS_SPAWN, + WASM_POSIX_SYS_SPAWN, (long long)(uintptr_t)path, (long long)strlen(path), (long long)(uintptr_t)blob, diff --git a/libc/musl-overlay/src/process/wasm32posix/spawn_contract.h b/libc/musl-overlay/src/process/wasm32posix/spawn_contract.h new file mode 100644 index 0000000000..0b79e2ff3c --- /dev/null +++ b/libc/musl-overlay/src/process/wasm32posix/spawn_contract.h @@ -0,0 +1,47 @@ +/* GENERATED by `cargo xtask dump-abi`. Do not edit by hand. */ +/* Regenerated by scripts/check-abi-version.sh; drift is a CI failure. */ +#ifndef WASM_POSIX_SPAWN_CONTRACT_H +#define WASM_POSIX_SPAWN_CONTRACT_H + +#include + +#define WASM_POSIX_ARG_MAX_BYTES KANDELO_POSIX_ARG_MAX_BYTES +#define WASM_POSIX_PATH_MAX_BYTES KANDELO_POSIX_PATH_MAX_BYTES +#define WASM_POSIX_SYS_SPAWN 500u +#define WASM_POSIX_SPAWN_HEADER_BYTES 40u +#define WASM_POSIX_SPAWN_STRING_OFFSET_BYTES 4u +#define WASM_POSIX_SPAWN_HEADER_ARGC_OFFSET 0u +#define WASM_POSIX_SPAWN_HEADER_ENVC_OFFSET 4u +#define WASM_POSIX_SPAWN_HEADER_ACTION_COUNT_OFFSET 8u +#define WASM_POSIX_SPAWN_HEADER_ATTR_FLAGS_OFFSET 12u +#define WASM_POSIX_SPAWN_HEADER_PGRP_OFFSET 16u +#define WASM_POSIX_SPAWN_HEADER_PAD_OFFSET 20u +#define WASM_POSIX_SPAWN_HEADER_SIGDEF_OFFSET 24u +#define WASM_POSIX_SPAWN_HEADER_SIGMASK_OFFSET 32u +#define WASM_POSIX_SPAWN_ACTION_RECORD_BYTES 28u +#define WASM_POSIX_SPAWN_ACTION_OP_OFFSET 0u +#define WASM_POSIX_SPAWN_ACTION_FD_OFFSET 4u +#define WASM_POSIX_SPAWN_ACTION_NEWFD_OFFSET 8u +#define WASM_POSIX_SPAWN_ACTION_PATH_OFF_OFFSET 12u +#define WASM_POSIX_SPAWN_ACTION_PATH_LEN_OFFSET 16u +#define WASM_POSIX_SPAWN_ACTION_OFLAG_OFFSET 20u +#define WASM_POSIX_SPAWN_ACTION_MODE_OFFSET 24u +#define WASM_POSIX_SPAWN_OP_OPEN 0u +#define WASM_POSIX_SPAWN_OP_CLOSE 1u +#define WASM_POSIX_SPAWN_OP_DUP2 2u +#define WASM_POSIX_SPAWN_OP_CHDIR 3u +#define WASM_POSIX_SPAWN_OP_FCHDIR 4u +#define WASM_POSIX_SPAWN_ATTR_RESETIDS 1u +#define WASM_POSIX_SPAWN_ATTR_SETPGROUP 2u +#define WASM_POSIX_SPAWN_ATTR_SETSIGDEF 4u +#define WASM_POSIX_SPAWN_ATTR_SETSIGMASK 8u +#define WASM_POSIX_SPAWN_ATTR_SETSCHEDPARAM 16u +#define WASM_POSIX_SPAWN_ATTR_SETSCHEDULER 32u +#define WASM_POSIX_SPAWN_ATTR_USEVFORK 64u +#define WASM_POSIX_SPAWN_ATTR_SETSID 128u +#define WASM_POSIX_SPAWN_MAX_ARGV_COUNT 4096u +#define WASM_POSIX_SPAWN_MAX_ENVP_COUNT 4096u +#define WASM_POSIX_SPAWN_MAX_ACTION_COUNT 1024u +#define WASM_POSIX_SPAWN_WIRE_MAX_BYTES 8417320u + +#endif /* WASM_POSIX_SPAWN_CONTRACT_H */ diff --git a/libc/musl-overlay/src/time/timer_create.c b/libc/musl-overlay/src/time/timer_create.c index ea8d8a6afa..39a5652c2b 100644 --- a/libc/musl-overlay/src/time/timer_create.c +++ b/libc/musl-overlay/src/time/timer_create.c @@ -8,30 +8,6 @@ #include "pthread_impl.h" #include "atomic.h" -/* - * Kandelo's timer_create syscall wire is four fixed-width i32 fields on both - * wasm32 and wasm64. Direct SIGEV_SIGNAL/SIGEV_THREAD_ID delivery currently - * carries the sival_int representation. SIGEV_THREAD callback values do not - * cross this wire: the helper copies the full union sigval locally. - */ -struct ksigevent { - int32_t sigev_value; - int32_t sigev_signo; - int32_t sigev_notify; - int32_t sigev_tid; -}; - -_Static_assert(sizeof(struct ksigevent) == 16, - "kernel sigevent wire must remain four i32 fields"); -_Static_assert(offsetof(struct ksigevent, sigev_value) == 0, - "kernel sigevent value offset"); -_Static_assert(offsetof(struct ksigevent, sigev_signo) == 4, - "kernel sigevent signo offset"); -_Static_assert(offsetof(struct ksigevent, sigev_notify) == 8, - "kernel sigevent notify offset"); -_Static_assert(offsetof(struct ksigevent, sigev_tid) == 12, - "kernel sigevent tid offset"); - struct start_args { pthread_barrier_t b; struct sigevent *sev; @@ -98,7 +74,7 @@ int timer_create( pthread_attr_t attr; int r; struct start_args args; - struct ksigevent ksev, *ksevp = 0; + struct sigevent ksev = {0}, *ksevp = 0; int timerid; sigset_t set; @@ -108,16 +84,17 @@ int timer_create( case SIGEV_THREAD_ID: if (evp) { /* - * The kernel ABI currently carries sival_int. A direct - * SIGEV_SIGNAL/SIGEV_THREAD_ID sival_ptr wider than 32 bits - * remains unsupported and must stay documented as such. + * Pass the native union without selecting a member. The host + * stages the complete caller-native structure, and the kernel + * preserves these raw pointer-width bits through delivery. */ - ksev.sigev_value = evp->sigev_value.sival_int; + ksev.sigev_value = evp->sigev_value; ksev.sigev_signo = evp->sigev_notify == SIGEV_NONE ? 0 : evp->sigev_signo; ksev.sigev_notify = evp->sigev_notify; - ksev.sigev_tid = evp->sigev_notify == SIGEV_THREAD_ID + ksev.sigev_notify_thread_id = + evp->sigev_notify == SIGEV_THREAD_ID ? evp->sigev_notify_thread_id : 0; ksevp = &ksev; @@ -161,10 +138,10 @@ int timer_create( * The callback value stays in the helper's local `val`; the kernel * notification only wakes the exact helper TID. */ - ksev.sigev_value = 0; + ksev.sigev_value.sival_ptr = 0; ksev.sigev_signo = SIGTIMER; ksev.sigev_notify = SIGEV_THREAD_ID; - ksev.sigev_tid = td->tid; + ksev.sigev_notify_thread_id = td->tid; if (syscall(SYS_timer_create, clk, &ksev, &timerid) < 0) { timerid = -1; diff --git a/programs/scm-rights-pipe-lifetime.c b/programs/scm-rights-pipe-lifetime.c index 85d2c39d95..f7386cb701 100644 --- a/programs/scm-rights-pipe-lifetime.c +++ b/programs/scm-rights-pipe-lifetime.c @@ -93,7 +93,11 @@ static int receive_fds_with_flags(int socket_fd, int *fds, size_t capacity, .msg_iov = &iov, .msg_iovlen = 1, .msg_control = control, - .msg_controllen = CMSG_SPACE(capacity * sizeof(int)), + // WHY: this is a logical descriptor capacity, not spare native + // alignment storage. On wasm64 CMSG_SPACE(one fd) is large enough to + // describe two canonical 32-bit FDs after the host translates the + // wider cmsghdr; CMSG_LEN keeps the one-FD truncation boundary exact. + .msg_controllen = CMSG_LEN(capacity * sizeof(int)), }; if (recvmsg(socket_fd, &message, 0) != 1) return -1; @@ -229,61 +233,52 @@ static int unreturnable_endpoint_is_released(void) { return close(data_pipe[1]) | close(carrier[0]) | close(carrier[1]); } -static int reachable_socket_right_cycles_deliver(void) { - int self[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, self) < 0 || - send_fd(self[0], self[1]) < 0) { - return -1; - } - int received_self = receive_fd(self[1]); - char byte = 0; - if (received_self < 0 || close(self[1]) < 0 || - write(self[0], "S", 1) != 1 || read(received_self, &byte, 1) != 1 || - byte != 'S' || write(received_self, "T", 1) != 1 || - read(self[0], &byte, 1) != 1 || byte != 'T' || close(self[0]) < 0 || - close(received_self) < 0) { +static int expect_socket_right_rejected(int sender, int receiver, int fd) { + errno = 0; + if (send_fd(sender, fd) != -1 || errno != EOPNOTSUPP) { + if (errno == 0) + errno = EIO; return -1; } - int a[2]; - int b[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, a) < 0 || - socketpair(AF_UNIX, SOCK_STREAM, 0, b) < 0 || - send_fd(a[0], b[1]) < 0 || send_fd(b[0], a[1]) < 0) { - return -1; - } - int received_b = receive_fd(a[1]); - int received_a = receive_fd(b[1]); - if (received_a < 0 || received_b < 0 || close(a[1]) < 0 || - close(b[1]) < 0 || write(a[0], "A", 1) != 1 || - read(received_a, &byte, 1) != 1 || byte != 'A' || - write(b[0], "B", 1) != 1 || read(received_b, &byte, 1) != 1 || - byte != 'B' || close(a[0]) < 0 || close(b[0]) < 0 || - close(received_a) < 0 || close(received_b) < 0) { + char byte = 0; + errno = 0; + if (recv(receiver, &byte, sizeof(byte), MSG_DONTWAIT) != -1 || + (errno != EAGAIN && errno != EWOULDBLOCK)) { + errno = EIO; return -1; } return 0; } -static int abandoned_socket_right_cycles_are_collected(void) { +static int socket_right_cycles_are_rejected_before_publication(void) { for (int i = 0; i < 64; ++i) { int self[2]; + char byte = 0; if (socketpair(AF_UNIX, SOCK_STREAM, 0, self) < 0 || - send_fd(self[0], self[1]) < 0 || close(self[0]) < 0 || - close(self[1]) < 0) { + expect_socket_right_rejected(self[0], self[1], self[1]) < 0 || + write(self[0], "S", 1) != 1 || + read(self[1], &byte, 1) != 1 || byte != 'S' || + close(self[0]) < 0 || close(self[1]) < 0) { return -1; } } int a[2]; int b[2]; + char byte = 0; if (socketpair(AF_UNIX, SOCK_STREAM, 0, a) < 0 || socketpair(AF_UNIX, SOCK_STREAM, 0, b) < 0 || - send_fd(a[0], b[1]) < 0 || send_fd(b[0], a[1]) < 0 || + expect_socket_right_rejected(a[0], a[1], b[1]) < 0 || + expect_socket_right_rejected(b[0], b[1], a[1]) < 0 || + write(a[0], "A", 1) != 1 || read(a[1], &byte, 1) != 1 || + byte != 'A' || write(b[0], "B", 1) != 1 || + read(b[1], &byte, 1) != 1 || byte != 'B' || close(a[0]) < 0 || close(a[1]) < 0 || close(b[0]) < 0 || close(b[1]) < 0) { return -1; } + puts("SCM_RIGHTS_SOCKET_REJECTION_PASS"); return 0; } @@ -588,8 +583,7 @@ int main(void) { transferred_writer_survives_sender_close() < 0 || abandoned_endpoint_is_released() < 0 || unreturnable_endpoint_is_released() < 0 || - reachable_socket_right_cycles_deliver() < 0 || - abandoned_socket_right_cycles_are_collected() < 0 || + socket_right_cycles_are_rejected_before_publication() < 0 || receiver_control_truncation_installs_prefix_and_releases_excess() < 0 || receiver_emfile_partially_installs_and_releases() < 0 || transferred_fifo_path_survives_sender_close_and_unlink() < 0 || diff --git a/programs/scm-rights-semantics.c b/programs/scm-rights-semantics.c new file mode 100644 index 0000000000..4cfadc7f64 --- /dev/null +++ b/programs/scm-rights-semantics.c @@ -0,0 +1,1026 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define ARRAY_LENGTH(array) (sizeof(array) / sizeof((array)[0])) +#define MAX_RECEIVED_FDS 2 + +static const char *const self_path = "/bin/scm-rights-semantics"; + +struct received_message { + ssize_t length; + int flags; + size_t fd_count; + int fds[MAX_RECEIVED_FDS]; +}; + +static int fail(const char *test, const char *detail) +{ + fprintf(stderr, "%s: %s (errno=%d: %s)\n", test, detail, errno, + strerror(errno)); + return -1; +} + +static int close_pair(int pair[2]) +{ + int result = 0; + if (pair[0] >= 0 && close(pair[0]) < 0) + result = -1; + if (pair[1] >= 0 && close(pair[1]) < 0) + result = -1; + pair[0] = -1; + pair[1] = -1; + return result; +} + +static ssize_t send_rights_form(int socket_fd, const void *data, + size_t data_len, const int *fds, + size_t fd_count, int include_iov, + const struct sockaddr *destination, + socklen_t destination_len) +{ + if (fd_count == 0 || fd_count > MAX_RECEIVED_FDS) { + errno = EINVAL; + return -1; + } + + unsigned char control[CMSG_SPACE(MAX_RECEIVED_FDS * sizeof(int))]; + memset(control, 0, sizeof(control)); + struct iovec iov = { + .iov_base = (void *) data, + .iov_len = data_len, + }; + struct msghdr message; + memset(&message, 0, sizeof(message)); + message.msg_iov = include_iov ? &iov : NULL; + message.msg_iovlen = include_iov ? 1 : 0; + message.msg_name = (void *) destination; + message.msg_namelen = destination_len; + message.msg_control = control; + message.msg_controllen = CMSG_SPACE(fd_count * sizeof(int)); + + struct cmsghdr *cmsg = CMSG_FIRSTHDR(&message); + if (!cmsg) { + errno = EINVAL; + return -1; + } + cmsg->cmsg_len = CMSG_LEN(fd_count * sizeof(int)); + cmsg->cmsg_level = SOL_SOCKET; + cmsg->cmsg_type = SCM_RIGHTS; + memcpy(CMSG_DATA(cmsg), fds, fd_count * sizeof(int)); + return sendmsg(socket_fd, &message, 0); +} + +static ssize_t send_rights(int socket_fd, const void *data, size_t data_len, + const int *fds, size_t fd_count) +{ + return send_rights_form(socket_fd, data, data_len, fds, fd_count, 1, + NULL, 0); +} + +static ssize_t send_rights_without_iov(int socket_fd, const int *fds, + size_t fd_count) +{ + return send_rights_form(socket_fd, NULL, 0, fds, fd_count, 0, NULL, 0); +} + +static ssize_t send_rights_to(int socket_fd, const void *data, + size_t data_len, const int *fds, + size_t fd_count, + const struct sockaddr *destination, + socklen_t destination_len) +{ + return send_rights_form(socket_fd, data, data_len, fds, fd_count, 1, + destination, destination_len); +} + +static int receive_message_form(int socket_fd, void *data, + size_t data_capacity, size_t fd_capacity, + int recv_flags, int include_iov, + struct received_message *received) +{ + if (fd_capacity > MAX_RECEIVED_FDS) { + errno = EINVAL; + return -1; + } + + unsigned char control[CMSG_SPACE(MAX_RECEIVED_FDS * sizeof(int))]; + memset(control, 0, sizeof(control)); + struct iovec iov = { + .iov_base = data, + .iov_len = data_capacity, + }; + struct msghdr message; + memset(&message, 0, sizeof(message)); + message.msg_iov = include_iov ? &iov : NULL; + message.msg_iovlen = include_iov ? 1 : 0; + if (fd_capacity > 0) { + message.msg_control = control; + /* + * This is a logical descriptor capacity. CMSG_LEN keeps the one-FD + * boundary exact on both wasm32 and wasm64 even when native control + * alignment leaves spare bytes in CMSG_SPACE. + */ + message.msg_controllen = CMSG_LEN(fd_capacity * sizeof(int)); + } + + memset(received, 0, sizeof(*received)); + for (size_t i = 0; i < ARRAY_LENGTH(received->fds); ++i) + received->fds[i] = -1; + received->length = recvmsg(socket_fd, &message, recv_flags); + if (received->length < 0) + return -1; + received->flags = message.msg_flags; + + struct cmsghdr *cmsg = CMSG_FIRSTHDR(&message); + if (!cmsg) + return 0; + if (cmsg->cmsg_level != SOL_SOCKET || cmsg->cmsg_type != SCM_RIGHTS || + cmsg->cmsg_len < CMSG_LEN(sizeof(int))) { + errno = EBADMSG; + return -1; + } + size_t payload_len = cmsg->cmsg_len - CMSG_LEN(0); + if (payload_len % sizeof(int) != 0) { + errno = EBADMSG; + return -1; + } + received->fd_count = payload_len / sizeof(int); + if (received->fd_count == 0 || received->fd_count > fd_capacity) { + errno = EBADMSG; + return -1; + } + memcpy(received->fds, CMSG_DATA(cmsg), + received->fd_count * sizeof(int)); + return 0; +} + +static int receive_message(int socket_fd, void *data, size_t data_capacity, + size_t fd_capacity, int recv_flags, + struct received_message *received) +{ + return receive_message_form(socket_fd, data, data_capacity, fd_capacity, + recv_flags, 1, received); +} + +static int receive_message_without_iov(int socket_fd, size_t fd_capacity, + int recv_flags, + struct received_message *received) +{ + return receive_message_form(socket_fd, NULL, 0, fd_capacity, recv_flags, + 0, received); +} + +static int expect_pipe_byte(int received_fd, int writer_fd, char expected) +{ + int flags = fcntl(received_fd, F_GETFL); + if (flags < 0 || fcntl(received_fd, F_SETFL, flags | O_NONBLOCK) < 0) + return -1; + if (write(writer_fd, &expected, 1) != 1) + return -1; + char actual = 0; + if (read(received_fd, &actual, 1) != 1 || actual != expected) { + errno = EIO; + return -1; + } + return 0; +} + +static int expect_empty_nonblocking(int fd) +{ + char byte = 0; + errno = 0; + ssize_t result = recv(fd, &byte, 1, MSG_DONTWAIT); + if (result != -1 || (errno != EAGAIN && errno != EWOULDBLOCK)) { + errno = EIO; + return -1; + } + return 0; +} + +static int peek_with_short_control(int fd, char expected) +{ + char byte = 0; + unsigned char control[CMSG_LEN(0)]; + memset(control, 0, sizeof(control)); + struct iovec iov = { + .iov_base = &byte, + .iov_len = 1, + }; + struct msghdr message; + memset(&message, 0, sizeof(message)); + message.msg_iov = &iov; + message.msg_iovlen = 1; + message.msg_control = control; + message.msg_controllen = sizeof(control); + ssize_t result = recvmsg(fd, &message, MSG_PEEK | MSG_DONTWAIT); + if (result != 1 || byte != expected || + (message.msg_flags & MSG_CTRUNC) == 0) { + errno = EIO; + return -1; + } + return 0; +} + +static int poll_readable(int fd); + +static int test_stream_barriers(void) +{ + static const char *const test = "stream barriers"; + int carrier[2] = { -1, -1 }; + int first_pipe[2] = { -1, -1 }; + int second_pipe[2] = { -1, -1 }; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, carrier) < 0 || + pipe(first_pipe) < 0 || pipe(second_pipe) < 0) + return fail(test, "setup"); + + if (write(carrier[0], "AA", 2) != 2 || + send_rights(carrier[0], "B", 1, &first_pipe[0], 1) != 1 || + send_rights(carrier[0], "C", 1, &second_pipe[0], 1) != 1 || + write(carrier[0], "DD", 2) != 2) + return fail(test, "queue plain and rights-bearing ranges"); + if (close(first_pipe[0]) < 0 || close(second_pipe[0]) < 0) + return fail(test, "close sender descriptor aliases"); + first_pipe[0] = second_pipe[0] = -1; + + char data[16]; + struct received_message received; + if (receive_message(carrier[1], data, 6, 1, + MSG_WAITALL | MSG_DONTWAIT, + &received) < 0) + return fail(test, "receive plain prefix and first carrier"); + if (received.length != 3 || memcmp(data, "AAB", 3) != 0 || + received.fd_count != 1) + return fail(test, "first receive crossed its byte-range barrier"); + if (expect_pipe_byte(received.fds[0], first_pipe[1], '1') < 0) + return fail(test, "first rights batch was not associated with B"); + close(received.fds[0]); + close(first_pipe[1]); + first_pipe[1] = -1; + + if (receive_message(carrier[1], data, sizeof(data), 1, MSG_DONTWAIT, + &received) < 0) + return fail(test, "receive consecutive carrier"); + if (received.length != 1 || data[0] != 'C' || received.fd_count != 1) + return fail(test, "consecutive rights batches were coalesced"); + if (expect_pipe_byte(received.fds[0], second_pipe[1], '2') < 0) + return fail(test, "second rights batch was not associated with C"); + close(received.fds[0]); + close(second_pipe[1]); + second_pipe[1] = -1; + + memset(data, 0, sizeof(data)); + if (recv(carrier[1], data, sizeof(data), MSG_DONTWAIT) != 2 || + memcmp(data, "DD", 2) != 0) + return fail(test, "plain suffix was not left after both barriers"); + close_pair(carrier); + + if (socketpair(AF_UNIX, SOCK_STREAM, 0, carrier) < 0 || + pipe(first_pipe) < 0 || pipe(second_pipe) < 0) + return fail(test, "ordinary-read setup"); + if (send_rights(carrier[0], "X", 1, &first_pipe[0], 1) != 1 || + send_rights(carrier[0], "Y", 1, &second_pipe[0], 1) != 1) + return fail(test, "ordinary-read queue"); + close(first_pipe[0]); + close(second_pipe[0]); + first_pipe[0] = second_pipe[0] = -1; + + memset(data, 0, sizeof(data)); + if (read(carrier[1], data, sizeof(data)) != 1 || data[0] != 'X') + return fail(test, "ordinary read did not stop at first barrier"); + errno = 0; + if (write(first_pipe[1], "x", 1) != -1 || errno != EPIPE) + return fail(test, "ordinary read did not discard first rights batch"); + close(first_pipe[1]); + first_pipe[1] = -1; + + if (receive_message(carrier[1], data, sizeof(data), 1, MSG_DONTWAIT, + &received) < 0) + return fail(test, "receive after ordinary discard"); + if (received.length != 1 || data[0] != 'Y' || received.fd_count != 1) + return fail(test, "ordinary read left stale rights for recvmsg"); + if (expect_pipe_byte(received.fds[0], second_pipe[1], 'y') < 0) + return fail(test, "later rights batch was not preserved"); + close(received.fds[0]); + close(second_pipe[1]); + close_pair(carrier); + + if (socketpair(AF_UNIX, SOCK_STREAM, 0, carrier) < 0 || + pipe(first_pipe) < 0) + return fail(test, "zero-iovec stream setup"); + if (send_rights_without_iov(carrier[0], &first_pipe[0], 1) != 0) + return fail(test, "zero-iovec stream send"); + if (poll_readable(carrier[1]) != 0) + return fail(test, "zero-iovec stream send queued a carrier"); + close(first_pipe[0]); + first_pipe[0] = -1; + errno = 0; + if (write(first_pipe[1], "x", 1) != -1 || errno != EPIPE) + return fail(test, "zero-iovec stream send retained rights"); + close(first_pipe[1]); + first_pipe[1] = -1; + + int invalid_fd = INT_MAX; + errno = 0; + if (send_rights_without_iov(carrier[0], &invalid_fd, 1) != -1 || + errno != EBADF) + return fail(test, "zero-iovec stream send skipped control validation"); + if (poll_readable(carrier[1]) != 0) + return fail(test, "invalid zero-iovec stream send queued data"); + close_pair(carrier); + + puts("SCM_RIGHTS_STREAM_BARRIER_PASS"); + return 0; +} + +static int test_stream_peek(void) +{ + static const char *const test = "stream MSG_PEEK"; + int carrier[2] = { -1, -1 }; + int data_pipe[2] = { -1, -1 }; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, carrier) < 0 || + pipe(data_pipe) < 0) + return fail(test, "setup"); + if (send_rights(carrier[0], "P", 1, &data_pipe[0], 1) != 1 || + close(data_pipe[0]) < 0) + return fail(test, "queue rights"); + data_pipe[0] = -1; + + char byte = 0; + struct received_message received; + if (receive_message(carrier[1], &byte, 1, 0, + MSG_PEEK | MSG_DONTWAIT, &received) < 0) + return fail(test, "peek without control"); + if (received.length != 1 || byte != 'P' || + (received.flags & MSG_CTRUNC) == 0 || received.fd_count != 0) + return fail(test, "control-less peek did not report truncation"); + if (peek_with_short_control(carrier[1], 'P') < 0) + return fail(test, "short-control peek did not preserve the message"); + + int peeked[2] = { -1, -1 }; + for (size_t i = 0; i < ARRAY_LENGTH(peeked); ++i) { + if (receive_message(carrier[1], &byte, 1, 1, + MSG_PEEK | MSG_DONTWAIT, &received) < 0) + return fail(test, "repeated peek"); + if (received.length != 1 || byte != 'P' || received.fd_count != 1 || + fcntl(received.fds[0], F_GETFD) < 0) + return fail(test, "peek did not install a valid descriptor"); + peeked[i] = received.fds[0]; + } + if (peeked[0] == peeked[1]) + return fail(test, "repeated peek reused one descriptor"); + + if (receive_message(carrier[1], &byte, 1, 1, MSG_DONTWAIT, + &received) < 0) + return fail(test, "normal receive after peeks"); + if (received.length != 1 || byte != 'P' || received.fd_count != 1 || + received.fds[0] == peeked[0] || received.fds[0] == peeked[1]) + return fail(test, "normal receive did not install a third descriptor"); + if (expect_empty_nonblocking(carrier[1]) < 0) + return fail(test, "normal receive did not consume queued message"); + + close(peeked[0]); + close(peeked[1]); + close(received.fds[0]); + close(data_pipe[1]); + close_pair(carrier); + puts("SCM_RIGHTS_STREAM_PEEK_PASS"); + return 0; +} + +static int poll_readable(int fd) +{ + struct pollfd descriptor = { + .fd = fd, + .events = POLLIN, + }; + int result = poll(&descriptor, 1, 0); + if (result < 0) + return -1; + return result == 1 && (descriptor.revents & POLLIN) != 0; +} + +static int make_unix_datagram_endpoints(int pair[2], const char *label, + int connect_sender, + struct sockaddr_un *destination, + socklen_t *destination_len) +{ + static unsigned int sequence; + pair[0] = pair[1] = -1; + int receiver = socket(AF_UNIX, SOCK_DGRAM, 0); + if (receiver < 0) + return -1; + + struct sockaddr_un address; + memset(&address, 0, sizeof(address)); + address.sun_family = AF_UNIX; + int written = snprintf(address.sun_path + 1, sizeof(address.sun_path) - 1, + "kandelo-scm-%s-%ld-%u", label, (long) getpid(), + sequence++); + if (written < 0 || (size_t) written >= sizeof(address.sun_path) - 1) { + close(receiver); + errno = ENAMETOOLONG; + return -1; + } + socklen_t address_len = + (socklen_t) (offsetof(struct sockaddr_un, sun_path) + 1 + + (size_t) written); + if (bind(receiver, (struct sockaddr *) &address, address_len) < 0) { + close(receiver); + return -1; + } + + int sender = socket(AF_UNIX, SOCK_DGRAM, 0); + if (sender < 0 || + (connect_sender && + connect(sender, (struct sockaddr *) &address, address_len) < 0)) { + if (sender >= 0) + close(sender); + close(receiver); + return -1; + } + if (destination) + *destination = address; + if (destination_len) + *destination_len = address_len; + pair[0] = sender; + pair[1] = receiver; + return 0; +} + +static int make_unix_datagram_pair(int pair[2], const char *label) +{ + return make_unix_datagram_endpoints(pair, label, 1, NULL, NULL); +} + +static int test_datagram_rights(void) +{ + static const char *const test = "AF_UNIX datagram rights"; + int carrier[2] = { -1, -1 }; + int addressed[2] = { -1, -1 }; + int data_pipe[2] = { -1, -1 }; + if (make_unix_datagram_pair(carrier, "rights") < 0 || + pipe(data_pipe) < 0) + return fail(test, "setup"); + + if (send_rights(carrier[0], "D", 1, &data_pipe[0], 1) != 1 || + close(data_pipe[0]) < 0) + return fail(test, "send nonempty datagram"); + data_pipe[0] = -1; + char byte = 0; + struct received_message received; + if (receive_message(carrier[1], &byte, 1, 1, MSG_DONTWAIT, + &received) < 0) + return fail(test, "receive nonempty datagram"); + if (received.length != 1 || byte != 'D' || received.fd_count != 1 || + expect_pipe_byte(received.fds[0], data_pipe[1], 'd') < 0) + return fail(test, "nonempty datagram lost its rights"); + close(received.fds[0]); + close(data_pipe[1]); + + struct sockaddr_un destination; + socklen_t destination_len = 0; + if (make_unix_datagram_endpoints(addressed, "addressed", 0, + &destination, &destination_len) < 0 || + pipe(data_pipe) < 0) + return fail(test, "addressed datagram setup"); + if (send_rights_to(addressed[0], "A", 1, &data_pipe[0], 1, + (struct sockaddr *) &destination, + destination_len) != 1 || + close(data_pipe[0]) < 0) + return fail(test, "send addressed rights datagram"); + data_pipe[0] = -1; + if (receive_message(addressed[1], &byte, 1, 1, MSG_DONTWAIT, + &received) < 0) + return fail(test, "receive addressed rights datagram"); + if (received.length != 1 || byte != 'A' || received.fd_count != 1 || + expect_pipe_byte(received.fds[0], data_pipe[1], 'a') < 0) + return fail(test, "addressed datagram lost its rights"); + close(received.fds[0]); + close(data_pipe[1]); + close_pair(addressed); + + if (pipe(data_pipe) < 0) + return fail(test, "zero datagram pipe"); + if (send_rights(carrier[0], "", 0, &data_pipe[0], 1) != 0 || + close(data_pipe[0]) < 0) + return fail(test, "send zero-byte rights datagram"); + data_pipe[0] = -1; + if (poll_readable(carrier[1]) != 1) + return fail(test, "zero-byte datagram was not readable"); + if (receive_message(carrier[1], &byte, 0, 1, MSG_DONTWAIT, + &received) < 0) + return fail(test, "receive zero-byte rights datagram"); + if (received.length != 0 || received.fd_count != 1 || + expect_pipe_byte(received.fds[0], data_pipe[1], 'z') < 0) + return fail(test, "zero-byte datagram lost its rights"); + if (poll_readable(carrier[1]) != 0) + return fail(test, "zero-byte datagram was not consumed"); + close(received.fds[0]); + close(data_pipe[1]); + + if (pipe(data_pipe) < 0) + return fail(test, "zero-iovec datagram pipe"); + if (send_rights_without_iov(carrier[0], &data_pipe[0], 1) != 0 || + close(data_pipe[0]) < 0) + return fail(test, "send zero-iovec rights datagram"); + data_pipe[0] = -1; + if (poll_readable(carrier[1]) != 1) + return fail(test, "zero-iovec datagram was not readable"); + if (receive_message_without_iov(carrier[1], 1, MSG_DONTWAIT, + &received) < 0) + return fail(test, "receive zero-iovec rights datagram"); + if (received.length != 0 || received.fd_count != 1 || + expect_pipe_byte(received.fds[0], data_pipe[1], 'i') < 0) + return fail(test, "zero-iovec datagram lost its rights"); + if (poll_readable(carrier[1]) != 0) + return fail(test, "zero-iovec datagram was not consumed"); + close(received.fds[0]); + close(data_pipe[1]); + + if (pipe(data_pipe) < 0) + return fail(test, "peek datagram pipe"); + if (send_rights(carrier[0], "Q", 1, &data_pipe[0], 1) != 1 || + close(data_pipe[0]) < 0) + return fail(test, "send peek datagram"); + data_pipe[0] = -1; + + int peeked[2] = { -1, -1 }; + for (size_t i = 0; i < ARRAY_LENGTH(peeked); ++i) { + if (receive_message(carrier[1], &byte, 1, 1, + MSG_PEEK | MSG_DONTWAIT, &received) < 0) + return fail(test, "repeated datagram peek"); + if (received.length != 1 || byte != 'Q' || received.fd_count != 1 || + fcntl(received.fds[0], F_GETFD) < 0) + return fail(test, "datagram peek descriptor"); + peeked[i] = received.fds[0]; + } + if (peeked[0] == peeked[1]) + return fail(test, "datagram peeks reused one descriptor"); + if (receive_message(carrier[1], &byte, 1, 1, MSG_DONTWAIT, + &received) < 0) + return fail(test, "consume peeked datagram"); + if (received.length != 1 || byte != 'Q' || received.fd_count != 1 || + received.fds[0] == peeked[0] || received.fds[0] == peeked[1]) + return fail(test, "datagram normal receive descriptor"); + if (expect_empty_nonblocking(carrier[1]) < 0) + return fail(test, "peeked datagram was not consumed once"); + + close(peeked[0]); + close(peeked[1]); + close(received.fds[0]); + close(data_pipe[1]); + close_pair(carrier); + puts("SCM_RIGHTS_DGRAM_ZERO_AND_PEEK_PASS"); + return 0; +} + +static int receive_scatter(int socket_fd, int flags, ssize_t expected_length, + int expect_trunc) +{ + char first[2] = { '?', 'A' }; + char second[3] = { '?', '?', 'B' }; + struct iovec iov[2] = { + { .iov_base = first, .iov_len = 1 }, + { .iov_base = second, .iov_len = 2 }, + }; + struct msghdr message; + memset(&message, 0, sizeof(message)); + message.msg_iov = iov; + message.msg_iovlen = ARRAY_LENGTH(iov); + ssize_t result = recvmsg(socket_fd, &message, flags); + if (result != expected_length || first[0] != 'a' || second[0] != 'b' || + second[1] != 'c' || first[1] != 'A' || second[2] != 'B' || + ((message.msg_flags & MSG_TRUNC) != 0) != expect_trunc) { + errno = EIO; + return -1; + } + return 0; +} + +static int receive_zero_capacity(int socket_fd, int flags, + ssize_t expected_length) +{ + char guard = 'G'; + struct iovec iov = { + .iov_base = &guard, + .iov_len = 0, + }; + struct msghdr message; + memset(&message, 0, sizeof(message)); + message.msg_iov = &iov; + message.msg_iovlen = 1; + ssize_t result = recvmsg(socket_fd, &message, flags); + if (result != expected_length || guard != 'G' || + (message.msg_flags & MSG_TRUNC) == 0) { + errno = EIO; + return -1; + } + return 0; +} + +static int test_datagram_truncation(void) +{ + static const char *const test = "datagram MSG_TRUNC"; + int carrier[2] = { -1, -1 }; + if (make_unix_datagram_pair(carrier, "trunc") < 0) + return fail(test, "setup"); + + if (send(carrier[0], "abcdef", 6, 0) != 6 || + receive_scatter(carrier[1], 0, 3, 1) < 0) + return fail(test, "output MSG_TRUNC without input flag"); + if (send(carrier[0], "abcdef", 6, 0) != 6 || + receive_scatter(carrier[1], MSG_TRUNC, 6, 1) < 0) + return fail(test, "input MSG_TRUNC full-length return"); + if (send(carrier[0], "abc", 3, 0) != 3 || + receive_scatter(carrier[1], 0, 3, 0) < 0) + return fail(test, "exact-capacity datagram"); + if (send(carrier[0], "wxyz", 4, 0) != 4 || + receive_zero_capacity(carrier[1], 0, 0) < 0) + return fail(test, "zero-capacity output MSG_TRUNC"); + if (send(carrier[0], "wxyz", 4, 0) != 4 || + receive_zero_capacity(carrier[1], MSG_TRUNC, 4) < 0) + return fail(test, "zero-capacity full-length MSG_TRUNC"); + + close_pair(carrier); + puts("SCM_RIGHTS_DGRAM_TRUNC_PASS"); + return 0; +} + +static int receive_cloexec_descriptors(int *cloexec_fd, int *plain_fd) +{ + static const char *const test = "MSG_CMSG_CLOEXEC"; + int carrier[2] = { -1, -1 }; + int cloexec_pipe[2] = { -1, -1 }; + int plain_pipe[2] = { -1, -1 }; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, carrier) < 0 || + pipe(cloexec_pipe) < 0 || pipe(plain_pipe) < 0) + return fail(test, "setup"); + if (send_rights(carrier[0], "C", 1, &cloexec_pipe[0], 1) != 1 || + send_rights(carrier[0], "N", 1, &plain_pipe[0], 1) != 1) + return fail(test, "queue descriptors"); + close(cloexec_pipe[0]); + close(plain_pipe[0]); + cloexec_pipe[0] = plain_pipe[0] = -1; + + char byte = 0; + struct received_message received; + if (receive_message(carrier[1], &byte, 1, 1, + MSG_CMSG_CLOEXEC | MSG_DONTWAIT, &received) < 0) + return fail(test, "receive CLOEXEC descriptor"); + if (received.length != 1 || byte != 'C' || received.fd_count != 1 || + (received.flags & MSG_CMSG_CLOEXEC) == 0) + return fail(test, "CLOEXEC receive flags"); + int fd_flags = fcntl(received.fds[0], F_GETFD); + if (fd_flags < 0 || (fd_flags & FD_CLOEXEC) == 0) + return fail(test, "received descriptor was visible without CLOEXEC"); + *cloexec_fd = received.fds[0]; + + if (receive_message(carrier[1], &byte, 1, 1, MSG_DONTWAIT, + &received) < 0) + return fail(test, "receive plain descriptor"); + if (received.length != 1 || byte != 'N' || received.fd_count != 1 || + (received.flags & MSG_CMSG_CLOEXEC) != 0) + return fail(test, "plain receive flags"); + fd_flags = fcntl(received.fds[0], F_GETFD); + if (fd_flags < 0 || (fd_flags & FD_CLOEXEC) != 0) + return fail(test, "plain received descriptor gained CLOEXEC"); + *plain_fd = received.fds[0]; + + if (send(carrier[0], "Z", 1, 0) != 1 || + receive_message(carrier[1], &byte, 1, 1, + MSG_CMSG_CLOEXEC | MSG_DONTWAIT, &received) < 0) + return fail(test, "CLOEXEC receive without rights"); + if (received.length != 1 || byte != 'Z' || received.fd_count != 0 || + (received.flags & MSG_CMSG_CLOEXEC) == 0) + return fail(test, "CLOEXEC input flag was not reflected on output"); + + close(cloexec_pipe[1]); + close(plain_pipe[1]); + close_pair(carrier); + puts("SCM_RIGHTS_CLOEXEC_FLAG_PASS"); + return 0; +} + +static int bind_loopback_socket(int fd, int family) +{ + if (family == AF_INET) { + struct sockaddr_in address; + memset(&address, 0, sizeof(address)); + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + address.sin_port = 0; + return bind(fd, (struct sockaddr *) &address, sizeof(address)); + } + struct sockaddr_in6 address; + memset(&address, 0, sizeof(address)); + address.sin6_family = AF_INET6; + address.sin6_addr = in6addr_loopback; + address.sin6_port = 0; + return bind(fd, (struct sockaddr *) &address, sizeof(address)); +} + +static int connect_bound_socket(int fd, int bound_fd, int family) +{ + if (family == AF_INET) { + struct sockaddr_in address; + socklen_t length = sizeof(address); + memset(&address, 0, sizeof(address)); + if (getsockname(bound_fd, (struct sockaddr *) &address, &length) < 0) + return -1; + return connect(fd, (struct sockaddr *) &address, length); + } + struct sockaddr_in6 address; + socklen_t length = sizeof(address); + memset(&address, 0, sizeof(address)); + if (getsockname(bound_fd, (struct sockaddr *) &address, &length) < 0) + return -1; + return connect(fd, (struct sockaddr *) &address, length); +} + +static int make_nonunix_pair(int family, int type, int pair[2]) +{ + pair[0] = pair[1] = -1; + int bound = socket(family, type, 0); + if (bound < 0 || bind_loopback_socket(bound, family) < 0) + goto error; + if (type == SOCK_DGRAM) { + int sender = socket(family, type, 0); + if (sender < 0 || connect_bound_socket(sender, bound, family) < 0) { + if (sender >= 0) + close(sender); + goto error; + } + pair[0] = sender; + pair[1] = bound; + return 0; + } + + if (listen(bound, 1) < 0) + goto error; + int sender = socket(family, type, 0); + if (sender < 0 || connect_bound_socket(sender, bound, family) < 0) { + if (sender >= 0) + close(sender); + goto error; + } + int receiver = accept(bound, NULL, NULL); + if (receiver < 0) { + close(sender); + goto error; + } + close(bound); + pair[0] = sender; + pair[1] = receiver; + return 0; + +error: + if (bound >= 0) + close(bound); + return -1; +} + +static int expect_nonunix_rights_rejected(int family, int type) +{ + int sockets[2] = { -1, -1 }; + int data_pipe[2] = { -1, -1 }; + if (make_nonunix_pair(family, type, sockets) < 0 || pipe(data_pipe) < 0) + return -1; + + errno = 0; + ssize_t sent = send_rights(sockets[0], "N", 1, &data_pipe[0], 1); + int send_errno = errno; + if (sent != -1 || send_errno != EINVAL) { + errno = send_errno; + return -1; + } + if (expect_empty_nonblocking(sockets[1]) < 0) + return -1; + + close(data_pipe[0]); + data_pipe[0] = -1; + errno = 0; + if (write(data_pipe[1], "x", 1) != -1 || errno != EPIPE) + return -1; + close(data_pipe[1]); + close_pair(sockets); + return 0; +} + +static int test_unrepresentable_descriptor_rejection(void) +{ + static const char *const test = "unrepresentable SCM_RIGHTS descriptor"; + int carrier[2] = { -1, -1 }; + int invalid[4] = { -1, -1, -1, -1 }; + int data_pipe[2] = { -1, -1 }; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, carrier) < 0) + return fail(test, "carrier socketpair"); + /* + * The carrier peer is itself a connected AF_UNIX stream descriptor. + * Include unbound datagram and non-Unix sockets too: transferability is + * rejected for every socket family, type, and state, not a narrow cycle. + */ + invalid[0] = carrier[1]; + invalid[1] = socket(AF_UNIX, SOCK_DGRAM, 0); + invalid[2] = socket(AF_INET, SOCK_DGRAM, 0); + invalid[3] = socket(AF_INET6, SOCK_STREAM, 0); + if (invalid[1] < 0 || invalid[2] < 0 || invalid[3] < 0) + return fail(test, "invalid descriptor setup"); + + for (size_t i = 0; i < ARRAY_LENGTH(invalid); ++i) { + char byte = (char) ('a' + i); + errno = 0; + ssize_t sent = send_rights(carrier[0], &byte, 1, &invalid[i], 1); + int send_errno = errno; + if (sent != -1 || send_errno != EOPNOTSUPP) { + errno = send_errno; + return fail(test, "lossy descriptor was accepted"); + } + if (expect_empty_nonblocking(carrier[1]) < 0) + return fail(test, "rejection published carrier data or rights"); + } + + /* + * The same channel must remain usable for a supported right. Receiving + * this pipe as the first message proves none of the rejected socket + * descriptors left a hidden control record or carrier byte behind. + */ + if (pipe(data_pipe) < 0 || + send_rights(carrier[0], "P", 1, &data_pipe[0], 1) != 1) + return fail(test, "supported descriptor after rejection"); + close(data_pipe[0]); + data_pipe[0] = -1; + char byte = 0; + struct received_message received; + if (receive_message(carrier[1], &byte, 1, 1, 0, &received) < 0 || + received.length != 1 || byte != 'P' || received.fd_count != 1 || + expect_pipe_byte(received.fds[0], data_pipe[1], 'R') < 0) + return fail(test, "supported descriptor was not first in queue"); + + close(received.fds[0]); + close(data_pipe[1]); + for (size_t i = 1; i < ARRAY_LENGTH(invalid); ++i) + close(invalid[i]); + close_pair(carrier); + puts("SCM_RIGHTS_UNREPRESENTABLE_REJECTION_PASS"); + return 0; +} + +static int test_stream_zero_iov_preserves_message(void) +{ + static const char *const test = "zero-iovec AF_UNIX stream recvmsg"; + int carrier[2] = { -1, -1 }; + int data_pipe[2] = { -1, -1 }; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, carrier) < 0 || + pipe(data_pipe) < 0 || + send_rights(carrier[0], "Z", 1, &data_pipe[0], 1) != 1) + return fail(test, "setup"); + + struct received_message empty; + if (receive_message_without_iov(carrier[1], 1, MSG_DONTWAIT, &empty) < 0 || + empty.length != 0 || empty.fd_count != 0) + return fail(test, "zero-iovec receive did not return empty"); + + char byte = 0; + struct received_message received; + if (receive_message(carrier[1], &byte, 1, 1, MSG_DONTWAIT, &received) < 0 || + received.length != 1 || byte != 'Z' || received.fd_count != 1 || + expect_pipe_byte(received.fds[0], data_pipe[1], 'I') < 0) + return fail(test, "zero-iovec receive consumed bytes or rights"); + + close(received.fds[0]); + close_pair(data_pipe); + close_pair(carrier); + puts("SCM_RIGHTS_STREAM_ZERO_IOV_PASS"); + return 0; +} + +static int test_nonunix_rejection(void) +{ + static const char *const test = "non-AF_UNIX SCM_RIGHTS"; + const int families[] = { AF_INET, AF_INET6 }; + const int types[] = { SOCK_STREAM, SOCK_DGRAM }; + for (size_t family = 0; family < ARRAY_LENGTH(families); ++family) { + for (size_t type = 0; type < ARRAY_LENGTH(types); ++type) { + if (expect_nonunix_rights_rejected(families[family], + types[type]) < 0) + return fail(test, "rights were accepted or data was sent"); + } + } + puts("SCM_RIGHTS_NON_UNIX_REJECTION_PASS"); + return 0; +} + +static int parse_fd(const char *text) +{ + char *end = NULL; + errno = 0; + long value = strtol(text, &end, 10); + if (errno != 0 || end == text || *end != '\0' || value < 0 || + value > 0x7fffffffL) { + errno = EINVAL; + return -1; + } + return (int) value; +} + +static int post_exec_probe(const char *cloexec_text, const char *plain_text) +{ + static const char *const test = "post-exec CLOEXEC"; + int cloexec_fd = parse_fd(cloexec_text); + int plain_fd = parse_fd(plain_text); + if (cloexec_fd < 0 || plain_fd < 0) + return fail(test, "invalid descriptor argument"); + + errno = 0; + if (fcntl(cloexec_fd, F_GETFD) != -1 || errno != EBADF) + return fail(test, "CLOEXEC received descriptor survived exec"); + int flags = fcntl(plain_fd, F_GETFD); + if (flags < 0 || (flags & FD_CLOEXEC) != 0) + return fail(test, "plain received descriptor did not survive exec"); + close(plain_fd); + alarm(0); + puts("SCM_RIGHTS_CLOEXEC_EXEC_PASS"); + puts("SCM_RIGHTS_SEMANTICS_PASS"); + return 0; +} + +static int run_cloexec_case(void) +{ + int cloexec_fd = -1; + int plain_fd = -1; + if (receive_cloexec_descriptors(&cloexec_fd, &plain_fd) < 0) + return -1; + + char cloexec_text[32]; + char plain_text[32]; + snprintf(cloexec_text, sizeof(cloexec_text), "%d", cloexec_fd); + snprintf(plain_text, sizeof(plain_text), "%d", plain_fd); + char *const exec_argv[] = { + (char *) self_path, + "--exec-probe", + cloexec_text, + plain_text, + NULL, + }; + char *const exec_env[] = { NULL }; + fflush(NULL); + execve(self_path, exec_argv, exec_env); + return fail("MSG_CMSG_CLOEXEC", "self exec"); +} + +static int run_named_case(const char *name) +{ + if (strcmp(name, "stream") == 0) + return test_stream_barriers(); + if (strcmp(name, "peek") == 0) + return test_stream_peek(); + if (strcmp(name, "datagram") == 0) + return test_datagram_rights(); + if (strcmp(name, "trunc") == 0) + return test_datagram_truncation(); + if (strcmp(name, "domain") == 0) + return test_nonunix_rejection(); + if (strcmp(name, "representability") == 0) + return test_unrepresentable_descriptor_rejection(); + if (strcmp(name, "zero-iov-stream") == 0) + return test_stream_zero_iov_preserves_message(); + if (strcmp(name, "cloexec") == 0) + return run_cloexec_case(); + errno = EINVAL; + return fail("SCM_RIGHTS case", "unknown case"); +} + +int main(int argc, char **argv) +{ + signal(SIGPIPE, SIG_IGN); + alarm(30); + if (argc == 4 && strcmp(argv[1], "--exec-probe") == 0) + return post_exec_probe(argv[2], argv[3]) == 0 ? 0 : 90; + if (argc == 3 && strcmp(argv[1], "--case") == 0) + return run_named_case(argv[2]) == 0 ? 0 : 80; + if (argc != 1) + return fail("SCM_RIGHTS semantics", "invalid arguments") == 0 ? 0 : 2; + + if (test_stream_barriers() < 0 || test_stream_peek() < 0 || + test_datagram_rights() < 0 || test_datagram_truncation() < 0 || + test_nonunix_rejection() < 0 || + test_unrepresentable_descriptor_rejection() < 0 || + test_stream_zero_iov_preserves_message() < 0) + return 1; + return run_cloexec_case() == 0 ? 0 : 1; +} diff --git a/scripts/build-musl.sh b/scripts/build-musl.sh index b1da31dbe1..73717d58ac 100755 --- a/scripts/build-musl.sh +++ b/scripts/build-musl.sh @@ -94,6 +94,15 @@ if [ -d "$OVERLAY_DIR/src" ]; then fi fi +# The installed overlay headers are normally copied after `make install`, but +# limits are also compiled into musl's sysconf implementation. Stage these two +# generated/consumer headers in the source tree before `make` so the runtime +# answer and the public header cannot advertise different Kandelo contracts. +cp "$OVERLAY_DIR/include/limits.h" "$MUSL_DIR/include/limits.h" +mkdir -p "$MUSL_DIR/include/bits" +cp "$OVERLAY_DIR/include/bits/kandelo_limits.h" \ + "$MUSL_DIR/include/bits/kandelo_limits.h" + # musl's src/internal/syscall.h uses syscall_arg_t for the public # varargs syscall() path and also hard-codes it into the non-varargs # __syscall_cp() cancellation-point prototype. On wasm32posix those diff --git a/scripts/build-programs.sh b/scripts/build-programs.sh index a0585ff545..b3e394ac3c 100755 --- a/scripts/build-programs.sh +++ b/scripts/build-programs.sh @@ -405,6 +405,8 @@ if [ -f "$SYSROOT64/lib/libc.a" ]; then "$REPO_ROOT/programs/"hello64.c \ "$REPO_ROOT/programs/"ifhwaddr.c \ "$REPO_ROOT/programs/"posix-timer-thread.c \ + "$REPO_ROOT/programs/"scm-rights-pipe-lifetime.c \ + "$REPO_ROOT/programs/"scm-rights-semantics.c \ "$REPO_ROOT/programs/"sched-getaffinity.c; do [ -f "$src" ] || continue local_name=$(basename "$src" .c) diff --git a/scripts/check-abi-version.sh b/scripts/check-abi-version.sh index 4c3ed1e9e8..77e4be36b4 100755 --- a/scripts/check-abi-version.sh +++ b/scripts/check-abi-version.sh @@ -21,6 +21,10 @@ fi REPO_ROOT="$(git rev-parse --show-toplevel)" cd "$REPO_ROOT" +bash scripts/check-sysv-ipc-layouts.sh +bash scripts/check-process-native-layouts.sh +bash scripts/check-fixed-process-layouts.sh + HOST_TARGET="$(rustc -vV | awk '/^host/ {print $2}')" # The snapshot includes exports parsed from the built kernel wasm. We @@ -87,9 +91,9 @@ version_bumped=0 snapshot_changed=0 if git rev-parse --verify --quiet "$base_ref" >/dev/null ; then if ! git diff --quiet "$base_ref" -- crates/shared/src/lib.rs 2>/dev/null ; then - # Do not use `grep -q` here: with pipefail, an early match can close - # the pipe while a large ABI diff is still being written, turning - # git's SIGPIPE into a false "version was not bumped" result. + # WHY: do not use grep -q here. Under pipefail, an early successful + # grep closes the pipe, git diff receives SIGPIPE, and a real bump is + # misclassified as absent. if git diff "$base_ref" -- crates/shared/src/lib.rs \ | grep -E '^\+pub const ABI_VERSION: u32 = ' >/dev/null ; then version_bumped=1 diff --git a/scripts/check-fixed-process-layouts.sh b/scripts/check-fixed-process-layouts.sh new file mode 100755 index 0000000000..c2870b691c --- /dev/null +++ b/scripts/check-fixed-process-layouts.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# +# Keep fixed native signal/stat/scheduler records synchronized with the actual +# musl structures installed in both Kandelo target sysroots. + +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel)" +source_file="$repo_root/tests/abi/fixed-process-layouts.c" +layout_tmp="$(mktemp -d "${TMPDIR:-/tmp}/kandelo-fixed-layouts.XXXXXX")" + +cleanup() { + rm -f "$layout_tmp/wasm32.o" "$layout_tmp/wasm64.o" + rmdir "$layout_tmp" +} +trap cleanup EXIT + +wasm32posix-cc -std=c11 -Wall -Wextra -Werror \ + -c "$source_file" -o "$layout_tmp/wasm32.o" +wasm64posix-cc -std=c11 -Wall -Wextra -Werror \ + -c "$source_file" -o "$layout_tmp/wasm64.o" + +echo "fixed-process-layouts: wasm32 and wasm64 musl layouts match" diff --git a/scripts/check-process-native-layouts.sh b/scripts/check-process-native-layouts.sh new file mode 100755 index 0000000000..741c237f47 --- /dev/null +++ b/scripts/check-process-native-layouts.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# +# Keep generated caller-native syscall sizes synchronized with the exact musl +# structures installed in both Kandelo target sysroots. + +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel)" +source_file="$repo_root/tests/abi/process-native-layouts.c" +layout_tmp="$(mktemp -d "${TMPDIR:-/tmp}/kandelo-process-layouts.XXXXXX")" + +cleanup() { + rm -f "$layout_tmp/wasm32.o" "$layout_tmp/wasm64.o" + rmdir "$layout_tmp" +} +trap cleanup EXIT + +wasm32posix-cc -std=c11 -Wall -Wextra -Werror \ + -c "$source_file" -o "$layout_tmp/wasm32.o" +wasm64posix-cc -std=c11 -Wall -Wextra -Werror \ + -c "$source_file" -o "$layout_tmp/wasm64.o" + +echo "process-native-layouts: wasm32 and wasm64 musl layouts match" diff --git a/scripts/check-sysv-ipc-layouts.sh b/scripts/check-sysv-ipc-layouts.sh new file mode 100755 index 0000000000..0263a4156a --- /dev/null +++ b/scripts/check-sysv-ipc-layouts.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# +# Keep Rust/host epoll and bounded SysV IPC layouts synchronized with the +# actual musl structures installed in both Kandelo target sysroots. + +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel)" +source_file="$repo_root/tests/abi/sysv-ipc-layouts.c" +layout_tmp="$(mktemp -d "${TMPDIR:-/tmp}/kandelo-ipc-layouts.XXXXXX")" + +cleanup() { + rm -f "$layout_tmp/wasm32.o" "$layout_tmp/wasm64.o" + rmdir "$layout_tmp" +} +trap cleanup EXIT + +wasm32posix-cc -std=c11 -Wall -Wextra -Werror \ + -c "$source_file" -o "$layout_tmp/wasm32.o" +wasm64posix-cc -std=c11 -Wall -Wextra -Werror \ + -c "$source_file" -o "$layout_tmp/wasm64.o" + +echo "native-ipc-layouts: wasm32 and wasm64 musl layouts match" diff --git a/tests/abi/fixed-process-layouts.c b/tests/abi/fixed-process-layouts.c new file mode 100644 index 0000000000..4744748f44 --- /dev/null +++ b/tests/abi/fixed-process-layouts.c @@ -0,0 +1,69 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include + +#define ASSERT_OFFSET(type, field, expected) \ + _Static_assert(offsetof(type, field) == (expected), #type "." #field) + +_Static_assert(sizeof(struct stat) == 112, "struct stat size"); +ASSERT_OFFSET(struct stat, st_dev, 0); +ASSERT_OFFSET(struct stat, st_ino, 8); +ASSERT_OFFSET(struct stat, st_mode, 16); +ASSERT_OFFSET(struct stat, st_nlink, 20); +ASSERT_OFFSET(struct stat, st_uid, 24); +ASSERT_OFFSET(struct stat, st_gid, 28); +ASSERT_OFFSET(struct stat, st_size, 32); +ASSERT_OFFSET(struct stat, st_atim, 40); +ASSERT_OFFSET(struct stat, st_mtim, 56); +ASSERT_OFFSET(struct stat, st_ctim, 72); +ASSERT_OFFSET(struct stat, st_rdev, 88); +ASSERT_OFFSET(struct stat, st_blksize, 96); +ASSERT_OFFSET(struct stat, st_blocks, 104); + +_Static_assert(sizeof(struct sched_param) == 48, "sched_param size"); +ASSERT_OFFSET(struct sched_param, sched_priority, 0); +ASSERT_OFFSET(struct sched_param, sched_ss_max_repl, 4); +ASSERT_OFFSET(struct sched_param, sched_ss_repl_period, 8); +ASSERT_OFFSET(struct sched_param, sched_ss_init_budget, 24); +ASSERT_OFFSET(struct sched_param, sched_ss_low_priority, 40); + +#if __SIZEOF_POINTER__ == 4 + +#include "../../libc/musl-overlay/arch/wasm32posix/kstat.h" + +_Static_assert(sizeof(siginfo_t) == KANDELO_PROCESS_SIGINFO_WASM32_SIZE, + "generated wasm32 siginfo_t size"); +_Static_assert(sizeof(union sigval) == + KANDELO_PROCESS_SIGINFO_WASM32_VALUE_SIZE, + "generated wasm32 siginfo sigval width"); +ASSERT_OFFSET(siginfo_t, si_pid, KANDELO_PROCESS_SIGINFO_WASM32_PID_OFFSET); +ASSERT_OFFSET(siginfo_t, si_uid, KANDELO_PROCESS_SIGINFO_WASM32_UID_OFFSET); +ASSERT_OFFSET(siginfo_t, si_value, + KANDELO_PROCESS_SIGINFO_WASM32_VALUE_OFFSET); + +#elif __SIZEOF_POINTER__ == 8 + +#include "../../libc/musl-overlay/arch/wasm64posix/kstat.h" + +_Static_assert(sizeof(siginfo_t) == KANDELO_PROCESS_SIGINFO_WASM64_SIZE, + "generated wasm64 siginfo_t size"); +_Static_assert(sizeof(union sigval) == + KANDELO_PROCESS_SIGINFO_WASM64_VALUE_SIZE, + "generated wasm64 siginfo sigval width"); +ASSERT_OFFSET(siginfo_t, si_pid, KANDELO_PROCESS_SIGINFO_WASM64_PID_OFFSET); +ASSERT_OFFSET(siginfo_t, si_uid, KANDELO_PROCESS_SIGINFO_WASM64_UID_OFFSET); +ASSERT_OFFSET(siginfo_t, si_value, + KANDELO_PROCESS_SIGINFO_WASM64_VALUE_OFFSET); + +#else +#error "Kandelo supports only four- and eight-byte process pointers" +#endif + +_Static_assert(sizeof(struct kstat) == 112, "native kstat size"); +ASSERT_OFFSET(struct kstat, st_rdev, 88); +ASSERT_OFFSET(struct kstat, st_blksize, 96); +ASSERT_OFFSET(struct kstat, st_blocks, 104); diff --git a/tests/abi/process-native-layouts.c b/tests/abi/process-native-layouts.c new file mode 100644 index 0000000000..11c0e381e9 --- /dev/null +++ b/tests/abi/process-native-layouts.c @@ -0,0 +1,303 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define ASSERT_OFFSET(type, field, expected) \ + _Static_assert(offsetof(type, field) == (expected), #type "." #field) + +_Static_assert(sizeof(long) == sizeof(void *), + "Kandelo process long and pointer widths must match"); +_Static_assert(sizeof(int) == KANDELO_SCM_RIGHTS_FD_BYTES, + "generated SCM_RIGHTS descriptor width"); +_Static_assert(SOL_SOCKET == KANDELO_SOCKET_SOL_SOCKET, + "generated SOL_SOCKET value"); +_Static_assert(SCM_RIGHTS == KANDELO_SOCKET_SCM_RIGHTS, + "generated SCM_RIGHTS value"); +_Static_assert(FD_SETSIZE == KANDELO_SELECT_FD_SETSIZE, + "generated FD_SETSIZE"); +_Static_assert(sizeof(fd_set) == KANDELO_SELECT_FD_SET_BYTES, + "generated fd_set size"); +_Static_assert(sizeof(struct pollfd) == KANDELO_KERNEL_POLLFD_SIZE, + "generated pollfd size"); +ASSERT_OFFSET(struct pollfd, fd, KANDELO_KERNEL_POLLFD_FD_OFFSET); +ASSERT_OFFSET(struct pollfd, events, KANDELO_KERNEL_POLLFD_EVENTS_OFFSET); +ASSERT_OFFSET(struct pollfd, revents, KANDELO_KERNEL_POLLFD_REVENTS_OFFSET); +_Static_assert(sizeof(struct itimerval) == 32, "public time64 itimerval size"); +ASSERT_OFFSET(struct itimerval, it_interval.tv_sec, 0); +ASSERT_OFFSET(struct itimerval, it_interval.tv_usec, 8); +ASSERT_OFFSET(struct itimerval, it_value.tv_sec, 16); +ASSERT_OFFSET(struct itimerval, it_value.tv_usec, 24); +#if __SIZEOF_POINTER__ == 4 + +_Static_assert(sizeof(socklen_t) == KANDELO_SCM_RIGHTS_FD_BYTES, + "wasm32 socklen_t width"); +_Static_assert(sizeof(int) == KANDELO_SCM_RIGHTS_FD_BYTES, + "wasm32 int width"); + +_Static_assert(sizeof(struct iovec) == KANDELO_PROCESS_IOVEC_WASM32_SIZE, + "generated wasm32 iovec size"); +ASSERT_OFFSET(struct iovec, iov_base, + KANDELO_PROCESS_IOVEC_WASM32_BASE_OFFSET); +ASSERT_OFFSET(struct iovec, iov_len, + KANDELO_PROCESS_IOVEC_WASM32_LEN_OFFSET); + +_Static_assert(sizeof(struct msghdr) == KANDELO_PROCESS_MSGHDR_WASM32_SIZE, + "generated wasm32 msghdr size"); +ASSERT_OFFSET(struct msghdr, msg_name, + KANDELO_PROCESS_MSGHDR_WASM32_NAME_OFFSET); +ASSERT_OFFSET(struct msghdr, msg_namelen, + KANDELO_PROCESS_MSGHDR_WASM32_NAMELEN_OFFSET); +ASSERT_OFFSET(struct msghdr, msg_iov, + KANDELO_PROCESS_MSGHDR_WASM32_IOV_OFFSET); +ASSERT_OFFSET(struct msghdr, msg_iovlen, + KANDELO_PROCESS_MSGHDR_WASM32_IOVLEN_OFFSET); +ASSERT_OFFSET(struct msghdr, msg_control, + KANDELO_PROCESS_MSGHDR_WASM32_CONTROL_OFFSET); +ASSERT_OFFSET(struct msghdr, msg_controllen, + KANDELO_PROCESS_MSGHDR_WASM32_CONTROLLEN_OFFSET); +ASSERT_OFFSET(struct msghdr, msg_flags, + KANDELO_PROCESS_MSGHDR_WASM32_FLAGS_OFFSET); + +_Static_assert(sizeof(struct cmsghdr) == + KANDELO_PROCESS_CMSGHDR_WASM32_SIZE, + "generated wasm32 cmsghdr size"); +ASSERT_OFFSET(struct cmsghdr, cmsg_len, + KANDELO_PROCESS_CMSGHDR_WASM32_LEN_OFFSET); +ASSERT_OFFSET(struct cmsghdr, cmsg_level, + KANDELO_PROCESS_CMSGHDR_WASM32_LEVEL_OFFSET); +ASSERT_OFFSET(struct cmsghdr, cmsg_type, + KANDELO_PROCESS_CMSGHDR_WASM32_TYPE_OFFSET); +_Static_assert(CMSG_ALIGN(1) == KANDELO_PROCESS_CMSGHDR_WASM32_ALIGN, + "generated wasm32 CMSG alignment"); +_Static_assert(CMSG_LEN(0) == KANDELO_PROCESS_CMSGHDR_WASM32_DATA_OFFSET, + "generated wasm32 CMSG data offset"); +_Static_assert(CMSG_ALIGN(KANDELO_SCM_RIGHTS_FD_BYTES) == + KANDELO_PROCESS_CMSGHDR_WASM32_ALIGN, + "generated wasm32 SCM_RIGHTS payload alignment"); +_Static_assert(CMSG_LEN(KANDELO_SCM_RIGHTS_FD_BYTES) == + KANDELO_PROCESS_CMSGHDR_WASM32_DATA_OFFSET + + KANDELO_SCM_RIGHTS_FD_BYTES, + "generated wasm32 SCM_RIGHTS one-fd length"); +_Static_assert(CMSG_SPACE(KANDELO_SCM_RIGHTS_FD_BYTES) == + KANDELO_PROCESS_CMSGHDR_WASM32_DATA_OFFSET + + KANDELO_PROCESS_CMSGHDR_WASM32_ALIGN, + "generated wasm32 SCM_RIGHTS one-fd space"); + +_Static_assert(sizeof(stack_t) == 12, "wasm32 stack_t size"); +ASSERT_OFFSET(stack_t, ss_sp, 0); +ASSERT_OFFSET(stack_t, ss_flags, 4); +ASSERT_OFFSET(stack_t, ss_size, 8); +_Static_assert(sizeof(siginfo_t) == KANDELO_PROCESS_SIGINFO_WASM32_SIZE, + "generated wasm32 siginfo_t size"); +ASSERT_OFFSET(siginfo_t, si_signo, KANDELO_PROCESS_SIGINFO_SIGNO_OFFSET); +ASSERT_OFFSET(siginfo_t, si_errno, KANDELO_PROCESS_SIGINFO_ERRNO_OFFSET); +ASSERT_OFFSET(siginfo_t, si_code, KANDELO_PROCESS_SIGINFO_CODE_OFFSET); +ASSERT_OFFSET(siginfo_t, si_pid, KANDELO_PROCESS_SIGINFO_WASM32_PID_OFFSET); +ASSERT_OFFSET(siginfo_t, si_uid, KANDELO_PROCESS_SIGINFO_WASM32_UID_OFFSET); +ASSERT_OFFSET(siginfo_t, si_value, + KANDELO_PROCESS_SIGINFO_WASM32_VALUE_OFFSET); +ASSERT_OFFSET(siginfo_t, si_timerid, + KANDELO_PROCESS_SIGINFO_WASM32_PID_OFFSET); +ASSERT_OFFSET(siginfo_t, si_overrun, + KANDELO_PROCESS_SIGINFO_WASM32_UID_OFFSET); + +/* + * WHY: wasm32 musl translates the public time64 struct above into the + * historical four-native-long kernel record. These width assertions bind the + * generated 16-byte kernel contract to the translation's actual scalar types. + */ +_Static_assert(sizeof(time_t) == 8, "wasm32 time_t width"); +_Static_assert(sizeof(long) == 4, "wasm32 kernel itimerval scalar width"); + +_Static_assert(sizeof(struct mq_attr) == 32, "wasm32 mq_attr size"); +ASSERT_OFFSET(struct mq_attr, mq_flags, 0); +ASSERT_OFFSET(struct mq_attr, mq_maxmsg, 4); +ASSERT_OFFSET(struct mq_attr, mq_msgsize, 8); +ASSERT_OFFSET(struct mq_attr, mq_curmsgs, 12); + +_Static_assert(sizeof(union sigval) == + KANDELO_PROCESS_SIGEVENT_WASM32_VALUE_SIZE, + "generated wasm32 sigval width"); +_Static_assert(sizeof(struct sigevent) == + KANDELO_PROCESS_SIGEVENT_WASM32_SIZE, + "generated wasm32 sigevent size"); +ASSERT_OFFSET(struct sigevent, sigev_value, + KANDELO_PROCESS_SIGEVENT_WASM32_VALUE_OFFSET); +ASSERT_OFFSET(struct sigevent, sigev_signo, + KANDELO_PROCESS_SIGEVENT_WASM32_SIGNO_OFFSET); +ASSERT_OFFSET(struct sigevent, sigev_notify, + KANDELO_PROCESS_SIGEVENT_WASM32_NOTIFY_OFFSET); +ASSERT_OFFSET(struct sigevent, __sev_fields, + KANDELO_PROCESS_SIGEVENT_WASM32_PAYLOAD_OFFSET); + +_Static_assert(sizeof(struct statfs) == 88, "wasm32 statfs size"); +ASSERT_OFFSET(struct statfs, f_type, 0); +ASSERT_OFFSET(struct statfs, f_bsize, 4); +ASSERT_OFFSET(struct statfs, f_blocks, 8); +ASSERT_OFFSET(struct statfs, f_bfree, 16); +ASSERT_OFFSET(struct statfs, f_bavail, 24); +ASSERT_OFFSET(struct statfs, f_files, 32); +ASSERT_OFFSET(struct statfs, f_ffree, 40); +ASSERT_OFFSET(struct statfs, f_fsid, 48); +ASSERT_OFFSET(struct statfs, f_namelen, 56); +ASSERT_OFFSET(struct statfs, f_frsize, 60); +ASSERT_OFFSET(struct statfs, f_flags, 64); +ASSERT_OFFSET(struct statfs, f_spare, 68); + +_Static_assert(sizeof(struct sysinfo) == 312, "wasm32 sysinfo size"); +ASSERT_OFFSET(struct sysinfo, uptime, 0); +ASSERT_OFFSET(struct sysinfo, loads, 4); +ASSERT_OFFSET(struct sysinfo, totalram, 16); +ASSERT_OFFSET(struct sysinfo, freeram, 20); +ASSERT_OFFSET(struct sysinfo, sharedram, 24); +ASSERT_OFFSET(struct sysinfo, bufferram, 28); +ASSERT_OFFSET(struct sysinfo, totalswap, 32); +ASSERT_OFFSET(struct sysinfo, freeswap, 36); +ASSERT_OFFSET(struct sysinfo, procs, 40); +ASSERT_OFFSET(struct sysinfo, pad, 42); +ASSERT_OFFSET(struct sysinfo, totalhigh, 44); +ASSERT_OFFSET(struct sysinfo, freehigh, 48); +ASSERT_OFFSET(struct sysinfo, mem_unit, 52); +ASSERT_OFFSET(struct sysinfo, __reserved, 56); + +#elif __SIZEOF_POINTER__ == 8 + +_Static_assert(sizeof(socklen_t) == KANDELO_SCM_RIGHTS_FD_BYTES, + "wasm64 socklen_t width"); +_Static_assert(sizeof(int) == KANDELO_SCM_RIGHTS_FD_BYTES, + "wasm64 int width"); + +_Static_assert(sizeof(struct iovec) == KANDELO_PROCESS_IOVEC_WASM64_SIZE, + "generated wasm64 iovec size"); +ASSERT_OFFSET(struct iovec, iov_base, + KANDELO_PROCESS_IOVEC_WASM64_BASE_OFFSET); +ASSERT_OFFSET(struct iovec, iov_len, + KANDELO_PROCESS_IOVEC_WASM64_LEN_OFFSET); + +_Static_assert(sizeof(struct msghdr) == KANDELO_PROCESS_MSGHDR_WASM64_SIZE, + "generated wasm64 msghdr size"); +ASSERT_OFFSET(struct msghdr, msg_name, + KANDELO_PROCESS_MSGHDR_WASM64_NAME_OFFSET); +ASSERT_OFFSET(struct msghdr, msg_namelen, + KANDELO_PROCESS_MSGHDR_WASM64_NAMELEN_OFFSET); +ASSERT_OFFSET(struct msghdr, msg_iov, + KANDELO_PROCESS_MSGHDR_WASM64_IOV_OFFSET); +ASSERT_OFFSET(struct msghdr, msg_iovlen, + KANDELO_PROCESS_MSGHDR_WASM64_IOVLEN_OFFSET); +ASSERT_OFFSET(struct msghdr, msg_control, + KANDELO_PROCESS_MSGHDR_WASM64_CONTROL_OFFSET); +ASSERT_OFFSET(struct msghdr, msg_controllen, + KANDELO_PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET); +ASSERT_OFFSET(struct msghdr, msg_flags, + KANDELO_PROCESS_MSGHDR_WASM64_FLAGS_OFFSET); + +_Static_assert(sizeof(struct cmsghdr) == + KANDELO_PROCESS_CMSGHDR_WASM64_SIZE, + "generated wasm64 cmsghdr size"); +ASSERT_OFFSET(struct cmsghdr, cmsg_len, + KANDELO_PROCESS_CMSGHDR_WASM64_LEN_OFFSET); +ASSERT_OFFSET(struct cmsghdr, cmsg_level, + KANDELO_PROCESS_CMSGHDR_WASM64_LEVEL_OFFSET); +ASSERT_OFFSET(struct cmsghdr, cmsg_type, + KANDELO_PROCESS_CMSGHDR_WASM64_TYPE_OFFSET); +_Static_assert(CMSG_ALIGN(1) == KANDELO_PROCESS_CMSGHDR_WASM64_ALIGN, + "generated wasm64 CMSG alignment"); +_Static_assert(CMSG_LEN(0) == KANDELO_PROCESS_CMSGHDR_WASM64_DATA_OFFSET, + "generated wasm64 CMSG data offset"); +_Static_assert(CMSG_ALIGN(KANDELO_SCM_RIGHTS_FD_BYTES) == + KANDELO_PROCESS_CMSGHDR_WASM64_ALIGN, + "generated wasm64 SCM_RIGHTS payload alignment"); +_Static_assert(CMSG_LEN(KANDELO_SCM_RIGHTS_FD_BYTES) == + KANDELO_PROCESS_CMSGHDR_WASM64_DATA_OFFSET + + KANDELO_SCM_RIGHTS_FD_BYTES, + "generated wasm64 SCM_RIGHTS one-fd length"); +_Static_assert(CMSG_SPACE(KANDELO_SCM_RIGHTS_FD_BYTES) == + KANDELO_PROCESS_CMSGHDR_WASM64_DATA_OFFSET + + KANDELO_PROCESS_CMSGHDR_WASM64_ALIGN, + "generated wasm64 SCM_RIGHTS one-fd space"); + +_Static_assert(sizeof(stack_t) == 24, "wasm64 stack_t size"); +ASSERT_OFFSET(stack_t, ss_sp, 0); +ASSERT_OFFSET(stack_t, ss_flags, 8); +ASSERT_OFFSET(stack_t, ss_size, 16); +_Static_assert(sizeof(siginfo_t) == KANDELO_PROCESS_SIGINFO_WASM64_SIZE, + "generated wasm64 siginfo_t size"); +ASSERT_OFFSET(siginfo_t, si_signo, KANDELO_PROCESS_SIGINFO_SIGNO_OFFSET); +ASSERT_OFFSET(siginfo_t, si_errno, KANDELO_PROCESS_SIGINFO_ERRNO_OFFSET); +ASSERT_OFFSET(siginfo_t, si_code, KANDELO_PROCESS_SIGINFO_CODE_OFFSET); +ASSERT_OFFSET(siginfo_t, si_pid, KANDELO_PROCESS_SIGINFO_WASM64_PID_OFFSET); +ASSERT_OFFSET(siginfo_t, si_uid, KANDELO_PROCESS_SIGINFO_WASM64_UID_OFFSET); +ASSERT_OFFSET(siginfo_t, si_value, + KANDELO_PROCESS_SIGINFO_WASM64_VALUE_OFFSET); +ASSERT_OFFSET(siginfo_t, si_timerid, + KANDELO_PROCESS_SIGINFO_WASM64_PID_OFFSET); +ASSERT_OFFSET(siginfo_t, si_overrun, + KANDELO_PROCESS_SIGINFO_WASM64_UID_OFFSET); + +_Static_assert(sizeof(time_t) == 8, "wasm64 time_t width"); +_Static_assert(sizeof(long) == 8, "wasm64 kernel itimerval scalar width"); + +_Static_assert(sizeof(struct mq_attr) == 64, "wasm64 mq_attr size"); +ASSERT_OFFSET(struct mq_attr, mq_flags, 0); +ASSERT_OFFSET(struct mq_attr, mq_maxmsg, 8); +ASSERT_OFFSET(struct mq_attr, mq_msgsize, 16); +ASSERT_OFFSET(struct mq_attr, mq_curmsgs, 24); + +_Static_assert(sizeof(union sigval) == + KANDELO_PROCESS_SIGEVENT_WASM64_VALUE_SIZE, + "generated wasm64 sigval width"); +_Static_assert(sizeof(struct sigevent) == + KANDELO_PROCESS_SIGEVENT_WASM64_SIZE, + "generated wasm64 sigevent size"); +ASSERT_OFFSET(struct sigevent, sigev_value, + KANDELO_PROCESS_SIGEVENT_WASM64_VALUE_OFFSET); +ASSERT_OFFSET(struct sigevent, sigev_signo, + KANDELO_PROCESS_SIGEVENT_WASM64_SIGNO_OFFSET); +ASSERT_OFFSET(struct sigevent, sigev_notify, + KANDELO_PROCESS_SIGEVENT_WASM64_NOTIFY_OFFSET); +ASSERT_OFFSET(struct sigevent, __sev_fields, + KANDELO_PROCESS_SIGEVENT_WASM64_PAYLOAD_OFFSET); + +_Static_assert(sizeof(struct statfs) == 120, "wasm64 statfs size"); +ASSERT_OFFSET(struct statfs, f_type, 0); +ASSERT_OFFSET(struct statfs, f_bsize, 8); +ASSERT_OFFSET(struct statfs, f_blocks, 16); +ASSERT_OFFSET(struct statfs, f_bfree, 24); +ASSERT_OFFSET(struct statfs, f_bavail, 32); +ASSERT_OFFSET(struct statfs, f_files, 40); +ASSERT_OFFSET(struct statfs, f_ffree, 48); +ASSERT_OFFSET(struct statfs, f_fsid, 56); +ASSERT_OFFSET(struct statfs, f_namelen, 64); +ASSERT_OFFSET(struct statfs, f_frsize, 72); +ASSERT_OFFSET(struct statfs, f_flags, 80); +ASSERT_OFFSET(struct statfs, f_spare, 88); + +_Static_assert(sizeof(struct sysinfo) == 368, "wasm64 sysinfo size"); +ASSERT_OFFSET(struct sysinfo, uptime, 0); +ASSERT_OFFSET(struct sysinfo, loads, 8); +ASSERT_OFFSET(struct sysinfo, totalram, 32); +ASSERT_OFFSET(struct sysinfo, freeram, 40); +ASSERT_OFFSET(struct sysinfo, sharedram, 48); +ASSERT_OFFSET(struct sysinfo, bufferram, 56); +ASSERT_OFFSET(struct sysinfo, totalswap, 64); +ASSERT_OFFSET(struct sysinfo, freeswap, 72); +ASSERT_OFFSET(struct sysinfo, procs, 80); +ASSERT_OFFSET(struct sysinfo, pad, 82); +ASSERT_OFFSET(struct sysinfo, totalhigh, 88); +ASSERT_OFFSET(struct sysinfo, freehigh, 96); +ASSERT_OFFSET(struct sysinfo, mem_unit, 104); +ASSERT_OFFSET(struct sysinfo, __reserved, 108); + +#else +#error "Kandelo supports only four- and eight-byte process pointers" +#endif diff --git a/tests/abi/sysv-ipc-layouts.c b/tests/abi/sysv-ipc-layouts.c new file mode 100644 index 0000000000..204f0f0944 --- /dev/null +++ b/tests/abi/sysv-ipc-layouts.c @@ -0,0 +1,104 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include + +#define ASSERT_OFFSET(type, field, expected) \ + _Static_assert(offsetof(type, field) == (expected), #type "." #field) + +ASSERT_OFFSET(struct ipc_perm, __ipc_perm_key, 0); +ASSERT_OFFSET(struct ipc_perm, uid, 4); +ASSERT_OFFSET(struct ipc_perm, gid, 8); +ASSERT_OFFSET(struct ipc_perm, cuid, 12); +ASSERT_OFFSET(struct ipc_perm, cgid, 16); +ASSERT_OFFSET(struct ipc_perm, mode, 20); +ASSERT_OFFSET(struct ipc_perm, __ipc_perm_seq, 24); + +_Static_assert(sizeof(epoll_data_t) == 8, "epoll_data_t size"); +_Static_assert(_Alignof(epoll_data_t) == 8, "epoll_data_t alignment"); +_Static_assert(sizeof(struct epoll_event) == 16, "epoll_event size"); +_Static_assert(_Alignof(struct epoll_event) == 8, "epoll_event alignment"); +ASSERT_OFFSET(struct epoll_event, events, 0); +ASSERT_OFFSET(struct epoll_event, data, 8); + +_Static_assert(sizeof(long) == sizeof(void *), + "Kandelo process long and pointer widths must match"); +ASSERT_OFFSET(struct msgbuf, mtype, 0); +_Static_assert(offsetof(struct msgbuf, mtext) == sizeof(long), + "msgbuf text must follow one native long"); + +#if __SIZEOF_POINTER__ == 4 + +_Static_assert(sizeof(struct ipc_perm) == 36, "wasm32 ipc_perm size"); +_Static_assert(_Alignof(struct ipc_perm) == 4, "wasm32 ipc_perm alignment"); +ASSERT_OFFSET(struct ipc_perm, __pad1, 28); +ASSERT_OFFSET(struct ipc_perm, __pad2, 32); + +_Static_assert(sizeof(struct semid_ds) == 72, "wasm32 semid_ds size"); +ASSERT_OFFSET(struct semid_ds, sem_otime, 40); +ASSERT_OFFSET(struct semid_ds, sem_ctime, 48); +ASSERT_OFFSET(struct semid_ds, sem_nsems, 56); + +_Static_assert(sizeof(struct msqid_ds) == 96, "wasm32 msqid_ds size"); +ASSERT_OFFSET(struct msqid_ds, msg_stime, 40); +ASSERT_OFFSET(struct msqid_ds, msg_rtime, 48); +ASSERT_OFFSET(struct msqid_ds, msg_ctime, 56); +ASSERT_OFFSET(struct msqid_ds, msg_cbytes, 64); +ASSERT_OFFSET(struct msqid_ds, msg_qnum, 68); +ASSERT_OFFSET(struct msqid_ds, msg_qbytes, 72); +ASSERT_OFFSET(struct msqid_ds, msg_lspid, 76); +ASSERT_OFFSET(struct msqid_ds, msg_lrpid, 80); +ASSERT_OFFSET(struct msqid_ds, __unused, 84); + +_Static_assert(sizeof(struct shmid_ds) == 88, "wasm32 shmid_ds size"); +ASSERT_OFFSET(struct shmid_ds, shm_segsz, 36); +ASSERT_OFFSET(struct shmid_ds, shm_atime, 40); +ASSERT_OFFSET(struct shmid_ds, shm_dtime, 48); +ASSERT_OFFSET(struct shmid_ds, shm_ctime, 56); +ASSERT_OFFSET(struct shmid_ds, shm_cpid, 64); +ASSERT_OFFSET(struct shmid_ds, shm_lpid, 68); +ASSERT_OFFSET(struct shmid_ds, shm_nattch, 72); +ASSERT_OFFSET(struct shmid_ds, __pad1, 76); +ASSERT_OFFSET(struct shmid_ds, __pad2, 80); + +#elif __SIZEOF_POINTER__ == 8 + +_Static_assert(sizeof(struct ipc_perm) == 48, "wasm64 ipc_perm size"); +_Static_assert(_Alignof(struct ipc_perm) == 8, "wasm64 ipc_perm alignment"); +ASSERT_OFFSET(struct ipc_perm, __pad1, 32); +ASSERT_OFFSET(struct ipc_perm, __pad2, 40); + +_Static_assert(sizeof(struct semid_ds) == 88, "wasm64 semid_ds size"); +ASSERT_OFFSET(struct semid_ds, sem_otime, 48); +ASSERT_OFFSET(struct semid_ds, sem_ctime, 56); +ASSERT_OFFSET(struct semid_ds, sem_nsems, 64); + +_Static_assert(sizeof(struct msqid_ds) == 120, "wasm64 msqid_ds size"); +ASSERT_OFFSET(struct msqid_ds, msg_stime, 48); +ASSERT_OFFSET(struct msqid_ds, msg_rtime, 56); +ASSERT_OFFSET(struct msqid_ds, msg_ctime, 64); +ASSERT_OFFSET(struct msqid_ds, msg_cbytes, 72); +ASSERT_OFFSET(struct msqid_ds, msg_qnum, 80); +ASSERT_OFFSET(struct msqid_ds, msg_qbytes, 88); +ASSERT_OFFSET(struct msqid_ds, msg_lspid, 96); +ASSERT_OFFSET(struct msqid_ds, msg_lrpid, 100); +ASSERT_OFFSET(struct msqid_ds, __unused, 104); + +_Static_assert(sizeof(struct shmid_ds) == 112, "wasm64 shmid_ds size"); +ASSERT_OFFSET(struct shmid_ds, shm_segsz, 48); +ASSERT_OFFSET(struct shmid_ds, shm_atime, 56); +ASSERT_OFFSET(struct shmid_ds, shm_dtime, 64); +ASSERT_OFFSET(struct shmid_ds, shm_ctime, 72); +ASSERT_OFFSET(struct shmid_ds, shm_cpid, 80); +ASSERT_OFFSET(struct shmid_ds, shm_lpid, 84); +ASSERT_OFFSET(struct shmid_ds, shm_nattch, 88); +ASSERT_OFFSET(struct shmid_ds, __pad1, 96); +ASSERT_OFFSET(struct shmid_ds, __pad2, 104); + +#else +#error "Kandelo supports only four- and eight-byte process pointers" +#endif diff --git a/tools/xtask/src/dump_abi.rs b/tools/xtask/src/dump_abi.rs index b6213784a8..3b9f28454f 100644 --- a/tools/xtask/src/dump_abi.rs +++ b/tools/xtask/src/dump_abi.rs @@ -29,7 +29,7 @@ //! defeat the check. use std::collections::BTreeMap; -use std::mem::{offset_of, size_of}; +use std::mem::{align_of, offset_of, size_of}; use std::path::PathBuf; use serde_json::{Value, json}; @@ -85,18 +85,50 @@ pub fn run(args: Vec) -> Result<(), String> { let snapshot = build_snapshot(&kernel_wasm)?; let rendered = render_deterministic(&snapshot); let header = render_c_header(); + let platform_limits_header = render_platform_limits_header(); + let process_layouts_header = render_process_layouts_header(); + let spawn_header = render_spawn_contract_header(); let ts_module = render_ts_module(); let out = out_path.unwrap_or_else(|| repo_root().join("abi/snapshot.json")); let header_out = repo_root().join("libc/glue/abi_constants.h"); + let platform_limits_header_out = + repo_root().join("libc/musl-overlay/include/bits/kandelo_limits.h"); + let process_layouts_header_out = + repo_root().join("libc/musl-overlay/include/bits/kandelo_process_layouts.h"); + let spawn_header_out = + repo_root().join("libc/musl-overlay/src/process/wasm32posix/spawn_contract.h"); let ts_out = repo_root().join("host/src/generated/abi.ts"); if check { check_file(&out, &rendered, "ABI snapshot")?; check_file(&header_out, &header, "libc/glue/abi_constants.h")?; + check_file( + &platform_limits_header_out, + &platform_limits_header, + "musl Kandelo limits header", + )?; + check_file( + &process_layouts_header_out, + &process_layouts_header, + "musl Kandelo process layouts header", + )?; + check_file(&spawn_header_out, &spawn_header, "musl spawn_contract.h")?; check_file(&ts_out, &ts_module, "host/src/generated/abi.ts")?; println!("abi snapshot up-to-date: {}", out.display()); println!("abi header up-to-date: {}", header_out.display()); + println!( + "platform limits header up-to-date: {}", + platform_limits_header_out.display(), + ); + println!( + "process layouts header up-to-date: {}", + process_layouts_header_out.display(), + ); + println!( + "spawn contract header up-to-date: {}", + spawn_header_out.display(), + ); println!("abi TS bindings up-to-date: {}", ts_out.display()); return Ok(()); } @@ -105,11 +137,286 @@ pub fn run(args: Vec) -> Result<(), String> { println!("wrote {}", out.display()); write_file(&header_out, &header)?; println!("wrote {}", header_out.display()); + write_file(&platform_limits_header_out, &platform_limits_header)?; + println!("wrote {}", platform_limits_header_out.display()); + write_file(&process_layouts_header_out, &process_layouts_header)?; + println!("wrote {}", process_layouts_header_out.display()); + write_file(&spawn_header_out, &spawn_header)?; + println!("wrote {}", spawn_header_out.display()); write_file(&ts_out, &ts_module)?; println!("wrote {}", ts_out.display()); Ok(()) } +fn render_platform_limits_header() -> String { + use shared::platform_limits; + + format!( + "/* GENERATED by `cargo xtask dump-abi`. Do not edit by hand. */\n\ + /* Regenerated by scripts/check-abi-version.sh; drift is a CI failure. */\n\ + #ifndef KANDELO_PLATFORM_LIMITS_H\n\ + #define KANDELO_PLATFORM_LIMITS_H\n\ + \n\ + #define KANDELO_POSIX_ARG_MAX_BYTES {arg_max}u\n\ + #define KANDELO_POSIX_PATH_MAX_BYTES {path_max}u\n\ + #define KANDELO_POSIX_IOV_MAX {iov_max}u\n\ + \n\ + #endif /* KANDELO_PLATFORM_LIMITS_H */\n", + arg_max = platform_limits::ARG_MAX_BYTES, + path_max = platform_limits::PATH_MAX_BYTES, + iov_max = platform_limits::IOV_MAX, + ) +} + +fn render_process_layouts_header() -> String { + use shared::process_layout::{cmsghdr, iovec, msghdr, rt_sigqueueinfo, sigevent}; + + format!( + "/* GENERATED by `cargo xtask dump-abi`. Do not edit by hand. */\n\ + /* Regenerated by scripts/check-abi-version.sh; drift is a CI failure. */\n\ + #ifndef KANDELO_PROCESS_LAYOUTS_H\n\ + #define KANDELO_PROCESS_LAYOUTS_H\n\ + \n\ + #define KANDELO_PROCESS_IOVEC_WASM32_SIZE {iov32_size}u\n\ + #define KANDELO_PROCESS_IOVEC_WASM32_BASE_OFFSET {iov32_base}u\n\ + #define KANDELO_PROCESS_IOVEC_WASM32_LEN_OFFSET {iov32_len}u\n\ + #define KANDELO_PROCESS_IOVEC_WASM64_SIZE {iov64_size}u\n\ + #define KANDELO_PROCESS_IOVEC_WASM64_BASE_OFFSET {iov64_base}u\n\ + #define KANDELO_PROCESS_IOVEC_WASM64_LEN_OFFSET {iov64_len}u\n\ + \n\ + #define KANDELO_PROCESS_MSGHDR_WASM32_SIZE {msg32_size}u\n\ + #define KANDELO_PROCESS_MSGHDR_WASM32_NAME_OFFSET {msg32_name}u\n\ + #define KANDELO_PROCESS_MSGHDR_WASM32_NAMELEN_OFFSET {msg32_namelen}u\n\ + #define KANDELO_PROCESS_MSGHDR_WASM32_IOV_OFFSET {msg32_iov}u\n\ + #define KANDELO_PROCESS_MSGHDR_WASM32_IOVLEN_OFFSET {msg32_iovlen}u\n\ + #define KANDELO_PROCESS_MSGHDR_WASM32_CONTROL_OFFSET {msg32_control}u\n\ + #define KANDELO_PROCESS_MSGHDR_WASM32_CONTROLLEN_OFFSET {msg32_controllen}u\n\ + #define KANDELO_PROCESS_MSGHDR_WASM32_FLAGS_OFFSET {msg32_flags}u\n\ + #define KANDELO_PROCESS_MSGHDR_WASM64_SIZE {msg64_size}u\n\ + #define KANDELO_PROCESS_MSGHDR_WASM64_NAME_OFFSET {msg64_name}u\n\ + #define KANDELO_PROCESS_MSGHDR_WASM64_NAMELEN_OFFSET {msg64_namelen}u\n\ + #define KANDELO_PROCESS_MSGHDR_WASM64_IOV_OFFSET {msg64_iov}u\n\ + #define KANDELO_PROCESS_MSGHDR_WASM64_IOVLEN_OFFSET {msg64_iovlen}u\n\ + #define KANDELO_PROCESS_MSGHDR_WASM64_CONTROL_OFFSET {msg64_control}u\n\ + #define KANDELO_PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET {msg64_controllen}u\n\ + #define KANDELO_PROCESS_MSGHDR_WASM64_FLAGS_OFFSET {msg64_flags}u\n\ + \n\ + #define KANDELO_PROCESS_CMSGHDR_WASM32_SIZE {cmsg32_size}u\n\ + #define KANDELO_PROCESS_CMSGHDR_WASM32_ALIGN {cmsg32_align}u\n\ + #define KANDELO_PROCESS_CMSGHDR_WASM32_LEN_OFFSET {cmsg32_len}u\n\ + #define KANDELO_PROCESS_CMSGHDR_WASM32_LEVEL_OFFSET {cmsg32_level}u\n\ + #define KANDELO_PROCESS_CMSGHDR_WASM32_TYPE_OFFSET {cmsg32_type}u\n\ + #define KANDELO_PROCESS_CMSGHDR_WASM32_DATA_OFFSET {cmsg32_data}u\n\ + #define KANDELO_PROCESS_CMSGHDR_WASM64_SIZE {cmsg64_size}u\n\ + #define KANDELO_PROCESS_CMSGHDR_WASM64_ALIGN {cmsg64_align}u\n\ + #define KANDELO_PROCESS_CMSGHDR_WASM64_LEN_OFFSET {cmsg64_len}u\n\ + #define KANDELO_PROCESS_CMSGHDR_WASM64_LEVEL_OFFSET {cmsg64_level}u\n\ + #define KANDELO_PROCESS_CMSGHDR_WASM64_TYPE_OFFSET {cmsg64_type}u\n\ + #define KANDELO_PROCESS_CMSGHDR_WASM64_DATA_OFFSET {cmsg64_data}u\n\ + \n\ + #define KANDELO_PROCESS_SIGINFO_SIGNO_OFFSET {siginfo_signo}u\n\ + #define KANDELO_PROCESS_SIGINFO_ERRNO_OFFSET {siginfo_errno}u\n\ + #define KANDELO_PROCESS_SIGINFO_CODE_OFFSET {siginfo_code}u\n\ + #define KANDELO_PROCESS_SIGINFO_WASM32_SIZE {siginfo32_size}u\n\ + #define KANDELO_PROCESS_SIGINFO_WASM32_PID_OFFSET {siginfo32_pid}u\n\ + #define KANDELO_PROCESS_SIGINFO_WASM32_UID_OFFSET {siginfo32_uid}u\n\ + #define KANDELO_PROCESS_SIGINFO_WASM32_VALUE_OFFSET {siginfo32_value}u\n\ + #define KANDELO_PROCESS_SIGINFO_WASM32_VALUE_SIZE {siginfo32_value_size}u\n\ + #define KANDELO_PROCESS_SIGINFO_WASM64_SIZE {siginfo64_size}u\n\ + #define KANDELO_PROCESS_SIGINFO_WASM64_PID_OFFSET {siginfo64_pid}u\n\ + #define KANDELO_PROCESS_SIGINFO_WASM64_UID_OFFSET {siginfo64_uid}u\n\ + #define KANDELO_PROCESS_SIGINFO_WASM64_VALUE_OFFSET {siginfo64_value}u\n\ + #define KANDELO_PROCESS_SIGINFO_WASM64_VALUE_SIZE {siginfo64_value_size}u\n\ + \n\ + #define KANDELO_PROCESS_SIGEVENT_WASM32_SIZE {sigevent32_size}u\n\ + #define KANDELO_PROCESS_SIGEVENT_WASM32_VALUE_OFFSET {sigevent32_value}u\n\ + #define KANDELO_PROCESS_SIGEVENT_WASM32_VALUE_SIZE {sigevent32_value_size}u\n\ + #define KANDELO_PROCESS_SIGEVENT_WASM32_SIGNO_OFFSET {sigevent32_signo}u\n\ + #define KANDELO_PROCESS_SIGEVENT_WASM32_NOTIFY_OFFSET {sigevent32_notify}u\n\ + #define KANDELO_PROCESS_SIGEVENT_WASM32_PAYLOAD_OFFSET {sigevent32_payload}u\n\ + #define KANDELO_PROCESS_SIGEVENT_WASM64_SIZE {sigevent64_size}u\n\ + #define KANDELO_PROCESS_SIGEVENT_WASM64_VALUE_OFFSET {sigevent64_value}u\n\ + #define KANDELO_PROCESS_SIGEVENT_WASM64_VALUE_SIZE {sigevent64_value_size}u\n\ + #define KANDELO_PROCESS_SIGEVENT_WASM64_SIGNO_OFFSET {sigevent64_signo}u\n\ + #define KANDELO_PROCESS_SIGEVENT_WASM64_NOTIFY_OFFSET {sigevent64_notify}u\n\ + #define KANDELO_PROCESS_SIGEVENT_WASM64_PAYLOAD_OFFSET {sigevent64_payload}u\n\ + \n\ + #define KANDELO_SOCKET_SOL_SOCKET {sol_socket}u\n\ + #define KANDELO_SOCKET_SCM_RIGHTS {scm_rights}u\n\ + #define KANDELO_SOCKET_MSG_TRUNC {msg_trunc}u\n\ + #define KANDELO_SCM_RIGHTS_FD_BYTES {scm_rights_fd_bytes}u\n\ + \n\ + #define KANDELO_KERNEL_POLLFD_SIZE {pollfd_size}u\n\ + #define KANDELO_KERNEL_POLLFD_FD_OFFSET {pollfd_fd}u\n\ + #define KANDELO_KERNEL_POLLFD_EVENTS_OFFSET {pollfd_events}u\n\ + #define KANDELO_KERNEL_POLLFD_REVENTS_OFFSET {pollfd_revents}u\n\ + \n\ + #define KANDELO_SELECT_FD_SETSIZE {fd_setsize}u\n\ + #define KANDELO_SELECT_FD_SET_BYTES {fd_set_bytes}u\n\ + \n\ + #endif /* KANDELO_PROCESS_LAYOUTS_H */\n", + iov32_size = iovec::WASM32_SIZE, + iov32_base = iovec::WASM32_BASE_OFFSET, + iov32_len = iovec::WASM32_LEN_OFFSET, + iov64_size = iovec::WASM64_SIZE, + iov64_base = iovec::WASM64_BASE_OFFSET, + iov64_len = iovec::WASM64_LEN_OFFSET, + msg32_size = msghdr::WASM32_SIZE, + msg32_name = msghdr::WASM32_NAME_OFFSET, + msg32_namelen = msghdr::WASM32_NAMELEN_OFFSET, + msg32_iov = msghdr::WASM32_IOV_OFFSET, + msg32_iovlen = msghdr::WASM32_IOVLEN_OFFSET, + msg32_control = msghdr::WASM32_CONTROL_OFFSET, + msg32_controllen = msghdr::WASM32_CONTROLLEN_OFFSET, + msg32_flags = msghdr::WASM32_FLAGS_OFFSET, + msg64_size = msghdr::WASM64_SIZE, + msg64_name = msghdr::WASM64_NAME_OFFSET, + msg64_namelen = msghdr::WASM64_NAMELEN_OFFSET, + msg64_iov = msghdr::WASM64_IOV_OFFSET, + msg64_iovlen = msghdr::WASM64_IOVLEN_OFFSET, + msg64_control = msghdr::WASM64_CONTROL_OFFSET, + msg64_controllen = msghdr::WASM64_CONTROLLEN_OFFSET, + msg64_flags = msghdr::WASM64_FLAGS_OFFSET, + cmsg32_size = cmsghdr::WASM32_SIZE, + cmsg32_align = cmsghdr::WASM32_ALIGN, + cmsg32_len = cmsghdr::WASM32_LEN_OFFSET, + cmsg32_level = cmsghdr::WASM32_LEVEL_OFFSET, + cmsg32_type = cmsghdr::WASM32_TYPE_OFFSET, + cmsg32_data = cmsghdr::WASM32_DATA_OFFSET, + cmsg64_size = cmsghdr::WASM64_SIZE, + cmsg64_align = cmsghdr::WASM64_ALIGN, + cmsg64_len = cmsghdr::WASM64_LEN_OFFSET, + cmsg64_level = cmsghdr::WASM64_LEVEL_OFFSET, + cmsg64_type = cmsghdr::WASM64_TYPE_OFFSET, + cmsg64_data = cmsghdr::WASM64_DATA_OFFSET, + siginfo_signo = rt_sigqueueinfo::SIGNO_OFFSET, + siginfo_errno = rt_sigqueueinfo::ERRNO_OFFSET, + siginfo_code = rt_sigqueueinfo::CODE_OFFSET, + siginfo32_size = rt_sigqueueinfo::WASM32_SIZE, + siginfo32_pid = rt_sigqueueinfo::WASM32_PID_OFFSET, + siginfo32_uid = rt_sigqueueinfo::WASM32_UID_OFFSET, + siginfo32_value = rt_sigqueueinfo::WASM32_VALUE_OFFSET, + siginfo32_value_size = rt_sigqueueinfo::WASM32_VALUE_SIZE, + siginfo64_size = rt_sigqueueinfo::WASM64_SIZE, + siginfo64_pid = rt_sigqueueinfo::WASM64_PID_OFFSET, + siginfo64_uid = rt_sigqueueinfo::WASM64_UID_OFFSET, + siginfo64_value = rt_sigqueueinfo::WASM64_VALUE_OFFSET, + siginfo64_value_size = rt_sigqueueinfo::WASM64_VALUE_SIZE, + sigevent32_size = sigevent::WASM32_SIZE, + sigevent32_value = sigevent::WASM32_VALUE_OFFSET, + sigevent32_value_size = sigevent::WASM32_VALUE_SIZE, + sigevent32_signo = sigevent::WASM32_SIGNO_OFFSET, + sigevent32_notify = sigevent::WASM32_NOTIFY_OFFSET, + sigevent32_payload = sigevent::WASM32_PAYLOAD_OFFSET, + sigevent64_size = sigevent::WASM64_SIZE, + sigevent64_value = sigevent::WASM64_VALUE_OFFSET, + sigevent64_value_size = sigevent::WASM64_VALUE_SIZE, + sigevent64_signo = sigevent::WASM64_SIGNO_OFFSET, + sigevent64_notify = sigevent::WASM64_NOTIFY_OFFSET, + sigevent64_payload = sigevent::WASM64_PAYLOAD_OFFSET, + sol_socket = shared::socket::SOL_SOCKET, + scm_rights = shared::socket::SCM_RIGHTS, + msg_trunc = shared::socket::MSG_TRUNC, + scm_rights_fd_bytes = shared::socket::SCM_RIGHTS_FD_BYTES, + pollfd_size = size_of::(), + pollfd_fd = offset_of!(shared::WasmPollFd, fd), + pollfd_events = offset_of!(shared::WasmPollFd, events), + pollfd_revents = offset_of!(shared::WasmPollFd, revents), + fd_setsize = shared::select::FD_SETSIZE, + fd_set_bytes = shared::select::FD_SET_BYTES, + ) +} + +fn render_spawn_contract_header() -> String { + use shared::spawn_contract; + + format!( + "/* GENERATED by `cargo xtask dump-abi`. Do not edit by hand. */\n\ + /* Regenerated by scripts/check-abi-version.sh; drift is a CI failure. */\n\ + #ifndef WASM_POSIX_SPAWN_CONTRACT_H\n\ + #define WASM_POSIX_SPAWN_CONTRACT_H\n\ + \n\ + #include \n\ + \n\ + #define WASM_POSIX_ARG_MAX_BYTES KANDELO_POSIX_ARG_MAX_BYTES\n\ + #define WASM_POSIX_PATH_MAX_BYTES KANDELO_POSIX_PATH_MAX_BYTES\n\ + #define WASM_POSIX_SYS_SPAWN {sys_spawn}u\n\ + #define WASM_POSIX_SPAWN_HEADER_BYTES {header_bytes}u\n\ + #define WASM_POSIX_SPAWN_STRING_OFFSET_BYTES {string_offset_bytes}u\n\ + #define WASM_POSIX_SPAWN_HEADER_ARGC_OFFSET {header_argc_offset}u\n\ + #define WASM_POSIX_SPAWN_HEADER_ENVC_OFFSET {header_envc_offset}u\n\ + #define WASM_POSIX_SPAWN_HEADER_ACTION_COUNT_OFFSET {header_action_count_offset}u\n\ + #define WASM_POSIX_SPAWN_HEADER_ATTR_FLAGS_OFFSET {header_attr_flags_offset}u\n\ + #define WASM_POSIX_SPAWN_HEADER_PGRP_OFFSET {header_pgrp_offset}u\n\ + #define WASM_POSIX_SPAWN_HEADER_PAD_OFFSET {header_pad_offset}u\n\ + #define WASM_POSIX_SPAWN_HEADER_SIGDEF_OFFSET {header_sigdef_offset}u\n\ + #define WASM_POSIX_SPAWN_HEADER_SIGMASK_OFFSET {header_sigmask_offset}u\n\ + #define WASM_POSIX_SPAWN_ACTION_RECORD_BYTES {action_bytes}u\n\ + #define WASM_POSIX_SPAWN_ACTION_OP_OFFSET {action_op_offset}u\n\ + #define WASM_POSIX_SPAWN_ACTION_FD_OFFSET {action_fd_offset}u\n\ + #define WASM_POSIX_SPAWN_ACTION_NEWFD_OFFSET {action_newfd_offset}u\n\ + #define WASM_POSIX_SPAWN_ACTION_PATH_OFF_OFFSET {action_path_off_offset}u\n\ + #define WASM_POSIX_SPAWN_ACTION_PATH_LEN_OFFSET {action_path_len_offset}u\n\ + #define WASM_POSIX_SPAWN_ACTION_OFLAG_OFFSET {action_oflag_offset}u\n\ + #define WASM_POSIX_SPAWN_ACTION_MODE_OFFSET {action_mode_offset}u\n\ + #define WASM_POSIX_SPAWN_OP_OPEN {op_open}u\n\ + #define WASM_POSIX_SPAWN_OP_CLOSE {op_close}u\n\ + #define WASM_POSIX_SPAWN_OP_DUP2 {op_dup2}u\n\ + #define WASM_POSIX_SPAWN_OP_CHDIR {op_chdir}u\n\ + #define WASM_POSIX_SPAWN_OP_FCHDIR {op_fchdir}u\n\ + #define WASM_POSIX_SPAWN_ATTR_RESETIDS {attr_resetids}u\n\ + #define WASM_POSIX_SPAWN_ATTR_SETPGROUP {attr_setpgroup}u\n\ + #define WASM_POSIX_SPAWN_ATTR_SETSIGDEF {attr_setsigdef}u\n\ + #define WASM_POSIX_SPAWN_ATTR_SETSIGMASK {attr_setsigmask}u\n\ + #define WASM_POSIX_SPAWN_ATTR_SETSCHEDPARAM {attr_setschedparam}u\n\ + #define WASM_POSIX_SPAWN_ATTR_SETSCHEDULER {attr_setscheduler}u\n\ + #define WASM_POSIX_SPAWN_ATTR_USEVFORK {attr_usevfork}u\n\ + #define WASM_POSIX_SPAWN_ATTR_SETSID {attr_setsid}u\n\ + #define WASM_POSIX_SPAWN_MAX_ARGV_COUNT {max_argv}u\n\ + #define WASM_POSIX_SPAWN_MAX_ENVP_COUNT {max_envp}u\n\ + #define WASM_POSIX_SPAWN_MAX_ACTION_COUNT {max_actions}u\n\ + #define WASM_POSIX_SPAWN_WIRE_MAX_BYTES {wire_max}u\n\ + \n\ + #endif /* WASM_POSIX_SPAWN_CONTRACT_H */\n", + sys_spawn = shared::abi::host_intercepted::SYS_SPAWN, + header_bytes = spawn_contract::WIRE_HEADER_BYTES, + string_offset_bytes = spawn_contract::WIRE_STRING_OFFSET_BYTES, + header_argc_offset = spawn_contract::WIRE_HEADER_ARGC_OFFSET, + header_envc_offset = spawn_contract::WIRE_HEADER_ENVC_OFFSET, + header_action_count_offset = spawn_contract::WIRE_HEADER_ACTION_COUNT_OFFSET, + header_attr_flags_offset = spawn_contract::WIRE_HEADER_ATTR_FLAGS_OFFSET, + header_pgrp_offset = spawn_contract::WIRE_HEADER_PGRP_OFFSET, + header_pad_offset = spawn_contract::WIRE_HEADER_PAD_OFFSET, + header_sigdef_offset = spawn_contract::WIRE_HEADER_SIGDEF_OFFSET, + header_sigmask_offset = spawn_contract::WIRE_HEADER_SIGMASK_OFFSET, + action_bytes = spawn_contract::WIRE_ACTION_RECORD_BYTES, + action_op_offset = spawn_contract::WIRE_ACTION_OP_OFFSET, + action_fd_offset = spawn_contract::WIRE_ACTION_FD_OFFSET, + action_newfd_offset = spawn_contract::WIRE_ACTION_NEWFD_OFFSET, + action_path_off_offset = spawn_contract::WIRE_ACTION_PATH_OFF_OFFSET, + action_path_len_offset = spawn_contract::WIRE_ACTION_PATH_LEN_OFFSET, + action_oflag_offset = spawn_contract::WIRE_ACTION_OFLAG_OFFSET, + action_mode_offset = spawn_contract::WIRE_ACTION_MODE_OFFSET, + op_open = spawn_contract::WIRE_OP_OPEN, + op_close = spawn_contract::WIRE_OP_CLOSE, + op_dup2 = spawn_contract::WIRE_OP_DUP2, + op_chdir = spawn_contract::WIRE_OP_CHDIR, + op_fchdir = spawn_contract::WIRE_OP_FCHDIR, + attr_resetids = spawn_contract::ATTR_RESETIDS, + attr_setpgroup = spawn_contract::ATTR_SETPGROUP, + attr_setsigdef = spawn_contract::ATTR_SETSIGDEF, + attr_setsigmask = spawn_contract::ATTR_SETSIGMASK, + attr_setschedparam = spawn_contract::ATTR_SETSCHEDPARAM, + attr_setscheduler = spawn_contract::ATTR_SETSCHEDULER, + attr_usevfork = spawn_contract::ATTR_USEVFORK, + attr_setsid = spawn_contract::ATTR_SETSID, + max_argv = spawn_contract::MAX_ARGV_COUNT, + max_envp = spawn_contract::MAX_ENVP_COUNT, + max_actions = spawn_contract::MAX_ACTION_COUNT, + wire_max = spawn_contract::WIRE_MAX_BYTES, + ) +} + fn check_file(path: &std::path::Path, expected: &str, label: &str) -> Result<(), String> { let existing = std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?; @@ -167,25 +474,163 @@ fn classify_compat_files( /// C header consumed by `libc/glue/channel_syscall.c` and any other C code /// that needs to agree with Rust on ABI-surface constants. fn render_c_header() -> String { - format!( + let mut out = format!( "/* GENERATED by `cargo xtask dump-abi`. Do not edit by hand. */\n\ /* Regenerated by scripts/check-abi-version.sh; drift is a CI failure. */\n\ #ifndef WASM_POSIX_ABI_CONSTANTS_H\n\ #define WASM_POSIX_ABI_CONSTANTS_H\n\ \n\ + #include \n\ + \n\ /* Mirrors wasm_posix_shared::ABI_VERSION. */\n\ #define WASM_POSIX_ABI_VERSION {version}u\n\ \n\ + /* Non-forking spawn syscall number. */\n\ + #define WASM_POSIX_SYS_SPAWN {sys_spawn}u\n\ + \n\ /* Default process-wasm pthread slot declaration. */\n\ #define WASM_POSIX_THREAD_SLOT_DECL_DEFAULT {thread_slots_default}\n\ \n\ /* Fixed kernel/musl resource-usage wire record size. */\n\ #define WASM_POSIX_RUSAGE_WIRE_SIZE {rusage_wire_size}u\n\ \n\ - #endif /* WASM_POSIX_ABI_CONSTANTS_H */\n", + /* Exact musl termios wire record size. */\n\ + #define WASM_POSIX_TERMIOS_SIZE {termios_size}u\n\ + \n", version = shared::ABI_VERSION, + sys_spawn = shared::abi::host_intercepted::SYS_SPAWN, thread_slots_default = shared::process_memory::THREAD_SLOTS_USE_HOST_DEFAULT, rusage_wire_size = shared::WASM_RUSAGE_WIRE_SIZE, + termios_size = shared::ioctl_contract::TERMIOS_SIZE, + ); + out.push_str(&render_c_channel_contract()); + out.push_str( + "/* A known request without a lossless layout for this caller. */\n\ + #define WASM_POSIX_IOCTL_UNSUPPORTED_SIZE UINT32_MAX\n\ + \n\ + static inline uint32_t\n\ + wasm_posix_ioctl_arg_size(uint32_t request, uint32_t pointer_width)\n\ + {\n\ + switch (request) {\n", + ); + for contract in shared::ioctl_contract::IOCTL_REQUEST_CONTRACTS { + let wasm32 = contract + .wasm32_size + .map(|size| format!("{size}u")) + .unwrap_or_else(|| "WASM_POSIX_IOCTL_UNSUPPORTED_SIZE".into()); + let wasm64 = contract + .wasm64_size + .map(|size| format!("{size}u")) + .unwrap_or_else(|| "WASM_POSIX_IOCTL_UNSUPPORTED_SIZE".into()); + out.push_str(&format!( + " case 0x{:08x}u:\n\ + return pointer_width == 4u ? {wasm32} :\n\ + pointer_width == 8u ? {wasm64} :\n\ + WASM_POSIX_IOCTL_UNSUPPORTED_SIZE;\n", + contract.request + )); + } + out.push_str( + " default:\n\ + return 0u;\n\ + }\n\ + }\n\ + \n\ + #endif /* WASM_POSIX_ABI_CONSTANTS_H */\n", + ); + out +} + +fn render_c_channel_contract() -> String { + use shared::channel; + + format!( + "/* Shared syscall-channel status values. */\n\ + #define WASM_POSIX_CHANNEL_STATUS_IDLE {status_idle}u\n\ + #define WASM_POSIX_CHANNEL_STATUS_PENDING {status_pending}u\n\ + #define WASM_POSIX_CHANNEL_STATUS_COMPLETE {status_complete}u\n\ + #define WASM_POSIX_CHANNEL_STATUS_ERROR {status_error}u\n\ + \n\ + /* Shared syscall-channel layout. */\n\ + #define WASM_POSIX_CHANNEL_STATUS_OFFSET {status_offset}u\n\ + #define WASM_POSIX_CHANNEL_STATUS_SIZE {status_size}u\n\ + #define WASM_POSIX_CHANNEL_SYSCALL_OFFSET {syscall_offset}u\n\ + #define WASM_POSIX_CHANNEL_SYSCALL_SIZE {syscall_size}u\n\ + #define WASM_POSIX_CHANNEL_ARGS_OFFSET {args_offset}u\n\ + #define WASM_POSIX_CHANNEL_ARGS_COUNT {args_count}u\n\ + #define WASM_POSIX_CHANNEL_ARG_SIZE {arg_size}u\n\ + #define WASM_POSIX_CHANNEL_RETURN_OFFSET {return_offset}u\n\ + #define WASM_POSIX_CHANNEL_RETURN_SIZE {return_size}u\n\ + #define WASM_POSIX_CHANNEL_ERRNO_OFFSET {errno_offset}u\n\ + #define WASM_POSIX_CHANNEL_ERRNO_SIZE {errno_size}u\n\ + #define WASM_POSIX_CHANNEL_REQUEST_FLAGS_OFFSET {request_flags_offset}u\n\ + #define WASM_POSIX_CHANNEL_REQUEST_FLAGS_SIZE {request_flags_size}u\n\ + #define WASM_POSIX_CHANNEL_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY {defer_signal_delivery}u\n\ + #define WASM_POSIX_CHANNEL_DATA_OFFSET {data_offset}u\n\ + #define WASM_POSIX_CHANNEL_DATA_SIZE {data_size}u\n\ + #define WASM_POSIX_CHANNEL_HEADER_SIZE {header_size}u\n\ + #define WASM_POSIX_CHANNEL_MIN_SIZE {min_size}u\n\ + \n\ + /* Signal-delivery wire at the end of the channel data buffer. */\n\ + #define WASM_POSIX_CHANNEL_SIG_AREA_SIZE {sig_area_size}u\n\ + #define WASM_POSIX_CHANNEL_SIG_DELIVERY_SIZE {sig_delivery_size}u\n\ + #define WASM_POSIX_CHANNEL_SIG_WORD_BYTES {sig_word_bytes}u\n\ + #define WASM_POSIX_CHANNEL_SIG_SI_VALUE_BYTES {sig_si_value_bytes}u\n\ + #define WASM_POSIX_CHANNEL_SIG_OLD_MASK_BYTES {sig_old_mask_bytes}u\n\ + #define WASM_POSIX_CHANNEL_SIG_ALT_SP_BYTES {sig_alt_sp_bytes}u\n\ + #define WASM_POSIX_CHANNEL_SIG_ALT_SIZE_BYTES {sig_alt_size_bytes}u\n\ + #define WASM_POSIX_CHANNEL_SIG_BASE_OFFSET {sig_base}u\n\ + #define WASM_POSIX_CHANNEL_SIG_SIGNUM_OFFSET {sig_signum}u\n\ + #define WASM_POSIX_CHANNEL_SIG_HANDLER_OFFSET {sig_handler}u\n\ + #define WASM_POSIX_CHANNEL_SIG_FLAGS_OFFSET {sig_flags}u\n\ + #define WASM_POSIX_CHANNEL_SIG_SI_VALUE_OFFSET {sig_si_value}u\n\ + #define WASM_POSIX_CHANNEL_SIG_OLD_MASK_OFFSET {sig_old_mask}u\n\ + #define WASM_POSIX_CHANNEL_SIG_SI_CODE_OFFSET {sig_si_code}u\n\ + #define WASM_POSIX_CHANNEL_SIGINFO_WORD_1_OFFSET {siginfo_word_1}u\n\ + #define WASM_POSIX_CHANNEL_SIGINFO_WORD_2_OFFSET {siginfo_word_2}u\n\ + #define WASM_POSIX_CHANNEL_SIG_ALT_SP_OFFSET {sig_alt_sp}u\n\ + #define WASM_POSIX_CHANNEL_SIG_ALT_SIZE_OFFSET {sig_alt_size}u\n\ + \n", + status_idle = shared::ChannelStatus::Idle as u32, + status_pending = shared::ChannelStatus::Pending as u32, + status_complete = shared::ChannelStatus::Complete as u32, + status_error = shared::ChannelStatus::Error as u32, + status_offset = channel::STATUS_OFFSET, + status_size = channel::STATUS_SIZE, + syscall_offset = channel::SYSCALL_OFFSET, + syscall_size = channel::SYSCALL_SIZE, + args_offset = channel::ARGS_OFFSET, + args_count = channel::ARGS_COUNT, + arg_size = channel::ARG_SIZE, + return_offset = channel::RETURN_OFFSET, + return_size = channel::RETURN_SIZE, + errno_offset = channel::ERRNO_OFFSET, + errno_size = channel::ERRNO_SIZE, + request_flags_offset = channel::REQUEST_FLAGS_OFFSET, + request_flags_size = channel::REQUEST_FLAGS_SIZE, + defer_signal_delivery = channel::REQUEST_FLAG_DEFER_SIGNAL_DELIVERY, + data_offset = channel::DATA_OFFSET, + data_size = channel::DATA_SIZE, + header_size = channel::HEADER_SIZE, + min_size = channel::MIN_CHANNEL_SIZE, + sig_area_size = channel::SIG_AREA_SIZE, + sig_delivery_size = channel::SIG_DELIVERY_SIZE, + sig_word_bytes = shared::kernel_scratch_wire::SIGNAL_WORD_BYTES, + sig_si_value_bytes = shared::kernel_scratch_wire::SIGNAL_SI_VALUE_BYTES, + sig_old_mask_bytes = shared::kernel_scratch_wire::SIGNAL_OLD_MASK_BYTES, + sig_alt_sp_bytes = shared::kernel_scratch_wire::SIGNAL_ALT_SP_BYTES, + sig_alt_size_bytes = shared::kernel_scratch_wire::SIGNAL_ALT_SIZE_BYTES, + sig_base = channel::SIG_BASE, + sig_signum = channel::SIG_SIGNUM, + sig_handler = channel::SIG_HANDLER, + sig_flags = channel::SIG_FLAGS, + sig_si_value = channel::SIG_SI_VALUE, + sig_old_mask = channel::SIG_OLD_MASK, + sig_si_code = channel::SIG_SI_CODE, + siginfo_word_1 = channel::SIGINFO_WORD_1, + siginfo_word_2 = channel::SIGINFO_WORD_2, + sig_alt_sp = channel::SIG_ALT_SP, + sig_alt_size = channel::SIG_ALT_SIZE, ) } @@ -1280,6 +1725,410 @@ fn render_ts_module() -> String { "export const SCHED_AFFINITY_MASK_SIZE = {} as const;\n\n", shared::SCHED_AFFINITY_MASK_SIZE )); + out.push_str(&format!( + "export const KERNEL_SCRATCH_SIGNAL_DELIVERY_BYTES = {} as const;\n", + shared::kernel_scratch_wire::SIGNAL_DELIVERY_BYTES + )); + out.push_str(&format!( + "export const KERNEL_SCRATCH_FD_PAIR_BYTES = {} as const;\n", + shared::kernel_scratch_wire::FD_PAIR_BYTES + )); + out.push_str(&format!( + "export const KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES = {} as const;\n", + shared::kernel_scratch_wire::MQUEUE_NOTIFICATION_BYTES + )); + out.push_str(&format!( + "export const KERNEL_SCRATCH_SOCKLEN_BYTES = {} as const;\n", + shared::kernel_scratch_wire::SOCKLEN_BYTES + )); + out.push_str(&format!( + "export const PR_SET_NAME = {} as const;\n", + shared::prctl::PR_SET_NAME + )); + out.push_str(&format!( + "export const PR_GET_NAME = {} as const;\n", + shared::prctl::PR_GET_NAME + )); + out.push_str(&format!( + "export const PRCTL_NAME_BYTES = {} as const;\n", + shared::kernel_scratch_wire::PRCTL_NAME_BYTES + )); + out.push_str(&format!( + "export const FCNTL_FLOCK_BYTES = {} as const;\n", + shared::kernel_scratch_wire::FCNTL_FLOCK_BYTES + )); + out.push_str(&format!( + "export const SIGNAL_MASK_BYTES = {} as const;\n\n", + shared::kernel_scratch_wire::SIGNAL_MASK_BYTES + )); + out.push_str(&format!( + "export const POSIX_ARG_MAX_BYTES = {} as const;\n", + shared::platform_limits::ARG_MAX_BYTES + )); + out.push_str(&format!( + "export const POSIX_PATH_MAX_BYTES = {} as const;\n", + shared::platform_limits::PATH_MAX_BYTES + )); + out.push_str(&format!( + "export const POSIX_IOV_MAX = {} as const;\n", + shared::platform_limits::IOV_MAX + )); + out.push_str(&format!( + "export const SELECT_FD_SETSIZE = {} as const;\n", + shared::select::FD_SETSIZE + )); + out.push_str(&format!( + "export const SELECT_FD_SET_BYTES = {} as const;\n", + shared::select::FD_SET_BYTES + )); + out.push_str(&format!( + "export const PROCESS_IOVEC_WASM32_SIZE = {} as const;\n", + shared::process_layout::iovec::WASM32_SIZE + )); + out.push_str(&format!( + "export const PROCESS_IOVEC_WASM32_BASE_OFFSET = {} as const;\n", + shared::process_layout::iovec::WASM32_BASE_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_IOVEC_WASM32_LEN_OFFSET = {} as const;\n", + shared::process_layout::iovec::WASM32_LEN_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_IOVEC_WASM64_SIZE = {} as const;\n", + shared::process_layout::iovec::WASM64_SIZE + )); + out.push_str(&format!( + "export const PROCESS_IOVEC_WASM64_BASE_OFFSET = {} as const;\n", + shared::process_layout::iovec::WASM64_BASE_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_IOVEC_WASM64_LEN_OFFSET = {} as const;\n", + shared::process_layout::iovec::WASM64_LEN_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_MSGHDR_WASM32_SIZE = {} as const;\n", + shared::process_layout::msghdr::WASM32_SIZE + )); + out.push_str(&format!( + "export const PROCESS_MSGHDR_WASM32_NAME_OFFSET = {} as const;\n", + shared::process_layout::msghdr::WASM32_NAME_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_MSGHDR_WASM32_NAMELEN_OFFSET = {} as const;\n", + shared::process_layout::msghdr::WASM32_NAMELEN_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_MSGHDR_WASM32_IOV_OFFSET = {} as const;\n", + shared::process_layout::msghdr::WASM32_IOV_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_MSGHDR_WASM32_IOVLEN_OFFSET = {} as const;\n", + shared::process_layout::msghdr::WASM32_IOVLEN_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_MSGHDR_WASM32_CONTROL_OFFSET = {} as const;\n", + shared::process_layout::msghdr::WASM32_CONTROL_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_MSGHDR_WASM32_CONTROLLEN_OFFSET = {} as const;\n", + shared::process_layout::msghdr::WASM32_CONTROLLEN_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_MSGHDR_WASM32_FLAGS_OFFSET = {} as const;\n", + shared::process_layout::msghdr::WASM32_FLAGS_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_MSGHDR_WASM64_SIZE = {} as const;\n", + shared::process_layout::msghdr::WASM64_SIZE + )); + out.push_str(&format!( + "export const PROCESS_MSGHDR_WASM64_NAME_OFFSET = {} as const;\n", + shared::process_layout::msghdr::WASM64_NAME_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_MSGHDR_WASM64_NAMELEN_OFFSET = {} as const;\n", + shared::process_layout::msghdr::WASM64_NAMELEN_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_MSGHDR_WASM64_IOV_OFFSET = {} as const;\n", + shared::process_layout::msghdr::WASM64_IOV_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_MSGHDR_WASM64_IOVLEN_OFFSET = {} as const;\n", + shared::process_layout::msghdr::WASM64_IOVLEN_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_MSGHDR_WASM64_CONTROL_OFFSET = {} as const;\n", + shared::process_layout::msghdr::WASM64_CONTROL_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET = {} as const;\n", + shared::process_layout::msghdr::WASM64_CONTROLLEN_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_MSGHDR_WASM64_FLAGS_OFFSET = {} as const;\n", + shared::process_layout::msghdr::WASM64_FLAGS_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_CMSGHDR_WASM32_SIZE = {} as const;\n", + shared::process_layout::cmsghdr::WASM32_SIZE + )); + out.push_str(&format!( + "export const PROCESS_CMSGHDR_WASM32_ALIGN = {} as const;\n", + shared::process_layout::cmsghdr::WASM32_ALIGN + )); + out.push_str(&format!( + "export const PROCESS_CMSGHDR_WASM32_LEN_OFFSET = {} as const;\n", + shared::process_layout::cmsghdr::WASM32_LEN_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_CMSGHDR_WASM32_LEVEL_OFFSET = {} as const;\n", + shared::process_layout::cmsghdr::WASM32_LEVEL_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_CMSGHDR_WASM32_TYPE_OFFSET = {} as const;\n", + shared::process_layout::cmsghdr::WASM32_TYPE_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_CMSGHDR_WASM32_DATA_OFFSET = {} as const;\n", + shared::process_layout::cmsghdr::WASM32_DATA_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_CMSGHDR_WASM64_SIZE = {} as const;\n", + shared::process_layout::cmsghdr::WASM64_SIZE + )); + out.push_str(&format!( + "export const PROCESS_CMSGHDR_WASM64_ALIGN = {} as const;\n", + shared::process_layout::cmsghdr::WASM64_ALIGN + )); + out.push_str(&format!( + "export const PROCESS_CMSGHDR_WASM64_LEN_OFFSET = {} as const;\n", + shared::process_layout::cmsghdr::WASM64_LEN_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_CMSGHDR_WASM64_LEVEL_OFFSET = {} as const;\n", + shared::process_layout::cmsghdr::WASM64_LEVEL_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_CMSGHDR_WASM64_TYPE_OFFSET = {} as const;\n", + shared::process_layout::cmsghdr::WASM64_TYPE_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_CMSGHDR_WASM64_DATA_OFFSET = {} as const;\n", + shared::process_layout::cmsghdr::WASM64_DATA_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_SIGINFO_SIGNO_OFFSET = {} as const;\n", + shared::process_layout::rt_sigqueueinfo::SIGNO_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_SIGINFO_ERRNO_OFFSET = {} as const;\n", + shared::process_layout::rt_sigqueueinfo::ERRNO_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_SIGINFO_CODE_OFFSET = {} as const;\n", + shared::process_layout::rt_sigqueueinfo::CODE_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_SIGINFO_WASM32_SIZE = {} as const;\n", + shared::process_layout::rt_sigqueueinfo::WASM32_SIZE + )); + out.push_str(&format!( + "export const PROCESS_SIGINFO_WASM32_PID_OFFSET = {} as const;\n", + shared::process_layout::rt_sigqueueinfo::WASM32_PID_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_SIGINFO_WASM32_UID_OFFSET = {} as const;\n", + shared::process_layout::rt_sigqueueinfo::WASM32_UID_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_SIGINFO_WASM32_VALUE_OFFSET = {} as const;\n", + shared::process_layout::rt_sigqueueinfo::WASM32_VALUE_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_SIGINFO_WASM32_VALUE_SIZE = {} as const;\n", + shared::process_layout::rt_sigqueueinfo::WASM32_VALUE_SIZE + )); + out.push_str(&format!( + "export const PROCESS_SIGINFO_WASM64_SIZE = {} as const;\n", + shared::process_layout::rt_sigqueueinfo::WASM64_SIZE + )); + out.push_str(&format!( + "export const PROCESS_SIGINFO_WASM64_PID_OFFSET = {} as const;\n", + shared::process_layout::rt_sigqueueinfo::WASM64_PID_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_SIGINFO_WASM64_UID_OFFSET = {} as const;\n", + shared::process_layout::rt_sigqueueinfo::WASM64_UID_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_SIGINFO_WASM64_VALUE_OFFSET = {} as const;\n", + shared::process_layout::rt_sigqueueinfo::WASM64_VALUE_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_SIGINFO_WASM64_VALUE_SIZE = {} as const;\n", + shared::process_layout::rt_sigqueueinfo::WASM64_VALUE_SIZE + )); + out.push_str(&format!( + "export const SOCKET_SOL_SOCKET = {} as const;\n", + shared::socket::SOL_SOCKET + )); + out.push_str(&format!( + "export const SOCKET_SCM_RIGHTS = {} as const;\n", + shared::socket::SCM_RIGHTS + )); + out.push_str(&format!( + "export const SOCKET_MSG_TRUNC = {} as const;\n", + shared::socket::MSG_TRUNC + )); + out.push_str(&format!( + "export const SCM_RIGHTS_FD_BYTES = {} as const;\n", + shared::socket::SCM_RIGHTS_FD_BYTES + )); + out.push_str(&format!( + "export const KERNEL_MESSAGE_WIRE_FLATTENED_IOVEC_COUNT = {} as const;\n", + shared::socket::KERNEL_MESSAGE_WIRE_FLATTENED_IOVEC_COUNT + )); + out.push_str(&format!( + "export const SPAWN_WIRE_HEADER_BYTES = {} as const;\n", + shared::spawn_contract::WIRE_HEADER_BYTES + )); + out.push_str(&format!( + "export const SPAWN_WIRE_STRING_OFFSET_BYTES = {} as const;\n", + shared::spawn_contract::WIRE_STRING_OFFSET_BYTES + )); + out.push_str(&format!( + "export const SPAWN_WIRE_HEADER_ARGC_OFFSET = {} as const;\n", + shared::spawn_contract::WIRE_HEADER_ARGC_OFFSET + )); + out.push_str(&format!( + "export const SPAWN_WIRE_HEADER_ENVC_OFFSET = {} as const;\n", + shared::spawn_contract::WIRE_HEADER_ENVC_OFFSET + )); + out.push_str(&format!( + "export const SPAWN_WIRE_HEADER_ACTION_COUNT_OFFSET = {} as const;\n", + shared::spawn_contract::WIRE_HEADER_ACTION_COUNT_OFFSET + )); + out.push_str(&format!( + "export const SPAWN_WIRE_HEADER_ATTR_FLAGS_OFFSET = {} as const;\n", + shared::spawn_contract::WIRE_HEADER_ATTR_FLAGS_OFFSET + )); + out.push_str(&format!( + "export const SPAWN_WIRE_HEADER_PGRP_OFFSET = {} as const;\n", + shared::spawn_contract::WIRE_HEADER_PGRP_OFFSET + )); + out.push_str(&format!( + "export const SPAWN_WIRE_HEADER_PAD_OFFSET = {} as const;\n", + shared::spawn_contract::WIRE_HEADER_PAD_OFFSET + )); + out.push_str(&format!( + "export const SPAWN_WIRE_HEADER_SIGDEF_OFFSET = {} as const;\n", + shared::spawn_contract::WIRE_HEADER_SIGDEF_OFFSET + )); + out.push_str(&format!( + "export const SPAWN_WIRE_HEADER_SIGMASK_OFFSET = {} as const;\n", + shared::spawn_contract::WIRE_HEADER_SIGMASK_OFFSET + )); + out.push_str(&format!( + "export const SPAWN_WIRE_ACTION_RECORD_BYTES = {} as const;\n", + shared::spawn_contract::WIRE_ACTION_RECORD_BYTES + )); + out.push_str(&format!( + "export const SPAWN_WIRE_ACTION_OP_OFFSET = {} as const;\n", + shared::spawn_contract::WIRE_ACTION_OP_OFFSET + )); + out.push_str(&format!( + "export const SPAWN_WIRE_ACTION_FD_OFFSET = {} as const;\n", + shared::spawn_contract::WIRE_ACTION_FD_OFFSET + )); + out.push_str(&format!( + "export const SPAWN_WIRE_ACTION_NEWFD_OFFSET = {} as const;\n", + shared::spawn_contract::WIRE_ACTION_NEWFD_OFFSET + )); + out.push_str(&format!( + "export const SPAWN_WIRE_ACTION_PATH_OFF_OFFSET = {} as const;\n", + shared::spawn_contract::WIRE_ACTION_PATH_OFF_OFFSET + )); + out.push_str(&format!( + "export const SPAWN_WIRE_ACTION_PATH_LEN_OFFSET = {} as const;\n", + shared::spawn_contract::WIRE_ACTION_PATH_LEN_OFFSET + )); + out.push_str(&format!( + "export const SPAWN_WIRE_ACTION_OFLAG_OFFSET = {} as const;\n", + shared::spawn_contract::WIRE_ACTION_OFLAG_OFFSET + )); + out.push_str(&format!( + "export const SPAWN_WIRE_ACTION_MODE_OFFSET = {} as const;\n", + shared::spawn_contract::WIRE_ACTION_MODE_OFFSET + )); + out.push_str(&format!( + "export const SPAWN_WIRE_OP_OPEN = {} as const;\n", + shared::spawn_contract::WIRE_OP_OPEN + )); + out.push_str(&format!( + "export const SPAWN_WIRE_OP_CLOSE = {} as const;\n", + shared::spawn_contract::WIRE_OP_CLOSE + )); + out.push_str(&format!( + "export const SPAWN_WIRE_OP_DUP2 = {} as const;\n", + shared::spawn_contract::WIRE_OP_DUP2 + )); + out.push_str(&format!( + "export const SPAWN_WIRE_OP_CHDIR = {} as const;\n", + shared::spawn_contract::WIRE_OP_CHDIR + )); + out.push_str(&format!( + "export const SPAWN_WIRE_OP_FCHDIR = {} as const;\n", + shared::spawn_contract::WIRE_OP_FCHDIR + )); + out.push_str(&format!( + "export const SPAWN_ATTR_RESETIDS = {} as const;\n", + shared::spawn_contract::ATTR_RESETIDS + )); + out.push_str(&format!( + "export const SPAWN_ATTR_SETPGROUP = {} as const;\n", + shared::spawn_contract::ATTR_SETPGROUP + )); + out.push_str(&format!( + "export const SPAWN_ATTR_SETSIGDEF = {} as const;\n", + shared::spawn_contract::ATTR_SETSIGDEF + )); + out.push_str(&format!( + "export const SPAWN_ATTR_SETSIGMASK = {} as const;\n", + shared::spawn_contract::ATTR_SETSIGMASK + )); + out.push_str(&format!( + "export const SPAWN_ATTR_SETSCHEDPARAM = {} as const;\n", + shared::spawn_contract::ATTR_SETSCHEDPARAM + )); + out.push_str(&format!( + "export const SPAWN_ATTR_SETSCHEDULER = {} as const;\n", + shared::spawn_contract::ATTR_SETSCHEDULER + )); + out.push_str(&format!( + "export const SPAWN_ATTR_USEVFORK = {} as const;\n", + shared::spawn_contract::ATTR_USEVFORK + )); + out.push_str(&format!( + "export const SPAWN_ATTR_SETSID = {} as const;\n", + shared::spawn_contract::ATTR_SETSID + )); + out.push_str(&format!( + "export const SPAWN_MAX_ARGV_COUNT = {} as const;\n", + shared::spawn_contract::MAX_ARGV_COUNT + )); + out.push_str(&format!( + "export const SPAWN_MAX_ENVP_COUNT = {} as const;\n", + shared::spawn_contract::MAX_ENVP_COUNT + )); + out.push_str(&format!( + "export const SPAWN_MAX_ACTION_COUNT = {} as const;\n", + shared::spawn_contract::MAX_ACTION_COUNT + )); + out.push_str(&format!( + "export const SPAWN_WIRE_MAX_BYTES = {} as const;\n\n", + shared::spawn_contract::WIRE_MAX_BYTES + )); out.push_str(&format!( "export const HOST_ADAPTER_VERSION = {} as const;\n", @@ -1491,6 +2340,14 @@ fn render_ts_module() -> String { "export const CH_SIG_BASE = {} as const;\n", channel::SIG_BASE )); + out.push_str(&format!( + "export const CH_SIG_AREA_SIZE = {} as const;\n", + channel::SIG_AREA_SIZE + )); + out.push_str(&format!( + "export const CH_SIG_DELIVERY_SIZE = {} as const;\n", + channel::SIG_DELIVERY_SIZE + )); out.push_str(&format!( "export const CH_SIG_SIGNUM = {} as const;\n", channel::SIG_SIGNUM @@ -1504,9 +2361,33 @@ fn render_ts_module() -> String { channel::SIG_FLAGS )); out.push_str(&format!( - "export const CH_SIG_OLD_MASK = {} as const;\n\n", + "export const CH_SIG_SI_VALUE = {} as const;\n", + channel::SIG_SI_VALUE + )); + out.push_str(&format!( + "export const CH_SIG_OLD_MASK = {} as const;\n", channel::SIG_OLD_MASK )); + out.push_str(&format!( + "export const CH_SIG_SI_CODE = {} as const;\n", + channel::SIG_SI_CODE + )); + out.push_str(&format!( + "export const CH_SIGINFO_WORD_1 = {} as const;\n", + channel::SIGINFO_WORD_1 + )); + out.push_str(&format!( + "export const CH_SIGINFO_WORD_2 = {} as const;\n", + channel::SIGINFO_WORD_2 + )); + out.push_str(&format!( + "export const CH_SIG_ALT_SP = {} as const;\n", + channel::SIG_ALT_SP + )); + out.push_str(&format!( + "export const CH_SIG_ALT_SIZE = {} as const;\n\n", + channel::SIG_ALT_SIZE + )); out.push_str(&format!( "export const WAIT_EVENT_EXITED = {} as const;\n", @@ -1529,78 +2410,190 @@ fn render_ts_module() -> String { shared::wait::WUNTRACED )); out.push_str(&format!( - "export const WAIT_WSTOPPED = {} as const;\n", - shared::wait::WSTOPPED + "export const WAIT_WSTOPPED = {} as const;\n", + shared::wait::WSTOPPED + )); + out.push_str(&format!( + "export const WAIT_WEXITED = {} as const;\n", + shared::wait::WEXITED + )); + out.push_str(&format!( + "export const WAIT_WCONTINUED = {} as const;\n", + shared::wait::WCONTINUED + )); + out.push_str(&format!( + "export const WAIT_WNOWAIT = {} as const;\n", + shared::wait::WNOWAIT + )); + out.push_str(&format!( + "export const WAIT_CLD_EXITED = {} as const;\n", + shared::wait::CLD_EXITED + )); + out.push_str(&format!( + "export const WAIT_CLD_KILLED = {} as const;\n", + shared::wait::CLD_KILLED + )); + out.push_str(&format!( + "export const WAIT_CLD_STOPPED = {} as const;\n", + shared::wait::CLD_STOPPED + )); + out.push_str(&format!( + "export const WAIT_CLD_CONTINUED = {} as const;\n", + shared::wait::CLD_CONTINUED + )); + out.push_str(&format!( + "export const PROCESS_STATE_RUNNING = {} as const;\n", + shared::wait::PROCESS_STATE_RUNNING + )); + out.push_str(&format!( + "export const PROCESS_STATE_STOPPED = {} as const;\n", + shared::wait::PROCESS_STATE_STOPPED + )); + out.push_str(&format!( + "export const PROCESS_STATE_EXITED = {} as const;\n", + shared::wait::PROCESS_STATE_EXITED + )); + out.push_str(&format!( + "export const WAKE_PROCESS_STOPPED = {} as const;\n", + shared::wait::WAKE_PROCESS_STOPPED + )); + out.push_str(&format!( + "export const WAKE_PROCESS_CONTINUED = {} as const;\n\n", + shared::wait::WAKE_PROCESS_CONTINUED + )); + + out.push_str(&format!( + "export const STRUCT_SIZE_WASM_STAT = {} as const;\n", + size_of::() + )); + out.push_str(&format!( + "export const STRUCT_SIZE_WASM_DIRENT = {} as const;\n", + size_of::() + )); + out.push_str(&format!( + "export const STRUCT_SIZE_WASM_TIMESPEC = {} as const;\n", + size_of::() + )); + out.push_str(&format!( + "export const STRUCT_SIZE_WASM_POLL_FD = {} as const;\n", + size_of::() + )); + out.push_str(&format!( + "export const WASM_POLL_FD_FD_OFFSET = {} as const;\n", + offset_of!(shared::WasmPollFd, fd) + )); + out.push_str(&format!( + "export const WASM_POLL_FD_EVENTS_OFFSET = {} as const;\n", + offset_of!(shared::WasmPollFd, events) + )); + out.push_str(&format!( + "export const WASM_POLL_FD_REVENTS_OFFSET = {} as const;\n", + offset_of!(shared::WasmPollFd, revents) + )); + out.push_str(&format!( + "export const STRUCT_SIZE_KERNEL_IOVEC_WIRE = {} as const;\n", + size_of::() + )); + out.push_str(&format!( + "export const KERNEL_IOVEC_WIRE_ALIGN = {} as const;\n", + align_of::() + )); + out.push_str(&format!( + "export const KERNEL_IOVEC_WIRE_BASE_OFFSET = {} as const;\n", + offset_of!(shared::KernelIovecWire, base) + )); + out.push_str(&format!( + "export const KERNEL_IOVEC_WIRE_LEN_OFFSET = {} as const;\n", + offset_of!(shared::KernelIovecWire, len) + )); + out.push_str(&format!( + "export const STRUCT_SIZE_KERNEL_MSGHDR_WIRE = {} as const;\n", + size_of::() + )); + out.push_str(&format!( + "export const KERNEL_MSGHDR_WIRE_ALIGN = {} as const;\n", + align_of::() + )); + out.push_str(&format!( + "export const KERNEL_MSGHDR_WIRE_NAME_OFFSET = {} as const;\n", + offset_of!(shared::KernelMsghdrWire, name) + )); + out.push_str(&format!( + "export const KERNEL_MSGHDR_WIRE_NAMELEN_OFFSET = {} as const;\n", + offset_of!(shared::KernelMsghdrWire, name_len) )); out.push_str(&format!( - "export const WAIT_WEXITED = {} as const;\n", - shared::wait::WEXITED + "export const KERNEL_MSGHDR_WIRE_IOV_OFFSET = {} as const;\n", + offset_of!(shared::KernelMsghdrWire, iov) )); out.push_str(&format!( - "export const WAIT_WCONTINUED = {} as const;\n", - shared::wait::WCONTINUED + "export const KERNEL_MSGHDR_WIRE_IOVLEN_OFFSET = {} as const;\n", + offset_of!(shared::KernelMsghdrWire, iov_len) )); out.push_str(&format!( - "export const WAIT_WNOWAIT = {} as const;\n", - shared::wait::WNOWAIT + "export const KERNEL_MSGHDR_WIRE_CONTROL_OFFSET = {} as const;\n", + offset_of!(shared::KernelMsghdrWire, control) )); out.push_str(&format!( - "export const WAIT_CLD_EXITED = {} as const;\n", - shared::wait::CLD_EXITED + "export const KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET = {} as const;\n", + offset_of!(shared::KernelMsghdrWire, control_len) )); out.push_str(&format!( - "export const WAIT_CLD_KILLED = {} as const;\n", - shared::wait::CLD_KILLED + "export const KERNEL_MSGHDR_WIRE_FLAGS_OFFSET = {} as const;\n", + offset_of!(shared::KernelMsghdrWire, flags) )); out.push_str(&format!( - "export const WAIT_CLD_STOPPED = {} as const;\n", - shared::wait::CLD_STOPPED + "export const STRUCT_SIZE_KERNEL_CMSGHDR_WIRE = {} as const;\n", + size_of::() )); out.push_str(&format!( - "export const WAIT_CLD_CONTINUED = {} as const;\n", - shared::wait::CLD_CONTINUED + "export const KERNEL_CMSGHDR_WIRE_ALIGN = {} as const;\n", + align_of::() )); out.push_str(&format!( - "export const PROCESS_STATE_RUNNING = {} as const;\n", - shared::wait::PROCESS_STATE_RUNNING + "export const KERNEL_CMSGHDR_WIRE_LEN_OFFSET = {} as const;\n", + offset_of!(shared::KernelCmsghdrWire, cmsg_len) )); out.push_str(&format!( - "export const PROCESS_STATE_STOPPED = {} as const;\n", - shared::wait::PROCESS_STATE_STOPPED + "export const KERNEL_CMSGHDR_WIRE_LEVEL_OFFSET = {} as const;\n", + offset_of!(shared::KernelCmsghdrWire, cmsg_level) )); out.push_str(&format!( - "export const PROCESS_STATE_EXITED = {} as const;\n", - shared::wait::PROCESS_STATE_EXITED + "export const KERNEL_CMSGHDR_WIRE_TYPE_OFFSET = {} as const;\n", + offset_of!(shared::KernelCmsghdrWire, cmsg_type) )); out.push_str(&format!( - "export const WAKE_PROCESS_STOPPED = {} as const;\n", - shared::wait::WAKE_PROCESS_STOPPED + "export const KERNEL_CMSGHDR_WIRE_DATA_OFFSET = {} as const;\n", + size_of::() )); out.push_str(&format!( - "export const WAKE_PROCESS_CONTINUED = {} as const;\n\n", - shared::wait::WAKE_PROCESS_CONTINUED + "export const STRUCT_SIZE_WASM_EPOLL_EVENT = {} as const;\n", + size_of::() )); - out.push_str(&format!( - "export const STRUCT_SIZE_WASM_STAT = {} as const;\n", - size_of::() + "export const WASM_EPOLL_EVENT_EVENTS_OFFSET = {} as const;\n", + offset_of!(shared::WasmEpollEvent, events) )); out.push_str(&format!( - "export const STRUCT_SIZE_WASM_DIRENT = {} as const;\n", - size_of::() + "export const WASM_EPOLL_EVENT_PAD_OFFSET = {} as const;\n", + offset_of!(shared::WasmEpollEvent, _pad) )); out.push_str(&format!( - "export const STRUCT_SIZE_WASM_TIMESPEC = {} as const;\n", - size_of::() + "export const WASM_EPOLL_EVENT_DATA_OFFSET = {} as const;\n", + offset_of!(shared::WasmEpollEvent, data) )); out.push_str(&format!( - "export const STRUCT_SIZE_WASM_POLL_FD = {} as const;\n", - size_of::() + "export const STRUCT_SIZE_WASM_SYSV_MESSAGE_HEADER = {} as const;\n", + size_of::() )); out.push_str(&format!( "export const STRUCT_SIZE_WASM_STATFS = {} as const;\n", size_of::() )); + out.push_str(&format!( + "export const STRUCT_SIZE_WPK_DRM_MODE_MODEINFO = {} as const;\n", + size_of::() + )); out.push_str(&format!( "export const STRUCT_SIZE_WASM_RUSAGE_WIRE = {} as const;\n", size_of::() @@ -1662,16 +2655,41 @@ fn render_ts_module() -> String { out.push_str(" | { type: \"cstring\" }\n"); out.push_str(" | { type: \"arg\"; argIndex: number; multiplier?: number; add?: number }\n"); out.push_str(" | { type: \"deref\"; argIndex: number }\n"); - out.push_str(" | { type: \"fixed\"; size: number };\n\n"); + out.push_str(" | { type: \"fixed\"; size: number }\n"); + out.push_str(" | { type: \"process-layout\"; wasm32Size: number; wasm64Size: number };\n\n"); + out.push_str(&format!( + "export const PROCESS_POINTER_WIDTH_ARG_INDEX = {} as const;\n\n", + shared::host_abi::PROCESS_POINTER_WIDTH_ARG_INDEX + )); out.push_str("export interface SyscallArgDesc {\n"); out.push_str(" argIndex: number;\n"); out.push_str(" direction: SyscallArgDirection;\n"); out.push_str(" size: SyscallArgSizeSpec;\n"); out.push_str(" nullable?: boolean;\n"); out.push_str(" required?: boolean;\n"); - out.push_str(" copyRetvalAdd?: number;\n"); out.push_str("}\n\n"); + out.push_str("export type IoctlArgKind = \"none\" | \"scalar-i32\" | \"pointer\";\n"); + out.push_str("export type IoctlDirection = \"none\" | \"in\" | \"out\" | \"inout\";\n\n"); + out.push_str("export interface IoctlRequestContract {\n"); + out.push_str(" argKind: IoctlArgKind;\n"); + out.push_str(" direction: IoctlDirection;\n"); + out.push_str(" wasm32Size: number | null;\n"); + out.push_str(" wasm64Size: number | null;\n"); + out.push_str("}\n\n"); + out.push_str("export const IOCTL_REQUESTS: Record = {\n"); + for contract in shared::ioctl_contract::IOCTL_REQUEST_CONTRACTS { + out.push_str(&format!( + " {}: {{ argKind: {:?}, direction: {:?}, wasm32Size: {}, wasm64Size: {} }},\n", + contract.request, + ioctl_arg_kind_name(contract.arg_kind), + ioctl_direction_name(contract.direction), + ts_optional_u32(contract.wasm32_size), + ts_optional_u32(contract.wasm64_size), + )); + } + out.push_str("};\n\n"); + out.push_str("export const SYSCALL_ARGS: Record = {\n"); for entry in shared::host_abi::SYSCALL_ARG_DESCRIPTORS { out.push_str(&format!(" {}: [\n", entry.syscall_number)); @@ -1720,9 +2738,6 @@ fn ts_syscall_arg_desc(desc: &shared::host_abi::SyscallArgDesc) -> String { if desc.required { s.push_str(", required: true"); } - if desc.copy_retval_add != 0 { - s.push_str(&format!(", copyRetvalAdd: {}", desc.copy_retval_add)); - } s.push_str(" }"); s } @@ -1751,6 +2766,12 @@ fn ts_syscall_arg_size(size: shared::host_abi::SyscallArgSize) -> String { format!("{{ type: \"deref\", argIndex: {arg_index} }}") } SyscallArgSize::Fixed { size } => format!("{{ type: \"fixed\", size: {size} }}"), + SyscallArgSize::ProcessLayout { + wasm32_size, + wasm64_size, + } => format!( + "{{ type: \"process-layout\", wasm32Size: {wasm32_size}, wasm64Size: {wasm64_size} }}" + ), } } @@ -1764,6 +2785,33 @@ fn syscall_arg_direction_name(direction: shared::host_abi::SyscallArgDirection) } } +fn ioctl_arg_kind_name(kind: shared::ioctl_contract::IoctlArgKind) -> &'static str { + use shared::ioctl_contract::IoctlArgKind; + + match kind { + IoctlArgKind::None => "none", + IoctlArgKind::ScalarI32 => "scalar-i32", + IoctlArgKind::Pointer => "pointer", + } +} + +fn ioctl_direction_name(direction: shared::ioctl_contract::IoctlDirection) -> &'static str { + use shared::ioctl_contract::IoctlDirection; + + match direction { + IoctlDirection::None => "none", + IoctlDirection::In => "in", + IoctlDirection::Out => "out", + IoctlDirection::InOut => "inout", + } +} + +fn ts_optional_u32(value: Option) -> String { + value + .map(|value| value.to_string()) + .unwrap_or_else(|| "null".into()) +} + #[derive(Debug, Clone, Copy)] struct HostAdapterManifestField { name: &'static str, @@ -1876,6 +2924,8 @@ fn build_snapshot(kernel_wasm: &std::path::Path) -> Result { let mut root: JsonMap = BTreeMap::new(); root.insert("abi_version".into(), json!(shared::ABI_VERSION)); + root.insert("platform_limits".into(), platform_limits()); + root.insert("spawn_contract".into(), spawn_contract()); root.insert("channel_header".into(), channel_header()); root.insert("channel_request_flags".into(), channel_request_flags()); @@ -1892,7 +2942,9 @@ fn build_snapshot(kernel_wasm: &std::path::Path) -> Result { ); root.insert("host_adapter".into(), host_adapter()); root.insert("syscall_arg_descriptors".into(), syscall_arg_descriptors()); + root.insert("ioctl_request_contracts".into(), ioctl_request_contracts()); root.insert("channel_status_codes".into(), channel_status_codes()); + root.insert("process_native_layouts".into(), process_native_layouts()); root.insert("process_memory_layout".into(), process_memory_layout()); root.insert("custom_sections".into(), custom_sections()); root.insert( @@ -1910,18 +2962,207 @@ fn build_snapshot(kernel_wasm: &std::path::Path) -> Result { Ok(root) } +fn platform_limits() -> Value { + json!({ + "arg_max_bytes": shared::platform_limits::ARG_MAX_BYTES, + "fd_set_bytes": shared::select::FD_SET_BYTES, + "fd_setsize": shared::select::FD_SETSIZE, + "iov_max": shared::platform_limits::IOV_MAX, + "path_max_bytes": shared::platform_limits::PATH_MAX_BYTES, + }) +} + +fn process_native_layouts() -> Value { + use shared::process_layout::{cmsghdr, iovec, msghdr, rt_sigqueueinfo, sigevent}; + + json!({ + "cmsghdr": { + "wasm32": { + "align": cmsghdr::WASM32_ALIGN, + "data_offset": cmsghdr::WASM32_DATA_OFFSET, + "len_offset": cmsghdr::WASM32_LEN_OFFSET, + "level_offset": cmsghdr::WASM32_LEVEL_OFFSET, + "size": cmsghdr::WASM32_SIZE, + "type_offset": cmsghdr::WASM32_TYPE_OFFSET, + }, + "wasm64": { + "align": cmsghdr::WASM64_ALIGN, + "data_offset": cmsghdr::WASM64_DATA_OFFSET, + "len_offset": cmsghdr::WASM64_LEN_OFFSET, + "level_offset": cmsghdr::WASM64_LEVEL_OFFSET, + "size": cmsghdr::WASM64_SIZE, + "type_offset": cmsghdr::WASM64_TYPE_OFFSET, + }, + }, + "scm_rights": { + "fd_bytes": shared::socket::SCM_RIGHTS_FD_BYTES, + "level": shared::socket::SOL_SOCKET, + "type": shared::socket::SCM_RIGHTS, + }, + "socket_message_flags": { + "trunc": shared::socket::MSG_TRUNC, + }, + "kernel_message_wire": { + "flattened_iovec_count": + shared::socket::KERNEL_MESSAGE_WIRE_FLATTENED_IOVEC_COUNT, + }, + "iovec": { + "wasm32": { + "base_offset": iovec::WASM32_BASE_OFFSET, + "len_offset": iovec::WASM32_LEN_OFFSET, + "size": iovec::WASM32_SIZE, + }, + "wasm64": { + "base_offset": iovec::WASM64_BASE_OFFSET, + "len_offset": iovec::WASM64_LEN_OFFSET, + "size": iovec::WASM64_SIZE, + }, + }, + "msghdr": { + "wasm32": { + "control_offset": msghdr::WASM32_CONTROL_OFFSET, + "controllen_offset": msghdr::WASM32_CONTROLLEN_OFFSET, + "flags_offset": msghdr::WASM32_FLAGS_OFFSET, + "iov_offset": msghdr::WASM32_IOV_OFFSET, + "iovlen_offset": msghdr::WASM32_IOVLEN_OFFSET, + "name_offset": msghdr::WASM32_NAME_OFFSET, + "namelen_offset": msghdr::WASM32_NAMELEN_OFFSET, + "size": msghdr::WASM32_SIZE, + }, + "wasm64": { + "control_offset": msghdr::WASM64_CONTROL_OFFSET, + "controllen_offset": msghdr::WASM64_CONTROLLEN_OFFSET, + "flags_offset": msghdr::WASM64_FLAGS_OFFSET, + "iov_offset": msghdr::WASM64_IOV_OFFSET, + "iovlen_offset": msghdr::WASM64_IOVLEN_OFFSET, + "name_offset": msghdr::WASM64_NAME_OFFSET, + "namelen_offset": msghdr::WASM64_NAMELEN_OFFSET, + "size": msghdr::WASM64_SIZE, + }, + }, + "siginfo": { + "signo_offset": rt_sigqueueinfo::SIGNO_OFFSET, + "errno_offset": rt_sigqueueinfo::ERRNO_OFFSET, + "code_offset": rt_sigqueueinfo::CODE_OFFSET, + "wasm32": { + "size": rt_sigqueueinfo::WASM32_SIZE, + "pid_offset": rt_sigqueueinfo::WASM32_PID_OFFSET, + "uid_offset": rt_sigqueueinfo::WASM32_UID_OFFSET, + "value_offset": rt_sigqueueinfo::WASM32_VALUE_OFFSET, + "value_size": rt_sigqueueinfo::WASM32_VALUE_SIZE, + }, + "wasm64": { + "size": rt_sigqueueinfo::WASM64_SIZE, + "pid_offset": rt_sigqueueinfo::WASM64_PID_OFFSET, + "uid_offset": rt_sigqueueinfo::WASM64_UID_OFFSET, + "value_offset": rt_sigqueueinfo::WASM64_VALUE_OFFSET, + "value_size": rt_sigqueueinfo::WASM64_VALUE_SIZE, + }, + }, + "sigevent": { + "wasm32": { + "size": sigevent::WASM32_SIZE, + "value_offset": sigevent::WASM32_VALUE_OFFSET, + "value_size": sigevent::WASM32_VALUE_SIZE, + "signo_offset": sigevent::WASM32_SIGNO_OFFSET, + "notify_offset": sigevent::WASM32_NOTIFY_OFFSET, + "payload_offset": sigevent::WASM32_PAYLOAD_OFFSET, + }, + "wasm64": { + "size": sigevent::WASM64_SIZE, + "value_offset": sigevent::WASM64_VALUE_OFFSET, + "value_size": sigevent::WASM64_VALUE_SIZE, + "signo_offset": sigevent::WASM64_SIGNO_OFFSET, + "notify_offset": sigevent::WASM64_NOTIFY_OFFSET, + "payload_offset": sigevent::WASM64_PAYLOAD_OFFSET, + }, + }, + }) +} + +fn spawn_contract() -> Value { + use shared::spawn_contract; + + json!({ + "action_record": { + "bytes": spawn_contract::WIRE_ACTION_RECORD_BYTES, + "offsets": { + "fd": spawn_contract::WIRE_ACTION_FD_OFFSET, + "mode": spawn_contract::WIRE_ACTION_MODE_OFFSET, + "newfd": spawn_contract::WIRE_ACTION_NEWFD_OFFSET, + "oflag": spawn_contract::WIRE_ACTION_OFLAG_OFFSET, + "op": spawn_contract::WIRE_ACTION_OP_OFFSET, + "path_len": spawn_contract::WIRE_ACTION_PATH_LEN_OFFSET, + "path_off": spawn_contract::WIRE_ACTION_PATH_OFF_OFFSET, + }, + }, + "attribute_bits": { + "resetids": spawn_contract::ATTR_RESETIDS, + "setpgroup": spawn_contract::ATTR_SETPGROUP, + "setschedparam": spawn_contract::ATTR_SETSCHEDPARAM, + "setscheduler": spawn_contract::ATTR_SETSCHEDULER, + "setsid": spawn_contract::ATTR_SETSID, + "setsigdef": spawn_contract::ATTR_SETSIGDEF, + "setsigmask": spawn_contract::ATTR_SETSIGMASK, + "usevfork": spawn_contract::ATTR_USEVFORK, + }, + "count_caps": { + "actions": spawn_contract::MAX_ACTION_COUNT, + "argv": spawn_contract::MAX_ARGV_COUNT, + "envp": spawn_contract::MAX_ENVP_COUNT, + }, + "header": { + "bytes": spawn_contract::WIRE_HEADER_BYTES, + "offsets": { + "action_count": spawn_contract::WIRE_HEADER_ACTION_COUNT_OFFSET, + "argc": spawn_contract::WIRE_HEADER_ARGC_OFFSET, + "attr_flags": spawn_contract::WIRE_HEADER_ATTR_FLAGS_OFFSET, + "envc": spawn_contract::WIRE_HEADER_ENVC_OFFSET, + "pad": spawn_contract::WIRE_HEADER_PAD_OFFSET, + "pgrp": spawn_contract::WIRE_HEADER_PGRP_OFFSET, + "sigdef": spawn_contract::WIRE_HEADER_SIGDEF_OFFSET, + "sigmask": spawn_contract::WIRE_HEADER_SIGMASK_OFFSET, + }, + }, + "opcodes": { + "chdir": spawn_contract::WIRE_OP_CHDIR, + "close": spawn_contract::WIRE_OP_CLOSE, + "dup2": spawn_contract::WIRE_OP_DUP2, + "fchdir": spawn_contract::WIRE_OP_FCHDIR, + "open": spawn_contract::WIRE_OP_OPEN, + }, + "platform_aliases": { + "arg_max_bytes": spawn_contract::POSIX_ARG_MAX_BYTES, + "path_max_bytes": spawn_contract::POSIX_PATH_MAX_BYTES, + }, + "string_offset_bytes": spawn_contract::WIRE_STRING_OFFSET_BYTES, + "syscall_number": shared::abi::host_intercepted::SYS_SPAWN, + "wire_max_bytes": spawn_contract::WIRE_MAX_BYTES, + }) +} + fn channel_header() -> Value { use shared::channel::*; - // The field list is hand-authored; offsets below are read from the - // actual shared:: constants that kernel and glue reference, so the - // hand-authored table cannot silently drift from them. + // Names and type labels are descriptive. Every offset, size, and repeated + // count comes from the shared channel contract, so snapshot generation + // cannot preserve stale arithmetic after that contract changes. let fields = [ - ("status", STATUS_OFFSET, 4usize, "i32"), - ("syscall", SYSCALL_OFFSET, 4, "i32"), - ("args", ARGS_OFFSET, ARGS_COUNT * ARG_SIZE, "[i64; 6]"), - ("ret", RETURN_OFFSET, 8, "i64"), - ("errno", ERRNO_OFFSET, 4, "i32"), - ("request_flags", REQUEST_FLAGS_OFFSET, 4, "u32"), + ("status", STATUS_OFFSET, STATUS_SIZE, "i32".to_string()), + ("syscall", SYSCALL_OFFSET, SYSCALL_SIZE, "i32".to_string()), + ( + "args", + ARGS_OFFSET, + ARGS_COUNT * ARG_SIZE, + format!("[i64; {ARGS_COUNT}]"), + ), + ("ret", RETURN_OFFSET, RETURN_SIZE, "i64".to_string()), + ("errno", ERRNO_OFFSET, ERRNO_SIZE, "i32".to_string()), + ( + "request_flags", + REQUEST_FLAGS_OFFSET, + REQUEST_FLAGS_SIZE, + "u32".to_string(), + ), ]; let mut covered: usize = 0; @@ -1942,9 +3183,9 @@ fn channel_header() -> Value { }) .collect(); - assert!( - covered <= HEADER_SIZE, - "channel header fields overrun HEADER_SIZE ({covered} > {HEADER_SIZE})" + assert_eq!( + covered, HEADER_SIZE, + "channel header fields must cover HEADER_SIZE exactly" ); let mut m: JsonMap = BTreeMap::new(); @@ -1955,10 +3196,7 @@ fn channel_header() -> Value { fn channel_request_flags() -> Value { let mut flag: JsonMap = BTreeMap::new(); - flag.insert( - "name".into(), - json!("defer_signal_delivery"), - ); + flag.insert("name".into(), json!("defer_signal_delivery")); flag.insert( "bit".into(), json!(shared::channel::REQUEST_FLAG_DEFER_SIGNAL_DELIVERY), @@ -2092,21 +3330,68 @@ fn process_memory_layout() -> Value { fn channel_signal_area() -> Value { use shared::channel::*; + use shared::kernel_scratch_wire as signal_wire; let entries = [ ( "SIG_SIGNUM", SIG_SIGNUM, - 4u32, + signal_wire::SIGNAL_WORD_BYTES, "u32, signal number (0=none)", ), - ("SIG_HANDLER", SIG_HANDLER, 4, "u32, handler table index"), - ("SIG_FLAGS", SIG_FLAGS, 4, "u32, sa_flags"), + ( + "SIG_HANDLER", + SIG_HANDLER, + signal_wire::SIGNAL_WORD_BYTES, + "u32, handler table index", + ), + ( + "SIG_FLAGS", + SIG_FLAGS, + signal_wire::SIGNAL_WORD_BYTES, + "u32, sa_flags", + ), + ( + "SIG_SI_VALUE", + SIG_SI_VALUE, + signal_wire::SIGNAL_SI_VALUE_BYTES, + "raw u64 sigval bits (wasm32 uses low 32 bits)", + ), ( "SIG_OLD_MASK", SIG_OLD_MASK, - 8, + signal_wire::SIGNAL_OLD_MASK_BYTES, "u64 (LE), saved blocked mask", ), + ( + "SIG_SI_CODE", + SIG_SI_CODE, + signal_wire::SIGNAL_WORD_BYTES, + "i32, siginfo si_code", + ), + ( + "SIGINFO_WORD_1", + SIGINFO_WORD_1, + signal_wire::SIGNAL_WORD_BYTES, + "i32, pid or SI_TIMER timer ID", + ), + ( + "SIGINFO_WORD_2", + SIGINFO_WORD_2, + signal_wire::SIGNAL_WORD_BYTES, + "raw u32 uid bits or i32 SI_TIMER overrun", + ), + ( + "SIG_ALT_SP", + SIG_ALT_SP, + signal_wire::SIGNAL_ALT_SP_BYTES, + "u64, caller-native alternate stack pointer or zero", + ), + ( + "SIG_ALT_SIZE", + SIG_ALT_SIZE, + signal_wire::SIGNAL_ALT_SIZE_BYTES, + "u64, caller-native alternate stack size", + ), ]; let mut list = Vec::new(); for (name, offset, size, meaning) in entries { @@ -2118,7 +3403,13 @@ fn channel_signal_area() -> Value { list.push(Value::Object(m.into_iter().collect())); } let mut m: JsonMap = BTreeMap::new(); + m.insert("area_size".into(), json!(SIG_AREA_SIZE)); m.insert("base".into(), json!(SIG_BASE)); + m.insert("delivery_size".into(), json!(SIG_DELIVERY_SIZE)); + m.insert( + "reserved_tail_size".into(), + json!(SIG_AREA_SIZE - SIG_DELIVERY_SIZE), + ); m.insert("slots".into(), Value::Array(list)); Value::Object(m.into_iter().collect()) } @@ -2134,8 +3425,9 @@ fn marshalled_structs() -> Value { use shared::fbdev::{FbBitfield, FbFixScreenInfo, FbVarScreenInfo}; use shared::gl::{GlContextAttrs, GlQueryInfo, GlSubmitInfo, GlSurfaceAttrs}; use shared::{ - KernelWaitResult, WasmDirent, WasmFlock, WasmPollFd, WasmRusageWire, WasmStat, WasmStatfs, - WasmTimespec, + KernelCmsghdrWire, KernelIovecWire, KernelMsghdrWire, KernelWaitResult, WasmDirent, + WasmEpollEvent, WasmFlock, WasmPollFd, WasmRusageWire, WasmStat, WasmStatfs, + WasmSysvMessageHeader, WasmTimespec, }; let mut structs: JsonMap = BTreeMap::new(); @@ -2190,6 +3482,38 @@ fn marshalled_structs() -> Value { revents }), ); + structs.insert( + "KernelIovecWire".into(), + struct_layout!(KernelIovecWire { base, len }), + ); + structs.insert( + "KernelMsghdrWire".into(), + struct_layout!(KernelMsghdrWire { + name, + name_len, + iov, + iov_len, + control, + control_len, + flags, + }), + ); + structs.insert( + "KernelCmsghdrWire".into(), + struct_layout!(KernelCmsghdrWire { + cmsg_len, + cmsg_level, + cmsg_type, + }), + ); + structs.insert( + "WasmEpollEvent".into(), + struct_layout!(WasmEpollEvent { events, _pad, data }), + ); + structs.insert( + "WasmSysvMessageHeader".into(), + struct_layout!(WasmSysvMessageHeader { mtype }), + ); structs.insert( "WasmStatfs".into(), struct_layout!(WasmStatfs { @@ -2734,6 +4058,28 @@ fn syscall_arg_descriptors() -> Value { Value::Object(descriptors.into_iter().collect()) } +fn ioctl_request_contracts() -> Value { + let mut contracts: JsonMap = BTreeMap::new(); + for contract in shared::ioctl_contract::IOCTL_REQUEST_CONTRACTS { + let mut value: JsonMap = BTreeMap::new(); + value.insert( + "argKind".into(), + json!(ioctl_arg_kind_name(contract.arg_kind)), + ); + value.insert( + "direction".into(), + json!(ioctl_direction_name(contract.direction)), + ); + value.insert("wasm32Size".into(), json!(contract.wasm32_size)); + value.insert("wasm64Size".into(), json!(contract.wasm64_size)); + contracts.insert( + contract.request.to_string(), + Value::Object(value.into_iter().collect()), + ); + } + Value::Object(contracts.into_iter().collect()) +} + fn host_adapter() -> Value { let manifest = shared::abi::HOST_ADAPTER_MANIFEST; @@ -2840,9 +4186,6 @@ fn syscall_arg_desc_json(desc: &shared::host_abi::SyscallArgDesc) -> Value { if desc.required { m.insert("required".into(), json!(true)); } - if desc.copy_retval_add != 0 { - m.insert("copyRetvalAdd".into(), json!(desc.copy_retval_add)); - } Value::Object(m.into_iter().collect()) } @@ -2876,6 +4219,14 @@ fn syscall_arg_size_json(size: shared::host_abi::SyscallArgSize) -> Value { m.insert("type".into(), json!("fixed")); m.insert("size".into(), json!(size)); } + SyscallArgSize::ProcessLayout { + wasm32_size, + wasm64_size, + } => { + m.insert("type".into(), json!("process-layout")); + m.insert("wasm32Size".into(), json!(wasm32_size)); + m.insert("wasm64Size".into(), json!(wasm64_size)); + } } Value::Object(m.into_iter().collect()) } @@ -2978,16 +4329,14 @@ fn program_artifact() -> Value { WPK_FORK_MODULE_STATE_MODULE_TEMPLATE_ID_SIZE, WPK_FORK_MODULE_STATE_POINTER_WIDTHS, WPK_FORK_MODULE_STATE_RECORD_ALIGNMENT, WPK_FORK_MODULE_STATE_RECORD_HEADER_SIZE, WPK_FORK_MODULE_STATE_RECORD_KINDS, WPK_FORK_MODULE_STATE_RECORD_MAGIC, - WPK_FORK_MODULE_STATE_RECORD_VERSION, WPK_FORK_MODULE_STATE_REPLAY_EVENT_SIZE, - WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_CAPACITY, + WPK_FORK_MODULE_STATE_RECORD_VERSION, WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_CAPACITY, WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_HEADER_SIZE, WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_KNOWN_FLAGS, WPK_FORK_MODULE_STATE_REPLAY_EVENT_SEGMENT_VERSION, - WPK_FORK_MODULE_STATE_REPLAY_EVENTS_HEADER_SIZE, + WPK_FORK_MODULE_STATE_REPLAY_EVENT_SIZE, WPK_FORK_MODULE_STATE_REPLAY_EVENTS_HEADER_SIZE, WPK_FORK_MODULE_STATE_REPLAY_EVENTS_KNOWN_FLAGS, WPK_FORK_MODULE_STATE_REPLAY_EVENTS_MAGIC, - WPK_FORK_MODULE_STATE_REPLAY_EVENTS_OWNER, - WPK_FORK_MODULE_STATE_REPLAY_EVENTS_VERSION, WPK_FORK_MODULE_STATE_REQUIRED_FLAGS, - WPK_FORK_MODULE_STATE_ROOT_POINTER_WORD_OFFSET, + WPK_FORK_MODULE_STATE_REPLAY_EVENTS_OWNER, WPK_FORK_MODULE_STATE_REPLAY_EVENTS_VERSION, + WPK_FORK_MODULE_STATE_REQUIRED_FLAGS, WPK_FORK_MODULE_STATE_ROOT_POINTER_WORD_OFFSET, WPK_FORK_MODULE_STATE_TABLE_BASELINE_FINGERPRINT_SIZE, WPK_FORK_MODULE_STATE_TABLE_DESCRIPTOR_PAYLOAD_SIZE, WPK_FORK_MODULE_STATE_TABLE_FLAG_SPARSE_OVERRIDES, WPK_FORK_MODULE_STATE_TABLE_KNOWN_FLAGS, @@ -2997,19 +4346,18 @@ fn program_artifact() -> Value { WPK_FORK_REFERENCE_SECTION_SCALARS, WPK_FORK_REFERENCE_SECTION_VECTOR_ENTRIES, WPK_FORK_REFERENCE_SECTION_VECTOR_INDEX, WPK_FORK_REFERENCE_SEGMENT_HEADER_SIZE, WPK_FORK_REFERENCE_SEGMENT_KNOWN_FLAGS, WPK_FORK_REFERENCE_SEGMENT_MAGIC, - WPK_FORK_REFERENCE_TRANSACTION_FLAG_SEALED, - WPK_FORK_REFERENCE_TRANSACTION_KNOWN_FLAGS, + WPK_FORK_REFERENCE_TRANSACTION_FLAG_SEALED, WPK_FORK_REFERENCE_TRANSACTION_KNOWN_FLAGS, WPK_FORK_REFERENCE_TRANSACTION_MAGIC, WPK_FORK_REFERENCE_TRANSACTION_MANIFEST_SIZE, WPK_FORK_REFERENCE_TRANSACTION_OWNER, WPK_FORK_REFERENCE_TRANSACTION_VERSION, - WPK_FORK_REFERENCE_VECTOR_INDEX_SIZE, WPK_FORK_REQUIRED_EXPORTS, - WPK_FORK_REQUIRED_IMPORTS, WPK_FORK_REQUIRED_TABLE_IMPORTS, - WPK_FORK_STATIC_ROOT_CATALOG_EXPORT, WPK_FORK_STATIC_ROOT_CATALOG_HEADER_SIZE, - WPK_FORK_STATIC_ROOT_CATALOG_MAGIC, WPK_FORK_STATIC_ROOT_CATALOG_SECTION, - WPK_FORK_STATIC_ROOT_CATALOG_VERSION, WPK_FORK_STATIC_ROOT_HARVEST_EXPORT, - WPK_FORK_UNWIND_TAG_IMPORT_MODULE, WPK_FORK_UNWIND_TAG_IMPORT_NAME, - WPK_FORK_UNWIND_TRANSPORT_PAYLOAD_ARITY, WPK_FORK_UNWIND_TRANSPORT_SECTION, - WPK_FORK_UNWIND_TRANSPORT_VERSION, wpk_fork_linked_chunk_header_size, - wpk_fork_linked_node_header_size, wpk_fork_module_state_chunk_header_size, + WPK_FORK_REFERENCE_VECTOR_INDEX_SIZE, WPK_FORK_REQUIRED_EXPORTS, WPK_FORK_REQUIRED_IMPORTS, + WPK_FORK_REQUIRED_TABLE_IMPORTS, WPK_FORK_STATIC_ROOT_CATALOG_EXPORT, + WPK_FORK_STATIC_ROOT_CATALOG_HEADER_SIZE, WPK_FORK_STATIC_ROOT_CATALOG_MAGIC, + WPK_FORK_STATIC_ROOT_CATALOG_SECTION, WPK_FORK_STATIC_ROOT_CATALOG_VERSION, + WPK_FORK_STATIC_ROOT_HARVEST_EXPORT, WPK_FORK_UNWIND_TAG_IMPORT_MODULE, + WPK_FORK_UNWIND_TAG_IMPORT_NAME, WPK_FORK_UNWIND_TRANSPORT_PAYLOAD_ARITY, + WPK_FORK_UNWIND_TRANSPORT_SECTION, WPK_FORK_UNWIND_TRANSPORT_VERSION, + wpk_fork_linked_chunk_header_size, wpk_fork_linked_node_header_size, + wpk_fork_module_state_chunk_header_size, }; let value_types = |values: &[ProgramArtifactValueType]| { @@ -3396,10 +4744,8 @@ fn program_artifact() -> Value { "known_flags".into(), json!(WPK_FORK_REFERENCE_TRANSACTION_KNOWN_FLAGS), ); - reference_transaction_payload.insert( - "magic".into(), - json!(WPK_FORK_REFERENCE_TRANSACTION_MAGIC), - ); + reference_transaction_payload + .insert("magic".into(), json!(WPK_FORK_REFERENCE_TRANSACTION_MAGIC)); reference_transaction_payload.insert( "manifest_size".into(), json!(WPK_FORK_REFERENCE_TRANSACTION_MANIFEST_SIZE), @@ -3408,10 +4754,8 @@ fn program_artifact() -> Value { "node_record_size".into(), json!(WPK_FORK_REFERENCE_NODE_RECORD_SIZE), ); - reference_transaction_payload.insert( - "owner".into(), - json!(WPK_FORK_REFERENCE_TRANSACTION_OWNER), - ); + reference_transaction_payload + .insert("owner".into(), json!(WPK_FORK_REFERENCE_TRANSACTION_OWNER)); reference_transaction_payload.insert( "sealed_flag".into(), json!(WPK_FORK_REFERENCE_TRANSACTION_FLAG_SEALED), @@ -4396,6 +5740,28 @@ mod tests { fn generated_typescript_contains_pathconf_names_and_required_outputs() { let rendered = render_ts_module(); assert!(rendered.contains("export const SCHED_AFFINITY_MASK_SIZE = 4 as const;")); + assert!(rendered.contains("export const PR_SET_NAME = 15 as const;")); + assert!(rendered.contains("export const PR_GET_NAME = 16 as const;")); + assert!(rendered.contains("export const PRCTL_NAME_BYTES = 16 as const;")); + assert!(rendered.contains("export const FCNTL_FLOCK_BYTES = 32 as const;")); + assert!(rendered.contains("export const SIGNAL_MASK_BYTES = 8 as const;")); + assert!(rendered.contains("export const SELECT_FD_SETSIZE = 1024 as const;")); + assert!(rendered.contains("export const SELECT_FD_SET_BYTES = 128 as const;")); + assert!(rendered.contains("export const PROCESS_IOVEC_WASM32_SIZE = 8 as const;")); + assert!(rendered.contains("export const PROCESS_IOVEC_WASM64_SIZE = 16 as const;")); + assert!(rendered.contains("export const PROCESS_MSGHDR_WASM64_SIZE = 56 as const;")); + assert!(rendered.contains("export const PROCESS_CMSGHDR_WASM64_ALIGN = 8 as const;")); + assert!(rendered.contains("export const STRUCT_SIZE_KERNEL_IOVEC_WIRE = 8 as const;")); + assert!(rendered.contains("export const STRUCT_SIZE_KERNEL_MSGHDR_WIRE = 28 as const;")); + assert!(rendered.contains("export const STRUCT_SIZE_KERNEL_CMSGHDR_WIRE = 12 as const;")); + assert!( + rendered + .contains("export const KERNEL_MESSAGE_WIRE_FLATTENED_IOVEC_COUNT = 1 as const;") + ); + assert!(rendered.contains("export const SOCKET_MSG_TRUNC = 32 as const;")); + assert!(rendered.contains("export const WASM_EPOLL_EVENT_EVENTS_OFFSET = 0 as const;")); + assert!(rendered.contains("export const WASM_EPOLL_EVENT_PAD_OFFSET = 4 as const;")); + assert!(rendered.contains("export const WASM_EPOLL_EVENT_DATA_OFFSET = 8 as const;")); assert!(rendered.contains("export const PATHCONF_NAMES = {")); assert!(rendered.contains(" PATH_MAX: 4,")); assert!(rendered.contains(" TIMESTAMP_RESOLUTION: 23,")); @@ -4409,6 +5775,248 @@ mod tests { assert_eq!(names.as_object().unwrap().len(), 24); } + #[test] + fn generated_channel_contract_covers_status_layout_and_signal_wire() { + let header = render_c_header(); + for expected in [ + "#define WASM_POSIX_CHANNEL_STATUS_IDLE 0u", + "#define WASM_POSIX_CHANNEL_STATUS_PENDING 1u", + "#define WASM_POSIX_CHANNEL_STATUS_COMPLETE 2u", + "#define WASM_POSIX_CHANNEL_STATUS_ERROR 3u", + "#define WASM_POSIX_CHANNEL_STATUS_OFFSET 0u", + "#define WASM_POSIX_CHANNEL_SYSCALL_OFFSET 4u", + "#define WASM_POSIX_CHANNEL_ARGS_OFFSET 8u", + "#define WASM_POSIX_CHANNEL_ARGS_COUNT 6u", + "#define WASM_POSIX_CHANNEL_ARG_SIZE 8u", + "#define WASM_POSIX_CHANNEL_RETURN_OFFSET 56u", + "#define WASM_POSIX_CHANNEL_ERRNO_OFFSET 64u", + "#define WASM_POSIX_CHANNEL_REQUEST_FLAGS_OFFSET 68u", + "#define WASM_POSIX_CHANNEL_REQUEST_FLAGS_SIZE 4u", + "#define WASM_POSIX_CHANNEL_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY 1u", + "#define WASM_POSIX_CHANNEL_DATA_OFFSET 72u", + "#define WASM_POSIX_CHANNEL_DATA_SIZE 65536u", + "#define WASM_POSIX_CHANNEL_HEADER_SIZE 72u", + "#define WASM_POSIX_CHANNEL_MIN_SIZE 65608u", + "#define WASM_POSIX_CHANNEL_SIG_AREA_SIZE 56u", + "#define WASM_POSIX_CHANNEL_SIG_DELIVERY_SIZE 56u", + "#define WASM_POSIX_CHANNEL_SIG_WORD_BYTES 4u", + "#define WASM_POSIX_CHANNEL_SIG_SI_VALUE_BYTES 8u", + "#define WASM_POSIX_CHANNEL_SIG_OLD_MASK_BYTES 8u", + "#define WASM_POSIX_CHANNEL_SIG_ALT_SP_BYTES 8u", + "#define WASM_POSIX_CHANNEL_SIG_ALT_SIZE_BYTES 8u", + "#define WASM_POSIX_CHANNEL_SIG_BASE_OFFSET 65552u", + "#define WASM_POSIX_CHANNEL_SIG_SIGNUM_OFFSET 65552u", + "#define WASM_POSIX_CHANNEL_SIG_HANDLER_OFFSET 65556u", + "#define WASM_POSIX_CHANNEL_SIG_FLAGS_OFFSET 65560u", + "#define WASM_POSIX_CHANNEL_SIG_SI_VALUE_OFFSET 65564u", + "#define WASM_POSIX_CHANNEL_SIG_OLD_MASK_OFFSET 65572u", + "#define WASM_POSIX_CHANNEL_SIG_SI_CODE_OFFSET 65580u", + "#define WASM_POSIX_CHANNEL_SIGINFO_WORD_1_OFFSET 65584u", + "#define WASM_POSIX_CHANNEL_SIGINFO_WORD_2_OFFSET 65588u", + "#define WASM_POSIX_CHANNEL_SIG_ALT_SP_OFFSET 65592u", + "#define WASM_POSIX_CHANNEL_SIG_ALT_SIZE_OFFSET 65600u", + ] { + assert!(header.contains(expected), "missing generated C: {expected}"); + } + + let typescript = render_ts_module(); + for expected in [ + "export const CH_SIG_AREA_SIZE = 56 as const;", + "export const CH_SIG_DELIVERY_SIZE = 56 as const;", + "export const CH_SIG_SI_VALUE = 65564 as const;", + "export const CH_SIG_SI_CODE = 65580 as const;", + "export const CH_SIGINFO_WORD_1 = 65584 as const;", + "export const CH_SIGINFO_WORD_2 = 65588 as const;", + "export const CH_SIG_ALT_SP = 65592 as const;", + "export const CH_SIG_ALT_SIZE = 65600 as const;", + ] { + assert!( + typescript.contains(expected), + "missing generated TypeScript: {expected}", + ); + } + + let signal = channel_signal_area(); + assert_eq!(signal["area_size"], json!(56)); + assert_eq!(signal["delivery_size"], json!(56)); + assert_eq!(signal["reserved_tail_size"], json!(0)); + let slot_names: Vec<&str> = signal["slots"] + .as_array() + .unwrap() + .iter() + .map(|slot| slot["name"].as_str().unwrap()) + .collect(); + assert_eq!( + slot_names, + vec![ + "SIG_SIGNUM", + "SIG_HANDLER", + "SIG_FLAGS", + "SIG_SI_VALUE", + "SIG_OLD_MASK", + "SIG_SI_CODE", + "SIGINFO_WORD_1", + "SIGINFO_WORD_2", + "SIG_ALT_SP", + "SIG_ALT_SIZE", + ], + ); + } + + #[test] + fn snapshot_captures_generated_platform_and_spawn_contracts() { + let limits = platform_limits(); + assert_eq!( + limits, + json!({ + "arg_max_bytes": shared::platform_limits::ARG_MAX_BYTES, + "fd_set_bytes": shared::select::FD_SET_BYTES, + "fd_setsize": shared::select::FD_SETSIZE, + "iov_max": shared::platform_limits::IOV_MAX, + "path_max_bytes": shared::platform_limits::PATH_MAX_BYTES, + }), + ); + + let spawn = spawn_contract(); + assert_eq!( + spawn["header"]["bytes"], + json!(shared::spawn_contract::WIRE_HEADER_BYTES), + ); + assert_eq!( + spawn["action_record"]["bytes"], + json!(shared::spawn_contract::WIRE_ACTION_RECORD_BYTES), + ); + assert_eq!( + spawn["count_caps"], + json!({ + "actions": shared::spawn_contract::MAX_ACTION_COUNT, + "argv": shared::spawn_contract::MAX_ARGV_COUNT, + "envp": shared::spawn_contract::MAX_ENVP_COUNT, + }), + ); + assert_eq!( + spawn["platform_aliases"], + json!({ + "arg_max_bytes": shared::platform_limits::ARG_MAX_BYTES, + "path_max_bytes": shared::platform_limits::PATH_MAX_BYTES, + }), + ); + assert_eq!( + spawn["wire_max_bytes"], + json!( + shared::spawn_contract::POSIX_ARG_MAX_BYTES + + shared::spawn_contract::WIRE_HEADER_BYTES + + shared::spawn_contract::MAX_ACTION_COUNT + * (shared::spawn_contract::WIRE_ACTION_RECORD_BYTES + + shared::spawn_contract::POSIX_PATH_MAX_BYTES) + ), + ); + } + + #[test] + fn generated_native_process_layout_contract_matches_both_musl_targets() { + let layouts = process_native_layouts(); + assert_eq!( + layouts["iovec"], + json!({ + "wasm32": {"base_offset": 0, "len_offset": 4, "size": 8}, + "wasm64": {"base_offset": 0, "len_offset": 8, "size": 16}, + }), + ); + assert_eq!( + layouts["msghdr"]["wasm64"], + json!({ + "control_offset": 32, + "controllen_offset": 40, + "flags_offset": 48, + "iov_offset": 16, + "iovlen_offset": 24, + "name_offset": 0, + "namelen_offset": 8, + "size": 56, + }), + ); + assert_eq!( + layouts["cmsghdr"]["wasm64"], + json!({ + "align": 8, + "data_offset": 16, + "len_offset": 0, + "level_offset": 8, + "size": 16, + "type_offset": 12, + }), + ); + assert_eq!( + layouts["kernel_message_wire"], + json!({"flattened_iovec_count": 1}), + ); + assert_eq!( + layouts["socket_message_flags"], + json!({"trunc": shared::socket::MSG_TRUNC}), + ); + assert_eq!( + layouts["siginfo"], + json!({ + "signo_offset": 0, + "errno_offset": 4, + "code_offset": 8, + "wasm32": { + "size": 128, + "pid_offset": 12, + "uid_offset": 16, + "value_offset": 20, + "value_size": 4, + }, + "wasm64": { + "size": 128, + "pid_offset": 16, + "uid_offset": 20, + "value_offset": 24, + "value_size": 8, + }, + }), + ); + assert_eq!( + layouts["sigevent"], + json!({ + "wasm32": { + "size": 64, + "value_offset": 0, + "value_size": 4, + "signo_offset": 4, + "notify_offset": 8, + "payload_offset": 12, + }, + "wasm64": { + "size": 64, + "value_offset": 0, + "value_size": 8, + "signo_offset": 8, + "notify_offset": 12, + "payload_offset": 16, + }, + }), + ); + + let header = render_process_layouts_header(); + assert!(header.contains("#define KANDELO_PROCESS_CMSGHDR_WASM32_SIZE 12u")); + assert!(header.contains("#define KANDELO_PROCESS_CMSGHDR_WASM64_SIZE 16u")); + assert!(header.contains("#define KANDELO_PROCESS_SIGINFO_SIGNO_OFFSET 0u")); + assert!(header.contains("#define KANDELO_PROCESS_SIGINFO_WASM32_PID_OFFSET 12u")); + assert!(header.contains("#define KANDELO_PROCESS_SIGINFO_WASM64_PID_OFFSET 16u")); + assert!(header.contains("#define KANDELO_PROCESS_SIGINFO_WASM64_VALUE_SIZE 8u")); + assert!(header.contains("#define KANDELO_PROCESS_SIGEVENT_WASM32_SIZE 64u")); + assert!(header.contains("#define KANDELO_PROCESS_SIGEVENT_WASM64_VALUE_SIZE 8u")); + assert!(header.contains("#define KANDELO_SOCKET_MSG_TRUNC 32u")); + assert!(header.contains("#define KANDELO_SELECT_FD_SET_BYTES 128u")); + + let structs = marshalled_structs(); + assert_eq!(structs["KernelIovecWire"]["size"], json!(8)); + assert_eq!(structs["KernelMsghdrWire"]["size"], json!(28)); + assert_eq!(structs["KernelCmsghdrWire"]["size"], json!(12)); + } + #[test] fn program_artifact_snapshot_captures_complete_abi43_fork_contract() { let artifact = program_artifact(); @@ -4657,6 +6265,7 @@ mod tests { fn generated_wait_abi_metadata_matches_shared_layouts() { let rendered = render_ts_module(); for expected in [ + "export const STRUCT_SIZE_WPK_DRM_MODE_MODEINFO = 68 as const;", "export const STRUCT_SIZE_WASM_RUSAGE_WIRE = 144 as const;", "export const STRUCT_SIZE_KERNEL_WAIT_RESULT = 160 as const;", "export const KERNEL_WAIT_RESULT_WAIT_STATUS_OFFSET = 0 as const;", @@ -4705,6 +6314,9 @@ mod tests { json!({ "abi_version": 10, "channel_header": {"size": 64}, + "platform_limits": platform_limits(), + "process_native_layouts": process_native_layouts(), + "spawn_contract": spawn_contract(), "host_intercepted_syscalls": [ {"number": 201, "name": "SYS_EXECVE"} ], @@ -4834,6 +6446,49 @@ mod tests { ); } + #[test] + fn adding_platform_or_spawn_contract_section_is_breaking() { + let mut old = base_snapshot(); + old.as_object_mut().unwrap().remove("platform_limits"); + old.as_object_mut().unwrap().remove("spawn_contract"); + let new = base_snapshot(); + + let report = classify_compat_change(&old, &new).unwrap(); + assert_eq!( + report.breaking, + vec![ + "added top-level section \"platform_limits\"", + "added top-level section \"spawn_contract\"", + ], + ); + } + + #[test] + fn changing_platform_limit_is_breaking() { + let old = base_snapshot(); + let mut new = old.clone(); + new["platform_limits"]["arg_max_bytes"] = json!(8 * 1024 * 1024); + + let report = classify_compat_change(&old, &new).unwrap(); + assert_eq!( + report.breaking, + vec!["changed top-level section \"platform_limits\""], + ); + } + + #[test] + fn changing_spawn_contract_is_breaking() { + let old = base_snapshot(); + let mut new = old.clone(); + new["spawn_contract"]["header"]["bytes"] = json!(44); + + let report = classify_compat_change(&old, &new).unwrap(); + assert_eq!( + report.breaking, + vec!["changed top-level section \"spawn_contract\""], + ); + } + #[test] fn adding_optional_host_adapter_export_is_compatible() { let old = base_snapshot(); From 0e0422cb8223bb57525581a3d50fb566c2547834 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sun, 26 Jul 2026 14:04:28 -0400 Subject: [PATCH 05/82] Build: Make cached toolchain sysroots portable Package cached toolchain sysroots without embedding the source worktree path. Reconstruct the expected layout after cache restore so the action works in a different checkout and on a different runner. --- .github/actions/package-toolchain/action.yml | 47 ++++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/.github/actions/package-toolchain/action.yml b/.github/actions/package-toolchain/action.yml index 471b3ed04c..872ddec028 100644 --- a/.github/actions/package-toolchain/action.yml +++ b/.github/actions/package-toolchain/action.yml @@ -27,7 +27,7 @@ runs: run: | set -euo pipefail musl_gitlink="$(git rev-parse HEAD:libc/musl)" - KEY="musl-sysroot-v5-${{ runner.os }}-$musl_gitlink-${{ hashFiles('flake.nix', 'flake.lock', 'rust-toolchain.toml', 'scripts/dev-shell.sh', 'scripts/build-musl.sh', 'scripts/install-overlay-headers.sh', 'packages/registry/libcxx/**', 'libc/musl-overlay/**', 'libc/glue/**', 'sdk/activate.sh', 'sdk/bin/**', 'sdk/config.site', 'sdk/package.json', 'sdk/package-lock.json', 'sdk/src/**') }}" + KEY="musl-sysroot-v6-${{ runner.os }}-$musl_gitlink-${{ hashFiles('.github/actions/package-toolchain/**', 'flake.nix', 'flake.lock', 'rust-toolchain.toml', 'scripts/dev-shell.sh', 'scripts/build-musl.sh', 'scripts/install-overlay-headers.sh', 'packages/registry/libcxx/**', 'libc/musl-overlay/**', 'libc/glue/**', 'sdk/activate.sh', 'sdk/bin/**', 'sdk/config.site', 'sdk/package.json', 'sdk/package-lock.json', 'sdk/src/**') }}" echo "value=$KEY" >> "$GITHUB_OUTPUT" echo "toolchain-cache key: $KEY" @@ -56,10 +56,14 @@ runs: build-deps path libcxx --arch "$arch")" if [ "$arch" = "wasm64" ]; then sysroot="sysroot64"; else sysroot="sysroot"; fi mkdir -p "$sysroot/lib" "$sysroot/include/c++" - ln -sf "$prefix/lib/libc++.a" "$sysroot/lib/libc++.a" - ln -sf "$prefix/lib/libc++abi.a" "$sysroot/lib/libc++abi.a" + # WHY: the resolver prefix is runner-local and is neither cached nor + # packed below. Copy these inputs so downstream test shards receive + # a self-contained sysroot instead of dangling absolute symlinks. + rm -f "$sysroot/lib/libc++.a" "$sysroot/lib/libc++abi.a" + install -m 0644 "$prefix/lib/libc++.a" "$sysroot/lib/libc++.a" + install -m 0644 "$prefix/lib/libc++abi.a" "$sysroot/lib/libc++abi.a" rm -rf "$sysroot/include/c++/v1" - ln -sfn "$prefix/include/c++/v1" "$sysroot/include/c++/v1" + cp -a "$prefix/include/c++/v1" "$sysroot/include/c++/v1" done ' @@ -67,6 +71,41 @@ runs: shell: bash run: bash scripts/dev-shell.sh bash scripts/build-fork-instrument-tool.sh + - name: Validate portable toolchain sysroots + shell: bash + run: | + set -euo pipefail + for sysroot in sysroot sysroot64; do + for archive in libc++.a libc++abi.a; do + path="$sysroot/lib/$archive" + if [ ! -f "$path" ] || [ -L "$path" ]; then + echo "package-toolchain: non-portable libc++ archive: $path" >&2 + exit 1 + fi + done + headers="$sysroot/include/c++/v1" + if [ ! -d "$headers" ] || [ -L "$headers" ]; then + echo "package-toolchain: non-portable libc++ headers: $headers" >&2 + exit 1 + fi + + # WHY: tar preserves symlinks, but it does not carry targets outside + # the archived sysroot. Reject any escaping or dangling link before + # publication so consumers cannot receive a locally valid artifact + # that becomes incomplete after extraction. + sysroot_root="$(realpath "$sysroot")" + while IFS= read -r -d '' link; do + target="$(realpath "$link" 2>/dev/null || true)" + case "$target" in + "$sysroot_root"/*) ;; + *) + echo "package-toolchain: sysroot link escapes archive: $link" >&2 + exit 1 + ;; + esac + done < <(find "$sysroot" -type l -print0) + done + - name: Pack toolchain sysroots + host tools shell: bash run: | From b8e6b4b8bcc423d6dc6d5bdbb3c6050f44dcc894 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sun, 26 Jul 2026 14:39:59 -0400 Subject: [PATCH 06/82] Build: Materialize browser memory64 fixtures Declare and stage the wasm64 examples needed by browser tests instead of assuming that a previous local build left them in place. Teach CI scope detection, workspace packing, and browser fetch tests to enforce the fixture manifest. --- .../detect-change-scope/ci-scope-paths.sh | 6 +- .../test-ci-scope-paths.sh | 16 +++ .../test/process-native-layout.spec.ts | 19 +-- .../test/run-fetched-wasm-program.ts | 59 +++++++++ .../test/terminal-attributes-api.spec.ts | 17 +-- .../browser-demos/test/wait-lifecycle.spec.ts | 30 ++--- host/test/binary-resolver.test.ts | 32 ++++- host/test/browser-wasm-fetch.test.ts | 33 +++++ run.sh | 14 +- scripts/browser-memory64-example-fixtures.sh | 68 ++++++++++ scripts/browser-memory64-example-fixtures.txt | 11 ++ scripts/build-programs.sh | 40 +++--- ...check-browser-memory64-example-fixtures.ts | 122 ++++++++++++++++++ scripts/ci-check-browser-assets.sh | 1 + scripts/pack-ci-test-workspace.sh | 39 +++--- .../scripts/ci-run-test-suite-groups.test.sh | 19 ++- 16 files changed, 437 insertions(+), 89 deletions(-) create mode 100644 apps/browser-demos/test/run-fetched-wasm-program.ts create mode 100644 host/test/browser-wasm-fetch.test.ts create mode 100644 scripts/browser-memory64-example-fixtures.sh create mode 100644 scripts/browser-memory64-example-fixtures.txt create mode 100644 scripts/check-browser-memory64-example-fixtures.ts diff --git a/.github/actions/detect-change-scope/ci-scope-paths.sh b/.github/actions/detect-change-scope/ci-scope-paths.sh index 12e7138675..7b84912c07 100644 --- a/.github/actions/detect-change-scope/ci-scope-paths.sh +++ b/.github/actions/detect-change-scope/ci-scope-paths.sh @@ -94,6 +94,7 @@ binary_materialization_changed_files() { grep -E \ -e '^packages/registry/program-packages\.json$' \ -e '^tools/xtask/src/(index_candidate|index_toml|package_archive_name|remote_fetch|util)\.rs$' \ + -e '^scripts/browser-memory64-example-fixtures\.(sh|txt)$' \ -e '^scripts/(activate-local-shell-build-override|ci-homebrew-browser-mirror-state|fetch-binaries|install-local-binary|install-local-shell-artifact|materialize-ci-canonical-package-index|materialize-ci-publication-blockers|materialize-pr-overlays|materialize-resolver-binaries|pack-ci-test-workspace|resolve-binary|stage-portable-resolver-binaries|test-wasm-artifact-guards|validate-publication-blocker-report|wasm-artifact-guards)\.sh$' \ -e '^scripts/(build-resolve-binary-bundle|test-resolve-binary-bundle)\.sh$' \ -e '^scripts/resolve-binary\.(ts|bundle\.mjs|bundle\.LICENSES\.txt)$' \ @@ -112,8 +113,9 @@ kernel_runtime_changed_files() { -e '^tests/vfs-products\.(toml|generated\.json)$' \ -e '^tools/xtask/src/abi_staging/' \ -e '^(Cargo\.(lock|toml)|flake\.(nix|lock)|rust-toolchain\.toml|\.gitmodules)$' \ - -e '^scripts/(build-musl|build-libcxx|build-programs|check-abi-version|check-libcxx-toolchain-version|ci-run-test-suite|dev-shell|run-libc-tests|run-posix-tests|run-sortix-tests)\.sh$' \ - -e '^scripts/(abi-staging-pages-(producer(-fixture)?|readiness)(\.test)?\.ts|abi-staging-product-(browser|node)-evidence(\.test)?\.ts|abi-staging-product-input-sources\.ts|check-pages-vfs-product-registry(\.test)?\.mjs|run-vfs-product-builder(\.test)?\.ts|test-abi-staging-(mini-lifecycle|pages-atomic|product-authority)\.sh|vfs-product-catalog(\.test)?\.mjs)$' \ + -e '^scripts/browser-memory64-example-fixtures\.(sh|txt)$' \ + -e '^scripts/check-browser-memory64-example-fixtures\.ts$' \ + -e '^scripts/(build-musl|build-libcxx|build-programs|check-abi-version|check-libcxx-toolchain-version|ci-check-browser-assets|ci-run-test-suite|dev-shell|run-libc-tests|run-posix-tests|run-sortix-tests)\.sh$' \ -e '^examples/run-example\.ts$' \ || true } diff --git a/.github/actions/detect-change-scope/test-ci-scope-paths.sh b/.github/actions/detect-change-scope/test-ci-scope-paths.sh index 29430c246f..a9ba802edb 100755 --- a/.github/actions/detect-change-scope/test-ci-scope-paths.sh +++ b/.github/actions/detect-change-scope/test-ci-scope-paths.sh @@ -223,6 +223,13 @@ for blocker_materialization_script in \ "$blocker_materialization_script" \ "$blocker_materialization_script" done +for browser_memory64_fixture_input in \ + scripts/browser-memory64-example-fixtures.sh \ + scripts/browser-memory64-example-fixtures.txt; do + assert_matches binary_materialization_changed_files \ + "$browser_memory64_fixture_input" \ + "$browser_memory64_fixture_input" +done assert_matches binary_materialization_changed_files \ "scripts/stage-portable-resolver-binaries.sh" \ "scripts/stage-portable-resolver-binaries.sh" @@ -528,6 +535,15 @@ assert_matches kernel_runtime_changed_files \ assert_matches kernel_runtime_changed_files \ "scripts/ci-run-test-suite.sh" \ "scripts/ci-run-test-suite.sh" +for browser_memory64_fixture_input in \ + scripts/browser-memory64-example-fixtures.sh \ + scripts/browser-memory64-example-fixtures.txt \ + scripts/check-browser-memory64-example-fixtures.ts \ + scripts/ci-check-browser-assets.sh; do + assert_matches kernel_runtime_changed_files \ + "$browser_memory64_fixture_input" \ + "$browser_memory64_fixture_input" +done assert_not_matches kernel_runtime_changed_files \ "tools/xtask/src/remote_fetch.rs" \ "tools/xtask/src/remote_fetch.rs" diff --git a/apps/browser-demos/test/process-native-layout.spec.ts b/apps/browser-demos/test/process-native-layout.spec.ts index e424bd23be..d749ff6a9f 100644 --- a/apps/browser-demos/test/process-native-layout.spec.ts +++ b/apps/browser-demos/test/process-native-layout.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { runFetchedWasmProgram } from "./run-fetched-wasm-program"; const __dirname = dirname(fileURLToPath(import.meta.url)); const programs = [ @@ -123,20 +124,12 @@ for (const program of programs) { const programUrl = new URL(`/@fs/${program.path}`, baseURL).href; const result = await page.evaluate( - async ({ programUrl, argv0 }) => { - const response = await fetch(programUrl); - if (!response.ok) { - throw new Error( - `program fetch failed: ${response.status} ${response.url}`, - ); - } - return (window as any).__runTest( - await response.arrayBuffer(), - [argv0], - 30_000, - ); + runFetchedWasmProgram, + { + programUrl, + argv: [program.argv0], + timeoutMs: 30_000, }, - { programUrl, argv0: program.argv0 }, ); expect(result.exitCode, result.stderr).toBe(0); diff --git a/apps/browser-demos/test/run-fetched-wasm-program.ts b/apps/browser-demos/test/run-fetched-wasm-program.ts new file mode 100644 index 0000000000..9e47d13da8 --- /dev/null +++ b/apps/browser-demos/test/run-fetched-wasm-program.ts @@ -0,0 +1,59 @@ +export interface FetchedWasmProgram { + programUrl: string; + argv: string[]; + timeoutMs: number; + wasmByteDataFiles?: string[]; +} + +/** + * This function is passed directly to Playwright's page.evaluate, so it must + * remain self-contained and must not close over module-level helpers. + */ +export async function runFetchedWasmProgram({ + programUrl, + argv, + timeoutMs, + wasmByteDataFiles = [], +}: FetchedWasmProgram) { + const response = await fetch(programUrl); + if (!response.ok) { + throw new Error( + `program fetch failed: ${response.status} ${response.statusText} ${response.url}`, + ); + } + + const wasmBytes = await response.arrayBuffer(); + const prefix = new Uint8Array(wasmBytes, 0, Math.min(4, wasmBytes.byteLength)); + const hasWasmMagic = + prefix.length === 4 && + prefix[0] === 0x00 && + prefix[1] === 0x61 && + prefix[2] === 0x73 && + prefix[3] === 0x6d; + if (!hasWasmMagic) { + const firstBytes = Array.from(prefix, (byte) => + byte.toString(16).padStart(2, "0") + ).join(" "); + throw new Error( + "program fetch returned non-WebAssembly bytes" + + `: status=${response.status}` + + ` content-type=${response.headers.get("content-type") ?? ""}` + + ` first-bytes=${firstBytes || ""}` + + ` url=${response.url || programUrl}`, + ); + } + + return (window as any).__runTest( + wasmBytes, + argv, + timeoutMs, + wasmByteDataFiles.length > 0 + ? { + dataFiles: wasmByteDataFiles.map((path) => ({ + path, + useWasmBytes: true, + })), + } + : undefined, + ); +} diff --git a/apps/browser-demos/test/terminal-attributes-api.spec.ts b/apps/browser-demos/test/terminal-attributes-api.spec.ts index c8a535ce1c..d36874f93f 100644 --- a/apps/browser-demos/test/terminal-attributes-api.spec.ts +++ b/apps/browser-demos/test/terminal-attributes-api.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { runFetchedWasmProgram } from "./run-fetched-wasm-program"; const __dirname = dirname(fileURLToPath(import.meta.url)); const programs = [ @@ -48,17 +49,11 @@ for (const program of programs) { ); const programUrl = new URL(`/@fs/${program.path}`, baseURL).href; - const result = await page.evaluate(async ({ programUrl }) => { - const response = await fetch(programUrl); - if (!response.ok) { - throw new Error(`program fetch failed: ${response.status}`); - } - return (window as any).__runTest( - await response.arrayBuffer(), - ["terminal-attributes-api-test"], - 20_000, - ); - }, { programUrl }); + const result = await page.evaluate(runFetchedWasmProgram, { + programUrl, + argv: ["terminal-attributes-api-test"], + timeoutMs: 20_000, + }); expect(result.exitCode, result.stderr).toBe(0); expect(result.stdout).toContain("TERMINAL_ATTRIBUTES_API_PASS"); diff --git a/apps/browser-demos/test/wait-lifecycle.spec.ts b/apps/browser-demos/test/wait-lifecycle.spec.ts index 1714e7ac58..a39dcf9416 100644 --- a/apps/browser-demos/test/wait-lifecycle.spec.ts +++ b/apps/browser-demos/test/wait-lifecycle.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { runFetchedWasmProgram } from "./run-fetched-wasm-program"; const __dirname = dirname(fileURLToPath(import.meta.url)); const programs = [ @@ -60,28 +61,15 @@ for (const program of programs) const programUrl = new URL(`/@fs/${program.path}`, baseURL).href; const result = await page.evaluate( - async ({ programUrl, selfSpawnPath }) => { - const response = await fetch(programUrl); - if (!response.ok) { - throw new Error( - `program fetch failed: ${response.status} ${response.url}`, - ); - } - const wasmBytes = await response.arrayBuffer(); - return (window as any).__runTest( - wasmBytes, - ["wait-lifecycle-test"], - 30_000, - selfSpawnPath - ? { - dataFiles: [ - { path: selfSpawnPath, useWasmBytes: true }, - ], - } - : undefined, - ); + runFetchedWasmProgram, + { + programUrl, + argv: ["wait-lifecycle-test"], + timeoutMs: 30_000, + wasmByteDataFiles: program.selfSpawnPath + ? [program.selfSpawnPath] + : [], }, - { programUrl, selfSpawnPath: program.selfSpawnPath }, ); expect(result.exitCode).toBe(0); diff --git a/host/test/binary-resolver.test.ts b/host/test/binary-resolver.test.ts index 745350fa31..f4ce617910 100644 --- a/host/test/binary-resolver.test.ts +++ b/host/test/binary-resolver.test.ts @@ -2481,6 +2481,20 @@ guest_path = '/usr/share/runtime.dat' cleanupDirs.add(sourceCache); const actualRepo = findRepoRoot(); + const memory64FixtureManifest = + "scripts/browser-memory64-example-fixtures.txt"; + const memory64FixtureReader = + "scripts/browser-memory64-example-fixtures.sh"; + const memory64ExampleSources = readFileSync( + join(actualRepo, memory64FixtureManifest), + "utf8", + ) + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line !== "" && !line.startsWith("#")); + const memory64ExampleOutputs = memory64ExampleSources.map( + (source) => `${source.slice(0, -2)}.wasm64.wasm`, + ); const packer = join(sourceRepo, "scripts", "pack-ci-test-workspace.sh"); mkdirSync(dirname(packer), { recursive: true }); copyFileSync( @@ -2498,6 +2512,18 @@ guest_path = '/usr/share/runtime.dat' portableStager, ); chmodSync(portableStager, 0o755); + for (const contractFile of [ + memory64FixtureManifest, + memory64FixtureReader, + ]) { + const target = join(sourceRepo, contractFile); + copyFileSync(join(actualRepo, contractFile), target); + } + for (const relPath of memory64ExampleSources) { + const path = join(sourceRepo, relPath); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, relPath); + } const fakeBin = join(sourceRepo, "fixture-bin"); mkdirSync(fakeBin, { recursive: true }); @@ -2551,8 +2577,7 @@ exit 2 "examples/gencat.wasm", "examples/pthread_channel_reuse_test.wasm", "examples/wait_lifecycle_test.wasm", - "examples/wait_lifecycle_test.wasm64.wasm", - "examples/terminal_attributes_api_test.wasm64.wasm", + ...memory64ExampleOutputs, "benchmarks/wasm/pipe-throughput.wasm", "benchmarks/wasm/file-throughput.wasm", "benchmarks/wasm/syscall-latency.wasm", @@ -2635,6 +2660,9 @@ exit 2 ["--zstd", "-xf", archive, "-C", relocatedRepo], { stdio: "pipe" }, ); + for (const relPath of memory64ExampleOutputs) { + expect(readFileSync(join(relocatedRepo, relPath), "utf8")).toBe(relPath); + } process.env.WASM_POSIX_BINARY_RESOLVER_REPO_ROOT = relocatedRepo; process.env.WASM_POSIX_BINARY_CACHE_ROOT = ".ci-test-binary-cache"; diff --git a/host/test/browser-wasm-fetch.test.ts b/host/test/browser-wasm-fetch.test.ts new file mode 100644 index 0000000000..43b42ee1d7 --- /dev/null +++ b/host/test/browser-wasm-fetch.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { runFetchedWasmProgram } from "../../apps/browser-demos/test/run-fetched-wasm-program"; + +describe("direct browser Wasm fixture fetches", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("rejects an HTTP-success HTML fallback before invoking the kernel", async () => { + const runTest = vi.fn(); + vi.stubGlobal("window", { __runTest: runTest }); + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response("", { + status: 200, + headers: { "content-type": "text/html" }, + }) + ), + ); + + await expect( + runFetchedWasmProgram({ + programUrl: "https://example.test/missing.wasm", + argv: ["missing"], + timeoutMs: 1_000, + }), + ).rejects.toThrow( + /non-WebAssembly bytes: status=200 content-type=text\/html first-bytes=3c 21 64 6f/, + ); + expect(runTest).not.toHaveBeenCalled(); + }); +}); diff --git a/run.sh b/run.sh index 78e77571fc..9f04733333 100755 --- a/run.sh +++ b/run.sh @@ -35,6 +35,10 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")" && pwd)" +BROWSER_MEMORY64_FIXTURES_REPO_ROOT="$REPO_ROOT" +BROWSER_MEMORY64_FIXTURES_MANIFEST="$REPO_ROOT/scripts/browser-memory64-example-fixtures.txt" +# shellcheck source=/dev/null +source "$REPO_ROOT/scripts/browser-memory64-example-fixtures.sh" # Activate the worktree-local SDK toolchain (no global npm link required). # Build scripts also source this directly; sourcing here makes the tools @@ -405,12 +409,20 @@ has_sysroot64() { [ -f "$REPO_ROOT/sysroot64/lib/libc.a" ]; } has_sdk() { command -v wasm32posix-cc &>/dev/null; } has_host() { [ -d "$REPO_ROOT/host/dist" ]; } has_rootfs() { [ -f "$REPO_ROOT/host/wasm/rootfs.vfs" ]; } +has_browser_memory64_example_fixtures() { + local output + local outputs + outputs="$(browser_memory64_fixture_outputs)" || return 1 + while IFS= read -r output; do + [ -f "$REPO_ROOT/$output" ] || return 1 + done <<< "$outputs" +} has_programs() { has_resolvable programs/fork-exec.wasm && has_resolvable programs/fbtest.wasm && [ -f "$REPO_ROOT/examples/pthread_channel_reuse_test.wasm" ] && [ -f "$REPO_ROOT/examples/wait_lifecycle_test.wasm" ] && - [ -f "$REPO_ROOT/examples/wait_lifecycle_test.wasm64.wasm" ] && + has_browser_memory64_example_fixtures && [ -f "$REPO_ROOT/benchmarks/wasm/pipe-throughput.wasm" ] && [ -f "$REPO_ROOT/benchmarks/wasm/file-throughput.wasm" ] && [ -f "$REPO_ROOT/benchmarks/wasm/syscall-latency.wasm" ] && diff --git a/scripts/browser-memory64-example-fixtures.sh b/scripts/browser-memory64-example-fixtures.sh new file mode 100644 index 0000000000..deb6795417 --- /dev/null +++ b/scripts/browser-memory64-example-fixtures.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash + +# Shared, fail-closed reader for browser-memory64-example-fixtures.txt. +# +# Callers set both variables before sourcing this file: +# BROWSER_MEMORY64_FIXTURES_REPO_ROOT +# BROWSER_MEMORY64_FIXTURES_MANIFEST + +browser_memory64_fixture_sources() { + if [ -z "${BROWSER_MEMORY64_FIXTURES_REPO_ROOT:-}" ]; then + echo "browser memory64 fixtures: repository root is not configured" >&2 + return 1 + fi + if [ -z "${BROWSER_MEMORY64_FIXTURES_MANIFEST:-}" ] || + [ ! -f "$BROWSER_MEMORY64_FIXTURES_MANIFEST" ]; then + echo "browser memory64 fixtures: manifest is missing: ${BROWSER_MEMORY64_FIXTURES_MANIFEST:-}" >&2 + return 1 + fi + + local count=0 + local leaf + local line + local previous="" + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + ""|\#*) continue ;; + examples/*.c) ;; + *) + echo "browser memory64 fixtures: invalid source path: $line" >&2 + return 1 + ;; + esac + leaf="${line#examples/}" + leaf="${leaf%.c}" + case "$leaf" in + ""|*/*|*[!a-z0-9_-]*) + echo "browser memory64 fixtures: invalid source path: $line" >&2 + return 1 + ;; + esac + if [ -n "$previous" ] && + { [ "$line" = "$previous" ] || [[ "$line" < "$previous" ]]; }; then + echo "browser memory64 fixtures: manifest must be sorted with no duplicates" >&2 + return 1 + fi + if [ ! -f "$BROWSER_MEMORY64_FIXTURES_REPO_ROOT/$line" ]; then + echo "browser memory64 fixtures: source is missing: $line" >&2 + return 1 + fi + printf '%s\n' "$line" + previous="$line" + count=$((count + 1)) + done < "$BROWSER_MEMORY64_FIXTURES_MANIFEST" + + if [ "$count" -eq 0 ]; then + echo "browser memory64 fixtures: manifest has no sources" >&2 + return 1 + fi +} + +browser_memory64_fixture_outputs() { + local source + local sources + sources="$(browser_memory64_fixture_sources)" || return 1 + while IFS= read -r source; do + printf '%s.wasm64.wasm\n' "${source%.c}" + done <<< "$sources" +} diff --git a/scripts/browser-memory64-example-fixtures.txt b/scripts/browser-memory64-example-fixtures.txt new file mode 100644 index 0000000000..fcfc4a42db --- /dev/null +++ b/scripts/browser-memory64-example-fixtures.txt @@ -0,0 +1,11 @@ +# Authoritative source list for browser-owned memory64 example fixtures. +# +# Each source is built to the same path with `.c` replaced by +# `.wasm64.wasm`. Keep this list sorted. Browser specs, build-programs, +# run.sh readiness, and prepared CI workspace packing are contract-checked +# against this file so browser-only jobs cannot depend on ambient artifacts. +examples/process_native_layout_test.c +examples/sysv_ipc_test.c +examples/terminal_attributes_api_test.c +examples/timerfd_signalfd_scratch_test.c +examples/wait_lifecycle_test.c diff --git a/scripts/build-programs.sh b/scripts/build-programs.sh index b3e394ac3c..efeedbf576 100755 --- a/scripts/build-programs.sh +++ b/scripts/build-programs.sh @@ -10,6 +10,10 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" SYSROOT="$REPO_ROOT/sysroot" GLUE_DIR="$REPO_ROOT/libc/glue" +BROWSER_MEMORY64_FIXTURES_REPO_ROOT="$REPO_ROOT" +BROWSER_MEMORY64_FIXTURES_MANIFEST="$REPO_ROOT/scripts/browser-memory64-example-fixtures.txt" +# shellcheck source=/dev/null +source "$REPO_ROOT/scripts/browser-memory64-example-fixtures.sh" # Per-arch output dirs match the layout the resolver's # `place_binaries_symlinks` writes: # binaries/programs// and local-binaries/programs//. @@ -420,28 +424,20 @@ if [ -f "$SYSROOT64/lib/libc.a" ]; then -o "$OUT_DIR_64/${local_name}.wasm" done - # Keep the memory64 wait-lifecycle browser fixture on the same owned build - # path as its wasm32 counterpart. The owning Vitest builds this fixture on - # demand, but browser-only and packed CI workspaces must not depend on a - # prior test runner having left a generated artifact behind. This fixture - # deliberately uses posix_spawn rather than fork because fork rewind - # instrumentation is currently a wasm32 artifact contract. - wait_lifecycle_src="$REPO_ROOT/examples/wait_lifecycle_test.c" - if [ -f "$wait_lifecycle_src" ]; then - echo " Compiling wait_lifecycle_test (wasm64)..." - "$CC" "${CFLAGS64[@]}" "$wait_lifecycle_src" "${LINK_FLAGS64[@]}" \ - -o "$REPO_ROOT/examples/wait_lifecycle_test.wasm64.wasm" - fi - - # Terminal-attribute marshalling is pointer-width sensitive in the host, - # so browser-only and packed CI workspaces need the same memory64 guest - # fixture that the owning Vitest can build on demand. - terminal_attributes_src="$REPO_ROOT/examples/terminal_attributes_api_test.c" - if [ -f "$terminal_attributes_src" ]; then - echo " Compiling terminal_attributes_api_test (wasm64)..." - "$CC" "${CFLAGS64[@]}" "$terminal_attributes_src" "${LINK_FLAGS64[@]}" \ - -o "$REPO_ROOT/examples/terminal_attributes_api_test.wasm64.wasm" - fi + # WHY: owning Vitests can build these on demand, but browser-only and + # packed CI workspaces cannot depend on a prior test runner leaving ambient + # artifacts behind. Every browser-owned example comes from the one + # contract-checked manifest. Their memory64 execution paths do not require + # fork rewind instrumentation; the wait fixture selects posix_spawn because + # that instrumentation is currently a wasm32 artifact contract. + memory64_example_sources="$(browser_memory64_fixture_sources)" + while IFS= read -r source_rel; do + source_path="$REPO_ROOT/$source_rel" + output_path="$REPO_ROOT/${source_rel%.c}.wasm64.wasm" + echo " Compiling $(basename "$source_rel" .c) (wasm64)..." + "$CC" "${CFLAGS64[@]}" "$source_path" "${LINK_FLAGS64[@]}" \ + -o "$output_path" + done <<< "$memory64_example_sources" # Fork continuation instrumentation is currently a wasm32 artifact # contract. Still cover the compiler's architecture-independent SjLj / diff --git a/scripts/check-browser-memory64-example-fixtures.ts b/scripts/check-browser-memory64-example-fixtures.ts new file mode 100644 index 0000000000..3b1738a622 --- /dev/null +++ b/scripts/check-browser-memory64-example-fixtures.ts @@ -0,0 +1,122 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const manifestRelativePath = + "scripts/browser-memory64-example-fixtures.txt"; +const manifestPath = join(repoRoot, manifestRelativePath); +const sourcePattern = /^examples\/[a-z0-9][a-z0-9_-]*\.c$/; + +function fail(message: string): never { + throw new Error(`browser memory64 fixture contract: ${message}`); +} + +function portableRelativePath(path: string): string { + return path.split(sep).join("/"); +} + +function readManifestSources(): string[] { + if (!existsSync(manifestPath)) { + fail(`missing manifest ${manifestRelativePath}`); + } + const sources = readFileSync(manifestPath, "utf8") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line !== "" && !line.startsWith("#")); + if (sources.length === 0) { + fail(`${manifestRelativePath} has no fixture sources`); + } + for (const source of sources) { + if (!sourcePattern.test(source)) { + fail(`invalid source path ${JSON.stringify(source)}`); + } + if (!existsSync(join(repoRoot, source))) { + fail(`missing source ${source}`); + } + } + const sortedUnique = [...new Set(sources)].sort(); + if ( + sources.length !== sortedUnique.length || + sources.some((source, index) => source !== sortedUnique[index]) + ) { + fail(`${manifestRelativePath} must be sorted with no duplicates`); + } + return sources; +} + +function browserSpecFiles(directory: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...browserSpecFiles(path)); + } else if (entry.isFile() && entry.name.endsWith(".spec.ts")) { + files.push(path); + } + } + return files; +} + +function referencedExampleOutputs(): string[] { + const outputs = new Set(); + const literalPattern = /(["'`])([^"'`\r\n]+\.wasm64\.wasm)\1/g; + for (const specPath of browserSpecFiles( + join(repoRoot, "apps", "browser-demos", "test"), + )) { + const source = readFileSync(specPath, "utf8"); + for (const match of source.matchAll(literalPattern)) { + const referencedPath = portableRelativePath( + relative(repoRoot, resolve(dirname(specPath), match[2])), + ); + if (referencedPath.startsWith("examples/")) { + outputs.add(referencedPath); + } + } + } + return [...outputs].sort(); +} + +function assertEqualSets(expected: string[], actual: string[]): void { + const actualSet = new Set(actual); + const expectedSet = new Set(expected); + const missing = expected.filter((path) => !actualSet.has(path)); + const unmanifested = actual.filter((path) => !expectedSet.has(path)); + if (missing.length === 0 && unmanifested.length === 0) return; + + const details = [ + ...missing.map((path) => ` manifest output has no browser spec: ${path}`), + ...unmanifested.map( + (path) => ` browser spec output is not in the manifest: ${path}`, + ), + ]; + fail(`browser references and manifest outputs differ\n${details.join("\n")}`); +} + +const manifestSources = readManifestSources(); +const manifestOutputs = manifestSources.map( + (source) => `${source.slice(0, -2)}.wasm64.wasm`, +); +const browserOutputs = referencedExampleOutputs(); +if (browserOutputs.length === 0) { + fail("no browser example memory64 references were found"); +} +assertEqualSets(manifestOutputs, browserOutputs); + +// WHY: checking only the manifest against specs would still allow a producer +// or readiness guard to regress to a partial literal list. These consumers +// must all derive their fixture set from the same authoritative file. +for (const consumer of [ + "scripts/build-programs.sh", + "run.sh", + "scripts/pack-ci-test-workspace.sh", +]) { + const source = readFileSync(join(repoRoot, consumer), "utf8"); + if (!source.includes(manifestRelativePath)) { + fail(`${consumer} does not consume ${manifestRelativePath}`); + } +} + +console.log( + `browser memory64 fixture contract: ${manifestOutputs.length} fixture(s) verified`, +); diff --git a/scripts/ci-check-browser-assets.sh b/scripts/ci-check-browser-assets.sh index bed112513f..42757e8aba 100755 --- a/scripts/ci-check-browser-assets.sh +++ b/scripts/ci-check-browser-assets.sh @@ -7,4 +7,5 @@ cd "$REPO_ROOT" bash scripts/test-pages-publish-size.sh bash scripts/test-pages-run-freshness.sh bash scripts/test-pages-deployment-contract.sh +npx tsx scripts/check-browser-memory64-example-fixtures.ts npx tsx scripts/ci-check-browser-assets.ts diff --git a/scripts/pack-ci-test-workspace.sh b/scripts/pack-ci-test-workspace.sh index 5f44864407..0c2397920b 100755 --- a/scripts/pack-ci-test-workspace.sh +++ b/scripts/pack-ci-test-workspace.sh @@ -9,6 +9,10 @@ PUBLICATION_BLOCKERS_REL=".ci-test-publication-blockers.json" HOMEBREW_BROWSER_MIRROR_STATE_REL=".ci-homebrew-browser-mirror-state.json" STAGING_SHELL_RECEIPT_REL=".ci-staging-shell-receipt.json" STAGING_SHELL_REPORT_REL=".ci-staging-shell-report.json" +BROWSER_MEMORY64_FIXTURES_REPO_ROOT="$REPO_ROOT" +BROWSER_MEMORY64_FIXTURES_MANIFEST="$REPO_ROOT/scripts/browser-memory64-example-fixtures.txt" +# shellcheck source=/dev/null +source "$REPO_ROOT/scripts/browser-memory64-example-fixtures.sh" publication_blockers="" homebrew_browser_mirror_state="" @@ -70,21 +74,26 @@ if [ ! -x "$xtask_path" ]; then exit 1 fi -for required in \ - local-binaries/kernel.wasm \ - host/wasm/rootfs.vfs \ - examples/gencat.wasm \ - examples/pthread_channel_reuse_test.wasm \ - examples/wait_lifecycle_test.wasm \ - examples/wait_lifecycle_test.wasm64.wasm \ - examples/terminal_attributes_api_test.wasm64.wasm \ - benchmarks/wasm/pipe-throughput.wasm \ - benchmarks/wasm/file-throughput.wasm \ - benchmarks/wasm/syscall-latency.wasm \ - benchmarks/wasm/fork-bench.wasm \ - benchmarks/wasm/clone-bench.wasm \ - benchmarks/wasm/spawn-bench.wasm \ - benchmarks/wasm/hello.wasm; do +required_items=( + local-binaries/kernel.wasm + host/wasm/rootfs.vfs + examples/gencat.wasm + examples/pthread_channel_reuse_test.wasm + examples/wait_lifecycle_test.wasm + benchmarks/wasm/pipe-throughput.wasm + benchmarks/wasm/file-throughput.wasm + benchmarks/wasm/syscall-latency.wasm + benchmarks/wasm/fork-bench.wasm + benchmarks/wasm/clone-bench.wasm + benchmarks/wasm/spawn-bench.wasm + benchmarks/wasm/hello.wasm +) +memory64_example_outputs="$(browser_memory64_fixture_outputs)" +while IFS= read -r output; do + required_items+=("$output") +done <<< "$memory64_example_outputs" + +for required in "${required_items[@]}"; do if [ ! -f "$required" ]; then echo "pack-ci-test-workspace: missing required artifact: $required" >&2 exit 1 diff --git a/tests/scripts/ci-run-test-suite-groups.test.sh b/tests/scripts/ci-run-test-suite-groups.test.sh index dab253010d..a2b648a8fb 100755 --- a/tests/scripts/ci-run-test-suite-groups.test.sh +++ b/tests/scripts/ci-run-test-suite-groups.test.sh @@ -133,6 +133,8 @@ mkdir -p \ cp \ "$REPO_ROOT/scripts/activate-ci-test-workspace.sh" \ "$REPO_ROOT/scripts/ci-homebrew-browser-mirror-state.sh" \ + "$REPO_ROOT/scripts/browser-memory64-example-fixtures.sh" \ + "$REPO_ROOT/scripts/browser-memory64-example-fixtures.txt" \ "$REPO_ROOT/scripts/ci-run-test-suite.sh" \ "$REPO_ROOT/scripts/ci-vitest-resource-isolated-cases.tsv" \ "$REPO_ROOT/scripts/pack-ci-test-workspace.sh" \ @@ -264,6 +266,15 @@ write_blocked_browser_mirror_state() { ' > "$out" } +BROWSER_MEMORY64_FIXTURES_REPO_ROOT="$REPO_ROOT" +BROWSER_MEMORY64_FIXTURES_MANIFEST="$REPO_ROOT/scripts/browser-memory64-example-fixtures.txt" +# shellcheck source=/dev/null +source "$REPO_ROOT/scripts/browser-memory64-example-fixtures.sh" +memory64_sources="$(browser_memory64_fixture_sources)" +while IFS= read -r source; do + cp "$REPO_ROOT/$source" "$FIXTURE/$source" +done <<< "$memory64_sources" + cat > "$FIXTURE/bin/npm" <<'EOF' #!/usr/bin/env bash if [ "${1:-}" = run ] && [ "${2:-}" = build ]; then @@ -1430,9 +1441,13 @@ prepared_files=( examples/gencat.wasm examples/pthread_channel_reuse_test.wasm examples/wait_lifecycle_test.wasm - examples/wait_lifecycle_test.wasm64.wasm - examples/terminal_attributes_api_test.wasm64.wasm ) +BROWSER_MEMORY64_FIXTURES_REPO_ROOT="$FIXTURE" +BROWSER_MEMORY64_FIXTURES_MANIFEST="$FIXTURE/scripts/browser-memory64-example-fixtures.txt" +memory64_outputs="$(browser_memory64_fixture_outputs)" +while IFS= read -r output; do + prepared_files+=("$output") +done <<< "$memory64_outputs" for benchmark in \ pipe-throughput.wasm \ file-throughput.wasm \ From 42c4ebd5558738616b7965230a3c274f3723da2d Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Mon, 27 Jul 2026 14:08:57 -0400 Subject: [PATCH 07/82] Host: Complete kernel scratch ownership closure Finish moving blocked retries, VFS operations, IPC, sockets, process state, and scalar results behind kernel-owned scratch and entry gates. Remove remaining host dependence on mutable guest pointer layouts. Apply the same ownership rules in Node and browser workers. Add focused tests for append and offset behavior, environment transactions, worker lifecycle, and fail-stop scratch violations, then refresh the ABI 43 snapshot and generated bindings. --- abi/snapshot.json | 1044 +- .../test/environment-transaction.spec.ts | 80 + apps/browser-demos/test/epoll-repro.ts | 172 +- .../opfs-advisory-lock-client-worker.ts | 333 +- .../test/fixtures/opfs-seek-client-worker.ts | 23 + .../test/kernel-scratch-runtime.spec.ts | 286 +- apps/browser-demos/test/opfs-seek.spec.ts | 2 + crates/kernel/src/blocked_retry.rs | 927 + crates/kernel/src/channel_result.rs | 137 + crates/kernel/src/channel_scratch.rs | 452 +- crates/kernel/src/fork.rs | 57 +- crates/kernel/src/ipc.rs | 1003 +- crates/kernel/src/lib.rs | 3 + crates/kernel/src/mqueue.rs | 727 +- crates/kernel/src/ofd.rs | 87 +- crates/kernel/src/process.rs | 98 +- crates/kernel/src/process_table.rs | 907 +- crates/kernel/src/signal.rs | 6 +- crates/kernel/src/socket.rs | 127 +- crates/kernel/src/syscalls.rs | 4100 +- crates/kernel/src/transfer.rs | 732 + crates/kernel/src/unix_socket.rs | 153 +- crates/kernel/src/wasm_api.rs | 2594 +- .../wasm_api_channel_pointer_contract.rs | 44 +- crates/shared/src/channel_scalar.rs | 921 + crates/shared/src/host_abi.rs | 182 +- crates/shared/src/lib.rs | 152 +- docs/abi-versioning.md | 94 +- docs/architecture.md | 181 +- ...026-07-25-kernel-scratch-transfer-audit.md | 1286 +- docs/posix-status.md | 55 +- examples/kernel_scratch_browser_test.c | 454 + examples/lseek_invalid_test.c | 21 +- examples/putenv_test.c | 334 + examples/wait_lifecycle_test.c | 152 + host/src/append-contract.ts | 33 + host/src/browser-kernel-host.ts | 50 +- host/src/browser-kernel-protocol.ts | 7 + host/src/browser-kernel-worker-entry.ts | 200 +- host/src/browser.ts | 1 + host/src/channel-scalar-contract.ts | 151 + host/src/file-offset.ts | 179 + host/src/generated/abi.ts | 243 +- host/src/host-adapter-manifest.ts | 164 +- host/src/index.ts | 1 + host/src/kernel-entry-gate.ts | 1596 + host/src/kernel-scratch.ts | 284 +- host/src/kernel-worker.ts | 30965 +++++++++++----- host/src/kernel.ts | 2393 +- host/src/native-positioned-write.ts | 323 + host/src/node-kernel-host.ts | 121 +- host/src/node-kernel-protocol.ts | 7 + host/src/node-kernel-worker-entry.ts | 60 +- host/src/platform/node.ts | 132 +- host/src/types.ts | 39 +- host/src/vfs/default-mounts-node.ts | 33 +- host/src/vfs/device-fs.ts | 47 +- host/src/vfs/host-fs.ts | 246 +- host/src/vfs/index.ts | 1 + host/src/vfs/memory-fs.ts | 64 +- host/src/vfs/opfs-append.ts | 29 + host/src/vfs/opfs-channel.ts | 6 + host/src/vfs/opfs-worker.ts | 79 + host/src/vfs/opfs.ts | 104 +- host/src/vfs/sharedfs-vendor.ts | 66 + host/src/vfs/types.ts | 38 +- host/src/vfs/vfs.ts | 22 +- host/src/wasi-shim.ts | 192 +- host/src/worker-main.ts | 43 +- host/test/abi-version.test.ts | 25 + host/test/advisory-lock-kernel.test.ts | 237 +- host/test/advisory-lock-retry.test.ts | 820 +- host/test/append-contract.test.ts | 269 + host/test/browser-kernel.test.ts | 70 +- host/test/centralized-test-helper.ts | 17 +- host/test/channel-listener-scheduling.test.ts | 95 +- host/test/channel-scalar-contract.test.ts | 165 + host/test/clone-tid-authority.test.ts | 589 +- host/test/connect-pending-retry.test.ts | 107 +- host/test/datagram-wakeup.test.ts | 97 +- host/test/deferred-worker-start.test.ts | 433 +- host/test/dri-kms-stats-sab.test.ts | 136 +- host/test/environment-transaction-fixture.ts | 81 + host/test/exec-state-tracking.test.ts | 788 +- host/test/file-shared-memory.test.ts | 293 +- host/test/fixtures/sharedfs-append-worker.ts | 51 + host/test/fixtures/wasi-scalar-abi.wat | 111 + host/test/global-setup.ts | 22 + host/test/host-adapter-manifest.test.ts | 220 +- host/test/host-diagnostic-routing.test.ts | 12 + host/test/host-file-offset.test.ts | 404 + host/test/host-process-pointer-width.test.ts | 311 +- host/test/interactive-stdin.test.ts | 20 + host/test/kernel-authority-boundary.test.ts | 295 + .../kernel-blocking-retry-snapshot.test.ts | 5637 +++ host/test/kernel-clone-exit-entry.test.ts | 434 + .../kernel-detached-effect-protocol.test.ts | 226 + host/test/kernel-entry-context-audit.test.ts | 1195 + host/test/kernel-entry-gate.test.ts | 1523 + host/test/kernel-exec-entry.test.ts | 281 + host/test/kernel-export-failure-audit.test.ts | 270 + .../kernel-initialization-lifetime.test.ts | 141 +- host/test/kernel-ipc-shmat-entry.test.ts | 302 + .../kernel-large-transfer-protocol.test.ts | 1669 + .../test/kernel-network-cleanup-entry.test.ts | 329 + .../kernel-process-registration-entry.test.ts | 275 + host/test/kernel-public-entry-roots.test.ts | 320 + host/test/kernel-public-scratch.test.ts | 451 +- ...kernel-reservation-export-contract.test.ts | 99 + host/test/kernel-scratch-contract.test.ts | 1328 +- host/test/kernel-scratch-region.test.ts | 269 +- host/test/kernel-scratch-runtime.test.ts | 191 + ...kernel-scratch-transfer-boundaries.test.ts | 4215 ++- ...el-shared-memory-inheritance-entry.test.ts | 682 + host/test/kernel-teardown-pipe-entry.test.ts | 283 + host/test/kernel-telemetry-entry.test.ts | 260 + host/test/kernel-wasm-input-snapshot.test.ts | 41 +- host/test/kernel-worker-copyback.test.ts | 337 +- .../kernel-worker-entry-root-contract.test.ts | 246 + host/test/kernel-worker-test-scratch.ts | 87 +- host/test/kernel.test.ts | 802 +- host/test/lseek-invalid-guest.test.ts | 14 +- host/test/mmap-tracking.test.ts | 6 +- host/test/multi-worker.test.ts | 2122 +- host/test/native-open-create-race.test.ts | 211 + host/test/node-host-mounts.test.ts | 2 +- host/test/node-host-vfs-only-metadata.test.ts | 9 +- host/test/node-kernel-fatal.test.ts | 189 + host/test/opfs-channel.test.ts | 3 + host/test/process-wait-lifecycle.test.ts | 2975 +- host/test/putenv.test.ts | 78 +- host/test/readdir-atomicity.test.ts | 73 +- host/test/readiness-deadline.test.ts | 685 +- host/test/select-signal-outcome.test.ts | 308 +- host/test/shared-memory-coherence.test.ts | 273 +- host/test/signal-accept-livelock.test.ts | 641 +- host/test/spawn-blob-transport.test.ts | 558 +- host/test/spawn-pid-authority.test.ts | 661 +- .../support/kernel-entry-context-audit.ts | 2731 ++ .../support/kernel-export-failure-audit.ts | 523 + host/test/support/kernel-scratch-instance.ts | 325 +- host/test/support/wasm-memory-write-audit.ts | 8951 +++-- host/test/teardown-reclaim.test.ts | 15 +- host/test/vfs/sharedfs-positioned-io.test.ts | 131 + host/test/wasi-shim.test.ts | 261 +- host/test/wasm-memory-write-audit.test.ts | 3689 +- .../worker-kernel-import-contract.test.ts | 79 + libc/glue/abi_constants.h | 5 +- libc/glue/channel_syscall.c | 164 +- libc/glue/syscall_glue.c | 119 +- libc/glue/syscall_imports.h | 54 +- .../arch/wasm32posix/bits/syscall.h.in | 6 - .../arch/wasm64posix/bits/syscall.h.in | 4 - .../include/bits/kandelo_channel_scalars.h | 321 + .../include/bits/kandelo_limits.h | 2 + .../include/bits/kandelo_process_layouts.h | 4 + .../include/bits/kandelo_thread_syscalls.h | 8 + libc/musl-overlay/src/env/putenv.c | 160 +- libc/musl-overlay/src/env/setenv.c | 56 +- libc/musl-overlay/src/env/unsetenv.c | 20 +- .../src/thread/wasm32posix/pthread_cancel.c | 94 +- packages/registry/erlang-vfs/build.toml | 1 + packages/registry/git/test/git.test.ts | 253 +- packages/registry/kandelo-sdk/build.toml | 1 + packages/registry/kernel/build-kernel.sh | 28 +- packages/registry/mariadb-test/build.toml | 1 + packages/registry/mariadb-vfs/build.toml | 1 + packages/registry/nginx-vfs/build.toml | 1 + packages/registry/node-vfs/build.toml | 1 + packages/registry/perl-vfs/build.toml | 1 + packages/registry/program-packages.json | 690 +- packages/registry/python-vfs/build.toml | 1 + packages/registry/redis-vfs/build.toml | 1 + packages/registry/rootfs/build.toml | 1 + packages/registry/shell/build.toml | 1 + run.sh | 28 +- scripts/browser-memory64-example-fixtures.txt | 2 + scripts/build-musl.sh | 20 +- scripts/install-overlay-headers.sh | 9 + scripts/resolve-binary.bundle.mjs | 22 +- scripts/test-install-local-generation.sh | 66 +- tests/abi/process-native-layouts.c | 12 + tools/xtask/src/dump_abi.rs | 335 +- web-libs/kandelo-session/src/kernel-host.ts | 13 +- 184 files changed, 87034 insertions(+), 24391 deletions(-) create mode 100644 apps/browser-demos/test/environment-transaction.spec.ts create mode 100644 crates/kernel/src/blocked_retry.rs create mode 100644 crates/kernel/src/channel_result.rs create mode 100644 crates/kernel/src/transfer.rs create mode 100644 crates/shared/src/channel_scalar.rs create mode 100644 host/src/append-contract.ts create mode 100644 host/src/channel-scalar-contract.ts create mode 100644 host/src/file-offset.ts create mode 100644 host/src/kernel-entry-gate.ts create mode 100644 host/src/native-positioned-write.ts create mode 100644 host/src/vfs/opfs-append.ts create mode 100644 host/test/append-contract.test.ts create mode 100644 host/test/channel-scalar-contract.test.ts create mode 100644 host/test/environment-transaction-fixture.ts create mode 100644 host/test/fixtures/sharedfs-append-worker.ts create mode 100644 host/test/fixtures/wasi-scalar-abi.wat create mode 100644 host/test/host-file-offset.test.ts create mode 100644 host/test/kernel-authority-boundary.test.ts create mode 100644 host/test/kernel-blocking-retry-snapshot.test.ts create mode 100644 host/test/kernel-clone-exit-entry.test.ts create mode 100644 host/test/kernel-detached-effect-protocol.test.ts create mode 100644 host/test/kernel-entry-context-audit.test.ts create mode 100644 host/test/kernel-entry-gate.test.ts create mode 100644 host/test/kernel-exec-entry.test.ts create mode 100644 host/test/kernel-export-failure-audit.test.ts create mode 100644 host/test/kernel-ipc-shmat-entry.test.ts create mode 100644 host/test/kernel-large-transfer-protocol.test.ts create mode 100644 host/test/kernel-network-cleanup-entry.test.ts create mode 100644 host/test/kernel-process-registration-entry.test.ts create mode 100644 host/test/kernel-public-entry-roots.test.ts create mode 100644 host/test/kernel-reservation-export-contract.test.ts create mode 100644 host/test/kernel-scratch-runtime.test.ts create mode 100644 host/test/kernel-shared-memory-inheritance-entry.test.ts create mode 100644 host/test/kernel-teardown-pipe-entry.test.ts create mode 100644 host/test/kernel-telemetry-entry.test.ts create mode 100644 host/test/kernel-worker-entry-root-contract.test.ts create mode 100644 host/test/native-open-create-race.test.ts create mode 100644 host/test/node-kernel-fatal.test.ts create mode 100644 host/test/support/kernel-entry-context-audit.ts create mode 100644 host/test/support/kernel-export-failure-audit.ts create mode 100644 host/test/worker-kernel-import-contract.test.ts create mode 100644 libc/musl-overlay/include/bits/kandelo_channel_scalars.h create mode 100644 libc/musl-overlay/include/bits/kandelo_thread_syscalls.h diff --git a/abi/snapshot.json b/abi/snapshot.json index 2f167529b4..b695caf034 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -44,14 +44,806 @@ "type": "u32" } ], + "request_flags": { + "cancellation_point": 1, + "cancellation_wake_allowed": 2, + "known_mask": 7 + }, "size": 72 }, "channel_request_flags": [ { - "bit": 1, + "bit": 4, "name": "defer_signal_delivery" } ], + "channel_scalar_contract": { + "default_argument_kind": "i32", + "default_result_kind": "i32", + "syscalls": [ + { + "arguments": [ + { + "index": 2, + "kind": "process-size" + } + ], + "musl_name": "read", + "number": 3, + "result": "i32" + }, + { + "arguments": [ + { + "index": 2, + "kind": "process-size" + } + ], + "musl_name": "write", + "number": 4, + "result": "i32" + }, + { + "arguments": [ + { + "index": 1, + "kind": "split-i64-low-u32" + }, + { + "index": 2, + "kind": "split-i64-high-i32" + } + ], + "musl_name": "lseek", + "number": 5, + "result": "i64" + }, + { + "arguments": [ + { + "index": 2, + "kind": "process-size" + } + ], + "musl_name": "readlink", + "number": 19, + "result": "i32" + }, + { + "arguments": [ + { + "index": 1, + "kind": "process-size" + } + ], + "musl_name": "getcwd", + "number": 23, + "result": "i32" + }, + { + "arguments": [ + { + "index": 3, + "kind": "process-size" + } + ], + "musl_name": "readdir", + "number": 26, + "result": "i32" + }, + { + "arguments": [ + { + "index": 2, + "kind": "process-size" + } + ], + "musl_name": "getenv", + "number": 43, + "result": "i32" + }, + { + "arguments": [ + { + "index": 0, + "kind": "process-address" + }, + { + "index": 1, + "kind": "process-size" + }, + { + "index": 5, + "kind": "i64" + } + ], + "musl_name": "mmap", + "number": 46, + "result": "process-address" + }, + { + "arguments": [ + { + "index": 0, + "kind": "process-address" + }, + { + "index": 1, + "kind": "process-size" + } + ], + "musl_name": "munmap", + "number": 47, + "result": "i32" + }, + { + "arguments": [ + { + "index": 0, + "kind": "process-address" + } + ], + "musl_name": "brk", + "number": 48, + "result": "process-address" + }, + { + "arguments": [ + { + "index": 0, + "kind": "process-address" + }, + { + "index": 1, + "kind": "process-size" + } + ], + "musl_name": "mprotect", + "number": 49, + "result": "i32" + }, + { + "arguments": [ + { + "index": 2, + "kind": "u32" + } + ], + "musl_name": "bind", + "number": 51, + "result": "i32" + }, + { + "arguments": [ + { + "index": 2, + "kind": "u32" + } + ], + "musl_name": "connect", + "number": 54, + "result": "i32" + }, + { + "arguments": [ + { + "index": 2, + "kind": "process-size" + } + ], + "musl_name": "send", + "number": 55, + "result": "i32" + }, + { + "arguments": [ + { + "index": 2, + "kind": "process-size" + } + ], + "musl_name": "recv", + "number": 56, + "result": "i32" + }, + { + "arguments": [ + { + "index": 4, + "kind": "u32" + } + ], + "musl_name": "setsockopt", + "number": 59, + "result": "i32" + }, + { + "arguments": [ + { + "index": 1, + "kind": "process-size" + } + ], + "musl_name": "poll", + "number": 60, + "result": "i32" + }, + { + "arguments": [ + { + "index": 2, + "kind": "process-size" + }, + { + "index": 5, + "kind": "u32" + } + ], + "musl_name": "sendto", + "number": 62, + "result": "i32" + }, + { + "arguments": [ + { + "index": 2, + "kind": "process-size" + } + ], + "musl_name": "recvfrom", + "number": 63, + "result": "i32" + }, + { + "arguments": [ + { + "index": 2, + "kind": "process-size" + }, + { + "index": 3, + "kind": "i64" + } + ], + "musl_name": "pread", + "number": 64, + "result": "i32" + }, + { + "arguments": [ + { + "index": 2, + "kind": "process-size" + }, + { + "index": 3, + "kind": "i64" + } + ], + "musl_name": "pwrite", + "number": 65, + "result": "i32" + }, + { + "arguments": [], + "musl_name": "time", + "number": 66, + "result": "i64" + }, + { + "arguments": [ + { + "index": 1, + "kind": "exact-u32" + } + ], + "musl_name": "signal", + "number": 73, + "result": "i32" + }, + { + "arguments": [ + { + "index": 1, + "kind": "i64" + } + ], + "musl_name": "ftruncate", + "number": 79, + "result": "i32" + }, + { + "arguments": [ + { + "index": 1, + "kind": "i64" + } + ], + "musl_name": "truncate", + "number": 85, + "result": "i32" + }, + { + "arguments": [ + { + "index": 3, + "kind": "process-size" + } + ], + "musl_name": "readlinkat", + "number": 102, + "result": "i32" + }, + { + "arguments": [ + { + "index": 2, + "kind": "process-size" + } + ], + "musl_name": "realpath", + "number": 109, + "result": "i32" + }, + { + "arguments": [ + { + "index": 1, + "kind": "split-i64-high-i32" + }, + { + "index": 2, + "kind": "split-i64-low-u32" + } + ], + "musl_name": "_llseek", + "number": 119, + "result": "i32" + }, + { + "arguments": [ + { + "index": 1, + "kind": "process-size" + } + ], + "musl_name": "getrandom", + "number": 120, + "result": "i32" + }, + { + "arguments": [ + { + "index": 2, + "kind": "process-size" + } + ], + "musl_name": "getdents64", + "number": 122, + "result": "i32" + }, + { + "arguments": [ + { + "index": 0, + "kind": "process-address" + }, + { + "index": 1, + "kind": "process-size" + }, + { + "index": 2, + "kind": "process-size" + } + ], + "musl_name": "mremap", + "number": 126, + "result": "process-address" + }, + { + "arguments": [ + { + "index": 0, + "kind": "process-address" + }, + { + "index": 1, + "kind": "process-size" + } + ], + "musl_name": "madvise", + "number": 128, + "result": "i32" + }, + { + "arguments": [ + { + "index": 0, + "kind": "process-size" + } + ], + "musl_name": "setgroups", + "number": 136, + "result": "i32" + }, + { + "arguments": [ + { + "index": 0, + "kind": "process-address" + } + ], + "musl_name": "futex", + "number": 200, + "result": "i32" + }, + { + "arguments": [ + { + "index": 0, + "kind": "process-address" + } + ], + "musl_name": "set_tid_address", + "number": 203, + "result": "i32" + }, + { + "arguments": [ + { + "index": 1, + "kind": "u32" + } + ], + "musl_name": "sched_setaffinity", + "number": 237, + "result": "i32" + }, + { + "arguments": [ + { + "index": 1, + "kind": "u32" + } + ], + "musl_name": "sched_getaffinity", + "number": 238, + "result": "i32" + }, + { + "arguments": [ + { + "index": 5, + "kind": "process-size" + } + ], + "musl_name": "epoll_pwait", + "number": 241, + "result": "i32" + }, + { + "arguments": [ + { + "index": 2, + "kind": "process-size" + } + ], + "musl_name": "signalfd4", + "number": 246, + "result": "i32" + }, + { + "arguments": [ + { + "index": 1, + "kind": "process-size" + }, + { + "index": 4, + "kind": "process-size" + } + ], + "musl_name": "ppoll", + "number": 251, + "result": "i32" + }, + { + "arguments": [ + { + "index": 0, + "kind": "process-address" + }, + { + "index": 1, + "kind": "process-size" + } + ], + "musl_name": "set_robust_list", + "number": 261, + "result": "i32" + }, + { + "arguments": [ + { + "index": 1, + "kind": "process-address" + }, + { + "index": 2, + "kind": "process-address" + } + ], + "musl_name": "get_robust_list", + "number": 262, + "result": "i32" + }, + { + "arguments": [ + { + "index": 0, + "kind": "process-address" + }, + { + "index": 1, + "kind": "process-size" + } + ], + "musl_name": "msync", + "number": 278, + "result": "i32" + }, + { + "arguments": [ + { + "index": 0, + "kind": "process-address" + }, + { + "index": 1, + "kind": "process-size" + } + ], + "musl_name": "mlock", + "number": 279, + "result": "i32" + }, + { + "arguments": [ + { + "index": 0, + "kind": "process-address" + }, + { + "index": 1, + "kind": "process-size" + } + ], + "musl_name": "mlock2", + "number": 280, + "result": "i32" + }, + { + "arguments": [ + { + "index": 0, + "kind": "process-address" + }, + { + "index": 1, + "kind": "process-size" + } + ], + "musl_name": "munlock", + "number": 281, + "result": "i32" + }, + { + "arguments": [ + { + "index": 4, + "kind": "process-size" + } + ], + "musl_name": "copy_file_range", + "number": 290, + "result": "i32" + }, + { + "arguments": [ + { + "index": 4, + "kind": "process-size" + } + ], + "musl_name": "splice", + "number": 291, + "result": "i32" + }, + { + "arguments": [ + { + "index": 1, + "kind": "i64" + }, + { + "index": 2, + "kind": "process-size" + } + ], + "musl_name": "readahead", + "number": 293, + "result": "i32" + }, + { + "arguments": [ + { + "index": 3, + "kind": "process-size" + } + ], + "musl_name": "sendfile", + "number": 294, + "result": "i32" + }, + { + "arguments": [ + { + "index": 3, + "kind": "split-i64-low-u32" + }, + { + "index": 4, + "kind": "split-i64-high-i32" + } + ], + "musl_name": "preadv", + "number": 295, + "result": "i32" + }, + { + "arguments": [ + { + "index": 3, + "kind": "split-i64-low-u32" + }, + { + "index": 4, + "kind": "split-i64-high-i32" + } + ], + "musl_name": "pwritev", + "number": 296, + "result": "i32" + }, + { + "arguments": [ + { + "index": 3, + "kind": "split-i64-low-u32" + }, + { + "index": 4, + "kind": "split-i64-high-i32" + } + ], + "musl_name": "preadv2", + "number": 297, + "result": "i32" + }, + { + "arguments": [ + { + "index": 3, + "kind": "split-i64-low-u32" + }, + { + "index": 4, + "kind": "split-i64-high-i32" + } + ], + "musl_name": "pwritev2", + "number": 298, + "result": "i32" + }, + { + "arguments": [ + { + "index": 2, + "kind": "i64" + }, + { + "index": 3, + "kind": "i64" + } + ], + "musl_name": "fallocate", + "number": 308, + "result": "i32" + }, + { + "arguments": [ + { + "index": 2, + "kind": "process-size" + } + ], + "musl_name": "mq_timedsend", + "number": 333, + "result": "i32" + }, + { + "arguments": [ + { + "index": 2, + "kind": "process-size" + } + ], + "musl_name": "mq_timedreceive", + "number": 334, + "result": "i32" + }, + { + "arguments": [ + { + "index": 2, + "kind": "process-size" + }, + { + "index": 3, + "kind": "i64" + } + ], + "musl_name": "msgrcv", + "number": 338, + "result": "i32" + }, + { + "arguments": [ + { + "index": 2, + "kind": "process-size" + } + ], + "musl_name": "msgsnd", + "number": 339, + "result": "i32" + }, + { + "arguments": [ + { + "index": 2, + "kind": "process-size" + } + ], + "musl_name": "semop", + "number": 342, + "result": "i32" + }, + { + "arguments": [ + { + "index": 1, + "kind": "process-size" + } + ], + "musl_name": "shmget", + "number": 344, + "result": "i32" + }, + { + "arguments": [ + { + "index": 2, + "kind": "process-size" + } + ], + "musl_name": "signalfd", + "number": 377, + "result": "i32" + } + ] + }, "channel_signal_area": { "area_size": 56, "base": 65552, @@ -252,7 +1044,10 @@ "required_kernel_exports": [ "__abi_version", "kernel_alloc_scratch", + "kernel_blocking_retry_release", + "kernel_blocking_retry_token", "kernel_clear_process_metadata", + "kernel_commit_process_exit", "kernel_create_process", "kernel_create_process_with_stdio", "kernel_dequeue_signal", @@ -262,6 +1057,7 @@ "kernel_get_parent_pid", "kernel_get_process_exit_signal", "kernel_get_process_state", + "kernel_get_socket_timeout_ms", "kernel_handle_channel", "kernel_has_sa_nocldstop", "kernel_host_adapter_manifest_len", @@ -270,11 +1066,13 @@ "kernel_ipc_shmat_for_task", "kernel_ipc_shmdt_for_process", "kernel_ipc_shmdt_for_task", + "kernel_is_fd_nonblock", "kernel_mark_process_signaled", + "kernel_mq_descriptor_msgsize", "kernel_msqid_ds_bytes", + "kernel_pick_signal_target_tid", "kernel_pipe_has_readers", "kernel_posix_timer_fire", - "kernel_prepare_write_operation", "kernel_push_process_metadata_entry", "kernel_reap_exited_child", "kernel_remove_process", @@ -291,6 +1089,13 @@ "kernel_spawn_scratch_pointer", "kernel_spawn_scratch_retained_capacity", "kernel_thread_exit", + "kernel_thread_has_deliverable", + "kernel_transfer_channel_execute", + "kernel_transfer_io_execute", + "kernel_transfer_scratch_begin", + "kernel_transfer_scratch_cancel", + "kernel_transfer_scratch_capacity", + "kernel_transfer_scratch_pointer", "kernel_validate_task", "kernel_wait_child_poll" ], @@ -793,6 +1598,16 @@ "name": "kernel_bind", "signature": "(i32,i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_blocking_retry_release", + "signature": "(i32,i32,i64) -> (i32)" + }, + { + "kind": "func", + "name": "kernel_blocking_retry_token", + "signature": "(i32,i32,i32) -> (i64)" + }, { "kind": "func", "name": "kernel_brk", @@ -863,6 +1678,11 @@ "name": "kernel_closedir", "signature": "(i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_commit_process_exit", + "signature": "(i32) -> (i32)" + }, { "kind": "func", "name": "kernel_connect", @@ -1321,7 +2141,7 @@ { "kind": "func", "name": "kernel_handle_channel", - "signature": "(i32,i32,i32) -> (i32)" + "signature": "(i32,i32,i32,i64) -> (i32)" }, { "kind": "func", @@ -1498,6 +2318,11 @@ "name": "kernel_mprotect", "signature": "(i32,i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_mq_descriptor_msgsize", + "signature": "(i32,i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_mq_drain_notification", @@ -1628,21 +2453,6 @@ "name": "kernel_prctl", "signature": "(i32,i32,i32,i32) -> (i32)" }, - { - "kind": "func", - "name": "kernel_pread", - "signature": "(i32,i32,i32,i64) -> (i32)" - }, - { - "kind": "func", - "name": "kernel_preadv", - "signature": "(i32,i32,i32,i32,i32) -> (i32)" - }, - { - "kind": "func", - "name": "kernel_prepare_write_operation", - "signature": "(i32,i32,i32,i64,i32,i32) -> (i64)" - }, { "kind": "func", "name": "kernel_pselect6", @@ -1678,26 +2488,11 @@ "name": "kernel_push_process_metadata_entry", "signature": "(i32,i32,i32,i32) -> (i32)" }, - { - "kind": "func", - "name": "kernel_pwrite", - "signature": "(i32,i32,i32,i64) -> (i32)" - }, - { - "kind": "func", - "name": "kernel_pwritev", - "signature": "(i32,i32,i32,i32,i32) -> (i32)" - }, { "kind": "func", "name": "kernel_raise", "signature": "(i32) -> (i32)" }, - { - "kind": "func", - "name": "kernel_read", - "signature": "(i32,i32,i32) -> (i32)" - }, { "kind": "func", "name": "kernel_read_proc_maps", @@ -1718,11 +2513,6 @@ "name": "kernel_readlinkat", "signature": "(i32,i32,i32,i32,i32) -> (i32)" }, - { - "kind": "func", - "name": "kernel_readv", - "signature": "(i32,i32,i32) -> (i32)" - }, { "kind": "func", "name": "kernel_realpath", @@ -1751,7 +2541,7 @@ { "kind": "func", "name": "kernel_recvmsg", - "signature": "(i32,i32,i32) -> (i32)" + "signature": "(i32,i32,i32,i64) -> (i32)" }, { "kind": "func", @@ -1831,7 +2621,7 @@ { "kind": "func", "name": "kernel_sendmsg", - "signature": "(i32,i32,i32) -> (i32)" + "signature": "(i32,i32,i32,i64) -> (i32)" }, { "kind": "func", @@ -2158,6 +2948,36 @@ "name": "kernel_tkill", "signature": "(i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_transfer_channel_execute", + "signature": "(i32,i32,i64,i64) -> (i32)" + }, + { + "kind": "func", + "name": "kernel_transfer_io_execute", + "signature": "(i32,i32,i64,i32,i32,i32,i64,i64) -> (i32)" + }, + { + "kind": "func", + "name": "kernel_transfer_scratch_begin", + "signature": "(i32) -> (i64)" + }, + { + "kind": "func", + "name": "kernel_transfer_scratch_cancel", + "signature": "(i64) -> (i32)" + }, + { + "kind": "func", + "name": "kernel_transfer_scratch_capacity", + "signature": "(i64) -> (i32)" + }, + { + "kind": "func", + "name": "kernel_transfer_scratch_pointer", + "signature": "(i64) -> (i32)" + }, { "kind": "func", "name": "kernel_truncate", @@ -2218,16 +3038,6 @@ "name": "kernel_wait_child_poll", "signature": "(i32,i32,i32,i32,i32,i32,i32) -> (i32)" }, - { - "kind": "func", - "name": "kernel_write", - "signature": "(i32,i32,i32) -> (i32)" - }, - { - "kind": "func", - "name": "kernel_writev", - "signature": "(i32,i32,i32) -> (i32)" - }, { "kind": "memory", "name": "memory" @@ -3781,7 +4591,11 @@ "fd_set_bytes": 128, "fd_setsize": 1024, "iov_max": 1024, - "path_max_bytes": 4096 + "max_reportable_transfer_bytes": 2147483647, + "max_transfer_allocation_bytes": 4294967295, + "ngroups_max": 32, + "path_max_bytes": 4096, + "sysv_msg_max_bytes": 8192 }, "process_expected_globals": [ "__channel_base", @@ -5551,6 +6365,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -5561,6 +6377,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } }, @@ -5569,6 +6387,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -5579,6 +6399,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } }, @@ -5587,6 +6409,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -5597,6 +6421,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } }, @@ -5627,6 +6453,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } }, @@ -5646,6 +6474,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } }, @@ -5676,6 +6506,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } }, @@ -5757,6 +6589,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } }, @@ -5820,6 +6654,8 @@ "direction": "in", "nullable": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } }, @@ -5839,6 +6675,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } }, @@ -5859,6 +6697,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -5971,6 +6811,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -5981,6 +6823,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 256, + "tooLongErrno": 36, "type": "cstring" } }, @@ -6000,6 +6844,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -6010,6 +6856,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } }, @@ -6018,6 +6866,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -6028,6 +6878,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } }, @@ -6036,6 +6888,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -6046,6 +6900,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } }, @@ -6054,6 +6910,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -6064,6 +6922,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } }, @@ -6083,6 +6943,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -6168,6 +7030,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -6178,6 +7042,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -6188,6 +7054,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -6298,6 +7166,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -6350,6 +7220,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -6392,6 +7264,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 250, + "tooLongErrno": 22, "type": "cstring" } } @@ -6422,6 +7296,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } }, @@ -6453,6 +7329,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -6463,6 +7341,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -6545,6 +7425,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -6566,6 +7448,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } }, @@ -6574,6 +7458,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -6656,6 +7542,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 256, + "tooLongErrno": 36, "type": "cstring" } }, @@ -6676,6 +7564,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 256, + "tooLongErrno": 36, "type": "cstring" } } @@ -6832,6 +7722,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -6842,6 +7734,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -6905,6 +7799,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 65537, + "tooLongErrno": 7, "type": "cstring" } }, @@ -6924,6 +7820,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 65537, + "tooLongErrno": 7, "type": "cstring" } }, @@ -6932,6 +7830,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 65537, + "tooLongErrno": 7, "type": "cstring" } } @@ -6942,6 +7842,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 65537, + "tooLongErrno": 7, "type": "cstring" } } @@ -7152,6 +8054,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -7228,6 +8132,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -7249,6 +8155,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } }, @@ -7268,6 +8176,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -7278,6 +8188,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -7288,6 +8200,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } }, @@ -7296,6 +8210,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -7306,6 +8222,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -7316,6 +8234,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -7326,6 +8246,8 @@ "direction": "in", "required": true, "size": { + "maxBytes": 4096, + "tooLongErrno": 36, "type": "cstring" } } @@ -7980,6 +8902,10 @@ "name": "SchedRrGetInterval", "number": 236 }, + { + "name": "SchedSetaffinity", + "number": 237 + }, { "name": "SchedGetaffinity", "number": 238 @@ -8056,6 +8982,18 @@ "name": "Msync", "number": 278 }, + { + "name": "Mlock", + "number": 279 + }, + { + "name": "Mlock2", + "number": 280 + }, + { + "name": "Munlock", + "number": 281 + }, { "name": "Waitid", "number": 288 @@ -8068,6 +9006,10 @@ "name": "Splice", "number": 291 }, + { + "name": "Readahead", + "number": 293 + }, { "name": "Sendfile", "number": 294 diff --git a/apps/browser-demos/test/environment-transaction.spec.ts b/apps/browser-demos/test/environment-transaction.spec.ts new file mode 100644 index 0000000000..6e99e10d21 --- /dev/null +++ b/apps/browser-demos/test/environment-transaction.spec.ts @@ -0,0 +1,80 @@ +import { expect, test } from "@playwright/test"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const programs = [ + { + arch: "wasm32", + path: resolve(__dirname, "../../../examples/putenv_test.wasm"), + }, + { + arch: "wasm64", + path: resolve(__dirname, "../../../examples/putenv_test.wasm64.wasm"), + }, +] as const; + +for (const program of programs) { + test(`environment metadata exact-capacity, capacity+1, and long-name cases stay coherent for ${program.arch} in Chromium`, async ({ + page, + baseURL, + browserName, + }) => { + test.skip( + browserName !== "chromium", + "the aggregate browser gate uses Chromium", + ); + expect(baseURL).toBeTruthy(); + + const runtimeErrors: string[] = []; + page.on("pageerror", (error) => { + runtimeErrors.push(`pageerror: ${error.message}`); + }); + page.on("console", (message) => { + if (message.type() === "error") { + runtimeErrors.push(`console: ${message.text()}`); + } + }); + page.on("requestfailed", (request) => { + runtimeErrors.push( + `requestfailed: ${request.url()} ${request.failure()?.errorText ?? "failed"}`, + ); + }); + + await page.goto(new URL("/pages/test-runner/?minimal=1", baseURL).href); + await page.waitForFunction( + () => (window as any).__testRunnerReady === true, + ); + + const programUrl = new URL(`/@fs/${program.path}`, baseURL).href; + const result = await page.evaluate( + async ({ programUrl }) => { + const response = await fetch(programUrl); + if (!response.ok) { + throw new Error( + `program fetch failed: ${response.status} ${response.url}`, + ); + } + const programBytes = await response.arrayBuffer(); + return (window as any).__runTest( + programBytes, + ["putenv-test"], + 30_000, + { + env: ["HOME=/home/test", "PATH=/usr/bin"], + }, + ); + }, + { programUrl }, + ); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("SETENV_BOUNDARY_PASS"); + expect(result.stdout).toContain("PUTENV_LONG_BOUNDARY_PASS"); + expect(result.stdout).toContain("ENV_COHERENCE_PASS"); + expect(result.stdout).toContain("DONE"); + expect(result.stderr).toBe(""); + expect(result.hostDiagnostics).toEqual([]); + expect(runtimeErrors).toEqual([]); + }); +} diff --git a/apps/browser-demos/test/epoll-repro.ts b/apps/browser-demos/test/epoll-repro.ts index 7ad966d8c4..f91b449725 100644 --- a/apps/browser-demos/test/epoll-repro.ts +++ b/apps/browser-demos/test/epoll-repro.ts @@ -2,7 +2,10 @@ * Reproduce the epoll_pwait crash using CentralizedKernelWorker in Node.js. * Run: npx tsx test/epoll-repro.ts */ -import { CAPTURED_STDIO, CentralizedKernelWorker } from "../../../host/src/kernel-worker.ts"; +import { + CAPTURED_STDIO, + createCentralizedKernelWorkerTestDouble, +} from "../../../host/src/kernel-worker.ts"; import { resolveBinary } from "../../../host/src/binary-resolver.ts"; import { CH_ARGS, @@ -10,24 +13,21 @@ import { CH_DATA, CH_ERRNO, CH_RETURN, + CH_STATUS, CH_SYSCALL, CH_TOTAL_SIZE, + CHANNEL_STATUS_COMPLETE, + CHANNEL_STATUS_PENDING, STRUCT_SIZE_WASM_EPOLL_EVENT, WASM_EPOLL_EVENT_DATA_OFFSET, } from "../../../host/src/generated/abi.ts"; -import type { KernelScratchRegion } from "../../../host/src/kernel-scratch.ts"; import { VirtualPlatformIO, MemoryFileSystem, DeviceFileSystem } from "../../../host/src/vfs/index.ts"; +import { BrowserTimeProvider } from "../../../host/src/vfs/time.ts"; import { readFileSync } from "fs"; const MAX_PAGES = 16384; const PAGE_SIZE = 65536; -interface KernelWorkerInternals { - scratchRegion: KernelScratchRegion; - kernelInstance: WebAssembly.Instance; - kernelMemory: WebAssembly.Memory; -} - interface ScratchPointerArgument { readonly offset: number; readonly length: number; @@ -48,23 +48,25 @@ async function main() { const io = new VirtualPlatformIO([ { mountPoint: "/dev", backend: devfs }, { mountPoint: "/", backend: memfs }, - ]); + ], new BrowserTimeProvider()); // Create dirs for (const d of ["/tmp", "/etc", "/var", "/proc"]) { try { memfs.mkdir(d, 0o755); } catch {} } - const kw = new CentralizedKernelWorker({ maxWorkers: 4, dataBufferSize: PAGE_SIZE, useSharedMemory: true }, io); + const kw = createCentralizedKernelWorkerTestDouble({ + config: { + maxWorkers: 4, + dataBufferSize: PAGE_SIZE, + useSharedMemory: true, + }, + io, + }); await kw.init(kernelWasm); - const internals = kw as unknown as KernelWorkerInternals; - const ki = internals.kernelInstance; - const km = internals.kernelMemory; - const scratchRegion = internals.scratchRegion; - console.log( - `scratchCapacity=${scratchRegion.capacity}, memPages=${km.grow(0)}`, + `memPages=${kw.getKernelMemoryPages()}`, ); // Register a fake process @@ -75,89 +77,83 @@ async function main() { const pid = kw.createProcess(CAPTURED_STDIO); kw.registerProcess(pid, procMem, [channelOff]); - const getSP = ki.exports.kernel_get_stack_pointer as () => number; - console.log(`SP initial: ${getSP()}`); - - const setCurrentTid = ki.exports.kernel_set_current_tid as ( - pid: number, - tid: number, - ) => number; - const bindCurrentTid = (): void => { - const bindResult = setCurrentTid(pid, pid); - if (bindResult !== 0) { - throw new Error( - `kernel_set_current_tid(${pid}, ${pid}) failed: ${bindResult}`, - ); - } - }; - const issueChannel = ( + const issueChannel = async ( syscall: number, args: readonly ChannelArgument[], event?: EpollEventPreparation, - ) => - scratchRegion.withLease((scratch) => { - // WHY: preparation is inert data rather than a callback receiving the - // lease. No promise or helper can retain scratch authority after this - // synchronous callback returns. - const kernelView = scratch.dataView(0, CH_TOTAL_SIZE); - if (event !== undefined) { - kernelView.setUint32(CH_DATA, event.events, true); - kernelView.setUint32(CH_DATA + 4, 0, true); - kernelView.setBigUint64( - CH_DATA + WASM_EPOLL_EVENT_DATA_OFFSET, - event.data, + ) => { + // Exercise the same registered process mailbox used by real guests. The + // worker, not this diagnostic, owns kernel scratch and entry serialization. + const channel = new DataView(procMem.buffer, channelOff, CH_TOTAL_SIZE); + if (event !== undefined) { + channel.setUint32(CH_DATA, event.events, true); + channel.setUint32(CH_DATA + 4, 0, true); + channel.setBigUint64( + CH_DATA + WASM_EPOLL_EVENT_DATA_OFFSET, + event.data, + true, + ); + } + channel.setUint32(CH_SYSCALL, syscall, true); + for (let index = 0; index < 6; index++) { + const argument = args[index] ?? 0n; + if (typeof argument === "object") { + if ( + !Number.isSafeInteger(argument.offset) + || !Number.isSafeInteger(argument.length) + || argument.offset < 0 + || argument.length < 0 + || argument.offset + argument.length > CH_TOTAL_SIZE + ) { + throw new RangeError("diagnostic channel pointer is out of range"); + } + channel.setBigUint64( + CH_ARGS + index * CH_ARG_SIZE, + BigInt(channelOff + argument.offset), + true, + ); + } else { + channel.setBigInt64( + CH_ARGS + index * CH_ARG_SIZE, + argument, true, ); } - kernelView.setUint32(CH_SYSCALL, syscall, true); - for (let index = 0; index < 6; index++) { - const argument = args[index] ?? 0n; - if (typeof argument === "object") { - // Keep the kernel pointer opaque and encode it losslessly into the - // channel's fixed u64 syscall slot. - scratch.writeAddress( - CH_ARGS + index * CH_ARG_SIZE, - argument.offset, - argument.length, - "u64-le", - ); - } else { - kernelView.setBigInt64( - CH_ARGS + index * CH_ARG_SIZE, - argument, - true, - ); - } + } + const words = new Int32Array(procMem.buffer); + const statusIndex = (channelOff + CH_STATUS) / Int32Array.BYTES_PER_ELEMENT; + Atomics.store(words, statusIndex, CHANNEL_STATUS_PENDING); + Atomics.notify(words, statusIndex, 1); + const deadline = Date.now() + 10_000; + while (Atomics.load(words, statusIndex) !== CHANNEL_STATUS_COMPLETE) { + if (Date.now() >= deadline) { + throw new Error(`syscall ${syscall} did not complete within 10 seconds`); } - bindCurrentTid(); - scratch.invokeKernelExport("kernel_handle_channel", [ - scratch.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - pid, - ]); - return { - result: kernelView.getBigInt64(CH_RETURN, true), - errno: kernelView.getUint32(CH_ERRNO, true), - data0: kernelView.getInt32(CH_DATA, true), - data1: kernelView.getInt32(CH_DATA + 4, true), - }; - }); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + return { + result: channel.getBigInt64(CH_RETURN, true), + errno: channel.getUint32(CH_ERRNO, true), + data0: channel.getInt32(CH_DATA, true), + data1: channel.getInt32(CH_DATA + 4, true), + }; + }; // 1. epoll_create1(0) - const epfd = issueChannel(239, [0n]).result; - console.log(`epoll_create1(0) = ${epfd}, SP=${getSP()}`); + const epfd = (await issueChannel(239, [0n])).result; + console.log(`epoll_create1(0) = ${epfd}`); // 2. pipe2() - const pipe = issueChannel(165, [ + const pipe = await issueChannel(165, [ { offset: CH_DATA, length: 8 }, 0n, ]); console.log( - `pipe2() = ${pipe.result}, fds=[${pipe.data0}, ${pipe.data1}], SP=${getSP()}`, + `pipe2() = ${pipe.result}, fds=[${pipe.data0}, ${pipe.data1}]`, ); // 3. epoll_ctl(epfd, EPOLL_CTL_ADD=1, pipe.data0, event) - const ctlResult = issueChannel( + const ctlResult = (await issueChannel( 240, [ epfd, @@ -172,16 +168,16 @@ async function main() { events: 1, // EPOLLIN data: BigInt(pipe.data0), }, - ).result; - console.log(`epoll_ctl = ${ctlResult}, SP=${getSP()}`); + )).result; + console.log(`epoll_ctl = ${ctlResult}`); // 4. timeout=0 for an immediate result, then use PHP-FPM's 1s timeout. for (const timeout of [0, 1000]) { console.log( - `\nCalling epoll_pwait(timeout=${timeout})... SP before=${getSP()}`, + `\nCalling epoll_pwait(timeout=${timeout})...`, ); try { - const result = issueChannel(241, [ + const result = await issueChannel(241, [ epfd, { offset: CH_DATA, @@ -193,11 +189,13 @@ async function main() { 8n, ]); console.log( - `epoll_pwait(${timeout}) = ${result.result}, errno=${result.errno}, SP=${getSP()}`, + `epoll_pwait(${timeout}) = ${result.result}, errno=${result.errno}`, ); } catch (error) { console.error(`CRASHED: ${error}`); - console.log(`SP after crash: ${getSP()}, memPages=${km.grow(0)}`); + console.log( + `memPages=${kw.getKernelMemoryPages()}`, + ); } } } diff --git a/apps/browser-demos/test/fixtures/opfs-advisory-lock-client-worker.ts b/apps/browser-demos/test/fixtures/opfs-advisory-lock-client-worker.ts index 69371425d5..fb05067929 100644 --- a/apps/browser-demos/test/fixtures/opfs-advisory-lock-client-worker.ts +++ b/apps/browser-demos/test/fixtures/opfs-advisory-lock-client-worker.ts @@ -1,7 +1,12 @@ -import { CAPTURED_STDIO, CentralizedKernelWorker } from "../../../../host/src/kernel-worker"; +import { + CAPTURED_STDIO, + CentralizedKernelWorker, + createCentralizedKernelWorkerTestDouble, +} from "../../../../host/src/kernel-worker"; import { ABI_SYSCALLS, CHANNEL_STATUS_COMPLETE, + CHANNEL_STATUS_PENDING, CH_ARGS, CH_ARG_SIZE, CH_DATA, @@ -16,7 +21,6 @@ import { createProcessMemory, type ProcessMemoryLayout, } from "../../../../host/src/process-memory"; -import type { KernelScratchRegion } from "../../../../host/src/kernel-scratch"; import { OpfsFileSystem } from "../../../../host/src/vfs/opfs"; import { BrowserTimeProvider } from "../../../../host/src/vfs/time"; import { VirtualPlatformIO } from "../../../../host/src/vfs/vfs"; @@ -70,21 +74,6 @@ function scratchArgument(offset: number, length: number): ScratchArgument { return { kind: "scratch", offset, length }; } -interface ChannelInfoForTest { - pid: number; - memory: WebAssembly.Memory; - channelOffset: number; -} - -interface KernelWorkerInternals { - scratchRegion: KernelScratchRegion; - kernelInstance: WebAssembly.Instance; - processes: Map; - pendingAdvisoryLockRetries: Map; - handleFcntlLock(channel: ChannelInfoForTest, args: number[]): void; - drainAndProcessWakeupEvents(): void; -} - interface FixtureRequest { buffer: SharedArrayBuffer; kernelWasm: ArrayBuffer; @@ -92,10 +81,6 @@ interface FixtureRequest { capacityPath: string; } -function internals(worker: CentralizedKernelWorker): KernelWorkerInternals { - return worker as unknown as KernelWorkerInternals; -} - function makeProcessMemory(): Omit { const layout = computeProcessMemoryLayout({ ptrWidth: 4, @@ -122,85 +107,124 @@ function register( return { ...process, pid }; } -function issue( - worker: CentralizedKernelWorker, - pid: number, +function prepareIssue( + process: RegisteredProcess, syscall: number, args: readonly SyscallArgument[], preparation?: ScratchPreparation, -): SyscallResult { - const state = internals(worker); - return state.scratchRegion.withLease((scratch) => { - // WHY: preparation is data, not a callback receiving the lease. Keeping - // every scratch operation in this synchronous callback prevents a helper - // from retaining the lease after withLease revokes it. - if (preparation?.kind === "copy") { - scratch.copyFrom( - preparation.source, - preparation.destinationOffset, - ); - } else if (preparation?.kind === "flock") { - scratch.fill(0, CH_DATA, 32); - const flock = scratch.dataView(CH_DATA, 32); - flock.setInt16(0, preparation.type, true); - flock.setInt16(2, 0, true); // SEEK_SET - flock.setBigInt64(8, preparation.start, true); - flock.setBigInt64(16, preparation.len, true); - } - const channel = scratch.dataView(0, CH_TOTAL_SIZE); - channel.setUint32(CH_SYSCALL, syscall, true); - channel.setUint32(CH_ERRNO, 0, true); - channel.setBigInt64(CH_RETURN, 0n, true); - for (let index = 0; index < 6; index++) { - const argument = args[index] ?? 0; - if (typeof argument === "object") { - // WHY: the descriptor carries only an offset and capacity. The - // lease writes its checked address without exposing a primitive that - // could outlive revocation. - scratch.writeAddress( - CH_ARGS + index * CH_ARG_SIZE, - argument.offset, - argument.length, - "u64-le", - ); - } else { - channel.setBigInt64( - CH_ARGS + index * CH_ARG_SIZE, - BigInt(argument), - true, - ); +): void { + const channel = new DataView( + process.memory.buffer, + process.channelOffset, + CH_TOTAL_SIZE, + ); + if (preparation?.kind === "copy") { + new Uint8Array( + process.memory.buffer, + process.channelOffset + preparation.destinationOffset, + preparation.source.byteLength, + ).set(preparation.source); + } else if (preparation?.kind === "flock") { + new Uint8Array( + process.memory.buffer, + process.channelOffset + CH_DATA, + 32, + ).fill(0); + const flock = new DataView( + process.memory.buffer, + process.channelOffset + CH_DATA, + 32, + ); + flock.setInt16(0, preparation.type, true); + flock.setInt16(2, 0, true); // SEEK_SET + flock.setBigInt64(8, preparation.start, true); + flock.setBigInt64(16, preparation.len, true); + } + channel.setUint32(CH_SYSCALL, syscall, true); + channel.setUint32(CH_ERRNO, 0, true); + channel.setBigInt64(CH_RETURN, 0n, true); + for (let index = 0; index < 6; index++) { + const argument = args[index] ?? 0; + if (typeof argument === "object") { + if ( + !Number.isSafeInteger(argument.offset) + || !Number.isSafeInteger(argument.length) + || argument.offset < 0 + || argument.length < 0 + || argument.offset + argument.length > CH_TOTAL_SIZE + ) { + throw new RangeError("fixture channel pointer is out of range"); } + channel.setBigUint64( + CH_ARGS + index * CH_ARG_SIZE, + BigInt(process.channelOffset + argument.offset), + true, + ); + } else { + channel.setBigInt64( + CH_ARGS + index * CH_ARG_SIZE, + BigInt(argument), + true, + ); } + } +} + +function submitPreparedIssue(process: RegisteredProcess): void { + const words = new Int32Array(process.memory.buffer); + const statusIndex = + (process.channelOffset + CH_STATUS) / Int32Array.BYTES_PER_ELEMENT; + Atomics.store(words, statusIndex, CHANNEL_STATUS_PENDING); + Atomics.notify(words, statusIndex, 1); +} - const setCurrentTid = state.kernelInstance.exports.kernel_set_current_tid as ( - pid: number, - tid: number, - ) => number; - const bindResult = setCurrentTid(pid, pid); - if (bindResult !== 0) { - throw new Error(`kernel_set_current_tid(${pid}, ${pid}) failed: ${bindResult}`); +async function waitForIssueCompletion( + process: RegisteredProcess, + timeoutMs = 10_000, +): Promise { + const words = new Int32Array(process.memory.buffer); + const statusIndex = + (process.channelOffset + CH_STATUS) / Int32Array.BYTES_PER_ELEMENT; + const deadline = Date.now() + timeoutMs; + for (;;) { + if (Atomics.load(words, statusIndex) === CHANNEL_STATUS_COMPLETE) { + // The worker schedules its next wait after publishing completion. Yield + // once more so a following fixture syscall cannot outrun that relisten. + await Promise.resolve(); + return processChannelResult(process); } - scratch.invokeKernelExport("kernel_handle_channel", [ - scratch.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - pid, - ]); - return { - value: Number(channel.getBigInt64(CH_RETURN, true)), - errno: channel.getUint32(CH_ERRNO, true), - }; - }); + if (Date.now() >= deadline) { + throw new Error( + `kernel syscall for pid ${process.pid} did not complete within ${timeoutMs}ms`, + ); + } + await Promise.resolve(); + if (Atomics.load(words, statusIndex) !== CHANNEL_STATUS_COMPLETE) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } } -function openFile( - worker: CentralizedKernelWorker, - pid: number, +async function issue( + process: RegisteredProcess, + syscall: number, + args: readonly SyscallArgument[], + preparation?: ScratchPreparation, +): Promise { + // Exercise the registered guest mailbox. Kernel scratch, its lease, and all + // export authority remain encapsulated by CentralizedKernelWorker. + prepareIssue(process, syscall, args, preparation); + submitPreparedIssue(process); + return waitForIssueCompletion(process); +} + +async function openFile( + process: RegisteredProcess, path: string, -): number { +): Promise { const pathBytes = new TextEncoder().encode(`${path}\0`); - const result = issue( - worker, - pid, + const result = await issue( + process, ABI_SYSCALLS.Open, [ scratchArgument(CH_DATA, pathBytes.byteLength), @@ -215,21 +239,20 @@ function openFile( ); if (result.errno !== 0 || result.value < 3) { throw new Error( - `kernel open failed for pid ${pid}: value=${result.value} errno=${result.errno}`, + `kernel open failed for pid ${process.pid}: value=${result.value} errno=${result.errno}`, ); } return result.value; } -function closeFile( - worker: CentralizedKernelWorker, - pid: number, +async function closeFile( + process: RegisteredProcess, fd: number, -): void { - const result = issue(worker, pid, ABI_SYSCALLS.Close, [fd]); +): Promise { + const result = await issue(process, ABI_SYSCALLS.Close, [fd]); if (result.value !== 0 || result.errno !== 0) { throw new Error( - `kernel close failed for pid ${pid}: value=${result.value} errno=${result.errno}`, + `kernel close failed for pid ${process.pid}: value=${result.value} errno=${result.errno}`, ); } } @@ -250,17 +273,15 @@ function writeFlock( } function lock( - worker: CentralizedKernelWorker, - pid: number, + process: RegisteredProcess, fd: number, start: bigint, len: bigint, type = F_WRLCK, command = F_SETLK64, -): SyscallResult { +): Promise { return issue( - worker, - pid, + process, ABI_SYSCALLS.Fcntl, [ fd, @@ -321,41 +342,45 @@ self.onmessage = async (event: MessageEvent) => { const renamedIdentityPath = `${identityPath}-renamed`; const opfs = OpfsFileSystem.create(buffer); let worker: CentralizedKernelWorker | null = null; - const pids: number[] = []; + const registrations: RegisteredProcess[] = []; let response: Record | null = null; try { createEmptyFile(opfs, identityPath); createEmptyFile(opfs, capacityPath); - worker = new CentralizedKernelWorker( - { maxWorkers: 4, dataBufferSize: 65_536, useSharedMemory: true }, - new VirtualPlatformIO( + worker = createCentralizedKernelWorkerTestDouble({ + config: { + maxWorkers: 4, + dataBufferSize: 65_536, + useSharedMemory: true, + }, + io: new VirtualPlatformIO( [{ mountPoint: "/", backend: opfs }], new BrowserTimeProvider(), ), - ); + }); await worker.init(kernelWasm); - pids.push(register(worker).pid); + const owner = register(worker); + registrations.push(owner); const peer = register(worker); - pids.push(peer.pid); + registrations.push(peer); const capacityOwner = register(worker); - pids.push(capacityOwner.pid); - pids.push(register(worker).pid); - - const ownerFd = openFile(worker, pids[0], identityPath); - const peerFd = openFile(worker, pids[1], identityPath); - const independentOpenAcquired = lock( - worker, - pids[0], + registrations.push(capacityOwner); + const capacityPeer = register(worker); + registrations.push(capacityPeer); + + const ownerFd = await openFile(owner, identityPath); + const peerFd = await openFile(peer, identityPath); + const independentOpenAcquired = await lock( + owner, ownerFd, 0n, 1n, ); - const independentOpenConflict = lock( - worker, - pids[1], + const independentOpenConflict = await lock( + peer, peerFd, 0n, 1n, @@ -363,29 +388,24 @@ self.onmessage = async (event: MessageEvent) => { opfs.rename(identityPath, renamedIdentityPath); opfs.unlink(renamedIdentityPath); - const renamedAndUnlinkedOpenConflict = lock( - worker, - pids[1], + const renamedAndUnlinkedOpenConflict = await lock( + peer, peerFd, 0n, 1n, ); createEmptyFile(opfs, identityPath); - const recreatedFd = openFile(worker, pids[2], identityPath); - const recreatedPathIsolated = lock( - worker, - pids[2], + const recreatedFd = await openFile(capacityOwner, identityPath); + const recreatedPathIsolated = await lock( + capacityOwner, recreatedFd, 0n, 1n, ); - closeFile(worker, pids[2], recreatedFd); + await closeFile(capacityOwner, recreatedFd); - const state = internals(worker); - const peerChannel = state.processes.get(pids[1])?.channels[0]; - if (!peerChannel) throw new Error("peer kernel channel is not registered"); - const blockingArgs = prepareProcessFcntl( + prepareProcessFcntl( peer, peerFd, F_SETLKW64, @@ -393,31 +413,29 @@ self.onmessage = async (event: MessageEvent) => { 1n, F_WRLCK, ); - state.handleFcntlLock(peerChannel, blockingArgs); + submitPreparedIssue(peer); + // Let the genuine channel listener park F_SETLKW before the owner unlocks. + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); const blockingParkedBeforeUnlock = - state.pendingAdvisoryLockRetries.has(peerChannel); + processChannelResult(peer).status === CHANNEL_STATUS_PENDING; - const unlockResult = lock(worker, pids[0], ownerFd, 0n, 1n, F_UNLCK); - // Direct kernel calls do not run the host's ordinary syscall-completion - // hook, so explicitly consume the same generic wake stream here. - state.drainAndProcessWakeupEvents(); - const wakeResult = processChannelResult(peer); + const unlockResult = await lock(owner, ownerFd, 0n, 1n, F_UNLCK); + const wakeResult = await waitForIssueCompletion(peer); const blockingWokeAfterUnlock = - !state.pendingAdvisoryLockRetries.has(peerChannel) && wakeResult.status === CHANNEL_STATUS_COMPLETE && wakeResult.value === 0 && wakeResult.errno === 0; - closeFile(worker, pids[0], ownerFd); - closeFile(worker, pids[1], peerFd); + await closeFile(owner, ownerFd); + await closeFile(peer, peerFd); - const capacityFd = openFile(worker, pids[2], capacityPath); - const capacityPeerFd = openFile(worker, pids[3], capacityPath); + const capacityFd = await openFile(capacityOwner, capacityPath); + const capacityPeerFd = await openFile(capacityPeer, capacityPath); let capacityInserted = 0; for (let index = 0; index < MAX_LOCK_RECORDS; index++) { - const result = lock( - worker, - pids[2], + const result = await lock( + capacityOwner, capacityFd, BigInt(index * 2), 1n, @@ -430,19 +448,14 @@ self.onmessage = async (event: MessageEvent) => { capacityInserted++; } - const capacityConflict = lock( - worker, - pids[3], + const capacityConflict = await lock( + capacityPeer, capacityPeerFd, 0n, 1n, ); - const capacityChannel = state.processes.get(pids[2])?.channels[0]; - if (!capacityChannel) { - throw new Error("capacity-owner kernel channel is not registered"); - } - const exhaustionArgs = prepareProcessFcntl( + prepareProcessFcntl( capacityOwner, capacityFd, F_SETLKW64, @@ -450,13 +463,13 @@ self.onmessage = async (event: MessageEvent) => { 1n, F_WRLCK, ); - state.handleFcntlLock(capacityChannel, exhaustionArgs); - const exhaustion = processChannelResult(capacityOwner); + submitPreparedIssue(capacityOwner); + const exhaustion = await waitForIssueCompletion(capacityOwner); const exhaustionWasNotParked = - !state.pendingAdvisoryLockRetries.has(capacityChannel); + exhaustion.status === CHANNEL_STATUS_COMPLETE; - closeFile(worker, pids[2], capacityFd); - closeFile(worker, pids[3], capacityPeerFd); + await closeFile(capacityOwner, capacityFd); + await closeFile(capacityPeer, capacityPeerFd); response = { type: "result", @@ -482,7 +495,7 @@ self.onmessage = async (event: MessageEvent) => { } finally { const cleanupErrors: string[] = []; if (worker) { - for (const pid of pids) { + for (const { pid } of registrations) { try { worker.unregisterProcess(pid); } catch (error) { diff --git a/apps/browser-demos/test/fixtures/opfs-seek-client-worker.ts b/apps/browser-demos/test/fixtures/opfs-seek-client-worker.ts index 1047d2c94d..4afae32065 100644 --- a/apps/browser-demos/test/fixtures/opfs-seek-client-worker.ts +++ b/apps/browser-demos/test/fixtures/opfs-seek-client-worker.ts @@ -28,6 +28,28 @@ self.onmessage = ( if (fs.write(fd, data, null, data.length) !== data.length) { throw new Error("short OPFS fixture write"); } + const marker = new TextEncoder().encode("!"); + const append = fs.append(fd, marker, marker.length, null); + if (append.written !== marker.length || append.end !== 7) { + throw new Error("short OPFS fixture append"); + } + const limited = fs.append( + fd, + new TextEncoder().encode("blocked"), + 7, + append.end, + ); + if (limited.written !== 0 || limited.end !== append.end) { + throw new Error("OPFS append limit was not atomic with EOF"); + } + const replacement = new TextEncoder().encode("X"); + if (fs.write(fd, replacement, 1, replacement.length) !== replacement.length) { + throw new Error("short OPFS positioned fixture write"); + } + const observed = new Uint8Array(7); + if (fs.read(fd, observed, 0, observed.length) !== observed.length) { + throw new Error("short OPFS positioned fixture read"); + } fs.seek(fd, 2, SEEK_SET); const negativeError = errorName(() => fs.seek(fd, -3, SEEK_CUR)); @@ -50,6 +72,7 @@ self.onmessage = ( wideResult, overflowError, afterOverflow, + content: new TextDecoder().decode(observed), }); } catch (error) { if (fd >= 0) { diff --git a/apps/browser-demos/test/kernel-scratch-runtime.spec.ts b/apps/browser-demos/test/kernel-scratch-runtime.spec.ts index 2aeae3b9cc..6d172ca56d 100644 --- a/apps/browser-demos/test/kernel-scratch-runtime.spec.ts +++ b/apps/browser-demos/test/kernel-scratch-runtime.spec.ts @@ -10,15 +10,31 @@ import { } from "../../../host/src/generated/abi"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const programPath = resolve( - __dirname, - "../../../examples/kernel_scratch_browser_test.wasm", -); +const programs = [ + { + arch: "wasm32", + path: resolve( + __dirname, + "../../../examples/kernel_scratch_browser_test.wasm", + ), + }, + { + arch: "wasm64", + path: resolve( + __dirname, + "../../../examples/kernel_scratch_browser_test.wasm64.wasm", + ), + }, +] as const; const ptyByte = 0x51; const ptyLength = CH_TOTAL_SIZE + 1; const readvDataBytes = CH_DATA_SIZE - POSIX_IOV_MAX * STRUCT_SIZE_KERNEL_IOVEC_WIRE; const readvBytesPerIovec = readvDataBytes / POSIX_IOV_MAX; +const largeVectorIovecCount = 2; +const largeVectorBytesPerIovec = Math.floor(CH_DATA_SIZE / 2) + 1; +const largeVectorBytes = + largeVectorIovecCount * largeVectorBytesPerIovec; if ( !Number.isInteger(readvBytesPerIovec) || @@ -27,102 +43,182 @@ if ( ) { throw new Error("generated readv scratch layout cannot form an exact boundary"); } +if (largeVectorBytes <= CH_DATA_SIZE) { + throw new Error("large vector fixture must exceed ordinary channel scratch"); +} -test("owned kernel scratch carries exact-boundary readv and chunked PTY input in Chromium", async ({ - page, - baseURL, - browserName, -}) => { - test.skip( - browserName !== "chromium", - "the aggregate browser gate uses Chromium", - ); - expect(baseURL).toBeTruthy(); - - const runtimeErrors: string[] = []; - page.on("pageerror", (error) => { - runtimeErrors.push(`pageerror: ${error.message}`); - }); - page.on("console", (message) => { - if (message.type() === "error") { - runtimeErrors.push(`console: ${message.text()}`); - } - }); - page.on("requestfailed", (request) => { - runtimeErrors.push( - `requestfailed: ${request.url()} ${request.failure()?.errorText ?? "failed"}`, +for (const program of programs) { + test(`owned kernel scratch preserves ${program.arch} vector operations and chunked PTY input in Chromium`, async ({ + page, + baseURL, + browserName, + }) => { + test.skip( + browserName !== "chromium", + "the aggregate browser gate uses Chromium", ); - }); - - await page.goto(new URL("/pages/test-runner/?minimal=1", baseURL).href); - await page.waitForFunction(() => (window as any).__testRunnerReady === true); + expect(baseURL).toBeTruthy(); - const programUrl = new URL(`/@fs/${programPath}`, baseURL).href; - const results = await page.evaluate( - async ({ - programUrl, - iovecCount, - bytesPerIovec, - ptyInputLength, - ptyInputByte, - }) => { - const response = await fetch(programUrl); - if (!response.ok) { - throw new Error( - `program fetch failed: ${response.status} ${response.url}`, - ); + const runtimeErrors: string[] = []; + page.on("pageerror", (error) => { + runtimeErrors.push(`pageerror: ${error.message}`); + }); + page.on("console", (message) => { + if (message.type() === "error") { + runtimeErrors.push(`console: ${message.text()}`); } - const program = await response.arrayBuffer(); - const readv = await (window as any).__runTest( - program.slice(0), - [ - "kernel-scratch-browser-test", - "readv", - String(iovecCount), - String(bytesPerIovec), - ], - 30_000, + }); + page.on("requestfailed", (request) => { + runtimeErrors.push( + `requestfailed: ${request.url()} ${request.failure()?.errorText ?? "failed"}`, ); - const pty = await (window as any).__runTest( - program.slice(0), - [ - "kernel-scratch-browser-test", - "pty", - String(ptyInputLength), - String(ptyInputByte), - ], - 30_000, - { - ptyInput: { - data: new Uint8Array(ptyInputLength).fill(ptyInputByte), - readyMarker: "KERNEL_SCRATCH_PTY_READY", + }); + + await page.goto(new URL("/pages/test-runner/?minimal=1", baseURL).href); + await page.waitForFunction(() => (window as any).__testRunnerReady === true); + + const programUrl = new URL(`/@fs/${program.path}`, baseURL).href; + const results = await page.evaluate( + async ({ + programUrl, + iovecCount, + bytesPerIovec, + largeIovecCount, + largeBytesPerIovec, + ptyInputLength, + ptyInputByte, + }) => { + const response = await fetch(programUrl); + if (!response.ok) { + throw new Error( + `program fetch failed: ${response.status} ${response.url}`, + ); + } + const programBytes = await response.arrayBuffer(); + const readv = await (window as any).__runTest( + programBytes.slice(0), + [ + "kernel-scratch-browser-test", + "readv", + String(iovecCount), + String(bytesPerIovec), + ], + 30_000, + ); + const datagramVector = await (window as any).__runTest( + programBytes.slice(0), + [ + "kernel-scratch-browser-test", + "dgram-vector", + String(largeIovecCount), + String(largeBytesPerIovec), + ], + 30_000, + ); + const positionedVector = await (window as any).__runTest( + programBytes.slice(0), + [ + "kernel-scratch-browser-test", + "positioned-vector", + String(largeIovecCount), + String(largeBytesPerIovec), + ], + 30_000, + ); + const appendFlags = await (window as any).__runTest( + programBytes.slice(0), + ["kernel-scratch-browser-test", "append-flags"], + 30_000, + ); + const zeroIov = await (window as any).__runTest( + programBytes.slice(0), + ["kernel-scratch-browser-test", "zero-iov"], + 30_000, + ); + const pty = await (window as any).__runTest( + programBytes.slice(0), + [ + "kernel-scratch-browser-test", + "pty", + String(ptyInputLength), + String(ptyInputByte), + ], + 30_000, + { + ptyInput: { + data: new Uint8Array(ptyInputLength).fill(ptyInputByte), + readyMarker: "KERNEL_SCRATCH_PTY_READY", + }, }, - }, - ); - return { readv, pty }; - }, - { - programUrl, - iovecCount: POSIX_IOV_MAX, - bytesPerIovec: readvBytesPerIovec, - ptyInputLength: ptyLength, - ptyInputByte: ptyByte, - }, - ); + ); + return { + readv, + datagramVector, + positionedVector, + appendFlags, + zeroIov, + pty, + }; + }, + { + programUrl, + iovecCount: POSIX_IOV_MAX, + bytesPerIovec: readvBytesPerIovec, + largeIovecCount: largeVectorIovecCount, + largeBytesPerIovec: largeVectorBytesPerIovec, + ptyInputLength: ptyLength, + ptyInputByte: ptyByte, + }, + ); + + expect(results.readv.exitCode, results.readv.stderr).toBe(0); + expect(results.readv.stdout).toContain( + `KERNEL_SCRATCH_READV_PASS iovecs=${POSIX_IOV_MAX} bytes=${readvDataBytes}`, + ); + expect(results.readv.stderr).toBe(""); + expect(results.readv.hostDiagnostics).toEqual([]); + + expect( + results.datagramVector.exitCode, + results.datagramVector.stderr, + ).toBe(0); + expect(results.datagramVector.stdout).toContain( + `KERNEL_SCRATCH_DGRAM_VECTOR_PASS iovecs=${largeVectorIovecCount} bytes=${largeVectorBytes} datagrams=1`, + ); + expect(results.datagramVector.stderr).toBe(""); + expect(results.datagramVector.hostDiagnostics).toEqual([]); + + expect( + results.positionedVector.exitCode, + results.positionedVector.stderr, + ).toBe(0); + expect(results.positionedVector.stdout).toContain( + `KERNEL_SCRATCH_POSITIONED_VECTOR_PASS iovecs=${largeVectorIovecCount} bytes=${largeVectorBytes} offset=4096 cursor=37`, + ); + expect(results.positionedVector.stderr).toBe(""); + expect(results.positionedVector.hostDiagnostics).toEqual([]); + + expect(results.appendFlags.exitCode, results.appendFlags.stderr).toBe(0); + expect(results.appendFlags.stdout).toContain( + "KERNEL_SCRATCH_APPEND_FLAGS_PASS bytes=5", + ); + expect(results.appendFlags.stderr).toBe(""); + expect(results.appendFlags.hostDiagnostics).toEqual([]); - expect(results.readv.exitCode, results.readv.stderr).toBe(0); - expect(results.readv.stdout).toContain( - `KERNEL_SCRATCH_READV_PASS iovecs=${POSIX_IOV_MAX} bytes=${readvDataBytes}`, - ); - expect(results.readv.stderr).toBe(""); - expect(results.readv.hostDiagnostics).toEqual([]); + expect(results.zeroIov.exitCode, results.zeroIov.stderr).toBe(0); + expect(results.zeroIov.stdout).toContain( + `KERNEL_SCRATCH_ZERO_IOV_PASS pointer_bits=${program.arch === "wasm64" ? 64 : 32}`, + ); + expect(results.zeroIov.stderr).toBe(""); + expect(results.zeroIov.hostDiagnostics).toEqual([]); - expect(results.pty.exitCode, results.pty.stderr).toBe(0); - expect(results.pty.stdout).toContain("KERNEL_SCRATCH_PTY_READY"); - expect(results.pty.stdout).toContain( - `KERNEL_SCRATCH_PTY_PASS bytes=${ptyLength}`, - ); - expect(results.pty.stderr).toBe(""); - expect(results.pty.hostDiagnostics).toEqual([]); - expect(runtimeErrors).toEqual([]); -}); + expect(results.pty.exitCode, results.pty.stderr).toBe(0); + expect(results.pty.stdout).toContain("KERNEL_SCRATCH_PTY_READY"); + expect(results.pty.stdout).toContain( + `KERNEL_SCRATCH_PTY_PASS bytes=${ptyLength}`, + ); + expect(results.pty.stderr).toBe(""); + expect(results.pty.hostDiagnostics).toEqual([]); + expect(runtimeErrors).toEqual([]); + }); +} diff --git a/apps/browser-demos/test/opfs-seek.spec.ts b/apps/browser-demos/test/opfs-seek.spec.ts index 2240c5ebb9..48c2b2e560 100644 --- a/apps/browser-demos/test/opfs-seek.spec.ts +++ b/apps/browser-demos/test/opfs-seek.spec.ts @@ -81,6 +81,7 @@ test("OPFS preserves signed 64-bit seek results and failed offsets", async ({ wideResult: number; overflowError: string | null; afterOverflow: number; + content: string; }>(client, "result"); client.postMessage({ buffer, @@ -102,5 +103,6 @@ test("OPFS preserves signed 64-bit seek results and failed offsets", async ({ wideResult: 2 ** 32 + 1, overflowError: "EOVERFLOW", afterOverflow: Number.MAX_SAFE_INTEGER, + content: "aXcdef!", }); }); diff --git a/crates/kernel/src/blocked_retry.rs b/crates/kernel/src/blocked_retry.rs new file mode 100644 index 0000000000..bccbe2f09d --- /dev/null +++ b/crates/kernel/src/blocked_retry.rs @@ -0,0 +1,927 @@ +//! Stable kernel-owned targets for host-driven blocking retries. +//! +//! The host may retain immutable request bytes while a syscall sleeps, but a +//! numeric fd, POSIX message-queue descriptor, or System V IPC id can be +//! closed and reused before the retry. These bindings retain the exact kernel +//! object and policy selected by the first attempt. The opaque token is only a +//! lookup key; ownership remains in the kernel until an exact release or +//! process lifecycle cleanup consumes the binding. + +extern crate alloc; + +use alloc::vec::Vec; +use wasm_posix_shared::Errno; + +use crate::ipc::{PinnedMsgQueue, PinnedSemSet}; +use crate::lock::OfdId; +use crate::mqueue::PinnedMqueueDescriptor; +use crate::pipe::InFlightFd; + +/// One normalized syscall family whose retry target must remain stable. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum BlockingRetryOperation { + Read, + Write, + Fcntl, + Pread, + Pwrite, + Accept, + Connect, + Send, + Recv, + Sendto, + Recvfrom, + Sendmsg, + Recvmsg, + Flock, + Sendfile, + CopyFileRange, + Splice, + MqSend, + MqReceive, + MsgSend, + MsgReceive, + Semop, +} + +impl BlockingRetryOperation { + /// Normalize vector variants to the one scalar operation Rust executes + /// after the host has flattened their iovecs. + pub(crate) fn from_syscall(syscall: u32) -> Result { + use wasm_posix_shared::abi::extended_syscalls; + + match syscall { + 3 | 82 => Ok(Self::Read), + 4 | 81 => Ok(Self::Write), + 10 => Ok(Self::Fcntl), + 53 => Ok(Self::Accept), + 54 => Ok(Self::Connect), + 55 => Ok(Self::Send), + 56 => Ok(Self::Recv), + 62 => Ok(Self::Sendto), + 63 => Ok(Self::Recvfrom), + 64 + | extended_syscalls::SYS_PREADV + | extended_syscalls::SYS_PREADV2 => Ok(Self::Pread), + 65 + | extended_syscalls::SYS_PWRITEV + | extended_syscalls::SYS_PWRITEV2 => Ok(Self::Pwrite), + 137 => Ok(Self::Sendmsg), + 138 => Ok(Self::Recvmsg), + extended_syscalls::SYS_FLOCK => Ok(Self::Flock), + extended_syscalls::SYS_ACCEPT4 => Ok(Self::Accept), + extended_syscalls::SYS_SENDFILE => Ok(Self::Sendfile), + extended_syscalls::SYS_COPY_FILE_RANGE => Ok(Self::CopyFileRange), + extended_syscalls::SYS_SPLICE => Ok(Self::Splice), + extended_syscalls::SYS_MQ_TIMEDSEND => Ok(Self::MqSend), + extended_syscalls::SYS_MQ_TIMEDRECEIVE => Ok(Self::MqReceive), + extended_syscalls::SYS_MSGSND => Ok(Self::MsgSend), + extended_syscalls::SYS_MSGRCV => Ok(Self::MsgReceive), + extended_syscalls::SYS_SEMOP => Ok(Self::Semop), + _ => Err(Errno::EINVAL), + } + } + + pub(crate) fn is_single_ofd(self) -> bool { + matches!( + self, + Self::Read + | Self::Write + | Self::Fcntl + | Self::Pread + | Self::Pwrite + | Self::Accept + | Self::Connect + | Self::Send + | Self::Recv + | Self::Sendto + | Self::Recvfrom + | Self::Sendmsg + | Self::Recvmsg + | Self::Flock + ) + } + + pub(crate) fn is_pair_ofd(self) -> bool { + matches!(self, Self::Sendfile | Self::CopyFileRange | Self::Splice) + } +} + +/// Return whether one host-owned immutable retry snapshot deliberately has no +/// kernel target capability. +/// +/// WHY: token zero is an affirmative ownership classification, not a fallback +/// for a syscall omitted from [`BlockingRetryOperation::from_syscall`]. A new +/// parked operation must either retain its exact Rust target or be added to +/// this reviewed list; otherwise the token query fails closed. +fn is_explicit_host_only_snapshot_syscall(syscall: u32) -> bool { + use wasm_posix_shared::Syscall; + use wasm_posix_shared::abi::extended_syscalls; + + syscall == Syscall::Open as u32 + || syscall == Syscall::Openat as u32 + || syscall == Syscall::Poll as u32 + || syscall == Syscall::Select as u32 + || syscall == extended_syscalls::SYS_RT_SIGTIMEDWAIT + || syscall == extended_syscalls::SYS_PPOLL + || syscall == extended_syscalls::SYS_PSELECT6 +} + +/// Return whether one syscall result means the host may own a sleeping retry. +/// +/// Most retryable kernel operations surface EAGAIN. connect(2) deliberately +/// translates its host handshake sentinel into EINPROGRESS/EALREADY, but a +/// blocking caller still sleeps and therefore needs the same stable target. +pub(crate) fn result_needs_target(syscall: u32, errno: u32) -> bool { + errno == Errno::EAGAIN as u32 + || (syscall == 54 + && (errno == Errno::EINPROGRESS as u32 || errno == Errno::EALREADY as u32)) +} + +/// Exact process-local OFD slot plus its non-reusable machine identity. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct StableOfdTarget { + pub(crate) original_fd: i32, + pub(crate) ofd_idx: usize, + pub(crate) ofd_id: OfdId, +} + +/// Resource authority retained by one blocked operation. +/// +/// Capabilities are deliberately non-Clone. Exact release consumes the enum, +/// preventing a second caller from decrementing the same kernel-owned pin. +pub(crate) enum BlockingRetryTarget { + Ofd(StableOfdTarget), + OfdPair { + input: StableOfdTarget, + output: StableOfdTarget, + }, + Sendmsg { + carrier: StableOfdTarget, + ancillary: Vec, + }, + Mqueue(PinnedMqueueDescriptor), + SysvMessage(PinnedMsgQueue), + SysvSemaphore(PinnedSemSet), +} + +impl BlockingRetryTarget { + fn supports(&self, operation: BlockingRetryOperation) -> bool { + match self { + Self::Ofd(_) => { + operation.is_single_ofd() && operation != BlockingRetryOperation::Sendmsg + } + Self::OfdPair { .. } => operation.is_pair_ofd(), + Self::Sendmsg { .. } => operation == BlockingRetryOperation::Sendmsg, + Self::Mqueue(_) => matches!( + operation, + BlockingRetryOperation::MqSend | BlockingRetryOperation::MqReceive + ), + Self::SysvMessage(_) => matches!( + operation, + BlockingRetryOperation::MsgSend | BlockingRetryOperation::MsgReceive + ), + Self::SysvSemaphore(_) => operation == BlockingRetryOperation::Semop, + } + } +} + +pub(crate) struct BlockingRetryBinding { + pub(crate) token: i64, + pub(crate) tid: u32, + pub(crate) operation: BlockingRetryOperation, + pub(crate) target: BlockingRetryTarget, +} + +/// Per-process binding registry. +/// +/// A `Vec` keeps the common one-blocked-call-per-task case compact and avoids +/// a second tree allocation. Tokens never wrap or reuse during a process +/// lifetime; exhaustion is a truthful terminal error. +pub(crate) struct BlockingRetryState { + next_token: u64, + bindings: Vec, + active: Option<(u32, i64, BlockingRetryOperation)>, + dispatch_tid: Option, + bound_tid: Option, +} + +impl BlockingRetryState { + pub(crate) fn new() -> Self { + Self { + next_token: 1, + bindings: Vec::new(), + active: None, + dispatch_tid: None, + bound_tid: None, + } + } + + pub(crate) fn bind_task(&mut self, tid: u32) { + self.bound_tid = Some(tid); + } + + pub(crate) fn clear_bound_task(&mut self, tid: u32) { + if self.bound_tid == Some(tid) { + self.bound_tid = None; + } + } + + pub(crate) fn bound_tid(&self) -> Option { + self.bound_tid + } + + /// Reserve storage and a nonzero signed-Wasm token before the target is + /// pinned. This ordering lets callers fail without needing rollback. + pub(crate) fn prepare_insert(&mut self) -> Result { + if self.next_token > i64::MAX as u64 { + return Err(Errno::EOVERFLOW); + } + self.bindings.try_reserve(1).map_err(|_| Errno::ENOMEM)?; + let token = self.next_token as i64; + self.next_token += 1; + Ok(token) + } + + pub(crate) fn insert_prepared( + &mut self, + token: i64, + tid: u32, + operation: BlockingRetryOperation, + target: BlockingRetryTarget, + ) -> Result<(), (Errno, BlockingRetryTarget)> { + if !target.supports(operation) { + return Err((Errno::EINVAL, target)); + } + if token <= 0 + || self + .bindings + .iter() + .any(|binding| binding.token == token || binding.tid == tid) + { + return Err((Errno::EBUSY, target)); + } + self.bindings.push(BlockingRetryBinding { + token, + tid, + operation, + target, + }); + Ok(()) + } + + pub(crate) fn token_for( + &self, + tid: u32, + operation: BlockingRetryOperation, + ) -> Result { + self.bindings + .iter() + .find(|binding| binding.tid == tid && binding.operation == operation) + .map(|binding| binding.token) + .ok_or(Errno::ENOENT) + } + + /// Return a positive exact-target token, or zero only for an explicitly + /// classified host-only immutable snapshot. + /// + /// WHY: making Rust answer this question removes a duplicated TypeScript + /// allowlist. A newly mapped operation with no binding still fails ENOENT, + /// while an unknown operation fails EINVAL, so protocol drift cannot + /// silently downgrade it to an unpinned retry. + pub(crate) fn token_for_syscall(&self, tid: u32, syscall: u32) -> Result { + let operation = match BlockingRetryOperation::from_syscall(syscall) { + Ok(operation) => operation, + Err(Errno::EINVAL) if is_explicit_host_only_snapshot_syscall(syscall) => { + return Ok(0); + } + Err(error) => return Err(error), + }; + self.token_for(tid, operation) + } + + pub(crate) fn has_binding_for_tid(&self, tid: u32) -> bool { + self.bindings.iter().any(|binding| binding.tid == tid) + } + + pub(crate) fn activate( + &mut self, + tid: u32, + token: i64, + operation: BlockingRetryOperation, + ) -> Result<(), Errno> { + if self.active.is_some() { + return Err(Errno::EBUSY); + } + let binding = self + .bindings + .iter() + .find(|binding| binding.token == token) + .ok_or(Errno::ENOENT)?; + if binding.tid != tid || binding.operation != operation { + return Err(Errno::EINVAL); + } + self.active = Some((tid, token, operation)); + Ok(()) + } + + pub(crate) fn clear_active(&mut self) { + self.active = None; + } + + /// Admit a direct socket-message export, returning whether it installed + /// the active token that its caller must later clear. + /// + /// A channel-dispatched call passes token zero after the outer dispatcher + /// has already activated the exact operation. Foreign task/operation + /// state is rejected without mutation. + pub(crate) fn activate_direct( + &mut self, + tid: u32, + token: i64, + operation: BlockingRetryOperation, + ) -> Result { + if token == 0 { + if let Some((active_tid, _, active_operation)) = self.active { + if active_tid != tid { + return Err(Errno::EBUSY); + } + return if active_operation == operation { + Ok(false) + } else { + Err(Errno::EINVAL) + }; + } + return if self.has_binding_for_tid(tid) { + Err(Errno::EBUSY) + } else { + Ok(false) + }; + } + if token < 0 { + return Err(Errno::EINVAL); + } + self.activate(tid, token, operation)?; + Ok(true) + } + + pub(crate) fn active_tid(&self) -> Option { + self.active.map(|(tid, _, _)| tid) + } + + pub(crate) fn begin_dispatch(&mut self, tid: u32) -> Result<(), Errno> { + if self.dispatch_tid.is_some() { + return Err(Errno::EBUSY); + } + self.dispatch_tid = Some(tid); + Ok(()) + } + + /// Enter a direct export that may be nested under channel dispatch. + /// + /// WHY: sendmsg/recvmsg are callable both directly and through the channel + /// dispatcher. A nested call must share the already-proven TID without + /// clearing its caller's dispatch authority, while a different TID must + /// never replace that authority. + pub(crate) fn enter_dispatch(&mut self, tid: u32) -> Result { + match self.dispatch_tid { + None => { + self.dispatch_tid = Some(tid); + Ok(true) + } + Some(active_tid) if active_tid == tid => Ok(false), + Some(_) => Err(Errno::EBUSY), + } + } + + pub(crate) fn dispatch_tid(&self) -> Option { + self.dispatch_tid + } + + pub(crate) fn clear_dispatch(&mut self) { + self.dispatch_tid = None; + } + + pub(crate) fn active_binding( + &self, + tid: u32, + operation: BlockingRetryOperation, + ) -> Result, Errno> { + let Some((active_tid, token, active_operation)) = self.active else { + return Ok(None); + }; + if active_tid != tid || active_operation != operation { + return Err(Errno::EINVAL); + } + self.bindings + .iter() + .find(|binding| binding.token == token) + .map(Some) + .ok_or(Errno::ENOENT) + } + + /// Return the binding already validated by [`Self::activate`]. + /// + /// WHY: syscall implementations hold `&mut Process`, which originated + /// from the global process table. Re-reading that table merely to recover + /// the current TID would create an aliased reference to the same Process. + /// Activation happens before that borrow reaches the syscall layer and + /// records the exact task/operation under serialized kernel entry. + pub(crate) fn active_binding_current( + &self, + ) -> Result, Errno> { + let Some((_, token, _)) = self.active else { + return Ok(None); + }; + self.bindings + .iter() + .find(|binding| binding.token == token) + .map(Some) + .ok_or(Errno::ENOENT) + } + + pub(crate) fn active_mqueue( + &self, + tid: u32, + operation: BlockingRetryOperation, + ) -> Result, Errno> { + let Some(binding) = self.active_binding(tid, operation)? else { + return Ok(None); + }; + match &binding.target { + BlockingRetryTarget::Mqueue(pinned) => Ok(Some(pinned)), + _ => Err(Errno::EINVAL), + } + } + + pub(crate) fn active_sysv_message( + &self, + tid: u32, + operation: BlockingRetryOperation, + ) -> Result, Errno> { + let Some(binding) = self.active_binding(tid, operation)? else { + return Ok(None); + }; + match &binding.target { + BlockingRetryTarget::SysvMessage(pinned) => Ok(Some(pinned)), + _ => Err(Errno::EINVAL), + } + } + + pub(crate) fn active_sysv_semaphore( + &self, + tid: u32, + ) -> Result, Errno> { + let Some(binding) = self.active_binding(tid, BlockingRetryOperation::Semop)? else { + return Ok(None); + }; + match &binding.target { + BlockingRetryTarget::SysvSemaphore(pinned) => Ok(Some(pinned)), + _ => Err(Errno::EINVAL), + } + } + + pub(crate) fn take_exact( + &mut self, + tid: u32, + token: i64, + ) -> Result { + if self + .active + .is_some_and(|(active_tid, active_token, _)| { + active_tid == tid && active_token == token + }) + { + return Err(Errno::EBUSY); + } + let index = self + .bindings + .iter() + .position(|binding| binding.tid == tid && binding.token == token) + .ok_or(Errno::ENOENT)?; + Ok(self.bindings.swap_remove(index)) + } + + pub(crate) fn take_for_tid(&mut self, tid: u32) -> Option { + if self.bound_tid == Some(tid) { + self.bound_tid = None; + } + if self.dispatch_tid == Some(tid) { + self.dispatch_tid = None; + } + if self.active.is_some_and(|(active_tid, _, _)| active_tid == tid) { + self.active = None; + } + // insert_prepared rejects a second binding for the same TID. Returning + // the one owned value directly keeps thread-exit cleanup allocation + // free even when the machine is already out of memory. + self.bindings + .iter() + .position(|binding| binding.tid == tid) + .map(|index| self.bindings.swap_remove(index)) + } + + pub(crate) fn take_all(&mut self) -> Vec { + self.bound_tid = None; + self.dispatch_tid = None; + self.active = None; + core::mem::take(&mut self.bindings) + } + + #[cfg(test)] + pub(crate) fn binding_count(&self) -> usize { + self.bindings.len() + } +} + +impl Default for BlockingRetryState { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ofd_target(fd: i32, ofd_idx: usize, ofd_id: u64) -> BlockingRetryTarget { + BlockingRetryTarget::Ofd(StableOfdTarget { + original_fd: fd, + ofd_idx, + ofd_id: OfdId(ofd_id), + }) + } + + fn insert( + state: &mut BlockingRetryState, + tid: u32, + operation: BlockingRetryOperation, + target: BlockingRetryTarget, + ) -> i64 { + let token = state.prepare_insert().unwrap(); + state + .insert_prepared(token, tid, operation, target) + .map_err(|(error, _)| error) + .unwrap(); + token + } + + #[test] + fn pending_connect_results_need_the_same_target_as_eagain() { + assert!(result_needs_target(54, Errno::EINPROGRESS as u32)); + assert!(result_needs_target(54, Errno::EALREADY as u32)); + assert!(result_needs_target(53, Errno::EAGAIN as u32)); + assert!(!result_needs_target(54, Errno::ECONNREFUSED as u32)); + assert!(!result_needs_target(53, Errno::EINPROGRESS as u32)); + } + + #[test] + fn rust_is_the_single_authority_for_whether_a_retry_needs_a_token() { + use wasm_posix_shared::Syscall; + use wasm_posix_shared::abi::extended_syscalls; + + let mut state = BlockingRetryState::new(); + for syscall in [ + Syscall::Open as u32, + Syscall::Openat as u32, + Syscall::Poll as u32, + Syscall::Select as u32, + extended_syscalls::SYS_RT_SIGTIMEDWAIT, + extended_syscalls::SYS_PPOLL, + extended_syscalls::SYS_PSELECT6, + ] { + assert!(is_explicit_host_only_snapshot_syscall(syscall)); + assert_eq!(state.token_for_syscall(17, syscall), Ok(0)); + } + assert!(!is_explicit_host_only_snapshot_syscall(u32::MAX)); + assert_eq!(state.token_for_syscall(17, u32::MAX), Err(Errno::EINVAL)); + assert_eq!(state.token_for_syscall(17, 3), Err(Errno::ENOENT)); + + let token = insert( + &mut state, + 17, + BlockingRetryOperation::Read, + ofd_target(3, 4, 5), + ); + assert_eq!(state.token_for_syscall(17, 3), Ok(token)); + assert_eq!(state.token_for_syscall(17, 82), Ok(token)); + } + + #[test] + fn token_activation_requires_exact_task_and_operation() { + let mut state = BlockingRetryState::new(); + let token = insert( + &mut state, + 17, + BlockingRetryOperation::Read, + ofd_target(3, 4, 5), + ); + + assert_eq!( + state.activate(18, token, BlockingRetryOperation::Read), + Err(Errno::EINVAL) + ); + assert_eq!( + state.activate(17, token, BlockingRetryOperation::Write), + Err(Errno::EINVAL) + ); + assert_eq!( + state.activate(17, token + 1, BlockingRetryOperation::Read), + Err(Errno::ENOENT) + ); + state + .activate(17, token, BlockingRetryOperation::Read) + .unwrap(); + assert!(state.active_binding(17, BlockingRetryOperation::Read).unwrap().is_some()); + assert_eq!( + state.take_exact(17, token).map(|_| ()), + Err(Errno::EBUSY) + ); + state.clear_active(); + assert_eq!(state.take_exact(18, token).map(|_| ()), Err(Errno::ENOENT)); + assert!(state.take_exact(17, token).is_ok()); + assert_eq!(state.take_exact(17, token).map(|_| ()), Err(Errno::ENOENT)); + } + + #[test] + fn socket_operations_keep_exact_token_families() { + for (syscall, operation) in [ + (55, BlockingRetryOperation::Send), + (56, BlockingRetryOperation::Recv), + (62, BlockingRetryOperation::Sendto), + (63, BlockingRetryOperation::Recvfrom), + ] { + assert_eq!(BlockingRetryOperation::from_syscall(syscall), Ok(operation)); + } + } + + #[test] + fn nested_direct_dispatch_cannot_replace_or_clear_another_task() { + let mut state = BlockingRetryState::new(); + state.begin_dispatch(19).unwrap(); + assert_eq!(state.enter_dispatch(19), Ok(false)); + assert_eq!(state.enter_dispatch(20), Err(Errno::EBUSY)); + assert_eq!(state.dispatch_tid(), Some(19)); + assert_eq!(state.begin_dispatch(20), Err(Errno::EBUSY)); + assert_eq!(state.dispatch_tid(), Some(19)); + state.clear_dispatch(); + assert_eq!(state.enter_dispatch(20), Ok(true)); + assert_eq!(state.dispatch_tid(), Some(20)); + } + + #[test] + fn direct_activation_preserves_foreign_and_wrong_operation_state() { + let mut state = BlockingRetryState::new(); + let token = insert( + &mut state, + 19, + BlockingRetryOperation::Sendmsg, + BlockingRetryTarget::Sendmsg { + carrier: StableOfdTarget { + original_fd: 3, + ofd_idx: 4, + ofd_id: OfdId(5), + }, + ancillary: Vec::new(), + }, + ); + state + .activate(19, token, BlockingRetryOperation::Sendmsg) + .unwrap(); + + assert_eq!( + state.activate_direct(19, 0, BlockingRetryOperation::Recvmsg), + Err(Errno::EINVAL) + ); + assert_eq!( + state.activate_direct(20, 0, BlockingRetryOperation::Sendmsg), + Err(Errno::EBUSY) + ); + assert!(state + .active_binding(19, BlockingRetryOperation::Sendmsg) + .unwrap() + .is_some()); + assert_eq!( + state.activate_direct(19, 0, BlockingRetryOperation::Sendmsg), + Ok(false) + ); + } + + #[test] + fn two_tasks_interleave_without_replacing_each_others_binding() { + let mut state = BlockingRetryState::new(); + let read_token = insert( + &mut state, + 21, + BlockingRetryOperation::Read, + ofd_target(3, 7, 11), + ); + let write_token = insert( + &mut state, + 22, + BlockingRetryOperation::Write, + ofd_target(3, 8, 12), + ); + + assert_eq!(state.binding_count(), 2); + state + .activate(21, read_token, BlockingRetryOperation::Read) + .unwrap(); + assert_eq!( + state.activate(22, write_token, BlockingRetryOperation::Write), + Err(Errno::EBUSY) + ); + state.clear_active(); + state + .activate(22, write_token, BlockingRetryOperation::Write) + .unwrap(); + assert!(state.active_binding(22, BlockingRetryOperation::Write).unwrap().is_some()); + state.clear_active(); + + let removed = state.take_for_tid(21).unwrap(); + assert_eq!(removed.token, read_token); + assert_eq!(state.binding_count(), 1); + assert_eq!( + state.token_for(22, BlockingRetryOperation::Write), + Ok(write_token) + ); + } + + #[test] + fn lifecycle_take_clears_every_process_owned_task_mirror() { + let mut state = BlockingRetryState::new(); + let token = insert( + &mut state, + 21, + BlockingRetryOperation::Read, + ofd_target(3, 7, 11), + ); + state.bind_task(21); + state.begin_dispatch(21).unwrap(); + state + .activate(21, token, BlockingRetryOperation::Read) + .unwrap(); + + assert!(state.take_for_tid(21).is_some()); + assert_eq!(state.bound_tid(), None); + assert_eq!(state.dispatch_tid(), None); + assert_eq!(state.active_tid(), None); + + let second = insert( + &mut state, + 22, + BlockingRetryOperation::Write, + ofd_target(4, 8, 12), + ); + state.bind_task(22); + state.begin_dispatch(22).unwrap(); + state + .activate(22, second, BlockingRetryOperation::Write) + .unwrap(); + + assert_eq!(state.take_all().len(), 1); + assert_eq!(state.bound_tid(), None); + assert_eq!(state.dispatch_tid(), None); + assert_eq!(state.active_tid(), None); + } + + #[test] + fn one_task_cannot_hold_two_pending_operations() { + let mut state = BlockingRetryState::new(); + let token = insert( + &mut state, + 31, + BlockingRetryOperation::Read, + ofd_target(3, 1, 1), + ); + let second = state.prepare_insert().unwrap(); + let rejected = state.insert_prepared( + second, + 31, + BlockingRetryOperation::Write, + ofd_target(4, 2, 2), + ); + assert!(matches!(rejected, Err((Errno::EBUSY, _)))); + assert_eq!( + state.token_for(31, BlockingRetryOperation::Read), + Ok(token) + ); + assert_eq!(state.binding_count(), 1); + } + + #[test] + fn target_kind_must_match_the_operation() { + let mut state = BlockingRetryState::new(); + let token = state.prepare_insert().unwrap(); + assert!(matches!( + state.insert_prepared( + token, + 32, + BlockingRetryOperation::Sendmsg, + ofd_target(3, 1, 1), + ), + Err((Errno::EINVAL, BlockingRetryTarget::Ofd(_))) + )); + assert_eq!(state.binding_count(), 0); + } + + #[test] + fn mqueue_binding_keeps_the_unlinked_closed_queue_and_policy_stable() { + let mut queues = crate::mqueue::MqueueTable::new(); + let mqd = queues + .mq_open("/retry", 0o100 | 2, 0o600, 1, 32, true) + .unwrap(); + let pin = queues.pin_descriptor(mqd).unwrap(); + let mut state = BlockingRetryState::new(); + let token = insert( + &mut state, + 41, + BlockingRetryOperation::MqReceive, + BlockingRetryTarget::Mqueue(pin), + ); + + queues.mq_unlink("/retry").unwrap(); + queues.mq_close(mqd).unwrap(); + state + .activate(41, token, BlockingRetryOperation::MqReceive) + .unwrap(); + let pin = state + .active_mqueue(41, BlockingRetryOperation::MqReceive) + .unwrap() + .unwrap(); + assert_eq!(queues.pinned_is_nonblock(pin), Ok(false)); + assert_eq!(queues.pinned_descriptor_msgsize(pin), Ok(32)); + state.clear_active(); + + let binding = state.take_exact(41, token).unwrap(); + let BlockingRetryTarget::Mqueue(pin) = binding.target else { + panic!("expected mqueue binding"); + }; + queues.release_pinned_descriptor(pin).unwrap(); + } + + #[test] + fn sysv_bindings_observe_removal_instead_of_redirecting() { + let mut ipc = crate::ipc::IpcTable::new(); + let qid = ipc.msgget(0, 0o1000 | 0o666, 1, 0, 0).unwrap(); + let queue_pin = ipc.pin_msg_queue(qid).unwrap(); + let semid = ipc.semget(0, 1, 0o1000 | 0o666, 1, 0, 0).unwrap(); + let sem_pin = ipc.pin_sem_set(semid).unwrap(); + + let mut state = BlockingRetryState::new(); + let queue_token = insert( + &mut state, + 51, + BlockingRetryOperation::MsgReceive, + BlockingRetryTarget::SysvMessage(queue_pin), + ); + let sem_token = insert( + &mut state, + 52, + BlockingRetryOperation::Semop, + BlockingRetryTarget::SysvSemaphore(sem_pin), + ); + ipc.msgctl(qid, 0, 1, 0, 0).unwrap(); + ipc.semctl(semid, 0, 0, 1, 0, 0, 0).unwrap(); + + state + .activate(51, queue_token, BlockingRetryOperation::MsgReceive) + .unwrap(); + let queue_pin = state + .active_sysv_message(51, BlockingRetryOperation::MsgReceive) + .unwrap() + .unwrap(); + assert_eq!( + ipc.msgrcv_pinned(queue_pin, 8, 0, 0, 1, 0, 0) + .unwrap_err(), + Errno::EIDRM + ); + state.clear_active(); + + state + .activate(52, sem_token, BlockingRetryOperation::Semop) + .unwrap(); + let sem_pin = state.active_sysv_semaphore(52).unwrap().unwrap(); + let decrement = [crate::ipc::SemOp { + num: 0, + op: -1, + flg: 0, + }]; + assert_eq!( + ipc.semop_pinned(sem_pin, &decrement, 1, 0, 0), + Err(Errno::EIDRM) + ); + state.clear_active(); + + let queue = state.take_exact(51, queue_token).unwrap(); + let BlockingRetryTarget::SysvMessage(queue_pin) = queue.target else { + panic!("expected SysV message binding"); + }; + ipc.release_msg_queue_pin(queue_pin).unwrap(); + let semaphore = state.take_exact(52, sem_token).unwrap(); + let BlockingRetryTarget::SysvSemaphore(sem_pin) = semaphore.target else { + panic!("expected SysV semaphore binding"); + }; + ipc.release_sem_set_pin(sem_pin).unwrap(); + } +} diff --git a/crates/kernel/src/channel_result.rs b/crates/kernel/src/channel_result.rs new file mode 100644 index 0000000000..dd32f64aab --- /dev/null +++ b/crates/kernel/src/channel_result.rs @@ -0,0 +1,137 @@ +use wasm_posix_shared::Errno; + +/// One syscall result as observed through the authoritative channel and the +/// legacy narrow export return. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct ChannelDispatchOutcome { + pub(crate) channel_result: i64, + pub(crate) channel_errno: u32, + pub(crate) export_result: i32, +} + +impl ChannelDispatchOutcome { + pub(crate) fn narrow(result: i32) -> Self { + let (channel_result, channel_errno) = encode_channel_result(i64::from(result)); + Self { + channel_result, + channel_errno, + export_result: result, + } + } + + pub(crate) fn exact(result: i64) -> Self { + let (channel_result, channel_errno) = encode_channel_result(result); + Self { + channel_result, + channel_errno, + // WHY: preserve the existing exported i32 signature and its + // low-word mirror. Callers needing the syscall value must read + // the generated i64 channel field, as the host runtime does. + export_result: result as i32, + } + } + + pub(crate) fn process_address(result: Result) -> Self { + match result { + Ok(address) => { + let Ok(bits) = u64::try_from(address) else { + return Self::exact(-(Errno::EOVERFLOW as i64)); + }; + Self { + // WHY: wasm64 pointers are unsigned. Preserve all 64 bits + // in the physical i64 channel word; channel_errno keeps a + // bit-63 address distinguishable from a negated errno. + channel_result: bits as i64, + channel_errno: 0, + export_result: bits as u32 as i32, + } + } + Err(error) => Self::exact(-(error as i64)), + } + } +} + +pub(crate) fn encode_channel_result(result: i64) -> (i64, u32) { + if result >= 0 { + return (result, 0); + } + let errno = result + .checked_neg() + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(Errno::EIO as u32); + (-1, errno) +} + +pub(crate) fn checked_mmap_byte_offset(page_offset: i64) -> Result { + if page_offset < 0 { + return Err(Errno::EINVAL); + } + page_offset.checked_mul(4096).ok_or(Errno::EOVERFLOW) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn channel_outcome_keeps_values_wider_than_i32_and_number() { + for result in [i64::from(i32::MAX) + 1, (1i64 << 53) + 1, i64::MAX] { + let outcome = ChannelDispatchOutcome::exact(result); + assert_eq!(outcome.channel_result, result); + assert_eq!(outcome.channel_errno, 0); + assert_eq!(outcome.export_result, result as i32); + assert_eq!(encode_channel_result(outcome.channel_result), (result, 0)); + } + + let error = -(Errno::EINVAL as i64); + let outcome = ChannelDispatchOutcome::exact(error); + assert_eq!(outcome.channel_result, -1); + assert_eq!(outcome.channel_errno, Errno::EINVAL as u32); + assert_eq!(outcome.export_result, -(Errno::EINVAL as i32)); + } + + #[test] + fn process_address_keeps_unsigned_wasm64_bits_distinct_from_errno() { + if usize::BITS == 64 { + for address in [ + 0x1_0000_0000usize, + (1usize << 63) | 0x1234, + usize::MAX, + ] { + let outcome = ChannelDispatchOutcome::process_address(Ok(address)); + assert_eq!(outcome.channel_result as u64, address as u64); + assert_eq!(outcome.channel_errno, 0); + assert_eq!(outcome.export_result, address as u32 as i32); + } + } + + let outcome = ChannelDispatchOutcome::process_address(Err(Errno::EINVAL)); + assert_eq!(outcome.channel_result, -1); + assert_eq!(outcome.channel_errno, Errno::EINVAL as u32); + assert_eq!(outcome.export_result, -(Errno::EINVAL as i32)); + } + + #[test] + fn mmap_page_offsets_are_lossless_or_rejected_before_shifting() { + assert_eq!(checked_mmap_byte_offset(0), Ok(0)); + assert_eq!( + checked_mmap_byte_offset(i64::from(u32::MAX)), + Ok(i64::from(u32::MAX) * 4096), + ); + assert_eq!( + checked_mmap_byte_offset(0x1_0000_0001), + Ok(0x1_0000_0001_000), + ); + + let largest_page_offset = i64::MAX / 4096; + assert_eq!( + checked_mmap_byte_offset(largest_page_offset), + Ok(largest_page_offset * 4096), + ); + assert_eq!( + checked_mmap_byte_offset(largest_page_offset + 1), + Err(Errno::EOVERFLOW), + ); + assert_eq!(checked_mmap_byte_offset(-1), Err(Errno::EINVAL)); + } +} diff --git a/crates/kernel/src/channel_scratch.rs b/crates/kernel/src/channel_scratch.rs index c2f2f2da48..ff7a1ede08 100644 --- a/crates/kernel/src/channel_scratch.rs +++ b/crates/kernel/src/channel_scratch.rs @@ -62,9 +62,7 @@ impl ChannelScratchRegion { } pub(crate) fn end(self) -> Result { - self.start - .checked_add(self.capacity) - .ok_or(Errno::EFAULT) + self.start.checked_add(self.capacity).ok_or(Errno::EFAULT) } /// Prove a complete byte range against this allocation, independently of @@ -91,6 +89,19 @@ impl ChannelScratchRegion { }) } + /// Prove a command-dependent payload starts at the allocation base and + /// fits completely inside its explicit capacity. + pub(crate) fn checked_start_range( + self, + pointer: usize, + length: usize, + ) -> Result { + if pointer != self.start { + return Err(Errno::EFAULT); + } + self.checked_range(pointer, length) + } + fn remaining_from(self, pointer: usize) -> Result { if pointer == 0 || pointer < self.start { return Err(Errno::EFAULT); @@ -184,9 +195,22 @@ unsafe fn descriptor_size( region: ChannelScratchRegion, ) -> Result { match descriptor.size { - SyscallArgSize::CString => { + SyscallArgSize::CString { + max_bytes, + too_long_errno, + } => { let pointer = checked_pointer(args[descriptor.arg_index as usize])?; - let length = unsafe { checked_cstr_len(pointer as *const u8, region) }?; + region.checked_range(pointer, 0)?; + let remaining = region.end()?.checked_sub(pointer).ok_or(Errno::EFAULT)?; + let bounded = + ChannelScratchRegion::new(pointer, remaining.min(max_bytes as usize))?; + let length = match unsafe { checked_cstr_len(pointer as *const u8, bounded) } { + Ok(length) => length, + Err(_) if remaining >= max_bytes as usize => { + return Err(Errno::from_u32(too_long_errno).unwrap_or(Errno::EIO)); + } + Err(error) => return Err(error), + }; usize::try_from(length) .ok() .and_then(|length| length.checked_add(1)) @@ -319,14 +343,7 @@ fn checked_nullable_exact_range( if pointer == 0 { return validated.mark_null(index); } - checked_exact_range( - validated, - args, - index, - expected_pointer, - length, - region, - )?; + checked_exact_range(validated, args, index, expected_pointer, length, region)?; Ok(()) } @@ -335,29 +352,27 @@ unsafe fn validate_iovec_layout( region: ChannelScratchRegion, ) -> Result { let count = checked_size_scalar(args[2])?; - if count == 0 || count > platform_limits::IOV_MAX { + if count > platform_limits::IOV_MAX { return Err(Errno::EINVAL); } + let mut validated = ValidatedChannelScratchArgs::new(); + if count == 0 { + // POSIX ignores the iovec pointer when no entries exist. Record a + // canonical null for dispatch without converting, range-checking, or + // reading the caller-provided pointer bits. + validated.mark_null(1)?; + return Ok(validated); + } let table_bytes = count .checked_mul(size_of::()) .ok_or(Errno::EINVAL)?; - let mut validated = ValidatedChannelScratchArgs::new(); - let table = checked_exact_range( - &mut validated, - args, - 1, - region.start, - table_bytes, - region, - )?; + let table = checked_exact_range(&mut validated, args, 1, region.start, table_bytes, region)?; let table_bytes = unsafe { core::slice::from_raw_parts(table.start as *const u8, table.length) }; let mut cursor = table.start.checked_add(table.length).ok_or(Errno::EFAULT)?; for entry in table_bytes.chunks_exact(size_of::()) { - let base = - read_wire_u32(entry, offset_of!(KernelIovecWire, base))? as usize; - let length = - read_wire_u32(entry, offset_of!(KernelIovecWire, len))? as usize; + let base = read_wire_u32(entry, offset_of!(KernelIovecWire, base))? as usize; + let length = read_wire_u32(entry, offset_of!(KernelIovecWire, len))? as usize; if base != cursor { return Err(Errno::EFAULT); } @@ -394,15 +409,34 @@ unsafe fn validate_message_layout( size_of::(), region, )?; - let header = - unsafe { core::slice::from_raw_parts(header.start as *const u8, header.length) }; + let header = unsafe { core::slice::from_raw_parts(header.start as *const u8, header.length) }; + unsafe { validate_message_wire_layout(header, region) }?; + Ok(validated) +} + +/// Validate the nested extents described by one canonical message header. +/// +/// Keeping this separate from the outer header-range proof lets native tests +/// exercise wasm32 wire addresses without requiring the test allocator itself +/// to return an address below 4 GiB. +/// +/// # Safety +/// +/// When the header describes one iovec, that iovec must name readable memory +/// for the complete `KernelIovecWire` after this function proves its range. +unsafe fn validate_message_wire_layout( + header: &[u8], + region: ChannelScratchRegion, +) -> Result<(), Errno> { + if header.len() != size_of::() { + return Err(Errno::EFAULT); + } let name = read_wire_u32(header, offset_of!(KernelMsghdrWire, name))? as usize; let name_len = read_wire_u32(header, offset_of!(KernelMsghdrWire, name_len))? as usize; let iov = read_wire_u32(header, offset_of!(KernelMsghdrWire, iov))? as usize; let iov_len = read_wire_u32(header, offset_of!(KernelMsghdrWire, iov_len))? as usize; let control = read_wire_u32(header, offset_of!(KernelMsghdrWire, control))? as usize; - let control_len = - read_wire_u32(header, offset_of!(KernelMsghdrWire, control_len))? as usize; + let control_len = read_wire_u32(header, offset_of!(KernelMsghdrWire, control_len))? as usize; let mut cursor = region .start @@ -410,7 +444,10 @@ unsafe fn validate_message_layout( .ok_or(Errno::EFAULT)?; let mut append = |pointer: usize, length: usize| -> Result<(), Errno> { if length == 0 { - return if pointer == 0 { + // A null pointer means the optional field is absent. The current + // cursor is the one canonical allocation-owned zero-capacity + // address and preserves presence without lending any bytes. + return if pointer == 0 || pointer == cursor { Ok(()) } else { Err(Errno::EFAULT) @@ -452,7 +489,7 @@ unsafe fn validate_message_layout( append(base, length)?; } } - Ok(validated) + Ok(()) } fn validate_select_layout( @@ -463,9 +500,7 @@ fn validate_select_layout( let mut validated = ValidatedChannelScratchArgs::new(); let fd_set_bytes = wasm_posix_shared::select::FD_SET_BYTES; for index in 1usize..=3 { - let offset = (index - 1) - .checked_mul(fd_set_bytes) - .ok_or(Errno::EFAULT)?; + let offset = (index - 1).checked_mul(fd_set_bytes).ok_or(Errno::EFAULT)?; checked_nullable_exact_range( &mut validated, args, @@ -513,14 +548,7 @@ fn validate_ioctl_layout( if checked_size_scalar(args[3])? != size { return Err(Errno::EINVAL); } - checked_exact_range( - &mut validated, - args, - 2, - region.start, - size, - region, - )?; + checked_exact_range(&mut validated, args, 2, region.start, size, region)?; Ok(validated) } @@ -541,14 +569,7 @@ fn validate_ipc_control_layout( } else { crate::ipc_wire::shmid_ds_size(width)? }; - checked_exact_range( - &mut validated, - args, - 2, - region.start, - size, - region, - )?; + checked_exact_range(&mut validated, args, 2, region.start, size, region)?; Ok(validated) } @@ -583,12 +604,10 @@ fn validate_special_layout( || x == extended_syscalls::SYS_PREADV2 || x == extended_syscalls::SYS_PWRITEV2 ) => - { - unsafe { validate_iovec_layout(args, region) } - } - number if number == Syscall::Sendmsg as u32 || number == Syscall::Recvmsg as u32 => { - unsafe { validate_message_layout(args, region) } - } + unsafe { validate_iovec_layout(args, region) }, + number if number == Syscall::Sendmsg as u32 || number == Syscall::Recvmsg as u32 => unsafe { + validate_message_layout(args, region) + }, number if number == Syscall::Getgroups as u32 => { let mut validated = ValidatedChannelScratchArgs::new(); let count = checked_size_scalar(args[0])?; @@ -623,14 +642,7 @@ fn validate_special_layout( .checked_add(payload) .ok_or(Errno::EINVAL)?; let mut validated = ValidatedChannelScratchArgs::new(); - checked_exact_range( - &mut validated, - args, - 1, - region.start, - length, - region, - )?; + checked_exact_range(&mut validated, args, 1, region.start, length, region)?; Ok(validated) } extended_syscalls::SYS_MSGCTL | extended_syscalls::SYS_SHMCTL => { @@ -654,14 +666,7 @@ fn validate_special_layout( .checked_mul(size_of::()) .ok_or(Errno::EINVAL)?; let mut validated = ValidatedChannelScratchArgs::new(); - checked_exact_range( - &mut validated, - args, - 1, - region.start, - length, - region, - )?; + checked_exact_range(&mut validated, args, 1, region.start, length, region)?; if syscall_number == extended_syscalls::SYS_EPOLL_PWAIT { let mask_pointer = align_up( region.start.checked_add(length).ok_or(Errno::EFAULT)?, @@ -780,6 +785,27 @@ mod tests { assert_eq!(region.checked_range(0x1010, 0).unwrap().length, 0); } + #[test] + fn checked_start_range_requires_the_owned_base_and_explicit_capacity() { + let region = ChannelScratchRegion::new(0x1000, 16).unwrap(); + assert_eq!( + region.checked_start_range(0x1000, 16), + Ok(ChannelScratchRange { + start: 0x1000, + length: 16, + }), + ); + assert_eq!( + region.checked_start_range(0x1000, 17), + Err(Errno::EFAULT), + ); + assert_eq!( + region.checked_start_range(0x1001, 15), + Err(Errno::EFAULT), + ); + assert_eq!(region.checked_start_range(0, 0), Err(Errno::EFAULT)); + } + #[test] fn dynamic_buffers_reject_positive_null_and_canonicalize_empty_null() { let bytes = vec![0u8; 16]; @@ -801,6 +827,61 @@ mod tests { } } + #[test] + fn zero_iovec_count_ignores_pointer_without_reading_it() { + let bytes = [0u8; 1]; + let region = ChannelScratchRegion::new(bytes.as_ptr() as usize, bytes.len()).unwrap(); + for ignored_pointer in [0, i64::MIN, -1] { + let mut args = [0i64; 6]; + args[1] = ignored_pointer; + args[2] = 0; + let validated = unsafe { validate_iovec_layout(&args, region) }.unwrap(); + assert_eq!(validated.pointer(1), Ok(0)); + } + + let mut args = [0i64; 6]; + args[2] = -1; + assert_eq!( + unsafe { validate_iovec_layout(&args, region) }, + Err(Errno::EINVAL), + ); + args[2] = i64::try_from(platform_limits::IOV_MAX + 1).unwrap(); + assert_eq!( + unsafe { validate_iovec_layout(&args, region) }, + Err(Errno::EINVAL), + ); + } + + #[test] + fn message_layout_distinguishes_absent_and_present_zero_capacity_names() { + let start = 0x1000usize; + let header_size = size_of::(); + let region = ChannelScratchRegion::new(start, header_size).unwrap(); + let canonical_zero_extent = start.checked_add(header_size).unwrap(); + let mut header = vec![0u8; header_size]; + + let set_name = |header: &mut [u8], pointer: usize| { + let pointer = u32::try_from(pointer).unwrap().to_le_bytes(); + let offset = offset_of!(KernelMsghdrWire, name); + header[offset..offset + pointer.len()].copy_from_slice(&pointer); + }; + + // Both sendmsg and recvmsg use this canonical nested-wire validator. + // Null encodes absence, while the current checked cursor encodes a + // present output field whose caller capacity is exactly zero. + set_name(&mut header, 0); + assert!(unsafe { validate_message_wire_layout(&header, region) }.is_ok()); + + set_name(&mut header, canonical_zero_extent); + assert!(unsafe { validate_message_wire_layout(&header, region) }.is_ok()); + + set_name(&mut header, canonical_zero_extent + 1); + assert_eq!( + unsafe { validate_message_wire_layout(&header, region) }, + Err(Errno::EFAULT), + ); + } + #[test] fn fixed_buffers_require_explicit_nullable_metadata() { let bytes = vec![0u8; 512]; @@ -840,11 +921,7 @@ mod tests { args[1] = 0; assert_eq!( unsafe { - validate_channel_scratch_arguments( - extended_syscalls::SYS_PRCTL, - &args, - region, - ) + validate_channel_scratch_arguments(extended_syscalls::SYS_PRCTL, &args, region) }, Err(Errno::EFAULT), ); @@ -875,58 +952,93 @@ mod tests { #[test] fn raw_channel_pointer_allowlist_contains_only_process_addresses() { let dispatcher = include_str!("wasm_api.rs"); + let channel_dispatch_source = dispatcher + .split("#[cfg(test)]\nmod channel_pointer_tests") + .next() + .expect("channel pointer test boundary disappeared"); assert!( !dispatcher.contains("channel_pointer!("), "ambiguous raw channel pointer bypasses the scratch proof" ); + // Pin every remaining direct widened-pointer normalization site in the + // channel dispatcher. Command-dependent SEMCTL buffers must go through + // the named allocation-start/range proof rather than adding another + // raw `checked_channel_pointer(args[..])` call. + for context in [ + "fn checked_channel_pointer(raw: i64) -> Result {", + "let pointer = checked_channel_pointer(raw)?;", + "checked_channel_pointer(channel_scalar::process_address_argument(", + "match checked_channel_pointer(args[$index]) {", + ] { + assert_eq!( + channel_dispatch_source.matches(context).count(), + 1, + "reviewed direct channel-pointer context changed:\n{context}" + ); + } + assert_eq!( + channel_dispatch_source + .matches("checked_channel_pointer(") + .count(), + 4, + "review every new direct widened-channel pointer conversion" + ); + // WHY: rustfmt may wrap the binding before `match`; normalize only + // whitespace so this still pins the exact binding and proof helper. + let normalized_channel_dispatch_source = channel_dispatch_source + .split_whitespace() + .collect::>() + .join(" "); + assert_eq!( + normalized_channel_dispatch_source + .matches("let values_pointer = match checked_channel_scratch_start_range(") + .count(), + 1, + "SEMCTL SETALL must prove the exact scratch allocation and range" + ); + assert_eq!( + normalized_channel_dispatch_source + .matches("let output_pointer = match checked_channel_scratch_start_range(") + .count(), + 2, + "SEMCTL STAT/GETALL must prove the exact scratch allocation and range" + ); + assert_eq!( + channel_dispatch_source + .matches("checked_channel_scratch_start_range(") + .count(), + 4, + "review every command-dependent scratch-start proof" + ); + // WHY: a count alone lets a newly added raw pointer hide behind removal // of an existing use. Pin each reviewed process-address context, then // also pin the total so every addition, removal, or relocation requires // an explicit ownership review. let reviewed_process_address_contexts = [ ( - "73 => kernel_signal(a1 as u32, process_address!(1)), // SYS_SIGNAL", - 1, - ), - ( - r#"let result = match syscalls::sys_mmap( - proc, - &mut host, - process_address!(0),"#, - 1, - ), - ( - "47 => kernel_munmap(process_address!(0), channel_i32_scalar_usize(a2)), // SYS_MUNMAP", - 1, - ), - ( - "48 => kernel_brk(process_address!(0)) as i32, // SYS_BRK", + "47 => kernel_munmap(process_address!(0), process_size!(1)), // SYS_MUNMAP", 1, ), ( - "49 => kernel_mprotect(process_address!(0), channel_i32_scalar_usize(a2), a3 as u32), // SYS_MPROTECT", + "49 => kernel_mprotect(process_address!(0), process_size!(1), a3 as u32), // SYS_MPROTECT", 1, ), ( - r#"126 => kernel_mremap( - process_address!(0),"#, - 1, - ), - ( - "128 => kernel_madvise(process_address!(0), channel_i32_scalar_usize(a2), a3 as u32), // SYS_MADVISE", + "128 => kernel_madvise(process_address!(0), process_size!(1), a3 as u32), // SYS_MADVISE", 1, ), ( r#"201 => kernel_clone( 0, - process_address!(1), + conditional_process_address!(1), a1 as u32, 0, - process_address!(2), - process_address!(3), - process_address!(4),"#, + conditional_process_address!(2), + conditional_process_address!(3), + conditional_process_address!(4),"#, 4, ), ( @@ -935,7 +1047,7 @@ mod tests { a2 as u32, a3 as u32, a4 as u32, - process_address!(4),"#, + conditional_process_address!(4),"#, 2, ), ( @@ -943,7 +1055,7 @@ mod tests { 1, ), ( - "261 => kernel_set_robust_list(process_address!(0), channel_u32_scalar_usize(a2)), // SYS_SET_ROBUST_LIST", + "261 => kernel_set_robust_list(process_address!(0), process_size!(1)), // SYS_SET_ROBUST_LIST", 1, ), ( @@ -951,12 +1063,12 @@ mod tests { 2, ), ( - r#"let _shmaddr = process_address!(1); + r#"let _shmaddr = conditional_process_address!(1); kernel_ipc_shmat(a1, a2, a3)"#, 1, ), ( - r#"let _shmaddr = process_address!(0); + r#"let _shmaddr = conditional_process_address!(0); kernel_ipc_shmdt(a1)"#, 1, ), @@ -972,6 +1084,12 @@ mod tests { let addr = process_address!(0);"#, 1, ), + ( + r#"278 => { + let _address = process_address!(0); + let _length = process_size!(1);"#, + 1, + ), ]; let mut reviewed_uses = 0; @@ -990,6 +1108,42 @@ mod tests { ); } + #[test] + fn variable_io_adapters_are_not_public_bare_pointer_exports() { + let wasm_api = include_str!("wasm_api.rs"); + for removed_function in [ + "fn kernel_read(", + "fn kernel_write(", + "fn kernel_pread(", + "fn kernel_pwrite(", + "fn kernel_readv(", + "fn kernel_writev(", + "fn kernel_preadv(", + "fn kernel_pwritev(", + "fn kernel_prepare_write_operation(", + ] { + assert!( + !wasm_api.contains(removed_function), + "variable I/O regained a pointer-only public adapter: {removed_function}", + ); + } + for required_private_adapter in [ + "fn channel_read(", + "fn channel_write(", + "fn channel_pread(", + "fn channel_pwrite(", + "fn channel_readv(", + "fn channel_writev(", + "fn channel_preadv(", + "fn channel_pwritev(", + ] { + assert!( + wasm_api.contains(required_private_adapter), + "bounded private adapter disappeared: {required_private_adapter}", + ); + } + } + #[test] fn descriptor_range_accepts_capacity_and_rejects_capacity_plus_one() { let bytes = vec![0u8; 16]; @@ -999,17 +1153,14 @@ mod tests { args[1] = pointer_arg(start); args[2] = bytes.len() as i64; - let validated = unsafe { - validate_channel_scratch_arguments(Syscall::Read as u32, &args, region) - } - .unwrap(); + let validated = + unsafe { validate_channel_scratch_arguments(Syscall::Read as u32, &args, region) } + .unwrap(); assert_eq!(validated.pointer(1), Ok(start)); args[2] += 1; assert_eq!( - unsafe { - validate_channel_scratch_arguments(Syscall::Read as u32, &args, region) - }, + unsafe { validate_channel_scratch_arguments(Syscall::Read as u32, &args, region) }, Err(Errno::EFAULT), ); } @@ -1024,16 +1175,12 @@ mod tests { args[2] = -1; assert_eq!( - unsafe { - validate_channel_scratch_arguments(Syscall::Write as u32, &args, region) - }, + unsafe { validate_channel_scratch_arguments(Syscall::Write as u32, &args, region) }, Err(Errno::EINVAL), ); args[2] = MAX_SAFE_INTEGER + 1; assert_eq!( - unsafe { - validate_channel_scratch_arguments(Syscall::Write as u32, &args, region) - }, + unsafe { validate_channel_scratch_arguments(Syscall::Write as u32, &args, region) }, Err(Errno::EINVAL), ); } @@ -1052,10 +1199,8 @@ mod tests { bytes[24..28].copy_from_slice(&4u32.to_le_bytes()); assert!( - unsafe { - validate_channel_scratch_arguments(Syscall::Recvfrom as u32, &args, region) - } - .is_ok() + unsafe { validate_channel_scratch_arguments(Syscall::Recvfrom as u32, &args, region) } + .is_ok() ); // This models a second/torn socklen observation after the host sized @@ -1064,9 +1209,7 @@ mod tests { // form its output slice. bytes[24..28].copy_from_slice(&12u32.to_le_bytes()); assert_eq!( - unsafe { - validate_channel_scratch_arguments(Syscall::Recvfrom as u32, &args, region) - }, + unsafe { validate_channel_scratch_arguments(Syscall::Recvfrom as u32, &args, region) }, Err(Errno::EFAULT), ); } @@ -1083,9 +1226,7 @@ mod tests { args[5] = 0; assert_eq!( - unsafe { - validate_channel_scratch_arguments(Syscall::Recvfrom as u32, &args, region) - }, + unsafe { validate_channel_scratch_arguments(Syscall::Recvfrom as u32, &args, region) }, Err(Errno::EFAULT), ); } @@ -1101,16 +1242,12 @@ mod tests { args[3] = pointer_arg(start + 2 * wasm_posix_shared::select::FD_SET_BYTES); assert!( - unsafe { - validate_channel_scratch_arguments(Syscall::Select as u32, &args, region) - } - .is_ok() + unsafe { validate_channel_scratch_arguments(Syscall::Select as u32, &args, region) } + .is_ok() ); args[2] = args[1]; assert_eq!( - unsafe { - validate_channel_scratch_arguments(Syscall::Select as u32, &args, region) - }, + unsafe { validate_channel_scratch_arguments(Syscall::Select as u32, &args, region) }, Err(Errno::EFAULT), ); } @@ -1173,4 +1310,39 @@ mod tests { Ok((platform_limits::PATH_MAX_BYTES + 1) as u32), ); } + + #[test] + fn descriptor_cstr_bound_accepts_exact_capacity_and_rejects_capacity_plus_one() { + let capacity = platform_limits::PROCESS_METADATA_ENTRY_MAX_BYTES + 1; + let descriptor = SyscallArgDesc { + arg_index: 0, + direction: wasm_posix_shared::host_abi::SyscallArgDirection::In, + size: SyscallArgSize::CString { + max_bytes: capacity as u32, + too_long_errno: Errno::E2BIG as u32, + }, + nullable: false, + required: true, + }; + let mut exact = vec![b'a'; capacity]; + *exact.last_mut().unwrap() = 0; + let exact_region = + ChannelScratchRegion::new(exact.as_ptr() as usize, exact.len()).unwrap(); + let mut args = [0i64; 6]; + args[0] = pointer_arg(exact.as_ptr() as usize); + assert_eq!( + unsafe { descriptor_size(&descriptor, &args, exact_region) }, + Ok(capacity), + ); + + let mut oversized = vec![b'a'; capacity + 1]; + *oversized.last_mut().unwrap() = 0; + let oversized_region = + ChannelScratchRegion::new(oversized.as_ptr() as usize, oversized.len()).unwrap(); + args[0] = pointer_arg(oversized.as_ptr() as usize); + assert_eq!( + unsafe { descriptor_size(&descriptor, &args, oversized_region) }, + Err(Errno::E2BIG), + ); + } } diff --git a/crates/kernel/src/fork.rs b/crates/kernel/src/fork.rs index 369f0ca67b..cbba2dbfd7 100644 --- a/crates/kernel/src/fork.rs +++ b/crates/kernel/src/fork.rs @@ -829,7 +829,11 @@ pub fn serialize_fork_state(proc: &Process, buf: &mut [u8]) -> Result = BTreeMap::new(); for (_, entry) in &fd_entries { - *inherited_ofd_refs.entry(entry.ofd_ref.0).or_insert(0) += 1; + if proc.ofd_table.get(entry.ofd_ref.0).is_none() { + return Err(Errno::EBADF); + } + let count = inherited_ofd_refs.entry(entry.ofd_ref.0).or_insert(0); + *count = count.checked_add(1).ok_or(Errno::EOVERFLOW)?; } w.write_u32(fd_entries.len() as u32)?; for (fd_num, entry) in &fd_entries { @@ -966,20 +970,36 @@ pub fn serialize_fork_state(proc: &Process, buf: &mut [u8]) -> Result MAX_SOCKET_SLOTS { + let socket_root_count = ofd_entries + .iter() + .filter(|(_, ofd)| ofd.file_type == FileType::Socket) + .count(); + let mut socket_roots = Vec::new(); + socket_roots + .try_reserve_exact(socket_root_count) + .map_err(|_| Errno::ENOMEM)?; + for (_, ofd) in &ofd_entries { + if ofd.file_type == FileType::Socket { + socket_roots.push(SocketTable::index_from_ofd_handle(ofd.host_handle)?); + } + } + let mut inherited_sockets = proc.sockets.clone(); + inherited_sockets.retain_inherited_roots(&socket_roots)?; + + if inherited_sockets.len() > MAX_SOCKET_SLOTS { return Err(Errno::EINVAL); } // Count actual sockets let mut sock_count = 0u32; - for idx in 0..proc.sockets.len() { - if proc.sockets.get(idx).is_some() { + for idx in 0..inherited_sockets.len() { + if inherited_sockets.get(idx).is_some() { sock_count += 1; } } - w.write_u32(proc.sockets.len() as u32)?; // total slots (for index preservation) + w.write_u32(inherited_sockets.len() as u32)?; // total slots (for index preservation) w.write_u32(sock_count)?; - for idx in 0..proc.sockets.len() { - if let Some(sock) = proc.sockets.get(idx) { + for idx in 0..inherited_sockets.len() { + if let Some(sock) = inherited_sockets.get(idx) { w.write_u32(idx as u32)?; w.write_u32(match sock.domain { SocketDomain::Unix => 0, @@ -2017,6 +2037,23 @@ mod tests { use crate::process::Process; use crate::signal::SignalHandler; + fn install_socket_for_fork( + proc: &mut Process, + socket: crate::socket::SocketInfo, + ) -> usize { + let socket_index = proc.sockets.alloc(socket); + let ofd_index = proc.ofd_table.create( + FileType::Socket, + wasm_posix_shared::flags::O_RDWR, + -((socket_index as i64) + 1), + b"/dev/socket".to_vec(), + ); + proc.fd_table + .alloc(OpenFileDescRef(ofd_index), 0) + .unwrap(); + socket_index + } + #[test] fn test_roundtrip_default_process() { let mut proc = Process::new(1); @@ -2505,7 +2542,7 @@ mod tests { included_sources: vec![[10, 88, 0, 3], [10, 88, 0, 4]], }, ]; - let socket_idx = proc.sockets.alloc(socket); + let socket_idx = install_socket_for_fork(&mut proc, socket); let mut buf = vec![0u8; 64 * 1024]; let written = serialize_fork_state(&proc, &mut buf).unwrap(); @@ -2550,7 +2587,7 @@ mod tests { let mut proc = Process::new(1); let mut socket = SocketInfo::new(SocketDomain::Inet, SocketType::Dgram, 17); socket.bind_device = Some(vec![b'x'; MAX_SOCKET_STRING_LEN + 1]); - proc.sockets.alloc(socket); + install_socket_for_fork(&mut proc, socket); let mut buf = vec![0u8; 64 * 1024]; assert_eq!(serialize_fork_state(&proc, &mut buf), Err(Errno::EINVAL)); @@ -2563,7 +2600,7 @@ mod tests { blocked_sources: vec![[127, 0, 0, 2]; MAX_IPV4_MULTICAST_SOURCES + 1], included_sources: vec![], }]; - proc.sockets.alloc(socket); + install_socket_for_fork(&mut proc, socket); assert_eq!(serialize_fork_state(&proc, &mut buf), Err(Errno::EINVAL)); } diff --git a/crates/kernel/src/ipc.rs b/crates/kernel/src/ipc.rs index d26c9c5d8b..b49b50b601 100644 --- a/crates/kernel/src/ipc.rs +++ b/crates/kernel/src/ipc.rs @@ -9,7 +9,7 @@ use alloc::collections::BTreeMap; use alloc::collections::VecDeque; use alloc::vec; use alloc::vec::Vec; -use wasm_posix_shared::Errno; +use wasm_posix_shared::{Errno, platform_limits}; // ── IPC constants ── @@ -39,7 +39,6 @@ const SETVAL: i32 = 16; const SETALL: i32 = 17; // Limits -const MSGMAX: usize = 8192; // max message size const MSGMNB: u32 = 16384; // default max bytes in queue const SEMMSL: usize = 32; // max semaphores per set @@ -93,6 +92,9 @@ struct MsgEntry { struct MsgQueue { key: i32, id: i32, + generation: u64, + active_pins: usize, + removed: bool, mode: u32, uid: u32, gid: u32, @@ -150,6 +152,9 @@ struct SemValue { struct SemSet { key: i32, id: i32, + generation: u64, + active_pins: usize, + removed: bool, mode: u32, uid: u32, gid: u32, @@ -237,31 +242,160 @@ pub enum SemCtlResult { All(Vec), } +/// Stable identity for one message-queue generation retained across a blocked +/// operation. +/// +/// The fields intentionally remain private and the capability is neither +/// `Clone` nor `Copy`: only `IpcTable` can create it, and releasing it consumes +/// the exact pin once. +#[derive(Debug)] +pub(crate) struct PinnedMsgQueue { + pin_id: IpcPinId, + public_id: i32, + generation: u64, +} + +/// Stable identity for one semaphore-set generation retained across a blocked +/// operation. +/// +/// See `PinnedMsgQueue` for the ownership contract. +#[derive(Debug)] +pub(crate) struct PinnedSemSet { + pin_id: IpcPinId, + public_id: i32, + generation: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct IpcPinId(u64); + +#[cfg(test)] +impl PinnedMsgQueue { + fn duplicate_for_exact_release_test(&self) -> Self { + Self { + pin_id: self.pin_id, + public_id: self.public_id, + generation: self.generation, + } + } +} + +#[cfg(test)] +impl PinnedSemSet { + fn duplicate_for_exact_release_test(&self) -> Self { + Self { + pin_id: self.pin_id, + public_id: self.public_id, + generation: self.generation, + } + } +} + // ── IPC Table ── /// Global SysV IPC table holding message queues, semaphore sets, /// and shared memory segments. pub struct IpcTable { msg_queues: BTreeMap, + removed_msg_queues: BTreeMap, + active_msg_pins: BTreeMap, sem_sets: BTreeMap, + removed_sem_sets: BTreeMap, + active_sem_pins: BTreeMap, shm_segments: BTreeMap, next_id: i32, + next_generation: u64, + next_pin_id: Option, } impl IpcTable { pub const fn new() -> Self { IpcTable { msg_queues: BTreeMap::new(), + removed_msg_queues: BTreeMap::new(), + active_msg_pins: BTreeMap::new(), sem_sets: BTreeMap::new(), + removed_sem_sets: BTreeMap::new(), + active_sem_pins: BTreeMap::new(), shm_segments: BTreeMap::new(), next_id: 0, + next_generation: 1, + next_pin_id: Some(IpcPinId(1)), } } - fn alloc_id(&mut self) -> i32 { - let id = self.next_id; - self.next_id = self.next_id.wrapping_add(1) & 0x7FFF_FFFF; - id + fn public_id_in_use(&self, id: i32) -> bool { + self.msg_queues.contains_key(&id) + || self.sem_sets.contains_key(&id) + || self.shm_segments.contains_key(&id) + } + + fn alloc_id(&mut self) -> Result { + self.alloc_id_bounded(i32::MAX) + } + + /// Allocate from `0..=max_id`, advancing through the domain without ever + /// overwriting a live object. + /// + /// The bounded form also gives unit tests a tractable way to prove + /// collision, wrap, and exhaustion behavior without constructing billions + /// of IPC objects. + fn alloc_id_bounded(&mut self, max_id: i32) -> Result { + if max_id < 0 { + return Err(Errno::ENOSPC); + } + + let mut candidate = if self.next_id >= 0 && self.next_id <= max_id { + self.next_id + } else { + 0 + }; + let domain_size = u64::try_from(max_id) + .map_err(|_| Errno::ENOSPC)? + .checked_add(1) + .ok_or(Errno::ENOSPC)?; + + for _ in 0..domain_size { + if !self.public_id_in_use(candidate) { + self.next_id = if candidate == max_id { + 0 + } else { + candidate + 1 + }; + return Ok(candidate); + } + candidate = if candidate == max_id { + 0 + } else { + candidate + 1 + }; + } + + Err(Errno::ENOSPC) + } + + fn alloc_generation(&mut self) -> Result { + let generation = self.next_generation; + self.next_generation = self.next_generation.checked_add(1).ok_or(Errno::ENOSPC)?; + Ok(generation) + } + + fn alloc_pin_id(&mut self) -> Result { + let mut candidate = self.next_pin_id.ok_or(Errno::EOVERFLOW)?; + loop { + if !self.active_msg_pins.contains_key(&candidate) + && !self.active_sem_pins.contains_key(&candidate) + { + self.next_pin_id = candidate.0.checked_add(1).map(IpcPinId); + return Ok(candidate); + } + candidate = IpcPinId(candidate.0.checked_add(1).ok_or(Errno::EOVERFLOW)?); + } + } + + #[cfg(test)] + fn set_next_public_id_for_test(&mut self, next_id: i32) { + self.next_id = next_id; } // ═══════════════════════════════════════════════════════════════ @@ -298,13 +432,17 @@ impl IpcTable { return Err(Errno::ENOENT); } - let id = self.alloc_id(); + let id = self.alloc_id()?; + let generation = self.alloc_generation()?; let seq = id; self.msg_queues.insert( id, MsgQueue { key, id, + generation, + active_pins: 0, + removed: false, mode, uid, gid, @@ -325,6 +463,81 @@ impl IpcTable { Ok(id) } + /// Pin the currently visible generation behind a public message-queue ID. + /// + /// WHY: a blocked retry must retain object identity, not merely the numeric + /// ID that a later `msgget` is allowed to reuse. + pub(crate) fn pin_msg_queue(&mut self, qid: i32) -> Result { + let generation = self + .msg_queues + .get(&qid) + .map(|queue| queue.generation) + .ok_or(Errno::EINVAL)?; + let pin_id = self.alloc_pin_id()?; + let queue = self.msg_queues.get_mut(&qid).ok_or(Errno::EINVAL)?; + queue.active_pins = queue.active_pins.checked_add(1).ok_or(Errno::EOVERFLOW)?; + let previous = self.active_msg_pins.insert(pin_id, generation); + debug_assert!(previous.is_none()); + Ok(PinnedMsgQueue { + pin_id, + public_id: qid, + generation, + }) + } + + /// Release one exact message-queue pin. + /// + /// Taking the opaque capability by value makes release consuming. A + /// tombstone remains reachable until the last capability is released. + pub(crate) fn release_msg_queue_pin(&mut self, pin: PinnedMsgQueue) -> Result<(), Errno> { + if self.active_msg_pins.get(&pin.pin_id) != Some(&pin.generation) { + return Err(Errno::EINVAL); + } + + if let Some(queue) = self.msg_queues.get_mut(&pin.public_id) { + if queue.generation == pin.generation { + queue.active_pins = queue.active_pins.checked_sub(1).ok_or(Errno::EINVAL)?; + self.active_msg_pins.remove(&pin.pin_id); + return Ok(()); + } + } + + let should_reclaim = { + let queue = self + .removed_msg_queues + .get_mut(&pin.generation) + .ok_or(Errno::EINVAL)?; + if queue.id != pin.public_id || !queue.removed { + return Err(Errno::EINVAL); + } + queue.active_pins = queue.active_pins.checked_sub(1).ok_or(Errno::EINVAL)?; + queue.active_pins == 0 + }; + if should_reclaim { + self.removed_msg_queues.remove(&pin.generation); + } + self.active_msg_pins.remove(&pin.pin_id); + Ok(()) + } + + fn live_msg_queue_for_pin_mut(&mut self, pin: &PinnedMsgQueue) -> Result<&mut MsgQueue, Errno> { + if self.active_msg_pins.get(&pin.pin_id) != Some(&pin.generation) { + return Err(Errno::EIDRM); + } + if self + .msg_queues + .get(&pin.public_id) + .is_some_and(|queue| queue.generation == pin.generation) + { + return self.msg_queues.get_mut(&pin.public_id).ok_or(Errno::EIDRM); + } + + // A capability never follows a reused public ID. Whether its removed + // generation is still tombstoned or has already been reclaimed, the + // operation's stable target no longer exists. + Err(Errno::EIDRM) + } + /// Send a message to a queue. pub fn msgsnd( &mut self, @@ -335,19 +548,113 @@ impl IpcTable { pid: u32, uid: u32, gid: u32, + ) -> Result<(), Errno> { + self.msgsnd_with_reserve( + qid, + mtype, + data, + flags, + pid, + uid, + gid, + |message, additional| { + message + .try_reserve_exact(additional) + .map_err(|_| Errno::ENOMEM) + }, + |messages| messages.try_reserve(1).map_err(|_| Errno::ENOMEM), + ) + } + + /// Retry `msgsnd` against the exact generation captured by + /// `pin_msg_queue`. + #[allow(clippy::too_many_arguments)] + pub(crate) fn msgsnd_pinned( + &mut self, + pin: &PinnedMsgQueue, + mtype: i64, + data: &[u8], + flags: u32, + pid: u32, + uid: u32, + gid: u32, + ) -> Result<(), Errno> { + let queue = self.live_msg_queue_for_pin_mut(pin)?; + if mtype <= 0 { + return Err(Errno::EINVAL); + } + Self::msgsnd_queue_with_reserve( + queue, + mtype, + data, + flags, + pid, + uid, + gid, + |message, additional| { + message + .try_reserve_exact(additional) + .map_err(|_| Errno::ENOMEM) + }, + |messages| messages.try_reserve(1).map_err(|_| Errno::ENOMEM), + ) + } + + #[allow(clippy::too_many_arguments)] + fn msgsnd_with_reserve( + &mut self, + qid: i32, + mtype: i64, + data: &[u8], + flags: u32, + pid: u32, + uid: u32, + gid: u32, + reserve_message: impl FnOnce(&mut Vec, usize) -> Result<(), Errno>, + reserve_slot: impl FnOnce(&mut VecDeque) -> Result<(), Errno>, ) -> Result<(), Errno> { if mtype <= 0 { return Err(Errno::EINVAL); } let q = self.msg_queues.get_mut(&qid).ok_or(Errno::EINVAL)?; + Self::msgsnd_queue_with_reserve( + q, + mtype, + data, + flags, + pid, + uid, + gid, + reserve_message, + reserve_slot, + ) + } + + #[allow(clippy::too_many_arguments)] + fn msgsnd_queue_with_reserve( + q: &mut MsgQueue, + mtype: i64, + data: &[u8], + flags: u32, + pid: u32, + uid: u32, + gid: u32, + reserve_message: impl FnOnce(&mut Vec, usize) -> Result<(), Errno>, + reserve_slot: impl FnOnce(&mut VecDeque) -> Result<(), Errno>, + ) -> Result<(), Errno> { ipc_check_perm(uid, gid, q.uid, q.gid, q.mode, IPC_W)?; - if data.len() > MSGMAX { + if data.len() > platform_limits::SYSV_MSG_MAX_BYTES { return Err(Errno::EINVAL); } - // Check queue capacity - if q.cbytes + data.len() as u32 > q.qbytes { + // Compute the complete post-commit accounting value once. Overflow + // cannot mean "space available"; treat it exactly like a full queue. + let next_cbytes = q + .cbytes + .checked_add(u32::try_from(data.len()).map_err(|_| Errno::EAGAIN)?) + .ok_or(Errno::EAGAIN)?; + if next_cbytes > q.qbytes { if (flags & IPC_NOWAIT) != 0 { return Err(Errno::EAGAIN); } @@ -355,11 +662,25 @@ impl IpcTable { return Err(Errno::EAGAIN); } - q.cbytes += data.len() as u32; + // WHY: both the owned payload and the VecDeque slot can allocate. + // Prepare them fallibly before changing accounting or queue order so + // ENOMEM leaves the logical message queue transaction untouched. + let mut message_data = Vec::new(); + reserve_message(&mut message_data, data.len())?; + if message_data.capacity() < data.len() { + return Err(Errno::ENOMEM); + } + message_data.extend_from_slice(data); + reserve_slot(&mut q.messages)?; + if q.messages.capacity().saturating_sub(q.messages.len()) < 1 { + return Err(Errno::ENOMEM); + } + q.messages.push_back(MsgEntry { mtype, - data: Vec::from(data), + data: message_data, }); + q.cbytes = next_cbytes; q.lspid = pid as i32; q.stime = crate::current_time_secs(); @@ -377,16 +698,7 @@ impl IpcTable { uid: u32, gid: u32, ) -> Result { - self.msgrcv_with_mtype_max( - qid, - max_size, - msgtype, - i64::MAX, - flags, - pid, - uid, - gid, - ) + self.msgrcv_with_mtype_max(qid, max_size, msgtype, i64::MAX, flags, pid, uid, gid) } /// Receive while proving the selected mtype fits the caller's native long. @@ -406,6 +718,71 @@ impl IpcTable { gid: u32, ) -> Result { let q = self.msg_queues.get_mut(&qid).ok_or(Errno::EINVAL)?; + Self::msgrcv_queue_with_mtype_max( + q, + max_size, + msgtype, + max_output_mtype, + flags, + pid, + uid, + gid, + ) + } + + /// Retry `msgrcv` against the exact generation captured by + /// `pin_msg_queue`. + #[allow(clippy::too_many_arguments)] + pub(crate) fn msgrcv_pinned( + &mut self, + pin: &PinnedMsgQueue, + max_size: u32, + msgtype: i64, + flags: u32, + pid: u32, + uid: u32, + gid: u32, + ) -> Result { + self.msgrcv_pinned_with_mtype_max(pin, max_size, msgtype, i64::MAX, flags, pid, uid, gid) + } + + /// Width-aware pinned `msgrcv`; see `msgrcv_with_mtype_max`. + #[allow(clippy::too_many_arguments)] + pub(crate) fn msgrcv_pinned_with_mtype_max( + &mut self, + pin: &PinnedMsgQueue, + max_size: u32, + msgtype: i64, + max_output_mtype: i64, + flags: u32, + pid: u32, + uid: u32, + gid: u32, + ) -> Result { + let queue = self.live_msg_queue_for_pin_mut(pin)?; + Self::msgrcv_queue_with_mtype_max( + queue, + max_size, + msgtype, + max_output_mtype, + flags, + pid, + uid, + gid, + ) + } + + #[allow(clippy::too_many_arguments)] + fn msgrcv_queue_with_mtype_max( + q: &mut MsgQueue, + max_size: u32, + msgtype: i64, + max_output_mtype: i64, + flags: u32, + pid: u32, + uid: u32, + gid: u32, + ) -> Result { ipc_check_perm(uid, gid, q.uid, q.gid, q.mode, IPC_R)?; let noerror = (flags & MSG_NOERROR) != 0; @@ -452,21 +829,25 @@ impl IpcTable { } } - let msg = q.messages.remove(idx).unwrap(); - let truncated_len = core::cmp::min(msg.data.len(), max_size as usize); - let data = if truncated_len < msg.data.len() { - msg.data[..truncated_len].to_vec() - } else { - msg.data - }; - - q.cbytes = q.cbytes.saturating_sub(data.len() as u32); + let mut msg = q.messages.remove(idx).unwrap(); + let removed_len = msg.data.len(); + let truncated_len = core::cmp::min(removed_len, max_size as usize); + // The dequeued message already owns its allocation. Truncate that Vec + // in place so MSG_NOERROR cannot lose a removed message to a second, + // fallible output-copy allocation. + msg.data.truncate(truncated_len); + + // WHY: MSG_NOERROR truncates only the caller's returned copy. The + // complete queued message was removed, so capacity accounting must + // release its original length or a full queue can remain spuriously + // full after a successful truncated receive. + q.cbytes = q.cbytes.saturating_sub(removed_len as u32); q.lrpid = pid as i32; q.rtime = crate::current_time_secs(); Ok(MsgRcvResult { mtype: msg.mtype, - data, + data: msg.data, }) } @@ -504,7 +885,16 @@ impl IpcTable { IPC_RMID => { let q = self.msg_queues.get(&qid).ok_or(Errno::EINVAL)?; ipc_check_owner(uid, q.uid, q.cuid)?; - self.msg_queues.remove(&qid); + let mut queue = self.msg_queues.remove(&qid).ok_or(Errno::EINVAL)?; + queue.removed = true; + if queue.active_pins != 0 { + // Public visibility ends before any waiter is woken. The + // generation-keyed tombstone exists only to make those + // already-pinned retries fail with EIDRM and to keep ID + // reuse from redirecting them. + let previous = self.removed_msg_queues.insert(queue.generation, queue); + debug_assert!(previous.is_none()); + } Ok(None) } IPC_SET => { @@ -576,7 +966,8 @@ impl IpcTable { return Err(Errno::EINVAL); } - let id = self.alloc_id(); + let id = self.alloc_id()?; + let generation = self.alloc_generation()?; let seq = id; let values = (0..nsems) .map(|_| SemValue { @@ -592,6 +983,9 @@ impl IpcTable { SemSet { key, id, + generation, + active_pins: 0, + removed: false, mode: flags & 0o777, uid, gid, @@ -608,6 +1002,71 @@ impl IpcTable { Ok(id) } + /// Pin the currently visible generation behind a public semaphore-set ID. + pub(crate) fn pin_sem_set(&mut self, semid: i32) -> Result { + let generation = self + .sem_sets + .get(&semid) + .map(|set| set.generation) + .ok_or(Errno::EINVAL)?; + let pin_id = self.alloc_pin_id()?; + let set = self.sem_sets.get_mut(&semid).ok_or(Errno::EINVAL)?; + set.active_pins = set.active_pins.checked_add(1).ok_or(Errno::EOVERFLOW)?; + let previous = self.active_sem_pins.insert(pin_id, generation); + debug_assert!(previous.is_none()); + Ok(PinnedSemSet { + pin_id, + public_id: semid, + generation, + }) + } + + /// Consume and release one exact semaphore-set pin. + pub(crate) fn release_sem_set_pin(&mut self, pin: PinnedSemSet) -> Result<(), Errno> { + if self.active_sem_pins.get(&pin.pin_id) != Some(&pin.generation) { + return Err(Errno::EINVAL); + } + + if let Some(set) = self.sem_sets.get_mut(&pin.public_id) { + if set.generation == pin.generation { + set.active_pins = set.active_pins.checked_sub(1).ok_or(Errno::EINVAL)?; + self.active_sem_pins.remove(&pin.pin_id); + return Ok(()); + } + } + + let should_reclaim = { + let set = self + .removed_sem_sets + .get_mut(&pin.generation) + .ok_or(Errno::EINVAL)?; + if set.id != pin.public_id || !set.removed { + return Err(Errno::EINVAL); + } + set.active_pins = set.active_pins.checked_sub(1).ok_or(Errno::EINVAL)?; + set.active_pins == 0 + }; + if should_reclaim { + self.removed_sem_sets.remove(&pin.generation); + } + self.active_sem_pins.remove(&pin.pin_id); + Ok(()) + } + + fn live_sem_set_for_pin_mut(&mut self, pin: &PinnedSemSet) -> Result<&mut SemSet, Errno> { + if self.active_sem_pins.get(&pin.pin_id) != Some(&pin.generation) { + return Err(Errno::EIDRM); + } + if self + .sem_sets + .get(&pin.public_id) + .is_some_and(|set| set.generation == pin.generation) + { + return self.sem_sets.get_mut(&pin.public_id).ok_or(Errno::EIDRM); + } + Err(Errno::EIDRM) + } + /// Perform atomic semaphore operations (two-pass: validate then apply). pub fn semop( &mut self, @@ -617,7 +1076,30 @@ impl IpcTable { uid: u32, gid: u32, ) -> Result<(), Errno> { - let s = self.sem_sets.get(&semid).ok_or(Errno::EINVAL)?; + let set = self.sem_sets.get_mut(&semid).ok_or(Errno::EINVAL)?; + Self::semop_set(set, sops, pid, uid, gid) + } + + /// Retry `semop` against the exact generation captured by `pin_sem_set`. + pub(crate) fn semop_pinned( + &mut self, + pin: &PinnedSemSet, + sops: &[SemOp], + pid: u32, + uid: u32, + gid: u32, + ) -> Result<(), Errno> { + let set = self.live_sem_set_for_pin_mut(pin)?; + Self::semop_set(set, sops, pid, uid, gid) + } + + fn semop_set( + s: &mut SemSet, + sops: &[SemOp], + pid: u32, + uid: u32, + gid: u32, + ) -> Result<(), Errno> { let mut perm = 0; for op in sops { perm |= if op.op == 0 { IPC_R } else { IPC_W }; @@ -649,7 +1131,6 @@ impl IpcTable { } // Second pass: apply atomically - let s = self.sem_sets.get_mut(&semid).unwrap(); for op in sops { let sem = &mut s.values[op.num as usize]; if op.op != 0 { @@ -693,7 +1174,12 @@ impl IpcTable { IPC_RMID => { let s = self.sem_sets.get(&semid).ok_or(Errno::EINVAL)?; ipc_check_owner(uid, s.uid, s.cuid)?; - self.sem_sets.remove(&semid); + let mut set = self.sem_sets.remove(&semid).ok_or(Errno::EINVAL)?; + set.removed = true; + if set.active_pins != 0 { + let previous = self.removed_sem_sets.insert(set.generation, set); + debug_assert!(previous.is_none()); + } Ok(SemCtlResult::Ok) } IPC_SET => { @@ -778,7 +1264,9 @@ impl IpcTable { _ => return Err(Errno::EINVAL), }; ipc_check_perm(uid, gid, set.uid, set.gid, set.mode, permission)?; - set.values.len().checked_mul(core::mem::size_of::()) + set.values + .len() + .checked_mul(core::mem::size_of::()) .ok_or(Errno::EOVERFLOW) } @@ -865,7 +1353,7 @@ impl IpcTable { return Err(Errno::EINVAL); } - let id = self.alloc_id(); + let id = self.alloc_id()?; let seq = id; self.shm_segments.insert( id, @@ -1101,6 +1589,96 @@ mod tests { assert_eq!(msg.data, b"world"); } + #[test] + fn test_msgsnd_shared_maximum_boundary() { + let mut t = IpcTable::new(); + let qid = t.msgget(IPC_PRIVATE, IPC_CREAT | 0o666, 1, 0, 0).unwrap(); + let exact = vec![0xa5; platform_limits::SYSV_MSG_MAX_BYTES]; + + t.msgsnd(qid, 1, &exact, 0, 1, 0, 0).unwrap(); + assert_eq!( + t.msgsnd( + qid, + 2, + &vec![0x5a; platform_limits::SYSV_MSG_MAX_BYTES + 1], + 0, + 1, + 0, + 0, + ), + Err(Errno::EINVAL), + ); + + let received = t + .msgrcv( + qid, + platform_limits::SYSV_MSG_MAX_BYTES as u32, + 0, + 0, + 1, + 0, + 0, + ) + .unwrap(); + assert_eq!(received.data, exact); + } + + #[test] + fn msgsnd_allocation_failures_preserve_queue_accounting() { + let mut t = IpcTable::new(); + let qid = t.msgget(IPC_PRIVATE, IPC_CREAT | 0o666, 1, 0, 0).unwrap(); + + assert_eq!( + t.msgsnd_with_reserve( + qid, + 1, + b"payload", + 0, + 42, + 0, + 0, + |_, _| Err(Errno::ENOMEM), + |_| panic!("slot reservation must follow message reservation"), + ), + Err(Errno::ENOMEM), + ); + let queue = &t.msg_queues[&qid]; + assert!(queue.messages.is_empty()); + assert_eq!(queue.cbytes, 0); + assert_eq!(queue.lspid, 0); + assert_eq!(queue.stime, 0); + + assert_eq!( + t.msgsnd_with_reserve( + qid, + 1, + b"payload", + 0, + 42, + 0, + 0, + |message, additional| { + message + .try_reserve_exact(additional) + .map_err(|_| Errno::ENOMEM) + }, + |_| Err(Errno::ENOMEM), + ), + Err(Errno::ENOMEM), + ); + let queue = &t.msg_queues[&qid]; + assert!(queue.messages.is_empty()); + assert_eq!(queue.cbytes, 0); + assert_eq!(queue.lspid, 0); + assert_eq!(queue.stime, 0); + + t.msgsnd(qid, 1, b"payload", 0, 42, 0, 0).unwrap(); + let queue = &t.msg_queues[&qid]; + assert_eq!(queue.messages.len(), 1); + assert_eq!(queue.cbytes, 7); + assert_eq!(queue.lspid, 42); + } + #[test] fn test_msgrcv_type_filter() { let mut t = IpcTable::new(); @@ -1137,17 +1715,8 @@ mod tests { t.msgsnd(qid, mtype, b"wide", 0, 1, 0, 0).unwrap(); assert_eq!( - t.msgrcv_with_mtype_max( - qid, - 100, - 0, - i32::MAX as i64, - 0, - 1, - 0, - 0, - ) - .unwrap_err(), + t.msgrcv_with_mtype_max(qid, 100, 0, i32::MAX as i64, 0, 1, 0, 0,) + .unwrap_err(), Errno::EOVERFLOW, ); @@ -1199,6 +1768,30 @@ mod tests { assert_eq!(msg.data, b"hello"); } + #[test] + fn test_msgrcv_truncate_releases_complete_message_capacity() { + let mut t = IpcTable::new(); + let qid = t.msgget(IPC_PRIVATE, IPC_CREAT | 0o666, 1, 0, 0).unwrap(); + let full_message = vec![0x33; platform_limits::SYSV_MSG_MAX_BYTES]; + + t.msgsnd(qid, 1, &full_message, 0, 1, 0, 0).unwrap(); + t.msgsnd(qid, 2, &full_message, 0, 1, 0, 0).unwrap(); + assert_eq!( + t.msgctl(qid, IPC_STAT, 1, 0, 0).unwrap().unwrap().cbytes, + MSGMNB, + ); + + let received = t.msgrcv(qid, 1, 0, MSG_NOERROR, 1, 0, 0).unwrap(); + assert_eq!(received.data, [0x33]); + + // The complete first message left the queue even though the caller + // requested one byte, so another maximum-sized send must fit. + t.msgsnd(qid, 3, &full_message, 0, 1, 0, 0).unwrap(); + let info = t.msgctl(qid, IPC_STAT, 1, 0, 0).unwrap().unwrap(); + assert_eq!(info.qnum, 2); + assert_eq!(info.cbytes, MSGMNB); + } + #[test] fn test_msgrcv_empty_nowait() { let mut t = IpcTable::new(); @@ -1245,8 +1838,7 @@ mod tests { .msgget(IPC_PRIVATE, IPC_CREAT | 0o666, 1, 1000, 1000) .unwrap(); - t.msgctl_set(qid, 2000, 2001, 0o1764, 8192, 1000) - .unwrap(); + t.msgctl_set(qid, 2000, 2001, 0o1764, 8192, 1000).unwrap(); let info = t.msgctl(qid, IPC_STAT, 1, 0, 0).unwrap().unwrap(); assert_eq!(info.uid, 2000); assert_eq!(info.gid, 2001); @@ -1264,8 +1856,7 @@ mod tests { Err(Errno::EPERM) ); - t.msgctl_set(qid, 0, 0, 0o600, MSGMNB + 1, 0) - .unwrap(); + t.msgctl_set(qid, 0, 0, 0o600, MSGMNB + 1, 0).unwrap(); let info = t.msgctl(qid, IPC_STAT, 1, 0, 0).unwrap().unwrap(); assert_eq!(info.uid, 0); assert_eq!(info.gid, 0); @@ -1281,6 +1872,96 @@ mod tests { assert_eq!(t.msgctl(qid, IPC_STAT, 1, 0, 0).unwrap_err(), Errno::EINVAL); } + #[test] + fn pinned_msgsnd_returns_eidrm_after_rmid_and_immediate_id_reuse() { + let mut t = IpcTable::new(); + let qid = t.msgget(IPC_PRIVATE, IPC_CREAT | 0o666, 1, 0, 0).unwrap(); + t.msgctl_set(qid, 0, 0, 0o666, 1, 0).unwrap(); + t.msgsnd(qid, 1, b"x", 0, 1, 0, 0).unwrap(); + + let pin = t.pin_msg_queue(qid).unwrap(); + let removed_generation = pin.generation; + assert_eq!( + t.msgsnd_pinned(&pin, 2, b"y", 0, 1, 0, 0), + Err(Errno::EAGAIN) + ); + + t.msgctl(qid, IPC_RMID, 1, 0, 0).unwrap(); + assert!(!t.msg_queues.contains_key(&qid)); + assert_eq!(t.removed_msg_queues[&removed_generation].active_pins, 1); + + t.set_next_public_id_for_test(qid); + let replacement = t.msgget(IPC_PRIVATE, IPC_CREAT | 0o666, 2, 0, 0).unwrap(); + assert_eq!(replacement, qid); + assert_ne!(t.msg_queues[&replacement].generation, removed_generation); + + assert_eq!( + t.msgsnd_pinned(&pin, 2, b"old", 0, 1, 0, 0), + Err(Errno::EIDRM) + ); + t.msgsnd(replacement, 3, b"new", 0, 2, 0, 0).unwrap(); + let received = t.msgrcv(replacement, 3, 0, 0, 2, 0, 0).unwrap(); + assert_eq!(received.mtype, 3); + assert_eq!(received.data, b"new"); + + t.release_msg_queue_pin(pin).unwrap(); + assert!(!t.removed_msg_queues.contains_key(&removed_generation)); + } + + #[test] + fn pinned_msgrcv_returns_eidrm_without_consuming_reused_queue() { + let mut t = IpcTable::new(); + let qid = t.msgget(IPC_PRIVATE, IPC_CREAT | 0o666, 1, 0, 0).unwrap(); + let pin = t.pin_msg_queue(qid).unwrap(); + let removed_generation = pin.generation; + + assert_eq!( + t.msgrcv_pinned(&pin, 16, 0, 0, 1, 0, 0).unwrap_err(), + Errno::EAGAIN + ); + t.msgctl(qid, IPC_RMID, 1, 0, 0).unwrap(); + + t.set_next_public_id_for_test(qid); + let replacement = t.msgget(IPC_PRIVATE, IPC_CREAT | 0o666, 2, 0, 0).unwrap(); + assert_eq!(replacement, qid); + t.msgsnd(replacement, 7, b"replacement", 0, 2, 0, 0) + .unwrap(); + + assert_eq!( + t.msgrcv_pinned(&pin, 16, 0, 0, 1, 0, 0).unwrap_err(), + Errno::EIDRM + ); + let received = t.msgrcv(replacement, 16, 0, 0, 2, 0, 0).unwrap(); + assert_eq!(received.mtype, 7); + assert_eq!(received.data, b"replacement"); + + t.release_msg_queue_pin(pin).unwrap(); + assert!(!t.removed_msg_queues.contains_key(&removed_generation)); + } + + #[test] + fn message_queue_pin_release_is_exact_and_reclaims_after_last_pin() { + let mut t = IpcTable::new(); + let qid = t.msgget(IPC_PRIVATE, IPC_CREAT | 0o666, 1, 0, 0).unwrap(); + let first = t.pin_msg_queue(qid).unwrap(); + let duplicate = first.duplicate_for_exact_release_test(); + let second = t.pin_msg_queue(qid).unwrap(); + let generation = first.generation; + + assert_eq!(t.msg_queues[&qid].active_pins, 2); + t.msgctl(qid, IPC_RMID, 1, 0, 0).unwrap(); + assert_eq!(t.removed_msg_queues[&generation].active_pins, 2); + + t.release_msg_queue_pin(first).unwrap(); + assert_eq!(t.removed_msg_queues[&generation].active_pins, 1); + assert_eq!(t.release_msg_queue_pin(duplicate), Err(Errno::EINVAL)); + assert_eq!(t.removed_msg_queues[&generation].active_pins, 1); + + t.release_msg_queue_pin(second).unwrap(); + assert!(!t.removed_msg_queues.contains_key(&generation)); + assert!(t.active_msg_pins.is_empty()); + } + // ── Semaphore Tests ── #[test] @@ -1578,10 +2259,7 @@ mod tests { )); // Root reads the values only to verify the write; the operation above // succeeded using the owning process's write-only permission. - let values = match t - .semctl(write_only, 0, GETALL, 1, 0, 0, 0) - .unwrap() - { + let values = match t.semctl(write_only, 0, GETALL, 1, 0, 0, 0).unwrap() { SemCtlResult::All(values) => values, _ => panic!("expected all semaphore values"), }; @@ -1628,6 +2306,138 @@ mod tests { ); } + #[test] + fn pinned_semop_returns_eidrm_after_rmid_and_immediate_id_reuse() { + let mut t = IpcTable::new(); + let semid = t + .semget(IPC_PRIVATE, 1, IPC_CREAT | 0o666, 1, 0, 0) + .unwrap(); + let pin = t.pin_sem_set(semid).unwrap(); + let removed_generation = pin.generation; + let decrement = [SemOp { + num: 0, + op: -1, + flg: 0, + }]; + + assert_eq!( + t.semop_pinned(&pin, &decrement, 1, 0, 0), + Err(Errno::EAGAIN) + ); + t.semctl(semid, 0, IPC_RMID, 1, 0, 0, 0).unwrap(); + assert!(!t.sem_sets.contains_key(&semid)); + assert_eq!(t.removed_sem_sets[&removed_generation].active_pins, 1); + + t.set_next_public_id_for_test(semid); + let replacement = t + .semget(IPC_PRIVATE, 1, IPC_CREAT | 0o666, 2, 0, 0) + .unwrap(); + assert_eq!(replacement, semid); + assert_ne!(t.sem_sets[&replacement].generation, removed_generation); + t.semctl(replacement, 0, SETVAL, 2, 2, 0, 0).unwrap(); + + assert_eq!(t.semop_pinned(&pin, &decrement, 1, 0, 0), Err(Errno::EIDRM)); + t.semop(replacement, &decrement, 2, 0, 0).unwrap(); + assert!(matches!( + t.semctl(replacement, 0, GETVAL, 2, 0, 0, 0), + Ok(SemCtlResult::Value(1)) + )); + + t.release_sem_set_pin(pin).unwrap(); + assert!(!t.removed_sem_sets.contains_key(&removed_generation)); + } + + #[test] + fn semaphore_pin_release_is_exact_and_reclaims_after_last_pin() { + let mut t = IpcTable::new(); + let semid = t + .semget(IPC_PRIVATE, 1, IPC_CREAT | 0o666, 1, 0, 0) + .unwrap(); + let first = t.pin_sem_set(semid).unwrap(); + let duplicate = first.duplicate_for_exact_release_test(); + let second = t.pin_sem_set(semid).unwrap(); + let generation = first.generation; + + assert_eq!(t.sem_sets[&semid].active_pins, 2); + t.semctl(semid, 0, IPC_RMID, 1, 0, 0, 0).unwrap(); + assert_eq!(t.removed_sem_sets[&generation].active_pins, 2); + + t.release_sem_set_pin(first).unwrap(); + assert_eq!(t.removed_sem_sets[&generation].active_pins, 1); + assert_eq!(t.release_sem_set_pin(duplicate), Err(Errno::EINVAL)); + assert_eq!(t.removed_sem_sets[&generation].active_pins, 1); + + t.release_sem_set_pin(second).unwrap(); + assert!(!t.removed_sem_sets.contains_key(&generation)); + assert!(t.active_sem_pins.is_empty()); + } + + #[test] + fn forced_process_removal_releases_blocked_message_and_semaphore_pins() { + use wasm_posix_shared::abi::extended_syscalls::{SYS_MSGSND, SYS_SEMOP}; + + let mut processes = crate::process_table::ProcessTable::new(); + let pid = processes.create_process().unwrap(); + let worker_tid = pid + 1; + processes + .get_mut(pid) + .unwrap() + .add_thread(crate::process::ThreadInfo::new(worker_tid, 0, 0, 0)); + let (qid, queue_generation, semid, semaphore_generation) = { + let ipc = unsafe { global_ipc_table() }; + let qid = ipc + .msgget(IPC_PRIVATE, IPC_CREAT | 0o600, pid, 0, 0) + .unwrap(); + let semid = ipc + .semget(IPC_PRIVATE, 1, IPC_CREAT | 0o600, pid, 0, 0) + .unwrap(); + ( + qid, + ipc.msg_queues[&qid].generation, + semid, + ipc.sem_sets[&semid].generation, + ) + }; + + crate::syscalls::ensure_blocking_retry_sysv_message_binding( + processes.get_mut(pid).unwrap(), + pid, + SYS_MSGSND, + qid, + ) + .unwrap(); + crate::syscalls::ensure_blocking_retry_sysv_semaphore_binding( + processes.get_mut(pid).unwrap(), + worker_tid, + SYS_SEMOP, + semid, + ) + .unwrap(); + { + let ipc = unsafe { global_ipc_table() }; + ipc.msgctl(qid, IPC_RMID, pid, 0, 0).unwrap(); + ipc.semctl(semid, 0, IPC_RMID, pid, 0, 0, 0).unwrap(); + assert_eq!(ipc.removed_msg_queues[&queue_generation].active_pins, 1,); + assert_eq!(ipc.removed_sem_sets[&semaphore_generation].active_pins, 1,); + } + + processes.remove_process(pid).unwrap(); + + let ipc = unsafe { global_ipc_table() }; + assert!(!ipc.removed_msg_queues.contains_key(&queue_generation)); + assert!(!ipc.removed_sem_sets.contains_key(&semaphore_generation)); + assert!( + ipc.active_msg_pins + .values() + .all(|generation| *generation != queue_generation), + ); + assert!( + ipc.active_sem_pins + .values() + .all(|generation| *generation != semaphore_generation), + ); + } + #[test] fn test_semctl_getpid() { let mut t = IpcTable::new(); @@ -1778,10 +2588,7 @@ mod tests { assert_eq!(info.mode, 0o640); assert_eq!(info.segsz, 4096); - assert_eq!( - t.shmctl_set(id, 3000, 3001, 0o600, 3000), - Err(Errno::EPERM) - ); + assert_eq!(t.shmctl_set(id, 3000, 3001, 0o600, 3000), Err(Errno::EPERM)); let info = t.shmctl(id, IPC_STAT, 1, 0, 0).unwrap().unwrap(); assert_eq!(info.uid, 2000); assert_eq!(info.gid, 2001); @@ -1809,4 +2616,76 @@ mod tests { .unwrap(); assert_ne!(id1, id2); } + + #[test] + fn shared_public_id_allocator_skips_cross_class_collisions() { + let mut t = IpcTable::new(); + let msgid = t.msgget(IPC_PRIVATE, IPC_CREAT | 0o666, 1, 0, 0).unwrap(); + assert_eq!(msgid, 0); + + t.set_next_public_id_for_test(msgid); + let semid = t + .semget(IPC_PRIVATE, 1, IPC_CREAT | 0o666, 1, 0, 0) + .unwrap(); + assert_eq!(semid, 1); + + t.set_next_public_id_for_test(msgid); + let shmid = t + .shmget(IPC_PRIVATE, 1, IPC_CREAT | 0o666, 1, 0, 0) + .unwrap(); + assert_eq!(shmid, 2); + } + + #[test] + fn shared_public_id_allocator_wraps_without_overwriting_live_ids() { + let mut t = IpcTable::new(); + assert_eq!( + t.msgget(IPC_PRIVATE, IPC_CREAT | 0o666, 1, 0, 0).unwrap(), + 0 + ); + assert_eq!( + t.semget(IPC_PRIVATE, 1, IPC_CREAT | 0o666, 1, 0, 0) + .unwrap(), + 1 + ); + + t.set_next_public_id_for_test(i32::MAX); + assert_eq!( + t.shmget(IPC_PRIVATE, 1, IPC_CREAT | 0o666, 1, 0, 0) + .unwrap(), + i32::MAX + ); + assert_eq!( + t.msgget(IPC_PRIVATE, IPC_CREAT | 0o666, 1, 0, 0).unwrap(), + 2 + ); + assert!(t.msg_queues.contains_key(&0)); + assert!(t.sem_sets.contains_key(&1)); + assert!(t.shm_segments.contains_key(&i32::MAX)); + } + + #[test] + fn shared_public_id_allocator_reports_bounded_exhaustion() { + let mut t = IpcTable::new(); + assert_eq!( + t.msgget(IPC_PRIVATE, IPC_CREAT | 0o666, 1, 0, 0).unwrap(), + 0 + ); + assert_eq!( + t.semget(IPC_PRIVATE, 1, IPC_CREAT | 0o666, 1, 0, 0) + .unwrap(), + 1 + ); + assert_eq!( + t.shmget(IPC_PRIVATE, 1, IPC_CREAT | 0o666, 1, 0, 0) + .unwrap(), + 2 + ); + + t.set_next_public_id_for_test(0); + assert_eq!(t.alloc_id_bounded(2), Err(Errno::ENOSPC)); + assert!(t.msg_queues.contains_key(&0)); + assert!(t.sem_sets.contains_key(&1)); + assert!(t.shm_segments.contains_key(&2)); + } } diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 6bae3b2477..41a4c7277e 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -6,6 +6,8 @@ extern crate alloc; extern crate wasm_posix_shared; pub mod audio; +pub(crate) mod blocked_retry; +pub(crate) mod channel_result; pub(crate) mod channel_scratch; pub(crate) mod descriptor_backing; pub mod devfs; @@ -35,6 +37,7 @@ pub(crate) mod socket_wire; pub mod spawn; pub mod syscalls; pub mod terminal; +pub(crate) mod transfer; pub mod unix_socket; pub mod wakeup; diff --git a/crates/kernel/src/mqueue.rs b/crates/kernel/src/mqueue.rs index 3f4bf49b52..144f7d6c22 100644 --- a/crates/kernel/src/mqueue.rs +++ b/crates/kernel/src/mqueue.rs @@ -7,7 +7,7 @@ use alloc::collections::BTreeMap; use alloc::string::String; use alloc::vec::Vec; -use wasm_posix_shared::{signal::NSIG, Errno}; +use wasm_posix_shared::{platform_limits, signal::NSIG, Errno}; // Access mode flags const O_RDONLY: u32 = 0; @@ -29,6 +29,8 @@ const DEFAULT_MSGSIZE: u32 = 8192; /// Descriptor base — high range to avoid kernel fd conflicts. pub const MQD_BASE: u32 = 0x40000000; +/// mqd_t and the channel result are signed 32-bit values. +const MQD_MAX: u32 = i32::MAX as u32; /// A single message in a queue. struct MqMessage { @@ -36,26 +38,75 @@ struct MqMessage { priority: u32, } -/// A named message queue. +/// Stable identity for one queue object. +/// +/// Names may be unlinked and reused while descriptors or blocked operations +/// still refer to the old object, so the name itself cannot be the identity. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct MqQueueId(u64); + +/// Stable identity for one active-operation retention. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct MqPinId(u64); + +/// A message queue object, independently of whether it still has a name. #[allow(dead_code)] struct MqQueue { - name: String, maxmsg: u32, msgsize: u32, messages: Vec, - unlinked: bool, + linked: bool, open_count: u32, + active_pins: u32, notification: Option, mode: u32, } /// Per-descriptor state. +#[derive(Clone, Copy)] struct MqDescriptor { - queue_name: String, + queue_id: MqQueueId, + access_mode: u32, + nonblock: bool, +} + +/// Opaque authority retained by one potentially blocking operation. +/// +/// The copied descriptor policy and stable queue ID remain valid after the +/// public mqd is closed or reused. Fields stay private so sibling modules can +/// only obtain a capability through [`MqueueTable::pin_descriptor`]. +pub(crate) struct PinnedMqueueDescriptor { + pin_id: MqPinId, + queue_id: MqQueueId, + access_mode: u32, + nonblock: bool, +} + +#[cfg(test)] +impl PinnedMqueueDescriptor { + fn duplicate_for_exact_release_test(&self) -> Self { + Self { + pin_id: self.pin_id, + queue_id: self.queue_id, + access_mode: self.access_mode, + nonblock: self.nonblock, + } + } +} + +#[derive(Clone, Copy)] +struct ResolvedMqDescriptor { + queue_id: MqQueueId, access_mode: u32, nonblock: bool, } +#[derive(Clone, Copy)] +enum MqDescriptorAuthority<'a> { + Public(u32), + Pinned(&'a PinnedMqueueDescriptor), +} + /// One-shot signal notification registration. #[derive(Clone, Copy, Debug)] pub struct MqNotification { @@ -89,18 +140,26 @@ pub struct MqSendResult { /// Global message queue table. pub struct MqueueTable { - queues: BTreeMap, + names: BTreeMap, + queues: BTreeMap, descriptors: BTreeMap, - next_mqd: u32, + active_pins: BTreeMap, + next_queue_id: Option, + next_mqd: Option, + next_pin_id: Option, pending_notification: Option, } impl MqueueTable { pub const fn new() -> Self { MqueueTable { + names: BTreeMap::new(), queues: BTreeMap::new(), descriptors: BTreeMap::new(), - next_mqd: MQD_BASE, + active_pins: BTreeMap::new(), + next_queue_id: Some(MqQueueId(1)), + next_mqd: Some(MQD_BASE), + next_pin_id: Some(MqPinId(1)), pending_notification: None, } } @@ -115,6 +174,87 @@ impl MqueueTable { self.pending_notification.take() } + fn next_free_mqd(&self) -> Result<(u32, Option), Errno> { + let mut candidate = self.next_mqd.ok_or(Errno::EMFILE)?; + if !(MQD_BASE..=MQD_MAX).contains(&candidate) { + return Err(Errno::EIO); + } + loop { + if !self.descriptors.contains_key(&candidate) { + let next = candidate.checked_add(1).filter(|value| *value <= MQD_MAX); + return Ok((candidate, next)); + } + candidate = candidate + .checked_add(1) + .filter(|value| *value <= MQD_MAX) + .ok_or(Errno::EMFILE)?; + } + } + + fn next_free_queue_id(&self) -> Result<(MqQueueId, Option), Errno> { + let mut candidate = self.next_queue_id.ok_or(Errno::ENFILE)?; + if candidate.0 == 0 { + return Err(Errno::EIO); + } + loop { + if !self.queues.contains_key(&candidate) { + return Ok((candidate, candidate.0.checked_add(1).map(MqQueueId))); + } + candidate = MqQueueId(candidate.0.checked_add(1).ok_or(Errno::ENFILE)?); + } + } + + fn next_free_pin_id(&self) -> Result<(MqPinId, Option), Errno> { + let mut candidate = self.next_pin_id.ok_or(Errno::EOVERFLOW)?; + if candidate.0 == 0 { + return Err(Errno::EIO); + } + loop { + if !self.active_pins.contains_key(&candidate) { + return Ok((candidate, candidate.0.checked_add(1).map(MqPinId))); + } + candidate = MqPinId(candidate.0.checked_add(1).ok_or(Errno::EOVERFLOW)?); + } + } + + fn resolve_descriptor( + &self, + authority: MqDescriptorAuthority<'_>, + ) -> Result { + match authority { + MqDescriptorAuthority::Public(mqd) => { + let descriptor = self.descriptors.get(&mqd).ok_or(Errno::EBADF)?; + Ok(ResolvedMqDescriptor { + queue_id: descriptor.queue_id, + access_mode: descriptor.access_mode, + nonblock: descriptor.nonblock, + }) + } + MqDescriptorAuthority::Pinned(pinned) => { + if self.active_pins.get(&pinned.pin_id) != Some(&pinned.queue_id) { + return Err(Errno::EBADF); + } + Ok(ResolvedMqDescriptor { + queue_id: pinned.queue_id, + access_mode: pinned.access_mode, + nonblock: pinned.nonblock, + }) + } + } + } + + fn reclaim_queue_if_unused(&mut self, queue_id: MqQueueId) { + let should_reclaim = self + .queues + .get(&queue_id) + .map(|queue| !queue.linked && queue.open_count == 0 && queue.active_pins == 0) + .unwrap_or(false); + if should_reclaim { + debug_assert!(!self.names.values().any(|id| *id == queue_id)); + self.queues.remove(&queue_id); + } + } + /// Returns true if `fd` is a message queue descriptor. pub fn is_mqd(&self, fd: u32) -> bool { self.descriptors.contains_key(&fd) @@ -123,7 +263,83 @@ impl MqueueTable { /// Returns `Some(true)` if the descriptor has O_NONBLOCK set, /// `Some(false)` if blocking, or `None` if the descriptor is unknown. pub fn is_nonblock(&self, mqd: u32) -> Option { - self.descriptors.get(&mqd).map(|d| d.nonblock) + self.resolve_descriptor(MqDescriptorAuthority::Public(mqd)) + .ok() + .map(|descriptor| descriptor.nonblock) + } + + /// Return the captured O_NONBLOCK policy for one pinned operation. + pub(crate) fn pinned_is_nonblock( + &self, + pinned: &PinnedMqueueDescriptor, + ) -> Result { + self.resolve_descriptor(MqDescriptorAuthority::Pinned(pinned)) + .map(|descriptor| descriptor.nonblock) + } + + /// Return the authoritative maximum message size for one descriptor. + /// + /// Host marshalling uses this only to size a kernel-owned transfer before + /// dispatch. Queue policy and the final access-mode/error decision remain + /// in [`Self::mq_send`] and [`Self::mq_receive`]. + pub fn descriptor_msgsize(&self, mqd: u32) -> Result { + self.descriptor_msgsize_for(MqDescriptorAuthority::Public(mqd)) + } + + /// Return the exact queue limit retained by a pinned operation. + pub(crate) fn pinned_descriptor_msgsize( + &self, + pinned: &PinnedMqueueDescriptor, + ) -> Result { + self.descriptor_msgsize_for(MqDescriptorAuthority::Pinned(pinned)) + } + + fn descriptor_msgsize_for(&self, authority: MqDescriptorAuthority<'_>) -> Result { + let descriptor = self.resolve_descriptor(authority)?; + self.queues + .get(&descriptor.queue_id) + .map(|queue| queue.msgsize) + .ok_or(Errno::EBADF) + } + + /// Retain one descriptor's exact queue object and policy for a blocked + /// operation. + pub(crate) fn pin_descriptor(&mut self, mqd: u32) -> Result { + let descriptor = self.resolve_descriptor(MqDescriptorAuthority::Public(mqd))?; + let (pin_id, next_pin_id) = self.next_free_pin_id()?; + let queue = self + .queues + .get_mut(&descriptor.queue_id) + .ok_or(Errno::EBADF)?; + queue.active_pins = queue.active_pins.checked_add(1).ok_or(Errno::EOVERFLOW)?; + + self.active_pins.insert(pin_id, descriptor.queue_id); + self.next_pin_id = next_pin_id; + Ok(PinnedMqueueDescriptor { + pin_id, + queue_id: descriptor.queue_id, + access_mode: descriptor.access_mode, + nonblock: descriptor.nonblock, + }) + } + + /// Release exactly one active-operation retention. + /// + /// Consuming the opaque value makes duplicate release unavailable to + /// production callers. The registry check also fails closed if a stale or + /// forged capability is presented by future unsafe code. + pub(crate) fn release_pinned_descriptor( + &mut self, + pinned: PinnedMqueueDescriptor, + ) -> Result<(), Errno> { + if self.active_pins.get(&pinned.pin_id) != Some(&pinned.queue_id) { + return Err(Errno::EBADF); + } + let queue = self.queues.get_mut(&pinned.queue_id).ok_or(Errno::EIO)?; + queue.active_pins = queue.active_pins.checked_sub(1).ok_or(Errno::EIO)?; + self.active_pins.remove(&pinned.pin_id); + self.reclaim_queue_if_unused(pinned.queue_id); + Ok(()) } /// Open or create a named message queue. @@ -142,11 +358,15 @@ impl MqueueTable { let exclusive = (flags & O_EXCL) != 0; let nonblock = (flags & O_NONBLOCK) != 0; - if name.is_empty() || name.len() > 255 { + if name.is_empty() { return Err(Errno::EINVAL); } + if name.len() >= platform_limits::NAME_MAX_BYTES { + return Err(Errno::ENAMETOOLONG); + } - let exists = self.queues.get(name).map(|q| !q.unlinked).unwrap_or(false); + let existing_queue_id = self.names.get(name).copied(); + let exists = existing_queue_id.is_some(); if creating && exclusive && exists { return Err(Errno::EEXIST); @@ -156,11 +376,9 @@ impl MqueueTable { return Err(Errno::ENOENT); } - if exists { - // Open existing queue - self.queues.get_mut(name).unwrap().open_count += 1; + let new_queue_attributes = if exists { + None } else { - // Create new queue let maxmsg = if has_attr { attr_maxmsg } else { @@ -174,91 +392,193 @@ impl MqueueTable { if maxmsg == 0 || msgsize == 0 { return Err(Errno::EINVAL); } + // WHY: mq_receive removes a message before returning its byte + // count. A queue whose configured message size cannot be reported + // through the signed-i32 channel result would make that side + // effect impossible to publish without truncation. + if msgsize as usize > platform_limits::MAX_REPORTABLE_TRANSFER_BYTES { + return Err(Errno::EINVAL); + } + Some((maxmsg, msgsize)) + }; + + // WHY: choose every identity before mutating the name, object, or open + // count. Exhaustion can then fail without publishing a half-open queue + // or overwriting an existing descriptor at a wrapped counter value. + let (mqd, next_mqd) = self.next_free_mqd()?; + let new_queue_identity = if new_queue_attributes.is_some() { + Some(self.next_free_queue_id()?) + } else { + None + }; + let queue_id = if let Some(queue_id) = existing_queue_id { + let queue = self.queues.get_mut(&queue_id).ok_or(Errno::EIO)?; + queue.open_count = queue.open_count.checked_add(1).ok_or(Errno::EMFILE)?; + queue_id + } else { + let (queue_id, next_queue_id) = new_queue_identity.ok_or(Errno::EIO)?; + let (maxmsg, msgsize) = new_queue_attributes.ok_or(Errno::EIO)?; let queue = MqQueue { - name: String::from(name), maxmsg, msgsize, messages: Vec::new(), - unlinked: false, + linked: true, open_count: 1, + active_pins: 0, notification: None, mode, }; - self.queues.insert(String::from(name), queue); - } + self.queues.insert(queue_id, queue); + self.names.insert(String::from(name), queue_id); + self.next_queue_id = next_queue_id; + queue_id + }; - let mqd = self.next_mqd; - self.next_mqd += 1; self.descriptors.insert( mqd, MqDescriptor { - queue_name: String::from(name), + queue_id, access_mode, nonblock, }, ); + self.next_mqd = next_mqd; Ok(mqd) } /// Close a message queue descriptor. pub fn mq_close(&mut self, mqd: u32) -> Result<(), Errno> { - let desc = self.descriptors.remove(&mqd).ok_or(Errno::EBADF)?; - - if let Some(queue) = self.queues.get_mut(&desc.queue_name) { - queue.open_count = queue.open_count.saturating_sub(1); - if queue.open_count == 0 && queue.unlinked { - self.queues.remove(&desc.queue_name); - } - } + let descriptor = *self.descriptors.get(&mqd).ok_or(Errno::EBADF)?; + let queue = self + .queues + .get_mut(&descriptor.queue_id) + .ok_or(Errno::EIO)?; + queue.open_count = queue.open_count.checked_sub(1).ok_or(Errno::EIO)?; + self.descriptors.remove(&mqd); + self.reclaim_queue_if_unused(descriptor.queue_id); Ok(()) } /// Unlink a named message queue. pub fn mq_unlink(&mut self, name: &str) -> Result<(), Errno> { - let queue = self.queues.get_mut(name).ok_or(Errno::ENOENT)?; - if queue.unlinked { - return Err(Errno::ENOENT); - } - - if queue.open_count == 0 { - self.queues.remove(name); - } else { - queue.unlinked = true; - } + let queue_id = self.names.remove(name).ok_or(Errno::ENOENT)?; + let queue = self.queues.get_mut(&queue_id).ok_or(Errno::EIO)?; + queue.linked = false; + self.reclaim_queue_if_unused(queue_id); Ok(()) } /// Send a message. Returns notification to fire if queue was empty. pub fn mq_send(&mut self, mqd: u32, data: &[u8], priority: u32) -> Result { - let desc = self.descriptors.get(&mqd).ok_or(Errno::EBADF)?; - if desc.access_mode == O_RDONLY { + self.mq_send_for_with_reserve( + MqDescriptorAuthority::Public(mqd), + data, + priority, + |message, additional| { + message + .try_reserve_exact(additional) + .map_err(|_| Errno::ENOMEM) + }, + |messages| messages.try_reserve(1).map_err(|_| Errno::ENOMEM), + ) + } + + /// Send through a pinned descriptor without looking up a numeric mqd. + pub(crate) fn mq_send_pinned( + &mut self, + pinned: &PinnedMqueueDescriptor, + data: &[u8], + priority: u32, + ) -> Result { + self.mq_send_for_with_reserve( + MqDescriptorAuthority::Pinned(pinned), + data, + priority, + |message, additional| { + message + .try_reserve_exact(additional) + .map_err(|_| Errno::ENOMEM) + }, + |messages| messages.try_reserve(1).map_err(|_| Errno::ENOMEM), + ) + } + + #[cfg(test)] + fn mq_send_with_reserve( + &mut self, + mqd: u32, + data: &[u8], + priority: u32, + reserve_message: impl FnOnce(&mut Vec, usize) -> Result<(), Errno>, + reserve_slot: impl FnOnce(&mut Vec) -> Result<(), Errno>, + ) -> Result { + self.mq_send_for_with_reserve( + MqDescriptorAuthority::Public(mqd), + data, + priority, + reserve_message, + reserve_slot, + ) + } + + fn mq_send_for_with_reserve( + &mut self, + authority: MqDescriptorAuthority<'_>, + data: &[u8], + priority: u32, + reserve_message: impl FnOnce(&mut Vec, usize) -> Result<(), Errno>, + reserve_slot: impl FnOnce(&mut Vec) -> Result<(), Errno>, + ) -> Result { + let descriptor = self.resolve_descriptor(authority)?; + if descriptor.access_mode == O_RDONLY { return Err(Errno::EBADF); } - let nonblock = desc.nonblock; - let queue = self.queues.get_mut(&desc.queue_name).ok_or(Errno::EBADF)?; + let queue = self + .queues + .get_mut(&descriptor.queue_id) + .ok_or(Errno::EBADF)?; if data.len() > queue.msgsize as usize { return Err(Errno::EMSGSIZE); } if queue.messages.len() >= queue.maxmsg as usize { - if nonblock { + if descriptor.nonblock { return Err(Errno::EAGAIN); } // Return EAGAIN for host retry. return Err(Errno::EAGAIN); } + // WHY: allocation failure is an ordinary ENOMEM result, not a Wasm + // trap. Finish both potentially allocating preparations before + // inserting the message or consuming the one-shot notification. + let mut message_data = Vec::new(); + reserve_message(&mut message_data, data.len())?; + if message_data.capacity() < data.len() { + return Err(Errno::ENOMEM); + } + message_data.extend_from_slice(data); + reserve_slot(&mut queue.messages)?; + if queue + .messages + .capacity() + .saturating_sub(queue.messages.len()) + < 1 + { + return Err(Errno::ENOMEM); + } + let was_empty = queue.messages.is_empty(); // Insert maintaining priority order (highest first) let msg = MqMessage { - data: Vec::from(data), + data: message_data, priority, }; let pos = queue.messages.iter().position(|m| priority > m.priority); @@ -279,20 +599,39 @@ impl MqueueTable { /// Receive the highest-priority message. pub fn mq_receive(&mut self, mqd: u32, buf_size: u32) -> Result { - let desc = self.descriptors.get(&mqd).ok_or(Errno::EBADF)?; - if desc.access_mode == O_WRONLY { + self.mq_receive_for(MqDescriptorAuthority::Public(mqd), buf_size) + } + + /// Receive through a pinned descriptor without looking up a numeric mqd. + pub(crate) fn mq_receive_pinned( + &mut self, + pinned: &PinnedMqueueDescriptor, + buf_size: u32, + ) -> Result { + self.mq_receive_for(MqDescriptorAuthority::Pinned(pinned), buf_size) + } + + fn mq_receive_for( + &mut self, + authority: MqDescriptorAuthority<'_>, + buf_size: u32, + ) -> Result { + let descriptor = self.resolve_descriptor(authority)?; + if descriptor.access_mode == O_WRONLY { return Err(Errno::EBADF); } - let nonblock = desc.nonblock; - let queue = self.queues.get_mut(&desc.queue_name).ok_or(Errno::EBADF)?; + let queue = self + .queues + .get_mut(&descriptor.queue_id) + .ok_or(Errno::EBADF)?; if buf_size < queue.msgsize { return Err(Errno::EMSGSIZE); } if queue.messages.is_empty() { - if nonblock { + if descriptor.nonblock { return Err(Errno::EAGAIN); } return Err(Errno::EAGAIN); @@ -315,7 +654,7 @@ impl MqueueTable { value_bits: u64, ) -> Result<(), Errno> { let desc = self.descriptors.get(&mqd).ok_or(Errno::EBADF)?; - let queue = self.queues.get_mut(&desc.queue_name).ok_or(Errno::EBADF)?; + let queue = self.queues.get_mut(&desc.queue_id).ok_or(Errno::EBADF)?; match sigev_notify { None => { @@ -355,10 +694,10 @@ impl MqueueTable { /// Get/set attributes on a descriptor. pub fn mq_getsetattr(&mut self, mqd: u32, new_flags: Option) -> Result { - let desc = self.descriptors.get_mut(&mqd).ok_or(Errno::EBADF)?; - let queue = self.queues.get(&desc.queue_name).ok_or(Errno::EBADF)?; + let descriptor = *self.descriptors.get(&mqd).ok_or(Errno::EBADF)?; + let queue = self.queues.get(&descriptor.queue_id).ok_or(Errno::EBADF)?; - let old_flags = if desc.nonblock { O_NONBLOCK } else { 0 }; + let old_flags = if descriptor.nonblock { O_NONBLOCK } else { 0 }; let result = MqAttr { flags: old_flags, maxmsg: queue.maxmsg, @@ -367,7 +706,8 @@ impl MqueueTable { }; if let Some(flags) = new_flags { - desc.nonblock = (flags & O_NONBLOCK) != 0; + self.descriptors.get_mut(&mqd).ok_or(Errno::EBADF)?.nonblock = + (flags & O_NONBLOCK) != 0; } Ok(result) @@ -383,6 +723,26 @@ impl MqueueTable { } } } + + #[cfg(test)] + fn linked_queue_for_test(&self, name: &str) -> &MqQueue { + let queue_id = self.names.get(name).expect("linked queue name"); + self.queues.get(queue_id).expect("linked queue object") + } + + #[cfg(test)] + fn set_next_mqd_for_test(&mut self, next: Option) { + assert!(next + .map(|value| (MQD_BASE..=MQD_MAX).contains(&value)) + .unwrap_or(true)); + self.next_mqd = next; + } + + #[cfg(test)] + fn set_next_queue_id_for_test(&mut self, next: Option) { + assert!(next.map(|value| value > 0).unwrap_or(true)); + self.next_queue_id = next.map(MqQueueId); + } } // --------------------------------------------------------------------------- @@ -491,6 +851,9 @@ mod tests { let mqd = t .mq_open("/size", O_CREAT | O_RDWR, 0o644, 10, 4, true) .unwrap(); + assert_eq!(t.descriptor_msgsize(mqd), Ok(4)); + let missing = t.descriptor_msgsize(MQD_BASE + 999); + assert_eq!(missing, Err(Errno::EBADF)); // Message too large assert_eq!(t.mq_send(mqd, b"12345", 1).unwrap_err(), Errno::EMSGSIZE); @@ -500,6 +863,78 @@ mod tests { assert_eq!(t.mq_receive(mqd, 3).unwrap_err(), Errno::EMSGSIZE); } + #[test] + fn send_allocation_failures_preserve_queue_and_notification() { + let mut t = MqueueTable::new(); + let mqd = t + .mq_open("/oom", O_CREAT | O_RDWR, 0o644, 10, 64, true) + .unwrap(); + t.mq_notify(mqd, 42, Some(SIGEV_SIGNAL), 10, 0x1234) + .unwrap(); + + assert_eq!( + t.mq_send_with_reserve( + mqd, + b"payload", + 1, + |_, _| Err(Errno::ENOMEM), + |_| panic!("slot reservation must follow message reservation"), + ) + .unwrap_err(), + Errno::ENOMEM, + ); + assert!(t.linked_queue_for_test("/oom").messages.is_empty()); + assert!(t.linked_queue_for_test("/oom").notification.is_some()); + + assert_eq!( + t.mq_send_with_reserve( + mqd, + b"payload", + 1, + |message, additional| { + message + .try_reserve_exact(additional) + .map_err(|_| Errno::ENOMEM) + }, + |_| Err(Errno::ENOMEM), + ) + .unwrap_err(), + Errno::ENOMEM, + ); + assert!(t.linked_queue_for_test("/oom").messages.is_empty()); + assert!(t.linked_queue_for_test("/oom").notification.is_some()); + + let result = t.mq_send(mqd, b"payload", 1).unwrap(); + assert!(result.notification.is_some()); + assert_eq!(t.linked_queue_for_test("/oom").messages.len(), 1); + } + + #[test] + fn queue_message_size_must_fit_the_channel_result_domain() { + let mut t = MqueueTable::new(); + assert!(t + .mq_open( + "/max-reportable", + O_CREAT | O_RDWR, + 0o644, + 1, + platform_limits::MAX_REPORTABLE_TRANSFER_BYTES as u32, + true, + ) + .is_ok()); + assert_eq!( + t.mq_open( + "/unreportable", + O_CREAT | O_RDWR, + 0o644, + 1, + platform_limits::MAX_REPORTABLE_TRANSFER_BYTES as u32 + 1, + true, + ), + Err(Errno::EINVAL), + ); + } + #[test] fn test_unlink_semantics() { let mut t = MqueueTable::new(); @@ -528,6 +963,173 @@ mod tests { assert_eq!(t.mq_unlink("/unl"), Err(Errno::ENOENT)); } + #[test] + fn pinned_descriptor_survives_unlink_and_close_until_exact_release() { + let mut t = MqueueTable::new(); + let mqd = t + .mq_open("/pinned", O_CREAT | O_RDWR, 0o644, 10, 64, true) + .unwrap(); + let pinned = t.pin_descriptor(mqd).unwrap(); + let duplicate = pinned.duplicate_for_exact_release_test(); + let queue_id = pinned.queue_id; + + // Descriptor policy is captured when the operation starts. A later + // public mq_setattr must not change a blocked operation's semantics. + t.mq_getsetattr(mqd, Some(O_NONBLOCK)).unwrap(); + assert_eq!(t.is_nonblock(mqd), Some(true)); + assert_eq!(t.pinned_is_nonblock(&pinned), Ok(false)); + + t.mq_unlink("/pinned").unwrap(); + t.mq_close(mqd).unwrap(); + assert!(!t.is_mqd(mqd)); + assert!(!t.names.contains_key("/pinned")); + assert!(t.queues.contains_key(&queue_id)); + + assert_eq!(t.pinned_descriptor_msgsize(&pinned), Ok(64)); + t.mq_send_pinned(&pinned, b"old-object", 3).unwrap(); + let received = t.mq_receive_pinned(&pinned, 64).unwrap(); + assert_eq!(received.data, b"old-object"); + assert_eq!(received.priority, 3); + + t.release_pinned_descriptor(pinned).unwrap(); + assert!(!t.queues.contains_key(&queue_id)); + let duplicate_release = t.release_pinned_descriptor(duplicate); + assert_eq!(duplicate_release, Err(Errno::EBADF)); + } + + #[test] + fn forced_process_removal_releases_blocked_descriptor_pin() { + use wasm_posix_shared::abi::extended_syscalls::SYS_MQ_TIMEDSEND; + + let mut processes = crate::process_table::ProcessTable::new(); + let pid = processes.create_process().unwrap(); + let (mqd, queue_id) = { + let table = unsafe { global_mqueue_table() }; + let mqd = table + .mq_open( + "/forced-removal-retry-pin", + O_CREAT | O_EXCL | O_RDWR, + 0o600, + 1, + 64, + true, + ) + .unwrap(); + (mqd, table.descriptors[&mqd].queue_id) + }; + + crate::syscalls::ensure_blocking_retry_mqueue_binding( + processes.get_mut(pid).unwrap(), + pid, + SYS_MQ_TIMEDSEND, + mqd as i32, + ) + .unwrap(); + { + let table = unsafe { global_mqueue_table() }; + table.mq_unlink("/forced-removal-retry-pin").unwrap(); + table.mq_close(mqd).unwrap(); + assert_eq!(table.queues[&queue_id].active_pins, 1); + assert!(table.active_pins.values().any(|id| *id == queue_id)); + } + + processes.remove_process(pid).unwrap(); + + let table = unsafe { global_mqueue_table() }; + assert!(!table.queues.contains_key(&queue_id)); + assert!(table.active_pins.values().all(|id| *id != queue_id)); + } + + #[test] + fn unlink_then_recreate_keeps_old_and_new_queue_objects_isolated() { + let mut t = MqueueTable::new(); + let old_mqd = t + .mq_open("/same", O_CREAT | O_RDWR, 0o644, 10, 64, true) + .unwrap(); + let old_queue_id = t.descriptors[&old_mqd].queue_id; + t.mq_send(old_mqd, b"old", 1).unwrap(); + + t.mq_unlink("/same").unwrap(); + let new_mqd = t + .mq_open("/same", O_CREAT | O_RDWR, 0o600, 10, 64, true) + .unwrap(); + let new_queue_id = t.descriptors[&new_mqd].queue_id; + assert_ne!(old_queue_id, new_queue_id); + assert_eq!(t.names.get("/same"), Some(&new_queue_id)); + assert!(t.queues.contains_key(&old_queue_id)); + assert!(t.queues.contains_key(&new_queue_id)); + + t.mq_send(new_mqd, b"new", 9).unwrap(); + assert_eq!(t.mq_receive(old_mqd, 64).unwrap().data, b"old"); + assert_eq!(t.mq_receive(new_mqd, 64).unwrap().data, b"new"); + + t.mq_close(old_mqd).unwrap(); + assert!(!t.queues.contains_key(&old_queue_id)); + assert!(t.queues.contains_key(&new_queue_id)); + assert_eq!(t.descriptor_msgsize(new_mqd), Ok(64)); + } + + #[test] + fn mqd_allocation_skips_collisions_and_never_wraps() { + let mut t = MqueueTable::new(); + let first = t + .mq_open("/first", O_CREAT | O_RDWR, 0o644, 1, 16, true) + .unwrap(); + + t.set_next_mqd_for_test(Some(first)); + let second = t + .mq_open("/second", O_CREAT | O_RDWR, 0o644, 1, 32, true) + .unwrap(); + assert_eq!(second, first + 1); + assert_eq!(t.descriptor_msgsize(first), Ok(16)); + assert_eq!(t.descriptor_msgsize(second), Ok(32)); + + t.set_next_mqd_for_test(Some(MQD_MAX)); + let last = t + .mq_open("/last", O_CREAT | O_RDWR, 0o644, 1, 48, true) + .unwrap(); + assert_eq!(last, MQD_MAX); + assert_eq!(t.next_mqd, None); + assert_eq!( + t.mq_open("/exhausted", O_CREAT | O_RDWR, 0o644, 1, 64, true), + Err(Errno::EMFILE), + ); + assert!(!t.names.contains_key("/exhausted")); + assert_eq!(t.descriptor_msgsize(last), Ok(48)); + } + + #[test] + fn queue_id_allocation_skips_collisions_and_never_wraps() { + let mut t = MqueueTable::new(); + let first_mqd = t + .mq_open("/queue-one", O_CREAT | O_RDWR, 0o644, 1, 16, true) + .unwrap(); + let first_queue_id = t.descriptors[&first_mqd].queue_id; + + t.set_next_queue_id_for_test(Some(first_queue_id.0)); + let second_mqd = t + .mq_open("/queue-two", O_CREAT | O_RDWR, 0o644, 1, 32, true) + .unwrap(); + let second_queue_id = t.descriptors[&second_mqd].queue_id; + assert_ne!(first_queue_id, second_queue_id); + + t.set_next_queue_id_for_test(Some(u64::MAX)); + let last_mqd = t + .mq_open("/queue-last", O_CREAT | O_RDWR, 0o644, 1, 48, true) + .unwrap(); + assert_eq!(t.descriptors[&last_mqd].queue_id, MqQueueId(u64::MAX)); + assert_eq!(t.next_queue_id, None); + + let next_mqd_before = t.next_mqd; + let exhausted = t.mq_open("/queue-exhausted", O_CREAT | O_RDWR, 0o644, 1, 64, true); + assert_eq!(exhausted, Err(Errno::ENFILE)); + assert_eq!(t.next_mqd, next_mqd_before); + assert!(!t.names.contains_key("/queue-exhausted")); + assert_eq!(t.descriptor_msgsize(first_mqd), Ok(16)); + assert_eq!(t.descriptor_msgsize(second_mqd), Ok(32)); + assert_eq!(t.descriptor_msgsize(last_mqd), Ok(48)); + } + #[test] fn test_notification() { let mut t = MqueueTable::new(); @@ -536,14 +1138,8 @@ mod tests { .unwrap(); // Register notification - t.mq_notify( - mqd, - 42, - Some(SIGEV_SIGNAL), - 10, - 0x0123_4567_89ab_cdef, - ) - .unwrap(); + t.mq_notify(mqd, 42, Some(SIGEV_SIGNAL), 10, 0x0123_4567_89ab_cdef) + .unwrap(); // Second registration should EBUSY assert_eq!( @@ -666,8 +1262,7 @@ mod tests { ); assert_eq!(t.mq_receive(MQD_BASE + 999, 64).unwrap_err(), Errno::EBADF); assert_eq!( - t.mq_notify(MQD_BASE + 999, 1, Some(0), 1, 0) - .unwrap_err(), + t.mq_notify(MQD_BASE + 999, 1, Some(0), 1, 0).unwrap_err(), Errno::EBADF ); assert_eq!( diff --git a/crates/kernel/src/ofd.rs b/crates/kernel/src/ofd.rs index 2de44573db..df4eaed6dc 100644 --- a/crates/kernel/src/ofd.rs +++ b/crates/kernel/src/ofd.rs @@ -8,6 +8,7 @@ use core::sync::atomic::{AtomicU64, Ordering}; use wasm_posix_shared::Errno; use wasm_posix_shared::flags::{O_APPEND, O_NONBLOCK, O_PATH}; +use crate::fd::FdTable; use crate::lock::{FileId, OfdId}; // OFD table slots are process-local and reusable, so they cannot identify an @@ -498,11 +499,62 @@ impl OfdTable { self.entries.get_mut(idx).and_then(|slot| slot.as_mut()) } + /// Keep only references actually inherited through a descriptor table. + /// + /// WHY: `ref_count` also includes kernel-owned blocked-retry pins. A + /// fork/spawn child does not inherit those capabilities, so cloning the + /// parent's count would either retain an OFD with no child fd or leave a + /// phantom reference after the child's last real fd closes. + pub(crate) fn retain_fd_references(&mut self, fd_table: &FdTable) -> Result<(), Errno> { + let mut inherited_refs = BTreeMap::::new(); + for (_, entry) in fd_table.iter() { + let index = entry.ofd_ref.0; + if self.get(index).is_none() { + return Err(Errno::EBADF); + } + let count = inherited_refs.entry(index).or_insert(0); + *count = count.checked_add(1).ok_or(Errno::EOVERFLOW)?; + } + + for (index, slot) in self.entries.iter_mut().enumerate() { + let Some(ofd) = slot.as_mut() else { + continue; + }; + if let Some(count) = inherited_refs.remove(&index) { + ofd.ref_count = count; + } else { + // This clone has not acquired a machine-wide backing + // reference yet, so dropping an unreferenced local copy must + // not run ordinary final-close bookkeeping. + *slot = None; + } + } + debug_assert!(inherited_refs.is_empty()); + Ok(()) + } + /// Increment the reference count for the OFD at `idx`. pub fn inc_ref(&mut self, idx: usize) { if let Some(ofd) = self.get_mut(idx) { - ofd.ref_count += 1; + ofd.ref_count = ofd + .ref_count + .checked_add(1) + .expect("open-file-description reference count exhausted"); + } + } + + /// Fallibly retain one exact live OFD. + /// + /// A table index is reusable, so callers that keep authority beyond the + /// current syscall must also prove the stable [`OfdId`]. This is the + /// allocation-free ownership primitive used by blocked-syscall bindings. + pub(crate) fn try_inc_ref_exact(&mut self, idx: usize, id: OfdId) -> Result<(), Errno> { + let ofd = self.get_mut(idx).ok_or(Errno::EBADF)?; + if ofd.ofd_id != id { + return Err(Errno::EBADF); } + ofd.ref_count = ofd.ref_count.checked_add(1).ok_or(Errno::EOVERFLOW)?; + Ok(()) } /// Decrement the reference count for the OFD at `idx`. @@ -612,6 +664,39 @@ mod tests { ); } + #[test] + fn inherited_table_counts_only_real_fd_aliases() { + let mut table = OfdTable::new(); + let inherited = table.create(FileType::Regular, O_RDONLY, 11, Vec::new()); + let retry_only = table.create(FileType::Regular, O_RDONLY, 12, Vec::new()); + table.inc_ref(inherited); + table.inc_ref(inherited); + table.inc_ref(retry_only); + + let mut fds = FdTable::new(); + fds.alloc(crate::fd::OpenFileDescRef(inherited), 0) + .unwrap(); + fds.alloc(crate::fd::OpenFileDescRef(inherited), 0) + .unwrap(); + + table.retain_fd_references(&fds).unwrap(); + assert_eq!(table.get(inherited).unwrap().ref_count, 2); + assert!(table.get(retry_only).is_none()); + } + + #[test] + fn inherited_table_rejects_a_dangling_fd_without_partial_rewrite() { + let mut table = OfdTable::new(); + let retained = table.create(FileType::Regular, O_RDONLY, 13, Vec::new()); + table.inc_ref(retained); + + let mut fds = FdTable::new(); + fds.alloc(crate::fd::OpenFileDescRef(99), 0).unwrap(); + + assert_eq!(table.retain_fd_references(&fds), Err(Errno::EBADF)); + assert_eq!(table.get(retained).unwrap().ref_count, 2); + } + #[test] fn test_set_status_flags_preserves_access_mode() { let mut table = OfdTable::new(); diff --git a/crates/kernel/src/process.rs b/crates/kernel/src/process.rs index d32e3303fc..26ec4d5923 100644 --- a/crates/kernel/src/process.rs +++ b/crates/kernel/src/process.rs @@ -21,12 +21,41 @@ pub struct DirStream { pub synth_dot_state: u8, } +/// Result of one backing-owned append operation. +/// +/// `end` is captured while the backing still owns the EOF serialization +/// boundary. Callers must not reconstruct it from a separate stat. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct HostAppendOutcome { + pub written: usize, + pub end: u64, +} + /// Trait for host I/O operations that the kernel delegates to the runtime. pub trait HostIO { fn host_open(&mut self, path: &[u8], flags: u32, mode: u32) -> Result; fn host_close(&mut self, handle: i64) -> Result<(), Errno>; fn host_read(&mut self, handle: i64, buf: &mut [u8]) -> Result; fn host_write(&mut self, handle: i64, buf: &[u8]) -> Result; + fn host_append( + &mut self, + _handle: i64, + _buf: &[u8], + _limit: Option, + ) -> Result { + // Append must remain one host operation. Emulating it with seek/write + // would lose atomicity at the backing-filesystem boundary. + Err(Errno::ENOSYS) + } + fn host_pread(&mut self, _handle: i64, _buf: &mut [u8], _offset: i64) -> Result { + // Positioned I/O must be one host operation. A default seek/read/seek + // implementation would race another user of the shared host cursor. + Err(Errno::ENOSYS) + } + fn host_pwrite(&mut self, _handle: i64, _buf: &[u8], _offset: i64) -> Result { + // See host_pread: unsupported is truthful; cursor emulation is not. + Err(Errno::ENOSYS) + } fn host_seek(&mut self, handle: i64, offset: i64, whence: u32) -> Result; fn host_fstat(&mut self, handle: i64) -> Result; fn host_stat(&mut self, path: &[u8]) -> Result; @@ -698,6 +727,10 @@ pub struct Process { pub wait_event: Option, pub fd_table: FdTable, pub ofd_table: OfdTable, + /// Exact kernel-owned resources retained across host-driven blocking + /// retries. Numeric descriptors and IPC ids may be reused while a task is + /// asleep, so they are never sufficient retry authority by themselves. + pub(crate) blocked_retries: crate::blocked_retry::BlockingRetryState, pub pipes: Vec>, pub sockets: SocketTable, pub cwd: Vec, @@ -719,8 +752,7 @@ pub struct Process { pub rlimits: [[u64; 2]; 16], // [soft, hard] pairs for each resource pub alarm_deadline_ns: u64, pub alarm_interval_ns: u64, - pub thread_name: - [u8; wasm_posix_shared::kernel_scratch_wire::PRCTL_NAME_BYTES as usize], + pub thread_name: [u8; wasm_posix_shared::kernel_scratch_wire::PRCTL_NAME_BYTES as usize], /// True if this process is a fork child that should exec on startup. pub fork_child: bool, /// Saved signal mask during sigsuspend host retry. @@ -937,6 +969,7 @@ impl Process { wait_event: None, fd_table, ofd_table, + blocked_retries: crate::blocked_retry::BlockingRetryState::new(), pipes: Vec::new(), sockets: SocketTable::new(), cwd: alloc::vec![b'/'], @@ -952,8 +985,7 @@ impl Process { rlimits, alarm_deadline_ns: 0, alarm_interval_ns: 0, - thread_name: - [0u8; wasm_posix_shared::kernel_scratch_wire::PRCTL_NAME_BYTES as usize], + thread_name: [0u8; wasm_posix_shared::kernel_scratch_wire::PRCTL_NAME_BYTES as usize], fork_child: false, sigsuspend_saved_mask: None, fork_exec_path: None, @@ -1131,13 +1163,8 @@ impl Process { sender_uid: u32, ) -> bool { self.prepare_signal_generation(signum); - self.signals.raise_with_metadata( - signum, - si_value_bits, - si_code, - sender_pid, - sender_uid, - ) + self.signals + .raise_with_metadata(signum, si_value_bits, si_code, sender_pid, sender_uid) } /// Compatibility helper for the legacy pipe slot vector, reusing the first @@ -1545,9 +1572,7 @@ impl Process { self.main_thread_signals .raise_timer(signum, si_value_bits, timer_id) } else if let Some(thread) = self.get_thread_mut(tid) { - thread - .signals - .raise_timer(signum, si_value_bits, timer_id) + thread.signals.raise_timer(signum, si_value_bits, timer_id) } else { false } @@ -1939,6 +1964,20 @@ mod tests { use crate::ofd::FileType; use crate::pipe::PipeBuffer; + fn install_socket_with_fd(proc: &mut Process, socket: crate::socket::SocketInfo) -> usize { + let socket_index = proc.sockets.alloc(socket); + let ofd_index = proc.ofd_table.create( + FileType::Socket, + wasm_posix_shared::flags::O_RDWR, + -((socket_index as i64) + 1), + b"/dev/socket".to_vec(), + ); + proc.fd_table + .alloc(crate::fd::OpenFileDescRef(ofd_index), 0) + .unwrap(); + socket_index + } + #[test] fn fork_count_starts_at_zero() { let proc = Process::new(1); @@ -2288,12 +2327,10 @@ mod tests { let backlog_idx = unsafe { shared_listener_backlog_table().alloc() }; let mut listener = SocketInfo::new(SocketDomain::Inet, SocketType::Stream, 0); listener.shared_backlog_idx = Some(backlog_idx); - let _sock_idx = table - .processes - .get_mut(&parent_pid) - .unwrap() - .sockets - .alloc(listener); + let _sock_idx = install_socket_with_fd( + table.processes.get_mut(&parent_pid).unwrap(), + listener, + ); let initial = unsafe { shared_listener_backlog_table().entries[backlog_idx].ref_count }; assert_eq!(initial, 1, "alloc starts the slot at ref_count=1"); @@ -2346,12 +2383,7 @@ mod tests { const HANDLE: i32 = 42; let mut sock = SocketInfo::new(SocketDomain::Inet, SocketType::Stream, 0); sock.host_net_handle = Some(HANDLE); - table - .processes - .get_mut(&parent_pid) - .unwrap() - .sockets - .alloc(sock); + install_socket_with_fd(table.processes.get_mut(&parent_pid).unwrap(), sock); // The handle isn't in the cross-process table yet — single-owner. assert_eq!(host_net_handle_ref_count(HANDLE), 0); @@ -2421,8 +2453,8 @@ mod tests { let mut tcp = SocketInfo::new(SocketDomain::Inet, SocketType::Stream, 0); tcp.oob_byte = Some(0xAB); let parent = table.processes.get_mut(&parent_pid).unwrap(); - let udp_idx = parent.sockets.alloc(udp); - let tcp_idx = parent.sockets.alloc(tcp); + let udp_idx = install_socket_with_fd(parent, udp); + let tcp_idx = install_socket_with_fd(parent, tcp); // Sanity: parent still has the consume-once data. assert_eq!( @@ -2498,7 +2530,7 @@ mod tests { listener.listen_backlog.push(7); listener.listen_backlog.push(11); let parent = table.processes.get_mut(&parent_pid).unwrap(); - let listener_idx = parent.sockets.alloc(listener); + let listener_idx = install_socket_with_fd(parent, listener); // Sanity: parent has both pending entries. assert_eq!( @@ -2584,12 +2616,8 @@ mod tests { let parent_pid = table.create_process().unwrap(); let mut sock = SocketInfo::new(SocketDomain::Inet, SocketType::Stream, 0); sock.host_net_handle = Some(HANDLE); - let _sock_idx = table - .processes - .get_mut(&parent_pid) - .unwrap() - .sockets - .alloc(sock); + let _sock_idx = + install_socket_with_fd(table.processes.get_mut(&parent_pid).unwrap(), sock); // Spawn a child → bump the refcount to (parent=1, child=2). let mut host = test_host::NoopHost; diff --git a/crates/kernel/src/process_table.rs b/crates/kernel/src/process_table.rs index 9e43668879..3bab10241f 100644 --- a/crates/kernel/src/process_table.rs +++ b/crates/kernel/src/process_table.rs @@ -153,6 +153,44 @@ struct SpawnInheritFromParent { sockets: crate::socket::SocketTable, } +/// Return each socket-table slot owned by at least one live OFD, exactly once. +/// +/// `peer_idx` is not authority to retain another slot. Only a socket OFD +/// inherited through the child's descriptor table is an owning root. +fn socket_indices_named_by_live_ofds(process: &Process) -> Result, Errno> { + let socket_ofd_count = process + .ofd_table + .iter() + .filter(|(_, ofd)| ofd.file_type == FileType::Socket) + .count(); + let mut indices = Vec::new(); + indices + .try_reserve_exact(socket_ofd_count) + .map_err(|_| Errno::ENOMEM)?; + for (_, ofd) in process.ofd_table.iter() { + if ofd.file_type != FileType::Socket { + continue; + } + let index = crate::socket::SocketTable::index_from_ofd_handle(ofd.host_handle)?; + if process.sockets.get(index).is_none() { + return Err(Errno::EBADF); + } + if !indices.contains(&index) { + indices.push(index); + } + } + Ok(indices) +} + +/// Allocation-free ownership query for process teardown. +fn socket_index_is_named_by_live_ofd(process: &Process, socket_index: usize) -> bool { + process.ofd_table.iter().any(|(_, ofd)| { + ofd.file_type == FileType::Socket + && crate::socket::SocketTable::index_from_ofd_handle(ofd.host_handle) + == Ok(socket_index) + }) +} + /// Bump cross-process refcounts on resources the child inherited from the /// parent (host file handles, global pipes, PTYs, and the global pipes /// referenced by sockets with `global_pipes`). @@ -168,11 +206,24 @@ pub(crate) fn bump_inherited_resource_refcounts( parent_pid: u32, child: &Process, ) -> Result<(), Errno> { + // Resolve and deduplicate every fallible socket root before the first + // machine-wide refcount mutation. An allocation or malformed handle must + // fail without requiring rollback of unrelated inherited resources. + let owned_socket_indices = socket_indices_named_by_live_ofds(child)?; + // Backings for eventfd/timerfd/signalfd/memfd/procfs are indexed by the // inherited OFD's stable negative handle. Add these fallible references // first, rolling them back if a stale handle is encountered, before // touching the older infallible global-resource refcounts below. + let inherited_ofd_count = child.ofd_table.iter().count(); let mut shared_backings_bumped: Vec<(FileType, i64)> = Vec::new(); + shared_backings_bumped + .try_reserve_exact(inherited_ofd_count) + .map_err(|_| Errno::ENOMEM)?; + let mut unix_registry_owners_added = Vec::new(); + unix_registry_owners_added + .try_reserve_exact(owned_socket_indices.len()) + .map_err(|_| Errno::ENOMEM)?; for (_idx, ofd) in child.ofd_table.iter() { match crate::descriptor_backing::add_ref_for_ofd(ofd.file_type, ofd.host_handle) { Ok(true) => shared_backings_bumped.push((ofd.file_type, ofd.host_handle)), @@ -186,6 +237,37 @@ pub(crate) fn bump_inherited_resource_refcounts( } } + // A bound socket's historical sockaddr can be stale after rename+reuse. + // Inherit only from the exact parent owner tuple. This operation can + // allocate, so rollback both earlier registry additions and descriptor + // backing refs before returning an error. + for &sock_idx in &owned_socket_indices { + let sock = child + .sockets + .get(sock_idx) + .expect("validated socket OFD lost its table slot"); + if sock.bind_path.is_none() { + continue; + } + let result = unsafe { crate::unix_socket::global_unix_socket_registry() } + .add_inherited_owner(parent_pid, sock_idx, child.pid, sock_idx); + match result { + Ok(true) => unix_registry_owners_added.push(sock_idx), + Ok(false) => {} + Err(err) => { + let registry = + unsafe { crate::unix_socket::global_unix_socket_registry() }; + for added_sock_idx in unix_registry_owners_added.into_iter().rev() { + registry.remove_owner_exact(child.pid, added_sock_idx); + } + for (file_type, host_handle) in shared_backings_bumped.into_iter().rev() { + crate::descriptor_backing::release_for_ofd(file_type, host_handle); + } + return Err(err); + } + } + } + let pipe_table = unsafe { crate::pipe::global_pipe_table() }; // Pipe-OFDs (host_handle is the negative-encoded global pipe index). @@ -252,27 +334,23 @@ pub(crate) fn bump_inherited_resource_refcounts( } } - // Shared listener backlog (AF_INET/AF_INET6 listeners) and host_net_handle - // (connected AF_INET sockets): increment one ref per socket entry that - // carries one. close() and process exit each drop one ref; last-drop - // either frees the listener slot or calls host_net_close. Iterates - // `child.sockets` directly (not via OFDs) so an unaccepted-but-stored - // listener inherits a refcount even if no fd in the child happens to - // reference it — this matches the prior fork-deserialize-time bump. + // Shared listener backlog, host_net_handle, INET binding ownership, and + // AF_UNIX registry ownership belong only to socket slots named by live + // child OFDs. `peer_idx` is not a capability: acquiring ownership for its + // target would let CLOFORK or retry-only peer state survive in a child + // that inherited no descriptor for it. let backlog_table = unsafe { crate::socket::shared_listener_backlog_table() }; - for sock_idx in 0..child.sockets.len() { - if let Some(sock) = child.sockets.get(sock_idx) { - crate::socket::inherit_inet_binding_owners(parent_pid, child.pid, sock_idx); - if let Some(shared_idx) = sock.shared_backlog_idx { - backlog_table.add_ref(shared_idx); - } - if let Some(net_handle) = sock.host_net_handle { - crate::socket::host_net_handle_fork_ref(net_handle); - } - if let Some(path) = sock.bind_path.as_deref() { - let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; - registry.add_owner(path, child.pid, sock_idx); - } + for sock_idx in owned_socket_indices { + let sock = child + .sockets + .get(sock_idx) + .expect("validated socket OFD lost its table slot"); + crate::socket::inherit_inet_binding_owners(parent_pid, child.pid, sock_idx); + if let Some(shared_idx) = sock.shared_backlog_idx { + backlog_table.add_ref(shared_idx); + } + if let Some(net_handle) = sock.host_net_handle { + crate::socket::host_net_handle_fork_ref(net_handle); } } @@ -423,6 +501,11 @@ impl ProcessTable { return None; } let mut proc = self.processes.remove(&pid)?; + // Whole-process teardown consumes the OFD table itself, but retry + // bindings can also own machine-global MQ/IPC pins and SCM_RIGHTS + // references. Drop those before the ordinary backing walk and its + // deferred ancillary cleanup boundary. + crate::syscalls::discard_blocking_retry_bindings_for_process_removal(&mut proc); let _ = unsafe { crate::pipe::global_pipe_table().cancel_fifo_opens_for_process(pid) }; let mut host_closes: Vec = Vec::new(); let mut host_dir_closes: Vec = Vec::new(); @@ -581,15 +664,21 @@ impl ProcessTable { } // Drop cross-process refcounts for socket-side resources, once per - // socket entry. Mirrors the per-socket bump in + // deduplicated OFD-named socket root. Mirrors the root-only bump in // `bump_inherited_resource_refcounts` so a fork/spawn parent and // child each contribute exactly one ref on inheritance and one // drop on exit. Sockets that the process closed via sys_close are // already removed from `proc.sockets` (sys_close calls // `sockets.free` on its happy path), so this loop visits only - // entries the process held until exit. + // owning roots the process held until exit. let shared_backlog_table = unsafe { crate::socket::shared_listener_backlog_table() }; for sock_idx in 0..proc.sockets.len() { + // A process-local slot without a live OFD never acquired these + // machine-wide inherited references, so teardown must not + // decrement them. + if !socket_index_is_named_by_live_ofd(&proc, sock_idx) { + continue; + } if let Some(sock) = proc.sockets.get(sock_idx) { if let Some(shared_idx) = sock.shared_backlog_idx { shared_backlog_table.dec_ref(shared_idx); @@ -755,6 +844,11 @@ impl ProcessTable { self.current_pid = pid; self.current_tid = tid; self.current_tid_pid = pid; + self.processes + .get_mut(&pid) + .expect("validated process disappeared during serialized bind") + .blocked_retries + .bind_task(tid); Ok(()) } @@ -791,6 +885,13 @@ impl ProcessTable { /// Consume the ambient task binding after one serialized channel call. /// A stale binding must never authorize a later mailbox dispatch. pub fn clear_current_tid_binding(&mut self) { + if self.current_tid_pid != 0 { + if let Some(process) = self.processes.get_mut(&self.current_tid_pid) { + process + .blocked_retries + .clear_bound_task(self.current_tid); + } + } self.current_pid = 0; self.current_tid = 0; self.current_tid_pid = 0; @@ -1048,6 +1149,14 @@ impl ProcessTable { child.ofd_table = inherit.ofd_table; child.sockets = inherit.sockets; + // Retry pins are kernel capabilities owned by the parent task, not + // descriptors inherited by a new process. Rebuild local OFD counts + // from the child's actual fd aliases before acquiring any shared + // backing references, then keep only the socket graph those OFDs own. + child.ofd_table.retain_fd_references(&child.fd_table)?; + let socket_roots = socket_indices_named_by_live_ofds(&child)?; + child.sockets.retain_inherited_roots(&socket_roots)?; + // A host directory iterator is process-local mutable state, not part // of the positive backing-handle ownership that fork/spawn refcount. // Cloning it here would give parent and child one host handle with @@ -1677,16 +1786,19 @@ mod wait_tests { assert_eq!(table.bind_current_tid(pid, tid), Ok(())); assert_eq!(table.current_tid(), tid); assert!(table.has_current_tid_binding(pid)); + assert_eq!(table.get(pid).unwrap().blocked_retries.bound_tid(), Some(tid)); assert_eq!(table.bind_current_tid(pid, tid + 1), Err(Errno::ESRCH)); assert!(!table.has_current_tid_binding(pid)); assert_eq!(table.current_tid(), 0); + assert_eq!(table.get(pid).unwrap().blocked_retries.bound_tid(), None); assert_eq!(table.bind_current_tid(pid, tid), Ok(())); table.clear_current_tid_binding(); assert!(!table.has_current_tid_binding(pid)); assert_eq!(table.current_pid(), 0); assert_eq!(table.current_tid(), 0); + assert_eq!(table.get(pid).unwrap().blocked_retries.bound_tid(), None); assert!(table.current_process().is_none()); assert!(table.current_process_and_advisory_locks().is_none()); @@ -1908,6 +2020,745 @@ mod tests { assert_eq!(table.get(child_pid).unwrap().state, ProcessState::Running); } + #[test] + fn spawn_recomputes_child_ofd_refs_without_inheriting_a_sibling_retry_pin() { + use crate::fd::OpenFileDescRef; + use crate::process::test_host::NoopHost; + use crate::spawn::SpawnAttrs; + use wasm_posix_shared::flags::O_RDONLY; + + const HANDLE: i64 = 9_470_001; + + let mut table = ProcessTable::new(); + let parent_pid = table.create_process().unwrap(); + let caller_tid = table + .create_thread(parent_pid, parent_pid, 0x1000, 0, 0) + .unwrap(); + let mut host = NoopHost; + let (fd, ofd_index, token) = { + let (parent, locks) = table + .process_and_advisory_locks(parent_pid) + .unwrap(); + let ofd_index = parent.ofd_table.create( + FileType::Regular, + O_RDONLY, + HANDLE, + b"/retry-pinned".to_vec(), + ); + let fd = parent + .fd_table + .alloc(OpenFileDescRef(ofd_index), 0) + .unwrap(); + let token = crate::syscalls::ensure_blocking_retry_ofd_binding( + parent, + locks, + &mut host, + parent_pid, + 3, + fd, + None, + ) + .unwrap(); + assert_eq!(parent.ofd_table.get(ofd_index).unwrap().ref_count, 2); + (fd, ofd_index, token) + }; + + let child_pid = table + .spawn_child_for_caller( + parent_pid, + caller_tid, + &[b"/bin/child".as_slice()], + &[], + &[], + &SpawnAttrs::empty(), + &mut host, + ) + .unwrap(); + let child = table.get(child_pid).unwrap(); + assert_eq!(child.blocked_retries.binding_count(), 0); + assert_eq!(child.ofd_table.get(ofd_index).unwrap().ref_count, 1); + + { + let (child, locks) = table + .process_and_advisory_locks(child_pid) + .unwrap(); + crate::syscalls::sys_close_with_locks(child, locks, &mut host, fd).unwrap(); + assert!(child.ofd_table.get(ofd_index).is_none()); + } + assert_eq!(crate::ofd::host_handle_ref_count(HANDLE), 1); + + { + let (parent, locks) = table + .process_and_advisory_locks(parent_pid) + .unwrap(); + crate::syscalls::release_blocking_retry_binding( + parent, + locks, + &mut host, + parent_pid, + token, + ) + .unwrap(); + crate::syscalls::sys_close_with_locks(parent, locks, &mut host, fd).unwrap(); + } + assert_eq!(crate::ofd::host_handle_ref_count(HANDLE), 0); + } + + #[test] + fn fork_and_spawn_exclude_retry_only_ofd_and_socket_state() { + use crate::process::test_host::NoopHost; + use crate::spawn::SpawnAttrs; + use wasm_posix_shared::socket::{AF_INET, SOCK_DGRAM}; + + let mut table = ProcessTable::new(); + let parent_pid = table.create_process().unwrap(); + let caller_tid = table + .create_thread(parent_pid, parent_pid, 0x1000, 0, 0) + .unwrap(); + let mut host = NoopHost; + let (ofd_index, socket_index, token) = { + let (parent, locks) = table + .process_and_advisory_locks(parent_pid) + .unwrap(); + let fd = + crate::syscalls::sys_socket(parent, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); + let ofd_index = parent.fd_table.get(fd).unwrap().ofd_ref.0; + let socket_index = crate::socket::SocketTable::index_from_ofd_handle( + parent.ofd_table.get(ofd_index).unwrap().host_handle, + ) + .unwrap(); + let token = crate::syscalls::ensure_blocking_retry_ofd_binding( + parent, + locks, + &mut host, + parent_pid, + 56, + fd, + None, + ) + .unwrap(); + crate::syscalls::sys_close_with_locks(parent, locks, &mut host, fd).unwrap(); + assert!(parent.ofd_table.get(ofd_index).is_some()); + assert!(parent.sockets.get(socket_index).is_some()); + (ofd_index, socket_index, token) + }; + + let fork_pid = table + .fork_process_for_caller(parent_pid, caller_tid) + .unwrap(); + let mut host = NoopHost; + let spawn_pid = table + .spawn_child_for_caller( + parent_pid, + caller_tid, + &[b"/bin/child".as_slice()], + &[], + &[], + &SpawnAttrs::empty(), + &mut host, + ) + .unwrap(); + for child_pid in [fork_pid, spawn_pid] { + let child = table.get(child_pid).unwrap(); + assert_eq!(child.blocked_retries.binding_count(), 0); + assert!(child.ofd_table.get(ofd_index).is_none()); + assert!(child.sockets.get(socket_index).is_none()); + } + + let (parent, locks) = table + .process_and_advisory_locks(parent_pid) + .unwrap(); + crate::syscalls::release_blocking_retry_binding( + parent, + locks, + &mut host, + parent_pid, + token, + ) + .unwrap(); + assert!(parent.ofd_table.get(ofd_index).is_none()); + assert!(parent.sockets.get(socket_index).is_none()); + } + + #[test] + fn fork_clofork_unix_datagram_peer_fails_truthfully_without_an_orphan_proxy() { + use crate::process::test_host::NoopHost; + use wasm_posix_shared::fd_flags::FD_CLOFORK; + use wasm_posix_shared::socket::{AF_UNIX, SOCK_DGRAM}; + + const ABSTRACT_PATH: &[u8] = b"\0kandelo-clofork-peer-proxy"; + + let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; + registry.unregister(ABSTRACT_PATH); + + let mut table = ProcessTable::new(); + let parent_pid = table.create_process().unwrap(); + let mut host = NoopHost; + let (source_fd, peer_fd, peer_index) = { + let parent = table.get_mut(parent_pid).unwrap(); + let source_fd = + crate::syscalls::sys_socket(parent, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + let peer_fd = + crate::syscalls::sys_socket(parent, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + let mut address = vec![0u8; 2 + ABSTRACT_PATH.len()]; + address[0] = AF_UNIX as u8; + address[2..].copy_from_slice(ABSTRACT_PATH); + crate::syscalls::sys_bind(parent, &mut host, peer_fd, &address).unwrap(); + crate::syscalls::sys_connect(parent, &mut host, source_fd, &address).unwrap(); + parent.fd_table.get_mut(peer_fd).unwrap().fd_flags |= FD_CLOFORK; + + let peer_ofd_index = parent.fd_table.get(peer_fd).unwrap().ofd_ref.0; + let peer_index = crate::socket::SocketTable::index_from_ofd_handle( + parent.ofd_table.get(peer_ofd_index).unwrap().host_handle, + ) + .unwrap(); + (source_fd, peer_fd, peer_index) + }; + + let child_pid = table + .fork_process_for_caller(parent_pid, parent_pid) + .unwrap(); + let child = table.get(child_pid).unwrap(); + assert!(child.fd_table.get(source_fd).is_ok()); + assert!(child.fd_table.get(peer_fd).is_err()); + assert!( + child.sockets.get(peer_index).is_none(), + "a non-inherited peer must not survive as a fake local endpoint" + ); + + let owner = unsafe { crate::unix_socket::global_unix_socket_registry() } + .lookup(ABSTRACT_PATH) + .unwrap(); + assert_eq!((owner.pid, owner.sock_idx), (parent_pid, peer_index)); + + { + let (parent, locks) = table.process_and_advisory_locks(parent_pid).unwrap(); + crate::syscalls::sys_close_with_locks(parent, locks, &mut host, peer_fd).unwrap(); + } + assert!( + unsafe { crate::unix_socket::global_unix_socket_registry() } + .lookup(ABSTRACT_PATH) + .is_none(), + "closing the sole owning descriptor must not reveal a child proxy as an owner" + ); + assert_eq!( + crate::syscalls::sys_send( + table.get_mut(child_pid).unwrap(), + &mut host, + source_fd, + b"orphan", + 0, + ), + Err(Errno::ECONNREFUSED), + "a child must not report success by queueing into an unreachable proxy" + ); + { + let (child, locks) = table.process_and_advisory_locks(child_pid).unwrap(); + crate::syscalls::sys_close_with_locks(child, locks, &mut host, source_fd).unwrap(); + assert!(child.sockets.get(peer_index).is_none()); + } + + table.remove_process(child_pid).unwrap(); + table.remove_process(parent_pid).unwrap(); + unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(ABSTRACT_PATH); + } + + #[test] + fn fork_and_spawn_drop_a_connected_unix_datagram_peer_owned_only_by_retry() { + use crate::process::test_host::NoopHost; + use crate::spawn::SpawnAttrs; + use wasm_posix_shared::socket::{AF_UNIX, SOCK_DGRAM}; + + const ABSTRACT_PATH: &[u8] = b"\0kandelo-retry-only-connected-peer"; + + unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(ABSTRACT_PATH); + + let mut table = ProcessTable::new(); + let parent_pid = table.create_process().unwrap(); + let caller_tid = table + .create_thread(parent_pid, parent_pid, 0x1000, 0, 0) + .unwrap(); + let mut host = NoopHost; + let (source_fd, peer_fd, source_index, peer_index, token) = { + let (parent, locks) = table + .process_and_advisory_locks(parent_pid) + .unwrap(); + let source_fd = + crate::syscalls::sys_socket(parent, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + let peer_fd = + crate::syscalls::sys_socket(parent, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + let mut address = vec![0u8; 2 + ABSTRACT_PATH.len()]; + address[0] = AF_UNIX as u8; + address[2..].copy_from_slice(ABSTRACT_PATH); + crate::syscalls::sys_bind(parent, &mut host, peer_fd, &address).unwrap(); + crate::syscalls::sys_connect(parent, &mut host, source_fd, &address).unwrap(); + + let source_ofd = parent.fd_table.get(source_fd).unwrap().ofd_ref.0; + let source_index = crate::socket::SocketTable::index_from_ofd_handle( + parent.ofd_table.get(source_ofd).unwrap().host_handle, + ) + .unwrap(); + let peer_ofd = parent.fd_table.get(peer_fd).unwrap().ofd_ref.0; + let peer_index = crate::socket::SocketTable::index_from_ofd_handle( + parent.ofd_table.get(peer_ofd).unwrap().host_handle, + ) + .unwrap(); + let token = crate::syscalls::ensure_blocking_retry_ofd_binding( + parent, + locks, + &mut host, + parent_pid, + 56, + peer_fd, + None, + ) + .unwrap(); + crate::syscalls::sys_close_with_locks(parent, locks, &mut host, peer_fd).unwrap(); + assert!(parent.sockets.get(peer_index).is_some()); + (source_fd, peer_fd, source_index, peer_index, token) + }; + + let fork_pid = table + .fork_process_for_caller(parent_pid, caller_tid) + .unwrap(); + let spawn_pid = table + .spawn_child_for_caller( + parent_pid, + caller_tid, + &[b"/bin/child".as_slice()], + &[], + &[], + &SpawnAttrs::empty(), + &mut host, + ) + .unwrap(); + for child_pid in [fork_pid, spawn_pid] { + let child = table.get(child_pid).unwrap(); + assert!(child.fd_table.get(source_fd).is_ok()); + assert!(child.fd_table.get(peer_fd).is_err()); + assert!(child.sockets.get(peer_index).is_none()); + assert_eq!(child.sockets.get(source_index).unwrap().peer_idx, None); + } + + { + let (parent, locks) = table + .process_and_advisory_locks(parent_pid) + .unwrap(); + crate::syscalls::release_blocking_retry_binding( + parent, + locks, + &mut host, + parent_pid, + token, + ) + .unwrap(); + assert!(parent.sockets.get(peer_index).is_none()); + } + assert!( + unsafe { crate::unix_socket::global_unix_socket_registry() } + .lookup(ABSTRACT_PATH) + .is_none() + ); + + for child_pid in [fork_pid, spawn_pid] { + assert_eq!( + crate::syscalls::sys_send( + table.get_mut(child_pid).unwrap(), + &mut host, + source_fd, + b"orphan", + 0, + ), + Err(Errno::ECONNREFUSED) + ); + } + + table.remove_process(fork_pid).unwrap(); + table.remove_process(spawn_pid).unwrap(); + table.remove_process(parent_pid).unwrap(); + unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(ABSTRACT_PATH); + } + + #[test] + fn fork_preserves_both_owned_unix_datagram_socketpair_roots() { + use crate::process::test_host::NoopHost; + use wasm_posix_shared::socket::{AF_UNIX, SOCK_DGRAM}; + + let mut table = ProcessTable::new(); + let parent_pid = table.create_process().unwrap(); + let mut host = NoopHost; + let (sender_fd, receiver_fd) = crate::syscalls::sys_socketpair( + table.get_mut(parent_pid).unwrap(), + &mut host, + AF_UNIX, + SOCK_DGRAM, + 0, + ) + .unwrap(); + + let child_pid = table + .fork_process_for_caller(parent_pid, parent_pid) + .unwrap(); + assert_eq!( + crate::syscalls::sys_send( + table.get_mut(child_pid).unwrap(), + &mut host, + sender_fd, + b"owned-pair", + 0, + ), + Ok(10) + ); + let mut received = [0u8; 16]; + assert_eq!( + crate::syscalls::sys_recv( + table.get_mut(child_pid).unwrap(), + &mut host, + receiver_fd, + &mut received, + 0, + ), + Ok(10) + ); + assert_eq!(&received[..10], b"owned-pair"); + + table.remove_process(child_pid).unwrap(); + table.remove_process(parent_pid).unwrap(); + } + + #[test] + fn fork_clofork_unix_stream_keeps_pipe_data_but_not_process_local_oob_peer() { + use crate::process::test_host::NoopHost; + use wasm_posix_shared::fd_flags::FD_CLOFORK; + use wasm_posix_shared::socket::{ + AF_UNIX, MSG_NOSIGNAL, MSG_OOB, SOCK_STREAM, + }; + + let mut table = ProcessTable::new(); + let parent_pid = table.create_process().unwrap(); + let mut host = NoopHost; + let (sender_fd, receiver_fd, receiver_index) = { + let parent = table.get_mut(parent_pid).unwrap(); + let (sender_fd, receiver_fd) = crate::syscalls::sys_socketpair( + parent, + &mut host, + AF_UNIX, + SOCK_STREAM, + 0, + ) + .unwrap(); + parent.fd_table.get_mut(receiver_fd).unwrap().fd_flags |= FD_CLOFORK; + let receiver_ofd = parent.fd_table.get(receiver_fd).unwrap().ofd_ref.0; + let receiver_index = crate::socket::SocketTable::index_from_ofd_handle( + parent.ofd_table.get(receiver_ofd).unwrap().host_handle, + ) + .unwrap(); + (sender_fd, receiver_fd, receiver_index) + }; + + let child_pid = table + .fork_process_for_caller(parent_pid, parent_pid) + .unwrap(); + let child = table.get(child_pid).unwrap(); + assert!(child.fd_table.get(sender_fd).is_ok()); + assert!(child.fd_table.get(receiver_fd).is_err()); + assert!(child.sockets.get(receiver_index).is_none()); + + assert_eq!( + crate::syscalls::sys_send( + table.get_mut(child_pid).unwrap(), + &mut host, + sender_fd, + b"pipe-data", + 0, + ), + Ok(9) + ); + let mut received = [0u8; 16]; + assert_eq!( + crate::syscalls::sys_recv( + table.get_mut(parent_pid).unwrap(), + &mut host, + receiver_fd, + &mut received, + 0, + ), + Ok(9) + ); + assert_eq!(&received[..9], b"pipe-data"); + + assert_eq!( + crate::syscalls::sys_send( + table.get_mut(child_pid).unwrap(), + &mut host, + sender_fd, + b"X", + MSG_OOB | MSG_NOSIGNAL, + ), + Err(Errno::EPIPE) + ); + + table.remove_process(child_pid).unwrap(); + table.remove_process(parent_pid).unwrap(); + } + + #[test] + fn inherited_unix_registry_owner_is_deduplicated_across_fd_aliases() { + use crate::process::test_host::NoopHost; + use wasm_posix_shared::socket::{AF_UNIX, SOCK_DGRAM}; + + const ABSTRACT_PATH: &[u8] = b"\0kandelo-inherited-alias-owner"; + + unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(ABSTRACT_PATH); + + let mut table = ProcessTable::new(); + let parent_pid = table.create_process().unwrap(); + let mut host = NoopHost; + let (bound_fd, alias_fd, socket_index) = { + let parent = table.get_mut(parent_pid).unwrap(); + let bound_fd = + crate::syscalls::sys_socket(parent, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + let mut address = vec![0u8; 2 + ABSTRACT_PATH.len()]; + address[0] = AF_UNIX as u8; + address[2..].copy_from_slice(ABSTRACT_PATH); + crate::syscalls::sys_bind(parent, &mut host, bound_fd, &address).unwrap(); + let alias_fd = crate::syscalls::sys_dup(parent, bound_fd).unwrap(); + let ofd_index = parent.fd_table.get(bound_fd).unwrap().ofd_ref.0; + let socket_index = crate::socket::SocketTable::index_from_ofd_handle( + parent.ofd_table.get(ofd_index).unwrap().host_handle, + ) + .unwrap(); + (bound_fd, alias_fd, socket_index) + }; + + let child_pid = table + .fork_process_for_caller(parent_pid, parent_pid) + .unwrap(); + + { + let (parent, locks) = table + .process_and_advisory_locks(parent_pid) + .unwrap(); + crate::syscalls::sys_close_with_locks(parent, locks, &mut host, bound_fd).unwrap(); + crate::syscalls::sys_close_with_locks(parent, locks, &mut host, alias_fd).unwrap(); + } + let owner = unsafe { crate::unix_socket::global_unix_socket_registry() } + .lookup(ABSTRACT_PATH) + .unwrap(); + assert_eq!((owner.pid, owner.sock_idx), (child_pid, socket_index)); + + { + let (child, locks) = table.process_and_advisory_locks(child_pid).unwrap(); + crate::syscalls::sys_close_with_locks(child, locks, &mut host, bound_fd).unwrap(); + let owner = unsafe { crate::unix_socket::global_unix_socket_registry() } + .lookup(ABSTRACT_PATH) + .unwrap(); + assert_eq!((owner.pid, owner.sock_idx), (child_pid, socket_index)); + crate::syscalls::sys_close_with_locks(child, locks, &mut host, alias_fd).unwrap(); + } + assert!( + unsafe { crate::unix_socket::global_unix_socket_registry() } + .lookup(ABSTRACT_PATH) + .is_none() + ); + + table.remove_process(child_pid).unwrap(); + table.remove_process(parent_pid).unwrap(); + unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(ABSTRACT_PATH); + } + + #[test] + fn inherited_unix_owner_follows_rename_while_old_name_is_reused() { + use crate::fd::OpenFileDescRef; + use crate::process::test_host::NoopHost; + use crate::socket::{SocketDomain, SocketInfo, SocketState, SocketType}; + use wasm_posix_shared::fd_flags::FD_CLOFORK; + use wasm_posix_shared::flags::O_RDWR; + + const OLD_NAME: &[u8] = b"/tmp/kandelo-owner-before-rename.sock"; + const NEW_NAME: &[u8] = b"/tmp/kandelo-owner-after-rename.sock"; + + let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; + registry.unregister(OLD_NAME); + registry.unregister(NEW_NAME); + + let mut table = ProcessTable::new(); + let parent_pid = table.create_process().unwrap(); + let mut host = NoopHost; + let (renamed_fd, renamed_index, reused_fd, reused_index) = { + let parent = table.get_mut(parent_pid).unwrap(); + let mut renamed_socket = + SocketInfo::new(SocketDomain::Unix, SocketType::Dgram, 0); + renamed_socket.bind_path = Some(OLD_NAME.to_vec()); + renamed_socket.state = SocketState::Bound; + let renamed_index = parent.sockets.alloc(renamed_socket); + let renamed_ofd = parent.ofd_table.create( + FileType::Socket, + O_RDWR, + -((renamed_index as i64) + 1), + b"/dev/socket".to_vec(), + ); + let renamed_fd = parent + .fd_table + .alloc(OpenFileDescRef(renamed_ofd), 0) + .unwrap(); + assert!( + unsafe { crate::unix_socket::global_unix_socket_registry() }.register( + OLD_NAME.to_vec(), + parent_pid, + renamed_index, + ) + ); + + assert!( + unsafe { crate::unix_socket::global_unix_socket_registry() } + .rename_path(OLD_NAME, NEW_NAME) + ); + + let mut reused_socket = + SocketInfo::new(SocketDomain::Unix, SocketType::Dgram, 0); + reused_socket.bind_path = Some(OLD_NAME.to_vec()); + reused_socket.state = SocketState::Bound; + let reused_index = parent.sockets.alloc(reused_socket); + let reused_ofd = parent.ofd_table.create( + FileType::Socket, + O_RDWR, + -((reused_index as i64) + 1), + b"/dev/socket".to_vec(), + ); + let reused_fd = parent + .fd_table + .alloc(OpenFileDescRef(reused_ofd), 0) + .unwrap(); + parent.fd_table.get_mut(reused_fd).unwrap().fd_flags |= FD_CLOFORK; + assert!( + unsafe { crate::unix_socket::global_unix_socket_registry() }.register( + OLD_NAME.to_vec(), + parent_pid, + reused_index, + ) + ); + (renamed_fd, renamed_index, reused_fd, reused_index) + }; + + let child_pid = table + .fork_process_for_caller(parent_pid, parent_pid) + .unwrap(); + assert!(table.get(child_pid).unwrap().fd_table.get(renamed_fd).is_ok()); + assert!(table.get(child_pid).unwrap().fd_table.get(reused_fd).is_err()); + + { + let (parent, locks) = table + .process_and_advisory_locks(parent_pid) + .unwrap(); + crate::syscalls::sys_close_with_locks(parent, locks, &mut host, renamed_fd).unwrap(); + } + let renamed_owner = unsafe { crate::unix_socket::global_unix_socket_registry() } + .lookup(NEW_NAME) + .unwrap(); + assert_eq!( + (renamed_owner.pid, renamed_owner.sock_idx), + (child_pid, renamed_index) + ); + let reused_owner = unsafe { crate::unix_socket::global_unix_socket_registry() } + .lookup(OLD_NAME) + .unwrap(); + assert_eq!( + (reused_owner.pid, reused_owner.sock_idx), + (parent_pid, reused_index) + ); + + { + let (child, locks) = table.process_and_advisory_locks(child_pid).unwrap(); + crate::syscalls::sys_close_with_locks(child, locks, &mut host, renamed_fd).unwrap(); + } + assert!( + unsafe { crate::unix_socket::global_unix_socket_registry() } + .lookup(NEW_NAME) + .is_none() + ); + { + let (parent, locks) = table + .process_and_advisory_locks(parent_pid) + .unwrap(); + crate::syscalls::sys_close_with_locks(parent, locks, &mut host, reused_fd).unwrap(); + } + assert!( + unsafe { crate::unix_socket::global_unix_socket_registry() } + .lookup(OLD_NAME) + .is_none() + ); + + table.remove_process(child_pid).unwrap(); + table.remove_process(parent_pid).unwrap(); + let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; + registry.unregister(OLD_NAME); + registry.unregister(NEW_NAME); + } + + #[test] + fn invalid_socket_root_fails_before_any_inherited_authority_is_mutated() { + use crate::socket::{SocketDomain, SocketInfo, SocketType}; + use wasm_posix_shared::flags::{O_RDONLY, O_RDWR}; + + const ABSTRACT_PATH: &[u8] = b"\0kandelo-invalid-root-atomicity"; + const HOST_HANDLE: i64 = 9_490_001; + const PARENT_PID: u32 = 9_490_010; + const CHILD_PID: u32 = 9_490_011; + + let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; + registry.unregister(ABSTRACT_PATH); + assert!(registry.register(ABSTRACT_PATH.to_vec(), PARENT_PID, 0)); + + let mut child = Process::new(CHILD_PID); + let socket_index = child.sockets.alloc(SocketInfo::new( + SocketDomain::Unix, + SocketType::Dgram, + 0, + )); + assert_eq!(socket_index, 0); + child.sockets.get_mut(socket_index).unwrap().bind_path = + Some(ABSTRACT_PATH.to_vec()); + child.ofd_table.create( + FileType::Regular, + O_RDONLY, + HOST_HANDLE, + b"/host-backed".to_vec(), + ); + child.ofd_table.create( + FileType::Socket, + O_RDWR, + -((socket_index as i64) + 1), + b"/dev/socket".to_vec(), + ); + child.ofd_table.create( + FileType::Socket, + O_RDWR, + -100, + b"/dev/socket".to_vec(), + ); + + assert_eq!( + bump_inherited_resource_refcounts(PARENT_PID, &child), + Err(Errno::EBADF) + ); + assert_eq!(crate::ofd::host_handle_ref_count(HOST_HANDLE), 0); + let owner = unsafe { crate::unix_socket::global_unix_socket_registry() } + .lookup(ABSTRACT_PATH) + .unwrap(); + assert_eq!((owner.pid, owner.sock_idx), (PARENT_PID, socket_index)); + + assert!( + unsafe { crate::unix_socket::global_unix_socket_registry() } + .remove_owner_exact(PARENT_PID, socket_index) + ); + assert!( + unsafe { crate::unix_socket::global_unix_socket_registry() } + .lookup(ABSTRACT_PATH) + .is_none() + ); + } + #[test] fn spawn_reopens_inherited_directory_without_owning_the_parent_iterator() { use crate::fd::OpenFileDescRef; @@ -2128,7 +2979,17 @@ mod tests { socket.state = SocketState::Bound; socket.bind_addr = [127, 0, 0, 1]; socket.bind_port = port; - proc.sockets.alloc(socket) + let sock_idx = proc.sockets.alloc(socket); + let ofd_idx = proc.ofd_table.create( + FileType::Socket, + wasm_posix_shared::flags::O_RDWR, + -((sock_idx as i64) + 1), + b"/dev/socket".to_vec(), + ); + proc.fd_table + .alloc(crate::fd::OpenFileDescRef(ofd_idx), 0) + .unwrap(); + sock_idx }; crate::socket::udp_register(pid, sock_idx, [127, 0, 0, 1], port, false).unwrap(); sock_idx diff --git a/crates/kernel/src/signal.rs b/crates/kernel/src/signal.rs index 16894f8138..0274e898be 100644 --- a/crates/kernel/src/signal.rs +++ b/crates/kernel/src/signal.rs @@ -177,7 +177,8 @@ pub(crate) fn dequeue_signal_for( /// signals stay queued for the guest glue. While stopped, Process selection /// exposes only SIGKILL; SIGCONT has already resumed at generation time. pub(crate) fn deliver_pending_signals(proc: &mut Process, host: &mut dyn HostIO) { - deliver_pending_signals_impl(proc, None, host, crate::process_table::current_tid()); + let tid = crate::syscalls::current_tid_for_process(proc); + deliver_pending_signals_impl(proc, None, host, tid); } pub(crate) fn deliver_pending_signals_with_locks( @@ -185,7 +186,8 @@ pub(crate) fn deliver_pending_signals_with_locks( locks: &mut crate::lock::AdvisoryLockManager, host: &mut dyn HostIO, ) { - deliver_pending_signals_impl(proc, Some(locks), host, crate::process_table::current_tid()); + let tid = crate::syscalls::current_tid_for_process(proc); + deliver_pending_signals_impl(proc, Some(locks), host, tid); } /// Consume default/ignored signals for one exact kernel-owned task. diff --git a/crates/kernel/src/socket.rs b/crates/kernel/src/socket.rs index 33e87a499b..8ee02d89d1 100644 --- a/crates/kernel/src/socket.rs +++ b/crates/kernel/src/socket.rs @@ -670,7 +670,11 @@ pub struct SocketInfo { pub recv_timeout_us: u64, /// Send timeout in microseconds (0 = no timeout). pub send_timeout_us: u64, - /// Bound filesystem path for AF_UNIX sockets. + /// Original bounded sockaddr name supplied to AF_UNIX bind(). + /// + /// WHY: the UnixSocketRegistry owns the canonical namespace key. Keeping + /// that potentially much longer resolved path here would let getsockname() + /// report more bytes than the concrete sockaddr supplied by the caller. pub bind_path: Option>, /// Errno cached from a failed host-delegated connect; read and cleared /// by SO_ERROR (Linux semantics). 0 means no error. @@ -855,6 +859,55 @@ impl SocketTable { } self.entries[idx] = Some(info); } + + /// Decode the negative `-(slot + 1)` stored in a socket OFD. + pub(crate) fn index_from_ofd_handle(host_handle: i64) -> Result { + let index = host_handle + .checked_add(1) + .and_then(i64::checked_neg) + .ok_or(Errno::EBADF)?; + usize::try_from(index).map_err(|_| Errno::EBADF) + } + + /// Retain only socket slots owned by inherited OFDs. + /// + /// WHY: `peer_idx` is an operational process-local endpoint, not an owning + /// capability. Following it into a slot with no inherited OFD would create + /// a fake child-local peer that can accept bytes after the real peer has + /// closed. Edges between two independently inherited roots remain valid; + /// all other edges are cleared. + pub(crate) fn retain_inherited_roots(&mut self, roots: &[usize]) -> Result<(), Errno> { + let mut retained = Vec::new(); + retained + .try_reserve_exact(self.entries.len()) + .map_err(|_| Errno::ENOMEM)?; + retained.resize(self.entries.len(), false); + + // Validate every owning root before mutating the cloned table so an + // invalid OFD cannot leave a partially filtered child. + for &root in roots { + if self.get(root).is_none() { + return Err(Errno::EBADF); + } + retained[root] = true; + } + + for (index, slot) in self.entries.iter_mut().enumerate() { + if !retained[index] { + *slot = None; + continue; + } + if let Some(socket) = slot { + if socket + .peer_idx + .is_some_and(|peer| peer >= retained.len() || !retained[peer]) + { + socket.peer_idx = None; + } + } + } + Ok(()) + } } // ── Shared listener backlog (cross-process accept queue) ── @@ -1053,6 +1106,78 @@ mod tests { assert_eq!(idx0, idx1); } + #[test] + fn inherited_socket_roots_drop_non_root_peers_and_clear_dangling_edges() { + let mut table = SocketTable::new(); + let root = table.alloc(SocketInfo::new(SocketDomain::Unix, SocketType::Stream, 0)); + let peer = table.alloc(SocketInfo::new(SocketDomain::Unix, SocketType::Stream, 0)); + let retry_only = table.alloc(SocketInfo::new(SocketDomain::Inet, SocketType::Stream, 0)); + table.get_mut(root).unwrap().peer_idx = Some(peer); + table.get_mut(peer).unwrap().peer_idx = Some(root); + + table.retain_inherited_roots(&[root]).unwrap(); + assert!(table.get(root).is_some()); + assert_eq!(table.get(root).unwrap().peer_idx, None); + assert!(table.get(peer).is_none()); + assert!(table.get(retry_only).is_none()); + } + + #[test] + fn inherited_socket_roots_clear_a_dangling_peer() { + let mut table = SocketTable::new(); + let root = table.alloc(SocketInfo::new(SocketDomain::Unix, SocketType::Stream, 0)); + table.get_mut(root).unwrap().peer_idx = Some(9); + + assert_eq!(table.retain_inherited_roots(&[root]), Ok(())); + assert!(table.get(root).is_some()); + assert_eq!(table.get(root).unwrap().peer_idx, None); + } + + #[test] + fn inherited_socket_roots_reject_an_invalid_root_without_partial_filtering() { + let mut table = SocketTable::new(); + let left = table.alloc(SocketInfo::new(SocketDomain::Unix, SocketType::Dgram, 0)); + let right = table.alloc(SocketInfo::new(SocketDomain::Unix, SocketType::Dgram, 0)); + table.get_mut(left).unwrap().peer_idx = Some(right); + + assert_eq!( + table.retain_inherited_roots(&[left, 99]), + Err(Errno::EBADF) + ); + assert_eq!(table.get(left).unwrap().peer_idx, Some(right)); + assert!(table.get(right).is_some()); + } + + #[test] + fn inherited_socket_roots_preserve_edges_between_two_owning_roots() { + let mut table = SocketTable::new(); + let left = table.alloc(SocketInfo::new(SocketDomain::Unix, SocketType::Dgram, 0)); + let right = table.alloc(SocketInfo::new(SocketDomain::Unix, SocketType::Dgram, 0)); + let unrelated = table.alloc(SocketInfo::new(SocketDomain::Inet, SocketType::Stream, 0)); + table.get_mut(left).unwrap().peer_idx = Some(right); + table.get_mut(right).unwrap().peer_idx = Some(left); + + table.retain_inherited_roots(&[left, right]).unwrap(); + + assert_eq!(table.get(left).unwrap().peer_idx, Some(right)); + assert_eq!(table.get(right).unwrap().peer_idx, Some(left)); + assert!(table.get(unrelated).is_none()); + } + + #[test] + fn socket_ofd_handle_decoding_is_checked() { + assert_eq!(SocketTable::index_from_ofd_handle(-1), Ok(0)); + assert_eq!(SocketTable::index_from_ofd_handle(-2), Ok(1)); + assert_eq!( + SocketTable::index_from_ofd_handle(0), + Err(Errno::EBADF) + ); + assert_eq!( + SocketTable::index_from_ofd_handle(i64::MAX), + Err(Errno::EBADF) + ); + } + #[test] fn test_socket_state_transitions() { let mut sock = SocketInfo::new(SocketDomain::Unix, SocketType::Stream, 0); diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 2fc9b7caf3..2270e203ef 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -3,7 +3,6 @@ extern crate alloc; use alloc::borrow::Cow; use alloc::collections::VecDeque; use alloc::vec::Vec; -use wasm_posix_shared::Errno; use wasm_posix_shared::access::{R_OK, W_OK, X_OK}; use wasm_posix_shared::fcntl_cmd::*; use wasm_posix_shared::fd_flags::{FD_CLOEXEC, FD_CLOFORK}; @@ -11,22 +10,26 @@ use wasm_posix_shared::flags::*; use wasm_posix_shared::flock_op::*; use wasm_posix_shared::lock_type::*; use wasm_posix_shared::mode::{S_IFCHR, S_IFDIR, S_IFIFO, S_IFLNK, S_IFMT, S_IFREG}; -use wasm_posix_shared::rlimit::{RLIM_INFINITY, RLIMIT_FSIZE}; +use wasm_posix_shared::rlimit::{RLIMIT_FSIZE, RLIM_INFINITY}; use wasm_posix_shared::seek::*; -use wasm_posix_shared::{WasmFlock, WasmPollFd, WasmStat, WasmStatfs, WasmTimespec}; +use wasm_posix_shared::Errno; +use wasm_posix_shared::{ + platform_limits, WasmFlock, WasmPollFd, WasmStat, WasmStatfs, WasmTimespec, +}; +use crate::blocked_retry::{BlockingRetryOperation, BlockingRetryTarget, StableOfdTarget}; use crate::fd::OpenFileDescRef; use crate::lock::{ AdvisoryLockManager, AdvisoryLockType, FileId, KernelFileKind, LockMutation, LockOwner, LockRange, OfdId, }; use crate::ofd::FileType; -use crate::pipe::{DEFAULT_PIPE_CAPACITY, PipeBuffer}; -use crate::process::{HostIO, Process}; +use crate::pipe::{PipeBuffer, DEFAULT_PIPE_CAPACITY}; +use crate::process::{HostAppendOutcome, HostIO, Process}; use crate::signal::SignalHandler; use wasm_posix_shared::mmap::{MAP_ANONYMOUS, MAP_FAILED}; use wasm_posix_shared::signal::{ - NSIG, SIG_BLOCK, SIG_DFL, SIG_IGN, SIG_SETMASK, SIG_UNBLOCK, SIGKILL, SIGSTOP, SIGXFSZ, + NSIG, SIGKILL, SIGSTOP, SIGXFSZ, SIG_BLOCK, SIG_DFL, SIG_IGN, SIG_SETMASK, SIG_UNBLOCK, }; /// Creation flags that are stripped from status_flags after open. @@ -55,9 +58,72 @@ fn oflags_to_fd_flags(oflags: u32) -> u32 { /// Reject operations that require an I/O-capable open file description. /// Metadata queries, descriptor duplication/flags, `fchdir`, and `*at` /// pathname resolution deliberately do not call this helper. +pub(crate) fn resolve_io_ofd(proc: &Process, fd: i32) -> Result { + if let Some(binding) = proc.blocked_retries.active_binding_current()? { + let target = match &binding.target { + BlockingRetryTarget::Ofd(target) + | BlockingRetryTarget::Sendmsg { + carrier: target, .. + } => target, + BlockingRetryTarget::OfdPair { input, output } => { + if input.original_fd == fd { + input + } else if output.original_fd == fd { + output + } else { + return Err(Errno::EINVAL); + } + } + _ => return Err(Errno::EINVAL), + }; + if target.original_fd != fd { + return Err(Errno::EINVAL); + } + let ofd = proc.ofd_table.get(target.ofd_idx).ok_or(Errno::EBADF)?; + if ofd.ofd_id != target.ofd_id { + return Err(Errno::EBADF); + } + return Ok(target.ofd_idx); + } + Ok(proc.fd_table.get(fd)?.ofd_ref.0) +} + +/// Resolve caller identity without reborrowing the ProcessTable that owns +/// `proc`. +/// +/// ProcessTable mirrors its validated one-shot task binding into this +/// process-owned state before lending `&mut Process`. A tokenized retry adds +/// stricter dispatch/active identities, but both initial and retry calls stay +/// independent of ambient global-table reentry. +pub(crate) fn current_tid_for_process(proc: &Process) -> u32 { + let bound = proc + .blocked_retries + .dispatch_tid() + .or_else(|| proc.blocked_retries.active_tid()) + .or_else(|| proc.blocked_retries.bound_tid()); + if let Some(tid) = bound { + return tid; + } + // Standalone unit fixtures are deliberately not installed in the global + // table but retain the historical test-only ambient TID hook. + #[cfg(test)] + { + let tid = crate::process_table::current_tid(); + return if tid == 0 { proc.pid } else { tid }; + } + // Production ProcessTable-derived references always carry bound_tid. + // Kernel-internal operations on detached Process values have no ambient + // caller and therefore use the process leader instead of borrowing global + // state that may belong to an unrelated entry. + #[cfg(not(test))] + { + proc.pid + } +} + fn require_io_fd(proc: &Process, fd: i32) -> Result<(), Errno> { - let entry = proc.fd_table.get(fd)?; - let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + let ofd_idx = resolve_io_ofd(proc, fd)?; + let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; if ofd.is_path_only() { Err(Errno::EBADF) } else { @@ -708,6 +774,24 @@ fn commit_exec_state_impl( ) { return Err(Errno::ESRCH); } + if caller_tid != 0 && caller_tid != proc.pid && proc.get_thread(caller_tid).is_none() { + return Err(Errno::ESRCH); + } + // The old image owns every blocked request snapshot. Release its stable + // kernel targets before closing CLOEXEC descriptors or discarding sibling + // threads, so no retry can survive into the replacement program. + if let Some(machine_locks) = locks.as_deref_mut() { + // Exec has crossed its validation/prepare boundary. Like errors from + // closing a CLOEXEC descriptor below, a backing close error must not + // leave a half-committed image after the bindings were consumed. + let _ = release_all_blocking_retry_bindings(proc, machine_locks, host); + } else { + // Isolated syscall tests have no machine lock authority. They still + // need exact resource cleanup; the compatibility close path likewise + // uses a detached empty manager. + let mut isolated_locks = AdvisoryLockManager::new(); + let _ = release_all_blocking_retry_bindings(proc, &mut isolated_locks, host); + } // Exec replaces the image, not the process's job-control state. A stop // can be generated while the host is asynchronously resolving the new // executable, so retain it through the irreversible commit point. @@ -751,8 +835,7 @@ fn commit_exec_state_impl( proc.state = lifecycle_state; proc.exit_status = 0; proc.exit_signal = 0; - proc.thread_name = - [0; wasm_posix_shared::kernel_scratch_wire::PRCTL_NAME_BYTES as usize]; + proc.thread_name = [0; wasm_posix_shared::kernel_scratch_wire::PRCTL_NAME_BYTES as usize]; proc.clear_threads(); proc.sigsuspend_saved_mask = None; proc.alt_stack_sp = 0; @@ -1780,7 +1863,7 @@ fn check_access_for_ids( /// from a deep CWD. Component limits are byte limits as required by the guest /// ABI, not JavaScript UTF-16 code-unit limits in a host backend. const NAMESPACE_PATH_MAX: usize = wasm_posix_shared::platform_limits::PATH_MAX_BYTES; -const NAMESPACE_NAME_MAX: usize = 255; +const NAMESPACE_NAME_MAX: usize = wasm_posix_shared::platform_limits::NAME_MAX_BYTES - 1; #[derive(Clone, Copy)] struct PathResolveOptions { @@ -2316,7 +2399,7 @@ fn requested_id_allowed(current_real: u32, current_effective: u32, requested: u3 } fn fifo_open_owner(proc: &Process) -> u64 { - let tid = crate::process_table::current_tid(); + let tid = current_tid_for_process(proc); let guest_tid = if tid == 0 { proc.pid } else { tid }; ((proc.pid as u64) << 32) | guest_tid as u64 } @@ -2331,6 +2414,40 @@ pub(crate) fn cancel_fifo_open_for_owner(proc: &mut Process, owner: u64) -> bool true } +/// Cancel kernel state retained for one host-owned blocking wait. +/// +/// WHY: ppoll/pselect keep their temporary signal mask installed while the +/// host owns the sleeping retry. A terminal host-side preflight failure cannot +/// re-enter those syscalls to restore it, so the same synthetic cancellation +/// entry that releases FIFO reservations must restore the exact task mask. +/// Taking the saved value makes duplicate cancellation idempotent. +pub(crate) fn cancel_host_owned_wait_for_tid(proc: &mut Process, tid: u32) -> bool { + let owner = ((proc.pid as u64) << 32) | tid as u64; + let mut cancelled = cancel_fifo_open_for_owner(proc, owner); + if let Some(saved) = proc.take_sigsuspend_saved_mask_for(tid) { + proc.set_blocked_for(tid, saved); + cancelled = true; + } + cancelled +} + +/// Cancel every Rust-owned host-wait record for one validated live task. +/// +/// The process leader is a live explicit TID even though it has no ThreadInfo +/// entry. Centralizing that distinction prevents synthetic thread +/// cancellation from rejecting the main pthread while still failing closed +/// for zero, stale, or exited task identities. +pub(crate) fn cancel_host_owned_wait_for_live_tid( + proc: &mut Process, + tid: u32, +) -> Result<(), Errno> { + if !proc.is_live_explicit_tid(tid) { + return Err(Errno::ESRCH); + } + cancel_host_owned_wait_for_tid(proc, tid); + Ok(()) +} + fn cancel_fifo_opens_for_process(proc: &mut Process) { let waiters = unsafe { crate::pipe::global_pipe_table().cancel_fifo_opens_for_process(proc.pid) }; @@ -2341,7 +2458,7 @@ fn cancel_fifo_opens_for_process(proc: &mut Process) { } fn has_caught_signal_for_current_thread(proc: &Process) -> bool { - let tid = crate::process_table::current_tid(); + let tid = current_tid_for_process(proc); let guest_tid = if tid == 0 { proc.pid } else { tid }; let mut deliverable = proc.deliverable_for(guest_tid); while deliverable != 0 { @@ -3099,17 +3216,11 @@ fn sys_close_impl( let ofd_ref = proc.fd_table.free(fd)?; let idx = ofd_ref.0; - // Snapshot OFD state before dec_ref, which may free the slot. - let (host_handle, file_type, status_flags, dir_host_handle, ofd_id, mut file_id) = { + // Snapshot the fields needed for POSIX process-lock cleanup. Final backing + // cleanup is shared with kernel-owned blocked-retry pin release below. + let (host_handle, file_type, mut file_id) = { let ofd = proc.ofd_table.get(idx).ok_or(Errno::EBADF)?; - ( - ofd.host_handle, - ofd.file_type, - ofd.status_flags, - ofd.dir_host_handle, - ofd.ofd_id, - ofd.file_id, - ) + (ofd.host_handle, ofd.file_type, ofd.file_id) }; // Most regular-file OFDs receive their lock identity during open, but a @@ -3129,10 +3240,42 @@ fn sys_close_impl( }); } + // Closing any descriptor for a file releases every POSIX process lock + // owned by this PID on that file, even if another descriptor remains. + if let (Some(locks), Some(file_id)) = (locks.as_deref_mut(), file_id) { + publish_advisory_lock_mutation(locks.remove_process_file(proc.pid, file_id)); + } + + release_ofd_reference_impl(proc, locks, host, idx) +} + +/// Release one OFD reference and, when it is the final reference, perform the +/// complete backing cleanup transaction. +/// +/// WHY: a blocked retry is a kernel-owned OFD reference even after the guest +/// closes and reuses its numeric fd. It must run the same pipe/socket/DRI/ +/// device/host-handle cleanup as an ordinary final close; a shortened cleanup +/// path would trade the reuse bug for leaked or half-closed resources. +fn release_ofd_reference_impl( + proc: &mut Process, + mut locks: Option<&mut AdvisoryLockManager>, + host: &mut dyn HostIO, + idx: usize, +) -> Result<(), Errno> { + let (host_handle, file_type, status_flags, dir_host_handle, ofd_id) = { + let ofd = proc.ofd_table.get(idx).ok_or(Errno::EBADF)?; + ( + ofd.host_handle, + ofd.file_type, + ofd.status_flags, + ofd.dir_host_handle, + ofd.ofd_id, + ) + }; + // Snapshot the DRI sidecar so we can release it (decref bos, drop - // prime-bo cookies) once `dec_ref` actually frees the OFD on the - // last fd. `take()` only when this is the last reference — a - // `dup`-shared OFD must keep its DRI state until every fd closes. + // prime-bo cookies) once `dec_ref` actually frees the OFD. A retained + // blocked retry is a real reference, so it keeps this state live too. let dri_state_for_release = { let ofd = proc.ofd_table.get(idx).ok_or(Errno::EBADF)?; if ofd.ref_count == 1 { @@ -3144,12 +3287,6 @@ fn sys_close_impl( } }; - // Closing any descriptor for a file releases every POSIX process lock - // owned by this PID on that file, even if another descriptor remains. - if let (Some(locks), Some(file_id)) = (locks.as_deref_mut(), file_id) { - publish_advisory_lock_mutation(locks.remove_process_file(proc.pid, file_id)); - } - let freed = proc.ofd_table.dec_ref(idx); if freed { @@ -3185,9 +3322,11 @@ fn sys_close_impl( && sock.sock_type == crate::socket::SocketType::Dgram }); if let Some(sock) = proc.sockets.get(sock_idx) { - if let Some(path) = sock.bind_path.as_deref() { + if sock.bind_path.is_some() { let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; - registry.remove_owner(path, proc.pid, sock_idx); + // WHY: bind_path is only the original sockaddr and may + // have been renamed then reused by another endpoint. + registry.remove_owner_exact(proc.pid, sock_idx); } if sock.domain == crate::socket::SocketDomain::Inet && sock.sock_type == crate::socket::SocketType::Dgram @@ -3353,7 +3492,8 @@ fn sys_close_impl( // Fb0 fd and no live mmap remains. Linux semantics: an mmap // outlives close of its fd, so we only drop ownership when the // mapping is also gone (handled in munmap / process exit). - if file_type == FileType::CharDevice + if freed + && file_type == FileType::CharDevice && VirtualDevice::from_host_handle(host_handle) == Some(VirtualDevice::Fb0) && proc.fb_binding.is_none() && !proc_has_fb0_fd(proc) @@ -3363,7 +3503,8 @@ fn sys_close_impl( // /dev/input/mice ownership: release once the process has dropped // its last Mice fd. No mmap relationship to consider (unlike fb0). - if file_type == FileType::CharDevice + if freed + && file_type == FileType::CharDevice && VirtualDevice::from_host_handle(host_handle) == Some(VirtualDevice::Mice) && !proc_has_mice_fd(proc) { @@ -3373,7 +3514,8 @@ fn sys_close_impl( // /dev/dsp ownership: same pattern — release once the last Dsp fd // is gone and drop any unflushed PCM bytes so a successor open // starts from silence. - if file_type == FileType::CharDevice + if freed + && file_type == FileType::CharDevice && VirtualDevice::from_host_handle(host_handle) == Some(VirtualDevice::Dsp) && !proc_has_dsp_fd(proc) { @@ -3383,6 +3525,416 @@ fn sys_close_impl( Ok(()) } +fn stable_ofd_target(proc: &Process, fd: i32) -> Result { + let ofd_idx = proc.fd_table.get(fd)?.ofd_ref.0; + let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; + Ok(StableOfdTarget { + original_fd: fd, + ofd_idx, + ofd_id: ofd.ofd_id, + }) +} + +/// Retain the exact descriptor-backed target selected by a would-block result. +/// +/// This runs before control returns to JavaScript. No close, dup2, exec, or +/// nested channel can interleave between the failed attempt and this pin. +/// Retrying through a numeric fd later would instead let close/reuse redirect +/// the operation to an unrelated open file description. +pub(crate) fn ensure_blocking_retry_ofd_binding( + proc: &mut Process, + locks: &mut AdvisoryLockManager, + host: &mut dyn HostIO, + tid: u32, + syscall: u32, + fd: i32, + mut ancillary: Option>, +) -> Result { + let operation = BlockingRetryOperation::from_syscall(syscall)?; + if !operation.is_single_ofd() { + return Err(Errno::EINVAL); + } + if let Ok(token) = proc.blocked_retries.token_for(tid, operation) { + return Ok(token); + } + if proc.blocked_retries.has_binding_for_tid(tid) { + return Err(Errno::EBUSY); + } + + let token = proc.blocked_retries.prepare_insert()?; + let target = stable_ofd_target(proc, fd)?; + proc.ofd_table + .try_inc_ref_exact(target.ofd_idx, target.ofd_id)?; + + let retain_result = ancillary.as_mut().map_or(Ok(()), |batch| { + batch + .iter_mut() + .try_for_each(crate::pipe::InFlightFd::retain_reference) + }); + if let Err(error) = retain_result { + drop(ancillary); + let release = release_ofd_reference_impl(proc, Some(&mut *locks), host, target.ofd_idx); + drain_deferred_scm_rights_releases(locks, host); + return release.and(Err(error)); + } + + let target = if let Some(ancillary) = ancillary { + BlockingRetryTarget::Sendmsg { + carrier: target, + ancillary, + } + } else { + BlockingRetryTarget::Ofd(target) + }; + if let Err((error, target)) = proc + .blocked_retries + .insert_prepared(token, tid, operation, target) + { + let target = match target { + BlockingRetryTarget::Ofd(target) => target, + BlockingRetryTarget::Sendmsg { carrier, ancillary } => { + drop(ancillary); + carrier + } + _ => unreachable!("OFD insertion returned a non-OFD target"), + }; + let release = release_ofd_reference_impl(proc, Some(&mut *locks), host, target.ofd_idx); + drain_deferred_scm_rights_releases(locks, host); + return release.and(Err(error)); + } + Ok(token) +} + +/// Retain both exact open-file descriptions used by a blocked file transfer. +/// +/// WHY: sendfile/copy_file_range/splice can block on either endpoint. Pinning +/// only the endpoint that returned EAGAIN would let close+reuse redirect the +/// other half of the same logical request on retry. +pub(crate) fn ensure_blocking_retry_ofd_pair_binding( + proc: &mut Process, + locks: &mut AdvisoryLockManager, + host: &mut dyn HostIO, + tid: u32, + syscall: u32, + input_fd: i32, + output_fd: i32, +) -> Result { + let operation = BlockingRetryOperation::from_syscall(syscall)?; + if !operation.is_pair_ofd() { + return Err(Errno::EINVAL); + } + if let Ok(token) = proc.blocked_retries.token_for(tid, operation) { + return Ok(token); + } + if proc.blocked_retries.has_binding_for_tid(tid) { + return Err(Errno::EBUSY); + } + + let token = proc.blocked_retries.prepare_insert()?; + let input = stable_ofd_target(proc, input_fd)?; + let output = stable_ofd_target(proc, output_fd)?; + proc.ofd_table + .try_inc_ref_exact(input.ofd_idx, input.ofd_id)?; + if let Err(error) = proc + .ofd_table + .try_inc_ref_exact(output.ofd_idx, output.ofd_id) + { + let release = release_ofd_reference_impl(proc, Some(&mut *locks), host, input.ofd_idx); + drain_deferred_scm_rights_releases(locks, host); + return release.and(Err(error)); + } + + let target = BlockingRetryTarget::OfdPair { input, output }; + match proc + .blocked_retries + .insert_prepared(token, tid, operation, target) + { + Ok(()) => Ok(token), + Err((error, BlockingRetryTarget::OfdPair { input, output })) => { + let first = release_ofd_reference_impl(proc, Some(&mut *locks), host, input.ofd_idx); + let second = release_ofd_reference_impl(proc, Some(&mut *locks), host, output.ofd_idx); + drain_deferred_scm_rights_releases(locks, host); + first.and(second).and(Err(error)) + } + Err((_error, _)) => unreachable!("OFD-pair insertion returned another target kind"), + } +} + +pub(crate) fn ensure_blocking_retry_mqueue_binding( + proc: &mut Process, + tid: u32, + syscall: u32, + mqd: i32, +) -> Result { + let operation = BlockingRetryOperation::from_syscall(syscall)?; + if !matches!( + operation, + BlockingRetryOperation::MqSend | BlockingRetryOperation::MqReceive + ) { + return Err(Errno::EINVAL); + } + if let Ok(token) = proc.blocked_retries.token_for(tid, operation) { + return Ok(token); + } + if proc.blocked_retries.has_binding_for_tid(tid) { + return Err(Errno::EBUSY); + } + let token = proc.blocked_retries.prepare_insert()?; + let mqd = u32::try_from(mqd).map_err(|_| Errno::EBADF)?; + let table = unsafe { crate::mqueue::global_mqueue_table() }; + let pinned = table.pin_descriptor(mqd)?; + match proc.blocked_retries.insert_prepared( + token, + tid, + operation, + BlockingRetryTarget::Mqueue(pinned), + ) { + Ok(()) => Ok(token), + Err((error, BlockingRetryTarget::Mqueue(pinned))) => { + let _ = table.release_pinned_descriptor(pinned); + Err(error) + } + Err((_error, _)) => unreachable!("mqueue insertion returned another target kind"), + } +} + +pub(crate) fn ensure_blocking_retry_sysv_message_binding( + proc: &mut Process, + tid: u32, + syscall: u32, + qid: i32, +) -> Result { + let operation = BlockingRetryOperation::from_syscall(syscall)?; + if !matches!( + operation, + BlockingRetryOperation::MsgSend | BlockingRetryOperation::MsgReceive + ) { + return Err(Errno::EINVAL); + } + if let Ok(token) = proc.blocked_retries.token_for(tid, operation) { + return Ok(token); + } + if proc.blocked_retries.has_binding_for_tid(tid) { + return Err(Errno::EBUSY); + } + let token = proc.blocked_retries.prepare_insert()?; + let ipc = unsafe { crate::ipc::global_ipc_table() }; + let pinned = ipc.pin_msg_queue(qid)?; + match proc.blocked_retries.insert_prepared( + token, + tid, + operation, + BlockingRetryTarget::SysvMessage(pinned), + ) { + Ok(()) => Ok(token), + Err((error, BlockingRetryTarget::SysvMessage(pinned))) => { + let _ = ipc.release_msg_queue_pin(pinned); + Err(error) + } + Err((_error, _)) => unreachable!("message insertion returned another target kind"), + } +} + +pub(crate) fn ensure_blocking_retry_sysv_semaphore_binding( + proc: &mut Process, + tid: u32, + syscall: u32, + semid: i32, +) -> Result { + let operation = BlockingRetryOperation::from_syscall(syscall)?; + if operation != BlockingRetryOperation::Semop { + return Err(Errno::EINVAL); + } + if let Ok(token) = proc.blocked_retries.token_for(tid, operation) { + return Ok(token); + } + if proc.blocked_retries.has_binding_for_tid(tid) { + return Err(Errno::EBUSY); + } + let token = proc.blocked_retries.prepare_insert()?; + let ipc = unsafe { crate::ipc::global_ipc_table() }; + let pinned = ipc.pin_sem_set(semid)?; + match proc.blocked_retries.insert_prepared( + token, + tid, + operation, + BlockingRetryTarget::SysvSemaphore(pinned), + ) { + Ok(()) => Ok(token), + Err((error, BlockingRetryTarget::SysvSemaphore(pinned))) => { + let _ = ipc.release_sem_set_pin(pinned); + Err(error) + } + Err((_error, _)) => unreachable!("semaphore insertion returned another target kind"), + } +} + +fn release_blocking_retry_target( + proc: &mut Process, + locks: &mut AdvisoryLockManager, + host: &mut dyn HostIO, + target: BlockingRetryTarget, +) -> Result<(), Errno> { + match target { + BlockingRetryTarget::Ofd(target) => { + let result = release_ofd_reference_impl(proc, Some(locks), host, target.ofd_idx); + drain_deferred_scm_rights_releases(locks, host); + result + } + BlockingRetryTarget::OfdPair { input, output } => { + let first = release_ofd_reference_impl(proc, Some(&mut *locks), host, input.ofd_idx); + let second = release_ofd_reference_impl(proc, Some(&mut *locks), host, output.ofd_idx); + drain_deferred_scm_rights_releases(locks, host); + first.and(second) + } + BlockingRetryTarget::Sendmsg { carrier, ancillary } => { + drop(ancillary); + let result = release_ofd_reference_impl(proc, Some(&mut *locks), host, carrier.ofd_idx); + drain_deferred_scm_rights_releases(locks, host); + result + } + BlockingRetryTarget::Mqueue(pinned) => unsafe { + crate::mqueue::global_mqueue_table().release_pinned_descriptor(pinned) + }, + BlockingRetryTarget::SysvMessage(pinned) => unsafe { + crate::ipc::global_ipc_table().release_msg_queue_pin(pinned) + }, + BlockingRetryTarget::SysvSemaphore(pinned) => unsafe { + crate::ipc::global_ipc_table().release_sem_set_pin(pinned) + }, + } +} + +pub(crate) fn release_blocking_retry_binding( + proc: &mut Process, + locks: &mut AdvisoryLockManager, + host: &mut dyn HostIO, + tid: u32, + token: i64, +) -> Result<(), Errno> { + let binding = proc.blocked_retries.take_exact(tid, token)?; + release_blocking_retry_target(proc, locks, host, binding.target) +} + +pub(crate) fn release_blocking_retry_bindings_for_tid( + proc: &mut Process, + locks: &mut AdvisoryLockManager, + host: &mut dyn HostIO, + tid: u32, +) -> Result<(), Errno> { + let Some(binding) = proc.blocked_retries.take_for_tid(tid) else { + return Ok(()); + }; + match release_blocking_retry_target(proc, locks, host, binding.target) { + Ok(()) => Ok(()), + Err(error) => Err(error), + } +} + +/// Consume every resource owned by one exiting task before removing its +/// process-table record. +/// +/// WHY: the host may still hold an immutable retry snapshot for this TID. +/// Removing the thread first would make that snapshot unreachable while its +/// stable OFD/MQ/IPC target remained pinned for the process lifetime. +pub(crate) fn cleanup_exiting_thread( + proc: &mut Process, + locks: &mut AdvisoryLockManager, + host: &mut dyn HostIO, + tid: u32, +) -> Result<(), Errno> { + if proc.get_thread(tid).is_none() { + return Err(Errno::ESRCH); + } + let release_result = release_blocking_retry_bindings_for_tid(proc, locks, host, tid); + let owner = ((proc.pid as u64) << 32) | tid as u64; + cancel_fifo_open_for_owner(proc, owner); + proc.remove_thread(tid).ok_or(Errno::ESRCH)?; + release_result +} + +pub(crate) fn release_all_blocking_retry_bindings( + proc: &mut Process, + locks: &mut AdvisoryLockManager, + host: &mut dyn HostIO, +) -> Result<(), Errno> { + let bindings = proc.blocked_retries.take_all(); + let mut first_error = None; + for binding in bindings { + if let Err(error) = release_blocking_retry_target(proc, locks, host, binding.target) { + first_error.get_or_insert(error); + } + } + first_error.map_or(Ok(()), Err) +} + +/// Drop non-OFD pins before a whole Process is consumed by teardown. +/// +/// The process's OFD table is about to be destroyed as one unit, so its +/// internal retry refcounts need no individual decrement. Global MQ/IPC and +/// SCM_RIGHTS references do: otherwise teardown would leak objects outside +/// the process table. +pub(crate) fn discard_blocking_retry_bindings_for_process_removal(proc: &mut Process) { + for binding in proc.blocked_retries.take_all() { + match binding.target { + BlockingRetryTarget::Ofd(_) | BlockingRetryTarget::OfdPair { .. } => {} + BlockingRetryTarget::Sendmsg { ancillary, .. } => drop(ancillary), + BlockingRetryTarget::Mqueue(pinned) => unsafe { + let _ = crate::mqueue::global_mqueue_table().release_pinned_descriptor(pinned); + }, + BlockingRetryTarget::SysvMessage(pinned) => unsafe { + let _ = crate::ipc::global_ipc_table().release_msg_queue_pin(pinned); + }, + BlockingRetryTarget::SysvSemaphore(pinned) => unsafe { + let _ = crate::ipc::global_ipc_table().release_sem_set_pin(pinned); + }, + } + } +} + +pub(crate) fn clone_active_sendmsg_ancillary( + proc: &Process, + tid: u32, +) -> Result>, Errno> { + let Some(binding) = proc + .blocked_retries + .active_binding(tid, BlockingRetryOperation::Sendmsg)? + else { + return Ok(None); + }; + let BlockingRetryTarget::Sendmsg { ancillary, .. } = &binding.target else { + return Err(Errno::EINVAL); + }; + let mut cloned = Vec::new(); + cloned + .try_reserve_exact(ancillary.len()) + .map_err(|_| Errno::ENOMEM)?; + for fd in ancillary { + cloned.push(fd.try_clone_retained()?); + } + Ok(Some(cloned)) +} + +/// Return the cursor after a byte transfer without narrowing or wrapping. +pub(crate) fn checked_offset_advance(offset: i64, transferred: usize) -> Result { + let transferred = i64::try_from(transferred).map_err(|_| Errno::EOVERFLOW)?; + offset.checked_add(transferred).ok_or(Errno::EOVERFLOW) +} + +fn checked_host_cursor_advance( + offset: i64, + capacity: usize, + transferred: usize, +) -> Result { + // WHY: HostIO implementations are outside the kernel's trust boundary. + // Validate their byte count independently before using it to update + // kernel-owned OFD state; a malformed result must not corrupt the cursor. + if transferred > capacity { + return Err(Errno::EIO); + } + checked_offset_advance(offset, transferred) +} + /// Read from a file descriptor into `buf`, returning the number of bytes read. pub fn sys_read( proc: &mut Process, @@ -3390,8 +3942,7 @@ pub fn sys_read( fd: i32, buf: &mut [u8], ) -> Result { - let entry = proc.fd_table.get(fd)?; - let ofd_idx = entry.ofd_ref.0; + let ofd_idx = resolve_io_ofd(proc, fd)?; let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; @@ -3400,6 +3951,14 @@ pub fn sys_read( if ofd.is_path_only() || access_mode == O_WRONLY { return Err(Errno::EBADF); } + + // A zero-byte read still validates the descriptor and access mode, but it + // must not consult a pipe, socket, timer, terminal, or host backing that + // could report EAGAIN or otherwise consume state. + if buf.is_empty() { + return Ok(0); + } + let host_handle = ofd.host_handle; let file_type = ofd.file_type; let status_flags = ofd.status_flags; @@ -3433,13 +3992,6 @@ pub fn sys_read( let domain = sock.domain; let sock_type = sock.sock_type; let shut_rd = sock.shut_rd; - // A zero-count socket read validates the descriptor and socket - // backing but performs no receive. In particular it must not - // consume an empty AF_UNIX datagram (or the SCM_RIGHTS control - // message atomically attached to that datagram). - if buf.is_empty() { - return Ok(0); - } if shut_rd { return Ok(0); } @@ -3517,7 +4069,7 @@ pub fn sys_read( table.get(sfd_idx).map(|sfd| sfd.mask).ok_or(Errno::EBADF) })?; // Find a pending signal matching the mask - let tid = crate::process_table::current_tid(); + let tid = current_tid_for_process(proc); let pending = proc.pending_for(tid); let matching = pending & mask; if matching == 0 { @@ -3708,8 +4260,9 @@ pub fn sys_read( return Ok(0); } let n = buf.len().min(backing.data.len() - offset); + let new_offset = checked_offset_advance(backing.offset, n)?; buf[..n].copy_from_slice(&backing.data[offset..offset + n]); - backing.offset += n as i64; + backing.offset = new_offset; Ok(n) }); } @@ -3724,8 +4277,9 @@ pub fn sys_read( return Ok(0); } let n = buf.len().min(backing.data.len() - offset); + let new_offset = checked_offset_advance(backing.offset, n)?; buf[..n].copy_from_slice(&backing.data[offset..offset + n]); - backing.offset += n as i64; + backing.offset = new_offset; Ok(n) }); } @@ -3743,19 +4297,32 @@ pub fn sys_read( return Ok(0); } let n = buf.len().min(data.len() - offset); + let new_offset = checked_offset_advance(current, n)?; buf[..n].copy_from_slice(&data[offset..offset + n]); crate::descriptor_backing::set_current_offset( ofd.file_type, ofd.host_handle, - current.checked_add(n as i64).ok_or(Errno::EOVERFLOW)?, + new_offset, )?; return Ok(n); } - let n = host.host_read(host_handle, buf)?; - if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { - ofd.offset += n as i64; - } + let current_offset = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?.offset; + // WHY: Rust owns the ordinary-file cursor. A backend cursor can + // be different after fork/SCM_RIGHTS metadata is copied, and a + // seek/read pair would expose an intermediate cursor to nested or + // concurrent host activity. Streams and devices retain host_read. + // + // Unlike writes, a read at offset_t::MAX may still discover EOF + // and return zero without advancing. Validate the actual host + // count after that observation, before publishing a new cursor. + let n = if file_type == FileType::Regular { + host.host_pread(host_handle, buf, current_offset)? + } else { + host.host_read(host_handle, buf)? + }; + let new_offset = checked_host_cursor_advance(current_offset, buf.len(), n)?; + proc.ofd_table.get_mut(ofd_idx).ok_or(Errno::EBADF)?.offset = new_offset; Ok(n) } } @@ -3768,8 +4335,7 @@ pub fn sys_write( fd: i32, buf: &[u8], ) -> Result { - let entry = proc.fd_table.get(fd)?; - let ofd_idx = entry.ofd_ref.0; + let ofd_idx = resolve_io_ofd(proc, fd)?; let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; @@ -3967,10 +4533,10 @@ pub fn sys_write( let max_off = (FB_SMEM_LEN as usize).saturating_sub(offset); let n = buf.len().min(max_off); if n > 0 { + let new_offset = checked_offset_advance(ofd.offset, n)?; host.fb_write(proc.pid as i32, offset, &buf[..n]); - if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { - ofd.offset += n as i64; - } + proc.ofd_table.get_mut(ofd_idx).ok_or(Errno::EBADF)?.offset = + new_offset; } // Linux fbdev returns the requested length even // when capping at smem_len; we mirror that. @@ -3991,17 +4557,28 @@ pub fn sys_write( } } - // Compute RLIMIT_FSIZE once for this logical write. For regular - // files and memfds this resolves the authoritative append or - // open-file-description offset without changing either cursor. - let writable_len = write_operation_budget( - proc, - host, - crate::process_table::current_tid(), - fd, - None, - buf.len(), - )?; + if file_type == FileType::Regular && status_flags & O_APPEND != 0 { + let caller_tid = current_tid_for_process(proc); + let fsize_limit = match proc.rlimits[RLIMIT_FSIZE as usize][0] { + RLIM_INFINITY => None, + limit => Some(limit), + }; + // WHY: EOF, RLIMIT_FSIZE clipping, mutation, and the final + // position belong to one backing-owned operation. A separate + // fstat would only prove an obsolete EOF. + let outcome = host.host_append(host_handle, buf, fsize_limit)?; + let (written, end) = + validate_append_outcome(proc, caller_tid, buf.len(), fsize_limit, outcome)?; + proc.ofd_table.get_mut(ofd_idx).ok_or(Errno::EBADF)?.offset = end; + return Ok(written); + } + + // Compute RLIMIT_FSIZE once for this logical non-host-append + // write. This resolves the Rust-owned regular-file or memfd + // position without changing either cursor. + let caller_tid = current_tid_for_process(proc); + let write_plan = write_operation_plan(proc, host, caller_tid, fd, None, buf.len())?; + let writable_len = write_plan.length; // memfd: write to the shared in-memory backing. Apply O_APPEND // only at the actual non-empty mutation boundary. @@ -4027,18 +4604,31 @@ pub fn sys_write( }); } - // O_APPEND positioning belongs to the actual non-empty write, not - // the side-effect-free operation-budget query. - if writable_len > 0 && status_flags & O_APPEND != 0 { - let end = host.host_seek(host_handle, 0, 2)?; // SEEK_END - if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { - ofd.offset = end; - } + if file_type == FileType::Regular { + let start = write_plan + .regular_start + .ok_or(Errno::EIO) + .and_then(|offset| i64::try_from(offset).map_err(|_| Errno::EOVERFLOW))?; + // Prove the complete possible cursor advance before the host + // can mutate a backing file. + checked_offset_advance(start, writable_len)?; + // WHY: Rust owns the regular-file cursor. An ordinary write + // is one positioned host operation and never synchronizes a + // persistent backend flag or relies on its mutable cursor. + let n = host.host_pwrite(host_handle, &buf[..writable_len], start)?; + let new_offset = checked_host_cursor_advance(start, writable_len, n)?; + proc.ofd_table.get_mut(ofd_idx).ok_or(Errno::EBADF)?.offset = new_offset; + return Ok(n); } + + let current_offset = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?.offset; + // A successful write consumes some prefix of the attempted + // capacity. Prove even the complete attempt is representable + // before the host can make an irreversible backing-file change. + checked_offset_advance(current_offset, writable_len)?; let n = host.host_write(host_handle, &buf[..writable_len])?; - if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { - ofd.offset += n as i64; - } + let new_offset = checked_host_cursor_advance(current_offset, writable_len, n)?; + proc.ofd_table.get_mut(ofd_idx).ok_or(Errno::EBADF)?.offset = new_offset; Ok(n) } } @@ -4310,8 +4900,7 @@ pub fn sys_pread( if offset < 0 { return Err(Errno::EINVAL); } - let entry = proc.fd_table.get(fd)?; - let ofd_idx = entry.ofd_ref.0; + let ofd_idx = resolve_io_ofd(proc, fd)?; let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; let access_mode = ofd.status_flags & O_ACCMODE; @@ -4334,8 +4923,14 @@ pub fn sys_pread( return Err(Errno::ESPIPE); } + // Preserve scalar validation order for zero-length positioned reads: the + // fd, access mode, offset, and seekability were all checked above, but no + // backing operation is needed. + if buf.is_empty() { + return Ok(0); + } + let host_handle = ofd.host_handle; - let saved_offset = ofd.offset; if crate::descriptor_backing::is_synthetic_regular_handle(host_handle) { let data = synthetic_file_content(&ofd.path).ok_or(Errno::EBADF)?; @@ -4377,13 +4972,14 @@ pub fn sys_pread( }); } - // Seek to the requested offset, read, then restore. - // Single-threaded, so save/seek/read/restore is safe. - host.host_seek(host_handle, offset, SEEK_SET)?; - let n = host.host_read(host_handle, buf)?; - host.host_seek(host_handle, saved_offset, SEEK_SET)?; - - Ok(n) + // WHY: seek/read/seek is not positioned I/O. A nested or concurrent user + // of this open file description could observe or replace the temporary + // cursor. Keep the offset exact and make the host perform one operation. + let read = host.host_pread(host_handle, buf, offset)?; + if read > buf.len() { + return Err(Errno::EIO); + } + Ok(read) } /// Queue a synchronous file-size-limit signal for the thread that issued the @@ -4395,6 +4991,41 @@ fn raise_fsize_signal_for_caller(proc: &mut Process, tid: u32) -> Result<(), Err .ok_or(Errno::ESRCH) } +/// Validate and publish the scalar result of one backing-owned append. +/// +/// The start is derived from the paired end/count result, never from an +/// earlier stat. A backend at or beyond a finite limit must report a zero-byte +/// operation so Rust can generate the caller-thread SIGXFSZ side effect. +fn validate_append_outcome( + proc: &mut Process, + caller_tid: u32, + requested_len: usize, + fsize_limit: Option, + outcome: HostAppendOutcome, +) -> Result<(usize, i64), Errno> { + if outcome.written > requested_len { + return Err(Errno::EIO); + } + let written = u64::try_from(outcome.written).map_err(|_| Errno::EIO)?; + let start = outcome.end.checked_sub(written).ok_or(Errno::EIO)?; + + if let Some(limit) = fsize_limit { + if start >= limit { + if outcome.written != 0 { + return Err(Errno::EIO); + } + raise_fsize_signal_for_caller(proc, caller_tid)?; + return Err(Errno::EFBIG); + } + if outcome.end > limit { + return Err(Errno::EIO); + } + } + + let end = i64::try_from(outcome.end).map_err(|_| Errno::EOVERFLOW)?; + Ok((outcome.written, end)) +} + /// Apply POSIX RLIMIT_FSIZE semantics to one regular-file write operation. /// /// A write that starts before the soft file-size limit may complete partially @@ -4434,16 +5065,21 @@ fn fsize_limited_write_len( /// size without moving either the host or kernel cursor; the actual write owns /// append positioning. Non-regular objects are validated for write access but /// are not constrained by RLIMIT_FSIZE. -pub(crate) fn write_operation_budget( +struct WriteOperationPlan { + length: usize, + /// Starting offset for a regular file or memfd, after applying O_APPEND. + regular_start: Option, +} + +fn write_operation_plan( proc: &mut Process, host: &mut dyn HostIO, caller_tid: u32, fd: i32, offset: Option, requested_len: usize, -) -> Result { - let entry = proc.fd_table.get(fd)?; - let ofd_idx = entry.ofd_ref.0; +) -> Result { + let ofd_idx = resolve_io_ofd(proc, fd)?; let (file_type, status_flags, host_handle, current_offset, path_only) = { let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; ( @@ -4459,10 +5095,16 @@ pub(crate) fn write_operation_budget( return Err(Errno::EBADF); } if requested_len == 0 { - return Ok(0); + return Ok(WriteOperationPlan { + length: 0, + regular_start: None, + }); } if !matches!(file_type, FileType::Regular | FileType::MemFd) { - return Ok(requested_len); + return Ok(WriteOperationPlan { + length: requested_len, + regular_start: None, + }); } let start = if let Some(offset) = offset { @@ -4485,14 +5127,28 @@ pub(crate) fn write_operation_budget( } }; - fsize_limited_write_len(proc, caller_tid, start, requested_len) + Ok(WriteOperationPlan { + length: fsize_limited_write_len(proc, caller_tid, start, requested_len)?, + regular_start: Some(start), + }) +} + +pub(crate) fn write_operation_budget( + proc: &mut Process, + host: &mut dyn HostIO, + caller_tid: u32, + fd: i32, + offset: Option, + requested_len: usize, +) -> Result { + Ok(write_operation_plan(proc, host, caller_tid, fd, offset, requested_len)?.length) } /// Validate a transfer source without consuming data or changing its cursor. /// Output RLIMIT checks must not hide an invalid input descriptor. fn validate_transfer_input(proc: &Process, fd: i32, offset: Option) -> Result<(), Errno> { - let entry = proc.fd_table.get(fd)?; - let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + let ofd_idx = resolve_io_ofd(proc, fd)?; + let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; if ofd.is_path_only() || ofd.status_flags & O_ACCMODE == O_WRONLY { return Err(Errno::EBADF); } @@ -4517,6 +5173,141 @@ fn validate_transfer_input(proc: &Process, fd: i32, offset: Option) -> Resu Ok(()) } +#[derive(Clone, Copy)] +enum StagedTransferInput { + Positioned, + Cursor { + ofd_idx: usize, + file_type: FileType, + host_handle: i64, + start: i64, + }, + KernelPipe { + pipe_idx: usize, + }, +} + +/// Read transfer input without publishing cursor or queue consumption. +/// +/// A host append can truthfully reject an externally mutable backing only +/// when the actual append is attempted. Stage the source first so that +/// rejection, a finite-limit clip, or a short append consumes exactly the +/// prefix the single backing-owned append reports. +fn stage_transfer_input( + proc: &mut Process, + host: &mut dyn HostIO, + fd: i32, + offset: Option, + buf: &mut [u8], +) -> Result<(usize, StagedTransferInput), Errno> { + if let Some(offset) = offset { + return Ok(( + sys_pread(proc, host, fd, buf, offset)?, + StagedTransferInput::Positioned, + )); + } + + let ofd_idx = resolve_io_ofd(proc, fd)?; + let (file_type, host_handle, local_offset) = { + let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; + (ofd.file_type, ofd.host_handle, ofd.offset) + }; + match file_type { + FileType::Regular | FileType::MemFd => { + let start = + crate::descriptor_backing::current_offset(file_type, host_handle, local_offset)?; + let read = sys_pread(proc, host, fd, buf, start)?; + Ok(( + read, + StagedTransferInput::Cursor { + ofd_idx, + file_type, + host_handle, + start, + }, + )) + } + FileType::Pipe if host_handle < 0 => { + let pipe_idx = (-(host_handle + 1)) as usize; + let pipe = unsafe { crate::pipe::global_pipe_table().get_mut(pipe_idx) } + .ok_or(Errno::EBADF)?; + let read = pipe.peek(buf); + if read == 0 && !pipe.read_end_has_eof() { + return Err(Errno::EAGAIN); + } + Ok((read, StagedTransferInput::KernelPipe { pipe_idx })) + } + // A delegated pipe or device has no non-consuming read primitive. + // Refuse before calling it rather than losing bytes if append support + // or the exact atomic capacity is smaller than the staged request. + _ => Err(Errno::EOPNOTSUPP), + } +} + +fn commit_staged_transfer_input( + proc: &mut Process, + staged: StagedTransferInput, + transferred: usize, + scratch: &mut [u8], +) -> Result<(), Errno> { + match staged { + StagedTransferInput::Positioned => Ok(()), + StagedTransferInput::Cursor { + ofd_idx, + file_type, + host_handle, + start, + } => { + let end = checked_offset_advance(start, transferred)?; + let current = crate::descriptor_backing::current_offset(file_type, host_handle, start)?; + if current != start { + return Err(Errno::EIO); + } + if !crate::descriptor_backing::set_current_offset(file_type, host_handle, end)? { + proc.ofd_table.get_mut(ofd_idx).ok_or(Errno::EBADF)?.offset = end; + } + Ok(()) + } + StagedTransferInput::KernelPipe { pipe_idx } => { + if transferred == 0 { + return Ok(()); + } + let destination = scratch.get_mut(..transferred).ok_or(Errno::EIO)?; + let pipe = unsafe { crate::pipe::global_pipe_table().get_mut(pipe_idx) } + .ok_or(Errno::EBADF)?; + (pipe.read(destination) == transferred) + .then_some(()) + .ok_or(Errno::EIO) + } + } +} + +fn transfer_output_plan( + proc: &mut Process, + host: &mut dyn HostIO, + caller_tid: u32, + fd: i32, + offset: Option, + requested_len: usize, +) -> Result<(usize, bool), Errno> { + let ofd_idx = resolve_io_ofd(proc, fd)?; + let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; + if ofd.is_path_only() || ofd.status_flags & O_ACCMODE == O_RDONLY { + return Err(Errno::EBADF); + } + if offset.is_none() && ofd.file_type == FileType::Regular && ofd.status_flags & O_APPEND != 0 { + // WHY: only the backing-owned append operation has a current EOF. + // Do not pre-limit against fstat: concurrent growth could raise an + // early EFBIG, and concurrent shrink could under-copy. The exact + // append result clips against RLIMIT_FSIZE and drives source commit. + return Ok((requested_len, true)); + } + Ok(( + write_operation_budget(proc, host, caller_tid, fd, offset, requested_len)?, + false, + )) +} + /// Write to a file descriptor at a given offset without modifying the file position. pub fn sys_pwrite( proc: &mut Process, @@ -4529,8 +5320,7 @@ pub fn sys_pwrite( if offset < 0 { return Err(Errno::EINVAL); } - let entry = proc.fd_table.get(fd)?; - let ofd_idx = entry.ofd_ref.0; + let ofd_idx = resolve_io_ofd(proc, fd)?; let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; let access_mode = ofd.status_flags & O_ACCMODE; @@ -4552,17 +5342,17 @@ pub fn sys_pwrite( return Err(Errno::ESPIPE); } + // Match the scalar write contract: validate the fd, access mode, offset, + // and seekability, then complete a zero-byte operation without consulting + // the backing object or applying a file-size limit. + if buf.is_empty() { + return Ok(0); + } + let host_handle = ofd.host_handle; let file_type = ofd.file_type; - let saved_offset = ofd.offset; - let writable_len = write_operation_budget( - proc, - host, - crate::process_table::current_tid(), - fd, - Some(offset), - buf.len(), - )?; + let caller_tid = current_tid_for_process(proc); + let writable_len = write_operation_budget(proc, host, caller_tid, fd, Some(offset), buf.len())?; if file_type == FileType::MemFd { let memfd_idx = (-(host_handle + 1)) as usize; @@ -4578,11 +5368,13 @@ pub fn sys_pwrite( }); } - host.host_seek(host_handle, offset, SEEK_SET)?; - let n = host.host_write(host_handle, &buf[..writable_len])?; - host.host_seek(host_handle, saved_offset, SEEK_SET)?; - - Ok(n) + // WHY: the host owns the backing cursor. Only a true positioned call can + // guarantee that this operation neither observes nor mutates it. + let written = host.host_pwrite(host_handle, &buf[..writable_len], offset)?; + if written > writable_len { + return Err(Errno::EIO); + } + Ok(written) } /// preadv -- scatter-gather read at offset. @@ -4596,35 +5388,99 @@ pub fn sys_preadv( offset: i64, ) -> Result { require_io_fd(proc, fd)?; - let mut total = 0usize; - let mut cur_offset = offset; - for buf in iovecs.iter_mut() { - if buf.is_empty() { - continue; - } - let n = sys_pread(proc, host, fd, *buf, cur_offset)?; - total += n; - cur_offset += n as i64; - if n < buf.len() || n == 0 { - break; // Short read or EOF - } - } - Ok(total) + let requested_len = checked_iovec_len(iovecs.len(), iovecs.iter().map(|buf| buf.len()))?; + let mut gathered = try_initialized_vec(requested_len)?; + let read = sys_pread(proc, host, fd, &mut gathered, offset)?; + scatter_iovec_prefix(iovecs, &gathered, read)?; + Ok(read) } /// Validate the total byte count represented by one wasm32 scatter/gather /// operation. Syscall return values are signed 32-bit even when the guest uses /// memory64, so a larger aggregate cannot be reported faithfully. -fn checked_iovec_len(iovecs: &[&[u8]]) -> Result { - let total = iovecs.iter().try_fold(0usize, |total, buf| { - total.checked_add(buf.len()).ok_or(Errno::EINVAL) +fn checked_iovec_len( + iovec_count: usize, + lengths: impl IntoIterator, +) -> Result { + if iovec_count > wasm_posix_shared::platform_limits::IOV_MAX { + return Err(Errno::EINVAL); + } + let total = lengths.into_iter().try_fold(0usize, |total, length| { + total.checked_add(length).ok_or(Errno::EINVAL) })?; - if total > i32::MAX as usize { + if total > platform_limits::MAX_REPORTABLE_TRANSFER_BYTES { return Err(Errno::EINVAL); } Ok(total) } +fn try_initialized_vec(length: usize) -> Result, Errno> { + try_initialized_vec_with_reserve(length, |bytes, additional| { + bytes + .try_reserve_exact(additional) + .map_err(|_| Errno::ENOMEM) + }) +} + +fn try_initialized_vec_with_reserve( + length: usize, + reserve: impl FnOnce(&mut Vec, usize) -> Result<(), Errno>, +) -> Result, Errno> { + let mut bytes = Vec::new(); + reserve(&mut bytes, length)?; + if bytes.capacity() < length { + return Err(Errno::ENOMEM); + } + bytes.resize(length, 0); + Ok(bytes) +} + +fn gather_iovecs(iovecs: &[&[u8]]) -> Result, Errno> { + gather_iovecs_with_reserve(iovecs, |bytes, additional| { + bytes + .try_reserve_exact(additional) + .map_err(|_| Errno::ENOMEM) + }) +} + +fn gather_iovecs_with_reserve( + iovecs: &[&[u8]], + reserve: impl FnOnce(&mut Vec, usize) -> Result<(), Errno>, +) -> Result, Errno> { + let length = checked_iovec_len(iovecs.len(), iovecs.iter().map(|buf| buf.len()))?; + let mut gathered = Vec::new(); + reserve(&mut gathered, length)?; + if gathered.capacity() < length { + return Err(Errno::ENOMEM); + } + for buf in iovecs { + gathered.extend_from_slice(buf); + } + Ok(gathered) +} + +fn scatter_iovec_prefix( + iovecs: &mut [&mut [u8]], + gathered: &[u8], + length: usize, +) -> Result<(), Errno> { + let source = gathered.get(..length).ok_or(Errno::EIO)?; + let mut copied = 0usize; + for destination in iovecs { + if copied == source.len() { + break; + } + let count = destination.len().min(source.len() - copied); + destination[..count].copy_from_slice(&source[copied..copied + count]); + copied += count; + } + if copied == source.len() { + Ok(()) + } else { + Err(Errno::EIO) + } +} + /// pwritev -- scatter-gather write at offset. /// Writes from multiple buffers to a file descriptor at the given offset /// without modifying the file position. @@ -4635,40 +5491,14 @@ pub fn sys_pwritev( iovecs: &[&[u8]], offset: i64, ) -> Result { - let requested_len = checked_iovec_len(iovecs)?; - let writable_len = write_operation_budget( - proc, - host, - crate::process_table::current_tid(), - fd, - Some(offset), - requested_len, - )?; - let mut total = 0usize; - let mut cur_offset = offset; - for buf in iovecs { - if total == writable_len { - break; - } - if buf.is_empty() { - continue; - } - let operation_remaining = writable_len - total; - let attempted = buf.len().min(operation_remaining); - let n = match sys_pwrite(proc, host, fd, &buf[..attempted], cur_offset) { - Ok(n) => n, - Err(_) if total > 0 => return Ok(total), - Err(e) => return Err(e), - }; - total += n; - cur_offset = cur_offset - .checked_add(i64::try_from(n).map_err(|_| Errno::EOVERFLOW)?) - .ok_or(Errno::EOVERFLOW)?; - if n < attempted || total == writable_len { - break; // Short write - } + require_io_fd(proc, fd)?; + if offset < 0 { + return Err(Errno::EINVAL); } - Ok(total) + let gathered = gather_iovecs(iovecs)?; + // One scalar positioned write preserves the operation-wide file offset, + // file-size-limit decision, and backing-object atomicity. + sys_pwrite(proc, host, fd, &gathered, offset) } /// sendfile -- copy data between file descriptors. @@ -4684,10 +5514,10 @@ pub fn sys_sendfile( count: usize, ) -> Result { validate_transfer_input(proc, in_fd, (offset >= 0).then_some(offset))?; - let writable_len = write_operation_budget( + let (writable_len, stage_source) = transfer_output_plan( proc, host, - crate::process_table::current_tid(), + current_tid_for_process(proc), out_fd, None, count, @@ -4701,12 +5531,25 @@ pub fn sys_sendfile( while total < writable_len { let to_read = (writable_len - total).min(buf.len()); - let n = if offset >= 0 { - match sys_pread(proc, host, in_fd, &mut buf[..to_read], cur_offset) { - Ok(n) => { - cur_offset += n as i64; - n + let (n, staged) = if stage_source { + match stage_transfer_input( + proc, + host, + in_fd, + (offset >= 0).then_some(cur_offset), + &mut buf[..to_read], + ) { + Ok(result) => (result.0, Some(result.1)), + Err(e) => { + if total > 0 { + return Ok(total); + } + return Err(e); } + } + } else if offset >= 0 { + match sys_pread(proc, host, in_fd, &mut buf[..to_read], cur_offset) { + Ok(n) => (n, None), Err(e) => { if total > 0 { return Ok(total); @@ -4716,7 +5559,7 @@ pub fn sys_sendfile( } } else { match sys_read(proc, host, in_fd, &mut buf[..to_read]) { - Ok(n) => n, + Ok(n) => (n, None), Err(e) => { if total > 0 { return Ok(total); @@ -4732,6 +5575,12 @@ pub fn sys_sendfile( match sys_write(proc, host, out_fd, &buf[..n]) { Ok(written) => { + if let Some(staged) = staged { + commit_staged_transfer_input(proc, staged, written, &mut buf)?; + } + if offset >= 0 { + cur_offset = checked_offset_advance(cur_offset, written)?; + } total += written; if written < n { break; // Short write @@ -4762,10 +5611,10 @@ pub fn sys_copy_file_range( len: usize, ) -> Result { validate_transfer_input(proc, fd_in, off_in)?; - let writable_len = write_operation_budget( + let (writable_len, stage_source) = transfer_output_plan( proc, host, - crate::process_table::current_tid(), + current_tid_for_process(proc), fd_out, off_out, len, @@ -4780,12 +5629,25 @@ pub fn sys_copy_file_range( while total < writable_len { let to_read = (writable_len - total).min(buf.len()); - let n = if off_in.is_some() { - match sys_pread(proc, host, fd_in, &mut buf[..to_read], cur_off_in) { - Ok(n) => { - cur_off_in += n as i64; - n + let (n, staged) = if stage_source { + match stage_transfer_input( + proc, + host, + fd_in, + off_in.map(|_| cur_off_in), + &mut buf[..to_read], + ) { + Ok(result) => (result.0, Some(result.1)), + Err(e) => { + if total > 0 { + return Ok(total); + } + return Err(e); } + } + } else if off_in.is_some() { + match sys_pread(proc, host, fd_in, &mut buf[..to_read], cur_off_in) { + Ok(n) => (n, None), Err(e) => { if total > 0 { return Ok(total); @@ -4795,7 +5657,7 @@ pub fn sys_copy_file_range( } } else { match sys_read(proc, host, fd_in, &mut buf[..to_read]) { - Ok(n) => n, + Ok(n) => (n, None), Err(e) => { if total > 0 { return Ok(total); @@ -4810,7 +5672,7 @@ pub fn sys_copy_file_range( let written = if off_out.is_some() { match sys_pwrite(proc, host, fd_out, &buf[..n], cur_off_out) { Ok(w) => { - cur_off_out += w as i64; + cur_off_out = checked_offset_advance(cur_off_out, w)?; w } Err(e) => { @@ -4831,6 +5693,12 @@ pub fn sys_copy_file_range( } } }; + if let Some(staged) = staged { + commit_staged_transfer_input(proc, staged, written, &mut buf)?; + } + if off_in.is_some() { + cur_off_in = checked_offset_advance(cur_off_in, written)?; + } total += written; if written < n { break; @@ -5080,8 +5948,7 @@ pub fn sys_pipe2(proc: &mut Process, flags: u32) -> Result<(i32, i32), Errno> { /// Get file status information. pub fn sys_fstat(proc: &Process, host: &mut dyn HostIO, fd: i32) -> Result { - let entry = proc.fd_table.get(fd)?; - let ofd_idx = entry.ofd_ref.0; + let ofd_idx = resolve_io_ofd(proc, fd)?; let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; @@ -5445,8 +6312,7 @@ fn sys_fcntl_lock_with_owner( ) -> Result<(), Errno> { use wasm_posix_shared::fcntl_cmd::{F_OFD_GETLK, F_OFD_SETLK, F_OFD_SETLKW}; - let entry = proc.fd_table.get(fd)?; - let ofd_idx = entry.ofd_ref.0; + let ofd_idx = resolve_io_ofd(proc, fd)?; let (host_handle, file_type, status_flags, local_offset, ofd_id) = { let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; if ofd.is_path_only() { @@ -5720,8 +6586,8 @@ fn fifo_path_stat( } fn named_fifo_pipe_idx(proc: &Process, fd: i32) -> Result, Errno> { - let entry = proc.fd_table.get(fd)?; - let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + let ofd_idx = resolve_io_ofd(proc, fd)?; + let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; if ofd.file_type != FileType::Pipe || ofd.host_handle >= 0 { return Ok(None); } @@ -6308,8 +7174,8 @@ pub fn sys_chdir(proc: &mut Process, host: &mut dyn HostIO, path: &[u8]) -> Resu /// Change directory by file descriptor. pub fn sys_fchdir(proc: &mut Process, fd: i32) -> Result<(), Errno> { - let entry = proc.fd_table.get(fd)?; - let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + let ofd_idx = resolve_io_ofd(proc, fd)?; + let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; if ofd.file_type != FileType::Directory { return Err(Errno::ENOTDIR); } @@ -7270,7 +8136,7 @@ pub fn sys_sigtimedwait( ) -> Result<(u32, u64, i32, i32, i32), Errno> { use wasm_posix_shared::signal::NSIG; - let tid = crate::process_table::current_tid(); + let tid = current_tid_for_process(proc); // Scan the calling thread's pending set (shared + directed) for a match. let pending_in_mask = proc.pending_for(tid) & mask; @@ -7285,13 +8151,7 @@ pub fn sys_sigtimedwait( ), None => (info.sender_pid as i32, info.sender_uid as i32), }; - return Ok(( - signum, - info.si_value_bits, - info.si_code, - word_1, - word_2, - )); + return Ok((signum, info.si_value_bits, info.si_code, word_1, word_2)); } } @@ -7306,7 +8166,7 @@ pub fn sys_sigtimedwait( pub fn sys_sigsuspend(proc: &mut Process, _host: &mut dyn HostIO, mask: u64) -> Result<(), Errno> { use wasm_posix_shared::signal::{SIGKILL, SIGSTOP}; - let tid = crate::process_table::current_tid(); + let tid = current_tid_for_process(proc); let sig_guard = crate::signal::sig_bit(SIGKILL) | crate::signal::sig_bit(SIGSTOP); let new_mask = mask & !sig_guard; @@ -7430,7 +8290,7 @@ pub fn sys_signal(proc: &mut Process, signum: u32, handler_val: u32) -> Result Result { - let tid = crate::process_table::current_tid(); + let tid = current_tid_for_process(proc); let old_mask = proc.blocked_for(tid); let mut new_mask = match how { @@ -7453,6 +8313,24 @@ fn cleanup_process_for_exit( mut locks: Option<&mut AdvisoryLockManager>, host: &mut dyn HostIO, ) { + // The exiting image owns every blocked request. Consume its stable + // targets before the ordinary fd walk so a guest-closed descriptor, MQ + // pin, SysV pin, or SCM_RIGHTS template cannot remain reachable only from + // a host promise after the process has published Exited. + if let Some(machine_locks) = locks.as_deref_mut() { + let _ = release_all_blocking_retry_bindings(proc, machine_locks, host); + } else { + // Isolated syscall tests have no machine lock table, but still need + // the exact resource-release transaction. + let mut isolated_locks = AdvisoryLockManager::new(); + let _ = release_all_blocking_retry_bindings(proc, &mut isolated_locks, host); + } + + // FIFO open keeps a reserved descriptor and endpoint outside the ordinary + // open-fd walk. A zombie can remain unreaped indefinitely, so waiting for + // ProcessTable removal would leak both resources past process exit. + cancel_fifo_opens_for_process(proc); + release_process_dri_mappings(proc, host); // Snapshot the sparse table because sys_close mutates it. RLIMIT_NOFILE @@ -7811,7 +8689,12 @@ pub fn sys_utimensat( } /// Memory advice hint. No-op in Wasm — there's no virtual memory paging. -pub fn sys_madvise(_proc: &mut Process, _addr: u32, _len: u32, _advice: u32) -> Result<(), Errno> { +pub fn sys_madvise( + _proc: &mut Process, + _addr: usize, + _len: usize, + _advice: u32, +) -> Result<(), Errno> { Ok(()) } @@ -7935,6 +8818,14 @@ pub fn sys_setenv( if name.is_empty() || name.contains(&b'=') { return Err(Errno::EINVAL); } + let encoded_entry_bytes = name + .len() + .checked_add(1) + .and_then(|length| length.checked_add(value.len())) + .ok_or(Errno::E2BIG)?; + if encoded_entry_bytes > wasm_posix_shared::platform_limits::PROCESS_METADATA_ENTRY_MAX_BYTES { + return Err(Errno::E2BIG); + } // Check if it already exists for entry in proc.environ.iter_mut() { @@ -8440,6 +9331,17 @@ fn sockaddr_family(addr: &[u8]) -> Result { Ok(u16::from_le_bytes([addr[0], addr[1]])) } +pub(crate) fn checked_sockaddr_un_path(addr: &[u8]) -> Result<&[u8], Errno> { + let path_offset = + wasm_posix_shared::kernel_scratch_wire::SOCKADDR_UNIX_PATH_OFFSET_BYTES as usize; + if addr.len() <= path_offset + || addr.len() > wasm_posix_shared::kernel_scratch_wire::SOCKADDR_UNIX_BYTES as usize + { + return Err(Errno::EINVAL); + } + Ok(&addr[path_offset..]) +} + fn parse_sockaddr_in(addr: &[u8]) -> Result<([u8; 4], u16), Errno> { use wasm_posix_shared::socket::AF_INET; @@ -8506,6 +9408,36 @@ fn write_sockaddr_in6(buf: &mut [u8], addr: [u8; 16], port: u16) -> usize { 28 } +fn write_sockaddr_family_prefix(buf: &mut [u8], family: u16) { + let family_bytes = family.to_le_bytes(); + let copied = buf.len().min(family_bytes.len()); + buf[..copied].copy_from_slice(&family_bytes[..copied]); +} + +pub(crate) fn validate_optional_socket_address_output( + address_present: bool, + length_pointer_present: bool, +) -> Result { + if !address_present { + // POSIX conditions the value-result length on the address pointer. + // Do not inspect or modify an ignored length pointer. + return Ok(false); + } + if !length_pointer_present { + return Err(Errno::EFAULT); + } + Ok(true) +} + +pub(crate) fn write_accept_peer_address( + proc: &Process, + fd: i32, + addr: &mut [u8], +) -> Result { + let actual = sys_getpeername(proc, fd, addr)?; + u32::try_from(actual).map_err(|_| Errno::EOVERFLOW) +} + fn is_loopback_addr(addr: [u8; 4]) -> bool { addr[0] == 127 } @@ -8672,7 +9604,7 @@ fn ipv4_multicast_interface_matches(interface_addr: [u8; 4], ingress_interface: } fn udp_reuse_addr(sock: &crate::socket::SocketInfo) -> bool { - use wasm_posix_shared::socket::{SO_REUSEADDR, SOL_SOCKET}; + use wasm_posix_shared::socket::{SOL_SOCKET, SO_REUSEADDR}; sock.get_option(SOL_SOCKET, SO_REUSEADDR).unwrap_or(0) != 0 } @@ -9002,7 +9934,7 @@ fn udp_send_datagram( udp_take_socket_error(proc, sock_idx)?; } if dst_addr == [255, 255, 255, 255] { - use wasm_posix_shared::socket::{SO_BROADCAST, SOL_SOCKET}; + use wasm_posix_shared::socket::{SOL_SOCKET, SO_BROADCAST}; let broadcast_enabled = proc .sockets @@ -9040,7 +9972,7 @@ fn udp_send_datagram( }; let (src_pid, src_uid, src_gid) = (proc.pid, proc.uid, proc.gid); if is_ipv4_multicast_addr(dst_addr) { - use wasm_posix_shared::socket::{IP_MULTICAST_IF, IP_MULTICAST_LOOP, IPPROTO_IP}; + use wasm_posix_shared::socket::{IPPROTO_IP, IP_MULTICAST_IF, IP_MULTICAST_LOOP}; let (loop_enabled, outgoing_interface) = { let sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; @@ -9500,8 +10432,8 @@ pub fn inject_udp_datagram_into( /// /// For AF_INET sockets, writes a full 16-byte sockaddr_in: /// family(2 LE) + port(2 BE) + addr(4) + zero(8) -/// For AF_UNIX sockets, writes AF_UNIX (family=1) with empty path. -/// Returns the number of bytes written. +/// For AF_UNIX sockets, writes the original bounded name supplied to bind(). +/// Returns the complete address length, even when `buf` truncates the bytes. pub fn sys_getsockname(proc: &Process, fd: i32, buf: &mut [u8]) -> Result { use crate::socket::SocketDomain; @@ -9517,35 +10449,32 @@ pub fn sys_getsockname(proc: &Process, fd: i32, buf: &mut [u8]) -> Result Ok(write_sockaddr_in(buf, sock.bind_addr, sock.bind_port)), SocketDomain::Inet6 => Ok(write_sockaddr_in6(buf, sock.bind_addr6, sock.bind_port)), SocketDomain::Unix => { + use wasm_posix_shared::socket::AF_UNIX; + + let path_offset = + wasm_posix_shared::kernel_scratch_wire::SOCKADDR_UNIX_PATH_OFFSET_BYTES as usize; if let Some(ref path) = sock.bind_path { // sockaddr_un: family(2) + path. Filesystem paths are // null-terminated; Linux abstract namespace paths start with // NUL and use the addrlen as their length (no terminator). let abstract_unix = path.first().copied() == Some(0); - let total_len = 2 + path.len() + if abstract_unix { 0 } else { 1 }; + let total_len = path_offset + path.len() + if abstract_unix { 0 } else { 1 }; let n = buf.len().min(total_len); - if n >= 1 { - buf[0] = 1; - } // AF_UNIX low byte - if n >= 2 { - buf[1] = 0; - } // AF_UNIX high byte - let path_copy = n.saturating_sub(2).min(path.len()); + write_sockaddr_family_prefix(&mut buf[..n], AF_UNIX as u16); + let path_copy = n.saturating_sub(path_offset).min(path.len()); if path_copy > 0 { - buf[2..2 + path_copy].copy_from_slice(&path[..path_copy]); + buf[path_offset..path_offset + path_copy] + .copy_from_slice(&path[..path_copy]); } // Null terminate filesystem paths if room. - if !abstract_unix && n > 2 + path_copy { - buf[2 + path_copy] = 0; + if !abstract_unix && n > path_offset + path_copy { + buf[path_offset + path_copy] = 0; } Ok(total_len) } else { // Unbound AF_UNIX socket — return just the family - if buf.len() >= 2 { - buf[0] = 1; // AF_UNIX - buf[1] = 0; - } - Ok(2) + write_sockaddr_family_prefix(buf, AF_UNIX as u16); + Ok(path_offset) } } } @@ -9582,11 +10511,11 @@ pub fn sys_getpeername(proc: &Process, fd: i32, buf: &mut [u8]) -> Result Ok(write_sockaddr_in(buf, sock.peer_addr, sock.peer_port)), SocketDomain::Inet6 => Ok(write_sockaddr_in6(buf, sock.peer_addr6, sock.peer_port)), SocketDomain::Unix => { - if buf.len() >= 2 { - buf[0] = 1; // AF_UNIX - buf[1] = 0; - } - Ok(2) + write_sockaddr_family_prefix(buf, wasm_posix_shared::socket::AF_UNIX as u16); + Ok( + wasm_posix_shared::kernel_scratch_wire::SOCKADDR_UNIX_PATH_OFFSET_BYTES + as usize, + ) } } } @@ -9706,8 +10635,8 @@ pub fn sys_send( use crate::socket::{SocketDomain, SocketState, SocketType}; use wasm_posix_shared::socket::{MSG_NOSIGNAL, MSG_OOB}; - let entry = proc.fd_table.get(fd)?; - let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + let ofd_idx = resolve_io_ofd(proc, fd)?; + let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; if ofd.file_type != FileType::Socket { return Err(Errno::ENOTSOCK); } @@ -9835,8 +10764,8 @@ pub fn sys_recv( use wasm_posix_shared::socket::{MSG_OOB, MSG_PEEK}; const MSG_WAITALL: u32 = 0x100; - let entry = proc.fd_table.get(fd)?; - let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + let ofd_idx = resolve_io_ofd(proc, fd)?; + let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; if ofd.file_type != FileType::Socket { return Err(Errno::ENOTSOCK); } @@ -10009,7 +10938,8 @@ pub fn sys_getsockopt(proc: &mut Process, fd: i32, level: u32, optname: u32) -> } /// Size of `struct tcp_info` (libc/musl/linux, wasm32). -pub const TCP_INFO_SIZE: usize = 232; +pub const TCP_INFO_SIZE: usize = + wasm_posix_shared::kernel_scratch_wire::SOCKET_OPTION_MAX_BYTES as usize; /// Build a virtual `struct tcp_info` for a socket. /// Returns a byte buffer matching the musl `struct tcp_info` layout with @@ -10073,7 +11003,7 @@ pub fn sys_getsockopt_tcp_info(proc: &Process, fd: i32) -> Result<[u8; TCP_INFO_ /// architecture-neutral time64 constants. pub(crate) fn canonical_socket_timeout_optname(level: u32, optname: u32) -> Option { use wasm_posix_shared::socket::{ - SO_RCVTIMEO, SO_RCVTIMEO_OLD, SO_SNDTIMEO, SO_SNDTIMEO_OLD, SOL_SOCKET, + SOL_SOCKET, SO_RCVTIMEO, SO_RCVTIMEO_OLD, SO_SNDTIMEO, SO_SNDTIMEO_OLD, }; if level != SOL_SOCKET { @@ -10518,12 +11448,10 @@ pub fn sys_bind( } SocketDomain::Unix => { // sockaddr_un: family(2) + sun_path (null-terminated, up to 108 bytes) - if addr.len() < 3 { - return Err(Errno::EINVAL); - } // Extract path: starts at offset 2, null-terminated - let path_bytes = &addr[2..]; - let (resolved, abstract_unix) = if path_bytes.first().copied() == Some(0) { + let path_bytes = checked_sockaddr_un_path(addr)?; + let abstract_unix = path_bytes.first().copied() == Some(0); + let (resolved, original) = if abstract_unix { if path_bytes.len() < 2 { return Err(Errno::EINVAL); } @@ -10531,7 +11459,8 @@ pub fn sys_bind( // bytes after sun_family, including the leading NUL. No // filesystem inode is created and embedded/trailing NUL bytes // are part of the address. - (path_bytes.to_vec(), true) + let original = path_bytes.to_vec(); + (original.clone(), original) } else { let path_end = path_bytes .iter() @@ -10540,26 +11469,24 @@ pub fn sys_bind( if path_end == 0 { return Err(Errno::EINVAL); } + let original = path_bytes[..path_end].to_vec(); ( resolve_namespace_path( proc, host, - &path_bytes[..path_end], + &original, PathResolveOptions::CREATE_ENTRY, )? .path, - false, + original, ) }; // POSIX: bind() must create a filesystem inode at sun_path so // chmod/stat/ls find a node there. Do that first via host O_CREAT| // O_EXCL so a pre-existing path turns into EADDRINUSE, matching the - // registry contract below. Other host_open errors (e.g. ENOENT for - // missing parent dir) propagate unchanged. (PR #356 — restored - // here after the package-management rebase dropped it; the same - // code lives at the merge base but didn't survive into the - // rebased branch.) + // registry contract below. Other host_open errors, such as ENOENT + // for a missing parent directory, propagate unchanged. if !abstract_unix { use wasm_posix_shared::flags::{O_CREAT, O_EXCL, O_WRONLY}; check_open_permissions(proc, host, &resolved, O_CREAT | O_EXCL | O_WRONLY)?; @@ -10588,7 +11515,10 @@ pub fn sys_bind( } let sock = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; - sock.bind_path = Some(resolved); + // WHY: the registry owns the canonical lookup key. The socket + // retains the bounded historical name so getsockname() cannot + // expand a short relative bind into an unbounded canonical path. + sock.bind_path = Some(original); sock.state = SocketState::Bound; Ok(()) } @@ -10729,8 +11659,8 @@ fn discard_accepted_socket_without_fd(proc: &mut Process, sock_idx: usize) { pub fn sys_accept(proc: &mut Process, _host: &mut dyn HostIO, fd: i32) -> Result { use crate::socket::{SocketDomain, SocketInfo, SocketState, SocketType}; - let entry = proc.fd_table.get(fd)?; - let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + let ofd_idx = resolve_io_ofd(proc, fd)?; + let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; if ofd.file_type != FileType::Socket { return Err(Errno::ENOTSOCK); } @@ -10865,8 +11795,8 @@ pub fn sys_connect( use crate::pipe::PipeBuffer; use crate::socket::{SocketDomain, SocketInfo, SocketState, SocketType}; - let entry = proc.fd_table.get(fd)?; - let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + let ofd_idx = resolve_io_ofd(proc, fd)?; + let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; if ofd.file_type != FileType::Socket { return Err(Errno::ENOTSOCK); } @@ -10896,7 +11826,7 @@ pub fn sys_connect( return Err(Errno::EADDRNOTAVAIL); } if ip == [255, 255, 255, 255] { - use wasm_posix_shared::socket::{SO_BROADCAST, SOL_SOCKET}; + use wasm_posix_shared::socket::{SOL_SOCKET, SO_BROADCAST}; let broadcast_enabled = sock.get_option(SOL_SOCKET, SO_BROADCAST).unwrap_or(0) != 0; @@ -11225,10 +12155,7 @@ pub fn sys_connect( SocketDomain::Unix => { let sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; if sock.sock_type == SocketType::Dgram { - if addr.len() < 3 { - return Err(Errno::EINVAL); - } - let path_bytes = &addr[2..]; + let path_bytes = checked_sockaddr_un_path(addr)?; let resolved = if path_bytes.first().copied() == Some(0) { if path_bytes.len() < 2 { return Err(Errno::EINVAL); @@ -11296,10 +12223,7 @@ pub fn sys_connect( } // Parse sockaddr_un to get the path - if addr.len() < 3 { - return Err(Errno::EINVAL); - } - let path_bytes = &addr[2..]; + let path_bytes = checked_sockaddr_un_path(addr)?; let resolved = if path_bytes.first().copied() == Some(0) { if path_bytes.len() < 2 { return Err(Errno::EINVAL); @@ -11437,10 +12361,7 @@ fn resolve_unix_datagram_destination( host: &mut dyn HostIO, addr: &[u8], ) -> Result { - if addr.len() < 3 { - return Err(Errno::EINVAL); - } - let path_bytes = &addr[2..]; + let path_bytes = checked_sockaddr_un_path(addr)?; let resolved = if path_bytes.first().copied() == Some(0) { if path_bytes.len() < 2 { return Err(Errno::EINVAL); @@ -11480,8 +12401,8 @@ pub fn sys_sendto( ) -> Result { use crate::socket::{SocketDomain, SocketState, SocketType}; - let entry = proc.fd_table.get(fd)?; - let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + let ofd_idx = resolve_io_ofd(proc, fd)?; + let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; if ofd.file_type != FileType::Socket { return Err(Errno::ENOTSOCK); } @@ -11553,8 +12474,8 @@ fn recv_datagram_message( use crate::socket::{SocketDomain, SocketState, SocketType}; use wasm_posix_shared::socket::{MSG_PEEK, MSG_TRUNC}; - let entry = proc.fd_table.get(fd)?; - let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + let ofd_idx = resolve_io_ofd(proc, fd)?; + let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; if ofd.file_type != FileType::Socket { return Err(Errno::ENOTSOCK); } @@ -11645,19 +12566,17 @@ fn recv_datagram_message( }; let output_flags = if full_len > buf.len() { MSG_TRUNC } else { 0 }; - let addr_len = if addr_buf.is_empty() { - 0 - } else { - match domain { - SocketDomain::Inet => write_sockaddr_in(addr_buf, src_addr, src_port), - SocketDomain::Inet6 => write_sockaddr_in6(addr_buf, src_addr6, src_port), - SocketDomain::Unix => { - if addr_buf.len() >= 2 { - addr_buf[0] = 1; - addr_buf[1] = 0; - } - 2 - } + // Report the complete address length independently of the caller's + // destination capacity. Each writer copies only the available prefix. + let addr_len = match domain { + SocketDomain::Inet => write_sockaddr_in(addr_buf, src_addr, src_port), + SocketDomain::Inet6 => write_sockaddr_in6(addr_buf, src_addr6, src_port), + SocketDomain::Unix => { + write_sockaddr_family_prefix( + addr_buf, + wasm_posix_shared::socket::AF_UNIX as u16, + ); + wasm_posix_shared::kernel_scratch_wire::SOCKADDR_UNIX_PATH_OFFSET_BYTES as usize } }; @@ -11682,8 +12601,8 @@ pub fn sys_recvfrom( ) -> Result<(usize, usize), Errno> { use crate::socket::SocketType; - let entry = proc.fd_table.get(fd)?; - let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + let ofd_idx = resolve_io_ofd(proc, fd)?; + let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; if ofd.file_type != FileType::Socket { return Err(Errno::ENOTSOCK); } @@ -11711,7 +12630,7 @@ pub fn sys_poll( return Ok(ready); } - let tid = crate::process_table::current_tid(); + let tid = current_tid_for_process(proc); if proc.deliverable_for(tid) != 0 && !proc.should_restart_for(tid) { return Err(Errno::EINTR); } @@ -11723,7 +12642,7 @@ fn poll_check(proc: &mut Process, host: &mut dyn HostIO, fds: &mut [WasmPollFd]) use wasm_posix_shared::poll::*; let mut ready_count = 0i32; - let tid = crate::process_table::current_tid(); + let tid = current_tid_for_process(proc); for pollfd in fds.iter_mut() { pollfd.revents = 0; @@ -12735,7 +13654,11 @@ pub fn sys_ioctl( } let sock_idx = (-(ofd.host_handle + 1)) as usize; let atmark: i32 = if let Some(sock) = proc.sockets.get(sock_idx) { - if sock.oob_byte.is_some() { 1 } else { 0 } + if sock.oob_byte.is_some() { + 1 + } else { + 0 + } } else { 0 }; @@ -13132,7 +14055,7 @@ pub fn sys_prctl(proc: &mut Process, option: u32, _arg2: u32, buf: &mut [u8]) -> // dispatch binds the explicit process-leader TID. The zero alias remains only // for isolated syscall unit tests, where it likewise reports the process PID. pub fn sys_gettid(proc: &Process) -> i32 { - let tid = crate::process_table::current_tid(); + let tid = current_tid_for_process(proc); if proc.is_main_thread(tid) { proc.pid as i32 } else { @@ -13150,7 +14073,7 @@ pub fn sys_gettid(proc: &Process) -> i32 { // Host-side thread exit already performs the actual clear-and-futex-wake using // clone's ctid pointer. pub fn sys_set_tid_address(proc: &mut Process, tidptr: usize) -> i32 { - let tid = crate::process_table::current_tid(); + let tid = current_tid_for_process(proc); if proc.is_main_thread(tid) { proc.pid as i32 } else { @@ -13285,7 +14208,7 @@ pub fn sys_ppoll( // Use the sigsuspend_saved_mask pattern for atomic mask swap. The mask // stays swapped across EAGAIN retries so cross-process signals arriving // between retries are caught on the next poll. - let tid = crate::process_table::current_tid(); + let tid = current_tid_for_process(proc); if let Some(new_mask) = mask { use wasm_posix_shared::signal::{SIGKILL, SIGSTOP}; if proc.sigsuspend_saved_mask_for(tid).is_none() { @@ -13321,7 +14244,7 @@ pub fn sys_pselect6( // Use the sigsuspend_saved_mask pattern for atomic mask swap. The mask // stays swapped across EAGAIN retries so cross-process signals arriving // between retries are caught on the next select. - let tid = crate::process_table::current_tid(); + let tid = current_tid_for_process(proc); if let Some(new_mask) = mask { use wasm_posix_shared::signal::{SIGKILL, SIGSTOP}; if proc.sigsuspend_saved_mask_for(tid).is_none() { @@ -14218,7 +15141,7 @@ pub fn sys_ftruncate( && (length as u64) > current_size && (length as u64) > fsize_limit { - raise_fsize_signal_for_caller(proc, crate::process_table::current_tid())?; + raise_fsize_signal_for_caller(proc, current_tid_for_process(proc))?; return Err(Errno::EFBIG); } @@ -14407,55 +15330,22 @@ pub fn sys_fchown( } } -/// writev -- write data from multiple buffers (scatter-gather I/O). -/// Iterates over the provided buffer slices, writing each in order. -/// Stops on a short write or error, returning the total bytes written. +/// writev -- write data from multiple buffers as one logical operation. pub fn sys_writev( proc: &mut Process, host: &mut dyn HostIO, fd: i32, buffers: &[&[u8]], ) -> Result { - let requested_len = checked_iovec_len(buffers)?; - let writable_len = write_operation_budget( - proc, - host, - crate::process_table::current_tid(), - fd, - None, - requested_len, - )?; - let mut total = 0usize; - for buf in buffers { - if total == writable_len { - break; - } - if buf.is_empty() { - continue; - } - let operation_remaining = writable_len - total; - let attempted = buf.len().min(operation_remaining); - match sys_write(proc, host, fd, &buf[..attempted]) { - Ok(n) => { - total += n; - if n < attempted || total == writable_len { - break; // Short write, stop - } - } - Err(e) => { - if total > 0 { - return Ok(total); - } - return Err(e); - } - } - } - Ok(total) + require_io_fd(proc, fd)?; + let gathered = gather_iovecs(buffers)?; + // WHY: issuing one scalar write preserves PIPE_BUF and datagram message + // atomicity. Iterating per iovec would create multiple operations with + // observably different boundaries. + sys_write(proc, host, fd, &gathered) } -/// readv -- read data into multiple buffers (scatter-gather I/O). -/// Iterates over the provided buffer slices, reading into each in order. -/// Stops on a short read, EOF, or error, returning the total bytes read. +/// readv -- perform one read, then scatter its returned prefix. pub fn sys_readv( proc: &mut Process, host: &mut dyn HostIO, @@ -14463,27 +15353,14 @@ pub fn sys_readv( buffers: &mut [&mut [u8]], ) -> Result { require_io_fd(proc, fd)?; - let mut total = 0usize; - for buf in buffers.iter_mut() { - if buf.is_empty() { - continue; - } - match sys_read(proc, host, fd, *buf) { - Ok(n) => { - total += n; - if n < buf.len() || n == 0 { - break; // Short read or EOF, stop - } - } - Err(e) => { - if total > 0 { - return Ok(total); - } - return Err(e); - } - } - } - Ok(total) + let requested_len = checked_iovec_len(buffers.len(), buffers.iter().map(|buf| buf.len()))?; + let mut gathered = try_initialized_vec(requested_len)?; + // WHY: one scalar read consumes at most one datagram and makes a single + // stream/pipe observation. Scatter happens only after that operation has + // completed. + let read = sys_read(proc, host, fd, &mut gathered)?; + scatter_iovec_prefix(buffers, &gathered, read)?; + Ok(read) } /// getrlimit — get resource limits @@ -14714,7 +15591,7 @@ pub fn sys_select( timeout_ms: i32, ) -> Result { use wasm_posix_shared::poll::{POLLERR, POLLHUP, POLLIN, POLLOUT, POLLPRI}; - use wasm_posix_shared::select::{FD_SET_BYTES, FD_SETSIZE}; + use wasm_posix_shared::select::{FD_SETSIZE, FD_SET_BYTES}; if nfds < 0 || nfds as usize > FD_SETSIZE { return Err(Errno::EINVAL); @@ -14811,7 +15688,7 @@ pub fn sys_select( if ready > 0 || timeout_ms == 0 { return Ok(ready); } - let tid = crate::process_table::current_tid(); + let tid = current_tid_for_process(proc); if proc.deliverable_for(tid) != 0 && !proc.should_restart_for(tid) { return Err(Errno::EINTR); } @@ -15197,8 +16074,8 @@ pub(crate) fn sys_sendmsg( .iter() .try_for_each(validate_scm_rights_in_flight_fd)?; - let entry = proc.fd_table.get(fd)?; - let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + let ofd_idx = resolve_io_ofd(proc, fd)?; + let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; if ofd.file_type != FileType::Socket { return Err(Errno::ENOTSOCK); } @@ -15295,8 +16172,8 @@ pub(crate) fn sys_recvmsg( use wasm_posix_shared::socket::{MSG_CMSG_CLOEXEC, MSG_OOB, MSG_PEEK}; const MSG_WAITALL: u32 = 0x100; - let entry = proc.fd_table.get(fd)?; - let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + let ofd_idx = resolve_io_ofd(proc, fd)?; + let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; if ofd.file_type != FileType::Socket { return Err(Errno::ENOTSOCK); } @@ -15438,6 +16315,9 @@ pub fn sys_memfd_create(proc: &mut Process, name: &[u8], flags: u32) -> Result= wasm_posix_shared::platform_limits::MEMFD_NAME_MAX_BYTES { + return Err(Errno::EINVAL); + } let memfd_idx = crate::descriptor_backing::with_memfds(|table| { table.alloc(crate::descriptor_backing::MemFdBacking::new()) @@ -15448,8 +16328,7 @@ pub fn sys_memfd_create(proc: &mut Process, name: &[u8], flags: u32) -> Result" let mut path = alloc::vec![b'm', b'e', b'm', b'f', b'd', b':']; - let name_len = name.len().min(249); // limit path length - path.extend_from_slice(&name[..name_len]); + path.extend_from_slice(name); let ofd_idx = proc .ofd_table @@ -15661,11 +16540,27 @@ mod tests { static THREAD_IDENTITY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); static SCM_RIGHTS_LIFETIME_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[test] + fn process_borrow_paths_centralize_ambient_tid_fallback() { + let direct_lookup = concat!("crate::process_table::", "current_tid()"); + let syscalls_source = include_str!("syscalls.rs"); + let signal_source = include_str!("signal.rs"); + let wasm_api_source = include_str!("wasm_api.rs"); + + // WHY: a syscall already holding &mut Process must not reborrow the + // ProcessTable that owns it. The one test-only fallback is isolated in + // current_tid_for_process; production dispatch uses the validated TID + // mirrored into BlockingRetryState by ProcessTable::bind_current_tid. + assert_eq!(syscalls_source.matches(direct_lookup).count(), 1); + assert!(!signal_source.contains(direct_lookup)); + assert!(!wasm_api_source.contains(direct_lookup)); + } + #[test] fn socket_timeout_options_accept_time64_and_long64_numbers() { use wasm_posix_shared::socket::{ - AF_INET, IPPROTO_TCP, SO_RCVTIMEO, SO_RCVTIMEO_OLD, SO_SNDTIMEO, SO_SNDTIMEO_OLD, - SOCK_STREAM, SOL_SOCKET, + AF_INET, IPPROTO_TCP, SOCK_STREAM, SOL_SOCKET, SO_RCVTIMEO, SO_RCVTIMEO_OLD, + SO_SNDTIMEO, SO_SNDTIMEO_OLD, }; assert_eq!( @@ -15866,6 +16761,24 @@ mod tests { fpathconf_result: Result, Errno>, pathconf_calls: Vec<(Vec, i32)>, fpathconf_calls: Vec<(i64, i32)>, + read_calls: usize, + write_calls: usize, + read_error: Option, + write_error: Option, + read_reported: Option, + write_reported: Option, + append_calls: Vec<(i64, Vec, Option)>, + append_mutations: Vec>, + append_error: Option, + append_reported: Option, + append_start: Option, + append_end: Option, + pread_calls: Vec<(i64, i64, usize)>, + pwrite_calls: Vec<(i64, i64, Vec)>, + pread_error: Option, + pwrite_error: Option, + pread_reported: Option, + pwrite_reported: Option, } impl MockHostIO { @@ -15923,6 +16836,24 @@ mod tests { fpathconf_result: Ok(Some(4096)), pathconf_calls: Vec::new(), fpathconf_calls: Vec::new(), + read_calls: 0, + write_calls: 0, + read_error: None, + write_error: None, + read_reported: None, + write_reported: None, + append_calls: Vec::new(), + append_mutations: Vec::new(), + append_error: None, + append_reported: None, + append_start: None, + append_end: None, + pread_calls: Vec::new(), + pwrite_calls: Vec::new(), + pread_error: None, + pwrite_error: None, + pread_reported: None, + pwrite_reported: None, } } @@ -15998,14 +16929,73 @@ mod tests { } fn host_read(&mut self, _handle: i64, buf: &mut [u8]) -> Result { + self.read_calls += 1; + if let Some(error) = self.read_error { + return Err(error); + } let data = b"hello"; let n = buf.len().min(data.len()); buf[..n].copy_from_slice(&data[..n]); - Ok(n) + Ok(self.read_reported.unwrap_or(n)) } fn host_write(&mut self, _handle: i64, buf: &[u8]) -> Result { - Ok(buf.len()) + self.write_calls += 1; + if let Some(error) = self.write_error { + return Err(error); + } + Ok(self.write_reported.unwrap_or(buf.len())) + } + + fn host_append( + &mut self, + handle: i64, + buf: &[u8], + limit: Option, + ) -> Result { + self.append_calls.push((handle, buf.to_vec(), limit)); + if let Some(error) = self.append_error { + return Err(error); + } + let start = self.append_start.unwrap_or(self.stat_size); + let available = limit + .map(|limit| limit.saturating_sub(start)) + .unwrap_or(u64::MAX); + let available = usize::try_from(available).unwrap_or(usize::MAX); + let written = self.append_reported.unwrap_or(buf.len().min(available)); + let end = self.append_end.unwrap_or_else(|| { + start + .checked_add(u64::try_from(written).unwrap_or(u64::MAX)) + .unwrap_or(u64::MAX) + }); + if self.append_end.is_none() && end > i64::MAX as u64 { + return Err(Errno::EOVERFLOW); + } + if written > 0 { + self.append_mutations + .push(buf[..written.min(buf.len())].to_vec()); + } + self.stat_size = end; + Ok(HostAppendOutcome { written, end }) + } + + fn host_pread(&mut self, handle: i64, buf: &mut [u8], offset: i64) -> Result { + self.pread_calls.push((handle, offset, buf.len())); + if let Some(error) = self.pread_error { + return Err(error); + } + let data = b"hello"; + let copied = buf.len().min(data.len()); + buf[..copied].copy_from_slice(&data[..copied]); + Ok(self.pread_reported.unwrap_or(copied)) + } + + fn host_pwrite(&mut self, handle: i64, buf: &[u8], offset: i64) -> Result { + self.pwrite_calls.push((handle, offset, buf.to_vec())); + if let Some(error) = self.pwrite_error { + return Err(error); + } + Ok(self.pwrite_reported.unwrap_or(buf.len())) } fn host_seek(&mut self, handle: i64, offset: i64, whence: u32) -> Result { @@ -17708,6 +18698,86 @@ mod tests { sys_unlink(&mut creator, &mut host, fifo).unwrap(); } + #[test] + fn fifo_thread_exit_cancels_its_blocked_open_reservation() { + let _guard = FIFO_REGISTRY_LOCK.lock().unwrap(); + let _thread_guard = THREAD_IDENTITY_LOCK.lock().unwrap(); + set_test_current_tid(0); + let mut creator = Process::new(81_060); + let mut opener = Process::new(81_061); + let mut peer = Process::new(81_062); + let worker_tid = 81_063; + opener.add_thread(crate::process::ThreadInfo::new(worker_tid, 0, 0, 0)); + let mut host = MockHostIO::new(); + let mut locks = AdvisoryLockManager::new(); + let fifo = b"/tmp/fifo_thread_exit_cancel"; + create_test_fifo(&mut creator, &mut host, fifo, 0o600); + + set_test_current_tid(worker_tid); + assert_eq!( + sys_open(&mut opener, &mut host, fifo, O_RDONLY, 0), + Err(Errno::EAGAIN), + ); + set_test_current_tid(0); + cleanup_exiting_thread(&mut opener, &mut locks, &mut host, worker_tid).unwrap(); + + let released_fd = opener.fd_table.reserve().unwrap(); + assert_eq!(released_fd, 3); + assert!(opener.fd_table.release_reserved(released_fd)); + assert_eq!( + sys_open(&mut peer, &mut host, fifo, O_WRONLY | O_NONBLOCK, 0), + Err(Errno::ENXIO), + ); + + sys_unlink(&mut creator, &mut host, fifo).unwrap(); + } + + #[test] + fn fifo_normal_and_signal_exit_cancel_blocked_open_reservations() { + let _guard = FIFO_REGISTRY_LOCK.lock().unwrap(); + let _thread_guard = THREAD_IDENTITY_LOCK.lock().unwrap(); + set_test_current_tid(0); + + for signal in [None, Some(wasm_posix_shared::signal::SIGTERM)] { + let suffix = if signal.is_some() { + b"signal" + } else { + b"normal" + }; + let mut fifo = b"/tmp/fifo_exit_cancel_".to_vec(); + fifo.extend_from_slice(suffix); + let mut creator = Process::new(81_070 + signal.is_some() as u32 * 10); + let mut opener = Process::new(creator.pid + 1); + let mut peer = Process::new(creator.pid + 2); + let mut host = MockHostIO::new(); + let mut locks = AdvisoryLockManager::new(); + create_test_fifo(&mut creator, &mut host, &fifo, 0o600); + + assert_eq!( + sys_open(&mut opener, &mut host, &fifo, O_RDONLY, 0), + Err(Errno::EAGAIN), + ); + if let Some(signum) = signal { + sys_exit_by_signal_with_locks(&mut opener, &mut locks, &mut host, signum); + } else { + sys_exit_with_locks(&mut opener, &mut locks, &mut host, 0); + } + + let released_fds = (0..4) + .map(|_| opener.fd_table.reserve().unwrap()) + .collect::>(); + assert_eq!(released_fds, vec![0, 1, 2, 3]); + for fd in released_fds { + assert!(opener.fd_table.release_reserved(fd)); + } + assert_eq!( + sys_open(&mut peer, &mut host, &fifo, O_WRONLY | O_NONBLOCK, 0,), + Err(Errno::ENXIO), + ); + sys_unlink(&mut creator, &mut host, &fifo).unwrap(); + } + } + #[test] fn fifo_hardlink_and_recreated_name_keep_distinct_pipe_identities() { let _guard = FIFO_REGISTRY_LOCK.lock().unwrap(); @@ -17889,13 +18959,12 @@ mod tests { proc.fd_table.get(fd).unwrap().ofd_ref, ); sys_close(&mut proc, &mut host, fd).unwrap(); - assert!( - proc.ofd_table - .get(ofd_idx) - .unwrap() - .dir_pending_entry - .is_some() - ); + assert!(proc + .ofd_table + .get(ofd_idx) + .unwrap() + .dir_pending_entry + .is_some()); // One byte less than the pending record needs returns EINVAL without // advancing either the host cursor or the guest-visible d_off cookie. @@ -17966,24 +19035,22 @@ mod tests { assert_eq!(entries[2].0, 42); let cookie = entries[2].1; assert_eq!(cookie, 3); - assert!( - proc.ofd_table - .get(ofd_idx) - .unwrap() - .dir_pending_entry - .is_some() - ); + assert!(proc + .ofd_table + .get(ofd_idx) + .unwrap() + .dir_pending_entry + .is_some()); assert_eq!( sys_lseek(&mut proc, &mut host, duplicate_fd, cookie, SEEK_SET), Ok(cookie), ); - assert!( - proc.ofd_table - .get(ofd_idx) - .unwrap() - .dir_pending_entry - .is_none() - ); + assert!(proc + .ofd_table + .get(ofd_idx) + .unwrap() + .dir_pending_entry + .is_none()); let len = sys_getdents64(&mut proc, &mut host, duplicate_fd, &mut one_entry).unwrap(); assert_eq!(parse_linux_dirents64(&one_entry, len)[0].0, 43); @@ -17997,13 +19064,12 @@ mod tests { sys_getdents64(&mut proc, &mut host, duplicate_fd, &mut prefix), Ok(prefix.len()), ); - assert!( - proc.ofd_table - .get(ofd_idx) - .unwrap() - .dir_pending_entry - .is_some() - ); + assert!(proc + .ofd_table + .get(ofd_idx) + .unwrap() + .dir_pending_entry + .is_some()); sys_close(&mut proc, &mut host, duplicate_fd).unwrap(); assert!(proc.ofd_table.get(ofd_idx).is_none()); @@ -18420,11 +19486,9 @@ mod tests { assert!(small_entries.len() > 2); assert_eq!(small_entries[0].2, b"."); assert_eq!(small_entries[1].2, b".."); - assert!( - small_entries - .windows(2) - .all(|pair| pair[0].1.checked_add(1) == Some(pair[1].1)) - ); + assert!(small_entries + .windows(2) + .all(|pair| pair[0].1.checked_add(1) == Some(pair[1].1))); assert_eq!(sys_lseek(&mut proc, &mut host, fd, 0, SEEK_SET), Ok(0)); assert_eq!( @@ -18466,11 +19530,9 @@ mod tests { sys_truncate(&mut proc, &mut host, fifo, 0), Err(Errno::EINVAL), ); - assert!( - unsafe { crate::pipe::global_pipe_table() } - .find_fifo_open(fifo_open_owner(&proc)) - .is_none() - ); + assert!(unsafe { crate::pipe::global_pipe_table() } + .find_fifo_open(fifo_open_owner(&proc)) + .is_none()); sys_unlink(&mut proc, &mut host, fifo).unwrap(); } @@ -19173,11 +20235,10 @@ mod tests { host.set_file_with_owner(b"/etc/file", 0, 0, 0o644, b""); sys_stat(&mut proc, &mut host, b"/tmp/../etc/file").unwrap(); - assert!( - host.lstat_paths - .iter() - .all(|path| !path.windows(2).any(|w| w == b"..")) - ); + assert!(host + .lstat_paths + .iter() + .all(|path| !path.windows(2).any(|w| w == b".."))); assert!(host.lstat_paths.iter().any(|path| path == b"/etc/file")); } @@ -19261,28 +20322,24 @@ mod tests { host.set_file_with_owner(b"/foreign/file", 9999, 4000, 0o040, b"data"); assert!(sys_access(&mut proc, &mut host, b"/supp/file", R_OK).is_ok()); - assert!( - sys_faccessat( - &mut proc, - &mut host, - AT_FDCWD, - b"/supp/file", - R_OK, - AT_EACCESS, - ) - .is_ok() - ); - assert!( - sys_faccessat( - &mut proc, - &mut host, - AT_FDCWD, - b"/effective/file", - R_OK, - AT_EACCESS, - ) - .is_ok() - ); + assert!(sys_faccessat( + &mut proc, + &mut host, + AT_FDCWD, + b"/supp/file", + R_OK, + AT_EACCESS, + ) + .is_ok()); + assert!(sys_faccessat( + &mut proc, + &mut host, + AT_FDCWD, + b"/effective/file", + R_OK, + AT_EACCESS, + ) + .is_ok()); assert_eq!( sys_access(&mut proc, &mut host, b"/foreign/file", R_OK), Err(Errno::EACCES), @@ -19327,6 +20384,14 @@ mod tests { !unsafe { crate::unix_socket::global_unix_socket_registry() } .contains(b"/tmp/a/../socket") ); + let mut original_name = [ + 0xa5; + wasm_posix_shared::kernel_scratch_wire::SOCKADDR_STORAGE_BYTES + as usize + ]; + let original_len = sys_getsockname(&proc, server, &mut original_name).unwrap(); + assert_eq!(original_len, aliased.len()); + assert_eq!(&original_name[..original_len], aliased.as_slice()); let client = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); sys_connect(&mut proc, &mut host, client, &canonical).unwrap(); @@ -19338,10 +20403,8 @@ mod tests { assert!( !unsafe { crate::unix_socket::global_unix_socket_registry() }.contains(b"/tmp/socket") ); - assert!( - unsafe { crate::unix_socket::global_unix_socket_registry() } - .contains(b"/tmp/renamed-socket") - ); + assert!(unsafe { crate::unix_socket::global_unix_socket_registry() } + .contains(b"/tmp/renamed-socket")); let renamed = test_unix_addr(b"/tmp/renamed-socket"); let second_client = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); @@ -19797,7 +20860,7 @@ mod tests { // Set handler to function pointer 42 let result = sys_signal(&mut proc, 10, 42); // SIGUSR1=10 assert_eq!(result, Ok(0)); // Was SIG_DFL - // Set back to default, should return 42 + // Set back to default, should return 42 let result = sys_signal(&mut proc, 10, 0); // SIG_DFL assert_eq!(result, Ok(42)); } @@ -20829,11 +21892,9 @@ mod tests { assert_eq!(&second, &expected[7..16]); let fdinfo = crate::procfs::generate_fdinfo(table.get(PARENT).unwrap(), inherited_fd).unwrap(); - assert!( - core::str::from_utf8(&fdinfo) - .unwrap() - .contains("pos:\t16\n") - ); + assert!(core::str::from_utf8(&fdinfo) + .unwrap() + .contains("pos:\t16\n")); let mut third = [0u8; 11]; sys_read( table.get_mut(spawn_child).unwrap(), @@ -21671,12 +22732,10 @@ mod tests { ); let receiver_socket = proc.sockets.get(receiver_idx).unwrap(); assert_eq!(receiver_socket.dgram_queue.len(), UDP_DATAGRAM_QUEUE_LIMIT,); - assert!( - receiver_socket - .dgram_queue - .iter() - .all(|datagram| datagram.ancillary_fds.is_empty()), - ); + assert!(receiver_socket + .dgram_queue + .iter() + .all(|datagram| datagram.ancillary_fds.is_empty()),); assert!(!crate::ofd::has_in_flight_ofd(carried_ofd_id)); let deferred_failed = crate::pipe::deferred_in_flight_release_state(); assert_eq!(deferred_failed.0, deferred_before.0 + 1); @@ -22090,12 +23149,10 @@ mod tests { receiver.fd_table.set_max_fds(0); assert!(install_scm_rights_fds(&mut receiver, vec![queued]).is_empty()); assert_eq!(receiver.ofd_table.iter().count(), existing_ofd_count); - assert!( - receiver - .ofd_table - .iter() - .all(|(_, ofd)| ofd.ofd_id != ofd_id) - ); + assert!(receiver + .ofd_table + .iter() + .all(|(_, ofd)| ofd.ofd_id != ofd_id)); assert!(!crate::ofd::has_in_flight_ofd(ofd_id)); assert_eq!(locks.len(), 1); let deferred_failed = crate::pipe::deferred_in_flight_release_state(); @@ -22141,6 +23198,61 @@ mod tests { assert!(table.advisory_locks().is_empty()); } + #[test] + fn forced_removal_releases_blocked_sendmsg_rights_template() { + const SENDER_PID: u32 = 100; + + let _guard = SCM_RIGHTS_LIFETIME_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut table = crate::process_table::ProcessTable::new(); + assert_eq!(table.create_process().unwrap(), SENDER_PID); + let mut host = MockHostIO::new(); + let carrier_fd = sys_open( + table.get_mut(SENDER_PID).unwrap(), + &mut host, + b"/tmp/scm-blocked-carrier", + O_WRONLY, + 0, + ) + .unwrap(); + let carried_fd = sys_memfd_create( + table.get_mut(SENDER_PID).unwrap(), + b"scm-blocked-template", + 0, + ) + .unwrap(); + let carried_ofd_id = scm_rights_test_ofd_id(table.get(SENDER_PID).unwrap(), carried_fd); + let snapshot = snapshot_scm_rights_fd(table.get(SENDER_PID).unwrap(), carried_fd).unwrap(); + let deferred_before = crate::pipe::deferred_in_flight_release_state(); + + { + let (sender, locks) = table.process_and_advisory_locks(SENDER_PID).unwrap(); + ensure_blocking_retry_ofd_binding( + sender, + locks, + &mut host, + SENDER_PID, + 137, + carrier_fd, + Some(vec![snapshot]), + ) + .unwrap(); + assert_eq!(sender.blocked_retries.binding_count(), 1); + } + assert!(crate::ofd::has_in_flight_ofd(carried_ofd_id)); + let retained = crate::pipe::deferred_in_flight_release_state(); + assert_eq!(retained.0, deferred_before.0); + assert_eq!(retained.1, deferred_before.1 + 1); + + table.remove_process(SENDER_PID).unwrap(); + + assert!(!crate::ofd::has_in_flight_ofd(carried_ofd_id)); + let released = crate::pipe::deferred_in_flight_release_state(); + assert_eq!(released.0, deferred_before.0); + assert_eq!(released.1, deferred_before.1); + } + #[test] fn forced_removal_drains_scm_rights_queued_in_unix_datagrams() { use wasm_posix_shared::socket::{AF_UNIX, SOCK_DGRAM}; @@ -22759,11 +23871,9 @@ mod tests { carried_host_handle, deferred_before, ); - assert!( - unsafe { crate::pipe::global_pipe_table() } - .get(pipe_idx) - .is_none() - ); + assert!(unsafe { crate::pipe::global_pipe_table() } + .get(pipe_idx) + .is_none()); } #[test] @@ -23182,7 +24292,7 @@ mod tests { let mut host = MockHostIO::new(); // Open a file to get a valid fd let fd = sys_open(&mut proc, &mut host, b"/tmp/mmaptest", 0x42, 0o644).unwrap(); // O_CREAT|O_RDWR - // MAP_PRIVATE without MAP_ANONYMOUS should succeed (host populates data) + // MAP_PRIVATE without MAP_ANONYMOUS should succeed (host populates data) let addr = sys_mmap(&mut proc, &mut host, 0, 4096, 3, 0x02, fd, 0).unwrap(); // PROT_READ|WRITE, MAP_PRIVATE assert_ne!(addr, 0xFFFFFFFF); } @@ -23202,7 +24312,7 @@ mod tests { let mut host = MockHostIO::new(); // Open a file to get a valid fd let fd = sys_open(&mut proc, &mut host, b"/tmp/mmaptest_shared", 0x42, 0o644).unwrap(); // O_CREAT|O_RDWR - // MAP_SHARED should succeed (allocates region, host does population + tracking) + // MAP_SHARED should succeed (allocates region, host does population + tracking) let addr = sys_mmap(&mut proc, &mut host, 0, 4096, 3, 0x01, fd, 0).unwrap(); // PROT_READ|WRITE, MAP_SHARED assert_ne!(addr, 0xFFFFFFFF); } @@ -23520,11 +24630,9 @@ mod tests { assert!(backlog.in_use); assert_eq!(backlog.ref_count, 1); assert_eq!(backlog.queue.len(), 1); - assert!( - unsafe { crate::unix_socket::global_unix_socket_registry() } - .lookup(&resolved) - .is_some() - ); + assert!(unsafe { crate::unix_socket::global_unix_socket_registry() } + .lookup(&resolved) + .is_some()); let accepted = sys_accept(&mut proc, &mut host, listener).unwrap(); assert_eq!( @@ -23588,11 +24696,9 @@ mod tests { assert!(proc.fd_table.get(listener).is_err()); assert!(proc.sockets.get(listener_sock_idx).is_none()); - assert!( - unsafe { crate::unix_socket::global_unix_socket_registry() } - .lookup(&resolved) - .is_none() - ); + assert!(unsafe { crate::unix_socket::global_unix_socket_registry() } + .lookup(&resolved) + .is_none()); let backlog = &unsafe { crate::socket::shared_listener_backlog_table() }.entries[shared_idx]; assert!(!backlog.in_use); @@ -24528,8 +25634,11 @@ mod tests { unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); let fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); // AF_UNIX, SOCK_STREAM - // sockaddr_un: family(2) + path - let mut addr = [0u8; 110]; + // sockaddr_un: family(2) + path + let mut addr = [ + 0u8; + wasm_posix_shared::kernel_scratch_wire::SOCKADDR_UNIX_BYTES as usize + ]; addr[0] = 1; // AF_UNIX addr[1] = 0; addr[2..2 + path.len()].copy_from_slice(path); @@ -24558,7 +25667,10 @@ mod tests { let fd1 = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); let fd2 = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); - let mut addr = [0u8; 110]; + let mut addr = [ + 0u8; + wasm_posix_shared::kernel_scratch_wire::SOCKADDR_UNIX_BYTES as usize + ]; addr[0] = 1; // AF_UNIX addr[2..2 + path.len()].copy_from_slice(path); let addrlen = 2 + path.len() + 1; @@ -24610,10 +25722,10 @@ mod tests { #[test] fn test_external_nonblocking_connect_reports_pending_errnos_once_then_writable() { - use wasm_posix_shared::WasmPollFd; use wasm_posix_shared::fcntl_cmd::F_SETFL; use wasm_posix_shared::poll::POLLOUT; use wasm_posix_shared::socket::*; + use wasm_posix_shared::WasmPollFd; let mut proc = Process::new(1); let mut host = MockHostIO::new(); @@ -24661,10 +25773,10 @@ mod tests { #[test] fn test_external_nonblocking_connect_poll_failure_caches_and_clears_so_error() { - use wasm_posix_shared::WasmPollFd; use wasm_posix_shared::fcntl_cmd::F_SETFL; use wasm_posix_shared::poll::{POLLERR, POLLOUT}; use wasm_posix_shared::socket::*; + use wasm_posix_shared::WasmPollFd; let mut proc = Process::new(1); let mut host = MockHostIO::new(); @@ -24707,8 +25819,8 @@ mod tests { fn test_poll_regular_file_always_ready() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::WasmPollFd; use wasm_posix_shared::poll::*; + use wasm_posix_shared::WasmPollFd; // stdout (fd 1) should be ready for writing let mut pollfd = WasmPollFd { fd: 1, @@ -24724,8 +25836,8 @@ mod tests { fn test_poll_pipe_readable() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::WasmPollFd; use wasm_posix_shared::poll::*; + use wasm_posix_shared::WasmPollFd; let (read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); // Pipe is empty — not readable yet @@ -24756,8 +25868,8 @@ mod tests { fn test_poll_pipe_writable() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::WasmPollFd; use wasm_posix_shared::poll::*; + use wasm_posix_shared::WasmPollFd; let (_read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); let mut pollfd = WasmPollFd { @@ -24774,9 +25886,9 @@ mod tests { fn test_poll_connected_inet6_datagram_matches_recv_filter() { let mut proc = Process::new(9032); let mut host = MockHostIO::new(); - use wasm_posix_shared::WasmPollFd; use wasm_posix_shared::poll::POLLIN; use wasm_posix_shared::socket::*; + use wasm_posix_shared::WasmPollFd; let fd = sys_socket(&mut proc, &mut host, AF_INET6, SOCK_DGRAM, 0).unwrap(); let entry = proc.fd_table.get(fd).unwrap(); @@ -24841,9 +25953,9 @@ mod tests { let mut proc = Process::new(9033); let owner_pid = proc.pid; let mut host = MockHostIO::new(); - use wasm_posix_shared::WasmPollFd; use wasm_posix_shared::poll::POLLIN; use wasm_posix_shared::socket::*; + use wasm_posix_shared::WasmPollFd; let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); let entry = proc.fd_table.get(fd).unwrap(); @@ -24903,8 +26015,8 @@ mod tests { fn test_poll_pipe_hangup() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::WasmPollFd; use wasm_posix_shared::poll::*; + use wasm_posix_shared::WasmPollFd; let (read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); sys_close(&mut proc, &mut host, write_fd).unwrap(); @@ -24923,8 +26035,8 @@ mod tests { fn test_poll_invalid_fd() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::WasmPollFd; use wasm_posix_shared::poll::*; + use wasm_posix_shared::WasmPollFd; let mut pollfd = WasmPollFd { fd: 99, events: POLLIN, @@ -24939,8 +26051,8 @@ mod tests { fn test_poll_negative_fd_ignored() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::WasmPollFd; use wasm_posix_shared::poll::*; + use wasm_posix_shared::WasmPollFd; let mut pollfd = WasmPollFd { fd: -1, events: POLLIN, @@ -24955,9 +26067,9 @@ mod tests { fn test_poll_socket_pair() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::WasmPollFd; use wasm_posix_shared::poll::*; use wasm_posix_shared::socket::*; + use wasm_posix_shared::WasmPollFd; let (fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); // Socket is writable (send buffer has space) @@ -24995,9 +26107,9 @@ mod tests { fn test_poll_multiple_fds() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::WasmPollFd; use wasm_posix_shared::poll::*; use wasm_posix_shared::socket::*; + use wasm_posix_shared::WasmPollFd; let (fd0, _fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); let mut pollfds = [ @@ -26151,13 +27263,12 @@ mod tests { Errno::EPIPE, ); assert!(proc.signals.is_pending(wasm_posix_shared::signal::SIGPIPE)); - assert!( - proc.sockets - .get(replacement_idx) - .unwrap() - .oob_byte - .is_none() - ); + assert!(proc + .sockets + .get(replacement_idx) + .unwrap() + .oob_byte + .is_none()); } // ---- prctl tests ---- @@ -26179,8 +27290,7 @@ mod tests { #[test] fn test_prctl_unknown_is_noop() { let mut proc = Process::new(1); - let mut buf = - [0u8; wasm_posix_shared::kernel_scratch_wire::PRCTL_NAME_BYTES as usize]; + let mut buf = [0u8; wasm_posix_shared::kernel_scratch_wire::PRCTL_NAME_BYTES as usize]; assert!(sys_prctl(&mut proc, 999, 0, &mut buf).is_ok()); } @@ -26334,7 +27444,7 @@ mod tests { // sysname at offset 0 assert_eq!(&buf[0..10], b"wasm-posix"); assert_eq!(buf[10], 0); // null terminated - // nodename at offset 65 + // nodename at offset 65 assert_eq!(&buf[65..74], b"localhost"); // machine at offset 260 assert_eq!(&buf[260..266], b"wasm32"); @@ -26796,6 +27906,77 @@ mod tests { assert_eq!(buf[0], 1); // AF_UNIX } + #[test] + fn test_unix_socket_name_family_truncates_at_every_prefix() { + use wasm_posix_shared::kernel_scratch_wire::SOCKADDR_UNIX_PATH_OFFSET_BYTES; + use wasm_posix_shared::socket::{AF_UNIX, SOCK_STREAM}; + + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let (fd0, _fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + let expected = (AF_UNIX as u16).to_le_bytes(); + let full_len = SOCKADDR_UNIX_PATH_OFFSET_BYTES as usize; + assert_eq!(full_len, expected.len()); + + for peer in [false, true] { + for capacity in 0..=full_len { + let mut buf = vec![0xa5; full_len]; + let reported = if peer { + sys_getpeername(&proc, fd0, &mut buf[..capacity]).unwrap() + } else { + sys_getsockname(&proc, fd0, &mut buf[..capacity]).unwrap() + }; + assert_eq!(reported, full_len); + assert_eq!(&buf[..capacity], &expected[..capacity]); + assert!(buf[capacity..].iter().all(|byte| *byte == 0xa5)); + } + } + } + + #[test] + fn test_optional_socket_address_output_requires_only_the_active_pair() { + assert_eq!( + validate_optional_socket_address_output(false, false), + Ok(false), + ); + assert_eq!( + validate_optional_socket_address_output(false, true), + Ok(false), + ); + assert_eq!( + validate_optional_socket_address_output(true, false), + Err(Errno::EFAULT), + ); + assert_eq!( + validate_optional_socket_address_output(true, true), + Ok(true), + ); + } + + #[test] + fn test_accept_peer_address_truncates_without_touching_the_tail() { + use wasm_posix_shared::kernel_scratch_wire::SOCKADDR_UNIX_PATH_OFFSET_BYTES; + use wasm_posix_shared::socket::{AF_UNIX, SOCK_STREAM}; + + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let (fd, _peer) = + sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + let expected = (AF_UNIX as u16).to_le_bytes(); + let full_len = SOCKADDR_UNIX_PATH_OFFSET_BYTES as usize; + assert_eq!(full_len, expected.len()); + + for capacity in 0..=full_len + 1 { + let mut address = vec![0xa5; full_len + 1]; + let reported = + write_accept_peer_address(&proc, fd, &mut address[..capacity]).unwrap(); + assert_eq!(reported as usize, full_len); + let copied = capacity.min(full_len); + assert_eq!(&address[..copied], &expected[..copied]); + assert!(address[copied..].iter().all(|byte| *byte == 0xa5)); + } + } + // ---- ftruncate tests ---- #[test] @@ -27035,6 +28216,13 @@ mod tests { let mut proc = Process::new(1); let mut host = MockHostIO::new(); let (r, w) = sys_pipe(&mut proc).unwrap(); + let mut empty1 = [0u8; 0]; + let mut empty2 = [0u8; 0]; + let mut empty_buffers: [&mut [u8]; 2] = [&mut empty1, &mut empty2]; + assert_eq!( + sys_readv(&mut proc, &mut host, r, &mut empty_buffers), + Ok(0), + ); sys_write(&mut proc, &mut host, w, b"data").unwrap(); let mut buf1 = [0u8; 0]; let mut buf2 = [0u8; 4]; @@ -27044,6 +28232,590 @@ mod tests { assert_eq!(&buf2, b"data"); } + #[test] + fn zero_length_vector_io_validates_without_touching_backing_state() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let read_fd = sys_open(&mut proc, &mut host, b"/zero-read", O_RDONLY, 0).unwrap(); + let write_fd = sys_open( + &mut proc, + &mut host, + b"/zero-write", + O_WRONLY | O_CREAT, + 0o644, + ) + .unwrap(); + let empty_writes: &[&[u8]] = &[b"", b""]; + + let mut first = [0u8; 0]; + let mut second = [0u8; 0]; + let mut empty_reads: [&mut [u8]; 2] = [&mut first, &mut second]; + assert_eq!( + sys_readv(&mut proc, &mut host, read_fd, &mut empty_reads), + Ok(0), + ); + assert_eq!( + sys_writev(&mut proc, &mut host, write_fd, empty_writes), + Ok(0), + ); + assert_eq!( + sys_preadv(&mut proc, &mut host, read_fd, &mut empty_reads, 17), + Ok(0), + ); + assert_eq!( + sys_pwritev(&mut proc, &mut host, write_fd, empty_writes, 23), + Ok(0), + ); + assert_eq!(host.read_calls, 0); + assert_eq!(host.write_calls, 0); + assert!(host.pread_calls.is_empty()); + assert!(host.pwrite_calls.is_empty()); + + assert_eq!( + sys_readv(&mut proc, &mut host, write_fd, &mut empty_reads), + Err(Errno::EBADF), + ); + assert_eq!( + sys_writev(&mut proc, &mut host, read_fd, empty_writes), + Err(Errno::EBADF), + ); + assert_eq!( + sys_preadv(&mut proc, &mut host, write_fd, &mut empty_reads, 0), + Err(Errno::EBADF), + ); + assert_eq!( + sys_pwritev(&mut proc, &mut host, read_fd, empty_writes, 0), + Err(Errno::EBADF), + ); + assert_eq!( + sys_preadv(&mut proc, &mut host, read_fd, &mut empty_reads, -1), + Err(Errno::EINVAL), + ); + assert_eq!( + sys_pwritev(&mut proc, &mut host, write_fd, empty_writes, -1), + Err(Errno::EINVAL), + ); + + let (pipe_read, pipe_write) = sys_pipe(&mut proc).unwrap(); + assert_eq!( + sys_readv(&mut proc, &mut host, pipe_read, &mut empty_reads), + Ok(0), + "an empty pipe read must not report EAGAIN", + ); + assert_eq!( + sys_writev(&mut proc, &mut host, pipe_write, empty_writes), + Ok(0), + ); + assert_eq!( + sys_preadv(&mut proc, &mut host, pipe_read, &mut empty_reads, 0), + Err(Errno::ESPIPE), + ); + assert_eq!( + sys_pwritev(&mut proc, &mut host, pipe_write, empty_writes, 0), + Err(Errno::ESPIPE), + ); + } + + #[test] + fn vector_io_uses_exactly_one_host_operation() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let read_fd = sys_open(&mut proc, &mut host, b"/vector-read", O_RDONLY, 0).unwrap(); + let write_fd = sys_open( + &mut proc, + &mut host, + b"/vector-write", + O_WRONLY | O_CREAT, + 0o644, + ) + .unwrap(); + + let mut first = [0xAA; 2]; + let mut second = [0xAA; 4]; + let mut read_iovecs: [&mut [u8]; 2] = [&mut first, &mut second]; + assert_eq!( + sys_readv(&mut proc, &mut host, read_fd, &mut read_iovecs), + Ok(5), + ); + assert_eq!(host.read_calls, 0); + assert_eq!(host.pread_calls, vec![(100, 0, 6)]); + assert_eq!(&first, b"he"); + assert_eq!(&second, &[b'l', b'l', b'o', 0xAA]); + + assert_eq!( + sys_writev(&mut proc, &mut host, write_fd, &[b"ab", b"cd"]), + Ok(4), + ); + assert_eq!(host.write_calls, 0); + assert_eq!(host.pwrite_calls, vec![(101, 0, b"abcd".to_vec())],); + + let mut positioned_first = [0u8; 2]; + let mut positioned_second = [0u8; 3]; + let mut positioned_iovecs: [&mut [u8]; 2] = [&mut positioned_first, &mut positioned_second]; + assert_eq!( + sys_preadv(&mut proc, &mut host, read_fd, &mut positioned_iovecs, 7,), + Ok(5), + ); + assert_eq!(host.read_calls, 0); + assert_eq!(host.pread_calls, vec![(100, 0, 6), (100, 7, 5)]); + assert_eq!( + sys_pwritev(&mut proc, &mut host, write_fd, &[b"ef", b"gh"], 9,), + Ok(4), + ); + assert_eq!(host.write_calls, 0); + assert_eq!( + host.pwrite_calls, + vec![(101, 0, b"abcd".to_vec()), (101, 9, b"efgh".to_vec()),], + ); + } + + #[test] + fn host_io_rejects_impossible_counts_without_mutating_the_ofd_cursor() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open( + &mut proc, + &mut host, + b"/malformed-host-count", + O_RDWR | O_CREAT, + 0o644, + ) + .unwrap(); + let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; + proc.ofd_table.get_mut(ofd_idx).unwrap().offset = 29; + + host.pread_reported = Some(2); + let mut byte = [0u8; 1]; + assert_eq!( + sys_read(&mut proc, &mut host, fd, &mut byte), + Err(Errno::EIO), + ); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, 29); + + host.pwrite_reported = Some(2); + assert_eq!(sys_write(&mut proc, &mut host, fd, b"x"), Err(Errno::EIO),); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, 29); + } + + #[test] + fn host_io_cursor_advancement_is_checked_at_i64_max() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open( + &mut proc, + &mut host, + b"/host-cursor-boundary", + O_RDWR | O_CREAT, + 0o644, + ) + .unwrap(); + let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; + let mut byte = [0u8; 1]; + + proc.ofd_table.get_mut(ofd_idx).unwrap().offset = i64::MAX - 1; + assert_eq!(sys_read(&mut proc, &mut host, fd, &mut byte), Ok(1)); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, i64::MAX); + + host.pread_reported = Some(0); + assert_eq!(sys_read(&mut proc, &mut host, fd, &mut byte), Ok(0),); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, i64::MAX); + assert_eq!( + host.pread_calls.len(), + 2, + "the host must be consulted to distinguish EOF from overflow", + ); + + proc.ofd_table.get_mut(ofd_idx).unwrap().offset = i64::MAX - 1; + assert_eq!(sys_write(&mut proc, &mut host, fd, b"x"), Ok(1)); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, i64::MAX); + assert_eq!(host.pwrite_calls.len(), 1); + + assert_eq!( + sys_write(&mut proc, &mut host, fd, b"x"), + Err(Errno::EOVERFLOW), + ); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, i64::MAX); + assert_eq!( + host.pwrite_calls.len(), + 1, + "cursor overflow must fail before an irreversible host write", + ); + } + + #[test] + fn cursor_advancement_rejects_counts_that_do_not_fit_offset_t() { + assert_eq!(checked_offset_advance(i64::MAX - 1, 1), Ok(i64::MAX),); + assert_eq!(checked_offset_advance(i64::MAX, 1), Err(Errno::EOVERFLOW),); + #[cfg(target_pointer_width = "64")] + assert_eq!( + checked_offset_advance(0, (i64::MAX as usize) + 1), + Err(Errno::EOVERFLOW), + ); + } + + #[test] + fn positioned_host_io_is_single_exact_and_cursor_neutral() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open( + &mut proc, + &mut host, + b"/positioned", + O_RDWR | O_CREAT, + 0o644, + ) + .unwrap(); + assert_eq!(sys_lseek(&mut proc, &mut host, fd, 37, SEEK_SET), Ok(37)); + host.seek_calls.clear(); + + let offset = (1i64 << 53) + 0x1234_5678; + let mut bytes = [0xAA; 6]; + assert_eq!( + sys_pread(&mut proc, &mut host, fd, &mut bytes, offset), + Ok(5), + ); + assert_eq!(&bytes, &[b'h', b'e', b'l', b'l', b'o', 0xAA]); + assert_eq!( + sys_pwrite(&mut proc, &mut host, fd, b"xyz", offset + 7), + Ok(3), + ); + + assert_eq!(host.pread_calls, vec![(100, offset, bytes.len())]); + assert_eq!(host.pwrite_calls, vec![(100, offset + 7, b"xyz".to_vec())],); + assert_eq!(host.read_calls, 0); + assert_eq!(host.write_calls, 0); + assert!( + host.seek_calls.is_empty(), + "positioned host I/O must not emulate with seek" + ); + let entry = proc.fd_table.get(fd).unwrap(); + assert_eq!( + proc.ofd_table.get(entry.ofd_ref.0).unwrap().offset, + 37, + "positioned I/O must not mutate the shared open-file cursor", + ); + } + + #[test] + fn positioned_host_io_propagates_errors_without_seeking() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open( + &mut proc, + &mut host, + b"/positioned-errors", + O_RDWR | O_CREAT, + 0o644, + ) + .unwrap(); + + host.pread_error = Some(Errno::EAGAIN); + let mut byte = [0u8; 1]; + assert_eq!( + sys_pread(&mut proc, &mut host, fd, &mut byte, 17), + Err(Errno::EAGAIN), + ); + host.pwrite_error = Some(Errno::ENOSPC); + assert_eq!( + sys_pwrite(&mut proc, &mut host, fd, b"x", 23), + Err(Errno::ENOSPC), + ); + assert_eq!(host.pread_calls, vec![(100, 17, 1)]); + assert_eq!(host.pwrite_calls, vec![(100, 23, b"x".to_vec())]); + assert!(host.seek_calls.is_empty()); + } + + #[test] + fn positioned_host_io_rejects_results_larger_than_the_checked_buffer() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open( + &mut proc, + &mut host, + b"/positioned-malformed-result", + O_RDWR | O_CREAT, + 0o644, + ) + .unwrap(); + + host.pread_reported = Some(2); + let mut byte = [0u8; 1]; + assert_eq!( + sys_pread(&mut proc, &mut host, fd, &mut byte, 31), + Err(Errno::EIO), + ); + host.pwrite_reported = Some(2); + assert_eq!( + sys_pwrite(&mut proc, &mut host, fd, b"x", 41), + Err(Errno::EIO), + ); + assert!(host.seek_calls.is_empty()); + } + + #[test] + fn regular_writes_follow_dynamic_rust_ofd_append_state() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open( + &mut proc, + &mut host, + b"/dynamic-append", + O_WRONLY | O_CREAT, + 0o644, + ) + .unwrap(); + let duplicate = sys_dup(&mut proc, fd).unwrap(); + + assert_eq!(sys_lseek(&mut proc, &mut host, fd, 3, SEEK_SET), Ok(3)); + host.seek_calls.clear(); + assert_eq!(sys_write(&mut proc, &mut host, duplicate, b"ab"), Ok(2)); + assert_eq!(host.pwrite_calls, vec![(100, 3, b"ab".to_vec())]); + assert!(host.append_calls.is_empty()); + + host.stat_size = 10; + assert_eq!(sys_fcntl(&mut proc, duplicate, F_SETFL, O_APPEND), Ok(0),); + assert_ne!( + sys_fcntl(&mut proc, fd, F_GETFL, 0).unwrap() as u32 & O_APPEND, + 0, + ); + assert_eq!(sys_write(&mut proc, &mut host, fd, b"cd"), Ok(2)); + assert_eq!(host.append_calls, vec![(100, b"cd".to_vec(), None)]); + let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, 12); + + // Positioned writes ignore O_APPEND and leave the OFD cursor intact. + assert_eq!(sys_pwrite(&mut proc, &mut host, duplicate, b"X", 1), Ok(1)); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, 12); + assert_eq!( + host.pwrite_calls, + vec![(100, 3, b"ab".to_vec()), (100, 1, b"X".to_vec()),], + ); + + assert_eq!(sys_fcntl(&mut proc, fd, F_SETFL, 0), Ok(0)); + assert_eq!( + sys_fcntl(&mut proc, duplicate, F_GETFL, 0).unwrap() as u32 & O_APPEND, + 0, + ); + assert_eq!( + sys_lseek(&mut proc, &mut host, duplicate, 4, SEEK_SET), + Ok(4), + ); + host.seek_calls.clear(); + assert_eq!(sys_write(&mut proc, &mut host, fd, b"Y"), Ok(1)); + assert_eq!(host.pwrite_calls.last(), Some(&(100, 4, b"Y".to_vec())),); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, 5); + assert!( + host.seek_calls.is_empty(), + "ordinary regular writes must not synchronize a backend cursor", + ); + } + + #[test] + fn append_outcome_is_checked_before_publishing_the_rust_cursor() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open( + &mut proc, + &mut host, + b"/append-boundary", + O_WRONLY | O_CREAT | O_APPEND, + 0o644, + ) + .unwrap(); + let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; + proc.ofd_table.get_mut(ofd_idx).unwrap().offset = 7; + + host.stat_size = i64::MAX as u64; + assert_eq!( + sys_write(&mut proc, &mut host, fd, b"x"), + Err(Errno::EOVERFLOW), + ); + assert_eq!(host.append_calls, vec![(100, b"x".to_vec(), None)]); + assert!(host.append_mutations.is_empty()); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, 7); + + host.append_calls.clear(); + host.stat_size = 20; + host.append_reported = Some(2); + assert_eq!(sys_write(&mut proc, &mut host, fd, b"x"), Err(Errno::EIO),); + assert_eq!(host.append_calls, vec![(100, b"x".to_vec(), None)]); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, 7); + } + + #[test] + fn append_rejects_malformed_end_and_limit_outcomes() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open( + &mut proc, + &mut host, + b"/append-malformed-end", + O_WRONLY | O_CREAT | O_APPEND, + 0o644, + ) + .unwrap(); + let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; + proc.ofd_table.get_mut(ofd_idx).unwrap().offset = 3; + + host.stat_size = 8; + host.append_reported = Some(2); + host.append_end = Some(1); + assert_eq!(sys_write(&mut proc, &mut host, fd, b"ab"), Err(Errno::EIO),); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, 3); + + host.append_end = Some(11); + sys_setrlimit(&mut proc, RLIMIT_FSIZE, 10, 10).unwrap(); + assert_eq!(sys_write(&mut proc, &mut host, fd, b"ab"), Err(Errno::EIO),); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, 3); + } + + #[test] + fn append_short_result_uses_the_backing_owned_end() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.stat_size = 40; + host.append_reported = Some(2); + let fd = sys_open( + &mut proc, + &mut host, + b"/append-short", + O_WRONLY | O_CREAT | O_APPEND, + 0o644, + ) + .unwrap(); + + assert_eq!(sys_write(&mut proc, &mut host, fd, b"abcd"), Ok(2)); + let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, 42); + assert_eq!(host.append_mutations, vec![b"ab".to_vec()]); + } + + #[test] + fn vector_io_preserves_eventfd_record_atomicity() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_eventfd2(&mut proc, 0, O_NONBLOCK).unwrap(); + let value = 0x0102_0304_0506_0708u64.to_le_bytes(); + + assert_eq!( + sys_writev(&mut proc, &mut host, fd, &[&value[..4], &value[4..]]), + Ok(8), + ); + + let mut first = [0u8; 3]; + let mut second = [0u8; 5]; + let mut iovecs: [&mut [u8]; 2] = [&mut first, &mut second]; + assert_eq!(sys_readv(&mut proc, &mut host, fd, &mut iovecs), Ok(8),); + let mut observed = [0u8; 8]; + observed[..3].copy_from_slice(&first); + observed[3..].copy_from_slice(&second); + assert_eq!(u64::from_le_bytes(observed), u64::from_le_bytes(value)); + } + + #[test] + fn writev_pipe_buf_failure_writes_no_partial_iovec() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let (read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); + let fill = vec![b'x'; DEFAULT_PIPE_CAPACITY - 3]; + assert_eq!( + sys_write(&mut proc, &mut host, write_fd, &fill), + Ok(fill.len()), + ); + + assert_eq!( + sys_writev(&mut proc, &mut host, write_fd, &[b"ab", b"cd"]), + Err(Errno::EAGAIN), + ); + let mut observed = vec![0u8; DEFAULT_PIPE_CAPACITY]; + assert_eq!( + sys_read(&mut proc, &mut host, read_fd, &mut observed), + Ok(fill.len()), + ); + assert_eq!(&observed[..fill.len()], fill.as_slice()); + } + + #[test] + fn vector_io_keeps_one_unix_datagram_boundary() { + use wasm_posix_shared::socket::{AF_UNIX, SOCK_DGRAM}; + + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let (sender, receiver) = + sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + + assert_eq!( + sys_writev(&mut proc, &mut host, sender, &[b"abc", b"def"]), + Ok(6), + ); + let mut first = [0u8; 2]; + let mut second = [0u8; 4]; + let mut iovecs: [&mut [u8]; 2] = [&mut first, &mut second]; + assert_eq!( + sys_readv(&mut proc, &mut host, receiver, &mut iovecs), + Ok(6), + ); + assert_eq!(&first, b"ab"); + assert_eq!(&second, b"cdef"); + + let mut empty = [0u8; 1]; + assert_eq!( + sys_read(&mut proc, &mut host, receiver, &mut empty), + Err(Errno::EAGAIN), + "writev must enqueue exactly one datagram", + ); + } + + #[test] + fn vector_io_rejects_iov_max_plus_one() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let (_read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); + let exact = vec![&[][..]; wasm_posix_shared::platform_limits::IOV_MAX]; + assert_eq!(sys_writev(&mut proc, &mut host, write_fd, &exact), Ok(0),); + let iovecs = vec![&[][..]; wasm_posix_shared::platform_limits::IOV_MAX + 1]; + assert_eq!( + sys_writev(&mut proc, &mut host, write_fd, &iovecs), + Err(Errno::EINVAL), + ); + } + + #[test] + fn vector_io_allocation_failure_is_enomem() { + assert_eq!( + checked_iovec_len(2, [i32::MAX as usize, 0]), + Ok(i32::MAX as usize), + ); + assert_eq!( + checked_iovec_len(2, [i32::MAX as usize, 1]), + Err(Errno::EINVAL), + ); + assert_eq!( + gather_iovecs_with_reserve(&[b"a", b"bc"], |bytes, length| { + assert!(bytes.is_empty()); + assert_eq!(length, 3); + Err(Errno::ENOMEM) + }), + Err(Errno::ENOMEM), + ); + assert_eq!( + try_initialized_vec_with_reserve(7, |bytes, length| { + assert!(bytes.is_empty()); + assert_eq!(length, 7); + Err(Errno::ENOMEM) + }), + Err(Errno::ENOMEM), + ); + assert_eq!( + gather_iovecs_with_reserve(&[b"abc"], |_, _| Ok(())), + Err(Errno::ENOMEM), + ); + assert_eq!( + try_initialized_vec_with_reserve(3, |_, _| Ok(())), + Err(Errno::ENOMEM), + ); + } + #[test] fn test_getrlimit_nofile_default() { let proc = Process::new(1); @@ -27262,16 +29034,38 @@ mod tests { assert_eq!(sys_write(&mut proc, &mut host, fd, b"abcde"), Ok(2)); let entry = proc.fd_table.get(fd).unwrap(); assert_eq!(proc.ofd_table.get(entry.ofd_ref.0).unwrap().offset, 10); - assert_eq!( - host.seek_calls - .iter() - .filter(|call| call.2 == SEEK_END) - .count(), - 1 - ); + assert!(host.seek_calls.is_empty()); + assert_eq!(host.append_calls, vec![(100, b"abcde".to_vec(), Some(10))],); + assert_eq!(host.append_mutations, vec![b"ab".to_vec()]); assert!(!fsize_signal_pending(&proc)); } + #[test] + fn test_rlimit_fsize_append_at_or_beyond_limit_does_not_mutate() { + for file_end in [10, 20] { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.stat_size = file_end; + let fd = sys_open( + &mut proc, + &mut host, + b"/tmp/fsize-append-at-limit", + O_WRONLY | O_CREAT | O_APPEND, + 0o644, + ) + .unwrap(); + let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; + proc.ofd_table.get_mut(ofd_idx).unwrap().offset = 4; + sys_setrlimit(&mut proc, RLIMIT_FSIZE, 10, 10).unwrap(); + + assert_eq!(sys_write(&mut proc, &mut host, fd, b"x"), Err(Errno::EFBIG),); + assert!(fsize_signal_pending(&proc)); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, 4); + assert_eq!(host.append_calls, vec![(100, b"x".to_vec(), Some(10))],); + assert!(host.append_mutations.is_empty()); + } + } + #[test] fn test_rlimit_fsize_memfd_write_pwrite_and_ftruncate() { let mut proc = Process::new(1); @@ -27398,6 +29192,162 @@ mod tests { assert!(!fsize_signal_pending(&proc)); } + #[test] + fn append_transfer_rejection_preserves_source_cursor_and_pipe_bytes() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let source = sys_memfd_create(&mut proc, b"append-source", 0).unwrap(); + sys_write(&mut proc, &mut host, source, b"abcdef").unwrap(); + sys_lseek(&mut proc, &mut host, source, 0, SEEK_SET).unwrap(); + let output = sys_open( + &mut proc, + &mut host, + b"/tmp/external-append-output", + O_WRONLY | O_CREAT | O_APPEND, + 0o644, + ) + .unwrap(); + host.stat_size = 0; + host.append_error = Some(Errno::EOPNOTSUPP); + + for operation in 0..3 { + sys_lseek(&mut proc, &mut host, source, 0, SEEK_SET).unwrap(); + let result = match operation { + 0 => sys_sendfile(&mut proc, &mut host, output, source, -1, 4), + 1 => sys_copy_file_range(&mut proc, &mut host, source, None, output, None, 4), + _ => sys_splice(&mut proc, &mut host, source, None, output, None, 4, 0), + }; + assert_eq!(result, Err(Errno::EOPNOTSUPP)); + assert_eq!(sys_lseek(&mut proc, &mut host, source, 0, SEEK_CUR), Ok(0),); + } + + let (pipe_reader, pipe_writer) = sys_pipe(&mut proc).unwrap(); + assert_eq!( + sys_write(&mut proc, &mut host, pipe_writer, b"queued"), + Ok(6), + ); + assert_eq!( + sys_splice(&mut proc, &mut host, pipe_reader, None, output, None, 6, 0,), + Err(Errno::EOPNOTSUPP), + ); + let mut queued = [0u8; 6]; + assert_eq!( + sys_read(&mut proc, &mut host, pipe_reader, &mut queued), + Ok(6), + ); + assert_eq!(&queued, b"queued"); + } + + #[test] + fn append_transfer_short_result_consumes_only_published_source_prefix() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let source = sys_memfd_create(&mut proc, b"short-append-source", 0).unwrap(); + sys_write(&mut proc, &mut host, source, b"abcdef").unwrap(); + sys_lseek(&mut proc, &mut host, source, 0, SEEK_SET).unwrap(); + let output = sys_open( + &mut proc, + &mut host, + b"/tmp/managed-append-output", + O_WRONLY | O_CREAT | O_APPEND, + 0o644, + ) + .unwrap(); + host.stat_size = 0; + host.append_reported = Some(2); + host.append_end = Some(2); + + assert_eq!( + sys_copy_file_range(&mut proc, &mut host, source, None, output, None, 5), + Ok(2), + ); + assert_eq!(sys_lseek(&mut proc, &mut host, source, 0, SEEK_CUR), Ok(2),); + + let (pipe_reader, pipe_writer) = sys_pipe(&mut proc).unwrap(); + assert_eq!( + sys_write(&mut proc, &mut host, pipe_writer, b"12345"), + Ok(5), + ); + assert_eq!( + sys_splice(&mut proc, &mut host, pipe_reader, None, output, None, 5, 0,), + Ok(2), + ); + let mut remainder = [0u8; 3]; + assert_eq!( + sys_read(&mut proc, &mut host, pipe_reader, &mut remainder), + Ok(3), + ); + assert_eq!(&remainder, b"345"); + } + + #[test] + fn append_transfer_fsize_exact_and_plus_one_preserve_source_prefix() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let source = sys_memfd_create(&mut proc, b"limited-append-source", 0).unwrap(); + sys_write(&mut proc, &mut host, source, b"ab").unwrap(); + sys_lseek(&mut proc, &mut host, source, 0, SEEK_SET).unwrap(); + host.stat_size = 4; + let output = sys_open( + &mut proc, + &mut host, + b"/tmp/limited-append-output", + O_WRONLY | O_CREAT | O_APPEND, + 0o644, + ) + .unwrap(); + sys_setrlimit(&mut proc, RLIMIT_FSIZE, 5, 5).unwrap(); + + assert_eq!( + sys_copy_file_range(&mut proc, &mut host, source, None, output, None, 2), + Ok(1), + ); + assert_eq!(sys_lseek(&mut proc, &mut host, source, 0, SEEK_CUR), Ok(1),); + assert_eq!(host.append_mutations, vec![b"a".to_vec()]); + assert!(!fsize_signal_pending(&proc)); + + assert_eq!( + sys_copy_file_range(&mut proc, &mut host, source, None, output, None, 1), + Err(Errno::EFBIG), + ); + assert_eq!(sys_lseek(&mut proc, &mut host, source, 0, SEEK_CUR), Ok(1),); + assert_eq!(host.append_mutations, vec![b"a".to_vec()]); + assert!(fsize_signal_pending(&proc)); + } + + #[test] + fn append_transfer_uses_atomic_end_when_preflight_stat_is_stale() { + for (stale_stat, append_start, expected) in [(6, 0, 4), (0, 4, 1)] { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let source = sys_memfd_create(&mut proc, b"stale-stat-source", 0).unwrap(); + sys_write(&mut proc, &mut host, source, b"abcd").unwrap(); + sys_lseek(&mut proc, &mut host, source, 0, SEEK_SET).unwrap(); + host.stat_size = stale_stat; + host.append_start = Some(append_start); + let output = sys_open( + &mut proc, + &mut host, + b"/tmp/stale-stat-append-output", + O_WRONLY | O_CREAT | O_APPEND, + 0o644, + ) + .unwrap(); + sys_setrlimit(&mut proc, RLIMIT_FSIZE, 5, 5).unwrap(); + + assert_eq!( + sys_copy_file_range(&mut proc, &mut host, source, None, output, None, 4), + Ok(expected), + ); + assert_eq!( + sys_lseek(&mut proc, &mut host, source, 0, SEEK_CUR), + Ok(expected as i64), + ); + assert_eq!(host.append_mutations, vec![b"abcd"[..expected].to_vec()],); + assert!(!fsize_signal_pending(&proc)); + } + } + #[test] fn test_ftruncate_and_fallocate_rlimit_fsize() { let mut proc = Process::new(1); @@ -27878,8 +29828,8 @@ mod tests { fn test_setuid_nonroot_to_own_uid_sets_euid_only() { let mut proc = Process::new(1); sys_setuid(&mut proc, 7).unwrap(); // drop to uid=euid=7 - // Simulate regaining privilege partly: impossible without saved-set, - // but setting euid back to real uid is always allowed. + // Simulate regaining privilege partly: impossible without saved-set, + // but setting euid back to real uid is always allowed. sys_seteuid(&mut proc, 7).unwrap(); assert_eq!(proc.euid, 7); } @@ -28149,7 +30099,7 @@ mod tests { proc.signals.blocked = 0xFF; proc.signals.raise(2); // SIGINT pending let result = sys_sigsuspend(&mut proc, &mut host, 0); - let tid = crate::process_table::current_tid(); + let tid = current_tid_for_process(&proc); assert_eq!(result, Err(Errno::EINTR)); assert_eq!(proc.signals.blocked, 0); assert_eq!(proc.sigsuspend_saved_mask_for(tid), Some(0xFF)); @@ -28180,7 +30130,7 @@ mod tests { let mut host = MockHostIO::new(); proc.signals.blocked = 0xFF; let result = sys_sigsuspend(&mut proc, &mut host, 0); - let tid = crate::process_table::current_tid(); + let tid = current_tid_for_process(&proc); assert_eq!(result, Err(Errno::EAGAIN)); assert_eq!(proc.signals.blocked, 0); assert_eq!(proc.sigsuspend_saved_mask_for(tid), Some(0xFF)); @@ -28275,6 +30225,19 @@ mod tests { fn host_write(&mut self, _handle: i64, buf: &[u8]) -> Result { Ok(buf.len()) } + fn host_pread( + &mut self, + _handle: i64, + buf: &mut [u8], + _offset: i64, + ) -> Result { + let n = buf.len().min(5); + buf[..n].copy_from_slice(&b"hello"[..n]); + Ok(n) + } + fn host_pwrite(&mut self, _handle: i64, buf: &[u8], _offset: i64) -> Result { + Ok(buf.len()) + } fn host_seek(&mut self, _handle: i64, _offset: i64, _whence: u32) -> Result { Ok(0) } @@ -28745,6 +30708,32 @@ mod tests { assert_eq!(err, Errno::ECONNREFUSED); } + #[test] + fn test_inet_dgram_disconnect_accepts_full_sockaddr_storage() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; + + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); + let peer = [2, 0, 0xff, 0xff, 127, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0]; + sys_connect(&mut proc, &mut host, fd, &peer).unwrap(); + let sock_idx = test_socket_idx(&proc, fd); + assert_eq!( + proc.sockets.get(sock_idx).unwrap().state, + crate::socket::SocketState::Connected, + ); + + let unspecified = [ + 0u8; + wasm_posix_shared::kernel_scratch_wire::SOCKADDR_STORAGE_BYTES as usize + ]; + sys_connect(&mut proc, &mut host, fd, &unspecified).unwrap(); + let socket = proc.sockets.get(sock_idx).unwrap(); + assert_eq!(socket.state, crate::socket::SocketState::Bound); + assert_eq!(socket.peer_addr, [0; 4]); + assert_eq!(socket.peer_port, 0); + } + #[test] fn test_inet_send_on_unconnected_returns_enotconn() { let mut proc = Process::new(1); @@ -28762,7 +30751,10 @@ mod tests { let mut host = MockHostIO::new(); use wasm_posix_shared::socket::*; let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - let mut addr = [0u8; 110]; + let mut addr = [ + 0u8; + wasm_posix_shared::kernel_scratch_wire::SOCKADDR_UNIX_BYTES as usize + ]; addr[0] = 1; // AF_UNIX let path = b"/tmp/noexist.sock"; addr[2..2 + path.len()].copy_from_slice(path); @@ -28787,6 +30779,34 @@ mod tests { ); } + #[test] + fn test_unix_connect_rejects_address_larger_than_sockaddr_un() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; + + let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + let mut exact = vec![ + 0u8; + wasm_posix_shared::kernel_scratch_wire::SOCKADDR_UNIX_BYTES + as usize + ]; + exact[0] = AF_UNIX as u8; + exact[2] = 0; + exact[3] = b'x'; + assert_eq!( + sys_connect(&mut proc, &mut host, fd, &exact).unwrap_err(), + Errno::ECONNREFUSED, + ); + + let mut oversized = exact; + oversized.push(0); + assert_eq!( + sys_connect(&mut proc, &mut host, fd, &oversized).unwrap_err(), + Errno::EINVAL, + ); + } + #[test] fn test_unix_stream_connect_same_process() { let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); @@ -28799,7 +30819,10 @@ mod tests { // Create and bind a listener let server_fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); // AF_UNIX, SOCK_STREAM - let mut addr = [0u8; 110]; + let mut addr = [ + 0u8; + wasm_posix_shared::kernel_scratch_wire::SOCKADDR_UNIX_BYTES as usize + ]; addr[0] = 1; // AF_UNIX addr[2..2 + path.len()].copy_from_slice(path); let addrlen = 2 + path.len() + 1; @@ -28865,7 +30888,10 @@ mod tests { let mut table = ProcessTable::new(); assert_eq!(table.create_process().unwrap(), PARENT); let server_fd = sys_socket(table.get_mut(PARENT).unwrap(), &mut host, 1, 1, 0).unwrap(); - let mut addr = [0u8; 110]; + let mut addr = [ + 0u8; + wasm_posix_shared::kernel_scratch_wire::SOCKADDR_UNIX_BYTES as usize + ]; addr[0] = 1; addr[2..2 + path.len()].copy_from_slice(path); let addrlen = 2 + path.len() + 1; @@ -28939,7 +30965,10 @@ mod tests { unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); let server_fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); - let mut addr = [0u8; 110]; + let mut addr = [ + 0u8; + wasm_posix_shared::kernel_scratch_wire::SOCKADDR_UNIX_BYTES as usize + ]; addr[0] = 1; // AF_UNIX addr[2..2 + path.len()].copy_from_slice(path); let addrlen = 2 + path.len() + 1; @@ -28976,7 +31005,10 @@ mod tests { let mut proc = Process::new(9002); let mut host = MockHostIO::new(); let client_fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); - let mut addr = [0u8; 110]; + let mut addr = [ + 0u8; + wasm_posix_shared::kernel_scratch_wire::SOCKADDR_UNIX_BYTES as usize + ]; addr[0] = 1; // AF_UNIX let path = b"/tmp/noexist_9002.sock"; addr[2..2 + path.len()].copy_from_slice(path); @@ -28997,7 +31029,10 @@ mod tests { // Set up listener let server_fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); - let mut addr = [0u8; 110]; + let mut addr = [ + 0u8; + wasm_posix_shared::kernel_scratch_wire::SOCKADDR_UNIX_BYTES as usize + ]; addr[0] = 1; addr[2..2 + path.len()].copy_from_slice(path); let addrlen = 2 + path.len() + 1; @@ -29046,7 +31081,10 @@ mod tests { proc.set_pid_for_test(9020); proc.umask = 0o027; let fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); - let mut addr = [0u8; 110]; + let mut addr = [ + 0u8; + wasm_posix_shared::kernel_scratch_wire::SOCKADDR_UNIX_BYTES as usize + ]; addr[0] = 1; let path = b"/tmp/stat.sock"; addr[2..2 + path.len()].copy_from_slice(path); @@ -29094,7 +31132,10 @@ mod tests { let mut host = MockHostIO::new(); proc.set_pid_for_test(9021); let fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); - let mut addr = [0u8; 110]; + let mut addr = [ + 0u8; + wasm_posix_shared::kernel_scratch_wire::SOCKADDR_UNIX_BYTES as usize + ]; addr[0] = 1; let path = b"/tmp/unlink.sock"; addr[2..2 + path.len()].copy_from_slice(path); @@ -29134,13 +31175,20 @@ mod tests { let mut host = MockHostIO::new(); proc.set_pid_for_test(9022); let fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); - let mut addr = [0u8; 110]; + let mut addr = [ + 0u8; + wasm_posix_shared::kernel_scratch_wire::SOCKADDR_UNIX_BYTES as usize + ]; addr[0] = 1; let path = b"/tmp/getsockname.sock"; addr[2..2 + path.len()].copy_from_slice(path); sys_bind(&mut proc, &mut host, fd, &addr[..2 + path.len() + 1]).unwrap(); - let mut buf = [0u8; 128]; + let mut buf = [ + 0u8; + wasm_posix_shared::kernel_scratch_wire::SOCKADDR_STORAGE_BYTES + as usize + ]; let n = sys_getsockname(&proc, fd, &mut buf).unwrap(); assert_eq!(buf[0], 1); // AF_UNIX assert!(n >= 2 + path.len()); @@ -29151,6 +31199,88 @@ mod tests { registry.cleanup_process(9022); } + #[test] + fn test_getsockname_unix_exact_nonterminated_path_fits_sockaddr_storage() { + use wasm_posix_shared::kernel_scratch_wire::{ + SOCKADDR_STORAGE_BYTES, SOCKADDR_UNIX_BYTES, SOCKADDR_UNIX_PATH_BYTES, + SOCKADDR_UNIX_PATH_OFFSET_BYTES, + }; + + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + proc.set_pid_for_test(9038); + + let mut path = vec![b'/']; + path.extend(std::iter::repeat_n( + b'x', + SOCKADDR_UNIX_PATH_BYTES as usize - 1, + )); + assert_eq!(path.len(), SOCKADDR_UNIX_PATH_BYTES as usize); + host.set_missing_path(&path); + + let mut addr = vec![0; SOCKADDR_UNIX_BYTES as usize]; + addr[0] = wasm_posix_shared::socket::AF_UNIX as u8; + let path_offset = SOCKADDR_UNIX_PATH_OFFSET_BYTES as usize; + addr[path_offset..].copy_from_slice(&path); + let fd = sys_socket( + &mut proc, + &mut host, + wasm_posix_shared::socket::AF_UNIX, + wasm_posix_shared::socket::SOCK_DGRAM, + 0, + ) + .unwrap(); + sys_bind(&mut proc, &mut host, fd, &addr).unwrap(); + + let mut name = vec![0xa5; SOCKADDR_STORAGE_BYTES as usize]; + let name_len = sys_getsockname(&proc, fd, &mut name).unwrap(); + assert_eq!(name_len, SOCKADDR_UNIX_BYTES as usize + 1); + assert_eq!(&name[..path_offset], &addr[..path_offset]); + assert_eq!( + &name[path_offset..path_offset + path.len()], + path.as_slice() + ); + assert_eq!(name[path_offset + path.len()], 0); + assert_eq!(name[path_offset + path.len() + 1], 0xa5); + + unsafe { crate::unix_socket::global_unix_socket_registry() }.cleanup_process(9038); + } + + #[test] + fn test_getsockname_unix_keeps_short_relative_name_from_deep_cwd() { + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + proc.set_pid_for_test(9039); + proc.cwd = namespace_boundary_path(254, true); + assert_eq!(proc.cwd.len(), 4095); + + let mut canonical = proc.cwd.clone(); + canonical.extend_from_slice(b"/s"); + host.set_missing_path(&canonical); + let addr = test_unix_addr(b"s"); + let fd = sys_socket( + &mut proc, + &mut host, + wasm_posix_shared::socket::AF_UNIX, + wasm_posix_shared::socket::SOCK_DGRAM, + 0, + ) + .unwrap(); + sys_bind(&mut proc, &mut host, fd, &addr).unwrap(); + + assert!( + unsafe { crate::unix_socket::global_unix_socket_registry() }.contains(&canonical) + ); + let mut name = [0xa5; 16]; + let name_len = sys_getsockname(&proc, fd, &mut name).unwrap(); + assert_eq!(name_len, addr.len()); + assert_eq!(&name[..name_len], addr.as_slice()); + + unsafe { crate::unix_socket::global_unix_socket_registry() }.cleanup_process(9039); + } + #[test] fn test_abstract_unix_socket_bind_is_not_filesystem_backed() { let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); @@ -29496,7 +31626,7 @@ mod tests { // Old should have had interval 0.5s assert_eq!(old.0, 0); // interval_sec assert_eq!(old.1, 500000); // interval_usec - // Now timer should be cleared + // Now timer should be cleared assert_eq!(proc.alarm_deadline_ns, 0); assert_eq!(proc.alarm_interval_ns, 0); } @@ -29561,7 +31691,7 @@ mod tests { let mask = crate::signal::sig_bit(10) | crate::signal::sig_bit(12); let (sig, ..) = sys_sigtimedwait(&mut proc, &mut host, mask, 0).unwrap(); assert_eq!(sig, 10); // lowest first - // Only SIGUSR1 should be dequeued + // Only SIGUSR1 should be dequeued assert_eq!(proc.signals.pending & crate::signal::sig_bit(10), 0); assert_ne!(proc.signals.pending & crate::signal::sig_bit(12), 0); } @@ -30465,6 +32595,67 @@ mod tests { } } + #[test] + fn test_unix_datagram_address_truncates_at_every_prefix() { + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + let mut proc = Process::new(9066); + let mut host = MockHostIO::new(); + use wasm_posix_shared::kernel_scratch_wire::SOCKADDR_UNIX_PATH_OFFSET_BYTES; + use wasm_posix_shared::socket::AF_UNIX; + + let recv_path = b"/tmp/udg-address-prefix-recv.sock"; + let send_path = b"/tmp/udg-address-prefix-send.sock"; + let (recv_fd, recv_addr) = bind_test_unix_dgram(&mut proc, &mut host, recv_path); + let (send_fd, _) = bind_test_unix_dgram(&mut proc, &mut host, send_path); + sys_connect(&mut proc, &mut host, send_fd, &recv_addr).unwrap(); + + let expected = (AF_UNIX as u16).to_le_bytes(); + let full_len = SOCKADDR_UNIX_PATH_OFFSET_BYTES as usize; + assert_eq!(full_len, expected.len()); + for recvmsg in [false, true] { + for capacity in 0..=full_len { + sys_send(&mut proc, &mut host, send_fd, b"x", 0).unwrap(); + let mut payload = [0u8; 1]; + let mut from = vec![0xa5; full_len + 1]; + let reported = if recvmsg { + let received = sys_recvmsg( + &mut proc, + &mut host, + recv_fd, + &mut payload, + 0, + &mut from[..capacity], + ) + .unwrap(); + assert_eq!(received.return_len, 1); + received.addr_len + } else { + let (received, address_len) = sys_recvfrom( + &mut proc, + &mut host, + recv_fd, + &mut payload, + 0, + &mut from[..capacity], + ) + .unwrap(); + assert_eq!(received, 1); + address_len + }; + assert_eq!(reported, full_len); + assert_eq!(&from[..capacity], &expected[..capacity]); + assert!(from[capacity..].iter().all(|byte| *byte == 0xa5)); + } + } + + for fd in [send_fd, recv_fd] { + sys_close(&mut proc, &mut host, fd).unwrap(); + } + for path in [send_path.as_slice(), recv_path.as_slice()] { + sys_unlink(&mut proc, &mut host, path).unwrap(); + } + } + #[test] fn test_unix_datagram_msg_trunc_peek_preserves_full_queue_backpressure() { let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); @@ -31257,11 +33448,7 @@ mod tests { ); assert!(proc.signals.is_pending(SIGPIPE)); assert!( - proc.sockets - .get(recv_idx) - .unwrap() - .dgram_queue - .is_empty(), + proc.sockets.get(recv_idx).unwrap().dgram_queue.is_empty(), "read shutdown must discard datagrams that can no longer be received", ); proc.signals.clear(SIGPIPE); @@ -31435,13 +33622,12 @@ mod tests { sys_write(&mut proc, &mut host, client_fd, b"must not redirect").unwrap_err(), Errno::ECONNREFUSED, ); - assert!( - proc.sockets - .get(replacement_idx) - .unwrap() - .dgram_queue - .is_empty() - ); + assert!(proc + .sockets + .get(replacement_idx) + .unwrap() + .dgram_queue + .is_empty()); unsafe { crate::unix_socket::global_unix_socket_registry() }.cleanup_process(9039); } @@ -32614,8 +34800,8 @@ mod tests { #[test] fn test_signalfd4_reads_main_directed_signal_without_exposing_it_to_workers() { - use wasm_posix_shared::WasmPollFd; use wasm_posix_shared::signal::SIGXFSZ; + use wasm_posix_shared::WasmPollFd; let _guard = THREAD_IDENTITY_LOCK.lock().unwrap(); set_test_current_tid(0); @@ -32664,8 +34850,7 @@ mod tests { overrun_current: 3, overrun_last: 0, })); - proc.signals - .raise_timer(10, 0x0123_4567_89ab_cdef, 0); + proc.signals.raise_timer(10, 0x0123_4567_89ab_cdef, 0); let fd = sys_signalfd4(&mut proc, -1, crate::signal::sig_bit(10), O_NONBLOCK).unwrap(); let mut buf = [0u8; 128]; @@ -32720,8 +34905,8 @@ mod tests { #[test] fn test_signalfd4_poll_with_signal() { - use wasm_posix_shared::WasmPollFd; use wasm_posix_shared::signal::SIGINT; + use wasm_posix_shared::WasmPollFd; let mut proc = Process::new(1); let mut host = MockHostIO::new(); let mask = crate::signal::sig_bit(SIGINT); @@ -33837,11 +36022,11 @@ mod tests { assert_ne!(new_addr, addr1); // moved assert!(proc.memory.is_mapped(new_addr)); assert!(!proc.memory.is_mapped(addr1)); // old freed - // Note: byte-preservation across the move (the contract that mallocng - // depends on) cannot be verified here — `proc.memory` is metadata - // only on the host, and the addresses returned above don't back any - // real bytes in this test process. The host-side copy is performed - // in `host/src/kernel-worker.ts`'s SYS_MREMAP post-syscall fixup. + // Note: byte-preservation across the move (the contract that mallocng + // depends on) cannot be verified here — `proc.memory` is metadata + // only on the host, and the addresses returned above don't back any + // real bytes in this test process. The host-side copy is performed + // in `host/src/kernel-worker.ts`'s SYS_MREMAP post-syscall fixup. } #[test] @@ -35319,8 +37504,8 @@ mod tests { #[test] fn dri_nested_u64_pointer_rejects_instead_of_aliasing_low_memory() { use wasm_posix_shared::dri::{ - DRM_IOCTL_MODE_GETCONNECTOR, DRM_IOCTL_MODE_GETRESOURCES, WpkDrmModeCardRes, - WpkDrmModeGetConnector, + WpkDrmModeCardRes, WpkDrmModeGetConnector, DRM_IOCTL_MODE_GETCONNECTOR, + DRM_IOCTL_MODE_GETRESOURCES, }; let mut proc = Process::new(1); @@ -35657,15 +37842,14 @@ mod tests { // Per-fd handle is present. let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; - assert!( - proc.ofd_table - .get(ofd_idx) - .unwrap() - .dri() - .unwrap() - .handles - .contains_key(&created.handle) - ); + assert!(proc + .ofd_table + .get(ofd_idx) + .unwrap() + .dri() + .unwrap() + .handles + .contains_key(&created.handle)); // DESTROY_DUMB removes it from the namespace. let req = WpkDrmModeDestroyDumb { @@ -35681,16 +37865,14 @@ mod tests { &mut dbuf, ) .unwrap(); - assert!( - !proc - .ofd_table - .get(ofd_idx) - .unwrap() - .dri() - .unwrap() - .handles - .contains_key(&created.handle) - ); + assert!(!proc + .ofd_table + .get(ofd_idx) + .unwrap() + .dri() + .unwrap() + .handles + .contains_key(&created.handle)); // Second DESTROY_DUMB on the same handle → ENOENT. assert_eq!( @@ -35743,16 +37925,14 @@ mod tests { sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_GEM_CLOSE, &mut gbuf).unwrap(); let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; - assert!( - !proc - .ofd_table - .get(ofd_idx) - .unwrap() - .dri() - .unwrap() - .handles - .contains_key(&created.handle) - ); + assert!(!proc + .ofd_table + .get(ofd_idx) + .unwrap() + .dri() + .unwrap() + .handles + .contains_key(&created.handle)); } #[test] @@ -36435,15 +38615,14 @@ mod tests { // Per-fd kms.fbs map is empty. let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; - assert!( - proc.ofd_table - .get(ofd_idx) - .unwrap() - .kms() - .unwrap() - .fbs - .is_empty() - ); + assert!(proc + .ofd_table + .get(ofd_idx) + .unwrap() + .kms() + .unwrap() + .fbs + .is_empty()); // Second RMFB on the same id → ENOENT. let mut rmbuf = fb_out.fb_id.to_le_bytes(); @@ -36870,15 +39049,14 @@ mod tests { let err = sys_ioctl(&mut proc, &mut host, fd, gl::GLIO_INIT, &mut buf).unwrap_err(); assert_eq!(err, Errno::ENOSYS); let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; - assert!( - proc.ofd_table - .get(ofd_idx) - .unwrap() - .dri() - .unwrap() - .gl - .is_none() - ); + assert!(proc + .ofd_table + .get(ofd_idx) + .unwrap() + .dri() + .unwrap() + .gl + .is_none()); } #[test] @@ -37252,15 +39430,14 @@ mod tests { ); assert_eq!(host.gl_unbind_calls, vec![proc.pid as i32]); let ofd_idx = proc.fd_table.get(gl_fd).unwrap().ofd_ref.0; - assert!( - proc.ofd_table - .get(ofd_idx) - .unwrap() - .dri() - .unwrap() - .gl - .is_none() - ); + assert!(proc + .ofd_table + .get(ofd_idx) + .unwrap() + .dri() + .unwrap() + .gl + .is_none()); } #[test] @@ -37338,4 +39515,733 @@ mod tests { Errno::EINVAL, ); } + + #[test] + fn blocked_read_keeps_exact_ofd_across_close_and_fd_reuse() { + let mut proc = Process::new(71); + let mut host = MockHostIO::new(); + let mut locks = AdvisoryLockManager::new(); + let tid = 71; + set_test_current_tid(tid); + + let old_fd = sys_open(&mut proc, &mut host, b"/old", O_RDONLY, 0).unwrap(); + let old_handle = proc + .ofd_table + .get(proc.fd_table.get(old_fd).unwrap().ofd_ref.0) + .unwrap() + .host_handle; + host.pread_error = Some(Errno::EAGAIN); + assert_eq!( + sys_read(&mut proc, &mut host, old_fd, &mut [0u8; 4]), + Err(Errno::EAGAIN) + ); + + let token = ensure_blocking_retry_ofd_binding( + &mut proc, &mut locks, &mut host, tid, 3, old_fd, None, + ) + .unwrap(); + sys_close_with_locks(&mut proc, &mut locks, &mut host, old_fd).unwrap(); + assert!(!host.closed_handles.contains(&old_handle)); + + let reused_fd = sys_open(&mut proc, &mut host, b"/replacement", O_RDONLY, 0).unwrap(); + assert_eq!(reused_fd, old_fd); + let replacement_handle = proc + .ofd_table + .get(proc.fd_table.get(reused_fd).unwrap().ofd_ref.0) + .unwrap() + .host_handle; + assert_ne!(replacement_handle, old_handle); + + proc.blocked_retries + .activate(tid, token, BlockingRetryOperation::Read) + .unwrap(); + host.pread_error = None; + host.pread_calls.clear(); + let mut output = [0u8; 4]; + assert_eq!( + sys_read(&mut proc, &mut host, reused_fd, &mut output), + Ok(4) + ); + assert_eq!(output, *b"hell"); + assert_eq!(host.pread_calls, vec![(old_handle, 0, 4)]); + proc.blocked_retries.clear_active(); + + release_blocking_retry_binding(&mut proc, &mut locks, &mut host, tid, token).unwrap(); + assert!(host.closed_handles.contains(&old_handle)); + assert!(!host.closed_handles.contains(&replacement_handle)); + assert_eq!( + release_blocking_retry_binding(&mut proc, &mut locks, &mut host, tid, token,), + Err(Errno::ENOENT) + ); + set_test_current_tid(0); + } + + #[test] + fn blocked_descriptor_wait_families_keep_the_exact_ofd_across_reuse() { + for syscall in [10, 53, 54, 121, 384] { + let mut proc = Process::new(500 + syscall); + let mut host = MockHostIO::new(); + let mut locks = AdvisoryLockManager::new(); + let tid = proc.pid; + let fd = sys_open(&mut proc, &mut host, b"/old-target", O_RDWR, 0).unwrap(); + let old_ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; + + let token = ensure_blocking_retry_ofd_binding( + &mut proc, &mut locks, &mut host, tid, syscall, fd, None, + ) + .unwrap(); + sys_close_with_locks(&mut proc, &mut locks, &mut host, fd).unwrap(); + let replacement = + sys_open(&mut proc, &mut host, b"/replacement-target", O_RDWR, 0).unwrap(); + assert_eq!(replacement, fd); + assert_ne!( + proc.fd_table.get(replacement).unwrap().ofd_ref.0, + old_ofd_idx, + ); + + let operation = BlockingRetryOperation::from_syscall(syscall).unwrap(); + proc.blocked_retries + .activate(tid, token, operation) + .unwrap(); + assert_eq!(resolve_io_ofd(&proc, fd), Ok(old_ofd_idx)); + proc.blocked_retries.clear_active(); + release_blocking_retry_binding(&mut proc, &mut locks, &mut host, tid, token).unwrap(); + } + } + + #[test] + fn blocked_accept_and_connect_do_not_follow_reused_descriptor_numbers() { + use wasm_posix_shared::socket::{AF_INET, SOCK_STREAM}; + + let mut locks = AdvisoryLockManager::new(); + + let mut accept_proc = Process::new(620); + let mut accept_host = MockHostIO::new(); + let accept_tid = accept_proc.pid; + let listener = + sys_socket(&mut accept_proc, &mut accept_host, AF_INET, SOCK_STREAM, 0).unwrap(); + let mut listen_addr = [0u8; 16]; + listen_addr[0] = AF_INET as u8; + sys_bind(&mut accept_proc, &mut accept_host, listener, &listen_addr).unwrap(); + sys_listen(&mut accept_proc, &mut accept_host, listener, 4).unwrap(); + assert_eq!( + sys_accept(&mut accept_proc, &mut accept_host, listener), + Err(Errno::EAGAIN), + ); + let accept_token = ensure_blocking_retry_ofd_binding( + &mut accept_proc, + &mut locks, + &mut accept_host, + accept_tid, + 53, + listener, + None, + ) + .unwrap(); + sys_close_with_locks(&mut accept_proc, &mut locks, &mut accept_host, listener).unwrap(); + let replacement = sys_open( + &mut accept_proc, + &mut accept_host, + b"/accept-replacement", + O_RDONLY, + 0, + ) + .unwrap(); + assert_eq!(replacement, listener); + accept_proc + .blocked_retries + .activate(accept_tid, accept_token, BlockingRetryOperation::Accept) + .unwrap(); + assert_eq!( + sys_accept(&mut accept_proc, &mut accept_host, replacement), + Err(Errno::EAGAIN), + ); + accept_proc.blocked_retries.clear_active(); + release_blocking_retry_binding( + &mut accept_proc, + &mut locks, + &mut accept_host, + accept_tid, + accept_token, + ) + .unwrap(); + + let mut connect_proc = Process::new(621); + let mut connect_host = MockHostIO::new(); + connect_host.net_connect_result = Ok(()); + connect_host.net_connect_status_result = Err(Errno::EAGAIN); + let connect_tid = connect_proc.pid; + let connector = sys_socket( + &mut connect_proc, + &mut connect_host, + AF_INET, + SOCK_STREAM, + 0, + ) + .unwrap(); + let connect_addr = [2, 0, 0, 80, 203, 0, 113, 7, 0, 0, 0, 0, 0, 0, 0, 0]; + assert_eq!( + sys_connect( + &mut connect_proc, + &mut connect_host, + connector, + &connect_addr, + ), + Err(Errno::EINPROGRESS), + ); + let connect_token = ensure_blocking_retry_ofd_binding( + &mut connect_proc, + &mut locks, + &mut connect_host, + connect_tid, + 54, + connector, + None, + ) + .unwrap(); + sys_close_with_locks(&mut connect_proc, &mut locks, &mut connect_host, connector).unwrap(); + let replacement = sys_open( + &mut connect_proc, + &mut connect_host, + b"/connect-replacement", + O_RDONLY, + 0, + ) + .unwrap(); + assert_eq!(replacement, connector); + connect_proc + .blocked_retries + .activate(connect_tid, connect_token, BlockingRetryOperation::Connect) + .unwrap(); + connect_host.net_connect_status_result = Ok(()); + assert_eq!( + sys_connect( + &mut connect_proc, + &mut connect_host, + replacement, + &connect_addr, + ), + Ok(()), + ); + assert_eq!(connect_host.net_connect_calls.len(), 1); + connect_proc.blocked_retries.clear_active(); + release_blocking_retry_binding( + &mut connect_proc, + &mut locks, + &mut connect_host, + connect_tid, + connect_token, + ) + .unwrap(); + } + + #[test] + fn blocked_advisory_locks_do_not_follow_reused_descriptor_numbers() { + use wasm_posix_shared::fcntl_cmd::F_OFD_SETLKW; + + let mut locks = AdvisoryLockManager::new(); + let mut host = MockHostIO::new(); + let mut proc = Process::new(622); + let tid = proc.pid; + let fd = sys_open(&mut proc, &mut host, b"/old-fcntl", O_RDWR, 0).unwrap(); + let token = + ensure_blocking_retry_ofd_binding(&mut proc, &mut locks, &mut host, tid, 10, fd, None) + .unwrap(); + sys_close_with_locks(&mut proc, &mut locks, &mut host, fd).unwrap(); + let replacement = sys_open(&mut proc, &mut host, b"/new-fcntl", O_RDONLY, 0).unwrap(); + assert_eq!(replacement, fd); + + proc.blocked_retries + .activate(tid, token, BlockingRetryOperation::Fcntl) + .unwrap(); + let mut flock = WasmFlock { + l_type: F_WRLCK as i16, + l_whence: SEEK_SET as i16, + _pad1: 0, + l_start: 0, + l_len: 1, + l_pid: 0, + _pad2: 0, + }; + assert_eq!( + sys_fcntl_lock( + &mut proc, + &mut locks, + replacement, + F_OFD_SETLKW, + &mut flock, + &mut host, + ), + Ok(()), + ); + proc.blocked_retries.clear_active(); + release_blocking_retry_binding(&mut proc, &mut locks, &mut host, tid, token).unwrap(); + + let mut proc = Process::new(623); + let tid = proc.pid; + let fd = sys_open(&mut proc, &mut host, b"/old-flock", O_RDWR, 0).unwrap(); + let token = + ensure_blocking_retry_ofd_binding(&mut proc, &mut locks, &mut host, tid, 121, fd, None) + .unwrap(); + sys_close_with_locks(&mut proc, &mut locks, &mut host, fd).unwrap(); + let replacement = sys_open(&mut proc, &mut host, b"/dev/null", O_RDWR, 0).unwrap(); + assert_eq!(replacement, fd); + + proc.blocked_retries + .activate(tid, token, BlockingRetryOperation::Flock) + .unwrap(); + assert_eq!( + sys_flock(&mut proc, &mut locks, replacement, LOCK_EX, &mut host,), + Ok(()), + ); + proc.blocked_retries.clear_active(); + release_blocking_retry_binding(&mut proc, &mut locks, &mut host, tid, token).unwrap(); + } + + #[test] + fn blocked_advisory_lock_seek_end_does_not_follow_reused_descriptor_numbers() { + use wasm_posix_shared::fcntl_cmd::F_OFD_SETLKW; + + for old_is_memfd in [false, true] { + let mut locks = AdvisoryLockManager::new(); + let mut host = MockHostIO::new(); + let mut proc = Process::new(624 + old_is_memfd as u32); + let tid = proc.pid; + let fd = if old_is_memfd { + let fd = sys_memfd_create(&mut proc, b"old-fcntl-seek-end", 0).unwrap(); + assert_eq!(sys_write(&mut proc, &mut host, fd, b"payload"), Ok(7)); + fd + } else { + sys_open(&mut proc, &mut host, b"/old-fcntl-seek-end", O_RDWR, 0).unwrap() + }; + let token = ensure_blocking_retry_ofd_binding( + &mut proc, &mut locks, &mut host, tid, 10, fd, None, + ) + .unwrap(); + sys_close_with_locks(&mut proc, &mut locks, &mut host, fd).unwrap(); + let replacement = sys_open(&mut proc, &mut host, b"/dev/null", O_RDWR, 0).unwrap(); + assert_eq!(replacement, fd); + + proc.blocked_retries + .activate(tid, token, BlockingRetryOperation::Fcntl) + .unwrap(); + let mut flock = WasmFlock { + l_type: F_WRLCK as i16, + l_whence: SEEK_END as i16, + _pad1: 0, + l_start: -1, + l_len: 1, + l_pid: 0, + _pad2: 0, + }; + assert_eq!( + sys_fcntl_lock( + &mut proc, + &mut locks, + replacement, + F_OFD_SETLKW, + &mut flock, + &mut host, + ), + Ok(()), + "SEEK_END retry followed the reused fd for old_is_memfd={old_is_memfd}", + ); + proc.blocked_retries.clear_active(); + release_blocking_retry_binding(&mut proc, &mut locks, &mut host, tid, token).unwrap(); + } + } + + #[test] + fn cancelling_host_owned_wait_restores_each_tasks_saved_signal_mask_once() { + let mut proc = Process::new(610); + let worker_tid = 611; + proc.add_thread(crate::process::ThreadInfo::new(worker_tid, 0, 0, 0)); + + for (tid, original, temporary) in [ + (proc.pid, 0x1234_u64, 0x5678_u64), + (worker_tid, 0x9abc_u64, 0xdef0_u64), + ] { + proc.set_blocked_for(tid, temporary); + proc.set_sigsuspend_saved_mask_for(tid, Some(original)); + + assert!(cancel_host_owned_wait_for_tid(&mut proc, tid)); + assert_eq!(proc.blocked_for(tid), original); + assert_eq!(proc.sigsuspend_saved_mask_for(tid), None); + assert!(!cancel_host_owned_wait_for_tid(&mut proc, tid)); + assert_eq!(proc.blocked_for(tid), original); + } + } + + #[test] + fn live_task_cancellation_accepts_leader_and_worker_but_rejects_stale_ids() { + let mut proc = Process::new(612); + let worker_tid = 613; + proc.add_thread(crate::process::ThreadInfo::new(worker_tid, 0, 0, 0)); + let leader_tid = proc.pid; + + // The leader is intentionally absent from Process::threads but is a + // live explicit task and must receive the same validation. + assert_eq!( + cancel_host_owned_wait_for_live_tid(&mut proc, leader_tid), + Ok(()) + ); + assert_eq!( + cancel_host_owned_wait_for_live_tid(&mut proc, worker_tid), + Ok(()) + ); + + assert_eq!( + cancel_host_owned_wait_for_live_tid(&mut proc, 0), + Err(Errno::ESRCH) + ); + assert_eq!( + cancel_host_owned_wait_for_live_tid(&mut proc, 999), + Err(Errno::ESRCH) + ); + + proc.state = ProcessState::Exited; + assert_eq!( + cancel_host_owned_wait_for_live_tid(&mut proc, worker_tid), + Err(Errno::ESRCH) + ); + } + + #[test] + fn blocked_file_transfers_keep_both_ofds_across_close_and_reuse() { + for (syscall, operation) in [ + (294, BlockingRetryOperation::Sendfile), + (290, BlockingRetryOperation::CopyFileRange), + (291, BlockingRetryOperation::Splice), + ] { + let mut proc = Process::new(80 + syscall); + let mut host = MockHostIO::new(); + let mut locks = AdvisoryLockManager::new(); + let tid = proc.pid; + set_test_current_tid(tid); + + let input = sys_open(&mut proc, &mut host, b"/old-input", O_RDONLY, 0).unwrap(); + let output = sys_open(&mut proc, &mut host, b"/old-output", O_WRONLY, 0).unwrap(); + let input_idx = proc.fd_table.get(input).unwrap().ofd_ref.0; + let output_idx = proc.fd_table.get(output).unwrap().ofd_ref.0; + let input_handle = proc.ofd_table.get(input_idx).unwrap().host_handle; + let output_handle = proc.ofd_table.get(output_idx).unwrap().host_handle; + + let token = ensure_blocking_retry_ofd_pair_binding( + &mut proc, &mut locks, &mut host, tid, syscall, input, output, + ) + .unwrap(); + sys_close_with_locks(&mut proc, &mut locks, &mut host, input).unwrap(); + sys_close_with_locks(&mut proc, &mut locks, &mut host, output).unwrap(); + assert!(!host.closed_handles.contains(&input_handle)); + assert!(!host.closed_handles.contains(&output_handle)); + + let reused_input = sys_open(&mut proc, &mut host, b"/new-input", O_RDONLY, 0).unwrap(); + let reused_output = + sys_open(&mut proc, &mut host, b"/new-output", O_WRONLY, 0).unwrap(); + assert_eq!((reused_input, reused_output), (input, output)); + let new_input_handle = proc + .ofd_table + .get(proc.fd_table.get(reused_input).unwrap().ofd_ref.0) + .unwrap() + .host_handle; + let new_output_handle = proc + .ofd_table + .get(proc.fd_table.get(reused_output).unwrap().ofd_ref.0) + .unwrap() + .host_handle; + + proc.blocked_retries + .activate(tid, token, operation) + .unwrap(); + host.pread_calls.clear(); + host.pwrite_calls.clear(); + let copied = match operation { + BlockingRetryOperation::Sendfile => { + sys_sendfile(&mut proc, &mut host, reused_output, reused_input, 0, 4) + } + BlockingRetryOperation::CopyFileRange => sys_copy_file_range( + &mut proc, + &mut host, + reused_input, + Some(0), + reused_output, + Some(0), + 4, + ), + BlockingRetryOperation::Splice => sys_splice( + &mut proc, + &mut host, + reused_input, + Some(0), + reused_output, + Some(0), + 4, + 0, + ), + _ => unreachable!(), + }; + assert_eq!(copied, Ok(4)); + assert_eq!(host.pread_calls, vec![(input_handle, 0, 4)]); + assert_eq!( + host.pwrite_calls, + vec![(output_handle, 0, b"hell".to_vec())], + ); + proc.blocked_retries.clear_active(); + + release_blocking_retry_binding(&mut proc, &mut locks, &mut host, tid, token).unwrap(); + assert!(host.closed_handles.contains(&input_handle)); + assert!(host.closed_handles.contains(&output_handle)); + assert!(!host.closed_handles.contains(&new_input_handle)); + assert!(!host.closed_handles.contains(&new_output_handle)); + } + set_test_current_tid(0); + } + + #[test] + fn blocked_file_transfer_pair_pin_rolls_back_first_reference() { + let mut proc = Process::new(92); + let mut host = MockHostIO::new(); + let mut locks = AdvisoryLockManager::new(); + let input = sys_open(&mut proc, &mut host, b"/input", O_RDONLY, 0).unwrap(); + let output = sys_open(&mut proc, &mut host, b"/output", O_WRONLY, 0).unwrap(); + let tid = proc.pid; + let input_idx = proc.fd_table.get(input).unwrap().ofd_ref.0; + let output_idx = proc.fd_table.get(output).unwrap().ofd_ref.0; + proc.ofd_table.get_mut(output_idx).unwrap().ref_count = u32::MAX; + + assert_eq!( + ensure_blocking_retry_ofd_pair_binding( + &mut proc, &mut locks, &mut host, tid, 290, input, output, + ), + Err(Errno::EOVERFLOW), + ); + assert_eq!(proc.ofd_table.get(input_idx).unwrap().ref_count, 1); + assert_eq!(proc.ofd_table.get(output_idx).unwrap().ref_count, u32::MAX); + assert_eq!(proc.blocked_retries.binding_count(), 0); + } + + #[test] + fn blocked_recvfrom_keeps_original_socket_type_across_fd_reuse() { + use wasm_posix_shared::socket::{AF_INET, SOCK_DGRAM, SOCK_STREAM}; + + for replacement_is_socket in [false, true] { + let mut proc = Process::new(93 + replacement_is_socket as u32); + let mut host = MockHostIO::new(); + let mut locks = AdvisoryLockManager::new(); + let tid = proc.pid; + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); + let old_ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; + let token = ensure_blocking_retry_ofd_binding( + &mut proc, &mut locks, &mut host, tid, 63, fd, None, + ) + .unwrap(); + sys_close_with_locks(&mut proc, &mut locks, &mut host, fd).unwrap(); + + let replacement = if replacement_is_socket { + sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap() + } else { + sys_open(&mut proc, &mut host, b"/replacement-file", O_RDONLY, 0).unwrap() + }; + assert_eq!(replacement, fd); + + proc.blocked_retries + .activate(tid, token, BlockingRetryOperation::Recvfrom) + .unwrap(); + assert_eq!(resolve_io_ofd(&proc, fd), Ok(old_ofd_idx)); + let old_socket_idx = { + let ofd = proc.ofd_table.get(old_ofd_idx).unwrap(); + (-(ofd.host_handle + 1)) as usize + }; + assert_eq!( + proc.sockets.get(old_socket_idx).unwrap().sock_type, + crate::socket::SocketType::Dgram, + ); + assert_eq!( + sys_recvfrom(&mut proc, &mut host, fd, &mut [0u8; 1], 0, &mut [0u8; 16],), + Err(Errno::EAGAIN), + ); + proc.blocked_retries.clear_active(); + release_blocking_retry_binding(&mut proc, &mut locks, &mut host, tid, token).unwrap(); + } + } + + #[test] + fn exec_releases_every_old_image_retry_binding_before_replacement() { + let mut proc = Process::new(72); + let mut host = MockHostIO::new(); + let mut locks = AdvisoryLockManager::new(); + let tid = 72; + + let fd = sys_open(&mut proc, &mut host, b"/blocked", O_RDONLY, 0).unwrap(); + let handle = proc + .ofd_table + .get(proc.fd_table.get(fd).unwrap().ofd_ref.0) + .unwrap() + .host_handle; + let token = + ensure_blocking_retry_ofd_binding(&mut proc, &mut locks, &mut host, tid, 3, fd, None) + .unwrap(); + sys_close_with_locks(&mut proc, &mut locks, &mut host, fd).unwrap(); + assert!(!host.closed_handles.contains(&handle)); + assert_eq!(proc.blocked_retries.binding_count(), 1); + + commit_exec_state_with_locks(&mut proc, &mut locks, &mut host, tid).unwrap(); + assert_eq!(proc.blocked_retries.binding_count(), 0); + assert!(host.closed_handles.contains(&handle)); + assert_eq!( + release_blocking_retry_binding(&mut proc, &mut locks, &mut host, tid, token,), + Err(Errno::ENOENT) + ); + } + + #[test] + fn normal_and_signal_exit_release_retry_bindings_before_publishing_exit() { + for signal in [None, Some(wasm_posix_shared::signal::SIGTERM)] { + let mut proc = Process::new(76 + signal.is_some() as u32); + let mut host = MockHostIO::new(); + let mut locks = AdvisoryLockManager::new(); + let tid = proc.pid; + let fd = sys_open(&mut proc, &mut host, b"/exit-blocked", O_RDONLY, 0).unwrap(); + let handle = proc + .ofd_table + .get(proc.fd_table.get(fd).unwrap().ofd_ref.0) + .unwrap() + .host_handle; + ensure_blocking_retry_ofd_binding(&mut proc, &mut locks, &mut host, tid, 3, fd, None) + .unwrap(); + sys_close_with_locks(&mut proc, &mut locks, &mut host, fd).unwrap(); + assert!(!host.closed_handles.contains(&handle)); + + if let Some(signum) = signal { + sys_exit_by_signal_with_locks(&mut proc, &mut locks, &mut host, signum); + assert_eq!(proc.exit_signal, signum); + } else { + sys_exit_with_locks(&mut proc, &mut locks, &mut host, 9); + assert_eq!(proc.exit_status, 9); + } + assert_eq!(proc.state, ProcessState::Exited); + assert_eq!(proc.blocked_retries.binding_count(), 0); + assert!(host.closed_handles.contains(&handle)); + } + } + + #[test] + fn blocked_sendmsg_keeps_original_rights_after_numeric_fd_reuse() { + let _guard = SCM_RIGHTS_LIFETIME_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut proc = Process::new(73); + let mut host = MockHostIO::new(); + let mut locks = AdvisoryLockManager::new(); + let tid = 73; + + let carrier = sys_open(&mut proc, &mut host, b"/carrier", O_WRONLY, 0).unwrap(); + let carried = sys_open(&mut proc, &mut host, b"/carried", O_RDONLY, 0).unwrap(); + let carried_id = scm_rights_test_ofd_id(&proc, carried); + let snapshot = snapshot_scm_rights_fd(&proc, carried).unwrap(); + let token = ensure_blocking_retry_ofd_binding( + &mut proc, + &mut locks, + &mut host, + tid, + 137, + carrier, + Some(vec![snapshot]), + ) + .unwrap(); + + sys_close_with_locks(&mut proc, &mut locks, &mut host, carried).unwrap(); + let replacement = + sys_open(&mut proc, &mut host, b"/replacement-right", O_RDONLY, 0).unwrap(); + assert_eq!(replacement, carried); + assert_ne!(scm_rights_test_ofd_id(&proc, replacement), carried_id); + + proc.blocked_retries + .activate(tid, token, BlockingRetryOperation::Sendmsg) + .unwrap(); + let cloned = clone_active_sendmsg_ancillary(&proc, tid) + .unwrap() + .expect("retry binding must carry an immutable rights template"); + assert_eq!(cloned.len(), 1); + assert_eq!(cloned[0].ofd_id, carried_id); + drop(cloned); + proc.blocked_retries.clear_active(); + drain_deferred_scm_rights_releases(&mut locks, &mut host); + + release_blocking_retry_binding(&mut proc, &mut locks, &mut host, tid, token).unwrap(); + assert!(!crate::ofd::has_in_flight_ofd(carried_id)); + } + + #[test] + fn releasing_blocked_receive_drains_rights_queued_on_its_final_socket_ref() { + use wasm_posix_shared::socket::{AF_UNIX, SOCK_DGRAM}; + + let _guard = SCM_RIGHTS_LIFETIME_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + for (syscall, operation) in [ + (56, BlockingRetryOperation::Recv), + (138, BlockingRetryOperation::Recvmsg), + ] { + let mut proc = Process::new(100 + syscall); + let mut host = MockHostIO::new(); + let mut locks = AdvisoryLockManager::new(); + let tid = proc.pid; + let (sender, receiver) = + sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + let (carried_reader, carried_writer) = sys_pipe(&mut proc).unwrap(); + let carried_id = scm_rights_test_ofd_id(&proc, carried_reader); + let snapshot = snapshot_scm_rights_fd(&proc, carried_reader).unwrap(); + assert_eq!( + sys_sendmsg(&mut proc, &mut host, sender, b"Q", 0, None, vec![snapshot],), + Ok(1), + ); + assert!(crate::ofd::has_in_flight_ofd(carried_id)); + + let token = ensure_blocking_retry_ofd_binding( + &mut proc, &mut locks, &mut host, tid, syscall, receiver, None, + ) + .unwrap(); + sys_close_with_locks(&mut proc, &mut locks, &mut host, carried_reader).unwrap(); + sys_close_with_locks(&mut proc, &mut locks, &mut host, receiver).unwrap(); + assert!(crate::ofd::has_in_flight_ofd(carried_id)); + + proc.blocked_retries + .activate(tid, token, operation) + .unwrap(); + proc.blocked_retries.clear_active(); + release_blocking_retry_binding(&mut proc, &mut locks, &mut host, tid, token).unwrap(); + assert!(!crate::ofd::has_in_flight_ofd(carried_id)); + assert_eq!(crate::pipe::deferred_in_flight_release_state().0, 0); + + for fd in [carried_writer, sender] { + sys_close_with_locks(&mut proc, &mut locks, &mut host, fd).unwrap(); + } + } + } + + #[test] + fn thread_exit_consumes_its_blocked_retry_target() { + let mut proc = Process::new(74); + let tid = 75; + proc.add_thread(crate::process::ThreadInfo::new(tid, 0, 0, 0)); + let mut host = MockHostIO::new(); + let mut locks = AdvisoryLockManager::new(); + let fd = sys_open(&mut proc, &mut host, b"/thread-blocked", O_RDONLY, 0).unwrap(); + let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; + ensure_blocking_retry_ofd_binding(&mut proc, &mut locks, &mut host, tid, 3, fd, None) + .unwrap(); + sys_close_with_locks(&mut proc, &mut locks, &mut host, fd).unwrap(); + assert!(proc.ofd_table.get(ofd_idx).is_some()); + assert_eq!(proc.blocked_retries.binding_count(), 1); + + cleanup_exiting_thread(&mut proc, &mut locks, &mut host, tid).unwrap(); + assert!(proc.ofd_table.get(ofd_idx).is_none()); + assert_eq!(proc.blocked_retries.binding_count(), 0); + assert!(proc.get_thread(tid).is_none()); + assert_eq!( + cleanup_exiting_thread(&mut proc, &mut locks, &mut host, tid), + Err(Errno::ESRCH) + ); + } } diff --git a/crates/kernel/src/transfer.rs b/crates/kernel/src/transfer.rs new file mode 100644 index 0000000000..f19d601fa2 --- /dev/null +++ b/crates/kernel/src/transfer.rs @@ -0,0 +1,732 @@ +//! Kernel-owned transport for one large host-mediated I/O operation. +//! +//! The ordinary syscall channel remains the cheap path. When a scalar or +//! vector I/O payload does not fit there, the host reserves this Rust-owned +//! region, copies at most its reported initialized capacity, and commits the +//! opaque token exactly once. + +extern crate alloc; + +use alloc::vec::Vec; +use core::mem; +use core::slice; +use spin::Mutex; +use wasm_posix_shared::{platform_limits, Errno}; + +/// Widened channels contain i64 fields and eight-byte-aligned companion +/// records. Backing the byte prefix with u64 words makes that base alignment +/// an owned allocation property rather than a dlmalloc implementation detail. +type TransferScratchWord = u64; +const TRANSFER_SCRATCH_WORD_BYTES: usize = core::mem::size_of::(); +const TRANSFER_SCRATCH_ALIGNMENT: usize = core::mem::align_of::(); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TransferIoOperation { + Read, + Write, + Pread, + Pwrite, +} + +pub(crate) fn io_operation_for_syscall( + original_syscall: u32, +) -> Result { + use wasm_posix_shared::Syscall; + use wasm_posix_shared::abi::extended_syscalls; + + match original_syscall { + number if number == Syscall::Read as u32 || number == Syscall::Readv as u32 => { + Ok(TransferIoOperation::Read) + } + number if number == Syscall::Write as u32 || number == Syscall::Writev as u32 => { + Ok(TransferIoOperation::Write) + } + number + if number == Syscall::Pread as u32 + || number == extended_syscalls::SYS_PREADV + || number == extended_syscalls::SYS_PREADV2 => + { + Ok(TransferIoOperation::Pread) + } + number + if number == Syscall::Pwrite as u32 + || number == extended_syscalls::SYS_PWRITEV + || number == extended_syscalls::SYS_PWRITEV2 => + { + Ok(TransferIoOperation::Pwrite) + } + _ => Err(Errno::EINVAL), + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TransferScratchState { + Idle, + Reserved { token: i64 }, + Executing { token: i64 }, + Ready { token: i64 }, +} + +struct TransferScratch { + state: TransferScratchState, + words: Vec, + authorized_bytes: usize, + next_token: Option, +} + +impl TransferScratch { + const fn new() -> Self { + Self { + state: TransferScratchState::Idle, + words: Vec::new(), + authorized_bytes: 0, + next_token: Some(1), + } + } + + fn begin(&mut self, minimum_capacity: usize) -> Result { + self.begin_with_reserve(minimum_capacity, |words, additional| { + words + .try_reserve_exact(additional) + .map_err(|_| Errno::ENOMEM) + }) + } + + fn begin_with_reserve( + &mut self, + minimum_capacity: usize, + reserve: impl FnOnce( + &mut Vec, + usize, + ) -> Result<(), Errno>, + ) -> Result { + if minimum_capacity == 0 + || minimum_capacity > platform_limits::MAX_TRANSFER_ALLOCATION_BYTES + { + return Err(Errno::EINVAL); + } + if !matches!(self.state, TransferScratchState::Idle) { + return Err(Errno::EBUSY); + } + + let token = self.next_token.ok_or(Errno::EOVERFLOW)?; + let complete_words = minimum_capacity / TRANSFER_SCRATCH_WORD_BYTES; + let word_count = complete_words + .checked_add(usize::from( + minimum_capacity % TRANSFER_SCRATCH_WORD_BYTES != 0, + )) + .ok_or(Errno::EINVAL)?; + let mut words = Vec::new(); + reserve(&mut words, word_count)?; + if words.capacity() < word_count { + return Err(Errno::ENOMEM); + } + // WHY: initialize all rounded backing words so constructing the exact + // authorized byte prefix is sound. Spare Vec capacity remains + // uninitialized and is never exposed to the host. + words.resize(word_count, 0); + + self.words = words; + self.authorized_bytes = minimum_capacity; + self.state = TransferScratchState::Reserved { token }; + self.next_token = token.checked_add(1); + Ok(token) + } + + fn pointer(&mut self, token: i64) -> Result { + if token <= 0 { + return Err(Errno::EINVAL); + } + match self.state { + TransferScratchState::Reserved { token: current } if current == token => { + let pointer = self.words.as_mut_ptr() as usize; + debug_assert_eq!(pointer % TRANSFER_SCRATCH_ALIGNMENT, 0); + Ok(pointer) + } + TransferScratchState::Executing { token: current } if current == token => { + Err(Errno::EBUSY) + } + _ => Err(Errno::EINVAL), + } + } + + fn capacity(&self, token: i64) -> Result { + if token <= 0 { + return Err(Errno::EINVAL); + } + match self.state { + TransferScratchState::Reserved { token: current } if current == token => { + Ok(self.authorized_bytes) + } + TransferScratchState::Executing { token: current } if current == token => { + Err(Errno::EBUSY) + } + _ => Err(Errno::EINVAL), + } + } + + fn begin_execution(&mut self, token: i64) -> Result<(*mut u8, usize), Errno> { + if token <= 0 { + return Err(Errno::EINVAL); + } + + let previous = mem::replace(&mut self.state, TransferScratchState::Idle); + match previous { + TransferScratchState::Reserved { token: current } if current == token => { + self.state = TransferScratchState::Executing { token }; + let pointer = self.words.as_mut_ptr().cast::(); + debug_assert_eq!( + pointer as usize % TRANSFER_SCRATCH_ALIGNMENT, + 0, + ); + Ok(( + pointer, + self.authorized_bytes, + )) + } + other => { + let error = match other { + TransferScratchState::Executing { token: current } if current == token => { + Errno::EBUSY + } + _ => Errno::EINVAL, + }; + self.state = other; + Err(error) + } + } + } + + fn finish_execution(&mut self, token: i64) -> Result<(), Errno> { + let previous = mem::replace(&mut self.state, TransferScratchState::Idle); + match previous { + TransferScratchState::Executing { token: current } if current == token => { + self.state = TransferScratchState::Ready { token }; + Ok(()) + } + other => { + self.state = other; + Err(Errno::EIO) + } + } + } + + fn cancel(&mut self, token: i64) -> Result<(), Errno> { + if token <= 0 { + return Err(Errno::EINVAL); + } + + let previous = mem::replace(&mut self.state, TransferScratchState::Idle); + match previous { + TransferScratchState::Reserved { token: current } + | TransferScratchState::Ready { token: current } + if current == token => + { + // Dropping the Vec returns its allocation to the kernel + // allocator. WebAssembly pages do not shrink, but a later + // kernel allocation can reuse these heap bytes. + drop(mem::take(&mut self.words)); + self.authorized_bytes = 0; + Ok(()) + } + other => { + let error = match other { + TransferScratchState::Executing { token: current } if current == token => { + Errno::EBUSY + } + _ => Errno::EINVAL, + }; + self.state = other; + Err(error) + } + } + } +} + +struct GlobalTransferScratch { + inner: Mutex, +} + +impl GlobalTransferScratch { + const fn new() -> Self { + Self { + inner: Mutex::new(TransferScratch::new()), + } + } + + fn begin(&self, minimum_capacity: usize) -> Result { + self.inner + .try_lock() + .ok_or(Errno::EBUSY)? + .begin(minimum_capacity) + } + + fn pointer(&self, token: i64) -> Result { + self.inner.try_lock().ok_or(Errno::EBUSY)?.pointer(token) + } + + fn capacity(&self, token: i64) -> Result { + self.inner.try_lock().ok_or(Errno::EBUSY)?.capacity(token) + } + + fn cancel(&self, token: i64) -> Result<(), Errno> { + // Cancellation mutates no external state and invokes no host code, so + // waiting for this short critical section cannot reenter the mutex. + // Once acquired it still rejects an Executing token without changing + // or dropping the allocation. + self.inner.lock().cancel(token) + } + + fn execute_with( + &self, + token: i64, + length: usize, + operation: impl FnOnce(&mut [u8]) -> Result, + ) -> Result { + self.execute_initialized_with(token, |initialized| { + if length > platform_limits::MAX_REPORTABLE_TRANSFER_BYTES { + return Err(Errno::EINVAL); + } + if length > initialized.len() { + return Err(Errno::E2BIG); + } + let result = operation(&mut initialized[..length]); + match result { + Ok(returned) if returned > length => Err(Errno::EIO), + other => other, + } + }) + } + + /// Consume a token and lend its complete initialized allocation. + /// + /// Unlike scalar I/O, a widened channel publishes its syscall result in + /// the channel header. The closure's `Result` is therefore transport + /// status only and must not be confused with a byte count. + fn execute_channel_with( + &self, + token: i64, + operation: impl FnOnce(&mut [u8]) -> Result<(), Errno>, + ) -> Result<(), Errno> { + self.execute_initialized_with(token, operation) + } + + fn execute_initialized_with( + &self, + token: i64, + operation: impl FnOnce(&mut [u8]) -> Result, + ) -> Result { + let (pointer, capacity) = self + .inner + .try_lock() + .ok_or(Errno::EBUSY)? + .begin_execution(token)?; + + // WHY: Executing forbids begin, query, cancellation, and another + // execute, so no path can mutate, reallocate, or drop the Vec while + // this stable pointer is in use. The mutex itself is released before + // the syscall can invoke host code, preventing callback deadlock. + let initialized = unsafe { slice::from_raw_parts_mut(pointer, capacity) }; + let result = operation(initialized); + + // Every ordinary Result path restores the stable allocation so the + // host can read a completed read-family payload through the pointer it + // obtained while Reserved, then cancel to drop it. A Wasm/host-import + // trap cannot unwind to this point: Executing and its allocation are + // intentionally irrecoverable in that case, and the host must + // fail-stop the kernel instance rather than reuse uncertain bytes. + self.inner.lock().finish_execution(token)?; + result + } +} + +static TRANSFER_SCRATCH: GlobalTransferScratch = GlobalTransferScratch::new(); + +/// Begin one exclusive initialized host-write reservation. +pub fn begin_transfer_scratch(minimum_capacity: usize) -> Result { + TRANSFER_SCRATCH.begin(minimum_capacity) +} + +/// Pointer owned by exactly the Reserved token. +pub fn transfer_scratch_pointer(token: i64) -> Result { + TRANSFER_SCRATCH.pointer(token) +} + +/// Initialized writable byte capacity owned by exactly the Reserved token. +pub fn transfer_scratch_capacity(token: i64) -> Result { + TRANSFER_SCRATCH.capacity(token) +} + +/// Drop the allocation owned by exactly the Reserved or Ready token. +pub fn cancel_transfer_scratch(token: i64) -> Result<(), Errno> { + TRANSFER_SCRATCH.cancel(token) +} + +/// Consume one reservation and execute without holding the scratch mutex. +pub fn execute_transfer_with( + token: i64, + length: usize, + operation: impl FnOnce(&mut [u8]) -> Result, +) -> Result { + TRANSFER_SCRATCH.execute_with(token, length, operation) +} + +/// Consume one reservation as a complete initialized widened channel. +pub fn execute_channel_transfer_with( + token: i64, + operation: impl FnOnce(&mut [u8]) -> Result<(), Errno>, +) -> Result<(), Errno> { + TRANSFER_SCRATCH.execute_channel_with(token, operation) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scalar_vector_and_v2_syscalls_map_to_one_scalar_operation() { + use wasm_posix_shared::Syscall; + use wasm_posix_shared::abi::extended_syscalls; + + for syscall in [Syscall::Read as u32, Syscall::Readv as u32] { + assert_eq!( + io_operation_for_syscall(syscall), + Ok(TransferIoOperation::Read), + ); + } + for syscall in [Syscall::Write as u32, Syscall::Writev as u32] { + assert_eq!( + io_operation_for_syscall(syscall), + Ok(TransferIoOperation::Write), + ); + } + for syscall in [ + Syscall::Pread as u32, + extended_syscalls::SYS_PREADV, + extended_syscalls::SYS_PREADV2, + ] { + assert_eq!( + io_operation_for_syscall(syscall), + Ok(TransferIoOperation::Pread), + ); + } + for syscall in [ + Syscall::Pwrite as u32, + extended_syscalls::SYS_PWRITEV, + extended_syscalls::SYS_PWRITEV2, + ] { + assert_eq!( + io_operation_for_syscall(syscall), + Ok(TransferIoOperation::Pwrite), + ); + } + assert_eq!(io_operation_for_syscall(0), Err(Errno::EINVAL)); + assert_eq!(io_operation_for_syscall(u32::MAX), Err(Errno::EINVAL)); + } + + #[test] + fn exact_capacity_executes_once_and_capacity_plus_one_does_not() { + let scratch = GlobalTransferScratch::new(); + let token = scratch.begin(8).unwrap(); + assert_ne!(scratch.pointer(token).unwrap(), 0); + assert_eq!(scratch.capacity(token), Ok(8)); + + let mut calls = 0; + assert_eq!( + scratch.execute_with(token, 8, |bytes| { + calls += 1; + bytes.copy_from_slice(b"complete"); + Ok(bytes.len()) + }), + Ok(8), + ); + assert_eq!(calls, 1); + assert_eq!(scratch.pointer(token), Err(Errno::EINVAL)); + assert_eq!(scratch.capacity(token), Err(Errno::EINVAL)); + assert_eq!( + scratch.execute_with(token, 8, |_| Ok(0)), + Err(Errno::EINVAL) + ); + scratch.cancel(token).unwrap(); + + let overflow = scratch.begin(8).unwrap(); + assert_eq!( + scratch.execute_with(overflow, 9, |_| { + calls += 1; + Ok(0) + }), + Err(Errno::E2BIG), + ); + assert_eq!(calls, 1); + scratch.cancel(overflow).unwrap(); + + let impossible = scratch.begin(8).unwrap(); + assert_eq!( + scratch.execute_with(impossible, 8, |_| Ok(9)), + Err(Errno::EIO), + ); + scratch.cancel(impossible).unwrap(); + } + + #[test] + fn every_authorized_byte_prefix_has_an_explicitly_aligned_base() { + for capacity in [1, 7, 8, 9, 65_609] { + let scratch = GlobalTransferScratch::new(); + let token = scratch.begin(capacity).unwrap(); + assert_eq!( + scratch.pointer(token).unwrap() % TRANSFER_SCRATCH_ALIGNMENT, + 0, + ); + assert_eq!(scratch.capacity(token), Ok(capacity)); + scratch.cancel(token).unwrap(); + } + } + + #[test] + fn channel_execution_lends_the_complete_initialized_extent_once() { + let scratch = GlobalTransferScratch::new(); + let token = scratch.begin(65_609).unwrap(); + let mut calls = 0; + assert_eq!( + scratch.execute_channel_with(token, |bytes| { + calls += 1; + assert_eq!(bytes.len(), 65_609); + bytes[0] = 0xa5; + bytes[65_608] = 0x5a; + Ok(()) + }), + Ok(()), + ); + assert_eq!(calls, 1); + assert_eq!(scratch.execute_channel_with(token, |_| Ok(())), Err(Errno::EINVAL)); + scratch.cancel(token).unwrap(); + } + + #[test] + fn reserve_failure_preserves_idle_state_and_token() { + let mut scratch = TransferScratch::new(); + assert_eq!( + scratch.begin_with_reserve(65_537, |bytes, requested| { + assert!(bytes.is_empty()); + assert_eq!(requested, 8_193); + Err(Errno::ENOMEM) + }), + Err(Errno::ENOMEM), + ); + assert!(matches!(scratch.state, TransferScratchState::Idle)); + assert_eq!(scratch.next_token, Some(1)); + + assert_eq!( + scratch.begin_with_reserve(4, |_, _| Ok(())), + Err(Errno::ENOMEM), + "a reserve implementation that reports success without capacity fails closed", + ); + assert!(matches!(scratch.state, TransferScratchState::Idle)); + assert_eq!(scratch.next_token, Some(1)); + + let token = scratch.begin(1).unwrap(); + assert_eq!(token, 1); + scratch.cancel(token).unwrap(); + } + + #[test] + fn u32_max_authorized_bytes_reaches_the_fallible_word_reserver() { + let mut scratch = TransferScratch::new(); + let maximum = platform_limits::MAX_TRANSFER_ALLOCATION_BYTES; + let expected_words = maximum / TRANSFER_SCRATCH_WORD_BYTES + + usize::from(maximum % TRANSFER_SCRATCH_WORD_BYTES != 0); + let mut reserve_called = false; + + assert_eq!( + scratch.begin_with_reserve(maximum, |words, requested_words| { + reserve_called = true; + assert!(words.is_empty()); + assert_eq!(requested_words, expected_words); + Err(Errno::ENOMEM) + }), + Err(Errno::ENOMEM), + ); + assert!(reserve_called); + assert!(matches!(scratch.state, TransferScratchState::Idle)); + assert_eq!(scratch.next_token, Some(1)); + } + + #[test] + fn overlap_stale_tokens_and_invalid_sizes_fail_closed() { + let scratch = GlobalTransferScratch::new(); + assert_eq!(scratch.begin(0), Err(Errno::EINVAL)); + assert_eq!( + scratch.begin( + platform_limits::MAX_TRANSFER_ALLOCATION_BYTES + .saturating_add(1), + ), + Err(Errno::EINVAL) + ); + + let first = scratch.begin(4).unwrap(); + assert_eq!(scratch.begin(4), Err(Errno::EBUSY)); + assert_eq!(scratch.pointer(0), Err(Errno::EINVAL)); + assert_eq!(scratch.capacity(-1), Err(Errno::EINVAL)); + assert_eq!(scratch.cancel(first + 1), Err(Errno::EINVAL)); + assert_eq!( + scratch.execute_with(first + 1, 4, |_| Ok(0)), + Err(Errno::EINVAL) + ); + scratch.cancel(first).unwrap(); + + let second = scratch.begin(4).unwrap(); + assert!(second > first); + assert_eq!(scratch.cancel(first), Err(Errno::EINVAL)); + scratch.cancel(second).unwrap(); + } + + #[test] + fn exhausted_token_space_fails_before_reserving_another_allocation() { + let mut scratch = TransferScratch::new(); + scratch.next_token = Some(i64::MAX); + let final_token = scratch.begin(1).unwrap(); + assert_eq!(final_token, i64::MAX); + scratch.cancel(final_token).unwrap(); + + let mut reserve_called = false; + assert_eq!( + scratch.begin_with_reserve(1, |_, _| { + reserve_called = true; + Ok(()) + }), + Err(Errno::EOVERFLOW), + ); + assert!(!reserve_called); + assert!(matches!(scratch.state, TransferScratchState::Idle)); + assert!(scratch.words.is_empty()); + assert_eq!(scratch.authorized_bytes, 0); + } + + #[test] + fn channel_metadata_does_not_reduce_the_scalar_payload_ceiling() { + let allocation = platform_limits::MAX_REPORTABLE_TRANSFER_BYTES + .checked_add(wasm_posix_shared::channel::DATA_OFFSET) + .and_then(|value| value.checked_add(7)) + .unwrap(); + assert!( + allocation <= platform_limits::MAX_TRANSFER_ALLOCATION_BYTES, + ); + + let scratch = GlobalTransferScratch::new(); + let token = scratch.begin(16).unwrap(); + assert_eq!( + scratch.execute_with( + token, + platform_limits::MAX_REPORTABLE_TRANSFER_BYTES + 1, + |_| Ok(0), + ), + Err(Errno::EINVAL), + ); + scratch.cancel(token).unwrap(); + } + + #[test] + fn queries_and_execution_fail_closed_during_lock_contention() { + let scratch = GlobalTransferScratch::new(); + let token = scratch.begin(4).unwrap(); + let guard = scratch.inner.lock(); + assert_eq!(scratch.begin(4), Err(Errno::EBUSY)); + assert_eq!(scratch.pointer(token), Err(Errno::EBUSY)); + assert_eq!(scratch.capacity(token), Err(Errno::EBUSY)); + assert_eq!(scratch.execute_with(token, 4, |_| Ok(4)), Err(Errno::EBUSY),); + drop(guard); + scratch.cancel(token).unwrap(); + } + + #[test] + fn cancel_waits_for_short_contention_then_drops_reserved_allocation() { + use std::sync::{Arc, mpsc}; + use std::time::Duration; + + let scratch = Arc::new(GlobalTransferScratch::new()); + let token = scratch.begin(4).unwrap(); + let guard = scratch.inner.lock(); + let (started_tx, started_rx) = mpsc::sync_channel(0); + let (result_tx, result_rx) = mpsc::sync_channel(0); + let cancel_scratch = Arc::clone(&scratch); + let cancel_thread = std::thread::spawn(move || { + started_tx.send(()).unwrap(); + result_tx.send(cancel_scratch.cancel(token)).unwrap(); + }); + + started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!( + result_rx.recv_timeout(Duration::from_millis(50)).is_err(), + "cancel must not report a transient lock-contention failure", + ); + drop(guard); + assert_eq!( + result_rx.recv_timeout(Duration::from_secs(1)).unwrap(), + Ok(()), + ); + cancel_thread.join().unwrap(); + let inner = scratch.inner.lock(); + assert_eq!(inner.state, TransferScratchState::Idle); + assert!(inner.words.is_empty()); + assert_eq!(inner.authorized_bytes, 0); + } + + #[test] + fn executing_rejects_reentrant_access_and_normal_error_becomes_ready() { + let scratch = GlobalTransferScratch::new(); + let token = scratch.begin(4).unwrap(); + assert_eq!( + scratch.execute_with(token, 4, |_| { + assert_eq!(scratch.begin(1), Err(Errno::EBUSY)); + assert_eq!(scratch.pointer(token), Err(Errno::EBUSY)); + assert_eq!(scratch.capacity(token), Err(Errno::EBUSY)); + assert_eq!(scratch.cancel(token), Err(Errno::EBUSY)); + assert_eq!(scratch.execute_with(token, 4, |_| Ok(0)), Err(Errno::EBUSY)); + Err(Errno::EBADF) + }), + Err(Errno::EBADF), + ); + scratch.cancel(token).unwrap(); + } + + #[test] + fn sequential_operations_use_fresh_allocations_and_monotonic_tokens() { + let scratch = GlobalTransferScratch::new(); + let first = scratch.begin(3).unwrap(); + assert_eq!(scratch.execute_with(first, 3, |_| Ok(3)), Ok(3)); + scratch.cancel(first).unwrap(); + + let second = scratch.begin(7).unwrap(); + assert!(second > first); + assert_eq!(scratch.capacity(second), Ok(7)); + assert_eq!(scratch.execute_with(second, 7, |_| Ok(7)), Ok(7)); + scratch.cancel(second).unwrap(); + } + + #[test] + fn panic_leaves_executing_token_irrecoverable_for_fail_stop() { + use std::panic::{AssertUnwindSafe, catch_unwind}; + + let scratch = GlobalTransferScratch::new(); + let token = scratch.begin(4).unwrap(); + let trapped = catch_unwind(AssertUnwindSafe(|| { + let _ = scratch.execute_with(token, 4, |_| -> Result { + panic!("simulated host-import trap"); + }); + })); + assert!(trapped.is_err()); + assert_eq!(scratch.cancel(token), Err(Errno::EBUSY)); + assert_eq!(scratch.begin(4), Err(Errno::EBUSY)); + let inner = scratch.inner.lock(); + assert_eq!( + inner.state, + TransferScratchState::Executing { token }, + "trap poisons the token", + ); + assert_eq!( + inner.authorized_bytes, + 4, + "kernel retains ownership until the failed instance is discarded", + ); + } +} diff --git a/crates/kernel/src/unix_socket.rs b/crates/kernel/src/unix_socket.rs index 576feeacd9..20e5dd85f2 100644 --- a/crates/kernel/src/unix_socket.rs +++ b/crates/kernel/src/unix_socket.rs @@ -9,6 +9,7 @@ extern crate alloc; use alloc::collections::BTreeMap; use alloc::vec::Vec; use core::cell::UnsafeCell; +use wasm_posix_shared::Errno; /// Entry in the Unix socket registry. #[derive(Debug, Clone)] @@ -52,57 +53,64 @@ impl UnixSocketRegistry { true } - /// Record a fork/spawn child that inherited a bound AF_UNIX endpoint. - pub fn add_owner(&mut self, path: &[u8], pid: u32, sock_idx: usize) -> bool { - let Some(entry) = self.entries.get_mut(path) else { - return false; + /// Record a fork/spawn child that inherited the parent's exact endpoint. + /// + /// The sockaddr retained by `SocketInfo` is not an authority lookup key: + /// the bound pathname may have been renamed and reused by another socket. + /// Match the stable parent owner tuple, then reserve before mutation so + /// allocation failure leaves the registry unchanged. + pub fn add_inherited_owner( + &mut self, + parent_pid: u32, + parent_sock_idx: usize, + child_pid: u32, + child_sock_idx: usize, + ) -> Result { + let Some(entry) = self + .entries + .values_mut() + .find(|entry| entry.owners.contains(&(parent_pid, parent_sock_idx))) + else { + // Unlinked/replaced pathname sockets no longer own a registry + // name, so there is no machine-wide name authority to inherit. + return Ok(false); }; - if !entry.owners.contains(&(pid, sock_idx)) { - entry.owners.push((pid, sock_idx)); + if entry.owners.contains(&(child_pid, child_sock_idx)) { + return Ok(false); } - true - } - - /// Drop one process-local owner. The name remains registered while any - /// inherited endpoint is live; otherwise it becomes reusable (which is - /// essential for Linux abstract-namespace sockets, which have no inode). - pub fn remove_owner(&mut self, path: &[u8], pid: u32, sock_idx: usize) -> bool { - let resolved_path = if self.entries.contains_key(path) { - Some(path.to_vec()) - } else { - // A bound pathname can be renamed while the socket remains open. - // SocketInfo intentionally retains the sockaddr supplied to - // bind(2), so locate the renamed registry entry by stable owner. - self.entries - .iter() - .find(|(_, entry)| entry.owners.contains(&(pid, sock_idx))) - .map(|(registered_path, _)| registered_path.clone()) - }; - let Some(resolved_path) = resolved_path else { - return false; - }; - let Some(entry) = self.entries.get_mut(&resolved_path) else { - return false; - }; - let old_len = entry.owners.len(); entry .owners - .retain(|owner| *owner != (pid, sock_idx)); - if entry.owners.len() == old_len { - return false; - } - if entry.owners.is_empty() { - // A pathname socket leaves its filesystem node behind after the - // last close; keep a metadata tombstone until unlink so stat still - // reports S_IFSOCK and bind still sees EADDRINUSE. Abstract names - // have no inode and disappear immediately. - if resolved_path.first().copied() == Some(0) { - self.entries.remove(&resolved_path); + .try_reserve_exact(1) + .map_err(|_| Errno::ENOMEM)?; + entry.owners.push((child_pid, child_sock_idx)); + Ok(true) + } + + /// Drop the exact process-local owner independently of its current name. + /// + /// The name remains registered while any inherited endpoint is live; + /// otherwise an abstract name becomes reusable immediately. A pathname + /// socket retains its metadata tombstone until unlink. + pub fn remove_owner_exact(&mut self, pid: u32, sock_idx: usize) -> bool { + let mut removed = false; + self.entries.retain(|path, entry| { + if removed || !entry.owners.contains(&(pid, sock_idx)) { + return true; } - } else if entry.pid == pid && entry.sock_idx == sock_idx { - (entry.pid, entry.sock_idx) = entry.owners[0]; - } - true + + entry.owners.retain(|owner| *owner != (pid, sock_idx)); + removed = true; + if entry.owners.is_empty() { + // A pathname socket leaves its filesystem node behind after + // last close; abstract names disappear immediately. + return path.first().copied() != Some(0); + } + if entry.pid == pid && entry.sock_idx == sock_idx { + (entry.pid, entry.sock_idx) = entry.owners[0]; + } + true + }); + removed } /// Re-key filesystem-backed socket metadata after a successful VFS @@ -205,7 +213,7 @@ mod tests { assert!(reg.lookup(b"/tmp/old.sock").is_none()); assert!(reg.lookup(b"/tmp/new.sock").is_some()); - assert!(reg.remove_owner(b"/tmp/old.sock", 1, 7)); + assert!(reg.remove_owner_exact(1, 7)); assert!(reg.lookup(b"/tmp/new.sock").is_none()); assert!(reg.contains(b"/tmp/new.sock")); } @@ -237,7 +245,7 @@ mod tests { fn test_pathname_metadata_remains_until_unlink() { let mut reg = UnixSocketRegistry::new(); reg.register(b"/tmp/stale.sock".to_vec(), 1, 0); - assert!(reg.remove_owner(b"/tmp/stale.sock", 1, 0)); + assert!(reg.remove_owner_exact(1, 0)); assert!(reg.contains(b"/tmp/stale.sock")); assert!(reg.lookup(b"/tmp/stale.sock").is_none()); assert!(reg.unregister(b"/tmp/stale.sock")); @@ -266,11 +274,54 @@ mod tests { fn test_inherited_owner_keeps_registration_live() { let mut reg = UnixSocketRegistry::new(); reg.register(b"\0abstract".to_vec(), 10, 4); - assert!(reg.add_owner(b"\0abstract", 20, 4)); - assert!(reg.remove_owner(b"\0abstract", 10, 4)); + assert_eq!(reg.add_inherited_owner(10, 4, 20, 4), Ok(true)); + assert_eq!(reg.add_inherited_owner(10, 4, 20, 4), Ok(false)); + assert!(reg.remove_owner_exact(10, 4)); let entry = reg.lookup(b"\0abstract").unwrap(); assert_eq!((entry.pid, entry.sock_idx), (20, 4)); - assert!(reg.remove_owner(b"\0abstract", 20, 4)); + assert!(reg.remove_owner_exact(20, 4)); assert!(reg.lookup(b"\0abstract").is_none()); } + + #[test] + fn inheritance_and_removal_follow_exact_owner_across_rename_and_reuse() { + let mut reg = UnixSocketRegistry::new(); + assert!(reg.register(b"/tmp/original.sock".to_vec(), 10, 4)); + assert!(reg.rename_path(b"/tmp/original.sock", b"/tmp/renamed.sock")); + assert!(reg.register(b"/tmp/original.sock".to_vec(), 30, 9)); + + assert_eq!(reg.add_inherited_owner(10, 4, 20, 4), Ok(true)); + assert_eq!( + ( + reg.lookup(b"/tmp/renamed.sock").unwrap().pid, + reg.lookup(b"/tmp/renamed.sock").unwrap().sock_idx + ), + (10, 4) + ); + assert_eq!( + ( + reg.lookup(b"/tmp/original.sock").unwrap().pid, + reg.lookup(b"/tmp/original.sock").unwrap().sock_idx + ), + (30, 9) + ); + + // The old sockaddr now names pid 30, but exact removal must update the + // renamed entry and leave that unrelated registration untouched. + assert!(reg.remove_owner_exact(10, 4)); + let renamed = reg.lookup(b"/tmp/renamed.sock").unwrap(); + assert_eq!((renamed.pid, renamed.sock_idx), (20, 4)); + let reused = reg.lookup(b"/tmp/original.sock").unwrap(); + assert_eq!((reused.pid, reused.sock_idx), (30, 9)); + } + + #[test] + fn inheritance_without_an_exact_parent_owner_does_not_mutate_registry() { + let mut reg = UnixSocketRegistry::new(); + assert!(reg.register(b"\0unrelated".to_vec(), 30, 9)); + + assert_eq!(reg.add_inherited_owner(10, 4, 20, 4), Ok(false)); + let entry = reg.lookup(b"\0unrelated").unwrap(); + assert_eq!((entry.pid, entry.sock_idx), (30, 9)); + } } diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index e9a6e2d520..693626febf 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -2,9 +2,11 @@ //! //! A single kernel instance manages all processes via `kernel_handle_channel`. //! Process state lives in `PROCESS_TABLE`. User programs use channel IPC -//! (`channel_syscall.c`) instead of direct kernel imports. The direct -//! `kernel_*` exports remain for compatibility with older host adapters and -//! tests, but they also resolve process state through `PROCESS_TABLE`. +//! (`channel_syscall.c`) instead of direct kernel imports. Fixed-size and +//! ownership-explicit `kernel_*` exports remain for host adapters and tests, +//! but variable-sized scalar/vector I/O is private to bounded channel or +//! tokenized transfer dispatch. All adapters resolve process state through +//! `PROCESS_TABLE`. //! //! This module declares: //! 1. Host function imports (functions the host must provide). @@ -19,20 +21,24 @@ use core::mem::{align_of, offset_of, size_of}; use core::slice; use wasm_posix_shared::{ - Errno, KernelWaitResult, WasmDirent, WasmStat, WasmStatfs, WasmTimespec, platform_limits, + abi::extended_syscalls as syscall_numbers, + channel_scalar::{self, ChannelResultKind}, + platform_limits, Errno, KernelWaitResult, WasmDirent, WasmStat, WasmStatfs, WasmTimespec, }; +use crate::channel_result::{checked_mmap_byte_offset, ChannelDispatchOutcome}; use crate::channel_scratch::{ - ChannelScratchRegion, checked_cstr_len, validate_channel_scratch_arguments, + checked_cstr_len, validate_channel_scratch_arguments, ChannelScratchRegion, }; use crate::ofd::FileType; use crate::process::{ - HostIO, Process, ProcessState, StdioConfig, StdioKind, normalize_posix_timer_signo, + normalize_posix_timer_signo, HostAppendOutcome, HostIO, Process, ProcessState, StdioConfig, + StdioKind, }; use crate::signal::{ - DefaultSignalOutcome, apply_default_signal_action_with_locks, - deliver_pending_signals_for_tid_with_locks, deliver_pending_signals_with_locks, - dequeue_signal_for, terminate_process_by_signal_with_locks, + apply_default_signal_action_with_locks, deliver_pending_signals_for_tid_with_locks, + deliver_pending_signals_with_locks, dequeue_signal_for, terminate_process_by_signal_with_locks, + DefaultSignalOutcome, }; use crate::socket_wire::validate_canonical_message_iov_len; use crate::syscalls; @@ -48,6 +54,28 @@ unsafe extern "C" { fn host_close(handle: i64) -> i32; fn host_read(handle: i64, buf_ptr: *mut u8, buf_len: u32) -> i32; fn host_write(handle: i64, buf_ptr: *const u8, buf_len: u32) -> i32; + fn host_append( + handle: i64, + buf_ptr: *const u8, + buf_len: u32, + limit_lo: u32, + limit_hi: i32, + ) -> i32; + fn host_append_position(handle: i64, written: u32) -> i64; + fn host_pread( + handle: i64, + buf_ptr: *mut u8, + buf_len: u32, + offset_lo: u32, + offset_hi: i32, + ) -> i32; + fn host_pwrite( + handle: i64, + buf_ptr: *const u8, + buf_len: u32, + offset_lo: u32, + offset_hi: i32, + ) -> i32; fn host_seek(handle: i64, offset_lo: u32, offset_hi: i32, whence: u32) -> i64; fn host_fstat(handle: i64, stat_ptr: *mut u8) -> i32; fn host_stat(path_ptr: *const u8, path_len: u32, stat_ptr: *mut u8) -> i32; @@ -215,7 +243,7 @@ struct WasmHostIO; /// Negative means error; the absolute value is the errno code. fn i32_to_result(val: i32) -> Result<(), Errno> { if val < 0 { - match Errno::from_u32((-val) as u32) { + match Errno::from_u32(val.unsigned_abs()) { Some(e) => Err(e), None => Err(Errno::EIO), } @@ -224,17 +252,43 @@ fn i32_to_result(val: i32) -> Result<(), Errno> { } } +fn checked_host_buffer_len(length: usize) -> Result { + u32::try_from(length).map_err(|_| Errno::EOVERFLOW) +} + +fn checked_host_transfer_result(result: i32, capacity: usize) -> Result { + if result < 0 { + return match Errno::from_u32(result.unsigned_abs()) { + Some(error) => Err(error), + None => Err(Errno::EIO), + }; + } + let transferred = result as usize; + if transferred > capacity { + return Err(Errno::EIO); + } + Ok(transferred) +} + +fn checked_host_i64_result(result: i64) -> Result { + if result >= 0 { + return Ok(result); + } + let raw_errno = u32::try_from(result.unsigned_abs()).map_err(|_| Errno::EIO)?; + match Errno::from_u32(raw_errno) { + Some(error) => Err(error), + None => Err(Errno::EIO), + } +} + +fn split_i64_words(value: i64) -> (u32, i32) { + (value as u32, (value >> 32) as i32) +} + impl HostIO for WasmHostIO { fn host_open(&mut self, path: &[u8], flags: u32, mode: u32) -> Result { let result = unsafe { host_open(path.as_ptr(), path.len() as u32, flags, mode) }; - if result < 0 { - match Errno::from_u32((-result) as u32) { - Some(e) => Err(e), - None => Err(Errno::EIO), - } - } else { - Ok(result) - } + checked_host_i64_result(result) } fn host_close(&mut self, handle: i64) -> Result<(), Errno> { @@ -243,41 +297,63 @@ impl HostIO for WasmHostIO { } fn host_read(&mut self, handle: i64, buf: &mut [u8]) -> Result { - let result = unsafe { host_read(handle, buf.as_mut_ptr(), buf.len() as u32) }; - if result < 0 { - match Errno::from_u32((-result) as u32) { - Some(e) => Err(e), - None => Err(Errno::EIO), - } - } else { - Ok(result as usize) - } + let capacity = checked_host_buffer_len(buf.len())?; + let result = unsafe { host_read(handle, buf.as_mut_ptr(), capacity) }; + checked_host_transfer_result(result, buf.len()) } fn host_write(&mut self, handle: i64, buf: &[u8]) -> Result { - let result = unsafe { host_write(handle, buf.as_ptr(), buf.len() as u32) }; - if result < 0 { - match Errno::from_u32((-result) as u32) { - Some(e) => Err(e), - None => Err(Errno::EIO), - } - } else { - Ok(result as usize) - } + let capacity = checked_host_buffer_len(buf.len())?; + let result = unsafe { host_write(handle, buf.as_ptr(), capacity) }; + checked_host_transfer_result(result, buf.len()) + } + + fn host_append( + &mut self, + handle: i64, + buf: &[u8], + limit: Option, + ) -> Result { + let capacity = checked_host_buffer_len(buf.len())?; + // A finite u64 ceiling above signed off_t cannot constrain a + // representable file position, so encode it as the unlimited sentinel. + let encoded_limit = limit + .and_then(|value| i64::try_from(value).ok()) + .unwrap_or(-1); + let (limit_lo, limit_hi) = split_i64_words(encoded_limit); + let result = unsafe { host_append(handle, buf.as_ptr(), capacity, limit_lo, limit_hi) }; + let written = checked_host_transfer_result(result, buf.len())?; + let written_u32 = u32::try_from(written).map_err(|_| Errno::EIO)?; + // WHY: the JavaScript host binds this scalar query to the immediately + // preceding successful append by handle and count, then consumes it. + // This avoids an additional host write into kernel Wasm memory. + let end = unsafe { host_append_position(handle, written_u32) }; + let end = checked_host_i64_result(end)?; + Ok(HostAppendOutcome { + written, + end: u64::try_from(end).map_err(|_| Errno::EIO)?, + }) + } + + fn host_pread(&mut self, handle: i64, buf: &mut [u8], offset: i64) -> Result { + let capacity = checked_host_buffer_len(buf.len())?; + let (offset_lo, offset_hi) = split_i64_words(offset); + let result = + unsafe { host_pread(handle, buf.as_mut_ptr(), capacity, offset_lo, offset_hi) }; + checked_host_transfer_result(result, buf.len()) + } + + fn host_pwrite(&mut self, handle: i64, buf: &[u8], offset: i64) -> Result { + let capacity = checked_host_buffer_len(buf.len())?; + let (offset_lo, offset_hi) = split_i64_words(offset); + let result = unsafe { host_pwrite(handle, buf.as_ptr(), capacity, offset_lo, offset_hi) }; + checked_host_transfer_result(result, buf.len()) } fn host_seek(&mut self, handle: i64, offset: i64, whence: u32) -> Result { - let offset_lo = offset as u32; - let offset_hi = (offset >> 32) as i32; + let (offset_lo, offset_hi) = split_i64_words(offset); let result = unsafe { host_seek(handle, offset_lo, offset_hi, whence) }; - if result < 0 { - match Errno::from_u32((-result) as u32) { - Some(e) => Err(e), - None => Err(Errno::EIO), - } - } else { - Ok(result) - } + checked_host_i64_result(result) } fn host_fstat(&mut self, handle: i64) -> Result { @@ -1137,6 +1213,30 @@ unsafe fn get_process_and_advisory_locks() -> ( } } +/// Get the current TID and its disjoint process/lock references from one +/// process-table borrow. +/// +/// WHY: obtaining `&mut Process` and then calling the global `current_tid()` +/// helper would reborrow the ProcessTable that owns that live reference. +#[inline] +unsafe fn get_process_tid_and_advisory_locks() -> ( + GklGuard, + u32, + &'static mut Process, + &'static mut crate::lock::AdvisoryLockManager, +) { + let guard = GklGuard::acquire(); + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let tid = table.current_tid(); + match table.current_process_and_advisory_locks() { + Some((process, locks)) => (guard, tid, process, locks), + #[cfg(any(target_arch = "wasm32", target_arch = "wasm64"))] + None => unsafe { core::hint::unreachable_unchecked() }, + #[cfg(not(any(target_arch = "wasm32", target_arch = "wasm64")))] + None => panic!("no current process in table"), + } +} + /// Finish machine-wide SCM_RIGHTS releases, if any, at an exported operation /// boundary. /// @@ -1150,10 +1250,7 @@ fn finish_machine_scm_rights_cleanup_if_pending() { let _gkl = GklGuard::acquire(); let table = unsafe { &mut *PROCESS_TABLE.0.get() }; let mut host = WasmHostIO; - syscalls::finish_scm_rights_cleanup( - table.advisory_locks_mut(), - &mut host, - ); + syscalls::finish_scm_rights_cleanup(table.advisory_locks_mut(), &mut host); }); } @@ -1243,6 +1340,41 @@ pub extern "C" fn kernel_alloc_scratch(size: u32) -> usize { ptr as usize } +/// Begin one exclusive initialized reservation for a large I/O payload. +/// +/// Returns a positive opaque token on success or a negated errno on failure. +/// The host must query pointer and capacity while the token is Reserved, then +/// either execute it once or cancel it. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_transfer_scratch_begin(minimum_capacity: usize) -> i64 { + match crate::transfer::begin_transfer_scratch(minimum_capacity) { + Ok(token) => token, + Err(error) => -(error as i64), + } +} + +/// Pointer owned by exactly the Reserved large-I/O token, or zero otherwise. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_transfer_scratch_pointer(token: i64) -> usize { + crate::transfer::transfer_scratch_pointer(token).unwrap_or(0) +} + +/// Initialized writable capacity owned by exactly the Reserved large-I/O +/// token, or zero otherwise. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_transfer_scratch_capacity(token: i64) -> usize { + crate::transfer::transfer_scratch_capacity(token).unwrap_or(0) +} + +/// Drop the allocation owned by exactly the Reserved or Ready token. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_transfer_scratch_cancel(token: i64) -> i32 { + match crate::transfer::cancel_transfer_scratch(token) { + Ok(()) => 0, + Err(error) => -(error as i32), + } +} + /// Begin one exclusive host-write reservation for a complete SYS_SPAWN blob. /// /// Returns a positive opaque token on success or a negated errno on failure. @@ -2023,7 +2155,11 @@ pub extern "C" fn kernel_thread_has_deliverable(pid: u32, tid: u32) -> i32 { if !proc.is_live_explicit_tid(tid) { return -(Errno::ESRCH as i32); } - if proc.deliverable_for(tid) != 0 { 1 } else { 0 } + if proc.deliverable_for(tid) != 0 { + 1 + } else { + 0 + } } None => -(Errno::ESRCH as i32), } @@ -2282,11 +2418,10 @@ pub extern "C" fn kernel_dequeue_signal( out_ptr: *mut u8, out_capacity: u32, ) -> i32 { - use crate::signal::{SignalHandler, sig_bit}; + use crate::signal::{sig_bit, SignalHandler}; use wasm_posix_shared::kernel_scratch_wire as signal_wire; - if let Err(error) = - crate::process_wire::validate_signal_delivery_output(out_ptr, out_capacity) + if let Err(error) = crate::process_wire::validate_signal_delivery_output(out_ptr, out_capacity) { return -(error as i32); } @@ -2363,10 +2498,7 @@ pub extern "C" fn kernel_dequeue_signal( }, ); let buf = unsafe { - slice::from_raw_parts_mut( - out_ptr, - signal_wire::SIGNAL_DELIVERY_BYTES as usize, - ) + slice::from_raw_parts_mut(out_ptr, signal_wire::SIGNAL_DELIVERY_BYTES as usize) }; buf.copy_from_slice(&encoded); return signum as i32; @@ -2518,37 +2650,157 @@ fn mq_timed_blocking_errno(timeout_ptr: usize, nonblock: bool) -> i32 { eagain } -/// Helper wrapping [`mq_timed_blocking_errno`] that resolves the non-blocking -/// flag from the mqueue table for a given descriptor. -fn mq_would_block_result(timeout_ptr: usize, table: &crate::mqueue::MqueueTable, mqd: u32) -> i32 { - let nonblock = table.is_nonblock(mqd).unwrap_or(false); - mq_timed_blocking_errno(timeout_ptr, nonblock) +fn activate_blocking_retry_for_current_task( + syscall_nr: u32, + retry_token: i64, +) -> Result<(), Errno> { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let tid = table.current_tid(); + let proc = table.current_process().ok_or(Errno::ESRCH)?; + proc.blocked_retries.begin_dispatch(tid)?; + let activation = if retry_token == 0 { + if proc.blocked_retries.has_binding_for_tid(tid) { + Err(Errno::EBUSY) + } else { + Ok(()) + } + } else if retry_token < 0 { + Err(Errno::EINVAL) + } else { + match crate::blocked_retry::BlockingRetryOperation::from_syscall(syscall_nr) { + Ok(operation) => proc.blocked_retries.activate(tid, retry_token, operation), + Err(error) => Err(error), + } + }; + if activation.is_err() { + proc.blocked_retries.clear_dispatch(); + } + activation } -/// Dispatches to the appropriate kernel function, then writes: -/// - return value (i64) at offset+56 -/// - errno (i32) at offset+64 +fn retain_blocking_retry_target(syscall_nr: u32, args: &[i64; 6]) -> Result<(), Errno> { + let Ok(operation) = crate::blocked_retry::BlockingRetryOperation::from_syscall(syscall_nr) + else { + return Ok(()); + }; + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let tid = table.current_tid(); + let (proc, locks) = table + .current_process_and_advisory_locks() + .ok_or(Errno::ESRCH)?; + let mut host = WasmHostIO; + match operation { + crate::blocked_retry::BlockingRetryOperation::Read + | crate::blocked_retry::BlockingRetryOperation::Write + | crate::blocked_retry::BlockingRetryOperation::Fcntl + | crate::blocked_retry::BlockingRetryOperation::Pread + | crate::blocked_retry::BlockingRetryOperation::Pwrite + | crate::blocked_retry::BlockingRetryOperation::Accept + | crate::blocked_retry::BlockingRetryOperation::Connect + | crate::blocked_retry::BlockingRetryOperation::Send + | crate::blocked_retry::BlockingRetryOperation::Recv + | crate::blocked_retry::BlockingRetryOperation::Sendto + | crate::blocked_retry::BlockingRetryOperation::Recvfrom + | crate::blocked_retry::BlockingRetryOperation::Recvmsg + | crate::blocked_retry::BlockingRetryOperation::Flock => { + syscalls::ensure_blocking_retry_ofd_binding( + proc, + locks, + &mut host, + tid, + syscall_nr, + args[0] as i32, + None, + )?; + } + crate::blocked_retry::BlockingRetryOperation::Sendfile => { + syscalls::ensure_blocking_retry_ofd_pair_binding( + proc, + locks, + &mut host, + tid, + syscall_nr, + args[1] as i32, + args[0] as i32, + )?; + } + crate::blocked_retry::BlockingRetryOperation::CopyFileRange + | crate::blocked_retry::BlockingRetryOperation::Splice => { + syscalls::ensure_blocking_retry_ofd_pair_binding( + proc, + locks, + &mut host, + tid, + syscall_nr, + args[0] as i32, + args[2] as i32, + )?; + } + // kernel_sendmsg retains its SCM_RIGHTS template while the complete + // canonical control wire is still available. + crate::blocked_retry::BlockingRetryOperation::Sendmsg => return Ok(()), + crate::blocked_retry::BlockingRetryOperation::MqSend + | crate::blocked_retry::BlockingRetryOperation::MqReceive => { + syscalls::ensure_blocking_retry_mqueue_binding(proc, tid, syscall_nr, args[0] as i32)?; + } + crate::blocked_retry::BlockingRetryOperation::MsgSend + | crate::blocked_retry::BlockingRetryOperation::MsgReceive => { + syscalls::ensure_blocking_retry_sysv_message_binding( + proc, + tid, + syscall_nr, + args[0] as i32, + )?; + } + crate::blocked_retry::BlockingRetryOperation::Semop => { + syscalls::ensure_blocking_retry_sysv_semaphore_binding( + proc, + tid, + syscall_nr, + args[0] as i32, + )?; + } + } + Ok(()) +} + +/// Dispatch one already-owned widened-channel allocation. /// -/// Returns the raw syscall result (also written to channel). -#[unsafe(no_mangle)] -pub extern "C" fn kernel_handle_channel( +/// `allocation_capacity` covers the header and the complete initialized data +/// area. The ordinary public export keeps its exact fixed-capacity ABI below; +/// the tokenized large-channel export can call this same implementation with a +/// larger Rust-owned allocation without weakening that public boundary. +fn handle_owned_channel_allocation( offset: usize, - capacity: u32, + allocation_capacity: usize, pid: u32, + retry_token: i64, ) -> i32 { use wasm_posix_shared::channel::*; - if capacity as usize != MIN_CHANNEL_SIZE { + if offset == 0 || allocation_capacity < MIN_CHANNEL_SIZE { unsafe { &mut *PROCESS_TABLE.0.get() }.clear_current_tid_binding(); - return -(Errno::EINVAL as i32); + return -(if offset == 0 { + Errno::EFAULT + } else { + Errno::EINVAL + } as i32); } - let scratch_region = match ChannelScratchRegion::for_channel(offset) { - Ok(region) => region, - Err(error) => { + let scratch_start = match offset.checked_add(DATA_OFFSET) { + Some(start) => start, + None => { unsafe { &mut *PROCESS_TABLE.0.get() }.clear_current_tid_binding(); - return -(error as i32); + return -(Errno::EFAULT as i32); } }; + let scratch_region = + match ChannelScratchRegion::new(scratch_start, allocation_capacity - DATA_OFFSET) { + Ok(region) => region, + Err(error) => { + unsafe { &mut *PROCESS_TABLE.0.get() }.clear_current_tid_binding(); + return -(error as i32); + } + }; // Every mailbox call consumes an explicit kernel-validated task binding // installed by kernel_set_current_tid. Missing or stale ambient state must @@ -2565,7 +2817,7 @@ pub extern "C" fn kernel_handle_channel( // mutate the same channel allocation through rewritten pointer args. let mem = unsafe { let ptr = base as *const u8; - core::slice::from_raw_parts(ptr, MIN_CHANNEL_SIZE) + core::slice::from_raw_parts(ptr, DATA_OFFSET) }; let syscall_nr = u32::from_le_bytes([ mem[SYSCALL_OFFSET], @@ -2599,11 +2851,41 @@ pub extern "C" fn kernel_handle_channel( // kernel memory terms. The JS layer sets pointer args as absolute // kernel-memory addresses, so we pass them through unchanged. - let result = if has_task_binding { - dispatch_channel_syscall(syscall_nr, &args, scratch_region) + let activation = if has_task_binding { + activate_blocking_retry_for_current_task(syscall_nr, retry_token) } else { - -(Errno::ESRCH as i32) + Err(Errno::ESRCH) + }; + let mut outcome = if let Err(error) = activation { + ChannelDispatchOutcome::narrow(-(error as i32)) + } else { + match channel_scalar::result_kind(syscall_nr) { + ChannelResultKind::I64 | ChannelResultKind::ProcessAddress => { + dispatch_channel_wide_result(syscall_nr, &args, scratch_region) + } + ChannelResultKind::I32 => ChannelDispatchOutcome::narrow(dispatch_channel_syscall( + syscall_nr, + &args, + scratch_region, + )), + } }; + if crate::blocked_retry::result_needs_target(syscall_nr, outcome.channel_errno) { + // connect(2) exposes EINPROGRESS/EALREADY while a host TCP handshake + // is pending instead of leaking HostIO's internal EAGAIN sentinel. + // Pin before returning to JavaScript anyway: the host may sleep and + // retry a blocking socket, and close+reuse must not redirect it. + if let Err(error) = retain_blocking_retry_target(syscall_nr, &args) { + outcome = ChannelDispatchOutcome::narrow(-(error as i32)); + } + } + if has_task_binding { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + if let Some(proc) = table.current_process() { + proc.blocked_retries.clear_active(); + proc.blocked_retries.clear_dispatch(); + } + } // Consume ambient task authority before cleanup can invoke a host close // callback. Cleanup needs no process identity, and a callback trap must // not leave a stale binding available to a later dispatch. @@ -2617,24 +2899,35 @@ pub extern "C" fn kernel_handle_channel( // Write result back to channel let out = unsafe { let ptr = base as *mut u8; - core::slice::from_raw_parts_mut(ptr, MIN_CHANNEL_SIZE) + core::slice::from_raw_parts_mut(ptr, DATA_OFFSET) }; - let ret_val: i64; - let errno_val: u32; - if result < 0 { - // Negative result: the absolute value is the errno - ret_val = -1; - errno_val = (-result) as u32; - } else { - ret_val = result as i64; - errno_val = 0; - } + out[RETURN_OFFSET..RETURN_OFFSET + 8].copy_from_slice(&outcome.channel_result.to_le_bytes()); + out[ERRNO_OFFSET..ERRNO_OFFSET + 4].copy_from_slice(&outcome.channel_errno.to_le_bytes()); - out[RETURN_OFFSET..RETURN_OFFSET + 8].copy_from_slice(&ret_val.to_le_bytes()); - out[ERRNO_OFFSET..ERRNO_OFFSET + 4].copy_from_slice(&errno_val.to_le_bytes()); + outcome.export_result +} - result +/// Dispatches to the appropriate kernel function, then writes: +/// - return value (i64) at offset+56 +/// - errno (i32) at offset+64 +/// +/// Returns the legacy i32 syscall mirror. The channel is authoritative for +/// results wider than i32. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_handle_channel( + scratch_ptr: usize, + capacity: u32, + pid: u32, + retry_token: i64, +) -> i32 { + use wasm_posix_shared::channel::MIN_CHANNEL_SIZE; + + if capacity as usize != MIN_CHANNEL_SIZE { + unsafe { &mut *PROCESS_TABLE.0.get() }.clear_current_tid_binding(); + return -(Errno::EINVAL as i32); + } + handle_owned_channel_allocation(scratch_ptr, capacity as usize, pid, retry_token) } /// Convert a raw widened-channel pointer without first narrowing it through @@ -2648,17 +2941,7 @@ pub extern "C" fn kernel_handle_channel( /// the checked `usize` conversion rejects values that cannot fit the kernel /// Wasm target (notably a wasm64 value presented to a wasm32 kernel). fn checked_channel_pointer_bits(raw: i64, pointer_bits: u32) -> Result { - let pointer = raw as u64; - let max_pointer = match pointer_bits { - 32 => u32::MAX as u64, - 64 => u64::MAX, - _ => return Err(Errno::EFAULT), - }; - if pointer > max_pointer { - Err(Errno::EFAULT) - } else { - Ok(pointer) - } + channel_scalar::process_size_for_pointer_bits(raw as u64, pointer_bits).ok_or(Errno::EFAULT) } fn checked_channel_pointer(raw: i64) -> Result { @@ -2666,12 +2949,33 @@ fn checked_channel_pointer(raw: i64) -> Result { usize::try_from(pointer).map_err(|_| Errno::EFAULT) } -/// Preserve the dispatcher's established i32 interpretation for scalar -/// count/length fields that are subsequently passed to a `usize` API. +/// Prove that a raw widened-channel pointer names the start of the current +/// allocation and that the complete requested range belongs to it. /// -/// Pointer fields must never use this helper. -fn channel_i32_scalar_usize(value: i32) -> usize { - value as usize +/// WHY: SEMCTL's command-dependent payload cannot use the generated fixed +/// descriptor plan, but it must not regain a bare pointer conversion that +/// proves only total Wasm addressability. The allocation start and capacity +/// remain independent requirements. +fn checked_channel_scratch_start_range( + raw: i64, + length: usize, + region: ChannelScratchRegion, +) -> Result { + let pointer = checked_channel_pointer(raw)?; + region.checked_start_range(pointer, length)?; + Ok(pointer) +} + +fn checked_channel_process_address( + syscall_number: u32, + args: &[i64; 6], + index: usize, +) -> Result { + checked_channel_pointer(channel_scalar::process_address_argument( + syscall_number, + args, + index, + )) } /// Zero-extend a scalar u32 count/length into the kernel target's `usize`. @@ -2681,8 +2985,114 @@ fn channel_u32_scalar_usize(value: i32) -> usize { usize::try_from(value as u32).expect("all supported kernel targets represent u32") } -fn checked_channel_usize_scalar(raw: i64) -> Result { - usize::try_from(raw).map_err(|_| Errno::EINVAL) +fn checked_process_size_bits(raw: u64, pointer_bits: u32) -> Result { + channel_scalar::process_size_for_pointer_bits(raw, pointer_bits).ok_or(Errno::EINVAL) +} + +fn checked_channel_process_size( + syscall_number: u32, + args: &[i64; 6], + index: usize, +) -> Result { + // WHY: getBigInt64/setBigInt64 transports the physical 64 bits through a + // signed i64. Reinterpret before the width check so a valid wasm64 size_t + // with bit 63 set is not mistaken for a negative length. + let raw = channel_scalar::process_size_argument(syscall_number, args, index); + let value = checked_process_size_bits(raw, usize::BITS)?; + usize::try_from(value).map_err(|_| Errno::EINVAL) +} + +fn reportable_channel_transfer_count(requested: usize) -> usize { + channel_scalar::reportable_transfer_count(requested as u64) as usize +} + +fn dispatch_channel_lseek(args: &[i64; 6], scratch_region: ChannelScratchRegion) -> i64 { + if let Err(error) = unsafe { validate_channel_scratch_arguments(5, args, scratch_region) } { + return -(error as i64); + } + kernel_lseek( + args[0] as i32, + channel_scalar::split_i64_low_argument(5, args, 1), + channel_scalar::split_i64_high_argument(5, args, 2), + args[3] as u32, + ) +} + +fn dispatch_channel_mmap( + args: &[i64; 6], + scratch_region: ChannelScratchRegion, +) -> Result { + unsafe { validate_channel_scratch_arguments(46, args, scratch_region) }?; + let byte_offset = checked_mmap_byte_offset(channel_scalar::i64_argument(46, args, 5))?; + let address = checked_channel_process_address(46, args, 0)?; + let length = checked_channel_process_size(46, args, 1)?; + let protection = args[2] as u32; + let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; + let mut host = WasmHostIO; + let result = syscalls::sys_mmap( + proc, + &mut host, + address, + length, + protection, + args[3] as u32, + args[4] as i32, + byte_offset, + ); + if let Ok(mapped_address) = result { + if protection != 0 { + ensure_memory_covers(mapped_address.saturating_add(length)); + } + } + deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); + result +} + +fn dispatch_channel_mremap( + args: &[i64; 6], + scratch_region: ChannelScratchRegion, +) -> Result { + unsafe { validate_channel_scratch_arguments(126, args, scratch_region) }?; + let old_address = checked_channel_process_address(126, args, 0)?; + let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; + let mut host = WasmHostIO; + let result = syscalls::sys_mremap( + proc, + old_address, + checked_channel_process_size(126, args, 1)?, + checked_channel_process_size(126, args, 2)?, + args[3] as u32, + ); + deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); + result +} + +fn dispatch_channel_wide_result( + nr: u32, + args: &[i64; 6], + scratch_region: ChannelScratchRegion, +) -> ChannelDispatchOutcome { + match (nr, channel_scalar::result_kind(nr)) { + (5, ChannelResultKind::I64) => { + ChannelDispatchOutcome::exact(dispatch_channel_lseek(args, scratch_region)) + } + (66, ChannelResultKind::I64) => ChannelDispatchOutcome::exact(kernel_time()), + (46, ChannelResultKind::ProcessAddress) => { + ChannelDispatchOutcome::process_address(dispatch_channel_mmap(args, scratch_region)) + } + (48, ChannelResultKind::ProcessAddress) => { + let result = unsafe { validate_channel_scratch_arguments(48, args, scratch_region) } + .and_then(|_| checked_channel_process_address(48, args, 0)) + .map(|address| kernel_brk(address)); + ChannelDispatchOutcome::process_address(result) + } + (126, ChannelResultKind::ProcessAddress) => { + ChannelDispatchOutcome::process_address(dispatch_channel_mremap(args, scratch_region)) + } + (_, kind) => { + unreachable!("channel {kind:?} result lacks a dedicated dispatcher for syscall {nr}") + } + } } // WHY: these exports gained explicit capacities together. Keep their exact @@ -2690,19 +3100,9 @@ fn checked_channel_usize_scalar(raw: i64) -> Result { // future pointer-only call or partial signature migration cannot compile. const _: extern "C" fn(u32, *mut i32, u32) -> i32 = kernel_pipe2; const _: extern "C" fn(u32, u32, u32, *mut i32, u32) -> i32 = kernel_socketpair; -const _: extern "C" fn(i32, u32, u32, *mut u8, u32, *mut u32, u32) -> i32 = - kernel_getsockopt; +const _: extern "C" fn(i32, u32, u32, *mut u8, u32, *mut u32, u32) -> i32 = kernel_getsockopt; const _: extern "C" fn(*mut u8, u32, u32, i32) -> i32 = kernel_poll; -const _: extern "C" fn( - i32, - *mut u8, - u32, - *mut u8, - u32, - *mut u8, - u32, - i32, -) -> i32 = kernel_select; +const _: extern "C" fn(i32, *mut u8, u32, *mut u8, u32, *mut u8, u32, i32) -> i32 = kernel_select; /// Dispatch a syscall by number with raw musl arguments. /// @@ -2721,8 +3121,8 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr }; // Scalar arguments retain the syscall ABI's existing i32 interpretation. - // Pointer arguments must instead use the checked macros below so wasm64 - // address bits are never lost through these scalar aliases. + // Pointer and process-size arguments must instead use the checked macros + // below so wasm64 high bits are never lost through these scalar aliases. let a1 = args[0] as i32; let a2 = args[1] as i32; let a3 = args[2] as i32; @@ -2734,6 +3134,14 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr // never kernel scratch. Scratch dereferences must use the validated // const/mut macros so allocation capacity remains part of their proof. macro_rules! process_address { + ($index:literal) => { + match checked_channel_process_address(nr, args, $index) { + Ok(pointer) => pointer, + Err(error) => return -(error as i32), + } + }; + } + macro_rules! conditional_process_address { ($index:literal) => { match checked_channel_pointer(args[$index]) { Ok(pointer) => pointer, @@ -2741,6 +3149,22 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr } }; } + macro_rules! process_size { + ($index:literal) => { + match checked_channel_process_size(nr, args, $index) { + Ok(size) => size, + Err(error) => return -(error as i32), + } + }; + } + macro_rules! process_size_u32 { + ($index:literal) => { + match u32::try_from(process_size!($index)) { + Ok(size) => size, + Err(_) => return -(Errno::EINVAL as i32), + } + }; + } macro_rules! channel_const_ptr { ($index:literal, $pointee:ty) => { match validated_scratch.pointer($index) { @@ -2757,16 +3181,61 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr } }; } - macro_rules! channel_cstr_len { - ($pointer:expr) => {{ - let pointer = $pointer; - match unsafe { checked_cstr_len(pointer, scratch_region) } { - Ok(length) => length, + macro_rules! channel_const_slice { + ($index:literal, $length:expr) => {{ + let pointer = match validated_scratch.pointer($index) { + Ok(pointer) => pointer, Err(error) => return -(error as i32), + }; + let length: usize = $length; + if let Err(error) = scratch_region.checked_range(pointer, length) { + return -(error as i32); + } + // The argument validator and this allocation-capacity check both + // run before the private slice-based syscall adapter can observe + // the bytes. + if length == 0 { + // WHY: Rust requires a non-null, aligned pointer even for an + // empty raw slice. Keep that language invariant automatic for + // every channel transfer instead of relying on each syscall + // to remember its own zero-length special case. + &[] + } else { + unsafe { slice::from_raw_parts(pointer as *const u8, length) } } }}; } - + macro_rules! channel_mut_slice { + ($index:literal, $length:expr) => {{ + let pointer = match validated_scratch.pointer($index) { + Ok(pointer) => pointer, + Err(error) => return -(error as i32), + }; + let length: usize = $length; + if let Err(error) = scratch_region.checked_range(pointer, length) { + return -(error as i32); + } + // WHY: a Rust slice, rather than a bare exported pointer, carries + // the exact live channel extent into the scalar I/O adapter. + if length == 0 { + // See channel_const_slice: an ignored zero-length pointer must + // never be used to construct a raw Rust slice. + &mut [] + } else { + unsafe { slice::from_raw_parts_mut(pointer as *mut u8, length) } + } + }}; + } + macro_rules! channel_cstr_len { + ($pointer:expr) => {{ + let pointer = $pointer; + match unsafe { checked_cstr_len(pointer, scratch_region) } { + Ok(length) => length, + Err(error) => return -(error as i32), + } + }}; + } + // Syscall number constants (must match libc/glue/syscall_glue.c) match nr { // Process info (0-arg) @@ -2810,12 +3279,16 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr kernel_close(a1) } } - 3 => kernel_read(a1, channel_mut_ptr!(1, u8), a3 as u32), // SYS_READ: (fd, buf, count) - 4 => kernel_write(a1, channel_const_ptr!(1, u8), a3 as u32), // SYS_WRITE: (fd, buf, count) - 5 => kernel_lseek(a1, a2 as u32, a3, a4 as u32) as i32, // SYS_LSEEK: (fd, off_lo, off_hi, whence) + 3 => channel_read(a1, channel_mut_slice!(1, process_size!(2))), // SYS_READ: (fd, buf, count) + 4 => channel_write(a1, channel_const_slice!(1, process_size!(2))), // SYS_WRITE: (fd, buf, count) 119 => { // SYS__LLSEEK: (fd, off_hi, off_lo, result_ptr, whence) - let result = kernel_lseek(a1, a3 as u32, a2, a5 as u32); + let result = kernel_lseek( + a1, + channel_scalar::split_i64_low_argument(119, args, 2), + channel_scalar::split_i64_high_argument(119, args, 1), + a5 as u32, + ); if result < 0 { result as i32 } else { @@ -2834,13 +3307,21 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr let stat_pointer = channel_mut_ptr!(1, u8); kernel_fstat(a1, stat_pointer) } - 64 => kernel_pread(a1, channel_mut_ptr!(1, u8), a3 as u32, args[3]), // SYS_PREAD: (fd, buf, count, offset) - 65 => kernel_pwrite(a1, channel_const_ptr!(1, u8), a3 as u32, args[3]), // SYS_PWRITE: (fd, buf, count, offset) + 64 => channel_pread( + a1, + channel_mut_slice!(1, process_size!(2)), + channel_scalar::i64_argument(64, args, 3), + ), // SYS_PREAD: (fd, buf, count, offset) + 65 => channel_pwrite( + a1, + channel_const_slice!(1, process_size!(2)), + channel_scalar::i64_argument(65, args, 3), + ), // SYS_PWRITE: (fd, buf, count, offset) // FD operations - 7 => kernel_dup(a1), // SYS_DUP - 8 => kernel_dup2(a1, a2), // SYS_DUP2 - 77 => kernel_dup3(a1, a2, a3 as u32), // SYS_DUP3 + 7 => kernel_dup(a1), // SYS_DUP + 8 => kernel_dup2(a1, a2), // SYS_DUP2 + 77 => kernel_dup3(a1, a2, a3 as u32), // SYS_DUP3 9 => kernel_pipe(channel_mut_ptr!(0, i32)), // SYS_PIPE: (pipefd_ptr) 78 => kernel_pipe2( a2 as u32, @@ -2925,7 +3406,7 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr // SYS_READLINK: (path, buf, bufsiz) let p = channel_const_ptr!(0, u8); let len = channel_cstr_len!(p); - kernel_readlink(p, len, channel_mut_ptr!(1, u8), a3 as u32) + kernel_readlink(p, len, channel_mut_ptr!(1, u8), process_size_u32!(2)) } 20 => { // SYS_CHMOD: (path, mode) @@ -2945,7 +3426,7 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr let len = channel_cstr_len!(p); kernel_access(p, len, a2 as u32) } - 23 => kernel_getcwd(channel_mut_ptr!(0, u8), a2 as u32), // SYS_GETCWD: (buf, size) + 23 => kernel_getcwd(channel_mut_ptr!(0, u8), process_size_u32!(1)), // SYS_GETCWD: (buf, size) 24 => { // SYS_CHDIR: (path) let p = channel_const_ptr!(0, u8); @@ -2963,10 +3444,10 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr a1, channel_mut_ptr!(1, u8), channel_mut_ptr!(2, u8), - a4 as u32, + process_size_u32!(3), ), // SYS_READDIR 27 => kernel_closedir(a1), // SYS_CLOSEDIR - 122 => kernel_getdents64(a1, channel_mut_ptr!(1, u8), a3 as u32), // SYS_GETDENTS64 + 122 => kernel_getdents64(a1, channel_mut_ptr!(1, u8), process_size_u32!(2)), // SYS_GETDENTS64 // Process control 34 => { @@ -3029,8 +3510,14 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr 0 } } - 73 => kernel_signal(a1 as u32, process_address!(1)), // SYS_SIGNAL - 39 => kernel_alarm(a1 as u32), // SYS_ALARM + 73 => { + let handler = match channel_scalar::exact_u32_argument(73, args, 1) { + Some(handler) => handler, + None => return -(Errno::EINVAL as i32), + }; + kernel_signal(a1 as u32, handler) + } // SYS_SIGNAL + 39 => kernel_alarm(a1 as u32), // SYS_ALARM 110 => { // SYS_SIGSUSPEND: (mask_ptr, sigsetsize) let (mask_lo, mask_hi) = if args[0] != 0 { @@ -3049,7 +3536,7 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr let ptr = channel_mut_ptr!(0, u8); unsafe { let bytes = proc - .pending_for(crate::process_table::current_tid()) + .pending_for(syscalls::current_tid_for_process(proc)) .to_le_bytes(); for i in 0..8 { *ptr.add(i) = bytes[i]; @@ -3122,9 +3609,7 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr // WHY: encode the complete native object before // replacing the capacity-checked scratch destination. // The host holds one exclusive synchronous lease. - let output = unsafe { - slice::from_raw_parts_mut(p, model.siginfo_size()) - }; + let output = unsafe { slice::from_raw_parts_mut(p, model.siginfo_size()) }; output.copy_from_slice(&encoded); } sig as i32 @@ -3151,54 +3636,12 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr }; kernel_utimensat(a1, p, len, channel_const_ptr!(2, u8), a4 as u32) } - 66 => kernel_time() as i32, // SYS_TIME 68 => kernel_usleep(a1 as u32), // SYS_USLEEP // Memory - 46 => { - // SYS_MMAP: (addr, len, prot, flags, fd, pgoffset) - // musl sends page offset (off / 4096) as a6. - // Call sys_mmap directly so the errno reaches the channel - // dispatcher — going through kernel_mmap would squash every - // Errno variant to MAP_FAILED (usize::MAX), and `as i32` - // turns that into -1, which the dispatcher interprets as - // -EPERM. - let pgoff = a6 as u32; - let byte_off = ((pgoff as u64) << 12) as i64; - let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; - let mut host = WasmHostIO; - let result = match syscalls::sys_mmap( - proc, - &mut host, - process_address!(0), - channel_i32_scalar_usize(a2), - a3 as u32, - a4 as u32, - a5, - byte_off, - ) { - Ok(addr) => { - if a3 as u32 != 0 { - let end = addr.saturating_add(channel_i32_scalar_usize(a2)); - ensure_memory_covers(end); - } - addr as i32 - } - Err(e) => -(e as i32), - }; - deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); - result - } - 47 => kernel_munmap(process_address!(0), channel_i32_scalar_usize(a2)), // SYS_MUNMAP - 48 => kernel_brk(process_address!(0)) as i32, // SYS_BRK - 49 => kernel_mprotect(process_address!(0), channel_i32_scalar_usize(a2), a3 as u32), // SYS_MPROTECT - 126 => kernel_mremap( - process_address!(0), - channel_i32_scalar_usize(a2), - channel_i32_scalar_usize(a3), - a4 as u32, - ) as i32, // SYS_MREMAP - 128 => kernel_madvise(process_address!(0), channel_i32_scalar_usize(a2), a3 as u32), // SYS_MADVISE + 47 => kernel_munmap(process_address!(0), process_size!(1)), // SYS_MUNMAP + 49 => kernel_mprotect(process_address!(0), process_size!(1), a3 as u32), // SYS_MPROTECT + 128 => kernel_madvise(process_address!(0), process_size!(1), a3 as u32), // SYS_MADVISE // Environment — musl: name/value are null-terminated strings 42 => kernel_isatty(a1), // SYS_ISATTY @@ -3206,7 +3649,7 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr // SYS_GETENV: (name, buf, buf_len) let p = channel_const_ptr!(0, u8); let len = channel_cstr_len!(p); - kernel_getenv(p, len, channel_mut_ptr!(1, u8), a3 as u32) + kernel_getenv(p, len, channel_mut_ptr!(1, u8), process_size_u32!(2)) } 44 => { // SYS_SETENV: (name, value, overwrite) @@ -3223,12 +3666,12 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr 74 => kernel_umask(a1 as u32) as i32, // SYS_UMASK 75 => kernel_uname(channel_mut_ptr!(0, u8), 390), // SYS_UNAME (musl passes 1 arg; struct utsname = 6x65 = 390) 76 => kernel_sysconf(a1) as i32, // SYS_SYSCONF - 120 => kernel_getrandom(channel_mut_ptr!(0, u8), a2 as u32, a3 as u32), // SYS_GETRANDOM + 120 => kernel_getrandom(channel_mut_ptr!(0, u8), process_size_u32!(1), a3 as u32), // SYS_GETRANDOM 109 => { // SYS_REALPATH: (path, buf, buf_len) let p = channel_const_ptr!(0, u8); let len = channel_cstr_len!(p); - kernel_realpath(p, len, channel_mut_ptr!(1, u8), a3 as u32) + kernel_realpath(p, len, channel_mut_ptr!(1, u8), process_size_u32!(2)) } // Sockets @@ -3240,8 +3683,12 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr channel_mut_ptr!(3, i32), wasm_posix_shared::kernel_scratch_wire::FD_PAIR_BYTES, ), // SYS_SOCKETPAIR - 51 => kernel_bind(a1, channel_const_ptr!(1, u8), a3 as u32), // SYS_BIND - 52 => kernel_listen(a1, a2 as u32), // SYS_LISTEN + 51 => kernel_bind( + a1, + channel_const_ptr!(1, u8), + channel_scalar::u32_argument(51, args, 2), + ), // SYS_BIND + 52 => kernel_listen(a1, a2 as u32), // SYS_LISTEN 53 => kernel_accept4(a1, channel_mut_ptr!(1, u8), channel_mut_ptr!(2, u8), 0), // SYS_ACCEPT 384 => kernel_accept4( a1, @@ -3249,10 +3696,19 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr channel_mut_ptr!(2, u8), a4 as u32, ), // SYS_ACCEPT4 - 54 => kernel_connect(a1, channel_const_ptr!(1, u8), a3 as u32), // SYS_CONNECT - 55 => kernel_send(a1, channel_const_ptr!(1, u8), a3 as u32, a4 as u32), // SYS_SEND - 56 => kernel_recv(a1, channel_mut_ptr!(1, u8), a3 as u32, a4 as u32), // SYS_RECV - 57 => kernel_shutdown(a1, a2 as u32), // SYS_SHUTDOWN + 54 => kernel_connect( + a1, + channel_const_ptr!(1, u8), + channel_scalar::u32_argument(54, args, 2), + ), // SYS_CONNECT + 55 => kernel_send( + a1, + channel_const_ptr!(1, u8), + process_size_u32!(2), + a4 as u32, + ), // SYS_SEND + 56 => kernel_recv(a1, channel_mut_ptr!(1, u8), process_size_u32!(2), a4 as u32), // SYS_RECV + 57 => kernel_shutdown(a1, a2 as u32), // SYS_SHUTDOWN 58 => { let optval_pointer = channel_mut_ptr!(3, u8); let optlen_pointer = channel_mut_ptr!(4, u32); @@ -3282,7 +3738,7 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr a2 as u32, a3 as u32, channel_const_ptr!(3, u8), - a5 as u32, + channel_scalar::u32_argument(59, args, 4), ), // SYS_SETSOCKOPT 114 => kernel_getsockname(a1, channel_mut_ptr!(1, u8), channel_mut_ptr!(2, u32)), // SYS_GETSOCKNAME 115 => kernel_getpeername(a1, channel_mut_ptr!(1, u8), channel_mut_ptr!(2, u32)), // SYS_GETPEERNAME @@ -3292,20 +3748,20 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr let len = channel_cstr_len!(p); kernel_getaddrinfo(p, len, channel_mut_ptr!(1, u8)) } - 137 => kernel_sendmsg(a1, channel_const_ptr!(1, u8), a3 as u32), // SYS_SENDMSG - 138 => kernel_recvmsg(a1, channel_mut_ptr!(1, u8), a3 as u32), // SYS_RECVMSG + 137 => kernel_sendmsg(a1, channel_const_ptr!(1, u8), a3 as u32, 0), // SYS_SENDMSG + 138 => kernel_recvmsg(a1, channel_mut_ptr!(1, u8), a3 as u32, 0), // SYS_RECVMSG 62 => kernel_sendto( a1, channel_const_ptr!(1, u8), - a3 as u32, + process_size_u32!(2), a4 as u32, channel_const_ptr!(4, u8), - a6 as u32, + channel_scalar::u32_argument(62, args, 5), ), // SYS_SENDTO 63 => kernel_recvfrom( a1, channel_mut_ptr!(1, u8), - a3 as u32, + process_size_u32!(2), a4 as u32, channel_mut_ptr!(4, u8), channel_mut_ptr!(5, u32), @@ -3313,16 +3769,17 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr // Poll/select 60 => { - let Some(capacity) = (a2 as u32) - .checked_mul(core::mem::size_of::() as u32) + let count = process_size_u32!(1); + let Some(capacity) = + count.checked_mul(core::mem::size_of::() as u32) else { return -(Errno::EOVERFLOW as i32); }; - kernel_poll(channel_mut_ptr!(0, u8), capacity, a2 as u32, a3) + kernel_poll(channel_mut_ptr!(0, u8), capacity, count, a3) } // SYS_POLL 251 => kernel_ppoll( channel_mut_ptr!(0, u8), - a2 as u32, + process_size_u32!(1), a3, a4 as u32, a5 as u32, @@ -3381,13 +3838,13 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr } // SYS_IOCTL // File system - 79 => kernel_ftruncate(a1, args[1]), // SYS_FTRUNCATE: (fd, length) - 80 => kernel_fsync(a1), // SYS_FSYNC + 79 => kernel_ftruncate(a1, channel_scalar::i64_argument(79, args, 1)), // SYS_FTRUNCATE: (fd, length) + 80 => kernel_fsync(a1), // SYS_FSYNC 85 => { // SYS_TRUNCATE: (path, length) let p = channel_const_ptr!(0, u8); let plen = channel_cstr_len!(p); - kernel_truncate(p, plen, args[1]) + kernel_truncate(p, plen, channel_scalar::i64_argument(85, args, 1)) } 86 => kernel_fdatasync(a1), // SYS_FDATASYNC 87 => kernel_fchmod(a1, a2 as u32), // SYS_FCHMOD @@ -3404,11 +3861,30 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr let output = channel_mut_ptr!(2, u8); kernel_fstatfs(a1, output, args[5]) } - 81 => kernel_writev(a1, channel_const_ptr!(1, u8), a3), // SYS_WRITEV - 82 => kernel_readv(a1, channel_mut_ptr!(1, u8), a3), // SYS_READV - 295 => kernel_preadv(a1, channel_mut_ptr!(1, u8), a3, a4 as u32, a5), // SYS_PREADV - 296 => kernel_pwritev(a1, channel_const_ptr!(1, u8), a3, a4 as u32, a5), // SYS_PWRITEV - 294 => kernel_sendfile(a1, a2, channel_mut_ptr!(2, u8), a4 as u32), // SYS_SENDFILE + 81 => channel_writev(a1, channel_const_ptr!(1, u8), a3, scratch_region), // SYS_WRITEV + 82 => channel_readv(a1, channel_mut_ptr!(1, u8), a3, scratch_region), // SYS_READV + 295 => channel_preadv( + a1, + channel_mut_ptr!(1, u8), + a3, + channel_scalar::split_i64_low_argument(295, args, 3), + channel_scalar::split_i64_high_argument(295, args, 4), + scratch_region, + ), // SYS_PREADV + 296 => channel_pwritev( + a1, + channel_const_ptr!(1, u8), + a3, + channel_scalar::split_i64_low_argument(296, args, 3), + channel_scalar::split_i64_high_argument(296, args, 4), + scratch_region, + ), // SYS_PWRITEV + 294 => kernel_sendfile_with_count( + a1, + a2, + channel_mut_ptr!(2, u8), + reportable_channel_transfer_count(process_size!(3)), + ), // SYS_SENDFILE // *at variants — musl: (dirfd, path, ...) without explicit path_len 69 => { @@ -3484,7 +3960,7 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr // SYS_READLINKAT: (dirfd, path, buf, bufsiz) let p = channel_const_ptr!(1, u8); let len = channel_cstr_len!(p); - kernel_readlinkat(a1, p, len, channel_mut_ptr!(2, u8), a4 as u32) + kernel_readlinkat(a1, p, len, channel_mut_ptr!(2, u8), process_size_u32!(3)) } // Resource limits @@ -3597,7 +4073,7 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr }; kernel_getgroups(a1 as u32, list_pointer, a3 as u32) } // SYS_GETGROUPS - 136 => kernel_setgroups(a1 as u32, channel_const_ptr!(1, u32)), // SYS_SETGROUPS + 136 => kernel_setgroups(process_size_u32!(0), channel_const_ptr!(1, u32)), // SYS_SETGROUPS // Wait 139 => kernel_wait4( @@ -3626,12 +4102,12 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr 212 | 213 => -(Errno::ENOSYS as i32), // SYS_FORK / SYS_VFORK 201 => kernel_clone( 0, - process_address!(1), + conditional_process_address!(1), a1 as u32, 0, - process_address!(2), - process_address!(3), - process_address!(4), + conditional_process_address!(2), + conditional_process_address!(3), + conditional_process_address!(4), ), // SYS_CLONE // Futex @@ -3640,14 +4116,14 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr a2 as u32, a3 as u32, a4 as u32, - process_address!(4), + conditional_process_address!(4), a6 as u32, ), // SYS_FUTEX // Thread 202 => kernel_gettid(), // SYS_GETTID 203 => kernel_set_tid_address(process_address!(0)), // SYS_SET_TID_ADDRESS - 261 => kernel_set_robust_list(process_address!(0), channel_u32_scalar_usize(a2)), // SYS_SET_ROBUST_LIST + 261 => kernel_set_robust_list(process_address!(0), process_size!(1)), // SYS_SET_ROBUST_LIST 262 => kernel_get_robust_list(a1 as u32, process_address!(1), process_address!(2)), // SYS_GET_ROBUST_LIST // prctl @@ -3710,6 +4186,17 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr // SYS_MSGRCV: (qid, msgp, msgsz, msgtyp, flags) let ipc = unsafe { crate::ipc::global_ipc_table() }; let (pid, uid, gid) = current_pid_eids(); + let task = unsafe { &*PROCESS_TABLE.0.get() }; + let tid = task.current_tid(); + let active_pin = match task.get(pid).ok_or(Errno::ESRCH).and_then(|proc| { + proc.blocked_retries.active_sysv_message( + tid, + crate::blocked_retry::BlockingRetryOperation::MsgReceive, + ) + }) { + Ok(pin) => pin, + Err(error) => return -(error as i32), + }; let pointer_width = match args[5] { 4 => 4, 8 => 8, @@ -3719,25 +4206,40 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr if msgp.is_null() { return -(Errno::EFAULT as i32); } - let msgsz = match u32::try_from(args[2]) { - Ok(size) => size, - Err(_) => return -(Errno::EINVAL as i32), - }; + let msgsz = process_size_u32!(2); let max_output_mtype = if pointer_width == 4 { i32::MAX as i64 } else { i64::MAX }; - match ipc.msgrcv_with_mtype_max( - a1, - msgsz, - args[3], - max_output_mtype, - args[4] as u32, - pid, - uid, - gid, - ) { + // WHY: the channel scalar contract requires one exact decode per + // consumed argument. Retry selection must not duplicate the + // signed-i64 interpretation of msgtyp. + let msgtyp = channel_scalar::i64_argument(338, args, 3); + let receive = if let Some(pin) = active_pin { + ipc.msgrcv_pinned_with_mtype_max( + pin, + msgsz, + msgtyp, + max_output_mtype, + args[4] as u32, + pid, + uid, + gid, + ) + } else { + ipc.msgrcv_with_mtype_max( + a1, + msgsz, + msgtyp, + max_output_mtype, + args[4] as u32, + pid, + uid, + gid, + ) + }; + match receive { Ok(result) => { let wire_size = match crate::ipc_wire::sysv_message_wire_size(result.data.len()) { @@ -3766,10 +4268,7 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr if msgp.is_null() { return -(Errno::EFAULT as i32); } - let msgsz = match checked_channel_usize_scalar(args[2]) { - Ok(size) => size, - Err(error) => return -(error as i32), - }; + let msgsz = process_size!(2); let wire_size = match crate::ipc_wire::sysv_message_wire_size(msgsz) { Ok(size) => size, Err(error) => return -(error as i32), @@ -3780,7 +4279,21 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr Err(error) => return -(error as i32), }; let data = &message[crate::ipc_wire::SYSV_MESSAGE_HEADER_SIZE..]; - match ipc.msgsnd(a1, mtype, data, args[3] as u32, pid, uid, gid) { + let task = unsafe { &*PROCESS_TABLE.0.get() }; + let tid = task.current_tid(); + let active_pin = match task.get(pid).ok_or(Errno::ESRCH).and_then(|proc| { + proc.blocked_retries + .active_sysv_message(tid, crate::blocked_retry::BlockingRetryOperation::MsgSend) + }) { + Ok(pin) => pin, + Err(error) => return -(error as i32), + }; + let send = if let Some(pin) = active_pin { + ipc.msgsnd_pinned(pin, mtype, data, args[3] as u32, pid, uid, gid) + } else { + ipc.msgsnd(a1, mtype, data, args[3] as u32, pid, uid, gid) + }; + match send { Ok(()) => 0, Err(e) => -(e as i32), } @@ -3790,8 +4303,8 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr let ipc = unsafe { crate::ipc::global_ipc_table() }; let (pid, uid, gid) = current_pid_eids(); let cmd = a2 & !0x100; // strip IPC_64 - // The host-only sixth slot names the caller data model; it may - // differ from the kernel Wasm's own pointer width. + // The host-only sixth slot names the caller data model; it may + // differ from the kernel Wasm's own pointer width. let wire_transfer = if cmd == 1 || cmd == 2 { let pointer_width = match args[5] { 4 => 4, @@ -3866,17 +4379,36 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr // SYS_SEMOP: (semid, sops_ptr, nsops) let ipc = unsafe { crate::ipc::global_ipc_table() }; let (pid, uid, gid) = current_pid_eids(); - let nsops = channel_i32_scalar_usize(a3); - let sops_ptr = channel_const_ptr!(1, u8); + let nsops = process_size!(2); + let bytes_len = match nsops.checked_mul(6) { + Some(length) => length, + None => return -(Errno::EINVAL as i32), + }; + let bytes = channel_const_slice!(1, bytes_len); let mut sops = alloc::vec::Vec::with_capacity(nsops); for i in 0..nsops { - let base = unsafe { sops_ptr.add(i * 6) }; - let num = unsafe { u16::from_le_bytes([*base, *base.add(1)]) }; - let op = unsafe { i16::from_le_bytes([*base.add(2), *base.add(3)]) }; - let flg = unsafe { u16::from_le_bytes([*base.add(4), *base.add(5)]) }; + let base = i * 6; + let num = u16::from_le_bytes([bytes[base], bytes[base + 1]]); + let op = i16::from_le_bytes([bytes[base + 2], bytes[base + 3]]); + let flg = u16::from_le_bytes([bytes[base + 4], bytes[base + 5]]); sops.push(crate::ipc::SemOp { num, op, flg }); } - match ipc.semop(a1, &sops, pid, uid, gid) { + let task = unsafe { &*PROCESS_TABLE.0.get() }; + let tid = task.current_tid(); + let active_pin = match task + .get(pid) + .ok_or(Errno::ESRCH) + .and_then(|proc| proc.blocked_retries.active_sysv_semaphore(tid)) + { + Ok(pin) => pin, + Err(error) => return -(error as i32), + }; + let operation = if let Some(pin) = active_pin { + ipc.semop_pinned(pin, &sops, pid, uid, gid) + } else { + ipc.semop(a1, &sops, pid, uid, gid) + }; + match operation { Ok(()) => 0, Err(e) => -(e as i32), } @@ -3886,9 +4418,9 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr let ipc = unsafe { crate::ipc::global_ipc_table() }; let (pid, uid, gid) = current_pid_eids(); let cmd = a3 & !0x100; // strip IPC_64 - // WHY: the host-only sixth channel slot carries the caller's - // pointer width. The kernel Wasm width is not authoritative - // because one kernel may serve both wasm32 and wasm64 processes. + // WHY: the host-only sixth channel slot carries the caller's + // pointer width. The kernel Wasm width is not authoritative + // because one kernel may serve both wasm32 and wasm64 processes. let stat_transfer = if cmd == 2 { let pointer_width = match args[5] { 4 => 4, @@ -3911,14 +4443,11 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr Ok(bytes) => bytes, Err(error) => return -(error as i32), }; - let values_pointer = match checked_channel_pointer(args[3]) { - Ok(pointer) if pointer == scratch_region.start() => pointer as *const u8, - Ok(_) => return -(Errno::EFAULT as i32), - Err(error) => return -(error as i32), - }; - if let Err(error) = scratch_region.checked_range(values_pointer as usize, bytes) { - return -(error as i32); - } + let values_pointer = + match checked_channel_scratch_start_range(args[3], bytes, scratch_region) { + Ok(pointer) => pointer as *const u8, + Err(error) => return -(error as i32), + }; // SAFETY: the ABI-43 host obtained the same permission-checked // byte count before copying into its channel-scratch lease. let values = unsafe { core::slice::from_raw_parts(values_pointer, bytes) }; @@ -3937,16 +4466,14 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr let Some((size, pointer_width)) = stat_transfer else { return -(Errno::EINVAL as i32); }; - let output_pointer = match checked_channel_pointer(args[3]) { - Ok(pointer) if pointer == scratch_region.start() => pointer as *mut u8, - Ok(_) => return -(Errno::EFAULT as i32), + let output_pointer = match checked_channel_scratch_start_range( + args[3], + size, + scratch_region, + ) { + Ok(pointer) => pointer as *mut u8, Err(error) => return -(error as i32), }; - if let Err(error) = - scratch_region.checked_range(output_pointer as usize, size) - { - return -(error as i32); - } // SAFETY: the ABI-43 host stages this exact-sized // output in its checked channel-scratch lease. let out = unsafe { core::slice::from_raw_parts_mut(output_pointer, size) }; @@ -3966,16 +4493,14 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr Some(byte_len) => byte_len, None => return -(Errno::EOVERFLOW as i32), }; - let output_pointer = match checked_channel_pointer(args[3]) { - Ok(pointer) if pointer == scratch_region.start() => pointer as *mut u8, - Ok(_) => return -(Errno::EFAULT as i32), + let output_pointer = match checked_channel_scratch_start_range( + args[3], + byte_len, + scratch_region, + ) { + Ok(pointer) => pointer as *mut u8, Err(error) => return -(error as i32), }; - if let Err(error) = - scratch_region.checked_range(output_pointer as usize, byte_len) - { - return -(error as i32); - } // SAFETY: the ABI-43 host obtained this exact byte // count before reserving the channel-scratch lease. let out = @@ -3993,7 +4518,11 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr // SYS_SHMGET: (key, size, flags) let ipc = unsafe { crate::ipc::global_ipc_table() }; let (pid, uid, gid) = current_pid_eids(); - match ipc.shmget(a1, a2 as u32, a3 as u32, pid, uid, gid) { + let size = match u32::try_from(process_size!(1)) { + Ok(size) => size, + Err(_) => return -(Errno::EINVAL as i32), + }; + match ipc.shmget(a1, size, a3 as u32, pid, uid, gid) { Ok(id) => id, Err(e) => -(e as i32), } @@ -4003,11 +4532,11 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr // The current kernel implementation ignores the requested attach // address, but still validate the complete raw pointer so a future // implementation cannot inherit the old i32 truncation. - let _shmaddr = process_address!(1); + let _shmaddr = conditional_process_address!(1); kernel_ipc_shmat(a1, a2, a3) } 346 => { - let _shmaddr = process_address!(0); + let _shmaddr = conditional_process_address!(0); kernel_ipc_shmdt(a1) } 347 => { @@ -4015,8 +4544,8 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr let ipc = unsafe { crate::ipc::global_ipc_table() }; let (pid, uid, gid) = current_pid_eids(); let cmd = a2 & !0x100; // strip IPC_64 - // The host-only sixth slot names the caller data model; it may - // differ from the kernel Wasm's own pointer width. + // The host-only sixth slot names the caller data model; it may + // differ from the kernel Wasm's own pointer width. let wire_transfer = if cmd == 1 || cmd == 2 { let pointer_width = match args[5] { 4 => 4, @@ -4082,6 +4611,12 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr 241 => { let events_ptr = channel_mut_ptr!(1, u8); let sigmask_ptr = channel_const_ptr!(4, u8); + if !sigmask_ptr.is_null() + && process_size!(5) + != wasm_posix_shared::kernel_scratch_wire::SIGNAL_MASK_BYTES as usize + { + return -(Errno::EINVAL as i32); + } kernel_epoll_pwait(a1, events_ptr, a3, a4, sigmask_ptr) } 379 => { @@ -4104,8 +4639,8 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr 245 => kernel_timerfd_gettime(a1, channel_mut_ptr!(1, u8)), // SYS_TIMERFD_GETTIME // signalfd - 246 => kernel_signalfd4(a1, channel_const_ptr!(1, u8), a3 as u32, a4 as u32), // SYS_SIGNALFD4: (fd, mask_ptr, sigsetsize, flags) - 377 => kernel_signalfd4(a1, channel_const_ptr!(1, u8), a3 as u32, 0), // SYS_SIGNALFD: (fd, mask_ptr, sigsetsize) + 246 => kernel_signalfd4(a1, channel_const_ptr!(1, u8), process_size!(2), a4 as u32), // SYS_SIGNALFD4: (fd, mask_ptr, sigsetsize, flags) + 377 => kernel_signalfd4(a1, channel_const_ptr!(1, u8), process_size!(2), 0), // SYS_SIGNALFD: (fd, mask_ptr, sigsetsize) // tkill — directed (per-thread) signal delivery. (wasm32 musl // uses __NR_tkill for pthread_kill too; __NR_tgkill isn't wired up.) @@ -4123,8 +4658,7 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr if info_pointer.is_null() { return -(Errno::EFAULT as i32); } - let info_bytes = - unsafe { slice::from_raw_parts(info_pointer, model.siginfo_size()) }; + let info_bytes = unsafe { slice::from_raw_parts(info_pointer, model.siginfo_size()) }; let info = match crate::process_wire::read_rt_sigqueueinfo(info_bytes, model) { Ok(info) => info, Err(error) => return -(error as i32), @@ -4222,7 +4756,7 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr 279 | 280 => { // mlock, mlock2: (addr, len, ...) let addr = process_address!(0); - let len = channel_u32_scalar_usize(a2); + let len = process_size!(1); if addr .checked_add(len) .map_or(true, |end| end > 1_073_741_824) @@ -4235,7 +4769,7 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr 281 => { // munlock: (addr, len) let addr = process_address!(0); - let len = channel_u32_scalar_usize(a2); + let len = process_size!(1); if addr .checked_add(len) .map_or(true, |end| end > 1_073_741_824) @@ -4373,25 +4907,35 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr off_in, a3, off_out, - channel_i32_scalar_usize(a5), + reportable_channel_transfer_count(process_size!(4)), ) { Ok(n) => { - // Update offset pointers if provided - if !off_in_ptr.is_null() { - if let Some(orig) = off_in { - let new_off = orig + n as i64; - let buf = unsafe { slice::from_raw_parts_mut(off_in_ptr, 8) }; - buf.copy_from_slice(&new_off.to_le_bytes()); - } - } - if !off_out_ptr.is_null() { - if let Some(orig) = off_out { - let new_off = orig + n as i64; - let buf = unsafe { slice::from_raw_parts_mut(off_out_ptr, 8) }; - buf.copy_from_slice(&new_off.to_le_bytes()); + let advanced_offsets = off_in + .map(|offset| syscalls::checked_offset_advance(offset, n)) + .transpose() + .and_then(|advanced_in| { + off_out + .map(|offset| syscalls::checked_offset_advance(offset, n)) + .transpose() + .map(|advanced_out| (advanced_in, advanced_out)) + }); + match advanced_offsets { + Ok((advanced_in, advanced_out)) => { + // Compute both values before publishing either so + // an overflow cannot leave the caller's pair only + // partially updated. + if let Some(new_off) = advanced_in { + let buf = unsafe { slice::from_raw_parts_mut(off_in_ptr, 8) }; + buf.copy_from_slice(&new_off.to_le_bytes()); + } + if let Some(new_off) = advanced_out { + let buf = unsafe { slice::from_raw_parts_mut(off_out_ptr, 8) }; + buf.copy_from_slice(&new_off.to_le_bytes()); + } + n as i32 } + Err(error) => -(error as i32), } - n as i32 } Err(e) => -(e as i32), }; @@ -4424,42 +4968,80 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr off_in, a3, off_out, - channel_i32_scalar_usize(a5), + reportable_channel_transfer_count(process_size!(4)), a6 as u32, ) { Ok(n) => { - if !off_in_ptr.is_null() { - if let Some(orig) = off_in { - let buf = unsafe { slice::from_raw_parts_mut(off_in_ptr, 8) }; - buf.copy_from_slice(&(orig + n as i64).to_le_bytes()); - } - } - if !off_out_ptr.is_null() { - if let Some(orig) = off_out { - let buf = unsafe { slice::from_raw_parts_mut(off_out_ptr, 8) }; - buf.copy_from_slice(&(orig + n as i64).to_le_bytes()); + let advanced_offsets = off_in + .map(|offset| syscalls::checked_offset_advance(offset, n)) + .transpose() + .and_then(|advanced_in| { + off_out + .map(|offset| syscalls::checked_offset_advance(offset, n)) + .transpose() + .map(|advanced_out| (advanced_in, advanced_out)) + }); + match advanced_offsets { + Ok((advanced_in, advanced_out)) => { + if let Some(new_off) = advanced_in { + let buf = unsafe { slice::from_raw_parts_mut(off_in_ptr, 8) }; + buf.copy_from_slice(&new_off.to_le_bytes()); + } + if let Some(new_off) = advanced_out { + let buf = unsafe { slice::from_raw_parts_mut(off_out_ptr, 8) }; + buf.copy_from_slice(&new_off.to_le_bytes()); + } + n as i32 } + Err(error) => -(error as i32), } - n as i32 } Err(e) => -(e as i32), }; deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); result } - 293 => 0, // SYS_READAHEAD: advisory, always succeed - 297 => kernel_preadv(a1, channel_mut_ptr!(1, u8), a3, a4 as u32, a5), // SYS_PREADV2 (ignore flags in a6) - 298 => kernel_pwritev(a1, channel_const_ptr!(1, u8), a3, a4 as u32, a5), // SYS_PWRITEV2 (ignore flags in a6) + 293 => { + let _count = process_size!(2); + 0 + } // SYS_READAHEAD: advisory, always succeed + 297 => channel_preadv( + a1, + channel_mut_ptr!(1, u8), + a3, + channel_scalar::split_i64_low_argument(297, args, 3), + channel_scalar::split_i64_high_argument(297, args, 4), + scratch_region, + ), // SYS_PREADV2 (ignore flags in a6) + 298 => channel_pwritev( + a1, + channel_const_ptr!(1, u8), + a3, + channel_scalar::split_i64_low_argument(298, args, 3), + channel_scalar::split_i64_high_argument(298, args, 4), + scratch_region, + ), // SYS_PWRITEV2 (ignore flags in a6) // -- Scheduling stubs (single-CPU Wasm) -- - 237 => 0, // SYS_SCHED_SETAFFINITY: no-op (single CPU) - 238 => kernel_sched_getaffinity(a1, args[1] as u32, channel_mut_ptr!(2, u8)), + 237 => { + let _cpusetsize = channel_scalar::u32_argument(237, args, 1); + 0 + } // SYS_SCHED_SETAFFINITY: no-op (single CPU) + 238 => kernel_sched_getaffinity( + a1, + channel_scalar::u32_argument(238, args, 1), + channel_mut_ptr!(2, u8), + ), // -- Memory/sync stubs -- 257 => 0, // SYS_MEMBARRIER: no-op (single-threaded per process in Wasm) 273 => 0, // SYS_SYNC: no-op (all I/O is synchronous to host) 274 => 0, // SYS_SYNCFS: no-op - 278 => 0, // SYS_MSYNC: no-op (MAP_PRIVATE changes are private, file-backed writes go through write()) + 278 => { + let _address = process_address!(0); + let _length = process_size!(1); + 0 + } // SYS_MSYNC: no-op (MAP_PRIVATE changes are private, file-backed writes go through write()) // -- Process stubs -- 287 => 0, // SYS_PERSONALITY: return 0 (current personality, PER_LINUX) @@ -4488,8 +5070,8 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr } else { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; - let offset = args[2]; - let len = args[3]; + let offset = channel_scalar::i64_argument(308, args, 2); + let len = channel_scalar::i64_argument(308, args, 3); let result = match syscalls::sys_fallocate(proc, &mut host, a1, offset, len) { Ok(()) => 0, Err(e) => -(e as i32), @@ -4619,9 +5201,8 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr let (maxmsg, msgsize) = if has_attr { // SAFETY: the generated process-layout descriptor copied this // exact caller-native structure into capacity-checked scratch. - let input = unsafe { - core::slice::from_raw_parts(attr_pointer, model.mq_attr_size()) - }; + let input = + unsafe { core::slice::from_raw_parts(attr_pointer, model.mq_attr_size()) }; let attr = match crate::process_wire::read_mq_attr(input, model) { Ok(attr) => attr, Err(error) => return -(error as i32), @@ -4651,19 +5232,34 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr } 333 => { // SYS_MQ_TIMEDSEND: (mqd, msg_ptr, msg_len, priority, timeout_ptr) - let data_len = channel_i32_scalar_usize(a3); - // WHY: zero-length POSIX messages are valid and lend no bytes. - // Do not make their ignored pointer satisfy Rust's stronger - // non-null slice requirement. - let data = if data_len == 0 { - &[] - } else { - // SAFETY: the host copied exactly data_len caller bytes into - // capacity-checked kernel scratch before dispatch. - unsafe { core::slice::from_raw_parts(channel_const_ptr!(1, u8), data_len) } - }; + let data_len = process_size!(2); + if data_len > channel_scalar::MAX_REPORTABLE_TRANSFER_BYTES as usize { + return -(Errno::EMSGSIZE as i32); + } + let data = channel_const_slice!(1, data_len); let table = unsafe { crate::mqueue::global_mqueue_table() }; - match table.mq_send(a1 as u32, data, a4 as u32) { + let process_table = unsafe { &*PROCESS_TABLE.0.get() }; + let pid = process_table.current_pid(); + let tid = process_table.current_tid(); + let (send, nonblock) = + match process_table.get(pid).ok_or(Errno::ESRCH).and_then(|proc| { + proc.blocked_retries + .active_mqueue(tid, crate::blocked_retry::BlockingRetryOperation::MqSend) + }) { + Ok(Some(pin)) => { + let nonblock = match table.pinned_is_nonblock(pin) { + Ok(nonblock) => nonblock, + Err(error) => return -(error as i32), + }; + (table.mq_send_pinned(pin, data, a4 as u32), nonblock) + } + Ok(None) => ( + table.mq_send(a1 as u32, data, a4 as u32), + table.is_nonblock(a1 as u32).unwrap_or(false), + ), + Err(error) => return -(error as i32), + }; + match send { Ok(result) => { if let Some(notif) = result.notification { let process_table = unsafe { &mut *PROCESS_TABLE.0.get() }; @@ -4686,18 +5282,38 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr } 0 } - Err(Errno::EAGAIN) => mq_would_block_result( - channel_const_ptr!(4, u8) as usize, - table, - a1 as u32, - ), + Err(Errno::EAGAIN) => { + mq_timed_blocking_errno(channel_const_ptr!(4, u8) as usize, nonblock) + } Err(e) => -(e as i32), } } 334 => { // SYS_MQ_TIMEDRECEIVE: (mqd, msg_ptr, msg_len, prio_ptr, timeout_ptr) + let capacity = process_size_u32!(2); let table = unsafe { crate::mqueue::global_mqueue_table() }; - match table.mq_receive(a1 as u32, a3 as u32) { + let process_table = unsafe { &*PROCESS_TABLE.0.get() }; + let pid = process_table.current_pid(); + let tid = process_table.current_tid(); + let (receive, nonblock) = + match process_table.get(pid).ok_or(Errno::ESRCH).and_then(|proc| { + proc.blocked_retries + .active_mqueue(tid, crate::blocked_retry::BlockingRetryOperation::MqReceive) + }) { + Ok(Some(pin)) => { + let nonblock = match table.pinned_is_nonblock(pin) { + Ok(nonblock) => nonblock, + Err(error) => return -(error as i32), + }; + (table.mq_receive_pinned(pin, capacity), nonblock) + } + Ok(None) => ( + table.mq_receive(a1 as u32, capacity), + table.is_nonblock(a1 as u32).unwrap_or(false), + ), + Err(error) => return -(error as i32), + }; + match receive { Ok(result) => { // WHY: a queued zero-length message has no destination // bytes, so do not make an ignored pointer satisfy Rust's @@ -4725,13 +5341,14 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr *prio_ptr.add(3) = prio_bytes[3]; } } - result.data.len() as i32 + match i32::try_from(result.data.len()) { + Ok(length) => length, + Err(_) => return -(Errno::EOVERFLOW as i32), + } + } + Err(Errno::EAGAIN) => { + mq_timed_blocking_errno(channel_const_ptr!(4, u8) as usize, nonblock) } - Err(Errno::EAGAIN) => mq_would_block_result( - channel_const_ptr!(4, u8) as usize, - table, - a1 as u32, - ), Err(e) => -(e as i32), } } @@ -4753,9 +5370,8 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr }; // SAFETY: the host stages the complete native sigevent under // the generated pointer-width-dependent descriptor. - let input = unsafe { - core::slice::from_raw_parts(event_pointer, model.sigevent_size()) - }; + let input = + unsafe { core::slice::from_raw_parts(event_pointer, model.sigevent_size()) }; let event = match crate::process_wire::read_sigevent(input, model) { Ok(event) => event, Err(error) => return -(error as i32), @@ -4784,9 +5400,8 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr let new_flags = if !new_pointer.is_null() { // SAFETY: generated host metadata copied the exact native // mq_attr size into this checked scratch allocation. - let input = unsafe { - core::slice::from_raw_parts(new_pointer, model.mq_attr_size()) - }; + let input = + unsafe { core::slice::from_raw_parts(new_pointer, model.mq_attr_size()) }; let attr = match crate::process_wire::read_mq_attr(input, model) { Ok(attr) => attr, Err(error) => return -(error as i32), @@ -4943,14 +5558,19 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr t.cond_wait_abort(a1 as u32, pid); 0 } - 415 => { + syscall_numbers::SYS_THREAD_CANCEL => { // SYS_THREAD_CANCEL: (target_tid). Host-owned wait state is woken - // in kernel-worker.ts; release kernel-owned FIFO open reservations - // here so the interrupted target cannot leave a phantom endpoint. + // in kernel-worker.ts; release FIFO reservations and restore a + // ppoll/pselect temporary mask before the interrupted target can + // leave kernel-owned state behind. let (_gkl, proc) = unsafe { get_process() }; - let owner = ((proc.pid as u64) << 32) | a1 as u32 as u64; - syscalls::cancel_fifo_open_for_owner(proc, owner); - 0 + let Ok(target_tid) = u32::try_from(a1) else { + return -(Errno::ESRCH as i32); + }; + match syscalls::cancel_host_owned_wait_for_live_tid(proc, target_tid) { + Ok(()) => 0, + Err(error) => -(error as i32), + } } 253..=254 @@ -4976,6 +5596,40 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr mod channel_pointer_tests { use super::*; + #[test] + fn host_transfer_counts_are_lossless_and_capacity_bounded() { + assert_eq!(checked_host_buffer_len(u32::MAX as usize), Ok(u32::MAX)); + if let Some(too_large) = (u32::MAX as usize).checked_add(1) { + assert_eq!(checked_host_buffer_len(too_large), Err(Errno::EOVERFLOW),); + } + assert_eq!(checked_host_transfer_result(4, 4), Ok(4)); + assert_eq!(checked_host_transfer_result(5, 4), Err(Errno::EIO)); + assert_eq!( + checked_host_transfer_result(-(Errno::EAGAIN as i32), 4), + Err(Errno::EAGAIN), + ); + assert_eq!(checked_host_transfer_result(i32::MIN, 4), Err(Errno::EIO),); + assert_eq!(checked_host_i64_result(17), Ok(17)); + assert_eq!( + checked_host_i64_result(-(Errno::EAGAIN as i64)), + Err(Errno::EAGAIN), + ); + assert_eq!(checked_host_i64_result(i64::MIN), Err(Errno::EIO)); + assert_eq!( + checked_host_i64_result(-((u32::MAX as i64) + 1)), + Err(Errno::EIO), + ); + } + + #[test] + fn split_i64_words_preserves_offsets_beyond_javascript_number_precision() { + for offset in [0, (1i64 << 53) + 0x1234_5678, i64::MAX, i64::MIN, -1] { + let (lo, hi) = split_i64_words(offset); + let reconstructed = ((hi as i64) << 32) | i64::from(lo); + assert_eq!(reconstructed, offset); + } + } + #[test] fn raw_pointer_conversion_models_both_wasm_widths_without_signed_narrowing() { assert_eq!(checked_channel_pointer_bits(0, 32), Ok(0)); @@ -4992,6 +5646,44 @@ mod channel_pointer_tests { assert_eq!(checked_channel_pointer_bits(0, 16), Err(Errno::EFAULT)); } + #[test] + fn process_size_conversion_is_unsigned_and_exact_or_rejected() { + let four_gib_plus_page = 0x1_0000_1000u64; + assert_eq!(checked_process_size_bits(0, 32), Ok(0)); + assert_eq!( + checked_process_size_bits(u32::MAX as u64, 32), + Ok(u32::MAX as u64) + ); + assert_eq!( + checked_process_size_bits(four_gib_plus_page, 32), + Err(Errno::EINVAL) + ); + assert_eq!( + checked_process_size_bits(four_gib_plus_page, 64), + Ok(four_gib_plus_page) + ); + assert_eq!(checked_process_size_bits(1u64 << 63, 64), Ok(1u64 << 63)); + assert_eq!(checked_process_size_bits(u64::MAX, 64), Ok(u64::MAX)); + assert_eq!(checked_process_size_bits(0, 16), Err(Errno::EINVAL)); + } + + #[test] + fn typed_process_size_reader_preserves_the_physical_channel_bits() { + let mut mmap_args = [0i64; 6]; + mmap_args[1] = i64::MIN; + assert_eq!( + channel_scalar::process_size_argument(46, &mmap_args, 1), + 1u64 << 63 + ); + + let mut sendfile_args = [0i64; 6]; + sendfile_args[3] = -1; + assert_eq!( + channel_scalar::process_size_argument(294, &sendfile_args, 3), + u64::MAX + ); + } + #[test] fn target_pointer_conversion_is_lossless_or_rejected() { assert_eq!(checked_channel_pointer(0), Ok(0)); @@ -5016,6 +5708,31 @@ mod channel_pointer_tests { } } + #[test] + fn zero_kernel_iovec_count_does_not_require_a_table_pointer() { + let region = ChannelScratchRegion::new(0x1000, 16).unwrap(); + assert_eq!( + checked_kernel_iovec_entries(core::ptr::null(), 0, region), + Ok((Vec::new(), 0)), + ); + assert_eq!( + checked_kernel_iovec_entries(core::ptr::null(), -1, region), + Err(Errno::EINVAL), + ); + assert_eq!( + checked_kernel_iovec_entries( + core::ptr::null(), + i32::try_from(platform_limits::IOV_MAX + 1).unwrap(), + region, + ), + Err(Errno::EINVAL), + ); + assert_eq!( + checked_kernel_iovec_entries(core::ptr::null(), 1, region), + Err(Errno::EFAULT), + ); + } + #[test] fn channel_dispatch_propagates_cstr_scan_errors_before_syscall_use() { let unterminated = b"unterminated"; @@ -5064,6 +5781,49 @@ pub extern "C" fn kernel_set_current_tid(pid: u32, tid: u32) -> i32 { } } +/// Return the retry authority created by the task's first blocking result. +/// +/// The syscall number is normalized to its scalar execution family, so a +/// host-flattened readv/writev request finds the read/write binding Rust +/// created before returning control to JavaScript. A positive value is an +/// opaque stable-target token. Zero means this syscall has no Rust target +/// class and uses only a host-owned immutable request snapshot. A mapped +/// operation with no binding fails instead of silently becoming host-only. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_blocking_retry_token(pid: u32, tid: u32, syscall_nr: u32) -> i64 { + let _gkl = GklGuard::acquire(); + let table = unsafe { &*PROCESS_TABLE.0.get() }; + if let Err(error) = table.validate_task(pid, tid) { + return -(error as i64); + } + match table + .get(pid) + .ok_or(Errno::ESRCH) + .and_then(|proc| proc.blocked_retries.token_for_syscall(tid, syscall_nr)) + { + Ok(token) => token, + Err(error) => -(error as i64), + } +} + +/// Consume one exact blocked-retry target and its kernel-owned references. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_blocking_retry_release(pid: u32, tid: u32, token: i64) -> i32 { + if token <= 0 { + return -(Errno::EINVAL as i32); + } + let _gkl = GklGuard::acquire(); + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let Some((proc, locks)) = table.process_and_advisory_locks(pid) else { + return -(Errno::ESRCH as i32); + }; + let mut host = WasmHostIO; + match syscalls::release_blocking_retry_binding(proc, locks, &mut host, tid, token) { + Ok(()) => 0, + Err(error) => -(error as i32), + } +} + /// Validate a host channel's exact task identity without installing a /// one-shot dispatch binding. #[unsafe(no_mangle)] @@ -5262,6 +6022,27 @@ pub extern "C" fn kernel_semctl_array_bytes(pid: u32, tid: u32, semid: i32, cmd: // POSIX mqueue kernel exports // --------------------------------------------------------------------------- +/// Return the configured maximum message size for one queue descriptor. +/// +/// PID and TID are explicit because this is a host sizing preflight, not a +/// channel dispatch: it must validate the caller without installing or +/// consuming the one-shot ambient task binding used by `kernel_handle_channel`. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_mq_descriptor_msgsize(pid: u32, tid: u32, mqd: i32) -> i32 { + let process_table = unsafe { &*PROCESS_TABLE.0.get() }; + if let Err(error) = process_table.validate_task(pid, tid) { + return -(error as i32); + } + if mqd < 0 { + return -(Errno::EBADF as i32); + } + let table = unsafe { crate::mqueue::global_mqueue_table() }; + match table.descriptor_msgsize(mqd as u32) { + Ok(size) => i32::try_from(size).unwrap_or(-(Errno::EOVERFLOW as i32)), + Err(error) => -(error as i32), + } +} + fn queue_mqueue_signal_notification( process_table: &mut crate::process_table::ProcessTable, notification: crate::mqueue::MqNotification, @@ -5292,10 +6073,7 @@ fn queue_mqueue_signal_notification( /// wake blocked work; it must not synthesize a second signal. /// Returns 1 if a notification was pending, 0 otherwise. #[unsafe(no_mangle)] -pub extern "C" fn kernel_mq_drain_notification( - out_ptr: *mut u8, - out_capacity: u32, -) -> i32 { +pub extern "C" fn kernel_mq_drain_notification(out_ptr: *mut u8, out_capacity: u32) -> i32 { if out_ptr.is_null() { return -(Errno::EFAULT as i32); } @@ -5325,7 +6103,11 @@ pub extern "C" fn kernel_mq_drain_notification( #[unsafe(no_mangle)] pub extern "C" fn kernel_mq_is_mqd(fd: i32) -> i32 { let table = unsafe { crate::mqueue::global_mqueue_table() }; - if table.is_mqd(fd as u32) { 1 } else { 0 } + if table.is_mqd(fd as u32) { + 1 + } else { + 0 + } } /// Serialize current process state for fork. Returns bytes written, or negative errno. @@ -5468,11 +6250,12 @@ pub extern "C" fn kernel_close(fd: i32) -> i32 { result } -/// Read from a file descriptor. Returns bytes read (>= 0) or negative errno. -#[unsafe(no_mangle)] -pub extern "C" fn kernel_read(fd: i32, buf_ptr: *mut u8, buf_len: u32) -> i32 { +/// Read into one already-proven live channel slice. +/// +/// WHY: this adapter is private so no host can supply a bare kernel pointer +/// without the channel dispatcher first proving the allocation and extent. +fn channel_read(fd: i32, buf: &mut [u8]) -> i32 { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; - let buf = unsafe { slice::from_raw_parts_mut(buf_ptr, buf_len as usize) }; let mut host = WasmHostIO; let result = match syscalls::sys_read(proc, &mut host, fd, buf) { Ok(n) => n as i32, @@ -5483,11 +6266,9 @@ pub extern "C" fn kernel_read(fd: i32, buf_ptr: *mut u8, buf_len: u32) -> i32 { result } -/// Write to a file descriptor. Returns bytes written (>= 0) or negative errno. -#[unsafe(no_mangle)] -pub extern "C" fn kernel_write(fd: i32, buf_ptr: *const u8, buf_len: u32) -> i32 { +/// Write from one already-proven live channel slice. +fn channel_write(fd: i32, buf: &[u8]) -> i32 { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; - let buf = unsafe { slice::from_raw_parts(buf_ptr, buf_len as usize) }; let mut host = WasmHostIO; let result = match syscalls::sys_write(proc, &mut host, fd, buf) { Ok(n) => n as i32, @@ -5497,44 +6278,6 @@ pub extern "C" fn kernel_write(fd: i32, buf_ptr: *const u8, buf_len: u32) -> i32 result } -/// Resolve one logical write's operation-wide byte budget before the host -/// decomposes it into scratch-buffer-sized kernel calls. -/// -/// `positioned != 0` selects the supplied offset (pwrite/pwritev); otherwise -/// the open-file-description cursor and O_APPEND state are authoritative. -/// The host supplies the exact calling TID so this direct export does not -/// install or consume ambient channel-dispatch authority. -#[unsafe(no_mangle)] -pub extern "C" fn kernel_prepare_write_operation( - pid: u32, - tid: u32, - fd: i32, - offset: i64, - requested_len: u32, - positioned: u32, -) -> i64 { - let _gkl = GklGuard::acquire(); - let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - let (proc, advisory_locks) = match table.task_and_advisory_locks(pid, tid) { - Some(pair) => pair, - None => return -(Errno::ESRCH as i64), - }; - let mut host = WasmHostIO; - let result = match syscalls::write_operation_budget( - proc, - &mut host, - tid, - fd, - (positioned != 0).then_some(offset), - requested_len as usize, - ) { - Ok(len) => len as i64, - Err(e) => -(e as i64), - }; - let _ = deliver_pending_signals_for_tid_with_locks(proc, advisory_locks, &mut host, tid); - result -} - /// Seek within a file. The 64-bit offset is passed as two 32-bit halves /// because some Wasm host bindings lack native i64 support. /// Returns the new offset (i64) or negative errno (i64). @@ -5551,12 +6294,10 @@ pub extern "C" fn kernel_lseek(fd: i32, offset_lo: u32, offset_hi: i32, whence: result } -/// pread - read at offset without modifying position. Returns bytes read or negative errno. -#[unsafe(no_mangle)] -pub extern "C" fn kernel_pread(fd: i32, buf_ptr: *mut u8, buf_len: u32, offset: i64) -> i32 { +/// Positioned read into one already-proven live channel slice. +fn channel_pread(fd: i32, buf: &mut [u8], offset: i64) -> i32 { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; - let buf = unsafe { slice::from_raw_parts_mut(buf_ptr, buf_len as usize) }; let result = match syscalls::sys_pread(proc, &mut host, fd, buf, offset) { Ok(n) => n as i32, Err(e) => -(e as i32), @@ -5565,12 +6306,10 @@ pub extern "C" fn kernel_pread(fd: i32, buf_ptr: *mut u8, buf_len: u32, offset: result } -/// pwrite - write at offset without modifying position. Returns bytes written or negative errno. -#[unsafe(no_mangle)] -pub extern "C" fn kernel_pwrite(fd: i32, buf_ptr: *const u8, buf_len: u32, offset: i64) -> i32 { +/// Positioned write from one already-proven live channel slice. +fn channel_pwrite(fd: i32, buf: &[u8], offset: i64) -> i32 { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; - let buf = unsafe { slice::from_raw_parts(buf_ptr, buf_len as usize) }; let result = match syscalls::sys_pwrite(proc, &mut host, fd, buf, offset) { Ok(n) => n as i32, Err(e) => -(e as i32), @@ -5579,6 +6318,168 @@ pub extern "C" fn kernel_pwrite(fd: i32, buf_ptr: *const u8, buf_len: u32, offse result } +fn execute_transfer_io_for_task( + pid: u32, + tid: u32, + original_syscall: u32, + fd: i32, + offset: i64, + bytes: &mut [u8], + retry_token: i64, +) -> Result { + use crate::transfer::TransferIoOperation; + + let _gkl = GklGuard::acquire(); + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + table.bind_current_tid(pid, tid)?; + let activation = (|| { + let operation = + crate::blocked_retry::BlockingRetryOperation::from_syscall(original_syscall)?; + let proc = table.current_process().ok_or(Errno::ESRCH)?; + proc.blocked_retries.begin_dispatch(tid)?; + if retry_token == 0 { + let result = if proc.blocked_retries.has_binding_for_tid(tid) { + Err(Errno::EBUSY) + } else { + Ok(()) + }; + if result.is_err() { + proc.blocked_retries.clear_dispatch(); + } + return result; + } + if retry_token < 0 { + proc.blocked_retries.clear_dispatch(); + return Err(Errno::EINVAL); + } + let result = proc.blocked_retries.activate(tid, retry_token, operation); + if result.is_err() { + proc.blocked_retries.clear_dispatch(); + } + result + })(); + if let Err(error) = activation { + table.clear_current_tid_binding(); + return Err(error); + } + + // WHY: bind_current_tid installs the same exact-task context consumed by + // ordinary channel dispatch. write_operation_budget, directed signals, + // and SCM_RIGHTS cleanup must see the actual issuing TID, not ambient + // state from the preceding mailbox. + let result = (|| { + let (proc, advisory_locks) = table + .current_process_and_advisory_locks() + .ok_or(Errno::ESRCH)?; + let mut host = WasmHostIO; + let mut result = match crate::transfer::io_operation_for_syscall(original_syscall) { + Ok(TransferIoOperation::Read) => syscalls::sys_read(proc, &mut host, fd, bytes), + Ok(TransferIoOperation::Write) => syscalls::sys_write(proc, &mut host, fd, bytes), + Ok(TransferIoOperation::Pread) => { + syscalls::sys_pread(proc, &mut host, fd, bytes, offset) + } + Ok(TransferIoOperation::Pwrite) => { + syscalls::sys_pwrite(proc, &mut host, fd, bytes, offset) + } + Err(error) => Err(error), + }; + if result == Err(Errno::EAGAIN) { + if let Err(error) = syscalls::ensure_blocking_retry_ofd_binding( + proc, + advisory_locks, + &mut host, + tid, + original_syscall, + fd, + None, + ) { + result = Err(error); + } + } + proc.blocked_retries.clear_active(); + proc.blocked_retries.clear_dispatch(); + syscalls::drain_deferred_scm_rights_releases(advisory_locks, &mut host); + let _ = deliver_pending_signals_for_tid_with_locks(proc, advisory_locks, &mut host, tid); + result + })(); + + // All ordinary success and errno paths consume this one-shot task + // authority. A host-import exception traps the Wasm call before cleanup; + // the host must terminate that failed kernel instance, just as it must not + // reuse the transfer's irrecoverable Executing token. + table.clear_current_tid_binding(); + result +} + +/// Execute one large scalar or vector I/O operation against a Reserved token. +/// +/// Vector syscall numbers deliberately map to exactly one scalar kernel +/// operation because the host has already flattened their iovecs into the +/// reservation. The return is a non-negative byte count or negated errno. +/// +/// WHY: this token-only call is a narrowly trusted host-adapter boundary. It +/// carries no raw pointer; the active transfer-region lease remains the sole +/// authority for the Rust-owned allocation. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_transfer_io_execute( + pid: u32, + tid: u32, + token: i64, + length: usize, + original_syscall: u32, + fd: i32, + offset: i64, + retry_token: i64, +) -> i32 { + match crate::transfer::execute_transfer_with(token, length, |bytes| { + execute_transfer_io_for_task(pid, tid, original_syscall, fd, offset, bytes, retry_token) + }) { + Ok(length) => length as i32, + Err(error) => -(error as i32), + } +} + +/// Execute one complete widened channel in a token-owned Rust allocation. +/// +/// The export accepts no pointer or host-supplied capacity. Reserved → +/// Executing atomically yields the Vec's own initialized base and length, and +/// the transfer mutex is released before task binding, syscall dispatch, or +/// any host import. A return value of zero means only that the transport +/// completed; the syscall's exact result and errno are authoritative in the +/// channel header. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_transfer_channel_execute( + pid: u32, + tid: u32, + token: i64, + retry_token: i64, +) -> i32 { + use wasm_posix_shared::channel::MIN_CHANNEL_SIZE; + + match crate::transfer::execute_channel_transfer_with(token, |bytes| { + if bytes.len() < MIN_CHANNEL_SIZE { + return Err(Errno::EINVAL); + } + + let _gkl = GklGuard::acquire(); + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + table.bind_current_tid(pid, tid)?; + + // handle_owned_channel_allocation consumes the exact binding and runs + // the same SCM_RIGHTS cleanup boundary as the ordinary channel. + let _syscall_mirror = handle_owned_channel_allocation( + bytes.as_mut_ptr() as usize, + bytes.len(), + pid, + retry_token, + ); + Ok(()) + }) { + Ok(()) => 0, + Err(error) => -(error as i32), + } +} + /// Duplicate a file descriptor. Returns new fd (>= 0) or negative errno. #[unsafe(no_mangle)] pub extern "C" fn kernel_dup(fd: i32) -> i32 { @@ -5645,11 +6546,7 @@ pub extern "C" fn kernel_dup3(oldfd: i32, newfd: i32, flags: u32) -> i32 { /// Create a pipe with flags. Writes [read_fd, write_fd] to the pointer. /// Returns 0 on success, or negative errno on error. #[unsafe(no_mangle)] -pub extern "C" fn kernel_pipe2( - flags: u32, - fd_ptr: *mut i32, - fd_capacity: u32, -) -> i32 { +pub extern "C" fn kernel_pipe2(flags: u32, fd_ptr: *mut i32, fd_capacity: u32) -> i32 { if fd_ptr.is_null() { return -(Errno::EFAULT as i32); } @@ -5857,10 +6754,10 @@ pub extern "C" fn kernel_timerfd_gettime(fd: i32, cur_ptr: *mut u8) -> i32 { pub extern "C" fn kernel_signalfd4( fd: i32, mask_ptr: *const u8, - sigsetsize: u32, + sigsetsize: usize, flags: u32, ) -> i32 { - if sigsetsize != core::mem::size_of::() as u32 { + if sigsetsize != core::mem::size_of::() { return -(Errno::EINVAL as i32); } if mask_ptr.is_null() { @@ -6586,13 +7483,13 @@ fn kernel_kill_with_metadata(pid: i32, sig: u32, si_value_bits: u64, si_code: i3 delivered = true; if sig > 0 { if let Some((target, locks)) = table.process_and_advisory_locks(target_pid) { - target.raise_signal_with_metadata( - sig, - si_value_bits, - si_code, - caller_pid, - sender_uid, - ); + target.raise_signal_with_metadata( + sig, + si_value_bits, + si_code, + caller_pid, + sender_uid, + ); if let Some(target_tid) = target.pick_thread_for_shared_signal(sig) { let _ = deliver_pending_signals_for_tid_with_locks( target, locks, &mut host, target_tid, @@ -6619,16 +7516,10 @@ fn kernel_kill_with_metadata(pid: i32, sig: u32, si_value_bits: u64, si_code: i3 return -(Errno::EINVAL as i32); } let Some((caller, locks)) = table.process_and_advisory_locks(caller_pid) else { - return -(Errno::ESRCH as i32); - }; - if sig > 0 { - caller.raise_signal_with_metadata( - sig, - si_value_bits, - si_code, - caller_pid, - sender_uid, - ); + return -(Errno::ESRCH as i32); + }; + if sig > 0 { + caller.raise_signal_with_metadata(sig, si_value_bits, si_code, caller_pid, sender_uid); } let _ = deliver_pending_signals_for_tid_with_locks(caller, locks, &mut host, caller_tid); 0 @@ -6645,8 +7536,7 @@ pub extern "C" fn kernel_sigaltstack( process_pointer_width: i64, ) -> i32 { use crate::process_wire::{ - NativeSigaltstack, ProcessDataModel, SIGALTSTACK_SS_DISABLE, - validate_sigaltstack_range, + validate_sigaltstack_range, NativeSigaltstack, ProcessDataModel, SIGALTSTACK_SS_DISABLE, }; let model = match ProcessDataModel::from_width(process_pointer_width) { @@ -7008,9 +7898,9 @@ pub extern "C" fn kernel_sigaction(sig: u32, act_ptr: *const u8, oldact_ptr: *mu /// signal() — set signal handler (legacy API). Returns old handler or negative errno. #[unsafe(no_mangle)] -pub extern "C" fn kernel_signal(signum: u32, handler: usize) -> i32 { +pub extern "C" fn kernel_signal(signum: u32, handler: u32) -> i32 { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; - let result = match syscalls::sys_signal(proc, signum, handler as u32) { + let result = match syscalls::sys_signal(proc, signum, handler) { Ok(old) => old, Err(e) => -(e as i32), }; @@ -7176,7 +8066,7 @@ pub extern "C" fn kernel_mremap( pub extern "C" fn kernel_madvise(addr: usize, len: usize, advice: u32) -> i32 { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; - let result = match syscalls::sys_madvise(proc, addr as u32, len as u32, advice) { + let result = match syscalls::sys_madvise(proc, addr, len, advice) { Ok(()) => 0, Err(e) => -(e as i32), }; @@ -7409,6 +8299,32 @@ fn write_wire_u32(bytes: &mut [u8], offset: usize, value: u32) { bytes[offset..offset + size_of::()].copy_from_slice(&value.to_le_bytes()); } +fn finish_direct_blocking_retry_dispatch( + proc: &mut Process, + owns_active: bool, + owns_dispatch: bool, +) { + if owns_active { + proc.blocked_retries.clear_active(); + } + if owns_dispatch { + proc.blocked_retries.clear_dispatch(); + } +} + +fn deliver_pending_signals_for_known_tid( + proc: &mut Process, + advisory_locks: &mut crate::lock::AdvisoryLockManager, + host: &mut dyn crate::process::HostIO, + tid: u32, +) { + // WHY: direct message exports captured TID before borrowing Process from + // the table. Re-reading ambient ProcessTable state here would alias that + // live mutable borrow, especially on activation-error paths that never + // install dispatch_tid. + let _ = deliver_pending_signals_for_tid_with_locks(proc, advisory_locks, host, tid); +} + /// Extract SCM_RIGHTS descriptors from one canonical kernel control wire. /// /// Malformed records and invalid descriptors are errors. Silently skipping @@ -7445,11 +8361,32 @@ fn extract_scm_rights( /// buffer. Keeping the fixed wire at zero or one iovec preserves datagram /// atomicity without a second kernel allocation and payload copy. #[unsafe(no_mangle)] -pub extern "C" fn kernel_sendmsg(fd: i32, msg_ptr: *const u8, flags: u32) -> i32 { +pub extern "C" fn kernel_sendmsg(fd: i32, msg_ptr: *const u8, flags: u32, retry_token: i64) -> i32 { use wasm_posix_shared::{KernelIovecWire, KernelMsghdrWire}; - let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; + let (_gkl, tid, proc, advisory_locks) = unsafe { get_process_tid_and_advisory_locks() }; let mut host = WasmHostIO; + let operation = crate::blocked_retry::BlockingRetryOperation::Sendmsg; + let owns_active = match proc + .blocked_retries + .activate_direct(tid, retry_token, operation) + { + Ok(owns_active) => owns_active, + Err(error) => { + deliver_pending_signals_for_known_tid(proc, advisory_locks, &mut host, tid); + return -(error as i32); + } + }; + let owns_dispatch = match proc.blocked_retries.enter_dispatch(tid) { + Ok(owns_dispatch) => owns_dispatch, + Err(error) => { + if owns_active { + proc.blocked_retries.clear_active(); + } + deliver_pending_signals_for_known_tid(proc, advisory_locks, &mut host, tid); + return -(error as i32); + } + }; let msg = unsafe { slice::from_raw_parts(msg_ptr, size_of::()) }; let name_ptr = read_wire_u32(msg, offset_of!(KernelMsghdrWire, name)) as usize; @@ -7460,17 +8397,54 @@ pub extern "C" fn kernel_sendmsg(fd: i32, msg_ptr: *const u8, flags: u32) -> i32 let control_len = read_wire_u32(msg, offset_of!(KernelMsghdrWire, control_len)) as usize; if let Err(err) = validate_canonical_message_iov_len(iov_len) { - deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); + finish_direct_blocking_retry_dispatch(proc, owns_active, owns_dispatch); + deliver_pending_signals_for_known_tid(proc, advisory_locks, &mut host, tid); return -(err as i32); } - let ancillary_fds = match extract_scm_rights(proc, control_ptr, control_len) { - Ok(fds) => fds, + let active_ancillary = match syscalls::clone_active_sendmsg_ancillary(proc, tid) { + Ok(ancillary) => ancillary, Err(err) => { - deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); + finish_direct_blocking_retry_dispatch(proc, owns_active, owns_dispatch); + // A fallible clone may already have retained an earlier rights + // entry. Its Drop queued exact deferred release metadata; consume + // that rollback before this exported operation returns. + syscalls::drain_deferred_scm_rights_releases(advisory_locks, &mut host); + deliver_pending_signals_for_known_tid(proc, advisory_locks, &mut host, tid); return -(err as i32); } }; + let (ancillary_fds, binding_template) = if let Some(ancillary) = active_ancillary { + (ancillary, None) + } else { + let ancillary = match extract_scm_rights(proc, control_ptr, control_len) { + Ok(fds) => fds, + Err(err) => { + finish_direct_blocking_retry_dispatch(proc, owns_active, owns_dispatch); + deliver_pending_signals_for_known_tid(proc, advisory_locks, &mut host, tid); + return -(err as i32); + } + }; + let mut template = Vec::new(); + if template.try_reserve_exact(ancillary.len()).is_err() { + finish_direct_blocking_retry_dispatch(proc, owns_active, owns_dispatch); + deliver_pending_signals_for_known_tid(proc, advisory_locks, &mut host, tid); + return -(Errno::ENOMEM as i32); + } + for entry in &ancillary { + match entry.try_clone_retained() { + Ok(entry) => template.push(entry), + Err(error) => { + finish_direct_blocking_retry_dispatch(proc, owns_active, owns_dispatch); + drop(template); + syscalls::drain_deferred_scm_rights_releases(advisory_locks, &mut host); + deliver_pending_signals_for_known_tid(proc, advisory_locks, &mut host, tid); + return -(error as i32); + } + } + } + (ancillary, Some(template)) + }; let (base, len) = if iov_len == 0 { (0, 0) @@ -7496,27 +8470,63 @@ pub extern "C" fn kernel_sendmsg(fd: i32, msg_ptr: *const u8, flags: u32) -> i32 } else { None }; - let result = match syscalls::sys_sendmsg(proc, &mut host, fd, buf, flags, addr, ancillary_fds) { - Ok(n) => n as i32, - Err(e) => -(e as i32), - }; + let mut result = + match syscalls::sys_sendmsg(proc, &mut host, fd, buf, flags, addr, ancillary_fds) { + Ok(n) => n as i32, + Err(e) => -(e as i32), + }; + if result == -(Errno::EAGAIN as i32) { + if let Err(error) = syscalls::ensure_blocking_retry_ofd_binding( + proc, + advisory_locks, + &mut host, + tid, + 137, + fd, + binding_template, + ) { + result = -(error as i32); + } + } + finish_direct_blocking_retry_dispatch(proc, owns_active, owns_dispatch); syscalls::drain_deferred_scm_rights_releases(advisory_locks, &mut host); - deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); + deliver_pending_signals_for_known_tid(proc, advisory_locks, &mut host, tid); result } /// recvmsg — receive into one canonical host-staged contiguous buffer. #[unsafe(no_mangle)] -pub extern "C" fn kernel_recvmsg(fd: i32, msg_ptr: *mut u8, flags: u32) -> i32 { +pub extern "C" fn kernel_recvmsg(fd: i32, msg_ptr: *mut u8, flags: u32, retry_token: i64) -> i32 { use wasm_posix_shared::fd_flags::FD_CLOEXEC; use wasm_posix_shared::socket::{ MSG_CMSG_CLOEXEC, MSG_CTRUNC, SCM_RIGHTS, SCM_RIGHTS_FD_BYTES, SOL_SOCKET, }; use wasm_posix_shared::{KernelCmsghdrWire, KernelIovecWire, KernelMsghdrWire}; - let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; + let (_gkl, tid, proc, advisory_locks) = unsafe { get_process_tid_and_advisory_locks() }; let mut host = WasmHostIO; + let operation = crate::blocked_retry::BlockingRetryOperation::Recvmsg; + let owns_active = match proc + .blocked_retries + .activate_direct(tid, retry_token, operation) + { + Ok(owns_active) => owns_active, + Err(error) => { + deliver_pending_signals_for_known_tid(proc, advisory_locks, &mut host, tid); + return -(error as i32); + } + }; + let owns_dispatch = match proc.blocked_retries.enter_dispatch(tid) { + Ok(owns_dispatch) => owns_dispatch, + Err(error) => { + if owns_active { + proc.blocked_retries.clear_active(); + } + deliver_pending_signals_for_known_tid(proc, advisory_locks, &mut host, tid); + return -(error as i32); + } + }; let msg = unsafe { slice::from_raw_parts(msg_ptr, size_of::()) }; let name_ptr = read_wire_u32(msg, offset_of!(KernelMsghdrWire, name)) as usize; @@ -7527,7 +8537,8 @@ pub extern "C" fn kernel_recvmsg(fd: i32, msg_ptr: *mut u8, flags: u32) -> i32 { let control_len = read_wire_u32(msg, offset_of!(KernelMsghdrWire, control_len)) as usize; if let Err(err) = validate_canonical_message_iov_len(iov_len) { - deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); + finish_direct_blocking_retry_dispatch(proc, owns_active, owns_dispatch); + deliver_pending_signals_for_known_tid(proc, advisory_locks, &mut host, tid); return -(err as i32); } @@ -7554,11 +8565,25 @@ pub extern "C" fn kernel_recvmsg(fd: i32, msg_ptr: *mut u8, flags: u32) -> i32 { &mut [] }; - let (result, mut received) = + let (mut result, mut received) = match syscalls::sys_recvmsg(proc, &mut host, fd, buf, flags, addr_buf) { Ok(received) => (received.return_len as i32, Some(received)), Err(err) => (-(err as i32), None), }; + if result == -(Errno::EAGAIN as i32) { + if let Err(error) = syscalls::ensure_blocking_retry_ofd_binding( + proc, + advisory_locks, + &mut host, + tid, + 138, + fd, + None, + ) { + result = -(error as i32); + } + } + finish_direct_blocking_retry_dispatch(proc, owns_active, owns_dispatch); // Publish all result metadata even for a zero-byte datagram: a zero-length // message can still carry descriptors and output flags. @@ -7568,11 +8593,16 @@ pub extern "C" fn kernel_recvmsg(fd: i32, msg_ptr: *mut u8, flags: u32) -> i32 { .map_or(0, |received| received.output_flags); if let Some(received) = received.as_mut() { let msg_mut = unsafe { slice::from_raw_parts_mut(msg_ptr, size_of::()) }; - write_wire_u32( - msg_mut, - offset_of!(KernelMsghdrWire, name_len), - received.addr_len as u32, - ); + if name_ptr != 0 { + // msg_name presence, not its capacity, controls whether + // msg_namelen is a value-result field. A canonical non-null + // zero-capacity pointer still receives the complete length. + write_wire_u32( + msg_mut, + offset_of!(KernelMsghdrWire, name_len), + received.addr_len as u32, + ); + } if !received.ancillary_fds.is_empty() { let mut in_flight = core::mem::take(&mut received.ancillary_fds); @@ -7654,7 +8684,7 @@ pub extern "C" fn kernel_recvmsg(fd: i32, msg_ptr: *mut u8, flags: u32) -> i32 { syscalls::drain_deferred_scm_rights_releases(advisory_locks, &mut host); - deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); + deliver_pending_signals_for_known_tid(proc, advisory_locks, &mut host, tid); result } @@ -7909,15 +8939,15 @@ pub extern "C" fn kernel_mprotect(addr: usize, len: usize, prot: u32) -> i32 { result } -/// Exit the process. Closes all fds and dir streams, sets state to Exited. -/// For thread workers, just sets exit_status without destroying shared state. +/// Commit the current task's exit transition and return its recorded status. /// -/// This kernel-side transaction returns so a reusable kernel Wasm instance can -/// run its compiler-generated shadow-stack epilogue. The guest's separate -/// `kernel_exit` import remains non-returning and traps only after the channel -/// handshake completes. -#[unsafe(no_mangle)] -pub extern "C" fn kernel_exit(status: i32) { +/// WHY: the host adapter must be able to verify process exit without treating +/// the deliberate trap required by the guest-facing `_exit` ABI as an +/// arbitrary recoverable WebAssembly exception. Both exported entry points +/// share this exact transition so their cleanup and task-binding lifetime +/// cannot drift. +fn commit_current_task_exit(status: i32) -> i32 { + let committed_status; { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; if unsafe { host_is_thread_worker() } != 0 { @@ -7930,10 +8960,38 @@ pub extern "C" fn kernel_exit(status: i32) { let mut host = WasmHostIO; syscalls::sys_exit_with_locks(proc, advisory_locks, &mut host, status); } + committed_status = proc.exit_status; } // _gkl dropped here — GKL released - // Preserve direct-export safety as well as the normal dispatcher contract. - // `kernel_handle_channel` clears the same binding again after this returns. + // Consume task authority before deferred descriptor cleanup can invoke a + // host callback. Cleanup needs no process identity, and a callback trap + // must not leave the exited task available to a later dispatch. unsafe { &mut *PROCESS_TABLE.0.get() }.clear_current_tid_binding(); + finish_machine_scm_rights_cleanup_if_pending(); + committed_status +} + +/// Host-adapter exit boundary that returns after committing process state. +/// +/// The caller must compare the returned low-eight-bit status and independently +/// verify `kernel_get_process_state(pid) == PROCESS_STATE_EXITED` before +/// publishing lifecycle effects. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_commit_process_exit(status: i32) -> i32 { + commit_current_task_exit(status) +} + +/// Exit the process. Closes all fds and dir streams, sets state to Exited. +/// For thread workers, just sets exit_status without destroying shared state. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_exit(status: i32) -> ! { + let _ = commit_current_task_exit(status); + // Halt execution — musl's _exit loops forever if we just return. + #[cfg(any(target_arch = "wasm32", target_arch = "wasm64"))] + unsafe { + core::hint::unreachable_unchecked(); + } + #[cfg(not(any(target_arch = "wasm32", target_arch = "wasm64")))] + unreachable!("kernel_exit should not return"); } /// Get the exit status of the current process (set by kernel_exit). @@ -8044,9 +9102,25 @@ pub extern "C" fn kernel_accept4( let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; + let address_requested = syscalls::validate_optional_socket_address_output( + !addr_ptr.is_null(), + !addrlen_ptr.is_null(), + ); let result = if !accept4_flags_are_valid(flags) { -(Errno::EINVAL as i32) + } else if let Err(error) = address_requested { + // Reject an incomplete active pair before accept can consume a queued + // connection and publish an unreachable descriptor. + -(error as i32) } else { + let requested_addr_len = if address_requested.unwrap_or(false) { + let addrlen_buf = unsafe { slice::from_raw_parts(addrlen_ptr, 4) }; + Some(u32::from_le_bytes( + addrlen_buf.try_into().unwrap_or([0; 4]), + ) as usize) + } else { + None + }; match syscalls::sys_accept(proc, &mut host, fd) { Ok(new_fd) => { // Apply SOCK_CLOEXEC flag @@ -8063,72 +9137,37 @@ pub extern "C" fn kernel_accept4( } } } - // Write peer address if buffers provided - if !addr_ptr.is_null() && !addrlen_ptr.is_null() { - let addrlen_buf = unsafe { slice::from_raw_parts_mut(addrlen_ptr, 4) }; - let max_len = - u32::from_le_bytes(addrlen_buf.try_into().unwrap_or([0; 4])) as usize; - // Get the accepted socket's peer address - let entry = proc.fd_table.get(new_fd); - if let Ok(entry) = entry { - let ofd = proc.ofd_table.get(entry.ofd_ref.0); - if let Some(ofd) = ofd { - let sock_idx = (-(ofd.host_handle + 1)) as usize; - if let Some(sock) = proc.sockets.get(sock_idx) { - match sock.domain { - crate::socket::SocketDomain::Unix => { - // Write AF_UNIX sockaddr - let n = max_len.min(2); - if n >= 2 { - let addr_buf = unsafe { - slice::from_raw_parts_mut(addr_ptr, max_len) - }; - addr_buf[0] = 1; // AF_UNIX - addr_buf[1] = 0; - for i in 2..max_len { - addr_buf[i] = 0; - } - addrlen_buf.copy_from_slice(&2u32.to_le_bytes()); - } - } - crate::socket::SocketDomain::Inet => { - let mut sa = [0u8; 16]; - sa[0] = 2; // AF_INET - let port_be = sock.peer_port.to_be_bytes(); - sa[2] = port_be[0]; - sa[3] = port_be[1]; - sa[4] = sock.peer_addr[0]; - sa[5] = sock.peer_addr[1]; - sa[6] = sock.peer_addr[2]; - sa[7] = sock.peer_addr[3]; - let n = max_len.min(16); - let addr_buf = - unsafe { slice::from_raw_parts_mut(addr_ptr, n) }; - addr_buf.copy_from_slice(&sa[..n]); - addrlen_buf.copy_from_slice(&16u32.to_le_bytes()); - } - crate::socket::SocketDomain::Inet6 => { - let mut sa = [0u8; 28]; - sa[0] = 10; // AF_INET6 - let port_be = sock.peer_port.to_be_bytes(); - sa[2] = port_be[0]; - sa[3] = port_be[1]; - sa[8..24].copy_from_slice(&sock.peer_addr6); - let n = max_len.min(28); - let addr_buf = - unsafe { slice::from_raw_parts_mut(addr_ptr, n) }; - addr_buf.copy_from_slice(&sa[..n]); - addrlen_buf.copy_from_slice(&28u32.to_le_bytes()); - } - } - } - } + let address_result = if let Some(max_len) = requested_addr_len { + // A zero-capacity result has no address bytes to lend, but + // still reports the complete peer-address length. + let addr_buf = if max_len == 0 { + &mut [] + } else { + unsafe { slice::from_raw_parts_mut(addr_ptr, max_len) } + }; + syscalls::write_accept_peer_address(proc, new_fd, addr_buf).map(|actual_len| { + let addrlen_buf = + unsafe { slice::from_raw_parts_mut(addrlen_ptr, 4) }; + addrlen_buf.copy_from_slice(&actual_len.to_le_bytes()); + }) + } else { + Ok(()) + }; + match address_result { + Ok(()) => new_fd, + Err(error) => { + // A result-marshalling failure cannot publish an fd + // whose peer metadata the caller requested but did not + // receive. Roll back the accepted descriptor first. + let rollback = syscalls::sys_close_with_locks( + proc, + advisory_locks, + &mut host, + new_fd, + ); + -(rollback.err().unwrap_or(error) as i32) } - } else if !addrlen_ptr.is_null() { - let buf = unsafe { slice::from_raw_parts_mut(addrlen_ptr, 4) }; - buf.copy_from_slice(&0u32.to_le_bytes()); } - new_fd } Err(e) => -(e as i32), } @@ -8223,8 +9262,8 @@ fn cross_process_loopback_connect( // searching or mutating any other process. let sock_idx = { let proc = table.get(my_pid).ok_or(Errno::ESRCH)?; - let entry = proc.fd_table.get(fd)?; - let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + let ofd_idx = syscalls::resolve_io_ofd(proc, fd)?; + let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; if ofd.file_type != FileType::Socket { return Err(Errno::ENOTSOCK); } @@ -8375,8 +9414,8 @@ fn cross_process_loopback_connect6( let sock_idx = { let proc = table.get(my_pid).ok_or(Errno::ESRCH)?; - let entry = proc.fd_table.get(fd)?; - let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + let ofd_idx = syscalls::resolve_io_ofd(proc, fd)?; + let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; if ofd.file_type != FileType::Socket { return Err(Errno::ENOTSOCK); } @@ -8487,11 +9526,9 @@ fn cross_process_unix_connect( use crate::pipe::PipeBuffer; use crate::socket::{SocketDomain, SocketState, SocketType}; - // Parse path from sockaddr_un - if addr.len() < 3 { - return Err(Errno::EINVAL); - } - let path_bytes = &addr[2..]; + // sys_connect already validated this address before the cross-process + // fallback, but retain the family-specific bound at this second parser. + let path_bytes = syscalls::checked_sockaddr_un_path(addr)?; let (resolved, sock_idx) = { let proc = table.get_mut(my_pid).ok_or(Errno::ESRCH)?; let resolved = if path_bytes.first().copied() == Some(0) { @@ -8513,8 +9550,8 @@ fn cross_process_unix_connect( // Only AF_UNIX stream sockets can enter the cross-process stream-pipe // connection path. In particular, never reinterpret a datagram socket // as a stream merely because its registry lookup found another process. - let entry = proc.fd_table.get(fd)?; - let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + let ofd_idx = syscalls::resolve_io_ofd(proc, fd)?; + let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; if ofd.file_type != FileType::Socket { return Err(Errno::ENOTSOCK); } @@ -8592,7 +9629,7 @@ fn cross_process_unix_connect( #[cfg(test)] mod socket_wrapper_tests { - use super::{WasmHostIO, cross_process_unix_connect, write_getsockopt_bytes}; + use super::{cross_process_unix_connect, write_getsockopt_bytes, WasmHostIO}; use crate::errno::Errno; use crate::fd::OpenFileDescRef; use crate::ofd::FileType; @@ -9135,15 +10172,10 @@ pub extern "C" fn kernel_setsockopt( /// Poll file descriptors. Returns number of ready fds, or negative errno. /// fds_ptr points to an array of WasmPollFd structs (8 bytes each: i32 fd, i16 events, i16 revents). #[unsafe(no_mangle)] -pub extern "C" fn kernel_poll( - fds_ptr: *mut u8, - fds_capacity: u32, - nfds: u32, - timeout: i32, -) -> i32 { - let Some(required_capacity) = nfds.checked_mul( - core::mem::size_of::() as u32, - ) else { +pub extern "C" fn kernel_poll(fds_ptr: *mut u8, fds_capacity: u32, nfds: u32, timeout: i32) -> i32 { + let Some(required_capacity) = + nfds.checked_mul(core::mem::size_of::() as u32) + else { return -(Errno::EOVERFLOW as i32); }; if fds_capacity != required_capacity { @@ -9214,27 +10246,38 @@ pub extern "C" fn kernel_recvfrom( } else { unsafe { slice::from_raw_parts_mut(buf_ptr, buf_len as usize) } }; - // addrlen_ptr is a channel-rewritten pointer to a u32 containing the buffer size - let addr_len = if !addrlen_ptr.is_null() { - unsafe { *addrlen_ptr } - } else { - 0 - }; - let addr_buf = if !addr_ptr.is_null() && addr_len > 0 { - unsafe { slice::from_raw_parts_mut(addr_ptr, addr_len as usize) } - } else { - &mut [] - }; - let result = match syscalls::sys_recvfrom(proc, &mut host, fd, buf, flags, addr_buf) { - Ok((n, actual_addr_len)) => { - if !addrlen_ptr.is_null() { - unsafe { - *addrlen_ptr = actual_addr_len as u32; + let address_requested = syscalls::validate_optional_socket_address_output( + !addr_ptr.is_null(), + !addrlen_ptr.is_null(), + ); + let result = match address_requested { + Err(error) => -(error as i32), + Ok(address_requested) => { + // addrlen_ptr is a channel-rewritten pointer only for an active + // address result. A caller may supply arbitrary ignored bits when + // addr_ptr is null. + let addr_len = if address_requested { + unsafe { *addrlen_ptr } + } else { + 0 + }; + let addr_buf = if addr_len > 0 { + unsafe { slice::from_raw_parts_mut(addr_ptr, addr_len as usize) } + } else { + &mut [] + }; + match syscalls::sys_recvfrom(proc, &mut host, fd, buf, flags, addr_buf) { + Ok((n, actual_addr_len)) => { + if address_requested { + unsafe { + *addrlen_ptr = actual_addr_len as u32; + } + } + n as i32 } + Err(e) => -(e as i32), } - n as i32 } - Err(e) => -(e as i32), }; syscalls::drain_deferred_scm_rights_releases(advisory_locks, &mut host); deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); @@ -9521,8 +10564,7 @@ fn kernel_prctl_from_channel(option: u32, arg2: usize, _arg3: *mut u8, _arg4: u3 // For the two thread-name operations, arg2 is the pointer to the // generated fixed-size name buffer. The other prctl args are // option-specific and may be garbage for options that don't use them. - let is_name_operation = - option == prctl::PR_SET_NAME || option == prctl::PR_GET_NAME; + let is_name_operation = option == prctl::PR_SET_NAME || option == prctl::PR_GET_NAME; let buf = if is_name_operation && arg2 != 0 { unsafe { core::slice::from_raw_parts_mut( @@ -9658,6 +10700,11 @@ pub extern "C" fn kernel_fchown(fd: i32, uid: u32, gid: u32) -> i32 { result } +// WHY: Vector parsing is private to channel dispatch and always carries the +// allocation-bearing region beside the table pointer. The former public raw +// exports could prove only that a pointer fit somewhere in total kernel +// memory, not that it belonged to the live channel allocation. Current user +// programs use channel_syscall.c and cannot import those obsolete functions. unsafe fn kernel_iovec_wire_at(iov_ptr: *const u8, index: usize) -> (usize, usize) { use wasm_posix_shared::KernelIovecWire; @@ -9669,28 +10716,115 @@ unsafe fn kernel_iovec_wire_at(iov_ptr: *const u8, index: usize) -> (usize, usiz ) } -/// Write data from multiple fixed kernel-wire buffers. -/// Returns total bytes written (>= 0) or negative errno. -#[unsafe(no_mangle)] -pub extern "C" fn kernel_writev(fd: i32, iov_ptr: *const u8, iovcnt: i32) -> i32 { - let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; - let mut host = WasmHostIO; +fn checked_kernel_iovec_entries( + iov_ptr: *const u8, + iovcnt: i32, + region: ChannelScratchRegion, +) -> Result<(Vec<(usize, usize)>, usize), Errno> { + if iovcnt < 0 || iovcnt as usize > platform_limits::IOV_MAX { + return Err(Errno::EINVAL); + } + if iovcnt == 0 { + // POSIX permits a null iovec pointer when no table entries exist. + // Return before inspecting the pointer or channel allocation. + return Ok((Vec::new(), 0)); + } + if iov_ptr.is_null() { + return Err(Errno::EFAULT); + } - let result = 'done: { - if iovcnt <= 0 || iovcnt as usize > platform_limits::IOV_MAX { - break 'done -(Errno::EINVAL as i32); + let count = iovcnt as usize; + let table_bytes = count + .checked_mul(size_of::()) + .ok_or(Errno::EFAULT)?; + region.checked_range(iov_ptr as usize, table_bytes)?; + + let mut entries = Vec::new(); + entries + .try_reserve_exact(count) + .map_err(|_| Errno::ENOMEM)?; + let mut total = 0usize; + for index in 0..count { + let (base, length) = unsafe { kernel_iovec_wire_at(iov_ptr, index) }; + region.checked_range(base, length)?; + total = total.checked_add(length).ok_or(Errno::EINVAL)?; + if total > platform_limits::MAX_REPORTABLE_TRANSFER_BYTES { + return Err(Errno::EINVAL); } + entries.push((base, length)); + } + Ok((entries, total)) +} - let mut buffers = Vec::with_capacity(iovcnt as usize); - for i in 0..iovcnt as usize { - let (base, len) = unsafe { kernel_iovec_wire_at(iov_ptr, i) }; +fn try_initialized_kernel_io_bytes(length: usize) -> Result, Errno> { + let mut bytes = Vec::new(); + bytes.try_reserve_exact(length).map_err(|_| Errno::ENOMEM)?; + bytes.resize(length, 0); + Ok(bytes) +} - if len == 0 { - continue; +unsafe fn gather_kernel_iovec_bytes( + entries: &[(usize, usize)], + total: usize, +) -> Result, Errno> { + let mut gathered = Vec::new(); + gathered + .try_reserve_exact(total) + .map_err(|_| Errno::ENOMEM)?; + for &(base, length) in entries { + if length != 0 { + let source = unsafe { slice::from_raw_parts(base as *const u8, length) }; + gathered.extend_from_slice(source); + } + } + Ok(gathered) +} + +unsafe fn scatter_kernel_iovec_prefix( + entries: &[(usize, usize)], + source: &[u8], + length: usize, +) -> Result<(), Errno> { + let source = source.get(..length).ok_or(Errno::EIO)?; + let mut copied = 0usize; + for &(base, capacity) in entries { + if copied == source.len() { + break; + } + let count = capacity.min(source.len() - copied); + if count != 0 { + // `copy`, unlike `copy_nonoverlapping`, remains sound if a raw + // compatibility caller supplies overlapping destination iovecs. + // The table/ranges were checked before the scalar read. + unsafe { + core::ptr::copy(source.as_ptr().add(copied), base as *mut u8, count); } - buffers.push(unsafe { slice::from_raw_parts(base as *const u8, len) }); + copied += count; } - match syscalls::sys_writev(proc, &mut host, fd, &buffers) { + } + if copied == source.len() { + Ok(()) + } else { + Err(Errno::EIO) + } +} + +/// Write data from multiple fixed kernel-wire buffers in one live channel. +/// Returns total bytes written (>= 0) or negative errno. +fn channel_writev(fd: i32, iov_ptr: *const u8, iovcnt: i32, region: ChannelScratchRegion) -> i32 { + let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; + let mut host = WasmHostIO; + + let result = 'done: { + let (entries, total) = match checked_kernel_iovec_entries(iov_ptr, iovcnt, region) { + Ok(entries) => entries, + Err(error) => break 'done -(error as i32), + }; + let gathered = match unsafe { gather_kernel_iovec_bytes(&entries, total) } { + Ok(bytes) => bytes, + Err(error) => break 'done -(error as i32), + }; + match syscalls::sys_write(proc, &mut host, fd, &gathered) { Ok(n) => n as i32, Err(e) => -(e as i32), } @@ -9699,129 +10833,95 @@ pub extern "C" fn kernel_writev(fd: i32, iov_ptr: *const u8, iovcnt: i32) -> i32 result } -/// Read data into multiple fixed kernel-wire buffers. +/// Read data into multiple fixed kernel-wire buffers in one live channel. /// Returns total bytes read (>= 0) or negative errno. -#[unsafe(no_mangle)] -pub extern "C" fn kernel_readv(fd: i32, iov_ptr: *mut u8, iovcnt: i32) -> i32 { +fn channel_readv(fd: i32, iov_ptr: *mut u8, iovcnt: i32, region: ChannelScratchRegion) -> i32 { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; let result = 'done: { - if iovcnt <= 0 || iovcnt as usize > platform_limits::IOV_MAX { - break 'done -(Errno::EINVAL as i32); - } - - let mut total: usize = 0; - for i in 0..iovcnt as usize { - let (base, len) = unsafe { kernel_iovec_wire_at(iov_ptr, i) }; - - if len == 0 { - continue; - } - let buf = unsafe { slice::from_raw_parts_mut(base as *mut u8, len) }; - match syscalls::sys_read(proc, &mut host, fd, buf) { - Ok(n) => { - total += n; - if n < len as usize || n == 0 { - break; - } - } - Err(e) => { - if total > 0 { - break 'done total as i32; - } - break 'done -(e as i32); - } - } + let (entries, total) = match checked_kernel_iovec_entries(iov_ptr, iovcnt, region) { + Ok(entries) => entries, + Err(error) => break 'done -(error as i32), + }; + let mut gathered = match try_initialized_kernel_io_bytes(total) { + Ok(bytes) => bytes, + Err(error) => break 'done -(error as i32), + }; + match syscalls::sys_read(proc, &mut host, fd, &mut gathered) { + Ok(n) => match unsafe { scatter_kernel_iovec_prefix(&entries, &gathered, n) } { + Ok(()) => n as i32, + Err(error) => -(error as i32), + }, + Err(e) => -(e as i32), } - total as i32 }; syscalls::drain_deferred_scm_rights_releases(advisory_locks, &mut host); deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); result } -/// preadv -- scatter-gather read from fixed kernel-wire buffers at offset. +/// preadv -- scatter-gather read from one live channel at an exact offset. /// offset is split into (lo, hi) u32 pair. /// Returns total bytes read or negative errno. -#[unsafe(no_mangle)] -pub extern "C" fn kernel_preadv( +fn channel_preadv( fd: i32, iov_ptr: *mut u8, iovcnt: i32, offset_lo: u32, offset_hi: i32, + region: ChannelScratchRegion, ) -> i32 { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; let offset = ((offset_hi as i64) << 32) | (offset_lo as u64 as i64); let result = 'done: { - if iovcnt <= 0 || iovcnt as usize > platform_limits::IOV_MAX { - break 'done -(Errno::EINVAL as i32); - } - - let mut total: usize = 0; - let mut cur_offset = offset; - for i in 0..iovcnt as usize { - let (base, len) = unsafe { kernel_iovec_wire_at(iov_ptr, i) }; - - if len == 0 { - continue; - } - let buf = unsafe { slice::from_raw_parts_mut(base as *mut u8, len) }; - match syscalls::sys_pread(proc, &mut host, fd, buf, cur_offset) { - Ok(n) => { - total += n; - cur_offset += n as i64; - if n < len as usize || n == 0 { - break; - } - } - Err(e) => { - if total > 0 { - break 'done total as i32; - } - break 'done -(e as i32); - } - } + let (entries, total) = match checked_kernel_iovec_entries(iov_ptr, iovcnt, region) { + Ok(entries) => entries, + Err(error) => break 'done -(error as i32), + }; + let mut gathered = match try_initialized_kernel_io_bytes(total) { + Ok(bytes) => bytes, + Err(error) => break 'done -(error as i32), + }; + match syscalls::sys_pread(proc, &mut host, fd, &mut gathered, offset) { + Ok(n) => match unsafe { scatter_kernel_iovec_prefix(&entries, &gathered, n) } { + Ok(()) => n as i32, + Err(error) => -(error as i32), + }, + Err(e) => -(e as i32), } - total as i32 }; deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); result } -/// pwritev -- scatter-gather write from fixed kernel-wire buffers at offset. +/// pwritev -- scatter-gather write from one live channel at an exact offset. /// offset is split into (lo, hi) u32 pair. /// Returns total bytes written or negative errno. -#[unsafe(no_mangle)] -pub extern "C" fn kernel_pwritev( +fn channel_pwritev( fd: i32, iov_ptr: *const u8, iovcnt: i32, offset_lo: u32, offset_hi: i32, + region: ChannelScratchRegion, ) -> i32 { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; let offset = ((offset_hi as i64) << 32) | (offset_lo as u64 as i64); let result = 'done: { - if iovcnt <= 0 || iovcnt as usize > platform_limits::IOV_MAX { - break 'done -(Errno::EINVAL as i32); - } - - let mut buffers = Vec::with_capacity(iovcnt as usize); - for i in 0..iovcnt as usize { - let (base, len) = unsafe { kernel_iovec_wire_at(iov_ptr, i) }; - - if len == 0 { - continue; - } - buffers.push(unsafe { slice::from_raw_parts(base as *const u8, len) }); - } - match syscalls::sys_pwritev(proc, &mut host, fd, &buffers, offset) { + let (entries, total) = match checked_kernel_iovec_entries(iov_ptr, iovcnt, region) { + Ok(entries) => entries, + Err(error) => break 'done -(error as i32), + }; + let gathered = match unsafe { gather_kernel_iovec_bytes(&entries, total) } { + Ok(bytes) => bytes, + Err(error) => break 'done -(error as i32), + }; + match syscalls::sys_pwrite(proc, &mut host, fd, &gathered, offset) { Ok(n) => n as i32, Err(e) => -(e as i32), } @@ -9833,8 +10933,7 @@ pub extern "C" fn kernel_pwritev( /// sendfile -- copy data between file descriptors. /// offset_ptr points to an i64 offset (or is null to use current position). /// Returns total bytes copied or negative errno. -#[unsafe(no_mangle)] -pub extern "C" fn kernel_sendfile(out_fd: i32, in_fd: i32, offset_ptr: *mut u8, count: u32) -> i32 { +fn kernel_sendfile_with_count(out_fd: i32, in_fd: i32, offset_ptr: *mut u8, count: usize) -> i32 { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; @@ -9845,19 +10944,24 @@ pub extern "C" fn kernel_sendfile(out_fd: i32, in_fd: i32, offset_ptr: *mut u8, i64::from_le_bytes(bytes.try_into().unwrap()) }; - let result = - match syscalls::sys_sendfile(proc, &mut host, out_fd, in_fd, offset, count as usize) { - Ok(n) => { - // Update offset_ptr if provided - if !offset_ptr.is_null() && offset >= 0 { - let new_offset = offset + n as i64; - let buf = unsafe { slice::from_raw_parts_mut(offset_ptr, 8) }; - buf.copy_from_slice(&new_offset.to_le_bytes()); + let result = match syscalls::sys_sendfile(proc, &mut host, out_fd, in_fd, offset, count) { + Ok(n) => { + // Update offset_ptr if provided + if !offset_ptr.is_null() && offset >= 0 { + match syscalls::checked_offset_advance(offset, n) { + Ok(new_offset) => { + let buf = unsafe { slice::from_raw_parts_mut(offset_ptr, 8) }; + buf.copy_from_slice(&new_offset.to_le_bytes()); + n as i32 + } + Err(error) => -(error as i32), } + } else { n as i32 } - Err(e) => -(e as i32), - }; + } + Err(e) => -(e as i32), + }; // WHY: sendfile without an explicit input offset consumes through the // ordinary read path. Crossing stream ancillary data discards its // SCM_RIGHTS, so direct callers need the same cleanup as channel dispatch. @@ -9866,6 +10970,16 @@ pub extern "C" fn kernel_sendfile(out_fd: i32, in_fd: i32, offset_ptr: *mut u8, result } +#[unsafe(no_mangle)] +pub extern "C" fn kernel_sendfile( + out_fd: i32, + in_fd: i32, + offset_ptr: *mut u8, + count: usize, +) -> i32 { + kernel_sendfile_with_count(out_fd, in_fd, offset_ptr, count) +} + /// statx -- extended file stat. /// Delegates to fstatat and fills the statx buffer from WasmStat. /// statx struct layout: we write a simplified version compatible with musl expectations. @@ -10403,7 +11517,11 @@ pub extern "C" fn kernel_clone( #[unsafe(no_mangle)] pub extern "C" fn kernel_is_fork_child() -> i32 { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; - if proc.fork_child { 1 } else { 0 } + if proc.fork_child { + 1 + } else { + 0 + } } /// Read the saved fork exec path into buf. Returns bytes written, or negative errno. @@ -10796,28 +11914,20 @@ pub extern "C" fn kernel_timer_create( }; // Parse sigevent (default: SIGEV_SIGNAL with SIGALRM) - let (sigev_signo, sigev_value_bits, sigev_notify, sigev_tid) = - if sevp_ptr.is_null() { - (14u32, 0u64, SIGEV_SIGNAL, 0u32) // default: SIGALRM - } else { - let model = match crate::process_wire::ProcessDataModel::from_width( - process_pointer_width, - ) { - Ok(model) => model, - Err(error) => return -(error as i32), - }; - let input = unsafe { slice::from_raw_parts(sevp_ptr, model.sigevent_size()) }; - let event = match crate::process_wire::read_sigevent(input, model) { - Ok(event) => event, - Err(error) => return -(error as i32), - }; - ( - event.signo, - event.value_bits, - event.notify, - event.thread_id, - ) + let (sigev_signo, sigev_value_bits, sigev_notify, sigev_tid) = if sevp_ptr.is_null() { + (14u32, 0u64, SIGEV_SIGNAL, 0u32) // default: SIGALRM + } else { + let model = match crate::process_wire::ProcessDataModel::from_width(process_pointer_width) { + Ok(model) => model, + Err(error) => return -(error as i32), + }; + let input = unsafe { slice::from_raw_parts(sevp_ptr, model.sigevent_size()) }; + let event = match crate::process_wire::read_sigevent(input, model) { + Ok(event) => event, + Err(error) => return -(error as i32), }; + (event.signo, event.value_bits, event.notify, event.thread_id) + }; let sigev_signo = match normalize_posix_timer_signo(sigev_notify, sigev_signo) { Ok(signo) => signo, @@ -11005,7 +12115,11 @@ pub extern "C" fn kernel_timer_settime( 0i64 } else { let ms = int_sec * 1000 + int_nsec / 1_000_000; - if ms < 1 { 1 } else { ms } // minimum 1ms for repeating + if ms < 1 { + 1 + } else { + ms + } // minimum 1ms for repeating }; let signo = timer.sigev_signo as i32; @@ -11323,6 +12437,7 @@ pub extern "C" fn kernel_get_robust_list(_pid: u32, _head_ptr: usize, _len_ptr: /// Removes the thread from the process's thread table. #[unsafe(no_mangle)] pub extern "C" fn kernel_thread_exit(pid: u32, tid: u32) -> i32 { + let _gkl = GklGuard::acquire(); let pt = unsafe { &mut *PROCESS_TABLE.0.get() }; match kernel_thread_exit_in_table(pt, pid, tid) { Ok(()) => 0, @@ -11335,14 +12450,9 @@ fn kernel_thread_exit_in_table( pid: u32, tid: u32, ) -> Result<(), Errno> { - let owner = ((pid as u64) << 32) | tid as u64; - let proc = pt.get_mut(pid).ok_or(Errno::ESRCH)?; - if proc.get_thread(tid).is_none() { - return Err(Errno::ESRCH); - } - syscalls::cancel_fifo_open_for_owner(proc, owner); - proc.remove_thread(tid).ok_or(Errno::ESRCH)?; - Ok(()) + let (proc, locks) = pt.process_and_advisory_locks(pid).ok_or(Errno::ESRCH)?; + let mut host = WasmHostIO; + syscalls::cleanup_exiting_thread(proc, locks, &mut host, tid) } #[cfg(test)] @@ -11746,7 +12856,11 @@ pub extern "C" fn kernel_pipe_is_write_open(_pid: u32, pipe_idx: u32) -> i32 { Some(p) => p, None => return -(Errno::EBADF as i32), }; - if pipe.is_write_end_open() { 1 } else { 0 } + if pipe.is_write_end_open() { + 1 + } else { + 0 + } } /// Check if a pipe accepts writes through a real reader or TCP discard sink. @@ -11761,7 +12875,11 @@ pub extern "C" fn kernel_pipe_is_read_open(_pid: u32, pipe_idx: u32) -> i32 { Some(p) => p, None => return -(Errno::EBADF as i32), }; - if pipe.is_read_end_open() { 1 } else { 0 } + if pipe.is_read_end_open() { + 1 + } else { + 0 + } } /// Check if a pipe has at least one application-owned reader. @@ -11777,7 +12895,11 @@ pub extern "C" fn kernel_pipe_has_readers(_pid: u32, pipe_idx: u32) -> i32 { Some(p) => p, None => return -(Errno::EBADF as i32), }; - if pipe.has_readers() { 1 } else { 0 } + if pipe.has_readers() { + 1 + } else { + 0 + } } /// Look up the recv pipe index for a socket fd. diff --git a/crates/kernel/tests/wasm_api_channel_pointer_contract.rs b/crates/kernel/tests/wasm_api_channel_pointer_contract.rs index e89a6f578f..c3d73e5907 100644 --- a/crates/kernel/tests/wasm_api_channel_pointer_contract.rs +++ b/crates/kernel/tests/wasm_api_channel_pointer_contract.rs @@ -66,6 +66,40 @@ fn sendmsg_zero_length_null_iovec_never_constructs_a_raw_slice() { #[test] fn mqueue_zero_length_message_never_constructs_a_null_raw_slice() { let source = include_str!("../src/wasm_api.rs"); + let const_slice_start = source + .find("macro_rules! channel_const_slice") + .expect("checked channel const-slice helper start"); + let mut_slice_start = source[const_slice_start..] + .find("macro_rules! channel_mut_slice") + .map(|offset| const_slice_start + offset) + .expect("checked channel mut-slice helper start"); + let cstr_start = source[mut_slice_start..] + .find("macro_rules! channel_cstr_len") + .map(|offset| mut_slice_start + offset) + .expect("checked channel mut-slice helper end"); + let const_slice = &source[const_slice_start..mut_slice_start]; + let mut_slice = &source[mut_slice_start..cstr_start]; + + for (name, helper, raw_constructor) in [ + ("const", const_slice, "slice::from_raw_parts("), + ("mut", mut_slice, "slice::from_raw_parts_mut("), + ] { + let empty_guard = helper + .find("if length == 0 {") + .unwrap_or_else(|| panic!("{name} helper must select a valid empty slice")); + let empty_slice = helper + .find("&[]") + .or_else(|| helper.find("&mut []")) + .unwrap_or_else(|| panic!("{name} helper must construct a safe empty slice")); + let raw_slice = helper + .find(raw_constructor) + .unwrap_or_else(|| panic!("{name} helper must retain bounded non-empty slices")); + assert!( + empty_guard < empty_slice && empty_slice < raw_slice, + "{name} helper must select its safe empty slice before raw construction" + ); + } + let send_start = source .find("// SYS_MQ_TIMEDSEND:") .expect("mq_timedsend dispatcher start"); @@ -80,15 +114,9 @@ fn mqueue_zero_length_message_never_constructs_a_null_raw_slice() { let send = &source[send_start..receive_start]; let receive = &source[receive_start..receive_end]; - let send_empty_guard = send - .find("let data = if data_len == 0 {\n &[]") - .expect("zero-length message must select a valid empty slice"); - let send_raw_slice = send - .find("core::slice::from_raw_parts(channel_const_ptr!(1, u8), data_len)") - .expect("positive-length message must retain the bounded slice"); assert!( - send_empty_guard < send_raw_slice, - "the zero-length send guard must precede raw-slice construction" + send.contains("let data = channel_const_slice!(1, data_len);"), + "mq_timedsend must use the checked slice helper with its actual length" ); let receive_empty_guard = receive diff --git a/crates/shared/src/channel_scalar.rs b/crates/shared/src/channel_scalar.rs new file mode 100644 index 0000000000..c383a90207 --- /dev/null +++ b/crates/shared/src/channel_scalar.rs @@ -0,0 +1,921 @@ +//! Scalar interpretation for the six i64 syscall-channel argument words. +//! +//! Most kernel-dispatched syscall scalars intentionally use the low signed +//! i32 word. The entries below describe every exception, plus successful +//! results that need a different host interpretation. Keeping the contract in +//! `wasm-posix-shared` lets Rust consume it directly while `xtask dump-abi` +//! generates the TypeScript host maps and musl number assertions. + +use crate::{abi::extended_syscalls, Syscall}; + +/// Interpretation of one physical i64 channel argument word. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChannelScalarKind { + /// The default low signed 32-bit scalar interpretation. + I32, + /// A low unsigned 32-bit scalar. + U32, + /// One unsigned scalar that must fit u32 without discarding high bits. + ExactU32, + /// One unsigned, guest-pointer-width `size_t` scalar. + /// + /// The physical channel word remains i64. Consumers reinterpret its bits + /// as u64, then reject values that do not fit the active Wasm target. + ProcessSize, + /// One unsigned guest process address that remains in process space. + /// + /// Transfer descriptors replace caller pointers with kernel allocation + /// addresses; this kind is reserved for raw process addresses that Rust + /// still interprets after host planning. + ProcessAddress, + /// One complete signed 64-bit scalar. + I64, + /// Low unsigned word of one split signed-i64 scalar. + SplitI64LowU32, + /// High signed word of one split signed-i64 scalar. + SplitI64HighI32, +} + +impl ChannelScalarKind { + pub const fn abi_name(self) -> &'static str { + match self { + Self::I32 => "i32", + Self::U32 => "u32", + Self::ExactU32 => "exact-u32", + Self::ProcessSize => "process-size", + Self::ProcessAddress => "process-address", + Self::I64 => "i64", + Self::SplitI64LowU32 => "split-i64-low-u32", + Self::SplitI64HighI32 => "split-i64-high-i32", + } + } +} + +/// Interpretation of a successful syscall-channel result. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChannelResultKind { + /// The default signed-i32 syscall result widened into the i64 channel. + I32, + /// An exact signed-i64 result that must not pass through JavaScript Number. + I64, + /// A process address that the host must validate for the caller's width. + ProcessAddress, +} + +impl ChannelResultKind { + pub const fn abi_name(self) -> &'static str { + match self { + Self::I32 => "i32", + Self::I64 => "i64", + Self::ProcessAddress => "process-address", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ChannelScalarArgument { + pub index: u8, + pub kind: ChannelScalarKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ChannelScalarSyscall { + pub syscall_number: u32, + /// Suffix of the target musl `__NR_*` macro. + pub musl_name: &'static str, + pub arguments: &'static [ChannelScalarArgument], + pub result: ChannelResultKind, +} + +const SEEK_ARGUMENTS: &[ChannelScalarArgument] = &[ + ChannelScalarArgument { + index: 1, + kind: ChannelScalarKind::SplitI64LowU32, + }, + ChannelScalarArgument { + index: 2, + kind: ChannelScalarKind::SplitI64HighI32, + }, +]; +const MMAP_ARGUMENTS: &[ChannelScalarArgument] = &[ + ChannelScalarArgument { + index: 0, + kind: ChannelScalarKind::ProcessAddress, + }, + ChannelScalarArgument { + index: 1, + kind: ChannelScalarKind::ProcessSize, + }, + ChannelScalarArgument { + index: 5, + kind: ChannelScalarKind::I64, + }, +]; +const PROCESS_ADDRESS_ARGUMENT_0: &[ChannelScalarArgument] = &[ChannelScalarArgument { + index: 0, + kind: ChannelScalarKind::ProcessAddress, +}]; +const PROCESS_ADDRESS_AND_SIZE_ARGUMENTS_0_1: &[ChannelScalarArgument] = &[ + ChannelScalarArgument { + index: 0, + kind: ChannelScalarKind::ProcessAddress, + }, + ChannelScalarArgument { + index: 1, + kind: ChannelScalarKind::ProcessSize, + }, +]; +const PROCESS_SIZE_ARGUMENT_0: &[ChannelScalarArgument] = &[ChannelScalarArgument { + index: 0, + kind: ChannelScalarKind::ProcessSize, +}]; +const PROCESS_SIZE_ARGUMENT_1: &[ChannelScalarArgument] = &[ChannelScalarArgument { + index: 1, + kind: ChannelScalarKind::ProcessSize, +}]; +const PROCESS_SIZE_ARGUMENT_2: &[ChannelScalarArgument] = &[ChannelScalarArgument { + index: 2, + kind: ChannelScalarKind::ProcessSize, +}]; +const PROCESS_SIZE_ARGUMENT_3: &[ChannelScalarArgument] = &[ChannelScalarArgument { + index: 3, + kind: ChannelScalarKind::ProcessSize, +}]; +const PROCESS_SIZE_ARGUMENT_5: &[ChannelScalarArgument] = &[ChannelScalarArgument { + index: 5, + kind: ChannelScalarKind::ProcessSize, +}]; +const U32_ARGUMENT_2: &[ChannelScalarArgument] = &[ChannelScalarArgument { + index: 2, + kind: ChannelScalarKind::U32, +}]; +const U32_ARGUMENT_4: &[ChannelScalarArgument] = &[ChannelScalarArgument { + index: 4, + kind: ChannelScalarKind::U32, +}]; +const PREAD_ARGUMENTS: &[ChannelScalarArgument] = &[ + ChannelScalarArgument { + index: 2, + kind: ChannelScalarKind::ProcessSize, + }, + ChannelScalarArgument { + index: 3, + kind: ChannelScalarKind::I64, + }, +]; +const PWRITE_ARGUMENTS: &[ChannelScalarArgument] = PREAD_ARGUMENTS; +const FTRUNCATE_ARGUMENTS: &[ChannelScalarArgument] = &[ChannelScalarArgument { + index: 1, + kind: ChannelScalarKind::I64, +}]; +const TRUNCATE_ARGUMENTS: &[ChannelScalarArgument] = FTRUNCATE_ARGUMENTS; +const LLSEEK_ARGUMENTS: &[ChannelScalarArgument] = &[ + ChannelScalarArgument { + index: 1, + kind: ChannelScalarKind::SplitI64HighI32, + }, + ChannelScalarArgument { + index: 2, + kind: ChannelScalarKind::SplitI64LowU32, + }, +]; +const MREMAP_ARGUMENTS: &[ChannelScalarArgument] = &[ + ChannelScalarArgument { + index: 0, + kind: ChannelScalarKind::ProcessAddress, + }, + ChannelScalarArgument { + index: 1, + kind: ChannelScalarKind::ProcessSize, + }, + ChannelScalarArgument { + index: 2, + kind: ChannelScalarKind::ProcessSize, + }, +]; +const SCHED_AFFINITY_ARGUMENTS: &[ChannelScalarArgument] = &[ChannelScalarArgument { + index: 1, + // Linux's raw sched_{get,set}affinity ABI uses unsigned int here even + // though libc exposes a size_t wrapper. Preserve the raw syscall contract + // instead of widening it with the guest pointer width. + kind: ChannelScalarKind::U32, +}]; +const SENDTO_ARGUMENTS: &[ChannelScalarArgument] = &[ + ChannelScalarArgument { + index: 2, + kind: ChannelScalarKind::ProcessSize, + }, + ChannelScalarArgument { + index: 5, + kind: ChannelScalarKind::U32, + }, +]; +const PPOLL_ARGUMENTS: &[ChannelScalarArgument] = &[ + ChannelScalarArgument { + index: 1, + kind: ChannelScalarKind::ProcessSize, + }, + ChannelScalarArgument { + index: 4, + kind: ChannelScalarKind::ProcessSize, + }, +]; +const READAHEAD_ARGUMENTS: &[ChannelScalarArgument] = &[ + ChannelScalarArgument { + index: 1, + kind: ChannelScalarKind::I64, + }, + ChannelScalarArgument { + index: 2, + kind: ChannelScalarKind::ProcessSize, + }, +]; +const POSITIONED_VECTOR_ARGUMENTS: &[ChannelScalarArgument] = &[ + ChannelScalarArgument { + index: 3, + kind: ChannelScalarKind::SplitI64LowU32, + }, + ChannelScalarArgument { + index: 4, + kind: ChannelScalarKind::SplitI64HighI32, + }, +]; +const FALLOCATE_ARGUMENTS: &[ChannelScalarArgument] = &[ + ChannelScalarArgument { + index: 2, + kind: ChannelScalarKind::I64, + }, + ChannelScalarArgument { + index: 3, + kind: ChannelScalarKind::I64, + }, +]; +const MSGRCV_ARGUMENTS: &[ChannelScalarArgument] = &[ + ChannelScalarArgument { + index: 2, + kind: ChannelScalarKind::ProcessSize, + }, + ChannelScalarArgument { + index: 3, + kind: ChannelScalarKind::I64, + }, +]; +const COPY_FILE_RANGE_ARGUMENTS: &[ChannelScalarArgument] = &[ChannelScalarArgument { + index: 4, + kind: ChannelScalarKind::ProcessSize, +}]; +const SENDFILE_ARGUMENTS: &[ChannelScalarArgument] = &[ChannelScalarArgument { + index: 3, + kind: ChannelScalarKind::ProcessSize, +}]; +const SIGNAL_ARGUMENTS: &[ChannelScalarArgument] = &[ChannelScalarArgument { + index: 1, + kind: ChannelScalarKind::ExactU32, +}]; +const GET_ROBUST_LIST_ARGUMENTS: &[ChannelScalarArgument] = &[ + ChannelScalarArgument { + index: 1, + kind: ChannelScalarKind::ProcessAddress, + }, + ChannelScalarArgument { + index: 2, + kind: ChannelScalarKind::ProcessAddress, + }, +]; +const SIGNALFD_ARGUMENTS: &[ChannelScalarArgument] = &[ChannelScalarArgument { + index: 2, + kind: ChannelScalarKind::ProcessSize, +}]; +const SHMGET_ARGUMENTS: &[ChannelScalarArgument] = &[ChannelScalarArgument { + index: 1, + kind: ChannelScalarKind::ProcessSize, +}]; + +/// Every syscall whose argument or successful-result interpretation differs +/// from the channel's default signed-i32 contract. +/// +/// Keep this sorted by syscall number. The generated C header checks each +/// number against both musl target headers when their glue is compiled. +pub const SYSCALLS: &[ChannelScalarSyscall] = &[ + ChannelScalarSyscall { + syscall_number: Syscall::Read as u32, + musl_name: "read", + arguments: PROCESS_SIZE_ARGUMENT_2, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Write as u32, + musl_name: "write", + arguments: PROCESS_SIZE_ARGUMENT_2, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Seek as u32, + musl_name: "lseek", + arguments: SEEK_ARGUMENTS, + result: ChannelResultKind::I64, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Readlink as u32, + musl_name: "readlink", + arguments: PROCESS_SIZE_ARGUMENT_2, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Getcwd as u32, + musl_name: "getcwd", + arguments: PROCESS_SIZE_ARGUMENT_1, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Readdir as u32, + musl_name: "readdir", + arguments: PROCESS_SIZE_ARGUMENT_3, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: Syscall::GetEnv as u32, + musl_name: "getenv", + arguments: PROCESS_SIZE_ARGUMENT_2, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Mmap as u32, + musl_name: "mmap", + arguments: MMAP_ARGUMENTS, + result: ChannelResultKind::ProcessAddress, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Munmap as u32, + musl_name: "munmap", + arguments: PROCESS_ADDRESS_AND_SIZE_ARGUMENTS_0_1, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Brk as u32, + musl_name: "brk", + arguments: PROCESS_ADDRESS_ARGUMENT_0, + result: ChannelResultKind::ProcessAddress, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Mprotect as u32, + musl_name: "mprotect", + arguments: PROCESS_ADDRESS_AND_SIZE_ARGUMENTS_0_1, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Bind as u32, + musl_name: "bind", + arguments: U32_ARGUMENT_2, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Connect as u32, + musl_name: "connect", + arguments: U32_ARGUMENT_2, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Send as u32, + musl_name: "send", + arguments: PROCESS_SIZE_ARGUMENT_2, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Recv as u32, + musl_name: "recv", + arguments: PROCESS_SIZE_ARGUMENT_2, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Setsockopt as u32, + musl_name: "setsockopt", + arguments: U32_ARGUMENT_4, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Poll as u32, + musl_name: "poll", + arguments: PROCESS_SIZE_ARGUMENT_1, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Sendto as u32, + musl_name: "sendto", + arguments: SENDTO_ARGUMENTS, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Recvfrom as u32, + musl_name: "recvfrom", + arguments: PROCESS_SIZE_ARGUMENT_2, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Pread as u32, + musl_name: "pread", + arguments: PREAD_ARGUMENTS, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Pwrite as u32, + musl_name: "pwrite", + arguments: PWRITE_ARGUMENTS, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Time as u32, + musl_name: "time", + arguments: &[], + result: ChannelResultKind::I64, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Signal as u32, + musl_name: "signal", + arguments: SIGNAL_ARGUMENTS, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Ftruncate as u32, + musl_name: "ftruncate", + arguments: FTRUNCATE_ARGUMENTS, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Truncate as u32, + musl_name: "truncate", + arguments: TRUNCATE_ARGUMENTS, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Readlinkat as u32, + musl_name: "readlinkat", + arguments: PROCESS_SIZE_ARGUMENT_3, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Realpath as u32, + musl_name: "realpath", + arguments: PROCESS_SIZE_ARGUMENT_2, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_LLSEEK, + musl_name: "_llseek", + arguments: LLSEEK_ARGUMENTS, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_GETRANDOM, + musl_name: "getrandom", + arguments: PROCESS_SIZE_ARGUMENT_1, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Getdents64 as u32, + musl_name: "getdents64", + arguments: PROCESS_SIZE_ARGUMENT_2, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Mremap as u32, + musl_name: "mremap", + arguments: MREMAP_ARGUMENTS, + result: ChannelResultKind::ProcessAddress, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Madvise as u32, + musl_name: "madvise", + arguments: PROCESS_ADDRESS_AND_SIZE_ARGUMENTS_0_1, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: Syscall::Setgroups as u32, + musl_name: "setgroups", + arguments: PROCESS_SIZE_ARGUMENT_0, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_FUTEX, + musl_name: "futex", + arguments: PROCESS_ADDRESS_ARGUMENT_0, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_SET_TID_ADDRESS, + musl_name: "set_tid_address", + arguments: PROCESS_ADDRESS_ARGUMENT_0, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_SCHED_SETAFFINITY, + musl_name: "sched_setaffinity", + arguments: SCHED_AFFINITY_ARGUMENTS, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_SCHED_GETAFFINITY, + musl_name: "sched_getaffinity", + arguments: SCHED_AFFINITY_ARGUMENTS, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_EPOLL_PWAIT, + musl_name: "epoll_pwait", + arguments: PROCESS_SIZE_ARGUMENT_5, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_SIGNALFD4, + musl_name: "signalfd4", + arguments: SIGNALFD_ARGUMENTS, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_PPOLL, + musl_name: "ppoll", + arguments: PPOLL_ARGUMENTS, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_SET_ROBUST_LIST, + musl_name: "set_robust_list", + arguments: PROCESS_ADDRESS_AND_SIZE_ARGUMENTS_0_1, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_GET_ROBUST_LIST, + musl_name: "get_robust_list", + arguments: GET_ROBUST_LIST_ARGUMENTS, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_MSYNC, + musl_name: "msync", + arguments: PROCESS_ADDRESS_AND_SIZE_ARGUMENTS_0_1, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_MLOCK, + musl_name: "mlock", + arguments: PROCESS_ADDRESS_AND_SIZE_ARGUMENTS_0_1, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_MLOCK2, + musl_name: "mlock2", + arguments: PROCESS_ADDRESS_AND_SIZE_ARGUMENTS_0_1, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_MUNLOCK, + musl_name: "munlock", + arguments: PROCESS_ADDRESS_AND_SIZE_ARGUMENTS_0_1, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_COPY_FILE_RANGE, + musl_name: "copy_file_range", + arguments: COPY_FILE_RANGE_ARGUMENTS, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_SPLICE, + musl_name: "splice", + arguments: COPY_FILE_RANGE_ARGUMENTS, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_READAHEAD, + musl_name: "readahead", + arguments: READAHEAD_ARGUMENTS, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_SENDFILE, + musl_name: "sendfile", + arguments: SENDFILE_ARGUMENTS, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_PREADV, + musl_name: "preadv", + arguments: POSITIONED_VECTOR_ARGUMENTS, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_PWRITEV, + musl_name: "pwritev", + arguments: POSITIONED_VECTOR_ARGUMENTS, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_PREADV2, + musl_name: "preadv2", + arguments: POSITIONED_VECTOR_ARGUMENTS, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_PWRITEV2, + musl_name: "pwritev2", + arguments: POSITIONED_VECTOR_ARGUMENTS, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_FALLOCATE, + musl_name: "fallocate", + arguments: FALLOCATE_ARGUMENTS, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_MQ_TIMEDSEND, + musl_name: "mq_timedsend", + arguments: PROCESS_SIZE_ARGUMENT_2, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_MQ_TIMEDRECEIVE, + musl_name: "mq_timedreceive", + arguments: PROCESS_SIZE_ARGUMENT_2, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_MSGRCV, + musl_name: "msgrcv", + arguments: MSGRCV_ARGUMENTS, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_MSGSND, + musl_name: "msgsnd", + arguments: PROCESS_SIZE_ARGUMENT_2, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_SEMOP, + musl_name: "semop", + arguments: PROCESS_SIZE_ARGUMENT_2, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_SHMGET, + musl_name: "shmget", + arguments: SHMGET_ARGUMENTS, + result: ChannelResultKind::I32, + }, + ChannelScalarSyscall { + syscall_number: extended_syscalls::SYS_SIGNALFD, + musl_name: "signalfd", + arguments: SIGNALFD_ARGUMENTS, + result: ChannelResultKind::I32, + }, +]; + +pub fn syscall_contract(syscall_number: u32) -> Option<&'static ChannelScalarSyscall> { + SYSCALLS + .binary_search_by_key(&syscall_number, |contract| contract.syscall_number) + .ok() + .map(|index| &SYSCALLS[index]) +} + +pub fn argument_kind(syscall_number: u32, index: usize) -> ChannelScalarKind { + let Ok(index) = u8::try_from(index) else { + return ChannelScalarKind::I32; + }; + syscall_contract(syscall_number) + .and_then(|contract| { + contract + .arguments + .iter() + .find(|argument| argument.index == index) + }) + .map(|argument| argument.kind) + .unwrap_or(ChannelScalarKind::I32) +} + +pub fn result_kind(syscall_number: u32) -> ChannelResultKind { + syscall_contract(syscall_number) + .map(|contract| contract.result) + .unwrap_or(ChannelResultKind::I32) +} + +#[inline] +pub fn i64_argument(syscall_number: u32, args: &[i64; 6], index: usize) -> i64 { + assert_eq!( + argument_kind(syscall_number, index), + ChannelScalarKind::I64, + "undeclared exact i64 channel scalar for syscall {syscall_number} slot {index}", + ); + args[index] +} + +#[inline] +pub fn u32_argument(syscall_number: u32, args: &[i64; 6], index: usize) -> u32 { + assert_eq!( + argument_kind(syscall_number, index), + ChannelScalarKind::U32, + "undeclared u32 channel scalar for syscall {syscall_number} slot {index}", + ); + args[index] as u32 +} + +#[inline] +pub fn exact_u32_argument(syscall_number: u32, args: &[i64; 6], index: usize) -> Option { + assert_eq!( + argument_kind(syscall_number, index), + ChannelScalarKind::ExactU32, + "undeclared exact-u32 channel scalar for syscall {syscall_number} slot {index}", + ); + u32::try_from(args[index]).ok() +} + +pub const fn process_size_for_pointer_bits(raw: u64, pointer_bits: u32) -> Option { + match pointer_bits { + 32 => { + let low = raw as u32; + let zero_extended = low as u64; + let sign_extended = (low as i32 as i64) as u64; + if raw == zero_extended || raw == sign_extended { + Some(zero_extended) + } else { + None + } + } + 64 => Some(raw), + _ => None, + } +} + +/// Maximum successful byte count representable by the channel's signed-i32 +/// syscall result domain. +pub const MAX_REPORTABLE_TRANSFER_BYTES: u64 = + crate::platform_limits::MAX_REPORTABLE_TRANSFER_BYTES as u64; + +/// These transfer syscalls permit a short successful operation. Bound the +/// requested work before effects so the returned count can never wrap into an +/// errno-looking i32 afterward. +pub const fn reportable_transfer_count(requested: u64) -> u64 { + if requested > MAX_REPORTABLE_TRANSFER_BYTES { + MAX_REPORTABLE_TRANSFER_BYTES + } else { + requested + } +} + +#[inline] +pub fn process_size_argument(syscall_number: u32, args: &[i64; 6], index: usize) -> u64 { + assert_eq!( + argument_kind(syscall_number, index), + ChannelScalarKind::ProcessSize, + "undeclared process-size channel scalar for syscall {syscall_number} slot {index}", + ); + args[index] as u64 +} + +#[inline] +pub fn process_address_argument(syscall_number: u32, args: &[i64; 6], index: usize) -> i64 { + assert_eq!( + argument_kind(syscall_number, index), + ChannelScalarKind::ProcessAddress, + "undeclared process-address channel scalar for syscall {syscall_number} slot {index}", + ); + args[index] +} + +#[inline] +pub fn split_i64_low_argument(syscall_number: u32, args: &[i64; 6], index: usize) -> u32 { + assert_eq!( + argument_kind(syscall_number, index), + ChannelScalarKind::SplitI64LowU32, + "undeclared low split-i64 channel scalar for syscall {syscall_number} slot {index}", + ); + args[index] as u32 +} + +#[inline] +pub fn split_i64_high_argument(syscall_number: u32, args: &[i64; 6], index: usize) -> i32 { + assert_eq!( + argument_kind(syscall_number, index), + ChannelScalarKind::SplitI64HighI32, + "undeclared high split-i64 channel scalar for syscall {syscall_number} slot {index}", + ); + args[index] as i32 +} + +#[cfg(test)] +mod tests { + extern crate std; + + use super::*; + + fn is_named_abi_syscall(number: u32) -> bool { + Syscall::from_u32(number).is_some() + || extended_syscalls::SYSCALLS + .iter() + .any(|syscall| syscall.number == number) + } + + #[test] + fn contracts_are_sorted_unique_named_and_channel_bounded() { + let mut previous: Option<&ChannelScalarSyscall> = None; + for contract in SYSCALLS { + if let Some(previous) = previous { + assert!( + previous.syscall_number < contract.syscall_number, + "channel scalar syscalls must be sorted and unique: \ + {} ({}) precedes {} ({})", + previous.musl_name, + previous.syscall_number, + contract.musl_name, + contract.syscall_number, + ); + } + assert!(is_named_abi_syscall(contract.syscall_number)); + assert!(!contract.musl_name.is_empty()); + + let mut previous_index = None; + for argument in contract.arguments { + if let Some(previous_index) = previous_index { + assert!( + previous_index < argument.index, + "scalar argument slots must be sorted and unique" + ); + } + assert!((argument.index as usize) < crate::channel::ARGS_COUNT); + assert_ne!(argument.kind, ChannelScalarKind::I32); + previous_index = Some(argument.index); + } + previous = Some(contract); + } + } + + #[test] + fn lookup_defaults_to_the_signed_i32_contract() { + assert_eq!( + argument_kind(Syscall::Getpid as u32, 0), + ChannelScalarKind::I32 + ); + assert_eq!(result_kind(Syscall::Getpid as u32), ChannelResultKind::I32); + assert_eq!( + argument_kind(Syscall::Seek as u32, 1), + ChannelScalarKind::SplitI64LowU32 + ); + assert_eq!(result_kind(Syscall::Seek as u32), ChannelResultKind::I64); + } + + #[test] + fn native_width_and_exact_u32_scalars_never_alias_high_bits() { + let four_gib_plus_page = 0x1_0000_1000u64; + assert_eq!(process_size_for_pointer_bits(four_gib_plus_page, 32), None); + assert_eq!( + process_size_for_pointer_bits(0x0000_0000_8000_0000, 32), + Some(0x8000_0000) + ); + assert_eq!( + process_size_for_pointer_bits(0xffff_ffff_8000_0000, 32), + Some(0x8000_0000) + ); + assert_eq!( + process_size_for_pointer_bits(0xffff_fffe_8000_0000, 32), + None + ); + assert_eq!( + process_size_for_pointer_bits(four_gib_plus_page, 64), + Some(four_gib_plus_page) + ); + assert_eq!( + process_size_for_pointer_bits(1u64 << 63, 64), + Some(1u64 << 63) + ); + assert_eq!(process_size_for_pointer_bits(u64::MAX, 64), Some(u64::MAX)); + + let mut args = [0i64; 6]; + args[1] = 0x1_0000_0001; + assert_eq!(exact_u32_argument(Syscall::Signal as u32, &args, 1), None); + args[1] = u32::MAX as i64; + assert_eq!( + exact_u32_argument(Syscall::Signal as u32, &args, 1), + Some(u32::MAX) + ); + assert_eq!( + reportable_transfer_count(MAX_REPORTABLE_TRANSFER_BYTES), + MAX_REPORTABLE_TRANSFER_BYTES + ); + assert_eq!( + reportable_transfer_count(MAX_REPORTABLE_TRANSFER_BYTES + 1), + MAX_REPORTABLE_TRANSFER_BYTES + ); + } + + #[test] + fn typed_readers_fail_closed_for_mismatched_kinds_in_release_too() { + let args = [0; 6]; + assert!(std::panic::catch_unwind(|| i64_argument(28, &args, 0)).is_err()); + assert!(std::panic::catch_unwind(|| u32_argument(28, &args, 0)).is_err()); + assert!(std::panic::catch_unwind(|| exact_u32_argument(28, &args, 0)).is_err()); + assert!(std::panic::catch_unwind(|| process_size_argument(28, &args, 0)).is_err()); + assert!(std::panic::catch_unwind(|| process_address_argument(28, &args, 0)).is_err()); + assert!(std::panic::catch_unwind(|| split_i64_low_argument(28, &args, 0)).is_err()); + assert!(std::panic::catch_unwind(|| split_i64_high_argument(28, &args, 0)).is_err()); + } +} diff --git a/crates/shared/src/host_abi.rs b/crates/shared/src/host_abi.rs index c8256dcaf7..bcd17ede3b 100644 --- a/crates/shared/src/host_abi.rs +++ b/crates/shared/src/host_abi.rs @@ -10,7 +10,8 @@ use core::mem::size_of; use crate::abi::extended_syscalls as extra_syscalls; use crate::process_layout; use crate::{ - SCHED_AFFINITY_MASK_SIZE, Syscall, WASM_RUSAGE_WIRE_SIZE, WasmTimespec, kernel_scratch_wire, + kernel_scratch_wire, platform_limits, Syscall, WasmTimespec, SCHED_AFFINITY_MASK_SIZE, + WASM_RUSAGE_WIRE_SIZE, }; /// Private channel argument used to carry the calling process's pointer width. @@ -30,8 +31,9 @@ pub enum SyscallArgDirection { /// How the host computes the byte length for a pointer argument. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SyscallArgSize { - /// A nul-terminated string in process memory. - CString, + /// A nul-terminated string in process memory with an explicit complete + /// scan/copy ceiling, including the NUL. + CString { max_bytes: u32, too_long_errno: u32 }, /// Byte length comes from another syscall argument. Arg { arg_index: u8, @@ -74,7 +76,22 @@ pub struct SyscallArgDescriptor { macro_rules! cstring { () => { - SyscallArgSize::CString + SyscallArgSize::CString { + max_bytes: platform_limits::PATH_MAX_BYTES as u32, + too_long_errno: crate::Errno::ENAMETOOLONG as u32, + } + }; + ($max_bytes:expr) => { + SyscallArgSize::CString { + max_bytes: $max_bytes as u32, + too_long_errno: crate::Errno::ENAMETOOLONG as u32, + } + }; + ($max_bytes:expr, $errno:ident) => { + SyscallArgSize::CString { + max_bytes: $max_bytes as u32, + too_long_errno: crate::Errno::$errno as u32, + } }; } @@ -268,28 +285,49 @@ pub const SYSCALL_ARG_DESCRIPTORS: &[SyscallArgDescriptor] = &[ entry!( Syscall::GetEnv as u32, [ - desc!(0, In, cstring!(), required), + desc!( + 0, + In, + cstring!(platform_limits::PROCESS_METADATA_ENTRY_MAX_BYTES + 1, E2BIG), + required + ), desc!(1, Out, arg!(2), required), ] ), entry!( Syscall::SetEnv as u32, [ - desc!(0, In, cstring!(), required), - desc!(1, In, cstring!(), required), + desc!( + 0, + In, + cstring!(platform_limits::PROCESS_METADATA_ENTRY_MAX_BYTES + 1, E2BIG), + required + ), + desc!( + 1, + In, + cstring!(platform_limits::PROCESS_METADATA_ENTRY_MAX_BYTES + 1, E2BIG), + required + ), ] ), entry!( Syscall::UnsetEnv as u32, - [desc!(0, In, cstring!(), required)] + [desc!( + 0, + In, + cstring!(platform_limits::PROCESS_METADATA_ENTRY_MAX_BYTES + 1, E2BIG), + required + )] ), entry!(Syscall::Bind as u32, [desc!(1, In, arg!(2), required)]), entry!( Syscall::Accept as u32, [ - // Linux permits omitting the peer address only as a nullable - // address/length pair. A non-null address still requires the - // length pointer because it defines the staged output capacity. + // When the address is null, POSIX makes the length pointer + // ignored. The host canonicalizes that absent pair to two nulls; + // a non-null address still requires the length pointer because it + // defines the staged output capacity. desc!(1, Out, deref!(2), nullable), desc!(2, InOut, fixed!(4), nullable), ] @@ -327,8 +365,9 @@ pub const SYSCALL_ARG_DESCRIPTORS: &[SyscallArgDescriptor] = &[ Syscall::Recvfrom as u32, [ desc!(1, Out, arg!(2), required), - // As with accept(2), the source address and its length are an - // optional pair, while a supplied address requires its length. + // As with accept(2), a null source-address pointer makes the + // caller's length pointer ignored; a supplied address requires + // its length. desc!(4, Out, deref!(5), nullable), desc!(5, InOut, fixed!(4), nullable), ] @@ -560,7 +599,12 @@ pub const SYSCALL_ARG_DESCRIPTORS: &[SyscallArgDescriptor] = &[ entry!( Syscall::Getaddrinfo as u32, [ - desc!(0, In, cstring!(), required), + desc!( + 0, + In, + cstring!(platform_limits::HOST_NAME_MAX_BYTES), + required + ), // WHY: musl's lookup_name.c supplies exactly one four-byte IPv4 // result. A larger copy-out contract overwrites caller-owned bytes // even though the kernel produces only this meaningful address. @@ -741,7 +785,12 @@ pub const SYSCALL_ARG_DESCRIPTORS: &[SyscallArgDescriptor] = &[ ), entry!( extra_syscalls::SYS_MEMFD_CREATE, - [desc!(0, In, cstring!(), required)] + [desc!( + 0, + In, + cstring!(platform_limits::MEMFD_NAME_MAX_BYTES, EINVAL), + required + )] ), entry!( extra_syscalls::SYS_STATX, @@ -851,7 +900,7 @@ pub const SYSCALL_ARG_DESCRIPTORS: &[SyscallArgDescriptor] = &[ entry!( extra_syscalls::SYS_MQ_OPEN, [ - desc!(0, In, cstring!(), required), + desc!(0, In, cstring!(platform_limits::NAME_MAX_BYTES), required), desc!( 3, In, @@ -865,7 +914,12 @@ pub const SYSCALL_ARG_DESCRIPTORS: &[SyscallArgDescriptor] = &[ ), entry!( extra_syscalls::SYS_MQ_UNLINK, - [desc!(0, In, cstring!(), required)] + [desc!( + 0, + In, + cstring!(platform_limits::NAME_MAX_BYTES), + required + )] ), entry!( extra_syscalls::SYS_MQ_TIMEDSEND, @@ -955,6 +1009,7 @@ mod tests { use self::std::vec::Vec; use super::*; + use crate::channel_scalar::{self, ChannelScalarKind}; #[test] fn syscall_arg_descriptors_are_sorted_and_unique() { @@ -970,6 +1025,79 @@ mod tests { } } + #[test] + fn variable_extent_descriptors_have_an_explicit_scalar_domain() { + for descriptor in SYSCALL_ARG_DESCRIPTORS { + for pointer in descriptor.args { + let SyscallArgSize::Arg { arg_index, .. } = pointer.size else { + continue; + }; + let kind = + channel_scalar::argument_kind(descriptor.syscall_number, arg_index.into()); + assert!( + matches!( + kind, + ChannelScalarKind::ProcessSize | ChannelScalarKind::U32 + ), + "syscall {} arg {} sizes pointer arg {} but has scalar domain {:?}", + descriptor.syscall_number, + arg_index, + pointer.arg_index, + kind, + ); + } + } + + for (syscall, index) in [ + (Syscall::Bind as u32, 2), + (Syscall::Connect as u32, 2), + (Syscall::Setsockopt as u32, 4), + (Syscall::Sendto as u32, 5), + ] { + assert_eq!( + channel_scalar::argument_kind(syscall, index), + ChannelScalarKind::U32, + "socklen_t extent must stay an unsigned 32-bit scalar" + ); + } + } + + #[test] + fn cstring_bounds_include_the_terminator_without_lowering_content_limits() { + let metadata_bound = (platform_limits::PROCESS_METADATA_ENTRY_MAX_BYTES + 1) as u32; + for (syscall, argument_indexes) in [ + (Syscall::GetEnv as u32, &[0u8][..]), + (Syscall::SetEnv as u32, &[0u8, 1][..]), + (Syscall::UnsetEnv as u32, &[0u8][..]), + ] { + for argument_index in argument_indexes { + let descriptor = find(syscall) + .args + .iter() + .find(|descriptor| descriptor.arg_index == *argument_index) + .expect("missing process-metadata string descriptor"); + assert_eq!( + descriptor.size, + cstring!(metadata_bound, E2BIG), + "metadata content cap excludes its required NUL" + ); + } + } + + for syscall in [extra_syscalls::SYS_MQ_OPEN, extra_syscalls::SYS_MQ_UNLINK] { + assert_eq!( + find(syscall).args[0].size, + cstring!(platform_limits::NAME_MAX_BYTES), + "NAME_MAX_BYTES already includes the terminating NUL" + ); + } + assert_eq!( + find(Syscall::Open as u32).args[0].size, + cstring!(), + "PATH_MAX is the complete C-string buffer size" + ); + } + #[test] fn pointer_nullability_is_explicit_and_exhaustive() { let mut actual_nullable = Vec::new(); @@ -1006,7 +1134,12 @@ mod tests { entry.syscall_number, arg.arg_index ); } - SyscallArgSize::CString | SyscallArgSize::Deref { .. } => {} + SyscallArgSize::CString { max_bytes, .. } => assert_ne!( + max_bytes, 0, + "syscall {} arg {} has an empty C-string bound", + entry.syscall_number, arg.arg_index + ), + SyscallArgSize::Deref { .. } => {} } if arg.nullable { actual_nullable.push((entry.syscall_number, arg.arg_index)); @@ -1095,15 +1228,15 @@ mod tests { let lchown = find(extra_syscalls::SYS_LCHOWN).args[0]; assert_eq!(lchown.arg_index, 0); assert_eq!(lchown.direction, SyscallArgDirection::In); - assert_eq!(lchown.size, SyscallArgSize::CString); + assert_eq!(lchown.size, cstring!()); assert!(!lchown.nullable); let utimensat_path = find(Syscall::Utimensat as u32).args[0]; - assert_eq!(utimensat_path.size, SyscallArgSize::CString); + assert_eq!(utimensat_path.size, cstring!()); assert!(utimensat_path.nullable); let pathconf = find(Syscall::Pathconf as u32).args; - assert_eq!(pathconf[0].size, SyscallArgSize::CString); + assert_eq!(pathconf[0].size, cstring!()); assert!(!pathconf[0].nullable); assert_eq!(pathconf[1].arg_index, 2); assert_eq!(pathconf[1].direction, SyscallArgDirection::Out); @@ -1211,7 +1344,10 @@ mod tests { let memfd_name = find(extra_syscalls::SYS_MEMFD_CREATE).args[0]; assert_eq!(memfd_name.arg_index, 0); assert_eq!(memfd_name.direction, SyscallArgDirection::In); - assert_eq!(memfd_name.size, SyscallArgSize::CString); + assert_eq!( + memfd_name.size, + cstring!(platform_limits::MEMFD_NAME_MAX_BYTES, EINVAL), + ); assert!(memfd_name.required); for syscall in [ @@ -1239,7 +1375,7 @@ mod tests { for (path, arg_index) in renameat2.iter().zip([1, 3]) { assert_eq!(path.arg_index, arg_index); assert_eq!(path.direction, SyscallArgDirection::In); - assert_eq!(path.size, SyscallArgSize::CString); + assert_eq!(path.size, cstring!()); assert!(!path.nullable); } diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index d7fb57676a..a10f3563e1 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -1,5 +1,6 @@ #![no_std] +pub mod channel_scalar; pub mod host_abi; pub mod ioctl_contract; pub mod process_layout; @@ -90,11 +91,23 @@ pub mod process_layout; /// references, complete exceptions, mutable reference globals, and mutable /// tables are serialized as versioned process-owned recipes and rebuilt /// with fresh instance-local identities before continuation replay. -/// Variable-sized host writes into reusable kernel spawn storage use a -/// tokenized begin/copy/commit transaction, and System V IPC control -/// transfers plus caller-native signal-stack, interval-timer, POSIX -/// message-queue, filesystem-statistics, and system-information records -/// use required pointer-width-aware kernel structure sizes. +/// Variable-sized host writes into reusable kernel spawn storage and fresh +/// kernel-owned large-I/O storage use tokenized begin/copy/execute +/// transactions; vector I/O executes as one scalar operation, obsolete +/// raw scalar/vector I/O exports and the decomposed-write budget export are +/// removed, and positioned host I/O preserves exact signed-i64 offsets +/// without changing the shared cursor. Paired host append imports report +/// one atomic append's written prefix and exact ending offset before Rust +/// advances the shared cursor. System V IPC control transfers plus +/// caller-native signal-stack, interval-timer, POSIX message-queue, +/// filesystem-statistics, and system-information records use required +/// pointer-width-aware kernel structure sizes. Host-deferred retries retain +/// exact kernel targets through required token/release exports, and their +/// signal interruption policy requires exact target, deliverability, +/// descriptor-mode, and socket-timeout query exports. Each channel request +/// also carries generated, one-shot flags that distinguish `__syscall_cp` +/// from a plain syscall with the same number and defer signal delivery for +/// completions consumed outside libc's post-syscall trampoline. pub const ABI_VERSION: u32 = 43; /// Byte width of Kandelo's Linux-compatible kernel CPU-affinity mask. @@ -112,6 +125,25 @@ pub const SCHED_AFFINITY_MASK_SIZE: u32 = 4; pub mod platform_limits { pub const ARG_MAX_BYTES: usize = 4 * 1024 * 1024; pub const PATH_MAX_BYTES: usize = 4096; + /// Maximum component plus its terminating NUL in a caller C string. + pub const NAME_MAX_BYTES: usize = 256; + /// Maximum hostname plus its terminating NUL in a caller C string. + pub const HOST_NAME_MAX_BYTES: usize = 256; + /// Linux memfd name content is limited to 249 bytes, plus its NUL. + pub const MEMFD_NAME_MAX_BYTES: usize = 250; + /// Largest one-entry argv/environment transport admitted by the host. + /// + /// This is not POSIX ARG_MAX; complete argv+env representation remains + /// governed independently by ARG_MAX_BYTES. + pub const PROCESS_METADATA_ENTRY_MAX_BYTES: usize = 65_536; + pub const NGROUPS_MAX: usize = 32; + pub const SYSV_MSG_MAX_BYTES: usize = 8192; + /// Largest successful byte count representable by the signed-i32 channel + /// result without becoming indistinguishable from an errno return. + pub const MAX_REPORTABLE_TRANSFER_BYTES: usize = i32::MAX as usize; + /// Largest private host/kernel transfer allocation representable by the + /// u32 byte-length wire used by tokenized scratch reservations. + pub const MAX_TRANSFER_ALLOCATION_BYTES: usize = u32::MAX as usize; pub const IOV_MAX: usize = 1024; } @@ -1073,7 +1105,7 @@ pub mod mode { /// 8 48B arguments (6 × i64) /// 56 8B return value (i64) /// 64 4B errno (i32) -/// 68 4B request flags +/// 68 4B request flags (u32) /// 72 64KB data transfer buffer pub mod channel { use super::kernel_scratch_wire; @@ -1097,14 +1129,27 @@ pub mod channel { /// Byte offset of the errno field (i32). pub const ERRNO_OFFSET: usize = RETURN_OFFSET + RETURN_SIZE; pub const ERRNO_SIZE: usize = size_of::(); - /// Byte offset of host/process request flags (u32). + /// Byte offset of request flags written before the PENDING publication. pub const REQUEST_FLAGS_OFFSET: usize = ERRNO_OFFSET + ERRNO_SIZE; pub const REQUEST_FLAGS_SIZE: usize = size_of::(); + /// This request entered libc through `__syscall_cp`, not a plain + /// `__syscallN` wrapper for the same syscall number. + pub const REQUEST_FLAG_CANCELLATION_POINT: u32 = 1 << 0; + /// The cancellation-point request may be interrupted for pthread_cancel. + /// + /// A target in PTHREAD_CANCEL_DISABLE still publishes cancellation-point + /// identity, but omits this bit so the host records cancellation without + /// disturbing the already-blocked operation or resetting its deadline. + pub const REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED: u32 = 1 << 1; /// The request completion is consumed by process-worker JavaScript, not /// the libc channel trampoline. Caught signals must remain kernel-pending /// until an explicit guest checkpoint can invoke the handler after the /// owning host transition returns. - pub const REQUEST_FLAG_DEFER_SIGNAL_DELIVERY: u32 = 1 << 0; + pub const REQUEST_FLAG_DEFER_SIGNAL_DELIVERY: u32 = 1 << 2; + /// Every request flag understood by this ABI epoch. + pub const REQUEST_FLAGS_KNOWN_MASK: u32 = REQUEST_FLAG_CANCELLATION_POINT + | REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED + | REQUEST_FLAG_DEFER_SIGNAL_DELIVERY; /// Total header size before data buffer. pub const HEADER_SIZE: usize = REQUEST_FLAGS_OFFSET + REQUEST_FLAGS_SIZE; /// Byte offset of the data buffer region. @@ -1179,6 +1224,12 @@ mod channel_abi_tests { channel::DATA_OFFSET, channel::REQUEST_FLAGS_OFFSET + channel::REQUEST_FLAGS_SIZE, ); + assert_eq!( + channel::REQUEST_FLAGS_KNOWN_MASK, + channel::REQUEST_FLAG_CANCELLATION_POINT + | channel::REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED + | channel::REQUEST_FLAG_DEFER_SIGNAL_DELIVERY, + ); assert_eq!( channel::SIG_BASE + channel::SIG_AREA_SIZE, channel::DATA_OFFSET + channel::DATA_SIZE, @@ -1434,6 +1485,25 @@ pub mod kernel_scratch_wire { pub const FD_PAIR_BYTES: u32 = 8; pub const MQUEUE_NOTIFICATION_BYTES: u32 = 8; pub const SOCKLEN_BYTES: u32 = 4; + /// Complete generic native socket-address container accepted or produced + /// at the syscall boundary (`struct sockaddr_storage`). + pub const SOCKADDR_STORAGE_BYTES: u32 = 128; + /// Offset of `sockaddr_un.sun_path` after `sa_family_t`. + pub const SOCKADDR_UNIX_PATH_OFFSET_BYTES: u32 = 2; + /// Native `sockaddr_un.sun_path` field capacity. + pub const SOCKADDR_UNIX_PATH_BYTES: u32 = 108; + /// Native AF_UNIX structure capacity. + /// + /// WHY: generic staging must accept a full `sockaddr_storage`, while + /// Rust's family parser must still reject AF_UNIX names that do not fit + /// the concrete `sockaddr_un` structure. + pub const SOCKADDR_UNIX_BYTES: u32 = + SOCKADDR_UNIX_PATH_OFFSET_BYTES + SOCKADDR_UNIX_PATH_BYTES; + /// Largest value currently produced by getsockopt (`struct tcp_info`). + pub const SOCKET_OPTION_MAX_BYTES: u32 = 232; + /// Largest currently accepted setsockopt record (`group_source_req` on + /// wasm64). Individual option parsers still enforce their exact layouts. + pub const SOCKET_OPTION_INPUT_MAX_BYTES: u32 = 264; pub const PRCTL_NAME_BYTES: u32 = 16; pub const FCNTL_FLOCK_BYTES: u32 = size_of::() as u32; pub const SIGNAL_MASK_BYTES: u32 = size_of::() as u32; @@ -1441,7 +1511,7 @@ pub mod kernel_scratch_wire { #[cfg(test)] mod wait_abi_tests { - use super::{KERNEL_WAIT_RESULT_SIZE, KernelWaitResult, WASM_RUSAGE_WIRE_SIZE, WasmRusageWire}; + use super::{KernelWaitResult, WasmRusageWire, KERNEL_WAIT_RESULT_SIZE, WASM_RUSAGE_WIRE_SIZE}; use core::mem::{offset_of, size_of}; #[test] @@ -1594,8 +1664,8 @@ pub struct WasmStatfs { #[cfg(test)] mod native_wire_layout_tests { use super::{ - KernelCmsghdrWire, KernelIovecWire, KernelMsghdrWire, WasmEpollEvent, WasmFlock, - WasmSysvMessageHeader, kernel_scratch_wire, prctl, + kernel_scratch_wire, prctl, KernelCmsghdrWire, KernelIovecWire, KernelMsghdrWire, + WasmEpollEvent, WasmFlock, WasmSysvMessageHeader, }; use core::mem::{align_of, offset_of, size_of}; @@ -2761,7 +2831,10 @@ pub mod abi { pub const HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS: &[&str] = &[ "__abi_version", "kernel_alloc_scratch", + "kernel_blocking_retry_release", + "kernel_blocking_retry_token", "kernel_clear_process_metadata", + "kernel_commit_process_exit", "kernel_create_process", "kernel_create_process_with_stdio", "kernel_dequeue_signal", @@ -2771,6 +2844,7 @@ pub mod abi { "kernel_get_parent_pid", "kernel_get_process_exit_signal", "kernel_get_process_state", + "kernel_get_socket_timeout_ms", "kernel_handle_channel", "kernel_has_sa_nocldstop", "kernel_host_adapter_manifest_len", @@ -2779,11 +2853,13 @@ pub mod abi { "kernel_ipc_shmat_for_task", "kernel_ipc_shmdt_for_process", "kernel_ipc_shmdt_for_task", + "kernel_is_fd_nonblock", "kernel_mark_process_signaled", + "kernel_mq_descriptor_msgsize", "kernel_msqid_ds_bytes", + "kernel_pick_signal_target_tid", "kernel_pipe_has_readers", "kernel_posix_timer_fire", - "kernel_prepare_write_operation", "kernel_push_process_metadata_entry", "kernel_reap_exited_child", "kernel_remove_process", @@ -2800,6 +2876,13 @@ pub mod abi { "kernel_spawn_scratch_pointer", "kernel_spawn_scratch_retained_capacity", "kernel_thread_exit", + "kernel_thread_has_deliverable", + "kernel_transfer_channel_execute", + "kernel_transfer_io_execute", + "kernel_transfer_scratch_begin", + "kernel_transfer_scratch_cancel", + "kernel_transfer_scratch_capacity", + "kernel_transfer_scratch_pointer", "kernel_validate_task", "kernel_wait_child_poll", ]; @@ -2867,6 +2950,7 @@ pub mod abi { pub const SYS_SCHED_SETPARAM: u32 = 231; pub const SYS_SCHED_SETSCHEDULER: u32 = 233; pub const SYS_SCHED_RR_GET_INTERVAL: u32 = 236; + pub const SYS_SCHED_SETAFFINITY: u32 = 237; pub const SYS_SCHED_GETAFFINITY: u32 = 238; pub const SYS_EPOLL_CREATE1: u32 = 239; pub const SYS_EPOLL_CTL: u32 = 240; @@ -2886,9 +2970,13 @@ pub mod abi { pub const SYS_MKNOD: u32 = 271; pub const SYS_MKNODAT: u32 = 272; pub const SYS_MSYNC: u32 = 278; + pub const SYS_MLOCK: u32 = 279; + pub const SYS_MLOCK2: u32 = 280; + pub const SYS_MUNLOCK: u32 = 281; pub const SYS_WAITID: u32 = 288; pub const SYS_COPY_FILE_RANGE: u32 = 290; pub const SYS_SPLICE: u32 = 291; + pub const SYS_READAHEAD: u32 = 293; pub const SYS_SENDFILE: u32 = 294; pub const SYS_PREADV: u32 = 295; pub const SYS_PWRITEV: u32 = 296; @@ -3030,6 +3118,10 @@ pub mod abi { name: "SchedRrGetInterval", number: SYS_SCHED_RR_GET_INTERVAL, }, + AbiSyscallNumber { + name: "SchedSetaffinity", + number: SYS_SCHED_SETAFFINITY, + }, AbiSyscallNumber { name: "SchedGetaffinity", number: SYS_SCHED_GETAFFINITY, @@ -3106,6 +3198,18 @@ pub mod abi { name: "Msync", number: SYS_MSYNC, }, + AbiSyscallNumber { + name: "Mlock", + number: SYS_MLOCK, + }, + AbiSyscallNumber { + name: "Mlock2", + number: SYS_MLOCK2, + }, + AbiSyscallNumber { + name: "Munlock", + number: SYS_MUNLOCK, + }, AbiSyscallNumber { name: "Waitid", number: SYS_WAITID, @@ -3118,6 +3222,10 @@ pub mod abi { name: "Splice", number: SYS_SPLICE, }, + AbiSyscallNumber { + name: "Readahead", + number: SYS_READAHEAD, + }, AbiSyscallNumber { name: "Sendfile", number: SYS_SENDFILE, @@ -3467,6 +3575,26 @@ pub mod abi { ); } + #[test] + fn host_adapter_requires_every_host_owned_retry_authority_export() { + for required in [ + "kernel_blocking_retry_release", + "kernel_blocking_retry_token", + "kernel_dequeue_signal", + "kernel_get_socket_timeout_ms", + "kernel_is_fd_nonblock", + "kernel_pick_signal_target_tid", + "kernel_thread_has_deliverable", + ] { + assert!( + HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS + .binary_search(&required) + .is_ok(), + "host-owned retry protocol silently treats {required} as optional" + ); + } + } + #[test] fn linked_fork_program_artifact_contract_is_complete_and_sorted() { assert_eq!(WPK_FORK_LINKED_FRAME_FORMAT_MAGIC, *b"KLCF"); diff --git a/docs/abi-versioning.md b/docs/abi-versioning.md index 7717bb5734..0601d2650e 100644 --- a/docs/abi-versioning.md +++ b/docs/abi-versioning.md @@ -384,8 +384,10 @@ completion instead of dequeuing it into a channel record that JavaScript cannot deliver. After `fork`, `clone`, or a staged-loader import returns, libc issues a side-effect-free `getpid` checkpoint through the ordinary channel path; that completion owns normal handler delivery and signal-mask restoration. -The flag changes neither the continuation encoding nor any activation's frame -size. +The flag occupies bit 2. Bits 0 and 1 independently record cancellation-point +membership and cancellation-wake authority, so all three meanings can be +preserved in one captured request snapshot. The flag changes neither the +continuation encoding nor any activation's frame size. Statically tagged scalar `Catch`/`CatchRef` arms serialize their exact selector and maximum live scalar tag tuple. During rewind the tool executes `throw` with @@ -438,9 +440,29 @@ that the Rust allocator assigned those bytes to the destination object. Ordinary channel-sized transfers carry the kernel pointer and capacity together in a host-side `KernelScratchRegion` and can be accessed only through a synchronous lease. The `kernel_handle_channel` export now takes -`(channel_offset, channel_capacity, pid)`; Rust rejects a capacity other than -the canonical complete channel allocation before decoding it. This signature -change is incompatible with an ABI-42 host or kernel. +`(channel_offset, channel_capacity, pid, retry_token)`; Rust rejects a capacity +other than the canonical complete channel allocation before decoding it. Token +zero starts an operation, while a positive token reactivates the exact +Rust-owned target retained for a represented retry. This signature change is +incompatible with an ABI-42 host or kernel. + +ABI 43 also assigns the channel header's former four-byte reservation at offset +68 to generated `request_flags`. The cancellation-point and wake-authority +bits occupy bits 0 and 1. The deferred signal-delivery authority occupies bit +2. They are written before status publication, captured and cleared once by +the host, and retained with every asynchronous request snapshot. Unknown bits +and wake-without-cancellation-point fail closed. This is observable wire state, +not a host-only implementation detail. + +`kernel_blocking_retry_token(pid, tid, syscall_nr)` returns the exact positive +token for a classified Rust target, zero for an authoritative host-only +snapshot, or a negated errno. `kernel_blocking_retry_release` consumes a +positive token. The trailing retry token on +`kernel_transfer_io_execute`, `kernel_transfer_channel_execute`, +`kernel_sendmsg`, and `kernel_recvmsg` prevents replay from resolving a numeric +fd, queue descriptor, or System V id that may have been closed and reused. +Completion and cancellation consume the token before the host deletes its +immutable snapshot. Every generated pointer descriptor is explicitly and exclusively `required` or `nullable`. Positive-extent null pointers fail unless the shared descriptor @@ -462,6 +484,25 @@ other options preserve its low 32-bit scalar value. Treating that slot as one shape for every option would either dereference a scalar or replace it with an unrelated scratch pointer. +Large scalar and vector I/O uses a separate Rust-owned, single-use +reservation. `kernel_transfer_scratch_begin`, +`kernel_transfer_scratch_pointer`, and +`kernel_transfer_scratch_capacity` publish one initialized allocation only +while its positive token is `Reserved`. `kernel_transfer_io_execute` or +`kernel_transfer_channel_execute` consumes that token and enters +`Executing` before releasing the reservation mutex and calling any host +import. A normal return makes it `Ready`; `kernel_transfer_scratch_cancel` +then drops the allocation. The execute exports accept no host-selected +pointer, so allocation capacity cannot be separated from ownership. + +A host-import trap can strand a reservation in `Executing`, where cancellation +must reject rather than free memory that a callback may still have partially +observed. The host therefore treats such a trap as a fatal kernel-generation +failure and admits no later ingress. This fail-closed lifetime rule, the +removed public raw scalar/vector exports, and the new required transactional +exports are incompatible ABI changes folded into the still-unreleased ABI 43; +they are not an additive ABI-42 extension. + Large `SYS_SPAWN` blobs use a Rust-owned reusable `Vec` with a tokenized transaction: @@ -495,6 +536,16 @@ reentrant host operations cannot replace bytes being consumed. The previous pointer-returning `kernel_spawn_scratch_reserve` interface and fixed worst-case compatibility fallback are not part of ABI 43. +ABI 43 requires `host_pread` and `host_pwrite` so positioned regular-file I/O +keeps a signed 64-bit offset lossless and does not mutate a shared +open-file-description cursor through seek emulation. It also requires the +paired append imports. `host_append(handle, pointer, length, limit_lo, +limit_hi)` performs one EOF/limit/write transaction, and +`host_append_position(handle, written)` consumes the matching one-shot ending +offset. Rust validates the returned prefix and ending position before +publishing its cursor. A backend that cannot provide this exact outcome must +return `EOPNOTSUPP` before mutation. + ABI 43 also makes System V IPC control-structure sizing explicit. Required pointer-width queries report the target musl layouts: `msqid_ds` is 96 bytes on wasm32 time64 and 120 bytes on wasm64 LP64, `semid_ds` is 72/88 bytes, and @@ -535,7 +586,10 @@ observable export and wire changes, not generation-only bookkeeping. The ABI 43 required host-adapter export set retains the ABI 42-required `kernel_spawn_process` and adds +`kernel_blocking_retry_release`, +`kernel_blocking_retry_token`, `kernel_clear_process_metadata`, +`kernel_commit_process_exit`, `kernel_msqid_ds_bytes`, `kernel_semctl_array_bytes`, `kernel_semid_ds_bytes`, `kernel_shmid_ds_bytes`, `kernel_push_process_metadata_entry`, `kernel_set_cwd`, @@ -543,10 +597,19 @@ The ABI 43 required host-adapter export set retains the ABI 42-required `kernel_spawn_scratch_begin`, `kernel_spawn_scratch_cancel`, `kernel_spawn_scratch_capacity`, `kernel_spawn_scratch_pointer`, and -`kernel_spawn_scratch_retained_capacity`. The required capabilities and large-spawn -semantics changed, so this is incompatible rather than bookkeeping around -additive constants. Kernels, hosts, packages, guest binaries, and VFS images -from ABI 42 must be rebuilt rather than mixed with ABI 43 artifacts. +`kernel_spawn_scratch_retained_capacity`, plus +`kernel_transfer_channel_execute`, `kernel_transfer_io_execute`, and the four +`kernel_transfer_scratch_*` exports described above. The required capabilities +and synchronization semantics changed, so this is incompatible rather than +bookkeeping around additive constants. Kernels, hosts, packages, guest +binaries, and VFS images from ABI 42 must be rebuilt rather than mixed with +ABI 43 artifacts. + +ABI 43 has not been published as a compatibility epoch. The retry-token, +large-transfer, fatal-lifetime, positioned-I/O, and append corrections amend +that same pending ABI-43 contract and snapshot. They do not justify inventing +ABI 44 merely to preserve an unreleased draft, and they must not be hidden +under released ABI 42. The metadata pair and cwd setter are required because process registration uses them unconditionally. A same-version kernel may not fall back to the @@ -592,7 +655,11 @@ captures: Any change to either this section or `platform_limits` is classified as breaking unless the ABI epoch changes. - `channel_header` — field offsets and sizes in the channel header, - read from `shared::channel::*` constants. + read from `shared::channel::*` constants, including the generated + request-flags word and known-bit mask. +- `channel_scalar_contract` — syscall arguments and results that must preserve + signed or unsigned 64-bit values rather than taking the default 32-bit + scalar path. - `channel_signal_area` — signal-delivery slot offsets in the trailing bytes of the channel data buffer. - `channel_buffers` — data buffer offset/size and minimum channel size. @@ -676,6 +743,13 @@ them: the kernel currently acts on `SETPGROUP`, `SETSIGDEF`, `SETSIGMASK`, and remain uninterpreted. The count and complete-wire caps are defensive parser/transport limits, not new POSIX promises. +Channel scalar widths are likewise Rust-owned. The generator writes +`host/src/generated/abi.ts` and +`libc/musl-overlay/include/bits/kandelo_channel_scalars.h` from +`crates/shared/src/channel_scalar.rs`; the ABI snapshot records the same +contract. Generated freshness tests fail if TypeScript, C, and Rust disagree +about a signed/unsigned 64-bit argument or result. + Native process layouts and fixed kernel wires follow the same ownership rule. `crates/shared/src/process_layout.rs` owns the wasm32/wasm64 native `iovec`/`msghdr`/`cmsghdr`, `pollfd`, and `fd_set` values; the generator writes diff --git a/docs/architecture.md b/docs/architecture.md index 3062005589..cadb13221e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -76,18 +76,26 @@ kernel_set_current_tid(pid, tid) → 0 | -errno kernel_fork_process(parent_pid, caller_tid) → assigned_child_pid | -errno kernel_spawn_process(parent_pid, caller_tid, blob_ptr, blob_len) → assigned_child_pid | -errno kernel_remove_process(pid) → 0 -kernel_handle_channel(channel_offset, channel_capacity, pid) → result +kernel_handle_channel(channel_offset, channel_capacity, pid, retry_token) → result +kernel_blocking_retry_token(pid, tid, syscall_nr) → opaque_token | -errno +kernel_blocking_retry_release(pid, tid, opaque_token) → 0 | -errno kernel_exec_prepare(pid, caller_tid) → 0 | -errno kernel_exec_setup_for_thread(pid, caller_tid) → 0 | -errno kernel_thread_exit(pid, tid) → 0 | -errno +kernel_commit_process_exit(status) → committed_low_8_bits kernel_dequeue_signal(pid, tid, out_ptr, out_capacity) → 0 | signum | -errno kernel_wait_child_poll(parent_pid, caller_tid, target_pid, event_mask, flags, out_ptr, out_capacity) → child_pid | 0 | -errno -kernel_prepare_write_operation(pid, tid, fd, offset, len, positioned) → allowed_len | -errno kernel_ipc_shmat_for_task(pid, tid, shmid, addr, flags) → segment_size | -errno kernel_ipc_shmdt_for_task(pid, tid, shmid) → 0 | -errno kernel_ipc_shmat_for_process(pid, shmid, addr, flags) → segment_size | -errno kernel_ipc_shmdt_for_process(pid, shmid) → 0 | -errno kernel_alloc_scratch(size) → kernel_owned_pointer | 0 +kernel_transfer_scratch_begin(minimum_capacity) → reservation_token | -errno +kernel_transfer_scratch_pointer(reservation_token) → kernel_owned_pointer | 0 +kernel_transfer_scratch_capacity(reservation_token) → reservation_capacity | 0 +kernel_transfer_scratch_cancel(reservation_token) → 0 | -errno +kernel_transfer_io_execute(pid, tid, reservation_token, len, syscall, fd, offset, retry_token) → bytes | -errno +kernel_transfer_channel_execute(pid, tid, reservation_token, retry_token) → 0 | -errno kernel_spawn_scratch_begin(minimum_capacity) → reservation_token | -errno kernel_spawn_scratch_pointer(reservation_token) → kernel_owned_pointer | 0 kernel_spawn_scratch_capacity(reservation_token) → reservation_capacity | 0 @@ -102,7 +110,7 @@ kernel_get_cwd(pid, buf, len) → bytes_written kernel_set_max_addr(pid, addr) → 0 kernel_set_brk_base(pid, addr) → 0 kernel_set_mmap_base(pid, addr) → 0 -kernel_is_fd_nonblock(pid, fd) → bool +kernel_is_fd_nonblock(pid, fd) → 1 | 0 | -1 ``` Normal guest exit closes descriptors before the process becomes reapable. @@ -223,12 +231,40 @@ Scalar access checks the lease on every operation. The native `DataView` stays private and may be reused only while `Memory.buffer` has the same identity; a `memory.grow()` replaces that buffer and forces the complete capacity and current-memory proof to run again before the view is refreshed. +Each genuine buffer identity is brand-checked once against captured native +`ArrayBuffer`/`SharedArrayBuffer` getters. The successful getter—not its +result—is cached, so repeated shared-memory proofs avoid an exception while +every proof still observes the live post-growth byte length. Async syscall preparation detaches caller data into host-owned arrays; the final stage, Rust call, and output snapshot happen in one lease without an `await`. Retry, timeout, stopped-process, and signal completion state carries only those detached writes; `completeChannel` never rereads reusable scratch after the lease has ended. +An `EAGAIN` retry must preserve more than the copied bytes. The host freezes an +immutable request snapshot, while Rust pins any exact resource selected by the +first attempt and exposes an opaque positive retry token. That token is bound +to the process, task, and normalized operation; it is not an fd, queue id, +pointer, or allocation address. Reentry reconstructs scratch only from the +snapshot and activates the pinned target instead of rereading the live mailbox +or resolving a numeric descriptor that may have been closed and reused. + +Normal completion, cancellation, and exact channel retirement consume a +positive token before deleting the matching host snapshot. Exec, thread exit, +process exit, and forced removal consume their Rust-owned bindings before the +corresponding task or image state disappears. A zero token is an explicit +host-only disposition, not a missing binding. This ordering prevents both +leaked kernel references and a later operation observing scratch bytes or a +descriptor identity from the wrong request. + +The generated channel `request_flags` word records whether the call entered +through libc's cancellation-point path and whether that point may currently be +woken. The host captures and clears those bits with the initial mailbox +snapshot and carries them through the asynchronous wait. Cancellation or exact +channel retirement settles that frozen request before the mailbox and its +scratch allocation can be reused; neither path infers authority later from a +replacement channel. + The Rust channel dispatcher carries the same ownership boundary numerically as `ChannelScratchRegion { start, capacity }`. The host passes the complete channel capacity to `kernel_handle_channel`; Rust rejects any value other than @@ -285,6 +321,18 @@ deliberately fixed: an eight-byte `KernelIovecWire`, a 28-byte `KernelMsghdrWire`, and a 12-byte-aligned `KernelCmsghdrWire`. These are separate contracts; copying a native wasm64 header and hoping the fixed parser interprets it is invalid even when the bytes fit in linear memory. +Socket-address sizing is likewise generated as two distinct contracts. +The 128-byte `sockaddr_storage` bounds every generic input and output staging +region; the 110-byte `sockaddr_un` bounds family-specific AF_UNIX parsing. +Musl layout assertions bind those totals, the two-byte `sun_path` offset, and +the 108-byte path field to both native data models. The Unix socket registry +owns the canonical namespace key, while `SocketInfo` retains the bounded +original name supplied to `bind()`. This distinction matters for a relative +name in a deep current directory: canonicalization may produce a much longer +lookup key, but it must not enlarge the value returned by `getsockname()`. +An exact 108-byte non-NUL pathname can make Linux-compatible `getsockname()` +report 111 bytes after accounting for its appended terminator, which still +fits the generic 128-byte output region. For `sendmsg`, the host validates the complete native header and iovec table, every nested caller range, `IOV_MAX`, and the complete fixed-wire footprint. @@ -299,14 +347,64 @@ does not pretend that a copied socket record is the original endpoint. The exact flattened-iovec count is generated from the shared protocol contract, and a Rust compile-time guard makes changing that count fail until the fixed parser changes with it. -For `recvmsg`, the host -derives fixed-wire control capacity from the caller-native data capacity, +Nested `sendmsg.msg_name` accepts exactly the same 128-byte input maximum as +`sendto`; it cannot bypass that check by living inside `msghdr`. For +`recvmsg`, the host proves and reserves at most 128 name bytes even when the +caller advertises a larger buffer, derives fixed-wire control capacity from +the caller-native data capacity, snapshots the result, validates the entire returned record, expands it with zeroed native padding, and scatters payload bytes across every caller iovec. A retry or malformed kernel result publishes none of those detached outputs. This flatten/scatter design preserves the public multi-iovec behavior while keeping the ordinary transport allocation fixed and cheap. +Scalar and vectored reads or writes at most `CH_DATA_SIZE` use the main +channel region. The host validates the complete caller range or native iovec +table, flattens a vector directly into the data area, and dispatches one scalar +kernel operation. A vector is never split merely to fit scratch; preserving +one logical operation is required for pipe atomicity, datagram boundaries, +short reads, EOF, and operation-wide file-size limits. + +Larger operations reserve +`crates/kernel/src/transfer.rs::TransferScratch`. Begin creates a fresh, +initialized Rust-owned allocation and returns a positive token. The host reads +the token's pointer and explicit capacity together, proves the current-memory +range, and copies under one synchronous lease. Execute changes the reservation +from `Reserved` to `Executing` before releasing the mutex and entering exactly +one scalar kernel operation. A normal return, including an errno, changes it +to `Ready`; cancellation then drops the allocation. No pointer-only execute +path exists. + +A host-import trap can prevent Rust from leaving `Executing`. Cancellation +must not free or reuse a region whose callback may have observed only a prefix, +so this state poisons the complete kernel generation. `KernelEntryGate` +serializes every export entry, revokes its lexical scope before running +detached callbacks, and discards queued ingress after a fatal latch. This is a +lifetime guarantee: later work cannot overwrite the reservation or publish a +channel completion against an uncertain Rust transition. + +Positioned host-backed I/O uses required `host_pread` and `host_pwrite` +imports. Signed 64-bit offsets remain exact across TypeScript routing and are +split and reconstructed losslessly at the Wasm boundary; one positioned +operation leaves the shared open-file-description cursor unchanged. A backend +that cannot represent an offset exactly returns `EOVERFLOW` instead of +rounding it or emulating it with seek/read-or-write/seek. + +Host-backed `O_APPEND` uses a separate exact-outcome contract. Rust passes the +complete payload and optional `RLIMIT_FSIZE` ceiling to `host_append`; the +backend owns EOF selection, clipping, mutation, and ending-position +observation as one serialized operation. `host_append_position` consumes the +matching one-shot ending offset, and Rust validates the written prefix and +derived start before publishing the cursor. Backends that cannot prove that +pair return `EOPNOTSUPP` before mutation. They do not infer ownership from a +later `stat`. + +For `sendfile`, `copy_file_range`, and `splice` into such an append +destination, the source is staged without publishing its cursor or consuming +pipe bytes. Only the prefix reported by the append is committed. An append +rejection, file-size clip, or short write therefore cannot consume source data +that the destination did not publish. + Large spawn blobs use a different kernel-owned high-water region in `crates/kernel/src/spawn.rs::SpawnScratchBuffer`. Every large operation calls `kernel_spawn_scratch_begin`, which may grow the Rust `Vec` only while no @@ -365,10 +463,10 @@ an occurrence is added, duplicated, or removed. This keeps framebuffer, process-memory, and shared-memory paths explicit without conflating their ownership with kernel scratch. Because untyped JavaScript can erase a receiver type, the audit also treats a zero-argument `.getMemory()` call in JavaScript -source as the documented raw kernel-memory accessor and follows its result into -aliases, helper parameters, views, and writes. Same-named non-kernel APIs need -an exact reviewed allowance. This narrow backstop is not a claim of sound -general JavaScript taint analysis. +source as a potential reintroduction of the former raw kernel-memory accessor +and follows its result into aliases, helper parameters, views, and writes. +Same-named non-kernel APIs need an exact reviewed allowance. This narrow +backstop is not a claim of sound general JavaScript taint analysis. The Rust dispatcher has a separate source-contract test for raw process addresses. It rejects the former raw-channel-pointer macro, matches every @@ -380,13 +478,17 @@ paired with an unrelated replacement, or a reintroduced bare channel pointer therefore requires an explicit review instead of passing a count-only allowlist. -The low-level `WasmPosixKernel.getMemory/getInstance` and -`CentralizedKernelWorker.getKernel/getKernelInstance` accessors are unsafe -trusted-embedder/debug escape hatches, not scratch-transfer APIs. A consumer -that calls raw pointer-returning exports and mutates the returned memory has -opted out of the capacity and lease guarantees above. Kandelo's own runtime -does not use those accessors for transfers, and the compiler-backed audit -covers repository source rather than arbitrary downstream mutations. +The former low-level `WasmPosixKernel.getMemory/getInstance` and +`CentralizedKernelWorker.getKernel/getKernelInstance` accessors are no longer +part of the supported host API. The public wrappers expose bounded queries +such as kernel-memory page count, not mutable `WebAssembly.Memory`, a raw +`WebAssembly.Instance`, or its export namespace. A module-private capability +gives only the dedicated kernel worker the exact gate and memory it owns; it is +not re-exported to embedders. This is an intentional host-API incompatibility: +downstream consumers of the former raw accessors must migrate to an +ownership-specific bounded operation. The compiler-backed audit retains the +old method spellings as fail-closed regression seeds so reintroducing an +unreviewed raw accessor becomes a contract failure. Current host-adapter admission requires `kernel_set_cwd`, `kernel_clear_process_metadata`, and @@ -600,7 +702,7 @@ Offset Size Field 8 48 arguments (6 × i64) 56 8 return_value (i64) 64 4 errno_value (i32) -68 4 request_flags (i32) +68 4 request_flags (u32; cancellation and signal-delivery authority) 72 65536 data_buffer (for path strings, read/write buffers, etc.) ``` @@ -611,10 +713,14 @@ public variadic `syscall()` entry point still reads 32-bit `long` arguments, because that is the C calling convention its callers use. The non-variadic `__syscallN` and cancellation-point `__syscall_cp` paths widen values to 64 bits before calling the glue layer so offsets and lengths are not truncated. - -Bit 0 of `request_flags`, -`REQUEST_FLAG_DEFER_SIGNAL_DELIVERY`, identifies a completion consumed by -process-worker JavaScript instead of libc's post-syscall signal trampoline. +Both paths overwrite `request_flags` before publishing the atomic status. +Plain calls write zero; cancellation-point calls use generated flag constants. +The host captures and clears the field with the request snapshot. + +Bits 0 and 1 of `request_flags` identify cancellation points and authorize a +cancellation wake, respectively. A wake is valid only when both bits are set. +Bit 2, `REQUEST_FLAG_DEFER_SIGNAL_DELIVERY`, identifies a completion consumed +by process-worker JavaScript instead of libc's post-syscall signal trampoline. The kernel leaves caught signals pending on those completions. Fork, clone, continuation allocation/cleanup, and staged-loader VFS/memory requests set the bit and clear it before returning control to guest code. Libc then uses an @@ -635,15 +741,17 @@ after the owning import returns. Ordinary guest syscalls clear the flags word. ``` Process Worker Kernel Worker (host) ───────────── ──────────────────── -1. Write syscall_number + args +1. Write syscall_number + args + request_flags to channel 2. Atomics.store(status, SYSCALL_READY) 3. Atomics.notify(status) 4. Atomics.wait(status, SYSCALL_READY) ─── blocks ─── 5. Atomics.waitAsync detects change - 6. Read channel: syscall + args + 6. Read channel: syscall + args; + capture and clear request_flags 7. Call kernel_handle_channel(offset, - capacity, pid) + capacity, pid, + retry_token=0) 8. Kernel reads args from process memory 9. Kernel executes syscall logic 10. Kernel writes return_value + errno @@ -663,15 +771,22 @@ loss and a reentrant host-to-Wasm callback. ### Blocking Syscalls and Retry -Some syscalls (read from empty pipe, accept on socket, poll with timeout) cannot complete immediately. The kernel returns `-EAGAIN` and the host enters a retry loop: - -1. Kernel returns EAGAIN for the syscall -2. Host checks if the fd is non-blocking (`kernel_is_fd_nonblock`). If so, return EAGAIN to the process. -3. If blocking: host stores RETRY status, keeps the channel pending -4. When another process writes to the pipe / connects to the socket / etc., the host wakes the pending channel -5. Host re-calls `kernel_handle_channel` — if still EAGAIN, continue waiting; if result ready, write RESULT_READY and notify - -This mechanism is critical: the process worker blocks on `Atomics.wait` while the host manages async retry via `Atomics.waitAsync`. +Some syscalls (read from an empty pipe, accept on a socket, or poll with a +timeout) cannot complete immediately. The process worker remains blocked in +`Atomics.wait` while the host parks and wakes its pending channel through +`Atomics.waitAsync`. + +For a represented retry, the initial call uses token zero. Before returning +`EAGAIN`, Rust pins any exact target required by that operation. The host +detaches the complete request, queries the authoritative token, and either +completes a nonblocking call or parks the immutable snapshot. A later wake +rebuilds scratch from that snapshot and calls the kernel with the same token; +it does not reread caller memory or follow a reused fd. Terminal completion or +cancellation consumes the token before the host drops its snapshot. + +This mechanism is critical: asynchronous scheduling never owns a live scratch +view, while Rust retains the resource identity and lifetime needed by the next +synchronous entry. `F_SETLKW` uses the same parking mechanism with a narrower wake contract. A conflict returns the internal retry result, and the host parks only that lock diff --git a/docs/plans/2026-07-25-kernel-scratch-transfer-audit.md b/docs/plans/2026-07-25-kernel-scratch-transfer-audit.md index 6290a026f8..5b47712ddc 100644 --- a/docs/plans/2026-07-25-kernel-scratch-transfer-audit.md +++ b/docs/plans/2026-07-25-kernel-scratch-transfer-audit.md @@ -6,12 +6,14 @@ preserved below. PR #1094 closed without merging. Its main-first replacement, PR #1097, merged as `c7d039794a43788acfa0b0aea30a700c257f57cb`, and this branch has been retargeted to that merge result. PR #1097 shipped ABI 42; the incompatible -export changes documented here intentionally use ABI 43. The pre-retarget and -dirty-worktree results below remain historical or interim evidence, not a -readiness claim. Because recording a commit's own SHA in a tracked document -would change that SHA, the mutable exact-PR-head validation ledger belongs in -the draft PR description after the head is frozen. Brandon's approval must be -requested only when that ledger names the current head and its exact results. +export changes documented here intentionally use the still-unpublished ABI +43. No ABI 44 is introduced. Because recording a commit's own SHA in a tracked +document would change that SHA, the mutable exact-PR-head validation ledger +belongs in the draft PR description after the head is frozen. This audit +records source evidence, reproductions, and validation targets; it does not +claim a browser run, performance measurement, or full build for the current +head. Brandon's approval must be requested only when the external ledger names +that head and its exact results. ## Scope and method @@ -49,11 +51,11 @@ The tables distinguish three source-safety dispositions: Validation status is tracked independently from source safety. Legacy table cells that say **Implemented; validation pending** or **final-head rerun -pending** record the pre-freeze audit state and mean **Safe in current source** -for the safety disposition; they do not make an exact-head validation claim. -The draft PR's exact-head ledger supersedes those mutable status labels only -when it names the current commit. Historical and interim commands later in -this document never substitute for that ledger. +pending** mean **Safe in current source** for the source disposition; they do +not make an exact-head validation claim. Test names below identify executable +coverage targets, not results from this documentation-only finalization. The +draft PR's exact-head ledger supersedes those labels only when it names the +current commit and exact commands. ## Required invariant @@ -83,8 +85,9 @@ inside the current linear memory while the range following it crosses from a `memory`, `pointer`, pointer width, capacity, and a diagnostic label. Production regions come only from `allocateKernelScratchRegion` or `reserveKernelScratchRegion`; the compiler-backed contract inventories - every direct or aliased factory call and admits only the five exact - kernel-export-backed production sites. + every direct or aliased factory call and admits only the six exact + kernel-export-backed production sites: four persistent allocations plus the + spawn and generic-transfer token reservations. Reservation-derived regions are single-use and explicitly revoked when their Rust token is consumed or cancelled, so a later `Vec` growth cannot revive a stale pointer/capacity pair. @@ -116,15 +119,18 @@ inside the current linear memory while the range following it crosses from a only for raw allocator/reservation export results; caller-supplied negative pointers remain invalid. -`WasmPosixKernel.getMemory/getInstance` and -`CentralizedKernelWorker.getKernel/getKernelInstance` remain explicit unsafe -trusted-embedder/debug escape hatches. A downstream embedder can use them to -call a pointer-returning export and mutate arbitrary kernel memory, so neither -the type nor the repository audit claims to protect that external code. They -are not used for repository-owned runtime transfers. Their API documentation -warns that direct mutation is outside the checked contract; narrowing or -removing these long-standing low-level APIs would be a separate public-host -API decision. +The public wrapper no longer exposes mutable kernel `Memory`, a raw +`WebAssembly.Instance`, its export namespace, the import object, scratch +regions, or region factories. Generation-bearing fields and helpers on +`WasmPosixKernel` and the worker-owned kernel and scratch regions are +ECMAScript `#private`; reflection cannot recover them by spelling a former +TypeScript-private property name. An internal module capability grants the +dedicated worker the exact gated facade and Memory it owns. Separate focused +test proxies require module-secret capabilities. Neither path is re-exported +from a supported host API entry point. This is an intentional +public-host-API incompatibility: callers that previously depended on +`getMemory()` or instance inspection must use an ownership-specific API rather +than recovering a bare allocation address. `host/test/support/wasm-memory-write-audit.ts` and `host/test/kernel-scratch-contract.test.ts` form the static contract. The @@ -132,29 +138,35 @@ TypeScript compiler and type checker discover production JavaScript, TypeScript, and selected diagnostic sources recursively, seed their ownership roots, and propagate kernel-memory ownership through aliases, helper parameters and returns, spreads, destructuring, logical/comma expressions, loop bindings, -and common intrinsic array element/callback methods. Kernel instance/export -namespace ownership is followed through `getInstance().exports.memory`, not -only the sibling `getMemory()` escape. Typed-array receiver methods use a -positive non-retaining whitelist; callback container arguments and retained -iterators remain visible to the analysis. They report raw +and common intrinsic array element/callback methods. The audit conservatively +follows a `getInstance().exports.memory` or `getMemory()` spelling as potential +kernel authority even though the supported public facade now exposes neither +mutable value; this makes their reintroduction fail closed. Typed-array +receiver methods use a positive non-retaining whitelist; callback container +arguments and retained iterators remain visible to the analysis. They report raw typed/DataView construction, scalar and bulk writes, escapes, persistent stores, returned views, allocator calls, scratch-region factory calls, and -spawn-reservation calls. The manually reviewed -`KERNEL_SCRATCH_EXPORT_NAMES` capability list supplies the pointer-export -contract. It is not a fail-closed classification of every present or future -kernel export: a newly named direct export omitted from that list would not be -recognized by the pointer-export finding alone. Listed exports and -computed/unknown export access are tracked through direct members, -destructuring, aliases, `call`/`apply`/`bind`, and `Reflect.apply`; invoking or -escaping them outside `KernelScratchLease.invokeKernelExport` is a contract -finding even when every argument is an untainted primitive. Independently, -the ownership audit still rejects any repository-owned raw kernel-memory -view/write and any unreviewed allocator, reservation, or region-factory call. -That independent fail-closed check is the future direct-variable-write -contract. The narrow exact pointer-export allowances are scalar/no-pointer -manifest, ABI, IPC-size, and ioctl queries plus the scratch core's captured -arity inspection. Token-only reserved spawn is not classified as a -pointer-bearing export. A separate exact multiset allowlist admits only named +spawn/generic-transfer scratch-reservation calls. The complete generated +kernel-export set from `abi/snapshot.json` is the default-deny raw-call +universe. `KERNEL_SCRATCH_EXPORT_NAMES` is the narrower, manually reviewed +capability list that a lease may invoke, and the audit fails if that list +contains a name absent from the generated set. A newly generated export is +therefore still classified and denied when it is omitted from the runtime +scratch list; the omission cannot hide a raw call. Direct members, +computed/unknown export access, destructuring, aliases, +`call`/`apply`/`bind`, and `Reflect.apply` are tracked. Invoking or escaping a +generated export outside `KernelScratchLease.invokeKernelExport` is a contract +finding even when every argument is an untainted primitive. Independently, the +ownership audit rejects any repository-owned raw kernel-memory view/write and +any unreviewed allocator, reservation, or region-factory call. The narrow exact +raw-call allowances are scalar/no-pointer manifest, ABI, IPC-size, and ioctl +queries plus the scratch core's captured arity inspection. There is no broad +“token-only export” exclusion. The reserved-spawn commit is admitted only by +an exact syntax-tree proof that pairs its begin-derived region and token, +copies before commit, revokes the region, and then cancels that same token. +Allocator and reserver calls retain their separate exact findings. A separate +exact +multiset allowlist admits only named reviewed occurrences with inline reasons; adding or duplicating an occurrence fails, and deleting one makes its allowance stale @@ -163,18 +175,18 @@ shared-backing roots remain separately classified because their owner is not the kernel scratch allocator. A `.set` or `.decode` call counts as a synchronous reader only when TypeScript resolves it to the native typed-array or `TextDecoder` declaration; a same-named custom method remains an escape. -JavaScript-family sources have one additional narrow syntax backstop: -zero-argument `.getMemory()` and `.getInstance()` calls are treated as the -documented raw kernel-memory authorities even when an untyped receiver prevents -the checker from recovering its class. The normal ownership analysis then -follows those values -through aliases and helper parameters into raw typed-array or `DataView` -writes. An unrelated JavaScript API with the same spelling requires an exact -site allowance; no file is excluded to suppress it. +JavaScript-family sources retain an additional conservative syntax backstop: +zero-argument `.getMemory()` and `.getInstance()` calls are treated as +potential kernel-memory authorities even when an untyped receiver prevents the +checker from recovering its class. The public kernel facade no longer carries +a memory export, but retaining this rule makes a future raw-accessor regression +visible. The normal ownership analysis follows any such values through aliases +and helper parameters into raw typed-array or `DataView` writes. An unrelated +JavaScript API with the same spelling requires an exact site allowance; no +file is excluded to suppress it. This is a compiler-backed contract over the reviewed repository source, not a -claim of a sound general-purpose JavaScript taint analysis or control over raw -writes performed by a downstream consumer through the unsafe trusted-embedder -accessors. +claim of a sound general-purpose JavaScript taint analysis or control over +memory capabilities obtained outside the supported host API. Rust has a separate source-contract guard for dispatcher pointers. `crates/kernel/src/channel_scratch.rs` test @@ -187,6 +199,88 @@ not authorize kernel-scratch dereferences. Exact contexts plus the count are both required so removing one approved site and adding an unrelated one cannot evade review. +### Closed process-address disposition + +The pointer audit distinguishes a caller process address from a pointer into +kernel scratch even though both occupy an `i64` channel slot. The authoritative +generated pointer set is +`crates/shared/src/host_abi.rs::SYSCALL_ARG_DESCRIPTORS`, emitted as +`host/src/generated/abi.ts::SYSCALL_ARGS`. `host/test/generated-abi.test.ts` +pins that complete generated set to the ABI snapshot and requires every +descriptor to be explicitly required or nullable. The host planner iterates +that table, captures the caller range, and replaces every non-null descriptor +slot with a lease-scoped allocator-owned address; +`crates/kernel/src/channel_scratch.rs::validate_channel_scratch_arguments` +recomputes the same layout before dispatch. Zero-size argument records receive +the allocator-owned empty address rather than forwarding an ignored process +pointer. The only generated-table bypasses are: + +- `read(3)`, `write(4)`, `pread(64)`, and `pwrite(65)`, which use the ordinary + planner through `CH_DATA_SIZE` and the tokenized exact large-transfer path + above it; +- `wait4(139)` and `waitid(288)`, whose complete optional outputs are handled + by the exact host wait path; and +- `execve(211)`, whose path, argument vector, and environment are decoded from + checked caller ranges before the asynchronous exec boundary. + +Every other generated descriptor slot is scratch-overwritten before Rust sees +it. The following table closes the remaining non-generated and conditional +slots. `host/test/channel-scalar-contract.test.ts`, +`host/test/host-process-pointer-width.test.ts`, and the exact Rust raw-pointer +allowlist make additions or lossy width conversions executable drift +failures. + +| Disposition | Exact symbols and process-address slots | Ownership and lifetime finding | +|---|---|---| +| **Live raw `ProcessAddress` consumed by Rust** | `host/src/kernel-worker.ts::{checkGeneratedProcessAddressArguments,checkHandwrittenProcessAddressArguments}` and `crates/shared/src/channel_scalar.rs::SYSCALLS` cover `mmap` a0, `munmap` a0, `brk` a0, `mprotect` a0, `mremap` old a0, `madvise` a0, `set_tid_address` a0, `set_robust_list` a0, `get_robust_list` a1/a2, `msync` a0, `mlock`/`mlock2` a0, and `munlock` a0. Rust consumes them only through `checked_channel_process_address`, `dispatch_channel_mmap`, `dispatch_channel_mremap`, `dispatch_channel_wide_result`, or the reviewed `process_address!` macro in `dispatch_channel_syscall`. | These are guest virtual addresses, not kernel allocation addresses. The host first proves caller width and lossless JavaScript indexing; Rust reinterprets the complete physical bits and checks its target width. No such value authorizes a kernel-scratch dereference. `mremap` fixed a4 is separately checked and used only by host mapping pre/postflight; the current Rust syscall does not consume it. | +| **Exact host-intercepted handwritten slots** | `checkHandwrittenProcessAddressArguments` covers spawn a0/a2/a4; execve a0/a1/a2; execveat a1/a2/a3; wait4 a1/a3; waitid a2/a4; futex a0 and operation-dependent a3/a4; vector I/O a1; sendmsg/recvmsg a1; pselect6 a1-a5; select a1-a4; lock-fcntl a2; network-ioctl a2; and large read/write/pread/pwrite a1. | The host validates the complete caller or nested range and either decodes it, copies it to an owned scratch subregion, or retains only detached scalar/byte state. None of these caller pointers reaches Rust as kernel scratch. | +| **Conditional raw values after an exact host intercept** | `handleClone` validates stack a1 and TLS a3 plus active parent/child-TID a2/a4, then its synchronous synthetic channel call lets `dispatch_channel_syscall` consume the exact active process addresses. Direct Rust fallbacks also retain checked conditional slots for futex a4, shmat a1, and shmdt a0; normal production dispatch intercepts those syscalls in `handleFutex`, `handleIpcShmat`, and `handleIpcShmdt`. | Ignored conditional arguments retain scalar/ignored semantics. Active clone pointers are process metadata or checked process-memory destinations, not scratch. The shmat/shmdt host paths keep mapping ownership in the process address space and pass no caller-selected scratch pointer to Rust. | +| **Other exact special paths** | `handleGetgroups`; ppoll timeout/mask decoding around `SYS_PPOLL`; `handleEpollCtl`/`handleEpollPwait`; `handleSysvMessage`; `handleIpcControl`; `handleSemctl`; option-sensitive `PR_SET_NAME`/`PR_GET_NAME`; and request-sensitive ioctl planning. | Each special path proves its caller-native fixed, nested, or command-dependent shape before staging. Ppoll's generated pollfd a0 is scratch-overwritten while its timeout and mask become scalars. Unknown, scalar, and no-argument ioctls stage no pointer. `epoll_pwait` currently validates the optional mask range although its contents are not yet applied; that semantic gap does not grant scratch authority. | +| **Nested process addresses inside an overwritten ioctl record** | `crates/shared/src/ioctl_contract.rs`, `crates/kernel/src/syscalls.rs::{handle_dri_ioctl,handle_dri_card_ioctl,checked_dri_process_pointer}`, and the process-memory `HostIO` bridge. | The outer ioctl argument is an exact scratch-overwritten record, but selected DRM/KMS/GL records contain nested process addresses that remain process addresses. Rust validates their current bridge representation before any process-memory write. The present `u32` bridge rejects a wasm64 nested address above 4 GiB; that is an explicit mixed-width device limitation, not permission to reinterpret it as kernel scratch. | + +`SYS_SIGNAL` a1 is deliberately absent from the raw-address set: it is a +`u32` WebAssembly function-table index, not a process linear-memory address. +The xattr stubs below are also not silently classified as pointers merely +because their future implementations will need buffers. + +### Native-width scalar and legacy direct-import evidence + +The channel carries six physical `i64` words, but each word still has the +syscall's real domain. `crates/shared/src/channel_scalar.rs` is the +authoritative exception table: it distinguishes caller-width pointers, +caller-width `size_t`, exact `u32`, complete `i64`, and split `i64`. The host +starts from untouched `bigint` words and never narrows an exact slot merely for +logging. It validates addresses before host use; Rust consumes the matching +typed helper and either represents the complete value or rejects it before +effects. Descriptor-backed counts are handled separately: the host validates +their raw caller range and replaces the pointer and count with one +capacity-owned staged extent. + +| Surface and exact symbols | True width and old boundary reproduction | Current disposition | +|---|---|---| +| Memory-management lengths for `mmap`, `munmap`, `mprotect`, `mremap`, `madvise`, `msync`, `mlock`, `mlock2`, and `munlock`; `ChannelScalarKind::ProcessSize`; `checked_channel_process_size` | The length is `size_t`. On the live lock path, `addr=0,len=0x1_0000_1000` formerly became 4,096; a high address such as `0x1_0000_1000` could likewise alias `0x1000`. | The preceding pointer table and `ProcessSize` jointly preserve both fields. `msync` and advisory memory operations validate even where the current operation is a no-op, so a future implementation cannot inherit the alias. | +| `set_tid_address(203)` and robust-list syscalls 261/262; `kernel_set_tid_address`, `kernel_{set,get}_robust_list` | Clear-TID and robust-list slots are process pointers; robust-list length is `size_t`. A high clear-TID pointer could formerly be stored as the low address. Robust-list exports currently retain/write nothing, so their same defect was latent rather than a live overwrite. | `ProcessAddress`/`ProcessSize` reject a lossy value. Exact validation is retained for the robust no-op/`ENOSYS` surface before it gains effects. | +| `shmget(344)`; `SHMGET_ARGUMENTS`; `u32::try_from(process_size!(1))` | Slot 1 is `size_t`, while the current segment implementation has a separate `u32` ceiling. `0x1_0000_1000` formerly allocated 4,096 bytes. | Preserve the native value first, then reject it if the implementation cannot represent it. No segment is allocated from an aliased low word. | +| `sendfile(294)`, `copy_file_range(290)`, and `splice(291)`; `reportable_channel_transfer_count` | Count is `size_t`, but a successful channel result is signed `i32`. Preserving a request above `i32::MAX` and casting the completed byte count afterward could publish a negative errno-looking result after effects. | These operations permit a short result, so work is capped at `MAX_REPORTABLE_TRANSFER_BYTES = i32::MAX` before reading, writing, consuming input, or advancing offsets. Exact and cap+1 helper tests exercise this without allocating 2 GiB. | +| `readahead(293)` | Slot 2 is `size_t`; the advisory implementation currently has no data effect. | `ProcessSize` is still validated. No-op is a disposition, not permission to truncate future input. | +| `signalfd4(246)`, `signalfd(377)`, `epoll_pwait(241)`, `ppoll(251)`, and the native `{ sigset_t *, size_t }` nested in `pselect6` | Signal-set width carriers are native `size_t`; low-word parsing could make a malformed wasm64 width appear to equal eight. | Direct slots use `ProcessSize`; the bespoke nested parser preserves the caller-native field. A non-null mask must still have the generated exact signal-mask width. | +| `sched_setaffinity(237)` and `sched_getaffinity(238)` | Linux's raw parameter is `unsigned int`, not the public musl wrapper's `size_t` (`kernel/sched/syscalls.c` declares `sched_getaffinity(pid_t, unsigned int, ...)`). | Intentionally `U32` and consumed through `u32_argument`. Widening it would invent an ABI rather than fix truncation. | +| Legacy `signal(73)`; `SIGNAL_ARGUMENTS`; `exact_u32_argument`; `kernel_signal` | Handler a1 is Kandelo's supported `u32` WebAssembly function-table index, not a linear-memory pointer. `0x1_0000_0001` formerly aliased index 1. | `ExactU32` rejects nonzero high bits, matching the existing wasm64 `sigaction` translator. A larger table-index domain would be a separate ABI decision. | +| Descriptor-planned read/write, socket, polling, pathname, message, and vector counts | Public counts may be `size_t`; they must not pass through JavaScript `Number` or a low-word scalar merely because the final transport is bounded. | `#handleSyscallInner` computes argument extents from `rawArgs`, checks safe arithmetic and the caller range, then publishes only the exact capacity-proven staged extent. The next subsection records which operations may legally be short. | +| `mincore`, `tee`, `vmsplice`, `process_vm_{readv,writev}`, xattr operations, and `remap_file_pages` | Their documented pointers/counts remain native, but current Rust stubs do not dereference or write them. | Reviewed stub/`ENOSYS` or no-effect disposition only. A real implementation must add the checked ownership path before its raw-pointer allowance is removed. | + +The historical direct C dispatcher was scanned separately because changing a +C type can change a Wasm function signature even when the channel is +unchanged. + +| Direct C/import/export group | Evidence and disposition | ABI conclusion | +|---|---|---| +| `kernel_signalfd4`, `kernel_sendfile`, `kernel_set_tid_address`, and `kernel_{set,get}_robust_list` in `libc/glue/{syscall_imports.h,syscall_glue.c}`; matching `wasm_api.rs` exports | Declarations now use `size_t`/`uintptr_t` where the syscall does; `kernel_signal` deliberately uses `u32`. | The shipped kernel target is wasm32, so these types still lower to the existing `i32` signatures. `abi/snapshot.json` retains `kernel_sendfile: (i32,i32,i32,i32) -> i32`, identical to `HEAD`; no extra bump follows solely from the source-type correction. | +| Direct path/string/record/buffer imports declared with C pointers | Those addresses belong to process memory, while the matching Rust exports dereference kernel memory and historically carried no allocation capacity. Updating pointer spelling cannot bridge the two address spaces. | The supported channel descriptor or bespoke path copies through a checked kernel-owned region. The legacy direct operation remains rejected; it is not an alternate scratch protocol. | +| Direct `epoll_pwait`, `ppoll`, and `pselect6` signal-mask widths | `syscall_glue.c` compares the native `size_t` carrier with the fixed mask width before calling an export that does not carry that size; `pselect6` reads its native nested `{ pointer, size_t }` record. | This source check does not add an export parameter or change the shipped wasm32 snapshot. The supported channel path independently validates the same native-width contract. | +| Direct `kernel_{mmap,munmap,mprotect,mremap,madvise}`, futex, and clone calls | Rust memory exports use `usize`, while the historical C dispatcher still declares/casts raw `u32` addresses and lengths; its futex/clone process pointers are also `u32`. | This is **not** a wasm64-safe direct interface. A future distributable wasm64 kernel would lower `usize` to `i64` and require an explicit export/signature ABI decision. | +| Stale direct `kernel_ipc_shmget(int32_t key, int32_t size, ...)` | No matching Rust export or snapshot entry exists; the supported channel path is the exact `ProcessSize` implementation above. A complete legacy `syscall_glue.c` artifact requests unsupported `kernel.*` functions and `assertSupportedKernelFunctionImports` rejects it before instantiation. Current SDK, program, libc, POSIX, Sortix, and browser build scripts link `channel_syscall.c`. | `worker-kernel-import-contract.test.ts` pins the fail-before-instantiation policy; the stale declaration is not treated as a compatibility API. The direct source edits add no ABI epoch beyond ABI 43 and do not hide a future wasm64 signature change under today's wasm32 snapshot. | + The option-sensitive `prctl` numbers and name width, the Fcntl lock-record width, and the signal-mask width are also cross-layer marshalling contracts. They are defined once in the `prctl` and `kernel_scratch_wire` modules in @@ -210,6 +304,64 @@ per-suballocation capacity would require a further ABI field (or removing the alignment slack); this audit does not overstate the information available to the current Rust validator. +### Generic argument-sized capacity is not permission to shorten + +`CentralizedKernelWorker.#handleSyscallInner` plans generated +`SyscallArgSize::Arg` records in `host/src/kernel-worker.ts`. When an otherwise +simple one-byte multiplier would cross `CH_DATA_SIZE`, the historical generic +fallback reduced both the staged extent and the count visible to Rust. That is +memory-safe only when the syscall is permitted to perform that shorter +operation. It is not a general ownership rule: an atomic message, socket +address, option object, or complete-or-error result cannot be made safe merely +by changing the caller's count. + +The following is the closed audit of every simple generated `Arg` record. +“Bounded contract” means shortening is not the reason the path is safe; a +separate generated platform maximum proves that a valid result can never +reach the fallback. Those maxima need exact-boundary and drift coverage. + +| Syscall and generated pointer/count slots | Short-operation disposition | Required ownership/capacity action and WHY | Focused executable evidence | +|---|---|---|---| +| `read(3)` `a1/a2`; `write(4)` `a1/a2`; `pread(64)` `a1/a2`; `pwrite(65)` `a1/a2` | The ordinary descriptor is never shortened: a count above `CH_DATA_SIZE` diverts before generic planning to `#handleLargeRead` or `#handleLargeWrite`. | Keep the one-operation Rust-owned large-transfer reservation. This is required even though ordinary files and streams may return short: `read`/`write` can name a datagram socket, and splitting or pre-shortening would change one message. | Existing exact/capacity+1 scalar and vector transfer tests cover reservation failure, sequential/interleaved attempts, wasm32/wasm64 ranges, and one-datagram behavior. Retain a direct scalar datagram boundary case. | +| `getrandom(120)` `a0/a1`; `getdents64(122)` `a1/a2` | **Short operation permitted.** Random generation may return a prefix. `getdents64` may return a whole-record prefix and resume at the next cookie. | The generic count cap is semantically legal only for these two records. `getdents64` must retain its pending-entry/cookie invariant so an entry is never split or lost. | Cover channel capacity and capacity+1. The directory case must prove every returned record is complete and the following call returns the unconsumed suffix. | +| `getcwd(23)` `a0/a1` | **Bounded contract, not short.** The result is complete with its NUL or `ERANGE`; kernel CWD state is strictly shorter than generated `PATH_MAX` (4,096). | A valid result fits one channel independently of the caller's larger capacity. Preserve the CWD admission invariant and generated-limit drift test; never document a partial `getcwd` result as legal. | Set/query `PATH_MAX-1`, reject a CWD at `PATH_MAX`, and pass caller capacities at channel capacity and capacity+1 without changing the complete result. | +| `realpath(109)` path `a0`, output `a1/a2` | **Bounded contract, not short.** Both the accepted input and canonical result are shorter than generated `PATH_MAX`. | The two maximum path extents fit together below `CH_DATA_SIZE`. Preserve the namespace resolver's `PATH_MAX` enforcement and an aggregate drift assertion. | Exercise an exact maximum canonical path plus output capacities at its exact size/size+1 and at channel capacity+1. | +| legacy `readdir(26)` fixed record `a1`, name `a2/a3` | **Bounded contract, not short.** One 16-byte record plus a `NAME_MAX` (255) name fits. | Preserve the generated/namespace `NAME_MAX` relationship and require the host iterator to return one complete name. A shortened name after advancing the iterator would lose directory state. | Exact 255-byte name, 254-byte destination failure behavior, and channel-capacity+1 caller capacity with one complete entry. | +| `readlink(19)` path `a0`, output `a1/a2`; `readlinkat(102)` path `a1`, output `a2/a3` | **Must not generically shorten.** The caller's `bufsiz`, not an internal transport cap, decides whether POSIX truncation occurs. Direct readlink does not impose `PATH_MAX`, and `_PC_SYMLINK_MAX` is currently indeterminate. | Use an exact/large owned region, or define and enforce a real cross-layer symbolic-link target maximum that leaves room for the path. “Readlink may truncate” is not permission to truncate a target that fits the caller's actual buffer. | Create or inject a target larger than the ordinary remaining channel extent but smaller than caller `bufsiz`; require the complete target. Also cover exact caller capacity and capacity-1 truncation. | +| `getenv(43)` name `a0`, output `a1/a2` | **Confirmed live false-`ERANGE` edge.** The operation is complete-or-`ERANGE`; it does not return a prefix. A process metadata entry may occupy 65,536 bytes, while the name and eight-byte alignment leave less output space. | Use aggregate exact/large ownership or reduce and document the metadata-entry contract consistently at every admission point. Do not silently lower only this call's capacity. | Install a maximum entry such as `X=` plus a value that fits the current metadata-entry ceiling. A caller buffer that holds the value must receive it; exact value capacity succeeds and one byte less returns `ERANGE`. | +| `mq_timedsend(333)` message `a1/a2`; `mq_timedreceive(334)` destination `a1/a2` | **Confirmed atomic-message defect.** Send must enqueue the complete message or fail. Receive must compare the caller's real capacity with the authoritative open queue's `mq_msgsize` before dequeue. | Query `mq_msgsize` before allocation: report `EMSGSIZE` for an oversized send or undersized receive, stage exactly the queue maximum for receive, and route the complete message plus priority/timeout records through the fixed-or-tokenized capacity-owned channel. Rust caps queue creation at the reportable-result domain and makes allocation failure atomic. The generic immutable snapshot freezes the request/deadline while Rust pins the exact mqueue descriptor. | Queue `mq_msgsize` at fixed-channel capacity and capacity+1; send exact/+1, receive exact/+1, verify no prefix enqueue, no dequeue on `EMSGSIZE`, allocation failure before mutation, sequential reuse, blocked-wake immutability, and descriptor close/reuse. | +| `bind(51)` `a1/a2`; `connect(54)` `a1/a2` | **All-or-nothing input object.** A socket address is not a short byte stream. | Validate family-specific minimum and maximum native `sockaddr` lengths and reject unsupported excess before copying. Do not allocate a caller-requested giant address and do not change its length to the channel remainder. In particular, bound AF_UNIX names so later address-producing calls have a finite maximum. | Supported IPv4, IPv6, pathname and abstract AF_UNIX exact maxima/+1; an oversized range with a valid prefix must reject without binding or connecting. | +| `setsockopt(59)` `a3/a4` | **All-or-nothing option object.** Supported scalar, timeout, linger, string, and multicast records have option-specific layouts; no “short setsockopt” result exists. | Select an option-specific exact/canonical maximum and reject invalid lengths. Never let a channel cap choose wasm32 versus wasm64 multicast layout or turn a future variable option into a prefix operation. | Exact/short/long cases for each structured option, including unambiguous wasm32/wasm64 group records and an oversized buffer with a valid fixed prefix. | +| `send(55)` `a1/a2` | **Must preserve one operation.** Stream sends may return short, but the same syscall sends atomic datagrams. | Route the real count through the Rust-owned large transaction, or perform an authoritative socket-type/datagram-limit preflight before any count rewrite. A generic prefix success is invalid. | AF_UNIX and IP datagram at channel capacity/+1: receive either the complete one message or observe the correct error, never a successful prefix. Retain a stream short-send case. | +| `recv(56)` `a1/a2` | **Must preserve the caller's capacity.** Streams may return short; datagram receive consumes one message and reports truncation relative to the caller's buffer. | Use an exact region, or generate/enforce a datagram ceiling no greater than the independently staged capacity. An internal cap must not create `MSG_TRUNC` or discard bytes that fit the caller's actual buffer. | Queue a datagram at the supported maximum and receive into capacity, capacity-1, and channel-capacity+1 buffers with/without `MSG_PEEK` and `MSG_TRUNC`. | +| `sendto(62)` payload `a1/a2`, address `a4/a5` | **Two all-or-nothing inputs.** Payload is one datagram and the address is one native object. | Plan one checked aggregate region: exact payload plus a bounded native address. The current descriptor order can let payload consume the channel, reduce address length to zero, and change the destination or produce `EDESTADDRREQ`. | Maximum payload with IPv4/IPv6/AF_UNIX address, aggregate exact/+1, oversized address, and an unconnected socket proving no zero-address fallback. | +| `recvfrom(63)` destination `a1/a2`, address `a4` via `*a5` | **One message plus value-result address.** Shortening data can discard a datagram the caller could hold; reserving the caller's entire address capacity can reject before receiving even though the actual address is small. | Plan exact data capacity plus `min(caller address capacity, supported sockaddr maximum)` in one owned aggregate. Copy back only the detached actual address and length. | Maximum datagram with non-null address, data/address aggregate exact/+1, caller address capacity above channel size, truncation/peek, and no dequeue on preflight failure. | + +The related generated `Deref` value-result paths do not use the simple cap: +`accept(53)`/`accept4(384)` address `a1` via `*a2`, +`getsockopt(58)` option output `a3` via `*a4`, +`getsockname(114)`/`getpeername(115)` address `a1` via `*a2`, and the +`recvfrom(63)` source address above. Generic planning currently reserves the +caller's entire advertised capacity and returns `EINVAL` when it exceeds the +remaining channel, even though the implementation can produce only a small, +known result (currently at most the 232-byte `TCP_INFO` or a supported native +socket address). These paths must stage +`min(caller capacity, generated supported-output maximum)`, preserve the +captured value-result semantics, and copy back only the actual detached bytes. +Focused regressions must pass an otherwise valid capacity of +`CH_DATA_SIZE + 1`, preserve canaries, and prove that a rejected accept does +not consume or leak the pending connection. + +The xattr syscall numbers 350–359 currently have no generated pointer +descriptors, and their Rust stubs do not dereference or write their pointer +slots (`get` returns `ENODATA`, `list` returns zero, and `set` currently +returns success). They are capacity-safe only because the surface is not +implemented, not because a raw pointer or partial xattr is valid. Keep these +exact stubs on the reviewed raw-pointer/static-contract allowlist until a +truthful implementation exists. A future get/list operation is +complete-or-`ERANGE`, and set is an all-or-nothing input; each must enter the +checked ownership abstraction before the stub allowance is removed. + ## Allocation inventory The last column records current source safety and a coverage pointer only; it @@ -221,13 +373,14 @@ draft PR ledger, independently of these source-safety rows. | Region and symbols | Allocating owner; pointer/capacity | Maximum accepted source | Lifetime and overlap | Hosts / widths | Historical audited-head finding | Current safety disposition and coverage notes | |---|---|---|---|---|---|---| | Raw allocator boundary, `crates/kernel/src/wasm_api.rs::kernel_alloc_scratch`; `crates/kernel/src/scratch_alloc.rs::layout` | Rust global allocator; successful pointer owns exactly the validated `Layout` size | The export accepts a `u32` request, but a successful allocation is further bounded by the aligned Rust `Layout`/`isize::MAX` domain | Allocation is retained for the kernel lifetime; no host-side free or growth workaround | Node/browser; wasm32/64 kernel | **Unsafe failure boundary.** Invalid `Layout` construction could trap instead of reporting allocation failure | **Implemented; validation pending.** Zero/invalid layouts and allocator-null return zero; the host rejects an invalid zero or out-of-memory-range result before constructing a region | -| Main syscall scratch, `CentralizedKernelWorker.scratchRegion` | Rust `kernel_alloc_scratch`; `KernelScratchRegion`, 65,608 bytes (`CH_TOTAL_SIZE`) | Each layout is checked against the region; ordinary data payload is at most 65,536 bytes (`CH_DATA_SIZE`) | Kernel lifetime; one synchronous lease per dispatch/copy; nested leases fail | Node and browser; wasm32/64 kernel | **Unsafe contract.** Bare `scratchOffset`; several live overflows | **Implemented; validation pending.** All allocator-owned access is lease-mediated | -| TCP/pipe scratch, `tcpScratchRegion`, `requireTcpScratchRegion` | Rust `kernel_alloc_scratch`; `KernelScratchRegion`, 65,536 bytes | One checked network/pipe chunk, at most 65,536 bytes | Kernel lifetime; worker callbacks/messages detach bytes before yielding | Node/browser; wasm32/64 kernel | **Safe sizes, weak contract.** Private pointer reached other code | **Implemented; validation pending.** Region stays private and all access is synchronously leased | -| Large spawn scratch, `beginLargeSpawnScratch`, `SpawnScratchBuffer` | Rust `Vec` through required `kernel_spawn_scratch_begin/pointer/capacity/cancel`; the returned token gates both pointer and capacity, while separate pointer-free retained-capacity telemetry grants no write authority | Complete blob at most 8,417,320 bytes; ordinary blobs use main scratch | Kernel-lifetime high-water allocation, but a fresh exclusive token and single-use host region per operation. Begin and queries are nonblocking; begin may move only while idle. After every successful begin, host cleanup runs in `finally`. Commit/cancel wait on the same no-import lock and return with a definitive token state; cleanup failure is fatal and leaves the host reentry guard closed | Node/browser; wasm32/64 kernel | **Safe after #1094, weak contract.** Fixed 8,417,320-byte allocation retained after first large use | **Safe in current source.** `kernel_spawn_reserved_process` accepts token+length rather than a bare pointer, with no ABI-42 fallback. The focused Node/Chromium sizing measurements are historical pre-retarget evidence; the frozen final-head rerun remains pending | -| Audio drain, `WasmPosixKernel.audioScratchRegion` | Rust `kernel_alloc_scratch`; 65,536-byte `KernelScratchRegion` bound to the exact Wasm instance and memory that allocated it | `min(out.byteLength, capacity)` and checked Rust return count | One kernel-wrapper generation; one synchronous drain lease. `init` and `initWithMemory` are mutually exclusive one-shot entry points, so a cached region cannot survive an instance replacement | Node/browser; wasm32/64 kernel | **Confirmed unsafe/uncertain.** Pointer/range and producer count were incomplete, and a later second initialization could leave the cached region bound to the old generation | **Safe in current source.** Allocation, requested bytes, current range, returned count, and one-generation lifetime are checked | -| Public wrapper temporary storage, `apiScratchRegion` | Rust `kernel_alloc_scratch`; 65,536-byte `KernelScratchRegion` bound to one exact kernel generation | Each socket/poll/terminal/ioctl/uname/pipe/rusage/select request must fit | One kernel-wrapper generation; synchronous public-call lease. Concurrent or post-success initialization rejects before state mutation; a failed first attempt clears partial state and remains retryable | Node/browser; wasm32/64 kernel | **Confirmed unsafe.** Hard-coded addresses 4 and 16 were not allocations, and later reinitialization could pair an old cached region with a new memory/instance | **Safe in current source.** All temporary public API storage is allocator-owned and cannot outlive its generation | -| Rust-lent host-import destinations, `checkedWasmImportMemoryRange`, `readKernelBytes`, `writeKernelBytes` | Rust slice/local/struct; pointer plus explicit capacity, or a generated authoritative fixed-format size such as the 68-byte KMS mode record, for one import call | Genuine producer span no larger than the Rust-supplied or generated capacity | Only the synchronous import; backend data is staged in host-owned memory and no kernel view is lent or retained | Node/browser; wasm32/64 kernel | **Valid ownership, incomplete checks.** Lossy conversions, live-view lending, and clamping writes existed | **Implemented; validation pending.** Signed-wasm32/wasm64 pointer normalization, complete range, intrinsic producer span, detached/staged backend I/O, and producer/result length precede one publish | -| Unsafe trusted-embedder accessors, `WasmPosixKernel::{getMemory,getInstance}` and `CentralizedKernelWorker::{getKernel,getKernelInstance}` | Exposes the complete raw kernel memory/instance, not a capacity-bearing allocation | Unrestricted by design; consumers are trusted to uphold the kernel ABI | Repository transfer code does not use this path; external direct mutation has no lease or overlap guarantee | Node/browser; wasm32/64 kernel | Existing public low-level/debug API | **Reviewed out-of-contract boundary.** Explicitly documented as unsafe; the static repository audit does not claim to control downstream raw-memory writes | +| Main syscall scratch, `CentralizedKernelWorker.#scratchRegion` | Rust `kernel_alloc_scratch`; `KernelScratchRegion`, 65,608 bytes (`CH_TOTAL_SIZE`) | Each layout is checked against the region; ordinary data payload is at most 65,536 bytes (`CH_DATA_SIZE`) | Kernel lifetime; one synchronous lease per dispatch/copy; nested leases fail | Node and browser; wasm32/64 kernel | **Unsafe contract.** Bare `scratchOffset`; several live overflows | **Implemented; validation pending.** All allocator-owned access is lease-mediated, and reflection cannot recover the region | +| Generic widened transfer scratch, `crates/kernel/src/transfer.rs::{TransferScratch,GlobalTransferScratch}`; `kernel_transfer_scratch_{begin,pointer,capacity,cancel}`; `kernel_transfer_{channel,io}_execute` | Rust owns a fresh initialized, eight-byte-aligned `Vec` byte prefix. A positive opaque token is the sole authority for its pointer and exact authorized byte capacity; spare vector capacity is never exposed | The allocator boundary is generated `MAX_TRANSFER_ALLOCATION_BYTES` (`u32::MAX`). Each consumer applies its narrower semantic/result ceiling before effects, including `MAX_REPORTABLE_TRANSFER_BYTES` for scalar/vector and message payloads | One exclusive Reserved → Executing → Ready transaction. Pointer/capacity queries work only while Reserved; ordinary completion revokes the host region and cancels/drops the vector. Executing rejects begin/query/cancel/reuse; an export trap leaves ownership uncertain and fail-stops the kernel generation rather than reusing bytes | Node/browser shared host path; kernel wasm32/64 and guest wasm32/64 | **Missing on the audited head.** Variable transfers either overfilled the fixed mailbox or required a protocol-specific large allocation | **Capacity-safe in current source; static-contract rerun pending.** Rust proves base alignment, initialized exact capacity, allocation failure, token exhaustion, sequential/interleaved exclusion, and invalid state transitions. The host must still receive a clean final rerun of the reservation-authority and entry-context gates before this row can be called validated | +| TCP/pipe scratch, `CentralizedKernelWorker.#tcpScratchRegion` and `#requireTcpScratchRegion` | Rust `kernel_alloc_scratch`; `KernelScratchRegion`, 65,536 bytes | One checked transport chunk, at most 65,536 bytes | Kernel lifetime; worker callbacks/messages detach bytes before yielding | Node/browser; wasm32/64 kernel | **Safe sizes, weak contract.** Private pointer reached other code | **Implemented; validation pending.** The region and accessor are runtime-private and all access is synchronously leased | +| Large spawn scratch, `beginLargeSpawnScratch`, `SpawnScratchBuffer` | Rust `Vec` through required `kernel_spawn_scratch_begin/pointer/capacity/cancel`; the returned token gates both pointer and capacity, while separate pointer-free retained-capacity telemetry grants no write authority | Complete blob at most 8,417,320 bytes; ordinary blobs use main scratch | Kernel-lifetime high-water allocation, but a fresh exclusive token and single-use host region per operation. Begin and queries are nonblocking; begin may move only while idle. After every successful begin, host cleanup runs in `finally`. Commit/cancel wait on the same no-import lock and return with a definitive token state; cleanup failure is fatal and leaves the host reentry guard closed | Node/browser; wasm32/64 kernel | **Safe after #1094, weak contract.** Fixed 8,417,320-byte allocation retained after first large use | **Safe in current source.** `kernel_spawn_reserved_process` accepts token+length rather than a bare pointer, with no ABI-42 fallback. This document makes no retained-memory or performance claim | +| Audio drain, `WasmPosixKernel.#audioScratchRegion` | Rust `kernel_alloc_scratch`; 65,536-byte `KernelScratchRegion` bound to the exact Wasm instance and memory that allocated it | `min(out.byteLength, capacity)` and checked Rust return count | One kernel-wrapper generation; one synchronous drain lease. `init` is one-shot, and the cached region is runtime-private, so it cannot survive an instance replacement or escape to a caller | Node/browser; wasm32/64 kernel | **Confirmed unsafe/uncertain.** Pointer/range and producer count were incomplete, and a later second initialization could leave the cached region bound to the old generation | **Safe in current source.** Allocation, requested bytes, current range, returned count, and one-generation lifetime are checked | +| Public wrapper temporary storage, `WasmPosixKernel.#apiScratchRegion` | Rust `kernel_alloc_scratch`; 65,536-byte `KernelScratchRegion` bound to one exact kernel generation | Each socket/poll/terminal/ioctl/uname/pipe/rusage/select request must fit | One kernel-wrapper generation; synchronous public-call lease. Concurrent or post-success initialization rejects before state mutation; a failed first attempt clears partial state and remains retryable | Node/browser; wasm32/64 kernel | **Confirmed unsafe.** Hard-coded addresses 4 and 16 were not allocations, and later reinitialization could pair an old cached region with a new memory/instance | **Safe in current source.** All temporary public API storage is allocator-owned, runtime-private, and cannot outlive its generation | +| Rust-lent host-import destinations, `checkedWasmImportMemoryRange`, `WasmPosixKernel.#readKernelBytes`, `#writeKernelBytes` | Rust slice/local/struct; pointer plus explicit capacity, or a generated authoritative fixed-format size such as the 68-byte KMS mode record, for one import call | Genuine producer span no larger than the Rust-supplied or generated capacity | Only the synchronous import; backend data is staged in host-owned memory and no kernel view is lent or retained | Node/browser; wasm32/64 kernel | **Valid ownership, incomplete checks.** Lossy conversions, live-view lending, and clamping writes existed | **Implemented; validation pending.** Signed-wasm32/wasm64 pointer normalization, complete range, intrinsic producer span, detached/staged backend I/O, and producer/result length precede one publish | +| Public kernel authority boundary, `WasmPosixKernel`, `CentralizedKernelWorker`; `KernelEntryGate`; frozen lexical `KernelWorkerEntryContext`; internal `getWasmPosixKernelRuntimeAccess` and module-secret method-only test companions | Public callers receive no instance, export namespace, Memory, import object, scratch region, or region factory. A selected worker ingress receives only one exact gate/scope-bound facade plus a gate-owned post-revocation protocol/observer registrar; the worker stores no ambient current context | No public mutable-memory transfer surface; every synchronous export-bearing helper must receive the exact lexical context, and every later callback must open a fresh ingress | Result-bearing reentry throws. Void ingress is FIFO-queued until the active export and scratch lease unwind. Immediate-only ingress rejects reentry without queueing or retaining caller values. Typed effects run only after scope revocation; serialized host-only operations reject Promise/thenable escape; retained context/export closures, cross-gate scopes, callback export attempts, and rebinding one raw instance or Memory to another generation fail. Runtime-private fields resist reflection. Test-only construction installs frozen exact method companions rather than target-bearing proxies, and those capabilities remain absent from supported host API entry points | Node/browser; wasm32/64 kernel | Existing public accessors and TypeScript-only private fields exposed raw Memory/instance/scratch authority. Interim production and test proxies were also rejected: a synchronously reentrant backend callback could call the production target directly, while a target-bearing test proxy preserved arbitrary binding and mutation authority inside runtime source | **Static-contract validation pending.** Earlier focused gate, reflection, API-entry, test-companion, PTY, FUTEX, and real-worker cases are evidence for their exact source checkpoints. The widened reservation detector and rigid stage → execute → finish helper changed afterward, so neither the kernel-memory ownership gate nor the entry-context gate is recorded as clean until both are rerun on the stabilized source | ## Transfer inventory @@ -238,52 +391,91 @@ or range validation. The last column records current source safety and coverage only. Its mutable exact-head validation status is recorded in the draft PR ledger. +### Blocking-retry ownership and lifetime disposition + +Blocking retries matter to this audit because bytes staged in reusable scratch +must not survive into a promise, timer, callback, or later channel use. After a +first `EAGAIN`, the host retains only detached request values in the exhaustive +`BlockingRetrySnapshot` union. The flattened scalar/vector forms retain their +input bytes or process-memory output destinations, and message forms retain +their detached nested records. A replay acquires a fresh fixed or reserved +scratch lease, stages the snapshot, executes synchronously, detaches output, +and releases the lease before waiting again. + +Rust remains the single authority for stable target ownership. +`BlockingRetryState::token_for_syscall` returns the opaque positive token for +the exact `(pid, tid, normalized operation)` binding created after the first +`EAGAIN`, or zero for a host-only immutable snapshot. A mapped operation +without a binding fails closed. Terminal completion, cancellation, exact +channel retirement, exec, task exit, and process removal consume the Rust +binding before the host forgets the snapshot, so a reused numeric descriptor +or channel cannot redirect a replay. + +ABI 43 also assigns the existing 72-byte channel header's former reserved +`u32` at offset 68 to generated `request_flags`. Libc publishes the generated +cancellation-point and wake-allowed bits before `PENDING`; the host captures +and clears them exactly once, rejects unknown combinations, and freezes them +with the detached retry request. This is part of the async ownership proof: +mailbox reuse cannot replace the cancellation policy of a request whose +scratch lease has already ended. + +| Exact files / symbols | Ownership and lifetime proof | Disposition | +|---|---|---| +| `libc/glue/channel_syscall.c::{__do_syscall_impl,__syscall_cp}`; `crates/shared/src/lib.rs::channel`; generated C/TypeScript ABI constants; `host/src/kernel-worker.ts::#captureChannelRequest` | Generated `request_flags` are published before `PENDING`, consumed and cleared once, validated, and retained only as detached scalars with the represented request | **Safe in current source; generated-file drift and sequential mailbox reuse remain exact-head validation targets** | +| `host/src/kernel-worker.ts::{BlockingRetrySnapshot,#rememberBlockingRetrySnapshot,#replayBlockingRetrySnapshot,#releaseBlockingRetrySnapshot}` | All seven shapes retain detached values only. A retry creates a new synchronous scratch transaction; no lease, native view, or pointer crosses the wait. Rust returns either the exact positive token or zero, and terminal completion releases a positive binding before deleting the host snapshot | **Safe in current source; exact-head execution not claimed here** | +| `crates/kernel/src/blocked_retry.rs::{BlockingRetryOperation::from_syscall,BlockingRetryState::token_for_syscall,take_exact,take_for_tid,take_all}`; `crates/kernel/src/wasm_api.rs::{kernel_blocking_retry_token,kernel_blocking_retry_release}` | Rust owns the only target-classification table. Positive tokens never name an fd/pointer and are consumed exactly once; zero is permitted only for an unmapped host-only snapshot | **Safe in current source; drift coverage targets the single-authority rule** | +| `host/src/kernel-worker.ts::{retireExactChannelAsyncState,retireAsyncChannelsForProcess}`; `crates/kernel/src/syscalls.rs::{cleanup_exiting_thread,release_all_blocking_retry_bindings,discard_blocking_retry_bindings_for_process_removal}` | Exact channel, task, and process lifecycle boundaries release the snapshot and Rust-owned pin once, after any active synchronous scratch transaction has settled | **Safe in current source; sequential, interleaved, exec, task-exit, and process-exit regressions are required targets** | + | File / exact symbols | Owner; pointer and declared capacity | Maximum accepted source and origin | Capacity, range, and pointer proof | Synchronous use / overlap | Hosts / widths | Historical audited-head finding | Current safety disposition and coverage notes | |---|---|---|---|---|---|---|---| | `host/src/kernel-worker.ts::pollWaitableChild`; `crates/kernel/src/wasm_api.rs::kernel_wait_child_poll`; `crates/shared/src/lib.rs::{KernelWaitResult,KERNEL_WAIT_RESULT_SIZE}` | Rust main allocation; one `KernelScratchLease` lends `STRUCT_SIZE_KERNEL_WAIT_RESULT` bytes (160) and passes the same explicit capacity to Rust | Exactly one fixed 160-byte wait-result record generated from the shared `KernelWaitResult` layout | The lease proves allocator ownership, allocation capacity, current-memory bounds, and lossless kernel-width conversion. Rust rejects pointer zero with `EFAULT` and every capacity other than 160 with `EINVAL` before task validation or waitable-child selection | One synchronous poll and detached decode inside the lease. Rejected output ranges cannot select or consume the sole event; a successful non-`WNOWAIT` call publishes the complete record and reaps atomically | Node/browser shared path; kernel wasm32/64. The shipped real-Wasm regression executes wasm32, and host mocks exercise bigint pointer handling | **Unsafe ABI-42 contract.** The export accepted a bare result pointer, so the host/Rust boundary could not prove that 160 writable bytes belonged to the allocation before selecting the child event | **Safe in current source.** The real-Wasm regression covers pointer zero, capacities 159/160/161, canaries, rejected-call non-consumption, exact-capacity reap, and the following `ECHILD` result | -| `host/src/kernel.ts::{intrinsicBufferSourceSpan,bufferSourceToArrayBuffer,init,initWithMemory,initialize}` | Caller supplies kernel module bytes; the host immediately owns one detached `ArrayBuffer` snapshot, then publishes one exact instance/memory generation | Exact intrinsic `ArrayBuffer`, typed-array, or `DataView` byte window accepted by the WebAssembly compiler | Captured native internal-slot getters reject non-genuine/detached sources and ignore subclass span getters; pointer-width detection and compilation consume the same snapshot. An explicit initialization state rejects a concurrent or post-success initializer before it mutates width, memory, instance, or cached scratch authority | Snapshot completes before the asynchronous compile; later caller mutation cannot replace either consumer's bytes. A failed first instantiation clears partial state and permits one clean retry; a successful wrapper is one-shot | Node/browser; kernel wasm32/64 | **Confirmed pointer-width and generation-lifetime defects.** A view subclass could make width detection parse decoy bytes while the engine compiled its intrinsic bytes; a second init could leave cached scratch authorized against the old instance | **Safe in current source.** Spoofed-input, wasm32/wasm64 cached public/audio scratch, rejected reinit, concurrent init, and failed-init retry regressions cover the contract | -| `host/src/kernel-worker.ts::replaceProcessMetadata` | Rust main allocation; private `scratchRegion`, 65,608 bytes; payload begins at `CH_DATA` | One metadata entry at most `CH_DATA_SIZE` (65,536); exec argv/environment aggregate at most generated `ARG_MAX` | Detached caller bytes; lease proves owned allocation and current memory; Rust return count is bounded | One lease and Rust call per entry; view is reacquired after possible growth; no overlap | Node/browser; kernel and guest wasm32/64 | Sizes fit, but a bare pointer represented ownership | **Implemented; validation pending.** Lease-mediated staging | +| `host/src/kernel.ts::{intrinsicBufferSourceSpan,bufferSourceToArrayBuffer,WasmPosixKernel.init}` | Caller supplies kernel module bytes; the host immediately owns one detached `ArrayBuffer` snapshot, then publishes one exact instance/memory generation | Exact intrinsic `ArrayBuffer`, typed-array, or `DataView` byte window accepted by the WebAssembly compiler | Captured native internal-slot getters reject non-genuine/detached sources and ignore subclass span getters; pointer-width detection and compilation consume the same snapshot. An explicit initialization state rejects a concurrent or post-success initializer before it mutates width, memory, instance, or cached scratch authority | Snapshot completes before the asynchronous compile; later caller mutation cannot replace either consumer's bytes. A failed first instantiation clears partial state and permits one clean retry; a successful wrapper is one-shot | Node/browser; kernel wasm32/64 | **Confirmed pointer-width and generation-lifetime defects.** A view subclass could make width detection parse decoy bytes while the engine compiled its intrinsic bytes; a second init could leave cached scratch authorized against the old instance | **Safe in current source.** Spoofed-input, wasm32/wasm64 cached public/audio scratch, rejected reinit, concurrent init, and failed-init retry regressions cover the contract | +| `host/src/host-adapter-manifest.ts::readKernelHostAdapterManifest` | Rust owns one static host-adapter manifest in the exact kernel instance; the export supplies its pointer/length and generated `HOST_ADAPTER_MANIFEST_SIZE` supplies the reviewed read extent | Exactly the generated fixed manifest size; extra exported length grants no larger view | The instance/Memory pair is authenticated first, the export pointer is converted losslessly, and the fixed extent is checked against the current genuine `Memory.buffer` before constructing a private `DataView` | Synchronous scalar reads only; the view and buffer are never returned, stored, or written | Node/browser; kernel wasm32/64 | **Reviewed read-only raw-memory path.** It does not match allocator-owned scratch even though it constructs a view over kernel memory | **Reviewed `kernel-read` exclusion; static-gate rerun pending.** The exact view site is allowlisted because it reads a fixed Rust-owned record after the full range proof and grants no variable-write authority | +| `host/src/kernel.ts::WasmPosixKernel::{#hostFutexWait,#hostFutexWake}` | Rust lends one four-byte aligned atomic word in the kernel's shared `Memory`; no allocator-scratch pointer or variable byte region is involved | Exactly four bytes per import; wake count and timeout are scalars | `checkedWasmImportMemoryRange` normalizes wasm32/wasm64 pointers losslessly, proves the current four-byte range, and requires four-byte alignment before constructing the private `Int32Array`; captured `Atomics.wait`/`notify` intrinsics receive only the proved index | One synchronous import. The atomic view is local and does not escape; wait may block the calling worker but retains no host callback or reusable scratch lease | Node/browser where shared-memory Atomics are supported; kernel wasm32/64 | **Reviewed atomic-control path.** It observes/wakes a Rust-owned futex word rather than copying variable host data | **Reviewed `kernel-control` exclusion; static-gate rerun pending.** The two exact view sites are allowlisted, and neither authorizes `set`, `fill`, `DataView` writes, or a caller-selected scratch capacity | +| `host/src/kernel-worker.ts::replaceProcessMetadata` | Rust main allocation; private `scratchRegion`, 65,608 bytes; each entry begins at allocation-relative offset 0 | One metadata entry at most `CH_DATA_SIZE` (65,536); exec argv/environment aggregate at most generated `ARG_MAX` | Detached caller bytes; lease proves owned allocation and current memory; Rust return count is bounded | One lease and Rust call per entry; view is reacquired after possible growth; no overlap | Node/browser; kernel and guest wasm32/64 | Sizes fit, but a bare pointer represented ownership | **Implemented; validation pending.** Lease-mediated staging | | `host/src/kernel-worker.ts::{handleExec,handleExecveat,readExecPathFromProcess,readStringArrayFromProcess,resolveExecPathAgainstCwd,checkedScratchProducerByteLength}` | Exec pathname/argv/environment are detached JS strings read from caller process memory; only CWD/fd-path queries use the 65,608-byte main allocation | Path scan is bounded by generated `PATH_MAX` 4,096; each string by 65,536; complete argv/environment representation, including pointers and NULs, by generated `ARG_MAX` 4 MiB; CWD/fd-path output by 4,096 | Native pointer-array entries are read at guest width and wasm64 values must be losslessly representable; every string must terminate in its caller range; each direct `withLease` query passes exact pointer/capacity, validates Rust's count with `checkedScratchProducerByteLength`, and detaches with `copyOut` before releasing the lease | No scratch view crosses `callbacks.onExec`'s promise; only detached strings/arrays do. Each CWD/fd-path query completes its lease before the callback | Node/browser; guest wasm32/64 independent of kernel width | **Unsafe/uncertain edge.** Async exec and bounded-string paths used bare scratch queries and lossy/incomplete pointer scans | **Implemented; validation pending.** Explicit `PATH_MAX`/`ARG_MAX`, lossless native-pointer, checked producer count, and no-view-across-promise contract | | `host/src/kernel-worker.ts::{ptyMasterWrite,ptyMasterRead}` | Rust main allocation, full 65,608-byte region | Write chunks are `min(remaining, lease.capacity)`; read request is `min(4,096, lease.capacity)` | Write source slice and destination are independently checked; returned write/read count must be a safe integer no larger than the offered chunk/request | One lease per chunk/call; read bytes are detached before `drainPtyOutput`; a second operation cannot enter the active lease | Node/browser; kernel wasm32/64 | **Confirmed unsafe.** `ptyMasterWrite` copied arbitrary `data.length` into the allocation; read trusted the producer count | **Implemented; validation pending.** Exact 65,608 and 65,609 regression | | `host/src/kernel-worker.ts::setCwd` | Rust main allocation, 65,608 bytes | Encoded path must be shorter than generated `POSIX_PATH_MAX_BYTES` (4,096, including the NUL contract) | Length is rejected before acquiring/copying; lease then proves allocation and current-memory bounds | One synchronous lease and `kernel_set_cwd` call; no retained view | Node/browser; kernel wasm32/64 | **Confirmed unsafe.** Copy happened before Rust's `PATH_MAX` rejection | **Implemented; validation pending.** Pre-copy oversized-CWD regression | | `host/src/kernel-worker.ts::{enumProcs,readProcMaps,checkedScratchProducerByteLength}`; Rust exports `kernel_get_cwd`, `kernel_get_fd_path`, and wait/wake/mqueue query helpers | Rust main allocation, 65,608 bytes | Fixed or explicit producer requests, presently no more than 4,096 bytes for paths and 1,280 bytes for listed fixed records | Requested capacity is passed to Rust; returned byte/count value must be safe and fit that capacity before the same lease calls `copyOut` | Producer runs inside one direct checked lease; detached bytes cross any callback/retry boundary | Node/browser; kernel/guest wasm32/64 | Fixed requests fit; several producer counts were trusted | **Implemented; validation pending.** Inline checked leases and `checkedScratchProducerByteLength` replace the removed aggregate helper | -| `host/src/kernel-worker.ts::CentralizedKernelWorker::_handleSyscallInner`; `host/src/generated/abi.ts::SYSCALL_ARGS`; `crates/shared/src/host_abi.rs::{SyscallArgDesc,SyscallArgSize}`; `crates/kernel/src/channel_scratch.rs::{ChannelScratchRegion,validate_channel_scratch_arguments,validate_prctl_layout,checked_cstr_len}`; `crates/kernel/src/wasm_api.rs::dispatch_channel_syscall` | Rust main allocation; channel is 72 bytes and data capacity is exactly 65,536; `kernel_handle_channel(offset, capacity, pid)` carries that complete capacity through dispatch | Sum of all descriptor-sized arguments, including alignment, must fit `CH_DATA_SIZE`; every pointer descriptor is explicitly required or nullable; size expressions originate in generated shared ABI metadata and raw syscall counts; every C string must terminate inside the remaining channel allocation | The host rejects negative, fractional, unsafe-integer, multiplication/addition overflow, positive null unless explicitly nullable, and a non-null `Deref` outer buffer without its length pointer. Null argument-sized zero-length buffers become a non-null owned empty range. All `Deref` lengths are captured before planning, then used for both buffer sizing and staged length independent of descriptor order. Rust verifies canonical pointer order, alignment, non-overlap, allocation bounds, descriptor nullability, and bespoke layouts before a checked pointer can reach dispatch. It also rejects a C-string pointer outside the numeric region or a missing in-region NUL; pathname exports separately retain generated `PATH_MAX` semantics. `prctl` uses an option-sensitive validator: name operations receive one exact required 16-byte range and scalar options receive no scratch pointer | Planning retains host-owned copies only; one lease stages, dispatches, detaches output inline in `_handleSyscallInner`, and releases; nested or promise-escaping lease use fails. Rust recomputes a dynamic range from the staged length but cannot reconstruct a separate unpadded host capacity within one alignment bucket because the wire does not encode one; the pre-captured host value under this lease remains the exact-capacity authority | Node/browser; guest wasm32/64 independent of kernel width | **Confirmed unsafe/uncertain domain edges.** Some raw pointers bypassed descriptors, fixed outputs such as `pipe(NULL)` were implicitly treated as nullable, `prctl` scalars were treated as pointers, `Deref` planning could reread mutable lengths, staging was not ownership-bearing, and Rust's bare-pointer scanner used `PATH_MAX` as both an allocation and semantic bound | **Implemented; focused validation passed.** Exact/capacity+1, positive-null and owned-empty, explicit-nullability drift, option-sensitive `prctl`, reordered/mutated `Deref`, exact raw-process-address allowlist, bounded C-string EFAULT, and non-path strings above `PATH_MAX` | -| `host/src/kernel-worker.ts::{_handleSyscallInner,completeChannel,handleBlockingRetry,handleSleepDelay}`; `PreparedChannelCompletion` | Output belongs to the just-completed main-scratch lease, but the only state allowed to outlive it is a detached `Uint8Array` plus its already-validated process destination | Exactly the output descriptors and successful byte counts detached inline in `_handleSyscallInner` before lease release; error and interrupted completions publish no staged output | `completeChannel` has no scratch-read fallback. Retry, timeout, stopped-process, signal, and teardown state accept only explicit detached writes; absent output means an empty list | Detachment occurs synchronously in the dispatch lease; later callbacks may overlap another scratch use without observing its bytes | Node/browser; guest/kernel wasm32/64 | **Confirmed lifetime defect.** Deferred completion could reread the shared allocation after another operation replaced it | **Implemented; validation pending.** Immediate-timeout poll, EAGAIN `recvmsg`, interrupted sleep, and stale-scratch regressions | -| `host/src/kernel-worker.ts::{PreparedChannelCompletion.deferredClone,failDeferredCloneLaunch}` | Caller process mailbox, not kernel scratch; the original four-byte parent-TID destination is validated and retained as a scalar | Exactly one `pid_t` word when the original clone requested `CLONE_PARENT_SETTID` | Rollback uses the captured `parentTidPointer`; it never rereads mutable flags or a replacement pointer from a parked mailbox | Parked completion may span worker construction and stop/continue callbacks, but retains no process or scratch view | Node/browser; guest wasm32/64 | **Confirmed deferred-lifetime defect.** Failure rollback reread mutable mailbox metadata and could clear a replacement address | **Implemented; validation pending.** Mailbox-replacement regression proves only the original word is cleared | -| `crates/shared/src/process_layout.rs`; `crates/shared/src/host_abi.rs::SyscallArgSize::ProcessLayout`; `crates/kernel/src/process_wire.rs::{read_*,write_*}` | Main data capacity 65,536; exact width-selected native record is the capacity passed to Rust | `stack_t` 12/24, kernel-facing `itimerval` 16/32, `mq_attr` 32/64, `sigevent` 64/64, `statfs` 88/120, `sysinfo` 312/368, and `siginfo_t` 128/128 | Host selects by guest pointer width in private slot 5, validates the full caller range, and stages exactly that size; Rust rejects widths other than 4/8 and non-exact slices; output padding/reserved bytes are zeroed | One dispatch lease; Rust serializes into the complete lent slice before copy-back | Node/browser; guest wasm32/64 independent of kernel width | **Confirmed unsafe mixed-width/native-layout contract.** Fixed wasm32 or partial records truncated wasm64/full native records; stale `sysinfo` syscall 208 conflicted with musl 269 | **Safe in current source.** Historical C-layout and Rust boundary coverage plus the current dirty-tree Node and real-Chromium wasm32/wasm64 process-native fixtures pass; the exact-final-head rerun remains pending | -| `host/src/kernel-worker.ts::dequeueSignalForDelivery`; `crates/kernel/src/wasm_api.rs::kernel_dequeue_signal`; `crates/kernel/src/process_wire.rs::{validate_signal_delivery_output,encode_signal_delivery_record}`; `libc/glue/channel_syscall.c` signal delivery | Rust main allocation; `KernelScratchLease.exportPointer(CH_SIG_BASE, 56)` lends exactly the generated 56-byte signal-delivery record and passes capacity 56 separately | Exactly one generated signal record: signum, handler, flags, raw eight-byte `si_value`, saved mask, `si_code`, two sender/timer metadata words, and alternate-stack pointer/size | The host lease proves the owned allocation and current-memory range; Rust rejects null and any capacity other than 56 before writing. Rust first encodes all 56 bytes into an owned array, then publishes once. The host detaches all 56 bytes before releasing the lease and copies them to the process channel only after a nonnegative result | One synchronous lease per dequeue; the detached record is published only after the lease ends, so a wake or second channel cannot observe partially replaced scratch bytes. The C trampoline reconstructs a native `siginfo_t` and copies only the target-width `union sigval` bytes: four for wasm32 and eight for wasm64 | Node/browser; kernel wasm32/64 and guest wasm32/64 | **Weak capacity and metadata contract.** The old export accepted only a bare output pointer, and its 44-byte payload inside a 48-byte reserved channel area did not carry complete `si_value`, sender/timer metadata, or one authoritative delivery size | **Implemented; focused Node and real-Chromium validation passed on the current dirty tree.** Rust exact-capacity/serialization tests and the rebuilt real-musl process-native fixture cover 56-byte delivery, `SA_SIGINFO`, sender metadata, and target-width C reconstruction on wasm32 and wasm64; the exact-final-head rerun remains pending | -| `host/src/kernel-worker.ts::drainMqueueNotification`; `crates/kernel/src/wasm_api.rs::{queue_mqueue_signal_notification,kernel_mq_drain_notification}`; `crates/kernel/src/mqueue.rs::mq_notify` | Rust main allocation; one leased pointer plus explicit capacity 8 for the wake-only `{ pid: u32, signo: u32 }` record. The full notification value remains in Rust's signal queue rather than this scratch record | At most one eight-byte wake record. `mq_notify(SIGEV_SIGNAL)` accepts only signums satisfying `1 <= signo < NSIG`; zero, `NSIG`, and a negative native value represented as `u32::MAX` are rejected before registration | The lease proves the owned allocation/current memory and Rust requires capacity 8 before writing. The host accepts only safe-integer results 0 or 1; a negative errno, fractional/unsafe value, or value above 1 fails closed before unchanged reusable bytes can be decoded. Rust queues raw eight-byte `si_value`, `SI_MESGQ`, sender PID, and UID before publishing the wake record | The eight bytes are detached inside one synchronous lease. The lease is released before wake/signal processing can reenter main scratch. A rejected registration does not occupy the queue's one-shot notification slot | Node/browser; kernel wasm32/64 and guest wasm32/64 | **Weak capacity and error contract.** The old drain export accepted a bare pointer, and a negative errno was truthy in JavaScript and could decode stale scratch as a fabricated notification; invalid signal registrations were not rejected before occupying the slot | **Implemented; focused Node and real-Chromium validation passed on the current dirty tree.** Native tests cover invalid signums without registration and a valid retry; the rebuilt process-native fixture covers `SI_MESGQ` plus full-width value/sender metadata, and the host regressions prove both fail-closed negative-result handling and the real export's null/7/8/9 boundary with exact eight-byte output canaries; the exact-final-head rerun remains pending | -| `crates/shared/src/host_abi.rs` `timer_create` process-layout descriptor; `host/src/kernel-worker.ts::_handleSyscallInner`; `crates/kernel/src/wasm_api.rs::kernel_timer_create`; `crates/kernel/src/process_wire::read_sigevent` | The caller-native 64-byte `sigevent` is staged in the 65,536-byte main data allocation; the timer ID output has its separately described caller and scratch capacity | Null selects the POSIX default; otherwise exactly 64 bytes. `union sigval` contributes four meaningful bytes for a wasm32 caller or eight for wasm64, while the containing native structure remains 64 bytes on both | Generic descriptor planning proves the complete caller range and main-allocation capacity. The private process-pointer-width slot is passed losslessly to `kernel_timer_create`; Rust accepts only width 4 or 8, selects that exact native layout, and preserves the parsed value as raw `u64` bits through timer state and delivery | One synchronous dispatch lease covers staging, parsing, timer creation, and detached timer-ID copy-back. No native view or scratch pointer survives the call | Node/browser; guest wasm32/64 independent of kernel width | **Confirmed mixed-width value defect.** The old export had no caller-width argument and parsed only a partial `sigevent`, so a wasm64 `sival_ptr` could be narrowed | **Implemented; focused native, Node, and real-Chromium validation passed on the current dirty tree.** Exact/short native-layout tests and the rebuilt process-native fixture cover wasm32 low-32-bit and wasm64 full-64-bit timer values; the exact-final-head rerun remains pending | +| `host/src/kernel-worker.ts::{#handleSyscallInner,#executeCapacityOwnedChannel,#executeReservedChannelDispatch}`; `host/src/generated/abi.ts::SYSCALL_ARGS`; `crates/shared/src/host_abi.rs::{SyscallArgDesc,SyscallArgSize}`; `crates/kernel/src/channel_scratch.rs::{ChannelScratchRegion,validate_channel_scratch_arguments,validate_prctl_layout,checked_cstr_len}`; `crates/kernel/src/wasm_api.rs::{dispatch_channel_syscall,kernel_transfer_channel_execute}` | A complete aligned channel at most 65,608 bytes uses the reusable main allocation; a larger footprint uses one fresh token-bound `TransferScratch` whose initialized capacity is exactly the planned aligned channel size | The sum of all descriptor-sized arguments and alignment is checked against generated `MAX_TRANSFER_ALLOCATION_BYTES`; each syscall's public or implementation limit may be smaller. Every pointer descriptor is explicitly required or nullable, and every C string must terminate inside its remaining owned subrange | The host rejects negative, fractional, unsafe-integer, multiplication/addition/alignment overflow, positive null unless explicitly nullable, and a non-null `Deref` outer buffer without its length pointer. It captures every `Deref` length before planning. The fixed path passes `kernel_handle_channel` its exact 65,608-byte allocation; the widened path passes no host pointer or capacity to `kernel_transfer_channel_execute`, which derives both from the Reserved token. Rust verifies canonical pointer order, alignment, non-overlap, complete allocation bounds, descriptor nullability, bespoke layouts, and in-region C strings before dispatch | `#executeCapacityOwnedChannel` owns one rigid stage → execute → finish transaction. Callers receive neither an execute closure nor entry authority; all writes precede the one fixed/token execution and all readback is detached before lease revocation. Nested, promise-escaping, duplicate, omitted, or reordered execution is structurally unavailable | Node/browser; guest wasm32/64 independent of kernel width; kernel wasm32/64 | **Confirmed unsafe/uncertain domain edges.** Some raw pointers bypassed descriptors, fixed outputs such as `pipe(NULL)` were implicitly treated as nullable, `prctl` scalars were treated as pointers, `Deref` planning could reread mutable lengths, staging was not ownership-bearing, and Rust's bare-pointer scanner used `PATH_MAX` as both an allocation and semantic bound | **Capacity-safe in current source; final static-gate rerun pending.** Exact/capacity+1, positive-null and owned-empty, explicit-nullability drift, option-sensitive `prctl`, reordered/mutated `Deref`, bounded C strings, fixed/widened selection, reservation failure, and token settlement have focused coverage. Blocking-retry request and target ownership is independently complete as recorded in the checkpoint above; this capacity row does not substitute for that lifetime proof | +| `host/src/kernel-worker.ts::{#handleSyscallInner,completeChannel,handleBlockingRetry,handleSleepDelay}`; `PreparedChannelCompletion` | Output belongs to the just-completed fixed or widened scratch lease, but the only byte state allowed to outlive it is a detached `Uint8Array` plus its already-validated process destination | Exactly the output descriptors and successful byte counts are detached inline before lease release; error and interrupted completions publish no staged output | `completeChannel` has no scratch-read fallback. Timeout, stopped-process, signal, and teardown state accept only explicit detached writes; absent output means an empty list | Detachment occurs synchronously in the dispatch lease; later callbacks may overlap another scratch use without observing its bytes | Node/browser; guest/kernel wasm32/64 | **Confirmed scratch-lifetime defect.** Deferred completion could reread the shared allocation after another operation replaced it | **Safe in current source; validation pending.** Scratch-byte lifetime is complete here, while immutable request/target ownership is proved independently by the following retry row | +| `host/src/kernel-worker.ts::{#handleSyscallInner,handleBlockingRetry,#rememberBlockingRetrySnapshot,#replayBlockingRetrySnapshot,#releaseBlockingRetrySnapshot,#forgetBlockingRetrySnapshotAfterKernelLifecycle,#retrySyscallWithinKernelEntry,#retireExactChannelAsyncState}`; `crates/kernel/src/{blocked_retry.rs,syscalls.rs,wasm_api.rs}` | No scratch lease or Wasm view crosses the wait. For all seven snapshot shapes, the host owns detached immutable request state. Rust owns one opaque-token binding when its single authority maps the operation; zero records a host-only immutable snapshot | The represented scalar/vector/channel/message request, including its captured fd/mqd/qid, nested layouts, payload or output destinations, flags, priorities, and deadlines. The token is scoped to the exact pid, tid, and normalized operation and is never a substitute for allocation capacity | On first `EAGAIN`, Rust pins the stable one/two-OFD, MQ, or SysV target before control returns to JavaScript. The host then queries the positive token or authoritative zero and retains the first immutable snapshot only. Replays stage from that snapshot and activate the exact binding; they do not resolve a reused numeric name. Missing exports and negative, out-of-range, mismatched, or stale target-token results fail closed; zero is accepted only when Rust classifies the snapshot host-only. The union is exhaustive for blocking-dispatch replay | The snapshot/token may span promises, timers, and wake callbacks, but no live scratch view does. Terminal completion/cancellation/retirement releases the exact token. Exec, task exit, process exit, signal exit, and forced removal consume Rust pins first; only then does the host forget its snapshot without double release | Node/browser shared source; guest wasm32/64 and kernel wasm32/64 | **Confirmed adjacent request-identity defect, present independently of #1094.** Re-executing from live mailbox/process memory can redirect a blocked request without any scratch overflow | **Safe in current source; exact-head execution is not claimed here.** Focused regression targets cover all seven immutable replay shapes, token/zero classification, mismatch/failure, close-and-reuse, one/two-target bindings, and task/process lifecycle retirement | +| `crates/shared/src/process_layout.rs`; `crates/shared/src/host_abi.rs::SyscallArgSize::ProcessLayout`; `crates/kernel/src/process_wire.rs::{read_*,write_*}` | Main data capacity 65,536; exact width-selected native record is the capacity passed to Rust | `stack_t` 12/24, `mq_attr` 32/64, `statfs` 88/120, `sysinfo` 312/368, and `siginfo_t` 128/128 | Host selects by guest pointer width in private slot 5, validates the full caller range, and stages exactly that size; Rust rejects widths other than 4/8 and non-exact slices; output padding/reserved bytes are zeroed | One dispatch lease; Rust serializes into the complete lent slice before copy-back | Node/browser; guest wasm32/64 independent of kernel width | **Confirmed unsafe mixed-width/native-layout contract.** Fixed wasm32 or partial records truncated wasm64/full native records; stale `sysinfo` syscall 208 conflicted with musl 269 | **Safe in current source; execution not claimed here.** C-layout, Rust boundary, and real-musl wasm32/wasm64 process-native fixtures are the required coverage targets | +| `host/src/kernel-worker.ts::dequeueSignalForDelivery`; `crates/kernel/src/wasm_api.rs::kernel_dequeue_signal`; `crates/kernel/src/process_wire.rs::{validate_signal_delivery_output,encode_signal_delivery_record}`; `libc/glue/channel_syscall.c` signal delivery | Rust main allocation; `KernelScratchLease.exportPointer(CH_SIG_BASE, 56)` lends exactly the generated 56-byte signal-delivery record and passes capacity 56 separately | Exactly one generated signal record: signum, handler, flags, raw eight-byte `si_value`, saved mask, `si_code`, two source-metadata words, and alternate-stack pointer/size | The host lease proves the owned allocation and current-memory range; Rust rejects null and any capacity other than 56 before writing. Rust first encodes all 56 bytes into an owned array, then publishes once. The host detaches all 56 bytes before releasing the lease and copies them to the process channel only after a nonnegative result | One synchronous lease per dequeue; the detached record is published only after the lease ends, so a wake or second channel cannot observe partially replaced scratch bytes. The C trampoline reconstructs a native `siginfo_t` and copies only the target-width `union sigval` bytes: four for wasm32 and eight for wasm64 | Node/browser; kernel wasm32/64 and guest wasm32/64 | **Weak capacity and metadata contract.** The old export accepted only a bare output pointer, and its 44-byte payload inside a 48-byte reserved channel area did not carry complete `si_value`, source metadata, or one authoritative delivery size | **Safe in current source; execution not claimed here.** Rust exact-capacity/serialization tests and the real-musl process-native fixture cover 56-byte delivery, `SA_SIGINFO`, sender metadata, and target-width C reconstruction on wasm32 and wasm64 | +| `host/src/kernel-worker.ts::drainMqueueNotification`; `crates/kernel/src/wasm_api.rs::{queue_mqueue_signal_notification,kernel_mq_drain_notification}`; `crates/kernel/src/mqueue.rs::mq_notify` | Rust main allocation; one leased pointer plus explicit capacity 8 for the wake-only `{ pid: u32, signo: u32 }` record. The full notification value remains in Rust's signal queue rather than this scratch record | At most one eight-byte wake record. `mq_notify(SIGEV_SIGNAL)` accepts only signums satisfying `1 <= signo < NSIG`; zero, `NSIG`, and a negative native value represented as `u32::MAX` are rejected before registration | The lease proves the owned allocation/current memory and Rust requires capacity 8 before writing. The host accepts only safe-integer results 0 or 1; a negative errno, fractional/unsafe value, or value above 1 fails closed before unchanged reusable bytes can be decoded. Rust queues raw eight-byte `si_value`, `SI_MESGQ`, sender PID, and UID before publishing the wake record | The eight bytes are detached inside one synchronous lease. The lease is released before wake/signal processing can reenter main scratch. A rejected registration does not occupy the queue's one-shot notification slot | Node/browser; kernel wasm32/64 and guest wasm32/64 | **Weak capacity and error contract.** The old drain export accepted a bare pointer, and a negative errno was truthy in JavaScript and could decode stale scratch as a fabricated notification; invalid signal registrations were not rejected before occupying the slot | **Safe in current source; execution not claimed here.** Native tests cover invalid signums without registration and a valid retry; the rebuilt process-native fixture covers `SI_MESGQ` plus full-width value/sender metadata, and the host regressions prove both fail-closed negative-result handling and the real export's null/7/8/9 boundary with exact eight-byte output canaries | +| `host/src/kernel-worker.ts::{#handleSyscallInner,#executeCapacityOwnedChannel}`; `crates/kernel/src/wasm_api.rs::kernel_mq_descriptor_msgsize`; `crates/kernel/src/mqueue.rs::{descriptor_msgsize,mq_timedsend,mq_timedreceive}` | Message, priority, and optional timeout records use the fixed main allocation when their complete aligned descriptor layout fits 65,608 bytes; a larger queue message uses one fresh token-bound `TransferScratch` with that exact complete capacity | The authoritative open queue's `mq_msgsize`, capped at `MAX_REPORTABLE_TRANSFER_BYTES`. Send must request no more than that value; receive must advertise at least it, but stages only `mq_msgsize` rather than allocating the caller's possibly larger capacity | Before any allocation write, the required descriptor query validates pid/tid/mqd and returns the queue limit. The host reports `EMSGSIZE` for send-above or receive-below that limit, captures all caller ranges, and routes the complete aligned plan through fixed/token capacity checks. Rust repeats descriptor/message-size checks and fallibly reserves message/vector storage before queue or notification mutation | Each attempt is one rigid synchronous stage → fixed/token execute → finish transaction and retains no scratch view. On first `EAGAIN`, the generic retry snapshot freezes the payload or output address, priority/timeout records, and deadline while Rust pins the exact mqueue descriptor. Replay uses that snapshot and token instead of reparsing a numeric mqd after close/reuse | Node/browser; guest wasm32/64; kernel wasm32/64 | **Confirmed atomic-message capacity defect.** Generic channel shortening could enqueue a prefix, return false `EMSGSIZE`, or make a configured large queue unusable | **Capacity, allocation-failure atomicity, and the represented MQ retry ownership are implemented in current source; exact-final-head validation remains pending.** Focused coverage exists for exact/+1 queue limits, fixed/widened selection, receive preflight, no prefix/no dequeue, allocation failure before mutation, sequential reservation reuse, and stable-target retry behavior | | `crates/shared/src/host_abi.rs::SyscallArgSize::Fixed`; `crates/kernel/src/process_wire.rs::{write_stat,read_sched_param,write_sched_param}` | Main data capacity 65,536; the fixed native record size is part of the generated syscall descriptor | `stat` 112 bytes and `sched_param` 48 bytes on both supported caller widths | The descriptor proves the complete caller range and exact fixed capacity; these records do not use width selection or private slot 5 | One dispatch lease; Rust consumes or fills the complete fixed slice | Node/browser; guest wasm32/64 | **Confirmed partial-record contract.** Earlier descriptors did not name the complete musl object | **Implemented; validation pending.** Fixed-layout C drift checks and Rust exact/short tests | -| Generated `timerfd_settime`, `timerfd_gettime`, `signalfd`, and `signalfd4` descriptors; Rust checked channel-pointer consumers | Main data allocation; native timer records are 32 bytes and the signal mask is exactly eight bytes | Fixed generated record sizes; nullable old-timer output is the only optional timer pointer | Complete caller range, direction, nullability, allocation capacity/current memory, and Rust channel-pointer checks; the raw caller pointer never enters the kernel namespace | One dispatch lease; all input/output is detached at the normal completion boundary | Node/browser; guest wasm32/64 independent of kernel width | **Confirmed address-domain defect.** Caller pointers were passed as kernel pointers | **Safe in current source.** Historical pre-retarget coverage rebuilt the ABI-43 kernel/host artifacts and passed guarded caller-object cases for wasm32 and wasm64; final-head Node and browser reruns remain pending | | `crates/shared/src/host_abi.rs` `Getaddrinfo` descriptor; `host/src/kernel-worker.ts::_handleSyscallInner`; `host/src/kernel.ts::hostGetaddrinfo` | Main data allocation; output capacity exactly four bytes, matching musl's private syscall result | Input is a required NUL-terminated name; name plus four-byte output must fit 65,536; host backend result must be exactly/fewer than the lent four bytes | Full caller name and four-byte output ranges; descriptor capacity and current memory; Rust and host import both receive explicit four-byte capacity | One dispatch lease and synchronous host import; four detached bytes are copied back | Node/browser; guest/kernel wasm32/64 | **Confirmed live caller overwrite.** Fixed 256-byte copy-back wrote 252 bytes beyond musl's four-byte result object | **Implemented; validation pending.** Four-byte result plus 252-byte canary regression | | `host/src/kernel-worker.ts::handleGetgroups`; `crates/kernel/src/wasm_api.rs::kernel_getgroups(size,list_ptr,list_capacity_bytes)` | Rust main allocation; positive request lends one explicit four-byte gid slot; count query lends pointer/capacity zero | Kandelo currently returns exactly one supplementary gid; `size` accepts 0 through `INT_MAX`, but positive size never increases the lent capacity beyond four | Positive caller output range is exactly four bytes; kernel pointer and capacity are staged together; Rust rejects null or capacity below four; returned count must be safe, `<= size`, and `<= 1` | One lease; output is detached before reuse; zero-count query performs no pointer conversion | Node/browser; guest/kernel wasm32/64 | **Confirmed unsafe.** A raw process pointer crossed into the kernel address space and Rust wrote one `u32` without an allocation-capacity contract | **Implemented; validation pending.** Capacity 0/3/4/5, null, count-query, and detached-copy regressions | | `crates/shared/src/host_abi.rs` `Setgroups` descriptor; `host/src/kernel-worker.ts::_handleSyscallInner` | Rust main data allocation, exactly 65,536 bytes | Count times four bytes; maximum one-call source is 16,384 gids from `CH_DATA_SIZE / sizeof(gid_t)` | Checked integer multiplication, complete caller source, descriptor layout, allocation capacity, current memory; count zero ignores the caller pointer and resolves a checked non-null empty scratch address under the final lease | One dispatch lease; no scratch view survives | Node/browser; guest/kernel wasm32/64 | **Unsafe address-domain contract, not a demonstrated live overwrite.** Bare caller pointer could enter the kernel namespace; current Rust did not dereference it | **Implemented; validation pending.** 16,384/16,385, zero-count high pointer, and positive null regressions | | `crates/shared/src/ioctl_contract.rs::IOCTL_REQUEST_CONTRACTS`; `host/src/kernel-worker.ts::_handleSyscallInner` ioctl branch; `crates/kernel/src/wasm_api.rs::kernel_ioctl` | Rust main data allocation; pointer requests receive exact request-specific capacity; scalar/no-argument/unknown requests receive no scratch pointer | Pointer sizes are table-selected: 1–160 bytes in the current table, including `termios` 60 and `DRM_IOCTL_VERSION` 36 for wasm32 or 64 for wasm64 | Unsigned request lookup; exact guest-width size/direction; complete caller range; null and one-byte-short rejection; explicit `buf_len`; Rust repeats kind, width, exact length, null, and current-memory checks. `ScalarI32` requests canonicalize only their low 32 transport bits, so unspecified upper wasm64 C-vararg bytes neither become a pointer nor reach Rust. Known width-incompatible pointer requests return `EOVERFLOW` | One dispatch lease; no pointer is manufactured for scalar/no-arg/unknown requests | Node/browser; guest wasm32/64 independent of kernel width | **Confirmed unsafe/incorrect.** Generic 256-byte staging/copy-back overran small caller objects and scalar values were treated as pointers; width-specific DRM layout was not represented | **Implemented; generated/runtime validation pending.** FIONREAD four-byte canary, exact 4/36/64, short/null, every scalar request with signed/unsigned and dirty-high-bit inputs, no-arg/unknown, and unsupported-width regressions | -| `host/src/kernel-worker.ts::{checkedNetworkIoctlProcessRange,handleIoctlIfconf,handleIoctlIfname,handleIoctlIfhwaddr,handleIoctlIfaddr,handleIoctlIfindex}` | Caller process memory, not kernel scratch; required outer `ifconf`/`ifreq` and the nested process buffer are caller-owned | Command-specific 8/16-byte `ifconf`, 32/40-byte `ifreq`, and `ifconf.ifc_len`; no shared-table maximum substitutes for the nested length | The shared checked process-range proof rejects a null/short outer object and checks the complete nested output range; the wasm64 nested pointer remains `bigint` until lossless conversion. Only `ifc_buf == 0` after a valid outer structure retains Linux size-query semantics | Synchronous host-side handling; no kernel scratch lease or retained view | Node/browser shared code; guest wasm32/64 | **Confirmed unsafe caller-boundary defect.** The former ad-hoc total-memory check accepted outer address zero, and the nested wasm64 pointer was narrowed before its proof | **Implemented; focused Node validation passed.** Exact and one-byte-short outer/nested ranges, capacity+1 output canaries, every network `ifreq` handler, null outer objects, and high/unsafe wasm64 non-aliasing are included in the current 189-test focused transfer-boundary file; the exact-final-head browser rerun remains pending | | `host/src/kernel-worker.ts::handleFcntlLock` | Rust main data allocation; 32-byte `struct flock` | Exactly 32 bytes | Full caller range, owned scratch range, current memory | One synchronous lease | Node/browser; guest/kernel wasm32/64 | Fixed size fit, bare pointer | **Implemented; validation pending** | -| `host/src/kernel-worker.ts::{handleSelect,handlePselect6}` | Rust main data allocation; three optional generated 128-byte fd sets plus timeout/mask records | Generated `FD_SETSIZE` 1,024 and `fd_set` size 128; optional eight-byte kernel mask and native timeout inputs | `nfds` is bounded by the generated set width; every optional fd set, timeout, outer pselect sigmask descriptor, and nested mask range is checked before staging | Each attempt is synchronous; retry owns copies/scalars and no scratch view | Node/browser; guest/kernel wasm32/64 | **Confirmed unsafe caller-range paths and duplicated layout constants** | **Implemented; focused Node validation passed.** Select/pselect count/range boundaries use the generated contract | +| `host/src/kernel-worker.ts::{handleSelect,handlePselect6}` | Rust main data allocation; three optional generated 128-byte fd sets plus timeout/mask records | Generated `FD_SETSIZE` 1,024 and `fd_set` size 128; optional eight-byte kernel mask and native timeout inputs | `nfds` is bounded by the generated set width; every optional fd set, timeout, outer pselect sigmask descriptor, and nested mask range is checked before staging | Each attempt is synchronous; retry owns copies/scalars and no scratch view | Node/browser; guest/kernel wasm32/64 | **Confirmed unsafe caller-range paths and duplicated layout constants** | **Safe in current source; execution not claimed here.** Select/pselect count/range boundaries use the generated contract | | `host/src/kernel-worker.ts` generic `ppoll` descriptor planning and retry conversion | Main allocation/channel; 16-byte caller timespec and optional eight-byte signal mask become scalar kernel arguments | Fixed native records from syscall contract | Raw pointers remain bigint until lossless conversion; both complete caller ranges are proved on the first attempt and retry | Only final dispatch lease contains scratch bytes; retry retains scalars, never a view | Node/browser; guest wasm32/64 | **Unsafe/uncertain.** Special pointers were outside generated descriptors | **Implemented; validation pending.** Out-of-range and unrepresentable wasm64 regressions | | `host/src/kernel-worker.ts::{handleEpollCtl,handleEpollPwait}`; `crates/shared/src/lib.rs::WasmEpollEvent` | Caller process memory for events plus main scratch for the internal poll request; native epoll event is exactly 16 bytes | One `epoll_ctl` event or checked `maxevents * 16`; fields are events at offset 0, zero/ignored pad at 4–7, data at offset 8 | Checked multiplication and complete caller input/output ranges; exact 16-byte records; copy-out explicitly zeroes padding and writes `u64` data at offset 8 | One synchronous attempt; retry/interest state stores values, not process or scratch views | Node/browser; guest wasm32/64 | **Confirmed unsafe caller/output range handling and stale 12-byte assumption** | **Implemented; validation pending.** Exact-end, one-byte-short, padding, and offset-eight regressions | -| `host/src/kernel-worker.ts::{checkedProcessIovecs,kernelIovecFootprint,handleWritev}` | Rust main data allocation; kernel table is 8 bytes per entry and payload follows with four-byte alignment after every entry | Count 1..generated `IOV_MAX` (1,024); full footprint is `8*count + Σ align4(iov_len)` and must be `<= CH_DATA_SIZE` | Native table is 8 bytes/entry on wasm32 or 16 on wasm64; table and every nested source are range-checked losslessly; total is `<= SSIZE_MAX`; result cannot exceed staged payload. Caller linear-memory address zero is valid for a table or data base when the complete positive-length range fits; `{ base: 0, len: 0 }` performs no data access. Positioned offsets remain exact signed `bigint` values across slow chunks | Fast path one lease; slow path sends one checked chunk of at most `CH_DATA_SIZE-8`; no view survives between calls | Node/browser; guest wasm32/64 | **Confirmed live allocation overflow and adjacent offset defect.** Admission omitted per-entry padding and could write 3,072 bytes past the 65,536-byte data area; slow `pwritev` rounded offsets above `Number.MAX_SAFE_INTEGER` | **Implemented; focused Node validation passed.** Exact footprint, address-zero semantics, and exact `2^53+1` slow-path offsets | -| `host/src/kernel-worker.ts::{checkedProcessIovecs,kernelIovecFootprint,handleReadv}` | Rust main data allocation; same 8-byte kernel table/alignment model | Count 1..1,024; table plus requested data must fit 65,536 for fast path; slow chunks reserve the eight-byte table first | Complete native table and every output buffer are checked; returned count must be safe and no larger than offered total; each copy-back uses checked destination capacity. Address zero is caller-owned process memory here, so bounded positive output and zero-length entries may begin there. Positioned offsets remain exact signed `bigint` values across slow chunks | Fast path one lease; slow path one bounded iovec chunk per lease; copy-back bytes are detached | Node/browser; guest wasm32/64 | **Confirmed live allocation overflow and adjacent offset defect.** Fast path subtracted only eight bytes and did not enforce `IOV_MAX`, reaching 8,184 bytes past the data allocation; slow `preadv` rounded offsets above `Number.MAX_SAFE_INTEGER` | **Implemented; focused Node validation passed.** Full-table/count/address-zero and exact `2^53+1` slow-path offset regressions | -| `host/src/kernel-worker.ts::{handleLargeWrite,handleLargeRead}` | Rust main data allocation; one data chunk at most 65,536 bytes | Requested scalar count may be larger, but each scratch transfer is `min(remaining, CH_DATA_SIZE)` | Complete caller source/destination range is proved before the first Rust call; each Rust count is safe and bounded by the offered chunk | One lease per chunk; no view survives | Node/browser; guest/kernel wasm32/64 | Scratch capacity fit; complete caller range was unsafe | **Implemented; validation pending.** Large-I/O source/destination regressions | -| `host/src/kernel-worker.ts::{_handleSyscallInner,handleLargeWrite,handleLargeRead,handleSharedMappingsAfterFileSyscall}` ordinary and large `pread`/`pwrite` | Main scratch for transfer; the positioned file offset is a signed i64 scalar and shared-mapping state has a separate host owner | Ordinary request at most 65,536 bytes; larger requests use checked chunks | The raw channel offset remains `bigint` through ordinary dispatch, large-operation preflight, chunk addition, and kernel argument encoding. Shared-mapping updates use the exact offset only when it is safely indexable; otherwise they refresh from the authoritative file instead of aliasing a rounded JS number | One lease per dispatch/chunk; mapping refresh owns no scratch view | Node/browser; guest wasm32/64 | **Confirmed precision defect adjacent to scratch dispatch.** Ordinary and large `pread`/`pwrite` rounded `2^53+1`, and a rounded shared-map offset could update the wrong page | **Implemented; focused Node validation passed.** Ordinary/large wasm32/wasm64 exact-i64 tests plus shared-map non-aliasing | -| `host/src/kernel-worker.ts::{checkedProcessMessage,nativeControlToKernelWire,kernelMessageLayout,handleSendmsg}`; `crates/kernel/src/socket_wire.rs`; fixed `Kernel{Msghdr,Iovec,Cmsghdr}Wire` | Rust main data allocation; one generated 28-byte fixed header, optional name/control, one generated eight-byte canonical iovec, and flattened payload share exactly 65,536 bytes | Caller-native `msghdr` is generated as 28 bytes on wasm32 or 56 on wasm64; native iovec count 0..generated `IOV_MAX` 1,024; the complete canonical footprint must fit | Full native header/table and every nested range are checked losslessly. Native `cmsghdr` records are validated and translated to a generated 12-byte-header/alignment-4 wire; all caller iovecs are flattened into one owned payload. Rust revalidates the complete canonical ancillary stream and accepts only the zero/one-iovec host wire. Returned count cannot exceed staged data | One synchronous lease covers header/control/flatten/call; only owned parsed metadata exists before it and no view survives | Node/browser; guest wasm32/64 | **Confirmed live allocation overflow plus mixed-width protocol defect.** Count/layout capacity was incomplete, only the first caller iovec reached Rust, and wasm64 ancillary headers were interpreted as wasm32 | **Implemented; focused Node validation passed.** `IOV_MAX+1`, exact/capacity+1 layout, multi-iovec/zero-entry flattening, malformed/wrapped control records, invalid descriptor propagation, sequential reuse, and wasm32/64 native-wire translation | -| `host/src/kernel-worker.ts::{checkedProcessMessage,kernelControlCapacityForRecv,kernelControlToNative,kernelMessageLayout,handleRecvmsg}`; `crates/kernel/src/wasm_api.rs::kernel_recvmsg` | Same fixed-wire main allocation; caller name, native control, and every native iovec destination retain their own separately checked capacities | Native header 28/56; count 0..1,024; one canonical contiguous receive payload plus name and the caller-representable canonical control capacity must fit 65,536 | Complete caller table/destination ranges are proved before dispatch. Canonical ancillary capacity is derived from native data capacity rather than total native header space; returned wire length, alignment, type, and descriptor width are validated before expansion. Payload is detached and scattered across all caller iovecs, skipping zero-length entries; native padding is zeroed. `MSG_TRUNC` may report the full datagram while only the bounded prefix is copied | One synchronous lease snapshots all output; caller publication uses detached arrays after release, and retry/error paths publish nothing | Node/browser; guest wasm32/64 | **Confirmed live allocation overwrite plus mixed-width/first-iovec defects.** Complete count/footprint was unproven, only one destination received bytes, and wasm64 `cmsghdr` capacity could install descriptors that could not be represented on copy-back | **Implemented; focused Node validation passed.** Exact/capacity+1, multi-iovec scatter with a zero middle entry, EAGAIN/no-publish, malformed canonical output, `MSG_CTRUNC`, wasm32/64 capacity matrices, flags, and padding | -| `crates/kernel/src/{pipe.rs,process_table.rs,socket.rs,syscalls.rs,wasm_api.rs}` AF_UNIX `SCM_RIGHTS`; `programs/scm-rights-semantics.c` | Stream ancillary records own retained descriptors at absolute carrier-byte ranges; each datagram queue entry atomically owns payload, source address, and retained descriptors | Generated control-record limits plus the fixed one-record host wire; receiver installation is additionally bounded by the caller control capacity and fd-table capacity | Stream reads cannot observe rights before their carrier bytes and stop `MSG_WAITALL` at a rights boundary. PEEK clones retained references fallibly without consuming them. Datagram enqueue rolls back all retained references if publication fails. Zero-iovec receive can consume a zero-byte datagram and its rights, while ordinary `read(...,0)` consumes nothing. Output `MSG_TRUNC`, input `MSG_TRUNC`, `MSG_CTRUNC`, and `MSG_CMSG_CLOEXEC` are independent. Snapshot, retain, complete-batch send, and receive installation each reject non-owning or non-reconstructible metadata; any socket in the batch returns `EOPNOTSUPP` before carrier publication | Pipe/datagram queues retain supported ownership until one consuming receive, ordinary carrier-byte discard, or close. Forced process removal, AF_UNIX datagram reconnect, and `SHUT_RD`/`SHUT_RDWR` first make every discarded queue entry visible to the one deferred-release drain; `SHUT_WR` preserves the readable queue. Accept failure and plain transfer syscalls finish any ownership they discard. Every channel dispatch clears its temporary task identity, then conditionally drains deferred ownership after all resource-table borrows end and before publishing the result. Direct host-pipe exports use the same one-check boundary, so a future ancillary-capable input cannot strand ownership. PEEK owns temporary fallible clones only. Data and ownership become visible atomically before readiness wakeup; rejected batches publish neither | Shared Rust kernel on Node/browser; real guest wasm32/64; AF_UNIX datagram routing remains same-process; socket-descriptor transfer is an explicit unsupported boundary | **Confirmed live semantic and cleanup-boundary defects.** In addition to the seven transport defects, forced removal and reconnect could discard queued datagram rights after the sole drain, read shutdown made queued rights unreachable, failed accept discarded a preaccepted stream carrying rights, and `sendfile`/`copy_file_range`/`splice` consumed plain bytes while silently discarding ancillary ownership. Direct host-pipe exports were a latent future boundary rather than an existing public ancillary input | **Safe in current source.** Historical pre-retarget evidence includes native kernel and 18/18 real-musl Node cases across wasm32/wasm64. Current dirty-tree real-Chromium evidence passes the same 16 semantics cases plus two pipe-lifetime cases; the exact-final-head browser rerun remains pending | -| `host/src/kernel-worker.ts::{handleSpawn,decodeSpawnBlobStrings,handleSpawnAfterResolve,beginLargeSpawnScratch,cancelLargeSpawnScratch}`; `crates/kernel/src/spawn.rs::{SpawnScratchBuffer,measure_strings_by_offset,decode_measured_strings}`; `crates/kernel/src/wasm_api.rs::kernel_spawn_reserved_process` | Ordinary blob uses main allocation; large blob uses token-bound Rust `Vec` whose pointer and actual capacity are returned only while reserved | Complete blob at most generated 8,417,320; argv/environment representation at most 4 MiB; path/action/count caps from generated contracts | Caller ranges, parsed counts, paths, complete blob length, allocation capacity, current memory, pointer width, token, and reservation state are independent checks. Host and Rust first measure every referenced string against one aggregate budget, then allocate/decode | Async lookup owns a JS copy; begin/copy/commit have no await. Begin and pointer/capacity queries fail without waiting on contention. After every successful begin, cancellation runs in `finally`, including setup/copy failure. Commit and cancellation wait on the same no-host-import mutex and return only after the token is consumed, released, or shown stale; host/Rust guards reject overlap. Duplicate maximum-count offsets cannot amplify allocations before rejection | Node/browser; guest/kernel wasm32/64 | #1094 spawn fix was capacity-safe but retained a fixed 8,417,320-byte region after first large use; decoding still admitted allocation amplification from duplicate offsets | **Safe in current source.** Growable Rust-owned tokenized reservation, pre-allocation aggregate accounting, and exact-count/`ARG_MAX` boundaries are covered. The real Node/Chromium workload is historical pre-retarget evidence; the frozen final-head rerun remains pending | -| `host/src/kernel-worker.ts::{populateMmapFromFile,pwriteFromProcessMemory,readSysvShmRange,writeSysvShmRange}` | Main data allocation for transit, 65,536 bytes per chunk; mapped/shared bytes have separate owners | One `CH_DATA_SIZE` chunk; overall mapping/segment size comes from checked mapping/kernel state | Complete process/mapping range and each Rust producer/consumer count; transit lease separately proves scratch capacity/current memory | One synchronous lease per chunk; authoritative shared bytes/snapshots live outside scratch | Node/browser; guest/kernel wasm32/64 | Capacity fit, bare pointer contract | **Implemented; validation pending** | +| `host/src/kernel-worker.ts::{checkedProcessIovecs,copyFlattenedTransferInput,handleWritev,executeMainScratchTransfer,executeReservedScratchTransfer}`; `crates/kernel/src/transfer.rs`; `crates/kernel/src/wasm_api.rs::kernel_transfer_io_execute` | At most 65,536 bytes use the Rust-owned main data allocation; larger vectors receive one fresh Rust-owned initialized `Vec` whose pointer and capacity exist only under a positive reservation token | Count 0..generated `IOV_MAX` (1,024); the complete aggregate is checked against `SSIZE_MAX`/the transfer export's `i32` result domain before either allocation is written | Native tables are 8 bytes/entry on wasm32 or 16 on wasm64; the complete table and every nested source range are checked losslessly before begin. Payload is flattened without a second kernel table, the host proves requested bytes against explicit allocation capacity and current memory, Rust repeats token/length/capacity checks, and the returned count cannot exceed the aggregate. Caller address zero is valid only when the complete caller-owned range fits; a zero-length entry performs no access | Exactly one lease and one scalar kernel write per logical vector. The large token moves Reserved→Executing before the scratch mutex is released across the host call, then Ready→cancelled on an ordinary result. Void ingress is queued in arrival order until the outer export and lease unwind; result-bearing reverse entry fails rather than fabricating a syscall errno. A host-import trap leaves the token Executing and fail-stops the worker rather than reusing uncertain bytes | Node/browser; guest wasm32/64; kernel wasm32/64 | **Confirmed live allocation overflow plus operation-boundary and offset defects.** Old fast admission omitted per-entry padding and could write 3,072 bytes past the 65,536-byte data area; the slow path split one vector and rounded `pwritev` offsets above `Number.MAX_SAFE_INTEGER` | **Safe in current source; execution not claimed here.** Coverage targets include exact/capacity+1, `IOV_MAX+1`, later-invalid nested range before begin, allocation failure, invalid reservation range, sequential/reentrant/trap paths, exact `2^53+1`, native one-operation tests, and a real 65,538-byte AF_UNIX datagram in Node and Chromium | +| `host/src/kernel-worker.ts::{checkedProcessIovecs,copyFlattenedTransferOutput,handleReadv,executeMainScratchTransfer,executeReservedScratchTransfer}`; `crates/kernel/src/transfer.rs`; `crates/kernel/src/wasm_api.rs::kernel_transfer_io_execute` | At most 65,536 bytes use the main data allocation; larger vectors use one fresh token-bound Rust `Vec` with explicit capacity | Count 0..1,024; the complete aggregate must stay in the one-operation transfer/result domain | The complete native table and every caller destination are proved before begin. Rust receives one contiguous capacity-bounded destination; the host validates the producer count, then scatters only that prefix through checked caller capacities. EOF and short results complete the one operation without issuing another read. Exact positioned offsets remain `bigint` through dispatch | One main lease or one Reserved→Executing→Ready large token; output is published only while the matching lease is live. A retry retains no view; reentry and traps follow the same fail-closed rules as writes | Node/browser; guest wasm32/64; kernel wasm32/64 | **Confirmed live allocation overwrite plus operation-boundary and offset defects.** Old fast admission omitted 8,184 bytes of table footprint and lacked `IOV_MAX`; the slow path could combine a second blocking read after EOF/short result and rounded `preadv` offsets | **Safe in current source; execution not claimed here.** Coverage targets include full table/count/range boundaries, producer over-report, sequential/interleaved guards, exact `2^53+1`, native one-call/EOF/record tests, and the real cross-channel AF_UNIX datagram read | +| `host/src/kernel-worker.ts::{handleLargeWrite,handleLargeRead,handleFlattenedTransfer,executeReservedScratchTransfer}`; `crates/kernel/src/transfer.rs` | One fresh Rust-owned tokenized allocation sized for the complete scalar request, not repeated main-scratch chunks | Complete caller range, bounded by the transfer result domain; begin fails with `ENOMEM` before publishing authority if reserve fails | Caller range is proved before begin; reservation exposes pointer plus actual capacity; the host checks allocation capacity and current memory; Rust rejects length above capacity; host and Rust both reject a returned count above the request | Exactly one scalar kernel operation. The mutex is not held across the host callback, but the Executing state excludes replacement. Normal return cancels and drops the allocation; trap fail-stops without cancel | Node/browser; guest/kernel wasm32/64 | **Confirmed caller-range weakness and semantic split.** The old implementation used safe-sized chunks but made one user operation into several kernel/host operations | **Safe in current source; execution not claimed here.** Coverage targets include the complete caller range, capacity/capacity+1, allocation failure, invalid range, and sequential/reentrant/trap behavior | +| `host/src/kernel-worker.ts::{handleWritev,handleReadv,handleLargeWrite,handleLargeRead,handleSharedMappingsAfterFileSyscall}`; `crates/kernel/src/{process.rs,syscalls.rs,wasm_api.rs}`; `host/src/{kernel.ts,file-offset.ts,types.ts}` ordinary and large `pread`/`pwrite` families | Main or tokenized transfer allocation; the signed-i64 position is a scalar `bigint`; a Rust-lent read destination is staged in host memory before one checked publish | One complete scalar/vector operation; backend offset range is signed i64, while a number-only backend has an explicit exact-representation boundary | Offset words reconstruct directly to `bigint`; Rust calls required `host_pread`/`host_pwrite` imports rather than seek/read-or-write/restore; `PlatformIO` carries `number | bigint`; unsafe narrowing returns `EOVERFLOW`. Read producer counts and Rust-lent destinations are capacity-checked. Shared-mapping updates use the exact offset only when safely indexable, otherwise refresh from authoritative storage | One positioned backend operation does not mutate the shared OFD cursor. Staged read bytes do not lend a live kernel view to the backend; main/token lifetime rules remain unchanged | Node/browser; guest wasm32/64; kernel wasm32/64 | **Confirmed precision and atomicity defects adjacent to scratch dispatch.** Ordinary/large offsets rounded `2^53+1`, shared-map follow-up could alias a rounded page, and seek/restore raced shared OFDs and could fail to restore after I/O error | **Safe in current source; execution not claimed here.** Coverage targets include exact signed-i64 words above `2^53`, unchanged cursor, one host call, backend `EOVERFLOW`, wasm32/64 import ranges, and producer over-report | +| `crates/kernel/src/syscalls.rs::{sys_write,validate_append_outcome,transfer_output_plan,stage_transfer_input,commit_staged_transfer_input}`; `host/src/kernel.ts::{#hostAppend,#hostAppendPosition}`; `host/src/vfs/{memory-fs,sharedfs-vendor,opfs,opfs-worker,host-fs,default-mounts-node}.ts`; `host/src/platform/node.ts` | Rust owns the regular-file OFD flag/cursor; each backing owns EOF and mutation. The import source is a Rust-lent, capacity-checked kernel range for one call, while the paired result is a scalar `{ written, end }` consumed through a one-shot latch | One complete scalar/vector write within the active main/tokenized transfer capacity; optional file-size ceiling is exact signed i64. Externally mutable native backings accept no append payload | Rust independently validates pointer/length, returned count, derived start/end, signed-i64 conversion, and the file-size ceiling. Shared memory holds EOF/limit/write under the inode lock; OPFS uses one serialized handler. A module-private identity brand is granted only to the lifecycle-owned Node scratch backing; externally mutable HostFS and raw Node backings return `EOPNOTSUPP` before mutation. For `sendfile`/`copy_file_range`/`splice`, regular input uses positioned read and a kernel pipe uses peek; only the append-reported prefix commits source state | The kernel export gate admits one result-bearing operation. The append-position latch is cleared before every attempt and bound to handle/count. A malformed outcome after possible mutation traps and poisons the generation. Two sequential/interleaved managed operations cannot replace each other's result; source staging owns no scratch view after return | Node/browser; guest wasm32/64; kernel wasm32/64 | **Confirmed adjacent ownership/atomicity defects.** Seek-to-end plus write did not return the authoritative ending position or combine `RLIMIT_FSIZE` with mutation. transfer wrappers could consume input before append rejected or clipped it | **Safe in current source; execution not claimed here.** Coverage targets include managed exact/limit/interleaving behavior, fail-closed malformed outcomes, prefix-only transfer commit on rejection/short/limit/stale-fstat cases | +| `crates/kernel/src/wasm_api.rs::{channel_readv,channel_writev,channel_preadv,channel_pwritev,checked_kernel_iovec_entries}`; `libc/glue/{channel_syscall.c,syscall_glue.c,syscall_imports.h}`; `host/src/worker-main.ts::assertSupportedKernelFunctionImports` | Private channel helpers receive `ChannelScratchRegion { start, capacity }`; there is no host-callable bare vector pointer | Canonical channel allocation and generated `IOV_MAX`; current programs use `channel_syscall.c` | Table and every payload range are checked against the same allocation-bearing region. The four raw vector exports/declarations are absent from the source and ABI snapshot. Unknown `kernel.*` function imports fail before process instantiation; they are never replaced with zero-success stubs | Private synchronous channel dispatch only; no compatibility caller can overlap an unowned raw pointer | Node/browser; kernel wasm32/64; guest wasm32/64 through channel IPC | **Confirmed unprovable compatibility surface.** The removed signatures checked total kernel memory but carried no allocation capacity; historical direct glue passed process-memory native iovecs into a distinct kernel address space/layout | **Safe in current source; execution not claimed here.** Required targets are the static source/snapshot guard, callable-import admission tests, and declared-shell artifact scan | +| `host/src/kernel-worker.ts::{checkedProcessMessage,nativeControlToKernelWire,kernelMessageLayout,handleSendmsg,#executeCapacityOwnedChannel}`; `crates/kernel/src/socket_wire.rs`; fixed `Kernel{Msghdr,Iovec,Cmsghdr}Wire` | The complete aligned canonical message uses main scratch when it fits 65,608 bytes and a fresh token-bound `TransferScratch` when larger. Both contain one 28-byte kernel header, optional name/control, zero or one eight-byte canonical iovec, and the flattened payload | Caller-native `msghdr` is generated as 28 bytes on wasm32 or 56 on wasm64; native iovec count is 0..generated `IOV_MAX` 1,024; aggregate payload is bounded by `MAX_REPORTABLE_TRANSFER_BYTES`, control conversion retains its explicit protocol bound, and the complete aligned allocation must fit `MAX_TRANSFER_ALLOCATION_BYTES` | Full native header/table and every nested source range are checked losslessly before reservation. Native `cmsghdr` records are validated and translated to the generated 12-byte-header/alignment-4 wire; all caller iovecs are flattened into one owned payload. The fixed path proves 65,608-byte capacity; the widened token path derives pointer/capacity in Rust. Rust revalidates the complete canonical ancillary stream and zero/one-iovec wire, and the returned count cannot exceed staged payload | One rigid stage → fixed/token execute → finish lease covers header/control/flatten/call. Only detached parsed metadata exists before it and no scratch view survives. On `EAGAIN`, `SendmsgBlockingRetrySnapshot` retains the checked message/layout plus detached name, control, and payload. Rust pins the carrier OFD and its frozen in-flight ancillary descriptor template; replay uses the same token even if the numeric fd is closed and reused | Node/browser; guest wasm32/64; kernel wasm32/64 | **Confirmed live allocation overflow plus mixed-width protocol defect.** Count/layout capacity was incomplete, only the first caller iovec reached Rust, and wasm64 ancillary headers were interpreted as wasm32 | **Synchronous transfer capacity and the represented `sendmsg` retry ownership are implemented in current source; exact-final-head static and runtime validation remain pending.** `IOV_MAX+1`, exact/capacity+1, fixed/widened selection, multi-iovec/zero-entry flattening, malformed control, invalid descriptors, sequential exclusion, wasm32/64 wire translation, immutable replay, and carrier close/reuse have focused coverage | +| `host/src/kernel-worker.ts::{checkedProcessMessage,kernelControlCapacityForRecv,kernelControlToNative,kernelMessageLayout,handleRecvmsg,#executeCapacityOwnedChannel}`; `crates/kernel/src/wasm_api.rs::kernel_recvmsg` | The same canonical layout uses the fixed main allocation when its aligned total fits and one token-bound `TransferScratch` otherwise; caller name, native control, and every native iovec destination retain separate checked process-memory capacities | Native header 28/56; count 0..1,024; aggregate destination capacity is bounded by `MAX_REPORTABLE_TRANSFER_BYTES`; canonical ancillary capacity is derived from what the caller-native control layout can represent; the complete aligned allocation must fit `MAX_TRANSFER_ALLOCATION_BYTES` | Complete caller table/destination ranges are proved before reservation. The fixed or token-owned region holds one contiguous receive payload plus name/control. Returned wire length, alignment, type, descriptor width, and producer byte count are validated before expansion; the host detaches and scatters only the bounded prefix across all caller iovecs. `MSG_TRUNC` may report the full datagram while only that prefix is copied | One rigid stage → fixed/token execute → finish lease snapshots all output, and caller publication uses detached arrays after release. Error paths publish nothing. On `EAGAIN`, `RecvmsgBlockingRetrySnapshot` retains the checked native header, iovec/name/control destinations, canonical layout, flags, and capacities. Rust pins the exact carrier OFD. Replay never reparses a replacement msghdr or numeric fd, and detached output publishes only to the originally validated destinations | Node/browser; guest wasm32/64; kernel wasm32/64 | **Confirmed live allocation overwrite plus mixed-width/first-iovec defects.** Complete count/footprint was unproven, only one destination received bytes, and wasm64 `cmsghdr` capacity could install descriptors that could not be represented on copy-back | **Synchronous transfer capacity and the represented `recvmsg` retry ownership are implemented in current source; exact-final-head static and runtime validation remain pending.** Exact/capacity+1, fixed/widened selection, multi-iovec scatter with a zero middle entry, EAGAIN/no-publish, malformed output, `MSG_CTRUNC`, flags, padding, wasm32/64 matrices, immutable destination replay, and carrier close/reuse have focused coverage | +| `host/src/kernel-worker.ts::{handleSpawn,decodeSpawnBlobStrings,handleSpawnAfterResolve,beginLargeSpawnScratch,cancelLargeSpawnScratch}`; `crates/kernel/src/spawn.rs::{SpawnScratchBuffer,measure_strings_by_offset,decode_measured_strings}`; `crates/kernel/src/wasm_api.rs::kernel_spawn_reserved_process` | Ordinary blob uses main allocation; large blob uses token-bound Rust `Vec` whose pointer and actual capacity are returned only while reserved | Complete blob at most generated 8,417,320; argv/environment representation at most 4 MiB; path/action/count caps from generated contracts | Caller ranges, parsed counts, paths, complete blob length, allocation capacity, current memory, pointer width, token, and reservation state are independent checks. Host and Rust first measure every referenced string against one aggregate budget, then allocate/decode | Async lookup owns a JS copy; begin/copy/commit have no await. Begin and pointer/capacity queries fail without waiting on contention. After every successful begin, cancellation runs in `finally`, including setup/copy failure. Commit and cancellation wait on the same no-host-import mutex and return only after the token is consumed, released, or shown stale; host/Rust guards reject overlap. Duplicate maximum-count offsets cannot amplify allocations before rejection | Node/browser; guest/kernel wasm32/64 | #1094 spawn fix was capacity-safe but retained a fixed 8,417,320-byte region after first large use; decoding still admitted allocation amplification from duplicate offsets | **Safe in current source; execution and sizing measurements are not claimed here.** Coverage targets include the growable Rust-owned reservation, pre-allocation aggregate accounting, and exact-count/`ARG_MAX` boundaries | +| `host/src/kernel-worker.ts::{runSharedMappingHostOperation,populateMmapFromFile,pwriteFromProcessMemory,readSysvShmRange,writeSysvShmRange}`; `KernelEntryGate::{runSerializedHostOperation,KernelVoidIngressScope.invokeSerializedHostOperation}` | Main data allocation for transit, 65,536 bytes per chunk; mapped/shared bytes have separate owners | One `CH_DATA_SIZE` chunk; overall mapping/segment size comes from checked mapping/kernel state | Complete process/mapping range and each Rust producer/consumer count; transit lease separately proves scratch capacity/current memory. Each synchronous backing read/write holds either the exact active entry's host-operation marker or the gate-owned host-only marker; it returns no Promise/thenable or retained backend view | One synchronous lease per chunk; authoritative shared bytes/snapshots live outside scratch. Reentrant void ingress queues, result-bearing ingress and a second host operation reject, and host-only teardown can run only while the gate is otherwise idle | Node/browser; guest/kernel wasm32/64 | **Confirmed ownership/overlap gap.** Capacity fit, but a bare scratch pointer and an unscoped synchronous backend callback could overlap or reenter the operation that was validating and committing its staged result | **Safe in current source; exact-final-head validation pending.** Allocation-bearing transit plus scoped/host-only serialization is covered by gate tests and shared-mapping inheritance regressions, including a hostile synchronous backend callback | | `host/src/kernel-worker.ts::{handleIpcShmat,handleIpcShmdt}` | Process `Memory` mapping and host `SysvShmMapping.snapshot`, not kernel scratch; address key is the checked native guest pointer | Segment size returned by the kernel attachment operation; full mapped range must fit process memory | Raw bigint address is checked losslessly for guest width before attachment/map lookup; mmap result and full mapped range are checked; failure rolls back attachment; shmdt uses the exact checked key | Coherence/attach/detach steps are synchronous; snapshot owns bytes between boundaries; no kernel scratch view is retained | Node/browser; guest wasm32/64 | **Confirmed high-address alias defect.** `>>> 0` narrowed wasm64 hints/detach keys so an address above 4 GiB could alias a low mapping | **Implemented; validation pending.** High hint, unsafe integer, and non-aliasing detach regressions | -| `host/src/kernel-worker.ts::handleSysvMessage`; `crates/kernel/src/ipc_wire.rs` System V message header conversion | Main data allocation; fixed kernel wire header plus payload; caller message begins with native `long` (4 wasm32, 8 wasm64) | Payload is syscall `msgsz`; header plus payload must fit 65,536 for one operation | Exact native mtype field and payload range; checked addition; width passed explicitly; Rust sees fixed wire format only | One synchronous lease; blocking retry retains owned parameters, not a scratch view | Node/browser; guest wasm32/64 | **Unsafe mixed-width/native-long and aggregate-capacity contract** | **Implemented; validation pending.** Exact capacity/capacity+1 and wasm32/64 mtype coverage | +| `host/src/kernel-worker.ts::handleSysvMessage`; `crates/shared/src/lib.rs::platform_limits::SYSV_MSG_MAX_BYTES`; `crates/kernel/src/{blocked_retry.rs,ipc.rs,ipc_wire.rs}` | The fixed main data allocation holds one generated eight-byte kernel mtype header plus payload; caller storage begins with a native four-byte wasm32 or eight-byte wasm64 `long`. The generated 8,192-byte payload ceiling keeps the complete canonical record below fixed capacity, so this path deliberately does not reserve widened transfer scratch | At most generated `SYSV_MSG_MAX_BYTES` (8,192) of message text. A larger send is `EINVAL`; receive may advertise a larger caller capacity, but no dequeued payload can exceed the queue contract | The host proves native prefix plus requested caller range and checked header addition before staging; Rust accepts only the shared payload maximum, converts through the fixed i64 wire, and fallibly reserves payload/deque storage before queue mutation. On `MSG_NOERROR`, byte accounting releases the complete dequeued message even when only a prefix is copied | One synchronous fixed-region lease and detached output. On `EAGAIN`, `SysvMessageBlockingRetrySnapshot` retains caller width, original destination, message size and flags, detached send input/native type, or receive type selector. Rust pins the exact System V message queue. Replay uses the snapshot/token rather than reparsing msgbuf or resolving a reused qid | Node/browser; guest wasm32/64; kernel wasm32/64 | **Unsafe mixed-width/native-long and aggregate-capacity contract.** The old path also had no explicit cross-layer message ceiling or allocation-failure atomicity | **Synchronous fixed-capacity transfer and represented System V message retry ownership are implemented in current source; exact-final-head validation remains pending.** Exact 8,192/8,193, wasm32/64 native mtype, allocation failure before mutation, `MSG_NOERROR` truncation, full-byte queue accounting, immutable replay, and qid close/remove-reuse behavior have focused coverage | | `host/src/kernel-worker.ts::handleIpcControl`; `crates/kernel/src/wasm_api.rs::{kernel_msqid_ds_bytes,kernel_shmid_ds_bytes}`; `crates/kernel/src/ipc_wire.rs::{read_*,write_*}` | Main data allocation; `msqid_ds` 96/120 and `shmid_ds` 88/112 for wasm32/64 | Exact layout size returned by required Rust query for `IPC_SET`/`IPC_STAT`; pointerless commands stage zero bytes | Width query, command direction, full caller range, allocation capacity, exact Rust slice, and narrowing checks; no fixed fallback | One synchronous lease; outputs serialize completely before copy-back | Node/browser; guest wasm32/64 independent of kernel width | **Confirmed unsafe mixed-width contract.** Fixed wasm32 descriptors proved/staged the wrong LP64 ranges | **Implemented; validation pending.** Exact/short/null/unsupported-width tests | | `host/src/kernel-worker.ts::handleSemctl`; `crates/kernel/src/wasm_api.rs::{kernel_semid_ds_bytes,kernel_semctl_array_bytes}`; `crates/kernel/src/ipc_wire.rs::write_semid_ds` | Main data allocation; `semid_ds` 72/88 or exact `2 * sem_nsems` array bytes | Rust permission-aware query is authoritative for GETALL/SETALL; structure query is authoritative for IPC commands | PID/TID, command kind, guest width, exact length, caller range, allocation capacity, and Rust slice bounds; missing/invalid required query fails closed | One synchronous lease; no `IPC_STAT` compatibility call is used to infer writable array capacity | Node/browser; guest wasm32/64 | **Confirmed unsafe.** Host assumed 1,024 array bytes and wasm32-only 72-byte structure | **Implemented; validation pending.** Exact/capacity+1, permissions, and missing-export regressions | -| `host/src/kernel-worker.ts::requireTcpScratchRegion` users: TCP, virtual network, UDP, browser-pipe bridges | Separate Rust allocation; private `tcpScratchRegion`, 65,536 bytes | One chunk at most 65,536; oversized UDP datagrams are rejected | Source/backend length, region capacity/current memory, and Rust producer count | Each callback/worker message enters one synchronous lease and detaches output before returning | Node/browser; kernel wasm32/64 | Sizes fit, but pointer escaped ownership value | **Implemented; validation pending.** Private capacity-bearing region | -| `host/src/kernel.ts` public socket/poll/terminal/ioctl/uname/pipe/rusage/select methods | Separate Rust allocation; private `apiScratchRegion`, 65,536 bytes | Call-specific exact fixed record or bounded payload; public poll accepts exactly `capacity / generated sizeof(pollfd)` = 8,192 entries and select uses generated 1,024-bit sets | Lease proves allocation and current memory; call validates the complete caller/result length and derives aggregate limits from the actual owned capacity rather than an unrelated protocol count | One synchronous public call; nested use fails | Node/browser; kernel wasm32/64 | **Confirmed unsafe ownership and artificial poll cap.** Temporary addresses 4 and 16 named no Rust allocation, while public poll reused `IOV_MAX` instead of its allocation capacity | **Implemented; focused Node validation passed.** Allocator-owned public scratch, poll exact-capacity/capacity+1, and generated select layout | -| `host/src/kernel.ts::{hostRead,readKernelBytes,writeKernelBytes}` and VFS (`stat`, `statfs`, `pathconf`, `readlink`, `readdir`), clock, random, waitpid, network/getaddrinfo, GL, proc, and KMS import callers | Rust-owned slice/local/struct lent as pointer plus explicit capacity for one import; `host_kms_mode_info` instead derives its exact 68-byte capacity from generated `WpkDrmModeModeinfo`; producer backends receive host-owned staging buffers rather than a live kernel view | Genuine intrinsic backend span no larger than the Rust-supplied or generated capacity; fixed formats use their exact generated/Rust size | Raw signed wasm32 or bigint wasm64 import pointer is normalized losslessly; nonnegative safe length, complete current-memory range, detached/staged producer data, and producer count precede one `writeKernelBytes` publish; no typed-array clamping or subclass getter counts as validation | Synchronous import only; neither backend nor callback receives a kernel-memory view | Node/browser; kernel wasm32/64 | Correct owner but incomplete conversions/result checks and live-view lending | **Implemented; validation pending.** Checked Rust-lent range plus host staging; high-bit wasm32 KMS and hostile-producer regressions; raw sink is explicitly allowlisted below | -| `apps/browser-demos/test/fixtures/opfs-advisory-lock-client-worker.ts::issue`; `apps/browser-demos/test/epoll-repro.ts::main` | Test kernel allocations represented as `KernelScratchRegion`; one complete channel | Fixed diagnostic channel and event records | Same lease capacity/current-memory rules as production | One lease covers stage/dispatch/snapshot | OPFS: real Chromium wasm32; epoll diagnostic: Node wasm32 | Sizes fit, bare diagnostic pointers/views | **Implemented; the historical authored-application Chromium run was blocked.** An earlier pre-final OPFS run passed, and the static contract includes both selected diagnostic sources. That broader historical run stopped before assertions because its program graph rejected an ABI-42 `bzip2.wasm`; it does not conflict with or count toward the later 28 focused minimal-runner Chromium passes | -| `host/src/{node,browser}-kernel-worker-entry.ts` clone/transport; process-worker argv/environment | Process `Memory`, `ArrayBuffer`, or `SharedArrayBuffer`, not kernel scratch | Process layout/worker-protocol limits | Process-owner and transport-specific validation | Worker/process lifetime, not a scratch lease | Node/browser; guest wasm32/64 | Outside allocator model | **Reviewed exclusion; final transport tests pending** | -| `host/src/framebuffer/**`; `host/src/dri/**`; GL command buffers; mmap/SysV backing views | Framebuffer/process memory or explicit host shared backing, never a pointer returned by `kernel_alloc_scratch` | Mapping/device-specific dimensions and buffer sizes | Subsystem owner/range contracts; static ownership seeds prevent reclassification as kernel scratch | Device/mapping lifetime; may be asynchronous by design, so no allocator-scratch view may enter these objects | Node/browser; guest wasm32/64 | Outside allocator model | **Reviewed exclusion, not declared globally safe.** Static contract covers ownership boundaries; subsystem-specific runtime validation remains required | +| `host/src/kernel-worker.ts::#requireTcpScratchRegion` data paths | Separate Rust allocation; runtime-private `#tcpScratchRegion`, 65,536 bytes | One producer-checked chunk at most 65,536 bytes | Source/backend length, region capacity/current memory, and Rust producer count | Each callback/worker message enters one synchronous lease and detaches output before returning | Node/browser; kernel wasm32/64 | Sizes fit, but pointer escaped ownership value | **Implemented; validation pending.** Runtime-private capacity-bearing region | +| `host/src/kernel.ts` public socket/poll/terminal/ioctl/uname/pipe/rusage/select methods | Separate Rust allocation; runtime-private `#apiScratchRegion`, 65,536 bytes | Call-specific exact fixed record or bounded payload; public poll accepts exactly `capacity / generated sizeof(pollfd)` = 8,192 entries and select uses generated 1,024-bit sets | Lease proves allocation and current memory; call validates the complete caller/result length and derives aggregate limits from the actual owned capacity rather than an unrelated protocol count | One synchronous public call; nested use fails | Node/browser; kernel wasm32/64 | **Confirmed unsafe ownership and artificial poll cap.** Temporary addresses 4 and 16 named no Rust allocation, while public poll reused `IOV_MAX` instead of its allocation capacity | **Safe in current source; execution not claimed here.** Allocator-owned public scratch, poll exact-capacity/capacity+1, generated select layout, and no reflective region access | +| `host/src/kernel.ts::WasmPosixKernel.setsockopt`; `host/src/kernel-scratch.ts::{KERNEL_SCRATCH_EXPORT_NAMES,kernelScratchRequiredPointerArguments}`; `crates/kernel/src/wasm_api.rs::kernel_setsockopt` | The runtime-private allocator-owned API region; the lease lends an exact four-byte subrange and its derived wasm32/wasm64 pointer | Exactly one JavaScript scalar option value encoded as little-endian `u32` | The lease writes only the four-byte allocation subrange, proves current-memory bounds and pointer width, and invokes the existing five-argument export with `{ optval_ptr, optlen: 4 }`. The scratch contract classifies argument 3 as required, while the compiler audit defaults every generated kernel export to denied even if it is absent from the narrower runtime scratch list | One synchronous lease and one Rust call; nested public scratch use rejects and no pointer/view escapes | Node/browser shared wrapper; kernel wasm32/64 | **Confirmed live ownership/signature defect found by the widened audit.** The direct public wrapper passed only four arguments, treated the scalar `value` as `optval_ptr`, omitted `optlen`, and was absent from the scratch export list; rejection therefore occurred only after unowned address authority crossed the boundary | **Safe in current source; exact-head execution not claimed here.** Focused wasm32/wasm64 coverage targets the exact pointer type, four staged bytes, low-memory and post-capacity canaries, five-argument call, and generated-export default-deny regression | +| `host/src/kernel.ts::{#hostRead,#readKernelBytes,#writeKernelBytes}` and VFS (`stat`, `statfs`, `pathconf`, `readlink`, `readdir`), clock, random, waitpid, network/getaddrinfo, GL, proc, and KMS import callers | Rust-owned slice/local/struct lent as pointer plus explicit capacity for one import; `host_kms_mode_info` instead derives its exact 68-byte capacity from generated `WpkDrmModeModeinfo`; producer backends receive host-owned staging buffers rather than a live kernel view | Genuine intrinsic backend span no larger than the Rust-supplied or generated capacity; fixed formats use their exact generated/Rust size | Raw signed wasm32 or bigint wasm64 import pointer is normalized losslessly; nonnegative safe length, complete current-memory range, detached/staged producer data, and producer count precede one `#writeKernelBytes` publish; no typed-array clamping or subclass getter counts as validation | Synchronous import only; neither backend nor callback receives a kernel-memory view | Node/browser; kernel wasm32/64 | Correct owner but incomplete conversions/result checks and live-view lending | **Implemented; validation pending.** Checked Rust-lent range plus host staging; high-bit wasm32 KMS and hostile-producer regressions; raw sink is explicitly allowlisted below | +| `apps/browser-demos/test/fixtures/opfs-advisory-lock-client-worker.ts::issue`; `apps/browser-demos/test/epoll-repro.ts::main` | Test kernel allocations represented as `KernelScratchRegion`; one complete channel | Fixed diagnostic channel and event records | Same lease capacity/current-memory rules as production | One lease covers stage/dispatch/snapshot | OPFS: real Chromium wasm32; epoll diagnostic: Node wasm32 | Sizes fit, bare diagnostic pointers/views | **Safe in current source; browser execution is not claimed here.** The static contract includes both selected diagnostic sources; their exact-head runtime checks remain external validation targets | +| `host/src/process-memory.ts::createProcessMemory`; `host/src/{node,browser}-kernel-worker-entry.ts` clone/transport; process-worker argv/environment | Each process owns its own `WebAssembly.Memory`; worker transport owns detached `ArrayBuffer`/`SharedArrayBuffer` values. None is the kernel `Memory` or a pointer returned by `kernel_alloc_scratch` | Process layout, guest address-space, and worker-protocol limits | Guest-width range checks and transport-specific validation; the static audit seeds these exact constructors/messages as `process-memory`, never as allocator scratch | Process/worker generation lifetime; asynchronous transport may retain its own detached/shared process backing but no kernel-scratch view | Node/browser; guest wasm32/64 | Outside allocator model | **Reviewed process-memory exclusion; final transport tests pending.** Its separate owner is explicit rather than hidden under a generic raw-memory allowance | +| `host/src/framebuffer/registry.ts::{FramebufferRegistry,FbBinding.hostBuffer}` and browser framebuffer binding/rebinding messages | An mmap framebuffer view belongs to one process `Memory`; a write-based framebuffer owns a host `ArrayBuffer`/`Uint8ClampedArray` sized from checked geometry. Neither backing is kernel scratch | Binding `addr/len` or `height * stride`, plus framebuffer/device format limits | Registry binding and process-range/geometry checks select the exact backing; memory growth invalidates cached process views. Static seeds classify only these exact values as `framebuffer` | Mapping/binding lifetime and renderer callbacks may be asynchronous. Cached views are dropped on grow/unbind/teardown, and no allocator-scratch lease enters the registry | Browser presentation plus shared Node/browser host code; guest wasm32/64 | Outside allocator model | **Reviewed framebuffer exclusion, not a scratch-safety claim.** Framebuffer runtime and teardown coverage remains subsystem-specific | +| `host/src/dri/registry.ts::{GbmBoRegistry,InternalEntry.sab}` and DRI/GBM bind/unbind synchronization | Each buffer object owns an explicit host `SharedArrayBuffer`; per-process mmap ranges belong to their respective process memories | Kernel-reported buffer-object size and each checked binding `addr/len` | The registry validates object/binding identity and copies only between the buffer object's canonical SAB and checked process ranges. Static seeds classify the SAB separately from both kernel and process memory | Buffer-object reference/binding lifetime; synchronization occurs at bind/unbind boundaries and may span processes, but never retains allocator scratch | Node/browser shared host path; guest wasm32/64 | Outside allocator model | **Reviewed explicit shared-backing exclusion.** Coherence is the DRI registry's snapshot contract, not a kernel-scratch lease | +| `host/src/kernel-worker.ts::{populateMmapFromFile,pwriteFromProcessMemory,readSysvShmRange,writeSysvShmRange}` mapped-file and System V shared-memory backings | Authoritative VFS storage, process mappings, and `SysvShmMapping.snapshot`/shared backing own the durable bytes; main scratch is only the separately inventoried 65,536-byte transit chunk | Checked mapping/segment size, processed in bounded transit chunks | Mapping/process ranges and backing lengths are proved independently; each transit chunk uses its own main-scratch lease under the serialized host-operation contract | Backing/mapping lifetime may outlive a transit call. No scratch view survives a chunk or becomes the authoritative mapped/shared state | Node/browser; guest/kernel wasm32/64 | Outside allocator model except for the already reviewed transit lease | **Reviewed mapped/shared-backing exclusion.** Ownership is explicit; mapping coherence and serialization retain their own runtime validation | ## Explicit write sinks and raw-write allowlist @@ -292,7 +484,7 @@ following sinks. `KernelScratchDataView` is a guarded part of the abstraction, not an allowance for a caller-created native `DataView`. `KernelScratchLease.copyFrom` and `fill` are likewise abstraction-internal guarded sinks. The only raw variable-size kernel-memory write outside that -abstraction is `WasmPosixKernel.writeKernelBytes`, whose pointer and capacity +abstraction is `WasmPosixKernel.#writeKernelBytes`, whose pointer and capacity are lent by Rust for one synchronous host import. All variable-size allocator-owned syscall staging must call `KernelScratchLease`. The other allowlisted occurrences in `host/test/kernel-scratch-contract.test.ts` are @@ -305,7 +497,7 @@ authorizes a call-site raw write. | `host/src/kernel-scratch.ts::KernelScratchDataView::{setBigInt64,setBigUint64,setFloat32,setFloat64,setInt8,setInt16,setInt32,setUint8,setUint16,setUint32}` | The native `DataView` is private and spans only the range proved by `KernelScratchLease.dataView`; a `Memory.buffer` replacement triggers the full proof again | Native `DataView` bounds-checks each scalar width and offset inside that exact range | Every setter calls `currentView`, which rechecks the active lease; **guarded scratch-core sink, not a raw allowance** | | `host/src/kernel-scratch.ts::KernelScratchLease.copyFrom` — `Uint8Array(...).set(...)` | `ownedRange` proves the private region pointer, explicit allocation capacity, pointer width, and current `Memory.buffer` range | Source offset/length are safe integers and fit the source's intrinsic typed-array slots; the exact native base-class view prevents a subclass override from widening the write | Synchronous active lease only; **guarded scratch-core sink, not a raw allowance** | | `host/src/kernel-scratch.ts::KernelScratchLease.fill` — `Uint8Array(...).fill(...)` | `ownedRange` supplies exact checked start/end inside the allocation and current memory | Fill length/value validation occurs before construction | Synchronous active lease only; **guarded scratch-core sink, not a raw allowance** | -| `host/src/kernel.ts::WasmPosixKernel.writeKernelBytes` — `getMemoryBuffer().set(...)` | `checkedWasmImportMemoryRange` normalizes the raw import pointer and proves the Rust-lent pointer, explicit/generated capacity, width, and current memory | The producer's intrinsic byte span must not exceed the supplied capacity; a typed-array subclass cannot under-report its real span | Complete synchronous import; **Rust-lent allowance** | +| `host/src/kernel.ts::WasmPosixKernel.#writeKernelBytes` — intrinsic `Uint8Array.prototype.set` on `#getMemoryBuffer()` | `checkedWasmImportMemoryRange` normalizes the raw import pointer and proves the Rust-lent pointer, explicit/generated capacity, width, and current memory | The producer's intrinsic byte span must not exceed the supplied capacity; a typed-array subclass cannot under-report its real span | Complete synchronous import; the method and memory getter are runtime-private; **Rust-lent allowance** | ## Executable reproductions and regression coverage @@ -320,11 +512,13 @@ claim a separate old-head test execution. | Confirmed old path and executable regression | Exact unsafe evidence on `6d923c6` | Current boundary asserted | |---|---|---| | `kernel_wait_child_poll` result pointer/capacity and child-state consumption | The ABI-42 export accepted only a bare output pointer, so the interface could not prove that the destination owned the complete 160-byte `KernelWaitResult` before selecting the sole waitable child event | The real compiled kernel rejects pointer zero and capacities 159/161 without changing either canary or consuming the event. Exact capacity 160 publishes the complete result and reaps the child, and the next exact-capacity call returns `ECHILD` | -| `WasmPosixKernel` cached public/audio scratch across `init`/`initWithMemory` replacement | The wrapper cached allocator-owned regions containing the first generation's instance, memory, pointer, and capacity, but a later initializer replaced only the wrapper's current instance/memory. Subsequent public/audio calls could select the old region and old snapshotted export while other wrapper state named the replacement generation; a failed second instantiate could also leave new memory paired with the old instance | `kernel-initialization-lifetime.test.ts` allocates and uses both cached regions on wasm32 and wasm64, rejects cross-entry-point reinitialization before compile/instantiate or state mutation, proves the original generation still works, rejects a concurrent initializer, and proves a failed first attempt clears partial state before one clean retry | +| `WasmPosixKernel` cached public/audio scratch across a second `init` | The wrapper cached allocator-owned regions containing the first generation's instance, memory, pointer, and capacity, but a later initializer could replace only the wrapper's current instance/memory. Subsequent public/audio calls could select the old region and old snapshotted export while other wrapper state named the replacement generation; a failed second instantiate could also leave new memory paired with the old instance | `kernel-initialization-lifetime.test.ts` allocates and uses both cached regions on wasm32 and wasm64, rejects concurrent and post-success reinitialization before compile/instantiate or state mutation, proves the original generation still works, and proves a failed first attempt clears partial state before one clean retry | +| Public `WasmPosixKernel.setsockopt` scalar option | The old wrapper called `kernel_setsockopt(fd, level, optname, value)` directly. Rust's actual fifth-argument signature expected `{ optval_ptr, optlen }`, so an ordinary scalar became an unowned kernel pointer and no capacity crossed the boundary. Omitting this export from the hand-reviewed scratch list also exposed the prior static classification gap | `kernel-public-scratch.test.ts` targets wasm32 and wasm64, requires the pointer type appropriate to the kernel width, observes exactly four staged little-endian bytes, requires `optlen == 4`, and preserves both low-memory and post-allocation canaries. The audit mutation fixture proves a generated `kernel_setsockopt` direct call remains denied even when the scratch list omits it | +| Channel `request_flags` capture and reuse | Before generated request flags, the host saw only `syscall_nr`; a plain call and cancellation-point call using the same number were indistinguishable, and a reused mailbox carried no authoritative call-site identity | wasm32/wasm64 fixtures publish zero, cancellation-point, and cancellation-point-plus-wake values for one number; they verify one-shot capture and clearing, reject unknown or inconsistent bits, retain the same detached flags through retry, and prove sequential mailbox reuse cannot inherit them | | `ptyMasterWrite` — “chunks PTY input at the exact scratch capacity and capacity + 1” | The old single `.set(data, scratchOffset)` accepts more than the 65,608-byte allocation because only total linear memory constrains the typed-array write | 65,608 is one call; 65,609 becomes calls of 65,608 and 1; 16 KiB sentinel after the allocation is unchanged | | `setCwd` — “rejects an oversized initial cwd before copying it” | `CH_TOTAL_SIZE + 1` encoded bytes are copied first; only the later Rust call applies `PATH_MAX` | Host rejects before `kernel_set_cwd` and before scratch mutation | -| `handleWritev` — “accounts for every writev table and alignment byte” | 1,024 iovecs contain 57,344 payload bytes, so the old `57,344 + 8,192 == 65,536` admission passes. Per-entry four-byte alignment makes the real footprint 68,608, writing 3,072 bytes beyond the data allocation | Complete `8 * count + Σ align4(len)` footprint is rejected or chunked; tail sentinel stays intact | -| `handleReadv` — “subtracts the complete readv iovec table from data capacity” | 1,024 entries request 65,528 data bytes. The old fast limit subtracts only one eight-byte entry, while the actual table is 8,192 bytes; the footprint is 73,720, or 8,184 bytes beyond the 65,536-byte data allocation | Complete table is included, `IOV_MAX` is enforced, returned bytes are bounded, sentinel is unchanged | +| `handleWritev` — “accounts for every writev table and alignment byte” | 1,024 iovecs contain 57,344 payload bytes, so the old `57,344 + 8,192 == 65,536` admission passes. Per-entry four-byte alignment makes the real footprint 68,608, writing 3,072 bytes beyond the data allocation | The caller table and all nested ranges are validated, but no second kernel table is constructed. Exactly 57,344 flattened bytes use the main data region in one write; the tail sentinel stays intact | +| `handleReadv` — “subtracts the complete readv iovec table from data capacity” | 1,024 entries request 65,528 data bytes. The old fast limit subtracts only one eight-byte entry, while the actual table is 8,192 bytes; the footprint is 73,720, or 8,184 bytes beyond the 65,536-byte data allocation | `IOV_MAX` and every destination are checked before dispatch; one 65,528-byte flat read is scattered through the caller capacities and the tail sentinel is unchanged. A larger aggregate uses one tokenized allocation rather than a partial table/data fit | | `handleSendmsg` / `handleRecvmsg` — `IOV_MAX + 1` cases | The old path calls Rust instead of rejecting count 1,025 with `EINVAL` | Count 1,025 is rejected before scratch mutation or Rust dispatch | | `handleSendmsg` / `handleRecvmsg` — complete-layout boundary and historical allocation-sized table cases | Count 8,192 alone consumes 65,536 kernel-table bytes, but the old path also writes the 28-byte kernel message header and aligned optional/data sections. That historical count is above `IOV_MAX`, so current code rejects it at the count check rather than exercising the capacity boundary | One 65,500-byte iovec makes the complete header/table/data layout exactly 65,536 bytes and is accepted; 65,501 is rejected before dispatch. Exactly 1,024 zero-length entries are accepted, while the historical 8,192-entry case is rejected by `IOV_MAX`; the sentinel stays unchanged | | `handleSendmsg` / `handleRecvmsg` multi-iovec behavior | The old kernel-facing path serialized the caller's full count but Rust read only the first table entry, so later payload sources/destinations were silently ignored even when all ranges fit | wasm32 and wasm64 send flatten every entry in order; receive scatters the detached result across every entry, including correctly skipping a zero-length middle entry | @@ -333,36 +527,32 @@ claim a separate old-head test execution. | Kernel channel C-string allocation boundary | The old Rust scanner accepted a bare kernel pointer, stopped at a duplicated 4,096-byte constant, and did not carry the channel allocation capacity that authorized each dereference | Pointer zero, before-start, exact-end, overflow, and a missing NUL before the allocation end return `EFAULT`; a NUL in the last owned byte succeeds, and a generic non-path string larger than `PATH_MAX` remains valid | | Positive-count null dynamic buffers and zero-count null buffers | The old generic host path did not make a positive `Arg` extent independently imply a non-null source/destination, while a raw zero-count process pointer could cross address spaces even though no caller bytes were borrowed | wasm32 and wasm64 `read`/`write` with count 1 and pointer 0 fail with `EFAULT` before dispatch. Count 0 with pointer 0 reaches Rust only as the allocation start with zero extent, never as the caller address | | Positive-size fixed output nullability (`pipe(NULL)` and `uname(NULL)`) | Absence of `required` metadata was interpreted as permission for null, so fixed outputs could reach Rust without an owned destination and write through kernel address zero | Every generated pointer descriptor is exactly one of required or nullable; the reviewed nullable set is asserted exactly. wasm32 and wasm64 `pipe`/`uname` null outputs fail before kernel dispatch | -| Non-null `Deref` outer buffer with a null length pointer | `accept`, `accept4`, `recvfrom`, `getsockname`, and `getpeername` derive output capacity from a separate `socklen_t *`. The old host could leave the non-null caller outer pointer in adjusted kernel args when that capacity pointer was null, crossing address spaces before later rejection | wasm32 and wasm64 `recvfrom` reject the malformed pair before scratch mutation or dispatch; the same shared planner covers every `Deref` descriptor | +| Non-null `Deref` outer buffer with a null length pointer | `accept`, `accept4`, `recvfrom`, `getsockname`, and `getpeername` derive output capacity from a separate `socklen_t *`. The old host could leave the non-null caller outer pointer in adjusted kernel args when that capacity pointer was null, crossing address spaces before later rejection | wasm32 and wasm64 `accept`, `accept4`, and `recvfrom` reject the malformed optional pair before scratch mutation or dispatch; the same shared planner covers every `Deref` descriptor | +| Absent versus zero-capacity optional socket-address output | The descriptor planner staged and later copied an `accept`/`accept4`/`recvfrom` length pointer even when the address pointer was null, although POSIX makes that field ignored. Nested `recvmsg` likewise collapsed absent `msg_name` and a supplied zero-capacity name into the same null kernel pointer, so it could overwrite an ignored native `msg_namelen` or fail to report the complete length for a present zero-capacity result | The generic nullable-`Deref` planner now canonicalizes an absent outer/length pair before any caller-memory read, while non-null output still requires its length. The canonical message wire represents a present zero-capacity name with the next allocation-owned cursor and represents absence with null; Rust and the host publish the complete length only for the former. wasm32/wasm64 regressions cover valid, out-of-range, negative, and unsafe-high-bit ignored length pointers, stale absent send/receive name lengths, present zero capacity, unchanged canaries, and both fixed socket descriptor forms | | One-snapshot, order-independent `Deref` planning | The old planner could size a destination from one `socklen_t` read and stage a later, mutated value; it also depended on the generated dynamic descriptor preceding the fixed length descriptor. A larger staged value could authorize Rust to use bytes the host had not reserved | The regressions mutate 4 to 28 between hypothetical reads and reverse the generated `recvfrom` descriptor order. The host performs one caller-memory read, stages that same value, and leaves the adjacent canary unchanged. Rust validates allocation order/range, with the documented alignment-bucket limitation because no separate unpadded capacity is encoded | | Option-sensitive `prctl` argument 1 | The generic descriptor treated argument 1 as a fixed 16-byte pointer for every option, so scalar operations such as `PR_SET_NO_NEW_PRIVS` had their value replaced by a scratch address | wasm32/wasm64 scalar options preserve the canonical low-32-bit value and stage no buffer; `PR_SET_NAME`/`PR_GET_NAME` stage exactly 16 bytes in the correct direction and reject null before dispatch | -| `SCM_RIGHTS` stream carrier position and `MSG_WAITALL` | The old side queue could return a descriptor before the byte with which it was sent, and a wait-all read could cross more than one rights boundary | The real-musl fixture queues `A`, rights with `B`, then `C`; a nonblocking wait-all receive returns exactly `AB` with the descriptor, and the next receive returns `C` | -| `SCM_RIGHTS` stream `MSG_PEEK` with short control | The old PEEK path removed the sole retained descriptor ownership even though it left stream bytes queued | A no-control peek and a `CMSG_LEN(0)` peek report no installed fd/`MSG_CTRUNC` as appropriate; two full peeks and the final consume all see a valid descriptor without sender ownership | -| Addressed and connected AF_UNIX datagram rights | Datagram queue entries previously dropped control ownership, and non-Unix destinations could reach partial data publication | Abstract-address `sendmsg.msg_name` and connected sends deliver payload and rights atomically; the sender alias may close before receipt. Non-AF_UNIX ancillary use returns `EINVAL` before data becomes visible | -| Zero-byte and zero-iovec rights | Fast exits bypassed the message ownership path, so zero-byte datagrams could lose rights or be consumed by an ordinary zero-length read | A datagram `sendmsg`/`recvmsg` with `msg_iov == NULL` and `msg_iovlen == 0` transports rights. `read(fd, ..., 0)` leaves that message queued for the later receive; a zero-byte stream send queues nothing and releases its temporary retain | -| Datagram input/output `MSG_TRUNC` separation | The old receive path used the input flag only and did not report output truncation in `msg_flags` | A short receive always reports output `MSG_TRUNC`; without input `MSG_TRUNC` it returns the copied prefix, and with the input flag it returns the full datagram length | -| `MSG_CMSG_CLOEXEC` | The old receive path installed transferred descriptors without applying the requested close-on-exec flag | The received descriptor has `FD_CLOEXEC`, is reflected in output flags, and is absent after the fixture execs itself; installation failure publishes no partial control result | -| `SCM_RIGHTS` descriptor transferability | A process-local socket snapshot preserved scalar metadata but not the authoritative endpoint or queue backing, so reporting successful socket transfer created a descriptor that could not preserve the source object | The real-musl wasm32/wasm64 fixture first proves pipe transfer still succeeds, then attempts AF_UNIX stream/datagram and AF_INET/AF_INET6 socket descriptors. Every socket batch returns `EOPNOTSUPP`, publishes no carrier byte, and retains no hidden reference. Native tests also reject stale, structurally incomplete, and non-owning in-flight records before installation | -| Forced process removal with queued datagram rights | `remove_process_inner` performed its sole deferred-release drain before dropping each socket's datagram queue, so the queue could enqueue backing work after the last cleanup boundary | `forced_removal_drains_scm_rights_queued_in_unix_datagrams` closes the sender alias, forces removal, and proves the in-flight OFD, host handle, and advisory lock all reach their final state | -| AF_UNIX datagram reconnect with queued rights | Replacing the datagram peer cleared the old queue only after the enclosing operation's cleanup opportunity, leaving its retained descriptors stranded | `unix_datagram_reconnect_drops_and_drains_scm_rights_ownership` proves reconnect discards and finishes the old queue before publishing success | -| Datagram shutdown modes with queued rights | `SHUT_RD` and `SHUT_RDWR` made queued messages permanently unreadable without releasing their `SCM_RIGHTS`; `SHUT_WR` must not destroy still-readable data | `unix_datagram_shutdown_modes_preserve_or_discard_scm_rights_correctly` covers both discarding modes plus `SHUT_WR` preservation and later receipt | -| Failed accept of a preaccepted stream carrying rights | An error after selecting a preaccepted AF_UNIX stream dropped its pending ancillary state without finishing the retained backing | `failed_accept_discards_and_drains_preaccepted_stream_scm_rights` injects the failure and proves no OFD, host handle, or lock ownership remains hidden | -| `sendfile` / `copy_file_range` / `splice` crossing a stream rights boundary | These plain-data transfers call the ordinary read path. It correctly refuses to return ancillary ownership, but the wrappers did not finish the ownership discarded at that boundary | `plain_transfer_syscalls_discard_and_drain_crossed_stream_scm_rights` executes all three operations and verifies both the copied byte and final retained-resource state | -| Direct host pipe read/close boundaries | Today these exports normally address host-injected TCP pipes, but message-aware read and unreachable-cycle collection can enqueue deferred ownership if that trusted input boundary broadens | `direct_host_pipe_read_and_close_read_detect_deferred_scm_cleanup` and `direct_host_pipe_close_write_detects_recursive_ancillary_collection` exercise the exact pending predicate and cleanup helper used after the pipe-table borrow ends | -| Systematic channel-dispatch cleanup order | A per-syscall list can miss a new transitive `Drop`, early return, or replacement that queues ancillary cleanup; cleanup while a task identity or resource-table borrow remains live can also re-enter with stale authority | Every channel result crosses one conditional machine-owned cleanup boundary after dispatch clears the current-TID binding and before result publication. The empty path performs one O(1) pending check; the pending path drains only after resource borrows have ended | | Vector/message wasm64 nested pointers | `Number(bigint)` loses an unrepresentable iovec/base/header pointer and permits an aliased range to reach Rust | Raw bigint remains intact through guest-width and safe-integer checks; failure precedes mutation | | Public poll exact allocation boundary | The public wrapper used the unrelated `IOV_MAX` value 1,024 even though its owned 65,536-byte region can hold 8,192 generated eight-byte `pollfd` records | Exactly 8,192 records are admitted and 8,193 is rejected before mutation; readiness parsing uses generated offsets | -| Slow `preadv` / `pwritev` positioned offset | Reassembling the signed high and unsigned low words as a JavaScript `Number` rounds `2^53 + 1` down to `2^53`, so the chunked path re-emits the wrong low word | Offset assembly, per-chunk addition, write-budget preflight, and low/high re-emission remain `bigint`; wasm64 ingress normalizes the complete low slot before any Number conversion | -| Ordinary and large `pread` / `pwrite` positioned offset | The generic ordinary path converted the signed i64 channel slot to `Number`, and the large path reused that rounded value across preflight and chunks. Shared-mapping follow-up could then index the rounded page | Both caller widths preserve `2^53+1` on the ordinary path and increment it exactly across a 65,536-byte chunk; an offset that cannot be indexed losslessly triggers authoritative mapping refresh instead of an aliased update | +| Large `preadv` / `pwritev` positioned offset and operation count | Reassembling the signed high and unsigned low words as a JavaScript `Number` rounds `2^53 + 1` down to `2^53`; splitting at channel capacity also changes one vector into several positioned operations | The complete offset remains `bigint` and one token executes one required `host_pread`/`host_pwrite` call. Native tests record the exact offset and unchanged OFD cursor; host import tests reconstruct the exact two-word value for wasm32 and wasm64 | +| Ordinary and large `pread` / `pwrite` positioned offset | The generic ordinary path converted the signed i64 channel slot to `Number`; large transfers reused that rounded value, and seek/read-or-write/restore could race a shared OFD or fail to restore after an I/O error. Shared-mapping follow-up could also index the rounded page | Both caller widths preserve `2^53+1`; Rust issues one true positioned host import without touching the cursor. Number-only backends return `EOVERFLOW`, and an offset that cannot index shared-mapping state losslessly triggers authoritative refresh instead of an aliased update | +| Rejected ambient/proxy kernel-entry authority | An interim gate proxy protected calls made through the proxy but did not prove that every callback still held that exact receiver. A synchronous backend callback could still invoke a raw-target method while Rust was active. Proxy-returned containers, accessors, or closures could likewise retain the target. Patching individual callbacks would leave the next return edge as another authority leak | The proxy design was removed. `KernelEntryGate` now issues a frozen lexical context whose scoped facade is bound in a private gate/scope registry, and the worker stores no ambient context. Gate tests reject unscoped generic callbacks, retained scoped exports after revocation, cross-gate scopes, prototype/descriptor mutation, and observer reentry. `kernel-entry-context-audit.test.ts` mutation cases require every production export-bearing call chain to carry the exact context or open a fresh ingress. A prior source checkpoint reported zero findings, but the widened reservation detector and rigid stage → execute → finish helpers changed afterward; both the entry-context and independent kernel-memory ownership assertions remain pending until rerun on stabilized source | +| Detached host-effect failure and reentry ordering | Running an observer while export authority was still ambient could let it steal the next export permit, append work to its own privileged phase, or make one throwing callback skip required later cleanup. Treating channel publication or scheduler registration as an ordinary observer could instead continue after required protocol state failed. A queued-then-thrown “test helper” could also reject its caller while retaining the values for later execution | `KernelEntryGate` owns a fixed, ordered batch of typed records and revokes the scope before running any of them. Immediate-only ingress rejects before queueing or retaining caller values; ordinary void ingress owns the FIFO explicitly. Observer throws and non-synchronous returns are reported and later independent records continue. Serialized host operations reject Promise/thenable escape and fail-stop because the continuation already exists. Protocol failure, or a fatal latch reached from any record, privately poisons the generation and discards every later effect and queued ingress before the worker fatal observer runs. Gate and production-worker regressions cover immediate/deferred ordering, retained-scope rejection, callback reentry, Promise returns, hostile fatal reporting, and no later publication/relisten/follower after failure | +| Host-region caller validation versus generation failure | `reserveHostRegion` and `reserveHostRegionAt` originally validated guest pointer/length geometry inside the generation-fatal entry callback. A malformed caller range could therefore poison a coherent kernel even though no Rust export had run; fork-from-pthread could also allocate a child PID before discovering that its requested control-slot range was impossible | `host-process-pointer-width.test.ts` proves negative dynamic length and an out-of-domain fixed wasm32 range reject before the genuine Wasm export is called, then a valid request succeeds on the same generation. Fork validates the exact control-slot range before allocating the child PID. Export-return validation remains inside the gate: a returned range that exceeds the guest domain is wrapped as a generation-fatal protocol failure, while an exact wasm32 end at 4 GiB and mixed kernel/guest widths retain their lossless bit patterns | +| Real `Getpid` completion with pending PTY output | Delivering PTY bytes after setting `CH_COMPLETE` or relistening lets a PTY callback reenter and replace reusable scratch before the original bytes are detached; delivering while the scope is live lends the callback export authority | `kernel-large-transfer-protocol.test.ts` drives the production `Getpid` channel on wasm32 and wasm64 in immediate and deferred cases. The PTY callback observes `CH_PENDING` and no relisten, its reentry queues, then the host publishes `CH_COMPLETE`, relistens, and only afterward admits the queued ingress | +| Real `FUTEX_WAKE` completion and kernel wake batch | Publishing the futex syscall before draining Rust's wake records permits callback/retry work to reuse scratch or observe a completed wake while the corresponding waiters remain undelivered | The production-path wasm32/wasm64 regression issues genuine `FUTEX_WAKE`, proves one wake record is drained under the same lexical scope, and reaches the real raw channel-completion path only after that drain | +| Host-driven process exit through a trapping export | Guest `kernel_exit` intentionally does not return. Calling it from the host makes its expected trap indistinguishable from a partial/incoherent export unwind, while treating that trap as success weakens the generation-fatal rule | Rust exposes required `kernel_commit_process_exit(status)`, sharing the authoritative cleanup helper but returning the low eight status bits. The host also requires `PROCESS_STATE_EXITED` before detached callbacks. A mismatched return test proves the fatal latch stops polling and leaves every channel inert rather than publishing `EIO` or relistening | +| Host append rejection, short result, and stale EOF during transfer syscalls | `sendfile`, `copy_file_range`, and `splice` consumed a source cursor or pipe before a later append failure. A separate preflight `fstat` could also apply `RLIMIT_FSIZE` to an EOF that no longer belonged to the append transaction | Four Rust `append_transfer_` regressions cover external `EOPNOTSUPP` with unchanged regular cursor and pipe bytes, a short result that consumes only the reported prefix, exact-limit/limit-plus-one clipping, and stale-too-large/stale-too-small `fstat` values. Host append owns the current EOF and only its reported prefix is committed | +| Large vector one-operation semantics | Chunking a 65,538-byte two-iovec AF_UNIX datagram produces multiple messages even though POSIX defines one `writev` call | The real-musl fixture writes and reads one 65,538-byte datagram, verifies every byte, then proves the nonblocking queue is empty. The same guest is the required Node and real-Chromium operation-boundary target | +| Token allocation/capacity/lifetime | A pointer-only or retained grow-by-leak scheme can overrun, leak old regions, or replace bytes while a host callback is using them | Focused wasm32/wasm64 protocol tests cover exact capacity and +1, `ENOMEM`, invalid pointer/range, sequential tokens, deferred reentrant channels, producer over-report, retry cancellation, and execute/cancel traps. Traps latch the kernel fatal state, clear queued work, and make direct/retry/listener dispatch inert | +| Obsolete direct variable-I/O exports | The old bare scalar/vector exports could check only total memory, and a legacy guest pointer names process memory/native layouts rather than a capacity-bearing kernel allocation | Source/snapshot tests require the four scalar exports, four vector exports, and bare-pointer write preflight to remain absent. Unknown `kernel.*` imports fail admission; a declared-shell scan found no raw vector import among 193 current program artifacts | | `Getaddrinfo` generic descriptor — “copies only getaddrinfo's four-byte result before the caller canary” | The old fixed 256-byte output descriptor copies 252 bytes beyond musl's four-byte result object | Detached copy-back is exactly four bytes and preserves a 252-byte canary | | `handleGetgroups` and `kernel_getgroups` | The old generic call passes the caller's process pointer as if it were a kernel pointer; Rust writes one `u32` without receiving the owned destination capacity | Size zero lends pointer/capacity zero; positive size lends exactly four bytes; Rust rejects capacity 0/3 and accepts 4/5; detached gid copy precedes reuse | | `Setgroups` generated descriptor | The old raw pointer crosses address spaces. The current Rust implementation does not dereference it, so this is an unsafe contract rather than a claimed observed overwrite | Exactly 16,384 gids fit; 16,385 is rejected; count zero ignores even an unrepresentable pointer; positive null returns `EFAULT` | | Request-aware `ioctl` — FIONREAD canary and exact capacities | The old generic 256-byte argument copies back 252 bytes beyond a four-byte FIONREAD object; it also cannot distinguish scalar/no-argument requests from pointer requests | FIONREAD copies exactly 4; wasm32 TIOCGPTN is 4, wasm32 DRM VERSION is 36, wasm64 DRM VERSION is 64; one-byte-short/null fail before mutation; scalar/no-arg/unknown stage no pointer | | Width-incompatible `ioctl` requests | The old contract has no lossless distinction for pointer-bearing wasm32-only layouts such as `GLIO_QUERY` and wasm32 DRM VERSION | wasm64 rejects those known requests with `EOVERFLOW` before conversion or copy | | Caller-native process records | Fixed wasm32/partial descriptors under-copy or copy back the wrong layout for wasm64; `sigevent` was treated as 16 rather than 64 bytes; `sysinfo` used stale syscall 208 instead of musl 269 | `tests/abi/{process-native-layouts,fixed-process-layouts}.c`, Rust exact/short tests, and host `sysinfo` exact-end/one-byte-short tests cover the enumerated 12/24, 16/32, 32/64, 64, 88/120, 312/368, 128, 112, and 48-byte records | -| Signal dequeue output capacity and complete `SA_SIGINFO` record | The old `kernel_dequeue_signal(pid, tid, out_ptr)` accepted a bare kernel pointer, while its 44-byte payload inside a 48-byte reserved channel area omitted a complete raw `si_value` plus sender/timer metadata | `signal_delivery_output_requires_nonnull_exact_capacity` rejects null, 55, and 57 while accepting exactly 56; `signal_delivery_record_serializes_every_field_at_the_shared_offsets` covers the full generated record. The rebuilt real-musl `process-native-layout` Node cases pass on wasm32 and wasm64 and exercise handler-side C reconstruction, raw value width, `si_code`, PID, and UID | +| Signal dequeue output capacity and complete `SA_SIGINFO` record | The old `kernel_dequeue_signal(pid, tid, out_ptr)` accepted a bare kernel pointer, while its 44-byte payload inside a 48-byte reserved channel area omitted a complete raw `si_value` plus source metadata | `signal_delivery_output_requires_nonnull_exact_capacity` rejects null, 55, and 57 while accepting exactly 56; `signal_delivery_record_serializes_every_field_at_the_shared_offsets` covers the full generated record. The real-musl `process-native-layout` fixture targets wasm32 and wasm64 handler-side C reconstruction, raw value width, `si_code`, PID, and UID | | Mqueue notification drain and registration validation | The old one-argument drain export had no allocation-capacity proof. A negative Rust errno is truthy in JavaScript, so the old host could parse unchanged reusable bytes as a pending `{pid, signo}` notification. `mq_notify` also admitted invalid signal numbers into the one-shot registration slot | Source requires an exact eight-byte destination and queues the full raw value with `SI_MESGQ` and sender metadata independently of the wake record. The host regression accepts only integer results 0/1 and fails closed on `-EINVAL` without waking or signaling. Native coverage rejects 0, `NSIG`, and `u32::MAX` without occupying the slot, then proves a valid registration succeeds. The real-Wasm export regression rejects pointer zero and capacities 7/9 without consuming or mutating the pending record, then accepts capacity 8 and preserves both destination canaries | -| POSIX timer `sigevent` pointer width and full `sigval` | The old three-argument `kernel_timer_create` could not distinguish a wasm32 from wasm64 caller and parsed only a partial event, narrowing a wasm64 pointer value | `mq_attr_and_sigevent_follow_process_long_width` covers exact 64-byte wasm32/wasm64 layouts, short/long rejection, and raw four/eight-byte values. The rebuilt real-musl `process-native-layout` Node cases verify the low 32 bits for wasm32 and all 64 bits for wasm64 through `timer_create`, expiration, and `sigtimedwait` | | Signal sender metadata for plain raise/kill versus queued sources | Plain self-raise previously reached the metadata-bearing queue with PID/UID zero, making handler `siginfo_t` inconsistent with the authoritative process identity | `test_raise_preserves_self_sender_metadata` and `process_signal_metadata_distinguishes_kill_from_sigqueue` distinguish SI_USER/SI_QUEUE while preserving sender PID/UID and raw queued value. The same rebuilt real-musl Node fixture checks the handler-visible fields | | `handleEpollCtl` / `handleEpollPwait` native event layout | A stale 12-byte assumption cannot represent musl's required padding before 64-bit `data` and proves the wrong output range | Exact record is 16 bytes: events offset 0, padding 4–7, data offset 8; exact-end and one-byte-short input/output tests verify padding and data | | wasm64 `handleIpcShmat` | `shmaddr >>> 0` aliases a hint above 4 GiB to its low 32 bits before mmap/attachment logic | `0x1_0000_0000n` reaches mmap unchanged; values above `Number.MAX_SAFE_INTEGER` fail before attachment | @@ -370,14 +560,28 @@ claim a separate old-head test execution. | wasm64 `msgctl` `IPC_STAT`/`IPC_SET` | Fixed 96-byte wasm32 descriptor validates/stages the wrong range instead of the 120-byte LP64 structure | Required size query selects 96/120 and exact/short ranges | | wasm64 `semctl` `IPC_STAT` | Fixed 72-byte wasm32 layout is selected instead of the 88-byte LP64 structure | Required size query selects 72/88; array size comes from permission-aware Rust preflight | | wasm64 `shmctl` `IPC_STAT`/`IPC_SET` | Fixed 88-byte wasm32 descriptor validates/stages the wrong range instead of the 112-byte LP64 structure | Required size query selects 88/112 and exact/short ranges | +| Direct and nested socket-address capacity | The native `msghdr` path validated the caller range but accepted raw `msg_namelen` as both an input length and receive scratch capacity. That bypassed the direct bind/connect/sendto input ceiling. A first hardening pass then treated 110-byte `sockaddr_un` as the producer maximum, but an exact 108-byte non-NUL pathname legitimately makes `getsockname()` report 111 bytes, and storing a canonicalized relative bind name could make that report unbounded in a deep current directory. Several AF_UNIX producers also wrote no family prefix at one-byte capacity, and `accept4` either left the result length stale or cleared bytes beyond the actual address | The generated 128-byte `sockaddr_storage` is the complete generic input/output staging ceiling; 110 bytes remains only the concrete AF_UNIX parser ceiling. wasm32/wasm64 direct inputs and nested `sendmsg` accept 128 and reject 129. Generic address outputs and `recvmsg` reserve at most 128, reject a producer report of 129 with no partial publication, and leave unused caller bytes untouched. When an address is requested, `getsockname`, `getpeername`, `accept4`, `recvfrom`, and `recvmsg` report the complete address length while copying only the caller's zero-, one-, or two-byte AF_UNIX prefix; an absent optional address leaves its ignored length untouched. Accept rolls back its new descriptor if requested peer-address publication fails. `SocketInfo` retains the bounded original bind name while the Unix registry independently owns its canonical namespace key | The expanded parameterized coverage includes those failures plus caller -address zero, adjacent vector slow paths, large read/write, select/pselect, +address zero, both main and tokenized vector paths, large read/write, select/pselect, `ppoll` special-pointer conversion, epoll, wasm32/wasm64 System V IPC control layouts, generic descriptor invalid lengths, lease-time staging, and Linux-compatible `MSG_TRUNC`. Final case counts belong in the post-retarget validation report, not this in-progress rehearsal record. +Validation of the abstraction itself found one performance regression rather +than an ownership escape. `intrinsicBufferByteLength` brand-checked a shared +kernel `Memory.buffer` by invoking the non-shared `ArrayBuffer` getter first, +so every range proof threw and caught a `TypeError` before the genuine +`SharedArrayBuffer` getter succeeded. A V8 profile attributed about 27 seconds +of a 38-second focused Sortix poll run to that repeated exception. The current +implementation caches only the successfully brand-checked intrinsic getter by +genuine buffer identity; it still calls that getter on every proof, so memory +growth cannot reuse a stale byte length. Focused tests cover ordinary and +shared memories, post-growth live bounds, captured intrinsics after prototype +replacement, and one failed brand probe per shared buffer identity. Default +watchdog conformance remains part of the exact-candidate validation gate. + Additional focused files cover the complete abstraction: - `host/test/kernel-scratch-region.test.ts`: exact capacity/capacity+1, @@ -388,8 +592,9 @@ Additional focused files cover the complete abstraction: `memory.grow()`. - `host/test/kernel-public-scratch.test.ts`: removal of low-address scratch, public capacity, audio counts, signed-high raw import pointers, hostile - producer views, exact KMS mode-info size, and Rust-lent network output - bounds. + producer views, exact KMS mode-info size, Rust-lent network output bounds, + and public scalar `setsockopt` staging with exact four-byte capacity, + wasm32/wasm64 pointer types, and canaries. - `host/test/kernel-initialization-lifetime.test.ts`: both kernel pointer widths, cached public/audio scratch, post-success cross-entry-point initialization rejection, concurrent initialization, original-generation @@ -422,8 +627,8 @@ Additional focused files cover the complete abstraction: - `tests/abi/process-native-layouts.c` and `scripts/check-process-native-layouts.sh`: executable wasm32/wasm64 musl size-and-offset drift checks for signal-stack, signal-information, - signal-event, interval-timer, message-queue, filesystem-statistics, and - system-information records, including the native `union sigval` width. + message-queue, filesystem-statistics, and system-information records, + including the native `union sigval` width. - `tests/abi/fixed-process-layouts.c`, `scripts/check-fixed-process-layouts.sh`, `tests/abi/sysv-ipc-layouts.c`, and @@ -434,14 +639,13 @@ Additional focused files cover the complete abstraction: and serialization tests, zeroed padding/reserved bytes, and end-to-end wasm32/wasm64 syscall round trips. The current real-musl fixture also installs an `SA_SIGINFO` handler and verifies the generated 56-byte delivery - record reconstructs native `si_value`, `si_code`, PID, and UID; its timer - and mqueue cases preserve four-byte wasm32 and eight-byte wasm64 values and - reject invalid `mq_notify` signums. Focused host boundary tests separately + record reconstructs native `si_value`, `si_code`, PID, and UID; its mqueue + cases preserve four-byte wasm32 and eight-byte wasm64 values and reject + invalid `mq_notify` signums. Focused host boundary tests separately prove `sysinfo` exact-end admission, one-byte-short rejection, and that a negative mqueue drain result cannot decode stale scratch. The two runtime - cases are self-contained and set `useDefaultRootfs: false`; both passed on - Node against the rebuilt ABI-43 kernel and host artifacts. Chromium - execution of this latest fixture remains pending. + cases are self-contained and set `useDefaultRootfs: false`; Node and browser + execution remain external validation targets. - `crates/kernel/src/mqueue.rs`, `crates/kernel/src/signal.rs`, `crates/kernel/src/syscalls.rs`, and `host/test/kernel-scratch-transfer-boundaries.test.ts`: invalid mqueue @@ -451,8 +655,7 @@ Additional focused files cover the complete abstraction: result. - `host/test/sysv-ipc.test.ts`: end-to-end wasm32/wasm64 message-queue, semaphore, and shared-memory control operations, including `IPC_SET`. Its - two self-contained runtime cases also set `useDefaultRootfs: false` and - both passed against the rebuilt ABI-43 kernel and host artifacts. + two self-contained runtime cases also set `useDefaultRootfs: false`. - `crates/shared/src/ioctl_contract.rs`, the ioctl tests in `crates/kernel/src/wasm_api.rs`, and `host/test/kernel-scratch-transfer-boundaries.test.ts`: sorted @@ -467,17 +670,38 @@ Additional focused files cover the complete abstraction: publish another operation's bytes. - `host/test/deferred-worker-start.test.ts`: a deferred clone failure clears the originally validated parent-TID word even when the guest replaces its - mutable mailbox before worker construction fails. -- `host/test/timerfd-signalfd-scratch.test.ts`: guarded caller objects for the - exact wasm32/wasm64 timer and signal-mask records. Both focused cases passed - after the current ABI-43 kernel and host artifacts were rebuilt. Like the - other self-contained native-layout fixtures, it sets - `useDefaultRootfs: false`. + mutable mailbox before worker construction fails. The real parked mailbox + remains owned through synchronous `Worker` construction, so constructor + failure replaces the provisional success before exactly one completion is + published. +- `host/test/host-process-pointer-width.test.ts`: invalid dynamic and fixed + host-region requests reject before the genuine Wasm export and do not poison + the generation; a malformed export result remains generation-fatal. The same + cases cover mixed kernel/guest pointer widths and exact wasm32 end-of-domain + arithmetic. - `host/test/kernel-scratch-contract.test.ts` and `host/test/wasm-memory-write-audit.test.ts`: compiler-backed repository drift guard plus focused ownership-propagation, write-kind, escape, exact-allowlist, direct/aliased/destructured/computed pointer-export invocation, wrapped - callable escape, `call`/`apply`/`bind`, and reflective invocation fixtures. + callable argument/return/persistent-storage escape, + `call`/`apply`/`bind`, reflective invocation fixtures, the exact + reserved-spawn transaction proof, and proof that the complete generated + export set defaults to denied even when a raw call's name is omitted from + the runtime scratch list. +- `host/test/process-wait-lifecycle.test.ts`, + `host/test/kernel-blocking-retry-snapshot.test.ts`, and the focused + sleep/signal/readiness/lock/pipe/FIFO tests: generated request-flag offsets + and known-bit masks; plain, enabled, masked, disabled, wake-without-point, + unknown, and stale combinations for one syscall number; capture-and-clear; + cancellation before and after every host-owned registration; disabled + finite-deadline preservation; immutable replay; and sequential mailbox reuse + on wasm32 and wasm64. +- `crates/kernel/src/{process_table,socket,unix_socket,syscalls}.rs` native + tests: root-only inherited socket graphs, one-sided `FD_CLOFORK` and + retry-only peers, both-root preservation, alias deduplication, AF_UNIX + rename/reuse exact owners, abstract/pathname cleanup, and transactional + allocation/invalid-root failure. These tests preserve the explicit + same-process AF_UNIX datagram transport boundary. - `crates/shared/src/host_abi.rs`, `crates/kernel/src/channel_scratch.rs`, `host/test/generated-abi.test.ts`, and @@ -497,82 +721,28 @@ Additional focused files cover the complete abstraction: valid empty slice before any `from_raw_parts` call. Pure Rust tests execute malformed canonical control lengths, partial/trailing records, invalid-FD propagation, and the zero/one-iovec wire limit on the native test target. -- `programs/scm-rights-pipe-lifetime.c`, - `host/test/scm-rights-pipe-lifetime.test.ts`, and - `apps/browser-demos/test/fifo-lifecycle.spec.ts`: the same real musl - `sendmsg`/`recvmsg` and `SCM_RIGHTS` workload is built for both wasm32 and - wasm64. Its one-FD receive uses the exact native `CMSG_LEN` capacity, making - wasm64's 20-byte logical record distinct from its 24-byte aligned storage. - Node and Chromium results are recorded only after those commands run. -- `programs/scm-rights-semantics.c`, - `host/test/scm-rights-semantics.test.ts`, and - `apps/browser-demos/test/scm-rights-semantics.spec.ts`: eight independent - real-musl cases cover stream carrier barriers/`MSG_WAITALL`, non-consuming - short/full stream `MSG_PEEK`, addressed/connected and zero-iovec AF_UNIX - datagrams versus ordinary `read(...,0)`, independent input/output - `MSG_TRUNC`, non-Unix rejection, an unrepresentable descriptor batch, - zero-iovec stream behavior, and `MSG_CMSG_CLOEXEC` across exec. The - post-retarget Node matrix passed all 16 wasm32/wasm64 semantic cases; the two - existing pipe-lifetime cases also passed, for 18 total Node cases. The - focused real-Chromium runs passed the same 16 semantic cases and both - pipe-lifetime cases. These are dirty-worktree results and still require the - frozen-head rerun described below. ## Evidence boundaries and external gaps -These validation gaps prevent a “ready” disposition. They do not erase a -source-safety proof, and a source-safety proof does not substitute for these -missing runs. Any row still marked **Uncertain** separately remains without a -safe source disposition. - -1. Retargeting to merged PR #1097 is complete. Exact-head readiness is a - per-PR gate: the draft PR ledger must name the current head and the complete - rerun performed on it. Test presence, an earlier source fingerprint, or a - pre-retarget run is never substituted for that evidence. -2. Framebuffer, Direct Rendering Manager (DRM), OpenGL, shared-mapping, and - process-worker transfers are deliberately excluded from allocator scratch. - The static ownership audit can prove that no kernel scratch view escapes - into those objects; it cannot by itself prove every subsystem's mapping - dimensions, callback lifetime, or browser behavior. -3. Focused post-retarget Chromium execution now covers the scratch runtime, - native wasm32/wasm64 process layouts, both child-wait widths, all 16 - wasm32/wasm64 `SCM_RIGHTS` semantic cases, both pipe-lifetime cases, and the - adjacent path, file-limit, and terminal fixtures: 28 assertions passed in - real Chromium. Those self-contained fixtures explicitly select the test - runner's minimal dependency set; they do not bypass validation for any - artifact they request. Vite's optional application dependency pre-scan - still warns about unavailable ABI-43 tools, and the complete browser - application suite remains blocked by those package artifacts. Focused - browser evidence does not establish every device/shared-memory exclusion or - the unexecuted application graph. -4. The normal conformance runners were reprobed after retargeting and all stop - before the guest reaches the kernel because no one provenance tier contains - the complete ABI-43 program closure. Open POSIX `sigqueue sigtimedwait` - reported 0 pass, 17 fail, and 1 timeout. libc-test `functional spawn` - reported 0 pass and 1 fail. Sortix `signal` reported 0 pass, 14 fail, and 18 - timeouts; a separately enumerated complete 24-test `basic/spawn/*` surface - reported 0 pass and 24 fail. A direct launch shows the exact cause: - `local-binaries` is not one direct immutable generation, `binaries` is not - one canonical program-cache generation, and the installed package lacks - `programs/wasm32/git/git.wasm` plus `git-remote-http.wasm`. No resolver - bypass, mixed-provenance selection, or test-only exception was added. -5. Comparable post-retarget dirty-worktree measurements establish reported - retained scratch capacity and post-run/peak kernel linear memory for the - deterministic workload on Node and real Chromium. Exact-PR-head result - files and fingerprints belong in the mutable PR ledger. Three-round timing - samples and the baseline-harness provenance are insufficient for a speed or - broad no-regression claim. The performance guide's complete application - suites remain blocked by unavailable ABI-43 PHP, WordPress, and MariaDB - artifacts. -6. The declared development shell does not provide its pinned - `rustfmt`/`cargo-fmt`; the only discovered formatter is an undeclared - Homebrew binary that produces unrelated repository-wide churn. Rust - formatting validation is blocked until the declared toolchain supplies the - formatter. -7. PR #1097 merged as - `c7d039794a43788acfa0b0aea30a700c257f57cb`, and retargeting is complete. - The draft must remain unapproved and unmerged whenever its validation ledger - does not match its current exact head. +The tables above record source ownership and executable regression targets. +They do not establish a current-head test result. This documentation-only +finalization does not claim Node, browser, conformance, performance, or +full-build evidence. + +Framebuffer, Direct Rendering Manager (DRM), OpenGL, process-memory, +shared-mapping, and worker-message transfers remain deliberately outside the +allocator-scratch abstraction because they have different owners and +lifetimes. The static contract must prove that allocator scratch does not +escape into them; each subsystem still needs its own range, lifetime, and host +validation. + +PR #1097 merged as +`c7d039794a43788acfa0b0aea30a700c257f57cb`, and retargeting is complete. +Before readiness, the external PR ledger must name the frozen exact head and +record the generated-file/ABI checks, focused host and Rust regressions, +complete required conformance surface, and any Node/browser or measurement +evidence needed for the claims actually made. The draft remains unapproved and +unmerged until that ledger is current and Brandon explicitly approves it. ## Platform and spawn contract sources of truth @@ -658,261 +828,21 @@ platform header, and that the musl build stages both headers before compiling `sysconf`. Values deliberately classified differently therefore cannot silently drift. -## Historical pre-retarget spawn buffer sizing evidence - -All numeric results in this section are historical #1094-baseline or -pre-retarget dirty-worktree measurements. They are retained to explain the -buffer-design decision; they are not current final-head performance evidence. -Exact-PR-head retained-memory and timing results are recorded in the mutable -draft PR ledger under the recording contract at the end of this section. - -Three designs were evaluated: - -1. The #1094 fixed 8,417,320-byte kernel allocation is simple and safe, but - first use just above channel size retains the complete worst case. -2. A Rust-owned reusable `Vec` can grow to the requested high-water mark. - A fresh token must bind every operation even when the existing capacity is - reused. `try_reserve_exact` reports allocation failure before publishing a - reservation; begin is rejected while another reservation is active. -3. Repeated/geometric host calls to `kernel_alloc_scratch` have no free - operation and would permanently leak every older region. That design was - rejected. ABI 43 has no host-allocation or older-kernel fixed-buffer - fallback. - -The tokenized Rust-owned reusable region is the chosen current-source design -because Rust remains the sole allocation owner and no pointer -can be used without an active exclusive reservation. Begin and the -pointer/capacity queries are nonblocking; contention returns `EBUSY` or zero. -After every successful begin, host cancellation runs in a `finally` block, -including setup and copy failures. Commit and cancellation wait on the same -no-host-import critical section and return with a definitive token state. -Commit parses the selected prefix into owned vectors and drops the scratch -lock before process-table work or host imports, so the allocation lifetime and -reentrancy rules are mechanically enforced rather than inferred from -JavaScript event-loop behavior. - -The workload performs one ordinary spawn with the fixed environment `LANG=C`, -`PATH=/bin`, one spawn whose complete wire blob is exactly 84,386 bytes, and -five more spawns at that size. It fails a sample unless every waited child -exits normally with status zero. Each round starts a fresh dedicated kernel -worker. The fixed-buffer baseline was built from an isolated archive of exact -#1094 head `6d923c6454dd7174082f25c3d3991d03f86f5ddb`; its temporary -host-only telemetry reported the existing fixed constant after program -completion and did not change kernel allocation or copy behavior. The -hardened measurements used the tokenized ABI-43 kernel and host artifacts at -the fingerprinted rehearsal state below. Subsequent descriptor and dispatcher -hardening means those fingerprints are not the current source head. - -Earlier diagnostic samples used a workload whose ordinary environment was -host-derived and which did not reject a nonzero or abnormal child exit. They -are superseded and are not presented as evidence for the hardened workload -described above. - -The comparable rehearsal measurements completed on July 25, 2026. Values are -medians of three fresh-worker rounds; times are milliseconds and memory is -bytes: - -The measured toolchain was Node.js `v24.15.0`, Playwright `1.61.0`, and -Chromium `149.0.7827.55`. - -| Host and design | Ordinary spawn | First 84,386-byte spawn | Five repeated 84,386-byte spawns, per spawn | Reported retained scratch capacity | Kernel linear-memory high-water mark | -|---|---:|---:|---:|---:|---:| -| Node, #1094 fixed buffer | 51.378 | 47.488 | 46.3876 | 8,417,320 | 26,017,792 (397 pages) | -| Node, tokenized growable buffer | 46.8 | 44.08 | 43.28 | 84,386 | 17,694,720 (270 pages) | -| Chromium, #1094 fixed buffer | 14 | 12 | 11 | 8,417,320 | 26,017,792 (397 pages) | -| Chromium, tokenized growable buffer | 14 | 11 | 10.2 | 84,386 | 17,694,720 (270 pages) | - -The focused workload therefore measured 8,332,934 fewer bytes of reported -retained scratch capacity, a 98.997% reduction. Whole kernel linear memory was -8,323,072 bytes, or 127 64-KiB pages, smaller after the workload (31.990%). -Those are memory measurements, not an allocator-rounding claim. The three -timing samples are too small and noisy to support a speedup or no-regression -claim; no such claim is made. - -For this design, post-run kernel memory equals peak kernel memory only because -WebAssembly memory grows monotonically and cannot shrink. Post-run Rust -`Vec` capacity is the retained scratch high-water mark only because the -kernel intentionally keeps that reusable allocation and does not shrink it -between spawns. These implementation properties make the final samples valid -for this workload; they are not a general substitute for peak-memory -instrumentation. - -The prepared hardened workload source has SHA-256 -`53556d1ad905c92b70b0f5cff29babcf5c0b3183185cdd6a86303eac18f14cc5`. -The exact-#1094 and measurement-time hardened workload Wasm files have SHA-256 -values -`b207969191ac8132150d43a84f0f2857db4326e7108b93ff60be7159de835514` -and -`e0738d4e6f87e099aa843ae562b03f14b1e16dd30b92abed2302a429c8119cfc`. -The exact baseline kernel Git tree is -`6a8721697edbfa5f4fbd22cb21b41d8ccdcc4a2e` and its built kernel SHA-256 is -`e6979f1fa7fdec68959c7f735c3c16ea91060c61cd203e86fd02eaf9a00326bd`; -the measurement-time hardened built-kernel SHA-256 is -`db2835a4905023c81a3eecaa6861feb955ea0610eb34763a8de65983b8a96ddb`. -The measurement-time hardened Node worker bundle is -`f3e1ae982b9c85fffa8caf85907e9c73e52db1cd24e7a9a2da2a132af279dfdb`, -and the measurement-time `host/src` fingerprint is -`89c24dba492309f0196059184e9af0ffaca4e91f1faa139ed0caa0be6574ac21`. -The fixed-baseline Node and Chromium raw logs have SHA-256 values -`f4374b0df0a66bbbd56f19c5637542fa8f40bbfe5f552b23af055c43fcb18dcc` -and -`a0a4b52088ab19ad71bc88ee48c514e1bc5b0c5c58f33410c66a2bcf5d44e814`. -The measurement-time rehearsal result files are -`benchmark-node-1785010575047.json` with SHA-256 -`78ccaac5f4b737b34b934f68ae808769512c3da824414452dd06d6a325b42fc8` -and `benchmark-browser-1785010588397.json` with SHA-256 -`bbef0b4bb0f82edc3d7f4fcfbc07d8e4fcc88ded464f989a99d2888e96794ede`. -Those result files fingerprint the exact runtime inputs above, but their Git -metadata records older committed head -`08620d9233a2812eb1098fe6e7b53a7fba58afb4` while the measured implementation -was still uncommitted. Later scratch-contract changes also postdate those -fingerprints. The files are evidence for the stated rehearsal workload only, -not a substitute for exact committed-head and post-retarget reruns. - -### Historical pre-retarget dirty-worktree evidence - -The later pre-retarget same-worktree focused results were produced on July 26, -2026 with: - -```bash -scripts/dev-shell.sh npx tsx benchmarks/run.ts --host=node \ - --suite=spawn-scratch --rounds=3 -scripts/dev-shell.sh npx tsx benchmarks/run.ts --host=browser \ - --suite=spawn-scratch --rounds=3 -``` - -Their digests were computed through the declared development shell with: - -```bash -scripts/dev-shell.sh sha256sum \ - benchmarks/results/benchmark-node-1785057474317.json \ - benchmarks/results/benchmark-browser-1785057482750.json -``` - -The Node result is -`benchmarks/results/benchmark-node-1785057474317.json`, SHA-256 -`dfea628c210f77430266d83ec8a48d92387a24870d8b427dd22021354591619d`. -It records timestamp `2026-07-26T09:17:54.316Z`, host `node`, Darwin arm64, -Node.js `v24.15.0`, three rounds, Git head -`b840bf2f145b264512a169dacdee21df1d4ea36b`, and Git ref -`remotes/origin/fix/kernel-scratch-capacity-j5u66-draft`. -The Chromium result is -`benchmarks/results/benchmark-browser-1785057482750.json`, SHA-256 -`dfd44e7b810be14afd7af86e8625b7f860600bfdd855abc4052d91c566851f88`. -It records timestamp `2026-07-26T09:18:02.750Z`, host `browser`, the same -platform, architecture, Node.js harness version, round count, Git head, and -Git ref. The result format does not record the browser executable version. A -same-tree declared-shell inspection reported Playwright `1.61.0` and Google -Chrome for Testing `149.0.7827.55`; those versions are environment metadata, -not fields authenticated by the result-file digests. - -Both files fingerprint the same measured inputs: - -- `local-binaries/kernel.wasm`: 642,109 bytes, SHA-256 - `e2abb9bf9d1b88e47e46f7971036fc44a417b6f4a43819b3319a90b0880b4df8`; -- `host/src`: 111 files and 2,781,747 bytes, SHA-256 - `d5fbfab41983255f2cf0dc0141d2dd82295dd15782b4cbee276abed46004cb64`; -- `benchmarks/wasm/spawn-bench.wasm`: 38,317 bytes, SHA-256 - `e0738d4e6f87e099aa843ae562b03f14b1e16dd30b92abed2302a429c8119cfc`; -- `benchmarks/wasm/hello.wasm`: 7,158 bytes, SHA-256 - `4c059e672853793fe2b0177c205de28d88cd7e8e84dabb79f30353708dce2741`. - -The Node file additionally fingerprints the selected -`host/dist/node-kernel-worker-entry.js`: 1,260,964 bytes, SHA-256 -`80f648f79f4b1020bd8f08e6f0bc545696de66a093b65a2566821661996b3cea`. - -| Host | Ordinary spawn | Wire bytes | First large spawn | Repeated large spawn | Retained scratch | Kernel memory | -|---|---:|---:|---:|---:|---:|---:| -| Node | 49.8 ms | 84,386 | 48.19 ms | 46.94 ms | 84,386 bytes | 17,694,720 bytes | -| Chromium | 20 ms | 84,386 | 17 ms | 15.4 ms | 84,386 bytes | 17,694,720 bytes | - -These are historical pre-retarget dirty-worktree observations, not -committed-head evidence: the JSON Git metadata identifies the checked-out -commit, while the artifact hashes identify the uncommitted runtime inputs -actually measured. They preserve the historical fixed-buffer comparison above -rather than replacing it; no contemporaneous fixed-buffer rerun was made. -Three-round timings do not support a latency improvement or no-regression -claim, and none is made. Retargeting is complete, but the exact frozen final -head still requires its own Node and Chromium reruns. - -The baseline archive was exact #1094 plus a host-only telemetry diff, with -SHA-256 -`f78fbd452f1b758aa9494998e00816aae521d1fc4d66e5c2a0d7de8062ebe73e`; -that diff reported the already-retained fixed capacity and did not change the -kernel allocator or copy path. Its Node worker bundle was -`dd9e9e03c84d80df116448594727f2e12af2957bac3b1e55b3fa9c7b27df5e35`. -The older Chromium wrapper labels the combined run `process-lifecycle`, while -the hardened measurement wrapper labels the isolated component -`spawn-scratch`; both -created a fresh browser kernel for each round and ran the same hardened spawn -workload. This provenance limitation is why the focused results are evidence -for buffer sizing, not broad application performance. - -The browser benchmark now loads optional application URL graphs only when an -application suite asks for them. The dedicated Node `spawn-scratch` suite uses -an empty filesystem because it supplies both executables; the established -`process-lifecycle` suite still requires its default rootfs. Those dependency -declarations let the focused scratch measurement run without weakening the -resolver or silently changing existing process metrics. Application suites -still resolve and enforce the same package policy. Broader Node/browser -application measurements remain blocked by unavailable ABI-43 package -artifacts. The focused workload and all broader measurements must be rerun -on the frozen post-retarget final head. - -### Interim post-retarget dirty-worktree measurements - -The focused workload was rerun after retargeting onto the actual #1097 merge, -but before the implementation was committed and frozen: - -```bash -scripts/dev-shell.sh -- npx tsx benchmarks/run.ts --host=node \ - --suite=spawn-scratch --rounds=3 -scripts/dev-shell.sh -- npx tsx benchmarks/run.ts --host=browser \ - --suite=spawn-scratch --rounds=3 -``` - -The environment reported Node.js `v24.15.0`, Playwright `1.61.0`, and Google -Chrome for Testing `149.0.7827.55`. - -| Host | Ordinary spawn | Wire bytes | First large spawn | Repeated large spawn | Retained scratch | Kernel memory | -|---|---:|---:|---:|---:|---:|---:| -| Node | 51.24 ms | 84,386 | 49.24 ms | 48.25 ms | 84,386 bytes | 17,694,720 bytes | -| Chromium | 17 ms | 84,386 | 15 ms | 14 ms | 84,386 bytes | 17,694,720 bytes | - -The Node result is -`benchmarks/results/benchmark-node-1785070891966.json`, SHA-256 -`2f5c6ec009829d2710f0106d1a0166fa3371e80810935d0bfeef553cee117026`. -The Chromium result is -`benchmarks/results/benchmark-browser-1785070900428.json`, SHA-256 -`29aa9ff74f3a7c81905e308eb01e715ce39430648a00e9bd51bd5b89722e03a4`. -Both identify checked-out Git head -`2e0b32d3e1620c8eb68c41999148824ceb3ccea8`, but the measured source was -dirty. The runtime fingerprints therefore identify the actual inputs: - -- kernel Wasm: 644,386 bytes, SHA-256 - `691a1ceedce21e9bce4c1eda09f646092f6c5c1c89311d70e3cc5debc5ada6a8`; -- `host/src`: 109 files, 2,775,329 bytes, SHA-256 - `1add5d34a592498552e50b9cbe64fc15008890cbe215c092cba61caefd0d4d0f`; -- spawn fixture: 38,373 bytes, SHA-256 - `b7dc5d5bc37aaafd5f384750efbfe10c89cf84de416b55cc40edc6a61c009de0`; -- Node worker bundle: 1,264,520 bytes, SHA-256 - `764ee4d1fdb22965bc6b1270ab1c3d04a250a931bf9a17439cfb617eac4824ea`. - -This post-retarget run again retained only 84,386 scratch bytes instead of the -historical fixed 8,417,320 bytes, and kernel linear memory remained 127 pages -below the comparable fixed-buffer workload. It is memory-sizing evidence for -the measured inputs, not exact committed-head evidence and not a latency -claim. - -### Exact-PR-head measurement recording contract - -After the PR head is frozen, its external validation ledger must record the -exact commit SHA, fresh source/artifact fingerprints, and the same Node and -real-Chromium measurements. Keeping that mutable evidence in the PR description -avoids changing the commit merely to embed its own SHA here. The ledger must -distinguish the historical exact-#1094 baseline from the final ABI-43 -implementation and must not claim a timing improvement from three-round -samples. +## Spawn buffer sizing decision + +The large-spawn ownership alternatives have different lifetime consequences: + +| Design | Ownership and lifetime | Decision | +|---|---|---| +| Fixed 8,417,320-byte worst-case region | Rust owns one bounded allocation, so it is capacity-safe, but the complete protocol ceiling is retained after the first large use even when ordinary large spawns are much smaller | Not selected for ABI 43 | +| Reusable growable Rust region, `SpawnScratchBuffer` | Rust grows a `Vec` only while no reservation is active. A fresh token exposes one pointer/capacity pair, commit or cancellation revokes it, and later growth cannot invalidate a live host view. The allocation is leak-free and retains only its kernel-lifetime high-water capacity | Selected | +| Geometric host allocations | The host would have to retain or leak old Rust allocations to keep stale views from becoming dangling, and it would become the de facto allocation owner | Rejected | + +The selected growable design follows from ownership, exclusion, and lifetime +correctness. This audit does not present before/after retained-memory, Node, +browser, or timing results. If the PR makes a memory or performance claim, the +external exact-head ledger must contain the performance guide's matching +measurements and artifact fingerprints. ## ABI decision @@ -924,13 +854,32 @@ samples. - `kernel_handle_channel` now accepts the complete channel capacity as its second argument and rejects any value other than the canonical allocation size. Its signature changes from the ABI-42 two-argument form to - `(channel_offset, channel_capacity, pid)`, so old hosts and kernels cannot be - mixed. + `(channel_offset, channel_capacity, pid, retry_token)`, so old hosts and + kernels cannot be mixed. Token zero denotes an initial attempt; a positive + token authorizes only one exact blocked operation and stable Rust-owned + target. +- The existing 72-byte channel header's former reserved `u32` at offset 68 is + generated `request_flags`. Libc writes the generated cancellation-point and + wake-allowed bits before `PENDING`; plain calls write zero. The host captures + and clears the word once, rejects unknown combinations, and freezes it with + the immutable request rather than rereading a reused mailbox. Assigning these + semantics without moving later fields is still a channel-contract change + recorded in the ABI snapshot. It is folded into unpublished ABI 43, never a + second ABI 44. +- ABI 43 requires + `kernel_blocking_retry_token(pid, tid, syscall_nr)` and + `kernel_blocking_retry_release(pid, tid, token)`, and adds the trailing retry + token to `kernel_transfer_io_execute`, + `kernel_transfer_channel_execute`, `kernel_sendmsg`, and `kernel_recvmsg`. + The immutable TypeScript snapshot is internal, but those export signatures, + required capabilities, and close/reuse semantics are incompatible ABI + contracts. They are part of this worktree's ABI-43 reconciliation rather + than a second ABI-44 epoch; that decision would not justify changing an + already released ABI 43 in place. - The process-channel signal area is one generated 56-byte delivery record for both caller widths, replacing the ABI-42 44-byte delivery payload inside a 48-byte reserved channel area. It carries raw eight-byte `si_value` bits, - `si_code`, sender or - timer metadata, and the alternate-stack fields. The C trampoline copies only + `si_code`, source metadata, and the alternate-stack fields. The C trampoline copies only the generated target-native `union sigval` width when constructing `siginfo_t`, so wasm32 observes the low four bytes and wasm64 observes all eight. This changes the process-channel layout and handler-visible metadata, @@ -949,14 +898,15 @@ samples. and rejects every nonexact capacity with `EINVAL` before validating the task or selecting a waitable child. The export-signature and child-state consumption boundary are incompatible ABI changes covered by ABI 43. -- `kernel_timer_create` changes from - `(clock_id, sigevent_ptr, timerid_ptr)` to - `(clock_id, sigevent_ptr, timerid_ptr, process_pointer_width)`. The fourth - parameter is an `i64` host-private dispatch value in the Wasm export - signature. It selects the complete 64-byte caller-native `sigevent` layout - and preserves `union sigval` as raw `u64` bits, including a wasm64 pointer. - Timer, queued-signal, plain sender, and `SI_MESGQ` metadata now remain intact - through dequeue and native `siginfo_t` reconstruction. +- Host-driven normal exit now calls the required returning + `kernel_commit_process_exit(status)` export. Rust commits the same + authoritative cleanup/state transition as guest `_exit`, then returns the + low eight status bits. The host verifies that return and + `PROCESS_STATE_EXITED` before publishing callbacks. Calling the + intentionally trapping guest `kernel_exit` export would make expected + success indistinguishable from an incoherent export trap, so this concrete + export addition is ABI-43 work; the lexical TypeScript entry-context shape + alone is not. - Generated pointer descriptors now classify every argument as exactly one of required or nullable, positive-extent null handling follows that explicit classification, and zero-length `Arg` buffers use a canonical owned empty @@ -967,16 +917,45 @@ samples. - The large-spawn host/kernel contract is incompatible. The old pointer-returning reserve/fixed-fallback model is replaced with required begin, pointer, capacity, cancel, and token-consuming commit exports. +- Public `WasmPosixKernel.setsockopt` now stages its four-byte scalar in + allocator-owned scratch and calls the already existing five-argument + `kernel_setsockopt` signature. The old four-argument wrapper was incorrect, + but this correction changes no export signature or accepted guest limit. + The static compiler audit's generated-export default-deny set is likewise + host-side enforcement, so neither creates an ABI epoch beyond 43. +- Scalar and vector I/O above the ordinary channel uses the required + `kernel_transfer_scratch_begin`, `kernel_transfer_scratch_pointer`, + `kernel_transfer_scratch_capacity`, `kernel_transfer_scratch_cancel`, and + `kernel_transfer_io_execute` exports. The removed `kernel_read`, + `kernel_write`, `kernel_pread`, `kernel_pwrite`, `kernel_readv`, + `kernel_writev`, `kernel_preadv`, `kernel_pwritev`, and + `kernel_prepare_write_operation` exports carried bare pointers or prepared a + later bare-pointer operation without carrying allocation capacity. They are + not an ABI-43 compatibility surface. +- Host-backed positioned I/O requires the new kernel-Wasm imports + `env.host_pread` and `env.host_pwrite`. They preserve the exact signed-i64 + offset and replace seek/read-or-write/restore. The ABI snapshot does not + encode kernel imports, so a built-Wasm import test enforces this requirement. - The host-adapter manifest continues to require `kernel_spawn_process` and - now also requires `kernel_spawn_reserved_process`, + now also requires `kernel_blocking_retry_release`, + `kernel_blocking_retry_token`, `kernel_commit_process_exit`, + `kernel_get_socket_timeout_ms`, `kernel_is_fd_nonblock`, + `kernel_pick_signal_target_tid`, `kernel_thread_has_deliverable`, + `kernel_spawn_reserved_process`, `kernel_clear_process_metadata`, `kernel_push_process_metadata_entry`, `kernel_set_cwd`, `kernel_spawn_scratch_begin`, `kernel_spawn_scratch_pointer`, `kernel_spawn_scratch_capacity`, `kernel_spawn_scratch_retained_capacity`, `kernel_spawn_scratch_cancel`, + `kernel_transfer_scratch_begin`, `kernel_transfer_scratch_pointer`, + `kernel_transfer_scratch_capacity`, `kernel_transfer_scratch_cancel`, + `kernel_transfer_channel_execute`, `kernel_transfer_io_execute`, `kernel_msqid_ds_bytes`, `kernel_semid_ds_bytes`, `kernel_semctl_array_bytes`, and `kernel_shmid_ds_bytes`. A same-version - kernel missing them fails loudly rather than entering a legacy path. + kernel missing them fails loudly rather than entering a legacy path. The + policy queries are required because nonblocking/timeout state and signal + target/deliverability are Rust-owned; a missing export must not fall back to + a host guess. - The three `*_ds_bytes(process_pointer_width)` exports and the host-private sixth dispatch slot make the caller's wasm32/wasm64 data model authoritative for `msqid_ds` (96/120 bytes), `semid_ds` (72/88), and `shmid_ds` (88/112). @@ -989,331 +968,32 @@ samples. PR #1097 merged as `c7d039794a43788acfa0b0aea30a700c257f57cb` with ABI 42. Retargeting is complete, so ABI 43 is the decided epoch for these incompatible changes. The -current Rust source, generated TypeScript consumer, and ABI snapshot declare -ABI 43, and generated TypeScript includes the request-aware ioctl table. -Generated-file freshness, the ABI classifier, and the snapshot must pass in -check mode on the exact PR head named by the external validation ledger. - -## Historical and interim validation evidence - -All commands recorded in this ledger ran through `scripts/dev-shell.sh`. -The subsection labels distinguish historical pre-retarget evidence from -current post-retarget dirty-tree evidence. Results inside either category do -not all describe one source fingerprint, and artifact-sensitive results -identify their inputs above. None is presented as an exact frozen final-head -run. - -### Interim post-retarget evidence, not final - -- `scripts/dev-shell.sh -- bash build.sh` passed the complete declared build: - the kernel, both-width program fixtures, host bundles, and an ABI-43 root - filesystem. Before that final run, both - `scripts/build-musl.sh` and `scripts/build-musl.sh --arch wasm64posix` - passed, as did `scripts/build-programs.sh`. -- `scripts/dev-shell.sh -- cargo build --release -p kandelo --target - wasm64-unknown-unknown -Z build-std=core,alloc` passed the explicit wasm64 - kernel build. It emitted existing target/conditional dead-code and - unused-variable warnings, not build errors. -- `scripts/dev-shell.sh -- bash scripts/check-abi-version.sh` passed check mode. - It matched the IPC, native-process, and fixed-process layouts for wasm32 and - wasm64; confirmed all six generated ABI outputs are fresh; and verified that - the snapshot change accompanies the `ABI_VERSION` bump to 43. -- The focused scratch/runtime Node matrix passed 11 files and 425 tests: - - ```bash - scripts/dev-shell.sh -- npm --prefix host exec vitest -- run \ - test/generated-abi.test.ts \ - test/kernel-scratch-contract.test.ts \ - test/wasm-memory-write-audit.test.ts \ - test/kernel-scratch-region.test.ts \ - test/kernel-public-scratch.test.ts \ - test/kernel-scratch-transfer-boundaries.test.ts \ - test/kernel.test.ts \ - test/process-native-layout.test.ts \ - test/timerfd-signalfd-scratch.test.ts \ - test/scm-rights-pipe-lifetime.test.ts \ - test/scm-rights-semantics.test.ts - ``` - - The 189 transfer-boundary cases cover both pointer widths. The four - real-compiled-kernel cases include mqueue and child-wait exact-capacity - contracts. The wait-child case rejects null and capacities 159/161 without - consuming the child or changing canaries, accepts exact capacity 160 and - reaps the child, then receives `ECHILD`. -- A separate canonical host-directory process/spawn batch passed 8 files and - 181 tests: - - ```bash - scripts/dev-shell.sh -- bash -lc 'cd host && npm test -- --run \ - test/spawn-blob-transport.test.ts \ - test/exec-state-tracking.test.ts \ - test/process-wait-lifecycle.test.ts \ - test/readiness-deadline.test.ts \ - test/advisory-lock-kernel.test.ts \ - test/signal-accept-livelock.test.ts \ - test/multi-worker.test.ts \ - test/host-adapter-manifest.test.ts' - ``` - - An initial noncanonical invocation ran `multi-worker.test.ts` from the - repository root and failed two relative `../Cargo.toml` opens with `ENOENT`; - the exact canonical rerun above passed both. That invocation-context failure - is not a runtime or scratch failure. -- After the adversarial review found the wrapper-generation defect, - `kernel-initialization-lifetime.test.ts` passed all four wasm32/wasm64, - reinit, concurrency, and failed-retry cases. The accompanying focused - public-scratch/input-snapshot/lifetime batch passed 54 tests. -- After closing the direct pointer-export audit gap, - `wasm-memory-write-audit.test.ts` passed 67 focused analyzer cases. The - repository-wide `kernel-scratch-contract.test.ts` audit passed its selected - contract case in 26.56 seconds with a 60-second CI timeout. It now traces - wrapped callable provenance to the one reviewed lease-core raw invocation; - no production pointer-bearing export bypass remains allowlisted. -- `scripts/dev-shell.sh -- npm --prefix host run typecheck` passed declaration - generation. -- `scripts/dev-shell.sh -- cargo test --target aarch64-apple-darwin -p - kandelo` passed 1,344 unit tests, four pointer-contract integration tests, - and six compile-fail documentation tests. -- `scripts/dev-shell.sh -- cargo test --target aarch64-apple-darwin -p - wasm-posix-shared` passed 37 shared-contract tests, and - `scripts/dev-shell.sh -- cargo check -p wasm-posix-shared` passed with only - the toolchain's unstable-atomics target-feature warning. -- `scripts/dev-shell.sh -- cargo test -p xtask --target - aarch64-apple-darwin dump_abi::tests` passed 21 generator tests, and - `scripts/dev-shell.sh -- bash scripts/test-resolve-binary-bundle.sh` passed - the standalone generated-bundle freshness check. -- Two focused Playwright commands drove real Chromium with one worker. The - scratch-runtime, path, file-limit, native-layout, terminal, and 16-case - two-width `SCM_RIGHTS` semantic group passed 23 tests. The two-width - child-wait and FIFO/SCM pipe-lifetime group passed another five. Total - focused browser evidence is eight spec files and 28 passed tests. Vite - reported that it could not prebundle optional application imports, but the - minimal self-contained test runner loaded and every listed assertion ran. -- The libc, Open POSIX, and Sortix runners were attempted through their normal - entry points. The exact pre-kernel artifact-closure results are recorded in - “Open evidence gaps” above; none is counted as a conformance pass or a - scratch test failure. -- `scripts/dev-shell.sh -- cargo fmt --all -- --check` remains blocked with - `error: no such command: fmt`; no undeclared host formatter was used. -- The post-retarget Node and real-Chromium spawn-scratch measurements passed - three fresh-worker rounds each. Their values and runtime fingerprints are in - “Interim post-retarget dirty-worktree measurements.” - -Every result in this subsection was produced from the current dirty -post-retarget source, not a frozen commit. It must be repeated as appropriate -on the exact final head. - -### Historical pre-retarget and dirty-worktree evidence - -- `bash scripts/dev-shell.sh bash build.sh`: passed the complete declared build, - including the wasm32 kernel, wasm32/wasm64 guest programs, the TypeScript - host, and the root filesystem. -- `bash scripts/dev-shell.sh cargo build --release -p kandelo --target - wasm64-unknown-unknown -Z build-std=core,alloc`: passed an explicit wasm64 - kernel build from the frozen Rust source. -- `scripts/dev-shell.sh -- cargo test --target aarch64-apple-darwin -p - kandelo`: the historical pre-retarget dirty-worktree run passed all 1,343 - native kernel unit - tests, four integration tests, and six documentation tests. This includes - the exact 56-byte signal record, invalid mqueue notification signums, - full-width signal metadata, and self-sender metadata regressions. -- `bash scripts/dev-shell.sh cargo test --target aarch64-apple-darwin -p - wasm-posix-shared` passed all 36 shared-crate unit tests, and - `bash scripts/dev-shell.sh cargo check -p wasm-posix-shared` passed. -- `bash scripts/dev-shell.sh cargo check -p kandelo --target - wasm32-unknown-unknown -Z build-std=core,alloc` passed the explicit wasm32 - kernel check. These are source/crate checks, not browser or full runtime - evidence. -- `bash scripts/dev-shell.sh cargo test --target aarch64-apple-darwin -p kandelo - --test wasm_api_channel_pointer_contract`: four - integration tests passed. These are source-contract checks over the Wasm API - dispatcher and zero-length `sendmsg` guard, not a wasm-target runtime - execution. -- An earlier `bash scripts/dev-shell.sh bash scripts/check-abi-version.sh` run - passed the ABI classifier and snapshot, generated Rust/TypeScript/C freshness - checks, and the wasm32/wasm64 native-layout checks. After the signal export - and channel-layout changes, `scripts/dev-shell.sh -- bash - scripts/check-abi-version.sh update` again passed both native-layout checks, - the kernel build, and regeneration. Check mode still requires a frozen-head - rerun; update mode is not substituted for that final freshness/classifier - evidence. -- `scripts/dev-shell.sh -- cargo test --target aarch64-apple-darwin -p xtask - dump_abi::tests::generated_native_process_layout_contract_matches_both_musl_targets` - and the corresponding - `dump_abi::tests::generated_channel_contract_covers_status_layout_and_signal_wire` - case passed against the generated native layouts and 56-byte channel signal - wire. -- `scripts/dev-shell.sh -- npm --prefix host test -- --run - test/kernel-scratch-transfer-boundaries.test.ts -t "fails closed when the - mqueue notification drain returns an errno"` passed its one selected case. - It proves `-EINVAL` publishes neither a wake nor a signal; the other 188 - cases in that file were intentionally skipped by the name filter. -- `scripts/dev-shell.sh -- npm --prefix host test -- --run test/kernel.test.ts - -t "requires a nonnull exact-capacity mqueue notification destination"` - passed its one selected real-Wasm case. It seeds one live notification - through `mq_notify`/`mq_timedsend`, rejects pointer zero and capacities 7/9 - without consuming or mutating it, then accepts capacity 8, returns the - expected PID/signum, and preserves both destination canaries. -- `scripts/dev-shell.sh -- npm --prefix host test -- --run - test/process-native-layout.test.ts` passed both wasm32 and wasm64 cases after - the latest full rebuild. Those cases now exercise 56-byte `SA_SIGINFO` - delivery, native C reconstruction, queued/timer/mqueue values, sender - metadata, and invalid `mq_notify` signums. This is Node evidence only; - Chromium remains pending. -- `bash scripts/dev-shell.sh npm --prefix host exec vitest -- run - test/generated-abi.test.ts - test/kernel-scratch-transfer-boundaries.test.ts` passed 195 tests on the - regenerated TypeScript ABI consumer. Of those, 188 are the transfer-boundary - cases covering wasm32/wasm64 positive null, owned empty, fixed-output null, - option-sensitive `prctl`, null nested length, reordered `Deref`, vector and - message capacity, shared-memory allocator identity, and the other - caller/allocation boundaries inventoried above. -- The historical broader focused host matrix passed 255 tests: - - ```bash - scripts/dev-shell.sh npm --prefix host exec vitest -- run \ - test/wasm-memory-write-audit.test.ts \ - test/kernel-scratch-contract.test.ts \ - test/kernel-scratch-region.test.ts \ - test/kernel-public-scratch.test.ts \ - test/spawn-blob-transport.test.ts \ - test/centralized-spawn.test.ts \ - test/spawn-host-parity.test.ts \ - test/host-process-pointer-width.test.ts - ``` - - This matrix includes the 64-case compiler-backed analyzer and the - three-case repository scratch contract, plus region, public wrapper, spawn - transport, spawn lifecycle/parity, and pointer-width coverage. It is focused - Node evidence, not the complete host suite or a browser claim. Both focused - commands remain rehearsal evidence and must be repeated on the frozen - post-retarget head. -- The historical broader integration matrix passed 29/29 files and 679/679 - tests: - - ```bash - scripts/dev-shell.sh npm --prefix host exec vitest -- run \ - test/kernel-scratch-contract.test.ts \ - test/wasm-memory-write-audit.test.ts \ - test/kernel-scratch-region.test.ts \ - test/kernel-scratch-transfer-boundaries.test.ts \ - test/kernel-public-scratch.test.ts \ - test/spawn-blob-transport.test.ts \ - test/pathconf.test.ts \ - test/file-shared-memory.test.ts \ - test/process-native-layout.test.ts \ - test/sysv-ipc.test.ts \ - test/timerfd-signalfd-scratch.test.ts \ - test/kernel-worker-copyback.test.ts \ - test/deferred-worker-start.test.ts \ - test/kernel-wasm-input-snapshot.test.ts \ - test/host-process-pointer-width.test.ts \ - test/program-fixture-freshness.test.ts \ - test/compiled-worker-entry.test.ts \ - test/clone-tid-authority.test.ts \ - test/exec-state-tracking.test.ts \ - test/process-wait-lifecycle.test.ts \ - test/shared-memory-coherence.test.ts \ - test/generated-abi.test.ts \ - test/abi-version.test.ts \ - test/host-adapter-manifest.test.ts \ - test/terminal-attributes-api.test.ts \ - test/centralized-spawn.test.ts \ - test/spawn-host-parity.test.ts \ - test/spawn-pid-authority.test.ts \ - test/advisory-lock-kernel.test.ts - ``` - - This exact rerun includes the shared-memory allocator-identity regression - and every repaired wait-result pointer-plus-capacity expectation. It is - historical dirty-worktree Node evidence, not the complete host suite or a - browser claim. -- `scripts/dev-shell.sh npm --prefix host run typecheck` passed the - pre-retarget dirty-worktree host declaration build. -- `bash scripts/dev-shell.sh npx tsx --test - benchmarks/artifact-selection.test.ts benchmarks/timeout.test.ts`: 13 - benchmark artifact-selection, timeout, and spawn-evidence contract tests - passed. -- The generated package-index projection and its source-context check passed - through the worktree's declared native `xtask` with: - - ```bash - bash scripts/dev-shell.sh target/aarch64-apple-darwin/release/xtask \ - build-deps program-index packages/registry \ - packages/registry/program-packages.json - bash scripts/dev-shell.sh target/aarch64-apple-darwin/release/xtask \ - build-deps program-index-context-check --source-repo-root "$PWD" - ``` - - The exact-source projection has SHA-256 - `538c269f8a4e86305929db6358176a38f4855cb6be9d83e324b1c4028db20fa0` - and is committed because leaving the base projection in place makes the - package-build-root contract fail as stale after the ABI/source changes. -- `bash scripts/dev-shell.sh bash scripts/test-package-build-roots.sh`: passed - after regenerating the projection. The first CI run exposed the stale - projection honestly; no freshness bypass or test exception was added. -- `bash scripts/dev-shell.sh npx tsx benchmarks/run.ts --host=node - --suite=spawn-scratch --rounds=3` and the corresponding `--host=browser` - command both passed. The browser command drove real Chromium. This evidence - covers only the self-contained spawn workload; the exact result files and - fingerprints are recorded in the sizing section. - -Before the fixtures opted out of the default root filesystem, artifact policy -correctly rejected an ABI-mismatched rootfs before any assertion ran: first for -the timer/signalfd cases, and later for the two process-native-layout plus two -System V IPC cases. These tests execute self-contained binaries and require no -rootfs contents, so they now pass `useDefaultRootfs: false`. The default-rootfs -policy itself was not weakened; tests that request that artifact still require -an ABI-matching image. - -### Exact final-head gates - -No dirty-worktree result above is substituted for these gates. The mutable -draft PR ledger must record: - -- exact final head SHA plus source, generated-file, kernel Wasm, worker-bundle, - guest-fixture, and benchmark-input fingerprints; -- the complete declared build and selected native/shared kernel suites; -- ABI check mode, generated-file freshness, ABI classifier, and committed - snapshot checks; -- the focused and broader Node matrices, including the real-Wasm - `kernel_wait_child_poll` exact-capacity regression; -- real Chromium execution of the exact relevant specs, including scratch - runtime, process-native layout, wait lifecycle, and all 16 `SCM_RIGHTS` - semantics cases plus the two pipe-lifetime cases; -- another normal attempt at the blocked Sortix, libc, and Open POSIX coverage - if the ABI-43 package closure becomes available; -- final Node and real-Chromium retained-memory and timing measurements described - in the recording contract above. - -### Historical blockers and uncompleted coverage to reprobe - -- From `apps/browser-demos`, - `../../scripts/dev-shell.sh env CI=1 KANDELO_PLAYWRIGHT_PORT=15466 - npx playwright test test/terminal-attributes-api.spec.ts - test/wait-lifecycle.spec.ts test/environment-lifecycle.spec.ts - test/opfs-advisory-lock.spec.ts --project=chromium` stopped during Vite - startup because the program graph rejected stale ABI-42 `bzip2.wasm`. - No assertion ran; after the blocked setup was interrupted, one test was - reported interrupted and five did not run. This is neither a Chromium pass - nor a changed-runtime failure. -- The historical pre-retarget `SCM_RIGHTS` Chromium attempt stopped before - guest launch on the authored-application graph. The post-retarget focused - minimal-dependency command supersedes that narrow gap: all 16 semantic and - both pipe-lifetime cases now pass. It does not unblock the broad application - graph or make the historical stopped run a pass. -- Sortix, libc-test, and Open POSIX were each reprobed normally and stop before - guest execution on the same incomplete one-tier ABI-43 artifact closure. - Exact counts and the direct resolver diagnostic are recorded above. No - resolver bypass or test-only exception was used. -- The performance guide's complete application suites remain blocked by - unavailable ABI-43 PHP, WordPress, and MariaDB artifacts. The focused timing - sample is not substituted for those suites. -- `bash scripts/dev-shell.sh cargo fmt --all -- --check` could not start: - the declared shell reports `error: no such command: fmt`. The discovered - Homebrew formatter is undeclared and was not used. -PR #1097 is merged and retargeting is complete. The focused Chromium and -retained-capacity results above remain historical/interim evidence rather than -complete-application evidence. No approval may be requested and no merge may -occur unless the draft PR's validation ledger names its current exact head and -reports the required reruns and external blocks truthfully. +current Rust source declares ABI 43 and the complete required-export set. +Generated TypeScript, the ABI classifier, and the snapshot must be regenerated +and pass in check mode on the exact PR head named by the external validation +ledger; this documentation-only finalization does not claim their freshness. + +## Validation boundary for finalization + +This tracked audit is a source and coverage contract, not a mutable execution +log. This documentation-only finalization runs only document-local checks and +does not claim: + +- a Node runtime result; +- real Chromium behavior; +- retained-memory or performance measurements; +- a complete build; +- generated-file or ABI snapshot freshness; or +- libc, Open POSIX, or Sortix conformance results. + +Before the change is presented as ready, the draft PR's external ledger must +name the frozen exact commit and record, through `scripts/dev-shell.sh`, the +focused scratch and immutable-retry regressions, kernel parser and lifecycle unit +tests, wasm32/wasm64 paths, complete Sortix spawn surface, generated-file and +ABI snapshot checks, and the broader conformance suites required by the +validation guide. Shared runtime behavior requires both Node and real Chromium +evidence. A retained-memory or performance statement additionally requires the +performance guide's matching before/after Node and browser measurements. + +No PR is merged by this document. Brandon's explicit approval remains required +after the exact head and its validation ledger are ready. diff --git a/docs/posix-status.md b/docs/posix-status.md index 8a1d3dfa4d..b4b99f7dce 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -29,7 +29,7 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve OFD seek positions and status flags are still copied per process. Directory descriptors reopen a process-local host iterator at the copied guest-visible cookie, but subsequent cursor movement is likewise not shared. -- **Serialized syscall execution** — the kernel handles one syscall at a time, which provides natural atomicity for operations like O_APPEND writes and PIPE_BUF-sized pipe writes +- **Serialized syscall execution** — the kernel handles one syscall at a time, which provides natural atomicity for kernel-owned operations such as memfd `O_APPEND` and `PIPE_BUF`-sized pipe writes; host-backed append additionally requires an exact backend outcome - **Signal delivery** across processes is direct — the kernel can write to any process's pending signal mask **Key kernel-side APIs:** @@ -38,8 +38,13 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve - `kernel_fork_process(parent, caller_tid)` — validate the calling task, allocate a child PID, and copy inherited state including that task's signal mask - `kernel_spawn_process(parent, caller_tid, blob_ptr, blob_len)` — validate the calling task, allocate the child PID, and apply spawn attributes and file actions - `kernel_remove_process(pid)` — clean up on exit -- `kernel_handle_channel(offset, capacity, pid)` — dispatch a syscall from a - process's capacity-bounded channel allocation +- `kernel_handle_channel(offset, capacity, pid, retry_token)` — dispatch a + syscall from a process's capacity-bounded channel allocation; token zero is + an initial attempt +- `kernel_blocking_retry_token(pid, tid, syscall_nr)` — obtain the opaque + stable-target token created by the first `EAGAIN` +- `kernel_blocking_retry_release(pid, tid, token)` — consume one exact retry + binding and its kernel-owned references --- @@ -51,17 +56,17 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve | `openat()` | Full | AT_FDCWD delegates to open(). Absolute paths handled. Real dirfd supported via stored OFD paths. | | `close()` | Partial | Ref-counted OFD cleanup. Host handle closed when last ref dropped. Closing any descriptor for a file releases every process lock held by that PID on the file; OFD locks survive duplicated/inherited references and disappear only with the final machine-wide OFD reference. EINTR not yet handled. | | `read()` | Partial | Host-delegated for files. Pipe/socket reads from kernel ring buffer with blocking when empty (EINTR on signal). Short reads permitted. O_NONBLOCK returns EAGAIN. | -| `pread()` | Partial | Host-delegated via seek-read-restore. Not atomic (single-threaded safe only). Rejects pipes/sockets with ESPIPE. | -| `write()` | Partial | Host-delegated for files. Pipe writes to kernel ring buffer with blocking when full (EINTR on signal). EPIPE + SIGPIPE on closed read end (POSIX-compliant). O_APPEND seeks to end before write. For regular files and memfds, RLIMIT_FSIZE is calculated once per logical operation: a crossing operation returns the prefix that fits without a signal; a later non-empty operation with no room fails with EFBIG and generates thread-directed SIGXFSZ. | -| `pwrite()` | Partial | Host-delegated via seek-write-restore. Not atomic (single-threaded safe only). Rejects pipes/sockets with ESPIPE. Uses the same operation-wide RLIMIT_FSIZE rule as write without changing the OFD cursor. | +| `pread()` | Partial | Host-backed files use one positioned backend read without changing the OFD cursor; in-kernel files retain their native positioned path. Rejects pipes/sockets with ESPIPE. Signed-i64 offsets stay exact through the host contract; number-only backends return EOVERFLOW rather than rounding an unrepresentable offset. | +| `write()` | Partial | Host-delegated for files. Pipe writes to kernel ring buffer with blocking when full (EINTR on signal). EPIPE + SIGPIPE on closed read end (POSIX-compliant). `O_APPEND` is one EOF/limit/write transaction that returns the exact written prefix and ending offset: memfds and shared-memory files serialize under their backing lock, OPFS serializes in its channel handler, and lifecycle-owned Node scratch mounts use a verified native append route. Externally mutable `HostFileSystem` mounts and the legacy raw Node adapter cannot prove the exact ending offset and return `EOPNOTSUPP` before mutation. For regular files and memfds, `RLIMIT_FSIZE` applies once per logical operation: a crossing operation returns the prefix that fits without a signal; a later non-empty operation with no room fails with `EFBIG` and generates thread-directed `SIGXFSZ`. | +| `pwrite()` | Partial | Host-backed files use one positioned backend write without changing the OFD cursor; in-kernel files retain their native positioned path. Rejects pipes/sockets with ESPIPE. Uses the same operation-wide RLIMIT_FSIZE rule as write. Number-only backends, including Node's synchronous positioned-write API above JavaScript's safe-integer range, return EOVERFLOW rather than rounding. | | `lseek()` | Partial | Regular files support SEEK_SET, SEEK_CUR, and SEEK_END; SEEK_END delegates to the host for size calculation. Directories accept a nonnegative next-record cookie with SEEK_SET and expose the current cookie through SEEK_CUR with offset zero; other directory seeks fail with EINVAL without changing the cursor. A regular-file seek whose result would be negative likewise fails with EINVAL, and arithmetic or host-number overflow fails with EOVERFLOW. Ordinary-file and directory positions still have the cross-process OFD boundary documented below. | | `dup()` | Full | Lowest available fd. FD_CLOEXEC cleared. Shares OFD with original. | | `dup2()` | Full | Atomic close-and-dup. Same-fd no-op. FD_CLOEXEC cleared. | | `dup3()` | Full | Like dup2 but returns EINVAL if oldfd==newfd. Supports O_CLOEXEC flag. | | `pipe()` | Partial | Kernel-space ring buffer (64KB). PIPE_BUF=4096 atomicity is guaranteed by serialized kernel syscalls. O_NONBLOCK returns EAGAIN. Forked descriptors retain the same global pipe backing even though their per-process OFD metadata is copied. | | `pipe2()` | Full | Like pipe with O_NONBLOCK and O_CLOEXEC flag support. | -| `readv()` | Full | Scatter read. Iterates over iovec array calling sys_read for each buffer. Stops on short read or EOF. | -| `writev()` | Full | Gather write. Enforces aggregate count and RLIMIT_FSIZE once across the full iovec operation, including host scratch-buffer decomposition, then stops on a short underlying write. | +| `readv()` | Full | Validates the complete caller-native iovec table and `IOV_MAX`, performs one contiguous scalar read, then scatters only the returned prefix. This preserves datagram/record boundaries and stops naturally on a short read or EOF even when the vector exceeds ordinary channel scratch. | +| `writev()` | Full | Validates and gathers the complete vector, then performs one scalar write. Pipe/datagram operation boundaries and operation-wide `RLIMIT_FSIZE` are preserved even when the vector exceeds ordinary channel scratch. | | `fstat()` | Partial | Host-delegated for regular files. Anonymous pipes report S_IFIFO with synthetic metadata; named FIFOs preserve their VFS permissions, ownership, timestamps, and authoritative link count across rename and unlink while an fd remains open. Removing the final name sets the cached inode link count to zero and advances ctime. ABI 39 does not report `st_rdev`, `st_blksize`, or `st_blocks`; libc initializes those fields to zero instead of exposing uninitialized memory. Truthful backend metadata is tracked in [issue #928](https://github.com/Automattic/kandelo/issues/928). | | `ftruncate()` | Partial | Host-delegated for regular files, with in-kernel memfd support. Requires write access, validates length >= 0, rejects non-regular fds, and enforces RLIMIT_FSIZE before changing either backing. | | `fsync()` | Partial | Host-delegated for regular files and directories. Node-backed directories use the native durability barrier; memory-backed filesystems have no queued writes. Browser OPFS flushes regular-file access handles, but its API exposes no separate directory durability barrier. Rejects pipes and sockets. | @@ -69,13 +74,13 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve | `truncate()` | Partial | Path-based. Named FIFOs fail with EINVAL without entering their open rendezvous; ordinary paths open O_WRONLY, call ftruncate, and close. | | `fchmod()` | Partial | Regular files, directories, and named FIFOs update VFS metadata; an unlinked but open named FIFO retains the updated cached inode metadata. O_PATH/O_SEARCH descriptors return EBADF. Other kernel-owned pipes/sockets accept the call as a no-op. Node host-backed files never receive native mode changes after creation. | | `fchown()` | Partial | Regular files, directories, and named FIFOs update VFS metadata. `(uid_t)-1` and `(gid_t)-1` preserve the corresponding current ID without bypassing descriptor, authorization, or backend-error checks. Root may select arbitrary IDs; an unprivileged owner must preserve its user ID and may select its effective GID or Kandelo's one synthesized supplementary GID (the real GID). On metadata-backed SharedFS and Node regular files, successful ownership calls clear S_ISUID and S_ISGID when any execute bit is set. O_PATH/O_SEARCH descriptors return EBADF. Unlinked open named FIFOs retain updated cached ownership; other kernel-owned non-file descriptors still accept the call as a metadata-less no-op, and Node host-backed ownership changes stay virtual. Arbitrary supplementary-group lists remain unsupported. | -| `preadv()` | Full | Scatter-gather read at offset. Iterates iovec entries calling pread for each. Stops on short read or EOF. | -| `pwritev()` | Full | Scatter-gather write at offset. Enforces aggregate count and RLIMIT_FSIZE once across the full iovec operation, then stops on a short underlying write. | +| `preadv()` | Full | Validates the complete vector and performs one exact-offset scalar read, then scatters only the returned prefix without changing the OFD cursor. | +| `pwritev()` | Full | Validates and gathers the complete vector, then performs one exact-offset scalar write without changing the OFD cursor. The aggregate `RLIMIT_FSIZE` decision applies once to that operation. | | `preadv2()` / `pwritev2()` | Partial | Delegates to preadv/pwritev. Extra flags parameter ignored. | -| `sendfile()` | Full | Emulated with read+write loop (no zero-copy in Wasm). Supports an optional positioned input offset. The output RLIMIT_FSIZE budget is fixed before input is consumed, so a limit-induced short transfer advances the input only by the returned count. | +| `sendfile()` | Full | Emulated with read+write loop (no zero-copy in Wasm). Supports an optional positioned input offset. The output `RLIMIT_FSIZE` budget is fixed before input is consumed. When source and destination can alias, each chunk stages the input before mutating the output and advances the input only by the prefix the output reports. | | `fallocate()` | Partial | Mode 0 extends through ftruncate when needed, including RLIMIT_FSIZE enforcement; allocation guarantees and nonzero modes are not implemented. | -| `copy_file_range()` | Full | Emulated with pread+pwrite loop. Supports optional offsets for both input and output fds. The output RLIMIT_FSIZE budget is fixed before input is consumed. | -| `splice()` | Full | Emulated through the copy loop with optional offsets. The output RLIMIT_FSIZE budget is fixed before input is consumed. | +| `copy_file_range()` | Full | Emulated with pread+pwrite loop. Supports optional offsets for both input and output fds. The output `RLIMIT_FSIZE` budget is fixed before input is consumed; staged input is committed only through the written prefix. | +| `splice()` | Full | Emulated through the same staged copy loop with optional offsets. The output `RLIMIT_FSIZE` budget is fixed before input is consumed, and source position advances only through the written prefix. | | `tee()` / `vmsplice()` | Stub | Returns ENOSYS. | | `readahead()` | Stub | Returns 0 (no-op advisory). | | `fstatat()` | Partial | AT_FDCWD delegates to stat/lstat. AT_SYMLINK_NOFOLLOW and Linux AT_EMPTY_PATH are supported; an empty path targets either the supplied fd or the current working directory for AT_FDCWD. Real dirfds are supported through stored OFD paths. Cwd- and dirfd-relative lookup inherit the pathname-backed directory-identity limitation documented below. ABI 39 omits `st_rdev`, `st_blksize`, and `st_blocks`; libc reports zero for those fields pending [issue #928](https://github.com/Automattic/kandelo/issues/928). | @@ -273,8 +278,8 @@ shortcuts. | `connect()` | Partial | AF_UNIX streams support same- and cross-process pathname or abstract-namespace listeners; pathname lookup uses the same canonical component walker as bind, including cross-process retries. AF_UNIX datagrams deliver to a registered peer only within the same process; a missing, wrong-type, or cross-process peer returns ECONNREFUSED until machine-wide datagram routing exists. AF_INET TCP is host-backed and works over Node external TCP or the browser local virtual-network backend. For an external non-blocking TCP handshake, the first pending call reports EINPROGRESS, a repeat while it remains pending reports EALREADY, and poll reports writable when completion or failure can be collected through SO_ERROR; blocking callers wait through the same host connection. AF_INET UDP connect stores the peer, auto-binds an ephemeral local port when needed, filters receives to the connected peer, and supports AF_UNSPEC unconnect. AF_INET6 streams support same- and cross-process `::1`; AF_INET6 datagrams are process-local and report `IPV6_V6ONLY=1` because dual-stack datagram routing is not implemented. Non-loopback IPv6 fails with EADDRNOTAVAIL for streams and ENETUNREACH for datagrams. External raw UDP also returns ENETUNREACH without another HostIO transport. | | `send()` / `recv()` | Partial | Unix domain streams and datagrams, AF_INET/AF_INET6 TCP streams, and connected AF_INET/AF_INET6 UDP preserve their socket-family addressing and datagram boundaries. TCP send/recv works over Node external TCP and the local virtual-network backend. Datagram MSG_PEEK and MSG_DONTWAIT are handled through recvfrom. Normal TCP close drains queued bytes before FIN and EOF; no transport invents a fixed post-FIN write count. A send rejected by a closed/reset stream returns EPIPE and raises SIGPIPE, while direct host/virtual handles may preserve ECONNRESET; accepted pipe-bridged resets currently surface as EOF/EPIPE. MSG_NOSIGNAL suppresses SIGPIPE without changing the errno. | | `sendto()` / `recvfrom()` | Partial | AF_INET, AF_INET6, and AF_UNIX datagrams support connected and unconnected send, receive queues, and connected-peer filtering. IPv4/IPv6 return sender addresses; AF_UNIX currently returns only the family. IPv4 limited-broadcast sends to `255.255.255.255` require `SO_BROADCAST` and fail with `EACCES` without it; enabling the option passes that permission gate, after which the send reaches the active routing/backend boundary. Kandelo does not itself model broadcast delivery. On AF_INET, AF_INET6, and AF_UNIX datagrams, Linux's input `MSG_TRUNC` extension returns the full datagram length while copying at most the caller's buffer; ordinary consume/`MSG_PEEK` behavior is unchanged. IPv4/IPv6 UDP receive queues hold 128 datagrams and drop a new arrival once full, preserving the accepted queue's order; `SO_RCVBUF` requests do not size that fixed queue, and `getsockopt` reports the fixed default capacity. AF_UNIX uses the same bound but preserves reliable delivery: a full queue blocks a blocking send through host retry and returns EAGAIN for `O_NONBLOCK`/`MSG_DONTWAIT`; capacity, association, shutdown, close, and pathname changes wake blocked sends and writable readiness waits to observe capacity or the new immediate error. In-kernel IPv4/IPv6 loopback, AF_UNIX datagram, and IPv4 multicast delivery currently reaches sockets in the sender's process only; machine-wide cross-process datagram routing remains unimplemented. Fork preserves kernel-local bind reservations and lookup ownership, but it does not yet share or transfer a host-backed UDP registration. The `10.88.*` LocalVirtualNetwork path can route IPv4 datagrams between attached Kandelo machines through HostIO for the process that registered the endpoint. IPv4 multicast supports interface selection, loop suppression, membership, and source filtering only; IPv6 multicast and external raw UDP are not implemented. | -| `sendmsg()` / `recvmsg()` | Partial | The host validates every native wasm32/wasm64 iovec and enforces the generated `IOV_MAX` of 1,024. It flattens the complete send list into one fixed-wire kernel buffer and scatters a received prefix across the complete caller list; zero-length entries remain valid. The fixed header, optional name, translated control records, one canonical iovec, and payload must fit the 65,536-byte owned transport or the call fails with `EMSGSIZE`. This transport ceiling is an implementation boundary, not an alternate `IOV_MAX`. Native `cmsghdr` records are translated between the generated wasm32/wasm64 layouts and a fixed kernel wire, so receive capacity reflects the descriptors the caller layout can actually represent. `SCM_RIGHTS` preserves owned, receiver-reconstructible non-socket descriptions while they are queued. A batch containing a socket, epoll instance, stale backing, or other process-owned description that cannot be reconstructed fails atomically with `EOPNOTSUPP` before carrier bytes are published; a copied socket snapshot is never reported as successful transfer. AF_UNIX stream rights remain associated with their carrier-byte positions, `MSG_WAITALL` stops at a rights boundary, ordinary reads discard only rights whose bytes they consume, and repeated `MSG_PEEK` does not consume bytes or rights. AF_UNIX datagrams queue payload/address/rights atomically for connected and addressed same-process sends, including zero-byte messages received with `msg_iovlen == 0`; ordinary `read(..., 0)` remains a no-op. Closing the sender's fd cannot invalidate a supported in-flight or received reference. `recvmsg()` installs the descriptor prefix that fits the caller's control buffer, releases the excess, reports `MSG_CTRUNC`, applies `MSG_CMSG_CLOEXEC` atomically, and reports output `MSG_TRUNC` independently of the input flag that selects full-length return behavior. Cross-process AF_UNIX datagram routing, socket-descriptor transfer, and other socket-family ancillary messages remain unsupported, so this surface is still partial. | -| `setsockopt()` / `getsockopt()` | Partial | SOL_SOCKET exposes SO_TYPE, SO_DOMAIN, SO_ERROR, SO_ACCEPTCONN, SO_RCVBUF, and SO_SNDBUF; SO_REUSEADDR affects UDP bind conflicts. `SO_RCVTIMEO`/`SO_SNDTIMEO` accept musl's wasm32 time64 option numbers (66/67) and wasm64 long64 numbers (20/21), canonicalizing both to the same stored timeout state; `struct timeval` is 16 bytes on both ABIs. `SO_RCVBUF`/`SO_SNDBUF` requests are accepted and stored but do not resize kernel queues or pipe buffers; `getsockopt()` reports the fixed default. `SO_BROADCAST` controls only the IPv4 limited-broadcast permission gate and does not provide broadcast delivery. SO_LINGER uses `struct linger`; its disabled form is stored, while enabling timed or reset-style linger returns EOPNOTSUPP until every transport supports the close mode. SO_BINDTODEVICE validates `lo`/`eth0`, supports empty-name unbind, and constrains bind/connect/send routing. TCP_CONGESTION uses a string layout and accepts only the modeled `cubic` policy; selecting unimplemented algorithms fails. IPv4 multicast membership/source-filter options drive process-local loopback delivery. IPV6_V6ONLY controls pre-bind stream dual-stack behavior; AF_INET6 datagrams truthfully remain V6-only. Other accepted IPv6 multicast options are stored but do not provide IPv6 multicast transport. | +| `sendmsg()` / `recvmsg()` | Partial | The host validates every native wasm32/wasm64 iovec and enforces the generated `IOV_MAX` of 1,024. It flattens the complete send list into one fixed-wire kernel buffer and scatters a received prefix across the complete caller list; zero-length entries remain valid. The complete aligned header, optional name, translated control records, one canonical iovec, and payload are capacity-checked as one owned layout: the ordinary channel allocation is used when it fits, and a fresh Rust-owned token reservation is used otherwise. The operation is never shortened merely to fit scratch. Native `cmsghdr` records are translated between the generated wasm32/wasm64 layouts and a fixed kernel wire, so receive capacity reflects the descriptors the caller layout can actually represent. `SCM_RIGHTS` preserves owned, receiver-reconstructible non-socket descriptions while they are queued. A batch containing a socket, epoll instance, stale backing, or other process-owned description that cannot be reconstructed fails atomically with `EOPNOTSUPP` before carrier bytes are published; a copied socket snapshot is never reported as successful transfer. AF_UNIX stream rights remain associated with their carrier-byte positions, `MSG_WAITALL` stops at a rights boundary, ordinary reads discard only rights whose bytes they consume, and repeated `MSG_PEEK` does not consume bytes or rights. AF_UNIX datagrams queue payload/address/rights atomically for connected and addressed same-process sends, including zero-byte messages received with `msg_iovlen == 0`; ordinary `read(..., 0)` remains a no-op. Closing the sender's fd cannot invalidate a supported in-flight or received reference. `recvmsg()` installs the descriptor prefix that fits the caller's control buffer, releases the excess, reports `MSG_CTRUNC`, applies `MSG_CMSG_CLOEXEC` atomically, and reports output `MSG_TRUNC` independently of the input flag that selects full-length return behavior. Cross-process AF_UNIX datagram routing, socket-descriptor transfer, and other socket-family ancillary messages remain unsupported, so this surface is still partial. | +| `setsockopt()` / `getsockopt()` | Partial | SOL_SOCKET exposes SO_TYPE, SO_DOMAIN, SO_ERROR, SO_ACCEPTCONN, SO_RCVBUF, and SO_SNDBUF; SO_REUSEADDR affects UDP bind conflicts. The public host scalar `setsockopt` wrapper stages exactly four value bytes in allocator-owned scratch and passes the lease-derived pointer plus length to Rust; the scalar is never interpreted as a kernel address. `SO_RCVTIMEO`/`SO_SNDTIMEO` accept musl's wasm32 time64 option numbers (66/67) and wasm64 long64 numbers (20/21), canonicalizing both to the same stored timeout state; `struct timeval` is 16 bytes on both ABIs. `SO_RCVBUF`/`SO_SNDBUF` requests are accepted and stored but do not resize kernel queues or pipe buffers; `getsockopt()` reports the fixed default. `SO_BROADCAST` controls only the IPv4 limited-broadcast permission gate and does not provide broadcast delivery. SO_LINGER uses `struct linger`; its disabled form is stored, while enabling timed or reset-style linger returns EOPNOTSUPP until every transport supports the close mode. SO_BINDTODEVICE validates `lo`/`eth0`, supports empty-name unbind, and constrains bind/connect/send routing. TCP_CONGESTION uses a string layout and accepts only the modeled `cubic` policy; selecting unimplemented algorithms fails. IPv4 multicast membership/source-filter options drive process-local loopback delivery. IPV6_V6ONLY controls pre-bind stream dual-stack behavior; AF_INET6 datagrams truthfully remain V6-only. Other accepted IPv6 multicast options are stored but do not provide IPv6 multicast transport. | | `shutdown()` | Partial | SHUT_RD, SHUT_WR, and SHUT_RDWR transitions are idempotent within a process and release each owned pipe/host reference once. UDP write shutdown returns EPIPE on datagram send; read shutdown is EOF-like for recv/poll. Sending to a read-shut AF_UNIX datagram peer returns EPIPE (and SIGPIPE unless MSG_NOSIGNAL is used), and the transition wakes blocked sends/readiness waits. Fork-inherited sockets still clone shutdown flags per process instead of sharing one socket-wide shutdown state, and the external host ABI has no half-shutdown operation. | | `select()` | Partial | Wrapper around poll(). Converts fd_set bitmasks to pollfd array. Timeout supported via a host retry loop. A caught signal interrupts a would-block retry, including the no-fd sleep path, with EINTR; ignored signals leave it parked and a concurrently ready result is preserved. | | `poll()` | Partial | Checks readiness for regular files, pipes, and sockets. UDP poll reports queued datagrams, connected-peer filtering, EOF-like read shutdown, write-shutdown hangup, and pending socket errors. Timeout supported via polling loop with 1ms sleep intervals. Returns EINTR on pending signals. | @@ -286,6 +291,17 @@ shortcuts. | `epoll_create()` / `epoll_wait()` | Full | Legacy aliases. epoll_create ignores size param. epoll_wait delegates to epoll_pwait with null sigmask. | | `sendmmsg()` / `recvmmsg()` | Stub | Returns ENOSYS. | +Socket-address transport uses two generated limits rather than one ambiguous +maximum. Bind, connect, sendto, and nested `sendmsg.msg_name` accept a complete +128-byte `sockaddr_storage`; generic address outputs use the same complete +container, while family-specific AF_UNIX parsing remains bounded by the +110-byte `sockaddr_un`. The registry keeps the canonical AF_UNIX namespace key, +but `getsockname()` returns the bounded original name supplied to `bind()`. +Consequently a short relative bind cannot expand into a deep canonical path on +output, while an exact 108-byte non-NUL pathname may still report 111 bytes +after its output terminator. A larger receive buffer is valid, but the host +proves and reserves only the 128-byte prefix the kernel can write. + ## Time | Function | Status | Notes | @@ -437,7 +453,7 @@ Systematic audit of all subsystems against POSIX specifications. Gaps are catego |-----|-----------|-------------| | **EINTR partially implemented** | all | read, write, recv, poll, select return EINTR when a signal is pending during a blocking wait. close() and other non-blocking syscalls do not check. Tied to signal handler invocation gap. | | **PIPE_BUF guarantee at host-backed stdio boundary** | pipe / host | In-kernel pipes guarantee atomic writes through 4096 bytes and report that value from `fpathconf()`. Captured stdio uses host-backed pipe OFDs; its callback/native-write boundary has not been proven all-or-nothing through the compile-time `PIPE_BUF` value, so `fpathconf()` reports the limit as indeterminate. Do not treat the global `` promise as fully reconciled until that boundary is enforced or stdio is modeled differently. | -| ~~**O_APPEND not atomic**~~ | write | **Resolved.** Syscalls are serialized through the kernel, so seek-to-end + write cannot be interrupted by another process. | +| Host-backed `O_APPEND` on externally mutable native mounts | write | Managed shared-memory, OPFS, memfd, and lifecycle-owned Node scratch backings perform one exact EOF/limit/write operation. Public or extra native mounts return `EOPNOTSUPP` before mutation because Node does not expose the ending offset of its atomic append; supporting that boundary requires a native broker/capability that can return the exact outcome. | | ~~**sigaction() missing sa_flags**~~ | signals | **Resolved.** SA_RESTART supported (auto-restart blocking syscalls). sa_flags and sa_mask stored. SA_SIGINFO handler delivery with siginfo_t. SA_NOCLDWAIT auto-reaps children. SA_NOCLDSTOP suppresses stop/continue SIGCHLD notification while preserving waitable status. | | ~~**No signal queuing**~~ | signals | **Resolved.** RT signals (32-63) are now queued in a VecDeque; standard signals (1-31) remain coalesced per POSIX. | | ~~**`*at()` functions with real dirfd**~~ | filesystem | **Resolved.** All *at() syscalls now support real dirfd via stored OFD paths. | @@ -447,12 +463,12 @@ Systematic audit of all subsystems against POSIX specifications. Gaps are catego | Gap | Subsystem | Description | |-----|-----------|-------------| -| ~~**RLIMIT_FSIZE partial enforcement**~~ | rlimits | **Resolved for implemented write and size-changing operations.** Scalar, vectored, host-chunked, transfer, regular-file, memfd, truncate, and mode-0 fallocate paths share one operation-boundary contract. Pipes, terminals, sockets, and other non-size-bearing objects remain unaffected. | +| ~~**RLIMIT_FSIZE partial enforcement**~~ | rlimits | **Resolved for implemented write and size-changing operations.** Scalar, vectored, tokenized large-transfer, regular-file, memfd, truncate, and mode-0 fallocate paths share one operation-boundary contract. Pipes, terminals, sockets, and other non-size-bearing objects remain unaffected. | | **setpgid() self-only** | process | Only supports setting own pgid. Setting another process's pgid returns ESRCH. | | ~~**realpath() no symlink resolution**~~ | filesystem | **Resolved.** Now resolves symlinks via iterative lstat/readlink with ELOOP after 40 resolutions. | | **Socket options partially no-op** | socket | `SO_REUSEADDR` affects UDP bind conflicts, and `SO_BROADCAST` enforces the IPv4 limited-broadcast permission gate, but actual broadcast delivery remains unavailable. `SO_RCVBUF` and `SO_SNDBUF` are accepted/stored without resizing queues or pipe buffers; `getsockopt()` reports the fixed default. `SO_KEEPALIVE`, `SO_RCVTIMEO`, `SO_SNDTIMEO`, and `TCP_NODELAY` remain accepted/stored with limited or no data-path effect. The timeout options recognize both wasm32 time64 numbers (66/67) and wasm64 long64 numbers (20/21); that ABI parity does not broaden their documented data-path effect. Enabled `SO_LINGER` is rejected rather than stored as a no-op. | | **POLLERR partial** | I/O multiplex | poll() reports UDP pending socket errors and stream shutdown/error cases. Some edge cases remain implementation-defined. | -| **pread/pwrite not multi-process safe** | I/O | Uses save/seek/read/restore pattern — safe only when no other process shares the OFD, but races with shared OFDs across processes. | +| ~~**pread/pwrite not multi-process safe**~~ | I/O | **Resolved.** Host-backed positioned I/O uses direct read-at/write-at operations and never changes or restores the shared OFD cursor. Backends that cannot represent an exact signed-i64 position fail with `EOVERFLOW`. | | ~~**brk not inherited on fork**~~ | memory | **Resolved.** Program break serialized/deserialized in fork state. (`exec` reset is intentional per POSIX; host re-installs from new program's `__heap_base`.) | | ~~**VMIN/VTIME not interpreted**~~ | terminal | **Partially resolved.** `VMIN`/`VTIME` values round-trip through both termios layouts, but full timer-based raw-read semantics remain approximated. Empty-line `VEOF` also lacks the queued EOF event needed to distinguish EOF from ordinary no-data, canonical reads can coalesce multiple completed lines, and `VWERASE` remains unimplemented. | | ~~**ICANON no line buffering**~~ | terminal | **Resolved.** ICANON mode now buffers input with line editing: VERASE (backspace), VKILL (^U), VEOF (^D). ICRNL/INLCR/IGNCR input processing and ECHO/ECHOE/ECHOK/ECHONL echo handling. | @@ -492,7 +508,8 @@ Systematic audit of all subsystems against POSIX specifications. Gaps are catego - Canonical one-record-per-read behavior, empty-line VEOF events, and VWERASE word editing **Shared-kernel advantages (already free):** -- O_APPEND atomicity (serialized syscalls) +- Kernel-owned `O_APPEND` atomicity (serialized syscalls); host-backed append + additionally requires an exact backend outcome - PIPE_BUF atomicity (serialized syscalls) - Cross-process pipe/socket/PTY and eventfd/timerfd/signalfd/memfd/procfs backing identity across inherited descriptors - Signal delivery across processes is direct diff --git a/examples/kernel_scratch_browser_test.c b/examples/kernel_scratch_browser_test.c index afe8383fdd..cf55602cab 100644 --- a/examples/kernel_scratch_browser_test.c +++ b/examples/kernel_scratch_browser_test.c @@ -1,11 +1,14 @@ #define _GNU_SOURCE #include +#include #include #include #include #include #include +#include +#include #include #include #include @@ -46,6 +49,241 @@ static void write_all(int fd, const unsigned char *bytes, size_t length, } } +static void expect_bytes_at(int fd, const unsigned char *expected, + size_t length, const char *step) +{ + unsigned char actual[32]; + if (length > sizeof(actual)) { + errno = EINVAL; + fail(step); + } + ssize_t amount = pread(fd, actual, length, 0); + if (amount < 0) + fail(step); + if ((size_t)amount != length || memcmp(actual, expected, length) != 0) { + errno = EIO; + fail(step); + } +} + +static void expect_zero_result(ssize_t amount, const char *step) +{ + if (amount != 0) { + errno = EIO; + fail(step); + } +} + +static void expect_errno_result(ssize_t amount, int expected, + const char *step) +{ + if (amount != -1 || errno != expected) { + errno = EIO; + fail(step); + } +} + +static int test_append_flags(void) +{ + static const char path[] = "/tmp/kernel-scratch-append-flags"; + static const char renamed[] = + "/tmp/kernel-scratch-append-flags-renamed"; + static const unsigned char expected_before_unlink[] = "aQYZ"; + static const unsigned char expected_after_append[] = "aQYZ!"; + static const unsigned char expected_after_clear[] = "BQYZ!"; + + if (unlink(path) < 0 && errno != ENOENT) + fail("remove stale append path"); + if (unlink(renamed) < 0 && errno != ENOENT) + fail("remove stale renamed append path"); + + int fd = open(path, O_WRONLY | O_CREAT | O_EXCL, 0600); + if (fd < 0) + fail("open O_WRONLY append fixture"); + write_all(fd, (const unsigned char *)"abc", 3, + "seed append fixture"); + int duplicate = dup(fd); + if (duplicate < 0) + fail("dup append fixture"); + + int flags = fcntl(fd, F_GETFL); + if (flags < 0) + fail("get initial append flags"); + if ((flags & O_ACCMODE) != O_WRONLY || (flags & O_APPEND) != 0) { + errno = EIO; + fail("initial append flags"); + } + if (lseek(fd, 1, SEEK_SET) != 1) + fail("set append fixture cursor"); + if (fcntl(duplicate, F_SETFL, O_APPEND) < 0) + fail("set append through duplicate"); + flags = fcntl(fd, F_GETFL); + if (flags < 0) + fail("get shared append flags"); + if ((flags & O_ACCMODE) != O_WRONLY || (flags & O_APPEND) == 0) { + errno = EIO; + fail("shared append flags"); + } + + write_all(fd, (const unsigned char *)"D", 1, "dynamic append write"); + if (lseek(duplicate, 0, SEEK_CUR) != 4) { + errno = EIO; + fail("dynamic append cursor"); + } + if (pwrite(fd, "X", 1, 1) != 1) + fail("append-independent pwrite"); + if (lseek(fd, 0, SEEK_CUR) != 4) { + errno = EIO; + fail("pwrite cursor neutrality"); + } + + unsigned char y = 'Y'; + unsigned char z = 'Z'; + struct iovec positioned[2] = { + { .iov_base = &y, .iov_len = 1 }, + { .iov_base = &z, .iov_len = 1 }, + }; + if (pwritev(duplicate, positioned, 2, 2) != 2) + fail("append-independent pwritev"); + if (lseek(fd, 0, SEEK_CUR) != 4) { + errno = EIO; + fail("pwritev cursor neutrality"); + } + + if (fcntl(fd, F_SETFL, 0) < 0) + fail("clear append"); + flags = fcntl(duplicate, F_GETFL); + if (flags < 0) + fail("get cleared append flags"); + if ((flags & O_ACCMODE) != O_WRONLY || (flags & O_APPEND) != 0) { + errno = EIO; + fail("cleared append flags"); + } + if (lseek(duplicate, 1, SEEK_SET) != 1) + fail("set nonappend cursor"); + write_all(fd, (const unsigned char *)"Q", 1, + "write after clearing append"); + if (lseek(duplicate, 0, SEEK_CUR) != 2) { + errno = EIO; + fail("cleared append cursor"); + } + + if (rename(path, renamed) < 0) + fail("rename open append fixture"); + int reader = open(renamed, O_RDONLY); + if (reader < 0) + fail("open append verification reader"); + expect_bytes_at(reader, expected_before_unlink, + sizeof(expected_before_unlink) - 1, "verify cleared append bytes"); + if (unlink(renamed) < 0) + fail("unlink open append fixture"); + + if (fcntl(duplicate, F_SETFL, O_APPEND) < 0) + fail("set append after unlink"); + write_all(fd, (const unsigned char *)"!", 1, + "append after rename and unlink"); + expect_bytes_at(reader, expected_after_append, + sizeof(expected_after_append) - 1, "verify append after unlink"); + + if (fcntl(fd, F_SETFL, 0) < 0) + fail("clear append after unlink"); + if (lseek(duplicate, 0, SEEK_SET) != 0) + fail("rewind unlinked append fixture"); + write_all(fd, (const unsigned char *)"B", 1, + "positioned write after clearing unlinked append"); + expect_bytes_at(reader, expected_after_clear, + sizeof(expected_after_clear) - 1, "verify clear after unlink"); + + printf("KERNEL_SCRATCH_APPEND_FLAGS_PASS bytes=%zu\n", + sizeof(expected_after_clear) - 1); + if (close(reader) < 0) + fail("close append verification reader"); + if (close(duplicate) < 0) + fail("close append duplicate"); + if (close(fd) < 0) + fail("close append fixture"); + return 0; +} + +static int test_zero_iov(void) +{ + static const char path[] = "/tmp/kernel-scratch-zero-iov"; + struct iovec *invalid_iov = + (struct iovec *)(uintptr_t)(UINTPTR_MAX - 15u); + + if (unlink(path) < 0 && errno != ENOENT) + fail("remove stale zero-iov path"); + int read_write = open(path, O_RDWR | O_CREAT | O_EXCL, 0600); + if (read_write < 0) + fail("open zero-iov fixture"); + int read_only = open(path, O_RDONLY); + if (read_only < 0) + fail("open zero-iov reader"); + int write_only = open(path, O_WRONLY); + if (write_only < 0) + fail("open zero-iov writer"); + int pipe_fds[2]; + if (pipe(pipe_fds) < 0) + fail("open zero-iov pipe"); + + expect_zero_result(readv(read_write, invalid_iov, 0), + "readv zero count"); + expect_zero_result(writev(read_write, invalid_iov, 0), + "writev zero count"); + expect_zero_result(preadv(read_write, invalid_iov, 0, 0), + "preadv zero count"); + expect_zero_result(pwritev(read_write, invalid_iov, 0, 0), + "pwritev zero count"); + + errno = 0; + expect_errno_result(readv(-1, invalid_iov, 0), EBADF, + "readv zero invalid fd"); + errno = 0; + expect_errno_result(writev(-1, invalid_iov, 0), EBADF, + "writev zero invalid fd"); + errno = 0; + expect_errno_result(readv(write_only, invalid_iov, 0), EBADF, + "readv zero access"); + errno = 0; + expect_errno_result(writev(read_only, invalid_iov, 0), EBADF, + "writev zero access"); + + errno = 0; + expect_errno_result(preadv(-1, invalid_iov, 0, -1), EBADF, + "preadv zero fd before offset"); + errno = 0; + expect_errno_result(pwritev(-1, invalid_iov, 0, -1), EBADF, + "pwritev zero fd before offset"); + errno = 0; + expect_errno_result(preadv(write_only, invalid_iov, 0, -1), EINVAL, + "preadv zero offset before access"); + errno = 0; + expect_errno_result(pwritev(read_only, invalid_iov, 0, -1), EINVAL, + "pwritev zero offset before access"); + errno = 0; + expect_errno_result(preadv(write_only, invalid_iov, 0, 0), EBADF, + "preadv zero access"); + errno = 0; + expect_errno_result(pwritev(read_only, invalid_iov, 0, 0), EBADF, + "pwritev zero access"); + errno = 0; + expect_errno_result(preadv(pipe_fds[0], invalid_iov, 0, 0), ESPIPE, + "preadv zero pipe seekability"); + errno = 0; + expect_errno_result(pwritev(pipe_fds[1], invalid_iov, 0, 0), ESPIPE, + "pwritev zero pipe seekability"); + + printf("KERNEL_SCRATCH_ZERO_IOV_PASS pointer_bits=%zu\n", + sizeof(uintptr_t) * CHAR_BIT); + if (close(pipe_fds[1]) < 0 || close(pipe_fds[0]) < 0 || + close(write_only) < 0 || close(read_only) < 0 || + close(read_write) < 0) + fail("close zero-iov fixtures"); + if (unlink(path) < 0) + fail("unlink zero-iov fixture"); + return 0; +} + static int test_readv(size_t iovec_count, size_t bytes_per_iovec) { if (iovec_count > IOV_MAX || @@ -92,6 +330,205 @@ static int test_readv(size_t iovec_count, size_t bytes_per_iovec) return 0; } +static int test_datagram_vector(size_t iovec_count, size_t bytes_per_iovec) +{ + if (iovec_count < 2 || iovec_count > IOV_MAX || + bytes_per_iovec > SIZE_MAX / iovec_count) { + errno = EINVAL; + fail("datagram vector dimensions"); + } + size_t total = iovec_count * bytes_per_iovec; + unsigned char *expected = malloc(total); + unsigned char *actual = calloc(total, 1); + struct iovec *send_iovecs = calloc(iovec_count, sizeof(*send_iovecs)); + struct iovec *recv_iovecs = calloc(iovec_count, sizeof(*recv_iovecs)); + if (expected == NULL || actual == NULL || + send_iovecs == NULL || recv_iovecs == NULL) + fail("datagram vector allocation"); + + for (size_t index = 0; index < total; index++) + expected[index] = (unsigned char)(index * 197u + 29u); + for (size_t index = 0; index < iovec_count; index++) { + send_iovecs[index].iov_base = + expected + index * bytes_per_iovec; + send_iovecs[index].iov_len = bytes_per_iovec; + recv_iovecs[index].iov_base = + actual + index * bytes_per_iovec; + recv_iovecs[index].iov_len = bytes_per_iovec; + } + + int pair[2]; + if (socketpair(AF_UNIX, SOCK_DGRAM | SOCK_NONBLOCK, 0, pair) < 0) + fail("datagram socketpair"); + + /* + * A datagram is one indivisible operation. Splitting either vector into + * channel-sized scalar calls would create or consume multiple messages, + * which the amount check and empty-queue check below both detect. + */ + ssize_t amount = writev(pair[0], send_iovecs, (int)iovec_count); + if (amount < 0) + fail("datagram writev"); + if ((size_t)amount != total) { + errno = EIO; + fail("datagram writev amount"); + } + + amount = readv(pair[1], recv_iovecs, (int)iovec_count); + if (amount < 0) + fail("datagram readv"); + if ((size_t)amount != total || memcmp(actual, expected, total) != 0) { + errno = EIO; + fail("datagram readv result"); + } + + unsigned char extra; + amount = recv(pair[1], &extra, sizeof(extra), MSG_DONTWAIT); + if (amount >= 0 || (errno != EAGAIN && errno != EWOULDBLOCK)) { + errno = EIO; + fail("datagram vector message count"); + } + + printf("KERNEL_SCRATCH_DGRAM_VECTOR_PASS iovecs=%zu bytes=%zu " + "datagrams=1\n", iovec_count, total); + close(pair[1]); + close(pair[0]); + free(recv_iovecs); + free(send_iovecs); + free(actual); + free(expected); + return 0; +} + +static int test_positioned_vector(size_t iovec_count, + size_t bytes_per_iovec) +{ + static const char path[] = "/tmp/kernel-scratch-positioned-vector"; + const size_t fixed_offset = 4096; + const size_t tail_guard = 4096; + const off_t cursor_marker = 37; + + if (iovec_count < 2 || iovec_count > IOV_MAX || + bytes_per_iovec > SIZE_MAX / iovec_count) { + errno = EINVAL; + fail("positioned vector dimensions"); + } + size_t total = iovec_count * bytes_per_iovec; + if (total > SIZE_MAX - fixed_offset - tail_guard || + total > (size_t)INT64_MAX - fixed_offset - tail_guard) { + errno = EOVERFLOW; + fail("positioned vector file size"); + } + size_t file_length = fixed_offset + total + tail_guard; + + unsigned char *expected = malloc(total); + unsigned char *actual = calloc(total, 1); + unsigned char *seed = malloc(file_length); + struct iovec *write_iovecs = calloc(iovec_count, sizeof(*write_iovecs)); + struct iovec *read_iovecs = calloc(iovec_count, sizeof(*read_iovecs)); + if (expected == NULL || actual == NULL || seed == NULL || + write_iovecs == NULL || read_iovecs == NULL) + fail("positioned vector allocation"); + + memset(seed, 0x6b, file_length); + for (size_t index = 0; index < total; index++) + expected[index] = (unsigned char)(index * 149u + 43u); + for (size_t index = 0; index < iovec_count; index++) { + write_iovecs[index].iov_base = + expected + index * bytes_per_iovec; + write_iovecs[index].iov_len = bytes_per_iovec; + read_iovecs[index].iov_base = + actual + index * bytes_per_iovec; + read_iovecs[index].iov_len = bytes_per_iovec; + } + + if (unlink(path) < 0 && errno != ENOENT) + fail("remove stale positioned vector file"); + int fd = open(path, O_RDWR | O_CREAT | O_EXCL, 0600); + if (fd < 0) + fail("open positioned vector file"); + write_all(fd, seed, file_length, "seed positioned vector file"); + /* + * Seed without append semantics: setup must not require a backend's + * atomic-append authority. The operation under test begins only after the + * same open file description has O_APPEND enabled. + */ + if (fcntl(fd, F_SETFL, O_APPEND) < 0) + fail("enable append for positioned vector"); + if (lseek(fd, cursor_marker, SEEK_SET) != cursor_marker) + fail("set positioned vector cursor"); + + /* + * WHY: Linux's native pwrite can incorrectly honor O_APPEND. The Kandelo + * host must still implement the POSIX positioned-write contract: overwrite + * at the requested offset without changing the open-file-description + * cursor. File size and target bytes distinguish an accidental append. + */ + ssize_t amount = pwritev(fd, write_iovecs, (int)iovec_count, + (off_t)fixed_offset); + if (amount < 0) + fail("pwritev"); + if ((size_t)amount != total) { + errno = EIO; + fail("pwritev amount"); + } + if (lseek(fd, 0, SEEK_CUR) != cursor_marker) { + errno = EIO; + fail("pwritev cursor"); + } + + struct stat status; + if (fstat(fd, &status) < 0) + fail("stat positioned vector file"); + if (status.st_size != (off_t)file_length) { + errno = EIO; + fail("pwritev appended"); + } + + amount = preadv(fd, read_iovecs, (int)iovec_count, + (off_t)fixed_offset); + if (amount < 0) + fail("preadv"); + if ((size_t)amount != total || memcmp(actual, expected, total) != 0) { + errno = EIO; + fail("preadv result"); + } + if (lseek(fd, 0, SEEK_CUR) != cursor_marker) { + errno = EIO; + fail("preadv cursor"); + } + + unsigned char guard = 0; + if (pread(fd, &guard, 1, (off_t)fixed_offset - 1) != 1 || + guard != 0x6b) { + errno = EIO; + fail("positioned vector leading guard"); + } + if (pread(fd, &guard, 1, (off_t)(fixed_offset + total)) != 1 || + guard != 0x6b) { + errno = EIO; + fail("positioned vector trailing guard"); + } + if (lseek(fd, 0, SEEK_CUR) != cursor_marker) { + errno = EIO; + fail("positioned vector guard cursor"); + } + + printf("KERNEL_SCRATCH_POSITIONED_VECTOR_PASS iovecs=%zu bytes=%zu " + "offset=%zu cursor=%lld\n", iovec_count, total, fixed_offset, + (long long)cursor_marker); + if (close(fd) < 0) + fail("close positioned vector file"); + if (unlink(path) < 0) + fail("unlink positioned vector file"); + free(read_iovecs); + free(write_iovecs); + free(seed); + free(actual); + free(expected); + return 0; +} + static int test_pty(size_t expected_length, unsigned char expected_byte) { struct termios attributes; @@ -140,11 +577,25 @@ static int test_pty(size_t expected_length, unsigned char expected_byte) int main(int argc, char **argv) { + if (argc == 2 && strcmp(argv[1], "append-flags") == 0) + return test_append_flags(); + if (argc == 2 && strcmp(argv[1], "zero-iov") == 0) + return test_zero_iov(); if (argc == 4 && strcmp(argv[1], "readv") == 0) { return test_readv( parse_size(argv[2], "readv iovec count"), parse_size(argv[3], "readv bytes per iovec")); } + if (argc == 4 && strcmp(argv[1], "dgram-vector") == 0) { + return test_datagram_vector( + parse_size(argv[2], "datagram vector iovec count"), + parse_size(argv[3], "datagram vector bytes per iovec")); + } + if (argc == 4 && strcmp(argv[1], "positioned-vector") == 0) { + return test_positioned_vector( + parse_size(argv[2], "positioned vector iovec count"), + parse_size(argv[3], "positioned vector bytes per iovec")); + } if (argc == 4 && strcmp(argv[1], "pty") == 0) { size_t byte_value = parse_size(argv[3], "PTY byte value"); if (byte_value > UCHAR_MAX) { @@ -158,6 +609,9 @@ int main(int argc, char **argv) fprintf(stderr, "usage: %s readv IOVEC_COUNT BYTES_PER_IOVEC | " + "dgram-vector IOVEC_COUNT BYTES_PER_IOVEC | " + "positioned-vector IOVEC_COUNT BYTES_PER_IOVEC | " + "append-flags | zero-iov | " "pty EXPECTED_LENGTH EXPECTED_BYTE\n", argv[0]); return 2; diff --git a/examples/lseek_invalid_test.c b/examples/lseek_invalid_test.c index aa2ba51aff..81818b0cd7 100644 --- a/examples/lseek_invalid_test.c +++ b/examples/lseek_invalid_test.c @@ -35,8 +35,25 @@ int main(int argc, char **argv) } if (expect_failure(fd, -1, SEEK_SET, EINVAL) != 0 || expect_failure(fd, -3, SEEK_CUR, EINVAL) != 0 || - expect_failure(fd, -7, SEEK_END, EINVAL) != 0 || - expect_failure(fd, (off_t)(1ULL << 53), SEEK_SET, EOVERFLOW) != 0 || + expect_failure(fd, -7, SEEK_END, EINVAL) != 0) { + close(fd); + return 4; + } + + /* + * An exact signed off_t above JavaScript's safe-integer boundary is a + * valid seek, even though a number-only host backend could not represent + * it. The host-file backend carries it as an exact 64-bit value. + */ + const off_t large = (off_t)(1ULL << 53); + errno = 0; + if (lseek(fd, large, SEEK_SET) != large || errno != 0) { + fprintf(stderr, "large exact lseek failed: errno=%d (%s)\n", + errno, strerror(errno)); + close(fd); + return 4; + } + if (lseek(fd, 2, SEEK_SET) != 2 || expect_failure(fd, (off_t)LLONG_MAX, SEEK_CUR, EOVERFLOW) != 0) { close(fd); return 4; diff --git a/examples/putenv_test.c b/examples/putenv_test.c index 2e2d43c2b5..b95bef4c83 100644 --- a/examples/putenv_test.c +++ b/examples/putenv_test.c @@ -10,6 +10,330 @@ #include #include #include +#include +#include +#include +#include + +#ifdef KANDELO_ENV_TRANSACTION_TEST_WRAPPERS +/* + * This fixture is linked with --wrap for allocator and raw syscall fault + * injection. Production libc has no test hook: the wrappers exist only in the + * test program and delegate unless a single failure has been armed. + */ +#if __SIZEOF_POINTER__ == 4 +typedef long long raw_syscall_arg_t; +#else +typedef long raw_syscall_arg_t; +#endif + +void *__real_malloc(size_t); +void *__real_realloc(void *, size_t); +long __real___syscall1(long, raw_syscall_arg_t); +long __real___syscall3( + long, raw_syscall_arg_t, raw_syscall_arg_t, raw_syscall_arg_t); + +static int allocations_before_failure = -1; +static int fail_next_setenv_syscall; +static int fail_next_unsetenv_syscall; +static unsigned long setenv_syscall_count; +static unsigned long unsetenv_syscall_count; + +static int allocation_should_fail(void) +{ + if (allocations_before_failure < 0) return 0; + if (allocations_before_failure-- > 0) return 0; + allocations_before_failure = -1; + errno = ENOMEM; + return 1; +} + +void *__wrap_malloc(size_t size) +{ + if (allocation_should_fail()) return NULL; + return __real_malloc(size); +} + +void *__wrap_realloc(void *pointer, size_t size) +{ + if (allocation_should_fail()) return NULL; + return __real_realloc(pointer, size); +} + +long __wrap___syscall1(long number, raw_syscall_arg_t arg1) +{ + if (number == SYS_unsetenv) { + unsetenv_syscall_count++; + if (fail_next_unsetenv_syscall) { + fail_next_unsetenv_syscall = 0; + return -EIO; + } + } + return __real___syscall1(number, arg1); +} + +long __wrap___syscall3(long number, raw_syscall_arg_t arg1, + raw_syscall_arg_t arg2, raw_syscall_arg_t arg3) +{ + if (number == SYS_setenv) { + setenv_syscall_count++; + if (fail_next_setenv_syscall) { + fail_next_setenv_syscall = 0; + return -EIO; + } + } + return __real___syscall3(number, arg1, arg2, arg3); +} +#endif + +static int kernel_environment_equals( + const char *name, const char *expected, size_t expected_len) +{ + size_t capacity = expected ? expected_len : 1; + unsigned char *buf = malloc(capacity ? capacity : 1); + if (!buf) { + perror("malloc kernel environment buffer"); + return 0; + } + + errno = 0; + long length = syscall(SYS_getenv, name, buf, capacity); + int matches = expected + ? length == (long)expected_len && + memcmp(buf, expected, expected_len) == 0 + : length == -1 && errno == ENOENT; + free(buf); + return matches; +} + +static int environment_equals( + const char *name, const char *expected, size_t expected_len) +{ + const char *local = getenv(name); + if (expected) { + if (!local || strlen(local) != expected_len || + memcmp(local, expected, expected_len) != 0) + return 0; + } else if (local) { + return 0; + } + return kernel_environment_equals(name, expected, expected_len); +} + +static char *filled_string(size_t length, char byte) +{ + char *string = malloc(length + 1); + if (!string) return NULL; + memset(string, byte, length); + string[length] = 0; + return string; +} + +static int test_setenv_transfer_boundary(void) +{ + const char name[] = "SET_BOUNDARY"; + const char rejected_name[] = "SET_REJECT_NEW"; + const size_t limit = KANDELO_PROCESS_METADATA_ENTRY_MAX_BYTES; + const size_t name_len = strlen(name); + const size_t rejected_name_len = strlen(rejected_name); + const size_t exact_value_len = limit - name_len - 1; + const size_t oversized_value_len = exact_value_len + 1; + const size_t rejected_value_len = limit - rejected_name_len; + char *exact = filled_string(exact_value_len, 's'); + char *oversized = filled_string(oversized_value_len, 'x'); + char *rejected = filled_string(rejected_value_len, 'n'); + if (!exact || !oversized || !rejected) { + perror("allocate setenv boundary values"); + free(exact); + free(oversized); + free(rejected); + return 60; + } + + if (setenv(name, exact, 1) != 0 || + !environment_equals(name, exact, exact_value_len)) { + fprintf(stderr, "exact-capacity setenv diverged\n"); + return 61; + } + + errno = 0; + if (setenv(name, oversized, 1) != -1 || errno != E2BIG || + !environment_equals(name, exact, exact_value_len)) { + fprintf(stderr, + "capacity+1 setenv did not preserve the prior value: errno=%d\n", + errno); + return 62; + } + + errno = 0; + if (setenv(rejected_name, rejected, 1) != -1 || errno != E2BIG || + !environment_equals(rejected_name, NULL, 0)) { + fprintf(stderr, + "capacity+1 new setenv did not remain absent: errno=%d\n", + errno); + return 63; + } + + if (unsetenv(name) != 0 || !environment_equals(name, NULL, 0)) { + fprintf(stderr, "unsetenv did not remove the exact-capacity value\n"); + return 64; + } + + free(exact); + free(oversized); + free(rejected); + puts("SETENV_BOUNDARY_PASS"); + return 0; +} + +static int test_putenv_long_name_boundary(void) +{ + const size_t limit = KANDELO_PROCESS_METADATA_ENTRY_MAX_BYTES; + const size_t name_len = 300; + const size_t value_len = limit - name_len - 1; + char *name = filled_string(name_len, 'L'); + char *entry = malloc(limit + 1); + char *oversized = malloc(limit + 2); + if (!name || !entry || !oversized) { + perror("allocate putenv boundary values"); + free(name); + free(entry); + free(oversized); + return 70; + } + + memcpy(entry, name, name_len); + entry[name_len] = '='; + memset(entry + name_len + 1, 'p', value_len); + entry[limit] = 0; + + memcpy(oversized, entry, limit); + oversized[limit] = 'q'; + oversized[limit + 1] = 0; + + if (putenv(entry) != 0 || + !environment_equals(name, entry + name_len + 1, value_len)) { + fprintf(stderr, "exact-capacity long-name putenv diverged\n"); + return 71; + } + + errno = 0; + if (putenv(oversized) != -1 || errno != E2BIG || + !environment_equals(name, entry + name_len + 1, value_len)) { + fprintf(stderr, + "capacity+1 putenv did not preserve the prior value: errno=%d\n", + errno); + return 72; + } + + if (unsetenv(name) != 0 || !environment_equals(name, NULL, 0)) { + fprintf(stderr, "unsetenv did not remove the long-name putenv value\n"); + return 73; + } + + free(name); + free(entry); + free(oversized); + puts("PUTENV_LONG_BOUNDARY_PASS"); + return 0; +} + +#ifdef KANDELO_ENV_TRANSACTION_TEST_WRAPPERS +static int test_transaction_failures(void) +{ + const char name[] = "ENV_TRANSACTION"; + const char local_setenv_name[] = "ENV_LOCAL_SETENV_FAILURE"; + const char local_putenv_name[] = "ENV_LOCAL_PUTENV_FAILURE"; + const char initial[] = "before"; + char putenv_kernel_failure[] = "ENV_TRANSACTION=putenv-after"; + char putenv_local_failure[] = "ENV_LOCAL_PUTENV_FAILURE=new"; + + if (setenv(name, initial, 1) != 0 || + !environment_equals(name, initial, sizeof(initial)-1)) { + fprintf(stderr, "failed to establish transaction baseline\n"); + return 80; + } + + unsigned long calls_before = setenv_syscall_count; + fail_next_setenv_syscall = 1; + errno = 0; + if (setenv(name, "setenv-after", 1) != -1 || errno != EIO || + fail_next_setenv_syscall || + setenv_syscall_count != calls_before + 1 || + !environment_equals(name, initial, sizeof(initial)-1)) { + fprintf(stderr, + "setenv kernel errno changed local or kernel state: errno=%d\n", + errno); + return 81; + } + + calls_before = setenv_syscall_count; + fail_next_setenv_syscall = 1; + errno = 0; + if (putenv(putenv_kernel_failure) != -1 || errno != EIO || + fail_next_setenv_syscall || + setenv_syscall_count != calls_before + 1 || + !environment_equals(name, initial, sizeof(initial)-1)) { + fprintf(stderr, + "putenv kernel errno changed local or kernel state: errno=%d\n", + errno); + return 82; + } + + unsigned long unset_calls_before = unsetenv_syscall_count; + fail_next_unsetenv_syscall = 1; + errno = 0; + if (unsetenv(name) != -1 || errno != EIO || + fail_next_unsetenv_syscall || + unsetenv_syscall_count != unset_calls_before + 1 || + !environment_equals(name, initial, sizeof(initial)-1)) { + fprintf(stderr, + "unsetenv kernel errno changed local or kernel state: errno=%d\n", + errno); + return 83; + } + + /* + * Allow construction of the owned entry/name, then fail allocation of the + * prospective environ array. The kernel syscall count must not advance. + */ + calls_before = setenv_syscall_count; + allocations_before_failure = 1; + errno = 0; + if (setenv(local_setenv_name, "new", 1) != -1 || errno != ENOMEM || + allocations_before_failure != -1 || + setenv_syscall_count != calls_before || + !environment_equals(local_setenv_name, NULL, 0)) { + fprintf(stderr, + "setenv local allocation failure reached or changed kernel state: " + "errno=%d\n", + errno); + return 84; + } + + calls_before = setenv_syscall_count; + allocations_before_failure = 1; + errno = 0; + if (putenv(putenv_local_failure) != -1 || errno != ENOMEM || + allocations_before_failure != -1 || + setenv_syscall_count != calls_before || + !environment_equals(local_putenv_name, NULL, 0)) { + fprintf(stderr, + "putenv local allocation failure reached or changed kernel state: " + "errno=%d\n", + errno); + return 85; + } + + if (unsetenv(name) != 0 || !environment_equals(name, NULL, 0)) { + fprintf(stderr, "failed to clean up transaction baseline\n"); + return 86; + } + + puts("ENV_TRANSACTION_FAILURE_PASS"); + return 0; +} +#endif int main(int argc, char **argv) { @@ -53,6 +377,16 @@ int main(int argc, char **argv) my_var = getenv("MY_VAR"); printf("MY_VAR=%s\n", my_var ? my_var : ""); + int result = test_setenv_transfer_boundary(); + if (result) return result; + result = test_putenv_long_name_boundary(); + if (result) return result; +#ifdef KANDELO_ENV_TRANSACTION_TEST_WRAPPERS + result = test_transaction_failures(); + if (result) return result; +#endif + puts("ENV_COHERENCE_PASS"); + printf("DONE\n"); return 0; } diff --git a/examples/wait_lifecycle_test.c b/examples/wait_lifecycle_test.c index cf9fbdc204..7391f72127 100644 --- a/examples/wait_lifecycle_test.c +++ b/examples/wait_lifecycle_test.c @@ -1,6 +1,7 @@ #define _GNU_SOURCE #include +#include #include #include #include @@ -165,6 +166,155 @@ static int test_cancel_preserves_completed_syscall(void) return 0; } +struct disabled_poll_cancel_ctx { + int read_fd; + atomic_int ready; + atomic_int poll_returned; + atomic_int cancellation_enabled; + atomic_int after_testcancel; + atomic_int cleanup_ran; + int poll_result; + int poll_errno; + short poll_revents; +}; + +static void record_disabled_poll_cancel_cleanup(void *opaque) +{ + struct disabled_poll_cancel_ctx *ctx = opaque; + atomic_store_explicit(&ctx->cleanup_ran, 1, memory_order_release); +} + +static void *disabled_poll_cancel_thread(void *opaque) +{ + struct disabled_poll_cancel_ctx *ctx = opaque; + struct pollfd descriptor = { + .fd = ctx->read_fd, + .events = POLLIN, + .revents = 0, + }; + int previous_state = PTHREAD_CANCEL_ENABLE; + + if (pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &previous_state) != 0) + return (void *)(uintptr_t)1; + + pthread_cleanup_push(record_disabled_poll_cancel_cleanup, ctx); + atomic_store_explicit(&ctx->ready, 1, memory_order_release); + errno = 0; + ctx->poll_result = poll(&descriptor, 1, -1); + ctx->poll_errno = errno; + ctx->poll_revents = descriptor.revents; + atomic_store_explicit(&ctx->poll_returned, 1, memory_order_release); + + if (pthread_setcancelstate(previous_state, NULL) != 0) + return (void *)(uintptr_t)2; + atomic_store_explicit( + &ctx->cancellation_enabled, + 1, + memory_order_release + ); + pthread_testcancel(); + atomic_store_explicit(&ctx->after_testcancel, 1, memory_order_release); + pthread_cleanup_pop(0); + return NULL; +} + +static int test_disabled_blocked_poll_cancellation(void) +{ + int pipe_fds[2]; + if (pipe(pipe_fds) != 0) + return fail("disabled-poll pipe"); + + struct disabled_poll_cancel_ctx ctx = { + .read_fd = pipe_fds[0], + .ready = ATOMIC_VAR_INIT(0), + .poll_returned = ATOMIC_VAR_INIT(0), + .cancellation_enabled = ATOMIC_VAR_INIT(0), + .after_testcancel = ATOMIC_VAR_INIT(0), + .cleanup_ran = ATOMIC_VAR_INIT(0), + .poll_result = -2, + .poll_errno = 0, + .poll_revents = 0, + }; + pthread_t thread; + int error = pthread_create( + &thread, + NULL, + disabled_poll_cancel_thread, + &ctx + ); + if (error != 0) { + errno = error; + close(pipe_fds[0]); + close(pipe_fds[1]); + return fail("disabled-poll pthread_create"); + } + + while (!atomic_load_explicit(&ctx.ready, memory_order_acquire)) + usleep(1000); + /* The target publishes ready immediately before poll. Give the host time + * to install the exact infinite poll registration before cancellation. */ + usleep(20000); + + error = pthread_cancel(thread); + if (error != 0) { + errno = error; + close(pipe_fds[0]); + close(pipe_fds[1]); + return fail("disabled-poll pthread_cancel"); + } + usleep(50000); + int returned_after_cancel = atomic_load_explicit( + &ctx.poll_returned, + memory_order_acquire + ); + + if (write(pipe_fds[1], "x", 1) != 1) { + close(pipe_fds[0]); + close(pipe_fds[1]); + return fail("disabled-poll release"); + } + + void *joined = NULL; + error = pthread_join(thread, &joined); + close(pipe_fds[0]); + close(pipe_fds[1]); + if (error != 0) { + errno = error; + return fail("disabled-poll pthread_join"); + } + + if (returned_after_cancel || joined != PTHREAD_CANCELED || + ctx.poll_result != 1 || ctx.poll_errno != 0 || + (ctx.poll_revents & POLLIN) == 0 || + !atomic_load_explicit(&ctx.poll_returned, memory_order_acquire) || + !atomic_load_explicit( + &ctx.cancellation_enabled, + memory_order_acquire + ) || + atomic_load_explicit(&ctx.after_testcancel, memory_order_acquire) || + !atomic_load_explicit(&ctx.cleanup_ran, memory_order_acquire)) { + fprintf(stderr, + "disabled poll cancellation mismatch: early=%d joined=%p " + "result=%d errno=%d revents=%#x returned=%d enabled=%d " + "after_testcancel=%d cleanup=%d\n", + returned_after_cancel, joined, ctx.poll_result, ctx.poll_errno, + ctx.poll_revents, + atomic_load_explicit(&ctx.poll_returned, memory_order_relaxed), + atomic_load_explicit( + &ctx.cancellation_enabled, + memory_order_relaxed + ), + atomic_load_explicit( + &ctx.after_testcancel, + memory_order_relaxed + ), + atomic_load_explicit(&ctx.cleanup_ran, memory_order_relaxed)); + return -1; + } + return 0; +} + + static int read_all(const char *path, char *buf, size_t size) { FILE *fp = fopen(path, "r"); @@ -1280,6 +1430,8 @@ int main(int argc, char **argv) #endif if (test_cancel_preserves_completed_syscall() != 0) return 12; + if (test_disabled_blocked_poll_cancellation() != 0) + return 13; #if __SIZEOF_POINTER__ == 8 if (test_memory64_wait_layouts() != 0) return 1; diff --git a/host/src/append-contract.ts b/host/src/append-contract.ts new file mode 100644 index 0000000000..60e635aa81 --- /dev/null +++ b/host/src/append-contract.ts @@ -0,0 +1,33 @@ +const intrinsicApply = Reflect.apply; +const intrinsicWeakSetAdd = WeakSet.prototype.add; +const intrinsicWeakSetHas = WeakSet.prototype.has; +const appendContractErrors = new WeakSet(); + +/** + * A backing mutated and then proved that it could not supply the exact append + * outcome it promised. + * + * This is not an errno-bearing I/O failure: continuing the kernel generation + * would publish an unknowable open-file-description cursor. + */ +export class HostAppendContractError extends Error { + constructor(message: string) { + super(message); + this.name = "HostAppendContractError"; + intrinsicApply(intrinsicWeakSetAdd, appendContractErrors, [this]); + } +} + +/** + * Check the private module brand without invoking constructors, prototypes, or + * `Symbol.hasInstance` hooks that a host callback could have replaced. + */ +export function isHostAppendContractError( + value: unknown, +): value is HostAppendContractError { + return intrinsicApply( + intrinsicWeakSetHas, + appendContractErrors, + [value as object], + ); +} diff --git a/host/src/browser-kernel-host.ts b/host/src/browser-kernel-host.ts index 78cad58e32..5d17cd2364 100644 --- a/host/src/browser-kernel-host.ts +++ b/host/src/browser-kernel-host.ts @@ -202,7 +202,11 @@ export class BrowserKernel { Pick > & BrowserKernelOptions; - private exitResolvers = new Map void>(); + private exitResolvers = new Map void; + reject: (error: Error) => void; + }>(); + private kernelFatalError: Error | null = null; private unclaimedExitStatuses = new Map(); private exitSequence = 0; private pendingRequests = new Map void; reject: (err: Error) => void }>(); @@ -376,6 +380,7 @@ export class BrowserKernel { "owned VFS image must be one whole ordinary ArrayBuffer", ); } + this.kernelFatalError = null; const closedLazyAssets = opts.closedLazyAssets === undefined ? undefined : snapshotClosedLazyAssets(opts.closedLazyAssets); @@ -388,10 +393,8 @@ export class BrowserKernel { }; this.kernelWorkerHandle.onerror = (e: ErrorEvent) => { const err = new Error(`Kernel worker error: ${e.message}`); - for (const [, { reject }] of this.pendingRequests) { - reject(err); - } - this.pendingRequests.clear(); + this.failKernelHost(err); + this.kernelWorkerHandle.terminate(); this.options.onHttpBridgePendingRequests?.(0); const diagnostic: HostDiagnostic = { pid: 0, @@ -434,6 +437,8 @@ export class BrowserKernel { settleResolve(); } else if (e.data?.type === "init_error") { settleReject(new Error(`Kernel worker init failed: ${e.data.error}`)); + } else if (e.data?.type === "kernel_fatal") { + settleReject(new Error(`Kernel worker failed: ${e.data.error}`)); } }; const errorHandler = (e: ErrorEvent) => { @@ -1075,7 +1080,7 @@ export class BrowserKernel { // Resolve exit promise const resolver = this.exitResolvers.get(pid); this.exitResolvers.delete(pid); - if (resolver) resolver(status); + if (resolver) resolver.resolve(status); } /** @@ -1170,7 +1175,7 @@ export class BrowserKernel { async destroy(): Promise { if (!this.workerStarted) return; let gracefulDetachFailure: string | undefined; - if (this.initialized) { + if (this.initialized && this.kernelFatalError === null) { const requestId = this.nextRequestId++; gracefulDetachFailure = await awaitGracefulKernelRealmDestroy( () => @@ -1274,6 +1279,7 @@ export class BrowserKernel { } private sendToKernel(msg: MainToKernelMessage, transfer?: Transferable[]): void { + if (this.kernelFatalError !== null) throw this.kernelFatalError; this.kernelWorkerHandle.postMessage(msg, transfer ?? []); } @@ -1283,18 +1289,35 @@ export class BrowserKernel { if (unclaimed !== undefined && unclaimed.sequence > spawnStartedBeforeExitSequence) { return Promise.resolve(unclaimed.status); } - return new Promise((resolve) => { - this.exitResolvers.set(pid, resolve); + return new Promise((resolve, reject) => { + if (this.kernelFatalError !== null) { + reject(this.kernelFatalError); + return; + } + this.exitResolvers.set(pid, { resolve, reject }); }); } private request(requestId: number, msg: MainToKernelMessage, transfer?: Transferable[]): Promise { return new Promise((resolve, reject) => { + if (this.kernelFatalError !== null) { + reject(this.kernelFatalError); + return; + } this.pendingRequests.set(requestId, { resolve, reject }); this.sendToKernel(msg, transfer); }); } + private failKernelHost(error: Error): void { + if (this.kernelFatalError !== null) return; + this.kernelFatalError = error; + for (const { reject } of this.pendingRequests.values()) reject(error); + this.pendingRequests.clear(); + for (const { reject } of this.exitResolvers.values()) reject(error); + this.exitResolvers.clear(); + } + private emitLazyDownload(event: LazyDownloadEvent): void { try { this.options.onLazyDownload?.(event); } catch { /* host callbacks should not break delivery */ } for (const cb of this.lazyDownloadListeners) { @@ -1310,6 +1333,13 @@ export class BrowserKernel { // permanent listener also receives these messages, so account for // them explicitly rather than relying on an implicit fall-through. break; + case "kernel_fatal": { + const error = new Error(`Kernel worker failed: ${msg.error}`); + this.failKernelHost(error); + this.options.onHttpBridgePendingRequests?.(0); + this.kernelWorkerHandle.terminate(); + break; + } case "response": { const pending = this.pendingRequests.get(msg.requestId); if (pending) { @@ -1330,7 +1360,7 @@ export class BrowserKernel { const resolver = this.exitResolvers.get(msg.pid); if (resolver) { this.exitResolvers.delete(msg.pid); - resolver(msg.status); + resolver.resolve(msg.status); } else { this.unclaimedExitStatuses.set(msg.pid, { status: msg.status, diff --git a/host/src/browser-kernel-protocol.ts b/host/src/browser-kernel-protocol.ts index 61f68f499d..2b95efbdc5 100644 --- a/host/src/browser-kernel-protocol.ts +++ b/host/src/browser-kernel-protocol.ts @@ -425,6 +425,12 @@ export interface InitErrorMessage { error: string; } +/** The dedicated kernel instance is poisoned and has stopped permanently. */ +export interface KernelFatalMessage { + type: "kernel_fatal"; + error: string; +} + export interface ResponseMessage { type: "response"; requestId: number; @@ -577,6 +583,7 @@ export interface LazyDownloadMessage { export type KernelToMainMessage = | ReadyMessage | InitErrorMessage + | KernelFatalMessage | ResponseMessage | ExitMessage | StdoutMessage diff --git a/host/src/browser-kernel-worker-entry.ts b/host/src/browser-kernel-worker-entry.ts index 3f0e991bc8..06fce7d13c 100644 --- a/host/src/browser-kernel-worker-entry.ts +++ b/host/src/browser-kernel-worker-entry.ts @@ -64,7 +64,6 @@ import { waitForWorkerQuiescence as waitForWorkerQuiescenceFence, } from "./worker-quiescence"; import { RootfsSnapshotGate } from "./rootfs-snapshot-gate"; -import { reapHostOwnedExitedProcess } from "./host-owned-process-reap"; import { ForkReplayGateCoordinator, observeForkReplayWorker, @@ -127,6 +126,7 @@ type LazyRegistrationMessage = Extract< let initReady = false; let initFailure: string | null = null; +let kernelFatalReported = false; const pendingLazyRegistrationMessages: LazyRegistrationMessage[] = []; let lazyRegistrationTail: Promise = Promise.resolve(); const rootfsSnapshotGate = new RootfsSnapshotGate(); @@ -529,10 +529,6 @@ function reportRetainedProcessGeneration( } } -// Kernel wasm exports cache -let kernelInstance: WebAssembly.Instance | null = null; -let kernelMemory: WebAssembly.Memory | null = null; - // HTTP bridge port (transferred from main thread → service worker comms) let bridgePort: MessagePort | null = null; let bridgeTargetPort: number | null = null; // The specific HTTP port to route bridge requests to @@ -617,6 +613,45 @@ function reportHostDiagnostic( post({ type: "host_diagnostic", ...diagnostic }); } +function terminatePoisonedKernelWorker(error: Error): void { + if (kernelFatalReported) return; + kernelFatalReported = true; + const detail = formatError(error); + try { + try { + reportHostDiagnostic({ + pid: 0, + source: "kernel fatal", + message: `[kernel-worker] fatal kernel instance failure: ${detail}`, + }); + } catch (reportError) { + console.error("[kernel-worker] could not report fatal diagnostic:", reportError); + } + try { + post({ type: "kernel_fatal", error: detail }); + } catch (postError) { + console.error("[kernel-worker] could not post fatal state:", postError); + } + } finally { + // The kernel can no longer coordinate process teardown. Stop every nested + // Worker directly, then close this dedicated kernel Worker. The in-class + // fatal latch makes queued waitAsync/timer callbacks inert during this turn. + for (const info of processes.values()) { + intentionallyTerminated.add(info.worker as object); + void info.worker.terminate(); + } + for (const threads of threadWorkers.values()) { + for (const thread of threads) { + intentionallyTerminated.add(thread.worker as object); + void thread.worker.terminate(); + } + } + queueMicrotask(() => { + (globalThis as unknown as { close(): void }).close(); + }); + } +} + function reportBridgePendingRequests(): void { post({ type: "http_bridge_pending", count: activeBridgeRequests.size }); } @@ -1010,6 +1045,7 @@ async function handleInit(msg: Extract) { onProcessMemoryTarget: (memory, target) => { processMemoryAllocator.observeTarget(memory, target); }, + onKernelFatal: terminatePoisonedKernelWorker, onFork: ({ parentPid, childPid, parentMemory, continuation }) => { return processMemoryCreators.run("a fork process Worker", () => { // Tell the main thread a kernel-side fork happened so Inspector @@ -1064,29 +1100,17 @@ async function handleInit(msg: Extract) { // MessageChannel-backed setImmediate queue so syscall handling and worker // messages both keep progressing under multi-process bridge load. // Notification remains event-driven through Atomics.waitAsync. - (kernelWorker as any).relistenBatchSize = 1; - - // Inject stdout/stderr/listen callbacks - const kw = kernelWorker as any; - const existingCallbacks = kw.kernel.callbacks || {}; - kw.kernel.callbacks = { - ...existingCallbacks, - onStdout: (data: Uint8Array) => post({ type: "stdout", pid: kw.currentHandlePid || 0, data }), - onStderr: (data: Uint8Array) => post({ type: "stderr", pid: kw.currentHandlePid || 0, data }), - onNetListen: (_fd: number, port: number, addr: [number, number, number, number]) => { - const pid = kw.currentHandlePid; - if (pid !== 0) { - // Register the listener target for pickListenerTarget - kw.startTcpListener(pid, _fd, port, addr); - } - post({ type: "listen_tcp", pid, fd: _fd, port }); - return 0; - }, - }; + kernelWorker.relistenBatchSize = 1; + + kernelWorker.setProcessOutputCallbacks({ + onStdout: (pid, data) => post({ type: "stdout", pid, data }), + onStderr: (pid, data) => post({ type: "stderr", pid, data }), + }); + kernelWorker.setNetworkListenObserver((pid, fd, port) => { + post({ type: "listen_tcp", pid, fd, port }); + }); await kernelWorker.init(msg.kernelWasmBytes); - kernelInstance = kw.kernelInstance; - kernelMemory = kw.kernelMemory; // /dev/fb0 forwarding: the registry lives in this worker, but the canvas // lives on the main thread. WHY: today's zero-copy fbdev contract therefore @@ -2707,7 +2731,7 @@ async function finishProcessExit( if (!detachResult.mayReapPid) return; try { - reapHostOwnedExitedProcess(kernelInstance, pid); + kernelWorker.reapHostOwnedExitedProcess(pid); } catch (error) { reportHostDiagnostic({ pid, @@ -2935,83 +2959,94 @@ async function handleTerminateProcess(msg: Extract) { - if (!kernelInstance) { respond(msg.requestId, null); return; } - respond( - msg.requestId, - kernelWorker.readPipeAvailable(msg.pid, msg.pipeIdx), - ); + try { + respond( + msg.requestId, + kernelWorker.readPipeAvailable(msg.pid, msg.pipeIdx), + ); + } catch (error) { + respondError(msg.requestId, formatError(error)); + } } function handlePipeWrite(msg: Extract) { - if (!kernelInstance) { respond(msg.requestId, -1); return; } - const written = kernelWorker.writePipeData(msg.pid, msg.pipeIdx, msg.data); - // Wake readers + pollers watching this pipe + broad wake. - kernelWorker.notifyPipeReadable(msg.pipeIdx); - respond(msg.requestId, written); + try { + const written = kernelWorker.writePipeData( + msg.pid, + msg.pipeIdx, + msg.data, + ); + // Wake readers and pollers only after the gated write has completed. + kernelWorker.notifyPipeReadable(msg.pipeIdx); + respond(msg.requestId, written); + } catch (error) { + respondError(msg.requestId, formatError(error)); + } } -function handlePipeCloseRead(msg: Extract) { - if (!initReady) return; - kernelWorker.closeHostPipeRead(msg.pid, msg.pipeIdx); +function handlePipeCloseRead( + msg: Extract, +) { + kernelWorker.closePipeRead(msg.pid, msg.pipeIdx); } -function handlePipeCloseWrite(msg: Extract) { - if (!initReady) return; - kernelWorker.closeHostPipeWrite(msg.pid, msg.pipeIdx); +function handlePipeCloseWrite( + msg: Extract, +) { + kernelWorker.closePipeWrite(msg.pid, msg.pipeIdx); } function handlePipeIsWriteOpen(msg: Extract) { - if (!initReady) { - respond(msg.requestId, uninitializedKernelPipeResult("is-write-open")); - return; + try { + respond( + msg.requestId, + kernelWorker.isPipeWriteOpen(msg.pid, msg.pipeIdx), + ); + } catch (error) { + respondError(msg.requestId, formatError(error)); } - respond( - msg.requestId, - kernelWorker.isHostPipeWriteOpen(msg.pid, msg.pipeIdx), - ); } -function handleInjectConnection(msg: Extract) { - if (!initReady) { - respond(msg.requestId, uninitializedKernelPipeResult("inject")); - return; - } - respond( - msg.requestId, - kernelWorker.injectHostConnection( +function handleInjectConnection( + msg: Extract, +) { + try { + const pipeIdx = kernelWorker.injectConnection( msg.pid, msg.fd, msg.peerAddr, msg.peerPort, - ), - ); + ); + respond(msg.requestId, pipeIdx); + } catch (error) { + respondError(msg.requestId, formatError(error)); + } } -function handleWakeBlockedReaders(msg: Extract) { - if (!initReady) return; - kernelWorker.wakeHostPipeReaders(msg.pipeIdx); +function handleWakeBlockedReaders( + msg: Extract, +) { + kernelWorker.wakeBlockedReaders(msg.pipeIdx); } -function handleWakeBlockedWriters(msg: Extract) { - if (!initReady) return; - kernelWorker.wakeHostPipeWriters(msg.pipeIdx); +function handleWakeBlockedWriters( + msg: Extract, +) { + kernelWorker.wakeBlockedWriters(msg.pipeIdx); } function handleIsStdinConsumed(msg: Extract) { - if (!initReady) { - respond(msg.requestId, false); - return; - } - const kw = kernelWorker as any; - respond(msg.requestId, kw.stdinFinite.has(msg.pid) && !kw.stdinBuffers.has(msg.pid)); + respond(msg.requestId, kernelWorker.isStdinConsumed(msg.pid)); } -function handlePickListenerTarget(msg: Extract) { - if (!initReady) { - respond(msg.requestId, uninitializedKernelPipeResult("pick-listener")); - return; +function handlePickListenerTarget( + msg: Extract, +) { + try { + respond(msg.requestId, kernelWorker.pickListenerTarget(msg.port)); + } catch (error) { + respondError(msg.requestId, formatError(error)); } - respond(msg.requestId, kernelWorker.pickListenerTarget(msg.port)); } async function performDestroy() { @@ -3027,7 +3062,7 @@ async function performDestroy() { // returns to its JS event loop (via {exit}), and it becomes reclaimable. A // no-op cost on V8 (Chrome), so it runs unconditionally. let woken = new Set(); - try { woken = kernelWorker.killAllBlockedForTeardown(); } catch (e) { + try { woken = await kernelWorker.killAllBlockedForTeardown(); } catch (e) { console.error(`[kernel-worker] killAllBlockedForTeardown failed: ${e}`); } @@ -3210,7 +3245,7 @@ function handleRegisterPtyOutput(msg: Extract + normalizeChannelScalar( + raw, + contract?.[index as ChannelArgumentIndex] + ?? CHANNEL_SCALAR_DEFAULT_SLOT_KIND, + ) + ); +} + +export function channelDiagnosticArguments( + controlArgs: readonly number[], + normalizedArgs: readonly ChannelScalarValue[], +): ChannelScalarTuple { + if (controlArgs.length !== 6 || normalizedArgs.length !== 6) { + throw new RangeError("channel diagnostics require exactly six arguments"); + } + return controlArgs.map((value, index) => + typeof normalizedArgs[index] === "bigint" + ? normalizedArgs[index] + : value + ) as ChannelScalarTuple; +} + +export function channelResultKind(syscallNr: number): ChannelResultKind { + return CHANNEL_RESULT_CONTRACTS[syscallNr] + ?? CHANNEL_RESULT_DEFAULT_KIND; +} + +export function checkedChannelScalarNumber( + value: ChannelScalarValue, + field: string, +): number { + if (typeof value === "number") { + if (!Number.isSafeInteger(value)) { + throw new RangeError(`${field} is not a safe integer`); + } + return value; + } + const narrowed = Number(value); + if (!Number.isSafeInteger(narrowed) || BigInt(narrowed) !== value) { + throw new RangeError(`${field} cannot be represented exactly`); + } + return narrowed; +} diff --git a/host/src/file-offset.ts b/host/src/file-offset.ts new file mode 100644 index 0000000000..3b90119d9e --- /dev/null +++ b/host/src/file-offset.ts @@ -0,0 +1,179 @@ +import type { HostFileOffset } from "./types"; + +// WHY: PlatformIO implementations are host callbacks. Capture the numeric +// operations used to validate their results before any callback can replace a +// writable global and make an inexact offset appear safe. +const intrinsicBigInt = BigInt; +const intrinsicNumber = Number; +const intrinsicNumberIsSafeInteger = Number.isSafeInteger; +const MIN_I64 = -(1n << 63n); +const MAX_I64 = (1n << 63n) - 1n; +const MIN_SAFE_INTEGER = intrinsicBigInt(Number.MIN_SAFE_INTEGER); +const MAX_SAFE_INTEGER = intrinsicBigInt(Number.MAX_SAFE_INTEGER); + +function offsetError( + code: "EINVAL" | "EOVERFLOW", + message: string, +): Error & { code: string } { + const error = new Error(`${code}: ${message}`) as Error & { code: string }; + error.code = code; + return error; +} + +function exactI64(value: HostFileOffset): bigint { + if (typeof value === "number") { + if (!intrinsicNumberIsSafeInteger(value)) { + throw offsetError( + "EOVERFLOW", + "file offset is not exactly representable", + ); + } + return intrinsicBigInt(value); + } + if (value < MIN_I64 || value > MAX_I64) { + throw offsetError("EOVERFLOW", "file offset is outside signed i64"); + } + return value; +} + +/** Validate a host-visible signed i64 file offset without narrowing it. */ +export function checkedHostFileOffset( + value: HostFileOffset, +): HostFileOffset { + exactI64(value); + return value; +} + +/** Validate a positioned-I/O offset, which must name a non-negative byte. */ +export function checkedHostFilePosition( + value: HostFileOffset, +): HostFileOffset { + const exact = exactI64(value); + if (exact < 0n) { + throw offsetError("EINVAL", "negative positioned I/O offset"); + } + return value; +} + +/** + * Node's synchronous read API supports bigint positions but rejects the + * signed-i64 maximum even though the native pread operation can address it. + */ +export function hostFilePositionForNodeRead( + value: HostFileOffset, + length: number, +): HostFileOffset { + const checked = checkedHostFilePosition(value); + if ( + length > 0 + && typeof checked === "bigint" + && checked === MAX_I64 + ) { + throw offsetError( + "EOVERFLOW", + "Node read API cannot represent the file offset", + ); + } + return checked; +} + +/** + * Narrow an offset only for a backend API whose position contract is a + * JavaScript safe integer. + */ +export function hostFileOffsetToSafeNumber( + value: HostFileOffset, +): number { + const exact = exactI64(value); + if (exact < MIN_SAFE_INTEGER || exact > MAX_SAFE_INTEGER) { + throw offsetError( + "EOVERFLOW", + "backend cannot represent the file offset exactly", + ); + } + return intrinsicNumber(exact); +} + +/** Narrow a non-negative positioned-I/O offset for a number-only backend. */ +export function hostFilePositionToSafeNumber( + value: HostFileOffset, +): number { + const checked = checkedHostFilePosition(value); + return hostFileOffsetToSafeNumber(checked); +} + +/** + * Adapt an optional file-size ceiling for a backend whose complete position + * domain is the JavaScript safe-integer range. + * + * A larger positive ceiling is indistinguishable from no ceiling to such a + * backend. Negative and otherwise invalid values remain errors. + */ +export function hostFileLimitForNumberBackend( + value: HostFileOffset | null, +): number | null { + if (value === null) return null; + const exact = exactI64(value); + if (exact < 0n) { + throw offsetError("EINVAL", "negative file-size limit"); + } + return exact > MAX_SAFE_INTEGER ? null : intrinsicNumber(exact); +} + +/** + * Add a seek delta without losing precision. Existing number-only callers + * keep their prior overflow behavior; a bigint operand opts into exact i64 + * arithmetic and a bigint result. + */ +export function checkedSeekPosition( + base: HostFileOffset, + offset: HostFileOffset, +): HostFileOffset { + const exactBase = exactI64(base); + const exactOffset = exactI64(offset); + const position = exactBase + exactOffset; + if (position < 0n) { + throw offsetError("EINVAL", "negative seek offset"); + } + if (position > MAX_I64) { + throw offsetError("EOVERFLOW", "seek result is outside signed i64"); + } + if (typeof base === "number" && typeof offset === "number") { + if (position > MAX_SAFE_INTEGER) { + throw offsetError( + "EOVERFLOW", + "seek result is not exactly representable", + ); + } + return intrinsicNumber(position); + } + return position; +} + +/** Advance a stored position, widening to bigint when a read crosses 2^53. */ +export function advanceHostFilePosition( + position: HostFileOffset, + bytes: number, +): HostFileOffset { + const exactPosition = exactI64(position); + if (!intrinsicNumberIsSafeInteger(bytes) || bytes < 0) { + throw offsetError("EOVERFLOW", "I/O byte count is not exactly representable"); + } + const result = exactPosition + intrinsicBigInt(bytes); + if (result > MAX_I64) { + throw offsetError("EOVERFLOW", "file position is outside signed i64"); + } + if (typeof position === "number" && result <= MAX_SAFE_INTEGER) { + return intrinsicNumber(result); + } + return result; +} + +/** Keep ordinary native file sizes numeric while preserving large exact sizes. */ +export function hostFileOffsetFromBigInt(value: bigint): HostFileOffset { + exactI64(value); + if (value >= MIN_SAFE_INTEGER && value <= MAX_SAFE_INTEGER) { + return intrinsicNumber(value); + } + return value; +} diff --git a/host/src/generated/abi.ts b/host/src/generated/abi.ts index 8a145f66e4..3dbb29646e 100644 --- a/host/src/generated/abi.ts +++ b/host/src/generated/abi.ts @@ -347,6 +347,12 @@ export const KERNEL_SCRATCH_SIGNAL_DELIVERY_BYTES = 56 as const; export const KERNEL_SCRATCH_FD_PAIR_BYTES = 8 as const; export const KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES = 8 as const; export const KERNEL_SCRATCH_SOCKLEN_BYTES = 4 as const; +export const KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES = 128 as const; +export const KERNEL_SCRATCH_SOCKADDR_UNIX_BYTES = 110 as const; +export const KERNEL_SCRATCH_SOCKADDR_UNIX_PATH_OFFSET_BYTES = 2 as const; +export const KERNEL_SCRATCH_SOCKADDR_UNIX_PATH_BYTES = 108 as const; +export const KERNEL_SCRATCH_SOCKET_OPTION_MAX_BYTES = 232 as const; +export const KERNEL_SCRATCH_SOCKET_OPTION_INPUT_MAX_BYTES = 264 as const; export const PR_SET_NAME = 15 as const; export const PR_GET_NAME = 16 as const; export const PRCTL_NAME_BYTES = 16 as const; @@ -355,6 +361,12 @@ export const SIGNAL_MASK_BYTES = 8 as const; export const POSIX_ARG_MAX_BYTES = 4194304 as const; export const POSIX_PATH_MAX_BYTES = 4096 as const; +export const POSIX_NAME_MAX_BYTES = 256 as const; +export const PROCESS_METADATA_ENTRY_MAX_BYTES = 65536 as const; +export const POSIX_NGROUPS_MAX = 32 as const; +export const SYSV_MSG_MAX_BYTES = 8192 as const; +export const MAX_REPORTABLE_TRANSFER_BYTES = 2147483647 as const; +export const MAX_TRANSFER_ALLOCATION_BYTES = 4294967295 as const; export const POSIX_IOV_MAX = 1024 as const; export const SELECT_FD_SETSIZE = 1024 as const; export const SELECT_FD_SET_BYTES = 128 as const; @@ -462,7 +474,10 @@ export const HOST_ADAPTER_WORKER_FEATURES = { export const HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS = [ "__abi_version", "kernel_alloc_scratch", + "kernel_blocking_retry_release", + "kernel_blocking_retry_token", "kernel_clear_process_metadata", + "kernel_commit_process_exit", "kernel_create_process", "kernel_create_process_with_stdio", "kernel_dequeue_signal", @@ -472,6 +487,7 @@ export const HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS = [ "kernel_get_parent_pid", "kernel_get_process_exit_signal", "kernel_get_process_state", + "kernel_get_socket_timeout_ms", "kernel_handle_channel", "kernel_has_sa_nocldstop", "kernel_host_adapter_manifest_len", @@ -480,11 +496,13 @@ export const HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS = [ "kernel_ipc_shmat_for_task", "kernel_ipc_shmdt_for_process", "kernel_ipc_shmdt_for_task", + "kernel_is_fd_nonblock", "kernel_mark_process_signaled", + "kernel_mq_descriptor_msgsize", "kernel_msqid_ds_bytes", + "kernel_pick_signal_target_tid", "kernel_pipe_has_readers", "kernel_posix_timer_fire", - "kernel_prepare_write_operation", "kernel_push_process_metadata_entry", "kernel_reap_exited_child", "kernel_remove_process", @@ -501,6 +519,13 @@ export const HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS = [ "kernel_spawn_scratch_pointer", "kernel_spawn_scratch_retained_capacity", "kernel_thread_exit", + "kernel_thread_has_deliverable", + "kernel_transfer_channel_execute", + "kernel_transfer_io_execute", + "kernel_transfer_scratch_begin", + "kernel_transfer_scratch_cancel", + "kernel_transfer_scratch_capacity", + "kernel_transfer_scratch_pointer", "kernel_validate_task", "kernel_wait_child_poll", ] as const; @@ -546,7 +571,10 @@ export const CH_ARG_SIZE = 8 as const; export const CH_RETURN = 56 as const; export const CH_ERRNO = 64 as const; export const CH_REQUEST_FLAGS = 68 as const; -export const CH_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY = 1 as const; +export const CH_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY = 4 as const; +export const CHANNEL_REQUEST_FLAG_CANCELLATION_POINT = 1 as const; +export const CHANNEL_REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED = 2 as const; +export const CHANNEL_REQUEST_FLAGS_KNOWN_MASK = 7 as const; export const CH_DATA = 72 as const; export const CH_DATA_SIZE = 65536 as const; export const CH_HEADER_SIZE = 72 as const; @@ -586,6 +614,8 @@ export const CH_SIGINFO_WORD_2 = 65588 as const; export const CH_SIG_ALT_SP = 65592 as const; export const CH_SIG_ALT_SIZE = 65600 as const; +export const SIGNAL_ACTION_RESTART = 268435456 as const; + export const WAIT_EVENT_EXITED = 1 as const; export const WAIT_EVENT_STOPPED = 2 as const; export const WAIT_EVENT_CONTINUED = 4 as const; @@ -817,6 +847,7 @@ export const ABI_SYSCALLS = { SchedSetparam: 231, SchedSetscheduler: 233, SchedRrGetInterval: 236, + SchedSetaffinity: 237, SchedGetaffinity: 238, EpollCreate1: 239, EpollCtl: 240, @@ -836,9 +867,13 @@ export const ABI_SYSCALLS = { Mknod: 271, Mknodat: 272, Msync: 278, + Mlock: 279, + Mlock2: 280, + Munlock: 281, Waitid: 288, CopyFileRange: 290, Splice: 291, + Readahead: 293, Sendfile: 294, Preadv: 295, Pwritev: 296, @@ -880,6 +915,95 @@ export const ABI_SYSCALLS = { ThreadCancel: 415, } as const; +export type ChannelScalarSlotKind = + | "i32" + | "u32" + | "exact-u32" + | "process-size" + | "process-address" + | "i64" + | "split-i64-low-u32" + | "split-i64-high-i32"; +export type ChannelResultKind = "i32" | "i64" | "process-address"; +export type ChannelArgumentIndex = 0 | 1 | 2 | 3 | 4 | 5; + +export const CHANNEL_SCALAR_DEFAULT_SLOT_KIND = "i32" as const; +export const CHANNEL_RESULT_DEFAULT_KIND = "i32" as const; +export const CHANNEL_SCALAR_SLOT_CONTRACTS: Readonly< + Record>>> +> = { + 3: { 2: "process-size", }, + 4: { 2: "process-size", }, + 5: { 1: "split-i64-low-u32", 2: "split-i64-high-i32", }, + 19: { 2: "process-size", }, + 23: { 1: "process-size", }, + 26: { 3: "process-size", }, + 43: { 2: "process-size", }, + 46: { 0: "process-address", 1: "process-size", 5: "i64", }, + 47: { 0: "process-address", 1: "process-size", }, + 48: { 0: "process-address", }, + 49: { 0: "process-address", 1: "process-size", }, + 51: { 2: "u32", }, + 54: { 2: "u32", }, + 55: { 2: "process-size", }, + 56: { 2: "process-size", }, + 59: { 4: "u32", }, + 60: { 1: "process-size", }, + 62: { 2: "process-size", 5: "u32", }, + 63: { 2: "process-size", }, + 64: { 2: "process-size", 3: "i64", }, + 65: { 2: "process-size", 3: "i64", }, + 73: { 1: "exact-u32", }, + 79: { 1: "i64", }, + 85: { 1: "i64", }, + 102: { 3: "process-size", }, + 109: { 2: "process-size", }, + 119: { 1: "split-i64-high-i32", 2: "split-i64-low-u32", }, + 120: { 1: "process-size", }, + 122: { 2: "process-size", }, + 126: { 0: "process-address", 1: "process-size", 2: "process-size", }, + 128: { 0: "process-address", 1: "process-size", }, + 136: { 0: "process-size", }, + 200: { 0: "process-address", }, + 203: { 0: "process-address", }, + 237: { 1: "u32", }, + 238: { 1: "u32", }, + 241: { 5: "process-size", }, + 246: { 2: "process-size", }, + 251: { 1: "process-size", 4: "process-size", }, + 261: { 0: "process-address", 1: "process-size", }, + 262: { 1: "process-address", 2: "process-address", }, + 278: { 0: "process-address", 1: "process-size", }, + 279: { 0: "process-address", 1: "process-size", }, + 280: { 0: "process-address", 1: "process-size", }, + 281: { 0: "process-address", 1: "process-size", }, + 290: { 4: "process-size", }, + 291: { 4: "process-size", }, + 293: { 1: "i64", 2: "process-size", }, + 294: { 3: "process-size", }, + 295: { 3: "split-i64-low-u32", 4: "split-i64-high-i32", }, + 296: { 3: "split-i64-low-u32", 4: "split-i64-high-i32", }, + 297: { 3: "split-i64-low-u32", 4: "split-i64-high-i32", }, + 298: { 3: "split-i64-low-u32", 4: "split-i64-high-i32", }, + 308: { 2: "i64", 3: "i64", }, + 333: { 2: "process-size", }, + 334: { 2: "process-size", }, + 338: { 2: "process-size", 3: "i64", }, + 339: { 2: "process-size", }, + 342: { 2: "process-size", }, + 344: { 1: "process-size", }, + 377: { 2: "process-size", }, +} as const; +export const CHANNEL_RESULT_CONTRACTS: Readonly< + Partial> +> = { + 5: "i64", + 46: "process-address", + 48: "process-address", + 66: "i64", + 126: "process-address", +} as const; + export const PATHCONF_NAMES = { LINK_MAX: 0, MAX_CANON: 1, @@ -1073,6 +1197,7 @@ export const ABI_SYSCALL_NAMES: Record = { 231: "sched_setparam", 233: "sched_setscheduler", 236: "sched_rr_get_interval", + 237: "sched_setaffinity", 238: "sched_getaffinity", 239: "epoll_create1", 240: "epoll_ctl", @@ -1092,9 +1217,13 @@ export const ABI_SYSCALL_NAMES: Record = { 271: "mknod", 272: "mknodat", 278: "msync", + 279: "mlock", + 280: "mlock2", + 281: "munlock", 288: "waitid", 290: "copy_file_range", 291: "splice", + 293: "readahead", 294: "sendfile", 295: "preadv", 296: "pwritev", @@ -1141,7 +1270,7 @@ export const ABI_SYSCALL_NAMES: Record = { export type SyscallArgDirection = "in" | "out" | "inout"; export type SyscallArgSizeSpec = - | { type: "cstring" } + | { type: "cstring"; maxBytes: number; tooLongErrno: number } | { type: "arg"; argIndex: number; multiplier?: number; add?: number } | { type: "deref"; argIndex: number } | { type: "fixed"; size: number } @@ -1239,7 +1368,7 @@ export const IOCTL_REQUESTS: Record = { export const SYSCALL_ARGS: Record = { 1: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 3: [ { argIndex: 1, direction: "out", size: { type: "arg", argIndex: 2 }, required: true }, @@ -1254,55 +1383,55 @@ export const SYSCALL_ARGS: Record = { { argIndex: 0, direction: "out", size: { type: "fixed", size: 8 }, required: true }, ], 11: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, { argIndex: 1, direction: "out", size: { type: "fixed", size: 112 }, required: true }, ], 12: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, { argIndex: 1, direction: "out", size: { type: "fixed", size: 112 }, required: true }, ], 13: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 14: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 15: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 16: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, - { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, + { argIndex: 1, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 17: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, - { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, + { argIndex: 1, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 18: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, - { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, + { argIndex: 1, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 19: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, { argIndex: 1, direction: "out", size: { type: "arg", argIndex: 2 }, required: true }, ], 20: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 21: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 22: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 23: [ { argIndex: 0, direction: "out", size: { type: "arg", argIndex: 1 }, required: true }, ], 24: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 25: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 26: [ { argIndex: 1, direction: "out", size: { type: "fixed", size: 16 }, required: true }, @@ -1323,15 +1452,15 @@ export const SYSCALL_ARGS: Record = { { argIndex: 0, direction: "in", size: { type: "fixed", size: 16 }, required: true }, ], 43: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 65537, tooLongErrno: 7 }, required: true }, { argIndex: 1, direction: "out", size: { type: "arg", argIndex: 2 }, required: true }, ], 44: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, - { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 65537, tooLongErrno: 7 }, required: true }, + { argIndex: 1, direction: "in", size: { type: "cstring", maxBytes: 65537, tooLongErrno: 7 }, required: true }, ], 45: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 65537, tooLongErrno: 7 }, required: true }, ], 51: [ { argIndex: 1, direction: "in", size: { type: "arg", argIndex: 2 }, required: true }, @@ -1378,7 +1507,7 @@ export const SYSCALL_ARGS: Record = { { argIndex: 1, direction: "in", size: { type: "arg", argIndex: 2 }, required: true }, ], 69: [ - { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 1, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 70: [ { argIndex: 1, direction: "out", size: { type: "fixed", size: 60 }, required: true }, @@ -1399,55 +1528,55 @@ export const SYSCALL_ARGS: Record = { { argIndex: 1, direction: "in", size: { type: "fixed", size: 16 }, required: true }, ], 85: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 93: [ - { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 1, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, { argIndex: 2, direction: "out", size: { type: "fixed", size: 112 }, required: true }, ], 94: [ - { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 1, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 95: [ - { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 1, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 96: [ - { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, - { argIndex: 3, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 1, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, + { argIndex: 3, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 97: [ - { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 1, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 98: [ - { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 1, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 99: [ - { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 1, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 100: [ - { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, - { argIndex: 3, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 1, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, + { argIndex: 3, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 101: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, - { argIndex: 2, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, + { argIndex: 2, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 102: [ - { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 1, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, { argIndex: 2, direction: "out", size: { type: "arg", argIndex: 3 }, required: true }, ], 108: [ { argIndex: 1, direction: "out", size: { type: "fixed", size: 144 }, required: true }, ], 109: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, { argIndex: 1, direction: "out", size: { type: "arg", argIndex: 2 }, required: true }, ], 110: [ { argIndex: 0, direction: "in", size: { type: "fixed", size: 8 }, required: true }, ], 112: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, { argIndex: 2, direction: "out", size: { type: "fixed", size: 8 }, required: true }, ], 113: [ @@ -1477,11 +1606,11 @@ export const SYSCALL_ARGS: Record = { { argIndex: 2, direction: "in", size: { type: "fixed", size: 16 }, required: true }, ], 125: [ - { argIndex: 1, direction: "in", size: { type: "cstring" }, nullable: true }, + { argIndex: 1, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, nullable: true }, { argIndex: 2, direction: "in", size: { type: "fixed", size: 32 }, nullable: true }, ], 129: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, { argIndex: 2, direction: "out", size: { type: "process-layout", wasm32Size: 88, wasm64Size: 120 }, required: true }, ], 130: [ @@ -1505,7 +1634,7 @@ export const SYSCALL_ARGS: Record = { { argIndex: 3, direction: "out", size: { type: "fixed", size: 144 }, nullable: true }, ], 140: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 256, tooLongErrno: 36 }, required: true }, { argIndex: 1, direction: "out", size: { type: "fixed", size: 4 }, required: true }, ], 205: [ @@ -1524,7 +1653,7 @@ export const SYSCALL_ARGS: Record = { { argIndex: 1, direction: "out", size: { type: "process-layout", wasm32Size: 12, wasm64Size: 24 }, nullable: true }, ], 211: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 224: [ { argIndex: 1, direction: "out", size: { type: "process-layout", wasm32Size: 16, wasm64Size: 32 }, required: true }, @@ -1566,20 +1695,20 @@ export const SYSCALL_ARGS: Record = { { argIndex: 0, direction: "inout", size: { type: "arg", argIndex: 1, multiplier: 8 }, required: true }, ], 256: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 250, tooLongErrno: 22 }, required: true }, ], 260: [ - { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 1, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, { argIndex: 4, direction: "out", size: { type: "fixed", size: 256 }, required: true }, ], 269: [ { argIndex: 0, direction: "out", size: { type: "process-layout", wasm32Size: 312, wasm64Size: 368 }, required: true }, ], 271: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 272: [ - { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 1, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 288: [ { argIndex: 2, direction: "out", size: { type: "process-layout", wasm32Size: 128, wasm64Size: 128 }, required: true }, @@ -1597,11 +1726,11 @@ export const SYSCALL_ARGS: Record = { { argIndex: 2, direction: "inout", size: { type: "fixed", size: 8 }, nullable: true }, ], 299: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 306: [ - { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, - { argIndex: 3, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 1, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, + { argIndex: 3, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 325: [ { argIndex: 0, direction: "out", size: { type: "fixed", size: 4 }, nullable: true }, @@ -1619,11 +1748,11 @@ export const SYSCALL_ARGS: Record = { { argIndex: 1, direction: "out", size: { type: "fixed", size: 32 }, required: true }, ], 331: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 256, tooLongErrno: 36 }, required: true }, { argIndex: 3, direction: "in", size: { type: "process-layout", wasm32Size: 32, wasm64Size: 64 }, nullable: true }, ], 332: [ - { argIndex: 0, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 0, direction: "in", size: { type: "cstring", maxBytes: 256, tooLongErrno: 36 }, required: true }, ], 333: [ { argIndex: 1, direction: "in", size: { type: "arg", argIndex: 2 }, required: true }, @@ -1648,10 +1777,10 @@ export const SYSCALL_ARGS: Record = { { argIndex: 1, direction: "in", size: { type: "fixed", size: 8 }, required: true }, ], 382: [ - { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 1, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 383: [ - { argIndex: 1, direction: "in", size: { type: "cstring" }, required: true }, + { argIndex: 1, direction: "in", size: { type: "cstring", maxBytes: 4096, tooLongErrno: 36 }, required: true }, ], 384: [ { argIndex: 1, direction: "out", size: { type: "deref", argIndex: 2 }, nullable: true }, diff --git a/host/src/host-adapter-manifest.ts b/host/src/host-adapter-manifest.ts index 2a75b80763..bce9a77b50 100644 --- a/host/src/host-adapter-manifest.ts +++ b/host/src/host-adapter-manifest.ts @@ -12,6 +12,43 @@ import { HOST_ADAPTER_VERSION, HOST_ADAPTER_WORKER_FEATURES, } from "./generated/abi"; +import { + hasValidatedKernelEntryExport, + readValidatedKernelHostAdapterManifestScalar, + type KernelHostAdapterManifestScalarExport, + validateKernelEntryMemoryOwnership, +} from "./kernel-entry-gate"; + +// WHY: kernel initialization crosses host hooks before the manifest is read. +// Capture every intrinsic that receives the private kernel Memory, its backing +// buffer, or a view over those bytes before userland can replace globals or +// configurable prototype accessors. +const IntrinsicDataView = DataView; +const IntrinsicNumber = Number; +const IntrinsicTypeError = TypeError; +const intrinsicApply = Reflect.apply; +const intrinsicNumberIsSafeInteger = Number.isSafeInteger; +const intrinsicObjectEntries = Object.entries; +const intrinsicObjectSetPrototypeOf = Object.setPrototypeOf; +const intrinsicArrayPush = Array.prototype.push; +const intrinsicArrayJoin = Array.prototype.join; +const intrinsicMemoryBuffer = Object.getOwnPropertyDescriptor( + WebAssembly.Memory.prototype, + "buffer", +)!.get!; +const intrinsicArrayBufferByteLength = Object.getOwnPropertyDescriptor( + ArrayBuffer.prototype, + "byteLength", +)!.get!; +const intrinsicSharedArrayBufferByteLength = + typeof SharedArrayBuffer === "undefined" + ? null + : Object.getOwnPropertyDescriptor( + SharedArrayBuffer.prototype, + "byteLength", + )!.get!; +const intrinsicDataViewGetUint16 = DataView.prototype.getUint16; +const intrinsicDataViewGetUint32 = DataView.prototype.getUint32; export interface HostAdapterManifest { magic: number; @@ -27,7 +64,34 @@ export interface HostAdapterManifest { channelMinSize: number; } -type ManifestExport = () => number | bigint; +function wasmMemoryBuffer(memory: WebAssembly.Memory): ArrayBufferLike { + return intrinsicApply( + intrinsicMemoryBuffer, + memory, + [], + ) as ArrayBufferLike; +} + +function bufferByteLength(buffer: ArrayBufferLike): number { + try { + return intrinsicApply( + intrinsicArrayBufferByteLength, + buffer, + [], + ) as number; + } catch { + if (intrinsicSharedArrayBufferByteLength !== null) { + return intrinsicApply( + intrinsicSharedArrayBufferByteLength, + buffer, + [], + ) as number; + } + throw new IntrinsicTypeError( + "kernel Memory has no genuine attached buffer", + ); + } +} export function detectHostAdapterWorkerFeatures(): number { let features = 0; @@ -50,21 +114,22 @@ export function readKernelHostAdapterManifest( instance: WebAssembly.Instance, memory: WebAssembly.Memory, ): HostAdapterManifest { - const ptrFn = requiredManifestExport( - instance, - "kernel_host_adapter_manifest_ptr", - ); - const lenFn = requiredManifestExport( - instance, - "kernel_host_adapter_manifest_len", - ); - + // WHY: a valid pointer in one kernel generation says nothing about a + // different generation's Memory. Authenticate the pair before reading any + // bytes so a larger unrelated linear memory cannot satisfy the range check. + validateKernelEntryMemoryOwnership(instance, memory); const pointer = wasmPointerToNumber( - ptrFn(), + requiredManifestExportValue( + instance, + "kernel_host_adapter_manifest_ptr", + ), "kernel_host_adapter_manifest_ptr", ); const length = wasmPointerToNumber( - lenFn(), + requiredManifestExportValue( + instance, + "kernel_host_adapter_manifest_len", + ), "kernel_host_adapter_manifest_len", ); if (length < HOST_ADAPTER_MANIFEST_SIZE) { @@ -73,15 +138,17 @@ export function readKernelHostAdapterManifest( `(expected at least ${HOST_ADAPTER_MANIFEST_SIZE})`, ); } - if (pointer + HOST_ADAPTER_MANIFEST_SIZE > memory.buffer.byteLength) { + const buffer = wasmMemoryBuffer(memory); + const memoryByteLength = bufferByteLength(buffer); + if (pointer > memoryByteLength - HOST_ADAPTER_MANIFEST_SIZE) { throw new Error( `kernel host adapter manifest is out of bounds: ptr=${pointer} ` + - `size=${HOST_ADAPTER_MANIFEST_SIZE} memory=${memory.buffer.byteLength}`, + `size=${HOST_ADAPTER_MANIFEST_SIZE} memory=${memoryByteLength}`, ); } - const view = new DataView( - memory.buffer, + const view = new IntrinsicDataView( + buffer, pointer, HOST_ADAPTER_MANIFEST_SIZE, ); @@ -168,8 +235,13 @@ export function validateKernelHostAdapterManifest( CH_TOTAL_SIZE, ); - for (const exportName of HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS) { - if (typeof instance.exports[exportName] !== "function") { + for ( + let index = 0; + index < HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS.length; + index++ + ) { + const exportName = HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS[index]!; + if (!hasValidatedKernelEntryExport(instance, exportName)) { throw new Error( `kernel wasm is missing required host adapter export ${exportName}`, ); @@ -179,23 +251,27 @@ export function validateKernelHostAdapterManifest( return manifest; } -function requiredManifestExport( +function requiredManifestExportValue( instance: WebAssembly.Instance, - name: string, -): ManifestExport { - const value = instance.exports[name]; - if (typeof value !== "function") { + name: KernelHostAdapterManifestScalarExport, +): number | bigint { + if (!hasValidatedKernelEntryExport(instance, name)) { throw new Error( `kernel wasm is missing required host adapter export ${name}`, ); } - return value as ManifestExport; + return readValidatedKernelHostAdapterManifestScalar( + instance, + name, + ); } function wasmPointerToNumber(value: number | bigint, exportName: string): number { - const numberValue = typeof value === "bigint" ? Number(value) : value; + const numberValue = typeof value === "bigint" + ? IntrinsicNumber(value) + : value; if ( - !Number.isSafeInteger(numberValue) || + !intrinsicNumberIsSafeInteger(numberValue) || numberValue < 0 ) { throw new Error( @@ -209,14 +285,22 @@ function u16( view: DataView, field: keyof typeof HOST_ADAPTER_MANIFEST_FIELDS, ): number { - return view.getUint16(HOST_ADAPTER_MANIFEST_FIELDS[field].offset, true); + return intrinsicApply( + intrinsicDataViewGetUint16, + view, + [HOST_ADAPTER_MANIFEST_FIELDS[field].offset, true], + ) as number; } function u32( view: DataView, field: keyof typeof HOST_ADAPTER_MANIFEST_FIELDS, ): number { - return view.getUint32(HOST_ADAPTER_MANIFEST_FIELDS[field].offset, true); + return intrinsicApply( + intrinsicDataViewGetUint32, + view, + [HOST_ADAPTER_MANIFEST_FIELDS[field].offset, true], + ) as number; } function assertManifestChannelField( @@ -233,13 +317,27 @@ function assertManifestChannelField( } function formatFeatureMask(mask: number): string { - const names: string[] = []; + const names = intrinsicObjectSetPrototypeOf([], null) as string[]; let knownMask = 0; - for (const [name, bit] of Object.entries(HOST_ADAPTER_WORKER_FEATURES)) { + const entries = intrinsicObjectEntries(HOST_ADAPTER_WORKER_FEATURES); + for (let index = 0; index < entries.length; index++) { + const entry = entries[index]!; + const name = entry[0]; + const bit = entry[1]; knownMask |= bit; - if ((mask & bit) !== 0) names.push(name); + if ((mask & bit) !== 0) { + intrinsicApply(intrinsicArrayPush, names, [name]); + } } const unknown = mask & ~knownMask; - if (unknown !== 0) names.push(`unknown(0x${unknown.toString(16)})`); - return names.length === 0 ? "none" : names.join(", "); + if (unknown !== 0) { + intrinsicApply( + intrinsicArrayPush, + names, + [`unknown(0x${unknown.toString(16)})`], + ); + } + return names.length === 0 + ? "none" + : intrinsicApply(intrinsicArrayJoin, names, [", "]) as string; } diff --git a/host/src/index.ts b/host/src/index.ts index edfe47dd17..480b749e34 100644 --- a/host/src/index.ts +++ b/host/src/index.ts @@ -17,6 +17,7 @@ export { NodeWorkerAdapter, MockWorkerAdapter, MockWorkerHandle } from "./worker export { centralizedWorkerMain, centralizedThreadWorkerMain } from "./worker-main"; export type { MessagePort as WorkerMessagePort } from "./worker-main"; export type { + HostFileOffset, KernelConfig, NetworkIO, PathconfValue, diff --git a/host/src/kernel-entry-gate.ts b/host/src/kernel-entry-gate.ts new file mode 100644 index 0000000000..fab52a5043 --- /dev/null +++ b/host/src/kernel-entry-gate.ts @@ -0,0 +1,1596 @@ +/** + * Single-entry serialization for calls into the kernel WebAssembly instance. + * + * Kernel exports may synchronously call a host import while Rust still owns + * mutable kernel state. A host callback must therefore not enter another + * export until the outer call has returned to the event loop. + */ + +type DeferredKernelEntry = { + readonly kind: "void-ingress"; + readonly label: string; + readonly operation: ( + scope: KernelVoidIngressScope, + effects: KernelEntryEffectRegistrar, + ) => undefined; + readonly dedupeKey?: object; +}; + +const kernelVoidIngressScopeBrand: unique symbol = Symbol( + "KernelVoidIngressScope", +); + +/** Opaque, gate-bound authority for one synchronous void ingress. */ +export interface KernelVoidIngressScope { + readonly [kernelVoidIngressScopeBrand]: true; +} + +export interface KernelEntryEffectRegistrar { + readonly deferProtocolEffect: (operation: () => undefined) => undefined; + /** + * Start one host-owned asynchronous transaction after scope revocation. + * + * The callback may synchronously launch host work and register its captured- + * Promise continuations, but it receives no Wasm authority and must return + * undefined before later ingress can run. Each continuation must re-enter + * through a fresh identity-checked ingress before touching kernel state. + */ + readonly deferProtocolTransactionStart: + (operation: () => undefined) => undefined; + readonly deferObserverEffect: (operation: () => undefined) => undefined; +} + +type KernelDetachedEffect = { + readonly kind: "protocol" | "protocol-transaction-start" | "observer"; + readonly operation: () => undefined; +}; + +type GatedInstanceRecord = { + readonly rawInstance: WebAssembly.Instance; + readonly gate: KernelEntryGate; +}; + +type VoidIngressScopeRecord = { + readonly gate: KernelEntryGate; + readonly invoke: (operation: () => T) => T; + readonly invokeSerializedHostOperation: (operation: () => T) => T; +}; + +type RawInstanceGateRecord = { + readonly gate: KernelEntryGate; + readonly facade: WebAssembly.Instance; +}; + +// WHY: kernel exports can call arbitrary host hooks before the gate drains. +// Capture every mutable intrinsic used for queueing, wrapping, invocation, +// and hidden authority lookup before those hooks can replace globals or +// prototype methods. +const IntrinsicError = Error; +const IntrinsicProxy = Proxy; +const IntrinsicSet = Set; +const intrinsicApply = Reflect.apply; +const intrinsicReflectGet = Reflect.get; +const intrinsicArrayPush = Array.prototype.push; +const intrinsicArrayShift = Array.prototype.shift; +const intrinsicConsoleError = console.error; +const intrinsicObjectCreate = Object.create; +const intrinsicObjectDefineProperty = Object.defineProperty; +const intrinsicObjectEntries = Object.entries; +const intrinsicObjectFreeze = Object.freeze; +const intrinsicObjectGetOwnPropertyDescriptor = + Object.getOwnPropertyDescriptor; +const intrinsicObjectSetPrototypeOf = Object.setPrototypeOf; +const intrinsicQueueMicrotask = queueMicrotask; +const intrinsicNumberIsSafeInteger = Number.isSafeInteger; +const intrinsicSetAdd = Set.prototype.add; +const intrinsicSetClear = Set.prototype.clear; +const intrinsicSetDelete = Set.prototype.delete; +const intrinsicSetHas = Set.prototype.has; +const intrinsicWeakMapGet = WeakMap.prototype.get; +const intrinsicWeakMapSet = WeakMap.prototype.set; +const intrinsicWeakSetAdd = WeakSet.prototype.add; +const intrinsicWeakSetHas = WeakSet.prototype.has; +const intrinsicInstanceExports = intrinsicObjectGetOwnPropertyDescriptor( + WebAssembly.Instance.prototype, + "exports", +)!.get!; +const intrinsicWasmInstancePrototype = WebAssembly.Instance.prototype; + +const gatedInstances = new WeakMap(); +const rawInstanceGates = + new WeakMap(); +const scopedInstances = + new WeakMap(); +const voidIngressScopes = + new WeakMap(); +const exactKernelEntryGates = new WeakSet(); +// WHY: a public property, exported class, or global symbol would let ordinary +// host/backend failures impersonate a poisoned Wasm generation. Identity in +// this module-private set is granted only while normalizing an actual kernel +// export exception. +const kernelExportFailures = new WeakSet(); + +/** Whether `value` is the normalized failure of an actual kernel export. */ +export function isKernelExportFailure(value: unknown): value is Error { + if ( + value === null + || (typeof value !== "object" && typeof value !== "function") + ) { + return false; + } + return intrinsicApply( + intrinsicWeakSetHas, + kernelExportFailures, + [value], + ); +} + +/** + * @internal Scratch leases use this only after validating and converting all + * caller-controlled arguments. Ordinary host call sites must use the scoped + * instance façade, whose wrapper evaluates arguments before entering here. + */ +export function invokeKernelEntryScopedOperation( + scope: KernelVoidIngressScope, + expectedInstance: WebAssembly.Instance, + operation: () => T, +): T { + const scopeRecord = intrinsicApply( + intrinsicWeakMapGet, + voidIngressScopes, + [scope], + ) as VoidIngressScopeRecord | undefined; + if (scopeRecord === undefined) { + throw new IntrinsicError("unknown kernel void-ingress scope"); + } + const instanceRecord = intrinsicApply( + intrinsicWeakMapGet, + gatedInstances, + [expectedInstance], + ) as GatedInstanceRecord | undefined; + if ( + instanceRecord === undefined + || scopeRecord.gate !== instanceRecord.gate + ) { + throw new IntrinsicError( + "kernel void-ingress scope does not own the supplied operation", + ); + } + return scopeRecord.invoke(operation); +} + +/** + * Run one synchronous host capability call while an exact void-ingress scope + * remains the serialization owner. + * + * This is narrower than an export permit: it cannot enter Wasm, register + * effects, nest another host operation, or outlive the lexical scope. Public + * void ingress reached by the host callback remains queued behind the current + * record, and result-bearing ingress fails synchronously. Callers must stage + * backend results in host-owned values and commit only after this returns. + */ +export function invokeKernelEntrySerializedHostOperation( + scope: KernelVoidIngressScope, + operation: () => T, +): T { + const scopeRecord = intrinsicApply( + intrinsicWeakMapGet, + voidIngressScopes, + [scope], + ) as VoidIngressScopeRecord | undefined; + if (scopeRecord === undefined) { + throw new IntrinsicError("unknown kernel void-ingress scope"); + } + if (typeof operation !== "function") { + throw new IntrinsicError( + "serialized kernel host operation must be callable", + ); + } + return scopeRecord.invokeSerializedHostOperation(operation); +} + +function normalizeCaughtFailure(message: string, cause: unknown): Error { + const error = new IntrinsicError(message); + // WHY: even `instanceof Error` consults mutable userland hooks. Always make + // a fresh intrinsic Error and preserve the original thrown value as an own + // data property without coercing or otherwise invoking attacker code. + intrinsicObjectDefineProperty(error, "cause", { + configurable: true, + enumerable: false, + writable: true, + value: cause, + }); + return error; +} + +export class KernelReentrantEntryError extends IntrinsicError { + declare readonly exportName: string; + declare readonly activeExportName?: string; + + constructor( + exportName: string, + activeExportName?: string, + ) { + super( + `kernel export ${exportName} cannot run while ` + + `${activeExportName ?? "another kernel export"} is active`, + ); + // WHY: Error.prototype is mutable. Parameter-property assignments and + // `this.name = ...` can invoke hostile inherited setters while this class + // is enforcing the entry guard. + intrinsicObjectDefineProperty(this, "exportName", { + configurable: true, + enumerable: true, + writable: false, + value: exportName, + }); + intrinsicObjectDefineProperty(this, "activeExportName", { + configurable: true, + enumerable: true, + writable: false, + value: activeExportName, + }); + intrinsicObjectDefineProperty(this, "name", { + configurable: true, + enumerable: false, + writable: true, + value: "KernelReentrantEntryError", + }); + } +} + +/** + * The capability held by one kernel worker for one kernel Wasm generation. + * + * Result-bearing reverse calls fail synchronously: their caller cannot be + * told a truthful result before the active Rust operation finishes. Every + * queued operation uses `runOrDeferVoidIngress` and an explicit scope token. + */ +export class KernelEntryGate { + #activeExportName: string | null = null; + #fatalError: Error | null = null; + #deferred = intrinsicObjectSetPrototypeOf( + [], + null, + ) as DeferredKernelEntry[]; + #deferredKeys = new IntrinsicSet(); + #drainScheduled = false; + #draining = false; + #activeVoidIngressLabel: string | null = null; + #activeVoidIngressToken: object | null = null; + #activeHostOperationLabel: string | null = null; + #scopedExportPermit: object | null = null; + #runningDetachedPost = false; + #runningProtocolTransactionStart = false; + #pendingProtocolTransactionStarts = 0; + #pendingFatalReport: Error | null = null; + #onFatal: ((error: Error) => void) | undefined; + + constructor( + onFatal?: (error: Error) => void, + ) { + if (new.target !== KernelEntryGate) { + throw new IntrinsicError( + "KernelEntryGate does not permit subclass dispatch overrides", + ); + } + intrinsicApply(intrinsicWeakSetAdd, exactKernelEntryGates, [this]); + this.#onFatal = onFatal; + // Private fields remain mutable on a frozen object. Freezing only removes + // the public own-property surface that an observer could otherwise use to + // shadow runOrDeferVoidIngress, fail, or the defer-state getter. + intrinsicObjectFreeze(this); + } + + /** @internal Install the owning worker's fatal-error sink exactly once. */ + setFatalHandler(handler: (error: Error) => void): void { + if (this.#onFatal !== undefined) { + throw new IntrinsicError( + "kernel entry gate already has a failure handler", + ); + } + this.#onFatal = handler; + } + + /** Whether a reviewed multi-export void ingress needs an owned snapshot. */ + get shouldDeferVoidIngress(): boolean { + return this.#shouldDeferVoidIngress(); + } + + #shouldDeferVoidIngress(): boolean { + if (this.#runningProtocolTransactionStart) { + // The transaction start itself may invoke reviewed public worker roots. + // Those roots receive a wholly fresh scope and are part of this ordered + // start record, so they run before unrelated ingress already in the FIFO. + // A detached effect produced by one of those roots must still queue: + // running another root synchronously there would nest detached phases + // and either expose partial publication or poison the generation. + return this.#fatalError !== null + || this.#activeExportName !== null + || this.#activeVoidIngressLabel !== null + || this.#activeHostOperationLabel !== null + || this.#runningDetachedPost; + } + return this.#fatalError !== null + || this.#activeExportName !== null + || this.#activeVoidIngressLabel !== null + || this.#activeHostOperationLabel !== null + || this.#runningDetachedPost + || this.#pendingProtocolTransactionStarts > 0 + || this.#draining + || this.#drainScheduled + || this.#deferred.length > 0; + } + + #acceptSynchronousHostOperationResult( + label: string, + result: T, + ): T { + if ( + result !== null + && (typeof result === "object" || typeof result === "function") + && typeof intrinsicReflectGet( + result, + "then", + result, + ) === "function" + ) { + const error = new IntrinsicError( + `serialized kernel host operation ${label} returned a Promise or thenable`, + ); + // WHY: the asynchronous continuation already exists and cannot be + // cancelled here. Releasing this marker for later kernel ingress would + // let that continuation overlap the host snapshot it was supposed to + // finish synchronously, so this is a coherence failure rather than an + // ordinary backend exception. + if (this.#fail(error)) this.#pendingFatalReport = error; + throw error; + } + return result; + } + + invokeKernelExport(name: string, operation: () => T): T { + if (this.#fatalError !== null) throw this.#fatalError; + if ( + this.#activeExportName !== null + || this.#activeHostOperationLabel !== null + ) { + throw new KernelReentrantEntryError( + name, + this.#activeExportName ?? this.#activeHostOperationLabel ?? undefined, + ); + } + let scopedExportAuthorized = false; + if (this.#activeVoidIngressToken !== null) { + if (this.#scopedExportPermit !== this.#activeVoidIngressToken) { + throw new KernelReentrantEntryError( + name, + this.#activeVoidIngressLabel ?? "scoped void ingress", + ); + } + // WHY: consume before entering Wasm. Any host import reached by this + // export observes #activeExportName, and code invoked after it returns + // cannot reuse the one-shot token for another export. + this.#scopedExportPermit = null; + scopedExportAuthorized = true; + } + if ( + !scopedExportAuthorized + && ( + this.#draining + || this.#runningDetachedPost + || this.#runningProtocolTransactionStart + || this.#drainScheduled + || this.#deferred.length > 0 + ) + ) { + // WHY: a generic FIFO callback is host-only and receives zero implicit + // export authority. Otherwise an observer or other callback reached + // before the intended export could steal that ambient permit and + // overtake older ingress. Wasm-bearing operations must present the + // exact token issued by runOrDeferVoidIngress. + throw new KernelReentrantEntryError( + name, + this.#runningDetachedPost || this.#runningProtocolTransactionStart + ? "detached host phase" + : "queued kernel ingress", + ); + } + this.#activeExportName = name; + try { + return operation(); + } catch (cause) { + const error = normalizeCaughtFailure( + `kernel export ${name} failed`, + cause, + ); + intrinsicApply(intrinsicWeakSetAdd, kernelExportFailures, [error]); + // WHY: an exception can unwind through Rust after arbitrary mutation. + // No caller can prove that global kernel state is reusable, so queued + // work must be discarded before this export releases the entry. + if (this.#fail(error)) { + this.#pendingFatalReport = error; + } + throw error; + } finally { + this.#activeExportName = null; + // WHY: the fatal observer may attempt ordinary worker teardown or a + // fresh ingress. Report only after the exact export authority is gone; + // it must observe the latched generation, never the active Rust scope. + this.#reportPendingFatalIfRevoked(); + // WHY: waiting for a microtask lets the outer lease copy its result, + // revoke its host view, cancel the Rust reservation, and restore the + // selected pid before any queued entry can observe that transaction. + this.#scheduleDrain(); + } + } + + /** + * Serialize one synchronous host-only operation while the gate is idle. + * + * Unlike a void ingress scope, this grants no Wasm or detached-effect + * authority. A reentrant void ingress joins the FIFO; result-bearing/export + * entry and a nested host operation fail synchronously. Backend exceptions + * propagate without poisoning the kernel because no Wasm mutation was + * active, and the marker is always released before queued work can drain. + * + * @internal Shared-mapping host-only cleanup uses this when no lexical + * KernelVoidIngressScope exists. + */ + runSerializedHostOperation( + label: string, + operation: () => T, + ): T { + if (this.#fatalError !== null) throw this.#fatalError; + if (typeof operation !== "function") { + throw new IntrinsicError( + "serialized kernel host operation must be callable", + ); + } + if (this.#shouldDeferVoidIngress()) { + throw new KernelReentrantEntryError( + label, + this.#activeExportName + ?? this.#activeVoidIngressLabel + ?? this.#activeHostOperationLabel + ?? "active or queued kernel entry", + ); + } + this.#activeHostOperationLabel = label; + try { + return this.#acceptSynchronousHostOperationResult( + label, + operation(), + ); + } finally { + this.#activeHostOperationLabel = null; + this.#reportPendingFatalIfRevoked(); + this.#scheduleDrain(); + } + } + + /** + * Run one synchronous void ingress only when the gate is completely idle. + * + * Unlike `runOrDeferVoidIngress`, this method never retains `operation`. + * Immediate-result and fault-injection seams use it when their arguments are + * caller-owned and cannot truthfully be snapshotted for later execution. + * A busy gate rejects before invoking or enqueueing the callback. + * + * @internal + */ + runImmediateVoidIngress( + label: string, + operation: ( + scope: KernelVoidIngressScope, + effects: KernelEntryEffectRegistrar, + ) => undefined, + ): void { + if (typeof label !== "string") { + throw new IntrinsicError( + "immediate kernel void-ingress label must be a primitive string", + ); + } + if (typeof operation !== "function") { + throw new IntrinsicError( + "immediate kernel void-ingress operation must be callable", + ); + } + if (this.#fatalError !== null) throw this.#fatalError; + if ( + this.#runningProtocolTransactionStart + || this.#shouldDeferVoidIngress() + ) { + throw new KernelReentrantEntryError( + label, + this.#activeExportName + ?? this.#activeVoidIngressLabel + ?? this.#activeHostOperationLabel + ?? ( + this.#runningProtocolTransactionStart + ? "protocol transaction start" + : "active or queued kernel entry" + ), + ); + } + const effects = this.#runVoidIngress(label, operation); + this.#runDetachedEffects(label, effects); + } + + /** + * Run one reviewed synchronous void-ingress operation, or append it to the + * ingress FIFO when the kernel entry is already owned. + * + * This grants the selected operation authority for multiple sequential + * exports. Keep the callback narrowly scoped to the reviewed export + * sequence: it must not invoke user callbacks or retain authority across a + * Promise. The gate-bound registrar records ordered work that runs only + * after scope revocation. The return value is true when the operation was + * queued or discarded after a fatal latch, and false when it completed + * synchronously. + */ + runOrDeferVoidIngress( + label: string, + operation: ( + scope: KernelVoidIngressScope, + effects: KernelEntryEffectRegistrar, + ) => undefined, + dedupeKey?: object, + ): boolean { + if (this.#fatalError !== null) return true; + if (this.#shouldDeferVoidIngress()) { + if ( + dedupeKey !== undefined + && intrinsicApply( + intrinsicSetHas, + this.#deferredKeys, + [dedupeKey], + ) + ) { + return true; + } + if (dedupeKey !== undefined) { + intrinsicApply(intrinsicSetAdd, this.#deferredKeys, [dedupeKey]); + } + intrinsicApply( + intrinsicArrayPush, + this.#deferred, + [{ + kind: "void-ingress", + label, + operation, + dedupeKey, + }], + ); + return true; + } + const effects = this.#runVoidIngress(label, operation); + this.#runDetachedEffects(label, effects); + return false; + } + + #runDetachedEffects( + label: string, + effects: KernelDetachedEffect[], + ): void { + if (this.#runningDetachedPost) { + throw new KernelReentrantEntryError( + "detached host phase", + "another detached host phase", + ); + } + this.#runningDetachedPost = true; + try { + const effectCount = effects.length; + for (let index = 0; index < effectCount; index++) { + // An earlier observer may intentionally retire this generation through + // the owning worker without throwing. A fatal latch revokes every + // remaining effect, including protocol publication that would + // otherwise make incoherent state externally visible. + if (this.#fatalError !== null) return; + const effect = effects[index]!; + if (effect.kind === "protocol-transaction-start") { + if (index !== effectCount - 1) { + const error = new IntrinsicError( + "protocol transaction start must be the final detached effect", + ); + if (this.#fail(error)) this.#pendingFatalReport = error; + throw error; + } + this.#pendingProtocolTransactionStarts++; + try { + intrinsicQueueMicrotask(() => { + this.#runProtocolTransactionStart( + label, + index, + effect.operation, + ); + }); + } catch (cause) { + this.#pendingProtocolTransactionStarts--; + throw cause; + } + continue; + } + try { + const result: unknown = effect.operation(); + // Runtime defense in depth: do not inspect a user-controlled + // thenable. Any non-undefined return released this synchronous + // boundary early and is therefore a contract failure. + if (result !== undefined) { + throw new IntrinsicError( + `${effect.kind} effect ${index} returned a value`, + ); + } + } catch (cause) { + if (effect.kind === "observer") { + this.#reportDetachedFailure(label, cause); + if (this.#fatalError !== null) return; + continue; + } + if (this.#fatalError !== null) throw this.#fatalError; + const error = normalizeCaughtFailure( + `kernel protocol effect ${index} for ${label} failed`, + cause, + ); + if (this.#fail(error)) { + // Latch and discard queued ingress before invoking the worker's + // potentially compromised fatal observer. + this.#pendingFatalReport = error; + } + throw error; + } + if (this.#fatalError !== null) return; + } + } finally { + this.#runningDetachedPost = false; + this.#reportPendingFatalIfRevoked(); + // A public ingress reached by an observer joins the FIFO. The scoped + // phase may have had no queued work when it released its token, so the + // detached barrier itself must arrange the later drain. + this.#scheduleDrain(); + } + } + + #runProtocolTransactionStart( + label: string, + index: number, + operation: () => undefined, + ): void { + this.#pendingProtocolTransactionStarts--; + if (this.#fatalError !== null) return; + this.#runningProtocolTransactionStart = true; + try { + const result: unknown = operation(); + if (result !== undefined) { + throw new IntrinsicError( + `protocol transaction start ${index} for ${label} returned a value`, + ); + } + } catch (cause) { + if (this.#fatalError !== null) return; + const error = normalizeCaughtFailure( + `protocol transaction start ${index} for ${label} failed`, + cause, + ); + if (this.#fail(error)) this.#pendingFatalReport = error; + } finally { + this.#runningProtocolTransactionStart = false; + this.#reportPendingFatalIfRevoked(); + this.#scheduleDrain(); + } + } + + #runVoidIngress( + label: string, + operation: ( + scope: KernelVoidIngressScope, + effects: KernelEntryEffectRegistrar, + ) => undefined, + ): KernelDetachedEffect[] { + if (this.#fatalError !== null) throw this.#fatalError; + if ( + this.#activeExportName !== null + || this.#activeVoidIngressLabel !== null + || this.#activeHostOperationLabel !== null + ) { + throw new KernelReentrantEntryError( + label, + this.#activeExportName + ?? this.#activeVoidIngressLabel + ?? this.#activeHostOperationLabel + ?? "kernel cleanup", + ); + } + const token = intrinsicObjectCreate(null) as object; + const scope = intrinsicObjectCreate(null) as KernelVoidIngressScope; + const detachedEffects = intrinsicObjectSetPrototypeOf( + [], + null, + ) as KernelDetachedEffect[]; + let acceptingEffects = true; + let runningSerializedHostOperation = false; + intrinsicObjectDefineProperty(scope, kernelVoidIngressScopeBrand, { + configurable: false, + enumerable: false, + writable: false, + value: true, + }); + const invoke = (invokeOperation: () => T): T => { + if ( + this.#activeVoidIngressToken !== token + || this.#activeVoidIngressLabel === null + ) { + throw new IntrinsicError( + `void kernel ingress ${label} scope is no longer active`, + ); + } + if ( + this.#activeExportName !== null + || this.#scopedExportPermit !== null + || runningSerializedHostOperation + ) { + throw new KernelReentrantEntryError( + label, + this.#activeExportName ?? "another scoped export", + ); + } + this.#scopedExportPermit = token; + try { + const result = invokeOperation(); + if (this.#scopedExportPermit === token) { + throw new IntrinsicError( + `void kernel ingress ${label} scope did not invoke an export`, + ); + } + return result; + } finally { + this.#scopedExportPermit = null; + } + }; + const invokeSerializedHostOperation = ( + hostOperation: () => T, + ): T => { + if ( + this.#activeVoidIngressToken !== token + || this.#activeVoidIngressLabel === null + ) { + throw new IntrinsicError( + `void kernel ingress ${label} scope is no longer active`, + ); + } + if ( + this.#activeExportName !== null + || this.#scopedExportPermit !== null + || runningSerializedHostOperation + ) { + throw new KernelReentrantEntryError( + "serialized host operation", + this.#activeExportName + ?? ( + runningSerializedHostOperation + ? "another serialized host operation" + : "another scoped export" + ), + ); + } + runningSerializedHostOperation = true; + try { + return this.#acceptSynchronousHostOperationResult( + label, + hostOperation(), + ); + } finally { + runningSerializedHostOperation = false; + } + }; + intrinsicApply( + intrinsicWeakMapSet, + voidIngressScopes, + [ + scope, + { + gate: this, + invoke, + invokeSerializedHostOperation, + }, + ], + ); + intrinsicObjectFreeze(scope); + const registerEffect = ( + kind: KernelDetachedEffect["kind"], + effectOperation: () => undefined, + ): undefined => { + if ( + !acceptingEffects + || this.#activeVoidIngressToken !== token + || this.#activeVoidIngressLabel === null + || runningSerializedHostOperation + ) { + throw new IntrinsicError( + `void kernel ingress ${label} effect registration is no longer active`, + ); + } + if (typeof effectOperation !== "function") { + throw new IntrinsicError( + `void kernel ingress ${label} effect must be callable`, + ); + } + intrinsicApply( + intrinsicArrayPush, + detachedEffects, + [intrinsicObjectFreeze({ kind, operation: effectOperation })], + ); + return undefined; + }; + const effectRegistrar = intrinsicObjectFreeze({ + deferProtocolEffect: (effectOperation: () => undefined): undefined => + registerEffect("protocol", effectOperation), + deferProtocolTransactionStart: ( + effectOperation: () => undefined, + ): undefined => + registerEffect("protocol-transaction-start", effectOperation), + deferObserverEffect: (effectOperation: () => undefined): undefined => + registerEffect("observer", effectOperation), + }); + this.#activeVoidIngressLabel = label; + this.#activeVoidIngressToken = token; + try { + const result: unknown = operation(scope, effectRegistrar); + // WHY: a Promise would extend this authority beyond the synchronous + // stack, while any other value indicates that a result-bearing boundary + // was accidentally routed through a void-only API. + if (result !== undefined) { + throw new IntrinsicError( + `void kernel ingress ${label} must return undefined synchronously`, + ); + } + if (this.#fatalError !== null) throw this.#fatalError; + return detachedEffects; + } catch (cause) { + if (this.#fatalError !== null) throw this.#fatalError; + const error = normalizeCaughtFailure( + `void kernel ingress ${label} failed`, + cause, + ); + if (this.#fail(error)) { + this.#pendingFatalReport = error; + } + throw error; + } finally { + acceptingEffects = false; + this.#scopedExportPermit = null; + this.#activeVoidIngressToken = null; + this.#activeVoidIngressLabel = null; + // WHY: no fatal observer receives a live scope token or registration + // surface. Reentrant work sees only the already-latched generation. + this.#reportPendingFatalIfRevoked(); + this.#scheduleDrain(); + } + } + + /** + * Poison this generation and discard work that can no longer be executed + * against a coherent Rust state. + */ + fail(error: Error): boolean { + return this.#fail(error); + } + + #fail(error: Error): boolean { + if (this.#fatalError !== null) return false; + this.#fatalError = error; + this.#deferred.length = 0; + intrinsicApply(intrinsicSetClear, this.#deferredKeys, []); + return true; + } + + #scheduleDrain(): void { + if ( + this.#drainScheduled + || this.#draining + || this.#runningDetachedPost + || this.#runningProtocolTransactionStart + || this.#pendingProtocolTransactionStarts > 0 + || this.#fatalError !== null + || this.#activeHostOperationLabel !== null + || this.#deferred.length === 0 + ) { + return; + } + this.#drainScheduled = true; + intrinsicQueueMicrotask(() => { + this.#drainScheduled = false; + if ( + this.#fatalError !== null + || this.#activeExportName !== null + || this.#activeHostOperationLabel !== null + || this.#runningProtocolTransactionStart + || this.#pendingProtocolTransactionStarts > 0 + ) { + return; + } + this.#draining = true; + try { + while ( + this.#deferred.length > 0 + && this.#fatalError === null + && this.#pendingProtocolTransactionStarts === 0 + ) { + const next = intrinsicApply( + intrinsicArrayShift, + this.#deferred, + [], + ) as DeferredKernelEntry; + if (next.dedupeKey !== undefined) { + intrinsicApply( + intrinsicSetDelete, + this.#deferredKeys, + [next.dedupeKey], + ); + } + try { + // WHY: only the callback selected from the FIFO may cross the + // pending-ingress barrier, and it carries the sole export token. + const effects = this.#runVoidIngress( + next.label, + next.operation, + ); + this.#runDetachedEffects(next.label, effects); + } catch (cause) { + if (this.#fatalError !== null) continue; + const error = normalizeCaughtFailure( + `deferred kernel entry ${next.label} failed`, + cause, + ); + if (this.#fail(error)) { + this.#pendingFatalReport = error; + } + } finally { + // No authority-bearing state survives one selected FIFO record. + } + } + } finally { + this.#draining = false; + this.#reportPendingFatalIfRevoked(); + } + }); + } + + #reportPendingFatalIfRevoked(): void { + const error = this.#pendingFatalReport; + if ( + error === null + || this.#activeExportName !== null + || this.#activeVoidIngressToken !== null + || this.#activeVoidIngressLabel !== null + || this.#activeHostOperationLabel !== null + || this.#runningDetachedPost + || this.#runningProtocolTransactionStart + || this.#draining + ) { + return; + } + this.#pendingFatalReport = null; + this.#reportFatal(error); + } + + #reportFatal(error: Error): void { + try { + this.#onFatal?.(error); + } catch { + // The generation was already poisoned before the reporting callback. + // Keep its original coherence failure authoritative. + try { + intrinsicApply( + intrinsicConsoleError, + console, + ["[kernel-entry-gate] fatal-error handler failed"], + ); + } catch { + // Reporting is best-effort after the fatal latch. Never let a hostile + // console implementation replace the authoritative coherence error. + } + } + } + + #reportDetachedFailure(label: string, cause: unknown): void { + try { + intrinsicApply( + intrinsicConsoleError, + console, + [ + `[kernel-entry-gate] detached host phase failed for ${label}`, + cause, + ], + ); + } catch { + // The scoped Wasm phase already completed coherently. Reporting a host + // observer/backend failure must not replace that state or poison FIFO + // work that never depended on the detached callback. + } + } +} + +const intrinsicKernelEntryGateInvoke = + KernelEntryGate.prototype.invokeKernelExport; +// WHY: every façade dispatches through this prototype. Freeze it before any +// caller can construct a gate so an observer cannot replace an entry-taking +// method for a later call. +intrinsicObjectFreeze(KernelEntryGate.prototype); +intrinsicObjectFreeze(KernelEntryGate); + +function createFrozenKernelInstanceFacade( + exports: WebAssembly.Exports, +): WebAssembly.Instance { + // WHY: proxying a genuine instance makes every unhandled mutation trap act + // on the authority-bearing target. A plain object with the nominal + // prototype preserves `instanceof WebAssembly.Instance`, while the + // intrinsic exports getter still rejects it as a non-genuine receiver. + const facade = intrinsicObjectCreate( + intrinsicWasmInstancePrototype, + ) as WebAssembly.Instance; + intrinsicObjectDefineProperty(facade, "exports", { + configurable: false, + enumerable: true, + writable: false, + value: exports, + }); + return intrinsicObjectFreeze(facade); +} + +/** + * Wrap all callable exports and omit every mutable exported Wasm object. + * + * The frozen façade remains `instanceof WebAssembly.Instance` for APIs that + * use that nominal check. It contains no raw instance target, and the + * intrinsic `Instance#exports` getter rejects the non-genuine receiver. + */ +export function createKernelEntryGatedInstance( + rawInstance: WebAssembly.Instance, + gate: KernelEntryGate, +): WebAssembly.Instance { + if ( + !intrinsicApply( + intrinsicWeakSetHas, + exactKernelEntryGates, + [gate], + ) + ) { + throw new IntrinsicError( + "kernel entry façade requires an exact KernelEntryGate", + ); + } + const rawExports = intrinsicApply( + intrinsicInstanceExports, + rawInstance, + [], + ) as WebAssembly.Exports; + const existing = intrinsicApply( + intrinsicWeakMapGet, + rawInstanceGates, + [rawInstance], + ) as RawInstanceGateRecord | undefined; + if (existing !== undefined) { + if (existing.gate !== gate) { + // WHY: two independent gates around one raw instance would each think + // it owns the sole entry permit. The second façade could therefore + // re-enter Rust while the first gate still has an export active. + throw new IntrinsicError( + "kernel WebAssembly instance is already owned by another entry gate", + ); + } + return existing.facade; + } + + const safeExports = intrinsicObjectCreate(null) as Record; + const invoke = (name: string, operation: () => T): T => + intrinsicApply( + intrinsicKernelEntryGateInvoke, + gate, + [name, operation], + ) as T; + const exportEntries = intrinsicObjectEntries(rawExports); + for (let index = 0; index < exportEntries.length; index++) { + const entry = exportEntries[index]!; + const name = entry[0]; + const value = entry[1]; + if (typeof value !== "function") continue; + const wrapped = (...args: unknown[]): unknown => + invoke( + name, + () => intrinsicApply(value, undefined, args), + ); + intrinsicObjectDefineProperty(safeExports, name, { + enumerable: true, + configurable: false, + writable: false, + value: wrapped, + }); + } + intrinsicObjectFreeze(safeExports); + + const facade = createFrozenKernelInstanceFacade(safeExports); + intrinsicApply( + intrinsicWeakMapSet, + gatedInstances, + [ + facade, + { + rawInstance, + gate, + }, + ], + ); + intrinsicApply( + intrinsicWeakMapSet, + rawInstanceGates, + [rawInstance, intrinsicObjectFreeze({ gate, facade })], + ); + return facade; +} + +/** + * @internal Return only exports proven to belong to a genuine raw instance or + * an exact registered gated façade. + */ +export function validatedKernelEntryExports( + instance: WebAssembly.Instance, +): WebAssembly.Exports { + const gatedRecord = intrinsicApply( + intrinsicWeakMapGet, + gatedInstances, + [instance], + ) as GatedInstanceRecord | undefined; + if (gatedRecord !== undefined) { + const descriptor = intrinsicObjectGetOwnPropertyDescriptor( + instance, + "exports", + ); + if ( + descriptor === undefined + || descriptor.get !== undefined + || descriptor.value === undefined + ) { + throw new IntrinsicError( + "registered gated kernel instance lost its frozen exports façade", + ); + } + return descriptor.value as WebAssembly.Exports; + } + if ( + intrinsicApply( + intrinsicWeakMapGet, + scopedInstances, + [instance], + ) !== undefined + ) { + throw new IntrinsicError( + "scoped kernel entry exports cannot escape their ingress", + ); + } + // The captured intrinsic getter rejects structural nominal objects and + // Proxy-wrapped instances because neither carries the exact engine slots. + return intrinsicApply( + intrinsicInstanceExports, + instance, + [], + ) as WebAssembly.Exports; +} + +export type KernelHostAdapterManifestScalarExport = + | "kernel_host_adapter_manifest_ptr" + | "kernel_host_adapter_manifest_len"; + +/** + * Read one exact host-adapter manifest scalar without returning a callable or + * an exports object. + * + * This is the narrow inspection boundary used while initialization owns a + * scoped entry instance. The ordinary validator above intentionally rejects + * that instance because its authority-bearing namespace must not escape. + * Keep the runtime name check as well as the TypeScript union: an erased or + * untyped caller must not turn manifest inspection into generic export-entry + * authority. The wrapper is obtained, called, and discarded synchronously; + * the scope still revokes it when the ingress ends. + */ +export function readValidatedKernelHostAdapterManifestScalar( + instance: WebAssembly.Instance, + exportName: KernelHostAdapterManifestScalarExport, +): number | bigint { + if ( + exportName !== "kernel_host_adapter_manifest_ptr" + && exportName !== "kernel_host_adapter_manifest_len" + ) { + throw new IntrinsicError( + "kernel export is not a host-adapter manifest scalar", + ); + } + let exports: WebAssembly.Exports; + if ( + intrinsicApply( + intrinsicWeakMapGet, + scopedInstances, + [instance], + ) !== undefined + ) { + const descriptor = intrinsicObjectGetOwnPropertyDescriptor( + instance, + "exports", + ); + if ( + descriptor === undefined + || descriptor.get !== undefined + || descriptor.value === undefined + ) { + throw new IntrinsicError( + "registered scoped kernel instance lost its exports façade", + ); + } + exports = descriptor.value as WebAssembly.Exports; + } else { + exports = validatedKernelEntryExports(instance); + } + const value = intrinsicReflectGet(exports, exportName, exports); + if (typeof value !== "function") { + throw new IntrinsicError( + `kernel export ${exportName} is unavailable`, + ); + } + const result = intrinsicApply(value, undefined, []); + if (typeof result !== "number" && typeof result !== "bigint") { + throw new IntrinsicError( + `kernel export ${exportName} did not return a Wasm scalar`, + ); + } + return result; +} + +/** Return function presence without exposing the function itself. */ +export function hasValidatedKernelEntryExport( + instance: WebAssembly.Instance, + exportName: string, +): boolean { + if (typeof exportName !== "string") { + throw new IntrinsicError( + "kernel entry export name must be a primitive string", + ); + } + if ( + intrinsicApply( + intrinsicWeakMapGet, + scopedInstances, + [instance], + ) !== undefined + ) { + const descriptor = intrinsicObjectGetOwnPropertyDescriptor( + instance, + "exports", + ); + if ( + descriptor === undefined + || descriptor.get !== undefined + || descriptor.value === undefined + ) { + throw new IntrinsicError( + "registered scoped kernel instance lost its exports façade", + ); + } + const exports = descriptor.value as WebAssembly.Exports; + return typeof intrinsicReflectGet( + exports, + exportName, + exports, + ) === "function"; + } + return typeof validatedKernelEntryExports(instance)[exportName] + === "function"; +} + +/** + * Bind one gated instance to an explicit void-ingress capability. + * + * Only code that receives this façade can make scoped exports. The ordinary + * façade remains blocked for callbacks that run between reviewed exports. + */ +export function createKernelEntryScopedInstance( + instance: WebAssembly.Instance, + scope: KernelVoidIngressScope, +): WebAssembly.Instance { + const record = intrinsicApply( + intrinsicWeakMapGet, + gatedInstances, + [instance], + ) as GatedInstanceRecord | undefined; + if (record === undefined) { + throw new IntrinsicError( + "scoped kernel entry requires a registered gated instance", + ); + } + const scopeRecord = intrinsicApply( + intrinsicWeakMapGet, + voidIngressScopes, + [scope], + ) as VoidIngressScopeRecord | undefined; + if (scopeRecord === undefined) { + throw new IntrinsicError( + "scoped kernel entry requires a registered void-ingress scope", + ); + } + if (scopeRecord.gate !== record.gate) { + throw new IntrinsicError( + "scoped kernel entry gate does not own the supplied scope", + ); + } + const gatedExports = intrinsicReflectGet( + instance, + "exports", + instance, + ) as WebAssembly.Exports; + const scopedExportCache = intrinsicObjectCreate(null) as Record< + string, + Function + >; + const scopedExportsTarget = intrinsicObjectFreeze( + intrinsicObjectCreate(null) as object, + ); + const invoke = scopeRecord.invoke; + // WHY: the kernel currently has hundreds of callable exports. Materializing + // a wrapper for every one on every syscall would turn an authority check + // into hundreds of hot-path allocations. Lazily cache only the handful this + // exact ingress actually requests; the underlying gated namespace exposes + // no Memory or other mutable Wasm object. + const scopedExports = new IntrinsicProxy(scopedExportsTarget, { + get(_target, property) { + if (typeof property !== "string") return undefined; + const cached = intrinsicObjectGetOwnPropertyDescriptor( + scopedExportCache, + property, + )?.value as Function | undefined; + if (cached !== undefined) return cached; + const gatedValue = intrinsicReflectGet( + gatedExports, + property, + gatedExports, + ); + if (typeof gatedValue !== "function") return undefined; + const wrapped = (...args: unknown[]): unknown => + intrinsicApply( + invoke, + undefined, + [() => intrinsicApply(gatedValue, undefined, args)], + ); + intrinsicObjectDefineProperty(scopedExportCache, property, { + enumerable: true, + configurable: false, + writable: false, + value: wrapped, + }); + return wrapped; + }, + }) as WebAssembly.Exports; + const facade = createFrozenKernelInstanceFacade(scopedExports); + intrinsicApply( + intrinsicWeakMapSet, + scopedInstances, + [facade, record], + ); + return facade; +} + +function rawKernelEntryExports( + instance: WebAssembly.Instance, +): WebAssembly.Exports { + if ( + intrinsicApply( + intrinsicWeakMapGet, + scopedInstances, + [instance], + ) !== undefined + ) { + throw new IntrinsicError( + "scoped kernel entry instances cannot bind allocator ownership", + ); + } + const record = ( + intrinsicApply( + intrinsicWeakMapGet, + gatedInstances, + [instance], + ) as GatedInstanceRecord | undefined + ); + const rawInstance = record?.rawInstance ?? instance; + // Reject a prototype-forged nominal object and a Proxy around a genuine + // instance. Only the intrinsic getter can prove the exact receiver carries + // the engine's internal WebAssembly.Instance slots. + return intrinsicApply( + intrinsicInstanceExports, + rawInstance, + [], + ) as WebAssembly.Exports; +} + +/** + * Safe binding metadata for one callable export. The callable is the exact + * already-visible gated wrapper (or the raw callable only when the supplied + * instance itself is a genuine ungated instance); raw instance/function + * authority is never returned from a registered façade. + */ +export interface ValidatedKernelEntryCallable { + readonly call: Function; + readonly argumentCount: number; +} + +/** @internal Prove exact instance/Memory ownership without returning either raw receiver. */ +export function validateKernelEntryMemoryOwnership( + instance: WebAssembly.Instance, + memory: WebAssembly.Memory, +): void { + const scopedRecord = intrinsicApply( + intrinsicWeakMapGet, + scopedInstances, + [instance], + ) as GatedInstanceRecord | undefined; + const exports = scopedRecord === undefined + ? rawKernelEntryExports(instance) + : intrinsicApply( + intrinsicInstanceExports, + scopedRecord.rawInstance, + [], + ) as WebAssembly.Exports; + if (exports.memory !== memory) { + throw new IntrinsicError( + "kernel entry instance does not own the supplied WebAssembly.Memory", + ); + } +} + +/** @internal Snapshot one safe callable plus its genuine Wasm arity. */ +export function validatedKernelEntryCallable( + instance: WebAssembly.Instance, + name: string, +): ValidatedKernelEntryCallable | undefined { + if (typeof name !== "string") { + throw new IntrinsicError( + "kernel entry callable name must be a primitive string", + ); + } + const rawValue = rawKernelEntryExports(instance)[name]; + if (typeof rawValue !== "function") return undefined; + const callable = validatedKernelEntryExports(instance)[name]; + if (typeof callable !== "function") { + throw new IntrinsicError( + `registered kernel entry export ${name} lost its callable façade`, + ); + } + const lengthDescriptor = + intrinsicObjectGetOwnPropertyDescriptor(rawValue, "length"); + const argumentCount = lengthDescriptor?.value; + if ( + typeof argumentCount !== "number" + || !intrinsicNumberIsSafeInteger(argumentCount) + || argumentCount < 0 + ) { + throw new IntrinsicError( + `kernel entry export ${name} has an invalid Wasm arity`, + ); + } + return intrinsicObjectFreeze({ call: callable, argumentCount }); +} + +/** + * Prove that a callable selected from a short-lived scoped façade belongs to + * the same generation as a persistent allocation owner. + * + * No gate, raw instance, exports namespace, or replacement callable is + * returned. Scratch allocation uses this only to bind a scoped allocator call + * to the persistent instance that will own the resulting region. + */ +export function validateKernelScratchAllocatorOwnership( + ownerInstance: WebAssembly.Instance, + callableInstance: WebAssembly.Instance, + callable: Function, +): void { + const name = "kernel_alloc_scratch"; + if (ownerInstance === callableInstance) { + if (validatedKernelEntryCallable(ownerInstance, name)?.call !== callable) { + throw new IntrinsicError( + `kernel entry callable ${name} does not belong to its owner`, + ); + } + return; + } + const ownerRecord = intrinsicApply( + intrinsicWeakMapGet, + gatedInstances, + [ownerInstance], + ) as GatedInstanceRecord | undefined; + const callableRecord = intrinsicApply( + intrinsicWeakMapGet, + scopedInstances, + [callableInstance], + ) as GatedInstanceRecord | undefined; + if ( + ownerRecord === undefined + || callableRecord === undefined + || ownerRecord !== callableRecord + ) { + throw new IntrinsicError( + `kernel entry callable ${name} belongs to another generation`, + ); + } + const descriptor = intrinsicObjectGetOwnPropertyDescriptor( + callableInstance, + "exports", + ); + if ( + descriptor === undefined + || descriptor.get !== undefined + || descriptor.value === undefined + ) { + throw new IntrinsicError( + "registered scoped kernel instance lost its exports façade", + ); + } + const scopedExports = descriptor.value as WebAssembly.Exports; + if ( + intrinsicReflectGet(scopedExports, name, scopedExports) !== callable + ) { + throw new IntrinsicError( + `kernel entry callable ${name} is not the scoped export`, + ); + } +} + +/** + * @internal Prove that an instance is the exact immutable façade registered + * for one kernel entry generation. This returns no gate or invocation + * capability. + */ +export function validateKernelEntryGatedInstance( + instance: WebAssembly.Instance, +): void { + if ( + intrinsicApply( + intrinsicWeakMapGet, + gatedInstances, + [instance], + ) === undefined + ) { + throw new IntrinsicError( + "kernel instance is not a registered entry-gated façade", + ); + } + validatedKernelEntryExports(instance); +} + +/** + * @internal Test initialization may supply a gate it already owns. Validate + * that candidate without returning the registered generation's authority. + */ +export function validateKernelEntryGateOwnership( + instance: WebAssembly.Instance, + gate: KernelEntryGate, +): void { + const record = ( + intrinsicApply( + intrinsicWeakMapGet, + gatedInstances, + [instance], + ) as GatedInstanceRecord | undefined + ); + if ( + record === undefined + || record.gate !== gate + || !intrinsicApply( + intrinsicWeakSetHas, + exactKernelEntryGates, + [gate], + ) + ) { + throw new IntrinsicError( + "kernel entry gate does not own the supplied instance", + ); + } + validatedKernelEntryExports(instance); +} diff --git a/host/src/kernel-scratch.ts b/host/src/kernel-scratch.ts index ac5f6b855a..107ab0edbe 100644 --- a/host/src/kernel-scratch.ts +++ b/host/src/kernel-scratch.ts @@ -10,6 +10,14 @@ import { checkedWasmGuestPointerOffset, } from "./wasm-guest-pointer"; +import { + invokeKernelEntryScopedOperation, + type KernelVoidIngressScope, + validatedKernelEntryCallable, + validateKernelEntryMemoryOwnership, + validateKernelEntryGatedInstance, + validateKernelScratchAllocatorOwnership, +} from "./kernel-entry-gate"; export type WasmPointer = number | bigint; export type WasmPointerWidth = 4 | 8; @@ -37,10 +45,6 @@ const intrinsicObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; const intrinsicWeakMapGet = WeakMap.prototype.get; const intrinsicWeakMapSet = WeakMap.prototype.set; -const intrinsicInstanceExports = intrinsicObjectGetOwnPropertyDescriptor( - WebAssembly.Instance.prototype, - "exports", -)!.get!; const intrinsicMemoryBuffer = intrinsicObjectGetOwnPropertyDescriptor( WebAssembly.Memory.prototype, "buffer", @@ -57,6 +61,13 @@ const intrinsicSharedArrayBufferByteLength = SharedArrayBuffer.prototype, "byteLength", )!.get!; +type IntrinsicBufferByteLengthGetter = ( + this: ArrayBufferLike, +) => number; +const intrinsicBufferByteLengthGetters = new WeakMap< + object, + IntrinsicBufferByteLengthGetter +>(); const intrinsicDataViewPrototype = IntrinsicDataView.prototype; const intrinsicDataViewByteLength = intrinsicObjectGetOwnPropertyDescriptor( intrinsicDataViewPrototype, @@ -99,17 +110,20 @@ const typedArrayByteLength = intrinsicObjectGetOwnPropertyDescriptor( )!.get!; /** - * Kernel exports whose raw pointer arguments may name a scratch lease. + * Kernel exports whose execution may borrow one active scratch lease. * * WHY: this is deliberately a narrow lifetime allowlist, not a list of every * kernel export. Each Rust implementation was reviewed to consume or copy its * borrowed bytes before returning. `kernel_handle_channel` scopes its raw * mailbox view to decoding/publishing and clears the active task binding; * `kernel_spawn_process` parses the complete blob into owned Rust values - * before it enters process-table or host work. Adding a name requires the same - * lifetime review and a pointer-position update below. + * before it enters process-table or host work. The transfer execute export + * names no raw pointer, but its token authorizes Rust to borrow the allocation + * represented by this exact lease. Adding a name requires the same lifetime + * review and a pointer-position update below. */ -const KERNEL_SCRATCH_EXPORT_NAMES = intrinsicObjectFreeze([ +/** @internal Exported only for the Rust/host semantic-role drift contract. */ +export const KERNEL_SCRATCH_EXPORT_NAMES = intrinsicObjectFreeze([ "kernel_dequeue_signal", "kernel_drain_audio", "kernel_drain_wakeup_events", @@ -136,10 +150,13 @@ const KERNEL_SCRATCH_EXPORT_NAMES = intrinsicObjectFreeze([ "kernel_select", "kernel_send", "kernel_set_cwd", + "kernel_setsockopt", "kernel_socketpair", "kernel_spawn_process", "kernel_tcgetattr", "kernel_tcsetattr", + "kernel_transfer_channel_execute", + "kernel_transfer_io_execute", "kernel_truncate", "kernel_uname", "kernel_wait_child_poll", @@ -173,6 +190,7 @@ type KernelScratchExportFunction = (...args: never[]) => unknown; interface KernelScratchExportBinding { readonly call: KernelScratchExportFunction; readonly argumentCount: number; + readonly instance: WebAssembly.Instance; } type KernelScratchExportSnapshot = Readonly< Partial> @@ -187,7 +205,8 @@ const REQUIRED_POINTER_5 = intrinsicObjectFreeze([5] as const); const REQUIRED_POINTER_11 = intrinsicObjectFreeze([11] as const); const NULLABLE_POINTER_1_3_5 = intrinsicObjectFreeze([1, 3, 5] as const); -function kernelScratchRequiredPointerArguments( +/** @internal Exported only for the Rust/host semantic-role drift contract. */ +export function kernelScratchRequiredPointerArguments( name: KernelScratchExportName, ): readonly number[] { switch (name) { @@ -222,6 +241,7 @@ function kernelScratchRequiredPointerArguments( case "kernel_spawn_process": case "kernel_tcsetattr": return REQUIRED_POINTER_2; + case "kernel_setsockopt": case "kernel_socketpair": return REQUIRED_POINTER_3; case "kernel_getsockopt": @@ -230,12 +250,15 @@ function kernelScratchRequiredPointerArguments( return REQUIRED_POINTER_5; case "kernel_inject_datagram": return REQUIRED_POINTER_11; + case "kernel_transfer_channel_execute": + case "kernel_transfer_io_execute": case "kernel_select": return []; } } -function kernelScratchNullablePointerArguments( +/** @internal Exported only for the Rust/host semantic-role drift contract. */ +export function kernelScratchNullablePointerArguments( name: KernelScratchExportName, ): readonly number[] { return name === "kernel_select" ? NULLABLE_POINTER_1_3_5 : []; @@ -285,10 +308,13 @@ function isKernelScratchExportName( case "kernel_select": case "kernel_send": case "kernel_set_cwd": + case "kernel_setsockopt": case "kernel_socketpair": case "kernel_spawn_process": case "kernel_tcgetattr": case "kernel_tcsetattr": + case "kernel_transfer_channel_execute": + case "kernel_transfer_io_execute": case "kernel_truncate": case "kernel_uname": case "kernel_wait_child_poll": @@ -303,59 +329,39 @@ function snapshotKernelScratchExports( memory: WebAssembly.Memory, label: string, expectedAllocator?: KernelScratchAllocator, + allocatorInstance: WebAssembly.Instance = instance, ): KernelScratchExportSnapshot { - let exports: WebAssembly.Exports; try { - // WHY: structural objects with an `exports` field are not sufficient. - // Calling the captured intrinsic getter proves the receiver is a genuine - // WebAssembly.Instance before any allocator or host callback can run. - exports = intrinsicApply( - intrinsicInstanceExports, - instance, - [], - ) as WebAssembly.Exports; + validateKernelEntryMemoryOwnership(instance, memory); } catch { - throw new KernelScratchError( - `${label} export binding is not a genuine WebAssembly.Instance`, - ); - } - if (exports.memory !== memory) { throw new KernelScratchError( `${label} export binding does not own the supplied WebAssembly.Memory`, ); } - if ( - expectedAllocator !== undefined - && exports.kernel_alloc_scratch !== expectedAllocator - ) { - throw new KernelScratchError( - `${label} allocator is not the bound instance's kernel allocator`, - ); + if (expectedAllocator !== undefined) { + try { + validateKernelScratchAllocatorOwnership( + instance, + allocatorInstance, + expectedAllocator, + ); + } catch { + throw new KernelScratchError( + `${label} allocator is not the bound instance's kernel allocator`, + ); + } } const snapshot = intrinsicObjectCreate(null) as Partial< Record >; for (let index = 0; index < KERNEL_SCRATCH_EXPORT_NAMES.length; index++) { const name = KERNEL_SCRATCH_EXPORT_NAMES[index]; - const value = exports[name]; - if (typeof value === "function") { - const lengthDescriptor = intrinsicObjectGetOwnPropertyDescriptor( - value, - "length", - ); - const argumentCount = lengthDescriptor?.value; - if ( - typeof argumentCount !== "number" - || !intrinsicNumberIsSafeInteger(argumentCount) - || argumentCount < 0 - ) { - throw new KernelScratchError( - `${label} kernel export ${name} has an invalid Wasm arity`, - ); - } + const binding = validatedKernelEntryCallable(instance, name); + if (binding !== undefined) { snapshot[name] = intrinsicObjectFreeze({ - call: value as KernelScratchExportFunction, - argumentCount, + call: binding.call as KernelScratchExportFunction, + argumentCount: binding.argumentCount, + instance, }); } } @@ -397,20 +403,43 @@ function intrinsicBufferByteLength( buffer: ArrayBufferLike, field: string, ): number { + const cachedGetter = intrinsicApply( + intrinsicWeakMapGet, + intrinsicBufferByteLengthGetters, + [buffer], + ) as IntrinsicBufferByteLengthGetter | undefined; + if (cachedGetter !== undefined) { + // WHY: cache only the proven intrinsic, never its result. Growable memory + // still needs a live length read, while a shared buffer must not throw + // through the ArrayBuffer getter on every scratch-range check. + return intrinsicApply(cachedGetter, buffer, []) as number; + } try { - return intrinsicApply( + const byteLength = intrinsicApply( intrinsicArrayBufferByteLength, buffer, [], ) as number; + intrinsicApply( + intrinsicWeakMapSet, + intrinsicBufferByteLengthGetters, + [buffer, intrinsicArrayBufferByteLength], + ); + return byteLength; } catch { if (intrinsicSharedArrayBufferByteLength !== null) { try { - return intrinsicApply( + const byteLength = intrinsicApply( intrinsicSharedArrayBufferByteLength, buffer, [], ) as number; + intrinsicApply( + intrinsicWeakMapSet, + intrinsicBufferByteLengthGetters, + [buffer, intrinsicSharedArrayBufferByteLength], + ); + return byteLength; } catch { // Fall through to the one checked error below. } @@ -1096,6 +1125,19 @@ export interface KernelScratchLease { | KernelScratchExportPointer )[], ): number; + /** + * Invoke after validating every argument, then consume one exact opaque + * void-ingress token immediately before the genuine gated export. + */ + invokeKernelExportScoped( + scope: KernelVoidIngressScope, + name: KernelScratchExportName, + args: readonly ( + | number + | bigint + | KernelScratchExportPointer + )[], + ): number; /** * Encode the checked address of one source range into another owned range. * @@ -1430,6 +1472,30 @@ class ActiveKernelScratchLease implements KernelScratchLease { | bigint | KernelScratchExportPointer )[], + ): number { + return this.#invokeKernelExport(undefined, name, args); + } + + invokeKernelExportScoped( + scope: KernelVoidIngressScope, + name: KernelScratchExportName, + args: readonly ( + | number + | bigint + | KernelScratchExportPointer + )[], + ): number { + return this.#invokeKernelExport(scope, name, args); + } + + #invokeKernelExport( + scope: KernelVoidIngressScope | undefined, + name: KernelScratchExportName, + args: readonly ( + | number + | bigint + | KernelScratchExportPointer + )[], ): number { this.#assertValid(); if (typeof name !== "string" || !isKernelScratchExportName(name)) { @@ -1646,11 +1712,22 @@ class ActiveKernelScratchLease implements KernelScratchLease { validatePointerCapacities(requiredPointers); validatePointerCapacities(nullablePointers); - const result = intrinsicApply( + // WHY: the snapshot retains either the genuine raw Wasm function or the + // exact registered façade wrapper. The latter re-enters through its + // private gate without exposing that gate or a raw invocation closure + // to the scratch layer. + const invoke = () => intrinsicApply( kernelExport.call, undefined, convertedArgs, ); + const result = scope === undefined + ? invoke() + : invokeKernelEntryScopedOperation( + scope, + kernelExport.instance, + invoke, + ); if (typeof result !== "number") { throw new KernelScratchError( `${this.#label} kernel export ${name} returned a non-number`, @@ -1897,6 +1974,15 @@ export interface KernelScratchRegion { revoke(): void; } +interface OwnedKernelScratchRegionOwnership { + readonly memory: WebAssembly.Memory; + readonly pointerWidth: WasmPointerWidth; + readonly instance: WebAssembly.Instance | null; +} + +const ownedKernelScratchRegionOwnerships = + new WeakMap(); + /** * Pointer plus declared capacity for one kernel-owned allocation. * @@ -1961,6 +2047,7 @@ class OwnedKernelScratchRegion implements KernelScratchRegion { pointerWidth: WasmPointerWidth, label: string, kernelInstance?: WebAssembly.Instance, + allocatorInstance?: WebAssembly.Instance, ): OwnedKernelScratchRegion { if (constructorKey !== ownedKernelScratchRegionConstructorKey) { throw new KernelScratchError( @@ -1979,6 +2066,7 @@ class OwnedKernelScratchRegion implements KernelScratchRegion { memory, label, allocator, + allocatorInstance, ); const capacity = exactNonNegativeInteger( capacityValue, @@ -2001,7 +2089,7 @@ class OwnedKernelScratchRegion implements KernelScratchRegion { throw new KernelScratchError(`${label} allocation failed`); } checkedMemoryRange(memory, pointer, capacity, pointerWidth, label); - return new OwnedKernelScratchRegion( + const region = new OwnedKernelScratchRegion( ownedKernelScratchRegionConstructorKey, memory, pointer, @@ -2011,6 +2099,19 @@ class OwnedKernelScratchRegion implements KernelScratchRegion { "reusable", kernelExports, ); + intrinsicApply( + intrinsicWeakMapSet, + ownedKernelScratchRegionOwnerships, + [ + region, + intrinsicObjectFreeze({ + memory, + pointerWidth, + instance: kernelInstance ?? null, + }), + ], + ); + return region; } static reserve( @@ -2044,6 +2145,17 @@ class OwnedKernelScratchRegion implements KernelScratchRegion { `${label} minimum capacity must be positive`, ); } + if ( + pointerWidth === 4 + && minimumCapacity > WASM32_MAX_POINTER + ) { + // WHY: the reservation export consumes a wasm32 usize. Reject before + // invoking it so JavaScript-to-Wasm i32 coercion cannot silently replace + // an oversized requested capacity with its low 32 bits. + throw new KernelScratchError( + `${label} minimum capacity does not fit a wasm32 usize`, + ); + } const reservation = reserver(minimumCapacity); const capacity = exactNonNegativeInteger( reservation.capacity, @@ -2063,7 +2175,7 @@ class OwnedKernelScratchRegion implements KernelScratchRegion { throw new KernelScratchError(`${label} reservation failed`); } checkedMemoryRange(memory, pointer, capacity, pointerWidth, label); - return new OwnedKernelScratchRegion( + const region = new OwnedKernelScratchRegion( ownedKernelScratchRegionConstructorKey, memory, pointer, @@ -2073,6 +2185,19 @@ class OwnedKernelScratchRegion implements KernelScratchRegion { "single-use", kernelExports, ); + intrinsicApply( + intrinsicWeakMapSet, + ownedKernelScratchRegionOwnerships, + [ + region, + intrinsicObjectFreeze({ + memory, + pointerWidth, + instance: kernelInstance ?? null, + }), + ], + ); + return region; } #assertActiveLease(token: object): void { @@ -2200,6 +2325,7 @@ export function allocateKernelScratchRegion( pointerWidth: WasmPointerWidth, label: string, kernelInstance?: WebAssembly.Instance, + allocatorInstance?: WebAssembly.Instance, ): KernelScratchRegion { return OwnedKernelScratchRegion.allocate( ownedKernelScratchRegionConstructorKey, @@ -2209,6 +2335,7 @@ export function allocateKernelScratchRegion( pointerWidth, label, kernelInstance, + allocatorInstance, ); } @@ -2236,3 +2363,52 @@ export function reserveKernelScratchRegion( kernelInstance, ); } + +/** + * @internal Validate a test-injected region without exposing its pointer. + * + * WHY: `KernelScratchRegion` is intentionally structural for callers, so a + * shape check cannot prove allocator ownership. The module-private WeakMap + * records the exact genuine instance, Memory, and entry-gate generation at + * factory time. + */ +export function validateKernelScratchRegionOwnership( + region: KernelScratchRegion, + instance: WebAssembly.Instance, + label: string, +): { + readonly region: KernelScratchRegion; + readonly memory: WebAssembly.Memory; + readonly pointerWidth: WasmPointerWidth; +} { + const ownership = intrinsicApply( + intrinsicWeakMapGet, + ownedKernelScratchRegionOwnerships, + [region as object], + ) as OwnedKernelScratchRegionOwnership | undefined; + if (ownership === undefined) { + throw new KernelScratchError( + `${label} is not an allocator-created kernel scratch region`, + ); + } + try { + validateKernelEntryGatedInstance(instance); + } catch { + throw new KernelScratchError( + `${label} instance is not a registered gated kernel generation`, + ); + } + if (ownership.instance !== instance) { + throw new KernelScratchError( + `${label} belongs to a different kernel generation`, + ); + } + // Re-prove the current engine Memory receiver before returning it to the + // owning worker. A replaced structural value cannot satisfy this getter. + intrinsicWasmMemoryBuffer(ownership.memory, `${label} memory`); + return intrinsicObjectFreeze({ + region, + memory: ownership.memory, + pointerWidth: ownership.pointerWidth, + }); +} diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index 6b2d398a66..77eda05de7 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -25,7 +25,22 @@ * explanatory only and generated-file drift tests cover the live values. */ -import { negErrno, WasmPosixKernel, type KernelPointer } from "./kernel"; +import { + getWasmPosixKernelRuntimeAccess, + negErrno, + WasmPosixKernel, + type KernelPointer, +} from "./kernel"; +import { + createKernelEntryScopedInstance, + invokeKernelEntrySerializedHostOperation, + isKernelExportFailure, + KernelEntryGate, + KernelReentrantEntryError, + type KernelEntryEffectRegistrar, + type KernelVoidIngressScope, + validateKernelEntryGateOwnership, +} from "./kernel-entry-gate"; import { allocateKernelScratchRegion, checkedKernelExportPointer, @@ -35,8 +50,24 @@ import { intrinsicUint8ArrayView, KernelScratchError, reserveKernelScratchRegion, + validateKernelScratchRegionOwnership, + type KernelScratchExportName, + type KernelScratchExportPointer, + type KernelScratchLease, type KernelScratchRegion, } from "./kernel-scratch"; +import { + canonicalGuestUnsignedScalar, + CHANNEL_SCALAR_SLOT_CONTRACTS, + channelDiagnosticArguments, + channelResultKind, + normalizeChannelScalarArguments, + type ChannelScalarValue, +} from "./channel-scalar-contract"; +import { + reapHostOwnedExitedProcess as reapHostOwnedExitedProcessFromKernel, + type HostOwnedProcessReapResult, +} from "./host-owned-process-reap"; import { buildRawHttpRequest, parseRawHttpResponse, @@ -61,12 +92,16 @@ import { CH_RETURN, CH_SIG_AREA_SIZE, CH_SIG_BASE, + CH_SIG_FLAGS, CH_SIG_HANDLER, CH_SIG_SI_CODE, CH_SIG_SIGNUM, CH_STATUS, CH_SYSCALL, CH_TOTAL_SIZE, + CHANNEL_REQUEST_FLAG_CANCELLATION_POINT, + CHANNEL_REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED, + CHANNEL_REQUEST_FLAGS_KNOWN_MASK, FCNTL_FLOCK_BYTES, HOST_INTERCEPTED_SYSCALLS, IOCTL_REQUESTS, @@ -82,7 +117,6 @@ import { KERNEL_CMSGHDR_WIRE_LEN_OFFSET, KERNEL_CMSGHDR_WIRE_LEVEL_OFFSET, KERNEL_CMSGHDR_WIRE_TYPE_OFFSET, - KERNEL_IOVEC_WIRE_ALIGN, KERNEL_IOVEC_WIRE_BASE_OFFSET, KERNEL_IOVEC_WIRE_LEN_OFFSET, KERNEL_MESSAGE_WIRE_FLATTENED_IOVEC_COUNT, @@ -95,6 +129,9 @@ import { KERNEL_MSGHDR_WIRE_NAME_OFFSET, KERNEL_MSGHDR_WIRE_NAMELEN_OFFSET, KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES, + KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, + KERNEL_SCRATCH_SOCKET_OPTION_INPUT_MAX_BYTES, + KERNEL_SCRATCH_SOCKET_OPTION_MAX_BYTES, KERNEL_SCRATCH_SIGNAL_DELIVERY_BYTES, SOCKET_MSG_TRUNC, PROCESS_STATE_EXITED, @@ -102,7 +139,12 @@ import { PROCESS_STATE_STOPPED, POSIX_ARG_MAX_BYTES, POSIX_IOV_MAX, + POSIX_NAME_MAX_BYTES, + POSIX_NGROUPS_MAX, POSIX_PATH_MAX_BYTES, + MAX_REPORTABLE_TRANSFER_BYTES, + MAX_TRANSFER_ALLOCATION_BYTES, + PROCESS_METADATA_ENTRY_MAX_BYTES, PROCESS_CMSGHDR_WASM32_ALIGN, PROCESS_CMSGHDR_WASM32_DATA_OFFSET, PROCESS_CMSGHDR_WASM32_LEN_OFFSET, @@ -155,6 +197,7 @@ import { SCHED_AFFINITY_MASK_SIZE, SELECT_FD_SET_BYTES, SELECT_FD_SETSIZE, + SIGNAL_ACTION_RESTART, SIGNAL_MASK_BYTES, SOCKET_SCM_RIGHTS, SOCKET_SOL_SOCKET, @@ -210,6 +253,64 @@ import { EXEC_RETIRE_SIGNAL_CODE } from "./worker-protocol"; import type { KernelConfig, NetworkAddress, PlatformIO, TcpConnectionPeer, UdpDatagram } from "./types"; +// WHY: kernel exports can synchronously reach hostile host hooks. Capture the +// mutable intrinsics used by the split scoped/detached entry protocol before +// any such hook can replace a prototype method and execute a host effect while +// the Wasm scope is still live. +const kernelEntryIntrinsicApply = Reflect.apply; +const kernelEntryIntrinsicArrayPush = Array.prototype.push; +const KernelEntryIntrinsicDataView = DataView; +const KernelEntryIntrinsicInt32Array = Int32Array; +const KernelEntryIntrinsicBigInt = BigInt; +const kernelEntryIntrinsicConsoleError = console.error; +const kernelEntryIntrinsicError = Error; +const kernelEntryIntrinsicConstruct = Reflect.construct; +const kernelEntryIntrinsicObjectCreate = Object.create; +const kernelEntryIntrinsicObjectDefineProperty = Object.defineProperty; +const kernelEntryIntrinsicObjectEntries = Object.entries; +const kernelEntryIntrinsicObjectFreeze = Object.freeze; +const kernelEntryIntrinsicObjectGetOwnPropertyDescriptor = + Object.getOwnPropertyDescriptor; +const kernelEntryIntrinsicObjectSeal = Object.seal; +const kernelEntryIntrinsicMemoryBuffer = + kernelEntryIntrinsicObjectGetOwnPropertyDescriptor( + WebAssembly.Memory.prototype, + "buffer", + )!.get!; +const kernelEntryIntrinsicDataViewSetBigInt64 = + KernelEntryIntrinsicDataView.prototype.setBigInt64; +const kernelEntryIntrinsicDataViewSetUint32 = + KernelEntryIntrinsicDataView.prototype.setUint32; +const kernelEntryIntrinsicAtomics = Atomics; +const kernelEntryIntrinsicAtomicsLoad = Atomics.load; +const kernelEntryIntrinsicAtomicsStore = Atomics.store; +const kernelEntryIntrinsicAtomicsNotify = Atomics.notify; +const KERNEL_ENTRY_I32_BYTES = 4; + +function kernelEntryMemoryBuffer( + memory: WebAssembly.Memory, +): ArrayBufferLike { + return kernelEntryIntrinsicApply( + kernelEntryIntrinsicMemoryBuffer, + memory, + [], + ) as ArrayBufferLike; +} + +function kernelEntryEffectFailure( + message: string, + cause: unknown, +): Error { + const error = new kernelEntryIntrinsicError(message); + kernelEntryIntrinsicObjectDefineProperty(error, "cause", { + configurable: true, + enumerable: false, + writable: true, + value: cause, + }); + return error; +} + function concatChunksLocal(chunks: Uint8Array[]): Uint8Array { if (chunks.length === 0) return new Uint8Array(0); if (chunks.length === 1) return chunks[0]!; @@ -291,6 +392,7 @@ const EEXIST = 17; const EFAULT = 14; const EIO = 5; const EINVAL = 22; +const EMSGSIZE = 90; const EOVERFLOW = 75; const ENODEV = 19; const ENOMEM = 12; @@ -299,6 +401,7 @@ const ENOENT = 2; const ENOSYS = 38; const ENOTSUP = 95; const ETIMEDOUT = 110; +const EHOSTUNREACH = 113; const EALREADY = 114; const EINPROGRESS = 115; const EINTR_ERRNO = 4; @@ -316,15 +419,81 @@ class KernelTaskBindingError extends Error { } } +/** + * A JavaScript exception escaped while Rust held the global transfer + * reservation in Executing state. + * + * This error must escape the syscall entry point. Rust deliberately retains + * ownership of the Vec, but a trap prevents its Executing state and active + * borrow from reaching the normal Ready transition. Neither cancellation nor + * process-local teardown can prove that the kernel-wide token is reusable. + */ +class KernelTransferExecuteTrapError extends Error { + constructor(message: string, readonly trappedCause?: unknown) { + // Keep the native cause chain as well as the stable named field. Node and + // browser consoles then expose the actual missing export/Wasm trap in the + // normal fatal diagnostic instead of printing only this ownership wrapper. + super(message, { cause: trappedCause }); + this.name = "KernelTransferExecuteTrapError"; + } +} + +/** + * Rust returned from the process-exit commit boundary without proving the + * exact state the host is about to publish. + * + * The process table may already have closed descriptors and recorded a + * zombie, so converting this to one process's EIO would re-enter a generation + * whose host/kernel lifecycle views disagree. The complete generation must + * stop. + */ +class KernelExitCommitProtocolError extends Error { + constructor(message: string) { + super(message); + this.name = "KernelExitCommitProtocolError"; + } +} + +/** + * A failed shmat rollback leaves Rust attachment accounting and the process + * address space with no provable common owner. + * + * Converting this to one syscall's errno would let the generation continue + * after losing track of an attachment. The error must escape the entry scope + * so the gate poisons the complete generation. + */ +class KernelIpcShmatRollbackError extends Error { + constructor(message: string) { + super(message); + this.name = "KernelIpcShmatRollbackError"; + } +} + +/** + * Rust returned EAGAIN but the host cannot recover or consume the exact + * kernel-owned target that must be retained for the retry. + * + * Continuing this generation would either replay against a reused numeric + * descriptor/IPC id or leak the unreachable target pin. Both are kernel-wide + * protocol failures rather than one guest's recoverable I/O error. + */ +class KernelBlockingRetryProtocolError extends Error { + constructor(message: string) { + super(message); + this.name = "KernelBlockingRetryProtocolError"; + } +} + function cstringCopySize( memory: Uint8Array, ptr: number, capacity: number, + tooLongErrno: number, ): { size: number } | { errno: number } { if (!Number.isSafeInteger(ptr) || ptr <= 0 || ptr >= memory.length) { return { errno: EFAULT }; } - if (capacity <= 0) return { errno: ENAMETOOLONG }; + if (capacity <= 0) return { errno: tooLongErrno }; const memoryAvailable = memory.length - ptr; const scanLength = Math.min(memoryAvailable, capacity); @@ -332,10 +501,288 @@ function cstringCopySize( if (nul >= 0) return { size: nul + 1 }; return { - errno: memoryAvailable < capacity ? EFAULT : ENAMETOOLONG, + errno: memoryAvailable < capacity ? EFAULT : tooLongErrno, }; } +/** + * Validate caller bytes against the exact process-memory view that supplied + * the syscall notification. + * + * WHY: growing a shared WebAssembly.Memory replaces `memory.buffer` while old + * views retain the original length. Validating against a later buffer and then + * copying from this earlier view could silently stage a truncated prefix. + */ +function checkedProcessMemoryViewRange( + memory: Uint8Array, + pointerValue: number | bigint, + lengthValue: number | bigint, + pointerWidth: 4 | 8, + field: string, + allowAddressZero = false, +): { pointer: number; length: number; end: number } { + const pointer = canonicalGuestUnsignedScalar( + BigInt(pointerValue), + pointerWidth, + `${field} pointer`, + ); + const length = canonicalGuestUnsignedScalar( + BigInt(lengthValue), + pointerWidth, + `${field} length`, + ); + const range = checkedWasmAddressRange( + pointer, + length, + pointerWidth, + field, + ); + if (!allowAddressZero && range.pointer === 0 && range.length !== 0) { + throw new KernelScratchError(`${field} uses a null pointer`); + } + if (range.end > memory.byteLength) { + throw new KernelScratchError( + `${field} is outside its captured process-memory generation`, + ); + } + return range; +} + +function checkedAlignUp( + value: number, + alignment: number, + field: string, +): number { + if ( + !Number.isSafeInteger(value) + || value < 0 + || !Number.isSafeInteger(alignment) + || alignment <= 0 + ) { + throw new KernelScratchError(`${field} is not a safe nonnegative integer`, EINVAL); + } + const remainder = value % alignment; + const aligned = remainder === 0 ? value : value + alignment - remainder; + if (!Number.isSafeInteger(aligned) || aligned < value) { + throw new KernelScratchError(`${field} alignment overflows`, EINVAL); + } + return aligned; +} + +/** + * Complete-result operations whose public caller capacity can exceed the + * largest result Kandelo can produce. This is not permission to return a + * prefix: the generated platform maximum proves the complete result fits. + */ +function boundedChannelArgumentSize( + syscallNr: number, + argIndex: number, +): number | undefined { + if (syscallNr === ABI_SYSCALLS.Getcwd && argIndex === 0) { + return POSIX_PATH_MAX_BYTES; + } + if (syscallNr === ABI_SYSCALLS.Realpath && argIndex === 1) { + return POSIX_PATH_MAX_BYTES; + } + if (syscallNr === ABI_SYSCALLS.Readdir && argIndex === 2) { + return POSIX_NAME_MAX_BYTES; + } + if (syscallNr === ABI_SYSCALLS.GetEnv && argIndex === 1) { + return PROCESS_METADATA_ENTRY_MAX_BYTES; + } + if ( + ( + syscallNr === ABI_SYSCALLS.Recv + || syscallNr === ABI_SYSCALLS.Recvfrom + || syscallNr === ABI_SYSCALLS.MqTimedreceive + ) + && argIndex === 1 + ) { + return MAX_REPORTABLE_TRANSFER_BYTES; + } + return undefined; +} + +/** The only generic operations for which an internal short count is legal. */ +function shortChannelArgumentSize( + syscallNr: number, + argIndex: number, +): number | undefined { + if ( + (syscallNr === ABI_SYSCALLS.Getrandom && argIndex === 0) + || (syscallNr === ABI_SYSCALLS.Getdents64 && argIndex === 1) + ) { + return CH_DATA_SIZE; + } + return undefined; +} + +/** + * Return a proven ceiling for a process-size scalar before projecting it to a + * JavaScript Number. A wasm64 caller may supply a capacity above 2^53 even + * though the complete result is bounded by a much smaller platform maximum. + */ +function semanticChannelProcessSizeCeiling( + syscallNr: number, + sizeArgIndex: number, +): number | undefined { + const descriptors = SYSCALL_ARGS[syscallNr]; + if (!descriptors) return undefined; + + let ceiling: number | undefined; + for (const descriptor of descriptors) { + if ( + descriptor.size.type !== "arg" + || descriptor.size.argIndex !== sizeArgIndex + || (descriptor.size.multiplier ?? 1) !== 1 + || (descriptor.size.add ?? 0) !== 0 + ) { + continue; + } + const candidate = + shortChannelArgumentSize(syscallNr, descriptor.argIndex) + ?? boundedChannelArgumentSize(syscallNr, descriptor.argIndex); + if (candidate === undefined) { + return undefined; + } + ceiling = ceiling === undefined ? candidate : Math.min(ceiling, candidate); + } + return ceiling; +} + +function dereferencedChannelOutputMaximum( + syscallNr: number, + argIndex: number, +): number | undefined { + if ( + ( + syscallNr === SYS_ACCEPT + || syscallNr === SYS_ACCEPT4 + || syscallNr === SYS_RECVFROM + || syscallNr === ABI_SYSCALLS.Getsockname + || syscallNr === ABI_SYSCALLS.Getpeername + ) + && (argIndex === 1 || argIndex === 4) + ) { + return KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES; + } + if (syscallNr === ABI_SYSCALLS.Getsockopt && argIndex === 3) { + return KERNEL_SCRATCH_SOCKET_OPTION_MAX_BYTES; + } + return undefined; +} + +function applyNullableDereferencePairPresence( + descriptors: SyscallArgDesc[], + rawArgs: readonly bigint[], + adjustedArgs: ChannelScalarValue[], +): SyscallArgDesc[] { + let ignoredLengthArgs: number[] | undefined; + for (const descriptor of descriptors) { + if ( + descriptor.size.type !== "deref" + || descriptor.nullable !== true + || rawArgs[descriptor.argIndex] !== 0n + ) { + continue; + } + const lengthArgIndex = descriptor.size.argIndex; + const hasPresentConsumer = descriptors.some((candidate) => + candidate.size.type === "deref" + && candidate.size.argIndex === lengthArgIndex + && rawArgs[candidate.argIndex] !== 0n + ); + if (!hasPresentConsumer) { + ignoredLengthArgs ??= []; + if (!ignoredLengthArgs.includes(lengthArgIndex)) { + ignoredLengthArgs.push(lengthArgIndex); + } + } + } + if (ignoredLengthArgs === undefined) return descriptors; + + for (const index of ignoredLengthArgs) { + // WHY: a nullable value-result buffer controls whether its paired length + // pointer participates at all. Canonicalize the absent pair before either + // side can read ignored caller memory; Rust then validates the same null + // pair without learning a process-space pointer. + adjustedArgs[index] = 0; + } + return descriptors.filter( + (descriptor) => !ignoredLengthArgs.includes(descriptor.argIndex), + ); +} + +function validateCompleteChannelInputSize( + syscallNr: number, + argIndex: number, + size: number, +): void { + const isSocketAddress = + (syscallNr === ABI_SYSCALLS.Bind || syscallNr === SYS_CONNECT) + && argIndex === 1 + || syscallNr === SYS_SENDTO && argIndex === 4; + if (isSocketAddress) validateSocketAddressInputSize(size); + if ( + syscallNr === ABI_SYSCALLS.Setsockopt + && argIndex === 3 + && size > KERNEL_SCRATCH_SOCKET_OPTION_INPUT_MAX_BYTES + ) { + throw new KernelScratchError( + "socket option exceeds the supported native maximum", + EINVAL, + ); + } + if ( + syscallNr === ABI_SYSCALLS.Setgroups + && argIndex === 1 + && size > POSIX_NGROUPS_MAX * 4 + ) { + throw new KernelScratchError( + "supplementary group count exceeds NGROUPS_MAX", + EINVAL, + ); + } + if ( + ( + syscallNr === ABI_SYSCALLS.Send + || syscallNr === ABI_SYSCALLS.Sendto + ) + && ( + argIndex === 1 + || ( + syscallNr === ABI_SYSCALLS.Sendto + && argIndex === 1 + ) + ) + && size > MAX_REPORTABLE_TRANSFER_BYTES + ) { + throw new KernelScratchError( + "transfer byte count exceeds the channel result domain", + EINVAL, + ); + } + if ( + syscallNr === ABI_SYSCALLS.MqTimedsend + && argIndex === 1 + && size > MAX_REPORTABLE_TRANSFER_BYTES + ) { + throw new KernelScratchError( + "message exceeds the maximum reportable queue message size", + EMSGSIZE, + ); + } +} + +function validateSocketAddressInputSize(size: number): void { + if (size > KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES) { + throw new KernelScratchError( + "socket address exceeds sockaddr_storage capacity", + EINVAL, + ); + } +} + function isValidMemoryRange( memory: Uint8Array, ptr: number, @@ -357,11 +804,6 @@ function isValidMemoryRange( const PROCESS_METADATA_ARGV = 0; const PROCESS_METADATA_ENVIRONMENT = 1; -// WHY: the current process-metadata replacement protocol transports one entry -// per ordinary channel scratch lease. This is an implementation transport -// ceiling derived from that allocation, not part of public POSIX ARG_MAX. -const PROCESS_METADATA_ENTRY_MAX_BYTES = CH_DATA_SIZE; - /** * Largest complete SYS_SPAWN wire blob accepted by the host. * @@ -460,7 +902,11 @@ const SYS_MUNMAP = ABI_SYSCALLS.Munmap; const SYS_MPROTECT = ABI_SYSCALLS.Mprotect; const SYS_BRK = ABI_SYSCALLS.Brk; const SYS_MREMAP = ABI_SYSCALLS.Mremap; +const SYS_MADVISE = ABI_SYSCALLS.Madvise; const SYS_MSYNC = ABI_SYSCALLS.Msync; +const SYS_MLOCK = ABI_SYSCALLS.Mlock; +const SYS_MLOCK2 = ABI_SYSCALLS.Mlock2; +const SYS_MUNLOCK = ABI_SYSCALLS.Munlock; const SYS_WRITE = ABI_SYSCALLS.Write; const SYS_READ = ABI_SYSCALLS.Read; const SYS_PREAD = ABI_SYSCALLS.Pread; @@ -552,11 +998,14 @@ const SYS_FCNTL = ABI_SYSCALLS.Fcntl; const SYS_MSGRCV = ABI_SYSCALLS.Msgrcv; const SYS_MSGSND = ABI_SYSCALLS.Msgsnd; const SYS_MSGCTL = ABI_SYSCALLS.Msgctl; +const SYS_SEMOP = ABI_SYSCALLS.Semop; const SYS_SEMCTL = ABI_SYSCALLS.Semctl; const SYS_SHMAT = ABI_SYSCALLS.Shmat; const SYS_SHMDT = ABI_SYSCALLS.Shmdt; const SYS_SHMCTL = ABI_SYSCALLS.Shmctl; const IPC_NOWAIT = 0x800; +const SPLICE_F_NONBLOCK = 0x02; +const RWF_NOWAIT = 0x08; /** POSIX message queue syscall numbers */ const SYS_MQ_TIMEDSEND = ABI_SYSCALLS.MqTimedsend; @@ -608,6 +1057,94 @@ const WRITE_LIKE_SYSCALLS = new Set([ ABI_SYSCALLS.Sendfile, ]); +/** + * Generic-channel data transfers whose complete request must survive a host + * EAGAIN park. Vector/message transfers have dedicated snapshot kinds below. + */ +const GENERIC_BLOCKING_SNAPSHOT_SYSCALLS = new Set([ + ABI_SYSCALLS.Open, + ABI_SYSCALLS.Read, + ABI_SYSCALLS.Write, + ABI_SYSCALLS.Pread, + ABI_SYSCALLS.Pwrite, + ABI_SYSCALLS.Recv, + ABI_SYSCALLS.Send, + ABI_SYSCALLS.Recvfrom, + ABI_SYSCALLS.Sendto, + ABI_SYSCALLS.Accept, + ABI_SYSCALLS.Connect, + ABI_SYSCALLS.Poll, + ABI_SYSCALLS.Openat, + ABI_SYSCALLS.RtSigtimedwait, + ABI_SYSCALLS.Ppoll, + ABI_SYSCALLS.MqTimedsend, + ABI_SYSCALLS.MqTimedreceive, + ABI_SYSCALLS.Semop, + ABI_SYSCALLS.Sendfile, + ABI_SYSCALLS.CopyFileRange, + ABI_SYSCALLS.Splice, + ABI_SYSCALLS.Accept4, +]); + +/** + * Read semop's public IPC_NOWAIT policy only from the already-detached wire. + * + * WHY: the guest may mutate either the semid or any sembuf while parked. The + * first channel plan is the logical request; mailbox/process memory is never a + * policy source after Rust has returned EAGAIN and pinned the target. + */ +function plannedSemopHasNowait( + writes: readonly PlannedScratchWrite[], +): boolean { + const operations = writes.find((write) => write.argIndex === 1); + if ( + !operations + || operations.inputBytes === null + || operations.inputBytes.byteLength !== operations.size + || operations.size % 6 !== 0 + ) { + throw new KernelScratchError( + "semop plan does not contain a complete detached sembuf array", + EIO, + ); + } + for (let offset = 4; offset < operations.size; offset += 6) { + const flags = + operations.inputBytes[offset]! + | (operations.inputBytes[offset + 1]! << 8); + if ((flags & IPC_NOWAIT) !== 0) return true; + } + return false; +} + +/** + * Resolve request-local nonblocking policy from the first detached plan. + * + * A retry cannot consult the guest mailbox again: doing so would let a parked + * caller turn a blocking operation into a different terminal request (or vice + * versa). Descriptor-level O_NONBLOCK is handled separately while the first + * exact kernel entry is still active. + */ +function plannedRequestForbidsEagainRetry( + syscallNr: number, + adjustedArgs: readonly ChannelScalarValue[], + writes: readonly PlannedScratchWrite[], +): boolean { + if (syscallNr === SYS_SEMOP) { + // A zero-operation semop is a valid no-op and intentionally has no staged + // sembuf record. Rust returns success, so there is no EAGAIN policy to + // derive from bytes that do not exist. + if (adjustedArgs[2] === 0 || adjustedArgs[2] === 0n) return false; + return plannedSemopHasNowait(writes); + } + if (syscallNr !== SYS_SPLICE) return false; + const rawFlags = adjustedArgs[5] ?? 0; + const flags = typeof rawFlags === "bigint" + ? Number(BigInt.asUintN(32, rawFlags)) + : rawFlags >>> 0; + return (flags & SPLICE_F_NONBLOCK) !== 0; +} + function syscallHasMsgDontwait(syscallNr: number, args: number[]): boolean { let flags: number | undefined; switch (syscallNr) { @@ -626,6 +1163,16 @@ function syscallHasMsgDontwait(syscallNr: number, args: number[]): boolean { } return flags !== undefined && (flags & MSG_DONTWAIT) !== 0; } + +function vectorRequestForbidsEagainRetry( + syscallNr: number, + args: readonly number[], +): boolean { + return ( + syscallNr === SYS_PREADV2 + || syscallNr === SYS_PWRITEV2 + ) && ((args[5] ?? 0) & RWF_NOWAIT) !== 0; +} /** Scratch area layout in kernel Memory for kernel_handle_channel. * Same as channel layout but used as the kernel-side buffer. */ const SCRATCH_SIZE = CH_TOTAL_SIZE; @@ -649,6 +1196,7 @@ interface CheckedProcessIovecs { interface CheckedProcessMessage { pointerWidth: 4 | 8; messagePointer: number; + namePresent: boolean; name: { pointer: number; length: number }; control: { pointer: number; length: number }; iovecs: CheckedProcessIovecs; @@ -706,6 +1254,41 @@ interface PlannedScratchWrite { inputBytes: Uint8Array | null; } +interface PlannedChannelDispatchResult { + retVal: number; + publicationRetVal: ChannelScalarValue; + rawRetVal: bigint; + errVal: number; + outputWrites: ChannelOutputWrite[]; + sleepDelayMs: number | undefined; +} + +/** + * A complete, host-owned channel plan for a syscall whose first kernel pass + * may park on EAGAIN. + * + * WHY: the guest mailbox and every nested pointer it names remain mutable + * while the issuing thread sleeps. A retry must therefore stage only these + * detached values and bytes; re-reading either the mailbox or process tables + * would silently turn one logical syscall into a different operation. + */ +interface PlannedBlockingChannelDispatch { + readonly syscallNr: number; + readonly adjustedArgs: readonly ChannelScalarValue[]; + readonly plannedZeroLengthScratchArgMask: number; + readonly plannedScratchWrites: readonly PlannedScratchWrite[]; + readonly plannedChannelScratchArgs: readonly PlannedChannelScratchArg[]; + readonly plannedChannelCapacity: number; + readonly schedGetaffinityOutputInvalid: boolean; + readonly retryForbiddenByCallFlags: boolean; + /** + * Full caller timeout before any bounded per-attempt conversion. + * Present only for readiness syscalls whose public timeout lives outside + * the generated pointer descriptors. + */ + readonly readinessTimeoutMs?: number; +} + /** * One captured syscall, surfaced by the opt-in trace ring buffer * (enableSyscallTrace + drainSyscallTrace). Used by Kandelo Inspector → @@ -718,7 +1301,14 @@ export interface SyscallTraceEvent { /** Linux syscall number from `shared::Syscall`. */ nr: number; /** Raw arg values as the wasm program saw them. 6 entries, undefined slots are 0. */ - args: [number, number, number, number, number, number]; + args: [ + ChannelScalarValue, + ChannelScalarValue, + ChannelScalarValue, + ChannelScalarValue, + ChannelScalarValue, + ChannelScalarValue, + ]; /** Human-readable syscall entry, including decoded pointer arguments when available. */ decoded?: string; } @@ -932,6 +1522,34 @@ interface ChannelInfo { readinessFinalCheck?: boolean; } +/** + * Host-owned identity captured once from one PENDING mailbox request. + * + * The syscall number cannot encode whether libc entered through + * `__syscall_cp`: public and internal callers can issue the same number. This + * detached identity therefore travels with every host-owned park until + * completion. Cancellation-wake authority is independent: a cancellation + * point entered under PTHREAD_CANCEL_DISABLE must remain blocked normally. + */ +interface FrozenCancellationPointIdentity { + readonly cancellationPoint: boolean; + readonly cancellationWakeAllowed: boolean; +} + +interface FrozenChannelRequest extends FrozenCancellationPointIdentity { + readonly requestFlags: number; + readonly syscallNr: number; +} + +function isWakeableCancellationPoint< + T extends FrozenCancellationPointIdentity, +>( + identity: T | undefined, +): identity is T { + return identity?.cancellationPoint === true + && identity.cancellationWakeAllowed === true; +} + /** Info about a registered process. */ interface ProcessRegistration { pid: number; @@ -1024,6 +1642,50 @@ interface SysvShmMapping { seenVersion: number; } +interface PreparedInheritedSharedMapping { + readonly mapAddr: number; + readonly source: SharedMmapMapping; + readonly inherited: SharedMmapMapping; + readonly backing: AnonymousSharedMmapBacking | SharedMmapBacking; + readonly backingKind: "anonymous" | "file"; + readonly backingVersion: number; + readonly latest: Uint8Array; +} + +interface PreparedInheritedSysvMapping { + readonly mapAddr: number; + readonly source: SysvShmMapping; + readonly segId: number; + readonly size: number; + readonly readOnly: boolean; +} + +interface PreparedSharedMappingInheritance { + readonly parentPid: number; + readonly childPid: number; + readonly child: ProcessRegistration; + readonly childMemory: WebAssembly.Memory; + readonly parentSharedMap: Map | undefined; + readonly parentSharedEntries: + readonly (readonly [number, SharedMmapMapping])[]; + readonly parentSysvMap: Map | undefined; + readonly parentSysvEntries: + readonly (readonly [number, SysvShmMapping])[]; + readonly sharedMappings: readonly PreparedInheritedSharedMapping[]; + readonly sysvMappings: readonly PreparedInheritedSysvMapping[]; +} + +interface MaterializedInheritedSysvMapping + extends PreparedInheritedSysvMapping { + readonly latest: Uint8Array; + readonly seenVersion: number; +} + +interface MaterializedSharedMappingInheritance { + readonly prepared: PreparedSharedMappingInheritance; + readonly sysvMappings: readonly MaterializedInheritedSysvMapping[]; +} + interface RegisterProcessOptions { argv?: string[]; env?: string[]; @@ -1109,7 +1771,7 @@ function processSiginfoLayout(pointerWidth: number): ProcessSiginfoLayout { throw new Error(`unsupported process pointer width ${pointerWidth}`); } -interface WaitingForChild { +interface WaitingForChild extends FrozenCancellationPointIdentity { parentPid: number; channel: ChannelInfo; origArgs: number[]; @@ -1123,7 +1785,7 @@ type ChannelOutputWrite = { ptr: number; bytes: Uint8Array }; interface PreparedChannelCompletion { kind: "marshalled" | "raw"; outputWrites: ChannelOutputWrite[]; - retVal: number; + retVal: ChannelScalarValue; errVal: number; /** Output bytes/shared backing have reached guest-visible memory. */ materialized: boolean; @@ -1250,6 +1912,12 @@ interface PendingThreadChannelAttachment { attachedChannelOffset?: number; } +interface CloneRollbackState { + parentTidWritten: boolean; + cloneAttachment?: ThreadChannelAttachment; + pendingAttachment?: PendingThreadChannelAttachment; +} + // Module-private authority store: neither a caller nor a subclass can mint a // capability or install a forged WeakMap record through the public object. const pendingThreadChannelAttachments = @@ -1302,6 +1970,134 @@ interface ReservedSpawnScratch { token: bigint; } +interface ReservedTransferScratch { + // A token exists before its pointer/capacity can be validated. Retain it so + // every non-trapping path can settle the Rust reservation exactly once. + region: KernelScratchRegion | null; + token: bigint; +} + +interface FlattenedTransferRequest { + fd: number; + entries: readonly CheckedProcessIovec[]; + totalData: number; + read: boolean; + offset: bigint | null; + /** + * Complete detached write payload. Required for retryable writes so later + * attempts never consult mutable process buffers again. + */ + inputBytes?: Uint8Array; +} + +interface BlockingRetryDisposition extends FrozenCancellationPointIdentity { + /** Request-local flags such as MSG_DONTWAIT forbid a host-owned park. */ + readonly retryForbiddenByCallFlags: boolean; + /** Exact first-attempt descriptor policy; never re-resolve after fd reuse. */ + readonly fdWasNonblocking: boolean; + /** Exact first-attempt socket timeout, captured only for a blocking retry. */ + readonly applicableSocketTimeoutMs: number; +} + +interface GenericBlockingRetrySnapshot extends BlockingRetryDisposition { + readonly kind: "generic-channel"; + readonly syscallNr: number; + readonly origArgs: number[]; + readonly argDescs: SyscallArgDesc[] | undefined; + readonly dispatch: PlannedBlockingChannelDispatch; + readonly retryToken: bigint; +} + +interface FcntlLockBlockingRetrySnapshot + extends FrozenCancellationPointIdentity { + readonly kind: "fcntl-lock"; + readonly syscallNr: typeof SYS_FCNTL; + readonly origArgs: number[]; + readonly flockPointer: number; + readonly flockBytes: Uint8Array; + readonly retryToken: bigint; +} + +interface SelectBlockingRetrySnapshot + extends FrozenCancellationPointIdentity { + readonly kind: "select"; + readonly syscallNr: typeof SYS_SELECT | typeof SYS_PSELECT6; + readonly origArgs: number[]; + readonly nfds: number; + readonly readPointer: number; + readonly writePointer: number; + readonly exceptPointer: number; + readonly readBytes: Uint8Array | null; + readonly writeBytes: Uint8Array | null; + readonly exceptBytes: Uint8Array | null; + readonly timeoutMs: number; + readonly maskBytes: Uint8Array | null; + readonly retryToken: bigint; +} + +interface FlattenedBlockingRetrySnapshot extends BlockingRetryDisposition { + readonly kind: "flattened-transfer"; + readonly syscallNr: number; + readonly origArgs: number[]; + readonly request: FlattenedTransferRequest; + readonly retryToken: bigint; +} + +interface SendmsgBlockingRetrySnapshot extends BlockingRetryDisposition { + readonly kind: "sendmsg"; + readonly syscallNr: typeof SYS_SENDMSG; + readonly origArgs: number[]; + readonly message: CheckedProcessMessage; + readonly layout: KernelMessageLayout; + readonly totalCapacity: number; + readonly name: Uint8Array; + readonly control: Uint8Array; + readonly payload: Uint8Array; + readonly retryToken: bigint; +} + +interface RecvmsgBlockingRetrySnapshot extends BlockingRetryDisposition { + readonly kind: "recvmsg"; + readonly syscallNr: typeof SYS_RECVMSG; + readonly origArgs: number[]; + readonly message: CheckedProcessMessage; + readonly layout: KernelMessageLayout; + readonly totalCapacity: number; + readonly retryToken: bigint; +} + +interface SysvMessageBlockingRetrySnapshot + extends FrozenCancellationPointIdentity { + readonly kind: "sysv-message"; + readonly syscallNr: typeof SYS_MSGSND | typeof SYS_MSGRCV; + readonly origArgs: number[]; + readonly pointerWidth: 4 | 8; + readonly processPointer: number; + readonly messageSize: number; + readonly flags: number; + readonly input: Uint8Array | null; + readonly nativeType: bigint; + readonly messageType: bigint; + readonly retryToken: bigint; +} + +type BlockingRetrySnapshot = + | GenericBlockingRetrySnapshot + | FcntlLockBlockingRetrySnapshot + | SelectBlockingRetrySnapshot + | FlattenedBlockingRetrySnapshot + | SendmsgBlockingRetrySnapshot + | RecvmsgBlockingRetrySnapshot + | SysvMessageBlockingRetrySnapshot; + +interface BlockingRetryWakeTargets { + readonly readPipeIndex?: number; + readonly writePipeIndex?: number; + readonly acceptIndex?: number; + readonly pollPipeIndices?: readonly number[]; + readonly pollAcceptIndices?: readonly number[]; +} + function isSpawnResolveError( resolution: SpawnProgramResolution, ): resolution is SpawnResolveError { @@ -1320,6 +2116,16 @@ export interface CentralizedKernelCallbacks { memory: WebAssembly.Memory, target: object, ) => void; + /** + * Called exactly once when the kernel instance can no longer be used + * safely by any process. + * + * The entry layer must terminate the dedicated kernel Worker. A transfer + * import trap can strand a Rust-owned global reservation in Executing state, + * so neither completing one guest with EIO nor continuing to dispatch other + * channels is a valid recovery boundary. + */ + onKernelFatal?: (error: Error) => void; /** * Called when a process forks. The kernel has already cloned the Process @@ -1444,38 +2250,435 @@ interface TcpListenerTarget { acceptWakeIdx?: number; } -export class CentralizedKernelWorker { - private kernel: WasmPosixKernel; - private kernelInstance: WebAssembly.Instance | null = null; - private kernelMemory: WebAssembly.Memory | null = null; - /** ABI version read from the kernel wasm at startup. */ - private kernelAbiVersion: number = 0; - private processes = new Map(); - private activeChannels: ChannelInfo[] = []; - /** - * Exact channel generations detached from dispatch but whose engine-owned - * Atomics.waitAsync listener may still need to be woken after its guest - * Worker has stopped. - */ - private retiredChannelListeners = new Set(); - private pendingChannelListenerCounts = new Map(); - private retiredChannelSettlements = new Map< - ChannelInfo, - { promise: Promise; resolve: () => void; notified: boolean } - >(); - /** Pids whose old image committed exec but whose replacement has no channel yet. */ - private execHandoffPids = new Set(); - /** Capacity travels with the allocator-owned pointer. */ - private scratchRegion: KernelScratchRegion | null = null; - /** - * Host-side half of the Rust reservation state machine. - * - * WHY: kernel imports can reenter JavaScript while an export is running. - * Keep the complete begin/copy/commit interval exclusive even after Rust +interface TcpListenerRegistrationPlan { + readonly liveWakeIdx: number; + readonly oldAlias: + | Readonly<{ fd: number; acceptWakeIdx?: number }> + | null; +} + +interface ExecFdMirrorPrunePlan { + readonly epollInterests: Map< + string, + Array<{ fd: number; events: number; data: bigint }> + >; + readonly tcpListenerTargets: Map; + readonly tcpListenerRRIndex: Map; + readonly tcpListeners: Map; + readonly tcpVirtualListenerKeys: Map; + readonly virtualListenerKeysToClose: readonly string[]; + readonly listenerServersToClose: readonly import("net").Server[]; +} + +interface UdpBindingCleanupPlan { + readonly udpBindings: Set; + readonly endpointKeysToUnbind: readonly string[]; +} + +interface TcpListenerCleanupPlan { + readonly tcpListenerTargets: Map; + readonly tcpListenerRRIndex: Map; + readonly tcpListeners: Map; + readonly tcpVirtualListenerKeys: Map; + readonly virtualListenerKeysToClose: readonly string[]; + readonly listenerServersToClose: readonly import("net").Server[]; +} + +/** + * Module-private observation seams for the legacy scratch-boundary suite. + * + * WHY: these callbacks deliberately receive only value-level syscall state, + * never a KernelWorkerEntryContext. Keeping them in one frozen private record + * avoids installing mutable method shadows on the authority-bearing worker. + */ +interface ScratchBoundaryTestHooks { + readonly fdSupportsMmapWriteback?: ( + pid: number, + fd: number, + ) => boolean; + readonly getFdStatForSharedMapping?: ( + channel: Pick, + fd: number, + ) => SharedMmapHostResult; + readonly getFdPathForSharedMapping?: ( + channel: Pick, + fd: number, + ) => SharedMmapHostResult; + readonly getFdAccessModeForSharedMapping?: ( + channel: Pick, + fd: number, + ) => SharedMmapHostResult; + readonly getPtrWidth?: (pid: number) => 4 | 8; + readonly guestTidForChannel?: (channel: ChannelInfo) => number; + readonly afterProcessMemorySnapshot?: (channel: ChannelInfo) => void; + readonly handleBlockingRetry?: ( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + ) => void; + readonly deferChannelWhileStopped?: (channel: ChannelInfo) => boolean; + readonly getReadinessDeadline?: ( + channel: ChannelInfo, + timeoutMs: number, + ) => number; + readonly isRegisteredChannel?: (channel: ChannelInfo) => boolean; + readonly handleSharedMappingsAfterFileSyscall?: ( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + retVal: number, + errVal: number, + positionedOffset?: bigint, + truncateLength?: bigint, + ) => void; + readonly synchronizeSharedMemoryForBoundary?: ( + process: Pick, + ) => void; + readonly completeChannel?: ( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + argDescs: SyscallArgDesc[] | undefined, + retVal: ChannelScalarValue, + errVal: number, + detachedOutput?: ChannelOutputWrite[], + ) => void; + readonly completeChannelRaw?: ( + channel: ChannelInfo, + retVal: number, + errVal: number, + ) => void; + readonly relistenChannel?: (channel: ChannelInfo) => void; + readonly wakePendingSignalWaits?: ( + pid: number, + signum: number, + targetTid?: number, + ) => void; + readonly sendSignalToProcess?: ( + targetPid: number, + signum: number, + queueSignal?: boolean, + ) => void; + readonly scheduleWakeBlockedRetries?: () => void; + readonly retrySyscall?: (channel: ChannelInfo) => void; + readonly interruptWaitingChildForDirectedSignal?: ( + pid: number, + tid: number, + ) => boolean; + readonly interruptWaitingChildrenForGeneratedSignal?: ( + signum: number, + ) => void; +} + +type DetachedCopybackTestOptions = + | { + readonly pid: number; + readonly registrationWitness: ChannelInfo; + readonly operation: "read"; + readonly fd: number; + readonly destination: number; + readonly requestedLength: number; + readonly returnValue: number; + readonly outputBytes?: Uint8Array; + } + | { + readonly pid: number; + readonly registrationWitness: ChannelInfo; + readonly operation: "fstat"; + readonly fd: number; + readonly destination: number; + readonly outputBytes: Uint8Array; + } + | { + readonly pid: number; + readonly registrationWitness: ChannelInfo; + readonly operation: "sched-getparam"; + readonly targetPid: number; + readonly destination: number; + readonly outputBytes: Uint8Array; + }; + +interface ImmediatePollTimeoutCopybackTestOptions { + readonly pid: number; + readonly registrationWitness: ChannelInfo; + readonly pollfdPointer: number; + readonly outputBytes: Uint8Array; +} + +type CapacityProbeDestination = "null" | "guarded"; + +interface KernelCapacityProbeResult { + readonly result: number; + readonly guardedBytes: Uint8Array; +} + +interface MqueueNotificationCapacityProbeTestOptions { + readonly registrationWitness: ChannelInfo; + readonly descriptor: number; + readonly triggerNotification: boolean; + readonly destination: CapacityProbeDestination; + readonly capacity: number; +} + +interface WaitableChildCapacityProbeTestOptions { + readonly registrationWitness: ChannelInfo; + readonly childPid: number; + readonly destination: CapacityProbeDestination; + readonly capacity: number; +} + +interface ThreadTransportStateTestResult { + readonly channelTidEntries: number; + readonly forkContextEntries: number; + readonly clearTidEntries: number; + readonly activeThreadChannels: number; +} + +interface CentralizedKernelWorkerTestAuthority { + initializeKernelForTest(options: { + readonly instance: WebAssembly.Instance; + readonly gate: KernelEntryGate; + readonly mainScratch: KernelScratchRegion; + readonly tcpScratch?: KernelScratchRegion; + }): void; + configureScratchBoundaryHooksForTest( + options: ScratchBoundaryTestHooks, + ): void; + completeDetachedCopybackForTest( + options: DetachedCopybackTestOptions, + ): void; + completeImmediatePollTimeoutForCopybackTest( + options: ImmediatePollTimeoutCopybackTestOptions, + ): void; + sendSignalForTest( + targetPid: number, + signum: number, + queueSignal?: boolean, + ): void; + completeSleepWithSignalCheckForTest( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + retVal: number, + errVal: number, + ): void; + dequeueSignalForDeliveryForTest(channel: ChannelInfo): number; + drainWakeupEventsForTest(): void; + dispatchRegisteredMainChannelForAdvisoryLockTest(pid: number): void; + forkKernelProcessForAdvisoryLockTest( + parentPid: number, + callerTid: number, + ): number; + probeMqueueNotificationCapacityForTest( + options: MqueueNotificationCapacityProbeTestOptions, + ): KernelCapacityProbeResult; + probeWaitableChildCapacityForTest( + options: WaitableChildCapacityProbeTestOptions, + ): KernelCapacityProbeResult; + inspectThreadTransportStateForLifecycleTest( + pid: number, + ): ThreadTransportStateTestResult; + dispatchUntrackedForkForTaskAuthorityTest( + pid: number, + registrationWitness: ChannelInfo, + channelOffset: number, + origArgs: number[], + ): void; + dispatchUntrackedExecForTaskAuthorityTest( + pid: number, + registrationWitness: ChannelInfo, + channelOffset: number, + origArgs: number[], + ): void; + dispatchUntrackedExecveatForTaskAuthorityTest( + pid: number, + registrationWitness: ChannelInfo, + channelOffset: number, + origArgs: number[], + ): void; + dispatchUntrackedThreadExitForTaskAuthorityTest( + pid: number, + registrationWitness: ChannelInfo, + channelOffset: number, + exitStatus: number, + ): void; + dispatchScratchBoundarySyscallForTest(channel: ChannelInfo): void; + dispatchSpawnPreflightForTest( + channel: ChannelInfo, + origArgs: number[], + ): void; + dispatchSpawnAfterResolveForTest(options: { + readonly channel: ChannelInfo; + readonly origArgs: number[]; + readonly parentPid: number; + readonly callerTid: number; + readonly pidOutPtr: number; + readonly blobBytes: Uint8Array; + readonly blobLen: number; + readonly program: ResolvedSpawnProgram; + readonly envp: string[]; + }): void; + replaceProcessRegistrationForLifecycleTest(options: { + readonly pid: number; + readonly memory: WebAssembly.Memory; + readonly channelOffsets: readonly number[]; + readonly pointerWidth?: 4 | 8; + readonly tcpListener?: Readonly<{ + readonly fd: number; + readonly port: number; + }>; + }): readonly ChannelInfo[]; + resumeStoppedProcessForTest(pid: number): boolean; + discardStoppedProcessStateForTest(pid: number): void; + installParkedCloneCompletionForTest(options: { + readonly channel: ChannelInfo; + readonly tid: number; + readonly parentTidPointer: number; + }): void; + replaceKernelForScratchBoundaryTest(kernel: WasmPosixKernel): void; + replaceTcpScratchForScratchBoundaryTest( + scratch: KernelScratchRegion, + ): void; +} + +type CentralizedKernelWorkerTestDouble = CentralizedKernelWorker & { + readonly testAuthority: CentralizedKernelWorkerTestAuthority; +}; + +interface KernelWorkerEntryContext { + readonly instance: WebAssembly.Instance; + readonly scope: KernelVoidIngressScope; + /** + * Schedule protocol work that must complete before any observer or later + * ingress can run. A throw or non-undefined return poisons this kernel + * generation, and the remainder of the detached batch is discarded. + */ + readonly deferProtocolEffect: + (operation: () => undefined) => undefined; + /** + * Start host-owned asynchronous work only after this scope is revoked. + * Continuations receive no retained entry authority and must validate their + * exact channel/generation through a fresh ingress before touching Wasm. + */ + readonly deferProtocolTransactionStart: + (operation: () => undefined) => undefined; + /** + * Reserve one transaction start that the ingress root appends only after + * every nested helper has returned and ordinary detached effects are known. + * + * WHY: a process-resume helper can run inside another syscall's wake drain, + * before that outer syscall registers its own mailbox publication. Keeping + * this finalizer lexical to the ingress makes the gate's "transaction start + * is final" invariant automatic without lending the callback any Wasm + * authority or reordering ordinary effects. + */ + readonly deferFinalProtocolTransactionStart: + (operation: () => undefined) => undefined; + /** + * Schedule an observer-only host effect in exact registration order. An + * observer failure is reported and later records continue; a protocol + * failure latches the generation fatal and discards the remainder. The + * callback must not retain kernel scratch or attempt to use this context. + */ + readonly deferObserverEffect: + (operation: () => undefined) => undefined; + /** + * Run one synchronous backend operation while this exact scope remains the + * serialization owner. The operation receives no Wasm authority; callers + * must stage host-owned results and commit only after it returns. + */ + readonly invokeSerializedHostOperation: + (operation: () => T) => T; +} + +const centralizedKernelWorkerTestCapability = {}; + +interface CentralizedKernelWorkerTestOptions { + readonly config?: KernelConfig; + readonly io?: PlatformIO; + readonly callbacks?: CentralizedKernelCallbacks; +} + +/** + * @internal Create a private-branded, package-private test double. + * + * WHY: focused unit tests exercise private syscall stages without compiling a + * complete kernel module. A module-secret constructor capability installs one + * frozen method-only companion while every production instance keeps direct + * JavaScript `#private` fields and has no test authority property. This helper is + * deliberately absent from every supported package entry point. + */ +export function createCentralizedKernelWorkerTestDouble( + options: CentralizedKernelWorkerTestOptions = {}, +): CentralizedKernelWorkerTestDouble { + return kernelEntryIntrinsicConstruct( + CentralizedKernelWorker, + [ + options.config ?? { + maxWorkers: 1, + dataBufferSize: 65_536, + useSharedMemory: true, + }, + options.io ?? ({} as PlatformIO), + options.callbacks, + centralizedKernelWorkerTestCapability, + ], + ) as CentralizedKernelWorkerTestDouble; +} + +export class CentralizedKernelWorker { + #kernel: WasmPosixKernel; + #kernelEntryGate: KernelEntryGate; + #kernelInstance: WebAssembly.Instance | null = null; + #kernelMemory: WebAssembly.Memory | null = null; + #kernelPointerWidth: 4 | 8 = 4; + #scratchBoundaryTestHooks: ScratchBoundaryTestHooks | null = null; + /** ABI version read from the kernel wasm at startup. */ + private kernelAbiVersion: number = 0; + private processes = new Map(); + private activeChannels: ChannelInfo[] = []; + /** + * Exact channel generations detached from dispatch but whose engine-owned + * Atomics.waitAsync listener may still need to be woken after its guest + * Worker has stopped. + */ + private retiredChannelListeners = new Set(); + private pendingChannelListenerCounts = new Map(); + private retiredChannelSettlements = new Map< + ChannelInfo, + { promise: Promise; resolve: () => void; notified: boolean } + >(); + /** Pids whose old image committed exec but whose replacement has no channel yet. */ + private execHandoffPids = new Set(); + /** Capacity travels with the allocator-owned pointer. */ + #scratchRegion: KernelScratchRegion | null = null; + /** + * Host-side half of the Rust reservation state machine. + * + * WHY: kernel imports can reenter JavaScript while an export is running. + * Keep the complete begin/copy/commit interval exclusive even after Rust * has parsed the bytes and released its own reservation lock. */ - private largeSpawnScratchInUse = false; - private initialized = false; + #largeSpawnScratchInUse = false; + /** + * Serialize the global Rust transfer reservation across host reentrancy. + * + * A trapped execute intentionally leaves this set. `handleSyscall` latches + * the whole instance fatal and asks the entry layer to terminate its + * dedicated Worker; process-local recovery cannot make a global Executing + * token safe. + */ + #largeTransferScratchInUse = false; + /** + * A fatal latch is stronger than throwing from one waitAsync continuation. + * + * WHY: promise and timer callbacks can turn a thrown exception into only an + * unhandled rejection while other channel listeners remain live. Once set, + * every dispatch/relisten path becomes inert and the entry callback tears + * down the dedicated Worker. + */ + #kernelFatalError: Error | null = null; + #initialized = false; /** * Maps a pthread syscall mailbox to its kernel/libc thread id. * @@ -1517,16 +2720,19 @@ export class CentralizedKernelWorker { * channel must retain the kernel-allocated TID recorded when that channel * was attached. */ - private bindKernelTidForChannel(channel: ChannelInfo): void { + #bindKernelTidForChannel( + channel: ChannelInfo, + entry?: KernelWorkerEntryContext, + ): void { const tid = this.channelTids.get( `${channel.pid}:${channel.channelOffset}`, ); if (tid !== undefined) { - this.bindKernelTid(channel.pid, tid); + this.#bindKernelTid(channel.pid, tid, entry); return; } if (this.isMainProcessChannel(channel)) { - this.bindKernelTid(channel.pid, channel.pid); + this.#bindKernelTid(channel.pid, channel.pid, entry); return; } throw this.missingChannelTidError(channel); @@ -1537,8 +2743,13 @@ export class CentralizedKernelWorker { * The kernel rejects unknown/mismatched TIDs; accepting a channel mapping * never grants the host authority to create an observable thread identity. */ - private bindKernelTid(pid: number, tid: number): void { - const setTid = this.kernelInstance?.exports.kernel_set_current_tid as + #bindKernelTid( + pid: number, + tid: number, + entry?: KernelWorkerEntryContext, + ): void { + const setTid = this.#kernelInstanceIfAvailableForEntry(entry)?.exports + .kernel_set_current_tid as ((pid: number, tid: number) => number) | undefined; if (!setTid) { throw new Error("Kernel missing kernel_set_current_tid export"); @@ -1549,8 +2760,12 @@ export class CentralizedKernelWorker { } } - private validateKernelTid(pid: number, tid: number): void { - const validateTask = this.kernelInstance?.exports.kernel_validate_task as + private validateKernelTid( + pid: number, + tid: number, + entry?: KernelWorkerEntryContext, + ): void { + const validateTask = this.#kernelInstanceIfAvailableForEntry(entry)?.exports.kernel_validate_task as ((pid: number, tid: number) => number) | undefined; if (!validateTask) { throw new Error("Kernel missing kernel_validate_task export"); @@ -1562,6 +2777,8 @@ export class CentralizedKernelWorker { } private guestTidForChannel(channel: ChannelInfo): number { + const testHook = this.#scratchBoundaryTestHooks?.guestTidForChannel; + if (testHook) return testHook(channel); const tid = this.channelTids.get( `${channel.pid}:${channel.channelOffset}`, ); @@ -1597,6 +2814,8 @@ export class CentralizedKernelWorker { private pendingSleeps = new Map< ChannelInfo, { + cancellationPoint: boolean; + cancellationWakeAllowed: boolean; timer: ReturnType; channel: ChannelInfo; syscallNr: number; @@ -1610,9 +2829,12 @@ export class CentralizedKernelWorker { private pendingSignalWaits = new Map< string, { + cancellationPoint: boolean; + cancellationWakeAllowed: boolean; timer: ReturnType; channel: ChannelInfo; origArgs: number[]; + signalMask: bigint; } >(); /** Finite rt_sigtimedwait deadlines retained across wake-driven retries. */ @@ -1634,11 +2856,22 @@ export class CentralizedKernelWorker { /** UDP virtual-network endpoint bindings: "pid:sockIdx" */ private udpBindings = new Set(); /** Separate scratch buffer for TCP data pumping */ - private tcpScratchRegion: KernelScratchRegion | null = null; + #tcpScratchRegion: KernelScratchRegion | null = null; /** Node.js net module (loaded dynamically for browser compatibility) */ private netModule: typeof import("net") | null = null; /** Deferred waitpid/waitid completions. Child matching/reap state is Rust-owned. */ private waitingForChild: WaitingForChild[] = []; + /** + * Exact request identity detached before any host-owned park can occur. + * + * The guest mailbox flag is cleared as it is captured. Keeping the frozen + * value here lets unsnapshotted retries retain the first request's identity + * without rereading bytes that now belong to host/guest handshaking. + */ + private activeChannelRequests = new Map< + ChannelInfo, + FrozenChannelRequest + >(); /** Pids whose authoritative kernel state is Stopped. Updated only from the * kernel wake-event stream, so ordinary syscall completion does not need an * extra Wasm state query. */ @@ -1672,6 +2905,8 @@ export class CentralizedKernelWorker { /** Cached kernel memory typed array view (invalidated on memory.grow) */ /** Pending poll/ppoll retries keyed by exact channel generation. */ private pendingPollRetries = new Map(); + /** + * Immutable request state for retryable data-transfer syscalls. + * + * ChannelInfo object identity is the execution-generation key: exec may + * reuse both pid and mailbox offset, but it cannot inherit this entry. No + * value in this map retains a process-memory or kernel-scratch view. + */ + private blockingRetrySnapshots = new Map< + ChannelInfo, + BlockingRetrySnapshot + >(); + /** + * Kernel readiness identities captured synchronously beside the first + * EAGAIN. Retries reuse them instead of resolving a possibly closed/reused + * numeric descriptor. + */ + private blockingRetryWakeTargets = new Map< + ChannelInfo, + BlockingRetryWakeTargets + >(); /** * Blocking advisory-lock requests waiting for a Rust-owned lock-state * change. The host owns only channel parking and the short retry safety * timer; it never inspects advisory-lock state. */ private pendingAdvisoryLockRetries = new Map; channel: ChannelInfo; }>(); /** Pending pselect6/select retries keyed by exact channel generation. */ private pendingSelectRetries = new Map>(); + private pendingPipeReaders = new Map< + number, + Array<{ + channel: ChannelInfo; + pid: number; + cancellationPoint: boolean; + cancellationWakeAllowed: boolean; + }> + >(); /** Pending pipe/socket writers: sendPipeIdx → array of waiting channels. * When a write-like syscall returns EAGAIN on a pipe/socket fd (buffer full), * the writer is registered here. When a read drains the pipe, writers wake. */ - private pendingPipeWriters = new Map>(); + private pendingPipeWriters = new Map< + number, + Array<{ + channel: ChannelInfo; + pid: number; + cancellationPoint: boolean; + cancellationWakeAllowed: boolean; + }> + >(); /** Socket timeout timers: channel → timer. When a socket read/write * blocks and has SO_RCVTIMEO/SO_SNDTIMEO set, a timer is scheduled * to complete the syscall with ETIMEDOUT. Cleared when the operation @@ -1732,18 +3007,23 @@ export class CentralizedKernelWorker { private pendingFutexWaits = new Map< ChannelInfo, { + cancellationPoint: boolean; + cancellationWakeAllowed: boolean; futexIndex: number; + /** Finite relative waits must expose EINTR even under SA_RESTART. */ + hasTimeout: boolean; /** Settle this exact wait once without racing its waitAsync callback. */ - interrupt?: (retVal: number, errVal: number) => void; + interrupt: (retVal: number, errVal: number) => void; /** Retire a discarded channel without publishing a guest completion. */ - retire?: () => void; + retire: () => void; } >(); /** Exact channel generations with a cancellation request pending. Set by - * SYS_THREAD_CANCEL as the pre-enqueue race guard for host-owned wait and - * futex entry. Already-tracked poll/select/pipe/wait/futex blockers are - * interrupted immediately; an otherwise untracked target relies on the - * authoritative guest pthread cancel flag at its next cancellation point. */ + * SYS_THREAD_CANCEL as the pre-enqueue race guard for every host-owned + * cancellation-point registration. Already-tracked sleep/signal/readiness/ + * advisory/pipe/futex/wait blockers are interrupted immediately; a plain + * syscall or otherwise untracked target leaves this armed until the thread's + * next marked cancellation point (the guest pthread bit remains authoritative). */ private pendingCancels = new Set(); /** Profiling data: syscallNr → {count, totalTimeMs, retries} */ private profileData: Map< @@ -1780,6 +3060,14 @@ export class CentralizedKernelWorker { private sharedMmapBackings = new Map(); /** Prevent nested signal cleanup from releasing the same address space twice. */ private sharedMemoryReleasePids = new Set(); + /** + * Child generations currently materializing host-backed inheritance bytes. + * + * WHY: file reads run with entry authority revoked and may call arbitrary + * host code. A same-child reentry must observe no partial owner/refcount + * publication and must not start a competing transaction from stale bytes. + */ + private sharedMappingInheritancePids = new Set(); /** Process fd → resolved backing identity, including negative lookups. */ private sharedMmapFdCache = new Map(); /** Host-side mirror of epoll interest lists: "pid:epfd" → interests. @@ -1801,7 +3089,9 @@ export class CentralizedKernelWorker { /** Virtual MAC address for this kernel instance (locally administered, unicast) */ private virtualMacAddress: Uint8Array; - + private networkListenObserver: + | ((pid: number, fd: number, port: number) => void) + | undefined; /** KMS presenter: OffscreenCanvas per CRTC for the vblank pump to blit * the bound framebuffer into. Populated via `attachKmsCanvas`. */ private kmsCanvases = new Map(); @@ -1825,13 +3115,42 @@ export class CentralizedKernelWorker { * accepts it (an `ImageDataArray` rejects SAB-backed views). */ private kmsScratchBytes = new Map>(); private vblankTimer: ReturnType | null = null; + /** Construction-time schedulers include the browser worker's installed + * polyfill but cannot be replaced by a later guest/host callback. */ + readonly #schedulerReceiver: typeof globalThis; + readonly #scheduleImmediate: typeof setImmediate; + readonly #cancelImmediate: typeof clearImmediate; + readonly #scheduleMicrotask: typeof queueMicrotask; + readonly #scheduleTimeout: typeof setTimeout; + readonly #cancelTimeout: typeof clearTimeout; + readonly #scheduleInterval: typeof setInterval; + readonly #cancelInterval: typeof clearInterval; + readonly #promiseReceiver: PromiseConstructor; + readonly #promiseResolve: typeof Promise.resolve; + readonly #promiseThen: typeof Promise.prototype.then; constructor( private config: KernelConfig, private io: PlatformIO, private callbacks: CentralizedKernelCallbacks = {}, ) { - this.kernel = new WasmPosixKernel(config, io, { + if (new.target !== CentralizedKernelWorker) { + throw new TypeError( + "CentralizedKernelWorker does not permit subclass dispatch overrides", + ); + } + this.#schedulerReceiver = globalThis; + this.#scheduleImmediate = setImmediate; + this.#cancelImmediate = clearImmediate; + this.#scheduleMicrotask = queueMicrotask; + this.#scheduleTimeout = setTimeout; + this.#cancelTimeout = clearTimeout; + this.#scheduleInterval = setInterval; + this.#cancelInterval = clearInterval; + this.#promiseReceiver = Promise; + this.#promiseResolve = Promise.resolve; + this.#promiseThen = Promise.prototype.then; + this.#kernel = new WasmPosixKernel(config, io, { // Process-lifecycle callbacks are handled by the kernel worker: the // kernel returns EAGAIN and JS performs the host-side action. getProcessMemory: (pid: number): WebAssembly.Memory | undefined => { @@ -1876,17 +3195,22 @@ export class CentralizedKernelWorker { const pid = this.currentHandlePid; if (pid === 0) return 0; - // Cancel any existing alarm for this process - const existing = this.alarmTimers.get(pid); - if (existing) { - clearTimeout(existing); - this.alarmTimers.delete(pid); - } + this.cancelAlarmTimerForProcess(pid); if (seconds > 0) { - const timer = setTimeout(() => { + const timer = this.#registerTimeout(() => { + if (this.alarmTimers.get(pid) !== timer) return; this.alarmTimers.delete(pid); - this.sendSignalToProcess(pid, SIGALRM); + // WHY: the timer runs after the syscall import and its Rust entry + // have both returned. Open a fresh lexical scope instead of + // letting an asynchronous callback recover ambient Wasm authority. + this.#runOrDeferKernelEntry( + `alarm expiry pid=${pid}`, + (entry) => { + this.sendSignalToProcess(pid, SIGALRM, true, entry); + return undefined; + }, + ); }, seconds * 1000); this.alarmTimers.set(pid, timer); } @@ -1896,8 +3220,37 @@ export class CentralizedKernelWorker { const pid = this.currentHandlePid; if (pid === 0) return 0; // addr is currently informational; reserved for future per-iface filtering. - void addr; - this.startTcpListener(pid, fd, port, addr); + const exactAddr = [...addr] as [number, number, number, number]; + this.#runOrDeferKernelEntry( + "TCP listener registration", + (entry) => { + if (!this.#kernelInstance) { + throw new Error( + "Kernel is not initialized for TCP listener registration", + ); + } + const preparedPlan = this.#prepareTcpListenerRegistration( + pid, + fd, + port, + entry, + ); + // Binding the transport is required for the accepted listen + // result to remain live; an unexpected throw poisons the batch. + entry.deferProtocolEffect(() => { + this.#startTcpListenerHostPhase( + pid, + fd, + port, + exactAddr, + preparedPlan, + ); + }); + entry.deferObserverEffect(() => { + this.networkListenObserver?.(pid, fd, port); + }); + }, + ); return 0; }, onUdpBind: (handle: number, addr: [number, number, number, number], port: number): number => { @@ -1978,6 +3331,11 @@ export class CentralizedKernelWorker { return 0; }, }); + const runtimeAccess = getWasmPosixKernelRuntimeAccess(this.#kernel); + this.#kernelEntryGate = runtimeAccess.gate; + this.#kernelEntryGate.setFatalHandler((error) => { + this.#failKernelInstance(error); + }); // Generate a random virtual MAC address (locally administered, unicast) this.virtualMacAddress = new Uint8Array(6); @@ -1991,6821 +3349,12413 @@ export class CentralizedKernelWorker { } // Set locally administered bit, clear multicast bit this.virtualMacAddress[0] = (this.virtualMacAddress[0] & 0xFE) | 0x02; - } - - /** - * Initialize the kernel. - * Loads kernel Wasm and validates the host adapter ABI. - */ - async init(kernelWasmBytes: BufferSource): Promise { - await this.kernel.init(kernelWasmBytes); - this.kernelInstance = this.kernel.getInstance()!; - this.kernelMemory = this.kernel.getMemory()!; - - // Read the kernel's advertised ABI version once at startup. Every - // user program spawned against this kernel will have its own - // `__abi_version` export compared against this value; mismatches - // are refused before any syscall runs. - const abiVersionFn = this.kernelInstance.exports[ABI_KERNEL_EXPORT] as - (() => number) | undefined; - if (typeof abiVersionFn !== "function") { - throw new Error( - `kernel wasm is missing the ${ABI_KERNEL_EXPORT} export — refusing to run. ` + - "Rebuild the kernel (bash build.sh) against the current ABI.", - ); - } - this.kernelAbiVersion = abiVersionFn(); - validateKernelHostAdapterManifest(this.kernelInstance, this.kernelMemory); - - // Allocate scratch area from the kernel's own heap allocator. - // IMPORTANT: Do NOT use this.kernelMemory.grow() — the kernel's - // allocator (dlmalloc) doesn't know about host-grown pages and will - // reuse them as heap, causing corruption (overlapping writes between - // scratch data and kernel heap structures like Vec). - const allocScratch = this.kernelInstance.exports.kernel_alloc_scratch as - (size: number) => KernelPointer; - this.scratchRegion = allocateKernelScratchRegion( - this.kernelMemory, - allocScratch, - SCRATCH_SIZE, - this.kernel.getKernelPtrWidth(), - "kernel syscall scratch", - this.kernelInstance, - ); - // Try to load Node.js net module for TCP bridging - try { - const net = await import("net"); - // Verify it's a real module (Vite externalizes it as an empty stub in browsers) - if (typeof net.createServer === "function") { - this.netModule = net; - } - } catch { - // Not in Node.js environment — TCP bridging disabled - } - - // Allocate a separate scratch buffer for TCP data pumping - this.tcpScratchRegion = allocateKernelScratchRegion( - this.kernelMemory, - allocScratch, - 65536, - this.kernel.getKernelPtrWidth(), - "kernel TCP scratch", - this.kernelInstance, - ); - this.initialized = true; - } - - private requireMainScratchRegion(): KernelScratchRegion { - if (!this.scratchRegion) { - throw new KernelScratchError("kernel syscall scratch is not allocated"); - } - return this.scratchRegion; - } - - private requireTcpScratchRegion(): KernelScratchRegion { - if (!this.tcpScratchRegion) { - throw new KernelScratchError("kernel TCP scratch is not allocated"); - } - return this.tcpScratchRegion; - } - - /** Validate one synchronous Rust producer result before copying its output. */ - private checkedScratchProducerByteLength( - result: number, - capacity: number, - label: string, - ): number { - if (!Number.isSafeInteger(result)) { - throw new KernelScratchError( - `${label} returned a non-integer byte count`, - EIO, - ); - } - if (result <= 0) return 0; - if (result > capacity) { - throw new KernelScratchError( - `${label} returned ${result} bytes for capacity ${capacity}`, - EIO, - ); + if (arguments[3] === centralizedKernelWorkerTestCapability) { + kernelEntryIntrinsicObjectDefineProperty(this, "testAuthority", { + configurable: false, + enumerable: false, + writable: false, + value: this.#createTestAuthority(), + }); } - return result; - } + // WHY: the worker crosses untrusted observers while retaining a live + // entry graph on its prototype. Sealing prevents an observer from + // shadowing an entry-taking method on either production or test instances. + kernelEntryIntrinsicObjectSeal(this); + } + + #createTestAuthority(): CentralizedKernelWorkerTestAuthority { + const methods: CentralizedKernelWorkerTestAuthority = { + initializeKernelForTest: (options: { + readonly instance: WebAssembly.Instance; + readonly gate: KernelEntryGate; + readonly mainScratch: KernelScratchRegion; + readonly tcpScratch?: KernelScratchRegion; + }): void => { + if (this.#initialized || this.#kernelInstance !== null) { + throw new Error("test kernel generation is already initialized"); + } + const mainOwner = validateKernelScratchRegionOwnership( + options.mainScratch, + options.instance, + "test main scratch", + ); + let tcpOwner: + | ReturnType + | undefined; + if (options.tcpScratch !== undefined) { + tcpOwner = validateKernelScratchRegionOwnership( + options.tcpScratch, + options.instance, + "test TCP scratch", + ); + if ( + tcpOwner.memory !== mainOwner.memory + || tcpOwner.pointerWidth !== mainOwner.pointerWidth + ) { + throw new KernelScratchError( + "test scratch regions do not share one kernel Memory and pointer width", + ); + } + } + validateKernelEntryGateOwnership(options.instance, options.gate); + options.gate.setFatalHandler((error) => { + this.#failKernelInstance(error); + }); + this.#kernelEntryGate = options.gate; + this.#kernelInstance = options.instance; + this.#kernelMemory = mainOwner.memory; + this.#kernelPointerWidth = mainOwner.pointerWidth; + this.#scratchRegion = mainOwner.region; + this.#tcpScratchRegion = tcpOwner?.region ?? null; + this.#largeSpawnScratchInUse = false; + this.#largeTransferScratchInUse = false; + this.#kernelFatalError = null; + this.#initialized = true; + if (this.kmsCanvases.size > 0 || this.kmsStatsViews.size > 0) { + this.startVblankPump(); + } + }, + configureScratchBoundaryHooksForTest: (options): void => { + const previous = this.#scratchBoundaryTestHooks; + // WHY: make replacement atomic and immutable. A test can refine one + // observation seam without exposing a generic property-name mutation + // primitive or replacing any method on the sealed worker. + this.#scratchBoundaryTestHooks = kernelEntryIntrinsicObjectFreeze({ + fdSupportsMmapWriteback: + options.fdSupportsMmapWriteback + ?? previous?.fdSupportsMmapWriteback, + getFdStatForSharedMapping: + options.getFdStatForSharedMapping + ?? previous?.getFdStatForSharedMapping, + getFdPathForSharedMapping: + options.getFdPathForSharedMapping + ?? previous?.getFdPathForSharedMapping, + getFdAccessModeForSharedMapping: + options.getFdAccessModeForSharedMapping + ?? previous?.getFdAccessModeForSharedMapping, + getPtrWidth: + options.getPtrWidth ?? previous?.getPtrWidth, + guestTidForChannel: + options.guestTidForChannel ?? previous?.guestTidForChannel, + afterProcessMemorySnapshot: + options.afterProcessMemorySnapshot + ?? previous?.afterProcessMemorySnapshot, + handleBlockingRetry: + options.handleBlockingRetry ?? previous?.handleBlockingRetry, + deferChannelWhileStopped: + options.deferChannelWhileStopped + ?? previous?.deferChannelWhileStopped, + getReadinessDeadline: + options.getReadinessDeadline ?? previous?.getReadinessDeadline, + isRegisteredChannel: + options.isRegisteredChannel ?? previous?.isRegisteredChannel, + handleSharedMappingsAfterFileSyscall: + options.handleSharedMappingsAfterFileSyscall + ?? previous?.handleSharedMappingsAfterFileSyscall, + synchronizeSharedMemoryForBoundary: + options.synchronizeSharedMemoryForBoundary + ?? previous?.synchronizeSharedMemoryForBoundary, + completeChannel: + options.completeChannel ?? previous?.completeChannel, + completeChannelRaw: + options.completeChannelRaw ?? previous?.completeChannelRaw, + relistenChannel: + options.relistenChannel ?? previous?.relistenChannel, + wakePendingSignalWaits: + options.wakePendingSignalWaits + ?? previous?.wakePendingSignalWaits, + sendSignalToProcess: + options.sendSignalToProcess ?? previous?.sendSignalToProcess, + scheduleWakeBlockedRetries: + options.scheduleWakeBlockedRetries + ?? previous?.scheduleWakeBlockedRetries, + retrySyscall: + options.retrySyscall ?? previous?.retrySyscall, + interruptWaitingChildForDirectedSignal: + options.interruptWaitingChildForDirectedSignal + ?? previous?.interruptWaitingChildForDirectedSignal, + interruptWaitingChildrenForGeneratedSignal: + options.interruptWaitingChildrenForGeneratedSignal + ?? previous?.interruptWaitingChildrenForGeneratedSignal, + }); + }, + completeDetachedCopybackForTest: (options): void => { + // WHY: the options and byte view belong to the test caller. Select an + // immediate entry before reading either, and consume the detached + // bytes synchronously in completeChannel so rejection can never leave + // a queued operation holding caller-owned state. + this.#runImmediateKernelEntry( + "detached syscall copy-back test", + (entry) => { + const operation = options.operation; + const channel = + this.#registeredPendingMainChannelForCopybackTest( + options.pid, + options.registrationWitness, + "detached syscall copy-back test", + ); + if (operation === "read") { + const fd = options.fd; + const destination = options.destination; + const requestedLength = options.requestedLength; + const returnValue = options.returnValue; + const outputValue = options.outputBytes; + if ( + !Number.isSafeInteger(fd) + || fd < 0 + || !Number.isSafeInteger(destination) + || destination < 0 + || !Number.isSafeInteger(requestedLength) + || requestedLength < 0 + || !Number.isSafeInteger(returnValue) + || returnValue < 0 + || returnValue > requestedLength + ) { + throw new TypeError( + "read copy-back test arguments are invalid", + ); + } + const destinationRange = this.checkedProcessRange( + channel, + destination, + requestedLength, + "read copy-back test destination", + ); + let outputWrites: ChannelOutputWrite[] = []; + if (returnValue === 0) { + if ( + outputValue !== undefined + && intrinsicUint8ArrayView( + outputValue, + "read copy-back test output", + ).byteLength !== 0 + ) { + throw new TypeError( + "EOF copy-back test must not carry output bytes", + ); + } + } else { + if (outputValue === undefined) { + throw new TypeError( + "successful read copy-back test requires output bytes", + ); + } + const outputBytes = intrinsicUint8ArrayView( + outputValue, + "read copy-back test output", + ); + if (outputBytes.byteLength !== returnValue) { + throw new TypeError( + "read copy-back test output length does not match return value", + ); + } + outputWrites = [{ + ptr: destinationRange.pointer, + bytes: outputBytes, + }]; + } + this.completeChannel( + channel, + SYS_READ, + [fd, destinationRange.pointer, requestedLength], + SYSCALL_ARGS[SYS_READ], + returnValue, + 0, + outputWrites, + undefined, + entry, + ); + return undefined; + } - private checkedProcessRange( - channel: ChannelInfo, - pointer: number | bigint, - length: number | bigint, - field: string, - allowAddressZero = false, - ): { pointer: number; length: number; end: number } { - return checkedMemoryRange( - channel.memory, - pointer, - length, - this.getPtrWidth(channel.pid), - field, - allowAddressZero, - ); - } + let syscallNr: number; + let sourceId: number; + if (operation === "fstat") { + syscallNr = ABI_SYSCALLS.Fstat; + sourceId = options.fd; + if (!Number.isSafeInteger(sourceId) || sourceId < 0) { + throw new TypeError("fstat copy-back test fd is invalid"); + } + } else if (operation === "sched-getparam") { + syscallNr = ABI_SYSCALLS.SchedGetparam; + sourceId = options.targetPid; + if ( + !Number.isSafeInteger(sourceId) + || sourceId < 0 + || sourceId > MAX_KERNEL_TASK_ID + ) { + throw new TypeError( + "sched_getparam copy-back test pid is invalid", + ); + } + } else { + throw new TypeError( + "detached copy-back test operation is invalid", + ); + } - /** - * Preserve i64 channel slots until a handwritten host path has decided - * which arguments are process addresses. - * - * Generated descriptors perform this proof while planning their transfer. - * The syscalls below bypass that planner or use process addresses again - * during host-side memory bookkeeping. Normalizing only their pointer-sized - * slots keeps signed scalar arguments signed while preventing a wasm64 - * address from being rounded or narrowed to a low wasm32 address. - */ - private checkHandwrittenProcessAddressArguments( - channel: ChannelInfo, - syscallNr: number, - args: number[], - rawArgs: readonly bigint[], - ): void { - const pointerWidth = this.getPtrWidth(channel.pid); - const pointer = (index: number, field: string): void => { - args[index] = checkedWasmPointer( - rawArgs[index] ?? 0n, - pointerWidth, - field, - ); - }; - const size = (index: number, field: string): void => { - try { - args[index] = checkedWasmPointer( - rawArgs[index] ?? 0n, - pointerWidth, - field, + const destination = options.destination; + const outputBytes = intrinsicUint8ArrayView( + options.outputBytes, + "fixed-record copy-back test output", + ); + const descriptors = SYSCALL_ARGS[syscallNr]; + const outputDescriptor = descriptors?.[0]; + if ( + descriptors?.length !== 1 + || outputDescriptor === undefined + || outputDescriptor.argIndex !== 1 + || outputDescriptor.direction !== "out" + || outputDescriptor.size.type !== "fixed" + || outputDescriptor.required !== true + ) { + throw new Error( + "generated fixed-record copy-back contract drifted", + ); + } + if ( + !Number.isSafeInteger(destination) + || destination < 0 + || outputBytes.byteLength !== outputDescriptor.size.size + ) { + throw new TypeError( + "fixed-record copy-back test output is invalid", + ); + } + const destinationRange = this.checkedProcessRange( + channel, + destination, + outputBytes.byteLength, + "fixed-record copy-back test destination", + ); + this.completeChannel( + channel, + syscallNr, + [sourceId, destinationRange.pointer], + descriptors, + 0, + 0, + [{ + ptr: destinationRange.pointer, + bytes: outputBytes, + }], + undefined, + entry, + ); + return undefined; + }, ); - } catch (error) { - throw new KernelScratchError( - error instanceof Error ? error.message : `${field} is invalid`, - EINVAL, + }, + completeImmediatePollTimeoutForCopybackTest: (options): void => { + // WHY: this seam names exactly poll(2), one pollfd, and timeout zero. + // It cannot become an arbitrary retry dispatcher, and immediate-only + // entry prevents a rejected call from replaying caller bytes later. + this.#runImmediateKernelEntry( + "immediate poll timeout copy-back test", + (entry) => { + const channel = + this.#registeredPendingMainChannelForCopybackTest( + options.pid, + options.registrationWitness, + "immediate poll timeout copy-back test", + ); + const pollfdPointer = options.pollfdPointer; + const outputBytes = intrinsicUint8ArrayView( + options.outputBytes, + "immediate poll timeout test output", + ); + if ( + !Number.isSafeInteger(pollfdPointer) + || pollfdPointer < 0 + || outputBytes.byteLength !== STRUCT_SIZE_WASM_POLL_FD + ) { + throw new TypeError( + "immediate poll timeout copy-back test output is invalid", + ); + } + const pollfdRange = this.checkedProcessRange( + channel, + pollfdPointer, + STRUCT_SIZE_WASM_POLL_FD, + "immediate poll timeout test pollfd", + ); + const origArgs = [pollfdRange.pointer, 1, 0]; + const argDescs = SYSCALL_ARGS[SYS_POLL]; + const pollfdDesc = argDescs?.find( + (desc) => desc.argIndex === 0, + ); + if (!pollfdDesc) { + throw new TypeError( + "immediate poll timeout test has no pollfd descriptor", + ); + } + const inputBytes = new Uint8Array( + channel.memory.buffer, + pollfdRange.pointer, + pollfdRange.length, + ).slice(); + const plannedPollfd = { + argIndex: 0, + desc: pollfdDesc, + processPointer: pollfdRange.pointer, + scratchOffset: CH_DATA, + size: pollfdRange.length, + inputBytes, + }; + // WHY: production reaches handleBlockingRetry only after the + // first EAGAIN pass has detached a complete generic-channel plan. + // This narrow seam must model that same ownership boundary; a + // timeout-zero shortcut with no snapshot would accidentally + // bless later retries that reread a mutable guest mailbox. + this.blockingRetrySnapshots.set(channel, { + kind: "generic-channel", + syscallNr: SYS_POLL, + origArgs: origArgs.slice(), + argDescs, + dispatch: { + syscallNr: SYS_POLL, + adjustedArgs: [0, 1, 0], + plannedZeroLengthScratchArgMask: 0, + plannedScratchWrites: [plannedPollfd], + plannedChannelScratchArgs: [plannedPollfd], + plannedChannelCapacity: CH_TOTAL_SIZE, + schedGetaffinityOutputInvalid: false, + retryForbiddenByCallFlags: false, + readinessTimeoutMs: 0, + }, + retryToken: 0n, + cancellationPoint: false, + cancellationWakeAllowed: false, + retryForbiddenByCallFlags: false, + fdWasNonblocking: false, + applicableSocketTimeoutMs: 0, + }); + this.handleBlockingRetry( + channel, + SYS_POLL, + origArgs, + [{ + ptr: pollfdRange.pointer, + bytes: outputBytes, + }], + entry, + ); + return undefined; + }, ); - } - }; - const rangeEnd = ( - pointerIndex: number, - lengthIndex: number, - field: string, - ): void => { - const end = args[pointerIndex] + args[lengthIndex]; - if ( - !Number.isSafeInteger(end) - || end < args[pointerIndex] - ) { - throw new KernelScratchError(`${field} overflows`, EINVAL); - } - const alignedLength = - Math.ceil(args[lengthIndex] / WASM_PAGE_SIZE) * WASM_PAGE_SIZE; - if (!Number.isSafeInteger(alignedLength)) { - throw new KernelScratchError( - `${field} page alignment overflows`, - EINVAL, + }, + sendSignalForTest: ( + targetPid, + signum, + queueSignal = true, + ): void => { + this.sendSignalToProcess(targetPid, signum, queueSignal); + }, + completeSleepWithSignalCheckForTest: ( + channel, + syscallNr, + origArgs, + retVal, + errVal, + ): void => { + this.completeSleepWithSignalCheck( + channel, + syscallNr, + origArgs, + retVal, + errVal, ); - } - const alignedEnd = args[pointerIndex] + alignedLength; - if ( - !Number.isSafeInteger(alignedEnd) - || alignedEnd < args[pointerIndex] - ) { - throw new KernelScratchError( - `${field} page-aligned end overflows`, - EINVAL, + }, + dequeueSignalForDeliveryForTest: (channel): number => { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + let result: number | undefined; + this.#runImmediateKernelEntry( + "signal dequeue test", + (entry) => { + result = this.#dequeueSignalForDelivery(channel, entry); + return undefined; + }, ); - } - }; - - switch (syscallNr) { - case SYS_MMAP: - pointer(0, "mmap address"); - size(1, "mmap length"); - rangeEnd(0, 1, "mmap range"); - return; - case SYS_MUNMAP: - case SYS_MPROTECT: - case SYS_MSYNC: - pointer(0, `syscall ${syscallNr} address`); - size(1, `syscall ${syscallNr} length`); - rangeEnd(0, 1, `syscall ${syscallNr} range`); - return; - case SYS_MREMAP: { - pointer(0, "mremap old address"); - size(1, "mremap old length"); - size(2, "mremap new length"); - const flags = Number(BigInt.asUintN(32, rawArgs[3] ?? 0n)); - args[3] = flags; - if ((flags & MREMAP_FIXED) !== 0) { - pointer(4, "mremap fixed address"); - rangeEnd(4, 2, "mremap fixed range"); - } - rangeEnd(0, 1, "mremap old range"); - return; - } - case SYS_BRK: - pointer(0, "brk address"); - return; - case SYS_SPAWN: - pointer(0, "spawn path pointer"); - size(1, "spawn path length"); - pointer(2, "spawn blob pointer"); - size(3, "spawn blob length"); - pointer(4, "spawn pid output pointer"); - return; - case SYS_EXECVE: - pointer(0, "execve path pointer"); - pointer(1, "execve argv pointer"); - pointer(2, "execve environment pointer"); - return; - case SYS_EXECVEAT: - pointer(1, "execveat path pointer"); - pointer(2, "execveat argv pointer"); - pointer(3, "execveat environment pointer"); - return; - case SYS_CLONE: { - const flags = Number(BigInt.asUintN(32, rawArgs[0] ?? 0n)); - args[0] = flags; - pointer(1, "clone stack pointer"); - // The attachment handed to the new Worker carries this slot even - // when a future clone variant omits CLONE_SETTLS. - pointer(3, "clone TLS pointer"); - if ((flags & CLONE_PARENT_SETTID) !== 0) { - pointer(2, "clone parent tid pointer"); - } - if ((flags & (CLONE_CHILD_CLEARTID | CLONE_CHILD_SETTID)) !== 0) { - pointer(4, "clone child tid pointer"); - } - return; - } - case SYS_WAIT4: - pointer(1, "wait4 status pointer"); - pointer(3, "wait4 rusage pointer"); - return; - case SYS_WAITID: - pointer(2, "waitid siginfo pointer"); - pointer(4, "waitid rusage pointer"); - return; - case SYS_FUTEX: { - pointer(0, "futex uaddr"); - const op = Number(BigInt.asUintN(32, rawArgs[1] ?? 0n)); - args[1] = op; - const command = - op & ~(FUTEX_PRIVATE_FLAG | FUTEX_CLOCK_REALTIME); - if (command === FUTEX_WAIT || command === FUTEX_WAIT_BITSET) { - pointer(3, "futex timeout pointer"); + if (result === undefined) { + throw new Error("signal dequeue test did not return a result"); } + return result; + }, + drainWakeupEventsForTest: (): void => { + // WHY: advisory-lock tests must exercise the real Rust wake decoder + // under one exact generation scope. Expose only this argument-free + // operation; returning the instance, scratch, or a target-bearing + // dispatcher would reopen the authority boundary that the sealed + // worker is meant to protect. + this.#runImmediateKernelEntry( + "advisory-lock wake drain test", + (entry) => { + this.#drainAndProcessWakeupEventsWithinKernelEntry(entry); + return undefined; + }, + ); + }, + dispatchRegisteredMainChannelForAdvisoryLockTest: (pid): void => { if ( - command === FUTEX_REQUEUE - || command === FUTEX_CMP_REQUEUE - || command === FUTEX_WAKE_OP + !Number.isSafeInteger(pid) + || pid <= 0 + || pid > MAX_KERNEL_TASK_ID ) { - pointer(4, "futex uaddr2"); + throw new TypeError( + "advisory-lock test process ID is invalid", + ); } - return; - } - case SYS_WRITEV: - case SYS_PWRITEV: - case SYS_PWRITEV2: - case SYS_READV: - case SYS_PREADV: - case SYS_PREADV2: - pointer(1, "iovec table pointer"); - return; - case SYS_SENDMSG: - case SYS_RECVMSG: - pointer(1, "message header pointer"); - return; - case SYS_PSELECT6: - pointer(1, "pselect6 read fd_set pointer"); - pointer(2, "pselect6 write fd_set pointer"); - pointer(3, "pselect6 except fd_set pointer"); - pointer(4, "pselect6 timeout pointer"); - pointer(5, "pselect6 mask descriptor pointer"); - return; - case SYS_SELECT: - pointer(1, "select read fd_set pointer"); - pointer(2, "select write fd_set pointer"); - pointer(3, "select except fd_set pointer"); - pointer(4, "select timeout pointer"); - return; - case SYS_FCNTL: { - const command = args[1]; + const registration = this.processes.get(pid); + const channel = registration?.channels[0]; if ( - command === F_GETLK - || command === F_SETLK - || command === F_SETLKW - || command === F_GETLK64 - || command === F_SETLK64 - || command === F_SETLKW64 - || command === F_OFD_GETLK - || command === F_OFD_SETLK - || command === F_OFD_SETLKW + registration === undefined + || registration.channels.length !== 1 + || channel === undefined + || channel.pid !== pid + || kernelEntryIntrinsicApply( + kernelEntryIntrinsicAtomicsLoad, + kernelEntryIntrinsicAtomics, + [ + channel.i32View, + CH_STATUS / KERNEL_ENTRY_I32_BYTES, + ], + ) !== CH_PENDING ) { - pointer(2, "fcntl flock pointer"); + throw new Error( + "advisory-lock test requires one exact pending main channel", + ); } - return; - } - case SYS_IOCTL: { - const request = Number(BigInt.asUintN(32, rawArgs[1] ?? 0n)); + // WHY: the test owns process Memory but receives no registered channel, + // kernel instance, export namespace, or scratch lease. Immediate-only + // entry means a busy generation rejects without retaining `pid` or + // dispatching the caller's mailbox later. + this.#runImmediateKernelEntry( + "advisory-lock registered main-channel test dispatch", + (entry) => { + const current = this.processes.get(pid); + if ( + current !== registration + || current.channels.length !== 1 + || current.channels[0] !== channel + || kernelEntryIntrinsicApply( + kernelEntryIntrinsicAtomicsLoad, + kernelEntryIntrinsicAtomics, + [ + channel.i32View, + CH_STATUS / KERNEL_ENTRY_I32_BYTES, + ], + ) !== CH_PENDING + ) { + throw new Error( + "advisory-lock test main channel changed before dispatch", + ); + } + this.#handleSyscallInner(channel, entry); + return undefined; + }, + ); + }, + forkKernelProcessForAdvisoryLockTest: ( + parentPid, + callerTid, + ): number => { if ( - request === SIOCGIFCONF - || request === SIOCGIFNAME - || request === SIOCGIFHWADDR - || request === SIOCGIFADDR - || request === SIOCGIFINDEX + !Number.isSafeInteger(parentPid) + || parentPid <= 0 + || parentPid > MAX_KERNEL_TASK_ID + || !Number.isSafeInteger(callerTid) + || callerTid <= 0 + || callerTid > MAX_KERNEL_TASK_ID ) { - pointer(2, "network ioctl pointer"); + throw new TypeError( + "advisory-lock fork test task identity is invalid", + ); } - return; - } - case SYS_WRITE: - case SYS_PWRITE: - case SYS_READ: - case SYS_PREAD: - if ((rawArgs[2] ?? 0n) > BigInt(CH_DATA_SIZE)) { - pointer(1, "large I/O buffer pointer"); - size(2, "large I/O byte count"); + let childPid: number | undefined; + // WHY: this fixed-export seam tests Rust lock inheritance without + // pretending to launch a host Worker. It returns only a validated task + // ID and never lends the export, its namespace, or entry authority. + this.#runImmediateKernelEntry( + "advisory-lock kernel fork test", + (entry) => { + const forkProcess = this.#kernelInstanceForEntry(entry).exports + .kernel_fork_process as + | ((parent: number, caller: number) => number) + | undefined; + if (forkProcess === undefined) { + throw new Error( + "kernel missing advisory-lock fork test export", + ); + } + const result = forkProcess(parentPid, callerTid); + if ( + !Number.isSafeInteger(result) + || result <= 0 + || result > MAX_KERNEL_TASK_ID + ) { + throw new Error( + "kernel returned an invalid advisory-lock fork child", + ); + } + childPid = result; + return undefined; + }, + ); + if (childPid === undefined) { + throw new Error( + "advisory-lock fork test did not return a child", + ); } - return; - } - } - - private normalizeKernelSyscallResult( - channel: ChannelInfo, - syscallNr: number, - rawRetVal: bigint, - errVal: number, - ): { retVal: number; errVal: number } { - if ( - rawRetVal < 0n - || ( - syscallNr !== SYS_MMAP - && syscallNr !== SYS_MREMAP - && syscallNr !== SYS_BRK - ) - ) { - return { retVal: Number(rawRetVal), errVal }; - } - try { - // WHY: these positive results become process-memory indices. Validate - // the complete i64 before any grow, zero, copy, or mapping mutation can - // reinterpret its low 32 bits. - return { - retVal: checkedWasmPointer( - rawRetVal, - this.getPtrWidth(channel.pid), - `syscall ${syscallNr} returned address`, - ), - errVal, - }; - } catch { - return { retVal: -1, errVal: EOVERFLOW }; - } - } + return childPid; + }, + probeMqueueNotificationCapacityForTest: (options) => { + const outcome: { + inputError?: TypeError; + value?: KernelCapacityProbeResult; + } = {}; + // WHY: this test seam names one fixed producer and one fixed consumer. + // Select immediate ingress before reading caller-owned options so a + // busy rejection neither retains nor later replays their witness. + this.#runImmediateKernelEntry( + "mqueue notification capacity probe", + (entry) => { + let registrationWitness: ChannelInfo; + let descriptor: number; + let triggerNotification: boolean; + let destination: CapacityProbeDestination; + let capacity: number; + try { + registrationWitness = options.registrationWitness; + descriptor = options.descriptor; + triggerNotification = options.triggerNotification; + destination = options.destination; + capacity = options.capacity; + } catch { + outcome.inputError = new TypeError( + "mqueue notification capacity probe options are invalid", + ); + return undefined; + } + const channelSnapshot = + this.#snapshotCapacityProbeMainChannel( + registrationWitness, + "mqueue notification capacity probe", + ); + if (channelSnapshot.inputError !== undefined) { + outcome.inputError = channelSnapshot.inputError; + return undefined; + } + const validCapacity = destination === "null" + ? capacity === KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES + : destination === "guarded" + && ( + capacity + === KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES - 1 + || capacity + === KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES + || capacity + === KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES + 1 + ); + if ( + !Number.isSafeInteger(descriptor) + || descriptor < 0 + || descriptor > MAX_KERNEL_TASK_ID + || ( + triggerNotification !== true + && triggerNotification !== false + ) + || !Number.isSafeInteger(capacity) + || !validCapacity + ) { + outcome.inputError = new TypeError( + "mqueue notification capacity probe arguments are invalid", + ); + return undefined; + } + const { channel, pointerWidth } = channelSnapshot; + + if (triggerNotification) { + const sent = this.#requireMainScratchRegion().withLease( + (lease) => { + lease.fill(0, 0, CH_TOTAL_SIZE); + lease.fill(0x51, CH_DATA, 1); + const view = lease.dataView(0, CH_TOTAL_SIZE); + view.setUint32(CH_STATUS, CH_PENDING, true); + view.setUint32(CH_SYSCALL, SYS_MQ_TIMEDSEND, true); + view.setBigInt64(CH_ARGS, BigInt(descriptor), true); + lease.writeAddress( + CH_ARGS + CH_ARG_SIZE, + CH_DATA, + 1, + "u64-le", + ); + view.setBigInt64( + CH_ARGS + 2 * CH_ARG_SIZE, + 1n, + true, + ); + view.setBigInt64( + CH_ARGS + 3 * CH_ARG_SIZE, + 0n, + true, + ); + view.setBigInt64( + CH_ARGS + 4 * CH_ARG_SIZE, + 0n, + true, + ); + view.setBigInt64( + CH_ARGS + 5 * CH_ARG_SIZE, + BigInt(pointerWidth), + true, + ); + this.#bindKernelTidForChannel(channel, entry); + const previousHandlePid = this.currentHandlePid; + this.currentHandlePid = channel.pid; + try { + this.#invokeEntryScratchExport( + entry, + lease, + "kernel_handle_channel", + [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + 0n, + ], + ); + } finally { + this.currentHandlePid = previousHandlePid; + } + return { + value: Number(view.getBigInt64(CH_RETURN, true)), + errno: view.getUint32(CH_ERRNO, true), + }; + }, + ); + if (sent.value !== 0 || sent.errno !== 0) { + throw new Error( + "mqueue notification capacity probe trigger failed", + ); + } + } - private checkedProcessIovecs( - channel: ChannelInfo, - iovPointer: number | bigint, - iovCount: number, - allowEmpty: boolean, - ): CheckedProcessIovecs { - if ( - !Number.isSafeInteger(iovCount) || - iovCount < (allowEmpty ? 0 : 1) || - iovCount > POSIX_IOV_MAX - ) { - throw new KernelScratchError( - `iovec count must be ${allowEmpty ? "between 0" : "between 1"} and ${POSIX_IOV_MAX}`, - EINVAL, - ); - } - if (iovCount === 0) return { entries: [], totalData: 0 }; - const pointerWidth = this.getPtrWidth(channel.pid); - const layout = this.processIovecLayout(pointerWidth); - const tableBytes = iovCount * layout.size; - if (!Number.isSafeInteger(tableBytes)) { - throw new KernelScratchError("process iovec table size overflows", EINVAL); - } - const table = this.checkedProcessRange( - channel, - iovPointer, - tableBytes, - "process iovec table", - // WHY: zero is an addressable byte in caller process linear memory. - // It means allocator failure only for kernel allocator/export results, - // so a nonempty caller-owned table at address zero is valid when its - // complete native table range fits. - true, - ); - const processView = new DataView( - channel.memory.buffer, - table.pointer, - table.length, - ); - const entries: CheckedProcessIovec[] = []; - let totalData = 0; - for (let index = 0; index < iovCount; index++) { - const offset = index * layout.size; - const rawBase = pointerWidth === 8 - ? processView.getBigUint64(offset + layout.baseOffset, true) - : processView.getUint32(offset + layout.baseOffset, true); - const rawLength = pointerWidth === 8 - ? processView.getBigUint64(offset + layout.lenOffset, true) - : processView.getUint32(offset + layout.lenOffset, true); - const base = checkedWasmPointer( - rawBase, - pointerWidth, - `iovec[${index}] base`, - ); - const lengthRange = rawLength === 0n || rawLength === 0 - ? { pointer: base, length: 0 } - : this.checkedProcessRange( - channel, - rawBase, - rawLength, - `iovec[${index}] data`, - // WHY: like the table itself, positive-length caller data may - // begin at linear-memory address zero. The complete range proof, - // rather than null-pointer convention, establishes ownership. - true, + const guardedLength = + KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES + 2; + const probe = this.#requireMainScratchRegion().withLease( + (lease) => { + lease.fill(0xa5, 0, guardedLength); + let result: number; + if (destination === "null") { + const drain = this.#kernelInstanceForEntry(entry).exports + .kernel_mq_drain_notification as ( + outPtr: KernelPointer, + outCapacity: number, + ) => number; + result = drain( + this.#kernelPointerWidth === 8 ? 0n : 0, + capacity, + ); + } else { + result = this.#invokeEntryScratchExport( + entry, + lease, + "kernel_mq_drain_notification", + [ + lease.exportPointer(1, capacity), + capacity, + ], + ); + } + return { + result, + guardedBytes: lease.copyOut(0, guardedLength), + }; + }, + ); + outcome.value = kernelEntryIntrinsicObjectFreeze(probe); + return undefined; + }, + ); + if (outcome.inputError !== undefined) throw outcome.inputError; + if (outcome.value === undefined) { + throw new Error( + "mqueue notification capacity probe returned no result", ); - const len = lengthRange.length; - totalData += len; - if (!Number.isSafeInteger(totalData) || totalData > 0x7fff_ffff) { - throw new KernelScratchError( - "aggregate iovec length exceeds SSIZE_MAX", - EINVAL, + } + return outcome.value; + }, + probeWaitableChildCapacityForTest: (options) => { + const outcome: { + inputError?: TypeError; + value?: KernelCapacityProbeResult; + } = {}; + // WHY: invalid destinations must be observed before Rust selects or + // consumes the sole wait record. Immediate-only entry also ensures a + // busy rejection cannot replay a caller-owned child or witness later. + this.#runImmediateKernelEntry( + "waitable-child capacity probe", + (entry) => { + let registrationWitness: ChannelInfo; + let childPid: number; + let destination: CapacityProbeDestination; + let capacity: number; + try { + registrationWitness = options.registrationWitness; + childPid = options.childPid; + destination = options.destination; + capacity = options.capacity; + } catch { + outcome.inputError = new TypeError( + "waitable-child capacity probe options are invalid", + ); + return undefined; + } + const channelSnapshot = + this.#snapshotCapacityProbeMainChannel( + registrationWitness, + "waitable-child capacity probe", + ); + if (channelSnapshot.inputError !== undefined) { + outcome.inputError = channelSnapshot.inputError; + return undefined; + } + const validCapacity = destination === "null" + ? capacity === STRUCT_SIZE_KERNEL_WAIT_RESULT + : destination === "guarded" + && ( + capacity === STRUCT_SIZE_KERNEL_WAIT_RESULT - 1 + || capacity === STRUCT_SIZE_KERNEL_WAIT_RESULT + || capacity === STRUCT_SIZE_KERNEL_WAIT_RESULT + 1 + ); + if ( + !Number.isSafeInteger(childPid) + || childPid <= 0 + || childPid > MAX_KERNEL_TASK_ID + || !Number.isSafeInteger(capacity) + || !validCapacity + ) { + outcome.inputError = new TypeError( + "waitable-child capacity probe arguments are invalid", + ); + return undefined; + } + const { channel } = channelSnapshot; + const callerTid = this.guestTidForChannel(channel); + const guardedLength = STRUCT_SIZE_KERNEL_WAIT_RESULT + 2; + const probe = this.#requireMainScratchRegion().withLease( + (lease) => { + lease.fill(0xa5, 0, guardedLength); + let result: number; + if (destination === "null") { + const waitPoll = this.#kernelInstanceForEntry(entry).exports + .kernel_wait_child_poll as ( + parentPid: number, + callerTid: number, + targetPid: number, + eventMask: number, + flags: number, + resultPtr: KernelPointer, + resultCapacity: number, + ) => number; + result = waitPoll( + channel.pid, + callerTid, + childPid, + WAIT_EVENT_EXITED, + 0, + this.#kernelPointerWidth === 8 ? 0n : 0, + capacity, + ); + } else { + result = this.#invokeEntryScratchExport( + entry, + lease, + "kernel_wait_child_poll", + [ + channel.pid, + callerTid, + childPid, + WAIT_EVENT_EXITED, + 0, + lease.exportPointer(1, capacity), + capacity, + ], + ); + } + return { + result, + guardedBytes: lease.copyOut(0, guardedLength), + }; + }, + ); + outcome.value = kernelEntryIntrinsicObjectFreeze(probe); + return undefined; + }, ); - } - entries.push({ base, len }); - } - return { entries, totalData }; - } - - private processIovecLayout(pointerWidth: 4 | 8): ProcessIovecLayout { - return pointerWidth === 8 - ? { - size: PROCESS_IOVEC_WASM64_SIZE, - baseOffset: PROCESS_IOVEC_WASM64_BASE_OFFSET, - lenOffset: PROCESS_IOVEC_WASM64_LEN_OFFSET, + if (outcome.inputError !== undefined) throw outcome.inputError; + if (outcome.value === undefined) { + throw new Error( + "waitable-child capacity probe returned no result", + ); } - : { - size: PROCESS_IOVEC_WASM32_SIZE, - baseOffset: PROCESS_IOVEC_WASM32_BASE_OFFSET, - lenOffset: PROCESS_IOVEC_WASM32_LEN_OFFSET, - }; - } - - private processMessageLayout(pointerWidth: 4 | 8): ProcessMessageLayout { - return pointerWidth === 8 - ? { - size: PROCESS_MSGHDR_WASM64_SIZE, - nameOffset: PROCESS_MSGHDR_WASM64_NAME_OFFSET, - nameLengthOffset: PROCESS_MSGHDR_WASM64_NAMELEN_OFFSET, - iovecOffset: PROCESS_MSGHDR_WASM64_IOV_OFFSET, - iovecCountOffset: PROCESS_MSGHDR_WASM64_IOVLEN_OFFSET, - controlOffset: PROCESS_MSGHDR_WASM64_CONTROL_OFFSET, - controlLengthOffset: PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET, - flagsOffset: PROCESS_MSGHDR_WASM64_FLAGS_OFFSET, + return outcome.value; + }, + inspectThreadTransportStateForLifecycleTest: (pid) => { + const outcome: { + inputError?: TypeError; + value?: ThreadTransportStateTestResult; + } = {}; + // WHY: unregister cleanup has no public observer for its three + // host-only pthread ownership indexes. Return only their per-PID + // aggregate counts so the test can prove retirement without learning + // or mutating any channel, continuation, clear-TID pointer, or Map. + this.#runImmediateKernelEntry( + "pthread transport state lifecycle inspection", + () => { + if ( + !Number.isSafeInteger(pid) + || pid <= 0 + || pid > MAX_KERNEL_TASK_ID + ) { + outcome.inputError = new TypeError( + "pthread transport lifecycle pid is invalid", + ); + return undefined; + } + const prefix = `${pid}:`; + let channelTidEntries = 0; + let forkContextEntries = 0; + let clearTidEntries = 0; + let activeThreadChannels = 0; + for (const [key, tid] of this.channelTids) { + if (key.startsWith(prefix) && tid !== pid) { + channelTidEntries++; + } + } + for (const key of this.threadForkContexts.keys()) { + if (key.startsWith(prefix)) forkContextEntries++; + } + for (const key of this.threadCtidPtrs.keys()) { + if (key.startsWith(prefix)) clearTidEntries++; + } + for (const channel of this.activeChannels) { + if (channel.pid !== pid) continue; + const tid = this.channelTids.get( + `${pid}:${channel.channelOffset}`, + ); + if (tid !== undefined && tid !== pid) { + activeThreadChannels++; + } + } + outcome.value = kernelEntryIntrinsicObjectFreeze({ + channelTidEntries, + forkContextEntries, + clearTidEntries, + activeThreadChannels, + }); + return undefined; + }, + ); + if (outcome.inputError !== undefined) throw outcome.inputError; + if (outcome.value === undefined) { + throw new Error( + "pthread transport lifecycle inspection returned no result", + ); } - : { - size: PROCESS_MSGHDR_WASM32_SIZE, - nameOffset: PROCESS_MSGHDR_WASM32_NAME_OFFSET, - nameLengthOffset: PROCESS_MSGHDR_WASM32_NAMELEN_OFFSET, - iovecOffset: PROCESS_MSGHDR_WASM32_IOV_OFFSET, - iovecCountOffset: PROCESS_MSGHDR_WASM32_IOVLEN_OFFSET, - controlOffset: PROCESS_MSGHDR_WASM32_CONTROL_OFFSET, - controlLengthOffset: PROCESS_MSGHDR_WASM32_CONTROLLEN_OFFSET, - flagsOffset: PROCESS_MSGHDR_WASM32_FLAGS_OFFSET, - }; + return outcome.value; + }, + dispatchUntrackedForkForTaskAuthorityTest: ( + pid, + registrationWitness, + channelOffset, + origArgs, + ): void => { + const outcome: { inputError?: TypeError } = {}; + this.#runImmediateKernelEntry( + "untracked fork task-authority test", + (entry) => { + const channelSnapshot = + this.#snapshotUntrackedTaskAuthorityChannel( + pid, + registrationWitness, + channelOffset, + ); + if (channelSnapshot.inputError !== undefined) { + outcome.inputError = channelSnapshot.inputError; + return undefined; + } + const argsSnapshot = + this.#snapshotTaskAuthorityTestArgs(origArgs, 1); + if (argsSnapshot.inputError !== undefined) { + outcome.inputError = argsSnapshot.inputError; + return undefined; + } + this.handleFork( + channelSnapshot.channel, + argsSnapshot.args, + entry, + ); + return undefined; + }, + ); + if (outcome.inputError !== undefined) throw outcome.inputError; + }, + dispatchUntrackedExecForTaskAuthorityTest: ( + pid, + registrationWitness, + channelOffset, + origArgs, + ): void => { + const outcome: { inputError?: TypeError } = {}; + this.#runImmediateKernelEntry( + "untracked exec task-authority test", + (entry) => { + const channelSnapshot = + this.#snapshotUntrackedTaskAuthorityChannel( + pid, + registrationWitness, + channelOffset, + ); + if (channelSnapshot.inputError !== undefined) { + outcome.inputError = channelSnapshot.inputError; + return undefined; + } + const argsSnapshot = + this.#snapshotTaskAuthorityTestArgs(origArgs, 3); + if (argsSnapshot.inputError !== undefined) { + outcome.inputError = argsSnapshot.inputError; + return undefined; + } + this.handleExec( + channelSnapshot.channel, + argsSnapshot.args, + entry, + ); + return undefined; + }, + ); + if (outcome.inputError !== undefined) throw outcome.inputError; + }, + dispatchUntrackedExecveatForTaskAuthorityTest: ( + pid, + registrationWitness, + channelOffset, + origArgs, + ): void => { + const outcome: { inputError?: TypeError } = {}; + this.#runImmediateKernelEntry( + "untracked execveat task-authority test", + (entry) => { + const channelSnapshot = + this.#snapshotUntrackedTaskAuthorityChannel( + pid, + registrationWitness, + channelOffset, + ); + if (channelSnapshot.inputError !== undefined) { + outcome.inputError = channelSnapshot.inputError; + return undefined; + } + const argsSnapshot = + this.#snapshotTaskAuthorityTestArgs(origArgs, 5); + if (argsSnapshot.inputError !== undefined) { + outcome.inputError = argsSnapshot.inputError; + return undefined; + } + this.handleExecveat( + channelSnapshot.channel, + argsSnapshot.args, + entry, + ); + return undefined; + }, + ); + if (outcome.inputError !== undefined) throw outcome.inputError; + }, + dispatchUntrackedThreadExitForTaskAuthorityTest: ( + pid, + registrationWitness, + channelOffset, + exitStatus, + ): void => { + const outcome: { inputError?: TypeError } = {}; + this.#runImmediateKernelEntry( + "untracked pthread exit task-authority test", + (entry) => { + const channelSnapshot = + this.#snapshotUntrackedTaskAuthorityChannel( + pid, + registrationWitness, + channelOffset, + ); + if (channelSnapshot.inputError !== undefined) { + outcome.inputError = channelSnapshot.inputError; + return undefined; + } + if (!Number.isSafeInteger(exitStatus)) { + outcome.inputError = new TypeError( + "task-authority pthread exit status is invalid", + ); + return undefined; + } + this.handleExit( + channelSnapshot.channel, + SYS_EXIT, + [exitStatus], + entry, + ); + return undefined; + }, + ); + if (outcome.inputError !== undefined) throw outcome.inputError; + }, + dispatchScratchBoundarySyscallForTest: (channel): void => { + // WHY: the boundary suite needs to exercise the exact private + // dispatch stage, including failures that the outer channel listener + // normally catches. Consume the same request-header identity as + // production, but deliberately bypass stopped-process deferral and + // outer fatal conversion so those independent lifecycle contracts + // remain directly observable in this test seam. + this.#runImmediateKernelEntry( + "scratch-boundary test syscall", + (entry) => { + if (!this.#captureChannelRequest(channel, entry)) return undefined; + this.#handleSyscallInner(channel, entry); + return undefined; + }, + ); + }, + dispatchSpawnPreflightForTest: (channel, origArgs): void => { + // WHY: spawn transport tests need the private preflight split without + // reopening a mutable method shadow on the sealed worker. Reject a + // reentrant test invocation because its caller-owned argv has not + // crossed the production channel snapshot boundary. + this.#runImmediateKernelEntry( + "spawn preflight test", + (entry) => { + this.#handleSpawn(channel, origArgs, entry); + return undefined; + }, + ); + }, + dispatchSpawnAfterResolveForTest: (options): void => { + // WHY: malformed allocator returns cannot be reached through a real + // Rust parser. Keep one exact test-only stage boundary, but run it + // under the same generation gate and lexical entry lifetime as the + // production continuation. Reject reentry because this fault seam + // intentionally accepts non-genuine byte producers that cannot be + // safely snapshotted for deferred use. + this.#runImmediateKernelEntry( + "resolved spawn transport test", + (entry) => { + this.#handleSpawnAfterResolve( + options.channel, + options.origArgs, + options.parentPid, + options.callerTid, + options.pidOutPtr, + options.blobBytes, + options.blobLen, + options.program, + options.envp, + entry, + ); + return undefined; + }, + ); + }, + replaceProcessRegistrationForLifecycleTest: (options) => { + // WHY: this companion mutates the exact process-registration + // generation observed by lifecycle callbacks. Read and validate every + // caller-owned property only while the host-operation barrier is held, + // then publish the replacement in one synchronous commit. + let registeredPid = 0; + let listener: + | Readonly<{ fd: number; port: number }> + | undefined; + const channels = this.#kernelEntryGate.runSerializedHostOperation( + "lifecycle test process registration replacement", + () => { + if (!this.#initialized || this.#kernelInstance === null) { + throw new Error("test kernel generation is not initialized"); + } + const pid = options.pid; + const memory = options.memory; + const pointerWidth = options.pointerWidth ?? 4; + const channelOffsets = [...options.channelOffsets]; + const requestedListener = options.tcpListener; + if ( + !Number.isSafeInteger(pid) + || pid <= 0 + || pid > MAX_KERNEL_TASK_ID + ) { + throw new TypeError("lifecycle test pid is invalid"); + } + if (pointerWidth !== 4 && pointerWidth !== 8) { + throw new TypeError( + "lifecycle test pointer width is invalid", + ); + } + if ( + requestedListener !== undefined + && ( + !Number.isSafeInteger(requestedListener.fd) + || requestedListener.fd < 0 + || !Number.isSafeInteger(requestedListener.port) + || requestedListener.port < 0 + || requestedListener.port > 0xffff + ) + ) { + throw new TypeError( + "lifecycle test TCP listener identity is invalid", + ); + } + if (memory === this.#kernelMemory) { + throw new TypeError( + "lifecycle process Memory must not be kernel Memory", + ); + } + const memoryBuffer = kernelEntryMemoryBuffer(memory); + const memoryBytes = new Uint8Array(memoryBuffer); + const channels: ChannelInfo[] = []; + for ( + let index = 0; + index < channelOffsets.length; + index++ + ) { + const channelOffset = channelOffsets[index]!; + if ( + !Number.isSafeInteger(channelOffset) + || channelOffset < 0 + || channelOffset % KERNEL_ENTRY_I32_BYTES !== 0 + || channelOffset + > memoryBytes.byteLength - CH_TOTAL_SIZE + ) { + throw new TypeError( + "lifecycle test channel is outside process memory", + ); + } + kernelEntryIntrinsicApply( + kernelEntryIntrinsicArrayPush, + channels, + [{ + pid, + memory, + channelOffset, + i32View: new KernelEntryIntrinsicInt32Array( + memoryBuffer, + channelOffset, + ), + consecutiveSyscalls: 0, + } satisfies ChannelInfo], + ); + } + const previous = this.processes.get(pid); + for (const channel of previous?.channels ?? []) { + this.channelTids.delete( + `${channel.pid}:${channel.channelOffset}`, + ); + } + this.processes.set(pid, { + pid, + memory, + channels, + ptrWidth: pointerWidth, + explicitMaxAddr: false, + }); + for (const channel of channels) { + this.channelTids.set( + `${channel.pid}:${channel.channelOffset}`, + pid, + ); + } + const exposedChannels: ChannelInfo[] = []; + for (let index = 0; index < channels.length; index++) { + kernelEntryIntrinsicApply( + kernelEntryIntrinsicArrayPush, + exposedChannels, + [channels[index]!], + ); + } + registeredPid = pid; + listener = requestedListener === undefined + ? undefined + : kernelEntryIntrinsicObjectFreeze({ + fd: requestedListener.fd, + port: requestedListener.port, + }); + return kernelEntryIntrinsicObjectFreeze(exposedChannels); + }, + ); + if (listener !== undefined) { + const exactListener = listener; + // WHY: the optional listener setup carries only validated scalars + // out of the host-registration barrier and then follows the exact + // production entry/host-phase split. No test receives a listener + // map, kernel view, or generic mutation primitive. + this.#runImmediateKernelEntry( + "TCP listener fork lifecycle test registration", + (entry) => { + const plan = this.#prepareTcpListenerRegistration( + registeredPid, + exactListener.fd, + exactListener.port, + entry, + ); + entry.deferProtocolEffect(() => { + this.#startTcpListenerHostPhase( + registeredPid, + exactListener.fd, + exactListener.port, + [0, 0, 0, 0], + plan, + ); + return undefined; + }); + return undefined; + }, + ); + } + return channels; + }, + resumeStoppedProcessForTest: (pid): boolean => { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + let resumed: boolean | undefined; + this.#runImmediateKernelEntry( + "stopped-process resume test", + (entry) => { + resumed = this.resumeStoppedProcess(pid, entry); + return undefined; + }, + ); + if (resumed === undefined) { + throw new Error( + "stopped-process resume test did not return a result", + ); + } + return resumed; + }, + discardStoppedProcessStateForTest: (pid): void => { + this.#kernelEntryGate.runSerializedHostOperation( + "stopped-process discard test", + () => { + this.discardStoppedChannelStateForProcess(pid); + }, + ); + }, + installParkedCloneCompletionForTest: (options): void => { + this.#kernelEntryGate.runSerializedHostOperation( + "parked clone completion installation test", + () => { + const channel = options.channel; + const tid = options.tid; + const parentTidPointer = options.parentTidPointer; + const registration = this.processes.get(channel.pid); + // Do not consult the overridable scratch-boundary registration + // hook here. This lifecycle capability is valid only for the + // exact channel object and process-Memory generation installed in + // the authoritative process table. + if ( + registration === undefined + || registration.memory !== channel.memory + || !registration.channels.includes(channel) + ) { + throw new TypeError( + "parked clone test channel is not the exact registration", + ); + } + const processBytes = new Uint8Array( + kernelEntryMemoryBuffer(registration.memory), + ); + if ( + !Number.isSafeInteger(tid) + || tid <= 0 + || !isValidMemoryRange( + processBytes, + parentTidPointer, + KERNEL_ENTRY_I32_BYTES, + ) + ) { + throw new TypeError( + "parked clone test identity is invalid", + ); + } + if (this.parkedChannelCompletions.has(channel)) { + throw new Error( + "parked clone test completion is already installed", + ); + } + this.stoppedPids.add(channel.pid); + this.parkedChannelCompletions.set(channel, { + prepared: { + kind: "marshalled", + outputWrites: [], + retVal: tid, + errVal: 0, + materialized: true, + relistenRequested: true, + deferredClone: { + tid, + parentTidPointer, + }, + }, + relistenRequested: true, + }); + }, + ); + }, + replaceKernelForScratchBoundaryTest: (kernel): void => { + if (!(kernel instanceof WasmPosixKernel)) { + throw new TypeError( + "scratch-boundary test kernel must be a WasmPosixKernel", + ); + } + this.#kernel = kernel; + }, + replaceTcpScratchForScratchBoundaryTest: (scratch): void => { + if (this.#kernelInstance === null || this.#kernelMemory === null) { + throw new Error("test kernel generation is not initialized"); + } + const owner = validateKernelScratchRegionOwnership( + scratch, + this.#kernelInstance, + "replacement test TCP scratch", + ); + if ( + owner.memory !== this.#kernelMemory + || owner.pointerWidth !== this.#kernelPointerWidth + ) { + throw new KernelScratchError( + "replacement test TCP scratch belongs to another kernel generation", + ); + } + this.#tcpScratchRegion = owner.region; + }, + }; + const authority = kernelEntryIntrinsicObjectCreate( + null, + ) as CentralizedKernelWorkerTestAuthority; + const methodEntries = kernelEntryIntrinsicObjectEntries(methods); + for (let index = 0; index < methodEntries.length; index++) { + const [name, value] = methodEntries[index]!; + kernelEntryIntrinsicObjectDefineProperty(authority, name, { + configurable: false, + enumerable: true, + writable: false, + value, + }); + } + return kernelEntryIntrinsicObjectFreeze(authority); } - private processControlMessageLayout( - pointerWidth: 4 | 8, - ): ProcessControlMessageLayout { - return pointerWidth === 8 - ? { - size: PROCESS_CMSGHDR_WASM64_SIZE, - alignment: PROCESS_CMSGHDR_WASM64_ALIGN, - lengthOffset: PROCESS_CMSGHDR_WASM64_LEN_OFFSET, - levelOffset: PROCESS_CMSGHDR_WASM64_LEVEL_OFFSET, - typeOffset: PROCESS_CMSGHDR_WASM64_TYPE_OFFSET, - dataOffset: PROCESS_CMSGHDR_WASM64_DATA_OFFSET, + /** + * Permanently stop dispatch through an instance whose global Rust state can + * no longer be proven reusable. + */ + #failKernelInstance(error: Error): void { + if (this.#kernelFatalError !== null) return; + this.#kernelEntryGate.fail(error); + this.#kernelFatalError = error; + this.#initialized = false; + // A poisoned generation can never execute or explicitly settle a parked + // request again. Drop every detached snapshot synchronously so neither + // guest memory nor a future kernel generation remains retained. + this.blockingRetrySnapshots.clear(); + this.blockingRetryWakeTargets.clear(); + this.activeChannelRequests.clear(); + if (this.vblankTimer !== null) { + this.#cancelRegisteredInterval(this.vblankTimer); + this.vblankTimer = null; + } + this.stopPolling(); + for (const channel of this.activeChannels) { + channel.handling = true; + } + // WHY: this method is reachable from an export catch while the exact + // void-ingress scope is still unwinding. The fatal latch is synchronous, + // but the host observer must run only after every entry token is revoked. + this.#scheduleMicrotaskListenerRoot( + "kernel fatal observer", + () => { + try { + this.callbacks.onKernelFatal?.(error); + } catch (callbackError) { + // The latch already prevents further dispatch. Keep an entry-layer + // reporting failure visible without reopening the poisoned instance. + console.error( + "[kernel-worker] onKernelFatal callback failed:", + callbackError, + ); } - : { - size: PROCESS_CMSGHDR_WASM32_SIZE, - alignment: PROCESS_CMSGHDR_WASM32_ALIGN, - lengthOffset: PROCESS_CMSGHDR_WASM32_LEN_OFFSET, - levelOffset: PROCESS_CMSGHDR_WASM32_LEVEL_OFFSET, - typeOffset: PROCESS_CMSGHDR_WASM32_TYPE_OFFSET, - dataOffset: PROCESS_CMSGHDR_WASM32_DATA_OFFSET, - }; + }, + true, + ); } - private checkedAlignUp( - value: number, - alignment: number, - context: string, - ): number { - if ( - !Number.isSafeInteger(value) || - value < 0 || - !Number.isSafeInteger(alignment) || - alignment <= 0 - ) { - throw new KernelScratchError(`${context} is not representable`, EINVAL); + /** + * Initialize the kernel. + * Loads kernel Wasm and validates the host adapter ABI. + */ + async init(kernelWasmBytes: BufferSource): Promise { + if (this.#kernelFatalError !== null) { + throw new Error("cannot reinitialize a failed kernel worker"); + } + await this.#kernel.init(kernelWasmBytes); + // WHY: these capabilities belong only to the worker that owns the gate. + // Public kernel accessors expose neither mutable Memory nor raw callables. + const runtimeAccess = getWasmPosixKernelRuntimeAccess(this.#kernel); + this.#kernelInstance = runtimeAccess.instance(); + this.#kernelMemory = runtimeAccess.memory(); + if (this.#kernelInstance === null || this.#kernelMemory === null) { + throw new Error("kernel initialization did not publish its runtime"); + } + this.#kernelPointerWidth = this.#kernel.getKernelPtrWidth(); + + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError("kernel initialization completion"); + } + let initializedRuntime: { + readonly abiVersion: number; + readonly mainScratch: KernelScratchRegion; + readonly tcpScratch: KernelScratchRegion; + } | undefined; + const deferred = this.#runOrDeferKernelEntry( + "kernel initialization completion", + (entry) => { + const instance = this.#kernelInstanceForEntry(entry); + + // Read the kernel's advertised ABI version once at startup. Every + // user program spawned against this kernel will have its own + // `__abi_version` export compared against this value; mismatches + // are refused before any syscall runs. + const abiVersionFn = instance.exports[ABI_KERNEL_EXPORT] as + (() => number) | undefined; + if (typeof abiVersionFn !== "function") { + throw new Error( + `kernel wasm is missing the ${ABI_KERNEL_EXPORT} export — refusing to run. ` + + "Rebuild the kernel (bash build.sh) against the current ABI.", + ); + } + const abiVersion = abiVersionFn(); + validateKernelHostAdapterManifest(instance, this.#kernelMemory!); + + // Allocate scratch from the kernel heap. Host-side memory.grow() would + // create pages unknown to dlmalloc and let later Rust allocations + // overlap the supposedly host-owned bytes. + const allocScratch = instance.exports.kernel_alloc_scratch as + (size: number) => KernelPointer; + const mainScratch = allocateKernelScratchRegion( + this.#kernelMemory!, + allocScratch, + SCRATCH_SIZE, + this.#kernelPointerWidth, + "kernel syscall scratch", + // WHY: the allocator call is scoped to this initialization entry, + // but the resulting region survives it. Bind ownership to the + // persistent gated generation, never the revocable scoped façade. + this.#kernelInstance!, + instance, + ); + const tcpScratch = allocateKernelScratchRegion( + this.#kernelMemory!, + allocScratch, + 65536, + this.#kernelPointerWidth, + "kernel TCP scratch", + this.#kernelInstance!, + instance, + ); + // WHY: publish neither region until both allocator calls and the + // manifest checks have completed under this one exact entry scope. + // A reentrant host import therefore cannot observe half-initialized + // pointer/capacity state. + initializedRuntime = { + abiVersion, + mainScratch, + tcpScratch, + }; + return undefined; + }, + ); + if (deferred || initializedRuntime === undefined) { + throw new KernelReentrantEntryError("kernel initialization completion"); } - const remainder = value % alignment; - const aligned = remainder === 0 ? value : value + alignment - remainder; - if (!Number.isSafeInteger(aligned)) { - throw new KernelScratchError(`${context} overflows`, EINVAL); + this.kernelAbiVersion = initializedRuntime.abiVersion; + this.#scratchRegion = initializedRuntime.mainScratch; + this.#tcpScratchRegion = initializedRuntime.tcpScratch; + + // Try to load Node.js net module for TCP bridging + try { + const net = await import("net"); + // Verify it's a real module (Vite externalizes it as an empty stub in browsers) + if (typeof net.createServer === "function") { + this.netModule = net; + } + } catch { + // Not in Node.js environment — TCP bridging disabled + } + + this.#initialized = true; + if (this.kmsCanvases.size > 0 || this.kmsStatsViews.size > 0) { + this.startVblankPump(); } - return aligned; } - private rejectScratchTransfer( - channel: ChannelInfo, - error: unknown, - ): void { - const errno = error instanceof KernelScratchError ? error.errno : EFAULT; - this.completeChannelRaw(channel, -1, errno); - this.relistenChannel(channel); + #requireMainScratchRegion(): KernelScratchRegion { + if (!this.#scratchRegion) { + throw new KernelScratchError("kernel syscall scratch is not allocated"); + } + return this.#scratchRegion; } - private kernelIovecFootprint(entries: CheckedProcessIovec[]): number { - let offset = entries.length * STRUCT_SIZE_KERNEL_IOVEC_WIRE; - if (!Number.isSafeInteger(offset)) { - throw new KernelScratchError("kernel iovec table size overflows", EINVAL); + #requireTcpScratchRegion(): KernelScratchRegion { + if (!this.#tcpScratchRegion) { + throw new KernelScratchError("kernel TCP scratch is not allocated"); } - for (const entry of entries) { - offset += entry.len; - if (!Number.isSafeInteger(offset)) { - throw new KernelScratchError("kernel iovec layout overflows", EINVAL); - } - offset = this.checkedAlignUp( - offset, - KERNEL_IOVEC_WIRE_ALIGN, - "kernel iovec layout", + return this.#tcpScratchRegion; + } + + /** Validate one synchronous Rust producer result before copying its output. */ + #checkedScratchProducerByteLength( + result: number, + capacity: number, + label: string, + ): number { + if (!Number.isSafeInteger(result)) { + throw new KernelScratchError( + `${label} returned a non-integer byte count`, + EIO, + ); + } + if (result <= 0) return 0; + if (result > capacity) { + throw new KernelScratchError( + `${label} returned ${result} bytes for capacity ${capacity}`, + EIO, ); } - return offset; + return result; } - private checkedProcessMessage( + private checkedProcessRange( channel: ChannelInfo, - messagePointerValue: number | bigint, - ): CheckedProcessMessage { + pointer: number | bigint, + length: number | bigint, + field: string, + allowAddressZero = false, + ): { pointer: number; length: number; end: number } { const pointerWidth = this.getPtrWidth(channel.pid); - const layout = this.processMessageLayout(pointerWidth); - const message = this.checkedProcessRange( - channel, - messagePointerValue, - layout.size, - "process msghdr", + const canonicalPointer = canonicalGuestUnsignedScalar( + BigInt(pointer), + pointerWidth, + `${field} pointer`, ); - const view = new DataView( - channel.memory.buffer, - message.pointer, - message.length, + const canonicalLength = canonicalGuestUnsignedScalar( + BigInt(length), + pointerWidth, + `${field} length`, ); - const rawNamePointer = pointerWidth === 8 - ? view.getBigUint64(layout.nameOffset, true) - : view.getUint32(layout.nameOffset, true); - const nameLength = view.getUint32(layout.nameLengthOffset, true); - const rawIovecPointer = pointerWidth === 8 - ? view.getBigUint64(layout.iovecOffset, true) - : view.getUint32(layout.iovecOffset, true); - const iovecCount = view.getUint32(layout.iovecCountOffset, true); - const rawControlPointer = pointerWidth === 8 - ? view.getBigUint64(layout.controlOffset, true) - : view.getUint32(layout.controlOffset, true); - const controlLength = view.getUint32(layout.controlLengthOffset, true); - - const checkedOptionalRange = ( - pointer: number | bigint, - length: number, - field: string, - ): { pointer: number; length: number } => { - if (length === 0) { - return { - pointer: checkedWasmPointer(pointer, pointerWidth, `${field} pointer`), - length: 0, - }; - } - return this.checkedProcessRange(channel, pointer, length, field); - }; - - return { + return checkedMemoryRange( + channel.memory, + canonicalPointer, + canonicalLength, pointerWidth, - messagePointer: message.pointer, - name: checkedOptionalRange( - rawNamePointer, - nameLength, - "msg_name", - ), - control: checkedOptionalRange( - rawControlPointer, - controlLength, - "msg_control", - ), - iovecs: this.checkedProcessIovecs( - channel, - rawIovecPointer, - iovecCount, - true, - ), - }; + field, + allowAddressZero, + ); } - private nativeControlToKernelWire( - processMem: Uint8Array, - message: CheckedProcessMessage, - ): Uint8Array { - if (message.control.length === 0) return new Uint8Array(0); - const native = this.processControlMessageLayout(message.pointerWidth); - const source = new DataView( - processMem.buffer, - processMem.byteOffset + message.control.pointer, - message.control.length, - ); - const records: Array<{ - level: number; - type: number; - data: Uint8Array; - wireLength: number; - wireSpace: number; - }> = []; - let nativeOffset = 0; - let wireBytes = 0; - while (nativeOffset + native.size <= message.control.length) { - const cmsgLength = source.getUint32( - nativeOffset + native.lengthOffset, - true, + /** + * Preserve i64 channel slots until a handwritten host path has decided + * which arguments are process addresses. + * + * Generated descriptors perform this proof while planning their transfer. + * The syscalls below bypass that planner or use process addresses again + * during host-side memory bookkeeping. Normalizing only their pointer-sized + * slots keeps signed scalar arguments signed while preventing a wasm64 + * address from being rounded or narrowed to a low wasm32 address. + */ + private checkHandwrittenProcessAddressArguments( + channel: ChannelInfo, + syscallNr: number, + args: number[], + stagedArgs: ChannelScalarValue[], + rawArgs: readonly bigint[], + ): void { + const pointerWidth = this.getPtrWidth(channel.pid); + const pointer = (index: number, field: string): void => { + const raw = rawArgs[index] ?? 0n; + const physical = canonicalGuestUnsignedScalar( + raw, + pointerWidth, + field, ); - if (cmsgLength < native.dataOffset) { + args[index] = checkedWasmPointer( + physical, + pointerWidth, + field, + ); + // WHY: args is only the JavaScript host-control projection. The kernel + // reads stagedArgs, so publish the same already-validated physical bits + // there or a wasm64 address above 4 GiB would still alias its low i32. + stagedArgs[index] = physical; + }; + const size = (index: number, field: string): void => { + let physical: bigint; + try { + physical = canonicalGuestUnsignedScalar( + rawArgs[index] ?? 0n, + pointerWidth, + field, + ); + args[index] = checkedWasmPointer( + physical, + pointerWidth, + field, + ); + } catch (error) { throw new KernelScratchError( - "native control message header is malformed", + error instanceof Error ? error.message : `${field} is invalid`, EINVAL, ); } - const nativeEnd = nativeOffset + cmsgLength; + stagedArgs[index] = physical; + }; + const rangeEnd = ( + pointerIndex: number, + lengthIndex: number, + field: string, + ): void => { + const end = args[pointerIndex] + args[lengthIndex]; if ( - !Number.isSafeInteger(nativeEnd) || - nativeEnd > message.control.length + !Number.isSafeInteger(end) + || end < args[pointerIndex] ) { + throw new KernelScratchError(`${field} overflows`, EINVAL); + } + const alignedLength = + Math.ceil(args[lengthIndex] / WASM_PAGE_SIZE) * WASM_PAGE_SIZE; + if (!Number.isSafeInteger(alignedLength)) { throw new KernelScratchError( - "native control message exceeds msg_controllen", + `${field} page alignment overflows`, EINVAL, ); } - const level = source.getUint32(nativeOffset + native.levelOffset, true); - const type = source.getUint32(nativeOffset + native.typeOffset, true); - const dataLength = cmsgLength - native.dataOffset; + const alignedEnd = args[pointerIndex] + alignedLength; if ( - level === SOCKET_SOL_SOCKET && - type === SOCKET_SCM_RIGHTS && - dataLength % SCM_RIGHTS_FD_BYTES !== 0 + !Number.isSafeInteger(alignedEnd) + || alignedEnd < args[pointerIndex] ) { throw new KernelScratchError( - "SCM_RIGHTS payload is not an array of file descriptors", + `${field} page-aligned end overflows`, EINVAL, ); } - const wireLength = KERNEL_CMSGHDR_WIRE_DATA_OFFSET + dataLength; - const wireSpace = this.checkedAlignUp( - wireLength, - KERNEL_CMSGHDR_WIRE_ALIGN, - "kernel control message", - ); - wireBytes += wireSpace; - if (!Number.isSafeInteger(wireBytes) || wireBytes > CH_DATA_SIZE) { - throw new KernelScratchError( - "control messages exceed bounded kernel transport", - 90, - ); - } - records.push({ - level, - type, - data: processMem.slice( - message.control.pointer + nativeOffset + native.dataOffset, - message.control.pointer + nativeEnd, - ), - wireLength, - wireSpace, - }); - nativeOffset = this.checkedAlignUp( - nativeEnd, - native.alignment, - "native control message", - ); - } - - const output = new Uint8Array(wireBytes); - const view = new DataView(output.buffer); - let wireOffset = 0; - for (const record of records) { - view.setUint32( - wireOffset + KERNEL_CMSGHDR_WIRE_LEN_OFFSET, - record.wireLength, - true, - ); - view.setUint32( - wireOffset + KERNEL_CMSGHDR_WIRE_LEVEL_OFFSET, - record.level, - true, - ); - view.setUint32( - wireOffset + KERNEL_CMSGHDR_WIRE_TYPE_OFFSET, - record.type, - true, - ); - output.set( - record.data, - wireOffset + KERNEL_CMSGHDR_WIRE_DATA_OFFSET, - ); - wireOffset += record.wireSpace; - } - return output; - } - - private kernelControlCapacityForRecv( - message: CheckedProcessMessage, - ): number { - const native = this.processControlMessageLayout(message.pointerWidth); - if ( - message.control.length < - native.dataOffset + SCM_RIGHTS_FD_BYTES - ) { - return 0; - } - const descriptorCapacity = Math.floor( - (message.control.length - native.dataOffset) / SCM_RIGHTS_FD_BYTES, - ); - // WHY: Rust emits at most one SCM_RIGHTS record. Bound its fixed-wire FD - // capacity by what the wider caller-native header can represent, so it - // cannot install descriptors that expansion back to wasm64 would lose. - return KERNEL_CMSGHDR_WIRE_DATA_OFFSET - + descriptorCapacity * SCM_RIGHTS_FD_BYTES; - } + }; - private kernelControlToNative( - wireBytes: Uint8Array, - message: CheckedProcessMessage, - ): { bytes: Uint8Array; length: number } { - if (wireBytes.length === 0) { - return { bytes: new Uint8Array(0), length: 0 }; - } - if (wireBytes.length < STRUCT_SIZE_KERNEL_CMSGHDR_WIRE) { - throw new KernelScratchError( - "kernel returned a partial control message header", - EIO, - ); - } - const wire = new DataView( - wireBytes.buffer, - wireBytes.byteOffset, - wireBytes.byteLength, - ); - const cmsgLength = wire.getUint32(KERNEL_CMSGHDR_WIRE_LEN_OFFSET, true); - if ( - cmsgLength < KERNEL_CMSGHDR_WIRE_DATA_OFFSET || - cmsgLength > wireBytes.length || - this.checkedAlignUp( - cmsgLength, - KERNEL_CMSGHDR_WIRE_ALIGN, - "returned kernel control message", - ) !== wireBytes.length - ) { - throw new KernelScratchError( - "kernel returned a malformed control message", - EIO, - ); - } - const level = wire.getUint32(KERNEL_CMSGHDR_WIRE_LEVEL_OFFSET, true); - const type = wire.getUint32(KERNEL_CMSGHDR_WIRE_TYPE_OFFSET, true); - const dataLength = cmsgLength - KERNEL_CMSGHDR_WIRE_DATA_OFFSET; - if ( - level !== SOCKET_SOL_SOCKET || - type !== SOCKET_SCM_RIGHTS || - dataLength === 0 || - dataLength % SCM_RIGHTS_FD_BYTES !== 0 - ) { - throw new KernelScratchError( - "kernel returned an unsupported control message", - EIO, - ); - } - - const native = this.processControlMessageLayout(message.pointerWidth); - const nativeLength = native.dataOffset + dataLength; - if (nativeLength > message.control.length) { - throw new KernelScratchError( - "kernel control message exceeds caller capacity", - EIO, - ); - } - const reportedLength = Math.min( - message.control.length, - this.checkedAlignUp( - nativeLength, - native.alignment, - "native returned control message", - ), - ); - const output = new Uint8Array(reportedLength); - const outputView = new DataView(output.buffer); - outputView.setUint32(native.lengthOffset, nativeLength, true); - outputView.setUint32(native.levelOffset, level, true); - outputView.setUint32(native.typeOffset, type, true); - output.set( - wireBytes.subarray( - KERNEL_CMSGHDR_WIRE_DATA_OFFSET, - KERNEL_CMSGHDR_WIRE_DATA_OFFSET + dataLength, - ), - native.dataOffset, - ); - return { bytes: output, length: reportedLength }; - } - - private kernelMessageLayout( - message: CheckedProcessMessage, - controlCapacity: number, - ): KernelMessageLayout { - let offset: number = STRUCT_SIZE_KERNEL_MSGHDR_WIRE; - const append = (length: number): number => { - const start = offset; - offset += length; - if (!Number.isSafeInteger(offset)) { - throw new KernelScratchError("kernel msghdr layout overflows", EINVAL); + switch (syscallNr) { + case SYS_MMAP: + pointer(0, "mmap address"); + size(1, "mmap length"); + rangeEnd(0, 1, "mmap range"); + return; + case SYS_MUNMAP: + case SYS_MPROTECT: + case SYS_MADVISE: + case SYS_MSYNC: + case SYS_MLOCK: + case SYS_MLOCK2: + case SYS_MUNLOCK: + pointer(0, `syscall ${syscallNr} address`); + size(1, `syscall ${syscallNr} length`); + rangeEnd(0, 1, `syscall ${syscallNr} range`); + return; + case SYS_MREMAP: { + pointer(0, "mremap old address"); + size(1, "mremap old length"); + size(2, "mremap new length"); + const flags = Number(BigInt.asUintN(32, rawArgs[3] ?? 0n)); + args[3] = flags; + if ((flags & MREMAP_FIXED) !== 0) { + pointer(4, "mremap fixed address"); + rangeEnd(4, 2, "mremap fixed range"); + } + rangeEnd(0, 1, "mremap old range"); + return; } - offset = this.checkedAlignUp( - offset, - KERNEL_MSGHDR_WIRE_ALIGN, - "kernel msghdr layout", - ); - return start; - }; - const nameOffset = message.name.length > 0 - ? append(message.name.length) - : 0; - const controlOffset = controlCapacity > 0 - ? append(controlCapacity) - : 0; - const iovecCount = message.iovecs.entries.length > 0 - ? FLATTENED_KERNEL_MESSAGE_IOVEC_COUNT - : 0; - const iovecBytes = iovecCount * STRUCT_SIZE_KERNEL_IOVEC_WIRE; - const iovecOffset = iovecBytes > 0 - ? append(iovecBytes) - : 0; - const dataOffset = message.iovecs.totalData > 0 - ? append(message.iovecs.totalData) - : 0; - return { - footprint: offset, - nameOffset, - controlOffset, - controlCapacity, - iovecOffset, - iovecCount, - iovecBytes, - dataOffset, - }; - } - - private checkedKernelWirePointer(pointer: number): number { - if ( - !Number.isSafeInteger(pointer) || - pointer < 0 || - pointer > 0xffff_ffff - ) { - throw new KernelScratchError( - "kernel wire pointer does not fit its u32 field", - EIO, - ); + case SYS_BRK: + pointer(0, "brk address"); + return; + case SYS_SPAWN: + pointer(0, "spawn path pointer"); + size(1, "spawn path length"); + pointer(2, "spawn blob pointer"); + size(3, "spawn blob length"); + pointer(4, "spawn pid output pointer"); + return; + case SYS_EXECVE: + pointer(0, "execve path pointer"); + pointer(1, "execve argv pointer"); + pointer(2, "execve environment pointer"); + return; + case SYS_EXECVEAT: + pointer(1, "execveat path pointer"); + pointer(2, "execveat argv pointer"); + pointer(3, "execveat environment pointer"); + return; + case SYS_CLONE: { + const flags = Number(BigInt.asUintN(32, rawArgs[0] ?? 0n)); + args[0] = flags; + pointer(1, "clone stack pointer"); + // The attachment handed to the new Worker carries this slot even + // when a future clone variant omits CLONE_SETTLS. + pointer(3, "clone TLS pointer"); + if ((flags & CLONE_PARENT_SETTID) !== 0) { + pointer(2, "clone parent tid pointer"); + } + if ((flags & (CLONE_CHILD_CLEARTID | CLONE_CHILD_SETTID)) !== 0) { + pointer(4, "clone child tid pointer"); + } + return; + } + case SYS_WAIT4: + pointer(1, "wait4 status pointer"); + pointer(3, "wait4 rusage pointer"); + return; + case SYS_WAITID: + pointer(2, "waitid siginfo pointer"); + pointer(4, "waitid rusage pointer"); + return; + case SYS_FUTEX: { + pointer(0, "futex uaddr"); + const op = Number(BigInt.asUintN(32, rawArgs[1] ?? 0n)); + args[1] = op; + const command = + op & ~(FUTEX_PRIVATE_FLAG | FUTEX_CLOCK_REALTIME); + if (command === FUTEX_WAIT || command === FUTEX_WAIT_BITSET) { + pointer(3, "futex timeout pointer"); + } + if ( + command === FUTEX_REQUEUE + || command === FUTEX_CMP_REQUEUE + || command === FUTEX_WAKE_OP + ) { + pointer(4, "futex uaddr2"); + } + return; + } + case SYS_WRITEV: + case SYS_PWRITEV: + case SYS_PWRITEV2: + case SYS_READV: + case SYS_PREADV: + case SYS_PREADV2: { + // POSIX defines a zero-count vector as an empty operation and does + // not inspect iov. Validate the complete i64 count before touching + // the pointer so wasm64 high bits cannot alias a small JavaScript + // number, then canonicalize the ignored pointer. + const rawCount = rawArgs[2] ?? 0n; + if (rawCount < 0n || rawCount > BigInt(POSIX_IOV_MAX)) { + throw new KernelScratchError( + `iovec count must be between 0 and ${POSIX_IOV_MAX}`, + EINVAL, + ); + } + args[2] = Number(rawCount); + if (rawCount === 0n) { + args[1] = 0; + return; + } + pointer(1, "iovec table pointer"); + return; + } + case SYS_SENDMSG: + case SYS_RECVMSG: + pointer(1, "message header pointer"); + return; + case SYS_PSELECT6: + pointer(1, "pselect6 read fd_set pointer"); + pointer(2, "pselect6 write fd_set pointer"); + pointer(3, "pselect6 except fd_set pointer"); + pointer(4, "pselect6 timeout pointer"); + pointer(5, "pselect6 mask descriptor pointer"); + return; + case SYS_SELECT: + pointer(1, "select read fd_set pointer"); + pointer(2, "select write fd_set pointer"); + pointer(3, "select except fd_set pointer"); + pointer(4, "select timeout pointer"); + return; + case SYS_FCNTL: { + const command = args[1]; + if ( + command === F_GETLK + || command === F_SETLK + || command === F_SETLKW + || command === F_GETLK64 + || command === F_SETLK64 + || command === F_SETLKW64 + || command === F_OFD_GETLK + || command === F_OFD_SETLK + || command === F_OFD_SETLKW + ) { + pointer(2, "fcntl flock pointer"); + } + return; + } + case SYS_IOCTL: { + const request = Number(BigInt.asUintN(32, rawArgs[1] ?? 0n)); + if ( + request === SIOCGIFCONF + || request === SIOCGIFNAME + || request === SIOCGIFHWADDR + || request === SIOCGIFADDR + || request === SIOCGIFINDEX + ) { + pointer(2, "network ioctl pointer"); + } + return; + } + case SYS_WRITE: + case SYS_PWRITE: + case SYS_READ: + case SYS_PREAD: + if ((rawArgs[2] ?? 0n) > BigInt(CH_DATA_SIZE)) { + pointer(1, "large I/O buffer pointer"); + size(2, "large I/O byte count"); + } + return; } - return pointer; } /** - * Ask the Rust kernel to allocate and create a process descriptor. + * Validate every unconditional raw process-address slot declared by the + * generated shared contract before it can enter kernel scratch. * - * The returned PID already names authoritative kernel state. Hosts may - * attach memory, channels, and a Worker to it, but never choose the PID. - */ - createProcess(stdio: RegisterProcessStdio): number { - if (!this.initialized) throw new Error("Kernel not initialized"); - const createProcess = this.kernelInstance!.exports.kernel_create_process_with_stdio as - ((stdinKind: number, stdoutKind: number, stderrKind: number) => number) | undefined; - if (!createProcess) { - throw new Error("Kernel missing kernel_create_process_with_stdio export"); - } - const pid = createProcess( - encodeStdioKind(stdio.stdin), - encodeStdioKind(stdio.stdout), - encodeStdioKind(stdio.stderr), - ); - if (pid <= 0) { - throw new Error(`Failed to create process: errno ${-pid}`); - } - return pid; - } - - /** - * Attach process memory and thread channels to an existing kernel Process. - * Each channel is a region in the process's shared Memory. + * Conditional pointers whose meaning depends on flags/counts remain in the + * handwritten validator above so ignored arguments keep POSIX semantics. */ - registerProcess( - pid: number, - memory: WebAssembly.Memory, - channelOffsets: number[], - options?: RegisterProcessOptions, + private checkGeneratedExactScalarArguments( + channel: ChannelInfo, + syscallNr: number, + args: number[], + stagedArgs: ChannelScalarValue[], + rawArgs: readonly bigint[], ): void { - if (!this.initialized) throw new Error("Kernel not initialized"); - if (!Number.isSafeInteger(pid) || pid <= 0 || pid > MAX_KERNEL_TASK_ID) { - throw new Error(`Cannot register invalid kernel process ID ${pid}`); - } - if (channelOffsets.length !== 1) { - throw new Error( - `Process ${pid} must register exactly one main syscall channel`, - ); - } - - const getProcessState = this.kernelInstance!.exports.kernel_get_process_state as - ((pid: number) => number) | undefined; - const processState = getProcessState?.(pid); - if (processState === undefined || processState < 0) { - throw new Error(`Cannot register unknown kernel process ${pid}`); - } - if (processState !== PROCESS_STATE_RUNNING && processState !== PROCESS_STATE_STOPPED) { - throw new Error(`Cannot register inactive kernel process ${pid}`); - } - if (pid === 1) { - throw new Error("Cannot register the kernel-reserved init process"); - } - const existingRegistration = this.processes.get(pid); - const replacingExecImage = - options?.preserveProcessState === true - && this.execHandoffPids?.has(pid) === true - && existingRegistration?.channels.length === 0; - if (existingRegistration && !replacingExecImage) { - throw new Error(`Process ${pid} is already registered with the host`); - } - - // Registration replaces every channel object for this pid. Exec keeps the - // authoritative stopped state; a genuinely fresh kernel Process does not. - this.discardStoppedChannelStateForProcess(pid, !options?.preserveProcessState); - - if (options?.argv !== undefined || options?.env !== undefined) { - const metadataResult = this.validateExecMetadata( - options.argv ?? [], - options.env ?? [], - options.metadataPtrWidth ?? options.ptrWidth ?? 4, - ); - if (metadataResult < 0) { - throw new Error(`Process argv/environment exceeds exec metadata limits: errno ${-metadataResult}`); - } - } - - // Kernel task IDs are never reused. Clear any stale host lifecycle marker - // defensively before installing this task's transport registration. - this.hostReaped.delete(pid); - - if (options?.brkBase !== undefined) { - if (!this.setBrkBase(pid, options.brkBase)) { - throw new Error( - "Kernel export kernel_set_brk_base is required for compact process memory layout", + const contract = CHANNEL_SCALAR_SLOT_CONTRACTS[syscallNr]; + if (!contract) return; + const pointerWidth = this.getPtrWidth(channel.pid); + for (const [indexText, kind] of Object.entries(contract)) { + const index = Number(indexText); + const raw = rawArgs[index] ?? 0n; + const physical = BigInt.asUintN(64, raw); + if (kind === "process-address") { + let processAddress: bigint; + try { + processAddress = canonicalGuestUnsignedScalar( + raw, + pointerWidth, + `syscall ${syscallNr} arg ${index} process address`, + ); + } catch (error) { + throw new KernelScratchError( + error instanceof Error ? error.message : "invalid process address", + EFAULT, + ); + } + args[index] = checkedWasmPointer( + processAddress, + pointerWidth, + `syscall ${syscallNr} arg ${index} process address`, ); - } - } - - // Set process argv in kernel for /proc//cmdline - if (options?.argv !== undefined) { - this.replaceProcessMetadata(pid, PROCESS_METADATA_ARGV, options.argv); - } - - // Keep kernel-owned environment state synchronized with the process - // worker. This matters for exec even when the replacement envp is empty. - if (options?.env !== undefined) { - this.replaceProcessMetadata(pid, PROCESS_METADATA_ENVIRONMENT, options.env); - } - - // Cap mmap address space. New hosts pass the process memory maximum here - // because syscall channels live below PROCESS_MMAP_BASE in a reserved - // control arena. Legacy callers without maxAddr still cap at the lowest - // channel offset, preserving the old high-channel layout behavior. - const setMaxAddr = this.kernelInstance!.exports.kernel_set_max_addr as - ((pid: number, maxAddr: KernelPointer) => number) | undefined; - if (setMaxAddr) { - const maxAddr = - options?.maxAddr ?? - (channelOffsets.length > 0 ? Math.min(...channelOffsets) : undefined); - if (maxAddr !== undefined) { - setMaxAddr(pid, this.toKernelPtr(maxAddr)); - } - } - - if (options?.mmapBase !== undefined) { - if (!this.setMmapBase(pid, options.mmapBase)) { - throw new Error( - "Kernel export kernel_set_mmap_base is required for compact process memory layout", + stagedArgs[index] = processAddress; + } else if (kind === "process-size") { + // wasm32 musl passes `size_t` through a signed long channel argument, + // so bit 31 arrives sign-extended in the physical i64 slot. The guest + // data model, not those transport-extension bits, defines the value. + let processSize: bigint; + try { + processSize = canonicalGuestUnsignedScalar( + raw, + pointerWidth, + `syscall ${syscallNr} arg ${index} process size`, + ); + } catch (error) { + throw new KernelScratchError( + error instanceof Error ? error.message : "invalid process size", + EINVAL, + ); + } + const semanticCeiling = semanticChannelProcessSizeCeiling( + syscallNr, + index, ); + // WHY: a complete-result capacity is not a demand to materialize the + // caller's entire size_t. Clamp in bigint space before Number + // conversion so a valid wasm64 capacity above 2^53 cannot be rejected + // or rounded before the independently bounded range proof. + const stagedProcessSize = semanticCeiling === undefined + ? processSize + : processSize > BigInt(semanticCeiling) + ? BigInt(semanticCeiling) + : processSize; + if ( + stagedProcessSize > BigInt(Number.MAX_SAFE_INTEGER) + ) { + throw new KernelScratchError( + `syscall ${syscallNr} arg ${index} exceeds the guest/host size domain`, + EINVAL, + ); + } + // Host control flow and planner arithmetic consume args; publish the + // same exact value there instead of retaining the default signed-i32 + // low-word projection while Rust sees a different staged size. + args[index] = Number(stagedProcessSize); + stagedArgs[index] = stagedProcessSize; + } else if (kind === "exact-u32") { + if (physical > 0xffff_ffffn) { + throw new KernelScratchError( + `syscall ${syscallNr} arg ${index} exceeds u32`, + EINVAL, + ); + } + args[index] = Number(physical); + stagedArgs[index] = physical; } } + } - if (options?.brkLimit !== undefined) { - if (!this.setBrkLimit(pid, options.brkLimit)) { - throw new Error( - "Kernel export kernel_set_brk_limit is required for legacy low-control layout", - ); + private normalizeKernelSyscallResult( + channel: ChannelInfo, + syscallNr: number, + rawRetVal: bigint, + errVal: number, + ): { + retVal: number; + publicationRetVal: ChannelScalarValue; + errVal: number; + } { + if (errVal !== 0) { + if (rawRetVal !== -1n) { + return { retVal: -1, publicationRetVal: -1, errVal: EIO }; } + return { retVal: -1, publicationRetVal: -1, errVal }; } - const channels: ChannelInfo[] = channelOffsets.map((offset) => ({ - pid, - memory, - channelOffset: offset, - i32View: new Int32Array(memory.buffer, offset), - consecutiveSyscalls: 0, - })); - - const registration: ProcessRegistration = { - pid, - memory, - channels, - ptrWidth: options?.ptrWidth ?? 4, - explicitMaxAddr: options?.maxAddr !== undefined, - }; - this.processes.set(pid, registration); - this.activeChannels.push(...channels); - this.observeProcessMemoryTarget(memory, memory); - this.observeProcessMemoryTarget(memory, memory.buffer); - this.observeProcessMemoryTarget(memory, registration); - for (const channel of channels) { - this.observeProcessMemoryTarget(memory, channel); - this.observeProcessMemoryTarget(memory, channel.i32View); - } - - if (this.usePolling) { - // Polling mode: start the poller (no per-channel listeners) - this.startPolling(); - } else { - // Event-driven mode: start listening on each channel - for (const channel of channels) { - this.listenOnChannel(channel); + switch (channelResultKind(syscallNr)) { + case "i64": + // The broad post-dispatch path needs only success/failure status for + // exact scalar results. Keep the value bigint from the first read; + // publication and logging never reconstruct it from a Number. + return { retVal: 0, publicationRetVal: rawRetVal, errVal: 0 }; + case "process-address": + try { + // WHY: getBigInt64 exposes bit 63 as a negative bigint even though a + // wasm64 pointer is unsigned. Reinterpret all physical bits before + // validating the caller's pointer width and JavaScript index domain. + const pointer = checkedWasmPointer( + BigInt.asUintN(64, rawRetVal), + this.getPtrWidth(channel.pid), + `syscall ${syscallNr} returned address`, + ); + return { + retVal: pointer, + publicationRetVal: pointer, + errVal: 0, + }; + } catch { + return { retVal: -1, publicationRetVal: -1, errVal: EOVERFLOW }; + } + case "i32": { + if (rawRetVal < -0x8000_0000n || rawRetVal > 0x7fff_ffffn) { + return { retVal: -1, publicationRetVal: -1, errVal: EIO }; + } + const retVal = Number(rawRetVal); + return { retVal, publicationRetVal: retVal, errVal: 0 }; } } } /** - * Side-effect-free exec argv/environment validation. Call this before the - * irreversible exec commit so oversized metadata returns E2BIG to the old - * image instead of failing while the replacement worker is being installed. + * Stage and execute one already-captured generic channel request. + * + * This is deliberately the only consumer of PlannedBlockingChannelDispatch: + * both the first pass and every retry therefore use identical pointer, + * capacity, and output-detachment rules without consulting guest request + * memory. */ - validateExecMetadata( - argv: readonly string[], - env: readonly string[], - ptrWidth: 4 | 8 = 4, - ): number { - const encoder = new TextEncoder(); - // Account for the null pointer terminating each vector even when it is - // explicitly empty. Pointer accounting both matches ARG_MAX semantics and - // bounds the number of zero-length entries without an arbitrary count cap. - let totalBytes = 2 * ptrWidth; - for (const value of [...argv, ...env]) { - const encodedLength = encoder.encode(value).byteLength; - if (encodedLength > PROCESS_METADATA_ENTRY_MAX_BYTES) return -E2BIG; - totalBytes += ptrWidth + encodedLength + 1; - if (!Number.isSafeInteger(totalBytes) || totalBytes > POSIX_ARG_MAX_BYTES) { - return -E2BIG; - } - } - return 0; - } + #executePlannedBlockingChannelDispatch( + channel: ChannelInfo, + plan: PlannedBlockingChannelDispatch, + entry: KernelWorkerEntryContext, + retryToken = 0n, + ): PlannedChannelDispatchResult { + const dispatched = this.#executeCapacityOwnedChannel( + channel, + plan.plannedChannelCapacity, + entry, + (lease) => { + const kernelView = lease.dataView(0, CH_DATA); + kernelView.setUint32(CH_SYSCALL, plan.syscallNr, true); + for (let index = 0; index < CH_ARGS_COUNT; index++) { + kernelView.setBigInt64( + CH_ARGS + index * CH_ARG_SIZE, + BigInt(plan.adjustedArgs[index] ?? 0), + true, + ); + } + if (plan.plannedZeroLengthScratchArgMask !== 0) { + for (let argIndex = 0; argIndex < CH_ARGS_COUNT; argIndex++) { + if ( + ( + plan.plannedZeroLengthScratchArgMask + & (1 << argIndex) + ) !== 0 + ) { + // The empty pointer is allocator-relative and valid only for + // this exact lease. Never retain it in the replay plan. + lease.writeAddress( + CH_ARGS + argIndex * CH_ARG_SIZE, + CH_DATA, + 0, + "u64-le", + ); + } + } + } + for (const write of plan.plannedScratchWrites) { + if (write.inputBytes) { + lease.copyFrom( + write.inputBytes, + write.scratchOffset, + 0, + write.size, + ); + } else { + lease.fill(0, write.scratchOffset, write.size); + } + lease.writeAddress( + CH_ARGS + write.argIndex * CH_ARG_SIZE, + write.scratchOffset, + write.size, + "u64-le", + ); + } + }, + (lease) => { + const kernelView = lease.dataView(0, CH_DATA); + const rawRetVal = kernelView.getBigInt64(CH_RETURN, true); + let { retVal, publicationRetVal, errVal } = + this.normalizeKernelSyscallResult( + channel, + plan.syscallNr, + rawRetVal, + kernelView.getUint32(CH_ERRNO, true), + ); + if ( + plan.syscallNr === SYS_SCHED_GETAFFINITY + && plan.schedGetaffinityOutputInvalid + && retVal >= 0 + ) { + retVal = -1; + publicationRetVal = -1; + errVal = EFAULT; + } + let sleepDelayMs: number | undefined; + if ( + retVal >= 0 + && ( + plan.syscallNr === SYS_NANOSLEEP + || plan.syscallNr === SYS_CLOCK_NANOSLEEP + ) + ) { + const timespec = lease.dataView(CH_DATA, 12); + const sec = timespec.getUint32(0, true); + const nsec = timespec.getUint32(8, true); + sleepDelayMs = sec * 1000 + Math.floor(nsec / 1_000_000); + } - /** Replace argv or environ using bounded, entry-at-a-time scratch copies. */ - private replaceProcessMetadata( - pid: number, - kind: number, - values: readonly string[], - ): void { - const clear = this.kernelInstance!.exports.kernel_clear_process_metadata as - ((pid: number, kind: number) => number) | undefined; - const push = this.kernelInstance!.exports - .kernel_push_process_metadata_entry as - | (( - pid: number, - kind: number, - dataPtr: KernelPointer, - dataLen: number, - ) => number) - | undefined; - if (typeof clear !== "function" || typeof push !== "function") { - // WHY: current-ABI admission requires both bounded entry-at-a-time - // exports. Falling back to the historical aggregate argv setter would - // silently lose environment replacement and revive a pointer-only - // transport after the capacity-safe contract was negotiated. - throw new Error( - "Kernel missing required bounded process metadata exports", - ); - } + const outputWrites: ChannelOutputWrite[] = []; + let outputContractViolation = false; + for (const planned of plan.plannedChannelScratchArgs) { + const { desc } = planned; + if (desc.direction !== "out" && desc.direction !== "inout") { + continue; + } + if (desc.direction === "out" && retVal < 0) continue; + + let copySize = planned.size; + if (desc.direction === "out" && desc.size.type === "arg") { + copySize = Math.min(retVal, copySize); + } else if ( + desc.direction === "out" + && desc.size.type === "deref" + ) { + const derefArgIndex = desc.size.argIndex; + const lengthRecord = plan.plannedChannelScratchArgs.find( + (candidate) => + candidate.desc.argIndex === derefArgIndex, + ); + if (lengthRecord === undefined || lengthRecord.size !== 4) { + throw new KernelScratchError( + "dereferenced output lacks its staged socklen_t record", + EIO, + ); + } + const actualLength = lease + .dataView(lengthRecord.scratchOffset, 4) + .getUint32(0, true); + const supportedMaximum = dereferencedChannelOutputMaximum( + plan.syscallNr, + desc.argIndex, + ); + // WHY: the caller's socklen_t is only its copy capacity. A kernel + // may legitimately report a longer value after truncation, but it + // may never claim bytes beyond the complete generated producer + // bound used to size this owned scratch allocation. + if ( + supportedMaximum === undefined + || actualLength > supportedMaximum + ) { + outputContractViolation = true; + break; + } + copySize = Math.min(actualLength, copySize); + } + if (copySize <= 0) continue; + outputWrites.push({ + ptr: planned.processPointer, + bytes: lease.copyOut(planned.scratchOffset, copySize), + }); + } + if (outputContractViolation) { + // A producer-contract violation is an ordinary EIO result, not a + // Wasm trap. Discard even earlier detached records so no partial + // output becomes observable. + retVal = -1; + publicationRetVal = -1; + errVal = EIO; + outputWrites.length = 0; + } + return { + retVal, + publicationRetVal, + rawRetVal, + errVal, + outputWrites, + sleepDelayMs, + }; + }, + retryToken, + ); + return dispatched.value ?? { + retVal: -1, + publicationRetVal: -1, + rawRetVal: -1n, + errVal: dispatched.errno, + outputWrites: [], + sleepDelayMs: undefined, + }; + } - const clearResult = clear(pid, kind); - if (clearResult < 0) { - throw new Error( - `Failed to clear process metadata for pid ${pid}: errno ${-clearResult}`, + private checkedProcessIovecs( + channel: ChannelInfo, + iovPointer: number | bigint, + iovCount: number, + allowEmpty: boolean, + capturedProcessMemory?: Uint8Array, + ): CheckedProcessIovecs { + if ( + !Number.isSafeInteger(iovCount) || + iovCount < (allowEmpty ? 0 : 1) || + iovCount > POSIX_IOV_MAX + ) { + throw new KernelScratchError( + `iovec count must be ${allowEmpty ? "between 0" : "between 1"} and ${POSIX_IOV_MAX}`, + EINVAL, ); } - - const encoder = new TextEncoder(); - for (const value of values) { - const encoded = encoder.encode(value); - if (encoded.byteLength > PROCESS_METADATA_ENTRY_MAX_BYTES) { - throw new Error( - `Process metadata entry exceeds bounded scratch transport: errno ${E2BIG}`, - ); - } - // A Rust push can grow memory. A fresh lease rechecks the replacement - // buffer and owns the bytes through the complete synchronous parse. - const pushResult = this.requireMainScratchRegion().withLease((scratch) => { - scratch.copyFrom(encoded); - return scratch.invokeKernelExport( - "kernel_push_process_metadata_entry", - [ - pid, - kind, - scratch.exportPointer(0, encoded.byteLength), - encoded.byteLength, - ], + if (iovCount === 0) return { entries: [], totalData: 0 }; + const pointerWidth = this.getPtrWidth(channel.pid); + const layout = this.processIovecLayout(pointerWidth); + const tableBytes = iovCount * layout.size; + if (!Number.isSafeInteger(tableBytes)) { + throw new KernelScratchError("process iovec table size overflows", EINVAL); + } + const checkRange = ( + pointer: number | bigint, + length: number | bigint, + field: string, + allowAddressZero = false, + ): { pointer: number; length: number; end: number } => + capturedProcessMemory === undefined + ? this.checkedProcessRange( + channel, + pointer, + length, + field, + allowAddressZero, + ) + : checkedProcessMemoryViewRange( + capturedProcessMemory, + pointer, + length, + pointerWidth, + field, + allowAddressZero, + ); + const table = checkRange( + iovPointer, + tableBytes, + "process iovec table", + // WHY: zero is an addressable byte in caller process linear memory. + // It means allocator failure only for kernel allocator/export results, + // so a nonempty caller-owned table at address zero is valid when its + // complete native table range fits. + true, + ); + const processMemory = capturedProcessMemory + ?? new Uint8Array(channel.memory.buffer); + const processView = new DataView( + processMemory.buffer, + processMemory.byteOffset + table.pointer, + table.length, + ); + const entries: CheckedProcessIovec[] = []; + let totalData = 0; + for (let index = 0; index < iovCount; index++) { + const offset = index * layout.size; + const rawBase = pointerWidth === 8 + ? processView.getBigUint64(offset + layout.baseOffset, true) + : processView.getUint32(offset + layout.baseOffset, true); + const rawLength = pointerWidth === 8 + ? processView.getBigUint64(offset + layout.lenOffset, true) + : processView.getUint32(offset + layout.lenOffset, true); + // POSIX ignores iov_base when iov_len is zero. In particular, a wasm64 + // caller may place a value above JavaScript's exact integer range there + // without naming any byte. Normalize it to zero only for the empty + // entry; every positive-length range is still checked losslessly. + const lengthRange = rawLength === 0n || rawLength === 0 + ? { pointer: 0, length: 0 } + : checkRange( + rawBase, + rawLength, + `iovec[${index}] data`, + // WHY: like the table itself, positive-length caller data may + // begin at linear-memory address zero. The complete range proof, + // rather than null-pointer convention, establishes ownership. + true, + ); + const len = lengthRange.length; + totalData += len; + if ( + !Number.isSafeInteger(totalData) + || totalData > MAX_REPORTABLE_TRANSFER_BYTES + ) { + throw new KernelScratchError( + "aggregate iovec length exceeds SSIZE_MAX", + EINVAL, ); - }); - if (pushResult < 0) { - throw new Error(`Failed to append process metadata for pid ${pid}: errno ${-pushResult}`); } + entries.push({ base: lengthRange.pointer, len }); } + return { entries, totalData }; } - /** - * Provide data that will be returned when the process reads from stdin (fd 0). - * Data is returned in chunks until exhausted, then EOF is returned. - * Must be called before the process starts reading stdin. - */ - setStdinData(pid: number, data: Uint8Array): void { - this.stdinBuffers.set(pid, { data, offset: 0 }); - this.stdinFinite.add(pid); // EOF after data is consumed + private processIovecLayout(pointerWidth: 4 | 8): ProcessIovecLayout { + return pointerWidth === 8 + ? { + size: PROCESS_IOVEC_WASM64_SIZE, + baseOffset: PROCESS_IOVEC_WASM64_BASE_OFFSET, + lenOffset: PROCESS_IOVEC_WASM64_LEN_OFFSET, + } + : { + size: PROCESS_IOVEC_WASM32_SIZE, + baseOffset: PROCESS_IOVEC_WASM32_BASE_OFFSET, + lenOffset: PROCESS_IOVEC_WASM32_LEN_OFFSET, + }; } - /** - * Set stdout/stderr capture callbacks on the underlying kernel instance. - * Must be called after construction but works at any time. - */ - setOutputCallbacks(callbacks: { - onStdout?: (data: Uint8Array) => void; - onStderr?: (data: Uint8Array) => void; - }): void { - this.kernel.mergeCallbacks(callbacks); + private processMessageLayout(pointerWidth: 4 | 8): ProcessMessageLayout { + return pointerWidth === 8 + ? { + size: PROCESS_MSGHDR_WASM64_SIZE, + nameOffset: PROCESS_MSGHDR_WASM64_NAME_OFFSET, + nameLengthOffset: PROCESS_MSGHDR_WASM64_NAMELEN_OFFSET, + iovecOffset: PROCESS_MSGHDR_WASM64_IOV_OFFSET, + iovecCountOffset: PROCESS_MSGHDR_WASM64_IOVLEN_OFFSET, + controlOffset: PROCESS_MSGHDR_WASM64_CONTROL_OFFSET, + controlLengthOffset: PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET, + flagsOffset: PROCESS_MSGHDR_WASM64_FLAGS_OFFSET, + } + : { + size: PROCESS_MSGHDR_WASM32_SIZE, + nameOffset: PROCESS_MSGHDR_WASM32_NAME_OFFSET, + nameLengthOffset: PROCESS_MSGHDR_WASM32_NAMELEN_OFFSET, + iovecOffset: PROCESS_MSGHDR_WASM32_IOV_OFFSET, + iovecCountOffset: PROCESS_MSGHDR_WASM32_IOVLEN_OFFSET, + controlOffset: PROCESS_MSGHDR_WASM32_CONTROL_OFFSET, + controlLengthOffset: PROCESS_MSGHDR_WASM32_CONTROLLEN_OFFSET, + flagsOffset: PROCESS_MSGHDR_WASM32_FLAGS_OFFSET, + }; } - /** - * Append data to a process's stdin buffer without marking stdin as a pipe. - * Used for interactive stdin where data arrives incrementally. - * Wakes any blocked stdin readers after appending. - */ - appendStdinData(pid: number, data: Uint8Array): void { - const existing = this.stdinBuffers.get(pid); - if (existing) { - // Concatenate with remaining unread data - const remaining = existing.data.subarray(existing.offset); - const combined = new Uint8Array(remaining.length + data.length); - combined.set(remaining); - combined.set(data, remaining.length); - this.stdinBuffers.set(pid, { data: combined, offset: 0 }); - } else { - this.stdinBuffers.set(pid, { data, offset: 0 }); + private processControlMessageLayout( + pointerWidth: 4 | 8, + ): ProcessControlMessageLayout { + return pointerWidth === 8 + ? { + size: PROCESS_CMSGHDR_WASM64_SIZE, + alignment: PROCESS_CMSGHDR_WASM64_ALIGN, + lengthOffset: PROCESS_CMSGHDR_WASM64_LEN_OFFSET, + levelOffset: PROCESS_CMSGHDR_WASM64_LEVEL_OFFSET, + typeOffset: PROCESS_CMSGHDR_WASM64_TYPE_OFFSET, + dataOffset: PROCESS_CMSGHDR_WASM64_DATA_OFFSET, + } + : { + size: PROCESS_CMSGHDR_WASM32_SIZE, + alignment: PROCESS_CMSGHDR_WASM32_ALIGN, + lengthOffset: PROCESS_CMSGHDR_WASM32_LEN_OFFSET, + levelOffset: PROCESS_CMSGHDR_WASM32_LEVEL_OFFSET, + typeOffset: PROCESS_CMSGHDR_WASM32_TYPE_OFFSET, + dataOffset: PROCESS_CMSGHDR_WASM32_DATA_OFFSET, + }; + } + + private readProcessUsize( + view: DataView, + offset: number, + pointerWidth: 4 | 8, + field: string, + ): number { + const raw = pointerWidth === 8 + ? view.getBigUint64(offset, true) + : view.getUint32(offset, true); + try { + return checkedWasmPointer(raw, pointerWidth, field); + } catch (error) { + throw new KernelScratchError( + error instanceof Error ? error.message : `${field} is invalid`, + EINVAL, + ); } - // Wake any blocked readers for this process - this.scheduleWakeBlockedRetries(); } - // ── PTY management ── + private writeProcessUsize( + view: DataView, + offset: number, + value: number, + pointerWidth: 4 | 8, + field: string, + ): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new KernelScratchError(`${field} is invalid`, EIO); + } + if (pointerWidth === 8) { + view.setBigUint64(offset, BigInt(value), true); + } else { + if (value > 0xffff_ffff) { + throw new KernelScratchError(`${field} exceeds wasm32 size_t`, EIO); + } + view.setUint32(offset, value, true); + } + } - /** - * Create a PTY pair and wire fds 0/1/2 of `pid` to the slave side. - * Returns the PTY index, or throws on failure. - */ - setupPty(pid: number): number { - const kernelPtyCreate = this.kernelInstance!.exports.kernel_pty_create as - ((pid: number) => number) | undefined; - if (!kernelPtyCreate) - throw new Error("Kernel missing kernel_pty_create export"); - const ptyIdx = kernelPtyCreate(pid); - if (ptyIdx < 0) - throw new Error(`kernel_pty_create failed: errno ${-ptyIdx}`); - this.ptyIndexByPid.set(pid, ptyIdx); - this.activePtyIndices.add(ptyIdx); - return ptyIdx; + private checkedAlignUp( + value: number, + alignment: number, + context: string, + ): number { + if ( + !Number.isSafeInteger(value) || + value < 0 || + !Number.isSafeInteger(alignment) || + alignment <= 0 + ) { + throw new KernelScratchError(`${context} is not representable`, EINVAL); + } + const remainder = value % alignment; + const aligned = remainder === 0 ? value : value + alignment - remainder; + if (!Number.isSafeInteger(aligned)) { + throw new KernelScratchError(`${context} overflows`, EINVAL); + } + return aligned; } - /** - * Write data to a PTY master (host → line discipline → slave). - * Wakes any process blocked on reading the slave side. - */ - ptyMasterWrite(ptyIdx: number, data: Uint8Array): void { - const kernelPtyMasterWrite = this.kernelInstance!.exports.kernel_pty_master_write as - ((ptyIdx: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; - if (!kernelPtyMasterWrite) return; - const exactData = intrinsicUint8ArrayView(data, "PTY input"); - const scratch = this.requireMainScratchRegion(); - scratch.withLease((lease) => { - let offset = 0; - while (offset < exactData.byteLength) { - const chunkLength = Math.min( - exactData.byteLength - offset, - scratch.capacity, - ); - lease.copyFrom(exactData, 0, offset, chunkLength); - const written = lease.invokeKernelExport( - "kernel_pty_master_write", - [ - ptyIdx, - lease.exportPointer(0, chunkLength), - chunkLength, - ], - ); - if (!Number.isSafeInteger(written) || written > chunkLength) { - throw new KernelScratchError( - "kernel PTY write exceeded its staged input", - EIO, - ); - } - if (written <= 0) break; - offset += written; - if (written < chunkLength) break; - } - }); - // Drain echo/output produced by the line discipline - this.drainPtyOutput(ptyIdx); - // Wake any process blocked on slave read - this.scheduleWakeBlockedRetries(); + #rejectScratchTransfer( + channel: ChannelInfo, + error: unknown, + entry?: KernelWorkerEntryContext, + ): void { + const errno = error instanceof KernelScratchError ? error.errno : EFAULT; + this.completeChannelRawAndRelisten(channel, -1, errno, entry); } - /** - * Read all available data from a PTY master (slave output → host). - * Returns data or null if empty. - */ - ptyMasterRead(ptyIdx: number): Uint8Array | null { - const kernelPtyMasterRead = this.kernelInstance!.exports.kernel_pty_master_read as - ((ptyIdx: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; - if (!kernelPtyMasterRead) return null; - const scratch = this.requireMainScratchRegion(); - const request = Math.min(4096, scratch.capacity); - return scratch.withLease((lease) => { - const n = lease.invokeKernelExport( - "kernel_pty_master_read", - [ - ptyIdx, - lease.exportPointer(0, request), - request, - ], + private checkedProcessMessage( + channel: ChannelInfo, + messagePointerValue: number | bigint, + direction: "send" | "receive", + capturedProcessMemory?: Uint8Array, + ): CheckedProcessMessage { + const pointerWidth = this.getPtrWidth(channel.pid); + const layout = this.processMessageLayout(pointerWidth); + const checkRange = ( + pointer: number | bigint, + length: number | bigint, + field: string, + ): { pointer: number; length: number; end: number } => + capturedProcessMemory === undefined + ? this.checkedProcessRange(channel, pointer, length, field) + : checkedProcessMemoryViewRange( + capturedProcessMemory, + pointer, + length, + pointerWidth, + field, + ); + const message = checkRange( + messagePointerValue, + layout.size, + "process msghdr", + ); + const processMemory = capturedProcessMemory + ?? new Uint8Array(channel.memory.buffer); + const view = new DataView( + processMemory.buffer, + processMemory.byteOffset + message.pointer, + message.length, + ); + const rawNamePointer = pointerWidth === 8 + ? view.getBigUint64(layout.nameOffset, true) + : view.getUint32(layout.nameOffset, true); + const namePresent = rawNamePointer !== 0 && rawNamePointer !== 0n; + const callerNameLength = view.getUint32(layout.nameLengthOffset, true); + if (direction === "send" && namePresent) { + // WHY: msg_name is a nested socket-address source. It must obey the + // same complete sockaddr_storage bound as sendto rather than escaping + // the generated descriptor check merely because it lives in msghdr. + validateSocketAddressInputSize(callerNameLength); + } + // A receive buffer can be larger than the complete generic address object + // the kernel can produce. Prove and reserve only sockaddr_storage bytes; + // validating the unused tail would conflate caller capacity with use. An + // absent msg_name makes msg_namelen ignored regardless of its stale value. + const nameLength = namePresent + ? direction === "receive" + ? Math.min(callerNameLength, KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES) + : callerNameLength + : 0; + const rawIovecPointer = pointerWidth === 8 + ? view.getBigUint64(layout.iovecOffset, true) + : view.getUint32(layout.iovecOffset, true); + // WHY: musl deliberately keeps both msg_iovlen (`int`) and + // msg_controllen (`socklen_t`) 32-bit on wasm64, then adds four bytes of + // ABI padding. Reading either as size_t would treat unrelated padding as + // the high half of a count and reject or mis-size a valid message. + const iovecCount = view.getUint32(layout.iovecCountOffset, true); + const rawControlPointer = pointerWidth === 8 + ? view.getBigUint64(layout.controlOffset, true) + : view.getUint32(layout.controlOffset, true); + const controlLength = view.getUint32( + layout.controlLengthOffset, + true, + ); + + const checkedOptionalRange = ( + pointer: number | bigint, + length: number, + field: string, + ): { pointer: number; length: number } => { + if (length === 0) { + // POSIX ignores the paired pointer when the byte count is zero. Do + // not even numerically convert it: a wasm64 caller may leave unsafe + // high bits there without naming process memory. + return { pointer: 0, length: 0 }; + } + return checkRange(pointer, length, field); + }; + + return { + pointerWidth, + messagePointer: message.pointer, + namePresent, + name: checkedOptionalRange( + rawNamePointer, + nameLength, + "msg_name", + ), + control: checkedOptionalRange( + rawControlPointer, + controlLength, + "msg_control", + ), + iovecs: this.checkedProcessIovecs( + channel, + rawIovecPointer, + iovecCount, + true, + capturedProcessMemory, + ), + }; + } + + private nativeControlToKernelWire( + processMem: Uint8Array, + message: CheckedProcessMessage, + ): Uint8Array { + if (message.control.length === 0) return new Uint8Array(0); + const native = this.processControlMessageLayout(message.pointerWidth); + const source = new DataView( + processMem.buffer, + processMem.byteOffset + message.control.pointer, + message.control.length, + ); + const records: Array<{ + level: number; + type: number; + data: Uint8Array; + wireLength: number; + wireSpace: number; + }> = []; + let nativeOffset = 0; + let wireBytes = 0; + while (nativeOffset + native.size <= message.control.length) { + const cmsgLength = this.readProcessUsize( + source, + nativeOffset + native.lengthOffset, + message.pointerWidth, + "cmsg_len", ); - if (n <= 0) return null; - if (!Number.isSafeInteger(n) || n > request) { + if (cmsgLength < native.dataOffset) { throw new KernelScratchError( - "kernel PTY read exceeded its requested scratch capacity", - EIO, + "native control message header is malformed", + EINVAL, ); } - return lease.copyOut(0, n); - }); - } - - /** - * Resize a PTY and send SIGWINCH to the foreground process group. - */ - ptySetWinsize(ptyIdx: number, rows: number, cols: number): void { - const kernelPtySetWinsize = this.kernelInstance!.exports - .kernel_pty_set_winsize as - ((ptyIdx: number, rows: number, cols: number) => number) | undefined; - if (!kernelPtySetWinsize) return; - kernelPtySetWinsize(ptyIdx, rows, cols); - this.scheduleWakeBlockedRetries(); - - // A process parked in a host-side setTimeout-backed nanosleep won't notice - // the SIGWINCH the kernel just raised — the timer just runs to completion. - // Speculatively dequeue a Handler signal for each blocked pid; if one was - // pending we complete the sleep with EINTR so the glue can dispatch it. - // Skipped pids (no signal queued) keep their original sleep deadline. - const EINTR = 4; - for (const [sleepChannel, entry] of Array.from(this.pendingSleeps.entries())) { - if (!this.isRegisteredChannel(entry.channel)) continue; - this.dequeueSignalForDelivery(entry.channel); - if (this.finishSignalTermination(entry.channel)) continue; - const view = new DataView(entry.channel.memory.buffer, entry.channel.channelOffset); - if (view.getUint32(CH_SIG_SIGNUM, true) > 0) { - clearTimeout(entry.timer); - this.pendingSleeps.delete(sleepChannel); - this.completeChannel( - entry.channel, entry.syscallNr, entry.origArgs, - SYSCALL_ARGS[entry.syscallNr], -1, EINTR, + const nativeEnd = nativeOffset + cmsgLength; + if ( + !Number.isSafeInteger(nativeEnd) || + nativeEnd > message.control.length + ) { + throw new KernelScratchError( + "native control message exceeds msg_controllen", + EINVAL, ); } + const level = source.getUint32(nativeOffset + native.levelOffset, true); + const type = source.getUint32(nativeOffset + native.typeOffset, true); + const dataLength = cmsgLength - native.dataOffset; + if ( + level === SOCKET_SOL_SOCKET && + type === SOCKET_SCM_RIGHTS && + dataLength % SCM_RIGHTS_FD_BYTES !== 0 + ) { + throw new KernelScratchError( + "SCM_RIGHTS payload is not an array of file descriptors", + EINVAL, + ); + } + const wireLength = KERNEL_CMSGHDR_WIRE_DATA_OFFSET + dataLength; + const wireSpace = this.checkedAlignUp( + wireLength, + KERNEL_CMSGHDR_WIRE_ALIGN, + "kernel control message", + ); + wireBytes += wireSpace; + if (!Number.isSafeInteger(wireBytes) || wireBytes > CH_DATA_SIZE) { + throw new KernelScratchError( + "control messages exceed bounded kernel transport", + 90, + ); + } + records.push({ + level, + type, + data: processMem.slice( + message.control.pointer + nativeOffset + native.dataOffset, + message.control.pointer + nativeEnd, + ), + wireLength, + wireSpace, + }); + nativeOffset = this.checkedAlignUp( + nativeEnd, + native.alignment, + "native control message", + ); } - } - - /** - * Register a callback for PTY output data. - */ - onPtyOutput(ptyIdx: number, callback: (data: Uint8Array) => void): void { - this.ptyOutputCallbacks.set(ptyIdx, callback); - this.drainPtyOutput(ptyIdx); - } - /** - * Drain output from a PTY master and invoke the registered callback. - */ - private drainPtyOutput(ptyIdx: number): void { - const callback = this.ptyOutputCallbacks.get(ptyIdx); - if (!callback) return; - for (;;) { - const data = this.ptyMasterRead(ptyIdx); - if (!data) break; - callback(data); + const output = new Uint8Array(wireBytes); + const view = new DataView(output.buffer); + let wireOffset = 0; + for (const record of records) { + view.setUint32( + wireOffset + KERNEL_CMSGHDR_WIRE_LEN_OFFSET, + record.wireLength, + true, + ); + view.setUint32( + wireOffset + KERNEL_CMSGHDR_WIRE_LEVEL_OFFSET, + record.level, + true, + ); + view.setUint32( + wireOffset + KERNEL_CMSGHDR_WIRE_TYPE_OFFSET, + record.type, + true, + ); + output.set( + record.data, + wireOffset + KERNEL_CMSGHDR_WIRE_DATA_OFFSET, + ); + wireOffset += record.wireSpace; } + return output; } - /** - * Drain all active PTY outputs. Called after each syscall completion - * to flush any program output produced during the syscall. - */ - private drainAllPtyOutputs(): void { - if (this.activePtyIndices.size === 0) return; - for (const ptyIdx of this.activePtyIndices) { - this.drainPtyOutput(ptyIdx); + private kernelControlCapacityForRecv( + message: CheckedProcessMessage, + ): number { + const native = this.processControlMessageLayout(message.pointerWidth); + if ( + message.control.length < + native.dataOffset + SCM_RIGHTS_FD_BYTES + ) { + return 0; } + const descriptorCapacity = Math.floor( + (message.control.length - native.dataOffset) / SCM_RIGHTS_FD_BYTES, + ); + // WHY: Rust emits at most one SCM_RIGHTS record. Bound its fixed-wire FD + // capacity by what the wider caller-native header can represent, so it + // cannot install descriptors that expansion back to wasm64 would lose. + return KERNEL_CMSGHDR_WIRE_DATA_OFFSET + + descriptorCapacity * SCM_RIGHTS_FD_BYTES; } - /** - * Set the working directory for a process. - * Must be called after registerProcess and before the process starts. - */ - setCwd(pid: number, cwd: string): void { - if (!this.initialized) throw new Error("Kernel not initialized"); - const kernelSetCwd = this.kernelInstance!.exports.kernel_set_cwd as - ((pid: number, ptr: KernelPointer, len: number) => number) | undefined; - if (typeof kernelSetCwd !== "function") { - // WHY: initial cwd is part of the current host/kernel contract. A - // silent older-kernel no-op would report a different cwd than the - // process actually owns after the checked scratch transfer. - throw new Error("Kernel missing required kernel_set_cwd export"); + private kernelControlToNative( + wireBytes: Uint8Array, + message: CheckedProcessMessage, + ): { bytes: Uint8Array; length: number } { + if (wireBytes.length === 0) { + return { bytes: new Uint8Array(0), length: 0 }; } - const encoded = new TextEncoder().encode(cwd); - // kernel_set_cwd applies PATH_MAX too, but that would be after the host - // copy. Reject first so an oversized pathname never reaches scratch. - if (encoded.byteLength >= POSIX_PATH_MAX_BYTES) { - throw new Error(`setCwd failed for pid ${pid}: cwd exceeds PATH_MAX`); + if (wireBytes.length < STRUCT_SIZE_KERNEL_CMSGHDR_WIRE) { + throw new KernelScratchError( + "kernel returned a partial control message header", + EIO, + ); } - const result = this.requireMainScratchRegion().withLease((scratch) => { - scratch.copyFrom(encoded); - return scratch.invokeKernelExport("kernel_set_cwd", [ - pid, - scratch.exportPointer(0, encoded.byteLength), - encoded.byteLength, - ]); - }); - if (result < 0) { - throw new Error(`setCwd failed for pid ${pid}: errno ${-result}`); - } - } - - /** - * Set a freshly-created process's initial real/effective uid and gid. - * Must be called after registerProcess and before the process starts. - */ - setCredentials(pid: number, ids: { uid?: number; gid?: number }): void { - if (!this.initialized) throw new Error("Kernel not initialized"); - if (ids.uid == null && ids.gid == null) return; - - const unchanged = 0xffffffff; - const direct = this.kernelInstance!.exports - .kernel_set_process_credentials as - ((pid: number, uid: number, gid: number) => number) | undefined; - if (!direct) { - throw new Error("Kernel missing kernel_set_process_credentials export"); + const wire = new DataView( + wireBytes.buffer, + wireBytes.byteOffset, + wireBytes.byteLength, + ); + const cmsgLength = wire.getUint32(KERNEL_CMSGHDR_WIRE_LEN_OFFSET, true); + if ( + cmsgLength < KERNEL_CMSGHDR_WIRE_DATA_OFFSET || + cmsgLength > wireBytes.length || + this.checkedAlignUp( + cmsgLength, + KERNEL_CMSGHDR_WIRE_ALIGN, + "returned kernel control message", + ) !== wireBytes.length + ) { + throw new KernelScratchError( + "kernel returned a malformed control message", + EIO, + ); } - const result = direct(pid, ids.uid ?? unchanged, ids.gid ?? unchanged); - if (result < 0) { - throw new Error( - `setCredentials failed for pid ${pid}: errno ${-result}`, + const level = wire.getUint32(KERNEL_CMSGHDR_WIRE_LEVEL_OFFSET, true); + const type = wire.getUint32(KERNEL_CMSGHDR_WIRE_TYPE_OFFSET, true); + const dataLength = cmsgLength - KERNEL_CMSGHDR_WIRE_DATA_OFFSET; + if ( + level !== SOCKET_SOL_SOCKET || + type !== SOCKET_SCM_RIGHTS || + dataLength === 0 || + dataLength % SCM_RIGHTS_FD_BYTES !== 0 + ) { + throw new KernelScratchError( + "kernel returned an unsupported control message", + EIO, ); } - } - - /** - * Snapshot the kernel's process table. Returns one ProcessSnapshot per - * live process. Used by Inspector → Procs (Kandelo UI) and any host that - * wants a `ps`-equivalent without spawning a user-mode reader. - * - * Reads from the kernel's scratch buffer. If the buffer overflows on a - * very large process table, returns an empty array — host can wrap with - * a retry on a larger scratch alloc. - * - * Returns an empty array if the kernel hasn't initialized yet or doesn't - * expose the export (older kernels). - */ - // ── Syscall trace (opt-in live ring buffer) ──────────────────────────── - // - // Off by default — zero cost when no subscriber. enableSyscallTrace() - // flips a flag; _handleSyscallInner pushes to this.syscallTraceRing - // when it's on. drainSyscallTrace() returns + clears the buffer; main - // thread polls every ~250ms via a worker→main request/response cycle. - - private syscallTraceEnabled = false; - private syscallTraceRing: SyscallTraceEvent[] = []; - /** Cap the ring so a forgotten subscriber can't blow memory. */ - private syscallTraceCap = 4096; - - enableSyscallTrace(): void { - this.syscallTraceEnabled = true; - } - disableSyscallTrace(): void { - this.syscallTraceEnabled = false; - this.syscallTraceRing.length = 0; + const native = this.processControlMessageLayout(message.pointerWidth); + const nativeLength = native.dataOffset + dataLength; + if (nativeLength > message.control.length) { + throw new KernelScratchError( + "kernel control message exceeds caller capacity", + EIO, + ); + } + const reportedLength = Math.min( + message.control.length, + this.checkedAlignUp( + nativeLength, + native.alignment, + "native returned control message", + ), + ); + const output = new Uint8Array(reportedLength); + const outputView = new DataView(output.buffer); + this.writeProcessUsize( + outputView, + native.lengthOffset, + nativeLength, + message.pointerWidth, + "returned cmsg_len", + ); + outputView.setUint32(native.levelOffset, level, true); + outputView.setUint32(native.typeOffset, type, true); + output.set( + wireBytes.subarray( + KERNEL_CMSGHDR_WIRE_DATA_OFFSET, + KERNEL_CMSGHDR_WIRE_DATA_OFFSET + dataLength, + ), + native.dataOffset, + ); + return { bytes: output, length: reportedLength }; } - drainSyscallTrace(): SyscallTraceEvent[] { - if (this.syscallTraceRing.length === 0) return []; - const out = this.syscallTraceRing; - this.syscallTraceRing = []; - return out; + private kernelMessageLayout( + message: CheckedProcessMessage, + controlCapacity: number, + ): KernelMessageLayout { + let offset: number = STRUCT_SIZE_KERNEL_MSGHDR_WIRE; + const append = (length: number): number => { + const start = offset; + offset += length; + if (!Number.isSafeInteger(offset)) { + throw new KernelScratchError("kernel msghdr layout overflows", EINVAL); + } + offset = this.checkedAlignUp( + offset, + KERNEL_MSGHDR_WIRE_ALIGN, + "kernel msghdr layout", + ); + return start; + }; + const nameOffset = message.namePresent + ? append(message.name.length) + : 0; + const controlOffset = controlCapacity > 0 + ? append(controlCapacity) + : 0; + const iovecCount = message.iovecs.entries.length > 0 + ? FLATTENED_KERNEL_MESSAGE_IOVEC_COUNT + : 0; + const iovecBytes = iovecCount * STRUCT_SIZE_KERNEL_IOVEC_WIRE; + const iovecOffset = iovecBytes > 0 + ? append(iovecBytes) + : 0; + const dataOffset = message.iovecs.totalData > 0 + ? append(message.iovecs.totalData) + : 0; + return { + footprint: offset, + nameOffset, + controlOffset, + controlCapacity, + iovecOffset, + iovecCount, + iovecBytes, + dataOffset, + }; } - enumProcs(): ProcessSnapshot[] { - if (!this.initialized) return []; - const enumProcs = this.kernelInstance!.exports.kernel_enum_procs as - ((ptr: KernelPointer, len: number) => number) | undefined; - if (!enumProcs) return []; - const scratch = this.requireMainScratchRegion(); - const owned = scratch.withLease((lease) => { - const request = Math.min(SCRATCH_SIZE, scratch.capacity); - const n = lease.invokeKernelExport("kernel_enum_procs", [ - lease.exportPointer(0, request), - request, - ]); - if (n <= 0) return null; - if (!Number.isSafeInteger(n) || n > request) { - throw new KernelScratchError( - "kernel process enumeration exceeded scratch capacity", - EIO, - ); - } - return lease.copyOut(0, n); - }); - if (!owned) return []; - const snapshots = parseProcSnapshots(owned); - for (const snapshot of snapshots) { - const registration = this.processes.get(snapshot.pid); - if (registration) { - snapshot.memoryBytes = registration.memory.buffer.byteLength; - } + private checkedKernelWirePointer(pointer: number): number { + if ( + !Number.isSafeInteger(pointer) || + pointer < 0 || + pointer > 0xffff_ffff + ) { + throw new KernelScratchError( + "kernel wire pointer does not fit its u32 field", + EIO, + ); } - return snapshots; + return pointer; } /** - * Read `/proc/[pid]/maps` for a foreign process. Returns the raw Linux- - * style text (one line per mapped region) or `null` if the pid doesn't - * exist. Empty string if the process has no mappings. + * Ask the Rust kernel to allocate and create a process descriptor. + * + * The returned PID already names authoritative kernel state. Hosts may + * attach memory, channels, and a Worker to it, but never choose the PID. */ - readProcMaps(pid: number): string | null { - if (!this.initialized) return null; - const readMaps = this.kernelInstance!.exports.kernel_read_proc_maps as - ((pid: number, ptr: KernelPointer, len: number) => number) | undefined; - if (!readMaps) return null; - const scratch = this.requireMainScratchRegion(); - const owned = scratch.withLease((lease) => { - const request = Math.min(SCRATCH_SIZE, scratch.capacity); - const n = lease.invokeKernelExport("kernel_read_proc_maps", [ - pid, - lease.exportPointer(0, request), - request, - ]); - if (n < 0) return null; // -ESRCH or similar - if (n === 0) return new Uint8Array(0); - if (!Number.isSafeInteger(n) || n > request) { - throw new KernelScratchError( - "kernel process maps output exceeded scratch capacity", - EIO, - ); - } - return lease.copyOut(0, n); - }); - if (owned === null) return null; - return new TextDecoder("utf-8", { fatal: false }).decode(owned); + createProcess(stdio: RegisterProcessStdio): number { + if (!this.#initialized) throw new Error("Kernel not initialized"); + const stdinKind = encodeStdioKind(stdio.stdin); + const stdoutKind = encodeStdioKind(stdio.stdout); + const stderrKind = encodeStdioKind(stdio.stderr); + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + // A PID is a synchronous authority result. Returning before a queued + // allocation runs would let the caller attach host state to a fiction. + throw new KernelReentrantEntryError("kernel process creation"); + } + let createdPid: number | undefined; + let creationError: Error | undefined; + const deferred = this.#runOrDeferKernelEntry( + "kernel process creation", + (entry) => { + const createProcess = this.#kernelInstanceForEntry(entry).exports + .kernel_create_process_with_stdio as + | (( + stdinKind: number, + stdoutKind: number, + stderrKind: number, + ) => number) + | undefined; + if (!createProcess) { + creationError = new Error( + "Kernel missing kernel_create_process_with_stdio export", + ); + return undefined; + } + const pid = createProcess(stdinKind, stdoutKind, stderrKind); + if (pid <= 0) { + creationError = new Error( + `Failed to create process: errno ${-pid}`, + ); + return undefined; + } + createdPid = pid; + return undefined; + }, + ); + if (creationError !== undefined) throw creationError; + if (deferred || createdPid === undefined) { + throw new KernelReentrantEntryError("kernel process creation"); + } + return createdPid; } /** - * Unregister a process. Stops listening on its channels and removes - * it from the kernel's process table. + * Attach process memory and thread channels to an existing kernel Process. + * Each channel is a region in the process's shared Memory. */ - unregisterProcess( + registerProcess( pid: number, - expectedMemory?: WebAssembly.Memory, - ): boolean { - const registration = this.processes.get(pid); - if (!registration) return true; - if (expectedMemory && registration.memory !== expectedMemory) return false; - - this.retireAsyncChannelsForProcess(pid); - this.discardStoppedChannelStateForProcess(pid); - this.waitingForChild = (this.waitingForChild ?? []).filter( - (waiter) => waiter.parentPid !== pid && waiter.channel.pid !== pid, - ); - - // Shared backing publication and SysV detach require the process memory and - // kernel Process to remain available, so do this before either is removed. - this.releaseAllSharedMemoryForProcess(pid); - this.releaseProcessViews(pid, registration.memory); - - // Remove channels from active list - this.activeChannels = this.activeChannels.filter((ch) => ch.pid !== pid); - this.clearProcessThreadTransportState(pid); - - // Clean up network listeners/endpoints for this process - this.cleanupUdpBindings(pid); - this.cleanupTcpListeners(pid); - - // Clean up pending poll retries - this.cleanupPendingPollRetries(pid); - // Clean up pending select retries - this.cleanupPendingSelectRetries(pid); - this.cleanupPendingSignalWaits(pid); - // Clean up pending pipe readers/writers - this.cleanupPendingPipeReaders(pid); - this.cleanupPendingPipeWriters(pid); - this.cancelPendingSleepsForProcess(pid); - // Clean up socket timeout timers for this process - for (const [ch, timer] of this.socketTimeoutTimers) { - if (ch.pid === pid) { - clearTimeout(timer); - this.socketTimeoutTimers.delete(ch); - } + memory: WebAssembly.Memory, + channelOffsets: number[], + options?: RegisterProcessOptions, + ): void { + if (!this.#initialized) throw new Error("Kernel not initialized"); + if (!Number.isSafeInteger(pid) || pid <= 0 || pid > MAX_KERNEL_TASK_ID) { + throw new Error(`Cannot register invalid kernel process ID ${pid}`); + } + const stableChannelOffsets = [...channelOffsets]; + if (stableChannelOffsets.length !== 1) { + throw new Error( + `Process ${pid} must register exactly one main syscall channel`, + ); + } + if (pid === 1) { + throw new Error("Cannot register the kernel-reserved init process"); + } + // WHY: exports can synchronously call host imports. Snapshot every + // caller-owned value before the first export so a reentrant callback + // cannot replace argv/env or layout fields halfway through registration. + const argv = options?.argv === undefined + ? undefined + : [...options.argv]; + const env = options?.env === undefined + ? undefined + : [...options.env]; + const ptrWidth = options?.ptrWidth ?? 4; + const metadataPtrWidth = options?.metadataPtrWidth ?? ptrWidth; + const preserveProcessState = options?.preserveProcessState === true; + const brkBase = options?.brkBase; + const mmapBase = options?.mmapBase; + const explicitMaxAddr = options?.maxAddr; + const brkLimit = options?.brkLimit; + const existingRegistration = this.processes.get(pid); + const replacingExecImage = + preserveProcessState + && this.execHandoffPids?.has(pid) === true + && existingRegistration?.channels.length === 0; + if (existingRegistration && !replacingExecImage) { + throw new Error(`Process ${pid} is already registered with the host`); } - // Clean up epoll interest mirrors for this process - for (const key of this.epollInterests.keys()) { - if (key.startsWith(`${pid}:`)) { - this.epollInterests.delete(key); + if (argv !== undefined || env !== undefined) { + const metadataResult = this.validateExecMetadata( + argv ?? [], + env ?? [], + metadataPtrWidth, + ); + if (metadataResult < 0) { + throw new Error(`Process argv/environment exceeds exec metadata limits: errno ${-metadataResult}`); } } - // Remove from kernel process table - this.removeFromKernelProcessTable(pid); - - this.processes.delete(pid); - this.execHandoffPids?.delete(pid); - this.stdinFinite.delete(pid); - this.stdinBuffers.delete(pid); + const channels: ChannelInfo[] = stableChannelOffsets.map((offset) => ({ + pid, + memory, + channelOffset: offset, + i32View: new Int32Array(memory.buffer, offset), + consecutiveSyscalls: 0, + })); - // Stop poller if no more processes - if (this.usePolling && this.processes.size === 0) { - this.stopPolling(); + const registration: ProcessRegistration = { + pid, + memory, + channels, + ptrWidth, + explicitMaxAddr: explicitMaxAddr !== undefined, + }; + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + // Registration is an acknowledged protocol transition: callers launch + // the Worker immediately after this method returns. + throw new KernelReentrantEntryError( + `kernel process registration pid=${pid}`, + ); } + let registrationPreconditionError: Error | null = null; + const deferred = this.#runOrDeferKernelEntry( + `kernel process registration pid=${pid}`, + (entry) => { + const getProcessState = this.#kernelInstanceForEntry(entry).exports + .kernel_get_process_state as + ((pid: number) => number) | undefined; + const processState = getProcessState?.(pid); + if (processState === undefined || processState < 0) { + registrationPreconditionError = new Error( + `Cannot register unknown kernel process ${pid}`, + ); + return undefined; + } + if ( + processState !== PROCESS_STATE_RUNNING + && processState !== PROCESS_STATE_STOPPED + ) { + registrationPreconditionError = new Error( + `Cannot register inactive kernel process ${pid}`, + ); + return undefined; + } - // Clean up PTY state - const ptyIdx = this.ptyIndexByPid.get(pid); - if (ptyIdx !== undefined) { - this.ptyIndexByPid.delete(pid); - this.activePtyIndices.delete(ptyIdx); - this.ptyOutputCallbacks.delete(ptyIdx); + if ( + brkBase !== undefined + && !this.#setBrkBaseWithinKernelEntry(pid, brkBase, entry) + ) { + throw new Error( + "Kernel export kernel_set_brk_base is required for compact process memory layout", + ); + } + + // Set process argv in kernel for /proc//cmdline. + if (argv !== undefined) { + registrationPreconditionError = + this.#replaceProcessMetadataWithinKernelEntry( + pid, + PROCESS_METADATA_ARGV, + argv, + entry, + ); + if (registrationPreconditionError !== null) return undefined; + } + + // Exec must synchronize an explicitly empty replacement environment. + if (env !== undefined) { + registrationPreconditionError = + this.#replaceProcessMetadataWithinKernelEntry( + pid, + PROCESS_METADATA_ENVIRONMENT, + env, + entry, + ); + if (registrationPreconditionError !== null) return undefined; + } + + // New layouts supply an explicit ceiling. Legacy layouts retain the + // historical lowest-channel ceiling. + const maxAddr = explicitMaxAddr + ?? Math.min(...stableChannelOffsets); + this.#setMaxAddrWithinKernelEntry(pid, maxAddr, entry); + + if ( + mmapBase !== undefined + && !this.#setMmapBaseWithinKernelEntry(pid, mmapBase, entry) + ) { + throw new Error( + "Kernel export kernel_set_mmap_base is required for compact process memory layout", + ); + } + if ( + brkLimit !== undefined + && !this.#setBrkLimitWithinKernelEntry(pid, brkLimit, entry) + ) { + throw new Error( + "Kernel export kernel_set_brk_limit is required for legacy low-control layout", + ); + } + + entry.deferProtocolEffect(() => { + // WHY: publish the replacement channel generation only after every + // Rust mutation has completed and the entry scope is revoked. Host + // callbacks and listeners can therefore observe either the old + // registration or the complete new one, never a partial mixture. + this.discardStoppedChannelStateForProcess( + pid, + !preserveProcessState, + ); + // Kernel task IDs are never reused. Clear a stale host lifecycle + // marker only as part of the same completed registration commit. + this.hostReaped.delete(pid); + this.processes.set(pid, registration); + this.activeChannels.push(...channels); + + if (this.usePolling) { + this.startPolling(); + } else { + for (const channel of channels) { + this.listenOnChannel(channel); + } + } + return undefined; + }); + entry.deferObserverEffect(() => { + // WHY: target discovery is weak retirement telemetry, not part of + // process registration. Invoke the host only after the exact + // registration is committed and all kernel authority is revoked. + this.observeProcessMemoryTarget(memory, memory); + this.observeProcessMemoryTarget(memory, memory.buffer); + this.observeProcessMemoryTarget(memory, registration); + for (const channel of channels) { + this.observeProcessMemoryTarget(memory, channel); + this.observeProcessMemoryTarget(memory, channel.i32View); + } + return undefined; + }); + return undefined; + }, + ); + if (registrationPreconditionError !== null) { + throw registrationPreconditionError; + } + if (deferred) { + throw new KernelReentrantEntryError( + `kernel process registration pid=${pid}`, + ); } - return true; } /** - * Deactivate a process's channels without removing it from the kernel - * process table. Used for zombie processes that need to remain queryable - * (getpgid, setpgid) until reaped by wait/waitpid. - */ - /** - * Remove a pid from the wasm kernel's ProcessTable entirely. Used by - * the worker-entry's crash path: when a worker dies via a wasm trap - * (signature mismatch, OOM, etc.) the kernel never saw a SYS_EXIT, so - * its ProcessTable still has the pid in state=Running. After this - * runs, kernel_enum_procs no longer reports it and a parent's - * waitpid() returns ECHILD — accurate for "the process really is gone." - * - * Don't call this for normal exits — the kernel marks those Exited - * (zombie) so the parent can still reap. + * Side-effect-free exec argv/environment validation. Call this before the + * irreversible exec commit so oversized metadata returns E2BIG to the old + * image instead of failing while the replacement worker is being installed. */ - removeProcessFromKernelTable(pid: number): void { - if (!this.initialized) { - throw new Error("Kernel is not initialized for process removal"); + validateExecMetadata( + argv: readonly string[], + env: readonly string[], + ptrWidth: 4 | 8 = 4, + ): number { + const encoder = new TextEncoder(); + // Account for the null pointer terminating each vector even when it is + // explicitly empty. Pointer accounting both matches ARG_MAX semantics and + // bounds the number of zero-length entries without an arbitrary count cap. + let totalBytes = 2 * ptrWidth; + for (const value of [...argv, ...env]) { + const encodedLength = encoder.encode(value).byteLength; + if (encodedLength > PROCESS_METADATA_ENTRY_MAX_BYTES) return -E2BIG; + totalBytes += ptrWidth + encodedLength + 1; + if (!Number.isSafeInteger(totalBytes) || totalBytes > POSIX_ARG_MAX_BYTES) { + return -E2BIG; + } } - this.removeFromKernelProcessTable(pid); + return 0; } - private cancelPendingSleepsForProcess(pid: number): void { - for (const [channel, sleep] of this.pendingSleeps) { - if (channel.pid !== pid) continue; - clearTimeout(sleep.timer); - this.pendingSleeps.delete(channel); + /** Replace argv or environ using bounded, entry-at-a-time scratch copies. */ + private replaceProcessMetadata( + pid: number, + kind: number, + values: readonly string[], + ): void { + const stableValues = [...values]; + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError( + `process metadata replacement pid=${pid}`, + ); + } + let preconditionError: Error | null = null; + const deferred = this.#runOrDeferKernelEntry( + `process metadata replacement pid=${pid}`, + (entry) => { + preconditionError = this.#replaceProcessMetadataWithinKernelEntry( + pid, + kind, + stableValues, + entry, + ); + return undefined; + }, + ); + if (preconditionError !== null) throw preconditionError; + if (deferred) { + throw new KernelReentrantEntryError( + `process metadata replacement pid=${pid}`, + ); } } - deactivateProcess( + #replaceProcessMetadataWithinKernelEntry( pid: number, - expectedMemory?: WebAssembly.Memory, - ): boolean { - const registration = this.processes.get(pid); - if (!registration) return true; - if (expectedMemory && registration.memory !== expectedMemory) return false; - this.retireAsyncChannelsForProcess(pid); - this.discardStoppedChannelStateForProcess(pid); - this.waitingForChild = (this.waitingForChild ?? []).filter( - (waiter) => waiter.parentPid !== pid && waiter.channel.pid !== pid, - ); - this.releaseAllSharedMemoryForProcess(pid); - if (registration) { - this.releaseProcessViews(pid, registration.memory); + kind: number, + values: readonly string[], + entry: KernelWorkerEntryContext, + ): Error | null { + const clear = this.#kernelInstanceForEntry(entry).exports.kernel_clear_process_metadata as + ((pid: number, kind: number) => number) | undefined; + const push = this.#kernelInstanceForEntry(entry).exports + .kernel_push_process_metadata_entry as + | (( + pid: number, + kind: number, + dataPtr: KernelPointer, + dataLen: number, + ) => number) + | undefined; + if (typeof clear !== "function" || typeof push !== "function") { + // WHY: current-ABI admission requires both bounded entry-at-a-time + // exports. Falling back to the historical aggregate argv setter would + // silently lose environment replacement and revive a pointer-only + // transport after the capacity-safe contract was negotiated. + return new Error( + "Kernel missing required bounded process metadata exports", + ); } - this.activeChannels = this.activeChannels.filter((ch) => ch.pid !== pid); - this.clearProcessThreadTransportState(pid); - this.processes.delete(pid); - this.execHandoffPids?.delete(pid); - this.stdinFinite.delete(pid); - this.stdinBuffers.delete(pid); - // Cancel any pending alarm timer for this process - const alarmTimer = this.alarmTimers.get(pid); - if (alarmTimer) { - clearTimeout(alarmTimer); - this.alarmTimers.delete(pid); + + const clearResult = clear(pid, kind); + if (clearResult < 0) { + throw new Error( + `Failed to clear process metadata for pid ${pid}: errno ${-clearResult}`, + ); } - // Cancel any pending posix timers for this process - for (const [key, entry] of this.posixTimers) { - if (key.startsWith(`${pid}:`)) { - clearTimeout(entry.timeout); - if (entry.interval) clearInterval(entry.interval); - this.posixTimers.delete(key); + + const encoder = new TextEncoder(); + for (const value of values) { + const encoded = encoder.encode(value); + if (encoded.byteLength > PROCESS_METADATA_ENTRY_MAX_BYTES) { + throw new Error( + `Process metadata entry exceeds bounded scratch transport: errno ${E2BIG}`, + ); + } + // A Rust push can grow memory. A fresh lease rechecks the replacement + // buffer and owns the bytes through the complete synchronous parse. + const pushResult = this.#requireMainScratchRegion().withLease((scratch) => { + scratch.copyFrom(encoded); + return this.#invokeEntryScratchExport( + entry, + scratch, + "kernel_push_process_metadata_entry", + [ + pid, + kind, + scratch.exportPointer(0, encoded.byteLength), + encoded.byteLength, + ], + ); + }); + if (pushResult < 0) { + throw new Error(`Failed to append process metadata for pid ${pid}: errno ${-pushResult}`); } } - // Cancel pending sleeps for every thread in this process. - this.cancelPendingSleepsForProcess(pid); - // Clean up pending poll retries - this.cleanupPendingPollRetries(pid); - // Clean up pending select retries - this.cleanupPendingSelectRetries(pid); - this.cleanupPendingSignalWaits(pid); - // Clean up network listeners/endpoints for this process - this.cleanupUdpBindings(pid); - this.cleanupTcpListeners(pid); - // Drop the killed-but-not-yet-reaped marker with the retired host - // registration. Kernel task IDs are not reused, but retaining stale - // transport lifecycle state would still be misleading and wasteful. - this.hostReaped.delete(pid); - return true; + return null; } /** - * Drop persistent device/view aliases only for the exact current process - * generation. A stale exec continuation must never clear the replacement - * image merely because it inherited the same numeric pid. + * Provide data that will be returned when the process reads from stdin (fd 0). + * Data is returned in chunks until exhausted, then EOF is returned. + * Must be called before the process starts reading stdin. */ - private releaseProcessViews( - pid: number, - expectedMemory: WebAssembly.Memory, - ): boolean { - const registration = this.processes.get(pid); - if (!registration || registration.memory !== expectedMemory) return false; - this.kernel.releaseProcessViews(pid); - return true; + setStdinData(pid: number, data: Uint8Array): void { + const owned = new Uint8Array( + intrinsicUint8ArrayView(data, "finite stdin data"), + ); + this.#runOrDeferKernelEntry( + `finite stdin replacement pid=${pid}`, + () => { + // WHY: host imports consume this buffer during later kernel exports. + // Install an owned snapshot only at a serialized entry boundary so a + // reentrant caller cannot replace bytes while Rust is reading them. + this.stdinBuffers.set(pid, { data: owned, offset: 0 }); + this.stdinFinite.add(pid); // EOF after data is consumed + return undefined; + }, + ); } /** - * Validate the exec caller and apply deferred posix_spawn file actions. - * This is the fallible kernel preflight; no image-owned state is discarded. + * Set stdout/stderr capture callbacks on the underlying kernel instance. + * Must be called after construction but works at any time. */ - kernelExecPrepare(pid: number, callerTid: number): number { - const prepare = this.kernelInstance!.exports.kernel_exec_prepare as - ((pid: number, callerTid: number) => number) | undefined; - if (!prepare) { - throw new Error("Kernel missing required kernel_exec_prepare export"); - } + setOutputCallbacks(callbacks: { + onStdout?: (data: Uint8Array) => void; + onStderr?: (data: Uint8Array) => void; + }): void { + this.#kernel.mergeCallbacks(callbacks); + } - const previousPid = this.currentHandlePid; - this.currentHandlePid = pid; - try { - return prepare(pid, callerTid); - } finally { - this.currentHandlePid = previousPid; - // Deferred spawn actions can close descriptors and publish a Rust - // advisory-lock wake even when a later action makes prepare fail. - this.drainAndProcessWakeupEvents(); - } + /** Set output callbacks that also receive the authoritative active pid. */ + setProcessOutputCallbacks(callbacks: { + onStdout?: (pid: number, data: Uint8Array) => void; + onStderr?: (pid: number, data: Uint8Array) => void; + }): void { + this.#kernel.mergeCallbacks({ + onStdout: callbacks.onStdout === undefined + ? undefined + : (data) => callbacks.onStdout!(this.currentHandlePid, data), + onStderr: callbacks.onStderr === undefined + ? undefined + : (data) => callbacks.onStderr!(this.currentHandlePid, data), + }); + } + + /** Observe a successfully registered kernel TCP listener. */ + setNetworkListenObserver( + observer: (pid: number, fd: number, port: number) => void, + ): void { + this.networkListenObserver = observer; } /** - * Run kernel-side exec setup: close CLOEXEC fds, reset signal handlers. - * Returns 0 on success, negative errno on failure. - * Called by onExec callbacks after confirming the target program exists. + * Append data to a process's stdin buffer without marking stdin as a pipe. + * Used for interactive stdin where data arrives incrementally. + * Wakes any blocked stdin readers after appending. */ - kernelExecSetup(pid: number, callerTid: number): number { - const threadAware = this.kernelInstance!.exports - .kernel_exec_setup_for_thread as - ((pid: number, callerTid: number) => number) | undefined; - if (!threadAware) { - throw new Error( - "Kernel missing required kernel_exec_setup_for_thread export", - ); - } - const previousPid = this.currentHandlePid; - this.currentHandlePid = pid; - try { - const listenerWakeSnapshot = this.snapshotExecTcpListenerWakeIds(pid); - const result = threadAware(pid, callerTid); - if (result === 0) { - // This is post-commit bookkeeping. Let failures propagate to the - // worker entry's fatal exec boundary; returning to the discarded - // caller or continuing with stale host mirrors would both be false. - this.pruneExecFdMirrors(pid, listenerWakeSnapshot); - } - return result; - } finally { - this.currentHandlePid = previousPid; - // CLOEXEC closure is an advisory-lock state transition. Consume the - // kernel event before the discarded image's host mirrors disappear. - this.drainAndProcessWakeupEvents(); - } + appendStdinData(pid: number, data: Uint8Array): void { + const owned = new Uint8Array( + intrinsicUint8ArrayView(data, "incremental stdin data"), + ); + this.#runOrDeferKernelEntry( + `incremental stdin append pid=${pid}`, + (entry) => { + const existing = this.stdinBuffers.get(pid); + if (existing) { + // Concatenate with remaining unread data. + const remaining = existing.data.subarray(existing.offset); + const combined = new Uint8Array(remaining.length + owned.length); + combined.set(remaining); + combined.set(owned, remaining.length); + this.stdinBuffers.set(pid, { data: combined, offset: 0 }); + } else { + this.stdinBuffers.set(pid, { data: owned, offset: 0 }); + } + // Wake any blocked readers only after this exact replacement is + // visible; the scheduler effect is detached by the entry context. + this.scheduleWakeBlockedRetries(entry); + return undefined; + }, + ); } - /** Snapshot stable accept-queue identities before CLOEXEC closes aliases. */ - private snapshotExecTcpListenerWakeIds(pid: number): Map { - const getAcceptWake = this.kernelInstance!.exports - .kernel_get_fd_accept_wake_idx as - ((pid: number, fd: number) => number) | undefined; - const snapshot = new Map(); - if (!getAcceptWake) return snapshot; - const remember = (port: number, fd: number, knownWakeIdx?: number) => { - // A target's stored queue token is its stable identity even after the - // numeric fd closes or is reused by a different listener. - const wakeIdx = knownWakeIdx ?? getAcceptWake(pid, fd); - if (wakeIdx >= 0) snapshot.set(`${port}:${fd}`, wakeIdx); - }; + /** Exact host-side finite-stdin state; exposes no backing buffer authority. */ + isStdinConsumed(pid: number): boolean { + return this.stdinFinite.has(pid) && !this.stdinBuffers.has(pid); + } - for (const [port, targets] of this.tcpListenerTargets) { - for (const target of targets) { - if (target.pid === pid) remember(port, target.fd, target.acceptWakeIdx); - } + // ── PTY management ── + + /** + * Create a PTY pair and wire fds 0/1/2 of `pid` to the slave side. + * Returns the PTY index, or throws on failure. + */ + setupPty(pid: number): number { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError("PTY setup"); + } + let result: number | null | undefined; + const deferred = this.#runOrDeferKernelEntry( + `PTY setup pid=${pid}`, + (entry) => { + result = this.#setupPtyWithinKernelEntry(pid, entry); + return undefined; + }, + ); + if (deferred || result === undefined) { + throw new KernelReentrantEntryError("PTY setup"); } - const prefix = `${pid}:`; - for (const [key, listener] of this.tcpListeners) { - if (!key.startsWith(prefix)) continue; - const fd = Number(key.slice(prefix.length)); - const target = this.tcpListenerTargets.get(listener.port) - ?.find(entry => entry.pid === pid && entry.fd === fd); - remember(listener.port, fd, target?.acceptWakeIdx); + if (result === null) { + throw new Error("Kernel missing kernel_pty_create export"); } - return snapshot; + if (result < 0) { + throw new Error(`kernel_pty_create failed: errno ${-result}`); + } + return result; } - /** Resolve one listener identity in another process after fork/spawn actions. */ - private resolveInheritedListenerFd( + #setupPtyWithinKernelEntry( pid: number, - preferredFd: number, - wakeIdx?: number, - ): { fd: number; acceptWakeIdx?: number } | null { - const getAcceptWake = this.kernelInstance!.exports - .kernel_get_fd_accept_wake_idx as - ((pid: number, fd: number) => number) | undefined; - if (!getAcceptWake) { - return { - fd: preferredFd, - ...(wakeIdx !== undefined ? { acceptWakeIdx: wakeIdx } : {}), - }; + entry: KernelWorkerEntryContext, + ): number | null { + const kernelPtyCreate = this.#kernelInstanceForEntry(entry).exports.kernel_pty_create as + ((pid: number) => number) | undefined; + if (!kernelPtyCreate) return null; + const ptyIdx = kernelPtyCreate(pid); + if (ptyIdx >= 0) { + this.ptyIndexByPid.set(pid, ptyIdx); + this.activePtyIndices.add(ptyIdx); } + return ptyIdx; + } - const liveWakeIdx = getAcceptWake(pid, preferredFd); - if (wakeIdx === undefined) { - return liveWakeIdx >= 0 - ? { fd: preferredFd, acceptWakeIdx: liveWakeIdx } - : null; - } - if (liveWakeIdx === wakeIdx) { - return { fd: preferredFd, acceptWakeIdx: wakeIdx }; - } + /** + * Write data to a PTY master (host → line discipline → slave). + * Wakes any process blocked on reading the slave side. + */ + ptyMasterWrite(ptyIdx: number, data: Uint8Array): void { + const exactData = intrinsicUint8ArrayView(data, "PTY input"); + const input = this.#kernelEntryGate.shouldDeferVoidIngress + ? new Uint8Array(exactData) + : exactData; + this.#runOrDeferKernelEntry( + "PTY input chunks", + (entry) => { + this.#writePtyMasterChunks(ptyIdx, input, entry); + return undefined; + }, + ); + // WHY: the output observer and readiness wake run only after the scoped + // read is complete. A fresh FIFO entry keeps them ordered after all input + // chunks without leaving any scoped authority live during the callback. + this.#queuePtyOutputDrain(ptyIdx, true); + } - const findListenerFd = this.kernelInstance!.exports - .kernel_find_listener_fd_by_accept_wake as - ((pid: number, wakeIdx: number) => number) | undefined; - let resolvedFd = findListenerFd?.(pid, wakeIdx) ?? -1; - if (!findListenerFd) { - // Compatibility with ABI 16 kernels predating the additive resolver. - for (let fd = 0; fd < 1024; fd++) { - if (getAcceptWake(pid, fd) === wakeIdx) { - resolvedFd = fd; - break; + #writePtyMasterChunks( + ptyIdx: number, + exactData: Uint8Array, + entry: KernelWorkerEntryContext, + ): void { + const kernelPtyMasterWrite = entry.instance.exports.kernel_pty_master_write as + ((ptyIdx: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; + if (!kernelPtyMasterWrite) return; + const scratch = this.#requireMainScratchRegion(); + scratch.withLease((lease) => { + let offset = 0; + while (offset < exactData.byteLength) { + const chunkLength = Math.min( + exactData.byteLength - offset, + scratch.capacity, + ); + lease.copyFrom(exactData, 0, offset, chunkLength); + const written = this.#invokeEntryScratchExport( + entry, + lease, + "kernel_pty_master_write", + [ + ptyIdx, + lease.exportPointer(0, chunkLength), + chunkLength, + ], + ); + if (!Number.isSafeInteger(written) || written > chunkLength) { + throw new KernelScratchError( + "kernel PTY write exceeded its staged input", + EIO, + ); } + if (written <= 0) break; + offset += written; + if (written < chunkLength) break; } - } - return resolvedFd >= 0 - ? { fd: resolvedFd, acceptWakeIdx: wakeIdx } - : null; + }); } /** - * Install host-only descriptor mirrors for a kernel child that already - * exists. This runs synchronously before async Worker launch so parent exec - * cannot close the final listener backend in the handoff window. + * Read all available data from a PTY master (slave output → host). + * Returns data or null if empty. */ - private inheritHostFdMirrors( - parentPid: number, - childPid: number, - includeEpoll: boolean = true, - ): void { - const getAcceptWake = this.kernelInstance!.exports - .kernel_get_fd_accept_wake_idx as - ((pid: number, fd: number) => number) | undefined; - for (const [, targets] of this.tcpListenerTargets) { - for (const parentTarget of targets.filter( - (target) => target.pid === parentPid, - )) { - const parentWakeIdx = - parentTarget.acceptWakeIdx ?? - (() => { - const wakeIdx = getAcceptWake?.(parentPid, parentTarget.fd) ?? -1; - return wakeIdx >= 0 ? wakeIdx : undefined; - })(); - const childTarget = this.resolveInheritedListenerFd( - childPid, - parentTarget.fd, - parentWakeIdx, - ); - if (!childTarget - || targets.some(target => - target.pid === childPid && target.fd === childTarget.fd)) continue; - targets.push({ pid: childPid, ...childTarget }); - } + ptyMasterRead(ptyIdx: number): Uint8Array | null { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError("PTY master read"); + } + let completed = false; + let result: Uint8Array | null = null; + const deferred = this.#runOrDeferKernelEntry( + `PTY master read index=${ptyIdx}`, + (entry) => { + result = this.#readPtyMasterWithinKernelEntry(ptyIdx, entry); + completed = true; + return undefined; + }, + ); + if (deferred || !completed) { + throw new KernelReentrantEntryError("PTY master read"); } + return result; + } - if (!includeEpoll) return; - - const fdIsOpen = this.kernelInstance!.exports.kernel_fd_is_open as - ((pid: number, fd: number) => number) | undefined; - for (const [key, interests] of Array.from(this.epollInterests.entries())) { - if (!key.startsWith(`${parentPid}:`)) continue; - const epfd = Number(key.slice(key.indexOf(":") + 1)); - if (fdIsOpen && fdIsOpen(childPid, epfd) !== 1) continue; - this.epollInterests.set( - `${childPid}:${epfd}`, - interests - .filter((entry) => !fdIsOpen || fdIsOpen(childPid, entry.fd) === 1) - .map((entry) => ({ ...entry })), + #readPtyMasterWithinKernelEntry( + ptyIdx: number, + entry: KernelWorkerEntryContext, + ): Uint8Array | null { + const kernelPtyMasterRead = this.#kernelInstanceForEntry(entry).exports.kernel_pty_master_read as + ((ptyIdx: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; + if (!kernelPtyMasterRead) return null; + const scratch = this.#requireMainScratchRegion(); + const request = Math.min(4096, scratch.capacity); + return scratch.withLease((lease) => { + const args = [ + ptyIdx, + lease.exportPointer(0, request), + request, + ] as const; + const n = this.#invokeEntryScratchExport( + entry, + lease, + "kernel_pty_master_read", + args, ); - } + if (n <= 0) return null; + if (!Number.isSafeInteger(n) || n > request) { + throw new KernelScratchError( + "kernel PTY read exceeded its requested scratch capacity", + EIO, + ); + } + return lease.copyOut(0, n); + }); } - /** Remove host-only child state after fork/spawn Worker launch fails. */ - private rollbackChildHostRegistration(childPid: number): void { - this.deactivateProcess(childPid); - for (const key of Array.from(this.epollInterests.keys())) { - if (key.startsWith(`${childPid}:`)) this.epollInterests.delete(key); - } + /** + * Resize a PTY and send SIGWINCH to the foreground process group. + */ + ptySetWinsize(ptyIdx: number, rows: number, cols: number): void { + this.#runOrDeferKernelEntry( + "PTY resize", + (entry) => { + this.#ptySetWinsizeWithinKernelEntry( + ptyIdx, + rows, + cols, + entry, + ); + return undefined; + }, + ); } - /** Reconcile host-only fd mirrors after the kernel closes CLOEXEC fds. */ - private pruneExecFdMirrors( - pid: number, - listenerWakeSnapshot: Map, + #ptySetWinsizeWithinKernelEntry( + ptyIdx: number, + rows: number, + cols: number, + kernelEntry: KernelWorkerEntryContext, ): void { - const fdIsOpen = this.kernelInstance!.exports.kernel_fd_is_open as - ((pid: number, fd: number) => number) | undefined; - if (!fdIsOpen) return; - const open = (fd: number) => fdIsOpen(pid, fd) === 1; - const prefix = `${pid}:`; - const getAcceptWake = this.kernelInstance!.exports - .kernel_get_fd_accept_wake_idx as - ((pid: number, fd: number) => number) | undefined; - const findListenerFd = this.kernelInstance!.exports - .kernel_find_listener_fd_by_accept_wake as - ((pid: number, wakeIdx: number) => number) | undefined; - const aliasByWake = new Map(); - const resolveListenerFd = (port: number, oldFd: number): number | null => { - const wakeIdx = listenerWakeSnapshot.get(`${port}:${oldFd}`); - if (wakeIdx === undefined || !getAcceptWake) - return open(oldFd) ? oldFd : null; - if (getAcceptWake(pid, oldFd) === wakeIdx) return oldFd; - if (aliasByWake.has(wakeIdx)) return aliasByWake.get(wakeIdx)!; - let candidate = findListenerFd?.(pid, wakeIdx) ?? -1; - // ABI 16 kernels built before the additive resolver export can still - // recover aliases within their historical default descriptor range. - if (!findListenerFd) { - for (let fd = 0; fd < 1024; fd++) { - if (getAcceptWake(pid, fd) === wakeIdx) { - candidate = fd; - break; - } - } - } - const alias = candidate >= 0 ? candidate : null; - aliasByWake.set(wakeIdx, alias); - return alias; - }; - - for (const [key, interests] of Array.from(this.epollInterests.entries())) { - if (!key.startsWith(prefix)) continue; - const epfd = Number(key.slice(prefix.length)); - if (!open(epfd)) { - this.epollInterests.delete(key); - } else { - // The current epoll model stores numeric fds rather than OFD identity. - // Dropping closed targets prevents later fd reuse from observing a - // stale registration; duplicate-fd retention remains a documented gap. - this.epollInterests.set(key, interests.filter(entry => open(entry.fd))); - } - } - - for (const [port, targets] of Array.from(this.tcpListenerTargets.entries())) { - const retained: Array<{ pid: number; fd: number; acceptWakeIdx?: number }> = []; - for (const target of targets) { - if (target.pid !== pid) { - retained.push(target); - continue; - } - const fd = resolveListenerFd(port, target.fd); - if (fd !== null && !retained.some(entry => entry.pid === pid && entry.fd === fd)) { - retained.push({ ...target, pid, fd }); - } - } - if (retained.length === 0) { - this.tcpListenerTargets.delete(port); - this.tcpListenerRRIndex.delete(port); - const virtualKey = this.tcpVirtualListenerKeys.get(port); - if (virtualKey) { - this.io.network?.closeTcpListener?.(virtualKey); - this.tcpVirtualListenerKeys.delete(port); - } - } else { - this.tcpListenerTargets.set(port, retained); - const oldIndex = this.tcpListenerRRIndex.get(port) ?? 0; - this.tcpListenerRRIndex.set(port, oldIndex % retained.length); - } - } + const kernelPtySetWinsize = kernelEntry.instance.exports + .kernel_pty_set_winsize as + ((ptyIdx: number, rows: number, cols: number) => number) | undefined; + if (!kernelPtySetWinsize) return; + kernelPtySetWinsize(ptyIdx, rows, cols); + this.scheduleWakeBlockedRetries(kernelEntry); - const removedByPort = new Map(); - for (const [key, listener] of Array.from(this.tcpListeners.entries())) { - if (!key.startsWith(prefix)) continue; - const fd = Number(key.slice(prefix.length)); - const replacementFd = resolveListenerFd(listener.port, fd); - if (replacementFd === fd) continue; - this.tcpListeners.delete(key); - if (replacementFd === null) { - removedByPort.set(listener.port, listener); - } else { - const replacementKey = `${pid}:${replacementFd}`; - if (!this.tcpListeners.has(replacementKey)) { - this.tcpListeners.set(replacementKey, { ...listener, pid }); - } - } - } - for (const [port, listener] of removedByPort) { - const targets = this.tcpListenerTargets.get(port); - if (!targets || targets.length === 0) { - listener.server.close(); - const virtualKey = this.tcpVirtualListenerKeys.get(port); - if (virtualKey) { - this.io.network?.closeTcpListener?.(virtualKey); - this.tcpVirtualListenerKeys.delete(port); - } - } else { - const replacement = targets[0]!; - const replacementKey = `${replacement.pid}:${replacement.fd}`; - if (!this.tcpListeners.has(replacementKey)) { - this.tcpListeners.set(replacementKey, { ...listener, pid: replacement.pid }); - } + // A process parked in a host-side setTimeout-backed nanosleep won't notice + // the SIGWINCH the kernel just raised — the timer just runs to completion. + // Speculatively dequeue a Handler signal for each blocked pid; if one was + // pending we complete the sleep with EINTR so the glue can dispatch it. + // Skipped pids (no signal queued) keep their original sleep deadline. + const EINTR = 4; + for (const [sleepChannel, entry] of Array.from(this.pendingSleeps.entries())) { + if (!this.isRegisteredChannel(entry.channel)) continue; + this.#dequeueSignalForDelivery(entry.channel, kernelEntry); + if (this.#finishSignalTermination(entry.channel, kernelEntry)) continue; + const view = new DataView(entry.channel.memory.buffer, entry.channel.channelOffset); + if (view.getUint32(CH_SIG_SIGNUM, true) > 0) { + this.#cancelRegisteredTimeout(entry.timer); + this.pendingSleeps.delete(sleepChannel); + this.completeChannel( + entry.channel, entry.syscallNr, entry.origArgs, + SYSCALL_ARGS[entry.syscallNr], -1, EINTR, + [], + undefined, + kernelEntry, + ); } } } - /** Whether a file mapping has a real writable regular-file backing. */ - private fdSupportsMmapWriteback(pid: number, fd: number): boolean { - const supports = this.kernelInstance!.exports - .kernel_fd_supports_mmap_writeback as - ((pid: number, fd: number) => number) | undefined; - // Older ABI-16 kernels predate capability classification. Preserve their - // existing msync behavior; exec itself is feature-gated on newer metadata - // exports, so it cannot hit the old device-preflush failure. - return supports ? supports(pid, fd) === 1 : true; - } - /** - * Flush mappings owned by the address space that exec is about to discard. - * Tracking and SysV attachments remain intact until the kernel commit - * succeeds, so a failed exec can continue using the old address space. + * Register a callback for PTY output data. */ - prepareAddressSpaceForExec(pid: number): number { - const registration = this.processes.get(pid); - const channel = registration?.channels[0]; - if (!channel) { - const hasShared = (this.sharedMappings.get(pid)?.size ?? 0) > 0; - const hasSysv = (this.shmMappings.get(pid)?.size ?? 0) > 0; - return hasShared || hasSysv ? -EIO : 0; - } - - try { - this.syncAnonymousSharedMappingsFromProcess(channel, { force: true }); - this.syncFileSharedMappingsFromProcess(channel, { force: true }); - const shared = this.sharedMappings.get(pid); - if (shared) { - for (const [addr, mapping] of shared) { - if (!mapping.writable) continue; - if (mapping.backingKind === "file" && mapping.backingKey) { - const backing = this.sharedMmapBackings.get(mapping.backingKey); - if (backing && !this.flushSharedMmapBackingRange( - backing, - mapping.fileOffset, - mapping.len, - )) return -EIO; - continue; - } - if (mapping.backingKey) continue; - if (!this.pwriteFromProcessMemory( - channel, - mapping.fd, - addr, - mapping.len, - mapping.fileOffset, - )) return -EIO; + onPtyOutput(ptyIdx: number, callback: (data: Uint8Array) => void): void { + this.ptyOutputCallbacks.set(ptyIdx, callback); + this.#queuePtyOutputDrain(ptyIdx); + } + + #queuePtyOutputDrain(ptyIdx: number, wakeSlaveReader = false): void { + this.#runOrDeferKernelEntry( + "PTY output drain", + (entry) => { + if (!this.ptyOutputCallbacks.has(ptyIdx)) return; + const data = this.#readPtyMasterWithinKernelEntry(ptyIdx, entry); + if (wakeSlaveReader) { + entry.deferProtocolEffect(() => { + this.scheduleWakeBlockedRetries(); + }); } - } - return this.syncSysvShmMappingsFromProcess(channel, { force: true }) ? 0 : -EIO; - } catch { - return -EIO; - } + if (data === null) return; + entry.deferObserverEffect(() => { + const callback = this.ptyOutputCallbacks.get(ptyIdx); + if (callback) callback(data); + }); + entry.deferProtocolEffect(() => { + this.#queuePtyOutputDrain(ptyIdx); + }); + }, + ); } /** - * Forget mappings and detach SysV segments after the irreversible kernel - * exec commit. A failure here is post-commit and must be treated as fatal by - * the caller; returning to the discarded image is no longer possible. + * Drain all active PTY outputs. Called after each syscall completion + * to flush any program output produced during the syscall. */ - finalizeAddressSpaceForExec(pid: number): number { - const shared = this.sharedMappings.get(pid); - if (shared) { - for (const mapping of shared.values()) this.releaseSharedMapping(mapping); - this.sharedMappings.delete(pid); - } - this.invalidateSharedMmapFdCacheForPid(pid); - - const sysv = this.shmMappings.get(pid); - if (!sysv) return 0; - const detach = this.kernelInstance!.exports.kernel_ipc_shmdt_for_process as - ((pid: number, shmid: number) => number) | undefined; - let result = 0; - try { - if (!detach) return -EIO; - for (const mapping of sysv.values()) { - if (detach(pid, mapping.segId) < 0) result = -EIO; + private drainAllPtyOutputs(entry?: KernelWorkerEntryContext): void { + if (this.activePtyIndices.size === 0) return; + for (const ptyIdx of this.activePtyIndices) { + if (!entry) { + this.#queuePtyOutputDrain(ptyIdx); + continue; + } + if (!this.ptyOutputCallbacks.has(ptyIdx)) continue; + for (;;) { + const data = this.#readPtyMasterWithinKernelEntry(ptyIdx, entry); + if (data === null) break; + // WHY: own every byte while this exact kernel entry remains live, but + // invoke the untrusted terminal observer only after scope revocation. + // Register these effects before channel publication so a guest cannot + // resume before all output caused by its syscall reaches the host. + const ownedData = data; + entry.deferObserverEffect(() => { + const callback = this.ptyOutputCallbacks.get(ptyIdx); + if (callback) callback(ownedData); + }); } - } catch { - result = -EIO; - } finally { - this.shmMappings.delete(pid); } - return result; } /** - * Remove old channel/registration state for a process about to exec. - * Does NOT remove from kernel process table (exec keeps the same pid). - * Preserves alarm()/ITIMER_REAL, but cancels timer_create() timers: POSIX - * keeps interval timers across exec and deletes per-process POSIX timers. - */ - prepareProcessForExec( - pid: number, - expectedMemory?: WebAssembly.Memory, - ): boolean { - const registration = this.processes.get(pid); - if ( - expectedMemory - && (!registration || registration.memory !== expectedMemory) - ) { - return false; + * Set the working directory for a process. + * Must be called after registerProcess and before the process starts. + */ + setCwd(pid: number, cwd: string): void { + if (!this.#initialized) throw new Error("Kernel not initialized"); + const encoded = new TextEncoder().encode(cwd); + // kernel_set_cwd applies PATH_MAX too, but that would be after the host + // copy. Reject first so an oversized pathname never reaches scratch. + if (encoded.byteLength >= POSIX_PATH_MAX_BYTES) { + throw new Error(`setCwd failed for pid ${pid}: cwd exceeds PATH_MAX`); } - if (registration) { - this.releaseProcessViews(pid, registration.memory); + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError("initial cwd setup"); } - (this.execHandoffPids ??= new Set()).add(pid); - for (const channel of registration?.channels ?? []) { - this.retireChannelListener(channel); + let result: number | null | undefined; + const deferred = this.#runOrDeferKernelEntry( + `initial cwd setup pid=${pid}`, + (entry) => { + result = this.#setCwdWithinKernelEntry(pid, encoded, entry); + return undefined; + }, + ); + if (deferred || result === undefined) { + throw new KernelReentrantEntryError("initial cwd setup"); } - for (const channel of this.activeChannels ?? []) { - if (channel.pid === pid) this.retireChannelListener(channel); + if (result === null) { + // WHY: initial cwd is part of the current host/kernel contract. A + // silent older-kernel no-op would report a different cwd than the + // process actually owns after the checked scratch transfer. + throw new Error("Kernel missing required kernel_set_cwd export"); } - if (registration) registration.channels = []; - // The old image's exact mailboxes can never publish after exec. Preserve - // the pid-level stop state: exec changes the image, not process state. - this.discardStoppedChannelStateForProcess(pid, false); + if (result < 0) { + throw new Error(`setCwd failed for pid ${pid}: errno ${-result}`); + } + } - // Remove channels from active list (stops listening on old memory) - this.activeChannels = this.activeChannels.filter((ch) => ch.pid !== pid); + #setCwdWithinKernelEntry( + pid: number, + encoded: Uint8Array, + entry: KernelWorkerEntryContext, + ): number | null { + const kernelSetCwd = this.#kernelInstanceForEntry(entry).exports.kernel_set_cwd as + ((pid: number, ptr: KernelPointer, len: number) => number) | undefined; + if (typeof kernelSetCwd !== "function") return null; + const result = this.#requireMainScratchRegion().withLease((scratch) => { + scratch.copyFrom(encoded); + return this.#invokeEntryScratchExport( + entry, + scratch, + "kernel_set_cwd", + [ + pid, + scratch.exportPointer(0, encoded.byteLength), + encoded.byteLength, + ], + ); + }); + return result; + } - // Clean up pending blocking retries (the old program's syscalls are dead) - this.cleanupPendingPollRetries(pid); - this.cleanupPendingSelectRetries(pid); - this.cleanupPendingSignalWaits(pid); - this.cleanupPendingPipeReaders(pid); - this.cleanupPendingPipeWriters(pid); - for (const [channel, entry] of this.pendingAdvisoryLockRetries ?? []) { - if (channel.pid !== pid) continue; - clearTimeout(entry.timer); - this.pendingAdvisoryLockRetries.delete(channel); + /** + * Set a freshly-created process's initial real/effective uid and gid. + * Must be called after registerProcess and before the process starts. + */ + setCredentials(pid: number, ids: { uid?: number; gid?: number }): void { + if (!this.#initialized) throw new Error("Kernel not initialized"); + const uid = ids.uid; + const gid = ids.gid; + if (uid == null && gid == null) return; + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError("initial credential setup"); } - // Deferred wait/sleep/futex completions retain the discarded Memory and - // would otherwise be able to run after the same pid is re-registered. - this.waitingForChild = (this.waitingForChild ?? []).filter( - (waiter) => waiter.parentPid !== pid, + const unchanged = 0xffffffff; + let result: number | null | undefined; + const deferred = this.#runOrDeferKernelEntry( + `initial credential setup pid=${pid}`, + (entry) => { + result = this.#setCredentialsWithinKernelEntry( + pid, + uid ?? unchanged, + gid ?? unchanged, + entry, + ); + return undefined; + }, ); - this.cancelPendingSleepsForProcess(pid); - for (const [channel, wait] of this.pendingFutexWaits) { - if (channel.pid !== pid) continue; - this.pendingFutexWaits.delete(channel); - // Release the waitAsync closure so it can observe that this channel is - // stale and drop its completion instead of retaining the old Memory. - try { - if (wait.retire) wait.retire(); - else - Atomics.notify( - new Int32Array(channel.memory.buffer), - wait.futexIndex, - 1, - ); - } catch { - // A detached/invalid discarded buffer needs no further cleanup. - } + if (deferred || result === undefined) { + throw new KernelReentrantEntryError("initial credential setup"); } - for (const channel of this.pendingCancels) { - if (channel.pid === pid) this.pendingCancels.delete(channel); + if (result === null) { + throw new Error("Kernel missing kernel_set_process_credentials export"); } + if (result < 0) { + throw new Error( + `setCredentials failed for pid ${pid}: errno ${-result}`, + ); + } + } - // Thread mailbox identity and fork/clear-TID metadata belong to the old - // image even though exec preserves the process id. - this.clearProcessThreadTransportState(pid); + #setCredentialsWithinKernelEntry( + pid: number, + uid: number, + gid: number, + entry: KernelWorkerEntryContext, + ): number | null { + const direct = this.#kernelInstanceForEntry(entry).exports + .kernel_set_process_credentials as + ((pid: number, uid: number, gid: number) => number) | undefined; + if (!direct) return null; + return direct(pid, uid, gid); + } - for (const [key, entry] of this.posixTimers) { - if (key.startsWith(`${pid}:`)) { - clearTimeout(entry.timeout); - if (entry.interval) clearInterval(entry.interval); - this.posixTimers.delete(key); - } - } - for (const [ch, timer] of this.socketTimeoutTimers) { - if (ch.pid === pid) { - clearTimeout(timer); - this.socketTimeoutTimers.delete(ch); - } - } + /** + * Snapshot the kernel's process table. Returns one ProcessSnapshot per + * live process. Used by Inspector → Procs (Kandelo UI) and any host that + * wants a `ps`-equivalent without spawning a user-mode reader. + * + * Reads from the kernel's scratch buffer. If the buffer overflows on a + * very large process table, returns an empty array — host can wrap with + * a retry on a larger scratch alloc. + * + * Returns an empty array if the kernel hasn't initialized yet or doesn't + * expose the export (older kernels). + */ + // ── Syscall trace (opt-in live ring buffer) ──────────────────────────── + // + // Off by default — zero cost when no subscriber. enableSyscallTrace() + // flips a flag; _handleSyscallInner pushes to this.syscallTraceRing + // when it's on. drainSyscallTrace() returns + clears the buffer; main + // thread polls every ~250ms via a worker→main request/response cycle. - // Keep a zero-channel registration until the replacement is installed. - // Network endpoints use process presence as their liveness signal; deleting - // the pid across awaited worker termination would make UDP drop datagrams - // and could permanently evict this owner from a shared TCP listener. - return true; + private syscallTraceEnabled = false; + private syscallTraceRing: SyscallTraceEvent[] = []; + /** Cap the ring so a forgotten subscriber can't blow memory. */ + private syscallTraceCap = 4096; + + enableSyscallTrace(): void { + this.syscallTraceEnabled = true; } - /** True while exec has committed but the replacement channel is not installed. */ - isExecHandoffActive(pid: number): boolean { - return this.execHandoffPids?.has(pid) ?? false; + disableSyscallTrace(): void { + this.syscallTraceEnabled = false; + this.syscallTraceRing.length = 0; } - /** Remove host transport metadata for every pthread in one process image. */ - private clearProcessThreadTransportState(pid: number): void { - const prefix = `${pid}:`; - for (const key of Array.from(this.channelTids.keys())) { - if (!key.startsWith(prefix)) continue; - const channelOffset = Number(key.slice(prefix.length)); - this.releaseThreadChannelOwnership(pid, channelOffset); - } - // Clean up any orphaned pre-invariant context left by a failed launch. - for (const key of this.threadForkContexts.keys()) { - if (key.startsWith(prefix)) this.threadForkContexts.delete(key); + drainSyscallTrace(): SyscallTraceEvent[] { + if (this.syscallTraceRing.length === 0) return []; + const out = this.syscallTraceRing; + this.syscallTraceRing = []; + return out; + } + + enumProcs(): ProcessSnapshot[] { + if (!this.#initialized) return []; + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError("process enumeration"); + } + let completed = false; + let owned: Uint8Array | null = null; + const deferred = this.#runOrDeferKernelEntry( + "process enumeration", + (entry) => { + owned = this.#enumProcsWithinKernelEntry(entry); + completed = true; + return undefined; + }, + ); + if (deferred || !completed) { + throw new KernelReentrantEntryError("process enumeration"); } - for (const key of this.threadCtidPtrs.keys()) { - if (key.startsWith(prefix)) this.threadCtidPtrs.delete(key); + if (!owned) return []; + const snapshots = parseProcSnapshots(owned); + for (const snapshot of snapshots) { + const registration = this.processes.get(snapshot.pid); + if (registration) { + snapshot.memoryBytes = registration.memory.buffer.byteLength; + } } + return snapshots; } - /** Release the exec guard only after the outer worker generation is installed. */ - finishProcessExecHandoff(pid: number): void { - this.execHandoffPids?.delete(pid); + #enumProcsWithinKernelEntry( + entry: KernelWorkerEntryContext, + ): Uint8Array | null { + const enumProcs = this.#kernelInstanceForEntry(entry).exports.kernel_enum_procs as + ((ptr: KernelPointer, len: number) => number) | undefined; + if (!enumProcs) return null; + const scratch = this.#requireMainScratchRegion(); + return scratch.withLease((lease) => { + const request = Math.min(SCRATCH_SIZE, scratch.capacity); + const n = this.#invokeEntryScratchExport( + entry, + lease, + "kernel_enum_procs", + [ + lease.exportPointer(0, request), + request, + ], + ); + if (n <= 0) return null; + if (!Number.isSafeInteger(n) || n > request) { + throw new KernelScratchError( + "kernel process enumeration exceeded scratch capacity", + EIO, + ); + } + return lease.copyOut(0, n); + }); } /** - * Remove a process from the kernel's PROCESS_TABLE. - * Called when a zombie is reaped by wait/waitpid. + * Read `/proc/[pid]/maps` for a foreign process. Returns the raw Linux- + * style text (one line per mapped region) or `null` if the pid doesn't + * exist. Empty string if the process has no mappings. */ - removeFromKernelProcessTable(pid: number): void { - const removeProcess = this.kernelInstance?.exports.kernel_remove_process as - ((pid: number) => number) | undefined; - if (!removeProcess) { - throw new Error("Kernel missing required kernel_remove_process export"); + readProcMaps(pid: number): string | null { + if (!this.#initialized) return null; + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError("process maps read"); + } + let completed = false; + let owned: Uint8Array | null = null; + const deferred = this.#runOrDeferKernelEntry( + `process maps read pid=${pid}`, + (entry) => { + owned = this.#readProcMapsWithinKernelEntry(pid, entry); + completed = true; + return undefined; + }, + ); + if (deferred || !completed) { + throw new KernelReentrantEntryError("process maps read"); } - const result = removeProcess(pid); - // ESRCH is idempotent success for removal: the requested postcondition is - // already true. Every other nonzero result leaves ownership uncertain. - if (result !== 0 && result !== -ESRCH) { - const errno = result < 0 ? -result : EIO; - throw new KernelTaskBindingError( - pid, - undefined, - errno, - `Kernel could not remove process ${pid}: errno ${errno}`, + if (owned === null) return null; + return new TextDecoder("utf-8", { fatal: false }).decode(owned); + } + + #readProcMapsWithinKernelEntry( + pid: number, + entry: KernelWorkerEntryContext, + ): Uint8Array | null { + const readMaps = this.#kernelInstanceForEntry(entry).exports.kernel_read_proc_maps as + ((pid: number, ptr: KernelPointer, len: number) => number) | undefined; + if (!readMaps) return null; + const scratch = this.#requireMainScratchRegion(); + return scratch.withLease((lease) => { + const request = Math.min(SCRATCH_SIZE, scratch.capacity); + const n = this.#invokeEntryScratchExport( + entry, + lease, + "kernel_read_proc_maps", + [ + pid, + lease.exportPointer(0, request), + request, + ], ); - } - // Forced removal releases process and final-OFD locks in Rust. Retry peer - // waiters from the emitted event before registration teardown can make - // this safety-net timer the primary wake path. - this.drainAndProcessWakeupEvents(); + if (n < 0) return null; // -ESRCH or similar + if (n === 0) return new Uint8Array(0); + if (!Number.isSafeInteger(n) || n > request) { + throw new KernelScratchError( + "kernel process maps output exceeded scratch capacity", + EIO, + ); + } + return lease.copyOut(0, n); + }); } /** - * Consume a host-side clone attachment proof and attach its one channel. - * - * PID, TID, process-memory generation, and pthread fork context all come - * from the capability's private WeakMap record. The caller chooses only the - * transport mailbox it allocated; it cannot name a task or copy/reuse an - * attachment object to create another authority. + * Unregister a process. Stops listening on its channels and removes + * it from the kernel's process table. */ - attachThreadChannel( - attachment: ThreadChannelAttachment, - channelOffset: number, - ): void { - const pending = pendingThreadChannelAttachments.get(attachment); - if (!pending || pending.owner !== this) { - throw new Error("Unknown, expired, or already consumed thread attachment"); + unregisterProcess( + pid: number, + expectedMemory?: WebAssembly.Memory, + ): boolean { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError(`process unregister pid=${pid}`); + } + let result = true; + const deferred = this.#runOrDeferKernelEntry( + `process unregister pid=${pid}`, + (entry) => { + result = this.#unregisterProcessWithinKernelEntry( + pid, + entry, + expectedMemory, + ); + return undefined; + }, + ); + if (deferred) { + throw new KernelReentrantEntryError(`process unregister pid=${pid}`); } - // Consume before validation. One clone event authorizes one attachment - // attempt; a failed attempt cannot be redirected to a different mailbox. - pendingThreadChannelAttachments.delete(attachment); + return result; + } - const { pid, tid, fnPtr, argPtr, memory } = pending; - if (this.execHandoffPids?.has(pid)) { - throw new Error(`Process ${pid} is replacing its image`); - } - if (!this.isProcessExecutionActive(pid)) { - throw new Error(`Process ${pid} is not running`); - } + #unregisterProcessWithinKernelEntry( + pid: number, + entry: KernelWorkerEntryContext, + expectedMemory?: WebAssembly.Memory, + ): boolean { const registration = this.processes.get(pid); - if (!registration) throw new Error(`Process ${pid} not registered`); - if (registration.memory !== memory) { - throw new Error(`Process ${pid} changed memory generation`); - } - if ( - !Number.isSafeInteger(tid) - || tid <= 0 - || tid > MAX_KERNEL_TASK_ID - || tid === pid - ) { - throw new Error( - `Thread channel for process ${pid} requires a positive, non-leader kernel TID`, - ); - } + if (!registration) return true; + if (expectedMemory && registration.memory !== expectedMemory) return false; - const channelKey = `${pid}:${channelOffset}`; - const channelOffsetAlreadyOwned = registration.channels.some( - (channel) => channel.channelOffset === channelOffset, - ) || this.activeChannels.some( - (channel) => channel.pid === pid && channel.channelOffset === channelOffset, - ) || this.channelTids.has(channelKey) - || this.threadForkContexts.has(channelKey); - if (channelOffsetAlreadyOwned) { - throw new Error( - `Channel offset ${channelOffset} for process ${pid} is already registered`, - ); - } + this.cancelAlarmTimerForProcess(pid); + this.retireAsyncChannelsForProcess(pid, entry); + this.discardStoppedChannelStateForProcess(pid); + this.waitingForChild = (this.waitingForChild ?? []).filter( + (waiter) => waiter.parentPid !== pid && waiter.channel.pid !== pid, + ); - // Validate the channel's task identity before mutating host registration. - // Rust allocated this TID during clone; the host only attaches transport. - this.validateKernelTid(pid, tid); + // Shared backing publication and SysV detach require the process memory and + // kernel Process to remain available, so do this before either is removed. + this.releaseAllSharedMemoryForProcess(pid, true, entry); + this.releaseProcessViews(pid, registration.memory); - for (const [existingChannelKey, existingTid] of this.channelTids) { - if (existingTid !== tid) continue; - throw new Error( - `Kernel TID ${tid} is already attached to channel ${existingChannelKey}`, - ); - } + // Remove channels from active list + this.activeChannels = this.activeChannels.filter((ch) => ch.pid !== pid); + this.clearProcessThreadTransportState(pid); - const channel: ChannelInfo = { - pid, - memory: registration.memory, - channelOffset, - i32View: new Int32Array(registration.memory.buffer, channelOffset), - consecutiveSyscalls: 0, - }; - this.observeProcessMemoryTarget(registration.memory, channel); - this.observeProcessMemoryTarget( - registration.memory, - channel.i32View, - ); - - try { - registration.channels.push(channel); - this.activeChannels.push(channel); - this.channelTids.set(channelKey, tid); - this.threadForkContexts.set(channelKey, { fnPtr, argPtr }); + // Clean up network listeners/endpoints for this process. + this.#cleanupProcessNetworkWithinKernelEntry(pid, entry); - // Lower the kernel's mmap ceiling only for legacy high-address thread - // control pages. Compact process memories reserve thread pages before the - // process's mmap base when the process is registered. - const setMaxAddr = this.kernelInstance!.exports.kernel_set_max_addr as - ((pid: number, maxAddr: KernelPointer) => number) | undefined; - if (setMaxAddr && !registration.explicitMaxAddr) { - const tlsPageAddr = channelOffset - 2 * WASM_PAGE_SIZE; - if (tlsPageAddr >= PROCESS_MMAP_BASE) { - setMaxAddr(pid, this.toKernelPtr(tlsPageAddr)); - } + // Clean up pending poll retries + this.cleanupPendingPollRetries(pid); + // Clean up pending select retries + this.cleanupPendingSelectRetries(pid); + this.cleanupPendingSignalWaits(pid); + // Clean up pending pipe readers/writers + this.cleanupPendingPipeReaders(pid); + this.cleanupPendingPipeWriters(pid); + this.cancelPendingSleepsForProcess(pid); + // Clean up socket timeout timers for this process + for (const [ch, timer] of this.socketTimeoutTimers) { + if (ch.pid === pid) { + this.#cancelRegisteredTimeout(timer); + this.socketTimeoutTimers.delete(ch); } + } - // In polling mode, the poller picks up new channels automatically. - if (!this.usePolling) { - this.listenOnChannel(channel); + // Clean up epoll interest mirrors for this process + for (const key of this.epollInterests.keys()) { + if (key.startsWith(`${pid}:`)) { + this.epollInterests.delete(key); } - pending.attachedChannelOffset = channelOffset; - } catch (error) { - registration.channels = registration.channels.filter( - (registered) => registered !== channel, - ); - this.activeChannels = this.activeChannels.filter( - (registered) => registered !== channel, - ); - this.releaseThreadChannelOwnership(pid, channelOffset); - throw error; } - } - /** - * Remove a channel from a process registration (e.g. when a thread exits). - */ - removeChannel(pid: number, channelOffset: number): void { - const registration = this.processes.get(pid); - for (const channel of registration?.channels ?? []) { - if (channel.channelOffset !== channelOffset) continue; - this.retireExactChannelAsyncState(channel); - } + // Remove from kernel process table + this.#removeFromKernelProcessTableWithinKernelEntry(pid, entry); - if (registration) { - registration.channels = registration.channels.filter( - (ch) => ch.channelOffset !== channelOffset, - ); + this.processes.delete(pid); + this.execHandoffPids?.delete(pid); + this.stdinFinite.delete(pid); + this.stdinBuffers.delete(pid); + + // Stop poller if no more processes + if (this.usePolling && this.processes.size === 0) { + this.stopPolling(); } - this.activeChannels = this.activeChannels.filter( - (ch) => !(ch.pid === pid && ch.channelOffset === channelOffset), - ); - this.releaseThreadChannelOwnership(pid, channelOffset); - } - /** Release one exact mailbox/TID ownership record. Idempotent for teardown. */ - private releaseThreadChannelOwnership(pid: number, channelOffset: number): void { - this.channelTids.delete(`${pid}:${channelOffset}`); - this.threadForkContexts.delete(`${pid}:${channelOffset}`); + // Clean up PTY state + const ptyIdx = this.ptyIndexByPid.get(pid); + if (ptyIdx !== undefined) { + this.ptyIndexByPid.delete(pid); + this.activePtyIndices.delete(ptyIdx); + this.ptyOutputCallbacks.delete(ptyIdx); + } + return true; } /** - * Retire every host-owned asynchronous continuation for one exact mailbox. - * No guest result is published: the channel generation is being removed. + * Deactivate a process's channels without removing it from the kernel + * process table. Used for zombie processes that need to remain queryable + * (getpgid, setpgid) until reaped by wait/waitpid. */ - private retireExactChannelAsyncState(channel: ChannelInfo): void { - this.retireChannelListener(channel); - this.cancelParkedFifoOpen(channel); - this.discardStoppedChannelState(channel); - this.resumePreparedSignals?.delete(channel); - this.pendingCancels?.delete(channel); - this.waitingForChild = (this.waitingForChild ?? []).filter( - (waiter) => waiter.channel !== channel, - ); - - const signalWaitKey = `${channel.pid}:${channel.channelOffset}`; - const signalWait = this.pendingSignalWaits?.get(signalWaitKey); - if (signalWait) clearTimeout(signalWait.timer); - this.pendingSignalWaits?.delete(signalWaitKey); - this.signalWaitDeadlines?.delete(signalWaitKey); - - const sleep = this.pendingSleeps?.get(channel); - if (sleep) clearTimeout(sleep.timer); - this.pendingSleeps?.delete(channel); - - const futex = this.pendingFutexWaits?.get(channel); - if (futex) { - this.pendingFutexWaits.delete(channel); - if (futex.retire) futex.retire(); - else { - try { - Atomics.notify( - new Int32Array(channel.memory.buffer), - futex.futexIndex, - ); - } catch { - // A detached discarded memory has no waiter left to release. - } - } + /** + * Remove a pid from the wasm kernel's ProcessTable entirely. Used by + * the worker-entry's crash path: when a worker dies via a wasm trap + * (signature mismatch, OOM, etc.) the kernel never saw a SYS_EXIT, so + * its ProcessTable still has the pid in state=Running. After this + * runs, kernel_enum_procs no longer reports it and a parent's + * waitpid() returns ECHILD — accurate for "the process really is gone." + * + * Don't call this for normal exits — the kernel marks those Exited + * (zombie) so the parent can still reap. + */ + removeProcessFromKernelTable(pid: number): void { + if (!this.#initialized) { + throw new Error("Kernel is not initialized for process removal"); } + this.removeFromKernelProcessTable(pid); + } - const poll = this.pendingPollRetries?.get(channel); - if (poll?.timer !== null && poll?.timer !== undefined) { - clearTimeout(poll.timer); - clearImmediate(poll.timer); - } - this.pendingPollRetries?.delete(channel); - const advisoryLock = this.pendingAdvisoryLockRetries?.get(channel); - if (advisoryLock) clearTimeout(advisoryLock.timer); - this.pendingAdvisoryLockRetries?.delete(channel); - const select = this.pendingSelectRetries?.get(channel); - if (select?.timer !== null && select?.timer !== undefined) { - clearTimeout(select.timer); - clearImmediate(select.timer); + private cancelPendingSleepsForProcess(pid: number): void { + for (const [channel, sleep] of this.pendingSleeps) { + if (channel.pid !== pid) continue; + this.#cancelRegisteredTimeout(sleep.timer); + this.pendingSleeps.delete(channel); } - this.pendingSelectRetries?.delete(channel); - channel.readinessDeadline = undefined; - channel.readinessFinalCheck = undefined; + } - this.removePendingPipeReader(channel); - this.removePendingPipeWriter(channel); - const socketTimer = this.socketTimeoutTimers?.get(channel); - if (socketTimer !== undefined) clearTimeout(socketTimer); - this.socketTimeoutTimers?.delete(channel); + private cancelAlarmTimerForProcess(pid: number): void { + const alarmTimer = this.alarmTimers.get(pid); + if (alarmTimer === undefined) return; + // WHY: browser timer handles may be zero, and deleting the exact identity + // before cancellation makes an already-queued old callback inert even if + // the numeric PID is registered again. + this.alarmTimers.delete(pid); + this.#cancelRegisteredTimeout(alarmTimer); } - /** Gather even partially detached channel objects before process teardown. */ - private retireAsyncChannelsForProcess(pid: number): void { - const channels = new Set(); - for (const channel of this.processes.get(pid)?.channels ?? []) { - channels.add(channel); - } - for (const channel of this.activeChannels ?? []) { - if (channel.pid === pid) channels.add(channel); - } - for (const waiter of this.waitingForChild ?? []) { - if (waiter.channel.pid === pid) channels.add(waiter.channel); - } - for (const channel of this.pendingSleeps?.keys() ?? []) { - if (channel.pid === pid) channels.add(channel); - } - for (const channel of this.pendingFutexWaits?.keys() ?? []) { - if (channel.pid === pid) channels.add(channel); - } - for (const channel of this.pendingPollRetries?.keys() ?? []) { - if (channel.pid === pid) channels.add(channel); - } - for (const channel of this.pendingAdvisoryLockRetries?.keys() ?? []) { - if (channel.pid === pid) channels.add(channel); - } - for (const channel of this.pendingSelectRetries?.keys() ?? []) { - if (channel.pid === pid) channels.add(channel); - } - for (const channel of this.pendingCancels ?? []) { - if (channel.pid === pid) channels.add(channel); - } - for (const readers of this.pendingPipeReaders?.values() ?? []) { - for (const reader of readers) { - if (reader.channel.pid === pid) channels.add(reader.channel); - } + deactivateProcess( + pid: number, + expectedMemory?: WebAssembly.Memory, + ): boolean { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError(`process deactivation pid=${pid}`); + } + let result = true; + const deferred = this.#runOrDeferKernelEntry( + `process deactivation pid=${pid}`, + (entry) => { + result = this.#deactivateProcessWithinKernelEntry( + pid, + entry, + expectedMemory, + ); + return undefined; + }, + ); + if (deferred) { + throw new KernelReentrantEntryError(`process deactivation pid=${pid}`); } - for (const writers of this.pendingPipeWriters?.values() ?? []) { - for (const writer of writers) { - if (writer.channel.pid === pid) channels.add(writer.channel); + if (!result) return false; + // Cancel any pending posix timers for this process + for (const [key, entry] of this.posixTimers) { + if (key.startsWith(`${pid}:`)) { + clearTimeout(entry.timeout); + if (entry.interval) clearInterval(entry.interval); + this.posixTimers.delete(key); } } - for (const channel of channels) this.retireExactChannelAsyncState(channel); + return true; } - /** Detach one exact listener generation without waking its guest Worker. */ - private retireChannelListener(channel: ChannelInfo): void { - (this.retiredChannelListeners ??= new Set()).add(channel); + #deactivateProcessWithinKernelEntry( + pid: number, + entry: KernelWorkerEntryContext, + expectedMemory?: WebAssembly.Memory, + ): boolean { + const registration = this.processes.get(pid); + if (!registration) return true; + if (expectedMemory && registration.memory !== expectedMemory) return false; + this.cancelAlarmTimerForProcess(pid); + this.retireAsyncChannelsForProcess(pid, entry); + this.discardStoppedChannelStateForProcess(pid); + this.waitingForChild = (this.waitingForChild ?? []).filter( + (waiter) => waiter.parentPid !== pid && waiter.channel.pid !== pid, + ); + this.releaseAllSharedMemoryForProcess(pid, true, entry); + this.releaseProcessViews(pid, registration.memory); + this.activeChannels = this.activeChannels.filter((ch) => ch.pid !== pid); + this.clearProcessThreadTransportState(pid); + this.processes.delete(pid); + this.execHandoffPids?.delete(pid); + this.stdinFinite.delete(pid); + this.stdinBuffers.delete(pid); + // Cancel pending sleeps for every thread in this process. + this.cancelPendingSleepsForProcess(pid); + // Clean up pending poll retries + this.cleanupPendingPollRetries(pid); + // Clean up pending select retries + this.cleanupPendingSelectRetries(pid); + this.cleanupPendingSignalWaits(pid); + // Clean up network listeners/endpoints for this process. + this.#cleanupProcessNetworkWithinKernelEntry(pid, entry); + // Drop the killed-but-not-yet-reaped marker with the retired host + // registration. Kernel task IDs are not reused, but retaining stale + // transport lifecycle state would still be misleading and wasteful. + this.hostReaped.delete(pid); + return true; } /** - * Release waitAsync listeners after the corresponding guest Worker stopped. - * - * `Atomics.waitAsync` has no cancellation API. Engines retain its unresolved - * Promise in a global waiter registry, and the Promise reaction closes over - * `channel`, pinning the process's entire Shared WebAssembly.Memory even - * after every ordinary host map has released it. Notify only after Worker - * termination: while a guest is live, it can also be waiting on CH_STATUS. - * - * The exact retired token remains until every pending listener callback has - * run and acknowledged the stale generation. Pool owners can await the - * returned Promise before reusing the backing or pthread slot. + * Drop persistent device/view aliases only for the exact current process + * generation. A stale exec continuation must never clear the replacement + * image merely because it inherited the same numeric pid. */ - settleRetiredChannelListeners( + private releaseProcessViews( pid: number, - expectedMemory?: WebAssembly.Memory, - expectedChannelOffset?: number, - ): Promise { - const retired = this.retiredChannelListeners; - if (!retired || retired.size === 0) return Promise.resolve(); - const settlements: Promise[] = []; - - for (const channel of Array.from(retired)) { - if (channel.pid !== pid) continue; - if (expectedMemory && channel.memory !== expectedMemory) continue; - if ( - expectedChannelOffset !== undefined - && channel.channelOffset !== expectedChannelOffset - ) { - continue; - } - - const state = this.retiredListenerSettlement(channel); - settlements.push(state.promise); - if ((this.pendingChannelListenerCounts?.get(channel) ?? 0) === 0) { - this.acknowledgeRetiredChannelListener(channel); - continue; - } - if (state.notified) continue; - state.notified = true; - try { - const view = new Int32Array( - channel.memory.buffer, - channel.channelOffset, - ); - // Omit the count so duplicate waitAsync registrations, if introduced - // by a future listener bug, cannot leave one Promise retaining Memory. - Atomics.notify(view, CH_STATUS / Int32Array.BYTES_PER_ELEMENT); - } catch { - // A detached or otherwise invalid retired buffer has no live waiter. - this.acknowledgeRetiredChannelListener(channel); - } - } - return Promise.all(settlements).then(() => {}); - } - - private retiredListenerSettlement(channel: ChannelInfo): { - promise: Promise; - resolve: () => void; - notified: boolean; - } { - const settlements = this.retiredChannelSettlements ??= new Map(); - const existing = settlements.get(channel); - if (existing) return existing; - let resolve!: () => void; - const promise = new Promise((done) => { - resolve = done; - }); - const state = { promise, resolve, notified: false }; - settlements.set(channel, state); - return state; - } - - private acknowledgeRetiredChannelListener(channel: ChannelInfo): void { - if ((this.pendingChannelListenerCounts?.get(channel) ?? 0) !== 0) return; - (this.retiredChannelListeners ??= new Set()).delete(channel); - const state = this.retiredChannelSettlements?.get(channel); - if (!state) return; - this.retiredChannelSettlements.delete(channel); - state.resolve(); - } - - private beginChannelListenerWait(channel: ChannelInfo): void { - const counts = this.pendingChannelListenerCounts ??= new Map(); - counts.set(channel, (counts.get(channel) ?? 0) + 1); - } - - private finishChannelListenerWait(channel: ChannelInfo): boolean { - const counts = this.pendingChannelListenerCounts ??= new Map(); - const remaining = (counts.get(channel) ?? 1) - 1; - if (remaining > 0) counts.set(channel, remaining); - else counts.delete(channel); - return remaining <= 0; - } - - private observeProcessMemoryTarget( - memory: WebAssembly.Memory, - target: object, - ): void { - try { - this.callbacks.onProcessMemoryTarget?.(memory, target); - } catch { - // Weak retirement telemetry must never change process correctness. - } + expectedMemory: WebAssembly.Memory, + ): boolean { + const registration = this.processes.get(pid); + if (!registration || registration.memory !== expectedMemory) return false; + this.#kernel.releaseProcessViews(pid); + return true; } /** - * Return whether this exact channel object belongs to the pid's current - * registration. Exec deliberately reuses the numeric pid (and commonly the - * same channel offset), so pid existence alone cannot distinguish a stale - * waitAsync/timer continuation from the replacement image's channel. + * Validate the exec caller and apply deferred posix_spawn file actions. + * This is the fallible kernel preflight; no image-owned state is discarded. */ - private isRegisteredChannel(channel: ChannelInfo): boolean { - const registration = this.processes.get(channel.pid); - return !(this.retiredChannelListeners?.has(channel) ?? false) - && registration !== undefined - && registration.channels.includes(channel); + kernelExecPrepare(pid: number, callerTid: number): number { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + // Exec preflight returns an authoritative synchronous result. Queuing it + // would let the discarded caller continue before Rust validates it. + throw new KernelReentrantEntryError("kernel exec preparation"); + } + let result = 0; + let completed = false; + let missingExportError: Error | undefined; + const deferred = this.#runOrDeferKernelEntry( + `kernel exec preparation pid=${pid}`, + (entry) => { + const prepare = this.#kernelInstanceForEntry(entry).exports + .kernel_exec_prepare as + ((pid: number, callerTid: number) => number) | undefined; + if (!prepare) { + missingExportError = new Error( + "Kernel missing required kernel_exec_prepare export", + ); + return undefined; + } + const previousPid = this.currentHandlePid; + this.currentHandlePid = pid; + try { + result = prepare(pid, callerTid); + completed = true; + } finally { + this.currentHandlePid = previousPid; + } + // Deferred spawn actions can close descriptors and publish a Rust + // advisory-lock wake even when a later action makes prepare fail. + this.#drainAndProcessWakeupEventsWithinKernelEntry(entry); + return undefined; + }, + ); + if (missingExportError !== undefined) throw missingExportError; + if (deferred || !completed) { + throw new KernelReentrantEntryError("kernel exec preparation"); + } + return result; } /** - * Async continuations may run while an exact channel remains registered for - * orderly worker teardown even though its kernel Process is already dead. + * Run kernel-side exec setup: close CLOEXEC fds, reset signal handlers. + * Returns 0 on success, negative errno on failure. + * Called by onExec callbacks after confirming the target program exists. */ - private isAsyncChannelProcessActive(channel: ChannelInfo): boolean { - if (!this.isRegisteredChannel(channel) || this.hostReaped?.has(channel.pid)) { - return false; + kernelExecSetup(pid: number, callerTid: number): number { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + // A successful setup commits exec in Rust. Its result cannot be queued + // behind the caller that needs to decide whether the old image survives. + throw new KernelReentrantEntryError("kernel exec setup"); } - try { - if (this.getProcessExitSignal(channel.pid) > 0) { - this.handleProcessTerminated(channel); - return false; - } - } catch { - // Older compatible kernels lack the additive exit-signal query; channel - // identity remains the best available liveness evidence there. + let result = 0; + let completed = false; + let missingExportError: Error | undefined; + const deferred = this.#runOrDeferKernelEntry( + `kernel exec setup pid=${pid}`, + (entry) => { + const threadAware = this.#kernelInstanceForEntry(entry).exports + .kernel_exec_setup_for_thread as + ((pid: number, callerTid: number) => number) | undefined; + if (!threadAware) { + missingExportError = new Error( + "Kernel missing required kernel_exec_setup_for_thread export", + ); + return undefined; + } + const previousPid = this.currentHandlePid; + this.currentHandlePid = pid; + let prunePlan: ExecFdMirrorPrunePlan | null = null; + try { + const listenerWakeSnapshot = + this.#snapshotExecTcpListenerWakeIdsWithinKernelEntry(pid, entry); + result = threadAware(pid, callerTid); + completed = true; + if (result === 0) { + prunePlan = this.#prepareExecFdMirrorPruneWithinKernelEntry( + pid, + listenerWakeSnapshot, + entry, + ); + } + } finally { + this.currentHandlePid = previousPid; + } + // CLOEXEC closure is an advisory-lock state transition. Consume the + // complete Rust event batch before publishing mirror removal. + this.#drainAndProcessWakeupEventsWithinKernelEntry(entry); + if (prunePlan !== null) { + entry.deferProtocolEffect(() => { + // WHY: server/network close callbacks are host-owned and may + // re-enter public roots. Publish the fully materialized maps only + // after the entry scope is revoked, then invoke callbacks after + // every replacement is visible. + this.#publishExecFdMirrorPrune(prunePlan); + return undefined; + }); + } + return undefined; + }, + ); + if (missingExportError !== undefined) throw missingExportError; + if (deferred || !completed) { + throw new KernelReentrantEntryError("kernel exec setup"); } - return true; + return result; } - /** Public liveness guard for async Node/browser worker-entry continuations. */ - isProcessExecutionActive(pid: number): boolean { - if (this.hostReaped?.has(pid)) return false; - try { - // kernel_get_process_exit_signal returns -1 while the Process is live - // (Running or Stopped), 0 for a normal zombie, a positive signal for - // signal death, and a negative errno when the pid no longer exists. - return this.getProcessExitSignal(pid) === -1; - } catch { - return true; - } - } + /** Snapshot stable accept-queue identities before CLOEXEC closes aliases. */ + #snapshotExecTcpListenerWakeIdsWithinKernelEntry( + pid: number, + entry: KernelWorkerEntryContext, + ): Map { + const getAcceptWake = this.#kernelInstanceForEntry(entry).exports + .kernel_get_fd_accept_wake_idx as + ((pid: number, fd: number) => number) | undefined; + const snapshot = new Map(); + if (!getAcceptWake) return snapshot; - /** - * Decide whether an asynchronously created fork/spawn child may receive a - * host Worker. A child killed before registration remains a real, waitable - * kernel zombie; finalize its host-only state without rolling it back. - */ - shouldLaunchPendingChild(pid: number): boolean { - if (this.isProcessExecutionActive(pid)) return true; - this.finalizePendingChildTermination(pid); - return false; + for (const [port, targets] of this.tcpListenerTargets) { + for (const target of targets) { + if (target.pid !== pid) continue; + // A target's stored queue token is its stable identity even after the + // numeric fd closes or is reused by a different listener. + const wakeIdx = + target.acceptWakeIdx ?? getAcceptWake(pid, target.fd); + if (wakeIdx >= 0) snapshot.set(`${port}:${target.fd}`, wakeIdx); + } + } + const prefix = `${pid}:`; + for (const [key, listener] of this.tcpListeners) { + if (!key.startsWith(prefix)) continue; + const fd = Number(key.slice(prefix.length)); + const target = this.tcpListenerTargets.get(listener.port) + ?.find(entry => entry.pid === pid && entry.fd === fd); + const wakeIdx = target?.acceptWakeIdx ?? getAcceptWake(pid, fd); + if (wakeIdx >= 0) snapshot.set(`${listener.port}:${fd}`, wakeIdx); + } + return snapshot; } - /** - * Start a prepared process/thread Worker only when the authoritative kernel - * Process is runnable. Fork/spawn/exec setup may register memory and return - * to its caller while stopped; the constructor itself is retained here so - * no guest instruction can execute before SIGCONT. `expectedMemory` is the - * generation token that prevents a deferred closure from attaching to a - * later exec image for the same persistent PID. - */ - startProcessWorkerWhenRunnable( + /** Resolve one listener identity in another process after fork/spawn actions. */ + private resolveInheritedListenerFd( pid: number, - expectedMemory: WebAssembly.Memory, - start: () => void, - cancel: () => void, - onStartError?: (error: unknown) => boolean, - ): ProcessWorkerStartDisposition { - const registration = this.processes.get(pid); - if (!registration || registration.memory !== expectedMemory) { - cancel(); - return "stale"; + preferredFd: number, + wakeIdx?: number, + entry?: KernelWorkerEntryContext, + ): { fd: number; acceptWakeIdx?: number } | null { + const getAcceptWake = this.#kernelInstanceForEntry(entry).exports + .kernel_get_fd_accept_wake_idx as + ((pid: number, fd: number) => number) | undefined; + if (!getAcceptWake) { + return { + fd: preferredFd, + ...(wakeIdx !== undefined ? { acceptWakeIdx: wakeIdx } : {}), + }; } - const getState = this.kernelInstance!.exports.kernel_get_process_state as ( - pid: number, - ) => number; - const state = getState(pid); - if (state === PROCESS_STATE_EXITED) { - cancel(); - return "dead"; + const liveWakeIdx = getAcceptWake(pid, preferredFd); + if (wakeIdx === undefined) { + return liveWakeIdx >= 0 + ? { fd: preferredFd, acceptWakeIdx: liveWakeIdx } + : null; } - if (state < 0) { - cancel(); - return "stale"; + if (liveWakeIdx === wakeIdx) { + return { fd: preferredFd, acceptWakeIdx: wakeIdx }; } - const deferStart = (): ProcessWorkerStartDisposition => { - this.stoppedPids.add(pid); - const entry: DeferredProcessWorkerStart = { - expectedMemory, - start, - cancel, - onStartError, - }; - let entries = this.deferredProcessWorkerStarts.get(pid); - if (!entries) { - entries = new Set(); - this.deferredProcessWorkerStarts.set(pid, entries); - } - entries.add(entry); - return "deferred"; - }; - if (state === PROCESS_STATE_STOPPED) { - return deferStart(); + const findListenerFd = this.#kernelInstanceForEntry(entry).exports + .kernel_find_listener_fd_by_accept_wake as + ((pid: number, wakeIdx: number) => number) | undefined; + let resolvedFd = findListenerFd?.(pid, wakeIdx) ?? -1; + if (!findListenerFd) { + // Compatibility with ABI 16 kernels predating the additive resolver. + for (let fd = 0; fd < 1024; fd++) { + if (getAcceptWake(pid, fd) === wakeIdx) { + resolvedFd = fd; + break; + } + } } - if (state !== PROCESS_STATE_RUNNING) { - cancel(); - return "stale"; - } - - // A CONTINUED wake may have arrived while async fork/spawn/exec had no - // registered channel to inspect. Queue this constructor first, then make - // the now-registered generation pass through the same all-thread signal - // barrier before any guest instruction can execute. - if (this.pendingResumePids?.has(pid) || this.stoppedPids?.has(pid)) { - deferStart(); - if (this.resumeStoppedProcess(pid)) return "started"; - // Direct resume preflight can apply a retained default stop and enqueue - // a STOPPED wake outside the ordinary wake-drain call stack (notably an - // exec handoff). Service it now so the parent does not remain asleep. - this.drainAndProcessWakeupEvents(); - const postResumeState = getState(pid); - if (postResumeState === PROCESS_STATE_EXITED) return "dead"; - if (postResumeState < 0) return "stale"; - return "deferred"; - } - - // The Process may have continued before its ordinary wake event was - // drained. Without an unregistered-resume barrier, the direct state query - // is authoritative for launch permission. - this.stoppedPids.delete(pid); - start(); - return "started"; + return resolvedFd >= 0 + ? { fd: resolvedFd, acceptWakeIdx: wakeIdx } + : null; } /** - * Listen for a syscall on a channel using Atomics.waitAsync. - * When the process sets status to PENDING, we handle the syscall. + * Install host-only descriptor mirrors for a kernel child that already + * exists. This runs synchronously before async Worker launch so parent exec + * cannot close the final listener backend in the handoff window. */ - private listenOnChannel(channel: ChannelInfo): void { - // A waitAsync continuation from the discarded exec image may run after a - // replacement registration with the same pid has been installed. - if (!this.isRegisteredChannel(channel)) return; - if (this.deferChannelWhileStopped(channel)) return; + private inheritHostFdMirrors( + parentPid: number, + childPid: number, + entry: KernelWorkerEntryContext, + includeEpoll: boolean = true, + ): void { + const getAcceptWake = this.#kernelInstanceForEntry(entry).exports + .kernel_get_fd_accept_wake_idx as + ((pid: number, fd: number) => number) | undefined; + for (const [, targets] of this.tcpListenerTargets) { + for (const parentTarget of targets.filter( + (target) => target.pid === parentPid, + )) { + const parentWakeIdx = + parentTarget.acceptWakeIdx ?? + (() => { + const wakeIdx = getAcceptWake?.(parentPid, parentTarget.fd) ?? -1; + return wakeIdx >= 0 ? wakeIdx : undefined; + })(); + const childTarget = this.resolveInheritedListenerFd( + childPid, + parentTarget.fd, + parentWakeIdx, + entry, + ); + if (!childTarget + || targets.some(target => + target.pid === childPid && target.fd === childTarget.fd)) continue; + targets.push({ pid: childPid, ...childTarget }); + } + } - // Re-create Int32Array view in case memory was grown - const i32View = new Int32Array( - channel.memory.buffer, - channel.channelOffset, + if (!includeEpoll) return; + + const fdIsOpen = this.#kernelInstanceForEntry(entry).exports.kernel_fd_is_open as + ((pid: number, fd: number) => number) | undefined; + for (const [key, interests] of Array.from(this.epollInterests.entries())) { + if (!key.startsWith(`${parentPid}:`)) continue; + const epfd = Number(key.slice(key.indexOf(":") + 1)); + if (fdIsOpen && fdIsOpen(childPid, epfd) !== 1) continue; + this.epollInterests.set( + `${childPid}:${epfd}`, + interests + .filter((entry) => !fdIsOpen || fdIsOpen(childPid, entry.fd) === 1) + .map((entry) => ({ ...entry })), + ); + } + } + + /** Remove host-only child state after fork/spawn Worker launch fails. */ + private rollbackChildHostRegistration(childPid: number): void { + this.#runOrDeferKernelEntry( + `child host rollback pid=${childPid}`, + (entry) => { + this.#rollbackChildHostRegistrationWithinKernelEntry( + childPid, + entry, + ); + return undefined; + }, ); - channel.i32View = i32View; + } - const statusIndex = CH_STATUS / Int32Array.BYTES_PER_ELEMENT; + #rollbackChildHostRegistrationWithinKernelEntry( + childPid: number, + entry: KernelWorkerEntryContext, + ): void { + this.#deactivateProcessWithinKernelEntry(childPid, entry); + for (const key of Array.from(this.epollInterests.keys())) { + if (key.startsWith(`${childPid}:`)) this.epollInterests.delete(key); + } + } - // Check if already pending (process might have sent before we started listening) - const currentStatus = Atomics.load(i32View, statusIndex); + /** + * Materialize the complete host-only fd mirror state after CLOEXEC. + * + * WHY: this helper may issue many identity queries, but it must not publish + * a partial replacement while Rust owns the entry. The returned plan holds + * no entry authority and is committed in one detached protocol effect. + */ + #prepareExecFdMirrorPruneWithinKernelEntry( + pid: number, + listenerWakeSnapshot: Map, + entry: KernelWorkerEntryContext, + ): ExecFdMirrorPrunePlan | null { + const fdIsOpen = this.#kernelInstanceForEntry(entry).exports + .kernel_fd_is_open as + ((pid: number, fd: number) => number) | undefined; + if (!fdIsOpen) return null; + const prefix = `${pid}:`; + const aliasByWake = new Map(); - if (currentStatus === CH_PENDING) { - // Handle the syscall. In browser mode (relistenBatchSize=1), defer via - // setImmediate so that Atomics.waitAsync microtask resolutions don't - // create tight chains that starve the event loop. In Node.js (default - // batchSize=64), handle immediately for throughput. - if (this.relistenBatchSize <= 1) { - setImmediate(() => { - if (this.isRegisteredChannel(channel)) { - this.handleSyscall(channel); - } - }); + const nextEpollInterests = new Map(this.epollInterests); + for (const [key, interests] of Array.from(this.epollInterests.entries())) { + if (!key.startsWith(prefix)) continue; + const epfd = Number(key.slice(prefix.length)); + if (fdIsOpen(pid, epfd) !== 1) { + nextEpollInterests.delete(key); } else { - this.handleSyscall(channel); + // The current epoll model stores numeric fds rather than OFD identity. + // Dropping closed targets prevents later fd reuse from observing a + // stale registration; duplicate-fd retention remains a documented gap. + nextEpollInterests.set( + key, + interests.filter( + (interest) => fdIsOpen(pid, interest.fd) === 1, + ), + ); } - return; } - // Wait for status to change from its current value. - // After a syscall completes, the process resets status COMPLETE→IDLE, - // then on its next syscall sets IDLE→PENDING. We need to handle all - // transitions, not just IDLE→PENDING. - const waitResult = Atomics.waitAsync(i32View, statusIndex, currentStatus); + const nextTcpListenerTargets = new Map(this.tcpListenerTargets); + const nextTcpListenerRRIndex = new Map(this.tcpListenerRRIndex); + const nextTcpVirtualListenerKeys = new Map(this.tcpVirtualListenerKeys); + const virtualListenerKeysToClose = new Set(); + for (const [port, targets] of Array.from(this.tcpListenerTargets.entries())) { + const retained: TcpListenerTarget[] = []; + for (const target of targets) { + if (target.pid !== pid) { + retained.push(target); + continue; + } + const fd = this.#resolveExecListenerFdWithinKernelEntry( + pid, + port, + target.fd, + listenerWakeSnapshot, + aliasByWake, + entry, + ); + if (fd !== null && !retained.some(entry => entry.pid === pid && entry.fd === fd)) { + retained.push({ ...target, pid, fd }); + } + } + if (retained.length === 0) { + nextTcpListenerTargets.delete(port); + nextTcpListenerRRIndex.delete(port); + const virtualKey = nextTcpVirtualListenerKeys.get(port); + if (virtualKey) { + virtualListenerKeysToClose.add(virtualKey); + nextTcpVirtualListenerKeys.delete(port); + } + } else { + nextTcpListenerTargets.set(port, retained); + const oldIndex = this.tcpListenerRRIndex.get(port) ?? 0; + nextTcpListenerRRIndex.set(port, oldIndex % retained.length); + } + } - if (waitResult.async) { - this.beginChannelListenerWait(channel); - waitResult.value.then( - () => { - const finalListener = this.finishChannelListenerWait(channel); - if (this.retiredChannelListeners?.has(channel)) { - if (finalListener) this.acknowledgeRetiredChannelListener(channel); - return; - } - // Check that this exact registration generation is still current. - if (!this.isRegisteredChannel(channel)) return; - // Status changed — re-enter to check new value - this.listenOnChannel(channel); - }, - () => { - const finalListener = this.finishChannelListenerWait(channel); - if ( - finalListener - && this.retiredChannelListeners?.has(channel) - ) { - this.acknowledgeRetiredChannelListener(channel); - } - }, + const nextTcpListeners = new Map(this.tcpListeners); + const removedByPort = new Map(); + for (const [key, listener] of Array.from(this.tcpListeners.entries())) { + if (!key.startsWith(prefix)) continue; + const fd = Number(key.slice(prefix.length)); + const replacementFd = this.#resolveExecListenerFdWithinKernelEntry( + pid, + listener.port, + fd, + listenerWakeSnapshot, + aliasByWake, + entry, ); - } else { - // Synchronous result — status already changed from what we expected - // Re-check on next tick to avoid stack overflow from tight loops - this.relistenChannel(channel); + if (replacementFd === fd) continue; + nextTcpListeners.delete(key); + if (replacementFd === null) { + removedByPort.set(listener.port, listener); + } else { + const replacementKey = `${pid}:${replacementFd}`; + if (!nextTcpListeners.has(replacementKey)) { + nextTcpListeners.set(replacementKey, { ...listener, pid }); + } + } + } + const listenerServersToClose = new Set(); + for (const [port, listener] of removedByPort) { + const targets = nextTcpListenerTargets.get(port); + if (!targets || targets.length === 0) { + listenerServersToClose.add(listener.server); + const virtualKey = nextTcpVirtualListenerKeys.get(port); + if (virtualKey) { + virtualListenerKeysToClose.add(virtualKey); + nextTcpVirtualListenerKeys.delete(port); + } + } else { + const replacement = targets[0]!; + const replacementKey = `${replacement.pid}:${replacement.fd}`; + if (!nextTcpListeners.has(replacementKey)) { + nextTcpListeners.set( + replacementKey, + { ...listener, pid: replacement.pid }, + ); + } + } } - } - /** - * Handle a pending syscall from a process channel. - * - * 1. Read syscall number + args from process Memory - * 2. For each pointer arg: copy data from process Memory to kernel scratch - * 3. Write adjusted args to kernel scratch channel header - * 4. Call kernel_handle_channel(scratchOffset, scratchCapacity, pid) - * 5. For each output pointer arg: copy data from kernel scratch to process Memory - * 6. Write return value + errno to process channel - * 7. Set status to COMPLETE and notify process - * 8. Re-listen for next syscall - */ - /** Get pointer width for a process (4=wasm32, 8=wasm64). */ - private getPtrWidth(pid: number): 4 | 8 { - return this.processes.get(pid)?.ptrWidth ?? 4; + return { + epollInterests: nextEpollInterests, + tcpListenerTargets: nextTcpListenerTargets, + tcpListenerRRIndex: nextTcpListenerRRIndex, + tcpListeners: nextTcpListeners, + tcpVirtualListenerKeys: nextTcpVirtualListenerKeys, + virtualListenerKeysToClose: [...virtualListenerKeysToClose], + listenerServersToClose: [...listenerServersToClose], + }; } - toKernelPtr(value: number | bigint): KernelPointer { - return this.kernel.toKernelPtr(value); + /** Resolve one pre-exec listener identity without retaining entry authority. */ + #resolveExecListenerFdWithinKernelEntry( + pid: number, + port: number, + oldFd: number, + listenerWakeSnapshot: Map, + aliasByWake: Map, + entry: KernelWorkerEntryContext, + ): number | null { + const fdIsOpen = this.#kernelInstanceForEntry(entry).exports + .kernel_fd_is_open as + ((pid: number, fd: number) => number) | undefined; + if (!fdIsOpen) return null; + const wakeIdx = listenerWakeSnapshot.get(`${port}:${oldFd}`); + const getAcceptWake = this.#kernelInstanceForEntry(entry).exports + .kernel_get_fd_accept_wake_idx as + ((pid: number, fd: number) => number) | undefined; + if (wakeIdx === undefined || !getAcceptWake) { + return fdIsOpen(pid, oldFd) === 1 ? oldFd : null; + } + if (getAcceptWake(pid, oldFd) === wakeIdx) return oldFd; + if (aliasByWake.has(wakeIdx)) return aliasByWake.get(wakeIdx)!; + const findListenerFd = this.#kernelInstanceForEntry(entry).exports + .kernel_find_listener_fd_by_accept_wake as + ((pid: number, wakeIdx: number) => number) | undefined; + let candidate = findListenerFd?.(pid, wakeIdx) ?? -1; + // ABI 16 kernels built before the additive resolver export can still + // recover aliases within their historical default descriptor range. + if (!findListenerFd) { + for (let fd = 0; fd < 1024; fd++) { + if (getAcceptWake(pid, fd) === wakeIdx) { + candidate = fd; + break; + } + } + } + const alias = candidate >= 0 ? candidate : null; + aliasByWake.set(wakeIdx, alias); + return alias; } - /** Debug: last N syscalls per pid for crash diagnosis */ - private syscallRing = new Map(); - dumpLastSyscalls(pid: number): string { - return (this.syscallRing.get(pid) ?? []).join("\n"); - } + /** Publish one materialized exec mirror replacement outside Wasm authority. */ + #publishExecFdMirrorPrune(plan: ExecFdMirrorPrunePlan): void { + this.epollInterests = plan.epollInterests; + this.tcpListenerTargets = plan.tcpListenerTargets; + this.tcpListenerRRIndex = plan.tcpListenerRRIndex; + this.tcpListeners = plan.tcpListeners; + this.tcpVirtualListenerKeys = plan.tcpVirtualListenerKeys; - /** Read a null-terminated C string from process memory */ - private readCString(memory: WebAssembly.Memory, ptr: number, maxLen = 256): string { - if (ptr === 0) return "(null)"; - const mem = new Uint8Array(memory.buffer); - let len = 0; - while (len < maxLen && ptr + len < mem.length && mem[ptr + len] !== 0) len++; - // TextDecoder.decode() rejects views over SharedArrayBuffer in Chrome; - // copy into a non-shared scratch first. - const copy = new Uint8Array(len); - copy.set(mem.subarray(ptr, ptr + len)); - return new TextDecoder().decode(copy); + // All maps are authoritative before a host callback can re-enter or + // observe this worker. The gate queues any such public ingress behind the + // complete protocol effect. + for (const virtualKey of plan.virtualListenerKeysToClose) { + this.io.network?.closeTcpListener?.(virtualKey); + } + for (const server of plan.listenerServersToClose) { + server.close(); + } } - private readBytesPreview(memory: WebAssembly.Memory, ptr: number, len: number, maxLen = 160): string { - if (ptr === 0 || len <= 0) return ""; - const mem = new Uint8Array(memory.buffer); - const capped = Math.max(0, Math.min(len, maxLen, mem.length - ptr)); - if (capped <= 0) return ""; - const copy = new Uint8Array(capped); - copy.set(mem.subarray(ptr, ptr + capped)); - return new TextDecoder("utf-8", { fatal: false }).decode(copy); - } - - private formatPollFds(memory: WebAssembly.Memory, ptr: number, nfds: number): string { - if (ptr === 0 || nfds <= 0) return ""; - const view = new DataView(memory.buffer); - const entries: string[] = []; - const capped = Math.min(nfds, 8); - for (let i = 0; i < capped; i++) { - const off = ptr + i * STRUCT_SIZE_WASM_POLL_FD; - if (off + STRUCT_SIZE_WASM_POLL_FD > view.byteLength) break; - const fd = view.getInt32(off + WASM_POLL_FD_FD_OFFSET, true); - const events = view.getInt16( - off + WASM_POLL_FD_EVENTS_OFFSET, - true, - ); - const revents = view.getInt16( - off + WASM_POLL_FD_REVENTS_OFFSET, - true, - ); - entries.push(`{fd:${fd},events:0x${(events & 0xffff).toString(16)},revents:0x${(revents & 0xffff).toString(16)}}`); - } - if (nfds > capped) entries.push("..."); - return entries.join(","); + /** Whether a file mapping has a real writable regular-file backing. */ + private fdSupportsMmapWriteback( + pid: number, + fd: number, + entry?: KernelWorkerEntryContext, + ): boolean { + const testHook = + this.#scratchBoundaryTestHooks?.fdSupportsMmapWriteback; + if (testHook) return testHook(pid, fd); + const supports = this.#kernelInstanceForEntry(entry).exports + .kernel_fd_supports_mmap_writeback as + ((pid: number, fd: number) => number) | undefined; + // Older ABI-16 kernels predate capability classification. Preserve their + // existing msync behavior; exec itself is feature-gated on newer metadata + // exports, so it cannot hit the old device-preflush failure. + return supports ? supports(pid, fd) === 1 : true; } - /** Format a syscall for logging, decoding path/string args from process memory */ - private formatSyscallEntry(channel: ChannelInfo, syscallNr: number, args: number[]): string { - const name = SYSCALL_NAMES[syscallNr] ?? `syscall_${syscallNr}`; - const pid = channel.pid; - const tid = this.channelTids.get(`${pid}:${channel.channelOffset}`); - const tidSuffix = tid !== undefined ? `:t${tid}` : ``; - - // Decode args based on syscall type - switch (syscallNr) { - case ABI_SYSCALLS.Open: // open(path, flags, mode) - return `[${pid}${tidSuffix}] open("${this.readCString(channel.memory, args[0])}", 0x${(args[1] >>> 0).toString(16)}, 0o${(args[2] >>> 0).toString(8)})`; - case ABI_SYSCALLS.Openat: // openat(dirfd, path, flags, mode) - return `[${pid}${tidSuffix}] openat(${args[0]}, "${this.readCString(channel.memory, args[1])}", 0x${(args[2] >>> 0).toString(16)}, 0o${(args[3] >>> 0).toString(8)})`; - case ABI_SYSCALLS.Stat: // stat(path, buf) - return `[${pid}${tidSuffix}] stat("${this.readCString(channel.memory, args[0])}")`; - case ABI_SYSCALLS.Lstat: // lstat(path, buf) - return `[${pid}${tidSuffix}] lstat("${this.readCString(channel.memory, args[0])}")`; - case ABI_SYSCALLS.Fstatat: // fstatat(dirfd, path, buf, flags) - return `[${pid}${tidSuffix}] fstatat(${args[0]}, "${this.readCString(channel.memory, args[1])}", 0x${(args[3] >>> 0).toString(16)})`; - case ABI_SYSCALLS.Access: // access(path, mode) - return `[${pid}${tidSuffix}] access("${this.readCString(channel.memory, args[0])}", ${args[1]})`; - case ABI_SYSCALLS.Faccessat: // faccessat(dirfd, path, mode, flags) - return `[${pid}${tidSuffix}] faccessat(${args[0]}, "${this.readCString(channel.memory, args[1])}", ${args[2]})`; - case ABI_SYSCALLS.Chdir: // chdir(path) - return `[${pid}${tidSuffix}] chdir("${this.readCString(channel.memory, args[0])}")`; - case ABI_SYSCALLS.Opendir: // opendir(path) - return `[${pid}${tidSuffix}] opendir("${this.readCString(channel.memory, args[0])}")`; - case ABI_SYSCALLS.Readlink: // readlink(path, buf, bufsiz) - return `[${pid}${tidSuffix}] readlink("${this.readCString(channel.memory, args[0])}", ${args[2]})`; - case ABI_SYSCALLS.Readlinkat: // readlinkat(dirfd, path, buf, bufsiz) - return `[${pid}${tidSuffix}] readlinkat(${args[0]}, "${this.readCString(channel.memory, args[1])}", ${args[3]})`; - case ABI_SYSCALLS.Realpath: // realpath(path, buf, bufsiz) - return `[${pid}${tidSuffix}] realpath("${this.readCString(channel.memory, args[0])}")`; - case ABI_SYSCALLS.Read: // read(fd, buf, count) - return `[${pid}${tidSuffix}] read(${args[0]}, ${args[2]})`; - case ABI_SYSCALLS.Write: // write(fd, buf, count) - return `[${pid}${tidSuffix}] write(${args[0]}, ${args[2]}, ${JSON.stringify(this.readBytesPreview(channel.memory, args[1], args[2]))})`; - case ABI_SYSCALLS.Close: // close(fd) - return `[${pid}${tidSuffix}] close(${args[0]})`; - case ABI_SYSCALLS.Fstat: // fstat(fd, buf) - return `[${pid}${tidSuffix}] fstat(${args[0]})`; - case ABI_SYSCALLS.Fcntl: // fcntl(fd, cmd, arg) - return `[${pid}${tidSuffix}] fcntl(${args[0]}, ${args[1]}, ${args[2]})`; - case ABI_SYSCALLS.Mmap: // mmap(addr, len, prot, flags, fd, offset) - return `[${pid}${tidSuffix}] mmap(0x${args[0].toString(16)}, ${args[1]}, ${args[2]}, 0x${(args[3] >>> 0).toString(16)}, ${args[4]}, ${args[5] >>> 0})`; - case ABI_SYSCALLS.Munmap: // munmap(addr, len) - return `[${pid}${tidSuffix}] munmap(0x${args[0].toString(16)}, ${args[1]})`; - case ABI_SYSCALLS.Brk: // brk(addr) - return `[${pid}${tidSuffix}] brk(0x${args[0].toString(16)})`; - case HOST_INTERCEPTED_SYSCALLS.SYS_EXECVE: // execve(path, argv, envp) - return `[${pid}${tidSuffix}] execve("${this.readCString(channel.memory, args[0])}")`; - case HOST_INTERCEPTED_SYSCALLS.SYS_FORK: return `[${pid}${tidSuffix}] fork()`; - case HOST_INTERCEPTED_SYSCALLS.SYS_VFORK: return `[${pid}${tidSuffix}] vfork()`; - case ABI_SYSCALLS.Clone: // clone(flags, stack, ptid, tls, ctid) - return `[${pid}${tidSuffix}] clone(0x${(args[0] >>> 0).toString(16)})`; - case ABI_SYSCALLS.Exit: return `[${pid}${tidSuffix}] exit(${args[0]})`; - case ABI_SYSCALLS.Poll: // poll(fds, nfds, timeout) - return `[${pid}${tidSuffix}] poll(${args[1]}, ${args[2]}, [${this.formatPollFds(channel.memory, args[0], args[1])}])`; - case ABI_SYSCALLS.Ioctl: // ioctl(fd, cmd, arg) - return `[${pid}${tidSuffix}] ioctl(${args[0]}, 0x${(args[1] >>> 0).toString(16)})`; - default: - return `[${pid}${tidSuffix}] ${name}(${args.filter((_, i) => i < 3).join(", ")})`; + /** + * Flush mappings owned by the address space that exec is about to discard. + * Tracking and SysV attachments remain intact until the kernel commit + * succeeds, so a failed exec can continue using the old address space. + */ + prepareAddressSpaceForExec(pid: number): number { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError("exec address-space preflight"); + } + let result: number | undefined; + const deferred = this.#runOrDeferKernelEntry( + `exec address-space preflight pid=${pid}`, + (entry) => { + result = this.#prepareAddressSpaceForExecWithinKernelEntry(pid, entry); + return undefined; + }, + ); + if (deferred || result === undefined) { + throw new KernelReentrantEntryError("exec address-space preflight"); } + return result; } - /** Format a syscall return value for logging */ - private formatSyscallReturn(syscallNr: number, retVal: number, errVal: number): string { - if (retVal < 0 || errVal !== 0) { - const errName = ERRNO_NAMES[errVal] ?? `errno=${errVal}`; - return ` = ${retVal} (${errName})`; - } - // Format return value based on syscall type - switch (syscallNr) { - case ABI_SYSCALLS.Mmap: // mmap - return ` = 0x${retVal.toString(16)}`; - case ABI_SYSCALLS.Brk: // brk - return ` = 0x${retVal.toString(16)}`; - default: - return ` = ${retVal}`; + #prepareAddressSpaceForExecWithinKernelEntry( + pid: number, + entry: KernelWorkerEntryContext, + ): number { + const registration = this.processes.get(pid); + const channel = registration?.channels[0]; + if (!channel) { + const hasShared = (this.sharedMappings.get(pid)?.size ?? 0) > 0; + const hasSysv = (this.shmMappings.get(pid)?.size ?? 0) > 0; + return hasShared || hasSysv ? -EIO : 0; } - } - private handleSyscall(channel: ChannelInfo): void { - if (!this.isRegisteredChannel(channel)) return; - if (this.handleExitedProcessChannel(channel)) return; - if (this.deferChannelWhileStopped(channel)) return; try { - if (PROFILING) { - const pv = new DataView(channel.memory.buffer, channel.channelOffset); - const nr = pv.getUint32(CH_SYSCALL, true); - const start = performance.now(); - this._handleSyscallInner(channel); - const elapsed = performance.now() - start; - let entry = this.profileData!.get(nr); - if (!entry) { - entry = { count: 0, totalTimeMs: 0, retries: 0 }; - this.profileData!.set(nr, entry); + this.syncAnonymousSharedMappingsFromProcess(channel, { force: true }); + this.syncFileSharedMappingsFromProcess( + channel, + { force: true }, + entry, + ); + const shared = this.sharedMappings.get(pid); + if (shared) { + for (const [addr, mapping] of shared) { + if (!mapping.writable) continue; + if (mapping.backingKind === "file" && mapping.backingKey) { + const backing = this.sharedMmapBackings.get(mapping.backingKey); + if (backing && !this.flushSharedMmapBackingRange( + backing, + mapping.fileOffset, + mapping.len, + entry, + )) return -EIO; + continue; + } + if (mapping.backingKey) continue; + if (!this.pwriteFromProcessMemory( + channel, + mapping.fd, + addr, + mapping.len, + mapping.fileOffset, + entry, + )) return -EIO; } - entry.count++; - entry.totalTimeMs += elapsed; - return; - } - this._handleSyscallInner(channel); - } catch (err) { - if (err instanceof KernelTaskBindingError) { - // A live channel that cannot bind to a kernel-owned task is a broken - // host/kernel identity invariant. Continuing with an arbitrary EIO - // would hide the protocol failure and let the guest keep executing. - this.terminateForKernelProtocolFailure( - channel, - `task binding error: ${err.message}`, - ); - return; } - console.error(`[handleSyscall] UNCAUGHT ERROR pid=${channel.pid}:`, err); - // Complete with EIO without re-entering the coherence path that just - // failed. Retrying a persistently unreadable backing here would throw a - // second time and leave the guest channel parked forever. - this.completeChannelRaw(channel, -EIO, EIO); - this.relistenChannel(channel); - } - } - - /** - * Stop one process after a host/kernel protocol invariant fails. - * - * Rust must accept the signal-death transition before host lifecycle state - * is published. Even if that transition or its shared-state teardown throws, - * the entry layer still has to terminate the guest Workers; rethrowing after - * that request keeps the kernel failure loud instead of fabricating a zombie. - */ - private terminateForKernelProtocolFailure( - channel: ChannelInfo, - reason: string, - ): void { - console.error(`[handleSyscall] FATAL ${reason}`); - channel.handling = true; - try { - this.notifyHostProcessCrashed(channel.pid, SIGSEGV); + return this.syncSysvShmMappingsFromProcess( + channel, + { force: true }, + entry, + ) + ? 0 + : -EIO; } catch (error) { - console.error( - `[handleSyscall] Failed to record process ${channel.pid} crash in kernel:`, - error, - ); - throw error; - } finally { - this.callbacks.onExit?.(channel.pid, 128 + SIGSEGV); + this.#rethrowKernelEntryFatal(error); + return -EIO; } } /** - * Settle the narrow mailbox handshake that can race process-wide teardown. - * - * `hostReaped` is set only after Rust has transitioned the authoritative - * Process to Exited (or accepted a host-crash transition). Node and browser - * deliberately keep that process's exact channel objects registered until - * their Workers are gone. During that interval, musl must finish its - * EXIT_GROUP -> EXIT unwind, while sibling threads may already have posted a - * syscall that must never enter the dead Process or be allowed to continue. - * - * This is a lifecycle gate, not an identity fallback: live processes still - * bind every selected channel through kernel_set_current_tid, so an unknown, - * stale, or cross-process TID remains a kernel-rejected protocol error. + * Forget mappings and detach SysV segments after the irreversible kernel + * exec commit. A failure here is post-commit and must be treated as fatal by + * the caller; returning to the discarded image is no longer possible. */ - private handleExitedProcessChannel(channel: ChannelInfo): boolean { - if (!this.hostReaped?.has(channel.pid)) return false; - - const processView = new DataView( - channel.memory.buffer, - channel.channelOffset, + finalizeAddressSpaceForExec(pid: number): number { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError("exec address-space finalization"); + } + let result: number | undefined; + const deferred = this.#runOrDeferKernelEntry( + `exec address-space finalization pid=${pid}`, + (entry) => { + result = this.#finalizeAddressSpaceForExecWithinKernelEntry(pid, entry); + return undefined; + }, ); - const syscallNr = processView.getUint32(CH_SYSCALL, true); - - if (syscallNr === SYS_EXIT || syscallNr === SYS_EXIT_GROUP) { - // Rust has already recorded the real exit status and released process - // state. Complete only the transport handshake; never dispatch this - // duplicate into the dead Process or repeat parent/onExit notification. - this.completeProcessExitHandshake(channel, syscallNr); - } else { - // The process is already dead, so no guest observes a syscall result. - // Leave this exact mailbox parked for entry-layer Worker termination. - // The handling flag prevents polling hosts from redispatching it. - channel.handling = true; + if (deferred || result === undefined) { + throw new KernelReentrantEntryError("exec address-space finalization"); } - return true; + return result; } - private completeProcessExitHandshake( - channel: ChannelInfo, - syscallNr: number, - ): void { - this.completeChannelRaw(channel, 0, 0); - if (syscallNr === SYS_EXIT_GROUP) { - // musl follows a returning EXIT_GROUP with the non-returning SYS_EXIT - // import. Re-arm once so worker-main can complete that request and trap - // out of Wasm. SYS_EXIT itself must not be re-armed. - this.relistenChannel(channel); + #finalizeAddressSpaceForExecWithinKernelEntry( + pid: number, + entry: KernelWorkerEntryContext, + ): number { + const shared = this.sharedMappings.get(pid); + if (shared) { + for (const mapping of shared.values()) { + this.releaseSharedMapping(mapping, entry); + } + this.sharedMappings.delete(pid); } - } - - private _handleSyscallInner(channel: ChannelInfo): void { - const processView = new DataView(channel.memory.buffer, channel.channelOffset); + this.invalidateSharedMmapFdCacheForPid(pid); - // Read syscall number and args from process channel - const syscallNr = processView.getUint32(CH_SYSCALL, true); - const origArgs: number[] = []; - const rawArgs: bigint[] = []; - const isPositionedVectorIo = - syscallNr === SYS_PREADV - || syscallNr === SYS_PWRITEV - || syscallNr === SYS_PREADV2 - || syscallNr === SYS_PWRITEV2; - for (let i = 0; i < CH_ARGS_COUNT; i++) { - const rawArg = processView.getBigInt64(CH_ARGS + i * CH_ARG_SIZE, true); - rawArgs.push(rawArg); - // Linux declares sched_getaffinity's length as unsigned int even for a - // 64-bit caller. Normalize it while it is still a bigint, before a - // memory64 value can lose precision in a JavaScript number. - if (syscallNr === SYS_SCHED_GETAFFINITY && i === 1) { - origArgs.push(Number(BigInt.asUintN(32, rawArg))); - } else if (isPositionedVectorIo && i === 3) { - // wasm64 musl passes the complete offset in this slot even though the - // kernel ABI consumes only its low word. Normalize before Number can - // discard low bits above 2^53. - origArgs.push(Number(BigInt.asUintN(32, rawArg))); - } else if (isPositionedVectorIo && i === 4) { - origArgs.push(Number(BigInt.asIntN(32, rawArg))); - } else { - origArgs.push(Number(rawArg)); - } - } + const sysv = this.shmMappings.get(pid); + if (!sysv) return 0; + const detach = this.#kernelInstanceForEntry(entry).exports.kernel_ipc_shmdt_for_process as + ((pid: number, shmid: number) => number) | undefined; + let result = 0; try { - this.checkHandwrittenProcessAddressArguments( - channel, - syscallNr, - origArgs, - rawArgs, - ); + if (!detach) return -EIO; + for (const mapping of sysv.values()) { + if (detach(pid, mapping.segId) < 0) result = -EIO; + } } catch (error) { - // Reject before syscall logging, shared-mapping synchronization, or - // kernel dispatch can observe an aliased low address. - this.rejectScratchTransfer(channel, error); - return; + this.#rethrowKernelEntryFatal(error); + result = -EIO; + } finally { + this.shmMappings.delete(pid); } + return result; + } - // Track last 30 syscalls per channel for crash diagnostics - const ringKey = channel.pid; - let ring = this.syscallRing.get(ringKey); - if (!ring) { ring = []; this.syscallRing.set(ringKey, ring); } - ring.push(` ${this.formatSyscallEntry(channel, syscallNr, origArgs)}`); - if (ring.length > 30) ring.shift(); - - // Opt-in live trace ring. enableSyscallTrace() flips the flag; the - // host polls via drainSyscallTrace(). Zero cost when off. - if (this.syscallTraceEnabled) { - if (this.syscallTraceRing.length >= this.syscallTraceCap) { - // Drop the oldest entry; a forgotten subscriber shouldn't blow memory. - this.syscallTraceRing.shift(); - } - this.syscallTraceRing.push({ - t: performance.now(), - pid: channel.pid, - nr: syscallNr, - args: [ - origArgs[0] ?? 0, origArgs[1] ?? 0, origArgs[2] ?? 0, - origArgs[3] ?? 0, origArgs[4] ?? 0, origArgs[5] ?? 0, - ], - decoded: this.formatSyscallEntry(channel, syscallNr, origArgs), - }); + /** + * Remove old channel/registration state for a process about to exec. + * Does NOT remove from kernel process table (exec keeps the same pid). + * Preserves alarm()/ITIMER_REAL, but cancels timer_create() timers: POSIX + * keeps interval timers across exec and deletes per-process POSIX timers. + */ + prepareProcessForExec( + pid: number, + expectedMemory?: WebAssembly.Memory, + ): boolean { + const registration = this.processes.get(pid); + if ( + expectedMemory + && (!registration || registration.memory !== expectedMemory) + ) { + return false; } - - // Syscall logging (enable globally via enableSyscallLog, or filter by - // process pointer width via syscallLogPtrWidth — useful when a single - // wasm64 process in a mixed-arch demo needs a focused trace). - const widthFilter = this.config.syscallLogPtrWidth; - const matchesWidthFilter = widthFilter !== undefined - && this.processes.get(channel.pid)?.ptrWidth === widthFilter; - const logging = !!this.config.enableSyscallLog || matchesWidthFilter; - let logEntry = ""; - if (logging) { - logEntry = this.formatSyscallEntry(channel, syscallNr, origArgs); + if (registration) { + this.releaseProcessViews(pid, registration.memory); } - - // Separate Wasm memories cannot observe MAP_SHARED/SysV writes directly. - // Treat every guest→kernel transition as a coherence boundary: merge only - // bytes changed since this process's snapshot, then import peer updates. - this.synchronizeSharedMemoryForBoundary(channel); - const mayFlushSharedBacking = (this.sharedMmapBackings?.size ?? 0) > 0; - const flushedSharedBacking = !mayFlushSharedBacking - || this.flushSharedMappingsBeforeFileSyscall(channel, syscallNr, origArgs); - if (mayFlushSharedBacking && this.hostReaped?.has(channel.pid)) return; - if (!flushedSharedBacking) { - this.completeChannel(channel, syscallNr, origArgs, undefined, -1, EIO); - return; + (this.execHandoffPids ??= new Set()).add(pid); + for (const channel of registration?.channels ?? []) { + this.retireChannelListener(channel); } - if ( - syscallNr === SYS_MPROTECT - && (origArgs[2] & PROT_WRITE) !== 0 - ) { - const protectionError = this.prepareFileSharedMappingsForWrite( - channel.pid, - origArgs[0], - alignWasmPageLength(origArgs[1]), - ); - if (protectionError !== 0) { - this.completeChannel( - channel, - syscallNr, - origArgs, - undefined, - -1, - protectionError, - ); - return; - } + for (const channel of this.activeChannels ?? []) { + if (channel.pid === pid) this.retireChannelListener(channel); } + if (registration) registration.channels = []; + // The old image's exact mailboxes can never publish after exec. Preserve + // the pid-level stop state: exec changes the image, not process state. + this.discardStoppedChannelStateForProcess(pid, false); - // --- Intercept fork/exec/clone/exit before calling kernel --- - // These syscalls need special async handling that can't go through - // direct kernel dispatch or the blocking host_exec import. + // Remove channels from active list (stops listening on old memory) + this.activeChannels = this.activeChannels.filter((ch) => ch.pid !== pid); - if (syscallNr === SYS_FORK || syscallNr === SYS_VFORK) { - if (logging) console.error(logEntry); - this.handleFork(channel, origArgs); - return; + // Rust's successful exec commit consumed every old-image target binding + // before closing CLOEXEC descriptors. The host must now forget only the + // corresponding detached request plans; calling release again would + // confuse an exact lifecycle handoff with an idempotent numeric lookup. + this.#forgetBlockingRetrySnapshotsAfterKernelLifecycle(pid); + this.cleanupPendingPollRetries(pid); + this.cleanupPendingSelectRetries(pid); + this.cleanupPendingSignalWaits(pid); + this.cleanupPendingPipeReaders(pid); + this.cleanupPendingPipeWriters(pid); + for (const [channel, entry] of this.pendingAdvisoryLockRetries ?? []) { + if (channel.pid !== pid) continue; + this.#cancelRegisteredTimeout(entry.timer); + this.pendingAdvisoryLockRetries.delete(channel); } - if (syscallNr === SYS_SPAWN) { - if (logging) console.error(logEntry); - this.handleSpawn(channel, origArgs); - return; + // Deferred wait/sleep/futex completions retain the discarded Memory and + // would otherwise be able to run after the same pid is re-registered. + this.waitingForChild = (this.waitingForChild ?? []).filter( + (waiter) => waiter.parentPid !== pid, + ); + this.cancelPendingSleepsForProcess(pid); + for (const [channel, wait] of this.pendingFutexWaits) { + if (channel.pid !== pid) continue; + this.pendingFutexWaits.delete(channel); + // Release the waitAsync closure so it can observe that this channel is + // stale and drop its completion instead of retaining the old Memory. + try { + if (wait.retire) wait.retire(); + else + Atomics.notify( + new Int32Array(channel.memory.buffer), + wait.futexIndex, + 1, + ); + } catch { + // A detached/invalid discarded buffer needs no further cleanup. + } } - - if (syscallNr === SYS_EXECVE) { - if (logging) console.error(logEntry); - this.handleExec(channel, origArgs); - return; + for (const channel of this.pendingCancels) { + if (channel.pid === pid) this.pendingCancels.delete(channel); } - - if (syscallNr === SYS_EXECVEAT) { - if (logging) console.error(logEntry); - this.handleExecveat(channel, origArgs); - return; + for (const channel of this.activeChannelRequests.keys()) { + if (channel.pid === pid) this.activeChannelRequests.delete(channel); } - if (syscallNr === SYS_CLONE) { - if (logging) console.error(logEntry); - this.handleClone(channel, origArgs); - return; - } + // Thread mailbox identity and fork/clear-TID metadata belong to the old + // image even though exec preserves the process id. + this.clearProcessThreadTransportState(pid); - if (syscallNr === SYS_EXIT || syscallNr === SYS_EXIT_GROUP) { - if (logging) console.error(logEntry); - this.handleExit(channel, syscallNr, origArgs); - return; + for (const [key, entry] of this.posixTimers) { + if (key.startsWith(`${pid}:`)) { + clearTimeout(entry.timeout); + if (entry.interval) clearInterval(entry.interval); + this.posixTimers.delete(key); + } } - - if (syscallNr === SYS_WAIT4) { - if (logging) console.error(logEntry); - this.handleWaitpid(channel, origArgs); - return; + for (const [ch, timer] of this.socketTimeoutTimers) { + if (ch.pid === pid) { + clearTimeout(timer); + this.socketTimeoutTimers.delete(ch); + } } - if (syscallNr === SYS_WAITID) { - if (logging) console.error(logEntry); - this.handleWaitid(channel, origArgs); - return; - } + // Keep a zero-channel registration until the replacement is installed. + // Network endpoints use process presence as their liveness signal; deleting + // the pid across awaited worker termination would make UDP drop datagrams + // and could permanently evict this owner from a shared TCP listener. + return true; + } - // --- Futex: must operate on process memory, not kernel memory --- - // The kernel's host_futex_wake/wait imports use kernel memory, but futex - // addresses are in process memory. Intercept here and handle directly. - if (syscallNr === SYS_FUTEX) { - if (logging) { - // Futex args: (uaddr, op, val, timeout, uaddr2, val3). Decode the op - // to make hung-thread investigations readable. - const FUTEX_OPS: Record = { - 0: "WAIT", 1: "WAKE", 2: "FD", 3: "REQUEUE", 4: "CMP_REQUEUE", - 5: "WAKE_OP", 6: "LOCK_PI", 7: "UNLOCK_PI", 8: "TRYLOCK_PI", - 9: "WAIT_BITSET", 10: "WAKE_BITSET", 11: "WAIT_REQUEUE_PI", - 12: "CMP_REQUEUE_PI", - }; - const FUTEX_PRIVATE_FLAG = 128; - const FUTEX_CLOCK_REALTIME = 256; - const op = origArgs[1]; - const cmd = op & ~(FUTEX_PRIVATE_FLAG | FUTEX_CLOCK_REALTIME); - const opName = FUTEX_OPS[cmd] ?? `op${cmd}`; - const flags = (op & FUTEX_PRIVATE_FLAG ? "|PRIVATE" : "") - + (op & FUTEX_CLOCK_REALTIME ? "|REALTIME" : ""); - const tid = this.channelTids.get(`${channel.pid}:${channel.channelOffset}`); - const tidSuffix = tid !== undefined ? `:t${tid}` : ``; - console.error(`[${channel.pid}${tidSuffix}] futex(0x${origArgs[0].toString(16)}, ${opName}${flags}, val=${origArgs[2]})`); - } - this.handleFutex(channel, origArgs, rawArgs); - return; - } + /** True while exec has committed but the replacement channel is not installed. */ + isExecHandoffActive(pid: number): boolean { + return this.execHandoffPids?.has(pid) ?? false; + } - // --- pthread_cancel wake-up: handled entirely on host side because - // the state we must perturb (futex waitAsync, pipe reader registration, - // poll/select retry timers) lives in TS, not in the kernel wasm. --- - if (syscallNr === SYS_THREAD_CANCEL) { - if (logging) console.error(logEntry); - this.handleThreadCancel(channel, origArgs); - return; + /** Remove host transport metadata for every pthread in one process image. */ + private clearProcessThreadTransportState(pid: number): void { + const prefix = `${pid}:`; + for (const key of Array.from(this.channelTids.keys())) { + if (!key.startsWith(prefix)) continue; + const channelOffset = Number(key.slice(prefix.length)); + this.releaseThreadChannelOwnership(pid, channelOffset); + } + // Clean up any orphaned pre-invariant context left by a failed launch. + for (const key of this.threadForkContexts.keys()) { + if (key.startsWith(prefix)) this.threadForkContexts.delete(key); + } + for (const key of this.threadCtidPtrs.keys()) { + if (key.startsWith(prefix)) this.threadCtidPtrs.delete(key); } + } - // --- Scatter/gather I/O (writev/readv/pwritev/preadv) --- - // These have nested pointers (iov array → base buffers) that can't be - // handled by the simple ArgDesc system. - if ( - syscallNr === SYS_WRITEV - || syscallNr === SYS_PWRITEV - || syscallNr === SYS_PWRITEV2 - ) { - if (logging) console.error(logEntry); - this.handleWritev(channel, syscallNr, origArgs); - return; - } - - if ( - syscallNr === SYS_READV - || syscallNr === SYS_PREADV - || syscallNr === SYS_PREADV2 - ) { - if (logging) console.error(logEntry); - this.handleReadv(channel, syscallNr, origArgs); - return; - } + /** Release the exec guard only after the outer worker generation is installed. */ + finishProcessExecHandoff(pid: number): void { + this.execHandoffPids?.delete(pid); + } - // --- getgroups: the return value is an entry count, not a byte count --- - // A simple output descriptor cannot express that getgroups(0, list) must - // not touch list while every positive-size call exposes exactly one - // four-byte slot in Kandelo's current single-supplementary-group model. - if (syscallNr === SYS_GETGROUPS) { - this.handleGetgroups(channel, origArgs, rawArgs); - return; - } + /** + * Remove a process from the kernel's PROCESS_TABLE. + * Called when a zombie is reaped by wait/waitpid. + */ + removeFromKernelProcessTable(pid: number): void { + this.#runOrDeferKernelEntry( + `kernel process removal pid=${pid}`, + (entry) => { + this.#removeFromKernelProcessTableWithinKernelEntry( + pid, + entry, + ); + return undefined; + }, + ); + } - // --- Large write/pwrite/read/pread: chunk through scratch buffer --- - // When the data exceeds CH_DATA_SIZE, the ArgDesc path returns a short - // read/write. Programs like InnoDB that write 1MB+ chunks may exhaust - // their retry budget. Handle large I/O by looping on the host side. - if ((syscallNr === SYS_WRITE || syscallNr === SYS_PWRITE) && origArgs[2] > CH_DATA_SIZE) { - this.handleLargeWrite(channel, syscallNr, origArgs, rawArgs); - return; + #removeFromKernelProcessTableWithinKernelEntry( + pid: number, + entry: KernelWorkerEntryContext, + ): void { + const removeProcess = this.#kernelInstanceForEntry(entry).exports.kernel_remove_process as + ((pid: number) => number) | undefined; + if (!removeProcess) { + throw new Error("Kernel missing required kernel_remove_process export"); } - if ((syscallNr === SYS_READ || syscallNr === SYS_PREAD) && origArgs[2] > CH_DATA_SIZE) { - this.handleLargeRead(channel, syscallNr, origArgs, rawArgs); - return; + const result = removeProcess(pid); + // ESRCH is idempotent success for removal: the requested postcondition is + // already true. Every other nonzero result leaves ownership uncertain. + if (result !== 0 && result !== -ESRCH) { + const errno = result < 0 ? -result : EIO; + throw new KernelTaskBindingError( + pid, + undefined, + errno, + `Kernel could not remove process ${pid}: errno ${errno}`, + ); } + // Forced removal releases process and final-OFD locks in Rust. Retry peer + // waiters from the emitted event before registration teardown can make + // this safety-net timer the primary wake path. + this.#drainAndProcessWakeupEventsWithinKernelEntry(entry); + } - // --- sendmsg/recvmsg: decompose msghdr from process memory --- - if (syscallNr === SYS_SENDMSG) { - this.handleSendmsg(channel, origArgs); - return; - } - if (syscallNr === SYS_RECVMSG) { - this.handleRecvmsg(channel, origArgs); - return; + /** + * Consume a host-side clone attachment proof and attach its one channel. + * + * PID, TID, process-memory generation, and pthread fork context all come + * from the capability's private WeakMap record. The caller chooses only the + * transport mailbox it allocated; it cannot name a task or copy/reuse an + * attachment object to create another authority. + */ + attachThreadChannel( + attachment: ThreadChannelAttachment, + channelOffset: number, + ): void { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError("thread channel attachment"); + } + const deferred = this.#runOrDeferKernelEntry( + "thread channel attachment", + (entry) => { + this.#attachThreadChannelWithinKernelEntry( + attachment, + channelOffset, + entry, + ); + return undefined; + }, + ); + if (deferred) { + throw new KernelReentrantEntryError("thread channel attachment"); } + } - // --- ioctl: intercept network interface ioctls --- - // These require host-side handling because: - // SIOCGIFCONF: struct ifconf contains a pointer to a process-memory buffer - // SIOCGIFHWADDR: returns the virtual MAC address for this kernel instance - if (syscallNr === SYS_IOCTL) { - const request = origArgs[1] >>> 0; - if (request === SIOCGIFCONF) { - this.handleIoctlIfconf(channel, origArgs); - return; - } - if (request === SIOCGIFNAME) { - this.handleIoctlIfname(channel, origArgs); - return; - } - if (request === SIOCGIFHWADDR) { - this.handleIoctlIfhwaddr(channel, origArgs); - return; - } - if (request === SIOCGIFADDR) { - this.handleIoctlIfaddr(channel, origArgs); - return; - } - if (request === SIOCGIFINDEX) { - this.handleIoctlIfindex(channel, origArgs); - return; - } + #attachThreadChannelWithinKernelEntry( + attachment: ThreadChannelAttachment, + channelOffset: number, + entry: KernelWorkerEntryContext, + ): void { + const pending = pendingThreadChannelAttachments.get(attachment); + if (!pending || pending.owner !== this) { + throw new Error("Unknown, expired, or already consumed thread attachment"); } + // Consume before validation. One clone event authorizes one attachment + // attempt; a failed attempt cannot be redirected to a different mailbox. + pendingThreadChannelAttachments.delete(attachment); - // --- fcntl with struct flock pointer --- - // When cmd is a lock operation, arg3 points to the generated flock wire. - // Handle as inout so the kernel can read/write the flock struct. - if (syscallNr === SYS_FCNTL) { - const cmd = origArgs[1]; - if (cmd === F_GETLK || cmd === F_SETLK || cmd === F_SETLKW || - cmd === F_GETLK64 || cmd === F_SETLK64 || cmd === F_SETLKW64 || - cmd === F_OFD_GETLK || cmd === F_OFD_SETLK || cmd === F_OFD_SETLKW) { - this.handleFcntlLock(channel, origArgs); - return; - } + const { pid, tid, fnPtr, argPtr, memory } = pending; + if (this.execHandoffPids?.has(pid)) { + throw new Error(`Process ${pid} is replacing its image`); } - - // --- epoll: intercept all epoll syscalls on host side --- - // kernel_handle_channel crashes in Chrome (V8 shared-memory Wasm bug) for - // epoll_pwait. Handle epoll_create1/ctl on the kernel but mirror the - // interest list, and convert epoll_pwait to poll entirely on the host. - if (syscallNr === SYS_EPOLL_CREATE1 || syscallNr === SYS_EPOLL_CREATE) { - this.handleEpollCreate(channel, syscallNr, origArgs); - return; + if (!this.#isProcessExecutionActiveWithinKernelEntry(pid, entry)) { + throw new Error(`Process ${pid} is not running`); } - if (syscallNr === SYS_EPOLL_CTL) { - this.handleEpollCtl(channel, origArgs, rawArgs); - return; + const registration = this.processes.get(pid); + if (!registration) throw new Error(`Process ${pid} not registered`); + if (registration.memory !== memory) { + throw new Error(`Process ${pid} changed memory generation`); } - if (syscallNr === SYS_EPOLL_PWAIT || syscallNr === SYS_EPOLL_WAIT) { - this.handleEpollPwait(channel, syscallNr, origArgs, rawArgs); - return; + if ( + !Number.isSafeInteger(tid) + || tid <= 0 + || tid > MAX_KERNEL_TASK_ID + || tid === pid + ) { + throw new Error( + `Thread channel for process ${pid} requires a positive, non-leader kernel TID`, + ); } - // --- SysV IPC: shmat/shmdt need host-side process memory management --- - if (syscallNr === SYS_SHMAT) { - this.handleIpcShmat(channel, origArgs, rawArgs); - return; - } - if (syscallNr === SYS_SHMDT) { - this.handleIpcShmdt(channel, origArgs, rawArgs); - return; - } - // --- SysV messages: msgbuf starts with native `long`, which differs - // between wasm32 and wasm64. Translate it to the fixed kernel wire header - // while the caller width is still known. --- - if (syscallNr === SYS_MSGSND || syscallNr === SYS_MSGRCV) { - this.handleSysvMessage(channel, syscallNr, origArgs, rawArgs); - return; - } - // --- SysV IPC: control structures follow the caller's wasm32/wasm64 - // data model and their pointer direction depends on cmd. --- - if (syscallNr === SYS_MSGCTL || syscallNr === SYS_SHMCTL) { - this.handleIpcControl(channel, syscallNr, origArgs, rawArgs); - return; - } - // --- SysV IPC: semctl has cmd-dependent arg types (scalar vs pointer) --- - if (syscallNr === SYS_SEMCTL) { - this.handleSemctl(channel, origArgs, rawArgs); - return; + const channelKey = `${pid}:${channelOffset}`; + const channelOffsetAlreadyOwned = registration.channels.some( + (channel) => channel.channelOffset === channelOffset, + ) || this.activeChannels.some( + (channel) => channel.pid === pid && channel.channelOffset === channelOffset, + ) || this.channelTids.has(channelKey) + || this.threadForkContexts.has(channelKey); + if (channelOffsetAlreadyOwned) { + throw new Error( + `Channel offset ${channelOffset} for process ${pid} is already registered`, + ); } - // (POSIX mqueue syscalls 331-336 now go through the normal kernel path) - - // --- pselect6: fd_sets (inout) + timeout/sigmask decoding --- - if (syscallNr === SYS_PSELECT6) { - this.handlePselect6(channel, origArgs); - return; - } + // Validate the channel's task identity before mutating host registration. + // Rust allocated this TID during clone; the host only attaches transport. + this.validateKernelTid(pid, tid, entry); - // --- select(2): same shape as pselect6 but with `struct timeval` - // (sec, usec) and no sigmask. musl's select.c routes here on wasm64 - // because `__NR_pselect6_time64` isn't defined for that arch (unlike - // wasm32, which aliases it to __NR_pselect6). Without this intercept, - // sys_select returns EAGAIN when it needs host-managed waiting, and the - // generic blocking-retry has no select-timeout awareness — every - // `select(0,0,0,0,&tv)` (= my_sleep) becomes an infinite loop. That - // surfaced as the wasm64 mariadbd boot hang at - // wait_for_signal_thread_to_end's kill+my_sleep loop. - if (syscallNr === SYS_SELECT) { - this.handleSelect(channel, origArgs); - return; + for (const [existingChannelKey, existingTid] of this.channelTids) { + if (existingTid !== tid) continue; + throw new Error( + `Kernel TID ${tid} is already attached to channel ${existingChannelKey}`, + ); } - // --- Normal syscall path --- - // Linux requires room for one kernel-word mask and a kernel-word-aligned - // length. The descriptor marshals only the fixed four bytes Kandelo can - // write, so a larger valid request is not constrained by channel capacity. - if ( - syscallNr === SYS_SCHED_GETAFFINITY - && ( - origArgs[1] < SCHED_AFFINITY_MASK_SIZE - || origArgs[1] % SCHED_AFFINITY_MASK_SIZE !== 0 - ) - ) { - this.completeChannel( - channel, - syscallNr, - origArgs, - undefined, - -1, - EINVAL, + const channel: ChannelInfo = { + pid, + memory: registration.memory, + channelOffset, + i32View: new Int32Array(registration.memory.buffer, channelOffset), + consecutiveSyscalls: 0, + }; + entry.deferObserverEffect(() => { + // WHY: weak target observation may enter arbitrary host code. The + // channel becomes observable only after attachment completes and the + // kernel entry scope is revoked. + this.observeProcessMemoryTarget(registration.memory, channel); + this.observeProcessMemoryTarget( + registration.memory, + channel.i32View, ); - return; - } + return undefined; + }); - // Copy raw args to kernel scratch header (will be adjusted below) - const adjustedArgs: Array = [...origArgs]; - if (syscallNr === SYS_PREAD || syscallNr === SYS_PWRITE) { - // WHY: pread/pwrite carry one signed i64 offset. Keep the channel value - // exact instead of round-tripping it through JavaScript Number before - // the checked kernel scratch dispatch. - adjustedArgs[3] = rawArgs[3]!; - } + try { + registration.channels.push(channel); + this.activeChannels.push(channel); + this.channelTids.set(channelKey, tid); + this.threadForkContexts.set(channelKey, { fnPtr, argPtr }); - // Process pointer args: copy data between process and kernel memory - const pointerWidth = this.getPtrWidth(channel.pid); - let argDescs = SYSCALL_ARGS[syscallNr]; - if (syscallNr === SYS_PRCTL) { - const option = Number(BigInt.asUintN(32, rawArgs[0]!)); - adjustedArgs[0] = option; - if (option === PR_SET_NAME || option === PR_GET_NAME) { - // WHY: only the two thread-name operations interpret arg2 as a - // process pointer. Every other prctl option owns scalar semantics, so - // a generic pointer descriptor would either read an arbitrary caller - // address or replace the scalar with a scratch pointer. - argDescs = [{ - argIndex: 1, - direction: option === PR_SET_NAME ? "in" : "out", - size: { type: "fixed", size: PRCTL_NAME_BYTES }, - required: true, - }]; - } else { - adjustedArgs[1] = Number( - BigInt.asUintN(32, BigInt.asUintN(pointerWidth * 8, rawArgs[1]!)), + // Lower the kernel's mmap ceiling only for legacy high-address thread + // control pages. Compact process memories reserve thread pages before the + // process's mmap base when the process is registered. + const setMaxAddr = this.#kernelInstanceForEntry(entry).exports.kernel_set_max_addr as + ((pid: number, maxAddr: KernelPointer) => number) | undefined; + if (setMaxAddr && !registration.explicitMaxAddr) { + const tlsPageAddr = channelOffset - 2 * WASM_PAGE_SIZE; + if (tlsPageAddr >= PROCESS_MMAP_BASE) { + setMaxAddr(pid, this.toKernelPtr(tlsPageAddr)); + } + } + + // In polling mode, the poller picks up new channels automatically. + if (!this.usePolling) { + this.listenOnChannel(channel); + } + pending.attachedChannelOffset = channelOffset; + } catch (error) { + this.#rethrowKernelEntryFatal(error); + registration.channels = registration.channels.filter( + (registered) => registered !== channel, + ); + this.activeChannels = this.activeChannels.filter( + (registered) => registered !== channel, + ); + this.releaseThreadChannelOwnership(pid, channelOffset); + throw error; + } + } + + /** + * Remove a channel from a process registration (e.g. when a thread exits). + */ + removeChannel(pid: number, channelOffset: number): void { + this.#runOrDeferKernelEntry( + `channel removal pid=${pid}`, + (entry) => { + this.#removeChannelWithinKernelEntry( + pid, + channelOffset, + entry, ); - argDescs = []; + return undefined; + }, + ); + } + + #removeChannelWithinKernelEntry( + pid: number, + channelOffset: number, + entry: KernelWorkerEntryContext, + ): void { + const registration = this.processes.get(pid); + for (const channel of registration?.channels ?? []) { + if (channel.channelOffset !== channelOffset) continue; + this.retireExactChannelAsyncState(channel, entry); + } + + if (registration) { + registration.channels = registration.channels.filter( + (ch) => ch.channelOffset !== channelOffset, + ); + } + this.activeChannels = this.activeChannels.filter( + (ch) => !(ch.pid === pid && ch.channelOffset === channelOffset), + ); + this.releaseThreadChannelOwnership(pid, channelOffset); + } + + /** Release one exact mailbox/TID ownership record. Idempotent for teardown. */ + private releaseThreadChannelOwnership(pid: number, channelOffset: number): void { + this.channelTids.delete(`${pid}:${channelOffset}`); + this.threadForkContexts.delete(`${pid}:${channelOffset}`); + } + + /** + * Retire every host-owned asynchronous continuation for one exact mailbox. + * No guest result is published: the channel generation is being removed. + */ + private retireExactChannelAsyncState( + channel: ChannelInfo, + entry: KernelWorkerEntryContext, + ): void { + this.retireChannelListener(channel); + const retrySnapshot = this.blockingRetrySnapshots.get(channel); + if (retrySnapshot) { + this.#cancelHostOwnedKernelWait( + channel, + retrySnapshot.syscallNr, + entry, + ); + } + this.#releaseBlockingRetrySnapshot(channel, entry); + this.cancelParkedFifoOpen(channel, entry, retrySnapshot?.syscallNr); + this.discardStoppedChannelState(channel); + this.resumePreparedSignals?.delete(channel); + this.activeChannelRequests?.delete(channel); + this.pendingCancels?.delete(channel); + this.waitingForChild = (this.waitingForChild ?? []).filter( + (waiter) => waiter.channel !== channel, + ); + + const signalWaitKey = `${channel.pid}:${channel.channelOffset}`; + const signalWait = this.pendingSignalWaits?.get(signalWaitKey); + if (signalWait) this.#cancelRegisteredTimeout(signalWait.timer); + this.pendingSignalWaits?.delete(signalWaitKey); + this.signalWaitDeadlines?.delete(signalWaitKey); + + const sleep = this.pendingSleeps?.get(channel); + if (sleep) this.#cancelRegisteredTimeout(sleep.timer); + this.pendingSleeps?.delete(channel); + + const futex = this.pendingFutexWaits?.get(channel); + if (futex) { + this.pendingFutexWaits.delete(channel); + if (futex.retire) futex.retire(); + else { + try { + Atomics.notify( + new Int32Array(channel.memory.buffer), + futex.futexIndex, + ); + } catch { + // A detached discarded memory has no waiter left to release. + } } } - if (syscallNr === SYS_IOCTL) { - const request = Number(BigInt.asUintN(32, rawArgs[1]!)); - const contract = IOCTL_REQUESTS[request]; - adjustedArgs[1] = request; - adjustedArgs[3] = 0; - adjustedArgs[PROCESS_POINTER_WIDTH_ARG_INDEX] = pointerWidth; - if (!contract) { - // WHY: an unknown ioctl must reach the device with no staged process - // pointer. The kernel can then report EBADF/ENOTTY/ENOSYS without an - // unrelated caller-memory read or write. - adjustedArgs[2] = 0; - argDescs = []; - } else { - const size = pointerWidth === 8 - ? contract.wasm64Size - : contract.wasm32Size; - if (size === null) { - // The request is known, but its nested pointer layout cannot be - // represented losslessly for this caller data model. + const poll = this.pendingPollRetries?.get(channel); + if (poll?.timer !== null && poll?.timer !== undefined) { + this.#cancelRegisteredTimeout(poll.timer); + this.#cancelRegisteredImmediate(poll.timer); + } + this.pendingPollRetries?.delete(channel); + const advisoryLock = this.pendingAdvisoryLockRetries?.get(channel); + if (advisoryLock) this.#cancelRegisteredTimeout(advisoryLock.timer); + this.pendingAdvisoryLockRetries?.delete(channel); + const select = this.pendingSelectRetries?.get(channel); + if (select?.timer !== null && select?.timer !== undefined) { + this.#cancelRegisteredTimeout(select.timer); + this.#cancelRegisteredImmediate(select.timer); + } + this.pendingSelectRetries?.delete(channel); + channel.readinessDeadline = undefined; + channel.readinessFinalCheck = undefined; + + this.removePendingPipeReader(channel); + this.removePendingPipeWriter(channel); + const socketTimer = this.socketTimeoutTimers?.get(channel); + if (socketTimer !== undefined) this.#cancelRegisteredTimeout(socketTimer); + this.socketTimeoutTimers?.delete(channel); + } + + /** Gather even partially detached channel objects before process teardown. */ + private retireAsyncChannelsForProcess( + pid: number, + entry: KernelWorkerEntryContext, + ): void { + const channels = new Set(); + for (const channel of this.processes.get(pid)?.channels ?? []) { + channels.add(channel); + } + for (const channel of this.activeChannels ?? []) { + if (channel.pid === pid) channels.add(channel); + } + for (const waiter of this.waitingForChild ?? []) { + if (waiter.channel.pid === pid) channels.add(waiter.channel); + } + for (const channel of this.pendingSleeps?.keys() ?? []) { + if (channel.pid === pid) channels.add(channel); + } + for (const channel of this.pendingFutexWaits?.keys() ?? []) { + if (channel.pid === pid) channels.add(channel); + } + for (const channel of this.pendingPollRetries?.keys() ?? []) { + if (channel.pid === pid) channels.add(channel); + } + for (const channel of this.blockingRetrySnapshots?.keys() ?? []) { + if (channel.pid === pid) channels.add(channel); + } + for (const channel of this.pendingAdvisoryLockRetries?.keys() ?? []) { + if (channel.pid === pid) channels.add(channel); + } + for (const channel of this.pendingSelectRetries?.keys() ?? []) { + if (channel.pid === pid) channels.add(channel); + } + for (const channel of this.pendingCancels ?? []) { + if (channel.pid === pid) channels.add(channel); + } + for (const readers of this.pendingPipeReaders?.values() ?? []) { + for (const reader of readers) { + if (reader.channel.pid === pid) channels.add(reader.channel); + } + } + for (const writers of this.pendingPipeWriters?.values() ?? []) { + for (const writer of writers) { + if (writer.channel.pid === pid) channels.add(writer.channel); + } + } + for (const channel of channels) { + this.retireExactChannelAsyncState(channel, entry); + } + } + + /** Detach one exact listener generation without waking its guest Worker. */ + private retireChannelListener(channel: ChannelInfo): void { + (this.retiredChannelListeners ??= new Set()).add(channel); + } + + /** + * Release waitAsync listeners after the corresponding guest Worker stopped. + * + * `Atomics.waitAsync` has no cancellation API. Engines retain its unresolved + * Promise in a global waiter registry, and the Promise reaction closes over + * `channel`, pinning the process's entire Shared WebAssembly.Memory even + * after every ordinary host map has released it. Notify only after Worker + * termination: while a guest is live, it can also be waiting on CH_STATUS. + * + * The exact retired token remains until every pending listener callback has + * run and acknowledged the stale generation. Pool owners can await the + * returned Promise before reusing the backing or pthread slot. + */ + settleRetiredChannelListeners( + pid: number, + expectedMemory?: WebAssembly.Memory, + expectedChannelOffset?: number, + ): Promise { + const retired = this.retiredChannelListeners; + if (!retired || retired.size === 0) return Promise.resolve(); + const settlements: Promise[] = []; + + for (const channel of Array.from(retired)) { + if (channel.pid !== pid) continue; + if (expectedMemory && channel.memory !== expectedMemory) continue; + if ( + expectedChannelOffset !== undefined + && channel.channelOffset !== expectedChannelOffset + ) { + continue; + } + + const state = this.retiredListenerSettlement(channel); + settlements.push(state.promise); + if ((this.pendingChannelListenerCounts?.get(channel) ?? 0) === 0) { + this.acknowledgeRetiredChannelListener(channel); + continue; + } + if (state.notified) continue; + state.notified = true; + try { + const view = new Int32Array( + channel.memory.buffer, + channel.channelOffset, + ); + // Omit the count so duplicate waitAsync registrations, if introduced + // by a future listener bug, cannot leave one Promise retaining Memory. + Atomics.notify(view, CH_STATUS / Int32Array.BYTES_PER_ELEMENT); + } catch { + // A detached or otherwise invalid retired buffer has no live waiter. + this.acknowledgeRetiredChannelListener(channel); + } + } + return Promise.all(settlements).then(() => {}); + } + + private retiredListenerSettlement(channel: ChannelInfo): { + promise: Promise; + resolve: () => void; + notified: boolean; + } { + const settlements = this.retiredChannelSettlements ??= new Map(); + const existing = settlements.get(channel); + if (existing) return existing; + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + const state = { promise, resolve, notified: false }; + settlements.set(channel, state); + return state; + } + + private acknowledgeRetiredChannelListener(channel: ChannelInfo): void { + if ((this.pendingChannelListenerCounts?.get(channel) ?? 0) !== 0) return; + (this.retiredChannelListeners ??= new Set()).delete(channel); + const state = this.retiredChannelSettlements?.get(channel); + if (!state) return; + this.retiredChannelSettlements.delete(channel); + state.resolve(); + } + + private beginChannelListenerWait(channel: ChannelInfo): void { + const counts = this.pendingChannelListenerCounts ??= new Map(); + counts.set(channel, (counts.get(channel) ?? 0) + 1); + } + + private finishChannelListenerWait(channel: ChannelInfo): boolean { + const counts = this.pendingChannelListenerCounts ??= new Map(); + const remaining = (counts.get(channel) ?? 1) - 1; + if (remaining > 0) counts.set(channel, remaining); + else counts.delete(channel); + return remaining <= 0; + } + + private observeProcessMemoryTarget( + memory: WebAssembly.Memory, + target: object, + ): void { + try { + this.callbacks.onProcessMemoryTarget?.(memory, target); + } catch { + // Weak retirement telemetry must never change process correctness. + } + } + + /** + * Return whether this exact channel object belongs to the pid's current + * registration. Exec deliberately reuses the numeric pid (and commonly the + * same channel offset), so pid existence alone cannot distinguish a stale + * waitAsync/timer continuation from the replacement image's channel. + */ + private isRegisteredChannel(channel: ChannelInfo): boolean { + const testHook = this.#scratchBoundaryTestHooks?.isRegisteredChannel; + if (testHook) return testHook(channel); + const registration = this.processes.get(channel.pid); + return !(this.retiredChannelListeners?.has(channel) ?? false) + && registration !== undefined + && registration.channels.includes(channel); + } + + /** + * Async continuations may run while an exact channel remains registered for + * orderly worker teardown even though its kernel Process is already dead. + */ + #isAsyncChannelProcessActiveWithinKernelEntry( + channel: ChannelInfo, + entry: KernelWorkerEntryContext, + ): boolean { + if (!this.isRegisteredChannel(channel) || this.hostReaped?.has(channel.pid)) { + return false; + } + try { + if (this.#getProcessExitSignal(channel.pid, entry) > 0) { + this.#handleProcessTerminatedWithinKernelEntry(channel, entry); + return false; + } + } catch (error) { + this.#rethrowKernelEntryFatal(error); + // Older compatible kernels lack the additive exit-signal query; channel + // identity remains the best available liveness evidence there. + } + return true; + } + + private isAsyncChannelProcessActive(channel: ChannelInfo): boolean { + if (!this.isRegisteredChannel(channel)) return false; + if (this.#kernelFatalError !== null) return false; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError( + `async channel liveness pid=${channel.pid}`, + ); + } + let active: boolean | undefined; + const deferred = this.#runOrDeferKernelEntry( + `async channel liveness pid=${channel.pid}`, + (entry) => { + active = this.#isAsyncChannelProcessActiveWithinKernelEntry( + channel, + entry, + ); + return undefined; + }, + ); + if (deferred || active === undefined) { + throw new KernelReentrantEntryError( + `async channel liveness pid=${channel.pid}`, + ); + } + return active; + } + + /** Public liveness guard for async Node/browser worker-entry continuations. */ + isProcessExecutionActive(pid: number): boolean { + if (this.hostReaped?.has(pid)) return false; + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError( + `process liveness query pid=${pid}`, + ); + } + let active: boolean | undefined; + const deferred = this.#runOrDeferKernelEntry( + `process liveness query pid=${pid}`, + (entry) => { + active = this.#isProcessExecutionActiveWithinKernelEntry(pid, entry); + return undefined; + }, + ); + if (deferred || active === undefined) { + throw new KernelReentrantEntryError( + `process liveness query pid=${pid}`, + ); + } + return active; + } + + #isProcessExecutionActiveWithinKernelEntry( + pid: number, + entry: KernelWorkerEntryContext, + ): boolean { + if (this.hostReaped?.has(pid)) return false; + try { + // kernel_get_process_exit_signal returns -1 while the Process is live + // (Running or Stopped), 0 for a normal zombie, a positive signal for + // signal death, and a negative errno when the pid no longer exists. + return this.#getProcessExitSignal(pid, entry) === -1; + } catch (error) { + this.#rethrowKernelEntryFatal(error); + return true; + } + } + + /** + * Decide whether an asynchronously created fork/spawn child may receive a + * host Worker. A child killed before registration remains a real, waitable + * kernel zombie; finalize its host-only state without rolling it back. + */ + shouldLaunchPendingChild(pid: number): boolean { + if (this.isProcessExecutionActive(pid)) return true; + this.finalizePendingChildTermination(pid); + return false; + } + + /** + * Start a prepared process/thread Worker only when the authoritative kernel + * Process is runnable. Fork/spawn/exec setup may register memory and return + * to its caller while stopped; the constructor itself is retained here so + * no guest instruction can execute before SIGCONT. `expectedMemory` is the + * generation token that prevents a deferred closure from attaching to a + * later exec image for the same persistent PID. + */ + startProcessWorkerWhenRunnable( + pid: number, + expectedMemory: WebAssembly.Memory, + start: () => void, + cancel: () => void, + onStartError?: (error: unknown) => boolean, + ): ProcessWorkerStartDisposition { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError("process Worker start preflight"); + } + let disposition: ProcessWorkerStartDisposition | undefined; + const deferred = this.#runOrDeferKernelEntry( + `process Worker start preflight pid=${pid}`, + (entry) => { + disposition = this.#startProcessWorkerWhenRunnableWithinKernelEntry( + pid, + expectedMemory, + start, + cancel, + onStartError, + entry, + ); + }, + ); + if (deferred || disposition === undefined) { + throw new KernelReentrantEntryError("process Worker start preflight"); + } + return disposition; + } + + #startProcessWorkerWhenRunnableWithinKernelEntry( + pid: number, + expectedMemory: WebAssembly.Memory, + start: () => void, + cancel: () => void, + onStartError: ((error: unknown) => boolean) | undefined, + entry: KernelWorkerEntryContext, + ): ProcessWorkerStartDisposition { + const registration = this.processes.get(pid); + if (!registration || registration.memory !== expectedMemory) { + entry.deferProtocolEffect(() => { + cancel(); + return undefined; + }); + return "stale"; + } + + const getState = this.#kernelInstanceForEntry(entry).exports.kernel_get_process_state as ( + pid: number, + ) => number; + const state = getState(pid); + if (state === PROCESS_STATE_EXITED) { + entry.deferProtocolEffect(() => { + cancel(); + return undefined; + }); + return "dead"; + } + if (state < 0) { + entry.deferProtocolEffect(() => { + cancel(); + return undefined; + }); + return "stale"; + } + const deferStart = (): ProcessWorkerStartDisposition => { + this.stoppedPids.add(pid); + const entry: DeferredProcessWorkerStart = { + expectedMemory, + start, + cancel, + onStartError, + }; + let entries = this.deferredProcessWorkerStarts.get(pid); + if (!entries) { + entries = new Set(); + this.deferredProcessWorkerStarts.set(pid, entries); + } + entries.add(entry); + return "deferred"; + }; + + if (state === PROCESS_STATE_STOPPED) { + return deferStart(); + } + if (state !== PROCESS_STATE_RUNNING) { + entry.deferProtocolEffect(() => { + cancel(); + return undefined; + }); + return "stale"; + } + + // A CONTINUED wake may have arrived while async fork/spawn/exec had no + // registered channel to inspect. Queue this constructor first, then make + // the now-registered generation pass through the same all-thread signal + // barrier before any guest instruction can execute. + if (this.pendingResumePids?.has(pid) || this.stoppedPids?.has(pid)) { + deferStart(); + if (this.resumeStoppedProcess(pid, entry)) return "started"; + // Direct resume preflight can apply a retained default stop and enqueue + // a STOPPED wake outside the ordinary wake-drain call stack (notably an + // exec handoff). Service it now so the parent does not remain asleep. + this.#drainAndProcessWakeupEventsWithinKernelEntry(entry); + const postResumeState = getState(pid); + if (postResumeState === PROCESS_STATE_EXITED) return "dead"; + if (postResumeState < 0) return "stale"; + return "deferred"; + } + + // The Process may have continued before its ordinary wake event was + // drained. Without an unregistered-resume barrier, the direct state query + // is authoritative for launch permission. + this.stoppedPids.delete(pid); + entry.deferProtocolTransactionStart(() => { + start(); + return undefined; + }); + return "started"; + } + + #runScheduledListenerRoot( + label: string, + operation: () => void, + runAfterFatal = false, + ): void { + if (this.#kernelFatalError !== null && !runAfterFatal) return; + try { + const result: unknown = operation(); + if (result !== undefined) { + throw kernelEntryEffectFailure( + `${label} returned a value`, + result, + ); + } + } catch (cause) { + this.#failKernelInstance(kernelEntryEffectFailure( + `${label} failed`, + cause, + )); + } + } + + #registerTimeout( + operation: () => void, + delayMs: number, + ): ReturnType { + return kernelEntryIntrinsicApply( + this.#scheduleTimeout, + this.#schedulerReceiver, + [operation, delayMs], + ) as ReturnType; + } + + #cancelRegisteredTimeout( + timer: Parameters[0], + ): void { + kernelEntryIntrinsicApply( + this.#cancelTimeout, + this.#schedulerReceiver, + [timer], + ); + } + + #registerInterval( + operation: () => void, + delayMs: number, + ): ReturnType { + return kernelEntryIntrinsicApply( + this.#scheduleInterval, + this.#schedulerReceiver, + [operation, delayMs], + ) as ReturnType; + } + + #cancelRegisteredInterval( + timer: Parameters[0], + ): void { + kernelEntryIntrinsicApply( + this.#cancelInterval, + this.#schedulerReceiver, + [timer], + ); + } + + #registerImmediate( + operation: () => void, + ): ReturnType { + return kernelEntryIntrinsicApply( + this.#scheduleImmediate, + this.#schedulerReceiver, + [operation], + ) as ReturnType; + } + + #cancelRegisteredImmediate( + immediate: Parameters[0], + ): void { + kernelEntryIntrinsicApply( + this.#cancelImmediate, + this.#schedulerReceiver, + [immediate], + ); + } + + #scheduleImmediateListenerRoot( + label: string, + operation: () => void, + ): void { + try { + kernelEntryIntrinsicApply( + this.#scheduleImmediate, + this.#schedulerReceiver, + [() => this.#runScheduledListenerRoot(label, operation)], + ); + } catch (cause) { + this.#failKernelInstance(kernelEntryEffectFailure( + `${label} scheduling failed`, + cause, + )); + } + } + + #scheduleMicrotaskListenerRoot( + label: string, + operation: () => void, + runAfterFatal = false, + ): void { + try { + kernelEntryIntrinsicApply( + this.#scheduleMicrotask, + this.#schedulerReceiver, + [ + () => this.#runScheduledListenerRoot( + label, + operation, + runAfterFatal, + ), + ], + ); + } catch (cause) { + if (runAfterFatal) { + // Scheduling the best-effort fatal observer cannot replace the + // generation error that has already been latched. + try { + kernelEntryIntrinsicApply( + kernelEntryIntrinsicConsoleError, + console, + ["[kernel-worker] kernel fatal observer scheduling failed", cause], + ); + } catch { + /* reporting remains best-effort after the fatal latch */ + } + return; + } + this.#failKernelInstance(kernelEntryEffectFailure( + `${label} scheduling failed`, + cause, + )); + } + } + + #continueWaitAsyncListenerRoot( + promise: Promise, + label: string, + operation: () => void, + onSettled?: () => void, + ): void { + let settled = false; + const settle = (): boolean => { + if (settled) return true; + settled = true; + try { + onSettled?.(); + return true; + } catch (cause) { + this.#failKernelInstance(kernelEntryEffectFailure( + `${label} settlement failed`, + cause, + )); + return false; + } + }; + try { + kernelEntryIntrinsicApply( + this.#promiseThen, + promise, + [ + () => { + if (settle()) this.#runScheduledListenerRoot(label, operation); + }, + (cause: unknown) => { + settle(); + this.#failKernelInstance(kernelEntryEffectFailure( + `${label} wait failed`, + cause, + )); + }, + ], + ); + } catch (cause) { + settle(); + this.#failKernelInstance(kernelEntryEffectFailure( + `${label} continuation registration failed`, + cause, + )); + } + } + + #continuePromise( + promise: Promise, + onFulfilled: (value: T) => unknown, + onRejected?: (cause: unknown) => unknown, + ): Promise { + const continuation = kernelEntryIntrinsicApply( + this.#promiseThen, + promise, + [onFulfilled], + ) as Promise; + if (onRejected === undefined) return continuation; + // Preserve `.then(onFulfilled).catch(onRejected)`: a rejection thrown or + // returned by the fulfillment continuation must reach the same rollback + // path as rejection of the original host operation. + return kernelEntryIntrinsicApply( + this.#promiseThen, + continuation, + [undefined, onRejected], + ) as Promise; + } + + #resolvePromise( + value: T | PromiseLike, + ): Promise> { + return kernelEntryIntrinsicApply( + this.#promiseResolve, + this.#promiseReceiver, + [value], + ) as Promise>; + } + + /** + * Listen for a syscall on a channel using Atomics.waitAsync. + * When the process sets status to PENDING, we handle the syscall. + */ + private listenOnChannel(channel: ChannelInfo): void { + if (this.#kernelFatalError !== null) return; + // A waitAsync continuation from the discarded exec image may run after a + // replacement registration with the same pid has been installed. + if (!this.isRegisteredChannel(channel)) return; + if (this.deferChannelWhileStopped(channel)) return; + + // Re-create Int32Array view in case memory was grown + const i32View = new Int32Array( + channel.memory.buffer, + channel.channelOffset, + ); + channel.i32View = i32View; + + const statusIndex = CH_STATUS / Int32Array.BYTES_PER_ELEMENT; + + // Check if already pending (process might have sent before we started listening) + const currentStatus = Atomics.load(i32View, statusIndex); + + if (currentStatus === CH_PENDING) { + // Handle the syscall. In browser mode (relistenBatchSize=1), defer via + // setImmediate so that Atomics.waitAsync microtask resolutions don't + // create tight chains that starve the event loop. In Node.js (default + // batchSize=64), handle immediately for throughput. + if (this.relistenBatchSize <= 1) { + this.#scheduleImmediateListenerRoot( + `scheduled syscall dispatch pid=${channel.pid}`, + () => { + if ( + this.#kernelFatalError === null + && this.isRegisteredChannel(channel) + ) { + this.handleSyscall(channel); + } + }, + ); + } else { + this.handleSyscall(channel); + } + return; + } + + // Wait for status to change from its current value. + // After a syscall completes, the process resets status COMPLETE→IDLE, + // then on its next syscall sets IDLE→PENDING. We need to handle all + // transitions, not just IDLE→PENDING. + const waitResult = Atomics.waitAsync(i32View, statusIndex, currentStatus); + + if (waitResult.async) { + this.beginChannelListenerWait(channel); + let retiredWhenSettled = false; + this.#continueWaitAsyncListenerRoot( + waitResult.value, + `channel wait continuation pid=${channel.pid}`, + () => { + if (retiredWhenSettled) return; + // Check that this exact registration generation is still current. + if (!this.isRegisteredChannel(channel)) return; + // Status changed — re-enter to check new value + this.listenOnChannel(channel); + }, + () => { + const finalListener = this.finishChannelListenerWait(channel); + retiredWhenSettled = + this.retiredChannelListeners?.has(channel) === true; + if (retiredWhenSettled) { + if (finalListener) this.acknowledgeRetiredChannelListener(channel); + } + }, + ); + } else { + // Synchronous result — status already changed from what we expected + // Re-check on next tick to avoid stack overflow from tight loops + this.relistenChannel(channel); + } + } + + /** + * Handle a pending syscall from a process channel. + * + * 1. Read syscall number + args from process Memory + * 2. For each pointer arg: copy data from process Memory to kernel scratch + * 3. Write adjusted args to kernel scratch channel header + * 4. Call kernel_handle_channel(scratchOffset, scratchCapacity, pid) + * 5. For each output pointer arg: copy data from kernel scratch to process Memory + * 6. Write return value + errno to process channel + * 7. Set status to COMPLETE and notify process + * 8. Re-listen for next syscall + */ + /** Get pointer width for a process (4=wasm32, 8=wasm64). */ + private getPtrWidth(pid: number): 4 | 8 { + const testHook = this.#scratchBoundaryTestHooks?.getPtrWidth; + if (testHook) return testHook(pid); + return this.processes.get(pid)?.ptrWidth ?? 4; + } + + toKernelPtr(value: number | bigint): KernelPointer { + const exact = checkedWasmPointer( + value, + this.#kernelPointerWidth, + "kernel pointer", + ); + return this.#kernelPointerWidth === 8 ? BigInt(exact) : exact; + } + + /** Debug: last N syscalls per pid for crash diagnosis */ + private syscallRing = new Map(); + dumpLastSyscalls(pid: number): string { + return (this.syscallRing.get(pid) ?? []).join("\n"); + } + + /** Read a null-terminated C string from process memory */ + private readCString(memory: WebAssembly.Memory, ptr: number, maxLen = 256): string { + if (ptr === 0) return "(null)"; + const mem = new Uint8Array(memory.buffer); + let len = 0; + while (len < maxLen && ptr + len < mem.length && mem[ptr + len] !== 0) len++; + // TextDecoder.decode() rejects views over SharedArrayBuffer in Chrome; + // copy into a non-shared scratch first. + const copy = new Uint8Array(len); + copy.set(mem.subarray(ptr, ptr + len)); + return new TextDecoder().decode(copy); + } + + private readBytesPreview(memory: WebAssembly.Memory, ptr: number, len: number, maxLen = 160): string { + if (ptr === 0 || len <= 0) return ""; + const mem = new Uint8Array(memory.buffer); + const capped = Math.max(0, Math.min(len, maxLen, mem.length - ptr)); + if (capped <= 0) return ""; + const copy = new Uint8Array(capped); + copy.set(mem.subarray(ptr, ptr + capped)); + return new TextDecoder("utf-8", { fatal: false }).decode(copy); + } + + private formatPollFds(memory: WebAssembly.Memory, ptr: number, nfds: number): string { + if (ptr === 0 || nfds <= 0) return ""; + const view = new DataView(memory.buffer); + const entries: string[] = []; + const capped = Math.min(nfds, 8); + for (let i = 0; i < capped; i++) { + const off = ptr + i * STRUCT_SIZE_WASM_POLL_FD; + if (off + STRUCT_SIZE_WASM_POLL_FD > view.byteLength) break; + const fd = view.getInt32(off + WASM_POLL_FD_FD_OFFSET, true); + const events = view.getInt16( + off + WASM_POLL_FD_EVENTS_OFFSET, + true, + ); + const revents = view.getInt16( + off + WASM_POLL_FD_REVENTS_OFFSET, + true, + ); + entries.push(`{fd:${fd},events:0x${(events & 0xffff).toString(16)},revents:0x${(revents & 0xffff).toString(16)}}`); + } + if (nfds > capped) entries.push("..."); + return entries.join(","); + } + + /** Format a syscall for logging, decoding path/string args from process memory */ + private formatSyscallEntry( + channel: ChannelInfo, + syscallNr: number, + args: number[], + diagnosticArgs: readonly ChannelScalarValue[] = args, + ): string { + const name = SYSCALL_NAMES[syscallNr] ?? `syscall_${syscallNr}`; + const pid = channel.pid; + const tid = this.channelTids.get(`${pid}:${channel.channelOffset}`); + const tidSuffix = tid !== undefined ? `:t${tid}` : ``; + + // Decode args based on syscall type + switch (syscallNr) { + case ABI_SYSCALLS.Open: // open(path, flags, mode) + return `[${pid}${tidSuffix}] open("${this.readCString(channel.memory, args[0])}", 0x${(args[1] >>> 0).toString(16)}, 0o${(args[2] >>> 0).toString(8)})`; + case ABI_SYSCALLS.Openat: // openat(dirfd, path, flags, mode) + return `[${pid}${tidSuffix}] openat(${args[0]}, "${this.readCString(channel.memory, args[1])}", 0x${(args[2] >>> 0).toString(16)}, 0o${(args[3] >>> 0).toString(8)})`; + case ABI_SYSCALLS.Stat: // stat(path, buf) + return `[${pid}${tidSuffix}] stat("${this.readCString(channel.memory, args[0])}")`; + case ABI_SYSCALLS.Lstat: // lstat(path, buf) + return `[${pid}${tidSuffix}] lstat("${this.readCString(channel.memory, args[0])}")`; + case ABI_SYSCALLS.Fstatat: // fstatat(dirfd, path, buf, flags) + return `[${pid}${tidSuffix}] fstatat(${args[0]}, "${this.readCString(channel.memory, args[1])}", 0x${(args[3] >>> 0).toString(16)})`; + case ABI_SYSCALLS.Access: // access(path, mode) + return `[${pid}${tidSuffix}] access("${this.readCString(channel.memory, args[0])}", ${args[1]})`; + case ABI_SYSCALLS.Faccessat: // faccessat(dirfd, path, mode, flags) + return `[${pid}${tidSuffix}] faccessat(${args[0]}, "${this.readCString(channel.memory, args[1])}", ${args[2]})`; + case ABI_SYSCALLS.Chdir: // chdir(path) + return `[${pid}${tidSuffix}] chdir("${this.readCString(channel.memory, args[0])}")`; + case ABI_SYSCALLS.Opendir: // opendir(path) + return `[${pid}${tidSuffix}] opendir("${this.readCString(channel.memory, args[0])}")`; + case ABI_SYSCALLS.Readlink: // readlink(path, buf, bufsiz) + return `[${pid}${tidSuffix}] readlink("${this.readCString(channel.memory, args[0])}", ${args[2]})`; + case ABI_SYSCALLS.Readlinkat: // readlinkat(dirfd, path, buf, bufsiz) + return `[${pid}${tidSuffix}] readlinkat(${args[0]}, "${this.readCString(channel.memory, args[1])}", ${args[3]})`; + case ABI_SYSCALLS.Realpath: // realpath(path, buf, bufsiz) + return `[${pid}${tidSuffix}] realpath("${this.readCString(channel.memory, args[0])}")`; + case ABI_SYSCALLS.Read: // read(fd, buf, count) + return `[${pid}${tidSuffix}] read(${args[0]}, ${args[2]})`; + case ABI_SYSCALLS.Write: // write(fd, buf, count) + return `[${pid}${tidSuffix}] write(${args[0]}, ${args[2]}, ${JSON.stringify(this.readBytesPreview(channel.memory, args[1], args[2]))})`; + case ABI_SYSCALLS.Close: // close(fd) + return `[${pid}${tidSuffix}] close(${args[0]})`; + case ABI_SYSCALLS.Fstat: // fstat(fd, buf) + return `[${pid}${tidSuffix}] fstat(${args[0]})`; + case ABI_SYSCALLS.Fcntl: // fcntl(fd, cmd, arg) + return `[${pid}${tidSuffix}] fcntl(${args[0]}, ${args[1]}, ${args[2]})`; + case ABI_SYSCALLS.Mmap: // mmap(addr, len, prot, flags, fd, offset) + return `[${pid}${tidSuffix}] mmap(0x${args[0].toString(16)}, ${args[1]}, ${args[2]}, 0x${(args[3] >>> 0).toString(16)}, ${args[4]}, ${diagnosticArgs[5]})`; + case ABI_SYSCALLS.Pread: + return `[${pid}${tidSuffix}] pread(${args[0]}, 0x${args[1].toString(16)}, ${args[2]}, ${diagnosticArgs[3]})`; + case ABI_SYSCALLS.Pwrite: + return `[${pid}${tidSuffix}] pwrite(${args[0]}, 0x${args[1].toString(16)}, ${args[2]}, ${diagnosticArgs[3]})`; + case ABI_SYSCALLS.Ftruncate: + return `[${pid}${tidSuffix}] ftruncate(${args[0]}, ${diagnosticArgs[1]})`; + case ABI_SYSCALLS.Truncate: + return `[${pid}${tidSuffix}] truncate(0x${args[0].toString(16)}, ${diagnosticArgs[1]})`; + case ABI_SYSCALLS.Fallocate: + return `[${pid}${tidSuffix}] fallocate(${args[0]}, ${args[1]}, ${diagnosticArgs[2]}, ${diagnosticArgs[3]})`; + case ABI_SYSCALLS.Munmap: // munmap(addr, len) + return `[${pid}${tidSuffix}] munmap(0x${args[0].toString(16)}, ${args[1]})`; + case ABI_SYSCALLS.Brk: // brk(addr) + return `[${pid}${tidSuffix}] brk(0x${args[0].toString(16)})`; + case HOST_INTERCEPTED_SYSCALLS.SYS_EXECVE: // execve(path, argv, envp) + return `[${pid}${tidSuffix}] execve("${this.readCString(channel.memory, args[0])}")`; + case HOST_INTERCEPTED_SYSCALLS.SYS_FORK: return `[${pid}${tidSuffix}] fork()`; + case HOST_INTERCEPTED_SYSCALLS.SYS_VFORK: return `[${pid}${tidSuffix}] vfork()`; + case ABI_SYSCALLS.Clone: // clone(flags, stack, ptid, tls, ctid) + return `[${pid}${tidSuffix}] clone(0x${(args[0] >>> 0).toString(16)})`; + case ABI_SYSCALLS.Exit: return `[${pid}${tidSuffix}] exit(${args[0]})`; + case ABI_SYSCALLS.Poll: // poll(fds, nfds, timeout) + return `[${pid}${tidSuffix}] poll(${args[1]}, ${args[2]}, [${this.formatPollFds(channel.memory, args[0], args[1])}])`; + case ABI_SYSCALLS.Ioctl: // ioctl(fd, cmd, arg) + return `[${pid}${tidSuffix}] ioctl(${args[0]}, 0x${(args[1] >>> 0).toString(16)})`; + default: + return `[${pid}${tidSuffix}] ${name}(${diagnosticArgs.filter((_, i) => i < 3).join(", ")})`; + } + } + + /** Format a syscall return value for logging */ + private formatSyscallReturn( + syscallNr: number, + retVal: ChannelScalarValue, + errVal: number, + ): string { + const negative = typeof retVal === "bigint" + ? retVal < 0n + : retVal < 0; + if (negative || errVal !== 0) { + const errName = ERRNO_NAMES[errVal] ?? `errno=${errVal}`; + return ` = ${retVal} (${errName})`; + } + // Format return value based on syscall type + switch (syscallNr) { + case ABI_SYSCALLS.Mmap: // mmap + return ` = 0x${retVal.toString(16)}`; + case ABI_SYSCALLS.Brk: // brk + return ` = 0x${retVal.toString(16)}`; + default: + return ` = ${retVal}`; + } + } + + private handleSyscall(channel: ChannelInfo): void { + if (this.#kernelFatalError !== null) return; + if (!this.isRegisteredChannel(channel)) return; + this.#runOrDeferChannelKernelEntry( + channel, + "syscall channel", + (entry) => { + this.#handleSyscallWithinKernelEntry(channel, entry); + return undefined; + }, + ); + } + + /** + * Consume the guest-written request flags exactly once for this mailbox use. + * + * The normal field writes happen-before libc's atomic PENDING publication. + * Clear the field while the request is selected, then retain only detached + * host state. A stale flag can therefore never grant cancellation authority + * to a later request that reuses this channel. + */ + #captureChannelRequest( + channel: ChannelInfo, + entry: KernelWorkerEntryContext, + ): FrozenChannelRequest | null { + const active = this.activeChannelRequests.get(channel); + if (active) return active; + + const processView = new DataView( + channel.memory.buffer, + channel.channelOffset, + ); + const syscallNr = processView.getUint32(CH_SYSCALL, true); + const requestFlags = processView.getUint32(CH_REQUEST_FLAGS, true); + processView.setUint32(CH_REQUEST_FLAGS, 0, true); + + const cancellationPoint = + ( + requestFlags + & CHANNEL_REQUEST_FLAG_CANCELLATION_POINT + ) !== 0; + const cancellationWakeAllowed = + ( + requestFlags + & CHANNEL_REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED + ) !== 0; + if ( + (requestFlags & ~CHANNEL_REQUEST_FLAGS_KNOWN_MASK) !== 0 + || (cancellationWakeAllowed && !cancellationPoint) + ) { + this.completeChannelRawAndRelisten(channel, -1, EINVAL, entry); + return null; + } + const request = kernelEntryIntrinsicObjectFreeze({ + syscallNr, + requestFlags, + cancellationPoint, + cancellationWakeAllowed, + }); + this.activeChannelRequests.set(channel, request); + return request; + } + + /** Fail closed when a test-only path has no production request identity. */ + #cancellationPointIdentity( + channel: ChannelInfo, + ): FrozenCancellationPointIdentity { + return { + cancellationPoint: + this.activeChannelRequests.get(channel)?.cancellationPoint === true, + cancellationWakeAllowed: + this.activeChannelRequests.get(channel)?.cancellationWakeAllowed + === true, + }; + } + + #finishChannelRequest(channel: ChannelInfo): void { + this.activeChannelRequests.delete(channel); + } + + /** + * Execute an already-selected channel while its synchronous handler owns + * the reviewed kernel entry scope. + */ + #handleSyscallWithinKernelEntry( + channel: ChannelInfo, + entry: KernelWorkerEntryContext, + ): void { + if (!this.#captureChannelRequest(channel, entry)) return; + if (this.handleExitedProcessChannel(channel, entry)) return; + if (this.deferChannelWhileStopped(channel)) return; + try { + if (PROFILING) { + const pv = new DataView(channel.memory.buffer, channel.channelOffset); + const nr = pv.getUint32(CH_SYSCALL, true); + const start = performance.now(); + this.#handleSyscallInner(channel, entry); + const elapsed = performance.now() - start; + let profileEntry = this.profileData!.get(nr); + if (!profileEntry) { + profileEntry = { count: 0, totalTimeMs: 0, retries: 0 }; + this.profileData!.set(nr, profileEntry); + } + profileEntry.count++; + profileEntry.totalTimeMs += elapsed; + return; + } + this.#handleSyscallInner(channel, entry); + } catch (err) { + this.#rethrowKernelEntryFatal(err); + if (this.#kernelFatalError !== null) { + // The entry gate latches before propagating an export exception. + // Waking this process with a recoverable EIO would let it race the + // worker-wide fatal shutdown against an incoherent kernel generation. + return; + } + if (err instanceof KernelReentrantEntryError) { + // The outer Rust export trapped before it could prove a coherent + // return state. A process-local EIO would invite another call into a + // generation whose mutation boundary is now unknown. + console.error(`[handleSyscall] KERNEL-FATAL ${err.message}`); + this.#failKernelInstance(err); + return; + } + if (err instanceof KernelTransferExecuteTrapError) { + // WHY: completing one process with EIO would leave every other process + // attached to a kernel whose global transfer Vec is stranded in + // Executing state. Permanently stop every listener before the entry + // layer terminates the dedicated Worker. + console.error("[handleSyscall] KERNEL-FATAL", err); + this.#failKernelInstance(err); + return; + } + if (err instanceof KernelExitCommitProtocolError) { + // Rust may already have committed irreversible exit cleanup. Keep the + // mismatched host lifecycle view inert instead of publishing EIO or + // relistening the exited process's mailbox. + console.error(`[handleSyscall] KERNEL-FATAL ${err.message}`); + this.#failKernelInstance(err); + return; + } + if (err instanceof KernelIpcShmatRollbackError) { + // Rust accepted the attachment, but the host could not prove that both + // its process mapping and Rust's attachment count were restored. + // Continuing would make later ownership decisions from split state. + console.error(`[handleSyscall] KERNEL-FATAL ${err.message}`); + this.#failKernelInstance(err); + return; + } + if (err instanceof KernelTaskBindingError) { + // A live channel that cannot bind to a kernel-owned task is a broken + // host/kernel identity invariant. Continuing with an arbitrary EIO + // would hide the protocol failure and let the guest keep executing. + this.#terminateForKernelProtocolFailureWithinKernelEntry( + channel, + `task binding error: ${err.message}`, + entry, + ); + return; + } + console.error(`[handleSyscall] UNCAUGHT ERROR pid=${channel.pid}:`, err); + // Complete with EIO without re-entering the coherence path that just + // failed. Retrying a persistently unreadable backing here would throw a + // second time and leave the guest channel parked forever. + this.completeChannelRawAndRelisten(channel, -EIO, EIO, entry); + } + } + + /** + * Run one reviewed kernel ingress with its exact scoped façade passed + * explicitly through every private helper reached on that synchronous stack. + * + * WHY: syscall dispatch fans out through many implementation-only helpers. + * Explicit threading keeps authority lexical: an observer/backend callback + * cannot recover the context from the worker object, and the detached phase + * runs only after the scope has been revoked. + */ + #runOrDeferKernelEntry( + label: string, + operation: (entry: KernelWorkerEntryContext) => undefined, + dedupeKey?: object, + ): boolean { + return this.#kernelEntryGate.runOrDeferVoidIngress( + label, + (scope, effects) => + this.#runKernelEntryOperation(scope, effects, operation), + dedupeKey, + ); + } + + /** + * Run one exact kernel entry now, or reject it without retaining its + * callback or caller-owned arguments. + * + * WHY: immediate-result and fault-injection test seams cannot report a + * truthful synchronous result if the gate is busy. Queueing and then + * throwing would be worse: the caller would observe rejection while its + * supposedly rejected buffers or channels were still used later. + */ + #runImmediateKernelEntry( + label: string, + operation: (entry: KernelWorkerEntryContext) => undefined, + ): void { + this.#kernelEntryGate.runImmediateVoidIngress( + label, + (scope, effects) => + this.#runKernelEntryOperation(scope, effects, operation), + ); + } + + /** + * Run one worker ingress and append its nested lifecycle transaction last. + * + * A single process-group continuation may release more than one stopped + * process. Those releases form one detached transaction so no reentrant + * listener can observe only a prefix of the completed synchronous ingress. + */ + #runKernelEntryOperation( + scope: KernelVoidIngressScope, + effects: KernelEntryEffectRegistrar, + operation: (entry: KernelWorkerEntryContext) => undefined, + ): undefined { + const finalTransactionStarts: Array<() => undefined> = []; + let acceptingFinalTransactionStarts = true; + const deferFinalProtocolTransactionStart = ( + transactionStart: () => undefined, + ): undefined => { + if (!acceptingFinalTransactionStarts) { + throw new kernelEntryIntrinsicError( + "final protocol transaction registration is no longer active", + ); + } + if (typeof transactionStart !== "function") { + throw new kernelEntryIntrinsicError( + "final protocol transaction start must be callable", + ); + } + kernelEntryIntrinsicApply( + kernelEntryIntrinsicArrayPush, + finalTransactionStarts, + [transactionStart], + ); + return undefined; + }; + const entry = this.#kernelEntryContext( + scope, + effects, + deferFinalProtocolTransactionStart, + ); + try { + operation(entry); + } finally { + acceptingFinalTransactionStarts = false; + } + if (finalTransactionStarts.length === 0) return undefined; + + kernelEntryIntrinsicObjectFreeze(finalTransactionStarts); + effects.deferProtocolTransactionStart(() => { + for (let index = 0; index < finalTransactionStarts.length; index++) { + const result: unknown = finalTransactionStarts[index]!(); + if (result !== undefined) { + throw new kernelEntryIntrinsicError( + `final protocol transaction start ${index} returned a value`, + ); + } + } + return undefined; + }); + return undefined; + } + + /** + * Resolve the one pending process mailbox accepted by copy-back test seams. + * + * Call only after immediate ingress has been selected. That ordering keeps a + * rejected busy call from reading or retaining any caller-arranged process + * generation, and a fresh status view avoids trusting a forged i32View. + */ + #registeredPendingMainChannelForCopybackTest( + pid: number, + registrationWitness: ChannelInfo, + purpose: string, + ): ChannelInfo { + if ( + !Number.isSafeInteger(pid) + || pid <= 0 + || pid > MAX_KERNEL_TASK_ID + ) { + throw new TypeError(`${purpose} process ID is invalid`); + } + const registration = this.processes.get(pid); + const channel = registration?.channels[0]; + if ( + registration === undefined + || registration.channels.length !== 1 + || channel === undefined + || channel !== registrationWitness + || channel.pid !== pid + || channel.memory !== registration.memory + || registration.memory === this.#kernelMemory + || !Number.isSafeInteger(channel.channelOffset) + || channel.channelOffset < 0 + || channel.channelOffset % KERNEL_ENTRY_I32_BYTES !== 0 + ) { + throw new TypeError( + `${purpose} requires one exact process-owned main channel`, + ); + } + const buffer = kernelEntryMemoryBuffer(registration.memory); + if (channel.channelOffset > buffer.byteLength - CH_TOTAL_SIZE) { + throw new TypeError( + `${purpose} main channel is outside process Memory`, + ); + } + const status = kernelEntryIntrinsicApply( + kernelEntryIntrinsicAtomicsLoad, + kernelEntryIntrinsicAtomics, + [ + new KernelEntryIntrinsicInt32Array( + buffer, + channel.channelOffset, + CH_TOTAL_SIZE / KERNEL_ENTRY_I32_BYTES, + ), + CH_STATUS / KERNEL_ENTRY_I32_BYTES, + ], + ); + if (status !== CH_PENDING) { + throw new TypeError(`${purpose} main channel is not pending`); + } + return channel; + } + + /** + * Resolve the exact current main-channel generation accepted by the two + * fixed capacity probes. + * + * Call only after immediate ingress has been selected. The returned channel + * remains host-owned, while the caller receives only detached result bytes. + */ + #snapshotCapacityProbeMainChannel( + registrationWitness: ChannelInfo, + purpose: string, + ): + | { + readonly channel: ChannelInfo; + readonly pointerWidth: 4 | 8; + readonly inputError?: undefined; + } + | { + readonly channel?: undefined; + readonly pointerWidth?: undefined; + readonly inputError: TypeError; + } { + const invalidWitness = (): { + readonly channel?: undefined; + readonly pointerWidth?: undefined; + readonly inputError: TypeError; + } => ({ + inputError: new TypeError( + `${purpose} requires the exact current registered main channel`, + ), + }); + try { + const pid = registrationWitness.pid; + const memory = registrationWitness.memory; + const channelOffset = registrationWitness.channelOffset; + const registration = this.processes.get(pid); + const channel = registration?.channels[0]; + if ( + !Number.isSafeInteger(pid) + || pid <= 0 + || pid > MAX_KERNEL_TASK_ID + || registration === undefined + || registration.pid !== pid + || channel === undefined + || channel !== registrationWitness + || channel.pid !== pid + || channel.memory !== memory + || registration.memory !== memory + || memory === this.#kernelMemory + || channel.channelOffset !== channelOffset + || !Number.isSafeInteger(channelOffset) + || channelOffset < 0 + || channelOffset % KERNEL_ENTRY_I32_BYTES !== 0 + || (registration.ptrWidth !== 4 && registration.ptrWidth !== 8) + ) { + return invalidWitness(); + } + const buffer = kernelEntryMemoryBuffer(memory); + if (channelOffset > buffer.byteLength - CH_TOTAL_SIZE) { + return invalidWitness(); + } + return { + channel, + pointerWidth: registration.ptrWidth, + }; + } catch { + return invalidWitness(); + } + } + + /** + * Validate the deliberately unregistered pthread channel used by task-ID + * authority tests. + * + * Call only from an already-selected immediate entry. The exact registered + * channel is a generation witness; never trust its caller-visible fields. + * This keeps the test operation from accepting a raw Memory or silently + * rebinding a stale request to a replacement process generation. + */ + #snapshotUntrackedTaskAuthorityChannel( + pid: number, + registrationWitness: ChannelInfo, + channelOffset: number, + ): + | { + readonly channel: ChannelInfo; + readonly inputError?: undefined; + } + | { + readonly channel?: undefined; + readonly inputError: TypeError; + } { + const invalidChannel = (): { + readonly channel?: undefined; + readonly inputError: TypeError; + } => ({ + inputError: new TypeError( + "task-authority test requires an untracked channel in the current process Memory generation", + ), + }); + try { + const registration = this.processes.get(pid); + if ( + !Number.isSafeInteger(pid) + || pid <= 0 + || pid > MAX_KERNEL_TASK_ID + || registration === undefined + || registration.pid !== pid + || registration.channels[0] !== registrationWitness + || registration.memory === this.#kernelMemory + || !Number.isSafeInteger(channelOffset) + || channelOffset < 0 + || channelOffset % KERNEL_ENTRY_I32_BYTES !== 0 + || this.channelTids.has(`${pid}:${channelOffset}`) + ) { + return invalidChannel(); + } + const memory = registration.memory; + const memoryBuffer = kernelEntryMemoryBuffer(memory); + if ( + channelOffset > memoryBuffer.byteLength - CH_TOTAL_SIZE + ) { + return invalidChannel(); + } + // WHY: the witness proves the generation but remains caller-visible. + // Pass private handlers a host-owned snapshot built only from the + // current registration so later Proxy mutation cannot replace bytes. + return { + channel: { + pid, + memory, + channelOffset, + i32View: new KernelEntryIntrinsicInt32Array( + memoryBuffer, + channelOffset, + ), + consecutiveSyscalls: 0, + }, + }; + } catch { + return invalidChannel(); + } + } + + /** + * Snapshot caller-owned syscall arguments for an exact task-authority test + * operation. + * + * Call only after the immediate gate selects the entry. Busy rejection must + * not invoke a Proxy getter or retain a caller-owned array for later use. + */ + #snapshotTaskAuthorityTestArgs( + origArgs: number[], + minimumLength: number, + ): + | { + readonly args: number[]; + readonly inputError?: undefined; + } + | { + readonly args?: undefined; + readonly inputError: TypeError; + } { + const invalidArgs = (): { + readonly args?: undefined; + readonly inputError: TypeError; + } => ({ + inputError: new TypeError( + "task-authority test arguments are invalid", + ), + }); + try { + const length = origArgs.length; + if ( + !Number.isSafeInteger(length) + || length < minimumLength + || length > CH_ARGS_COUNT + ) { + return invalidArgs(); + } + const args: number[] = []; + for (let index = 0; index < length; index++) { + const value = origArgs[index]; + if (!Number.isSafeInteger(value)) return invalidArgs(); + kernelEntryIntrinsicApply( + kernelEntryIntrinsicArrayPush, + args, + [value], + ); + } + return { args }; + } catch { + return invalidArgs(); + } + } + + /** + * Run an already-pending mailbox or defer it until the active Rust transfer + * unwinds. + * + * WHY: `kernel_transfer_io_execute` calls host I/O while Rust still owns + * mutable process-table state. The mailbox remains PENDING—fabricating + * EBUSY would change syscall semantics and could make a non-retryable call + * fail. The channel object is the dedupe key because one mailbox can encode + * only one pending syscall. The queued closure calls the already-gated body + * directly; recursively calling `handleSyscall` would enqueue itself forever + * behind its own selected entry. + */ + #runOrDeferChannelKernelEntry( + channel: ChannelInfo, + label: string, + operation: (entry: KernelWorkerEntryContext) => undefined, + ): void { + const deferred = this.#runOrDeferKernelEntry( + `${label} pid=${channel.pid}`, + (entry) => { + if ( + this.#kernelFatalError !== null + || !this.isRegisteredChannel(channel) + ) { + return; + } + const i32View = new Int32Array( + channel.memory.buffer, + channel.channelOffset, + ); + channel.i32View = i32View; + if ( + Atomics.load( + i32View, + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + ) !== CH_PENDING + ) { + // A duplicate notification can remain queued after another path + // publishes this mailbox. It no longer owns work and must not leave + // polling mode permanently convinced that a handler is active. + channel.handling = false; + return; + } + if (!this.#kernelInstance) { + throw new Error("Kernel is not initialized for channel dispatch"); + } + operation(entry); + }, + channel, + ); + if (deferred) channel.handling = true; + } + + #kernelInstanceForEntry( + entry?: KernelWorkerEntryContext, + ): WebAssembly.Instance { + const instance = entry?.instance ?? this.#kernelInstance; + if (!instance) { + throw new Error("Kernel is not initialized for kernel entry"); + } + return instance; + } + + #kernelInstanceIfAvailableForEntry( + entry?: KernelWorkerEntryContext, + ): WebAssembly.Instance | null { + return entry?.instance ?? this.#kernelInstance; + } + + #kernelEntryContext( + scope: KernelVoidIngressScope, + effects: KernelEntryEffectRegistrar, + deferFinalProtocolTransactionStart: + (operation: () => undefined) => undefined, + ): KernelWorkerEntryContext { + if (!this.#kernelInstance) { + throw new Error("Kernel is not initialized for kernel entry"); + } + return kernelEntryIntrinsicObjectFreeze({ + instance: createKernelEntryScopedInstance( + this.#kernelInstance, + scope, + ), + scope, + deferProtocolEffect: effects.deferProtocolEffect, + deferProtocolTransactionStart: + effects.deferProtocolTransactionStart, + deferFinalProtocolTransactionStart, + deferObserverEffect: effects.deferObserverEffect, + invokeSerializedHostOperation: (operation: () => T): T => + invokeKernelEntrySerializedHostOperation(scope, operation), + }); + } + + /** + * Execute one synchronous MAP_SHARED backend operation under a real entry + * lease, or only while the gate is completely idle for host-only teardown. + * + * WHY: PlatformIO implementations can synchronously call back into public + * worker roots. The exact scope keeps void ingress queued and result-bearing + * ingress rejected until the caller validates its staged result and commits. + */ + #invokeSharedMmapHostOperation( + entry: KernelWorkerEntryContext | undefined, + operation: () => T, + ): T { + if (entry !== undefined) { + return entry.invokeSerializedHostOperation(operation); + } + return this.#kernelEntryGate.runSerializedHostOperation( + "MAP_SHARED host operation", + operation, + ); + } + + #invokeEntryScratchExport( + entry: KernelWorkerEntryContext | undefined, + lease: KernelScratchLease, + name: KernelScratchExportName, + args: readonly ( + | number + | bigint + | KernelScratchExportPointer + )[], + ): number { + return entry === undefined + ? lease.invokeKernelExport(name, args) + : lease.invokeKernelExportScoped(entry.scope, name, args); + } + + /** + * Preserve the entry gate's non-forgeable export-failure signal across + * specialized syscall catches. + * + * WHY: the gate cannot notify its fatal observer until this lexical scope is + * revoked. A same-scope catch must therefore rethrow the branded value + * before publishing an errno, fallback result, or relisten effect. + */ + #rethrowKernelEntryFatal(error: unknown): void { + if (isKernelExportFailure(error)) throw error; + } + + /** + * Stop one process after a host/kernel protocol invariant fails. + * + * Rust must accept the signal-death transition before host lifecycle state + * is published. If that transition or its shared-state teardown throws, the + * entry gate poisons the whole generation and `onKernelFatal` terminates every + * nested guest Worker directly. Publishing this pid's `onExit` in that case + * would fabricate a zombie transition that Rust never accepted. + */ + private terminateForKernelProtocolFailure( + channel: ChannelInfo, + reason: string, + ): void { + this.#runOrDeferKernelEntry( + `kernel protocol failure pid=${channel.pid}`, + (entry) => { + this.#terminateForKernelProtocolFailureWithinKernelEntry( + channel, + reason, + entry, + ); + return undefined; + }, + channel, + ); + } + + #terminateForKernelProtocolFailureWithinKernelEntry( + channel: ChannelInfo, + reason: string, + entry: KernelWorkerEntryContext, + ): void { + console.error(`[handleSyscall] FATAL ${reason}`); + channel.handling = true; + try { + this.#notifyHostProcessCrashedWithinKernelEntry( + channel.pid, + SIGSEGV, + entry, + ); + } catch (error) { + this.#rethrowKernelEntryFatal(error); + console.error( + `[handleSyscall] Failed to record process ${channel.pid} crash in kernel:`, + error, + ); + throw error; + } + // WHY: terminating Workers is externally visible and can synchronously + // trigger teardown callbacks. Publish it only after Rust accepted the + // signal-death transition and this exact scope has been revoked. + entry.deferProtocolEffect(() => { + this.callbacks.onExit?.(channel.pid, 128 + SIGSEGV); + }); + } + + /** + * Settle the narrow mailbox handshake that can race process-wide teardown. + * + * `hostReaped` is set only after Rust has transitioned the authoritative + * Process to Exited (or accepted a host-crash transition). Node and browser + * deliberately keep that process's exact channel objects registered until + * their Workers are gone. During that interval, musl must finish its + * EXIT_GROUP -> EXIT unwind, while sibling threads may already have posted a + * syscall that must never enter the dead Process or be allowed to continue. + * + * This is a lifecycle gate, not an identity fallback: live processes still + * bind every selected channel through kernel_set_current_tid, so an unknown, + * stale, or cross-process TID remains a kernel-rejected protocol error. + */ + private handleExitedProcessChannel( + channel: ChannelInfo, + entry?: KernelWorkerEntryContext, + ): boolean { + if (!this.hostReaped?.has(channel.pid)) return false; + + const processView = new DataView( + channel.memory.buffer, + channel.channelOffset, + ); + const syscallNr = processView.getUint32(CH_SYSCALL, true); + + if (syscallNr === SYS_EXIT || syscallNr === SYS_EXIT_GROUP) { + // Rust has already recorded the real exit status and released process + // state. Complete only the transport handshake; never dispatch this + // duplicate into the dead Process or repeat parent/onExit notification. + this.completeProcessExitHandshake(channel, syscallNr, entry); + } else { + // The process is already dead, so no guest observes a syscall result. + // Leave this exact mailbox parked for entry-layer Worker termination. + // The handling flag prevents polling hosts from redispatching it. + channel.handling = true; + } + return true; + } + + private completeProcessExitHandshake( + channel: ChannelInfo, + syscallNr: number, + entry?: KernelWorkerEntryContext, + ): void { + this.completeChannelRaw(channel, 0, 0, entry); + if (syscallNr === SYS_EXIT_GROUP) { + // musl follows a returning EXIT_GROUP with the non-returning SYS_EXIT + // import. Re-arm once so worker-main can complete that request and trap + // out of Wasm. SYS_EXIT itself must not be re-armed. + if (entry) { + entry.deferProtocolEffect(() => { + this.relistenChannel(channel); + }); + } else { + this.relistenChannel(channel); + } + } + } + + #handleSyscallInner( + channel: ChannelInfo, + entry: KernelWorkerEntryContext, + ): void { + // Bind every argument and generic payload read to the one Memory generation + // that carried this notification. A concurrent memory.grow may replace the + // Memory.buffer getter result, but must not splice a newer range proof onto + // bytes read from this older view. + const processMem = new Uint8Array(channel.memory.buffer); + const processView = new DataView( + processMem.buffer, + processMem.byteOffset + channel.channelOffset, + ); + + // Production capture freezes the syscall number beside its request flags. + // WHY: a stopped or retried mailbox must not pair a later live syscall + // number with the earlier request's cancellation authority. Narrow + // test-only inner-dispatch seams that deliberately bypass channel capture + // retain their historical live-number behavior. + const syscallNr = + this.activeChannelRequests.get(channel)?.syscallNr + ?? processView.getUint32(CH_SYSCALL, true); + const rawArgs: bigint[] = []; + for (let i = 0; i < CH_ARGS_COUNT; i++) { + rawArgs.push( + processView.getBigInt64(CH_ARGS + i * CH_ARG_SIZE, true), + ); + } + // The scalar contract initializes the values destined for kernel scratch. + // `origArgs` is the deliberate host-control view. Exact i64 and process + // addresses stay bigint in adjustedArgs; ProcessSize is first normalized + // to the guest width, then projected to Number only after an exact safe- + // integer proof because planner arithmetic consumes it. + const adjustedArgs = normalizeChannelScalarArguments(syscallNr, rawArgs); + const origArgs: number[] = adjustedArgs.map((value, index) => + typeof value === "number" + ? value + : Number(BigInt.asIntN(32, rawArgs[index] ?? 0n)) + ); + try { + this.checkGeneratedExactScalarArguments( + channel, + syscallNr, + origArgs, + adjustedArgs, + rawArgs, + ); + this.checkHandwrittenProcessAddressArguments( + channel, + syscallNr, + origArgs, + adjustedArgs, + rawArgs, + ); + } catch (error) { + // Reject before syscall logging, shared-mapping synchronization, or + // kernel dispatch can observe an aliased low address. + this.#rejectScratchTransfer(channel, error, entry); + return; + } + const diagnosticArgs = channelDiagnosticArguments(origArgs, adjustedArgs); + + // Track last 30 syscalls per channel for crash diagnostics + const ringKey = channel.pid; + let ring = this.syscallRing.get(ringKey); + if (!ring) { ring = []; this.syscallRing.set(ringKey, ring); } + ring.push( + ` ${this.formatSyscallEntry( + channel, + syscallNr, + origArgs, + diagnosticArgs, + )}`, + ); + if (ring.length > 30) ring.shift(); + + // Opt-in live trace ring. enableSyscallTrace() flips the flag; the + // host polls via drainSyscallTrace(). Zero cost when off. + if (this.syscallTraceEnabled) { + if (this.syscallTraceRing.length >= this.syscallTraceCap) { + // Drop the oldest entry; a forgotten subscriber shouldn't blow memory. + this.syscallTraceRing.shift(); + } + this.syscallTraceRing.push({ + t: performance.now(), + pid: channel.pid, + nr: syscallNr, + args: diagnosticArgs, + decoded: this.formatSyscallEntry( + channel, + syscallNr, + origArgs, + diagnosticArgs, + ), + }); + } + + // Syscall logging (enable globally via enableSyscallLog, or filter by + // process pointer width via syscallLogPtrWidth — useful when a single + // wasm64 process in a mixed-arch demo needs a focused trace). + const widthFilter = this.config.syscallLogPtrWidth; + const matchesWidthFilter = widthFilter !== undefined + && this.processes.get(channel.pid)?.ptrWidth === widthFilter; + const logging = !!this.config.enableSyscallLog || matchesWidthFilter; + let logEntry = ""; + if (logging) { + logEntry = this.formatSyscallEntry( + channel, + syscallNr, + origArgs, + diagnosticArgs, + ); + } + + // Separate Wasm memories cannot observe MAP_SHARED/SysV writes directly. + // Treat every guest→kernel transition as a coherence boundary: merge only + // bytes changed since this process's snapshot, then import peer updates. + this.synchronizeSharedMemoryForBoundary(channel, entry); + const mayFlushSharedBacking = (this.sharedMmapBackings?.size ?? 0) > 0; + const flushedSharedBacking = !mayFlushSharedBacking + || this.flushSharedMappingsBeforeFileSyscall( + channel, + syscallNr, + origArgs, + entry, + ); + if (mayFlushSharedBacking && this.hostReaped?.has(channel.pid)) return; + if (!flushedSharedBacking) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EIO, + [], + undefined, + entry, + ); + return; + } + if ( + syscallNr === SYS_MPROTECT + && (origArgs[2] & PROT_WRITE) !== 0 + ) { + const protectionError = this.prepareFileSharedMappingsForWrite( + channel.pid, + origArgs[0], + alignWasmPageLength(origArgs[1]), + ); + if (protectionError !== 0) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + protectionError, + [], + undefined, + entry, + ); + return; + } + } + + // --- Intercept fork/exec/clone/exit before calling kernel --- + // These syscalls need special async handling that can't go through + // direct kernel dispatch or the blocking host_exec import. + + if (syscallNr === SYS_FORK || syscallNr === SYS_VFORK) { + if (logging) console.error(logEntry); + this.handleFork(channel, origArgs, entry); + return; + } + + if (syscallNr === SYS_SPAWN) { + if (logging) console.error(logEntry); + this.#handleSpawn(channel, origArgs, entry); + return; + } + + if (syscallNr === SYS_EXECVE) { + if (logging) console.error(logEntry); + this.handleExec(channel, origArgs, entry); + return; + } + + if (syscallNr === SYS_EXECVEAT) { + if (logging) console.error(logEntry); + this.handleExecveat(channel, origArgs, entry); + return; + } + + if (syscallNr === SYS_CLONE) { + if (logging) console.error(logEntry); + this.handleClone(channel, origArgs, entry); + return; + } + + if (syscallNr === SYS_EXIT || syscallNr === SYS_EXIT_GROUP) { + if (logging) console.error(logEntry); + this.handleExit(channel, syscallNr, origArgs, entry); + return; + } + + if (syscallNr === SYS_WAIT4) { + if (logging) console.error(logEntry); + this.handleWaitpid(channel, origArgs, entry); + return; + } + + if (syscallNr === SYS_WAITID) { + if (logging) console.error(logEntry); + this.handleWaitid(channel, origArgs, entry); + return; + } + + // --- Futex: must operate on process memory, not kernel memory --- + // The kernel's host_futex_wake/wait imports use kernel memory, but futex + // addresses are in process memory. Intercept here and handle directly. + if (syscallNr === SYS_FUTEX) { + if (logging) { + // Futex args: (uaddr, op, val, timeout, uaddr2, val3). Decode the op + // to make hung-thread investigations readable. + const FUTEX_OPS: Record = { + 0: "WAIT", 1: "WAKE", 2: "FD", 3: "REQUEUE", 4: "CMP_REQUEUE", + 5: "WAKE_OP", 6: "LOCK_PI", 7: "UNLOCK_PI", 8: "TRYLOCK_PI", + 9: "WAIT_BITSET", 10: "WAKE_BITSET", 11: "WAIT_REQUEUE_PI", + 12: "CMP_REQUEUE_PI", + }; + const FUTEX_PRIVATE_FLAG = 128; + const FUTEX_CLOCK_REALTIME = 256; + const op = origArgs[1]; + const cmd = op & ~(FUTEX_PRIVATE_FLAG | FUTEX_CLOCK_REALTIME); + const opName = FUTEX_OPS[cmd] ?? `op${cmd}`; + const flags = (op & FUTEX_PRIVATE_FLAG ? "|PRIVATE" : "") + + (op & FUTEX_CLOCK_REALTIME ? "|REALTIME" : ""); + const tid = this.channelTids.get(`${channel.pid}:${channel.channelOffset}`); + const tidSuffix = tid !== undefined ? `:t${tid}` : ``; + console.error(`[${channel.pid}${tidSuffix}] futex(0x${origArgs[0].toString(16)}, ${opName}${flags}, val=${origArgs[2]})`); + } + this.handleFutex(channel, origArgs, rawArgs, entry); + return; + } + + // --- pthread_cancel wake-up: handled entirely on host side because + // the state we must perturb (futex waitAsync, pipe reader registration, + // poll/select retry timers) lives in TS, not in the kernel wasm. --- + if (syscallNr === SYS_THREAD_CANCEL) { + if (logging) console.error(logEntry); + this.handleThreadCancel(channel, origArgs, entry); + return; + } + + // --- Scatter/gather I/O (writev/readv/pwritev/preadv) --- + // These have nested pointers (iov array → base buffers) that can't be + // handled by the simple ArgDesc system. + if ( + syscallNr === SYS_WRITEV + || syscallNr === SYS_PWRITEV + || syscallNr === SYS_PWRITEV2 + ) { + if (logging) console.error(logEntry); + this.#handleWritev(channel, syscallNr, origArgs, rawArgs, entry); + return; + } + + if ( + syscallNr === SYS_READV + || syscallNr === SYS_PREADV + || syscallNr === SYS_PREADV2 + ) { + if (logging) console.error(logEntry); + this.#handleReadv(channel, syscallNr, origArgs, rawArgs, entry); + return; + } + + // --- getgroups: the return value is an entry count, not a byte count --- + // A simple output descriptor cannot express that getgroups(0, list) must + // not touch list while every positive-size call exposes exactly one + // four-byte slot in Kandelo's current single-supplementary-group model. + if (syscallNr === SYS_GETGROUPS) { + this.handleGetgroups(channel, origArgs, rawArgs, entry); + return; + } + + // --- Large write/pwrite/read/pread: one kernel-owned transfer region --- + // The ordinary channel has a fixed data capacity. Preserve one POSIX I/O + // operation above that boundary instead of splitting datagrams, pipe + // atomicity, signals, or open-file-description cursor updates into chunks. + if ( + (syscallNr === SYS_WRITE || syscallNr === SYS_PWRITE) + && (origArgs[2] ?? 0) > CH_DATA_SIZE + ) { + this.#handleLargeWrite(channel, syscallNr, origArgs, rawArgs, entry); + return; + } + if ( + (syscallNr === SYS_READ || syscallNr === SYS_PREAD) + && (origArgs[2] ?? 0) > CH_DATA_SIZE + ) { + this.#handleLargeRead(channel, syscallNr, origArgs, rawArgs, entry); + return; + } + + // --- sendmsg/recvmsg: decompose msghdr from process memory --- + if (syscallNr === SYS_SENDMSG) { + this.handleSendmsg(channel, origArgs, processMem, entry); + return; + } + if (syscallNr === SYS_RECVMSG) { + this.handleRecvmsg(channel, origArgs, processMem, entry); + return; + } + + // --- ioctl: intercept network interface ioctls --- + // These require host-side handling because: + // SIOCGIFCONF: struct ifconf contains a pointer to a process-memory buffer + // SIOCGIFHWADDR: returns the virtual MAC address for this kernel instance + if (syscallNr === SYS_IOCTL) { + const request = origArgs[1] >>> 0; + if (request === SIOCGIFCONF) { + this.handleIoctlIfconf(channel, origArgs, entry); + return; + } + if (request === SIOCGIFNAME) { + this.handleIoctlIfname(channel, origArgs, entry); + return; + } + if (request === SIOCGIFHWADDR) { + this.handleIoctlIfhwaddr(channel, origArgs, entry); + return; + } + if (request === SIOCGIFADDR) { + this.handleIoctlIfaddr(channel, origArgs, entry); + return; + } + if (request === SIOCGIFINDEX) { + this.handleIoctlIfindex(channel, origArgs, entry); + return; + } + } + + // --- fcntl with struct flock pointer --- + // When cmd is a lock operation, arg3 points to the generated flock wire. + // Handle as inout so the kernel can read/write the flock struct. + if (syscallNr === SYS_FCNTL) { + const cmd = origArgs[1]; + if (cmd === F_GETLK || cmd === F_SETLK || cmd === F_SETLKW || + cmd === F_GETLK64 || cmd === F_SETLK64 || cmd === F_SETLKW64 || + cmd === F_OFD_GETLK || cmd === F_OFD_SETLK || cmd === F_OFD_SETLKW) { + this.handleFcntlLock(channel, origArgs, entry); + return; + } + } + + // --- epoll: intercept all epoll syscalls on host side --- + // kernel_handle_channel crashes in Chrome (V8 shared-memory Wasm bug) for + // epoll_pwait. Handle epoll_create1/ctl on the kernel but mirror the + // interest list, and convert epoll_pwait to poll entirely on the host. + if (syscallNr === SYS_EPOLL_CREATE1 || syscallNr === SYS_EPOLL_CREATE) { + this.handleEpollCreate(channel, syscallNr, origArgs, entry); + return; + } + if (syscallNr === SYS_EPOLL_CTL) { + this.handleEpollCtl(channel, origArgs, entry, rawArgs); + return; + } + if (syscallNr === SYS_EPOLL_PWAIT || syscallNr === SYS_EPOLL_WAIT) { + this.handleEpollPwait(channel, syscallNr, origArgs, entry, rawArgs); + return; + } + + // --- SysV IPC: shmat/shmdt need host-side process memory management --- + if (syscallNr === SYS_SHMAT) { + this.handleIpcShmat(channel, origArgs, rawArgs, entry); + return; + } + if (syscallNr === SYS_SHMDT) { + this.handleIpcShmdt(channel, origArgs, rawArgs, entry); + return; + } + // --- SysV messages: msgbuf starts with native `long`, which differs + // between wasm32 and wasm64. Translate it to the fixed kernel wire header + // while the caller width is still known. --- + if (syscallNr === SYS_MSGSND || syscallNr === SYS_MSGRCV) { + this.handleSysvMessage(channel, syscallNr, origArgs, rawArgs, entry); + return; + } + // --- SysV IPC: control structures follow the caller's wasm32/wasm64 + // data model and their pointer direction depends on cmd. --- + if (syscallNr === SYS_MSGCTL || syscallNr === SYS_SHMCTL) { + this.handleIpcControl(channel, syscallNr, origArgs, rawArgs, entry); + return; + } + // --- SysV IPC: semctl has cmd-dependent arg types (scalar vs pointer) --- + if (syscallNr === SYS_SEMCTL) { + this.handleSemctl(channel, origArgs, rawArgs, entry); + return; + } + + // (POSIX mqueue syscalls 331-336 now go through the normal kernel path) + + // --- pselect6: fd_sets (inout) + timeout/sigmask decoding --- + if (syscallNr === SYS_PSELECT6) { + this.handlePselect6(channel, origArgs, entry); + return; + } + + // --- select(2): same shape as pselect6 but with `struct timeval` + // (sec, usec) and no sigmask. musl's select.c routes here on wasm64 + // because `__NR_pselect6_time64` isn't defined for that arch (unlike + // wasm32, which aliases it to __NR_pselect6). Without this intercept, + // sys_select returns EAGAIN when it needs host-managed waiting, and the + // generic blocking-retry has no select-timeout awareness — every + // `select(0,0,0,0,&tv)` (= my_sleep) becomes an infinite loop. That + // surfaced as the wasm64 mariadbd boot hang at + // wait_for_signal_thread_to_end's kill+my_sleep loop. + if (syscallNr === SYS_SELECT) { + this.handleSelect(channel, origArgs, entry); + return; + } + + // --- Normal syscall path --- + // Linux requires room for one kernel-word mask and a kernel-word-aligned + // length. The descriptor marshals only the fixed four bytes Kandelo can + // write, so a larger valid request is not constrained by channel capacity. + if ( + syscallNr === SYS_SCHED_GETAFFINITY + && ( + origArgs[1] < SCHED_AFFINITY_MASK_SIZE + || origArgs[1] % SCHED_AFFINITY_MASK_SIZE !== 0 + ) + ) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EINVAL, + [], + undefined, + entry, + ); + return; + } + + // Process pointer args: copy data between process and kernel memory + const pointerWidth = this.getPtrWidth(channel.pid); + if ( + syscallNr === SYS_MQ_TIMEDSEND + || syscallNr === SYS_MQ_TIMEDRECEIVE + ) { + try { + const messageSizeForDescriptor = this.#kernelInstanceForEntry(entry) + .exports.kernel_mq_descriptor_msgsize as + | (( + pid: number, + tid: number, + descriptor: number, + ) => number) + | undefined; + if (typeof messageSizeForDescriptor !== "function") { + throw new KernelScratchError( + "kernel mqueue descriptor sizing export is unavailable", + EIO, + ); + } + const queueMessageSize = messageSizeForDescriptor( + channel.pid, + this.guestTidForChannel(channel), + origArgs[0], + ); + if (queueMessageSize < 0) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + -queueMessageSize, + [], + undefined, + entry, + ); + return; + } + if ( + !Number.isSafeInteger(queueMessageSize) + || queueMessageSize <= 0 + || queueMessageSize > MAX_REPORTABLE_TRANSFER_BYTES + ) { + throw new KernelScratchError( + "kernel returned an invalid mqueue descriptor message size", + EIO, + ); + } + const requestedSize = adjustedArgs[2]; + const requestedSizeBigInt = typeof requestedSize === "bigint" + ? requestedSize + : BigInt(requestedSize); + if ( + syscallNr === SYS_MQ_TIMEDSEND + && requestedSizeBigInt > BigInt(queueMessageSize) + ) { + // WHY: POSIX requires EMSGSIZE for a message larger than this + // queue's mq_msgsize. Resolve that authoritative limit before a + // large kernel reservation can turn the same request into ENOMEM. + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EMSGSIZE, + [], + undefined, + entry, + ); + return; + } + if ( + syscallNr === SYS_MQ_TIMEDRECEIVE + && requestedSizeBigInt < BigInt(queueMessageSize) + ) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EMSGSIZE, + [], + undefined, + entry, + ); + return; + } + if (syscallNr === SYS_MQ_TIMEDRECEIVE) { + // WHY: the caller's size is a capacity, not a demand to allocate it. + // Rust proves no complete queue message can exceed mq_msgsize; stage + // and range-check exactly that complete-result maximum. + adjustedArgs[2] = BigInt(queueMessageSize); + } + } catch (error) { + this.#rethrowKernelEntryFatal(error); + this.#rejectScratchTransfer(channel, error, entry); + return; + } + } + let argDescs = SYSCALL_ARGS[syscallNr]; + if (argDescs) { + argDescs = applyNullableDereferencePairPresence( + argDescs, + rawArgs, + adjustedArgs, + ); + } + if (syscallNr === SYS_PRCTL) { + const option = Number(BigInt.asUintN(32, rawArgs[0]!)); + adjustedArgs[0] = option; + if (option === PR_SET_NAME || option === PR_GET_NAME) { + // WHY: only the two thread-name operations interpret arg2 as a + // process pointer. Every other prctl option owns scalar semantics, so + // a generic pointer descriptor would either read an arbitrary caller + // address or replace the scalar with a scratch pointer. + argDescs = [{ + argIndex: 1, + direction: option === PR_SET_NAME ? "in" : "out", + size: { type: "fixed", size: PRCTL_NAME_BYTES }, + required: true, + }]; + } else { + adjustedArgs[1] = Number( + BigInt.asUintN(32, BigInt.asUintN(pointerWidth * 8, rawArgs[1]!)), + ); + argDescs = []; + } + } + if (syscallNr === SYS_IOCTL) { + const request = Number(BigInt.asUintN(32, rawArgs[1]!)); + const contract = IOCTL_REQUESTS[request]; + adjustedArgs[1] = request; + adjustedArgs[3] = 0; + adjustedArgs[PROCESS_POINTER_WIDTH_ARG_INDEX] = pointerWidth; + + if (!contract) { + // WHY: an unknown ioctl must reach the device with no staged process + // pointer. The kernel can then report EBADF/ENOTTY/ENOSYS without an + // unrelated caller-memory read or write. + adjustedArgs[2] = 0; + argDescs = []; + } else { + const size = pointerWidth === 8 + ? contract.wasm64Size + : contract.wasm32Size; + if (size === null) { + // The request is known, but its nested pointer layout cannot be + // represented losslessly for this caller data model. + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EOVERFLOW, + [], + undefined, + entry, + ); + return; + } + + switch (contract.argKind) { + case "none": + adjustedArgs[2] = 0; + argDescs = []; + break; + case "scalar-i32": { + const rawScalar = BigInt.asUintN(pointerWidth * 8, rawArgs[2]!); + // WHY: ScalarI32 defines only the low 32 bits. In particular, + // wasm64 C varargs may leave the wider transport slot's upper + // half unspecified when musl's ioctl wrapper reads an `int` + // argument. Those non-semantic bits must neither trigger a + // pointer-style EOVERFLOW nor reach Rust. No caller range exists + // for this request and no scratch bytes are staged. + adjustedArgs[2] = Number(BigInt.asUintN(32, rawScalar)); + argDescs = []; + break; + } + case "pointer": + if (contract.direction === "none") { + throw new Error( + `ioctl 0x${request.toString(16)} pointer has no direction`, + ); + } + adjustedArgs[3] = size; + argDescs = [{ + argIndex: 2, + direction: contract.direction, + size: { type: "fixed", size }, + required: true, + }]; + break; + } + } + } + let dataOffset = 0; // Offset within scratch data area for allocations + let schedGetaffinityOutputInvalid = false; + const plannedChannelScratchArgs: PlannedChannelScratchArg[] = []; + const plannedScratchWrites: PlannedScratchWrite[] = []; + const capturedDerefU32Inputs: Array<{ + processPointer: number; + value: number; + } | undefined> = []; + let plannedZeroLengthScratchArgMask = 0; + + if (argDescs) { + this.#scratchBoundaryTestHooks?.afterProcessMemorySnapshot?.(channel); + if (argDescs.some((desc) => desc.size.type === "process-layout")) { + // WHY: the kernel Wasm target cannot select a native guest structure + // layout because one instance may serve both wasm32 and wasm64. + adjustedArgs[PROCESS_POINTER_WIDTH_ARG_INDEX] = pointerWidth; + } + + // Capture every pointer-derived size before planning any subregion. + // WHY: descriptor order is generated ABI data and may change. If the + // four-byte length slot were staged first, rereading the guest later to + // size its companion output would allow another thread to make Rust see + // a larger capacity than the host-owned subregion was planned for. + for (const desc of argDescs) { + if (desc.size.type !== "deref") continue; + const rawOuterPointer = rawArgs[desc.argIndex]!; + if (rawOuterPointer === 0n) continue; + try { + checkedWasmPointer( + canonicalGuestUnsignedScalar( + rawOuterPointer, + pointerWidth, + `syscall ${syscallNr} arg ${desc.argIndex} pointer`, + ), + pointerWidth, + `syscall ${syscallNr} arg ${desc.argIndex} pointer`, + ); + } catch { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EFAULT, + [], + undefined, + entry, + ); + return; + } + const rawDerefPtr = rawArgs[desc.size.argIndex]!; + if (rawDerefPtr === 0n) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EFAULT, + [], + undefined, + entry, + ); + return; + } + let derefPtr: number; + try { + derefPtr = checkedProcessMemoryViewRange( + processMem, + rawDerefPtr, + 4, + pointerWidth, + `syscall ${syscallNr} length pointer`, + ).pointer; + } catch { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EFAULT, + [], + undefined, + entry, + ); + return; + } + const existing = capturedDerefU32Inputs[desc.size.argIndex]; + if (existing !== undefined) { + if (existing.processPointer !== derefPtr) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EINVAL, + [], + undefined, + entry, + ); + return; + } + continue; + } + capturedDerefU32Inputs[desc.size.argIndex] = { + processPointer: derefPtr, + value: new DataView( + processMem.buffer, + processMem.byteOffset, + processMem.byteLength, + ).getUint32(derefPtr, true), + }; + } + + // Resolve every Deref capacity before descriptor-order-dependent + // planning. The paired socklen_t record may appear before or after its + // output descriptor in generated metadata, but both must observe the + // same captured, supported capacity. + for (const desc of argDescs) { + if ( + desc.size.type !== "deref" + || rawArgs[desc.argIndex] === 0n + ) continue; + const captured = capturedDerefU32Inputs[desc.size.argIndex]; + const supportedMaximum = dereferencedChannelOutputMaximum( + syscallNr, + desc.argIndex, + ); + if (captured === undefined || supportedMaximum === undefined) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EINVAL, + [], + undefined, + entry, + ); + return; + } + // WHY: socklen_t is a capacity, not a demand to reserve every caller + // byte. The complete supported result cannot exceed this maximum. + captured.value = Math.min(captured.value, supportedMaximum); + } + + for (const desc of argDescs) { + let argumentSizedBytes: number | undefined; + if (desc.size.type === "arg") { + const normalizedCount = adjustedArgs[desc.size.argIndex]!; + const rawCount = typeof normalizedCount === "bigint" + ? normalizedCount + : BigInt(normalizedCount); + if ( + rawCount < 0n + || rawCount > BigInt(Number.MAX_SAFE_INTEGER) + ) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EINVAL, + [], + undefined, + entry, + ); + return; + } + const multiplier = desc.size.multiplier ?? 1; + const add = desc.size.add ?? 0; + argumentSizedBytes = Number(rawCount) * multiplier + add; + if ( + !Number.isSafeInteger(argumentSizedBytes) + || argumentSizedBytes < 0 + ) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EINVAL, + [], + undefined, + entry, + ); + return; + } + if (argumentSizedBytes === 0) { + // WHY: a zero-length buffer lends no caller bytes, so its raw + // wasm64 pointer bits are intentionally ignored. Resolve it to a + // checked non-null kernel-scratch address under the final lease: + // Rust slices require non-null pointers even at length zero. + adjustedArgs[desc.argIndex] = 0; + plannedZeroLengthScratchArgMask |= 1 << desc.argIndex; + continue; + } + } + const rawPtr = rawArgs[desc.argIndex]!; + const deferSchedGetaffinityOutputError = + syscallNr === SYS_SCHED_GETAFFINITY + && desc.argIndex === 2 + && desc.direction === "out"; + let ptr: number; + try { + ptr = checkedWasmPointer( + canonicalGuestUnsignedScalar( + rawPtr, + pointerWidth, + `syscall ${syscallNr} arg ${desc.argIndex} pointer`, + ), + pointerWidth, + `syscall ${syscallNr} arg ${desc.argIndex} pointer`, + ); + } catch { + if (deferSchedGetaffinityOutputError) { + // WHY: Linux resolves the selected task before copying the mask. + // Keep a lossy/invalid guest pointer out of kernel memory, but + // still dispatch through safe scratch so ESRCH can take precedence + // over the eventual EFAULT. + schedGetaffinityOutputInvalid = true; + ptr = 0; + } else { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EFAULT, + [], + undefined, + entry, + ); + return; + } + } + if (rawPtr === 0n && !deferSchedGetaffinityOutputError) { + if (desc.required === true || desc.nullable !== true) { + // WHY: every descriptor with a positive extent needs an owned + // channel subregion. Null is valid only when the shared contract + // says so explicitly; absence of `required` must not silently + // turn fixed outputs such as pipefd[2] into nullable pointers. + // Arg-sized zero-length buffers were canonicalized above. + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EFAULT, + [], + undefined, + entry, + ); + return; + } + continue; + } + + // Compute size of data to copy + let size: number; + if (desc.size.type === "cstring") { + const result = cstringCopySize( + processMem, + ptr, + desc.size.maxBytes, + desc.size.tooLongErrno, + ); + if ("errno" in result) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + result.errno, + [], + undefined, + entry, + ); + return; + } + size = result.size; + } else if (desc.size.type === "arg") { + size = argumentSizedBytes!; + } else if (desc.size.type === "deref") { + // Dereference: arg is a pointer to a u32 value (e.g. socklen_t*) + const rawDerefPtr = rawArgs[desc.size.argIndex]!; + if (rawDerefPtr === 0n) { + // WHY: the outer pointer is non-null here, so its separate length + // pointer is the only source of the destination capacity. Without + // it no owned channel subregion can be planned; forwarding the + // caller pointer would cross address spaces before Rust rejects + // the malformed pair. + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EFAULT, + [], + undefined, + entry, + ); + return; + } + let derefPtr: number; + try { + derefPtr = checkedProcessMemoryViewRange( + processMem, + rawDerefPtr, + 4, + pointerWidth, + `syscall ${syscallNr} length pointer`, + ).pointer; + } catch { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EFAULT, + [], + undefined, + entry, + ); + return; + } + const captured = capturedDerefU32Inputs[desc.size.argIndex]; + if ( + captured === undefined + || captured.processPointer !== derefPtr + ) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EINVAL, + [], + undefined, + entry, + ); + return; + } + size = captured.value; + } else if (desc.size.type === "fixed") { + size = desc.size.size; + } else { + size = pointerWidth === 8 + ? desc.size.wasm64Size + : desc.size.wasm32Size; + } + + if (!Number.isSafeInteger(size) || size < 0) { this.completeChannel( channel, syscallNr, origArgs, undefined, -1, - EOVERFLOW, + EINVAL, + [], + undefined, + entry, ); return; } + if (size === 0) { + // Never leak a process-space pointer into the kernel address space, + // even when the associated count is zero. The final lease supplies + // a non-null allocator-owned empty address for Rust slice validity. + adjustedArgs[desc.argIndex] = 0; + plannedZeroLengthScratchArgMask |= 1 << desc.argIndex; + continue; + } - switch (contract.argKind) { - case "none": - adjustedArgs[2] = 0; - argDescs = []; - break; - case "scalar-i32": { - const rawScalar = BigInt.asUintN(pointerWidth * 8, rawArgs[2]!); - // WHY: ScalarI32 defines only the low 32 bits. In particular, - // wasm64 C varargs may leave the wider transport slot's upper - // half unspecified when musl's ioctl wrapper reads an `int` - // argument. Those non-semantic bits must neither trigger a - // pointer-style EOVERFLOW nor reach Rust. No caller range exists - // for this request and no scratch bytes are staged. - adjustedArgs[2] = Number(BigInt.asUintN(32, rawScalar)); - argDescs = []; - break; - } - case "pointer": - if (contract.direction === "none") { - throw new Error( - `ioctl 0x${request.toString(16)} pointer has no direction`, - ); - } - adjustedArgs[3] = size; - argDescs = [{ - argIndex: 2, - direction: contract.direction, - size: { type: "fixed", size }, - required: true, - }]; - break; + try { + validateCompleteChannelInputSize( + syscallNr, + desc.argIndex, + size, + ); + } catch (error) { + this.#rejectScratchTransfer(channel, error, entry); + return; } - } - } - let dataOffset = 0; // Offset within scratch data area for allocations - let schedGetaffinityOutputInvalid = false; - const plannedChannelScratchArgs: PlannedChannelScratchArg[] = []; - const plannedScratchWrites: PlannedScratchWrite[] = []; - const capturedDerefU32Inputs: Array<{ - processPointer: number; - value: number; - } | undefined> = []; - let plannedZeroLengthScratchArgMask = 0; - if (argDescs) { - // Re-create typed views (memory may have grown) - const processMem = new Uint8Array(channel.memory.buffer); - if (argDescs.some((desc) => desc.size.type === "process-layout")) { - // WHY: the kernel Wasm target cannot select a native guest structure - // layout because one instance may serve both wasm32 and wasm64. - adjustedArgs[PROCESS_POINTER_WIDTH_ARG_INDEX] = pointerWidth; - } + if (desc.size.type === "arg") { + const simpleCount = + (desc.size.multiplier ?? 1) === 1 + && (desc.size.add ?? 0) === 0; + const semanticCeiling = simpleCount + ? shortChannelArgumentSize(syscallNr, desc.argIndex) + ?? boundedChannelArgumentSize(syscallNr, desc.argIndex) + : undefined; + if (semanticCeiling !== undefined && size > semanticCeiling) { + // Only the two short-safe calls or a separately generated + // complete-result maximum may rewrite a caller count. Every other + // variable extent retains its exact operation semantics and uses a + // Rust-owned large channel below. + size = semanticCeiling; + adjustedArgs[desc.size.argIndex] = size; + } + } - // Capture every pointer-derived size before planning any subregion. - // WHY: descriptor order is generated ABI data and may change. If the - // four-byte length slot were staged first, rereading the guest later to - // size its companion output would allow another thread to make Rust see - // a larger capacity than the host-owned subregion was planned for. - for (const desc of argDescs) { - if (desc.size.type !== "deref") continue; - const rawOuterPointer = rawArgs[desc.argIndex]!; - if (rawOuterPointer === 0n) continue; + let processRangeValid = true; try { - checkedWasmPointer( - rawOuterPointer, + checkedProcessMemoryViewRange( + processMem, + rawPtr, + size, pointerWidth, - `syscall ${syscallNr} arg ${desc.argIndex} pointer`, + `syscall ${syscallNr} arg ${desc.argIndex} data`, ); } catch { + if (deferSchedGetaffinityOutputError) { + // Linux resolves the requested task before copying its affinity + // mask. Use safe kernel scratch now, then convert a successful + // lookup to EFAULT below; an ESRCH result must take precedence. + schedGetaffinityOutputInvalid = true; + processRangeValid = false; + } else { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EFAULT, + [], + undefined, + entry, + ); + return; + } + } + + const scratchOffset = CH_DATA + dataOffset; + let inputBytes: Uint8Array | null = null; + if ( + processRangeValid + && (desc.direction === "in" || desc.direction === "inout") + ) { + const captured = capturedDerefU32Inputs[desc.argIndex]; + if (captured !== undefined) { + if (size !== 4 || captured.processPointer !== ptr) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EINVAL, + [], + undefined, + entry, + ); + return; + } + inputBytes = new Uint8Array(4); + new DataView(inputBytes.buffer).setUint32( + 0, + captured.value, + true, + ); + } else { + inputBytes = processMem.slice(ptr, ptr + size); + } + } + const plannedArg: PlannedChannelScratchArg = { + desc, + processPointer: ptr, + scratchOffset, + size, + inputBytes, + }; + plannedChannelScratchArgs.push(plannedArg); + plannedScratchWrites.push({ + argIndex: desc.argIndex, + scratchOffset, + size, + inputBytes, + }); + + // Install the allocator-selected pointer only while the final + // exclusive lease is active. + adjustedArgs[desc.argIndex] = 0; + + dataOffset += size; + if (!Number.isSafeInteger(dataOffset)) { this.completeChannel( channel, syscallNr, origArgs, undefined, -1, - EFAULT, + E2BIG, + [], + undefined, + entry, ); return; } - const rawDerefPtr = rawArgs[desc.size.argIndex]!; - if (rawDerefPtr === 0n) { - this.completeChannel( - channel, - syscallNr, - origArgs, - undefined, - -1, - EFAULT, + // Kernel exports may dereference i64-bearing structs and scalar output + // slots directly. Keep every following allocation eight-byte aligned; + // CH_DATA itself is eight-byte aligned. + try { + dataOffset = checkedAlignUp( + dataOffset, + 8, + `syscall ${syscallNr} scratch footprint`, ); + } catch (error) { + this.#rejectScratchTransfer(channel, error, entry); return; } - let derefPtr: number; - try { - derefPtr = this.checkedProcessRange( - channel, - rawDerefPtr, - 4, - `syscall ${syscallNr} length pointer`, - ).pointer; - } catch { + } + if (syscallNr === ABI_SYSCALLS.SetEnv) { + const name = plannedChannelScratchArgs.find( + (planned) => planned.desc.argIndex === 0, + ); + const value = plannedChannelScratchArgs.find( + (planned) => planned.desc.argIndex === 1, + ); + const encodedEntryBytes = name && value + ? name.size + value.size - 1 + : Number.NaN; + if ( + !Number.isSafeInteger(encodedEntryBytes) + || encodedEntryBytes > PROCESS_METADATA_ENTRY_MAX_BYTES + ) { this.completeChannel( channel, syscallNr, origArgs, undefined, -1, - EFAULT, + E2BIG, + [], + undefined, + entry, ); return; } - const existing = capturedDerefU32Inputs[desc.size.argIndex]; - if (existing !== undefined) { - if (existing.processPointer !== derefPtr) { - this.completeChannel( - channel, - syscallNr, - origArgs, - undefined, - -1, - EINVAL, - ); - return; - } - continue; - } - capturedDerefU32Inputs[desc.size.argIndex] = { - processPointer: derefPtr, - value: new DataView( - processMem.buffer, - processMem.byteOffset, - processMem.byteLength, - ).getUint32(derefPtr, true), - }; } + // WHY: guest inputs are detached into host-owned bytes during planning. + // File-backed mmap preparation and other callbacks below may re-enter + // host code, so no bytes enter shared kernel scratch until the one lease + // that also writes the header and invokes kernel_handle_channel. + } - for (const desc of argDescs) { - let argumentSizedBytes: number | undefined; - if (desc.size.type === "arg") { - const rawCount = rawArgs[desc.size.argIndex]!; - if ( - rawCount < 0n - || rawCount > BigInt(Number.MAX_SAFE_INTEGER) - ) { - this.completeChannel( - channel, - syscallNr, - origArgs, - undefined, - -1, - EINVAL, - ); - return; - } - const multiplier = desc.size.multiplier ?? 1; - const add = desc.size.add ?? 0; - argumentSizedBytes = Number(rawCount) * multiplier + add; - if ( - !Number.isSafeInteger(argumentSizedBytes) - || argumentSizedBytes < 0 - ) { - this.completeChannel( - channel, - syscallNr, - origArgs, - undefined, - -1, + let readinessTimeoutMs: number | undefined = + syscallNr === SYS_POLL ? origArgs[2] : undefined; + + // ppoll: convert timespec pointer and sigset pointer to scalar values. + // musl sends: (fds, nfds, timespec_ptr, sigset_ptr, sigset_size) + // kernel expects: (fds, nfds, timeout_ms, has_mask, mask_lo, mask_hi) + if (syscallNr === SYS_PPOLL) { + try { + const rawTimespecPointer = rawArgs[2]; + if (rawTimespecPointer !== 0n) { + // time64: timespec is {int64 sec, int64 nsec} = 16 bytes. + // WHY: ppoll's scalar conversion is outside SYSCALL_ARGS, so prove + // this caller-owned source independently before staging its values. + const range = checkedProcessMemoryViewRange( + processMem, + rawTimespecPointer, + 16, + pointerWidth, + "ppoll timeout", + ); + const pv = new DataView( + processMem.buffer, + processMem.byteOffset + range.pointer, + range.length, + ); + const sec = Number(pv.getBigInt64(0, true)); + const nsec = Number(pv.getBigInt64(8, true)); + const timeoutMs = sec * 1000 + Math.floor(nsec / 1_000_000); + readinessTimeoutMs = timeoutMs; + adjustedArgs[2] = timeoutMs; + } else { + readinessTimeoutMs = -1; + adjustedArgs[2] = -1; // infinite timeout + } + const rawMaskPointer = rawArgs[3]; + if (rawMaskPointer !== 0n) { + const rawSigsetSize = BigInt.asUintN(64, rawArgs[4] ?? 0n); + if (rawSigsetSize !== BigInt(SIGNAL_MASK_BYTES)) { + throw new KernelScratchError( + `ppoll sigset size must be ${SIGNAL_MASK_BYTES}`, EINVAL, ); - return; - } - if (argumentSizedBytes === 0) { - // WHY: a zero-length buffer lends no caller bytes, so its raw - // wasm64 pointer bits are intentionally ignored. Resolve it to a - // checked non-null kernel-scratch address under the final lease: - // Rust slices require non-null pointers even at length zero. - adjustedArgs[desc.argIndex] = 0; - plannedZeroLengthScratchArgMask |= 1 << desc.argIndex; - continue; } - } - const rawPtr = rawArgs[desc.argIndex]!; - const deferSchedGetaffinityOutputError = - syscallNr === SYS_SCHED_GETAFFINITY - && desc.argIndex === 2 - && desc.direction === "out"; - let ptr: number; - try { - ptr = checkedWasmPointer( - rawPtr, + const range = checkedProcessMemoryViewRange( + processMem, + rawMaskPointer, + SIGNAL_MASK_BYTES, pointerWidth, - `syscall ${syscallNr} arg ${desc.argIndex} pointer`, + "ppoll signal mask", ); - } catch { - if (deferSchedGetaffinityOutputError) { - // WHY: Linux resolves the selected task before copying the mask. - // Keep a lossy/invalid guest pointer out of kernel memory, but - // still dispatch through safe scratch so ESRCH can take precedence - // over the eventual EFAULT. - schedGetaffinityOutputInvalid = true; - ptr = 0; - } else { - this.completeChannel( - channel, - syscallNr, - origArgs, - undefined, - -1, - EFAULT, - ); - return; - } + const pv = new DataView( + processMem.buffer, + processMem.byteOffset + range.pointer, + range.length, + ); + adjustedArgs[3] = 1; // has_mask = true + adjustedArgs[4] = pv.getUint32(0, true); // mask_lo + adjustedArgs[5] = pv.getUint32(4, true); // mask_hi + } else { + adjustedArgs[3] = 0; // has_mask = false + adjustedArgs[4] = 0; + adjustedArgs[5] = 0; } - if (rawPtr === 0n && !deferSchedGetaffinityOutputError) { - if (desc.required === true || desc.nullable !== true) { - // WHY: every descriptor with a positive extent needs an owned - // channel subregion. Null is valid only when the shared contract - // says so explicitly; absence of `required` must not silently - // turn fixed outputs such as pipefd[2] into nullable pointers. - // Arg-sized zero-length buffers were canonicalized above. - this.completeChannel( - channel, - syscallNr, - origArgs, - undefined, - -1, - EFAULT, - ); - return; - } - continue; + } catch (error) { + this.#rejectScratchTransfer(channel, error, entry); + return; + } + } + + if ( + channel.readinessFinalCheck === true + && (syscallNr === SYS_POLL || syscallNr === SYS_PPOLL) + ) { + // The Rust poll/ppoll path sees timeout=0 and returns a real readiness + // result. For ppoll, that non-EAGAIN result also restores the saved mask. + adjustedArgs[2] = 0; + channel.readinessFinalCheck = false; + } + + let fileSharedMmapPreparation: FileSharedMmapPreparationResult | null = null; + if ( + syscallNr === SYS_MMAP + && origArgs[1] > 0 + && (origArgs[3] & MAP_SHARED) !== 0 + && (origArgs[3] & MAP_ANONYMOUS) === 0 + && origArgs[4] >= 0 + ) { + const preparation = this.prepareSharedMmapFromFile( + channel, + origArgs, + rawArgs[5] ?? 0n, + entry, + ); + if (this.hostReaped?.has(channel.pid)) return; + if (preparation.kind === "error") { + // Regular-file host setup is part of mmap. Fail before invoking the + // kernel so MAP_FIXED cannot destroy an existing interval first. + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + preparation.errno, + [], + undefined, + entry, + ); + return; + } + fileSharedMmapPreparation = preparation; + } + + try { + if (syscallNr === SYS_MREMAP) { + const preflightError = this.preflightFileSharedMremap( + channel.pid, + origArgs, + entry, + ); + if (preflightError !== 0) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + preflightError, + [], + undefined, + entry, + ); + return; } + } - // Compute size of data to copy - let size: number; - if (desc.size.type === "cstring") { - const result = cstringCopySize( - processMem, - ptr, - CH_DATA_SIZE - dataOffset, - ); - if ("errno" in result) { + try { + if (syscallNr === SYS_MMAP && (origArgs[3] & MAP_FIXED) !== 0) { + if (!this.ensureFixedMmapProcessMemoryCapacity( + channel, + origArgs, + entry, + )) { + if (fileSharedMmapPreparation?.kind === "prepared") { + this.releasePreparedSharedMmap( + fileSharedMmapPreparation.context, + entry, + ); + fileSharedMmapPreparation = null; + } this.completeChannel( channel, syscallNr, origArgs, undefined, -1, - result.errno, + ENOMEM, + [], + undefined, + entry, ); return; } - size = result.size; - } else if (desc.size.type === "arg") { - size = argumentSizedBytes!; - } else if (desc.size.type === "deref") { - // Dereference: arg is a pointer to a u32 value (e.g. socklen_t*) - const rawDerefPtr = rawArgs[desc.size.argIndex]!; - if (rawDerefPtr === 0n) { - // WHY: the outer pointer is non-null here, so its separate length - // pointer is the only source of the destination capacity. Without - // it no owned channel subregion can be planned; forwarding the - // caller pointer would cross address spaces before Rust rejects - // the malformed pair. - this.completeChannel( - channel, - syscallNr, - origArgs, - undefined, - -1, - EFAULT, - ); + // Flush the replaced mapping while its kernel interval and process + // bytes are both still intact. + const flushedReplacement = this.flushSharedMappings( + channel, + [ + origArgs[0], + alignWasmPageLength(origArgs[1]), + ], + entry, + ); + if (this.hostReaped?.has(channel.pid)) { + if (fileSharedMmapPreparation?.kind === "prepared") { + this.releasePreparedSharedMmap( + fileSharedMmapPreparation.context, + entry, + ); + fileSharedMmapPreparation = null; + } return; } - let derefPtr: number; - try { - derefPtr = this.checkedProcessRange( - channel, - rawDerefPtr, - 4, - `syscall ${syscallNr} length pointer`, - ).pointer; - } catch { + if (!flushedReplacement) { + if (fileSharedMmapPreparation?.kind === "prepared") { + this.releasePreparedSharedMmap( + fileSharedMmapPreparation.context, + entry, + ); + fileSharedMmapPreparation = null; + } this.completeChannel( channel, syscallNr, origArgs, undefined, -1, - EFAULT, + EIO, + [], + undefined, + entry, ); return; } - const captured = capturedDerefU32Inputs[desc.size.argIndex]; - if ( - captured === undefined - || captured.processPointer !== derefPtr - ) { - this.completeChannel( - channel, - syscallNr, - origArgs, - undefined, - -1, - EINVAL, + } + + } catch (err) { + this.#rethrowKernelEntryFatal(err); + if (fileSharedMmapPreparation?.kind === "prepared") { + this.releasePreparedSharedMmap( + fileSharedMmapPreparation.context, + entry, + ); + fileSharedMmapPreparation = null; + } + throw err; + } + + // The fixed channel remains the cheap path. Only a complete aligned + // footprint beyond it reserves the Rust-owned token channel. + let plannedChannelCapacity: number; + try { + plannedChannelCapacity = checkedAlignUp( + CH_DATA + dataOffset, + 8, + `syscall ${syscallNr} total channel capacity`, + ); + } catch (error) { + this.#rejectScratchTransfer(channel, error, entry); + return; + } + plannedChannelCapacity = Math.max( + CH_TOTAL_SIZE, + plannedChannelCapacity, + ); + if (plannedChannelCapacity > MAX_TRANSFER_ALLOCATION_BYTES) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + E2BIG, + [], + undefined, + entry, + ); + return; + } + let retryForbiddenByCallFlags = false; + try { + retryForbiddenByCallFlags = plannedRequestForbidsEagainRetry( + syscallNr, + adjustedArgs, + plannedScratchWrites, + ) || syscallHasMsgDontwait(syscallNr, origArgs); + } catch (error) { + this.#rejectScratchTransfer(channel, error, entry); + return; + } + this.currentHandlePid = channel.pid; + // DIAGNOSTIC: globalThis.__sysprof aggregates per-(pid,syscall_nr) + // timing across kernel_handle_channel calls so we can dump a profile + // afterward (via globalThis.__sysprofDump()). Off by default — flip on + // from the demo page right before the slow operation, off after. + // Also tracks wall-clock gap since *this* pid's previous syscall — that + // gap is the time the pid spent in user wasm code, the actual perf + // bottleneck when kernel-side handling itself is fast. + const sysprof = (globalThis as { __sysprof?: boolean }).__sysprof; + const sysprofStart = sysprof ? performance.now() : 0; + if (sysprof) { + type GapRow = { count: number; gapTotalMs: number; gapMaxMs: number }; + const g = globalThis as { + __sysprofGap?: Map; + __sysprofLastSeen?: Map; + }; + if (!g.__sysprofGap) g.__sysprofGap = new Map(); + if (!g.__sysprofLastSeen) g.__sysprofLastSeen = new Map(); + const last = g.__sysprofLastSeen.get(channel.pid); + if (last !== undefined) { + const gap = sysprofStart - last; + let row = g.__sysprofGap.get(channel.pid); + if (!row) { + row = { count: 0, gapTotalMs: 0, gapMaxMs: 0 }; + g.__sysprofGap.set(channel.pid, row); + } + row.count++; + row.gapTotalMs += gap; + if (gap > row.gapMaxMs) row.gapMaxMs = gap; + } + g.__sysprofLastSeen.set(channel.pid, sysprofStart); + } + const plannedDispatch: PlannedBlockingChannelDispatch = { + syscallNr, + adjustedArgs: adjustedArgs.slice(), + plannedZeroLengthScratchArgMask, + plannedScratchWrites: plannedScratchWrites.slice(), + plannedChannelScratchArgs: plannedChannelScratchArgs.slice(), + plannedChannelCapacity, + schedGetaffinityOutputInvalid, + retryForbiddenByCallFlags, + readinessTimeoutMs, + }; + let kernelResult: PlannedChannelDispatchResult; + try { + kernelResult = this.#executePlannedBlockingChannelDispatch( + channel, + plannedDispatch, + entry, + ); + } catch (err) { + this.#rethrowKernelEntryFatal(err); + if (fileSharedMmapPreparation?.kind === "prepared") { + this.releasePreparedSharedMmap( + fileSharedMmapPreparation.context, + entry, + ); + fileSharedMmapPreparation = null; + } + if ( + this.#kernelFatalError !== null + || + err instanceof KernelTransferExecuteTrapError + || err instanceof KernelTaskBindingError + || err instanceof KernelReentrantEntryError + ) { + throw err; + } + // If the kernel throws (e.g., invalid memory access), complete the + // channel with -EIO to unblock the process rather than deadlocking. + if (logging) console.error(logEntry + " = KERNEL THROW"); + console.error( + `[handleSyscall] kernel threw for pid=${channel.pid} syscall=${syscallNr} args=[${diagnosticArgs}]:`, + err, + ); + if (syscallNr === SYS_RT_SIGTIMEDWAIT) { + this.signalWaitDeadlines.delete( + `${channel.pid}:${channel.channelOffset}`, + ); + } + if ( + !this.#cancelHostOwnedKernelWait(channel, syscallNr, entry) + ) { + this.#failBlockingRetryProtocol( + `kernel wait cleanup failed after syscall ${syscallNr} dispatch error`, + err, + ); + } + this.completeChannelRawAndRelisten(channel, -5, 5, entry); // -EIO + return; + } finally { + this.currentHandlePid = 0; + if (sysprof) { + const elapsed = performance.now() - sysprofStart; + type ProfRow = { count: number; totalMs: number; maxMs: number }; + const g = globalThis as { __sysprofTable?: Map }; + if (!g.__sysprofTable) g.__sysprofTable = new Map(); + const key = `${channel.pid}:${syscallNr}`; + let row = g.__sysprofTable.get(key); + if (!row) { + row = { count: 0, totalMs: 0, maxMs: 0 }; + g.__sysprofTable.set(key, row); + } + row.count++; + row.totalMs += elapsed; + if (elapsed > row.maxMs) row.maxMs = elapsed; + if (elapsed > 50) { + console.warn( + `[sysprof] slow pid=${channel.pid} nr=${syscallNr} ${elapsed.toFixed(1)}ms args=[${diagnosticArgs.join(",")}]`, ); - return; } - size = captured.value; - } else if (desc.size.type === "fixed") { - size = desc.size.size; - } else { - size = pointerWidth === 8 - ? desc.size.wasm64Size - : desc.size.wasm32Size; } + } + + // Stop signal death before any host postprocessing can re-enter the kernel + // or mutate state for an execution that must never resume. + if (this.#getProcessExitSignal(channel.pid, entry) > 0) { + if (fileSharedMmapPreparation?.kind === "prepared") { + this.releasePreparedSharedMmap( + fileSharedMmapPreparation.context, + entry, + ); + fileSharedMmapPreparation = null; + } + this.#handleProcessTerminatedWithinKernelEntry(channel, entry); + return; + } + + // The kernel result and every descriptor output are host-owned now. + // Postprocessing below may run nested synthetic syscalls or wake another + // channel, so it must never retain or reread the shared allocation. + let { retVal, publicationRetVal, errVal } = kernelResult; + if ( + syscallNr === SYS_RT_SIGTIMEDWAIT && + !(retVal === -1 && errVal === EAGAIN) + ) { + this.signalWaitDeadlines.delete( + `${channel.pid}:${channel.channelOffset}`, + ); + } + if ( + syscallNr === SYS_MMAP && + fileSharedMmapPreparation?.kind === "prepared" && + retVal <= 0 + ) { + this.releasePreparedSharedMmap( + fileSharedMmapPreparation.context, + entry, + ); + fileSharedMmapPreparation = null; + } + + // MAP_FIXED's old interval was published and flushed before the kernel + // call. After success, detach its trackers before registering the new map. + if ( + syscallNr === SYS_MMAP && + retVal > 0 && + (origArgs[3] & MAP_FIXED) !== 0 + ) { + const replacementArgs = [ + retVal, + alignWasmPageLength(origArgs[1]), + ]; + this.cleanupSharedMappings( + channel.pid, + replacementArgs[0]!, + replacementArgs[1]!, + entry, + ); + } + if (syscallNr === SYS_MREMAP && retVal > 0) { + this.flushSharedMappings( + channel, + [ + origArgs[0], + alignWasmPageLength(origArgs[1]), + ], + entry, + ); + if (this.hostReaped?.has(channel.pid)) return; + } - if (!Number.isSafeInteger(size) || size < 0) { - this.completeChannel( - channel, + // --- Process memory growth for brk/mmap/mremap --- + // The kernel's ensure_memory_covers() grows the KERNEL's Wasm memory, not + // the process's. We must grow the process's + // WebAssembly.Memory here so the process can access the new addresses. + if ( + retVal > 0 + && ( + syscallNr === SYS_BRK + || syscallNr === SYS_MMAP + || syscallNr === SYS_MREMAP + ) + ) { + try { + this.ensureProcessMemoryCovers( + channel.pid, + channel.memory, syscallNr, + retVal, origArgs, - undefined, - -1, - EINVAL, + entry, ); - return; - } - if (size === 0) { - // Never leak a process-space pointer into the kernel address space, - // even when the associated count is zero. The final lease supplies - // a non-null allocator-owned empty address for Rust slice validity. - adjustedArgs[desc.argIndex] = 0; - plannedZeroLengthScratchArgMask |= 1 << desc.argIndex; - continue; - } - - // Cap size to fit in the channel data buffer. For read/write-like - // syscalls where the size comes from another arg, also update that - // arg so the kernel uses the capped count. The caller (musl libc) - // will see a short read/write and retry for the remainder. - if (dataOffset + size > CH_DATA_SIZE) { - const simpleCount = - desc.size.type === "arg" - && (desc.size.multiplier ?? 1) === 1 - && (desc.size.add ?? 0) === 0; - if (!simpleCount) { - this.completeChannel( - channel, - syscallNr, - origArgs, - undefined, - -1, - EINVAL, + } catch (err) { + this.#rethrowKernelEntryFatal(err); + if (fileSharedMmapPreparation?.kind === "prepared") { + this.releasePreparedSharedMmap( + fileSharedMmapPreparation.context, + entry, ); - return; - } - size = CH_DATA_SIZE - dataOffset; - if (desc.size.type === "arg") { - adjustedArgs[desc.size.argIndex] = size; - } - if (size === 0) { - adjustedArgs[desc.argIndex] = 0; - plannedZeroLengthScratchArgMask |= 1 << desc.argIndex; - continue; + fileSharedMmapPreparation = null; } + throw err; } + } - let processRangeValid = true; - try { - this.checkedProcessRange( - channel, - rawPtr, - size, - `syscall ${syscallNr} arg ${desc.argIndex} data`, + // --- DEBUG: detect memory operations in legacy high control pages --- + const highControlFloor = this.highControlFloorForProcess(channel.pid); + if (syscallNr === SYS_MMAP && retVal > 0) { + const mmapAddr = retVal; + const mmapLen = origArgs[1]; + if ( + highControlFloor !== null && + mmapAddr + mmapLen > highControlFloor + ) { + console.error( + `[MMAP ALERT] pid=${channel.pid} mmap returned 0x${mmapAddr.toString(16)} len=${mmapLen} — OVERLAPS THREAD REGION! args=[${diagnosticArgs.join(",")}]`, + ); + } + } + if ( + syscallNr === SYS_MREMAP && + retVal > 0 + ) { + const mremapAddr = retVal; + const mremapLen = origArgs[2]; + if ( + highControlFloor !== null && + mremapAddr + mremapLen > highControlFloor + ) { + console.error( + `[MREMAP ALERT] pid=${channel.pid} mremap returned 0x${mremapAddr.toString(16)} len=${mremapLen} — OVERLAPS THREAD REGION!`, ); - } catch { - if (deferSchedGetaffinityOutputError) { - // Linux resolves the requested task before copying its affinity - // mask. Use safe kernel scratch now, then convert a successful - // lookup to EFAULT below; an ESRCH result must take precedence. - schedGetaffinityOutputInvalid = true; - processRangeValid = false; - } else { - this.completeChannel( - channel, - syscallNr, - origArgs, - undefined, - -1, - EFAULT, - ); - return; - } } + } + if ( + highControlFloor !== null && + syscallNr === SYS_BRK && + retVal > highControlFloor + ) { + console.error( + `[BRK ALERT] pid=${channel.pid} brk returned 0x${retVal.toString(16)} — IN THREAD REGION!`, + ); + } - const scratchOffset = CH_DATA + dataOffset; - let inputBytes: Uint8Array | null = null; + // --- mmap backing: populate files and register shared-memory intervals --- + if (syscallNr === SYS_MMAP && retVal > 0) { + const mmapFd = origArgs[4]; + const mmapFlags = origArgs[3] >>> 0; if ( - processRangeValid - && (desc.direction === "in" || desc.direction === "inout") + (mmapFlags & MAP_SHARED) !== 0 && + (mmapFlags & MAP_ANONYMOUS) !== 0 ) { - const captured = capturedDerefU32Inputs[desc.argIndex]; - if (captured !== undefined) { - if (size !== 4 || captured.processPointer !== ptr) { - this.completeChannel( + this.trackAnonymousSharedMapping(channel, retVal, origArgs); + } else if (mmapFd >= 0 && (mmapFlags & MAP_ANONYMOUS) === 0) { + if ((mmapFlags & MAP_SHARED) !== 0) { + const sharedResult = + fileSharedMmapPreparation?.kind === "prepared" + ? this.registerPreparedSharedMmap( + channel, + retVal, + fileSharedMmapPreparation.context, + entry, + ) + : fileSharedMmapPreparation?.kind === "unsupported" + ? fileSharedMmapPreparation + : this.mapSharedMmapFromFile( + channel, + retVal, + origArgs, + rawArgs[5] ?? 0n, + entry, + ); + fileSharedMmapPreparation = null; + if (this.hostReaped?.has(channel.pid)) return; + if (sharedResult.kind === "unsupported") { + this.populateMmapFromFile( channel, - syscallNr, + retVal, origArgs, - undefined, - -1, - EINVAL, + rawArgs[5] ?? 0n, + entry, ); - return; + if (this.hostReaped?.has(channel.pid)) return; + } else if (sharedResult.kind === "error") { + // The kernel has already reserved the interval. Undo that + // allocation and report the host-backing failure truthfully; + // silently leaving an untracked MAP_SHARED mapping would lose + // writes and violate fd-close/fork coherence. + try { + this.runSyntheticMemorySyscall( + channel, + SYS_MUNMAP, + [ + retVal, + alignWasmPageLength(origArgs[1]), + ], + entry, + ); + if (this.hostReaped?.has(channel.pid)) return; + } catch (error) { + this.#rethrowKernelEntryFatal(error); + // Preserve the original mmap failure even if rollback itself + // cannot be completed. The guest must not observe success. + } + retVal = -1; + publicationRetVal = -1; + errVal = sharedResult.errno; } - inputBytes = new Uint8Array(4); - new DataView(inputBytes.buffer).setUint32( - 0, - captured.value, - true, - ); } else { - inputBytes = processMem.slice(ptr, ptr + size); + this.populateMmapFromFile( + channel, + retVal, + origArgs, + rawArgs[5] ?? 0n, + entry, + ); + if (this.hostReaped?.has(channel.pid)) return; + } + } + // DRI bo mmap prime: the kernel's sys_mmap on /dev/dri/{render,card} + // already called `host_gbm_bo_bind` to record metadata, but the + // actual SAB→Memory copy is deferred until here so the + // anonymous-mmap zero-fill is in place first. This is what + // delivers the parent's writes to a child across PRIME + // export → fork → PRIME import. No-op for non-DRI mmaps. + if (retVal > 0) { + const mmapAddr = retVal; + const boId = this.#kernel.bos.findBindingByAddr(channel.pid, mmapAddr); + if (boId !== undefined) { + this.#kernel.bos.primeBindFromSab(channel.pid, boId, channel.memory); } } - const plannedArg: PlannedChannelScratchArg = { - desc, - processPointer: ptr, - scratchOffset, - size, - inputBytes, - }; - plannedChannelScratchArgs.push(plannedArg); - plannedScratchWrites.push({ - argIndex: desc.argIndex, - scratchOffset, - size, - inputBytes, - }); - - // Install the allocator-selected pointer only while the final - // exclusive lease is active. - adjustedArgs[desc.argIndex] = 0; - - dataOffset += size; - // Kernel exports may dereference i64-bearing structs and scalar output - // slots directly. Keep every following allocation eight-byte aligned; - // CH_DATA itself is eight-byte aligned. - dataOffset = (dataOffset + 7) & ~7; - } - // WHY: guest inputs are detached into host-owned bytes during planning. - // File-backed mmap preparation and other callbacks below may re-enter - // host code, so no bytes enter shared kernel scratch until the one lease - // that also writes the header and invokes kernel_handle_channel. - } - - // ppoll: convert timespec pointer and sigset pointer to scalar values. - // musl sends: (fds, nfds, timespec_ptr, sigset_ptr, sigset_size) - // kernel expects: (fds, nfds, timeout_ms, has_mask, mask_lo, mask_hi) - if (syscallNr === SYS_PPOLL) { - try { - const rawTimespecPointer = rawArgs[2]; - if (rawTimespecPointer !== 0n) { - // time64: timespec is {int64 sec, int64 nsec} = 16 bytes. - // WHY: ppoll's scalar conversion is outside SYSCALL_ARGS, so prove - // this caller-owned source independently before staging its values. - const range = this.checkedProcessRange( - channel, - rawTimespecPointer, - 16, - "ppoll timeout", - ); - const pv = new DataView( - channel.memory.buffer, - range.pointer, - range.length, - ); - const sec = Number(pv.getBigInt64(0, true)); - const nsec = Number(pv.getBigInt64(8, true)); - adjustedArgs[2] = sec * 1000 + Math.floor(nsec / 1000000); - } else { - adjustedArgs[2] = -1; // infinite timeout - } - const rawMaskPointer = rawArgs[3]; - if (rawMaskPointer !== 0n) { - const range = this.checkedProcessRange( - channel, - rawMaskPointer, - SIGNAL_MASK_BYTES, - "ppoll signal mask", - ); - const pv = new DataView( - channel.memory.buffer, - range.pointer, - range.length, - ); - adjustedArgs[3] = 1; // has_mask = true - adjustedArgs[4] = pv.getUint32(0, true); // mask_lo - adjustedArgs[5] = pv.getUint32(4, true); // mask_hi - } else { - adjustedArgs[3] = 0; // has_mask = false - adjustedArgs[4] = 0; - adjustedArgs[5] = 0; - } - } catch (error) { - this.rejectScratchTransfer(channel, error); - return; } - } - if ( - channel.readinessFinalCheck === true - && (syscallNr === SYS_POLL || syscallNr === SYS_PPOLL) - ) { - // The Rust poll/ppoll path sees timeout=0 and returns a real readiness - // result. For ppoll, that non-EAGAIN result also restores the saved mask. - adjustedArgs[2] = 0; - channel.readinessFinalCheck = false; - } + // --- msync: flush MAP_SHARED regions back to file --- + if (syscallNr === SYS_MSYNC && retVal === 0) { + if (!this.flushSharedMappings(channel, origArgs, entry)) { + retVal = -1; + publicationRetVal = -1; + errVal = EIO; + } + if (this.hostReaped?.has(channel.pid)) return; + } - let fileSharedMmapPreparation: FileSharedMmapPreparationResult | null = null; - if ( - syscallNr === SYS_MMAP - && origArgs[1] > 0 - && (origArgs[3] & MAP_SHARED) !== 0 - && (origArgs[3] & MAP_ANONYMOUS) === 0 - && origArgs[4] >= 0 - ) { - const preparation = this.prepareSharedMmapFromFile(channel, origArgs); - if (this.hostReaped?.has(channel.pid)) return; - if (preparation.kind === "error") { - // Regular-file host setup is part of mmap. Fail before invoking the - // kernel so MAP_FIXED cannot destroy an existing interval first. - this.completeChannel( - channel, - syscallNr, - origArgs, - undefined, - -1, - preparation.errno, + // --- munmap: flush + clean up shared mapping tracking --- + if (syscallNr === SYS_MUNMAP && retVal === 0) { + const unmapArgs = [ + origArgs[0], + alignWasmPageLength(origArgs[1]), + ]; + this.flushSharedMappings(channel, unmapArgs, entry); + if (this.hostReaped?.has(channel.pid)) return; + this.cleanupSharedMappings( + channel.pid, + unmapArgs[0]!, + unmapArgs[1]!, + entry, ); - return; } - fileSharedMmapPreparation = preparation; - } - try { - if (syscallNr === SYS_MREMAP) { - const preflightError = this.preflightFileSharedMremap( + if (syscallNr === SYS_MREMAP && retVal > 0) { + this.remapSharedMapping( + channel.pid, + origArgs[0], + retVal, + origArgs[2], + entry, + ); + } + if (syscallNr === SYS_MPROTECT && retVal === 0) { + this.updateSharedMappingProtection( channel.pid, + origArgs[0], + alignWasmPageLength(origArgs[1]), + (origArgs[2] & PROT_WRITE) !== 0, + ); + } + + if ((this.sharedMmapBackings?.size ?? 0) > 0) { + this.handleSharedMappingsAfterFileSyscall( + channel, + syscallNr, origArgs, + retVal, + errVal, + syscallNr === SYS_PWRITE ? rawArgs[3] : undefined, + syscallNr === SYS_FTRUNCATE || syscallNr === SYS_TRUNCATE + ? rawArgs[1] + : undefined, + entry, ); - if (preflightError !== 0) { - this.completeChannel( - channel, - syscallNr, - origArgs, - undefined, - -1, - preflightError, - ); - return; - } + if (this.hostReaped?.has(channel.pid)) return; } - try { - if (syscallNr === SYS_MMAP && (origArgs[3] & MAP_FIXED) !== 0) { - if (!this.ensureFixedMmapProcessMemoryCapacity(channel, origArgs)) { - if (fileSharedMmapPreparation?.kind === "prepared") { - this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); - fileSharedMmapPreparation = null; - } - this.completeChannel( - channel, - syscallNr, - origArgs, - undefined, - -1, - ENOMEM, - ); - return; - } - // Flush the replaced mapping while its kernel interval and process - // bytes are both still intact. - const flushedReplacement = this.flushSharedMappings(channel, [ - origArgs[0], - alignWasmPageLength(origArgs[1]), - ]); - if (this.hostReaped?.has(channel.pid)) { - if (fileSharedMmapPreparation?.kind === "prepared") { - this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); - fileSharedMmapPreparation = null; - } - return; - } - if (!flushedReplacement) { - if (fileSharedMmapPreparation?.kind === "prepared") { - this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); - fileSharedMmapPreparation = null; - } - this.completeChannel( + // --- POSIX mqueue notification --- + // After mq_timedsend, the kernel may have a pending notification (signal + // to deliver when a message arrives on a previously empty queue). + const routedMqNotification = + syscallNr === SYS_MQ_TIMEDSEND && retVal === 0; + if (routedMqNotification) { + this.drainMqueueNotification(entry); + if (this.#finishSignalTermination(channel, entry)) return; + } + + // --- Signal delivery --- + // After each syscall, check if the kernel has a pending Handler signal. + // If so, dequeue it and write delivery info to the process channel. + // The glue code (channel_syscall.c) will invoke the handler after waking. + // Dequeue carries the exact kernel-owned TID explicitly, so notification + // routing cannot leak one channel's ambient task context into another. + const isPendingInetConnect = this.#isPendingInetConnect( + syscallNr, + retVal, + errVal, + plannedDispatch, + ); + const isRetryResult = + retVal === -1 + && ( + errVal === EAGAIN + || errVal === EINPROGRESS + || errVal === EALREADY + ); + const retryDisposition = + isRetryResult + && ( + syscallNr !== SYS_CONNECT + || errVal === EAGAIN + || isPendingInetConnect + ) + ? this.#captureBlockingRetryDisposition( channel, syscallNr, origArgs, - undefined, - -1, - EIO, - ); - return; - } - } + entry, + plannedDispatch.retryForbiddenByCallFlags, + ) + : undefined; + const deliveredSignal = this.#dequeueSignalForDelivery( + channel, + entry, + (retryDisposition?.applicableSocketTimeoutMs ?? 0) > 0, + ); + if ( + routedMqNotification + && this.#finishSignalTermination(channel, entry) + ) return; - } catch (err) { - if (fileSharedMmapPreparation?.kind === "prepared") { - this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); - fileSharedMmapPreparation = null; - } - throw err; + // --- Blocking syscall handling --- + // Host-delegated AF_INET connect has its own public pending errnos. Keep + // EINPROGRESS/EALREADY visible to non-blocking callers, while blocking + // callers remain parked in the same host-owned retry loop as EAGAIN. + // The sockaddr-family guard deliberately excludes AF_UNIX from this + // transport-specific retry rule. + if ( + this.handlePendingInetConnect( + channel, + syscallNr, + origArgs, + retVal, + errVal, + argDescs, + plannedDispatch, + entry, + undefined, + deliveredSignal, + retryDisposition, + ) + ) { + return; } - // Call kernel_handle_channel through the active scratch lease. - try { - this.bindKernelTidForChannel(channel); - } catch (err) { - if (fileSharedMmapPreparation?.kind === "prepared") { - this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); - fileSharedMmapPreparation = null; - } - throw err; + // flock shares Rust's advisory-lock wake contract but not fcntl's + // struct-flock marshalling path. LOCK_NB is a public EAGAIN result; + // only the blocking form may be parked for a lock-state wake. + if ( + this.handleFlockConflict( + channel, + syscallNr, + origArgs, + retVal, + errVal, + deliveredSignal, + argDescs, + plannedDispatch, + entry, + ) + ) { + return; } - this.currentHandlePid = channel.pid; - // DIAGNOSTIC: globalThis.__sysprof aggregates per-(pid,syscall_nr) - // timing across kernel_handle_channel calls so we can dump a profile - // afterward (via globalThis.__sysprofDump()). Off by default — flip on - // from the demo page right before the slow operation, off after. - // Also tracks wall-clock gap since *this* pid's previous syscall — that - // gap is the time the pid spent in user wasm code, the actual perf - // bottleneck when kernel-side handling itself is fast. - const sysprof = (globalThis as { __sysprof?: boolean }).__sysprof; - const sysprofStart = sysprof ? performance.now() : 0; - if (sysprof) { - type GapRow = { count: number; gapTotalMs: number; gapMaxMs: number }; - const g = globalThis as { - __sysprofGap?: Map; - __sysprofLastSeen?: Map; - }; - if (!g.__sysprofGap) g.__sysprofGap = new Map(); - if (!g.__sysprofLastSeen) g.__sysprofLastSeen = new Map(); - const last = g.__sysprofLastSeen.get(channel.pid); - if (last !== undefined) { - const gap = sysprofStart - last; - let row = g.__sysprofGap.get(channel.pid); - if (!row) { - row = { count: 0, gapTotalMs: 0, gapMaxMs: 0 }; - g.__sysprofGap.set(channel.pid, row); - } - row.count++; - row.gapTotalMs += gap; - if (gap > row.gapMaxMs) row.gapMaxMs = gap; + + // 1. EAGAIN: kernel returned EAGAIN for a blocking syscall. + // Schedule async retry — the process stays blocked on Atomics.wait. + if (retVal === -1 && errVal === EAGAIN) { + if (logging) { + console.error(logEntry + " = -1 (EAGAIN, will retry)"); } - g.__sysprofLastSeen.set(channel.pid, sysprofStart); - } - let kernelResult: { - retVal: number; - errVal: number; - outputWrites: ChannelOutputWrite[]; - sleepDelayMs: number | undefined; - }; - try { - kernelResult = this.requireMainScratchRegion().withLease((lease) => { - const kernelView = lease.dataView(0, CH_TOTAL_SIZE); - kernelView.setUint32(CH_SYSCALL, syscallNr, true); - for (let i = 0; i < CH_ARGS_COUNT; i++) { - kernelView.setBigInt64( - CH_ARGS + i * CH_ARG_SIZE, - BigInt(adjustedArgs[i]), - true, - ); - } - if (plannedZeroLengthScratchArgMask !== 0) { - for (let argIndex = 0; argIndex < CH_ARGS_COUNT; argIndex++) { - if ((plannedZeroLengthScratchArgMask & (1 << argIndex)) !== 0) { - // WHY: encode the lease-scoped primitive immediately. Keeping - // it in adjustedArgs would leave a valid-looking address in an - // outer array after this lease has released the allocation. - lease.writeAddress( - CH_ARGS + argIndex * CH_ARG_SIZE, - CH_DATA, - 0, - "u64-le", - ); - } - } - } - for (const write of plannedScratchWrites) { - if (write.inputBytes) { - lease.copyFrom( - write.inputBytes, - write.scratchOffset, - 0, - write.size, - ); - } else { - lease.fill(0, write.scratchOffset, write.size); - } - lease.writeAddress( - CH_ARGS + write.argIndex * CH_ARG_SIZE, - write.scratchOffset, - write.size, - "u64-le", + if (GENERIC_BLOCKING_SNAPSHOT_SYSCALLS.has(syscallNr)) { + if (!retryDisposition) { + this.#failBlockingRetryProtocol( + `syscall ${syscallNr} EAGAIN has no frozen blocking disposition`, ); } - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - channel.pid, - ]); - const rawRetVal = kernelView.getBigInt64(CH_RETURN, true); - let { retVal, errVal } = this.normalizeKernelSyscallResult( - channel, - syscallNr, - rawRetVal, - kernelView.getUint32(CH_ERRNO, true), - ); if ( - syscallNr === SYS_SCHED_GETAFFINITY - && schedGetaffinityOutputInvalid - && retVal >= 0 - ) { - retVal = -1; - errVal = EFAULT; - } - let sleepDelayMs: number | undefined; - if ( - retVal >= 0 - && ( - syscallNr === SYS_NANOSLEEP - || syscallNr === SYS_CLOCK_NANOSLEEP + !this.#rememberBlockingRetrySnapshot( + channel, + { + ...retryDisposition, + kind: "generic-channel", + syscallNr, + origArgs: origArgs.slice(), + argDescs, + dispatch: plannedDispatch, + retryToken: 0n, + }, + entry, ) - ) { - const timespec = lease.dataView(CH_DATA, 12); - const sec = timespec.getUint32(0, true); - const nsec = timespec.getUint32(8, true); - sleepDelayMs = - sec * 1000 + Math.floor(nsec / 1_000_000); - } - // Detach every output while this exact lease is active. Passing the - // lease to a helper would obscure that no allocation-backed view or - // address survives the synchronous callback. - const outputWrites: ChannelOutputWrite[] = []; - for (const planned of plannedChannelScratchArgs) { - const { desc } = planned; - if (desc.direction !== "out" && desc.direction !== "inout") { - continue; - } - // Pure output is unspecified on failure; preserve caller bytes. - if (desc.direction === "out" && retVal < 0) continue; - - let copySize = planned.size; - if (desc.direction === "out" && desc.size.type === "arg") { - copySize = Math.min(retVal, copySize); - } - if (copySize <= 0) continue; - - const bytes = lease.copyOut(planned.scratchOffset, copySize); - outputWrites.push({ ptr: planned.processPointer, bytes }); + ) return; + if (retryDisposition.retryForbiddenByCallFlags) { + // The first pass created a stable Rust pin before returning + // EAGAIN. Complete through the normal boundary so that exact pin + // is released even though the request's immutable flags forbid + // parking a retry. + this.completeChannel( + channel, + syscallNr, + origArgs, + argDescs, + -1, + EAGAIN, + kernelResult.outputWrites, + undefined, + entry, + ); + return; } - return { - retVal, - errVal, - outputWrites, - sleepDelayMs, - }; - }); - } catch (err) { - if (fileSharedMmapPreparation?.kind === "prepared") { - this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); - fileSharedMmapPreparation = null; } - // If the kernel throws (e.g., invalid memory access), complete the - // channel with -EIO to unblock the process rather than deadlocking. - if (logging) console.error(logEntry + " = KERNEL THROW"); - console.error( - `[handleSyscall] kernel threw for pid=${channel.pid} syscall=${syscallNr} args=[${origArgs}]:`, - err, + this.handleBlockingRetry( + channel, + syscallNr, + origArgs, + kernelResult.outputWrites, + entry, + false, + deliveredSignal, ); - if (syscallNr === SYS_RT_SIGTIMEDWAIT) { - this.signalWaitDeadlines.delete( - `${channel.pid}:${channel.channelOffset}`, - ); - } - this.completeChannelRaw(channel, -5, 5); // -EIO - this.relistenChannel(channel); return; - } finally { - this.currentHandlePid = 0; - if (sysprof) { - const elapsed = performance.now() - sysprofStart; - type ProfRow = { count: number; totalMs: number; maxMs: number }; - const g = globalThis as { __sysprofTable?: Map }; - if (!g.__sysprofTable) g.__sysprofTable = new Map(); - const key = `${channel.pid}:${syscallNr}`; - let row = g.__sysprofTable.get(key); - if (!row) { - row = { count: 0, totalMs: 0, maxMs: 0 }; - g.__sysprofTable.set(key, row); - } - row.count++; - row.totalMs += elapsed; - if (elapsed > row.maxMs) row.maxMs = elapsed; - if (elapsed > 50) { - console.warn( - `[sysprof] slow pid=${channel.pid} nr=${syscallNr} ${elapsed.toFixed(1)}ms args=[${origArgs.join(",")}]`, - ); - } - } } - // Stop signal death before any host postprocessing can re-enter the kernel - // or mutate state for an execution that must never resume. - if (this.getProcessExitSignal(channel.pid) > 0) { - if (fileSharedMmapPreparation?.kind === "prepared") { - this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); - fileSharedMmapPreparation = null; - } - this.handleProcessTerminated(channel); + // 2. Sleep syscalls: kernel returned success immediately, but we need + // to delay the response to simulate the sleep duration. + if ( + this.handleSleepDelay( + channel, + syscallNr, + origArgs, + retVal, + errVal, + kernelResult.sleepDelayMs, + kernelResult.outputWrites, + entry, + ) + ) { return; } - // The kernel result and every descriptor output are host-owned now. - // Postprocessing below may run nested synthetic syscalls or wake another - // channel, so it must never retain or reread the shared allocation. - let { retVal, errVal } = kernelResult; + // --- Process group change: re-check deferred waitpid calls --- + // When a process changes its pgid (setpgid/setsid), a parent blocked in + // waitpid(-pgid) may no longer have any matching children. Wake it with ECHILD. if ( - syscallNr === SYS_RT_SIGTIMEDWAIT && - !(retVal === -1 && errVal === EAGAIN) + errVal === 0 && + (syscallNr === SYS_SETPGID || syscallNr === SYS_SETSID) ) { - this.signalWaitDeadlines.delete( - `${channel.pid}:${channel.channelOffset}`, + this.recheckDeferredWaitpids(entry); + } + + // --- Signal generation: wake blocked peers + reap terminating actions --- + // kill(), tkill()/pthread_kill(), and rt_sigqueueinfo() can all target a + // thread parked in a host-owned blocking operation. They can also apply + // process-wide stop/continue/terminate actions synchronously. + // + // Two follow-ups are required: + // (a) Wake any blocked syscalls on the target (pipe/poll/select) so + // their handlers observe the new exit state and complete with the + // right errno (handled by scheduleWakeBlockedRetries). + // (b) For any process the kernel marked Exited but that is still + // blocked in a non-blocking-retry path (most importantly + // pendingSleeps), call handleProcessTerminated directly so the + // parent's wait4 actually sees the killed child. Without this, + // a `kill` of a sleeping child can leave the parent blocked even + // though Rust has marked the child as an Exited zombie. + if ( + errVal === 0 && + (syscallNr === SYS_KILL || + syscallNr === SYS_TKILL || + syscallNr === SYS_RT_SIGQUEUEINFO) + ) { + // Apply STOPPED/CONTINUED transitions before waking a target's deferred + // syscall. `kill` has no descriptor output, so this scratch reuse is safe. + this.#drainAndProcessWakeupEventsWithinKernelEntry(entry); + this.scheduleWakeBlockedRetries(entry); + this.reapKilledProcessesAfterSyscall(entry); + if (syscallNr === SYS_TKILL) { + this.interruptPendingFutexForCaughtSignal( + channel.pid, + origArgs[1] >>> 0, + entry, + origArgs[0] >>> 0, + ); + this.wakePendingSignalWaits( + channel.pid, + origArgs[1] >>> 0, + origArgs[0] >>> 0, + ); + this.interruptWaitingChildForDirectedSignal( + channel.pid, + origArgs[0], + entry, + ); + } else { + const targetSelector = origArgs[0]; + if (targetSelector > 0) { + this.interruptPendingFutexForCaughtSignal( + targetSelector, + origArgs[1] >>> 0, + entry, + ); + } else { + // Process-group and broadcast signals have no single target PID + // in the request. Ask Rust independently for each pid that has a + // host-owned futex; non-target processes report no deliverable TID. + const pendingPids = new Set( + Array.from(this.pendingFutexWaits.keys(), (candidate) => + candidate.pid + ), + ); + for (const pendingPid of pendingPids) { + this.interruptPendingFutexForCaughtSignal( + pendingPid, + origArgs[1] >>> 0, + entry, + ); + } + } + this.interruptWaitingChildrenForGeneratedSignal( + origArgs[1], + entry, + ); + } + } + + // --- Normal completion --- + if (logging) { + console.error( + logEntry + + this.formatSyscallReturn( + syscallNr, + publicationRetVal, + errVal, + ), + ); + } + this.completeChannel( + channel, + syscallNr, + origArgs, + argDescs, + publicationRetVal, + errVal, + kernelResult.outputWrites, + undefined, + entry, + ); + } catch (err) { + this.#rethrowKernelEntryFatal(err); + if (fileSharedMmapPreparation?.kind === "prepared") { + this.releasePreparedSharedMmap( + fileSharedMmapPreparation.context, + entry, + ); + fileSharedMmapPreparation = null; + } + throw err; + } + } + + /** + * Dequeue one pending Handler signal from the kernel and write delivery + * info to the process channel. The glue code (channel_syscall.c) reads + * this after the syscall returns and invokes the handler. Returns the + * handler signal number, or zero when no caught handler was dequeued. + */ + #dequeueSignalForDelivery( + channel: ChannelInfo, + entry?: KernelWorkerEntryContext, + suppressRestart = false, + ): number { + const requestFlags = this.activeChannelRequests.get(channel)?.requestFlags + ?? new DataView( + channel.memory.buffer, + channel.channelOffset, + ).getUint32(CH_REQUEST_FLAGS, true); + if ( + (requestFlags & CH_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY) !== 0 + ) { + // WHY: process-worker JavaScript consumes this completion outside + // libc's post-syscall signal trampoline. Dequeuing here would consume + // the kernel signal and block it for a handler that this completion can + // never invoke. Leave it pending for the explicit guest checkpoint after + // the owning fork, clone, or staged-loader transition. + return 0; + } + const preparedSignals = this.resumePreparedSignals; + if (preparedSignals?.has(channel)) { + const channelView = new DataView( + channel.memory.buffer, + channel.channelOffset, + ); + const existingSignal = channelView.getUint32(CH_SIG_SIGNUM, true); + if (existingSignal > 0 && suppressRestart) { + channelView.setUint32( + CH_SIG_FLAGS, + channelView.getUint32(CH_SIG_FLAGS, true) + & ~SIGNAL_ACTION_RESTART, + true, + ); + } + if (existingSignal > 0) return existingSignal; + // The channel was retired or the guest consumed the record without a + // normal publication path. Do not suppress a genuinely new signal. + preparedSignals.delete(channel); + } + + const dequeueSignal = this.#kernelInstanceForEntry(entry).exports.kernel_dequeue_signal as + (( + pid: number, + tid: number, + outPtr: KernelPointer, + outCapacity: number, + ) => number) | undefined; + if (!dequeueSignal) return 0; + + const tid = this.guestTidForChannel(channel); + // Copy the fixed signal record to host-owned bytes before releasing the + // region. Completion can synchronously wake another channel and reuse it. + const snapshot = this.#requireMainScratchRegion().withLease((lease) => { + const sigResult = this.#invokeEntryScratchExport( + entry, + lease, + "kernel_dequeue_signal", + [ + channel.pid, + tid, + lease.exportPointer( + CH_SIG_BASE, + KERNEL_SCRATCH_SIGNAL_DELIVERY_BYTES, + ), + KERNEL_SCRATCH_SIGNAL_DELIVERY_BYTES, + ], + ); + return { + sigResult, + bytes: sigResult > 0 + ? lease.copyOut( + CH_SIG_BASE, + KERNEL_SCRATCH_SIGNAL_DELIVERY_BYTES, + ) + : new Uint8Array(0), + }; + }); + const { sigResult } = snapshot; + if (sigResult < 0) { + throw new KernelTaskBindingError( + channel.pid, + tid, + -sigResult, + `Kernel rejected signal dequeue for tid ${tid} in process ${channel.pid}`, + ); + } + if (sigResult > 0) { + if (suppressRestart) { + // WHY: CH_SIG carries the effective action flags for this one + // interruption. A socket deadline makes Linux/POSIX restart + // inapplicable, so clear only SA_RESTART in the host-owned dequeue + // snapshot before exposing any bytes to the shared guest mailbox. + const flagsOffset = CH_SIG_FLAGS - CH_SIG_BASE; + const signalView = new DataView( + snapshot.bytes.buffer, + snapshot.bytes.byteOffset, + snapshot.bytes.byteLength, + ); + signalView.setUint32( + flagsOffset, + signalView.getUint32(flagsOffset, true) + & ~SIGNAL_ACTION_RESTART, + true, ); } - if ( - syscallNr === SYS_MMAP && - fileSharedMmapPreparation?.kind === "prepared" && - retVal <= 0 - ) { - this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); - fileSharedMmapPreparation = null; - } + // Copy the complete generated signal-delivery wire into its reserved + // process-channel slot. + const processMem = new Uint8Array(channel.memory.buffer); + processMem.set( + snapshot.bytes, + channel.channelOffset + CH_SIG_BASE, + ); + return sigResult; + } else { + // Clear the complete reserved area, including its trailing pad. + const sigStart = channel.channelOffset + CH_SIG_BASE; + new Uint8Array( + channel.memory.buffer, + sigStart, + CH_SIG_AREA_SIZE, + ).fill(0); + return 0; + } + } - // MAP_FIXED's old interval was published and flushed before the kernel - // call. After success, detach its trackers before registering the new map. + /** + * Complete a syscall by copying output data and notifying the process. + */ + private completeChannel( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + argDescs: SyscallArgDesc[] | undefined, + retVal: ChannelScalarValue, + errVal: number, + detachedOutput?: ChannelOutputWrite[], + deferredClone?: PreparedChannelCompletion["deferredClone"], + entry?: KernelWorkerEntryContext, + ): void { + // Completion is the logical lifetime boundary even when publication is + // parked behind SIGSTOP or observed only by a test hook. + this.#releaseBlockingRetrySnapshot(channel, entry); + this.#finishChannelRequest(channel); + const testHook = this.#scratchBoundaryTestHooks?.completeChannel; + if (testHook) { + // The test observer receives detached value state only. Entry authority + // remains lexical to this method and is never stored in the hook record. + // WHY: this method owns an entry capability, so every positional value + // must remain an explicit lexical parameter. Reading `arguments` would + // let a later edit recover or store `entry` outside the audited edge. + // Internal calls thread `entry` in the trailing slot, which also proves + // that the optional detached-output slot was explicitly part of the call. + const suppliedDetachedOutput = detachedOutput; if ( - syscallNr === SYS_MMAP && - retVal > 0 && - (origArgs[3] & MAP_FIXED) !== 0 + !( + entry !== undefined + && Array.isArray(suppliedDetachedOutput) + && suppliedDetachedOutput.length === 0 + && (retVal === -1 || argDescs === undefined) + ) + && ( + entry !== undefined + || deferredClone !== undefined + || suppliedDetachedOutput !== undefined + ) ) { - const replacementArgs = [ + testHook( + channel, + syscallNr, + origArgs, + argDescs, retVal, - alignWasmPageLength(origArgs[1]), - ]; - this.cleanupSharedMappings( - channel.pid, - replacementArgs[0]!, - replacementArgs[1]!, + errVal, + suppliedDetachedOutput, + ); + } else { + testHook( + channel, + syscallNr, + origArgs, + argDescs, + retVal, + errVal, ); } - if (syscallNr === SYS_MREMAP && retVal > 0) { - this.flushSharedMappings(channel, [ - origArgs[0], - alignWasmPageLength(origArgs[1]), - ]); - if (this.hostReaped?.has(channel.pid)) return; - } + return; + } + // WHY: only bytes detached while the allocation lease was active may + // cross this completion boundary. Re-reading shared scratch here would let + // a signal, retry, timeout, or teardown copy bytes from another operation. + void syscallNr; + void origArgs; + void argDescs; + const prepared: PreparedChannelCompletion = { + kind: "marshalled", + outputWrites: detachedOutput ?? [], + retVal, + errVal, + materialized: false, + relistenRequested: true, + deferredClone, + }; - // --- Process memory growth for brk/mmap/mremap --- - // The kernel's ensure_memory_covers() grows the KERNEL's Wasm memory, not - // the process's. We must grow the process's - // WebAssembly.Memory here so the process can access the new addresses. - if (retVal > 0) { - try { - this.ensureProcessMemoryCovers( - channel.pid, - channel.memory, - syscallNr, - retVal, - origArgs, - ); - } catch (err) { - if (fileSharedMmapPreparation?.kind === "prepared") { - this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); - fileSharedMmapPreparation = null; - } - throw err; - } - } + // Output and shared backing belong to the completed syscall before any + // lifecycle observer is released. A STOPPED wake can synchronously finish + // the parent's wait and let that Worker import the same backing; delaying + // materialization until after wake processing would expose stale bytes. + // The stopped child's mailbox status/notification remains parked below. + this.materializePreparedChannelCompletion(channel, prepared, entry); - // --- DEBUG: detect memory operations in legacy high control pages --- - const highControlFloor = this.highControlFloorForProcess(channel.pid); - if (syscallNr === SYS_MMAP && retVal > 0) { - const mmapAddr = retVal; - const mmapLen = origArgs[1]; - if ( - highControlFloor !== null && - mmapAddr + mmapLen > highControlFloor - ) { - console.error( - `[MMAP ALERT] pid=${channel.pid} mmap returned 0x${mmapAddr.toString(16)} len=${mmapLen} — OVERLAPS THREAD REGION! args=[${origArgs.map((a) => `${a < 0 ? "-" : ""}0x${Math.abs(a).toString(16)}`).join(",")}]`, - ); - } - } - if ( - syscallNr === SYS_MREMAP && - retVal > 0 - ) { - const mremapAddr = retVal; - const mremapLen = origArgs[2]; - if ( - highControlFloor !== null && - mremapAddr + mremapLen > highControlFloor - ) { - console.error( - `[MREMAP ALERT] pid=${channel.pid} mremap returned 0x${mremapAddr.toString(16)} len=${mremapLen} — OVERLAPS THREAD REGION!`, - ); - } - } - if ( - highControlFloor !== null && - syscallNr === SYS_BRK && - retVal > highControlFloor - ) { - console.error( - `[BRK ALERT] pid=${channel.pid} brk returned 0x${retVal.toString(16)} — IN THREAD REGION!`, - ); - } + // The syscall is logically complete even if publication must wait for a + // future SIGCONT. Retire one-shot timeout/deadline state now so no second + // completion can race the parked one. + this.clearSocketTimeout(channel); + this.clearReadinessWait(channel); - // --- mmap backing: populate files and register shared-memory intervals --- - if (syscallNr === SYS_MMAP && retVal > 0) { - const mmapFd = origArgs[4]; - const mmapFlags = origArgs[3] >>> 0; - if ( - (mmapFlags & MAP_SHARED) !== 0 && - (mmapFlags & MAP_ANONYMOUS) !== 0 - ) { - this.trackAnonymousSharedMapping(channel, retVal, origArgs); - } else if (mmapFd >= 0 && (mmapFlags & MAP_ANONYMOUS) === 0) { - if ((mmapFlags & MAP_SHARED) !== 0) { - const sharedResult = - fileSharedMmapPreparation?.kind === "prepared" - ? this.registerPreparedSharedMmap( - channel, - retVal, - fileSharedMmapPreparation.context, - ) - : fileSharedMmapPreparation?.kind === "unsupported" - ? fileSharedMmapPreparation - : this.mapSharedMmapFromFile(channel, retVal, origArgs); - fileSharedMmapPreparation = null; - if (this.hostReaped?.has(channel.pid)) return; - if (sharedResult.kind === "unsupported") { - this.populateMmapFromFile(channel, retVal, origArgs); - if (this.hostReaped?.has(channel.pid)) return; - } else if (sharedResult.kind === "error") { - // The kernel has already reserved the interval. Undo that - // allocation and report the host-backing failure truthfully; - // silently leaving an untracked MAP_SHARED mapping would lose - // writes and violate fd-close/fork coherence. - try { - this.runSyntheticMemorySyscall(channel, SYS_MUNMAP, [ - retVal, - alignWasmPageLength(origArgs[1]), - ]); - if (this.hostReaped?.has(channel.pid)) return; - } catch { - // Preserve the original mmap failure even if rollback itself - // cannot be completed. The guest must not observe success. - } - retVal = -1; - errVal = sharedResult.errno; - } - } else { - this.populateMmapFromFile(channel, retVal, origArgs); - if (this.hostReaped?.has(channel.pid)) return; - } - } - // DRI bo mmap prime: the kernel's sys_mmap on /dev/dri/{render,card} - // already called `host_gbm_bo_bind` to record metadata, but the - // actual SAB→Memory copy is deferred until here so the - // anonymous-mmap zero-fill is in place first. This is what - // delivers the parent's writes to a child across PRIME - // export → fork → PRIME import. No-op for non-DRI mmaps. - if (retVal > 0) { - const mmapAddr = retVal; - const boId = this.kernel.bos.findBindingByAddr(channel.pid, mmapAddr); - if (boId !== undefined) { - this.kernel.bos.primeBindFromSab(channel.pid, boId, channel.memory); - } - } + // Drain PTY output buffers before notifying the process — slave writes + // produce data in the PTY output_buf that needs to reach the host (xterm.js). + this.drainAllPtyOutputs(entry); + + // Flush TCP send pipes before notifying the process — gets PHP's + // response data to the browser without waiting for the next pump cycle + this.flushTcpSendPipes(channel.pid, entry); + + // This consumes process STOPPED/CONTINUED transitions before deciding + // whether CH_STATUS may be published. + if (entry) { + this.#drainAndProcessWakeupEventsWithinKernelEntry(entry); + } else { + this.drainAndProcessWakeupEvents(); + } + if (entry) { + // WHY: Atomics.notify releases guest execution and relistening can invoke + // host scheduling hooks. The kernel wake snapshot is already owned, so + // publish only after the scoped capability is revoked. + entry.deferProtocolEffect(() => { + this.publishOrParkChannelCompletion(channel, prepared); + }); + } else { + this.publishOrParkChannelCompletion(channel, prepared); + } + } + + private publishOrParkChannelCompletion( + channel: ChannelInfo, + prepared: PreparedChannelCompletion, + ): void { + // WHY: this function is the detached/publication phase. Every variable + // byte and shared-backing update must already be materialized while the + // exact kernel entry scope is live; publishing an unmaterialized result + // would let a host callback recover scratch-backed work after revocation. + if (!prepared.materialized) { + throw new Error( + `Refusing to publish unmaterialized completion for pid ${channel.pid}`, + ); + } + if ( + this.stoppedPids?.has(channel.pid) && + this.isRegisteredChannel(channel) + ) { + const parkedCompletions = (this.parkedChannelCompletions ??= new Map()); + const existing = parkedCompletions.get(channel); + if (existing) { + existing.relistenRequested ||= prepared.relistenRequested; + return; } + channel.handling = true; + this.deferredStoppedChannels?.delete(channel); + parkedCompletions.set(channel, { + prepared, + relistenRequested: prepared.relistenRequested, + }); + return; + } + + this.publishPreparedChannelCompletion(channel, prepared); + } + + private publishPreparedChannelCompletion( + channel: ChannelInfo, + prepared: PreparedChannelCompletion, + ): void { + if (!prepared.materialized) { + throw new Error( + `Refusing to publish unmaterialized completion for pid ${channel.pid}`, + ); + } + + channel.handling = false; + const buffer = kernelEntryMemoryBuffer(channel.memory); + const processView = new KernelEntryIntrinsicDataView( + buffer, + channel.channelOffset, + ); + kernelEntryIntrinsicApply( + kernelEntryIntrinsicDataViewSetBigInt64, + processView, + [CH_RETURN, KernelEntryIntrinsicBigInt(prepared.retVal), true], + ); + kernelEntryIntrinsicApply( + kernelEntryIntrinsicDataViewSetUint32, + processView, + [CH_ERRNO, prepared.errVal, true], + ); - // --- msync: flush MAP_SHARED regions back to file --- - if (syscallNr === SYS_MSYNC && retVal === 0) { - if (!this.flushSharedMappings(channel, origArgs)) { - retVal = -1; - errVal = EIO; - } - if (this.hostReaped?.has(channel.pid)) return; - } + // The copied signal record now belongs to the guest. A later syscall may + // dequeue another signal after this boundary has actually been observed. + this.resumePreparedSignals?.delete(channel); + const i32View = new KernelEntryIntrinsicInt32Array( + buffer, + channel.channelOffset, + ); + const statusIndex = CH_STATUS / KERNEL_ENTRY_I32_BYTES; + kernelEntryIntrinsicApply( + kernelEntryIntrinsicAtomicsStore, + kernelEntryIntrinsicAtomics, + [i32View, statusIndex, CH_COMPLETE], + ); + kernelEntryIntrinsicApply( + kernelEntryIntrinsicAtomicsNotify, + kernelEntryIntrinsicAtomics, + [i32View, statusIndex, 1], + ); + if (prepared.relistenRequested && this.isRegisteredChannel(channel)) { + this.relistenChannel(channel); + } + } - // --- munmap: flush + clean up shared mapping tracking --- - if (syscallNr === SYS_MUNMAP && retVal === 0) { - const unmapArgs = [ - origArgs[0], - alignWasmPageLength(origArgs[1]), - ]; - this.flushSharedMappings(channel, unmapArgs); - if (this.hostReaped?.has(channel.pid)) return; - this.cleanupSharedMappings(channel.pid, unmapArgs[0]!, unmapArgs[1]!); - } + private materializePreparedChannelCompletion( + channel: ChannelInfo, + prepared: PreparedChannelCompletion, + entry?: KernelWorkerEntryContext, + ): void { + if (prepared.materialized) return; - if (syscallNr === SYS_MREMAP && retVal > 0) { - this.remapSharedMapping( - channel.pid, - origArgs[0], - retVal, - origArgs[2], - ); - } - if (syscallNr === SYS_MPROTECT && retVal === 0) { - this.updateSharedMappingProtection( - channel.pid, - origArgs[0], - alignWasmPageLength(origArgs[1]), - (origArgs[2] & PROT_WRITE) !== 0, - ); - } + const processMem = new Uint8Array(channel.memory.buffer); + for (const write of prepared.outputWrites) { + processMem.set(write.bytes, write.ptr); + } + prepared.outputWrites = []; - if ((this.sharedMmapBackings?.size ?? 0) > 0) { - this.handleSharedMappingsAfterFileSyscall( - channel, - syscallNr, - origArgs, - retVal, - errVal, - syscallNr === SYS_PWRITE ? rawArgs[3] : undefined, - ); - if (this.hostReaped?.has(channel.pid)) return; - } + try { + this.synchronizeSharedMemoryForBoundary(channel, entry); + } catch (err) { + this.#rethrowKernelEntryFatal(err); + console.error( + `[completeChannel] shared-memory synchronization failed for pid=${channel.pid}:`, + err, + ); + prepared.retVal = -EIO; + prepared.errVal = EIO; + } + prepared.materialized = true; + } - // --- POSIX mqueue notification --- - // After mq_timedsend, the kernel may have a pending notification (signal - // to deliver when a message arrives on a previously empty queue). - const routedMqNotification = - syscallNr === SYS_MQ_TIMEDSEND && retVal === 0; - if (routedMqNotification) { - this.drainMqueueNotification(); - if (this.finishSignalTermination(channel)) return; + /** Hold one exact mailbox at a syscall boundary while its process is stopped. */ + private deferChannelWhileStopped(channel: ChannelInfo): boolean { + const testHook = this.#scratchBoundaryTestHooks?.deferChannelWhileStopped; + if (testHook) return testHook(channel); + if (!this.stoppedPids?.has(channel.pid)) return false; + if (!this.isRegisteredChannel(channel)) return true; + if (!this.parkedChannelCompletions?.has(channel)) { + (this.deferredStoppedChannels ??= new Map()).set(channel, true); + } + channel.handling = true; + return true; + } + + /** + * Publish completed mailboxes and re-arm deferred dispatches after SIGCONT. + * + * Resume is a barrier: first inspect every exact registered thread channel + * for signals retained while the process was stopped. No Worker constructor + * and no mailbox notification may run until that complete scan still leaves + * the authoritative Process Running. This prevents an earlier pthread from + * executing while a later thread's directed fatal/stop signal is still + * waiting to be applied. + * + * Returns true only when the continued transition remained current through + * release. The wake-event caller uses this to suppress a stale CONTINUED + * parent notification after a resume-time stop or exit. + */ + private resumeStoppedProcess( + pid: number, + kernelEntry: KernelWorkerEntryContext, + ): boolean { + // Wake events carry a PID, not a host execution-generation token. A + // delayed event must not release a process that has since stopped again, + // exited, or entered an exec handoff. + const getState = this.#kernelInstanceForEntry(kernelEntry).exports.kernel_get_process_state as ( + pid: number, + ) => number; + const state = getState(pid); + if (state !== PROCESS_STATE_RUNNING) { + if (state !== PROCESS_STATE_STOPPED) { + this.discardStoppedChannelStateForProcess(pid); } + return false; + } - // --- Signal delivery --- - // After each syscall, check if the kernel has a pending Handler signal. - // If so, dequeue it and write delivery info to the process channel. - // The glue code (channel_syscall.c) will invoke the handler after waking. - // Dequeue carries the exact kernel-owned TID explicitly, so notification - // routing cannot leak one channel's ambient task context into another. - const deliveredSignal = this.dequeueSignalForDelivery(channel); - if (routedMqNotification && this.finishSignalTermination(channel)) return; + const registration = this.processes.get(pid); + if (!registration || registration.channels.length === 0) { + // Fork/spawn/exec can yield between kernel Process creation and host + // memory registration, and exec handoff deliberately retains an empty + // registration. Preserve the real CONTINUED parent event now, but keep + // execution gated until startProcessWorkerWhenRunnable can scan the + // subsequently registered exact channels. + (this.pendingResumePids ??= new Set()).add(pid); + (this.stoppedPids ??= new Set()).add(pid); + return true; + } + this.pendingResumePids?.delete(pid); - // --- Blocking syscall handling --- - // Host-delegated AF_INET connect has its own public pending errnos. Keep - // EINPROGRESS/EALREADY visible to non-blocking callers, while blocking - // callers remain parked in the same host-owned retry loop as EAGAIN. - // The sockaddr-family guard deliberately excludes AF_UNIX from this - // transport-specific retry rule. - if (this.handlePendingInetConnect(channel, syscallNr, origArgs, retVal, errVal)) { - return; + const parkedCompletions = (this.parkedChannelCompletions ??= new Map()); + const deferredChannels = (this.deferredStoppedChannels ??= new Map()); + const preparedSignals = (this.resumePreparedSignals ??= new WeakSet()); + const caughtSignalChannels: ChannelInfo[] = []; + + // Keep the host stop gate armed throughout preflight. Any completion + // prepared while servicing a retained signal must join the parked batch, + // not wake guest code in the middle of this scan. + (this.stoppedPids ??= new Set()).add(pid); + + for (const channel of Array.from(registration.channels)) { + if (!this.isRegisteredChannel(channel)) continue; + + const channelView = new DataView( + channel.memory.buffer, + channel.channelOffset, + ); + let deliveredSignal = channelView.getUint32(CH_SIG_SIGNUM, true); + if (deliveredSignal > 0) { + // A caught signal may already have been attached before the syscall + // completion observed STOPPED, or by an earlier resume attempt whose + // later channel immediately stopped the process again. + preparedSignals.add(channel); + } else { + preparedSignals.delete(channel); + deliveredSignal = this.#dequeueSignalForDelivery(channel, kernelEntry); + if (deliveredSignal > 0) preparedSignals.add(channel); } - // flock shares Rust's advisory-lock wake contract but not fcntl's - // struct-flock marshalling path. LOCK_NB is a public EAGAIN result; - // only the blocking form may be parked for a lock-state wake. - if ( - this.handleFlockConflict( - channel, - syscallNr, - origArgs, - retVal, - errVal, - deliveredSignal, - ) - ) { - return; + if (this.#finishSignalTermination(channel, kernelEntry)) return false; + const postSignalState = getState(pid); + if (postSignalState === PROCESS_STATE_STOPPED) { + this.stoppedPids.add(pid); + return false; + } + if (postSignalState !== PROCESS_STATE_RUNNING) { + this.discardStoppedChannelStateForProcess(pid); + return false; } + if (deliveredSignal > 0) caughtSignalChannels.push(channel); + } - // 1. EAGAIN: kernel returned EAGAIN for a blocking syscall. - // Schedule async retry — the process stays blocked on Atomics.wait. - if (retVal === -1 && errVal === EAGAIN) { - if (logging) { - console.error(logEntry + " = -1 (EAGAIN, will retry)"); - } - this.handleBlockingRetry( - channel, - syscallNr, - origArgs, - kernelResult.outputWrites, - ); - return; + // wait4/waitid, sleeps, futexes, and readiness retries live outside an + // ordinary kernel dispatch. A caught directed signal preloaded above must + // wake those exact blockers; otherwise they can remain asleep forever + // after SIGCONT. The stop gate is still set, so every synchronous + // completion prepared here is added to parkedCompletions. + for (const channel of caughtSignalChannels) { + if (parkedCompletions.has(channel)) continue; + this.interruptStoppedChannelWithPreparedSignal(channel, kernelEntry); + if (this.#finishSignalTermination(channel, kernelEntry)) return false; + const postInterruptState = getState(pid); + if (postInterruptState === PROCESS_STATE_STOPPED) return false; + if (postInterruptState !== PROCESS_STATE_RUNNING) { + this.discardStoppedChannelStateForProcess(pid); + return false; } + } - // 2. Sleep syscalls: kernel returned success immediately, but we need - // to delay the response to simulate the sleep duration. - if ( - this.handleSleepDelay( - channel, - syscallNr, - origArgs, - retVal, - errVal, - kernelResult.sleepDelayMs, - kernelResult.outputWrites, - ) - ) { - return; + if (getState(pid) !== PROCESS_STATE_RUNNING) return false; + this.stoppedPids.delete(pid); + + // Worker construction is the first guest-execution boundary. Release it + // only after every retained exact-thread signal has been preflighted, and + // only for the exact memory generation prepared while this Process was + // stopped. Clone-specific start failure may still replace its parked + // success result before any completion is published. + const starts = this.deferredProcessWorkerStarts.get(pid); + const pendingStarts = starts ? Array.from(starts) : []; + if (starts) { + this.deferredProcessWorkerStarts.delete(pid); + } + + const parkedToPublish: Array<{ + channel: ChannelInfo; + prepared: PreparedChannelCompletion; + }> = []; + const parked = Array.from(parkedCompletions.entries()).filter( + ([channel]) => channel.pid === pid, + ); + for (const [channel, entry] of parked) { + if (parkedCompletions.get(channel) !== entry) continue; + if (!this.isRegisteredChannel(channel)) { + parkedCompletions.delete(channel); + deferredChannels.delete(channel); + continue; } - - // --- Process group change: re-check deferred waitpid calls --- - // When a process changes its pgid (setpgid/setsid), a parent blocked in - // waitpid(-pgid) may no longer have any matching children. Wake it with ECHILD. - if ( - errVal === 0 && - (syscallNr === SYS_SETPGID || syscallNr === SYS_SETSID) - ) { - this.recheckDeferredWaitpids(); + const releaseState = getState(pid); + if (releaseState === PROCESS_STATE_STOPPED) { + this.stoppedPids.add(pid); + return false; } + if (releaseState !== PROCESS_STATE_RUNNING) { + this.discardStoppedChannelStateForProcess(pid); + return false; + } + entry.prepared.relistenRequested ||= entry.relistenRequested; + parkedToPublish.push({ channel, prepared: entry.prepared }); + } - // --- Signal generation: wake blocked peers + reap terminating actions --- - // kill(), tkill()/pthread_kill(), and rt_sigqueueinfo() can all target a - // thread parked in a host-owned blocking operation. They can also apply - // process-wide stop/continue/terminate actions synchronously. - // - // Two follow-ups are required: - // (a) Wake any blocked syscalls on the target (pipe/poll/select) so - // their handlers observe the new exit state and complete with the - // right errno (handled by scheduleWakeBlockedRetries). - // (b) For any process the kernel marked Exited but that is still - // blocked in a non-blocking-retry path (most importantly - // pendingSleeps), call handleProcessTerminated directly so the - // parent's wait4 actually sees the killed child. Without this, - // a `kill` of a sleeping child can leave the parent blocked even - // though Rust has marked the child as an Exited zombie. - if ( - errVal === 0 && - (syscallNr === SYS_KILL || - syscallNr === SYS_TKILL || - syscallNr === SYS_RT_SIGQUEUEINFO) - ) { - // Apply STOPPED/CONTINUED transitions before waking a target's deferred - // syscall. `kill` has no descriptor output, so this scratch reuse is safe. - this.drainAndProcessWakeupEvents(); - this.scheduleWakeBlockedRetries(); - this.reapKilledProcessesAfterSyscall(); - if (syscallNr === SYS_TKILL) { - this.wakePendingSignalWaits( - channel.pid, - origArgs[1] >>> 0, - origArgs[0] >>> 0, + const deferredToRelisten: ChannelInfo[] = []; + const deferred = Array.from(deferredChannels.keys()).filter( + (channel) => channel.pid === pid, + ); + for (const channel of deferred) { + deferredChannels.delete(channel); + if (!this.isRegisteredChannel(channel)) continue; + channel.handling = false; + deferredToRelisten.push(channel); + } + + const finalState = getState(pid); + if (finalState === PROCESS_STATE_STOPPED) { + this.stoppedPids.add(pid); + return false; + } + if (finalState !== PROCESS_STATE_RUNNING) { + this.discardStoppedChannelStateForProcess(pid); + return false; + } + // Worker launch can still replace a parked clone success with a crash. + // Keep launch, publication, and relistening in one ordered transaction + // start after scope revocation so no observer or later ingress can see an + // intermediate result. + // WHY: resume can be nested in another syscall's wake drain. The caller + // may still need to publish a parent wait result and its own mailbox, so + // the ingress root—not this nested helper—owns the final effect position. + kernelEntry.deferFinalProtocolTransactionStart(() => { + for (let i = 0; i < pendingStarts.length; i++) { + const deferredStart = pendingStarts[i]!; + const currentRegistration = this.processes.get(pid); + if ( + !currentRegistration + || currentRegistration.memory !== deferredStart.expectedMemory + ) { + deferredStart.cancel(); + continue; + } + try { + deferredStart.start(); + } catch (error) { + deferredStart.cancel(); + console.error( + `[kernel-worker] deferred Worker launch failed for pid=${pid}:`, + error, ); - this.interruptWaitingChildForDirectedSignal( - channel.pid, - origArgs[0], + if (deferredStart.onStartError?.(error) === true) continue; + for (const remaining of pendingStarts.slice(i + 1)) { + try { + remaining.cancel(); + } catch { + /* best-effort */ + } + } + // WHY: this detached callback owns no kernel authority. Queue a new + // root that records signal death before its own detached onExit. + this.#runOrDeferKernelEntry( + `deferred Worker launch failure pid=${pid}`, + (entry) => { + this.#notifyHostProcessCrashedWithinKernelEntry( + pid, + SIGSEGV, + entry, + ); + entry.deferProtocolEffect(() => { + this.callbacks.onExit?.(pid, 128 + SIGSEGV); + }); + }, ); - } else { - this.interruptWaitingChildrenForGeneratedSignal(origArgs[1]); + return; } } - - // --- Normal completion --- - if (logging) { - console.error( - logEntry + this.formatSyscallReturn(syscallNr, retVal, errVal), + for (const publication of parkedToPublish) { + const stillParked = parkedCompletions.get(publication.channel); + if ( + stillParked === undefined + || stillParked.prepared !== publication.prepared + ) { + throw new Error( + `parked completion ownership changed before pid ${pid} launch publication`, + ); + } + // WHY: clone Worker construction may synchronously fail above. Keep + // the exact prepared result discoverable by + // failDeferredCloneLaunch until its onStartError callback has replaced + // the retained success with ENOMEM; only then transfer ownership to + // mailbox publication. + parkedCompletions.delete(publication.channel); + deferredChannels.delete(publication.channel); + this.publishPreparedChannelCompletion( + publication.channel, + publication.prepared, ); } - this.completeChannel( - channel, - syscallNr, - origArgs, - argDescs, - retVal, - errVal, - kernelResult.outputWrites, - ); - } catch (err) { - if (fileSharedMmapPreparation?.kind === "prepared") { - this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); - fileSharedMmapPreparation = null; + // Re-enter through the normal listener so polling mode and the browser's + // event-loop yielding policy retain their existing behavior. + for (const channel of deferredToRelisten) { + this.relistenChannel(channel); } - throw err; - } + }); + return true; } /** - * Dequeue one pending Handler signal from the kernel and write delivery - * info to the process channel. The glue code (channel_syscall.c) reads - * this after the syscall returns and invokes the handler. Returns the - * handler signal number, or zero when no caught handler was dequeued. + * Wake one host-owned blocker after resume preflight already copied a caught + * signal into its exact channel. The process stop gate remains armed, so a + * synchronous completion is parked until the full process scan succeeds. */ - private dequeueSignalForDelivery(channel: ChannelInfo): number { - const requestFlags = new DataView( - channel.memory.buffer, - channel.channelOffset, - ).getUint32(CH_REQUEST_FLAGS, true); - if ( - (requestFlags & CH_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY) !== 0 - ) { - // WHY: process-worker JavaScript consumes this completion outside - // libc's post-syscall signal trampoline. Dequeuing here would consume - // the kernel signal and block it for a handler that this completion can - // never invoke. Leave it pending for the explicit guest checkpoint after - // the owning fork, clone, or staged-loader transition. - return 0; + private interruptStoppedChannelWithPreparedSignal( + channel: ChannelInfo, + entry: KernelWorkerEntryContext, + ): boolean { + const waitIndex = this.waitingForChild.findIndex( + (waiter) => waiter.channel === channel, + ); + if (waitIndex >= 0) { + const [waiter] = this.waitingForChild.splice(waitIndex, 1); + if (this.interruptWaiterWithPendingSignal(waiter, entry)) return true; + this.waitingForChild.splice(waitIndex, 0, waiter); + return false; } - const preparedSignals = this.resumePreparedSignals; - if (preparedSignals?.has(channel)) { - const existingSignal = new DataView( - channel.memory.buffer, - channel.channelOffset, - ).getUint32(CH_SIG_SIGNUM, true); - if (existingSignal > 0) return existingSignal; - // The channel was retired or the guest consumed the record without a - // normal publication path. Do not suppress a genuinely new signal. - preparedSignals.delete(channel); + const sleep = this.pendingSleeps.get(channel); + if (sleep) { + this.#cancelRegisteredTimeout(sleep.timer); + this.pendingSleeps.delete(channel); + this.#completeSleepWithSignalCheckWithinKernelEntry( + sleep.channel, + sleep.syscallNr, + sleep.origArgs, + sleep.retVal, + sleep.errVal, + sleep.outputWrites, + entry, + ); + return true; } - const dequeueSignal = this.kernelInstance!.exports.kernel_dequeue_signal as - (( - pid: number, - tid: number, - outPtr: KernelPointer, - outCapacity: number, - ) => number) | undefined; - if (!dequeueSignal) return 0; + const futex = this.pendingFutexWaits.get(channel); + if (futex) { + if (futex.interrupt) { + futex.interrupt(-EINTR_ERRNO, EINTR_ERRNO); + } else { + Atomics.notify( + new Int32Array(channel.memory.buffer), + futex.futexIndex, + 1, + ); + } + return true; + } - const tid = this.guestTidForChannel(channel); - // Copy the fixed signal record to host-owned bytes before releasing the - // region. Completion can synchronously wake another channel and reuse it. - const snapshot = this.requireMainScratchRegion().withLease((lease) => { - const sigResult = lease.invokeKernelExport("kernel_dequeue_signal", [ + let blocked = + this.pendingPollRetries.has(channel) || + (this.pendingAdvisoryLockRetries?.has(channel) ?? false) || + this.pendingSelectRetries.has(channel); + for (const readers of this.pendingPipeReaders.values()) { + if (readers.some((reader) => reader.channel === channel)) { + blocked = true; + break; + } + } + if (!blocked) { + for (const writers of this.pendingPipeWriters.values()) { + if (writers.some((writer) => writer.channel === channel)) { + blocked = true; + break; + } + } + } + if (!blocked) return false; + + this.cancelParkedFifoOpen(channel, entry); + this.removePendingPipeReader(channel); + this.removePendingPipeWriter(channel); + this.completeChannelRaw(channel, -EINTR_ERRNO, EINTR_ERRNO, entry); + this.relistenChannel(channel); + return true; + } + + /** Release a kernel-owned FIFO rendezvous before a host path retires or + * completes the parked open without re-entering the original syscall. */ + private cancelParkedFifoOpen( + channel: ChannelInfo, + entry: KernelWorkerEntryContext, + capturedSyscallNr?: number, + ): boolean { + if (!this.#kernelInstance || !this.#kernelMemory) return false; + if ( + !this.#isProcessExecutionActiveWithinKernelEntry( channel.pid, - tid, - lease.exportPointer( - CH_SIG_BASE, - KERNEL_SCRATCH_SIGNAL_DELIVERY_BYTES, - ), - KERNEL_SCRATCH_SIGNAL_DELIVERY_BYTES, - ]); - return { - sigResult, - bytes: sigResult > 0 - ? lease.copyOut( - CH_SIG_BASE, - KERNEL_SCRATCH_SIGNAL_DELIVERY_BYTES, - ) - : new Uint8Array(0), - }; - }); - const { sigResult } = snapshot; - if (sigResult < 0) { - throw new KernelTaskBindingError( + entry, + ) + ) { + return false; + } + let syscallNr = capturedSyscallNr; + if (syscallNr === undefined) { + const snapshot = this.blockingRetrySnapshots.get(channel); + syscallNr = snapshot?.syscallNr; + } + if (syscallNr === undefined) { + try { + syscallNr = new DataView( + channel.memory.buffer, + channel.channelOffset, + ).getUint32(CH_SYSCALL, true); + } catch { + return false; + } + } + if (syscallNr !== SYS_OPEN && syscallNr !== SYS_OPENAT) return false; + return this.#cancelHostOwnedKernelWait(channel, syscallNr, entry); + } + + /** + * Abort Rust state retained by a host-owned wait that cannot re-enter its + * normal terminal syscall path. + * + * WHY: SYS_THREAD_CANCEL is an idempotent kernel cleanup primitive for the + * exact TID. It releases FIFO rendezvous state and restores a ppoll/pselect + * temporary mask before the host publishes an unrelated EIO/EINTR result. + */ + #cancelHostOwnedKernelWait( + channel: ChannelInfo, + syscallNr: number, + entry: KernelWorkerEntryContext, + ): boolean { + if ( + syscallNr !== SYS_OPEN + && syscallNr !== SYS_OPENAT + && syscallNr !== SYS_PPOLL + && syscallNr !== SYS_PSELECT6 + ) return true; + return this.#cancelLiveTaskKernelWait(channel, entry); + } + + /** + * Validate one exact live task and retire any Rust state that could outlive + * a host-published cancellation result. + * + * WHY: even waits without syscall-specific Rust state must cross this gate. + * Otherwise the host could publish EINTR for a stale task identity while + * FIFO, temporary-mask, or condition-wait state retained by that task + * remained authoritative in Rust. + */ + #cancelLiveTaskKernelWait( + channel: ChannelInfo, + entry: KernelWorkerEntryContext, + ): boolean { + if (!this.#kernelInstance || !this.#kernelMemory) return false; + if ( + !this.#isProcessExecutionActiveWithinKernelEntry( channel.pid, - tid, - -sigResult, - `Kernel rejected signal dequeue for tid ${tid} in process ${channel.pid}`, - ); - } - if (sigResult > 0) { - // Copy the complete generated signal-delivery wire into its reserved - // process-channel slot. - const processMem = new Uint8Array(channel.memory.buffer); - processMem.set( - snapshot.bytes, - channel.channelOffset + CH_SIG_BASE, + entry, + ) + ) return false; + try { + const result = this.runSyntheticMemorySyscall( + channel, + SYS_THREAD_CANCEL, + [this.guestTidForChannel(channel)], + entry, ); - return sigResult; - } else { - // Clear the complete reserved area, including its trailing pad. - const sigStart = channel.channelOffset + CH_SIG_BASE; - new Uint8Array( - channel.memory.buffer, - sigStart, - CH_SIG_AREA_SIZE, - ).fill(0); - return 0; + // WHY: THREAD_CANCEL's cleanup proof is the exact pair (0, 0). + // Treating errno alone as success would let a malformed (-1, 0) or + // positive return retire host state and publish EINTR even though the + // kernel never confirmed exact-task cleanup. + return result.retVal === 0 && result.errVal === 0; + } catch (error) { + this.#rethrowKernelEntryFatal(error); + // Process/thread teardown also owns idempotent kernel-side cleanup. + return false; } } /** - * Complete a syscall by copying output data and notifying the process. + * Consume a cancellation only at the last synchronous boundary before a + * marked request becomes host-owned parked state. + * + * A pending marker belongs to the pthread, not to an arbitrary syscall + * number. Plain `__syscallN` requests leave it armed for the thread's next + * real cancellation point. CP requests that retained Rust wait state first + * release that state through the same exact-task cleanup path. */ - private completeChannel( + private interruptPendingCancellationBeforeRegistration( channel: ChannelInfo, syscallNr: number, - origArgs: number[], - argDescs: SyscallArgDesc[] | undefined, - retVal: number, - errVal: number, - detachedOutput: ChannelOutputWrite[] = [], - deferredClone?: PreparedChannelCompletion["deferredClone"], - ): void { - // WHY: only bytes detached while the allocation lease was active may - // cross this completion boundary. Re-reading shared scratch here would let - // a signal, retry, timeout, or teardown copy bytes from another operation. - void syscallNr; - void origArgs; - void argDescs; - const prepared: PreparedChannelCompletion = { - kind: "marshalled", - outputWrites: detachedOutput, - retVal, - errVal, - materialized: false, - relistenRequested: true, - deferredClone, - }; - - // Output and shared backing belong to the completed syscall before any - // lifecycle observer is released. A STOPPED wake can synchronously finish - // the parent's wait and let that Worker import the same backing; delaying - // materialization until after wake processing would expose stale bytes. - // The stopped child's mailbox status/notification remains parked below. - this.materializePreparedChannelCompletion(channel, prepared); + cancellationIdentity: FrozenCancellationPointIdentity, + entry: KernelWorkerEntryContext, + ): boolean { + if ( + !cancellationIdentity.cancellationPoint + || !cancellationIdentity.cancellationWakeAllowed + ) return false; + if (!this.pendingCancels.has(channel)) return false; - // The syscall is logically complete even if publication must wait for a - // future SIGCONT. Retire one-shot timeout/deadline state now so no second - // completion can race the parked one. - this.clearSocketTimeout(channel); - this.clearReadinessWait(channel); + if (!this.#cancelLiveTaskKernelWait(channel, entry)) { + this.#failBlockingRetryProtocol( + `cancellation cleanup failed for syscall ${syscallNr}`, + ); + } + this.pendingCancels.delete(channel); + this.completeChannelRawAndRelisten( + channel, + -EINTR_ERRNO, + EINTR_ERRNO, + entry, + ); + return true; + } - // Drain PTY output buffers before notifying the process — slave writes - // produce data in the PTY output_buf that needs to reach the host (xterm.js). - this.drainAllPtyOutputs(); + /** + * Replace the parked success result of a clone whose deferred thread Worker + * could not be constructed. The entry layer separately rolls back ThreadInfo, + * channel, allocator, and Worker registries before this completion publishes. + */ + failDeferredCloneLaunch(pid: number, tid: number, errno: number): boolean { + for (const [channel, parked] of this.parkedChannelCompletions ?? []) { + const clone = parked.prepared.deferredClone; + if (channel.pid !== pid || clone?.tid !== tid) continue; - // Flush TCP send pipes before notifying the process — gets PHP's - // response data to the browser without waiting for the next pump cycle - this.flushTcpSendPipes(channel.pid); + // WHY: the guest mailbox is mutable while this completion is parked. + // Clear only the parent-TID address validated for the original clone, + // never flags/pointers re-read from potentially replaced channel bytes. + const ptidPtr = clone.parentTidPointer; + if (ptidPtr !== undefined) { + if ( + !isValidMemoryRange( + new Uint8Array(channel.memory.buffer), + ptidPtr, + 4, + ) + ) { + return false; + } + new DataView(channel.memory.buffer).setInt32(ptidPtr, 0, true); + } - // This consumes process STOPPED/CONTINUED transitions before deciding - // whether CH_STATUS may be published. - this.drainAndProcessWakeupEvents(); - this.publishOrParkChannelCompletion(channel, prepared); + parked.prepared.outputWrites = []; + parked.prepared.retVal = -1; + parked.prepared.errVal = errno; + parked.prepared.deferredClone = undefined; + return true; + } + return false; } - private publishOrParkChannelCompletion( - channel: ChannelInfo, - prepared: PreparedChannelCompletion, + /** Discard state that must never publish into a dead or replaced channel. */ + private discardStoppedChannelStateForProcess( + pid: number, + clearProcessStop = true, ): void { - if ( - this.stoppedPids?.has(channel.pid) && - this.isRegisteredChannel(channel) - ) { - const parkedCompletions = (this.parkedChannelCompletions ??= new Map()); - const existing = parkedCompletions.get(channel); - if (existing) { - existing.relistenRequested ||= prepared.relistenRequested; - return; + const starts = this.deferredProcessWorkerStarts?.get(pid); + if (starts) { + this.deferredProcessWorkerStarts.delete(pid); + for (const entry of starts) { + try { + entry.cancel(); + } catch { + /* best-effort generation teardown */ + } + } + } + for (const channel of Array.from( + this.parkedChannelCompletions?.keys() ?? [], + )) { + if (channel.pid === pid) this.parkedChannelCompletions.delete(channel); + } + for (const channel of Array.from( + this.deferredStoppedChannels?.keys() ?? [], + )) { + if (channel.pid === pid) this.deferredStoppedChannels.delete(channel); + } + if (clearProcessStop) this.stoppedPids?.delete(pid); + if (clearProcessStop) this.pendingResumePids?.delete(pid); + } + + private discardStoppedChannelState(channel: ChannelInfo): void { + this.parkedChannelCompletions?.delete(channel); + this.deferredStoppedChannels?.delete(channel); + } + + /** + * Host-teardown reclamation. + * + * [JSC-TERMINATE-ATOMICS-WAIT-LEAK] — WORKAROUND, remove when the engine bug + * is fixed; see docs/jsc-terminate-atomics-wait-workaround.md. + * + * On JSC (Safari, and Bun via `bun`'s JavaScriptCore), `Worker.terminate()` + * cannot kill (or free the memory of) a worker parked in `Atomics.wait` on + * its syscall channel — which is where every idle/blocked process worker sits + * (accept, read, poll, select, sleep, futex, the channel round-trip). + * Terminating them directly leaks their threads + committed working set, so + * each image switch accumulates a whole machine and the tab OOMs. V8 (Chrome, + * Node) interrupts the wait on terminate and reclaims, so this is a no-op cost + * there and is invoked unconditionally by both host entries for parity. + * + * For every worker currently parked at CH_PENDING we complete its syscall + * with EINTR AND queue a SIGKILL into the channel signal slot. The glue's + * `__deliver_pending_signal` (run right after the syscall returns) sees + * SIGKILL and calls the `kernel_exit` import directly (NOT musl `_exit()`, + * which would re-park the worker in the SYS_exit spin loop) → the `unreachable` + * trap that worker-main catches → the worker posts `{exit}` and returns to its + * JS event loop, where the host's `terminate()` (or the `{exit}` handler) can + * finally reclaim it. + * + * SIGKILL is never delivered to the guest in normal operation (it is + * uncatchable — the kernel enforces the default terminate action itself), so + * the glue treats a queued SIGKILL unambiguously as "exit now". + */ + killAllBlockedForTeardown(): Promise> { + if (this.#kernelFatalError !== null) { + return this.#resolvePromise(new Set()); + } + return new this.#promiseReceiver>((resolve, reject) => { + try { + this.#runOrDeferKernelEntry( + "blocked-process teardown wake", + (entry) => { + const woken = + this.#killAllBlockedForTeardownWithinKernelEntry(entry); + entry.deferProtocolEffect(() => { + // WHY: destroy must not start its bounded drain until all + // channel publications registered by this teardown scope have + // completed. Promise resolution itself retains no entry token. + resolve(woken); + return undefined; + }); + return undefined; + }, + ); + } catch (cause) { + this.#rethrowKernelEntryFatal(cause); + reject(cause); + } + }); + } + + #killAllBlockedForTeardownWithinKernelEntry( + entry: KernelWorkerEntryContext, + ): Set { + // Drop all pending-retry bookkeeping first so nothing tries to re-arm a + // syscall behind the teardown. The actual wake is driven off the channels' + // CH_STATUS below, not off these maps — a worker parked on accept(), + // epoll_pwait(), a socket read, or a futex may not appear in any of these + // maps, but it is always sitting at CH_PENDING on its channel. + for (const e of this.pendingPollRetries.values()) if (e.timer) this.#cancelRegisteredTimeout(e.timer); + for (const e of this.pendingAdvisoryLockRetries?.values() ?? []) { + this.#cancelRegisteredTimeout(e.timer); + } + for (const e of this.pendingSelectRetries.values()) if (e.timer) this.#cancelRegisteredTimeout(e.timer); + for (const e of this.pendingSleeps.values()) this.#cancelRegisteredTimeout(e.timer); + for (const e of this.pendingSignalWaits.values()) this.#cancelRegisteredTimeout(e.timer); + this.pendingPipeReaders.clear(); + this.pendingPipeWriters.clear(); + this.pendingPollRetries.clear(); + this.pendingAdvisoryLockRetries?.clear(); + this.pendingSelectRetries.clear(); + this.pendingSleeps.clear(); + this.pendingSignalWaits.clear(); + this.signalWaitDeadlines.clear(); + this.pendingFutexWaits.clear(); + // Teardown has not yet transitioned these live tasks in Rust. Consume + // every exact target pin before discarding the immutable host plans. + for (const channel of Array.from(this.blockingRetrySnapshots.keys())) { + this.#releaseBlockingRetrySnapshot(channel, entry); + } + this.blockingRetryWakeTargets.clear(); + + // Wake every channel (process main threads + pthreads) that is parked in + // Atomics.wait — i.e. status CH_PENDING — completing its syscall with + // -EINTR and queueing SIGKILL so the guest glue runs its cooperative exit. + // Returns the set of pids we actually woke so the caller can drain only for + // those (a not-woken straggler never posts {exit} and must be terminated + // directly, not waited on). + const woken = new Set(); + const getExitStatus = this.#kernelInstanceIfAvailableForEntry(entry)?.exports + .kernel_get_process_exit_status as ((pid: number) => number) | undefined; + for (const registration of this.processes.values()) { + // Skip processes that have already exited (kernel state == Exited, i.e. + // status != -1). A sibling thread may have called exit_group and set the + // process's real exit status while this thread is still parked; forcing + // our own kernel_exit on that parked thread would clobber that status + // (e.g. a pthread exit(0) turning into 137). Only genuinely-live processes + // need waking; already-exited stragglers are reaped/terminated normally. + if (getExitStatus && getExitStatus(registration.pid) !== -1) continue; + for (const channel of registration.channels) { + let status: number; + try { + const i32 = new Int32Array(channel.memory.buffer, channel.channelOffset); + status = Atomics.load( + i32, + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + ); + } catch { continue; } + if (status !== CH_PENDING) continue; + try { + this.wakeChannelForTeardownExit(channel, entry); + woken.add(channel.pid); + } catch (err) { + this.#rethrowKernelEntryFatal(err); + console.error(`[killAllBlockedForTeardown] wake failed for pid=${channel.pid} off=${channel.channelOffset}: ${err}`); + } } - // A stopped process must remain parked at CH_PENDING, but a syscall that - // has completed already owns its output. Materialize it now so another - // process mapping the same backing cannot observe stale bytes until - // SIGCONT. Only the mailbox return/notification is deferred. - this.materializePreparedChannelCompletion(channel, prepared); - channel.handling = true; - this.deferredStoppedChannels?.delete(channel); - parkedCompletions.set(channel, { - prepared, - relistenRequested: prepared.relistenRequested, - }); - return; } - - this.publishPreparedChannelCompletion(channel, prepared); + return woken; } - private publishPreparedChannelCompletion( - channel: ChannelInfo, - prepared: PreparedChannelCompletion, - ): void { - this.materializePreparedChannelCompletion(channel, prepared); - - channel.handling = false; - const processView = new DataView( - channel.memory.buffer, - channel.channelOffset, - ); - processView.setBigInt64(CH_RETURN, BigInt(prepared.retVal), true); - processView.setUint32(CH_ERRNO, prepared.errVal, true); - - // The copied signal record now belongs to the guest. A later syscall may - // dequeue another signal after this boundary has actually been observed. - this.resumePreparedSignals?.delete(channel); - // pthread_t->cancel in guest memory is authoritative. This host marker is - // only a one-shot pre-enqueue race guard and must not retain a channel - // after any actual completion (including one parked before cancel arrived). - this.pendingCancels?.delete(channel); - const i32View = new Int32Array( - channel.memory.buffer, - channel.channelOffset, + /** + * Cooperatively unwind the exact browser Worker generation discarded by + * exec without exiting the persistent kernel Process. + * + * Every parked channel receives an internal SIGKILL marker plus EINTR. The + * guest glue enters its existing non-returning kernel_exit import; worker-main + * recognizes the exec marker, skips SYS_EXIT, and returns so the browser + * wrapper can publish an exact memory_quiescent ownership fence. + */ + wakeProcessWorkersForExecRetirement( + pid: number, + expectedMemory: WebAssembly.Memory, + ): Set { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError( + `exec generation retirement pid=${pid}`, + ); + } + const registration = this.processes.get(pid); + if (!registration) return new Set(); + if (registration.memory !== expectedMemory) { + // A stale entry adapter request is an ordinary generation mismatch, not + // a kernel failure. Reject it before opening an entry scope so it cannot + // poison the current kernel generation. + throw new Error(`Exec retirement generation changed for pid ${pid}`); + } + let result: Set | undefined; + const deferred = this.#runOrDeferKernelEntry( + `exec generation retirement pid=${pid}`, + (entry) => { + result = this.#wakeProcessWorkersForExecRetirementWithinKernelEntry( + pid, + expectedMemory, + entry, + ); + return undefined; + }, ); - const statusIndex = CH_STATUS / Int32Array.BYTES_PER_ELEMENT; - Atomics.store(i32View, statusIndex, CH_COMPLETE); - Atomics.notify(i32View, statusIndex, 1); - if (prepared.relistenRequested && this.isRegisteredChannel(channel)) { - this.relistenChannel(channel); + if (deferred || result === undefined) { + throw new KernelReentrantEntryError( + `exec generation retirement pid=${pid}`, + ); } + return result; } - private materializePreparedChannelCompletion( - channel: ChannelInfo, - prepared: PreparedChannelCompletion, - ): void { - if (prepared.materialized) return; - - const processMem = new Uint8Array(channel.memory.buffer); - for (const write of prepared.outputWrites) { - processMem.set(write.bytes, write.ptr); + #wakeProcessWorkersForExecRetirementWithinKernelEntry( + pid: number, + expectedMemory: WebAssembly.Memory, + entry: KernelWorkerEntryContext, + ): Set { + const wokenOffsets = new Set(); + const registration = this.processes.get(pid); + if (!registration) return wokenOffsets; + if (registration.memory !== expectedMemory) { + throw new Error( + `Exec retirement generation changed for pid ${pid}`, + ); } - prepared.outputWrites = []; - - try { - this.synchronizeSharedMemoryForBoundary(channel); - } catch (err) { - console.error( - `[completeChannel] shared-memory synchronization failed for pid=${channel.pid}:`, - err, + const execCallers = registration.channels.filter((channel) => { + if (channel.memory !== expectedMemory) return false; + const view = new DataView( + expectedMemory.buffer, + channel.channelOffset, + ); + const syscall = view.getUint32(CH_SYSCALL, true); + const status = Atomics.load( + new Int32Array(expectedMemory.buffer, channel.channelOffset), + CH_STATUS / 4, + ); + return status === CH_PENDING + && (syscall === SYS_EXECVE || syscall === SYS_EXECVEAT); + }); + if (execCallers.length !== 1) { + throw new Error( + `Exec retirement expected exactly one execve/execveat caller for pid ${pid}, found ${execCallers.length}`, ); - prepared.retVal = -EIO; - prepared.errVal = EIO; } - prepared.materialized = true; + for (const channel of registration.channels) { + if (channel.memory !== expectedMemory) { + throw new Error( + `Exec retirement found a mixed memory generation for pid ${pid}`, + ); + } + const i32 = new Int32Array( + channel.memory.buffer, + channel.channelOffset, + ); + if (Atomics.load(i32, CH_STATUS / 4) !== CH_PENDING) continue; + const view = new DataView( + channel.memory.buffer, + channel.channelOffset, + ); + view.setUint32(CH_SIG_SIGNUM, SIGKILL, true); + view.setUint32(CH_SIG_HANDLER, 0, true); + view.setUint32(CH_SIG_SI_CODE, EXEC_RETIRE_SIGNAL_CODE, true); + // WHY: this is not an ordinary syscall completion. Exec has already + // committed and the generation is being retired, so relistening or + // copying scratch-backed outputs would let the discarded Worker issue + // more work. Publish only EINTR plus the private retirement marker. + this.completeChannelRaw(channel, -1, EINTR_ERRNO, entry); + wokenOffsets.add(channel.channelOffset); + } + return wokenOffsets; } - /** Hold one exact mailbox at a syscall boundary while its process is stopped. */ - private deferChannelWhileStopped(channel: ChannelInfo): boolean { - if (!this.stoppedPids?.has(channel.pid)) return false; - if (!this.isRegisteredChannel(channel)) return true; - if (!this.parkedChannelCompletions?.has(channel)) { - (this.deferredStoppedChannels ??= new Map()).set(channel, true); + /** Complete a blocked channel with EINTR and queue SIGKILL so the guest glue + * runs its cooperative exit. See {@link killAllBlockedForTeardown}. + * [JSC-TERMINATE-ATOMICS-WAIT-LEAK] — see + * docs/jsc-terminate-atomics-wait-workaround.md. */ + private wakeChannelForTeardownExit( + channel: ChannelInfo, + entry: KernelWorkerEntryContext, + ): void { + const pv = new DataView(channel.memory.buffer, channel.channelOffset); + // Queue SIGKILL for the glue's post-syscall __deliver_pending_signal. The + // syscall handlers may have called dequeueSignalForDelivery, but SIGKILL + // is never a queued Handler signal, so this slot is ours to set. Zero the + // handler slot too: SIGKILL is uncatchable, so it must never dispatch a + // userspace handler — the glue keys off signum==9 and exits before reading + // the handler, but clearing it keeps this write self-consistent. + pv.setUint32(CH_SIG_SIGNUM, SIGKILL, true); + pv.setUint32(CH_SIG_HANDLER, 0, true); + // Read the still-pending syscall request and complete it with -EINTR. + const syscallNr = pv.getUint32(CH_SYSCALL, true); + const origArgs: number[] = []; + for (let i = 0; i < CH_ARGS_COUNT; i++) { + origArgs.push(Number(pv.getBigInt64(CH_ARGS + i * CH_ARG_SIZE, true))); } - channel.handling = true; - return true; + this.completeChannel( + channel, + syscallNr, + origArgs, + SYSCALL_ARGS[syscallNr], + -1, + EINTR_ERRNO, + [], + undefined, + entry, + ); } /** - * Publish completed mailboxes and re-arm deferred dispatches after SIGCONT. + * Schedule re-listen on a channel. * - * Resume is a barrier: first inspect every exact registered thread channel - * for signals retained while the process was stopped. No Worker constructor - * and no mailbox notification may run until that complete scan still leaves - * the authoritative Process Running. This prevents an earlier pthread from - * executing while a later thread's directed fatal/stop signal is still - * waiting to be applied. + * Uses queueMicrotask for speed (near-zero delay between syscalls). + * Every Nth call (relistenBatchSize), yields via setImmediate so timer + * callbacks (setTimeout/setInterval) can fire — prevents event loop + * starvation while keeping throughput close to Node.js native setImmediate. * - * Returns true only when the continued transition remained current through - * release. The wake-event caller uses this to suppress a stale CONTINUED - * parent notification after a resume-time stop or exit. + * The dedicated browser worker sets relistenBatchSize=1 so every relisten + * is deferred through its MessageChannel-backed setImmediate queue. This + * lets worker messages and timers interleave with multi-process syscall + * traffic. Node.js retains the larger native-setImmediate batch. */ - private resumeStoppedProcess(pid: number): boolean { - // Wake events carry a PID, not a host execution-generation token. A - // delayed event must not release a process that has since stopped again, - // exited, or entered an exec handoff. - const getState = this.kernelInstance!.exports.kernel_get_process_state as ( - pid: number, - ) => number; - const state = getState(pid); - if (state !== PROCESS_STATE_RUNNING) { - if (state !== PROCESS_STATE_STOPPED) { - this.discardStoppedChannelStateForProcess(pid); - } - return false; - } - - const registration = this.processes.get(pid); - if (!registration || registration.channels.length === 0) { - // Fork/spawn/exec can yield between kernel Process creation and host - // memory registration, and exec handoff deliberately retains an empty - // registration. Preserve the real CONTINUED parent event now, but keep - // execution gated until startProcessWorkerWhenRunnable can scan the - // subsequently registered exact channels. - (this.pendingResumePids ??= new Set()).add(pid); - (this.stoppedPids ??= new Set()).add(pid); - return true; - } - this.pendingResumePids?.delete(pid); - - const parkedCompletions = (this.parkedChannelCompletions ??= new Map()); - const deferredChannels = (this.deferredStoppedChannels ??= new Map()); - const preparedSignals = (this.resumePreparedSignals ??= new WeakSet()); - const caughtSignalChannels: ChannelInfo[] = []; - - // Keep the host stop gate armed throughout preflight. Any completion - // prepared while servicing a retained signal must join the parked batch, - // not wake guest code in the middle of this scan. - (this.stoppedPids ??= new Set()).add(pid); - - for (const channel of Array.from(registration.channels)) { - if (!this.isRegisteredChannel(channel)) continue; + private relistenCount = 0; + /** How many syscalls to process via microtask before yielding to the event + * loop via setImmediate. Default 64 is tuned for Node.js. The dedicated + * browser worker sets this to 1 so worker messages keep progressing. */ + relistenBatchSize = 64; - const channelView = new DataView( - channel.memory.buffer, - channel.channelOffset, - ); - let deliveredSignal = channelView.getUint32(CH_SIG_SIGNUM, true); - if (deliveredSignal > 0) { - // A caught signal may already have been attached before the syscall - // completion observed STOPPED, or by an earlier resume attempt whose - // later channel immediately stopped the process again. - preparedSignals.add(channel); - } else { - preparedSignals.delete(channel); - deliveredSignal = this.dequeueSignalForDelivery(channel); - if (deliveredSignal > 0) preparedSignals.add(channel); - } + /** + * When true, use a MessageChannel-based poller to check all channels + * instead of per-channel Atomics.waitAsync listeners. + * + * This avoids a V8 bug where Atomics.waitAsync microtask chains from + * multiple concurrent processes freeze the main thread. The poller + * uses MessageChannel for ~0ms dispatch (bypassing the browser's 4ms + * timer clamp on setTimeout/setInterval), with periodic setTimeout + * yields every 4ms to keep timers and rendering alive. + * + * This remains a legacy opt-in for browser embeddings that run the kernel + * on the main thread. The dedicated browser worker and Node.js both keep + * the default event-driven Atomics.waitAsync mode. + */ + usePolling = false; + private pollMC: MessageChannel | null = null; + private pollScheduled = false; + private pollLastYield = 0; - if (this.finishSignalTermination(channel)) return false; - const postSignalState = getState(pid); - if (postSignalState === PROCESS_STATE_STOPPED) { - this.stoppedPids.add(pid); - return false; - } - if (postSignalState !== PROCESS_STATE_RUNNING) { - this.discardStoppedChannelStateForProcess(pid); - return false; - } - if (deliveredSignal > 0) caughtSignalChannels.push(channel); + /** Start the channel poller. Called automatically when usePolling=true + * and a process is registered. */ + private startPolling(): void { + if (this.pollMC !== null) return; + this.pollMC = new MessageChannel(); + this.pollMC.port1.onmessage = () => this.pollTick(); + this.pollLastYield = performance.now(); + this.schedulePoll(); + } + + /** Stop the channel poller. Called when all processes are unregistered. */ + private stopPolling(): void { + if (this.pollMC !== null) { + this.pollMC.port1.close(); + this.pollMC = null; + this.pollScheduled = false; } + } - // wait4/waitid, sleeps, futexes, and readiness retries live outside an - // ordinary kernel dispatch. A caught directed signal preloaded above must - // wake those exact blockers; otherwise they can remain asleep forever - // after SIGCONT. The stop gate is still set, so every synchronous - // completion prepared here is added to parkedCompletions. - for (const channel of caughtSignalChannels) { - if (parkedCompletions.has(channel)) continue; - this.interruptStoppedChannelWithPreparedSignal(channel); - if (this.finishSignalTermination(channel)) return false; - const postInterruptState = getState(pid); - if (postInterruptState === PROCESS_STATE_STOPPED) return false; - if (postInterruptState !== PROCESS_STATE_RUNNING) { - this.discardStoppedChannelStateForProcess(pid); - return false; - } + /** Schedule the next poll tick. Uses MessageChannel for ~0ms dispatch, + * with a setTimeout yield every 4ms to prevent timer starvation. */ + private schedulePoll(): void { + if (this.#kernelFatalError !== null || this.pollScheduled || !this.pollMC) { + return; + } + this.pollScheduled = true; + const now = performance.now(); + if (now - this.pollLastYield >= 4) { + // Yield to timers/rendering + this.pollLastYield = now; + this.#registerTimeout(() => { + this.pollScheduled = false; + this.pollTick(); + }, 0); + } else { + this.pollMC.port2.postMessage(null); } + } - if (getState(pid) !== PROCESS_STATE_RUNNING) return false; - this.stoppedPids.delete(pid); + /** Poll all active channels for PENDING syscalls. */ + private pollTick(): void { + this.pollScheduled = false; + if ( + this.#kernelFatalError !== null + || !this.pollMC + || this.activeChannels.length === 0 + ) { + return; + } - // Worker construction is the first guest-execution boundary. Release it - // only after every retained exact-thread signal has been preflighted, and - // only for the exact memory generation prepared while this Process was - // stopped. Clone-specific start failure may still replace its parked - // success result before any completion is published. - const starts = this.deferredProcessWorkerStarts.get(pid); - if (starts) { - this.deferredProcessWorkerStarts.delete(pid); - const pendingStarts = Array.from(starts); - for (let i = 0; i < pendingStarts.length; i++) { - const entry = pendingStarts[i]; - const currentRegistration = this.processes.get(pid); + // Snapshot to handle mutations during iteration + // (attachThreadChannel/removeChannel). + const channels = this.activeChannels.slice(); + for (const channel of channels) { + if (!this.isRegisteredChannel(channel)) continue; + if (this.stoppedPids?.has(channel.pid)) { + const stoppedView = new Int32Array( + channel.memory.buffer, + channel.channelOffset, + ); + channel.i32View = stoppedView; if ( - !currentRegistration || - currentRegistration.memory !== entry.expectedMemory + Atomics.load( + stoppedView, + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + ) === CH_PENDING ) { - entry.cancel(); - continue; - } - try { - entry.start(); - } catch (error) { - entry.cancel(); - console.error( - `[kernel-worker] deferred Worker launch failed for pid=${pid}:`, - error, - ); - if (entry.onStartError?.(error) === true) { - continue; - } - for (const remaining of pendingStarts.slice(i + 1)) { - try { - remaining.cancel(); - } catch { - /* best-effort */ - } - } - this.notifyHostProcessCrashed(pid); - // No backing process Worker exists to emit a later error/exit event. - // Drive the normal entry-layer teardown now so any Workers that did - // start for this generation are terminated and registries are retired. - if (this.callbacks.onExit) this.callbacks.onExit(pid, 128 + 11); - return false; + this.deferChannelWhileStopped(channel); } - } - } - - const parked = Array.from(parkedCompletions.entries()).filter( - ([channel]) => channel.pid === pid, - ); - for (const [channel, entry] of parked) { - if (parkedCompletions.get(channel) !== entry) continue; - if (!this.isRegisteredChannel(channel)) { - parkedCompletions.delete(channel); - deferredChannels.delete(channel); continue; } - const releaseState = getState(pid); - if (releaseState === PROCESS_STATE_STOPPED) { - this.stoppedPids.add(pid); - return false; - } - if (releaseState !== PROCESS_STATE_RUNNING) { - this.discardStoppedChannelStateForProcess(pid); - return false; + if (channel.handling) continue; + // Re-create view in case memory was grown + const i32View = new Int32Array( + channel.memory.buffer, + channel.channelOffset, + ); + channel.i32View = i32View; + if ( + Atomics.load( + i32View, + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + ) === CH_PENDING + ) { + channel.handling = true; + this.handleSyscall(channel); } - parkedCompletions.delete(channel); - deferredChannels.delete(channel); - entry.prepared.relistenRequested ||= entry.relistenRequested; - this.publishPreparedChannelCompletion(channel, entry.prepared); - } - - const deferred = Array.from(deferredChannels.keys()).filter( - (channel) => channel.pid === pid, - ); - for (const channel of deferred) { - deferredChannels.delete(channel); - if (!this.isRegisteredChannel(channel)) continue; - channel.handling = false; - // Re-enter through the normal listener so polling mode and the browser's - // event-loop yielding policy retain their existing behavior. - this.relistenChannel(channel); } - const finalState = getState(pid); - if (finalState === PROCESS_STATE_STOPPED) { - this.stoppedPids.add(pid); - return false; - } - if (finalState !== PROCESS_STATE_RUNNING) { - this.discardStoppedChannelStateForProcess(pid); - return false; - } - return true; + this.schedulePoll(); } - /** - * Wake one host-owned blocker after resume preflight already copied a caught - * signal into its exact channel. The process stop gate remains armed, so a - * synchronous completion is parked until the full process scan succeeds. - */ - private interruptStoppedChannelWithPreparedSignal( - channel: ChannelInfo, - ): boolean { - const waitIndex = this.waitingForChild.findIndex( - (waiter) => waiter.channel === channel, - ); - if (waitIndex >= 0) { - const [waiter] = this.waitingForChild.splice(waitIndex, 1); - if (this.interruptWaiterWithPendingSignal(waiter)) return true; - this.waitingForChild.splice(waitIndex, 0, waiter); - return false; - } - - const sleep = this.pendingSleeps.get(channel); - if (sleep) { - clearTimeout(sleep.timer); - this.pendingSleeps.delete(channel); - this.completeSleepWithSignalCheck( - sleep.channel, - sleep.syscallNr, - sleep.origArgs, - sleep.retVal, - sleep.errVal, - sleep.outputWrites, - ); - return true; - } - - const futex = this.pendingFutexWaits.get(channel); - if (futex) { - if (futex.interrupt) { - futex.interrupt(-EINTR_ERRNO, EINTR_ERRNO); - } else { - Atomics.notify( - new Int32Array(channel.memory.buffer), - futex.futexIndex, - 1, - ); - } - return true; + private relistenChannel(channel: ChannelInfo): void { + const testHook = this.#scratchBoundaryTestHooks?.relistenChannel; + if (testHook) { + testHook(channel); + return; } - - let blocked = - this.pendingPollRetries.has(channel) || - (this.pendingAdvisoryLockRetries?.has(channel) ?? false) || - this.pendingSelectRetries.has(channel); - for (const readers of this.pendingPipeReaders.values()) { - if (readers.some((reader) => reader.channel === channel)) { - blocked = true; - break; - } + if (this.#kernelFatalError !== null) { + channel.handling = true; + return; } - if (!blocked) { - for (const writers of this.pendingPipeWriters.values()) { - if (writers.some((writer) => writer.channel === channel)) { - blocked = true; - break; - } - } + const parked = this.parkedChannelCompletions?.get(channel); + if (parked) { + parked.relistenRequested = true; + parked.prepared.relistenRequested = true; + channel.handling = true; + return; } - if (!blocked) return false; + if (this.deferChannelWhileStopped(channel)) return; - this.cancelParkedFifoOpen(channel); - this.removePendingPipeReader(channel); - this.removePendingPipeWriter(channel); - this.completeChannelRaw(channel, -EINTR_ERRNO, EINTR_ERRNO); - this.relistenChannel(channel); - return true; + // Clear handling flag so the poller can pick up this channel again + channel.handling = false; + if (!this.isRegisteredChannel(channel)) return; + // In polling mode, don't re-listen — the poller will pick up the next syscall + if (this.usePolling) return; + this.relistenCount++; + const useImmediate = this.relistenCount >= this.relistenBatchSize; + if (useImmediate) { + this.relistenCount = 0; + this.#scheduleImmediateListenerRoot( + `channel relisten pid=${channel.pid}`, + () => this.listenOnChannel(channel), + ); + } else { + this.#scheduleMicrotaskListenerRoot( + `channel relisten pid=${channel.pid}`, + () => this.listenOnChannel(channel), + ); + } } - /** Release a kernel-owned FIFO rendezvous before a host path retires or - * completes the parked open without re-entering the original syscall. */ - private cancelParkedFifoOpen(channel: ChannelInfo): boolean { - if (!this.kernelInstance || !this.kernelMemory) return false; - if (!this.isProcessExecutionActive(channel.pid)) return false; - let syscallNr: number; - try { - syscallNr = new DataView( - channel.memory.buffer, - channel.channelOffset, - ).getUint32(CH_SYSCALL, true); - } catch { - return false; + /** + * Complete a channel with just return value and errno (no scatter/gather). + * Used for thread exit where we need to unblock the worker. + */ + private completeChannelRaw( + channel: ChannelInfo, + retVal: number, + errVal: number, + entry?: KernelWorkerEntryContext, + relistenRequested = false, + ): void { + this.#releaseBlockingRetrySnapshot(channel, entry); + this.#finishChannelRequest(channel); + const testHook = this.#scratchBoundaryTestHooks?.completeChannelRaw; + if (testHook) { + testHook(channel, retVal, errVal); + return; } - if (syscallNr !== SYS_OPEN && syscallNr !== SYS_OPENAT) return false; - try { - const result = this.runSyntheticMemorySyscall( - channel, - SYS_THREAD_CANCEL, - [this.guestTidForChannel(channel)], - ); - return result.errVal === 0; - } catch { - // Process/thread teardown also owns idempotent kernel-side cleanup. - return false; + this.clearSocketTimeout(channel); + this.clearReadinessWait(channel); + const prepared: PreparedChannelCompletion = { + kind: "raw", + outputWrites: [], + retVal, + errVal, + materialized: false, + relistenRequested, + }; + this.materializePreparedChannelCompletion(channel, prepared, entry); + if (entry) { + this.#drainAndProcessWakeupEventsWithinKernelEntry(entry); + } else { + this.drainAndProcessWakeupEvents(); + } + if (entry) { + entry.deferProtocolEffect(() => { + this.publishOrParkChannelCompletion(channel, prepared); + }); + } else { + this.publishOrParkChannelCompletion(channel, prepared); } } /** - * Consume a cancellation request that won the race with FIFO-open retry - * registration. Only open/openat reach this path: non-cancellation-point - * syscalls must leave the token for their next real cancellation point. + * Publish a raw completion and re-arm its exact mailbox in one ordered host + * phase when called from a scoped kernel entry. */ - private interruptPendingFifoOpenCancellation( + private completeChannelRawAndRelisten( channel: ChannelInfo, - syscallNr: number, - ): boolean { - if (syscallNr !== SYS_OPEN && syscallNr !== SYS_OPENAT) return false; - if (!this.pendingCancels.has(channel)) return false; - - // handleBlockingRetry is entered only after the kernel reserved the FIFO - // endpoint and returned EAGAIN. Release that exact reservation before the - // channel is completed, then retire the one-shot host cancellation token. - if (!this.cancelParkedFifoOpen(channel)) return false; - this.pendingCancels.delete(channel); - this.completeChannelRaw(channel, -EINTR_ERRNO, EINTR_ERRNO); - this.relistenChannel(channel); - return true; + retVal: number, + errVal: number, + entry?: KernelWorkerEntryContext, + ): void { + // WHY: publication and relistening are one protocol transaction. Encoding + // the relisten request in the prepared completion prevents a publication + // throw from being followed by a second effect that re-arms a mailbox with + // incomplete return state. + this.completeChannelRaw( + channel, + retVal, + errVal, + entry, + true, + ); } /** - * Replace the parked success result of a clone whose deferred thread Worker - * could not be constructed. The entry layer separately rolls back ThreadInfo, - * channel, allocator, and Worker registries before this completion publishes. + * Handle EAGAIN retry for blocking syscalls. + * The process stays blocked while we retry asynchronously. */ - failDeferredCloneLaunch(pid: number, tid: number, errno: number): boolean { - for (const [channel, parked] of this.parkedChannelCompletions ?? []) { - const clone = parked.prepared.deferredClone; - if (channel.pid !== pid || clone?.tid !== tid) continue; - - // WHY: the guest mailbox is mutable while this completion is parked. - // Clear only the parent-TID address validated for the original clone, - // never flags/pointers re-read from potentially replaced channel bytes. - const ptidPtr = clone.parentTidPointer; - if (ptidPtr !== undefined) { - if ( - !isValidMemoryRange( - new Uint8Array(channel.memory.buffer), - ptidPtr, - 4, - ) - ) { - return false; - } - new DataView(channel.memory.buffer).setInt32(ptidPtr, 0, true); - } + private resolvePollReadinessIndices( + pid: number, + dispatch: PlannedBlockingChannelDispatch, + entry: KernelWorkerEntryContext, + ): { pipeIndices: number[]; acceptIndices: number[] } { + // Prefer kernel_get_fd_pipe_idx which handles both pipes AND sockets. + // Fall back to kernel_get_socket_recv_pipe for older kernels. + const getFdPipeIdx = this.#kernelInstanceForEntry(entry).exports.kernel_get_fd_pipe_idx as + ((pid: number, fd: number) => number) | undefined; + const getRecvPipe = + getFdPipeIdx ?? + (this.#kernelInstanceForEntry(entry).exports.kernel_get_socket_recv_pipe as + ((pid: number, fd: number) => number) | undefined); + const getAcceptWakeIdx = this.#kernelInstanceForEntry(entry).exports + .kernel_get_fd_accept_wake_idx as + ((pid: number, fd: number) => number) | undefined; + if (!getRecvPipe && !getAcceptWakeIdx) + return { pipeIndices: [], acceptIndices: [] }; - parked.prepared.outputWrites = []; - parked.prepared.retVal = -1; - parked.prepared.errVal = errno; - parked.prepared.deferredClone = undefined; - return true; + const nfds = Number(dispatch.adjustedArgs[1] ?? 0); + if (nfds === 0) + return { pipeIndices: [], acceptIndices: [] }; + const pollfds = dispatch.plannedScratchWrites.find( + (write) => write.argIndex === 0, + ); + const expectedBytes = nfds * STRUCT_SIZE_WASM_POLL_FD; + if ( + !Number.isSafeInteger(nfds) + || nfds < 0 + || !Number.isSafeInteger(expectedBytes) + || !pollfds + || pollfds.inputBytes === null + || pollfds.size !== expectedBytes + || pollfds.inputBytes.byteLength !== expectedBytes + ) { + throw new KernelScratchError( + "poll retry plan has no complete detached pollfd table", + EIO, + ); } - return false; - } - /** Discard state that must never publish into a dead or replaced channel. */ - private discardStoppedChannelStateForProcess( - pid: number, - clearProcessStop = true, - ): void { - const starts = this.deferredProcessWorkerStarts?.get(pid); - if (starts) { - this.deferredProcessWorkerStarts.delete(pid); - for (const entry of starts) { - try { - entry.cancel(); - } catch { - /* best-effort generation teardown */ + const indices: number[] = []; + const acceptIndices: number[] = []; + const pollView = new DataView( + pollfds.inputBytes.buffer, + pollfds.inputBytes.byteOffset, + pollfds.inputBytes.byteLength, + ); + const POLLIN = 0x001; + for (let i = 0; i < nfds; i++) { + const pollfdOffset = i * STRUCT_SIZE_WASM_POLL_FD; + const fd = pollView.getInt32( + pollfdOffset + WASM_POLL_FD_FD_OFFSET, + true, + ); + if (fd < 0) continue; + const events = pollView.getInt16( + pollfdOffset + WASM_POLL_FD_EVENTS_OFFSET, + true, + ); + if (getRecvPipe) { + const pipeIdx = getRecvPipe(pid, fd); + if (pipeIdx >= 0) { + indices.push(pipeIdx); + } + } + if (getAcceptWakeIdx && (events & POLLIN) !== 0) { + const acceptIdx = getAcceptWakeIdx(pid, fd); + if (acceptIdx >= 0) { + acceptIndices.push(acceptIdx); } } } - for (const channel of Array.from( - this.parkedChannelCompletions?.keys() ?? [], - )) { - if (channel.pid === pid) this.parkedChannelCompletions.delete(channel); - } - for (const channel of Array.from( - this.deferredStoppedChannels?.keys() ?? [], - )) { - if (channel.pid === pid) this.deferredStoppedChannels.delete(channel); - } - if (clearProcessStop) this.stoppedPids?.delete(pid); - if (clearProcessStop) this.pendingResumePids?.delete(pid); - } - - private discardStoppedChannelState(channel: ChannelInfo): void { - this.parkedChannelCompletions?.delete(channel); - this.deferredStoppedChannels?.delete(channel); + return { pipeIndices: indices, acceptIndices }; } - /** - * Host-teardown reclamation. - * - * [JSC-TERMINATE-ATOMICS-WAIT-LEAK] — WORKAROUND, remove when the engine bug - * is fixed; see docs/jsc-terminate-atomics-wait-workaround.md. - * - * On JSC (Safari, and Bun via `bun`'s JavaScriptCore), `Worker.terminate()` - * cannot kill (or free the memory of) a worker parked in `Atomics.wait` on - * its syscall channel — which is where every idle/blocked process worker sits - * (accept, read, poll, select, sleep, futex, the channel round-trip). - * Terminating them directly leaks their threads + committed working set, so - * each image switch accumulates a whole machine and the tab OOMs. V8 (Chrome, - * Node) interrupts the wait on terminate and reclaims, so this is a no-op cost - * there and is invoked unconditionally by both host entries for parity. - * - * For every worker currently parked at CH_PENDING we complete its syscall - * with EINTR AND queue a SIGKILL into the channel signal slot. The glue's - * `__deliver_pending_signal` (run right after the syscall returns) sees - * SIGKILL and calls the `kernel_exit` import directly (NOT musl `_exit()`, - * which would re-park the worker in the SYS_exit spin loop) → the `unreachable` - * trap that worker-main catches → the worker posts `{exit}` and returns to its - * JS event loop, where the host's `terminate()` (or the `{exit}` handler) can - * finally reclaim it. - * - * SIGKILL is never delivered to the guest in normal operation (it is - * uncatchable — the kernel enforces the default terminate action itself), so - * the glue treats a queued SIGKILL unambiguously as "exit now". - */ - killAllBlockedForTeardown(): Set { - // Drop all pending-retry bookkeeping first so nothing tries to re-arm a - // syscall behind the teardown. The actual wake is driven off the channels' - // CH_STATUS below, not off these maps — a worker parked on accept(), - // epoll_pwait(), a socket read, or a futex may not appear in any of these - // maps, but it is always sitting at CH_PENDING on its channel. - for (const e of this.pendingPollRetries.values()) if (e.timer) clearTimeout(e.timer); - for (const e of this.pendingAdvisoryLockRetries?.values() ?? []) { - clearTimeout(e.timer); - } - for (const e of this.pendingSelectRetries.values()) if (e.timer) clearTimeout(e.timer); - for (const e of this.pendingSleeps.values()) clearTimeout(e.timer); - for (const e of this.pendingSignalWaits.values()) clearTimeout(e.timer); - this.pendingPipeReaders.clear(); - this.pendingPipeWriters.clear(); - this.pendingPollRetries.clear(); - this.pendingAdvisoryLockRetries?.clear(); - this.pendingSelectRetries.clear(); - this.pendingSleeps.clear(); - this.pendingSignalWaits.clear(); - this.signalWaitDeadlines.clear(); - this.pendingFutexWaits.clear(); + private resolveEpollReadinessIndices( + pid: number, + entry: KernelWorkerEntryContext, + ): { + pipeIndices: number[]; + acceptIndices: number[]; + } { + const getRecvPipe = this.#kernelInstanceForEntry(entry).exports + .kernel_get_socket_recv_pipe as + ((pid: number, fd: number) => number) | undefined; + const getAcceptWakeIdx = this.#kernelInstanceForEntry(entry).exports + .kernel_get_fd_accept_wake_idx as + ((pid: number, fd: number) => number) | undefined; + if (!getRecvPipe && !getAcceptWakeIdx) + return { pipeIndices: [], acceptIndices: [] }; - // Wake every channel (process main threads + pthreads) that is parked in - // Atomics.wait — i.e. status CH_PENDING — completing its syscall with - // -EINTR and queueing SIGKILL so the guest glue runs its cooperative exit. - // Returns the set of pids we actually woke so the caller can drain only for - // those (a not-woken straggler never posts {exit} and must be terminated - // directly, not waited on). - const woken = new Set(); - const getExitStatus = this.kernelInstance?.exports - .kernel_get_process_exit_status as ((pid: number) => number) | undefined; - for (const registration of this.processes.values()) { - // Skip processes that have already exited (kernel state == Exited, i.e. - // status != -1). A sibling thread may have called exit_group and set the - // process's real exit status while this thread is still parked; forcing - // our own kernel_exit on that parked thread would clobber that status - // (e.g. a pthread exit(0) turning into 137). Only genuinely-live processes - // need waking; already-exited stragglers are reaped/terminated normally. - if (getExitStatus && getExitStatus(registration.pid) !== -1) continue; - for (const channel of registration.channels) { - let status: number; - try { - const i32 = new Int32Array(channel.memory.buffer, channel.channelOffset); - status = Atomics.load( - i32, - CH_STATUS / Int32Array.BYTES_PER_ELEMENT, - ); - } catch { continue; } - if (status !== CH_PENDING) continue; - try { - this.wakeChannelForTeardownExit(channel); - woken.add(channel.pid); - } catch (err) { - console.error(`[killAllBlockedForTeardown] wake failed for pid=${channel.pid} off=${channel.channelOffset}: ${err}`); + const key = `${pid}:`; + const indices: number[] = []; + const acceptIndices: number[] = []; + const EPOLLIN = 0x001; + for (const [k, interests] of this.epollInterests) { + if (!k.startsWith(key)) continue; + for (const interest of interests) { + if (getRecvPipe) { + const pipeIdx = getRecvPipe(pid, interest.fd); + if (pipeIdx >= 0) { + indices.push(pipeIdx); + } + } + if (getAcceptWakeIdx && (interest.events & EPOLLIN) !== 0) { + const acceptIdx = getAcceptWakeIdx(pid, interest.fd); + if (acceptIdx >= 0) { + acceptIndices.push(acceptIdx); + } } } } - return woken; + return { pipeIndices: indices, acceptIndices }; } - /** - * Cooperatively unwind the exact browser Worker generation discarded by - * exec without exiting the persistent kernel Process. - * - * Every parked channel receives an internal SIGKILL marker plus EINTR. The - * guest glue enters its existing non-returning kernel_exit import; worker-main - * recognizes the exec marker, skips SYS_EXIT, and returns so the browser - * wrapper can publish an exact memory_quiescent ownership fence. - */ - wakeProcessWorkersForExecRetirement( - pid: number, - expectedMemory: WebAssembly.Memory, - ): Set { - const wokenOffsets = new Set(); - const registration = this.processes.get(pid); - if (!registration) return wokenOffsets; - if (registration.memory !== expectedMemory) { - throw new Error( - `Exec retirement generation changed for pid ${pid}`, - ); - } - const execCallers = registration.channels.filter((channel) => { - if (channel.memory !== expectedMemory) return false; - const view = new DataView( - expectedMemory.buffer, - channel.channelOffset, - ); - const syscall = view.getUint32(CH_SYSCALL, true); - const status = Atomics.load( - new Int32Array(expectedMemory.buffer, channel.channelOffset), - CH_STATUS / 4, - ); - return status === CH_PENDING - && (syscall === SYS_EXECVE || syscall === SYS_EXECVEAT); - }); - if (execCallers.length !== 1) { - throw new Error( - `Exec retirement expected exactly one execve/execveat caller for pid ${pid}, found ${execCallers.length}`, - ); - } - for (const channel of registration.channels) { - if (channel.memory !== expectedMemory) { - throw new Error( - `Exec retirement found a mixed memory generation for pid ${pid}`, - ); + private wakeBlockedAccept(acceptIdx: number): void { + const matches = Array.from(this.pendingPollRetries.entries()).filter( + ([, e]) => e.acceptIndices?.includes(acceptIdx), + ); + for (const [key, entry] of matches) { + if (this.pendingPollRetries.get(key) !== entry) continue; + if (entry.timer !== null) { + this.#cancelRegisteredTimeout(entry.timer); + } + this.pendingPollRetries.delete(key); + if (this.isRegisteredChannel(entry.channel)) { + this.retrySyscall(entry.channel); } - const i32 = new Int32Array( - channel.memory.buffer, - channel.channelOffset, - ); - if (Atomics.load(i32, CH_STATUS / 4) !== CH_PENDING) continue; - const view = new DataView( - channel.memory.buffer, - channel.channelOffset, - ); - view.setUint32(CH_SIG_SIGNUM, SIGKILL, true); - view.setUint32(CH_SIG_HANDLER, 0, true); - view.setUint32(CH_SIG_SI_CODE, EXEC_RETIRE_SIGNAL_CODE, true); - // WHY: this is not an ordinary syscall completion. Exec has already - // committed and the generation is being retired, so relistening or - // copying scratch-backed outputs would let the discarded Worker issue - // more work. Publish only EINTR plus the private retirement marker. - this.completeChannelRaw(channel, -1, EINTR_ERRNO); - wokenOffsets.add(channel.channelOffset); } - return wokenOffsets; } - /** Complete a blocked channel with EINTR and queue SIGKILL so the guest glue - * runs its cooperative exit. See {@link killAllBlockedForTeardown}. - * [JSC-TERMINATE-ATOMICS-WAIT-LEAK] — see - * docs/jsc-terminate-atomics-wait-workaround.md. */ - private wakeChannelForTeardownExit(channel: ChannelInfo): void { - const pv = new DataView(channel.memory.buffer, channel.channelOffset); - // Queue SIGKILL for the glue's post-syscall __deliver_pending_signal. The - // syscall handlers may have called dequeueSignalForDelivery, but SIGKILL - // is never a queued Handler signal, so this slot is ours to set. Zero the - // handler slot too: SIGKILL is uncatchable, so it must never dispatch a - // userspace handler — the glue keys off signum==9 and exits before reading - // the handler, but clearing it keeps this write self-consistent. - pv.setUint32(CH_SIG_SIGNUM, SIGKILL, true); - pv.setUint32(CH_SIG_HANDLER, 0, true); - // Read the still-pending syscall request and complete it with -EINTR. - const syscallNr = pv.getUint32(CH_SYSCALL, true); - const origArgs: number[] = []; - for (let i = 0; i < CH_ARGS_COUNT; i++) { - origArgs.push(Number(pv.getBigInt64(CH_ARGS + i * CH_ARG_SIZE, true))); + private wakeBlockedPoll(pid: number, pipeIdx: number): void { + // retrySyscall runs handleSyscall synchronously, which can re-insert + // the same key via pendingPollRetries.set when the kernel returns + // EAGAIN. JS Map iterators are not snapshots — re-inserted entries + // appear at the new tail and the iterator yields them, livelocking + // wakeBlockedPoll-hit / poll / poll-register inside one tick. Mirror + // wakeAllBlockedRetries' snapshot-and-skip-if-replaced pattern. + const matches = Array.from(this.pendingPollRetries.entries()).filter( + ([, e]) => e.channel.pid === pid && e.pipeIndices.includes(pipeIdx), + ); + for (const [key, entry] of matches) { + if (this.pendingPollRetries.get(key) !== entry) continue; + if (entry.timer !== null) { + this.#cancelRegisteredTimeout(entry.timer); + } + this.pendingPollRetries.delete(key); + if (this.isRegisteredChannel(entry.channel)) { + this.retrySyscall(entry.channel); + } } - this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], -1, EINTR_ERRNO); } /** - * Schedule re-listen on a channel. + * Public wake helper for host-side pipe writes (TCP bridges, HTTP + * bridges, etc.). Call this AFTER directly writing into a pipe via + * `kernel_pipe_write` or `kernel_inject_connection`. * - * Uses queueMicrotask for speed (near-zero delay between syscalls). - * Every Nth call (relistenBatchSize), yields via setImmediate so timer - * callbacks (setTimeout/setInterval) can fire — prevents event loop - * starvation while keeping throughput close to Node.js native setImmediate. + * In order: + * 1. Wake any process blocked in read/recv on this pipe + * (`pendingPipeReaders`). + * 2. Wake any process blocked in poll/ppoll/pselect6 whose + * `pipeIndices` includes this pipe (`pendingPollRetries`). + * Pass `pidFilter` only when ownership cannot be shared. Accepted TCP + * pipes omit it because fork children can inherit the same connection. + * 3. Schedule a broad wake (`scheduleWakeBlockedRetries`) for + * everything else. * - * The dedicated browser worker sets relistenBatchSize=1 so every relisten - * is deferred through its MessageChannel-backed setImmediate queue. This - * lets worker messages and timers interleave with multi-process syscall - * traffic. Node.js retains the larger native-setImmediate batch. - */ - private relistenCount = 0; - /** How many syscalls to process via microtask before yielding to the event - * loop via setImmediate. Default 64 is tuned for Node.js. The dedicated - * browser worker sets this to 1 so worker messages keep progressing. */ - relistenBatchSize = 64; + * Without step 2, blocked pollers wait for the fallback timer in + * `handleBlockingRetry` to fire, which is the bug behind PR fixing + * the WordPress LAMP demo's slow install.php (see commit history). + */ + public notifyPipeReadable(pipeIdx: number, pidFilter?: number): void { + this.#runOrDeferKernelEntry( + `pipe readable notification index=${pipeIdx}`, + (entry) => { + this.#notifyPipeReadableWithinKernelEntry( + pipeIdx, + pidFilter, + entry, + ); + return undefined; + }, + ); + } + + #notifyPipeReadableWithinKernelEntry( + pipeIdx: number, + pidFilter: number | undefined, + entry: KernelWorkerEntryContext, + ): void { + // 1. Blocked readers + const readers = this.pendingPipeReaders.get(pipeIdx); + if (readers && readers.length > 0) { + this.pendingPipeReaders.delete(pipeIdx); + for (const reader of readers) { + if (this.isRegisteredChannel(reader.channel)) { + this.retrySyscall(reader.channel); + } + } + } + // 2. Blocked pollers watching this pipe. Snapshot-and-skip-if-replaced: + // retrySyscall runs synchronously and a re-parking wait re-inserts the + // same exact-channel key, which a raw for..of over the live Map would + // revisit forever (see wakeBlockedPoll / sendSignalToProcess). + const pollMatches = Array.from(this.pendingPollRetries.entries()).filter( + ([, e]) => + (pidFilter === undefined || e.channel.pid === pidFilter) && + e.pipeIndices.includes(pipeIdx), + ); + for (const [key, entry] of pollMatches) { + if (this.pendingPollRetries.get(key) !== entry) continue; + if (entry.timer !== null) this.#cancelRegisteredTimeout(entry.timer); + this.pendingPollRetries.delete(key); + if (this.isRegisteredChannel(entry.channel)) { + this.retrySyscall(entry.channel); + } + } + // 3. Broad wake for any other pending retries + this.scheduleWakeBlockedRetries(entry); + } /** - * When true, use a MessageChannel-based poller to check all channels - * instead of per-channel Atomics.waitAsync listeners. - * - * This avoids a V8 bug where Atomics.waitAsync microtask chains from - * multiple concurrent processes freeze the main thread. The poller - * uses MessageChannel for ~0ms dispatch (bypassing the browser's 4ms - * timer clamp on setTimeout/setInterval), with periodic setTimeout - * yields every 4ms to keep timers and rendering alive. - * - * This remains a legacy opt-in for browser embeddings that run the kernel - * on the main thread. The dedicated browser worker and Node.js both keep - * the default event-driven Atomics.waitAsync mode. - */ - usePolling = false; - private pollMC: MessageChannel | null = null; - private pollScheduled = false; - private pollLastYield = 0; + * Public wake helper for host-side pipe reads (response pump in + * the TCP/HTTP bridges). Call this AFTER directly reading data + * from a pipe so any process blocked writing because the pipe was + * full can resume, plus a broad wake. + */ + public notifyPipeWritable(pipeIdx: number): void { + this.#runOrDeferKernelEntry( + `pipe writable notification index=${pipeIdx}`, + (entry) => { + this.#notifyPipeWritableWithinKernelEntry(pipeIdx, entry); + return undefined; + }, + ); + } - /** Start the channel poller. Called automatically when usePolling=true - * and a process is registered. */ - private startPolling(): void { - if (this.pollMC !== null) return; - this.pollMC = new MessageChannel(); - this.pollMC.port1.onmessage = () => this.pollTick(); - this.pollLastYield = performance.now(); - this.schedulePoll(); + #notifyPipeWritableWithinKernelEntry( + pipeIdx: number, + entry: KernelWorkerEntryContext, + ): void { + const writers = this.pendingPipeWriters.get(pipeIdx); + if (writers && writers.length > 0) { + this.pendingPipeWriters.delete(pipeIdx); + for (const writer of writers) { + if (this.isRegisteredChannel(writer.channel)) { + this.retrySyscall(writer.channel); + } + } + } + this.scheduleWakeBlockedRetries(entry); } - /** Stop the channel poller. Called when all processes are unregistered. */ - private stopPolling(): void { - if (this.pollMC !== null) { - this.pollMC.port1.close(); - this.pollMC = null; - this.pollScheduled = false; + /** Cancel all pending poll retries for a given pid (used during cleanup) */ + private cleanupPendingPollRetries(pid: number): void { + for (const [key, entry] of this.pendingPollRetries) { + if (entry.channel.pid === pid) { + if (entry.timer) this.#cancelRegisteredTimeout(entry.timer); + this.pendingPollRetries.delete(key); + } + } + } + + /** Cancel all pending select/pselect retries for a given pid. */ + private cleanupPendingSelectRetries(pid: number): void { + for (const [key, entry] of this.pendingSelectRetries) { + if (entry.channel.pid === pid) { + if (entry.timer !== null) { + this.#cancelRegisteredTimeout(entry.timer); + this.#cancelRegisteredImmediate(entry.timer); + } + this.pendingSelectRetries.delete(key); + } } } - /** Schedule the next poll tick. Uses MessageChannel for ~0ms dispatch, - * with a setTimeout yield every 4ms to prevent timer starvation. */ - private schedulePoll(): void { - if (this.pollScheduled || !this.pollMC) return; - this.pollScheduled = true; - const now = performance.now(); - if (now - this.pollLastYield >= 4) { - // Yield to timers/rendering - this.pollLastYield = now; - setTimeout(() => { - this.pollScheduled = false; - this.pollTick(); - }, 0); - } else { - this.pollMC.port2.postMessage(null); - } + /** + * Drain kernel wakeup events and process readiness/lifecycle wakeups. + * Called after each syscall completion. The kernel pushes events from + * PipeBuffer operations, listener backlog changes, and datagram send-state + * changes such as capacity, association, shutdown, close, or unlink, plus + * advisory-lock changes that may unblock a parked F_SETLKW request. + */ + private drainAndProcessWakeupEvents(): void { + this.#runOrDeferKernelEntry( + "kernel wake-event drain", + (entry) => { + this.#drainAndProcessWakeupEventsWithinKernelEntry(entry); + return undefined; + }, + this, + ); } - /** Poll all active channels for PENDING syscalls. */ - private pollTick(): void { - this.pollScheduled = false; - if (!this.pollMC || this.activeChannels.length === 0) return; + #drainAndProcessWakeupEventsWithinKernelEntry( + entry: KernelWorkerEntryContext, + ): void { + const drainFn = this.#kernelInstanceForEntry(entry).exports.kernel_drain_wakeup_events as + | ((outPtr: KernelPointer, outLen: number, maxEvents: number) => number) + | undefined; + if (!drainFn) return; - // Snapshot to handle mutations during iteration - // (attachThreadChannel/removeChannel). - const channels = this.activeChannels.slice(); - for (const channel of channels) { - if (!this.isRegisteredChannel(channel)) continue; - if (this.stoppedPids?.has(channel.pid)) { - const stoppedView = new Int32Array( - channel.memory.buffer, - channel.channelOffset, + const MAX_EVENTS = 256; + const BYTES_PER_EVENT = 5; + const bufSize = MAX_EVENTS * BYTES_PER_EVENT; + + // Own the complete batch before acting on any event. STOPPED/CONTINUED + // processing can send SIGCHLD and complete a parent wait, both of which + // reuse this scratch allocation. + const events: OwnedKernelWakeEvent[] = []; + for (;;) { + const batch = this.#requireMainScratchRegion().withLease((lease) => { + const count = this.#invokeEntryScratchExport( + entry, + lease, + "kernel_drain_wakeup_events", + [ + lease.exportPointer(0, bufSize), + bufSize, + MAX_EVENTS, + ], ); - channel.i32View = stoppedView; if ( - Atomics.load( - stoppedView, - CH_STATUS / Int32Array.BYTES_PER_ELEMENT, - ) === CH_PENDING + !Number.isSafeInteger(count) + || count > MAX_EVENTS ) { - this.deferChannelWhileStopped(channel); + throw new KernelScratchError( + `kernel wake drain returned invalid event count ${count}`, + EIO, + ); } - continue; + return { + count, + bytes: count > 0 + ? lease.copyOut(0, count * BYTES_PER_EVENT) + : new Uint8Array(0), + }; + }); + const { count } = batch; + if (count <= 0) break; + for (let i = 0; i < count; i++) { + const off = i * BYTES_PER_EVENT; + events.push({ + wakeIdx: + (batch.bytes[off] | + (batch.bytes[off + 1] << 8) | + (batch.bytes[off + 2] << 16) | + (batch.bytes[off + 3] << 24)) >>> + 0, + wakeType: batch.bytes[off + 4], + }); } - if (channel.handling) continue; - // Re-create view in case memory was grown - const i32View = new Int32Array( - channel.memory.buffer, - channel.channelOffset, - ); - channel.i32View = i32View; + if (count < MAX_EVENTS) break; + } + if (events.length === 0) return; + + const WAKE_READABLE = 1; + const WAKE_WRITABLE = 2; + const WAKE_ACCEPT = 4; + const WAKE_DATAGRAM_WRITABLE = 8; + const WAKE_ADVISORY_LOCK = 64; + let needBroadWake = false; + let needDatagramWriterWake = false; + let needAdvisoryLockWake = false; + + for (const { wakeIdx, wakeType } of events) { + const lifecycleEvent = + wakeType & (WAKE_PROCESS_STOPPED | WAKE_PROCESS_CONTINUED); + const lifecycleSupersededByExit = + lifecycleEvent !== 0 && + this.finalizeExitedProcessBeforeLifecycleNotification(wakeIdx, entry); + + if (!lifecycleSupersededByExit && wakeType & WAKE_PROCESS_STOPPED) { + (this.stoppedPids ??= new Set()).add(wakeIdx); + this.notifyParentOfChildStateTransition(wakeIdx, entry); + } + + if (!lifecycleSupersededByExit && wakeType & WAKE_PROCESS_CONTINUED) { + if (this.resumeStoppedProcess(wakeIdx, entry)) { + this.notifyParentOfChildStateTransition(wakeIdx, entry); + } else { + // Resume-time delivery can immediately apply a retained default + // stop and enqueue a new STOPPED wake after this method owned its + // initial scratch batch. Drain that follow-up now; host-originated + // SIGCONT has no guaranteed later syscall completion to do it. + this.#drainAndProcessWakeupEventsWithinKernelEntry(entry); + } + } + + if (wakeType & WAKE_READABLE) { + // Pipe became readable — wake pending readers on this pipe + const readers = this.pendingPipeReaders.get(wakeIdx); + if (readers && readers.length > 0) { + this.pendingPipeReaders.delete(wakeIdx); + for (const reader of readers) { + if (this.isRegisteredChannel(reader.channel)) { + this.retrySyscall(reader.channel); + } + } + } + } + + if (wakeType & WAKE_WRITABLE) { + // Pipe became writable — wake pending writers on this pipe + const writers = this.pendingPipeWriters.get(wakeIdx); + if (writers && writers.length > 0) { + this.pendingPipeWriters.delete(wakeIdx); + for (const writer of writers) { + if (this.isRegisteredChannel(writer.channel)) { + this.retrySyscall(writer.channel); + } + } + } + } + + if (wakeType & WAKE_ACCEPT) { + this.wakeBlockedAccept(wakeIdx); + } + + if (wakeType & WAKE_DATAGRAM_WRITABLE) { + // Datagram queues have no pipe token that identifies every blocked + // sender. Retry generic blocked writes synchronously so a short + // SO_SNDTIMEO cannot win after the send has become ready or acquired + // an immediate error. Poll, select, and epoll still use the broad path + // below so ppoll/pselect's deliberate signal-safe wake deferral + // remains intact. + needDatagramWriterWake = true; + } + + if (wakeType & WAKE_ADVISORY_LOCK) { + needAdvisoryLockWake = true; + } + if ( - Atomics.load( - i32View, - CH_STATUS / Int32Array.BYTES_PER_ELEMENT, - ) === CH_PENDING + wakeType & + (WAKE_READABLE | WAKE_WRITABLE | WAKE_ACCEPT | WAKE_DATAGRAM_WRITABLE) ) { - channel.handling = true; - this.handleSyscall(channel); + needBroadWake = true; } } - this.schedulePoll(); - } - - private relistenChannel(channel: ChannelInfo): void { - const parked = this.parkedChannelCompletions?.get(channel); - if (parked) { - parked.relistenRequested = true; - parked.prepared.relistenRequested = true; - channel.handling = true; - return; + // Any kernel readiness event may affect poll/select retries. + // + // If any of those retries is a signal-mask-swapping ppoll/pselect6, + // defer the wake a few ms. A pipe write from process X is often + // immediately followed by a cross-process signal (kill) from X — + // e.g. "write to pipe, then kill parent" — where the writer expects + // a blocked ppoll in the reader to observe BOTH events atomically. + // On a real kernel that works because X's two syscalls execute + // before the scheduler runs the reader. In our retry-based + // shared kernel, the pipe wakeup can fire a ppoll retry BEFORE + // X's follow-up kill is even sent by X's worker (Atomics.notify → + // uv_async round-trip takes 1–5ms). If the retry fires first, ppoll + // returns POLLIN and restores its sigmask; the late signal is then + // blocked and the handler never fires. See + // tests/sortix/os-test/signal/ppoll-block-sleep-write-raise. + // + // Deferring the broad wake a few ms gives X's follow-up syscalls + // time to land. Kill-triggered wakes (line ~2050) always use the + // immediate setImmediate path — by the time kill has been processed + // the signal is already queued, so there's no race. Pipe + // reader/writer wakes above run synchronously (not via this + // deferred path), so plain read/write throughput is unaffected. We + // only pay the delay when a pipe event happens to wake a ppoll or + // pselect6 caller. + if (needDatagramWriterWake) { + this.wakeBlockedFallbackWriters(); } - if (this.deferChannelWhileStopped(channel)) return; - - // Clear handling flag so the poller can pick up this channel again - channel.handling = false; - if (!this.isRegisteredChannel(channel)) return; - // In polling mode, don't re-listen — the poller will pick up the next syscall - if (this.usePolling) return; - this.relistenCount++; - const useImmediate = this.relistenCount >= this.relistenBatchSize; - if (useImmediate) { - this.relistenCount = 0; - setImmediate(() => this.listenOnChannel(channel)); - } else { - queueMicrotask(() => this.listenOnChannel(channel)); + if (needAdvisoryLockWake) { + this.wakeBlockedAdvisoryLockRetries(); + } + if (needBroadWake) { + if (this.anyPendingRetryNeedsSignalSafeWake()) { + this.scheduleWakeBlockedRetriesDeferred(entry); + } else { + this.scheduleWakeBlockedRetries(entry); + } } } - /** - * Complete a channel with just return value and errno (no scatter/gather). - * Used for thread exit where we need to unblock the worker. - */ - private completeChannelRaw( - channel: ChannelInfo, - retVal: number, - errVal: number, - ): void { - this.clearSocketTimeout(channel); - this.clearReadinessWait(channel); - this.pendingCancels.delete(channel); - const prepared: PreparedChannelCompletion = { - kind: "raw", - outputWrites: [], - retVal, - errVal, - materialized: false, - // Raw callers preserve their existing explicit relisten decision. - relistenRequested: false, - }; - this.materializePreparedChannelCompletion(channel, prepared); - this.drainAndProcessWakeupEvents(); - this.publishOrParkChannelCompletion(channel, prepared); - } - - /** - * Handle EAGAIN retry for blocking syscalls. - * The process stays blocked while we retry asynchronously. - */ - private resolvePollReadinessIndices( - pid: number, - origArgs: number[], - ): { pipeIndices: number[]; acceptIndices: number[] } { - // Prefer kernel_get_fd_pipe_idx which handles both pipes AND sockets. - // Fall back to kernel_get_socket_recv_pipe for older kernels. - const getFdPipeIdx = this.kernelInstance!.exports.kernel_get_fd_pipe_idx as - ((pid: number, fd: number) => number) | undefined; - const getRecvPipe = - getFdPipeIdx ?? - (this.kernelInstance!.exports.kernel_get_socket_recv_pipe as - ((pid: number, fd: number) => number) | undefined); - const getAcceptWakeIdx = this.kernelInstance!.exports - .kernel_get_fd_accept_wake_idx as - ((pid: number, fd: number) => number) | undefined; - if (!getRecvPipe && !getAcceptWakeIdx) - return { pipeIndices: [], acceptIndices: [] }; - - const fdsPtr = origArgs[0]; - const nfds = origArgs[1]; - if (fdsPtr === 0 || nfds === 0) - return { pipeIndices: [], acceptIndices: [] }; - - // Find the channel for this pid to read process memory - const channel = this.activeChannels.find((c) => c.pid === pid); - if (!channel) return { pipeIndices: [], acceptIndices: [] }; + /** Retry only blocking advisory-lock waiters after a Rust lock-state change. */ + private wakeBlockedAdvisoryLockRetries(): void { + const pending = this.pendingAdvisoryLockRetries; + if (!pending || pending.size === 0) return; - const indices: number[] = []; - const acceptIndices: number[] = []; - const processMem = new DataView(channel.memory.buffer); - const POLLIN = 0x001; - for (let i = 0; i < nfds; i++) { - const entry = fdsPtr + i * STRUCT_SIZE_WASM_POLL_FD; - const fd = processMem.getInt32( - entry + WASM_POLL_FD_FD_OFFSET, - true, - ); - if (fd < 0) continue; - const events = processMem.getInt16( - entry + WASM_POLL_FD_EVENTS_OFFSET, - true, - ); - if (getRecvPipe) { - const pipeIdx = getRecvPipe(pid, fd); - if (pipeIdx >= 0) { - indices.push(pipeIdx); - } - } - if (getAcceptWakeIdx && (events & POLLIN) !== 0) { - const acceptIdx = getAcceptWakeIdx(pid, fd); - if (acceptIdx >= 0) { - acceptIndices.push(acceptIdx); - } + // Retrying can synchronously re-park the same channel, so iterate a + // snapshot and skip entries that another wake already replaced. + const entries = Array.from(pending.entries()); + for (const [key, entry] of entries) { + if (pending.get(key) !== entry) continue; + this.#cancelRegisteredTimeout(entry.timer); + pending.delete(key); + if (this.isRegisteredChannel(entry.channel)) { + this.retrySyscall(entry.channel); } } - return { pipeIndices: indices, acceptIndices }; } - private resolveEpollReadinessIndices(pid: number): { - pipeIndices: number[]; - acceptIndices: number[]; - } { - const getRecvPipe = this.kernelInstance!.exports - .kernel_get_socket_recv_pipe as - ((pid: number, fd: number) => number) | undefined; - const getAcceptWakeIdx = this.kernelInstance!.exports - .kernel_get_fd_accept_wake_idx as - ((pid: number, fd: number) => number) | undefined; - if (!getRecvPipe && !getAcceptWakeIdx) - return { pipeIndices: [], acceptIndices: [] }; + /** STOPPED/CONTINUED are waitable even when SA_NOCLDSTOP suppresses SIGCHLD. */ + private notifyParentOfChildStateTransition( + pid: number, + entry: KernelWorkerEntryContext, + ): void { + const parentPid = this.getParentPid(pid, entry); + if (parentPid === undefined) return; - const key = `${pid}:`; - const indices: number[] = []; - const acceptIndices: number[] = []; - const EPOLLIN = 0x001; - for (const [k, interests] of this.epollInterests) { - if (!k.startsWith(key)) continue; - for (const interest of interests) { - if (getRecvPipe) { - const pipeIdx = getRecvPipe(pid, interest.fd); - if (pipeIdx >= 0) { - indices.push(pipeIdx); - } - } - if (getAcceptWakeIdx && (interest.events & EPOLLIN) !== 0) { - const acceptIdx = getAcceptWakeIdx(pid, interest.fd); - if (acceptIdx >= 0) { - acceptIndices.push(acceptIdx); - } - } - } + const hasNoCldStop = this.#kernelInstanceForEntry(entry).exports + .kernel_has_sa_nocldstop as (pid: number) => number; + if (hasNoCldStop(parentPid) !== 1) { + this.sendSignalToProcess(parentPid, SIGCHLD, true, entry); + } else { + // SA_NOCLDSTOP suppresses only SIGCHLD generation. The status record is + // still waitable and must wake a matching wait4/waitid caller. + this.wakeWaitingParent(parentPid, entry); } - return { pipeIndices: indices, acceptIndices }; } - private wakeBlockedAccept(acceptIdx: number): void { + /** Retry write-like fallback entries that have no targetable pipe token. */ + private wakeBlockedFallbackWriters(): void { const matches = Array.from(this.pendingPollRetries.entries()).filter( - ([, e]) => e.acceptIndices?.includes(acceptIdx), + ([, entry]) => entry.isWriteRetry, ); for (const [key, entry] of matches) { if (this.pendingPollRetries.get(key) !== entry) continue; - if (entry.timer !== null) { - clearTimeout(entry.timer); - } this.pendingPollRetries.delete(key); + if (entry.timer !== null) this.#cancelRegisteredTimeout(entry.timer); if (this.isRegisteredChannel(entry.channel)) { this.retrySyscall(entry.channel); } } } - private wakeBlockedPoll(pid: number, pipeIdx: number): void { - // retrySyscall runs handleSyscall synchronously, which can re-insert - // the same key via pendingPollRetries.set when the kernel returns - // EAGAIN. JS Map iterators are not snapshots — re-inserted entries - // appear at the new tail and the iterator yields them, livelocking - // wakeBlockedPoll-hit / poll / poll-register inside one tick. Mirror - // wakeAllBlockedRetries' snapshot-and-skip-if-replaced pattern. - const matches = Array.from(this.pendingPollRetries.entries()).filter( - ([, e]) => e.channel.pid === pid && e.pipeIndices.includes(pipeIdx), - ); - for (const [key, entry] of matches) { - if (this.pendingPollRetries.get(key) !== entry) continue; + private anyPendingRetryNeedsSignalSafeWake(): boolean { + for (const entry of this.pendingPollRetries.values()) { + if (entry.needsSignalSafeWake) return true; + } + for (const entry of this.pendingSelectRetries.values()) { + if (entry.needsSignalSafeWake) return true; + } + return false; + } + + /** Same as scheduleWakeBlockedRetries but delays by a few ms to allow + * follow-up cross-process syscalls from the event source to land. */ + private scheduleWakeBlockedRetriesDeferred( + entry?: KernelWorkerEntryContext, + ): void { + const schedule = (): undefined => { + if (this.pendingPollRetries.size === 0 && this.pendingSelectRetries.size === 0 && this.pendingPipeReaders.size === 0 && this.pendingPipeWriters.size === 0) return undefined; + this.postponeSignalSafePollRetries(SIGNAL_SAFE_POLL_WAKE_DELAY_MS); + this.postponeSignalSafeSelectRetries(SIGNAL_SAFE_POLL_WAKE_DELAY_MS); + if (this.wakeScheduled) return undefined; + this.wakeScheduled = true; + this.#registerTimeout(() => { + this.wakeScheduled = false; + this.wakeAllBlockedRetries(); + }, SIGNAL_SAFE_POLL_WAKE_DELAY_MS); + return undefined; + }; + if (entry) entry.deferProtocolEffect(schedule); + else schedule(); + } + + private postponeSignalSafePollRetries(delayMs: number): void { + const now = Date.now(); + for (const [key, entry] of this.pendingPollRetries) { + if (!entry.needsSignalSafeWake) continue; if (entry.timer !== null) { - clearTimeout(entry.timer); + this.#cancelRegisteredTimeout(entry.timer); } - this.pendingPollRetries.delete(key); - if (this.isRegisteredChannel(entry.channel)) { - this.retrySyscall(entry.channel); + + const remainingMs = entry.deadline && entry.deadline > 0 + ? Math.max(1, entry.deadline - now) + : delayMs; + const retryMs = Math.max(1, Math.min(delayMs, remainingMs)); + entry.timer = this.#registerTimeout(() => { + if (this.pendingPollRetries.get(key) !== entry) return; + this.pendingPollRetries.delete(key); + if (this.isRegisteredChannel(entry.channel)) { + this.retrySyscall(entry.channel); + } + }, retryMs); + } + } + + /** Keep pselect's fallback timer from bypassing the signal-safe wake grace. */ + private postponeSignalSafeSelectRetries(delayMs: number): void { + const now = Date.now(); + for (const [key, entry] of this.pendingSelectRetries) { + if (!entry.needsSignalSafeWake) continue; + if (entry.timer !== null) { + this.#cancelRegisteredTimeout(entry.timer); + this.#cancelRegisteredImmediate(entry.timer); } + + const remainingMs = entry.deadline > 0 + ? Math.max(1, entry.deadline - now) + : delayMs; + const retryMs = Math.max(1, Math.min(delayMs, remainingMs)); + entry.timer = this.#registerTimeout(() => { + if (this.pendingSelectRetries.get(key) !== entry) return; + this.pendingSelectRetries.delete(key); + if (!this.isRegisteredChannel(entry.channel)) return; + // WHY: the timer owns no kernel authority. The ordinary retry root + // re-reads the still-pending mailbox and dispatches the correct select + // shape under a fresh exact entry scope. + this.retrySyscall(entry.channel); + }, retryMs); } } /** - * Public wake helper for host-side pipe writes (TCP bridges, HTTP - * bridges, etc.). Call this AFTER directly writing into a pipe via - * `kernel_pipe_write` or `kernel_inject_connection`. - * - * In order: - * 1. Wake any process blocked in read/recv on this pipe - * (`pendingPipeReaders`). - * 2. Wake any process blocked in poll/ppoll/pselect6 whose - * `pipeIndices` includes this pipe (`pendingPollRetries`). - * Pass `pidFilter` only when ownership cannot be shared. Accepted TCP - * pipes omit it because fork children can inherit the same connection. - * 3. Schedule a broad wake (`scheduleWakeBlockedRetries`) for - * everything else. - * - * Without step 2, blocked pollers wait for the fallback timer in - * `handleBlockingRetry` to fire, which is the bug behind PR fixing - * the WordPress LAMP demo's slow install.php (see commit history). + * Schedule a microtask to wake all blocked poll/pselect6 retries. + * Coalesced via wakeScheduled flag — multiple calls within the same + * microtask batch result in only one wake cycle. This catches cross-process + * pipe writes, socket connections, and other state changes that unblock + * another process's pending poll/select. */ - public notifyPipeReadable(pipeIdx: number, pidFilter?: number): void { - // 1. Blocked readers - const readers = this.pendingPipeReaders.get(pipeIdx); - if (readers && readers.length > 0) { - this.pendingPipeReaders.delete(pipeIdx); - for (const reader of readers) { - if (this.isRegisteredChannel(reader.channel)) { - this.retrySyscall(reader.channel); + private scheduleWakeBlockedRetries( + entry?: KernelWorkerEntryContext, + ): void { + const testHook = + this.#scratchBoundaryTestHooks?.scheduleWakeBlockedRetries; + if (testHook) { + testHook(); + return; + } + const schedule = (): undefined => { + if (this.wakeScheduled) return undefined; + if (this.pendingPollRetries.size === 0 && this.pendingSelectRetries.size === 0 && this.pendingPipeReaders.size === 0 && this.pendingPipeWriters.size === 0) return undefined; + this.wakeScheduled = true; + // Use setImmediate (not queueMicrotask) so that timer callbacks + // (setTimeout/setInterval) can interleave. In browsers, microtask + // chains from queueMicrotask starve all macrotasks, breaking progress + // updates and timeouts. setImmediate goes through the polyfill which + // yields to the timer queue periodically. + this.#registerImmediate(() => { + this.wakeScheduled = false; + this.wakeAllBlockedRetries(); + }); + return undefined; + }; + // WHY: scheduler callbacks are host effects and must not run while Rust + // owns mutable kernel state. Their retry operations enter through fresh + // public roots after this exact scope has been revoked. + if (entry) entry.deferProtocolEffect(schedule); + else schedule(); + } + + /** + * Wake all blocked poll/pselect6 retries by cancelling their setImmediate + * timers and immediately re-executing the syscalls. + */ + private wakeAllBlockedRetries(): void { + // Snapshot and clear — retries may re-add themselves if still not ready + const pollEntries = Array.from(this.pendingPollRetries.entries()); + const selectEntries = Array.from(this.pendingSelectRetries.entries()); + this.pendingPollRetries.clear(); + this.pendingSelectRetries.clear(); + + for (const [_key, entry] of pollEntries) { + if (!this.isRegisteredChannel(entry.channel)) continue; + if (entry.timer !== null) { + this.#cancelRegisteredTimeout(entry.timer); + } + this.retrySyscall(entry.channel); + } + + for (const [, entry] of selectEntries) { + if (!this.isRegisteredChannel(entry.channel)) continue; + // Cancel both setTimeout and setImmediate handles (one will be a no-op) + this.#cancelRegisteredTimeout(entry.timer); + this.#cancelRegisteredImmediate(entry.timer); + // Re-dispatch to the right handler — SYS_SELECT and SYS_PSELECT6 have + // different time-struct shapes (timeval vs timespec). + this.retrySyscall(entry.channel); + } + + // Also wake all pending pipe readers — a cross-process write may have + // made data available on pipes that readers are waiting on. + if (this.pendingPipeReaders.size > 0) { + const pipeEntries = Array.from(this.pendingPipeReaders.entries()); + this.pendingPipeReaders.clear(); + for (const [, readers] of pipeEntries) { + for (const reader of readers) { + if (this.isRegisteredChannel(reader.channel)) { + this.retrySyscall(reader.channel); + } } } } - // 2. Blocked pollers watching this pipe. Snapshot-and-skip-if-replaced: - // retrySyscall runs synchronously and a re-parking wait re-inserts the - // same exact-channel key, which a raw for..of over the live Map would - // revisit forever (see wakeBlockedPoll / sendSignalToProcess). - const pollMatches = Array.from(this.pendingPollRetries.entries()).filter( - ([, e]) => - (pidFilter === undefined || e.channel.pid === pidFilter) && - e.pipeIndices.includes(pipeIdx), - ); - for (const [key, entry] of pollMatches) { - if (this.pendingPollRetries.get(key) !== entry) continue; - if (entry.timer !== null) clearTimeout(entry.timer); - this.pendingPollRetries.delete(key); - if (this.isRegisteredChannel(entry.channel)) { - this.retrySyscall(entry.channel); + + // Also wake all pending pipe writers — a cross-process read may have + // drained pipe buffer space that writers are waiting on. + if (this.pendingPipeWriters.size > 0) { + const writerEntries = Array.from(this.pendingPipeWriters.entries()); + this.pendingPipeWriters.clear(); + for (const [, writers] of writerEntries) { + for (const writer of writers) { + if (this.isRegisteredChannel(writer.channel)) { + this.retrySyscall(writer.channel); + } + } } } - // 3. Broad wake for any other pending retries - this.scheduleWakeBlockedRetries(); } /** - * Public wake helper for host-side pipe reads (response pump in - * the TCP/HTTP bridges). Call this AFTER directly reading data - * from a pipe so any process blocked writing because the pipe was - * full can resume, plus a broad wake. + * Remove a process's entries from pendingPipeReaders. + * Called during process cleanup. */ - public notifyPipeWritable(pipeIdx: number): void { - const writers = this.pendingPipeWriters.get(pipeIdx); - if (writers && writers.length > 0) { - this.pendingPipeWriters.delete(pipeIdx); - for (const writer of writers) { - if (this.isRegisteredChannel(writer.channel)) { - this.retrySyscall(writer.channel); - } + private cleanupPendingPipeReaders(pid: number): void { + for (const [pipeIdx, readers] of this.pendingPipeReaders) { + const filtered = readers.filter(r => r.pid !== pid); + if (filtered.length === 0) { + this.pendingPipeReaders.delete(pipeIdx); + } else { + this.pendingPipeReaders.set(pipeIdx, filtered); } } - this.scheduleWakeBlockedRetries(); } - /** Cancel all pending poll retries for a given pid (used during cleanup) */ - private cleanupPendingPollRetries(pid: number): void { - for (const [key, entry] of this.pendingPollRetries) { - if (entry.channel.pid === pid) { - if (entry.timer) clearTimeout(entry.timer); - this.pendingPollRetries.delete(key); + private cleanupPendingPipeWriters(pid: number): void { + for (const [pipeIdx, writers] of this.pendingPipeWriters) { + const filtered = writers.filter(w => w.pid !== pid); + if (filtered.length === 0) { + this.pendingPipeWriters.delete(pipeIdx); + } else { + this.pendingPipeWriters.set(pipeIdx, filtered); } } } - /** Cancel all pending select/pselect retries for a given pid. */ - private cleanupPendingSelectRetries(pid: number): void { - for (const [key, entry] of this.pendingSelectRetries) { - if (entry.channel.pid === pid) { - if (entry.timer !== null) { - clearTimeout(entry.timer); - clearImmediate(entry.timer); - } - this.pendingSelectRetries.delete(key); - } + /** + * Cancel a pending socket timeout timer for a channel. + */ + private clearSocketTimeout(channel: ChannelInfo): void { + const timer = this.socketTimeoutTimers.get(channel); + if (timer !== undefined) { + this.#cancelRegisteredTimeout(timer); + this.socketTimeoutTimers.delete(channel); } } - /** - * Drain kernel wakeup events and process readiness/lifecycle wakeups. - * Called after each syscall completion. The kernel pushes events from - * PipeBuffer operations, listener backlog changes, and datagram send-state - * changes such as capacity, association, shutdown, close, or unlink, plus - * advisory-lock changes that may unblock a parked F_SETLKW request. - */ - private drainAndProcessWakeupEvents(): void { - const drainFn = this.kernelInstance!.exports.kernel_drain_wakeup_events as - | ((outPtr: KernelPointer, outLen: number, maxEvents: number) => number) - | undefined; - if (!drainFn) return; + /** Reuse one absolute deadline across readiness retries for this syscall. */ + private getReadinessDeadline(channel: ChannelInfo, timeoutMs: number): number { + const testHook = this.#scratchBoundaryTestHooks?.getReadinessDeadline; + if (testHook) return testHook(channel, timeoutMs); + if (timeoutMs <= 0) return -1; + if (channel.readinessDeadline === undefined) { + channel.readinessDeadline = Date.now() + timeoutMs; + } + return channel.readinessDeadline; + } - const MAX_EVENTS = 256; - const BYTES_PER_EVENT = 5; - const bufSize = MAX_EVENTS * BYTES_PER_EVENT; + /** Clear readiness deadline and any still-parked retry for a completed call. */ + private clearReadinessWait(channel: ChannelInfo): void { + channel.readinessDeadline = undefined; + channel.readinessFinalCheck = undefined; - // Own the complete batch before acting on any event. STOPPED/CONTINUED - // processing can send SIGCHLD and complete a parent wait, both of which - // reuse this scratch allocation. - const events: OwnedKernelWakeEvent[] = []; - for (;;) { - const batch = this.requireMainScratchRegion().withLease((lease) => { - const count = lease.invokeKernelExport( - "kernel_drain_wakeup_events", - [ - lease.exportPointer(0, bufSize), - bufSize, - MAX_EVENTS, - ], - ); - if ( - !Number.isSafeInteger(count) - || count > MAX_EVENTS - ) { - throw new KernelScratchError( - `kernel wake drain returned invalid event count ${count}`, - EIO, - ); - } - return { - count, - bytes: count > 0 - ? lease.copyOut(0, count * BYTES_PER_EVENT) - : new Uint8Array(0), - }; - }); - const { count } = batch; - if (count <= 0) break; - for (let i = 0; i < count; i++) { - const off = i * BYTES_PER_EVENT; - events.push({ - wakeIdx: - (batch.bytes[off] | - (batch.bytes[off + 1] << 8) | - (batch.bytes[off + 2] << 16) | - (batch.bytes[off + 3] << 24)) >>> - 0, - wakeType: batch.bytes[off + 4], - }); - } - if (count < MAX_EVENTS) break; + const pollEntry = this.pendingPollRetries.get(channel); + if (pollEntry) { + if (pollEntry.timer !== null) this.#cancelRegisteredTimeout(pollEntry.timer); + this.pendingPollRetries.delete(channel); } - if (events.length === 0) return; - - const WAKE_READABLE = 1; - const WAKE_WRITABLE = 2; - const WAKE_ACCEPT = 4; - const WAKE_DATAGRAM_WRITABLE = 8; - const WAKE_ADVISORY_LOCK = 64; - let needBroadWake = false; - let needDatagramWriterWake = false; - let needAdvisoryLockWake = false; - for (const { wakeIdx, wakeType } of events) { - const lifecycleEvent = - wakeType & (WAKE_PROCESS_STOPPED | WAKE_PROCESS_CONTINUED); - const lifecycleSupersededByExit = - lifecycleEvent !== 0 && - this.finalizeExitedProcessBeforeLifecycleNotification(wakeIdx); + const advisoryLockEntry = this.pendingAdvisoryLockRetries?.get(channel); + if (advisoryLockEntry) { + this.#cancelRegisteredTimeout(advisoryLockEntry.timer); + this.pendingAdvisoryLockRetries.delete(channel); + } - if (!lifecycleSupersededByExit && wakeType & WAKE_PROCESS_STOPPED) { - (this.stoppedPids ??= new Set()).add(wakeIdx); - this.notifyParentOfChildStateTransition(wakeIdx); + const selectEntry = this.pendingSelectRetries.get(channel); + if (selectEntry) { + if (selectEntry.timer !== null) { + this.#cancelRegisteredTimeout(selectEntry.timer); + this.#cancelRegisteredImmediate(selectEntry.timer); } + this.pendingSelectRetries.delete(channel); + } + } - if (!lifecycleSupersededByExit && wakeType & WAKE_PROCESS_CONTINUED) { - if (this.resumeStoppedProcess(wakeIdx)) { - this.notifyParentOfChildStateTransition(wakeIdx); - } else { - // Resume-time delivery can immediately apply a retained default - // stop and enqueue a new STOPPED wake after this method owned its - // initial scratch batch. Drain that follow-up now; host-originated - // SIGCONT has no guaranteed later syscall completion to do it. - this.drainAndProcessWakeupEvents(); - } + /** + * Remove a channel from pending pipe readers (all pipes). + * Called when a socket timeout fires to clean up the reader registration. + */ + private removePendingPipeReader(channel: ChannelInfo): void { + if (!this.pendingPipeReaders) return; + for (const [pipeIdx, readers] of this.pendingPipeReaders) { + const filtered = readers.filter((r) => r.channel !== channel); + if (filtered.length === 0) { + this.pendingPipeReaders.delete(pipeIdx); + } else if (filtered.length !== readers.length) { + this.pendingPipeReaders.set(pipeIdx, filtered); } + } + } - if (wakeType & WAKE_READABLE) { - // Pipe became readable — wake pending readers on this pipe - const readers = this.pendingPipeReaders.get(wakeIdx); - if (readers && readers.length > 0) { - this.pendingPipeReaders.delete(wakeIdx); - for (const reader of readers) { - if (this.isRegisteredChannel(reader.channel)) { - this.retrySyscall(reader.channel); - } - } - } + /** + * Remove a channel from pending pipe writers (all pipes). + */ + private removePendingPipeWriter(channel: ChannelInfo): void { + if (!this.pendingPipeWriters) return; + for (const [pipeIdx, writers] of this.pendingPipeWriters) { + const filtered = writers.filter((w) => w.channel !== channel); + if (filtered.length === 0) { + this.pendingPipeWriters.delete(pipeIdx); + } else if (filtered.length !== writers.length) { + this.pendingPipeWriters.set(pipeIdx, filtered); } + } + } - if (wakeType & WAKE_WRITABLE) { - // Pipe became writable — wake pending writers on this pipe - const writers = this.pendingPipeWriters.get(wakeIdx); - if (writers && writers.length > 0) { - this.pendingPipeWriters.delete(wakeIdx); - for (const writer of writers) { - if (this.isRegisteredChannel(writer.channel)) { - this.retrySyscall(writer.channel); - } - } - } - } + /** + * SYS_THREAD_CANCEL — wake a thread that is blocked in a cancellation-point + * syscall so its glue (__syscall_cp) can observe the pending cancel flag + * and run pthread_exit(PTHREAD_CANCELED). + * + * The guest pthread_cancel() overlay has already atomically set + * target->cancel = 1 in shared memory before calling this syscall — see + * libc/musl-overlay/src/thread/wasm32posix/pthread_cancel.c for the full flow. + * + * This handler's sole job is to force the target out of its Atomics.wait32 + * on CH_STATUS (if blocked). Strategy depends on what the target is + * waiting on: + * + * - futex wait: settle the exact engine wait with -EINTR and wake its + * waitAsync closure so it cannot consume a later wake quota. + * - nanosleep/clock_nanosleep and rt_sigtimedwait: cancel the exact timer, + * discard any retained deadline/output state, and complete with -EINTR. + * - pipe read/write blocked on pendingPipeReaders/Writers: remove the + * registration and complete the channel with -EINTR. + * - poll/select/advisory-lock waits scheduled with a retry timer: clear + * the timer and complete with -EINTR. + * - wait-family child waits: remove the exact waiter and complete with + * -EINTR. + * - otherwise (not blocked, or already completed): no-op. The target + * will observe self->cancel on its next cancel-point entry. + * + * The caller's own syscall always succeeds with 0. + */ + private handleThreadCancel( + channel: ChannelInfo, + origArgs: number[], + entry: KernelWorkerEntryContext, + ): void { + const targetTid = origArgs[0]; + const registration = this.processes.get(channel.pid); - if (wakeType & WAKE_ACCEPT) { - this.wakeBlockedAccept(wakeIdx); - } + // Always complete the caller's syscall first so pthread_cancel returns. + this.completeChannelRawAndRelisten(channel, 0, 0, entry); - if (wakeType & WAKE_DATAGRAM_WRITABLE) { - // Datagram queues have no pipe token that identifies every blocked - // sender. Retry generic blocked writes synchronously so a short - // SO_SNDTIMEO cannot win after the send has become ready or acquired - // an immediate error. Poll, select, and epoll still use the broad path - // below so ppoll/pselect's deliberate signal-safe wake deferral - // remains intact. - needDatagramWriterWake = true; - } + if (!registration) return; - if (wakeType & WAKE_ADVISORY_LOCK) { - needAdvisoryLockWake = true; + // Resolve target channel: only the exact main channel may use pid as its + // task identity. Every pthread channel must retain its kernel-allocated + // TID mapping; silently treating an unmapped channel as the leader would + // let host transport metadata redirect cancellation to another task. + let target: ChannelInfo | undefined; + for (const ch of registration.channels) { + const effectiveTid = this.guestTidForChannel(ch); + if (effectiveTid === targetTid) { + target = ch; + break; } + } + if (!target) return; + // Arm the host-side pre-enqueue guard used by wait, futex, and FIFO open. + // The guest pthread_t cancel bit remains authoritative for untracked + // operations and is checked by __syscall_cp before/after their next + // cancellation point. + this.pendingCancels.add(target); + const activeTargetRequest = this.activeChannelRequests.get(target); + if (isWakeableCancellationPoint(activeTargetRequest)) { + // Only a request that came through __syscall_cp may validate and retire + // the exact task before cancellation publication. A plain syscall using + // the same number must remain parked and keep its state unchanged. if ( - wakeType & - (WAKE_READABLE | WAKE_WRITABLE | WAKE_ACCEPT | WAKE_DATAGRAM_WRITABLE) + !this.#cancelLiveTaskKernelWait( + target, + entry, + ) ) { - needBroadWake = true; + this.#failBlockingRetryProtocol( + `cancellation cleanup failed for syscall ${activeTargetRequest.syscallNr}`, + ); } } - // Any kernel readiness event may affect poll/select retries. - // - // If any of those retries is a signal-mask-swapping ppoll/pselect6, - // defer the wake a few ms. A pipe write from process X is often - // immediately followed by a cross-process signal (kill) from X — - // e.g. "write to pipe, then kill parent" — where the writer expects - // a blocked ppoll in the reader to observe BOTH events atomically. - // On a real kernel that works because X's two syscalls execute - // before the scheduler runs the reader. In our retry-based - // shared kernel, the pipe wakeup can fire a ppoll retry BEFORE - // X's follow-up kill is even sent by X's worker (Atomics.notify → - // uv_async round-trip takes 1–5ms). If the retry fires first, ppoll - // returns POLLIN and restores its sigmask; the late signal is then - // blocked and the handler never fires. See - // tests/sortix/os-test/signal/ppoll-block-sleep-write-raise. - // - // Deferring the broad wake a few ms gives X's follow-up syscalls - // time to land. Kill-triggered wakes (line ~2050) always use the - // immediate setImmediate path — by the time kill has been processed - // the signal is already queued, so there's no race. Pipe - // reader/writer wakes above run synchronously (not via this - // deferred path), so plain read/write throughput is unaffected. We - // only pay the delay when a pipe event happens to wake a ppoll or - // pselect6 caller. - if (needDatagramWriterWake) { - this.wakeBlockedFallbackWriters(); - } - if (needAdvisoryLockWake) { - this.wakeBlockedAdvisoryLockRetries(); - } - if (needBroadWake) { - if (this.anyPendingRetryNeedsSignalSafeWake()) { - this.scheduleWakeBlockedRetriesDeferred(); + // If the target has already parked in a tracked blocking wait, wake + // it so its natural completion path runs and the guest sees the + // cancel in __syscall_cp_check. Doing the wake via the same mechanism + // the wait uses (Atomics.notify on the futex addr, cancelling the + // retry timer, etc.) avoids racing against the handler's own + // completion path — we never write the channel directly here. + + // 1) Futex wait — Atomics.notify wakes the in-flight waitAsync, which + // calls complete() and completeChannelRaw naturally. + const futexEntry = this.pendingFutexWaits.get(target); + if (isWakeableCancellationPoint(futexEntry)) { + this.pendingCancels.delete(target); + if (futexEntry.interrupt) { + futexEntry.interrupt(-EINTR_ERRNO, EINTR_ERRNO); } else { - this.scheduleWakeBlockedRetries(); + const tgtMemView = new Int32Array(target.memory.buffer); + Atomics.notify(tgtMemView, futexEntry.futexIndex, 1); } + return; } - } - - /** Retry only blocking advisory-lock waiters after a Rust lock-state change. */ - private wakeBlockedAdvisoryLockRetries(): void { - const pending = this.pendingAdvisoryLockRetries; - if (!pending || pending.size === 0) return; - // Retrying can synchronously re-park the same channel, so iterate a - // snapshot and skip entries that another wake already replaced. - const entries = Array.from(pending.entries()); - for (const [key, entry] of entries) { - if (pending.get(key) !== entry) continue; - clearTimeout(entry.timer); - pending.delete(key); - if (this.isRegisteredChannel(entry.channel)) { - this.retrySyscall(entry.channel); - } + // 2) Host-deferred sleep — discard its staged timeout output and cancel + // the exact timer before publishing EINTR. A stale callback must never + // complete a later request that reuses this mailbox. + const sleepEntry = this.pendingSleeps.get(target); + if (isWakeableCancellationPoint(sleepEntry)) { + this.pendingCancels.delete(target); + this.#cancelRegisteredTimeout(sleepEntry.timer); + this.pendingSleeps.delete(target); + this.completeChannelRawAndRelisten( + target, + -EINTR_ERRNO, + EINTR_ERRNO, + entry, + ); + return; } - } - /** STOPPED/CONTINUED are waitable even when SA_NOCLDSTOP suppresses SIGCHLD. */ - private notifyParentOfChildStateTransition(pid: number): void { - const parentPid = this.getParentPid(pid); - if (parentPid === undefined) return; - - const hasNoCldStop = this.kernelInstance!.exports - .kernel_has_sa_nocldstop as (pid: number) => number; - if (hasNoCldStop(parentPid) !== 1) { - this.sendSignalToProcess(parentPid, SIGCHLD); - } else { - // SA_NOCLDSTOP suppresses only SIGCHLD generation. The status record is - // still waitable and must wake a matching wait4/waitid caller. - this.wakeWaitingParent(parentPid); + // 3) rt_sigtimedwait — retire both the current timer and its persistent + // deadline. The key is numeric, so prove that it still names this exact + // channel generation before deleting either record. + const signalWaitKey = `${target.pid}:${target.channelOffset}`; + const signalWaitEntry = this.pendingSignalWaits.get(signalWaitKey); + if ( + signalWaitEntry?.channel === target + && isWakeableCancellationPoint(signalWaitEntry) + ) { + this.pendingCancels.delete(target); + this.#cancelRegisteredTimeout(signalWaitEntry.timer); + this.pendingSignalWaits.delete(signalWaitKey); + this.signalWaitDeadlines.delete(signalWaitKey); + this.completeChannelRawAndRelisten( + target, + -EINTR_ERRNO, + EINTR_ERRNO, + entry, + ); + return; } - } - /** Retry write-like fallback entries that have no targetable pipe token. */ - private wakeBlockedFallbackWriters(): void { - const matches = Array.from(this.pendingPollRetries.entries()).filter( - ([, entry]) => entry.isWriteRetry, - ); - for (const [key, entry] of matches) { - if (this.pendingPollRetries.get(key) !== entry) continue; - this.pendingPollRetries.delete(key); - if (entry.timer !== null) clearTimeout(entry.timer); - if (this.isRegisteredChannel(entry.channel)) { - this.retrySyscall(entry.channel); - } + // 4) Poll/ppoll retry timer — retire the tracked retry and complete the + // exact cancellation point with EINTR. + const pollEntry = this.pendingPollRetries.get(target); + if (isWakeableCancellationPoint(pollEntry)) { + this.pendingCancels.delete(target); + if (pollEntry.timer !== null) this.#cancelRegisteredTimeout(pollEntry.timer); + this.pendingPollRetries.delete(target); + this.completeChannelRawAndRelisten( + target, + -EINTR_ERRNO, + EINTR_ERRNO, + entry, + ); + return; } - } - private anyPendingRetryNeedsSignalSafeWake(): boolean { - for (const entry of this.pendingPollRetries.values()) { - if (entry.needsSignalSafeWake) return true; - } - for (const entry of this.pendingSelectRetries.values()) { - if (entry.needsSignalSafeWake) return true; + // 5) Advisory-lock retry timer. + const advisoryLockEntry = this.pendingAdvisoryLockRetries?.get(target); + if (isWakeableCancellationPoint(advisoryLockEntry)) { + this.pendingCancels.delete(target); + this.#cancelRegisteredTimeout(advisoryLockEntry.timer); + this.pendingAdvisoryLockRetries.delete(target); + this.completeChannelRawAndRelisten( + target, + -EINTR_ERRNO, + EINTR_ERRNO, + entry, + ); + return; } - return false; - } - /** Same as scheduleWakeBlockedRetries but delays by a few ms to allow - * follow-up cross-process syscalls from the event source to land. */ - private scheduleWakeBlockedRetriesDeferred(): void { - if (this.pendingPollRetries.size === 0 && this.pendingSelectRetries.size === 0 && this.pendingPipeReaders.size === 0 && this.pendingPipeWriters.size === 0) return; - this.postponeSignalSafePollRetries(SIGNAL_SAFE_POLL_WAKE_DELAY_MS); - this.postponeSignalSafeSelectRetries(SIGNAL_SAFE_POLL_WAKE_DELAY_MS); - if (this.wakeScheduled) return; - this.wakeScheduled = true; - setTimeout(() => { - this.wakeScheduled = false; - this.wakeAllBlockedRetries(); - }, SIGNAL_SAFE_POLL_WAKE_DELAY_MS); - } + // 6) Select/pselect retry timer. + const selEntry = this.pendingSelectRetries.get(target); + if (isWakeableCancellationPoint(selEntry)) { + this.pendingCancels.delete(target); + this.#cancelRegisteredTimeout(selEntry.timer); + this.#cancelRegisteredImmediate(selEntry.timer); + this.pendingSelectRetries.delete(target); + this.completeChannelRawAndRelisten( + target, + -EINTR_ERRNO, + EINTR_ERRNO, + entry, + ); + return; + } - private postponeSignalSafePollRetries(delayMs: number): void { - const now = Date.now(); - for (const [key, entry] of this.pendingPollRetries) { - if (!entry.needsSignalSafeWake) continue; - if (entry.timer !== null) { - clearTimeout(entry.timer); + // 7) Pipe/socket reader/writer registration — unregister and wake. + let wokePipe = false; + for (const [pipeIdx, readers] of this.pendingPipeReaders) { + const filtered = readers.filter( + (reader) => + reader.channel !== target + || !isWakeableCancellationPoint(reader), + ); + if (filtered.length !== readers.length) { + if (filtered.length === 0) this.pendingPipeReaders.delete(pipeIdx); + else this.pendingPipeReaders.set(pipeIdx, filtered); + wokePipe = true; } - - const remainingMs = entry.deadline && entry.deadline > 0 - ? Math.max(1, entry.deadline - now) - : delayMs; - const retryMs = Math.max(1, Math.min(delayMs, remainingMs)); - entry.timer = setTimeout(() => { - if (this.pendingPollRetries.get(key) !== entry) return; - this.pendingPollRetries.delete(key); - if (this.isRegisteredChannel(entry.channel)) { - this.retrySyscall(entry.channel); - } - }, retryMs); } - } - - /** Keep pselect's fallback timer from bypassing the signal-safe wake grace. */ - private postponeSignalSafeSelectRetries(delayMs: number): void { - const now = Date.now(); - for (const [key, entry] of this.pendingSelectRetries) { - if (!entry.needsSignalSafeWake) continue; - if (entry.timer !== null) { - clearTimeout(entry.timer); - clearImmediate(entry.timer); + for (const [pipeIdx, writers] of this.pendingPipeWriters) { + const filtered = writers.filter( + (writer) => + writer.channel !== target + || !isWakeableCancellationPoint(writer), + ); + if (filtered.length !== writers.length) { + if (filtered.length === 0) this.pendingPipeWriters.delete(pipeIdx); + else this.pendingPipeWriters.set(pipeIdx, filtered); + wokePipe = true; } + } + if (wokePipe) { + this.pendingCancels.delete(target); + this.clearSocketTimeout(target); + this.completeChannelRawAndRelisten( + target, + -EINTR_ERRNO, + EINTR_ERRNO, + entry, + ); + return; + } - const remainingMs = entry.deadline > 0 - ? Math.max(1, entry.deadline - now) - : delayMs; - const retryMs = Math.max(1, Math.min(delayMs, remainingMs)); - entry.timer = setTimeout(() => { - if (this.pendingSelectRetries.get(key) !== entry) return; - this.pendingSelectRetries.delete(key); - if (!this.isRegisteredChannel(entry.channel)) return; - if (entry.syscallNr === SYS_SELECT) { - this.handleSelect(entry.channel, entry.origArgs); - } else { - this.handlePselect6(entry.channel, entry.origArgs); - } - }, retryMs); + // 8) wait()/waitpid()/wait4()/waitid() are cancellation points in musl. + // Remove the exact host-owned waiter before waking its channel so a later + // child transition cannot complete a canceled thread's reused mailbox. + const waitIndex = this.waitingForChild.findIndex( + (waiter) => + waiter.channel === target + && isWakeableCancellationPoint(waiter), + ); + if (waitIndex >= 0) { + this.pendingCancels.delete(target); + this.waitingForChild.splice(waitIndex, 1); + this.completeChannelRawAndRelisten( + target, + -EINTR_ERRNO, + EINTR_ERRNO, + entry, + ); + return; } - } - /** - * Schedule a microtask to wake all blocked poll/pselect6 retries. - * Coalesced via wakeScheduled flag — multiple calls within the same - * microtask batch result in only one wake cycle. This catches cross-process - * pipe writes, socket connections, and other state changes that unblock - * another process's pending poll/select. - */ - private scheduleWakeBlockedRetries(): void { - if (this.wakeScheduled) return; - if (this.pendingPollRetries.size === 0 && this.pendingSelectRetries.size === 0 && this.pendingPipeReaders.size === 0 && this.pendingPipeWriters.size === 0) return; - this.wakeScheduled = true; - // Use setImmediate (not queueMicrotask) so that timer callbacks - // (setTimeout/setInterval) can interleave. In browsers, microtask - // chains from queueMicrotask starve all macrotasks, breaking progress - // updates and timeouts. setImmediate goes through the polyfill which - // yields to the timer queue periodically. - setImmediate(() => { - this.wakeScheduled = false; - this.wakeAllBlockedRetries(); - }); + // 9) No tracked blocking state — the target either hasn't reached the + // blocking entry yet, or its handler is synchronous and will pick + // up pendingCancels the next time it enters a blocking operation. + // Do NOT write the channel here: the in-flight handleSyscall owns + // it and would race with our completeChannelRaw. } /** - * Wake all blocked poll/pselect6 retries by cancelling their setImmediate - * timers and immediately re-executing the syscalls. + * Dump syscall profiling data to stderr. Call from your serve script: + * process.on('SIGINT', () => { kernelWorker.dumpProfile(); process.exit(); }); + * + * Only produces output when WASM_POSIX_PROFILE=1 env var is set. */ - private wakeAllBlockedRetries(): void { - // Snapshot and clear — retries may re-add themselves if still not ready - const pollEntries = Array.from(this.pendingPollRetries.entries()); - const selectEntries = Array.from(this.pendingSelectRetries.entries()); - this.pendingPollRetries.clear(); - this.pendingSelectRetries.clear(); + dumpProfile(): void { + if (!this.profileData) { + console.error('[profile] Profiling not enabled. Set WASM_POSIX_PROFILE=1'); + return; + } - for (const [_key, entry] of pollEntries) { - if (!this.isRegisteredChannel(entry.channel)) continue; - if (entry.timer !== null) { - clearTimeout(entry.timer); - } - this.retrySyscall(entry.channel); + const entries = Array.from(this.profileData.entries()) + .sort((a, b) => b[1].totalTimeMs - a[1].totalTimeMs); + + let totalCalls = 0; + let totalTime = 0; + let totalRetries = 0; + + console.error('\n=== Syscall Profile ==='); + console.error(`${'Syscall'.padEnd(8)} ${'Count'.padStart(10)} ${'Time(ms)'.padStart(12)} ${'Avg(ms)'.padStart(10)} ${'Retries'.padStart(10)}`); + console.error('-'.repeat(52)); + + for (const [nr, data] of entries) { + totalCalls += data.count; + totalTime += data.totalTimeMs; + totalRetries += data.retries; + console.error( + `${String(nr).padEnd(8)} ${String(data.count).padStart(10)} ${data.totalTimeMs.toFixed(2).padStart(12)} ${(data.totalTimeMs / data.count).toFixed(3).padStart(10)} ${String(data.retries).padStart(10)}` + ); } - for (const [, entry] of selectEntries) { - if (!this.isRegisteredChannel(entry.channel)) continue; - // Cancel both setTimeout and setImmediate handles (one will be a no-op) - clearTimeout(entry.timer); - clearImmediate(entry.timer); - // Re-dispatch to the right handler — SYS_SELECT and SYS_PSELECT6 have - // different time-struct shapes (timeval vs timespec). - if (entry.syscallNr === SYS_SELECT) { - this.handleSelect(entry.channel, entry.origArgs); - } else { - this.handlePselect6(entry.channel, entry.origArgs); - } - } + console.error('-'.repeat(52)); + console.error( + `${'TOTAL'.padEnd(8)} ${String(totalCalls).padStart(10)} ${totalTime.toFixed(2).padStart(12)} ${(totalTime / (totalCalls || 1)).toFixed(3).padStart(10)} ${String(totalRetries).padStart(10)}` + ); + console.error(`Pending pipe readers: ${this.pendingPipeReaders.size}, writers: ${this.pendingPipeWriters.size}`); + console.error('=== End Profile ===\n'); + } - // Also wake all pending pipe readers — a cross-process write may have - // made data available on pipes that readers are waiting on. - if (this.pendingPipeReaders.size > 0) { - const pipeEntries = Array.from(this.pendingPipeReaders.entries()); - this.pendingPipeReaders.clear(); - for (const [, readers] of pipeEntries) { - for (const reader of readers) { - if (this.isRegisteredChannel(reader.channel)) { - this.retrySyscall(reader.channel); - } - } - } + private flushTcpSendPipes( + pid: number, + entry?: KernelWorkerEntryContext, + ): void { + const conns = this.tcpConnections.get(pid); + if (!conns || conns.length === 0) return; + + if (!entry) { + // WHY: a legacy completion without an explicit entry context owns no + // authority to read kernel pipe memory. Each connection pump opens its + // own fresh ingress and performs the same drain there. + for (const conn of conns) conn.schedulePump(); + return; } - // Also wake all pending pipe writers — a cross-process read may have - // drained pipe buffer space that writers are waiting on. - if (this.pendingPipeWriters.size > 0) { - const writerEntries = Array.from(this.pendingPipeWriters.entries()); - this.pendingPipeWriters.clear(); - for (const [, writers] of writerEntries) { - for (const writer of writers) { - if (this.isRegisteredChannel(writer.channel)) { - this.retrySyscall(writer.channel); + // Injected-connection pipes live in the global pipe table; pid=0 + // tells kernel_pipe_read to use it directly. See kernel_inject_connection. + for (const conn of conns) { + const chunks: Uint8Array[] = []; + // Drain all available data from the send pipe (not just one chunk) + for (;;) { + const bytes = this.readPipeChunk(0, conn.sendPipeIdx, entry); + if (!bytes) break; + chunks.push(bytes); + } + const publish = (): undefined => { + for (const bytes of chunks) { + const outData = Buffer.from(bytes); + if (!conn.clientSocket.destroyed) { + conn.clientSocket.write(outData); } } - } + // Schedule pump to detect pipe closure (PHP closing the socket). + conn.schedulePump(); + return undefined; + }; + // WHY: socket writes and pump scheduling invoke host-owned code. The + // complete kernel pipe snapshot is already detached, so publish it only + // after the exact entry scope has been revoked. + entry.deferProtocolEffect(publish); } } /** - * Remove a process's entries from pendingPipeReaders. - * Called during process cleanup. + * Route a host-delegated AF_INET connect that has not completed yet. + * + * The Rust kernel translates HostIO's internal EAGAIN sentinel into the + * connect(2) API's EINPROGRESS (first attempt) or EALREADY (repeat attempt). + * A non-blocking guest must observe that exact errno. A blocking guest must + * remain asleep while the host periodically re-enters the kernel to query + * the same connection; that retry never starts a second host connection. */ - private cleanupPendingPipeReaders(pid: number): void { - for (const [pipeIdx, readers] of this.pendingPipeReaders) { - const filtered = readers.filter(r => r.pid !== pid); - if (filtered.length === 0) { - this.pendingPipeReaders.delete(pipeIdx); - } else { - this.pendingPipeReaders.set(pipeIdx, filtered); - } + #isPendingInetConnect( + syscallNr: number, + retVal: number, + errVal: number, + plannedDispatch: PlannedBlockingChannelDispatch, + ): boolean { + if ( + syscallNr !== SYS_CONNECT + || retVal !== -1 + || (errVal !== EINPROGRESS && errVal !== EALREADY) + ) { + return false; } + + const address = plannedDispatch.plannedScratchWrites.find( + (write) => write.argIndex === 1, + ); + if ( + !address + || address.inputBytes === null + || address.size < 2 + || address.inputBytes.byteLength !== address.size + ) return false; + const AF_INET = 2; + return new DataView( + address.inputBytes.buffer, + address.inputBytes.byteOffset, + address.inputBytes.byteLength, + ).getUint16(0, true) === AF_INET; } - private cleanupPendingPipeWriters(pid: number): void { - for (const [pipeIdx, writers] of this.pendingPipeWriters) { - const filtered = writers.filter(w => w.pid !== pid); - if (filtered.length === 0) { - this.pendingPipeWriters.delete(pipeIdx); - } else { - this.pendingPipeWriters.set(pipeIdx, filtered); + private handlePendingInetConnect( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + retVal: number, + errVal: number, + argDescs: SyscallArgDesc[] | undefined, + plannedDispatch: PlannedBlockingChannelDispatch, + entry: KernelWorkerEntryContext, + retainedSnapshot?: GenericBlockingRetrySnapshot, + deliveredSignal = 0, + firstAttemptDisposition?: BlockingRetryDisposition, + ): boolean { + if ( + !this.#isPendingInetConnect( + syscallNr, + retVal, + errVal, + plannedDispatch, + ) + ) return false; + + let snapshot = retainedSnapshot; + if (!snapshot) { + if (!firstAttemptDisposition) { + this.#failBlockingRetryProtocol( + "pending connect has no frozen blocking disposition", + ); } + if ( + !this.#rememberBlockingRetrySnapshot( + channel, + { + ...firstAttemptDisposition, + kind: "generic-channel", + syscallNr, + origArgs: origArgs.slice(), + argDescs, + dispatch: plannedDispatch, + retryToken: 0n, + }, + entry, + ) + ) return true; + snapshot = this.blockingRetrySnapshots.get(channel) as + | GenericBlockingRetrySnapshot + | undefined; + if (!snapshot) return true; + } + if (snapshot.fdWasNonblocking) { + this.completeChannel( + channel, + syscallNr, + origArgs, + argDescs, + -1, + errVal, + [], + undefined, + entry, + ); + } else if (deliveredSignal > 0) { + // Rust created the exact connect retry binding before reporting its + // internal pending errno. Complete only after that binding has been + // captured above, so EINTR publication consumes it exactly once. + this.completeChannel( + channel, + syscallNr, + origArgs, + argDescs, + -1, + EINTR_ERRNO, + [], + undefined, + entry, + ); + } else { + this.handleBlockingRetry( + channel, + syscallNr, + snapshot.origArgs, + [], + entry, + retainedSnapshot !== undefined, + 0, + ); } + return true; } /** - * Cancel a pending socket timeout timer for a channel. + * Park a blocking advisory-lock request without retaining any lock state in + * the host. Rust wake events provide the normal retry path; the timer is a + * short safety net for a lost event or an older compatible kernel. */ - private clearSocketTimeout(channel: ChannelInfo): void { - const timer = this.socketTimeoutTimers.get(channel); - if (timer !== undefined) { - clearTimeout(timer); - this.socketTimeoutTimers.delete(channel); - } + private parkAdvisoryLockRetry( + channel: ChannelInfo, + syscallNr: number = SYS_FCNTL, + entry?: KernelWorkerEntryContext, + ): void { + if (!this.isRegisteredChannel(channel)) return; + if ( + entry + && this.interruptPendingCancellationBeforeRegistration( + channel, + syscallNr, + this.blockingRetrySnapshots.get(channel) + ?? this.#cancellationPointIdentity(channel), + entry, + ) + ) return; + + const register = (): undefined => { + const pending = this.pendingAdvisoryLockRetries ??= new Map(); + const previous = pending.get(channel); + if (previous) this.#cancelRegisteredTimeout(previous.timer); + + const retry = () => { + const pendingEntry = pending.get(channel); + if (!pendingEntry || pendingEntry.timer !== timer) return; + pending.delete(channel); + if (this.isRegisteredChannel(channel)) { + // WHY: a timer owns no kernel authority. The retry root revalidates + // the exact channel generation and process state under a fresh scope. + this.retrySyscall(channel); + } + }; + const timer = this.#registerTimeout(retry, 10); + pending.set(channel, { + ...this.#cancellationPointIdentity(channel), + timer, + channel, + }); + + if (PROFILING) { + const profileEntry = this.profileData!.get(syscallNr); + if (profileEntry) profileEntry.retries++; + } + return undefined; + }; + if (entry) entry.deferProtocolEffect(register); + else register(); } - /** Reuse one absolute deadline across readiness retries for this syscall. */ - private getReadinessDeadline(channel: ChannelInfo, timeoutMs: number): number { - if (timeoutMs <= 0) return -1; - if (channel.readinessDeadline === undefined) { - channel.readinessDeadline = Date.now() + timeoutMs; + /** Apply flock's LOCK_NB/blocking distinction before generic EAGAIN retry. */ + private handleFlockConflict( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + retVal: number, + errVal: number, + deliveredSignal: number, + argDescs: SyscallArgDesc[] | undefined, + plannedDispatch: PlannedBlockingChannelDispatch, + entry: KernelWorkerEntryContext, + retainedSnapshot?: GenericBlockingRetrySnapshot, + ): boolean { + if (syscallNr !== SYS_FLOCK || retVal !== -1 || errVal !== EAGAIN) { + return false; } - return channel.readinessDeadline; + if ( + !retainedSnapshot + && !this.#rememberBlockingRetrySnapshot( + channel, + { + ...this.#cancellationPointIdentity(channel), + retryForbiddenByCallFlags: (origArgs[1] & LOCK_NB) !== 0, + fdWasNonblocking: false, + applicableSocketTimeoutMs: 0, + kind: "generic-channel", + syscallNr, + origArgs: origArgs.slice(), + argDescs, + dispatch: plannedDispatch, + retryToken: 0n, + }, + entry, + ) + ) return true; + if ((origArgs[1] & LOCK_NB) !== 0) { + this.completeChannel( + channel, + syscallNr, + origArgs, + argDescs, + retVal, + errVal, + [], + undefined, + entry, + ); + } else if (deliveredSignal > 0) { + this.completeChannel( + channel, + syscallNr, + origArgs, + argDescs, + -1, + EINTR_ERRNO, + [], + undefined, + entry, + ); + } else { + this.parkAdvisoryLockRetry(channel, syscallNr, entry); + } + return true; } - /** Clear readiness deadline and any still-parked retry for a completed call. */ - private clearReadinessWait(channel: ChannelInfo): void { - channel.readinessDeadline = undefined; - channel.readinessFinalCheck = undefined; + /** + * Publish the first detached plan for one exact mailbox generation. + * + * A later EAGAIN pass may only continue the existing logical operation; + * never replace its plan with state observed after the guest started + * waiting. + */ + #rememberBlockingRetrySnapshot( + channel: ChannelInfo, + snapshot: BlockingRetrySnapshot, + entry: KernelWorkerEntryContext, + ): boolean { + const existing = this.blockingRetrySnapshots.get(channel); + if (existing) return true; - const pollEntry = this.pendingPollRetries.get(channel); - if (pollEntry) { - if (pollEntry.timer !== null) clearTimeout(pollEntry.timer); - this.pendingPollRetries.delete(channel); + const tokenForRetry = this.#kernelInstanceForEntry(entry).exports + .kernel_blocking_retry_token as + | (( + pid: number, + tid: number, + syscallNr: number, + ) => bigint) + | undefined; + if (typeof tokenForRetry !== "function") { + this.#failBlockingRetryProtocol( + "kernel blocking-retry token export is unavailable after EAGAIN", + ); } - - const advisoryLockEntry = this.pendingAdvisoryLockRetries?.get(channel); - if (advisoryLockEntry) { - clearTimeout(advisoryLockEntry.timer); - this.pendingAdvisoryLockRetries.delete(channel); + let retryToken: unknown; + try { + retryToken = tokenForRetry( + channel.pid, + this.guestTidForChannel(channel), + snapshot.syscallNr, + ); + } catch (error) { + this.#rethrowKernelEntryFatal(error); + this.#failBlockingRetryProtocol( + "kernel blocking-retry token query threw after EAGAIN", + error, + ); + } + if ( + ( + retryToken === -BigInt(ENOENT) + || retryToken === -BigInt(ESRCH) + ) + && this.#retireBlockingRetryCaptureAfterExitedProcess(channel, entry) + ) { + return false; } - - const selectEntry = this.pendingSelectRetries.get(channel); - if (selectEntry) { - if (selectEntry.timer !== null) { - clearTimeout(selectEntry.timer); - clearImmediate(selectEntry.timer); - } - this.pendingSelectRetries.delete(channel); + if ( + typeof retryToken !== "bigint" + || retryToken < 0n + || retryToken > (1n << 63n) - 1n + ) { + this.#failBlockingRetryProtocol( + `kernel returned invalid blocking-retry token ${String(retryToken)}`, + ); } + this.blockingRetrySnapshots.set(channel, { + ...snapshot, + retryToken, + }); + return true; } /** - * Remove a channel from pending pipe readers (all pipes). - * Called when a socket timeout fires to clean up the reader registration. + * Consume one kernel-owned target binding before the host forgets its exact + * channel-generation snapshot. */ - private removePendingPipeReader(channel: ChannelInfo): void { - if (!this.pendingPipeReaders) return; - for (const [pipeIdx, readers] of this.pendingPipeReaders) { - const filtered = readers.filter((r) => r.channel !== channel); - if (filtered.length === 0) { - this.pendingPipeReaders.delete(pipeIdx); - } else if (filtered.length !== readers.length) { - this.pendingPipeReaders.set(pipeIdx, filtered); - } + #releaseBlockingRetrySnapshot( + channel: ChannelInfo, + entry: KernelWorkerEntryContext | undefined, + ): void { + const snapshot = this.blockingRetrySnapshots.get(channel); + if (!snapshot) { + this.blockingRetryWakeTargets.delete(channel); + return; + } + if (snapshot.retryToken === 0n) { + this.blockingRetrySnapshots.delete(channel); + this.blockingRetryWakeTargets.delete(channel); + return; + } + if (!entry) { + this.#failBlockingRetryProtocol( + "blocking-retry target release has no kernel entry", + ); + } + const release = this.#kernelInstanceForEntry(entry).exports + .kernel_blocking_retry_release as + | ((pid: number, tid: number, token: bigint) => number) + | undefined; + if (typeof release !== "function") { + this.#failBlockingRetryProtocol( + "kernel blocking-retry release export is unavailable", + ); } + const result = release( + channel.pid, + this.guestTidForChannel(channel), + snapshot.retryToken, + ); + if (!Number.isSafeInteger(result) || result !== 0) { + this.#failBlockingRetryProtocol( + `kernel rejected blocking-retry release: ${result}`, + ); + } + this.blockingRetrySnapshots.delete(channel); + this.blockingRetryWakeTargets.delete(channel); } /** - * Remove a channel from pending pipe writers (all pipes). + * Fail closed when the host can no longer prove one retry target's identity. */ - private removePendingPipeWriter(channel: ChannelInfo): void { - if (!this.pendingPipeWriters) return; - for (const [pipeIdx, writers] of this.pendingPipeWriters) { - const filtered = writers.filter((w) => w.channel !== channel); - if (filtered.length === 0) { - this.pendingPipeWriters.delete(pipeIdx); - } else if (filtered.length !== writers.length) { - this.pendingPipeWriters.set(pipeIdx, filtered); - } + #failBlockingRetryProtocol( + message: string, + cause?: unknown, + ): never { + const error = new KernelBlockingRetryProtocolError(message); + if (cause !== undefined) { + kernelEntryIntrinsicObjectDefineProperty(error, "cause", { + configurable: true, + enumerable: false, + writable: true, + value: cause, + }); } + this.#failKernelInstance(error); + throw error; } /** - * SYS_THREAD_CANCEL — wake a thread that is blocked in a cancellation-point - * syscall so its glue (__syscall_cp) can observe the pending cancel flag - * and run pthread_exit(PTHREAD_CANCELED). - * - * The guest pthread_cancel() overlay has already atomically set - * target->cancel = 1 in shared memory before calling this syscall — see - * libc/musl-overlay/src/thread/wasm32posix/pthread_cancel.c for the full flow. - * - * This handler's sole job is to force the target out of its Atomics.wait32 - * on CH_STATUS (if blocked). Strategy depends on what the target is - * waiting on: - * - * - futex wait: fire Atomics.notify on the futex address. handleFutex's - * waitAsync Promise resolves, writes (0, 0) to the channel, target - * wakes. Return-value 0 is benign — the post-syscall __testcancel() - * in glue picks up self->cancel and exits before the caller re-checks - * its predicate. - * - pipe read/write blocked on pendingPipeReaders/Writers: remove the - * registration and complete the channel with -EINTR. - * - poll/select/advisory-lock waits scheduled with a retry timer: clear - * the timer and complete with -EINTR. - * - otherwise (not blocked, or already completed): no-op. The target - * will observe self->cancel on its next cancel-point entry. - * - * The caller's own syscall always succeeds with 0. + * Forget host state only after an independently verified Rust lifecycle + * transition already consumed the corresponding target binding. */ - private handleThreadCancel(channel: ChannelInfo, origArgs: number[]): void { - const targetTid = origArgs[0]; - const registration = this.processes.get(channel.pid); - - // Reuse the existing syscall ABI to release any kernel-owned FIFO-open - // reservation before waking the target's host-owned retry state. - this.runSyntheticMemorySyscall(channel, SYS_THREAD_CANCEL, [targetTid]); - - // Always complete the caller's syscall first so pthread_cancel returns. - this.completeChannelRaw(channel, 0, 0); - this.relistenChannel(channel); - - if (!registration) return; + #forgetBlockingRetrySnapshotAfterKernelLifecycle( + channel: ChannelInfo, + ): void { + this.blockingRetrySnapshots.delete(channel); + this.blockingRetryWakeTargets.delete(channel); + } - // Resolve target channel: only the exact main channel may use pid as its - // task identity. Every pthread channel must retain its kernel-allocated - // TID mapping; silently treating an unmapped channel as the leader would - // let host transport metadata redirect cancellation to another task. - let target: ChannelInfo | undefined; - for (const ch of registration.channels) { - const effectiveTid = this.guestTidForChannel(ch); - if (effectiveTid === targetTid) { - target = ch; - break; + #forgetBlockingRetrySnapshotsAfterKernelLifecycle(pid: number): void { + for (const channel of this.blockingRetrySnapshots.keys()) { + if (channel.pid === pid) { + this.blockingRetrySnapshots.delete(channel); } } - if (!target) return; - - // Arm the host-side pre-enqueue guard used by wait, futex, and FIFO open. - // The guest pthread_t cancel bit remains authoritative for untracked - // operations and is checked by __syscall_cp before/after their next - // cancellation point. - this.pendingCancels.add(target); - - // If the target has already parked in a tracked blocking wait, wake - // it so its natural completion path runs and the guest sees the - // cancel in __syscall_cp_check. Doing the wake via the same mechanism - // the wait uses (Atomics.notify on the futex addr, cancelling the - // retry timer, etc.) avoids racing against the handler's own - // completion path — we never write the channel directly here. - - // 1) Futex wait — Atomics.notify wakes the in-flight waitAsync, which - // calls complete() and completeChannelRaw naturally. - const futexEntry = this.pendingFutexWaits.get(target); - if (futexEntry) { - if (futexEntry.interrupt) { - futexEntry.interrupt(-EINTR_ERRNO, EINTR_ERRNO); - } else { - const tgtMemView = new Int32Array(target.memory.buffer); - Atomics.notify(tgtMemView, futexEntry.futexIndex, 1); + for (const channel of this.blockingRetryWakeTargets.keys()) { + if (channel.pid === pid) { + this.blockingRetryWakeTargets.delete(channel); } - return; } + } - // 2) Poll/ppoll retry timer — retire the tracked retry and complete the - // exact cancellation point with EINTR. - const pollEntry = this.pendingPollRetries.get(target); - if (pollEntry) { - if (pollEntry.timer !== null) clearTimeout(pollEntry.timer); - this.pendingPollRetries.delete(target); - this.completeChannelRaw(target, -EINTR_ERRNO, EINTR_ERRNO); - this.relistenChannel(target); - return; + /** + * An EAGAIN result can race terminating signal delivery inside Rust: the + * lifecycle cleanup then consumes the new pin—or the complete task—before + * JavaScript asks for its token. Accept ENOENT/ESRCH only after the + * independent process-state export proves that exact process is already + * Exited. + */ + #retireBlockingRetryCaptureAfterExitedProcess( + channel: ChannelInfo, + entry: KernelWorkerEntryContext, + ): boolean { + const getState = this.#kernelInstanceForEntry(entry).exports + .kernel_get_process_state as ((pid: number) => number) | undefined; + if ( + typeof getState !== "function" + || getState(channel.pid) !== PROCESS_STATE_EXITED + ) { + return false; } - - // 3) Advisory-lock retry timer. - const advisoryLockEntry = this.pendingAdvisoryLockRetries?.get(target); - if (advisoryLockEntry) { - clearTimeout(advisoryLockEntry.timer); - this.pendingAdvisoryLockRetries.delete(target); - this.completeChannelRaw(target, -EINTR_ERRNO, EINTR_ERRNO); - this.relistenChannel(target); - return; + this.#forgetBlockingRetrySnapshotsAfterKernelLifecycle(channel.pid); + const signal = this.#getProcessExitSignal(channel.pid, entry); + if (signal > 0 && !this.hostReaped.has(channel.pid)) { + this.#handleProcessTerminatedWithinKernelEntry(channel, entry); + } else { + // Another channel owns normal-exit publication. This exact guest must + // remain parked until that process-wide lifecycle callback terminates it. + channel.handling = true; } + return true; + } - // 4) Select/pselect retry timer. - const selEntry = this.pendingSelectRetries.get(target); - if (selEntry) { - clearTimeout(selEntry.timer); - clearImmediate(selEntry.timer); - this.pendingSelectRetries.delete(target); - this.completeChannelRaw(target, -EINTR_ERRNO, EINTR_ERRNO); - this.relistenChannel(target); - return; + #replayBlockingRetrySnapshot( + channel: ChannelInfo, + snapshot: BlockingRetrySnapshot, + entry: KernelWorkerEntryContext, + ): void { + switch (snapshot.kind) { + case "generic-channel": + this.#replayGenericBlockingRetry(channel, snapshot, entry); + return; + case "fcntl-lock": + this.handleFcntlLock( + channel, + snapshot.origArgs, + entry, + snapshot, + ); + return; + case "select": + if (snapshot.syscallNr === SYS_PSELECT6) { + this.handlePselect6(channel, snapshot.origArgs, entry, snapshot); + } else { + this.handleSelect(channel, snapshot.origArgs, entry, snapshot); + } + return; + case "flattened-transfer": + this.#handleFlattenedTransfer( + channel, + snapshot.syscallNr, + snapshot.origArgs, + snapshot.request, + entry, + snapshot, + ); + return; + case "sendmsg": + this.handleSendmsg( + channel, + snapshot.origArgs, + null, + entry, + snapshot, + ); + return; + case "recvmsg": + this.handleRecvmsg( + channel, + snapshot.origArgs, + null, + entry, + snapshot, + ); + return; + case "sysv-message": + this.handleSysvMessage( + channel, + snapshot.syscallNr, + snapshot.origArgs, + [], + entry, + snapshot, + ); + return; } + } - // 5) Pipe/socket reader/writer registration — unregister and wake. - let wokePipe = false; - for (const [pipeIdx, readers] of this.pendingPipeReaders) { - const filtered = readers.filter(r => r.channel !== target); - if (filtered.length !== readers.length) { - if (filtered.length === 0) this.pendingPipeReaders.delete(pipeIdx); - else this.pendingPipeReaders.set(pipeIdx, filtered); - wokePipe = true; - } + #replayGenericBlockingRetry( + channel: ChannelInfo, + snapshot: GenericBlockingRetrySnapshot, + entry: KernelWorkerEntryContext, + ): void { + const isFinalReadinessCheck = + channel.readinessFinalCheck === true + && ( + snapshot.syscallNr === SYS_POLL + || snapshot.syscallNr === SYS_PPOLL + ); + let replayDispatch = snapshot.dispatch; + if (isFinalReadinessCheck) { + const adjustedArgs = snapshot.dispatch.adjustedArgs.slice(); + adjustedArgs[2] = 0; + replayDispatch = { + ...snapshot.dispatch, + adjustedArgs, + readinessTimeoutMs: 0, + }; + channel.readinessFinalCheck = false; + // WHY: the retained plan remains the immutable logical request. Only + // this deadline attempt is zero-time; mutating the snapshot would erase + // the original deadline policy and make later cleanup/replay ambiguous. } - for (const [pipeIdx, writers] of this.pendingPipeWriters) { - const filtered = writers.filter(w => w.channel !== target); - if (filtered.length !== writers.length) { - if (filtered.length === 0) this.pendingPipeWriters.delete(pipeIdx); - else this.pendingPipeWriters.set(pipeIdx, filtered); - wokePipe = true; + let result: PlannedChannelDispatchResult; + try { + result = this.#executePlannedBlockingChannelDispatch( + channel, + replayDispatch, + entry, + snapshot.retryToken, + ); + } catch (error) { + this.#rethrowKernelEntryFatal(error); + if ( + this.#kernelFatalError !== null + || error instanceof KernelTransferExecuteTrapError + || error instanceof KernelTaskBindingError + || error instanceof KernelReentrantEntryError + ) { + throw error; } - } - if (wokePipe) { - this.clearSocketTimeout(target); - this.completeChannelRaw(target, -EINTR_ERRNO, EINTR_ERRNO); - this.relistenChannel(target); + if ( + !this.#cancelHostOwnedKernelWait( + channel, + snapshot.syscallNr, + entry, + ) + ) { + this.#failBlockingRetryProtocol( + `kernel wait cleanup failed for syscall ${snapshot.syscallNr}`, + error, + ); + } + this.completeChannelRawAndRelisten(channel, -1, EIO, entry); return; } - // 6) wait()/waitpid()/wait4()/waitid() are cancellation points in musl. - // Remove the exact host-owned waiter before waking its channel so a later - // child transition cannot complete a canceled thread's reused mailbox. - const waitIndex = this.waitingForChild.findIndex( - (waiter) => waiter.channel === target, - ); - if (waitIndex >= 0) { - this.waitingForChild.splice(waitIndex, 1); - this.completeChannelRaw(target, -EINTR_ERRNO, EINTR_ERRNO); - this.relistenChannel(target); + if (this.#getProcessExitSignal(channel.pid, entry) > 0) { + this.#handleProcessTerminatedWithinKernelEntry(channel, entry); return; } - // 7) No tracked blocking state — the target either hasn't reached the - // blocking entry yet, or its handler is synchronous and will pick - // up pendingCancels the next time it enters a blocking operation. - // Do NOT write the channel here: the in-flight handleSyscall owns - // it and would race with our completeChannelRaw. - } - - /** - * Dump syscall profiling data to stderr. Call from your serve script: - * process.on('SIGINT', () => { kernelWorker.dumpProfile(); process.exit(); }); - * - * Only produces output when WASM_POSIX_PROFILE=1 env var is set. - */ - dumpProfile(): void { - if (!this.profileData) { - console.error('[profile] Profiling not enabled. Set WASM_POSIX_PROFILE=1'); - return; + const routedMqNotification = + snapshot.syscallNr === SYS_MQ_TIMEDSEND + && result.retVal === 0; + if (routedMqNotification) { + this.drainMqueueNotification(entry); + if (this.#finishSignalTermination(channel, entry)) return; } + const deliveredSignal = this.#dequeueSignalForDelivery( + channel, + entry, + snapshot.applicableSocketTimeoutMs > 0, + ); + if (this.#finishSignalTermination(channel, entry)) return; - const entries = Array.from(this.profileData.entries()) - .sort((a, b) => b[1].totalTimeMs - a[1].totalTimeMs); + if ( + this.handlePendingInetConnect( + channel, + snapshot.syscallNr, + snapshot.origArgs, + result.retVal, + result.errVal, + snapshot.argDescs, + replayDispatch, + entry, + snapshot, + deliveredSignal, + ) + ) return; - let totalCalls = 0; - let totalTime = 0; - let totalRetries = 0; + if ( + this.handleFlockConflict( + channel, + snapshot.syscallNr, + snapshot.origArgs, + result.retVal, + result.errVal, + deliveredSignal, + snapshot.argDescs, + snapshot.dispatch, + entry, + snapshot, + ) + ) return; - console.error('\n=== Syscall Profile ==='); - console.error(`${'Syscall'.padEnd(8)} ${'Count'.padStart(10)} ${'Time(ms)'.padStart(12)} ${'Avg(ms)'.padStart(10)} ${'Retries'.padStart(10)}`); - console.error('-'.repeat(52)); + if (result.retVal === -1 && result.errVal === EAGAIN) { + this.handleBlockingRetry( + channel, + snapshot.syscallNr, + snapshot.origArgs, + result.outputWrites, + entry, + true, + deliveredSignal, + isFinalReadinessCheck ? 0 : undefined, + ); + return; + } - for (const [nr, data] of entries) { - totalCalls += data.count; - totalTime += data.totalTimeMs; - totalRetries += data.retries; - console.error( - `${String(nr).padEnd(8)} ${String(data.count).padStart(10)} ${data.totalTimeMs.toFixed(2).padStart(12)} ${(data.totalTimeMs / data.count).toFixed(3).padStart(10)} ${String(data.retries).padStart(10)}` + if ( + (this.sharedMmapBackings?.size ?? 0) > 0 + && ( + snapshot.syscallNr === SYS_READ + || snapshot.syscallNr === SYS_WRITE + || snapshot.syscallNr === SYS_PREAD + || snapshot.syscallNr === SYS_PWRITE + || snapshot.syscallNr === SYS_SENDFILE + || snapshot.syscallNr === SYS_COPY_FILE_RANGE + || snapshot.syscallNr === SYS_SPLICE + ) + ) { + const rawOffset = snapshot.syscallNr === SYS_PWRITE + ? BigInt(snapshot.dispatch.adjustedArgs[3] ?? 0) + : undefined; + this.handleSharedMappingsAfterFileSyscall( + channel, + snapshot.syscallNr, + snapshot.origArgs, + result.retVal, + result.errVal, + rawOffset, + undefined, + entry, ); + if (this.hostReaped?.has(channel.pid)) return; } - console.error('-'.repeat(52)); - console.error( - `${'TOTAL'.padEnd(8)} ${String(totalCalls).padStart(10)} ${totalTime.toFixed(2).padStart(12)} ${(totalTime / (totalCalls || 1)).toFixed(3).padStart(10)} ${String(totalRetries).padStart(10)}` + this.completeChannel( + channel, + snapshot.syscallNr, + snapshot.origArgs, + snapshot.argDescs, + result.publicationRetVal, + result.errVal, + result.outputWrites, + undefined, + entry, ); - console.error(`Pending pipe readers: ${this.pendingPipeReaders.size}, writers: ${this.pendingPipeWriters.size}`); - console.error('=== End Profile ===\n'); } - private flushTcpSendPipes(pid: number): void { - const conns = this.tcpConnections.get(pid); - if (!conns || conns.length === 0) return; - - // Injected-connection pipes live in the global pipe table; pid=0 - // tells kernel_pipe_read to use it directly. See kernel_inject_connection. - for (const conn of conns) { - // Drain all available data from the send pipe (not just one chunk) - for (;;) { - const bytes = this.readPipeChunk(0, conn.sendPipeIdx); - if (!bytes) break; - const outData = Buffer.from(bytes); - if (!conn.clientSocket.destroyed) { - conn.clientSocket.write(outData); - } - } - // Schedule pump to detect pipe closure (PHP closing the socket) - conn.schedulePump(); + #blockingRetryNonblockingDescriptors( + syscallNr: number, + origArgs: readonly number[], + ): readonly number[] { + switch (syscallNr) { + case SYS_READ: + case SYS_WRITE: + case SYS_PREAD: + case SYS_PWRITE: + case SYS_READV: + case SYS_WRITEV: + case SYS_PREADV: + case SYS_PWRITEV: + case SYS_PREADV2: + case SYS_PWRITEV2: + case SYS_RECV: + case SYS_SEND: + case SYS_RECVFROM: + case SYS_SENDTO: + case SYS_RECVMSG: + case SYS_SENDMSG: + case SYS_ACCEPT: + case SYS_ACCEPT4: + case SYS_CONNECT: + case SYS_MQ_TIMEDSEND: + case SYS_MQ_TIMEDRECEIVE: + return [origArgs[0]!]; + case SYS_SENDFILE: + return [origArgs[0]!, origArgs[1]!]; + case SYS_COPY_FILE_RANGE: + case SYS_SPLICE: + return [origArgs[0]!, origArgs[2]!]; + default: + return []; } } - /** - * Route a host-delegated AF_INET connect that has not completed yet. - * - * The Rust kernel translates HostIO's internal EAGAIN sentinel into the - * connect(2) API's EINPROGRESS (first attempt) or EALREADY (repeat attempt). - * A non-blocking guest must observe that exact errno. A blocking guest must - * remain asleep while the host periodically re-enters the kernel to query - * the same connection; that retry never starts a second host connection. - */ - private handlePendingInetConnect( - channel: ChannelInfo, + #blockingRetrySocketTimeoutDirection( syscallNr: number, - origArgs: number[], - retVal: number, - errVal: number, - ): boolean { - if ( - syscallNr !== SYS_CONNECT || - retVal !== -1 || - (errVal !== EINPROGRESS && errVal !== EALREADY) - ) { - return false; - } - - const addrPtr = origArgs[1]; - const addrLen = origArgs[2]; - if ( - !Number.isSafeInteger(addrPtr) || - addrPtr <= 0 || - addrLen < 2 || - addrPtr + 2 > channel.memory.buffer.byteLength - ) { - return false; - } - const AF_INET = 2; - const family = new DataView(channel.memory.buffer).getUint16(addrPtr, true); - if (family !== AF_INET) return false; - - const isFdNonblock = this.kernelInstance!.exports.kernel_is_fd_nonblock as - ((pid: number, fd: number) => number) | undefined; - const nonblock = isFdNonblock?.(channel.pid, origArgs[0]) === 1; - if (nonblock) { - this.completeChannel( - channel, - syscallNr, - origArgs, - SYSCALL_ARGS[syscallNr], - -1, - errVal, - ); - } else { - this.handleBlockingRetry(channel, syscallNr, origArgs); + ): 0 | 1 | null { + switch (syscallNr) { + case SYS_READ: + case SYS_PREAD: + case SYS_READV: + case SYS_PREADV: + case SYS_PREADV2: + case SYS_RECV: + case SYS_RECVFROM: + case SYS_RECVMSG: + case SYS_ACCEPT: + case SYS_ACCEPT4: + return 1; + case SYS_WRITE: + case SYS_PWRITE: + case SYS_WRITEV: + case SYS_PWRITEV: + case SYS_PWRITEV2: + case SYS_SEND: + case SYS_SENDTO: + case SYS_SENDMSG: + case SYS_CONNECT: + case SYS_SENDFILE: + return 0; + default: + return null; } - return true; } /** - * Park a blocking advisory-lock request without retaining any lock state in - * the host. Rust wake events provide the normal retry path; the timer is a - * short safety net for a lost event or an older compatible kernel. + * Freeze whether the first EAGAIN operation may park and, only then, the + * exact OFD's socket timeout. + * + * WHY: request flags, descriptor flags, and timeout all belong to the same + * logical attempt. Querying any of them after a host wait could apply a + * replacement numeric fd's policy; querying timeout before MSG_DONTWAIT or + * O_NONBLOCK would also add avoidable Rust work to a terminal EAGAIN. */ - private parkAdvisoryLockRetry( + #captureBlockingRetryDisposition( channel: ChannelInfo, - syscallNr: number = SYS_FCNTL, - ): void { - if (!this.isRegisteredChannel(channel)) return; - - const pending = this.pendingAdvisoryLockRetries ??= new Map(); - const previous = pending.get(channel); - if (previous) clearTimeout(previous.timer); + syscallNr: number, + origArgs: readonly number[], + entry: KernelWorkerEntryContext, + retryForbiddenByCallFlags: boolean, + ): BlockingRetryDisposition { + const cancellationIdentity = + this.#cancellationPointIdentity(channel); + if (retryForbiddenByCallFlags) { + return { + ...cancellationIdentity, + retryForbiddenByCallFlags: true, + fdWasNonblocking: false, + applicableSocketTimeoutMs: 0, + }; + } - const retry = () => { - const entry = pending.get(channel); - if (!entry || entry.timer !== timer) return; - pending.delete(channel); - if (this.isAsyncChannelProcessActive(channel)) { - this.retrySyscall(channel); + const descriptors = this.#blockingRetryNonblockingDescriptors( + syscallNr, + origArgs, + ); + let fdWasNonblocking = false; + if (descriptors.length > 0) { + const isFdNonblock = this.#kernelInstanceForEntry(entry).exports + .kernel_is_fd_nonblock as + ((pid: number, fd: number) => number) | undefined; + if (typeof isFdNonblock !== "function") { + this.#failBlockingRetryProtocol( + "kernel descriptor nonblocking export is unavailable after EAGAIN", + ); + } + for (const fd of descriptors) { + const result = isFdNonblock(channel.pid, fd); + if (result !== 0 && result !== 1) { + this.#failBlockingRetryProtocol( + `kernel returned invalid nonblocking state ${result} for fd ${fd}`, + ); + } + fdWasNonblocking ||= result === 1; } - }; - const timer = setTimeout(retry, 10); - pending.set(channel, { timer, channel }); - - if (PROFILING) { - const entry = this.profileData!.get(syscallNr); - if (entry) entry.retries++; } - } + if (fdWasNonblocking) { + return { + ...cancellationIdentity, + retryForbiddenByCallFlags: false, + fdWasNonblocking: true, + applicableSocketTimeoutMs: 0, + }; + } - /** Apply flock's LOCK_NB/blocking distinction before generic EAGAIN retry. */ - private handleFlockConflict( - channel: ChannelInfo, - syscallNr: number, - origArgs: number[], - retVal: number, - errVal: number, - deliveredSignal: number, - ): boolean { - if (syscallNr !== SYS_FLOCK || retVal !== -1 || errVal !== EAGAIN) { - return false; + const timeoutDirection = + this.#blockingRetrySocketTimeoutDirection(syscallNr); + if (timeoutDirection === null) { + return { + ...cancellationIdentity, + retryForbiddenByCallFlags: false, + fdWasNonblocking: false, + applicableSocketTimeoutMs: 0, + }; } - if ((origArgs[1] & LOCK_NB) !== 0) { - this.completeChannel( - channel, - syscallNr, - origArgs, - undefined, - retVal, - errVal, + const getTimeout = this.#kernelInstanceForEntry(entry).exports + .kernel_get_socket_timeout_ms as + | ((pid: number, fd: number, isReceive: number) => bigint) + | undefined; + if (typeof getTimeout !== "function") { + this.#failBlockingRetryProtocol( + "kernel socket-timeout export is unavailable after EAGAIN", ); - } else if (deliveredSignal > 0) { - this.completeChannel( - channel, - syscallNr, - origArgs, - undefined, - -1, - EINTR_ERRNO, + } + const rawTimeout = getTimeout( + channel.pid, + origArgs[0]!, + timeoutDirection, + ); + if ( + typeof rawTimeout !== "bigint" + || rawTimeout < -1n + || rawTimeout > BigInt(Number.MAX_SAFE_INTEGER) + ) { + this.#failBlockingRetryProtocol( + `kernel returned invalid socket timeout ${String(rawTimeout)}`, ); - } else { - this.parkAdvisoryLockRetry(channel, syscallNr); } - return true; + return { + ...cancellationIdentity, + retryForbiddenByCallFlags: false, + fdWasNonblocking: false, + // Rust documents -1 for a non-socket descriptor. + applicableSocketTimeoutMs: + rawTimeout > 0n ? Number(rawTimeout) : 0, + }; } private handleBlockingRetry( @@ -8813,19 +15763,50 @@ export class CentralizedKernelWorker { syscallNr: number, origArgs: number[], detachedOutput: ChannelOutputWrite[] = [], + entry: KernelWorkerEntryContext, + replayingDetachedSnapshot = false, + deliveredSignal = 0, + effectiveReadinessTimeoutMs?: number, ): void { + const testHook = this.#scratchBoundaryTestHooks?.handleBlockingRetry; + if (testHook) { + testHook(channel, syscallNr, origArgs); + return; + } if (!this.isRegisteredChannel(channel)) return; - - // pthread_cancel can be handled after the target submitted open/openat - // but before this retry path records the kernel-owned FIFO rendezvous. - // Retire that reservation and complete the cancellation point instead of - // parking a retry that no later cancellation dispatch can discover. - if (this.interruptPendingFifoOpenCancellation(channel, syscallNr)) return; + const retainedRetrySnapshot = + this.blockingRetrySnapshots.get(channel); + const retainedGenericSnapshot = + retainedRetrySnapshot?.kind === "generic-channel" + ? retainedRetrySnapshot + : undefined; + const retryDisposition: BlockingRetryDisposition | undefined = + retainedRetrySnapshot + && "applicableSocketTimeoutMs" in retainedRetrySnapshot + ? retainedRetrySnapshot + : undefined; + const cancellationIdentity: FrozenCancellationPointIdentity = + retainedRetrySnapshot + ?? this.#cancellationPointIdentity(channel); // Futex wait: use Atomics.waitAsync on the target address in process memory if (syscallNr === SYS_FUTEX) { const futexOp = origArgs[1] & 0x7f; // mask out FUTEX_PRIVATE_FLAG if (futexOp === 0) { // FUTEX_WAIT + if (deliveredSignal > 0) { + this.completeChannel( + channel, + syscallNr, + origArgs, + SYSCALL_ARGS[syscallNr], + -1, + EINTR_ERRNO, + [], + undefined, + entry, + ); + return; + } let addr: number; try { addr = this.checkedProcessRange( @@ -8841,7 +15822,7 @@ export class CentralizedKernelWorker { ); } } catch (error) { - this.rejectScratchTransfer(channel, error); + this.#rejectScratchTransfer(channel, error, entry); return; } const expectedVal = origArgs[2]; @@ -8856,10 +15837,22 @@ export class CentralizedKernelWorker { return; } + // pthread_cancel may have selected this thread after the futex value + // check. Consume it only at the final boundary before installing an + // asynchronous engine wait. + if ( + this.interruptPendingCancellationBeforeRegistration( + channel, + syscallNr, + cancellationIdentity, + entry, + ) + ) return; + // Wait for value to change const waitResult = Atomics.waitAsync(i32View, index, expectedVal); if (waitResult.async) { - waitResult.value.then(() => { + this.#continuePromise(waitResult.value, () => { if (this.isRegisteredChannel(channel)) { this.retrySyscall(channel); } @@ -8867,7 +15860,7 @@ export class CentralizedKernelWorker { } else { // Already changed — use setImmediate (not queueMicrotask) to avoid // microtask chains that starve the browser event loop. - setImmediate(() => this.retrySyscall(channel)); + this.#registerImmediate(() => this.retrySyscall(channel)); } return; } @@ -8877,44 +15870,38 @@ export class CentralizedKernelWorker { // We retry after a short delay. If poll has timeout=0 (EAGAIN means no events), // we should return 0 immediately instead of retrying. if (syscallNr === SYS_POLL || syscallNr === SYS_PPOLL) { - let timeoutMs = -1; + if (!retainedGenericSnapshot) { + this.#failBlockingRetryProtocol( + "poll retry has no immutable channel snapshot", + ); + } + const timeoutMs = effectiveReadinessTimeoutMs + ?? retainedGenericSnapshot.dispatch.readinessTimeoutMs; + if (timeoutMs === undefined) { + if (!this.#cancelHostOwnedKernelWait(channel, syscallNr, entry)) { + this.#failBlockingRetryProtocol( + `poll retry cleanup failed for syscall ${syscallNr}`, + ); + } + this.completeChannelRawAndRelisten(channel, -1, EIO, entry); + return; + } // PPOLL with a non-null sigmask pointer swaps the signal mask for the // duration of the wait. Broad wakes from cross-process pipe writes // need a short grace period for such callers so follow-up signals // from the writer land before ppoll returns with fds ready. - const needsSignalSafeWake = syscallNr === SYS_PPOLL && origArgs[3] !== 0; - if (syscallNr === SYS_POLL) { - timeoutMs = origArgs[2]; // timeout in ms - } else { - const tsPtr = origArgs[2]; - if (tsPtr !== 0) { - // Every retry re-enters _handleSyscallInner, which validates this - // special ppoll source before the kernel returns EAGAIN. Repeat the - // range proof here rather than treating that earlier proof as a - // lifetime guarantee for caller memory. - let range: { pointer: number; length: number }; - try { - range = this.checkedProcessRange( - channel, - tsPtr, - 16, - "ppoll retry timeout", - ); - } catch (error) { - this.rejectScratchTransfer(channel, error); - return; - } - const pv = new DataView( - channel.memory.buffer, - range.pointer, - range.length, + const needsSignalSafeWake = + syscallNr === SYS_PPOLL + && Number(retainedGenericSnapshot.dispatch.adjustedArgs[3] ?? 0) !== 0; + if (timeoutMs === 0) { + if ( + syscallNr === SYS_PPOLL + && !this.#cancelHostOwnedKernelWait(channel, syscallNr, entry) + ) { + this.#failBlockingRetryProtocol( + "ppoll could not restore its temporary mask", ); - const sec = Number(pv.getBigInt64(0, true)); - const nsec = Number(pv.getBigInt64(8, true)); - timeoutMs = sec * 1000 + Math.floor(nsec / 1000000); } - } - if (timeoutMs === 0) { this.completeChannel( channel, syscallNr, @@ -8923,6 +15910,30 @@ export class CentralizedKernelWorker { 0, 0, detachedOutput, + undefined, + entry, + ); + return; + } + if (deliveredSignal > 0) { + if ( + syscallNr === SYS_PPOLL + && !this.#cancelHostOwnedKernelWait(channel, syscallNr, entry) + ) { + this.#failBlockingRetryProtocol( + "ppoll could not restore its temporary mask for EINTR", + ); + } + this.completeChannel( + channel, + syscallNr, + origArgs, + SYSCALL_ARGS[syscallNr], + -1, + EINTR_ERRNO, + [], + undefined, + entry, ); return; } @@ -8936,17 +15947,56 @@ export class CentralizedKernelWorker { } // Resolve which pipe/listener readiness tokens the polled fds map to. - const { pipeIndices, acceptIndices } = - this.resolvePollReadinessIndices(channel.pid, origArgs); + let wakeTargets = this.blockingRetryWakeTargets.get(channel); + if ( + wakeTargets?.pollPipeIndices === undefined + || wakeTargets.pollAcceptIndices === undefined + ) { + try { + const resolved = this.resolvePollReadinessIndices( + channel.pid, + retainedGenericSnapshot.dispatch, + entry, + ); + wakeTargets = { + ...wakeTargets, + pollPipeIndices: resolved.pipeIndices.slice(), + pollAcceptIndices: resolved.acceptIndices.slice(), + }; + this.blockingRetryWakeTargets.set(channel, wakeTargets); + } catch (error) { + this.#rethrowKernelEntryFatal(error); + if (!this.#cancelHostOwnedKernelWait(channel, syscallNr, entry)) { + this.#failBlockingRetryProtocol( + `poll retry cleanup failed for syscall ${syscallNr}`, + error, + ); + } + this.#rejectScratchTransfer(channel, error, entry); + return; + } + } + const pipeIndices = [...(wakeTargets.pollPipeIndices ?? [])]; + const acceptIndices = [...(wakeTargets.pollAcceptIndices ?? [])]; // For finite timeout, track the deadline so we return 0 (timeout) when it // expires instead of retrying forever. The nfds=0 case (pure sleep) is // optimized to skip retries entirely — just wait for the deadline. - const nfds = origArgs[1]; // poll(fds, nfds, ...) / ppoll(fds, nfds, ...) + const nfds = Number( + retainedGenericSnapshot.dispatch.adjustedArgs[1] ?? 0, + ); + if ( + this.interruptPendingCancellationBeforeRegistration( + channel, + syscallNr, + cancellationIdentity, + entry, + ) + ) return; if (timeoutMs > 0 && nfds === 0) { // Pure sleep: no fds to poll, just wait for timeout const remainingMs = Math.max(deadline - Date.now(), 1); - const timer = setTimeout(() => { + const timer = this.#registerTimeout(() => { if (this.pendingPollRetries.get(channel)?.timer !== timer) return; this.pendingPollRetries.delete(channel); if (this.isRegisteredChannel(channel)) { @@ -8955,6 +16005,7 @@ export class CentralizedKernelWorker { } }, remainingMs); this.pendingPollRetries.set(channel, { + ...cancellationIdentity, timer, channel, pipeIndices, @@ -8991,8 +16042,9 @@ export class CentralizedKernelWorker { const retryMs = hasTargetedWake ? (deadline > 0 ? Math.min(deadline - Date.now(), 10) : 10) : (deadline > 0 ? Math.min(deadline - Date.now(), 50) : 50); - const timer = setTimeout(retryFn, Math.max(retryMs, 1)); + const timer = this.#registerTimeout(retryFn, Math.max(retryMs, 1)); this.pendingPollRetries.set(channel, { + ...cancellationIdentity, timer, channel, pipeIndices, @@ -9009,67 +16061,205 @@ export class CentralizedKernelWorker { // Instead of busy-retrying, delay for the requested timeout then complete // with -1/EAGAIN. if (syscallNr === SYS_RT_SIGTIMEDWAIT) { - const timeoutPtr = origArgs[2]; // pointer to timespec in process memory - if (timeoutPtr === 0) { + if (!retainedGenericSnapshot) { + this.#failBlockingRetryProtocol( + "rt_sigtimedwait retry has no immutable channel snapshot", + ); + } + const maskInput = retainedGenericSnapshot.dispatch.plannedScratchWrites + .find((write) => write.argIndex === 0)?.inputBytes; + if (!maskInput || maskInput.byteLength !== SIGNAL_MASK_BYTES) { + this.completeChannelRawAndRelisten(channel, -1, EIO, entry); + return; + } + const signalMask = new DataView( + maskInput.buffer, + maskInput.byteOffset, + maskInput.byteLength, + ).getBigUint64(0, true); + if (deliveredSignal > 0) { + this.completeChannel( + channel, + syscallNr, + origArgs, + SYSCALL_ARGS[syscallNr], + -1, + EINTR_ERRNO, + [], + undefined, + entry, + ); + return; + } + const timeoutInput = retainedGenericSnapshot.dispatch + .plannedScratchWrites.find((write) => write.argIndex === 2) + ?.inputBytes; + if (retainedGenericSnapshot.origArgs[2] === 0) { // NULL timeout = wait indefinitely. Use long retry interval since // signals arrive via kernel_kill, not organically. In the browser, // short retries starve the event loop when multiple threads are active // (e.g. MariaDB's signal handler thread). Targeted signal paths wake // this registration immediately; 500ms remains a safety net. + if ( + this.interruptPendingCancellationBeforeRegistration( + channel, + syscallNr, + cancellationIdentity, + entry, + ) + ) return; const key = `${channel.pid}:${channel.channelOffset}`; const previous = this.pendingSignalWaits.get(key); - if (previous) clearTimeout(previous.timer); - const timer = setTimeout(() => { + if (previous) this.#cancelRegisteredTimeout(previous.timer); + const timer = this.#registerTimeout(() => { + const current = this.pendingSignalWaits.get(key); + if ( + current?.timer !== timer + || current.channel !== channel + ) { + return; + } this.pendingSignalWaits.delete(key); if (this.isRegisteredChannel(channel)) { this.retrySyscall(channel); } }, 500); - this.pendingSignalWaits.set(key, { timer, channel, origArgs }); + this.pendingSignalWaits.set(key, { + ...cancellationIdentity, + timer, + channel, + origArgs: retainedGenericSnapshot.origArgs, + signalMask, + }); + return; + } + let timeoutMs: number; + try { + if (!timeoutInput || timeoutInput.byteLength !== 16) { + throw new KernelScratchError( + "sigtimedwait retry plan has no complete timeout", + EIO, + ); + } + const timeoutView = new DataView( + timeoutInput.buffer, + timeoutInput.byteOffset, + timeoutInput.byteLength, + ); + const sec = Number(timeoutView.getBigInt64(0, true)); + const nsec = Number(timeoutView.getBigInt64(8, true)); + timeoutMs = sec * 1000 + Math.floor(nsec / 1_000_000); + } catch (error) { + this.signalWaitDeadlines.delete( + `${channel.pid}:${channel.channelOffset}`, + ); + this.#rejectScratchTransfer(channel, error, entry); return; } - const pv = new DataView(channel.memory.buffer, timeoutPtr); - // timespec: i64 sec + i64 nsec (time64) - const sec = Number(pv.getBigInt64(0, true)); - const nsec = Number(pv.getBigInt64(8, true)); - const timeoutMs = sec * 1000 + Math.floor(nsec / 1_000_000); const EAGAIN_ERRNO = 11; const key = `${channel.pid}:${channel.channelOffset}`; if (timeoutMs <= 0) { this.signalWaitDeadlines.delete(key); - this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], -1, EAGAIN_ERRNO); + this.completeChannel( + channel, + syscallNr, + origArgs, + SYSCALL_ARGS[syscallNr], + -1, + EAGAIN_ERRNO, + [], + undefined, + entry, + ); } else { const existingDeadline = this.signalWaitDeadlines.get(key); - const deadline = existingDeadline?.deadline ?? performance.now() + timeoutMs; - if (!existingDeadline) { - this.signalWaitDeadlines.set(key, { pid: channel.pid, deadline }); - } - const remainingMs = deadline - performance.now(); + const deadline = existingDeadline?.deadline + ?? Date.now() + timeoutMs; + const remainingMs = deadline - Date.now(); if (remainingMs <= 0) { this.signalWaitDeadlines.delete(key); - this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], -1, EAGAIN_ERRNO); + this.completeChannel( + channel, + syscallNr, + origArgs, + SYSCALL_ARGS[syscallNr], + -1, + EAGAIN_ERRNO, + [], + undefined, + entry, + ); return; } + if ( + this.interruptPendingCancellationBeforeRegistration( + channel, + syscallNr, + cancellationIdentity, + entry, + ) + ) return; + if (!existingDeadline) { + this.signalWaitDeadlines.set(key, { pid: channel.pid, deadline }); + } const previous = this.pendingSignalWaits.get(key); - if (previous) clearTimeout(previous.timer); - const timer = setTimeout(() => { + if (previous) this.#cancelRegisteredTimeout(previous.timer); + const timer = this.#registerTimeout(() => { + const current = this.pendingSignalWaits.get(key); + if ( + current?.timer !== timer + || current.channel !== channel + ) { + return; + } this.pendingSignalWaits.delete(key); this.signalWaitDeadlines.delete(key); if (this.isRegisteredChannel(channel)) { - this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], -1, EAGAIN_ERRNO); + this.#runOrDeferChannelKernelEntry( + channel, + "sigtimedwait timeout", + (timeoutEntry) => { + this.completeChannel( + channel, + syscallNr, + origArgs, + SYSCALL_ARGS[syscallNr], + -1, + EAGAIN_ERRNO, + [], + undefined, + timeoutEntry, + ); + return undefined; + }, + ); } }, remainingMs); - this.pendingSignalWaits.set(key, { timer, channel, origArgs }); + this.pendingSignalWaits.set(key, { + ...cancellationIdentity, + timer, + channel, + origArgs: retainedGenericSnapshot.origArgs, + signalMask, + }); } return; } - // Non-blocking FD check: if the FD has O_NONBLOCK set, return EAGAIN - // immediately instead of retrying. This is critical for programs like - // nginx that use non-blocking I/O and expect EAGAIN returned promptly. - // Also honor MSG_DONTWAIT on socket send/recv syscalls; unlike O_NONBLOCK, - // it lives only in the syscall arguments and should not enter the retry path. - if (syscallHasMsgDontwait(syscallNr, origArgs)) { + const needsFrozenDescriptorPolicy = + this.#blockingRetryNonblockingDescriptors( + syscallNr, + origArgs, + ).length > 0; + if (needsFrozenDescriptorPolicy && !retryDisposition) { + this.#failBlockingRetryProtocol( + `syscall ${syscallNr} retry has no frozen blocking disposition`, + ); + } + if ( + retryDisposition?.retryForbiddenByCallFlags + || retryDisposition?.fdWasNonblocking + ) { this.completeChannel( channel, syscallNr, @@ -9077,145 +16267,169 @@ export class CentralizedKernelWorker { SYSCALL_ARGS[syscallNr], -1, EAGAIN, + [], + undefined, + entry, ); return; } - // Covers read/write, accept, accept4, and connect syscalls. - if ( - READ_LIKE_SYSCALLS.has(syscallNr) || - WRITE_LIKE_SYSCALLS.has(syscallNr) || - syscallNr === SYS_ACCEPT || - syscallNr === SYS_ACCEPT4 || - syscallNr === SYS_CONNECT - ) { - const fd = origArgs[0]; - const isFdNonblock = this.kernelInstance!.exports - .kernel_is_fd_nonblock as - ((pid: number, fd: number) => number) | undefined; - if (isFdNonblock) { - const nb = isFdNonblock(channel.pid, fd); - if (nb === 1) { - this.completeChannel( - channel, - syscallNr, - origArgs, - SYSCALL_ARGS[syscallNr], - -1, - EAGAIN, - ); - return; - } - } + // A caught handler signal interrupts only an operation that would + // otherwise park. The call-flag and descriptor nonblocking checks above + // retain their public EAGAIN result, while every blocking path reaches + // completion here after its exact Rust retry binding has been captured. + if (deliveredSignal > 0) { + this.completeChannel( + channel, + syscallNr, + origArgs, + SYSCALL_ARGS[syscallNr], + -1, + EINTR_ERRNO, + [], + undefined, + entry, + ); + return; } - // Non-blocking mqueue check: mq_timedsend/mq_timedreceive return EAGAIN - // from the kernel in both blocking and non-blocking modes (the kernel - // has no way to actually block). For non-blocking descriptors we must - // return EAGAIN to the caller; otherwise the default retry loop spins - // forever waiting for state that will never change (e.g., the final - // mq_receive in tests/sortix/os-test/basic/mqueue/mq_receive.c after mq_setattr sets - // O_NONBLOCK on an empty queue). - if (syscallNr === SYS_MQ_TIMEDSEND || syscallNr === SYS_MQ_TIMEDRECEIVE) { - const mqd = origArgs[0]; - const isFdNonblock = this.kernelInstance!.exports - .kernel_is_fd_nonblock as - ((pid: number, fd: number) => number) | undefined; - if (isFdNonblock && isFdNonblock(channel.pid, mqd) === 1) { - this.completeChannel( - channel, - syscallNr, - origArgs, - SYSCALL_ARGS[syscallNr], - -1, - EAGAIN, - ); - return; - } - } + // From here every path installs host-owned parked state: a socket timeout, + // pipe/listener registration, or fallback retry timer. Delay cancellation + // consumption until all terminal/nonblocking outcomes above are known. + if ( + this.interruptPendingCancellationBeforeRegistration( + channel, + syscallNr, + cancellationIdentity, + entry, + ) + ) return; // Socket timeout check: if a read/write-like syscall blocks on a socket // with SO_RCVTIMEO or SO_SNDTIMEO set, schedule a timer for ETIMEDOUT. + const socketTimeoutMs = + retryDisposition?.applicableSocketTimeoutMs ?? 0; if ( - READ_LIKE_SYSCALLS.has(syscallNr) || - WRITE_LIKE_SYSCALLS.has(syscallNr) + !replayingDetachedSnapshot + && socketTimeoutMs > 0 + && ( + READ_LIKE_SYSCALLS.has(syscallNr) + || WRITE_LIKE_SYSCALLS.has(syscallNr) + ) + && !this.socketTimeoutTimers.has(channel) ) { - const fd = origArgs[0]; - const getTimeout = this.kernelInstance!.exports - .kernel_get_socket_timeout_ms as - ((pid: number, fd: number, isRecv: number) => bigint) | undefined; - if (getTimeout && !this.socketTimeoutTimers.has(channel)) { - const isRecv = READ_LIKE_SYSCALLS.has(syscallNr) ? 1 : 0; - const timeoutMs = Number(getTimeout(channel.pid, fd, isRecv)); - if (timeoutMs > 0) { - const timer = setTimeout(() => { - if (this.socketTimeoutTimers.get(channel) !== timer) return; - this.socketTimeoutTimers.delete(channel); - // Remove from pending pipe readers if registered - this.removePendingPipeReader(channel); - if (this.isRegisteredChannel(channel)) { - this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], -1, ETIMEDOUT); - } - }, timeoutMs); - this.socketTimeoutTimers.set(channel, timer); + const timer = this.#registerTimeout(() => { + if (this.socketTimeoutTimers.get(channel) !== timer) return; + this.socketTimeoutTimers.delete(channel); + // Remove from pending pipe readers if registered + this.removePendingPipeReader(channel); + if (this.isRegisteredChannel(channel)) { + this.#runOrDeferChannelKernelEntry( + channel, + "socket timeout", + (timeoutEntry) => { + this.completeChannel( + channel, + syscallNr, + origArgs, + SYSCALL_ARGS[syscallNr], + -1, + ETIMEDOUT, + [], + undefined, + timeoutEntry, + ); + return undefined; + }, + ); } - } + }, socketTimeoutMs); + this.socketTimeoutTimers.set(channel, timer); } // Event-driven pipe/socket wakeup: if this is a read-like syscall on a // pipe/socket fd, register the reader so a matching write can wake it // immediately instead of polling via setImmediate. if (READ_LIKE_SYSCALLS.has(syscallNr)) { - const fd = origArgs[0]; - const getFdPipeIdx = this.kernelInstance!.exports - .kernel_get_fd_pipe_idx as - ((pid: number, fd: number) => number) | undefined; - if (getFdPipeIdx) { - const pipeIdx = getFdPipeIdx(channel.pid, fd); - if (pipeIdx >= 0) { - let readers = this.pendingPipeReaders.get(pipeIdx); - if (!readers) { - readers = []; - this.pendingPipeReaders.set(pipeIdx, readers); - } - // Avoid duplicate registrations for the same channel - if (!readers.some(r => r.channel === channel)) { - readers.push({ channel, pid: channel.pid }); - } - if (PROFILING) { - const entry = this.profileData!.get(syscallNr); - if (entry) entry.retries++; - } - return; + let pipeIdx = + this.blockingRetryWakeTargets.get(channel)?.readPipeIndex; + if (!replayingDetachedSnapshot) { + const getFdPipeIdx = this.#kernelInstanceForEntry(entry).exports + .kernel_get_fd_pipe_idx as + ((pid: number, fd: number) => number) | undefined; + pipeIdx = getFdPipeIdx?.(channel.pid, origArgs[0]); + if ( + pipeIdx !== undefined + && pipeIdx >= 0 + && this.blockingRetrySnapshots.has(channel) + ) { + this.blockingRetryWakeTargets.set(channel, { + ...this.blockingRetryWakeTargets.get(channel), + readPipeIndex: pipeIdx, + }); } } + if (pipeIdx !== undefined && pipeIdx >= 0) { + let readers = this.pendingPipeReaders.get(pipeIdx); + if (!readers) { + readers = []; + this.pendingPipeReaders.set(pipeIdx, readers); + } + if (!readers.some(r => r.channel === channel)) { + readers.push({ + ...this.#cancellationPointIdentity(channel), + channel, + pid: channel.pid, + }); + } + if (PROFILING) { + const profile = this.profileData!.get(syscallNr); + if (profile) profile.retries++; + } + return; + } } // Event-driven pipe/socket wakeup for writes: if a write-like syscall // blocks because the pipe/socket send buffer is full, register the writer // so a matching read (draining the pipe) can wake it immediately. if (WRITE_LIKE_SYSCALLS.has(syscallNr)) { - const fd = origArgs[0]; - const getSendPipeIdx = this.kernelInstance!.exports - .kernel_get_fd_send_pipe_idx as - ((pid: number, fd: number) => number) | undefined; - if (getSendPipeIdx) { - const pipeIdx = getSendPipeIdx(channel.pid, fd); - if (pipeIdx >= 0) { - let writers = this.pendingPipeWriters.get(pipeIdx); - if (!writers) { - writers = []; - this.pendingPipeWriters.set(pipeIdx, writers); - } - if (!writers.some(w => w.channel === channel)) { - writers.push({ channel, pid: channel.pid }); - } - if (PROFILING) { - const entry = this.profileData!.get(syscallNr); - if (entry) entry.retries++; - } - return; + let pipeIdx = + this.blockingRetryWakeTargets.get(channel)?.writePipeIndex; + if (!replayingDetachedSnapshot) { + const getSendPipeIdx = this.#kernelInstanceForEntry(entry).exports + .kernel_get_fd_send_pipe_idx as + ((pid: number, fd: number) => number) | undefined; + pipeIdx = getSendPipeIdx?.(channel.pid, origArgs[0]); + if ( + pipeIdx !== undefined + && pipeIdx >= 0 + && this.blockingRetrySnapshots.has(channel) + ) { + this.blockingRetryWakeTargets.set(channel, { + ...this.blockingRetryWakeTargets.get(channel), + writePipeIndex: pipeIdx, + }); + } + } + if (pipeIdx !== undefined && pipeIdx >= 0) { + let writers = this.pendingPipeWriters.get(pipeIdx); + if (!writers) { + writers = []; + this.pendingPipeWriters.set(pipeIdx, writers); + } + if (!writers.some(w => w.channel === channel)) { + writers.push({ + ...this.#cancellationPointIdentity(channel), + channel, + pid: channel.pid, + }); } + if (PROFILING) { + const profile = this.profileData!.get(syscallNr); + if (profile) profile.retries++; + } + return; } } @@ -9223,13 +16437,26 @@ export class CentralizedKernelWorker { // accept-readiness token so local connect/injected connection wakes the // accept immediately instead of waiting for the fallback timer. if (syscallNr === SYS_ACCEPT || syscallNr === SYS_ACCEPT4) { - const fd = origArgs[0]; - const getAcceptWakeIdx = this.kernelInstance!.exports - .kernel_get_fd_accept_wake_idx as - ((pid: number, fd: number) => number) | undefined; - if (getAcceptWakeIdx) { - const acceptIdx = getAcceptWakeIdx(channel.pid, fd); - if (acceptIdx >= 0) { + let acceptIdx = + this.blockingRetryWakeTargets.get(channel)?.acceptIndex; + if (!replayingDetachedSnapshot) { + const fd = origArgs[0]; + const getAcceptWakeIdx = this.#kernelInstanceForEntry(entry).exports + .kernel_get_fd_accept_wake_idx as + ((pid: number, fd: number) => number) | undefined; + acceptIdx = getAcceptWakeIdx?.(channel.pid, fd); + if ( + acceptIdx !== undefined + && acceptIdx >= 0 + && this.blockingRetrySnapshots.has(channel) + ) { + this.blockingRetryWakeTargets.set(channel, { + ...this.blockingRetryWakeTargets.get(channel), + acceptIndex: acceptIdx, + }); + } + } + if (acceptIdx !== undefined && acceptIdx >= 0) { const retryFn = () => { const pending = this.pendingPollRetries.get(channel); if (!pending || pending.timer !== timer) return; @@ -9238,8 +16465,9 @@ export class CentralizedKernelWorker { this.retrySyscall(channel); } }; - const timer = setTimeout(retryFn, 10); + const timer = this.#registerTimeout(retryFn, 10); this.pendingPollRetries.set(channel, { + ...this.#cancellationPointIdentity(channel), timer, channel, pipeIndices: [], @@ -9250,7 +16478,6 @@ export class CentralizedKernelWorker { if (entry) entry.retries++; } return; - } } } @@ -9266,12 +16493,13 @@ export class CentralizedKernelWorker { const pending = this.pendingPollRetries.get(channel); if (!pending || pending.timer !== timer) return; this.pendingPollRetries.delete(channel); - if (this.isAsyncChannelProcessActive(channel)) { + if (this.isRegisteredChannel(channel)) { this.retrySyscall(channel); } }; - const timer = setTimeout(retryFn, 10); + const timer = this.#registerTimeout(retryFn, 10); this.pendingPollRetries.set(channel, { + ...this.#cancellationPointIdentity(channel), timer, channel, pipeIndices: [], @@ -9280,29 +16508,62 @@ export class CentralizedKernelWorker { } /** - * Retry a syscall by re-invoking handleSyscall with the original - * args still in the process channel. + * Retry one exact channel generation. + * + * Data-transfer snapshots bypass the mutable mailbox entirely. Legacy + * blocking families retain their existing parser until they are migrated to + * an equally complete operation-specific plan. */ private retrySyscall(channel: ChannelInfo): void { + const testHook = this.#scratchBoundaryTestHooks?.retrySyscall; + if (testHook) { + testHook(channel); + return; + } + if (this.#kernelFatalError !== null) { + channel.handling = true; + return; + } // Deferred retry callbacks can outlive an exec image. Never consult or // mutate the replacement generation through a discarded channel object. if (!this.isRegisteredChannel(channel)) return; + this.#runOrDeferChannelKernelEntry( + channel, + "syscall retry", + (entry) => { + this.#retrySyscallWithinKernelEntry(channel, entry); + return undefined; + }, + ); + } + + #retrySyscallWithinKernelEntry( + channel: ChannelInfo, + entry: KernelWorkerEntryContext, + ): void { if (this.deferChannelWhileStopped(channel)) return; // Check if the process was killed by a signal while blocking. // This handles cases like sigsuspend + cross-process SIGABRT where // deliver_pending_signals marks the target as Exited. - if (this.getProcessExitSignal(channel.pid) > 0) { + if (this.#getProcessExitSignal(channel.pid, entry) > 0) { this.signalWaitDeadlines.delete( `${channel.pid}:${channel.channelOffset}`, ); - this.handleProcessTerminated(channel); + this.#handleProcessTerminatedWithinKernelEntry(channel, entry); + return; + } + + const snapshot = this.blockingRetrySnapshots.get(channel); + if (snapshot) { + this.#replayBlockingRetrySnapshot(channel, snapshot, entry); return; } - // The process channel still has the original args (we never wrote a response). - // Just re-handle it. - this.handleSyscall(channel); + // Call the already-gated body directly for non-snapshotted blockers. + // Re-entering the public wrapper here would put this selected mailbox + // behind itself again. + this.#handleSyscallWithinKernelEntry(channel, entry); } /** @@ -9318,6 +16579,7 @@ export class CentralizedKernelWorker { errVal: number, capturedDelayMs?: number, outputWrites: ChannelOutputWrite[] = [], + entry?: KernelWorkerEntryContext, ): boolean { let delayMs = 0; @@ -9331,59 +16593,115 @@ export class CentralizedKernelWorker { } if (delayMs > 0) { - const timer = setTimeout(() => { - const pending = this.pendingSleeps.get(channel); - if (pending?.timer !== timer || pending.channel !== channel) return; - this.pendingSleeps.delete(channel); - if (this.isRegisteredChannel(channel)) { - this.completeSleepWithSignalCheck( - channel, - syscallNr, - origArgs, - retVal, - errVal, - outputWrites, - ); - } - }, delayMs); - this.pendingSleeps.set(channel, { - timer, - channel, - syscallNr, - origArgs, - retVal, - errVal, - outputWrites, - }); + if ( + entry + && this.interruptPendingCancellationBeforeRegistration( + channel, + syscallNr, + this.#cancellationPointIdentity(channel), + entry, + ) + ) return true; + const register = (): undefined => { + const timer = this.#registerTimeout(() => { + const pending = this.pendingSleeps.get(channel); + if (pending?.timer !== timer || pending.channel !== channel) return; + this.pendingSleeps.delete(channel); + if (this.isRegisteredChannel(channel)) { + this.completeSleepWithSignalCheck( + channel, + syscallNr, + origArgs, + retVal, + errVal, + outputWrites, + ); + } + }, delayMs); + this.pendingSleeps.set(channel, { + ...this.#cancellationPointIdentity(channel), + timer, + channel, + syscallNr, + origArgs, + retVal, + errVal, + outputWrites, + }); + return undefined; + }; + // WHY: timer registration is a host effect and its callback outlives the + // current kernel stack. Install it only after scope revocation; the + // callback enters through the public completion root below. + if (entry) entry.deferProtocolEffect(register); + else register(); return true; } return false; } - /** - * Complete a sleep syscall, checking for pending signals first. - * POSIX: sleep interrupted by signal returns EINTR. - */ - private completeSleepWithSignalCheck( + /** + * Complete a sleep syscall, checking for pending signals first. + * POSIX: sleep interrupted by signal returns EINTR. + */ + private completeSleepWithSignalCheck( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + retVal: number, + errVal: number, + outputWrites: ChannelOutputWrite[] = [], + ): void { + if (!this.isRegisteredChannel(channel)) return; + this.#runOrDeferChannelKernelEntry( + channel, + "sleep completion", + (entry) => { + this.#completeSleepWithSignalCheckWithinKernelEntry( + channel, + syscallNr, + origArgs, + retVal, + errVal, + outputWrites, + entry, + ); + return undefined; + }, + ); + } + + #completeSleepWithSignalCheckWithinKernelEntry( channel: ChannelInfo, syscallNr: number, origArgs: number[], retVal: number, errVal: number, - outputWrites: ChannelOutputWrite[] = [], + outputWrites: ChannelOutputWrite[], + entry: KernelWorkerEntryContext, ): void { // Check if a signal became pending during the sleep - this.dequeueSignalForDelivery(channel); - if (this.finishSignalTermination(channel)) return; + const deliveredSignal = this.#dequeueSignalForDelivery(channel, entry); + if (this.#finishSignalTermination(channel, entry)) return; - // If a signal was dequeued, return EINTR instead of success - const processView = new DataView(channel.memory.buffer, channel.channelOffset); - const pendingSig = processView.getUint32(CH_SIG_SIGNUM, true); - if (pendingSig > 0) { + // The kernel dequeue return is the authority marker. Another process + // thread can write its shared mailbox, so CH_SIG bytes alone must never + // manufacture an interruption. + if (deliveredSignal > 0) { // POSIX: nanosleep/usleep interrupted by signal returns -1/EINTR const EINTR = 4; - this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], -1, EINTR); + this.completeChannel( + channel, + syscallNr, + origArgs, + SYSCALL_ARGS[syscallNr], + -1, + EINTR, + [], + undefined, + entry, + ); } else { this.completeChannel( channel, @@ -9393,6 +16711,8 @@ export class CentralizedKernelWorker { retVal, errVal, outputWrites, + undefined, + entry, ); } } @@ -9410,30 +16730,44 @@ export class CentralizedKernelWorker { * Handle fcntl lock operations (F_GETLK, F_SETLK, F_SETLKW). * Arg3 points to the generated fixed-size flock wire and needs copy in/out. */ - private handleFcntlLock(channel: ChannelInfo, origArgs: number[]): void { - const flockPtr = origArgs[2]; - - const processMem = new Uint8Array(channel.memory.buffer); - if ( - !Number.isSafeInteger(flockPtr) || - flockPtr <= 0 || - flockPtr > processMem.byteLength - FCNTL_FLOCK_BYTES - ) { - this.completeChannel( - channel, - SYS_FCNTL, - origArgs, - undefined, - -1, - EFAULT, + private handleFcntlLock( + channel: ChannelInfo, + origArgs: number[], + entry: KernelWorkerEntryContext, + retainedSnapshot?: FcntlLockBlockingRetrySnapshot, + ): void { + const flockPtr = retainedSnapshot?.flockPointer ?? origArgs[2]; + let flockBytes = retainedSnapshot?.flockBytes; + if (!flockBytes) { + const processMem = new Uint8Array(channel.memory.buffer); + if ( + !Number.isSafeInteger(flockPtr) || + flockPtr <= 0 || + flockPtr > processMem.byteLength - FCNTL_FLOCK_BYTES + ) { + this.completeChannel( + channel, + SYS_FCNTL, + origArgs, + undefined, + -1, + EFAULT, + [], + undefined, + entry, + ); + return; + } + flockBytes = processMem.slice( + flockPtr, + flockPtr + FCNTL_FLOCK_BYTES, ); - return; } let result: { retVal: number; errVal: number; flock: Uint8Array | null }; try { - result = this.requireMainScratchRegion().withLease((lease) => { + result = this.#requireMainScratchRegion().withLease((lease) => { const kernelView = lease.dataView(0, CH_TOTAL_SIZE); - lease.copyFrom(processMem, CH_DATA, flockPtr, FCNTL_FLOCK_BYTES); + lease.copyFrom(flockBytes, CH_DATA, 0, FCNTL_FLOCK_BYTES); kernelView.setUint32(CH_SYSCALL, SYS_FCNTL, true); kernelView.setBigInt64(CH_ARGS, BigInt(origArgs[0]), true); kernelView.setBigInt64( @@ -9455,14 +16789,20 @@ export class CentralizedKernelWorker { ); } - this.bindKernelTidForChannel(channel); + this.#bindKernelTidForChannel(channel, entry); this.currentHandlePid = channel.pid; try { - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - channel.pid, - ]); + this.#invokeEntryScratchExport( + entry, + lease, + "kernel_handle_channel", + [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + retainedSnapshot?.retryToken ?? 0n, + ], + ); } finally { this.currentHandlePid = 0; } @@ -9477,19 +16817,20 @@ export class CentralizedKernelWorker { }; }); } catch (error) { - this.rejectScratchTransfer(channel, error); + this.#rethrowKernelEntryFatal(error); + this.#rejectScratchTransfer(channel, error, entry); return; } - if (this.finishSignalTermination(channel)) return; + if (this.#finishSignalTermination(channel, entry)) return; const { retVal, errVal } = result; // This marshalling path bypasses the generic syscall completion path, // so it must also dequeue a caught signal itself. A conflicting blocking // request is interruptible: once a handler signal is prepared for this // exact channel, publish EINTR instead of re-parking it on EAGAIN. - const deliveredSignal = this.dequeueSignalForDelivery(channel); - if (this.finishSignalTermination(channel)) return; + const deliveredSignal = this.#dequeueSignalForDelivery(channel, entry); + if (this.#finishSignalTermination(channel, entry)) return; // Copy flock struct back from kernel → process (F_GETLK writes to it) if (result.flock) { @@ -9497,6 +16838,24 @@ export class CentralizedKernelWorker { } const cmd = origArgs[1]; + if ( + retVal === -1 + && errVal === EAGAIN + && !retainedSnapshot + && !this.#rememberBlockingRetrySnapshot( + channel, + { + ...this.#cancellationPointIdentity(channel), + kind: "fcntl-lock", + syscallNr: SYS_FCNTL, + origArgs: origArgs.slice(), + flockPointer: flockPtr, + flockBytes, + retryToken: 0n, + }, + entry, + ) + ) return; if ( retVal === -1 && errVal === EAGAIN && @@ -9510,14 +16869,27 @@ export class CentralizedKernelWorker { undefined, -1, EINTR_ERRNO, + [], + undefined, + entry, ); return; } - this.parkAdvisoryLockRetry(channel); + this.parkAdvisoryLockRetry(channel, SYS_FCNTL, entry); return; } - this.completeChannel(channel, SYS_FCNTL, origArgs, undefined, retVal, errVal); + this.completeChannel( + channel, + SYS_FCNTL, + origArgs, + undefined, + retVal, + errVal, + [], + undefined, + entry, + ); } /** @@ -9546,11 +16918,22 @@ export class CentralizedKernelWorker { syscallNr: number, origArgs: number[], interruptCaughtSignal: boolean, + entry: KernelWorkerEntryContext, ): boolean { - const deliveredSignal = this.dequeueSignalForDelivery(channel); - if (this.finishSignalTermination(channel)) return true; + const deliveredSignal = this.#dequeueSignalForDelivery(channel, entry); + if (this.#finishSignalTermination(channel, entry)) return true; if (interruptCaughtSignal && deliveredSignal > 0) { - this.completeChannel(channel, syscallNr, origArgs, undefined, -1, EINTR_ERRNO); + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EINTR_ERRNO, + [], + undefined, + entry, + ); return true; } return false; @@ -9567,11 +16950,12 @@ export class CentralizedKernelWorker { channel: ChannelInfo, syscallNr: number, nfds: number, - readPtr: number, - writePtr: number, - exceptPtr: number, + readBytes: Uint8Array | null, + writeBytes: Uint8Array | null, + exceptBytes: Uint8Array | null, timeoutMs: number, - maskPtr: number, + maskBytes: Uint8Array | null, + entry: KernelWorkerEntryContext, ): { retVal: number; errVal: number; @@ -9580,19 +16964,24 @@ export class CentralizedKernelWorker { except: Uint8Array | null; usedMask: boolean; } { - const processMem = new Uint8Array(channel.memory.buffer); - const scratch = this.requireMainScratchRegion(); + const scratch = this.#requireMainScratchRegion(); return scratch.withLease((lease) => { const kernelView = lease.dataView(0, CH_TOTAL_SIZE); const fdSetOffset = (index: number) => CH_DATA + index * SELECT_FD_SET_BYTES; - for (const [index, pointer] of [readPtr, writePtr, exceptPtr].entries()) { - if (pointer !== 0) { + for ( + const [index, bytes] of [ + readBytes, + writeBytes, + exceptBytes, + ].entries() + ) { + if (bytes !== null) { lease.copyFrom( - processMem, + bytes, fdSetOffset(index), - pointer, + 0, SELECT_FD_SET_BYTES, ); } else { @@ -9600,11 +16989,11 @@ export class CentralizedKernelWorker { } } const maskOffset = fdSetOffset(3); - if (maskPtr !== 0) { + if (maskBytes !== null) { lease.copyFrom( - processMem, + maskBytes, maskOffset, - maskPtr, + 0, SIGNAL_MASK_BYTES, ); } @@ -9614,7 +17003,7 @@ export class CentralizedKernelWorker { kernelView.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, 0n, true); } kernelView.setBigInt64(CH_ARGS, BigInt(nfds), true); - if (readPtr !== 0) { + if (readBytes !== null) { lease.writeAddress( CH_ARGS + CH_ARG_SIZE, fdSetOffset(0), @@ -9622,7 +17011,7 @@ export class CentralizedKernelWorker { "u64-le", ); } - if (writePtr !== 0) { + if (writeBytes !== null) { lease.writeAddress( CH_ARGS + 2 * CH_ARG_SIZE, fdSetOffset(1), @@ -9630,7 +17019,7 @@ export class CentralizedKernelWorker { "u64-le", ); } - if (exceptPtr !== 0) { + if (exceptBytes !== null) { lease.writeAddress( CH_ARGS + 3 * CH_ARG_SIZE, fdSetOffset(2), @@ -9643,7 +17032,7 @@ export class CentralizedKernelWorker { BigInt(timeoutMs), true, ); - if (syscallNr === SYS_PSELECT6 && maskPtr !== 0) { + if (syscallNr === SYS_PSELECT6 && maskBytes !== null) { lease.writeAddress( CH_ARGS + 5 * CH_ARG_SIZE, maskOffset, @@ -9652,16 +17041,23 @@ export class CentralizedKernelWorker { ); } - this.bindKernelTidForChannel(channel); + this.#bindKernelTidForChannel(channel, entry); + const previousHandlePid = this.currentHandlePid; this.currentHandlePid = channel.pid; try { - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - channel.pid, - ]); + this.#invokeEntryScratchExport( + entry, + lease, + "kernel_handle_channel", + [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + 0n, + ], + ); } finally { - this.currentHandlePid = 0; + this.currentHandlePid = previousHandlePid; } const resultView = lease.dataView(0, CH_TOTAL_SIZE); @@ -9670,80 +17066,187 @@ export class CentralizedKernelWorker { return { retVal, errVal, - read: retVal >= 0 && readPtr !== 0 + read: retVal >= 0 && readBytes !== null ? lease.copyOut(fdSetOffset(0), SELECT_FD_SET_BYTES) : null, - write: retVal >= 0 && writePtr !== 0 + write: retVal >= 0 && writeBytes !== null ? lease.copyOut(fdSetOffset(1), SELECT_FD_SET_BYTES) : null, - except: retVal >= 0 && exceptPtr !== 0 + except: retVal >= 0 && exceptBytes !== null ? lease.copyOut(fdSetOffset(2), SELECT_FD_SET_BYTES) : null, - usedMask: maskPtr !== 0, + usedMask: maskBytes !== null, }; }); } - private handleSelect(channel: ChannelInfo, origArgs: number[]): void { - if (this.deferChannelWhileStopped(channel)) return; + /** Capture every select-family scalar and nested byte range exactly once. */ + private captureSelectBlockingRetrySnapshot( + channel: ChannelInfo, + syscallNr: typeof SYS_SELECT | typeof SYS_PSELECT6, + origArgs: number[], + ): SelectBlockingRetrySnapshot { const nfds = origArgs[0]; - const readPtr = origArgs[1]; - const writePtr = origArgs[2]; - const exceptPtr = origArgs[3]; - const tvPtr = origArgs[4]; - const pointerWidth = this.getPtrWidth(channel.pid); - try { - if ( - !Number.isSafeInteger(nfds) || - nfds < 0 || - nfds > SELECT_FD_SETSIZE - ) { - throw new KernelScratchError( - `select nfds must be between 0 and ${SELECT_FD_SETSIZE}`, - EINVAL, - ); + if ( + !Number.isSafeInteger(nfds) + || nfds < 0 + || nfds > SELECT_FD_SETSIZE + ) { + throw new KernelScratchError( + `${syscallNr === SYS_SELECT ? "select" : "pselect"} nfds must be between 0 and ${SELECT_FD_SETSIZE}`, + EINVAL, + ); + } + const processBytes = new Uint8Array(channel.memory.buffer); + const captureFdSet = (pointer: number, field: string): Uint8Array | null => { + if (pointer === 0) return null; + const range = this.checkedProcessRange( + channel, + pointer, + SELECT_FD_SET_BYTES, + field, + ); + return processBytes.slice(range.pointer, range.end); + }; + const readPointer = origArgs[1]; + const writePointer = origArgs[2]; + const exceptPointer = origArgs[3]; + const readBytes = captureFdSet( + readPointer, + `${syscallNr === SYS_SELECT ? "select" : "pselect6"} read fd_set`, + ); + const writeBytes = captureFdSet( + writePointer, + `${syscallNr === SYS_SELECT ? "select" : "pselect6"} write fd_set`, + ); + const exceptBytes = captureFdSet( + exceptPointer, + `${syscallNr === SYS_SELECT ? "select" : "pselect6"} except fd_set`, + ); + + let timeoutMs = -1; + const timeoutPointer = origArgs[4]; + if (timeoutPointer !== 0) { + const pointerWidth = this.getPtrWidth(channel.pid); + const timeoutBytes = syscallNr === SYS_SELECT + ? (pointerWidth === 8 ? 16 : 8) + : 16; + const range = this.checkedProcessRange( + channel, + timeoutPointer, + timeoutBytes, + syscallNr === SYS_SELECT ? "select timeout" : "pselect6 timeout", + ); + const timeoutView = new DataView( + channel.memory.buffer, + range.pointer, + range.length, + ); + if (syscallNr === SYS_SELECT) { + const sec = pointerWidth === 8 + ? Number(timeoutView.getBigInt64(0, true)) + : timeoutView.getInt32(0, true); + const usec = pointerWidth === 8 + ? Number(timeoutView.getBigInt64(8, true)) + : timeoutView.getInt32(4, true); + timeoutMs = sec * 1000 + Math.floor(usec / 1_000); + if (timeoutMs < 0) timeoutMs = 0; + } else { + const sec = Number(timeoutView.getBigInt64(0, true)); + const nsec = Number(timeoutView.getBigInt64(8, true)); + timeoutMs = sec * 1000 + Math.floor(nsec / 1_000_000); } - for (const [pointer, field] of [ - [readPtr, "select read fd_set"], - [writePtr, "select write fd_set"], - [exceptPtr, "select except fd_set"], - ] as const) { - if (pointer !== 0) { - this.checkedProcessRange( - channel, - pointer, - SELECT_FD_SET_BYTES, - field, + } + + let maskBytes: Uint8Array | null = null; + if (syscallNr === SYS_PSELECT6 && origArgs[5] !== 0) { + const pointerWidth = this.getPtrWidth(channel.pid); + const outer = this.checkedProcessRange( + channel, + origArgs[5], + pointerWidth === 8 ? 16 : 8, + "pselect6 mask descriptor", + ); + const descriptor = new DataView( + channel.memory.buffer, + outer.pointer, + outer.length, + ); + const rawMaskPointer = pointerWidth === 8 + ? descriptor.getBigUint64(0, true) + : descriptor.getUint32(0, true); + const rawMaskSize = pointerWidth === 8 + ? descriptor.getBigUint64(8, true) + : BigInt(descriptor.getUint32(4, true)); + const maskPointer = checkedWasmPointer( + rawMaskPointer, + pointerWidth, + "pselect6 mask pointer", + ); + if (maskPointer !== 0) { + if (rawMaskSize !== BigInt(SIGNAL_MASK_BYTES)) { + throw new KernelScratchError( + `pselect6 sigset size must be ${SIGNAL_MASK_BYTES}`, + EINVAL, ); } - } - if (tvPtr !== 0) { - this.checkedProcessRange( + const range = this.checkedProcessRange( channel, - tvPtr, - pointerWidth === 8 ? 16 : 8, - "select timeout", + rawMaskPointer, + SIGNAL_MASK_BYTES, + "pselect6 signal mask", ); + maskBytes = processBytes.slice(range.pointer, range.end); } - } catch (error) { - this.rejectScratchTransfer(channel, error); - return; } - let timeoutMs = -1; // -1 = infinite (NULL timeval) - if (tvPtr !== 0) { - const pv = new DataView(channel.memory.buffer, tvPtr); - let sec: number, usec: number; - if (pointerWidth === 8) { - sec = Number(pv.getBigInt64(0, true)); - usec = Number(pv.getBigInt64(8, true)); - } else { - sec = pv.getInt32(0, true); - usec = pv.getInt32(4, true); + return { + ...this.#cancellationPointIdentity(channel), + kind: "select", + syscallNr, + origArgs: origArgs.slice(), + nfds, + readPointer, + writePointer, + exceptPointer, + readBytes, + writeBytes, + exceptBytes, + timeoutMs, + maskBytes, + retryToken: 0n, + }; + } + + private handleSelect( + channel: ChannelInfo, + origArgs: number[], + entry: KernelWorkerEntryContext, + retainedSnapshot?: SelectBlockingRetrySnapshot, + ): void { + if (this.deferChannelWhileStopped(channel)) return; + let snapshot = retainedSnapshot; + if (!snapshot) { + try { + snapshot = this.captureSelectBlockingRetrySnapshot( + channel, + SYS_SELECT, + origArgs, + ); + } catch (error) { + this.#rejectScratchTransfer(channel, error, entry); + return; } - timeoutMs = sec * 1000 + Math.floor(usec / 1000); - if (timeoutMs < 0) timeoutMs = 0; } + const { + nfds, + readPointer: readPtr, + writePointer: writePtr, + exceptPointer: exceptPtr, + timeoutMs, + } = snapshot; + origArgs = snapshot.origArgs; + const finalCheck = channel.readinessFinalCheck === true; channel.readinessFinalCheck = false; const kernelTimeoutMs = finalCheck ? 0 : timeoutMs; @@ -9756,30 +17259,76 @@ export class CentralizedKernelWorker { // (handleKill -> scheduleWakeBlockedRetries -> wakeAllBlockedRetries // already iterates pendingSelectRetries entries). if (nfds === 0 && readPtr === 0 && writePtr === 0 && exceptPtr === 0) { - if (this.completeSelectSignalOutcome(channel, SYS_SELECT, origArgs, true)) return; + if ( + this.completeSelectSignalOutcome( + channel, + SYS_SELECT, + origArgs, + true, + entry, + ) + ) return; if (kernelTimeoutMs === 0) { - this.completeChannel(channel, SYS_SELECT, origArgs, undefined, 0, 0); + this.completeChannel( + channel, + SYS_SELECT, + origArgs, + undefined, + 0, + 0, + [], + undefined, + entry, + ); return; } + if ( + !retainedSnapshot + && !this.#rememberBlockingRetrySnapshot(channel, snapshot, entry) + ) return; + if ( + this.interruptPendingCancellationBeforeRegistration( + channel, + SYS_SELECT, + snapshot, + entry, + ) + ) return; const finite = timeoutMs > 0; - const remainingMs = finite ? Math.max(deadline - Date.now(), 1) : -1; - const timer = finite - ? setTimeout(() => { + const remainingMs = finite + ? Math.max(deadline - Date.now(), 1) + : -1; + if (finite) { + entry.deferProtocolEffect(() => { + const timer = this.#registerTimeout(() => { if (this.pendingSelectRetries.get(channel)?.timer !== timer) return; this.pendingSelectRetries.delete(channel); if (this.isRegisteredChannel(channel)) { - this.completeChannel(channel, SYS_SELECT, origArgs, undefined, 0, 0); + channel.readinessFinalCheck = true; + this.retrySyscall(channel); } - }, remainingMs) - : (null as any); - this.pendingSelectRetries.set(channel, { - timer, - channel, - origArgs, - deadline, - needsSignalSafeWake: false, - syscallNr: SYS_SELECT, - }); + }, remainingMs); + this.pendingSelectRetries.set(channel, { + ...this.#cancellationPointIdentity(channel), + timer, + channel, + origArgs, + deadline, + needsSignalSafeWake: false, + syscallNr: SYS_SELECT, + }); + }); + } else { + this.pendingSelectRetries.set(channel, { + ...this.#cancellationPointIdentity(channel), + timer: null as any, + channel, + origArgs, + deadline, + needsSignalSafeWake: false, + syscallNr: SYS_SELECT, + }); + } return; } @@ -9789,14 +17338,25 @@ export class CentralizedKernelWorker { channel, SYS_SELECT, nfds, - readPtr, - writePtr, - exceptPtr, + snapshot.readBytes, + snapshot.writeBytes, + snapshot.exceptBytes, kernelTimeoutMs, - 0, + null, + entry, ); } catch (error) { - this.rejectScratchTransfer(channel, error); + this.#rethrowKernelEntryFatal(error); + if ( + retainedSnapshot + && !this.#cancelHostOwnedKernelWait(channel, SYS_SELECT, entry) + ) { + this.#failBlockingRetryProtocol( + "select retry cleanup failed after staging error", + error, + ); + } + this.#rejectScratchTransfer(channel, error, entry); return; } const { retVal, errVal } = attempt; @@ -9810,36 +17370,61 @@ export class CentralizedKernelWorker { SYS_SELECT, origArgs, retVal === -1 && errVal === EAGAIN, + entry, )) return; // EAGAIN retry for blocking select. Mirrors handlePselect6. if (retVal === -1 && errVal === EAGAIN) { if (timeoutMs === 0) { - this.completeChannel(channel, SYS_SELECT, origArgs, undefined, 0, 0); + this.completeChannel( + channel, + SYS_SELECT, + origArgs, + undefined, + 0, + 0, + [], + undefined, + entry, + ); return; } + if ( + !retainedSnapshot + && !this.#rememberBlockingRetrySnapshot(channel, snapshot, entry) + ) return; if (deadline > 0 && Date.now() >= deadline) { channel.readinessFinalCheck = true; - this.handleSelect(channel, origArgs); + this.handleSelect(channel, snapshot.origArgs, entry, snapshot); return; } - const retryFn = () => { - const pending = this.pendingSelectRetries.get(channel); - if (!pending || pending.timer !== timer) return; - this.pendingSelectRetries.delete(channel); - if (!this.isRegisteredChannel(channel)) return; - this.handleSelect(channel, origArgs); - }; + if ( + this.interruptPendingCancellationBeforeRegistration( + channel, + SYS_SELECT, + snapshot, + entry, + ) + ) return; const finite = timeoutMs > 0; const remainingMs = finite ? Math.max(deadline - Date.now(), 1) : 50; - const timer = setTimeout(retryFn, Math.min(remainingMs, 50)); - this.pendingSelectRetries.set(channel, { - timer, - channel, - origArgs, - deadline, - needsSignalSafeWake: false, - syscallNr: SYS_SELECT, + entry.deferProtocolEffect(() => { + const timer = this.#registerTimeout(() => { + const pending = this.pendingSelectRetries.get(channel); + if (!pending || pending.timer !== timer) return; + this.pendingSelectRetries.delete(channel); + if (!this.isRegisteredChannel(channel)) return; + this.retrySyscall(channel); + }, Math.min(remainingMs, 50)); + this.pendingSelectRetries.set(channel, { + ...this.#cancellationPointIdentity(channel), + timer, + channel, + origArgs, + deadline, + needsSignalSafeWake: false, + syscallNr: SYS_SELECT, + }); }); return; } @@ -9850,92 +17435,42 @@ export class CentralizedKernelWorker { origArgs, undefined, retVal, - errVal, - ); - } - - private handlePselect6(channel: ChannelInfo, origArgs: number[]): void { - if (this.deferChannelWhileStopped(channel)) return; - const processMem = new Uint8Array(channel.memory.buffer); - - const nfds = origArgs[0]; - const readPtr = origArgs[1]; - const writePtr = origArgs[2]; - const exceptPtr = origArgs[3]; - const tsPtr = origArgs[4]; - const maskDataPtr = origArgs[5]; // pointer to {sigset_t *mask, size_t size} - let checkedMaskPtr = 0; - try { - if ( - !Number.isSafeInteger(nfds) || - nfds < 0 || - nfds > SELECT_FD_SETSIZE - ) { - throw new KernelScratchError( - `pselect nfds must be between 0 and ${SELECT_FD_SETSIZE}`, - EINVAL, - ); - } - for (const [pointer, field] of [ - [readPtr, "pselect6 read fd_set"], - [writePtr, "pselect6 write fd_set"], - [exceptPtr, "pselect6 except fd_set"], - ] as const) { - if (pointer !== 0) { - this.checkedProcessRange( - channel, - pointer, - SELECT_FD_SET_BYTES, - field, - ); - } - } - if (tsPtr !== 0) { - this.checkedProcessRange(channel, tsPtr, 16, "pselect6 timeout"); - } - if (maskDataPtr !== 0) { - const pointerWidth = this.getPtrWidth(channel.pid); - const outer = this.checkedProcessRange( + errVal, + [], + undefined, + entry, + ); + } + + private handlePselect6( + channel: ChannelInfo, + origArgs: number[], + entry: KernelWorkerEntryContext, + retainedSnapshot?: SelectBlockingRetrySnapshot, + ): void { + if (this.deferChannelWhileStopped(channel)) return; + let snapshot = retainedSnapshot; + if (!snapshot) { + try { + snapshot = this.captureSelectBlockingRetrySnapshot( channel, - maskDataPtr, - pointerWidth === 8 ? 16 : 8, - "pselect6 mask descriptor", - ); - const descriptor = new DataView( - channel.memory.buffer, - outer.pointer, - outer.length, - ); - const rawMaskPointer = pointerWidth === 8 - ? descriptor.getBigUint64(0, true) - : descriptor.getUint32(0, true); - checkedMaskPtr = checkedWasmPointer( - rawMaskPointer, - pointerWidth, - "pselect6 mask pointer", + SYS_PSELECT6, + origArgs, ); - if (checkedMaskPtr !== 0) { - this.checkedProcessRange( - channel, - rawMaskPointer, - SIGNAL_MASK_BYTES, - "pselect6 signal mask", - ); - } + } catch (error) { + this.#rejectScratchTransfer(channel, error, entry); + return; } - } catch (error) { - this.rejectScratchTransfer(channel, error); - return; } + const { + nfds, + readPointer: readPtr, + writePointer: writePtr, + exceptPointer: exceptPtr, + timeoutMs, + } = snapshot; + origArgs = snapshot.origArgs; - // Decode timeout: timespec {i64 sec, i64 nsec} → ms - let timeoutMs = -1; - if (tsPtr !== 0) { - const pv = new DataView(channel.memory.buffer, tsPtr); - const sec = Number(pv.getBigInt64(0, true)); - const nsec = Number(pv.getBigInt64(8, true)); - timeoutMs = sec * 1000 + Math.floor(nsec / 1000000); - } const finalCheck = channel.readinessFinalCheck === true; channel.readinessFinalCheck = false; const kernelTimeoutMs = finalCheck ? 0 : timeoutMs; @@ -9962,14 +17497,22 @@ export class CentralizedKernelWorker { channel, SYS_PSELECT6, nfds, - readPtr, - writePtr, - exceptPtr, + snapshot.readBytes, + snapshot.writeBytes, + snapshot.exceptBytes, kernelTimeoutMs, - checkedMaskPtr, + snapshot.maskBytes, + entry, ); } catch (error) { - this.rejectScratchTransfer(channel, error); + this.#rethrowKernelEntryFatal(error); + if (!this.#cancelHostOwnedKernelWait(channel, SYS_PSELECT6, entry)) { + this.#failBlockingRetryProtocol( + "pselect6 retry cleanup failed after staging error", + error, + ); + } + this.#rejectScratchTransfer(channel, error, entry); return; } const { retVal, errVal } = attempt; @@ -9983,19 +17526,53 @@ export class CentralizedKernelWorker { SYS_PSELECT6, origArgs, retVal === -1 && errVal === EAGAIN, + entry, )) return; // Handle EAGAIN retry for blocking select if (retVal === -1 && errVal === EAGAIN) { if (timeoutMs === 0) { - this.completeChannel(channel, SYS_PSELECT6, origArgs, undefined, 0, 0); + if ( + !this.#cancelHostOwnedKernelWait( + channel, + SYS_PSELECT6, + entry, + ) + ) { + this.#failBlockingRetryProtocol( + "pselect6 could not restore its temporary mask", + ); + } + this.completeChannel( + channel, + SYS_PSELECT6, + origArgs, + undefined, + 0, + 0, + [], + undefined, + entry, + ); return; } + if ( + !retainedSnapshot + && !this.#rememberBlockingRetrySnapshot(channel, snapshot, entry) + ) return; if (deadline > 0 && Date.now() >= deadline) { channel.readinessFinalCheck = true; - this.handlePselect6(channel, origArgs); + this.handlePselect6(channel, snapshot.origArgs, entry, snapshot); return; } + if ( + this.interruptPendingCancellationBeforeRegistration( + channel, + SYS_PSELECT6, + snapshot, + entry, + ) + ) return; // pselect6 with a non-null sigmask pointer has the same late-signal // race as ppoll. See scheduleWakeBlockedRetriesDeferred. @@ -10007,21 +17584,25 @@ export class CentralizedKernelWorker { if (nfds === 0) { if (timeoutMs > 0) { const remainingMs = Math.max(deadline - Date.now(), 1); - const timer = setTimeout(() => { - if (this.pendingSelectRetries.get(channel)?.timer !== timer) return; - this.pendingSelectRetries.delete(channel); - if (this.isRegisteredChannel(channel)) { - channel.readinessFinalCheck = true; - this.handlePselect6(channel, origArgs); - } - }, remainingMs); - this.pendingSelectRetries.set(channel, { - timer, channel, origArgs, deadline, needsSignalSafeWake, syscallNr: SYS_PSELECT6, + entry.deferProtocolEffect(() => { + const timer = this.#registerTimeout(() => { + if (this.pendingSelectRetries.get(channel)?.timer !== timer) return; + this.pendingSelectRetries.delete(channel); + if (this.isRegisteredChannel(channel)) { + channel.readinessFinalCheck = true; + this.retrySyscall(channel); + } + }, remainingMs); + this.pendingSelectRetries.set(channel, { + ...this.#cancellationPointIdentity(channel), + timer, channel, origArgs, deadline, needsSignalSafeWake, syscallNr: SYS_PSELECT6, + }); }); } else { // Infinite timeout with nfds=0: wait for signal delivery. // No timer — wakeAllBlockedRetries will trigger the retry. this.pendingSelectRetries.set(channel, { + ...this.#cancellationPointIdentity(channel), timer: null as any, channel, origArgs, deadline: -1, needsSignalSafeWake, syscallNr: SYS_PSELECT6, }); @@ -10030,22 +17611,34 @@ export class CentralizedKernelWorker { } // For finite timeout with actual fds, track the deadline - const retryFn = () => { - const pending = this.pendingSelectRetries.get(channel); - if (!pending || pending.timer !== timer) return; - this.pendingSelectRetries.delete(channel); - if (!this.isRegisteredChannel(channel)) return; - this.handlePselect6(channel, origArgs); - }; const remainingMs = deadline > 0 ? Math.max(deadline - Date.now(), 1) : 50; - const timer = setTimeout(retryFn, Math.min(remainingMs, 50)); - this.pendingSelectRetries.set(channel, { - timer, channel, origArgs, deadline, needsSignalSafeWake, syscallNr: SYS_PSELECT6, + entry.deferProtocolEffect(() => { + const timer = this.#registerTimeout(() => { + const pending = this.pendingSelectRetries.get(channel); + if (!pending || pending.timer !== timer) return; + this.pendingSelectRetries.delete(channel); + if (!this.isRegisteredChannel(channel)) return; + this.retrySyscall(channel); + }, Math.min(remainingMs, 50)); + this.pendingSelectRetries.set(channel, { + ...this.#cancellationPointIdentity(channel), + timer, channel, origArgs, deadline, needsSignalSafeWake, syscallNr: SYS_PSELECT6, + }); }); return; } - this.completeChannel(channel, SYS_PSELECT6, origArgs, undefined, retVal, errVal); + this.completeChannel( + channel, + SYS_PSELECT6, + origArgs, + undefined, + retVal, + errVal, + [], + undefined, + entry, + ); } // ---- epoll host-side implementation ---- @@ -10059,28 +17652,39 @@ export class CentralizedKernelWorker { * Handle epoll_create1 / epoll_create: let the kernel create the fd, * then initialise an empty interest list on the host side. */ - private handleEpollCreate(channel: ChannelInfo, syscallNr: number, origArgs: number[]): void { + private handleEpollCreate( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + entry: KernelWorkerEntryContext, + ): void { const flags = origArgs[0]; // For SYS_EPOLL_CREATE, kernel expects flags=0 (size arg ignored) - const actualFlags = syscallNr === SYS_EPOLL_CREATE ? 0 : flags; + const actualFlags = syscallNr === SYS_EPOLL_CREATE ? 0 : flags; let result: { retVal: number; errVal: number }; try { - result = this.requireMainScratchRegion().withLease((lease) => { + result = this.#requireMainScratchRegion().withLease((lease) => { const kernelView = lease.dataView(0, CH_TOTAL_SIZE); kernelView.setUint32(CH_SYSCALL, syscallNr, true); kernelView.setBigInt64(CH_ARGS, BigInt(actualFlags), true); for (let i = 1; i < CH_ARGS_COUNT; i++) { kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); } - this.bindKernelTidForChannel(channel); + this.#bindKernelTidForChannel(channel, entry); this.currentHandlePid = channel.pid; try { - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - channel.pid, - ]); + this.#invokeEntryScratchExport( + entry, + lease, + "kernel_handle_channel", + [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + 0n, + ], + ); } finally { this.currentHandlePid = 0; } @@ -10091,11 +17695,12 @@ export class CentralizedKernelWorker { }; }); } catch (error) { - this.rejectScratchTransfer(channel, error); + this.#rethrowKernelEntryFatal(error); + this.#rejectScratchTransfer(channel, error, entry); return; } - if (this.finishSignalTermination(channel)) return; + if (this.#finishSignalTermination(channel, entry)) return; const { retVal, errVal } = result; @@ -10105,7 +17710,17 @@ export class CentralizedKernelWorker { this.epollInterests.set(key, []); } - this.completeChannel(channel, syscallNr, origArgs, undefined, retVal, errVal); + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + retVal, + errVal, + [], + undefined, + entry, + ); } /** @@ -10115,6 +17730,7 @@ export class CentralizedKernelWorker { private handleEpollCtl( channel: ChannelInfo, origArgs: number[], + entry: KernelWorkerEntryContext, rawArgs?: readonly bigint[], ): void { const epfd = origArgs[0]; @@ -10138,7 +17754,7 @@ export class CentralizedKernelWorker { "epoll_ctl event", ); } catch (error) { - this.rejectScratchTransfer(channel, error); + this.#rejectScratchTransfer(channel, error, entry); return; } eventPtr = eventRange.pointer; @@ -10153,7 +17769,7 @@ export class CentralizedKernelWorker { let result: { retVal: number; errVal: number }; try { - result = this.requireMainScratchRegion().withLease((lease) => { + result = this.#requireMainScratchRegion().withLease((lease) => { const kernelView = lease.dataView(0, CH_TOTAL_SIZE); if (hasEvent) { lease.copyFrom( @@ -10182,14 +17798,20 @@ export class CentralizedKernelWorker { kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, 0n, true); kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, 0n, true); - this.bindKernelTidForChannel(channel); + this.#bindKernelTidForChannel(channel, entry); this.currentHandlePid = channel.pid; try { - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - channel.pid, - ]); + this.#invokeEntryScratchExport( + entry, + lease, + "kernel_handle_channel", + [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + 0n, + ], + ); } finally { this.currentHandlePid = 0; } @@ -10200,11 +17822,12 @@ export class CentralizedKernelWorker { }; }); } catch (error) { - this.rejectScratchTransfer(channel, error); + this.#rethrowKernelEntryFatal(error); + this.#rejectScratchTransfer(channel, error, entry); return; } - if (this.finishSignalTermination(channel)) return; + if (this.#finishSignalTermination(channel, entry)) return; const { retVal, errVal } = result; @@ -10235,16 +17858,33 @@ export class CentralizedKernelWorker { } } - this.completeChannel(channel, SYS_EPOLL_CTL, origArgs, undefined, retVal, errVal); + this.completeChannel( + channel, + SYS_EPOLL_CTL, + origArgs, + undefined, + retVal, + errVal, + [], + undefined, + entry, + ); } /** Complete or reap an epoll wait when its kernel signal boundary fired. */ - private completeEpollSignalOutcome(channel: ChannelInfo): boolean { - const deliveredSignal = this.dequeueSignalForDelivery(channel); - if (this.finishSignalTermination(channel)) return true; + private completeEpollSignalOutcome( + channel: ChannelInfo, + entry: KernelWorkerEntryContext, + ): boolean { + const deliveredSignal = this.#dequeueSignalForDelivery(channel, entry); + if (this.#finishSignalTermination(channel, entry)) return true; if (deliveredSignal > 0) { - this.completeChannelRaw(channel, -EINTR_ERRNO, EINTR_ERRNO); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten( + channel, + -EINTR_ERRNO, + EINTR_ERRNO, + entry, + ); return true; } return false; @@ -10260,6 +17900,7 @@ export class CentralizedKernelWorker { channel: ChannelInfo, syscallNr: number, origArgs: number[], + entry: KernelWorkerEntryContext, rawArgs?: readonly bigint[], ): void { if (this.deferChannelWhileStopped(channel)) return; @@ -10272,16 +17913,35 @@ export class CentralizedKernelWorker { // origArgs[4] = sigmask ptr (process-space), origArgs[5] = sigset size if (maxevents <= 0) { - this.completeChannelRaw(channel, -22, 22); // -EINVAL - this.relistenChannel(channel); + this.completeChannelRawAndRelisten(channel, -22, 22, entry); // -EINVAL return; } if (!Number.isSafeInteger(maxevents)) { - this.completeChannelRaw(channel, -1, EINVAL); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten(channel, -1, EINVAL, entry); return; } try { + if (syscallNr === SYS_EPOLL_PWAIT) { + const rawMaskPointer = rawArgs?.[4] ?? BigInt(origArgs[4]); + if (rawMaskPointer !== 0n) { + const rawSigsetSize = BigInt.asUintN( + 64, + rawArgs?.[5] ?? BigInt(origArgs[5]), + ); + if (rawSigsetSize !== BigInt(SIGNAL_MASK_BYTES)) { + throw new KernelScratchError( + `epoll_pwait sigset size must be ${SIGNAL_MASK_BYTES}`, + EINVAL, + ); + } + this.checkedProcessRange( + channel, + rawMaskPointer, + SIGNAL_MASK_BYTES, + "epoll_pwait signal mask", + ); + } + } eventsPtr = this.checkedProcessRange( channel, rawEventsPtr, @@ -10289,51 +17949,58 @@ export class CentralizedKernelWorker { "epoll output events", ).pointer; } catch (error) { - this.rejectScratchTransfer(channel, error); + this.#rejectScratchTransfer(channel, error, entry); return; } const key = `${channel.pid}:${epfd}`; const interests = this.epollInterests.get(key); if (!interests) { - this.completeChannelRaw(channel, -9, 9); // -EBADF - this.relistenChannel(channel); + this.completeChannelRawAndRelisten(channel, -9, 9, entry); // -EBADF return; } if (interests.length === 0) { // No poll call follows for an empty interest set, so explicitly service // the signal boundary before parking or returning a timeout result. - if (this.completeEpollSignalOutcome(channel)) return; + if (this.completeEpollSignalOutcome(channel, entry)) return; // No interests registered — return 0 immediately for timeout=0, // or block (EAGAIN) for non-zero timeout. if (timeoutMs === 0) { - this.completeChannelRaw(channel, 0, 0); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten(channel, 0, 0, entry); return; } if (deadline > 0 && Date.now() >= deadline) { - this.completeChannelRaw(channel, 0, 0); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten(channel, 0, 0, entry); return; } + if ( + this.interruptPendingCancellationBeforeRegistration( + channel, + syscallNr, + this.#cancellationPointIdentity(channel), + entry, + ) + ) return; // For non-zero timeout with no interests, retry with delay to avoid starvation - const retryFn = () => { - const pending = this.pendingPollRetries.get(channel); - if (!pending || pending.timer !== timer) return; - this.pendingPollRetries.delete(channel); - if (this.isRegisteredChannel(channel)) { - this.handleEpollPwait(channel, syscallNr, origArgs); - } - }; const retryMs = deadline > 0 ? Math.min(Math.max(deadline - Date.now(), 1), 10) : 10; - const timer = setTimeout(retryFn, retryMs); - this.pendingPollRetries.set(channel, { - timer, - channel, - pipeIndices: [], - deadline, + entry.deferProtocolEffect(() => { + const timer = this.#registerTimeout(() => { + const pending = this.pendingPollRetries.get(channel); + if (!pending || pending.timer !== timer) return; + this.pendingPollRetries.delete(channel); + if (this.isRegisteredChannel(channel)) { + this.retrySyscall(channel); + } + }, retryMs); + this.pendingPollRetries.set(channel, { + ...this.#cancellationPointIdentity(channel), + timer, + channel, + pipeIndices: [], + deadline, + }); }); return; } @@ -10354,8 +18021,7 @@ export class CentralizedKernelWorker { if (pollfdSize > CH_DATA_SIZE) { // Too many fds — unlikely but handle gracefully - this.completeChannelRaw(channel, -22, 22); // -EINVAL - this.relistenChannel(channel); + this.completeChannelRawAndRelisten(channel, -22, 22, entry); // -EINVAL return; } @@ -10365,7 +18031,7 @@ export class CentralizedKernelWorker { pollfds: Uint8Array; }; try { - pollResult = this.requireMainScratchRegion().withLease((lease) => { + pollResult = this.#requireMainScratchRegion().withLease((lease) => { const kernelView = lease.dataView(0, CH_TOTAL_SIZE); const pollfdsView = lease.dataView(CH_DATA, pollfdSize); for (let i = 0; i < nfds; i++) { @@ -10404,14 +18070,20 @@ export class CentralizedKernelWorker { kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); } - this.bindKernelTidForChannel(channel); + this.#bindKernelTidForChannel(channel, entry); this.currentHandlePid = channel.pid; try { - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - channel.pid, - ]); + this.#invokeEntryScratchExport( + entry, + lease, + "kernel_handle_channel", + [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + 0n, + ], + ); } finally { this.currentHandlePid = 0; } @@ -10423,7 +18095,8 @@ export class CentralizedKernelWorker { }; }); } catch (error) { - this.rejectScratchTransfer(channel, error); + this.#rethrowKernelEntryFatal(error); + this.#rejectScratchTransfer(channel, error, entry); return; } @@ -10436,12 +18109,11 @@ export class CentralizedKernelWorker { // reap the worker without waking guest code. A caught handler interrupts // epoll with EINTR so the glue can run the copied handler metadata before // the application decides whether to restart the wait. - if (this.completeEpollSignalOutcome(channel)) return; + if (this.completeEpollSignalOutcome(channel, entry)) return; // If poll returned error (not EAGAIN), propagate it if (retVal < 0 && errVal !== EAGAIN) { - this.completeChannelRaw(channel, retVal, errVal); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten(channel, retVal, errVal, entry); return; } @@ -10492,46 +18164,55 @@ export class CentralizedKernelWorker { // If we got events, return them if (readyCount > 0) { - this.completeChannelRaw(channel, readyCount, 0); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten(channel, readyCount, 0, entry); return; } // No events ready — handle timeout if (timeoutMs === 0) { // Non-blocking: return 0 events - this.completeChannelRaw(channel, 0, 0); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten(channel, 0, 0, entry); return; } if (deadline > 0 && Date.now() >= deadline) { // The nonblocking kernel poll above was the final readiness check. - this.completeChannelRaw(channel, 0, 0); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten(channel, 0, 0, entry); return; } // Blocking: retry via setTimeout to avoid starving other processes. // Pipe-based wakeup (via wakeAllBlockedRetries) provides instant wakeup // when data arrives; setTimeout is only a fallback. - const { pipeIndices, acceptIndices } = this.resolveEpollReadinessIndices(channel.pid); - - const retryFn = () => { - const pending = this.pendingPollRetries.get(channel); - if (!pending || pending.timer !== timer) return; - this.pendingPollRetries.delete(channel); - if (this.isRegisteredChannel(channel)) { - this.handleEpollPwait(channel, syscallNr, origArgs); - } - }; + const { pipeIndices, acceptIndices } = this.resolveEpollReadinessIndices( + channel.pid, + entry, + ); + if ( + this.interruptPendingCancellationBeforeRegistration( + channel, + syscallNr, + this.#cancellationPointIdentity(channel), + entry, + ) + ) return; const retryMs = deadline > 0 ? Math.min(Math.max(deadline - Date.now(), 1), 10) : 10; - const timer = setTimeout(retryFn, retryMs); - this.pendingPollRetries.set(channel, { - timer, - channel, - pipeIndices, - acceptIndices, - deadline, + entry.deferProtocolEffect(() => { + const timer = this.#registerTimeout(() => { + const pending = this.pendingPollRetries.get(channel); + if (!pending || pending.timer !== timer) return; + this.pendingPollRetries.delete(channel); + if (this.isRegisteredChannel(channel)) { + this.retrySyscall(channel); + } + }, retryMs); + this.pendingPollRetries.set(channel, { + ...this.#cancellationPointIdentity(channel), + timer, + channel, + pipeIndices, + acceptIndices, + deadline, + }); }); } @@ -10539,11 +18220,11 @@ export class CentralizedKernelWorker { private finishNetworkIoctl( channel: ChannelInfo, + entry: KernelWorkerEntryContext, retVal = 0, errno = 0, ): void { - this.completeChannelRaw(channel, retVal, errno); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten(channel, retVal, errno, entry); } private checkedNetworkIoctlProcessRange( @@ -10551,12 +18232,13 @@ export class CentralizedKernelWorker { pointer: number | bigint, length: number | bigint, field: string, + entry: KernelWorkerEntryContext, ): { pointer: number; length: number; end: number } | null { try { return this.checkedProcessRange(channel, pointer, length, field); } catch (error) { if (!(error instanceof KernelScratchError)) throw error; - this.finishNetworkIoctl(channel, -EFAULT, EFAULT); + this.finishNetworkIoctl(channel, entry, -EFAULT, EFAULT); return null; } } @@ -10601,7 +18283,11 @@ export class CentralizedKernelWorker { * The ifc_buf pointer is in process memory, so the kernel can't write to it * directly — we handle the entire ioctl on the host side. */ - private handleIoctlIfconf(channel: ChannelInfo, origArgs: number[]): void { + private handleIoctlIfconf( + channel: ChannelInfo, + origArgs: number[], + entry: KernelWorkerEntryContext, + ): void { const pw = this.getPtrWidth(channel.pid); const ifconfSize = pw === 8 ? 16 : 8; const ifconfRange = this.checkedNetworkIoctlProcessRange( @@ -10609,6 +18295,7 @@ export class CentralizedKernelWorker { origArgs[2], ifconfSize, "network ioctl ifconf", + entry, ); if (!ifconfRange) return; const ifconfPtr = ifconfRange.pointer; @@ -10618,7 +18305,7 @@ export class CentralizedKernelWorker { const ifreqSize = this.ifreqSize(channel); const ifcLen = processView.getInt32(ifconfPtr, true); if (ifcLen < 0) { - this.finishNetworkIoctl(channel, -EINVAL, EINVAL); + this.finishNetworkIoctl(channel, entry, -EINVAL, EINVAL); return; } const ifcBufValue = pw === 8 @@ -10633,13 +18320,13 @@ export class CentralizedKernelWorker { VIRTUAL_INTERFACES.length * ifreqSize, true, ); - this.finishNetworkIoctl(channel); + this.finishNetworkIoctl(channel, entry); return; } if (ifcLen < ifreqSize) { processView.setInt32(ifconfPtr, 0, true); - this.finishNetworkIoctl(channel); + this.finishNetworkIoctl(channel, entry); return; } @@ -10654,6 +18341,7 @@ export class CentralizedKernelWorker { ifcBufValue, bytesToWrite, "network ioctl ifconf output", + entry, ); if (!ifcBufRange) return; const ifcBuf = ifcBufRange.pointer; @@ -10668,19 +18356,24 @@ export class CentralizedKernelWorker { if (address) processMem.set(address, entryPtr + IF_NAMESIZE + 4); } processView.setInt32(ifconfPtr, bytesToWrite, true); - this.finishNetworkIoctl(channel); + this.finishNetworkIoctl(channel, entry); } /** * Handle SIOCGIFNAME: map an interface index to its name. * struct ifreq at arg[2]: ifr_name[16] + union; ifr_ifindex lives at +16. */ - private handleIoctlIfname(channel: ChannelInfo, origArgs: number[]): void { + private handleIoctlIfname( + channel: ChannelInfo, + origArgs: number[], + entry: KernelWorkerEntryContext, + ): void { const ifreqRange = this.checkedNetworkIoctlProcessRange( channel, origArgs[2], this.ifreqSize(channel), "network ioctl ifreq", + entry, ); if (!ifreqRange) return; const ifreqPtr = ifreqRange.pointer; @@ -10690,12 +18383,12 @@ export class CentralizedKernelWorker { const iface = VIRTUAL_INTERFACES.find((candidate) => candidate.index === ifindex); if (!iface) { - this.finishNetworkIoctl(channel, -ENODEV, ENODEV); + this.finishNetworkIoctl(channel, entry, -ENODEV, ENODEV); return; } this.writeIfreqName(processMem, ifreqPtr, iface.name); - this.finishNetworkIoctl(channel); + this.finishNetworkIoctl(channel, entry); } /** @@ -10703,19 +18396,24 @@ export class CentralizedKernelWorker { * struct ifreq at arg[2]: ifr_name[16] + ifr_hwaddr (struct sockaddr, 16 bytes) * Returns the virtual MAC in ifr_hwaddr.sa_data[0..5]. */ - private handleIoctlIfhwaddr(channel: ChannelInfo, origArgs: number[]): void { + private handleIoctlIfhwaddr( + channel: ChannelInfo, + origArgs: number[], + entry: KernelWorkerEntryContext, + ): void { const ifreqRange = this.checkedNetworkIoctlProcessRange( channel, origArgs[2], this.ifreqSize(channel), "network ioctl ifreq", + entry, ); if (!ifreqRange) return; const ifreqPtr = ifreqRange.pointer; const name = this.readIfreqName(channel, ifreqPtr); const iface = VIRTUAL_INTERFACES.find((candidate) => candidate.name === name); if (!iface) { - this.finishNetworkIoctl(channel, -ENODEV, ENODEV); + this.finishNetworkIoctl(channel, entry, -ENODEV, ENODEV); return; } const processView = new DataView(channel.memory.buffer); @@ -10735,7 +18433,7 @@ export class CentralizedKernelWorker { processMem.set(this.virtualMacAddress, ifreqPtr + IF_NAMESIZE + 2); } - this.finishNetworkIoctl(channel); + this.finishNetworkIoctl(channel, entry); } /** @@ -10743,24 +18441,34 @@ export class CentralizedKernelWorker { * struct ifreq at arg[2]: ifr_name[16] + ifr_addr (struct sockaddr, 16 bytes) * Returns the selected virtual interface's assigned IPv4 address. */ - private handleIoctlIfaddr(channel: ChannelInfo, origArgs: number[]): void { + private handleIoctlIfaddr( + channel: ChannelInfo, + origArgs: number[], + entry: KernelWorkerEntryContext, + ): void { const ifreqRange = this.checkedNetworkIoctlProcessRange( channel, origArgs[2], this.ifreqSize(channel), "network ioctl ifreq", + entry, ); if (!ifreqRange) return; const ifreqPtr = ifreqRange.pointer; const name = this.readIfreqName(channel, ifreqPtr); const iface = VIRTUAL_INTERFACES.find((candidate) => candidate.name === name); if (!iface) { - this.finishNetworkIoctl(channel, -ENODEV, ENODEV); + this.finishNetworkIoctl(channel, entry, -ENODEV, ENODEV); return; } const address = this.interfaceAddress(iface); if (!address) { - this.finishNetworkIoctl(channel, -EADDRNOTAVAIL, EADDRNOTAVAIL); + this.finishNetworkIoctl( + channel, + entry, + -EADDRNOTAVAIL, + EADDRNOTAVAIL, + ); return; } const processView = new DataView(channel.memory.buffer); @@ -10774,19 +18482,24 @@ export class CentralizedKernelWorker { processView.setUint16(ifreqPtr + IF_NAMESIZE, AF_INET, true); processMem.set(address, ifreqPtr + IF_NAMESIZE + 4); - this.finishNetworkIoctl(channel); + this.finishNetworkIoctl(channel, entry); } /** * Handle SIOCGIFINDEX: map an interface name to its index. * struct ifreq at arg[2]: ifr_name[16] + union; ifr_ifindex lives at +16. */ - private handleIoctlIfindex(channel: ChannelInfo, origArgs: number[]): void { + private handleIoctlIfindex( + channel: ChannelInfo, + origArgs: number[], + entry: KernelWorkerEntryContext, + ): void { const ifreqRange = this.checkedNetworkIoctlProcessRange( channel, origArgs[2], this.ifreqSize(channel), "network ioctl ifreq", + entry, ); if (!ifreqRange) return; const ifreqPtr = ifreqRange.pointer; @@ -10794,7 +18507,7 @@ export class CentralizedKernelWorker { const iface = VIRTUAL_INTERFACES.find((candidate) => candidate.name === name); if (!iface) { - this.finishNetworkIoctl(channel, -ENODEV, ENODEV); + this.finishNetworkIoctl(channel, entry, -ENODEV, ENODEV); return; } @@ -10803,68 +18516,7 @@ export class CentralizedKernelWorker { iface.index, true, ); - this.finishNetworkIoctl(channel); - } - - /** - * Ask the kernel for one logical write's complete byte budget before the - * host splits it across scratch-buffer calls. A negative result has already - * generated any required SIGXFSZ in the calling thread; this method finishes - * that syscall boundary and returns null. - */ - private prepareWriteOperationBudget( - channel: ChannelInfo, - fd: number, - offset: bigint, - requestedLen: number, - positioned: boolean, - ): number | null { - const prepare = this.kernelInstance!.exports.kernel_prepare_write_operation as - | ((pid: number, tid: number, fd: number, offset: bigint, len: number, positioned: number) => bigint) - | undefined; - if (!prepare) { - throw new Error( - "kernel ABI is missing kernel_prepare_write_operation for chunked writes", - ); - } - - let result: number; - const tid = this.guestTidForChannel(channel); - this.currentHandlePid = channel.pid; - try { - result = Number( - prepare(channel.pid, tid, fd, offset, requestedLen, positioned ? 1 : 0), - ); - } catch (err) { - console.error( - `[prepareWriteOperationBudget] kernel threw for pid=${channel.pid}:`, - err, - ); - this.completeChannelRaw(channel, -1, EIO); - this.relistenChannel(channel); - return null; - } finally { - this.currentHandlePid = 0; - } - - if (this.finishSignalTermination(channel)) return null; - - if (!Number.isSafeInteger(result) || result > requestedLen) { - console.error( - `[prepareWriteOperationBudget] invalid kernel budget ${result} for request ${requestedLen}`, - ); - this.completeChannelRaw(channel, -1, EIO); - this.relistenChannel(channel); - return null; - } - if (result < 0) { - this.dequeueSignalForDelivery(channel); - if (this.finishSignalTermination(channel)) return null; - this.completeChannelRaw(channel, -1, -result); - this.relistenChannel(channel); - return null; - } - return result; + this.finishNetworkIoctl(channel, entry); } /** @@ -10879,11 +18531,11 @@ export class CentralizedKernelWorker { channel: ChannelInfo, origArgs: number[], rawArgs: readonly bigint[], + entry: KernelWorkerEntryContext, ): void { const rawSize = rawArgs[0] ?? 0n; if (rawSize < 0n || rawSize > 0x7fff_ffffn) { - this.completeChannelRaw(channel, -1, EINVAL); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten(channel, -1, EINVAL, entry); return; } const size = Number(rawSize); @@ -10897,7 +18549,7 @@ export class CentralizedKernelWorker { "getgroups output", ).pointer; } catch (error) { - this.rejectScratchTransfer(channel, error); + this.#rejectScratchTransfer(channel, error, entry); return; } } @@ -10908,7 +18560,7 @@ export class CentralizedKernelWorker { output: Uint8Array | null; }; try { - result = this.requireMainScratchRegion().withLease((lease) => { + result = this.#requireMainScratchRegion().withLease((lease) => { const kernelView = lease.dataView(0, CH_TOTAL_SIZE); for (let index = 0; index < CH_ARGS_COUNT; index++) { kernelView.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, 0n, true); @@ -10930,14 +18582,20 @@ export class CentralizedKernelWorker { ); } - this.bindKernelTidForChannel(channel); + this.#bindKernelTidForChannel(channel, entry); this.currentHandlePid = channel.pid; try { - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - channel.pid, - ]); + this.#invokeEntryScratchExport( + entry, + lease, + "kernel_handle_channel", + [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + 0n, + ], + ); } finally { this.currentHandlePid = 0; } @@ -10957,17 +18615,17 @@ export class CentralizedKernelWorker { return { retVal, errVal, output }; }); } catch (error) { + this.#rethrowKernelEntryFatal(error); if (error instanceof KernelScratchError) { - this.rejectScratchTransfer(channel, error); + this.#rejectScratchTransfer(channel, error, entry); } else { - this.completeChannelRaw(channel, -1, EIO); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten(channel, -1, EIO, entry); } return; } - this.dequeueSignalForDelivery(channel); - if (this.finishSignalTermination(channel)) return; + this.#dequeueSignalForDelivery(channel, entry); + if (this.#finishSignalTermination(channel, entry)) return; this.completeChannel( channel, SYS_GETGROUPS, @@ -10978,1140 +18636,1402 @@ export class CentralizedKernelWorker { result.output ? [{ ptr: processPointer, bytes: result.output }] : undefined, + undefined, + entry, ); } - /** - * Handle writev/pwritev: validate caller iovecs, stage each bounded chunk - * into owned kernel scratch, and dispatch only the staged kernel pointers. - */ - private handleWritev(channel: ChannelInfo, syscallNr: number, origArgs: number[]): void { - const fd = origArgs[0]; - const iovPtr = origArgs[1]; - const iovcnt = origArgs[2]; + /** Map scalar and vector variants to one contiguous kernel operation. */ + #scalarTransferSyscall(syscallNr: number): number { + switch (syscallNr) { + case SYS_WRITE: + case SYS_WRITEV: + return SYS_WRITE; + case SYS_PWRITE: + case SYS_PWRITEV: + case SYS_PWRITEV2: + return SYS_PWRITE; + case SYS_READ: + case SYS_READV: + return SYS_READ; + case SYS_PREAD: + case SYS_PREADV: + case SYS_PREADV2: + return SYS_PREAD; + default: + throw new KernelScratchError( + "unsupported scratch transfer syscall " + String(syscallNr), + EINVAL, + ); + } + } + + private checkedVectorCount(rawCount: bigint): number { + if (rawCount < 0n || rawCount > BigInt(POSIX_IOV_MAX)) { + throw new KernelScratchError( + "iovec count must be between 0 and " + String(POSIX_IOV_MAX), + EINVAL, + ); + } + return Number(rawCount); + } + + #copyFlattenedTransferInput( + lease: import("./kernel-scratch").KernelScratchLease, + processMem: Uint8Array, + entries: readonly CheckedProcessIovec[], + destinationOffset: number, + ): void { + let flattenedOffset = destinationOffset; + for (const entry of entries) { + if (entry.len > 0) { + lease.copyFrom( + processMem, + flattenedOffset, + entry.base, + entry.len, + ); + } + flattenedOffset += entry.len; + } + } + + #snapshotFlattenedTransferInput( + channel: ChannelInfo, + entries: readonly CheckedProcessIovec[], + totalData: number, + ): Uint8Array { const processMem = new Uint8Array(channel.memory.buffer); - let checkedIovecs: CheckedProcessIovecs; - try { - checkedIovecs = this.checkedProcessIovecs( - channel, - iovPtr, - iovcnt, - false, + const snapshot = new Uint8Array(totalData); + let offset = 0; + for (const entry of entries) { + if (entry.len > 0) { + snapshot.set( + processMem.subarray(entry.base, entry.base + entry.len), + offset, + ); + } + offset += entry.len; + } + if (offset !== totalData) { + throw new KernelScratchError( + "flattened transfer snapshot length mismatch", + EIO, ); - } catch (error) { - this.rejectScratchTransfer(channel, error); - return; } - const { entries, totalData } = checkedIovecs; - const scratch = this.requireMainScratchRegion(); - const footprint = this.kernelIovecFootprint(entries); - const isPwritev = - syscallNr === SYS_PWRITEV || syscallNr === SYS_PWRITEV2; - const isPwritev2 = syscallNr === SYS_PWRITEV2; - if (footprint <= CH_DATA_SIZE) { - // Fast path: all data fits in one kernel call - const result = scratch.withLease((lease) => { - lease.assertRange(CH_DATA, footprint); - const tableBytes = - entries.length * STRUCT_SIZE_KERNEL_IOVEC_WIRE; - const kernelIovecs = lease.dataView( - CH_DATA, - tableBytes, + return snapshot; + } + + #copyFlattenedTransferOutput( + lease: import("./kernel-scratch").KernelScratchLease, + processMem: Uint8Array, + entries: readonly CheckedProcessIovec[], + sourceOffset: number, + byteLength: number, + ): void { + let flattenedOffset = sourceOffset; + let remaining = byteLength; + for (const entry of entries) { + if (remaining === 0) break; + const copyLength = Math.min(entry.len, remaining); + if (copyLength > 0) { + lease.copyTo( + processMem, + flattenedOffset, + entry.base, + copyLength, ); - let dataOffset = tableBytes; - for (let index = 0; index < entries.length; index++) { - const entry = entries[index]; - if (entry.len > 0) { - lease.copyFrom( - processMem, - CH_DATA + dataOffset, - entry.base, - entry.len, - ); - } - lease.writeAddress( - CH_DATA - + index * STRUCT_SIZE_KERNEL_IOVEC_WIRE - + KERNEL_IOVEC_WIRE_BASE_OFFSET, - CH_DATA + dataOffset, - entry.len, - "u32-le", - ); - kernelIovecs.setUint32( - index * STRUCT_SIZE_KERNEL_IOVEC_WIRE - + KERNEL_IOVEC_WIRE_LEN_OFFSET, - entry.len, - true, - ); - dataOffset = this.checkedAlignUp( - dataOffset + entry.len, - KERNEL_IOVEC_WIRE_ALIGN, - "kernel writev layout", - ); - } + } + flattenedOffset += entry.len; + remaining -= copyLength; + } + if (remaining !== 0) { + throw new KernelScratchError( + "kernel transfer result exceeds caller iovecs", + EIO, + ); + } + } - const kernelView = lease.dataView(0, CH_DATA); - kernelView.setUint32(CH_SYSCALL, syscallNr, true); - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - lease.writeAddress( - CH_ARGS + CH_ARG_SIZE, + #checkedChannelTransferResult( + rawRetVal: bigint, + errVal: number, + capacity: number, + ): { retVal: number; errVal: number } { + if ( + !Number.isSafeInteger(errVal) + || errVal < 0 + || errVal > MAX_KERNEL_TASK_ID + ) { + return { retVal: -1, errVal: EIO }; + } + if (rawRetVal >= 0n) { + if (errVal !== 0 || rawRetVal > BigInt(capacity)) { + return { retVal: -1, errVal: EIO }; + } + return { retVal: Number(rawRetVal), errVal: 0 }; + } + if (rawRetVal === -1n && errVal > 0) { + return { retVal: -1, errVal }; + } + return { retVal: -1, errVal: EIO }; + } + + #checkedReservedTransferResult( + rawResult: number, + capacity: number, + ): { retVal: number; errVal: number } { + if (!Number.isSafeInteger(rawResult)) { + return { retVal: -1, errVal: EIO }; + } + if (rawResult >= 0) { + if (rawResult > capacity) { + return { retVal: -1, errVal: EIO }; + } + return { retVal: rawResult, errVal: 0 }; + } + const rawErrno = -rawResult; + if (rawErrno > 0 && rawErrno <= MAX_KERNEL_TASK_ID) { + return { retVal: -1, errVal: rawErrno }; + } + return { retVal: -1, errVal: EIO }; + } + + /** + * Flatten one logical vector into the ordinary channel allocation. + * + * WHY: the iovec table is a caller/kernel parsing detail, not part of the + * host transport. One contiguous scalar dispatch preserves datagram and + * pipe operation boundaries while making the exact CH_DATA_SIZE capacity + * proof independent of table size or alignment padding. + */ + #executeMainScratchTransfer( + channel: ChannelInfo, + syscallNr: number, + request: FlattenedTransferRequest, + entry: KernelWorkerEntryContext, + retryToken = 0n, + ): { retVal: number; errVal: number } { + const processMem = new Uint8Array(channel.memory.buffer); + const scalarSyscall = this.#scalarTransferSyscall(syscallNr); + this.#bindKernelTidForChannel(channel, entry); + return this.#requireMainScratchRegion().withLease((lease) => { + if (request.read) { + lease.fill(0, CH_DATA, request.totalData); + } else if (request.inputBytes) { + lease.copyFrom( + request.inputBytes, CH_DATA, - tableBytes, - "u64-le", + 0, + request.totalData, ); - kernelView.setBigInt64( - CH_ARGS + 2 * CH_ARG_SIZE, - BigInt(iovcnt), - true, + } else { + this.#copyFlattenedTransferInput( + lease, + processMem, + request.entries, + CH_DATA, ); - if (isPwritev) { - kernelView.setBigInt64( - CH_ARGS + 3 * CH_ARG_SIZE, - BigInt(origArgs[3]), - true, - ); - kernelView.setBigInt64( - CH_ARGS + 4 * CH_ARG_SIZE, - BigInt(origArgs[4]), - true, - ); - } + } + + const kernelView = lease.dataView(0, CH_DATA); + kernelView.setUint32(CH_SYSCALL, scalarSyscall, true); + for (let index = 0; index < CH_ARGS_COUNT; index++) { + kernelView.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, 0n, true); + } + kernelView.setBigInt64(CH_ARGS, BigInt(request.fd), true); + lease.writeAddress( + CH_ARGS + CH_ARG_SIZE, + CH_DATA, + request.totalData, + "u64-le", + ); + kernelView.setBigInt64( + CH_ARGS + 2 * CH_ARG_SIZE, + BigInt(request.totalData), + true, + ); + if (request.offset !== null) { kernelView.setBigInt64( - CH_ARGS + 5 * CH_ARG_SIZE, - isPwritev2 ? BigInt(origArgs[5]) : 0n, + CH_ARGS + 3 * CH_ARG_SIZE, + request.offset, true, ); - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; - try { - lease.invokeKernelExport("kernel_handle_channel", [ + } + + const previousHandlePid = this.currentHandlePid; + this.currentHandlePid = channel.pid; + try { + this.#invokeEntryScratchExport( + entry, + lease, + "kernel_handle_channel", + [ lease.exportPointer(0, CH_TOTAL_SIZE), CH_TOTAL_SIZE, channel.pid, - ]); - } finally { - this.currentHandlePid = 0; - } - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); - if (!Number.isSafeInteger(retVal) || retVal > totalData) { - return { retVal: -1, errVal: EIO }; - } - return { retVal, errVal }; - }); + retryToken, + ], + ); + } finally { + this.currentHandlePid = previousHandlePid; + } - this.dequeueSignalForDelivery(channel); - if (this.finishSignalTermination(channel)) return; + const result = this.#checkedChannelTransferResult( + kernelView.getBigInt64(CH_RETURN, true), + kernelView.getUint32(CH_ERRNO, true), + request.totalData, + ); + if (request.read && result.retVal > 0) { + this.#copyFlattenedTransferOutput( + lease, + processMem, + request.entries, + CH_DATA, + result.retVal, + ); + } + return result; + }); + } - const { retVal, errVal } = result; + #beginLargeTransferScratch( + minimumCapacity: number, + entry: KernelWorkerEntryContext, + ): { reservation: ReservedTransferScratch | null; errno: number } { + const kernelExports = this.#kernelInstanceForEntry(entry).exports; + const begin = kernelExports.kernel_transfer_scratch_begin as + ((capacity: KernelPointer) => bigint) | undefined; + const pointer = kernelExports.kernel_transfer_scratch_pointer as + ((token: bigint) => KernelPointer) | undefined; + const capacity = kernelExports.kernel_transfer_scratch_capacity as + ((token: bigint) => KernelPointer) | undefined; + const cancel = kernelExports.kernel_transfer_scratch_cancel as + ((token: bigint) => number) | undefined; + if ( + typeof begin !== "function" + || typeof pointer !== "function" + || typeof capacity !== "function" + || typeof cancel !== "function" + ) { + return { reservation: null, errno: EIO }; + } - if (retVal === -1 && errVal === EAGAIN) { - this.handleBlockingRetry(channel, syscallNr, origArgs); - return; + let token: bigint | null = null; + let beginErrno = EIO; + try { + const rawToken = begin(this.toKernelPtr(minimumCapacity)); + if (typeof rawToken !== "bigint") { + throw new KernelScratchError( + "kernel returned a non-i64 transfer scratch token", + EIO, + ); } - - this.handleSharedMappingsAfterFileSyscall( - channel, syscallNr, origArgs, retVal, errVal, + if (rawToken <= 0n) { + const rawErrno = -rawToken; + beginErrno = rawErrno > 0n + && rawErrno <= BigInt(MAX_KERNEL_TASK_ID) + ? Number(rawErrno) + : EIO; + return { reservation: null, errno: beginErrno }; + } + token = rawToken; + const region = reserveKernelScratchRegion( + this.#kernelMemory!, + () => ({ + pointer: pointer(rawToken), + capacity: capacity(rawToken), + }), + minimumCapacity, + this.#kernelPointerWidth, + "kernel reserved I/O transfer scratch", + // The region factory binds allocator ownership to the persistent + // gated façade. `entry` proves this reservation belongs to that exact + // generation; scoped façades are deliberately non-transferable. + this.#kernelInstance!, ); - this.completeChannel(channel, syscallNr, origArgs, undefined, retVal, errVal); - } else { - // Slow path: total data exceeds scratch buffer. Issue individual SYS_WRITEV - // calls with one iov entry each, chunked to fit in CH_DATA_SIZE. - let fileOffset = isPwritev - ? joinPositionedVectorOffset(origArgs[3], origArgs[4]) - : 0n; - const operationLen = this.prepareWriteOperationBudget( - channel, - fd, - fileOffset, - totalData, - isPwritev, - ); - if (operationLen === null) return; - let totalWritten = 0; - let gotEagain = false; - let firstError: { retVal: number; errVal: number } | null = null; - const maxChunk = CH_DATA_SIZE - STRUCT_SIZE_KERNEL_IOVEC_WIRE; - - for (const entry of entries) { - if (totalWritten >= operationLen) break; - if (entry.len === 0) continue; - let entryWritten = 0; - - while (entryWritten < entry.len && totalWritten < operationLen) { - const chunkLen = Math.min( - entry.len - entryWritten, - maxChunk, - operationLen - totalWritten, - ); - const result = scratch.withLease((lease) => { - lease.copyFrom( - processMem, - CH_DATA + STRUCT_SIZE_KERNEL_IOVEC_WIRE, - entry.base + entryWritten, - chunkLen, - ); - const kernelIovec = lease.dataView( - CH_DATA, - STRUCT_SIZE_KERNEL_IOVEC_WIRE, - ); - lease.writeAddress( - CH_DATA + KERNEL_IOVEC_WIRE_BASE_OFFSET, - CH_DATA + STRUCT_SIZE_KERNEL_IOVEC_WIRE, - chunkLen, - "u32-le", - ); - kernelIovec.setUint32( - KERNEL_IOVEC_WIRE_LEN_OFFSET, - chunkLen, - true, - ); - const kernelView = lease.dataView(0, CH_DATA); - kernelView.setUint32( - CH_SYSCALL, - isPwritev ? syscallNr : SYS_WRITEV, - true, - ); - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - lease.writeAddress( - CH_ARGS + CH_ARG_SIZE, - CH_DATA, - STRUCT_SIZE_KERNEL_IOVEC_WIRE, - "u64-le", - ); - kernelView.setBigInt64( - CH_ARGS + 2 * CH_ARG_SIZE, - 1n, - true, - ); - if (isPwritev) { - kernelView.setBigInt64( - CH_ARGS + 3 * CH_ARG_SIZE, - BigInt.asUintN(32, fileOffset), - true, - ); - kernelView.setBigInt64( - CH_ARGS + 4 * CH_ARG_SIZE, - BigInt.asIntN(32, fileOffset >> 32n), - true, - ); - } - kernelView.setBigInt64( - CH_ARGS + 5 * CH_ARG_SIZE, - isPwritev2 ? BigInt(origArgs[5]) : 0n, - true, - ); - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; - try { - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - channel.pid, - ]); - } finally { - this.currentHandlePid = 0; - } - return { - retVal: Number(kernelView.getBigInt64(CH_RETURN, true)), - errVal: kernelView.getUint32(CH_ERRNO, true), - }; - }); + return { reservation: { region, token }, errno: 0 }; + } catch (error) { + this.#rethrowKernelEntryFatal(error); + if (this.#kernelFatalError !== null) { + // A scoped begin/pointer/capacity export exception has already + // poisoned the gate. Its Rust reservation state is uncertain, so it + // must unwind to generation shutdown without guest completion or a + // speculative cancel. + throw new KernelTransferExecuteTrapError( + "kernel transfer reservation query trapped", + error, + ); + } + // WHY: begin returning a token transfers cleanup authority even when its + // pointer or capacity is malformed. Keep the token so the caller can + // cancel the reservation without ever trusting the invalid range. + return { + reservation: token === null ? null : { region: null, token }, + errno: beginErrno, + }; + } + } - if (this.finishSignalTermination(channel)) return; + #cancelLargeTransferScratch( + token: bigint, + entry: KernelWorkerEntryContext, + ): void { + const cancel = this.#kernelInstanceForEntry(entry).exports + .kernel_transfer_scratch_cancel as + ((token: bigint) => number) | undefined; + if (typeof cancel !== "function") { + throw new KernelScratchError( + "kernel transfer scratch cancel export is unavailable", + EIO, + ); + } + const result = cancel(token); + if (result !== 0) { + throw new KernelScratchError( + "kernel rejected transfer scratch cancellation: " + String(result), + EIO, + ); + } + } - const { retVal, errVal } = result; + /** + * Execute one widened channel whose complete aligned footprint exceeds the + * ordinary fixed mailbox. + * + * The token export receives only `{pid, tid, token}`. Rust derives the Vec's + * base and initialized length after the Reserved → Executing transition; no + * host pointer or capacity can replace that allocator-owned authority. + */ + #executeReservedChannelDispatch( + channel: ChannelInfo, + totalCapacity: number, + entry: KernelWorkerEntryContext, + stage: (lease: KernelScratchLease) => void, + finish: (lease: KernelScratchLease) => T, + retryToken = 0n, + ): { value: T | null; errno: number } { + if (this.#largeTransferScratchInUse) { + throw new KernelReentrantEntryError( + "kernel_transfer_channel_execute reservation", + ); + } + this.#largeTransferScratchInUse = true; - if (retVal === -1) { - if (errVal === EAGAIN && totalWritten === 0) { - gotEagain = true; - } else if (totalWritten === 0) { - firstError = { retVal, errVal }; + let reservation: ReservedTransferScratch | null = null; + let value: T | null = null; + let errno = EIO; + let executeStarted = false; + let executeReturned = false; + let pendingError: unknown; + let fatalError: KernelTransferExecuteTrapError | null = null; + try { + const begun = this.#beginLargeTransferScratch(totalCapacity, entry); + reservation = begun.reservation; + errno = begun.errno; + if (reservation?.region) { + const activeToken = reservation.token; + value = reservation.region.withLease((lease) => { + // WHY: this is an intentionally rigid stage → execute → finish + // transaction. Callers never receive an execute closure or entry + // authority, so they cannot omit, duplicate, defer, or reorder the + // one Rust transition that owns this token. + stage(lease); + executeStarted = true; + const tid = this.guestTidForChannel(channel); + const rawResult = this.#invokeEntryScratchExport( + entry, + lease, + "kernel_transfer_channel_execute", + [channel.pid, tid, activeToken, retryToken], + ); + executeReturned = true; + if (rawResult !== 0) { + const transportErrno = + Number.isInteger(rawResult) && rawResult < 0 + ? -rawResult + : EIO; + if (transportErrno === ESRCH) { + throw new KernelTaskBindingError( + channel.pid, + tid, + transportErrno, + "kernel rejected reserved-channel task binding", + ); } - break; - } - if (!Number.isSafeInteger(retVal) || retVal > chunkLen) { - firstError = { retVal: -1, errVal: EIO }; - break; + throw new KernelScratchError( + `kernel rejected reserved-channel transport: ${rawResult}`, + transportErrno, + ); } - - entryWritten += retVal; - totalWritten += retVal; - if (isPwritev) fileOffset += BigInt(retVal); - - if (retVal < chunkLen) break; // short write (e.g. pipe full) - } - - if (gotEagain || entryWritten < entry.len) break; + return finish(lease); + }); + errno = 0; } - - if (gotEagain) { - this.dequeueSignalForDelivery(channel); - if (this.finishSignalTermination(channel)) return; - this.handleBlockingRetry(channel, syscallNr, origArgs); - return; + } catch (error) { + if (isKernelExportFailure(error)) { + fatalError = new KernelTransferExecuteTrapError( + executeStarted && !executeReturned + ? "kernel channel transfer trapped with a global reservation active" + : "kernel channel reservation export trapped", + error, + ); + } else if (error instanceof KernelTransferExecuteTrapError) { + fatalError = error; + } else if (executeStarted && !executeReturned) { + fatalError = new KernelTransferExecuteTrapError( + "kernel channel transfer trapped with a global reservation active", + error, + ); + } else { + pendingError = error; } - if (firstError) { - this.dequeueSignalForDelivery(channel); - if (this.finishSignalTermination(channel)) return; - this.completeChannelRaw(channel, firstError.retVal, firstError.errVal); - this.relistenChannel(channel); - return; + } finally { + if (reservation?.region) { + try { + reservation.region.revoke(); + } catch (error) { + fatalError ??= new KernelTransferExecuteTrapError( + "kernel channel transfer lease could not be revoked", + error, + ); + } + } + if (reservation && fatalError === null) { + try { + this.#cancelLargeTransferScratch(reservation.token, entry); + } catch (error) { + this.#rethrowKernelEntryFatal(error); + fatalError = new KernelTransferExecuteTrapError( + "kernel channel transfer reservation could not be settled", + error, + ); + } } + if (fatalError === null) this.#largeTransferScratchInUse = false; + } - this.dequeueSignalForDelivery(channel); - if (this.finishSignalTermination(channel)) return; - this.handleSharedMappingsAfterFileSyscall( - channel, syscallNr, origArgs, totalWritten, 0, + if (fatalError !== null) throw fatalError; + if (pendingError !== undefined) throw pendingError; + return { value, errno }; + } + + /** + * Execute a complete channel layout in the cheap reusable mailbox when it + * fits, otherwise in one token-owned Rust allocation. + */ + #executeCapacityOwnedChannel( + channel: ChannelInfo, + totalCapacity: number, + entry: KernelWorkerEntryContext, + stage: (lease: KernelScratchLease) => void, + finish: (lease: KernelScratchLease) => T, + retryToken = 0n, + ): { value: T | null; errno: number } { + if (totalCapacity > CH_TOTAL_SIZE) { + return this.#executeReservedChannelDispatch( + channel, + totalCapacity, + entry, + stage, + finish, + retryToken, ); - this.synchronizeSharedMemoryForBoundary(channel); - this.completeChannelRaw(channel, totalWritten, 0); - this.relistenChannel(channel); } + + // Bind before leasing the reusable mailbox. The binding export can invoke + // synchronous host hooks; staging first would let such a hook either + // observe partial bytes or collide with the active lease. + this.#bindKernelTidForChannel(channel, entry); + const value = this.#requireMainScratchRegion().withLease((lease) => { + // WHY: keep the fixed-mailbox path structurally identical to the + // reservation path: all writes finish before the one kernel call, and + // every readback occurs before the lease is revoked. + stage(lease); + const previousHandlePid = this.currentHandlePid; + this.currentHandlePid = channel.pid; + try { + this.#invokeEntryScratchExport( + entry, + lease, + "kernel_handle_channel", + [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + retryToken, + ], + ); + } finally { + this.currentHandlePid = previousHandlePid; + } + return finish(lease); + }); + return { value, errno: 0 }; } /** - * Handle large write/pwrite where the data exceeds CH_DATA_SIZE. - * Loops through CH_DATA_SIZE chunks, issuing individual kernel calls. + * Execute one large transfer while its kernel-owned allocation is leased. + * + * Normal Rust errno returns transition Executing to Ready and are always + * followed by cancellation. A JavaScript/Wasm exception cannot run Rust's + * Ready transition; that path revokes the host lease, skips unsafe cancel, + * and throws a kernel-fatal error out of the dedicated Worker. */ - private handleLargeWrite( + #executeReservedScratchTransfer( channel: ChannelInfo, syscallNr: number, - origArgs: number[], - rawArgs: readonly bigint[], - ): void { - const fd = origArgs[0]; - const bufPtr = origArgs[1]; - const totalLen = origArgs[2]; - if ( - !Number.isSafeInteger(totalLen) || - totalLen < 0 || - totalLen > 0x7FFFFFFF - ) { - this.completeChannelRaw(channel, -1, EINVAL); - this.relistenChannel(channel); - return; - } - try { - this.checkedProcessRange( - channel, - bufPtr, - totalLen, - "large write source", + request: FlattenedTransferRequest, + entry: KernelWorkerEntryContext, + retryToken = 0n, + ): { retVal: number; errVal: number } { + if (this.#largeTransferScratchInUse) { + // Channel entry is deferred before reaching this method. Seeing the + // reservation twice is therefore an internal ownership violation, not + // a guest-visible resource conflict. + throw new KernelReentrantEntryError( + "kernel_transfer_io_execute reservation", ); - } catch (error) { - this.rejectScratchTransfer(channel, error); - return; } - const isPwrite = syscallNr === SYS_PWRITE; - // pwrite offset is a single i64 arg (arg index 3) - const initialFileOffset = isPwrite ? rawArgs[3]! : 0n; - let fileOffset = initialFileOffset; - const operationLen = this.prepareWriteOperationBudget( - channel, - fd, - fileOffset, - totalLen, - isPwrite, - ); - if (operationLen === null) return; + this.#largeTransferScratchInUse = true; const processMem = new Uint8Array(channel.memory.buffer); - const scratch = this.requireMainScratchRegion(); - let totalWritten = 0; - - while (totalWritten < operationLen) { - const chunkLen = Math.min(operationLen - totalWritten, CH_DATA_SIZE); - - let result: { retVal: number; errVal: number }; - try { - result = scratch.withLease((lease) => { - lease.copyFrom( - processMem, - CH_DATA, - bufPtr + totalWritten, - chunkLen, - ); - const kernelView = lease.dataView(0, CH_DATA); - kernelView.setUint32(CH_SYSCALL, syscallNr, true); - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - lease.writeAddress( - CH_ARGS + CH_ARG_SIZE, - CH_DATA, - chunkLen, - "u64-le", - ); - kernelView.setBigInt64( - CH_ARGS + 2 * CH_ARG_SIZE, - BigInt(chunkLen), - true, - ); - if (isPwrite) { - kernelView.setBigInt64( - CH_ARGS + 3 * CH_ARG_SIZE, - fileOffset, - true, + let reservation: ReservedTransferScratch | null = null; + let result = { retVal: -1, errVal: EIO }; + let executeStarted = false; + let executeReturned = false; + let fatalError: KernelTransferExecuteTrapError | null = null; + try { + const begun = this.#beginLargeTransferScratch( + request.totalData, + entry, + ); + reservation = begun.reservation; + if (!reservation?.region) { + result = { retVal: -1, errVal: begun.errno }; + } else { + const activeRegion = reservation.region; + const activeToken = reservation.token; + result = activeRegion.withLease((lease) => { + if (request.read) { + lease.fill(0, 0, request.totalData); + } else if (request.inputBytes) { + lease.copyFrom( + request.inputBytes, + 0, + 0, + request.totalData, + ); + } else { + this.#copyFlattenedTransferInput( + lease, + processMem, + request.entries, + 0, ); } - this.bindKernelTidForChannel(channel); + + executeStarted = true; + // Host imports such as stdin routing use the selected process id. + // Preserve a prior binding so reentrant callbacks cannot silently + // replace their outer operation's host-side identity. + const previousHandlePid = this.currentHandlePid; this.currentHandlePid = channel.pid; + let rawResult: number; try { - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - channel.pid, - ]); + rawResult = this.#invokeEntryScratchExport( + entry, + lease, + "kernel_transfer_io_execute", + [ + channel.pid, + this.guestTidForChannel(channel), + activeToken, + this.toKernelPtr(request.totalData), + syscallNr, + request.fd, + request.offset ?? 0n, + retryToken, + ], + ); } finally { - this.currentHandlePid = 0; + this.currentHandlePid = previousHandlePid; } - const resultView = lease.dataView(0, CH_DATA); - return { - retVal: Number(resultView.getBigInt64(CH_RETURN, true)), - errVal: resultView.getUint32(CH_ERRNO, true), - }; + executeReturned = true; + const checked = this.#checkedReservedTransferResult( + rawResult, + request.totalData, + ); + if (request.read && checked.retVal > 0) { + this.#copyFlattenedTransferOutput( + lease, + processMem, + request.entries, + 0, + checked.retVal, + ); + } + return checked; }); - } catch (err) { - console.error(`[handleLargeWrite] kernel threw for pid=${channel.pid}:`, err); - if (totalWritten > 0) { - this.handleSharedMappingsAfterFileSyscall( - channel, syscallNr, origArgs, totalWritten, 0, - isPwrite ? initialFileOffset : undefined, + } + } catch (error) { + if (isKernelExportFailure(error)) { + fatalError = new KernelTransferExecuteTrapError( + executeStarted && !executeReturned + ? "kernel transfer execute trapped with a global reservation active" + : "kernel transfer reservation export trapped", + error, + ); + } else if (error instanceof KernelTransferExecuteTrapError) { + fatalError = error; + } else if (this.#kernelFatalError !== null) { + fatalError = new KernelTransferExecuteTrapError( + "kernel transfer reservation export trapped", + error, + ); + } else if (executeStarted && !executeReturned) { + fatalError = new KernelTransferExecuteTrapError( + "kernel transfer execute trapped with a global reservation active", + error, + ); + } else { + result = { retVal: -1, errVal: EIO }; + } + } finally { + if (reservation?.region) { + try { + reservation.region.revoke(); + } catch (error) { + fatalError ??= new KernelTransferExecuteTrapError( + "kernel transfer lease could not be revoked", + error, ); - this.synchronizeSharedMemoryForBoundary(channel); - this.completeChannelRaw(channel, totalWritten, 0); - } else { - this.completeChannelRaw(channel, -5, 5); // -EIO } - this.relistenChannel(channel); - return; } - - if (this.finishSignalTermination(channel)) return; - - const { retVal, errVal } = result; - - if (retVal === -1 && errVal === EAGAIN) { - if (totalWritten > 0) { - this.dequeueSignalForDelivery(channel); - if (this.finishSignalTermination(channel)) return; - this.handleSharedMappingsAfterFileSyscall( - channel, syscallNr, origArgs, totalWritten, 0, - isPwrite ? initialFileOffset : undefined, + if (reservation && fatalError === null) { + try { + // WHY: a host notification may have queued while the execute export + // owned Rust state. The exact lexical entry remains live through + // settlement, so cancellation completes under the same scoped + // authority before any queued ingress can run. + this.#cancelLargeTransferScratch( + reservation.token, + entry, + ); + } catch (error) { + this.#rethrowKernelEntryFatal(error); + fatalError = new KernelTransferExecuteTrapError( + "kernel transfer reservation could not be settled", + error, ); - this.synchronizeSharedMemoryForBoundary(channel); - this.completeChannelRaw(channel, totalWritten, 0); - this.relistenChannel(channel); - return; } - this.dequeueSignalForDelivery(channel); - if (this.finishSignalTermination(channel)) return; - this.handleBlockingRetry(channel, syscallNr, origArgs); - return; } + // A fatal path leaves the guard set. No later code in this kernel + // instance may begin another transfer before the Worker terminates. + if (fatalError === null) this.#largeTransferScratchInUse = false; + } + + if (fatalError !== null) throw fatalError; + return result; + } + + #handleFlattenedTransfer( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + request: FlattenedTransferRequest, + entry: KernelWorkerEntryContext, + replaySnapshot?: FlattenedBlockingRetrySnapshot, + ): void { + if (this.#largeTransferScratchInUse) { + throw new KernelReentrantEntryError( + "flattened transfer reservation", + ); + } + let result: { retVal: number; errVal: number }; + try { + result = request.totalData <= CH_DATA_SIZE + ? this.#executeMainScratchTransfer( + channel, + syscallNr, + request, + entry, + replaySnapshot?.retryToken ?? 0n, + ) + : this.#executeReservedScratchTransfer( + channel, + syscallNr, + request, + entry, + replaySnapshot?.retryToken ?? 0n, + ); + } catch (error) { + this.#rethrowKernelEntryFatal(error); + if ( + this.#kernelFatalError !== null + || error instanceof KernelTransferExecuteTrapError + || error instanceof KernelTaskBindingError + || error instanceof KernelReentrantEntryError + ) { + throw error; + } + console.error( + "[kernel-worker] scratch transfer failed for pid=" + + String(channel.pid) + ":", + error, + ); + result = { retVal: -1, errVal: EIO }; + } + + const retryDisposition: BlockingRetryDisposition | undefined = + replaySnapshot + ?? ( + result.retVal === -1 + && result.errVal === EAGAIN + ? this.#captureBlockingRetryDisposition( + channel, + syscallNr, + origArgs, + entry, + vectorRequestForbidsEagainRetry(syscallNr, origArgs), + ) + : undefined + ); + const deliveredSignal = this.#dequeueSignalForDelivery( + channel, + entry, + (retryDisposition?.applicableSocketTimeoutMs ?? 0) > 0, + ); + if (this.#finishSignalTermination(channel, entry)) return; - if (errVal !== 0 || retVal <= 0) { - this.dequeueSignalForDelivery(channel); - if (this.finishSignalTermination(channel)) return; - if (totalWritten > 0) { - this.handleSharedMappingsAfterFileSyscall( - channel, syscallNr, origArgs, totalWritten, 0, - isPwrite ? initialFileOffset : undefined, + if (result.retVal === -1 && result.errVal === EAGAIN) { + if (!replaySnapshot) { + if (!retryDisposition) { + this.#failBlockingRetryProtocol( + `vector syscall ${syscallNr} EAGAIN has no frozen disposition`, ); - this.synchronizeSharedMemoryForBoundary(channel); - this.completeChannelRaw(channel, totalWritten, 0); - } else { - this.completeChannelRaw(channel, retVal, errVal); } - this.relistenChannel(channel); - return; - } - if (!Number.isSafeInteger(retVal) || retVal > chunkLen) { - this.completeChannelRaw(channel, -1, EIO); - this.relistenChannel(channel); - return; + if ( + !this.#rememberBlockingRetrySnapshot( + channel, + { + ...retryDisposition, + kind: "flattened-transfer", + syscallNr, + origArgs: origArgs.slice(), + request, + retryToken: 0n, + }, + entry, + ) + ) return; } + this.handleBlockingRetry( + channel, + syscallNr, + origArgs, + [], + entry, + replaySnapshot !== undefined, + deliveredSignal, + ); + return; + } - totalWritten += retVal; - if (isPwrite) fileOffset += BigInt(retVal); + if (!request.read) { + this.handleSharedMappingsAfterFileSyscall( + channel, + syscallNr, + origArgs, + result.retVal, + result.errVal, + syscallNr === SYS_PWRITE ? request.offset ?? undefined : undefined, + undefined, + entry, + ); + } + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + result.retVal, + result.errVal, + [], + undefined, + entry, + ); + } - // Short write from kernel — return what we have - if (retVal < chunkLen) break; + /** + * Handle writev/pwritev as one logical contiguous kernel write. + * + * pwritev2 flags remain ignored, matching the kernel's existing ABI 43 + * behavior, but the offset and complete caller ranges remain exact. + */ + #handleWritev( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + rawArgs: readonly bigint[], + entry: KernelWorkerEntryContext, + ): void { + let checkedIovecs: CheckedProcessIovecs; + let offset: bigint | null; + try { + const iovCount = this.checkedVectorCount(rawArgs[2] ?? 0n); + checkedIovecs = this.checkedProcessIovecs( + channel, + rawArgs[1] ?? 0n, + iovCount, + true, + ); + offset = syscallNr === SYS_PWRITEV || syscallNr === SYS_PWRITEV2 + ? joinPositionedVectorOffset(origArgs[3], origArgs[4]) + : null; + } catch (error) { + this.#rejectScratchTransfer(channel, error, entry); + return; } - this.dequeueSignalForDelivery(channel); - if (this.finishSignalTermination(channel)) return; - this.handleSharedMappingsAfterFileSyscall( - channel, syscallNr, origArgs, totalWritten, 0, - isPwrite ? initialFileOffset : undefined, + let request: FlattenedTransferRequest; + try { + request = { + fd: origArgs[0], + entries: checkedIovecs.entries.slice(), + totalData: checkedIovecs.totalData, + read: false, + offset, + inputBytes: this.#snapshotFlattenedTransferInput( + channel, + checkedIovecs.entries, + checkedIovecs.totalData, + ), + }; + } catch (error) { + this.#rejectScratchTransfer(channel, error, entry); + return; + } + this.#handleFlattenedTransfer( + channel, + syscallNr, + origArgs, + request, + entry, ); - this.synchronizeSharedMemoryForBoundary(channel); - this.completeChannelRaw(channel, totalWritten, 0); - this.relistenChannel(channel); } /** - * Handle large read/pread where the buffer exceeds CH_DATA_SIZE. - * Loops through CH_DATA_SIZE chunks, copying data back to process memory. + * Handle large write/pwrite as one operation, not channel-sized chunks. */ - private handleLargeRead( + #handleLargeWrite( channel: ChannelInfo, syscallNr: number, origArgs: number[], rawArgs: readonly bigint[], + entry: KernelWorkerEntryContext, ): void { - const fd = origArgs[0]; - const bufPtr = origArgs[1]; - const totalLen = origArgs[2]; + const length = origArgs[2] ?? 0; if ( - !Number.isSafeInteger(totalLen) || - totalLen < 0 || - totalLen > 0x7fff_ffff + !Number.isSafeInteger(length) + || length < 0 + || length > MAX_REPORTABLE_TRANSFER_BYTES ) { - this.completeChannelRaw(channel, -1, EINVAL); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten(channel, -1, EINVAL, entry); return; } + let source: { pointer: number; length: number }; try { - this.checkedProcessRange( + source = this.checkedProcessRange( channel, - bufPtr, - totalLen, - "large read destination", + rawArgs[1] ?? 0n, + length, + "large write source", ); } catch (error) { - this.rejectScratchTransfer(channel, error); + this.#rejectScratchTransfer(channel, error, entry); return; } - const isPread = syscallNr === SYS_PREAD; - let fileOffset = isPread ? rawArgs[3]! : 0n; - - const processMem = new Uint8Array(channel.memory.buffer); - const scratch = this.requireMainScratchRegion(); - let totalRead = 0; - - while (totalRead < totalLen) { - const chunkLen = Math.min(totalLen - totalRead, CH_DATA_SIZE); - - let result: { retVal: number; errVal: number }; - try { - result = scratch.withLease((lease) => { - lease.fill(0, CH_DATA, chunkLen); - const kernelView = lease.dataView(0, CH_DATA); - kernelView.setUint32(CH_SYSCALL, syscallNr, true); - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - lease.writeAddress( - CH_ARGS + CH_ARG_SIZE, - CH_DATA, - chunkLen, - "u64-le", - ); - kernelView.setBigInt64( - CH_ARGS + 2 * CH_ARG_SIZE, - BigInt(chunkLen), - true, - ); - if (isPread) { - kernelView.setBigInt64( - CH_ARGS + 3 * CH_ARG_SIZE, - fileOffset, - true, - ); - } - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; - try { - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - channel.pid, - ]); - } finally { - this.currentHandlePid = 0; - } - const resultView = lease.dataView(0, CH_DATA); - const retVal = Number(resultView.getBigInt64(CH_RETURN, true)); - const errVal = resultView.getUint32(CH_ERRNO, true); - if (retVal > 0 && retVal <= chunkLen) { - lease.copyTo( - processMem, - CH_DATA, - bufPtr + totalRead, - retVal, - ); - } - return { retVal, errVal }; - }); - } catch (err) { - console.error(`[handleLargeRead] kernel threw for pid=${channel.pid}:`, err); - if (totalRead > 0) { - this.synchronizeSharedMemoryForBoundary(channel); - this.completeChannelRaw(channel, totalRead, 0); - } else { - this.completeChannelRaw(channel, -5, 5); // -EIO - } - this.relistenChannel(channel); - return; - } - - if (this.finishSignalTermination(channel)) return; - - const { retVal, errVal } = result; - - if (retVal === -1 && errVal === EAGAIN) { - if (totalRead > 0) { - this.synchronizeSharedMemoryForBoundary(channel); - this.completeChannelRaw(channel, totalRead, 0); - this.relistenChannel(channel); - return; - } - this.handleBlockingRetry(channel, syscallNr, origArgs); - return; - } - - if (errVal !== 0 || retVal <= 0) { - if (totalRead > 0) { - this.synchronizeSharedMemoryForBoundary(channel); - this.completeChannelRaw(channel, totalRead, 0); - } else { - this.completeChannelRaw(channel, retVal, errVal); - } - this.relistenChannel(channel); - return; - } - if (!Number.isSafeInteger(retVal) || retVal > chunkLen) { - this.completeChannelRaw(channel, -1, EIO); - this.relistenChannel(channel); - return; - } - totalRead += retVal; - if (isPread) fileOffset += BigInt(retVal); + const entries = [{ base: source.pointer, len: source.length }]; + this.#handleFlattenedTransfer( + channel, + syscallNr, + origArgs, + { + fd: origArgs[0], + entries, + totalData: source.length, + read: false, + offset: syscallNr === SYS_PWRITE ? rawArgs[3] ?? 0n : null, + inputBytes: this.#snapshotFlattenedTransferInput( + channel, + entries, + source.length, + ), + }, + entry, + ); + } - // Short read (EOF or partial) — return what we have - if (retVal < chunkLen) break; + /** + * Handle large read/pread as one operation, not channel-sized chunks. + */ + #handleLargeRead( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + rawArgs: readonly bigint[], + entry: KernelWorkerEntryContext, + ): void { + const length = origArgs[2] ?? 0; + if ( + !Number.isSafeInteger(length) + || length < 0 + || length > MAX_REPORTABLE_TRANSFER_BYTES + ) { + this.completeChannelRawAndRelisten(channel, -1, EINVAL, entry); + return; + } + let destination: { pointer: number; length: number }; + try { + destination = this.checkedProcessRange( + channel, + rawArgs[1] ?? 0n, + length, + "large read destination", + ); + } catch (error) { + this.#rejectScratchTransfer(channel, error, entry); + return; } - this.dequeueSignalForDelivery(channel); - if (this.finishSignalTermination(channel)) return; - this.synchronizeSharedMemoryForBoundary(channel); - this.completeChannelRaw(channel, totalRead, 0); - this.relistenChannel(channel); + this.#handleFlattenedTransfer( + channel, + syscallNr, + origArgs, + { + fd: origArgs[0], + entries: [{ base: destination.pointer, len: destination.length }], + totalData: destination.length, + read: true, + offset: syscallNr === SYS_PREAD ? rawArgs[3] ?? 0n : null, + }, + entry, + ); } /** - * Handle readv/preadv: set up iov array in kernel scratch, call - * kernel_handle_channel, then copy read data back to process memory. + * Handle readv/preadv as one logical contiguous kernel read and scatter only + * the returned prefix into caller-owned ranges. */ - private handleReadv(channel: ChannelInfo, syscallNr: number, origArgs: number[]): void { - const fd = origArgs[0]; - const iovPtr = origArgs[1]; - const iovcnt = origArgs[2]; - - const processMem = new Uint8Array(channel.memory.buffer); + #handleReadv( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + rawArgs: readonly bigint[], + entry: KernelWorkerEntryContext, + ): void { let checkedIovecs: CheckedProcessIovecs; + let offset: bigint | null; try { + const iovCount = this.checkedVectorCount(rawArgs[2] ?? 0n); checkedIovecs = this.checkedProcessIovecs( channel, - iovPtr, - iovcnt, - false, + rawArgs[1] ?? 0n, + iovCount, + true, ); + offset = syscallNr === SYS_PREADV || syscallNr === SYS_PREADV2 + ? joinPositionedVectorOffset(origArgs[3], origArgs[4]) + : null; } catch (error) { - this.rejectScratchTransfer(channel, error); + this.#rejectScratchTransfer(channel, error, entry); return; } - const { entries } = checkedIovecs; - const footprint = this.kernelIovecFootprint(entries); - const scratch = this.requireMainScratchRegion(); - const maxDataPerCall = - CH_DATA_SIZE - STRUCT_SIZE_KERNEL_IOVEC_WIRE; - const isPreadv = - syscallNr === SYS_PREADV || syscallNr === SYS_PREADV2; - const isPreadv2 = syscallNr === SYS_PREADV2; - if (footprint <= CH_DATA_SIZE) { - // Fast path: everything fits in one kernel call - const iovSize = iovcnt * STRUCT_SIZE_KERNEL_IOVEC_WIRE; - const result = scratch.withLease((lease) => { - lease.assertRange(CH_DATA, footprint); - const kernelIovecs = lease.dataView(CH_DATA, iovSize); - let dataOffset = iovSize; - const kernelEntries: Array<{ - base: number; - scratchOffset: number; - len: number; - }> = []; - for (let index = 0; index < entries.length; index++) { - const entry = entries[index]; - const scratchOffset = CH_DATA + dataOffset; - kernelEntries.push({ - base: entry.base, - scratchOffset, - len: entry.len, - }); - if (entry.len > 0) lease.fill(0, scratchOffset, entry.len); - lease.writeAddress( - CH_DATA - + index * STRUCT_SIZE_KERNEL_IOVEC_WIRE - + KERNEL_IOVEC_WIRE_BASE_OFFSET, - scratchOffset, - entry.len, - "u32-le", - ); - kernelIovecs.setUint32( - index * STRUCT_SIZE_KERNEL_IOVEC_WIRE - + KERNEL_IOVEC_WIRE_LEN_OFFSET, - entry.len, - true, - ); - dataOffset = this.checkedAlignUp( - dataOffset + entry.len, - KERNEL_IOVEC_WIRE_ALIGN, - "kernel readv layout", - ); - } - const kernelView = lease.dataView(0, CH_DATA); - kernelView.setUint32(CH_SYSCALL, syscallNr, true); - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - lease.writeAddress( - CH_ARGS + CH_ARG_SIZE, - CH_DATA, - iovSize, - "u64-le", - ); - kernelView.setBigInt64( - CH_ARGS + 2 * CH_ARG_SIZE, - BigInt(iovcnt), - true, - ); - if (isPreadv) { - kernelView.setBigInt64( - CH_ARGS + 3 * CH_ARG_SIZE, - BigInt(origArgs[3]), - true, - ); - kernelView.setBigInt64( - CH_ARGS + 4 * CH_ARG_SIZE, - BigInt(origArgs[4]), - true, - ); - } - kernelView.setBigInt64( - CH_ARGS + 5 * CH_ARG_SIZE, - isPreadv2 ? BigInt(origArgs[5]) : 0n, - true, - ); - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; - try { - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - channel.pid, - ]); - } finally { - this.currentHandlePid = 0; - } - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); - if ( - retVal > checkedIovecs.totalData || - !Number.isSafeInteger(retVal) - ) { - return { retVal: -1, errVal: EIO }; - } - if (retVal > 0) { - let remaining = retVal; - for (const entry of kernelEntries) { - if (remaining <= 0) break; - const copyLength = Math.min(entry.len, remaining); - lease.copyTo( - processMem, - entry.scratchOffset, - entry.base, - copyLength, - ); - remaining -= copyLength; - } - } - return { retVal, errVal }; - }); - - if (this.finishSignalTermination(channel)) return; - - const { retVal, errVal } = result; - - if (retVal === -1 && errVal === EAGAIN) { - this.handleBlockingRetry(channel, syscallNr, origArgs); - return; - } - - this.completeChannel(channel, syscallNr, origArgs, undefined, retVal, errVal); - } else { - // Slow path: total data exceeds scratch buffer. Issue one SYS_READ per iov entry, - // chunked to fit in CH_DATA_SIZE. Use pread to maintain file offset for preadv. - let fileOffset = isPreadv - ? joinPositionedVectorOffset(origArgs[3], origArgs[4]) - : 0n; - let totalRead = 0; - let lastErr = 0; - let gotEagain = false; - - for (const entry of entries) { - if (entry.len === 0) continue; - let entryRead = 0; - - while (entryRead < entry.len) { - const chunkLen = Math.min(entry.len - entryRead, maxDataPerCall); - const result = scratch.withLease((lease) => { - const kernelBufferOffset = - CH_DATA + STRUCT_SIZE_KERNEL_IOVEC_WIRE; - const kernelIovec = lease.dataView( - CH_DATA, - STRUCT_SIZE_KERNEL_IOVEC_WIRE, - ); - lease.writeAddress( - CH_DATA + KERNEL_IOVEC_WIRE_BASE_OFFSET, - kernelBufferOffset, - chunkLen, - "u32-le", - ); - kernelIovec.setUint32( - KERNEL_IOVEC_WIRE_LEN_OFFSET, - chunkLen, - true, - ); - lease.fill(0, kernelBufferOffset, chunkLen); - const kernelView = lease.dataView(0, CH_DATA); - kernelView.setUint32( - CH_SYSCALL, - isPreadv ? syscallNr : SYS_READV, - true, - ); - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - lease.writeAddress( - CH_ARGS + CH_ARG_SIZE, - CH_DATA, - STRUCT_SIZE_KERNEL_IOVEC_WIRE, - "u64-le", - ); - kernelView.setBigInt64( - CH_ARGS + 2 * CH_ARG_SIZE, - 1n, - true, - ); - if (isPreadv) { - kernelView.setBigInt64( - CH_ARGS + 3 * CH_ARG_SIZE, - BigInt.asUintN(32, fileOffset), - true, - ); - kernelView.setBigInt64( - CH_ARGS + 4 * CH_ARG_SIZE, - BigInt.asIntN(32, fileOffset >> 32n), - true, - ); - } - kernelView.setBigInt64( - CH_ARGS + 5 * CH_ARG_SIZE, - isPreadv2 ? BigInt(origArgs[5]) : 0n, - true, - ); - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; - try { - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - channel.pid, - ]); - } finally { - this.currentHandlePid = 0; - } - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); - if (!Number.isSafeInteger(retVal) || retVal > chunkLen) { - return { retVal: -1, errVal: EIO }; - } - if (retVal > 0) { - lease.copyTo( - processMem, - kernelBufferOffset, - entry.base + entryRead, - retVal, - ); - } - return { retVal, errVal }; - }); - - if (this.finishSignalTermination(channel)) return; - - const { retVal, errVal } = result; - - if (retVal === -1) { - if (errVal === EAGAIN && totalRead === 0) { - gotEagain = true; - break; - } - lastErr = errVal; - break; - } - - if (retVal === 0) break; // EOF - - entryRead += retVal; - totalRead += retVal; - if (isPreadv) fileOffset += BigInt(retVal); - - if (retVal < chunkLen) break; // short read - } - - if (gotEagain || lastErr) break; - } - - if (gotEagain) { - this.handleBlockingRetry(channel, syscallNr, origArgs); - return; - } - const finalRet = totalRead > 0 ? totalRead : (lastErr ? -1 : 0); - const finalErr = totalRead > 0 ? 0 : lastErr; - this.completeChannel(channel, syscallNr, origArgs, undefined, finalRet, finalErr); - } + this.#handleFlattenedTransfer( + channel, + syscallNr, + origArgs, + { + fd: origArgs[0], + entries: checkedIovecs.entries.slice(), + totalData: checkedIovecs.totalData, + read: true, + offset, + }, + entry, + ); } /** * Handle sendmsg: decompose msghdr from process memory, flatten data + addr * into kernel scratch, call kernel_sendmsg which dispatches to sendto/send. */ - private handleSendmsg(channel: ChannelInfo, origArgs: number[]): void { + private handleSendmsg( + channel: ChannelInfo, + origArgs: number[], + processMem: Uint8Array | null, + entry: KernelWorkerEntryContext, + retainedSnapshot?: SendmsgBlockingRetrySnapshot, + ): void { const fd = origArgs[0]; const msgPtr = origArgs[1]; const flags = origArgs[2]; - const processMem = new Uint8Array(channel.memory.buffer); - let message: CheckedProcessMessage; - let layout: KernelMessageLayout; - let kernelControl: Uint8Array; - try { - message = this.checkedProcessMessage(channel, msgPtr); - kernelControl = this.nativeControlToKernelWire(processMem, message); - layout = this.kernelMessageLayout(message, kernelControl.length); - if (layout.footprint > CH_DATA_SIZE) { - throw new KernelScratchError( - "sendmsg payload exceeds bounded kernel transport", - 90, + let snapshot: SendmsgBlockingRetrySnapshot; + if (retainedSnapshot) { + snapshot = retainedSnapshot; + } else { + try { + if (processMem === null) { + throw new KernelScratchError( + "sendmsg process snapshot is unavailable", + EIO, + ); + } + this.#scratchBoundaryTestHooks?.afterProcessMemorySnapshot?.(channel); + const message = this.checkedProcessMessage( + channel, + msgPtr, + "send", + processMem, + ); + const kernelControl = this.nativeControlToKernelWire( + processMem, + message, + ); + const layout = this.kernelMessageLayout( + message, + kernelControl.length, + ); + const totalCapacity = checkedAlignUp( + CH_DATA + layout.footprint, + 8, + "sendmsg channel capacity", ); + snapshot = { + ...this.#cancellationPointIdentity(channel), + retryForbiddenByCallFlags: false, + fdWasNonblocking: false, + applicableSocketTimeoutMs: 0, + kind: "sendmsg", + syscallNr: SYS_SENDMSG, + origArgs: origArgs.slice(), + message, + layout, + totalCapacity, + name: message.name.length > 0 + ? processMem.slice( + message.name.pointer, + message.name.pointer + message.name.length, + ) + : new Uint8Array(0), + control: kernelControl, + payload: this.#snapshotFlattenedTransferInput( + channel, + message.iovecs.entries, + message.iovecs.totalData, + ), + retryToken: 0n, + }; + } catch (error) { + this.#rejectScratchTransfer(channel, error, entry); + return; } - } catch (error) { - this.rejectScratchTransfer(channel, error); - return; } - const scratch = this.requireMainScratchRegion(); + const { message, layout, totalCapacity } = snapshot; + const kernelControl = snapshot.control; let result: { retVal: number; errVal: number }; try { - result = scratch.withLease((lease) => { - lease.assertRange(CH_DATA, layout.footprint); - const kernelMessage = lease.dataView( - CH_DATA, - STRUCT_SIZE_KERNEL_MSGHDR_WIRE, - ); - - if (message.name.length > 0) { - lease.copyFrom( - processMem, - CH_DATA + layout.nameOffset, - message.name.pointer, - message.name.length, + const dispatched = this.#executeCapacityOwnedChannel( + channel, + totalCapacity, + entry, + (lease) => { + lease.assertRange(CH_DATA, layout.footprint); + const kernelMessage = lease.dataView( + CH_DATA, + STRUCT_SIZE_KERNEL_MSGHDR_WIRE, ); - } - if (kernelControl.length > 0) { - lease.copyFrom( - kernelControl, - CH_DATA + layout.controlOffset, - 0, - kernelControl.length, - ); - } + if (message.name.length > 0) { + lease.copyFrom( + snapshot.name, + CH_DATA + layout.nameOffset, + 0, + message.name.length, + ); + } - if (layout.iovecCount > 0) { - const kernelIovec = lease.dataView( - CH_DATA + layout.iovecOffset, - layout.iovecBytes, - ); - if (message.iovecs.totalData === 0) { - kernelIovec.setUint32(KERNEL_IOVEC_WIRE_BASE_OFFSET, 0, true); + if (kernelControl.length > 0) { + lease.copyFrom( + kernelControl, + CH_DATA + layout.controlOffset, + 0, + kernelControl.length, + ); + } + + if (layout.iovecCount > 0) { + const kernelIovec = lease.dataView( + CH_DATA + layout.iovecOffset, + layout.iovecBytes, + ); + if (message.iovecs.totalData === 0) { + kernelIovec.setUint32(KERNEL_IOVEC_WIRE_BASE_OFFSET, 0, true); + } else { + lease.writeAddress( + CH_DATA + + layout.iovecOffset + + KERNEL_IOVEC_WIRE_BASE_OFFSET, + CH_DATA + layout.dataOffset, + message.iovecs.totalData, + "u32-le", + ); + } + kernelIovec.setUint32( + KERNEL_IOVEC_WIRE_LEN_OFFSET, + message.iovecs.totalData, + true, + ); + } + if (snapshot.payload.length > 0) { + lease.copyFrom( + snapshot.payload, + CH_DATA + layout.dataOffset, + 0, + snapshot.payload.length, + ); + } + + if (!message.namePresent) { + kernelMessage.setUint32( + KERNEL_MSGHDR_WIRE_NAME_OFFSET, + 0, + true, + ); } else { lease.writeAddress( - CH_DATA - + layout.iovecOffset - + KERNEL_IOVEC_WIRE_BASE_OFFSET, - CH_DATA + layout.dataOffset, - message.iovecs.totalData, + CH_DATA + KERNEL_MSGHDR_WIRE_NAME_OFFSET, + CH_DATA + layout.nameOffset, + message.name.length, "u32-le", ); } - kernelIovec.setUint32( - KERNEL_IOVEC_WIRE_LEN_OFFSET, - message.iovecs.totalData, + kernelMessage.setUint32( + KERNEL_MSGHDR_WIRE_NAMELEN_OFFSET, + message.name.length, true, ); - } - let stagedData = 0; - for (const entry of message.iovecs.entries) { - if (entry.len > 0) { - lease.copyFrom( - processMem, - CH_DATA + layout.dataOffset + stagedData, - entry.base, - entry.len, + if (layout.iovecOffset === 0) { + kernelMessage.setUint32( + KERNEL_MSGHDR_WIRE_IOV_OFFSET, + 0, + true, + ); + } else { + lease.writeAddress( + CH_DATA + KERNEL_MSGHDR_WIRE_IOV_OFFSET, + CH_DATA + layout.iovecOffset, + layout.iovecBytes, + "u32-le", ); - stagedData += entry.len; } - } - - if (layout.nameOffset === 0) { kernelMessage.setUint32( - KERNEL_MSGHDR_WIRE_NAME_OFFSET, - 0, + KERNEL_MSGHDR_WIRE_IOVLEN_OFFSET, + layout.iovecCount, true, ); - } else { - lease.writeAddress( - CH_DATA + KERNEL_MSGHDR_WIRE_NAME_OFFSET, - CH_DATA + layout.nameOffset, - message.name.length, - "u32-le", - ); - } - kernelMessage.setUint32( - KERNEL_MSGHDR_WIRE_NAMELEN_OFFSET, - message.name.length, - true, - ); - if (layout.iovecOffset === 0) { + if (layout.controlOffset === 0) { + kernelMessage.setUint32( + KERNEL_MSGHDR_WIRE_CONTROL_OFFSET, + 0, + true, + ); + } else { + lease.writeAddress( + CH_DATA + KERNEL_MSGHDR_WIRE_CONTROL_OFFSET, + CH_DATA + layout.controlOffset, + layout.controlCapacity, + "u32-le", + ); + } kernelMessage.setUint32( - KERNEL_MSGHDR_WIRE_IOV_OFFSET, - 0, + KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET, + kernelControl.length, true, ); - } else { + kernelMessage.setUint32(KERNEL_MSGHDR_WIRE_FLAGS_OFFSET, 0, true); + + const kernelView = lease.dataView(0, CH_DATA); + kernelView.setUint32(CH_SYSCALL, SYS_SENDMSG, true); + for (let index = 0; index < CH_ARGS_COUNT; index++) { + kernelView.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, 0n, true); + } + kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); lease.writeAddress( - CH_DATA + KERNEL_MSGHDR_WIRE_IOV_OFFSET, - CH_DATA + layout.iovecOffset, - layout.iovecBytes, - "u32-le", + CH_ARGS + CH_ARG_SIZE, + CH_DATA, + STRUCT_SIZE_KERNEL_MSGHDR_WIRE, + "u32-to-u64-le", ); - } - kernelMessage.setUint32( - KERNEL_MSGHDR_WIRE_IOVLEN_OFFSET, - layout.iovecCount, - true, - ); - if (layout.controlOffset === 0) { - kernelMessage.setUint32( - KERNEL_MSGHDR_WIRE_CONTROL_OFFSET, - 0, + kernelView.setBigInt64( + CH_ARGS + 2 * CH_ARG_SIZE, + BigInt(flags), true, ); - } else { - lease.writeAddress( - CH_DATA + KERNEL_MSGHDR_WIRE_CONTROL_OFFSET, - CH_DATA + layout.controlOffset, - layout.controlCapacity, - "u32-le", - ); - } - kernelMessage.setUint32( - KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET, - kernelControl.length, - true, - ); - kernelMessage.setUint32(KERNEL_MSGHDR_WIRE_FLAGS_OFFSET, 0, true); - - const kernelView = lease.dataView(0, CH_DATA); - kernelView.setUint32(CH_SYSCALL, SYS_SENDMSG, true); - for (let index = 0; index < CH_ARGS_COUNT; index++) { - kernelView.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, 0n, true); - } - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - lease.writeAddress( - CH_ARGS + CH_ARG_SIZE, - CH_DATA, - STRUCT_SIZE_KERNEL_MSGHDR_WIRE, - "u32-to-u64-le", - ); - kernelView.setBigInt64( - CH_ARGS + 2 * CH_ARG_SIZE, - BigInt(flags), - true, - ); - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; - try { - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - channel.pid, - ]); - } finally { - this.currentHandlePid = 0; - } - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); - if ( - !Number.isSafeInteger(retVal) || - retVal > message.iovecs.totalData - ) { - return { retVal: -1, errVal: EIO }; - } - return { retVal, errVal }; - }); + }, + (lease) => { + const kernelView = lease.dataView(0, CH_DATA); + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + const errVal = kernelView.getUint32(CH_ERRNO, true); + if ( + !Number.isSafeInteger(retVal) || + retVal > message.iovecs.totalData + ) { + return { retVal: -1, errVal: EIO }; + } + return { retVal, errVal }; + }, + retainedSnapshot?.retryToken ?? 0n, + ); + result = dispatched.value ?? { + retVal: -1, + errVal: dispatched.errno, + }; } catch (error) { - this.rejectScratchTransfer(channel, error); + this.#rethrowKernelEntryFatal(error); + if ( + this.#kernelFatalError !== null + || error instanceof KernelTransferExecuteTrapError + || error instanceof KernelTaskBindingError + || error instanceof KernelReentrantEntryError + ) { + throw error; + } + this.#rejectScratchTransfer(channel, error, entry); return; } - if (this.finishSignalTermination(channel)) return; - const { retVal, errVal } = result; + if ( + !retainedSnapshot + && retVal === -1 + && errVal === EAGAIN + ) { + // WHY: an ordinary successful message transfer must not pay for a + // second Rust export. The first EAGAIN still owns the exact entry and + // fd pin, so this is the last safe point to freeze timeout policy + // before a retry can overlap close/reuse of the numeric descriptor. + snapshot = { + ...snapshot, + ...this.#captureBlockingRetryDisposition( + channel, + SYS_SENDMSG, + origArgs, + entry, + syscallHasMsgDontwait(SYS_SENDMSG, origArgs), + ), + }; + } + const deliveredSignal = this.#dequeueSignalForDelivery( + channel, + entry, + snapshot.applicableSocketTimeoutMs > 0, + ); + if (this.#finishSignalTermination(channel, entry)) return; if (retVal === -1 && errVal === EAGAIN) { - this.handleBlockingRetry(channel, SYS_SENDMSG, origArgs); + if (!this.#rememberBlockingRetrySnapshot(channel, snapshot, entry)) { + return; + } + this.handleBlockingRetry( + channel, + SYS_SENDMSG, + origArgs, + [], + entry, + retainedSnapshot !== undefined, + deliveredSignal, + ); return; } - this.completeChannel(channel, SYS_SENDMSG, origArgs, undefined, retVal, errVal); + this.completeChannel( + channel, + SYS_SENDMSG, + origArgs, + undefined, + retVal, + errVal, + [], + undefined, + entry, + ); } /** * Handle recvmsg: decompose msghdr from process memory, set up buffers in * kernel scratch, call kernel_recvmsg, copy results back. */ - private handleRecvmsg(channel: ChannelInfo, origArgs: number[]): void { + private handleRecvmsg( + channel: ChannelInfo, + origArgs: number[], + processMem: Uint8Array | null, + entry: KernelWorkerEntryContext, + retainedSnapshot?: RecvmsgBlockingRetrySnapshot, + ): void { const fd = origArgs[0]; const msgPtr = origArgs[1]; const flags = origArgs[2]; - const processMem = new Uint8Array(channel.memory.buffer); - let message: CheckedProcessMessage; - let layout: KernelMessageLayout; - try { - message = this.checkedProcessMessage(channel, msgPtr); - layout = this.kernelMessageLayout( - message, - this.kernelControlCapacityForRecv(message), - ); - if (layout.footprint > CH_DATA_SIZE) { - throw new KernelScratchError( - "recvmsg buffers exceed bounded kernel transport", - 90, + let snapshot: RecvmsgBlockingRetrySnapshot; + if (retainedSnapshot) { + snapshot = retainedSnapshot; + } else { + try { + if (processMem === null) { + throw new KernelScratchError( + "recvmsg process snapshot is unavailable", + EIO, + ); + } + this.#scratchBoundaryTestHooks?.afterProcessMemorySnapshot?.(channel); + const message = this.checkedProcessMessage( + channel, + msgPtr, + "receive", + processMem, ); + const layout = this.kernelMessageLayout( + message, + this.kernelControlCapacityForRecv(message), + ); + const totalCapacity = checkedAlignUp( + CH_DATA + layout.footprint, + 8, + "recvmsg channel capacity", + ); + snapshot = { + ...this.#cancellationPointIdentity(channel), + retryForbiddenByCallFlags: false, + fdWasNonblocking: false, + applicableSocketTimeoutMs: 0, + kind: "recvmsg", + syscallNr: SYS_RECVMSG, + origArgs: origArgs.slice(), + message, + layout, + totalCapacity, + retryToken: 0n, + }; + } catch (error) { + this.#rejectScratchTransfer(channel, error, entry); + return; } - } catch (error) { - this.rejectScratchTransfer(channel, error); - return; } - const scratch = this.requireMainScratchRegion(); + const { message, layout, totalCapacity } = snapshot; let result: { retVal: number; errVal: number; @@ -12123,218 +20043,237 @@ export class CentralizedKernelWorker { control: Uint8Array; }; try { - result = scratch.withLease((lease) => { - lease.assertRange(CH_DATA, layout.footprint); - const kernelMessage = lease.dataView( - CH_DATA, - STRUCT_SIZE_KERNEL_MSGHDR_WIRE, - ); - if (message.name.length > 0) { - lease.fill(0, CH_DATA + layout.nameOffset, message.name.length); - } - if (layout.controlCapacity > 0) { - lease.fill( - 0, - CH_DATA + layout.controlOffset, - layout.controlCapacity, + const dispatched = this.#executeCapacityOwnedChannel( + channel, + totalCapacity, + entry, + (lease) => { + lease.assertRange(CH_DATA, layout.footprint); + const kernelMessage = lease.dataView( + CH_DATA, + STRUCT_SIZE_KERNEL_MSGHDR_WIRE, ); - } - if (layout.iovecCount > 0) { - const kernelIovec = lease.dataView( - CH_DATA + layout.iovecOffset, - layout.iovecBytes, + if (message.name.length > 0) { + lease.fill(0, CH_DATA + layout.nameOffset, message.name.length); + } + if (layout.controlCapacity > 0) { + lease.fill( + 0, + CH_DATA + layout.controlOffset, + layout.controlCapacity, + ); + } + if (layout.iovecCount > 0) { + const kernelIovec = lease.dataView( + CH_DATA + layout.iovecOffset, + layout.iovecBytes, + ); + if (message.iovecs.totalData === 0) { + kernelIovec.setUint32(KERNEL_IOVEC_WIRE_BASE_OFFSET, 0, true); + } else { + lease.writeAddress( + CH_DATA + + layout.iovecOffset + + KERNEL_IOVEC_WIRE_BASE_OFFSET, + CH_DATA + layout.dataOffset, + message.iovecs.totalData, + "u32-le", + ); + } + kernelIovec.setUint32( + KERNEL_IOVEC_WIRE_LEN_OFFSET, + message.iovecs.totalData, + true, + ); + } + if (message.iovecs.totalData > 0) { + lease.fill( + 0, + CH_DATA + layout.dataOffset, + message.iovecs.totalData, + ); + } + if (!message.namePresent) { + kernelMessage.setUint32( + KERNEL_MSGHDR_WIRE_NAME_OFFSET, + 0, + true, + ); + } else { + lease.writeAddress( + CH_DATA + KERNEL_MSGHDR_WIRE_NAME_OFFSET, + CH_DATA + layout.nameOffset, + message.name.length, + "u32-le", + ); + } + kernelMessage.setUint32( + KERNEL_MSGHDR_WIRE_NAMELEN_OFFSET, + message.name.length, + true, + ); + if (layout.iovecOffset === 0) { + kernelMessage.setUint32( + KERNEL_MSGHDR_WIRE_IOV_OFFSET, + 0, + true, + ); + } else { + lease.writeAddress( + CH_DATA + KERNEL_MSGHDR_WIRE_IOV_OFFSET, + CH_DATA + layout.iovecOffset, + layout.iovecBytes, + "u32-le", + ); + } + kernelMessage.setUint32( + KERNEL_MSGHDR_WIRE_IOVLEN_OFFSET, + layout.iovecCount, + true, ); - if (message.iovecs.totalData === 0) { - kernelIovec.setUint32(KERNEL_IOVEC_WIRE_BASE_OFFSET, 0, true); + if (layout.controlOffset === 0) { + kernelMessage.setUint32( + KERNEL_MSGHDR_WIRE_CONTROL_OFFSET, + 0, + true, + ); } else { lease.writeAddress( - CH_DATA - + layout.iovecOffset - + KERNEL_IOVEC_WIRE_BASE_OFFSET, - CH_DATA + layout.dataOffset, - message.iovecs.totalData, + CH_DATA + KERNEL_MSGHDR_WIRE_CONTROL_OFFSET, + CH_DATA + layout.controlOffset, + layout.controlCapacity, "u32-le", ); } - kernelIovec.setUint32( - KERNEL_IOVEC_WIRE_LEN_OFFSET, - message.iovecs.totalData, - true, - ); - } - if (message.iovecs.totalData > 0) { - lease.fill( - 0, - CH_DATA + layout.dataOffset, - message.iovecs.totalData, - ); - } - if (layout.nameOffset === 0) { kernelMessage.setUint32( - KERNEL_MSGHDR_WIRE_NAME_OFFSET, - 0, + KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET, + layout.controlCapacity, true, ); - } else { + kernelMessage.setUint32(KERNEL_MSGHDR_WIRE_FLAGS_OFFSET, 0, true); + + const kernelView = lease.dataView(0, CH_DATA); + kernelView.setUint32(CH_SYSCALL, SYS_RECVMSG, true); + for (let index = 0; index < CH_ARGS_COUNT; index++) { + kernelView.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, 0n, true); + } + kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); lease.writeAddress( - CH_DATA + KERNEL_MSGHDR_WIRE_NAME_OFFSET, - CH_DATA + layout.nameOffset, - message.name.length, - "u32-le", + CH_ARGS + CH_ARG_SIZE, + CH_DATA, + STRUCT_SIZE_KERNEL_MSGHDR_WIRE, + "u32-to-u64-le", ); - } - kernelMessage.setUint32( - KERNEL_MSGHDR_WIRE_NAMELEN_OFFSET, - message.name.length, - true, - ); - if (layout.iovecOffset === 0) { - kernelMessage.setUint32( - KERNEL_MSGHDR_WIRE_IOV_OFFSET, - 0, + kernelView.setBigInt64( + CH_ARGS + 2 * CH_ARG_SIZE, + BigInt(flags), true, ); - } else { - lease.writeAddress( - CH_DATA + KERNEL_MSGHDR_WIRE_IOV_OFFSET, - CH_DATA + layout.iovecOffset, - layout.iovecBytes, - "u32-le", + }, + (lease) => { + const kernelView = lease.dataView(0, CH_DATA); + const kernelMessage = lease.dataView( + CH_DATA, + STRUCT_SIZE_KERNEL_MSGHDR_WIRE, ); - } - kernelMessage.setUint32( - KERNEL_MSGHDR_WIRE_IOVLEN_OFFSET, - layout.iovecCount, - true, - ); - if (layout.controlOffset === 0) { - kernelMessage.setUint32( - KERNEL_MSGHDR_WIRE_CONTROL_OFFSET, - 0, + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + const errVal = kernelView.getUint32(CH_ERRNO, true); + if (retVal < 0) { + return { + retVal, + errVal, + nameLength: 0, + controlLength: 0, + messageFlags: 0, + payload: new Uint8Array(0), + name: new Uint8Array(0), + control: new Uint8Array(0), + }; + } + const nameLength = kernelMessage.getUint32( + KERNEL_MSGHDR_WIRE_NAMELEN_OFFSET, true, ); - } else { - lease.writeAddress( - CH_DATA + KERNEL_MSGHDR_WIRE_CONTROL_OFFSET, - CH_DATA + layout.controlOffset, - layout.controlCapacity, - "u32-le", + const kernelControlLength = kernelMessage.getUint32( + KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET, + true, ); - } - kernelMessage.setUint32( - KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET, - layout.controlCapacity, - true, - ); - kernelMessage.setUint32(KERNEL_MSGHDR_WIRE_FLAGS_OFFSET, 0, true); - - const kernelView = lease.dataView(0, CH_DATA); - kernelView.setUint32(CH_SYSCALL, SYS_RECVMSG, true); - for (let index = 0; index < CH_ARGS_COUNT; index++) { - kernelView.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, 0n, true); - } - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - lease.writeAddress( - CH_ARGS + CH_ARG_SIZE, - CH_DATA, - STRUCT_SIZE_KERNEL_MSGHDR_WIRE, - "u32-to-u64-le", - ); - kernelView.setBigInt64( - CH_ARGS + 2 * CH_ARG_SIZE, - BigInt(flags), - true, - ); - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; - try { - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - channel.pid, - ]); - } finally { - this.currentHandlePid = 0; - } - - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); - if (retVal < 0) { + const messageFlags = kernelMessage.getUint32( + KERNEL_MSGHDR_WIRE_FLAGS_OFFSET, + true, + ); + // WHY: MSG_TRUNC deliberately reports the complete datagram length + // even though only the bounded iovec prefix exists to copy back. + if ( + !Number.isSafeInteger(retVal) || + ( + retVal > message.iovecs.totalData && + (flags & SOCKET_MSG_TRUNC) === 0 + ) || + nameLength > KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES || + kernelControlLength > layout.controlCapacity + ) { + throw new KernelScratchError( + "kernel returned data outside recvmsg capacities", + EIO, + ); + } + const payloadLength = Math.min(retVal, message.iovecs.totalData); + const payload = payloadLength > 0 + ? lease.copyOut(CH_DATA + layout.dataOffset, payloadLength) + : new Uint8Array(0); + const name = message.name.length > 0 && nameLength > 0 + ? lease.copyOut( + CH_DATA + layout.nameOffset, + Math.min(message.name.length, nameLength), + ) + : new Uint8Array(0); + const nativeControl = kernelControlLength > 0 + ? this.kernelControlToNative( + lease.copyOut( + CH_DATA + layout.controlOffset, + kernelControlLength, + ), + message, + ) + : { bytes: new Uint8Array(0), length: 0 }; return { retVal, errVal, - nameLength: 0, - controlLength: 0, - messageFlags: 0, - payload: new Uint8Array(0), - name: new Uint8Array(0), - control: new Uint8Array(0), + nameLength, + controlLength: nativeControl.length, + messageFlags, + payload, + name, + control: nativeControl.bytes, }; - } - const nameLength = kernelMessage.getUint32( - KERNEL_MSGHDR_WIRE_NAMELEN_OFFSET, - true, - ); - const kernelControlLength = kernelMessage.getUint32( - KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET, - true, - ); - const messageFlags = kernelMessage.getUint32( - KERNEL_MSGHDR_WIRE_FLAGS_OFFSET, - true, - ); - // WHY: MSG_TRUNC deliberately reports the complete datagram length - // even though only the bounded iovec prefix exists to copy back. - if ( - !Number.isSafeInteger(retVal) || - ( - retVal > message.iovecs.totalData && - (flags & SOCKET_MSG_TRUNC) === 0 - ) || - kernelControlLength > layout.controlCapacity - ) { - throw new KernelScratchError( - "kernel returned data outside recvmsg capacities", - EIO, - ); - } - const payloadLength = Math.min(retVal, message.iovecs.totalData); - const payload = payloadLength > 0 - ? lease.copyOut(CH_DATA + layout.dataOffset, payloadLength) - : new Uint8Array(0); - const name = message.name.length > 0 && nameLength > 0 - ? lease.copyOut( - CH_DATA + layout.nameOffset, - Math.min(message.name.length, nameLength), - ) - : new Uint8Array(0); - const nativeControl = kernelControlLength > 0 - ? this.kernelControlToNative( - lease.copyOut( - CH_DATA + layout.controlOffset, - kernelControlLength, - ), - message, - ) - : { bytes: new Uint8Array(0), length: 0 }; - return { - retVal, - errVal, - nameLength, - controlLength: nativeControl.length, - messageFlags, - payload, - name, - control: nativeControl.bytes, - }; - }); + }, + retainedSnapshot?.retryToken ?? 0n, + ); + result = dispatched.value ?? { + retVal: -1, + errVal: dispatched.errno, + nameLength: 0, + controlLength: 0, + messageFlags: 0, + payload: new Uint8Array(0), + name: new Uint8Array(0), + control: new Uint8Array(0), + }; } catch (error) { - this.rejectScratchTransfer(channel, error); + this.#rethrowKernelEntryFatal(error); + if ( + this.#kernelFatalError !== null + || error instanceof KernelTransferExecuteTrapError + || error instanceof KernelTaskBindingError + || error instanceof KernelReentrantEntryError + ) { + throw error; + } + this.#rejectScratchTransfer(channel, error, entry); return; } - if (this.finishSignalTermination(channel)) return; - const { retVal, errVal, @@ -12342,20 +20281,55 @@ export class CentralizedKernelWorker { controlLength, messageFlags, } = result; + if ( + !retainedSnapshot + && retVal === -1 + && errVal === EAGAIN + ) { + // See sendmsg above: freeze the exact-OFD timeout only when a retry is + // actually possible, while the first EAGAIN entry still owns it. + snapshot = { + ...snapshot, + ...this.#captureBlockingRetryDisposition( + channel, + SYS_RECVMSG, + origArgs, + entry, + syscallHasMsgDontwait(SYS_RECVMSG, origArgs), + ), + }; + } + const deliveredSignal = this.#dequeueSignalForDelivery( + channel, + entry, + snapshot.applicableSocketTimeoutMs > 0, + ); + if (this.#finishSignalTermination(channel, entry)) return; if (retVal === -1 && errVal === EAGAIN) { - this.handleBlockingRetry(channel, SYS_RECVMSG, origArgs); + if (!this.#rememberBlockingRetrySnapshot(channel, snapshot, entry)) { + return; + } + this.handleBlockingRetry( + channel, + SYS_RECVMSG, + origArgs, + [], + entry, + retainedSnapshot !== undefined, + deliveredSignal, + ); return; } if (retVal >= 0) { const publishMemory = new Uint8Array(channel.memory.buffer); let payloadOffset = 0; - for (const entry of message.iovecs.entries) { + for (const iovec of message.iovecs.entries) { if (payloadOffset >= result.payload.length) break; - if (entry.len === 0) continue; + if (iovec.len === 0) continue; const copyLength = Math.min( - entry.len, + iovec.len, result.payload.length - payloadOffset, ); publishMemory.set( @@ -12363,7 +20337,7 @@ export class CentralizedKernelWorker { payloadOffset, payloadOffset + copyLength, ), - entry.base, + iovec.base, ); payloadOffset += copyLength; } @@ -12380,11 +20354,18 @@ export class CentralizedKernelWorker { message.messagePointer, processLayout.size, ); - processView.setUint32( - processLayout.nameLengthOffset, - nameLength, - true, - ); + if (message.namePresent) { + // msg_namelen is a value-result field only when msg_name was supplied. + // An absent address request must preserve even a stale nonzero value. + processView.setUint32( + processLayout.nameLengthOffset, + nameLength, + true, + ); + } + // musl's msg_controllen is socklen_t on both data models. On wasm64 the + // next four bytes are ABI padding, not the high half of a size_t; retain + // them exactly as supplied by the caller. processView.setUint32( processLayout.controlLengthOffset, controlLength, @@ -12397,21 +20378,115 @@ export class CentralizedKernelWorker { ); } - this.completeChannel(channel, SYS_RECVMSG, origArgs, undefined, retVal, errVal); + this.completeChannel( + channel, + SYS_RECVMSG, + origArgs, + undefined, + retVal, + errVal, + [], + undefined, + entry, + ); } // ----------------------------------------------------------------------- // Fork/exec/clone/exit handling // ----------------------------------------------------------------------- + #completeForkWithinKernelEntry( + channel: ChannelInfo, + origArgs: number[], + retVal: number, + errno: number, + entry: KernelWorkerEntryContext, + ): void { + this.completeChannel( + channel, + SYS_FORK, + origArgs, + undefined, + retVal, + errno, + [], + undefined, + entry, + ); + } + + #rollbackForkWithinKernelEntry( + channel: ChannelInfo, + origArgs: number[], + childPid: number, + cause: unknown, + entry: KernelWorkerEntryContext, + ): void { + if (cause !== undefined) { + entry.deferObserverEffect(() => { + console.error( + `[kernel-worker] fork worker launch failed: ${String(cause)}`, + ); + return undefined; + }); + } + try { + this.#rollbackChildHostRegistrationWithinKernelEntry(childPid, entry); + } catch (hostRollbackError) { + this.#rethrowKernelEntryFatal(hostRollbackError); + entry.deferObserverEffect(() => { + console.error( + `[kernel-worker] fork child ${childPid} host rollback failed:`, + hostRollbackError, + ); + return undefined; + }); + } + try { + this.#removeFromKernelProcessTableWithinKernelEntry(childPid, entry); + } catch (rollbackError) { + this.#rethrowKernelEntryFatal(rollbackError); + this.#terminateForKernelProtocolFailureWithinKernelEntry( + channel, + `could not roll back fork child ${childPid}: ${ + rollbackError instanceof Error + ? rollbackError.message + : String(rollbackError) + }`, + entry, + ); + return; + } + if ( + this.#isAsyncChannelProcessActiveWithinKernelEntry(channel, entry) + ) { + const errno = cause instanceof ProcessMemoryRetirementBacklogError + ? 11 // EAGAIN: bounded retired-memory debt denied admission. + : 12; // ENOMEM: worker launch or ordinary allocation failure. + this.#completeForkWithinKernelEntry( + channel, + origArgs, + -1, + errno, + entry, + ); + } + } + /** * Handle SYS_FORK/SYS_VFORK: clone the Process in the kernel's ProcessTable, * then call the onFork callback to spawn the child Worker. */ - private handleFork(channel: ChannelInfo, _origArgs: number[]): void { + private handleFork( + channel: ChannelInfo, + _origArgs: number[], + entry: KernelWorkerEntryContext, + ): void { if (!this.callbacks.onFork) { // No fork handler — return -ENOSYS - this.completeChannel(channel, SYS_FORK, _origArgs, undefined, -1, 38); + this.#completeForkWithinKernelEntry( + channel, _origArgs, -1, 38, entry, + ); return; } @@ -12422,9 +20497,21 @@ export class CentralizedKernelWorker { // kernel_fork_process avoids leaking a committed child/zombie or reserved // pthread slot when fork must report EIO. this.syncAnonymousSharedMappingsFromProcess(channel, { force: true }); - this.syncFileSharedMappingsFromProcess(channel, { force: true }); - if (!this.syncSysvShmMappingsFromProcess(channel, { force: true })) { - this.completeChannel(channel, SYS_FORK, _origArgs, undefined, -1, EIO); + this.syncFileSharedMappingsFromProcess( + channel, + { force: true }, + entry, + ); + if ( + !this.syncSysvShmMappingsFromProcess( + channel, + { force: true }, + entry, + ) + ) { + this.#completeForkWithinKernelEntry( + channel, _origArgs, -1, EIO, entry, + ); return; } @@ -12453,16 +20540,44 @@ export class CentralizedKernelWorker { slotLen: callerSlotLen, } : { kind: "main", forkBufAddr }; + const guestPointerWidth = this.getPtrWidth(parentPid); + let callerSlotReservation: + | { pointer: number; length: number; end: number } + | null = null; + if (continuation.kind === "thread") { + try { + // WHY: reject impossible guest geometry before Rust allocates the + // child PID. The later reservation export receives this same checked + // range, so it cannot reinterpret a lossy host number after fork. + callerSlotReservation = checkedWasmAddressRange( + continuation.slotStart, + continuation.slotLen, + guestPointerWidth, + "fork child pthread control slot", + ); + } catch { + this.#completeForkWithinKernelEntry( + channel, + _origArgs, + -1, + EFAULT, + entry, + ); + return; + } + } // Fork atomically allocates the child PID and inserts its Process in Rust. // The host receives that identity only after the authoritative state exists. - const kernelForkProcess = this.kernelInstance!.exports.kernel_fork_process as + const kernelForkProcess = this.#kernelInstanceForEntry(entry).exports.kernel_fork_process as (parentPid: number, callerTid: number) => number; const forkResult = kernelForkProcess(parentPid, callerTid); if (forkResult <= 0) { // Fork failed in kernel (e.g., ESRCH, ENOMEM) const errno = forkResult < 0 ? (-forkResult) >>> 0 : EIO; - this.completeChannel(channel, SYS_FORK, _origArgs, undefined, -1, errno); + this.#completeForkWithinKernelEntry( + channel, _origArgs, -1, errno, entry, + ); return; } const childPid = forkResult >>> 0; @@ -12471,34 +20586,48 @@ export class CentralizedKernelWorker { // child resumes from the fork point and never checks this flag. Without // clearing it, a nested fork() from the child would hit the isForkChild // check above and return 0 instead of creating a grandchild. - const clearForkChild = this.kernelInstance!.exports.kernel_clear_fork_child as + const clearForkChild = this.#kernelInstanceForEntry(entry).exports.kernel_clear_fork_child as ((pid: number) => number) | undefined; if (clearForkChild) clearForkChild(childPid); if (continuation.kind === "thread") { try { - this.reserveHostRegionAt( + this.#reserveHostRegionAtWithinKernelEntry( childPid, - continuation.slotStart, - continuation.slotLen, + callerSlotReservation!, + guestPointerWidth, + entry, ); } catch (err) { + this.#rethrowKernelEntryFatal(err); try { - this.removeFromKernelProcessTable(childPid); + this.#removeFromKernelProcessTableWithinKernelEntry( + childPid, + entry, + ); } catch (rollbackError) { - this.terminateForKernelProtocolFailure( + this.#rethrowKernelEntryFatal(rollbackError); + this.#terminateForKernelProtocolFailureWithinKernelEntry( channel, `could not roll back fork child ${childPid}: ${ rollbackError instanceof Error ? rollbackError.message : String(rollbackError) }`, + entry, ); return; } const message = err instanceof Error ? err.message : String(err); - console.error(`[kernel-worker] fork child slot reservation failed: ${message}`); - this.completeChannel(channel, SYS_FORK, _origArgs, undefined, -1, 12); + entry.deferObserverEffect(() => { + console.error( + `[kernel-worker] fork child slot reservation failed: ${message}`, + ); + return undefined; + }); + this.#completeForkWithinKernelEntry( + channel, _origArgs, -1, 12, entry, + ); return; } } @@ -12506,76 +20635,95 @@ export class CentralizedKernelWorker { // The kernel child is real before its host Worker launches. Install its // host-only fd mirrors synchronously so a sibling exec cannot remove the // parent's last listener and close the shared backend during onFork's - // async worker setup. pickListenerTarget still ignores the child until - // onFork registers its process memory. - const rollbackFork = (err?: unknown) => { - if (err !== undefined) { - console.error(`[kernel-worker] fork worker launch failed: ${String(err)}`); - } + // async worker setup. Exact listener selection still ignores the child + // until onFork registers its process memory. + try { + this.inheritHostFdMirrors(parentPid, childPid, entry); + } catch (err) { + this.#rethrowKernelEntryFatal(err); + this.#rollbackForkWithinKernelEntry( + channel, + _origArgs, + childPid, + err, + entry, + ); + return; + } + + entry.deferProtocolTransactionStart(() => { + let launch: Promise; try { - this.rollbackChildHostRegistration(childPid); - } catch (hostRollbackError) { - console.error( - `[kernel-worker] fork child ${childPid} host rollback failed:`, - hostRollbackError, + launch = this.#resolvePromise( + this.callbacks.onFork!({ + parentPid, + childPid, + parentMemory: channel.memory, + continuation, + }), + ); + } catch (cause) { + this.#runOrDeferChannelKernelEntry( + channel, + "fork launch failure", + (rollbackEntry) => { + this.#rollbackForkWithinKernelEntry( + channel, + _origArgs, + childPid, + cause, + rollbackEntry, + ); + return undefined; + }, ); + return undefined; } - try { - this.removeFromKernelProcessTable(childPid); - } catch (rollbackError) { - this.terminateForKernelProtocolFailure( + this.#continuePromise(launch, () => { + this.#runOrDeferChannelKernelEntry( channel, - `could not roll back fork child ${childPid}: ${ - rollbackError instanceof Error - ? rollbackError.message - : String(rollbackError) - }`, + "fork launch completion", + (completionEntry) => { + this.#finalizePendingChildTerminationWithinKernelEntry( + childPid, + completionEntry, + ); + if ( + !this.#isAsyncChannelProcessActiveWithinKernelEntry( + channel, + completionEntry, + ) + ) { + return undefined; + } + this.#completeForkWithinKernelEntry( + channel, + _origArgs, + childPid, + 0, + completionEntry, + ); + return undefined; + }, ); - return; - } - if (this.isAsyncChannelProcessActive(channel)) { - const errno = err instanceof ProcessMemoryRetirementBacklogError - ? 11 // EAGAIN - : 12; // ENOMEM - this.completeChannel( + }, (cause) => { + this.#runOrDeferChannelKernelEntry( channel, - SYS_FORK, - _origArgs, - undefined, - -1, - errno, + "fork launch rejection", + (rollbackEntry) => { + this.#rollbackForkWithinKernelEntry( + channel, + _origArgs, + childPid, + cause, + rollbackEntry, + ); + return undefined; + }, ); - } - }; - - let launch: Promise; - try { - this.inheritHostFdMirrors(parentPid, childPid); - launch = Promise.resolve( - this.callbacks.onFork({ - parentPid, - childPid, - parentMemory: channel.memory, - continuation, - }), - ); - } catch (err) { - rollbackFork(err); - return; - } - - // Call the async fork handler to spawn child Worker. - launch.then((_childChannelOffsets) => { - this.finalizePendingChildTermination(childPid); - - // A sibling may have committed exec while the child worker launched. - // The child is already real and still inherits host mirrors; only the - // discarded caller's channel completion must be suppressed. - if (!this.isAsyncChannelProcessActive(channel)) return; - - // Complete parent's channel with child PID - this.completeChannel(channel, SYS_FORK, _origArgs, undefined, childPid, 0); - }).catch(rollbackFork); + }); + return undefined; + }); } /** @@ -12601,7 +20749,11 @@ export class CentralizedKernelWorker { * descriptor is rolled back via `kernel_remove_process` so the spawn * attempt leaves no trace. */ - private handleSpawn(channel: ChannelInfo, origArgs: number[]): void { + #handleSpawn( + channel: ChannelInfo, + origArgs: number[], + entry: KernelWorkerEntryContext, + ): void { const parentPid = channel.pid; const callerTid = this.guestTidForChannel(channel); const pathPtr = origArgs[0]; @@ -12611,7 +20763,10 @@ export class CentralizedKernelWorker { const pidOutPtr = origArgs[4]; if (!this.callbacks.onSpawn || !this.callbacks.onResolveSpawn) { - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, 38); // ENOSYS + this.completeChannel( + channel, SYS_SPAWN, origArgs, undefined, -1, 38, + [], undefined, entry, + ); // ENOSYS return; } @@ -12623,7 +20778,10 @@ export class CentralizedKernelWorker { !Number.isSafeInteger(blobLen) || blobLen <= 0 ) { - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, EINVAL); + this.completeChannel( + channel, SYS_SPAWN, origArgs, undefined, -1, EINVAL, + [], undefined, entry, + ); return; } if (pathLen >= POSIX_PATH_MAX_BYTES) { @@ -12634,11 +20792,17 @@ export class CentralizedKernelWorker { undefined, -1, ENAMETOOLONG, + [], + undefined, + entry, ); return; } if (blobLen > SPAWN_BLOB_MAX_BYTES) { - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, E2BIG); + this.completeChannel( + channel, SYS_SPAWN, origArgs, undefined, -1, E2BIG, + [], undefined, entry, + ); return; } let checkedPathPtr = pathPtr; @@ -12668,7 +20832,10 @@ export class CentralizedKernelWorker { ).pointer; } } catch { - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, EFAULT); + this.completeChannel( + channel, SYS_SPAWN, origArgs, undefined, -1, EFAULT, + [], undefined, entry, + ); return; } @@ -12682,7 +20849,7 @@ export class CentralizedKernelWorker { } const rawPath = path; if (path && !path.startsWith("/")) { - path = this.resolveExecPathAgainstCwd(parentPid, path); + path = this.resolveExecPathAgainstCwd(parentPid, path, entry); } // .slice copies into a regular ArrayBuffer (TextDecoder rejects SAB views). @@ -12715,6 +20882,9 @@ export class CentralizedKernelWorker { undefined, -1, errno, + [], + undefined, + entry, ); return; } @@ -12731,6 +20901,9 @@ export class CentralizedKernelWorker { undefined, -1, -metadataResult, + [], + undefined, + entry, ); return; } @@ -12757,31 +20930,86 @@ export class CentralizedKernelWorker { return this.callbacks.onResolveSpawn!(rawPath, argv); }; - resolveSpawnProgram().then((resolved) => { - if (!this.isAsyncChannelProcessActive(channel)) return; - if (!resolved) { - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, 2); // ENOENT - return; - } - if (isSpawnResolveError(resolved)) { - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, resolved.errno >>> 0); - return; - } - this.handleSpawnAfterResolve( - channel, - origArgs, - parentPid, - callerTid, - checkedPidOutPtr, - blobBytes, - blobLen, - resolved, - envp, - ); - }).catch((err) => { - if (!this.isAsyncChannelProcessActive(channel)) return; - console.error(`[kernel] spawn resolve error for parent ${parentPid}:`, err); - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, 5); // EIO + entry.deferProtocolTransactionStart(() => { + const resolution = resolveSpawnProgram(); + this.#continuePromise(resolution, (resolved) => { + this.#runOrDeferChannelKernelEntry( + channel, + "spawn program resolution", + (resolutionEntry) => { + if ( + !this.#isAsyncChannelProcessActiveWithinKernelEntry( + channel, + resolutionEntry, + ) + ) { + return undefined; + } + if (!resolved) { + this.completeChannel( + channel, SYS_SPAWN, origArgs, undefined, -1, 2, + [], undefined, resolutionEntry, + ); // ENOENT + return undefined; + } + if (isSpawnResolveError(resolved)) { + this.completeChannel( + channel, + SYS_SPAWN, + origArgs, + undefined, + -1, + resolved.errno >>> 0, + [], + undefined, + resolutionEntry, + ); + return undefined; + } + this.#handleSpawnAfterResolve( + channel, + origArgs, + parentPid, + callerTid, + checkedPidOutPtr, + blobBytes, + blobLen, + resolved, + envp, + resolutionEntry, + ); + return undefined; + }, + ); + }, (err) => { + this.#runOrDeferChannelKernelEntry( + channel, + "spawn program resolution failure", + (failureEntry) => { + if ( + !this.#isAsyncChannelProcessActiveWithinKernelEntry( + channel, + failureEntry, + ) + ) { + return undefined; + } + failureEntry.deferObserverEffect(() => { + console.error( + `[kernel] spawn resolve error for parent ${parentPid}:`, + err, + ); + return undefined; + }); + this.completeChannel( + channel, SYS_SPAWN, origArgs, undefined, -1, 5, + [], undefined, failureEntry, + ); // EIO + return undefined; + }, + ); + }); + return undefined; }); } @@ -12790,10 +21018,11 @@ export class CentralizedKernelWorker { * validated, compiled program. Now safe to ask the kernel to build the * child (which will apply file_actions exactly once). */ - private beginLargeSpawnScratch( + #beginLargeSpawnScratch( blobLen: number, + entry: KernelWorkerEntryContext, ): { reservation: ReservedSpawnScratch | null; errno: number } { - const kernelExports = this.kernelInstance!.exports; + const kernelExports = this.#kernelInstanceForEntry(entry).exports; const begin = kernelExports.kernel_spawn_scratch_begin as ((minimumCapacity: KernelPointer) => bigint) | undefined; const pointer = kernelExports.kernel_spawn_scratch_pointer as @@ -12832,17 +21061,27 @@ export class CentralizedKernelWorker { } token = rawToken; const region = reserveKernelScratchRegion( - this.kernelMemory!, + this.#kernelMemory!, () => ({ pointer: pointer(rawToken), capacity: capacity(rawToken), }), blobLen, - this.kernel.getKernelPtrWidth(), + this.#kernelPointerWidth, "kernel reserved spawn scratch", + // Bind allocator provenance to the same persistent gated generation as + // every I/O reservation. A scoped entry façade cannot outlive this call. + this.#kernelInstance!, ); return { reservation: { region, token }, errno: 0 }; - } catch { + } catch (error) { + this.#rethrowKernelEntryFatal(error); + if (this.#kernelFatalError !== null) { + throw new KernelTransferExecuteTrapError( + "kernel spawn reservation query trapped", + error, + ); + } // WHY: once begin returns a token, even an invalid allocator pointer or // capacity must flow through the caller's unconditional cancellation. // Returning only an errno here would lose the sole cleanup authority. @@ -12853,10 +21092,11 @@ export class CentralizedKernelWorker { } } - private cancelLargeSpawnScratch( + #cancelLargeSpawnScratch( token: bigint, + entry: KernelWorkerEntryContext, ): "cancelled" | "already-consumed" { - const cancel = this.kernelInstance!.exports.kernel_spawn_scratch_cancel as + const cancel = this.#kernelInstanceForEntry(entry).exports.kernel_spawn_scratch_cancel as ((token: bigint) => number) | undefined; if (typeof cancel !== "function") { throw new KernelScratchError( @@ -12879,7 +21119,82 @@ export class CentralizedKernelWorker { ); } - private handleSpawnAfterResolve( + #completeSpawnWithinKernelEntry( + channel: ChannelInfo, + origArgs: number[], + retVal: number, + errno: number, + entry: KernelWorkerEntryContext, + ): void { + this.completeChannel( + channel, + SYS_SPAWN, + origArgs, + undefined, + retVal, + errno, + [], + undefined, + entry, + ); + } + + #rollbackSpawnWithinKernelEntry( + channel: ChannelInfo, + origArgs: number[], + parentPid: number, + childPid: number, + errno: number, + cause: unknown, + entry: KernelWorkerEntryContext, + ): void { + if (cause !== undefined) { + entry.deferObserverEffect(() => { + console.error(`[kernel] spawn error for parent ${parentPid}:`, cause); + return undefined; + }); + } + try { + this.#rollbackChildHostRegistrationWithinKernelEntry(childPid, entry); + } catch (hostRollbackError) { + this.#rethrowKernelEntryFatal(hostRollbackError); + entry.deferObserverEffect(() => { + console.error( + `[kernel-worker] spawn child ${childPid} host rollback failed:`, + hostRollbackError, + ); + return undefined; + }); + } + try { + this.#removeFromKernelProcessTableWithinKernelEntry(childPid, entry); + } catch (rollbackError) { + this.#rethrowKernelEntryFatal(rollbackError); + this.#terminateForKernelProtocolFailureWithinKernelEntry( + channel, + `could not roll back spawn child ${childPid}: ${ + rollbackError instanceof Error + ? rollbackError.message + : String(rollbackError) + }`, + entry, + ); + return; + } + if ( + this.#isAsyncChannelProcessActiveWithinKernelEntry(channel, entry) + ) { + this.#completeSpawnWithinKernelEntry( + channel, + origArgs, + -1, + errno, + entry, + ); + } + } + + #handleSpawnAfterResolve( channel: ChannelInfo, origArgs: number[], parentPid: number, @@ -12889,6 +21204,7 @@ export class CentralizedKernelWorker { blobLen: number, program: ResolvedSpawnProgram, envp: string[], + entry: KernelWorkerEntryContext, ): void { // ── Copy blob to kernel scratch ── if ( @@ -12897,12 +21213,18 @@ export class CentralizedKernelWorker { blobLen > SPAWN_BLOB_MAX_BYTES ) { const errno = blobLen > SPAWN_BLOB_MAX_BYTES ? E2BIG : EINVAL; - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, errno); + this.#completeSpawnWithinKernelEntry( + channel, + origArgs, + -1, + errno, + entry, + ); return; } let result = -EIO; if (blobLen <= SCRATCH_SIZE) { - const kernelSpawn = this.kernelInstance!.exports.kernel_spawn_process as + const kernelSpawn = this.#kernelInstanceForEntry(entry).exports.kernel_spawn_process as | (( parentPid: number, callerTid: number, @@ -12911,50 +21233,47 @@ export class CentralizedKernelWorker { ) => number) | undefined; if (typeof kernelSpawn !== "function") { - this.completeChannel( - channel, - SYS_SPAWN, - origArgs, - undefined, - -1, - EIO, + this.#completeSpawnWithinKernelEntry( + channel, origArgs, -1, EIO, entry, ); return; } try { - result = this.requireMainScratchRegion().withLease((scratch) => { + result = this.#requireMainScratchRegion().withLease((scratch) => { scratch.copyFrom(blobBytes, 0, 0, blobLen); - return scratch.invokeKernelExport("kernel_spawn_process", [ - parentPid, - callerTid, - scratch.exportPointer(0, blobLen), - this.toKernelPtr(blobLen), - ]); + return this.#invokeEntryScratchExport( + entry, + scratch, + "kernel_spawn_process", + [ + parentPid, + callerTid, + scratch.exportPointer(0, blobLen), + this.toKernelPtr(blobLen), + ], + ); }); - } catch { - this.completeChannel( - channel, - SYS_SPAWN, - origArgs, - undefined, - -1, - EIO, + } catch (error) { + this.#rethrowKernelEntryFatal(error); + if (this.#kernelFatalError !== null) { + // The scoped export exception already made the kernel generation + // unusable. Do not publish EIO or relisten this guest while fatal + // shutdown is unwinding. + throw error; + } + this.#completeSpawnWithinKernelEntry( + channel, origArgs, -1, EIO, entry, ); return; } } else { - if (this.largeSpawnScratchInUse) { - this.completeChannel( - channel, - SYS_SPAWN, - origArgs, - undefined, - -1, - EBUSY, + if (this.#largeSpawnScratchInUse) { + this.#completeSpawnWithinKernelEntry( + channel, origArgs, -1, EBUSY, entry, ); return; } - const reservedSpawn = this.kernelInstance!.exports + const reservedSpawn = this.#kernelInstanceForEntry(entry).exports .kernel_spawn_reserved_process as | (( parentPid: number, @@ -12964,23 +21283,20 @@ export class CentralizedKernelWorker { ) => number) | undefined; if (typeof reservedSpawn !== "function") { - this.completeChannel( - channel, - SYS_SPAWN, - origArgs, - undefined, - -1, - EIO, + this.#completeSpawnWithinKernelEntry( + channel, origArgs, -1, EIO, entry, ); return; } - this.largeSpawnScratchInUse = true; + this.#largeSpawnScratchInUse = true; let reservation: ReservedSpawnScratch | null = null; let operationErrno: number | null = null; - let cleanupFailure: unknown = null; + let commitStarted = false; + let commitReturned = false; + let fatalError: KernelTransferExecuteTrapError | null = null; try { - const begun = this.beginLargeSpawnScratch(blobLen); + const begun = this.#beginLargeSpawnScratch(blobLen, entry); reservation = begun.reservation; if (!reservation?.region) { operationErrno = begun.errno; @@ -12989,12 +21305,14 @@ export class CentralizedKernelWorker { const activeToken = reservation.token; result = activeRegion.withLease((scratch) => { scratch.copyFrom(blobBytes, 0, 0, blobLen); + commitStarted = true; const spawnResult = reservedSpawn( parentPid, callerTid, activeToken, this.toKernelPtr(blobLen), ); + commitReturned = true; if ( !Number.isInteger(spawnResult) || spawnResult < -0x8000_0000 @@ -13010,55 +21328,73 @@ export class CentralizedKernelWorker { return spawnResult; }); } - } catch { - operationErrno = EIO; + } catch (error) { + if (isKernelExportFailure(error)) { + fatalError = new KernelTransferExecuteTrapError( + "kernel spawn reservation export trapped", + error, + ); + } else if (error instanceof KernelTransferExecuteTrapError) { + fatalError = error; + } else if ( + this.#kernelFatalError !== null + || (commitStarted && !commitReturned) + ) { + fatalError = new KernelTransferExecuteTrapError( + "kernel reserved spawn export trapped", + error, + ); + } else { + operationErrno = EIO; + } } finally { if (reservation) { // WHY: the Rust Vec may move on the next reservation. Revoke the // one-shot host region whether this token was consumed or cancelled // so no retained object can later lease the stale pointer. - reservation.region?.revoke(); try { - // WHY: do not infer reservation state from the spawn errno. Rust - // commit and cancel take a blocking, no-import lock, and tokens are - // never reused. Cancelling after every return therefore either - // releases an unconsumed matching token or harmlessly gets EINVAL - // for a token commit already consumed. - const disposition = this.cancelLargeSpawnScratch(reservation.token); - if (result > 0 && disposition === "cancelled") { - throw new KernelScratchError( - "kernel created a child without consuming its spawn reservation", - EIO, + reservation.region?.revoke(); + } catch (error) { + fatalError ??= new KernelTransferExecuteTrapError( + "kernel spawn reservation lease could not be revoked", + error, + ); + } + if (fatalError === null) { + try { + // WHY: do not infer reservation state from the spawn errno. Rust + // commit and cancel take a blocking, no-import lock, and tokens + // are never reused. Cancelling after every ordinary return + // either releases an unconsumed token or observes that commit + // already consumed it. + const disposition = this.#cancelLargeSpawnScratch( + reservation.token, + entry, + ); + if (result > 0 && disposition === "cancelled") { + throw new KernelScratchError( + "kernel created a child without consuming its spawn reservation", + EIO, + ); + } + } catch (error) { + this.#rethrowKernelEntryFatal(error); + fatalError = new KernelTransferExecuteTrapError( + "kernel spawn reservation could not be settled", + error, ); } - } catch (error) { - cleanupFailure = error; } } - // A cleanup protocol failure may have left writable authority live. - // Keep the host guard set so no later operation can replace its bytes. - if (cleanupFailure === null) this.largeSpawnScratchInUse = false; + // A fatal path leaves the guard set. No later operation may replace + // bytes whose Rust reservation state is uncertain. + if (fatalError === null) this.#largeSpawnScratchInUse = false; } - if (cleanupFailure !== null) { - this.terminateForKernelProtocolFailure( - channel, - `could not settle reserved spawn scratch: ${ - cleanupFailure instanceof Error - ? cleanupFailure.message - : String(cleanupFailure) - }`, - ); - return; - } + if (fatalError !== null) throw fatalError; if (operationErrno !== null) { - this.completeChannel( - channel, - SYS_SPAWN, - origArgs, - undefined, - -1, - operationErrno, + this.#completeSpawnWithinKernelEntry( + channel, origArgs, -1, operationErrno, entry, ); return; } @@ -13066,71 +21402,124 @@ export class CentralizedKernelWorker { if (result <= 0) { const errno = result < 0 ? (-result) >>> 0 : EIO; - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, errno); + this.#completeSpawnWithinKernelEntry( + channel, origArgs, -1, errno, entry, + ); return; } const childPid = result >>> 0; - const rollbackSpawn = (errno: number, err?: unknown) => { - if (err !== undefined) { - console.error(`[kernel] spawn error for parent ${parentPid}:`, err); - } - try { - this.rollbackChildHostRegistration(childPid); - } catch (hostRollbackError) { - console.error( - `[kernel-worker] spawn child ${childPid} host rollback failed:`, - hostRollbackError, - ); - } - try { - this.removeFromKernelProcessTable(childPid); - } catch (rollbackError) { - this.terminateForKernelProtocolFailure( - channel, - `could not roll back spawn child ${childPid}: ${ - rollbackError instanceof Error - ? rollbackError.message - : String(rollbackError) - }`, - ); - return; - } - if (this.isAsyncChannelProcessActive(channel)) { - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, errno); - } - }; - // posix_spawn clones listener sockets after applying fd actions. Install // those mirrors before async Worker launch so parent exec cannot close the // shared backend. Epoll backing tables are not yet cloned by spawn_child, // so only listener mirrors are inherited here. - let launch: Promise; try { - this.inheritHostFdMirrors(parentPid, childPid, false); - launch = Promise.resolve( - this.callbacks.onSpawn!(parentPid, childPid, program, envp), - ); + this.inheritHostFdMirrors(parentPid, childPid, entry, false); } catch (err) { - rollbackSpawn(5, err); + this.#rethrowKernelEntryFatal(err); + this.#rollbackSpawnWithinKernelEntry( + channel, + origArgs, + parentPid, + childPid, + EIO, + err, + entry, + ); return; } - // ── Launch the worker async with the preflighted program bytes ── - launch.then((rc) => { - if (rc < 0) { - rollbackSpawn((-rc) >>> 0); - return; - } - this.finalizePendingChildTermination(childPid); - if (!this.isAsyncChannelProcessActive(channel)) return; - // Write the child pid through pid_out_ptr in caller memory. - if (pidOutPtr !== 0) { - new DataView(channel.memory.buffer).setInt32(pidOutPtr, childPid, true); + // Launching the Worker starts a host-owned asynchronous transaction after + // scope revocation. Its continuation retains only detached inputs and + // opens a new exact channel entry before consulting kernel liveness, + // rolling back, or publishing success. + entry.deferProtocolTransactionStart(() => { + let launch: Promise; + try { + launch = this.#resolvePromise( + this.callbacks.onSpawn!(parentPid, childPid, program, envp), + ); + } catch (cause) { + this.#runOrDeferChannelKernelEntry( + channel, + "spawn launch failure", + (rollbackEntry) => { + this.#rollbackSpawnWithinKernelEntry( + channel, + origArgs, + parentPid, + childPid, + EIO, + cause, + rollbackEntry, + ); + return undefined; + }, + ); + return undefined; } - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, 0, 0); - }).catch((err) => { - rollbackSpawn(5, err); // EIO + this.#continuePromise(launch, (rc) => { + this.#runOrDeferChannelKernelEntry( + channel, + "spawn launch completion", + (completionEntry) => { + if (rc < 0) { + this.#rollbackSpawnWithinKernelEntry( + channel, + origArgs, + parentPid, + childPid, + (-rc) >>> 0, + undefined, + completionEntry, + ); + return undefined; + } + this.#finalizePendingChildTerminationWithinKernelEntry( + childPid, + completionEntry, + ); + if ( + !this.#isAsyncChannelProcessActiveWithinKernelEntry( + channel, + completionEntry, + ) + ) { + return undefined; + } + if (pidOutPtr !== 0) { + new DataView(channel.memory.buffer) + .setInt32(pidOutPtr, childPid, true); + } + this.#completeSpawnWithinKernelEntry( + channel, + origArgs, + 0, + 0, + completionEntry, + ); + return undefined; + }, + ); + }, (cause) => { + this.#runOrDeferChannelKernelEntry( + channel, + "spawn launch rejection", + (rollbackEntry) => { + this.#rollbackSpawnWithinKernelEntry( + channel, + origArgs, + parentPid, + childPid, + EIO, + cause, + rollbackEntry, + ); + return undefined; + }, + ); + }); + return undefined; }); } @@ -13250,40 +21639,89 @@ export class CentralizedKernelWorker { return { errno: E2BIG }; } - /** Complete a failed async exec only if the old image is still Running. */ - private finishFailedExec( + /** Complete a failed async exec only if the exact old image remains live. */ + #finishFailedExecWithinKernelEntry( channel: ChannelInfo, syscallNr: number, origArgs: number[], errno: number, + entry: KernelWorkerEntryContext, ): void { - if (!this.isAsyncChannelProcessActive(channel)) return; - this.completeChannel(channel, syscallNr, origArgs, undefined, -1, errno); + if ( + !this.#isAsyncChannelProcessActiveWithinKernelEntry(channel, entry) + ) { + return; + } + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + errno, + [], + undefined, + entry, + ); } /** * Handle SYS_EXECVE: read path, argv, and envp from process memory, * then call the onExec callback to load the new program. */ - private handleExec(channel: ChannelInfo, origArgs: number[]): void { + private handleExec( + channel: ChannelInfo, + origArgs: number[], + entry: KernelWorkerEntryContext, + ): void { const processMem = new Uint8Array(channel.memory.buffer); // Read path (arg 0), argv (arg 1), envp (arg 2) from process memory const pw = this.getPtrWidth(channel.pid); const pathResult = this.readExecPathFromProcess(processMem, origArgs[0]); if ("errno" in pathResult) { - this.completeChannel(channel, SYS_EXECVE, origArgs, undefined, -1, pathResult.errno); + this.completeChannel( + channel, + SYS_EXECVE, + origArgs, + undefined, + -1, + pathResult.errno, + [], + undefined, + entry, + ); return; } let path = pathResult.value; const argvResult = this.readStringArrayFromProcess(processMem, origArgs[1], pw); const envResult = this.readStringArrayFromProcess(processMem, origArgs[2], pw); if ("errno" in argvResult) { - this.completeChannel(channel, SYS_EXECVE, origArgs, undefined, -1, argvResult.errno); + this.completeChannel( + channel, + SYS_EXECVE, + origArgs, + undefined, + -1, + argvResult.errno, + [], + undefined, + entry, + ); return; } if ("errno" in envResult) { - this.completeChannel(channel, SYS_EXECVE, origArgs, undefined, -1, envResult.errno); + this.completeChannel( + channel, + SYS_EXECVE, + origArgs, + undefined, + -1, + envResult.errno, + [], + undefined, + entry, + ); return; } const argv = argvResult.values; @@ -13292,11 +21730,21 @@ export class CentralizedKernelWorker { // Resolve relative exec paths against process CWD (not initial KERNEL_CWD). // Critical for posix_spawn with chdir file actions where child CWD != parent CWD. if (path && !path.startsWith("/")) { - path = this.resolveExecPathAgainstCwd(channel.pid, path); + path = this.resolveExecPathAgainstCwd(channel.pid, path, entry); } if (!this.callbacks.onExec) { - this.completeChannel(channel, SYS_EXECVE, origArgs, undefined, -1, 38); // ENOSYS + this.completeChannel( + channel, + SYS_EXECVE, + origArgs, + undefined, + -1, + 38, + [], + undefined, + entry, + ); // ENOSYS return; } @@ -13304,20 +21752,74 @@ export class CentralizedKernelWorker { // program doesn't exist, allowing posix_spawnp/execvpe PATH search to retry. // The exact kernel exec prepare/commit sequence and host teardown are // deferred until after onExec confirms the program exists (returns 0). + const pid = channel.pid; const callerTid = this.guestTidForChannel(channel); - this.callbacks.onExec(channel.pid, path, argv, envp, callerTid).then((result) => { - if (result < 0) { - // Exec failed (e.g. ENOENT) — process is still alive. - // Complete the channel so the calling process can handle the error - // (e.g., __execvpe tries the next PATH entry). - this.finishFailedExec(channel, SYS_EXECVE, origArgs, (-result) >>> 0); - } - // On success (result === 0), execve doesn't return — the Worker has been - // reinitialized with the new program via registerProcess. The old channel - // is dead (prepareProcessForExec removed it in onExec). - }).catch((err) => { - console.error(`[kernel] exec error for pid ${channel.pid}:`, err); - this.finishFailedExec(channel, SYS_EXECVE, origArgs, 5); // EIO + entry.deferProtocolTransactionStart(() => { + let transaction: Promise; + try { + transaction = this.#resolvePromise( + this.callbacks.onExec!(pid, path, argv, envp, callerTid), + ); + } catch (cause) { + this.#runOrDeferChannelKernelEntry( + channel, + "exec launch failure", + (failureEntry) => { + failureEntry.deferObserverEffect(() => { + console.error(`[kernel] exec error for pid ${pid}:`, cause); + return undefined; + }); + this.#finishFailedExecWithinKernelEntry( + channel, + SYS_EXECVE, + origArgs, + EIO, + failureEntry, + ); + return undefined; + }, + ); + return undefined; + } + this.#continuePromise(transaction, (result) => { + if (result >= 0) return; + // Exec failed (for example ENOENT), so the exact old mailbox is still + // live and may receive an error for execvpe's next PATH candidate. + this.#runOrDeferChannelKernelEntry( + channel, + "exec launch completion", + (completionEntry) => { + this.#finishFailedExecWithinKernelEntry( + channel, + SYS_EXECVE, + origArgs, + (-result) >>> 0, + completionEntry, + ); + return undefined; + }, + ); + }, (cause) => { + this.#runOrDeferChannelKernelEntry( + channel, + "exec launch rejection", + (failureEntry) => { + failureEntry.deferObserverEffect(() => { + console.error(`[kernel] exec error for pid ${pid}:`, cause); + return undefined; + }); + this.#finishFailedExecWithinKernelEntry( + channel, + SYS_EXECVE, + origArgs, + EIO, + failureEntry, + ); + return undefined; + }, + ); + }); + return undefined; }); } @@ -13325,19 +21827,28 @@ export class CentralizedKernelWorker { * Resolve a relative exec path against the process's kernel CWD. * Returns absolute path if CWD can be queried, otherwise returns path unchanged. */ - private resolveExecPathAgainstCwd(pid: number, path: string): string { - const getCwd = this.kernelInstance!.exports.kernel_get_cwd as + private resolveExecPathAgainstCwd( + pid: number, + path: string, + entry: KernelWorkerEntryContext, + ): string { + const getCwd = this.#kernelInstanceForEntry(entry).exports.kernel_get_cwd as ((pid: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; if (!getCwd) return path; let output: { result: number; bytes: Uint8Array }; try { - output = this.requireMainScratchRegion().withLease((lease) => { - const result = lease.invokeKernelExport("kernel_get_cwd", [ - pid, - lease.exportPointer(0, POSIX_PATH_MAX_BYTES), - POSIX_PATH_MAX_BYTES, - ]); - const byteLength = this.checkedScratchProducerByteLength( + output = this.#requireMainScratchRegion().withLease((lease) => { + const result = this.#invokeEntryScratchExport( + entry, + lease, + "kernel_get_cwd", + [ + pid, + lease.exportPointer(0, POSIX_PATH_MAX_BYTES), + POSIX_PATH_MAX_BYTES, + ], + ); + const byteLength = this.#checkedScratchProducerByteLength( result, POSIX_PATH_MAX_BYTES, "kernel_get_cwd", @@ -13349,7 +21860,8 @@ export class CentralizedKernelWorker { : lease.copyOut(0, byteLength), }; }); - } catch { + } catch (error) { + this.#rethrowKernelEntryFatal(error); return path; } if (output.result <= 0) return path; @@ -13371,7 +21883,11 @@ export class CentralizedKernelWorker { * Used by fexecve which calls execveat(fd, "", argv, envp, AT_EMPTY_PATH). * Resolves the fd path via kernel_get_fd_path, then delegates to exec flow. */ - private handleExecveat(channel: ChannelInfo, origArgs: number[]): void { + private handleExecveat( + channel: ChannelInfo, + origArgs: number[], + entry: KernelWorkerEntryContext, + ): void { const AT_EMPTY_PATH = 0x1000; const dirfd = origArgs[0]; const flags = origArgs[4]; @@ -13382,18 +21898,48 @@ export class CentralizedKernelWorker { const pw = this.getPtrWidth(channel.pid); const pathResult = this.readExecPathFromProcess(processMem, origArgs[1]); if ("errno" in pathResult) { - this.completeChannel(channel, SYS_EXECVEAT, origArgs, undefined, -1, pathResult.errno); + this.completeChannel( + channel, + SYS_EXECVEAT, + origArgs, + undefined, + -1, + pathResult.errno, + [], + undefined, + entry, + ); return; } const pathStr = pathResult.value; const argvResult = this.readStringArrayFromProcess(processMem, origArgs[2], pw); const envResult = this.readStringArrayFromProcess(processMem, origArgs[3], pw); if ("errno" in argvResult) { - this.completeChannel(channel, SYS_EXECVEAT, origArgs, undefined, -1, argvResult.errno); + this.completeChannel( + channel, + SYS_EXECVEAT, + origArgs, + undefined, + -1, + argvResult.errno, + [], + undefined, + entry, + ); return; } if ("errno" in envResult) { - this.completeChannel(channel, SYS_EXECVEAT, origArgs, undefined, -1, envResult.errno); + this.completeChannel( + channel, + SYS_EXECVEAT, + origArgs, + undefined, + -1, + envResult.errno, + [], + undefined, + entry, + ); return; } const argv = argvResult.values; @@ -13403,22 +21949,37 @@ export class CentralizedKernelWorker { if ((flags & AT_EMPTY_PATH) !== 0 && pathStr === "") { // fexecve path: resolve fd to file path via kernel - const getFdPath = this.kernelInstance!.exports.kernel_get_fd_path as + const getFdPath = this.#kernelInstanceForEntry(entry).exports.kernel_get_fd_path as ((pid: number, fd: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; if (!getFdPath) { - this.completeChannel(channel, SYS_EXECVEAT, origArgs, undefined, -1, 38); // ENOSYS + this.completeChannel( + channel, + SYS_EXECVEAT, + origArgs, + undefined, + -1, + 38, + [], + undefined, + entry, + ); // ENOSYS return; } let output: { result: number; bytes: Uint8Array }; try { - output = this.requireMainScratchRegion().withLease((lease) => { - const result = lease.invokeKernelExport("kernel_get_fd_path", [ - channel.pid, - dirfd, - lease.exportPointer(0, POSIX_PATH_MAX_BYTES), - POSIX_PATH_MAX_BYTES, - ]); - const byteLength = this.checkedScratchProducerByteLength( + output = this.#requireMainScratchRegion().withLease((lease) => { + const result = this.#invokeEntryScratchExport( + entry, + lease, + "kernel_get_fd_path", + [ + channel.pid, + dirfd, + lease.exportPointer(0, POSIX_PATH_MAX_BYTES), + POSIX_PATH_MAX_BYTES, + ], + ); + const byteLength = this.#checkedScratchProducerByteLength( result, POSIX_PATH_MAX_BYTES, "kernel_get_fd_path", @@ -13428,17 +21989,28 @@ export class CentralizedKernelWorker { bytes: byteLength === 0 ? new Uint8Array(0) : lease.copyOut(0, byteLength), - }; + }; }); } catch (error) { - this.rejectScratchTransfer(channel, error); + this.#rethrowKernelEntryFatal(error); + this.#rejectScratchTransfer(channel, error, entry); return; } if (output.result <= 0) { const errno = output.result < 0 ? (-output.result) >>> 0 : 2; // ENOENT - this.completeChannel(channel, SYS_EXECVEAT, origArgs, undefined, -1, errno); + this.completeChannel( + channel, + SYS_EXECVEAT, + origArgs, + undefined, + -1, + errno, + [], + undefined, + entry, + ); return; } execPath = new TextDecoder().decode(output.bytes); @@ -13449,18 +22021,23 @@ export class CentralizedKernelWorker { // For simplicity, resolve against process CWD here. // The kernel's sys_execveat already resolves this, but since we intercept // host-side, we need to do it ourselves. - const getCwd = this.kernelInstance!.exports.kernel_get_cwd as + const getCwd = this.#kernelInstanceForEntry(entry).exports.kernel_get_cwd as ((pid: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; if (getCwd) { let output: { result: number; bytes: Uint8Array }; try { - output = this.requireMainScratchRegion().withLease((lease) => { - const result = lease.invokeKernelExport("kernel_get_cwd", [ - channel.pid, - lease.exportPointer(0, POSIX_PATH_MAX_BYTES), - POSIX_PATH_MAX_BYTES, - ]); - const byteLength = this.checkedScratchProducerByteLength( + output = this.#requireMainScratchRegion().withLease((lease) => { + const result = this.#invokeEntryScratchExport( + entry, + lease, + "kernel_get_cwd", + [ + channel.pid, + lease.exportPointer(0, POSIX_PATH_MAX_BYTES), + POSIX_PATH_MAX_BYTES, + ], + ); + const byteLength = this.#checkedScratchProducerByteLength( result, POSIX_PATH_MAX_BYTES, "kernel_get_cwd", @@ -13470,10 +22047,11 @@ export class CentralizedKernelWorker { bytes: byteLength === 0 ? new Uint8Array(0) : lease.copyOut(0, byteLength), - }; + }; }); } catch (error) { - this.rejectScratchTransfer(channel, error); + this.#rethrowKernelEntryFatal(error); + this.#rejectScratchTransfer(channel, error, entry); return; } if (output.result > 0) { @@ -13488,18 +22066,98 @@ export class CentralizedKernelWorker { } if (!this.callbacks.onExec) { - this.completeChannel(channel, SYS_EXECVEAT, origArgs, undefined, -1, 38); // ENOSYS + this.completeChannel( + channel, + SYS_EXECVEAT, + origArgs, + undefined, + -1, + 38, + [], + undefined, + entry, + ); // ENOSYS return; } + const pid = channel.pid; const callerTid = this.guestTidForChannel(channel); - this.callbacks.onExec(channel.pid, execPath, argv, envp, callerTid).then((result) => { - if (result < 0) { - this.finishFailedExec(channel, SYS_EXECVEAT, origArgs, (-result) >>> 0); + entry.deferProtocolTransactionStart(() => { + let transaction: Promise; + try { + transaction = this.#resolvePromise( + this.callbacks.onExec!( + pid, + execPath, + argv, + envp, + callerTid, + ), + ); + } catch (cause) { + this.#runOrDeferChannelKernelEntry( + channel, + "execveat launch failure", + (failureEntry) => { + failureEntry.deferObserverEffect(() => { + console.error( + `[kernel] execveat error for pid ${pid}:`, + cause, + ); + return undefined; + }); + this.#finishFailedExecWithinKernelEntry( + channel, + SYS_EXECVEAT, + origArgs, + EIO, + failureEntry, + ); + return undefined; + }, + ); + return undefined; } - }).catch((err) => { - console.error(`[kernel] execveat error for pid ${channel.pid}:`, err); - this.finishFailedExec(channel, SYS_EXECVEAT, origArgs, 5); // EIO + this.#continuePromise(transaction, (result) => { + if (result >= 0) return; + this.#runOrDeferChannelKernelEntry( + channel, + "execveat launch completion", + (completionEntry) => { + this.#finishFailedExecWithinKernelEntry( + channel, + SYS_EXECVEAT, + origArgs, + (-result) >>> 0, + completionEntry, + ); + return undefined; + }, + ); + }, (cause) => { + this.#runOrDeferChannelKernelEntry( + channel, + "execveat launch rejection", + (failureEntry) => { + failureEntry.deferObserverEffect(() => { + console.error( + `[kernel] execveat error for pid ${pid}:`, + cause, + ); + return undefined; + }); + this.#finishFailedExecWithinKernelEntry( + channel, + SYS_EXECVEAT, + origArgs, + EIO, + failureEntry, + ); + return undefined; + }, + ); + }); + return undefined; }); } @@ -13507,7 +22165,11 @@ export class CentralizedKernelWorker { * Handle SYS_CLONE: thread creation. Call the onClone callback to spawn * a thread Worker sharing the parent's Memory. */ - private handleClone(channel: ChannelInfo, origArgs: number[]): void { + private handleClone( + channel: ChannelInfo, + origArgs: number[], + entry: KernelWorkerEntryContext, + ): void { // Channel args from musl's __clone override which calls kernel_clone directly: // kernel_clone(fn_ptr, stack_ptr, flags, arg, ptid_ptr, tls_ptr, ctid_ptr) // The channel syscall path dispatches SYS_CLONE with Linux syscall @@ -13523,7 +22185,17 @@ export class CentralizedKernelWorker { // origArgs[0]=flags, [1]=stack, [2]=ptid, [3]=tls, [4]=ctid if (!this.callbacks.onClone) { - this.completeChannel(channel, SYS_CLONE, origArgs, undefined, -1, 38); + this.completeChannel( + channel, + SYS_CLONE, + origArgs, + undefined, + -1, + 38, + [], + undefined, + entry, + ); return; } @@ -13534,18 +22206,38 @@ export class CentralizedKernelWorker { const validTaskWord = (ptr: number) => (ptr & 3) === 0 && isValidMemoryRange(processBytes, ptr, 4); if ((flags & CLONE_PARENT_SETTID) !== 0 && !validTaskWord(ptidPtr)) { - this.completeChannel(channel, SYS_CLONE, origArgs, undefined, -1, EFAULT); + this.completeChannel( + channel, + SYS_CLONE, + origArgs, + undefined, + -1, + EFAULT, + [], + undefined, + entry, + ); return; } const ctidPtr = (flags & CLONE_CHILD_CLEARTID) !== 0 ? rawCtidPtr : 0; if (ctidPtr !== 0 && !validTaskWord(ctidPtr)) { - this.completeChannel(channel, SYS_CLONE, origArgs, undefined, -1, EFAULT); + this.completeChannel( + channel, + SYS_CLONE, + origArgs, + undefined, + -1, + EFAULT, + [], + undefined, + entry, + ); return; } // Route through kernel_handle_channel — the kernel allocates a TID and // stores ThreadInfo. The dispatch table remaps args correctly. - const { retVal, errVal } = this.requireMainScratchRegion().withLease( + const { retVal, errVal } = this.#requireMainScratchRegion().withLease( (lease) => { const kernelView = lease.dataView(0, CH_TOTAL_SIZE); kernelView.setUint32(CH_SYSCALL, SYS_CLONE, true); @@ -13556,14 +22248,20 @@ export class CentralizedKernelWorker { true, ); } - this.bindKernelTidForChannel(channel); + this.#bindKernelTidForChannel(channel, entry); this.currentHandlePid = channel.pid; try { - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - channel.pid, - ]); + this.#invokeEntryScratchExport( + entry, + lease, + "kernel_handle_channel", + [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + 0n, + ], + ); } finally { this.currentHandlePid = 0; } @@ -13577,55 +22275,32 @@ export class CentralizedKernelWorker { if (retVal <= 0) { const errno = retVal < 0 ? errVal : EIO; - this.completeChannel(channel, SYS_CLONE, origArgs, undefined, -1, errno); + this.completeChannel( + channel, + SYS_CLONE, + origArgs, + undefined, + -1, + errno, + [], + undefined, + entry, + ); return; - } - - const tid = retVal; - let parentTidWritten = false; - let cloneAttachment: ThreadChannelAttachment | undefined; - let pendingAttachment: PendingThreadChannelAttachment | undefined; - const rollback = () => { - if (cloneAttachment) { - pendingThreadChannelAttachments.delete(cloneAttachment); - } - let transportRollbackError: unknown; - const attachedChannelOffset = pendingAttachment?.attachedChannelOffset; - if (attachedChannelOffset !== undefined) { - // Do not let a stale clone continuation tear down a same-PID channel - // that now belongs to a replacement exec image. - if (this.processes.get(channel.pid)?.memory === channel.memory) { - try { - this.removeChannel(channel.pid, attachedChannelOffset); - } catch (error) { - transportRollbackError = error; - } - } - pendingAttachment!.attachedChannelOffset = undefined; - } - this.threadCtidPtrs.delete(`${channel.pid}:${tid}`); - if (parentTidWritten) { - new DataView(channel.memory.buffer).setInt32(ptidPtr, 0, true); - parentTidWritten = false; - } - try { - this.rollbackKernelThread(channel.pid, tid); - } catch (error) { - throw error; - } - if (transportRollbackError !== undefined) { - throw transportRollbackError; - } + } + + const tid = retVal; + const cloneState: CloneRollbackState = { + parentTidWritten: false, }; - let launch: Promise; try { // CLONE_PARENT_SETTID lives in process memory, so the host performs the // write only after Rust has committed the exact TID. Preflight above // guarantees this cannot strand ThreadInfo with a host RangeError. if ((flags & CLONE_PARENT_SETTID) !== 0) { new DataView(channel.memory.buffer).setInt32(ptidPtr, tid, true); - parentTidWritten = true; + cloneState.parentTidWritten = true; } // Read fnPtr and argPtr from CH_DATA (written by the clone glue). Wasm @@ -13653,82 +22328,250 @@ export class CentralizedKernelWorker { ctidPtr, channel.memory, ); - cloneAttachment = createdAttachment.attachment; - pendingAttachment = createdAttachment.pending; - launch = Promise.resolve(this.callbacks.onClone(cloneAttachment)); + cloneState.cloneAttachment = createdAttachment.attachment; + cloneState.pendingAttachment = createdAttachment.pending; } catch (error) { try { - rollback(); + this.#rollbackCloneWithinKernelEntry( + channel, + tid, + ptidPtr, + cloneState, + entry, + ); } catch (rollbackError) { + this.#rethrowKernelEntryFatal(rollbackError); throw rollbackError; } throw error; } - launch.then(() => { - if (cloneAttachment) { - pendingThreadChannelAttachments.delete(cloneAttachment); - } - // prepareProcessForExec already removed the old generation's metadata. - // A stale continuation must not delete a same pid/tid key now owned by - // the replacement image. - if (!this.isAsyncChannelProcessActive(channel)) return; - if (pendingAttachment?.attachedChannelOffset === undefined) { - try { - rollback(); - } catch (rollbackError) { - this.terminateForKernelProtocolFailure( - channel, - `clone callback did not attach tid ${tid}, and rollback failed: ${ - rollbackError instanceof Error - ? rollbackError.message - : String(rollbackError) - }`, - ); - return; - } - console.error( - `[kernel-worker] onClone returned without attaching kernel tid ${tid}`, + const attachment = cloneState.cloneAttachment!; + entry.deferProtocolTransactionStart(() => { + let transaction: Promise; + try { + transaction = this.#resolvePromise( + this.callbacks.onClone!(attachment), ); - this.completeChannel(channel, SYS_CLONE, origArgs, undefined, -1, 12); - return; + } catch (cause) { + this.#runOrDeferChannelKernelEntry( + channel, + "clone launch failure", + (failureEntry) => { + try { + this.#rollbackCloneWithinKernelEntry( + channel, + tid, + ptidPtr, + cloneState, + failureEntry, + ); + } catch (rollbackError) { + this.#rethrowKernelEntryFatal(rollbackError); + this.#terminateForKernelProtocolFailureWithinKernelEntry( + channel, + `could not roll back allocated tid ${tid}: ${ + rollbackError instanceof Error + ? rollbackError.message + : String(rollbackError) + }`, + failureEntry, + ); + return undefined; + } + failureEntry.deferObserverEffect(() => { + console.error(`[kernel-worker] onClone failed: ${String(cause)}`); + return undefined; + }); + this.completeChannel( + channel, + SYS_CLONE, + origArgs, + undefined, + -1, + 12, + [], + undefined, + failureEntry, + ); + return undefined; + }, + ); + return undefined; } - this.completeChannel( - channel, - SYS_CLONE, - origArgs, - undefined, - tid, - 0, - [], - { - tid, - parentTidPointer: parentTidWritten ? ptidPtr : undefined, - }, - ); - }).catch((err) => { - try { - // The callback can reject after performing part of its own transport - // teardown. ESRCH therefore also proves that no exact kernel task is - // left to strand; every other rollback failure is fatal. - rollback(); - } catch (rollbackError) { - if (this.isAsyncChannelProcessActive(channel)) { - this.terminateForKernelProtocolFailure( - channel, - `could not roll back allocated tid ${tid}: ${ - rollbackError instanceof Error - ? rollbackError.message - : String(rollbackError) - }`, - ); + this.#continuePromise(transaction, () => { + this.#runOrDeferChannelKernelEntry( + channel, + "clone launch completion", + (completionEntry) => { + pendingThreadChannelAttachments.delete(attachment); + if ( + !this.#isAsyncChannelProcessActiveWithinKernelEntry( + channel, + completionEntry, + ) + ) { + return undefined; + } + if ( + cloneState.pendingAttachment?.attachedChannelOffset + === undefined + ) { + try { + this.#rollbackCloneWithinKernelEntry( + channel, + tid, + ptidPtr, + cloneState, + completionEntry, + ); + } catch (rollbackError) { + this.#rethrowKernelEntryFatal(rollbackError); + this.#terminateForKernelProtocolFailureWithinKernelEntry( + channel, + `clone callback did not attach tid ${tid}, and rollback failed: ${ + rollbackError instanceof Error + ? rollbackError.message + : String(rollbackError) + }`, + completionEntry, + ); + return undefined; + } + completionEntry.deferObserverEffect(() => { + console.error( + `[kernel-worker] onClone returned without attaching kernel tid ${tid}`, + ); + return undefined; + }); + this.completeChannel( + channel, + SYS_CLONE, + origArgs, + undefined, + -1, + 12, + [], + undefined, + completionEntry, + ); + return undefined; + } + this.completeChannel( + channel, + SYS_CLONE, + origArgs, + undefined, + tid, + 0, + [], + { + tid, + parentTidPointer: + cloneState.parentTidWritten ? ptidPtr : undefined, + }, + completionEntry, + ); + return undefined; + }, + ); + }, (cause) => { + this.#runOrDeferChannelKernelEntry( + channel, + "clone launch rejection", + (failureEntry) => { + try { + // The callback can reject after partial transport teardown. + // ESRCH proves no exact globally non-reused task remains. + this.#rollbackCloneWithinKernelEntry( + channel, + tid, + ptidPtr, + cloneState, + failureEntry, + ); + } catch (rollbackError) { + this.#rethrowKernelEntryFatal(rollbackError); + this.#terminateForKernelProtocolFailureWithinKernelEntry( + channel, + `could not roll back allocated tid ${tid}: ${ + rollbackError instanceof Error + ? rollbackError.message + : String(rollbackError) + }`, + failureEntry, + ); + return undefined; + } + failureEntry.deferObserverEffect(() => { + console.error(`[kernel-worker] onClone failed: ${String(cause)}`); + return undefined; + }); + this.completeChannel( + channel, + SYS_CLONE, + origArgs, + undefined, + -1, + 12, + [], + undefined, + failureEntry, + ); + return undefined; + }, + ); + }); + return undefined; + }); + } + + /** + * Roll back every host and Rust owner created for one clone attempt. + * + * WHY: clone launch finishes from several asynchronous outcomes. The state + * object retains only host identities; each outcome must supply its own + * fresh lexical entry instead of a closure alias retaining old authority. + */ + #rollbackCloneWithinKernelEntry( + channel: ChannelInfo, + tid: number, + ptidPtr: number, + state: CloneRollbackState, + entry: KernelWorkerEntryContext, + ): void { + if (state.cloneAttachment) { + pendingThreadChannelAttachments.delete(state.cloneAttachment); + } + let transportRollbackError: unknown; + const attachedChannelOffset = + state.pendingAttachment?.attachedChannelOffset; + if (attachedChannelOffset !== undefined) { + // Do not let a stale clone continuation tear down a same-PID channel + // that now belongs to a replacement exec image. + if (this.processes.get(channel.pid)?.memory === channel.memory) { + try { + this.removeChannel(channel.pid, attachedChannelOffset); + } catch (error) { + this.#rethrowKernelEntryFatal(error); + transportRollbackError = error; } - return; } - if (!this.isAsyncChannelProcessActive(channel)) return; - console.error(`[kernel-worker] onClone failed: ${err}`); - this.completeChannel(channel, SYS_CLONE, origArgs, undefined, -1, 12); // ENOMEM - }); + state.pendingAttachment!.attachedChannelOffset = undefined; + } + this.threadCtidPtrs.delete(`${channel.pid}:${tid}`); + if (state.parentTidWritten) { + new DataView(channel.memory.buffer).setInt32(ptidPtr, 0, true); + state.parentTidWritten = false; + } + this.#rollbackKernelThreadWithinKernelEntry( + channel.pid, + tid, + entry, + ); + if (transportRollbackError !== undefined) { + throw transportRollbackError; + } } /** @@ -13739,7 +22582,12 @@ export class CentralizedKernelWorker { * backing Worker when it installed a thread-exit callback. * For SYS_EXIT from main channel or SYS_EXIT_GROUP: current behavior. */ - private handleExit(channel: ChannelInfo, syscallNr: number, origArgs: number[]): void { + private handleExit( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + entry: KernelWorkerEntryContext, + ): void { const exitStatus = origArgs[0]; // Check if this is a thread exit (non-main channel + SYS_EXIT) @@ -13750,7 +22598,12 @@ export class CentralizedKernelWorker { // then ask the host to tear down the backing Worker (browser + Node both // wire onThreadExit). const tid = this.guestTidForChannel(channel); - this.finalizeThreadExit(channel.pid, tid, channel.channelOffset); + this.#finalizeThreadExitWithinKernelEntry( + channel.pid, + tid, + channel.channelOffset, + entry, + ); // Complete — never merely abandon — the channel on thread exit. This // flips the status word off CH_PENDING so the exiting guest's in-wasm // memory.atomic.wait32() returns and its waiter is removed while the @@ -13764,80 +22617,80 @@ export class CentralizedKernelWorker { // forever. Observed as: MariaDB's connection-handler thread (cloned when // it accepts php-fpm's DB connection) never runs its first syscall, so // the WordPress-over-MariaDB demo never gets a MySQL greeting and hangs. - this.completeChannelRaw(channel, 0, 0); - this.callbacks.onThreadExit?.(channel.pid, tid, channel.channelOffset); + this.completeChannelRaw(channel, 0, 0, entry); + entry.deferProtocolEffect(() => { + this.callbacks.onThreadExit?.( + channel.pid, + tid, + channel.channelOffset, + ); + }); return; } // Publish and detach while the process still owns its descriptors and // before waking a parent waiter. Duplicate exit syscalls are harmless. - this.releaseAllSharedMemoryForProcess(channel.pid); - if (this.getProcessExitSignal(channel.pid) > 0) { - if (!this.hostReaped.has(channel.pid)) this.handleProcessTerminated(channel); + this.releaseAllSharedMemoryForProcess(channel.pid, false, entry); + if (this.#getProcessExitSignal(channel.pid, entry) > 0) { + if (!this.hostReaped.has(channel.pid)) { + this.#handleProcessTerminatedWithinKernelEntry(channel, entry); + } return; } - // Run the kernel's exit transaction so it closes all FDs (including pipe - // write ends). This reusable kernel Wasm must return normally: trapping - // would skip its shadow-stack epilogue and permanently consume stack on - // every short-lived child. The disposable guest Wasm still traps after - // this channel handshake, which preserves _exit's non-returning contract. - { - this.bindKernelTidForChannel(channel); - this.currentHandlePid = channel.pid; - try { - this.requireMainScratchRegion().withLease((lease) => { - const kernelView = lease.dataView(0, CH_TOTAL_SIZE); - kernelView.setUint32(CH_SYSCALL, syscallNr, true); - kernelView.setBigInt64(CH_ARGS, BigInt(exitStatus), true); - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - channel.pid, - ]); - }); - } catch { - // ABI 42 kernels published before the reusable-stack fix deliberately - // trap after committing exit. Keep accepting that paired-kernel shape - // during the ABI 42 transition; the authoritative state check below - // still rejects a trap that did not actually make the Process Exited. - } finally { - this.currentHandlePid = 0; - } + // The guest-facing kernel_exit export must trap to implement `_Noreturn`. + // The host adapter instead uses the ABI-43 returning boundary: treating an + // arbitrary trap as a successful exit would hide a potentially half- + // mutated kernel. Rust clears the exact task binding before this returns. + this.#bindKernelTidForChannel(channel, entry); + const exitExports = this.#kernelInstanceForEntry(entry).exports; + const commitProcessExit = exitExports.kernel_commit_process_exit as + ((status: number) => number) | undefined; + if (!commitProcessExit) { + throw new KernelExitCommitProtocolError( + "Kernel missing required kernel_commit_process_exit export", + ); } - - // Neither a normal return nor a legacy compatibility trap proves that - // Rust committed the exit transition. Do not turn an incomplete - // transaction into a successful guest exit or a host-authored zombie. - const getProcessState = this.kernelInstance!.exports - .kernel_get_process_state as ((pid: number) => number) | undefined; - let processState: number; + let committedStatus: number; + this.currentHandlePid = channel.pid; try { - if (!getProcessState) { - throw new Error("Kernel missing required kernel_get_process_state export"); - } - processState = getProcessState(channel.pid); - } catch (error) { - this.terminateForKernelProtocolFailure( - channel, - `could not verify exit state for process ${channel.pid}: ${ - error instanceof Error ? error.message : String(error) - }`, + committedStatus = commitProcessExit(exitStatus); + } finally { + this.currentHandlePid = 0; + } + const expectedStatus = exitStatus & 0xff; + if (committedStatus !== expectedStatus) { + throw new KernelExitCommitProtocolError( + `kernel committed exit status ${committedStatus} for process ` + + `${channel.pid}; expected ${expectedStatus}`, ); - return; } + + // A matching status alone does not prove that the process reached the + // parent-visible zombie state. Validate the independent state transition + // before publishing any host lifecycle effect. + const getProcessState = exitExports.kernel_get_process_state as + ((pid: number) => number) | undefined; + if (!getProcessState) { + throw new KernelExitCommitProtocolError( + "Kernel missing required kernel_get_process_state export", + ); + } + const processState = getProcessState(channel.pid); if (processState !== PROCESS_STATE_EXITED) { - this.terminateForKernelProtocolFailure( - channel, + throw new KernelExitCommitProtocolError( `kernel exit left process ${channel.pid} in state ${processState}`, ); - return; } + // Rust's committed process-exit transition consumed every retry binding. + // Drop the now-dead image's detached plans without issuing a second + // numeric release against an Exited process. + this.#forgetBlockingRetrySnapshotsAfterKernelLifecycle(channel.pid); // Closing descriptors during exit can release process, OFD, and flock // records. Rust publishes the generic advisory-lock wake while doing so; // consume it before parent notification and host-worker teardown. - this.drainAndProcessWakeupEvents(); + this.#drainAndProcessWakeupEventsWithinKernelEntry(entry); // Main thread exit or exit_group: record exit status for waitpid, // queue SIGCHLD to parent, then notify the host callback. @@ -13847,30 +22700,27 @@ export class CentralizedKernelWorker { // SYS_KILL that races a clean SYS_EXIT from the same process doesn't // produce two SIGCHLDs / two parent wake-ups. Cleared by // deactivateProcess and registerProcess. - if (this.hostReaped.has(exitingPid)) { - // Already reaped via the kill path — still complete the channel so - // the worker can finish tearing down, but skip the parent-wakeup work. - this.completeProcessExitHandshake(channel, syscallNr); - this.scheduleWakeBlockedRetries(); - if (this.callbacks.onExit) this.callbacks.onExit(exitingPid, exitStatus); - return; + if (!this.hostReaped.has(exitingPid)) { + this.hostReaped.add(exitingPid); + this.notifyParentOfExitedProcess(exitingPid, entry); } - this.hostReaped.add(exitingPid); - this.notifyParentOfExitedProcess(exitingPid); // Complete the channel so the worker unblocks from Atomics.wait(). // Without this, the worker stays blocked and Node.js aborts when // trying to terminate worker threads during process.exit(). - this.completeProcessExitHandshake(channel, syscallNr); + this.completeProcessExitHandshake(channel, syscallNr, entry); // Wake any processes blocked on pipe reads/polls — the exiting process's // FDs were closed by the kernel (sys_exit), so pipes with no remaining // writers should now return EOF to readers. - this.scheduleWakeBlockedRetries(); - - if (this.callbacks.onExit) { - this.callbacks.onExit(exitingPid, exitStatus); - } + entry.deferProtocolEffect(() => { + // WHY: retry scheduling and lifecycle callbacks may synchronously reach + // arbitrary host code. Publish them only after the exit scope and its + // Rust/process-memory ownership have both been revoked. + this.scheduleWakeBlockedRetries(); + this.callbacks.onExit?.(exitingPid, exitStatus); + return undefined; + }); } /** @@ -13878,7 +22728,26 @@ export class CentralizedKernelWorker { * syscall retry. Rust already owns the Exited state and wait status; the * host only wakes the parent waiter and terminates the Worker. */ - private handleProcessTerminated(channel: ChannelInfo): void { + private handleProcessTerminated( + channel: ChannelInfo, + ): void { + this.#runOrDeferKernelEntry( + `signal termination pid=${channel.pid}`, + (entry) => { + this.#handleProcessTerminatedWithinKernelEntry( + channel, + entry, + ); + return undefined; + }, + channel, + ); + } + + #handleProcessTerminatedWithinKernelEntry( + channel: ChannelInfo, + entry: KernelWorkerEntryContext, + ): void { const exitingPid = channel.pid; this.discardStoppedChannelStateForProcess(exitingPid); // Idempotency guard — both handleExit and reapKilledProcessesAfterSyscall @@ -13892,21 +22761,25 @@ export class CentralizedKernelWorker { // early guard prevents recursive termination cleanup. // Capture the signal before notifying the parent: a synchronous wait can // consume and reap the zombie, after which the kernel query returns ESRCH. - const signal = this.getProcessExitSignal(exitingPid); + const signal = this.#getProcessExitSignal(exitingPid, entry); + this.#forgetBlockingRetrySnapshotsAfterKernelLifecycle(exitingPid); this.hostReaped.add(exitingPid); - this.releaseAllSharedMemoryForProcess(exitingPid); + this.releaseAllSharedMemoryForProcess(exitingPid, true, entry); // Default signal delivery has already transitioned the Rust Process to // Exited and released its process/OFD/flock records. Consume that generic // kernel wake before a parent waiter or host exit callback can run. - this.drainAndProcessWakeupEvents(); - this.notifyParentOfExitedProcess(exitingPid); + this.#drainAndProcessWakeupEventsWithinKernelEntry(entry); + this.notifyParentOfExitedProcess(exitingPid, entry); // Do NOT complete the channel — the worker is blocked on Atomics.wait // and waking it would cause the C code to continue executing. // onExit will terminate the worker. - if (this.callbacks.onExit) { - this.callbacks.onExit(exitingPid, signal > 0 ? 128 + signal : -1); - } + entry.deferProtocolEffect(() => { + this.callbacks.onExit?.( + exitingPid, + signal > 0 ? 128 + signal : -1, + ); + }); } /** @@ -13917,8 +22790,9 @@ export class CentralizedKernelWorker { */ private finalizeExitedProcessBeforeLifecycleNotification( pid: number, + entry?: KernelWorkerEntryContext, ): boolean { - const getState = this.kernelInstance!.exports.kernel_get_process_state as + const getState = this.#kernelInstanceForEntry(entry).exports.kernel_get_process_state as ((pid: number) => number) | undefined; if (!getState || getState(pid) !== PROCESS_STATE_EXITED) return false; @@ -13927,7 +22801,11 @@ export class CentralizedKernelWorker { this.cancelPendingSleepsForProcess(pid); const channel = this.processes.get(pid)?.channels[0]; - if (channel) { + if (channel && entry) { + this.#handleProcessTerminatedWithinKernelEntry(channel, entry); + } else if (entry) { + this.#finalizeExecHandoffTerminationWithinKernelEntry(pid, entry); + } else if (channel) { this.handleProcessTerminated(channel); } else { this.finalizeExecHandoffTermination(pid); @@ -13963,7 +22841,26 @@ export class CentralizedKernelWorker { signum: number = 11 /* SIGSEGV */, ): void { if (this.hostReaped.has(pid)) return; - const markSignaled = this.kernelInstance!.exports + this.#runOrDeferKernelEntry( + "host process crash", + (entry) => { + this.#notifyHostProcessCrashedWithinKernelEntry( + pid, + signum, + entry, + ); + return undefined; + }, + ); + } + + #notifyHostProcessCrashedWithinKernelEntry( + pid: number, + signum: number, + entry: KernelWorkerEntryContext, + ): void { + if (this.hostReaped.has(pid)) return; + const markSignaled = entry.instance.exports .kernel_mark_process_signaled as ((pid: number, signum: number) => number) | undefined; if (!markSignaled) { @@ -13976,14 +22873,15 @@ export class CentralizedKernelWorker { `Kernel rejected signal-death transition for process ${pid}: ${detail}`, ); } + this.#forgetBlockingRetrySnapshotsAfterKernelLifecycle(pid); this.discardStoppedChannelStateForProcess(pid); this.hostReaped.add(pid); - this.releaseAllSharedMemoryForProcess(pid); + this.releaseAllSharedMemoryForProcess(pid, true, entry); // Signal termination closes Rust-owned advisory locks. Consume that wake // even for a root/no-parent process, where SIGCHLD routing cannot provide // an incidental kernel event drain. - this.drainAndProcessWakeupEvents(); - this.notifyParentOfExitedProcess(pid); + this.#drainAndProcessWakeupEventsWithinKernelEntry(entry); + this.notifyParentOfExitedProcess(pid, entry); } /** @@ -13997,13 +22895,15 @@ export class CentralizedKernelWorker { * The kernel exposes the termination signal separately from the normal exit * status, so exit codes 128..255 cannot be mistaken for signal death. */ - private reapKilledProcessesAfterSyscall(): void { + private reapKilledProcessesAfterSyscall( + entry?: KernelWorkerEntryContext, + ): void { // Snapshot the registered pids so we can mutate this.processes safely // inside the loop (handleProcessTerminated calls onExit which can // remove entries). const pids = Array.from(this.processes.keys()); for (const pid of pids) { - if (this.getProcessExitSignal(pid) <= 0) continue; + if (this.#getProcessExitSignal(pid, entry) <= 0) continue; if (this.hostReaped.has(pid)) continue; // already handled for this task ID // Cancel any pending blocking-syscall timers — the process is gone. @@ -14014,12 +22914,19 @@ export class CentralizedKernelWorker { // handleProcessTerminated re-checks hostReaped and adds the pid // itself, so passing through here is idempotent if two reap // events fire close together. - if (ch) this.handleProcessTerminated(ch); + if (ch && entry) { + this.#handleProcessTerminatedWithinKernelEntry(ch, entry); + } else if (ch) { + this.handleProcessTerminated(ch); + } } } - private getProcessExitSignal(pid: number): number { - const getExitSignal = this.kernelInstance!.exports + #getProcessExitSignal( + pid: number, + entry?: KernelWorkerEntryContext, + ): number { + const getExitSignal = this.#kernelInstanceForEntry(entry).exports .kernel_get_process_exit_signal as ((pid: number) => number) | undefined; if (!getExitSignal) { throw new Error("Kernel missing required kernel_get_process_exit_signal export"); @@ -14028,10 +22935,17 @@ export class CentralizedKernelWorker { } /** Stop a channel boundary when signal delivery transitioned its process to Exited. */ - private finishSignalTermination(channel: ChannelInfo): boolean { - if (this.getProcessExitSignal(channel.pid) <= 0) return false; + #finishSignalTermination( + channel: ChannelInfo, + entry?: KernelWorkerEntryContext, + ): boolean { + if (this.#getProcessExitSignal(channel.pid, entry) <= 0) return false; this.cancelPendingSleepsForProcess(channel.pid); - this.handleProcessTerminated(channel); + if (entry) { + this.#handleProcessTerminatedWithinKernelEntry(channel, entry); + } else { + this.handleProcessTerminated(channel); + } return true; } @@ -14042,17 +22956,45 @@ export class CentralizedKernelWorker { * not install a replacement worker when the returned signal is positive. */ finalizeExecHandoffTermination(pid: number): number { - const signal = this.getProcessExitSignal(pid); + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + // Result-bearing reverse calls cannot truthfully report a value while + // another entry owns Rust state. Their caller must retry from a later + // host turn instead of queuing a side effect behind a fabricated result. + throw new KernelReentrantEntryError("exec handoff finalization"); + } + let result: number | undefined; + const deferred = this.#runOrDeferKernelEntry( + `exec handoff finalization pid=${pid}`, + (entry) => { + result = this.#finalizeExecHandoffTerminationWithinKernelEntry( + pid, + entry, + ); + }, + ); + if (deferred || result === undefined) { + throw new KernelReentrantEntryError("exec handoff finalization"); + } + return result; + } + + #finalizeExecHandoffTerminationWithinKernelEntry( + pid: number, + entry: KernelWorkerEntryContext, + ): number { + const signal = this.#getProcessExitSignal(pid, entry); if (signal <= 0) return signal; this.discardStoppedChannelStateForProcess(pid); if (this.hostReaped.has(pid)) return signal; + this.#forgetBlockingRetrySnapshotsAfterKernelLifecycle(pid); this.hostReaped.add(pid); - this.releaseAllSharedMemoryForProcess(pid); - this.notifyParentOfExitedProcess(pid); - if (this.callbacks.onExit) { - this.callbacks.onExit(pid, 128 + signal); - } + this.releaseAllSharedMemoryForProcess(pid, true, entry); + this.notifyParentOfExitedProcess(pid, entry); + entry.deferProtocolEffect(() => { + this.callbacks.onExit?.(pid, 128 + signal); + }); return signal; } @@ -14072,6 +23014,24 @@ export class CentralizedKernelWorker { return exitSignal; } + #finalizePendingChildTerminationWithinKernelEntry( + pid: number, + entry: KernelWorkerEntryContext, + ): number { + const exitSignal = + this.#finalizeExecHandoffTerminationWithinKernelEntry(pid, entry); + if (exitSignal !== -1) { + entry.deferProtocolEffect(() => { + this.cleanupTcpListeners(pid); + for (const key of Array.from(this.epollInterests.keys())) { + if (key.startsWith(`${pid}:`)) this.epollInterests.delete(key); + } + return undefined; + }); + } + return exitSignal; + } + /** Track pids the host has already reaped (prevents double-reaping * when reapKilledProcessesAfterSyscall is called multiple times for * the same already-Exited process). Kernel task IDs are monotonic and @@ -14082,23 +23042,21 @@ export class CentralizedKernelWorker { * Handle SYS_WAIT4: wait for a child process to exit. * Args: [pid, wstatus_ptr, options, rusage_ptr] */ - private handleWaitpid(channel: ChannelInfo, origArgs: number[]): void { + private handleWaitpid( + channel: ChannelInfo, + origArgs: number[], + entry: KernelWorkerEntryContext, + ): void { const targetPid = origArgs[0]; // pid argument const wstatusPtr = origArgs[1]; const options = origArgs[2] >>> 0; const rusagePtr = origArgs[3]; const parentPid = channel.pid; - if (this.pendingCancels.delete(channel)) { - this.completeChannelRaw(channel, -EINTR_ERRNO, EINTR_ERRNO); - this.relistenChannel(channel); - return; - } - const allowedOptions = WAIT_WNOHANG | WAIT_WUNTRACED | WAIT_WSTOPPED | WAIT_WCONTINUED; if ((options & ~allowedOptions) !== 0) { - this.completeWaitpid(channel, origArgs, -1, EINVAL); + this.completeWaitpid(channel, origArgs, -1, EINVAL, entry); return; } if ( @@ -14109,28 +23067,35 @@ export class CentralizedKernelWorker { STRUCT_SIZE_WASM_RUSAGE_WIRE, ) ) { - this.completeWaitpid(channel, origArgs, -1, EFAULT); + this.completeWaitpid(channel, origArgs, -1, EFAULT, entry); return; } const eventMask = this.wait4EventMask(options); - const poll = this.pollWaitableChild(channel, targetPid, eventMask, 0); + const poll = this.pollWaitableChild( + channel, + targetPid, + eventMask, + 0, + entry, + ); if (poll.kind === "error") { - this.completeWaitpid(channel, origArgs, -1, poll.errno); + this.completeWaitpid(channel, origArgs, -1, poll.errno, entry); return; } if (poll.kind === "event") { this.writeWait4Result(channel, wstatusPtr, rusagePtr, poll); - this.completeWaitpid(channel, origArgs, poll.childPid, 0); + this.completeWaitpid(channel, origArgs, poll.childPid, 0, entry); return; } if (options & WAIT_WNOHANG) { - this.completeWaitpid(channel, origArgs, 0, 0); + this.completeWaitpid(channel, origArgs, 0, 0, entry); return; } const pendingWaiter: WaitingForChild = { + ...this.#cancellationPointIdentity(channel), parentPid, channel, origArgs, @@ -14138,7 +23103,15 @@ export class CentralizedKernelWorker { options, syscallNr: SYS_WAIT4, }; - if (this.interruptWaiterWithPendingSignal(pendingWaiter)) return; + if (this.interruptWaiterWithPendingSignal(pendingWaiter, entry)) return; + if ( + this.interruptPendingCancellationBeforeRegistration( + channel, + SYS_WAIT4, + pendingWaiter, + entry, + ) + ) return; // Blocking wait: defer completion until a child exits this.waitingForChild.push(pendingWaiter); @@ -14170,8 +23143,9 @@ export class CentralizedKernelWorker { targetPid: number, eventMask: number, flags: number, + entry: KernelWorkerEntryContext, ): WaitPollResult { - const waitPoll = this.kernelInstance!.exports.kernel_wait_child_poll as ( + const waitPoll = this.#kernelInstanceForEntry(entry).exports.kernel_wait_child_poll as ( parentPid: number, callerTid: number, targetPid: number, @@ -14180,16 +23154,21 @@ export class CentralizedKernelWorker { resultPtr: KernelPointer, resultCapacity: number, ) => number; - const output = this.requireMainScratchRegion().withLease((lease) => { - const result = lease.invokeKernelExport("kernel_wait_child_poll", [ - channel.pid, - this.guestTidForChannel(channel), - targetPid, - eventMask, - flags, - lease.exportPointer(0, STRUCT_SIZE_KERNEL_WAIT_RESULT), - STRUCT_SIZE_KERNEL_WAIT_RESULT, - ]); + const output = this.#requireMainScratchRegion().withLease((lease) => { + const result = this.#invokeEntryScratchExport( + entry, + lease, + "kernel_wait_child_poll", + [ + channel.pid, + this.guestTidForChannel(channel), + targetPid, + eventMask, + flags, + lease.exportPointer(0, STRUCT_SIZE_KERNEL_WAIT_RESULT), + STRUCT_SIZE_KERNEL_WAIT_RESULT, + ], + ); return { result, bytes: result > 0 @@ -14237,38 +23216,48 @@ export class CentralizedKernelWorker { return isValidMemoryRange(new Uint8Array(channel.memory.buffer), ptr, size); } - private getParentPid(pid: number): number | undefined { - const getParentPid = this.kernelInstance!.exports.kernel_get_parent_pid as ( + private getParentPid( + pid: number, + entry: KernelWorkerEntryContext, + ): number | undefined { + const getParentPid = this.#kernelInstanceForEntry(entry).exports.kernel_get_parent_pid as ( pid: number, ) => number; const result = getParentPid(pid); return result > 0 ? result : undefined; } - private consumeExitedChild(parentPid: number, childPid: number): void { - const reapChild = this.kernelInstance!.exports.kernel_reap_exited_child as ( + private consumeExitedChild( + parentPid: number, + childPid: number, + entry: KernelWorkerEntryContext, + ): void { + const reapChild = this.#kernelInstanceForEntry(entry).exports.kernel_reap_exited_child as ( parentPid: number, childPid: number, ) => number; reapChild(parentPid, childPid); } - private notifyParentOfExitedProcess(pid: number): void { - const parentPid = this.getParentPid(pid); + private notifyParentOfExitedProcess( + pid: number, + entry: KernelWorkerEntryContext, + ): void { + const parentPid = this.getParentPid(pid, entry); if (parentPid === undefined) return; - const hasNoCldWait = this.kernelInstance!.exports + const hasNoCldWait = this.#kernelInstanceForEntry(entry).exports .kernel_has_sa_nocldwait as ((pid: number) => number) | undefined; const autoReap = hasNoCldWait ? hasNoCldWait(parentPid) === 1 : false; if (autoReap) { - this.consumeExitedChild(parentPid, pid); + this.consumeExitedChild(parentPid, pid, entry); // A parent may already be blocked in a wait for this child. Re-poll it // after auto-reap so it observes ECHILD instead of sleeping forever. - this.wakeWaitingParent(parentPid); + this.wakeWaitingParent(parentPid, entry); return; } - this.sendSignalToProcess(parentPid, SIGCHLD); + this.sendSignalToProcess(parentPid, SIGCHLD, true, entry); } private writeWait4Result( @@ -14294,13 +23283,14 @@ export class CentralizedKernelWorker { origArgs: number[], retVal: number, errVal: number, + entry: KernelWorkerEntryContext, ): void { // Waitpid is handled host-side (never goes through kernel_handle_channel), // so we must check for pending signals here. Without this, cross-process // signals (e.g., kill from child to parent) are lost — the signal is queued // in the kernel but never dequeued for the blocked parent. - this.dequeueSignalForDelivery(channel); - if (this.finishSignalTermination(channel)) return; + this.#dequeueSignalForDelivery(channel, entry); + if (this.#finishSignalTermination(channel, entry)) return; this.completeChannel( channel, SYS_WAIT4, @@ -14308,6 +23298,9 @@ export class CentralizedKernelWorker { undefined, retVal, errVal, + [], + undefined, + entry, ); } @@ -14316,9 +23309,10 @@ export class CentralizedKernelWorker { origArgs: number[], retVal: number, errVal: number, + entry: KernelWorkerEntryContext, ): void { - this.dequeueSignalForDelivery(channel); - if (this.finishSignalTermination(channel)) return; + this.#dequeueSignalForDelivery(channel, entry); + if (this.#finishSignalTermination(channel, entry)) return; this.completeChannel( channel, SYS_WAITID, @@ -14326,6 +23320,9 @@ export class CentralizedKernelWorker { undefined, retVal, errVal, + [], + undefined, + entry, ); } @@ -14334,9 +23331,15 @@ export class CentralizedKernelWorker { * that thread's channel. The libc glue runs the handler and transparently * reissues wait4/waitid when the delivered action has SA_RESTART. */ - private interruptWaiterWithPendingSignal(waiter: WaitingForChild): boolean { - const deliveredSignal = this.dequeueSignalForDelivery(waiter.channel); - if (this.finishSignalTermination(waiter.channel)) return true; + private interruptWaiterWithPendingSignal( + waiter: WaitingForChild, + entry: KernelWorkerEntryContext, + ): boolean { + const deliveredSignal = this.#dequeueSignalForDelivery( + waiter.channel, + entry, + ); + if (this.#finishSignalTermination(waiter.channel, entry)) return true; if (deliveredSignal <= 0) return false; this.completeChannel( @@ -14346,10 +23349,74 @@ export class CentralizedKernelWorker { undefined, -1, EINTR_ERRNO, + [], + undefined, + entry, ); return true; } + #validateKernelSignalTargetTid(targetTid: number): number { + if (!Number.isSafeInteger(targetTid) || targetTid < 0) { + this.#failBlockingRetryProtocol( + `kernel returned invalid signal target TID ${targetTid}`, + ); + } + return targetTid; + } + + /** + * Select one exact signal target through the required kernel authority. + * + * WHY: treating a missing export or malformed TID as "no target" could leave + * a deliverable signal stranded while a host-owned wait remains parked. + */ + #pickKernelSignalTargetTid( + pid: number, + signum: number, + entry: KernelWorkerEntryContext, + ): number { + const pickSignalTarget = this.#kernelInstanceForEntry(entry).exports + .kernel_pick_signal_target_tid as + ((targetPid: number, signal: number) => number) | undefined; + if (typeof pickSignalTarget !== "function") { + this.#failBlockingRetryProtocol( + "kernel signal-target selection export is unavailable", + ); + } + return this.#validateKernelSignalTargetTid( + pickSignalTarget(pid, signum), + ); + } + + /** + * Query deliverability through the required kernel authority. + * + * Rust owns pending/blocked/ignored signal state. Only exact 0/1 results are + * valid; truthiness would turn ABI corruption into an observable wake race. + */ + #kernelThreadHasDeliverable( + pid: number, + tid: number, + entry: KernelWorkerEntryContext, + ): boolean { + const threadHasDeliverable = this.#kernelInstanceForEntry(entry).exports + .kernel_thread_has_deliverable as + ((targetPid: number, targetTid: number) => number) | undefined; + if (typeof threadHasDeliverable !== "function") { + this.#failBlockingRetryProtocol( + "kernel deliverable-signal query export is unavailable", + ); + } + const result = threadHasDeliverable(pid, tid); + if (result !== 0 && result !== 1) { + this.#failBlockingRetryProtocol( + `kernel returned invalid deliverable-signal state ${result}`, + ); + } + return result === 1; + } + /** * Give already-available child status priority, then interrupt the one wait * thread selected by the kernel for `signum`. This mirrors the exact-thread @@ -14358,12 +23425,15 @@ export class CentralizedKernelWorker { private interruptWaitingChildForSignal( targetPid: number, signum: number, + entry: KernelWorkerEntryContext, ): boolean { - this.wakeWaitingParent(targetPid); + this.wakeWaitingParent(targetPid, entry); - const pickSignalTarget = this.kernelInstance!.exports - .kernel_pick_signal_target_tid as (pid: number, signum: number) => number; - const targetTid = pickSignalTarget(targetPid, signum); + const targetTid = this.#pickKernelSignalTargetTid( + targetPid, + signum, + entry, + ); if (targetTid <= 0) return false; const waiterIndex = this.waitingForChild.findIndex( @@ -14375,7 +23445,7 @@ export class CentralizedKernelWorker { if (waiterIndex < 0) return false; const [waiter] = this.waitingForChild.splice(waiterIndex, 1); - if (this.interruptWaiterWithPendingSignal(waiter)) return true; + if (this.interruptWaiterWithPendingSignal(waiter, entry)) return true; // The signal may have been consumed or changed disposition between target // selection and dequeue. Preserve the original wait in that rare race. @@ -14387,11 +23457,13 @@ export class CentralizedKernelWorker { private interruptWaitingChildForDirectedSignal( pid: number, tid: number, + entry: KernelWorkerEntryContext, ): boolean { - this.wakeWaitingParent(pid); - const threadHasDeliverable = this.kernelInstance!.exports - .kernel_thread_has_deliverable as (pid: number, tid: number) => number; - if (threadHasDeliverable(pid, tid) <= 0) return false; + const testHook = this.#scratchBoundaryTestHooks + ?.interruptWaitingChildForDirectedSignal; + if (testHook) return testHook(pid, tid); + this.wakeWaitingParent(pid, entry); + if (!this.#kernelThreadHasDeliverable(pid, tid, entry)) return false; const waiterIndex = this.waitingForChild.findIndex( (waiter) => @@ -14402,25 +23474,37 @@ export class CentralizedKernelWorker { if (waiterIndex < 0) return false; const [waiter] = this.waitingForChild.splice(waiterIndex, 1); - if (this.interruptWaiterWithPendingSignal(waiter)) return true; + if (this.interruptWaiterWithPendingSignal(waiter, entry)) return true; this.waitingForChild.splice(waiterIndex, 0, waiter); return false; } /** Service waiters after a guest-originated kill, including pid/group sends. */ - private interruptWaitingChildrenForGeneratedSignal(signum: number): void { + private interruptWaitingChildrenForGeneratedSignal( + signum: number, + entry: KernelWorkerEntryContext, + ): void { + const testHook = this.#scratchBoundaryTestHooks + ?.interruptWaitingChildrenForGeneratedSignal; + if (testHook) { + testHook(signum); + return; + } if (signum <= 0) return; const waitingForChild = this.waitingForChild ?? []; const parentPids = new Set( waitingForChild.map((waiter) => waiter.parentPid), ); for (const parentPid of parentPids) { - this.interruptWaitingChildForSignal(parentPid, signum); + this.interruptWaitingChildForSignal(parentPid, signum, entry); } } /** Wake a parent blocked in waitpid/waitid when a child exits. */ - private wakeWaitingParent(parentPid: number): void { + private wakeWaitingParent( + parentPid: number, + entry: KernelWorkerEntryContext, + ): void { this.waitingForChild ??= []; const resolved: Array<{ waiter: WaitingForChild; @@ -14453,6 +23537,7 @@ export class CentralizedKernelWorker { waiter.pid, eventMask, pollFlags, + entry, ); if (waiterPoll.kind === "running") { i++; @@ -14465,9 +23550,21 @@ export class CentralizedKernelWorker { for (const { waiter, poll } of resolved) { if (poll.kind === "error") { if (waiter.syscallNr === SYS_WAITID) { - this.completeWaitid(waiter.channel, waiter.origArgs, -1, poll.errno); + this.completeWaitid( + waiter.channel, + waiter.origArgs, + -1, + poll.errno, + entry, + ); } else { - this.completeWaitpid(waiter.channel, waiter.origArgs, -1, poll.errno); + this.completeWaitpid( + waiter.channel, + waiter.origArgs, + -1, + poll.errno, + entry, + ); } continue; } @@ -14480,7 +23577,7 @@ export class CentralizedKernelWorker { poll, processSiginfoLayout(this.getPtrWidth(waiter.channel.pid)), ); - this.completeWaitid(waiter.channel, waiter.origArgs, 0, 0); + this.completeWaitid(waiter.channel, waiter.origArgs, 0, 0, entry); } else { this.writeWait4Result( waiter.channel, @@ -14488,7 +23585,13 @@ export class CentralizedKernelWorker { waiter.origArgs[3], poll, ); - this.completeWaitpid(waiter.channel, waiter.origArgs, poll.childPid, 0); + this.completeWaitpid( + waiter.channel, + waiter.origArgs, + poll.childPid, + 0, + entry, + ); } } } @@ -14498,7 +23601,7 @@ export class CentralizedKernelWorker { * When a child changes its pgid (setpgid/setsid), a parent waiting on * waitpid(-pgid) may no longer have matching children → return ECHILD. */ - private recheckDeferredWaitpids(): void { + private recheckDeferredWaitpids(entry: KernelWorkerEntryContext): void { const parentsWithNewlyMatchingStatus = new Set(); // Iterate backwards to safely splice while iterating for (let i = this.waitingForChild.length - 1; i >= 0; i--) { @@ -14518,14 +23621,27 @@ export class CentralizedKernelWorker { waiter.pid, eventMask, pollFlags, + entry, ); if (poll.kind === "error") { // No more matching children — wake with ECHILD this.waitingForChild.splice(i, 1); if (waiter.syscallNr === SYS_WAITID) { - this.completeWaitid(waiter.channel, waiter.origArgs, -1, poll.errno); + this.completeWaitid( + waiter.channel, + waiter.origArgs, + -1, + poll.errno, + entry, + ); } else { - this.completeWaitpid(waiter.channel, waiter.origArgs, -1, poll.errno); + this.completeWaitpid( + waiter.channel, + waiter.origArgs, + -1, + poll.errno, + entry, + ); } } else if (poll.kind === "event") { // The pgid change can make an already-recorded status newly eligible. @@ -14536,7 +23652,7 @@ export class CentralizedKernelWorker { } for (const parentPid of parentsWithNewlyMatchingStatus) { - this.wakeWaitingParent(parentPid); + this.wakeWaitingParent(parentPid, entry); } } @@ -14547,7 +23663,11 @@ export class CentralizedKernelWorker { * Supports P_PID, P_ALL, P_PGID id types and WNOWAIT/WNOHANG/WEXITED flags. * Fills siginfo_t in process memory with si_signo, si_code, si_pid, si_uid, si_status. */ - private handleWaitid(channel: ChannelInfo, origArgs: number[]): void { + private handleWaitid( + channel: ChannelInfo, + origArgs: number[], + entry: KernelWorkerEntryContext, + ): void { const idtype = origArgs[0]; const id = origArgs[1]; const siginfoPtr = origArgs[2]; @@ -14557,12 +23677,6 @@ export class CentralizedKernelWorker { const waitPid = this.waitidToWaitPid(idtype, id); const siginfoLayout = processSiginfoLayout(this.getPtrWidth(channel.pid)); - if (this.pendingCancels.delete(channel)) { - this.completeChannelRaw(channel, -EINTR_ERRNO, EINTR_ERRNO); - this.relistenChannel(channel); - return; - } - const allowedOptions = WAIT_WNOHANG | WAIT_WNOWAIT | @@ -14576,7 +23690,7 @@ export class CentralizedKernelWorker { (options & ~allowedOptions) !== 0 || eventMask === 0 ) { - this.completeWaitid(channel, origArgs, -1, EINVAL); + this.completeWaitid(channel, origArgs, -1, EINVAL, entry); return; } if ( @@ -14591,7 +23705,7 @@ export class CentralizedKernelWorker { STRUCT_SIZE_WASM_RUSAGE_WIRE, ) ) { - this.completeWaitid(channel, origArgs, -1, EFAULT); + this.completeWaitid(channel, origArgs, -1, EFAULT, entry); return; } @@ -14600,9 +23714,10 @@ export class CentralizedKernelWorker { waitPid, eventMask, options & WAIT_WNOWAIT, + entry, ); if (poll.kind === "error") { - this.completeWaitid(channel, origArgs, -1, poll.errno); + this.completeWaitid(channel, origArgs, -1, poll.errno, entry); return; } if (poll.kind === "event") { @@ -14613,7 +23728,7 @@ export class CentralizedKernelWorker { poll, siginfoLayout, ); - this.completeWaitid(channel, origArgs, 0, 0); + this.completeWaitid(channel, origArgs, 0, 0, entry); return; } @@ -14623,11 +23738,12 @@ export class CentralizedKernelWorker { siginfoPtr, siginfoLayout.size, ).fill(0); - this.completeWaitid(channel, origArgs, 0, 0); + this.completeWaitid(channel, origArgs, 0, 0, entry); return; } const pendingWaiter: WaitingForChild = { + ...this.#cancellationPointIdentity(channel), parentPid, channel, origArgs, @@ -14635,7 +23751,15 @@ export class CentralizedKernelWorker { options, syscallNr: SYS_WAITID, }; - if (this.interruptWaiterWithPendingSignal(pendingWaiter)) return; + if (this.interruptWaiterWithPendingSignal(pendingWaiter, entry)) return; + if ( + this.interruptPendingCancellationBeforeRegistration( + channel, + SYS_WAITID, + pendingWaiter, + entry, + ) + ) return; // Blocking wait: defer until a child exits. this.waitingForChild.push(pendingWaiter); @@ -14700,7 +23824,8 @@ export class CentralizedKernelWorker { private handleFutex( channel: ChannelInfo, origArgs: number[], - rawArgs?: readonly bigint[], + rawArgs: readonly bigint[] | undefined, + entry: KernelWorkerEntryContext, ): void { const rawOp = rawArgs?.[1] ?? BigInt(origArgs[1]); const op = Number(BigInt.asUintN(32, rawOp)); @@ -14709,6 +23834,8 @@ export class CentralizedKernelWorker { let addr: number; let timeoutPtr = 0; + let timeoutMs: number | undefined; + let timeoutDeadline: number | undefined; let uaddr2 = 0; try { addr = this.checkedProcessRange( @@ -14723,12 +23850,37 @@ export class CentralizedKernelWorker { if (baseOp === FUTEX_WAIT || baseOp === FUTEX_WAIT_BITSET) { const rawTimeout = rawArgs?.[3] ?? origArgs[3]; if (rawTimeout !== 0n && rawTimeout !== 0) { - timeoutPtr = this.checkedProcessRange( + const range = this.checkedProcessRange( channel, rawTimeout, 16, "futex timeout", - ).pointer; + ); + timeoutPtr = range.pointer; + const timeoutView = new DataView( + channel.memory.buffer, + range.pointer, + range.length, + ); + const sec = timeoutView.getBigInt64(0, true); + const nsec = timeoutView.getBigInt64(8, true); + if (sec < 0n || nsec < 0n || nsec >= 1_000_000_000n) { + throw new KernelScratchError( + "futex timeout is not a normalized timespec", + EINVAL, + ); + } + // WHY: validate and cap in bigint before converting to Number. + // Otherwise a large signed timespec can round to a different wait, + // while an invalid nanosecond field can silently carry into seconds. + const requestedMs = + sec * 1000n + (nsec + 999_999n) / 1_000_000n; + const cappedMs = + requestedMs > 2_147_483_647n + ? 2_147_483_647n + : requestedMs; + timeoutMs = Number(cappedMs); + timeoutDeadline = Date.now() + timeoutMs; } } if ( @@ -14747,7 +23899,7 @@ export class CentralizedKernelWorker { } } } catch (error) { - this.rejectScratchTransfer(channel, error); + this.#rejectScratchTransfer(channel, error, entry); return; } @@ -14755,47 +23907,36 @@ export class CentralizedKernelWorker { const index = addr / 4; if (baseOp === FUTEX_WAIT || baseOp === FUTEX_WAIT_BITSET) { - // Pre-empt cancel: if SYS_THREAD_CANCEL arrived before we got here - // the channel status was PENDING but no futex wait had been set up - // yet, so handleThreadCancel had nothing to notify. Completing the - // syscall with EINTR lets the guest's post-__testcancel() pick up - // the flag and exit. The deferred-cancel guest overlay treats this - // return value like any other EINTR and checks self->cancel. - if (this.pendingCancels.has(channel)) { - this.pendingCancels.delete(channel); - this.completeChannelRaw(channel, -EINTR_ERRNO, EINTR_ERRNO); - this.relistenChannel(channel); - return; - } // Compare value at addr with expected const currentVal = Atomics.load(i32View, index); if (currentVal !== val) { // Value already changed — return -EAGAIN (Linux convention) - this.completeChannelRaw(channel, -EAGAIN, EAGAIN); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten( + channel, + -EAGAIN, + EAGAIN, + entry, + ); return; } - // Read timeout from origArgs[3] (pointer to struct timespec in process memory). - // Layout: { int64 tv_sec; int64 tv_nsec } — 16 bytes, relative timeout. - let timeoutMs: number | undefined; - if (timeoutPtr !== 0) { - const dataView = new DataView(channel.memory.buffer); - const tv_sec = Number(dataView.getBigInt64(timeoutPtr, true)); - const tv_nsec = Number(dataView.getBigInt64(timeoutPtr + 8, true)); - if (tv_sec < 0 || (tv_sec === 0 && tv_nsec <= 0)) { - // Already expired - this.completeChannelRaw(channel, -ETIMEDOUT, ETIMEDOUT); - this.relistenChannel(channel); - return; - } - timeoutMs = tv_sec * 1000 + Math.ceil(tv_nsec / 1_000_000); - if (timeoutMs <= 0) timeoutMs = 1; // minimum 1ms - // Cap to avoid Node.js TimeoutOverflowWarning (max safe is 2^31-1 ms ≈ 24.8 days). - // Without this, huge timeouts from 32-bit LONG_MAX deadlines get clipped to 1ms - // by Node.js, causing tight retry loops. - if (timeoutMs > 2147483647) timeoutMs = 2147483647; + if (timeoutMs === 0) { + this.completeChannelRawAndRelisten( + channel, + -ETIMEDOUT, + ETIMEDOUT, + entry, + ); + return; } + if ( + this.interruptPendingCancellationBeforeRegistration( + channel, + SYS_FUTEX, + this.#cancellationPointIdentity(channel), + entry, + ) + ) return; // Value matches — wait asynchronously for it to change const waitResult = Atomics.waitAsync(i32View, index, val); @@ -14806,16 +23947,27 @@ export class CentralizedKernelWorker { const settle = (): boolean => { if (settled) return false; settled = true; - if (timer !== undefined) clearTimeout(timer); + if (timer !== undefined) this.#cancelRegisteredTimeout(timer); this.pendingFutexWaits.delete(channel); return true; }; const complete = (retVal: number, errVal: number) => { if (!settle()) return; if (!this.isRegisteredChannel(channel)) return; - this.completeChannelRaw(channel, retVal, errVal); - channel.consecutiveSyscalls = 0; // genuinely blocked — reset - this.relistenChannel(channel); + this.#runOrDeferChannelKernelEntry( + channel, + "futex completion", + (completionEntry) => { + this.completeChannelRawAndRelisten( + channel, + retVal, + errVal, + completionEntry, + ); + channel.consecutiveSyscalls = 0; // genuinely blocked — reset + return undefined; + }, + ); }; const wakeAllEngineWaiters = () => { // waitAsync has no exact-waiter cancellation API. Wake every engine @@ -14836,35 +23988,48 @@ export class CentralizedKernelWorker { settle(); }; - // Track the wait so SYS_THREAD_CANCEL can force-wake this channel - // without leaving an uncancellable engine waiter behind. - this.pendingFutexWaits.set(channel, { - futexIndex: index, - interrupt, - retire, - }); + entry.deferProtocolEffect(() => { + // Track and continue the engine wait only after the scoped kernel + // authority is revoked. The retained closures own process memory + // and channel identity only; every completion opens a fresh entry. + this.pendingFutexWaits.set(channel, { + ...this.#cancellationPointIdentity(channel), + futexIndex: index, + hasTimeout: timeoutDeadline !== undefined, + interrupt, + retire, + }); - waitResult.value.then(() => { - complete(0, 0); - }); + this.#continuePromise(waitResult.value, () => { + complete(0, 0); + }); - if (timeoutMs !== undefined) { - timer = setTimeout(() => { - interrupt(-ETIMEDOUT, ETIMEDOUT); - }, timeoutMs); - } + if (timeoutDeadline !== undefined) { + const armTimeoutChunk = (): void => { + if (settled) return; + const remainingMs = timeoutDeadline - Date.now(); + if (remainingMs <= 0) { + interrupt(-ETIMEDOUT, ETIMEDOUT); + return; + } + timer = this.#registerTimeout(() => { + armTimeoutChunk(); + }, Math.max(Math.ceil(remainingMs), 1)); + }; + armTimeoutChunk(); + } + return undefined; + }); } else { // Already changed — return 0 - this.completeChannelRaw(channel, 0, 0); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten(channel, 0, 0, entry); } return; } if (baseOp === FUTEX_WAKE || baseOp === FUTEX_WAKE_BITSET) { const woken = Atomics.notify(i32View, index, val); - this.completeChannelRaw(channel, woken, 0); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten(channel, woken, 0, entry); return; } @@ -14873,8 +24038,7 @@ export class CentralizedKernelWorker { // so wake val + val2 on uaddr. const val2 = origArgs[3]; // timeout param repurposed as val2 const woken = Atomics.notify(i32View, index, val + val2); - this.completeChannelRaw(channel, woken, 0); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten(channel, woken, 0, entry); return; } @@ -14885,14 +24049,12 @@ export class CentralizedKernelWorker { const index2 = uaddr2 / 4; let woken = Atomics.notify(i32View, index, val); woken += Atomics.notify(i32View, index2, val2); - this.completeChannelRaw(channel, woken, 0); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten(channel, woken, 0, entry); return; } // Unknown futex op — return -ENOSYS - this.completeChannelRaw(channel, -38, 38); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten(channel, -38, 38, entry); } /** @@ -14900,10 +24062,26 @@ export class CentralizedKernelWorker { * Removes thread state from the process's thread table. */ notifyThreadExit(pid: number, tid: number): void { - if (!this.kernelInstance) { - throw new Error("Kernel is not initialized for thread cleanup"); - } - const threadExit = this.kernelInstance.exports.kernel_thread_exit as + this.#runOrDeferKernelEntry( + "thread exit", + (entry) => { + this.#notifyThreadExitWithinKernelEntry( + pid, + tid, + entry, + ); + return undefined; + }, + ); + } + + #notifyThreadExitWithinKernelEntry( + pid: number, + tid: number, + entry?: KernelWorkerEntryContext, + ): void { + const threadExit = this.#kernelInstanceForEntry(entry).exports + .kernel_thread_exit as ((pid: number, tid: number) => number) | undefined; if (!threadExit) { throw new Error("Kernel missing required kernel_thread_exit export"); @@ -14926,10 +24104,15 @@ export class CentralizedKernelWorker { * already have removed this exact, globally non-reused task. Any other * result leaves task ownership uncertain and must remain fatal. */ - private rollbackKernelThread(pid: number, tid: number): void { + #rollbackKernelThreadWithinKernelEntry( + pid: number, + tid: number, + entry: KernelWorkerEntryContext, + ): void { try { - this.notifyThreadExit(pid, tid); + this.#notifyThreadExitWithinKernelEntry(pid, tid, entry); } catch (error) { + this.#rethrowKernelEntryFatal(error); if (error instanceof KernelTaskBindingError && error.errno === ESRCH) { return; } @@ -14947,6 +24130,26 @@ export class CentralizedKernelWorker { * futex word used by joiners. */ finalizeThreadExit(pid: number, tid: number, channelOffset: number): void { + this.#runOrDeferKernelEntry( + "thread exit finalization", + (entry) => { + this.#finalizeThreadExitWithinKernelEntry( + pid, + tid, + channelOffset, + entry, + ); + return undefined; + }, + ); + } + + #finalizeThreadExitWithinKernelEntry( + pid: number, + tid: number, + channelOffset: number, + entry: KernelWorkerEntryContext, + ): void { const ctidKey = `${pid}:${tid}`; const ctidPtr = this.threadCtidPtrs.get(ctidKey); const channel = this.activeChannels.find( @@ -14957,7 +24160,12 @@ export class CentralizedKernelWorker { // Remove authoritative ThreadInfo before any host-memory bookkeeping can // fail. The clone path prevalidates ctid, but this check also rejects stale // or externally-constructed registrations without stranding a kernel TID. - this.notifyThreadExit(pid, tid); + this.#notifyThreadExitWithinKernelEntry(pid, tid, entry); + if (channel) { + // kernel_thread_exit consumed this TID's exact retry binding before it + // removed task authority. Host retirement must not release it twice. + this.#forgetBlockingRetrySnapshotAfterKernelLifecycle(channel); + } try { if (ctidPtr && ctidPtr !== 0) { if (!memory) { @@ -14984,26 +24192,32 @@ export class CentralizedKernelWorker { } } finally { this.threadCtidPtrs.delete(ctidKey); - this.removeChannel(pid, channelOffset); + this.#removeChannelWithinKernelEntry(pid, channelOffset, entry); } } /** Queue one host-scheduled expiration through the ABI-required kernel path. */ private firePosixTimer(pid: number, timerId: number, signum: number): void { - const fire = this.kernelInstance!.exports.kernel_posix_timer_fire as ( - pid: number, - timerId: number, - ) => number; - const targetTid = fire(pid, timerId); - if (targetTid < 0) return; - - if (targetTid > 0) { - this.wakePendingSignalWaits(pid, signum, targetTid); - return; - } + this.#runOrDeferKernelEntry( + `POSIX timer expiration pid=${pid}`, + (entry) => { + const fire = this.#kernelInstanceForEntry(entry).exports.kernel_posix_timer_fire as ( + pid: number, + timerId: number, + ) => number; + const targetTid = fire(pid, timerId); + if (targetTid < 0) return undefined; + + if (targetTid > 0) { + this.wakePendingSignalWaits(pid, signum, targetTid); + return undefined; + } - this.wakePendingSignalWaits(pid, signum); - this.sendSignalToProcess(pid, signum, false); + this.wakePendingSignalWaits(pid, signum); + this.sendSignalToProcess(pid, signum, false, entry); + return undefined; + }, + ); } /** Wake rt_sigtimedwait callers whose mask accepts this signal. */ @@ -15012,6 +24226,11 @@ export class CentralizedKernelWorker { signum: number, targetTid?: number, ): void { + const testHook = this.#scratchBoundaryTestHooks?.wakePendingSignalWaits; + if (testHook) { + testHook(pid, signum, targetTid); + return; + } const matches = Array.from(this.pendingSignalWaits.entries()).filter( ([, entry]) => { if (entry.channel.pid !== pid) return false; @@ -15021,35 +24240,20 @@ export class CentralizedKernelWorker { ) { return false; } - if ( - entry.origArgs[0] === 0 - || signum <= 0 - || signum > 64 - ) return false; - let maskPtr: number; - try { - // The wait crosses a timer/callback boundary. Re-prove the process - // range instead of narrowing the address retained by the first - // dispatch or treating that earlier proof as a lifetime guarantee. - maskPtr = this.checkedProcessRange( - entry.channel, - entry.origArgs[0], - 8, - "pending signal-wait mask", - ).pointer; - } catch { - return false; - } - const mask = new DataView( - entry.channel.memory.buffer, - ).getBigUint64(maskPtr, true); - return (mask & (1n << BigInt(signum - 1))) !== 0n; + if (signum <= 0 || signum > 64) return false; + // WHY: the signal set is part of the logical wait. Caller memory can + // change while the channel is parked, so wake matching consumes only + // the detached mask recorded with the retry. + return ( + entry.signalMask + & (1n << BigInt(signum - 1)) + ) !== 0n; }, ); for (const [key, entry] of matches) { if (this.pendingSignalWaits.get(key) !== entry) continue; - clearTimeout(entry.timer); + this.#cancelRegisteredTimeout(entry.timer); this.pendingSignalWaits.delete(key); if (this.isRegisteredChannel(entry.channel)) { this.retrySyscall(entry.channel); @@ -15060,7 +24264,7 @@ export class CentralizedKernelWorker { private cleanupPendingSignalWaits(pid: number): void { for (const [key, entry] of this.pendingSignalWaits ?? []) { if (entry.channel.pid !== pid) continue; - clearTimeout(entry.timer); + this.#cancelRegisteredTimeout(entry.timer); this.pendingSignalWaits.delete(key); this.signalWaitDeadlines?.delete(key); } @@ -15069,6 +24273,52 @@ export class CentralizedKernelWorker { } } + /** + * Interrupt the exact host-owned futex selected for one caught signal. + * + * The dequeue must precede the host wake: it both proves that this TID owns + * a caught handler action and copies the immutable signal record into that + * mailbox. A raw Atomics.notify would instead complete the futex with + * success and strand the handler metadata in Rust. + */ + private interruptPendingFutexForCaughtSignal( + targetPid: number, + signum: number, + entry: KernelWorkerEntryContext, + directedTid?: number, + ): boolean { + const targetTid = directedTid + === undefined + ? this.#pickKernelSignalTargetTid(targetPid, signum, entry) + : this.#validateKernelSignalTargetTid(directedTid); + if (targetTid <= 0) return false; + + const registration = this.processes.get(targetPid); + const target = registration?.channels.find( + (candidate) => + this.guestTidForChannel(candidate) === targetTid + && this.isRegisteredChannel(candidate), + ); + if (!target) return false; + const wait = this.pendingFutexWaits.get(target); + if (!wait) return false; + + if (!this.#kernelThreadHasDeliverable(targetPid, targetTid, entry)) { + return false; + } + + const deliveredSignal = this.#dequeueSignalForDelivery( + target, + entry, + wait.hasTimeout, + ); + if (this.#finishSignalTermination(target, entry)) return true; + if (deliveredSignal <= 0) return false; + + wait.interrupt(-EINTR_ERRNO, EINTR_ERRNO); + return true; + } + /** * Queue a signal on a target process in the kernel by invoking SYS_KILL * through kernel_handle_channel. The signal is queued in the kernel's @@ -15079,8 +24329,29 @@ export class CentralizedKernelWorker { targetPid: number, signum: number, queueSignal = true, + entry?: KernelWorkerEntryContext, ): void { - if (!this.kernelInstance || !this.kernelMemory) return; + const testHook = this.#scratchBoundaryTestHooks?.sendSignalToProcess; + if (testHook) { + testHook(targetPid, signum, queueSignal); + return; + } + if (!this.#kernelInstance || !this.#kernelMemory) return; + if (!entry) { + this.#runOrDeferKernelEntry( + `host signal pid=${targetPid}`, + (kernelEntry) => { + this.sendSignalToProcess( + targetPid, + signum, + queueSignal, + kernelEntry, + ); + return undefined; + }, + ); + return; + } // Do not gate on the host registration map: exec temporarily removes the // old worker registration while the same kernel Process (and its alarm) @@ -15092,13 +24363,14 @@ export class CentralizedKernelWorker { // kernel-owned leader rather than relying on an implicit main-thread // sentinel or state left over from a prior dispatch. try { - this.bindKernelTid(targetPid, targetPid); - } catch { + this.#bindKernelTid(targetPid, targetPid, entry); + } catch (error) { + this.#rethrowKernelEntryFatal(error); return; } this.currentHandlePid = targetPid; try { - this.requireMainScratchRegion().withLease((lease) => { + this.#requireMainScratchRegion().withLease((lease) => { const kernelView = lease.dataView(0, CH_TOTAL_SIZE); // Write SYS_KILL into scratch: kill(targetPid, signum) kernelView.setUint32(CH_SYSCALL, SYS_KILL, true); @@ -15111,13 +24383,20 @@ export class CentralizedKernelWorker { for (let i = 2; i < CH_ARGS_COUNT; i++) { kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); } - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - targetPid, - ]); + this.#invokeEntryScratchExport( + entry, + lease, + "kernel_handle_channel", + [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + targetPid, + 0n, + ], + ); }); } catch (err) { + this.#rethrowKernelEntryFatal(err); // Non-fatal — signal delivery is best-effort from the host side console.error( `[sendSignalToProcess] kernel threw for pid=${targetPid} sig=${signum}: ${err}`, @@ -15133,36 +24412,45 @@ export class CentralizedKernelWorker { // Signal generation can synchronously stop or continue one or many // processes. Consume those transition events before any deliverability // query or blocked-syscall retry observes the target. - this.drainAndProcessWakeupEvents(); + this.#drainAndProcessWakeupEventsWithinKernelEntry(entry); // Default terminating actions are applied inside kernel_handle_channel. // Retire a newly exited worker before considering any blocking-channel // wakeup; guest code must not resume after signal death. - this.reapKilledProcessesAfterSyscall(); - if (this.getProcessExitSignal(targetPid) > 0) return; + this.reapKilledProcessesAfterSyscall(entry); + if (this.#getProcessExitSignal(targetPid, entry) > 0) return; // wait4/waitid live outside the generic retry maps. Prefer any matching // child status, then interrupt only the exact thread selected for this // caught signal. If it was serviced, the signal has been consumed. - if (this.interruptWaitingChildForSignal(targetPid, signum)) return; + if (this.interruptWaitingChildForSignal(targetPid, signum, entry)) return; // Select the exact eligible thread before waking a per-thread blocking // channel. A process-level "some thread accepts this" answer is not enough: // waking a different sleeper can complete its nanosleep early while leaving // the shared signal pending for the intended thread. - const pickSignalTarget = this.kernelInstance!.exports - .kernel_pick_signal_target_tid as - (pid: number, signum: number) => number; - const targetTid = pickSignalTarget(targetPid, signum); + const targetTid = this.#pickKernelSignalTargetTid( + targetPid, + signum, + entry, + ); if (targetTid <= 0) return; // Ignored and default-ignore signals are consumed inside the kernel. Do // not shorten a sleep merely because its mask would have accepted a // signal that is no longer pending. - const threadHasDeliverable = this.kernelInstance!.exports - .kernel_thread_has_deliverable as - (pid: number, tid: number) => number; - if (threadHasDeliverable(targetPid, targetTid) <= 0) return; + if ( + !this.#kernelThreadHasDeliverable(targetPid, targetTid, entry) + ) return; + + if ( + this.interruptPendingFutexForCaughtSignal( + targetPid, + signum, + entry, + targetTid, + ) + ) return; // Signal is deliverable — wake any blocking syscall for this process @@ -15173,11 +24461,13 @@ export class CentralizedKernelWorker { ); if (pendingSleepMatch) { const [sleepChannel, pendingSleep] = pendingSleepMatch; - clearTimeout(pendingSleep.timer); + this.#cancelRegisteredTimeout(pendingSleep.timer); this.pendingSleeps.delete(sleepChannel); - this.completeSleepWithSignalCheck( + this.#completeSleepWithSignalCheckWithinKernelEntry( pendingSleep.channel, pendingSleep.syscallNr, pendingSleep.origArgs, pendingSleep.retVal, pendingSleep.errVal, + [], + entry, ); } @@ -15197,7 +24487,7 @@ export class CentralizedKernelWorker { ); for (const [key, pollEntry] of pollMatches) { if (this.pendingPollRetries.get(key) !== pollEntry) continue; - if (pollEntry.timer) clearTimeout(pollEntry.timer); + if (pollEntry.timer) this.#cancelRegisteredTimeout(pollEntry.timer); this.pendingPollRetries.delete(key); if (this.processes.has(targetPid)) { this.retrySyscall(pollEntry.channel); @@ -15214,7 +24504,7 @@ export class CentralizedKernelWorker { : []; for (const [key, entry] of advisoryMatches) { if (advisoryRetries.get(key) !== entry) continue; - clearTimeout(entry.timer); + this.#cancelRegisteredTimeout(entry.timer); advisoryRetries.delete(key); if (this.isRegisteredChannel(entry.channel)) { this.retrySyscall(entry.channel); @@ -15227,15 +24517,13 @@ export class CentralizedKernelWorker { ); for (const [key, selectEntry] of selectMatches) { if (this.pendingSelectRetries.get(key) !== selectEntry) continue; - clearTimeout(selectEntry.timer); - clearImmediate(selectEntry.timer); + this.#cancelRegisteredTimeout(selectEntry.timer); + this.#cancelRegisteredImmediate(selectEntry.timer); this.pendingSelectRetries.delete(key); if (!this.processes.has(targetPid)) continue; - if (selectEntry.syscallNr === SYS_SELECT) { - this.handleSelect(selectEntry.channel, selectEntry.origArgs); - } else { - this.handlePselect6(selectEntry.channel, selectEntry.origArgs); - } + // A directed signal may target another pending mailbox. Re-enter that + // exact channel through its public root after this scope unwinds. + this.retrySyscall(selectEntry.channel); } } @@ -15252,6 +24540,7 @@ export class CentralizedKernelWorker { private ensureFixedMmapProcessMemoryCapacity( channel: ChannelInfo, origArgs: number[], + entry: KernelWorkerEntryContext, ): boolean { const addr = origArgs[0]; const len = origArgs[1]; @@ -15269,15 +24558,17 @@ export class CentralizedKernelWorker { const ptrWidth = this.processes.get(channel.pid)?.ptrWidth ?? 4; growMemoryToCover(channel.memory, end, ptrWidth); if (channel.memory.buffer.byteLength < end) return false; - this.observeProcessMemoryTarget( - channel.memory, - channel.memory.buffer, - ); + const grownBuffer = channel.memory.buffer; + entry.deferObserverEffect(() => { + this.observeProcessMemoryTarget(channel.memory, grownBuffer); + return undefined; + }); // Growth appends zero pages and does not overwrite the MAP_FIXED target. // Rebind only consumers whose cached view was detached by memory.grow. - this.kernel.framebuffers.rebindMemory(channel.pid); + this.#kernel.framebuffers.rebindMemory(channel.pid); return true; - } catch { + } catch (error) { + this.#rethrowKernelEntryFatal(error); // Memory.grow itself is irreversible if a later step fails, but it never // mutates the old fixed interval. Capacity failure stays pre-kernel. return false; @@ -15290,6 +24581,7 @@ export class CentralizedKernelWorker { syscallNr: number, retVal: number, origArgs: number[], + entry: KernelWorkerEntryContext, ): void { if (!Number.isSafeInteger(retVal) || retVal < 0) { throw new KernelScratchError( @@ -15352,15 +24644,16 @@ export class CentralizedKernelWorker { if (endAddr > 0 && endAddr > currentBytes) { growMemoryToCover(processMemory, endAddr, ptrWidth); - this.observeProcessMemoryTarget( - processMemory, - processMemory.buffer, - ); + const grownBuffer = processMemory.buffer; + entry.deferObserverEffect(() => { + this.observeProcessMemoryTarget(processMemory, grownBuffer); + return undefined; + }); // Memory.grow detaches any TypedArray bound to the previous SAB. // Any cached framebuffer view on this pid is now invalid; the // renderer must rebuild it on the next frame from the new // Memory.buffer. Idempotent for pids without a binding. - this.kernel.framebuffers.rebindMemory(pid); + this.#kernel.framebuffers.rebindMemory(pid); } // Zero the mmap'd region. Anonymous mmap must return zeroed pages (like @@ -15476,7 +24769,14 @@ export class CentralizedKernelWorker { private synchronizeSharedMemoryForBoundary( process: Pick, + entry?: KernelWorkerEntryContext, ): void { + const testHook = + this.#scratchBoundaryTestHooks?.synchronizeSharedMemoryForBoundary; + if (testHook) { + testHook(process); + return; + } const registration = this.processes?.get(process.pid); if (registration && registration.memory !== process.memory) return; if (this.processes && !registration) return; @@ -15485,8 +24785,8 @@ export class CentralizedKernelWorker { && (this.shmMappings?.size ?? 0) === 0 ) return; this.syncAnonymousSharedMappingsFromProcess(process); - this.syncFileSharedMappingsFromProcess(process); - this.syncSysvShmMappingsFromProcess(process); + this.syncFileSharedMappingsFromProcess(process, {}, entry); + this.syncSysvShmMappingsFromProcess(process, {}, entry); } /** @@ -15555,14 +24855,22 @@ export class CentralizedKernelWorker { channel: ChannelInfo, mapAddr: number, origArgs: number[], + rawPageOffset: bigint, + entry?: KernelWorkerEntryContext, ): FileSharedMmapResult { if (origArgs[1] === 0) return { kind: "mapped" }; - const preparation = this.prepareSharedMmapFromFile(channel, origArgs); + const preparation = this.prepareSharedMmapFromFile( + channel, + origArgs, + rawPageOffset, + entry, + ); if (preparation.kind !== "prepared") return preparation; return this.registerPreparedSharedMmap( channel, mapAddr, preparation.context, + entry, ); } @@ -15574,19 +24882,20 @@ export class CentralizedKernelWorker { private prepareSharedMmapFromFile( channel: ChannelInfo, origArgs: number[], + rawPageOffset: bigint, + entry?: KernelWorkerEntryContext, ): FileSharedMmapPreparationResult { const fd = origArgs[4]; const len = origArgs[1]; - const pageOffset = origArgs[5]; - const fileOffset = pageOffset * FILE_PAGE_SIZE; + const fileOffsetBig = rawPageOffset * BigInt(FILE_PAGE_SIZE); if ( - !Number.isSafeInteger(pageOffset) - || pageOffset < 0 - || !Number.isSafeInteger(fileOffset) + rawPageOffset < 0n + || fileOffsetBig > BigInt(Number.MAX_SAFE_INTEGER) ) return { kind: "error", errno: EINVAL }; + const fileOffset = Number(fileOffsetBig); const writable = (origArgs[2] & PROT_WRITE) !== 0; - const statResult = this.getFdStatForSharedMapping(channel, fd); + const statResult = this.getFdStatForSharedMapping(channel, fd, entry); if (statResult.kind === "error") return statResult; const stat = statResult.value; if ((stat.mode & 0o170000) !== 0o100000) return { kind: "unsupported" }; @@ -15596,7 +24905,11 @@ export class CentralizedKernelWorker { // kernel-owned mapping bridge; MAP_PRIVATE keeps its fd-pread path. return { kind: "error", errno: ENOTSUP }; } - const accessResult = this.getFdAccessModeForSharedMapping(channel, fd); + const accessResult = this.getFdAccessModeForSharedMapping( + channel, + fd, + entry, + ); if (accessResult.kind === "error") return accessResult; const accessMode = accessResult.value; // POSIX file mappings require a readable descriptor. A shared writable @@ -15605,10 +24918,14 @@ export class CentralizedKernelWorker { // or an in-kernel synthetic object. if (accessMode === O_WRONLY) return { kind: "error", errno: EACCES }; const writeAllowed = accessMode === O_RDWR - && this.fdSupportsMmapWriteback(channel.pid, fd); + && this.fdSupportsMmapWriteback(channel.pid, fd, entry); if (writable && !writeAllowed) return { kind: "error", errno: EACCES }; - const keyResult = this.resolveSharedMmapBackingKey(stat, stat.hostHandle); + const keyResult = this.resolveSharedMmapBackingKey( + stat, + stat.hostHandle, + entry, + ); if (keyResult.kind === "error") return keyResult; const key = keyResult.value; // Preserve the fd's lifetime capability, not merely the initial @@ -15618,6 +24935,7 @@ export class CentralizedKernelWorker { key, stat, writeAllowed, + entry, ); if (backingResult.kind === "error") return backingResult; const backing = backingResult.value; @@ -15627,9 +24945,15 @@ export class CentralizedKernelWorker { // its mapping on every syscall. Before another mapping joins, publish // every existing observer so the new mapping starts from the latest // shared state rather than the last persisted/cache snapshot. - this.publishSharedMmapBackingObservers(backing); - this.ensureSharedMmapBackingRangeLoaded(backing, fileOffset, len); + this.publishSharedMmapBackingObservers(backing, entry); + this.ensureSharedMmapBackingRangeLoaded( + backing, + fileOffset, + len, + entry, + ); } catch (err) { + this.#rethrowKernelEntryFatal(err); this.discardUnreferencedSharedMmapBacking(backing); return { kind: "error", errno: this.sharedMmapErrno(err) }; } @@ -15656,18 +24980,24 @@ export class CentralizedKernelWorker { channel: ChannelInfo, mapAddr: number, context: PreparedFileSharedMmap, + entry?: KernelWorkerEntryContext, ): FileSharedMmapResult { const { fd, fileOffset, len, writable, writeAllowed, backing } = context; try { const processMem = new Uint8Array(channel.memory.buffer); if (mapAddr + len > processMem.length) { - this.releasePreparedSharedMmap(context); + this.releasePreparedSharedMmap(context, entry); return { kind: "error", errno: EIO }; } // Re-read the authoritative cache here rather than storing preflight // bytes: MAP_FIXED first flushes the replaced interval, which may refer // to this same backing and advance it after preparation. - const initial = this.readSharedMmapBackingRange(backing, fileOffset, len); + const initial = this.readSharedMmapBackingRange( + backing, + fileOffset, + len, + entry, + ); processMem.set(initial, mapAddr); let pidMap = this.sharedMappings.get(channel.pid); if (!pidMap) { @@ -15692,7 +25022,8 @@ export class CentralizedKernelWorker { }); return { kind: "mapped" }; } catch (err) { - this.releasePreparedSharedMmap(context); + this.#rethrowKernelEntryFatal(err); + this.releasePreparedSharedMmap(context, entry); return { kind: "error", errno: this.sharedMmapErrno(err) }; } } @@ -15701,9 +25032,17 @@ export class CentralizedKernelWorker { private resolveSharedMmapBackingKey( stat: SharedMmapFdStat, handle: number, + entry?: KernelWorkerEntryContext, ): SharedMmapHostResult { try { - const key = this.io.fileHandleIdentity?.(handle, stat.dev, stat.ino) ?? null; + const key = this.#invokeSharedMmapHostOperation( + entry, + () => this.io.fileHandleIdentity?.( + handle, + stat.dev, + stat.ino, + ) ?? null, + ); return key ? { kind: "ok", value: key } : { kind: "error", errno: ENOTSUP }; @@ -15715,7 +25054,11 @@ export class CentralizedKernelWorker { private getFdStatForSharedMapping( channel: Pick, fd: number, + entry?: KernelWorkerEntryContext, ): SharedMmapHostResult { + const testHook = + this.#scratchBoundaryTestHooks?.getFdStatForSharedMapping; + if (testHook) return testHook(channel, fd); const previousPid = this.currentHandlePid; let captured: { result: number; @@ -15727,9 +25070,9 @@ export class CentralizedKernelWorker { hostHandle: number | null; }; try { - this.bindKernelTidForChannel(channel as ChannelInfo); + this.#bindKernelTidForChannel(channel as ChannelInfo, entry); this.currentHandlePid = channel.pid; - captured = this.requireMainScratchRegion().withLease((lease) => { + captured = this.#requireMainScratchRegion().withLease((lease) => { const kernelView = lease.dataView(0, CH_TOTAL_SIZE); kernelView.setUint32(CH_SYSCALL, ABI_SYSCALLS.Fstat, true); kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); @@ -15742,16 +25085,22 @@ export class CentralizedKernelWorker { for (let i = 2; i < CH_ARGS_COUNT; i++) { kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); } - const captureToken = this.kernel.beginFstatHandleCapture(); + const captureToken = this.#kernel.beginFstatHandleCapture(); let hostHandle: number | null = null; try { - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - channel.pid, - ]); + this.#invokeEntryScratchExport( + entry, + lease, + "kernel_handle_channel", + [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + 0n, + ], + ); } finally { - hostHandle = this.kernel.finishFstatHandleCapture(captureToken); + hostHandle = this.#kernel.finishFstatHandleCapture(captureToken); } const resultView = lease.dataView(0, CH_TOTAL_SIZE); const statView = lease.dataView(CH_DATA, STRUCT_SIZE_WASM_STAT); @@ -15765,12 +25114,13 @@ export class CentralizedKernelWorker { hostHandle, }; }); - } catch { + } catch (error) { + this.#rethrowKernelEntryFatal(error); return { kind: "error", errno: EIO }; } finally { this.currentHandlePid = previousPid; } - if (this.finishSignalTermination(channel as ChannelInfo)) { + if (this.#finishSignalTermination(channel as ChannelInfo, entry)) { return { kind: "error", errno: EINTR_ERRNO }; } const { result, errno, dev, ino, mode, size64, hostHandle } = captured; @@ -15798,21 +25148,30 @@ export class CentralizedKernelWorker { private getFdPathForSharedMapping( channel: Pick, fd: number, + entry?: KernelWorkerEntryContext, ): SharedMmapHostResult { - const getFdPath = this.kernelInstance!.exports.kernel_get_fd_path as + const testHook = + this.#scratchBoundaryTestHooks?.getFdPathForSharedMapping; + if (testHook) return testHook(channel, fd); + const getFdPath = this.#kernelInstanceForEntry(entry).exports.kernel_get_fd_path as ((pid: number, fd: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; if (!getFdPath) return { kind: "error", errno: ENOSYS }; let output: { result: number; bytes: Uint8Array }; try { const capacity = Math.min(POSIX_PATH_MAX_BYTES, CH_DATA_SIZE); - output = this.requireMainScratchRegion().withLease((lease) => { - const result = lease.invokeKernelExport("kernel_get_fd_path", [ - channel.pid, - fd, - lease.exportPointer(0, capacity), - capacity, - ]); - const byteLength = this.checkedScratchProducerByteLength( + output = this.#requireMainScratchRegion().withLease((lease) => { + const result = this.#invokeEntryScratchExport( + entry, + lease, + "kernel_get_fd_path", + [ + channel.pid, + fd, + lease.exportPointer(0, capacity), + capacity, + ], + ); + const byteLength = this.#checkedScratchProducerByteLength( result, capacity, "kernel_get_fd_path", @@ -15824,7 +25183,8 @@ export class CentralizedKernelWorker { : lease.copyOut(0, byteLength), }; }); - } catch { + } catch (error) { + this.#rethrowKernelEntryFatal(error); return { kind: "error", errno: EIO }; } const len = output.result; @@ -15839,13 +25199,17 @@ export class CentralizedKernelWorker { private getFdAccessModeForSharedMapping( channel: Pick, fd: number, + entry?: KernelWorkerEntryContext, ): SharedMmapHostResult { + const testHook = + this.#scratchBoundaryTestHooks?.getFdAccessModeForSharedMapping; + if (testHook) return testHook(channel, fd); const previousPid = this.currentHandlePid; let captured: { result: number; errno: number }; try { - this.bindKernelTidForChannel(channel as ChannelInfo); + this.#bindKernelTidForChannel(channel as ChannelInfo, entry); this.currentHandlePid = channel.pid; - captured = this.requireMainScratchRegion().withLease((lease) => { + captured = this.#requireMainScratchRegion().withLease((lease) => { const kernelView = lease.dataView(0, CH_TOTAL_SIZE); kernelView.setUint32(CH_SYSCALL, SYS_FCNTL, true); kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); @@ -15857,23 +25221,30 @@ export class CentralizedKernelWorker { for (let i = 2; i < CH_ARGS_COUNT; i++) { kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); } - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - channel.pid, - ]); + this.#invokeEntryScratchExport( + entry, + lease, + "kernel_handle_channel", + [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + 0n, + ], + ); const resultView = lease.dataView(0, CH_TOTAL_SIZE); return { result: Number(resultView.getBigInt64(CH_RETURN, true)), errno: resultView.getUint32(CH_ERRNO, true), }; }); - } catch { + } catch (error) { + this.#rethrowKernelEntryFatal(error); return { kind: "error", errno: EIO }; } finally { this.currentHandlePid = previousPid; } - if (this.finishSignalTermination(channel as ChannelInfo)) { + if (this.#finishSignalTermination(channel as ChannelInfo, entry)) { return { kind: "error", errno: EINTR_ERRNO }; } const { result, errno } = captured; @@ -15890,6 +25261,7 @@ export class CentralizedKernelWorker { key: string, source: SharedMmapFdStat, sourceWritable: boolean, + entry?: KernelWorkerEntryContext, ): SharedMmapHostResult { const sourceHandle = source.hostHandle; if (sourceHandle === null) return { kind: "error", errno: ENOTSUP }; @@ -15902,8 +25274,9 @@ export class CentralizedKernelWorker { return { kind: "error", errno: EIO }; } try { - this.kernel.retainHostFileHandle(sourceHandle); + this.#kernel.retainHostFileHandle(sourceHandle); } catch (err) { + this.#rethrowKernelEntryFatal(err); return { kind: "error", errno: this.sharedMmapErrno(err) }; } const oldHandle = existing.handle; @@ -15911,17 +25284,18 @@ export class CentralizedKernelWorker { existing.writable = true; existing.size = source.size; existing.sizeValid = true; - this.kernel.releaseHostFileHandle(oldHandle); + this.#kernel.releaseHostFileHandle(oldHandle); } else { - const errno = this.revalidateSharedMmapBacking(existing); + const errno = this.revalidateSharedMmapBacking(existing, entry); if (errno !== 0) return { kind: "error", errno }; } return { kind: "ok", value: existing }; } try { - this.kernel.retainHostFileHandle(sourceHandle); + this.#kernel.retainHostFileHandle(sourceHandle); } catch (err) { + this.#rethrowKernelEntryFatal(err); return { kind: "error", errno: this.sharedMmapErrno(err) }; } const backing: SharedMmapBacking = { @@ -15940,29 +25314,54 @@ export class CentralizedKernelWorker { return { kind: "ok", value: backing }; } - private revalidateSharedMmapBacking(backing: SharedMmapBacking): number { + private revalidateSharedMmapBacking( + backing: SharedMmapBacking, + entry?: KernelWorkerEntryContext, + ): number { + const expectedHandle = backing.handle; + const expectedKey = backing.key; try { - const stat = this.io.fstat(backing.handle); - if (!Number.isSafeInteger(stat.size) || stat.size < 0) { + const snapshot = this.#invokeSharedMmapHostOperation(entry, () => { + const stat = this.io.fstat(expectedHandle); + const dev = BigInt(stat.dev); + const ino = BigInt(stat.ino); + return { + dev, + ino, + mode: stat.mode, + size: stat.size, + key: this.io.fileHandleIdentity?.( + expectedHandle, + dev, + ino, + ) ?? null, + }; + }); + if ( + backing.handle !== expectedHandle + || backing.key !== expectedKey + || this.sharedMmapBackings.get(expectedKey) !== backing + ) { + backing.sizeValid = false; + return EIO; + } + if ( + !Number.isSafeInteger(snapshot.size) + || snapshot.size < 0 + || !Number.isSafeInteger(snapshot.mode) + ) { backing.sizeValid = false; return EIO; } - if ((stat.mode & 0o170000) !== 0o100000) { + if ((snapshot.mode & 0o170000) !== 0o100000) { backing.sizeValid = false; return EIO; } - const actual = this.resolveSharedMmapBackingKey({ - dev: BigInt(stat.dev), - ino: BigInt(stat.ino), - mode: stat.mode, - size: stat.size, - hostHandle: backing.handle, - }, backing.handle); - if (actual.kind === "error" || actual.value !== backing.key) { + if (!snapshot.key || snapshot.key !== expectedKey) { backing.sizeValid = false; - return actual.kind === "error" ? actual.errno : EIO; + return snapshot.key ? EIO : ENOTSUP; } - backing.size = stat.size; + backing.size = snapshot.size; backing.sizeValid = true; return 0; } catch (err) { @@ -15982,7 +25381,7 @@ export class CentralizedKernelWorker { // Preserve its dirty pages and stable handle for a later same-object map // rather than silently discarding acknowledged MAP_SHARED stores. if (backing.dirtyPages.size > 0) return; - this.kernel.releaseHostFileHandle(backing.handle); + this.#kernel.releaseHostFileHandle(backing.handle); this.sharedMmapBackings.delete(backing.key); this.invalidateSharedMmapFdCache(); } @@ -15991,23 +25390,25 @@ export class CentralizedKernelWorker { backing: SharedMmapBacking, offset: number, len: number, + entry?: KernelWorkerEntryContext, ): void { if (len <= 0) return; const firstPage = Math.floor(offset / FILE_PAGE_SIZE); const lastPage = Math.floor((offset + len - 1) / FILE_PAGE_SIZE); for (let page = firstPage; page <= lastPage; page++) { - this.ensureSharedMmapBackingPageLoaded(backing, page); + this.ensureSharedMmapBackingPageLoaded(backing, page, entry); } } private ensureSharedMmapBackingPageLoaded( backing: SharedMmapBacking, page: number, + entry?: KernelWorkerEntryContext, ): Uint8Array { const existing = backing.pages.get(page); if (existing) return existing; if (!backing.sizeValid) { - const errno = this.revalidateSharedMmapBacking(backing); + const errno = this.revalidateSharedMmapBacking(backing, entry); if (errno !== 0) { const err = new Error("Cannot determine MAP_SHARED backing size") as Error & { code: number }; @@ -16015,41 +25416,69 @@ export class CentralizedKernelWorker { throw err; } } - const loaded = this.readSharedMmapBackingPage(backing, page); + const loaded = this.readSharedMmapBackingPage(backing, page, entry); backing.pages.set(page, loaded); return loaded; } - private readSharedMmapBackingPage(backing: SharedMmapBacking, page: number): Uint8Array { - const bytes = new Uint8Array(FILE_PAGE_SIZE); + private readSharedMmapBackingPage( + backing: SharedMmapBacking, + page: number, + entry?: KernelWorkerEntryContext, + ): Uint8Array { if (!backing.sizeValid) throw new Error("Unknown MAP_SHARED backing size"); + const expectedHandle = backing.handle; + const expectedKey = backing.key; + const expectedSize = backing.size; const pageOffset = page * FILE_PAGE_SIZE; - const readable = Math.max(0, Math.min(FILE_PAGE_SIZE, backing.size - pageOffset)); - if (readable === 0) return bytes; - let total = 0; - while (total < readable) { - const remaining = readable - total; - const read = this.io.read( - backing.handle, - bytes.subarray(total), - pageOffset + total, - remaining, - ); - if (read <= 0 || read > remaining) { - // fstat declared these bytes readable. A premature EOF means the file - // raced with this snapshot (or the backend violated count semantics), - // so zero-filling would manufacture data and must fail coherently. - throw new Error(`Invalid MAP_SHARED backing read length: ${read}`); + const readable = Math.max( + 0, + Math.min(FILE_PAGE_SIZE, expectedSize - pageOffset), + ); + const staged = this.#invokeSharedMmapHostOperation(entry, () => { + const bytes = new Uint8Array(FILE_PAGE_SIZE); + let total = 0; + while (total < readable) { + const remaining = readable - total; + const read = this.io.read( + expectedHandle, + bytes.subarray(total), + pageOffset + total, + remaining, + ); + if ( + !Number.isSafeInteger(read) + || read <= 0 + || read > remaining + ) { + // fstat declared these bytes readable. A premature EOF means the + // file raced with this snapshot (or the backend violated count + // semantics), so zero-filling would manufacture data. + throw new Error(`Invalid MAP_SHARED backing read length: ${read}`); + } + total += read; } - total += read; + // The backend receives views into `bytes`; publish a copy so a retained + // backend view cannot mutate the committed cache after return. + return new Uint8Array(bytes); + }); + if ( + backing.handle !== expectedHandle + || backing.key !== expectedKey + || backing.size !== expectedSize + || !backing.sizeValid + || this.sharedMmapBackings.get(expectedKey) !== backing + ) { + throw new Error("MAP_SHARED backing changed during staged read"); } - return bytes; + return staged; } private readSharedMmapBackingRange( backing: SharedMmapBacking, offset: number, len: number, + entry?: KernelWorkerEntryContext, ): Uint8Array { const result = new Uint8Array(len); let copied = 0; @@ -16059,7 +25488,7 @@ export class CentralizedKernelWorker { const pageOffset = absolute % FILE_PAGE_SIZE; const count = Math.min(FILE_PAGE_SIZE - pageOffset, len - copied); result.set( - this.ensureSharedMmapBackingPageLoaded(backing, page) + this.ensureSharedMmapBackingPageLoaded(backing, page, entry) .subarray(pageOffset, pageOffset + count), copied, ); @@ -16073,6 +25502,7 @@ export class CentralizedKernelWorker { offset: number, bytes: Uint8Array, markDirty: boolean, + entry?: KernelWorkerEntryContext, ): void { let copied = 0; while (copied < bytes.length) { @@ -16081,7 +25511,7 @@ export class CentralizedKernelWorker { const pageOffset = absolute % FILE_PAGE_SIZE; const count = Math.min(FILE_PAGE_SIZE - pageOffset, bytes.length - copied); const wasDirty = backing.dirtyPages.has(page); - this.ensureSharedMmapBackingPageLoaded(backing, page).set( + this.ensureSharedMmapBackingPageLoaded(backing, page, entry).set( bytes.subarray(copied, copied + count), pageOffset, ); @@ -16094,6 +25524,7 @@ export class CentralizedKernelWorker { private syncFileSharedMappingsFromProcess( process: Pick, options: { force?: boolean } = {}, + entry?: KernelWorkerEntryContext, ): void { const mappings = this.sharedMappings?.get(process.pid); if (!mappings) return; @@ -16137,6 +25568,7 @@ export class CentralizedKernelWorker { offset, mapping.fileOffset + offset, len, + entry, )) changed = true; } } @@ -16157,6 +25589,7 @@ export class CentralizedKernelWorker { backing, mapping.fileOffset, mapping.len, + entry, ), })); for (const { mapAddr, mapping, backing, latest } of refreshes) { @@ -16167,7 +25600,10 @@ export class CentralizedKernelWorker { } /** Force all current mappings to publish before a new observer or fd read. */ - private publishSharedMmapBackingObservers(backing: SharedMmapBacking): void { + private publishSharedMmapBackingObservers( + backing: SharedMmapBacking, + entry?: KernelWorkerEntryContext, + ): void { if (backing.refCount <= 0) return; const observerPids = new Set(); for (const [pid, mappings] of this.sharedMappings) { @@ -16183,7 +25619,11 @@ export class CentralizedKernelWorker { if (!registration) { throw new Error(`Missing process memory for MAP_SHARED observer ${pid}`); } - this.syncFileSharedMappingsFromProcess(registration, { force: true }); + this.syncFileSharedMappingsFromProcess( + registration, + { force: true }, + entry, + ); } } @@ -16195,6 +25635,7 @@ export class CentralizedKernelWorker { snapshotOffset: number, backingOffset: number, len: number, + entry?: KernelWorkerEntryContext, ): boolean { let changed = false; let i = 0; @@ -16210,6 +25651,7 @@ export class CentralizedKernelWorker { backingOffset + start, source.subarray(sourceOffset + start, sourceOffset + i), true, + entry, ); changed = true; } @@ -16220,6 +25662,7 @@ export class CentralizedKernelWorker { backing: SharedMmapBacking, offset: number, len: number, + entry?: KernelWorkerEntryContext, ): boolean { if (len <= 0 || backing.dirtyPages.size === 0) return true; if (!backing.sizeValid) return false; @@ -16241,11 +25684,20 @@ export class CentralizedKernelWorker { const writeStart = Math.max(offset, pageStart); const validPageEnd = Math.min(pageEnd, backing.size); const writeEnd = Math.min(end, validPageEnd); - const source = this.ensureSharedMmapBackingPageLoaded(backing, page).subarray( + const source = this.ensureSharedMmapBackingPageLoaded( + backing, + page, + entry, + ).subarray( writeStart - pageStart, writeEnd - pageStart, ); - if (!this.writeAllToSharedMmapBacking(backing, source, writeStart)) { + if (!this.writeAllToSharedMmapBacking( + backing, + source, + writeStart, + entry, + )) { success = false; continue; } @@ -16260,36 +25712,58 @@ export class CentralizedKernelWorker { backing: SharedMmapBacking, source: Uint8Array, offset: number, + entry?: KernelWorkerEntryContext, ): boolean { - let written = 0; - while (written < source.length) { - try { - const count = this.io.write( - backing.handle, - source.subarray(written), - offset + written, - source.length - written, - ); - if (count <= 0) return false; - written += count; - } catch { - return false; - } + const expectedHandle = backing.handle; + const expectedKey = backing.key; + const ownedSource = new Uint8Array(source); + try { + const success = this.#invokeSharedMmapHostOperation(entry, () => { + let written = 0; + while (written < ownedSource.length) { + const remaining = ownedSource.length - written; + const count = this.io.write( + expectedHandle, + ownedSource.subarray(written), + offset + written, + remaining, + ); + if ( + !Number.isSafeInteger(count) + || count <= 0 + || count > remaining + ) return false; + written += count; + } + return true; + }); + return success + && backing.handle === expectedHandle + && backing.key === expectedKey + && this.sharedMmapBackings.get(expectedKey) === backing; + } catch (error) { + this.#rethrowKernelEntryFatal(error); + return false; } - return true; } private flushSharedMappingsBeforeFileSyscall( channel: ChannelInfo, syscallNr: number, origArgs: number[], + entry?: KernelWorkerEntryContext, ): boolean { if ((this.sharedMmapBackings?.size ?? 0) === 0) return true; try { if (syscallNr === SYS_TRUNCATE) { - const path = this.resolveSharedMmapPath(channel, origArgs[0]); + const path = this.resolveSharedMmapPath( + channel, + origArgs[0], + AT_FDCWD, + entry, + ); return path.kind === "error" - || this.flushSharedBackingForPath(path.value); + || this.flushSharedBackingForPath(path.value, entry); } if (syscallNr === SYS_OPEN || syscallNr === SYS_OPENAT) { const flags = syscallNr === SYS_OPEN ? origArgs[1] : origArgs[2]; @@ -16298,9 +25772,10 @@ export class CentralizedKernelWorker { channel, syscallNr === SYS_OPEN ? origArgs[0] : origArgs[1], syscallNr === SYS_OPENAT ? origArgs[0] : AT_FDCWD, + entry, ); return path.kind === "error" - || this.flushSharedBackingForPath(path.value); + || this.flushSharedBackingForPath(path.value, entry); } } if ( @@ -16312,23 +25787,40 @@ export class CentralizedKernelWorker { // MAP_PRIVATE is populated through the guest fd's pread path after the // kernel reserves memory. Publish and persist any dirty shared view of // that file first so the private snapshot does not start stale. - this.syncFileSharedMappingsFromProcess(channel, { force: true }); - return this.flushSharedBackingForFd(channel, origArgs[4]); + this.syncFileSharedMappingsFromProcess( + channel, + { force: true }, + entry, + ); + return this.flushSharedBackingForFd(channel, origArgs[4], entry); } if (syscallNr === SYS_SENDFILE) { - this.syncFileSharedMappingsFromProcess(channel, { force: true }); - return this.flushSharedBackingForFd(channel, origArgs[0]) - && this.flushSharedBackingForFd(channel, origArgs[1]); + this.syncFileSharedMappingsFromProcess( + channel, + { force: true }, + entry, + ); + return this.flushSharedBackingForFd(channel, origArgs[0], entry) + && this.flushSharedBackingForFd(channel, origArgs[1], entry); } if (syscallNr === SYS_COPY_FILE_RANGE || syscallNr === SYS_SPLICE) { - this.syncFileSharedMappingsFromProcess(channel, { force: true }); - return this.flushSharedBackingForFd(channel, origArgs[0]) - && this.flushSharedBackingForFd(channel, origArgs[2]); + this.syncFileSharedMappingsFromProcess( + channel, + { force: true }, + entry, + ); + return this.flushSharedBackingForFd(channel, origArgs[0], entry) + && this.flushSharedBackingForFd(channel, origArgs[2], entry); } if (!this.syscallTouchesFdStorageBeforeKernel(syscallNr)) return true; - this.syncFileSharedMappingsFromProcess(channel, { force: true }); - return this.flushSharedBackingForFd(channel, origArgs[0]); - } catch { + this.syncFileSharedMappingsFromProcess( + channel, + { force: true }, + entry, + ); + return this.flushSharedBackingForFd(channel, origArgs[0], entry); + } catch (error) { + this.#rethrowKernelEntryFatal(error); return false; } } @@ -16350,15 +25842,20 @@ export class CentralizedKernelWorker { || syscallNr === SYS_FALLOCATE; } - private flushSharedBackingForFd(channel: ChannelInfo, fd: number): boolean { + private flushSharedBackingForFd( + channel: ChannelInfo, + fd: number, + entry?: KernelWorkerEntryContext, + ): boolean { if (fd < 0) return true; - const backing = this.findSharedMmapBackingForFd(channel, fd); + const backing = this.findSharedMmapBackingForFd(channel, fd, entry); if (!backing) return true; - this.publishSharedMmapBackingObservers(backing); + this.publishSharedMmapBackingObservers(backing, entry); const flushed = this.flushSharedMmapBackingRange( backing, 0, Number.MAX_SAFE_INTEGER, + entry, ); if (flushed && backing.refCount === 0) { this.discardUnreferencedSharedMmapBacking(backing); @@ -16370,6 +25867,7 @@ export class CentralizedKernelWorker { channel: Pick, pathPtr: number, dirfd: number = AT_FDCWD, + entry?: KernelWorkerEntryContext, ): SharedMmapHostResult { try { const memory = new Uint8Array(channel.memory.buffer); @@ -16395,21 +25893,30 @@ export class CentralizedKernelWorker { let base: string; if (dirfd !== AT_FDCWD) { - const baseResult = this.getFdPathForSharedMapping(channel, dirfd); + const baseResult = this.getFdPathForSharedMapping( + channel, + dirfd, + entry, + ); if (baseResult.kind === "error") return baseResult; base = baseResult.value; } else { - const getCwd = this.kernelInstance!.exports.kernel_get_cwd as + const getCwd = this.#kernelInstanceForEntry(entry).exports.kernel_get_cwd as ((pid: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; if (!getCwd) return { kind: "error", errno: ENOSYS }; const capacity = Math.min(POSIX_PATH_MAX_BYTES, CH_DATA_SIZE); - const output = this.requireMainScratchRegion().withLease((lease) => { - const result = lease.invokeKernelExport("kernel_get_cwd", [ - channel.pid, - lease.exportPointer(0, capacity), - capacity, - ]); - const byteLength = this.checkedScratchProducerByteLength( + const output = this.#requireMainScratchRegion().withLease((lease) => { + const result = this.#invokeEntryScratchExport( + entry, + lease, + "kernel_get_cwd", + [ + channel.pid, + lease.exportPointer(0, capacity), + capacity, + ], + ); + const byteLength = this.#checkedScratchProducerByteLength( result, capacity, "kernel_get_cwd", @@ -16431,6 +25938,7 @@ export class CentralizedKernelWorker { value: this.normalizeSharedMmapPath(`${base}/${path}`), }; } catch (err) { + this.#rethrowKernelEntryFatal(err); return { kind: "error", errno: this.sharedMmapErrno(err) }; } } @@ -16445,16 +25953,26 @@ export class CentralizedKernelWorker { return `/${normalized.join("/")}`; } - private findSharedMmapBackingForPath(path: string): SharedMmapBacking | null { + private findSharedMmapBackingForPath( + path: string, + entry?: KernelWorkerEntryContext, + ): SharedMmapBacking | null { if (this.sharedMmapBackings.size === 0) return null; try { - const stat = this.io.stat(path); - if ((stat.mode & 0o170000) !== 0o100000) return null; - const key = this.io.fileIdentity?.( - path, - BigInt(stat.dev), - BigInt(stat.ino), - ) ?? null; + const snapshot = this.#invokeSharedMmapHostOperation(entry, () => { + const stat = this.io.stat(path); + const dev = BigInt(stat.dev); + const ino = BigInt(stat.ino); + return { + mode: stat.mode, + key: this.io.fileIdentity?.(path, dev, ino) ?? null, + }; + }); + if ( + !Number.isSafeInteger(snapshot.mode) + || (snapshot.mode & 0o170000) !== 0o100000 + ) return null; + const key = snapshot.key; return key ? this.sharedMmapBackings.get(key) ?? null : null; } catch { // The kernel remains authoritative for the pathname error. If the path @@ -16463,14 +25981,18 @@ export class CentralizedKernelWorker { } } - private flushSharedBackingForPath(path: string): boolean { - const backing = this.findSharedMmapBackingForPath(path); + private flushSharedBackingForPath( + path: string, + entry?: KernelWorkerEntryContext, + ): boolean { + const backing = this.findSharedMmapBackingForPath(path, entry); if (!backing) return true; - this.publishSharedMmapBackingObservers(backing); + this.publishSharedMmapBackingObservers(backing, entry); const flushed = this.flushSharedMmapBackingRange( backing, 0, Number.MAX_SAFE_INTEGER, + entry, ); if (flushed && backing.refCount === 0) { this.discardUnreferencedSharedMmapBacking(backing); @@ -16485,14 +26007,30 @@ export class CentralizedKernelWorker { retVal: number, errVal: number, positionedOffset?: bigint, + truncateLength?: bigint, + entry?: KernelWorkerEntryContext, ): void { + const testHook = + this.#scratchBoundaryTestHooks?.handleSharedMappingsAfterFileSyscall; + if (testHook) { + testHook( + channel, + syscallNr, + origArgs, + retVal, + errVal, + positionedOffset, + truncateLength, + ); + return; + } if ((this.sharedMmapBackings?.size ?? 0) === 0) return; if (errVal !== 0) return; if ((syscallNr === SYS_OPEN || syscallNr === SYS_OPENAT) && retVal >= 0) { this.invalidateSharedMmapFdCache(channel.pid, retVal); const flags = syscallNr === SYS_OPEN ? origArgs[1] : origArgs[2]; if ((flags & O_TRUNC) !== 0) { - this.reloadSharedMmapBackingForFd(channel, retVal, 0); + this.reloadSharedMmapBackingForFd(channel, retVal, 0, entry); } return; } @@ -16524,7 +26062,12 @@ export class CentralizedKernelWorker { // The shared-mapping cache is indexed with JavaScript numbers. A // successful pwrite beyond that domain must refresh mapped ranges // from the authoritative file rather than aliasing a rounded offset. - this.reloadSharedMmapBackingForFd(channel, origArgs[0]); + this.reloadSharedMmapBackingForFd( + channel, + origArgs[0], + undefined, + entry, + ); return; } this.updateSharedMmapBackingFromProcessBuffer( @@ -16533,11 +26076,17 @@ export class CentralizedKernelWorker { origArgs[1], retVal, Number(exactOffset), + entry, ); return; } if (syscallNr === SYS_WRITE && retVal > 0) { - this.reloadSharedMmapBackingForFd(channel, origArgs[0]); + this.reloadSharedMmapBackingForFd( + channel, + origArgs[0], + undefined, + entry, + ); return; } if ( @@ -16548,32 +26097,77 @@ export class CentralizedKernelWorker { ) && retVal > 0 ) { - this.reloadSharedMmapBackingForFd(channel, origArgs[0]); + this.reloadSharedMmapBackingForFd( + channel, + origArgs[0], + undefined, + entry, + ); return; } if (syscallNr === SYS_SENDFILE && retVal > 0) { - this.reloadSharedMmapBackingForFd(channel, origArgs[0]); + this.reloadSharedMmapBackingForFd( + channel, + origArgs[0], + undefined, + entry, + ); return; } if ( (syscallNr === SYS_COPY_FILE_RANGE || syscallNr === SYS_SPLICE) && retVal > 0 ) { - this.reloadSharedMmapBackingForFd(channel, origArgs[2]); + this.reloadSharedMmapBackingForFd( + channel, + origArgs[2], + undefined, + entry, + ); return; } if (syscallNr === SYS_FTRUNCATE && retVal === 0) { - this.reloadSharedMmapBackingForFd(channel, origArgs[0], origArgs[1]); + const exactLength = truncateLength ?? BigInt(origArgs[1]); + this.reloadSharedMmapBackingForFd( + channel, + origArgs[0], + exactLength >= 0n + && exactLength <= BigInt(Number.MAX_SAFE_INTEGER) + ? Number(exactLength) + : undefined, + entry, + ); return; } if (syscallNr === SYS_FALLOCATE && retVal === 0) { - this.reloadSharedMmapBackingForFd(channel, origArgs[0]); + // Fallocate offsets are exact i64 channel scalars. This cache does not + // model sparse ranges, so refresh authoritative state instead of ever + // deriving a rounded JavaScript-number interval from those values. + this.reloadSharedMmapBackingForFd( + channel, + origArgs[0], + undefined, + entry, + ); return; } if (syscallNr === SYS_TRUNCATE && retVal === 0) { - const path = this.resolveSharedMmapPath(channel, origArgs[0]); + const exactLength = truncateLength ?? BigInt(origArgs[1]); + const path = this.resolveSharedMmapPath( + channel, + origArgs[0], + AT_FDCWD, + entry, + ); if (path.kind === "ok") { - this.reloadSharedMmapBackingForPath(path.value, origArgs[1]); + this.reloadSharedMmapBackingForPath( + path.value, + exactLength >= 0n + && exactLength <= BigInt(Number.MAX_SAFE_INTEGER) + ? Number(exactLength) + : undefined, + entry, + ); } } } @@ -16584,9 +26178,10 @@ export class CentralizedKernelWorker { ptr: number, len: number, offset: number, + entry?: KernelWorkerEntryContext, ): void { if (len <= 0) return; - const backing = this.findSharedMmapBackingForFd(channel, fd); + const backing = this.findSharedMmapBackingForFd(channel, fd, entry); if (!backing) return; if ( !Number.isSafeInteger(offset) @@ -16597,13 +26192,13 @@ export class CentralizedKernelWorker { this.invalidateSharedMmapBackingPages(backing); return; } - if (this.revalidateSharedMmapBacking(backing) !== 0) { + if (this.revalidateSharedMmapBacking(backing, entry) !== 0) { this.invalidateSharedMmapBackingPages(backing); return; } const processMem = new Uint8Array(channel.memory.buffer); if (ptr + len > processMem.length) { - this.reloadSharedMmapBackingRange(backing, offset, len); + this.reloadSharedMmapBackingRange(backing, offset, len, entry); return; } try { @@ -16612,6 +26207,7 @@ export class CentralizedKernelWorker { offset, processMem.subarray(ptr, ptr + len), false, + entry, ); backing.version++; } catch { @@ -16623,29 +26219,32 @@ export class CentralizedKernelWorker { channel: ChannelInfo, fd: number, exactSize?: number, + entry?: KernelWorkerEntryContext, ): boolean { - const backing = this.findSharedMmapBackingForFd(channel, fd); + const backing = this.findSharedMmapBackingForFd(channel, fd, entry); if (!backing) return true; - return this.reloadSharedMmapBacking(backing, exactSize); + return this.reloadSharedMmapBacking(backing, exactSize, entry); } private reloadSharedMmapBackingForPath( path: string, exactSize?: number, + entry?: KernelWorkerEntryContext, ): boolean { - const backing = this.findSharedMmapBackingForPath(path); + const backing = this.findSharedMmapBackingForPath(path, entry); if (!backing) return true; - return this.reloadSharedMmapBacking(backing, exactSize); + return this.reloadSharedMmapBacking(backing, exactSize, entry); } private reloadSharedMmapBacking( backing: SharedMmapBacking, exactSize?: number, + entry?: KernelWorkerEntryContext, ): boolean { if (exactSize !== undefined && Number.isSafeInteger(exactSize) && exactSize >= 0) { backing.size = exactSize; backing.sizeValid = true; - } else if (this.revalidateSharedMmapBacking(backing) !== 0) { + } else if (this.revalidateSharedMmapBacking(backing, entry) !== 0) { this.invalidateSharedMmapBackingPages(backing); return false; } @@ -16657,7 +26256,10 @@ export class CentralizedKernelWorker { const replacements = new Map(); try { for (const page of loadedPages) { - replacements.set(page, this.readSharedMmapBackingPage(backing, page)); + replacements.set( + page, + this.readSharedMmapBackingPage(backing, page, entry), + ); } } catch { this.invalidateSharedMmapBackingPages(backing, loadedPages); @@ -16675,6 +26277,7 @@ export class CentralizedKernelWorker { backing: SharedMmapBacking, offset: number, len: number, + entry?: KernelWorkerEntryContext, ): boolean { if (len <= 0) return true; const firstPage = Math.floor(offset / FILE_PAGE_SIZE); @@ -16683,7 +26286,10 @@ export class CentralizedKernelWorker { try { for (let page = firstPage; page <= lastPage; page++) { if (!backing.pages.has(page)) continue; - replacements.set(page, this.readSharedMmapBackingPage(backing, page)); + replacements.set( + page, + this.readSharedMmapBackingPage(backing, page, entry), + ); } } catch { this.invalidateSharedMmapBackingPages( @@ -16731,6 +26337,7 @@ export class CentralizedKernelWorker { private findSharedMmapBackingForFd( channel: ChannelInfo, fd: number, + entry?: KernelWorkerEntryContext, ): SharedMmapBacking | null { if (this.sharedMmapBackings.size === 0 || fd < 0) return null; const cacheKey = this.sharedMmapFdCacheKey(channel.pid, fd); @@ -16741,7 +26348,7 @@ export class CentralizedKernelWorker { : null; } - const statResult = this.getFdStatForSharedMapping(channel, fd); + const statResult = this.getFdStatForSharedMapping(channel, fd, entry); if (statResult.kind === "error") { if (statResult.errno === EBADF) { this.sharedMmapFdCache.set(cacheKey, { backingKey: null }); @@ -16755,7 +26362,11 @@ export class CentralizedKernelWorker { const hostHandle = statResult.value.hostHandle; const keyResult = hostHandle === null ? { kind: "error" as const, errno: ENOTSUP } - : this.resolveSharedMmapBackingKey(statResult.value, hostHandle); + : this.resolveSharedMmapBackingKey( + statResult.value, + hostHandle, + entry, + ); if (keyResult.kind === "error") { if (keyResult.errno === EBADF || keyResult.errno === ENOTSUP) { this.sharedMmapFdCache.set(cacheKey, { backingKey: null }); @@ -16791,26 +26402,40 @@ export class CentralizedKernelWorker { } } - private releaseFileSharedMapping(mapping: SharedMmapMapping): void { + private releaseFileSharedMapping( + mapping: SharedMmapMapping, + entry?: KernelWorkerEntryContext, + ): void { if (mapping.backingKind !== "file" || !mapping.backingKey) return; const backing = this.sharedMmapBackings.get(mapping.backingKey); if (!backing) return; - this.releaseSharedMmapBackingReference(backing); + this.releaseSharedMmapBackingReference(backing, entry); } - private releasePreparedSharedMmap(context: PreparedFileSharedMmap): void { - this.releaseSharedMmapBackingReference(context.backing); + private releasePreparedSharedMmap( + context: PreparedFileSharedMmap, + entry?: KernelWorkerEntryContext, + ): void { + this.releaseSharedMmapBackingReference(context.backing, entry); } - private releaseSharedMmapBackingReference(backing: SharedMmapBacking): void { + private releaseSharedMmapBackingReference( + backing: SharedMmapBacking, + entry?: KernelWorkerEntryContext, + ): void { backing.refCount = Math.max(0, backing.refCount - 1); if (backing.refCount > 0) return; - if (!this.flushSharedMmapBackingRange(backing, 0, Number.MAX_SAFE_INTEGER)) { + if (!this.flushSharedMmapBackingRange( + backing, + 0, + Number.MAX_SAFE_INTEGER, + entry, + )) { // Keep the stable handle and dirty cache available for a later mapping // of the same object. Closing here would irreversibly lose dirty bytes. return; } - this.kernel.releaseHostFileHandle(backing.handle); + this.#kernel.releaseHostFileHandle(backing.handle); this.sharedMmapBackings.delete(backing.key); this.invalidateSharedMmapFdCache(); } @@ -16870,8 +26495,13 @@ export class CentralizedKernelWorker { if (backing.refCount === 0) this.anonymousSharedBackings.delete(backing.key); } - private releaseSharedMapping(mapping: SharedMmapMapping): void { - if (mapping.backingKind === "file") this.releaseFileSharedMapping(mapping); + private releaseSharedMapping( + mapping: SharedMmapMapping, + entry?: KernelWorkerEntryContext, + ): void { + if (mapping.backingKind === "file") { + this.releaseFileSharedMapping(mapping, entry); + } else this.releaseAnonymousSharedMapping(mapping); } @@ -16880,56 +26510,300 @@ export class CentralizedKernelWorker { * been registered, but before its Worker starts executing. */ inheritProcessSharedMappings(parentPid: number, childPid: number): void { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + // The caller starts the child Worker immediately after this returns. + // Queuing would expose a child that falsely appears fully inherited. + throw new KernelReentrantEntryError( + `shared mapping inheritance child pid=${childPid}`, + ); + } + if (this.sharedMappingInheritancePids.has(childPid)) { + throw new KernelReentrantEntryError( + `shared mapping inheritance child pid=${childPid}`, + "shared mapping inheritance host preparation", + ); + } + this.sharedMappingInheritancePids.add(childPid); + try { + let completed = false; + let inheritanceError: Error | null = null; + let preparationFailed = false; + let preparationFailure: unknown; + const deferred = this.#runOrDeferKernelEntry( + `shared mapping inheritance child pid=${childPid}`, + (entry) => { + try { + // WHY: the file read remains inside this exact entry's serialized + // host-operation lease. A backend callback cannot enter Wasm; + // void ingress waits until the staged bytes are validated and + // committed, and no live entry authority reaches the backend. + const prepared = this.#prepareSharedMappingInheritance( + parentPid, + childPid, + entry, + ); + inheritanceError = + this.#inheritPreparedSharedMappingsWithinKernelEntry( + prepared, + entry, + ); + completed = inheritanceError === null; + } catch (error) { + this.#rethrowKernelEntryFatal(error); + // A rejected host read occurs before Rust mutation. Preserve only + // that ordinary host failure; a branded export unwind is fatal. + preparationFailed = true; + preparationFailure = error; + } + return undefined; + }, + ); + if (preparationFailed) throw preparationFailure; + if (inheritanceError !== null) throw inheritanceError; + if (deferred || !completed) { + throw new KernelReentrantEntryError( + `shared mapping inheritance child pid=${childPid}`, + ); + } + } finally { + this.sharedMappingInheritancePids.delete(childPid); + } + } + + /** + * Read every host-owned backing without retaining or publishing child state. + */ + #prepareSharedMappingInheritance( + parentPid: number, + childPid: number, + entry: KernelWorkerEntryContext, + ): PreparedSharedMappingInheritance { + if (parentPid === childPid) { + throw new Error("A process cannot inherit shared mappings from itself"); + } const child = this.processes.get(childPid); if (!child) throw new Error(`Process ${childPid} is not registered`); + // WHY: registration identity alone is not enough. A host callback can + // replace the mutable memory field in-place without replacing the object. + // Capture the allocation whose bounds were checked and never redirect the + // prepared transaction to a later memory object. + const childMemory = child.memory; + if ( + this.sharedMappings.has(childPid) + || this.shmMappings.has(childPid) + ) { + throw new Error( + `Process ${childPid} already owns inherited shared mappings`, + ); + } - try { - const parentMap = this.sharedMappings.get(parentPid); - if (parentMap) { - const childMem = new Uint8Array(child.memory.buffer); - const childMap = new Map(); - // Install incrementally so the outer rollback can release references if - // a later mapping or SysV attachment fails. - this.sharedMappings.set(childPid, childMap); - for (const [mapAddr, mapping] of parentMap) { - if (!mapping.backingKey) continue; - const anonymousBacking = mapping.backingKind !== "file" - ? this.anonymousSharedBackings.get(mapping.backingKey) - : undefined; - const fileBacking = mapping.backingKind === "file" - ? this.sharedMmapBackings.get(mapping.backingKey) - : undefined; - if ((!anonymousBacking && !fileBacking) || mapAddr + mapping.len > childMem.length) { - throw new Error(`Cannot inherit shared mapping at 0x${mapAddr.toString(16)}`); - } - const latest = anonymousBacking - ? anonymousBacking.bytes.slice( - mapping.fileOffset, - mapping.fileOffset + mapping.len, - ) - : this.readSharedMmapBackingRange( - fileBacking!, - mapping.fileOffset, - mapping.len, - ); - childMem.set(latest, mapAddr); - const version = anonymousBacking?.version ?? fileBacking!.version; - if (anonymousBacking) anonymousBacking.refCount++; - else fileBacking!.refCount++; - childMap.set(mapAddr, { - ...mapping, - snapshot: latest, - seenVersion: version, - }); - } - if (childMap.size === 0) this.sharedMappings.delete(childPid); + const parentSharedMap = this.sharedMappings.get(parentPid); + const parentSharedEntries = parentSharedMap + ? Array.from(parentSharedMap.entries()) + : []; + const parentSysvMap = this.shmMappings.get(parentPid); + const parentSysvEntries = parentSysvMap + ? Array.from(parentSysvMap.entries()) + : []; + const childBytes = childMemory.buffer.byteLength; + const sharedMappings: PreparedInheritedSharedMapping[] = []; + for (const [mapAddr, mapping] of parentSharedEntries) { + if (!mapping.backingKey) continue; + if ( + !Number.isSafeInteger(mapAddr) + || mapAddr < 0 + || !Number.isSafeInteger(mapping.fileOffset) + || mapping.fileOffset < 0 + || !Number.isSafeInteger(mapping.len) + || mapping.len < 0 + || !Number.isSafeInteger(mapping.fileOffset + mapping.len) + || !Number.isSafeInteger(mapAddr + mapping.len) + || mapAddr + mapping.len > childBytes + ) { + throw new Error( + `Cannot inherit shared mapping at 0x${mapAddr.toString(16)}`, + ); } - this.inheritSysvShmMappings(parentPid, childPid); - } catch (err) { - this.releaseAllSharedMemoryForProcess(childPid, false); - throw err; + const backingKind = mapping.backingKind === "file" + ? "file" + : "anonymous"; + const backing = backingKind === "file" + ? this.sharedMmapBackings.get(mapping.backingKey) + : this.anonymousSharedBackings.get(mapping.backingKey); + if (!backing) { + throw new Error( + `Cannot inherit shared mapping at 0x${mapAddr.toString(16)}`, + ); + } + if ( + backingKind === "anonymous" + && ( + !Number.isSafeInteger(mapping.fileOffset + mapping.len) + || mapping.fileOffset + mapping.len + > (backing as AnonymousSharedMmapBacking).bytes.byteLength + ) + ) { + throw new Error( + `Cannot inherit shared mapping at 0x${mapAddr.toString(16)}`, + ); + } + + const latest = backingKind === "anonymous" + ? (backing as AnonymousSharedMmapBacking).bytes.slice( + mapping.fileOffset, + mapping.fileOffset + mapping.len, + ) + : this.readSharedMmapBackingRange( + backing as SharedMmapBacking, + mapping.fileOffset, + mapping.len, + entry, + ); + sharedMappings.push({ + mapAddr, + source: mapping, + inherited: { ...mapping }, + backing, + backingKind, + backingVersion: backing.version, + latest, + }); + } + + const sysvMappings: PreparedInheritedSysvMapping[] = []; + for (const [mapAddr, mapping] of parentSysvEntries) { + if ( + !Number.isSafeInteger(mapAddr) + || mapAddr < 0 + || mapAddr > 0xffff_ffff + || !Number.isSafeInteger(mapping.segId) + || mapping.segId < 0 + || mapping.segId > 0x7fff_ffff + || !Number.isSafeInteger(mapping.size) + || mapping.size <= 0 + || mapping.size > 0x7fff_ffff + || !Number.isSafeInteger(mapAddr + mapping.size) + || mapAddr + mapping.size > childBytes + ) { + throw new Error( + `Cannot inherit SysV mapping at 0x${mapAddr.toString(16)}`, + ); + } + sysvMappings.push({ + mapAddr, + source: mapping, + segId: mapping.segId, + size: mapping.size, + readOnly: mapping.readOnly, + }); + } + + return { + parentPid, + childPid, + child, + childMemory, + parentSharedMap, + parentSharedEntries, + parentSysvMap, + parentSysvEntries, + sharedMappings, + sysvMappings, + }; + } + + /** + * Revalidate every identity after host callbacks and before Rust mutation. + */ + #validatePreparedSharedMappingInheritance( + prepared: PreparedSharedMappingInheritance, + ): Error | null { + if ( + this.processes.get(prepared.childPid) !== prepared.child + || prepared.child.memory !== prepared.childMemory + || this.sharedMappings.has(prepared.childPid) + || this.shmMappings.has(prepared.childPid) + ) { + return new Error( + `Process ${prepared.childPid} changed during shared mapping inheritance`, + ); + } + if ( + this.sharedMappings.get(prepared.parentPid) + !== prepared.parentSharedMap + || (prepared.parentSharedMap?.size ?? 0) + !== prepared.parentSharedEntries.length + || this.shmMappings.get(prepared.parentPid) !== prepared.parentSysvMap + || (prepared.parentSysvMap?.size ?? 0) + !== prepared.parentSysvEntries.length + ) { + return new Error( + `Process ${prepared.parentPid} changed during shared mapping inheritance`, + ); + } + for (const [mapAddr, source] of prepared.parentSharedEntries) { + if (prepared.parentSharedMap?.get(mapAddr) !== source) { + return new Error( + `Process ${prepared.parentPid} changed shared mapping 0x${mapAddr.toString(16)}`, + ); + } + } + for (const [mapAddr, source] of prepared.parentSysvEntries) { + if (prepared.parentSysvMap?.get(mapAddr) !== source) { + return new Error( + `Process ${prepared.parentPid} changed SysV mapping 0x${mapAddr.toString(16)}`, + ); + } + } + for (const mapping of prepared.sharedMappings) { + const currentBacking = mapping.backingKind === "file" + ? this.sharedMmapBackings.get(mapping.inherited.backingKey!) + : this.anonymousSharedBackings.get(mapping.inherited.backingKey!); + if ( + currentBacking !== mapping.backing + || mapping.backing.version !== mapping.backingVersion + || mapping.source.fd !== mapping.inherited.fd + || mapping.source.fileOffset !== mapping.inherited.fileOffset + || mapping.source.len !== mapping.inherited.len + || mapping.source.writable !== mapping.inherited.writable + || mapping.source.writeAllowed !== mapping.inherited.writeAllowed + || mapping.source.backingKind !== mapping.inherited.backingKind + || mapping.source.backingKey !== mapping.inherited.backingKey + ) { + return new Error( + `Shared backing changed during inheritance at 0x${mapping.mapAddr.toString(16)}`, + ); + } + } + for (const mapping of prepared.sysvMappings) { + if ( + mapping.source.segId !== mapping.segId + || mapping.source.size !== mapping.size + || mapping.source.readOnly !== mapping.readOnly + ) { + return new Error( + `SysV mapping changed during inheritance at 0x${mapping.mapAddr.toString(16)}`, + ); + } + } + const childBytes = prepared.childMemory.buffer.byteLength; + for (const mapping of prepared.sharedMappings) { + if (mapping.mapAddr + mapping.inherited.len > childBytes) { + return new Error( + `Child memory changed during shared mapping inheritance`, + ); + } + } + for (const mapping of prepared.sysvMappings) { + if (mapping.mapAddr + mapping.size > childBytes) { + return new Error( + `Child memory changed during SysV mapping inheritance`, + ); + } } + return null; } /** @@ -16941,14 +26815,25 @@ export class CentralizedKernelWorker { channel: ChannelInfo, mmapAddr: number, origArgs: number[], + rawPageOffset: bigint, + entry?: KernelWorkerEntryContext, ): void { const fd = origArgs[4]; const mapLen = origArgs[1]; // musl sends page offset (off / 4096) as arg[5] - const pageOffset = origArgs[5]; - let fileOffset = pageOffset * 4096; + const fileOffsetBig = rawPageOffset * 4096n; + if ( + rawPageOffset < 0n + || fileOffsetBig > BigInt(Number.MAX_SAFE_INTEGER) + ) { + throw new KernelScratchError( + "mmap file offset exceeds the host backend position domain", + EOVERFLOW, + ); + } + let fileOffset = Number(fileOffsetBig); - const scratch = this.requireMainScratchRegion(); + const scratch = this.#requireMainScratchRegion(); let written = 0; while (written < mapLen) { @@ -16979,14 +26864,20 @@ export class CentralizedKernelWorker { kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, 0n, true); kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, 0n, true); - this.bindKernelTidForChannel(channel); + this.#bindKernelTidForChannel(channel, entry); this.currentHandlePid = channel.pid; try { - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - channel.pid, - ]); + this.#invokeEntryScratchExport( + entry, + lease, + "kernel_handle_channel", + [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + 0n, + ], + ); } finally { this.currentHandlePid = 0; } @@ -17005,10 +26896,11 @@ export class CentralizedKernelWorker { bytes: lease.copyOut(CH_DATA, bytesRead), }; }); - } catch { + } catch (error) { + this.#rethrowKernelEntryFatal(error); break; // pread failed, leave rest as zeros } - if (this.finishSignalTermination(channel)) return; + if (this.#finishSignalTermination(channel, entry)) return; const { bytesRead } = attempt; if (!Number.isSafeInteger(bytesRead) || bytesRead <= 0) break; @@ -17032,13 +26924,15 @@ export class CentralizedKernelWorker { private flushSharedMappings( channel: ChannelInfo, origArgs: number[], + entry?: KernelWorkerEntryContext, ): boolean { // msync/munmap/MAP_FIXED are explicit publication points for anonymous // mappings too, including the single-observer-before-fork case. try { this.syncAnonymousSharedMappingsFromProcess(channel, { force: true }); - this.syncFileSharedMappingsFromProcess(channel, { force: true }); - } catch { + this.syncFileSharedMappingsFromProcess(channel, { force: true }, entry); + } catch (error) { + this.#rethrowKernelEntryFatal(error); return false; } @@ -17077,6 +26971,7 @@ export class CentralizedKernelWorker { backing, fileOffsetBase, flushLen, + entry, )) success = false; continue; } @@ -17085,7 +26980,12 @@ export class CentralizedKernelWorker { // Compatibility for pre-page-cache tracking in focused exec harnesses. if (!this.pwriteFromProcessMemory( - channel, mapping.fd, flushStart, flushLen, fileOffsetBase, + channel, + mapping.fd, + flushStart, + flushLen, + fileOffsetBase, + entry, )) success = false; } return success; @@ -17100,8 +27000,9 @@ export class CentralizedKernelWorker { processAddr: number, len: number, fileOffset: number, + entry?: KernelWorkerEntryContext, ): boolean { - const scratch = this.requireMainScratchRegion(); + const scratch = this.#requireMainScratchRegion(); try { this.checkedProcessRange( @@ -17110,7 +27011,8 @@ export class CentralizedKernelWorker { len, "shared mmap writeback source", ); - } catch { + } catch (error) { + this.#rethrowKernelEntryFatal(error); return false; } const previousPid = this.currentHandlePid; @@ -17149,13 +27051,19 @@ export class CentralizedKernelWorker { kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, 0n, true); kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, 0n, true); - this.bindKernelTidForChannel(channel); + this.#bindKernelTidForChannel(channel, entry); this.currentHandlePid = channel.pid; - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - channel.pid, - ]); + this.#invokeEntryScratchExport( + entry, + lease, + "kernel_handle_channel", + [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + 0n, + ], + ); // A synchronous kernel import may grow its Memory and detach every // host view created before the call. Reacquire through the lease so // both current-memory bounds and allocation capacity are rechecked. @@ -17163,14 +27071,15 @@ export class CentralizedKernelWorker { lease.dataView(0, CH_TOTAL_SIZE).getBigInt64(CH_RETURN, true), ); }); - if (this.finishSignalTermination(channel)) return false; + if (this.#finishSignalTermination(channel, entry)) return false; if (bytesWritten <= 0 || bytesWritten > chunkSize) return false; written += bytesWritten; if (bytesWritten < chunkSize) return false; } return written === len; - } catch { + } catch (error) { + this.#rethrowKernelEntryFatal(error); return false; } finally { this.currentHandlePid = previousPid; @@ -17180,7 +27089,12 @@ export class CentralizedKernelWorker { /** * Remove shared mapping entries that overlap the munmap range. */ - private cleanupSharedMappings(pid: number, addr: number, len: number): void { + private cleanupSharedMappings( + pid: number, + addr: number, + len: number, + entry?: KernelWorkerEntryContext, + ): void { const pidMap = this.sharedMappings.get(pid); if (!pidMap) return; @@ -17192,7 +27106,7 @@ export class CentralizedKernelWorker { if (overlapStart >= overlapEnd) continue; if (overlapStart <= mapAddr && overlapEnd >= mapEnd) { - this.releaseSharedMapping(mapping); + this.releaseSharedMapping(mapping, entry); pidMap.delete(mapAddr); continue; } @@ -17204,7 +27118,7 @@ export class CentralizedKernelWorker { mapping.len = mapEnd - overlapEnd; if (mapping.snapshot) mapping.snapshot = mapping.snapshot.slice(trim); if (mapping.len > 0) pidMap.set(overlapEnd, mapping); - else this.releaseSharedMapping(mapping); + else this.releaseSharedMapping(mapping, entry); continue; } @@ -17238,7 +27152,11 @@ export class CentralizedKernelWorker { } } - private preflightFileSharedMremap(pid: number, origArgs: number[]): number { + private preflightFileSharedMremap( + pid: number, + origArgs: number[], + entry?: KernelWorkerEntryContext, + ): number { const oldAddr = origArgs[0]; const newLen = origArgs[2]; const mapping = this.sharedMappings.get(pid)?.get(oldAddr); @@ -17255,6 +27173,7 @@ export class CentralizedKernelWorker { backing, mapping.fileOffset + mapping.len, newLen - mapping.len, + entry, ); return 0; } catch { @@ -17269,6 +27188,7 @@ export class CentralizedKernelWorker { oldAddr: number, newAddr: number, newLen: number, + entry?: KernelWorkerEntryContext, ): void { const pidMap = this.sharedMappings.get(pid); const mapping = pidMap?.get(oldAddr); @@ -17288,11 +27208,13 @@ export class CentralizedKernelWorker { fileBacking, mapping.fileOffset, newLen, + entry, ); const latest = this.readSharedMmapBackingRange( fileBacking, mapping.fileOffset, newLen, + entry, ); new Uint8Array(registration.memory.buffer).set(latest, newAddr); mapping.snapshot = latest; @@ -17391,6 +27313,7 @@ export class CentralizedKernelWorker { private syncSysvShmMappingsFromProcess( process: Pick, options: { force?: boolean } = {}, + entry?: KernelWorkerEntryContext, ): boolean { const pidMap = this.shmMappings?.get(process.pid); if (!pidMap) return true; @@ -17399,20 +27322,33 @@ export class CentralizedKernelWorker { for (const [mapAddr, mapping] of pidMap) { if (!options.force && !this.hasPeerSysvShmMapping(process.pid, mapAddr, mapping.segId)) continue; - if (!this.mergeAndRefreshSysvShmMapping(processMem, mapAddr, mapping)) success = false; + if (!this.mergeAndRefreshSysvShmMapping( + processMem, + mapAddr, + mapping, + entry, + )) success = false; } return success; } /** Publish all current attachments before a new observer joins a segment. */ - private syncSysvShmSegmentFromMappedProcesses(segId: number): void { + private syncSysvShmSegmentFromMappedProcesses( + segId: number, + entry?: KernelWorkerEntryContext, + ): void { for (const [pid, mappings] of this.shmMappings) { const registration = this.processes.get(pid); if (!registration) continue; const processMem = new Uint8Array(registration.memory.buffer); for (const [mapAddr, mapping] of mappings) { if (mapping.segId === segId) { - this.mergeAndRefreshSysvShmMapping(processMem, mapAddr, mapping); + this.mergeAndRefreshSysvShmMapping( + processMem, + mapAddr, + mapping, + entry, + ); } } } @@ -17441,6 +27377,7 @@ export class CentralizedKernelWorker { processMem: Uint8Array, mapAddr: number, mapping: SysvShmMapping, + entry?: KernelWorkerEntryContext, ): boolean { if (mapAddr + mapping.size > processMem.length) return false; const currentVersion = this.shmSegmentVersions.get(mapping.segId) ?? 0; @@ -17452,7 +27389,12 @@ export class CentralizedKernelWorker { ); if (!locallyChanged && mapping.seenVersion === currentVersion) return true; - const authoritative = this.readSysvShmRange(mapping.segId, 0, mapping.size); + const authoritative = this.readSysvShmRange( + mapping.segId, + 0, + mapping.size, + entry, + ); if (!authoritative) return false; let published = false; let success = true; @@ -17482,7 +27424,12 @@ export class CentralizedKernelWorker { mapAddr + offset + start, mapAddr + offset + i, ); - if (!this.writeSysvShmRange(mapping.segId, offset + start, bytes)) { + if (!this.writeSysvShmRange( + mapping.segId, + offset + start, + bytes, + entry, + )) { success = false; break; } @@ -17502,17 +27449,24 @@ export class CentralizedKernelWorker { return success; } - private readSysvShmRange(segId: number, offset: number, len: number): Uint8Array | null { - const readChunk = this.kernelInstance!.exports.kernel_ipc_shm_read_chunk as + private readSysvShmRange( + segId: number, + offset: number, + len: number, + entry?: KernelWorkerEntryContext, + ): Uint8Array | null { + const readChunk = this.#kernelInstanceForEntry(entry).exports.kernel_ipc_shm_read_chunk as ((shmid: number, offset: number, outPtr: KernelPointer, maxLen: number) => number) | undefined; if (!readChunk) return null; const result = new Uint8Array(len); - const scratch = this.requireMainScratchRegion(); + const scratch = this.#requireMainScratchRegion(); let transferred = 0; while (transferred < len) { const toRead = Math.min(CH_DATA_SIZE, len - transferred); const attempt = scratch.withLease((lease) => { - const nRead = lease.invokeKernelExport( + const nRead = this.#invokeEntryScratchExport( + entry, + lease, "kernel_ipc_shm_read_chunk", [ segId, @@ -17540,18 +27494,23 @@ export class CentralizedKernelWorker { result.set(attempt.bytes, transferred); transferred += attempt.nRead; } - return result; + return transferred === len ? result : null; } - private writeSysvShmRange(segId: number, offset: number, bytes: Uint8Array): boolean { - const writeChunk = this.kernelInstance!.exports.kernel_ipc_shm_write_chunk as + private writeSysvShmRange( + segId: number, + offset: number, + bytes: Uint8Array, + entry?: KernelWorkerEntryContext, + ): boolean { + const writeChunk = this.#kernelInstanceForEntry(entry).exports.kernel_ipc_shm_write_chunk as ((shmid: number, offset: number, dataPtr: KernelPointer, dataLen: number) => number) | undefined; if (!writeChunk) return false; const exactBytes = intrinsicUint8ArrayView( bytes, "System V shared-memory input", ); - const scratch = this.requireMainScratchRegion(); + const scratch = this.#requireMainScratchRegion(); let transferred = 0; while (transferred < exactBytes.byteLength) { const toWrite = Math.min( @@ -17560,7 +27519,9 @@ export class CentralizedKernelWorker { ); const written = scratch.withLease((lease) => { lease.copyFrom(exactBytes, CH_DATA, transferred, toWrite); - return lease.invokeKernelExport( + return this.#invokeEntryScratchExport( + entry, + lease, "kernel_ipc_shm_write_chunk", [ segId, @@ -17578,65 +27539,256 @@ export class CentralizedKernelWorker { return true; } - private inheritSysvShmMappings(parentPid: number, childPid: number): void { - const parentMap = this.shmMappings.get(parentPid); - if (!parentMap || parentMap.size === 0) return; - const child = this.processes.get(childPid); - if (!child) throw new Error(`Process ${childPid} is not registered`); - const kernelShmat = this.kernelInstance!.exports.kernel_ipc_shmat_for_process as - ((pid: number, shmid: number, shmaddr: number, flags: number) => number) | undefined; - const kernelShmdt = this.kernelInstance!.exports.kernel_ipc_shmdt_for_process as + /** + * Attach and snapshot every SysV segment under one exact entry. + * + * Expected errno-style failures are rolled back before the scope releases; + * a thrown export traps the generation and is handled by the gate's fatal + * boundary because Rust may already be partially mutated. + */ + #inheritPreparedSharedMappingsWithinKernelEntry( + prepared: PreparedSharedMappingInheritance, + entry: KernelWorkerEntryContext, + ): Error | null { + const validationError = + this.#validatePreparedSharedMappingInheritance(prepared); + if (validationError !== null) return validationError; + + const kernelShmat = this.#kernelInstanceForEntry(entry).exports + .kernel_ipc_shmat_for_process as + ((pid: number, shmid: number, shmaddr: number, flags: number) => number) + | undefined; + const kernelShmdt = this.#kernelInstanceForEntry(entry).exports + .kernel_ipc_shmdt_for_process as + ((pid: number, shmid: number) => number) | undefined; + if ( + prepared.sysvMappings.length > 0 + && (!kernelShmat || !kernelShmdt) + ) { + return new Error("Kernel lacks SysV SHM inheritance exports"); + } + + const attachedSegments: number[] = []; + const materializedSysv: MaterializedInheritedSysvMapping[] = []; + for (const mapping of prepared.sysvMappings) { + const result = kernelShmat!( + prepared.childPid, + mapping.segId, + mapping.mapAddr, + mapping.readOnly ? SHM_RDONLY : 0, + ); + // Every non-negative return represents a completed attachment, even if + // an incompatible kernel reports an unexpected size. + if (Number.isSafeInteger(result) && result >= 0) { + attachedSegments.push(mapping.segId); + } + if ( + !Number.isSafeInteger(result) + || result < 0 + || result !== mapping.size + ) { + this.#rollbackInheritedSysvAttachmentsWithinKernelEntry( + prepared.childPid, + attachedSegments, + entry, + ); + return new Error( + `SysV shmat inheritance failed for segment ${mapping.segId}`, + ); + } + const latest = this.readSysvShmRange( + mapping.segId, + 0, + mapping.size, + entry, + ); + if (!latest) { + this.#rollbackInheritedSysvAttachmentsWithinKernelEntry( + prepared.childPid, + attachedSegments, + entry, + ); + return new Error( + `Cannot read inherited SysV segment ${mapping.segId}`, + ); + } + materializedSysv.push({ + ...mapping, + latest, + seenVersion: + this.shmSegmentVersions.get(mapping.segId) + ?? mapping.source.seenVersion, + }); + } + + const postExportValidation = + this.#validatePreparedSharedMappingInheritance(prepared); + if (postExportValidation !== null) { + this.#rollbackInheritedSysvAttachmentsWithinKernelEntry( + prepared.childPid, + attachedSegments, + entry, + ); + return postExportValidation; + } + + const materialized: MaterializedSharedMappingInheritance = { + prepared, + sysvMappings: materializedSysv, + }; + entry.deferProtocolEffect(() => { + // WHY: child bytes, mapping ownership, and backing references become + // visible together only after every Rust attachment succeeded and the + // exact entry token was revoked. + this.#publishSharedMappingInheritance(materialized); + return undefined; + }); + return null; + } + + #rollbackInheritedSysvAttachmentsWithinKernelEntry( + childPid: number, + attachedSegments: readonly number[], + entry: KernelWorkerEntryContext, + ): void { + const kernelShmdt = this.#kernelInstanceForEntry(entry).exports + .kernel_ipc_shmdt_for_process as ((pid: number, shmid: number) => number) | undefined; - if (!kernelShmat || !kernelShmdt) - throw new Error("Kernel lacks SysV SHM inheritance exports"); + if (!kernelShmdt) { + throw new Error("Kernel lost required SysV SHM rollback export"); + } + for (let index = attachedSegments.length - 1; index >= 0; index--) { + const segId = attachedSegments[index]!; + const result = kernelShmdt(childPid, segId); + if (!Number.isSafeInteger(result) || result < 0) { + throw new Error( + `SysV shmdt rollback failed for inherited segment ${segId}`, + ); + } + } + } + + /** + * Publish the prepared child state without invoking host or kernel callbacks. + */ + #publishSharedMappingInheritance( + materialized: MaterializedSharedMappingInheritance, + ): void { + const { prepared } = materialized; + const validationError = + this.#validatePreparedSharedMappingInheritance(prepared); + if (validationError !== null) throw validationError; + + const childMem = new Uint8Array(prepared.childMemory.buffer); + const originals: Array<{ + readonly mapAddr: number; + readonly bytes: Uint8Array; + }> = []; + for (const mapping of prepared.sharedMappings) { + originals.push({ + mapAddr: mapping.mapAddr, + bytes: childMem.slice( + mapping.mapAddr, + mapping.mapAddr + mapping.inherited.len, + ), + }); + } + for (const mapping of materialized.sysvMappings) { + originals.push({ + mapAddr: mapping.mapAddr, + bytes: childMem.slice( + mapping.mapAddr, + mapping.mapAddr + mapping.size, + ), + }); + } + + const childSharedMap = new Map(); + for (const mapping of prepared.sharedMappings) { + childSharedMap.set(mapping.mapAddr, { + ...mapping.inherited, + snapshot: mapping.latest, + seenVersion: mapping.backingVersion, + }); + } + const childSysvMap = new Map(); + for (const mapping of materialized.sysvMappings) { + childSysvMap.set(mapping.mapAddr, { + segId: mapping.segId, + size: mapping.size, + readOnly: mapping.readOnly, + snapshot: mapping.latest, + seenVersion: mapping.seenVersion, + }); + } - const childMem = new Uint8Array(child.memory.buffer); - const childMap = new Map(); + const retainedBackings: + Array = []; + let sharedPublished = false; + let sysvPublished = false; try { - for (const [mapAddr, mapping] of parentMap) { - if (mapAddr + mapping.size > childMem.length) { - throw new Error(`Cannot inherit SysV mapping at 0x${mapAddr.toString(16)}`); - } - const result = kernelShmat( - childPid, - mapping.segId, - mapAddr, - mapping.readOnly ? SHM_RDONLY : 0, - ); - if (result < 0 || result !== mapping.size) { - throw new Error(`SysV shmat inheritance failed for segment ${mapping.segId}`); - } - const latest = this.readSysvShmRange(mapping.segId, 0, mapping.size); - if (!latest) { - kernelShmdt(childPid, mapping.segId); - throw new Error(`Cannot read inherited SysV segment ${mapping.segId}`); - } - childMem.set(latest, mapAddr); - childMap.set(mapAddr, { - ...mapping, - snapshot: latest, - seenVersion: this.shmSegmentVersions.get(mapping.segId) ?? mapping.seenVersion, - }); + for (const mapping of prepared.sharedMappings) { + childMem.set(mapping.latest, mapping.mapAddr); } - } catch (err) { - for (const mapping of childMap.values()) kernelShmdt(childPid, mapping.segId); - childMap.clear(); - throw err; + for (const mapping of materialized.sysvMappings) { + childMem.set(mapping.latest, mapping.mapAddr); + } + for (const mapping of prepared.sharedMappings) { + mapping.backing.refCount++; + retainedBackings.push(mapping.backing); + } + const prePublicationValidation = + this.#validatePreparedSharedMappingInheritance(prepared); + if (prePublicationValidation !== null) { + throw prePublicationValidation; + } + if (childSharedMap.size > 0) { + this.sharedMappings.set(prepared.childPid, childSharedMap); + sharedPublished = true; + } + if (childSysvMap.size > 0) { + this.shmMappings.set(prepared.childPid, childSysvMap); + sysvPublished = true; + } + } catch (cause) { + if ( + sharedPublished + && this.sharedMappings.get(prepared.childPid) === childSharedMap + ) { + this.sharedMappings.delete(prepared.childPid); + } + if ( + sysvPublished + && this.shmMappings.get(prepared.childPid) === childSysvMap + ) { + this.shmMappings.delete(prepared.childPid); + } + for (let index = retainedBackings.length - 1; index >= 0; index--) { + retainedBackings[index]!.refCount--; + } + for (const original of originals) { + childMem.set(original.bytes, original.mapAddr); + } + throw cause; } - if (childMap.size > 0) this.shmMappings.set(childPid, childMap); } private releaseAllSysvShmMappingsForProcess( pid: number, publish: boolean = true, + entry?: KernelWorkerEntryContext, ): void { const pidMap = this.shmMappings?.get(pid); if (!pidMap) return; const registration = this.processes.get(pid); if (publish && registration) { - this.syncSysvShmMappingsFromProcess(registration, { force: true }); + this.syncSysvShmMappingsFromProcess( + registration, + { force: true }, + entry, + ); } - const kernelShmdt = this.kernelInstance!.exports.kernel_ipc_shmdt_for_process as + const kernelShmdt = this.#kernelInstanceForEntry(entry).exports.kernel_ipc_shmdt_for_process as ((pid: number, shmid: number) => number) | undefined; if (kernelShmdt) { for (const mapping of pidMap.values()) kernelShmdt(pid, mapping.segId); @@ -17644,7 +27796,11 @@ export class CentralizedKernelWorker { this.shmMappings.delete(pid); } - private releaseAllSharedMemoryForProcess(pid: number, publish: boolean = true): void { + private releaseAllSharedMemoryForProcess( + pid: number, + publish: boolean = true, + entry?: KernelWorkerEntryContext, + ): void { const releasing = this.sharedMemoryReleasePids ??= new Set(); if (releasing.has(pid)) return; releasing.add(pid); @@ -17659,11 +27815,23 @@ export class CentralizedKernelWorker { this.syncAnonymousSharedMappingsFromProcess(registration, { force: true }); } catch {} try { - this.syncFileSharedMappingsFromProcess(registration, { force: true }); - } catch {} + this.syncFileSharedMappingsFromProcess( + registration, + { force: true }, + entry, + ); + } catch (error) { + this.#rethrowKernelEntryFatal(error); + } try { - this.syncSysvShmMappingsFromProcess(registration, { force: true }); - } catch {} + this.syncSysvShmMappingsFromProcess( + registration, + { force: true }, + entry, + ); + } catch (error) { + this.#rethrowKernelEntryFatal(error); + } if (channel) { const mappings = this.sharedMappings.get(pid); if (mappings) { @@ -17675,6 +27843,7 @@ export class CentralizedKernelWorker { backing, mapping.fileOffset, mapping.len, + entry, ); continue; } @@ -17685,6 +27854,7 @@ export class CentralizedKernelWorker { addr, mapping.len, mapping.fileOffset, + entry, ); } } @@ -17693,11 +27863,15 @@ export class CentralizedKernelWorker { const mappings = this.sharedMappings?.get(pid); if (mappings) { - for (const mapping of mappings.values()) this.releaseSharedMapping(mapping); + for (const mapping of mappings.values()) { + this.releaseSharedMapping(mapping, entry); + } this.sharedMappings?.delete(pid); } this.invalidateSharedMmapFdCacheForPid(pid); - if (this.shmMappings) this.releaseAllSysvShmMappingsForProcess(pid, false); + if (this.shmMappings) { + this.releaseAllSysvShmMappingsForProcess(pid, false, entry); + } } finally { releasing.delete(pid); } @@ -17709,7 +27883,36 @@ export class CentralizedKernelWorker { * from allocating in the thread channel/TLS region. */ setMaxAddr(pid: number, maxAddr: number): void { - const setMaxAddrFn = this.kernelInstance!.exports.kernel_set_max_addr as + if (!this.#initialized) throw new Error("Kernel not initialized"); + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError( + `process max-address update pid=${pid}`, + ); + } + let completed = false; + const deferred = this.#runOrDeferKernelEntry( + `process max-address update pid=${pid}`, + (entry) => { + this.#setMaxAddrWithinKernelEntry(pid, maxAddr, entry); + completed = true; + return undefined; + }, + ); + if (deferred || !completed) { + throw new KernelReentrantEntryError( + `process max-address update pid=${pid}`, + ); + } + } + + #setMaxAddrWithinKernelEntry( + pid: number, + maxAddr: number, + entry: KernelWorkerEntryContext, + ): void { + const setMaxAddrFn = this.#kernelInstanceForEntry(entry).exports + .kernel_set_max_addr as ((pid: number, maxAddr: KernelPointer) => number) | undefined; if (setMaxAddrFn) { setMaxAddrFn(pid, this.toKernelPtr(maxAddr)); @@ -17722,7 +27925,40 @@ export class CentralizedKernelWorker { * letting brk grow into them. */ setBrkLimit(pid: number, brkLimit: number): boolean { - const setBrkLimitFn = this.kernelInstance!.exports.kernel_set_brk_limit as + if (!this.#initialized) throw new Error("Kernel not initialized"); + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError( + `process brk-limit update pid=${pid}`, + ); + } + let updated: boolean | undefined; + const deferred = this.#runOrDeferKernelEntry( + `process brk-limit update pid=${pid}`, + (entry) => { + updated = this.#setBrkLimitWithinKernelEntry( + pid, + brkLimit, + entry, + ); + return undefined; + }, + ); + if (deferred || updated === undefined) { + throw new KernelReentrantEntryError( + `process brk-limit update pid=${pid}`, + ); + } + return updated; + } + + #setBrkLimitWithinKernelEntry( + pid: number, + brkLimit: number, + entry: KernelWorkerEntryContext, + ): boolean { + const setBrkLimitFn = this.#kernelInstanceForEntry(entry).exports + .kernel_set_brk_limit as ((pid: number, brkLimit: KernelPointer) => number) | undefined; if (!setBrkLimitFn) { return false; @@ -17735,7 +27971,40 @@ export class CentralizedKernelWorker { * set this to the first guest-managed byte after the host control prefix. */ setMmapBase(pid: number, mmapBase: number): boolean { - const setMmapBaseFn = this.kernelInstance!.exports.kernel_set_mmap_base as + if (!this.#initialized) throw new Error("Kernel not initialized"); + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError( + `process mmap-base update pid=${pid}`, + ); + } + let updated: boolean | undefined; + const deferred = this.#runOrDeferKernelEntry( + `process mmap-base update pid=${pid}`, + (entry) => { + updated = this.#setMmapBaseWithinKernelEntry( + pid, + mmapBase, + entry, + ); + return undefined; + }, + ); + if (deferred || updated === undefined) { + throw new KernelReentrantEntryError( + `process mmap-base update pid=${pid}`, + ); + } + return updated; + } + + #setMmapBaseWithinKernelEntry( + pid: number, + mmapBase: number, + entry: KernelWorkerEntryContext, + ): boolean { + const setMmapBaseFn = this.#kernelInstanceForEntry(entry).exports + .kernel_set_mmap_base as ((pid: number, mmapBase: KernelPointer) => number) | undefined; if (!setMmapBaseFn) { return false; @@ -17744,7 +28013,58 @@ export class CentralizedKernelWorker { } reserveHostRegion(pid: number, len: number): number { - const reserveHostRegionFn = this.kernelInstance!.exports + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + const guestPointerWidth = this.getPtrWidth(pid); + let checkedLength: number; + try { + checkedLength = checkedWasmPointer( + len, + guestPointerWidth, + "host-region length", + ); + if (checkedLength === 0) { + throw new KernelScratchError("host-region length is empty"); + } + } catch { + // WHY: caller-domain rejection has not entered Wasm and cannot have + // mutated Rust. Prove it before opening the entry scope so an invalid + // guest length does not poison an otherwise coherent kernel generation. + throw new Error( + `failed to reserve ${len} bytes of pthread control memory for pid=${pid}`, + ); + } + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError("dynamic host-region reservation"); + } + let result: number | undefined; + const deferred = this.#runOrDeferKernelEntry( + `dynamic host-region reservation pid=${pid}`, + (entry) => { + result = this.#reserveHostRegionWithinKernelEntry( + pid, + checkedLength, + guestPointerWidth, + entry, + ); + return undefined; + }, + ); + if (deferred || result === undefined) { + throw new KernelReentrantEntryError("dynamic host-region reservation"); + } + return result; + } + + #reserveHostRegionWithinKernelEntry( + pid: number, + checkedLength: number, + guestPointerWidth: 4 | 8, + entry: KernelWorkerEntryContext, + ): number { + // WHY: pthread clone launch runs as a detached protocol transaction. + // Reserving its control slot therefore opens a fresh, exact gate root + // instead of retaining or implicitly reusing the clone syscall's entry. + const reserveHostRegionFn = this.#kernelInstanceForEntry(entry).exports .kernel_reserve_host_region as ((pid: number, len: KernelPointer) => KernelPointer) | undefined; if (!reserveHostRegionFn) { @@ -17752,16 +28072,7 @@ export class CentralizedKernelWorker { "Kernel export kernel_reserve_host_region is required for dynamic pthread control slots", ); } - const guestPointerWidth = this.getPtrWidth(pid); - const kernelPointerWidth = this.kernel.getKernelPtrWidth(); - const checkedLength = checkedWasmPointer( - len, - guestPointerWidth, - "host-region length", - ); - if (checkedLength === 0) { - throw new Error(`failed to reserve ${len} bytes of pthread control memory for pid=${pid}`); - } + const kernelPointerWidth = this.#kernelPointerWidth; const addr = reserveHostRegionFn(pid, this.toKernelPtr(checkedLength)); let n: number; try { @@ -17780,24 +28091,21 @@ export class CentralizedKernelWorker { "reserved host region", ); } catch { - throw new Error(`failed to reserve ${len} bytes of pthread control memory for pid=${pid}`); + throw new Error( + `failed to reserve ${checkedLength} bytes of pthread control memory for pid=${pid}`, + ); } if (kernelPointerWidth === 4 && n === 0xffff_ffff) { - throw new Error(`failed to reserve ${len} bytes of pthread control memory for pid=${pid}`); + throw new Error( + `failed to reserve ${checkedLength} bytes of pthread control memory for pid=${pid}`, + ); } return n; } reserveHostRegionAt(pid: number, addr: number, len: number): number { - const reserveHostRegionAtFn = this.kernelInstance!.exports.kernel_reserve_host_region_at as - ((pid: number, addr: KernelPointer, len: KernelPointer) => KernelPointer) | undefined; - if (!reserveHostRegionAtFn) { - throw new Error( - "Kernel export kernel_reserve_host_region_at is required for fork-from-pthread control slots", - ); - } + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; const guestPointerWidth = this.getPtrWidth(pid); - const kernelPointerWidth = this.kernel.getKernelPtrWidth(); let request: { pointer: number; length: number; end: number }; try { request = checkedWasmAddressRange( @@ -17806,13 +28114,53 @@ export class CentralizedKernelWorker { guestPointerWidth, "fixed host region", ); - if (request.length === 0) throw new KernelScratchError("fixed host region is empty"); + if (request.length === 0) { + throw new KernelScratchError("fixed host region is empty"); + } } catch { + // WHY: as above, a malformed caller range is not evidence of a partial + // Rust mutation. Keep it outside the generation-fatal export boundary. throw new Error( `failed to reserve pthread control memory at 0x${addr.toString(16)} ` + `for pid=${pid}`, ); } + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError("fixed host-region reservation"); + } + let result: number | undefined; + const deferred = this.#runOrDeferKernelEntry( + `fixed host-region reservation pid=${pid}`, + (entry) => { + result = this.#reserveHostRegionAtWithinKernelEntry( + pid, + request, + guestPointerWidth, + entry, + ); + return undefined; + }, + ); + if (deferred || result === undefined) { + throw new KernelReentrantEntryError("fixed host-region reservation"); + } + return result; + } + + #reserveHostRegionAtWithinKernelEntry( + pid: number, + request: { pointer: number; length: number; end: number }, + guestPointerWidth: 4 | 8, + entry: KernelWorkerEntryContext, + ): number { + const reserveHostRegionAtFn = this.#kernelInstanceForEntry(entry).exports.kernel_reserve_host_region_at as + ((pid: number, addr: KernelPointer, len: KernelPointer) => KernelPointer) | undefined; + if (!reserveHostRegionAtFn) { + throw new Error( + "Kernel export kernel_reserve_host_region_at is required for fork-from-pthread control slots", + ); + } + const kernelPointerWidth = this.#kernelPointerWidth; const reserved = reserveHostRegionAtFn( pid, this.toKernelPtr(request.pointer), @@ -17835,7 +28183,7 @@ export class CentralizedKernelWorker { ); } catch { throw new Error( - `failed to reserve pthread control memory at 0x${addr.toString(16)} ` + + `failed to reserve pthread control memory at 0x${request.pointer.toString(16)} ` + `for pid=${pid}`, ); } @@ -17844,7 +28192,7 @@ export class CentralizedKernelWorker { || n !== request.pointer ) { throw new Error( - `failed to reserve pthread control memory at 0x${addr.toString(16)} ` + + `failed to reserve pthread control memory at 0x${request.pointer.toString(16)} ` + `for pid=${pid}`, ); } @@ -17876,7 +28224,36 @@ export class CentralizedKernelWorker { * depends on the kernel wasm pointer width. */ setBrkBase(pid: number, addr: bigint | number): boolean { - const setBrkBaseFn = this.kernelInstance!.exports.kernel_set_brk_base as + if (!this.#initialized) throw new Error("Kernel not initialized"); + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError( + `process brk-base update pid=${pid}`, + ); + } + let updated: boolean | undefined; + const deferred = this.#runOrDeferKernelEntry( + `process brk-base update pid=${pid}`, + (entry) => { + updated = this.#setBrkBaseWithinKernelEntry(pid, addr, entry); + return undefined; + }, + ); + if (deferred || updated === undefined) { + throw new KernelReentrantEntryError( + `process brk-base update pid=${pid}`, + ); + } + return updated; + } + + #setBrkBaseWithinKernelEntry( + pid: number, + addr: bigint | number, + entry: KernelWorkerEntryContext, + ): boolean { + const setBrkBaseFn = this.#kernelInstanceForEntry(entry).exports + .kernel_set_brk_base as ((pid: number, addr: KernelPointer) => number) | undefined; if (!setBrkBaseFn) { return false; @@ -17884,17 +28261,6 @@ export class CentralizedKernelWorker { return setBrkBaseFn(pid, this.toKernelPtr(addr)) >= 0; } - /** - * UNSAFE trusted-embedder/debug access to the low-level wrapper. - * - * Direct allocator calls or memory writes bypass the worker's checked - * scratch regions. Repository runtime code uses this only for observation; - * transfer implementations must stay on the capacity-bearing APIs. - */ - getKernel(): WasmPosixKernel { - return this.kernel; - } - /** * Live `/dev/fb0` mappings reported by the kernel, indexed by pid. * Renderers (canvas in browser, no-op in Node) read from this on @@ -17902,7 +28268,7 @@ export class CentralizedKernelWorker { * import. */ get framebuffers() { - return this.kernel.framebuffers; + return this.#kernel.framebuffers; } /** @@ -17914,14 +28280,38 @@ export class CentralizedKernelWorker { return this.processes.get(pid)?.memory; } - /** - * UNSAFE trusted-embedder/debug access to the raw kernel instance. - * - * Pointer-returning exports do not themselves carry allocation capacity. - * Do not combine this with raw kernel-memory writes. - */ - getKernelInstance(): WebAssembly.Instance | null { - return this.kernelInstance; + /** Whether the dedicated worker has one usable, non-poisoned generation. */ + isKernelInitialized(): boolean { + return this.#initialized + && this.#kernelFatalError === null + && this.#kernelInstance !== null; + } + + /** First registered TCP listener port for bridge auto-selection. */ + firstTcpListenerPort(): number | null { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (!this.#initialized || this.#kernelInstance === null) return null; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + // WHY: listener registration publishes several host maps as one + // protocol effect. A synchronous query cannot truthfully return while + // that replacement is only partially committed. + throw new KernelReentrantEntryError("first TCP listener query"); + } + let port: number | null = null; + let completed = false; + const deferred = this.#runOrDeferKernelEntry( + "first TCP listener query", + () => { + const next = this.tcpListenerTargets.keys().next(); + port = next.done ? null : next.value; + completed = true; + return undefined; + }, + ); + if (deferred || !completed) { + throw new KernelReentrantEntryError("first TCP listener query"); + } + return port; } /** @@ -17932,12 +28322,36 @@ export class CentralizedKernelWorker { * Returns `u64::MAX` (as `bigint`) if the pid does not exist; callers * should compare against an explicit before-value rather than treating * "no process" as "0 forks". - */ + */ getForkCount(pid: number): bigint { - const fn = this.kernelInstance?.exports.kernel_get_fork_count as - ((pid: number) => bigint) | undefined; - if (!fn) return BigInt(0); - return fn(pid); + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelInstance === null) return 0n; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + // A result cannot be truthfully queued behind the export whose + // in-progress mutation it is meant to observe. + throw new KernelReentrantEntryError( + `fork-count query pid=${pid}`, + ); + } + let count = 0n; + let completed = false; + const deferred = this.#runOrDeferKernelEntry( + `fork-count query pid=${pid}`, + (entry) => { + const fn = this.#kernelInstanceForEntry(entry).exports + .kernel_get_fork_count as + ((pid: number) => bigint) | undefined; + count = typeof fn === "function" ? fn(pid) : 0n; + completed = true; + return undefined; + }, + ); + if (deferred || !completed) { + throw new KernelReentrantEntryError( + `fork-count query pid=${pid}`, + ); + } + return count; } /** @@ -17946,31 +28360,88 @@ export class CentralizedKernelWorker { * This deliberately reports kernel memory, not any guest process memory. * Hosts use it for bounded-lifetime diagnostics such as proving that * repeated pipe/fork teardown reuses allocator-owned chunks. - */ + */ getKernelMemoryPages(): number { - const fn = this.kernelInstance?.exports.kernel_get_memory_pages as - (() => number) | undefined; - if (typeof fn !== "function") { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelInstance === null) { + throw new Error("kernel_get_memory_pages export is unavailable"); + } + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError("kernel memory-page query"); + } + let pages = 0; + let exportMissing = false; + let completed = false; + const deferred = this.#runOrDeferKernelEntry( + "kernel memory-page query", + (entry) => { + const fn = this.#kernelInstanceForEntry(entry).exports + .kernel_get_memory_pages as (() => number) | undefined; + if (typeof fn !== "function") { + exportMissing = true; + } else { + pages = fn() >>> 0; + } + completed = true; + return undefined; + }, + ); + if (deferred || !completed) { + throw new KernelReentrantEntryError("kernel memory-page query"); + } + // Missing same-version exports are ordinary mismatch diagnostics. Raise + // them after scope revocation so the query does not poison the generation. + if (exportMissing) { throw new Error("kernel_get_memory_pages export is unavailable"); } - return fn() >>> 0; + return pages; } /** * Retained capacity of the kernel-owned large-spawn reservation in bytes. * * Zero means no large spawn has needed a reservation. - */ + */ getSpawnScratchCapacity(): number { - const fn = this.kernelInstance?.exports - .kernel_spawn_scratch_retained_capacity as - (() => KernelPointer) | undefined; - if (typeof fn !== "function") { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelInstance === null) { + throw new Error( + "kernel_spawn_scratch_retained_capacity export is unavailable", + ); + } + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError( + "retained spawn-scratch capacity query", + ); + } + let raw: KernelPointer = this.#kernelPointerWidth === 8 ? 0n : 0; + let exportMissing = false; + let completed = false; + const deferred = this.#runOrDeferKernelEntry( + "retained spawn-scratch capacity query", + (entry) => { + const fn = this.#kernelInstanceForEntry(entry).exports + .kernel_spawn_scratch_retained_capacity as + (() => KernelPointer) | undefined; + if (typeof fn !== "function") { + exportMissing = true; + } else { + raw = fn(); + } + completed = true; + return undefined; + }, + ); + if (deferred || !completed) { + throw new KernelReentrantEntryError( + "retained spawn-scratch capacity query", + ); + } + if (exportMissing) { throw new Error( "kernel_spawn_scratch_retained_capacity export is unavailable", ); } - const raw = fn(); const capacity = typeof raw === "bigint" ? Number(raw) : raw; if (!Number.isSafeInteger(capacity) || capacity < 0) { throw new Error( @@ -17986,8 +28457,17 @@ export class CentralizedKernelWorker { * `read()` or `poll()` on the device is woken on the next retry tick. */ injectMouseEvent(dx: number, dy: number, buttons: number): void { - this.kernel.injectMouseEvent(dx, dy, buttons); - this.scheduleWakeBlockedRetries(); + this.#runOrDeferKernelEntry( + "mouse input and wake", + (entry) => { + const inject = entry.instance.exports.kernel_inject_mouse_event as + | ((dx: number, dy: number, buttons: number) => void) + | undefined; + if (!inject) return; + inject(dx, dy, buttons); + this.scheduleWakeBlockedRetries(entry); + }, + ); } /** @@ -18003,22 +28483,22 @@ export class CentralizedKernelWorker { * audio but never wedges DOOM. */ drainAudio(out: Uint8Array): number { - return this.kernel.drainAudio(out); + return this.#kernel.drainAudio(out); } /** Sample rate (Hz) the program last configured on `/dev/dsp`. */ audioSampleRate(): number { - return this.kernel.audioSampleRate(); + return this.#kernel.audioSampleRate(); } /** Channel count the program last configured on `/dev/dsp`. */ audioChannels(): number { - return this.kernel.audioChannels(); + return this.#kernel.audioChannels(); } /** Bytes buffered in the `/dev/dsp` ring waiting to be drained. */ audioPending(): number { - return this.kernel.audioPending(); + return this.#kernel.audioPending(); } /** @@ -18030,6 +28510,29 @@ export class CentralizedKernelWorker { return this.kernelAbiVersion; } + /** + * Reap one exited top-level process through the serialized kernel entry. + * + * WHY: the dedicated worker owns the kernel instance and its entry gate. + * Exposing the raw instance to browser or Node teardown would bypass scratch + * and reentrancy ownership; callers receive only the detached result. + */ + reapHostOwnedExitedProcess(pid: number): HostOwnedProcessReapResult { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + let result: HostOwnedProcessReapResult | undefined; + const deferred = this.#runOrDeferKernelEntry( + `host-owned process reap pid=${pid}`, + (entry) => { + result = reapHostOwnedExitedProcessFromKernel(entry.instance, pid); + return undefined; + }, + ); + if (deferred || result === undefined) { + throw new KernelReentrantEntryError(`host-owned process reap pid=${pid}`); + } + return result; + } + // --------------------------------------------------------------------------- // TCP bridge — injects real TCP connections into kernel pipe-buffer sockets // --------------------------------------------------------------------------- @@ -18044,17 +28547,19 @@ export class CentralizedKernelWorker { newPort: number, oldTarget: TcpListenerTarget | undefined, existing: TcpListenerBridge, + oldAlias: Readonly<{ fd: number; acceptWakeIdx?: number }> | null, ): TcpListenerBridge | undefined { const oldPort = existing.port; const oldTargets = this.tcpListenerTargets.get(oldPort) ?? []; const retained = oldTargets.filter(target => !(target.pid === pid && target.fd === fd)); - const oldAlias = oldTarget?.acceptWakeIdx !== undefined - ? this.resolveInheritedListenerFd(pid, fd, oldTarget.acceptWakeIdx) - : null; - if (oldAlias && oldAlias.fd !== fd - && !retained.some(target => - target.pid === pid && target.fd === oldAlias.fd)) { + if ( + oldTarget + && oldAlias + && oldAlias.fd !== fd + && !retained.some(target => + target.pid === pid && target.fd === oldAlias.fd) + ) { retained.push({ pid, ...oldAlias }); } @@ -18076,7 +28581,7 @@ export class CentralizedKernelWorker { const oldKey = `${pid}:${fd}`; this.tcpListeners.delete(oldKey); - if (oldAlias && oldAlias.fd !== fd) { + if (oldTarget && oldAlias && oldAlias.fd !== fd) { const aliasKey = `${pid}:${oldAlias.fd}`; if (!this.tcpListeners.has(aliasKey)) { this.tcpListeners.set(aliasKey, existing); @@ -18099,17 +28604,49 @@ export class CentralizedKernelWorker { return oldPort === newPort ? existing : undefined; } - private startTcpListener( + #prepareTcpListenerRegistration( pid: number, fd: number, - port: number, - addr: [number, number, number, number] = [0, 0, 0, 0], - ): void { + _port: number, + entry: KernelWorkerEntryContext, + ): TcpListenerRegistrationPlan { const key = `${pid}:${fd}`; - const getAcceptWake = this.kernelInstance!.exports + const getAcceptWake = this.#kernelInstanceForEntry(entry).exports .kernel_get_fd_accept_wake_idx as ((pid: number, fd: number) => number) | undefined; const liveWakeIdx = getAcceptWake?.(pid, fd) ?? -1; + const existing = this.tcpListeners.get(key); + const oldTarget = existing + ? this.tcpListenerTargets + .get(existing.port) + ?.find((target) => target.pid === pid && target.fd === fd) + : undefined; + const resolvedOldAlias = oldTarget?.acceptWakeIdx !== undefined + ? this.resolveInheritedListenerFd( + pid, + fd, + oldTarget.acceptWakeIdx, + entry, + ) + : null; + const oldAlias = resolvedOldAlias === null + ? null + : kernelEntryIntrinsicObjectFreeze({ ...resolvedOldAlias }); + // WHY: host listener publication runs only after the entry token is + // revoked. Carry an immutable scalar identity, never a live kernel view or + // a mutable target-map entry, across that boundary. + return kernelEntryIntrinsicObjectFreeze({ liveWakeIdx, oldAlias }); + } + + #startTcpListenerHostPhase( + pid: number, + fd: number, + port: number, + addr: [number, number, number, number] = [0, 0, 0, 0], + plan: TcpListenerRegistrationPlan, + ): void { + const { liveWakeIdx, oldAlias } = plan; + const key = `${pid}:${fd}`; let reusableListener: TcpListenerBridge | undefined; const existing = this.tcpListeners.get(key); if (existing) { @@ -18136,12 +28673,12 @@ export class CentralizedKernelWorker { port, existingTarget, existing, + oldAlias, ); } // Register this pid:fd as a target for this port (needed for both - // Node.js TCP bridging and browser service worker bridging via - // pickListenerTarget + injectConnection) + // Node.js TCP bridging and browser service-worker connection injection). if (!this.tcpListenerTargets.has(port)) { this.tcpListenerTargets.set(port, []); this.tcpListenerRRIndex.set(port, 0); @@ -18163,7 +28700,7 @@ export class CentralizedKernelWorker { { accept: (peer, _local, remote) => { const target = this.pickListenerTarget(port); - if (!target) return 113; // EHOSTUNREACH + if (!target) return EHOSTUNREACH; return this.handleIncomingVirtualTcpConnection( target.pid, target.fd, @@ -18202,13 +28739,7 @@ export class CentralizedKernelWorker { const connections = new Set(); const server = net.createServer({ allowHalfOpen: true }, (clientSocket) => { - // Pick target via round-robin among registered processes for this port - const target = this.pickListenerTarget(port); - if (target) { - this.handleIncomingTcpConnection(target.pid, target.fd, clientSocket, connections); - } else { - clientSocket.destroy(); - } + this.handleIncomingTcpConnection(port, clientSocket, connections); }); server.listen(port, "0.0.0.0", () => { @@ -18222,19 +28753,34 @@ export class CentralizedKernelWorker { this.tcpListeners.set(key, { server, pid, port, connections }); } - /** - * Pick the next listener target for a port via round-robin. - * Only considers processes that are still registered. - * - * Public so external callers (the in-kernel HTTP request bridge) can - * resolve a port to a {pid, fd} before injecting a connection. - */ - pickListenerTarget(port: number): {pid: number, fd: number} | null { + pickListenerTarget(port: number): { pid: number; fd: number } | null { + if (!Number.isSafeInteger(port) || port < 0 || port > 0xffff) { + throw new RangeError(`invalid listener port ${String(port)}`); + } + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError("listener target selection"); + } + let result: TcpListenerTarget | null | undefined; + const deferred = this.#runOrDeferKernelEntry( + `listener target selection port=${port}`, + (entry) => { + result = this.#pickListenerTargetWithinKernelEntry(port, entry); + }, + ); + if (deferred || result === undefined) { + throw new KernelReentrantEntryError("listener target selection"); + } + return result === null ? null : { pid: result.pid, fd: result.fd }; + } + + #pickListenerTargetWithinKernelEntry( + port: number, + entry: KernelWorkerEntryContext, + ): TcpListenerTarget | null { const targets = this.tcpListenerTargets.get(port); if (!targets || targets.length === 0) return null; - - // Filter out dead processes - const alive = targets.filter(t => this.processes.has(t.pid)); + const alive = targets.filter((target) => this.processes.has(target.pid)); if (alive.length === 0) return null; // Do not prune unregistered targets here: a fork/spawn child owns its @@ -18245,7 +28791,9 @@ export class CentralizedKernelWorker { // listener (the master doesn't accept connections, workers do). let candidates = alive; if (alive.length > 1) { - const children = alive.filter(t => this.getParentPid(t.pid) !== undefined); + const children = alive.filter( + (target) => this.getParentPid(target.pid, entry) !== undefined, + ); if (children.length > 0) { candidates = children; } @@ -18401,26 +28949,13 @@ export class CentralizedKernelWorker { throw new Error(`No in-kernel listener for port ${port}`); } - const exports = this.kernelInstance!.exports; - const injectConnection = exports.kernel_inject_connection as ( - pid: number, fd: number, a: number, b: number, c: number, d: number, port: number, - ) => number; - const pipeIsWriteOpen = exports.kernel_pipe_is_write_open as ( - pid: number, pipeIdx: number, - ) => number; - const pipeCloseWrite = exports.kernel_pipe_close_write as ( - pid: number, pipeIdx: number, - ) => number; - const pipeCloseRead = exports.kernel_pipe_close_read as ( - pid: number, pipeIdx: number, - ) => number; - // Synthetic remote — picked from the ephemeral range so the kernel // doesn't think two simultaneous external calls share a 4-tuple. const remotePort = 1024 + Math.floor(Math.random() * 60_000); - const recvPipeIdx = injectConnection( - target.pid, target.fd, - 127, 0, 0, 1, + const recvPipeIdx = this.injectConnection( + target.pid, + target.fd, + [127, 0, 0, 1], remotePort, ); if (recvPipeIdx < 0) { @@ -18429,41 +28964,31 @@ export class CentralizedKernelWorker { ); } const sendPipeIdx = recvPipeIdx + 1; - const GLOBAL_PIPE_PID = 0; - - // Wake any pending poll on the target so accept() fires immediately. - // Without this we'd wait for the next 5s poll fallback timer. - this.wakeTargetPollNow(target.pid); - this.scheduleWakeBlockedRetries(); + const globalPipePid = 0; // Write the request bytes through the TCP scratch buffer. const rawRequest = buildRawHttpRequest(request); - const written = this.writePipeChunked( - GLOBAL_PIPE_PID, + const written = this.writePipeData( + globalPipePid, recvPipeIdx, rawRequest, ); if (written < rawRequest.length) { // Partial write here would mean the recv pipe filled up before the // server even started reading. Treat as a hard error for the prototype. - pipeCloseWrite(GLOBAL_PIPE_PID, recvPipeIdx); - pipeCloseRead(GLOBAL_PIPE_PID, sendPipeIdx); + this.closePipeWrite(globalPipePid, recvPipeIdx); + this.closePipeRead(globalPipePid, sendPipeIdx); throw new Error( `[in-kernel-http ${label}] partial write ${written}/${rawRequest.length}`, ); } - - // Wake any reader/poller already blocked on the recv pipe. this.notifyPipeReadable(recvPipeIdx); // Pump the response. const response = await this.pumpHttpResponse( - GLOBAL_PIPE_PID, + globalPipePid, sendPipeIdx, recvPipeIdx, - pipeIsWriteOpen, - pipeCloseRead, - pipeCloseWrite, timeoutMs, maxResponseBytes, request.method, @@ -18493,7 +29018,7 @@ export class CentralizedKernelWorker { private wakeTargetPollNow(pid: number): void { for (const [key, entry] of this.pendingPollRetries) { if (entry.channel.pid !== pid) continue; - if (entry.timer !== null) clearTimeout(entry.timer); + if (entry.timer !== null) this.#cancelRegisteredTimeout(entry.timer); this.pendingPollRetries.delete(key); if (this.isRegisteredChannel(entry.channel)) this.retrySyscall(entry.channel); break; @@ -18508,23 +29033,34 @@ export class CentralizedKernelWorker { private readPipeChunk( pid: number, pipeIdx: number, + entry: KernelWorkerEntryContext, ): Uint8Array | null { - const pipeRead = this.kernelInstance!.exports.kernel_pipe_read as + const pipeRead = this.#kernelInstanceForEntry(entry).exports.kernel_pipe_read as ( pid: number, pipeIdx: number, bufPtr: KernelPointer, bufLen: number, ) => number; - const scratch = this.requireTcpScratchRegion(); + const scratch = this.#requireTcpScratchRegion(); return scratch.withLease((lease) => { - const n = lease.invokeKernelExport("kernel_pipe_read", [ - pid, - pipeIdx, - lease.exportPointer(0, scratch.capacity), - scratch.capacity, - ]); - if (n <= 0) return null; + const n = this.#invokeEntryScratchExport( + entry, + lease, + "kernel_pipe_read", + [ + pid, + pipeIdx, + lease.exportPointer(0, scratch.capacity), + scratch.capacity, + ], + ); + if (n < 0) { + throw new Error( + `kernel lost host-owned pipe read reference ${pipeIdx}: ${n}`, + ); + } + if (n === 0) return null; if (!Number.isSafeInteger(n) || n > scratch.capacity) { throw new KernelScratchError( "kernel pipe read exceeded TCP scratch capacity", @@ -18539,8 +29075,9 @@ export class CentralizedKernelWorker { pid: number, pipeIdx: number, data: Uint8Array, + entry: KernelWorkerEntryContext, ): number { - const pipeWrite = this.kernelInstance!.exports.kernel_pipe_write as + const pipeWrite = this.#kernelInstanceForEntry(entry).exports.kernel_pipe_write as ( pid: number, pipeIdx: number, @@ -18548,7 +29085,7 @@ export class CentralizedKernelWorker { bufLen: number, ) => number; const exactData = intrinsicUint8ArrayView(data, "kernel pipe input"); - const scratch = this.requireTcpScratchRegion(); + const scratch = this.#requireTcpScratchRegion(); let written = 0; while (written < exactData.byteLength) { const chunk = Math.min( @@ -18557,52 +29094,225 @@ export class CentralizedKernelWorker { ); const n = scratch.withLease((lease) => { lease.copyFrom(exactData, 0, written, chunk); - return lease.invokeKernelExport("kernel_pipe_write", [ - pid, - pipeIdx, - lease.exportPointer(0, chunk), - chunk, - ]); + return this.#invokeEntryScratchExport( + entry, + lease, + "kernel_pipe_write", + [ + pid, + pipeIdx, + lease.exportPointer(0, chunk), + chunk, + ], + ); }); - if (!Number.isSafeInteger(n) || n <= 0 || n > chunk) break; + if (!Number.isSafeInteger(n) || n < 0 || n > chunk) { + throw new Error( + `kernel returned invalid host-owned pipe write result ${String(n)}`, + ); + } + if (n === 0) break; written += n; } return written; } - /** Read all currently available pipe bytes through the owned TCP region. */ + /** Drain all currently available bytes from one raw kernel pipe. */ readPipeAvailable(pid: number, pipeIdx: number): Uint8Array | null { - const pipeRead = this.kernelInstance?.exports.kernel_pipe_read as - | (( - pid: number, - pipeIdx: number, - bufPtr: KernelPointer, - bufLen: number, - ) => number) - | undefined; - if (!pipeRead) return null; - const chunks: Uint8Array[] = []; - for (;;) { - const chunk = this.readPipeChunk(pid, pipeIdx); - if (!chunk) break; - chunks.push(chunk); + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError("host pipe read"); + } + let result: Uint8Array | null | undefined; + const deferred = this.#runOrDeferKernelEntry( + "host pipe read", + (entry) => { + const chunks: Uint8Array[] = []; + for (;;) { + const chunk = this.readPipeChunk(pid, pipeIdx, entry); + if (!chunk) break; + chunks.push(chunk); + } + result = chunks.length > 0 ? concatChunksLocal(chunks) : null; + }, + ); + if (deferred || result === undefined) { + throw new KernelReentrantEntryError("host pipe read"); } - return chunks.length > 0 ? concatChunksLocal(chunks) : null; + return result; } - /** Write host bytes to a kernel pipe through the owned TCP region. */ + /** Write through one raw kernel pipe using the capacity-checked TCP scratch. */ writePipeData(pid: number, pipeIdx: number, data: Uint8Array): number { - const pipeWrite = this.kernelInstance?.exports.kernel_pipe_write as - | (( - pid: number, - pipeIdx: number, - bufPtr: KernelPointer, - bufLen: number, - ) => number) - | undefined; - return pipeWrite - ? this.writePipeChunked(pid, pipeIdx, data) - : -1; + const exactData = intrinsicUint8ArrayView(data, "host pipe input"); + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError("host pipe write"); + } + let result: number | undefined; + const deferred = this.#runOrDeferKernelEntry( + "host pipe write", + (entry) => { + result = this.writePipeChunked(pid, pipeIdx, exactData, entry); + }, + ); + if (deferred || result === undefined) { + throw new KernelReentrantEntryError("host pipe write"); + } + return result; + } + + closePipeRead(pid: number, pipeIdx: number): number { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError("host pipe read close"); + } + let result: number | undefined; + const deferred = this.#runOrDeferKernelEntry( + "host pipe read close", + (entry) => { + result = ( + entry.instance.exports.kernel_pipe_close_read as + (pid: number, pipeIdx: number) => number + )(pid, pipeIdx); + }, + ); + if (deferred || result === undefined) { + throw new KernelReentrantEntryError("host pipe read close"); + } + return result; + } + + closePipeWrite(pid: number, pipeIdx: number): number { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError("host pipe write close"); + } + let result: number | undefined; + const deferred = this.#runOrDeferKernelEntry( + "host pipe write close", + (entry) => { + result = ( + entry.instance.exports.kernel_pipe_close_write as + (pid: number, pipeIdx: number) => number + )(pid, pipeIdx); + }, + ); + if (deferred || result === undefined) { + throw new KernelReentrantEntryError("host pipe write close"); + } + return result; + } + + isPipeWriteOpen(pid: number, pipeIdx: number): boolean { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError("host pipe writer query"); + } + let result: boolean | undefined; + const deferred = this.#runOrDeferKernelEntry( + "host pipe writer query", + (entry) => { + const rawResult = ( + entry.instance.exports.kernel_pipe_is_write_open as + (pid: number, pipeIdx: number) => number + )(pid, pipeIdx); + if (rawResult !== 0 && rawResult !== 1) { + throw new Error( + `kernel returned invalid pipe writer state ${rawResult}`, + ); + } + result = rawResult === 1; + }, + ); + if (deferred || result === undefined) { + throw new KernelReentrantEntryError("host pipe writer query"); + } + return result; + } + + injectConnection( + pid: number, + fd: number, + peerAddr: readonly [number, number, number, number], + peerPort: number, + ): number { + if (!Number.isSafeInteger(peerPort) || peerPort < 0 || peerPort > 0xffff) { + throw new RangeError(`invalid peer port ${String(peerPort)}`); + } + const addressLength = peerAddr.length; + const address: [number, number, number, number] = [ + peerAddr[0], + peerAddr[1], + peerAddr[2], + peerAddr[3], + ]; + if (addressLength !== 4) { + throw new RangeError("peer address must contain exactly four octets"); + } + for (const octet of address) { + if (!Number.isSafeInteger(octet) || octet < 0 || octet > 0xff) { + throw new RangeError(`invalid peer address octet ${String(octet)}`); + } + } + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError("host connection injection"); + } + let result: number | undefined; + const deferred = this.#runOrDeferKernelEntry( + `host connection injection pid=${pid} fd=${fd}`, + (entry) => { + const inject = entry.instance.exports.kernel_inject_connection as + (pid: number, fd: number, a: number, b: number, c: number, + d: number, peerPort: number) => number; + result = inject( + pid, + fd, + address[0]!, + address[1]!, + address[2]!, + address[3]!, + peerPort, + ); + if (result >= 0) { + entry.deferProtocolEffect(() => { + this.scheduleWakeBlockedRetries(); + return undefined; + }); + } + }, + ); + if (deferred || result === undefined) { + throw new KernelReentrantEntryError("host connection injection"); + } + return result; + } + + wakeBlockedReaders(pipeIdx: number): void { + this.#runOrDeferKernelEntry( + `external pipe readable wake index=${pipeIdx}`, + (entry) => { + // Keep direct-reader, poller, and broad wake sequencing identical to + // every other host-side pipe write. + this.#notifyPipeReadableWithinKernelEntry( + pipeIdx, + undefined, + entry, + ); + return undefined; + }, + ); + } + + wakeBlockedWriters(pipeIdx: number): void { + this.#runOrDeferKernelEntry( + `external pipe writable wake index=${pipeIdx}`, + (entry) => { + this.#notifyPipeWritableWithinKernelEntry(pipeIdx, entry); + return undefined; + }, + ); } /** @@ -18614,22 +29324,24 @@ export class CentralizedKernelWorker { pid: number, sendPipeIdx: number, recvPipeIdx: number, - pipeIsWriteOpen: (pid: number, pipeIdx: number) => number, - pipeCloseRead: (pid: number, pipeIdx: number) => number, - pipeCloseWrite: (pid: number, pipeIdx: number) => number, timeoutMs: number, maxResponseBytes: number, requestMethod: string, label: string, ): Promise { - return new Promise((resolve, reject) => { - const chunks = new BoundedHttpResponseChunks(maxResponseBytes); + return new this.#promiseReceiver((resolve, reject) => { + const chunks: Uint8Array[] = []; const start = Date.now(); let sawWriteOpen = false; + let settled = false; + let timer: ReturnType | null = null; const finish = (response: HttpResponse) => { - pipeCloseRead(pid, sendPipeIdx); - pipeCloseWrite(pid, recvPipeIdx); + if (settled) return; + settled = true; + if (timer !== null) this.#cancelRegisteredTimeout(timer); + this.closePipeRead(pid, sendPipeIdx); + this.closePipeWrite(pid, recvPipeIdx); this.notifyPipeReadable(recvPipeIdx); this.scheduleWakeBlockedRetries(); resolve(response); @@ -18644,37 +29356,54 @@ export class CentralizedKernelWorker { }; const tick = () => { + if (settled) return; if (Date.now() - start > timeoutMs) { finish({ status: 504, headers: {}, body: new Uint8Array(0) }); return; } // Drain whatever is currently in the pipe. - let gotData = false; - for (;;) { - const chunk = this.readPipeChunk(pid, sendPipeIdx); - if (!chunk) break; - gotData = true; - chunks.push(chunk); - } + try { + const chunk = this.readPipeAvailable(pid, sendPipeIdx); + const gotData = chunk !== null; + if (chunk) { + chunks.push(chunk); + this.notifyPipeWritable(sendPipeIdx); + } - if (gotData) { - // Wake any writer blocked filling this pipe (we just freed buffer). - this.notifyPipeWritable(sendPipeIdx); - } + const writeOpen = this.isPipeWriteOpen(pid, sendPipeIdx); + if (writeOpen && !sawWriteOpen) sawWriteOpen = true; - const writeOpen = pipeIsWriteOpen(pid, sendPipeIdx) === 1; - if (writeOpen && !sawWriteOpen) sawWriteOpen = true; + if (sawWriteOpen && !writeOpen && !gotData) { + // Server closed its end and we drained all bytes. + const raw = concatChunksLocal(chunks); + finish(parseRawHttpResponse(raw)); + return; + } - if (sawWriteOpen && !writeOpen && !gotData) { - // Server closed its end and we drained all bytes. - const raw = chunks.concat(); - finish(parseRawHttpResponse(raw, requestMethod)); - return; + // Re-arm. Tight when bytes were flowing, slower poll otherwise. + const nextTimer = this.#registerTimeout(() => { + if (timer !== nextTimer) return; + timer = null; + tick(); + }, gotData ? 0 : 2); + timer = nextTimer; + } catch (cause) { + this.#rethrowKernelEntryFatal(cause); + settled = true; + if (timer !== null) this.#cancelRegisteredTimeout(timer); + try { + this.closePipeRead(pid, sendPipeIdx); + this.closePipeWrite(pid, recvPipeIdx); + } catch (error) { + this.#rethrowKernelEntryFatal(error); + // The kernel fatal latch, if any, remains authoritative. + } + reject(new Error( + `[in-kernel-http ${label}] response pump failed`, + { cause }, + )); } - - // Re-arm. Tight when bytes were flowing, slower poll otherwise. - setTimeout(tick, gotData ? 0 : 2); }; tick(); @@ -18686,243 +29415,323 @@ export class CentralizedKernelWorker { * socket's backlog and pump data between the real socket and kernel pipes. */ private handleIncomingTcpConnection( + port: number, + clientSocket: import("net").Socket, + connections: Set, + ): void { + connections.add(clientSocket); + + const remoteAddr = clientSocket.remoteAddress || "127.0.0.1"; + const remotePort = clientSocket.remotePort || 0; + const parts = remoteAddr.replace("::ffff:", "").split("."); + const address = [0, 1, 2, 3].map((index) => { + const value = Number(parts[index]); + return Number.isInteger(value) && value >= 0 && value <= 0xff + ? value + : [127, 0, 0, 1][index]!; + }) as [number, number, number, number]; + + this.#runOrDeferKernelEntry( + `Node TCP accept port=${port}`, + (entry) => { + const target = this.#pickListenerTargetWithinKernelEntry(port, entry); + if (!target) { + entry.deferProtocolEffect(() => { + connections.delete(clientSocket); + clientSocket.destroy(); + return undefined; + }); + return; + } + const recvPipeIdx = ( + entry.instance.exports.kernel_inject_connection as ( + pid: number, + listenerFd: number, + a: number, + b: number, + c: number, + d: number, + port: number, + ) => number + )( + target.pid, + target.fd, + address[0], + address[1], + address[2], + address[3], + remotePort, + ); + if (recvPipeIdx < 0) { + entry.deferProtocolEffect(() => { + connections.delete(clientSocket); + clientSocket.destroy(); + return undefined; + }); + return; + } + entry.deferProtocolEffect(() => { + this.#startNodeTcpConnectionPump( + target.pid, + recvPipeIdx, + clientSocket, + connections, + ); + this.wakeTargetPollNow(target.pid); + this.scheduleWakeBlockedRetries(); + return undefined; + }); + }, + ); + } + + #closeTcpPipeWriteWithinKernelEntry( + pipeIdx: number, + entry: KernelWorkerEntryContext, + ): void { + const result = ( + entry.instance.exports.kernel_pipe_close_write as + (pid: number, pipeIdx: number) => number + )(0, pipeIdx); + if (result < 0) { + throw new Error( + `kernel lost TCP receive write reference ${pipeIdx}: ${result}`, + ); + } + } + + #closeTcpPipeReadWithinKernelEntry( + pipeIdx: number, + entry: KernelWorkerEntryContext, + ): void { + const result = ( + entry.instance.exports.kernel_pipe_close_read as + (pid: number, pipeIdx: number) => number + )(0, pipeIdx); + if (result < 0) { + throw new Error( + `kernel lost TCP send read reference ${pipeIdx}: ${result}`, + ); + } + } + + #tcpPipeReadOpenWithinKernelEntry( + pipeIdx: number, + entry: KernelWorkerEntryContext, + ): boolean { + const result = ( + entry.instance.exports.kernel_pipe_is_read_open as + (pid: number, pipeIdx: number) => number + )(0, pipeIdx); + if (result !== 0 && result !== 1) { + throw new Error( + `kernel returned invalid TCP read-open state for ${pipeIdx}: ${result}`, + ); + } + return result === 1; + } + + #tcpPipeWriteOpenWithinKernelEntry( + pipeIdx: number, + entry: KernelWorkerEntryContext, + ): boolean { + const result = ( + entry.instance.exports.kernel_pipe_is_write_open as + (pid: number, pipeIdx: number) => number + )(0, pipeIdx); + if (result !== 0 && result !== 1) { + throw new Error( + `kernel returned invalid TCP write-open state for ${pipeIdx}: ${result}`, + ); + } + return result === 1; + } + + #tcpPipeHasReadersWithinKernelEntry( + pipeIdx: number, + entry: KernelWorkerEntryContext, + ): boolean { + const result = ( + entry.instance.exports.kernel_pipe_has_readers as + (pid: number, pipeIdx: number) => number + )(0, pipeIdx); + if (result !== 0 && result !== 1) { + throw new Error( + `kernel returned invalid TCP reader state for ${pipeIdx}: ${result}`, + ); + } + return result === 1; + } + + #startNodeTcpConnectionPump( pid: number, - listenerFd: number, + recvPipeIdx: number, clientSocket: import("net").Socket, connections: Set, ): void { - connections.add(clientSocket); - - const remoteAddr = clientSocket.remoteAddress || "127.0.0.1"; - const remotePort = clientSocket.remotePort || 0; - - // Parse IP address - const parts = remoteAddr.replace("::ffff:", "").split(".").map(Number); - const addrA = parts[0] || 127; - const addrB = parts[1] || 0; - const addrC = parts[2] || 0; - const addrD = parts[3] || 1; - - // Inject connection into kernel - const injectConnection = this.kernelInstance!.exports.kernel_inject_connection as - (pid: number, listenerFd: number, a: number, b: number, c: number, d: number, port: number) => number; - const recvPipeIdx = injectConnection(pid, listenerFd, addrA, addrB, addrC, addrD, remotePort); - if (recvPipeIdx < 0) { - clientSocket.destroy(); - connections.delete(clientSocket); - return; - } - - // Wake any blocked poll/accept anywhere — the listener's shared - // accept queue now has a new entry, and any worker sharing the - // listener can pick it up. Broad wake covers all of them. - this.scheduleWakeBlockedRetries(); - const sendPipeIdx = recvPipeIdx + 1; - - // The injected pipes live in the global pipe table (see - // kernel_inject_connection in crates/kernel/src/wasm_api.rs). Pass - // pid=0 to the legacy kernel pipe APIs as a compatibility sentinel. - // The APIs now always resolve pipe indexes through the global pipe table, - // which lets any process sharing the listener accept this connection. - const GLOBAL_PIPE_PID = 0; - const pipeCloseWrite = this.kernelInstance!.exports.kernel_pipe_close_write as - (pid: number, pipeIdx: number) => number; - const pipeCloseRead = this.kernelInstance!.exports.kernel_pipe_close_read as - (pid: number, pipeIdx: number) => number; - const pipeIsReadOpen = this.kernelInstance!.exports.kernel_pipe_is_read_open as - (pid: number, pipeIdx: number) => number; - const pipeHasReaders = this.kernelInstance!.exports.kernel_pipe_has_readers as - (pid: number, pipeIdx: number) => number; - // Queue for incoming TCP data (written to recv pipe) - const inboundQueue: Buffer[] = []; + const inboundQueue: Uint8Array[] = []; let clientEnded = false; let clientClosed = false; let guestWriteEnded = false; let recvPipeWriteClosed = false; - let pumpPending = false; + let sendPipeReadClosed = false; + let abortRequested = false; let cleaned = false; - - const pipeIsWriteOpen = this.kernelInstance!.exports.kernel_pipe_is_write_open as - (pid: number, pipeIdx: number) => number; - - const closeRecvPipeWrite = () => { - if (recvPipeWriteClosed) return; - recvPipeWriteClosed = true; - pipeCloseWrite(GLOBAL_PIPE_PID, recvPipeIdx); - // EOF is readable state even when the peer sent no data. - this.notifyPipeReadable(recvPipeIdx); - }; - - // Drain inbound queue into recv pipe - const drainInbound = () => { - if (pipeIsReadOpen(GLOBAL_PIPE_PID, recvPipeIdx) === 0) { - inboundQueue.length = 0; - if (clientEnded) closeRecvPipeWrite(); - return; - } - let wroteAny = false; - while (inboundQueue.length > 0) { - const chunk = inboundQueue[0]!; - const written = this.writePipeChunked( - GLOBAL_PIPE_PID, - recvPipeIdx, - chunk, - ); - if (written <= 0) break; // Pipe full, retry next pump - wroteAny = true; - if (written >= chunk.length) { - inboundQueue.shift(); - } else { - inboundQueue[0] = chunk.subarray(written) as Buffer; - } - } - if (clientEnded && inboundQueue.length === 0) { - closeRecvPipeWrite(); - } - if (wroteAny) { - this.notifyPipeReadable(recvPipeIdx); - } - }; - - // Read send pipe → TCP socket (drains all available data) - const drainOutbound = () => { - let totalRead = 0; - // Loop to drain the entire pipe, not just one 65KB chunk. - // Responses larger than 65KB (e.g. 662KB site-editor.php) need - // multiple reads to fully transfer. - for (;;) { - const bytes = this.readPipeChunk( - GLOBAL_PIPE_PID, - sendPipeIdx, - ); - if (!bytes) break; - totalRead += bytes.byteLength; - const outData = Buffer.from(bytes); - if (!clientSocket.destroyed) { - clientSocket.write(outData); - } - } - if (totalRead > 0) { - this.notifyPipeWritable(sendPipeIdx); - } - return totalRead; + let scheduled: ReturnType | null = null; + + const schedulePump = (): void => { + if (scheduled !== null || cleaned) return; + const handle = this.#registerImmediate(() => { + if (scheduled !== handle) return; + scheduled = null; + pump(); + }); + scheduled = handle; }; - const schedulePump = (delayMs = 0) => { - if (pumpPending || cleaned) return; - pumpPending = true; - if (delayMs > 0) { - setTimeout(pump, delayMs); - } else { - setImmediate(pump); + const finalizeHostCleanup = (): void => { + if (cleaned) return; + cleaned = true; + inboundQueue.length = 0; + connections.delete(clientSocket); + const arr = this.tcpConnections.get(pid); + if (arr) { + const index = arr.indexOf(connEntry); + if (index >= 0) arr.splice(index, 1); + if (arr.length === 0) this.tcpConnections.delete(pid); } + if (!clientSocket.destroyed) clientSocket.destroySoon(); }; - const pump = () => { - pumpPending = false; + const pump = (): void => { if (cleaned) return; + this.#runOrDeferKernelEntry( + `Node TCP pump pid=${pid}`, + (entry) => { + let wroteAny = false; + const outbound: Uint8Array[] = []; + if ( + !abortRequested + && this.#tcpPipeReadOpenWithinKernelEntry(recvPipeIdx, entry) + ) { + while (inboundQueue.length > 0) { + const chunk = inboundQueue[0]!; + const written = this.writePipeChunked( + 0, + recvPipeIdx, + chunk, + entry, + ); + if (written <= 0) break; + wroteAny = true; + if (written >= chunk.byteLength) inboundQueue.shift(); + else inboundQueue[0] = chunk.subarray(written); + } + } else { + inboundQueue.length = 0; + } + if ( + (clientEnded && inboundQueue.length === 0) + || abortRequested + ) { + if (!recvPipeWriteClosed) { + this.#closeTcpPipeWriteWithinKernelEntry(recvPipeIdx, entry); + recvPipeWriteClosed = true; + } + } - drainInbound(); - const readN = drainOutbound(); - - const writeOpen = pipeIsWriteOpen(GLOBAL_PIPE_PID, sendPipeIdx); - const hasReaders = pipeHasReaders(GLOBAL_PIPE_PID, recvPipeIdx); - if (writeOpen === 0 && readN === 0 && !guestWriteEnded) { - guestWriteEnded = true; - if (!clientSocket.destroyed && !clientSocket.writableEnded) { - // SHUT_WR is a half-close: send FIN after queued bytes but keep the - // real receive half alive until the guest closes it or the peer ends. - clientSocket.end(); - } - } - if (writeOpen === 0 && hasReaders <= 0) { - cleanup(); - return; - } - if (guestWriteEnded && clientEnded && inboundQueue.length === 0) { - cleanup(); - return; - } - if (clientClosed && inboundQueue.length === 0) { - cleanup(); - return; - } + if (!abortRequested) { + for (;;) { + const bytes = this.readPipeChunk(0, sendPipeIdx, entry); + if (!bytes) break; + outbound.push(bytes); + } + } + const writeOpen = + this.#tcpPipeWriteOpenWithinKernelEntry(sendPipeIdx, entry); + const hasReaders = + this.#tcpPipeHasReadersWithinKernelEntry(recvPipeIdx, entry); + const shouldEndGuestWrite = + !writeOpen + && outbound.length === 0 + && !guestWriteEnded; + const shouldCleanup = + abortRequested + || (!writeOpen && !hasReaders) + || (guestWriteEnded && clientEnded && inboundQueue.length === 0) + || (clientClosed && inboundQueue.length === 0); + if (shouldCleanup) { + if (!recvPipeWriteClosed) { + this.#closeTcpPipeWriteWithinKernelEntry(recvPipeIdx, entry); + recvPipeWriteClosed = true; + } + if (!sendPipeReadClosed) { + this.#closeTcpPipeReadWithinKernelEntry(sendPipeIdx, entry); + sendPipeReadClosed = true; + } + } - // Always reschedule while connection is alive. After fork(), the child - // process writes response data to the same pipe, but flushTcpSendPipes - // is keyed by pid and won't find the parent's connections. Without - // continuous pumping, response data gets stranded in the pipe. - // Use setImmediate for both active and idle — the 2ms idle delay adds - // significant latency when fork children write to the send pipe (since - // flushTcpSendPipes is keyed by parent pid and won't find child writes). - schedulePump(); + entry.deferProtocolEffect(() => { + if (wroteAny || recvPipeWriteClosed) { + this.notifyPipeReadable(recvPipeIdx); + } + if (outbound.length > 0 || sendPipeReadClosed) { + this.notifyPipeWritable(sendPipeIdx); + } + for (const bytes of outbound) { + if (!clientSocket.destroyed) { + clientSocket.write(Buffer.from(bytes)); + } + } + if (shouldEndGuestWrite) { + guestWriteEnded = true; + if (!clientSocket.destroyed && !clientSocket.writableEnded) { + clientSocket.end(); + } + } + if (shouldCleanup) finalizeHostCleanup(); + else schedulePump(); + return undefined; + }); + }, + ); }; - // Incoming TCP data → write directly to recv pipe, queue overflow clientSocket.on("data", (chunk: Buffer) => { if (cleaned) return; - inboundQueue.push(chunk); - drainInbound(); - // Schedule pump to handle outbound + close detection + inboundQueue.push(new Uint8Array(chunk)); schedulePump(); }); - clientSocket.on("end", () => { clientEnded = true; schedulePump(); }); - clientSocket.on("error", () => { clientEnded = true; + abortRequested = true; clientSocket.destroy(); - cleanup(); + schedulePump(); }); - clientSocket.on("close", () => { - connections.delete(clientSocket); clientClosed = true; clientEnded = true; - // A clean close can arrive while pre-FIN bytes are still queued because - // the guest receive pipe is full. Let the pump deliver those bytes - // before releasing the pipe ends. The error path above remains an - // immediate reset/abort. schedulePump(); }); - // Register this connection for piggyback flushing let conns = this.tcpConnections.get(pid); if (!conns) { conns = []; this.tcpConnections.set(pid, conns); } - const connEntry = { sendPipeIdx, clientSocket, recvPipeIdx, schedulePump }; - conns.push(connEntry); - - const cleanup = () => { - if (cleaned) return; - cleaned = true; - inboundQueue.length = 0; - // Close the host's ends of both pipes: - // recvPipe: host is the writer → close write end - // sendPipe: host is the reader → close read end - closeRecvPipeWrite(); - pipeCloseRead(GLOBAL_PIPE_PID, sendPipeIdx); - // A closed host read end makes any parked guest writer fail with EPIPE. - this.notifyPipeWritable(sendPipeIdx); - // Remove from tcpConnections tracking - const arr = this.tcpConnections?.get(pid); - if (arr) { - const idx = arr.indexOf(connEntry); - if (idx >= 0) arr.splice(idx, 1); - if (arr.length === 0) this.tcpConnections?.delete(pid); - } - if (!clientSocket.destroyed) { - // Flush queued bytes, send FIN, then release the Node handle. The - // operating system owns subsequent TCP close-state timing. - clientSocket.destroySoon(); - } + const connEntry = { + sendPipeIdx, + clientSocket, + recvPipeIdx, + schedulePump, }; + conns.push(connEntry); + schedulePump(); } /** @@ -18930,158 +29739,349 @@ export class CentralizedKernelWorker { * kernel's normal AF_INET accept path and pumping bytes between the virtual * stream peer and the accepted socket's global pipe pair. */ + #snapshotVirtualNetworkAddress(remote: NetworkAddress): { + readonly addr: [number, number, number, number]; + readonly port: number; + } { + const exactAddr = intrinsicUint8ArrayView( + remote.addr, + "virtual TCP remote address", + ); + const port = remote.port; + if (exactAddr.byteLength !== 4) { + throw new RangeError("virtual TCP remote address must contain four octets"); + } + if (!Number.isSafeInteger(port) || port < 0 || port > 0xffff) { + throw new RangeError(`invalid virtual TCP remote port ${String(port)}`); + } + return { + addr: [ + exactAddr[0]!, + exactAddr[1]!, + exactAddr[2]!, + exactAddr[3]!, + ], + port, + }; + } + private handleIncomingVirtualTcpConnection( pid: number, listenerFd: number, peer: TcpConnectionPeer, remote: NetworkAddress, ): number { - if (!this.kernelInstance) return 107; // ENOTCONN + if (!this.#initialized || this.#kernelInstance === null) return 107; + let remoteSnapshot: { + readonly addr: [number, number, number, number]; + readonly port: number; + }; + try { + remoteSnapshot = this.#snapshotVirtualNetworkAddress(remote); + } catch { + return EIO; + } + if (this.#kernelFatalError !== null) return EIO; + if (this.#kernelEntryGate.shouldDeferVoidIngress) return EIO; + + let result: number | undefined; + const deferred = this.#runOrDeferKernelEntry( + `virtual TCP accept pid=${pid} fd=${listenerFd}`, + (entry) => { + const recvPipeIdx = this.#injectIncomingVirtualTcpConnection( + { pid, fd: listenerFd }, + remoteSnapshot.addr, + remoteSnapshot.port, + entry, + ); + if (recvPipeIdx < 0) { + result = -recvPipeIdx; + return; + } + result = 0; + entry.deferProtocolEffect(() => { + this.wakeTargetPollNow(pid); + this.scheduleWakeBlockedRetries(); + this.#startIncomingVirtualTcpConnectionPump( + pid, + recvPipeIdx, + peer, + ); + return undefined; + }); + }, + ); + return deferred || result === undefined ? EIO : result; + } - const injectConnection = this.kernelInstance.exports.kernel_inject_connection as - (pid: number, listenerFd: number, a: number, b: number, c: number, d: number, port: number) => number; - const recvPipeIdx = injectConnection( - pid, - listenerFd, - remote.addr[0] ?? 0, - remote.addr[1] ?? 0, - remote.addr[2] ?? 0, - remote.addr[3] ?? 0, - remote.port, + #injectIncomingVirtualTcpConnection( + target: TcpListenerTarget, + remoteAddr: readonly [number, number, number, number], + remotePort: number, + entry: KernelWorkerEntryContext, + ): number { + return ( + this.#kernelInstanceForEntry(entry).exports.kernel_inject_connection as ( + pid: number, + listenerFd: number, + a: number, + b: number, + c: number, + d: number, + port: number, + ) => number + )( + target.pid, + target.fd, + remoteAddr[0], + remoteAddr[1], + remoteAddr[2], + remoteAddr[3], + remotePort, ); - if (recvPipeIdx < 0) return -recvPipeIdx; + } + #startIncomingVirtualTcpConnectionPump( + targetPid: number, + recvPipeIdx: number, + peer: TcpConnectionPeer, + ): void { const sendPipeIdx = recvPipeIdx + 1; - const GLOBAL_PIPE_PID = 0; - const pipeCloseWrite = this.kernelInstance.exports.kernel_pipe_close_write as - (pid: number, pipeIdx: number) => number; - const pipeCloseRead = this.kernelInstance.exports.kernel_pipe_close_read as - (pid: number, pipeIdx: number) => number; - const pipeIsWriteOpen = this.kernelInstance.exports.kernel_pipe_is_write_open as - (pid: number, pipeIdx: number) => number; - const pipeIsReadOpen = this.kernelInstance.exports.kernel_pipe_is_read_open as - (pid: number, pipeIdx: number) => number; - const pipeHasReaders = this.kernelInstance.exports.kernel_pipe_has_readers as - (pid: number, pipeIdx: number) => number; - let cleaned = false; - let recvPipeWriteClosed = false; + let abortRequested = false; + let peerReceiveEnded = false; let guestReadShutdown = false; - let guestWriteEnded = false; + let guestWriteShutdown = false; + let recvPipeWriteOpen = true; + let sendPipeReadOpen = true; let pendingInbound: Uint8Array | null = null; - let pumpPending = false; - - const closeRecvPipeWrite = () => { - if (recvPipeWriteClosed) return; - recvPipeWriteClosed = true; - pipeCloseWrite(GLOBAL_PIPE_PID, recvPipeIdx); + let pendingOutbound: Uint8Array | null = null; + let timer: ReturnType | null = null; + + const schedulePump = (delayMs = 0): void => { + if (timer !== null || cleaned) return; + const nextTimer = this.#registerTimeout(() => { + if (timer !== nextTimer) return; + timer = null; + pumpHostPhase(); + }, delayMs); + timer = nextTimer; }; - const cleanup = () => { + const pumpHostPhase = (): void => { if (cleaned) return; - cleaned = true; - closeRecvPipeWrite(); - pipeCloseRead(GLOBAL_PIPE_PID, sendPipeIdx); - peer.close(); - this.notifyPipeReadable(recvPipeIdx); - this.notifyPipeWritable(sendPipeIdx); - this.scheduleWakeBlockedRetries(); - }; + let madeProgress = false; - const drainInbound = () => { - if (pipeIsReadOpen(GLOBAL_PIPE_PID, recvPipeIdx) === 0) { - pendingInbound = null; - if (!guestReadShutdown) { - guestReadShutdown = true; - peer.shutdown(0); - } - return; - } - for (;;) { - let data: Uint8Array; - if (pendingInbound) { - data = pendingInbound; - } else { - try { - data = peer.recv(65536, 0); - } catch (e: any) { - if (e?.errno === 11) return; - cleanup(); - return; + // Peer methods are arbitrary host code. Flush/receive only while no + // kernel entry is live and retain exact copied suffixes across retries. + if (pendingOutbound && !abortRequested) { + try { + const sent = peer.send(pendingOutbound, 0); + if ( + !Number.isSafeInteger(sent) + || sent < 0 + || sent > pendingOutbound.byteLength + ) { + throw new Error( + `virtual TCP peer returned invalid send length ${String(sent)}`, + ); } + if (sent > 0) { + madeProgress = true; + pendingOutbound = + sent === pendingOutbound.byteLength + ? null + : pendingOutbound.subarray(sent); + } + } catch (error) { + const errno = (error as { errno?: unknown })?.errno; + if (errno !== EAGAIN) abortRequested = true; } - if (data.length === 0) { - pendingInbound = null; - closeRecvPipeWrite(); - this.notifyPipeReadable(recvPipeIdx); - return; - } - const written = this.writePipeChunked( - GLOBAL_PIPE_PID, - recvPipeIdx, - data, - ); - if (written < data.length) { - // `peer.recv` consumes bytes, so retain the unwritten suffix while - // the guest receive pipe is full and retry it on a later pump tick. - pendingInbound = data.subarray(written); - return; - } - pendingInbound = null; - this.notifyPipeReadable(recvPipeIdx); } - }; - const drainOutbound = () => { - for (;;) { - const bytes = this.readPipeChunk( - GLOBAL_PIPE_PID, - sendPipeIdx, - ); - if (!bytes) break; + if ( + !pendingInbound + && !peerReceiveEnded + && !guestReadShutdown + && !abortRequested + ) { try { - peer.send(bytes, 0); - } catch { - cleanup(); - return; + const received = intrinsicUint8ArrayView( + peer.recv(65536, 0), + "virtual TCP peer receive", + ); + if (received.byteLength === 0) { + peerReceiveEnded = true; + madeProgress = true; + } else { + pendingInbound = new Uint8Array(received); + madeProgress = true; + } + } catch (error) { + const errno = (error as { errno?: unknown })?.errno; + if (errno !== EAGAIN) abortRequested = true; } - this.notifyPipeWritable(sendPipeIdx); } - }; - const pump = () => { - pumpPending = false; - if (cleaned) { - return; - } - drainInbound(); - drainOutbound(); - const writeOpen = pipeIsWriteOpen(GLOBAL_PIPE_PID, sendPipeIdx); - const hasReaders = pipeHasReaders(GLOBAL_PIPE_PID, recvPipeIdx); - if (writeOpen === 0 && !guestWriteEnded) { - guestWriteEnded = true; - peer.shutdown(1); - } - if (writeOpen === 0 && hasReaders <= 0) { - cleanup(); - return; - } - if (guestWriteEnded && recvPipeWriteClosed) { - cleanup(); - return; - } - schedulePump(2); - }; + this.#runOrDeferKernelEntry( + `virtual TCP pump pid=${targetPid} pipe=${recvPipeIdx}`, + (entry) => { + let notifyReadable = false; + let notifyWritable = false; + let shutdownPeerRead = false; + let shutdownPeerWrite = false; + let closePeer = false; + + if (!abortRequested) { + const readOpen = recvPipeWriteOpen + ? this.#tcpPipeReadOpenWithinKernelEntry( + recvPipeIdx, + entry, + ) + : false; + const hasReaders = recvPipeWriteOpen + ? this.#tcpPipeHasReadersWithinKernelEntry( + recvPipeIdx, + entry, + ) + : false; + if (!readOpen) { + pendingInbound = null; + if (!guestReadShutdown) { + guestReadShutdown = true; + shutdownPeerRead = true; + } + if (recvPipeWriteOpen) { + this.#closeTcpPipeWriteWithinKernelEntry( + recvPipeIdx, + entry, + ); + recvPipeWriteOpen = false; + notifyReadable = true; + } + } else if (pendingInbound) { + const written = this.writePipeChunked( + 0, + recvPipeIdx, + pendingInbound, + entry, + ); + if (written > 0) { + madeProgress = true; + notifyReadable = true; + pendingInbound = + written === pendingInbound.byteLength + ? null + : pendingInbound.subarray(written); + } + } + + if ( + peerReceiveEnded + && pendingInbound === null + && recvPipeWriteOpen + ) { + this.#closeTcpPipeWriteWithinKernelEntry( + recvPipeIdx, + entry, + ); + recvPipeWriteOpen = false; + madeProgress = true; + notifyReadable = true; + } + + if (pendingOutbound === null) { + pendingOutbound = this.readPipeChunk( + 0, + sendPipeIdx, + entry, + ); + if (pendingOutbound) notifyWritable = true; + if (pendingOutbound) madeProgress = true; + } + + const writeOpen = sendPipeReadOpen + ? this.#tcpPipeWriteOpenWithinKernelEntry( + sendPipeIdx, + entry, + ) + : false; + if ( + !writeOpen + && pendingOutbound === null + && !guestWriteShutdown + ) { + guestWriteShutdown = true; + madeProgress = true; + shutdownPeerWrite = true; + } + closePeer = + (!writeOpen && !hasReaders && pendingOutbound === null) + || ( + guestWriteShutdown + && !recvPipeWriteOpen + && pendingOutbound === null + ); + } + + if (abortRequested || closePeer) { + if (recvPipeWriteOpen) { + this.#closeTcpPipeWriteWithinKernelEntry( + recvPipeIdx, + entry, + ); + recvPipeWriteOpen = false; + madeProgress = true; + notifyReadable = true; + } + if (sendPipeReadOpen) { + this.#closeTcpPipeReadWithinKernelEntry( + sendPipeIdx, + entry, + ); + sendPipeReadOpen = false; + madeProgress = true; + notifyWritable = true; + } + cleaned = true; + } - const schedulePump = (delayMs = 0) => { - if (pumpPending || cleaned) return; - pumpPending = true; - setTimeout(pump, delayMs); + entry.deferProtocolEffect(() => { + // These notifications run before another entry can reuse a raw + // index; nonterminal records still own both referenced slots. + if (notifyReadable) { + this.notifyPipeReadable(recvPipeIdx); + } + if (notifyWritable) { + this.notifyPipeWritable(sendPipeIdx); + } + try { + if (abortRequested) { + peer.abort(); + } else if (cleaned) { + peer.close(); + } else { + if (shutdownPeerRead) peer.shutdown(0); + if (shutdownPeerWrite) peer.shutdown(1); + } + } catch { + abortRequested = true; + } + this.scheduleWakeBlockedRetries(); + if (!cleaned) { + // Zero-length/EAGAIN/full-pipe states retain owned bytes but + // back off instead of spinning a zero-delay timer forever. + schedulePump(madeProgress ? 0 : 2); + } + return undefined; + }); + }, + ); }; - this.scheduleWakeBlockedRetries(); - schedulePump(); - return 0; + // First pump handoff is synchronous host-only setup. It cannot strand an + // injected backlog entry behind a fallible timer registration. + pumpHostPhase(); } /** @@ -19089,103 +30089,256 @@ export class CentralizedKernelWorker { * SOCK_DGRAM receive queue for the destination process. */ private injectUdpDatagram(pid: number, datagram: UdpDatagram): number { - if (!this.kernelInstance || !this.processes.has(pid)) return 113; // EHOSTUNREACH + if (!this.#kernelInstance || !this.processes.has(pid)) return 113; // EHOSTUNREACH let exactData: Uint8Array; + let srcAddr: Uint8Array; + let dstAddr: Uint8Array; try { exactData = intrinsicUint8ArrayView( datagram.data, "virtual UDP datagram", ); + srcAddr = intrinsicUint8ArrayView( + datagram.srcAddr, + "UDP source address", + ); + dstAddr = intrinsicUint8ArrayView( + datagram.dstAddr, + "UDP destination address", + ); } catch { return EIO; } if (exactData.byteLength > 65536) return 90; // EMSGSIZE + const mustOwnInput = this.#kernelEntryGate.shouldDeferVoidIngress; + const ownedDatagram: UdpDatagram = { + srcAddr: mustOwnInput ? new Uint8Array(srcAddr) : srcAddr, + srcPort: datagram.srcPort, + dstAddr: mustOwnInput ? new Uint8Array(dstAddr) : dstAddr, + dstPort: datagram.dstPort, + data: mustOwnInput ? new Uint8Array(exactData) : exactData, + }; + let synchronous = true; + let immediateErrno = 0; + let deliveryErrno = 0; + const deferred = this.#runOrDeferKernelEntry( + "virtual UDP datagram", + (entry) => { + deliveryErrno = this.#injectUdpDatagramWithinKernelEntry( + pid, + ownedDatagram, + entry, + ); + if (synchronous) { + immediateErrno = deliveryErrno; + } + entry.deferObserverEffect(() => { + if (!synchronous && deliveryErrno !== 0) { + console.warn( + `[kernel-worker] deferred UDP delivery failed: errno ${deliveryErrno}`, + ); + } + }); + }, + ); + synchronous = false; + // The virtual network has already routed a deferred datagram. Delivery is + // a void notification at that boundary; a later failure is logged. + return deferred ? 0 : immediateErrno; + } - const injectDatagram = this.kernelInstance.exports.kernel_inject_datagram as + #injectUdpDatagramWithinKernelEntry( + pid: number, + datagram: UdpDatagram, + entry: KernelWorkerEntryContext, + ): number { + const injectDatagram = entry.instance.exports.kernel_inject_datagram as ((pid: number, dstA: number, dstB: number, dstC: number, dstD: number, dstPort: number, srcA: number, srcB: number, srcC: number, srcD: number, srcPort: number, dataPtr: KernelPointer, dataLen: number) => number) | undefined; if (!injectDatagram) return 38; // ENOSYS - const result = this.requireTcpScratchRegion().withLease((scratch) => { + const exactData = datagram.data; + const result = this.#requireTcpScratchRegion().withLease((scratch) => { scratch.copyFrom(exactData); - return scratch.invokeKernelExport("kernel_inject_datagram", [ - pid, - datagram.dstAddr[0] ?? 0, - datagram.dstAddr[1] ?? 0, - datagram.dstAddr[2] ?? 0, - datagram.dstAddr[3] ?? 0, - datagram.dstPort, - datagram.srcAddr[0] ?? 0, - datagram.srcAddr[1] ?? 0, - datagram.srcAddr[2] ?? 0, - datagram.srcAddr[3] ?? 0, - datagram.srcPort, - scratch.exportPointer(0, exactData.byteLength), - exactData.byteLength, - ]); + return this.#invokeEntryScratchExport( + entry, + scratch, + "kernel_inject_datagram", + [ + pid, + datagram.dstAddr[0] ?? 0, + datagram.dstAddr[1] ?? 0, + datagram.dstAddr[2] ?? 0, + datagram.dstAddr[3] ?? 0, + datagram.dstPort, + datagram.srcAddr[0] ?? 0, + datagram.srcAddr[1] ?? 0, + datagram.srcAddr[2] ?? 0, + datagram.srcAddr[3] ?? 0, + datagram.srcPort, + scratch.exportPointer(0, exactData.byteLength), + exactData.byteLength, + ], + ); }); if (result < 0) return -result; - this.scheduleWakeBlockedRetries(); + this.scheduleWakeBlockedRetries(entry); return 0; } - private cleanupUdpBindings(pid: number): void { - if (!this.io.network?.unbindUdp) return; + /** + * Materialize and publish all process-owned network cleanup as one detached + * protocol transaction. + * + * WHY: unbind/close callbacks are host-owned and may synchronously re-enter + * public roots. UDP and TCP state therefore has to become authoritative as + * one replacement after this exact entry is revoked and before the first + * callback can observe it. + */ + #cleanupProcessNetworkWithinKernelEntry( + pid: number, + entry: KernelWorkerEntryContext, + ): void { + const udpPlan = this.#prepareUdpBindingCleanup(pid); + const tcpPlan = this.#prepareTcpListenerCleanup(pid); + entry.deferProtocolEffect(() => { + this.#publishProcessNetworkCleanup(pid, udpPlan, tcpPlan); + return undefined; + }); + } + + #prepareUdpBindingCleanup(pid: number): UdpBindingCleanupPlan { const prefix = `${pid}:`; - for (const key of Array.from(this.udpBindings)) { + const nextBindings = new Set(this.udpBindings); + const endpointKeysToUnbind: string[] = []; + for (const key of this.udpBindings) { if (!key.startsWith(prefix)) continue; - this.io.network.unbindUdp(key); - this.udpBindings.delete(key); + nextBindings.delete(key); + endpointKeysToUnbind.push(key); } + return { + udpBindings: nextBindings, + endpointKeysToUnbind, + }; } /** - * Clean up all TCP listeners and connections for a process. + * Build a complete TCP mirror replacement without invoking host callbacks. */ - private cleanupTcpListeners(pid: number): void { + #prepareTcpListenerCleanup(pid: number): TcpListenerCleanupPlan { + const nextTargets = new Map(this.tcpListenerTargets); + const nextRRIndex = new Map(this.tcpListenerRRIndex); + const nextVirtualKeys = new Map(this.tcpVirtualListenerKeys); + const virtualListenerKeysToClose = new Set(); + // Remove this pid from listener targets for (const [port, targets] of this.tcpListenerTargets) { const filtered = targets.filter(t => t.pid !== pid); if (filtered.length === 0) { - this.tcpListenerTargets.delete(port); - this.tcpListenerRRIndex.delete(port); - const virtualKey = this.tcpVirtualListenerKeys.get(port); + nextTargets.delete(port); + nextRRIndex.delete(port); + const virtualKey = nextVirtualKeys.get(port); if (virtualKey) { - this.io.network?.closeTcpListener?.(virtualKey); - this.tcpVirtualListenerKeys.delete(port); + virtualListenerKeysToClose.add(virtualKey); + nextVirtualKeys.delete(port); } } else { - this.tcpListenerTargets.set(port, filtered); + nextTargets.set(port, filtered); + const oldIndex = this.tcpListenerRRIndex.get(port) ?? 0; + nextRRIndex.set(port, oldIndex % filtered.length); } } + const nextListeners = new Map(this.tcpListeners); + const listenerServersToClose = new Set(); const keyPrefix = `${pid}:`; for (const [key, entry] of Array.from(this.tcpListeners)) { if (!key.startsWith(keyPrefix)) continue; - this.tcpListeners.delete(key); + nextListeners.delete(key); // Accepted sockets have independent pipe ownership and may still belong // to a fork child. Their pumps close them when the final pipe references // disappear; listener teardown only stops new accepts. - const remainingTargets = this.tcpListenerTargets.get(entry.port); + const remainingTargets = nextTargets.get(entry.port); if (!remainingTargets || remainingTargets.length === 0) { - entry.server.close(); + listenerServersToClose.add(entry.server); } else { // Fork inheritance adds listener targets without re-running listen(2). // Keep the shared server reachable under a surviving owner's key so // final-owner cleanup can close it instead of leaking the port. const replacement = remainingTargets[0]!; const replacementKey = `${replacement.pid}:${replacement.fd}`; - if (!this.tcpListeners.has(replacementKey)) { - this.tcpListeners.set(replacementKey, { + if (!nextListeners.has(replacementKey)) { + nextListeners.set(replacementKey, { ...entry, pid: replacement.pid, }); } } } + + return { + tcpListenerTargets: nextTargets, + tcpListenerRRIndex: nextRRIndex, + tcpListeners: nextListeners, + tcpVirtualListenerKeys: nextVirtualKeys, + virtualListenerKeysToClose: [...virtualListenerKeysToClose], + listenerServersToClose: [...listenerServersToClose], + }; + } + + #publishTcpListenerCleanup( + pid: number, + plan: TcpListenerCleanupPlan, + ): void { + this.tcpListenerTargets = plan.tcpListenerTargets; + this.tcpListenerRRIndex = plan.tcpListenerRRIndex; + this.tcpListeners = plan.tcpListeners; + this.tcpVirtualListenerKeys = plan.tcpVirtualListenerKeys; + this.tcpConnections.delete(pid); + for (const key of plan.virtualListenerKeysToClose) { + this.io.network?.closeTcpListener?.(key); + } + for (const server of plan.listenerServersToClose) { + server.close(); + } + } + + #publishProcessNetworkCleanup( + pid: number, + udpPlan: UdpBindingCleanupPlan, + tcpPlan: TcpListenerCleanupPlan, + ): void { + // Publish every replacement before the first callback. The gate keeps + // reentrant ingress behind the complete detached protocol-effect record. + this.udpBindings = udpPlan.udpBindings; + this.tcpListenerTargets = tcpPlan.tcpListenerTargets; + this.tcpListenerRRIndex = tcpPlan.tcpListenerRRIndex; + this.tcpListeners = tcpPlan.tcpListeners; + this.tcpVirtualListenerKeys = tcpPlan.tcpVirtualListenerKeys; this.tcpConnections.delete(pid); + + for (const key of udpPlan.endpointKeysToUnbind) { + this.io.network?.unbindUdp?.(key); + } + for (const key of tcpPlan.virtualListenerKeysToClose) { + this.io.network?.closeTcpListener?.(key); + } + for (const server of tcpPlan.listenerServersToClose) { + server.close(); + } + } + + /** + * Clean up all TCP listeners and connections from an already detached host + * phase. Scoped callers use #cleanupProcessNetworkWithinKernelEntry. + */ + private cleanupTcpListeners(pid: number): void { + this.#publishTcpListenerCleanup( + pid, + this.#prepareTcpListenerCleanup(pid), + ); } // ========================================================================= @@ -19211,74 +30364,98 @@ export class CentralizedKernelWorker { channel: ChannelInfo, syscallNr: typeof SYS_MSGSND | typeof SYS_MSGRCV, origArgs: number[], - rawArgs?: readonly bigint[], + rawArgs: readonly bigint[], + entry: KernelWorkerEntryContext, + retainedSnapshot?: SysvMessageBlockingRetrySnapshot, ): void { - const pointerWidth = this.getPtrWidth(channel.pid); - const rawPointer = rawArgs?.[1] ?? origArgs[1] ?? 0; - const rawMessageSize = rawArgs?.[2] ?? origArgs[2] ?? 0; + const pointerWidth = + retainedSnapshot?.pointerWidth ?? this.getPtrWidth(channel.pid); + const rawPointer = rawArgs[1] ?? BigInt(origArgs[1] ?? 0); + const rawMessageSize = rawArgs[2] ?? BigInt(origArgs[2] ?? 0); const sending = syscallNr === SYS_MSGSND; - const flags = sending ? origArgs[3] : origArgs[4]; + const flags = retainedSnapshot?.flags + ?? (sending ? origArgs[3] : origArgs[4]); try { - if (rawPointer === 0n || rawPointer === 0) { - throw new KernelScratchError("SysV message pointer is null", EFAULT); - } - if ( - (typeof rawMessageSize === "bigint" && ( + let snapshot = retainedSnapshot; + if (!snapshot) { + if (rawPointer === 0n) { + throw new KernelScratchError("SysV message pointer is null", EFAULT); + } + if ( rawMessageSize < 0n || rawMessageSize > BigInt(Number.MAX_SAFE_INTEGER) - )) - || (typeof rawMessageSize === "number" && ( - !Number.isSafeInteger(rawMessageSize) - || rawMessageSize < 0 - )) - ) { - throw new KernelScratchError("invalid SysV message length", EINVAL); + ) { + throw new KernelScratchError("invalid SysV message length", EINVAL); + } + const messageSize = Number(rawMessageSize); + const processBytes = pointerWidth + messageSize; + const scratchBytes = + STRUCT_SIZE_WASM_SYSV_MESSAGE_HEADER + messageSize; + if ( + !Number.isSafeInteger(processBytes) + || !Number.isSafeInteger(scratchBytes) + || scratchBytes > CH_DATA_SIZE + ) { + throw new KernelScratchError( + "SysV message exceeds bounded kernel transport", + EINVAL, + ); + } + + const processPointer = this.checkedProcessRange( + channel, + rawPointer, + processBytes, + "SysV message caller buffer", + ).pointer; + const processMemory = new Uint8Array(channel.memory.buffer); + const input = sending + ? processMemory.slice(processPointer, processPointer + processBytes) + : null; + const nativeType = input + ? ( + pointerWidth === 8 + ? new DataView( + input.buffer, + input.byteOffset, + input.byteLength, + ).getBigInt64(0, true) + : BigInt(new DataView( + input.buffer, + input.byteOffset, + input.byteLength, + ).getInt32(0, true)) + ) + : 0n; + const rawMessageType = rawArgs[3] ?? BigInt(origArgs[3] ?? 0); + snapshot = { + ...this.#cancellationPointIdentity(channel), + kind: "sysv-message", + syscallNr, + origArgs: origArgs.slice(), + pointerWidth, + processPointer, + messageSize, + flags, + input, + nativeType, + messageType: rawMessageType, + retryToken: 0n, + }; } - const messageSize = Number(rawMessageSize); - const processBytes = pointerWidth + messageSize; + const { + processPointer, + messageSize, + input, + nativeType, + messageType, + } = snapshot; const scratchBytes = STRUCT_SIZE_WASM_SYSV_MESSAGE_HEADER + messageSize; - if ( - !Number.isSafeInteger(processBytes) - || !Number.isSafeInteger(scratchBytes) - || scratchBytes > CH_DATA_SIZE - ) { - throw new KernelScratchError( - "SysV message exceeds bounded kernel transport", - EINVAL, - ); - } - - const processPointer = this.checkedProcessRange( - channel, - rawPointer, - processBytes, - "SysV message caller buffer", - ).pointer; - const processMemory = new Uint8Array(channel.memory.buffer); - // Detach input before acquiring the shared region. Another synchronous - // host callback cannot then replace half of the staged message. - const input = sending - ? processMemory.slice(processPointer, processPointer + processBytes) - : null; - const nativeType = input - ? ( - pointerWidth === 8 - ? new DataView( - input.buffer, - input.byteOffset, - input.byteLength, - ).getBigInt64(0, true) - : BigInt(new DataView( - input.buffer, - input.byteOffset, - input.byteLength, - ).getInt32(0, true)) - ) - : 0n; - const result = this.requireMainScratchRegion().withLease((lease) => { + this.#bindKernelTidForChannel(channel, entry); + const result = this.#requireMainScratchRegion().withLease((lease) => { const kernelView = lease.dataView(0, CH_TOTAL_SIZE); lease.fill(0, CH_DATA, scratchBytes); if (input) { @@ -19319,12 +30496,9 @@ export class CentralizedKernelWorker { true, ); } else { - const rawMessageType = rawArgs?.[3] ?? origArgs[3] ?? 0; kernelView.setBigInt64( CH_ARGS + 3 * CH_ARG_SIZE, - typeof rawMessageType === "bigint" - ? rawMessageType - : BigInt(rawMessageType), + messageType, true, ); kernelView.setBigInt64( @@ -19341,14 +30515,19 @@ export class CentralizedKernelWorker { true, ); - this.bindKernelTidForChannel(channel); this.currentHandlePid = channel.pid; try { - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - channel.pid, - ]); + this.#invokeEntryScratchExport( + entry, + lease, + "kernel_handle_channel", + [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + snapshot.retryToken, + ], + ); } finally { this.currentHandlePid = 0; } @@ -19370,15 +30549,39 @@ export class CentralizedKernelWorker { return { retVal, errVal, canonicalOutput }; }); - this.dequeueSignalForDelivery(channel); - if (this.finishSignalTermination(channel)) return; + const deliveredSignal = this.#dequeueSignalForDelivery(channel, entry); + if (this.#finishSignalTermination(channel, entry)) return; - if ( - result.retVal === -1 - && result.errVal === EAGAIN - && (flags & IPC_NOWAIT) === 0 - ) { - this.handleBlockingRetry(channel, syscallNr, origArgs); + if (result.retVal === -1 && result.errVal === EAGAIN) { + if (!this.#rememberBlockingRetrySnapshot(channel, snapshot, entry)) { + return; + } + if ((flags & IPC_NOWAIT) !== 0) { + // Rust retains the exact queue generation before returning EAGAIN. + // IPC_NOWAIT forbids a host retry, but it does not waive the one + // exact release required before terminal guest publication. + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EAGAIN, + [], + undefined, + entry, + ); + return; + } + this.handleBlockingRetry( + channel, + syscallNr, + origArgs, + [], + entry, + retainedSnapshot !== undefined, + deliveredSignal, + ); return; } @@ -19422,9 +30625,12 @@ export class CentralizedKernelWorker { result.retVal, result.errVal, outputWrites, + undefined, + entry, ); } catch (error) { - this.rejectScratchTransfer(channel, error); + this.#rethrowKernelEntryFatal(error); + this.#rejectScratchTransfer(channel, error, entry); } } @@ -19432,7 +30638,8 @@ export class CentralizedKernelWorker { channel: ChannelInfo, syscallNr: typeof SYS_MSGCTL | typeof SYS_SHMCTL, origArgs: number[], - rawArgs?: readonly bigint[], + rawArgs: readonly bigint[], + entry: KernelWorkerEntryContext, ): void { const IPC_RMID = 0; const IPC_SET = 1; @@ -19444,7 +30651,7 @@ export class CentralizedKernelWorker { // direct-call fallback as a number lets the checked pointer conversion // reject fractional or unsafe test inputs instead of BigInt coercion // throwing before the syscall can report EFAULT. - const rawPointer = rawArgs?.[2] ?? origArgs[2] ?? 0; + const rawPointer = rawArgs[2] ?? BigInt(origArgs[2] ?? 0); const pointerWidth = this.getPtrWidth(channel.pid); const pointerCommand = cmd === IPC_SET || cmd === IPC_STAT; const outputCommand = cmd === IPC_STAT; @@ -19453,13 +30660,13 @@ export class CentralizedKernelWorker { let transferBytes = 0; let processPointer = 0; if (pointerCommand) { - if (rawPointer === 0n || rawPointer === 0) { + if (rawPointer === 0n) { throw new KernelScratchError("IPC control pointer is null", EFAULT); } const exportName = syscallNr === SYS_MSGCTL ? "kernel_msqid_ds_bytes" : "kernel_shmid_ds_bytes"; - const structureBytes = this.kernelInstance!.exports[exportName] as + const structureBytes = this.#kernelInstanceForEntry(entry).exports[exportName] as | ((width: number) => number) | undefined; if (typeof structureBytes !== "function") { @@ -19475,8 +30682,12 @@ export class CentralizedKernelWorker { || transferBytes > CH_DATA_SIZE ) { if (Number.isSafeInteger(transferBytes) && transferBytes < 0) { - this.completeChannelRaw(channel, -1, -transferBytes); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten( + channel, + -1, + -transferBytes, + entry, + ); return; } throw new KernelScratchError( @@ -19497,7 +30708,7 @@ export class CentralizedKernelWorker { } const processMemory = new Uint8Array(channel.memory.buffer); - const result = this.requireMainScratchRegion().withLease((lease) => { + const result = this.#requireMainScratchRegion().withLease((lease) => { const kernelView = lease.dataView(0, CH_TOTAL_SIZE); if (cmd === IPC_SET) { lease.copyFrom( @@ -19539,14 +30750,20 @@ export class CentralizedKernelWorker { true, ); - this.bindKernelTidForChannel(channel); + this.#bindKernelTidForChannel(channel, entry); this.currentHandlePid = channel.pid; try { - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - channel.pid, - ]); + this.#invokeEntryScratchExport( + entry, + lease, + "kernel_handle_channel", + [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + 0n, + ], + ); } finally { this.currentHandlePid = 0; } @@ -19562,10 +30779,15 @@ export class CentralizedKernelWorker { if (result.output) { processMemory.set(result.output, processPointer); } - this.completeChannelRaw(channel, result.retVal, result.errVal); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten( + channel, + result.retVal, + result.errVal, + entry, + ); } catch (error) { - this.rejectScratchTransfer(channel, error); + this.#rethrowKernelEntryFatal(error); + this.#rejectScratchTransfer(channel, error, entry); } } @@ -19574,10 +30796,11 @@ export class CentralizedKernelWorker { private handleSemctl( channel: ChannelInfo, origArgs: number[], - rawArgs?: readonly bigint[], + rawArgs: readonly bigint[], + entry: KernelWorkerEntryContext, ): void { const [semid, semnum, rawCmd, arg] = origArgs; - const rawArg = rawArgs?.[3] ?? arg; + const rawArg = rawArgs[3] ?? BigInt(arg); const cmd = rawCmd & ~IPC_64; const IPC_STAT = 2; const GETALL = 13; @@ -19587,11 +30810,11 @@ export class CentralizedKernelWorker { let transferBytes = 0; try { if (pointerCommand) { - if (rawArg === 0n || rawArg === 0) { + if (rawArg === 0n) { throw new KernelScratchError("semctl pointer is null", EFAULT); } if (cmd === IPC_STAT) { - const statBytes = this.kernelInstance!.exports + const statBytes = this.#kernelInstanceForEntry(entry).exports .kernel_semid_ds_bytes as | ((pointerWidth: number) => number) | undefined; @@ -19603,13 +30826,12 @@ export class CentralizedKernelWorker { } const result = statBytes(processPointerWidth); if (result < 0) { - this.completeChannelRaw(channel, -1, -result); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten(channel, -1, -result, entry); return; } transferBytes = result; } else { - const arrayBytes = this.kernelInstance!.exports + const arrayBytes = this.#kernelInstanceForEntry(entry).exports .kernel_semctl_array_bytes as | (( pid: number, @@ -19631,8 +30853,7 @@ export class CentralizedKernelWorker { rawCmd, ); if (result < 0) { - this.completeChannelRaw(channel, -1, -result); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten(channel, -1, -result, entry); return; } transferBytes = result; @@ -19656,7 +30877,7 @@ export class CentralizedKernelWorker { } const processMem = new Uint8Array(channel.memory.buffer); - const scratch = this.requireMainScratchRegion(); + const scratch = this.#requireMainScratchRegion(); const result = scratch.withLease((lease) => { const kernelView = lease.dataView(0, CH_TOTAL_SIZE); @@ -19698,14 +30919,20 @@ export class CentralizedKernelWorker { true, ); - this.bindKernelTidForChannel(channel); + this.#bindKernelTidForChannel(channel, entry); this.currentHandlePid = channel.pid; try { - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - channel.pid, - ]); + this.#invokeEntryScratchExport( + entry, + lease, + "kernel_handle_channel", + [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + 0n, + ], + ); } finally { this.currentHandlePid = 0; } @@ -19726,10 +30953,15 @@ export class CentralizedKernelWorker { ); processMem.set(result.output, processPointer); } - this.completeChannelRaw(channel, result.retVal, result.errVal); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten( + channel, + result.retVal, + result.errVal, + entry, + ); } catch (error) { - this.rejectScratchTransfer(channel, error); + this.#rethrowKernelEntryFatal(error); + this.#rejectScratchTransfer(channel, error, entry); } } @@ -19737,13 +30969,14 @@ export class CentralizedKernelWorker { channel: ChannelInfo, syscallNr: number, args: number[], + entry: KernelWorkerEntryContext, ): { retVal: number; errVal: number } { const previousPid = this.currentHandlePid; - this.bindKernelTidForChannel(channel); + this.#bindKernelTidForChannel(channel, entry); this.currentHandlePid = channel.pid; let captured: { retVal: number; errVal: number }; try { - captured = this.requireMainScratchRegion().withLease((lease) => { + captured = this.#requireMainScratchRegion().withLease((lease) => { const kernelView = lease.dataView(0, CH_TOTAL_SIZE); kernelView.setUint32(CH_SYSCALL, syscallNr, true); for (let i = 0; i < CH_ARGS_COUNT; i++) { @@ -19753,11 +30986,17 @@ export class CentralizedKernelWorker { true, ); } - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - channel.pid, - ]); + this.#invokeEntryScratchExport( + entry, + lease, + "kernel_handle_channel", + [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + channel.pid, + 0n, + ], + ); const resultView = lease.dataView(0, CH_TOTAL_SIZE); const rawRetVal = resultView.getBigInt64(CH_RETURN, true); return this.normalizeKernelSyscallResult( @@ -19770,17 +31009,68 @@ export class CentralizedKernelWorker { } finally { this.currentHandlePid = previousPid; } - if (this.finishSignalTermination(channel)) { + if (this.#finishSignalTermination(channel, entry)) { return { retVal: -EINTR_ERRNO, errVal: EINTR_ERRNO }; } return captured; } + /** + * Restore both process-address-space and Rust attachment ownership after a + * partially completed shmat. + * + * WHY: this helper receives the one exact lexical entry explicitly. It may + * not capture or reuse ambient Wasm authority, and an unproven rollback must + * poison the generation instead of being converted to an ordinary errno. + */ + #rollbackIpcShmatWithinKernelEntry( + channel: ChannelInfo, + shmid: number, + size: number, + allocatedAddr: number | null, + entry: KernelWorkerEntryContext, + ): void { + if (allocatedAddr !== null) { + const unmap = this.runSyntheticMemorySyscall( + channel, + SYS_MUNMAP, + [allocatedAddr, size], + entry, + ); + if (this.hostReaped.has(channel.pid)) return; + if ( + !Number.isSafeInteger(unmap.retVal) + || unmap.retVal < 0 + || unmap.errVal !== 0 + ) { + throw new KernelIpcShmatRollbackError( + `cannot roll back shmat process mapping for pid=${channel.pid}`, + ); + } + } + + const kernelShmdt = this.#kernelInstanceForEntry(entry).exports + .kernel_ipc_shmdt_for_process as + ((pid: number, shmid: number) => number) | undefined; + if (!kernelShmdt) { + throw new KernelIpcShmatRollbackError( + "kernel lacks required shmat rollback export", + ); + } + const result = kernelShmdt(channel.pid, shmid); + if (!Number.isSafeInteger(result) || result !== 0) { + throw new KernelIpcShmatRollbackError( + `cannot roll back shmat attachment ${shmid} for pid=${channel.pid}`, + ); + } + } + /** shmat: allocate a process interval and attach it to authoritative bytes. */ private handleIpcShmat( channel: ChannelInfo, args: number[], - rawArgs?: readonly bigint[], + rawArgs: readonly bigint[], + entry: KernelWorkerEntryContext, ): void { const [shmid, shmaddr, flags] = args; let checkedShmaddr: number; @@ -19794,20 +31084,18 @@ export class CentralizedKernelWorker { "shmat address", ); } catch (error) { - this.rejectScratchTransfer(channel, error); + this.#rejectScratchTransfer(channel, error, entry); return; } const callerTid = this.guestTidForChannel(channel); - this.validateKernelTid(channel.pid, callerTid); + this.validateKernelTid(channel.pid, callerTid, entry); // A previously sole observer may not have published at ordinary boundaries. // Force it current before this new attachment reads the segment. - this.syncSysvShmSegmentFromMappedProcesses(shmid); + this.syncSysvShmSegmentFromMappedProcesses(shmid, entry); - const kernelShmat = this.kernelInstance!.exports.kernel_ipc_shmat_for_task as + const kernelShmat = this.#kernelInstanceForEntry(entry).exports.kernel_ipc_shmat_for_task as (pid: number, tid: number, shmid: number, shmaddr: number, flags: number) => number; - const kernelShmdt = this.kernelInstance!.exports.kernel_ipc_shmdt_for_process as - (pid: number, shmid: number) => number; const sizeOrErr = kernelShmat( channel.pid, callerTid, @@ -19818,8 +31106,12 @@ export class CentralizedKernelWorker { flags, ); if (sizeOrErr < 0) { - this.completeChannelRaw(channel, sizeOrErr, -sizeOrErr); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten( + channel, + sizeOrErr, + -sizeOrErr, + entry, + ); return; } const size = sizeOrErr; @@ -19827,15 +31119,6 @@ export class CentralizedKernelWorker { const readOnly = (flags & SHM_RDONLY) !== 0; const prot = readOnly ? PROT_READ : PROT_READ | PROT_WRITE; let allocatedAddr: number | null = null; - const rollback = () => { - if (allocatedAddr !== null) { - try { this.runSyntheticMemorySyscall(channel, SYS_MUNMAP, [allocatedAddr, size]); } catch {} - if (this.hostReaped?.has(channel.pid)) return; - } - try { - kernelShmdt(channel.pid, shmid); - } catch {} - }; try { const mmap = this.runSyntheticMemorySyscall(channel, SYS_MMAP, [ @@ -19845,14 +31128,19 @@ export class CentralizedKernelWorker { 0x22, // MAP_PRIVATE | MAP_ANONYMOUS: host supplies sharing. -1, 0, - ]); - if (this.hostReaped?.has(channel.pid)) return; + ], entry); + if (this.hostReaped.has(channel.pid)) return; if (mmap.retVal < 0) { - rollback(); - if (this.hostReaped?.has(channel.pid)) return; + this.#rollbackIpcShmatWithinKernelEntry( + channel, + shmid, + size, + allocatedAddr, + entry, + ); + if (this.hostReaped.has(channel.pid)) return; const errno = mmap.errVal || ENOMEM; - this.completeChannelRaw(channel, -errno, errno); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten(channel, -errno, errno, entry); return; } allocatedAddr = checkedWasmPointer( @@ -19862,10 +31150,15 @@ export class CentralizedKernelWorker { ); // Unlike mmap, a non-null shmat address is not merely a fallback hint. if (checkedShmaddr !== 0 && allocatedAddr !== checkedShmaddr) { - rollback(); - if (this.hostReaped?.has(channel.pid)) return; - this.completeChannelRaw(channel, -EINVAL, EINVAL); - this.relistenChannel(channel); + this.#rollbackIpcShmatWithinKernelEntry( + channel, + shmid, + size, + allocatedAddr, + entry, + ); + if (this.hostReaped.has(channel.pid)) return; + this.completeChannelRawAndRelisten(channel, -EINVAL, EINVAL, entry); return; } @@ -19875,8 +31168,9 @@ export class CentralizedKernelWorker { SYS_MMAP, allocatedAddr, [checkedShmaddr, size, prot, 0x22, -1, 0], + entry, ); - const snapshot = this.readSysvShmRange(shmid, 0, size); + const snapshot = this.readSysvShmRange(shmid, 0, size, entry); const processMem = new Uint8Array(channel.memory.buffer); let mappedRangeValid = false; try { @@ -19889,10 +31183,15 @@ export class CentralizedKernelWorker { mappedRangeValid = true; } catch {} if (!snapshot || !mappedRangeValid) { - rollback(); - if (this.hostReaped?.has(channel.pid)) return; - this.completeChannelRaw(channel, -EIO, EIO); - this.relistenChannel(channel); + this.#rollbackIpcShmatWithinKernelEntry( + channel, + shmid, + size, + allocatedAddr, + entry, + ); + if (this.hostReaped.has(channel.pid)) return; + this.completeChannelRawAndRelisten(channel, -EIO, EIO, entry); return; } processMem.set(snapshot, allocatedAddr); @@ -19910,24 +31209,31 @@ export class CentralizedKernelWorker { seenVersion: this.shmSegmentVersions.get(shmid) ?? 0, }); } catch (err) { + this.#rethrowKernelEntryFatal(err); + if (err instanceof KernelIpcShmatRollbackError) throw err; console.error(`[handleIpcShmat] mmap failed for pid=${channel.pid}:`, err); - rollback(); - if (this.hostReaped?.has(channel.pid)) return; + this.#rollbackIpcShmatWithinKernelEntry( + channel, + shmid, + size, + allocatedAddr, + entry, + ); + if (this.hostReaped.has(channel.pid)) return; const errno = err instanceof KernelScratchError ? EIO : ENOMEM; - this.completeChannelRaw(channel, -errno, errno); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten(channel, -errno, errno, entry); return; } - this.completeChannelRaw(channel, allocatedAddr!, 0); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten(channel, allocatedAddr!, 0, entry); } /** shmdt: publish this attachment, detach exactly once, and unmap it. */ private handleIpcShmdt( channel: ChannelInfo, args: number[], - rawArgs?: readonly bigint[], + rawArgs: readonly bigint[], + entry: KernelWorkerEntryContext, ): void { let addr: number; try { @@ -19939,33 +31245,35 @@ export class CentralizedKernelWorker { "shmdt address", ); } catch (error) { - this.rejectScratchTransfer(channel, error); + this.#rejectScratchTransfer(channel, error, entry); return; } const callerTid = this.guestTidForChannel(channel); - this.validateKernelTid(channel.pid, callerTid); + this.validateKernelTid(channel.pid, callerTid, entry); const pidMappings = this.shmMappings.get(channel.pid); if (!pidMappings) { - this.completeChannelRaw(channel, -22, 22); // EINVAL - this.relistenChannel(channel); + this.completeChannelRawAndRelisten(channel, -22, 22, entry); // EINVAL return; } const mapping = pidMappings.get(addr); if (!mapping) { - this.completeChannelRaw(channel, -22, 22); // EINVAL - this.relistenChannel(channel); + this.completeChannelRawAndRelisten(channel, -22, 22, entry); // EINVAL return; } const processMem = new Uint8Array(channel.memory.buffer); - const synced = this.mergeAndRefreshSysvShmMapping(processMem, addr, mapping); + const synced = this.mergeAndRefreshSysvShmMapping( + processMem, + addr, + mapping, + entry, + ); if (!synced) { - this.completeChannelRaw(channel, -EIO, EIO); - this.relistenChannel(channel); + this.completeChannelRawAndRelisten(channel, -EIO, EIO, entry); return; } - const kernelShmdt = this.kernelInstance!.exports.kernel_ipc_shmdt_for_task as + const kernelShmdt = this.#kernelInstanceForEntry(entry).exports.kernel_ipc_shmdt_for_task as (pid: number, tid: number, shmid: number) => number; const result = kernelShmdt( channel.pid, @@ -19974,21 +31282,31 @@ export class CentralizedKernelWorker { ); if (result < 0) { - this.completeChannelRaw(channel, result, -result); + this.completeChannelRawAndRelisten(channel, result, -result, entry); } else { pidMappings.delete(addr); if (pidMappings.size === 0) this.shmMappings.delete(channel.pid); let unmapFailed = false; try { - const unmap = this.runSyntheticMemorySyscall(channel, SYS_MUNMAP, [addr, mapping.size]); + const unmap = this.runSyntheticMemorySyscall( + channel, + SYS_MUNMAP, + [addr, mapping.size], + entry, + ); if (this.hostReaped?.has(channel.pid)) return; unmapFailed = unmap.retVal < 0; - } catch { + } catch (error) { + this.#rethrowKernelEntryFatal(error); unmapFailed = true; } - this.completeChannelRaw(channel, unmapFailed ? -EIO : 0, unmapFailed ? EIO : 0); + this.completeChannelRawAndRelisten( + channel, + unmapFailed ? -EIO : 0, + unmapFailed ? EIO : 0, + entry, + ); } - this.relistenChannel(channel); } // ========================================================================= @@ -20000,16 +31318,20 @@ export class CentralizedKernelWorker { * (a signal to deliver when a message arrives on a previously empty queue). * The notification is stored in the kernel's MqueueTable and drained here. */ - private drainMqueueNotification(): void { - const drain = this.kernelInstance!.exports.kernel_mq_drain_notification as + private drainMqueueNotification( + entry?: KernelWorkerEntryContext, + ): void { + const drain = this.#kernelInstanceForEntry(entry).exports.kernel_mq_drain_notification as (( outPtr: KernelPointer, outCapacity: number, ) => number) | undefined; if (!drain) return; - const notification = this.requireMainScratchRegion().withLease((lease) => { - const hasPending = lease.invokeKernelExport( + const notification = this.#requireMainScratchRegion().withLease((lease) => { + const hasPending = this.#invokeEntryScratchExport( + entry, + lease, "kernel_mq_drain_notification", [ lease.exportPointer( @@ -20049,7 +31371,12 @@ export class CentralizedKernelWorker { // before waking and processing the detached notification. if (notification && notification.signo > 0) { this.wakePendingSignalWaits(notification.pid, notification.signo); - this.sendSignalToProcess(notification.pid, notification.signo, false); + this.sendSignalToProcess( + notification.pid, + notification.signo, + false, + entry, + ); } } @@ -20060,15 +31387,15 @@ export class CentralizedKernelWorker { * consumers read pixels by projecting `[addr, addr+len)` onto the * SAB returned by `getProcessMemory(pid)`. */ get bos() { - return this.kernel.bos; + return this.#kernel.bos; } get gl() { - return this.kernel.gl; + return this.#kernel.gl; } get kms() { - return this.kernel.kms; + return this.#kernel.kms; } /** Register an `OffscreenCanvas` (and optional stats SAB) as the @@ -20093,8 +31420,11 @@ export class CentralizedKernelWorker { statsSab?: SharedArrayBuffer, opts?: { mode?: "auto" | "2d" | "webgl2" }, ): void { + const statsView = statsSab === undefined + ? undefined + : new Int32Array(statsSab); this.kmsCanvases.set(crtc_id, canvas); - if (statsSab) this.kmsStatsViews.set(crtc_id, new Int32Array(statsSab)); + if (statsView) this.kmsStatsViews.set(crtc_id, statsView); const mode = opts?.mode ?? "auto"; if (mode === "2d") { // Eagerly acquire 2D so the first tickVblank can blit without a @@ -20118,82 +31448,207 @@ export class CentralizedKernelWorker { * target. Used by demos that render through the GL bridge while * still driving real `drmModePageFlip` ioctls. */ attachKmsStats(crtc_id: number, statsSab: SharedArrayBuffer): void { - this.kmsStatsViews.set(crtc_id, new Int32Array(statsSab)); + this.kmsStatsViews.set( + crtc_id, + new Int32Array(statsSab), + ); this.startVblankPump(); } private startVblankPump(): void { - if (this.vblankTimer) return; - this.vblankTimer = setInterval(() => this.tickVblank(), 1000 / 60); + if ( + this.vblankTimer + || !this.#initialized + || this.#kernelInstance === null + || this.#kernelFatalError !== null + ) { + return; + } + const timer = this.#registerInterval(() => { + this.#runScheduledListenerRoot("vblank interval", () => { + if (this.vblankTimer !== timer) { + this.#cancelRegisteredInterval(timer); + return; + } + if ( + this.#kernelFatalError !== null + || !this.#initialized + || this.#kernelInstance === null + ) { + this.#cancelRegisteredInterval(timer); + if (this.vblankTimer === timer) this.vblankTimer = null; + return; + } + this.#tickVblank(timer); + }); + }, 1000 / 60); + this.vblankTimer = timer; // Node only: prevent the pump from blocking process exit. - (this.vblankTimer as { unref?: () => void }).unref?.(); - } - - private tickVblank(): void { - const vblankFn = this.kernelInstance?.exports.kernel_vblank as - (() => void) | undefined; - vblankFn?.(); - // 2D-blit path. Runs only for CRTCs the embedder explicitly opted - // into `mode: "2d"`. The pump never touches the canvas in "auto" - // or "webgl2" mode — touching it with `getContext("2d")` would - // claim the canvas for life and break the later WebGL2 attach - // (an OffscreenCanvas can only hold one context type ever). - for (const [crtc_id, canvas] of this.kmsCanvases) { - if (this.kmsContextMode.get(crtc_id) !== "2d") continue; - const fb = this.kernel.kms.currentFb(crtc_id); - if (!fb) continue; - const pixels = this.kernel.kms.scanoutBytes(crtc_id); - if (!pixels) continue; - const ctx = this.kmsContexts.get(crtc_id); - if (!ctx) continue; - if (canvas.width !== fb.width || canvas.height !== fb.height) { - canvas.width = fb.width; - canvas.height = fb.height; - } - // bo bytes are opaque RGBA8888 — one memcpy into a cached - // Uint8ClampedArray is all the pump owes the canvas. - const blitStart = performance.now(); - const need = fb.width * fb.height * 4; - let scratch = this.kmsScratchBytes.get(crtc_id); - if (!scratch || scratch.byteLength !== need) { - scratch = new Uint8ClampedArray(new ArrayBuffer(need)) as Uint8ClampedArray; - this.kmsScratchBytes.set(crtc_id, scratch); - } - scratch.set(pixels); - ctx.putImageData(new ImageData(scratch, fb.width, fb.height), 0, 0); - const blitUs = ((performance.now() - blitStart) * 1000) | 0; - const stats = this.kmsStatsViews.get(crtc_id); - if (stats) { - Atomics.add(stats, 0, 1); - Atomics.store(stats, 1, performance.now() | 0); - Atomics.store(stats, 4, blitUs); - } - } - - // Slots 2/3 (scanout width/height) and 5/6 (kernel-side PAGE_FLIP - // commit count, last frame µs) are populated for every CRTC with - // a stats SAB, regardless of the canvas-owner mode. Slots 2/3 - // sourced from the kernel's current FB so embedders (e.g. the - // Modeset React pane) can detect "scanout active" without - // depending on the 2D-blit path. Slots 5/6 reflect kernel-side - // ioctls — independent of the 60 Hz blit loop above. - if (this.kmsStatsViews.size > 0) { - const exports = this.kernelInstance?.exports as - | { kernel_kms_commit_count?: (id: number) => bigint; - kernel_kms_last_frame_us?: (id: number) => bigint } - | undefined; - for (const [crtc_id, stats] of this.kmsStatsViews) { - const fb = this.kernel.kms.currentFb(crtc_id); - if (fb) { - Atomics.store(stats, 2, fb.width); - Atomics.store(stats, 3, fb.height); + (timer as { unref?: () => void }).unref?.(); + } + + #tickVblank(timer: ReturnType): void { + this.#runOrDeferKernelEntry( + "vblank tick", + (entry) => { + if (this.vblankTimer !== timer) return; + const vblankFn = entry.instance.exports.kernel_vblank as + (() => void) | undefined; + vblankFn?.(); + + // WHY: process/BO memory may change on the next ingress. Copy every + // scanout now; the detached canvas phase retains no Wasm view. + const blits: Array<{ + crtcId: number; + canvas: OffscreenCanvas; + context: OffscreenCanvasRenderingContext2D; + width: number; + height: number; + pixels: Uint8Array; + stats: Int32Array | undefined; + }> = []; + for (const [crtcId, canvas] of this.kmsCanvases) { + if (this.kmsContextMode.get(crtcId) !== "2d") continue; + const fb = this.#kernel.kms.currentFb(crtcId); + const pixels = fb + ? this.#kernel.kms.scanoutBytes(crtcId) + : null; + const context = this.kmsContexts.get(crtcId); + if (!fb || !pixels || !context) continue; + const width = fb.width; + const height = fb.height; + const pitch = fb.pitch; + if ( + !Number.isSafeInteger(width) + || width <= 0 + || !Number.isSafeInteger(height) + || height <= 0 + || !Number.isSafeInteger(pitch) + || pitch <= 0 + ) { + throw new KernelScratchError( + `invalid KMS scanout geometry ${width}x${height} pitch=${pitch}`, + EIO, + ); + } + const rowBytes = width * 4; + const packedLength = rowBytes * height; + const sourceLength = pitch * (height - 1) + rowBytes; + if ( + !Number.isSafeInteger(rowBytes) + || !Number.isSafeInteger(packedLength) + || !Number.isSafeInteger(sourceLength) + || pitch < rowBytes + || sourceLength > pixels.byteLength + ) { + throw new KernelScratchError( + "KMS scanout bytes do not cover the declared pitch and geometry", + EIO, + ); + } + const packedPixels = new Uint8Array(packedLength); + for (let row = 0; row < height; row++) { + const sourceOffset = row * pitch; + packedPixels.set( + pixels.subarray(sourceOffset, sourceOffset + rowBytes), + row * rowBytes, + ); + } + blits.push({ + crtcId, + canvas, + context, + width, + height, + pixels: packedPixels, + stats: this.kmsStatsViews.get(crtcId), + }); } - if (stats.length < 7) continue; - const commits = exports?.kernel_kms_commit_count?.(crtc_id) ?? 0n; - const lastUs = exports?.kernel_kms_last_frame_us?.(crtc_id) ?? 0n; - Atomics.store(stats, 5, Number(commits & 0x7fffffffn)); - Atomics.store(stats, 6, Number(lastUs & 0x7fffffffn)); - } - } + + const commitCount = entry.instance.exports + .kernel_kms_commit_count as + ((id: number) => bigint) | undefined; + const lastFrameUs = entry.instance.exports + .kernel_kms_last_frame_us as + ((id: number) => bigint) | undefined; + const statsSnapshots: Array<{ + stats: Int32Array; + width: number | null; + height: number | null; + commits: number; + lastUs: number; + }> = []; + for (const [crtcId, stats] of this.kmsStatsViews) { + const fb = this.#kernel.kms.currentFb(crtcId); + const commits = stats.length < 7 + ? 0 + : Number((commitCount?.(crtcId) ?? 0n) & 0x7fffffffn); + const lastUs = stats.length < 7 + ? 0 + : Number((lastFrameUs?.(crtcId) ?? 0n) & 0x7fffffffn); + statsSnapshots.push({ + stats, + width: fb?.width ?? null, + height: fb?.height ?? null, + commits, + lastUs, + }); + } + + entry.deferObserverEffect(() => { + if (this.vblankTimer !== timer) return undefined; + // The pump never acquires a context here: only canvases explicitly + // attached in 2D mode can appear in the owned snapshot. + for (const blit of blits) { + if ( + blit.canvas.width !== blit.width + || blit.canvas.height !== blit.height + ) { + blit.canvas.width = blit.width; + blit.canvas.height = blit.height; + } + const blitStart = performance.now(); + const need = blit.pixels.byteLength; + let scratch = this.kmsScratchBytes.get(blit.crtcId); + if (!scratch || scratch.byteLength !== need) { + scratch = new Uint8ClampedArray( + new ArrayBuffer(need), + ) as Uint8ClampedArray; + this.kmsScratchBytes.set(blit.crtcId, scratch); + } + scratch.set(blit.pixels); + blit.context.putImageData( + new ImageData(scratch, blit.width, blit.height), + 0, + 0, + ); + if (blit.stats) { + const blitUs = + ((performance.now() - blitStart) * 1000) | 0; + Atomics.add(blit.stats, 0, 1); + Atomics.store(blit.stats, 1, performance.now() | 0); + Atomics.store(blit.stats, 4, blitUs); + } + } + for (const snapshot of statsSnapshots) { + if (snapshot.width !== null && snapshot.height !== null) { + Atomics.store(snapshot.stats, 2, snapshot.width); + Atomics.store(snapshot.stats, 3, snapshot.height); + } + if (snapshot.stats.length >= 7) { + Atomics.store(snapshot.stats, 5, snapshot.commits); + Atomics.store(snapshot.stats, 6, snapshot.lastUs); + } + } + return undefined; + }); + }, + ); } } + +// WHY: observers receive host callbacks after scoped Rust work and may retain +// a reference to the owning worker. A frozen prototype plus sealed production +// instances prevents either prototype replacement or own-property shadowing +// from redirecting a later entry-taking call and stealing its live context. +kernelEntryIntrinsicObjectFreeze(CentralizedKernelWorker.prototype); diff --git a/host/src/kernel.ts b/host/src/kernel.ts index c9cc53f7ab..b2e0250173 100644 --- a/host/src/kernel.ts +++ b/host/src/kernel.ts @@ -7,6 +7,10 @@ * env.host_close(handle: i64) -> i32 * env.host_read(handle: i64, buf_ptr, buf_len) -> i32 * env.host_write(handle: i64, buf_ptr, buf_len) -> i32 + * env.host_append(handle: i64, buf_ptr, buf_len, limit_lo, limit_hi) -> i32 + * env.host_append_position(handle: i64, written) -> i64 + * env.host_pread(handle: i64, buf_ptr, buf_len, offset_lo, offset_hi) -> i32 + * env.host_pwrite(handle: i64, buf_ptr, buf_len, offset_lo, offset_hi) -> i32 * env.host_seek(handle: i64, offset_lo, offset_hi, whence) -> i64 * env.host_fstat(handle: i64, stat_ptr) -> i32 * env.host_statfs(path_ptr, path_len, statfs_ptr) -> i32 @@ -14,7 +18,16 @@ * IMPORTANT: Wasm i64 values appear as BigInt in JavaScript. */ -import type { KernelConfig, PlatformIO, StatResult, StatfsResult } from "./types"; +import type { + AppendOutcome, + HostFileOffset, + KernelConfig, + PlatformIO, + StatResult, + StatfsResult, +} from "./types"; +import { checkedHostFileOffset } from "./file-offset"; +import { isHostAppendContractError } from "./append-contract"; import { SharedPipeBuffer } from "./shared-pipe-buffer"; import { FramebufferRegistry } from "./framebuffer/registry"; import { GbmBoRegistry } from "./dri/registry"; @@ -43,22 +56,67 @@ import { import { detectPtrWidth } from "./constants"; import { allocateKernelScratchRegion, + checkedMemoryRange, checkedWasmImportMemoryRange, checkedWasmPointer, intrinsicUint8ArrayView, KernelScratchError, type KernelScratchRegion, } from "./kernel-scratch"; +import { + createKernelEntryGatedInstance, + createKernelEntryScopedInstance, + KernelEntryGate, +} from "./kernel-entry-gate"; export type KernelPointer = number | bigint; +interface WasmPosixKernelRuntimeAccess { + readonly gate: KernelEntryGate; + readonly instance: () => WebAssembly.Instance | null; + readonly memory: () => WebAssembly.Memory | null; +} + +// Package-private authority used by kernel-worker.ts. It is intentionally not +// re-exported from the host package entry point. +const wasmPosixKernelRuntimeAccess = + new WeakMap(); + +/** @internal Dedicated-worker access; never return this from a public API. */ +export function getWasmPosixKernelRuntimeAccess( + kernel: WasmPosixKernel, +): WasmPosixKernelRuntimeAccess { + const access = intrinsicApply( + intrinsicWeakMapGet, + wasmPosixKernelRuntimeAccess, + [kernel], + ) as WasmPosixKernelRuntimeAccess | undefined; + if (access === undefined) { + throw new Error("unknown WasmPosixKernel runtime"); + } + return access; +} + const MAX_U64 = (1n << 64n) - 1n; const intrinsicApply = Reflect.apply; +const intrinsicBigInt = BigInt; +const intrinsicNumber = Number; +const intrinsicNumberIsSafeInteger = Number.isSafeInteger; +const INTRINSIC_NUMBER_MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER; +const intrinsicWeakMapGet = WeakMap.prototype.get; +const intrinsicWeakMapSet = WeakMap.prototype.set; const intrinsicArrayBufferIsView = ArrayBuffer.isView; const intrinsicArrayBufferByteLength = Object.getOwnPropertyDescriptor( ArrayBuffer.prototype, "byteLength", )!.get!; +const intrinsicSharedArrayBufferByteLength = + typeof SharedArrayBuffer === "undefined" + ? null + : Object.getOwnPropertyDescriptor( + SharedArrayBuffer.prototype, + "byteLength", + )!.get!; const intrinsicDataViewBuffer = Object.getOwnPropertyDescriptor( DataView.prototype, "buffer", @@ -87,7 +145,360 @@ const intrinsicTypedArrayByteLength = Object.getOwnPropertyDescriptor( "byteLength", )!.get!; const IntrinsicUint8Array = Uint8Array; +const IntrinsicInt32Array = Int32Array; +const IntrinsicDataView = DataView; +const IntrinsicWasmMemory = WebAssembly.Memory; const intrinsicUint8ArraySet = Uint8Array.prototype.set; +const intrinsicUint8ArraySlice = Uint8Array.prototype.slice; +const intrinsicUint8ArraySubarray = Uint8Array.prototype.subarray; +const intrinsicDataViewGetInt16 = DataView.prototype.getInt16; +const intrinsicDataViewGetInt32 = DataView.prototype.getInt32; +const intrinsicDataViewGetUint8 = DataView.prototype.getUint8; +const intrinsicDataViewGetUint32 = DataView.prototype.getUint32; +const intrinsicDataViewSetBigInt64 = DataView.prototype.setBigInt64; +const intrinsicDataViewSetBigUint64 = DataView.prototype.setBigUint64; +const intrinsicDataViewSetInt16 = DataView.prototype.setInt16; +const intrinsicDataViewSetInt32 = DataView.prototype.setInt32; +const intrinsicDataViewSetUint8 = DataView.prototype.setUint8; +const intrinsicDataViewSetUint16 = DataView.prototype.setUint16; +const intrinsicDataViewSetUint32 = DataView.prototype.setUint32; +const intrinsicAtomicsCompareExchange = Atomics.compareExchange; +const intrinsicAtomicsLoad = Atomics.load; +const intrinsicAtomicsNotify = Atomics.notify; +const intrinsicAtomicsStore = Atomics.store; +const intrinsicAtomicsWait = Atomics.wait; +const intrinsicReflectConstruct = Reflect.construct; +const intrinsicObjectCreate = Object.create; +const intrinsicObjectDefineProperty = Object.defineProperty; +const intrinsicObjectFreeze = Object.freeze; +const intrinsicWasmCompile = WebAssembly.compile; +const intrinsicWasmInstantiate = WebAssembly.instantiate; +const intrinsicWasmMemoryBuffer = Object.getOwnPropertyDescriptor( + WebAssembly.Memory.prototype, + "buffer", +)!.get!; +const intrinsicWasmInstanceExports = Object.getOwnPropertyDescriptor( + WebAssembly.Instance.prototype, + "exports", +)!.get!; +const intrinsicWasmTableGet = WebAssembly.Table.prototype.get; + +const wasmPosixKernelTestCapability = {}; + +interface WasmPosixKernelTestHarnessOptions { + readonly config?: KernelConfig; + readonly io?: PlatformIO; + readonly callbacks?: KernelCallbacks; + readonly instance?: WebAssembly.Instance | null; + readonly memory?: WebAssembly.Memory | null; + readonly pointerWidth?: 4 | 8; + readonly initialized?: boolean; + readonly engine?: { + readonly compile: ( + bytes: BufferSource, + ) => Promise; + readonly instantiate: ( + module: WebAssembly.Module, + imports: WebAssembly.Imports, + ) => Promise; + }; +} + +interface WasmPosixKernelTestAuthority { + buildImportObject(memory: WebAssembly.Memory): WebAssembly.Imports; + writeKernelBytes( + pointer: KernelPointer, + capacity: number | bigint, + bytes: Uint8Array, + ): void; + hostFstat(handle: bigint, statPointer: KernelPointer): number; + hostReaddir( + handle: bigint, + direntPointer: KernelPointer, + namePointer: KernelPointer, + nameLength: number, + ): number; + hostClosedir(handle: bigint): number; + hostClose(handle: bigint): number; +} + +type WasmPosixKernelTestHarness = WasmPosixKernel & { + readonly testAuthority: WasmPosixKernelTestAuthority; +}; + +declare const rustLentKernelDestinationBrand: unique symbol; + +/** + * Opaque proof that one host-import destination belongs to this exact kernel + * generation and carries an explicit Rust-declared capacity. + * + * The pointer and ownership record intentionally live only in the module + * WeakMap below. Structural objects with the same public capacity cannot be + * used to authorize a write. + */ +interface RustLentKernelDestination { + readonly [rustLentKernelDestinationBrand]: never; + readonly capacity: number; +} + +interface RustLentKernelDestinationRecord { + readonly owner: WasmPosixKernel; + readonly generation: object; + readonly memory: WebAssembly.Memory; + readonly pointer: number; + readonly capacity: number; + readonly label: string; + consumed: boolean; +} + +const rustLentKernelDestinationRecords = + new WeakMap(); + +/** + * @internal Build a real private-branded wrapper for focused host unit tests. + * + * WHY: scratch tests need deterministic fake Wasm exports, but an + * `Object.create(WasmPosixKernel.prototype)` double has no JavaScript private + * field brand. The unexported capability keeps this construction path out of + * supported package entry points and prevents reflective callers from + * replacing a live generation's Instance or Memory after construction. + */ +export function createWasmPosixKernelTestHarness( + options: WasmPosixKernelTestHarnessOptions, +): WasmPosixKernelTestHarness { + return intrinsicReflectConstruct( + WasmPosixKernel, + [ + options.config ?? ({} as KernelConfig), + options.io ?? ({} as PlatformIO), + options.callbacks, + wasmPosixKernelTestCapability, + options, + ], + ) as WasmPosixKernelTestHarness; +} + +function wasmMemoryBuffer(memory: WebAssembly.Memory): ArrayBufferLike { + return intrinsicApply( + intrinsicWasmMemoryBuffer, + memory, + [], + ) as ArrayBufferLike; +} + +function wasmInstanceExports( + instance: WebAssembly.Instance, +): WebAssembly.Exports { + return intrinsicApply( + intrinsicWasmInstanceExports, + instance, + [], + ) as WebAssembly.Exports; +} + +function bufferByteLength(buffer: ArrayBufferLike): number { + try { + return intrinsicApply( + intrinsicArrayBufferByteLength, + buffer, + [], + ) as number; + } catch { + if (intrinsicSharedArrayBufferByteLength !== null) { + return intrinsicApply( + intrinsicSharedArrayBufferByteLength, + buffer, + [], + ) as number; + } + throw new TypeError("memory buffer is not a genuine attached buffer"); + } +} + +function typedArrayBuffer(view: Uint8Array): ArrayBufferLike { + return intrinsicApply( + intrinsicTypedArrayBuffer, + view, + [], + ) as ArrayBufferLike; +} + +function typedArrayByteOffset(view: Uint8Array): number { + return intrinsicApply( + intrinsicTypedArrayByteOffset, + view, + [], + ) as number; +} + +function typedArrayByteLength(view: Uint8Array): number { + return intrinsicApply( + intrinsicTypedArrayByteLength, + view, + [], + ) as number; +} + +function sliceUint8Array( + view: Uint8Array, + start?: number, + end?: number, +): Uint8Array { + return intrinsicApply( + intrinsicUint8ArraySlice, + view, + end === undefined + ? start === undefined ? [] : [start] + : [start ?? 0, end], + ) as Uint8Array; +} + +function subarrayUint8Array( + view: Uint8Array, + start: number, + end?: number, +): Uint8Array { + return intrinsicApply( + intrinsicUint8ArraySubarray, + view, + end === undefined ? [start] : [start, end], + ) as Uint8Array; +} + +function dataViewGetInt16( + view: DataView, + byteOffset: number, + littleEndian = false, +): number { + return intrinsicApply( + intrinsicDataViewGetInt16, + view, + [byteOffset, littleEndian], + ) as number; +} + +function dataViewGetInt32( + view: DataView, + byteOffset: number, + littleEndian = false, +): number { + return intrinsicApply( + intrinsicDataViewGetInt32, + view, + [byteOffset, littleEndian], + ) as number; +} + +function dataViewGetUint8(view: DataView, byteOffset: number): number { + return intrinsicApply( + intrinsicDataViewGetUint8, + view, + [byteOffset], + ) as number; +} + +function dataViewGetUint32( + view: DataView, + byteOffset: number, + littleEndian = false, +): number { + return intrinsicApply( + intrinsicDataViewGetUint32, + view, + [byteOffset, littleEndian], + ) as number; +} + +function dataViewSetBigInt64( + view: DataView, + byteOffset: number, + value: bigint, + littleEndian = false, +): void { + intrinsicApply( + intrinsicDataViewSetBigInt64, + view, + [byteOffset, value, littleEndian], + ); +} + +function dataViewSetBigUint64( + view: DataView, + byteOffset: number, + value: bigint, + littleEndian = false, +): void { + intrinsicApply( + intrinsicDataViewSetBigUint64, + view, + [byteOffset, value, littleEndian], + ); +} + +function dataViewSetInt16( + view: DataView, + byteOffset: number, + value: number, + littleEndian = false, +): void { + intrinsicApply( + intrinsicDataViewSetInt16, + view, + [byteOffset, value, littleEndian], + ); +} + +function dataViewSetInt32( + view: DataView, + byteOffset: number, + value: number, + littleEndian = false, +): void { + intrinsicApply( + intrinsicDataViewSetInt32, + view, + [byteOffset, value, littleEndian], + ); +} + +function dataViewSetUint8( + view: DataView, + byteOffset: number, + value: number, +): void { + intrinsicApply(intrinsicDataViewSetUint8, view, [byteOffset, value]); +} + +function dataViewSetUint16( + view: DataView, + byteOffset: number, + value: number, + littleEndian = false, +): void { + intrinsicApply( + intrinsicDataViewSetUint16, + view, + [byteOffset, value, littleEndian], + ); +} + +function dataViewSetUint32( + view: DataView, + byteOffset: number, + value: number, + littleEndian = false, +): void { + intrinsicApply( + intrinsicDataViewSetUint32, + view, + [byteOffset, value, littleEndian], + ); +} + +function signedI64FromWords(offsetLo: number, offsetHi: number): bigint { + return (intrinsicBigInt(offsetHi | 0) << 32n) + | intrinsicBigInt(offsetLo >>> 0); +} interface IntrinsicBufferSourceSpan { buffer: ArrayBufferLike; @@ -210,23 +621,23 @@ function kmsModeInfoBytes( const vsyncEnd = clampU16(h + 8); const vtotal = clampU16(h + 45); const clock = Math.max(1, Math.min(0xffffffff, Math.round(htotal * vtotal * refreshHz / 1000))); - const out = new Uint8Array(STRUCT_SIZE_WPK_DRM_MODE_MODEINFO); - const dv = new DataView(out.buffer); - dv.setUint32(0, clock, true); - dv.setUint16(4, w, true); - dv.setUint16(6, hsyncStart, true); - dv.setUint16(8, hsyncEnd, true); - dv.setUint16(10, htotal, true); - dv.setUint16(12, 0, true); - dv.setUint16(14, h, true); - dv.setUint16(16, vsyncStart, true); - dv.setUint16(18, vsyncEnd, true); - dv.setUint16(20, vtotal, true); - dv.setUint16(22, 0, true); - dv.setUint32(24, refreshHz, true); - dv.setUint32(28, 0, true); + const out = new IntrinsicUint8Array(STRUCT_SIZE_WPK_DRM_MODE_MODEINFO); + const dv = new IntrinsicDataView(typedArrayBuffer(out)); + dataViewSetUint32(dv, 0, clock, true); + dataViewSetUint16(dv, 4, w, true); + dataViewSetUint16(dv, 6, hsyncStart, true); + dataViewSetUint16(dv, 8, hsyncEnd, true); + dataViewSetUint16(dv, 10, htotal, true); + dataViewSetUint16(dv, 12, 0, true); + dataViewSetUint16(dv, 14, h, true); + dataViewSetUint16(dv, 16, vsyncStart, true); + dataViewSetUint16(dv, 18, vsyncEnd, true); + dataViewSetUint16(dv, 20, vtotal, true); + dataViewSetUint16(dv, 22, 0, true); + dataViewSetUint32(dv, 24, refreshHz, true); + dataViewSetUint32(dv, 28, 0, true); // DRM_MODE_TYPE_DRIVER | DRM_MODE_TYPE_PREFERRED - dv.setUint32(32, 0x1 | 0x8, true); + dataViewSetUint32(dv, 32, 0x1 | 0x8, true); const name = `${w}x${h}`; for (let i = 0; i < Math.min(name.length, 31); i++) { out[36 + i] = name.charCodeAt(i) & 0xff; @@ -403,9 +814,12 @@ export class WasmPosixKernel { private config: KernelConfig; private io: PlatformIO; private callbacks: KernelCallbacks; - private instance: WebAssembly.Instance | null = null; - private memory: WebAssembly.Memory | null = null; - private kernelPtrWidth: 4 | 8 = 4; + #instance: WebAssembly.Instance | null = null; + #memory: WebAssembly.Memory | null = null; + #memoryGeneration: object = intrinsicObjectFreeze({}); + readonly #kernelEntryGate = new KernelEntryGate(); + #kernelPtrWidth: 4 | 8 = 4; + #testEngine: WasmPosixKernelTestHarnessOptions["engine"] | undefined; /** * One wrapper owns exactly one kernel Wasm generation. * @@ -416,13 +830,14 @@ export class WasmPosixKernel { * invariant structural instead of relying on every cached region being * remembered during a future reinitialization. */ - private initializationState: + #initializationState: | "uninitialized" | "initializing" | "initialized" = "uninitialized"; private sharedPipes = new Map(); private signalWakeSab: SharedArrayBuffer | null = null; private programFuncTable: WebAssembly.Table | null = null; + #kernelFuncTable: WebAssembly.Table | null = null; private waitpidSab: SharedArrayBuffer | null = null; /** * A backend directory iterator may already have advanced before the host @@ -515,10 +930,54 @@ export class WasmPosixKernel { this.programFuncTable = table; } - constructor(config: KernelConfig, io: PlatformIO, callbacks?: KernelCallbacks) { + constructor( + config: KernelConfig, + io: PlatformIO, + callbacks?: KernelCallbacks, + ) { this.config = config; this.io = io; this.callbacks = callbacks ?? {}; + if (arguments[3] === wasmPosixKernelTestCapability) { + const testRuntime = arguments[4] as + | WasmPosixKernelTestHarnessOptions + | undefined; + if (testRuntime === undefined) { + throw new Error("missing WasmPosixKernel test runtime"); + } + this.#instance = testRuntime.instance ?? null; + this.#kernelFuncTable = testRuntime.instance === undefined + || testRuntime.instance === null + ? null + : ( + wasmInstanceExports(testRuntime.instance) + .__indirect_function_table as WebAssembly.Table | undefined + ) ?? null; + this.#memory = testRuntime.memory ?? null; + this.#kernelPtrWidth = testRuntime.pointerWidth ?? 4; + this.#testEngine = testRuntime.engine; + if ( + testRuntime.initialized + ?? ( + testRuntime.instance !== undefined + || testRuntime.memory !== undefined + ) + ) { + this.#initializationState = "initialized"; + } + } + intrinsicApply( + intrinsicWeakMapSet, + wasmPosixKernelRuntimeAccess, + [ + this, + { + gate: this.#kernelEntryGate, + instance: () => this.#instance, + memory: () => this.#memory, + }, + ], + ); // Let the GBM bo registry reach per-pid wasm Memory so the // bind/unbind sync (parent writes → SAB → child reads after PRIME // export+import) actually moves bytes. The closure follows @@ -526,19 +985,126 @@ export class WasmPosixKernel { this.bos.setProcessMemoryResolver((pid) => this.callbacks.getProcessMemory?.(pid), ); + if (arguments[3] === wasmPosixKernelTestCapability) { + intrinsicObjectDefineProperty(this, "testAuthority", { + configurable: false, + enumerable: false, + writable: false, + value: this.#createTestAuthority(), + }); + } + } + + /** + * Expose only the six white-box operations used by focused tests. + * + * WHY: a Proxy would retain the kernel object as its mutation target and + * bind every otherwise-unhandled method to that target. The frozen + * method-only companion exposes no raw Instance, Memory, gate, scratch + * region, getter, or arbitrary property dispatch. Its six retained closures + * are intentional test-generation authority and exist only on an object + * produced with the module-secret constructor capability. + */ + #createTestAuthority(): WasmPosixKernelTestAuthority { + const authority = intrinsicObjectCreate( + null, + ) as WasmPosixKernelTestAuthority; + const defineMethod = ( + name: keyof WasmPosixKernelTestAuthority, + value: Function, + ): void => { + intrinsicObjectDefineProperty(authority, name, { + configurable: false, + enumerable: true, + writable: false, + value, + }); + }; + defineMethod( + "buildImportObject", + (memory: WebAssembly.Memory) => this.#buildImportObject(memory), + ); + defineMethod( + "writeKernelBytes", + ( + pointer: KernelPointer, + capacity: number | bigint, + bytes: Uint8Array, + ) => this.#writeKernelBytes( + this.#rustLentKernelDestination( + pointer, + capacity, + "test kernel destination", + ), + bytes, + ), + ); + defineMethod( + "hostFstat", + (handle: bigint, statPointer: KernelPointer) => { + try { + return this.#hostFstat( + handle, + this.#rustLentKernelDestination( + statPointer, + WASM_STAT_SIZE, + "test host_fstat destination", + ), + ); + } catch { + return -14; // EFAULT + } + }, + ); + defineMethod( + "hostReaddir", + ( + handle: bigint, + direntPointer: KernelPointer, + namePointer: KernelPointer, + nameLength: number, + ) => { + try { + return this.#hostReaddir( + handle, + this.#rustLentKernelDestination( + direntPointer, + WASM_DIRENT_SIZE, + "test host_readdir dirent destination", + ), + this.#rustLentKernelDestination( + namePointer, + nameLength, + "test host_readdir name destination", + ), + ); + } catch { + return -14; // EFAULT + } + }, + ); + defineMethod( + "hostClosedir", + (handle: bigint) => this.#hostClosedir(handle), + ); + defineMethod( + "hostClose", + (handle: bigint) => this.#hostClose(handle), + ); + return intrinsicObjectFreeze(authority); } getKernelPtrWidth(): 4 | 8 { - return this.kernelPtrWidth; + return this.#kernelPtrWidth; } toKernelPtr(value: number | bigint): KernelPointer { const numberValue = checkedWasmPointer( value, - this.kernelPtrWidth, + this.#kernelPtrWidth, "kernel export pointer", ); - return this.kernelPtrWidth === 8 ? BigInt(numberValue) : numberValue; + return this.#kernelPtrWidth === 8 ? BigInt(numberValue) : numberValue; } /** @@ -549,18 +1115,18 @@ export class WasmPosixKernel { * operators silently discard every bit above bit 31. Device metadata is not * scratch, but it still must not alias a different process-memory range. */ - private checkedKernelIndex(value: KernelPointer, field: string): number { - return checkedWasmPointer(value, this.kernelPtrWidth, field); + #checkedKernelIndex(value: KernelPointer, field: string): number { + return checkedWasmPointer(value, this.#kernelPtrWidth, field); } - private checkedKernelSpan( + #checkedKernelSpan( offsetValue: KernelPointer, lengthValue: KernelPointer, limit: number, field: string, ): { offset: number; length: number; end: number } { - const offset = this.checkedKernelIndex(offsetValue, `${field} offset`); - const length = this.checkedKernelIndex(lengthValue, `${field} length`); + const offset = this.#checkedKernelIndex(offsetValue, `${field} offset`); + const length = this.#checkedKernelIndex(lengthValue, `${field} length`); if (!Number.isSafeInteger(limit) || limit < 0) { throw new KernelScratchError(`${field} has an invalid capacity`); } @@ -638,16 +1204,16 @@ export class WasmPosixKernel { } } - private createKernelMemory(pointerWidth: 4 | 8): WebAssembly.Memory { + #createKernelMemory(pointerWidth: 4 | 8): WebAssembly.Memory { if (pointerWidth === 8) { - return new WebAssembly.Memory({ + return new IntrinsicWasmMemory({ initial: 24n, maximum: 16384n, shared: true, address: "i64", } as unknown as WebAssembly.MemoryDescriptor); } - return new WebAssembly.Memory({ + return new IntrinsicWasmMemory({ // 24 pages = 1.5 MiB of initial address space. This must remain above // the kernel Wasm's linker-derived minimum and leaves headroom for // future static data without re-tuning host construction each time. @@ -665,11 +1231,21 @@ export class WasmPosixKernel { * caller must invert browser deltaY before calling. */ injectMouseEvent(dx: number, dy: number, buttons: number): void { - const inject = this.instance?.exports?.kernel_inject_mouse_event as - | ((dx: number, dy: number, buttons: number) => void) - | undefined; - if (!inject) return; - inject(dx, dy, buttons); + this.#kernelEntryGate.runOrDeferVoidIngress( + "mouse input", + (scope) => { + if (!this.#instance) return; + const scoped = createKernelEntryScopedInstance( + this.#instance, + scope, + ); + const inject = scoped.exports.kernel_inject_mouse_event as + | ((dx: number, dy: number, buttons: number) => void) + | undefined; + if (!inject) return; + inject(dx, dy, buttons); + }, + ); } // --------------------------------------------------------------------------- @@ -682,49 +1258,49 @@ export class WasmPosixKernel { * memory in processes that never play sound. ~64 KiB is comfortably * larger than any single drain call would ask for. */ - private audioScratchRegion: KernelScratchRegion | null = null; + #audioScratchRegion: KernelScratchRegion | null = null; private static readonly AUDIO_SCRATCH_SIZE = 65536; - private apiScratchRegion: KernelScratchRegion | null = null; + #apiScratchRegion: KernelScratchRegion | null = null; private static readonly API_SCRATCH_SIZE = 65536; - private requireApiScratch(): KernelScratchRegion { - if (this.apiScratchRegion) return this.apiScratchRegion; - if (!this.memory) { + #requireApiScratch(): KernelScratchRegion { + if (this.#apiScratchRegion) return this.#apiScratchRegion; + if (!this.#memory) { throw new Error("kernel memory is not initialized"); } - const allocator = this.instance?.exports.kernel_alloc_scratch as + const allocator = this.#instance?.exports.kernel_alloc_scratch as | ((size: number) => KernelPointer) | undefined; if (!allocator) { throw new Error("kernel is missing its scratch allocator"); } - this.apiScratchRegion = allocateKernelScratchRegion( - this.memory, + this.#apiScratchRegion = allocateKernelScratchRegion( + this.#memory, allocator, WasmPosixKernel.API_SCRATCH_SIZE, - this.kernelPtrWidth, + this.#kernelPtrWidth, "kernel public API scratch", - this.instance!, + this.#instance!, ); - return this.apiScratchRegion; + return this.#apiScratchRegion; } - private ensureAudioScratch(): boolean { - if (this.audioScratchRegion) return true; - if (!this.memory) return false; - const exports = this.instance?.exports as Record | undefined; + #ensureAudioScratch(): boolean { + if (this.#audioScratchRegion) return true; + if (!this.#memory) return false; + const exports = this.#instance?.exports as Record | undefined; const alloc = exports?.kernel_alloc_scratch as | ((size: number) => bigint | number) | undefined; if (!alloc) return false; try { - this.audioScratchRegion = allocateKernelScratchRegion( - this.memory, + this.#audioScratchRegion = allocateKernelScratchRegion( + this.#memory, alloc, WasmPosixKernel.AUDIO_SCRATCH_SIZE, - this.kernelPtrWidth, + this.#kernelPtrWidth, "kernel audio scratch", - this.instance!, + this.#instance!, ); return true; } catch { @@ -743,17 +1319,17 @@ export class WasmPosixKernel { * have to special-case any of those. */ drainAudio(out: Uint8Array): number { - const exports = this.instance?.exports as Record | undefined; + const exports = this.#instance?.exports as Record | undefined; if ( typeof exports?.kernel_drain_audio !== "function" - || !this.memory - || !this.ensureAudioScratch() + || !this.#memory + || !this.#ensureAudioScratch() ) return 0; // Cap the request at our scratch size. Typical drain rates // (~22 ms of stereo S16 @ 44.1 kHz = ~7.7 KiB per call) are well // under the cap; callers needing more invoke drainAudio in a loop. - const region = this.audioScratchRegion!; - const want = Math.min(out.byteLength, region.capacity); + const region = this.#audioScratchRegion!; + const want = Math.min(typedArrayByteLength(out), region.capacity); return region.withLease((scratch) => { const n = scratch.invokeKernelExport("kernel_drain_audio", [ scratch.exportPointer(0, want), @@ -770,7 +1346,7 @@ export class WasmPosixKernel { * isn't instantiated yet. */ audioSampleRate(): number { - const exports = this.instance?.exports as Record | undefined; + const exports = this.#instance?.exports as Record | undefined; const fn = exports?.kernel_audio_sample_rate as (() => number) | undefined; return fn ? fn() : 0; } @@ -780,7 +1356,7 @@ export class WasmPosixKernel { * 0 if the kernel isn't instantiated yet. */ audioChannels(): number { - const exports = this.instance?.exports as Record | undefined; + const exports = this.#instance?.exports as Record | undefined; const fn = exports?.kernel_audio_channels as (() => number) | undefined; return fn ? fn() : 0; } @@ -790,7 +1366,7 @@ export class WasmPosixKernel { * estimate how much audio is queued ahead of the AudioContext clock. */ audioPending(): number { - const exports = this.instance?.exports as Record | undefined; + const exports = this.#instance?.exports as Record | undefined; const fn = exports?.kernel_audio_pending as (() => number) | undefined; return fn ? fn() : 0; } @@ -822,60 +1398,51 @@ export class WasmPosixKernel { * @param wasmBytes - The compiled kernel Wasm binary */ async init(wasmBytes: BufferSource): Promise { - this.beginInitialization(); - try { - const { module, pointerWidth } = - await this.compileKernelModule(wasmBytes); - this.kernelPtrWidth = pointerWidth; - const memory = this.createKernelMemory(pointerWidth); - this.memory = memory; - const importObject = this.buildImportObject(memory); - this.instance = await WebAssembly.instantiate(module, importObject); - this.initializationState = "initialized"; - } catch (error) { - this.abortInitialization(error); - } - } - - /** - * Like init(), but uses an existing shared WebAssembly.Memory instead of - * creating a new one. Used by thread workers that share the parent's memory. - * - * A WasmPosixKernel owns one kernel generation, so this and init() are - * mutually exclusive one-shot entry points. - */ - async initWithMemory( - wasmBytes: BufferSource, - memory: WebAssembly.Memory, - ): Promise { - this.beginInitialization(); + this.#beginInitialization(); try { const { module, pointerWidth } = - await this.compileKernelModule(wasmBytes); - this.kernelPtrWidth = pointerWidth; - this.memory = memory; - const importObject = this.buildImportObject(memory); - this.instance = await WebAssembly.instantiate(module, importObject); - this.initializationState = "initialized"; + await this.#compileKernelModule(wasmBytes); + this.#kernelPtrWidth = pointerWidth; + const memory = this.#createKernelMemory(pointerWidth); + this.#memoryGeneration = intrinsicObjectFreeze({}); + this.#memory = memory; + const importObject = this.#buildImportObject(memory); + const rawInstance = + this.#testEngine === undefined + ? await intrinsicApply( + intrinsicWasmInstantiate, + WebAssembly, + [module, importObject], + ) as WebAssembly.Instance + : await this.#testEngine.instantiate(module, importObject); + this.#kernelFuncTable = ( + wasmInstanceExports(rawInstance) + .__indirect_function_table as WebAssembly.Table | undefined + ) ?? null; + this.#instance = createKernelEntryGatedInstance( + rawInstance, + this.#kernelEntryGate, + ); + this.#initializationState = "initialized"; } catch (error) { - this.abortInitialization(error); + this.#abortInitialization(error); } } - private beginInitialization(): void { - if (this.initializationState === "initializing") { + #beginInitialization(): void { + if (this.#initializationState === "initializing") { throw new Error("kernel initialization is already in progress"); } - if (this.initializationState === "initialized") { + if (this.#initializationState === "initialized") { throw new Error( "kernel is already initialized; create a new WasmPosixKernel " + "for a different kernel generation", ); } - this.initializationState = "initializing"; + this.#initializationState = "initializing"; } - private async compileKernelModule( + async #compileKernelModule( wasmBytes: BufferSource, ): Promise<{ module: WebAssembly.Module; pointerWidth: 4 | 8 }> { // WHY: view subclasses can spoof public span getters. Pointer-width @@ -883,145 +1450,342 @@ export class WasmPosixKernel { // snapshot or imports can normalize every pointer for the wrong Wasm ABI. const wasmSnapshot = bufferSourceToArrayBuffer(wasmBytes); const pointerWidth = detectPtrWidth(wasmSnapshot); - const module = await WebAssembly.compile(wasmSnapshot); + const module = this.#testEngine === undefined + ? await intrinsicApply( + intrinsicWasmCompile, + WebAssembly, + [wasmSnapshot], + ) as WebAssembly.Module + : await this.#testEngine.compile(wasmSnapshot); return { module, pointerWidth }; } - private abortInitialization(error: unknown): never { + #abortInitialization(error: unknown): never { // A failed first attempt has created no usable kernel generation. Clear // the partially published import state so callers may retry cleanly. - this.instance = null; - this.memory = null; - this.kernelPtrWidth = 4; - this.initializationState = "uninitialized"; + this.#instance = null; + this.#kernelFuncTable = null; + this.#memoryGeneration = intrinsicObjectFreeze({}); + this.#memory = null; + this.#kernelPtrWidth = 4; + this.#initializationState = "uninitialized"; throw error; } - private buildImportObject(memory: WebAssembly.Memory): WebAssembly.Imports { + #buildImportObject(memory: WebAssembly.Memory): WebAssembly.Imports { return { env: { memory, host_debug_log: (ptr: KernelPointer, len: number): void => { const msg = new TextDecoder().decode( - this.readKernelBytes(ptr, len), + this.#readKernelBytes(ptr, len), ); console.log(`[KERNEL] ${msg}`); }, host_open: (pathPtr: KernelPointer, pathLen: number, flags: number, mode: number): bigint => { - return this.hostOpen(pathPtr, pathLen, flags, mode); + return this.#hostOpen(pathPtr, pathLen, flags, mode); }, host_close: (handle: bigint): number => { - return this.hostClose(handle); + return this.#hostClose(handle); }, host_read: (handle: bigint, bufPtr: KernelPointer, bufLen: number): number => { - return this.hostRead(handle, bufPtr, bufLen); + try { + return this.#hostRead( + handle, + this.#rustLentKernelDestination( + bufPtr, + bufLen, + "host_read destination", + ), + ); + } catch { + return -14; // EFAULT + } }, host_write: (handle: bigint, bufPtr: KernelPointer, bufLen: number): number => { - return this.hostWrite(handle, bufPtr, bufLen); + return this.#hostWrite(handle, bufPtr, bufLen); + }, + host_append: ( + handle: bigint, + bufPtr: KernelPointer, + bufLen: number, + limitLo: number, + limitHi: number, + ): number => { + return this.#hostAppend( + handle, + bufPtr, + bufLen, + limitLo, + limitHi, + ); + }, + host_append_position: ( + handle: bigint, + written: number, + ): bigint => { + return this.#hostAppendPosition(handle, written); + }, + host_pread: ( + handle: bigint, + bufPtr: KernelPointer, + bufLen: number, + offsetLo: number, + offsetHi: number, + ): number => { + try { + return this.#hostPread( + handle, + this.#rustLentKernelDestination( + bufPtr, + bufLen, + "host_pread destination", + ), + offsetLo, + offsetHi, + ); + } catch { + return -14; // EFAULT + } + }, + host_pwrite: ( + handle: bigint, + bufPtr: KernelPointer, + bufLen: number, + offsetLo: number, + offsetHi: number, + ): number => { + return this.#hostPwrite( + handle, + bufPtr, + bufLen, + offsetLo, + offsetHi, + ); }, host_seek: (handle: bigint, offsetLo: number, offsetHi: number, whence: number): bigint => { - return this.hostSeek(handle, offsetLo, offsetHi, whence); + return this.#hostSeek(handle, offsetLo, offsetHi, whence); }, host_fstat: (handle: bigint, statPtr: KernelPointer): number => { - return this.hostFstat(handle, statPtr); + try { + return this.#hostFstat( + handle, + this.#rustLentKernelDestination( + statPtr, + WASM_STAT_SIZE, + "host_fstat destination", + ), + ); + } catch { + return -14; // EFAULT + } }, host_stat: (pathPtr: KernelPointer, pathLen: number, statPtr: KernelPointer): number => { - return this.hostStat(pathPtr, pathLen, statPtr); + try { + return this.#hostStat( + pathPtr, + pathLen, + this.#rustLentKernelDestination( + statPtr, + WASM_STAT_SIZE, + "host_stat destination", + ), + ); + } catch { + return -14; // EFAULT + } }, host_lstat: (pathPtr: KernelPointer, pathLen: number, statPtr: KernelPointer): number => { - return this.hostLstat(pathPtr, pathLen, statPtr); + try { + return this.#hostLstat( + pathPtr, + pathLen, + this.#rustLentKernelDestination( + statPtr, + WASM_STAT_SIZE, + "host_lstat destination", + ), + ); + } catch { + return -14; // EFAULT + } }, host_statfs: (pathPtr: KernelPointer, pathLen: number, statfsPtr: KernelPointer): number => { - return this.hostStatfs(pathPtr, pathLen, statfsPtr); + try { + return this.#hostStatfs( + pathPtr, + pathLen, + this.#rustLentKernelDestination( + statfsPtr, + WASM_STATFS_SIZE, + "host_statfs destination", + ), + ); + } catch { + return -14; // EFAULT + } }, host_pathconf: (pathPtr: KernelPointer, pathLen: number, name: number, valuePtr: KernelPointer): number => { - return this.hostPathconf(pathPtr, pathLen, name, valuePtr); + try { + return this.#hostPathconf( + pathPtr, + pathLen, + name, + this.#rustLentKernelDestination( + valuePtr, + 8, + "host_pathconf destination", + ), + ); + } catch { + return -14; // EFAULT + } }, host_fpathconf: (handle: bigint, name: number, valuePtr: KernelPointer): number => { - return this.hostFpathconf(handle, name, valuePtr); + try { + return this.#hostFpathconf( + handle, + name, + this.#rustLentKernelDestination( + valuePtr, + 8, + "host_fpathconf destination", + ), + ); + } catch { + return -14; // EFAULT + } }, host_mkdir: (pathPtr: KernelPointer, pathLen: number, mode: number): number => { - return this.hostMkdir(pathPtr, pathLen, mode); + return this.#hostMkdir(pathPtr, pathLen, mode); }, host_rmdir: (pathPtr: KernelPointer, pathLen: number): number => { - return this.hostRmdir(pathPtr, pathLen); + return this.#hostRmdir(pathPtr, pathLen); }, host_unlink: (pathPtr: KernelPointer, pathLen: number): number => { - return this.hostUnlink(pathPtr, pathLen); + return this.#hostUnlink(pathPtr, pathLen); }, host_rename: (oldPtr: KernelPointer, oldLen: number, newPtr: KernelPointer, newLen: number): number => { - return this.hostRename(oldPtr, oldLen, newPtr, newLen); + return this.#hostRename(oldPtr, oldLen, newPtr, newLen); }, host_link: (oldPtr: KernelPointer, oldLen: number, newPtr: KernelPointer, newLen: number): number => { - return this.hostLink(oldPtr, oldLen, newPtr, newLen); + return this.#hostLink(oldPtr, oldLen, newPtr, newLen); }, host_symlink: (targetPtr: KernelPointer, targetLen: number, linkPtr: KernelPointer, linkLen: number): number => { - return this.hostSymlink(targetPtr, targetLen, linkPtr, linkLen); + return this.#hostSymlink(targetPtr, targetLen, linkPtr, linkLen); }, host_readlink: (pathPtr: KernelPointer, pathLen: number, bufPtr: KernelPointer, bufLen: number): number => { - return this.hostReadlink(pathPtr, pathLen, bufPtr, bufLen); + try { + return this.#hostReadlink( + pathPtr, + pathLen, + this.#rustLentKernelDestination( + bufPtr, + bufLen, + "host_readlink destination", + ), + ); + } catch { + return -14; // EFAULT + } }, host_chmod: (pathPtr: KernelPointer, pathLen: number, mode: number): number => { - return this.hostChmod(pathPtr, pathLen, mode); + return this.#hostChmod(pathPtr, pathLen, mode); }, host_chown: (pathPtr: KernelPointer, pathLen: number, uid: number, gid: number): number => { - return this.hostChown(pathPtr, pathLen, uid, gid); + return this.#hostChown(pathPtr, pathLen, uid, gid); }, host_lchown: (pathPtr: KernelPointer, pathLen: number, uid: number, gid: number): number => { - return this.hostLchown(pathPtr, pathLen, uid, gid); + return this.#hostLchown(pathPtr, pathLen, uid, gid); }, host_access: (pathPtr: KernelPointer, pathLen: number, amode: number): number => { - return this.hostAccess(pathPtr, pathLen, amode); + return this.#hostAccess(pathPtr, pathLen, amode); }, host_opendir: (pathPtr: KernelPointer, pathLen: number): bigint => { - return this.hostOpendir(pathPtr, pathLen); + return this.#hostOpendir(pathPtr, pathLen); }, host_readdir: (dirHandle: bigint, direntPtr: KernelPointer, namePtr: KernelPointer, nameLen: number): number => { - return this.hostReaddir(dirHandle, direntPtr, namePtr, nameLen); + try { + return this.#hostReaddir( + dirHandle, + this.#rustLentKernelDestination( + direntPtr, + WASM_DIRENT_SIZE, + "host_readdir dirent destination", + ), + this.#rustLentKernelDestination( + namePtr, + nameLen, + "host_readdir name destination", + ), + ); + } catch { + return -14; // EFAULT + } }, host_closedir: (dirHandle: bigint): number => { - return this.hostClosedir(dirHandle); + return this.#hostClosedir(dirHandle); }, host_clock_gettime: (clockId: number, secPtr: KernelPointer, nsecPtr: KernelPointer): number => { - return this.hostClockGettime(clockId, secPtr, nsecPtr); + try { + return this.#hostClockGettime( + clockId, + this.#rustLentKernelDestination( + secPtr, + 8, + "host_clock_gettime seconds destination", + ), + this.#rustLentKernelDestination( + nsecPtr, + 8, + "host_clock_gettime nanoseconds destination", + ), + ); + } catch { + return -14; // EFAULT + } }, host_nanosleep: (sec: bigint, nsec: bigint): number => { - return this.hostNanosleep(sec, nsec); + return this.#hostNanosleep(sec, nsec); }, host_ftruncate: (handle: bigint, length: bigint): number => { - return this.hostFtruncate(handle, length); + return this.#hostFtruncate(handle, length); }, host_fsync: (handle: bigint): number => { - return this.hostFsync(handle); + return this.#hostFsync(handle); }, host_fchmod: (handle: bigint, mode: number): number => { - return this.hostFchmod(handle, mode); + return this.#hostFchmod(handle, mode); }, host_fchown: (handle: bigint, uid: number, gid: number): number => { - return this.hostFchown(handle, uid, gid); + return this.#hostFchown(handle, uid, gid); }, host_exec: (pathPtr: KernelPointer, pathLen: number): number => { - return this.hostExec(pathPtr, pathLen); + return this.#hostExec(pathPtr, pathLen); }, host_set_alarm: (seconds: number): number => { - return this.hostSetAlarm(seconds); + return this.#hostSetAlarm(seconds); }, host_set_posix_timer: (timerId: number, signo: number, valueMsLo: number, valueMsHi: number, intervalMsLo: number, intervalMsHi: number): number => { const valueMs = (valueMsHi >>> 0) * 0x100000000 + (valueMsLo >>> 0); const intervalMs = (intervalMsHi >>> 0) * 0x100000000 + (intervalMsLo >>> 0); - return this.hostSetPosixTimer(timerId, signo, valueMs, intervalMs); + return this.#hostSetPosixTimer(timerId, signo, valueMs, intervalMs); }, host_sigsuspend_wait: (): number => { - return this.hostSigsuspendWait(); + return this.#hostSigsuspendWait(); }, host_call_signal_handler: (handler_index: number, signum: number, sa_flags: number): number => { const SA_SIGINFO = 4; const table = this.programFuncTable - ?? (this.instance?.exports.__indirect_function_table as WebAssembly.Table | undefined); + ?? this.#kernelFuncTable; if (!table) { return -22; // EINVAL } - const handler = table.get(handler_index); + const handler = intrinsicApply( + intrinsicWasmTableGet, + table, + [handler_index], + ); if (handler) { try { if (sa_flags & SA_SIGINFO) { @@ -1040,26 +1804,24 @@ export class WasmPosixKernel { }, host_getrandom: (bufPtr: KernelPointer, bufLen: number): number => { try { - const destination = checkedWasmImportMemoryRange( - memory, + const destination = this.#rustLentKernelDestination( bufPtr, bufLen, - this.kernelPtrWidth, "host_getrandom destination", ); - const random = new Uint8Array(destination.length); + const random = new IntrinsicUint8Array(destination.capacity); if (typeof globalThis.crypto !== "undefined" && globalThis.crypto.getRandomValues) { // crypto.getRandomValues rejects SharedArrayBuffer-backed views // in browsers. The owned temporary also ensures no host callback // retains a live view of kernel memory. globalThis.crypto.getRandomValues(random); } else { - for (let i = 0; i < bufLen; i++) { + for (let i = 0; i < destination.capacity; i++) { random[i] = (Math.random() * 256) | 0; } } - this.writeKernelBytes(bufPtr, bufLen, random); - return bufLen; + this.#writeKernelBytes(destination, random); + return destination.capacity; } catch (error) { return negErrno(error); } @@ -1068,57 +1830,100 @@ export class WasmPosixKernel { pathPtr: KernelPointer, pathLen: number, atimeSec: bigint, atimeNsec: bigint, mtimeSec: bigint, mtimeNsec: bigint, ): number => { - return this.hostUtimensat(pathPtr, pathLen, atimeSec, atimeNsec, mtimeSec, mtimeNsec); + return this.#hostUtimensat(pathPtr, pathLen, atimeSec, atimeNsec, mtimeSec, mtimeNsec); }, host_waitpid: (pid: number, options: number, statusPtr: KernelPointer): number => { - return this.hostWaitpid(pid, options, statusPtr); + const hasStatus = typeof statusPtr === "bigint" + ? statusPtr !== 0n + : statusPtr !== 0; + try { + return this.#hostWaitpid( + pid, + options, + hasStatus + ? this.#rustLentKernelDestination( + statusPtr, + 4, + "host_waitpid status destination", + ) + : null, + ); + } catch { + return -14; // EFAULT + } }, host_net_connect: (handle: number, addrPtr: KernelPointer, addrLen: number, port: number): number => { - return this.hostNetConnect(handle, addrPtr, addrLen, port); + return this.#hostNetConnect(handle, addrPtr, addrLen, port); }, host_net_send: (handle: number, bufPtr: KernelPointer, bufLen: number, flags: number): number => { - return this.hostNetSend(handle, bufPtr, bufLen, flags); + return this.#hostNetSend(handle, bufPtr, bufLen, flags); }, host_net_recv: (handle: number, bufPtr: KernelPointer, bufLen: number, flags: number): number => { - return this.hostNetRecv(handle, bufPtr, bufLen, flags); + if (!this.io.network) return -107; // -ENOTCONN + try { + return this.#hostNetRecv( + handle, + this.#rustLentKernelDestination( + bufPtr, + bufLen, + "host_net_recv destination", + ), + flags, + ); + } catch { + return -14; // EFAULT + } }, host_net_poll: (handle: number, events: number): number => { - return this.hostNetPoll(handle, events); + return this.#hostNetPoll(handle, events); }, host_net_connect_status: (handle: number): number => { - return this.hostNetConnectStatus(handle); + return this.#hostNetConnectStatus(handle); }, host_net_close: (handle: number): number => { - return this.hostNetClose(handle); + return this.#hostNetClose(handle); }, host_net_listen: (fd: number, port: number, addrA: number, addrB: number, addrC: number, addrD: number): number => { - return this.hostNetListen(fd, port, addrA, addrB, addrC, addrD); + return this.#hostNetListen(fd, port, addrA, addrB, addrC, addrD); }, host_udp_bind: (handle: number, addrA: number, addrB: number, addrC: number, addrD: number, port: number): number => { - return this.hostUdpBind(handle, addrA, addrB, addrC, addrD, port); + return this.#hostUdpBind(handle, addrA, addrB, addrC, addrD, port); }, host_udp_unbind: (handle: number): number => { - return this.hostUdpUnbind(handle); + return this.#hostUdpUnbind(handle); }, host_udp_send: ( srcA: number, srcB: number, srcC: number, srcD: number, srcPort: number, dstA: number, dstB: number, dstC: number, dstD: number, dstPort: number, dataPtr: KernelPointer, dataLen: number, ): number => { - return this.hostUdpSend( + return this.#hostUdpSend( srcA, srcB, srcC, srcD, srcPort, dstA, dstB, dstC, dstD, dstPort, dataPtr, dataLen, ); }, host_getaddrinfo: (namePtr: KernelPointer, nameLen: number, resultPtr: KernelPointer, resultLen: number): number => { - return this.hostGetaddrinfo(namePtr, nameLen, resultPtr, resultLen); + if (!this.io.network) return -2; // -ENOENT + try { + return this.#hostGetaddrinfo( + namePtr, + nameLen, + this.#rustLentKernelDestination( + resultPtr, + resultLen, + "host_getaddrinfo destination", + ), + ); + } catch { + return -14; // EFAULT + } }, host_futex_wait: (addr: KernelPointer, expected: number, timeoutLo: number, timeoutHi: number): number => { - return this.hostFutexWait(addr, expected, timeoutLo, timeoutHi); + return this.#hostFutexWait(addr, expected, timeoutLo, timeoutHi); }, host_futex_wake: (addr: KernelPointer, count: number): number => { - return this.hostFutexWake(addr, count); + return this.#hostFutexWake(addr, count); }, host_is_thread_worker: (): number => { return this.isThreadWorker ? 1 : 0; @@ -1131,7 +1936,7 @@ export class WasmPosixKernel { pid: number, addr: KernelPointer, len: KernelPointer, w: number, h: number, stride: number, fmt: number, ): void => { - const binding = this.checkedKernelSpan( + const binding = this.#checkedKernelSpan( addr, len, Number.MAX_SAFE_INTEGER, @@ -1160,8 +1965,8 @@ export class WasmPosixKernel { ): void => { this.framebuffers.fbWrite( pid, - this.checkedKernelIndex(offset, "host_fb_write offset"), - this.readKernelBytes(srcPtr, len), + this.#checkedKernelIndex(offset, "host_fb_write offset"), + this.#readKernelBytes(srcPtr, len), ); }, // /dev/dri/renderD128 hooks. v1 CpuShared tier: pixel storage @@ -1177,7 +1982,7 @@ export class WasmPosixKernel { ): number => { let checkedSize: number; try { - checkedSize = this.checkedKernelIndex( + checkedSize = this.#checkedKernelIndex( size, "host_gbm_bo_create size", ); @@ -1205,7 +2010,7 @@ export class WasmPosixKernel { // so current process-memory bounds are not yet meaningful. The // Rust BO owns this mapping contract; the registry caps later // copies to the BO's size and rechecks current memory bounds. - const binding = this.checkedKernelSpan( + const binding = this.#checkedKernelSpan( addr, len, Number.MAX_SAFE_INTEGER, @@ -1236,7 +2041,7 @@ export class WasmPosixKernel { // submit/query become silent no-ops, so kernels that haven't // wired a renderer (Node tests, headless smoke runs) stay safe. host_gl_bind: (pid: number, addr: KernelPointer, len: KernelPointer): void => { - const binding = this.checkedKernelSpan( + const binding = this.#checkedKernelSpan( addr, len, Number.MAX_SAFE_INTEGER, @@ -1343,7 +2148,7 @@ export class WasmPosixKernel { if (!b.forward && !b.gl) return 0; let submission: { offset: number; length: number; end: number }; try { - submission = this.checkedKernelSpan( + submission = this.#checkedKernelSpan( offset, length, b.cmdbufLen, @@ -1356,8 +2161,8 @@ export class WasmPosixKernel { const memory = this.callbacks.getProcessMemory?.(pid); if (!memory) return -5; // EIO try { - b.cmdbufView = new Uint8Array( - memory.buffer, + b.cmdbufView = new IntrinsicUint8Array( + wasmMemoryBuffer(memory), b.cmdbufAddr, b.cmdbufLen, ); @@ -1382,12 +2187,16 @@ export class WasmPosixKernel { ); if (rc < 0) return rc; b.forward.onSubmit( - b.cmdbufView.slice(submission.offset, submission.end), + sliceUint8Array( + b.cmdbufView, + submission.offset, + submission.end, + ), ); return 0; } this.gl_submit_queue.enqueue(b, { - memorySab: b.cmdbufView.buffer as ArrayBufferLike, + memorySab: typedArrayBuffer(b.cmdbufView), off: submission.offset, len: submission.length, }); @@ -1418,24 +2227,22 @@ export class WasmPosixKernel { if (!b || !b.gl) return -1; let inputLength: number; let outputLength: number; + let outputDestination: RustLentKernelDestination | null = null; try { - inputLength = this.checkedKernelIndex( + inputLength = this.#checkedKernelIndex( inLen, "host_gl_query input length", ); - outputLength = this.checkedKernelIndex( + outputLength = this.#checkedKernelIndex( outLen, "host_gl_query output length", ); if (outputLength > 0) { - if (!this.memory) return -5; // Preflight before touching WebGL state. A bad Rust destination // must not execute a query and only then discover EFAULT. - checkedWasmImportMemoryRange( - this.memory, + outputDestination = this.#rustLentKernelDestination( outPtr, - outputLength, - this.kernelPtrWidth, + outLen, "host_gl_query destination", ); } @@ -1445,21 +2252,21 @@ export class WasmPosixKernel { let inBuf: Uint8Array; try { inBuf = inputLength > 0 - ? this.readKernelBytes(inPtr, inputLength) - : new Uint8Array(0); + ? this.#readKernelBytes(inPtr, inputLength) + : new IntrinsicUint8Array(0); } catch { return -14; // EFAULT } - const outBuf = new Uint8Array(outputLength); + const outBuf = new IntrinsicUint8Array(outputLength); const written = runGlQuery(b, op, inBuf, outBuf); if (!Number.isSafeInteger(written)) return -5; if (written < 0) return written; if (written > outputLength) return -5; if (written > 0) { - this.writeKernelBytes( - outPtr, - outputLength, - outBuf.subarray(0, written), + if (outputDestination === null) return -5; + this.#writeKernelBytes( + outputDestination, + subarrayUint8Array(outBuf, 0, written), ); } return written; @@ -1482,7 +2289,7 @@ export class WasmPosixKernel { 4, "host_proc_write_bytes process destination", ); - const src = this.readKernelBytes(src_ptr, len); + const src = this.#readKernelBytes(src_ptr, len); // Reacquire the process buffer after copying the kernel source: // another process worker may have grown it in the meantime. const destination = checkedWasmImportMemoryRange( @@ -1492,7 +2299,11 @@ export class WasmPosixKernel { 4, "host_proc_write_bytes process destination", ); - new Uint8Array(procMem.buffer).set(src, destination.pointer); + intrinsicApply( + intrinsicUint8ArraySet, + new IntrinsicUint8Array(wasmMemoryBuffer(procMem)), + [src, destination.pointer], + ); return 0; } catch { return -14; @@ -1504,19 +2315,16 @@ export class WasmPosixKernel { dst_ptr: KernelPointer, len: number, ): number => { - const procMem = this.callbacks.getProcessMemory?.(pid); - if (!procMem) return -14; try { - if (!this.memory) return -5; // Prove the Rust-owned destination before reading caller bytes so // an invalid kernel range cannot consume a source operation. - checkedWasmImportMemoryRange( - this.memory, + const destination = this.#rustLentKernelDestination( dst_ptr, len, - this.kernelPtrWidth, - "host_proc_read_bytes kernel destination", + "host_proc_read_bytes destination", ); + const procMem = this.callbacks.getProcessMemory?.(pid); + if (!procMem) return -14; const source = checkedWasmImportMemoryRange( procMem, addr, @@ -1524,12 +2332,13 @@ export class WasmPosixKernel { 4, "host_proc_read_bytes process source", ); - const copy = new Uint8Array( - procMem.buffer, + const processView = new IntrinsicUint8Array( + wasmMemoryBuffer(procMem), source.pointer, source.length, - ).slice(); - this.writeKernelBytes(dst_ptr, len, copy); + ); + const copy = sliceUint8Array(processView); + this.#writeKernelBytes(destination, copy); return 0; } catch { return -14; @@ -1539,13 +2348,14 @@ export class WasmPosixKernel { connector_id: number, out_ptr: KernelPointer, ): void => { - const canvas = this.callbacks.getKmsCanvas?.(connector_id); - const bytes = kmsModeInfoBytes(canvas?.width, canvas?.height); - this.writeKernelBytes( + const destination = this.#rustLentKernelDestination( out_ptr, STRUCT_SIZE_WPK_DRM_MODE_MODEINFO, - bytes, + "host_kms_mode_info destination", ); + const canvas = this.callbacks.getKmsCanvas?.(connector_id); + const bytes = kmsModeInfoBytes(canvas?.width, canvas?.height); + this.#writeKernelBytes(destination, bytes); }, host_kms_addfb: ( _pid: number, @@ -1567,82 +2377,140 @@ export class WasmPosixKernel { }; } - /** - * UNSAFE trusted-embedder escape hatch for tests and low-level diagnostics. - * - * Direct mutation bypasses KernelScratchRegion ownership, capacity, and - * lifetime checks. Runtime transfer code must use the typed public methods - * instead of pairing this memory with allocator exports. - */ - getMemory(): WebAssembly.Memory | null { - return this.memory; - } - - /** - * UNSAFE trusted-embedder escape hatch for tests and low-level diagnostics. - * - * Calling pointer-returning exports and writing through getMemory() is - * outside the checked scratch-transfer contract. - */ - getInstance(): WebAssembly.Instance | null { - return this.instance; + /** Current kernel-memory size without exposing its mutable backing buffer. */ + getMemoryPageCount(): number | null { + return this.#memory === null + ? null + : bufferByteLength(wasmMemoryBuffer(this.#memory)) / 65536; } // ---- Host import implementations ---- - private getMemoryBuffer(): Uint8Array { - if (!this.memory) { + #getMemoryBuffer(): Uint8Array { + if (!this.#memory) { throw new Error("Kernel not initialized"); } - return new Uint8Array(this.memory.buffer); + return new IntrinsicUint8Array(wasmMemoryBuffer(this.#memory)); } /** Copy `len` bytes from kernel memory at `ptr` into a non-shared * Uint8Array. Used by host imports that consume kernel-scratch * payloads (e.g. host_fb_write). */ - private readKernelBytes( + #readKernelBytes( ptr: KernelPointer, len: number | bigint, ): Uint8Array { - if (!this.memory) throw new Error("Kernel not initialized"); + if (!this.#memory) throw new Error("Kernel not initialized"); const range = checkedWasmImportMemoryRange( - this.memory, + this.#memory, ptr, len, - this.kernelPtrWidth, + this.#kernelPtrWidth, "kernel import source", ); - return this.getMemoryBuffer().slice(range.pointer, range.end); + return sliceUint8Array( + this.#getMemoryBuffer(), + range.pointer, + range.end, + ); } - /** Write `bytes` into kernel memory at `ptr`. Used by host imports - * that return kernel-scratch payloads (e.g. host_gl_query, - * host_kms_mode_info, host_proc_read_bytes). + /** + * Bind one Rust-lent pointer/capacity pair to this exact Memory generation. + * + * WHY: fitting in the current WebAssembly Memory proves only addressability, + * not ownership. The Rust import arguments name the allocation and its + * capacity; keeping both in an authenticated token prevents a later caller + * from substituting total Memory length for the allocation bound. */ - private writeKernelBytes( + #rustLentKernelDestination( ptr: KernelPointer, capacity: number | bigint, - bytes: Uint8Array, - ): void { - if (!this.memory) throw new Error("Kernel not initialized"); + label: string, + ): RustLentKernelDestination { + if (!this.#memory) throw new Error("Kernel not initialized"); const range = checkedWasmImportMemoryRange( - this.memory, + this.#memory, ptr, capacity, - this.kernelPtrWidth, - "kernel import destination", + this.#kernelPtrWidth, + label, + ); + const destination = intrinsicObjectFreeze({ + capacity: range.length, + }) as RustLentKernelDestination; + intrinsicApply( + intrinsicWeakMapSet, + rustLentKernelDestinationRecords, + [ + destination, + { + owner: this, + generation: this.#memoryGeneration, + memory: this.#memory, + pointer: range.pointer, + capacity: range.length, + label, + consumed: false, + }, + ], + ); + return destination; + } + + /** + * Publish bytes once through an authenticated Rust-lent destination. + * + * No live view survives this synchronous method. The current Memory range is + * checked again because `memory.grow()` may replace its backing buffer after + * an asynchronous backend operation staged the bytes. + */ + #writeKernelBytes( + destination: RustLentKernelDestination, + bytes: Uint8Array, + ): void { + const record = intrinsicApply( + intrinsicWeakMapGet, + rustLentKernelDestinationRecords, + [destination], + ) as RustLentKernelDestinationRecord | undefined; + if ( + record === undefined + || record.owner !== this + || record.generation !== this.#memoryGeneration + || record.memory !== this.#memory + || record.consumed + ) { + throw new Error("invalid or stale Rust-lent kernel destination"); + } + if (!this.#memory) throw new Error("Kernel not initialized"); + const range = checkedMemoryRange( + this.#memory, + record.pointer, + record.capacity, + this.#kernelPtrWidth, + record.label, ); const exactBytes = intrinsicUint8ArrayView( bytes, - "kernel import output", + `${record.label} output`, ); - if (exactBytes.byteLength > range.length) { + const exactLength = typedArrayByteLength(exactBytes); + if (exactLength > range.length) { throw new Error( - `kernel import output ${exactBytes.byteLength} exceeds capacity ${range.length}`, + `${record.label} output ${exactLength} exceeds capacity ${range.length}`, ); } - this.getMemoryBuffer().set(exactBytes, range.pointer); + // WHY: one destination represents one Rust borrow. Consuming before the + // intrinsic copy prevents nested/retried host code from partially replacing + // bytes that a completed import may already expose to the kernel. + record.consumed = true; + intrinsicApply( + intrinsicUint8ArraySet, + this.#getMemoryBuffer(), + [exactBytes, range.pointer], + ); } /** @@ -1654,14 +2522,14 @@ export class WasmPosixKernel { * blocks on the promise. In practice, NodePlatformIO uses sync fs * operations internally, so the promise resolves immediately. */ - private hostOpen( + #hostOpen( pathPtr: KernelPointer, pathLen: number, flags: number, mode: number, ): bigint { try { - const pathBytes = this.readKernelBytes(pathPtr, pathLen); + const pathBytes = this.#readKernelBytes(pathPtr, pathLen); const path = new TextDecoder().decode(pathBytes); return BigInt(this.io.open(path, flags, mode)); } catch (e) { @@ -1672,8 +2540,8 @@ export class WasmPosixKernel { /** * host_close(handle: i64) -> i32 */ - private hostClose(handle: bigint): number { - const h = Number(handle); + #hostClose(handle: bigint): number { + const h = intrinsicNumber(handle); // Check shared pipe registry const entry = this.sharedPipes.get(h); @@ -1715,30 +2583,52 @@ export class WasmPosixKernel { * For handle 0 (stdin): return 0 (no stdin support yet). * Other handles: delegate to PlatformIO. */ - private hostRead( + #hostRead( handle: bigint, - bufPtr: KernelPointer, - bufLen: number, + destination: RustLentKernelDestination, + ): number { + return this.#hostReadAt(handle, destination, null); + } + + #hostPread( + handle: bigint, + destination: RustLentKernelDestination, + offsetLo: number, + offsetHi: number, + ): number { + return this.#hostReadAt( + handle, + destination, + signedI64FromWords(offsetLo, offsetHi), + ); + } + + /** + * Stage one scalar read outside kernel memory, then publish its validated + * prefix once. A non-null offset is a true positioned operation and must + * bypass stream-only stdin/shared-pipe behavior. + */ + #hostReadAt( + handle: bigint, + destination: RustLentKernelDestination, + offset: HostFileOffset | null, ): number { const h = Number(handle); - let destinationCapacity: number; - try { - if (!this.memory) return -5; - destinationCapacity = checkedWasmImportMemoryRange( - this.memory, - bufPtr, - bufLen, - this.kernelPtrWidth, - "host_read destination", - ).length; - } catch { - return -14; // EFAULT - } + const destinationCapacity = destination.capacity; // WHY: never lend a live view of Rust-owned memory to PlatformIO. A // backend can accidentally retain that view or reenter the kernel. Stage // into host memory, validate the producer count, then publish once through - // the pointer-plus-capacity helper. - const staged = new Uint8Array(destinationCapacity); + // the generation-bound pointer-plus-capacity token. + let staged: Uint8Array; + try { + staged = new IntrinsicUint8Array(destinationCapacity); + } catch { + // WHY: one-operation transfers can legitimately exceed the ordinary + // channel size. Expected host allocation failure is ENOMEM, not a + // JavaScript exception allowed to trap Wasm while Rust owns an + // Executing reservation that can no longer be recovered. + return -12; // ENOMEM + } const publish = (result: number): number => { if ( !Number.isSafeInteger(result) @@ -1749,10 +2639,9 @@ export class WasmPosixKernel { } if (result > 0) { try { - this.writeKernelBytes( - bufPtr, - destinationCapacity, - staged.subarray(0, result), + this.#writeKernelBytes( + destination, + subarrayUint8Array(staged, 0, result), ); } catch { return -14; @@ -1761,42 +2650,49 @@ export class WasmPosixKernel { return result; }; - // Check shared pipe registry - const readEntry = this.sharedPipes.get(h); - if (readEntry) { - return publish(readEntry.pipe.read(staged)); - } + if (offset === null) { + // Check shared pipe registry + const readEntry = this.sharedPipes.get(h); + if (readEntry) { + return publish(readEntry.pipe.read(staged)); + } - // stdin - if (h === 0) { - if (this.callbacks.onStdin) { - const data = this.callbacks.onStdin(bufLen); - if (data === null) return 0; // EOF - let exactData: Uint8Array; - try { - exactData = intrinsicUint8ArrayView(data, "stdin callback output"); - } catch { - return -5; // EIO: the callback violated its byte-source contract. - } - if (exactData.byteLength === 0) { - return -11; // EAGAIN — no data yet, retry later + // stdin + if (h === 0) { + if (this.callbacks.onStdin) { + const data = this.callbacks.onStdin(destinationCapacity); + if (data === null) return 0; // EOF + let exactData: Uint8Array; + try { + exactData = intrinsicUint8ArrayView(data, "stdin callback output"); + } catch { + return -5; // EIO: the callback violated its byte-source contract. + } + const exactLength = typedArrayByteLength(exactData); + if (exactLength === 0) { + return -11; // EAGAIN — no data yet, retry later + } + const n = Math.min(exactLength, destinationCapacity); + intrinsicApply( + intrinsicUint8ArraySet, + staged, + [ + new IntrinsicUint8Array( + typedArrayBuffer(exactData), + typedArrayByteOffset(exactData), + n, + ), + ], + ); + return publish(n); } - const n = Math.min(exactData.byteLength, destinationCapacity); - staged.set( - new Uint8Array( - exactData.buffer, - exactData.byteOffset, - n, - ), - ); - return publish(n); + return 0; // EOF when no stdin callback } - return 0; // EOF when no stdin callback } try { return publish( - this.io.read(h, staged, null, destinationCapacity), + this.io.read(h, staged, offset, destinationCapacity), ); } catch (e) { return negErrno(e); @@ -1810,52 +2706,191 @@ export class WasmPosixKernel { * falls back to process.stdout/stderr (Node.js), then console (browser). * Other handles: delegate to PlatformIO. */ - private hostWrite( + #hostWrite( handle: bigint, bufPtr: KernelPointer, bufLen: number, ): number { - const h = Number(handle); + return this.#hostWriteAt(handle, bufPtr, bufLen, null); + } + + #hostPwrite( + handle: bigint, + bufPtr: KernelPointer, + bufLen: number, + offsetLo: number, + offsetHi: number, + ): number { + return this.#hostWriteAt( + handle, + bufPtr, + bufLen, + signedI64FromWords(offsetLo, offsetHi), + ); + } + + /** + * Append to a regular backing in one backend operation. Rust owns the live + * O_APPEND bit and chooses this import per write; no persistent host flag is + * changed when F_SETFL toggles. + */ + #appendOutcomeLatch: { + readonly handle: bigint; + readonly written: number; + readonly end: bigint; + } | null = null; + + #hostAppend( + handle: bigint, + bufPtr: KernelPointer, + bufLen: number, + limitLo: number, + limitHi: number, + ): number { + // WHY: a failed append attempt must invalidate any abandoned result from + // an earlier call before Rust can ask for a position. + this.#appendOutcomeLatch = null; + const h = intrinsicNumber(handle); let data: Uint8Array; try { - data = this.readKernelBytes(bufPtr, bufLen); + data = this.#readKernelBytes(bufPtr, bufLen); } catch (error) { return negErrno(error); } - // Check shared pipe registry - const writeEntry = this.sharedPipes.get(h); - if (writeEntry) { - return writeEntry.pipe.write(data); + const encodedLimit = signedI64FromWords(limitLo, limitHi); + if (encodedLimit < -1n) { + throw new Error("kernel append limit is not -1 or a file position"); } + const limit: HostFileOffset | null = encodedLimit === -1n + ? null + : encodedLimit <= intrinsicBigInt(INTRINSIC_NUMBER_MAX_SAFE_INTEGER) + ? intrinsicNumber(encodedLimit) + : encodedLimit; - // stdout / stderr — callback → process → console fallback chain - if (h === 1) { - if (this.callbacks.onStdout) { - this.callbacks.onStdout(data); - } else if (typeof process !== "undefined" && process.stdout) { - process.stdout.write(data); - } else { - console.log(new TextDecoder().decode(data)); + let outcome: AppendOutcome; + try { + outcome = this.io.append( + h, + data, + typedArrayByteLength(data), + limit, + ); + } catch (error) { + if (isHostAppendContractError(error)) throw error; + return negErrno(error); + } + + // Backend contract violations are not ordinary I/O failures: the backing + // may already have mutated. Throwing through the active Wasm export lets + // the kernel entry gate poison this generation instead of returning EIO + // and continuing with an unknowable cursor. + const written = outcome.written; + if ( + !intrinsicNumberIsSafeInteger(written) + || written < 0 + || written > typedArrayByteLength(data) + ) { + throw new Error("backend returned an invalid append byte count"); + } + const checkedEnd = checkedHostFileOffset(outcome.end); + const end = intrinsicBigInt(checkedEnd); + if (end < intrinsicBigInt(written)) { + throw new Error("backend returned an append end before its written bytes"); + } + if (encodedLimit >= 0n) { + const start = end - intrinsicBigInt(written); + if (start >= encodedLimit) { + if (written !== 0) { + throw new Error( + "backend mutated an append that began at its size limit", + ); + } + } else if (end > encodedLimit) { + throw new Error("backend append exceeded its exclusive size limit"); } - return bufLen; } - if (h === 2) { - if (this.callbacks.onStderr) { - this.callbacks.onStderr(data); - } else if (typeof process !== "undefined" && process.stderr) { - process.stderr.write(data); - } else { - console.error(new TextDecoder().decode(data)); + + this.#appendOutcomeLatch = { handle, written, end }; + return written; + } + + /** + * Consume the exact end paired with the immediately preceding append. + * + * A scalar one-shot latch avoids adding another host-to-kernel memory write + * while still binding the result to the handle and byte count Rust saw. + */ + #hostAppendPosition(handle: bigint, written: number): bigint { + const outcome = this.#appendOutcomeLatch; + this.#appendOutcomeLatch = null; + if ( + outcome === null + || outcome.handle !== handle + || !intrinsicNumberIsSafeInteger(written) + || written < 0 + || outcome.written !== written + ) { + throw new Error("missing or mismatched append outcome"); + } + return outcome.end; + } + + /** + * Read one allocation-proved source and issue one scalar write. Positioned + * writes bypass stream-only stdout/shared-pipe behavior and preserve the + * open file description's current offset. + */ + #hostWriteAt( + handle: bigint, + bufPtr: KernelPointer, + bufLen: number, + offset: HostFileOffset | null, + ): number { + const h = Number(handle); + let data: Uint8Array; + try { + data = this.#readKernelBytes(bufPtr, bufLen); + } catch (error) { + return negErrno(error); + } + + if (offset === null) { + // Check shared pipe registry + const writeEntry = this.sharedPipes.get(h); + if (writeEntry) { + return writeEntry.pipe.write(data); + } + + // stdout / stderr — callback → process → console fallback chain + if (h === 1) { + if (this.callbacks.onStdout) { + this.callbacks.onStdout(data); + } else if (typeof process !== "undefined" && process.stdout) { + process.stdout.write(data); + } else { + console.log(new TextDecoder().decode(data)); + } + return bufLen; + } + if (h === 2) { + if (this.callbacks.onStderr) { + this.callbacks.onStderr(data); + } else if (typeof process !== "undefined" && process.stderr) { + process.stderr.write(data); + } else { + console.error(new TextDecoder().decode(data)); + } + return bufLen; } - return bufLen; } try { - const written = this.io.write(h, data, null, data.byteLength); + const dataLength = typedArrayByteLength(data); + const written = this.io.write(h, data, offset, dataLength); return Number.isSafeInteger(written) && written >= 0 - && written <= data.byteLength + && written <= dataLength ? written : -5; } catch (e) { @@ -1868,19 +2903,23 @@ export class WasmPosixKernel { * * Combines the low and high 32-bit parts into a 64-bit offset. */ - private hostSeek( + #hostSeek( handle: bigint, offsetLo: number, offsetHi: number, whence: number, ): bigint { const h = Number(handle); - // Reconstruct 64-bit signed offset from two 32-bit parts. - // JS bitwise operators are 32-bit, so we use multiplication for the high word. - const offset = offsetHi * 0x100000000 + (offsetLo >>> 0); + const offset = signedI64FromWords(offsetLo, offsetHi); try { - return BigInt(this.io.seek(h, offset, whence)); + const result = checkedHostFileOffset(this.io.seek(h, offset, whence)); + const exactResult = BigInt(result); + // WHY: negative i64 values on this import encode errno for Rust. A + // backend seek result is a file position, so accepting one would let a + // malformed backend forge an errno (and i64::MIN cannot be negated in + // Rust). Collapse the broken backend contract to EIO. + return exactResult < 0n ? -5n : exactResult; } catch (e) { return BigInt(negErrno(e)); } @@ -1909,12 +2948,15 @@ export class WasmPosixKernel { * 80: st_ctime_nsec u32 * 84: _pad u32 */ - private hostFstat(handle: bigint, statPtr: KernelPointer): number { + #hostFstat( + handle: bigint, + destination: RustLentKernelDestination, + ): number { const h = Number(handle); try { const stat = this.io.fstat(h); - this.writeStatToMemory(statPtr, stat); + this.#writeStatToMemory(destination, stat); if (this.fstatHandleCapture) this.fstatHandleCapture.handle = h; return 0; } catch (e) { @@ -1925,45 +2967,48 @@ export class WasmPosixKernel { /** * Write a StatResult into the WasmStat struct at the given Wasm memory offset. */ - private writeStatToMemory(ptr: KernelPointer, stat: StatResult): void { + #writeStatToMemory( + destination: RustLentKernelDestination, + stat: StatResult, + ): void { // Build the complete structure in host-owned memory, then publish it only // after the pointer and Rust-declared fixed capacity have both passed. - const bytes = new Uint8Array(WASM_STAT_SIZE); - const dv = new DataView(bytes.buffer); + const bytes = new IntrinsicUint8Array(WASM_STAT_SIZE); + const dv = new IntrinsicDataView(typedArrayBuffer(bytes)); - dv.setBigUint64(0, exactU64(stat.dev, "st_dev"), true); // st_dev - dv.setBigUint64(8, exactU64(stat.ino, "st_ino"), true); // st_ino - dv.setUint32(16, stat.mode, true); // st_mode - dv.setUint32(20, stat.nlink, true); // st_nlink - dv.setUint32(24, stat.uid, true); // st_uid - dv.setUint32(28, stat.gid, true); // st_gid - dv.setBigUint64(32, BigInt(stat.size), true); // st_size + dataViewSetBigUint64(dv, 0, exactU64(stat.dev, "st_dev"), true); + dataViewSetBigUint64(dv, 8, exactU64(stat.ino, "st_ino"), true); + dataViewSetUint32(dv, 16, stat.mode, true); + dataViewSetUint32(dv, 20, stat.nlink, true); + dataViewSetUint32(dv, 24, stat.uid, true); + dataViewSetUint32(dv, 28, stat.gid, true); + dataViewSetBigUint64(dv, 32, BigInt(stat.size), true); // Convert millisecond timestamps to seconds + nanoseconds. const atimeSec = Math.floor(stat.atimeMs / 1000); const atimeNsec = Math.floor((stat.atimeMs % 1000) * 1_000_000); - dv.setBigUint64(40, BigInt(atimeSec), true); // st_atime_sec - dv.setUint32(48, atimeNsec, true); // st_atime_nsec + dataViewSetBigUint64(dv, 40, BigInt(atimeSec), true); + dataViewSetUint32(dv, 48, atimeNsec, true); const mtimeSec = Math.floor(stat.mtimeMs / 1000); const mtimeNsec = Math.floor((stat.mtimeMs % 1000) * 1_000_000); - dv.setBigUint64(56, BigInt(mtimeSec), true); // st_mtime_sec - dv.setUint32(64, mtimeNsec, true); // st_mtime_nsec + dataViewSetBigUint64(dv, 56, BigInt(mtimeSec), true); + dataViewSetUint32(dv, 64, mtimeNsec, true); const ctimeSec = Math.floor(stat.ctimeMs / 1000); const ctimeNsec = Math.floor((stat.ctimeMs % 1000) * 1_000_000); - dv.setBigUint64(72, BigInt(ctimeSec), true); // st_ctime_sec - dv.setUint32(80, ctimeNsec, true); // st_ctime_nsec + dataViewSetBigUint64(dv, 72, BigInt(ctimeSec), true); + dataViewSetUint32(dv, 80, ctimeNsec, true); // _pad at offset 84 already zeroed - this.writeKernelBytes(ptr, WASM_STAT_SIZE, bytes); + this.#writeKernelBytes(destination, bytes); } - private writeStatfsToMemory( - ptr: KernelPointer, + #writeStatfsToMemory( + destination: RustLentKernelDestination, statfs: StatfsResult, ): void { - const bytes = new Uint8Array(WASM_STATFS_SIZE); - const dv = new DataView(bytes.buffer); + const bytes = new IntrinsicUint8Array(WASM_STATFS_SIZE); + const dv = new IntrinsicDataView(typedArrayBuffer(bytes)); const u32 = (value: number): number => { if (!Number.isFinite(value)) return 0; @@ -1974,18 +3019,18 @@ export class WasmPosixKernel { return BigInt(Math.min(Math.floor(value), Number.MAX_SAFE_INTEGER)); }; - dv.setUint32(0, u32(statfs.type), true); - dv.setUint32(4, u32(statfs.bsize), true); - dv.setBigUint64(8, u64(statfs.blocks), true); - dv.setBigUint64(16, u64(statfs.bfree), true); - dv.setBigUint64(24, u64(statfs.bavail), true); - dv.setBigUint64(32, u64(statfs.files), true); - dv.setBigUint64(40, u64(statfs.ffree), true); - dv.setBigUint64(48, u64(statfs.fsid), true); - dv.setUint32(56, u32(statfs.namelen), true); - dv.setUint32(60, u32(statfs.frsize), true); - dv.setUint32(64, u32(statfs.flags), true); - this.writeKernelBytes(ptr, WASM_STATFS_SIZE, bytes); + dataViewSetUint32(dv, 0, u32(statfs.type), true); + dataViewSetUint32(dv, 4, u32(statfs.bsize), true); + dataViewSetBigUint64(dv, 8, u64(statfs.blocks), true); + dataViewSetBigUint64(dv, 16, u64(statfs.bfree), true); + dataViewSetBigUint64(dv, 24, u64(statfs.bavail), true); + dataViewSetBigUint64(dv, 32, u64(statfs.files), true); + dataViewSetBigUint64(dv, 40, u64(statfs.ffree), true); + dataViewSetBigUint64(dv, 48, u64(statfs.fsid), true); + dataViewSetUint32(dv, 56, u32(statfs.namelen), true); + dataViewSetUint32(dv, 60, u32(statfs.frsize), true); + dataViewSetUint32(dv, 64, u32(statfs.flags), true); + this.#writeKernelBytes(destination, bytes); } // ---- Phase 2: Path-based and directory host imports ---- @@ -1993,23 +3038,23 @@ export class WasmPosixKernel { /** * Read a UTF-8 path string from Wasm memory. */ - private readPathFromMemory(ptr: KernelPointer, len: number): string { - const pathBytes = this.readKernelBytes(ptr, len); + #readPathFromMemory(ptr: KernelPointer, len: number): string { + const pathBytes = this.#readKernelBytes(ptr, len); return new TextDecoder().decode(pathBytes); } /** * host_stat(path_ptr, path_len, stat_ptr) -> i32 */ - private hostStat( + #hostStat( pathPtr: KernelPointer, pathLen: number, - statPtr: KernelPointer, + destination: RustLentKernelDestination, ): number { try { - const path = this.readPathFromMemory(pathPtr, pathLen); + const path = this.#readPathFromMemory(pathPtr, pathLen); const stat = this.io.stat(path); - this.writeStatToMemory(statPtr, stat); + this.#writeStatToMemory(destination, stat); return 0; } catch (e) { return negErrno(e); @@ -2019,64 +3064,74 @@ export class WasmPosixKernel { /** * host_lstat(path_ptr, path_len, stat_ptr) -> i32 */ - private hostLstat( + #hostLstat( pathPtr: KernelPointer, pathLen: number, - statPtr: KernelPointer, + destination: RustLentKernelDestination, ): number { try { - const path = this.readPathFromMemory(pathPtr, pathLen); + const path = this.#readPathFromMemory(pathPtr, pathLen); const stat = this.io.lstat(path); - this.writeStatToMemory(statPtr, stat); + this.#writeStatToMemory(destination, stat); return 0; } catch (e) { return negErrno(e); } } - private hostStatfs( + #hostStatfs( pathPtr: KernelPointer, pathLen: number, - statfsPtr: KernelPointer, + destination: RustLentKernelDestination, ): number { try { - const path = this.readPathFromMemory(pathPtr, pathLen); + const path = this.#readPathFromMemory(pathPtr, pathLen); const statfs = this.io.statfs(path); - this.writeStatfsToMemory(statfsPtr, statfs); + this.#writeStatfsToMemory(destination, statfs); return 0; } catch (e) { return negErrno(e); } } - private hostPathconf( + #hostPathconf( pathPtr: KernelPointer, pathLen: number, name: number, - valuePtr: KernelPointer, + destination: RustLentKernelDestination, ): number { try { - const path = this.readPathFromMemory(pathPtr, pathLen); + const path = this.#readPathFromMemory(pathPtr, pathLen); const value = this.io.pathconf(path, name); - const bytes = new Uint8Array(8); - new DataView(bytes.buffer).setBigInt64(0, BigInt(value ?? -1), true); - this.writeKernelBytes(valuePtr, bytes.byteLength, bytes); + const bytes = new IntrinsicUint8Array(8); + dataViewSetBigInt64( + new IntrinsicDataView(typedArrayBuffer(bytes)), + 0, + BigInt(value ?? -1), + true, + ); + this.#writeKernelBytes(destination, bytes); return 0; } catch (e) { return negErrno(e); } } - private hostFpathconf( + #hostFpathconf( handle: bigint, name: number, - valuePtr: KernelPointer, + destination: RustLentKernelDestination, ): number { try { const value = this.io.fpathconf(Number(handle), name); - const bytes = new Uint8Array(8); - new DataView(bytes.buffer).setBigInt64(0, BigInt(value ?? -1), true); - this.writeKernelBytes(valuePtr, bytes.byteLength, bytes); + const bytes = new IntrinsicUint8Array(8); + dataViewSetBigInt64( + new IntrinsicDataView(typedArrayBuffer(bytes)), + 0, + BigInt(value ?? -1), + true, + ); + this.#writeKernelBytes(destination, bytes); return 0; } catch (e) { return negErrno(e); @@ -2086,13 +3141,13 @@ export class WasmPosixKernel { /** * host_mkdir(path_ptr, path_len, mode) -> i32 */ - private hostMkdir( + #hostMkdir( pathPtr: KernelPointer, pathLen: number, mode: number, ): number { try { - const path = this.readPathFromMemory(pathPtr, pathLen); + const path = this.#readPathFromMemory(pathPtr, pathLen); this.io.mkdir(path, mode); return 0; } catch (e) { @@ -2103,9 +3158,9 @@ export class WasmPosixKernel { /** * host_rmdir(path_ptr, path_len) -> i32 */ - private hostRmdir(pathPtr: KernelPointer, pathLen: number): number { + #hostRmdir(pathPtr: KernelPointer, pathLen: number): number { try { - const path = this.readPathFromMemory(pathPtr, pathLen); + const path = this.#readPathFromMemory(pathPtr, pathLen); this.io.rmdir(path); return 0; } catch (e) { @@ -2116,9 +3171,9 @@ export class WasmPosixKernel { /** * host_unlink(path_ptr, path_len) -> i32 */ - private hostUnlink(pathPtr: KernelPointer, pathLen: number): number { + #hostUnlink(pathPtr: KernelPointer, pathLen: number): number { try { - const path = this.readPathFromMemory(pathPtr, pathLen); + const path = this.#readPathFromMemory(pathPtr, pathLen); this.io.unlink(path); return 0; } catch (e) { @@ -2129,15 +3184,15 @@ export class WasmPosixKernel { /** * host_rename(old_ptr, old_len, new_ptr, new_len) -> i32 */ - private hostRename( + #hostRename( oldPtr: KernelPointer, oldLen: number, newPtr: KernelPointer, newLen: number, ): number { try { - const oldPath = this.readPathFromMemory(oldPtr, oldLen); - const newPath = this.readPathFromMemory(newPtr, newLen); + const oldPath = this.#readPathFromMemory(oldPtr, oldLen); + const newPath = this.#readPathFromMemory(newPtr, newLen); this.io.rename(oldPath, newPath); return 0; } catch (e) { @@ -2148,15 +3203,15 @@ export class WasmPosixKernel { /** * host_link(old_ptr, old_len, new_ptr, new_len) -> i32 */ - private hostLink( + #hostLink( oldPtr: KernelPointer, oldLen: number, newPtr: KernelPointer, newLen: number, ): number { try { - const existingPath = this.readPathFromMemory(oldPtr, oldLen); - const newPath = this.readPathFromMemory(newPtr, newLen); + const existingPath = this.#readPathFromMemory(oldPtr, oldLen); + const newPath = this.#readPathFromMemory(newPtr, newLen); this.io.link(existingPath, newPath); return 0; } catch (e) { @@ -2167,15 +3222,15 @@ export class WasmPosixKernel { /** * host_symlink(target_ptr, target_len, link_ptr, link_len) -> i32 */ - private hostSymlink( + #hostSymlink( targetPtr: KernelPointer, targetLen: number, linkPtr: KernelPointer, linkLen: number, ): number { try { - const target = this.readPathFromMemory(targetPtr, targetLen); - const linkPath = this.readPathFromMemory(linkPtr, linkLen); + const target = this.#readPathFromMemory(targetPtr, targetLen); + const linkPath = this.#readPathFromMemory(linkPtr, linkLen); this.io.symlink(target, linkPath); return 0; } catch (e) { @@ -2188,26 +3243,20 @@ export class WasmPosixKernel { * * Returns the number of bytes written to the buffer, or -1 on error. */ - private hostReadlink( + #hostReadlink( pathPtr: KernelPointer, pathLen: number, - bufPtr: KernelPointer, - bufLen: number, + destination: RustLentKernelDestination, ): number { try { - const path = this.readPathFromMemory(pathPtr, pathLen); - if (!this.memory) return -5; - checkedWasmImportMemoryRange( - this.memory, - bufPtr, - bufLen, - this.kernelPtrWidth, - "host_readlink destination", - ); + const path = this.#readPathFromMemory(pathPtr, pathLen); const target = this.io.readlink(path); const encoded = new TextEncoder().encode(target); - const n = Math.min(encoded.length, bufLen); - this.writeKernelBytes(bufPtr, bufLen, encoded.subarray(0, n)); + const n = Math.min(encoded.length, destination.capacity); + this.#writeKernelBytes( + destination, + subarrayUint8Array(encoded, 0, n), + ); return n; } catch (e) { return negErrno(e); @@ -2217,13 +3266,13 @@ export class WasmPosixKernel { /** * host_chmod(path_ptr, path_len, mode) -> i32 */ - private hostChmod( + #hostChmod( pathPtr: KernelPointer, pathLen: number, mode: number, ): number { try { - const path = this.readPathFromMemory(pathPtr, pathLen); + const path = this.#readPathFromMemory(pathPtr, pathLen); this.io.chmod(path, mode); return 0; } catch (e) { @@ -2234,14 +3283,14 @@ export class WasmPosixKernel { /** * host_chown(path_ptr, path_len, uid, gid) -> i32 */ - private hostChown( + #hostChown( pathPtr: KernelPointer, pathLen: number, uid: number, gid: number, ): number { try { - const path = this.readPathFromMemory(pathPtr, pathLen); + const path = this.#readPathFromMemory(pathPtr, pathLen); this.io.chown(path, uid, gid); return 0; } catch (e) { @@ -2252,14 +3301,14 @@ export class WasmPosixKernel { /** * host_lchown(path_ptr, path_len, uid, gid) -> i32 */ - private hostLchown( + #hostLchown( pathPtr: KernelPointer, pathLen: number, uid: number, gid: number, ): number { try { - const path = this.readPathFromMemory(pathPtr, pathLen); + const path = this.#readPathFromMemory(pathPtr, pathLen); this.io.lchown(path, uid, gid); return 0; } catch (e) { @@ -2270,13 +3319,13 @@ export class WasmPosixKernel { /** * host_access(path_ptr, path_len, amode) -> i32 */ - private hostAccess( + #hostAccess( pathPtr: KernelPointer, pathLen: number, amode: number, ): number { try { - const path = this.readPathFromMemory(pathPtr, pathLen); + const path = this.#readPathFromMemory(pathPtr, pathLen); this.io.access(path, amode); return 0; } catch (e) { @@ -2287,7 +3336,7 @@ export class WasmPosixKernel { /** * host_utimensat(path_ptr, path_len, atime_sec, atime_nsec, mtime_sec, mtime_nsec) -> i32 */ - private hostUtimensat( + #hostUtimensat( pathPtr: KernelPointer, pathLen: number, atimeSec: bigint, @@ -2296,7 +3345,7 @@ export class WasmPosixKernel { mtimeNsec: bigint, ): number { try { - const path = this.readPathFromMemory(pathPtr, pathLen); + const path = this.#readPathFromMemory(pathPtr, pathLen); this.io.utimensat(path, Number(atimeSec), Number(atimeNsec), Number(mtimeSec), Number(mtimeNsec)); return 0; } catch { @@ -2309,54 +3358,53 @@ export class WasmPosixKernel { * Returns child pid on success, negative errno on error. * Writes wait status to status_ptr. */ - private hostWaitpid( + #hostWaitpid( pid: number, options: number, - statusPtr: KernelPointer, + statusDestination: RustLentKernelDestination | null, ): number { - const hasStatus = typeof statusPtr === "bigint" - ? statusPtr !== 0n - : statusPtr !== 0; - if (hasStatus) { - try { - if (!this.memory) return -5; - // Validate before either wait backend can consume a child state. The - // final write repeats this proof against the then-current buffer. - checkedWasmImportMemoryRange( - this.memory, - statusPtr, - 4, - this.kernelPtrWidth, - "host_waitpid status destination", - ); - } catch { - return -14; // EFAULT - } - } + // The import boundary mints this token before either wait backend can + // consume child state. Publication rechecks the current Memory generation. // If we have a waitpid callback + SAB, use blocking host delegation if (this.waitpidSab && this.callbacks.onWaitpid) { - const view = new Int32Array(this.waitpidSab); - Atomics.store(view, 0, 0); // flag = waiting - Atomics.store(view, 1, 0); // result pid - Atomics.store(view, 2, 0); // status + const view = new IntrinsicInt32Array(this.waitpidSab); + intrinsicApply(intrinsicAtomicsStore, Atomics, [view, 0, 0]); + intrinsicApply(intrinsicAtomicsStore, Atomics, [view, 1, 0]); + intrinsicApply(intrinsicAtomicsStore, Atomics, [view, 2, 0]); this.callbacks.onWaitpid(pid, options); // Block until host signals completion - Atomics.wait(view, 0, 0); - - const resultPid = Atomics.load(view, 1); - const resultStatus = Atomics.load(view, 2); + intrinsicApply(intrinsicAtomicsWait, Atomics, [view, 0, 0]); + + const resultPid = intrinsicApply( + intrinsicAtomicsLoad, + Atomics, + [view, 1], + ) as number; + const resultStatus = intrinsicApply( + intrinsicAtomicsLoad, + Atomics, + [view, 2], + ) as number; if (resultPid < 0) { return resultPid; // negative errno } - if (hasStatus) { - const bytes = new Uint8Array(4); - new DataView(bytes.buffer).setInt32(0, resultStatus, true); + if (statusDestination !== null) { + const bytes = new IntrinsicUint8Array(4); + dataViewSetInt32( + new IntrinsicDataView(typedArrayBuffer(bytes)), + 0, + resultStatus, + true, + ); try { - this.writeKernelBytes(statusPtr, bytes.byteLength, bytes); + this.#writeKernelBytes( + statusDestination, + bytes, + ); } catch { return -14; // EFAULT } @@ -2374,11 +3422,19 @@ export class WasmPosixKernel { } catch { return -10; // -ECHILD } - if (hasStatus) { - const bytes = new Uint8Array(4); - new DataView(bytes.buffer).setInt32(0, result.status, true); + if (statusDestination !== null) { + const bytes = new IntrinsicUint8Array(4); + dataViewSetInt32( + new IntrinsicDataView(typedArrayBuffer(bytes)), + 0, + result.status, + true, + ); try { - this.writeKernelBytes(statusPtr, bytes.byteLength, bytes); + this.#writeKernelBytes( + statusDestination, + bytes, + ); } catch { return -14; // EFAULT } @@ -2391,9 +3447,9 @@ export class WasmPosixKernel { * * Returns a directory handle as i64, or -1 on error. */ - private hostOpendir(pathPtr: KernelPointer, pathLen: number): bigint { + #hostOpendir(pathPtr: KernelPointer, pathLen: number): bigint { try { - const path = this.readPathFromMemory(pathPtr, pathLen); + const path = this.#readPathFromMemory(pathPtr, pathLen); const handle = this.io.opendir(path); // Backends may reuse numeric handles after close. Never let an entry // staged for an older iterator leak into the new one. @@ -2410,11 +3466,10 @@ export class WasmPosixKernel { * Writes a WasmDirent struct and the entry name to Wasm memory. * Returns 1 if an entry was written, 0 at end-of-directory, -1 on error. */ - private hostReaddir( + #hostReaddir( dirHandle: bigint, - direntPtr: KernelPointer, - namePtr: KernelPointer, - nameLen: number, + direntDestination: RustLentKernelDestination, + nameDestination: RustLentKernelDestination, ): number { try { const h = Number(dirHandle); @@ -2428,32 +3483,20 @@ export class WasmPosixKernel { // Write WasmDirent: d_ino(u64) + d_type(u32) + d_namlen(u32) const encoded = new TextEncoder().encode(dirEntry.name); - const n = Math.min(encoded.length, nameLen); - if (!this.memory) throw new Error("Kernel not initialized"); - // Preflight both destinations before publishing either half of the - // aggregate record. A retry must never observe a new dirent paired with - // stale name bytes. - checkedWasmImportMemoryRange( - this.memory, - direntPtr, - WASM_DIRENT_SIZE, - this.kernelPtrWidth, - "host_readdir dirent destination", + const n = Math.min(encoded.length, nameDestination.capacity); + const dirent = new IntrinsicUint8Array(WASM_DIRENT_SIZE); + const view = new IntrinsicDataView(typedArrayBuffer(dirent)); + dataViewSetBigUint64(view, 0, BigInt(dirEntry.ino), true); + dataViewSetUint32(view, 8, dirEntry.type, true); + dataViewSetUint32(view, 12, n, true); + this.#writeKernelBytes( + direntDestination, + dirent, ); - checkedWasmImportMemoryRange( - this.memory, - namePtr, - nameLen, - this.kernelPtrWidth, - "host_readdir name destination", + this.#writeKernelBytes( + nameDestination, + subarrayUint8Array(encoded, 0, n), ); - const dirent = new Uint8Array(WASM_DIRENT_SIZE); - const view = new DataView(dirent.buffer); - view.setBigUint64(0, BigInt(dirEntry.ino), true); - view.setUint32(8, dirEntry.type, true); - view.setUint32(12, n, true); - this.writeKernelBytes(direntPtr, WASM_DIRENT_SIZE, dirent); - this.writeKernelBytes(namePtr, nameLen, encoded.subarray(0, n)); this.pendingDirectoryEntries.delete(h); return 1; @@ -2465,7 +3508,7 @@ export class WasmPosixKernel { /** * host_closedir(dir_handle: i64) -> i32 */ - private hostClosedir(dirHandle: bigint): number { + #hostClosedir(dirHandle: bigint): number { const h = Number(dirHandle); try { this.io.closedir(h); @@ -2485,38 +3528,35 @@ export class WasmPosixKernel { * Writes the current time (seconds and nanoseconds) to Wasm memory * at the given pointers. */ - private hostClockGettime( + #hostClockGettime( clockId: number, - secPtr: KernelPointer, - nsecPtr: KernelPointer, + secondsDestination: RustLentKernelDestination, + nanosecondsDestination: RustLentKernelDestination, ): number { try { const result = this.io.clockGettime(clockId); - if (!this.memory) throw new Error("Kernel not initialized"); - checkedWasmImportMemoryRange( - this.memory, - secPtr, - 8, - this.kernelPtrWidth, - "host_clock_gettime seconds destination", - ); - checkedWasmImportMemoryRange( - this.memory, - nsecPtr, - 8, - this.kernelPtrWidth, - "host_clock_gettime nanoseconds destination", + const seconds = new IntrinsicUint8Array(8); + const nanoseconds = new IntrinsicUint8Array(8); + dataViewSetBigInt64( + new IntrinsicDataView(typedArrayBuffer(seconds)), + 0, + BigInt(result.sec), + true, ); - const seconds = new Uint8Array(8); - const nanoseconds = new Uint8Array(8); - new DataView(seconds.buffer).setBigInt64(0, BigInt(result.sec), true); - new DataView(nanoseconds.buffer).setBigInt64( + dataViewSetBigInt64( + new IntrinsicDataView(typedArrayBuffer(nanoseconds)), 0, BigInt(result.nsec), true, ); - this.writeKernelBytes(secPtr, seconds.byteLength, seconds); - this.writeKernelBytes(nsecPtr, nanoseconds.byteLength, nanoseconds); + this.#writeKernelBytes( + secondsDestination, + seconds, + ); + this.#writeKernelBytes( + nanosecondsDestination, + nanoseconds, + ); return 0; } catch (error) { return negErrno(error); @@ -2529,7 +3569,7 @@ export class WasmPosixKernel { * Sleep for the specified duration. The i64 parameters appear as * BigInt in JavaScript. */ - private hostNanosleep(sec: bigint, nsec: bigint): number { + #hostNanosleep(sec: bigint, nsec: bigint): number { try { this.io.nanosleep(Number(sec), Number(nsec)); return 0; @@ -2540,7 +3580,7 @@ export class WasmPosixKernel { // ---- Phase 11: ftruncate/fsync/fchmod/fchown host imports ---- - private hostFtruncate(handle: bigint, length: bigint): number { + #hostFtruncate(handle: bigint, length: bigint): number { if (length < 0n) return -22; // EINVAL if (length > BigInt(Number.MAX_SAFE_INTEGER)) return -75; // EOVERFLOW try { @@ -2551,7 +3591,7 @@ export class WasmPosixKernel { } } - private hostFsync(handle: bigint): number { + #hostFsync(handle: bigint): number { try { this.io.fsync(Number(handle)); return 0; @@ -2563,7 +3603,7 @@ export class WasmPosixKernel { /** * host_fchmod(handle: i64, mode: u32) -> i32 */ - private hostFchmod(handle: bigint, mode: number): number { + #hostFchmod(handle: bigint, mode: number): number { try { this.io.fchmod(Number(handle), mode); return 0; @@ -2575,7 +3615,7 @@ export class WasmPosixKernel { /** * host_fchown(handle: i64, uid: u32, gid: u32) -> i32 */ - private hostFchown(handle: bigint, uid: number, gid: number): number { + #hostFchown(handle: bigint, uid: number, gid: number): number { try { this.io.fchown(Number(handle), uid, gid); return 0; @@ -2586,11 +3626,11 @@ export class WasmPosixKernel { // ---- Phase 13e: Exec ---- - private hostExec(pathPtr: KernelPointer, pathLen: number): number { + #hostExec(pathPtr: KernelPointer, pathLen: number): number { if (this.callbacks.onExec) { try { const path = new TextDecoder().decode( - this.readKernelBytes(pathPtr, pathLen), + this.#readKernelBytes(pathPtr, pathLen), ); return this.callbacks.onExec(path); } catch (error) { @@ -2602,41 +3642,53 @@ export class WasmPosixKernel { // ---- Phase 14: Alarm ---- - private hostSetAlarm(seconds: number): number { + #hostSetAlarm(seconds: number): number { if (this.callbacks.onAlarm) { return this.callbacks.onAlarm(seconds); } return 0; } - private hostSetPosixTimer(timerId: number, signo: number, valueMs: number, intervalMs: number): number { + #hostSetPosixTimer(timerId: number, signo: number, valueMs: number, intervalMs: number): number { if (this.callbacks.onPosixTimer) { return this.callbacks.onPosixTimer(timerId, signo, valueMs, intervalMs); } return 0; } - private hostSigsuspendWait(): number { + #hostSigsuspendWait(): number { if (!this.signalWakeSab) { return -(4); // -EINTR, no SAB available } - const view = new Int32Array(this.signalWakeSab); + const view = new IntrinsicInt32Array(this.signalWakeSab); // Check if already signaled (race-safe via CAS) - const old = Atomics.compareExchange(view, 0, 1, 0); + const old = intrinsicApply( + intrinsicAtomicsCompareExchange, + Atomics, + [view, 0, 1, 0], + ) as number; if (old === 1) { - const sig = Atomics.load(view, 1); - Atomics.store(view, 1, 0); + const sig = intrinsicApply( + intrinsicAtomicsLoad, + Atomics, + [view, 1], + ) as number; + intrinsicApply(intrinsicAtomicsStore, Atomics, [view, 1, 0]); return sig; } // Block until notified - Atomics.wait(view, 0, 0); + intrinsicApply(intrinsicAtomicsWait, Atomics, [view, 0, 0]); // Read signal and reset - const sig = Atomics.load(view, 1); - Atomics.store(view, 0, 0); - Atomics.store(view, 1, 0); + const sig = intrinsicApply( + intrinsicAtomicsLoad, + Atomics, + [view, 1], + ) as number; + intrinsicApply(intrinsicAtomicsStore, Atomics, [view, 0, 0]); + intrinsicApply(intrinsicAtomicsStore, Atomics, [view, 1, 0]); return sig; } @@ -2646,7 +3698,7 @@ export class WasmPosixKernel { * Create a socket. Returns the fd or throws on error. */ socket(domain: number, type: number, protocol: number): number { - const fn = this.instance!.exports.kernel_socket as ( + const fn = this.#instance!.exports.kernel_socket as ( domain: number, type: number, protocol: number, @@ -2661,7 +3713,7 @@ export class WasmPosixKernel { * Returns [fd0, fd1]. */ socketpair(domain: number, type: number, protocol: number): [number, number] { - return this.requireApiScratch().withLease((scratch) => { + return this.#requireApiScratch().withLease((scratch) => { const result = scratch.invokeKernelExport("kernel_socketpair", [ domain, type, @@ -2679,7 +3731,7 @@ export class WasmPosixKernel { * Shut down part of a full-duplex socket connection. */ shutdown(fd: number, how: number): void { - const fn = this.instance!.exports.kernel_shutdown as ( + const fn = this.#instance!.exports.kernel_shutdown as ( fd: number, how: number, ) => number; @@ -2692,16 +3744,17 @@ export class WasmPosixKernel { */ send(fd: number, data: Uint8Array, flags: number = 0): number { const exactData = intrinsicUint8ArrayView(data, "socket send input"); - return this.requireApiScratch().withLease((scratch) => { + const exactLength = typedArrayByteLength(exactData); + return this.#requireApiScratch().withLease((scratch) => { scratch.copyFrom(exactData); const result = scratch.invokeKernelExport("kernel_send", [ fd, - scratch.exportPointer(0, exactData.byteLength), - exactData.byteLength, + scratch.exportPointer(0, exactLength), + exactLength, flags, ]); if (result < 0) throw new Error(`send failed: errno ${-result}`); - if (!Number.isSafeInteger(result) || result > exactData.byteLength) { + if (!Number.isSafeInteger(result) || result > exactLength) { throw new Error(`send returned invalid byte count ${result}`); } return result; @@ -2715,7 +3768,7 @@ export class WasmPosixKernel { if (!Number.isSafeInteger(maxLen) || maxLen < 0) { throw new Error("recv length must be a non-negative safe integer"); } - return this.requireApiScratch().withLease((scratch) => { + return this.#requireApiScratch().withLease((scratch) => { const result = scratch.invokeKernelExport("kernel_recv", [ fd, scratch.exportPointer(0, maxLen), @@ -2742,7 +3795,7 @@ export class WasmPosixKernel { if (!Number.isSafeInteger(nfds) || nfds < 0) { throw new Error("poll descriptor count must be a non-negative safe integer"); } - const scratchRegion = this.requireApiScratch(); + const scratchRegion = this.#requireApiScratch(); const descriptorCapacity = Math.floor( scratchRegion.capacity / STRUCT_SIZE_WASM_POLL_FD, ); @@ -2796,7 +3849,7 @@ export class WasmPosixKernel { * Get a socket option value. */ getsockopt(fd: number, level: number, optname: number): number { - return this.requireApiScratch().withLease((scratch) => { + return this.#requireApiScratch().withLease((scratch) => { const output = scratch.dataView( 0, KERNEL_SCRATCH_SOCKLEN_BYTES * 2, @@ -2836,14 +3889,24 @@ export class WasmPosixKernel { * Set a socket option value. */ setsockopt(fd: number, level: number, optname: number, value: number): void { - const fn = this.instance!.exports.kernel_setsockopt as ( - fd: number, - level: number, - optname: number, - optval: number, - ) => number; - const result = fn(fd, level, optname, value); - if (result < 0) throw new Error(`setsockopt failed: errno ${-result}`); + this.#requireApiScratch().withLease((scratch) => { + // WHY: `value` is data, not a kernel address. Stage its complete scalar + // representation in an allocator-owned region and pass the exact extent; + // total Wasm memory size says nothing about this allocation's capacity. + scratch.dataView(0, KERNEL_SCRATCH_SOCKLEN_BYTES).setUint32( + 0, + value, + true, + ); + const result = scratch.invokeKernelExport("kernel_setsockopt", [ + fd, + level, + optname, + scratch.exportPointer(0, KERNEL_SCRATCH_SOCKLEN_BYTES), + KERNEL_SCRATCH_SOCKLEN_BYTES, + ]); + if (result < 0) throw new Error(`setsockopt failed: errno ${-result}`); + }); } // ---- Public API: Terminal operations ---- @@ -2852,7 +3915,7 @@ export class WasmPosixKernel { * Get terminal attributes in musl's exact 60-byte struct termios layout. */ tcgetattr(fd: number): Uint8Array { - return this.requireApiScratch().withLease((scratch) => { + return this.#requireApiScratch().withLease((scratch) => { const result = scratch.invokeKernelExport("kernel_tcgetattr", [ fd, scratch.exportPointer(0, 60), @@ -2872,13 +3935,13 @@ export class WasmPosixKernel { attrs, "terminal attributes input", ); - this.requireApiScratch().withLease((scratch) => { + this.#requireApiScratch().withLease((scratch) => { scratch.copyFrom(exactAttrs); const result = scratch.invokeKernelExport("kernel_tcsetattr", [ fd, action, - scratch.exportPointer(0, exactAttrs.byteLength), - exactAttrs.byteLength, + scratch.exportPointer(0, typedArrayByteLength(exactAttrs)), + typedArrayByteLength(exactAttrs), ]); if (result < 0) throw new Error(`tcsetattr failed: errno ${-result}`); }); @@ -2894,7 +3957,7 @@ export class WasmPosixKernel { request: number, arg?: Uint8Array | number, ): Uint8Array { - const fn = this.instance!.exports.kernel_ioctl as ( + const fn = this.#instance!.exports.kernel_ioctl as ( fd: number, request: number, bufPtr: KernelPointer, @@ -2909,7 +3972,7 @@ export class WasmPosixKernel { ); } const expectedSize = wasm32Size ?? 0; - return this.requireApiScratch().withLease((scratch) => { + return this.#requireApiScratch().withLease((scratch) => { let bufLen = 0; let scalarArgument = 0; if (contract?.argKind === "pointer") { @@ -2917,9 +3980,9 @@ export class WasmPosixKernel { throw new Error("pointer ioctl requires a byte buffer"); } bufLen = expectedSize; - if (arg && arg.byteLength !== bufLen) { + if (arg && typedArrayByteLength(arg) !== bufLen) { throw new Error( - `ioctl buffer is ${arg.byteLength} bytes; expected ${bufLen}`, + `ioctl buffer is ${typedArrayByteLength(arg)} bytes; expected ${bufLen}`, ); } if (!arg && contract.direction !== "out") { @@ -2950,7 +4013,7 @@ export class WasmPosixKernel { 4, ); if (result < 0) throw new Error(`ioctl failed: errno ${-result}`); - return bufLen === 0 ? new Uint8Array(0) : scratch.copyOut(0, bufLen); + return bufLen === 0 ? new IntrinsicUint8Array(0) : scratch.copyOut(0, bufLen); }); } @@ -2959,7 +4022,7 @@ export class WasmPosixKernel { * handler: 0=SIG_DFL, 1=SIG_IGN, or function pointer index */ signal(signum: number, handler: number): number { - const fn = this.instance!.exports.kernel_signal as ( + const fn = this.#instance!.exports.kernel_signal as ( signum: number, handler: number, ) => number; @@ -2974,7 +4037,7 @@ export class WasmPosixKernel { * Set file creation mask. Returns previous mask. */ umask(mask: number): number { - const fn = this.instance!.exports.kernel_umask as (mask: number) => number; + const fn = this.#instance!.exports.kernel_umask as (mask: number) => number; return fn(mask); } @@ -2982,7 +4045,7 @@ export class WasmPosixKernel { * Get system identification. Returns object with sysname, nodename, release, version, machine. */ uname(): { sysname: string; nodename: string; release: string; version: string; machine: string } { - return this.requireApiScratch().withLease((scratch) => { + return this.#requireApiScratch().withLease((scratch) => { const result = scratch.invokeKernelExport("kernel_uname", [ scratch.exportPointer(0, 325), 325, @@ -2993,7 +4056,7 @@ export class WasmPosixKernel { const readField = (offset: number): string => { let end = offset; while (end < offset + 65 && bytes[end] !== 0) end++; - return decoder.decode(bytes.subarray(offset, end)); + return decoder.decode(subarrayUint8Array(bytes, offset, end)); }; return { sysname: readField(0), @@ -3009,7 +4072,7 @@ export class WasmPosixKernel { * Get configurable system variable value. */ sysconf(name: number): number { - const fn = this.instance!.exports.kernel_sysconf as (name: number) => bigint; + const fn = this.#instance!.exports.kernel_sysconf as (name: number) => bigint; const result = fn(name); return Number(result); } @@ -3018,7 +4081,7 @@ export class WasmPosixKernel { * Duplicate fd with flags. Unlike dup2, returns error if oldfd == newfd. */ dup3(oldfd: number, newfd: number, flags: number): number { - const fn = this.instance!.exports.kernel_dup3 as ( + const fn = this.#instance!.exports.kernel_dup3 as ( oldfd: number, newfd: number, flags: number ) => number; const result = fn(oldfd, newfd, flags); @@ -3030,7 +4093,7 @@ export class WasmPosixKernel { * Create pipe with flags (O_NONBLOCK, O_CLOEXEC). Returns [readFd, writeFd]. */ pipe2(flags: number): [number, number] { - return this.requireApiScratch().withLease((scratch) => { + return this.#requireApiScratch().withLease((scratch) => { const result = scratch.invokeKernelExport("kernel_pipe2", [ flags, scratch.exportPointer(0, KERNEL_SCRATCH_FD_PAIR_BYTES), @@ -3046,12 +4109,15 @@ export class WasmPosixKernel { * Truncate file to specified length. */ ftruncate(fd: number, length: number): void { - const fn = this.instance!.exports.kernel_ftruncate as ( - fd: number, lengthLo: number, lengthHi: number + if (!Number.isSafeInteger(length) || length < 0) { + throw new Error("ftruncate length must be a non-negative safe integer"); + } + const fn = this.#instance!.exports.kernel_ftruncate as ( + fd: number, length: bigint ) => number; - const lo = length & 0xFFFFFFFF; - const hi = Math.floor(length / 0x100000000); - const result = fn(fd, lo, hi); + // WHY: direct Wasm i64 parameters are JavaScript BigInt values. Splitting + // this scalar into i32 words changes the call shape and traps before Rust. + const result = fn(fd, BigInt(length)); if (result < 0) throw new Error(`ftruncate failed: errno ${-result}`); } @@ -3059,7 +4125,7 @@ export class WasmPosixKernel { * Synchronize file state to storage. */ fsync(fd: number): void { - const fn = this.instance!.exports.kernel_fsync as (fd: number) => number; + const fn = this.#instance!.exports.kernel_fsync as (fd: number) => number; const result = fn(fd); if (result < 0) throw new Error(`fsync failed: errno ${-result}`); } @@ -3074,11 +4140,11 @@ export class WasmPosixKernel { throw new Error("truncate length must be a non-negative safe integer"); } const encodedPath = new TextEncoder().encode(path); - this.requireApiScratch().withLease((scratch) => { + this.#requireApiScratch().withLease((scratch) => { scratch.copyFrom(encodedPath); const result = scratch.invokeKernelExport("kernel_truncate", [ - scratch.exportPointer(0, encodedPath.byteLength), - encodedPath.byteLength, + scratch.exportPointer(0, typedArrayByteLength(encodedPath)), + typedArrayByteLength(encodedPath), BigInt(length), ]); if (result < 0) throw new Error(`truncate failed: errno ${-result}`); @@ -3089,7 +4155,7 @@ export class WasmPosixKernel { * Synchronize file data to storage (alias for fsync in Wasm). */ fdatasync(fd: number): void { - const fn = this.instance!.exports.kernel_fdatasync as (fd: number) => number; + const fn = this.#instance!.exports.kernel_fdatasync as (fd: number) => number; const result = fn(fd); if (result < 0) throw new Error(`fdatasync failed: errno ${-result}`); } @@ -3098,7 +4164,7 @@ export class WasmPosixKernel { * Change file mode via fd. */ fchmod(fd: number, mode: number): void { - const fn = this.instance!.exports.kernel_fchmod as (fd: number, mode: number) => number; + const fn = this.#instance!.exports.kernel_fchmod as (fd: number, mode: number) => number; const result = fn(fd, mode); if (result < 0) throw new Error(`fchmod failed: errno ${-result}`); } @@ -3107,7 +4173,7 @@ export class WasmPosixKernel { * Change file owner/group via fd. */ fchown(fd: number, uid: number, gid: number): void { - const fn = this.instance!.exports.kernel_fchown as ( + const fn = this.#instance!.exports.kernel_fchown as ( fd: number, uid: number, gid: number ) => number; const result = fn(fd, uid, gid); @@ -3118,7 +4184,7 @@ export class WasmPosixKernel { * Get process group ID. */ getpgrp(): number { - const fn = this.instance!.exports.kernel_getpgrp as () => number; + const fn = this.#instance!.exports.kernel_getpgrp as () => number; return fn(); } @@ -3126,7 +4192,7 @@ export class WasmPosixKernel { * Set process group ID. */ setpgid(pid: number, pgid: number): void { - const fn = this.instance!.exports.kernel_setpgid as ( + const fn = this.#instance!.exports.kernel_setpgid as ( pid: number, pgid: number ) => number; const result = fn(pid, pgid); @@ -3137,7 +4203,7 @@ export class WasmPosixKernel { * Get session ID. */ getsid(pid: number): number { - const fn = this.instance!.exports.kernel_getsid as (pid: number) => number; + const fn = this.#instance!.exports.kernel_getsid as (pid: number) => number; const result = fn(pid); if (result < 0) throw new Error(`getsid failed: errno ${-result}`); return result; @@ -3147,7 +4213,7 @@ export class WasmPosixKernel { * Create new session. */ setsid(): number { - const fn = this.instance!.exports.kernel_setsid as () => number; + const fn = this.#instance!.exports.kernel_setsid as () => number; const result = fn(); if (result < 0) throw new Error(`setsid failed: errno ${-result}`); return result; @@ -3159,7 +4225,7 @@ export class WasmPosixKernel { * Set real and effective user ID. */ setuid(uid: number): void { - const fn = this.instance!.exports.kernel_setuid as (uid: number) => number; + const fn = this.#instance!.exports.kernel_setuid as (uid: number) => number; const result = fn(uid); if (result < 0) throw new Error(`setuid failed: errno ${-result}`); } @@ -3168,7 +4234,7 @@ export class WasmPosixKernel { * Set real and effective group ID. */ setgid(gid: number): void { - const fn = this.instance!.exports.kernel_setgid as (gid: number) => number; + const fn = this.#instance!.exports.kernel_setgid as (gid: number) => number; const result = fn(gid); if (result < 0) throw new Error(`setgid failed: errno ${-result}`); } @@ -3177,7 +4243,7 @@ export class WasmPosixKernel { * Set effective user ID. */ seteuid(euid: number): void { - const fn = this.instance!.exports.kernel_seteuid as (euid: number) => number; + const fn = this.#instance!.exports.kernel_seteuid as (euid: number) => number; const result = fn(euid); if (result < 0) throw new Error(`seteuid failed: errno ${-result}`); } @@ -3186,7 +4252,7 @@ export class WasmPosixKernel { * Set effective group ID. */ setegid(egid: number): void { - const fn = this.instance!.exports.kernel_setegid as (egid: number) => number; + const fn = this.#instance!.exports.kernel_setegid as (egid: number) => number; const result = fn(egid); if (result < 0) throw new Error(`setegid failed: errno ${-result}`); } @@ -3195,7 +4261,7 @@ export class WasmPosixKernel { * Get resource usage. Returns 144-byte rusage struct. */ getrusage(who: number): Uint8Array { - return this.requireApiScratch().withLease((scratch) => { + return this.#requireApiScratch().withLease((scratch) => { const result = scratch.invokeKernelExport("kernel_getrusage", [ who, scratch.exportPointer(0, 144), @@ -3236,7 +4302,7 @@ export class WasmPosixKernel { validateSet(writefds); validateSet(exceptfds); - return this.requireApiScratch().withLease((scratch) => { + return this.#requireApiScratch().withLease((scratch) => { const totalSetBytes = 3 * SELECT_FD_SET_BYTES; scratch.fill(0, 0, totalSetBytes); const readOffset = 0; @@ -3339,7 +4405,7 @@ export class WasmPosixKernel { // ---- Networking host imports ---- - private hostNetConnect( + #hostNetConnect( handle: number, addrPtr: KernelPointer, addrLen: number, @@ -3348,7 +4414,7 @@ export class WasmPosixKernel { if (!this.io.network) return -111; // -ECONNREFUSED let addr: Uint8Array; try { - addr = this.readKernelBytes(addrPtr, addrLen); + addr = this.#readKernelBytes(addrPtr, addrLen); } catch { return -14; // EFAULT } @@ -3360,7 +4426,7 @@ export class WasmPosixKernel { } } - private hostNetConnectStatus(handle: number): number { + #hostNetConnectStatus(handle: number): number { if (!this.io.network) return -107; // -ENOTCONN try { // Backend returns positive errno on failure; kernel expects negative. @@ -3371,7 +4437,7 @@ export class WasmPosixKernel { } } - private hostNetSend( + #hostNetSend( handle: number, bufPtr: KernelPointer, bufLen: number, @@ -3380,15 +4446,16 @@ export class WasmPosixKernel { if (!this.io.network) return -107; // -ENOTCONN let data: Uint8Array; try { - data = this.readKernelBytes(bufPtr, bufLen); + data = this.#readKernelBytes(bufPtr, bufLen); } catch { return -14; // EFAULT } try { const sent = this.io.network.send(handle, data, flags); + const dataLength = typedArrayByteLength(data); return Number.isSafeInteger(sent) && sent >= 0 - && sent <= data.byteLength + && sent <= dataLength ? sent : -5; } catch (e: any) { @@ -3397,28 +4464,18 @@ export class WasmPosixKernel { } } - private hostNetRecv( + #hostNetRecv( handle: number, - bufPtr: KernelPointer, - bufLen: number, + destination: RustLentKernelDestination, flags: number, ): number { if (!this.io.network) return -107; // -ENOTCONN - if (!this.memory) return -5; - let destination: { pointer: number; length: number; end: number }; try { - destination = checkedWasmImportMemoryRange( - this.memory, - bufPtr, - bufLen, - this.kernelPtrWidth, - "host_net_recv destination", + const produced = this.io.network.recv( + handle, + destination.capacity, + flags, ); - } catch { - return -14; // -EFAULT - } - try { - const produced = this.io.network.recv(handle, bufLen, flags); let data: Uint8Array; try { data = intrinsicUint8ArrayView( @@ -3428,22 +4485,23 @@ export class WasmPosixKernel { } catch { return -5; // EIO: the backend violated its byte-source contract. } - if (data.byteLength > destination.length) { + const dataLength = typedArrayByteLength(data); + if (dataLength > destination.capacity) { return -5; // EIO: backend violated the supplied capacity } - if (data.byteLength > 0) { + if (dataLength > 0) { // Recheck after the backend callback in case memory grew while the // Rust import was suspended in host code. - this.writeKernelBytes(bufPtr, bufLen, data); + this.#writeKernelBytes(destination, data); } - return data.byteLength; + return dataLength; } catch (e: any) { if (e?.errno === 11) return -11; // -EAGAIN return -104; // -ECONNRESET } } - private hostNetPoll(handle: number, events: number): number { + #hostNetPoll(handle: number, events: number): number { const POLLIN = 0x0001; const POLLOUT = 0x0004; if (!this.io.network) return -107; // -ENOTCONN @@ -3458,7 +4516,7 @@ export class WasmPosixKernel { } } - private hostNetClose(handle: number): number { + #hostNetClose(handle: number): number { if (!this.io.network) return 0; try { this.io.network.close(handle); @@ -3468,24 +4526,24 @@ export class WasmPosixKernel { } } - private hostNetListen(fd: number, port: number, addrA: number, addrB: number, addrC: number, addrD: number): number { + #hostNetListen(fd: number, port: number, addrA: number, addrB: number, addrC: number, addrD: number): number { if (this.callbacks.onNetListen) { return this.callbacks.onNetListen(fd, port, [addrA, addrB, addrC, addrD]); } return 0; } - private hostUdpBind(handle: number, addrA: number, addrB: number, addrC: number, addrD: number, port: number): number { + #hostUdpBind(handle: number, addrA: number, addrB: number, addrC: number, addrD: number, port: number): number { if (!this.callbacks.onUdpBind) return 0; return this.callbacks.onUdpBind(handle, [addrA, addrB, addrC, addrD], port); } - private hostUdpUnbind(handle: number): number { + #hostUdpUnbind(handle: number): number { if (!this.callbacks.onUdpUnbind) return 0; return this.callbacks.onUdpUnbind(handle); } - private hostUdpSend( + #hostUdpSend( srcA: number, srcB: number, srcC: number, @@ -3502,12 +4560,13 @@ export class WasmPosixKernel { if (!this.io.network?.sendDatagram) return -101; // -ENETUNREACH let data: Uint8Array; try { - data = this.readKernelBytes(dataPtr, dataLen); + data = this.#readKernelBytes(dataPtr, dataLen); } catch { return -14; // EFAULT } try { - let srcAddr = new Uint8Array([srcA, srcB, srcC, srcD]); + let srcAddr: Uint8Array = + new IntrinsicUint8Array([srcA, srcB, srcC, srcD]); if ( srcAddr[0] === 0 && srcAddr[1] === 0 && @@ -3515,12 +4574,12 @@ export class WasmPosixKernel { srcAddr[3] === 0 && this.io.network.localAddress ) { - srcAddr = this.io.network.localAddress.slice(); + srcAddr = sliceUint8Array(this.io.network.localAddress); } const result = this.io.network.sendDatagram({ srcAddr, srcPort, - dstAddr: new Uint8Array([dstA, dstB, dstC, dstD]), + dstAddr: new IntrinsicUint8Array([dstA, dstB, dstC, dstD]), dstPort, data, }); @@ -3531,24 +4590,15 @@ export class WasmPosixKernel { } } - private hostGetaddrinfo( + #hostGetaddrinfo( namePtr: KernelPointer, nameLen: number, - resultPtr: KernelPointer, - resultLen: number, + destination: RustLentKernelDestination, ): number { if (!this.io.network) return -2; // -ENOENT try { - if (!this.memory) return -5; - checkedWasmImportMemoryRange( - this.memory, - resultPtr, - resultLen, - this.kernelPtrWidth, - "host_getaddrinfo destination", - ); const name = new TextDecoder().decode( - this.readKernelBytes(namePtr, nameLen), + this.#readKernelBytes(namePtr, nameLen), ); // WHY: EAGAIN is the backend's asynchronous DNS handoff to the kernel // retry loop. Keep backend exceptions outside the producer-validation @@ -3563,30 +4613,31 @@ export class WasmPosixKernel { } catch { return -5; // EIO: the backend violated its byte-source contract. } - if (addr.byteLength > resultLen) return -22; // -EINVAL - this.writeKernelBytes(resultPtr, resultLen, addr); - return addr.byteLength; + const addressLength = typedArrayByteLength(addr); + if (addressLength > destination.capacity) return -22; // -EINVAL + this.#writeKernelBytes(destination, addr); + return addressLength; } catch (e: any) { if (e?.errno === 11) return -11; // -EAGAIN — kernel-worker retries return negErrno(e); } } - private hostFutexWait( + #hostFutexWait( addr: KernelPointer, expected: number, timeoutLo: number, timeoutHi: number, ): number { - if (!this.memory) return -22; // -EINVAL + if (!this.#memory) return -22; // -EINVAL let index: number; try { const range = checkedWasmImportMemoryRange( - this.memory, + this.#memory, addr, 4, - this.kernelPtrWidth, + this.#kernelPtrWidth, "host_futex_wait word", ); if (range.pointer % 4 !== 0) return -22; // EINVAL @@ -3594,7 +4645,7 @@ export class WasmPosixKernel { } catch { return -14; // EFAULT } - const i32view = new Int32Array(this.memory.buffer); + const i32view = new IntrinsicInt32Array(wasmMemoryBuffer(this.#memory)); // Reconstruct 64-bit timeout_ns from lo/hi const timeoutNs = BigInt(timeoutHi >>> 0) * 0x100000000n + BigInt(timeoutLo >>> 0); @@ -3611,7 +4662,11 @@ export class WasmPosixKernel { let result: "ok" | "not-equal" | "timed-out"; try { - result = Atomics.wait(i32view, index, expected, timeoutMs); + result = intrinsicApply( + intrinsicAtomicsWait, + Atomics, + [i32view, index, expected, timeoutMs], + ) as "ok" | "not-equal" | "timed-out"; } catch { return -22; // EINVAL: memory was not shared or became unusable } @@ -3622,15 +4677,15 @@ export class WasmPosixKernel { return 0; // "ok" } - private hostFutexWake(addr: KernelPointer, count: number): number { - if (!this.memory) return 0; + #hostFutexWake(addr: KernelPointer, count: number): number { + if (!this.#memory) return 0; let index: number; try { const range = checkedWasmImportMemoryRange( - this.memory, + this.#memory, addr, 4, - this.kernelPtrWidth, + this.#kernelPtrWidth, "host_futex_wake word", ); if (range.pointer % 4 !== 0) return -22; // EINVAL @@ -3638,9 +4693,13 @@ export class WasmPosixKernel { } catch { return -14; // EFAULT } - const i32view = new Int32Array(this.memory.buffer); + const i32view = new IntrinsicInt32Array(wasmMemoryBuffer(this.#memory)); try { - return Atomics.notify(i32view, index, count); + return intrinsicApply( + intrinsicAtomicsNotify, + Atomics, + [i32view, index, count], + ) as number; } catch { return -22; // EINVAL } diff --git a/host/src/native-positioned-write.ts b/host/src/native-positioned-write.ts new file mode 100644 index 0000000000..ef2526e842 --- /dev/null +++ b/host/src/native-positioned-write.ts @@ -0,0 +1,323 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +const LINUX_O_ACCMODE = 0o3; +const LINUX_O_WRONLY = 0o1; +const LINUX_O_RDWR = 0o2; +const LINUX_O_CREAT = 0o100; +const LINUX_O_EXCL = 0o200; +const LINUX_O_APPEND = 0o2000; +const LINUX_O_NOFOLLOW = 0o400000; +const NATIVE_BACKING_MODE = 0o600; +const MAX_SYMLINK_TRAVERSALS = 40; + +interface NativeWriteRoutes { + companion: number; + append: number; + positioned: number; +} + +function nativeWriteAccess(flags: number): number | null { + switch (flags & LINUX_O_ACCMODE) { + case LINUX_O_WRONLY: + return fs.constants.O_WRONLY; + case LINUX_O_RDWR: + return fs.constants.O_RDWR; + default: + return null; + } +} + +function nativeBackingCreationMode( + linuxFlags: number, + requestedGuestMode: number, +): number { + return (linuxFlags & LINUX_O_CREAT) !== 0 + ? NATIVE_BACKING_MODE + : requestedGuestMode; +} + +function prepareCreatedNativeBackingFile(primary: number): void { + fs.fchmodSync(primary, NATIVE_BACKING_MODE); +} + +function hasErrorCode(error: unknown, code: string): boolean { + return ( + typeof error === "object" + && error !== null + && "code" in error + && error.code === code + ); +} + +function tooManySymlinksError(nativePath: string): Error & { code: string } { + const error = new Error( + `ELOOP: too many symbolic links, open '${nativePath}'`, + ) as Error & { code: string }; + error.code = "ELOOP"; + return error; +} + +function danglingSymlinkTarget(nativePath: string): string | null { + let stat: fs.Stats; + try { + stat = fs.lstatSync(nativePath); + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return null; + throw error; + } + if (!stat.isSymbolicLink()) return null; + + const target = fs.readlinkSync(nativePath, "utf8"); + return path.isAbsolute(target) + ? path.normalize(target) + : path.resolve(path.dirname(nativePath), target); +} + +export interface NativeBackingOpenResult { + fd: number; + created: boolean; +} + +/** + * Open a native backing file while retaining authoritative create provenance. + * + * Node's open API does not say whether a successful O_CREAT made the inode. + * WHY: probing with existsSync before open is a time-of-check/time-of-use bug: + * a racing creator's inode could then receive our fchmod and guest metadata. + * Non-exclusive O_CREAT is therefore split into two unambiguous operations: + * an atomic O_CREAT|O_EXCL attempt, then an existing-only attempt. ENOENT on + * the second operation means the name raced away, so the transaction retries. + * + * O_TRUNC, O_APPEND, and O_NOFOLLOW stay present in both operations. Ordinary + * O_CREAT still follows a dangling final symlink by continuing the transaction + * at its target; caller-requested O_EXCL or O_NOFOLLOW never takes that path. + */ +export function openNativeBackingFile( + nativePath: string, + nativeFlags: number, + linuxFlags: number, + requestedGuestMode: number, +): NativeBackingOpenResult { + const creationMode = nativeBackingCreationMode( + linuxFlags, + requestedGuestMode, + ); + + if ((linuxFlags & LINUX_O_CREAT) === 0) { + return { + fd: fs.openSync(nativePath, nativeFlags, creationMode), + created: false, + }; + } + + const finishCreatedOpen = (fd: number): NativeBackingOpenResult => { + try { + // WHY: guest permissions live in NativeMetadataOverlay, while the native + // inode must remain owner-accessible for later opens even when this first + // descriptor is read-only and the requested guest mode is 0000. fchmod + // restores owner-only access after a restrictive umask. + prepareCreatedNativeBackingFile(fd); + return { fd, created: true }; + } catch (error) { + try { + fs.closeSync(fd); + } catch { + // Preserve the permission-establishment failure. + } + throw error; + } + }; + + if ((linuxFlags & LINUX_O_EXCL) !== 0) { + return finishCreatedOpen( + fs.openSync(nativePath, nativeFlags, creationMode), + ); + } + + const exclusiveCreateFlags = + nativeFlags | fs.constants.O_CREAT | fs.constants.O_EXCL; + const existingOnlyFlags = + nativeFlags & ~fs.constants.O_CREAT & ~fs.constants.O_EXCL; + let transactionPath = nativePath; + let symlinkTraversals = 0; + + for (;;) { + let createdFd: number; + try { + createdFd = fs.openSync( + transactionPath, + exclusiveCreateFlags, + creationMode, + ); + } catch (error) { + if (!hasErrorCode(error, "EEXIST")) throw error; + createdFd = -1; + } + if (createdFd >= 0) { + return finishCreatedOpen(createdFd); + } + + try { + return { + fd: fs.openSync(transactionPath, existingOnlyFlags, creationMode), + created: false, + }; + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) throw error; + + const target = danglingSymlinkTarget(transactionPath); + if (target === null) continue; + if ((linuxFlags & LINUX_O_NOFOLLOW) !== 0) throw error; + if (++symlinkTraversals > MAX_SYMLINK_TRAVERSALS) { + throw tooManySymlinksError(nativePath); + } + transactionPath = target; + } + } +} + +function nativeWriteError( + code: "EIO" | "EOPNOTSUPP", + message: string, + cause?: unknown, +): Error & { code: string; cause?: unknown } { + const error = new Error(`${code}: ${message}`) as Error & { + code: string; + cause?: unknown; + }; + error.code = code; + if (cause !== undefined) error.cause = cause; + return error; +} + +function sameNativeFile(primary: number, candidate: number): boolean { + const primaryStat = fs.fstatSync(primary, { bigint: true }); + const candidateStat = fs.fstatSync(candidate, { bigint: true }); + return ( + primaryStat.dev === candidateStat.dev + && primaryStat.ino === candidateStat.ino + ); +} + +/** + * Own both native routes required by one writable regular-file handle. + * + * Rust, not the backend descriptor, owns the live O_APPEND bit. Every open + * therefore establishes an O_APPEND route and a non-append positioned route + * before returning. Later F_SETFL operations only select a route; they never + * mutate a persistent native flag, and both routes survive rename/unlink. + * + * Linux `/proc/self/fd` acquires the companion from the live inode. Other + * hosts reopen the pathname immediately and accept it only after dev+ino + * identity verification. If no exact companion can be established, open + * fails honestly with EOPNOTSUPP instead of deferring a broken transition. + */ +export class NativePositionedWriteHandles { + private readonly routes = new Map(); + + register(primary: number, linuxFlags: number, nativePath: string): void { + const access = nativeWriteAccess(linuxFlags); + if (access === null) return; + + const primaryStat = fs.fstatSync(primary, { bigint: true }); + if (!primaryStat.isFile()) return; + + const primaryIsAppend = (linuxFlags & LINUX_O_APPEND) !== 0; + const companionFlags = access + | (primaryIsAppend ? 0 : fs.constants.O_APPEND); + const candidates = process.platform === "linux" + ? [`/proc/self/fd/${primary}`, nativePath] + : [nativePath]; + let lastFailure: unknown; + + for (const candidatePath of candidates) { + let companion: number; + try { + companion = fs.openSync(candidatePath, companionFlags); + } catch (error) { + lastFailure = error; + continue; + } + + try { + if (!sameNativeFile(primary, companion)) { + throw nativeWriteError( + "EIO", + "native write companion does not name the opened file", + ); + } + } catch (error) { + try { + fs.closeSync(companion); + } catch { + // Preserve the identity failure. + } + lastFailure = error; + continue; + } + + this.routes.set(primary, { + companion, + append: primaryIsAppend ? primary : companion, + positioned: primaryIsAppend ? companion : primary, + }); + return; + } + + throw nativeWriteError( + "EOPNOTSUPP", + "cannot establish exact append and positioned routes for this file", + lastFailure, + ); + } + + forWrite(primary: number, positioned: boolean): number { + if (!positioned) return primary; + const route = this.routes.get(primary); + if (route !== undefined) return route.positioned; + + // Non-regular descriptors retain their native positioned-write behavior. + // A writable regular descriptor, however, must have established both + // routes during open. Falling back to a possibly O_APPEND primary would + // silently turn pwrite into append. + if (fs.fstatSync(primary, { bigint: true }).isFile()) { + throw nativeWriteError( + "EOPNOTSUPP", + "positioned write route is unavailable for this regular file", + ); + } + return primary; + } + + forAppend(primary: number): number { + const route = this.routes.get(primary); + if (route === undefined) { + throw nativeWriteError( + "EOPNOTSUPP", + "atomic append route is unavailable for this handle", + ); + } + return route.append; + } + + close(primary: number): void { + const route = this.routes.get(primary); + this.routes.delete(primary); + + let closeError: unknown; + if (route !== undefined) { + try { + fs.closeSync(route.companion); + } catch (error) { + closeError = error; + } + } + try { + fs.closeSync(primary); + } catch (error) { + closeError ??= error; + } + if (closeError !== undefined) throw closeError; + } +} diff --git a/host/src/node-kernel-host.ts b/host/src/node-kernel-host.ts index 7eef03905c..fddc698d5c 100644 --- a/host/src/node-kernel-host.ts +++ b/host/src/node-kernel-host.ts @@ -176,7 +176,13 @@ export class NodeKernelHost { private workerStarted = false; private initialized = false; private pendingRequests = new Map void; reject: (err: Error) => void }>(); - private exitResolvers = new Map void>(); + private exitResolvers = new Map void; + reject: (error: Error) => void; + }>(); + private kernelFatalError: Error | null = null; + private kernelWorkerExitExpected = false; + private workerTermination: Promise | null = null; private unclaimedExitStatuses = new Map(); private exitSequence = 0; private _nextRequestId = 1; @@ -189,6 +195,7 @@ export class NodeKernelHost { /** Initialize the kernel by spawning a dedicated worker_thread */ async init(kernelWasmBytes?: ArrayBuffer): Promise { + if (this.kernelFatalError !== null) throw this.kernelFatalError; const wasmBytes = kernelWasmBytes ?? loadKernelWasm(); const rootfsImage = resolveRootfsImage(this.options.rootfsImage); if (this.options.rootfsLazyAssets !== undefined && rootfsImage === null) { @@ -221,16 +228,15 @@ export class NodeKernelHost { this.worker = spawnKernelWorkerThread(); this.workerStarted = true; + this.kernelWorkerExitExpected = false; + this.workerTermination = null; this.worker.on("message", (msg: KernelToMainMessage) => { this.handleWorkerMessage(msg); }); this.worker.on("error", (err) => { const error = err instanceof Error ? err : new Error(String(err)); - for (const [, { reject }] of this.pendingRequests) { - reject(error); - } - this.pendingRequests.clear(); + this.failKernelHost(error); const diagnostic: HostDiagnostic = { pid: 0, source: "kernel worker", @@ -246,6 +252,31 @@ export class NodeKernelHost { console.error("[NodeKernelHost] onHostDiagnostic callback failed:", callbackError); } }); + this.worker.on("exit", (code) => { + this.workerStarted = false; + this.initialized = false; + if (this.kernelWorkerExitExpected || this.kernelFatalError !== null) { + return; + } + const error = new Error( + `Kernel worker exited unexpectedly (code ${code})`, + ); + this.failKernelHost(error); + const diagnostic: HostDiagnostic = { + pid: 0, + source: "kernel worker", + message: `[NodeKernelHost] ${error.message}`, + }; + console.error(diagnostic.message); + try { + this.options.onHostDiagnostic?.(diagnostic); + } catch (callbackError) { + console.error( + "[NodeKernelHost] onHostDiagnostic callback failed:", + callbackError, + ); + } + }); // Send init and wait for ready. A typed init_error is required here // because an async handler rejection does not reliably terminate a worker. @@ -265,18 +296,34 @@ export class NodeKernelHost { }; const readyHandler = (msg: KernelToMainMessage) => { if (msg.type === "ready") { - settle(resolve); + if (this.kernelFatalError !== null) { + settle(() => reject(this.kernelFatalError!)); + } else { + settle(resolve); + } } else if (msg.type === "init_error") { settle(() => reject(new Error(`Kernel worker init failed: ${msg.error}`)) ); + } else if (msg.type === "kernel_fatal") { + settle(() => + reject( + this.kernelFatalError + ?? new Error(`Kernel worker failed: ${msg.error}`), + ) + ); } }; const errorHandler = (err: Error) => { settle(() => reject(err)); }; const exitHandler = (code: number) => { - settle(() => reject(new Error(`kernel worker exited before ready (code ${code})`))); + settle(() => + reject( + this.kernelFatalError + ?? new Error(`kernel worker exited before ready (code ${code})`), + ) + ); }; this.worker.on("message", readyHandler); this.worker.once("error", errorHandler); @@ -317,8 +364,8 @@ export class NodeKernelHost { } catch (error) { // WHY: a worker that rejected initialization owns no usable kernel and // must not remain alive as a half-initialized hidden resource. - await this.worker.terminate().catch(() => {}); - this.workerStarted = false; + this.kernelWorkerExitExpected = true; + await this.terminateWorker().catch(() => {}); throw error; } this.initialized = true; @@ -398,8 +445,12 @@ export class NodeKernelHost { const exit = unclaimedExitStatus !== undefined && unclaimedExitStatus.sequence > spawnStartedBeforeExitSequence ? Promise.resolve(unclaimedExitStatus.status) - : new Promise((resolve) => { - this.exitResolvers.set(pid, resolve); + : new Promise((resolve, reject) => { + if (this.kernelFatalError !== null) { + reject(this.kernelFatalError); + return; + } + this.exitResolvers.set(pid, { resolve, reject }); }); this.options.onProcessEvent?.({ kind: "spawn", pid }); @@ -712,7 +763,7 @@ export class NodeKernelHost { }); const resolver = this.exitResolvers.get(pid); this.exitResolvers.delete(pid); - if (resolver) resolver(status); + resolver?.resolve(status); } /** Subscribe to worker-owned lazy VFS transport progress. */ @@ -769,7 +820,8 @@ export class NodeKernelHost { async destroy(): Promise { if (!this.workerStarted) return; let gracefulDetachFailure: string | undefined; - if (this.initialized) { + this.kernelWorkerExitExpected = true; + if (this.initialized && this.kernelFatalError === null) { const requestId = this._nextRequestId++; gracefulDetachFailure = await awaitGracefulKernelRealmDestroy( () => this.request(requestId, { type: "destroy", requestId }), @@ -782,14 +834,12 @@ export class NodeKernelHost { // graceful exact-generation report was false, malformed, or timed out. let realmTerminationFailure: string | undefined; try { - await this.worker.terminate(); + await this.terminateWorker(); } catch (error) { realmTerminationFailure = "kernel-worker realm termination failed: " + (error instanceof Error ? error.message : String(error)); } - this.workerStarted = false; - this.initialized = false; this.exitResolvers.clear(); this.unclaimedExitStatuses.clear(); this.pendingRequests.clear(); @@ -823,16 +873,43 @@ export class NodeKernelHost { // ── Private ── private sendToWorker(msg: MainToKernelMessage): void { + if (this.kernelFatalError !== null) throw this.kernelFatalError; this.worker.postMessage(msg); } private request(requestId: number, msg: MainToKernelMessage): Promise { return new Promise((resolve, reject) => { + if (this.kernelFatalError !== null) { + reject(this.kernelFatalError); + return; + } this.pendingRequests.set(requestId, { resolve, reject }); this.sendToWorker(msg); }); } + private failKernelHost(error: Error): void { + if (this.kernelFatalError !== null) return; + this.kernelFatalError = error; + for (const { reject } of this.pendingRequests.values()) reject(error); + this.pendingRequests.clear(); + for (const { reject } of this.exitResolvers.values()) reject(error); + this.exitResolvers.clear(); + this.unclaimedExitStatuses.clear(); + } + + private terminateWorker(): Promise { + if (this.workerTermination !== null) return this.workerTermination; + const worker = this.worker; + this.workerTermination = worker.terminate().finally(() => { + if (this.worker === worker) { + this.workerStarted = false; + this.initialized = false; + } + }); + return this.workerTermination; + } + private handleWorkerMessage(msg: KernelToMainMessage): void { switch (msg.type) { case "ready": @@ -841,6 +918,16 @@ export class NodeKernelHost { // listener also receives init terminal messages, so account for them // explicitly rather than relying on implicit fall-through. break; + case "kernel_fatal": { + const error = new Error(`Kernel worker failed: ${msg.error}`); + this.failKernelHost(error); + // WHY: after a trapped kernel export, Rust may retain an active global + // transfer borrow. No later request or process completion is safe to + // observe, so stop the poisoned worker after rejecting every waiter. + this.kernelWorkerExitExpected = true; + void this.terminateWorker().catch(() => {}); + break; + } case "response": { const pending = this.pendingRequests.get(msg.requestId); if (pending) { @@ -857,7 +944,7 @@ export class NodeKernelHost { const resolver = this.exitResolvers.get(msg.pid); if (resolver) { this.exitResolvers.delete(msg.pid); - resolver(msg.status); + resolver.resolve(msg.status); } else { this.unclaimedExitStatuses.set(msg.pid, { status: msg.status, diff --git a/host/src/node-kernel-protocol.ts b/host/src/node-kernel-protocol.ts index 675c8d6b88..0a5b980bd5 100644 --- a/host/src/node-kernel-protocol.ts +++ b/host/src/node-kernel-protocol.ts @@ -346,6 +346,12 @@ export interface InitErrorMessage { error: string; } +/** The dedicated kernel instance is poisoned and has stopped permanently. */ +export interface KernelFatalMessage { + type: "kernel_fatal"; + error: string; +} + export interface ResponseMessage { type: "response"; requestId: number; @@ -402,6 +408,7 @@ export type ProcEventMessage = export type KernelToMainMessage = | ReadyMessage | InitErrorMessage + | KernelFatalMessage | ResponseMessage | ExitMessage | StdoutMessage diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts index 3b507a8674..f25b9a6e90 100644 --- a/host/src/node-kernel-worker-entry.ts +++ b/host/src/node-kernel-worker-entry.ts @@ -39,9 +39,9 @@ import { ensureMountParentDirectories, HostFileSystem, MemoryFileSystem, - resolveForNode, readPreparedPlatformFile, } from "./vfs"; +import { resolveForNodeKernelSession } from "./vfs/default-mounts-node"; import type { MountConfig } from "./vfs/types"; import type { MountSpec } from "./vfs/default-mounts"; import { @@ -82,8 +82,6 @@ import { waitForWorkerQuiescence, } from "./worker-quiescence"; import { RootfsSnapshotGate } from "./rootfs-snapshot-gate"; -import { reapHostOwnedExitedProcess } from "./host-owned-process-reap"; -import { uninitializedKernelPipeResult } from "./kernel-pipe-transport"; import { ForkReplayGateCoordinator, observeForkReplayWorker, @@ -172,6 +170,7 @@ let execPrograms: Record = {}; let vfsExecIO: PlatformIO | null = null; let rootfsMemfs: MemoryFileSystem | null = null; let initReady = false; +let kernelFatalReported = false; /** Per-boot scratch directory; cleaned up on `destroy`. Only set when the * worker constructs a `VirtualPlatformIO` from the default mount spec. */ let sessionDir: string | null = null; @@ -619,6 +618,52 @@ function reportHostDiagnostic( post({ type: "host_diagnostic", ...diagnostic }); } +function terminatePoisonedKernelWorker(error: Error): void { + if (kernelFatalReported) return; + kernelFatalReported = true; + const detail = error.stack + ? `${error.message}\n${error.stack}` + : error.message; + try { + try { + reportHostDiagnostic({ + pid: 0, + source: "kernel fatal", + message: `[node-kernel-worker] fatal kernel instance failure: ${detail}`, + }); + } catch (reportError) { + console.error( + "[node-kernel-worker] could not report fatal diagnostic:", + reportError, + ); + } + try { + post({ type: "kernel_fatal", error: detail }); + } catch (postError) { + console.error( + "[node-kernel-worker] could not post fatal state:", + postError, + ); + } + } finally { + // WHY: a trapped kernel export can strand Rust's global transfer + // reservation in Executing state. Do not call back into that generation; + // terminate its process workers directly and stop this worker thread. + for (const info of processes.values()) { + intentionallyTerminated.add(info.worker as object); + void info.worker.terminate().catch(() => {}); + } + for (const threads of threadWorkers.values()) { + for (const thread of threads) { + intentionallyTerminated.add(thread.worker as object); + void thread.worker.terminate().catch(() => {}); + } + } + cleanupSessionDir(); + queueMicrotask(() => process.exit(1)); + } +} + function reportWorkerProtocolError(message: string): void { reportHostDiagnostic({ pid: 0, @@ -825,8 +870,8 @@ async function buildVirtualPlatformIO( sessionDir = bootSessionDir; let specMounts: MountConfig[]; try { - specMounts = await resolveForNode( - rootfsMountSpec ?? DEFAULT_MOUNT_SPEC, + specMounts = await resolveForNodeKernelSession( + DEFAULT_MOUNT_SPEC, new Uint8Array(rootfsImage), bootSessionDir, ); @@ -948,6 +993,7 @@ async function handleInit(msg: InitMessage) { onProcessMemoryTarget: (memory, target) => { processMemoryAllocator.observeTarget(memory, target); }, + onKernelFatal: terminatePoisonedKernelWorker, onFork: ({ parentPid, childPid, parentMemory, continuation }) => { return processMemoryCreators.run("a fork process Worker", () => { // Notify the main thread of every kernel-side process event so @@ -2309,7 +2355,7 @@ async function finishProcessExit( // to its exec successor. if (!detachResult.mayReapPid) return; try { - reapHostOwnedExitedProcess(kernelWorker.getKernelInstance(), pid); + kernelWorker.reapHostOwnedExitedProcess(pid); } catch (error) { reportHostDiagnostic({ pid, @@ -2427,7 +2473,7 @@ async function performDestroy() { // matching the browser host (which does the same and is likewise a no-op cost // on Chrome/V8). Phases mirror browser-kernel-worker-entry.ts performDestroy. let woken = new Set(); - try { woken = kernelWorker.killAllBlockedForTeardown(); } catch (e) { + try { woken = await kernelWorker.killAllBlockedForTeardown(); } catch (e) { console.error(`[node-kernel-worker] killAllBlockedForTeardown failed: ${e}`); } // Drain only for the pids we woke — a process we did not wake (e.g. one diff --git a/host/src/platform/node.ts b/host/src/platform/node.ts index 0ec5686698..931a1230b3 100644 --- a/host/src/platform/node.ts +++ b/host/src/platform/node.ts @@ -9,7 +9,25 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import type { PathconfValue, PlatformIO, StatResult, StatfsResult } from "../types"; +import type { + AppendOutcome, + HostFileOffset, + PathconfValue, + PlatformIO, + StatResult, + StatfsResult, +} from "../types"; +import { + advanceHostFilePosition, + checkedSeekPosition, + hostFileOffsetFromBigInt, + hostFilePositionForNodeRead, + hostFilePositionToSafeNumber, +} from "../file-offset"; +import { + NativePositionedWriteHandles, + openNativeBackingFile, +} from "../native-positioned-write"; import { filesystemPathconf } from "../pathconf"; import { nativeStatfs, translateOpenFlags } from "../vfs/host-fs"; import { NativeMetadataOverlay } from "./native-metadata"; @@ -23,24 +41,11 @@ function makeFsError(code: string, message: string): Error & { code: string } { return error; } -function checkedSeekPosition(base: number, offset: number): number { - if (!Number.isSafeInteger(base) || !Number.isSafeInteger(offset)) { - throw makeFsError("EOVERFLOW", "seek offset is not exactly representable"); - } - const position = base + offset; - if (!Number.isSafeInteger(position)) { - throw makeFsError("EOVERFLOW", "seek result is not exactly representable"); - } - if (position < 0) { - throw makeFsError("EINVAL", "negative seek offset"); - } - return position; -} - export class NodePlatformIO implements PlatformIO { private dirHandles = new Map(); private nextDirHandle = 1; - private fdPositions = new Map(); + private fdPositions = new Map(); + private readonly positionedWrites = new NativePositionedWriteHandles(); // Offset from hrtime (monotonic) to epoch, computed once at startup. private readonly _epochOffsetNs: bigint; // hrtime at creation, used as process start for CPUTIME clocks. @@ -86,29 +91,55 @@ export class NodePlatformIO implements PlatformIO { open(path: string, flags: number, mode: number): number { const nativePath = this.rewritePath(path); - const created = (flags & 0o100) !== 0 && !fs.existsSync(nativePath); - const fd = fs.openSync(nativePath, translateOpenFlags(flags), mode); - if (created) this.metadata.chmod(fs.fstatSync(fd, { bigint: true }), mode); - this.fdPositions.set(fd, 0); - return fd; + const { fd, created } = openNativeBackingFile( + nativePath, + translateOpenFlags(flags), + flags, + mode, + ); + try { + if (created) { + this.metadata.chmod(fs.fstatSync(fd, { bigint: true }), mode); + } + this.fdPositions.set(fd, 0); + this.positionedWrites.register(fd, flags, nativePath); + return fd; + } catch (error) { + this.fdPositions.delete(fd); + try { + this.positionedWrites.close(fd); + } catch { + // Preserve the route-establishment failure. + } + throw error; + } } close(handle: number): number { - fs.closeSync(handle); - this.fdPositions.delete(handle); + try { + this.positionedWrites.close(handle); + } finally { + this.fdPositions.delete(handle); + } return 0; } read( handle: number, buffer: Uint8Array, - offset: number | null, + offset: HostFileOffset | null, length: number, ): number { - const pos = offset ?? this.fdPositions.get(handle) ?? 0; + const pos = hostFilePositionForNodeRead( + offset ?? this.fdPositions.get(handle) ?? 0, + length, + ); const bytesRead = fs.readSync(handle, buffer, 0, length, pos); if (offset === null) { - this.fdPositions.set(handle, pos + bytesRead); + this.fdPositions.set( + handle, + advanceHostFilePosition(pos, bytesRead), + ); } return bytesRead; } @@ -116,29 +147,60 @@ export class NodePlatformIO implements PlatformIO { write( handle: number, buffer: Uint8Array, - offset: number | null, + offset: HostFileOffset | null, length: number, ): number { const pos = offset ?? this.fdPositions.get(handle) ?? 0; - const bytesWritten = fs.writeSync(handle, buffer, 0, length, pos); + // Node's synchronous read API accepts bigint positions, but writeSync's + // position contract is number-only (and silently ignores a bigint). + const nativePos = hostFilePositionToSafeNumber(pos); + const writeHandle = this.positionedWrites.forWrite( + handle, + offset !== null, + ); + const bytesWritten = fs.writeSync( + writeHandle, + buffer, + 0, + length, + nativePos, + ); if (bytesWritten > 0) { this.metadata.noteNativeContentChange( fs.fstatSync(handle, { bigint: true }), ); } if (offset === null) { - this.fdPositions.set(handle, pos + bytesWritten); + this.fdPositions.set( + handle, + advanceHostFilePosition(pos, bytesWritten), + ); } return bytesWritten; } + append( + _handle: number, + _buffer: Uint8Array, + _length: number, + _limit: HostFileOffset | null, + ): AppendOutcome { + // Raw Node paths are explicitly externally mutable. O_APPEND itself is + // atomic, but Node exposes neither its exact resulting offset nor a way to + // combine it with a guest-specific size ceiling. + throw makeFsError( + "EOPNOTSUPP", + "exact append outcomes require exclusive native-writer ownership", + ); + } + seek( handle: number, - offset: number, + offset: HostFileOffset, whence: number, - ): number { + ): HostFileOffset { // SEEK_SET=0, SEEK_CUR=1, SEEK_END=2 - let newPos: number; + let newPos: HostFileOffset; switch (whence) { case 0: // SEEK_SET newPos = checkedSeekPosition(0, offset); @@ -150,8 +212,10 @@ export class NodePlatformIO implements PlatformIO { } case 2: { // SEEK_END — compute from file size - const stat = this.fstat(handle); - newPos = checkedSeekPosition(stat.size, offset); + const size = hostFileOffsetFromBigInt( + fs.fstatSync(handle, { bigint: true }).size, + ); + newPos = checkedSeekPosition(size, offset); break; } default: diff --git a/host/src/types.ts b/host/src/types.ts index 3b04c2982b..e39379b50f 100644 --- a/host/src/types.ts +++ b/host/src/types.ts @@ -44,6 +44,25 @@ export interface StatfsResult { /** `null` represents a successful indeterminate/unsupported-option result. */ export type PathconfValue = number | null; +/** + * An exact signed i64 file offset. Ordinary offsets remain numbers; bigint is + * used when a Wasm64 caller's value cannot be represented safely as a number. + */ +export type HostFileOffset = number | bigint; + +/** + * The result of one append operation while the backing still owns its EOF + * serialization boundary. + * + * `end` is the file position immediately after the bytes reported by + * `written`. Keeping both values prevents callers from reconstructing the + * append start from a stale pre-write stat. + */ +export interface AppendOutcome { + readonly written: number; + readonly end: HostFileOffset; +} + export interface PlatformIO { /** * Resolve and materialize deferred backing for a path before a synchronous @@ -56,16 +75,30 @@ export interface PlatformIO { read( handle: number, buffer: Uint8Array, - offset: number | null, + offset: HostFileOffset | null, length: number, ): number; write( handle: number, buffer: Uint8Array, - offset: number | null, + offset: HostFileOffset | null, length: number, ): number; - seek(handle: number, offset: number, whence: number): number; + /** + * Atomically resolve EOF, apply an optional exclusive file-size ceiling, + * and append within one backing-owned operation. + */ + append( + handle: number, + buffer: Uint8Array, + length: number, + limit: HostFileOffset | null, + ): AppendOutcome; + seek( + handle: number, + offset: HostFileOffset, + whence: number, + ): HostFileOffset; fstat(handle: number): StatResult; fpathconf(handle: number, name: number): PathconfValue; diff --git a/host/src/vfs/default-mounts-node.ts b/host/src/vfs/default-mounts-node.ts index 24956ba068..4c27dff8c1 100644 --- a/host/src/vfs/default-mounts-node.ts +++ b/host/src/vfs/default-mounts-node.ts @@ -8,7 +8,10 @@ import { join } from "node:path"; import { mkdirSync } from "node:fs"; import type { MountConfig } from "./types"; import { MemoryFileSystem } from "./memory-fs"; -import { HostFileSystem } from "./host-fs"; +import { + createSessionOwnedHostFileSystem, + HostFileSystem, +} from "./host-fs"; import { restoreVerifiedImageMounts, validateSpec, @@ -22,7 +25,9 @@ import { * created with `mkdirSync({recursive:true})` so `safePath` is happy on first * access). * - * Asynchronous input → output function with no global state. + * The public resolver treats `sessionDir` as caller-owned. Exact native append + * authority is reserved for the internal resolver whose caller already owns a + * runtime-created random root. */ export function resolveForNode( spec: MountSpec[], @@ -30,13 +35,14 @@ export function resolveForNode( sessionDir: string, ): Promise { validateSpec(spec); - return resolveValidatedForNode(spec, rootfsImage, sessionDir); + return resolveValidatedForNode(spec, rootfsImage, sessionDir, false); } async function resolveValidatedForNode( spec: MountSpec[], rootfsImage: Uint8Array, sessionDir: string, + sessionOwned: boolean, ): Promise { const imageMounts = await restoreVerifiedImageMounts(spec, rootfsImage); const out: MountConfig[] = []; @@ -54,7 +60,9 @@ async function resolveValidatedForNode( } else { const hostDir = join(sessionDir, m.path); mkdirSync(hostDir, { recursive: true, mode: m.mode }); - const backend = new HostFileSystem(hostDir); + const backend = sessionOwned + ? createSessionOwnedHostFileSystem(hostDir) + : new HostFileSystem(hostDir); if (m.mode !== undefined) backend.chmod("/", m.mode); if (m.uid !== undefined || m.gid !== undefined) { backend.chown("/", m.uid ?? 0, m.gid ?? 0); @@ -68,3 +76,20 @@ async function resolveValidatedForNode( } return out; } + +/** + * Materialise mounts beneath the Node worker's private per-boot session root. + * + * @internal The caller must have created a fresh, unshared directory and must + * retain its cleanup lease for the complete kernel lifetime. This distinct + * entry point prevents a caller-selected path from acquiring exact native + * append authority. + */ +export function resolveForNodeKernelSession( + spec: MountSpec[], + rootfsImage: Uint8Array, + sessionDir: string, +): Promise { + validateSpec(spec); + return resolveValidatedForNode(spec, rootfsImage, sessionDir, true); +} diff --git a/host/src/vfs/device-fs.ts b/host/src/vfs/device-fs.ts index b5d10500f2..66d6114212 100644 --- a/host/src/vfs/device-fs.ts +++ b/host/src/vfs/device-fs.ts @@ -1,4 +1,11 @@ -import type { PathconfValue, StatResult, StatfsResult } from "../types"; +import type { + AppendOutcome, + HostFileOffset, + PathconfValue, + StatResult, + StatfsResult, +} from "../types"; +import { checkedHostFileOffset } from "../file-offset"; import { filesystemPathconf } from "../pathconf"; import type { FileSystemBackend, DirEntry } from "./types"; import { DEVFS_SUPER_MAGIC, zeroCapacityStatfs } from "../statfs"; @@ -120,21 +127,53 @@ export class DeviceFileSystem implements FileSystemBackend { return 0; } - read(handle: number, buffer: Uint8Array, _offset: number | null, length: number): number { + read( + handle: number, + buffer: Uint8Array, + offset: HostFileOffset | null, + length: number, + ): number { + if (typeof offset === "bigint") checkedHostFileOffset(offset); const h = this.handles.get(handle); if (!h) throw new Error("EBADF"); if (!h.device) throw new Error("EISDIR"); return h.device.reader(buffer, Math.min(length, buffer.length)); } - write(handle: number, buffer: Uint8Array, _offset: number | null, length: number): number { + write( + handle: number, + buffer: Uint8Array, + offset: HostFileOffset | null, + length: number, + ): number { + if (typeof offset === "bigint") checkedHostFileOffset(offset); const h = this.handles.get(handle); if (!h) throw new Error("EBADF"); if (!h.device) throw new Error("EISDIR"); return h.device.writer(buffer, Math.min(length, buffer.length)); } - seek(_handle: number, _offset: number, _whence: number): number { + append( + _handle: number, + _buffer: Uint8Array, + _length: number, + _limit: HostFileOffset | null, + ): AppendOutcome { + // Append outcomes carry a regular file's exact final position. Character + // devices have no such position, and the Rust kernel never selects this + // operation for them. + const error = new Error("EOPNOTSUPP: append requires a regular file") as + Error & { code: string }; + error.code = "EOPNOTSUPP"; + throw error; + } + + seek( + _handle: number, + offset: HostFileOffset, + _whence: number, + ): HostFileOffset { + if (typeof offset === "bigint") checkedHostFileOffset(offset); return 0; // character devices don't seek } diff --git a/host/src/vfs/host-fs.ts b/host/src/vfs/host-fs.ts index 450a4d14d3..03c89b4275 100644 --- a/host/src/vfs/host-fs.ts +++ b/host/src/vfs/host-fs.ts @@ -7,7 +7,29 @@ import * as fs from "node:fs"; import * as nodePath from "node:path"; -import type { PathconfValue, StatResult, StatfsResult } from "../types"; +import { + HostAppendContractError, + isHostAppendContractError, +} from "../append-contract"; +import type { + AppendOutcome, + HostFileOffset, + PathconfValue, + StatResult, + StatfsResult, +} from "../types"; +import { + advanceHostFilePosition, + checkedHostFilePosition, + checkedSeekPosition, + hostFileOffsetFromBigInt, + hostFilePositionForNodeRead, + hostFilePositionToSafeNumber, +} from "../file-offset"; +import { + NativePositionedWriteHandles, + openNativeBackingFile, +} from "../native-positioned-write"; import { NativeMetadataOverlay } from "../platform/native-metadata"; import { filesystemPathconf } from "../pathconf"; import type { FileSystemBackend, DirEntry } from "./types"; @@ -15,6 +37,19 @@ import { DEFAULT_STATFS_BLOCK_SIZE, DEFAULT_STATFS_NAMELEN } from "../statfs"; const UTIME_NOW = 0x3fffffff; const UTIME_OMIT = 0x3ffffffe; +const MAX_SIGNED_I64 = (1n << 63n) - 1n; +const intrinsicBigInt = BigInt; +const intrinsicNumber = Number; +const intrinsicNumberIsSafeInteger = Number.isSafeInteger; +const intrinsicApply = Reflect.apply; +const intrinsicWeakSetAdd = WeakSet.prototype.add; +const intrinsicWeakSetHas = WeakSet.prototype.has; +const sessionOwnedHostFileSystems = new WeakSet(); + +interface HostFileSystemOptions { + uid?: number; + gid?: number; +} function makeHostFsError(code: string, message: string): Error & { code: string } { const error = new Error(`${code}: ${message}`) as Error & { code: string }; @@ -22,18 +57,18 @@ function makeHostFsError(code: string, message: string): Error & { code: string return error; } -function checkedSeekPosition(base: number, offset: number): number { - if (!Number.isSafeInteger(base) || !Number.isSafeInteger(offset)) { - throw makeHostFsError("EOVERFLOW", "seek offset is not exactly representable"); - } - const position = base + offset; - if (!Number.isSafeInteger(position)) { - throw makeHostFsError("EOVERFLOW", "seek result is not exactly representable"); - } - if (position < 0) { - throw makeHostFsError("EINVAL", "negative seek offset"); - } - return position; +/** + * Construct the backing for a freshly created, lifecycle-owned Node session + * directory. This factory is intentionally not re-exported from the public VFS + * entry point; the internal fresh-session resolver is its sole production + * caller. + */ +export function createSessionOwnedHostFileSystem( + rootPath: string, +): HostFileSystem { + const backend = new HostFileSystem(rootPath); + intrinsicApply(intrinsicWeakSetAdd, sessionOwnedHostFileSystems, [backend]); + return backend; } /** @@ -108,7 +143,8 @@ export function nativeStatfs(path: string): StatfsResult { export class HostFileSystem implements FileSystemBackend { private rootPath: string; private guestMountPoint: string; - private fdPositions = new Map(); + private fdPositions = new Map(); + private readonly positionedWrites = new NativePositionedWriteHandles(); private dirHandles = new Map(); private nextDirHandle = 1; private metadata: NativeMetadataOverlay; @@ -116,7 +152,7 @@ export class HostFileSystem implements FileSystemBackend { constructor( rootPath: string, guestMountPoint = "/", - options: { uid?: number; gid?: number } = {}, + options: HostFileSystemOptions = {}, ) { const resolvedRoot = nodePath.resolve(rootPath); this.rootPath = fs.existsSync(resolvedRoot) @@ -279,29 +315,55 @@ export class HostFileSystem implements FileSystemBackend { (flags & 0o400000) !== 0 || ((flags & 0o100) !== 0 && (flags & 0o200) !== 0); const nativePath = this.safePath(path, !noFollowFinal); - const created = (flags & 0o100) !== 0 && !fs.existsSync(nativePath); - const fd = fs.openSync(nativePath, translateOpenFlags(flags), mode); - if (created) this.metadata.chmod(fs.fstatSync(fd, { bigint: true }), mode); - this.fdPositions.set(fd, 0); - return fd; + const { fd, created } = openNativeBackingFile( + nativePath, + translateOpenFlags(flags), + flags, + mode, + ); + try { + if (created) { + this.metadata.chmod(fs.fstatSync(fd, { bigint: true }), mode); + } + this.fdPositions.set(fd, 0); + this.positionedWrites.register(fd, flags, nativePath); + return fd; + } catch (error) { + this.fdPositions.delete(fd); + try { + this.positionedWrites.close(fd); + } catch { + // Preserve the route-establishment failure. + } + throw error; + } } close(handle: number): number { - fs.closeSync(handle); - this.fdPositions.delete(handle); + try { + this.positionedWrites.close(handle); + } finally { + this.fdPositions.delete(handle); + } return 0; } read( handle: number, buffer: Uint8Array, - offset: number | null, + offset: HostFileOffset | null, length: number, ): number { - const pos = offset ?? this.fdPositions.get(handle) ?? 0; + const pos = hostFilePositionForNodeRead( + offset ?? this.fdPositions.get(handle) ?? 0, + length, + ); const bytesRead = fs.readSync(handle, buffer, 0, length, pos); if (offset === null) { - this.fdPositions.set(handle, pos + bytesRead); + this.fdPositions.set( + handle, + advanceHostFilePosition(pos, bytesRead), + ); } return bytesRead; } @@ -309,24 +371,141 @@ export class HostFileSystem implements FileSystemBackend { write( handle: number, buffer: Uint8Array, - offset: number | null, + offset: HostFileOffset | null, length: number, ): number { const pos = offset ?? this.fdPositions.get(handle) ?? 0; - const bytesWritten = fs.writeSync(handle, buffer, 0, length, pos); + // Node's synchronous write API cannot represent a bigint position. + const nativePos = hostFilePositionToSafeNumber(pos); + const writeHandle = this.positionedWrites.forWrite( + handle, + offset !== null, + ); + const bytesWritten = fs.writeSync( + writeHandle, + buffer, + 0, + length, + nativePos, + ); if (bytesWritten > 0) { this.metadata.noteNativeContentChange( fs.fstatSync(handle, { bigint: true }), ); } if (offset === null) { - this.fdPositions.set(handle, pos + bytesWritten); + this.fdPositions.set( + handle, + advanceHostFilePosition(pos, bytesWritten), + ); } return bytesWritten; } - seek(handle: number, offset: number, whence: number): number { - let newPos: number; + append( + handle: number, + buffer: Uint8Array, + length: number, + limit: HostFileOffset | null, + ): AppendOutcome { + if ( + !intrinsicApply( + intrinsicWeakSetHas, + sessionOwnedHostFileSystems, + [this], + ) + ) { + // A later fstat cannot identify where this append ended if an unrelated + // native writer may run before or after it. Do not fabricate an exact + // outcome even when no file-size limit is active. + throw makeHostFsError( + "EOPNOTSUPP", + "exact append outcomes require exclusive native-writer ownership", + ); + } + const appendHandle = this.positionedWrites.forAppend(handle); + if ( + !intrinsicNumberIsSafeInteger(length) + || length < 0 + || length > buffer.byteLength + ) { + throw makeHostFsError("EINVAL", "invalid append length"); + } + const before = fs.fstatSync(appendHandle, { bigint: true }); + const start = before.size; + const startOffset = hostFileOffsetFromBigInt(start); + const exactLimit = limit === null + ? null + : intrinsicBigInt(checkedHostFilePosition(limit)); + if (exactLimit !== null && start >= exactLimit) { + this.fdPositions.set(handle, startOffset); + return { written: 0, end: startOffset }; + } + + const offsetCapacity = MAX_SIGNED_I64 - start; + if (offsetCapacity < 0n) { + throw makeHostFsError("EOVERFLOW", "append start is outside signed i64"); + } + const limitedCapacity = exactLimit === null + ? offsetCapacity + : exactLimit - start < offsetCapacity + ? exactLimit - start + : offsetCapacity; + const writableLength = limitedCapacity < intrinsicBigInt(length) + ? intrinsicNumber(limitedCapacity) + : length; + if (length > 0 && writableLength === 0 && exactLimit === null) { + throw makeHostFsError("EOVERFLOW", "append end would exceed signed i64"); + } + const bytesWritten = writableLength === 0 + ? 0 + : fs.writeSync( + appendHandle, + buffer, + 0, + writableLength, + null, + ); + if ( + !intrinsicNumberIsSafeInteger(bytesWritten) + || bytesWritten < 0 + || bytesWritten > writableLength + ) { + throw new HostAppendContractError( + "native append returned an invalid byte count", + ); + } + try { + const exactEnd = start + intrinsicBigInt(bytesWritten); + const stat = fs.fstatSync(handle, { bigint: true }); + if (stat.size !== exactEnd) { + throw new HostAppendContractError( + "session-owned append observed an unexpected native file size", + ); + } + if (bytesWritten > 0) { + this.metadata.noteNativeContentChange(stat); + } + const end = hostFileOffsetFromBigInt(exactEnd); + this.fdPositions.set(handle, end); + return { written: bytesWritten, end }; + } catch (error) { + if (isHostAppendContractError(error)) throw error; + // The native write already returned success. Any failure to verify and + // publish its exact end must poison the kernel generation rather than + // masquerade as an ordinary retryable filesystem errno. + throw new HostAppendContractError( + "session-owned append could not verify its post-write outcome", + ); + } + } + + seek( + handle: number, + offset: HostFileOffset, + whence: number, + ): HostFileOffset { + let newPos: HostFileOffset; switch (whence) { case 0: // SEEK_SET newPos = checkedSeekPosition(0, offset); @@ -335,7 +514,12 @@ export class HostFileSystem implements FileSystemBackend { newPos = checkedSeekPosition(this.fdPositions.get(handle) ?? 0, offset); break; case 2: // SEEK_END - newPos = checkedSeekPosition(this.fstat(handle).size, offset); + newPos = checkedSeekPosition( + hostFileOffsetFromBigInt( + fs.fstatSync(handle, { bigint: true }).size, + ), + offset, + ); break; default: throw makeHostFsError("EINVAL", `invalid whence value: ${whence}`); diff --git a/host/src/vfs/index.ts b/host/src/vfs/index.ts index ddfc55dfdf..f621efbed9 100644 --- a/host/src/vfs/index.ts +++ b/host/src/vfs/index.ts @@ -1,4 +1,5 @@ export { readPreparedPlatformFile, VirtualPlatformIO } from "./vfs"; +export type { HostFileOffset } from "../types"; export type { PreparedPlatformFile } from "./vfs"; export { HostFileSystem } from "./host-fs"; export { MemoryFileSystem } from "./memory-fs"; diff --git a/host/src/vfs/memory-fs.ts b/host/src/vfs/memory-fs.ts index 17c8e07f31..6553b4ce5d 100644 --- a/host/src/vfs/memory-fs.ts +++ b/host/src/vfs/memory-fs.ts @@ -1,5 +1,16 @@ import { decompress as zstdDecompress } from "fzstd"; -import type { PathconfValue, StatResult, StatfsResult } from "../types"; +import type { + AppendOutcome, + HostFileOffset, + PathconfValue, + StatResult, + StatfsResult, +} from "../types"; +import { + hostFileLimitForNumberBackend, + hostFileOffsetToSafeNumber, + hostFilePositionToSafeNumber, +} from "../file-offset"; import { filesystemPathconf } from "../pathconf"; import { SFFS_SUPER_MAGIC } from "../statfs"; import type { FileSystemBackend, DirEntry } from "./types"; @@ -6795,7 +6806,7 @@ export class MemoryFileSystem implements FileSystemBackend { read( handle: number, buffer: Uint8Array, - offset: number | null, + offset: HostFileOffset | null, length: number, ): number { if (length > 0) { @@ -6809,7 +6820,13 @@ export class MemoryFileSystem implements FileSystemBackend { } } if (offset !== null) { - return this.fs.readAt(handle, buffer.subarray(0, length), offset); + return this.fs.readAt( + handle, + buffer.subarray(0, length), + typeof offset === "bigint" + ? hostFilePositionToSafeNumber(offset) + : offset, + ); } return this.fs.read(handle, buffer.subarray(0, length)); } @@ -6817,11 +6834,17 @@ export class MemoryFileSystem implements FileSystemBackend { write( handle: number, buffer: Uint8Array, - offset: number | null, + offset: HostFileOffset | null, length: number, ): number { if (offset !== null) { - const n = this.fs.writeAt(handle, buffer.subarray(0, length), offset); + const n = this.fs.writeAt( + handle, + buffer.subarray(0, length), + typeof offset === "bigint" + ? hostFilePositionToSafeNumber(offset) + : offset, + ); if (n > 0) this.invalidateLazyData(this.fs.fstat(handle)); return n; } @@ -6830,8 +6853,35 @@ export class MemoryFileSystem implements FileSystemBackend { return n; } - seek(handle: number, offset: number, whence: number): number { - return this.fs.lseek(handle, offset, whence); + append( + handle: number, + buffer: Uint8Array, + length: number, + limit: HostFileOffset | null, + ): AppendOutcome { + const outcome = this.fs.append( + handle, + buffer.subarray(0, length), + hostFileLimitForNumberBackend(limit), + ); + if (outcome.written > 0) { + this.invalidateLazyData(this.fs.fstat(handle)); + } + return outcome; + } + + seek( + handle: number, + offset: HostFileOffset, + whence: number, + ): HostFileOffset { + return this.fs.lseek( + handle, + typeof offset === "bigint" + ? hostFileOffsetToSafeNumber(offset) + : offset, + whence, + ); } fstat(handle: number): StatResult { diff --git a/host/src/vfs/opfs-append.ts b/host/src/vfs/opfs-append.ts new file mode 100644 index 0000000000..6deb077829 --- /dev/null +++ b/host/src/vfs/opfs-append.ts @@ -0,0 +1,29 @@ +const intrinsicNumberIsSafeInteger = Number.isSafeInteger; +const intrinsicMathMin = Math.min; + +/** + * Resolve the complete append window before an OPFS access handle can write. + * + * `null` means even the largest valid result is not exactly representable by + * the number-only OPFS API. The caller must reject it before mutation. + */ +export function opfsAppendWritableLength( + writeAt: number, + length: number, + limit: number | null, +): number | null { + if ( + !intrinsicNumberIsSafeInteger(writeAt) + || writeAt < 0 + || !intrinsicNumberIsSafeInteger(length) + || length < 0 + ) { + return null; + } + const writableLength = limit === null || writeAt < limit + ? limit === null ? length : intrinsicMathMin(length, limit - writeAt) + : 0; + return intrinsicNumberIsSafeInteger(writeAt + writableLength) + ? writableLength + : null; +} diff --git a/host/src/vfs/opfs-channel.ts b/host/src/vfs/opfs-channel.ts index 8cdd4c1072..51952402c3 100644 --- a/host/src/vfs/opfs-channel.ts +++ b/host/src/vfs/opfs-channel.ts @@ -52,8 +52,14 @@ export const enum OpfsOpcode { READDIR = 17, CLOSEDIR = 18, STATFS = 19, + APPEND = 20, } +// This is deliberately outside the errno range. It means an append may have +// mutated the backing but the OPFS worker could not report an exact outcome; +// the receiving kernel generation must stop instead of treating it as EIO. +export const OPFS_APPEND_CONTRACT_FAILURE = -0x4b41; + /** Default SAB size: 4 MB */ export const OPFS_CHANNEL_SIZE = 4 * 1024 * 1024; diff --git a/host/src/vfs/opfs-worker.ts b/host/src/vfs/opfs-worker.ts index 769ac5d7f0..c3f15ed2ec 100644 --- a/host/src/vfs/opfs-worker.ts +++ b/host/src/vfs/opfs-worker.ts @@ -3,6 +3,8 @@ import { joinSafeI64, splitSafeI64 } from "./i64"; import type { StatResult } from "../types"; import { writeOpfsStatResult } from "./opfs-stat"; +import { OPFS_APPEND_CONTRACT_FAILURE } from "./opfs-channel"; +import { opfsAppendWritableLength } from "./opfs-append"; import { marshalNextOpfsDirectoryEntry, type OpfsDirectoryIterator, @@ -52,10 +54,12 @@ const Opcode = { READDIR: 17, CLOSEDIR: 18, STATFS: 19, + APPEND: 20, } as const; // Errno values (negative, matching Linux) const ENOENT = -2; +const EIO = -5; const EBADF = -9; const EEXIST = -17; const ENOTDIR = -20; @@ -118,6 +122,10 @@ class WorkerChannel { return this.view.getInt32(ARGS_OFFSET + index * 4, true); } + setArg(index: number, value: number): void { + this.view.setInt32(ARGS_OFFSET + index * 4, value, true); + } + getI64Arg(index: number): number { return joinSafeI64(this.getArg(index), this.getArg(index + 1)); } @@ -781,6 +789,76 @@ async function handleWrite(): Promise { } } +async function handleAppend(): Promise { + const handle = channel.getArg(0); + const length = channel.getArg(1); + const limitLo = channel.getArg(2); + const limitHi = channel.getArg(3); + const hasLimit = channel.getArg(4); + const entry = fileHandles.get(handle); + const accessHandle = entry && accessHandleFor(entry); + if (!entry || !accessHandle) { + channel.notifyError(EBADF); + return; + } + + try { + // WHY: the proxy worker serializes requests. Resolve EOF and write in this + // single handler so no second Kandelo operation can insert bytes between + // them. Apply the file-size ceiling in the same handler for the same + // reason. appendMode is intentionally ignored; Rust owns the live OFD + // flag. + const writeAt = accessHandle.getSize(); + let limit: number | null = null; + if (hasLimit) { + try { + limit = joinSafeI64(limitLo, limitHi); + } catch (error) { + if (error instanceof RangeError) { + channel.notifyError(EOVERFLOW); + return; + } + throw error; + } + if (limit < 0) { + channel.notifyError(EINVAL); + return; + } + } + // WHY: reject an unrepresentable complete result before the backing can + // mutate. Checking only the actual end after write would be too late. + const writableLength = opfsAppendWritableLength(writeAt, length, limit); + if (writableLength === null) { + channel.notifyError(EOVERFLOW); + return; + } + const data = channel.dataBuffer.slice(0, writableLength); + const bytesWritten = writableLength === 0 + ? 0 + : accessHandle.write(data, { at: writeAt }); + if ( + !Number.isSafeInteger(bytesWritten) + || bytesWritten < 0 + || bytesWritten > writableLength + ) { + // The write already ran, so an ordinary errno would let the kernel keep + // using an unknowable OFD cursor. Surface the dedicated fatal contract + // marker through the synchronous channel instead. + channel.notifyError(OPFS_APPEND_CONTRACT_FAILURE); + return; + } + const end = writeAt + bytesWritten; + entry.position = end; + channel.result = bytesWritten; + const [endLow, endHigh] = splitSafeI64(end); + channel.setArg(2, endLow); + channel.setArg(3, endHigh); + channel.notifyComplete(); + } catch (err) { + channel.notifyError(mapError(err)); + } +} + async function handleSeek(): Promise { const handle = channel.getArg(0); const offsetLo = channel.getArg(1); @@ -1313,6 +1391,7 @@ async function dispatch(): Promise { case Opcode.READDIR: return handleReaddir(); case Opcode.CLOSEDIR: return handleClosedir(); case Opcode.STATFS: return handleStatfs(); + case Opcode.APPEND: return handleAppend(); default: channel.notifyError(ENOTSUP); } diff --git a/host/src/vfs/opfs.ts b/host/src/vfs/opfs.ts index 32030f86ac..9a15860e9e 100644 --- a/host/src/vfs/opfs.ts +++ b/host/src/vfs/opfs.ts @@ -5,10 +5,27 @@ * then block with Atomics.wait() until the OpfsProxyWorker completes * the async OPFS operation. */ -import type { PathconfValue, StatResult, StatfsResult } from "../types"; +import type { + AppendOutcome, + HostFileOffset, + PathconfValue, + StatResult, + StatfsResult, +} from "../types"; +import { + hostFileLimitForNumberBackend, + hostFileOffsetToSafeNumber, + hostFilePositionToSafeNumber, +} from "../file-offset"; +import { HostAppendContractError } from "../append-contract"; import { filesystemPathconf } from "../pathconf"; import type { FileSystemBackend, DirEntry } from "./types"; -import { OpfsChannel, OpfsChannelStatus, OpfsOpcode } from "./opfs-channel"; +import { + OPFS_APPEND_CONTRACT_FAILURE, + OpfsChannel, + OpfsChannelStatus, + OpfsOpcode, +} from "./opfs-channel"; export class OpfsFileSystem implements FileSystemBackend { private readonly channel: OpfsChannel; @@ -35,6 +52,11 @@ export class OpfsFileSystem implements FileSystemBackend { } private errnoToError(negErrno: number): Error { + if (negErrno === OPFS_APPEND_CONTRACT_FAILURE) { + return new HostAppendContractError( + "OPFS append mutated without an exact byte-count outcome", + ); + } const ERRNO_NAMES: Record = { [-1]: "EPERM", [-2]: "ENOENT", @@ -54,9 +76,14 @@ export class OpfsFileSystem implements FileSystemBackend { return new Error(name); } - private setI64Arg(index: number, value: number): void { + private setI64Arg(index: number, value: HostFileOffset): void { try { - this.channel.setI64Arg(index, value); + this.channel.setI64Arg( + index, + typeof value === "bigint" + ? hostFileOffsetToSafeNumber(value) + : value, + ); } catch (error) { if (error instanceof RangeError) throw this.errnoToError(-75); throw error; @@ -79,11 +106,21 @@ export class OpfsFileSystem implements FileSystemBackend { return 0; } - read(handle: number, buffer: Uint8Array, offset: number | null, length: number): number { + read( + handle: number, + buffer: Uint8Array, + offset: HostFileOffset | null, + length: number, + ): number { this.channel.setArg(0, handle); this.channel.setArg(1, length); if (offset !== null) { - this.setI64Arg(2, offset); + this.setI64Arg( + 2, + typeof offset === "bigint" + ? hostFilePositionToSafeNumber(offset) + : offset, + ); this.channel.setArg(4, 1); // has_offset } else { this.channel.setArg(2, 0); @@ -97,11 +134,21 @@ export class OpfsFileSystem implements FileSystemBackend { return bytesRead; } - write(handle: number, buffer: Uint8Array, offset: number | null, length: number): number { + write( + handle: number, + buffer: Uint8Array, + offset: HostFileOffset | null, + length: number, + ): number { this.channel.setArg(0, handle); this.channel.setArg(1, length); if (offset !== null) { - this.setI64Arg(2, offset); + this.setI64Arg( + 2, + typeof offset === "bigint" + ? hostFilePositionToSafeNumber(offset) + : offset, + ); this.channel.setArg(4, 1); } else { this.channel.setArg(2, 0); @@ -112,7 +159,46 @@ export class OpfsFileSystem implements FileSystemBackend { return this.call(OpfsOpcode.WRITE); } - seek(handle: number, offset: number, whence: number): number { + append( + handle: number, + buffer: Uint8Array, + length: number, + limit: HostFileOffset | null, + ): AppendOutcome { + this.channel.setArg(0, handle); + this.channel.setArg(1, length); + const numberLimit = hostFileLimitForNumberBackend(limit); + if (numberLimit === null) { + this.channel.setArg(2, 0); + this.channel.setArg(3, 0); + this.channel.setArg(4, 0); + } else { + this.channel.setI64Arg(2, numberLimit); + this.channel.setArg(4, 1); + } + this.channel.dataBuffer.set(buffer.subarray(0, length)); + const written = this.call(OpfsOpcode.APPEND); + try { + return { + written, + // The append handler replaces the request's limit words with the + // exact post-operation EOF before publishing completion. + end: this.channel.getI64Arg(2), + }; + } catch { + // Completion promises a checked exact end. If the channel violates that + // promise after append, continuing would publish an unknowable cursor. + throw new HostAppendContractError( + "OPFS append completed with an inexact end position", + ); + } + } + + seek( + handle: number, + offset: HostFileOffset, + whence: number, + ): HostFileOffset { this.channel.setArg(0, handle); this.setI64Arg(1, offset); this.channel.setArg(3, whence); diff --git a/host/src/vfs/sharedfs-vendor.ts b/host/src/vfs/sharedfs-vendor.ts index fa7846c0dc..db4fef16f8 100644 --- a/host/src/vfs/sharedfs-vendor.ts +++ b/host/src/vfs/sharedfs-vendor.ts @@ -152,6 +152,11 @@ export interface StatResult { gid: number; } +export interface SharedFsAppendOutcome { + readonly written: number; + readonly end: number; +} + export interface SharedFsStats { blockSize: number; totalBlocks: number; @@ -2754,6 +2759,67 @@ export class SharedFS { } } + append( + fd: number, + data: Uint8Array, + limit: number | null, + ): SharedFsAppendOutcome { + const entry = this.fdGet(fd); + if (!entry) throw new SFSError(EBADF); + + const accMode = entry.flags & O_ACCMODE; + if (accMode === O_RDONLY) throw new SFSError(EBADF); + if (limit !== null && (!Number.isSafeInteger(limit) || limit < 0)) { + throw new SFSError(EINVAL); + } + + this.inodeWriteLock(entry.ino); + try { + // WHY: resolve EOF and mutate the inode under the same lock. The + // caller's current O_APPEND bit is Rust-owned, so this operation is + // deliberately independent of the flags captured by SharedFS.open(). + // The file-size ceiling is applied before releasing this same lock, so + // another SharedFS actor cannot move EOF between the limit decision and + // the append. + const inoOff = this.inodeOffset(entry.ino); + const offset = this.r64(inoOff + INO_SIZE); + if (!Number.isSafeInteger(offset) || offset < 0) { + throw new SFSError(EINVAL); + } + if (offset > MAX_FILE_SIZE) { + throw new SFSError(EFBIG); + } + + if (limit !== null && offset >= limit) { + const base = FD_TABLE_OFFSET + fd * FD_ENTRY_SIZE; + this.w64(base + FD_OFFSET, offset); + return { written: 0, end: offset }; + } + + const limitAvailable = limit === null + ? data.length + : Math.min(data.length, limit - offset); + const fsAvailable = MAX_FILE_SIZE - offset; + if (limitAvailable > fsAvailable) { + throw new SFSError(EFBIG); + } + const writable = data.subarray(0, limitAvailable); + const nwritten = this.inodeWriteData( + entry.ino, + offset, + writable, + writable.length, + ); + if (nwritten < 0) throw new SFSError(nwritten); + const base = FD_TABLE_OFFSET + fd * FD_ENTRY_SIZE; + const end = offset + nwritten; + this.w64(base + FD_OFFSET, end); + return { written: nwritten, end }; + } finally { + this.inodeWriteUnlock(entry.ino); + } + } + writeAt(fd: number, data: Uint8Array, offset: number): number { const entry = this.fdGet(fd); if (!entry) throw new SFSError(EBADF); diff --git a/host/src/vfs/types.ts b/host/src/vfs/types.ts index a384cb19b5..0fde0c19dc 100644 --- a/host/src/vfs/types.ts +++ b/host/src/vfs/types.ts @@ -1,4 +1,10 @@ -import type { PathconfValue, StatResult, StatfsResult } from "../types"; +import type { + AppendOutcome, + HostFileOffset, + PathconfValue, + StatResult, + StatfsResult, +} from "../types"; export interface DirEntry { name: string; @@ -12,9 +18,33 @@ export interface FileSystemBackend { // File handle operations open(path: string, flags: number, mode: number): number; close(handle: number): number; - read(handle: number, buffer: Uint8Array, offset: number | null, length: number): number; - write(handle: number, buffer: Uint8Array, offset: number | null, length: number): number; - seek(handle: number, offset: number, whence: number): number; + read( + handle: number, + buffer: Uint8Array, + offset: HostFileOffset | null, + length: number, + ): number; + write( + handle: number, + buffer: Uint8Array, + offset: HostFileOffset | null, + length: number, + ): number; + /** + * Atomically resolve EOF, apply an optional exclusive file-size ceiling, + * and append within one backing-owned operation. + */ + append( + handle: number, + buffer: Uint8Array, + length: number, + limit: HostFileOffset | null, + ): AppendOutcome; + seek( + handle: number, + offset: HostFileOffset, + whence: number, + ): HostFileOffset; fstat(handle: number): StatResult; fpathconf(handle: number, name: number): PathconfValue; ftruncate(handle: number, length: number): void; diff --git a/host/src/vfs/vfs.ts b/host/src/vfs/vfs.ts index 3fc2890e35..b17c928e3c 100644 --- a/host/src/vfs/vfs.ts +++ b/host/src/vfs/vfs.ts @@ -1,4 +1,6 @@ import type { + AppendOutcome, + HostFileOffset, NetworkIO, PathconfValue, PlatformIO, @@ -197,7 +199,7 @@ export class VirtualPlatformIO implements PlatformIO { read( handle: number, buffer: Uint8Array, - offset: number | null, + offset: HostFileOffset | null, length: number, ): number { const info = this.getFileHandle(handle); @@ -207,14 +209,28 @@ export class VirtualPlatformIO implements PlatformIO { write( handle: number, buffer: Uint8Array, - offset: number | null, + offset: HostFileOffset | null, length: number, ): number { const info = this.getFileHandle(handle); return info.backend.write(info.localHandle, buffer, offset, length); } - seek(handle: number, offset: number, whence: number): number { + append( + handle: number, + buffer: Uint8Array, + length: number, + limit: HostFileOffset | null, + ): AppendOutcome { + const info = this.getFileHandle(handle); + return info.backend.append(info.localHandle, buffer, length, limit); + } + + seek( + handle: number, + offset: HostFileOffset, + whence: number, + ): HostFileOffset { const info = this.getFileHandle(handle); return info.backend.seek(info.localHandle, offset, whence); } diff --git a/host/src/wasi-shim.ts b/host/src/wasi-shim.ts index a1547ffee0..9884114ad2 100644 --- a/host/src/wasi-shim.ts +++ b/host/src/wasi-shim.ts @@ -104,6 +104,34 @@ const SEEK_SET = 0; const SEEK_CUR = 1; const SEEK_END = 2; +type SyscallScalar = number | bigint; +const MIN_SIGNED_I64 = -(1n << 63n); +const MAX_SIGNED_I64 = (1n << 63n) - 1n; + +function checkedSignedI64Scalar(value: SyscallScalar, label: string): bigint { + if (typeof value === "number") { + if (!Number.isSafeInteger(value)) { + throw new RangeError(`${label} must be a safe integer`); + } + return BigInt(value); + } + if (value < MIN_SIGNED_I64 || value > MAX_SIGNED_I64) { + throw new RangeError(`${label} is outside signed i64`); + } + return value; +} + +function splitSignedI64Words(value: bigint): { + low: bigint; + high: bigint; +} { + const signed = checkedSignedI64Scalar(value, "seek offset"); + return { + low: BigInt.asUintN(32, signed), + high: BigInt.asIntN(32, signed >> 32n), + }; +} + // S_IFMT mode bits const S_IFDIR = 0o040000; const S_IFCHR = 0o020000; @@ -356,13 +384,13 @@ function modeToFiletype(mode: number): number { } } -function wasiWhenceToPosix(wasiWhence: number): number { +function wasiWhenceToPosix(wasiWhence: number): number | null { // WASI and POSIX happen to use the same numbering for whence switch (wasiWhence) { case WASI_WHENCE_SET: return SEEK_SET; case WASI_WHENCE_CUR: return SEEK_CUR; case WASI_WHENCE_END: return SEEK_END; - default: return SEEK_SET; + default: return null; } } @@ -452,26 +480,45 @@ export class WasiShim { 0, ); - if (errno === 0 && result >= 0) { - this.preopens.set(result, "/"); + if (errno === 0 && result >= 0n) { + this.preopens.set(this.syscallResultNumber(result), "/"); } } /** Issue a syscall through the channel and wait for the result. */ private doSyscall( syscallNum: number, - a0 = 0, a1 = 0, a2 = 0, a3 = 0, a4 = 0, a5 = 0, - ): { result: number; errno: number } { + a0: SyscallScalar = 0, + a1: SyscallScalar = 0, + a2: SyscallScalar = 0, + a3: SyscallScalar = 0, + a4: SyscallScalar = 0, + a5: SyscallScalar = 0, + ): { result: bigint; errno: number } { + if ( + !Number.isSafeInteger(syscallNum) + || syscallNum < 0 + || syscallNum > 0x7fff_ffff + ) { + throw new RangeError("syscall number must be a non-negative i32"); + } + // Validate the complete record before publishing any channel word. Native + // DataView#setBigInt64 wraps out-of-range values modulo 2^64; allowing that + // here would turn a direct JavaScript caller mistake into another syscall. + const args = [a0, a1, a2, a3, a4, a5].map((value, index) => + checkedSignedI64Scalar(value, `syscall argument ${index}`) + ); const base = this.channelOffset; const view = new DataView(this.memory.buffer); view.setInt32(base + CH_SYSCALL, syscallNum, true); - view.setBigInt64(base + CH_ARGS + 0 * CH_ARG_SIZE, BigInt(a0), true); - view.setBigInt64(base + CH_ARGS + 1 * CH_ARG_SIZE, BigInt(a1), true); - view.setBigInt64(base + CH_ARGS + 2 * CH_ARG_SIZE, BigInt(a2), true); - view.setBigInt64(base + CH_ARGS + 3 * CH_ARG_SIZE, BigInt(a3), true); - view.setBigInt64(base + CH_ARGS + 4 * CH_ARG_SIZE, BigInt(a4), true); - view.setBigInt64(base + CH_ARGS + 5 * CH_ARG_SIZE, BigInt(a5), true); + for (let index = 0; index < args.length; index++) { + view.setBigInt64( + base + CH_ARGS + index * CH_ARG_SIZE, + args[index], + true, + ); + } const i32 = new Int32Array(this.memory.buffer); const statusIdx = base / 4; @@ -482,7 +529,10 @@ export class WasiShim { // Block until kernel signals completion while (Atomics.wait(i32, statusIdx, CH_PENDING) === "ok") { /* */ } - const result = Number(view.getBigInt64(base + CH_RETURN, true)); + // WHY: CH_RETURN is an i64 scalar. Converting every result to Number here + // silently rounds valid offsets above 2^53 before the caller can decide + // whether its own result contract permits narrowing. + const result = view.getBigInt64(base + CH_RETURN, true); const errno = view.getUint32(base + CH_ERRNO, true); // Reset to idle @@ -491,6 +541,15 @@ export class WasiShim { return { result, errno }; } + /** Narrow a result only after the caller's ABI contract proves Number is sufficient. */ + private syscallResultNumber(result: bigint): number { + const narrowed = Number(result); + if (!Number.isSafeInteger(narrowed) || BigInt(narrowed) !== result) { + throw new RangeError(`syscall result ${result} cannot be represented exactly`); + } + return narrowed; + } + /** Get the channel data area address. */ private get dataArea(): number { return this.channelOffset + CH_DATA; @@ -715,7 +774,7 @@ export class WasiShim { const { result, errno } = this.doSyscall(SYS_READV, fd, iovsPtr, iovsLen); if (errno) return translateLinuxErrno(errno); const view = new DataView(this.memory.buffer); - view.setUint32(nreadOut, result, true); + view.setUint32(nreadOut, this.syscallResultNumber(result), true); return WASI_ESUCCESS; } @@ -723,7 +782,7 @@ export class WasiShim { const { result, errno } = this.doSyscall(SYS_WRITEV, fd, iovsPtr, iovsLen); if (errno) return translateLinuxErrno(errno); const view = new DataView(this.memory.buffer); - view.setUint32(nwrittenOut, result, true); + view.setUint32(nwrittenOut, this.syscallResultNumber(result), true); return WASI_ESUCCESS; } @@ -747,14 +806,17 @@ export class WasiShim { totalLen = Math.min(totalLen, CH_DATA_SIZE - 256); const { result, errno } = this.doSyscall( - SYS_PREAD, fd, this.dataArea, totalLen, - Number(offset & 0xFFFFFFFFn), - Number((offset >> 32n) & 0xFFFFFFFFn), + SYS_PREAD, + fd, + this.dataArea, + totalLen, + offset, ); if (errno) return translateLinuxErrno(errno); + const bytesRead = this.syscallResultNumber(result); // Scatter data from data area into iovecs - let remaining = result; + let remaining = bytesRead; let srcOff = 0; for (let i = 0; i < iovsLen && remaining > 0; i++) { const entry = iovsPtr + i * PROCESS_IOVEC_WASM32_SIZE; @@ -772,7 +834,7 @@ export class WasiShim { remaining -= copyLen; } - view.setUint32(nreadOut, result, true); + view.setUint32(nreadOut, bytesRead, true); return WASI_ESUCCESS; } @@ -800,34 +862,42 @@ export class WasiShim { } const { result, errno } = this.doSyscall( - SYS_PWRITE, fd, this.dataArea, totalLen, - Number(offset & 0xFFFFFFFFn), - Number((offset >> 32n) & 0xFFFFFFFFn), + SYS_PWRITE, + fd, + this.dataArea, + totalLen, + offset, ); if (errno) return translateLinuxErrno(errno); - view.setUint32(nwrittenOut, result, true); + view.setUint32(nwrittenOut, this.syscallResultNumber(result), true); return WASI_ESUCCESS; } fd_seek(fd: number, offset: bigint, whence: number, newOffsetOut: number): number { const posixWhence = wasiWhenceToPosix(whence); - // lseek on wasm32: args are fd, offset_lo, offset_hi, whence - // But our SYS_LSEEK takes (fd, offset_lo, offset_hi, result_ptr, whence) - // Actually checking the kernel — it uses a simpler 64-bit lseek - const offsetNum = Number(offset); - const { result, errno } = this.doSyscall(SYS_LSEEK, fd, offsetNum, posixWhence); + if (posixWhence === null) return WASI_EINVAL; + // Unlike pread/pwrite, the Kandelo SYS_LSEEK ABI deliberately carries the + // signed offset as low-u32/high-i32 words: (fd, low, high, whence). + const { low, high } = splitSignedI64Words(offset); + const { result, errno } = this.doSyscall( + SYS_LSEEK, + fd, + low, + high, + posixWhence, + ); if (errno) return translateLinuxErrno(errno); const view = new DataView(this.memory.buffer); - view.setBigUint64(newOffsetOut, BigInt(result), true); + view.setBigUint64(newOffsetOut, result, true); return WASI_ESUCCESS; } fd_tell(fd: number, offsetOut: number): number { - const { result, errno } = this.doSyscall(SYS_LSEEK, fd, 0, SEEK_CUR); + const { result, errno } = this.doSyscall(SYS_LSEEK, fd, 0, 0, SEEK_CUR); if (errno) return translateLinuxErrno(errno); const view = new DataView(this.memory.buffer); - view.setBigUint64(offsetOut, BigInt(result), true); + view.setBigUint64(offsetOut, result, true); return WASI_ESUCCESS; } @@ -853,7 +923,9 @@ export class WasiShim { // Get flags via fcntl const { result: flags, errno: fcntlErr } = this.doSyscall(SYS_FCNTL, fd, F_GETFL); - const fdflags = fcntlErr ? 0 : posixFlagToWasiFdflags(flags); + const fdflags = fcntlErr + ? 0 + : posixFlagToWasiFdflags(this.syscallResultNumber(flags)); // WASI fdstat: filetype(u8) + pad(1) + fdflags(u16) + pad(4) + rights_base(u64) + rights_inheriting(u64) = 24 bytes view.setUint8(fdstatPtr, filetype); @@ -885,7 +957,7 @@ export class WasiShim { } fd_filestat_set_size(fd: number, size: bigint): number { - const { errno } = this.doSyscall(SYS_FTRUNCATE, fd, Number(size)); + const { errno } = this.doSyscall(SYS_FTRUNCATE, fd, size); return errno ? translateLinuxErrno(errno) : WASI_ESUCCESS; } @@ -938,7 +1010,13 @@ export class WasiShim { fd_allocate(fd: number, offset: bigint, len: bigint): number { const { errno } = this.doSyscall( - SYS_FALLOCATE, fd, Number(offset), Number(len), + // Kandelo follows Linux here: (fd, mode, offset, len). WASI only + // exposes allocation mode zero. + SYS_FALLOCATE, + fd, + 0, + offset, + len, ); return errno ? translateLinuxErrno(errno) : WASI_ESUCCESS; } @@ -955,10 +1033,11 @@ export class WasiShim { // Use getdents64 to read directory entries into data area const maxRead = Math.min(CH_DATA_SIZE - 256, 32768); - const { result: bytesRead, errno } = this.doSyscall( + const { result, errno } = this.doSyscall( SYS_GETDENTS64, fd, this.dataArea, maxRead, ); if (errno) return translateLinuxErrno(errno); + const bytesRead = this.syscallResultNumber(result); // Parse Linux dirent64 entries and write WASI dirents // Linux dirent64: d_ino(8) d_off(8) d_reclen(2) d_type(1) d_name(...) @@ -1103,9 +1182,14 @@ export class WasiShim { SYS_READLINKAT, kernelDirfd, pathAddr, resultAddr, maxLen, ); if (errno) return translateLinuxErrno(errno); + const resultLength = this.syscallResultNumber(result); // Copy result to caller's buffer - new Uint8Array(this.memory.buffer).copyWithin(buf, resultAddr, resultAddr + result); - new DataView(this.memory.buffer).setUint32(sizeOut, result, true); + new Uint8Array(this.memory.buffer).copyWithin( + buf, + resultAddr, + resultAddr + resultLength, + ); + new DataView(this.memory.buffer).setUint32(sizeOut, resultLength, true); return WASI_ESUCCESS; } @@ -1153,12 +1237,20 @@ export class WasiShim { posixFlags = (posixFlags & ~3) | O_RDONLY; const retry = this.doSyscall(SYS_OPENAT, kernelDirfd, pathAddr, posixFlags, 0o666); if (retry.errno) return translateLinuxErrno(retry.errno); - new DataView(this.memory.buffer).setUint32(fdOut, retry.result, true); + new DataView(this.memory.buffer).setUint32( + fdOut, + this.syscallResultNumber(retry.result), + true, + ); return WASI_ESUCCESS; } return translateLinuxErrno(errno); } - new DataView(this.memory.buffer).setUint32(fdOut, result, true); + new DataView(this.memory.buffer).setUint32( + fdOut, + this.syscallResultNumber(result), + true, + ); return WASI_ESUCCESS; } @@ -1224,10 +1316,13 @@ export class WasiShim { SYS_GETRANDOM, this.dataArea, chunkSize, 0, ); if (errno) return translateLinuxErrno(errno); + const bytesRead = this.syscallResultNumber(result); new Uint8Array(this.memory.buffer).copyWithin( - buf + offset, this.dataArea, this.dataArea + result, + buf + offset, + this.dataArea, + this.dataArea + bytesRead, ); - offset += result; + offset += bytesRead; } return WASI_ESUCCESS; } @@ -1265,7 +1360,11 @@ export class WasiShim { proc_raise(sig: number): number { // kill(getpid(), sig) const { result: pid } = this.doSyscall(SYS_GETPID); - const { errno } = this.doSyscall(SYS_KILL, pid, sig); + const { errno } = this.doSyscall( + SYS_KILL, + this.syscallResultNumber(pid), + sig, + ); return errno ? translateLinuxErrno(errno) : WASI_ESUCCESS; } @@ -1443,9 +1542,10 @@ export class WasiShim { SYS_RECVFROM, fd, this.dataArea, totalLen, 0, 0, 0, ); if (errno) return translateLinuxErrno(errno); + const bytesRead = this.syscallResultNumber(result); // Scatter into iovecs - let remaining = result; + let remaining = bytesRead; let srcOff = 0; for (let i = 0; i < iovsLen && remaining > 0; i++) { const entry = iovsPtr + i * PROCESS_IOVEC_WASM32_SIZE; @@ -1463,7 +1563,7 @@ export class WasiShim { remaining -= copyLen; } - view.setUint32(roDataLenOut, result, true); + view.setUint32(roDataLenOut, bytesRead, true); view.setUint16(roFlagsOut, 0, true); return WASI_ESUCCESS; } @@ -1497,7 +1597,7 @@ export class WasiShim { ); if (errno) return translateLinuxErrno(errno); - view.setUint32(nwrittenOut, result, true); + view.setUint32(nwrittenOut, this.syscallResultNumber(result), true); return WASI_ESUCCESS; } diff --git a/host/src/worker-main.ts b/host/src/worker-main.ts index f34d212ded..8c1009767a 100644 --- a/host/src/worker-main.ts +++ b/host/src/worker-main.ts @@ -2097,7 +2097,37 @@ export function buildDlopenImports( } /** - * Build import object for a Wasm module, stubbing unresolved imports. + * Reject process artifacts that request a kernel function outside the exact + * channel-mode CRT contract. + * + * WHY: supplying a zero-returning placeholder makes an obsolete or corrupt + * direct-kernel syscall import look like success. It also cannot safely bridge + * the process and kernel address spaces. Fail before instantiation so stale + * artifacts are rebuilt through the supported channel path. + */ +export function assertSupportedKernelFunctionImports( + module: WebAssembly.Module, + kernelImports: Record, +): void { + for (const imp of WebAssembly.Module.imports(module)) { + if ( + imp.kind === "function" + && imp.module === "kernel" + && ( + !Object.hasOwn(kernelImports, imp.name) + || typeof kernelImports[imp.name] !== "function" + ) + ) { + throw new Error( + `Unsupported kernel import kernel.${imp.name}; ` + + "rebuild this program with the current Kandelo SDK", + ); + } + } +} + +/** + * Build the exact import object for a channel-mode Wasm module. */ function buildImportObject( module: WebAssembly.Module, @@ -2117,6 +2147,8 @@ function buildImportObject( ) => void, forkEnvImports?: Record, ): WebAssembly.Imports { + assertSupportedKernelFunctionImports(module, kernelImports); + const envImports: Record = { memory }; /** Convert wasm64 BigInt pointer to number (safe since addresses < 4GB) */ const n = (v: number | bigint): number => @@ -2484,19 +2516,16 @@ function buildImportObject( view.setBigUint64(begin + i * 8, arr[i], true); }; - // Stub any remaining unresolved function imports + // Environment integrations fail at the point of use when the host does not + // implement them. Kernel imports were validated above and are never faked. for (const imp of WebAssembly.Module.imports(module)) { if (imp.kind !== "function") continue; if (imp.module === "env") { - if (!envImports[imp.name]) { + if (!Object.hasOwn(envImports, imp.name)) { envImports[imp.name] = (..._args: unknown[]) => { throw new Error(`Unimplemented import: env.${imp.name}`); }; } - } else if (imp.module === "kernel") { - if (!kernelImports[imp.name]) { - kernelImports[imp.name] = (..._args: unknown[]) => 0; - } } } diff --git a/host/test/abi-version.test.ts b/host/test/abi-version.test.ts index 135d08be81..fad3f33a43 100644 --- a/host/test/abi-version.test.ts +++ b/host/test/abi-version.test.ts @@ -84,6 +84,31 @@ describe("ABI version marker", () => { expect(value).toBeGreaterThan(0); }); + it("built kernel requires paired append and true positioned host I/O imports", async () => { + const module = await WebAssembly.compile(kernelWasm as BufferSource); + const envFunctionImports = new Set( + WebAssembly.Module.imports(module) + .filter((entry) => + entry.module === "env" && entry.kind === "function" + ) + .map((entry) => entry.name), + ); + + // WHY: append returns its exact end through a paired scalar import, while + // seek/read/seek is not pread and rounds wasm64 offsets if the split i64 + // crosses JavaScript's safe-integer boundary. Keep this built-artifact + // guard beside the ABI marker because kernel imports are required host + // capabilities but are not represented in abi/snapshot.json. + expect([...envFunctionImports]).toEqual( + expect.arrayContaining([ + "host_append", + "host_append_position", + "host_pread", + "host_pwrite", + ]), + ); + }); + it("freshly-built user programs export a matching __abi_version", async () => { // Pick a program we know build-programs.sh regenerates every run. const userProg = readFileSync(resolveBinary("programs/exec-caller.wasm")); diff --git a/host/test/advisory-lock-kernel.test.ts b/host/test/advisory-lock-kernel.test.ts index ce0b3b7588..0de0c19af9 100644 --- a/host/test/advisory-lock-kernel.test.ts +++ b/host/test/advisory-lock-kernel.test.ts @@ -14,9 +14,8 @@ import { readFileSync } from "node:fs"; import { resolveBinary } from "../src/binary-resolver"; import { CAPTURED_STDIO, - CentralizedKernelWorker, + createCentralizedKernelWorkerTestDouble, } from "../src/kernel-worker"; -import type { KernelScratchLease } from "../src/kernel-scratch"; import { NodePlatformIO } from "../src/platform/node"; import type { PlatformIO } from "../src/types"; import { MemoryFileSystem } from "../src/vfs/memory-fs"; @@ -30,11 +29,13 @@ import { import { CH_TOTAL_SIZE } from "../src/constants"; import { ABI_SYSCALLS, + CHANNEL_STATUS_PENDING, CH_ARGS, CH_ARG_SIZE, CH_DATA, CH_ERRNO, CH_RETURN, + CH_STATUS, CH_SYSCALL, FCNTL_FLOCK_BYTES, } from "../src/generated/abi"; @@ -62,18 +63,32 @@ interface SyscallResult { errno: number; } -interface ScratchArgument { - readonly scratchOffset: number; +interface ChannelArgument { + readonly channelOffset: number; readonly length: number; } -function scratchArgument( - scratchOffset: number, +function channelArgument( + channelOffset: number, length: number, -): ScratchArgument { - return { scratchOffset, length }; +): ChannelArgument { + return { channelOffset, length }; } +type AdvisoryLockTestWorker = + ReturnType; + +interface ChannelTransferBuffer { + copyFrom(source: Uint8Array, targetOffset: number): void; + fill(value: number, targetOffset: number, length: number): void; + dataView(targetOffset: number, length: number): DataView; +} + +const processMemoryByWorker = new WeakMap< + AdvisoryLockTestWorker, + Map +>(); + function loadKernelWasm(): ArrayBuffer { const bytes = readFileSync(resolveBinary("kernel.wasm")); return bytes.buffer.slice( @@ -96,20 +111,46 @@ function makeProcessMemory(): ProcessMemory { } function register( - worker: CentralizedKernelWorker, + worker: AdvisoryLockTestWorker, ): number { const pid = worker.createProcess(CAPTURED_STDIO); + registerExistingProcess(worker, pid); + return pid; +} + +function registerExistingProcess( + worker: AdvisoryLockTestWorker, + pid: number, +): ProcessMemory { const entry = makeProcessMemory(); worker.registerProcess(pid, entry.memory, [entry.channelOffset], { brkBase: entry.layout.brkBase, mmapBase: entry.layout.mmapBase, maxAddr: entry.layout.maxAddr, }); - return pid; + let processes = processMemoryByWorker.get(worker); + if (processes === undefined) { + processes = new Map(); + processMemoryByWorker.set(worker, processes); + } + processes.set(pid, entry); + return entry; +} + +function assertChannelTransferRange(offset: number, length: number): void { + if ( + !Number.isSafeInteger(offset) + || offset < 0 + || !Number.isSafeInteger(length) + || length < 0 + || offset > CH_TOTAL_SIZE - length + ) { + throw new RangeError("test channel transfer is outside its allocation"); + } } function issue( - worker: CentralizedKernelWorker, + worker: AdvisoryLockTestWorker, pid: number, syscall: number, args: Array, @@ -118,59 +159,89 @@ function issue( } function issuePrepared( - worker: CentralizedKernelWorker, + worker: AdvisoryLockTestWorker, pid: number, syscall: number, prepareArgs: ( - lease: KernelScratchLease, - ) => Array, + transfer: ChannelTransferBuffer, + ) => Array, ): SyscallResult { - const setCurrentTid = (worker as any).kernelInstance.exports - .kernel_set_current_tid as (pid: number, tid: number) => number; - expect(setCurrentTid(pid, pid)).toBe(0); - return (worker as any).scratchRegion.withLease( - (lease: KernelScratchLease) => { - const args = prepareArgs(lease); - const channel = lease.dataView(0, CH_TOTAL_SIZE); - channel.setUint32(CH_SYSCALL, syscall, true); - channel.setUint32(CH_ERRNO, 0, true); - channel.setBigInt64(CH_RETURN, 0n, true); - for (let index = 0; index < 6; index++) { - const argument = args[index] ?? 0; - if (typeof argument === "object") { - channel.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, 0n, true); - lease.writeAddress( - CH_ARGS + index * CH_ARG_SIZE, - argument.scratchOffset, + const entry = processMemoryByWorker.get(worker)?.get(pid); + if (entry === undefined) { + throw new Error(`process ${pid} has no test-owned Memory`); + } + const channelBytes = new Uint8Array( + entry.memory.buffer, + entry.channelOffset, + CH_TOTAL_SIZE, + ); + channelBytes.fill(0); + const transfer: ChannelTransferBuffer = { + copyFrom(source, targetOffset): void { + assertChannelTransferRange(targetOffset, source.byteLength); + channelBytes.set(source, targetOffset); + }, + fill(value, targetOffset, length): void { + assertChannelTransferRange(targetOffset, length); + channelBytes.fill(value, targetOffset, targetOffset + length); + }, + dataView(targetOffset, length): DataView { + assertChannelTransferRange(targetOffset, length); + return new DataView( + entry.memory.buffer, + entry.channelOffset + targetOffset, + length, + ); + }, + }; + const args = prepareArgs(transfer); + const channel = new DataView( + entry.memory.buffer, + entry.channelOffset, + CH_TOTAL_SIZE, + ); + channel.setUint32(CH_SYSCALL, syscall, true); + channel.setUint32(CH_ERRNO, 0, true); + channel.setBigInt64(CH_RETURN, 0n, true); + for (let index = 0; index < 6; index++) { + const argument = args[index] ?? 0; + const value = typeof argument === "object" + ? (() => { + assertChannelTransferRange( + argument.channelOffset, argument.length, - (worker as any).kernel.getKernelPtrWidth() === 8 - ? "u64-le" - : "u32-to-u64-le", ); - continue; - } - channel.setBigInt64( - CH_ARGS + index * CH_ARG_SIZE, - BigInt(argument), - true, - ); - } - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - pid, - ]); - const result = lease.dataView(0, CH_TOTAL_SIZE); - return { - value: Number(result.getBigInt64(CH_RETURN, true)), - errno: result.getUint32(CH_ERRNO, true), - }; - }, + return entry.channelOffset + argument.channelOffset; + })() + : argument; + channel.setBigInt64( + CH_ARGS + index * CH_ARG_SIZE, + BigInt(value), + true, + ); + } + // Publish the complete caller-owned request last. The exact test companion + // accepts only this registered main mailbox in PENDING state and never + // returns the channel or underlying kernel authority. + Atomics.store( + new Int32Array(entry.memory.buffer, entry.channelOffset), + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + CHANNEL_STATUS_PENDING, ); + worker.testAuthority.dispatchRegisteredMainChannelForAdvisoryLockTest(pid); + const result = new DataView( + entry.memory.buffer, + entry.channelOffset, + CH_TOTAL_SIZE, + ); + return { + value: Number(result.getBigInt64(CH_RETURN, true)), + errno: result.getUint32(CH_ERRNO, true), + }; } function openFile( - worker: CentralizedKernelWorker, + worker: AdvisoryLockTestWorker, pid: number, path: string, ): number { @@ -179,10 +250,10 @@ function openFile( worker, pid, ABI_SYSCALLS.Open, - (lease) => { - lease.copyFrom(encoded, CH_DATA); + (transfer) => { + transfer.copyFrom(encoded, CH_DATA); return [ - scratchArgument(CH_DATA, encoded.byteLength), + channelArgument(CH_DATA, encoded.byteLength), O_RDWR, 0, ]; @@ -194,7 +265,7 @@ function openFile( } function closeFile( - worker: CentralizedKernelWorker, + worker: AdvisoryLockTestWorker, pid: number, fd: number, ): void { @@ -205,7 +276,7 @@ function closeFile( } function lock( - worker: CentralizedKernelWorker, + worker: AdvisoryLockTestWorker, pid: number, fd: number, start: bigint, @@ -217,26 +288,30 @@ function lock( worker, pid, ABI_SYSCALLS.Fcntl, - (lease) => { - lease.fill(0, CH_DATA, FCNTL_FLOCK_BYTES); - const flock = lease.dataView(CH_DATA, FCNTL_FLOCK_BYTES); + (transfer) => { + transfer.fill(0, CH_DATA, FCNTL_FLOCK_BYTES); + const flock = transfer.dataView(CH_DATA, FCNTL_FLOCK_BYTES); flock.setInt16(0, type, true); flock.setInt16(2, 0, true); // SEEK_SET flock.setBigInt64(8, start, true); flock.setBigInt64(16, len, true); // l_pid remains zero, as required for F_OFD_* commands. - return [fd, command, scratchArgument(CH_DATA, FCNTL_FLOCK_BYTES)]; + return [fd, command, channelArgument(CH_DATA, FCNTL_FLOCK_BYTES)]; }, ); } async function makeWorker( platform: PlatformIO = new NodePlatformIO(), -): Promise { - const worker = new CentralizedKernelWorker( - { maxWorkers: 4, dataBufferSize: 65_536, useSharedMemory: true }, - platform, - ); +): Promise { + const worker = createCentralizedKernelWorkerTestDouble({ + config: { + maxWorkers: 4, + dataBufferSize: 65_536, + useSharedMemory: true, + }, + io: platform, + }); await worker.init(loadKernelWasm()); return worker; } @@ -376,6 +451,7 @@ describe("Rust advisory locks through the real kernel Wasm", () => { const worker = await makeWorker(); const parentPid = register(worker); const peerPid = register(worker); + let childPid: number | undefined; try { const parentFd = openFile(worker, parentPid, path); @@ -385,10 +461,12 @@ describe("Rust advisory locks through the real kernel Wasm", () => { errno: 0, }); - const forkProcess = (worker as any).kernelInstance.exports - .kernel_fork_process as (parent: number, callerTid: number) => number; - const childPid = forkProcess(parentPid, parentPid); + childPid = worker.testAuthority.forkKernelProcessForAdvisoryLockTest( + parentPid, + parentPid, + ); expect(childPid).toBeGreaterThan(0); + registerExistingProcess(worker, childPid); expect(lock(worker, peerPid, peerFd, 0n, 1n)).toEqual({ value: -1, errno: EAGAIN, @@ -404,10 +482,8 @@ describe("Rust advisory locks through the real kernel Wasm", () => { closeFile(worker, childPid, parentFd); closeFile(worker, peerPid, peerFd); - const removeProcess = (worker as any).kernelInstance.exports - .kernel_remove_process as (pid: number) => number; - expect(removeProcess(childPid)).toBe(0); } finally { + if (childPid !== undefined) worker.unregisterProcess(childPid); worker.unregisterProcess(parentPid); worker.unregisterProcess(peerPid); rmSync(root, { recursive: true, force: true }); @@ -421,6 +497,7 @@ describe("Rust advisory locks through the real kernel Wasm", () => { const worker = await makeWorker(); const ownerPid = register(worker); const peerPid = register(worker); + let childPid: number | undefined; try { const ownerFd = openFile(worker, ownerPid, path); @@ -433,10 +510,12 @@ describe("Rust advisory locks through the real kernel Wasm", () => { expect(lock(worker, peerPid, peerFd, 0n, 1n, F_WRLCK, F_OFD_SETLK)) .toEqual({ value: -1, errno: EAGAIN }); - const forkProcess = (worker as any).kernelInstance.exports - .kernel_fork_process as (parent: number, callerTid: number) => number; - const childPid = forkProcess(ownerPid, ownerPid); + childPid = worker.testAuthority.forkKernelProcessForAdvisoryLockTest( + ownerPid, + ownerPid, + ); expect(childPid).toBeGreaterThan(0); + registerExistingProcess(worker, childPid); closeFile(worker, ownerPid, ownerFd); closeFile(worker, ownerPid, duplicate.value); @@ -450,10 +529,8 @@ describe("Rust advisory locks through the real kernel Wasm", () => { .toEqual({ value: 0, errno: 0 }); closeFile(worker, peerPid, peerFd); - const removeProcess = (worker as any).kernelInstance.exports - .kernel_remove_process as (pid: number) => number; - expect(removeProcess(childPid)).toBe(0); } finally { + if (childPid !== undefined) worker.unregisterProcess(childPid); worker.unregisterProcess(ownerPid); worker.unregisterProcess(peerPid); rmSync(root, { recursive: true, force: true }); diff --git a/host/test/advisory-lock-retry.test.ts b/host/test/advisory-lock-retry.test.ts index 392087ad6e..6596bcd6bd 100644 --- a/host/test/advisory-lock-retry.test.ts +++ b/host/test/advisory-lock-retry.test.ts @@ -1,12 +1,25 @@ import { describe, expect, it, vi } from "vitest"; import { ABI_SYSCALLS, + CHANNEL_REQUEST_FLAG_CANCELLATION_POINT, + CHANNEL_REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED, + CHANNEL_STATUS_PENDING, + CH_ARGS, + CH_ARGS_COUNT, + CH_ARG_SIZE, CH_ERRNO, + CH_REQUEST_FLAGS, CH_RETURN, + CH_STATUS, + CH_SYSCALL, + CH_TOTAL_SIZE, PROCESS_STATE_EXITED, PROCESS_STATE_RUNNING, } from "../src/generated/abi"; -import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { KernelReentrantEntryError } from "../src/kernel-entry-gate"; +import { + createCentralizedKernelWorkerTestDouble, +} from "../src/kernel-worker"; import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; const EAGAIN = 11; @@ -14,7 +27,6 @@ const EINTR = 4; const ENOLCK = 37; const F_SETLKW = 7; const FLOCK_PTR = 512; -const SYS_FLOCK = 121; const LOCK_EX = 2; const LOCK_NB = 4; const WAKE_ADVISORY_LOCK = 64; @@ -22,20 +34,26 @@ const WAKE_ADVISORY_LOCK = 64; describe("Rust-owned advisory-lock retry scheduling", () => { it("parks only a conflicting blocking request, not ENOLCK", () => { const conflict = createFcntlHarness(EAGAIN); - conflict.worker.handleFcntlLock(conflict.channel, [3, F_SETLKW, FLOCK_PTR, 0, 0, 0]); + conflict.worker.testAuthority.dispatchScratchBoundarySyscallForTest( + conflict.channel, + ); - const parked = conflict.worker.pendingAdvisoryLockRetries.get(conflict.channel); + const parked = mutableState(conflict.worker) + .pendingAdvisoryLockRetries.get(conflict.channel); expect(parked).toBeDefined(); - expect(conflict.worker.completeChannel).not.toHaveBeenCalled(); - clearTimeout(parked.timer); + expect(conflict.completeChannel).not.toHaveBeenCalled(); + clearTimeout(parked!.timer); const exhausted = createFcntlHarness(ENOLCK); - exhausted.worker.handleFcntlLock(exhausted.channel, [3, F_SETLKW, FLOCK_PTR, 0, 0, 0]); + exhausted.worker.testAuthority.dispatchScratchBoundarySyscallForTest( + exhausted.channel, + ); - expect(exhausted.worker.pendingAdvisoryLockRetries.size).toBe(0); - expect(exhausted.worker.completeChannel).toHaveBeenCalledWith( + expect(mutableState(exhausted.worker).pendingAdvisoryLockRetries.size) + .toBe(0); + expect(exhausted.completeChannel).toHaveBeenCalledWith( exhausted.channel, - expect.any(Number), + ABI_SYSCALLS.Fcntl, [3, F_SETLKW, FLOCK_PTR, 0, 0, 0], undefined, -1, @@ -47,12 +65,15 @@ describe("Rust-owned advisory-lock retry scheduling", () => { const interrupted = createFcntlHarness(EAGAIN, 10); const args = [3, F_SETLKW, FLOCK_PTR, 0, 0, 0]; - interrupted.worker.handleFcntlLock(interrupted.channel, args); + interrupted.worker.testAuthority.dispatchScratchBoundarySyscallForTest( + interrupted.channel, + ); - expect(interrupted.worker.pendingAdvisoryLockRetries.size).toBe(0); - expect(interrupted.worker.completeChannel).toHaveBeenCalledWith( + expect(mutableState(interrupted.worker).pendingAdvisoryLockRetries.size) + .toBe(0); + expect(interrupted.completeChannel).toHaveBeenCalledWith( interrupted.channel, - expect.any(Number), + ABI_SYSCALLS.Fcntl, args, undefined, -1, @@ -70,137 +91,172 @@ describe("Rust-owned advisory-lock retry scheduling", () => { view.setUint8(outPtr + 4, WAKE_ADVISORY_LOCK); return 1; }); - const worker = createWorker({ kernel_drain_wakeup_events: drain }); - worker.kernelMemory = kernelMemory; - installKernelWorkerTestScratch(worker, kernelMemory); - worker.processes = new Map([[channel.pid, { + const worker = createWorker( + { kernel_drain_wakeup_events: drain }, + kernelMemory, + ); + const state = mutableState(worker); + state.processes = new Map([[channel.pid, { channels: [channel], memory: processMemory, + ptrWidth: 4, }]]); - worker.pendingAdvisoryLockRetries = new Map(); - worker.scheduleWakeBlockedRetries = vi.fn(); - worker.retrySyscall = vi.fn(); + state.pendingAdvisoryLockRetries = new Map(); + const scheduleWakeBlockedRetries = vi.fn(); + const retrySyscall = vi.fn(); + worker.testAuthority.configureScratchBoundaryHooksForTest({ + scheduleWakeBlockedRetries, + retrySyscall, + }); const timer = setTimeout(() => undefined, 1_000); - worker.pendingAdvisoryLockRetries.set(channel, { timer, channel }); + state.pendingAdvisoryLockRetries.set(channel, { + cancellationPoint: false, + cancellationWakeAllowed: false, + timer, + channel, + }); - worker.drainAndProcessWakeupEvents(); + worker.testAuthority.drainWakeupEventsForTest(); - expect(worker.retrySyscall).toHaveBeenCalledOnce(); - expect(worker.retrySyscall).toHaveBeenCalledWith(channel); - expect(worker.pendingAdvisoryLockRetries.size).toBe(0); - expect(worker.scheduleWakeBlockedRetries).not.toHaveBeenCalled(); + expect(retrySyscall).toHaveBeenCalledOnce(); + expect(retrySyscall).toHaveBeenCalledWith(channel); + expect(state.pendingAdvisoryLockRetries.size).toBe(0); + expect(scheduleWakeBlockedRetries).not.toHaveBeenCalled(); }); it("retires a parked lock request when its exact channel is removed", () => { const memory = createSharedMemory(); const channel = createChannel(12, memory); const worker = createWorker({}); - worker.waitingForChild = []; - worker.pendingAdvisoryLockRetries = new Map(); + const state = mutableState(worker); + state.processes = new Map([[channel.pid, { + channels: [channel], + memory, + ptrWidth: 4, + }]]); + state.activeChannels = [channel]; + state.waitingForChild = []; + state.pendingAdvisoryLockRetries = new Map(); const timer = setTimeout(() => undefined, 1_000); - worker.pendingAdvisoryLockRetries.set(channel, { timer, channel }); + state.pendingAdvisoryLockRetries.set(channel, { + cancellationPoint: false, + cancellationWakeAllowed: false, + timer, + channel, + }); - worker.retireExactChannelAsyncState(channel); + worker.removeChannel(channel.pid, channel.channelOffset); - expect(worker.pendingAdvisoryLockRetries.size).toBe(0); + expect(state.pendingAdvisoryLockRetries.size).toBe(0); }); it("drains Rust lock wakes after direct process removal", () => { const remove = vi.fn(() => 0); - const worker = createWorker({ kernel_remove_process: remove }); - worker.drainAndProcessWakeupEvents = vi.fn(); + const drain = vi.fn(() => 0); + const worker = createWorker({ + kernel_remove_process: remove, + kernel_drain_wakeup_events: drain, + }); worker.removeFromKernelProcessTable(12); expect(remove).toHaveBeenCalledWith(12); - expect(worker.drainAndProcessWakeupEvents).toHaveBeenCalledOnce(); + expect(drain).toHaveBeenCalledOnce(); expect(remove.mock.invocationCallOrder[0]).toBeLessThan( - worker.drainAndProcessWakeupEvents.mock.invocationCallOrder[0], + drain.mock.invocationCallOrder[0], ); }); it("drains Rust lock wakes after normal process exit", () => { const memory = createSharedMemory(); const channel = createChannel(18, memory); - const handleChannel = vi.fn(() => { - throw new WebAssembly.RuntimeError("unreachable"); - }); + const commitExit = vi.fn(() => 0); + const drain = vi.fn(() => 0); const worker = createWorker({ - kernel_handle_channel: handleChannel, + kernel_commit_process_exit: commitExit, + kernel_drain_wakeup_events: drain, kernel_get_process_state: vi.fn(() => PROCESS_STATE_EXITED), }); - worker.kernelMemory = createSharedMemory(); - worker.processes = new Map([[channel.pid, { channels: [channel], memory }]]); - worker.releaseAllSharedMemoryForProcess = vi.fn(); - worker.getProcessExitSignal = vi.fn(() => -1); - worker.discardStoppedChannelStateForProcess = vi.fn(); - worker.drainAndProcessWakeupEvents = vi.fn(); - worker.notifyParentOfExitedProcess = vi.fn(); - worker.completeChannelRaw = vi.fn(); - worker.scheduleWakeBlockedRetries = vi.fn(); - worker.callbacks = {}; - - worker.handleExit(channel, ABI_SYSCALLS.ExitGroup, [0]); - - expect(worker.drainAndProcessWakeupEvents).toHaveBeenCalledOnce(); - expect(handleChannel.mock.invocationCallOrder[0]).toBeLessThan( - worker.drainAndProcessWakeupEvents.mock.invocationCallOrder[0], + const state = mutableState(worker); + state.processes = new Map([[channel.pid, { + channels: [channel], + memory, + ptrWidth: 4, + }]]); + state.activeChannels = [channel]; + const completeChannelRaw = vi.fn(); + worker.testAuthority.configureScratchBoundaryHooksForTest({ + completeChannelRaw, + relistenChannel: vi.fn(), + scheduleWakeBlockedRetries: vi.fn(), + }); + setChannelSyscall(channel, ABI_SYSCALLS.ExitGroup, [0]); + + worker.testAuthority.dispatchScratchBoundarySyscallForTest(channel); + + expect(completeChannelRaw).toHaveBeenCalledWith(channel, 0, 0); + expect(drain).toHaveBeenCalledOnce(); + expect(commitExit.mock.invocationCallOrder[0]).toBeLessThan( + drain.mock.invocationCallOrder[0], ); }); it("drains Rust lock wakes before signal-termination notifications", () => { const memory = createSharedMemory(); const channel = createChannel(20, memory); + const onExit = vi.fn(); + let state!: ReturnType; + const sharedMappingsPresentAtDrain: boolean[] = []; + const drain = vi.fn(() => { + sharedMappingsPresentAtDrain.push(state.sharedMappings.has(channel.pid)); + return 0; + }); const worker = createWorker({ + kernel_drain_wakeup_events: drain, + kernel_handle_channel: vi.fn(() => 0), kernel_get_process_exit_signal: vi.fn(() => 9), - }); - worker.processes = new Map([[channel.pid, { channels: [channel], memory }]]); - worker.discardStoppedChannelStateForProcess = vi.fn(); - worker.releaseAllSharedMemoryForProcess = vi.fn(); - worker.drainAndProcessWakeupEvents = vi.fn(); - worker.notifyParentOfExitedProcess = vi.fn(); - worker.callbacks = { onExit: vi.fn() }; - - worker.handleProcessTerminated(channel); - - expect(worker.drainAndProcessWakeupEvents).toHaveBeenCalledOnce(); - expect(worker.notifyParentOfExitedProcess).toHaveBeenCalledWith(channel.pid); - expect(worker.callbacks.onExit).toHaveBeenCalledWith(channel.pid, 137); - expect( - worker.releaseAllSharedMemoryForProcess.mock.invocationCallOrder[0], - ).toBeLessThan( - worker.drainAndProcessWakeupEvents.mock.invocationCallOrder[0], - ); - expect( - worker.drainAndProcessWakeupEvents.mock.invocationCallOrder[0], - ).toBeLessThan( - worker.notifyParentOfExitedProcess.mock.invocationCallOrder[0], + }, undefined, { onExit }); + state = mutableState(worker); + state.processes = new Map([[channel.pid, { + channels: [channel], + memory, + ptrWidth: 4, + }]]); + state.activeChannels = [channel]; + // An empty real mapping set is enough to observe the exact release point: + // releaseAllSharedMemoryForProcess removes the pid before its second drain. + state.sharedMappings = new Map([[channel.pid, new Map()]]); + + worker.testAuthority.sendSignalForTest(channel.pid, 9); + + expect(sharedMappingsPresentAtDrain).toEqual([true, false]); + expect(state.sharedMappings.has(channel.pid)).toBe(false); + expect(onExit).toHaveBeenCalledWith(channel.pid, 137); + expect(drain.mock.invocationCallOrder[1]).toBeLessThan( + onExit.mock.invocationCallOrder[0], ); - expect( - worker.drainAndProcessWakeupEvents.mock.invocationCallOrder[0], - ).toBeLessThan(worker.callbacks.onExit.mock.invocationCallOrder[0]); }); it("drains Rust lock wakes after both exec cleanup phases", () => { const prepare = vi.fn(() => -5); const setup = vi.fn(() => -5); + const drain = vi.fn(() => 0); const worker = createWorker({ + kernel_drain_wakeup_events: drain, kernel_exec_prepare: prepare, kernel_exec_setup_for_thread: setup, }); - worker.drainAndProcessWakeupEvents = vi.fn(); - worker.snapshotExecTcpListenerWakeIds = vi.fn(() => new Map()); expect(worker.kernelExecPrepare(19, 19)).toBe(-5); expect(worker.kernelExecSetup(19, 19)).toBe(-5); - expect(worker.drainAndProcessWakeupEvents).toHaveBeenCalledTimes(2); + expect(drain).toHaveBeenCalledTimes(2); expect(prepare.mock.invocationCallOrder[0]).toBeLessThan( - worker.drainAndProcessWakeupEvents.mock.invocationCallOrder[0], + drain.mock.invocationCallOrder[0], ); expect(setup.mock.invocationCallOrder[0]).toBeLessThan( - worker.drainAndProcessWakeupEvents.mock.invocationCallOrder[1], + drain.mock.invocationCallOrder[1], ); }); @@ -209,78 +265,103 @@ describe("Rust-owned advisory-lock retry scheduling", () => { const oldChannel = createChannel(16, memory); const peerChannel = createChannel(17, memory); const worker = createWorker({}); - worker.processes = new Map([[16, { channels: [oldChannel], memory }]]); - worker.activeChannels = [oldChannel, peerChannel]; - worker.pendingAdvisoryLockRetries = new Map(); + const state = mutableState(worker); + state.processes = new Map([[16, { + channels: [oldChannel], + memory, + ptrWidth: 4, + }]]); + state.activeChannels = [oldChannel, peerChannel]; + state.pendingAdvisoryLockRetries = new Map(); const oldTimer = setTimeout(() => undefined, 1_000); const peerTimer = setTimeout(() => undefined, 1_000); - worker.pendingAdvisoryLockRetries.set(oldChannel, { + state.pendingAdvisoryLockRetries.set(oldChannel, { + cancellationPoint: false, + cancellationWakeAllowed: false, timer: oldTimer, channel: oldChannel, }); - worker.pendingAdvisoryLockRetries.set(peerChannel, { + state.pendingAdvisoryLockRetries.set(peerChannel, { + cancellationPoint: false, + cancellationWakeAllowed: false, timer: peerTimer, channel: peerChannel, }); - worker.discardStoppedChannelStateForProcess = vi.fn(); - worker.cleanupPendingPollRetries = vi.fn(); - worker.cleanupPendingSelectRetries = vi.fn(); - worker.cleanupPendingSignalWaits = vi.fn(); - worker.cleanupPendingPipeReaders = vi.fn(); - worker.cleanupPendingPipeWriters = vi.fn(); - worker.waitingForChild = []; - worker.cancelPendingSleepsForProcess = vi.fn(); - worker.pendingFutexWaits = new Map(); - worker.pendingCancels = new Set(); - worker.threadForkContexts = new Map(); - worker.threadCtidPtrs = new Map(); - worker.posixTimers = new Map(); - worker.socketTimeoutTimers = new Map(); + state.waitingForChild = []; + state.pendingFutexWaits = new Map(); + state.pendingCancels = new Set(); + state.threadForkContexts = new Map(); + state.threadCtidPtrs = new Map(); + state.posixTimers = new Map(); + state.socketTimeoutTimers = new Map(); worker.prepareProcessForExec(16); - expect(worker.pendingAdvisoryLockRetries.has(oldChannel)).toBe(false); - expect(worker.pendingAdvisoryLockRetries.has(peerChannel)).toBe(true); - expect(worker.activeChannels).toEqual([peerChannel]); - expect(worker.processes.get(16).channels).toEqual([]); + expect(state.pendingAdvisoryLockRetries.has(oldChannel)).toBe(false); + expect(state.pendingAdvisoryLockRetries.has(peerChannel)).toBe(true); + expect(state.activeChannels).toEqual([peerChannel]); + expect(state.processes.get(16)!.channels).toEqual([]); clearTimeout(peerTimer); }); it("interrupts a parked lock request at a thread cancellation point", () => { - const memory = createSharedMemory(); - const caller = createChannel(13, memory); - const target = createChannel(13, memory, 256); - const worker = createWorker({}); - worker.processes = new Map([[13, { - channels: [caller, target], - memory, - }]]); - worker.channelTids = new Map([["13:256", 99]]); - worker.pendingCancels = new Set(); - worker.pendingFutexWaits = new Map(); - worker.pendingPollRetries = new Map(); - worker.pendingAdvisoryLockRetries = new Map(); - worker.pendingSelectRetries = new Map(); - worker.pendingPipeReaders = new Map(); - worker.pendingPipeWriters = new Map(); - worker.waitingForChild = []; - worker.runSyntheticMemorySyscall = vi.fn(() => ({ retVal: 0, errVal: 0 })); - worker.completeChannelRaw = vi.fn(); - worker.relistenChannel = vi.fn(); - const timer = setTimeout(() => undefined, 1_000); - worker.pendingAdvisoryLockRetries.set(target, { timer, channel: target }); - - worker.handleThreadCancel(caller, [99]); + const { + caller, + relistenChannel, + state, + syntheticCalls, + target, + worker, + } = createParkedAdvisoryCancellationHarness(true); + expect(state.pendingAdvisoryLockRetries.get(target)).toMatchObject({ + cancellationPoint: true, + cancellationWakeAllowed: true, + }); + setChannelSyscall(caller, ABI_SYSCALLS.ThreadCancel, [99]); + + worker.testAuthority.dispatchScratchBoundarySyscallForTest(caller); + + expect(syntheticCalls).toEqual([{ + syscall: ABI_SYSCALLS.ThreadCancel, + args: [99, 0, 0, 0, 0, 0], + }]); + expect(state.pendingAdvisoryLockRetries.size).toBe(0); + expect(readChannelResult(caller)).toEqual({ retVal: 0n, errVal: 0 }); + expect(readChannelResult(target)).toEqual({ retVal: -4n, errVal: 4 }); + expect(relistenChannel).toHaveBeenCalledWith(caller); + expect(relistenChannel).toHaveBeenCalledWith(target); + }); - expect(worker.runSyntheticMemorySyscall).toHaveBeenCalledWith( + it("keeps a cancellation-disabled advisory lock request parked", () => { + const { caller, - ABI_SYSCALLS.ThreadCancel, - [99], - ); - expect(worker.pendingAdvisoryLockRetries.size).toBe(0); - expect(worker.completeChannelRaw).toHaveBeenNthCalledWith(1, caller, 0, 0); - expect(worker.completeChannelRaw).toHaveBeenNthCalledWith(2, target, -4, 4); - expect(worker.relistenChannel).toHaveBeenCalledWith(target); + relistenChannel, + state, + syntheticCalls, + target, + worker, + } = createParkedAdvisoryCancellationHarness(false); + const parked = state.pendingAdvisoryLockRetries.get(target); + expect(parked).toMatchObject({ + cancellationPoint: true, + cancellationWakeAllowed: false, + }); + setChannelSyscall(caller, ABI_SYSCALLS.ThreadCancel, [99]); + + worker.testAuthority.dispatchScratchBoundarySyscallForTest(caller); + + expect(syntheticCalls).toEqual([]); + expect(state.pendingAdvisoryLockRetries.get(target)).toBe(parked); + expect(state.pendingCancels.has(target)).toBe(true); + expect(readChannelResult(caller)).toEqual({ retVal: 0n, errVal: 0 }); + expect(Atomics.load( + target.i32View, + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + )).toBe(CHANNEL_STATUS_PENDING); + expect(relistenChannel).toHaveBeenCalledOnce(); + expect(relistenChannel).toHaveBeenCalledWith(caller); + expect(relistenChannel).not.toHaveBeenCalledWith(target); + clearTimeout(parked!.timer); }); it("retries a parked lock request so Rust can observe a pending signal", () => { @@ -290,97 +371,189 @@ describe("Rust-owned advisory-lock retry scheduling", () => { kernel_pick_signal_target_tid: vi.fn(() => 14), kernel_thread_has_deliverable: vi.fn(() => 1), }); - worker.kernelMemory = createSharedMemory(); - worker.processes = new Map([[14, { channels: [channel], memory }]]); - worker.pendingSleeps = new Map(); - worker.pendingPollRetries = new Map(); - worker.pendingAdvisoryLockRetries = new Map(); - worker.pendingSelectRetries = new Map(); - worker.drainAndProcessWakeupEvents = vi.fn(); - worker.reapKilledProcessesAfterSyscall = vi.fn(); - worker.getProcessExitSignal = vi.fn(() => -1); - worker.interruptWaitingChildForSignal = vi.fn(() => false); - worker.retrySyscall = vi.fn(); + const state = mutableState(worker); + state.processes = new Map([[14, { + channels: [channel], + memory, + ptrWidth: 4, + }]]); + state.activeChannels = [channel]; + state.pendingSleeps = new Map(); + state.pendingPollRetries = new Map(); + state.pendingAdvisoryLockRetries = new Map(); + state.pendingSelectRetries = new Map(); + const retrySyscall = vi.fn(); + worker.testAuthority.configureScratchBoundaryHooksForTest({ + retrySyscall, + }); const timer = setTimeout(() => undefined, 1_000); - worker.pendingAdvisoryLockRetries.set(channel, { timer, channel }); + state.pendingAdvisoryLockRetries.set(channel, { + cancellationPoint: false, + cancellationWakeAllowed: false, + timer, + channel, + }); - worker.sendSignalToProcess(14, 10, false); + worker.testAuthority.sendSignalForTest(14, 10, false); - expect(worker.pendingAdvisoryLockRetries.size).toBe(0); - expect(worker.retrySyscall).toHaveBeenCalledWith(channel); + expect(state.pendingAdvisoryLockRetries.size).toBe(0); + expect(retrySyscall).toHaveBeenCalledWith(channel); }); it("parks blocking flock but completes LOCK_NB conflicts immediately", () => { const memory = createSharedMemory(); const channel = createChannel(15, memory); - const worker = createWorker({}); - worker.processes = new Map([[channel.pid, { channels: [channel], memory }]]); - worker.pendingAdvisoryLockRetries = new Map(); - worker.completeChannel = vi.fn(); - - expect( - worker.handleFlockConflict( - channel, - SYS_FLOCK, - [3, LOCK_EX, 0, 0, 0, 0], - -1, - EAGAIN, - 0, - ), - ).toBe(true); - const parked = worker.pendingAdvisoryLockRetries.get(channel); + const kernelMemory = createSharedMemory(); + let deliveredSignal = 0; + const worker = createWorker({ + kernel_dequeue_signal: vi.fn(() => deliveredSignal), + kernel_handle_channel: vi.fn((pointer: number) => { + const view = new DataView(kernelMemory.buffer, pointer); + view.setBigInt64(CH_RETURN, -1n, true); + view.setUint32(CH_ERRNO, EAGAIN, true); + return 0; + }), + }, kernelMemory); + const state = mutableState(worker); + state.processes = new Map([[channel.pid, { + channels: [channel], + memory, + ptrWidth: 4, + }]]); + state.activeChannels = [channel]; + state.pendingAdvisoryLockRetries = new Map(); + const completeChannel = vi.fn(); + worker.testAuthority.configureScratchBoundaryHooksForTest({ + completeChannel, + }); + + setChannelSyscall( + channel, + ABI_SYSCALLS.Flock, + [3, LOCK_EX, 0, 0, 0, 0], + ); + worker.testAuthority.dispatchScratchBoundarySyscallForTest(channel); + const parked = state.pendingAdvisoryLockRetries.get(channel); expect(parked).toBeDefined(); - expect(worker.completeChannel).not.toHaveBeenCalled(); - clearTimeout(parked.timer); - worker.pendingAdvisoryLockRetries.clear(); - - expect( - worker.handleFlockConflict( - channel, - SYS_FLOCK, - [3, LOCK_EX | LOCK_NB, 0, 0, 0, 0], - -1, - EAGAIN, - 0, - ), - ).toBe(true); - expect(worker.pendingAdvisoryLockRetries.size).toBe(0); - expect(worker.completeChannel).toHaveBeenCalledWith( + expect(completeChannel).not.toHaveBeenCalled(); + clearTimeout(parked!.timer); + state.pendingAdvisoryLockRetries.clear(); + + setChannelSyscall( channel, - SYS_FLOCK, + ABI_SYSCALLS.Flock, + [3, LOCK_EX | LOCK_NB, 0, 0, 0, 0], + ); + worker.testAuthority.dispatchScratchBoundarySyscallForTest(channel); + expect(state.pendingAdvisoryLockRetries.size).toBe(0); + expect(completeChannel).toHaveBeenCalledWith( + channel, + ABI_SYSCALLS.Flock, [3, LOCK_EX | LOCK_NB, 0, 0, 0, 0], undefined, -1, EAGAIN, ); - worker.completeChannel.mockClear(); - expect( - worker.handleFlockConflict( - channel, - SYS_FLOCK, - [3, LOCK_EX, 0, 0, 0, 0], - -1, - EAGAIN, - 10, - ), - ).toBe(true); - expect(worker.pendingAdvisoryLockRetries.size).toBe(0); - expect(worker.completeChannel).toHaveBeenCalledWith( + completeChannel.mockClear(); + deliveredSignal = 10; + setChannelSyscall( + channel, + ABI_SYSCALLS.Flock, + [3, LOCK_EX, 0, 0, 0, 0], + ); + worker.testAuthority.dispatchScratchBoundarySyscallForTest(channel); + expect(state.pendingAdvisoryLockRetries.size).toBe(0); + expect(completeChannel).toHaveBeenCalledWith( channel, - SYS_FLOCK, + ABI_SYSCALLS.Flock, [3, LOCK_EX, 0, 0, 0, 0], undefined, -1, EINTR, ); }); + + it("rejects busy advisory test operations without running them later", async () => { + const kernelMemory = createSharedMemory(); + const processMemory = createSharedMemory(); + const channel = createChannel(23, processMemory); + const forkProcess = vi.fn(() => 24); + let worker!: TestWorker; + let dispatchError: unknown; + let forkError: unknown; + let handleCalls = 0; + const handleChannel = vi.fn((pointer: number) => { + handleCalls += 1; + if (handleCalls === 1) { + try { + worker.testAuthority + .dispatchRegisteredMainChannelForAdvisoryLockTest(channel.pid); + } catch (error) { + dispatchError = error; + } + try { + worker.testAuthority.forkKernelProcessForAdvisoryLockTest( + channel.pid, + channel.pid, + ); + } catch (error) { + forkError = error; + } + } + const view = new DataView(kernelMemory.buffer, pointer); + view.setBigInt64(CH_RETURN, 0n, true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }); + worker = createWorker({ + kernel_fork_process: forkProcess, + kernel_handle_channel: handleChannel, + }, kernelMemory); + const state = mutableState(worker); + state.processes = new Map([[channel.pid, { + channels: [channel], + memory: processMemory, + ptrWidth: 4, + }]]); + state.activeChannels = [channel]; + worker.testAuthority.configureScratchBoundaryHooksForTest({ + // Keep the caller-owned mailbox pending after the outer dispatch. If a + // rejected operation were accidentally queued, its later revalidation + // would still succeed and this regression would observe a second call. + completeChannel: vi.fn(), + }); + setChannelSyscall(channel, ABI_SYSCALLS.Getpid, []); + Atomics.store( + channel.i32View, + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + CHANNEL_STATUS_PENDING, + ); + + worker.testAuthority.dispatchScratchBoundarySyscallForTest(channel); + + expect(dispatchError).toBeInstanceOf(KernelReentrantEntryError); + expect(forkError).toBeInstanceOf(KernelReentrantEntryError); + expect(handleChannel).toHaveBeenCalledOnce(); + expect(forkProcess).not.toHaveBeenCalled(); + + // Let the gate's detached-effect/drain microtasks run. Immediate test + // ingress must reject without retaining either operation for that drain. + await Promise.resolve(); + await Promise.resolve(); + expect(handleChannel).toHaveBeenCalledOnce(); + expect(forkProcess).not.toHaveBeenCalled(); + }); }); function createFcntlHarness( errno: number, caughtSignal = 0, -): { worker: any; channel: any } { +): { + worker: TestWorker; + channel: TestChannel; + completeChannel: ReturnType; +} { const kernelMemory = createSharedMemory(); const processMemory = createSharedMemory(); const channel = createChannel(7, processMemory); @@ -392,42 +565,190 @@ function createFcntlHarness( return 0; }), kernel_dequeue_signal: vi.fn(() => caughtSignal), - }); - worker.kernelMemory = kernelMemory; - installKernelWorkerTestScratch(worker, kernelMemory); - worker.processes = new Map([[channel.pid, { + }, kernelMemory); + const state = mutableState(worker); + state.processes = new Map([[channel.pid, { channels: [channel], memory: processMemory, + ptrWidth: 4, }]]); - worker.pendingAdvisoryLockRetries = new Map(); - worker.completeChannel = vi.fn(); - return { worker, channel }; + state.activeChannels = [channel]; + state.pendingAdvisoryLockRetries = new Map(); + const completeChannel = vi.fn(); + worker.testAuthority.configureScratchBoundaryHooksForTest({ + completeChannel, + }); + setChannelSyscall( + channel, + ABI_SYSCALLS.Fcntl, + [3, F_SETLKW, FLOCK_PTR, 0, 0, 0], + ); + return { worker, channel, completeChannel }; } -function createWorker(exports: Record): any { - const kernelMemory = createSharedMemory(); - const worker = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - kernel: { - releaseProcessViews: vi.fn(), - toKernelPtr: (value: number | bigint) => value, - }, - kernelInstance: { - exports: { - kernel_get_process_exit_signal: vi.fn(() => -1), - kernel_get_process_state: vi.fn(() => PROCESS_STATE_RUNNING), - kernel_set_current_tid: vi.fn(() => 0), - ...exports, - }, - }, - kernelMemory, - processes: new Map(), - channelTids: new Map(), - hostReaped: new Set(), +type TestWorker = ReturnType; + +interface TestChannel { + readonly pid: number; + readonly memory: WebAssembly.Memory; + readonly channelOffset: number; + i32View: Int32Array; + consecutiveSyscalls: number; + handling: boolean; +} + +interface MutableWorkerState { + processes: Map; + activeChannels: TestChannel[]; + channelTids: Map; + hostReaped: Set; + waitingForChild: unknown[]; + pendingSleeps: Map; + pendingFutexWaits: Map; + pendingPollRetries: Map; + pendingAdvisoryLockRetries: Map; + channel: TestChannel; + }>; + pendingSelectRetries: Map; + pendingPipeReaders: Map; + pendingPipeWriters: Map; + pendingCancels: Set; + threadForkContexts: Map; + threadCtidPtrs: Map; + posixTimers: Map; + socketTimeoutTimers: Map>; + sharedMappings: Map>; +} + +function mutableState(worker: TestWorker): MutableWorkerState { + // These are existing writable value fields on a sealed test instance. Tests + // may arrange inert host state, but never replace entry-taking methods or + // install a raw instance/export namespace on the worker. + return worker as unknown as MutableWorkerState; +} + +function createWorker( + exports: Record, + suppliedMemory?: WebAssembly.Memory, + callbacks: { onExit?: (pid: number, status: number) => void } = {}, +): TestWorker { + const kernelMemory = suppliedMemory ?? createSharedMemory(); + const kernelExports: Record = { + kernel_blocking_retry_release: () => 0, + kernel_blocking_retry_token: () => 1n, + kernel_dequeue_signal: () => 0, + kernel_drain_wakeup_events: () => 0, + kernel_get_parent_pid: () => 0, + kernel_get_process_exit_signal: () => -1, + kernel_get_process_exit_status: () => -1, + kernel_get_process_state: () => PROCESS_STATE_RUNNING, + kernel_has_sa_nocldstop: () => 0, + kernel_has_sa_nocldwait: () => 0, + kernel_pick_signal_target_tid: () => 0, + kernel_set_current_tid: () => 0, + kernel_thread_has_deliverable: () => 0, + ...exports, + }; + const worker = createCentralizedKernelWorkerTestDouble({ + callbacks, + }); + installKernelWorkerTestScratch(worker, kernelMemory, 128, 4, { + kernelExports, }); - installKernelWorkerTestScratch(worker, kernelMemory); return worker; } +function createParkedAdvisoryCancellationHarness( + cancellationWakeAllowed: boolean, +): { + worker: TestWorker; + state: MutableWorkerState; + caller: TestChannel; + target: TestChannel; + syntheticCalls: Array<{ syscall: number; args: number[] }>; + relistenChannel: ReturnType; +} { + const memory = createSharedMemory(); + const caller = createChannel(13, memory); + const target = createChannel(13, memory, 256); + const kernelMemory = createSharedMemory(); + const syntheticCalls: Array<{ syscall: number; args: number[] }> = []; + const worker = createWorker({ + kernel_handle_channel: vi.fn((pointer: number) => { + const view = new DataView(kernelMemory.buffer, pointer); + const syscall = view.getUint32(CH_SYSCALL, true); + if (syscall === ABI_SYSCALLS.Fcntl) { + view.setBigInt64(CH_RETURN, -1n, true); + view.setUint32(CH_ERRNO, EAGAIN, true); + return 0; + } + syntheticCalls.push({ + syscall, + args: Array.from( + { length: CH_ARGS_COUNT }, + (_, index) => Number(view.getBigInt64( + CH_ARGS + index * CH_ARG_SIZE, + true, + )), + ), + }); + view.setBigInt64(CH_RETURN, 0n, true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }), + }, kernelMemory); + const state = mutableState(worker); + state.processes = new Map([[13, { + channels: [caller, target], + memory, + ptrWidth: 4, + }]]); + state.activeChannels = [caller, target]; + state.channelTids = new Map([["13:256", 99]]); + state.pendingCancels = new Set(); + state.pendingFutexWaits = new Map(); + state.pendingPollRetries = new Map(); + state.pendingAdvisoryLockRetries = new Map(); + state.pendingSelectRetries = new Map(); + state.pendingPipeReaders = new Map(); + state.pendingPipeWriters = new Map(); + state.waitingForChild = []; + const relistenChannel = vi.fn(); + worker.testAuthority.configureScratchBoundaryHooksForTest({ + relistenChannel, + }); + Atomics.store( + target.i32View, + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + CHANNEL_STATUS_PENDING, + ); + setChannelSyscall( + target, + ABI_SYSCALLS.Fcntl, + [3, F_SETLKW, FLOCK_PTR, 0, 0, 0], + CHANNEL_REQUEST_FLAG_CANCELLATION_POINT + | (cancellationWakeAllowed + ? CHANNEL_REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED + : 0), + ); + worker.testAuthority.dispatchScratchBoundarySyscallForTest(target); + return { + worker, + state, + caller, + target, + syntheticCalls, + relistenChannel, + }; +} + function createSharedMemory(): WebAssembly.Memory { return new WebAssembly.Memory({ initial: 2, @@ -440,12 +761,45 @@ function createChannel( pid: number, memory: WebAssembly.Memory, channelOffset = 0, -): any { +): TestChannel { return { pid, memory, channelOffset, - i32View: new Int32Array(memory.buffer, channelOffset), + i32View: new Int32Array( + memory.buffer, + channelOffset, + CH_TOTAL_SIZE / Int32Array.BYTES_PER_ELEMENT, + ), consecutiveSyscalls: 0, + handling: true, + }; +} + +function setChannelSyscall( + channel: TestChannel, + syscallNr: number, + args: readonly number[], + requestFlags = 0, +): void { + const view = new DataView(channel.memory.buffer, channel.channelOffset); + view.setUint32(CH_SYSCALL, syscallNr, true); + view.setUint32(CH_REQUEST_FLAGS, requestFlags, true); + for (let index = 0; index < CH_ARGS_COUNT; index++) { + view.setBigInt64( + CH_ARGS + index * CH_ARG_SIZE, + BigInt(args[index] ?? 0), + true, + ); + } +} + +function readChannelResult( + channel: TestChannel, +): { retVal: bigint; errVal: number } { + const view = new DataView(channel.memory.buffer, channel.channelOffset); + return { + retVal: view.getBigInt64(CH_RETURN, true), + errVal: view.getUint32(CH_ERRNO, true), }; } diff --git a/host/test/append-contract.test.ts b/host/test/append-contract.test.ts new file mode 100644 index 0000000000..8192aa3c22 --- /dev/null +++ b/host/test/append-contract.test.ts @@ -0,0 +1,269 @@ +import { + mkdtempSync, + readFileSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + HostAppendContractError, + isHostAppendContractError, +} from "../src/append-contract"; +import { + checkedHostFileOffset, + hostFileOffsetToSafeNumber, +} from "../src/file-offset"; +import { + createWasmPosixKernelTestHarness, + type WasmPosixKernel, +} from "../src/kernel"; +import { + OPFS_APPEND_CONTRACT_FAILURE, + OpfsChannelStatus, +} from "../src/vfs/opfs-channel"; +import { OpfsFileSystem } from "../src/vfs/opfs"; +import { opfsAppendWritableLength } from "../src/vfs/opfs-append"; +import { createSessionOwnedHostFileSystem } from "../src/vfs/host-fs"; +import { createKernelScratchTestInstance } from "./support/kernel-scratch-instance"; + +function appendImports( + append: ( + handle: number, + buffer: Uint8Array, + length: number, + limit: number | bigint | null, + ) => { written: number; end: number | bigint }, +): { + memory: WebAssembly.Memory; + imports: Record any>; +} { + const memory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); + const kernel = createWasmPosixKernelTestHarness({ + io: { append } as any, + memory, + pointerWidth: 4, + instance: createKernelScratchTestInstance( + 4, + memory, + () => ({}), + () => 4096, + ), + }) as WasmPosixKernel & Record; + return { + memory, + imports: kernel.testAuthority.buildImportObject(memory).env as Record< + string, + (...args: any[]) => any + >, + }; +} + +function replaceNumericGlobals(): () => void { + const numberDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + "Number", + )!; + const bigIntDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + "BigInt", + )!; + const hostileNumber = Object.assign( + () => 0, + { + isSafeInteger: () => true, + MIN_SAFE_INTEGER: Number.MIN_SAFE_INTEGER, + MAX_SAFE_INTEGER: Number.MAX_SAFE_INTEGER, + }, + ); + Object.defineProperty(globalThis, "Number", { + ...numberDescriptor, + value: hostileNumber, + }); + Object.defineProperty(globalThis, "BigInt", { + ...bigIntDescriptor, + value: () => 0n, + }); + return () => { + Object.defineProperty(globalThis, "Number", numberDescriptor); + Object.defineProperty(globalThis, "BigInt", bigIntDescriptor); + }; +} + +describe("append outcome authority", () => { + it("recognizes only privately branded fatal outcomes without hasInstance", () => { + const ownHasInstance = Object.getOwnPropertyDescriptor( + HostAppendContractError, + Symbol.hasInstance, + ); + const branded = new HostAppendContractError("mutated without an outcome"); + const counterfeit = Object.create(HostAppendContractError.prototype); + Object.defineProperty(HostAppendContractError, Symbol.hasInstance, { + configurable: true, + value: () => { + throw new Error("hostile Symbol.hasInstance"); + }, + }); + try { + expect(isHostAppendContractError(branded)).toBe(true); + expect(isHostAppendContractError(counterfeit)).toBe(false); + } finally { + if (ownHasInstance === undefined) { + delete (HostAppendContractError as any)[Symbol.hasInstance]; + } else { + Object.defineProperty( + HostAppendContractError, + Symbol.hasInstance, + ownHasInstance, + ); + } + } + }); + + it("keeps exact append outcomes after a backend replaces numeric globals", () => { + const exactEnd = (1n << 53n) + 1n; + let restoreGlobals: (() => void) | null = null; + const { memory, imports } = appendImports(() => { + restoreGlobals = replaceNumericGlobals(); + return { written: 1, end: exactEnd }; + }); + new Uint8Array(memory.buffer, 4096, 1)[0] = 0x41; + + let written: unknown; + let end: unknown; + let thrown: unknown; + try { + written = imports.host_append(7n, 4096, 1, -1, -1); + end = imports.host_append_position(7n, 1); + } catch (error) { + thrown = error; + } finally { + restoreGlobals?.(); + } + + expect(thrown).toBeUndefined(); + expect(written).toBe(1); + expect(end).toBe(exactEnd); + }); + + it("does not admit an unsafe count after a backend replaces validation", () => { + let restoreGlobals: (() => void) | null = null; + const { memory, imports } = appendImports(() => { + restoreGlobals = replaceNumericGlobals(); + return { written: 0.5, end: 1 }; + }); + new Uint8Array(memory.buffer, 4096, 1)[0] = 0x41; + + let thrown: unknown; + try { + imports.host_append(7n, 4096, 1, -1, -1); + } catch (error) { + thrown = error; + } finally { + restoreGlobals?.(); + } + + expect(thrown).toBeDefined(); + expect(String(thrown)).toMatch(/invalid append byte count/i); + }); + + it("keeps positioned-offset checks exact after import-time globals change", () => { + const restoreGlobals = replaceNumericGlobals(); + let unsafeError: unknown; + let narrowed: unknown; + try { + try { + checkedHostFileOffset(2 ** 53); + } catch (error) { + unsafeError = error; + } + narrowed = hostFileOffsetToSafeNumber(123n); + } finally { + restoreGlobals(); + } + + expect(String(unsafeError)).toMatch(/EOVERFLOW/); + expect(narrowed).toBe(123); + }); + + it("promotes malformed OPFS completion outcomes to the fatal contract", () => { + const dataBuffer = new Uint8Array(16); + const makeChannel = ( + status: OpfsChannelStatus, + result: number, + getI64Arg: () => number, + ) => ({ + dataBuffer, + result, + status: OpfsChannelStatus.Idle, + opcode: 0, + setArg: () => {}, + setI64Arg: () => {}, + setPending: () => {}, + waitForComplete: () => status, + getI64Arg, + }); + + for (const channel of [ + makeChannel( + OpfsChannelStatus.Error, + OPFS_APPEND_CONTRACT_FAILURE, + () => 0, + ), + makeChannel( + OpfsChannelStatus.Complete, + 1, + () => { + throw new RangeError("inexact end"); + }, + ), + ]) { + let thrown: unknown; + try { + new OpfsFileSystem(channel as any).append( + 1, + new Uint8Array([0x41]), + 1, + null, + ); + } catch (error) { + thrown = error; + } + expect(isHostAppendContractError(thrown)).toBe(true); + } + }); + + it("preflights the complete number-only OPFS append window", () => { + const max = Number.MAX_SAFE_INTEGER; + expect(opfsAppendWritableLength(max - 1, 1, null)).toBe(1); + expect(opfsAppendWritableLength(max - 1, 2, null)).toBeNull(); + expect(opfsAppendWritableLength(max - 1, 2, max)).toBe(1); + expect(opfsAppendWritableLength(max, 1, max)).toBe(0); + }); + + it("makes a failed post-write native verification fatal", () => { + const root = mkdtempSync(join(tmpdir(), "kandelo-append-contract-")); + const io = createSessionOwnedHostFileSystem(root); + const handle = io.open("/result", 0o100 | 0o2, 0o600); + (io as any).metadata = { + noteNativeContentChange: () => { + throw new Error("post-write metadata failure"); + }, + }; + + let thrown: unknown; + try { + try { + io.append(handle, new Uint8Array([0x41]), 1, null); + } catch (error) { + thrown = error; + } + expect(isHostAppendContractError(thrown)).toBe(true); + expect(readFileSync(join(root, "result"))).toEqual(Buffer.from("A")); + } finally { + io.close(handle); + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/host/test/browser-kernel.test.ts b/host/test/browser-kernel.test.ts index 54d35037ae..e8938f1dd8 100644 --- a/host/test/browser-kernel.test.ts +++ b/host/test/browser-kernel.test.ts @@ -538,9 +538,13 @@ describe("BrowserKernel", () => { requestId: spawn.requestId, result: 100, }); - await bootPromise; + const { exit } = await bootPromise; + const exitRejection = expect(exit).rejects.toThrow( + "Kernel worker error: worker crashed", + ); worker.onerror?.({ message: "worker crashed" }); + await exitRejection; expect(onHostDiagnostic).toHaveBeenCalledOnce(); expect(onHostDiagnostic).toHaveBeenCalledWith({ @@ -554,6 +558,70 @@ describe("BrowserKernel", () => { ); }); + it("fails pending and future work when the kernel worker reports a fatal instance", async () => { + const BrowserKernel = await loadBrowserKernel(); + const kernel = new BrowserKernel({ kernelOwnedFs: true }); + const initPromise = kernel.initFromImage({ + kernelWasm: new ArrayBuffer(8), + vfsImage: new Uint8Array(0), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const worker = MockWorker.instances[0]!; + worker.simulateMessage({ type: "ready" }); + await initPromise; + + const spawnPromise = kernel.spawnFromVfs("/bin/sleep", ["/bin/sleep"]); + await new Promise((resolve) => setTimeout(resolve, 0)); + const spawn = worker.lastMessage("spawn"); + worker.simulateMessage({ + type: "response", + requestId: spawn.requestId, + result: 101, + }); + const { exit } = await spawnPromise; + + const pendingRequest = kernel.getKernelMemoryPages(); + const fatalMessage = "reserved transfer execution trapped"; + const pendingRejection = expect(pendingRequest).rejects.toThrow( + `Kernel worker failed: ${fatalMessage}`, + ); + const exitRejection = expect(exit).rejects.toThrow( + `Kernel worker failed: ${fatalMessage}`, + ); + const messagesBeforeFatal = worker.sent.length; + + worker.simulateMessage({ type: "kernel_fatal", error: fatalMessage }); + + await Promise.all([pendingRejection, exitRejection]); + expect(worker.terminated).toBe(true); + + await expect(kernel.getKernelMemoryPages()).rejects.toThrow( + `Kernel worker failed: ${fatalMessage}`, + ); + expect(worker.sent).toHaveLength(messagesBeforeFatal); + }); + + it("rejects initialization when the kernel becomes fatal before ready", async () => { + const BrowserKernel = await loadBrowserKernel(); + const kernel = new BrowserKernel({ kernelOwnedFs: true }); + const initPromise = kernel.initFromImage({ + kernelWasm: new ArrayBuffer(8), + vfsImage: new Uint8Array(0), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const worker = MockWorker.instances[0]!; + + worker.simulateMessage({ + type: "kernel_fatal", + error: "kernel initialization trapped", + }); + + await expect(initPromise).rejects.toThrow( + "Kernel worker failed: kernel initialization trapped", + ); + expect(worker.terminated).toBe(true); + }); + it("forwards posix_spawn parentage from the browser kernel worker", async () => { const BrowserKernel = await loadBrowserKernel(); const processEvents: Array<{ diff --git a/host/test/centralized-test-helper.ts b/host/test/centralized-test-helper.ts index 5eaefe3989..04cab0c84a 100644 --- a/host/test/centralized-test-helper.ts +++ b/host/test/centralized-test-helper.ts @@ -838,10 +838,6 @@ async function runOnMainThread(options: RunProgramOptions): Promise { if (exitPid === pid) { - if (options.captureForkCount) { - mainThreadForkCount = kernelWorker.getForkCount(exitPid); - } - kernelWorker.unregisterProcess(exitPid); processProgramBytes.delete(exitPid); processLayouts.delete(exitPid); threadAllocators.delete(exitPid); @@ -995,6 +991,19 @@ async function runOnMainThread(options: RunProgramOptions): Promise { afterEach(() => { @@ -14,36 +22,39 @@ describe("browser channel-listener scheduling", () => { it("defers every batch-1 relisten through setImmediate, not queueMicrotask", () => { const tasks = controlTaskQueues(); const { worker, channel } = createScheduler(); - const listenOnChannel = vi.fn(); - worker.listenOnChannel = listenOnChannel; + const waitAsync = vi.spyOn(Atomics, "waitAsync").mockReturnValue({ + async: true, + value: new Promise<"ok">(() => {}), + } as any); worker.relistenChannel(channel); expect(worker.relistenBatchSize).toBe(1); expect(tasks.setImmediate).toHaveBeenCalledOnce(); expect(tasks.queueMicrotask).not.toHaveBeenCalled(); - expect(listenOnChannel).not.toHaveBeenCalled(); + expect(waitAsync).not.toHaveBeenCalled(); tasks.runNextImmediate(); - expect(listenOnChannel).toHaveBeenCalledOnce(); - expect(listenOnChannel).toHaveBeenCalledWith(channel); + expect(waitAsync).toHaveBeenCalledOnce(); }); it("defers an already-pending batch-1 dispatch", () => { const tasks = controlTaskQueues(); - const { worker, channel } = createScheduler(CHANNEL_STATUS_PENDING); - const handleSyscall = vi.fn(); - worker.handleSyscall = handleSyscall; + const { handleChannel, worker, channel } = + createScheduler(CHANNEL_STATUS_PENDING); worker.listenOnChannel(channel); expect(tasks.setImmediate).toHaveBeenCalledOnce(); expect(tasks.queueMicrotask).not.toHaveBeenCalled(); - expect(handleSyscall).not.toHaveBeenCalled(); + expect(handleChannel).not.toHaveBeenCalled(); tasks.runNextImmediate(); - expect(handleSyscall).toHaveBeenCalledOnce(); - expect(handleSyscall).toHaveBeenCalledWith(channel); + expect(handleChannel).toHaveBeenCalledOnce(); + expect(Atomics.load( + channel.i32View, + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + )).toBe(CHANNEL_STATUS_COMPLETE); }); it("arms Atomics.waitAsync for an idle channel instead of polling", async () => { @@ -53,10 +64,15 @@ describe("browser channel-listener scheduling", () => { const waited = new Promise<"ok">((resolve) => { wake = resolve; }); - const waitAsync = vi.spyOn(Atomics, "waitAsync").mockReturnValue({ - async: true, - value: waited, - } as any); + const waitAsync = vi.spyOn(Atomics, "waitAsync") + .mockReturnValueOnce({ + async: true, + value: waited, + } as any) + .mockReturnValueOnce({ + async: true, + value: new Promise<"ok">(() => {}), + } as any); worker.listenOnChannel(channel); @@ -70,21 +86,17 @@ describe("browser channel-listener scheduling", () => { expect(tasks.setImmediate).not.toHaveBeenCalled(); expect(tasks.queueMicrotask).not.toHaveBeenCalled(); - const listenAgain = vi.fn(); - worker.listenOnChannel = listenAgain; wake("ok"); await waited; await Promise.resolve(); - expect(listenAgain).toHaveBeenCalledOnce(); - expect(listenAgain).toHaveBeenCalledWith(channel); + expect(waitAsync).toHaveBeenCalledTimes(2); }); it("drops an already-pending dispatch when exec replaces its channel", () => { const tasks = controlTaskQueues(); - const { worker, channel } = createScheduler(CHANNEL_STATUS_PENDING); - const handleSyscall = vi.fn(); - worker.handleSyscall = handleSyscall; + const { handleChannel, worker, channel } = + createScheduler(CHANNEL_STATUS_PENDING); worker.listenOnChannel(channel); @@ -98,15 +110,13 @@ describe("browser channel-listener scheduling", () => { worker.activeChannels = [replacement]; tasks.runNextImmediate(); - expect(handleSyscall).not.toHaveBeenCalled(); + expect(handleChannel).not.toHaveBeenCalled(); }); it("makes a queued relisten a no-op after unregister", () => { const tasks = controlTaskQueues(); const { worker, channel } = createScheduler(CHANNEL_STATUS_IDLE); const waitAsync = vi.spyOn(Atomics, "waitAsync"); - const handleSyscall = vi.fn(); - worker.handleSyscall = handleSyscall; worker.relistenChannel(channel); worker.processes.delete(channel.pid); @@ -114,19 +124,31 @@ describe("browser channel-listener scheduling", () => { tasks.runNextImmediate(); expect(waitAsync).not.toHaveBeenCalled(); - expect(handleSyscall).not.toHaveBeenCalled(); }); }); function createScheduler(status = CHANNEL_STATUS_IDLE): { worker: any; channel: any; + handleChannel: ReturnType; } { const pid = 7; const memory = createMemory(); const channel = createChannel(pid, memory); Atomics.store(channel.i32View, CH_STATUS / Int32Array.BYTES_PER_ELEMENT, status); - const worker = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + new DataView(memory.buffer).setUint32( + CH_SYSCALL, + ABI_SYSCALLS.Getpid, + true, + ); + const kernelMemory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); + const handleChannel = vi.fn((scratchPtr: number) => { + const view = new DataView(kernelMemory.buffer, scratchPtr); + view.setBigInt64(CH_RETURN, BigInt(pid), true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }); + const worker = Object.assign(createCentralizedKernelWorkerTestDouble(), { processes: new Map([[pid, { pid, memory, channels: [channel] }]]), activeChannels: [channel], stoppedPids: new Set(), @@ -136,7 +158,22 @@ function createScheduler(status = CHANNEL_STATUS_IDLE): { relistenBatchSize: 1, relistenCount: 0, }); - return { worker, channel }; + installKernelWorkerTestScratch( + worker, + kernelMemory, + 128, + 4, + { + kernelExports: { + kernel_dequeue_signal: vi.fn(() => 0), + kernel_drain_wakeup_events: vi.fn(() => 0), + kernel_get_process_exit_signal: vi.fn(() => -1), + kernel_handle_channel: handleChannel, + kernel_set_current_tid: vi.fn(() => 0), + }, + }, + ); + return { handleChannel, worker, channel }; } function createMemory(): WebAssembly.Memory { diff --git a/host/test/channel-scalar-contract.test.ts b/host/test/channel-scalar-contract.test.ts new file mode 100644 index 0000000000..888b346b16 --- /dev/null +++ b/host/test/channel-scalar-contract.test.ts @@ -0,0 +1,165 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { + CHANNEL_SCALAR_SLOT_CONTRACTS, + channelResultKind, + normalizeChannelScalarArguments, +} from "../src/channel-scalar-contract"; +import { ABI_SYSCALLS } from "../src/generated/abi"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const wasmApiSource = readFileSync( + join(repoRoot, "crates/kernel/src/wasm_api.rs"), + "utf8", +); +const kernelWorkerSource = readFileSync( + join(repoRoot, "host/src/kernel-worker.ts"), + "utf8", +); +const sharedScalarSource = readFileSync( + join(repoRoot, "crates/shared/src/channel_scalar.rs"), + "utf8", +); + +function hostExactI64Slots(): string[] { + return Object.entries(CHANNEL_SCALAR_SLOT_CONTRACTS) + .flatMap(([syscall, slots]) => + Object.entries(slots) + .filter(([, kind]) => kind === "i64") + .map(([index]) => `${Number(syscall)}:${Number(index)}`) + ) + .sort(); +} + +describe("channel scalar-slot contract", () => { + it("preserves every exact i64 field before kernel scratch dispatch", () => { + const exact = (1n << 53n) + 1n; + for (const [syscallText, slots] of Object.entries( + CHANNEL_SCALAR_SLOT_CONTRACTS, + )) { + for (const [indexText, kind] of Object.entries(slots)) { + if (kind !== "i64") continue; + const index = Number(indexText); + const rawArgs = Array(6).fill(0n); + rawArgs[index] = exact; + expect( + normalizeChannelScalarArguments(Number(syscallText), rawArgs)[index], + ).toBe(exact); + } + } + }); + + it("applies the default signed-i32 contract before any Number conversion", () => { + const raw = (1n << 53n) + 1n; + expect( + normalizeChannelScalarArguments(ABI_SYSCALLS.Getpid, [ + raw, + 0n, + 0n, + 0n, + 0n, + 0n, + ])[0], + ).toBe(1); + }); + + it("normalizes split signed and unsigned words before Number conversion", () => { + const tooWideLowWord = 0x1_ffff_ffffn; + const widenedNegativeHighWord = 0xffff_ffffn; + + const lseek = normalizeChannelScalarArguments(ABI_SYSCALLS.Seek, [ + 7n, + tooWideLowWord, + widenedNegativeHighWord, + 0n, + 0n, + 0n, + ]); + expect(lseek[1]).toBe(0xffff_ffff); + expect(lseek[2]).toBe(-1); + + const llseek = normalizeChannelScalarArguments(ABI_SYSCALLS.Llseek, [ + 7n, + widenedNegativeHighWord, + tooWideLowWord, + 0n, + 0n, + 0n, + ]); + expect(llseek[1]).toBe(-1); + expect(llseek[2]).toBe(0xffff_ffff); + + for (const syscall of [ + ABI_SYSCALLS.Preadv, + ABI_SYSCALLS.Pwritev, + ABI_SYSCALLS.Preadv2, + ABI_SYSCALLS.Pwritev2, + ]) { + const vector = normalizeChannelScalarArguments(syscall, [ + 7n, + 0n, + 1n, + tooWideLowWord, + widenedNegativeHighWord, + 0n, + ]); + expect(vector[3]).toBe(0xffff_ffff); + expect(vector[4]).toBe(-1); + } + }); + + it("generates exact and pointer result kinds from the shared table", () => { + expect(channelResultKind(ABI_SYSCALLS.Seek)).toBe("i64"); + expect(channelResultKind(ABI_SYSCALLS.Time)).toBe("i64"); + expect(channelResultKind(ABI_SYSCALLS.Mmap)).toBe("process-address"); + expect(channelResultKind(ABI_SYSCALLS.Brk)).toBe("process-address"); + expect(channelResultKind(ABI_SYSCALLS.Mremap)).toBe("process-address"); + expect(channelResultKind(ABI_SYSCALLS.Getpid)).toBe("i32"); + }); + + it("routes every live Rust exact-i64 consumer through the declared helper", () => { + const latentStubs = new Set([ + ABI_SYSCALLS.Readahead, + ABI_SYSCALLS.Fadvise, + ABI_SYSCALLS.SyncFileRange, + ]); + const expectedLive = hostExactI64Slots() + .filter((slot) => !latentStubs.has(Number(slot.split(":")[0]))) + .sort(); + const liveConsumers = Array.from( + wasmApiSource.matchAll( + /channel_scalar::i64_argument\((\d+),\s*args,\s*(\d+)\)/g, + ), + (match) => `${Number(match[1])}:${Number(match[2])}`, + ).sort(); + + expect(liveConsumers).toEqual(expectedLive); + }); + + it("uses the scalar contract to initialize adjusted arguments", () => { + expect(kernelWorkerSource).toContain( + "const adjustedArgs = normalizeChannelScalarArguments(syscallNr, rawArgs);", + ); + expect(kernelWorkerSource).not.toMatch( + /syscallNr === SYS_LLSEEK && i ===/, + ); + expect(kernelWorkerSource).not.toContain("adjustedArgs.map(Number)"); + expect(kernelWorkerSource).toContain( + "BigInt.asUintN(64, rawRetVal)", + ); + expect(kernelWorkerSource).toContain("publicationRetVal"); + expect(kernelWorkerSource).not.toContain( + "channelReturnValueForPublication", + ); + expect(wasmApiSource).toContain("dispatch_channel_wide_result"); + expect(wasmApiSource).not.toMatch( + /(?:46|48|66|126)\s*=>[^,\\n]*as\s+i32/, + ); + expect(sharedScalarSource).toContain( + "assert_eq!(\n argument_kind(syscall_number, index)", + ); + }); +}); diff --git a/host/test/clone-tid-authority.test.ts b/host/test/clone-tid-authority.test.ts index b0a9f34386..b16f9d1233 100644 --- a/host/test/clone-tid-authority.test.ts +++ b/host/test/clone-tid-authority.test.ts @@ -1,12 +1,20 @@ import { describe, expect, it, vi } from "vitest"; -import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { + createCentralizedKernelWorkerTestDouble, CentralizedKernelWorker +} from "../src/kernel-worker"; import { WASM_PAGE_SIZE } from "../src/constants"; import { ABI_SYSCALLS, + CHANNEL_STATUS_COMPLETE, + CHANNEL_STATUS_PENDING, + CH_ARGS, + CH_ARG_SIZE, CH_DATA, CH_ERRNO, CH_RETURN, + CH_STATUS, + CH_SYSCALL, } from "../src/generated/abi"; import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; @@ -15,6 +23,39 @@ const KERNEL_TID = 318; const CHANNEL_OFFSET = WASM_PAGE_SIZE; const CLONE_ARGS = [0x0021_0100, 0x0080_0000, 0, 0x0090_0000, 0x0004_0000, 0]; +interface TestChannel { + readonly pid: number; + readonly channelOffset: number; + readonly memory: WebAssembly.Memory; + i32View: Int32Array; + consecutiveSyscalls: number; + handling?: boolean; +} + +function writeCloneRequest(channel: TestChannel, args: readonly number[]): void { + const view = new DataView(channel.memory.buffer, channel.channelOffset); + view.setUint32(CH_STATUS, CHANNEL_STATUS_PENDING, true); + view.setUint32(CH_DATA, 11, true); + view.setUint32(CH_DATA + 4, 22, true); + view.setUint32(CH_SYSCALL, ABI_SYSCALLS.Clone, true); + for (let index = 0; index < args.length; index++) { + view.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, BigInt(args[index]!), true); + } +} + +function readCloneCompletion(channel: TestChannel): { + readonly status: number; + readonly retVal: number; + readonly errno: number; +} { + const view = new DataView(channel.memory.buffer, channel.channelOffset); + return { + status: view.getUint32(CH_STATUS, true), + retVal: Number(view.getBigInt64(CH_RETURN, true)), + errno: view.getUint32(CH_ERRNO, true), + }; +} + function makeCloneHarness( onClone: (...args: unknown[]) => Promise, kernelTid = KERNEL_TID, @@ -25,76 +66,77 @@ function makeCloneHarness( maximum: 16, shared: true, }); - const channel = { pid: PID, channelOffset: CHANNEL_OFFSET, memory }; - const processView = new DataView(memory.buffer, CHANNEL_OFFSET); - processView.setUint32(CH_DATA, 11, true); - processView.setUint32(CH_DATA + 4, 22, true); + const channel: TestChannel = { + pid: PID, + channelOffset: CHANNEL_OFFSET, + memory, + i32View: new Int32Array(memory.buffer, CHANNEL_OFFSET), + consecutiveSyscalls: 0, + }; const kernelMemory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); - const completeChannel = vi.fn(); - const notifyThreadExit = vi.fn(); - const worker = Object.assign( - Object.create(CentralizedKernelWorker.prototype), - { - callbacks: {}, - kernel: { - toKernelPtr(value: number | bigint): number { - return Number(value); - }, - }, - kernelMemory, - currentHandlePid: 0, - activeChannels: [channel], - channelTids: new Map(), - execHandoffPids: new Set(), - hostReaped: new Set(), - processes: new Map([[PID, { - pid: PID, - channels: [channel], - memory, - explicitMaxAddr: true, - }]]), - threadCtidPtrs: new Map(), - threadForkContexts: new Map(), - retireExactChannelAsyncState: vi.fn(), - usePolling: true, - completeChannel, - notifyThreadExit, - bindKernelTidForChannel: vi.fn(), - kernelInstance: { - exports: { - kernel_get_process_exit_signal: vi.fn(() => -1), - kernel_validate_task: vi.fn(() => 0), - kernel_handle_channel: vi.fn((offset: number) => { - const kernelView = new DataView(kernelMemory.buffer, offset); - kernelView.setBigInt64(CH_RETURN, BigInt(kernelTid), true); - kernelView.setUint32(CH_ERRNO, 0, true); - return 0; - }), - }, + const kernelHandleChannel = vi.fn((offset: number | bigint) => { + const kernelView = new DataView(kernelMemory.buffer, Number(offset)); + kernelView.setBigInt64(CH_RETURN, BigInt(kernelTid), true); + kernelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + const notifyThreadExit = vi.fn(() => 0); + let worker!: CentralizedKernelWorker; + worker = createCentralizedKernelWorkerTestDouble({ + callbacks: { + onClone: (attachment) => { + if (autoAttach) { + worker.attachThreadChannel( + attachment, + 2 * WASM_PAGE_SIZE, + ); + } + return onClone(attachment) as Promise; }, }, - ) as CentralizedKernelWorker; + }); + Object.assign(worker, { + currentHandlePid: 0, + activeChannels: [channel], + channelTids: new Map(), + execHandoffPids: new Set(), + hostReaped: new Set(), + processes: new Map([[PID, { + pid: PID, + channels: [channel], + memory, + explicitMaxAddr: true, + }]]), + threadCtidPtrs: new Map(), + threadForkContexts: new Map(), + usePolling: true, + }); installKernelWorkerTestScratch( worker as unknown as Record, kernelMemory, - ); - (worker as any).callbacks = { - onClone: (attachment: unknown) => { - if (autoAttach) { - worker.attachThreadChannel( - attachment as Parameters[0], - 2 * WASM_PAGE_SIZE, - ); - } - return onClone(attachment); + 128, + 4, + { + kernelExports: { + kernel_drain_wakeup_events: vi.fn(() => 0), + kernel_get_process_exit_signal: vi.fn(() => -1), + kernel_get_process_state: vi.fn(() => 0), + kernel_handle_channel: kernelHandleChannel, + kernel_set_current_tid: vi.fn(() => 0), + kernel_thread_exit: notifyThreadExit, + kernel_validate_task: vi.fn(() => 0), + }, }, - }; + ); return { channel, - completeChannel, - kernelHandleChannel: (worker as any).kernelInstance.exports.kernel_handle_channel, + dispatch(args: readonly number[] = CLONE_ARGS) { + writeCloneRequest(channel, args); + (worker as any).handleSyscall(channel); + }, + kernelHandleChannel, notifyThreadExit, worker, }; @@ -107,7 +149,7 @@ function makeChannelOwnershipHarness() { shared: true, }); const mainChannelOffset = WASM_PAGE_SIZE; - const mainChannel = { + const mainChannel: TestChannel = { pid: PID, channelOffset: mainChannelOffset, memory, @@ -115,84 +157,91 @@ function makeChannelOwnershipHarness() { consecutiveSyscalls: 0, }; const validateTask = vi.fn(() => 0); - const retireExactChannelAsyncState = vi.fn(); const kernelMemory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); let nextKernelTid = 0; - const worker = Object.assign( - Object.create(CentralizedKernelWorker.prototype), - { - callbacks: {}, - kernel: { - toKernelPtr(value: number | bigint): number { - return Number(value); - }, - }, - kernelMemory, - currentHandlePid: 0, - activeChannels: [mainChannel], - channelTids: new Map(), - execHandoffPids: new Set(), - hostReaped: new Set(), - processes: new Map([ - [PID, { - pid: PID, - memory, - channels: [mainChannel], - explicitMaxAddr: true, - }], - ]), - retireExactChannelAsyncState, - threadCtidPtrs: new Map(), - threadForkContexts: new Map(), - usePolling: true, - completeChannel: vi.fn(), - notifyThreadExit: vi.fn(), - bindKernelTidForChannel: vi.fn(), - kernelInstance: { - exports: { - kernel_get_process_exit_signal: vi.fn(() => -1), - kernel_validate_task: validateTask, - kernel_handle_channel: vi.fn((offset: number) => { - const kernelView = new DataView(kernelMemory.buffer, offset); - kernelView.setBigInt64(CH_RETURN, BigInt(nextKernelTid), true); - kernelView.setUint32(CH_ERRNO, 0, true); - return 0; - }), - }, + let receiveAttachment: + | (( + attachment: Parameters< + CentralizedKernelWorker["attachThreadChannel"] + >[0], + ) => Promise) + | undefined; + const worker = createCentralizedKernelWorkerTestDouble({ + callbacks: { + onClone: (attachment) => { + if (!receiveAttachment) { + throw new Error("clone attachment receiver is not armed"); + } + return receiveAttachment(attachment); }, }, - ) as CentralizedKernelWorker; + }); + Object.assign(worker, { + currentHandlePid: 0, + activeChannels: [mainChannel], + channelTids: new Map(), + execHandoffPids: new Set(), + hostReaped: new Set(), + processes: new Map([ + [PID, { + pid: PID, + memory, + channels: [mainChannel], + explicitMaxAddr: true, + }], + ]), + threadCtidPtrs: new Map(), + threadForkContexts: new Map(), + usePolling: true, + }); installKernelWorkerTestScratch( worker as unknown as Record, kernelMemory, + 128, + 4, + { + kernelExports: { + kernel_drain_wakeup_events: vi.fn(() => 0), + kernel_get_process_exit_signal: vi.fn(() => -1), + kernel_get_process_state: vi.fn(() => 0), + kernel_handle_channel: vi.fn((offset: number | bigint) => { + const kernelView = new DataView(kernelMemory.buffer, Number(offset)); + kernelView.setBigInt64(CH_RETURN, BigInt(nextKernelTid), true); + kernelView.setUint32(CH_ERRNO, 0, true); + return 0; + }), + kernel_set_current_tid: vi.fn(() => 0), + kernel_thread_exit: vi.fn(() => 0), + kernel_validate_task: validateTask, + }, + }, ); return { mainChannel, memory, - retireExactChannelAsyncState, validateTask, worker, - issueThreadAttachment(tid: number, fnPtr = 11, argPtr = 22) { + async issueThreadAttachment(tid: number, fnPtr = 11, argPtr = 22) { let attachment: | Parameters[0] | undefined; nextKernelTid = tid; - const processView = new DataView(memory.buffer, mainChannelOffset); - processView.setUint32(CH_DATA, fnPtr, true); - processView.setUint32(CH_DATA + 4, argPtr, true); - (worker as any).callbacks = { - onClone: ( - value: Parameters[0], - ) => { - attachment = value; - return new Promise(() => {}); - }, + receiveAttachment = ( + value: Parameters[0], + ) => { + attachment = value; + return new Promise(() => {}); }; - (worker as any).handleClone( + writeCloneRequest( mainChannel, [0, 0x0080_0000, 0, 0x0090_0000, 0, 0], ); + const processView = new DataView(memory.buffer, mainChannelOffset); + processView.setUint32(CH_DATA, fnPtr, true); + processView.setUint32(CH_DATA + 4, argPtr, true); + (worker as any).handleSyscall(mainChannel); + await flushCloneContinuation(); if (!attachment) throw new Error("clone callback did not receive attachment"); return attachment; }, @@ -200,8 +249,25 @@ function makeChannelOwnershipHarness() { } async function flushCloneContinuation(): Promise { - await Promise.resolve(); - await Promise.resolve(); + for (let index = 0; index < 8; index++) { + await Promise.resolve(); + } +} + +function expectIngressFailureCause( + operation: () => void, + expectedMessage: string, +): void { + let failure: unknown; + try { + operation(); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + const cause = (failure as Error & { cause?: unknown }).cause; + expect(cause).toBeInstanceOf(Error); + expect((cause as Error).message).toContain(expectedMessage); } describe("kernel TID authority", () => { @@ -218,99 +284,80 @@ describe("kernel TID authority", () => { 0, ]; - (first.worker as any).handleClone(first.channel, parentArgs); + first.dispatch(parentArgs); expect(first.kernelHandleChannel).not.toHaveBeenCalled(); - expect(first.completeChannel).toHaveBeenCalledWith( - first.channel, - ABI_SYSCALLS.Clone, - parentArgs, - undefined, - -1, - 14, - ); + expect(readCloneCompletion(first.channel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + retVal: -1, + errno: 14, + }); const second = makeCloneHarness(onClone); const childArgs = [...CLONE_ARGS]; childArgs[4] = invalidPtr; - (second.worker as any).handleClone(second.channel, childArgs); + second.dispatch(childArgs); expect(second.kernelHandleChannel).not.toHaveBeenCalled(); - expect(second.completeChannel).toHaveBeenCalledWith( - second.channel, - ABI_SYSCALLS.Clone, - childArgs, - undefined, - -1, - 14, - ); + expect(readCloneCompletion(second.channel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + retVal: -1, + errno: 14, + }); expect(onClone).not.toHaveBeenCalled(); }); - it("rolls back the exact Rust TID when the clone callback throws synchronously", () => { + it("rolls back the exact Rust TID when the clone callback throws synchronously", async () => { const launchError = new Error("synchronous worker construction failed"); const onClone = vi.fn(() => { throw launchError; }); - const { channel, notifyThreadExit, worker } = makeCloneHarness(onClone); + const harness = makeCloneHarness(onClone); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + // A synchronous constructor failure is an asynchronous clone-transaction + // failure at the mailbox boundary; it must roll back and complete the + // caller rather than escape the worker listener. + expect(() => harness.dispatch()).not.toThrow(); + await flushCloneContinuation(); - expect(() => (worker as any).handleClone(channel, CLONE_ARGS)) - .toThrow(launchError); - expect(notifyThreadExit).toHaveBeenCalledOnce(); - expect(notifyThreadExit).toHaveBeenCalledWith(PID, KERNEL_TID); + expect(harness.notifyThreadExit).toHaveBeenCalledOnce(); + expect(harness.notifyThreadExit).toHaveBeenCalledWith(PID, KERNEL_TID); + expect(readCloneCompletion(harness.channel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + retVal: -1, + errno: 12, + }); + } finally { + consoleError.mockRestore(); + } }); it("does not track an unflagged child-TID pointer as clear-on-exit state", async () => { const onClone = vi.fn(async () => {}); - const { channel, worker } = makeCloneHarness(onClone); + const harness = makeCloneHarness(onClone); const args = [...CLONE_ARGS]; args[0] &= ~0x0020_0000; - (worker as any).handleClone(channel, args); + harness.dispatch(args); await flushCloneContinuation(); expect(onClone.mock.calls[0][0]).toMatchObject({ ctidPtr: 0 }); - expect((worker as any).threadCtidPtrs.size).toBe(0); - }); - - it("does not bind a pthread clone as the process leader when its mapping is missing", () => { - const onClone = vi.fn(async () => {}); - const { channel, worker } = makeCloneHarness(onClone); - const mainChannel = { - pid: PID, - channelOffset: 2 * WASM_PAGE_SIZE, - memory: channel.memory, - }; - const kernelHandleChannel = (worker as any).kernelInstance.exports - .kernel_handle_channel as ReturnType; - (worker as any).processes = new Map([ - [PID, { channels: [mainChannel, channel] }], - ]); - (worker as any).channelTids = new Map(); - delete (worker as any).bindKernelTidForChannel; - const expected = - `No kernel-validated TID for non-main channel ${CHANNEL_OFFSET} of process ${PID}`; - - expect(() => (worker as any).handleClone(channel, CLONE_ARGS)).toThrow(expected); - expect(kernelHandleChannel).not.toHaveBeenCalled(); - expect(onClone).not.toHaveBeenCalled(); + expect((harness.worker as any).threadCtidPtrs.size).toBe(0); }); it("rejects zero before a host callback can attach an unallocated task", () => { const onClone = vi.fn(async () => {}); - const { channel, completeChannel, notifyThreadExit, worker } = - makeCloneHarness(onClone, 0); + const harness = makeCloneHarness(onClone, 0); - (worker as any).handleClone(channel, CLONE_ARGS); + harness.dispatch(); expect(onClone).not.toHaveBeenCalled(); - expect(notifyThreadExit).not.toHaveBeenCalled(); - expect(completeChannel).toHaveBeenCalledWith( - channel, - ABI_SYSCALLS.Clone, - CLONE_ARGS, - undefined, - -1, - 5, - ); + expect(harness.notifyThreadExit).not.toHaveBeenCalled(); + expect(readCloneCompletion(harness.channel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + retVal: -1, + errno: 5, + }); }); it("ignores a host callback return value and completes with the Rust-assigned TID", async () => { @@ -318,9 +365,9 @@ describe("kernel TID authority", () => { // current callback type is Promise, and the runtime must likewise // ignore any value so the host cannot become an alternate TID authority. const onClone = vi.fn(async () => 999); - const { channel, completeChannel, worker } = makeCloneHarness(onClone); + const harness = makeCloneHarness(onClone); - (worker as any).handleClone(channel, CLONE_ARGS); + harness.dispatch(); await flushCloneContinuation(); expect(onClone).toHaveBeenCalledWith(expect.objectContaining({ @@ -331,45 +378,33 @@ describe("kernel TID authority", () => { stackPtr: CLONE_ARGS[1], tlsPtr: CLONE_ARGS[3], ctidPtr: CLONE_ARGS[4], - memory: channel.memory, + memory: harness.channel.memory, })); - expect(completeChannel).toHaveBeenCalledWith( - channel, - ABI_SYSCALLS.Clone, - CLONE_ARGS, - undefined, - KERNEL_TID, - 0, - [], - { - tid: KERNEL_TID, - parentTidPointer: undefined, - }, - ); + expect(readCloneCompletion(harness.channel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + retVal: KERNEL_TID, + errno: 0, + }); }); it("rolls back the exact Rust-assigned TID when host thread launch fails", async () => { const onClone = vi.fn(async () => { throw new Error("worker launch failed"); }); - const { channel, completeChannel, notifyThreadExit, worker } = - makeCloneHarness(onClone); + const harness = makeCloneHarness(onClone); const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); try { - (worker as any).handleClone(channel, CLONE_ARGS); + harness.dispatch(); await flushCloneContinuation(); - expect(notifyThreadExit).toHaveBeenCalledOnce(); - expect(notifyThreadExit).toHaveBeenCalledWith(PID, KERNEL_TID); - expect(completeChannel).toHaveBeenCalledWith( - channel, - ABI_SYSCALLS.Clone, - CLONE_ARGS, - undefined, - -1, - 12, - ); + expect(harness.notifyThreadExit).toHaveBeenCalledOnce(); + expect(harness.notifyThreadExit).toHaveBeenCalledWith(PID, KERNEL_TID); + expect(readCloneCompletion(harness.channel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + retVal: -1, + errno: 12, + }); } finally { consoleError.mockRestore(); } @@ -377,23 +412,19 @@ describe("kernel TID authority", () => { it("does not complete clone when the callback fails to consume its attachment", async () => { const onClone = vi.fn(async () => {}); - const { channel, completeChannel, notifyThreadExit, worker } = - makeCloneHarness(onClone, KERNEL_TID, false); + const harness = makeCloneHarness(onClone, KERNEL_TID, false); const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); try { - (worker as any).handleClone(channel, CLONE_ARGS); + harness.dispatch(); await flushCloneContinuation(); - expect(notifyThreadExit).toHaveBeenCalledWith(PID, KERNEL_TID); - expect(completeChannel).toHaveBeenCalledWith( - channel, - ABI_SYSCALLS.Clone, - CLONE_ARGS, - undefined, - -1, - 12, - ); + expect(harness.notifyThreadExit).toHaveBeenCalledWith(PID, KERNEL_TID); + expect(readCloneCompletion(harness.channel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + retVal: -1, + errno: 12, + }); } finally { consoleError.mockRestore(); } @@ -405,21 +436,23 @@ describe("thread channel ownership", () => { const secondThreadOffset = 3 * WASM_PAGE_SIZE; const thirdThreadOffset = 4 * WASM_PAGE_SIZE; - it("rejects a duplicate channel offset instead of remapping its TID", () => { + it("rejects a duplicate channel offset instead of remapping its TID", async () => { const { issueThreadAttachment, validateTask, worker } = makeChannelOwnershipHarness(); worker.attachThreadChannel( - issueThreadAttachment(KERNEL_TID), + await issueThreadAttachment(KERNEL_TID), firstThreadOffset, ); - - expect(() => worker.attachThreadChannel( - issueThreadAttachment(KERNEL_TID + 1), - firstThreadOffset, - )) - .toThrow( - `Channel offset ${firstThreadOffset} for process ${PID} is already registered`, - ); + const duplicateOffsetAttachment = + await issueThreadAttachment(KERNEL_TID + 1); + + expectIngressFailureCause( + () => worker.attachThreadChannel( + duplicateOffsetAttachment, + firstThreadOffset, + ), + `Channel offset ${firstThreadOffset} for process ${PID} is already registered`, + ); expect(validateTask).toHaveBeenCalledTimes(1); expect((worker as any).processes.get(PID).channels).toHaveLength(2); @@ -429,21 +462,22 @@ describe("thread channel ownership", () => { .toEqual({ fnPtr: 11, argPtr: 22 }); }); - it("rejects assigning one kernel TID to a second channel", () => { + it("rejects assigning one kernel TID to a second channel", async () => { const { issueThreadAttachment, validateTask, worker } = makeChannelOwnershipHarness(); worker.attachThreadChannel( - issueThreadAttachment(KERNEL_TID), + await issueThreadAttachment(KERNEL_TID), firstThreadOffset, ); - - expect(() => worker.attachThreadChannel( - issueThreadAttachment(KERNEL_TID), - secondThreadOffset, - )) - .toThrow( - `Kernel TID ${KERNEL_TID} is already attached to channel ${PID}:${firstThreadOffset}`, - ); + const duplicateTidAttachment = await issueThreadAttachment(KERNEL_TID); + + expectIngressFailureCause( + () => worker.attachThreadChannel( + duplicateTidAttachment, + secondThreadOffset, + ), + `Kernel TID ${KERNEL_TID} is already attached to channel ${PID}:${firstThreadOffset}`, + ); expect(validateTask).toHaveBeenNthCalledWith(2, PID, KERNEL_TID); expect((worker as any).processes.get(PID).channels).toHaveLength(2); @@ -452,26 +486,28 @@ describe("thread channel ownership", () => { .toBe(false); }); - it("rejects a wrong-but-valid sibling TID for another clone channel", () => { + it("rejects a wrong-but-valid sibling TID for another clone channel", async () => { const siblingTid = KERNEL_TID + 1; const { issueThreadAttachment, validateTask, worker } = makeChannelOwnershipHarness(); worker.attachThreadChannel( - issueThreadAttachment(KERNEL_TID), + await issueThreadAttachment(KERNEL_TID), firstThreadOffset, ); worker.attachThreadChannel( - issueThreadAttachment(siblingTid), + await issueThreadAttachment(siblingTid), secondThreadOffset, ); - - expect(() => worker.attachThreadChannel( - issueThreadAttachment(siblingTid), - thirdThreadOffset, - )) - .toThrow( - `Kernel TID ${siblingTid} is already attached to channel ${PID}:${secondThreadOffset}`, - ); + const duplicateSiblingAttachment = + await issueThreadAttachment(siblingTid); + + expectIngressFailureCause( + () => worker.attachThreadChannel( + duplicateSiblingAttachment, + thirdThreadOffset, + ), + `Kernel TID ${siblingTid} is already attached to channel ${PID}:${secondThreadOffset}`, + ); expect(validateTask).toHaveBeenLastCalledWith(PID, siblingTid); expect(validateTask).toHaveBeenCalledTimes(3); @@ -480,22 +516,13 @@ describe("thread channel ownership", () => { .toBe(false); }); - it("keeps concurrent pending TIDs bound to uncopyable one-shot attachments", () => { + it("keeps concurrent pending TIDs bound to uncopyable one-shot attachments", async () => { const siblingTid = KERNEL_TID + 1; const { issueThreadAttachment, worker } = makeChannelOwnershipHarness(); - const first = issueThreadAttachment(KERNEL_TID); - const sibling = issueThreadAttachment(siblingTid, 33, 44); - const forgedSibling = Object.freeze({ - ...sibling, - tid: KERNEL_TID, - }) as typeof sibling; - - expect(() => worker.attachThreadChannel(forgedSibling, firstThreadOffset)) - .toThrow("Unknown, expired, or already consumed thread attachment"); + const first = await issueThreadAttachment(KERNEL_TID); + const sibling = await issueThreadAttachment(siblingTid, 33, 44); worker.attachThreadChannel(first, firstThreadOffset); - expect(() => worker.attachThreadChannel(first, thirdThreadOffset)) - .toThrow("Unknown, expired, or already consumed thread attachment"); worker.attachThreadChannel(sibling, secondThreadOffset); expect((worker as any).channelTids.get(`${PID}:${firstThreadOffset}`)) @@ -505,27 +532,49 @@ describe("thread channel ownership", () => { expect((worker as any).threadForkContexts.get(`${PID}:${secondThreadOffset}`)) .toEqual({ fnPtr: 33, argPtr: 44 }); expect((worker as any).addChannel).toBeUndefined(); + + // A failed public ingress poisons this deliberately conservative test + // generation, so make the one-shot replay assertion the final operation. + expectIngressFailureCause( + () => worker.attachThreadChannel(first, thirdThreadOffset), + "Unknown, expired, or already consumed thread attachment", + ); + }); + + it("rejects a copied attachment with substituted identity", async () => { + const { issueThreadAttachment, worker } = makeChannelOwnershipHarness(); + const attachment = await issueThreadAttachment(KERNEL_TID); + const forged = Object.freeze({ + ...attachment, + tid: KERNEL_TID + 1, + }) as typeof attachment; + + expectIngressFailureCause( + () => worker.attachThreadChannel(forged, firstThreadOffset), + "Unknown, expired, or already consumed thread attachment", + ); + expect((worker as any).channelTids.size).toBe(0); + expect((worker as any).threadForkContexts.size).toBe(0); }); - it("releases channel ownership on removal so a later clone can reuse the slot", () => { + it("releases channel ownership on removal so a later clone can reuse the slot", async () => { const replacementTid = KERNEL_TID + 1; - const { issueThreadAttachment, retireExactChannelAsyncState, worker } = + const { issueThreadAttachment, worker } = makeChannelOwnershipHarness(); worker.attachThreadChannel( - issueThreadAttachment(KERNEL_TID, 11, 22), + await issueThreadAttachment(KERNEL_TID, 11, 22), firstThreadOffset, ); worker.removeChannel(PID, firstThreadOffset); - expect(retireExactChannelAsyncState).toHaveBeenCalledOnce(); expect((worker as any).channelTids.has(`${PID}:${firstThreadOffset}`)) .toBe(false); expect((worker as any).threadForkContexts.has(`${PID}:${firstThreadOffset}`)) .toBe(false); worker.attachThreadChannel( - issueThreadAttachment(replacementTid, 33, 44), + await issueThreadAttachment(replacementTid, 33, 44), firstThreadOffset, ); expect((worker as any).channelTids.get(`${PID}:${firstThreadOffset}`)) diff --git a/host/test/connect-pending-retry.test.ts b/host/test/connect-pending-retry.test.ts index 798aaea7b5..045ba4b94f 100644 --- a/host/test/connect-pending-retry.test.ts +++ b/host/test/connect-pending-retry.test.ts @@ -1,13 +1,17 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { ABI_SYSCALLS, + CHANNEL_STATUS_PENDING, CH_ARG_SIZE, CH_ARGS, CH_ERRNO, CH_RETURN, + CH_STATUS, CH_SYSCALL, } from "../src/generated/abi"; -import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { + createCentralizedKernelWorkerTestDouble, +} from "../src/kernel-worker"; import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; const EINPROGRESS = 115; @@ -26,24 +30,11 @@ function createConnectHarness( ) { const kernelMemory = createSharedMemory(); const processMemory = createSharedMemory(); - const channel: any = { - pid: 42, - channelOffset: 0, - memory: processMemory, - i32View: new Int32Array(processMemory.buffer), - }; + const pid = 42; const fd = 7; const addrPtr = 1024; const addrLen = 16; const args = [fd, addrPtr, addrLen, 0, 0, 0]; - const processView = new DataView(processMemory.buffer); - processView.setUint32(CH_SYSCALL, ABI_SYSCALLS.Connect, true); - args.forEach((arg, index) => { - processView.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, BigInt(arg), true); - }); - processView.setUint16(addrPtr, options.family ?? 2, true); - processView.setUint16(addrPtr + 2, 80, false); - new Uint8Array(processMemory.buffer, addrPtr + 4, 4).set([203, 0, 113, 9]); let resultIndex = 0; const handleChannel = vi.fn((offset: number) => { @@ -55,45 +46,59 @@ function createConnectHarness( return 0; }); const completeChannel = vi.fn(); - const worker: any = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - kernel: { toKernelPtr: (value: number | bigint) => Number(value) }, - kernelInstance: { - exports: { + const isFdNonblock = vi.fn(() => options.nonblock ? 1 : 0); + const getSocketTimeout = vi.fn(() => 0n); + const worker = createCentralizedKernelWorkerTestDouble(); + installKernelWorkerTestScratch( + worker, + kernelMemory, + 128, + 4, + { + kernelExports: { + kernel_blocking_retry_release: () => 0, + kernel_blocking_retry_token: () => 1n, + kernel_dequeue_signal: () => 0, + kernel_get_process_exit_signal: () => -1, + kernel_get_socket_timeout_ms: getSocketTimeout, kernel_handle_channel: handleChannel, - kernel_is_fd_nonblock: vi.fn(() => options.nonblock ? 1 : 0), + kernel_is_fd_nonblock: isFdNonblock, + kernel_set_current_tid: () => 0, }, }, - kernelMemory, - processes: new Map([[channel.pid, { ptrWidth: 4 }]]), - currentHandlePid: 0, - config: {}, - syscallRing: new Map(), - channelTids: new Map(), - syscallTraceEnabled: false, - sharedMmapBackings: new Map(), - hostReaped: new Set(), - pendingPollRetries: new Map(), - pendingSelectRetries: new Map(), - pendingSleeps: new Map(), - pendingPipeReaders: new Map(), - pendingPipeWriters: new Map(), - socketTimeoutTimers: new Map(), - isRegisteredChannel: vi.fn(() => true), - isAsyncChannelProcessActive: vi.fn(() => true), - deferChannelWhileStopped: vi.fn(() => false), - synchronizeSharedMemoryForBoundary: vi.fn(), - bindKernelTidForChannel: vi.fn(), - highControlFloorForProcess: vi.fn(() => null), - getProcessExitSignal: vi.fn(() => 0), - dequeueSignalForDelivery: vi.fn(() => 0), - finishSignalTermination: vi.fn(() => false), + ); + worker.testAuthority.configureScratchBoundaryHooksForTest({ completeChannel, completeChannelRaw: vi.fn(), relistenChannel: vi.fn(), + synchronizeSharedMemoryForBoundary: vi.fn(), + }); + const [channel] = + worker.testAuthority.replaceProcessRegistrationForLifecycleTest({ + pid, + memory: processMemory, + channelOffsets: [0], + pointerWidth: 4, + }); + const processView = new DataView(processMemory.buffer); + processView.setUint32(CH_SYSCALL, ABI_SYSCALLS.Connect, true); + processView.setUint32(CH_STATUS, CHANNEL_STATUS_PENDING, true); + args.forEach((arg, index) => { + processView.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, BigInt(arg), true); }); - installKernelWorkerTestScratch(worker, kernelMemory); + processView.setUint16(addrPtr, options.family ?? 2, true); + processView.setUint16(addrPtr + 2, 80, false); + new Uint8Array(processMemory.buffer, addrPtr + 4, 4).set([203, 0, 113, 9]); - return { args, channel, completeChannel, handleChannel, worker }; + return { + args, + channel, + completeChannel, + getSocketTimeout, + handleChannel, + isFdNonblock, + worker, + }; } afterEach(() => { @@ -117,6 +122,8 @@ describe("pending AF_INET connect routing", () => { expect(harness.completeChannel.mock.calls[0].slice(-2)).toEqual([-1, EINPROGRESS]); expect(harness.completeChannel.mock.calls[1].slice(-2)).toEqual([-1, EALREADY]); expect(harness.worker.pendingPollRetries.size).toBe(0); + expect(harness.isFdNonblock).toHaveBeenCalledTimes(2); + expect(harness.getSocketTimeout).not.toHaveBeenCalled(); }); it("retries a blocking connect until success", () => { @@ -136,6 +143,8 @@ describe("pending AF_INET connect routing", () => { expect(harness.completeChannel).toHaveBeenCalledOnce(); expect(harness.completeChannel.mock.calls[0].slice(4, 6)).toEqual([0, 0]); expect(harness.worker.pendingPollRetries.size).toBe(0); + expect(harness.isFdNonblock).toHaveBeenCalledOnce(); + expect(harness.getSocketTimeout).toHaveBeenCalledOnce(); }); it("keeps a blocking EALREADY retry parked and then returns the failure", () => { @@ -158,6 +167,8 @@ describe("pending AF_INET connect routing", () => { expect(harness.completeChannel).toHaveBeenCalledOnce(); expect(harness.completeChannel.mock.calls[0].slice(4, 6)).toEqual([-1, ECONNREFUSED]); expect(harness.worker.pendingPollRetries.size).toBe(0); + expect(harness.isFdNonblock).toHaveBeenCalledOnce(); + expect(harness.getSocketTimeout).toHaveBeenCalledOnce(); }); it("does not apply the host-delegated AF_INET retry rule to AF_UNIX", () => { @@ -171,10 +182,12 @@ describe("pending AF_INET connect routing", () => { expect(harness.completeChannel).toHaveBeenCalledOnce(); expect(harness.completeChannel.mock.calls[0].slice(4, 6)).toEqual([-1, EINPROGRESS]); expect(harness.worker.pendingPollRetries.size).toBe(0); + expect(harness.isFdNonblock).not.toHaveBeenCalled(); + expect(harness.getSocketTimeout).not.toHaveBeenCalled(); }); it("names EALREADY in syscall diagnostics", () => { - const worker: any = Object.create(CentralizedKernelWorker.prototype); + const worker = createCentralizedKernelWorkerTestDouble(); expect(worker.formatSyscallReturn(ABI_SYSCALLS.Connect, -1, EALREADY)) .toBe(" = -1 (EALREADY)"); diff --git a/host/test/datagram-wakeup.test.ts b/host/test/datagram-wakeup.test.ts index b3819a22a2..1d0de0a8f5 100644 --- a/host/test/datagram-wakeup.test.ts +++ b/host/test/datagram-wakeup.test.ts @@ -1,5 +1,16 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { + createCentralizedKernelWorkerTestDouble, +} from "../src/kernel-worker"; +import { + ABI_SYSCALLS, + CHANNEL_STATUS_COMPLETE, + CHANNEL_STATUS_PENDING, + CH_ERRNO, + CH_RETURN, + CH_STATUS, + CH_SYSCALL, +} from "../src/generated/abi"; import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; const WAKE_DATAGRAM_WRITABLE = 8; @@ -9,27 +20,45 @@ function createSharedMemory(): WebAssembly.Memory { } function createWorkerHarness(): any { - const memory = createSharedMemory(); + const kernelMemory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); const drain = (outPtr: number): number => { - const bytes = new Uint8Array(memory.buffer, outPtr, 5); + const bytes = new Uint8Array(kernelMemory.buffer, outPtr, 5); bytes.fill(0); bytes[4] = WAKE_DATAGRAM_WRITABLE; return 1; }; - - const worker = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - kernel: { toKernelPtr: (value: number | bigint) => Number(value) }, - kernelInstance: { exports: { kernel_drain_wakeup_events: drain } }, - kernelMemory: memory, + const handleChannel = vi.fn((outPtr: number) => { + const view = new DataView(kernelMemory.buffer, outPtr); + view.setBigInt64(CH_RETURN, 42n, true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }); + const worker = Object.assign(createCentralizedKernelWorkerTestDouble(), { + activeChannels: [], processes: new Map(), pendingPollRetries: new Map(), pendingSelectRetries: new Map(), pendingPipeReaders: new Map(), pendingPipeWriters: new Map(), wakeScheduled: false, + usePolling: true, }); - installKernelWorkerTestScratch(worker, memory); - return worker; + installKernelWorkerTestScratch( + worker, + kernelMemory, + 128, + 4, + { + kernelExports: { + kernel_dequeue_signal: vi.fn(() => 0), + kernel_drain_wakeup_events: drain, + kernel_get_process_exit_signal: vi.fn(() => -1), + kernel_handle_channel: handleChannel, + kernel_set_current_tid: vi.fn(() => 0), + }, + }, + ); + return { handleChannel, worker }; } afterEach(() => { @@ -37,12 +66,34 @@ afterEach(() => { }); describe("datagram send-state wakeups", () => { - it("retries blocked writes immediately without bypassing signal-safe poll deferral", () => { + it("retries blocked writes immediately without bypassing signal-safe poll deferral", async () => { vi.useFakeTimers(); - const worker = createWorkerHarness(); - const channel = { pid: 42, channelOffset: 0, memory: createSharedMemory() }; - const pollChannel = { pid: channel.pid, channelOffset: 64, memory: channel.memory }; - worker.processes.set(channel.pid, { channels: [channel, pollChannel] }); + const { handleChannel, worker } = createWorkerHarness(); + const processMemory = createSharedMemory(); + const channel = { + pid: 42, + channelOffset: 0, + memory: processMemory, + i32View: new Int32Array(processMemory.buffer), + consecutiveSyscalls: 0, + }; + const pollChannel = { + pid: channel.pid, + channelOffset: 64, + memory: channel.memory, + i32View: new Int32Array(processMemory.buffer, 64), + consecutiveSyscalls: 0, + }; + worker.processes.set(channel.pid, { + pid: channel.pid, + memory: processMemory, + channels: [channel, pollChannel], + explicitMaxAddr: true, + }); + worker.activeChannels = [channel, pollChannel]; + const channelView = new DataView(processMemory.buffer); + channelView.setUint32(CH_STATUS, CHANNEL_STATUS_PENDING, true); + channelView.setUint32(CH_SYSCALL, ABI_SYSCALLS.Getpid, true); const fallback = vi.fn(); const timer = setTimeout(fallback, 1); @@ -59,20 +110,20 @@ describe("datagram send-state wakeups", () => { pipeIndices: [], needsSignalSafeWake: true, }); - worker.retrySyscall = vi.fn(); - worker.scheduleWakeBlockedRetries = vi.fn(); - worker.scheduleWakeBlockedRetriesDeferred = vi.fn(); worker.drainAndProcessWakeupEvents(); + for (let index = 0; index < 8; index++) await Promise.resolve(); - expect(worker.retrySyscall).toHaveBeenCalledOnce(); - expect(worker.retrySyscall).toHaveBeenCalledWith(channel); + expect(handleChannel).toHaveBeenCalledOnce(); + expect(channelView.getUint32(CH_STATUS, true)) + .toBe(CHANNEL_STATUS_COMPLETE); + expect(Number(channelView.getBigInt64(CH_RETURN, true))).toBe(42); + expect(channelView.getUint32(CH_ERRNO, true)).toBe(0); expect(worker.pendingPollRetries.has(channel)).toBe(false); expect(worker.pendingPollRetries.has(pollChannel)).toBe(true); - expect(worker.scheduleWakeBlockedRetries).not.toHaveBeenCalled(); - expect(worker.scheduleWakeBlockedRetriesDeferred).toHaveBeenCalledOnce(); - vi.runAllTimers(); + vi.advanceTimersByTime(49); + expect(worker.pendingPollRetries.has(pollChannel)).toBe(true); expect(fallback).not.toHaveBeenCalled(); }); }); diff --git a/host/test/deferred-worker-start.test.ts b/host/test/deferred-worker-start.test.ts index 9b4f61640a..ee4274f891 100644 --- a/host/test/deferred-worker-start.test.ts +++ b/host/test/deferred-worker-start.test.ts @@ -1,11 +1,19 @@ import { describe, expect, it, vi } from "vitest"; import { DeferredWorkerHandle } from "../src/deferred-worker-handle"; -import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { + type CentralizedKernelCallbacks, + createCentralizedKernelWorkerTestDouble, +} from "../src/kernel-worker"; +import { KernelReentrantEntryError } from "../src/kernel-entry-gate"; import { MockWorkerAdapter } from "../src/worker-adapter"; import { ABI_SYSCALLS, + CHANNEL_STATUS_COMPLETE, CH_ARGS, CH_ARG_SIZE, + CH_ERRNO, + CH_RETURN, + CH_STATUS, CH_SYSCALL, PROCESS_STATE_EXITED, PROCESS_STATE_RUNNING, @@ -85,12 +93,12 @@ describe("DeferredWorkerHandle", () => { }); describe("stopped process Worker launch gate", () => { - it("holds construction through STOPPED and releases it on SIGCONT", () => { + it("holds construction through STOPPED and releases it on SIGCONT", async () => { let processState = 1; const memory = createSharedMemory(); const start = vi.fn(); const cancel = vi.fn(); - const worker = createWorkerHarness(memory, () => processState); + const { worker } = createWorkerHarness(memory, () => processState); expect( worker.startProcessWorkerWhenRunnable(41, memory, start, cancel), @@ -98,71 +106,191 @@ describe("stopped process Worker launch gate", () => { expect(start).not.toHaveBeenCalled(); processState = 0; - worker.resumeStoppedProcess(41); + expect( + worker.testAuthority.resumeStoppedProcessForTest(41), + ).toBe(true); + await drainLifecycleGate(); expect(start).toHaveBeenCalledOnce(); expect(cancel).not.toHaveBeenCalled(); - expect(worker.deferredProcessWorkerStarts.has(41)).toBe(false); }); - it("cancels an exact deferred generation on exec replacement", () => { + it("cancels an exact deferred generation on exec replacement", async () => { let processState = 1; const oldMemory = createSharedMemory(); const newMemory = createSharedMemory(); const start = vi.fn(); const cancel = vi.fn(); - const worker = createWorkerHarness(oldMemory, () => processState); + const { worker } = createWorkerHarness(oldMemory, () => processState); expect( worker.startProcessWorkerWhenRunnable(41, oldMemory, start, cancel), ).toBe("deferred"); - worker.processes.set(41, { + worker.testAuthority.replaceProcessRegistrationForLifecycleTest({ + pid: 41, memory: newMemory, - channels: [ - { - pid: 41, - memory: newMemory, - channelOffset: 0, - i32View: new Int32Array(newMemory.buffer), - consecutiveSyscalls: 0, - }, - ], + channelOffsets: [0], }); processState = 0; - worker.resumeStoppedProcess(41); + expect( + worker.testAuthority.resumeStoppedProcessForTest(41), + ).toBe(true); + await drainLifecycleGate(); expect(start).not.toHaveBeenCalled(); expect(cancel).toHaveBeenCalledOnce(); }); - it("ignores a stale continue wake while the current Process is stopped", () => { + it("ignores a stale continue wake while the current Process is stopped", async () => { const memory = createSharedMemory(); const start = vi.fn(); const cancel = vi.fn(); - const worker = createWorkerHarness(memory, () => 1); + let processState = PROCESS_STATE_STOPPED; + const { worker } = createWorkerHarness(memory, () => processState); expect( worker.startProcessWorkerWhenRunnable(41, memory, start, cancel), ).toBe("deferred"); - worker.resumeStoppedProcess(41); + expect( + worker.testAuthority.resumeStoppedProcessForTest(41), + ).toBe(false); expect(start).not.toHaveBeenCalled(); expect(cancel).not.toHaveBeenCalled(); - expect(worker.deferredProcessWorkerStarts.has(41)).toBe(true); + processState = PROCESS_STATE_RUNNING; + expect( + worker.testAuthority.resumeStoppedProcessForTest(41), + ).toBe(true); + await drainLifecycleGate(); + expect(start).toHaveBeenCalledOnce(); + }); + + it("rejects reentrant lifecycle test seams without retaining caller values", async () => { + const memory = createSharedMemory(); + const replacementRead = vi.fn(); + const parkedRead = vi.fn(); + const errors: unknown[] = []; + let exerciseReentry = true; + let worker!: ReturnType< + typeof createCentralizedKernelWorkerTestDouble + >; + let registeredChannel!: ReturnType< + typeof createWorkerHarness + >["channel"]; + const getProcessState = vi.fn(() => { + if (exerciseReentry) { + exerciseReentry = false; + try { + worker.testAuthority.resumeStoppedProcessForTest(41); + } catch (error) { + errors.push(error); + } + const replacement = new Proxy( + { + pid: 41, + memory, + channelOffsets: [0], + }, + { + get(target, property, receiver) { + replacementRead(property); + return Reflect.get(target, property, receiver); + }, + }, + ); + try { + worker.testAuthority + .replaceProcessRegistrationForLifecycleTest(replacement); + } catch (error) { + errors.push(error); + } + const parked = new Proxy( + { + channel: registeredChannel, + tid: 99, + parentTidPointer: 512, + }, + { + get(target, property, receiver) { + parkedRead(property); + return Reflect.get(target, property, receiver); + }, + }, + ); + try { + worker.testAuthority.installParkedCloneCompletionForTest( + parked, + ); + } catch (error) { + errors.push(error); + } + } + return PROCESS_STATE_STOPPED; + }); + const harness = createWorkerHarness(memory, getProcessState); + worker = harness.worker; + registeredChannel = harness.channel; + + expect( + worker.testAuthority.resumeStoppedProcessForTest(41), + ).toBe(false); + expect(errors).toHaveLength(3); + for (const error of errors) { + expect(error).toBeInstanceOf(KernelReentrantEntryError); + } + expect(replacementRead).not.toHaveBeenCalled(); + expect(parkedRead).not.toHaveBeenCalled(); + const stateReadsAfterReturn = getProcessState.mock.calls.length; + await drainLifecycleGate(); + expect(getProcessState).toHaveBeenCalledTimes(stateReadsAfterReturn); + expect(replacementRead).not.toHaveBeenCalled(); + expect(parkedRead).not.toHaveBeenCalled(); + }); + + it("rejects a parked clone completion from a replaced memory generation", () => { + const oldMemory = createSharedMemory(); + const newMemory = createSharedMemory(); + const { worker, channel: oldChannel } = createWorkerHarness( + oldMemory, + () => PROCESS_STATE_STOPPED, + ); + const [currentChannel] = + worker.testAuthority.replaceProcessRegistrationForLifecycleTest({ + pid: 41, + memory: newMemory, + channelOffsets: [0], + }); + if (currentChannel === undefined) { + throw new Error("replacement lifecycle channel was not created"); + } + + expect(() => + worker.testAuthority.installParkedCloneCompletionForTest({ + channel: oldChannel, + tid: 99, + parentTidPointer: 512, + }) + ).toThrow(/exact registration/); + expect(() => + worker.testAuthority.installParkedCloneCompletionForTest({ + channel: currentChannel, + tid: 99, + parentTidPointer: 512, + }) + ).not.toThrow(); }); it("never queues a launch for an exited child", () => { const memory = createSharedMemory(); const start = vi.fn(); const cancel = vi.fn(); - const worker = createWorkerHarness(memory, () => 2); + const { worker } = createWorkerHarness(memory, () => 2); expect( worker.startProcessWorkerWhenRunnable(41, memory, start, cancel), ).toBe("dead"); expect(start).not.toHaveBeenCalled(); expect(cancel).toHaveBeenCalledOnce(); - expect(worker.deferredProcessWorkerStarts.has(41)).toBe(false); }); it("preflights a continuation observed before async child registration", () => { @@ -177,28 +305,40 @@ describe("stopped process Worker launch gate", () => { }; const start = vi.fn(); const cancel = vi.fn(); - const worker = createWorkerHarness(memory, () => processState); + const { worker } = createWorkerHarness( + memory, + () => processState, + { + kernelExports: { + kernel_dequeue_signal: vi.fn(() => { + processState = PROCESS_STATE_EXITED; + return 0; + }), + }, + }, + ); // Exec handoff retains the pid registration but temporarily has no exact // channel; an async fork/spawn can also have no registration at all. - worker.processes.set(41, { memory, channels: [] }); + worker.testAuthority.replaceProcessRegistrationForLifecycleTest({ + pid: 41, + memory, + channelOffsets: [], + }); processState = PROCESS_STATE_RUNNING; - expect(worker.resumeStoppedProcess(41)).toBe(true); - expect(worker.pendingResumePids.has(41)).toBe(true); - - worker.processes.set(41, { memory, channels: [channel] }); - worker.kernel = { toKernelPtr: (value: number) => value }; - worker.kernelMemory = createSharedMemory(); - installKernelWorkerTestScratch(worker, worker.kernelMemory); - worker.channelTids = new Map(); - worker.kernelInstance.exports.kernel_dequeue_signal = vi.fn(() => { - processState = PROCESS_STATE_EXITED; - return 0; - }); - worker.finishSignalTermination = vi.fn(() => { - if (processState !== PROCESS_STATE_EXITED) return false; - worker.discardStoppedChannelStateForProcess(41); - return true; + expect( + worker.testAuthority.resumeStoppedProcessForTest(41), + ).toBe(true); + const [registeredChannel] = + worker.testAuthority.replaceProcessRegistrationForLifecycleTest({ + pid: 41, + memory, + channelOffsets: [0], + }); + expect(registeredChannel).toMatchObject({ + pid: channel.pid, + memory: channel.memory, + channelOffset: channel.channelOffset, }); expect( @@ -206,7 +346,6 @@ describe("stopped process Worker launch gate", () => { ).toBe("dead"); expect(start).not.toHaveBeenCalled(); expect(cancel).toHaveBeenCalledOnce(); - expect(worker.pendingResumePids.has(41)).toBe(false); }); it("drains a re-stop generated by direct late-registration preflight", () => { @@ -214,18 +353,33 @@ describe("stopped process Worker launch gate", () => { const memory = createSharedMemory(); const start = vi.fn(); const cancel = vi.fn(); - const worker = createWorkerHarness(memory, () => processState); - worker.pendingResumePids.add(41); - worker.stoppedPids.add(41); - worker.kernel = { toKernelPtr: (value: number) => value }; - worker.kernelMemory = createSharedMemory(); - installKernelWorkerTestScratch(worker, worker.kernelMemory); - worker.kernelInstance.exports.kernel_dequeue_signal = vi.fn(() => { - processState = PROCESS_STATE_STOPPED; - return 0; + const drainWakeups = vi.fn(() => 0); + const { worker } = createWorkerHarness( + memory, + () => processState, + { + kernelExports: { + kernel_dequeue_signal: vi.fn(() => { + processState = PROCESS_STATE_STOPPED; + return 0; + }), + kernel_drain_wakeup_events: drainWakeups, + }, + }, + ); + worker.testAuthority.replaceProcessRegistrationForLifecycleTest({ + pid: 41, + memory, + channelOffsets: [], + }); + expect( + worker.testAuthority.resumeStoppedProcessForTest(41), + ).toBe(true); + worker.testAuthority.replaceProcessRegistrationForLifecycleTest({ + pid: 41, + memory, + channelOffsets: [0], }); - worker.finishSignalTermination = vi.fn(() => false); - worker.drainAndProcessWakeupEvents = vi.fn(); expect( worker.startProcessWorkerWhenRunnable(41, memory, start, cancel), @@ -233,27 +387,25 @@ describe("stopped process Worker launch gate", () => { expect(start).not.toHaveBeenCalled(); expect(cancel).not.toHaveBeenCalled(); - expect(worker.drainAndProcessWakeupEvents).toHaveBeenCalledOnce(); - expect(worker.stoppedPids.has(41)).toBe(true); + expect(drainWakeups).toHaveBeenCalledOnce(); }); it("cancels pending launches during process teardown", () => { const memory = createSharedMemory(); const start = vi.fn(); const cancel = vi.fn(); - const worker = createWorkerHarness(memory, () => 1); + const { worker } = createWorkerHarness(memory, () => 1); expect( worker.startProcessWorkerWhenRunnable(41, memory, start, cancel), ).toBe("deferred"); - worker.discardStoppedChannelStateForProcess(41); + worker.testAuthority.discardStoppedProcessStateForTest(41); expect(start).not.toHaveBeenCalled(); expect(cancel).toHaveBeenCalledOnce(); - expect(worker.deferredProcessWorkerStarts.has(41)).toBe(false); }); - it("turns deferred constructor failure into process exit and full teardown", () => { + it("turns deferred constructor failure into process exit and full teardown", async () => { let processState = 1; const memory = createSharedMemory(); const failure = new Error("Worker constructor failed after SIGCONT"); @@ -263,11 +415,18 @@ describe("stopped process Worker launch gate", () => { const cancel = vi.fn(); const laterStart = vi.fn(); const laterCancel = vi.fn(); - const notifyCrash = vi.fn(); + const markSignaled = vi.fn(() => 0); const onExit = vi.fn(); - const worker = createWorkerHarness(memory, () => processState); - worker.notifyHostProcessCrashed = notifyCrash; - worker.callbacks = { onExit }; + const { worker } = createWorkerHarness( + memory, + () => processState, + { + callbacks: { onExit }, + kernelExports: { + kernel_mark_process_signaled: markSignaled, + }, + }, + ); expect( worker.startProcessWorkerWhenRunnable(41, memory, start, cancel), @@ -282,18 +441,20 @@ describe("stopped process Worker launch gate", () => { ).toBe("deferred"); processState = 0; - worker.resumeStoppedProcess(41); + expect( + worker.testAuthority.resumeStoppedProcessForTest(41), + ).toBe(true); + await drainLifecycleGate(); expect(start).toHaveBeenCalledOnce(); expect(cancel).toHaveBeenCalledOnce(); expect(laterStart).not.toHaveBeenCalled(); expect(laterCancel).toHaveBeenCalledOnce(); - expect(notifyCrash).toHaveBeenCalledWith(41); + expect(markSignaled).toHaveBeenCalledWith(41, 11); expect(onExit).toHaveBeenCalledWith(41, 139); - expect(worker.deferredProcessWorkerStarts.has(41)).toBe(false); }); - it("rolls back only a deferred clone when its thread Worker cannot start", () => { + it("rolls back only a deferred clone when its thread Worker cannot start", async () => { let processState = 1; const memory = createSharedMemory(); const channel = { @@ -316,26 +477,25 @@ describe("stopped process Worker launch gate", () => { throw new Error("thread Worker failed"); }); const cancel = vi.fn(); - const notifyCrash = vi.fn(); - const publish = vi.fn(); - const worker = createWorkerHarness(memory, () => processState); - worker.processes.set(41, { memory, channels: [channel] }); - worker.notifyHostProcessCrashed = notifyCrash; - worker.publishPreparedChannelCompletion = publish; - worker.parkedChannelCompletions.set(channel, { - prepared: { - kind: "marshalled", - outputWrites: [], - retVal: tid, - errVal: 0, - materialized: true, - relistenRequested: true, - deferredClone: { - tid, - parentTidPointer: ptidPtr, + const markSignaled = vi.fn(() => 0); + const { worker, channel: registeredChannel } = createWorkerHarness( + memory, + () => processState, + { + kernelExports: { + kernel_mark_process_signaled: markSignaled, }, }, - relistenRequested: true, + ); + expect(registeredChannel).toMatchObject({ + pid: channel.pid, + memory: channel.memory, + channelOffset: channel.channelOffset, + }); + worker.testAuthority.installParkedCloneCompletionForTest({ + channel: registeredChannel, + tid, + parentTidPointer: ptidPtr, }); expect( @@ -356,46 +516,91 @@ describe("stopped process Worker launch gate", () => { view.setInt32(replacementPtidPtr, 0x12345678, true); processState = 0; - worker.resumeStoppedProcess(41); + expect( + worker.testAuthority.resumeStoppedProcessForTest(41), + ).toBe(true); + await drainLifecycleGate(); expect(cancel).toHaveBeenCalledOnce(); - expect(notifyCrash).not.toHaveBeenCalled(); + expect(markSignaled).not.toHaveBeenCalled(); expect(view.getInt32(ptidPtr, true)).toBe(0); expect(view.getInt32(replacementPtidPtr, true)).toBe(0x12345678); - expect(publish).toHaveBeenCalledWith( - channel, - expect.objectContaining({ retVal: -1, errVal: 12 }), - ); + expect(view.getBigInt64(CH_RETURN, true)).toBe(-1n); + expect(view.getUint32(CH_ERRNO, true)).toBe(12); + expect( + Atomics.load( + registeredChannel.i32View, + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + ), + ).toBe(CHANNEL_STATUS_COMPLETE); }); }); +interface LifecycleWorkerHarnessOptions { + readonly callbacks?: CentralizedKernelCallbacks; + readonly kernelExports?: Readonly>; +} + function createWorkerHarness( memory: WebAssembly.Memory, getProcessState: () => number, -): any { - const channel = { - pid: 41, - memory, - channelOffset: 0, - i32View: new Int32Array(memory.buffer), - consecutiveSyscalls: 0, + options: LifecycleWorkerHarnessOptions = {}, +): { + readonly worker: ReturnType< + typeof createCentralizedKernelWorkerTestDouble + >; + readonly channel: { + readonly pid: number; + readonly memory: WebAssembly.Memory; + readonly channelOffset: number; + readonly i32View: Int32Array; + consecutiveSyscalls: number; }; - return Object.assign(Object.create(CentralizedKernelWorker.prototype), { - kernelInstance: { - exports: { - kernel_get_process_state: getProcessState, - kernel_get_process_exit_signal: vi.fn(() => -1), - kernel_set_current_tid: vi.fn(() => 0), - }, - }, - processes: new Map([[41, { memory, channels: [channel] }]]), - channelTids: new Map(), - stoppedPids: new Set(), - pendingResumePids: new Set(), - deferredProcessWorkerStarts: new Map(), - parkedChannelCompletions: new Map(), - deferredStoppedChannels: new Map(), +} { + const implementations: Record = { + kernel_dequeue_signal: vi.fn(() => 0), + kernel_drain_wakeup_events: vi.fn(() => 0), + kernel_get_parent_pid: vi.fn(() => -1), + kernel_get_process_exit_signal: vi.fn(() => + getProcessState() === PROCESS_STATE_EXITED ? 11 : -1 + ), + kernel_get_process_state: getProcessState, + kernel_mark_process_signaled: vi.fn(() => 0), + kernel_set_current_tid: vi.fn(() => 0), + ...(options.kernelExports ?? {}), + }; + const worker = createCentralizedKernelWorkerTestDouble({ + callbacks: options.callbacks, }); + const kernelMemory = new WebAssembly.Memory({ + initial: 2, + maximum: 2, + }); + installKernelWorkerTestScratch( + worker, + kernelMemory, + 1024, + 4, + { kernelExports: implementations }, + ); + const [channel] = + worker.testAuthority.replaceProcessRegistrationForLifecycleTest({ + pid: 41, + memory, + channelOffsets: [0], + }); + if (channel === undefined) { + throw new Error("lifecycle harness did not create its main channel"); + } + return { worker, channel }; +} + +async function drainLifecycleGate(): Promise { + // Resume publishes Worker construction and any crash rollback only after + // the exact kernel-entry scope is revoked. + for (let turn = 0; turn < 24; turn++) { + await Promise.resolve(); + } } function createSharedMemory(): WebAssembly.Memory { diff --git a/host/test/dri-kms-stats-sab.test.ts b/host/test/dri-kms-stats-sab.test.ts index d1b34dd478..092d154cac 100644 --- a/host/test/dri-kms-stats-sab.test.ts +++ b/host/test/dri-kms-stats-sab.test.ts @@ -1,6 +1,17 @@ -import { beforeAll, describe, expect, it } from "vitest"; -import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { + createCentralizedKernelWorkerTestDouble, +} from "../src/kernel-worker"; import { NodePlatformIO } from "../src/platform/node"; +import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; beforeAll(() => { if (typeof (globalThis as { ImageData?: unknown }).ImageData === "undefined") { @@ -18,14 +29,37 @@ function makeFakeCanvas(): OffscreenCanvas { } as unknown as OffscreenCanvas; } -function makeKernel(): CentralizedKernelWorker { - return new CentralizedKernelWorker( - { maxWorkers: 1, dataBufferSize: 65536, useSharedMemory: true }, - new NodePlatformIO(), +type TestKernel = ReturnType; + +function makeKernel( + kernelExports: Readonly> = {}, +): TestKernel { + const kernel = createCentralizedKernelWorkerTestDouble({ + config: { + maxWorkers: 1, + dataBufferSize: 65_536, + useSharedMemory: true, + }, + io: new NodePlatformIO(), + }); + const implementations = { + kernel_vblank: () => 0, + ...kernelExports, + }; + installKernelWorkerTestScratch( + kernel, + new WebAssembly.Memory({ initial: 2 }), + 128, + 4, + { + kernelExports: implementations, + kernelExportNames: Object.keys(implementations), + }, ); + return kernel; } -function stubScanout(kernel: CentralizedKernelWorker, w: number, h: number): void { +function stubScanout(kernel: TestKernel, w: number, h: number): void { const fb = { fb_id: 10, bo_id: 100, width: w, height: h, pixel_format: 0, pitch: w * 4 }; const pixels = new Uint8Array(w * h * 4); (kernel.kms as unknown as { currentFb: (id: number) => unknown }).currentFb = () => fb; @@ -33,6 +67,17 @@ function stubScanout(kernel: CentralizedKernelWorker, w: number, h: number): voi } describe("CentralizedKernelWorker KMS stats SAB", () => { + beforeEach(() => { + // The worker captures its scheduler during construction, so install fake + // timers before makeKernel and exercise the real registered interval. + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + it("tickVblank writes [count, ts_ms, width, height, tick_us] when a statsSab is attached", () => { const kernel = makeKernel(); stubScanout(kernel, 32, 24); @@ -43,14 +88,14 @@ describe("CentralizedKernelWorker KMS stats SAB", () => { // "auto" mode skips the blit branch and slots 0/1/4 stay 0. kernel.attachKmsCanvas(1, makeFakeCanvas(), statsSab, { mode: "2d" }); - (kernel as unknown as { tickVblank: () => void }).tickVblank(); + vi.advanceTimersByTime(17); expect(Atomics.load(view, 0)).toBe(1); expect(Atomics.load(view, 2)).toBe(32); expect(Atomics.load(view, 3)).toBe(24); expect(Atomics.load(view, 1)).toBeGreaterThanOrEqual(0); expect(Atomics.load(view, 4)).toBeGreaterThanOrEqual(0); - (kernel as unknown as { tickVblank: () => void }).tickVblank(); + vi.advanceTimersByTime(17); expect(Atomics.load(view, 0)).toBe(2); }); @@ -58,25 +103,19 @@ describe("CentralizedKernelWorker KMS stats SAB", () => { const kernel = makeKernel(); stubScanout(kernel, 8, 8); kernel.attachKmsCanvas(1, makeFakeCanvas()); - expect(() => (kernel as unknown as { tickVblank: () => void }).tickVblank()).not.toThrow(); + expect(() => vi.advanceTimersByTime(17)).not.toThrow(); }); it("tickVblank fills slots 5/6 from kernel kms_commit_count + kms_last_frame_us when SAB is sized for them", () => { - const kernel = makeKernel(); + const kernel = makeKernel({ + kernel_kms_commit_count: (_crtc: number) => 42n, + kernel_kms_last_frame_us: (_crtc: number) => 16_667n, + }); stubScanout(kernel, 16, 16); - // Stub the kernel-wasm exports the way the production tickVblank - // path will read them, so the test exercises the real wire-up - // rather than the "no kernel instance → 0" defensive fallback. - (kernel as unknown as { kernelInstance: unknown }).kernelInstance = { - exports: { - kernel_kms_commit_count: (_crtc: number) => 42n, - kernel_kms_last_frame_us: (_crtc: number) => 16_667n, - }, - }; const statsSab = new SharedArrayBuffer(7 * 4); const view = new Int32Array(statsSab); kernel.attachKmsCanvas(1, makeFakeCanvas(), statsSab); - (kernel as unknown as { tickVblank: () => void }).tickVblank(); + vi.advanceTimersByTime(17); expect(Atomics.load(view, 5)).toBe(42); expect(Atomics.load(view, 6)).toBe(16_667); }); @@ -89,7 +128,7 @@ describe("CentralizedKernelWorker KMS stats SAB", () => { const statsSab = new SharedArrayBuffer(5 * 4); const view = new Int32Array(statsSab); kernel.attachKmsCanvas(1, makeFakeCanvas(), statsSab); - (kernel as unknown as { tickVblank: () => void }).tickVblank(); + vi.advanceTimersByTime(17); expect(Atomics.load(view, 2)).toBe(1920); expect(Atomics.load(view, 3)).toBe(1080); expect(Atomics.load(view, 0)).toBe(0); @@ -97,36 +136,30 @@ describe("CentralizedKernelWorker KMS stats SAB", () => { }); it("tickVblank leaves slots 5/6 alone when the SAB is the legacy 5-slot size", () => { - const kernel = makeKernel(); - stubScanout(kernel, 16, 16); - (kernel as unknown as { kernelInstance: unknown }).kernelInstance = { - exports: { - kernel_kms_commit_count: (_crtc: number) => { - throw new Error("should not be called for a 5-slot SAB"); - }, - kernel_kms_last_frame_us: (_crtc: number) => { - throw new Error("should not be called for a 5-slot SAB"); - }, + const kernel = makeKernel({ + kernel_kms_commit_count: (_crtc: number) => { + throw new Error("should not be called for a 5-slot SAB"); }, - }; + kernel_kms_last_frame_us: (_crtc: number) => { + throw new Error("should not be called for a 5-slot SAB"); + }, + }); + stubScanout(kernel, 16, 16); const statsSab = new SharedArrayBuffer(5 * 4); kernel.attachKmsCanvas(1, makeFakeCanvas(), statsSab); - expect(() => (kernel as unknown as { tickVblank: () => void }).tickVblank()).not.toThrow(); + expect(() => vi.advanceTimersByTime(17)).not.toThrow(); }); it("attachKmsStats publishes slots 5/6 without a canvas attachment", () => { - const kernel = makeKernel(); - (kernel as unknown as { kernelInstance: unknown }).kernelInstance = { - exports: { - kernel_kms_commit_count: (_crtc: number) => 7n, - kernel_kms_last_frame_us: (_crtc: number) => 16_500n, - }, - }; + const kernel = makeKernel({ + kernel_kms_commit_count: (_crtc: number) => 7n, + kernel_kms_last_frame_us: (_crtc: number) => 16_500n, + }); const statsSab = new SharedArrayBuffer(7 * 4); const view = new Int32Array(statsSab); kernel.attachKmsStats(0, statsSab); - (kernel as unknown as { tickVblank: () => void }).tickVblank(); + vi.advanceTimersByTime(17); expect(Atomics.load(view, 5)).toBe(7); expect(Atomics.load(view, 6)).toBe(16_500); expect(Atomics.load(view, 0)).toBe(0); @@ -135,19 +168,16 @@ describe("CentralizedKernelWorker KMS stats SAB", () => { }); it("attachKmsStats leaves slots 5/6 untouched when the SAB is too small", () => { - const kernel = makeKernel(); - (kernel as unknown as { kernelInstance: unknown }).kernelInstance = { - exports: { - kernel_kms_commit_count: (_crtc: number) => { - throw new Error("should not be called for a 5-slot SAB"); - }, - kernel_kms_last_frame_us: (_crtc: number) => { - throw new Error("should not be called for a 5-slot SAB"); - }, + const kernel = makeKernel({ + kernel_kms_commit_count: (_crtc: number) => { + throw new Error("should not be called for a 5-slot SAB"); }, - }; + kernel_kms_last_frame_us: (_crtc: number) => { + throw new Error("should not be called for a 5-slot SAB"); + }, + }); const statsSab = new SharedArrayBuffer(5 * 4); kernel.attachKmsStats(0, statsSab); - expect(() => (kernel as unknown as { tickVblank: () => void }).tickVblank()).not.toThrow(); + expect(() => vi.advanceTimersByTime(17)).not.toThrow(); }); }); diff --git a/host/test/environment-transaction-fixture.ts b/host/test/environment-transaction-fixture.ts new file mode 100644 index 0000000000..6fc95397b2 --- /dev/null +++ b/host/test/environment-transaction-fixture.ts @@ -0,0 +1,81 @@ +import { execFileSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + captureProgramFixtureBuildContract, + programFixtureNeedsRebuild, + stampProgramFixture, + type ProgramFixtureBuildContract, +} from "./program-fixture-freshness"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const fixtureDirectory = join( + dirname(fileURLToPath(import.meta.url)), + "fixtures", +); +const buildFlags = [ + "-DKANDELO_ENV_TRANSACTION_TEST_WRAPPERS=1", + "-Wl,--wrap=malloc", + "-Wl,--wrap=realloc", + "-Wl,--wrap=__syscall1", + "-Wl,--wrap=__syscall3", +] as const; +const contracts = new Map<"wasm32" | "wasm64", ProgramFixtureBuildContract>(); + +function fixtureBuildContract( + arch: "wasm32" | "wasm64", +): ProgramFixtureBuildContract { + const cached = contracts.get(arch); + if (cached) return cached; + + const compiler = `${arch}posix-cc`; + const compilerVersion = execFileSync(compiler, ["--version"], { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + const contract = captureProgramFixtureBuildContract( + repoRoot, + `${arch}\nfork=false\nflags=${buildFlags.join(" ")}\n${compilerVersion}`, + [ + join(repoRoot, "sdk/bin"), + join(repoRoot, "sdk/src"), + join(repoRoot, "sdk/package.json"), + join(repoRoot, "sdk/package-lock.json"), + join(repoRoot, arch === "wasm64" ? "sysroot64" : "sysroot"), + ], + ); + contracts.set(arch, contract); + return contract; +} + +/** + * Build the public environment fixture with link-time-only failure injection. + * + * WHY: transaction regressions must exercise real libc public functions and + * raw syscall errno conversion without adding a production failure hook. + */ +export function ensureEnvironmentTransactionFixture( + arch: "wasm32" | "wasm64", +): string { + const src = join(repoRoot, "examples/putenv_test.c"); + // WHY: the browser contract owns examples/putenv_test*.wasm as ordinary + // production-linked fixtures. Keep these fault-wrapped binaries in the + // Node-test tree so test order cannot silently replace browser evidence. + const out = join( + fixtureDirectory, + arch === "wasm64" + ? "environment-transaction.wasm64.wasm" + : "environment-transaction.wasm", + ); + const contract = fixtureBuildContract(arch); + if (programFixtureNeedsRebuild(src, out, contract)) { + console.log(`[fixture] Compiling putenv_test.c for ${arch}...`); + execFileSync(`${arch}posix-cc`, [...buildFlags, src, "-o", out], { + cwd: repoRoot, + stdio: "pipe", + }); + stampProgramFixture(src, out, contract); + } + return out; +} diff --git a/host/test/exec-state-tracking.test.ts b/host/test/exec-state-tracking.test.ts index 802b337dc8..ccf1d2ce69 100644 --- a/host/test/exec-state-tracking.test.ts +++ b/host/test/exec-state-tracking.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it, vi } from "vitest"; import { + createCentralizedKernelWorkerTestDouble, CentralizedKernelWorker, isCurrentProcessGeneration, + } from "../src/kernel-worker"; import { ABI_SYSCALLS, @@ -11,9 +13,10 @@ import { CH_ARGS, CH_DATA, CH_DATA_SIZE, + CH_ERRNO, CH_RETURN, - CH_SIG_BASE, CH_SIG_HANDLER, + CH_SIG_SI_CODE, CH_SIG_SIGNUM, CH_STATUS, CH_SYSCALL, @@ -50,35 +53,23 @@ describe("exec host-state transition", () => { true, ); - const completeRaw = vi.fn(( - channel: ReturnType, - retVal: number, - errVal: number, - ) => { - expect(retVal).toBe(-1); - expect(errVal).toBe(4); - Atomics.store( - channel.i32View, - CH_STATUS / 4, - CHANNEL_STATUS_COMPLETE, - ); - }); const worker = createWorker({ processes: new Map([[ 7, { pid: 7, memory, channels: [mainChannel, threadChannel] }, ]]), - completeChannelRaw: completeRaw, }); expect(worker.wakeProcessWorkersForExecRetirement(7, memory)).toEqual( new Set([0, 3 * 65536]), ); - expect(completeRaw).toHaveBeenCalledTimes(2); for (const view of [main, thread]) { + expect(view.getUint32(CH_STATUS, true)).toBe(CHANNEL_STATUS_COMPLETE); + expect(Number(view.getBigInt64(CH_RETURN, true))).toBe(-1); + expect(view.getUint32(CH_ERRNO, true)).toBe(4); expect(view.getUint32(CH_SIG_SIGNUM, true)).toBe(9); expect(view.getUint32(CH_SIG_HANDLER, true)).toBe(0); - expect(view.getUint32(CH_SIG_BASE + 24, true)).toBe( + expect(view.getUint32(CH_SIG_SI_CODE, true)).toBe( EXEC_RETIRE_SIGNAL_CODE, ); } @@ -89,7 +80,7 @@ describe("exec host-state transition", () => { shared: true, }); expect(() => - worker.wakeProcessWorkersForExecRetirement(7, replacement), + worker.wakeProcessWorkersForExecRetirement(7, replacement) ).toThrow("generation changed"); }); @@ -128,7 +119,7 @@ describe("exec host-state transition", () => { )).toBe(false); }); - it("drops discarded-image async and thread-channel state", () => { + it("drops discarded-image async and thread-channel state", async () => { vi.useFakeTimers(); try { const memory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); @@ -182,17 +173,28 @@ describe("exec host-state transition", () => { ]), }); const notify = vi.spyOn(Atomics, "notify"); - const pendingAttachment = issueThreadAttachment(worker, mainChannel, 11); + const parkedMain = worker.parkedChannelCompletions.get(mainChannel); + worker.parkedChannelCompletions.delete(mainChannel); + worker.stoppedPids.delete(7); + const pendingAttachment = await issueThreadAttachment( + worker, + mainChannel, + 11, + ); + if (parkedMain) { + worker.parkedChannelCompletions.set(mainChannel, parkedMain); + } + worker.stoppedPids.add(7); worker.prepareProcessForExec(7); expect(worker.processes.has(7)).toBe(true); expect(worker.processes.get(7).channels).toEqual([]); expect(worker.isExecHandoffActive(7)).toBe(true); - expect(() => worker.attachThreadChannel( - pendingAttachment, - 512, - )).toThrow(/replacing its image/); + expectGateFailureCause( + () => worker.attachThreadChannel(pendingAttachment, 512), + "replacing its image", + ); expect(worker.processes.has(8)).toBe(true); expect(worker.activeChannels).toEqual([otherChannel]); expect(worker.waitingForChild).toEqual([ @@ -229,7 +231,7 @@ describe("exec host-state transition", () => { } }); - it("rejects an old-memory clone after replacement registration", () => { + it("rejects an old-memory clone after replacement registration", async () => { const oldMemory = new WebAssembly.Memory({ initial: 1 }); const newMemory = new WebAssembly.Memory({ initial: 1 }); const oldChannel = createChannel(7, oldMemory, 0); @@ -237,14 +239,19 @@ describe("exec host-state transition", () => { processes: new Map([[7, { channels: [oldChannel], memory: oldMemory }]]), activeChannels: [oldChannel], }); - const pendingAttachment = issueThreadAttachment(worker, oldChannel, 11, 1, 2); + const pendingAttachment = await issueThreadAttachment( + worker, + oldChannel, + 11, + 1, + 2, + ); worker.processes.set(7, { channels: [], memory: newMemory }); - expect(() => worker.attachThreadChannel( - pendingAttachment, - 512, - )) - .toThrow(/changed memory generation/); + expectGateFailureCause( + () => worker.attachThreadChannel(pendingAttachment, 512), + "changed memory generation", + ); expect(worker.processes.get(7).channels).toEqual([]); }); @@ -254,7 +261,6 @@ describe("exec host-state transition", () => { const memory = new WebAssembly.Memory({ initial: 3, maximum: 3, shared: true }); const mainChannel = createChannel(7, memory, 0); const threadChannel = createChannel(7, memory, 0x10000); - const completeSleep = vi.fn(); const mainDetachedOutput = [{ ptr: 0x800, bytes: Uint8Array.of(1), @@ -268,8 +274,17 @@ describe("exec host-state transition", () => { channels: [mainChannel, threadChannel], memory, }]]), - completeSleepWithSignalCheck: completeSleep, }); + Atomics.store( + mainChannel.i32View, + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + CHANNEL_STATUS_PENDING, + ); + Atomics.store( + threadChannel.i32View, + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + CHANNEL_STATUS_PENDING, + ); expect(worker.handleSleepDelay( mainChannel, @@ -292,27 +307,23 @@ describe("exec host-state transition", () => { expect(worker.pendingSleeps.size).toBe(2); await vi.advanceTimersByTimeAsync(10); - expect(completeSleep).toHaveBeenCalledTimes(1); - expect(completeSleep).toHaveBeenLastCalledWith( - threadChannel, - ABI_SYSCALLS.Usleep, - [10_000], - 0, - 0, - threadDetachedOutput, - ); + expect(Atomics.load( + threadChannel.i32View, + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + )).not.toBe(CHANNEL_STATUS_PENDING); + expect(new Uint8Array(memory.buffer)[0x900]).toBe(2); + expect(Atomics.load( + mainChannel.i32View, + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + )).toBe(CHANNEL_STATUS_PENDING); expect(worker.pendingSleeps.has(mainChannel)).toBe(true); await vi.advanceTimersByTimeAsync(40); - expect(completeSleep).toHaveBeenCalledTimes(2); - expect(completeSleep).toHaveBeenLastCalledWith( - mainChannel, - ABI_SYSCALLS.Usleep, - [50_000], - 0, - 0, - mainDetachedOutput, - ); + expect(Atomics.load( + mainChannel.i32View, + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + )).not.toBe(CHANNEL_STATUS_PENDING); + expect(new Uint8Array(memory.buffer)[0x800]).toBe(1); expect(worker.pendingSleeps.size).toBe(0); } finally { vi.useRealTimers(); @@ -341,40 +352,98 @@ describe("exec host-state transition", () => { expect(handleSyscall).not.toHaveBeenCalled(); }); - it("does not wake a signal-dead image after async exec failure", () => { - const memory = new WebAssembly.Memory({ initial: 1 }); + it("does not wake a signal-dead image after async exec failure", async () => { + const memory = new WebAssembly.Memory({ + initial: 1, + maximum: 1, + shared: true, + }); const channel = createChannel(7, memory, 0); - const handleProcessTerminated = vi.fn(); - const completeChannel = vi.fn(); + const pathPtr = 0x100; + new Uint8Array(memory.buffer).set( + new TextEncoder().encode("/bin/missing\0"), + pathPtr, + ); + let finishExec!: (result: number) => void; + const launched = new Promise((resolve) => { + finishExec = resolve; + }); + const getProcessExitSignal = vi.fn(() => 11); + const onExit = vi.fn(); const worker = createWorker({ processes: new Map([[7, { channels: [channel], memory }]]), - getProcessExitSignal: vi.fn(() => 11), - handleProcessTerminated, - completeChannel, + callbacks: { + onExec: vi.fn(() => launched), + onExit, + }, + kernelInstance: { + exports: { kernel_get_process_exit_signal: getProcessExitSignal }, + }, }); - worker.finishFailedExec(channel, 211, [0, 0, 0], 3); + writeChannelSyscall( + channel, + HOST_INTERCEPTED_SYSCALLS.SYS_EXECVE, + [pathPtr, 0, 0], + ); + worker.handleSyscall(channel); + await flushMicrotasksUntil( + () => worker.callbacks.onExec.mock.calls.length === 1, + "exec callback did not start", + ); + finishExec(-3); + await flushMicrotasksUntil( + () => worker.hostReaped.has(7), + "signal-dead exec image was not reaped", + ); - expect(handleProcessTerminated).toHaveBeenCalledWith(channel); - expect(completeChannel).not.toHaveBeenCalled(); + expect(getProcessExitSignal).toHaveBeenCalledWith(7); + expect(onExit).toHaveBeenCalledWith(7, 139); + expect(readChannelStatus(channel)).toBe(CHANNEL_STATUS_PENDING); }); - it("does not wake a normally reaped image after async exec failure", () => { - const memory = new WebAssembly.Memory({ initial: 1 }); + it("does not wake a normally reaped image after async exec failure", async () => { + const memory = new WebAssembly.Memory({ + initial: 1, + maximum: 1, + shared: true, + }); const channel = createChannel(7, memory, 0); + const pathPtr = 0x100; + new Uint8Array(memory.buffer).set( + new TextEncoder().encode("/bin/missing\0"), + pathPtr, + ); + let finishExec!: (result: number) => void; + const launched = new Promise((resolve) => { + finishExec = resolve; + }); const getProcessExitSignal = vi.fn(() => 0); - const completeChannel = vi.fn(); const worker = createWorker({ processes: new Map([[7, { channels: [channel], memory }]]), - hostReaped: new Set([7]), - getProcessExitSignal, - completeChannel, + callbacks: { onExec: vi.fn(() => launched) }, + kernelInstance: { + exports: { kernel_get_process_exit_signal: getProcessExitSignal }, + }, }); - worker.finishFailedExec(channel, 211, [0, 0, 0], 3); + writeChannelSyscall( + channel, + HOST_INTERCEPTED_SYSCALLS.SYS_EXECVE, + [pathPtr, 0, 0], + ); + worker.handleSyscall(channel); + await flushMicrotasksUntil( + () => worker.callbacks.onExec.mock.calls.length === 1, + "exec callback did not start", + ); + worker.hostReaped.add(7); + getProcessExitSignal.mockClear(); + finishExec(-3); + await flushMicrotasks(); expect(getProcessExitSignal).not.toHaveBeenCalled(); - expect(completeChannel).not.toHaveBeenCalled(); + expect(readChannelStatus(channel)).toBe(CHANNEL_STATUS_PENDING); }); it("does not create a spawn child after async resolution loses its parent channel", async () => { @@ -392,28 +461,34 @@ describe("exec host-state transition", () => { }); const kernelSpawn = vi.fn(() => 100); const onSpawn = vi.fn(async () => 0); - const completeChannel = vi.fn(); const worker = createWorker({ processes: new Map([[7, { channels: [channel], memory }]]), callbacks: { onResolveSpawn: vi.fn(() => program), onSpawn, }, - completeChannel, kernelInstance: { exports: { kernel_spawn_process: kernelSpawn }, }, }); - worker.handleSpawn(channel, [pathPtr, path.length, blobPtr, 40, 0, 0]); + writeChannelSyscall( + channel, + HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN, + [pathPtr, path.length, blobPtr, 40, 0, 0], + ); + worker.handleSyscall(channel); + await flushMicrotasksUntil( + () => worker.callbacks.onResolveSpawn.mock.calls.length === 1, + "spawn resolution did not start", + ); worker.processes.get(7).channels = []; resolveProgram(resolvedProgram()); - await Promise.resolve(); - await Promise.resolve(); + await flushMicrotasks(); expect(kernelSpawn).not.toHaveBeenCalled(); expect(onSpawn).not.toHaveBeenCalled(); - expect(completeChannel).not.toHaveBeenCalled(); + expect(readChannelStatus(channel)).toBe(CHANNEL_STATUS_PENDING); }); it("rejects an unlaunchable spawn before creating a child or applying file actions", async () => { @@ -427,53 +502,60 @@ describe("exec host-state transition", () => { bytes.fill(0, blobPtr, blobPtr + 40); const kernelSpawn = vi.fn(() => 100); const onSpawn = vi.fn(async () => 0); - const completeChannel = vi.fn(); const worker = createWorker({ processes: new Map([[7, { channels: [channel], memory }]]), callbacks: { onResolveSpawn: vi.fn(async () => ({ errno: 8 })), onSpawn, }, - completeChannel, kernelInstance: { exports: { kernel_spawn_process: kernelSpawn }, }, }); - worker.handleSpawn(channel, [pathPtr, path.length, blobPtr, 40, 0, 0]); - await Promise.resolve(); - await Promise.resolve(); - - expect(kernelSpawn).not.toHaveBeenCalled(); - expect(onSpawn).not.toHaveBeenCalled(); - expect(completeChannel).toHaveBeenCalledWith( + writeChannelSyscall( channel, HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN, [pathPtr, path.length, blobPtr, 40, 0, 0], - undefined, - -1, - 8, ); + worker.handleSyscall(channel); + await flushMicrotasksUntil( + () => readChannelStatus(channel) !== CHANNEL_STATUS_PENDING, + "unlaunchable spawn did not complete", + ); + + expect(kernelSpawn).not.toHaveBeenCalled(); + expect(onSpawn).not.toHaveBeenCalled(); + expect(readChannelCompletion(channel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: -1, + errno: 8, + }); }); it("keeps a created spawn child but suppresses stale parent completion", async () => { const memory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); const channel = createChannel(7, memory, 0); + const pathPtr = 0x100; + const path = new TextEncoder().encode("/bin/child"); + new Uint8Array(memory.buffer).set(path, pathPtr); + const blobPtr = 0x200; + new Uint8Array(memory.buffer).fill(0, blobPtr, blobPtr + 40); let finishSpawn!: (result: number) => void; const spawned = new Promise((resolve) => { finishSpawn = resolve; }); const kernelSpawn = vi.fn(() => 100); const removeProcess = vi.fn(); - const completeChannel = vi.fn(); const onSpawn = vi.fn(() => spawned); const program = resolvedProgram(); const worker = createWorker({ processes: new Map([[7, { channels: [channel], memory }]]), - callbacks: { onSpawn }, - completeChannel, + callbacks: { + onResolveSpawn: vi.fn(async () => program), + onSpawn, + }, kernelMemory: new WebAssembly.Memory({ initial: 2 }), - toKernelPtr: (value: number) => value, kernelInstance: { exports: { kernel_spawn_process: kernelSpawn, @@ -482,31 +564,37 @@ describe("exec host-state transition", () => { }, }); - worker.handleSpawnAfterResolve( + writeChannelSyscall( channel, - [0, 0, 0, 40, 0, 0], - 7, - 7, - 0, - new Uint8Array(40), - 40, - program, - [], + HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN, + [pathPtr, path.length, blobPtr, 40, 0, 0], ); + worker.handleSyscall(channel); + await flushMicrotasks(); + expect(worker.callbacks.onResolveSpawn).toHaveBeenCalledOnce(); + expect( + kernelSpawn, + JSON.stringify(readChannelCompletion(channel)), + ).toHaveBeenCalledOnce(); + expect(onSpawn).toHaveBeenCalledOnce(); expect(onSpawn).toHaveBeenCalledWith(7, 100, program, []); worker.processes.get(7).channels = []; finishSpawn(0); - await Promise.resolve(); - await Promise.resolve(); + await flushMicrotasks(); expect(kernelSpawn).toHaveBeenCalled(); expect(removeProcess).not.toHaveBeenCalled(); - expect(completeChannel).not.toHaveBeenCalled(); + expect(readChannelStatus(channel)).toBe(CHANNEL_STATUS_PENDING); }); it("installs spawn-child listener mirrors before async worker launch", async () => { const memory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); const channel = createChannel(7, memory, 0); + const pathPtr = 0x100; + const path = new TextEncoder().encode("/bin/child"); + new Uint8Array(memory.buffer).set(path, pathPtr); + const blobPtr = 0x200; + new Uint8Array(memory.buffer).fill(0, blobPtr, blobPtr + 40); let finishSpawn!: (result: number) => void; const spawned = new Promise((resolve) => { finishSpawn = resolve; @@ -520,16 +608,19 @@ describe("exec host-state transition", () => { }; const worker = createWorker({ processes: new Map([[7, { channels: [channel], memory }]]), - callbacks: { onSpawn: vi.fn(() => spawned) }, - completeChannel: vi.fn(), + callbacks: { + onResolveSpawn: vi.fn(async () => resolvedProgram()), + onSpawn: vi.fn(() => spawned), + }, kernelMemory: new WebAssembly.Memory({ initial: 2 }), - toKernelPtr: (value: number) => value, kernelInstance: { exports: { kernel_spawn_process: () => 100, kernel_remove_process: vi.fn(), kernel_get_fd_accept_wake_idx: (_pid: number, fd: number) => fd === 4 ? 41 : -1, + kernel_find_listener_fd_by_accept_wake: + (_pid: number, wakeIdx: number) => wakeIdx === 41 ? 4 : -1, }, }, tcpListenerTargets: new Map([[8080, [{ @@ -541,17 +632,18 @@ describe("exec host-state transition", () => { tcpListeners: new Map([["7:4", listener]]), }); - worker.handleSpawnAfterResolve( + writeChannelSyscall( channel, - [0, 0, 0, 40, 0, 0], - 7, - 7, - 0, - new Uint8Array(40), - 40, - resolvedProgram(), - [], + HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN, + [pathPtr, path.length, blobPtr, 40, 0, 0], ); + worker.handleSyscall(channel); + await flushMicrotasks(); + expect(worker.callbacks.onResolveSpawn).toHaveBeenCalledOnce(); + expect( + worker.callbacks.onSpawn, + JSON.stringify(readChannelCompletion(channel)), + ).toHaveBeenCalledOnce(); expect(worker.tcpListenerTargets.get(8080)).toContainEqual({ pid: 100, @@ -560,8 +652,18 @@ describe("exec host-state transition", () => { }); worker.cleanupTcpListeners(7); expect(close).not.toHaveBeenCalled(); + expect(worker.pickListenerTarget(8080)).toBeNull(); + + const childMemory = new WebAssembly.Memory({ + initial: 1, + maximum: 1, + shared: true, + }); + worker.registerProcess(100, childMemory, [0]); + expect(worker.pickListenerTarget(8080)).toEqual({ pid: 100, fd: 4 }); + finishSpawn(0); - await Promise.resolve(); + await flushMicrotasks(); }); it("drops a stale channel listener after the pid is re-registered", async () => { @@ -569,14 +671,16 @@ describe("exec host-state transition", () => { const newMemory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); const oldChannel = createChannel(7, oldMemory, 0); const newChannel = createChannel(7, newMemory, 0); + const kernelHandleChannel = vi.fn(() => 0); const worker = createWorker({ processes: new Map([[7, { channels: [oldChannel], memory: oldMemory }]]), activeChannels: [oldChannel], usePolling: false, relistenBatchSize: 64, + kernelInstance: { + exports: { kernel_handle_channel: kernelHandleChannel }, + }, }); - const handleSyscall = vi.fn(); - worker.handleSyscall = handleSyscall; let wake!: (value: "ok") => void; const waited = new Promise<"ok">((resolve) => { wake = resolve; }); @@ -593,13 +697,13 @@ describe("exec host-state transition", () => { await Promise.resolve(); expect(waitAsync).toHaveBeenCalledTimes(1); - expect(handleSyscall).not.toHaveBeenCalled(); + expect(kernelHandleChannel).not.toHaveBeenCalled(); // Even if the discarded mailbox becomes pending later, entering the // listener directly cannot dispatch it into the replacement process. Atomics.store(oldChannel.i32View, 0, 1); worker.listenOnChannel(oldChannel); - expect(handleSyscall).not.toHaveBeenCalled(); + expect(kernelHandleChannel).not.toHaveBeenCalled(); } finally { waitAsync.mockRestore(); } @@ -612,12 +716,14 @@ describe("exec host-state transition", () => { const newMemory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); const oldChannel = createChannel(7, oldMemory, 0); const newChannel = createChannel(7, newMemory, 0); + const kernelHandleChannel = vi.fn(() => 0); const worker = createWorker({ processes: new Map([[7, { channels: [oldChannel], memory: oldMemory }]]), - kernelInstance: { exports: {} }, + kernelInstance: { + exports: { kernel_handle_channel: kernelHandleChannel }, + }, profileData: null, }); - worker.retrySyscall = vi.fn(); worker.handleBlockingRetry(oldChannel, 999, [0, 0, 0, 0, 0, 0]); vi.advanceTimersByTime(5); @@ -630,7 +736,7 @@ describe("exec host-state transition", () => { vi.advanceTimersByTime(5); expect(worker.pendingPollRetries.has(oldChannel)).toBe(false); expect(worker.pendingPollRetries.has(newChannel)).toBe(true); - expect(worker.retrySyscall).not.toHaveBeenCalled(); + expect(kernelHandleChannel).not.toHaveBeenCalled(); } finally { vi.useRealTimers(); } @@ -787,18 +893,40 @@ describe("exec host-state transition", () => { it("flushes file-backed mappings before commit and forgets them afterward", () => { const memory = new WebAssembly.Memory({ initial: 1 }); const channel = { pid: 7, memory }; - const flush = vi.fn(() => true); + const kernelMemory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); + const writes: Array<{ fd: number; length: number; offset: number }> = []; + const kernelHandleChannel = vi.fn((scratch: number) => { + const view = new DataView(kernelMemory.buffer, scratch); + const length = Number(view.getBigInt64( + CH_ARGS + 2 * CH_ARG_SIZE, + true, + )); + writes.push({ + fd: Number(view.getBigInt64(CH_ARGS, true)), + length, + offset: Number(view.getBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, true)), + }); + view.setBigInt64(CH_RETURN, BigInt(length), true); + return 0; + }); const worker = createWorker({ processes: new Map([[7, { channels: [channel], memory }]]), sharedMappings: new Map([[7, new Map([ [0x1000, { fd: 4, fileOffset: 0x2000, len: 0x3000, writable: true }], ])]]), - pwriteFromProcessMemory: flush, + kernelMemory, + kernelInstance: { + exports: { kernel_handle_channel: kernelHandleChannel }, + }, }); expect(worker.prepareAddressSpaceForExec(7)).toBe(0); - expect(flush).toHaveBeenCalledWith(channel, 4, 0x1000, 0x3000, 0x2000); + expect(writes).toEqual([{ + fd: 4, + length: 0x3000, + offset: 0x2000, + }]); expect(worker.sharedMappings.has(7)).toBe(true); expect(worker.finalizeAddressSpaceForExec(7)).toBe(0); expect(worker.sharedMappings.has(7)).toBe(false); @@ -806,12 +934,22 @@ describe("exec host-state transition", () => { it("retains mapping trackers when a pre-commit flush fails", () => { const memory = new WebAssembly.Memory({ initial: 1 }); + const kernelMemory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); const worker = createWorker({ processes: new Map([[7, { channels: [{ pid: 7, memory }], memory }]]), sharedMappings: new Map([[7, new Map([ [0x1000, { fd: 4, fileOffset: 0, len: 0x1000, writable: true }], ])]]), - pwriteFromProcessMemory: vi.fn(() => false), + kernelMemory, + kernelInstance: { + exports: { + kernel_handle_channel: (scratch: number) => { + new DataView(kernelMemory.buffer, scratch) + .setBigInt64(CH_RETURN, 0n, true); + return 0; + }, + }, + }, }); expect(worker.prepareAddressSpaceForExec(7)).toBe(-5); @@ -820,17 +958,19 @@ describe("exec host-state transition", () => { it("does not flush read-only shared mappings during exec", () => { const memory = new WebAssembly.Memory({ initial: 1 }); - const flush = vi.fn(() => false); + const kernelHandleChannel = vi.fn(() => 0); const worker = createWorker({ processes: new Map([[7, { channels: [{ pid: 7, memory }], memory }]]), sharedMappings: new Map([[7, new Map([ [0x1000, { fd: 4, fileOffset: 0, len: 0x1000, writable: false }], ])]]), - pwriteFromProcessMemory: flush, + kernelInstance: { + exports: { kernel_handle_channel: kernelHandleChannel }, + }, }); expect(worker.prepareAddressSpaceForExec(7)).toBe(0); - expect(flush).not.toHaveBeenCalled(); + expect(kernelHandleChannel).not.toHaveBeenCalled(); }); it("tracks mmap writeback only for kernel-classified writable regular fds", () => { @@ -850,13 +990,20 @@ describe("exec host-state transition", () => { it("reacquires pwrite scratch views after kernel memory growth", () => { const processMemory = new WebAssembly.Memory({ initial: 2 }); const kernelMemory = new WebAssembly.Memory({ initial: 2, maximum: 4 }); - const channel = { pid: 7, memory: processMemory }; + const channel = createChannel(7, processMemory, 0); let calls = 0; const worker = createWorker({ currentHandlePid: 0, + processes: new Map([[7, { channels: [channel], memory: processMemory }]]), + sharedMappings: new Map([[7, new Map([ + [0x1000, { + fd: 4, + fileOffset: 0, + len: CH_DATA_SIZE + 4, + writable: true, + }], + ])]]), kernelMemory, - toKernelPtr: (value: number) => value, - bindKernelTidForChannel: vi.fn(), kernelInstance: { exports: { kernel_handle_channel: (offset: number) => { @@ -877,13 +1024,7 @@ describe("exec host-state transition", () => { }, }); - expect(worker.pwriteFromProcessMemory( - channel, - 4, - 0x1000, - CH_DATA_SIZE + 4, - 0, - )).toBe(true); + expect(worker.prepareAddressSpaceForExec(7)).toBe(0); expect(calls).toBe(2); expect(worker.currentHandlePid).toBe(0); }); @@ -927,7 +1068,7 @@ describe("exec host-state transition", () => { expect(writeChunk).toHaveBeenCalledWith( 3, 0, - worker.testScratchPointer + CH_DATA, + workerScratchPointer(worker) + CH_DATA, 4, ); expect(detach).not.toHaveBeenCalled(); @@ -1078,72 +1219,15 @@ describe("exec host-state transition", () => { expect(listener.server.close).not.toHaveBeenCalled(); }); - it("keeps pending child listener targets during async worker launch", () => { - const targets = [ - { pid: 7, fd: 4 }, - { pid: 8, fd: 4 }, - ]; - const worker = createWorker({ - processes: new Map([[7, { channels: [], memory: new WebAssembly.Memory({ initial: 1 }) }]]), - tcpListenerTargets: new Map([[8080, targets]]), - tcpListenerRRIndex: new Map([[8080, 0]]), - }); - - expect(worker.pickListenerTarget(8080)).toEqual({ pid: 7, fd: 4 }); - expect(worker.tcpListenerTargets.get(8080)).toEqual(targets); - }); - - it("reconciles a reused listener fd without losing its surviving alias", () => { - const listener = { - server: { close: vi.fn() }, - pid: 7, - port: 8080, - connections: new Set(), - }; - const worker = createWorker({ - kernelInstance: { - exports: { - kernel_get_fd_accept_wake_idx: (_pid: number, fd: number) => - fd === 4 ? 99 : fd === 6 ? 41 : -1, - kernel_find_listener_fd_by_accept_wake: (_pid: number, wakeIdx: number) => - wakeIdx === 41 ? 6 : wakeIdx === 99 ? 4 : -1, - }, - }, - tcpListenerTargets: new Map([[8080, [{ - pid: 7, - fd: 4, - acceptWakeIdx: 41, - }]]]), - tcpListenerRRIndex: new Map([[8080, 0]]), - tcpListeners: new Map([["7:4", listener]]), - netModule: null, - }); - - worker.startTcpListener(7, 4, 9090); - - expect(worker.tcpListenerTargets.get(8080)).toEqual([{ - pid: 7, - fd: 6, - acceptWakeIdx: 41, - }]); - expect(worker.tcpListenerTargets.get(9090)).toEqual([{ - pid: 7, - fd: 4, - acceptWakeIdx: 99, - }]); - expect(worker.tcpListeners.get("7:6")).toEqual(listener); - expect(listener.server.close).not.toHaveBeenCalled(); - }); - it("finalizes signal death during the exec handoff exactly once", () => { - const notifyParent = vi.fn(); + const getParentPid = vi.fn(() => 0); const onExit = vi.fn(); const worker = createWorker({ hostReaped: new Set(), callbacks: { onExit }, - notifyParentOfExitedProcess: notifyParent, kernelInstance: { exports: { + kernel_get_parent_pid: getParentPid, kernel_get_process_exit_signal: () => 15, }, }, @@ -1152,7 +1236,8 @@ describe("exec host-state transition", () => { expect(worker.finalizeExecHandoffTermination(7)).toBe(15); expect(worker.finalizeExecHandoffTermination(7)).toBe(15); - expect(notifyParent).toHaveBeenCalledTimes(1); + expect(getParentPid).toHaveBeenCalledOnce(); + expect(getParentPid).toHaveBeenCalledWith(7); expect(onExit).toHaveBeenCalledTimes(1); expect(onExit).toHaveBeenCalledWith(7, 143); expect(worker.sharedMappings.has(7)).toBe(false); @@ -1167,23 +1252,28 @@ describe("exec host-state transition", () => { }, }); - expect(() => worker.finalizeExecHandoffTermination(7)).toThrow( + expectGateFailureCause( + () => worker.finalizeExecHandoffTermination(7), "Kernel missing required kernel_get_process_exit_signal export", ); }); it("does not launch a signal-dead pending child or roll back its zombie", () => { - const notifyParent = vi.fn(); + const getParentPid = vi.fn(() => 0); const onExit = vi.fn(); - const cleanupTcpListeners = vi.fn(); const removeProcess = vi.fn(); + const listenerClose = new Map([ + [8, vi.fn()], + [9, vi.fn()], + [10, vi.fn()], + [11, vi.fn()], + ]); const worker = createWorker({ hostReaped: new Set(), callbacks: { onExit }, - notifyParentOfExitedProcess: notifyParent, - cleanupTcpListeners, kernelInstance: { exports: { + kernel_get_parent_pid: getParentPid, kernel_get_process_exit_signal: (pid: number) => { if (pid === 8) return 9; if (pid === 9) return -1; @@ -1193,6 +1283,17 @@ describe("exec host-state transition", () => { kernel_remove_process: removeProcess, }, }, + tcpListeners: new Map( + Array.from(listenerClose, ([pid, close]) => [ + `${pid}:4`, + { + server: { close }, + pid, + port: 8000 + pid, + connections: new Set(), + }, + ]), + ), epollInterests: new Map([ ["8:4", [{ fd: 6, events: 1, data: 1n }]], ["9:4", [{ fd: 6, events: 1, data: 2n }]], @@ -1205,9 +1306,12 @@ describe("exec host-state transition", () => { expect(worker.shouldLaunchPendingChild(9)).toBe(true); expect(worker.shouldLaunchPendingChild(10)).toBe(false); expect(worker.shouldLaunchPendingChild(11)).toBe(false); - expect(notifyParent).toHaveBeenCalledWith(8); + expect(getParentPid).toHaveBeenCalledWith(8); expect(onExit).toHaveBeenCalledWith(8, 137); - expect(cleanupTcpListeners.mock.calls).toEqual([[8], [10], [11]]); + expect(listenerClose.get(8)).toHaveBeenCalledOnce(); + expect(listenerClose.get(9)).not.toHaveBeenCalled(); + expect(listenerClose.get(10)).toHaveBeenCalledOnce(); + expect(listenerClose.get(11)).toHaveBeenCalledOnce(); expect(worker.epollInterests.has("8:4")).toBe(false); expect(worker.epollInterests.has("9:4")).toBe(true); expect(worker.epollInterests.has("10:4")).toBe(false); @@ -1216,99 +1320,164 @@ describe("exec host-state transition", () => { }); }); +const workerKernelExports = new WeakMap< + CentralizedKernelWorker, + Record +>(); +const workerKernelMemories = new WeakMap< + CentralizedKernelWorker, + WebAssembly.Memory +>(); +const workerScratchPointers = new WeakMap(); + function createWorker(overrides: Record): any { - const worker = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - processes: new Map(), - activeChannels: [], - execHandoffPids: new Set(), - waitingForChild: [], - pendingSleeps: new Map(), - pendingPollRetries: new Map(), - pendingSelectRetries: new Map(), - pendingPipeReaders: new Map(), - pendingPipeWriters: new Map(), - pendingFutexWaits: new Map(), - pendingCancels: new Set(), - stoppedPids: new Set(), - parkedChannelCompletions: new Map(), - deferredStoppedChannels: new Map(), - socketTimeoutTimers: new Map(), - posixTimers: new Map(), - channelTids: new Map(), - threadForkContexts: new Map(), - threadCtidPtrs: new Map(), - sharedMappings: new Map(), - shmMappings: new Map(), - epollInterests: new Map(), - tcpListenerTargets: new Map(), - tcpListenerRRIndex: new Map(), - tcpVirtualListenerKeys: new Map(), - tcpListeners: new Map(), - tcpConnections: new Map(), - hostReaped: new Set(), - callbacks: {}, - kernel: { releaseProcessViews: vi.fn() }, - io: { network: undefined }, - ...overrides, - }); - const kernelInstance = worker.kernelInstance ?? { exports: {} }; - worker.kernelInstance = { - ...kernelInstance, - exports: { - kernel_get_process_exit_signal: vi.fn(() => -1), - ...(kernelInstance.exports ?? {}), - }, - }; - if (worker.kernelMemory instanceof WebAssembly.Memory) { - worker.testScratchPointer = installKernelWorkerTestScratch( - worker, - worker.kernelMemory, - ); + const callbacks = (overrides.callbacks ?? {}) as ConstructorParameters< + typeof CentralizedKernelWorker + >[2]; + const io = (overrides.io ?? { network: undefined }) as ConstructorParameters< + typeof CentralizedKernelWorker + >[1]; + const worker = createCentralizedKernelWorkerTestDouble({ callbacks, io }); + + // Seed only real writable state slots. Method shadows and raw + // instance/memory fields are deliberately ignored: the test must exercise + // the frozen production methods against one genuine gated Wasm instance. + for (const [name, value] of Object.entries(overrides)) { + if (name === "callbacks" || name === "io") continue; + if (Object.prototype.hasOwnProperty.call(worker, name)) { + Reflect.set(worker, name, value); + } + } + + const suppliedInstance = overrides.kernelInstance as + | { exports?: Record } + | undefined; + const suppliedExports = suppliedInstance?.exports ?? {}; + const exports: Record = { ...suppliedExports }; + if (!Object.prototype.hasOwnProperty.call( + suppliedExports, + "kernel_get_process_exit_signal", + )) { + exports.kernel_get_process_exit_signal = vi.fn(() => -1); + } + if (!Object.prototype.hasOwnProperty.call( + suppliedExports, + "kernel_handle_channel", + )) { + exports.kernel_handle_channel = vi.fn(() => 0); } + for (const name of [ + "kernel_drain_wakeup_events", + "kernel_get_parent_pid", + "kernel_get_process_state", + "kernel_set_current_tid", + "kernel_thread_exit", + "kernel_validate_task", + ]) { + if (!Object.prototype.hasOwnProperty.call(suppliedExports, name)) { + exports[name] = vi.fn(() => 0); + } + } + + const kernelMemory = overrides.kernelMemory instanceof WebAssembly.Memory + ? overrides.kernelMemory + : new WebAssembly.Memory({ initial: 4, maximum: 8 }); + const requestedPointerWidth = ( + overrides.kernel as { getKernelPtrWidth?: () => unknown } | undefined + )?.getKernelPtrWidth?.(); + const pointerWidth = requestedPointerWidth === 8 ? 8 : 4; + const scratchPointer = typeof overrides.scratchPointer === "number" + ? overrides.scratchPointer + : 128; + installKernelWorkerTestScratch( + worker, + kernelMemory, + scratchPointer, + pointerWidth, + { + kernelExports: exports, + kernelExportNames: Object.entries(exports) + .filter(([, value]) => typeof value === "function") + .map(([name]) => name), + }, + ); + workerKernelExports.set(worker, exports); + workerKernelMemories.set(worker, kernelMemory); + workerScratchPointers.set(worker, scratchPointer); return worker; } -function issueThreadAttachment( +function workerScratchPointer(worker: CentralizedKernelWorker): number { + const pointer = workerScratchPointers.get(worker); + if (pointer === undefined) throw new Error("test worker has no scratch pointer"); + return pointer; +} + +function expectGateFailureCause( + operation: () => void, + expectedMessage: string, +): void { + let failure: unknown; + try { + operation(); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + const cause = (failure as Error & { cause?: unknown }).cause; + expect(cause).toBeInstanceOf(Error); + expect((cause as Error).message).toContain(expectedMessage); +} + +async function issueThreadAttachment( worker: CentralizedKernelWorker, channel: ReturnType, tid: number, fnPtr = 1, argPtr = 2, ) { - const kernelMemory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); let attachment: Parameters[0] | undefined; new DataView(channel.memory.buffer, channel.channelOffset) .setUint32(CH_DATA, fnPtr, true); new DataView(channel.memory.buffer, channel.channelOffset) .setUint32(CH_DATA + 4, argPtr, true); - Object.assign(worker as any, { - callbacks: { - onClone: ( - value: Parameters[0], - ) => { - attachment = value; - return new Promise(() => {}); - }, - }, - kernel: { - toKernelPtr: (value: number | bigint) => Number(value), - releaseProcessViews: vi.fn(), + (worker as any).callbacks = { + onClone: ( + value: Parameters[0], + ) => { + attachment = value; + return new Promise(() => {}); }, - kernelMemory, - currentHandlePid: 0, - threadCtidPtrs: (worker as any).threadCtidPtrs ?? new Map(), - bindKernelTidForChannel: vi.fn(), - }); - installKernelWorkerTestScratch(worker as any, kernelMemory); - (worker as any).kernelInstance.exports.kernel_handle_channel = vi.fn( - (offset: number) => { - const kernelView = new DataView(kernelMemory.buffer, offset); + }; + const exports = workerKernelExports.get(worker); + if (!exports) throw new Error("test worker has no gated export resolver"); + const kernelMemory = workerKernelMemories.get(worker); + if (!kernelMemory) throw new Error("test worker has no kernel Memory"); + exports.kernel_handle_channel = vi.fn( + (offset: number | bigint) => { + const scratchPointer = workerScratchPointers.get(worker); + if (scratchPointer === undefined || Number(offset) !== scratchPointer) { + throw new Error("clone did not use the owned test scratch region"); + } + const kernelView = new DataView(kernelMemory.buffer, Number(offset)); kernelView.setBigInt64(CH_RETURN, BigInt(tid), true); return 0; }, ); - (worker as any).handleClone(channel, [0, 0, 0, 0, 0, 0]); + const processView = new DataView( + channel.memory.buffer, + channel.channelOffset, + ); + processView.setUint32(CH_STATUS, CHANNEL_STATUS_PENDING, true); + processView.setUint32(CH_SYSCALL, ABI_SYSCALLS.Clone, true); + for (let index = 0; index < 6; index++) { + processView.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, 0n, true); + } + (worker as any).handleSyscall(channel); + for (let index = 0; index < 8 && !attachment; index++) { + await Promise.resolve(); + } if (!attachment) throw new Error("clone callback did not receive attachment"); return attachment; } @@ -1325,6 +1494,67 @@ function resolvedProgram() { }; } +function writeChannelSyscall( + channel: ReturnType, + syscall: number, + args: readonly number[], +): void { + const view = new DataView( + channel.memory.buffer, + channel.channelOffset, + ); + view.setUint32(CH_STATUS, CHANNEL_STATUS_PENDING, true); + view.setUint32(CH_SYSCALL, syscall, true); + for (let index = 0; index < 6; index++) { + view.setBigInt64( + CH_ARGS + index * CH_ARG_SIZE, + BigInt(args[index] ?? 0), + true, + ); + } +} + +function readChannelStatus( + channel: ReturnType, +): number { + return new DataView( + channel.memory.buffer, + channel.channelOffset, + ).getUint32(CH_STATUS, true); +} + +function readChannelCompletion( + channel: ReturnType, +): { status: number; returnValue: number; errno: number } { + const view = new DataView( + channel.memory.buffer, + channel.channelOffset, + ); + return { + status: view.getUint32(CH_STATUS, true), + returnValue: Number(view.getBigInt64(CH_RETURN, true)), + errno: view.getUint32(CH_ERRNO, true), + }; +} + +async function flushMicrotasks(turns = 16): Promise { + for (let index = 0; index < turns; index++) { + await Promise.resolve(); + } +} + +async function flushMicrotasksUntil( + condition: () => boolean, + failureMessage: string, + turns = 32, +): Promise { + for (let index = 0; index < turns; index++) { + if (condition()) return; + await Promise.resolve(); + } + if (!condition()) throw new Error(failureMessage); +} + function createChannel(pid: number, memory: WebAssembly.Memory, channelOffset: number): any { return { pid, diff --git a/host/test/file-shared-memory.test.ts b/host/test/file-shared-memory.test.ts index 53aab4054f..da7219ae1c 100644 --- a/host/test/file-shared-memory.test.ts +++ b/host/test/file-shared-memory.test.ts @@ -8,9 +8,14 @@ import { CH_STATUS, CH_SYSCALL, CHANNEL_STATUS_COMPLETE, + CHANNEL_STATUS_PENDING, } from "../src/generated/abi"; -import { WasmPosixKernel } from "../src/kernel"; -import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { + createWasmPosixKernelTestHarness, +} from "../src/kernel"; +import { + createCentralizedKernelWorkerTestDouble, +} from "../src/kernel-worker"; import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; const MAP_SHARED = 1; @@ -106,38 +111,36 @@ function createFileHarness() { fileHandleIdentity: vi.fn((_handle: number, dev: bigint, ino: bigint) => ino === 0n ? null : `test:${dev}:${ino}`), }; - const kernel = new WasmPosixKernel( - { maxWorkers: 4, dataBufferSize: 65536, useSharedMemory: true }, - io as any, - ); + const kernel = createWasmPosixKernelTestHarness({ + config: { + maxWorkers: 4, + dataBufferSize: 65536, + useSharedMemory: true, + }, + io: io as any, + initialized: false, + }); const retainHostFileHandle = vi.spyOn(kernel, "retainHostFileHandle"); const releaseHostFileHandle = vi.spyOn(kernel, "releaseHostFileHandle"); const fdIdentity = new Map([ [4, "/dev/shm/php-cache"], [9, "/dev/shm/php-cache"], ]); + const onFork = vi.fn(); const processes = new Map(pids.map((pid) => [pid, { pid, memory: memories.get(pid)!, channels: [channels.get(pid)!], }])); - const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - io, - kernel, - processes, - channelTids: new Map(), - sharedMappings: new Map(), - anonymousSharedBackings: new Map(), - sharedMmapBackings: new Map(), - sharedMmapFdCache: new Map(), - shmMappings: new Map(), - shmSegmentVersions: new Map(), - fdSupportsMmapWriteback: vi.fn(() => true), - getFdAccessModeForSharedMapping: vi.fn(() => ({ kind: "ok", value: 2 })), - getFdStatForSharedMapping: vi.fn((_channel: unknown, fd: number) => { + const fdSupportsMmapWriteback = vi.fn(() => true); + const getFdAccessModeForSharedMapping = vi.fn( + () => ({ kind: "ok" as const, value: 2 }), + ); + const getFdStatForSharedMapping = vi.fn( + (_channel: unknown, fd: number) => { return fdHostHandles.has(fd) ? { - kind: "ok", + kind: "ok" as const, value: { dev: 7n, ino: 99n, @@ -146,13 +149,43 @@ function createFileHarness() { hostHandle: fdHostHandles.get(fd)!, }, } - : { kind: "error", errno: 9 }; - }), - getFdPathForSharedMapping: vi.fn((_channel: unknown, fd: number) => + : { kind: "error" as const, errno: 9 }; + }, + ); + const getFdPathForSharedMapping = vi.fn( + (_channel: unknown, fd: number) => fdIdentity.has(fd) - ? { kind: "ok", value: fdIdentity.get(fd)! } - : { kind: "error", errno: 9 }), - }) as CentralizedKernelWorker; + ? { kind: "ok" as const, value: fdIdentity.get(fd)! } + : { kind: "error" as const, errno: 9 }, + ); + const kw = Object.assign(createCentralizedKernelWorkerTestDouble({ + io: io as any, + callbacks: { onFork }, + }), { + processes, + activeChannels: Array.from(channels.values()), + channelTids: new Map(), + sharedMappings: new Map(), + anonymousSharedBackings: new Map(), + sharedMmapBackings: new Map(), + sharedMmapFdCache: new Map(), + shmMappings: new Map(), + shmSegmentVersions: new Map(), + }); + kw.testAuthority.replaceKernelForScratchBoundaryTest(kernel); + kw.testAuthority.configureScratchBoundaryHooksForTest({ + fdSupportsMmapWriteback, + getFdAccessModeForSharedMapping, + getFdStatForSharedMapping, + getFdPathForSharedMapping, + }); + installKernelWorkerTestScratch( + kw as unknown as Record, + new WebAssembly.Memory({ initial: 2, maximum: 2 }), + 128, + 4, + { kernelExportNames: [] }, + ); const mapResult = ( pid: number, @@ -165,6 +198,7 @@ function createFileHarness() { channels.get(pid), addr, [0, len, prot, MAP_SHARED, fd, 0], + 0n, ) as { kind: "mapped" | "unsupported" | "error"; errno?: number }; const map = ( pid: number, @@ -179,6 +213,10 @@ function createFileHarness() { close, fdHostHandles, fdIdentity, + fdSupportsMmapWriteback, + getFdAccessModeForSharedMapping, + getFdPathForSharedMapping, + getFdStatForSharedMapping, io, kernel, kw, @@ -186,6 +224,7 @@ function createFileHarness() { map, mapResult, memories, + onFork, open, pids, releaseHostFileHandle, @@ -200,19 +239,15 @@ type FileHarness = ReturnType; function configureKernelSyscallHarness(h: FileHarness, pid: number) { const kernelHandle = vi.fn(); const completeChannel = vi.fn(); - const kernelMemory = new WebAssembly.Memory({ initial: 2 }); Object.assign(h.kw as any, { config: {}, syscallRing: new Map(), syscallTraceEnabled: false, - kernelMemory, - kernelInstance: { exports: { kernel_handle_channel: kernelHandle } }, - formatSyscallEntry: vi.fn(() => "memory syscall"), - synchronizeSharedMemoryForBoundary: vi.fn(), - flushSharedMappingsBeforeFileSyscall: vi.fn(() => true), + }); + h.kw.testAuthority.configureScratchBoundaryHooksForTest({ + synchronizeSharedMemoryForBoundary: () => {}, completeChannel, }); - installKernelWorkerTestScratch(h.kw as any, kernelMemory); return { completeChannel, kernelHandle }; } @@ -236,6 +271,7 @@ describe("file/POSIX MAP_SHARED page cache", () => { const preparation = (h.kw as any).prepareSharedMmapFromFile( h.channels.get(pid), args, + 0n, ); expect(preparation.kind).toBe("prepared"); @@ -247,9 +283,9 @@ describe("file/POSIX MAP_SHARED page cache", () => { expect(h.open).not.toHaveBeenCalled(); expect(h.retainHostFileHandle).toHaveBeenCalledOnce(); expect(h.retainHostFileHandle).toHaveBeenCalledWith(100); - expect((h.kw as any).getFdStatForSharedMapping).toHaveBeenCalledTimes(1); - expect((h.kw as any).getFdPathForSharedMapping).not.toHaveBeenCalled(); - expect((h.kw as any).getFdAccessModeForSharedMapping).toHaveBeenCalledTimes(1); + expect(h.getFdStatForSharedMapping).toHaveBeenCalledTimes(1); + expect(h.getFdPathForSharedMapping).not.toHaveBeenCalled(); + expect(h.getFdAccessModeForSharedMapping).toHaveBeenCalledTimes(1); }); it("reserves a same-file backing across MAP_FIXED replacement cleanup", () => { @@ -261,6 +297,7 @@ describe("file/POSIX MAP_SHARED page cache", () => { const preparation = (h.kw as any).prepareSharedMmapFromFile( h.channels.get(pid), [addr, 4096, PROT_WRITE, MAP_SHARED | MAP_FIXED, 9, 0], + 0n, ); expect(preparation.kind).toBe("prepared"); expect(backing.refCount).toBe(2); @@ -297,21 +334,8 @@ describe("file/POSIX MAP_SHARED page cache", () => { throw Object.assign(new Error("handle retain failed"), { code: "EACCES" }); }); - const kernelHandle = vi.fn(); - const completeChannel = vi.fn(); - const kernelMemory = new WebAssembly.Memory({ initial: 2 }); - Object.assign(h.kw as any, { - config: {}, - syscallRing: new Map(), - syscallTraceEnabled: false, - kernelMemory, - kernelInstance: { exports: { kernel_handle_channel: kernelHandle } }, - formatSyscallEntry: vi.fn(() => "mmap"), - synchronizeSharedMemoryForBoundary: vi.fn(), - flushSharedMappingsBeforeFileSyscall: vi.fn(() => true), - completeChannel, - }); - installKernelWorkerTestScratch(h.kw as any, kernelMemory); + const { completeChannel, kernelHandle } = + configureKernelSyscallHarness(h, pid); const args = [addr, 4096, PROT_WRITE, MAP_SHARED | MAP_FIXED, 4, 0]; const view = new DataView(channel.memory.buffer, channel.channelOffset); @@ -320,7 +344,7 @@ describe("file/POSIX MAP_SHARED page cache", () => { view.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, BigInt(args[i]), true); } - (h.kw as any)._handleSyscallInner(channel); + h.kw.testAuthority.dispatchScratchBoundarySyscallForTest(channel as any); expect(kernelHandle).not.toHaveBeenCalled(); expect(completeChannel).toHaveBeenCalledWith( @@ -344,15 +368,15 @@ describe("file/POSIX MAP_SHARED page cache", () => { expect(h.map(pid, 4, addr)).toBe(true); const originalMap = (h.kw as any).sharedMappings.get(pid); const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; - const flush = vi.fn(() => false); - Object.assign(h.kw as any, { flushSharedMappings: flush }); + new Uint8Array(h.memories.get(pid)!.buffer)[addr + 7] = 0xc7; + h.io.write.mockReturnValueOnce(0); const { completeChannel, kernelHandle } = configureKernelSyscallHarness(h, pid); const args = [addr, 4096, PROT_WRITE, MAP_SHARED | MAP_FIXED, 9, 0]; writeChannelSyscall(channel, ABI_SYSCALLS.Mmap, args); - (h.kw as any)._handleSyscallInner(channel); + h.kw.testAuthority.dispatchScratchBoundarySyscallForTest(channel as any); - expect(flush).toHaveBeenCalledWith(channel, [addr, 65536]); + expect(h.io.write).toHaveBeenCalled(); expect(kernelHandle).not.toHaveBeenCalled(); expect(completeChannel).toHaveBeenCalledWith( channel, ABI_SYSCALLS.Mmap, args, undefined, -1, 5, @@ -371,16 +395,14 @@ describe("file/POSIX MAP_SHARED page cache", () => { expect(h.map(pid, 4, oldAddr)).toBe(true); const originalMap = (h.kw as any).sharedMappings.get(pid); const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; - const flush = vi.fn(() => true); - Object.assign(h.kw as any, { flushSharedMappings: flush }); const { completeChannel, kernelHandle } = configureKernelSyscallHarness(h, pid); const args = [fixedAddr, 4096, PROT_WRITE, MAP_SHARED | MAP_FIXED, 9, 0]; writeChannelSyscall(channel, ABI_SYSCALLS.Mmap, args); - (h.kw as any)._handleSyscallInner(channel); + h.kw.testAuthority.dispatchScratchBoundarySyscallForTest(channel as any); expect(kernelHandle).not.toHaveBeenCalled(); - expect(flush).not.toHaveBeenCalled(); + expect(h.io.write).not.toHaveBeenCalled(); expect(completeChannel).toHaveBeenCalledWith( channel, ABI_SYSCALLS.Mmap, args, undefined, -1, 12, ); @@ -389,29 +411,46 @@ describe("file/POSIX MAP_SHARED page cache", () => { expect(h.close).not.toHaveBeenCalledWith(backing.handle); }); - it("releases a prepared reservation when pre-kernel MAP_FIXED work throws", () => { + it("releases a prepared reservation when pre-kernel MAP_FIXED flush fails", () => { const h = createFileHarness(); const pid = h.pids[0]; const addr = 0x1000; const channel = h.channels.get(pid)!; expect(h.map(pid, 4, addr)).toBe(true); const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; - Object.assign(h.kw as any, { - flushSharedMappings: vi.fn(() => { - throw new Error("pre-kernel flush threw"); - }), - }); - const { kernelHandle } = configureKernelSyscallHarness(h, pid); + new Uint8Array(h.memories.get(pid)!.buffer)[addr + 9] = 0xd9; + h.io.write.mockImplementationOnce( + (_handle, _input, _offset, count) => count + 1, + ); + const { completeChannel, kernelHandle } = + configureKernelSyscallHarness(h, pid); + const args = [ + addr, + 4096, + PROT_WRITE, + MAP_SHARED | MAP_FIXED, + 9, + 0, + ]; writeChannelSyscall( channel, ABI_SYSCALLS.Mmap, - [addr, 4096, PROT_WRITE, MAP_SHARED | MAP_FIXED, 9, 0], + args, ); - expect(() => (h.kw as any)._handleSyscallInner(channel)) - .toThrow(/pre-kernel flush threw/); + h.kw.testAuthority.dispatchScratchBoundarySyscallForTest(channel as any); + expect(kernelHandle).not.toHaveBeenCalled(); + expect(completeChannel).toHaveBeenCalledWith( + channel, + ABI_SYSCALLS.Mmap, + args, + undefined, + -1, + 5, + ); expect(backing.refCount).toBe(1); + expect(backing.dirtyPages.has(0)).toBe(true); expect(h.close).not.toHaveBeenCalledWith(backing.handle); }); @@ -429,7 +468,7 @@ describe("file/POSIX MAP_SHARED page cache", () => { const args = [addr, 4096, 8192, 1, 0, 0]; writeChannelSyscall(channel, ABI_SYSCALLS.Mremap, args); - (h.kw as any)._handleSyscallInner(channel); + h.kw.testAuthority.dispatchScratchBoundarySyscallForTest(channel as any); expect(kernelHandle).not.toHaveBeenCalled(); expect(completeChannel).toHaveBeenCalledWith( @@ -536,7 +575,7 @@ describe("file/POSIX MAP_SHARED page cache", () => { // close(fd) asks the host to close the original handle, but the mapping's // retain keeps it usable until the final munmap releases the backing. - expect((h.kernel as any).hostClose(BigInt(stableHandle))).toBe(0); + expect(h.kernel.testAuthority.hostClose(BigInt(stableHandle))).toBe(0); h.fdHostHandles.delete(4); (h.kw as any).handleSharedMappingsAfterFileSyscall( h.channels.get(pid), ABI_SYSCALLS.Close, [4], 0, 0, @@ -561,7 +600,7 @@ describe("file/POSIX MAP_SHARED page cache", () => { expect(h.releaseHostFileHandle).toHaveBeenCalledWith(stableHandle); expect(h.close).not.toHaveBeenCalledWith(stableHandle); - expect((h.kernel as any).hostClose(BigInt(stableHandle))).toBe(0); + expect(h.kernel.testAuthority.hostClose(BigInt(stableHandle))).toBe(0); expect(h.close).toHaveBeenCalledWith(stableHandle); }); @@ -823,16 +862,11 @@ describe("file/POSIX MAP_SHARED page cache", () => { h.io.read.mockImplementation(() => { throw new Error("fork publication failed"); }); - const kernelForkProcess = vi.fn(() => 0); - Object.assign(h.kw as any, { - callbacks: { onFork: vi.fn() }, - kernelInstance: { exports: { kernel_fork_process: kernelForkProcess } }, - }); expect(() => (h.kw as any).handleFork(channel, [])).toThrow( /fork publication failed/, ); - expect(kernelForkProcess).not.toHaveBeenCalled(); + expect(h.onFork).not.toHaveBeenCalled(); }); it("reports handle-retention and initial-read failures without leaking a backing", () => { @@ -855,7 +889,7 @@ describe("file/POSIX MAP_SHARED page cache", () => { expect(readFailure.close).not.toHaveBeenCalled(); const writeOnly = createFileHarness(); - (writeOnly.kw as any).getFdAccessModeForSharedMapping.mockReturnValue({ + writeOnly.getFdAccessModeForSharedMapping.mockReturnValue({ kind: "ok", value: 1, }); @@ -872,7 +906,7 @@ describe("file/POSIX MAP_SHARED page cache", () => { for (const errno of [24, 2, 30]) { const h = createFileHarness(); - (h.kw as any).getFdStatForSharedMapping.mockReturnValueOnce({ + h.getFdStatForSharedMapping.mockReturnValueOnce({ kind: "error", errno, }); @@ -900,7 +934,7 @@ describe("file/POSIX MAP_SHARED page cache", () => { it("guards mprotect write upgrades with a lifetime-stable writable handle", () => { const denied = createFileHarness(); - (denied.kw as any).fdSupportsMmapWriteback.mockReturnValue(false); + denied.fdSupportsMmapWriteback.mockReturnValue(false); expect(denied.map(denied.pids[0], 4, 0x1000, 4096, PROT_READ)).toBe(true); expect((denied.kw as any).prepareFileSharedMappingsForWrite( denied.pids[0], 0x1000, 4096, @@ -914,7 +948,9 @@ describe("file/POSIX MAP_SHARED page cache", () => { )[0].handle; // Simulate close(fd) followed by unlink/rename. The retained O_RDWR host // handle must suffice; no pathname lookup or reopen is permitted. - expect((allowed.kernel as any).hostClose(BigInt(stableHandle))).toBe(0); + expect( + allowed.kernel.testAuthority.hostClose(BigInt(stableHandle)), + ).toBe(0); allowed.fdHostHandles.delete(4); (allowed.kw as any).handleSharedMappingsAfterFileSyscall( allowed.channels.get(allowed.pids[0]), ABI_SYSCALLS.Close, [4], 0, 0, @@ -932,7 +968,7 @@ describe("file/POSIX MAP_SHARED page cache", () => { it("replaces one retained O_RDONLY handle with a distinct O_RDWR handle", () => { const h = createFileHarness(); const pid = h.pids[0]; - (h.kw as any).getFdAccessModeForSharedMapping.mockImplementation( + h.getFdAccessModeForSharedMapping.mockImplementation( (_channel: unknown, fd: number) => ({ kind: "ok", value: fd === 4 ? 0 : 2 }), ); @@ -955,7 +991,7 @@ describe("file/POSIX MAP_SHARED page cache", () => { const h = createFileHarness(); const pid = h.pids[0]; let accessMode = 0; - (h.kw as any).getFdAccessModeForSharedMapping.mockImplementation( + h.getFdAccessModeForSharedMapping.mockImplementation( () => ({ kind: "ok", value: accessMode }), ); @@ -971,7 +1007,7 @@ describe("file/POSIX MAP_SHARED page cache", () => { const h = createFileHarness(); const pid = h.pids[0]; expect(h.map(pid, 4, 0x1000)).toBe(true); - const stat = (h.kw as any).getFdStatForSharedMapping; + const stat = h.getFdStatForSharedMapping; const callsBefore = stat.mock.calls.length; expect((h.kw as any).findSharedMmapBackingForFd(h.channels.get(pid), 77)) @@ -1194,29 +1230,28 @@ describe("file/POSIX MAP_SHARED page cache", () => { throw new Error("persistent refresh failure"); }); - const kernelHandle = vi.fn(); const relistenChannel = vi.fn(); - const kernelMemory = new WebAssembly.Memory({ initial: 2 }); Object.assign(h.kw as any, { config: {}, syscallRing: new Map(), syscallTraceEnabled: false, - kernelMemory, - kernelInstance: { exports: { kernel_handle_channel: kernelHandle } }, - clearSocketTimeout: vi.fn(), - clearReadinessWait: vi.fn(), pendingCancels: new Set(), + }); + h.kw.testAuthority.configureScratchBoundaryHooksForTest({ relistenChannel, }); - installKernelWorkerTestScratch(h.kw as any, kernelMemory); writeChannelSyscall(channel, ABI_SYSCALLS.Getpid, []); + Atomics.store( + channel.i32View, + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + CHANNEL_STATUS_PENDING, + ); const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); (h.kw as any).handleSyscall(channel); consoleError.mockRestore(); const view = new DataView(channel.memory.buffer, channel.channelOffset); - expect(kernelHandle).not.toHaveBeenCalled(); expect(Number(view.getBigInt64(CH_RETURN, true))).toBe(-5); expect(view.getUint32(CH_ERRNO, true)).toBe(5); expect(Atomics.load(channel.i32View, CH_STATUS / 4)) @@ -1228,15 +1263,10 @@ describe("file/POSIX MAP_SHARED page cache", () => { const h = createFileHarness(); const channel = h.channels.get(h.pids[0])!; const relistenChannel = vi.fn(); - Object.assign(h.kw as any, { + h.kw.testAuthority.configureScratchBoundaryHooksForTest({ synchronizeSharedMemoryForBoundary: vi.fn(() => { throw new Error("asynchronous refresh failure"); }), - clearSocketTimeout: vi.fn(), - clearReadinessWait: vi.fn(), - drainAllPtyOutputs: vi.fn(), - flushTcpSendPipes: vi.fn(), - drainAndProcessWakeupEvents: vi.fn(), relistenChannel, }); const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); @@ -1291,14 +1321,11 @@ describe("file/POSIX MAP_SHARED page cache", () => { const addr = 0x1000; expect(h.map(pid, 4, addr)).toBe(true); const exactOffset = BigInt(Number.MAX_SAFE_INTEGER) + 2n; - const reload = vi.spyOn( - h.kw as any, - "reloadSharedMmapBackingForFd", - ).mockImplementation(() => {}); - const update = vi.spyOn( - h.kw as any, - "updateSharedMmapBackingFromProcessBuffer", - ); + const backing = Array.from( + (h.kw as any).sharedMmapBackings.values(), + )[0]; + const version = backing.version; + h.storage[0] = 0xe4; (h.kw as any).handleSharedMappingsAfterFileSyscall( h.channels.get(pid), @@ -1309,13 +1336,41 @@ describe("file/POSIX MAP_SHARED page cache", () => { exactOffset, ); - expect(update).not.toHaveBeenCalled(); - expect(reload).toHaveBeenCalledWith(h.channels.get(pid), 4); + expect(backing.version).toBeGreaterThan(version); + expect(backing.sizeValid).toBe(true); + expect(backing.pages.get(0)?.[0]).toBe(0xe4); + }); + + it("does not publish a rounded ftruncate length to shared-mapping state", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + expect(h.map(pid, 4, 0x1000)).toBe(true); + const backing = Array.from( + (h.kw as any).sharedMmapBackings.values(), + )[0]; + const exactLength = BigInt(Number.MAX_SAFE_INTEGER) + 2n; + + // Model the kernel/host file operation having completed at a size that the + // cache can represent. The lossy Number projection of exactLength must not + // be installed as if it were authoritative. + h.setLogicalSize(37); + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), + ABI_SYSCALLS.Ftruncate, + [4, Number(exactLength)], + 0, + 0, + undefined, + exactLength, + ); + + expect(backing.size).toBe(37); + expect(backing.sizeValid).toBe(true); }); it("rejects shared memfd mappings deliberately without affecting private mmap", () => { const h = createFileHarness(); - (h.kw as any).getFdStatForSharedMapping.mockReturnValue({ + h.getFdStatForSharedMapping.mockReturnValue({ kind: "ok", value: { dev: 0n, @@ -1362,9 +1417,9 @@ describe("file/POSIX MAP_SHARED page cache", () => { it("skips file-coherence hooks when no shared file backing exists", () => { const h = createFileHarness(); const pid = h.pids[0]; - const syncFile = vi.spyOn(h.kw as any, "syncFileSharedMappingsFromProcess"); - const flushFd = vi.spyOn(h.kw as any, "flushSharedBackingForFd"); - const invalidateFd = vi.spyOn(h.kw as any, "invalidateSharedMmapFdCache"); + const cache = (h.kw as any).sharedMmapFdCache as Map; + const sentinel = { backingKey: "untouched" }; + cache.set(`${pid}:4`, sentinel); expect((h.kw as any).flushSharedMappingsBeforeFileSyscall( h.channels.get(pid), ABI_SYSCALLS.Pwrite, [4, 0, 1, 0], @@ -1373,9 +1428,11 @@ describe("file/POSIX MAP_SHARED page cache", () => { h.channels.get(pid), ABI_SYSCALLS.Open, [0, 0], 4, 0, ); - expect(syncFile).not.toHaveBeenCalled(); - expect(flushFd).not.toHaveBeenCalled(); - expect(invalidateFd).not.toHaveBeenCalled(); + expect(h.getFdStatForSharedMapping).not.toHaveBeenCalled(); + expect(h.getFdPathForSharedMapping).not.toHaveBeenCalled(); + expect(h.io.read).not.toHaveBeenCalled(); + expect(h.io.write).not.toHaveBeenCalled(); + expect(cache.get(`${pid}:4`)).toBe(sentinel); }); it("reaps a retained zero-reference backing after writeback recovers", () => { diff --git a/host/test/fixtures/sharedfs-append-worker.ts b/host/test/fixtures/sharedfs-append-worker.ts new file mode 100644 index 0000000000..2d125064ae --- /dev/null +++ b/host/test/fixtures/sharedfs-append-worker.ts @@ -0,0 +1,51 @@ +import { parentPort, workerData } from "node:worker_threads"; +import { MemoryFileSystem } from "../../src/vfs/memory-fs"; + +const { + fsBuffer, + controlBuffer, + marker, + iterations, + limit, +} = workerData as { + fsBuffer: SharedArrayBuffer; + controlBuffer: SharedArrayBuffer; + marker: string; + iterations: number; + limit: number; +}; + +const O_RDWR = 0x0002; +const control = new Int32Array(controlBuffer); +const fs = MemoryFileSystem.fromExisting(fsBuffer); +const fd = fs.open("/append-race", O_RDWR, 0); +const record = new TextEncoder().encode(marker); + +while (Atomics.load(control, 0) === 0) Atomics.wait(control, 0, 0); + +try { + for (let index = 0; index < iterations; index++) { + const outcome = fs.append(fd, record, record.byteLength, limit); + if ( + outcome.written !== record.byteLength + || outcome.end < outcome.written + || outcome.end % record.byteLength !== 0 + ) { + throw new Error( + `invalid append outcome ${JSON.stringify(outcome)} for ${marker}`, + ); + } + } + fs.close(fd); + parentPort!.postMessage({ ok: true }); +} catch (error) { + try { + fs.close(fd); + } catch { + // Preserve the append failure. + } + parentPort!.postMessage({ + ok: false, + error: error instanceof Error ? error.stack ?? error.message : String(error), + }); +} diff --git a/host/test/fixtures/wasi-scalar-abi.wat b/host/test/fixtures/wasi-scalar-abi.wat new file mode 100644 index 0000000000..5ef980ee39 --- /dev/null +++ b/host/test/fixtures/wasi-scalar-abi.wat @@ -0,0 +1,111 @@ +;; WASI scalar-ABI regression for channel syscalls carrying i64 values. +;; +;; The file remains tiny: the large value is only a seek cursor, not an +;; allocation. This proves a position above JavaScript's safe-integer limit +;; crosses the real process/kernel channel and returns unchanged. +;; +;; Build: wat2wasm --enable-threads wasi-scalar-abi.wat -o wasi-scalar-abi.wasm + +(module + (import "env" "memory" (memory 1 16384 shared)) + + (import "wasi_snapshot_preview1" "path_open" + (func $path_open + (param i32 i32 i32 i32 i32 i64 i64 i32 i32) + (result i32))) + (import "wasi_snapshot_preview1" "fd_allocate" + (func $fd_allocate (param i32 i64 i64) (result i32))) + (import "wasi_snapshot_preview1" "fd_filestat_set_size" + (func $fd_filestat_set_size (param i32 i64) (result i32))) + (import "wasi_snapshot_preview1" "fd_seek" + (func $fd_seek (param i32 i64 i32 i32) (result i32))) + (import "wasi_snapshot_preview1" "fd_tell" + (func $fd_tell (param i32 i32) (result i32))) + (import "wasi_snapshot_preview1" "fd_close" + (func $fd_close (param i32) (result i32))) + (import "wasi_snapshot_preview1" "proc_exit" + (func $proc_exit (param i32))) + + (data (i32.const 1024) "tmp/wasi-scalar-offset.tmp") + + (func $require_success (param $errno i32) (param $exit_code i32) + (if (local.get $errno) + (then (call $proc_exit (local.get $exit_code))))) + + (func $require_i64 (param $actual i64) (param $expected i64) (param $exit_code i32) + (if (i64.ne (local.get $actual) (local.get $expected)) + (then (call $proc_exit (local.get $exit_code))))) + + (func $start (export "_start") + (local $fd i32) + + ;; Open /tmp/wasi-scalar-offset.tmp via preopen fd 3. O_CREAT|O_TRUNC = 9. + (call $require_success + (call $path_open + (i32.const 3) + (i32.const 0) + (i32.const 1024) + (i32.const 26) + (i32.const 9) + (i64.const 0) + (i64.const 0) + (i32.const 0) + (i32.const 0)) + (i32.const 10)) + (local.set $fd (i32.load (i32.const 0))) + + ;; Ftruncate carries its size in one exact i64 channel slot. + (call $require_success + (call $fd_filestat_set_size (local.get $fd) (i64.const 4)) + (i32.const 11)) + + ;; Fallocate's Linux-compatible channel ABI includes a zero mode slot. + ;; Keep the requested range inside the existing file so this remains a + ;; metadata-only channel check on every host backend. + (call $require_success + (call $fd_allocate (local.get $fd) (i64.const 1) (i64.const 1)) + (i32.const 12)) + + (call $require_success + (call $fd_seek (local.get $fd) (i64.const 0) (i32.const 2) (i32.const 8)) + (i32.const 13)) + (call $require_i64 + (i64.load (i32.const 8)) + (i64.const 4) + (i32.const 14)) + + ;; 2^53 + 1 must not round through a JavaScript Number. + (call $require_success + (call $fd_seek + (local.get $fd) + (i64.const 9007199254740993) + (i32.const 0) + (i32.const 8)) + (i32.const 15)) + (call $require_i64 + (i64.load (i32.const 8)) + (i64.const 9007199254740993) + (i32.const 16)) + (call $require_success + (call $fd_tell (local.get $fd) (i32.const 16)) + (i32.const 17)) + (call $require_i64 + (i64.load (i32.const 16)) + (i64.const 9007199254740993) + (i32.const 18)) + + ;; A negative SEEK_CUR offset must sign-extend the high lseek word. + (call $require_success + (call $fd_seek (local.get $fd) (i64.const -1) (i32.const 1) (i32.const 8)) + (i32.const 19)) + (call $require_i64 + (i64.load (i32.const 8)) + (i64.const 9007199254740992) + (i32.const 20)) + + (call $require_success + (call $fd_close (local.get $fd)) + (i32.const 21)) + (call $proc_exit (i32.const 0)) + ) +) diff --git a/host/test/global-setup.ts b/host/test/global-setup.ts index f640829fcd..e7d052e35a 100644 --- a/host/test/global-setup.ts +++ b/host/test/global-setup.ts @@ -124,6 +124,9 @@ const TEST_PROGRAMS = [ "kernel_allocator_churn_test.c", ]; +/** Memory64 counterparts needed to prove pointer-width-neutral syscall input. */ +const WASM64_TEST_PROGRAMS = ["lseek_invalid_test.c"]; + const FORK_INSTRUMENTED_PROGRAMS = new Set([ "environment_lifecycle_test.c", "pthread_channel_reuse_test.c", @@ -138,6 +141,7 @@ const WAT_FIXTURES = [ "deep-wasm-recursion.wat", "wasi-args.wat", "wasi-hello.wat", + "wasi-scalar-abi.wat", ]; function needsRebuild(srcFile: string, outFile: string): boolean { @@ -272,6 +276,24 @@ export async function setup() { stampProgramFixture(src, out, contract); } + for (const cFile of WASM64_TEST_PROGRAMS) { + const src = join(examplesDir, cFile); + const out = src.replace(/\.c$/, ".wasm64.wasm"); + + if (!existsSync(src)) { + console.warn(`[global-setup] Source not found: ${src}, skipping`); + continue; + } + if (!programFixtureNeedsRebuild(src, out, wasm64Contract)) continue; + + console.log(`[global-setup] Compiling ${cFile} (wasm64)...`); + execFileSync("wasm64posix-cc", [src, "-o", out], { + cwd: repoRoot, + stdio: "pipe", + }); + stampProgramFixture(src, out, wasm64Contract); + } + for (const watFile of WAT_FIXTURES) { const src = join(fixturesDir, watFile); const out = src.replace(/\.wat$/, ".wasm"); diff --git a/host/test/host-adapter-manifest.test.ts b/host/test/host-adapter-manifest.test.ts index e9e2d7a4ae..8bd594b5d5 100644 --- a/host/test/host-adapter-manifest.test.ts +++ b/host/test/host-adapter-manifest.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { ABI_VERSION, CH_DATA, @@ -20,6 +20,12 @@ import { validateKernelHostAdapterManifest, type HostAdapterManifest, } from "../src/host-adapter-manifest"; +import { + createKernelEntryGatedInstance, + createKernelEntryScopedInstance, + KernelEntryGate, + readValidatedKernelHostAdapterManifestScalar, +} from "../src/kernel-entry-gate"; const MANIFEST_OFFSET = 64; @@ -27,7 +33,10 @@ describe("host adapter manifest validation", () => { it("reads and validates a current Rust-owned manifest", () => { const memory = createMemory(); writeManifest(memory); - const instance = createInstance(); + const instance = createKernelEntryGatedInstance( + createInstance(memory), + new KernelEntryGate(), + ); const manifest = validateKernelHostAdapterManifest( instance, @@ -50,10 +59,94 @@ describe("host adapter manifest validation", () => { }); }); + it("reads the manifest through one active initialization scope only", () => { + const memory = createMemory(); + writeManifest(memory); + const gate = new KernelEntryGate(); + const owner = createKernelEntryGatedInstance( + createInstance(memory), + gate, + ); + let scoped!: WebAssembly.Instance; + let manifest!: HostAdapterManifest; + + gate.runOrDeferVoidIngress("manifest validation", (scope) => { + scoped = createKernelEntryScopedInstance(owner, scope); + manifest = validateKernelHostAdapterManifest( + scoped, + memory, + HOST_ADAPTER_REQUIRED_WORKER_FEATURES, + ); + }); + + expect(manifest.magic).toBe(HOST_ADAPTER_MANIFEST_MAGIC); + expect(() => + readValidatedKernelHostAdapterManifestScalar( + scoped, + "kernel_host_adapter_manifest_ptr", + ) + ).toThrow(/scope (?:is no longer active|.*(?:ended|revoked))|outside.*scope/i); + }); + + it("rejects gated and scoped instance/Memory generation mismatches", () => { + const ownedMemory = createMemory(); + const foreignMemory = createMemory(); + writeManifest(ownedMemory); + writeManifest(foreignMemory); + const pointer = vi.fn(() => MANIFEST_OFFSET); + const gate = new KernelEntryGate(); + const owner = createKernelEntryGatedInstance( + createInstance(ownedMemory, { + kernel_host_adapter_manifest_ptr: pointer, + }), + gate, + ); + + expect(() => readKernelHostAdapterManifest(owner, foreignMemory)) + .toThrow(/does not own the supplied WebAssembly\.Memory/); + expect(pointer).not.toHaveBeenCalled(); + + gate.runOrDeferVoidIngress("manifest memory ownership", (scope) => { + const scoped = createKernelEntryScopedInstance(owner, scope); + expect(() => readKernelHostAdapterManifest(scoped, foreignMemory)) + .toThrow(/does not own the supplied WebAssembly\.Memory/); + expect(pointer).not.toHaveBeenCalled(); + expect(readKernelHostAdapterManifest(scoped, ownedMemory).magic) + .toBe(HOST_ADAPTER_MANIFEST_MAGIC); + }); + expect(pointer).toHaveBeenCalledOnce(); + }); + + it("does not turn manifest inspection into generic export authority", () => { + const memory = createMemory(); + const instance = createKernelEntryGatedInstance( + createInstance(memory), + new KernelEntryGate(), + ); + const coerce = vi.fn(() => "kernel_host_adapter_manifest_ptr"); + + expect(() => + readValidatedKernelHostAdapterManifestScalar( + instance, + "kernel_alloc_scratch" as never, + ) + ).toThrow(/not a host-adapter manifest scalar/i); + expect(() => + readValidatedKernelHostAdapterManifestScalar( + instance, + { toString: coerce } as never, + ) + ).toThrow(/not a host-adapter manifest scalar/i); + expect(coerce).not.toHaveBeenCalled(); + }); + it("rejects missing required kernel exports", () => { const memory = createMemory(); writeManifest(memory); - const instance = createInstance({ kernel_alloc_scratch: undefined }); + const instance = createInstance( + memory, + { kernel_alloc_scratch: undefined }, + ); expect(() => validateKernelHostAdapterManifest( @@ -71,7 +164,7 @@ describe("host adapter manifest validation", () => { ])("rejects a kernel missing required scratch transfer export %s", (name) => { const memory = createMemory(); writeManifest(memory); - const instance = createInstance({ [name]: undefined }); + const instance = createInstance(memory, { [name]: undefined }); expect(() => validateKernelHostAdapterManifest( @@ -85,7 +178,7 @@ describe("host adapter manifest validation", () => { it("rejects unsupported worker feature bits", () => { const memory = createMemory(); writeManifest(memory); - const instance = createInstance(); + const instance = createInstance(memory); const supportedFeatures = HOST_ADAPTER_REQUIRED_WORKER_FEATURES & ~HOST_ADAPTER_WORKER_FEATURES.atomics_wait_async; @@ -98,7 +191,7 @@ describe("host adapter manifest validation", () => { it("rejects out-of-bounds manifest pointers", () => { const memory = createMemory(); writeManifest(memory); - const instance = createInstance({ + const instance = createInstance(memory, { kernel_host_adapter_manifest_ptr: () => BigInt(memory.buffer.byteLength), }); @@ -106,6 +199,31 @@ describe("host adapter manifest validation", () => { /out of bounds/, ); }); + + it("rejects structural and proxied generation forgeries before reading exports", () => { + const memory = createMemory(); + writeManifest(memory); + const forged = { + exports: { + kernel_host_adapter_manifest_ptr: () => MANIFEST_OFFSET, + kernel_host_adapter_manifest_len: () => HOST_ADAPTER_MANIFEST_SIZE, + }, + } as unknown as WebAssembly.Instance; + + expect(() => readKernelHostAdapterManifest(forged, memory)).toThrow( + /WebAssembly\.Instance|receiver|incompatible/i, + ); + + const raw = createInstance(memory); + expect(() => readKernelHostAdapterManifest( + new Proxy(raw, {}), + memory, + )).toThrow(/WebAssembly\.Instance|receiver|incompatible/i); + expect(() => readKernelHostAdapterManifest( + raw, + new Proxy(memory, {}), + )).toThrow(/does not own the supplied WebAssembly\.Memory/i); + }); }); function createMemory(): WebAssembly.Memory { @@ -117,21 +235,99 @@ function createMemory(): WebAssembly.Memory { } function createInstance( + memory: WebAssembly.Memory, overrides: Record = {}, ): WebAssembly.Instance { - const exports: Record = {}; + const exports: Record bigint> = {}; for (const exportName of HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS) { - exports[exportName] = () => 0; + exports[exportName] = () => 0n; } exports.kernel_host_adapter_manifest_ptr = () => BigInt(MANIFEST_OFFSET); - exports.kernel_host_adapter_manifest_len = () => HOST_ADAPTER_MANIFEST_SIZE; - Object.assign(exports, overrides); + exports.kernel_host_adapter_manifest_len = () => + BigInt(HOST_ADAPTER_MANIFEST_SIZE); for (const [name, value] of Object.entries(overrides)) { - if (value === undefined) delete exports[name]; + if (value === undefined) { + delete exports[name]; + continue; + } + if (typeof value !== "function") { + throw new TypeError(`test export ${name} must be a function`); + } + exports[name] = () => BigInt(Reflect.apply(value, undefined, [])); } - return { exports } as unknown as WebAssembly.Instance; + const entries = Object.entries(exports); + const typeSection = [ + 1, + 0x60, + 0, + 1, + 0x7e, + ]; + const importSection = [ + ...unsignedLeb128(entries.length + 1), + ...wasmString("manifest"), + ...wasmString("memory"), + 2, + 0x03, + 1, + 1, + ]; + const exportSection = [ + ...unsignedLeb128(entries.length + 1), + ...wasmString("memory"), + 2, + 0, + ]; + const imports: Record bigint> = {}; + entries.forEach(([name, implementation], index) => { + importSection.push( + ...wasmString("manifest"), + ...wasmString(name), + 0, + 0, + ); + exportSection.push( + ...wasmString(name), + 0, + ...unsignedLeb128(index), + ); + imports[name] = implementation; + }); + const module = new WebAssembly.Module(new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, + 0x01, 0x00, 0x00, 0x00, + ...wasmSection(1, typeSection), + ...wasmSection(2, importSection), + ...wasmSection(7, exportSection), + ])); + return new WebAssembly.Instance(module, { + manifest: { + ...imports, + memory, + }, + }); +} + +function unsignedLeb128(value: number): number[] { + const bytes: number[] = []; + do { + let byte = value & 0x7f; + value >>>= 7; + if (value !== 0) byte |= 0x80; + bytes.push(byte); + } while (value !== 0); + return bytes; +} + +function wasmString(value: string): number[] { + const bytes = Array.from(new TextEncoder().encode(value)); + return [...unsignedLeb128(bytes.length), ...bytes]; +} + +function wasmSection(id: number, payload: number[]): number[] { + return [id, ...unsignedLeb128(payload.length), ...payload]; } function writeManifest( diff --git a/host/test/host-diagnostic-routing.test.ts b/host/test/host-diagnostic-routing.test.ts index 37c6332fd5..1977453e67 100644 --- a/host/test/host-diagnostic-routing.test.ts +++ b/host/test/host-diagnostic-routing.test.ts @@ -41,6 +41,18 @@ describe.each(entries)("%s kernel-worker diagnostic routing", (_name, path) => { expect(source).not.toContain("reportedNonzeroProcessExits"); expect(source).not.toContain("-> forcing exit"); }); + + it("wires a poisoned shared kernel instance to definitive worker teardown", () => { + expect(source).toMatch( + /\bfunction\s+terminatePoisonedKernelWorker\s*\(\s*error:\s*Error\s*\)/, + ); + expect(source).toMatch( + /\bonKernelFatal:\s*terminatePoisonedKernelWorker\b/, + ); + expect(source).toMatch( + /post\(\{\s*type:\s*"kernel_fatal",\s*error:\s*detail\s*\}\)/, + ); + }); }); it("does not log an ordinary process exit from the process worker", () => { diff --git a/host/test/host-file-offset.test.ts b/host/test/host-file-offset.test.ts new file mode 100644 index 0000000000..92dbe3b15f --- /dev/null +++ b/host/test/host-file-offset.test.ts @@ -0,0 +1,404 @@ +import { + fstatSync, + mkdtempSync, + openSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { NativePositionedWriteHandles } from "../src/native-positioned-write"; +import { NodePlatformIO } from "../src/platform/node"; +import type { HostFileOffset } from "../src/types"; +import { DeviceFileSystem } from "../src/vfs/device-fs"; +import { HostFileSystem } from "../src/vfs/host-fs"; +import { MemoryFileSystem } from "../src/vfs/memory-fs"; +import { OPFS_CHANNEL_SIZE } from "../src/vfs/opfs-channel"; +import { OpfsFileSystem } from "../src/vfs/opfs"; +import type { FileSystemBackend } from "../src/vfs/types"; +import { VirtualPlatformIO } from "../src/vfs/vfs"; + +const TWO_TO_53 = 1n << 53n; +const MIN_I64 = -(1n << 63n); +const MAX_I64 = (1n << 63n) - 1n; +const O_WRONLY = 0o1; +const O_RDWR = 0o2; +const O_APPEND = 0o2000; +const SEEK_SET = 0; +const SEEK_CUR = 1; +const SEEK_END = 2; + +const tempRoots: string[] = []; + +afterEach(() => { + while (tempRoots.length > 0) { + rmSync(tempRoots.pop()!, { recursive: true, force: true }); + } +}); + +function tempRoot(prefix: string): string { + const root = mkdtempSync(join(tmpdir(), prefix)); + tempRoots.push(root); + return root; +} + +function linuxOpenFileDescriptionCount( + target: { dev: bigint; ino: bigint }, +): number | null { + if (process.platform !== "linux") return null; + let count = 0; + for (const entry of readdirSync("/proc/self/fd")) { + const fd = Number(entry); + if (!Number.isInteger(fd)) continue; + try { + const candidate = fstatSync(fd, { bigint: true }); + if (candidate.dev === target.dev && candidate.ino === target.ino) { + count++; + } + } catch { + // The directory enumeration itself can briefly occupy a descriptor. + } + } + return count; +} + +describe("HostFileOffset VFS contract", () => { + it("forwards exact positioned and seek offsets without narrowing", () => { + const reads: HostFileOffset[] = []; + const writes: HostFileOffset[] = []; + const seeks: HostFileOffset[] = []; + const backend = { + open: () => 7, + close: () => 0, + read: ( + _handle: number, + _buffer: Uint8Array, + offset: HostFileOffset | null, + ) => { + if (offset !== null) reads.push(offset); + return 0; + }, + write: ( + _handle: number, + _buffer: Uint8Array, + offset: HostFileOffset | null, + ) => { + if (offset !== null) writes.push(offset); + return 0; + }, + seek: ( + _handle: number, + offset: HostFileOffset, + ): HostFileOffset => { + seeks.push(offset); + return offset; + }, + } as unknown as FileSystemBackend; + const io = new VirtualPlatformIO( + [{ mountPoint: "/", backend }], + { + clockGettime: () => ({ sec: 0, nsec: 0 }), + nanosleep: () => {}, + }, + ); + const handle = io.open("/file", O_RDWR, 0); + const byte = new Uint8Array(1); + + expect(io.read(handle, byte, TWO_TO_53 + 1n, 1)).toBe(0); + expect(io.write(handle, byte, MAX_I64, 1)).toBe(0); + expect(io.seek(handle, MIN_I64, SEEK_CUR)).toBe(MIN_I64); + expect(reads).toEqual([TWO_TO_53 + 1n]); + expect(writes).toEqual([MAX_I64]); + expect(seeks).toEqual([MIN_I64]); + }); +}); + +describe("native positioned-write route ownership", () => { + it("fails closed for a regular file without an established route", () => { + const root = tempRoot("kandelo-missing-write-route-"); + const path = join(root, "file"); + writeFileSync(path, "abcdef"); + + // Use a fresh descriptor that deliberately bypasses the owner registration + // path to prove the helper never silently uses an unknown O_APPEND state. + const rawHandle = openSync(path, "r+"); + const routes = new NativePositionedWriteHandles(); + try { + expect(() => routes.forWrite(rawHandle, true)).toThrow(/EOPNOTSUPP/); + } finally { + routes.close(rawHandle); + } + }); +}); + +describe.each([ + [ + "NodePlatformIO", + () => { + const root = tempRoot("kandelo-node-offset-"); + const path = join(root, "file"); + writeFileSync(path, "abcdef"); + return { + io: new NodePlatformIO(), + path, + nativePath: path, + }; + }, + ], + [ + "HostFileSystem", + () => { + const root = tempRoot("kandelo-host-fs-offset-"); + const nativePath = join(root, "file"); + writeFileSync(nativePath, "abcdef"); + return { + io: new HostFileSystem(root), + path: "/file", + nativePath, + }; + }, + ], +] as const)("%s exact offsets", (_name, makeCase) => { + it("keeps bigint seeks and positioned reads exact at and above 2^53", () => { + const { io, path } = makeCase(); + const handle = io.open(path, O_RDWR, 0); + try { + expect(io.seek(handle, TWO_TO_53, SEEK_SET)).toBe(TWO_TO_53); + expect(io.seek(handle, 1n, SEEK_CUR)).toBe(TWO_TO_53 + 1n); + expect(io.read(handle, new Uint8Array(1), TWO_TO_53 + 1n, 1)).toBe(0); + expect(io.seek(handle, 0n, SEEK_CUR)).toBe(TWO_TO_53 + 1n); + + expect(io.seek(handle, MAX_I64, SEEK_SET)).toBe(MAX_I64); + expect(io.read(handle, new Uint8Array(0), MAX_I64, 0)).toBe(0); + expect(() => { + io.read(handle, new Uint8Array(1), MAX_I64, 1); + }).toThrow(/EOVERFLOW/); + expect(() => io.seek(handle, 1n, SEEK_CUR)).toThrow(/EOVERFLOW/); + expect(io.seek(handle, 0n, SEEK_CUR)).toBe(MAX_I64); + } finally { + io.close(handle); + } + }); + + it("fails before a bigint position could be silently lost by writeSync", () => { + const { io, path, nativePath } = makeCase(); + const handle = io.open(path, O_RDWR, 0); + try { + expect(() => { + io.write(handle, new Uint8Array([0x7a]), TWO_TO_53, 1); + }).toThrow(/EOVERFLOW/); + expect(readFileSync(nativePath, "utf8")).toBe("abcdef"); + + expect(io.write(handle, new Uint8Array([0x5a]), 1n, 1)).toBe(1); + expect(readFileSync(nativePath, "utf8")).toBe("aZcdef"); + } finally { + io.close(handle); + } + }); + + it("checks negative and out-of-i64 offsets without changing seek state", () => { + const { io, path } = makeCase(); + const handle = io.open(path, O_RDWR, 0); + try { + expect(io.seek(handle, 2, SEEK_SET)).toBe(2); + expect(() => { + io.read(handle, new Uint8Array(1), MIN_I64, 1); + }).toThrow(/EINVAL/); + expect(() => io.seek(handle, MIN_I64, SEEK_SET)).toThrow(/EINVAL/); + expect(() => { + io.read(handle, new Uint8Array(1), MAX_I64 + 1n, 1); + }).toThrow(/EOVERFLOW/); + expect(() => { + io.write(handle, new Uint8Array(1), MIN_I64 - 1n, 1); + }).toThrow(/EOVERFLOW/); + expect(io.seek(handle, 0, SEEK_CUR)).toBe(2); + } finally { + io.close(handle); + } + }); + + it("rejects externally mutable append before writing any bytes", () => { + const { io, path, nativePath } = makeCase(); + const handle = io.open(path, O_RDWR, 0); + try { + for (const limit of [null, 7] as const) { + expect(() => { + io.append(handle, new Uint8Array([0x21]), 1, limit); + }).toThrow(/EOPNOTSUPP/); + expect(readFileSync(nativePath, "utf8")).toBe("abcdef"); + } + } finally { + io.close(handle); + } + }); + + it("positions writes independently of O_APPEND and preserves the cursor", () => { + const { io, path, nativePath } = makeCase(); + const handle = io.open(path, O_RDWR | O_APPEND, 0); + try { + expect(io.seek(handle, 4, SEEK_SET)).toBe(4); + expect(io.write(handle, new Uint8Array([0x5a]), 1, 1)).toBe(1); + expect(readFileSync(nativePath, "utf8")).toBe("aZcdef"); + expect(io.seek(handle, 0, SEEK_CUR)).toBe(4); + + expect(() => { + io.append(handle, new Uint8Array([0x21]), 1, null); + }).toThrow(/EOPNOTSUPP/); + expect(readFileSync(nativePath, "utf8")).toBe("aZcdef"); + } finally { + io.close(handle); + } + }); + + it("keeps positioned writes bound to the opened inode after rename", () => { + const { io, path, nativePath } = makeCase(); + const handle = io.open(path, O_RDWR, 0); + const renamedPath = `${nativePath}.renamed`; + try { + renameSync(nativePath, renamedPath); + writeFileSync(nativePath, "replacement"); + + expect(() => { + io.append(handle, new Uint8Array([0x21]), 1, null); + }).toThrow(/EOPNOTSUPP/); + expect(io.write(handle, new Uint8Array([0x58]), 2, 1)).toBe(1); + expect(readFileSync(renamedPath, "utf8")).toBe("abXdef"); + expect(readFileSync(nativePath, "utf8")).toBe("replacement"); + } finally { + io.close(handle); + } + }); + + it("supports positioned writes after the opened file is unlinked", () => { + const { io, path, nativePath } = makeCase(); + const handle = io.open(path, O_RDWR, 0); + try { + unlinkSync(nativePath); + + expect(() => { + io.append(handle, new Uint8Array([0x21]), 1, null); + }).toThrow(/EOPNOTSUPP/); + expect(io.write(handle, new Uint8Array([0x51]), 3, 1)).toBe(1); + const result = new Uint8Array(6); + expect(io.read(handle, result, 0, result.byteLength)).toBe(6); + expect(new TextDecoder().decode(result)).toBe("abcQef"); + } finally { + io.close(handle); + } + }); + + it("closes the Linux companion together with the primary descriptor", () => { + const { io, path } = makeCase(); + const handle = io.open(path, O_RDWR | O_APPEND, 0); + const target = fstatSync(handle, { bigint: true }); + let closed = false; + try { + expect(linuxOpenFileDescriptionCount(target)).toBe( + process.platform === "linux" ? 2 : null, + ); + expect(io.write(handle, new Uint8Array([0x58]), 0, 1)).toBe(1); + expect(linuxOpenFileDescriptionCount(target)).toBe( + process.platform === "linux" ? 2 : null, + ); + + expect(io.close(handle)).toBe(0); + closed = true; + expect(linuxOpenFileDescriptionCount(target)).toBe( + process.platform === "linux" ? 0 : null, + ); + expect(() => { + io.write(handle, new Uint8Array([0x59]), 0, 1); + }).toThrow(/EBADF|bad file descriptor/i); + } finally { + if (!closed) io.close(handle); + } + }); + + it("keeps O_WRONLY positioned writes while append stays unsupported", () => { + const { io, path, nativePath } = makeCase(); + const handle = io.open(path, O_WRONLY, 0); + try { + expect(io.write(handle, new Uint8Array([0x58]), 1, 1)).toBe(1); + expect(() => { + io.append(handle, new Uint8Array([0x21]), 1, null); + }).toThrow(/EOPNOTSUPP/); + expect(readFileSync(nativePath, "utf8")).toBe("aXcdef"); + } finally { + io.close(handle); + } + }); + + it("keeps positioned writes attached to the opened inode across fchmod", () => { + const { io, path, nativePath } = makeCase(); + const handle = io.open(path, O_WRONLY, 0); + try { + io.fchmod(handle, 0o640); + expect(io.fstat(handle).mode & 0o777).toBe(0o640); + + expect(io.write(handle, new Uint8Array([0x58]), 2, 1)).toBe(1); + expect(() => { + io.append(handle, new Uint8Array([0x21]), 1, null); + }).toThrow(/EOPNOTSUPP/); + expect(readFileSync(nativePath, "utf8")).toBe("abXdef"); + } finally { + io.close(handle); + } + }); +}); + +describe("number-only VFS backends", () => { + it.each([ + [ + "MemoryFileSystem", + () => { + const io = MemoryFileSystem.create( + new SharedArrayBuffer(4 * 1024 * 1024), + ); + return { + io, + handle: io.open("/file", 0o100 | O_RDWR, 0o600), + }; + }, + ], + [ + "OpfsFileSystem", + () => { + const io = OpfsFileSystem.create( + new SharedArrayBuffer(OPFS_CHANNEL_SIZE), + ); + // Unsafe offsets are rejected before OPFS tries to use the channel. + return { io, handle: 7 }; + }, + ], + ] as const)("%s reports EOVERFLOW instead of narrowing bigint", (_name, makeCase) => { + const { io, handle } = makeCase(); + const byte = new Uint8Array(1); + + expect(() => io.read(handle, byte, TWO_TO_53, 1)).toThrow(/EOVERFLOW/); + expect(() => io.write(handle, byte, TWO_TO_53, 1)).toThrow(/EOVERFLOW/); + expect(() => io.seek(handle, TWO_TO_53, SEEK_SET)).toThrow(/EOVERFLOW/); + expect(() => io.read(handle, byte, MAX_I64, 1)).toThrow(/EOVERFLOW/); + expect(() => io.seek(handle, MIN_I64, SEEK_SET)).toThrow(/EOVERFLOW/); + expect(() => io.read(handle, byte, MAX_I64 + 1n, 1)).toThrow(/EOVERFLOW/); + expect(() => io.write(handle, byte, MIN_I64, 1)).toThrow(/EINVAL/); + }); +}); + +describe("DeviceFileSystem exact offsets", () => { + it("validates signed i64 input without narrowing ignored device offsets", () => { + const io = new DeviceFileSystem(); + const handle = io.open("/null", O_RDWR, 0); + const byte = new Uint8Array(1); + + expect(io.read(handle, byte, MAX_I64, 1)).toBe(0); + expect(io.write(handle, byte, MAX_I64, 1)).toBe(1); + expect(io.seek(handle, MIN_I64, SEEK_SET)).toBe(0); + expect(() => io.read(handle, byte, MAX_I64 + 1n, 1)) + .toThrow(/EOVERFLOW/); + }); +}); diff --git a/host/test/host-process-pointer-width.test.ts b/host/test/host-process-pointer-width.test.ts index 7fd206a862..7d78f11534 100644 --- a/host/test/host-process-pointer-width.test.ts +++ b/host/test/host-process-pointer-width.test.ts @@ -1,12 +1,16 @@ import { describe, expect, it, vi } from "vitest"; import { ABI_SYSCALLS, + CHANNEL_STATUS_PENDING, CH_ARGS, CH_ARG_SIZE, + CH_STATUS, CH_SYSCALL, } from "../src/generated/abi"; -import { checkedWasmPointer } from "../src/kernel-scratch"; -import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { + createCentralizedKernelWorkerTestDouble, +} from "../src/kernel-worker"; +import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; const PID = 73; const MAP_PRIVATE = 0x02; @@ -17,17 +21,7 @@ const FUTEX_REQUEUE = 3; const FUTEX_WAKE_OP = 5; function sharedMemory(): WebAssembly.Memory { - return new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); -} - -function channel(memory: WebAssembly.Memory) { - return { - pid: PID, - memory, - channelOffset: 0, - i32View: new Int32Array(memory.buffer, 0, 1), - consecutiveSyscalls: 0, - }; + return new WebAssembly.Memory({ initial: 2, maximum: 2, shared: true }); } function workerHarness( @@ -35,59 +29,49 @@ function workerHarness( kernelPointerWidth: 4 | 8 = pointerWidth, ) { const memory = sharedMemory(); - const processChannel = channel(memory); + const kernelMemory = new WebAssembly.Memory({ + initial: 2, + maximum: 2, + }); const completeChannelRaw = vi.fn(); const completeChannel = vi.fn(); const relistenChannel = vi.fn(); const synchronizeSharedMemoryForBoundary = vi.fn(); const kernelHandle = vi.fn(); - const worker = Object.assign( - Object.create(CentralizedKernelWorker.prototype), + const kernelExports: Record = { + kernel_dequeue_signal: () => 0, + kernel_get_process_exit_signal: () => -1, + kernel_handle_channel: kernelHandle, + kernel_set_current_tid: () => 0, + }; + const worker = createCentralizedKernelWorkerTestDouble(); + installKernelWorkerTestScratch( + worker, + kernelMemory, + 128, + kernelPointerWidth, { - callbacks: {}, - channelTids: new Map(), - completeChannel, - completeChannelRaw, - config: {}, - hostReaped: new Set(), - kernel: { - framebuffers: { rebindMemory: vi.fn() }, - getKernelPtrWidth: () => kernelPointerWidth, - toKernelPtr: (value: number | bigint) => { - const checked = checkedWasmPointer( - value, - kernelPointerWidth, - "test kernel pointer", - ); - return kernelPointerWidth === 8 ? BigInt(checked) : checked; - }, - }, - kernelInstance: { - exports: { kernel_handle_channel: kernelHandle }, - }, - pendingCancels: new Set(), - pendingFutexWaits: new Map(), - processes: new Map([[ - PID, - { - pid: PID, - memory, - ptrWidth: pointerWidth, - channels: [processChannel], - }, - ]]), - relistenChannel, - sharedMmapBackings: new Map(), - syscallRing: new Map(), - syscallTraceEnabled: false, - syscallTraceRing: [], - synchronizeSharedMemoryForBoundary, + kernelExports, }, - ) as CentralizedKernelWorker; + ); + worker.testAuthority.configureScratchBoundaryHooksForTest({ + completeChannel, + completeChannelRaw, + relistenChannel, + synchronizeSharedMemoryForBoundary, + }); + const [processChannel] = + worker.testAuthority.replaceProcessRegistrationForLifecycleTest({ + pid: PID, + memory, + channelOffsets: [0], + pointerWidth, + }); return { completeChannel, completeChannelRaw, kernelHandle, + kernelExports, memory, processChannel, relistenChannel, @@ -97,7 +81,10 @@ function workerHarness( } function writeSyscall( - processChannel: ReturnType, + processChannel: { + readonly memory: WebAssembly.Memory; + readonly channelOffset: number; + }, syscallNr: number, args: readonly bigint[], ): void { @@ -105,6 +92,7 @@ function writeSyscall( processChannel.memory.buffer, processChannel.channelOffset, ); + view.setUint32(CH_STATUS, CHANNEL_STATUS_PENDING, true); view.setUint32(CH_SYSCALL, syscallNr, true); for (let index = 0; index < 6; index++) { view.setBigInt64( @@ -131,7 +119,9 @@ describe("handwritten host process-pointer width checks", () => { 0n, ]); - (h.worker as any)._handleSyscallInner(h.processChannel); + h.worker.testAuthority.dispatchScratchBoundarySyscallForTest( + h.processChannel, + ); expect(h.kernelHandle).not.toHaveBeenCalled(); expect(h.completeChannel).toHaveBeenCalledWith( @@ -173,7 +163,9 @@ describe("handwritten host process-pointer width checks", () => { 0n, ]); - (h.worker as any)._handleSyscallInner(h.processChannel); + h.worker.testAuthority.dispatchScratchBoundarySyscallForTest( + h.processChannel, + ); expect(h.synchronizeSharedMemoryForBoundary).not.toHaveBeenCalled(); expect(h.kernelHandle).not.toHaveBeenCalled(); @@ -182,7 +174,6 @@ describe("handwritten host process-pointer width checks", () => { -1, errno, ); - expect(h.relistenChannel).toHaveBeenCalledWith(h.processChannel); }); it("rejects pointer-plus-length overflow before synchronization or dispatch", () => { @@ -196,7 +187,9 @@ describe("handwritten host process-pointer width checks", () => { 0n, ]); - (h.worker as any)._handleSyscallInner(h.processChannel); + h.worker.testAuthority.dispatchScratchBoundarySyscallForTest( + h.processChannel, + ); expect(h.synchronizeSharedMemoryForBoundary).not.toHaveBeenCalled(); expect(h.kernelHandle).not.toHaveBeenCalled(); @@ -215,13 +208,17 @@ describe("handwritten host process-pointer width checks", () => { ABI_SYSCALLS.Mmap, highAddress, 0, - )).toEqual({ retVal: Number(highAddress), errVal: 0 }); + )).toEqual({ + retVal: Number(highAddress), + publicationRetVal: Number(highAddress), + errVal: 0, + }); expect((wasm64.worker as any).normalizeKernelSyscallResult( wasm64.processChannel, ABI_SYSCALLS.Mremap, BigInt(Number.MAX_SAFE_INTEGER) + 1n, 0, - )).toEqual({ retVal: -1, errVal: 75 }); + )).toEqual({ retVal: -1, publicationRetVal: -1, errVal: 75 }); const wasm32 = workerHarness(4); expect((wasm32.worker as any).normalizeKernelSyscallResult( @@ -229,33 +226,38 @@ describe("handwritten host process-pointer width checks", () => { ABI_SYSCALLS.Brk, highAddress, 0, - )).toEqual({ retVal: -1, errVal: 75 }); + )).toEqual({ retVal: -1, publicationRetVal: -1, errVal: 75 }); }); it("does not mistake a high wasm64 host reservation for a low u32 sentinel", () => { const h = workerHarness(8); const highAddress = 0x1_ffff_ffffn; - (h.worker as any).toKernelPtr = (value: number | bigint) => value; - (h.worker as any).kernelInstance = { - exports: { - kernel_reserve_host_region: vi.fn(() => highAddress), - }, - }; + h.kernelExports.kernel_reserve_host_region = vi.fn(() => highAddress); expect(h.worker.reserveHostRegion(PID, 4096)).toBe(Number(highAddress)); }); + it("rejects an invalid dynamic reservation length before entering Wasm", () => { + const h = workerHarness(8); + const reserve = vi.fn(() => 0x20_000n); + h.kernelExports.kernel_reserve_host_region = reserve; + + expect(() => h.worker.reserveHostRegion(PID, -1)).toThrow( + /failed to reserve -1 bytes/, + ); + expect(reserve).not.toHaveBeenCalled(); + expect(h.worker.reserveHostRegion(PID, 4096)).toBe(0x20_000); + expect(reserve).toHaveBeenCalledOnce(); + }); + it("losslessly normalizes signed high-bit wasm32 host reservations", () => { const h = workerHarness(4); const highAddress = 0x8000_5000; const signedExportResult = highAddress | 0; - (h.worker as any).toKernelPtr = (value: number | bigint) => value; - (h.worker as any).kernelInstance = { - exports: { - kernel_reserve_host_region: vi.fn(() => signedExportResult), - kernel_reserve_host_region_at: vi.fn(() => signedExportResult), - }, - }; + h.kernelExports.kernel_reserve_host_region = + vi.fn(() => signedExportResult); + h.kernelExports.kernel_reserve_host_region_at = + vi.fn(() => signedExportResult); expect(h.worker.reserveHostRegion(PID, 4096)).toBe(highAddress); expect(h.worker.reserveHostRegionAt(PID, highAddress, 4096)).toBe( @@ -269,67 +271,69 @@ describe("handwritten host process-pointer width checks", () => { const signedExportResult = highAddress | 0; const reserve = vi.fn(() => signedExportResult); const reserveAt = vi.fn(() => signedExportResult); - (h.worker as any).kernelInstance = { - exports: { - kernel_reserve_host_region: reserve, - kernel_reserve_host_region_at: reserveAt, - }, - }; + h.kernelExports.kernel_reserve_host_region = reserve; + h.kernelExports.kernel_reserve_host_region_at = reserveAt; expect(h.worker.reserveHostRegion(PID, 4096)).toBe(highAddress); expect(h.worker.reserveHostRegionAt(PID, highAddress, 4096)).toBe( highAddress, ); expect(reserve).toHaveBeenCalledWith(PID, 4096); - expect(reserveAt).toHaveBeenCalledWith(PID, highAddress, 4096); + // The genuine wasm32 boundary presents the same address bits to its + // JavaScript import as a signed i32; the host normalizes only the export + // result back into the guest's unsigned logical address. + expect(reserveAt).toHaveBeenCalledWith(PID, signedExportResult, 4096); }); it("rejects a fixed reservation outside a wasm32 guest before a wasm64 export", () => { const h = workerHarness(4, 8); const reserveAt = vi.fn(() => 0x1_0000_0000n); - (h.worker as any).kernelInstance = { - exports: { - kernel_reserve_host_region_at: reserveAt, - }, - }; + h.kernelExports.kernel_reserve_host_region_at = reserveAt; expect(() => h.worker.reserveHostRegionAt(PID, 0x1_0000_0000, 4096) ).toThrow(/failed to reserve pthread control memory/); expect(reserveAt).not.toHaveBeenCalled(); + + // Caller-domain rejection happens before the entry scope. It must not + // poison the genuine kernel generation or prevent a later valid request. + h.kernelExports.kernel_reserve_host_region_at = + vi.fn(() => 0x20_000n); + expect(h.worker.reserveHostRegionAt(PID, 0x20_000, 4096)).toBe(0x20_000); }); it("rejects a returned reservation whose end exceeds the wasm32 guest domain", () => { const h = workerHarness(4, 8); - (h.worker as any).kernelInstance = { - exports: { - kernel_reserve_host_region: vi.fn(() => 0xffff_f000n), - }, - }; - - expect(() => h.worker.reserveHostRegion(PID, 8192)).toThrow( - /failed to reserve 8192 bytes/, + h.kernelExports.kernel_reserve_host_region = + vi.fn(() => 0xffff_f000n); + + let failure: unknown; + try { + h.worker.reserveHostRegion(PID, 8192); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toMatch( + /void kernel ingress dynamic host-region reservation/, ); + expect((failure as Error & { cause?: unknown }).cause).toMatchObject({ + message: expect.stringMatching(/failed to reserve 8192 bytes/), + }); }); it("accepts a wasm32 reservation whose exclusive end is exactly 4 GiB", () => { const h = workerHarness(4, 8); - (h.worker as any).kernelInstance = { - exports: { - kernel_reserve_host_region: vi.fn(() => 0xffff_0000n), - }, - }; + h.kernelExports.kernel_reserve_host_region = + vi.fn(() => 0xffff_0000n); expect(h.worker.reserveHostRegion(PID, 0x1_0000)).toBe(0xffff_0000); }); it("does not treat a kernel64 0xffffffff address as the wasm32 failure sentinel", () => { const h = workerHarness(8, 8); - (h.worker as any).kernelInstance = { - exports: { - kernel_reserve_host_region: vi.fn(() => 0xffff_ffffn), - }, - }; + h.kernelExports.kernel_reserve_host_region = + vi.fn(() => 0xffff_ffffn); expect(h.worker.reserveHostRegion(PID, 1)).toBe(0xffff_ffff); }); @@ -340,17 +344,16 @@ describe("handwritten host process-pointer width checks", () => { const highSecond = 0x1_0000_2000n; const notify = vi.spyOn(Atomics, "notify"); try { - (h.worker as any).handleFutex( + writeSyscall(h.processChannel, ABI_SYSCALLS.Futex, [ + BigInt(primary), + BigInt(FUTEX_WAKE_OP), + 1n, + 1n, + highSecond, + 0n, + ]); + h.worker.testAuthority.dispatchScratchBoundarySyscallForTest( h.processChannel, - [primary, FUTEX_WAKE_OP, 1, 1, Number(highSecond), 0], - [ - BigInt(primary), - BigInt(FUTEX_WAKE_OP), - 1n, - 1n, - highSecond, - 0n, - ], ); expect(notify).not.toHaveBeenCalled(); @@ -370,9 +373,16 @@ describe("handwritten host process-pointer width checks", () => { const second = 0x2000; const truncatedTimeout = h.memory.buffer.byteLength - 8; - (h.worker as any).handleFutex( + writeSyscall(h.processChannel, ABI_SYSCALLS.Futex, [ + BigInt(primary), + BigInt(FUTEX_WAIT), + 1n, + BigInt(truncatedTimeout), + 0n, + 0n, + ]); + h.worker.testAuthority.dispatchScratchBoundarySyscallForTest( h.processChannel, - [primary, FUTEX_WAIT, 1, truncatedTimeout, 0, 0], ); expect(h.completeChannelRaw).toHaveBeenLastCalledWith( h.processChannel, @@ -381,9 +391,16 @@ describe("handwritten host process-pointer width checks", () => { ); h.completeChannelRaw.mockClear(); - (h.worker as any).handleFutex( + writeSyscall(h.processChannel, ABI_SYSCALLS.Futex, [ + BigInt(primary), + BigInt(FUTEX_REQUEUE), + 1n, + -1n, + BigInt(second), + 0n, + ]); + h.worker.testAuthority.dispatchScratchBoundarySyscallForTest( h.processChannel, - [primary, FUTEX_REQUEUE, 1, -1, second, 0], ); expect(h.completeChannelRaw).toHaveBeenCalledWith( h.processChannel, @@ -392,12 +409,62 @@ describe("handwritten host process-pointer width checks", () => { ); }); + it.each([ + [-1n, 0n], + [0n, -1n], + [0n, 1_000_000_000n], + ])( + "rejects a non-normalized futex timeout (%s, %s) before waiting", + (seconds, nanoseconds) => { + const h = workerHarness(8); + const primary = 0x1000; + const timeout = 0x2000; + const view = new DataView(h.memory.buffer); + view.setInt32(primary, 0, true); + view.setBigInt64(timeout, seconds, true); + view.setBigInt64(timeout + 8, nanoseconds, true); + const waitAsync = vi.spyOn(Atomics, "waitAsync").mockReturnValue({ + async: false, + value: "not-equal", + }); + try { + writeSyscall(h.processChannel, ABI_SYSCALLS.Futex, [ + BigInt(primary), + BigInt(FUTEX_WAIT), + 0n, + BigInt(timeout), + 0n, + 0n, + ]); + h.worker.testAuthority.dispatchScratchBoundarySyscallForTest( + h.processChannel, + ); + + expect(waitAsync).not.toHaveBeenCalled(); + expect(h.completeChannelRaw).toHaveBeenCalledWith( + h.processChannel, + -1, + 22, + ); + } finally { + waitAsync.mockRestore(); + } + }, + ); + it("rejects a futex word that crosses the current memory boundary", () => { const h = workerHarness(8); const crossingWord = h.memory.buffer.byteLength - 2; - (h.worker as any).handleFutex( + writeSyscall(h.processChannel, ABI_SYSCALLS.Futex, [ + BigInt(crossingWord), + BigInt(FUTEX_WAIT), + 0n, + 0n, + 0n, + 0n, + ]); + h.worker.testAuthority.dispatchScratchBoundarySyscallForTest( h.processChannel, - [crossingWord, FUTEX_WAIT, 0, 0, 0, 0], ); expect(h.completeChannelRaw).toHaveBeenCalledWith( h.processChannel, diff --git a/host/test/interactive-stdin.test.ts b/host/test/interactive-stdin.test.ts index fe3bba673b..14bb3085fb 100644 --- a/host/test/interactive-stdin.test.ts +++ b/host/test/interactive-stdin.test.ts @@ -70,6 +70,26 @@ describe.each(shells)( expect(result.stdout).toContain("got:hello"); }); + it("owns appended bytes before the caller can replace them", async () => { + const result = await runCentralizedProgram({ + programPath: binary, + argv: [argv0, "-c", 'read line; echo "got:$line"'], + env, + timeout: 20_000, + onStarted: async (kernelWorker, pid) => { + await new Promise((r) => setTimeout(r, 200)); + const callerBytes = new TextEncoder().encode("stable\n"); + kernelWorker.appendStdinData(pid, callerBytes); + // The host keeps stdin across later kernel exports. Mutating the + // caller's view after append must not replace those retained bytes. + callerBytes.fill("x".charCodeAt(0)); + }, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("got:stable"); + expect(result.stdout).not.toContain("got:xxxxxx"); + }); + it("delivers multiple lines incrementally", async () => { const result = await runCentralizedProgram({ programPath: binary, diff --git a/host/test/kernel-authority-boundary.test.ts b/host/test/kernel-authority-boundary.test.ts new file mode 100644 index 0000000000..6b1048214a --- /dev/null +++ b/host/test/kernel-authority-boundary.test.ts @@ -0,0 +1,295 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it, vi } from "vitest"; + +import * as browserEntry from "../src/browser"; +import * as nodeEntry from "../src/index"; +import { + CentralizedKernelWorker, + createCentralizedKernelWorkerTestDouble, +} from "../src/kernel-worker"; +import { + createWasmPosixKernelTestHarness, + WasmPosixKernel, +} from "../src/kernel"; + +const hiddenKernelNames = [ + "instance", + "memory", + "rawInstance", + "kernelEntryGate", + "getInstance", + "getMemory", + "initWithMemory", + "buildImportObject", + "writeKernelBytes", + "hostFstat", + "hostReaddir", + "hostClosedir", + "hostClose", + "testAuthority", +] as const; + +const hiddenWorkerNames = [ + "kernel", + "kernelEntryGate", + "kernelInstance", + "kernelMemory", + "scratchOffset", + "scratchRegion", + "tcpScratchRegion", + "largeSpawnScratchInUse", + "largeTransferScratchInUse", + "kernelFatalError", + "initialized", + "getKernel", + "getKernelInstance", + "_handleSyscallInner", + "bindKernelTidForChannel", + "bindKernelTid", + "getProcessExitSignal", + "dequeueSignalForDelivery", + "finishSignalTermination", + "startTcpListener", + "handleSyscallWithinKernelEntry", + "retrySyscallWithinKernelEntry", + "kernelInstanceForEntry", + "kernelEntryContext", + "invokeEntryScratchExport", + "checkedScratchProducerByteLength", + "rejectScratchTransfer", + "scalarTransferSyscall", + "copyFlattenedTransferInput", + "copyFlattenedTransferOutput", + "checkedChannelTransferResult", + "checkedReservedTransferResult", + "executeMainScratchTransfer", + "cancelLargeTransferScratch", + "executeReservedScratchTransfer", + "handleFlattenedTransfer", + "handleWritev", + "handleLargeWrite", + "handleLargeRead", + "handleReadv", + "handleSpawn", + "cancelLargeSpawnScratch", + "handleSpawnAfterResolve", +] as const; + +const hiddenPackageSymbols = [ + "createWasmPosixKernelTestHarness", + "getWasmPosixKernelRuntimeAccess", + "createCentralizedKernelWorkerTestDouble", + "createKernelEntryGatedInstance", + "createKernelEntryScopedInstance", + "invokeKernelEntryScopedOperation", + "unwrapKernelEntryGatedInstance", + "unwrapKernelEntryGatedExport", + "kernelEntryInvokerForInstance", + "kernelEntryGateForInstance", + "KernelEntryGate", +] as const; + +describe("kernel authority boundary", () => { + it("keeps raw Wasm authority and white-box hooks off production objects", () => { + const kernel = new WasmPosixKernel({}, {}); + const worker = new CentralizedKernelWorker({}, {}); + + for (const name of hiddenKernelNames) { + expect(name in kernel, `WasmPosixKernel.${name}`).toBe(false); + expect(Object.getOwnPropertyDescriptor( + WasmPosixKernel.prototype, + name, + )).toBeUndefined(); + } + for (const name of hiddenWorkerNames) { + expect(name in worker, `CentralizedKernelWorker.${name}`).toBe(false); + expect(Object.getOwnPropertyDescriptor( + CentralizedKernelWorker.prototype, + name, + )).toBeUndefined(); + } + }); + + it("rejects reflected worker shadows and keeps kernel shadows inert", () => { + const fakeMemory = new WebAssembly.Memory({ initial: 1, maximum: 1 }); + const fakeInstance = { exports: { kernel_set_cwd: vi.fn(() => 0) } }; + const kernel = new WasmPosixKernel({}, {}); + const worker = new CentralizedKernelWorker({}, {}); + + expect(Reflect.set(kernel, "memory", fakeMemory)).toBe(true); + expect(Reflect.set(kernel, "instance", fakeInstance)).toBe(true); + expect(Reflect.set(worker, "initialized", true)).toBe(false); + expect(Reflect.set(worker, "kernelMemory", fakeMemory)).toBe(false); + expect(Reflect.set(worker, "kernelInstance", fakeInstance)).toBe(false); + expect(Reflect.set(worker, "largeSpawnScratchInUse", false)).toBe(false); + expect(Reflect.set(worker, "largeTransferScratchInUse", false)).toBe(false); + expect(Reflect.set(worker, "kernelFatalError", null)).toBe(false); + + expect(Reflect.get(kernel, "memory")).toBe(fakeMemory); + expect(Reflect.get(worker, "kernelInstance")).toBeUndefined(); + // WHY: the kernel still ignores ordinary public shadows. The worker is + // stricter because it crosses host observers while retaining entry-taking + // methods: sealing prevents those observers from installing own-property + // overrides ahead of the frozen reviewed prototype. + expect(kernel.getMemoryPageCount()).toBeNull(); + expect(() => worker.setCwd(1, "/")).toThrow("Kernel not initialized"); + expect(fakeInstance.exports.kernel_set_cwd).not.toHaveBeenCalled(); + }); + + it("rejects subclass prototypes that could override entry-taking methods", () => { + class SubclassedWorker extends CentralizedKernelWorker {} + + expect( + () => new SubclassedWorker({}, {}), + ).toThrow(/subclass|exact CentralizedKernelWorker/i); + }); + + it("limits kernel test authority to one frozen six-method companion", () => { + const production = new WasmPosixKernel({}, {}); + const harness = createWasmPosixKernelTestHarness({}); + const authority = harness.testAuthority; + const expectedNames = [ + "buildImportObject", + "hostClose", + "hostClosedir", + "hostFstat", + "hostReaddir", + "writeKernelBytes", + ]; + + expect("testAuthority" in production).toBe(false); + expect(harness).toBeInstanceOf(WasmPosixKernel); + expect(Object.getOwnPropertyDescriptor( + harness, + "testAuthority", + )).toEqual({ + configurable: false, + enumerable: false, + writable: false, + value: authority, + }); + expect(Object.getPrototypeOf(authority)).toBeNull(); + expect(Object.isFrozen(authority)).toBe(true); + expect(Reflect.ownKeys(authority).sort()).toEqual(expectedNames); + for (const name of expectedNames) { + expect(Object.getOwnPropertyDescriptor(authority, name)).toEqual({ + configurable: false, + enumerable: true, + writable: false, + value: expect.any(Function), + }); + } + for (const name of [ + "instance", + "memory", + "kernelEntryGate", + "scratchRegion", + "getInstance", + "getMemory", + ]) { + expect(name in authority).toBe(false); + } + expect(Reflect.set(authority, "hostClose", vi.fn())).toBe(false); + expect(Reflect.defineProperty(authority, "arbitraryDispatch", { + value: vi.fn(), + })).toBe(false); + expect(Reflect.setPrototypeOf(authority, {})).toBe(false); + expect(Reflect.get(authority, "arbitraryDispatch")).toBeUndefined(); + expect(harness.getMemoryPageCount()).toBeNull(); + }); + + it("limits worker test authority to one frozen exact-method companion", () => { + const production = new CentralizedKernelWorker({}, {}); + const harness = createCentralizedKernelWorkerTestDouble(); + const authority = harness.testAuthority; + const expectedNames = [ + "completeDetachedCopybackForTest", + "completeImmediatePollTimeoutForCopybackTest", + "completeSleepWithSignalCheckForTest", + "configureScratchBoundaryHooksForTest", + "dequeueSignalForDeliveryForTest", + "discardStoppedProcessStateForTest", + "dispatchRegisteredMainChannelForAdvisoryLockTest", + "dispatchScratchBoundarySyscallForTest", + "dispatchSpawnAfterResolveForTest", + "dispatchSpawnPreflightForTest", + "dispatchUntrackedExecForTaskAuthorityTest", + "dispatchUntrackedExecveatForTaskAuthorityTest", + "dispatchUntrackedForkForTaskAuthorityTest", + "dispatchUntrackedThreadExitForTaskAuthorityTest", + "drainWakeupEventsForTest", + "forkKernelProcessForAdvisoryLockTest", + "initializeKernelForTest", + "inspectThreadTransportStateForLifecycleTest", + "installParkedCloneCompletionForTest", + "probeMqueueNotificationCapacityForTest", + "probeWaitableChildCapacityForTest", + "replaceKernelForScratchBoundaryTest", + "replaceProcessRegistrationForLifecycleTest", + "replaceTcpScratchForScratchBoundaryTest", + "resumeStoppedProcessForTest", + "sendSignalForTest", + ]; + + expect("testAuthority" in production).toBe(false); + expect(harness).toBeInstanceOf(CentralizedKernelWorker); + expect(Object.getOwnPropertyDescriptor( + harness, + "testAuthority", + )).toEqual({ + configurable: false, + enumerable: false, + writable: false, + value: authority, + }); + expect(Object.getPrototypeOf(authority)).toBeNull(); + expect(Object.isFrozen(authority)).toBe(true); + expect(Reflect.ownKeys(authority).sort()).toEqual(expectedNames); + for (const name of expectedNames) { + expect(Object.getOwnPropertyDescriptor(authority, name)).toEqual({ + configurable: false, + enumerable: true, + writable: false, + value: expect.any(Function), + }); + } + for (const name of [ + "instance", + "memory", + "kernel", + "kernelEntryGate", + "scratchRegion", + "getInstance", + "getMemory", + "arbitraryDispatch", + ]) { + expect(name in authority).toBe(false); + } + expect( + Reflect.set(authority, "dispatchSpawnPreflightForTest", vi.fn()), + ).toBe(false); + expect(Reflect.defineProperty(authority, "arbitraryDispatch", { + value: vi.fn(), + })).toBe(false); + expect(Reflect.setPrototypeOf(authority, {})).toBe(false); + }); + + it("omits deep test and raw-entry capabilities from supported packages", () => { + for (const entry of [nodeEntry, browserEntry]) { + expect(entry.WasmPosixKernel).toBe(WasmPosixKernel); + expect(entry.CentralizedKernelWorker).toBe(CentralizedKernelWorker); + for (const symbol of hiddenPackageSymbols) { + expect(symbol in entry, symbol).toBe(false); + } + } + + const packageJson = JSON.parse(readFileSync( + new URL("../package.json", import.meta.url), + "utf8", + )) as { exports: Record }; + expect(packageJson.exports).not.toHaveProperty("./kernel"); + expect(packageJson.exports).not.toHaveProperty("./kernel-worker"); + expect(packageJson.exports).not.toHaveProperty("./kernel-entry-gate"); + }); +}); diff --git a/host/test/kernel-blocking-retry-snapshot.test.ts b/host/test/kernel-blocking-retry-snapshot.test.ts new file mode 100644 index 0000000000..19940bb2eb --- /dev/null +++ b/host/test/kernel-blocking-retry-snapshot.test.ts @@ -0,0 +1,5637 @@ +import { readFileSync } from "node:fs"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createCentralizedKernelWorkerTestDouble } from "../src/kernel-worker"; +import { + ABI_SYSCALLS, + CHANNEL_STATUS_COMPLETE, + CHANNEL_STATUS_PENDING, + CH_ARGS, + CH_ARG_SIZE, + CH_DATA, + CH_ERRNO, + CH_REQUEST_FLAGS, + CH_RETURN, + CH_SIG_FLAGS, + CH_SIG_SIGNUM, + CH_STATUS, + CH_SYSCALL, + CHANNEL_REQUEST_FLAG_CANCELLATION_POINT, + CHANNEL_REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED, + FCNTL_FLOCK_BYTES, + KERNEL_IOVEC_WIRE_BASE_OFFSET, + KERNEL_IOVEC_WIRE_LEN_OFFSET, + KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET, + KERNEL_MSGHDR_WIRE_FLAGS_OFFSET, + KERNEL_MSGHDR_WIRE_IOV_OFFSET, + KERNEL_MSGHDR_WIRE_IOVLEN_OFFSET, + KERNEL_MSGHDR_WIRE_NAMELEN_OFFSET, + PROCESS_MSGHDR_WASM32_CONTROLLEN_OFFSET, + PROCESS_MSGHDR_WASM32_CONTROL_OFFSET, + PROCESS_MSGHDR_WASM32_FLAGS_OFFSET, + PROCESS_MSGHDR_WASM32_IOVLEN_OFFSET, + PROCESS_MSGHDR_WASM32_IOV_OFFSET, + PROCESS_MSGHDR_WASM32_NAMELEN_OFFSET, + PROCESS_MSGHDR_WASM32_NAME_OFFSET, + PROCESS_MSGHDR_WASM32_SIZE, + PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET, + PROCESS_MSGHDR_WASM64_CONTROL_OFFSET, + PROCESS_MSGHDR_WASM64_FLAGS_OFFSET, + PROCESS_MSGHDR_WASM64_IOVLEN_OFFSET, + PROCESS_MSGHDR_WASM64_IOV_OFFSET, + PROCESS_MSGHDR_WASM64_NAMELEN_OFFSET, + PROCESS_MSGHDR_WASM64_NAME_OFFSET, + PROCESS_MSGHDR_WASM64_SIZE, + PROCESS_STATE_EXITED, + SELECT_FD_SET_BYTES, + SIGNAL_MASK_BYTES, + STRUCT_SIZE_WASM_POLL_FD, + STRUCT_SIZE_WASM_SYSV_MESSAGE_HEADER, + WASM_POLL_FD_EVENTS_OFFSET, + WASM_POLL_FD_FD_OFFSET, + WASM_POLL_FD_REVENTS_OFFSET, +} from "../src/generated/abi"; +import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; + +const EAGAIN = 11; +const EINTR = 4; +const EINPROGRESS = 115; +const ESRCH = 3; +const IPC_NOWAIT = 0x800; +const MSG_DONTWAIT = 0x40; +const SA_RESTART = 0x10000000; +const SIGUSR1 = 10; +const WIDTHS = [ + ["wasm32", 4], + ["wasm64", 8], +] as const; +const KERNEL_WORKER_SOURCE = readFileSync( + new URL("../src/kernel-worker.ts", import.meta.url), + "utf8", +); +const BLOCKED_RETRY_SOURCE = readFileSync( + new URL("../../crates/kernel/src/blocked_retry.rs", import.meta.url), + "utf8", +); +const CHANNEL_SYSCALL_SOURCE = readFileSync( + new URL("../../libc/glue/channel_syscall.c", import.meta.url), + "utf8", +); +const PTHREAD_CANCEL_SOURCE = readFileSync( + new URL( + "../../libc/musl-overlay/src/thread/wasm32posix/pthread_cancel.c", + import.meta.url, + ), + "utf8", +); +const RUST_TARGETED_RETRY_OPERATIONS = [ + "Accept", + "Connect", + "CopyFileRange", + "Fcntl", + "Flock", + "MqReceive", + "MqSend", + "MsgReceive", + "MsgSend", + "Pread", + "Pwrite", + "Read", + "Recv", + "Recvfrom", + "Recvmsg", + "Semop", + "Send", + "Sendfile", + "Sendmsg", + "Sendto", + "Splice", + "Write", +] as const; + +interface RetryHarness { + readonly worker: Record; + readonly channel: Record; + readonly channels: readonly Record[]; + readonly processMemory: WebAssembly.Memory; + readonly processBytes: Uint8Array; + readonly kernelBytes: Uint8Array; + readonly kernelExports: Record; + readonly scratchOffset: number; + readonly relistenChannel: ReturnType; + readonly onKernelFatal: ReturnType; +} + +interface NativeMessageLayout { + readonly size: number; + readonly nameOffset: number; + readonly nameLengthOffset: number; + readonly iovecOffset: number; + readonly iovecLengthOffset: number; + readonly controlOffset: number; + readonly controlLengthOffset: number; + readonly flagsOffset: number; +} + +function sharedMemory(pages = 8, maximumPages = pages): WebAssembly.Memory { + return new WebAssembly.Memory({ + initial: pages, + maximum: maximumPages, + shared: true, + }); +} + +function nativeMessageLayout(pointerWidth: 4 | 8): NativeMessageLayout { + return pointerWidth === 8 + ? { + size: PROCESS_MSGHDR_WASM64_SIZE, + nameOffset: PROCESS_MSGHDR_WASM64_NAME_OFFSET, + nameLengthOffset: PROCESS_MSGHDR_WASM64_NAMELEN_OFFSET, + iovecOffset: PROCESS_MSGHDR_WASM64_IOV_OFFSET, + iovecLengthOffset: PROCESS_MSGHDR_WASM64_IOVLEN_OFFSET, + controlOffset: PROCESS_MSGHDR_WASM64_CONTROL_OFFSET, + controlLengthOffset: PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET, + flagsOffset: PROCESS_MSGHDR_WASM64_FLAGS_OFFSET, + } + : { + size: PROCESS_MSGHDR_WASM32_SIZE, + nameOffset: PROCESS_MSGHDR_WASM32_NAME_OFFSET, + nameLengthOffset: PROCESS_MSGHDR_WASM32_NAMELEN_OFFSET, + iovecOffset: PROCESS_MSGHDR_WASM32_IOV_OFFSET, + iovecLengthOffset: PROCESS_MSGHDR_WASM32_IOVLEN_OFFSET, + controlOffset: PROCESS_MSGHDR_WASM32_CONTROL_OFFSET, + controlLengthOffset: PROCESS_MSGHDR_WASM32_CONTROLLEN_OFFSET, + flagsOffset: PROCESS_MSGHDR_WASM32_FLAGS_OFFSET, + }; +} + +function createRetryHarness( + pointerWidth: 4 | 8, + options: { + readonly channelOffsets?: readonly number[]; + readonly maximumProcessPages?: number; + } = {}, +): RetryHarness { + const pid = 41; + const scratchOffset = 4096; + const channelOffsets = options.channelOffsets ?? [6 * 65_536]; + const kernelMemory = new WebAssembly.Memory({ + initial: 4, + maximum: 4, + }); + const processMemory = sharedMemory( + 8, + options.maximumProcessPages ?? 8, + ); + const kernelBytes = new Uint8Array(kernelMemory.buffer); + const processBytes = new Uint8Array(processMemory.buffer); + const hostOnlyRetrySyscalls = new Set([ + ABI_SYSCALLS.Open, + ABI_SYSCALLS.Openat, + ABI_SYSCALLS.Poll, + ABI_SYSCALLS.Ppoll, + ABI_SYSCALLS.RtSigtimedwait, + ABI_SYSCALLS.Select, + ABI_SYSCALLS.Pselect6, + ]); + const kernelExports: Record = { + kernel_blocking_retry_release: vi.fn(() => 0), + kernel_blocking_retry_token: vi.fn( + (_pid: number, _tid: number, syscall: number) => + hostOnlyRetrySyscalls.has(syscall) ? 0n : 1n, + ), + kernel_dequeue_signal: () => 0, + kernel_get_fd_accept_wake_idx: vi.fn(() => -1), + kernel_get_fd_pipe_idx: vi.fn(() => -1), + kernel_get_process_exit_signal: () => -1, + kernel_get_process_state: () => 0, + kernel_get_socket_timeout_ms: vi.fn(() => 0n), + kernel_handle_channel: () => 0, + kernel_is_fd_nonblock: () => 0, + kernel_mq_descriptor_msgsize: () => 4, + kernel_pick_signal_target_tid: vi.fn(() => pid), + kernel_set_current_tid: () => 0, + kernel_thread_has_deliverable: vi.fn(() => 1), + }; + const onKernelFatal = vi.fn(); + const worker = createCentralizedKernelWorkerTestDouble({ + callbacks: { onKernelFatal }, + }) as unknown as Record; + installKernelWorkerTestScratch( + worker, + kernelMemory, + scratchOffset, + pointerWidth, + { + kernelExports, + kernelExportNames: Object.keys(kernelExports), + }, + ); + const relistenChannel = vi.fn(); + worker.testAuthority.configureScratchBoundaryHooksForTest({ + handleSharedMappingsAfterFileSyscall: vi.fn(), + relistenChannel, + synchronizeSharedMemoryForBoundary: vi.fn(), + }); + const channels = + worker.testAuthority.replaceProcessRegistrationForLifecycleTest({ + pid, + memory: processMemory, + channelOffsets, + pointerWidth, + }); + const channel = channels[0]!; + + return { + worker, + channel, + channels, + processMemory, + processBytes, + kernelBytes, + kernelExports, + scratchOffset, + relistenChannel, + onKernelFatal, + }; +} + +function writeRequest( + harness: RetryHarness, + syscall: number, + args: readonly bigint[], + channel = harness.channel, + cancellationPoint = false, + cancellationWakeAllowed = cancellationPoint, +): void { + const view = new DataView( + harness.processMemory.buffer, + channel.channelOffset, + ); + view.setUint32(CH_STATUS, CHANNEL_STATUS_PENDING, true); + view.setUint32(CH_SYSCALL, syscall, true); + view.setUint32( + CH_REQUEST_FLAGS, + (cancellationPoint + ? CHANNEL_REQUEST_FLAG_CANCELLATION_POINT + : 0) + | (cancellationWakeAllowed + ? CHANNEL_REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED + : 0), + true, + ); + view.setBigInt64(CH_RETURN, 0n, true); + view.setUint32(CH_ERRNO, 0, true); + for (let index = 0; index < 6; index++) { + view.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, args[index] ?? 0n, true); + } +} + +function requestStatus( + harness: RetryHarness, + channel = harness.channel, +): number { + return new DataView( + harness.processMemory.buffer, + channel.channelOffset, + ).getUint32(CH_STATUS, true); +} + +function requestResult( + harness: RetryHarness, + channel = harness.channel, +): { + readonly status: number; + readonly returnValue: number; + readonly errno: number; +} { + const view = new DataView( + harness.processMemory.buffer, + channel.channelOffset, + ); + return { + status: view.getUint32(CH_STATUS, true), + returnValue: Number(view.getBigInt64(CH_RETURN, true)), + errno: view.getUint32(CH_ERRNO, true), + }; +} + +function kernelView( + harness: RetryHarness, + rawPointer: number | bigint, +): DataView { + return new DataView(harness.kernelBytes.buffer, Number(rawPointer)); +} + +function kernelArg(view: DataView, index: number): bigint { + return view.getBigInt64(CH_ARGS + index * CH_ARG_SIZE, true); +} + +function publishKernelResult( + view: DataView, + returnValue: number, + errno: number, +): void { + view.setBigInt64(CH_RETURN, BigInt(returnValue), true); + view.setUint32(CH_ERRNO, errno, true); +} + +function writeKernelCaughtSignal( + harness: RetryHarness, + rawPointer: number | bigint, + flags = SA_RESTART, +): number { + const pointer = Number(rawPointer); + const view = new DataView(harness.kernelBytes.buffer); + view.setUint32(pointer, SIGUSR1, true); + view.setUint32(pointer + 4, 0x1234, true); + view.setUint32(pointer + 8, flags, true); + return SIGUSR1; +} + +function writeNativeIovec( + bytes: Uint8Array, + pointerWidth: 4 | 8, + tablePointer: number, + index: number, + base: number, + length: number, +): void { + const view = new DataView(bytes.buffer); + const entry = tablePointer + index * 2 * pointerWidth; + if (pointerWidth === 8) { + view.setBigUint64(entry, BigInt(base), true); + view.setBigUint64(entry + 8, BigInt(length), true); + } else { + view.setUint32(entry, base, true); + view.setUint32(entry + 4, length, true); + } +} + +function writeNativeMessage( + bytes: Uint8Array, + pointerWidth: 4 | 8, + messagePointer: number, + iovecPointer: number, + iovecCount: number, +): void { + const layout = nativeMessageLayout(pointerWidth); + bytes.fill(0, messagePointer, messagePointer + layout.size); + const view = new DataView(bytes.buffer); + if (pointerWidth === 8) { + view.setBigUint64(messagePointer + layout.nameOffset, 0n, true); + view.setBigUint64( + messagePointer + layout.iovecOffset, + BigInt(iovecPointer), + true, + ); + view.setBigUint64(messagePointer + layout.controlOffset, 0n, true); + } else { + view.setUint32(messagePointer + layout.nameOffset, 0, true); + view.setUint32(messagePointer + layout.iovecOffset, iovecPointer, true); + view.setUint32(messagePointer + layout.controlOffset, 0, true); + } + view.setUint32(messagePointer + layout.nameLengthOffset, 0, true); + view.setUint32(messagePointer + layout.iovecLengthOffset, iovecCount, true); + view.setUint32(messagePointer + layout.controlLengthOffset, 0, true); + view.setUint32(messagePointer + layout.flagsOffset, 0, true); +} + +function writeNativeSysvMessage( + bytes: Uint8Array, + pointerWidth: 4 | 8, + pointer: number, + type: bigint, + payload: readonly number[], +): void { + const view = new DataView(bytes.buffer); + if (pointerWidth === 8) { + view.setBigInt64(pointer, type, true); + } else { + view.setInt32(pointer, Number(type), true); + } + bytes.set(payload, pointer + pointerWidth); +} + +function writeNativeSemop( + bytes: Uint8Array, + pointer: number, + index: number, + number: number, + operation: number, + flags: number, +): void { + const view = new DataView(bytes.buffer); + const offset = pointer + index * 6; + view.setUint16(offset, number, true); + view.setInt16(offset + 2, operation, true); + view.setUint16(offset + 4, flags, true); +} + +function writeNativeOffset( + bytes: Uint8Array, + pointer: number, + value: bigint, +): void { + new DataView(bytes.buffer).setBigInt64(pointer, value, true); +} + +function readNativeOffset(bytes: Uint8Array, pointer: number): bigint { + return new DataView(bytes.buffer).getBigInt64(pointer, true); +} + +function readNativeSysvMessage( + bytes: Uint8Array, + pointerWidth: 4 | 8, + pointer: number, + payloadLength: number, +): { readonly type: bigint; readonly payload: number[] } { + const view = new DataView(bytes.buffer); + const type = + pointerWidth === 8 + ? view.getBigInt64(pointer, true) + : BigInt(view.getInt32(pointer, true)); + return { + type, + payload: Array.from( + bytes.slice( + pointer + pointerWidth, + pointer + pointerWidth + payloadLength, + ), + ), + }; +} + +async function retryAfterDefaultDelay( + harness: RetryHarness, + channel = harness.channel, +): Promise { + expect(harness.worker.pendingPollRetries.has(channel)).toBe(true); + await vi.advanceTimersByTimeAsync(10); + await Promise.resolve(); +} + +function expectExactRetryBindingLifecycle( + harness: RetryHarness, + syscall: number, + token = 1n, + channel = harness.channel, +): void { + const tokenForRetry = harness.kernelExports + .kernel_blocking_retry_token as ReturnType; + const release = harness.kernelExports + .kernel_blocking_retry_release as ReturnType; + const handleChannel = harness.kernelExports + .kernel_handle_channel as ReturnType; + const tid = + harness.worker.channelTids.get( + `${channel.pid}:${channel.channelOffset}`, + ) ?? channel.pid; + + expect(tokenForRetry).toHaveBeenCalledOnce(); + expect(tokenForRetry).toHaveBeenCalledWith(channel.pid, tid, syscall); + expect(handleChannel.mock.calls.map((call) => call[3])).toEqual([ + 0n, + token, + ]); + expect(release).toHaveBeenCalledOnce(); + expect(release).toHaveBeenCalledWith(channel.pid, tid, token); +} + +function expectHostOnlyRetryLifecycle( + harness: RetryHarness, + syscall: number, + channel = harness.channel, +): void { + const tokenForRetry = harness.kernelExports + .kernel_blocking_retry_token as ReturnType; + const release = harness.kernelExports + .kernel_blocking_retry_release as ReturnType; + const handleChannel = harness.kernelExports + .kernel_handle_channel as ReturnType; + + const tid = + harness.worker.channelTids.get( + `${channel.pid}:${channel.channelOffset}`, + ) ?? channel.pid; + expect(tokenForRetry).toHaveBeenCalledOnce(); + expect(tokenForRetry).toHaveBeenCalledWith(channel.pid, tid, syscall); + expect(handleChannel.mock.calls.map((call) => call[3])).toEqual([0n, 0n]); + expect(release).not.toHaveBeenCalled(); +} + +describe("blocking retry snapshot contract", () => { + it("publishes call-site cancellation identity before PENDING and consumes it once", () => { + const requestPublish = CHANNEL_SYSCALL_SOURCE.match( + /restart_wait_syscall:[\s\S]*?__c11_atomic_store\([\s\S]*?CH_PENDING,[\s\S]*?\);/, + )?.[0]; + expect(requestPublish, "libc request publication").toBeDefined(); + const flagWrite = requestPublish!.indexOf( + "*(uint32_t *)(uintptr_t)(base + CH_REQUEST_FLAGS) = request_flags", + ); + const wakeAuthority = requestPublish!.indexOf( + "__syscall_cp_cancel_wake_allowed()", + ); + const pendingWrite = requestPublish!.indexOf("CH_PENDING"); + expect(wakeAuthority).toBeGreaterThanOrEqual(0); + expect(flagWrite).toBeGreaterThanOrEqual(0); + expect(flagWrite).toBeGreaterThan(wakeAuthority); + expect(pendingWrite).toBeGreaterThan(flagWrite); + expect(CHANNEL_SYSCALL_SOURCE).toContain( + "return __do_syscall_impl(n, a1, a2, a3, a4, a5, a6, 0);", + ); + expect(CHANNEL_SYSCALL_SOURCE).toContain( + "long r = __do_syscall_impl(n, a1, a2, a3, a4, a5, a6, 1);", + ); + expect(CHANNEL_SYSCALL_SOURCE).toContain( + "request_flags |= CH_REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED", + ); + expect(CHANNEL_SYSCALL_SOURCE).not.toContain( + "__syscall_cp_cancel_pending_disabled", + ); + expect(CHANNEL_SYSCALL_SOURCE).not.toMatch( + /n == SYS_OPEN\s*\|\|\s*n == SYS_OPENAT/, + ); + expect(PTHREAD_CANCEL_SOURCE).toContain( + "hidden int __syscall_cp_cancel_wake_allowed(void)", + ); + expect(PTHREAD_CANCEL_SOURCE).toContain( + "self->canceldisable != PTHREAD_CANCEL_DISABLE", + ); + + const hostCaptureStart = KERNEL_WORKER_SOURCE.indexOf( + " #captureChannelRequest(\n channel: ChannelInfo,", + ); + const hostCaptureEnd = KERNEL_WORKER_SOURCE.indexOf( + "\n /** Fail closed", + hostCaptureStart, + ); + expect(hostCaptureStart, "host request-identity capture start") + .toBeGreaterThanOrEqual(0); + expect(hostCaptureEnd, "host request-identity capture end") + .toBeGreaterThan(hostCaptureStart); + const hostCapture = KERNEL_WORKER_SOURCE.slice( + hostCaptureStart, + hostCaptureEnd, + ); + const read = hostCapture.indexOf( + "processView.getUint32(CH_REQUEST_FLAGS, true)", + ); + const clear = hostCapture.indexOf( + "processView.setUint32(CH_REQUEST_FLAGS, 0, true)", + ); + const freeze = hostCapture.indexOf( + "const request = kernelEntryIntrinsicObjectFreeze", + ); + expect(read).toBeGreaterThanOrEqual(0); + expect(clear).toBeGreaterThan(read); + expect(freeze).toBeGreaterThan(clear); + expect(hostCapture).toMatch( + /requestFlags\s*& CHANNEL_REQUEST_FLAG_CANCELLATION_POINT/, + ); + expect(hostCapture).toMatch( + /requestFlags\s*& CHANNEL_REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED/, + ); + expect(hostCapture).toContain( + "requestFlags & ~CHANNEL_REQUEST_FLAGS_KNOWN_MASK", + ); + expect(hostCapture).toContain( + "cancellationWakeAllowed && !cancellationPoint", + ); + }); + + it("keeps Rust retry-operation families on the reviewed host snapshot allowlist", () => { + const operationEnum = BLOCKED_RETRY_SOURCE.match( + /enum BlockingRetryOperation\s*\{([\s\S]*?)\n\}/, + )?.[1]; + expect(operationEnum, "BlockingRetryOperation enum").toBeDefined(); + const operations = Array.from( + operationEnum!.matchAll(/^\s{4}([A-Z][A-Za-z0-9]*),\s*$/gm), + (match) => match[1], + ).sort(); + expect(operations).toEqual(RUST_TARGETED_RETRY_OPERATIONS); + + const genericSet = KERNEL_WORKER_SOURCE.match( + /const GENERIC_BLOCKING_SNAPSHOT_SYSCALLS = new Set\(\[([\s\S]*?)\]\);/, + )?.[1]; + expect(genericSet, "generic blocking snapshot set").toBeDefined(); + for (const syscall of [ + "Read", + "Write", + "Pread", + "Pwrite", + "Recv", + "Send", + "Recvfrom", + "Sendto", + "MqTimedsend", + "MqTimedreceive", + "Semop", + "Sendfile", + "CopyFileRange", + "Splice", + ]) { + expect(genericSet).toContain(`ABI_SYSCALLS.${syscall}`); + } + // These families need nested-layout-specific plans and are deliberately + // reviewed outside the generic channel abstraction. + for (const kind of [ + "flattened-transfer", + "sendmsg", + "recvmsg", + "sysv-message", + ]) { + expect(KERNEL_WORKER_SOURCE).toContain(`kind: "${kind}"`); + } + }); + + it("keeps token zero exclusive to seven reviewed host-only snapshot families", () => { + const hostOnlyClassifier = BLOCKED_RETRY_SOURCE.match( + /fn is_explicit_host_only_snapshot_syscall\(syscall: u32\) -> bool \{([\s\S]*?)\n\}/, + )?.[1]; + expect( + hostOnlyClassifier, + "explicit host-only snapshot classifier", + ).toBeDefined(); + const hostOnlyFamilies = Array.from( + hostOnlyClassifier!.matchAll( + /syscall == ((?:Syscall::[A-Za-z0-9_]+ as u32)|(?:extended_syscalls::SYS_[A-Z0-9_]+))/g, + ), + (match) => match[1], + ).sort(); + expect(hostOnlyFamilies).toEqual([ + "Syscall::Open as u32", + "Syscall::Openat as u32", + "Syscall::Poll as u32", + "Syscall::Select as u32", + "extended_syscalls::SYS_PPOLL", + "extended_syscalls::SYS_PSELECT6", + "extended_syscalls::SYS_RT_SIGTIMEDWAIT", + ]); + + const fromSyscall = BLOCKED_RETRY_SOURCE.match( + /pub\(crate\) fn from_syscall\(syscall: u32\) -> Result \{([\s\S]*?)\n \}/, + )?.[1]; + expect(fromSyscall, "BlockingRetryOperation::from_syscall").toBeDefined(); + const targetedFamilies = Array.from( + fromSyscall!.matchAll(/Ok\(Self::([A-Z][A-Za-z0-9]*)\)/g), + (match) => match[1], + ).sort(); + expect(Array.from(new Set(targetedFamilies))).toEqual( + RUST_TARGETED_RETRY_OPERATIONS, + ); + expect(fromSyscall).toContain("_ => Err(Errno::EINVAL)"); + }); + + it("keeps defensive scalar guards beyond the genuine Wasm value boundary", () => { + // Genuine i32/i64 exports coerce or reject fractional, unsafe, and + // non-BigInt JavaScript fixture values before these helpers run. Dynamic + // tests below cover representable values and export traps; this narrow + // source contract prevents removal of the remaining defense-in-depth. + const signalTargetValidator = KERNEL_WORKER_SOURCE.match( + /#validateKernelSignalTargetTid\(targetTid: number\): number \{([\s\S]*?)\n \}/, + )?.[1]; + expect(signalTargetValidator, "signal target validator").toBeDefined(); + expect(signalTargetValidator).toContain( + "!Number.isSafeInteger(targetTid)", + ); + expect(signalTargetValidator).toContain("targetTid < 0"); + + const timeoutValidator = KERNEL_WORKER_SOURCE.match( + /const rawTimeout = getTimeout\([\s\S]*?return \{\n \.\.\.cancellationIdentity,\n retryForbiddenByCallFlags:/, + )?.[0]; + expect(timeoutValidator, "socket-timeout validator").toBeDefined(); + expect(timeoutValidator).toContain( + 'typeof rawTimeout !== "bigint"', + ); + expect(timeoutValidator).toContain("rawTimeout < -1n"); + expect(timeoutValidator).toContain( + "rawTimeout > BigInt(Number.MAX_SAFE_INTEGER)", + ); + }); + + it("keeps caught-handler restart policy on the reviewed syscall allowlist", () => { + const classifier = CHANNEL_SYSCALL_SOURCE.match( + /static int kandelo_should_restart_after_handler\([\s\S]*?\n\}\n\n\/\* The kernel ABI/, + )?.[0]; + expect( + classifier, + "kandelo_should_restart_after_handler classifier", + ).toBeDefined(); + + const restartCases = Array.from( + classifier!.matchAll(/case __NR_([a-z0-9_]+):/g), + (match) => match[1], + ).sort(); + expect(restartCases).toEqual([ + "accept", + "accept4", + "connect", + "fcntl", + "flock", + "futex", + "mq_timedreceive", + "mq_timedsend", + "open", + "openat", + "pread", + "preadv", + "preadv2", + "pwrite", + "pwritev", + "pwritev2", + "read", + "readv", + "recv", + "recvfrom", + "recvmsg", + "send", + "sendmsg", + "sendto", + "wait4", + "waitid", + "write", + "writev", + ]); + + // WHY: these operations expose EINTR after a caught handler even when the + // action has SA_RESTART. An accidental broad case would reset a deadline, + // hide an interrupted readiness wait, or repeat an operation whose partial + // progress cannot be reconstructed by the host. + for (const syscall of [ + "poll", + "ppoll", + "select", + "pselect6", + "epoll_wait", + "epoll_pwait", + "rt_sigtimedwait", + "sigsuspend", + "pause", + "nanosleep", + "clock_nanosleep", + "msgrcv", + "msgsnd", + "semop", + "sendfile", + "copy_file_range", + "splice", + ]) { + expect(classifier).not.toContain(`case __NR_${syscall}:`); + } + + expect(classifier).toContain("a2 == F_SETLKW"); + expect(classifier).toContain("a2 == F_OFD_SETLKW"); + expect(classifier).toContain("(a2 & LOCK_NB) == 0"); + expect(classifier).toContain("a4 == 0"); + expect(CHANNEL_SYSCALL_SOURCE).toContain( + "#define KANDELO_FUTEX_WAIT 0", + ); + expect(CHANNEL_SYSCALL_SOURCE).toContain( + "#define KANDELO_FUTEX_WAIT_BITSET 9", + ); + expect(CHANNEL_SYSCALL_SOURCE).toContain( + "#define KANDELO_FUTEX_CMD_MASK 0x7f", + ); + expect(classifier).toContain( + "command == KANDELO_FUTEX_WAIT", + ); + expect(classifier).toContain( + "command == KANDELO_FUTEX_WAIT_BITSET", + ); + }); +}); + +afterEach(() => { + vi.clearAllTimers(); + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe("blocking retry request snapshots", () => { + it.each(WIDTHS)( + "%s retains a scalar write's fd, source range, length, and payload", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const originalSource = 0x1000; + const replacementSource = 0x2000; + const originalPayload = [0x11, 0x22, 0x33, 0x44]; + const replacementPayload = [0xaa, 0xbb, 0xcc, 0xdd]; + harness.processBytes.set(originalPayload, originalSource); + harness.processBytes.set(replacementPayload, replacementSource); + writeRequest(harness, ABI_SYSCALLS.Write, [ + 7n, + BigInt(originalSource), + BigInt(originalPayload.length), + ]); + + const attempts: Array<{ + fd: number; + length: number; + payload: number[]; + }> = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + const length = Number(kernelArg(view, 2)); + const dataPointer = Number(kernelArg(view, 1)); + attempts.push({ + fd: Number(kernelArg(view, 0)), + length, + payload: Array.from( + harness.kernelBytes.slice(dataPointer, dataPointer + length), + ), + }); + if (attempts.length === 1) { + publishKernelResult(view, -1, EAGAIN); + } else { + publishKernelResult(view, length, 0); + } + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + expect(requestStatus(harness)).toBe(CHANNEL_STATUS_PENDING); + + writeRequest(harness, ABI_SYSCALLS.Write, [ + 88n, + BigInt(replacementSource), + BigInt(replacementPayload.length), + ]); + harness.processBytes.fill( + 0xee, + originalSource, + originalSource + originalPayload.length, + ); + await retryAfterDefaultDelay(harness); + + expect(attempts).toEqual([ + { + fd: 7, + length: originalPayload.length, + payload: originalPayload, + }, + { + fd: 7, + length: originalPayload.length, + payload: originalPayload, + }, + ]); + expect(requestResult(harness)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: originalPayload.length, + errno: 0, + }); + expectExactRetryBindingLifecycle(harness, ABI_SYSCALLS.Write); + }, + ); + + it.each(WIDTHS)( + "%s retains a scalar read's fd and original destination", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const originalDestination = 0x1000; + const replacementDestination = 0x2000; + const payload = [0x41, 0x42, 0x43]; + harness.processBytes.fill( + 0x10, + originalDestination, + originalDestination + payload.length, + ); + harness.processBytes.fill( + 0x20, + replacementDestination, + replacementDestination + payload.length, + ); + writeRequest(harness, ABI_SYSCALLS.Read, [ + 7n, + BigInt(originalDestination), + BigInt(payload.length), + ]); + + const attempts: Array<{ fd: number; length: number }> = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + const length = Number(kernelArg(view, 2)); + attempts.push({ + fd: Number(kernelArg(view, 0)), + length, + }); + if (attempts.length === 1) { + publishKernelResult(view, -1, EAGAIN); + } else { + const dataPointer = Number(kernelArg(view, 1)); + harness.kernelBytes.set(payload, dataPointer); + publishKernelResult(view, payload.length, 0); + } + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + writeRequest(harness, ABI_SYSCALLS.Read, [ + 88n, + BigInt(replacementDestination), + BigInt(payload.length), + ]); + await retryAfterDefaultDelay(harness); + + expect(attempts).toEqual([ + { fd: 7, length: payload.length }, + { fd: 7, length: payload.length }, + ]); + expect( + Array.from( + harness.processBytes.slice( + originalDestination, + originalDestination + payload.length, + ), + ), + ).toEqual(payload); + expect( + Array.from( + harness.processBytes.slice( + replacementDestination, + replacementDestination + payload.length, + ), + ), + ).toEqual([0x20, 0x20, 0x20]); + expectExactRetryBindingLifecycle(harness, ABI_SYSCALLS.Read); + }, + ); + + it.each(WIDTHS)( + "%s returns MSG_DONTWAIT EAGAIN without descriptor-policy queries", + (_name, pointerWidth) => { + const harness = createRetryHarness(pointerWidth); + const destination = 0x1000; + writeRequest(harness, ABI_SYSCALLS.Recv, [ + 7n, + BigInt(destination), + 1n, + BigInt(MSG_DONTWAIT), + ]); + const isFdNonblock = vi.fn(() => { + throw new Error("MSG_DONTWAIT must short-circuit O_NONBLOCK"); + }); + const getTimeout = vi.fn(() => { + throw new Error("MSG_DONTWAIT must short-circuit socket timeout"); + }); + harness.kernelExports.kernel_is_fd_nonblock = isFdNonblock; + harness.kernelExports.kernel_get_socket_timeout_ms = getTimeout; + harness.kernelExports.kernel_blocking_retry_token = vi.fn(() => 51n); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + publishKernelResult( + kernelView(harness, rawPointer), + -1, + EAGAIN, + ); + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + + expect(requestResult(harness)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: -1, + errno: EAGAIN, + }); + expect(isFdNonblock).not.toHaveBeenCalled(); + expect(getTimeout).not.toHaveBeenCalled(); + expect( + harness.kernelExports.kernel_blocking_retry_token, + ).toHaveBeenCalledWith( + harness.channel.pid, + harness.channel.pid, + ABI_SYSCALLS.Recv, + ); + expect( + harness.kernelExports.kernel_blocking_retry_release, + ).toHaveBeenCalledWith( + harness.channel.pid, + harness.channel.pid, + 51n, + ); + expect(harness.worker.pendingPollRetries.has(harness.channel)).toBe( + false, + ); + }, + ); + + it.each(WIDTHS)( + "%s returns O_NONBLOCK EAGAIN without a socket-timeout query", + (_name, pointerWidth) => { + const harness = createRetryHarness(pointerWidth); + const destination = 0x1000; + writeRequest(harness, ABI_SYSCALLS.Read, [ + 7n, + BigInt(destination), + 1n, + ]); + const isFdNonblock = vi.fn(() => 1); + const getTimeout = vi.fn(() => { + throw new Error("O_NONBLOCK must short-circuit socket timeout"); + }); + harness.kernelExports.kernel_is_fd_nonblock = isFdNonblock; + harness.kernelExports.kernel_get_socket_timeout_ms = getTimeout; + harness.kernelExports.kernel_blocking_retry_token = vi.fn(() => 52n); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + publishKernelResult( + kernelView(harness, rawPointer), + -1, + EAGAIN, + ); + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + + expect(requestResult(harness)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: -1, + errno: EAGAIN, + }); + expect(isFdNonblock).toHaveBeenCalledOnce(); + expect(isFdNonblock).toHaveBeenCalledWith(harness.channel.pid, 7); + expect(getTimeout).not.toHaveBeenCalled(); + expect( + harness.kernelExports.kernel_blocking_retry_token, + ).toHaveBeenCalledWith( + harness.channel.pid, + harness.channel.pid, + ABI_SYSCALLS.Read, + ); + expect( + harness.kernelExports.kernel_blocking_retry_release, + ).toHaveBeenCalledWith( + harness.channel.pid, + harness.channel.pid, + 52n, + ); + expect(harness.worker.pendingPollRetries.has(harness.channel)).toBe( + false, + ); + }, + ); + + it.each([ + ["wasm32 sendmsg", 4, ABI_SYSCALLS.Sendmsg], + ["wasm64 sendmsg", 8, ABI_SYSCALLS.Sendmsg], + ["wasm32 recvmsg", 4, ABI_SYSCALLS.Recvmsg], + ["wasm64 recvmsg", 8, ABI_SYSCALLS.Recvmsg], + ] as const)( + "%s success does not query retry-only descriptor policy", + (_name, pointerWidth, syscall) => { + const harness = createRetryHarness(pointerWidth); + const messagePointer = 0x1000; + writeNativeMessage( + harness.processBytes, + pointerWidth, + messagePointer, + 0, + 0, + ); + writeRequest(harness, syscall, [7n, BigInt(messagePointer), 0n]); + const isFdNonblock = vi.fn(() => { + throw new Error("success must not query O_NONBLOCK"); + }); + const getTimeout = vi.fn(() => { + throw new Error("success must not query socket timeout"); + }); + harness.kernelExports.kernel_is_fd_nonblock = isFdNonblock; + harness.kernelExports.kernel_get_socket_timeout_ms = getTimeout; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + publishKernelResult(kernelView(harness, rawPointer), 0, 0); + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + + expect(requestResult(harness)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: 0, + errno: 0, + }); + expect(isFdNonblock).not.toHaveBeenCalled(); + expect(getTimeout).not.toHaveBeenCalled(); + expect( + harness.kernelExports.kernel_blocking_retry_token, + ).not.toHaveBeenCalled(); + expect( + harness.kernelExports.kernel_blocking_retry_release, + ).not.toHaveBeenCalled(); + }, + ); + + it.each([ + [ + "missing descriptor nonblocking export", + (harness: RetryHarness) => { + harness.kernelExports.kernel_is_fd_nonblock = undefined; + }, + "kernel export kernel_is_fd_nonblock failed", + ], + [ + "invalid descriptor nonblocking state", + (harness: RetryHarness) => { + harness.kernelExports.kernel_is_fd_nonblock = vi.fn(() => 2); + }, + "kernel returned invalid nonblocking state 2 for fd 7", + ], + [ + "missing socket-timeout export", + (harness: RetryHarness) => { + harness.kernelExports.kernel_is_fd_nonblock = vi.fn(() => 0); + harness.kernelExports.kernel_get_socket_timeout_ms = undefined; + }, + "kernel export kernel_get_socket_timeout_ms failed", + ], + [ + "invalid socket-timeout state", + (harness: RetryHarness) => { + harness.kernelExports.kernel_is_fd_nonblock = vi.fn(() => 0); + harness.kernelExports.kernel_get_socket_timeout_ms = vi.fn( + () => -2n, + ); + }, + "kernel returned invalid socket timeout -2", + ], + [ + "non-coercible socket-timeout state", + (harness: RetryHarness) => { + harness.kernelExports.kernel_is_fd_nonblock = vi.fn(() => 0); + harness.kernelExports.kernel_get_socket_timeout_ms = vi.fn( + () => Symbol("not-an-i64"), + ); + }, + "kernel export kernel_get_socket_timeout_ms failed", + ], + [ + "unsafe-integer socket-timeout state", + (harness: RetryHarness) => { + harness.kernelExports.kernel_is_fd_nonblock = vi.fn(() => 0); + harness.kernelExports.kernel_get_socket_timeout_ms = vi.fn( + () => BigInt(Number.MAX_SAFE_INTEGER) + 1n, + ); + }, + "kernel returned invalid socket timeout 9007199254740992", + ], + ] as const)( + "fails the kernel generation for %s", + async (_description, configure, expectedMessage) => { + const harness = createRetryHarness(4); + const destination = 0x1000; + writeRequest(harness, ABI_SYSCALLS.Read, [ + 7n, + BigInt(destination), + 1n, + ]); + configure(harness); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + publishKernelResult( + kernelView(harness, rawPointer), + -1, + EAGAIN, + ); + return 0; + }, + ); + + expect(() => harness.worker.handleSyscall(harness.channel)).toThrow( + expectedMessage, + ); + await Promise.resolve(); + + expect(harness.onKernelFatal).toHaveBeenCalledOnce(); + expect(requestStatus(harness)).toBe(CHANNEL_STATUS_PENDING); + expect( + harness.kernelExports.kernel_blocking_retry_token, + ).not.toHaveBeenCalled(); + expect( + harness.kernelExports.kernel_blocking_retry_release, + ).not.toHaveBeenCalled(); + expect( + harness.worker.blockingRetrySnapshots.has(harness.channel), + ).toBe(false); + }, + ); + + it.each(WIDTHS)( + "%s retains a writev request after its mailbox, iovec table, and data change", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const originalTable = 0x1000; + const replacementTable = 0x1400; + const originalFirst = 0x2000; + const originalSecond = 0x2100; + const replacementData = 0x2200; + writeNativeIovec( + harness.processBytes, + pointerWidth, + originalTable, + 0, + originalFirst, + 2, + ); + writeNativeIovec( + harness.processBytes, + pointerWidth, + originalTable, + 1, + originalSecond, + 3, + ); + writeNativeIovec( + harness.processBytes, + pointerWidth, + replacementTable, + 0, + replacementData, + 4, + ); + harness.processBytes.set([1, 2], originalFirst); + harness.processBytes.set([3, 4, 5], originalSecond); + harness.processBytes.set([9, 9, 9, 9], replacementData); + writeRequest(harness, ABI_SYSCALLS.Writev, [ + 7n, + BigInt(originalTable), + 2n, + ]); + + const attempts: Array<{ + syscall: number; + fd: number; + payload: number[]; + }> = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + const length = Number(kernelArg(view, 2)); + const dataPointer = Number(kernelArg(view, 1)); + attempts.push({ + syscall: view.getUint32(CH_SYSCALL, true), + fd: Number(kernelArg(view, 0)), + payload: Array.from( + harness.kernelBytes.slice(dataPointer, dataPointer + length), + ), + }); + if (attempts.length === 1) { + publishKernelResult(view, -1, EAGAIN); + } else { + publishKernelResult(view, length, 0); + } + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + writeRequest(harness, ABI_SYSCALLS.Writev, [ + 88n, + BigInt(replacementTable), + 1n, + ]); + writeNativeIovec( + harness.processBytes, + pointerWidth, + originalTable, + 0, + replacementData, + 4, + ); + harness.processBytes.fill(0xee, originalFirst, originalFirst + 2); + harness.processBytes.fill(0xee, originalSecond, originalSecond + 3); + await retryAfterDefaultDelay(harness); + + expect(attempts).toEqual([ + { + syscall: ABI_SYSCALLS.Write, + fd: 7, + payload: [1, 2, 3, 4, 5], + }, + { + syscall: ABI_SYSCALLS.Write, + fd: 7, + payload: [1, 2, 3, 4, 5], + }, + ]); + expectExactRetryBindingLifecycle(harness, ABI_SYSCALLS.Writev); + }, + ); + + it.each(WIDTHS)( + "%s retains sendmsg's native header, iovec table, and payload", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const originalMessage = 0x1000; + const originalTable = 0x1100; + const replacementMessage = 0x1400; + const replacementTable = 0x1500; + const originalPayloadPointer = 0x2000; + const replacementPayloadPointer = 0x2200; + const originalPayload = [0x31, 0x32, 0x33, 0x34]; + const replacementPayload = [0x91, 0x92, 0x93]; + writeNativeIovec( + harness.processBytes, + pointerWidth, + originalTable, + 0, + originalPayloadPointer, + originalPayload.length, + ); + writeNativeMessage( + harness.processBytes, + pointerWidth, + originalMessage, + originalTable, + 1, + ); + writeNativeIovec( + harness.processBytes, + pointerWidth, + replacementTable, + 0, + replacementPayloadPointer, + replacementPayload.length, + ); + writeNativeMessage( + harness.processBytes, + pointerWidth, + replacementMessage, + replacementTable, + 1, + ); + harness.processBytes.set(originalPayload, originalPayloadPointer); + harness.processBytes.set(replacementPayload, replacementPayloadPointer); + writeRequest(harness, ABI_SYSCALLS.Sendmsg, [ + 7n, + BigInt(originalMessage), + 0n, + ]); + + const attempts: Array<{ fd: number; payload: number[] }> = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + const messagePointer = Number(kernelArg(view, 1)); + const messageView = new DataView( + harness.kernelBytes.buffer, + messagePointer, + ); + const iovecPointer = messageView.getUint32( + KERNEL_MSGHDR_WIRE_IOV_OFFSET, + true, + ); + const iovecView = new DataView( + harness.kernelBytes.buffer, + iovecPointer, + ); + const dataPointer = iovecView.getUint32( + KERNEL_IOVEC_WIRE_BASE_OFFSET, + true, + ); + const length = iovecView.getUint32( + KERNEL_IOVEC_WIRE_LEN_OFFSET, + true, + ); + attempts.push({ + fd: Number(kernelArg(view, 0)), + payload: Array.from( + harness.kernelBytes.slice(dataPointer, dataPointer + length), + ), + }); + if (attempts.length === 1) { + publishKernelResult(view, -1, EAGAIN); + } else { + publishKernelResult(view, length, 0); + } + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + writeRequest(harness, ABI_SYSCALLS.Sendmsg, [ + 88n, + BigInt(replacementMessage), + 0n, + ]); + writeNativeIovec( + harness.processBytes, + pointerWidth, + originalTable, + 0, + replacementPayloadPointer, + replacementPayload.length, + ); + harness.processBytes.fill( + 0xee, + originalPayloadPointer, + originalPayloadPointer + originalPayload.length, + ); + await retryAfterDefaultDelay(harness); + + expect(attempts).toEqual([ + { fd: 7, payload: originalPayload }, + { fd: 7, payload: originalPayload }, + ]); + expectExactRetryBindingLifecycle(harness, ABI_SYSCALLS.Sendmsg); + }, + ); + + it.each(WIDTHS)( + "%s retains recvmsg's native header, iovec table, and destinations", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const originalMessage = 0x1000; + const originalTable = 0x1100; + const replacementMessage = 0x1400; + const replacementTable = 0x1500; + const originalDestination = 0x2000; + const replacementDestination = 0x2200; + const payload = [0x51, 0x52, 0x53, 0x54]; + writeNativeIovec( + harness.processBytes, + pointerWidth, + originalTable, + 0, + originalDestination, + payload.length, + ); + writeNativeMessage( + harness.processBytes, + pointerWidth, + originalMessage, + originalTable, + 1, + ); + writeNativeIovec( + harness.processBytes, + pointerWidth, + replacementTable, + 0, + replacementDestination, + payload.length, + ); + writeNativeMessage( + harness.processBytes, + pointerWidth, + replacementMessage, + replacementTable, + 1, + ); + harness.processBytes.fill( + 0x10, + originalDestination, + originalDestination + payload.length, + ); + harness.processBytes.fill( + 0x20, + replacementDestination, + replacementDestination + payload.length, + ); + writeRequest(harness, ABI_SYSCALLS.Recvmsg, [ + 7n, + BigInt(originalMessage), + 0n, + ]); + + let attempts = 0; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + attempts++; + const view = kernelView(harness, rawPointer); + const messagePointer = Number(kernelArg(view, 1)); + const messageView = new DataView( + harness.kernelBytes.buffer, + messagePointer, + ); + if (attempts === 1) { + publishKernelResult(view, -1, EAGAIN); + } else { + const iovecPointer = messageView.getUint32( + KERNEL_MSGHDR_WIRE_IOV_OFFSET, + true, + ); + const iovecView = new DataView( + harness.kernelBytes.buffer, + iovecPointer, + ); + const dataPointer = iovecView.getUint32( + KERNEL_IOVEC_WIRE_BASE_OFFSET, + true, + ); + harness.kernelBytes.set(payload, dataPointer); + messageView.setUint32(KERNEL_MSGHDR_WIRE_NAMELEN_OFFSET, 0, true); + messageView.setUint32( + KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET, + 0, + true, + ); + messageView.setUint32(KERNEL_MSGHDR_WIRE_FLAGS_OFFSET, 0x20, true); + publishKernelResult(view, payload.length, 0); + } + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + writeRequest(harness, ABI_SYSCALLS.Recvmsg, [ + 88n, + BigInt(replacementMessage), + 0n, + ]); + writeNativeIovec( + harness.processBytes, + pointerWidth, + originalTable, + 0, + replacementDestination, + payload.length, + ); + await retryAfterDefaultDelay(harness); + + expect( + Array.from( + harness.processBytes.slice( + originalDestination, + originalDestination + payload.length, + ), + ), + ).toEqual(payload); + expect( + Array.from( + harness.processBytes.slice( + replacementDestination, + replacementDestination + payload.length, + ), + ), + ).toEqual([0x20, 0x20, 0x20, 0x20]); + const layout = nativeMessageLayout(pointerWidth); + expect( + new DataView(harness.processBytes.buffer).getUint32( + originalMessage + layout.flagsOffset, + true, + ), + ).toBe(0x20); + expect( + new DataView(harness.processBytes.buffer).getUint32( + replacementMessage + layout.flagsOffset, + true, + ), + ).toBe(0); + expectExactRetryBindingLifecycle(harness, ABI_SYSCALLS.Recvmsg); + }, + ); + + it.each(WIDTHS)( + "%s retains mq_timedsend's descriptor, priority, and message bytes", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const originalMessage = 0x1000; + const replacementMessage = 0x2000; + const originalPayload = [0x61, 0x62, 0x63, 0x64]; + const replacementPayload = [0x91, 0x92, 0x93, 0x94]; + harness.processBytes.set(originalPayload, originalMessage); + harness.processBytes.set(replacementPayload, replacementMessage); + writeRequest(harness, ABI_SYSCALLS.MqTimedsend, [ + 7n, + BigInt(originalMessage), + 4n, + 3n, + 0n, + ]); + + const attempts: Array<{ + descriptor: number; + priority: number; + payload: number[]; + }> = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + const length = Number(kernelArg(view, 2)); + const dataPointer = Number(kernelArg(view, 1)); + attempts.push({ + descriptor: Number(kernelArg(view, 0)), + priority: Number(kernelArg(view, 3)), + payload: Array.from( + harness.kernelBytes.slice(dataPointer, dataPointer + length), + ), + }); + if (attempts.length === 1) { + publishKernelResult(view, -1, EAGAIN); + } else { + publishKernelResult(view, 0, 0); + } + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + writeRequest(harness, ABI_SYSCALLS.MqTimedsend, [ + 88n, + BigInt(replacementMessage), + 4n, + 9n, + 0n, + ]); + harness.processBytes.fill( + 0xee, + originalMessage, + originalMessage + originalPayload.length, + ); + await retryAfterDefaultDelay(harness); + + expect(attempts).toEqual([ + { + descriptor: 7, + priority: 3, + payload: originalPayload, + }, + { + descriptor: 7, + priority: 3, + payload: originalPayload, + }, + ]); + expectExactRetryBindingLifecycle(harness, ABI_SYSCALLS.MqTimedsend); + }, + ); + + it.each(WIDTHS)( + "%s retains mq_timedreceive's descriptor and output destinations", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const originalDestination = 0x1000; + const originalPriority = 0x1100; + const replacementDestination = 0x2000; + const replacementPriority = 0x2100; + const payload = [0x71, 0x72, 0x73, 0x74]; + harness.processBytes.fill( + 0x10, + originalDestination, + originalDestination + payload.length, + ); + harness.processBytes.fill( + 0x20, + replacementDestination, + replacementDestination + payload.length, + ); + writeRequest(harness, ABI_SYSCALLS.MqTimedreceive, [ + 7n, + BigInt(originalDestination), + 4n, + BigInt(originalPriority), + 0n, + ]); + + const descriptors: number[] = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + descriptors.push(Number(kernelArg(view, 0))); + if (descriptors.length === 1) { + publishKernelResult(view, -1, EAGAIN); + } else { + harness.kernelBytes.set(payload, Number(kernelArg(view, 1))); + new DataView(harness.kernelBytes.buffer).setUint32( + Number(kernelArg(view, 3)), + 17, + true, + ); + publishKernelResult(view, payload.length, 0); + } + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + writeRequest(harness, ABI_SYSCALLS.MqTimedreceive, [ + 88n, + BigInt(replacementDestination), + 4n, + BigInt(replacementPriority), + 0n, + ]); + await retryAfterDefaultDelay(harness); + + expect(descriptors).toEqual([7, 7]); + expect( + Array.from( + harness.processBytes.slice( + originalDestination, + originalDestination + payload.length, + ), + ), + ).toEqual(payload); + expect( + Array.from( + harness.processBytes.slice( + replacementDestination, + replacementDestination + payload.length, + ), + ), + ).toEqual([0x20, 0x20, 0x20, 0x20]); + const processView = new DataView(harness.processBytes.buffer); + expect(processView.getUint32(originalPriority, true)).toBe(17); + expect(processView.getUint32(replacementPriority, true)).toBe(0); + expectExactRetryBindingLifecycle( + harness, + ABI_SYSCALLS.MqTimedreceive, + ); + }, + ); + + it.each(WIDTHS)( + "%s retains msgsnd's queue, native type, flags, and payload", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const originalMessage = 0x1000; + const replacementMessage = 0x2000; + const originalPayload = [0x21, 0x22, 0x23]; + const replacementPayload = [0x81, 0x82, 0x83]; + writeNativeSysvMessage( + harness.processBytes, + pointerWidth, + originalMessage, + 5n, + originalPayload, + ); + writeNativeSysvMessage( + harness.processBytes, + pointerWidth, + replacementMessage, + 9n, + replacementPayload, + ); + writeRequest(harness, ABI_SYSCALLS.Msgsnd, [ + 7n, + BigInt(originalMessage), + BigInt(originalPayload.length), + 0n, + ]); + + const attempts: Array<{ + queue: number; + type: bigint; + payload: number[]; + }> = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + const dataPointer = Number(kernelArg(view, 1)); + const length = Number(kernelArg(view, 2)); + const dataView = new DataView( + harness.kernelBytes.buffer, + dataPointer, + ); + attempts.push({ + queue: Number(kernelArg(view, 0)), + type: dataView.getBigInt64(0, true), + payload: Array.from( + harness.kernelBytes.slice( + dataPointer + STRUCT_SIZE_WASM_SYSV_MESSAGE_HEADER, + dataPointer + STRUCT_SIZE_WASM_SYSV_MESSAGE_HEADER + length, + ), + ), + }); + if (attempts.length === 1) { + publishKernelResult(view, -1, EAGAIN); + } else { + publishKernelResult(view, 0, 0); + } + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + writeRequest(harness, ABI_SYSCALLS.Msgsnd, [ + 88n, + BigInt(replacementMessage), + BigInt(replacementPayload.length), + 0n, + ]); + writeNativeSysvMessage( + harness.processBytes, + pointerWidth, + originalMessage, + 11n, + [0xee, 0xee, 0xee], + ); + await retryAfterDefaultDelay(harness); + + expect(attempts).toEqual([ + { + queue: 7, + type: 5n, + payload: originalPayload, + }, + { + queue: 7, + type: 5n, + payload: originalPayload, + }, + ]); + expectExactRetryBindingLifecycle(harness, ABI_SYSCALLS.Msgsnd); + }, + ); + + it.each(WIDTHS)( + "%s retains msgrcv's queue, type selector, flags, and destination", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const originalDestination = 0x1000; + const replacementDestination = 0x2000; + const payload = [0x31, 0x32, 0x33]; + writeNativeSysvMessage( + harness.processBytes, + pointerWidth, + originalDestination, + 0n, + [0x10, 0x10, 0x10], + ); + writeNativeSysvMessage( + harness.processBytes, + pointerWidth, + replacementDestination, + 0n, + [0x20, 0x20, 0x20], + ); + writeRequest(harness, ABI_SYSCALLS.Msgrcv, [ + 7n, + BigInt(originalDestination), + BigInt(payload.length), + 5n, + 0n, + ]); + + const attempts: Array<{ + queue: number; + typeSelector: bigint; + flags: number; + }> = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + attempts.push({ + queue: Number(kernelArg(view, 0)), + typeSelector: kernelArg(view, 3), + flags: Number(kernelArg(view, 4)), + }); + if (attempts.length === 1) { + publishKernelResult(view, -1, EAGAIN); + } else { + const dataPointer = Number(kernelArg(view, 1)); + const dataView = new DataView( + harness.kernelBytes.buffer, + dataPointer, + ); + dataView.setBigInt64(0, 9n, true); + harness.kernelBytes.set( + payload, + dataPointer + STRUCT_SIZE_WASM_SYSV_MESSAGE_HEADER, + ); + publishKernelResult(view, payload.length, 0); + } + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + writeRequest(harness, ABI_SYSCALLS.Msgrcv, [ + 88n, + BigInt(replacementDestination), + BigInt(payload.length), + 12n, + 0x800n, + ]); + await retryAfterDefaultDelay(harness); + + expect(attempts).toEqual([ + { queue: 7, typeSelector: 5n, flags: 0 }, + { queue: 7, typeSelector: 5n, flags: 0 }, + ]); + expect( + readNativeSysvMessage( + harness.processBytes, + pointerWidth, + originalDestination, + payload.length, + ), + ).toEqual({ + type: 9n, + payload, + }); + expect( + readNativeSysvMessage( + harness.processBytes, + pointerWidth, + replacementDestination, + payload.length, + ), + ).toEqual({ + type: 0n, + payload: [0x20, 0x20, 0x20], + }); + expectExactRetryBindingLifecycle(harness, ABI_SYSCALLS.Msgrcv); + }, + ); + + describe.each([ + ["msgsnd", ABI_SYSCALLS.Msgsnd, [7n, 0x1000n, 3n, 0x800n]], + [ + "msgrcv", + ABI_SYSCALLS.Msgrcv, + [7n, 0x1000n, 3n, 5n, 0x800n], + ], + ] as const)("%s IPC_NOWAIT", (_syscallName, syscall, args) => { + it.each(WIDTHS)( + "%s publishes EAGAIN only after releasing the exact queue pin", + (_name, pointerWidth) => { + const harness = createRetryHarness(pointerWidth); + writeNativeSysvMessage( + harness.processBytes, + pointerWidth, + 0x1000, + 3n, + [1, 2, 3], + ); + writeRequest(harness, syscall, args); + harness.kernelExports.kernel_blocking_retry_token = vi.fn(() => 78n); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + publishKernelResult( + kernelView(harness, rawPointer), + -1, + EAGAIN, + ); + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + + expect(requestResult(harness)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: -1, + errno: EAGAIN, + }); + expect(harness.worker.pendingPollRetries.has(harness.channel)).toBe( + false, + ); + expect( + harness.kernelExports.kernel_blocking_retry_token, + ).toHaveBeenCalledWith( + harness.channel.pid, + harness.channel.pid, + syscall, + ); + expect( + harness.kernelExports.kernel_blocking_retry_release, + ).toHaveBeenCalledWith( + harness.channel.pid, + harness.channel.pid, + 78n, + ); + expect( + ( + harness.kernelExports.kernel_handle_channel as ReturnType< + typeof vi.fn + > + ).mock.calls.map((call) => call[3]), + ).toEqual([0n]); + }, + ); + }); + + it.each(WIDTHS)( + "%s retains semop's semid and detached sembuf array across ID reuse", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const originalOperations = 0x1000; + const replacementOperations = 0x1100; + writeNativeSemop( + harness.processBytes, + originalOperations, + 0, + 2, + -1, + 0, + ); + writeNativeSemop( + harness.processBytes, + replacementOperations, + 0, + 9, + 1, + IPC_NOWAIT, + ); + writeRequest(harness, ABI_SYSCALLS.Semop, [ + 7n, + BigInt(originalOperations), + 1n, + ]); + harness.kernelExports.kernel_blocking_retry_token = vi.fn(() => 77n); + + const attempts: Array<{ + semid: number; + number: number; + operation: number; + flags: number; + }> = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + const operations = Number(kernelArg(view, 1)); + const operationView = new DataView( + harness.kernelBytes.buffer, + operations, + 6, + ); + attempts.push({ + semid: Number(kernelArg(view, 0)), + number: operationView.getUint16(0, true), + operation: operationView.getInt16(2, true), + flags: operationView.getUint16(4, true), + }); + publishKernelResult( + view, + attempts.length === 1 ? -1 : 0, + attempts.length === 1 ? EAGAIN : 0, + ); + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + // Model IPC_RMID plus reuse of numeric semid 7: the mailbox and both + // guest arrays now describe a different logical operation. Only the + // detached plan plus Rust's exact retry token may survive this point. + writeRequest(harness, ABI_SYSCALLS.Semop, [ + 7n, + BigInt(replacementOperations), + 1n, + ]); + writeNativeSemop( + harness.processBytes, + originalOperations, + 0, + 12, + 3, + IPC_NOWAIT, + ); + await retryAfterDefaultDelay(harness); + + expect(attempts).toEqual([ + { semid: 7, number: 2, operation: -1, flags: 0 }, + { semid: 7, number: 2, operation: -1, flags: 0 }, + ]); + expect(requestResult(harness)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: 0, + errno: 0, + }); + expectExactRetryBindingLifecycle( + harness, + ABI_SYSCALLS.Semop, + 77n, + ); + }, + ); + + it.each(WIDTHS)( + "%s preserves valid zero-operation semop without inventing policy bytes", + (_name, pointerWidth) => { + const harness = createRetryHarness(pointerWidth); + writeRequest(harness, ABI_SYSCALLS.Semop, [7n, 0n, 0n]); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + expect(Number(kernelArg(view, 0))).toBe(7); + expect(Number(kernelArg(view, 2))).toBe(0); + publishKernelResult(view, 0, 0); + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + + expect(requestResult(harness)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: 0, + errno: 0, + }); + expect( + harness.kernelExports.kernel_blocking_retry_token, + ).not.toHaveBeenCalled(); + }, + ); + + it.each(WIDTHS)( + "%s returns semop IPC_NOWAIT EAGAIN and releases its exact target pin", + (_name, pointerWidth) => { + const harness = createRetryHarness(pointerWidth); + const operations = 0x1000; + writeNativeSemop( + harness.processBytes, + operations, + 0, + 2, + -1, + IPC_NOWAIT, + ); + writeRequest(harness, ABI_SYSCALLS.Semop, [ + 7n, + BigInt(operations), + 1n, + ]); + harness.kernelExports.kernel_blocking_retry_token = vi.fn(() => 88n); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + publishKernelResult( + kernelView(harness, rawPointer), + -1, + EAGAIN, + ); + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + + expect(requestResult(harness)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: -1, + errno: EAGAIN, + }); + expect(harness.worker.pendingPollRetries.has(harness.channel)).toBe( + false, + ); + const token = harness.kernelExports + .kernel_blocking_retry_token as ReturnType; + const release = harness.kernelExports + .kernel_blocking_retry_release as ReturnType; + expect(token).toHaveBeenCalledWith( + harness.channel.pid, + harness.channel.pid, + ABI_SYSCALLS.Semop, + ); + expect(release).toHaveBeenCalledWith( + harness.channel.pid, + harness.channel.pid, + 88n, + ); + expect( + ( + harness.kernelExports.kernel_handle_channel as ReturnType< + typeof vi.fn + > + ).mock.calls.map((call) => call[3]), + ).toEqual([0n]); + }, + ); + + it.each(WIDTHS)( + "%s returns splice SPLICE_F_NONBLOCK EAGAIN and releases both targets", + (_name, pointerWidth) => { + const harness = createRetryHarness(pointerWidth); + writeRequest(harness, ABI_SYSCALLS.Splice, [ + 7n, + 0n, + 8n, + 0n, + 4n, + 0x02n, + ]); + harness.kernelExports.kernel_blocking_retry_token = vi.fn(() => 89n); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + publishKernelResult( + kernelView(harness, rawPointer), + -1, + EAGAIN, + ); + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + + expect(requestResult(harness)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: -1, + errno: EAGAIN, + }); + expect(harness.worker.pendingPollRetries.has(harness.channel)).toBe( + false, + ); + expect( + harness.kernelExports.kernel_blocking_retry_token, + ).toHaveBeenCalledWith( + harness.channel.pid, + harness.channel.pid, + ABI_SYSCALLS.Splice, + ); + expect( + harness.kernelExports.kernel_blocking_retry_release, + ).toHaveBeenCalledWith( + harness.channel.pid, + harness.channel.pid, + 89n, + ); + expect( + ( + harness.kernelExports.kernel_handle_channel as ReturnType< + typeof vi.fn + > + ).mock.calls.map((call) => call[3]), + ).toEqual([0n]); + }, + ); + + it.each(WIDTHS)( + "%s returns sendfile input O_NONBLOCK EAGAIN without parking", + (_name, pointerWidth) => { + const harness = createRetryHarness(pointerWidth); + writeRequest(harness, ABI_SYSCALLS.Sendfile, [ + 7n, + 8n, + 0n, + 4n, + ]); + harness.kernelExports.kernel_blocking_retry_token = vi.fn(() => 90n); + const isFdNonblock = vi.fn( + (_pid: number, fd: number) => fd === 8 ? 1 : 0, + ); + const getTimeout = vi.fn(() => { + throw new Error("O_NONBLOCK input must short-circuit socket timeout"); + }); + harness.kernelExports.kernel_is_fd_nonblock = isFdNonblock; + harness.kernelExports.kernel_get_socket_timeout_ms = getTimeout; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + publishKernelResult( + kernelView(harness, rawPointer), + -1, + EAGAIN, + ); + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + + expect(requestResult(harness)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: -1, + errno: EAGAIN, + }); + expect(isFdNonblock.mock.calls).toEqual([ + [harness.channel.pid, 7], + [harness.channel.pid, 8], + ]); + expect(getTimeout).not.toHaveBeenCalled(); + expect(harness.worker.pendingPollRetries.has(harness.channel)).toBe( + false, + ); + expect( + harness.kernelExports.kernel_blocking_retry_token, + ).toHaveBeenCalledWith( + harness.channel.pid, + harness.channel.pid, + ABI_SYSCALLS.Sendfile, + ); + expect( + harness.kernelExports.kernel_blocking_retry_release, + ).toHaveBeenCalledWith( + harness.channel.pid, + harness.channel.pid, + 90n, + ); + expect( + ( + harness.kernelExports.kernel_handle_channel as ReturnType< + typeof vi.fn + > + ).mock.calls.map((call) => call[3]), + ).toEqual([0n]); + }, + ); + + it.each(WIDTHS)( + "%s parks sendfile only after both endpoints prove blocking", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + writeRequest(harness, ABI_SYSCALLS.Sendfile, [ + 7n, + 8n, + 0n, + 4n, + ]); + harness.kernelExports.kernel_blocking_retry_token = vi.fn(() => 91n); + const isFdNonblock = vi.fn(() => 0); + const getTimeout = vi.fn(() => 0n); + harness.kernelExports.kernel_is_fd_nonblock = isFdNonblock; + harness.kernelExports.kernel_get_socket_timeout_ms = getTimeout; + let attempts = 0; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + attempts++; + publishKernelResult( + kernelView(harness, rawPointer), + attempts === 1 ? -1 : 4, + attempts === 1 ? EAGAIN : 0, + ); + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + + expect(requestStatus(harness)).toBe(CHANNEL_STATUS_PENDING); + expect(isFdNonblock.mock.calls).toEqual([ + [harness.channel.pid, 7], + [harness.channel.pid, 8], + ]); + expect(getTimeout).toHaveBeenCalledOnce(); + expect(getTimeout).toHaveBeenCalledWith( + harness.channel.pid, + 7, + 0, + ); + + await retryAfterDefaultDelay(harness); + + expect(requestResult(harness)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: 4, + errno: 0, + }); + expect(isFdNonblock.mock.calls).toEqual([ + [harness.channel.pid, 7], + [harness.channel.pid, 8], + ]); + expect(getTimeout).toHaveBeenCalledOnce(); + expectExactRetryBindingLifecycle( + harness, + ABI_SYSCALLS.Sendfile, + 91n, + ); + }, + ); + + it.each(WIDTHS)( + "%s retains sendfile's two fds and offset bytes across numeric fd reuse", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const originalOffset = 0x1000; + const replacementOffset = 0x1100; + writeNativeOffset(harness.processBytes, originalOffset, 123n); + writeNativeOffset(harness.processBytes, replacementOffset, 999n); + writeRequest(harness, ABI_SYSCALLS.Sendfile, [ + 7n, + 8n, + BigInt(originalOffset), + 4n, + ]); + harness.kernelExports.kernel_blocking_retry_token = vi.fn(() => 31n); + + let numericFdsWereReused = false; + const attempts: Array<{ + outputFd: number; + inputFd: number; + offset: bigint; + count: number; + target: string; + }> = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + ( + rawPointer: number | bigint, + _capacity: number, + _pid: number, + retryToken: bigint, + ) => { + const view = kernelView(harness, rawPointer); + const offsetPointer = Number(kernelArg(view, 2)); + attempts.push({ + outputFd: Number(kernelArg(view, 0)), + inputFd: Number(kernelArg(view, 1)), + offset: new DataView( + harness.kernelBytes.buffer, + offsetPointer, + 8, + ).getBigInt64(0, true), + count: Number(kernelArg(view, 3)), + target: numericFdsWereReused + ? retryToken === 31n + ? "original-pinned" + : "reused-numeric" + : "original-lookup", + }); + if (attempts.length === 1) { + publishKernelResult(view, -1, EAGAIN); + } else { + new DataView( + harness.kernelBytes.buffer, + offsetPointer, + 8, + ).setBigInt64(0, 127n, true); + publishKernelResult(view, 4, 0); + } + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + numericFdsWereReused = true; + writeRequest(harness, ABI_SYSCALLS.Sendfile, [ + 7n, + 8n, + BigInt(replacementOffset), + 99n, + ]); + writeNativeOffset(harness.processBytes, originalOffset, 555n); + await retryAfterDefaultDelay(harness); + + expect(attempts).toEqual([ + { + outputFd: 7, + inputFd: 8, + offset: 123n, + count: 4, + target: "original-lookup", + }, + { + outputFd: 7, + inputFd: 8, + offset: 123n, + count: 4, + target: "original-pinned", + }, + ]); + expect(readNativeOffset(harness.processBytes, originalOffset)).toBe( + 127n, + ); + expect(readNativeOffset(harness.processBytes, replacementOffset)).toBe( + 999n, + ); + expectExactRetryBindingLifecycle( + harness, + ABI_SYSCALLS.Sendfile, + 31n, + ); + }, + ); + + describe.each([ + ["copy_file_range", ABI_SYSCALLS.CopyFileRange], + ["splice", ABI_SYSCALLS.Splice], + ] as const)("%s retry snapshot", (_syscallName, syscall) => { + it.each(WIDTHS)( + "%s retains both fds, both offsets, count, and flags across fd reuse", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const originalInputOffset = 0x1000; + const originalOutputOffset = 0x1010; + const replacementInputOffset = 0x1100; + const replacementOutputOffset = 0x1110; + writeNativeOffset(harness.processBytes, originalInputOffset, 100n); + writeNativeOffset(harness.processBytes, originalOutputOffset, 200n); + writeNativeOffset(harness.processBytes, replacementInputOffset, 900n); + writeNativeOffset(harness.processBytes, replacementOutputOffset, 950n); + writeRequest(harness, syscall, [ + 7n, + BigInt(originalInputOffset), + 8n, + BigInt(originalOutputOffset), + 4n, + 0x20n, + ]); + harness.kernelExports.kernel_blocking_retry_token = vi.fn(() => 32n); + + let numericFdsWereReused = false; + const attempts: Array<{ + inputFd: number; + inputOffset: bigint; + outputFd: number; + outputOffset: bigint; + count: number; + flags: number; + target: string; + }> = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + ( + rawPointer: number | bigint, + _capacity: number, + _pid: number, + retryToken: bigint, + ) => { + const view = kernelView(harness, rawPointer); + const inputOffsetPointer = Number(kernelArg(view, 1)); + const outputOffsetPointer = Number(kernelArg(view, 3)); + attempts.push({ + inputFd: Number(kernelArg(view, 0)), + inputOffset: new DataView( + harness.kernelBytes.buffer, + inputOffsetPointer, + 8, + ).getBigInt64(0, true), + outputFd: Number(kernelArg(view, 2)), + outputOffset: new DataView( + harness.kernelBytes.buffer, + outputOffsetPointer, + 8, + ).getBigInt64(0, true), + count: Number(kernelArg(view, 4)), + flags: Number(kernelArg(view, 5)), + target: numericFdsWereReused + ? retryToken === 32n + ? "original-pinned" + : "reused-numeric" + : "original-lookup", + }); + if (attempts.length === 1) { + publishKernelResult(view, -1, EAGAIN); + } else { + const kernelViewBytes = new DataView( + harness.kernelBytes.buffer, + ); + kernelViewBytes.setBigInt64( + inputOffsetPointer, + 104n, + true, + ); + kernelViewBytes.setBigInt64( + outputOffsetPointer, + 204n, + true, + ); + publishKernelResult(view, 4, 0); + } + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + numericFdsWereReused = true; + writeRequest(harness, syscall, [ + 7n, + BigInt(replacementInputOffset), + 8n, + BigInt(replacementOutputOffset), + 99n, + 0x40n, + ]); + writeNativeOffset(harness.processBytes, originalInputOffset, 500n); + writeNativeOffset(harness.processBytes, originalOutputOffset, 600n); + await retryAfterDefaultDelay(harness); + + expect(attempts).toEqual([ + { + inputFd: 7, + inputOffset: 100n, + outputFd: 8, + outputOffset: 200n, + count: 4, + flags: 0x20, + target: "original-lookup", + }, + { + inputFd: 7, + inputOffset: 100n, + outputFd: 8, + outputOffset: 200n, + count: 4, + flags: 0x20, + target: "original-pinned", + }, + ]); + expect( + readNativeOffset(harness.processBytes, originalInputOffset), + ).toBe(104n); + expect( + readNativeOffset(harness.processBytes, originalOutputOffset), + ).toBe(204n); + expect( + readNativeOffset(harness.processBytes, replacementInputOffset), + ).toBe(900n); + expect( + readNativeOffset(harness.processBytes, replacementOutputOffset), + ).toBe(950n); + expectExactRetryBindingLifecycle(harness, syscall, 32n); + }, + ); + }); + + it.each(WIDTHS)( + "%s accepts ESRCH token loss only after authoritative Exited state", + async (_name, pointerWidth) => { + const harness = createRetryHarness(pointerWidth); + const source = 0x1000; + harness.processBytes.set([1, 2, 3], source); + writeRequest(harness, ABI_SYSCALLS.Write, [ + 7n, + BigInt(source), + 3n, + ]); + harness.kernelExports.kernel_blocking_retry_token = vi.fn( + () => -BigInt(ESRCH), + ); + harness.kernelExports.kernel_get_process_state = vi.fn( + () => PROCESS_STATE_EXITED, + ); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + publishKernelResult( + kernelView(harness, rawPointer), + -1, + EAGAIN, + ); + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + await Promise.resolve(); + + expect(harness.onKernelFatal).not.toHaveBeenCalled(); + expect(requestStatus(harness)).toBe(CHANNEL_STATUS_PENDING); + expect(harness.worker.pendingPollRetries.has(harness.channel)).toBe( + false, + ); + expect( + harness.worker.blockingRetrySnapshots.has(harness.channel), + ).toBe(false); + expect( + harness.kernelExports.kernel_get_process_state, + ).toHaveBeenCalledWith(harness.channel.pid); + expect( + harness.kernelExports.kernel_blocking_retry_release, + ).not.toHaveBeenCalled(); + }, + ); + + it.each(WIDTHS)( + "%s treats ESRCH token loss for a live process as generation-fatal", + async (_name, pointerWidth) => { + const harness = createRetryHarness(pointerWidth); + const source = 0x1000; + harness.processBytes.set([1, 2, 3], source); + writeRequest(harness, ABI_SYSCALLS.Write, [ + 7n, + BigInt(source), + 3n, + ]); + harness.kernelExports.kernel_blocking_retry_token = vi.fn( + () => -BigInt(ESRCH), + ); + harness.kernelExports.kernel_get_process_state = vi.fn(() => 0); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + publishKernelResult( + kernelView(harness, rawPointer), + -1, + EAGAIN, + ); + return 0; + }, + ); + + expect(() => harness.worker.handleSyscall(harness.channel)).toThrow( + "invalid blocking-retry token", + ); + await Promise.resolve(); + + expect(harness.onKernelFatal).toHaveBeenCalledOnce(); + expect(requestStatus(harness)).toBe(CHANNEL_STATUS_PENDING); + expect(harness.worker.pendingPollRetries.has(harness.channel)).toBe( + false, + ); + expect( + harness.kernelExports.kernel_blocking_retry_release, + ).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ["missing implementation", "missing"], + ["throwing implementation", "throw"], + ] as const)( + "treats a %s from blocking-retry token query as generation-fatal", + async (_description, mode) => { + const harness = createRetryHarness(4); + const source = 0x1000; + harness.processBytes.set([1], source); + writeRequest(harness, ABI_SYSCALLS.Write, [7n, BigInt(source), 1n]); + if (mode === "missing") { + harness.kernelExports.kernel_blocking_retry_token = undefined; + } else { + harness.kernelExports.kernel_blocking_retry_token = vi.fn(() => { + throw new Error("token query trap"); + }); + } + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + publishKernelResult( + kernelView(harness, rawPointer), + -1, + EAGAIN, + ); + return 0; + }, + ); + + expect(() => harness.worker.handleSyscall(harness.channel)).toThrow(); + await Promise.resolve(); + + expect(harness.onKernelFatal).toHaveBeenCalledOnce(); + expect(requestStatus(harness)).toBe(CHANNEL_STATUS_PENDING); + expect(harness.worker.pendingPollRetries.has(harness.channel)).toBe( + false, + ); + expect( + harness.kernelExports.kernel_blocking_retry_release, + ).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ["missing implementation", "missing"], + ["throwing implementation", "throw"], + ["nonzero result", "nonzero"], + ] as const)( + "treats a %s from exact blocking-retry release as generation-fatal", + async (_description, mode) => { + vi.useFakeTimers(); + const harness = createRetryHarness(4); + const source = 0x1000; + harness.processBytes.set([1], source); + writeRequest(harness, ABI_SYSCALLS.Write, [7n, BigInt(source), 1n]); + harness.kernelExports.kernel_blocking_retry_token = vi.fn(() => 91n); + let attempts = 0; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + attempts++; + publishKernelResult( + kernelView(harness, rawPointer), + attempts === 1 ? -1 : 1, + attempts === 1 ? EAGAIN : 0, + ); + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + if (mode === "missing") { + harness.kernelExports.kernel_blocking_retry_release = undefined; + } else if (mode === "throw") { + harness.kernelExports.kernel_blocking_retry_release = vi.fn(() => { + throw new Error("release trap"); + }); + } else { + harness.kernelExports.kernel_blocking_retry_release = vi.fn( + () => -1, + ); + } + await expect(retryAfterDefaultDelay(harness)).rejects.toThrow(); + await Promise.resolve(); + + expect(attempts).toBe(2); + expect(harness.onKernelFatal).toHaveBeenCalledOnce(); + // Release precedes all guest publication. A failed ownership handoff + // must leave this generation parked, never report the successful retry. + expect(requestStatus(harness)).toBe(CHANNEL_STATUS_PENDING); + expect(harness.worker.pendingPollRetries.has(harness.channel)).toBe( + false, + ); + }, + ); + + it.each(WIDTHS)( + "%s releases a retry token when its exact channel is retired", + (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const source = 0x1000; + harness.processBytes.set([1], source); + writeRequest(harness, ABI_SYSCALLS.Write, [7n, BigInt(source), 1n]); + harness.kernelExports.kernel_blocking_retry_token = vi.fn(() => 92n); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + publishKernelResult( + kernelView(harness, rawPointer), + -1, + EAGAIN, + ); + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + harness.worker.removeChannel( + harness.channel.pid, + harness.channel.channelOffset, + ); + + expect( + harness.kernelExports.kernel_blocking_retry_release, + ).toHaveBeenCalledWith( + harness.channel.pid, + harness.channel.pid, + 92n, + ); + expect(harness.worker.pendingPollRetries.has(harness.channel)).toBe( + false, + ); + expect( + harness.worker.blockingRetrySnapshots.has(harness.channel), + ).toBe(false); + expect(requestStatus(harness)).toBe(CHANNEL_STATUS_PENDING); + }, + ); + + it.each(WIDTHS)( + "%s releases retry authority before teardown publishes EINTR", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const source = 0x1000; + harness.processBytes.set([1], source); + writeRequest(harness, ABI_SYSCALLS.Write, [7n, BigInt(source), 1n]); + harness.kernelExports.kernel_blocking_retry_token = vi.fn(() => 93n); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + publishKernelResult( + kernelView(harness, rawPointer), + -1, + EAGAIN, + ); + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + const woken = await harness.worker.killAllBlockedForTeardown(); + + expect(woken).toEqual(new Set([harness.channel.pid])); + expect( + harness.kernelExports.kernel_blocking_retry_release, + ).toHaveBeenCalledWith( + harness.channel.pid, + harness.channel.pid, + 93n, + ); + expect(requestResult(harness)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: -1, + errno: 4, + }); + }, + ); + + it.each(WIDTHS)( + "%s reacquires process memory after memory.grow before retry copyback", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth, { + maximumProcessPages: 9, + }); + const destination = 0x1000; + const payload = [0x41, 0x42, 0x43]; + writeRequest(harness, ABI_SYSCALLS.Read, [ + 7n, + BigInt(destination), + BigInt(payload.length), + ]); + let attempts = 0; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + attempts++; + const view = kernelView(harness, rawPointer); + if (attempts === 1) { + publishKernelResult(view, -1, EAGAIN); + } else { + harness.kernelBytes.set( + payload, + Number(kernelArg(view, 1)), + ); + publishKernelResult(view, payload.length, 0); + } + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + harness.processMemory.grow(1); + await retryAfterDefaultDelay(harness); + + expect( + Array.from( + new Uint8Array(harness.processMemory.buffer).slice( + destination, + destination + payload.length, + ), + ), + ).toEqual(payload); + expectExactRetryBindingLifecycle(harness, ABI_SYSCALLS.Read); + }, + ); + + it.each(WIDTHS)( + "%s releases and replaces tokens across sequential requests on one channel", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const firstSource = 0x1000; + const secondSource = 0x1100; + harness.processBytes.set([1], firstSource); + harness.processBytes.set([2], secondSource); + const tokenForRetry = vi.fn() + .mockReturnValueOnce(101n) + .mockReturnValueOnce(102n); + harness.kernelExports.kernel_blocking_retry_token = tokenForRetry; + const attemptsByFd = new Map(); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + const fd = Number(kernelArg(view, 0)); + const count = (attemptsByFd.get(fd) ?? 0) + 1; + attemptsByFd.set(fd, count); + publishKernelResult( + view, + count === 1 ? -1 : 1, + count === 1 ? EAGAIN : 0, + ); + return 0; + }, + ); + + writeRequest(harness, ABI_SYSCALLS.Write, [ + 7n, + BigInt(firstSource), + 1n, + ]); + harness.worker.handleSyscall(harness.channel); + await retryAfterDefaultDelay(harness); + writeRequest(harness, ABI_SYSCALLS.Write, [ + 8n, + BigInt(secondSource), + 1n, + ]); + harness.worker.handleSyscall(harness.channel); + await retryAfterDefaultDelay(harness); + + expect(requestResult(harness)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: 1, + errno: 0, + }); + expect(tokenForRetry.mock.calls).toEqual([ + [ + harness.channel.pid, + harness.channel.pid, + ABI_SYSCALLS.Write, + ], + [ + harness.channel.pid, + harness.channel.pid, + ABI_SYSCALLS.Write, + ], + ]); + expect( + ( + harness.kernelExports.kernel_blocking_retry_release as ReturnType< + typeof vi.fn + > + ).mock.calls, + ).toEqual([ + [harness.channel.pid, harness.channel.pid, 101n], + [harness.channel.pid, harness.channel.pid, 102n], + ]); + }, + ); + + it.each(WIDTHS)( + "%s keeps interleaved channel generations and tokens disjoint", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth, { + channelOffsets: [6 * 65_536, 4 * 65_536], + }); + const [first, second] = harness.channels; + harness.worker.channelTids.set( + `${first.pid}:${first.channelOffset}`, + 41, + ); + harness.worker.channelTids.set( + `${second.pid}:${second.channelOffset}`, + 42, + ); + const firstSource = 0x1000; + const secondSource = 0x1100; + harness.processBytes.set([1], firstSource); + harness.processBytes.set([2], secondSource); + writeRequest( + harness, + ABI_SYSCALLS.Write, + [7n, BigInt(firstSource), 1n], + first, + ); + writeRequest( + harness, + ABI_SYSCALLS.Write, + [8n, BigInt(secondSource), 1n], + second, + ); + harness.kernelExports.kernel_blocking_retry_token = vi.fn( + (_pid: number, tid: number) => tid === 41 ? 111n : 112n, + ); + const attemptsByFd = new Map(); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + const fd = Number(kernelArg(view, 0)); + const count = (attemptsByFd.get(fd) ?? 0) + 1; + attemptsByFd.set(fd, count); + publishKernelResult( + view, + count === 1 ? -1 : 1, + count === 1 ? EAGAIN : 0, + ); + return 0; + }, + ); + + harness.worker.handleSyscall(first); + harness.worker.handleSyscall(second); + expect(harness.worker.pendingPollRetries.has(first)).toBe(true); + expect(harness.worker.pendingPollRetries.has(second)).toBe(true); + await vi.advanceTimersByTimeAsync(10); + await Promise.resolve(); + + expect(requestResult(harness, first)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: 1, + errno: 0, + }); + expect(requestResult(harness, second)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: 1, + errno: 0, + }); + expect( + ( + harness.kernelExports.kernel_blocking_retry_release as ReturnType< + typeof vi.fn + > + ).mock.calls, + ).toEqual( + expect.arrayContaining([ + [first.pid, 41, 111n], + [second.pid, 42, 112n], + ]), + ); + }, + ); + + it.each(WIDTHS)( + "%s forgets an old-image retry only after the exec lifecycle consumes it", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const source = 0x1000; + harness.processBytes.set([1, 2, 3], source); + writeRequest(harness, ABI_SYSCALLS.Write, [7n, BigInt(source), 3n]); + const handleChannel = vi.fn((rawPointer: number | bigint) => { + publishKernelResult(kernelView(harness, rawPointer), -1, EAGAIN); + return 0; + }); + harness.kernelExports.kernel_handle_channel = handleChannel; + + harness.worker.handleSyscall(harness.channel); + expect(handleChannel).toHaveBeenCalledOnce(); + expect(harness.worker.pendingPollRetries.has(harness.channel)).toBe(true); + + const release = harness.kernelExports + .kernel_blocking_retry_release as ReturnType; + harness.worker.prepareProcessForExec(harness.channel.pid); + // prepareProcessForExec is invoked only after Rust's irreversible exec + // commit consumed every old-image pin. A numeric release here would be a + // second, ambiguous ownership transition. + expect(release).not.toHaveBeenCalled(); + expect( + harness.worker.blockingRetrySnapshots.has(harness.channel), + ).toBe(false); + + const replacementMemory = sharedMemory(); + const [replacementChannel] = + harness.worker.testAuthority.replaceProcessRegistrationForLifecycleTest( + { + pid: harness.channel.pid, + memory: replacementMemory, + channelOffsets: [6 * 65_536], + pointerWidth, + }, + ); + const replacementView = new DataView( + replacementMemory.buffer, + replacementChannel.channelOffset, + ); + replacementView.setUint32(CH_STATUS, CHANNEL_STATUS_PENDING, true); + replacementView.setUint32(CH_SYSCALL, ABI_SYSCALLS.Getpid, true); + + await vi.advanceTimersByTimeAsync(10); + await Promise.resolve(); + + expect(handleChannel).toHaveBeenCalledOnce(); + expect(harness.worker.pendingPollRetries.has(harness.channel)).toBe( + false, + ); + expect(replacementView.getUint32(CH_STATUS, true)).toBe( + CHANNEL_STATUS_PENDING, + ); + }, + ); +}); + +describe("remaining pointer-bearing blocking retry snapshots", () => { + it.each(WIDTHS)( + "%s retains pollfd bytes, nfds, timeout, and the original output range", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const originalPollfds = 0x1000; + const replacementPollfds = 0x2000; + const original = new DataView( + harness.processMemory.buffer, + originalPollfds, + STRUCT_SIZE_WASM_POLL_FD, + ); + original.setInt32(WASM_POLL_FD_FD_OFFSET, 7, true); + original.setInt16(WASM_POLL_FD_EVENTS_OFFSET, 1, true); + const replacement = new DataView( + harness.processMemory.buffer, + replacementPollfds, + STRUCT_SIZE_WASM_POLL_FD, + ); + replacement.setInt32(WASM_POLL_FD_FD_OFFSET, 88, true); + replacement.setInt16(WASM_POLL_FD_EVENTS_OFFSET, 4, true); + writeRequest(harness, ABI_SYSCALLS.Poll, [ + BigInt(originalPollfds), + 1n, + 1_000n, + ]); + harness.kernelExports.kernel_get_fd_pipe_idx = vi.fn(() => 17); + + const attempts: Array<{ + fd: number; + events: number; + nfds: number; + timeout: number; + }> = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + const pollfdPointer = Number(kernelArg(view, 0)); + const pollfd = new DataView( + harness.kernelBytes.buffer, + pollfdPointer, + STRUCT_SIZE_WASM_POLL_FD, + ); + attempts.push({ + fd: pollfd.getInt32(WASM_POLL_FD_FD_OFFSET, true), + events: pollfd.getInt16(WASM_POLL_FD_EVENTS_OFFSET, true), + nfds: Number(kernelArg(view, 1)), + timeout: Number(kernelArg(view, 2)), + }); + if (attempts.length === 1) { + publishKernelResult(view, -1, EAGAIN); + } else { + pollfd.setInt16(WASM_POLL_FD_REVENTS_OFFSET, 1, true); + publishKernelResult(view, 1, 0); + } + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + writeRequest(harness, ABI_SYSCALLS.Poll, [ + BigInt(replacementPollfds), + 1n, + 0n, + ]); + original.setInt32(WASM_POLL_FD_FD_OFFSET, 99, true); + original.setInt16(WASM_POLL_FD_EVENTS_OFFSET, 8, true); + await retryAfterDefaultDelay(harness); + + expect(attempts).toEqual([ + { fd: 7, events: 1, nfds: 1, timeout: 1_000 }, + { fd: 7, events: 1, nfds: 1, timeout: 1_000 }, + ]); + expect( + new DataView( + harness.processMemory.buffer, + originalPollfds, + STRUCT_SIZE_WASM_POLL_FD, + ).getInt16(WASM_POLL_FD_REVENTS_OFFSET, true), + ).toBe(1); + expect( + new DataView( + harness.processMemory.buffer, + replacementPollfds, + STRUCT_SIZE_WASM_POLL_FD, + ).getInt16(WASM_POLL_FD_REVENTS_OFFSET, true), + ).toBe(0); + expectHostOnlyRetryLifecycle(harness, ABI_SYSCALLS.Poll); + }, + ); + + it.each(WIDTHS)( + "%s retains ppoll's pollfd, timeout, and signal mask values", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const pollfds = 0x1000; + const timeout = 0x1800; + const mask = 0x1900; + const pollfd = new DataView( + harness.processMemory.buffer, + pollfds, + STRUCT_SIZE_WASM_POLL_FD, + ); + pollfd.setInt32(WASM_POLL_FD_FD_OFFSET, 9, true); + pollfd.setInt16(WASM_POLL_FD_EVENTS_OFFSET, 1, true); + const processView = new DataView(harness.processMemory.buffer); + processView.setBigInt64(timeout, 1n, true); + processView.setBigInt64(timeout + 8, 0n, true); + processView.setUint32(mask, 0x11223344, true); + processView.setUint32(mask + 4, 0x55667788, true); + writeRequest(harness, ABI_SYSCALLS.Ppoll, [ + BigInt(pollfds), + 1n, + BigInt(timeout), + BigInt(mask), + BigInt(SIGNAL_MASK_BYTES), + ]); + harness.kernelExports.kernel_get_fd_pipe_idx = vi.fn(() => 19); + + const attempts: Array<{ + fd: number; + timeout: number; + hasMask: number; + maskLow: number; + maskHigh: number; + }> = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + const stagedPollfd = new DataView( + harness.kernelBytes.buffer, + Number(kernelArg(view, 0)), + STRUCT_SIZE_WASM_POLL_FD, + ); + attempts.push({ + fd: stagedPollfd.getInt32(WASM_POLL_FD_FD_OFFSET, true), + timeout: Number(kernelArg(view, 2)), + hasMask: Number(kernelArg(view, 3)), + maskLow: Number(kernelArg(view, 4)), + maskHigh: Number(kernelArg(view, 5)), + }); + publishKernelResult( + view, + attempts.length === 1 ? -1 : 1, + attempts.length === 1 ? EAGAIN : 0, + ); + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + pollfd.setInt32(WASM_POLL_FD_FD_OFFSET, 77, true); + processView.setBigInt64(timeout, 0n, true); + processView.setUint32(mask, 0xaabbccdd, true); + processView.setUint32(mask + 4, 0xeeff0011, true); + writeRequest(harness, ABI_SYSCALLS.Ppoll, [0n, 0n, 0n, 0n, 0n]); + await retryAfterDefaultDelay(harness); + + expect(attempts).toEqual([ + { + fd: 9, + timeout: 1_000, + hasMask: 1, + maskLow: 0x11223344, + maskHigh: 0x55667788, + }, + { + fd: 9, + timeout: 1_000, + hasMask: 1, + maskLow: 0x11223344, + maskHigh: 0x55667788, + }, + ]); + expectHostOnlyRetryLifecycle(harness, ABI_SYSCALLS.Ppoll); + }, + ); + + it.each(WIDTHS)( + "%s retains a blocking connect's fd and sockaddr through EINPROGRESS", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const originalAddress = 0x1000; + const replacementAddress = 0x2000; + const originalBytes = [2, 0, 0, 80, 203, 0, 113, 9]; + const replacementBytes = [2, 0, 1, 187, 198, 51, 100, 7]; + harness.processBytes.set(originalBytes, originalAddress); + harness.processBytes.set(replacementBytes, replacementAddress); + writeRequest(harness, ABI_SYSCALLS.Connect, [ + 7n, + BigInt(originalAddress), + BigInt(originalBytes.length), + ]); + harness.kernelExports.kernel_blocking_retry_token = vi.fn(() => 41n); + + const attempts: Array<{ fd: number; address: number[] }> = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + const addressPointer = Number(kernelArg(view, 1)); + const addressLength = Number(kernelArg(view, 2)); + attempts.push({ + fd: Number(kernelArg(view, 0)), + address: Array.from( + harness.kernelBytes.slice( + addressPointer, + addressPointer + addressLength, + ), + ), + }); + publishKernelResult( + view, + attempts.length === 1 ? -1 : 0, + attempts.length === 1 ? EINPROGRESS : 0, + ); + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + harness.processBytes.fill( + 0xee, + originalAddress, + originalAddress + originalBytes.length, + ); + writeRequest(harness, ABI_SYSCALLS.Connect, [ + 88n, + BigInt(replacementAddress), + BigInt(replacementBytes.length), + ]); + await retryAfterDefaultDelay(harness); + + expect(attempts).toEqual([ + { fd: 7, address: originalBytes }, + { fd: 7, address: originalBytes }, + ]); + expectExactRetryBindingLifecycle( + harness, + ABI_SYSCALLS.Connect, + 41n, + ); + }, + ); + + it.each(WIDTHS)( + "%s retains accept's listener and original peer-address outputs", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const originalAddress = 0x1000; + const originalLength = 0x1100; + const replacementAddress = 0x2000; + const replacementLength = 0x2100; + const processView = new DataView(harness.processMemory.buffer); + processView.setUint32(originalLength, 16, true); + processView.setUint32(replacementLength, 16, true); + writeRequest(harness, ABI_SYSCALLS.Accept, [ + 7n, + BigInt(originalAddress), + BigInt(originalLength), + ]); + harness.kernelExports.kernel_blocking_retry_token = vi.fn(() => 42n); + harness.kernelExports.kernel_get_fd_accept_wake_idx = vi.fn(() => 23); + + const attempts: number[] = []; + const peer = [2, 0, 0, 80, 203, 0, 113, 11]; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + attempts.push(Number(kernelArg(view, 0))); + if (attempts.length === 1) { + publishKernelResult(view, -1, EAGAIN); + } else { + const addressPointer = Number(kernelArg(view, 1)); + const lengthPointer = Number(kernelArg(view, 2)); + harness.kernelBytes.set(peer, addressPointer); + new DataView(harness.kernelBytes.buffer).setUint32( + lengthPointer, + peer.length, + true, + ); + publishKernelResult(view, 12, 0); + } + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + writeRequest(harness, ABI_SYSCALLS.Accept, [ + 88n, + BigInt(replacementAddress), + BigInt(replacementLength), + ]); + processView.setUint32(originalLength, 1, true); + await retryAfterDefaultDelay(harness); + + expect(attempts).toEqual([7, 7]); + expect( + Array.from( + harness.processBytes.slice( + originalAddress, + originalAddress + peer.length, + ), + ), + ).toEqual(peer); + expect(processView.getUint32(originalLength, true)).toBe(peer.length); + expect( + Array.from( + harness.processBytes.slice( + replacementAddress, + replacementAddress + peer.length, + ), + ), + ).toEqual(new Array(peer.length).fill(0)); + expect(processView.getUint32(replacementLength, true)).toBe(16); + expectExactRetryBindingLifecycle( + harness, + ABI_SYSCALLS.Accept, + 42n, + ); + }, + ); + + it.each(WIDTHS)( + "%s retains rt_sigtimedwait's mask, output destination, and timeout", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const mask = 0x1000; + const info = 0x1200; + const timeout = 0x1400; + const replacementInfo = 0x2200; + const processView = new DataView(harness.processMemory.buffer); + processView.setBigUint64(mask, 0x0102030405060708n, true); + processView.setBigInt64(timeout, 5n, true); + processView.setBigInt64(timeout + 8, 0n, true); + writeRequest(harness, ABI_SYSCALLS.RtSigtimedwait, [ + BigInt(mask), + BigInt(info), + BigInt(timeout), + BigInt(SIGNAL_MASK_BYTES), + ]); + + const attempts: Array<{ mask: bigint; timeoutSeconds: bigint }> = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + const stagedMask = new DataView( + harness.kernelBytes.buffer, + Number(kernelArg(view, 0)), + SIGNAL_MASK_BYTES, + ); + const stagedTimeout = new DataView( + harness.kernelBytes.buffer, + Number(kernelArg(view, 2)), + 16, + ); + attempts.push({ + mask: stagedMask.getBigUint64(0, true), + timeoutSeconds: stagedTimeout.getBigInt64(0, true), + }); + if (attempts.length === 1) { + publishKernelResult(view, -1, EAGAIN); + } else { + const infoPointer = Number(kernelArg(view, 1)); + harness.kernelBytes.fill(0x5a, infoPointer, infoPointer + 128); + publishKernelResult(view, 10, 0); + } + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + processView.setBigUint64(mask, 0xffffffffffffffffn, true); + processView.setBigInt64(timeout, 0n, true); + writeRequest(harness, ABI_SYSCALLS.RtSigtimedwait, [ + BigInt(mask), + BigInt(replacementInfo), + 0n, + BigInt(SIGNAL_MASK_BYTES), + ]); + harness.worker.retrySyscall(harness.channel); + await Promise.resolve(); + + expect(attempts).toEqual([ + { mask: 0x0102030405060708n, timeoutSeconds: 5n }, + { mask: 0x0102030405060708n, timeoutSeconds: 5n }, + ]); + expect( + Array.from(harness.processBytes.slice(info, info + 128)), + ).toEqual(new Array(128).fill(0x5a)); + expect( + Array.from( + harness.processBytes.slice(replacementInfo, replacementInfo + 128), + ), + ).toEqual(new Array(128).fill(0)); + expectHostOnlyRetryLifecycle( + harness, + ABI_SYSCALLS.RtSigtimedwait, + ); + }, + ); + + it.each(WIDTHS)( + "%s retains a FIFO open pathname and flags while parked", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const originalPath = 0x1000; + const replacementPath = 0x2000; + const original = Array.from(new TextEncoder().encode("/fifo/original\0")); + const replacement = Array.from( + new TextEncoder().encode("/fifo/replacement\0"), + ); + harness.processBytes.set(original, originalPath); + harness.processBytes.set(replacement, replacementPath); + writeRequest(harness, ABI_SYSCALLS.Open, [ + BigInt(originalPath), + 0n, + 0n, + ]); + + const attempts: Array<{ path: number[]; flags: number }> = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + const pathPointer = Number(kernelArg(view, 0)); + attempts.push({ + path: Array.from( + harness.kernelBytes.slice( + pathPointer, + pathPointer + original.length, + ), + ), + flags: Number(kernelArg(view, 1)), + }); + publishKernelResult( + view, + attempts.length === 1 ? -1 : 14, + attempts.length === 1 ? EAGAIN : 0, + ); + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + harness.processBytes.fill( + 0xee, + originalPath, + originalPath + original.length, + ); + writeRequest(harness, ABI_SYSCALLS.Open, [ + BigInt(replacementPath), + 0x800n, + 0n, + ]); + await retryAfterDefaultDelay(harness); + + expect(attempts).toEqual([ + { path: original, flags: 0 }, + { path: original, flags: 0 }, + ]); + expectHostOnlyRetryLifecycle(harness, ABI_SYSCALLS.Open); + }, + ); + + it.each(WIDTHS)( + "%s retains F_SETLKW's flock wire and acquires an exact target token", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const flockPointer = 0x1000; + const replacementPointer = 0x2000; + const original = Array.from( + { length: FCNTL_FLOCK_BYTES }, + (_, index) => (index * 7 + 3) & 0xff, + ); + const replacement = new Array(FCNTL_FLOCK_BYTES).fill(0xee); + harness.processBytes.set(original, flockPointer); + harness.processBytes.set(replacement, replacementPointer); + writeRequest(harness, ABI_SYSCALLS.Fcntl, [ + 7n, + 7n, + BigInt(flockPointer), + ]); + harness.kernelExports.kernel_blocking_retry_token = vi.fn(() => 43n); + + const attempts: Array<{ fd: number; flock: number[] }> = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + const stagedPointer = Number(kernelArg(view, 2)); + attempts.push({ + fd: Number(kernelArg(view, 0)), + flock: Array.from( + harness.kernelBytes.slice( + stagedPointer, + stagedPointer + FCNTL_FLOCK_BYTES, + ), + ), + }); + publishKernelResult( + view, + attempts.length === 1 ? -1 : 0, + attempts.length === 1 ? EAGAIN : 0, + ); + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + harness.processBytes.fill( + 0xaa, + flockPointer, + flockPointer + FCNTL_FLOCK_BYTES, + ); + writeRequest(harness, ABI_SYSCALLS.Fcntl, [ + 88n, + 7n, + BigInt(replacementPointer), + ]); + await vi.advanceTimersByTimeAsync(10); + await Promise.resolve(); + + expect(attempts).toEqual([ + { fd: 7, flock: original }, + { fd: 7, flock: original }, + ]); + expectExactRetryBindingLifecycle( + harness, + ABI_SYSCALLS.Fcntl, + 43n, + ); + }, + ); + + it.each(WIDTHS)( + "%s retains blocking flock scalars and its exact OFD token", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + writeRequest(harness, ABI_SYSCALLS.Flock, [7n, 2n]); + harness.kernelExports.kernel_blocking_retry_token = vi.fn(() => 44n); + + const attempts: Array<{ fd: number; operation: number }> = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + attempts.push({ + fd: Number(kernelArg(view, 0)), + operation: Number(kernelArg(view, 1)), + }); + publishKernelResult( + view, + attempts.length === 1 ? -1 : 0, + attempts.length === 1 ? EAGAIN : 0, + ); + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + writeRequest(harness, ABI_SYSCALLS.Flock, [88n, 8n]); + await vi.advanceTimersByTimeAsync(10); + await Promise.resolve(); + + expect(attempts).toEqual([ + { fd: 7, operation: 2 }, + { fd: 7, operation: 2 }, + ]); + expectExactRetryBindingLifecycle( + harness, + ABI_SYSCALLS.Flock, + 44n, + ); + }, + ); + + it.each([ + ["wasm32 F_SETLK", 4, 6], + ["wasm64 F_SETLK", 8, 6], + ["wasm32 F_OFD_SETLK", 4, 37], + ["wasm64 F_OFD_SETLK", 8, 37], + ] as const)( + "%s releases its Rust target pin before returning EAGAIN", + (_name, pointerWidth, command) => { + const harness = createRetryHarness(pointerWidth); + const flockPointer = 0x1000; + harness.processBytes.fill( + 0x3c, + flockPointer, + flockPointer + FCNTL_FLOCK_BYTES, + ); + writeRequest(harness, ABI_SYSCALLS.Fcntl, [ + 7n, + BigInt(command), + BigInt(flockPointer), + ]); + harness.kernelExports.kernel_blocking_retry_token = vi.fn(() => 45n); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + publishKernelResult(kernelView(harness, rawPointer), -1, EAGAIN); + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + + expect(requestResult(harness)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: -1, + errno: EAGAIN, + }); + expect(harness.worker.pendingAdvisoryLockRetries.size).toBe(0); + expect( + harness.kernelExports.kernel_blocking_retry_token, + ).toHaveBeenCalledWith( + harness.channel.pid, + harness.channel.pid, + ABI_SYSCALLS.Fcntl, + ); + expect( + harness.kernelExports.kernel_blocking_retry_release, + ).toHaveBeenCalledWith( + harness.channel.pid, + harness.channel.pid, + 45n, + ); + }, + ); + + it.each(WIDTHS)( + "%s retains select fd_sets, timeout, and original output pointers", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const readPointer = 0x1000; + const replacementReadPointer = 0x2000; + const timeoutPointer = 0x1800; + const processView = new DataView(harness.processMemory.buffer); + harness.processBytes[readPointer] = 0x01; + harness.processBytes[replacementReadPointer] = 0x80; + if (pointerWidth === 8) { + processView.setBigInt64(timeoutPointer, 1n, true); + processView.setBigInt64(timeoutPointer + 8, 0n, true); + } else { + processView.setInt32(timeoutPointer, 1, true); + processView.setInt32(timeoutPointer + 4, 0, true); + } + writeRequest(harness, ABI_SYSCALLS.Select, [ + 8n, + BigInt(readPointer), + 0n, + 0n, + BigInt(timeoutPointer), + ]); + + const attempts: Array<{ firstByte: number; timeout: number }> = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + const stagedReadPointer = Number(kernelArg(view, 1)); + attempts.push({ + firstByte: harness.kernelBytes[stagedReadPointer]!, + timeout: Number(kernelArg(view, 4)), + }); + if (attempts.length === 1) { + publishKernelResult(view, -1, EAGAIN); + } else { + harness.kernelBytes[stagedReadPointer] = 0x04; + publishKernelResult(view, 1, 0); + } + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + harness.processBytes[readPointer] = 0xff; + if (pointerWidth === 8) { + processView.setBigInt64(timeoutPointer, 0n, true); + } else { + processView.setInt32(timeoutPointer, 0, true); + } + writeRequest(harness, ABI_SYSCALLS.Select, [ + 64n, + BigInt(replacementReadPointer), + 0n, + 0n, + 0n, + ]); + await vi.advanceTimersByTimeAsync(50); + await Promise.resolve(); + + expect(attempts).toEqual([ + { firstByte: 0x01, timeout: 1_000 }, + { firstByte: 0x01, timeout: 1_000 }, + ]); + expect(harness.processBytes[readPointer]).toBe(0x04); + expect(harness.processBytes[replacementReadPointer]).toBe(0x80); + expectHostOnlyRetryLifecycle(harness, ABI_SYSCALLS.Select); + }, + ); + + it.each(WIDTHS)( + "%s retains pselect6 fd_sets, timeout, mask descriptor, and outputs", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const readPointer = 0x1000; + const replacementReadPointer = 0x2000; + const timeoutPointer = 0x1800; + const maskDescriptorPointer = 0x1900; + const maskPointer = 0x1a00; + const replacementMaskPointer = 0x2a00; + const processView = new DataView(harness.processMemory.buffer); + harness.processBytes[readPointer] = 0x02; + harness.processBytes[replacementReadPointer] = 0x40; + processView.setBigInt64(timeoutPointer, 1n, true); + processView.setBigInt64(timeoutPointer + 8, 0n, true); + processView.setBigUint64(maskPointer, 0x1122334455667788n, true); + processView.setBigUint64( + replacementMaskPointer, + 0xaabbccddeeff0011n, + true, + ); + if (pointerWidth === 8) { + processView.setBigUint64( + maskDescriptorPointer, + BigInt(maskPointer), + true, + ); + processView.setBigUint64( + maskDescriptorPointer + 8, + BigInt(SIGNAL_MASK_BYTES), + true, + ); + } else { + processView.setUint32(maskDescriptorPointer, maskPointer, true); + processView.setUint32( + maskDescriptorPointer + 4, + SIGNAL_MASK_BYTES, + true, + ); + } + writeRequest(harness, ABI_SYSCALLS.Pselect6, [ + 8n, + BigInt(readPointer), + 0n, + 0n, + BigInt(timeoutPointer), + BigInt(maskDescriptorPointer), + ]); + + const attempts: Array<{ + firstByte: number; + timeout: number; + mask: bigint; + }> = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + const stagedReadPointer = Number(kernelArg(view, 1)); + const stagedMaskPointer = Number(kernelArg(view, 5)); + attempts.push({ + firstByte: harness.kernelBytes[stagedReadPointer]!, + timeout: Number(kernelArg(view, 4)), + mask: new DataView( + harness.kernelBytes.buffer, + stagedMaskPointer, + SIGNAL_MASK_BYTES, + ).getBigUint64(0, true), + }); + if (attempts.length === 1) { + publishKernelResult(view, -1, EAGAIN); + } else { + harness.kernelBytes[stagedReadPointer] = 0x08; + publishKernelResult(view, 1, 0); + } + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + harness.processBytes[readPointer] = 0xff; + processView.setBigInt64(timeoutPointer, 0n, true); + processView.setBigUint64(maskPointer, 0xffffffffffffffffn, true); + if (pointerWidth === 8) { + processView.setBigUint64( + maskDescriptorPointer, + BigInt(replacementMaskPointer), + true, + ); + } else { + processView.setUint32( + maskDescriptorPointer, + replacementMaskPointer, + true, + ); + } + writeRequest(harness, ABI_SYSCALLS.Pselect6, [ + 64n, + BigInt(replacementReadPointer), + 0n, + 0n, + 0n, + 0n, + ]); + await vi.advanceTimersByTimeAsync(50); + await Promise.resolve(); + + expect(attempts).toEqual([ + { + firstByte: 0x02, + timeout: 1_000, + mask: 0x1122334455667788n, + }, + { + firstByte: 0x02, + timeout: 1_000, + mask: 0x1122334455667788n, + }, + ]); + expect(harness.processBytes[readPointer]).toBe(0x08); + expect(harness.processBytes[replacementReadPointer]).toBe(0x40); + expectHostOnlyRetryLifecycle(harness, ABI_SYSCALLS.Pselect6); + }, + ); + + it.each(WIDTHS)( + "%s restores ppoll's temporary mask before a zero-time EAGAIN completes", + (_name, pointerWidth) => { + const harness = createRetryHarness(pointerWidth); + const pollfds = 0x1000; + const timeout = 0x1800; + const mask = 0x1900; + const pollfd = new DataView( + harness.processMemory.buffer, + pollfds, + STRUCT_SIZE_WASM_POLL_FD, + ); + pollfd.setInt32(WASM_POLL_FD_FD_OFFSET, 7, true); + pollfd.setInt16(WASM_POLL_FD_EVENTS_OFFSET, 1, true); + new DataView(harness.processMemory.buffer).setBigUint64( + mask, + 0x1122334455667788n, + true, + ); + writeRequest(harness, ABI_SYSCALLS.Ppoll, [ + BigInt(pollfds), + 1n, + BigInt(timeout), + BigInt(mask), + BigInt(SIGNAL_MASK_BYTES), + ]); + + const syscalls: number[] = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + const syscall = view.getUint32(CH_SYSCALL, true); + syscalls.push(syscall); + publishKernelResult( + view, + syscall === ABI_SYSCALLS.Ppoll ? -1 : 0, + syscall === ABI_SYSCALLS.Ppoll ? EAGAIN : 0, + ); + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + + expect(syscalls).toEqual([ + ABI_SYSCALLS.Ppoll, + ABI_SYSCALLS.ThreadCancel, + ]); + expect(requestResult(harness)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: 0, + errno: 0, + }); + expect( + harness.worker.blockingRetrySnapshots.has(harness.channel), + ).toBe(false); + }, + ); + + it.each(WIDTHS)( + "%s restores pselect6's temporary mask before a zero-time EAGAIN completes", + (_name, pointerWidth) => { + const harness = createRetryHarness(pointerWidth); + const readPointer = 0x1000; + const timeoutPointer = 0x1800; + const descriptorPointer = 0x1900; + const maskPointer = 0x1a00; + const processView = new DataView(harness.processMemory.buffer); + harness.processBytes[readPointer] = 1; + processView.setBigUint64(maskPointer, 0x80n, true); + if (pointerWidth === 8) { + processView.setBigUint64( + descriptorPointer, + BigInt(maskPointer), + true, + ); + processView.setBigUint64( + descriptorPointer + 8, + BigInt(SIGNAL_MASK_BYTES), + true, + ); + } else { + processView.setUint32(descriptorPointer, maskPointer, true); + processView.setUint32( + descriptorPointer + 4, + SIGNAL_MASK_BYTES, + true, + ); + } + writeRequest(harness, ABI_SYSCALLS.Pselect6, [ + 8n, + BigInt(readPointer), + 0n, + 0n, + BigInt(timeoutPointer), + BigInt(descriptorPointer), + ]); + + const syscalls: number[] = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + const syscall = view.getUint32(CH_SYSCALL, true); + syscalls.push(syscall); + publishKernelResult( + view, + syscall === ABI_SYSCALLS.Pselect6 ? -1 : 0, + syscall === ABI_SYSCALLS.Pselect6 ? EAGAIN : 0, + ); + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + + expect(syscalls).toEqual([ + ABI_SYSCALLS.Pselect6, + ABI_SYSCALLS.ThreadCancel, + ]); + expect(requestResult(harness)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: 0, + errno: 0, + }); + expect( + harness.worker.blockingRetrySnapshots.has(harness.channel), + ).toBe(false); + }, + ); + + it.each(WIDTHS)( + "%s completes zero-time select without synthetic mask cleanup", + (_name, pointerWidth) => { + const harness = createRetryHarness(pointerWidth); + const readPointer = 0x1000; + const timeoutPointer = 0x1800; + harness.processBytes[readPointer] = 1; + writeRequest(harness, ABI_SYSCALLS.Select, [ + 8n, + BigInt(readPointer), + 0n, + 0n, + BigInt(timeoutPointer), + ]); + + const syscalls: number[] = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + syscalls.push(view.getUint32(CH_SYSCALL, true)); + publishKernelResult(view, -1, EAGAIN); + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + + expect(syscalls).toEqual([ABI_SYSCALLS.Select]); + expect(requestResult(harness)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: 0, + errno: 0, + }); + expect( + harness.worker.blockingRetrySnapshots.has(harness.channel), + ).toBe(false); + }, + ); + + it.each(WIDTHS)( + "%s releases exact retry authority before a first-attempt caught-signal EINTR", + (_name, pointerWidth) => { + const harness = createRetryHarness(pointerWidth); + const destination = 0x1000; + writeRequest(harness, ABI_SYSCALLS.Read, [ + 7n, + BigInt(destination), + 1n, + ]); + harness.kernelExports.kernel_blocking_retry_token = vi.fn(() => 71n); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + publishKernelResult( + kernelView(harness, rawPointer), + -1, + EAGAIN, + ); + return 0; + }, + ); + harness.kernelExports.kernel_dequeue_signal = vi.fn( + ( + _pid: number, + _tid: number, + rawPointer: number | bigint, + ) => writeKernelCaughtSignal(harness, rawPointer), + ); + + harness.worker.handleSyscall(harness.channel); + + expect(requestResult(harness)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: -1, + errno: EINTR, + }); + expect( + harness.kernelExports.kernel_blocking_retry_token, + ).toHaveBeenCalledOnce(); + expect( + harness.kernelExports.kernel_blocking_retry_release, + ).toHaveBeenCalledWith( + harness.channel.pid, + harness.channel.pid, + 71n, + ); + const channelView = new DataView( + harness.processMemory.buffer, + harness.channel.channelOffset, + ); + expect(channelView.getUint32(CH_SIG_SIGNUM, true)).toBe(SIGUSR1); + expect(channelView.getUint32(CH_SIG_FLAGS, true)).toBe(SA_RESTART); + expect(harness.worker.pendingPollRetries.has(harness.channel)).toBe( + false, + ); + }, + ); + + it.each(WIDTHS)( + "%s preserves a replay-dequeued caught signal while releasing the original retry token", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const destination = 0x1000; + writeRequest(harness, ABI_SYSCALLS.Read, [ + 7n, + BigInt(destination), + 1n, + ]); + harness.kernelExports.kernel_blocking_retry_token = vi.fn(() => 72n); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + publishKernelResult( + kernelView(harness, rawPointer), + -1, + EAGAIN, + ); + return 0; + }, + ); + let dequeues = 0; + harness.kernelExports.kernel_dequeue_signal = vi.fn( + ( + _pid: number, + _tid: number, + rawPointer: number | bigint, + ) => { + dequeues++; + return dequeues === 2 + ? writeKernelCaughtSignal(harness, rawPointer) + : 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + expect(requestStatus(harness)).toBe(CHANNEL_STATUS_PENDING); + await retryAfterDefaultDelay(harness); + + expect(requestResult(harness)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: -1, + errno: EINTR, + }); + const channelView = new DataView( + harness.processMemory.buffer, + harness.channel.channelOffset, + ); + expect(channelView.getUint32(CH_SIG_SIGNUM, true)).toBe(SIGUSR1); + expect(channelView.getUint32(CH_SIG_FLAGS, true)).toBe(SA_RESTART); + expectExactRetryBindingLifecycle( + harness, + ABI_SYSCALLS.Read, + 72n, + ); + expect(harness.worker.pendingPollRetries.has(harness.channel)).toBe( + false, + ); + }, + ); + + it.each(WIDTHS)( + "%s ignores guest-forged signal bytes while replaying a blocked read", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const destination = 0x1000; + writeRequest(harness, ABI_SYSCALLS.Read, [ + 7n, + BigInt(destination), + 1n, + ]); + harness.kernelExports.kernel_blocking_retry_token = vi.fn(() => 73n); + let attempts = 0; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + attempts++; + const view = kernelView(harness, rawPointer); + publishKernelResult( + view, + attempts === 1 ? -1 : 1, + attempts === 1 ? EAGAIN : 0, + ); + return 0; + }, + ); + harness.kernelExports.kernel_dequeue_signal = vi.fn(() => 0); + + harness.worker.handleSyscall(harness.channel); + const channelView = new DataView( + harness.processMemory.buffer, + harness.channel.channelOffset, + ); + channelView.setUint32(CH_SIG_SIGNUM, SIGUSR1, true); + channelView.setUint32(CH_SIG_FLAGS, SA_RESTART, true); + await retryAfterDefaultDelay(harness); + + expect(requestResult(harness)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: 1, + errno: 0, + }); + expect(channelView.getUint32(CH_SIG_SIGNUM, true)).toBe(0); + expect(channelView.getUint32(CH_SIG_FLAGS, true)).toBe(0); + expectExactRetryBindingLifecycle( + harness, + ABI_SYSCALLS.Read, + 73n, + ); + }, + ); + + it.each(WIDTHS)( + "%s freezes timed-socket restart policy across a blocked-read replay", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const destination = 0x1000; + writeRequest(harness, ABI_SYSCALLS.Read, [ + 7n, + BigInt(destination), + 1n, + ]); + harness.kernelExports.kernel_blocking_retry_token = vi.fn(() => 74n); + const isFdNonblock = vi.fn(() => 0); + const getTimeout = vi.fn(() => 5_000n); + harness.kernelExports.kernel_is_fd_nonblock = isFdNonblock; + harness.kernelExports.kernel_get_socket_timeout_ms = getTimeout; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + publishKernelResult( + kernelView(harness, rawPointer), + -1, + EAGAIN, + ); + return 0; + }, + ); + let dequeues = 0; + harness.kernelExports.kernel_dequeue_signal = vi.fn( + ( + _pid: number, + _tid: number, + rawPointer: number | bigint, + ) => { + dequeues++; + return dequeues === 2 + ? writeKernelCaughtSignal(harness, rawPointer) + : 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + const firstAttemptTimeoutQueries = getTimeout.mock.calls.length; + expect(isFdNonblock).toHaveBeenCalledOnce(); + expect(firstAttemptTimeoutQueries).toBe(1); + + // Model close/reuse before the retry. The retained policy belongs to + // the original pinned OFD, so the replacement numeric fd's zero timeout + // must not re-enable SA_RESTART. + getTimeout.mockReturnValue(0n); + await retryAfterDefaultDelay(harness); + + expect(requestResult(harness)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: -1, + errno: EINTR, + }); + expect(getTimeout).toHaveBeenCalledTimes( + firstAttemptTimeoutQueries, + ); + expect(isFdNonblock).toHaveBeenCalledOnce(); + const channelView = new DataView( + harness.processMemory.buffer, + harness.channel.channelOffset, + ); + expect(channelView.getUint32(CH_SIG_SIGNUM, true)).toBe(SIGUSR1); + expect(channelView.getUint32(CH_SIG_FLAGS, true)).toBe(0); + expectExactRetryBindingLifecycle( + harness, + ABI_SYSCALLS.Read, + 74n, + ); + }, + ); + + it.each([ + [ + "missing signal-target selector", + (harness: RetryHarness) => { + harness.kernelExports.kernel_pick_signal_target_tid = undefined; + }, + "kernel export kernel_pick_signal_target_tid failed", + ], + [ + "throwing signal-target selector", + (harness: RetryHarness) => { + harness.kernelExports.kernel_pick_signal_target_tid = vi.fn( + () => { + throw new Error("selector trap"); + }, + ); + }, + "kernel export kernel_pick_signal_target_tid failed", + ], + [ + "negative signal-target selector result", + (harness: RetryHarness) => { + harness.kernelExports.kernel_pick_signal_target_tid = vi.fn( + () => -1, + ); + }, + "kernel returned invalid signal target TID -1", + ], + [ + "missing deliverable-signal query", + (harness: RetryHarness) => { + harness.kernelExports.kernel_thread_has_deliverable = undefined; + }, + "kernel export kernel_thread_has_deliverable failed", + ], + [ + "throwing deliverable-signal query", + (harness: RetryHarness) => { + harness.kernelExports.kernel_thread_has_deliverable = vi.fn( + () => { + throw new Error("deliverability trap"); + }, + ); + }, + "kernel export kernel_thread_has_deliverable failed", + ], + [ + "invalid deliverable-signal query result", + (harness: RetryHarness) => { + harness.kernelExports.kernel_thread_has_deliverable = vi.fn( + () => 2, + ); + }, + "kernel returned invalid deliverable-signal state 2", + ], + [ + "negative deliverable-signal query result", + (harness: RetryHarness) => { + harness.kernelExports.kernel_thread_has_deliverable = vi.fn( + () => -1, + ); + }, + "kernel returned invalid deliverable-signal state -1", + ], + ] as const)( + "fails the kernel generation for a %s", + async (_description, configure, expectedMessage) => { + const harness = createRetryHarness(4); + configure(harness); + + expect(() => { + harness.worker.testAuthority.sendSignalForTest( + harness.channel.pid, + SIGUSR1, + ); + }).toThrow(expectedMessage); + await Promise.resolve(); + + expect(harness.onKernelFatal).toHaveBeenCalledOnce(); + }, + ); + + it.each([ + [ + "SYS_KILL missing signal-target selector", + ABI_SYSCALLS.Kill, + (harness: RetryHarness) => { + harness.kernelExports.kernel_pick_signal_target_tid = undefined; + }, + "kernel export kernel_pick_signal_target_tid failed", + ], + [ + "SYS_KILL negative signal-target selector", + ABI_SYSCALLS.Kill, + (harness: RetryHarness) => { + harness.kernelExports.kernel_pick_signal_target_tid = vi.fn( + () => -1, + ); + }, + "kernel returned invalid signal target TID -1", + ], + [ + "SYS_TKILL missing deliverable-signal query", + ABI_SYSCALLS.Tkill, + (harness: RetryHarness) => { + harness.kernelExports.kernel_thread_has_deliverable = undefined; + }, + "kernel export kernel_thread_has_deliverable failed", + ], + [ + "SYS_TKILL invalid deliverable-signal query", + ABI_SYSCALLS.Tkill, + (harness: RetryHarness) => { + harness.kernelExports.kernel_thread_has_deliverable = vi.fn( + () => 2, + ); + }, + "kernel returned invalid deliverable-signal state 2", + ], + ] as const)( + "fails the kernel generation for guest %s", + async (_description, syscall, configure, expectedMessage) => { + const harness = createRetryHarness(4); + configure(harness); + writeRequest(harness, syscall, [ + BigInt(harness.channel.pid), + BigInt(SIGUSR1), + ]); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + publishKernelResult(kernelView(harness, rawPointer), 0, 0); + return 0; + }, + ); + + expect(() => harness.worker.handleSyscall(harness.channel)).toThrow( + expectedMessage, + ); + await Promise.resolve(); + + expect(harness.onKernelFatal).toHaveBeenCalledOnce(); + expect(requestStatus(harness)).toBe(CHANNEL_STATUS_PENDING); + }, + ); + + it.each(WIDTHS)( + "%s interrupts only the exact caught-signal futex target", + async (_name, pointerWidth) => { + const harness = createRetryHarness(pointerWidth, { + channelOffsets: [4 * 65_536, 6 * 65_536], + }); + const [mainChannel, threadChannel] = harness.channels; + const threadTid = 77; + harness.worker.channelTids.set( + `${threadChannel.pid}:${threadChannel.channelOffset}`, + threadTid, + ); + const mainFutex = 0x1000; + const threadFutex = 0x1100; + new Int32Array(harness.processMemory.buffer)[mainFutex / 4] = 0; + new Int32Array(harness.processMemory.buffer)[threadFutex / 4] = 0; + writeRequest( + harness, + ABI_SYSCALLS.Futex, + [BigInt(mainFutex), 0n, 0n, 0n, 0n, 0n], + mainChannel, + ); + writeRequest( + harness, + ABI_SYSCALLS.Futex, + [BigInt(threadFutex), 0n, 0n, 0n, 0n, 0n], + threadChannel, + ); + harness.kernelExports.kernel_pick_signal_target_tid = vi.fn( + () => threadTid, + ); + harness.kernelExports.kernel_thread_has_deliverable = vi.fn(() => 1); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + publishKernelResult(kernelView(harness, rawPointer), 0, 0); + return 0; + }, + ); + harness.kernelExports.kernel_dequeue_signal = vi.fn( + ( + _pid: number, + _tid: number, + rawPointer: number | bigint, + ) => writeKernelCaughtSignal(harness, rawPointer), + ); + + harness.worker.handleSyscall(mainChannel); + harness.worker.handleSyscall(threadChannel); + expect(harness.worker.pendingFutexWaits.has(mainChannel)).toBe(true); + expect(harness.worker.pendingFutexWaits.has(threadChannel)).toBe(true); + + harness.worker.testAuthority.sendSignalForTest( + harness.channel.pid, + SIGUSR1, + ); + await Promise.resolve(); + await Promise.resolve(); + + expect(requestStatus(harness, mainChannel)).toBe( + CHANNEL_STATUS_PENDING, + ); + expect(requestResult(harness, threadChannel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: -EINTR, + errno: EINTR, + }); + expect(harness.worker.pendingFutexWaits.has(mainChannel)).toBe(true); + expect(harness.worker.pendingFutexWaits.has(threadChannel)).toBe(false); + expect( + harness.kernelExports.kernel_dequeue_signal, + ).toHaveBeenCalledWith( + harness.channel.pid, + threadTid, + expect.anything(), + expect.any(Number), + ); + const threadView = new DataView( + harness.processMemory.buffer, + threadChannel.channelOffset, + ); + expect(threadView.getUint32(CH_SIG_SIGNUM, true)).toBe(SIGUSR1); + expect(threadView.getUint32(CH_SIG_FLAGS, true)).toBe(SA_RESTART); + + harness.worker.removeChannel( + mainChannel.pid, + mainChannel.channelOffset, + ); + }, + ); + + it.each([ + ["wasm32 SYS_TKILL", 4, ABI_SYSCALLS.Tkill], + ["wasm64 SYS_TKILL", 8, ABI_SYSCALLS.Tkill], + ["wasm32 SYS_KILL", 4, ABI_SYSCALLS.Kill], + ["wasm64 SYS_KILL", 8, ABI_SYSCALLS.Kill], + ] as const)( + "%s interrupts the exact pending futex after successful guest dispatch", + async (_name, pointerWidth, signalSyscall) => { + const harness = createRetryHarness(pointerWidth, { + channelOffsets: [4 * 65_536, 6 * 65_536], + }); + const [senderChannel, targetChannel] = harness.channels; + const targetTid = 77; + harness.worker.channelTids.set( + `${targetChannel.pid}:${targetChannel.channelOffset}`, + targetTid, + ); + const futexPointer = 0x1000; + new Int32Array(harness.processMemory.buffer)[futexPointer / 4] = 0; + writeRequest( + harness, + ABI_SYSCALLS.Futex, + [BigInt(futexPointer), 0n, 0n, 0n, 0n, 0n], + targetChannel, + ); + harness.kernelExports.kernel_pick_signal_target_tid = vi.fn( + () => targetTid, + ); + harness.kernelExports.kernel_thread_has_deliverable = vi.fn( + (_pid: number, tid: number) => tid === targetTid ? 1 : 0, + ); + harness.kernelExports.kernel_dequeue_signal = vi.fn( + ( + _pid: number, + tid: number, + rawPointer: number | bigint, + ) => tid === targetTid + ? writeKernelCaughtSignal(harness, rawPointer) + : 0, + ); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + publishKernelResult(kernelView(harness, rawPointer), 0, 0); + return 0; + }, + ); + + harness.worker.handleSyscall(targetChannel); + expect(harness.worker.pendingFutexWaits.has(targetChannel)).toBe(true); + const signalArgs = signalSyscall === ABI_SYSCALLS.Tkill + ? [BigInt(targetTid), BigInt(SIGUSR1)] + : [BigInt(senderChannel.pid), BigInt(SIGUSR1)]; + writeRequest(harness, signalSyscall, signalArgs, senderChannel); + harness.worker.handleSyscall(senderChannel); + // Target publication enters only after the sender's kernel-entry token + // is revoked; the signal dequeue itself still happened synchronously + // under the authoritative sender transition. + await Promise.resolve(); + await Promise.resolve(); + + expect(requestResult(harness, senderChannel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: 0, + errno: 0, + }); + expect( + harness.kernelExports.kernel_thread_has_deliverable, + ).toHaveBeenCalledWith(senderChannel.pid, targetTid); + expect(requestResult(harness, targetChannel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: -EINTR, + errno: EINTR, + }); + expect(harness.worker.pendingFutexWaits.has(targetChannel)).toBe(false); + expect( + harness.kernelExports.kernel_dequeue_signal, + ).toHaveBeenCalledWith( + senderChannel.pid, + targetTid, + expect.anything(), + expect.any(Number), + ); + const targetView = new DataView( + harness.processMemory.buffer, + targetChannel.channelOffset, + ); + expect(targetView.getUint32(CH_SIG_SIGNUM, true)).toBe(SIGUSR1); + expect(targetView.getUint32(CH_SIG_FLAGS, true)).toBe(SA_RESTART); + }, + ); + + it.each(WIDTHS)( + "%s clears effective SA_RESTART when a finite futex wait is interrupted", + async (_name, pointerWidth) => { + const harness = createRetryHarness(pointerWidth); + const futexPointer = 0x1000; + const timeoutPointer = 0x1800; + new Int32Array(harness.processMemory.buffer)[futexPointer / 4] = 0; + const processView = new DataView(harness.processMemory.buffer); + processView.setBigInt64(timeoutPointer, 10n, true); + processView.setBigInt64(timeoutPointer + 8, 0n, true); + writeRequest(harness, ABI_SYSCALLS.Futex, [ + BigInt(futexPointer), + 0n, + 0n, + BigInt(timeoutPointer), + 0n, + 0n, + ]); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + publishKernelResult(kernelView(harness, rawPointer), 0, 0); + return 0; + }, + ); + harness.kernelExports.kernel_dequeue_signal = vi.fn( + ( + _pid: number, + _tid: number, + rawPointer: number | bigint, + ) => writeKernelCaughtSignal(harness, rawPointer), + ); + + harness.worker.handleSyscall(harness.channel); + expect( + harness.worker.pendingFutexWaits.get(harness.channel)?.hasTimeout, + ).toBe(true); + harness.worker.testAuthority.sendSignalForTest( + harness.channel.pid, + SIGUSR1, + ); + await Promise.resolve(); + await Promise.resolve(); + + expect(requestResult(harness)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: -EINTR, + errno: EINTR, + }); + const channelView = new DataView( + harness.processMemory.buffer, + harness.channel.channelOffset, + ); + expect(channelView.getUint32(CH_SIG_SIGNUM, true)).toBe(SIGUSR1); + expect(channelView.getUint32(CH_SIG_FLAGS, true)).toBe(0); + }, + ); + + it.each(WIDTHS)( + "%s consumes a pending cancel immediately before every host wait registry", + (_name, pointerWidth) => { + vi.useFakeTimers(); + + const exactCleanupCalls = new WeakMap(); + const publishRetryOrExactCleanup = ( + harness: RetryHarness, + rawPointer: number | bigint, + retryReturnValue: number, + retryErrno: number, + ): number => { + const view = kernelView(harness, rawPointer); + const syscall = view.getUint32(CH_SYSCALL, true); + if (syscall === ABI_SYSCALLS.ThreadCancel) { + exactCleanupCalls.set( + harness, + (exactCleanupCalls.get(harness) ?? 0) + 1, + ); + publishKernelResult(view, 0, 0); + } else { + publishKernelResult(view, retryReturnValue, retryErrno); + } + return 0; + }; + const expectInterrupted = ( + harness: RetryHarness, + label: string, + ): void => { + expect(requestResult(harness), label).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: -EINTR, + errno: EINTR, + }); + expect( + harness.worker.pendingCancels.has(harness.channel), + `${label} pending marker`, + ).toBe(false); + expect( + new DataView( + harness.processMemory.buffer, + harness.channel.channelOffset, + ).getUint32(CH_REQUEST_FLAGS, true), + `${label} request flags`, + ).toBe(0); + expect( + exactCleanupCalls.get(harness), + `${label} exact live-task cleanup`, + ).toBe(1); + }; + const armCancellationPoint = ( + harness: RetryHarness, + syscall: number, + args: readonly bigint[], + ): void => { + writeRequest( + harness, + syscall, + args, + harness.channel, + true, + ); + harness.worker.pendingCancels.add(harness.channel); + }; + + { + const harness = createRetryHarness(pointerWidth); + const requestPointer = 0x1000; + const view = new DataView(harness.processMemory.buffer); + view.setBigInt64(requestPointer, 5n, true); + view.setBigInt64(requestPointer + 8, 0n, true); + armCancellationPoint(harness, ABI_SYSCALLS.Nanosleep, [ + BigInt(requestPointer), + 0n, + ]); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => + publishRetryOrExactCleanup(harness, rawPointer, 0, 0), + ); + + harness.worker.handleSyscall(harness.channel); + + expectInterrupted(harness, "sleep"); + expect(harness.worker.pendingSleeps.has(harness.channel)).toBe(false); + } + + { + const harness = createRetryHarness(pointerWidth); + const maskPointer = 0x1000; + const infoPointer = 0x1200; + const timeoutPointer = 0x1400; + const view = new DataView(harness.processMemory.buffer); + view.setBigUint64(maskPointer, 1n, true); + view.setBigInt64(timeoutPointer, 5n, true); + view.setBigInt64(timeoutPointer + 8, 0n, true); + armCancellationPoint(harness, ABI_SYSCALLS.RtSigtimedwait, [ + BigInt(maskPointer), + BigInt(infoPointer), + BigInt(timeoutPointer), + BigInt(SIGNAL_MASK_BYTES), + ]); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => + publishRetryOrExactCleanup(harness, rawPointer, -1, EAGAIN), + ); + + harness.worker.handleSyscall(harness.channel); + + expectInterrupted(harness, "sigtimedwait"); + const key = + `${harness.channel.pid}:${harness.channel.channelOffset}`; + expect(harness.worker.pendingSignalWaits.has(key)).toBe(false); + expect(harness.worker.signalWaitDeadlines.has(key)).toBe(false); + } + + { + const harness = createRetryHarness(pointerWidth); + armCancellationPoint(harness, ABI_SYSCALLS.Poll, [ + 0n, + 0n, + 5_000n, + ]); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => + publishRetryOrExactCleanup(harness, rawPointer, -1, EAGAIN), + ); + + harness.worker.handleSyscall(harness.channel); + + expectInterrupted(harness, "poll"); + expect( + harness.worker.pendingPollRetries.has(harness.channel), + ).toBe(false); + } + + { + const harness = createRetryHarness(pointerWidth); + const timeoutPointer = 0x1800; + const view = new DataView(harness.processMemory.buffer); + if (pointerWidth === 8) { + view.setBigInt64(timeoutPointer, 5n, true); + view.setBigInt64(timeoutPointer + 8, 0n, true); + } else { + view.setInt32(timeoutPointer, 5, true); + view.setInt32(timeoutPointer + 4, 0, true); + } + armCancellationPoint(harness, ABI_SYSCALLS.Select, [ + 0n, + 0n, + 0n, + 0n, + BigInt(timeoutPointer), + ]); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => + publishRetryOrExactCleanup(harness, rawPointer, 0, 0), + ); + + harness.worker.handleSyscall(harness.channel); + + expectInterrupted(harness, "select"); + expect( + harness.worker.pendingSelectRetries.has(harness.channel), + ).toBe(false); + } + + { + const harness = createRetryHarness(pointerWidth); + armCancellationPoint( + harness, + ABI_SYSCALLS.Flock, + [7n, 2n], + ); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => + publishRetryOrExactCleanup(harness, rawPointer, -1, EAGAIN), + ); + + harness.worker.handleSyscall(harness.channel); + + expectInterrupted(harness, "advisory lock"); + expect( + harness.worker.pendingAdvisoryLockRetries.has(harness.channel), + ).toBe(false); + } + + { + const harness = createRetryHarness(pointerWidth); + const outputPointer = 0x1000; + armCancellationPoint(harness, ABI_SYSCALLS.Read, [ + 7n, + BigInt(outputPointer), + 1n, + ]); + harness.kernelExports.kernel_get_fd_pipe_idx = vi.fn(() => 31); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => + publishRetryOrExactCleanup(harness, rawPointer, -1, EAGAIN), + ); + + harness.worker.handleSyscall(harness.channel); + + expectInterrupted(harness, "pipe reader"); + expect(harness.worker.pendingPipeReaders.size).toBe(0); + } + + { + const harness = createRetryHarness(pointerWidth); + const pathPointer = 0x1000; + harness.processBytes.set( + new TextEncoder().encode("/fifo/pre-cancel\0"), + pathPointer, + ); + armCancellationPoint(harness, ABI_SYSCALLS.Open, [ + BigInt(pathPointer), + 0n, + 0n, + ]); + const syscalls: number[] = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + const syscall = view.getUint32(CH_SYSCALL, true); + syscalls.push(syscall); + if (syscall === ABI_SYSCALLS.ThreadCancel) { + exactCleanupCalls.set( + harness, + (exactCleanupCalls.get(harness) ?? 0) + 1, + ); + } + publishKernelResult( + view, + syscall === ABI_SYSCALLS.Open ? -1 : 0, + syscall === ABI_SYSCALLS.Open ? EAGAIN : 0, + ); + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + + expectInterrupted(harness, "FIFO open"); + expect( + harness.worker.pendingPollRetries.has(harness.channel), + ).toBe(false); + expect(syscalls).toEqual([ + ABI_SYSCALLS.Open, + ABI_SYSCALLS.ThreadCancel, + ]); + } + + { + const harness = createRetryHarness(pointerWidth); + const futexPointer = 0x1000; + new Int32Array( + harness.processMemory.buffer, + )[futexPointer >>> 2] = 0; + armCancellationPoint(harness, ABI_SYSCALLS.Futex, [ + BigInt(futexPointer), + 0n, + 0n, + 0n, + 0n, + 0n, + ]); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => + publishRetryOrExactCleanup(harness, rawPointer, 0, 0), + ); + + harness.worker.handleSyscall(harness.channel); + + expectInterrupted(harness, "futex"); + expect( + harness.worker.pendingFutexWaits.has(harness.channel), + ).toBe(false); + } + }, + ); + + + it.each(WIDTHS)( + "%s preserves disabled sleep and poll registrations while cancellation stays pending", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + + { + const harness = createRetryHarness(pointerWidth, { + channelOffsets: [4 * 65_536, 6 * 65_536], + }); + const [callerChannel, targetChannel] = harness.channels; + const targetTid = 81; + harness.worker.channelTids.set( + `${targetChannel.pid}:${targetChannel.channelOffset}`, + targetTid, + ); + const requestPointer = 0x1000; + const processView = new DataView(harness.processMemory.buffer); + processView.setBigInt64(requestPointer, 5n, true); + processView.setBigInt64(requestPointer + 8, 0n, true); + writeRequest( + harness, + ABI_SYSCALLS.Nanosleep, + [BigInt(requestPointer), 0n], + targetChannel, + true, + false, + ); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + publishKernelResult(kernelView(harness, rawPointer), 0, 0); + return 0; + }, + ); + + harness.worker.handleSyscall(targetChannel); + const originalSleep = harness.worker.pendingSleeps.get(targetChannel); + expect(originalSleep).toMatchObject({ + cancellationPoint: true, + cancellationWakeAllowed: false, + }); + + writeRequest( + harness, + ABI_SYSCALLS.ThreadCancel, + [BigInt(targetTid)], + callerChannel, + ); + harness.worker.handleSyscall(callerChannel); + + expect(harness.worker.pendingSleeps.get(targetChannel)).toBe( + originalSleep, + ); + expect(harness.worker.pendingCancels.has(targetChannel)).toBe(true); + expect(requestStatus(harness, targetChannel)).toBe( + CHANNEL_STATUS_PENDING, + ); + await vi.advanceTimersByTimeAsync(4_999); + expect(requestStatus(harness, targetChannel)).toBe( + CHANNEL_STATUS_PENDING, + ); + await vi.advanceTimersByTimeAsync(1); + expect(requestResult(harness, targetChannel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: 0, + errno: 0, + }); + expect(harness.worker.pendingCancels.has(targetChannel)).toBe(true); + } + + { + const harness = createRetryHarness(pointerWidth, { + channelOffsets: [4 * 65_536, 6 * 65_536], + }); + const [callerChannel, targetChannel] = harness.channels; + const targetTid = 82; + harness.worker.channelTids.set( + `${targetChannel.pid}:${targetChannel.channelOffset}`, + targetTid, + ); + writeRequest( + harness, + ABI_SYSCALLS.Poll, + [0n, 0n, 5_000n], + targetChannel, + true, + false, + ); + let pollAttempts = 0; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + const syscall = view.getUint32(CH_SYSCALL, true); + if (syscall === ABI_SYSCALLS.Poll) { + pollAttempts++; + publishKernelResult( + view, + pollAttempts === 1 ? -1 : 0, + pollAttempts === 1 ? EAGAIN : 0, + ); + } else { + publishKernelResult(view, 0, 0); + } + return 0; + }, + ); + + harness.worker.handleSyscall(targetChannel); + const originalPoll = + harness.worker.pendingPollRetries.get(targetChannel); + expect(originalPoll).toMatchObject({ + cancellationPoint: true, + cancellationWakeAllowed: false, + }); + const originalDeadline = originalPoll.deadline; + + writeRequest( + harness, + ABI_SYSCALLS.ThreadCancel, + [BigInt(targetTid)], + callerChannel, + ); + harness.worker.handleSyscall(callerChannel); + + expect(harness.worker.pendingPollRetries.get(targetChannel)).toBe( + originalPoll, + ); + expect( + harness.worker.pendingPollRetries.get(targetChannel)?.deadline, + ).toBe(originalDeadline); + expect(harness.worker.pendingCancels.has(targetChannel)).toBe(true); + expect(requestStatus(harness, targetChannel)).toBe( + CHANNEL_STATUS_PENDING, + ); + + await vi.advanceTimersByTimeAsync(4_999); + expect(requestStatus(harness, targetChannel)).toBe( + CHANNEL_STATUS_PENDING, + ); + await vi.advanceTimersByTimeAsync(1); + await Promise.resolve(); + expect(requestResult(harness, targetChannel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: 0, + errno: 0, + }); + expect(pollAttempts).toBe(2); + expect(harness.worker.pendingCancels.has(targetChannel)).toBe(true); + } + }, + ); + + it.each(WIDTHS)( + "%s cancellation retires an exact sleep before mailbox reuse", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth, { + channelOffsets: [4 * 65_536, 6 * 65_536], + }); + const [callerChannel, targetChannel] = harness.channels; + const targetTid = 77; + harness.worker.channelTids.set( + `${targetChannel.pid}:${targetChannel.channelOffset}`, + targetTid, + ); + const requestPointer = 0x1000; + const remainderPointer = 0x1100; + const processView = new DataView(harness.processMemory.buffer); + processView.setBigInt64(requestPointer, 5n, true); + processView.setBigInt64(requestPointer + 8, 0n, true); + harness.processBytes.fill( + 0x5a, + remainderPointer, + remainderPointer + 16, + ); + writeRequest( + harness, + ABI_SYSCALLS.Nanosleep, + [BigInt(requestPointer), BigInt(remainderPointer)], + targetChannel, + true, + ); + const syscalls: number[] = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + syscalls.push(view.getUint32(CH_SYSCALL, true)); + publishKernelResult(view, 0, 0); + return 0; + }, + ); + + harness.worker.handleSyscall(targetChannel); + expect(harness.worker.pendingSleeps.has(targetChannel)).toBe(true); + + writeRequest( + harness, + ABI_SYSCALLS.ThreadCancel, + [BigInt(targetTid)], + callerChannel, + ); + harness.worker.handleSyscall(callerChannel); + + expect(requestResult(harness, callerChannel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: 0, + errno: 0, + }); + expect(requestResult(harness, targetChannel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: -EINTR, + errno: EINTR, + }); + expect(harness.worker.pendingSleeps.has(targetChannel)).toBe(false); + expect(harness.worker.pendingCancels.has(targetChannel)).toBe(false); + expect( + Array.from( + harness.processBytes.slice( + remainderPointer, + remainderPointer + 16, + ), + ), + ).toEqual(new Array(16).fill(0x5a)); + expect(syscalls).toEqual([ + ABI_SYSCALLS.Nanosleep, + ABI_SYSCALLS.ThreadCancel, + ]); + + writeRequest( + harness, + ABI_SYSCALLS.Getpid, + [], + targetChannel, + ); + await vi.advanceTimersByTimeAsync(5_000); + await Promise.resolve(); + expect(requestStatus(harness, targetChannel)).toBe( + CHANNEL_STATUS_PENDING, + ); + }, + ); + + it.each(WIDTHS)( + "%s cancellation retires an exact sigtimedwait deadline before mailbox reuse", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth, { + channelOffsets: [4 * 65_536, 6 * 65_536], + }); + const [callerChannel, targetChannel] = harness.channels; + const targetTid = 78; + harness.worker.channelTids.set( + `${targetChannel.pid}:${targetChannel.channelOffset}`, + targetTid, + ); + const maskPointer = 0x1000; + const infoPointer = 0x1200; + const timeoutPointer = 0x1400; + const processView = new DataView(harness.processMemory.buffer); + processView.setBigUint64(maskPointer, 1n, true); + processView.setBigInt64(timeoutPointer, 5n, true); + processView.setBigInt64(timeoutPointer + 8, 0n, true); + harness.processBytes.fill(0x6b, infoPointer, infoPointer + 128); + writeRequest( + harness, + ABI_SYSCALLS.RtSigtimedwait, + [ + BigInt(maskPointer), + BigInt(infoPointer), + BigInt(timeoutPointer), + BigInt(SIGNAL_MASK_BYTES), + ], + targetChannel, + true, + ); + const syscalls: number[] = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + const syscall = view.getUint32(CH_SYSCALL, true); + syscalls.push(syscall); + publishKernelResult( + view, + syscall === ABI_SYSCALLS.RtSigtimedwait ? -1 : 0, + syscall === ABI_SYSCALLS.RtSigtimedwait ? EAGAIN : 0, + ); + return 0; + }, + ); + + harness.worker.handleSyscall(targetChannel); + const signalWaitKey = + `${targetChannel.pid}:${targetChannel.channelOffset}`; + expect(harness.worker.pendingSignalWaits.has(signalWaitKey)).toBe(true); + expect(harness.worker.signalWaitDeadlines.has(signalWaitKey)).toBe(true); + + writeRequest( + harness, + ABI_SYSCALLS.ThreadCancel, + [BigInt(targetTid)], + callerChannel, + ); + harness.worker.handleSyscall(callerChannel); + + expect(requestResult(harness, callerChannel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: 0, + errno: 0, + }); + expect(requestResult(harness, targetChannel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: -EINTR, + errno: EINTR, + }); + expect(harness.worker.pendingSignalWaits.has(signalWaitKey)).toBe(false); + expect(harness.worker.signalWaitDeadlines.has(signalWaitKey)).toBe(false); + expect(harness.worker.pendingCancels.has(targetChannel)).toBe(false); + expect( + Array.from( + harness.processBytes.slice(infoPointer, infoPointer + 128), + ), + ).toEqual(new Array(128).fill(0x6b)); + expect( + harness.worker.blockingRetrySnapshots.has(targetChannel), + ).toBe(false); + expect(syscalls).toEqual([ + ABI_SYSCALLS.RtSigtimedwait, + ABI_SYSCALLS.ThreadCancel, + ]); + + writeRequest( + harness, + ABI_SYSCALLS.Getpid, + [], + targetChannel, + ); + await vi.advanceTimersByTimeAsync(5_000); + await Promise.resolve(); + expect(requestStatus(harness, targetChannel)).toBe( + CHANNEL_STATUS_PENDING, + ); + }, + ); + + it.each(WIDTHS)( + "%s cancels an exact FIFO reservation before terminal retry-preflight EIO", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const pathPointer = 0x1000; + harness.processBytes.set( + new TextEncoder().encode("/fifo/preflight\0"), + pathPointer, + ); + writeRequest(harness, ABI_SYSCALLS.Open, [BigInt(pathPointer), 0n, 0n]); + + const syscalls: number[] = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + const syscall = view.getUint32(CH_SYSCALL, true); + syscalls.push(syscall); + publishKernelResult( + view, + syscall === ABI_SYSCALLS.Open ? -1 : 0, + syscall === ABI_SYSCALLS.Open ? EAGAIN : 0, + ); + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + const snapshot = harness.worker.blockingRetrySnapshots.get( + harness.channel, + ); + const firstWrite = snapshot.dispatch.plannedScratchWrites[0]; + harness.worker.blockingRetrySnapshots.set(harness.channel, { + ...snapshot, + dispatch: { + ...snapshot.dispatch, + plannedScratchWrites: [{ + ...firstWrite, + inputBytes: new Uint8Array(0), + }], + }, + }); + writeRequest(harness, ABI_SYSCALLS.Getpid, []); + await vi.advanceTimersByTimeAsync(10); + await Promise.resolve(); + + expect(syscalls).toEqual([ + ABI_SYSCALLS.Open, + ABI_SYSCALLS.ThreadCancel, + ]); + expect(requestResult(harness)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: -1, + errno: 5, + }); + expect( + harness.worker.blockingRetrySnapshots.has(harness.channel), + ).toBe(false); + }, + ); + + it.each(WIDTHS)( + "%s fails the kernel generation when FIFO retry cleanup cannot be proven", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const pathPointer = 0x1000; + harness.processBytes.set( + new TextEncoder().encode("/fifo/cleanup-failure\0"), + pathPointer, + ); + writeRequest(harness, ABI_SYSCALLS.Open, [BigInt(pathPointer), 0n, 0n]); + + const syscalls: number[] = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + const syscall = view.getUint32(CH_SYSCALL, true); + syscalls.push(syscall); + publishKernelResult( + view, + -1, + syscall === ABI_SYSCALLS.Open ? EAGAIN : 5, + ); + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + const snapshot = harness.worker.blockingRetrySnapshots.get( + harness.channel, + ); + const firstWrite = snapshot.dispatch.plannedScratchWrites[0]; + harness.worker.blockingRetrySnapshots.set(harness.channel, { + ...snapshot, + dispatch: { + ...snapshot.dispatch, + plannedScratchWrites: [{ + ...firstWrite, + inputBytes: new Uint8Array(0), + }], + }, + }); + // A poisoned generation must not publish a synthetic EIO into whatever + // request now occupies the guest-controlled mailbox. + writeRequest(harness, ABI_SYSCALLS.Getpid, []); + + await expect(retryAfterDefaultDelay(harness)).rejects.toThrow( + "kernel wait cleanup failed", + ); + await Promise.resolve(); + + expect(syscalls).toEqual([ + ABI_SYSCALLS.Open, + ABI_SYSCALLS.ThreadCancel, + ]); + expect(harness.onKernelFatal).toHaveBeenCalledOnce(); + expect(requestStatus(harness)).toBe(CHANNEL_STATUS_PENDING); + expect( + harness.worker.blockingRetrySnapshots.has(harness.channel), + ).toBe(false); + }, + ); + + it.each(WIDTHS)( + "%s restores pselect6's temporary mask before terminal retry-preflight EIO", + async (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = createRetryHarness(pointerWidth); + const readPointer = 0x1000; + const timeoutPointer = 0x1800; + const descriptorPointer = 0x1900; + const maskPointer = 0x1a00; + const processView = new DataView(harness.processMemory.buffer); + harness.processBytes[readPointer] = 1; + processView.setBigInt64(timeoutPointer, 1n, true); + processView.setBigInt64(timeoutPointer + 8, 0n, true); + processView.setBigUint64(maskPointer, 0x80n, true); + if (pointerWidth === 8) { + processView.setBigUint64( + descriptorPointer, + BigInt(maskPointer), + true, + ); + processView.setBigUint64( + descriptorPointer + 8, + BigInt(SIGNAL_MASK_BYTES), + true, + ); + } else { + processView.setUint32(descriptorPointer, maskPointer, true); + processView.setUint32( + descriptorPointer + 4, + SIGNAL_MASK_BYTES, + true, + ); + } + writeRequest(harness, ABI_SYSCALLS.Pselect6, [ + 8n, + BigInt(readPointer), + 0n, + 0n, + BigInt(timeoutPointer), + BigInt(descriptorPointer), + ]); + + const syscalls: number[] = []; + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + const view = kernelView(harness, rawPointer); + const syscall = view.getUint32(CH_SYSCALL, true); + syscalls.push(syscall); + publishKernelResult( + view, + syscall === ABI_SYSCALLS.Pselect6 ? -1 : 0, + syscall === ABI_SYSCALLS.Pselect6 ? EAGAIN : 0, + ); + return 0; + }, + ); + + harness.worker.handleSyscall(harness.channel); + const snapshot = harness.worker.blockingRetrySnapshots.get( + harness.channel, + ); + harness.worker.blockingRetrySnapshots.set(harness.channel, { + ...snapshot, + readBytes: new Uint8Array(0), + }); + await vi.advanceTimersByTimeAsync(50); + await Promise.resolve(); + + expect(syscalls).toEqual([ + ABI_SYSCALLS.Pselect6, + ABI_SYSCALLS.ThreadCancel, + ]); + expect(requestResult(harness)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: -1, + errno: 14, + }); + expect( + harness.worker.blockingRetrySnapshots.has(harness.channel), + ).toBe(false); + }, + ); +}); diff --git a/host/test/kernel-clone-exit-entry.test.ts b/host/test/kernel-clone-exit-entry.test.ts new file mode 100644 index 0000000000..515cc3a70b --- /dev/null +++ b/host/test/kernel-clone-exit-entry.test.ts @@ -0,0 +1,434 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + createCentralizedKernelWorkerTestDouble, + type CentralizedKernelCallbacks, +} from "../src/kernel-worker"; +import { + createKernelEntryGatedInstance, + KernelEntryGate, + KernelReentrantEntryError, +} from "../src/kernel-entry-gate"; +import { allocateKernelScratchRegion } from "../src/kernel-scratch"; +import { + ABI_SYSCALLS, + CHANNEL_STATUS_COMPLETE, + CHANNEL_STATUS_PENDING, + CH_ARGS, + CH_ARG_SIZE, + CH_DATA, + CH_ERRNO, + CH_RETURN, + CH_STATUS, + CH_SYSCALL, + CH_TOTAL_SIZE, + PROCESS_STATE_EXITED, +} from "../src/generated/abi"; +import { createKernelScratchTestInstance } from "./support/kernel-scratch-instance"; + +const CLONE_PARENT_SETTID = 0x0010_0000; +const ENOMEM = 12; +const KERNEL_EXPORT_NAMES = [ + "kernel_commit_process_exit", + "kernel_dequeue_signal", + "kernel_drain_wakeup_events", + "kernel_get_memory_pages", + "kernel_get_parent_pid", + "kernel_get_process_exit_signal", + "kernel_get_process_state", + "kernel_handle_channel", + "kernel_inject_mouse_event", + "kernel_set_current_tid", + "kernel_thread_exit", +] as const; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +interface TestChannel { + readonly pid: number; + readonly memory: WebAssembly.Memory; + readonly channelOffset: number; + i32View: Int32Array; + consecutiveSyscalls: number; + handling: boolean; +} + +interface LifecycleHarness { + readonly worker: Record; + readonly channel: TestChannel; + readonly kernelMemory: WebAssembly.Memory; + readonly implementations: Record; + readonly gate: KernelEntryGate; +} + +function kernelPointer( + pointerWidth: 4 | 8, + value: number, +): number | bigint { + return pointerWidth === 8 ? BigInt(value) : value; +} + +function makeHarness( + pointerWidth: 4 | 8, + callbacks: CentralizedKernelCallbacks, + implementations: Record, +): LifecycleHarness { + const processMemory = new WebAssembly.Memory({ + initial: 4, + maximum: 4, + shared: true, + }); + const channel: TestChannel = { + pid: 41, + memory: processMemory, + channelOffset: 0, + i32View: new Int32Array(processMemory.buffer), + consecutiveSyscalls: 0, + handling: true, + }; + const kernelMemory = new WebAssembly.Memory({ + initial: 4, + maximum: 4, + }); + const mutableImplementations: Record = { + kernel_commit_process_exit: (status: number) => status & 0xff, + kernel_dequeue_signal: () => 0, + kernel_drain_wakeup_events: () => 0, + kernel_get_memory_pages: () => 256, + kernel_get_parent_pid: () => 0, + kernel_get_process_exit_signal: () => 0, + kernel_get_process_state: () => PROCESS_STATE_EXITED, + kernel_handle_channel: () => 0, + kernel_inject_mouse_event: () => 0, + kernel_set_current_tid: () => 0, + kernel_thread_exit: () => 0, + ...implementations, + }; + const gate = new KernelEntryGate(); + const rawInstance = createKernelScratchTestInstance( + pointerWidth, + kernelMemory, + () => mutableImplementations, + () => kernelPointer(pointerWidth, 4_096), + 4, + KERNEL_EXPORT_NAMES, + ); + const gatedInstance = createKernelEntryGatedInstance(rawInstance, gate); + const mainScratch = allocateKernelScratchRegion( + kernelMemory, + gatedInstance.exports.kernel_alloc_scratch as + (capacity: number) => number | bigint, + CH_TOTAL_SIZE, + pointerWidth, + "clone/exit entry test scratch", + gatedInstance, + ); + const worker = createCentralizedKernelWorkerTestDouble({ + callbacks, + }) as unknown as Record; + Object.assign(worker, { + activeChannels: [channel], + channelTids: new Map([[`${channel.pid}:${channel.channelOffset}`, channel.pid]]), + currentHandlePid: 0, + execHandoffPids: new Set(), + hostReaped: new Set(), + pendingPipeReaders: new Map(), + pendingPipeWriters: new Map(), + pendingPollRetries: new Map(), + pendingSelectRetries: new Map(), + processes: new Map([[channel.pid, { + pid: channel.pid, + memory: channel.memory, + channels: [channel], + ptrWidth: pointerWidth, + explicitMaxAddr: true, + }]]), + relistenBatchSize: 64, + relistenCount: 0, + syscallRing: new Map(), + syscallTraceCap: 64, + syscallTraceEnabled: false, + syscallTraceRing: [], + threadCtidPtrs: new Map(), + threadForkContexts: new Map(), + usePolling: true, + }); + worker.testAuthority.initializeKernelForTest({ + instance: gatedInstance, + gate, + mainScratch, + tcpScratch: mainScratch, + }); + return { + worker, + channel, + kernelMemory, + implementations: mutableImplementations, + gate, + }; +} + +function writeSyscall( + channel: TestChannel, + syscall: number, + args: readonly bigint[], +): void { + const view = new DataView( + channel.memory.buffer, + channel.channelOffset, + ); + view.setInt32(CH_STATUS, CHANNEL_STATUS_PENDING, true); + view.setUint32(CH_SYSCALL, syscall, true); + for (let index = 0; index < 6; index++) { + view.setBigInt64( + CH_ARGS + index * CH_ARG_SIZE, + args[index] ?? 0n, + true, + ); + } +} + +function channelResult(channel: TestChannel): { + readonly status: number; + readonly result: number; + readonly errno: number; +} { + const view = new DataView( + channel.memory.buffer, + channel.channelOffset, + ); + return { + status: view.getUint32(CH_STATUS, true), + result: Number(view.getBigInt64(CH_RETURN, true)), + errno: view.getUint32(CH_ERRNO, true), + }; +} + +async function flushLifecycleContinuations(): Promise { + for (let index = 0; index < 8; index++) { + await Promise.resolve(); + } +} + +describe("clone and exit entry authority", () => { + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s rolls back an unattached clone through a fresh exact entry", + async (_name, pointerWidth) => { + const tid = 73; + const parentTidPointer = 0x4000; + const order: string[] = []; + let rollbackReentryError: unknown; + let harness!: LifecycleHarness; + const getMemoryPages = vi.fn(() => { + order.push("fresh callback query"); + return 256; + }); + const threadExit = vi.fn((pid: number, removedTid: number) => { + order.push("Rust rollback"); + expect(pid).toBe(harness.channel.pid); + expect(removedTid).toBe(tid); + expect( + new DataView(harness.channel.memory.buffer).getInt32( + parentTidPointer, + true, + ), + ).toBe(0); + try { + harness.worker.getKernelMemoryPages(); + } catch (error) { + rollbackReentryError = error; + } + return 0; + }); + const onClone = vi.fn((attachment) => { + order.push("host clone callback"); + expect(attachment.pid).toBe(harness.channel.pid); + expect(attachment.tid).toBe(tid); + expect( + new DataView(harness.channel.memory.buffer).getInt32( + parentTidPointer, + true, + ), + ).toBe(tid); + // The callback runs after the allocating scope was revoked. A + // synchronous query therefore receives a wholly fresh entry. + expect(harness.worker.getKernelMemoryPages()).toBe(256); + return Promise.resolve(); + }); + vi.spyOn(console, "error").mockImplementation(() => undefined); + harness = makeHarness( + pointerWidth, + { onClone }, + { + kernel_get_memory_pages: getMemoryPages, + kernel_handle_channel: (pointer: number | bigint) => { + order.push("Rust clone allocation"); + const view = new DataView(harness.kernelMemory.buffer); + view.setBigInt64(Number(pointer) + CH_RETURN, BigInt(tid), true); + view.setUint32(Number(pointer) + CH_ERRNO, 0, true); + return 0; + }, + kernel_thread_exit: threadExit, + }, + ); + const processView = new DataView(harness.channel.memory.buffer); + processView.setUint32(CH_DATA, 11, true); + processView.setUint32(CH_DATA + 4, 22, true); + processView.setInt32(parentTidPointer, -1, true); + writeSyscall( + harness.channel, + ABI_SYSCALLS.Clone, + [ + BigInt(CLONE_PARENT_SETTID), + 0x8000n, + BigInt(parentTidPointer), + 0x9000n, + 0n, + ], + ); + + harness.worker.handleSyscall(harness.channel); + expect(order).toEqual(["Rust clone allocation"]); + + await flushLifecycleContinuations(); + + expect(order).toEqual([ + "Rust clone allocation", + "host clone callback", + "fresh callback query", + "Rust rollback", + ]); + expect(rollbackReentryError).toBeInstanceOf( + KernelReentrantEntryError, + ); + expect(getMemoryPages).toHaveBeenCalledOnce(); + expect(threadExit).toHaveBeenCalledOnce(); + expect(channelResult(harness.channel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + result: -1, + errno: ENOMEM, + }); + }, + ); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s publishes a complete exit before detached host callbacks", + async (_name, pointerWidth) => { + const order: string[] = []; + const mouse = vi.fn(() => { + order.push("queued mouse ingress"); + return 0; + }); + const getMemoryPages = vi.fn(() => 512); + let callbackReentryError: unknown; + let orderAtCallback: string[] = []; + let harness!: LifecycleHarness; + const onExit = vi.fn((pid: number, status: number) => { + order.push("host exit callback"); + orderAtCallback = [...order]; + expect(pid).toBe(harness.channel.pid); + expect(status).toBe(7); + expect(harness.worker.hostReaped.has(pid)).toBe(true); + expect(channelResult(harness.channel).status).toBe( + CHANNEL_STATUS_COMPLETE, + ); + try { + harness.worker.getKernelMemoryPages(); + } catch (error) { + callbackReentryError = error; + } + harness.worker.injectMouseEvent(1, 2, 3); + expect(mouse).not.toHaveBeenCalled(); + }); + harness = makeHarness( + pointerWidth, + { onExit }, + { + kernel_commit_process_exit: (status: number) => { + order.push("Rust exit commit"); + return status & 0xff; + }, + kernel_drain_wakeup_events: () => { + order.push("Rust wake drain"); + return 0; + }, + kernel_get_memory_pages: getMemoryPages, + kernel_get_parent_pid: () => { + order.push("Rust parent query"); + return 0; + }, + kernel_get_process_state: () => { + order.push("Rust state proof"); + return PROCESS_STATE_EXITED; + }, + kernel_inject_mouse_event: mouse, + }, + ); + writeSyscall(harness.channel, ABI_SYSCALLS.Exit, [7n]); + + harness.worker.handleSyscall(harness.channel); + + expect(orderAtCallback).toEqual([ + "Rust exit commit", + "Rust state proof", + "Rust wake drain", + "Rust parent query", + "Rust wake drain", + "host exit callback", + ]); + expect(callbackReentryError).toBeInstanceOf( + KernelReentrantEntryError, + ); + expect(getMemoryPages).not.toHaveBeenCalled(); + + await flushLifecycleContinuations(); + + expect(mouse).toHaveBeenCalledExactlyOnceWith(1, 2, 3); + expect(order.at(-1)).toBe("queued mouse ingress"); + expect(order.indexOf("host exit callback")).toBeLessThan( + order.indexOf("queued mouse ingress"), + ); + }, + ); + + it("keeps host exit state private when Rust cannot prove the committed status", async () => { + const onExit = vi.fn(); + const onKernelFatal = vi.fn(); + const consoleError = vi.spyOn(console, "error").mockImplementation( + () => undefined, + ); + const harness = makeHarness( + 4, + { onExit, onKernelFatal }, + { + kernel_commit_process_exit: () => 6, + }, + ); + writeSyscall(harness.channel, ABI_SYSCALLS.Exit, [7n]); + + expect(() => { + harness.worker.handleSyscall(harness.channel); + }).toThrow( + "kernel committed exit status 6 for process 41; expected 7", + ); + + expect(onExit).not.toHaveBeenCalled(); + // The fatal latch is synchronous, but its host observer must not run until + // the failing export's exact entry scope has been fully revoked. + await flushLifecycleContinuations(); + expect(onKernelFatal).toHaveBeenCalledOnce(); + expect(harness.worker.hostReaped.has(harness.channel.pid)).toBe(false); + expect(channelResult(harness.channel).status).toBe( + CHANNEL_STATUS_PENDING, + ); + consoleError.mockRestore(); + }); +}); diff --git a/host/test/kernel-detached-effect-protocol.test.ts b/host/test/kernel-detached-effect-protocol.test.ts new file mode 100644 index 0000000000..3392607ada --- /dev/null +++ b/host/test/kernel-detached-effect-protocol.test.ts @@ -0,0 +1,226 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + type KernelEntryEffectRegistrar, + KernelEntryGate, +} from "../src/kernel-entry-gate"; + +type TestIngress = "first" | "follower"; + +type EffectHandler = ( + ingress: TestIngress, + effects: KernelEntryEffectRegistrar, +) => undefined; + +function createEffectHarness( + onKernelFatal: (error: Error) => void = () => {}, +): { + gate: KernelEntryGate; + run(ingress: TestIngress): boolean; + setHandler(handler: EffectHandler): void; +} { + const gate = new KernelEntryGate(onKernelFatal); + let handler: EffectHandler = () => undefined; + + return { + gate, + run(ingress): boolean { + return gate.runOrDeferVoidIngress( + `test ${ingress} ingress`, + (_scope, effects) => handler(ingress, effects), + ); + }, + setHandler(nextHandler): void { + handler = nextHandler; + }, + }; +} + +async function drainGate(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("kernel detached-effect protocol", () => { + it.each(["immediate", "deferred"] as const)( + "%s protocol publication failure latches fatal before any later work", + async (phase) => { + const order: string[] = []; + let harness: + | ReturnType + | undefined; + const onKernelFatal = vi.fn(() => { + order.push("fatal"); + if (harness) { + // A fatal observer can synchronously try to resurrect dispatch. The + // latch and queue discard must already be authoritative. + harness.run("follower"); + } + }); + harness = createEffectHarness(onKernelFatal); + const publish = vi.fn(() => { + order.push("publish"); + throw new Error("injected publication failure"); + }); + const relisten = vi.fn(() => { + order.push("relisten"); + }); + const observer = vi.fn(() => { + order.push("observer"); + }); + const followerHandler = vi.fn(() => { + order.push("follower"); + }); + harness.setHandler((ingress, effects) => { + if (ingress === "follower") { + followerHandler(); + return undefined; + } + order.push("handler"); + effects.deferProtocolEffect(publish); + effects.deferProtocolEffect(relisten); + effects.deferObserverEffect(observer); + return undefined; + }); + + if (phase === "immediate") { + expect(() => harness!.run("first")) + .toThrow(/kernel protocol effect 0.*failed/); + } else { + harness.gate.invokeKernelExport("hold ingress", () => { + harness!.run("first"); + harness!.run("follower"); + }); + await drainGate(); + } + + expect(order).toEqual(["handler", "publish", "fatal"]); + expect(onKernelFatal).toHaveBeenCalledOnce(); + expect(relisten).not.toHaveBeenCalled(); + expect(observer).not.toHaveBeenCalled(); + expect(followerHandler).not.toHaveBeenCalled(); + + harness.run("first"); + harness.run("follower"); + await drainGate(); + expect(order).toEqual(["handler", "publish", "fatal"]); + }, + ); + + it.each([ + ["immediate", "protocol"], + ["immediate", "observer"], + ["deferred", "protocol"], + ["deferred", "observer"], + ] as const)( + "%s %s Promise return is rejected at the detached boundary", + async (phase, kind) => { + const order: string[] = []; + const onKernelFatal = vi.fn(() => order.push("fatal")); + const harness = createEffectHarness(onKernelFatal); + const laterProtocol = vi.fn(() => { + order.push("later-protocol"); + }); + const laterObserver = vi.fn(() => { + order.push("later-observer"); + }); + const followerHandler = vi.fn(() => { + order.push("follower"); + }); + const returningPromise = (() => { + order.push(`${kind}-promise`); + return Promise.resolve().then(() => order.push(`${kind}-continuation`)); + }) as unknown as () => undefined; + harness.setHandler((ingress, effects) => { + if (ingress === "follower") { + followerHandler(); + return undefined; + } + order.push("handler"); + if (kind === "protocol") { + effects.deferProtocolEffect(returningPromise); + } else { + effects.deferObserverEffect(returningPromise); + } + effects.deferProtocolEffect(laterProtocol); + effects.deferObserverEffect(laterObserver); + return undefined; + }); + + if (phase === "immediate") { + const invoke = () => harness.run("first"); + if (kind === "protocol") { + expect(invoke).toThrow(/kernel protocol effect 0.*failed/); + } else { + expect(invoke).not.toThrow(); + harness.run("follower"); + } + } else { + harness.gate.invokeKernelExport("hold ingress", () => { + harness.run("first"); + harness.run("follower"); + }); + } + await drainGate(); + + if (kind === "protocol") { + expect(onKernelFatal).toHaveBeenCalledOnce(); + expect(laterProtocol).not.toHaveBeenCalled(); + expect(laterObserver).not.toHaveBeenCalled(); + expect(followerHandler).not.toHaveBeenCalled(); + } else { + expect(onKernelFatal).not.toHaveBeenCalled(); + expect(laterProtocol).toHaveBeenCalledOnce(); + expect(laterObserver).toHaveBeenCalledOnce(); + expect(followerHandler).toHaveBeenCalledOnce(); + } + expect(order[0]).toBe("handler"); + expect(order[1]).toBe(`${kind}-promise`); + expect(order).toContain(`${kind}-continuation`); + }, + ); + + it.each(["immediate", "deferred"] as const)( + "%s generic ingress Promise return poisons and discards followers", + async (phase) => { + const order: string[] = []; + const fatal = vi.fn(() => order.push("fatal")); + const gate = new KernelEntryGate(fatal); + const asyncIngress = (() => { + order.push("async-ingress"); + return Promise.resolve().then(() => order.push("continuation")); + }) as unknown as ( + scope: unknown, + effects: unknown, + ) => undefined; + const follower = vi.fn(() => { + order.push("follower"); + return undefined; + }); + + if (phase === "immediate") { + expect(() => + gate.runOrDeferVoidIngress("async ingress", asyncIngress) + ).toThrow(/void kernel ingress async ingress failed/); + } else { + gate.invokeKernelExport("hold ingress", () => { + gate.runOrDeferVoidIngress("async ingress", asyncIngress); + gate.runOrDeferVoidIngress("follower", follower); + }); + } + await drainGate(); + + expect(fatal).toHaveBeenCalledOnce(); + expect(follower).not.toHaveBeenCalled(); + expect(order).toEqual([ + "async-ingress", + "fatal", + "continuation", + ]); + }, + ); +}); diff --git a/host/test/kernel-entry-context-audit.test.ts b/host/test/kernel-entry-context-audit.test.ts new file mode 100644 index 0000000000..6c03739ae5 --- /dev/null +++ b/host/test/kernel-entry-context-audit.test.ts @@ -0,0 +1,1195 @@ +import { describe, expect, it } from "vitest"; +import { + auditKernelEntryContext, + type KernelEntryContextViolationKind, +} from "./support/kernel-entry-context-audit"; + +function violationKinds(source: string): KernelEntryContextViolationKind[] { + return auditKernelEntryContext(source).map(({ kind }) => kind); +} + +describe("kernel entry-context static audit", () => { + it("accepts explicit lexical authority and detached host effects", () => { + const violations = auditKernelEntryContext(` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + deferObserverEffect(operation: () => void): void; + } + class CentralizedKernelWorker { + #kernelInstance: WebAssembly.Instance | null = null; + private io = { write(): void {} }; + #runOrDeferKernelEntry( + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + ): void {} + #kernelInstanceForEntry( + entry?: KernelWorkerEntryContext, + ): WebAssembly.Instance { + return entry?.instance ?? this.#kernelInstance!; + } + #write( + bytes: Uint8Array, + entry?: KernelWorkerEntryContext, + ): void { + const fn = this.#kernelInstanceForEntry(entry).exports.write as + (length: number) => void; + fn(bytes.byteLength); + entry?.deferObserverEffect(() => { + this.io.write(); + }); + } + dispatch(bytes: Uint8Array): void { + this.#runOrDeferKernelEntry("write", (entry) => { + this.#write(bytes, entry); + }); + } + } + `); + + expect(violations).toEqual([]); + }); + + it("admits only exact synchronous serialized host operations", () => { + const safe = auditKernelEntryContext(` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + } + class CentralizedKernelWorker { + private io = { read(): number { return 7; } }; + #runOrDeferKernelEntry( + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + ): void {} + #invokeSharedMmapHostOperation( + _entry: KernelWorkerEntryContext | undefined, + operation: () => T, + ): T { + return operation(); + } + #read(entry: KernelWorkerEntryContext): number { + return this.#invokeSharedMmapHostOperation( + entry, + () => this.io.read(), + ); + } + dispatch(): void { + this.#runOrDeferKernelEntry("read", (entry) => { + void this.#read(entry); + }); + } + } + `); + expect(safe).toEqual([]); + + const unsafe = auditKernelEntryContext(` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + } + class CentralizedKernelWorker { + private io = { read(): number { return 7; } }; + #runOrDeferKernelEntry( + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + ): void {} + #invokeSharedMmapHostOperation( + _entry: KernelWorkerEntryContext | undefined, + operation: () => T, + ): T { + return operation(); + } + #workerHelper(): void {} + #hostCallback(): number { + return this.io.read(); + } + #bad(entry: KernelWorkerEntryContext): void { + this.io.read(); + this.#invokeSharedMmapHostOperation( + undefined, + async () => this.io.read(), + ); + this.#invokeSharedMmapHostOperation(entry, () => { + void entry.instance; + return this.io.read(); + }); + this.#invokeSharedMmapHostOperation(entry, () => { + this.#workerHelper(); + }); + this.#invokeSharedMmapHostOperation(entry, this.#hostCallback); + } + dispatch(): void { + this.#runOrDeferKernelEntry("bad", (entry) => { + this.#bad(entry); + }); + } + } + `); + const kinds = unsafe.map(({ kind }) => kind); + expect(kinds).toContain("host-effect-in-scoped-graph"); + expect(kinds).toContain("missing-explicit-entry"); + expect(kinds).toContain("scoped-method-async"); + expect(kinds).toContain("context-detached-capture"); + expect(kinds).toContain("nonlexical-entry-operation"); + }); + + it("rejects authority storage, async capture, bare selectors, and host effects", () => { + const kinds = violationKinds(` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + deferObserverEffect(operation: () => void): void; + } + class CentralizedKernelWorker { + #kernelInstance: WebAssembly.Instance | null = null; + #saved: KernelWorkerEntryContext | null = null; + private callbacks = { onExit(): void {} }; + #runOrDeferKernelEntry( + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + _dedupe?: object, + _legacyPost?: () => void, + ): void {} + #kernelInstanceForEntry( + entry?: KernelWorkerEntryContext, + ): WebAssembly.Instance { + return entry?.instance ?? this.#kernelInstance!; + } + #exporting(entry?: KernelWorkerEntryContext): void { + const fn = this.#kernelInstanceForEntry().exports.write as + () => void; + fn(); + this.callbacks.onExit(); + Promise.resolve().then(() => { + this.#kernelInstanceForEntry(entry); + }); + } + #bad(entry: KernelWorkerEntryContext): KernelWorkerEntryContext { + const alias = entry; + this.#saved = entry; + this.#exporting(entry); + entry.deferObserverEffect(() => this.#exporting(entry)); + return alias; + } + dispatch(): void { + this.#runOrDeferKernelEntry( + "bad", + (entry) => this.#bad(entry), + undefined, + () => this.callbacks.onExit(), + ); + } + } + `); + + for (const expected of [ + "bare-entry-selector", + "context-alias", + "context-async-capture", + "context-detached-capture", + "context-return", + "context-storage", + "export-from-detached-effect", + "host-effect-in-scoped-graph", + "legacy-detached-operation", + ] satisfies KernelEntryContextViolationKind[]) { + expect(kinds).toContain(expected); + } + }); + + it("rejects a scoped call chain that drops its explicit entry parameter", () => { + const violations = auditKernelEntryContext(` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + deferObserverEffect(operation: () => void): void; + } + class CentralizedKernelWorker { + #kernelInstance: WebAssembly.Instance | null = null; + #runOrDeferKernelEntry( + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + ): void {} + #kernelInstanceForEntry( + entry?: KernelWorkerEntryContext, + ): WebAssembly.Instance { + return entry?.instance ?? this.#kernelInstance!; + } + #leaf(): void { + const fn = this.#kernelInstanceForEntry().exports.read as + () => void; + fn(); + } + #middle(_entry?: KernelWorkerEntryContext): void { + this.#leaf(); + } + dispatch(): void { + this.#runOrDeferKernelEntry("read", (entry) => { + this.#middle(entry); + }); + } + } + `); + + expect(violations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "export-call-without-entry-channel", + owner: "CentralizedKernelWorker.#middle", + }), + ])); + }); + + it("treats a nested ingress operation as a fresh lexical context", () => { + const violations = auditKernelEntryContext(` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + deferObserverEffect(operation: () => void): void; + } + class CentralizedKernelWorker { + #kernelInstance: WebAssembly.Instance | null = null; + #runOrDeferKernelEntry( + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + ): void {} + #kernelInstanceForEntry( + entry?: KernelWorkerEntryContext, + ): WebAssembly.Instance { + return entry?.instance ?? this.#kernelInstance!; + } + #leaf(entry: KernelWorkerEntryContext): void { + const fn = this.#kernelInstanceForEntry(entry).exports.read as + () => void; + fn(); + } + #nested(_outerEntry: KernelWorkerEntryContext): void { + this.#runOrDeferKernelEntry("nested", (innerEntry) => { + this.#leaf(innerEntry); + }); + } + dispatch(): void { + this.#runOrDeferKernelEntry("outer", (entry) => { + this.#nested(entry); + }); + } + } + `); + + expect(violations).toEqual([]); + }); + + it("rejects detached authority aliases and nonlexical callbacks", () => { + const violations = auditKernelEntryContext(` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + scope: object; + deferObserverEffect(operation: () => void): void; + } + class CentralizedKernelWorker { + #kernelInstance: WebAssembly.Instance | null = null; + #runOrDeferKernelEntry( + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + ): void {} + #kernelInstanceForEntry( + entry?: KernelWorkerEntryContext, + ): WebAssembly.Instance { + return entry?.instance ?? this.#kernelInstance!; + } + #exporting(entry: KernelWorkerEntryContext): void { + const instance = this.#kernelInstanceForEntry(entry); + const write = instance.exports.write as () => void; + entry.deferObserverEffect(() => write()); + } + #fieldCallback(_entry: KernelWorkerEntryContext): void {} + #bad(entry: KernelWorkerEntryContext): void { + const directAlias = entry; + const scopeAlias = entry.scope; + void directAlias; + entry.deferObserverEffect(() => void scopeAlias); + entry.deferObserverEffect(this.#fieldCallback); + entry.deferObserverEffect.call(entry, () => {}); + this.#kernelInstanceForEntry.call(this, entry); + this.#kernelInstanceForEntry.apply(this, [entry]); + const selectorAlias = this.#kernelInstanceForEntry; + const bound = this.#kernelInstanceForEntry.bind(this, entry); + void selectorAlias; + void bound; + } + dispatch(): void { + this.#runOrDeferKernelEntry("bad", this.#bad); + } + } + `); + const kinds = violations.map(({ kind }) => kind); + + expect(kinds).toContain("context-alias"); + expect(kinds).toContain("context-detached-capture"); + expect(kinds).toContain("nonlexical-detached-effect"); + expect(kinds).toContain("nonlexical-entry-operation"); + expect( + kinds.filter((kind) => kind === "indirect-entry-authority").length, + ).toBeGreaterThanOrEqual(5); + }); + + it("rejects destructured authority and synchronous closure escape", () => { + const violations = auditKernelEntryContext(` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + scope: object; + deferObserverEffect(operation: () => void): void; + } + class CentralizedKernelWorker { + #runOrDeferKernelEntry( + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + ): void {} + #leak( + entry: KernelWorkerEntryContext, + ): () => WebAssembly.Instance { + const { scope } = entry; + void scope; + const closure = () => entry.instance; + return closure; + } + dispatch(): void { + this.#runOrDeferKernelEntry("leak", (entry) => { + void this.#leak(entry); + }); + } + } + `); + + expect(violations).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "context-alias" }), + expect.objectContaining({ kind: "context-return" }), + ])); + }); + + it("requires async callbacks that export to open a fresh ingress", () => { + const unsafe = auditKernelEntryContext(` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + deferObserverEffect(operation: () => void): void; + } + class CentralizedKernelWorker { + #kernelInstance: WebAssembly.Instance | null = null; + #runOrDeferKernelEntry( + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + ): void {} + #kernelInstanceForEntry( + entry?: KernelWorkerEntryContext, + ): WebAssembly.Instance { + return entry?.instance ?? this.#kernelInstance!; + } + #leaf(entry?: KernelWorkerEntryContext): void { + const fn = this.#kernelInstanceForEntry(entry).exports.read as + () => void; + fn(); + } + dispatch(): void { + this.#runOrDeferKernelEntry("outer", (_entry) => { + setTimeout(() => this.#leaf(), 0); + queueMicrotask(() => { + this.#kernelInstanceForEntry().exports.read; + }); + }); + } + promiseDispatch(): void { + this.#runOrDeferKernelEntry("promise", (entry) => { + const launch: Promise = Promise.resolve(); + launch.then(() => this.#leaf(entry)); + }); + } + } + `); + expect(unsafe).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "async-export-without-ingress" }), + expect.objectContaining({ kind: "context-async-capture" }), + ])); + + const safe = auditKernelEntryContext(` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + deferObserverEffect(operation: () => void): void; + } + class CentralizedKernelWorker { + #kernelInstance: WebAssembly.Instance | null = null; + #runOrDeferKernelEntry( + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + ): void {} + #kernelInstanceForEntry( + entry?: KernelWorkerEntryContext, + ): WebAssembly.Instance { + return entry?.instance ?? this.#kernelInstance!; + } + #leaf(entry: KernelWorkerEntryContext): void { + const fn = this.#kernelInstanceForEntry(entry).exports.read as + () => void; + fn(); + } + dispatch(): void { + this.#runOrDeferKernelEntry("outer", (_entry) => { + setTimeout(() => { + this.#runOrDeferKernelEntry("timer", (timerEntry) => { + this.#leaf(timerEntry); + }); + }, 0); + }); + } + } + `); + expect(safe).toEqual([]); + }); + + it("follows reviewed listener APIs, callback objects, and local scheduler wrappers", () => { + const source = (operation: string) => ` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + deferObserverEffect(operation: () => void): void; + } + class CentralizedKernelWorker { + #kernelInstance: WebAssembly.Instance | null = null; + #runOrDeferKernelEntry( + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + ): void {} + #kernelInstanceForEntry( + entry?: KernelWorkerEntryContext, + ): WebAssembly.Instance { + return entry?.instance ?? this.#kernelInstance!; + } + #leaf(entry?: KernelWorkerEntryContext): void { + void this.#kernelInstanceForEntry(entry).exports.read; + } + #registerTimeout(operation: () => void, delay: number): void { + setTimeout(operation, delay); + } + #registerInterval(operation: () => void, delay: number): void { + setInterval(operation, delay); + } + #customSchedule(operation: () => void): void { + this.#registerTimeout(operation, 0); + } + #continuePromise( + promise: Promise, + operation: () => void, + ): void { + promise.then(operation); + } + #later(): void { + ${operation} + } + } + `; + const unsafeOperations = [ + `this.#customSchedule(() => this.#leaf());`, + `this.#registerInterval(() => this.#leaf(), 1);`, + `const promise: Promise = Promise.resolve(); + this.#continuePromise(promise, () => this.#leaf());`, + `const channel = new MessageChannel(); + channel.port1.onmessage = () => this.#leaf();`, + `const socket = {} as { + on(name: string, listener: () => void): void; + }; + socket.on("data", () => this.#leaf());`, + `const network = {} as { + bindUdp( + key: string, + address: Uint8Array, + port: number, + callbacks: { receive(): void }, + ): void; + }; + network.bindUdp("key", new Uint8Array(), 7, { + receive: () => this.#leaf(), + });`, + `new WasmPosixKernel({}, {}, { + onAlarm: () => { + this.#leaf(); + return 0; + }, + });`, + ]; + for (const operation of unsafeOperations) { + expect(auditKernelEntryContext(source(operation))).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "async-export-without-ingress", + owner: "CentralizedKernelWorker.#later", + }), + ]), + ); + } + + const safeOperations = unsafeOperations.map((operation) => + operation.replace( + /this\.#leaf\(\)/g, + `this.#runOrDeferKernelEntry("listener", (entry) => { + this.#leaf(entry); + })`, + ) + ); + for (const operation of safeOperations) { + expect(auditKernelEntryContext(source(operation))).toEqual([]); + } + }); + + it("requires transaction continuations to re-enter through the exact channel", () => { + const source = (transaction: string) => ` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + deferObserverEffect(operation: () => void): void; + deferProtocolTransactionStart(operation: () => undefined): void; + } + class CentralizedKernelWorker { + #kernelInstance: WebAssembly.Instance | null = null; + private callbacks = { + launch(): Promise { + return Promise.resolve(7); + }, + }; + #runOrDeferKernelEntry( + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + ): void {} + #runOrDeferChannelKernelEntry( + _channel: object, + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + ): void {} + #kernelInstanceForEntry( + entry?: KernelWorkerEntryContext, + ): WebAssembly.Instance { + return entry?.instance ?? this.#kernelInstance!; + } + #continuePromise( + promise: Promise, + onFulfilled: (value: T) => unknown, + onRejected?: (cause: unknown) => unknown, + ): void { + promise.then(onFulfilled, onRejected); + } + #finish(entry: KernelWorkerEntryContext): void { + void this.#kernelInstanceForEntry(entry).exports.finish; + } + #start( + channel: object, + entry: KernelWorkerEntryContext, + ): void { + ${transaction} + } + dispatch(channel: object): void { + this.#runOrDeferChannelKernelEntry( + channel, + "dispatch", + (entry) => this.#start(channel, entry), + ); + } + } + `; + + const capturedOuterEntry = auditKernelEntryContext(source(` + entry.deferProtocolTransactionStart(() => { + void this.#continuePromise(this.callbacks.launch(), () => { + this.#runOrDeferChannelKernelEntry( + channel, + "finish", + (_innerEntry) => this.#finish(entry), + ); + }); + return undefined; + }); + `)); + expect(capturedOuterEntry).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "context-detached-capture" }), + ])); + + const wrongIngress = auditKernelEntryContext(source(` + entry.deferProtocolTransactionStart(() => { + void this.#continuePromise(this.callbacks.launch(), () => { + this.#runOrDeferKernelEntry("finish", (innerEntry) => { + this.#finish(innerEntry); + }); + }); + return undefined; + }); + `)); + expect(wrongIngress).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "transaction-continuation-without-channel-ingress", + }), + ])); + + const directCompletion = auditKernelEntryContext(source(` + entry.deferProtocolTransactionStart(() => { + void this.#continuePromise( + this.callbacks.launch(), + () => this.#finish({} as KernelWorkerEntryContext), + ); + return undefined; + }); + `)); + expect(directCompletion).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "async-export-without-ingress" }), + expect.objectContaining({ + kind: "transaction-continuation-without-channel-ingress", + }), + ])); + + const asyncStart = auditKernelEntryContext(source(` + entry.deferProtocolTransactionStart(async () => { + await this.callbacks.launch(); + }); + `)); + expect(asyncStart).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "async-detached-effect" }), + ])); + + const safe = auditKernelEntryContext(source(` + entry.deferProtocolTransactionStart(() => { + void this.#continuePromise( + this.callbacks.launch(), + (_result) => { + this.#runOrDeferChannelKernelEntry( + channel, + "finish", + (innerEntry) => this.#finish(innerEntry), + ); + }, + (_cause) => { + this.#runOrDeferChannelKernelEntry( + channel, + "rollback", + (innerEntry) => this.#finish(innerEntry), + ); + }, + ); + return undefined; + }); + `)); + expect(safe).toEqual([]); + }); + + it("keeps unknown HOFs synchronous and honors lexical shadowing", () => { + const safe = auditKernelEntryContext(` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + deferObserverEffect(operation: () => void): void; + } + class CentralizedKernelWorker { + #kernelInstance: WebAssembly.Instance | null = null; + #runOrDeferKernelEntry( + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + ): void {} + #kernelInstanceForEntry( + entry?: KernelWorkerEntryContext, + ): WebAssembly.Instance { + return entry?.instance ?? this.#kernelInstance!; + } + #leaf(entry: KernelWorkerEntryContext): void { + this.#kernelInstanceForEntry(entry).exports.read; + } + dispatch(): void { + this.#runOrDeferKernelEntry("outer", (entry) => { + const setTimeout = (operation: () => void) => operation(); + setTimeout(() => this.#leaf(entry)); + ({ run(operation: () => void) { operation(); } }).run( + () => this.#leaf(entry), + ); + for (const entry of [1, 2]) void entry; + { const entry = "shadow"; void entry; } + [1].forEach((entry) => void entry); + }); + } + } + `); + expect(safe).toEqual([]); + + const unsafe = auditKernelEntryContext(` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + deferObserverEffect(operation: () => void): void; + } + class CentralizedKernelWorker { + #kernelInstance: WebAssembly.Instance | null = null; + #runOrDeferKernelEntry( + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + ): void {} + #kernelInstanceForEntry( + entry?: KernelWorkerEntryContext, + ): WebAssembly.Instance { + return entry?.instance ?? this.#kernelInstance!; + } + #leaf(entry?: KernelWorkerEntryContext): void { + this.#kernelInstanceForEntry(entry).exports.read; + } + dispatch(): void { + this.#runOrDeferKernelEntry("outer", (_entry) => { + const run = () => this.#leaf(); + run(); + }); + } + } + `); + expect(unsafe).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "missing-explicit-entry", + }), + ])); + }); + + it("distinguishes host capability reads from calls and mutations", () => { + const source = (operation: string) => ` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + deferObserverEffect(operation: () => void): void; + } + class CentralizedKernelWorker { + private callbacks = { onExit(): void {}, status: 0 }; + #runOrDeferKernelEntry( + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + ): void {} + dispatch(): void { + this.#runOrDeferKernelEntry("outer", (_entry) => { + ${operation} + }); + } + } + `; + expect( + auditKernelEntryContext(source("void this.callbacks.onExit;")), + ).toEqual([]); + for (const operation of [ + "this.callbacks.onExit();", + "this.callbacks.status = 1;", + "const callback = this.callbacks.onExit; callback();", + ]) { + expect(auditKernelEntryContext(source(operation))).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "host-effect-in-scoped-graph", + }), + ]), + ); + } + }); + + it("rejects cross-ingress and detached authority but accepts materialized publication", () => { + const unsafe = auditKernelEntryContext(` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + deferObserverEffect(operation: () => void): void; + } + class CentralizedKernelWorker { + #kernelInstance: WebAssembly.Instance | null = null; + #runOrDeferKernelEntry( + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + ): void {} + #kernelInstanceForEntry( + entry?: KernelWorkerEntryContext, + ): WebAssembly.Instance { + return entry?.instance ?? this.#kernelInstance!; + } + #nested(outer: KernelWorkerEntryContext): void { + this.#runOrDeferKernelEntry("inner", (_inner) => { + outer.instance.exports.read; + }); + } + #bad(entry: KernelWorkerEntryContext): void { + const extracted = entry["instance"]["exports"]["read"] as + () => void; + entry.deferObserverEffect(() => extracted()); + entry = {} as KernelWorkerEntryContext; + } + dispatch(): void { + this.#runOrDeferKernelEntry("outer", (entry) => { + this.#nested(entry); + this.#bad(entry); + }); + } + } + `); + expect(unsafe).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "context-cross-ingress-capture", + }), + expect.objectContaining({ + kind: "context-detached-capture", + }), + expect.objectContaining({ kind: "context-storage" }), + ])); + + const safe = auditKernelEntryContext(` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + deferObserverEffect(operation: () => void): void; + } + interface MaterializedCompletion { readonly value: number } + class CentralizedKernelWorker { + #kernelInstance: WebAssembly.Instance | null = null; + private callbacks = { publish(_value: number): void {} }; + #runOrDeferKernelEntry( + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + ): void {} + #kernelInstanceForEntry( + entry?: KernelWorkerEntryContext, + ): WebAssembly.Instance { + return entry?.instance ?? this.#kernelInstance!; + } + #materialize( + entry: KernelWorkerEntryContext, + ): MaterializedCompletion { + const read = this.#kernelInstanceForEntry(entry).exports.read as + () => number; + return { value: read() }; + } + #publish(completion: MaterializedCompletion): void { + this.callbacks.publish(completion.value); + } + dispatch(): void { + this.#runOrDeferKernelEntry("outer", (entry) => { + const completion = this.#materialize(entry); + entry.deferObserverEffect(() => this.#publish(completion)); + }); + } + } + `); + expect(safe).toEqual([]); + }); + + it("keeps observer effects out of protocol publication and ingress", () => { + const violations = auditKernelEntryContext(` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + deferObserverEffect(operation: () => void): void; + deferProtocolEffect(operation: () => void): void; + } + interface MaterializedCompletion { readonly value: number } + class CentralizedKernelWorker { + private callbacks = { onOutput(_value: number): void {} }; + private published = 0; + #runOrDeferKernelEntry( + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + ): void {} + private relistenChannel(): void {} + #publish(completion: MaterializedCompletion): void { + this.published = completion.value; + this.relistenChannel(); + } + #publishAlias(completion: MaterializedCompletion): void { + this.#publish(completion); + } + #startAlias(): void { + this.#runOrDeferKernelEntry("observer-start", (_entry) => {}); + } + #observe(completion: MaterializedCompletion): void { + this.callbacks.onOutput(completion.value); + } + dispatch(): void { + this.#runOrDeferKernelEntry("outer", (entry) => { + const completion = { value: 7 }; + entry.deferProtocolEffect(() => this.#publishAlias(completion)); + entry.deferObserverEffect(() => this.#observe(completion)); + entry.deferObserverEffect(() => this.#publishAlias(completion)); + entry.deferObserverEffect(() => this.#startAlias()); + }); + } + } + `); + + expect( + violations.filter( + ({ kind }) => kind === "protocol-effect-from-observer", + ), + ).toHaveLength(2); + }); + + it("rejects asynchronous detached callbacks and Promise-launching aliases", () => { + const violations = auditKernelEntryContext(` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + deferObserverEffect(operation: () => void): void; + deferProtocolEffect(operation: () => void): void; + } + class CentralizedKernelWorker { + #runOrDeferKernelEntry( + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + ): void {} + dispatch(): void { + this.#runOrDeferKernelEntry("outer", (entry) => { + const asyncAlias = async (): Promise => {}; + const launch = (): Promise => Promise.resolve(); + entry.deferObserverEffect(async () => {}); + entry.deferProtocolEffect(asyncAlias); + entry.deferObserverEffect(() => { + void launch(); + }); + entry.deferProtocolEffect(() => Promise.resolve()); + }); + } + } + `); + + expect( + violations.filter(({ kind }) => kind === "async-detached-effect"), + ).toHaveLength(4); + }); + + it("does not recognize the removed deferHostEffect name", () => { + const violations = auditKernelEntryContext(` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + deferHostEffect(operation: () => void): void; + } + class CentralizedKernelWorker { + private callbacks = { onOutput(): void {} }; + #runOrDeferKernelEntry( + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + ): void {} + dispatch(): void { + this.#runOrDeferKernelEntry("outer", (entry) => { + entry.deferHostEffect(() => this.callbacks.onOutput()); + }); + } + } + `); + + expect(violations).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "host-effect-in-scoped-graph" }), + ])); + }); + + it("rejects nonliteral this dispatch and resolves immutable literal keys", () => { + const unsafe = auditKernelEntryContext(` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + } + class CentralizedKernelWorker { + #runOrDeferKernelEntry( + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + ): void {} + entryExport(entry: KernelWorkerEntryContext): void { + void entry.instance.exports.read; + } + dispatch(selectedMethod: string): void { + this.#runOrDeferKernelEntry("dynamic", (entry) => { + (this as any)[selectedMethod](entry); + }); + } + } + `); + expect(unsafe).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "dynamic-entry-method-dispatch", + owner: expect.stringContaining(" void, + ): void {} + entryExport(entry: KernelWorkerEntryContext): void { + void entry.instance.exports.read; + } + dispatch(): void { + const method = "entryExport" as const; + const exactAlias = method; + this.#runOrDeferKernelEntry("exact", (entry) => { + this[exactAlias](entry); + }); + } + } + intrinsicFreeze(CentralizedKernelWorker.prototype); + `); + expect(safe).toEqual([]); + }); + + it("rejects implicit arguments access in an entry-owning method", () => { + const violations = auditKernelEntryContext(` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + } + class CentralizedKernelWorker { + private saved: unknown; + #runOrDeferKernelEntry( + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + ): void {} + #bad(entry: KernelWorkerEntryContext): void { + this.saved = arguments[0]; + void entry; + } + dispatch(): void { + this.#runOrDeferKernelEntry("arguments", (entry) => { + this.#bad(entry); + }); + } + } + `); + + expect(violations).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "implicit-arguments-entry-authority", + owner: "CentralizedKernelWorker.#bad", + }), + ])); + }); + + it("rejects genuine direct eval in the entry graph", () => { + const unsafe = auditKernelEntryContext(` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + } + class CentralizedKernelWorker { + #runOrDeferKernelEntry( + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + ): void {} + #bad(entry: KernelWorkerEntryContext): void { + eval("void entry.instance.exports.read"); + } + dispatch(): void { + this.#runOrDeferKernelEntry("eval", (entry) => { + this.#bad(entry); + }); + } + } + `); + expect(unsafe).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "direct-eval-in-entry-graph", + owner: "CentralizedKernelWorker.#bad", + }), + ])); + + const safe = auditKernelEntryContext(` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + } + class CentralizedKernelWorker { + #runOrDeferKernelEntry( + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + ): void {} + dispatch(): void { + const eval = (_source: string): void => {}; + this.#runOrDeferKernelEntry("shadowed", (_entry) => { + eval("not the intrinsic"); + globalThis.eval?.("indirect eval"); + }); + } + } + `); + expect(safe).toEqual([]); + }); + + it("requires sealed instances and a frozen prototype for TS-private entry dispatch", () => { + const source = (hardening: string, prelude = "") => ` + ${prelude} + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + deferObserverEffect(operation: () => void): void; + deferProtocolEffect(operation: () => void): void; + } + class CentralizedKernelWorker { + #kernelInstance: WebAssembly.Instance | null = null; + constructor() { + ${hardening.includes("rejectSubclass") + ? `if (new.target !== CentralizedKernelWorker) { + throw new TypeError("CentralizedKernelWorker is final"); + }` + : ""} + ${hardening.includes("intrinsicSeal") + ? "intrinsicSeal(this);" + : hardening.includes("fakeSeal") + ? "fakeSeal(this);" + : ""} + } + #runOrDeferKernelEntry( + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + ): void {} + #kernelInstanceForEntry( + entry: KernelWorkerEntryContext, + ): WebAssembly.Instance { + return entry.instance; + } + private dynamicEntryMethod( + entry: KernelWorkerEntryContext, + ): void { + void this.#kernelInstanceForEntry(entry).exports.read; + } + dispatch(): void { + this.#runOrDeferKernelEntry("outer", (entry) => { + this.dynamicEntryMethod(entry); + }); + } + } + ${hardening.includes("intrinsicFreeze") + ? "intrinsicFreeze(CentralizedKernelWorker.prototype);" + : hardening.includes("fakeFreeze") + ? "fakeFreeze(CentralizedKernelWorker.prototype);" + : ""} + `; + + const unsafe = auditKernelEntryContext(source("")); + expect(unsafe).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "mutable-entry-method-dispatch", + text: expect.stringContaining("dynamicEntryMethod"), + }), + ])); + + const spoofed = auditKernelEntryContext(source( + "fakeSeal fakeFreeze", + ` + const fakeSeal = (value: T): T => value; + const fakeFreeze = (value: T): T => value; + `, + )); + expect(spoofed).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "mutable-entry-method-dispatch" }), + ])); + + const subclassable = auditKernelEntryContext(source( + "intrinsicSeal intrinsicFreeze", + ` + const intrinsicSeal = Object.seal; + const intrinsicFreeze = Object.freeze; + `, + )); + expect(subclassable).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "mutable-entry-method-dispatch" }), + ])); + + const hardened = auditKernelEntryContext(source( + "intrinsicSeal intrinsicFreeze rejectSubclass", + ` + const intrinsicSeal = Object.seal; + const intrinsicFreeze = Object.freeze; + `, + )); + expect(hardened).toEqual([]); + }); +}); diff --git a/host/test/kernel-entry-gate.test.ts b/host/test/kernel-entry-gate.test.ts new file mode 100644 index 0000000000..7c98aac3f7 --- /dev/null +++ b/host/test/kernel-entry-gate.test.ts @@ -0,0 +1,1523 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createKernelEntryGatedInstance, + createKernelEntryScopedInstance, + hasValidatedKernelEntryExport, + isKernelExportFailure, + invokeKernelEntrySerializedHostOperation, + KernelEntryGate, + KernelReentrantEntryError, + validatedKernelEntryCallable, +} from "../src/kernel-entry-gate"; +import { createKernelScratchTestInstance } from "./support/kernel-scratch-instance"; + +function testInstance( + pointerWidth: 4 | 8, + gate: KernelEntryGate, + implementations: Record, +): { + raw: WebAssembly.Instance; + gated: WebAssembly.Instance; +} { + const memory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); + const raw = createKernelScratchTestInstance( + pointerWidth, + memory, + () => implementations, + () => pointerWidth === 8 ? 4096n : 4096, + ); + return { + raw, + gated: createKernelEntryGatedInstance(raw, gate), + }; +} + +describe("KernelEntryGate", () => { + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s hides mutable exports and rejects result-bearing reverse entry", + (_name, pointerWidth) => { + const gate = new KernelEntryGate(); + const nestedRaw = vi.fn(() => 0); + let gated!: WebAssembly.Instance; + const implementations: Record = { + kernel_handle_channel: vi.fn(() => { + const nested = gated.exports.kernel_transfer_scratch_cancel as + (token: bigint) => number; + expect(() => nested(1n)).toThrow(KernelReentrantEntryError); + return 0; + }), + kernel_transfer_scratch_cancel: nestedRaw, + }; + const instance = testInstance(pointerWidth, gate, implementations); + gated = instance.gated; + + expect(Object.isFrozen(gated.exports)).toBe(true); + expect(gated.exports.memory).toBeUndefined(); + expect(gated).toBeInstanceOf(WebAssembly.Instance); + expect(() => Reflect.apply( + Object.getOwnPropertyDescriptor( + WebAssembly.Instance.prototype, + "exports", + )!.get!, + gated, + [], + )).toThrow(); + + const handle = gated.exports.kernel_handle_channel as ( + pointer: number | bigint, + capacity: number, + pointerWidth: number, + retryToken: bigint, + ) => number; + expect(handle(pointerWidth === 8 ? 0n : 0, 0, pointerWidth, 0n)) + .toBe(0); + expect(nestedRaw).not.toHaveBeenCalled(); + }, + ); + + it("binds one raw instance to exactly one gate generation", () => { + const memory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); + const inner = vi.fn(() => 0); + const raw = createKernelScratchTestInstance( + 4, + memory, + () => ({ + kernel_transfer_scratch_cancel: inner, + }), + () => 4096, + ); + const gateA = new KernelEntryGate(); + const gateB = new KernelEntryGate(); + + const first = createKernelEntryGatedInstance(raw, gateA); + expect(createKernelEntryGatedInstance(raw, gateA)).toBe(first); + expect(() => createKernelEntryGatedInstance(raw, gateB)).toThrow( + /different kernel entry gate|already.*gate/i, + ); + expect(inner).not.toHaveBeenCalled(); + + const cancel = first.exports.kernel_transfer_scratch_cancel as + (token: bigint) => number; + expect(cancel(1n)).toBe(0); + expect(inner).toHaveBeenCalledOnce(); + }); + + it("rejects gate subclass and mutation-based dispatch overrides", () => { + class SubclassedGate extends KernelEntryGate {} + + expect(() => new SubclassedGate()).toThrow( + /subclass|exact KernelEntryGate/i, + ); + + const gate = new KernelEntryGate(); + const originalRun = KernelEntryGate.prototype.runOrDeferVoidIngress; + const replacement = vi.fn(() => false); + expect(Object.isFrozen(KernelEntryGate.prototype)).toBe(true); + expect(Object.isFrozen(gate)).toBe(true); + expect(Reflect.set( + KernelEntryGate.prototype, + "runOrDeferVoidIngress", + replacement, + )).toBe(false); + expect(Reflect.defineProperty( + KernelEntryGate.prototype, + "runOrDeferVoidIngress", + { value: replacement }, + )).toBe(false); + expect(Reflect.defineProperty( + gate, + "runOrDeferVoidIngress", + { value: replacement }, + )).toBe(false); + expect(KernelEntryGate.prototype.runOrDeferVoidIngress).toBe(originalRun); + expect(gate.runOrDeferVoidIngress("still guarded", () => {})).toBe(false); + expect(replacement).not.toHaveBeenCalled(); + }); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s drains void reverse entries once in FIFO order", + async (_name, pointerWidth) => { + const gate = new KernelEntryGate(); + const order: string[] = []; + const dedupe = {}; + const implementations: Record = { + kernel_handle_channel: vi.fn(() => { + expect(gate.runOrDeferVoidIngress( + "first", + () => { + order.push("first"); + }, + dedupe, + )).toBe(true); + expect(gate.runOrDeferVoidIngress( + "duplicate", + () => { + order.push("duplicate"); + }, + dedupe, + )).toBe(true); + expect(gate.runOrDeferVoidIngress( + "second", + () => { + order.push("second"); + }, + )).toBe(true); + expect(order).toEqual([]); + return 0; + }), + }; + const { gated } = testInstance(pointerWidth, gate, implementations); + const handle = gated.exports.kernel_handle_channel as ( + pointer: number | bigint, + capacity: number, + pointerWidth: number, + retryToken: bigint, + ) => number; + + expect(handle(pointerWidth === 8 ? 0n : 0, 0, pointerWidth, 0n)) + .toBe(0); + expect(order).toEqual([]); + await Promise.resolve(); + expect(order).toEqual(["first", "second"]); + }, + ); + + it("rejects immediate-only ingress without retaining its callback", async () => { + const gate = new KernelEntryGate(); + const rejected = vi.fn(); + const order: string[] = []; + + gate.invokeKernelExport("outer", () => { + expect(() => gate.runImmediateVoidIngress( + "immediate-only test seam", + rejected, + )).toThrow(KernelReentrantEntryError); + expect(gate.runOrDeferVoidIngress( + "queued follower", + () => { + order.push("follower"); + }, + )).toBe(true); + }); + + expect(rejected).not.toHaveBeenCalled(); + await Promise.resolve(); + expect(rejected).not.toHaveBeenCalled(); + expect(order).toEqual(["follower"]); + + gate.runImmediateVoidIngress("later immediate ingress", () => { + order.push("immediate"); + }); + expect(order).toEqual(["follower", "immediate"]); + }); + + it("rejects immediate-only ingress during a transaction start", async () => { + const gate = new KernelEntryGate(); + const rejected = vi.fn(); + const observedRejection = vi.fn(); + + expect(gate.runOrDeferVoidIngress( + "transaction owner", + (_scope, effects) => { + effects.deferProtocolTransactionStart(() => { + expect(() => gate.runImmediateVoidIngress( + "transaction-start reentry", + rejected, + )).toThrow(KernelReentrantEntryError); + observedRejection(); + }); + }, + )).toBe(false); + + await Promise.resolve(); + expect(observedRejection).toHaveBeenCalledOnce(); + expect(rejected).not.toHaveBeenCalled(); + await Promise.resolve(); + expect(rejected).not.toHaveBeenCalled(); + }); + + it("keeps nested ingress behind work already queued after its parent", async () => { + const gate = new KernelEntryGate(); + const order: string[] = []; + + gate.invokeKernelExport("outer", () => { + gate.runOrDeferVoidIngress("A", () => { + order.push("A"); + gate.runOrDeferVoidIngress( + "B", + () => { + order.push("B"); + }, + ); + }); + gate.runOrDeferVoidIngress("C", () => { + order.push("C"); + }); + }); + await Promise.resolve(); + + expect(order).toEqual(["A", "C", "B"]); + }); + + it("grants a selected generic callback no observer-stealable export authority", async () => { + const gate = new KernelEntryGate(); + const order: string[] = []; + + gate.invokeKernelExport("outer", () => { + gate.runOrDeferVoidIngress("TCP listener registration", () => { + order.push("observer"); + expect(() => gate.invokeKernelExport( + "observer-stolen export", + () => order.push("stolen"), + )).toThrow(KernelReentrantEntryError); + }); + gate.runOrDeferVoidIngress( + "follower", + () => { + order.push("follower"); + }, + ); + }); + await Promise.resolve(); + + expect(order).toEqual(["observer", "follower"]); + }); + + it("requires explicit scope for every export-bearing deferred callback", async () => { + const gate = new KernelEntryGate(); + const order: string[] = []; + const { gated } = testInstance(4, gate, { + kernel_transfer_scratch_cancel: () => { + order.push("allowed"); + return 0; + }, + }); + const unscoped = gated.exports.kernel_transfer_scratch_cancel as + (token: bigint) => number; + + gate.invokeKernelExport("outer", () => { + gate.runOrDeferVoidIngress("host-only", () => { + order.push("host-only"); + expect(() => unscoped(1n)).toThrow(KernelReentrantEntryError); + }); + gate.runOrDeferVoidIngress("scoped", (scope) => { + order.push("scoped"); + const scoped = createKernelEntryScopedInstance(gated, scope); + const cancel = scoped.exports.kernel_transfer_scratch_cancel as + (token: bigint) => number; + expect(cancel(1n)).toBe(0); + }); + gate.runOrDeferVoidIngress( + "follower", + () => { + order.push("follower"); + }, + ); + }); + await Promise.resolve(); + + expect(order).toEqual([ + "host-only", + "scoped", + "allowed", + "follower", + ]); + }); + + it("keeps a reviewed synchronous void ingress across sequential exports", async () => { + const gate = new KernelEntryGate(); + const order: string[] = []; + const ingress = (label: string): void => { + gate.runOrDeferVoidIngress( + label, + () => { + order.push(label); + }, + ); + }; + const { gated } = testInstance(4, gate, { + kernel_transfer_scratch_cancel: (token: bigint) => { + if (token === 1n) { + order.push("first"); + ingress("nested"); + } else { + order.push("second"); + } + return 0; + }, + }); + + gate.invokeKernelExport("outer", () => { + expect(gate.runOrDeferVoidIngress("chunked input", (scope) => { + order.push("selected"); + const scoped = createKernelEntryScopedInstance(gated, scope); + const cancel = scoped.exports.kernel_transfer_scratch_cancel as + (token: bigint) => number; + expect(cancel(1n)).toBe(0); + expect(() => gate.invokeKernelExport( + "callback-borrowed result export", + () => order.push("stolen"), + )).toThrow(KernelReentrantEntryError); + expect(cancel(2n)).toBe(0); + })).toBe(true); + ingress("follower"); + }); + await Promise.resolve(); + + expect(order).toEqual([ + "selected", + "first", + "second", + "follower", + "nested", + ]); + }); + + it("keeps one synchronous host operation inside the exact entry lifetime", async () => { + const gate = new KernelEntryGate(); + const order: string[] = []; + let invokeAfterRevocation!: () => number; + + expect(gate.runOrDeferVoidIngress( + "MAP_SHARED transaction", + (scope, effects) => { + invokeAfterRevocation = () => + invokeKernelEntrySerializedHostOperation(scope, () => 11); + const result = invokeKernelEntrySerializedHostOperation( + scope, + () => { + order.push("host:start"); + expect(() => gate.invokeKernelExport( + "host callback result ingress", + () => order.push("stolen"), + )).toThrow(KernelReentrantEntryError); + expect(gate.runOrDeferVoidIngress( + "host callback void ingress", + () => { + order.push("queued"); + }, + )).toBe(true); + expect(() => invokeKernelEntrySerializedHostOperation( + scope, + () => 12, + )).toThrow(KernelReentrantEntryError); + expect(() => effects.deferProtocolEffect( + () => undefined, + )).toThrow(/effect registration is no longer active/); + order.push("host:end"); + return 7; + }, + ); + expect(result).toBe(7); + order.push("commit"); + }, + )).toBe(false); + + expect(order).toEqual(["host:start", "host:end", "commit"]); + expect(() => invokeAfterRevocation()).toThrow(/scope is no longer active/); + await Promise.resolve(); + expect(order).toEqual(["host:start", "host:end", "commit", "queued"]); + }); + + it("serializes a host-only operation and releases ordinary backend errors", async () => { + const gate = new KernelEntryGate(); + const order: string[] = []; + const backendFailure = new Error("backend read failed"); + + expect(() => gate.runSerializedHostOperation( + "host-only MAP_SHARED read", + () => { + order.push("host:start"); + expect(gate.runOrDeferVoidIngress( + "backend callback void ingress", + () => { + order.push("queued"); + }, + )).toBe(true); + expect(() => gate.invokeKernelExport( + "backend callback result ingress", + () => order.push("stolen"), + )).toThrow(KernelReentrantEntryError); + expect(() => gate.runSerializedHostOperation( + "nested host operation", + () => 1, + )).toThrow(KernelReentrantEntryError); + order.push("host:error"); + throw backendFailure; + }, + )).toThrow(backendFailure); + + expect(order).toEqual(["host:start", "host:error"]); + expect(() => gate.runSerializedHostOperation( + "overtaking host operation", + () => 8, + )).toThrow(KernelReentrantEntryError); + await Promise.resolve(); + expect(order).toEqual(["host:start", "host:error", "queued"]); + expect(gate.runSerializedHostOperation( + "later host operation", + () => 9, + )).toBe(9); + expect(gate.invokeKernelExport("later export", () => 12)).toBe(12); + }); + + it("fails closed when a serialized host operation crosses an async boundary", async () => { + let gate!: KernelEntryGate; + let invokeAfterRevocation!: () => number; + const fatalScopeProbe = vi.fn(); + const onFatal = vi.fn(() => { + expect(() => invokeAfterRevocation()).toThrow( + /scope is no longer active/, + ); + fatalScopeProbe(); + }); + gate = new KernelEntryGate(onFatal); + const continuation = vi.fn(); + + expect(() => gate.runOrDeferVoidIngress( + "async MAP_SHARED transaction", + (scope) => { + invokeAfterRevocation = () => + invokeKernelEntrySerializedHostOperation(scope, () => 11); + invokeKernelEntrySerializedHostOperation( + scope, + () => Promise.resolve().then(continuation), + ); + }, + )).toThrow(/returned a Promise or thenable/); + + expect(onFatal).toHaveBeenCalledOnce(); + expect(fatalScopeProbe).toHaveBeenCalledOnce(); + expect(() => gate.invokeKernelExport("after async escape", () => 1)) + .toThrow(/returned a Promise or thenable/); + await Promise.resolve(); + expect(continuation).toHaveBeenCalledOnce(); + }); + + it("keeps thenable inspection inside the idle host-operation barrier", async () => { + const onFatal = vi.fn(); + const gate = new KernelEntryGate(onFatal); + const order: string[] = []; + const thenable = { + get then(): () => void { + order.push("then:get"); + expect(gate.runOrDeferVoidIngress( + "then getter reentry", + () => { + order.push("queued"); + }, + )).toBe(true); + expect(() => gate.invokeKernelExport( + "then getter result ingress", + () => order.push("stolen"), + )).toThrow(KernelReentrantEntryError); + return () => undefined; + }, + }; + + expect(() => gate.runSerializedHostOperation( + "host-only async result", + () => thenable, + )).toThrow(/returned a Promise or thenable/); + expect(order).toEqual(["then:get"]); + expect(onFatal).toHaveBeenCalledOnce(); + await Promise.resolve(); + expect(order).toEqual(["then:get"]); + }); + + it("rejects coercive export names before scoped or raw lookup", () => { + const gate = new KernelEntryGate(); + const rawCancel = vi.fn(() => 0); + const { gated } = testInstance(4, gate, { + kernel_transfer_scratch_cancel: rawCancel, + }); + const coerce = vi.fn(() => "kernel_transfer_scratch_cancel"); + const hostileName = { toString: coerce } as unknown as string; + + expect(() => hasValidatedKernelEntryExport(gated, hostileName)) + .toThrow(/primitive string/); + expect(() => validatedKernelEntryCallable(gated, hostileName)) + .toThrow(/primitive string/); + expect(coerce).not.toHaveBeenCalled(); + expect(rawCancel).not.toHaveBeenCalled(); + }); + + it("returns only the persistent gated wrapper from callable validation", () => { + const gate = new KernelEntryGate(); + const rawCancel = vi.fn(() => 0); + const { raw, gated } = testInstance(4, gate, { + kernel_transfer_scratch_cancel: rawCancel, + }); + const binding = validatedKernelEntryCallable( + gated, + "kernel_transfer_scratch_cancel", + )!; + const persistent = gated.exports.kernel_transfer_scratch_cancel as + (token: bigint) => number; + const rawCallable = raw.exports.kernel_transfer_scratch_cancel; + + expect(Object.isFrozen(binding)).toBe(true); + expect(binding.call).toBe(persistent); + expect(binding.call).not.toBe(rawCallable); + expect(binding.argumentCount).toBe(1); + + gate.runOrDeferVoidIngress("callable validation scope", (scope) => { + const scoped = createKernelEntryScopedInstance(gated, scope); + const scopedCallable = scoped.exports.kernel_transfer_scratch_cancel as + (token: bigint) => number; + expect(scopedCallable).not.toBe(binding.call); + expect(() => Reflect.apply(binding.call, undefined, [1n])) + .toThrow(KernelReentrantEntryError); + expect(scopedCallable(1n)).toBe(0); + }); + expect(rawCancel).toHaveBeenCalledOnce(); + }); + + it("revokes immediate scope and keeps post reentry behind the whole host phase", async () => { + const gate = new KernelEntryGate(); + const order: string[] = []; + const { gated } = testInstance(4, gate, { + kernel_transfer_scratch_cancel: () => { + order.push("export"); + return 0; + }, + }); + let extractedScoped!: (token: bigint) => number; + + expect(gate.runOrDeferVoidIngress( + "immediate split phase", + (scope, effects) => { + order.push("scoped"); + const scoped = createKernelEntryScopedInstance(gated, scope); + extractedScoped = scoped.exports.kernel_transfer_scratch_cancel as + (token: bigint) => number; + expect(extractedScoped(1n)).toBe(0); + effects.deferObserverEffect(() => { + order.push("post:first"); + expect(() => extractedScoped(2n)).toThrow( + /scope is no longer active/, + ); + const ordinary = gated.exports.kernel_transfer_scratch_cancel as + (token: bigint) => number; + expect(() => ordinary(3n)).toThrow(KernelReentrantEntryError); + expect(gate.runOrDeferVoidIngress( + "post reentry", + (reentryScope) => { + order.push("reentry"); + const reentryInstance = createKernelEntryScopedInstance( + gated, + reentryScope, + ); + const cancel = + reentryInstance.exports.kernel_transfer_scratch_cancel as + (token: bigint) => number; + expect(cancel(4n)).toBe(0); + }, + )).toBe(true); + order.push("post:second"); + }); + }, + )).toBe(false); + + expect(order).toEqual([ + "scoped", + "export", + "post:first", + "post:second", + ]); + await Promise.resolve(); + expect(order).toEqual([ + "scoped", + "export", + "post:first", + "post:second", + "reentry", + "export", + ]); + }); + + it("reports an immediate observer failure without poisoning the gate", () => { + const gate = new KernelEntryGate(); + const failure = new Error("immediate observer failed"); + const after = vi.fn(() => 0); + const { gated } = testInstance(4, gate, { + kernel_transfer_scratch_cancel: after, + }); + + expect(() => gate.runOrDeferVoidIngress( + "immediate observer", + (_scope, effects) => { + effects.deferObserverEffect(() => { + throw failure; + }); + }, + )).not.toThrow(); + + const cancel = gated.exports.kernel_transfer_scratch_cancel as + (token: bigint) => number; + expect(cancel(1n)).toBe(0); + expect(after).toHaveBeenCalledOnce(); + }); + + it("stops every later effect when an observer latches the gate fatal", () => { + const gate = new KernelEntryGate(); + const fatal = new Error("observer requested kernel shutdown"); + const order: string[] = []; + + expect(gate.runOrDeferVoidIngress( + "observer fatal latch", + (_scope, effects) => { + effects.deferObserverEffect(() => { + order.push("observer"); + gate.fail(fatal); + }); + effects.deferProtocolEffect(() => { + order.push("protocol-after-fatal"); + }); + effects.deferObserverEffect(() => { + order.push("observer-after-fatal"); + }); + }, + )).toBe(false); + + expect(order).toEqual(["observer"]); + expect(() => gate.invokeKernelExport("after fatal", () => {})) + .toThrow(fatal); + }); + + it("reports a trapped scoped export only after all entry authority is revoked", () => { + let gate!: KernelEntryGate; + let scopedCancel!: (token: bigint) => number; + const queuedIngress = vi.fn(); + const rawReentry = vi.fn(() => 0); + const onFatal = vi.fn((failure: Error) => { + expect(() => scopedCancel(2n)).toThrow(/scope is no longer active/); + expect(gate.runOrDeferVoidIngress( + "fatal observer ingress", + () => { + queuedIngress(); + }, + )).toBe(true); + expect(queuedIngress).not.toHaveBeenCalled(); + expect(() => gate.invokeKernelExport( + "fatal observer export", + rawReentry, + )).toThrow(failure); + expect(rawReentry).not.toHaveBeenCalled(); + }); + gate = new KernelEntryGate(onFatal); + const rawTrap = new Error("synthetic scoped export trap"); + const { gated } = testInstance(4, gate, { + kernel_transfer_scratch_cancel: () => { + throw rawTrap; + }, + }); + + let trapped!: Error & { cause?: unknown }; + try { + gate.runOrDeferVoidIngress( + "scoped export trap", + (scope) => { + const scoped = createKernelEntryScopedInstance(gated, scope); + scopedCancel = scoped.exports.kernel_transfer_scratch_cancel as + (token: bigint) => number; + scopedCancel(1n); + }, + ); + } catch (error) { + trapped = error as Error & { cause?: unknown }; + } + + expect(trapped.message).toBe( + "kernel export kernel_transfer_scratch_cancel failed", + ); + expect(trapped.cause).toBe(rawTrap); + expect(onFatal).toHaveBeenCalledOnce(); + expect(onFatal).toHaveBeenCalledWith(trapped); + }); + + it("brands a trapped export inside its live scope and preserves the brand through rethrow", () => { + const rawTrap = new Error("synthetic scoped export trap"); + const onFatal = vi.fn(); + const gate = new KernelEntryGate(onFatal); + const { gated } = testInstance(4, gate, { + kernel_transfer_scratch_cancel: () => { + throw rawTrap; + }, + }); + let caughtInScope!: Error & { cause?: unknown }; + let rethrown!: Error; + + try { + gate.runOrDeferVoidIngress( + "same-scope export catch", + (scope) => { + const scoped = createKernelEntryScopedInstance(gated, scope); + const cancel = scoped.exports.kernel_transfer_scratch_cancel as + (token: bigint) => number; + try { + cancel(1n); + } catch (error) { + expect(isKernelExportFailure(error)).toBe(true); + expect(onFatal).not.toHaveBeenCalled(); + caughtInScope = error as Error & { cause?: unknown }; + try { + throw error; + } catch (sameError) { + rethrown = sameError as Error; + expect(isKernelExportFailure(sameError)).toBe(true); + } + throw error; + } + }, + ); + } catch (error) { + expect(error).toBe(caughtInScope); + } + + expect(rethrown).toBe(caughtInScope); + expect(caughtInScope.cause).toBe(rawTrap); + expect(isKernelExportFailure(caughtInScope)).toBe(true); + expect(onFatal).toHaveBeenCalledOnce(); + expect(onFatal).toHaveBeenCalledWith(caughtInScope); + }); + + it("keeps the export-failure brand non-forgeable under mutable WeakSet hooks", () => { + const gate = new KernelEntryGate(); + const rawTrap = new Error("raw export trap"); + let actual!: Error & { cause?: unknown }; + try { + gate.invokeKernelExport("trapping export", () => { + throw rawTrap; + }); + } catch (error) { + actual = error as Error & { cause?: unknown }; + } + const sameFields = Object.assign( + new Error(actual.message), + { cause: actual.cause }, + ); + const inheritedFields = Object.create(actual) as Error; + const primitiveValues: unknown[] = [ + undefined, + null, + false, + 0, + "kernel export trapping export failed", + ]; + + expect(isKernelExportFailure(actual)).toBe(true); + expect(isKernelExportFailure(sameFields)).toBe(false); + expect(isKernelExportFailure(inheritedFields)).toBe(false); + for (const value of primitiveValues) { + expect(isKernelExportFailure(value)).toBe(false); + } + + const hasSpy = vi + .spyOn(WeakSet.prototype, "has") + .mockImplementation(() => true); + try { + expect(isKernelExportFailure(actual)).toBe(true); + expect(isKernelExportFailure(sameFields)).toBe(false); + expect(hasSpy).not.toHaveBeenCalled(); + } finally { + hasSpy.mockRestore(); + } + }); + + it("does not brand backend, reentry, or non-export gate failures", () => { + const backendFailure = new Error("backend failure"); + const backendGate = new KernelEntryGate(); + let caughtBackend!: unknown; + try { + backendGate.runSerializedHostOperation("backend operation", () => { + throw backendFailure; + }); + } catch (error) { + caughtBackend = error; + } + expect(caughtBackend).toBe(backendFailure); + expect(isKernelExportFailure(caughtBackend)).toBe(false); + + let reentryFailure!: unknown; + backendGate.invokeKernelExport("outer export", () => { + try { + backendGate.invokeKernelExport("nested export", () => undefined); + } catch (error) { + reentryFailure = error; + } + }); + expect(reentryFailure).toBeInstanceOf(KernelReentrantEntryError); + expect(isKernelExportFailure(reentryFailure)).toBe(false); + + const invalidIngressGate = new KernelEntryGate(); + let invalidIngressFailure!: unknown; + try { + invalidIngressGate.runOrDeferVoidIngress( + "invalid non-export ingress", + (() => 1) as unknown as () => undefined, + ); + } catch (error) { + invalidIngressFailure = error; + } + expect(invalidIngressFailure).toBeInstanceOf(Error); + expect(isKernelExportFailure(invalidIngressFailure)).toBe(false); + expect(isKernelExportFailure(new Error("ordinary failure"))).toBe(false); + }); + + it("drains reentry queued by a failing immediate observer", async () => { + const gate = new KernelEntryGate(); + const failure = new Error("observer failed after reentry"); + const order: string[] = []; + + expect(() => gate.runOrDeferVoidIngress( + "throwing immediate observer", + (_scope, effects) => { + effects.deferObserverEffect(() => { + expect(gate.runOrDeferVoidIngress( + "queued before throw", + () => { + order.push("queued"); + }, + )).toBe(true); + throw failure; + }); + }, + )).not.toThrow(); + + expect(() => gate.invokeKernelExport( + "overtaking export", + () => order.push("overtook"), + )).toThrow(KernelReentrantEntryError); + expect(order).toEqual([]); + await Promise.resolve(); + expect(order).toEqual(["queued"]); + expect(() => gate.invokeKernelExport( + "after drain", + () => order.push("after"), + )).not.toThrow(); + expect(order).toEqual(["queued", "after"]); + }); + + it("keeps detached reentry behind followers and lends it no drain authority", async () => { + const gate = new KernelEntryGate(); + const order: string[] = []; + const rawCancel = vi.fn(() => { + order.push("export"); + return 0; + }); + const { gated } = testInstance(4, gate, { + kernel_transfer_scratch_cancel: rawCancel, + }); + const ordinary = gated.exports.kernel_transfer_scratch_cancel as + (token: bigint) => number; + let extractedScoped!: (token: bigint) => number; + + gate.invokeKernelExport("outer", () => { + gate.runOrDeferVoidIngress( + "split phase", + (scope, effects) => { + order.push("scoped"); + const scoped = createKernelEntryScopedInstance(gated, scope); + extractedScoped = scoped.exports.kernel_transfer_scratch_cancel as + (token: bigint) => number; + expect(extractedScoped(1n)).toBe(0); + effects.deferObserverEffect(() => { + order.push("post"); + expect(() => extractedScoped(2n)).toThrow( + /scope is no longer active/, + ); + expect(() => ordinary(3n)).toThrow(KernelReentrantEntryError); + expect(gate.runOrDeferVoidIngress( + "post reentry", + (reentryScope) => { + order.push("reentry"); + const reentryInstance = createKernelEntryScopedInstance( + gated, + reentryScope, + ); + const cancel = + reentryInstance.exports.kernel_transfer_scratch_cancel as + (token: bigint) => number; + expect(cancel(4n)).toBe(0); + }, + )).toBe(true); + }); + }, + ); + gate.runOrDeferVoidIngress( + "follower", + () => { + order.push("follower"); + }, + ); + }); + await Promise.resolve(); + + expect(order).toEqual([ + "scoped", + "export", + "post", + "follower", + "reentry", + "export", + ]); + expect(rawCancel).toHaveBeenCalledTimes(2); + }); + + it("revokes transaction scope and admits only a fresh public ingress", async () => { + const gate = new KernelEntryGate(); + const order: string[] = []; + const rawCancel = vi.fn((token: bigint) => { + order.push(`export:${token}`); + return 0; + }); + const { gated } = testInstance(4, gate, { + kernel_transfer_scratch_cancel: rawCancel, + }); + const ordinary = gated.exports.kernel_transfer_scratch_cancel as + (token: bigint) => number; + let extractedScoped!: (token: bigint) => number; + + expect(gate.runOrDeferVoidIngress( + "transaction owner", + (scope, effects) => { + order.push("scope"); + const scoped = createKernelEntryScopedInstance(gated, scope); + extractedScoped = scoped.exports.kernel_transfer_scratch_cancel as + (token: bigint) => number; + effects.deferProtocolTransactionStart(() => { + order.push("transaction"); + // WHY: the transaction can outlive the selected kernel scratch + // bytes. Neither its revoked scope nor the ordinary façade grants + // ambient Wasm authority after this asynchronous boundary. + expect(() => extractedScoped(1n)).toThrow( + /scope is no longer active/, + ); + expect(() => ordinary(2n)).toThrow(KernelReentrantEntryError); + expect(gate.runOrDeferVoidIngress( + "fresh transaction ingress", + (freshScope) => { + order.push("fresh"); + const fresh = createKernelEntryScopedInstance( + gated, + freshScope, + ); + const cancel = + fresh.exports.kernel_transfer_scratch_cancel as + (token: bigint) => number; + expect(cancel(3n)).toBe(0); + }, + )).toBe(false); + }); + }, + )).toBe(false); + expect(order).toEqual(["scope"]); + + await Promise.resolve(); + expect(order).toEqual([ + "scope", + "transaction", + "fresh", + "export:3", + ]); + expect(rawCancel).toHaveBeenCalledOnce(); + }); + + it("queues roots opened by a transaction-start root's detached effects", async () => { + const gate = new KernelEntryGate(); + const order: string[] = []; + + expect(gate.runOrDeferVoidIngress( + "transaction owner", + (_scope, effects) => { + order.push("scope"); + effects.deferProtocolTransactionStart(() => { + order.push("transaction"); + expect(gate.runOrDeferVoidIngress( + "fresh transaction ingress", + (_freshScope, freshEffects) => { + order.push("fresh"); + freshEffects.deferProtocolEffect(() => { + order.push("effect"); + // WHY: this effect is detached from its fresh entry, but the + // surrounding transaction start has not returned yet. A + // second root must join the FIFO instead of nesting detached + // publication and poisoning the kernel generation. + expect(gate.runOrDeferVoidIngress( + "effect follower", + () => { + order.push("follower"); + }, + )).toBe(true); + }); + }, + )).toBe(false); + }); + }, + )).toBe(false); + expect(order).toEqual(["scope"]); + + await Promise.resolve(); + expect(order).toEqual([ + "scope", + "transaction", + "fresh", + "effect", + ]); + await Promise.resolve(); + expect(order).toEqual([ + "scope", + "transaction", + "fresh", + "effect", + "follower", + ]); + }); + + it("keeps an unrelated ingress behind a pending transaction start", async () => { + const gate = new KernelEntryGate(); + const order: string[] = []; + + expect(gate.runOrDeferVoidIngress( + "transaction owner", + (_scope, effects) => { + order.push("scope"); + effects.deferProtocolTransactionStart(() => { + order.push("transaction"); + }); + }, + )).toBe(false); + expect(gate.runOrDeferVoidIngress( + "unrelated follower", + () => { + order.push("follower"); + }, + )).toBe(true); + expect(order).toEqual(["scope"]); + + await Promise.resolve(); + expect(order).toEqual(["scope", "transaction"]); + await Promise.resolve(); + expect(order).toEqual(["scope", "transaction", "follower"]); + }); + + it("uses its captured microtask scheduler for transaction starts", async () => { + vi.resetModules(); + const { + createKernelEntryGatedInstance: createFreshGatedInstance, + KernelEntryGate: FreshKernelEntryGate, + } = await import("../src/kernel-entry-gate"); + const originalQueueMicrotask = globalThis.queueMicrotask; + const replacement = vi.fn((_callback: VoidFunction) => undefined); + const memory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); + const raw = createKernelScratchTestInstance( + 4, + memory, + () => ({}), + () => 4096, + ); + const gate = new FreshKernelEntryGate(); + createFreshGatedInstance(raw, gate); + const started = vi.fn(); + + try { + globalThis.queueMicrotask = replacement; + expect(gate.runOrDeferVoidIngress( + "captured scheduler transaction", + (_scope, effects) => { + effects.deferProtocolTransactionStart(() => { + started(); + }); + }, + )).toBe(false); + } finally { + globalThis.queueMicrotask = originalQueueMicrotask; + } + + await Promise.resolve(); + expect(replacement).not.toHaveBeenCalled(); + expect(started).toHaveBeenCalledOnce(); + }); + + it.each([ + [ + "throws", + () => { + throw new Error("transaction start threw"); + }, + "transaction start threw", + ], + [ + "returns a value", + (() => 1) as unknown as () => undefined, + "returned a value", + ], + ] as const)( + "poisons the gate, reports, and discards followers when a transaction start %s", + async (_description, transactionStart, causeMessage) => { + const onFatal = vi.fn(); + const gate = new KernelEntryGate(onFatal); + const follower = vi.fn(); + + expect(gate.runOrDeferVoidIngress( + "invalid transaction", + (_scope, effects) => { + effects.deferProtocolTransactionStart(transactionStart); + }, + )).toBe(false); + expect(gate.runOrDeferVoidIngress( + "must be discarded", + () => { + follower(); + }, + )).toBe(true); + expect(onFatal).not.toHaveBeenCalled(); + + await Promise.resolve(); + expect(onFatal).toHaveBeenCalledOnce(); + const [failure] = onFatal.mock.calls[0] as [Error & { cause?: unknown }]; + expect(failure.message).toBe( + "protocol transaction start 0 for invalid transaction failed", + ); + expect((failure.cause as Error).message).toContain(causeMessage); + expect(follower).not.toHaveBeenCalled(); + await Promise.resolve(); + expect(follower).not.toHaveBeenCalled(); + expect(() => gate.invokeKernelExport( + "after invalid transaction", + () => undefined, + )).toThrow(failure); + }, + ); + + it("reports a deferred detached failure without poisoning or dropping followers", async () => { + vi.resetModules(); + const report = vi.spyOn(console, "error").mockImplementation(() => {}); + // Import after installing the spy because the authority boundary captures + // reporting intrinsics before any untrusted kernel callback can replace + // them. + const { KernelEntryGate: FreshKernelEntryGate } = await import( + "../src/kernel-entry-gate" + ); + const gate = new FreshKernelEntryGate(); + const order: string[] = []; + const failure = new Error("deferred observer failed"); + + gate.invokeKernelExport("outer", () => { + gate.runOrDeferVoidIngress( + "deferred observer", + (_scope, effects) => { + order.push("scoped"); + effects.deferObserverEffect(() => { + order.push("post"); + throw failure; + }); + }, + ); + gate.runOrDeferVoidIngress( + "follower", + () => { + order.push("follower"); + }, + ); + }); + await Promise.resolve(); + + expect(order).toEqual(["scoped", "post", "follower"]); + expect(report).toHaveBeenCalledWith( + "[kernel-entry-gate] detached host phase failed for deferred observer", + failure, + ); + const after = vi.fn(); + gate.invokeKernelExport("after detached failure", after); + expect(after).toHaveBeenCalledOnce(); + report.mockRestore(); + }); + + it("binds scoped exports only to a registered gated instance", () => { + const gate = new KernelEntryGate(); + const rawCancel = vi.fn(() => 0); + const instance = testInstance(4, gate, { + kernel_transfer_scratch_cancel: rawCancel, + }); + + expect(gate.runOrDeferVoidIngress("scoped façade", (scope) => { + expect(() => createKernelEntryScopedInstance(instance.raw, scope)) + .toThrow(/requires a registered gated instance/); + const scoped = createKernelEntryScopedInstance(instance.gated, scope); + const scopedExports = scoped.exports as Record; + const injected = vi.fn(() => 0); + expect(Reflect.set( + scopedExports, + "kernel_transfer_scratch_cancel", + injected, + )).toBe(false); + expect(Reflect.defineProperty( + scopedExports, + "kernel_transfer_scratch_cancel", + { value: injected }, + )).toBe(false); + expect(Reflect.setPrototypeOf(scopedExports, { injected })).toBe(false); + expect(Reflect.preventExtensions(scopedExports)).toBe(true); + const cancel = scoped.exports.kernel_transfer_scratch_cancel as + (token: bigint) => number; + expect(Reflect.deleteProperty( + scopedExports, + "kernel_transfer_scratch_cancel", + )).toBe(true); + expect(Reflect.set( + scopedExports, + "kernel_transfer_scratch_cancel", + injected, + )).toBe(false); + expect(Reflect.defineProperty( + scopedExports, + "kernel_transfer_scratch_cancel", + { value: injected }, + )).toBe(false); + expect(cancel(1n)).toBe(0); + expect(injected).not.toHaveBeenCalled(); + expect(rawCancel).toHaveBeenCalledOnce(); + })).toBe(false); + }); + + it("rejects a scope from another gate before either export executes", () => { + const gateA = new KernelEntryGate(); + const gateB = new KernelEntryGate(); + const gateBExport = vi.fn(() => 0); + const { gated: gatedB } = testInstance(4, gateB, { + kernel_transfer_scratch_cancel: gateBExport, + }); + + expect(gateA.runOrDeferVoidIngress("gate A", (scopeA) => { + expect(() => createKernelEntryScopedInstance(gatedB, scopeA)) + .toThrow(/does not own the supplied scope/); + })).toBe(false); + expect(gateBExport).not.toHaveBeenCalled(); + }); + + it.each([ + ["a result", () => 1], + ["a Promise", () => Promise.resolve()], + ] as const)( + "poisons the gate when void ingress returns %s", + (_description, operation) => { + const onFatal = vi.fn(); + const gate = new KernelEntryGate(onFatal); + + let failure!: Error & { cause?: unknown }; + try { + gate.runOrDeferVoidIngress( + "invalid void operation", + operation, + ); + } catch (error) { + failure = error as Error & { cause?: unknown }; + } + + expect(failure.message).toBe( + "void kernel ingress invalid void operation failed", + ); + expect((failure.cause as Error).message).toBe( + "void kernel ingress invalid void operation must return undefined synchronously", + ); + expect(onFatal).toHaveBeenCalledOnce(); + expect(onFatal).toHaveBeenCalledWith(failure); + expect(() => gate.invokeKernelExport("after failure", () => {})) + .toThrow(failure); + }, + ); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s keeps queued ingress ahead of pre-existing Promise callbacks", + async (_name, pointerWidth) => { + const gate = new KernelEntryGate(); + const order: string[] = []; + const laterRaw = vi.fn(() => 0); + let gated!: WebAssembly.Instance; + const implementations: Record = { + kernel_handle_channel: vi.fn(() => { + expect(gate.runOrDeferVoidIngress( + "first arrival", + () => { + order.push("first"); + const later = gated.exports.kernel_transfer_scratch_cancel as + (token: bigint) => number; + expect(() => later(1n)).toThrow(KernelReentrantEntryError); + }, + )).toBe(true); + return 0; + }), + kernel_transfer_scratch_cancel: laterRaw, + }; + const instance = testInstance(pointerWidth, gate, implementations); + gated = instance.gated; + const handle = gated.exports.kernel_handle_channel as ( + pointer: number | bigint, + capacity: number, + width: number, + retryToken: bigint, + ) => number; + const later = gated.exports.kernel_transfer_scratch_cancel as + (token: bigint) => number; + + // Queue this callback before the outer export queues the gate's drain. + // Its ingress still arrived after "first" and may not overtake it. + const preExistingPromise = Promise.resolve().then(() => { + expect(gate.runOrDeferVoidIngress( + "second arrival", + () => { + order.push("second"); + }, + )).toBe(true); + expect(() => later(2n)).toThrow(KernelReentrantEntryError); + }); + + expect(handle( + pointerWidth === 8 ? 0n : 0, + 0, + pointerWidth, + 0n, + )).toBe(0); + expect(() => later(3n)).toThrow(KernelReentrantEntryError); + expect(order).toEqual([]); + + await preExistingPromise; + await Promise.resolve(); + expect(order).toEqual(["first", "second"]); + // Generic FIFO callbacks and both overtaking attempts have zero export + // authority. Only the later call after the queue is empty reaches Wasm. + expect(laterRaw).not.toHaveBeenCalled(); + expect(later(4n)).toBe(0); + expect(laterRaw).toHaveBeenCalledOnce(); + }, + ); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s poisons the generation when an export unwinds exceptionally", + async (_name, pointerWidth) => { + const fatal = new Error("synthetic kernel export trap"); + const onFatal = vi.fn(); + const gate = new KernelEntryGate(onFatal); + const deferred = vi.fn(); + const implementations: Record = { + kernel_handle_channel: vi.fn(() => { + expect(gate.runOrDeferVoidIngress( + "must not run after trap", + () => { + deferred(); + }, + )).toBe(true); + throw fatal; + }), + }; + const { gated } = testInstance(pointerWidth, gate, implementations); + const handle = gated.exports.kernel_handle_channel as ( + pointer: number | bigint, + capacity: number, + width: number, + retryToken: bigint, + ) => number; + + let trapped!: Error & { cause?: unknown }; + try { + handle( + pointerWidth === 8 ? 0n : 0, + 0, + pointerWidth, + 0n, + ); + } catch (error) { + trapped = error as Error & { cause?: unknown }; + } + expect(trapped).toBeInstanceOf(Error); + expect(trapped.message).toBe( + "kernel export kernel_handle_channel failed", + ); + expect(trapped.cause).toBe(fatal); + expect(onFatal).toHaveBeenCalledOnce(); + expect(onFatal).toHaveBeenCalledWith(trapped); + await Promise.resolve(); + expect(deferred).not.toHaveBeenCalled(); + expect(() => handle( + pointerWidth === 8 ? 0n : 0, + 0, + pointerWidth, + 0n, + )).toThrow(trapped); + expect(onFatal).toHaveBeenCalledOnce(); + }, + ); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s discards deferred work after a fatal latch", + async (_name, pointerWidth) => { + const gate = new KernelEntryGate(); + const deferred = vi.fn(); + const fatal = new Error("synthetic fatal kernel state"); + const implementations: Record = { + kernel_transfer_io_execute: vi.fn(() => { + expect(gate.runOrDeferVoidIngress( + "must be discarded", + () => { + deferred(); + }, + )).toBe(true); + gate.fail(fatal); + return 0; + }), + }; + const { gated } = testInstance(pointerWidth, gate, implementations); + const execute = gated.exports.kernel_transfer_io_execute as ( + pid: number, + tid: number, + token: bigint, + length: number | bigint, + syscall: number, + fd: number, + offset: bigint, + retryToken: bigint, + ) => number; + + expect(execute( + 1, + 1, + 1n, + pointerWidth === 8 ? 0n : 0, + 0, + 0, + 0n, + 0n, + )).toBe(0); + await Promise.resolve(); + expect(deferred).not.toHaveBeenCalled(); + expect(() => execute( + 1, + 1, + 1n, + pointerWidth === 8 ? 0n : 0, + 0, + 0, + 0n, + 0n, + )).toThrow(fatal); + }, + ); +}); diff --git a/host/test/kernel-exec-entry.test.ts b/host/test/kernel-exec-entry.test.ts new file mode 100644 index 0000000000..b1b350453b --- /dev/null +++ b/host/test/kernel-exec-entry.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createCentralizedKernelWorkerTestDouble, + type CentralizedKernelWorker, +} from "../src/kernel-worker"; +import { + createKernelEntryGatedInstance, + KernelEntryGate, + KernelReentrantEntryError, +} from "../src/kernel-entry-gate"; +import { allocateKernelScratchRegion } from "../src/kernel-scratch"; +import { CH_TOTAL_SIZE } from "../src/generated/abi"; +import type { PlatformIO } from "../src/types"; +import { createKernelScratchTestInstance } from "./support/kernel-scratch-instance"; + +const KERNEL_EXPORT_NAMES = [ + "kernel_drain_wakeup_events", + "kernel_exec_prepare", + "kernel_exec_setup_for_thread", + "kernel_fd_is_open", + "kernel_find_listener_fd_by_accept_wake", + "kernel_get_fd_accept_wake_idx", + "kernel_vblank", +] as const; + +interface TestTcpListener { + readonly server: { close(): void }; + readonly pid: number; + readonly port: number; + readonly connections: Set; +} + +interface ExecWorkerState { + currentHandlePid: number; + epollInterests: Map< + string, + Array<{ fd: number; events: number; data: bigint }> + >; + tcpListenerTargets: Map< + number, + Array<{ pid: number; fd: number; acceptWakeIdx?: number }> + >; + tcpListenerRRIndex: Map; + tcpListeners: Map; + tcpVirtualListenerKeys: Map; +} + +interface ExecEntryHarness { + readonly worker: CentralizedKernelWorker; + readonly gatedInstance: WebAssembly.Instance; + readonly implementations: Record; +} + +function execState(worker: CentralizedKernelWorker): ExecWorkerState { + return worker as unknown as ExecWorkerState; +} + +function makeHarness( + implementations: Record, + io?: Partial, +): ExecEntryHarness { + const kernelMemory = new WebAssembly.Memory({ + initial: 4, + maximum: 4, + }); + const gate = new KernelEntryGate(); + const rawInstance = createKernelScratchTestInstance( + 4, + kernelMemory, + () => implementations, + () => 4_096, + 4, + KERNEL_EXPORT_NAMES, + ); + const gatedInstance = createKernelEntryGatedInstance(rawInstance, gate); + const mainScratch = allocateKernelScratchRegion( + kernelMemory, + gatedInstance.exports.kernel_alloc_scratch as (size: number) => number, + CH_TOTAL_SIZE, + 4, + "exec entry test scratch", + gatedInstance, + ); + const worker = createCentralizedKernelWorkerTestDouble({ + io: io as PlatformIO | undefined, + }); + const authority = ( + worker as unknown as { + readonly testAuthority: { + initializeKernelForTest(options: { + readonly instance: WebAssembly.Instance; + readonly gate: KernelEntryGate; + readonly mainScratch: typeof mainScratch; + }): void; + }; + } + ).testAuthority; + authority.initializeKernelForTest({ + instance: gatedInstance, + gate, + mainScratch, + }); + return { worker, gatedInstance, implementations }; +} + +describe("kernel exec entry authority", () => { + it("rejects synchronous exec results during a live export", async () => { + const prepare = vi.fn(() => 0); + const setup = vi.fn(() => 0); + const drain = vi.fn(() => 0); + const caught: unknown[] = []; + let harness!: ExecEntryHarness; + harness = makeHarness({ + kernel_drain_wakeup_events: drain, + kernel_exec_prepare: prepare, + kernel_exec_setup_for_thread: setup, + kernel_fd_is_open: () => 0, + kernel_find_listener_fd_by_accept_wake: () => -1, + kernel_get_fd_accept_wake_idx: () => -1, + kernel_vblank: () => { + for (const operation of [ + () => harness.worker.kernelExecPrepare(7, 11), + () => harness.worker.kernelExecSetup(7, 11), + ]) { + try { + operation(); + } catch (error) { + caught.push(error); + } + } + return 0; + }, + }); + + (harness.gatedInstance.exports.kernel_vblank as () => number)(); + await Promise.resolve(); + + expect(caught).toHaveLength(2); + for (const error of caught) { + expect(error).toBeInstanceOf(KernelReentrantEntryError); + } + expect(prepare).not.toHaveBeenCalled(); + expect(setup).not.toHaveBeenCalled(); + expect(drain).not.toHaveBeenCalled(); + + // Rejection does not queue an authority result or poison the generation. + expect(harness.worker.kernelExecPrepare(7, 11)).toBe(0); + expect(harness.worker.kernelExecSetup(7, 11)).toBe(0); + expect(prepare).toHaveBeenCalledOnce(); + expect(setup).toHaveBeenCalledOnce(); + expect(drain).toHaveBeenCalledTimes(2); + }); + + it("publishes a complete mirror plan before closing host listeners", () => { + const observations: Array<{ + readonly phase: string; + readonly epollPresent: boolean; + readonly targetsPresent: boolean; + readonly listenerPresent: boolean; + readonly virtualKeyPresent: boolean; + readonly currentHandlePid: number; + }> = []; + const observe = (phase: string, state: ExecWorkerState): void => { + observations.push({ + phase, + epollPresent: state.epollInterests.has("7:6"), + targetsPresent: state.tcpListenerTargets.has(8080), + listenerPresent: state.tcpListeners.has("7:4"), + virtualKeyPresent: state.tcpVirtualListenerKeys.has(8080), + currentHandlePid: state.currentHandlePid, + }); + }; + const closeVirtual = vi.fn(); + let state!: ExecWorkerState; + let committed = false; + const closeServer = vi.fn(() => observe("server close", state)); + const harness = makeHarness( + { + kernel_drain_wakeup_events: () => { + observe("wake drain", state); + return 0; + }, + kernel_exec_prepare: () => 0, + kernel_exec_setup_for_thread: () => { + expect(state.currentHandlePid).toBe(7); + committed = true; + return 0; + }, + kernel_fd_is_open: (_pid: number, _fd: number) => { + expect(committed).toBe(true); + // The scoped query phase must not expose a partial host replacement. + expect(state.tcpListenerTargets.has(8080)).toBe(true); + expect(state.tcpListeners.has("7:4")).toBe(true); + return 0; + }, + kernel_find_listener_fd_by_accept_wake: () => -1, + kernel_get_fd_accept_wake_idx: () => -1, + kernel_vblank: () => 0, + }, + { + network: { + closeTcpListener: (key: string) => { + closeVirtual(key); + observe("virtual close", state); + }, + }, + } as unknown as Partial, + ); + state = execState(harness.worker); + state.currentHandlePid = 0; + state.epollInterests = new Map([ + ["7:6", [{ fd: 9, events: 1, data: 11n }]], + ]); + state.tcpListenerTargets = new Map([ + [8080, [{ pid: 7, fd: 4, acceptWakeIdx: 41 }]], + ]); + state.tcpListenerRRIndex = new Map([[8080, 3]]); + state.tcpListeners = new Map([ + ["7:4", { + server: { close: closeServer }, + pid: 7, + port: 8080, + connections: new Set(), + }], + ]); + state.tcpVirtualListenerKeys = new Map([[8080, "virtual:7:4"]]); + + expect(harness.worker.kernelExecSetup(7, 11)).toBe(0); + + expect(observations).toEqual([ + { + phase: "wake drain", + epollPresent: true, + targetsPresent: true, + listenerPresent: true, + virtualKeyPresent: true, + currentHandlePid: 0, + }, + { + phase: "virtual close", + epollPresent: false, + targetsPresent: false, + listenerPresent: false, + virtualKeyPresent: false, + currentHandlePid: 0, + }, + { + phase: "server close", + epollPresent: false, + targetsPresent: false, + listenerPresent: false, + virtualKeyPresent: false, + currentHandlePid: 0, + }, + ]); + expect(closeVirtual).toHaveBeenCalledExactlyOnceWith("virtual:7:4"); + expect(closeServer).toHaveBeenCalledOnce(); + expect(state.tcpListenerRRIndex.has(8080)).toBe(false); + }); + + it("keeps every host mirror intact when exec setup fails", () => { + const fdIsOpen = vi.fn(() => 0); + const harness = makeHarness({ + kernel_drain_wakeup_events: () => 0, + kernel_exec_prepare: () => 0, + kernel_exec_setup_for_thread: () => -5, + kernel_fd_is_open: fdIsOpen, + kernel_find_listener_fd_by_accept_wake: () => -1, + kernel_get_fd_accept_wake_idx: () => -1, + kernel_vblank: () => 0, + }); + const state = execState(harness.worker); + const interests = [{ fd: 9, events: 1, data: 11n }]; + state.epollInterests = new Map([["7:6", interests]]); + + expect(harness.worker.kernelExecSetup(7, 11)).toBe(-5); + expect(state.epollInterests.get("7:6")).toBe(interests); + expect(fdIsOpen).not.toHaveBeenCalled(); + }); +}); diff --git a/host/test/kernel-export-failure-audit.test.ts b/host/test/kernel-export-failure-audit.test.ts new file mode 100644 index 0000000000..aff4c73658 --- /dev/null +++ b/host/test/kernel-export-failure-audit.test.ts @@ -0,0 +1,270 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { + auditKernelExportFailureCatches, + formatKernelExportFailureAudit, + type KernelExportFailureCatchAllowance, +} from "./support/kernel-export-failure-audit"; + +const reservationSettlementAllowances = [ + { + owner: + "CentralizedKernelWorker.#executeReservedChannelDispatch", + why: + "The catch records the branded execute trap, then its finally revokes " + + "the lease and deliberately skips cancellation because Rust settlement " + + "is unknown before throwing one fatal wrapper.", + }, + { + owner: + "CentralizedKernelWorker.#executeReservedScratchTransfer", + why: + "The catch records the branded execute trap, then its finally revokes " + + "the lease and deliberately skips cancellation because Rust settlement " + + "is unknown before throwing one fatal wrapper.", + }, + { + owner: + "CentralizedKernelWorker.#handleSpawnAfterResolve", + why: + "The reserved spawn catch records the branded commit trap, then its " + + "finally revokes the lease and skips cancellation because Rust " + + "settlement is unknown before throwing one fatal wrapper.", + }, +] satisfies KernelExportFailureCatchAllowance[]; + +describe("kernel export-failure catch audit", () => { + it("rejects an errno fallback before the gate's deferred fatal observer", () => { + const source = ` + class CentralizedKernelWorker { + #invokeEntryScratchExport(): void {} + #dispatch(): void { + this.#invokeEntryScratchExport(); + } + handle(): number { + try { + this.#dispatch(); + return 0; + } catch (error) { + return 5; + } + } + } + `; + expect( + auditKernelExportFailureCatches(source).violations, + ).toEqual([ + expect.objectContaining({ + owner: "CentralizedKernelWorker.handle", + }), + ]); + }); + + it("follows an export callable extracted before the guarded try", () => { + const source = ` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + } + class CentralizedKernelWorker { + handle(entry: KernelWorkerEntryContext): number { + const fn = entry.instance.exports.some_export as () => number; + try { + return fn(); + } catch { + return 5; + } + } + } + `; + expect( + auditKernelExportFailureCatches(source).violations, + ).toEqual([ + expect.objectContaining({ + owner: "CentralizedKernelWorker.handle", + }), + ]); + }); + + it("follows callable properties selected through an export namespace alias", () => { + const source = ` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + } + class CentralizedKernelWorker { + handle(entry: KernelWorkerEntryContext): number { + const kernelExports = entry.instance.exports; + const begin = kernelExports.kernel_transfer_scratch_begin as + () => number; + try { + return begin(); + } catch { + return 5; + } + } + } + `; + expect( + auditKernelExportFailureCatches(source).violations, + ).toEqual([ + expect.objectContaining({ + owner: "CentralizedKernelWorker.handle", + }), + ]); + }); + + it("follows a callable destructured from an export namespace", () => { + const source = ` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + } + class CentralizedKernelWorker { + handle(entry: KernelWorkerEntryContext): number { + const kernelExports = entry.instance.exports; + const { kernel_transfer_scratch_begin: begin } = kernelExports; + try { + return begin(); + } catch { + return 5; + } + } + } + `; + expect( + auditKernelExportFailureCatches(source).violations, + ).toHaveLength(1); + }); + + it("accepts the exact branded value as the first catch action", () => { + const source = ` + class CentralizedKernelWorker { + #invokeEntryScratchExport(): void {} + #rethrowKernelEntryFatal(_error: unknown): void {} + #capacityOwnedOrdinary(): void { + this.#invokeEntryScratchExport(); + } + #capacityOwnedReserved(): void { + this.#capacityOwnedOrdinary(); + } + handle(useReserved: boolean): number { + try { + if (useReserved) this.#capacityOwnedReserved(); + else this.#capacityOwnedOrdinary(); + return 0; + } catch (error) { + this.#rethrowKernelEntryFatal(error); + return 5; + } + } + } + `; + expect(formatKernelExportFailureAudit( + auditKernelExportFailureCatches(source), + )).toEqual([]); + }); + + it("requires an explicit owner allowance for deferred settlement", () => { + const source = ` + function isKernelExportFailure(_error: unknown): boolean { + return false; + } + class CentralizedKernelWorker { + #invokeEntryScratchExport(): void {} + #reserved(): void { + try { + this.#invokeEntryScratchExport(); + } catch (error) { + if (isKernelExportFailure(error)) { + const fatal = error; + void fatal; + } + } + } + } + `; + expect( + auditKernelExportFailureCatches(source).violations, + ).toHaveLength(1); + expect(formatKernelExportFailureAudit( + auditKernelExportFailureCatches(source, [{ + owner: "CentralizedKernelWorker.#reserved", + why: "The fixture models settlement in finally.", + }]), + )).toEqual([]); + }); + + it("rejects duplicate and empty-WHY settlement allowances", () => { + const source = ` + class CentralizedKernelWorker { + #invokeEntryScratchExport(): void {} + #reserved(): void { + try { + this.#invokeEntryScratchExport(); + } catch (error) { + if (isKernelExportFailure(error)) void error; + } + } + } + `; + const result = auditKernelExportFailureCatches(source, [ + { + owner: "CentralizedKernelWorker.#reserved", + why: "", + }, + { + owner: "CentralizedKernelWorker.#reserved", + why: "duplicate", + }, + ]); + expect(result.contractErrors).toEqual([ + "kernel-export catch allowance " + + "CentralizedKernelWorker.#reserved has an empty WHY", + "duplicate kernel-export catch allowance: " + + "CentralizedKernelWorker.#reserved", + ]); + }); + + it("requires one owner allowance to match exactly one catch", () => { + const source = ` + class CentralizedKernelWorker { + #invokeEntryScratchExport(): void {} + #reserved(): void { + try { + this.#invokeEntryScratchExport(); + } catch (first) { + if (isKernelExportFailure(first)) void first; + } + try { + this.#invokeEntryScratchExport(); + } catch (second) { + if (isKernelExportFailure(second)) void second; + } + } + } + `; + const result = auditKernelExportFailureCatches(source, [{ + owner: "CentralizedKernelWorker.#reserved", + why: "Each fixture catch models a distinct deferred settlement.", + }]); + expect(result.contractErrors).toEqual([ + "kernel-export catch allowance CentralizedKernelWorker.#reserved " + + "matched 2 catches; each allowance must identify exactly one " + + "settlement catch", + ]); + }); + + it("guards every live worker catch that can receive an export trap", () => { + const source = readFileSync( + new URL("../src/kernel-worker.ts", import.meta.url), + "utf8", + ); + const result = auditKernelExportFailureCatches( + source, + reservationSettlementAllowances, + ); + expect(formatKernelExportFailureAudit(result)).toEqual([]); + expect(result.exportBearingOwners).toEqual(expect.arrayContaining([ + "CentralizedKernelWorker.#beginLargeSpawnScratch", + "CentralizedKernelWorker.#beginLargeTransferScratch", + ])); + }); +}); diff --git a/host/test/kernel-initialization-lifetime.test.ts b/host/test/kernel-initialization-lifetime.test.ts index c2e905b6e2..f6674503e6 100644 --- a/host/test/kernel-initialization-lifetime.test.ts +++ b/host/test/kernel-initialization-lifetime.test.ts @@ -1,6 +1,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { WasmPosixKernel } from "../src/kernel"; +import { + createWasmPosixKernelTestHarness, + WasmPosixKernel, +} from "../src/kernel"; import { createKernelScratchTestInstance } from "./support/kernel-scratch-instance"; const emptyModule = new WebAssembly.Module(new Uint8Array([ @@ -22,34 +25,36 @@ function memoryImportModule(pointerWidth: 4 | 8): Uint8Array { ]); } -function kernel(): WasmPosixKernel { - return new WasmPosixKernel( - { +type TestEngine = { + compile: (bytes: BufferSource) => Promise; + instantiate: ( + module: WebAssembly.Module, + imports: WebAssembly.Imports, + ) => Promise; +}; + +function kernel(engine: TestEngine): WasmPosixKernel { + return createWasmPosixKernelTestHarness({ + config: { maxWorkers: 1, dataBufferSize: 65_536, useSharedMemory: true, }, - {} as never, - ); + engine, + initialized: false, + }); } function installSuccessfulEngine( pointerWidth: 4 | 8, implementations: Record, allocator: (capacity: number) => number | bigint, -): { - compile: ReturnType; - instantiate: ReturnType; - memory: () => WebAssembly.Memory; -} { - const compile = vi - .spyOn(WebAssembly, "compile") - .mockResolvedValue(emptyModule); +){ + const compile = vi.fn(async (_bytes: BufferSource) => emptyModule); let activeMemory: WebAssembly.Memory | null = null; - const instantiate = vi.spyOn(WebAssembly, "instantiate"); - instantiate.mockImplementation((async ( + const instantiate = vi.fn(async ( _module: WebAssembly.Module, - importObject?: WebAssembly.Imports, + importObject: WebAssembly.Imports, ) => { activeMemory = ( importObject as { env: { memory: WebAssembly.Memory } } @@ -59,11 +64,13 @@ function installSuccessfulEngine( activeMemory, () => implementations, allocator, + pointerWidth, ); - }) as never); + }); return { compile, instantiate, + engine: { compile, instantiate }, memory: () => { if (!activeMemory) throw new Error("kernel memory was not instantiated"); return activeMemory; @@ -79,7 +86,6 @@ describe("WasmPosixKernel initialization lifetime", () => { it.each([4, 8] as const)( "keeps wasm%d public and audio scratch bound to its first generation", async (pointerWidth) => { - const instance = kernel(); let allocationIndex = 0; let activeMemory: WebAssembly.Memory; const allocator = vi.fn((_capacity: number) => { @@ -118,46 +124,29 @@ describe("WasmPosixKernel initialization lifetime", () => { }, allocator, ); - const suppliedMemory = new WebAssembly.Memory({ - initial: 4, - maximum: 4, - shared: true, - }); - - if (pointerWidth === 4) { - await instance.init(memoryImportModule(pointerWidth)); - } else { - await instance.initWithMemory( - memoryImportModule(pointerWidth), - suppliedMemory, - ); - } + const instance = kernel(engine.engine); + await instance.init(memoryImportModule(pointerWidth)); activeMemory = engine.memory(); - const firstMemory = instance.getMemory(); - const firstInstance = instance.getInstance(); + const firstMemoryPages = instance.getMemoryPageCount(); + expect(firstMemoryPages).not.toBeNull(); + expect(Object.getOwnPropertyNames(instance)).not.toContain("memory"); + expect(Object.getOwnPropertyNames(instance)).not.toContain("instance"); + expect(Object.getOwnPropertyNames(instance)).not.toContain("rawInstance"); + expect(Object.getOwnPropertyNames(instance)).not.toContain( + "kernelEntryGate", + ); expect(instance.send(7, new Uint8Array([1, 2, 3, 4]))).toBe(4); const firstAudio = new Uint8Array(4); expect(instance.drainAudio(firstAudio)).toBe(4); expect(firstAudio).toEqual(new Uint8Array([9, 8, 7, 6])); - const replacementMemory = new WebAssembly.Memory({ - initial: 4, - maximum: 4, - shared: true, - }); - const replacement = pointerWidth === 4 - ? instance.initWithMemory( - memoryImportModule(pointerWidth), - replacementMemory, - ) - : instance.init(memoryImportModule(pointerWidth)); + const replacement = instance.init(memoryImportModule(pointerWidth)); await expect(replacement).rejects.toThrow(/already initialized/i); expect(engine.compile).toHaveBeenCalledOnce(); expect(engine.instantiate).toHaveBeenCalledOnce(); - expect(instance.getMemory()).toBe(firstMemory); - expect(instance.getInstance()).toBe(firstInstance); + expect(instance.getMemoryPageCount()).toBe(firstMemoryPages); expect(instance.getKernelPtrWidth()).toBe(pointerWidth); expect(instance.send(7, new Uint8Array([1, 2, 3, 4]))).toBe(4); @@ -171,18 +160,14 @@ describe("WasmPosixKernel initialization lifetime", () => { ); it("rejects a concurrent initializer before it can replace candidate state", async () => { - const instance = kernel(); let releaseCompile!: (module: WebAssembly.Module) => void; const compileGate = new Promise((resolve) => { releaseCompile = resolve; }); - const compile = vi - .spyOn(WebAssembly, "compile") - .mockReturnValue(compileGate); - const instantiate = vi.spyOn(WebAssembly, "instantiate"); - instantiate.mockImplementation((async ( + const compile = vi.fn((_bytes: BufferSource) => compileGate); + const instantiate = vi.fn(async ( _module: WebAssembly.Module, - importObject?: WebAssembly.Imports, + importObject: WebAssembly.Imports, ) => { const memory = ( importObject as { env: { memory: WebAssembly.Memory } } @@ -193,66 +178,56 @@ describe("WasmPosixKernel initialization lifetime", () => { () => ({}), () => 4_096, ); - }) as never); + }); + const instance = kernel({ compile, instantiate }); const first = instance.init(memoryImportModule(4)); - const competingMemory = new WebAssembly.Memory({ - initial: 2, - maximum: 2, - shared: true, - }); await expect( - instance.initWithMemory(memoryImportModule(4), competingMemory), + instance.init(memoryImportModule(4)), ).rejects.toThrow(/already in progress/i); releaseCompile(emptyModule); await expect(first).resolves.toBeUndefined(); expect(compile).toHaveBeenCalledOnce(); expect(instantiate).toHaveBeenCalledOnce(); - expect(instance.getMemory()).not.toBe(competingMemory); + expect(instance.getMemoryPageCount()).not.toBeNull(); }); it("clears a failed first instantiation and permits one clean retry", async () => { - const instance = kernel(); const failure = new Error("synthetic instantiation failure"); - const compile = vi - .spyOn(WebAssembly, "compile") - .mockResolvedValue(emptyModule); + const compile = vi.fn(async (_bytes: BufferSource) => emptyModule); let attempt = 0; - const instantiate = vi.spyOn(WebAssembly, "instantiate"); - instantiate.mockImplementation((async ( + let activeMemory: WebAssembly.Memory | null = null; + const instantiate = vi.fn(async ( _module: WebAssembly.Module, - importObject?: WebAssembly.Imports, + importObject: WebAssembly.Imports, ) => { if (attempt++ === 0) throw failure; - const memory = ( + activeMemory = ( importObject as { env: { memory: WebAssembly.Memory } } ).env.memory; return createKernelScratchTestInstance( 8, - memory, + activeMemory, () => ({}), () => 4_096n, + 8, ); - }) as never); - const memory = new WebAssembly.Memory({ - initial: 2, - maximum: 2, - shared: true, }); + const instance = kernel({ compile, instantiate }); await expect( - instance.initWithMemory(memoryImportModule(8), memory), + instance.init(memoryImportModule(8)), ).rejects.toBe(failure); - expect(instance.getMemory()).toBeNull(); - expect(instance.getInstance()).toBeNull(); + expect(instance.getMemoryPageCount()).toBeNull(); expect(instance.getKernelPtrWidth()).toBe(4); await expect( - instance.initWithMemory(memoryImportModule(8), memory), + instance.init(memoryImportModule(8)), ).resolves.toBeUndefined(); - expect(instance.getMemory()).toBe(memory); - expect(instance.getInstance()).not.toBeNull(); + expect(instance.getMemoryPageCount()).toBe( + activeMemory!.buffer.byteLength / 65_536, + ); expect(instance.getKernelPtrWidth()).toBe(8); expect(compile).toHaveBeenCalledTimes(2); expect(instantiate).toHaveBeenCalledTimes(2); diff --git a/host/test/kernel-ipc-shmat-entry.test.ts b/host/test/kernel-ipc-shmat-entry.test.ts new file mode 100644 index 0000000000..f1795d3d7f --- /dev/null +++ b/host/test/kernel-ipc-shmat-entry.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createCentralizedKernelWorkerTestDouble, +} from "../src/kernel-worker"; +import { + createKernelEntryGatedInstance, + KernelEntryGate, + KernelReentrantEntryError, +} from "../src/kernel-entry-gate"; +import { allocateKernelScratchRegion } from "../src/kernel-scratch"; +import { + ABI_SYSCALLS, + CHANNEL_STATUS_COMPLETE, + CHANNEL_STATUS_PENDING, + CH_ARGS, + CH_ARG_SIZE, + CH_ERRNO, + CH_RETURN, + CH_STATUS, + CH_SYSCALL, + CH_TOTAL_SIZE, +} from "../src/generated/abi"; +import { createKernelScratchTestInstance } from "./support/kernel-scratch-instance"; + +const EINVAL = 22; +const KERNEL_EXPORT_NAMES = [ + "kernel_drain_wakeup_events", + "kernel_get_memory_pages", + "kernel_get_process_exit_signal", + "kernel_handle_channel", + "kernel_ipc_shmat_for_task", + "kernel_ipc_shmdt_for_process", + "kernel_set_current_tid", + "kernel_validate_task", +] as const; + +interface TestChannel { + readonly pid: number; + readonly memory: WebAssembly.Memory; + readonly channelOffset: number; + i32View: Int32Array; + consecutiveSyscalls: number; + handling: boolean; +} + +function kernelPointer( + pointerWidth: 4 | 8, + value: number, +): number | bigint { + return pointerWidth === 8 ? BigInt(value) : value; +} + +function makeHarness( + pointerWidth: 4 | 8, + callbacks: { readonly onKernelFatal?: (error: Error) => void }, + implementations: ( + worker: Record, + kernelMemory: WebAssembly.Memory, + ) => Record, +): { + readonly worker: Record; + readonly channel: TestChannel; + readonly gate: KernelEntryGate; + readonly kernelMemory: WebAssembly.Memory; + readonly implementations: Record; +} { + const channelMemory = new WebAssembly.Memory({ + initial: 4, + maximum: 4, + shared: true, + }); + const channel: TestChannel = { + pid: 41, + memory: channelMemory, + channelOffset: 0, + i32View: new Int32Array(channelMemory.buffer), + consecutiveSyscalls: 0, + handling: true, + }; + const kernelMemory = new WebAssembly.Memory({ + initial: 4, + maximum: 4, + }); + const gate = new KernelEntryGate(); + let worker!: Record; + let mutableImplementations!: Record; + const rawInstance = createKernelScratchTestInstance( + pointerWidth, + kernelMemory, + () => mutableImplementations, + () => kernelPointer(pointerWidth, 4_096), + 4, + KERNEL_EXPORT_NAMES, + ); + const gatedInstance = createKernelEntryGatedInstance(rawInstance, gate); + const scratch = allocateKernelScratchRegion( + kernelMemory, + gatedInstance.exports.kernel_alloc_scratch as + (capacity: number) => number | bigint, + CH_TOTAL_SIZE, + pointerWidth, + "IPC shmat entry test scratch", + gatedInstance, + ); + worker = createCentralizedKernelWorkerTestDouble({ + callbacks, + }) as unknown as Record; + mutableImplementations = implementations(worker, kernelMemory); + Object.assign(worker, { + activeChannels: [channel], + channelTids: new Map(), + processes: new Map([[channel.pid, { + pid: channel.pid, + memory: channel.memory, + channels: [channel], + ptrWidth: pointerWidth, + explicitMaxAddr: true, + }]]), + usePolling: true, + }); + worker.testAuthority.initializeKernelForTest({ + instance: gatedInstance, + gate, + mainScratch: scratch, + tcpScratch: scratch, + }); + return { + worker, + channel, + gate, + kernelMemory, + implementations: mutableImplementations, + }; +} + +function writeShmat( + channel: TestChannel, + shmid: number, + requestedAddress: number, +): void { + const view = new DataView( + channel.memory.buffer, + channel.channelOffset, + ); + view.setUint32(CH_STATUS, CHANNEL_STATUS_PENDING, true); + view.setUint32(CH_SYSCALL, ABI_SYSCALLS.Shmat, true); + for (const [index, value] of [ + BigInt(shmid), + BigInt(requestedAddress), + 0n, + ].entries()) { + view.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, value, true); + } +} + +function readResult(channel: TestChannel): { + readonly status: number; + readonly retVal: number; + readonly errno: number; +} { + const view = new DataView( + channel.memory.buffer, + channel.channelOffset, + ); + return { + status: view.getUint32(CH_STATUS, true), + retVal: Number(view.getBigInt64(CH_RETURN, true)), + errno: view.getUint32(CH_ERRNO, true), + }; +} + +describe("IPC shmat rollback entry authority", () => { + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s rolls back the process mapping and Rust attachment under one exact entry", + (_name, pointerWidth) => { + const requestedAddress = 0x6_000; + const allocatedAddress = 0x7_000; + const segmentSize = 4_096; + const syscallOrder: number[] = []; + const syntheticArgs: bigint[][] = []; + let shmdtReentryError: unknown; + let harness!: ReturnType; + const shmdt = vi.fn(() => { + try { + harness.gate.invokeKernelExport( + "shmdt reentry probe", + () => 0, + ); + } catch (error) { + shmdtReentryError = error; + } + return 0; + }); + harness = makeHarness(pointerWidth, {}, (_worker, kernelMemory) => ({ + kernel_drain_wakeup_events: () => 0, + kernel_get_memory_pages: () => 256, + kernel_get_process_exit_signal: () => 0, + kernel_handle_channel: (rawPointer: number | bigint) => { + const view = new DataView( + kernelMemory.buffer, + Number(rawPointer), + CH_TOTAL_SIZE, + ); + const syscall = view.getUint32(CH_SYSCALL, true); + syscallOrder.push(syscall); + syntheticArgs.push( + Array.from({ length: 6 }, (_, index) => + view.getBigInt64(CH_ARGS + index * CH_ARG_SIZE, true)), + ); + view.setBigInt64( + CH_RETURN, + BigInt( + syscall === ABI_SYSCALLS.Mmap ? allocatedAddress : 0, + ), + true, + ); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }, + kernel_ipc_shmat_for_task: () => segmentSize, + kernel_ipc_shmdt_for_process: shmdt, + kernel_set_current_tid: () => 0, + kernel_validate_task: () => 0, + })); + + writeShmat(harness.channel, 17, requestedAddress); + harness.worker.handleSyscall(harness.channel); + + expect(syscallOrder).toEqual([ + ABI_SYSCALLS.Mmap, + ABI_SYSCALLS.Munmap, + ]); + expect(syntheticArgs[1]?.slice(0, 2)).toEqual([ + BigInt(allocatedAddress), + BigInt(segmentSize), + ]); + expect(shmdt).toHaveBeenCalledExactlyOnceWith(41, 17); + expect(shmdtReentryError).toBeInstanceOf(KernelReentrantEntryError); + expect( + (shmdtReentryError as KernelReentrantEntryError).activeExportName, + ).toBe("kernel_ipc_shmdt_for_process"); + expect(readResult(harness.channel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + retVal: -EINVAL, + errno: EINVAL, + }); + expect(harness.worker.shmMappings.has(41)).toBe(false); + }, + ); + + it("poisons the generation when Rust attachment rollback is not proven", async () => { + const onKernelFatal = vi.fn(); + let harness!: ReturnType; + harness = makeHarness( + 4, + { onKernelFatal }, + (_worker, kernelMemory) => ({ + kernel_drain_wakeup_events: () => 0, + kernel_get_memory_pages: () => 256, + kernel_get_process_exit_signal: () => 0, + kernel_handle_channel: (rawPointer: number) => { + const view = new DataView( + kernelMemory.buffer, + rawPointer, + CH_TOTAL_SIZE, + ); + const syscall = view.getUint32(CH_SYSCALL, true); + view.setBigInt64( + CH_RETURN, + BigInt(syscall === ABI_SYSCALLS.Mmap ? 0x7_000 : 0), + true, + ); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }, + kernel_ipc_shmat_for_task: () => 4_096, + kernel_ipc_shmdt_for_process: () => -5, + kernel_set_current_tid: () => 0, + kernel_validate_task: () => 0, + }), + ); + writeShmat(harness.channel, 19, 0x6_000); + + expect(() => harness.worker.handleSyscall(harness.channel)) + .toThrow(/cannot roll back shmat attachment/); + await Promise.resolve(); + + expect(readResult(harness.channel).status).toBe( + CHANNEL_STATUS_PENDING, + ); + expect(onKernelFatal).toHaveBeenCalledOnce(); + expect(onKernelFatal.mock.calls[0]?.[0]).toMatchObject({ + name: "KernelIpcShmatRollbackError", + }); + expect(() => harness.worker.getKernelMemoryPages()) + .toThrow(/cannot roll back shmat attachment/); + }); +}); diff --git a/host/test/kernel-large-transfer-protocol.test.ts b/host/test/kernel-large-transfer-protocol.test.ts new file mode 100644 index 0000000000..ae5336bbf8 --- /dev/null +++ b/host/test/kernel-large-transfer-protocol.test.ts @@ -0,0 +1,1669 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { allocateKernelScratchRegion } from "../src/kernel-scratch"; +import { + createCentralizedKernelWorkerTestDouble, +} from "../src/kernel-worker"; +import { + createKernelEntryGatedInstance, + KernelEntryGate, +} from "../src/kernel-entry-gate"; +import { + ABI_SYSCALLS, + CHANNEL_STATUS_COMPLETE, + CHANNEL_STATUS_PENDING, + CH_ARGS, + CH_ARG_SIZE, + CH_DATA_SIZE, + CH_ERRNO, + CH_RETURN, + CH_STATUS, + CH_SYSCALL, + CH_TOTAL_SIZE, + KERNEL_CMSGHDR_WIRE_DATA_OFFSET, + KERNEL_CMSGHDR_WIRE_ALIGN, + KERNEL_CMSGHDR_WIRE_LEN_OFFSET, + KERNEL_CMSGHDR_WIRE_LEVEL_OFFSET, + KERNEL_CMSGHDR_WIRE_TYPE_OFFSET, + POSIX_IOV_MAX, + PROCESS_CMSGHDR_WASM64_DATA_OFFSET, + PROCESS_CMSGHDR_WASM64_LEN_OFFSET, + PROCESS_CMSGHDR_WASM64_SIZE, + PROCESS_MSGHDR_WASM32_CONTROLLEN_OFFSET, + PROCESS_MSGHDR_WASM32_CONTROL_OFFSET, + PROCESS_MSGHDR_WASM32_IOVLEN_OFFSET, + PROCESS_MSGHDR_WASM32_IOV_OFFSET, + PROCESS_MSGHDR_WASM32_NAMELEN_OFFSET, + PROCESS_MSGHDR_WASM32_NAME_OFFSET, + PROCESS_MSGHDR_WASM32_SIZE, + PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET, + PROCESS_MSGHDR_WASM64_CONTROL_OFFSET, + PROCESS_MSGHDR_WASM64_IOVLEN_OFFSET, + PROCESS_MSGHDR_WASM64_IOV_OFFSET, + PROCESS_MSGHDR_WASM64_NAMELEN_OFFSET, + PROCESS_MSGHDR_WASM64_NAME_OFFSET, + PROCESS_MSGHDR_WASM64_SIZE, + PROCESS_STATE_EXITED, + SOCKET_SCM_RIGHTS, + SOCKET_SOL_SOCKET, + STRUCT_SIZE_KERNEL_IOVEC_WIRE, + STRUCT_SIZE_KERNEL_MSGHDR_WIRE, +} from "../src/generated/abi"; +import { createKernelScratchTestInstance } from "./support/kernel-scratch-instance"; + +const EAGAIN = 11; +const ENOMEM = 12; +const EFAULT = 14; +const EINVAL = 22; +const EIO = 5; +const LARGE_LENGTH = CH_DATA_SIZE + 1; + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +interface TestChannel { + pid: number; + memory: WebAssembly.Memory; + channelOffset: number; + i32View: Int32Array; + consecutiveSyscalls: number; + handling: boolean; +} + +interface TransferHarness { + worker: Record; + channel: TestChannel; + processBytes: Uint8Array; + kernelBytes: Uint8Array; + kernelExports: Record; + transferOffset: number; + begin: ReturnType; + pointer: ReturnType; + capacity: ReturnType; + cancel: ReturnType; + execute: ReturnType; + channelExecute: ReturnType; + onKernelFatal: ReturnType; + gate: KernelEntryGate; + scratchRegion: ReturnType; +} + +function sharedMemory(pages = 4): WebAssembly.Memory { + return new WebAssembly.Memory({ + initial: pages, + maximum: pages, + shared: true, + }); +} + +function kernelPointer( + pointerWidth: 4 | 8, + value: number, +): number | bigint { + return pointerWidth === 8 ? BigInt(value) : value; +} + +function makeChannel(pid: number): TestChannel { + const memory = sharedMemory(); + return { + pid, + memory, + channelOffset: 0, + i32View: new Int32Array(memory.buffer), + consecutiveSyscalls: 0, + handling: true, + }; +} + +function makeTransferHarness( + pointerWidth: 4 | 8, + scratchCapacity = CH_TOTAL_SIZE, +): TransferHarness { + const scratchOffset = 4096; + const transferOffset = 2 * 65_536; + const kernelMemory = new WebAssembly.Memory({ initial: 4, maximum: 4 }); + const kernelBytes = new Uint8Array(kernelMemory.buffer); + const channel = makeChannel(41); + const processBytes = new Uint8Array(channel.memory.buffer); + let worker!: Record; + let kernelExports!: Record; + + let nextToken = 101n; + let reservedCapacity = 0; + const begin = vi.fn((minimumCapacity: number | bigint) => { + reservedCapacity = Number(minimumCapacity); + return nextToken++; + }); + const pointer = vi.fn(() => + kernelPointer(pointerWidth, transferOffset) + ); + const capacity = vi.fn(() => + kernelPointer(pointerWidth, reservedCapacity) + ); + const cancel = vi.fn(() => 0); + const execute = vi.fn(( + _pid: number, + _tid: number, + _token: bigint, + length: number | bigint, + ) => Number(length)); + const channelExecute = vi.fn(() => { + const view = new DataView(kernelMemory.buffer, transferOffset); + const messagePointer = Number( + view.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + const iovecPointer = new DataView(kernelMemory.buffer).getUint32( + messagePointer + 8, + true, + ); + const length = new DataView(kernelMemory.buffer).getUint32( + iovecPointer + 4, + true, + ); + view.setBigInt64(CH_RETURN, BigInt(length), true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }); + const onKernelFatal = vi.fn(); + + const gate = new KernelEntryGate(); + const rawScratchInstance = createKernelScratchTestInstance( + pointerWidth, + kernelMemory, + () => kernelExports, + () => kernelPointer(pointerWidth, scratchOffset), + ); + const scratchInstance = createKernelEntryGatedInstance( + rawScratchInstance, + gate, + ); + const scratchRegion = allocateKernelScratchRegion( + kernelMemory, + scratchInstance.exports.kernel_alloc_scratch as + (size: number) => number | bigint, + scratchCapacity, + pointerWidth, + "large transfer protocol test channel scratch", + scratchInstance, + ); + kernelExports = { + kernel_blocking_retry_release: vi.fn(() => 0), + kernel_blocking_retry_token: vi.fn(() => 701n), + kernel_handle_channel: () => 0, + kernel_dequeue_signal: () => 0, + kernel_drain_wakeup_events: () => 0, + kernel_get_process_exit_signal: () => 0, + kernel_get_process_state: () => 0, + // Large-transfer EAGAIN follows the same blocking retry contract as the + // ordinary channel path. Model a blocking descriptor unless a focused + // case replaces this export. + kernel_is_fd_nonblock: () => 0, + kernel_get_socket_timeout_ms: () => -1n, + kernel_set_current_tid: () => 0, + kernel_transfer_scratch_begin: begin, + kernel_transfer_scratch_pointer: pointer, + kernel_transfer_scratch_capacity: capacity, + kernel_transfer_scratch_cancel: cancel, + kernel_transfer_channel_execute: channelExecute, + kernel_transfer_io_execute: execute, + }; + + worker = createCentralizedKernelWorkerTestDouble({ + callbacks: { onKernelFatal }, + }) as unknown as Record; + // WHY: the real worker owns all entry, scratch, and fatal state. Tests may + // replace ordinary host registries, but they must not recreate private Wasm + // authority as mutable structural fields. + Object.assign(worker, { + currentHandlePid: 0, + activeChannels: [channel], + syscallRing: new Map(), + syscallTraceEnabled: false, + syscallTraceRing: [], + syscallTraceCap: 64, + channelTids: new Map(), + processes: new Map([[channel.pid, { + pid: channel.pid, + memory: channel.memory, + channels: [channel], + ptrWidth: pointerWidth, + explicitMaxAddr: true, + }]]), + hostReaped: new Set(), + sharedMmapBackings: new Map(), + relistenBatchSize: 64, + relistenCount: 0, + // Avoid installing a waitAsync listener in these synchronous protocol + // tests. Completion still publishes the genuine process mailbox. + usePolling: true, + pendingPollRetries: new Map(), + pendingSelectRetries: new Map(), + ptyOutputCallbacks: new Map(), + }); + worker.testAuthority.initializeKernelForTest({ + instance: scratchInstance, + gate, + mainScratch: scratchRegion, + tcpScratch: scratchRegion, + }); + + return { + worker, + channel, + processBytes, + kernelBytes, + kernelExports, + transferOffset, + begin, + pointer, + capacity, + cancel, + execute, + channelExecute, + onKernelFatal, + gate, + scratchRegion, + }; +} + +function writeSyscall( + channel: TestChannel, + syscall: number, + args: readonly bigint[], + status: number = CHANNEL_STATUS_PENDING, +): void { + const view = new DataView(channel.memory.buffer, channel.channelOffset); + view.setInt32(CH_STATUS, status, true); + view.setUint32(CH_SYSCALL, syscall, true); + for (let index = 0; index < 6; index++) { + view.setBigInt64( + CH_ARGS + index * CH_ARG_SIZE, + args[index] ?? 0n, + true, + ); + } +} + +function invokeLargeWrite( + harness: TransferHarness, + length = LARGE_LENGTH, + channel = harness.channel, + source = 1024, +): void { + const sourceBytes = new Uint8Array(channel.memory.buffer); + sourceBytes.fill(0x6b, source, source + length); + writeSyscall( + channel, + ABI_SYSCALLS.Write, + [7n, BigInt(source), BigInt(length)], + ); + harness.worker.handleSyscall(channel); +} + +function readChannelCompletion(channel: TestChannel): { + readonly status: number; + readonly retVal: number; + readonly errno: number; +} { + const view = new DataView(channel.memory.buffer, channel.channelOffset); + return { + status: view.getUint32(CH_STATUS, true), + retVal: Number(view.getBigInt64(CH_RETURN, true)), + errno: view.getUint32(CH_ERRNO, true), + }; +} + +function writeIovec( + memory: WebAssembly.Memory, + pointerWidth: 4 | 8, + tablePointer: number, + index: number, + base: number, + length: number, +): void { + const view = new DataView(memory.buffer); + const offset = tablePointer + index * 2 * pointerWidth; + if (pointerWidth === 8) { + view.setBigUint64(offset, BigInt(base), true); + view.setBigUint64(offset + 8, BigInt(length), true); + } else { + view.setUint32(offset, base, true); + view.setUint32(offset + 4, length, true); + } +} + +function writeLargeSendmsg( + pointerWidth: 4 | 8, + channel: TestChannel, + payloadByte = 0x6b, +): number { + const messagePointer = 256; + const iovecPointer = 512; + const sourcePointer = 1024; + const length = CH_DATA_SIZE + - STRUCT_SIZE_KERNEL_MSGHDR_WIRE + - STRUCT_SIZE_KERNEL_IOVEC_WIRE + + 1; + const bytes = new Uint8Array(channel.memory.buffer); + bytes.fill(payloadByte, sourcePointer, sourcePointer + length); + writeIovec( + channel.memory, + pointerWidth, + iovecPointer, + 0, + sourcePointer, + length, + ); + const view = new DataView(channel.memory.buffer); + if (pointerWidth === 8) { + view.setBigUint64( + messagePointer + PROCESS_MSGHDR_WASM64_IOV_OFFSET, + BigInt(iovecPointer), + true, + ); + view.setUint32( + messagePointer + PROCESS_MSGHDR_WASM64_IOVLEN_OFFSET, + 1, + true, + ); + } else { + view.setUint32( + messagePointer + PROCESS_MSGHDR_WASM32_IOV_OFFSET, + iovecPointer, + true, + ); + view.setUint32( + messagePointer + PROCESS_MSGHDR_WASM32_IOVLEN_OFFSET, + 1, + true, + ); + } + writeSyscall( + channel, + ABI_SYSCALLS.Sendmsg, + [7n, BigInt(messagePointer), 0n], + ); + return length; +} + +function invokeLargeSendmsg( + harness: TransferHarness, + pointerWidth: 4 | 8, + channel = harness.channel, + payloadByte = 0x6b, +): number { + const length = writeLargeSendmsg(pointerWidth, channel, payloadByte); + harness.worker.handleSyscall(channel); + return length; +} + +function addChannel( + harness: TransferHarness, + channel: TestChannel, + pointerWidth: 4 | 8, +): void { + harness.worker.processes.set(channel.pid, { + pid: channel.pid, + memory: channel.memory, + channels: [channel], + ptrWidth: pointerWidth, + }); + harness.worker.activeChannels.push(channel); +} + +describe("kernel-owned large transfer reservation protocol", () => { + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s accepts the exact owned capacity and rejects capacity + 1", + (_name, pointerWidth) => { + const ownedCapacity = LARGE_LENGTH; + const exact = makeTransferHarness(pointerWidth); + exact.kernelExports.kernel_transfer_scratch_capacity = () => + kernelPointer(pointerWidth, ownedCapacity); + + invokeLargeWrite(exact, ownedCapacity); + + expect(exact.begin).toHaveBeenCalledWith( + kernelPointer(pointerWidth, ownedCapacity), + ); + expect(exact.execute).toHaveBeenCalledOnce(); + expect(exact.cancel).toHaveBeenCalledOnce(); + expect(readChannelCompletion(exact.channel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + retVal: ownedCapacity, + errno: 0, + }); + + const over = makeTransferHarness(pointerWidth); + over.kernelExports.kernel_transfer_scratch_capacity = () => + kernelPointer(pointerWidth, ownedCapacity); + + invokeLargeWrite(over, ownedCapacity + 1); + + expect(over.begin).toHaveBeenCalledWith( + kernelPointer(pointerWidth, ownedCapacity + 1), + ); + expect(over.execute).not.toHaveBeenCalled(); + expect(over.cancel).toHaveBeenCalledOnce(); + expect(readChannelCompletion(over.channel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + retVal: -1, + errno: EIO, + }); + }, + ); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s completes a real FUTEX_WAKE channel while draining kernel wake events through its scope", + (_name, pointerWidth) => { + const harness = makeTransferHarness(pointerWidth); + const futexAddress = 4096; + const wakeDrain = vi.fn(( + pointer: number | bigint, + capacity: number, + ) => { + expect(capacity).toBeGreaterThanOrEqual(5); + const offset = Number(pointer); + const view = new DataView(harness.kernelBytes.buffer); + view.setUint32(offset, 77, true); + harness.kernelBytes[offset + 4] = 1; + return 1; + }); + harness.kernelExports.kernel_drain_wakeup_events = wakeDrain; + writeSyscall( + harness.channel, + ABI_SYSCALLS.Futex, + [ + BigInt(futexAddress), + 1n, // FUTEX_WAKE + 1n, + 0n, + 0n, + 0n, + ], + ); + + harness.worker.handleSyscall(harness.channel); + + const channelView = new DataView(harness.channel.memory.buffer); + expect(channelView.getUint32(CH_STATUS, true)) + .toBe(CHANNEL_STATUS_COMPLETE); + expect(channelView.getBigInt64(CH_RETURN, true)).toBe(0n); + expect(channelView.getUint32(CH_ERRNO, true)).toBe(0); + expect(wakeDrain).toHaveBeenCalledOnce(); + expect(harness.onKernelFatal).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ["wasm32 immediate", 4, false], + ["wasm32 deferred", 4, true], + ["wasm64 immediate", 8, false], + ["wasm64 deferred", 8, true], + ] as const)( + "%s snapshots PTY output before channel publication and queues callback reentry behind it", + async (_name, pointerWidth, deferChannel) => { + const harness = makeTransferHarness(pointerWidth); + const order: string[] = []; + harness.worker.activePtyIndices.add(7); + harness.kernelExports.kernel_drain_wakeup_events = vi.fn(() => 0); + harness.kernelExports.kernel_inject_mouse_event = vi.fn(() => { + order.push("mouse"); + }); + let readCount = 0; + harness.kernelExports.kernel_pty_master_read = vi.fn(( + _pty: number, + pointer: number | bigint, + ) => { + order.push("read"); + if (readCount++ !== 0) return 0; + harness.kernelBytes[Number(pointer)] = 0x5a; + return 1; + }); + harness.worker.ptyOutputCallbacks.set(7, (data: Uint8Array) => { + order.push("callback"); + expect(Array.from(data)).toEqual([0x5a]); + expect(new DataView(harness.channel.memory.buffer).getUint32( + CH_STATUS, + true, + )).toBe(CHANNEL_STATUS_PENDING); + harness.worker.injectMouseEvent(1, 2, 3); + }); + writeSyscall(harness.channel, ABI_SYSCALLS.Getpid, []); + + if (deferChannel) { + harness.gate.invokeKernelExport("outer", () => { + harness.worker.handleSyscall(harness.channel); + }); + expect(order).toEqual([]); + expect(new DataView(harness.channel.memory.buffer).getUint32( + CH_STATUS, + true, + )).toBe(CHANNEL_STATUS_PENDING); + await Promise.resolve(); + } else { + harness.worker.handleSyscall(harness.channel); + } + + expect(order.slice(0, 3)).toEqual([ + "read", + "read", + "callback", + ]); + expect(new DataView(harness.channel.memory.buffer).getUint32( + CH_STATUS, + true, + )).toBe(CHANNEL_STATUS_COMPLETE); + if (!deferChannel) { + expect(order).not.toContain("mouse"); + await Promise.resolve(); + } + expect(order).toEqual([ + "read", + "read", + "callback", + "mouse", + ]); + expect(harness.onKernelFatal).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s drains all deferred PTY chunks before followers without lending callback authority", + async (_name, pointerWidth) => { + const harness = makeTransferHarness(pointerWidth, 16); + const order: string[] = []; + const writtenBytes: number[][] = []; + let readCount = 0; + harness.kernelExports.kernel_inject_mouse_event = vi.fn(() => { + order.push("mouse"); + }); + harness.worker.ptyOutputCallbacks.set(7, () => { + order.push("callback"); + harness.worker.injectMouseEvent(1, 2, 3); + }); + harness.kernelExports.kernel_pty_master_write = vi.fn(( + _pty: number, + pointer: number | bigint, + length: number, + ) => { + order.push(`write:${length}`); + writtenBytes.push(Array.from( + harness.kernelBytes.slice( + Number(pointer), + Number(pointer) + length, + ), + )); + return length; + }); + harness.kernelExports.kernel_pty_master_read = vi.fn(( + _pty: number, + pointer: number | bigint, + _length: number, + ) => { + order.push("read"); + if (readCount++ !== 0) return 0; + harness.kernelBytes[Number(pointer)] = 0x7a; + return 1; + }); + const input = Uint8Array.from({ length: 17 }, (_, index) => index + 1); + + harness.gate.invokeKernelExport("outer", () => { + harness.worker.ptyMasterWrite(7, input); + harness.gate.runOrDeferVoidIngress( + "follower", + () => { + order.push("follower"); + }, + ); + input.fill(0xff); + }); + await Promise.resolve(); + + expect(writtenBytes).toEqual([ + Array.from({ length: 16 }, (_, index) => index + 1), + [17], + ]); + expect(order).toEqual([ + "write:16", + "write:1", + "read", + "callback", + "follower", + "mouse", + "read", + ]); + }, + ); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s preserves FIFO order and owned bytes for PTY, UDP, and mouse events", + async (_name, pointerWidth) => { + const harness = makeTransferHarness(pointerWidth); + const order: string[] = []; + const ptyBytes: number[][] = []; + const udpBytes: number[][] = []; + harness.kernelExports.kernel_inject_mouse_event = vi.fn(() => { + order.push("mouse"); + }); + harness.kernelExports.kernel_pty_master_write = vi.fn(( + _pty: number, + pointer: number | bigint, + length: number, + ) => { + order.push("pty"); + ptyBytes.push(Array.from( + harness.kernelBytes.slice( + Number(pointer), + Number(pointer) + length, + ), + )); + return length; + }); + harness.kernelExports.kernel_inject_datagram = vi.fn(( + ...args: Array + ) => { + const pointer = Number(args[11]); + const length = Number(args[12]); + order.push("udp"); + udpBytes.push(Array.from( + harness.kernelBytes.slice(pointer, pointer + length), + )); + return 0; + }); + const ptyInput = new Uint8Array([1, 2, 3]); + const udpInput = new Uint8Array([4, 5, 6]); + + harness.gate.invokeKernelExport("kernel_handle_channel", () => { + harness.worker.ptyMasterWrite(7, ptyInput); + expect(harness.worker.injectUdpDatagram(harness.channel.pid, { + srcAddr: new Uint8Array([10, 0, 0, 1]), + srcPort: 1000, + dstAddr: new Uint8Array([10, 0, 0, 2]), + dstPort: 2000, + data: udpInput, + })).toBe(0); + harness.worker.injectMouseEvent(1, 2, 3); + ptyInput.fill(9); + udpInput.fill(9); + expect(order).toEqual([]); + }); + + await Promise.resolve(); + expect(order).toEqual(["pty", "udp", "mouse"]); + expect(ptyBytes).toEqual([[1, 2, 3]]); + expect(udpBytes).toEqual([[4, 5, 6]]); + }, + ); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s gates retry queries during an ordinary kernel_handle_channel call", + async (_name, pointerWidth) => { + const harness = makeTransferHarness(pointerWidth); + const nestedChannel = makeChannel(42); + addChannel(harness, nestedChannel, pointerWidth); + new Uint8Array(nestedChannel.memory.buffer)[1024] = 0x61; + writeSyscall( + nestedChannel, + ABI_SYSCALLS.Write, + [7n, 1024n, 1n], + ); + const getProcessExitSignal = vi.fn(() => 0); + harness.kernelExports.kernel_get_process_exit_signal = + getProcessExitSignal; + let handleCount = 0; + const handleChannel = vi.fn((pointer: number | bigint) => { + handleCount++; + if (handleCount === 1) { + harness.worker.retrySyscall(nestedChannel); + harness.worker.retrySyscall(nestedChannel); + expect(getProcessExitSignal).not.toHaveBeenCalled(); + expect(readChannelCompletion(nestedChannel).status) + .toBe(CHANNEL_STATUS_PENDING); + return 0; + } + const view = new DataView( + harness.kernelBytes.buffer, + Number(pointer), + ); + view.setBigInt64(CH_RETURN, 1n, true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }); + harness.kernelExports.kernel_handle_channel = handleChannel; + + harness.scratchRegion.withLease((lease) => { + expect(lease.invokeKernelExport("kernel_handle_channel", [ + lease.exportPointer(0, CH_TOTAL_SIZE), + CH_TOTAL_SIZE, + pointerWidth, + 0n, + ])).toBe(0); + }); + + expect(getProcessExitSignal).not.toHaveBeenCalled(); + expect(handleChannel).toHaveBeenCalledOnce(); + await Promise.resolve(); + // The selected retry checks for an already-fatal process before + // dispatch and again after signal dequeue. Both queries must happen + // only after the outer export has released the gate. + expect(getProcessExitSignal).toHaveBeenCalledTimes(2); + expect(handleChannel).toHaveBeenCalledTimes(2); + expect(readChannelCompletion(nestedChannel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + retVal: 1, + errno: 0, + }); + }, + ); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s keeps nested channel imports behind followers while a selected handler makes sequential exports", + async (_name, pointerWidth) => { + const harness = makeTransferHarness(pointerWidth); + const selected = makeChannel(42); + const nested = makeChannel(43); + addChannel(harness, selected, pointerWidth); + addChannel(harness, nested, pointerWidth); + new Uint8Array(selected.memory.buffer).fill( + 0x42, + 1024, + 1024 + LARGE_LENGTH, + ); + new Uint8Array(nested.memory.buffer)[2048] = 0x43; + writeSyscall( + selected, + ABI_SYSCALLS.Write, + [7n, 1024n, BigInt(LARGE_LENGTH)], + ); + writeSyscall(nested, ABI_SYSCALLS.Write, [8n, 2048n, 1n]); + const order: string[] = []; + harness.kernelExports.kernel_transfer_scratch_begin = vi.fn(( + minimumCapacity: number | bigint, + ) => { + order.push("begin"); + return harness.begin(minimumCapacity); + }); + harness.kernelExports.kernel_transfer_scratch_pointer = vi.fn(( + token: bigint, + ) => { + order.push("pointer"); + return harness.pointer(token); + }); + harness.kernelExports.kernel_transfer_scratch_capacity = vi.fn(( + token: bigint, + ) => { + order.push("capacity"); + return harness.capacity(token); + }); + harness.kernelExports.kernel_transfer_io_execute = vi.fn(() => { + order.push("execute"); + harness.worker.handleSyscall(nested); + expect(order).not.toContain("nested"); + expect(() => harness.worker.ptyMasterRead(7)) + .toThrow(/PTY master read/); + return LARGE_LENGTH; + }); + harness.kernelExports.kernel_transfer_scratch_cancel = vi.fn(( + token: bigint, + ) => { + order.push("cancel"); + return harness.cancel(token); + }); + harness.kernelExports.kernel_handle_channel = vi.fn(( + pointer: number | bigint, + ) => { + order.push("nested"); + const view = new DataView( + harness.kernelBytes.buffer, + Number(pointer), + ); + view.setBigInt64(CH_RETURN, 1n, true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }); + harness.kernelExports.kernel_pty_master_read = vi.fn(() => { + order.push("stolen"); + return 0; + }); + + harness.gate.invokeKernelExport("outer", () => { + harness.worker.retrySyscall(selected); + harness.gate.runOrDeferVoidIngress( + "follower", + () => { + order.push("follower"); + }, + ); + }); + await Promise.resolve(); + + expect(order).toEqual([ + "begin", + "pointer", + "capacity", + "execute", + "cancel", + "follower", + "nested", + ]); + expect(readChannelCompletion(selected).status) + .toBe(CHANNEL_STATUS_COMPLETE); + expect(readChannelCompletion(nested).status) + .toBe(CHANNEL_STATUS_COMPLETE); + }, + ); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s propagates begin ENOMEM without consulting or cancelling a token", + (_name, pointerWidth) => { + const harness = makeTransferHarness(pointerWidth); + harness.kernelExports.kernel_transfer_scratch_begin = + vi.fn(() => -BigInt(ENOMEM)); + + invokeLargeWrite(harness); + + expect(harness.pointer).not.toHaveBeenCalled(); + expect(harness.capacity).not.toHaveBeenCalled(); + expect(harness.execute).not.toHaveBeenCalled(); + expect(harness.cancel).not.toHaveBeenCalled(); + expect(readChannelCompletion(harness.channel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + retVal: -1, + errno: ENOMEM, + }); + }, + ); + + it.each([ + ["wasm32 null pointer", 4, 0, LARGE_LENGTH], + ["wasm32 zero capacity", 4, 2 * 65_536, 0], + ["wasm32 end-of-memory range", 4, 4 * 65_536 - 8, LARGE_LENGTH], + ["wasm64 null pointer", 8, 0, LARGE_LENGTH], + ["wasm64 zero capacity", 8, 2 * 65_536, 0], + ["wasm64 end-of-memory range", 8, 4 * 65_536 - 8, LARGE_LENGTH], + ] as const)( + "%s is cancelled exactly once before any execute", + (_name, pointerWidth, pointerValue, capacityValue) => { + const harness = makeTransferHarness(pointerWidth); + harness.kernelExports.kernel_transfer_scratch_pointer = () => + kernelPointer(pointerWidth, pointerValue); + harness.kernelExports.kernel_transfer_scratch_capacity = () => + kernelPointer(pointerWidth, capacityValue); + + invokeLargeWrite(harness); + + expect(harness.execute).not.toHaveBeenCalled(); + expect(harness.cancel).toHaveBeenCalledOnce(); + expect(harness.cancel).toHaveBeenCalledWith(101n); + expect(readChannelCompletion(harness.channel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + retVal: -1, + errno: EIO, + }); + }, + ); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s uses and settles a fresh token for sequential operations", + (_name, pointerWidth) => { + const harness = makeTransferHarness(pointerWidth); + + invokeLargeWrite(harness); + expect(readChannelCompletion(harness.channel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + retVal: LARGE_LENGTH, + errno: 0, + }); + invokeLargeWrite(harness); + + expect(harness.execute.mock.calls.map((call) => call[2])) + .toEqual([101n, 102n]); + expect(harness.cancel.mock.calls.map((call) => call[0])) + .toEqual([101n, 102n]); + expect(readChannelCompletion(harness.channel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + retVal: LARGE_LENGTH, + errno: 0, + }); + }, + ); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s uses and settles a fresh token for sequential reserved sendmsg channels", + (_name, pointerWidth) => { + const harness = makeTransferHarness(pointerWidth); + + const length = invokeLargeSendmsg(harness, pointerWidth); + expect(readChannelCompletion(harness.channel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + retVal: length, + errno: 0, + }); + invokeLargeSendmsg(harness, pointerWidth); + + expect(harness.channelExecute.mock.calls.map((call) => call[2])) + .toEqual([101n, 102n]); + expect(harness.cancel.mock.calls.map((call) => call[0])) + .toEqual([101n, 102n]); + expect(readChannelCompletion(harness.channel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + retVal: length, + errno: 0, + }); + }, + ); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s defers a reentrant reserved sendmsg without replacing outer bytes", + async (_name, pointerWidth) => { + const harness = makeTransferHarness(pointerWidth); + const nestedChannel = makeChannel(42); + addChannel(harness, nestedChannel, pointerWidth); + const messageLength = writeLargeSendmsg( + pointerWidth, + nestedChannel, + 0x42, + ); + const channelExecute = vi.fn(() => { + const channelView = new DataView( + harness.kernelBytes.buffer, + harness.transferOffset, + ); + const messagePointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + const kernelView = new DataView(harness.kernelBytes.buffer); + const iovecPointer = kernelView.getUint32( + messagePointer + 8, + true, + ); + const dataPointer = kernelView.getUint32(iovecPointer, true); + const length = kernelView.getUint32(iovecPointer + 4, true); + const expectedByte = channelExecute.mock.calls.length === 1 + ? 0x41 + : 0x42; + expect(length).toBe(messageLength); + expect( + harness.kernelBytes.slice(dataPointer, dataPointer + length), + ).toEqual(new Uint8Array(length).fill(expectedByte)); + + if (channelExecute.mock.calls.length === 1) { + harness.worker.handleSyscall(nestedChannel); + harness.worker.handleSyscall(nestedChannel); + expect(readChannelCompletion(nestedChannel).status) + .toBe(CHANNEL_STATUS_PENDING); + // WHY: the global reservation remains owned by the outer entry + // until execute and settlement finish. Reentrant ingress must wait, + // or it could replace bytes the kernel is still synchronously using. + expect( + harness.kernelBytes.slice(dataPointer, dataPointer + length), + ).toEqual(new Uint8Array(length).fill(0x41)); + } + channelView.setBigInt64(CH_RETURN, BigInt(length), true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + harness.kernelExports.kernel_transfer_channel_execute = + channelExecute; + + const outerLength = invokeLargeSendmsg( + harness, + pointerWidth, + harness.channel, + 0x41, + ); + + expect(channelExecute).toHaveBeenCalledOnce(); + expect(readChannelCompletion(harness.channel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + retVal: outerLength, + errno: 0, + }); + expect(readChannelCompletion(nestedChannel).status) + .toBe(CHANNEL_STATUS_PENDING); + + await Promise.resolve(); + + expect(channelExecute.mock.calls.map((call) => call[2])) + .toEqual([101n, 102n]); + expect(harness.cancel.mock.calls.map((call) => call[0])) + .toEqual([101n, 102n]); + expect(readChannelCompletion(nestedChannel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + retVal: messageLength, + errno: 0, + }); + }, + ); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s defers a reentrant channel without replacing its mailbox or outer bytes", + async (_name, pointerWidth) => { + const harness = makeTransferHarness(pointerWidth); + const nestedChannel = makeChannel(42); + addChannel(harness, nestedChannel, pointerWidth); + writeSyscall( + nestedChannel, + ABI_SYSCALLS.Write, + [7n, 2048n, BigInt(LARGE_LENGTH)], + ); + const outerPayload = Uint8Array.from( + { length: LARGE_LENGTH }, + (_, index) => (index * 17 + 3) % 251, + ); + harness.processBytes.set(outerPayload, 1024); + const execute = vi.fn(() => { + if (execute.mock.calls.length === 1) { + expect(harness.worker.currentHandlePid).toBe(harness.channel.pid); + harness.worker.handleSyscall(nestedChannel); + harness.worker.handleSyscall(nestedChannel); + expect(readChannelCompletion(nestedChannel).status) + .toBe(CHANNEL_STATUS_PENDING); + expect( + harness.kernelBytes.slice( + harness.transferOffset, + harness.transferOffset + LARGE_LENGTH, + ), + ).toEqual(outerPayload); + } + return LARGE_LENGTH; + }); + harness.kernelExports.kernel_transfer_io_execute = execute; + + writeSyscall( + harness.channel, + ABI_SYSCALLS.Write, + [7n, 1024n, BigInt(LARGE_LENGTH)], + ); + harness.worker.handleSyscall(harness.channel); + + expect(harness.begin).toHaveBeenCalledOnce(); + expect(harness.cancel).toHaveBeenCalledOnce(); + expect(execute).toHaveBeenCalledOnce(); + expect(readChannelCompletion(harness.channel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + retVal: LARGE_LENGTH, + errno: 0, + }); + expect(readChannelCompletion(nestedChannel).status) + .toBe(CHANNEL_STATUS_PENDING); + + await Promise.resolve(); + + expect(execute).toHaveBeenCalledTimes(2); + expect(readChannelCompletion(nestedChannel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + retVal: LARGE_LENGTH, + errno: 0, + }); + }, + ); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s converts a result above the requested length to EIO and settles", + (_name, pointerWidth) => { + const harness = makeTransferHarness(pointerWidth); + harness.kernelExports.kernel_transfer_io_execute = + vi.fn(() => LARGE_LENGTH + 1); + + invokeLargeWrite(harness); + + expect(harness.cancel).toHaveBeenCalledOnce(); + expect(readChannelCompletion(harness.channel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + retVal: -1, + errno: EIO, + }); + }, + ); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s cancels EAGAIN scratch before parking the retry", + (_name, pointerWidth) => { + vi.useFakeTimers(); + const harness = makeTransferHarness(pointerWidth); + const order: string[] = []; + class RecordingRetryMap extends Map { + override set(key: unknown, value: unknown): this { + order.push("retry"); + return super.set(key, value); + } + } + harness.worker.pendingPollRetries = new RecordingRetryMap(); + harness.kernelExports.kernel_transfer_io_execute = + vi.fn(() => -EAGAIN); + harness.kernelExports.kernel_transfer_scratch_cancel = + vi.fn(() => { + order.push("cancel"); + return 0; + }); + + invokeLargeWrite(harness); + + expect(order).toEqual(["cancel", "retry"]); + expect(readChannelCompletion(harness.channel).status) + .toBe(CHANNEL_STATUS_PENDING); + expect(harness.worker.pendingPollRetries.has(harness.channel)).toBe(true); + vi.clearAllTimers(); + }, + ); +}); + +describe("kernel transfer fatal latch", () => { + it("keeps every channel inert when Rust returns a mismatched committed exit status", async () => { + const harness = makeTransferHarness(4); + harness.kernelExports.kernel_commit_process_exit = vi.fn(() => 6); + harness.kernelExports.kernel_get_process_state = vi.fn( + () => PROCESS_STATE_EXITED, + ); + + writeSyscall( + harness.channel, + ABI_SYSCALLS.ExitGroup, + [7n], + ); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + expect(() => harness.worker.handleSyscall(harness.channel)).toThrow( + "kernel committed exit status 6 for process 41; expected 7", + ); + await Promise.resolve(); + expect(harness.onKernelFatal).toHaveBeenCalledOnce(); + expect(harness.onKernelFatal.mock.calls[0]?.[0]).toMatchObject({ + name: "KernelExitCommitProtocolError", + }); + expect(harness.worker.isKernelInitialized()).toBe(false); + expect(harness.channel.handling).toBe(true); + expect(readChannelCompletion(harness.channel).status) + .toBe(CHANNEL_STATUS_PENDING); + expect(error).toHaveBeenCalledWith( + "[handleSyscall] KERNEL-FATAL " + + "kernel committed exit status 6 for process 41; expected 7", + ); + }); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s export trap never completes the channel with recoverable EIO", + async (_name, pointerWidth) => { + const harness = makeTransferHarness(pointerWidth); + const fatal = new Error("synthetic ordinary kernel export trap"); + harness.kernelExports.kernel_handle_channel = () => { + throw fatal; + }; + writeSyscall( + harness.channel, + ABI_SYSCALLS.Write, + [7n, 1024n, 1n], + ); + + expect(() => harness.worker.handleSyscall(harness.channel)) + .toThrow(/kernel export kernel_handle_channel failed/); + await Promise.resolve(); + + expect(harness.onKernelFatal).toHaveBeenCalledOnce(); + const failure = harness.onKernelFatal.mock.calls[0]?.[0] as + (Error & { cause?: unknown }) | undefined; + expect(failure).toBeInstanceOf(Error); + expect(failure?.cause).toBe(fatal); + expect(harness.worker.isKernelInitialized()).toBe(false); + expect(harness.channel.handling).toBe(true); + expect(readChannelCompletion(harness.channel).status) + .toBe(CHANNEL_STATUS_PENDING); + }, + ); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s execute trap latches once and makes queued and direct dispatch inert", + async (_name, pointerWidth) => { + const harness = makeTransferHarness(pointerWidth); + const deferredChannel = makeChannel(42); + addChannel(harness, deferredChannel, pointerWidth); + writeSyscall( + deferredChannel, + ABI_SYSCALLS.Write, + [7n, 1024n, 1n], + ); + + harness.processBytes.fill(0x3a, 1024, 1024 + LARGE_LENGTH); + writeSyscall( + harness.channel, + ABI_SYSCALLS.Write, + [7n, 1024n, BigInt(LARGE_LENGTH)], + ); + const deferredBeforeTrap = vi.fn(() => 0); + harness.kernelExports.kernel_handle_channel = deferredBeforeTrap; + harness.worker.currentHandlePid = 777; + const transferTrap = new Error("synthetic transfer import trap"); + const execute = vi.fn(() => { + expect(harness.worker.currentHandlePid).toBe(harness.channel.pid); + harness.worker.handleSyscall(deferredChannel); + expect(deferredBeforeTrap).not.toHaveBeenCalled(); + throw transferTrap; + }); + harness.kernelExports.kernel_transfer_io_execute = execute; + const consoleError = vi.spyOn(console, "error").mockImplementation( + () => {}, + ); + + expect(() => harness.worker.handleSyscall(harness.channel)) + .toThrow(/kernel export kernel_transfer_io_execute failed/); + await Promise.resolve(); + + expect(execute).toHaveBeenCalledOnce(); + expect(harness.cancel).not.toHaveBeenCalled(); + expect(harness.worker.currentHandlePid).toBe(777); + expect(harness.worker.isKernelInitialized()).toBe(false); + expect(harness.onKernelFatal).toHaveBeenCalledOnce(); + const failure = harness.onKernelFatal.mock.calls[0]?.[0] as + (Error & { trappedCause?: unknown }) | undefined; + expect(failure).toMatchObject({ + name: "KernelTransferExecuteTrapError", + message: + "kernel transfer execute trapped with a global reservation active", + }); + const trappedExport = failure?.trappedCause as + (Error & { cause?: unknown }) | undefined; + expect(trappedExport).toMatchObject({ + message: "kernel export kernel_transfer_io_execute failed", + }); + expect(trappedExport?.cause).toBe(transferTrap); + expect(harness.channel.handling).toBe(true); + expect(deferredChannel.handling).toBe(true); + expect(readChannelCompletion(harness.channel).status) + .toBe(CHANNEL_STATUS_PENDING); + expect(readChannelCompletion(deferredChannel).status) + .toBe(CHANNEL_STATUS_PENDING); + + harness.worker.handleSyscall(harness.channel); + harness.worker.retrySyscall(harness.channel); + harness.worker.listenOnChannel(harness.channel); + + expect(execute).toHaveBeenCalledOnce(); + expect(deferredBeforeTrap).not.toHaveBeenCalled(); + expect(harness.onKernelFatal).toHaveBeenCalledOnce(); + expect(readChannelCompletion(harness.channel).status) + .toBe(CHANNEL_STATUS_PENDING); + consoleError.mockRestore(); + }, + ); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s cancel failure is kernel-fatal after restoring the selected pid", + async (_name, pointerWidth) => { + const harness = makeTransferHarness(pointerWidth); + harness.processBytes.fill(0x72, 1024, 1024 + LARGE_LENGTH); + writeSyscall( + harness.channel, + ABI_SYSCALLS.Write, + [7n, 1024n, BigInt(LARGE_LENGTH)], + ); + harness.worker.currentHandlePid = 888; + const execute = vi.fn(() => { + expect(harness.worker.currentHandlePid).toBe(harness.channel.pid); + return LARGE_LENGTH; + }); + const cancel = vi.fn(() => -EINVAL); + harness.kernelExports.kernel_transfer_io_execute = execute; + harness.kernelExports.kernel_transfer_scratch_cancel = cancel; + const consoleError = vi.spyOn(console, "error").mockImplementation( + () => {}, + ); + + expect(() => harness.worker.handleSyscall(harness.channel)) + .toThrow(/kernel transfer reservation could not be settled/); + harness.worker.handleSyscall(harness.channel); + await Promise.resolve(); + + expect(execute).toHaveBeenCalledOnce(); + expect(cancel).toHaveBeenCalledOnce(); + expect(harness.worker.currentHandlePid).toBe(888); + expect(harness.worker.isKernelInitialized()).toBe(false); + expect(harness.onKernelFatal).toHaveBeenCalledOnce(); + expect(readChannelCompletion(harness.channel).status) + .toBe(CHANNEL_STATUS_PENDING); + consoleError.mockRestore(); + }, + ); +}); + +describe("large vector validation precedes reservation", () => { + it.each([ + ["wasm32 writev", 4, ABI_SYSCALLS.Writev], + ["wasm32 readv", 4, ABI_SYSCALLS.Readv], + ["wasm64 writev", 8, ABI_SYSCALLS.Writev], + ["wasm64 readv", 8, ABI_SYSCALLS.Readv], + ] as const)( + "%s rejects IOV_MAX + 1 without beginning a reservation", + (_name, pointerWidth, syscall) => { + const harness = makeTransferHarness(pointerWidth); + const tablePointer = 256; + + writeSyscall( + harness.channel, + syscall, + [ + 7n, + BigInt(tablePointer), + BigInt(POSIX_IOV_MAX + 1), + 0n, + 0n, + 0n, + ], + ); + harness.worker.handleSyscall(harness.channel); + + expect(harness.begin).not.toHaveBeenCalled(); + expect(harness.execute).not.toHaveBeenCalled(); + expect(readChannelCompletion(harness.channel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + retVal: -1, + errno: EINVAL, + }); + }, + ); + + it.each([ + ["wasm32 writev", 4, ABI_SYSCALLS.Writev], + ["wasm32 readv", 4, ABI_SYSCALLS.Readv], + ["wasm64 writev", 8, ABI_SYSCALLS.Writev], + ["wasm64 readv", 8, ABI_SYSCALLS.Readv], + ] as const)( + "%s rejects a later invalid nested range without beginning a reservation", + (_name, pointerWidth, syscall) => { + const harness = makeTransferHarness(pointerWidth); + const tablePointer = 256; + writeIovec( + harness.channel.memory, + pointerWidth, + tablePointer, + 0, + 4096, + LARGE_LENGTH, + ); + writeIovec( + harness.channel.memory, + pointerWidth, + tablePointer, + 1, + harness.processBytes.byteLength - 1, + 2, + ); + + writeSyscall( + harness.channel, + syscall, + [7n, BigInt(tablePointer), 2n, 0n, 0n, 0n], + ); + harness.worker.handleSyscall(harness.channel); + + expect(harness.begin).not.toHaveBeenCalled(); + expect(harness.execute).not.toHaveBeenCalled(); + expect(readChannelCompletion(harness.channel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + retVal: -1, + errno: EFAULT, + }); + }, + ); +}); + +describe("ignored vector and message pointers", () => { + it.each([ + ["wasm32", 4, 0xffff_ffffn], + ["wasm64", 8, 1n << 60n], + ] as const)( + "%s validates iovcnt before the pointer and canonicalizes zero-count iov", + (_name, pointerWidth, ignoredPointer) => { + const harness = makeTransferHarness(pointerWidth); + const zeroArgs = [7, Number(ignoredPointer), 0, 0, 0, 0]; + expect(() => harness.worker.checkHandwrittenProcessAddressArguments( + harness.channel, + ABI_SYSCALLS.Writev, + zeroArgs, + [7n, ignoredPointer, 0n, 0n, 0n, 0n], + [7n, ignoredPointer, 0n, 0n, 0n, 0n], + )).not.toThrow(); + expect(zeroArgs[1]).toBe(0); + expect(zeroArgs[2]).toBe(0); + + const invalidCountArgs = [7, 0, 0, 0, 0, 0]; + expect(() => harness.worker.checkHandwrittenProcessAddressArguments( + harness.channel, + ABI_SYSCALLS.Readv, + invalidCountArgs, + [7n, ignoredPointer, BigInt(POSIX_IOV_MAX + 1), 0n, 0n, 0n], + [ + 7n, + ignoredPointer, + BigInt(POSIX_IOV_MAX + 1), + 0n, + 0n, + 0n, + ], + )).toThrow(/iovec count/); + }, + ); + + it.each([ + ["wasm32", 4, 0xffff_ffffn], + ["wasm64", 8, 1n << 60n], + ] as const)( + "%s ignores msg_name, msg_control, and iov pointers with zero lengths", + (_name, pointerWidth, ignoredPointer) => { + const harness = makeTransferHarness(pointerWidth); + const messagePointer = 512; + const view = new DataView(harness.channel.memory.buffer); + const layout = pointerWidth === 8 + ? { + size: PROCESS_MSGHDR_WASM64_SIZE, + name: PROCESS_MSGHDR_WASM64_NAME_OFFSET, + nameLength: PROCESS_MSGHDR_WASM64_NAMELEN_OFFSET, + iov: PROCESS_MSGHDR_WASM64_IOV_OFFSET, + iovCount: PROCESS_MSGHDR_WASM64_IOVLEN_OFFSET, + control: PROCESS_MSGHDR_WASM64_CONTROL_OFFSET, + controlLength: PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET, + } + : { + size: PROCESS_MSGHDR_WASM32_SIZE, + name: PROCESS_MSGHDR_WASM32_NAME_OFFSET, + nameLength: PROCESS_MSGHDR_WASM32_NAMELEN_OFFSET, + iov: PROCESS_MSGHDR_WASM32_IOV_OFFSET, + iovCount: PROCESS_MSGHDR_WASM32_IOVLEN_OFFSET, + control: PROCESS_MSGHDR_WASM32_CONTROL_OFFSET, + controlLength: PROCESS_MSGHDR_WASM32_CONTROLLEN_OFFSET, + }; + new Uint8Array( + harness.channel.memory.buffer, + messagePointer, + layout.size, + ).fill(0); + if (pointerWidth === 8) { + view.setBigUint64(messagePointer + layout.name, ignoredPointer, true); + view.setBigUint64(messagePointer + layout.iov, ignoredPointer, true); + view.setBigUint64( + messagePointer + layout.control, + ignoredPointer, + true, + ); + view.setBigUint64(messagePointer + layout.iovCount, 0n, true); + view.setBigUint64(messagePointer + layout.controlLength, 0n, true); + } else { + view.setUint32( + messagePointer + layout.name, + Number(ignoredPointer), + true, + ); + view.setUint32( + messagePointer + layout.iov, + Number(ignoredPointer), + true, + ); + view.setUint32( + messagePointer + layout.control, + Number(ignoredPointer), + true, + ); + view.setUint32(messagePointer + layout.iovCount, 0, true); + view.setUint32(messagePointer + layout.controlLength, 0, true); + } + view.setUint32(messagePointer + layout.nameLength, 0, true); + + const message = harness.worker.checkedProcessMessage( + harness.channel, + kernelPointer(pointerWidth, messagePointer), + ); + expect(message.name).toEqual({ pointer: 0, length: 0 }); + expect(message.control).toEqual({ pointer: 0, length: 0 }); + expect(message.iovecs).toEqual({ entries: [], totalData: 0 }); + }, + ); + + it("wasm64 ignores the ABI padding after 32-bit msghdr counts", () => { + const harness = makeTransferHarness(8); + const messagePointer = 512; + const bytes = new Uint8Array( + harness.channel.memory.buffer, + messagePointer, + PROCESS_MSGHDR_WASM64_SIZE, + ); + bytes.fill(0); + const view = new DataView(harness.channel.memory.buffer); + + view.setUint32( + messagePointer + PROCESS_MSGHDR_WASM64_IOVLEN_OFFSET + 4, + 1, + true, + ); + let message = harness.worker.checkedProcessMessage( + harness.channel, + BigInt(messagePointer), + ); + expect(message.iovecs).toEqual({ entries: [], totalData: 0 }); + + view.setUint32( + messagePointer + PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET + 4, + 1, + true, + ); + message = harness.worker.checkedProcessMessage( + harness.channel, + BigInt(messagePointer), + ); + expect(message.control).toEqual({ pointer: 0, length: 0 }); + }); + + it("wasm64 rejects a high-word cmsg_len and emits native size_t fields", () => { + const harness = makeTransferHarness(8); + const controlPointer = 1024; + const controlLength = Math.max( + PROCESS_CMSGHDR_WASM64_SIZE, + PROCESS_CMSGHDR_WASM64_DATA_OFFSET + 8, + ); + const processView = new DataView(harness.channel.memory.buffer); + processView.setBigUint64( + controlPointer + PROCESS_CMSGHDR_WASM64_LEN_OFFSET, + (1n << 32n) + BigInt(PROCESS_CMSGHDR_WASM64_DATA_OFFSET + 4), + true, + ); + const message = { + pointerWidth: 8, + messagePointer: 0, + namePresent: false, + name: { pointer: 0, length: 0 }, + control: { pointer: controlPointer, length: controlLength }, + iovecs: { entries: [], totalData: 0 }, + }; + expect(() => harness.worker.nativeControlToKernelWire( + new Uint8Array(harness.channel.memory.buffer), + message, + )).toThrow(/control message exceeds|cmsg_len/); + + const wireLength = KERNEL_CMSGHDR_WIRE_DATA_OFFSET + 4; + const wireSpace = Math.ceil( + wireLength / KERNEL_CMSGHDR_WIRE_ALIGN, + ) * KERNEL_CMSGHDR_WIRE_ALIGN; + const wire = new Uint8Array(wireSpace); + const wireView = new DataView(wire.buffer); + wireView.setUint32(KERNEL_CMSGHDR_WIRE_LEN_OFFSET, wireLength, true); + wireView.setUint32( + KERNEL_CMSGHDR_WIRE_LEVEL_OFFSET, + SOCKET_SOL_SOCKET, + true, + ); + wireView.setUint32( + KERNEL_CMSGHDR_WIRE_TYPE_OFFSET, + SOCKET_SCM_RIGHTS, + true, + ); + wireView.setInt32(KERNEL_CMSGHDR_WIRE_DATA_OFFSET, 7, true); + + const native = harness.worker.kernelControlToNative(wire, message); + expect(new DataView( + native.bytes.buffer, + native.bytes.byteOffset, + native.bytes.byteLength, + ).getBigUint64(PROCESS_CMSGHDR_WASM64_LEN_OFFSET, true)).toBe( + BigInt(PROCESS_CMSGHDR_WASM64_DATA_OFFSET + 4), + ); + + const sizeField = new DataView(new ArrayBuffer(8)); + harness.worker.writeProcessUsize( + sizeField, + 0, + 0x1_0000_0001, + 8, + "test msg_controllen", + ); + expect(sizeField.getBigUint64(0, true)).toBe(0x1_0000_0001n); + }); +}); diff --git a/host/test/kernel-network-cleanup-entry.test.ts b/host/test/kernel-network-cleanup-entry.test.ts new file mode 100644 index 0000000000..e374e801e3 --- /dev/null +++ b/host/test/kernel-network-cleanup-entry.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createCentralizedKernelWorkerTestDouble, +} from "../src/kernel-worker"; +import { + createKernelEntryGatedInstance, + KernelEntryGate, + KernelReentrantEntryError, +} from "../src/kernel-entry-gate"; +import { allocateKernelScratchRegion } from "../src/kernel-scratch"; +import { CH_TOTAL_SIZE } from "../src/generated/abi"; +import { createKernelScratchTestInstance } from "./support/kernel-scratch-instance"; + +const KERNEL_EXPORT_NAMES = [ + "kernel_drain_wakeup_events", + "kernel_get_memory_pages", + "kernel_inject_datagram", + "kernel_remove_process", +] as const; + +function kernelPointer( + pointerWidth: 4 | 8, + value: number, +): number | bigint { + return pointerWidth === 8 ? BigInt(value) : value; +} + +function processMemory(): WebAssembly.Memory { + return new WebAssembly.Memory({ + initial: 4, + maximum: 4, + shared: true, + }); +} + +function makeHarness( + pointerWidth: 4 | 8, + options: { + readonly gate?: KernelEntryGate; + readonly io?: Record; + readonly implementations?: Record; + } = {}, +): { + readonly worker: Record; + readonly gate: KernelEntryGate; + readonly implementations: Record; +} { + const kernelMemory = new WebAssembly.Memory({ + initial: 4, + maximum: 4, + }); + const implementations: Record = { + kernel_drain_wakeup_events: () => 0, + kernel_get_memory_pages: () => 256, + kernel_inject_datagram: () => 0, + kernel_remove_process: () => 0, + ...options.implementations, + }; + const gate = options.gate ?? new KernelEntryGate(); + const rawInstance = createKernelScratchTestInstance( + pointerWidth, + kernelMemory, + () => implementations, + () => kernelPointer(pointerWidth, 4_096), + 4, + KERNEL_EXPORT_NAMES, + ); + const gatedInstance = createKernelEntryGatedInstance(rawInstance, gate); + const scratch = allocateKernelScratchRegion( + kernelMemory, + gatedInstance.exports.kernel_alloc_scratch as + (capacity: number) => number | bigint, + CH_TOTAL_SIZE, + pointerWidth, + "network cleanup entry test scratch", + gatedInstance, + ); + const worker = createCentralizedKernelWorkerTestDouble({ + io: options.io as any, + }) as unknown as Record; + worker.testAuthority.initializeKernelForTest({ + instance: gatedInstance, + gate, + mainScratch: scratch, + tcpScratch: scratch, + }); + return { worker, gate, implementations }; +} + +function networkSnapshot(worker: Record): { + readonly udp: string[]; + readonly targetPorts: number[]; + readonly listenerKeys: string[]; + readonly virtualPorts: number[]; + readonly connectionPids: number[]; + readonly processPids: number[]; +} { + return { + udp: [...worker.udpBindings].sort(), + targetPorts: [...worker.tcpListenerTargets.keys()].sort(), + listenerKeys: [...worker.tcpListeners.keys()].sort(), + virtualPorts: [...worker.tcpVirtualListenerKeys.keys()].sort(), + connectionPids: [...worker.tcpConnections.keys()].sort(), + processPids: [...worker.processes.keys()].sort(), + }; +} + +describe("network cleanup entry authority", () => { + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s publishes complete UDP/TCP cleanup before reentrant host callbacks", + async (_name, pointerWidth) => { + const callbackSnapshots: ReturnType[] = []; + const reentryErrors: unknown[] = []; + const order: string[] = []; + let queuedSecondUnregister = false; + let harness!: ReturnType; + + const observe = (label: string): void => { + order.push(label); + callbackSnapshots.push(networkSnapshot(harness.worker)); + try { + harness.gate.invokeKernelExport( + "network cleanup callback probe", + () => 0, + ); + } catch (error) { + reentryErrors.push(error); + } + }; + const finalServer = { + close: vi.fn(() => observe("server close")), + }; + const sharedServer = { + close: vi.fn(() => observe("shared server close")), + }; + const network = { + connect: vi.fn(), + connectStatus: vi.fn(() => 0), + send: vi.fn(() => 0), + recv: vi.fn(() => new Uint8Array()), + close: vi.fn(), + getaddrinfo: vi.fn(() => new Uint8Array([127, 0, 0, 1])), + unbindUdp: vi.fn(() => { + observe("UDP unbind"); + if (!queuedSecondUnregister) { + queuedSecondUnregister = true; + // Void ingress from a detached callback joins the FIFO. It must + // not overlap this publication or close the same resources twice. + harness.worker.unregisterProcess(41); + } + }), + closeTcpListener: vi.fn(() => observe("virtual listener close")), + }; + const removeProcess = vi.fn(() => 0); + harness = makeHarness(pointerWidth, { + io: { network }, + implementations: { kernel_remove_process: removeProcess }, + }); + + const memory41 = processMemory(); + const memory42 = processMemory(); + Object.assign(harness.worker, { + processes: new Map([ + [41, { + pid: 41, + memory: memory41, + channels: [], + ptrWidth: pointerWidth, + explicitMaxAddr: true, + }], + [42, { + pid: 42, + memory: memory42, + channels: [], + ptrWidth: pointerWidth, + explicitMaxAddr: true, + }], + ]), + udpBindings: new Set(["41:7", "42:9"]), + tcpListenerTargets: new Map([ + [8_000, [{ pid: 41, fd: 4 }]], + [8_001, [{ pid: 41, fd: 5 }, { pid: 42, fd: 6 }]], + ]), + tcpListenerRRIndex: new Map([ + [8_000, 0], + [8_001, 1], + ]), + tcpVirtualListenerKeys: new Map([ + [8_000, "virtual:8000"], + [8_001, "virtual:8001"], + ]), + tcpListeners: new Map([ + ["41:4", { + server: finalServer, + pid: 41, + port: 8_000, + connections: new Set(), + }], + ["41:5", { + server: sharedServer, + pid: 41, + port: 8_001, + connections: new Set(), + }], + ]), + tcpConnections: new Map([ + [41, []], + [42, []], + ]), + usePolling: true, + }); + + harness.worker.unregisterProcess(41); + for (let index = 0; index < 4; index++) await Promise.resolve(); + + expect(order).toEqual([ + "UDP unbind", + "virtual listener close", + "server close", + ]); + const completeState = { + udp: ["42:9"], + targetPorts: [8_001], + listenerKeys: ["42:6"], + virtualPorts: [8_001], + connectionPids: [42], + processPids: [42], + }; + expect(callbackSnapshots).toEqual([ + completeState, + completeState, + completeState, + ]); + expect(reentryErrors).toHaveLength(3); + for (const error of reentryErrors) { + expect(error).toBeInstanceOf(KernelReentrantEntryError); + expect( + (error as KernelReentrantEntryError).activeExportName, + ).toBe("detached host phase"); + } + expect(removeProcess).toHaveBeenCalledExactlyOnceWith(41); + expect(network.unbindUdp).toHaveBeenCalledExactlyOnceWith("41:7"); + expect(network.closeTcpListener) + .toHaveBeenCalledExactlyOnceWith("virtual:8000"); + expect(finalServer.close).toHaveBeenCalledOnce(); + expect(sharedServer.close).not.toHaveBeenCalled(); + expect(harness.worker.tcpListenerRRIndex.get(8_001)).toBe(0); + expect(harness.worker.tcpVirtualListenerKeys.get(8_001)) + .toBe("virtual:8001"); + expect(harness.worker.tcpListeners.get("42:6")?.server) + .toBe(sharedServer); + expect(harness.worker.getKernelMemoryPages()).toBe(256); + }, + ); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s schedules UDP wake work only after the exact entry is revoked", + (_name, pointerWidth) => { + const gate = new KernelEntryGate(); + const schedulingErrors: unknown[] = []; + const originalDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + "setImmediate", + ); + Object.defineProperty(globalThis, "setImmediate", { + configurable: true, + writable: true, + value: (() => { + try { + gate.invokeKernelExport("UDP scheduler phase probe", () => 0); + } catch (error) { + schedulingErrors.push(error); + } + return 1 as unknown as ReturnType; + }) as typeof setImmediate, + }); + + let harness: ReturnType; + try { + harness = makeHarness(pointerWidth, { + gate, + implementations: {}, + }); + } finally { + if (originalDescriptor === undefined) { + Reflect.deleteProperty(globalThis, "setImmediate"); + } else { + Object.defineProperty( + globalThis, + "setImmediate", + originalDescriptor, + ); + } + } + const memory = processMemory(); + harness.worker.processes.set(41, { + pid: 41, + memory, + channels: [], + ptrWidth: pointerWidth, + explicitMaxAddr: true, + }); + harness.worker.pendingPipeReaders.set(7, new Set()); + + expect(harness.worker.injectUdpDatagram(41, { + srcAddr: new Uint8Array([10, 0, 0, 1]), + srcPort: 1_000, + dstAddr: new Uint8Array([10, 0, 0, 2]), + dstPort: 2_000, + data: new Uint8Array([1, 2, 3]), + })).toBe(0); + + expect(schedulingErrors).toHaveLength(1); + expect(schedulingErrors[0]).toBeInstanceOf( + KernelReentrantEntryError, + ); + expect( + (schedulingErrors[0] as KernelReentrantEntryError).activeExportName, + ).toBe("detached host phase"); + }, + ); +}); diff --git a/host/test/kernel-process-registration-entry.test.ts b/host/test/kernel-process-registration-entry.test.ts new file mode 100644 index 0000000000..8966ffd20d --- /dev/null +++ b/host/test/kernel-process-registration-entry.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + CAPTURED_STDIO, + createCentralizedKernelWorkerTestDouble, + type CentralizedKernelWorker, +} from "../src/kernel-worker"; +import { + createKernelEntryGatedInstance, + KernelEntryGate, + KernelReentrantEntryError, +} from "../src/kernel-entry-gate"; +import { allocateKernelScratchRegion } from "../src/kernel-scratch"; +import { + CH_TOTAL_SIZE, + PROCESS_STATE_RUNNING, +} from "../src/generated/abi"; +import { createKernelScratchTestInstance } from "./support/kernel-scratch-instance"; + +const KERNEL_EXPORT_NAMES = [ + "kernel_clear_process_metadata", + "kernel_create_process_with_stdio", + "kernel_get_process_state", + "kernel_push_process_metadata_entry", + "kernel_set_brk_base", + "kernel_set_brk_limit", + "kernel_set_max_addr", + "kernel_set_mmap_base", + "kernel_vblank", +] as const; + +interface ProcessEntryHarness { + readonly worker: CentralizedKernelWorker; + readonly gatedInstance: WebAssembly.Instance; + readonly kernelMemory: WebAssembly.Memory; + readonly implementations: Record; +} + +function processMemory(): WebAssembly.Memory { + return new WebAssembly.Memory({ + initial: 3, + maximum: 3, + shared: true, + }); +} + +function makeHarness( + implementations: Record, +): ProcessEntryHarness { + const kernelMemory = new WebAssembly.Memory({ + initial: 4, + maximum: 4, + }); + const gate = new KernelEntryGate(); + const rawInstance = createKernelScratchTestInstance( + 4, + kernelMemory, + () => implementations, + () => 4_096, + 4, + KERNEL_EXPORT_NAMES, + ); + const gatedInstance = createKernelEntryGatedInstance(rawInstance, gate); + const mainScratch = allocateKernelScratchRegion( + kernelMemory, + gatedInstance.exports.kernel_alloc_scratch as (size: number) => number, + CH_TOTAL_SIZE, + 4, + "process registration entry test scratch", + gatedInstance, + ); + const worker = createCentralizedKernelWorkerTestDouble(); + const authority = ( + worker as unknown as { + readonly testAuthority: { + initializeKernelForTest(options: { + readonly instance: WebAssembly.Instance; + readonly gate: KernelEntryGate; + readonly mainScratch: typeof mainScratch; + }): void; + }; + } + ).testAuthority; + authority.initializeKernelForTest({ + instance: gatedInstance, + gate, + mainScratch, + }); + return { + worker, + gatedInstance, + kernelMemory, + implementations, + }; +} + +describe("kernel process registration entry authority", () => { + it("publishes one complete registration from immutable metadata snapshots", () => { + const argv = ["program", "first"]; + const env = ["A=original"]; + const clear = vi.fn(() => 0); + const createProcess = vi.fn(() => 47); + const getProcessState = vi.fn(() => PROCESS_STATE_RUNNING); + const setBrkBase = vi.fn(() => 0); + const setBrkLimit = vi.fn(() => 0); + const setMaxAddr = vi.fn(() => 0); + const setMmapBase = vi.fn(() => 0); + const pushed: Array<{ + readonly kind: number; + readonly bytes: Uint8Array; + }> = []; + let harness!: ProcessEntryHarness; + const push = vi.fn(( + _pid: number, + kind: number, + pointer: number, + length: number, + ) => { + pushed.push({ + kind, + bytes: new Uint8Array( + new Uint8Array( + harness.kernelMemory.buffer, + pointer, + length, + ), + ), + }); + // The source arrays remain caller-owned. Reentrant mutation after the + // first Rust call must not replace later entries in this transaction. + if (pushed.length === 1) { + argv[1] = "replaced"; + env.push("B=late"); + } + return 0; + }); + harness = makeHarness({ + kernel_clear_process_metadata: clear, + kernel_create_process_with_stdio: createProcess, + kernel_get_process_state: getProcessState, + kernel_push_process_metadata_entry: push, + kernel_set_brk_base: setBrkBase, + kernel_set_brk_limit: setBrkLimit, + kernel_set_max_addr: setMaxAddr, + kernel_set_mmap_base: setMmapBase, + kernel_vblank: () => 0, + }); + + const pid = harness.worker.createProcess(CAPTURED_STDIO); + const memory = processMemory(); + harness.worker.registerProcess(pid, memory, [65_536], { + argv, + env, + brkBase: 70_000, + brkLimit: 120_000, + maxAddr: 130_000, + mmapBase: 80_000, + ptrWidth: 4, + }); + + expect(createProcess).toHaveBeenCalledWith(0, 0, 0); + expect(getProcessState).toHaveBeenCalledWith(pid); + expect(clear.mock.calls).toEqual([ + [pid, 0], + [pid, 1], + ]); + expect(pushed.map(({ kind, bytes }) => ({ + kind, + text: new TextDecoder().decode(bytes), + }))).toEqual([ + { kind: 0, text: "program" }, + { kind: 0, text: "first" }, + { kind: 1, text: "A=original" }, + ]); + expect(setBrkBase).toHaveBeenCalledWith(pid, 70_000); + expect(setBrkLimit).toHaveBeenCalledWith(pid, 120_000); + expect(setMaxAddr).toHaveBeenCalledWith(pid, 130_000); + expect(setMmapBase).toHaveBeenCalledWith(pid, 80_000); + expect(harness.worker.getProcessMemory(pid)).toBe(memory); + }); + + it("rejects synchronous authority roots during a live kernel export", async () => { + const exportCalls = { + clear: vi.fn(() => 0), + create: vi.fn(() => 51), + getState: vi.fn(() => PROCESS_STATE_RUNNING), + push: vi.fn(() => 0), + setBrkBase: vi.fn(() => 0), + setBrkLimit: vi.fn(() => 0), + setMaxAddr: vi.fn(() => 0), + setMmapBase: vi.fn(() => 0), + }; + const caught: unknown[] = []; + let harness!: ProcessEntryHarness; + const guestMemory = processMemory(); + harness = makeHarness({ + kernel_clear_process_metadata: exportCalls.clear, + kernel_create_process_with_stdio: exportCalls.create, + kernel_get_process_state: exportCalls.getState, + kernel_push_process_metadata_entry: exportCalls.push, + kernel_set_brk_base: exportCalls.setBrkBase, + kernel_set_brk_limit: exportCalls.setBrkLimit, + kernel_set_max_addr: exportCalls.setMaxAddr, + kernel_set_mmap_base: exportCalls.setMmapBase, + kernel_vblank: () => { + const attempts: Array<() => unknown> = [ + () => harness.worker.createProcess(CAPTURED_STDIO), + () => harness.worker.registerProcess( + 51, + guestMemory, + [65_536], + ), + () => harness.worker.setBrkBase(51, 70_000), + () => harness.worker.setBrkLimit(51, 120_000), + () => harness.worker.setMaxAddr(51, 130_000), + () => harness.worker.setMmapBase(51, 80_000), + () => ( + harness.worker as unknown as { + replaceProcessMetadata( + pid: number, + kind: number, + values: readonly string[], + ): void; + } + ).replaceProcessMetadata(51, 0, ["program"]), + ]; + for (const attempt of attempts) { + try { + attempt(); + } catch (error) { + caught.push(error); + } + } + return 0; + }, + }); + + ( + harness.gatedInstance.exports.kernel_vblank as () => number + )(); + await Promise.resolve(); + + expect(caught).toHaveLength(7); + for (const error of caught) { + expect(error).toBeInstanceOf(KernelReentrantEntryError); + } + for (const call of Object.values(exportCalls)) { + expect(call).not.toHaveBeenCalled(); + } + expect(harness.worker.getProcessMemory(51)).toBeUndefined(); + }); + + it("does not publish host registration after a metadata-stage failure", () => { + const harness = makeHarness({ + kernel_clear_process_metadata: () => 0, + kernel_create_process_with_stdio: () => 63, + kernel_get_process_state: () => PROCESS_STATE_RUNNING, + kernel_push_process_metadata_entry: () => -5, + kernel_set_brk_base: () => 0, + kernel_set_brk_limit: () => 0, + kernel_set_max_addr: () => 0, + kernel_set_mmap_base: () => 0, + kernel_vblank: () => 0, + }); + const memory = processMemory(); + + expect(() => harness.worker.registerProcess( + 63, + memory, + [65_536], + { argv: ["program"] }, + )).toThrow(); + expect(harness.worker.getProcessMemory(63)).toBeUndefined(); + }); +}); diff --git a/host/test/kernel-public-entry-roots.test.ts b/host/test/kernel-public-entry-roots.test.ts new file mode 100644 index 0000000000..532ebbeb20 --- /dev/null +++ b/host/test/kernel-public-entry-roots.test.ts @@ -0,0 +1,320 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createCentralizedKernelWorkerTestDouble, +} from "../src/kernel-worker"; +import { + createKernelEntryGatedInstance, + KernelEntryGate, + KernelReentrantEntryError, +} from "../src/kernel-entry-gate"; +import { + allocateKernelScratchRegion, + KernelScratchError, +} from "../src/kernel-scratch"; +import { createKernelScratchTestInstance } from "./support/kernel-scratch-instance"; + +const SCRATCH_OFFSET = 4096; +const SCRATCH_CAPACITY = 65_536; + +interface RootHarness { + readonly worker: ReturnType; + readonly gate: KernelEntryGate; + readonly kernelBytes: Uint8Array; + readonly implementations: Record; +} + +function kernelPointer(pointerWidth: 4 | 8, value: number): number | bigint { + return pointerWidth === 8 ? BigInt(value) : value; +} + +function makeRootHarness(pointerWidth: 4 | 8): RootHarness { + const memory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); + const kernelBytes = new Uint8Array(memory.buffer); + const implementations: Record = { + kernel_enum_procs: () => 0, + kernel_pty_create: () => 7, + kernel_pty_master_read: () => 0, + kernel_read_proc_maps: () => 0, + kernel_set_cwd: () => 0, + kernel_set_process_credentials: () => 0, + }; + const gate = new KernelEntryGate(); + const rawInstance = createKernelScratchTestInstance( + pointerWidth, + memory, + () => implementations, + () => kernelPointer(pointerWidth, SCRATCH_OFFSET), + 4, + [ + "kernel_enum_procs", + "kernel_pty_create", + "kernel_pty_master_read", + "kernel_read_proc_maps", + "kernel_set_cwd", + "kernel_set_process_credentials", + ], + ); + const instance = createKernelEntryGatedInstance(rawInstance, gate); + const scratch = allocateKernelScratchRegion( + memory, + instance.exports.kernel_alloc_scratch as + (capacity: number) => number | bigint, + SCRATCH_CAPACITY, + pointerWidth, + "public entry-root test scratch", + instance, + ); + const worker = createCentralizedKernelWorkerTestDouble(); + worker.testAuthority.initializeKernelForTest({ + instance, + gate, + mainScratch: scratch, + tcpScratch: scratch, + }); + return { worker, gate, kernelBytes, implementations }; +} + +function processSnapshotBytes(): Uint8Array { + const encoder = new TextEncoder(); + const comm = encoder.encode("demo"); + const cmdline = encoder.encode("demo\0--safe\0"); + const bytes = new Uint8Array(4 + 36 + comm.length + cmdline.length); + const view = new DataView(bytes.buffer); + let offset = 0; + view.setUint32(offset, 1, true); offset += 4; + view.setUint32(offset, 41, true); offset += 4; + view.setUint32(offset, 1, true); offset += 4; + view.setUint32(offset, 501, true); offset += 4; + view.setUint32(offset, 20, true); offset += 4; + view.setBigUint64(offset, 8192n, true); offset += 8; + view.setUint32(offset, "R".charCodeAt(0), true); offset += 4; + view.setUint32(offset, comm.length, true); offset += 4; + view.setUint32(offset, cmdline.length, true); offset += 4; + bytes.set(comm, offset); offset += comm.length; + bytes.set(cmdline, offset); + return bytes; +} + +describe("CentralizedKernelWorker public kernel-entry roots", () => { + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s scopes PTY, cwd, credential, and process-inspection exports", + (_name, pointerWidth) => { + const harness = makeRootHarness(pointerWidth); + const cwd = new TextEncoder().encode("/safe"); + const maps = new TextEncoder().encode( + "1000-2000 rw-p 00000000 00:00 0 [heap]\n", + ); + const snapshots = processSnapshotBytes(); + const setupPty = vi.fn(() => 9); + const setCredentials = vi.fn(() => 0); + const setCwd = vi.fn(( + pid: number, + pointer: number | bigint, + length: number, + ) => { + expect(pid).toBe(41); + expect(harness.kernelBytes.slice( + Number(pointer), + Number(pointer) + length, + )).toEqual(cwd); + return 0; + }); + const readPty = vi.fn(( + ptyIndex: number, + pointer: number | bigint, + capacity: number, + ) => { + expect(ptyIndex).toBe(9); + expect(capacity).toBe(4096); + harness.kernelBytes.set([1, 2, 3], Number(pointer)); + return 3; + }); + const enumProcs = vi.fn(( + pointer: number | bigint, + capacity: number, + ) => { + expect(capacity).toBe(SCRATCH_CAPACITY); + harness.kernelBytes.set(snapshots, Number(pointer)); + return snapshots.length; + }); + const readMaps = vi.fn(( + pid: number, + pointer: number | bigint, + capacity: number, + ) => { + expect(pid).toBe(41); + expect(capacity).toBe(SCRATCH_CAPACITY); + harness.kernelBytes.set(maps, Number(pointer)); + return maps.length; + }); + Object.assign(harness.implementations, { + kernel_enum_procs: enumProcs, + kernel_pty_create: setupPty, + kernel_pty_master_read: readPty, + kernel_read_proc_maps: readMaps, + kernel_set_cwd: setCwd, + kernel_set_process_credentials: setCredentials, + }); + + expect(harness.worker.setupPty(41)).toBe(9); + harness.worker.setCredentials(41, { uid: 501, gid: 20 }); + harness.worker.setCwd(41, "/safe"); + expect(harness.worker.ptyMasterRead(9)).toEqual( + new Uint8Array([1, 2, 3]), + ); + expect(harness.worker.enumProcs()).toEqual([{ + pid: 41, + ppid: 1, + uid: 501, + gid: 20, + vsizeBytes: 8192, + state: "R", + comm: "demo", + cmdline: "demo --safe", + }]); + expect(harness.worker.readProcMaps(41)).toBe( + new TextDecoder().decode(maps), + ); + + expect(setupPty).toHaveBeenCalledOnce(); + expect(setCredentials).toHaveBeenCalledWith(41, 501, 20); + expect(setCwd).toHaveBeenCalledOnce(); + expect(readPty).toHaveBeenCalledOnce(); + expect(enumProcs).toHaveBeenCalledOnce(); + expect(readMaps).toHaveBeenCalledOnce(); + }, + ); + + it("rejects result-bearing reverse entry before any export runs", () => { + const harness = makeRootHarness(4); + const calls = Object.fromEntries( + [ + "kernel_enum_procs", + "kernel_pty_create", + "kernel_pty_master_read", + "kernel_read_proc_maps", + "kernel_set_cwd", + "kernel_set_process_credentials", + ].map((name) => [name, vi.fn(() => 0)]), + ); + Object.assign(harness.implementations, calls); + + harness.gate.invokeKernelExport("active outer export", () => { + for (const operation of [ + () => harness.worker.setupPty(41), + () => harness.worker.ptyMasterRead(7), + () => harness.worker.setCwd(41, "/"), + () => harness.worker.setCredentials(41, { uid: 1 }), + () => harness.worker.enumProcs(), + () => harness.worker.readProcMaps(41), + ]) { + expect(operation).toThrow(KernelReentrantEntryError); + } + return 0; + }); + + for (const call of Object.values(calls)) { + expect(call).not.toHaveBeenCalled(); + } + }); + + it("keeps ordinary PTY/cwd/credential errnos process-local", () => { + const harness = makeRootHarness(4); + + harness.implementations.kernel_pty_create = () => -12; + expect(() => harness.worker.setupPty(41)).toThrow("errno 12"); + harness.implementations.kernel_pty_create = () => 11; + expect(harness.worker.setupPty(41)).toBe(11); + + harness.implementations.kernel_set_cwd = () => -36; + expect(() => harness.worker.setCwd(41, "/missing")).toThrow("errno 36"); + harness.implementations.kernel_set_cwd = () => 0; + expect(() => harness.worker.setCwd(41, "/safe")).not.toThrow(); + + harness.implementations.kernel_set_process_credentials = () => -1; + expect(() => + harness.worker.setCredentials(41, { uid: 501 }) + ).toThrow("errno 1"); + harness.implementations.kernel_set_process_credentials = () => 0; + expect(() => + harness.worker.setCredentials(41, { uid: 501 }) + ).not.toThrow(); + }); + + it.each([ + { + name: "PTY read", + request: 4096, + install( + harness: RootHarness, + result: (pointer: number | bigint, capacity: number) => number, + ): void { + harness.implementations.kernel_pty_master_read = ( + _ptyIndex: number, + pointer: number | bigint, + capacity: number, + ) => result(pointer, capacity); + }, + invoke(harness: RootHarness): unknown { + return harness.worker.ptyMasterRead(7); + }, + }, + { + name: "process enumeration", + request: SCRATCH_CAPACITY, + install( + harness: RootHarness, + result: (pointer: number | bigint, capacity: number) => number, + ): void { + harness.implementations.kernel_enum_procs = result; + }, + invoke(harness: RootHarness): unknown { + return harness.worker.enumProcs(); + }, + }, + { + name: "process maps", + request: SCRATCH_CAPACITY, + install( + harness: RootHarness, + result: (pointer: number | bigint, capacity: number) => number, + ): void { + harness.implementations.kernel_read_proc_maps = ( + _pid: number, + pointer: number | bigint, + capacity: number, + ) => result(pointer, capacity); + }, + invoke(harness: RootHarness): unknown { + return harness.worker.readProcMaps(41); + }, + }, + ])( + "accepts exact capacity and rejects capacity+1 for $name", + ({ request, install, invoke }) => { + const exact = makeRootHarness(4); + install(exact, (pointer, capacity) => { + expect(capacity).toBe(request); + exact.kernelBytes.fill(0, Number(pointer), Number(pointer) + capacity); + return capacity; + }); + expect(() => invoke(exact)).not.toThrow(); + + const oversized = makeRootHarness(4); + install(oversized, (_pointer, capacity) => capacity + 1); + let failure: unknown; + try { + invoke(oversized); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect((failure as Error & { cause?: unknown }).cause) + .toBeInstanceOf(KernelScratchError); + }, + ); +}); diff --git a/host/test/kernel-public-scratch.test.ts b/host/test/kernel-public-scratch.test.ts index 2402691e01..8c1ef7d7c7 100644 --- a/host/test/kernel-public-scratch.test.ts +++ b/host/test/kernel-public-scratch.test.ts @@ -7,7 +7,10 @@ import { STRUCT_SIZE_WASM_POLL_FD, STRUCT_SIZE_WPK_DRM_MODE_MODEINFO, } from "../src/generated/abi"; -import { WasmPosixKernel } from "../src/kernel"; +import { + createWasmPosixKernelTestHarness, + WasmPosixKernel, +} from "../src/kernel"; import { QOP_GET_ERROR } from "../src/webgl/ops"; import { createKernelScratchTestInstance } from "./support/kernel-scratch-instance"; @@ -27,60 +30,57 @@ function hostileBytes(length: number, reportedLength = 1): Uint8Array { function kernelHarness( exports: Record, pointerWidth: 4 | 8 = 4, + io: Record = {}, + callbacks: Record = {}, ): { kernel: WasmPosixKernel & Record; memory: WebAssembly.Memory; } { const memory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); - const kernel = Object.assign( - Object.create(WasmPosixKernel.prototype), - { + const kernel = createWasmPosixKernelTestHarness({ + io: io as any, + callbacks: callbacks as any, + memory, + pointerWidth, + instance: createKernelScratchTestInstance( + pointerWidth, memory, - instance: createKernelScratchTestInstance( - pointerWidth, - memory, - () => exports, - (capacity) => { - const allocator = exports.kernel_alloc_scratch; - if (typeof allocator !== "function") { - throw new Error("missing test implementation for kernel_alloc_scratch"); - } - return Reflect.apply(allocator, undefined, [capacity]) as number | bigint; - }, - ), - kernelPtrWidth: pointerWidth, - apiScratchRegion: null, - callbacks: {}, - sharedPipes: new Map(), - }, - ) as WasmPosixKernel & Record; + () => exports, + (capacity) => { + const allocator = exports.kernel_alloc_scratch; + if (typeof allocator !== "function") { + throw new Error("missing test implementation for kernel_alloc_scratch"); + } + return Reflect.apply(allocator, undefined, [capacity]) as number | bigint; + }, + ), + }) as WasmPosixKernel & Record; return { kernel, memory }; } function fullKernelHarness( io: Record = {}, callbacks: Record = {}, + pointerWidth: 4 | 8 = 4, + suppliedMemory?: WebAssembly.Memory, ): { kernel: WasmPosixKernel & Record; memory: WebAssembly.Memory; } { - const memory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); - const kernel = new WasmPosixKernel( - {} as any, - io as any, - callbacks as any, - ) as WasmPosixKernel & Record; - Object.assign(kernel, { + const memory = suppliedMemory + ?? new WebAssembly.Memory({ initial: 2, maximum: 2 }); + const kernel = createWasmPosixKernelTestHarness({ + io: io as any, + callbacks: callbacks as any, memory, instance: createKernelScratchTestInstance( - 4, + pointerWidth, memory, () => ({}), - () => 4096, + () => pointerWidth === 8 ? 4096n : 4096, ), - kernelPtrWidth: 4, - apiScratchRegion: null, - }); + pointerWidth, + }) as WasmPosixKernel & Record; return { kernel, memory }; } @@ -94,10 +94,10 @@ describe("WasmPosixKernel public API scratch ownership", () => { expect(() => kernel.toKernelPtr(-1)).toThrow(/non-negative/i); expect(() => kernel.toKernelPtr(1.5)).toThrow(/integer/i); - Object.assign(kernel, { kernelPtrWidth: 8 }); - expect(kernel.toKernelPtr(0x1_0000_0000)).toBe(0x1_0000_0000n); + const { kernel: kernel64 } = kernelHarness({}, 8); + expect(kernel64.toKernelPtr(0x1_0000_0000)).toBe(0x1_0000_0000n); expect(() => - kernel.toKernelPtr(BigInt(Number.MAX_SAFE_INTEGER) + 1n) + kernel64.toKernelPtr(BigInt(Number.MAX_SAFE_INTEGER) + 1n) ).toThrow(/representable/i); }); @@ -131,6 +131,60 @@ describe("WasmPosixKernel public API scratch ownership", () => { .toEqual(new Uint8Array(64).fill(0xa5)); }); + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s stages scalar socket options in an allocator-owned exact range", + (_name, pointerWidth) => { + const scratchPointer = 4096; + const exportedPointer = pointerWidth === 8 + ? BigInt(scratchPointer) + : scratchPointer; + const allocate = vi.fn(() => exportedPointer); + let memory!: WebAssembly.Memory; + const setsockopt = vi.fn(( + fd: number, + level: number, + optname: number, + pointer: number | bigint, + length: number, + ) => { + expect([fd, level, optname]).toEqual([7, 1, 2]); + expect(pointer).toBe(exportedPointer); + expect(length).toBe(4); + expect( + new DataView( + memory.buffer, + Number(pointer), + length, + ).getUint32(0, true), + ).toBe(0x89ab_cdef); + return 0; + }); + const harness = kernelHarness({ + kernel_alloc_scratch: allocate, + kernel_setsockopt: setsockopt, + }, pointerWidth); + memory = harness.memory; + new Uint8Array(memory.buffer).fill(0xa5, 0, 64); + new Uint8Array(memory.buffer).fill( + 0x5a, + scratchPointer + 4, + scratchPointer + 20, + ); + + harness.kernel.setsockopt(7, 1, 2, 0x89ab_cdef); + + expect(allocate).toHaveBeenCalledTimes(1); + expect(setsockopt).toHaveBeenCalledTimes(1); + expect(new Uint8Array(memory.buffer, 0, 64)) + .toEqual(new Uint8Array(64).fill(0xa5)); + expect(new Uint8Array(memory.buffer, scratchPointer + 4, 16)) + .toEqual(new Uint8Array(16).fill(0x5a)); + }, + ); + it("never lends stale scratch bytes when public inputs spoof their length", () => { const scratchPointer = 4096; const source = hostileBytes(1, 4); @@ -500,6 +554,55 @@ describe("WasmPosixKernel public API scratch ownership", () => { expect(allocate).toHaveBeenCalledTimes(1); expect(truncate).toHaveBeenCalledTimes(1); }); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s passes the public ftruncate length through the direct i64 ABI", + (_name, pointerWidth) => { + const length = 0x1_0000_0001; + const ftruncate = vi.fn((fd: number, wasmLength: bigint) => { + if (typeof wasmLength !== "bigint") { + throw new TypeError("Wasm i64 arguments require BigInt"); + } + expect(fd).toBe(7); + expect(wasmLength).toBe(BigInt(length)); + return 0; + }); + const { kernel } = kernelHarness( + { kernel_ftruncate: ftruncate }, + pointerWidth, + ); + + kernel.ftruncate(7, length); + + expect(ftruncate).toHaveBeenCalledWith(7, BigInt(length)); + }, + ); + + it.each([ + ["wasm32 negative", 4, -1], + ["wasm32 fractional", 4, 1.5], + ["wasm32 unsafe", 4, Number.MAX_SAFE_INTEGER + 1], + ["wasm64 negative", 8, -1], + ["wasm64 fractional", 8, 1.5], + ["wasm64 unsafe", 8, Number.MAX_SAFE_INTEGER + 1], + ] as const)( + "rejects a %s public ftruncate length before entering Wasm", + (_name, pointerWidth, length) => { + const ftruncate = vi.fn(() => 0); + const { kernel } = kernelHarness( + { kernel_ftruncate: ftruncate }, + pointerWidth, + ); + + expect(() => kernel.ftruncate(7, length)).toThrow( + /non-negative safe integer/, + ); + expect(ftruncate).not.toHaveBeenCalled(); + }, + ); }); describe("Rust-owned host import ranges", () => { @@ -507,7 +610,7 @@ describe("Rust-owned host import ranges", () => { const open = vi.fn(() => 7); const { kernel, memory } = kernelHarness({}); Object.assign(kernel, { io: { open } }); - const imports = kernel.buildImportObject(memory) as { + const imports = kernel.testAuthority.buildImportObject(memory) as { env: Record any>; }; const pointer = memory.buffer.byteLength - 2; @@ -520,7 +623,7 @@ describe("Rust-owned host import ranges", () => { const write = vi.fn(() => 4); const { kernel, memory } = kernelHarness({}); Object.assign(kernel, { io: { write } }); - const imports = kernel.buildImportObject(memory) as { + const imports = kernel.testAuthority.buildImportObject(memory) as { env: Record any>; }; const pointer = memory.buffer.byteLength - 4; @@ -547,7 +650,7 @@ describe("Rust-owned host import ranges", () => { const write = vi.fn(() => 0); const { kernel, memory } = kernelHarness({}); Object.assign(kernel, { io: { write } }); - const imports = kernel.buildImportObject(memory) as { + const imports = kernel.testAuthority.buildImportObject(memory) as { env: Record any>; }; @@ -557,12 +660,11 @@ describe("Rust-owned host import ranges", () => { it("rejects an unrepresentable wasm64 network source without aliasing", () => { const send = vi.fn(() => 1); - const { kernel, memory } = kernelHarness({}); + const { kernel, memory } = kernelHarness({}, 8); Object.assign(kernel, { - kernelPtrWidth: 8, io: { network: { send } }, }); - const imports = kernel.buildImportObject(memory) as { + const imports = kernel.testAuthority.buildImportObject(memory) as { env: Record any>; }; @@ -579,7 +681,7 @@ describe("Rust-owned host import ranges", () => { const ftruncate = vi.fn(); const { kernel, memory } = kernelHarness({}); Object.assign(kernel, { io: { ftruncate } }); - const imports = kernel.buildImportObject(memory) as { + const imports = kernel.testAuthority.buildImportObject(memory) as { env: Record any>; }; @@ -602,7 +704,7 @@ describe("Rust-owned host import ranges", () => { }); const { kernel, memory } = kernelHarness({}); Object.assign(kernel, { io: { read } }); - const imports = kernel.buildImportObject(memory) as { + const imports = kernel.testAuthority.buildImportObject(memory) as { env: Record any>; }; @@ -614,11 +716,214 @@ describe("Rust-owned host import ranges", () => { .toEqual(new Uint8Array([0x41, 0x42])); }); + it.each([4, 8] as const)( + "publishes a positioned wasm%d read at the exact memory boundary", + (pointerWidth) => { + const exactOffset = (1n << 53n) + 1n; + let retained: Uint8Array | undefined; + const read = vi.fn(( + _handle: number, + destination: Uint8Array, + offset: number | bigint | null, + ) => { + retained = destination; + expect(offset).toBe(exactOffset); + destination.set([0x31, 0x32, 0x33, 0x34]); + return 4; + }); + const { kernel, memory } = kernelHarness({}, pointerWidth); + Object.assign(kernel, { io: { read } }); + const imports = kernel.testAuthority.buildImportObject(memory) as { + env: Record any>; + }; + const destination = memory.buffer.byteLength - 4; + const pointer = pointerWidth === 4 + ? destination + : BigInt(destination); + + expect(imports.env.host_pread( + 8n, + pointer, + 4, + 1, + 0x20_0000, + )).toBe(4); + expect(read).toHaveBeenCalledWith( + 8, + expect.any(Uint8Array), + exactOffset, + 4, + ); + expect(new Uint8Array(memory.buffer, destination, 4)) + .toEqual(new Uint8Array([0x31, 0x32, 0x33, 0x34])); + + retained![0] = 0x7f; + expect(new Uint8Array(memory.buffer, destination, 4)) + .toEqual(new Uint8Array([0x31, 0x32, 0x33, 0x34])); + + read.mockClear(); + expect(imports.env.host_pread( + 8n, + pointer, + 5, + 1, + 0x20_0000, + )).toBe(-14); + expect(read).not.toHaveBeenCalled(); + }, + ); + + it("rejects an oversized positioned-read result without publishing bytes", () => { + const read = vi.fn(( + _handle: number, + destination: Uint8Array, + ) => { + destination.fill(0x6b); + return destination.byteLength + 1; + }); + const { kernel, memory } = kernelHarness({}); + Object.assign(kernel, { io: { read } }); + const imports = kernel.testAuthority.buildImportObject(memory) as { + env: Record any>; + }; + new Uint8Array(memory.buffer, 4096, 8).fill(0xa5); + + expect(imports.env.host_pread(8n, 4096, 4, 0, 0)).toBe(-5); + expect(new Uint8Array(memory.buffer, 4096, 8)) + .toEqual(new Uint8Array(8).fill(0xa5)); + }); + + it.each([4, 8] as const)( + "passes an exact positioned wasm%d write without stream callbacks", + (pointerWidth) => { + const exactOffset = (1n << 53n) + 1n; + const write = vi.fn(() => 4); + const onStdout = vi.fn(); + const { kernel, memory } = kernelHarness({}, pointerWidth); + Object.assign(kernel, { + io: { write }, + callbacks: { onStdout }, + }); + const imports = kernel.testAuthority.buildImportObject(memory) as { + env: Record any>; + }; + const source = memory.buffer.byteLength - 4; + new Uint8Array(memory.buffer, source, 4).set([1, 2, 3, 4]); + const pointer = pointerWidth === 4 ? source : BigInt(source); + + expect(imports.env.host_pwrite( + 1n, + pointer, + 4, + 1, + 0x20_0000, + )).toBe(4); + expect(write).toHaveBeenCalledWith( + 1, + new Uint8Array([1, 2, 3, 4]), + exactOffset, + 4, + ); + expect(onStdout).not.toHaveBeenCalled(); + + write.mockClear(); + expect(imports.env.host_pwrite( + 1n, + pointer, + 5, + 1, + 0x20_0000, + )).toBe(-14); + expect(write).not.toHaveBeenCalled(); + }, + ); + + it.each([4, 8] as const)( + "passes a bounded wasm%d append through the explicit backend operation", + (pointerWidth) => { + const append = vi.fn(() => ({ written: 4, end: 19 })); + const write = vi.fn(); + const onStdout = vi.fn(); + const { kernel, memory } = kernelHarness( + {}, + pointerWidth, + { append, write }, + { onStdout }, + ); + const imports = kernel.testAuthority.buildImportObject(memory) as { + env: Record any>; + }; + const source = memory.buffer.byteLength - 4; + new Uint8Array(memory.buffer, source, 4).set([4, 3, 2, 1]); + const pointer = pointerWidth === 4 ? source : BigInt(source); + + expect(imports.env.host_append(1n, pointer, 4, -1, -1)).toBe(4); + expect(append).toHaveBeenCalledWith( + 1, + new Uint8Array([4, 3, 2, 1]), + 4, + null, + ); + expect(imports.env.host_append_position(1n, 4)).toBe(19n); + expect(write).not.toHaveBeenCalled(); + expect(onStdout).not.toHaveBeenCalled(); + + append.mockClear(); + expect(imports.env.host_append(1n, pointer, 5, -1, -1)).toBe(-14); + expect(append).not.toHaveBeenCalled(); + }, + ); + + it("throws on an impossible append count returned by a backend", () => { + const append = vi.fn(() => ({ written: 2, end: 2 })); + const { kernel, memory } = kernelHarness({}, 4, { append }); + const imports = kernel.testAuthority.buildImportObject(memory) as { + env: Record any>; + }; + new Uint8Array(memory.buffer, 4096, 1)[0] = 0x41; + + expect(() => imports.env.host_append(8n, 4096, 1, -1, -1)) + .toThrow(/invalid append byte count/i); + }); + + it("reconstructs signed i64 seek words without Number precision loss", () => { + const seek = vi.fn(( + _handle: number, + offset: number | bigint, + ) => offset < 0 ? 17 : offset); + const { kernel, memory } = kernelHarness({}); + Object.assign(kernel, { io: { seek } }); + const imports = kernel.testAuthority.buildImportObject(memory) as { + env: Record any>; + }; + + expect(imports.env.host_seek(8n, 1, 0x20_0000, 0)) + .toBe((1n << 53n) + 1n); + expect(imports.env.host_seek(8n, 0xffff_ffff, -1, 0)).toBe(17n); + expect(seek.mock.calls.map((call) => call[1])) + .toEqual([(1n << 53n) + 1n, -1n]); + }); + + it.each([-1n, -(1n << 63n)])( + "maps malformed negative backend seek result %s to EIO", + (backendResult) => { + const { kernel, memory } = kernelHarness({}); + Object.assign(kernel, { + io: { seek: vi.fn(() => backendResult) }, + }); + const imports = kernel.testAuthority.buildImportObject(memory) as { + env: Record any>; + }; + + expect(imports.env.host_seek(8n, 0, 0, 0)).toBe(-5n); + }, + ); + it("rejects an invalid read destination before consuming backend data", () => { const read = vi.fn(() => 1); const { kernel, memory } = kernelHarness({}); Object.assign(kernel, { io: { read } }); - const imports = kernel.buildImportObject(memory) as { + const imports = kernel.testAuthority.buildImportObject(memory) as { env: Record any>; }; @@ -634,7 +939,7 @@ describe("Rust-owned host import ranges", () => { const waitpid = vi.fn(() => ({ pid: 42, status: 0 })); const { kernel, memory } = kernelHarness({}); Object.assign(kernel, { io: { waitpid } }); - const imports = kernel.buildImportObject(memory) as { + const imports = kernel.testAuthority.buildImportObject(memory) as { env: Record any>; }; @@ -655,7 +960,7 @@ describe("Rust-owned host import ranges", () => { }, }, }); - const imports = kernel.buildImportObject(memory) as { + const imports = kernel.testAuthority.buildImportObject(memory) as { env: Record any>; }; new Uint8Array(memory.buffer).fill(0xa5, 4096, 4112); @@ -676,7 +981,7 @@ describe("Rust-owned host import ranges", () => { }, }, }); - const imports = kernel.buildImportObject(memory) as { + const imports = kernel.testAuthority.buildImportObject(memory) as { env: Record any>; }; new Uint8Array(memory.buffer).fill(0xa5, 4096, 4120); @@ -684,7 +989,7 @@ describe("Rust-owned host import ranges", () => { expect(imports.env.host_net_recv(1, 4096, 4, 0)).toBe(-5); expect(new Uint8Array(memory.buffer, 4096, 24)) .toEqual(new Uint8Array(24).fill(0xa5)); - expect(() => kernel.writeKernelBytes(4096, 4, output)) + expect(() => kernel.testAuthority.writeKernelBytes(4096, 4, output)) .toThrow(/20 exceeds capacity 4/i); expect(new Uint8Array(memory.buffer, 4096, 24)) .toEqual(new Uint8Array(24).fill(0xa5)); @@ -703,7 +1008,7 @@ describe("Rust-owned host import ranges", () => { }, }, }); - const imports = kernel.buildImportObject(memory) as { + const imports = kernel.testAuthority.buildImportObject(memory) as { env: Record any>; }; new Uint8Array(memory.buffer, 2048, 2).set([0x78, 0]); @@ -725,7 +1030,7 @@ describe("Rust-owned host import ranges", () => { }, }, }); - const imports = kernel.buildImportObject(memory) as { + const imports = kernel.testAuthority.buildImportObject(memory) as { env: Record any>; }; new Uint8Array(memory.buffer, 2048, 2).set([0x78, 0]); @@ -745,7 +1050,7 @@ describe("Rust-owned host import ranges", () => { onStdin: () => output, }, }); - const imports = kernel.buildImportObject(memory) as { + const imports = kernel.testAuthority.buildImportObject(memory) as { env: Record any>; }; new Uint8Array(memory.buffer).fill(0xa5, 4096, 4104); @@ -757,7 +1062,7 @@ describe("Rust-owned host import ranges", () => { it("rejects a null positive-length getrandom pointer", () => { const { kernel, memory } = kernelHarness({}); - const imports = kernel.buildImportObject(memory) as { + const imports = kernel.testAuthority.buildImportObject(memory) as { env: Record any>; }; @@ -766,14 +1071,13 @@ describe("Rust-owned host import ranges", () => { it("rejects an unrepresentable wasm64 process-copy destination before a kernel write", () => { const processMemory = new WebAssembly.Memory({ initial: 1 }); - const { kernel, memory } = kernelHarness({}); + const { kernel, memory } = kernelHarness({}, 8); Object.assign(kernel, { - kernelPtrWidth: 8, callbacks: { getProcessMemory: () => processMemory, }, }); - const imports = kernel.buildImportObject(memory) as { + const imports = kernel.testAuthority.buildImportObject(memory) as { env: Record any>; }; new Uint8Array(memory.buffer).fill(0xa5, 4096, 4100); @@ -796,7 +1100,7 @@ describe("Rust-owned host import ranges", () => { getProcessMemory: () => processMemory, }, }); - const imports = kernel.buildImportObject(memory) as { + const imports = kernel.testAuthority.buildImportObject(memory) as { env: Record any>; }; new Uint8Array(memory.buffer, 4096, 4).set([1, 2, 3, 4]); @@ -818,7 +1122,7 @@ describe("Rust-owned host import ranges", () => { getProcessMemory: () => processMemory, }, }); - const imports = kernel.buildImportObject(memory) as { + const imports = kernel.testAuthority.buildImportObject(memory) as { env: Record any>; }; const processEnd = processMemory.buffer.byteLength; @@ -858,9 +1162,8 @@ describe("Rust-owned host import ranges", () => { }); it("does not wrap a wasm64 futex address onto a low kernel word", () => { - const { kernel, memory } = kernelHarness({}); - Object.assign(kernel, { kernelPtrWidth: 8 }); - const imports = kernel.buildImportObject(memory) as { + const { kernel, memory } = kernelHarness({}, 8); + const imports = kernel.testAuthority.buildImportObject(memory) as { env: Record any>; }; const notify = vi.spyOn(Atomics, "notify"); @@ -872,7 +1175,7 @@ describe("Rust-owned host import ranges", () => { it("rejects unaligned and end-crossing futex words", () => { const { kernel, memory } = kernelHarness({}); - const imports = kernel.buildImportObject(memory) as { + const imports = kernel.testAuthority.buildImportObject(memory) as { env: Record any>; }; @@ -884,9 +1187,8 @@ describe("Rust-owned host import ranges", () => { }); it("rejects lossy device metadata conversions before registration", () => { - const { kernel, memory } = fullKernelHarness(); - Object.assign(kernel, { kernelPtrWidth: 8 }); - const imports = kernel.buildImportObject(memory) as { + const { kernel, memory } = fullKernelHarness({}, {}, 8); + const imports = kernel.testAuthority.buildImportObject(memory) as { env: Record any>; }; const invalid = BigInt(Number.MAX_SAFE_INTEGER) + 1n; @@ -913,7 +1215,7 @@ describe("Rust-owned host import ranges", () => { const create = vi.spyOn(kernel.bos, "create").mockImplementation(() => { throw new RangeError("allocation failed"); }); - const imports = kernel.buildImportObject(memory) as { + const imports = kernel.testAuthority.buildImportObject(memory) as { env: Record any>; }; @@ -930,7 +1232,7 @@ describe("Rust-owned host import ranges", () => { kernel.gl.get(7)!.gl = { getError, } as unknown as WebGL2RenderingContext; - const imports = kernel.buildImportObject(memory) as { + const imports = kernel.testAuthority.buildImportObject(memory) as { env: Record any>; }; @@ -964,9 +1266,8 @@ describe("Rust-owned host import ranges", () => { it.each([4, 8] as const)( "writes the generated KMS mode size at the exact wasm%d memory boundary", (pointerWidth) => { - const { kernel, memory } = fullKernelHarness(); - Object.assign(kernel, { kernelPtrWidth: pointerWidth }); - const imports = kernel.buildImportObject(memory) as { + const { kernel, memory } = fullKernelHarness({}, {}, pointerWidth); + const imports = kernel.testAuthority.buildImportObject(memory) as { env: Record any>; }; const exactPointer = @@ -995,12 +1296,8 @@ describe("Rust-owned host import ranges", () => { initial: 32_769, maximum: 32_769, }); - const { kernel } = fullKernelHarness(); - Object.assign(kernel, { - memory: highMemory, - kernelPtrWidth: 4, - }); - const imports = kernel.buildImportObject(highMemory) as { + const { kernel } = fullKernelHarness({}, {}, 4, highMemory); + const imports = kernel.testAuthority.buildImportObject(highMemory) as { env: Record any>; }; const unsignedPointer = 0x8000_0020; diff --git a/host/test/kernel-reservation-export-contract.test.ts b/host/test/kernel-reservation-export-contract.test.ts new file mode 100644 index 0000000000..b5e4db8bca --- /dev/null +++ b/host/test/kernel-reservation-export-contract.test.ts @@ -0,0 +1,99 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS } from "../src/generated/abi"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +const ABI_43_RESERVATION_EXPORTS = [ + "kernel_blocking_retry_release", + "kernel_blocking_retry_token", + "kernel_spawn_reserved_process", + "kernel_spawn_scratch_begin", + "kernel_spawn_scratch_cancel", + "kernel_spawn_scratch_capacity", + "kernel_spawn_scratch_pointer", + "kernel_spawn_scratch_retained_capacity", + "kernel_transfer_channel_execute", + "kernel_transfer_io_execute", + "kernel_transfer_scratch_begin", + "kernel_transfer_scratch_cancel", + "kernel_transfer_scratch_capacity", + "kernel_transfer_scratch_pointer", +] as const; +const ABI_43_CAPACITY_PREFLIGHT_EXPORTS = [ + "kernel_mq_descriptor_msgsize", +] as const; + +function continuedShellWords(source: string, firstLine: string): string[] { + const lines = source.split("\n"); + const start = lines.indexOf(firstLine); + expect(start).toBeGreaterThanOrEqual(0); + const words: string[] = []; + for (const line of lines.slice(start + 1)) { + const continued = line.endsWith(" \\"); + const word = (continued ? line.slice(0, -2) : line).trim(); + expect(word).toMatch(/^[A-Za-z0-9_]+$/); + words.push(word); + if (!continued) break; + } + return words; +} + +function shellArrayWords(source: string, assignment: string): string[] { + const lines = source.split("\n"); + const start = lines.indexOf(`${assignment}=(`); + expect(start).toBeGreaterThanOrEqual(0); + const words: string[] = []; + for (const line of lines.slice(start + 1)) { + const word = line.trim(); + if (word === ")") return words; + expect(word).toMatch(/^[A-Za-z0-9_]+$/); + words.push(word); + } + throw new Error(`unterminated shell array ${assignment}`); +} + +describe("kernel reservation export contract", () => { + it("makes the complete ABI 43 reservation protocol mandatory", () => { + const buildGuard = readFileSync( + join(repoRoot, "packages", "registry", "kernel", "build-kernel.sh"), + "utf8", + ); + const runtimeGuard = readFileSync(join(repoRoot, "run.sh"), "utf8"); + const guardedExports = continuedShellWords( + buildGuard, + 'wasm_require_exports "$OUT" \\', + ); + const runtimeGuardedExports = shellArrayWords( + runtimeGuard, + "KERNEL_REQUIRED_EXPORTS", + ); + + for (const exportName of ABI_43_RESERVATION_EXPORTS) { + // WHY: both runtime validation and packaged-kernel validation must reject + // an artifact that exposes only part of a tokenized reservation protocol. + expect(HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS).toContain(exportName); + expect(guardedExports).toContain(exportName); + expect(runtimeGuardedExports).toContain(exportName); + } + for (const exportName of ABI_43_CAPACITY_PREFLIGHT_EXPORTS) { + // WHY: POSIX MQ must resolve the queue-owned message ceiling before a + // host reservation. A packaged kernel lacking this query could otherwise + // change EMSGSIZE into ENOMEM or reserve the caller's unbounded capacity. + expect(HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS).toContain(exportName); + expect(guardedExports).toContain(exportName); + expect(runtimeGuardedExports).toContain(exportName); + } + + // WHY: startup and package installation are two entrances to the same host + // adapter. Comparing the complete generated ABI list prevents a future + // required export from being enforced by only one of those entrances. + expect(guardedExports).toEqual([...HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS]); + expect(runtimeGuardedExports).toEqual([ + ...HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS, + ]); + }); +}); diff --git a/host/test/kernel-scratch-contract.test.ts b/host/test/kernel-scratch-contract.test.ts index 5cc447d8b2..85d7bc1115 100644 --- a/host/test/kernel-scratch-contract.test.ts +++ b/host/test/kernel-scratch-contract.test.ts @@ -44,6 +44,12 @@ import { SPAWN_WIRE_OP_OPEN, SPAWN_WIRE_STRING_OFFSET_BYTES, } from "../src/generated/abi"; +import { + KERNEL_SCRATCH_EXPORT_NAMES, + kernelScratchNullablePointerArguments, + kernelScratchRequiredPointerArguments, + type KernelScratchExportName, +} from "../src/kernel-scratch"; import { auditWasmMemoryWrites, formatAuditFailures, @@ -51,6 +57,10 @@ import { type AuditAllowance, type OwnershipSeed, } from "./support/wasm-memory-write-audit"; +import { + auditKernelEntryContext, + formatKernelEntryContextViolations, +} from "./support/kernel-entry-context-audit"; const platformLimitsHeader = readFileSync( new URL( "../../libc/musl-overlay/include/bits/kandelo_limits.h", @@ -77,6 +87,10 @@ const buildMuslSource = readFileSync( new URL("../../scripts/build-musl.sh", import.meta.url), "utf8", ); +const installOverlayHeadersSource = readFileSync( + new URL("../../scripts/install-overlay-headers.sh", import.meta.url), + "utf8", +); const muslSpawnSource = readFileSync( new URL( "../../libc/musl-overlay/src/process/wasm32posix/posix_spawn.c", @@ -88,30 +102,230 @@ const kernelSpawnSource = readFileSync( new URL("../../crates/kernel/src/spawn.rs", import.meta.url), "utf8", ); +const kernelWasmApiSource = readFileSync( + new URL("../../crates/kernel/src/wasm_api.rs", import.meta.url), + "utf8", +); +const legacySyscallImportsSource = readFileSync( + new URL("../../libc/glue/syscall_imports.h", import.meta.url), + "utf8", +); +const legacySyscallGlueSource = readFileSync( + new URL("../../libc/glue/syscall_glue.c", import.meta.url), + "utf8", +); +const abiSnapshotSource = readFileSync( + new URL("../../abi/snapshot.json", import.meta.url), + "utf8", +); +function kernelExportNamesFromSnapshot(source: string): Set { + const snapshot = JSON.parse(source) as unknown; + if ( + snapshot === null || + typeof snapshot !== "object" || + !("kernel_exports" in snapshot) || + !Array.isArray(snapshot.kernel_exports) + ) { + throw new Error("ABI snapshot kernel_exports must be an array"); + } + const names = snapshot.kernel_exports.map((entry, index) => { + if ( + entry === null || + typeof entry !== "object" || + !("name" in entry) || + typeof entry.name !== "string" || + entry.name.length === 0 + ) { + throw new Error( + `ABI snapshot kernel_exports[${index}] has no exact name`, + ); + } + return entry.name; + }); + const uniqueNames = new Set(names); + if (uniqueNames.size !== names.length) { + throw new Error("ABI snapshot kernel_exports contains duplicate names"); + } + return uniqueNames; +} +const abiKernelExportNames = kernelExportNamesFromSnapshot(abiSnapshotSource); const hostKernelWorkerSource = readFileSync( new URL("../src/kernel-worker.ts", import.meta.url), "utf8", ); +const hostKernelSource = readFileSync( + new URL("../src/kernel.ts", import.meta.url), + "utf8", +); +const kernelScratchSource = readFileSync( + new URL("../src/kernel-scratch.ts", import.meta.url), + "utf8", +); +const kernelEntryGateSource = readFileSync( + new URL("../src/kernel-entry-gate.ts", import.meta.url), + "utf8", +); +const hostIndexSource = readFileSync( + new URL("../src/index.ts", import.meta.url), + "utf8", +); const repoRoot = fileURLToPath(new URL("../..", import.meta.url)); +interface RustKernelExportParameter { + readonly name: string; + readonly type: string; +} + +function rustKernelExportParameters( + source: string, + exportName: string, +): RustKernelExportParameter[] { + const declaration = `pub extern "C" fn ${exportName}`; + const declarationOffsets: number[] = []; + let searchOffset = 0; + while (searchOffset < source.length) { + const candidateOffset = source.indexOf(declaration, searchOffset); + if (candidateOffset < 0) break; + const afterName = source[candidateOffset + declaration.length]; + if (afterName === "(" || /\s/.test(afterName)) { + declarationOffsets.push(candidateOffset); + } + searchOffset = candidateOffset + declaration.length; + } + if (declarationOffsets.length === 0) { + throw new Error(`missing Rust declaration for ${exportName}`); + } + if (declarationOffsets.length !== 1) { + throw new Error(`duplicate Rust declaration for ${exportName}`); + } + const declarationOffset = declarationOffsets[0]; + const open = source.indexOf("(", declarationOffset + declaration.length); + if (open < 0) throw new Error(`missing parameter list for ${exportName}`); + + let close = -1; + let depth = 0; + for (let offset = open; offset < source.length; offset++) { + const char = source[offset]; + if (char === "(") depth++; + if (char === ")" && --depth === 0) { + close = offset; + break; + } + } + if (close < 0) + throw new Error(`unterminated parameter list for ${exportName}`); + + const parameters: string[] = []; + let parameterStart = open + 1; + let nested = 0; + for (let offset = parameterStart; offset <= close; offset++) { + const char = source[offset]; + if (char === "(" || char === "[" || char === "{" || char === "<") nested++; + if (char === ")" || char === "]" || char === "}" || char === ">") nested--; + if ((char === "," && nested === 0) || offset === close) { + const parameter = source.slice(parameterStart, offset).trim(); + if (parameter) parameters.push(parameter); + parameterStart = offset + 1; + } + } + + return parameters.map((parameter) => { + const match = parameter.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([\s\S]+)$/); + if (!match) { + throw new Error( + `cannot parse ${exportName} Rust parameter ${JSON.stringify(parameter)}`, + ); + } + return { name: match[1], type: match[2].trim() }; + }); +} + +function rustKernelScratchPointerIndexes( + source: string, + exportName: KernelScratchExportName, +): number[] { + const parameters = rustKernelExportParameters(source, exportName); + const pointerIndexes: number[] = []; + parameters.forEach((parameter, index) => { + const rawPointer = /^\*(?:const|mut)\s+/.test(parameter.type); + const namedPointer = parameter.name.endsWith("_ptr"); + if (rawPointer && !namedPointer) { + throw new Error( + `${exportName} raw pointer parameter ${parameter.name} must use ` + + "the _ptr suffix consumed by this drift contract", + ); + } + if (!namedPointer) return; + if (!rawPointer && parameter.type !== "usize") { + throw new Error( + `${exportName} pointer parameter ${parameter.name} has ` + + `unsupported Rust type ${parameter.type}`, + ); + } + const capacity = parameters[index + 1]; + if ( + capacity === undefined || + !/(?:len|capacity)$/.test(capacity.name) || + (capacity.type !== "u32" && capacity.type !== "usize") + ) { + throw new Error( + `${exportName} pointer parameter ${parameter.name} must be ` + + "followed by an explicit u32/usize length or capacity", + ); + } + pointerIndexes.push(index); + }); + return pointerIndexes; +} + +function assertKernelScratchPointerRoleContract( + source: string, + exportName: KernelScratchExportName, +): void { + const required = [...kernelScratchRequiredPointerArguments(exportName)]; + const nullable = [...kernelScratchNullablePointerArguments(exportName)]; + const hostPointerIndexes = [...required, ...nullable].sort( + (left, right) => left - right, + ); + if (new Set(hostPointerIndexes).size !== hostPointerIndexes.length) { + throw new Error(`${exportName} host pointer roles are not unique`); + } + const rustPointerIndexes = rustKernelScratchPointerIndexes( + source, + exportName, + ); + if ( + hostPointerIndexes.length !== rustPointerIndexes.length || + hostPointerIndexes.some( + (pointerIndex, index) => pointerIndex !== rustPointerIndexes[index], + ) + ) { + throw new Error( + `${exportName} Rust/host pointer-role drift: Rust has ` + + `[${rustPointerIndexes.join(", ")}], host has ` + + `[${hostPointerIndexes.join(", ")}]`, + ); + } +} + const ownershipSeeds: OwnershipSeed[] = [ { - declaration: "host/src/kernel.ts::WasmPosixKernel.memory", + declaration: "host/src/kernel.ts::WasmPosixKernel.#memory", target: "value", owner: "kernel", form: "memory", why: "This private field is the kernel WebAssembly linear memory.", }, { - declaration: "host/src/kernel.ts::WasmPosixKernel.instance", + declaration: "host/src/kernel.ts::WasmPosixKernel.#instance", target: "value", owner: "kernel", form: "instance", why: "This private field is the instantiated kernel module whose exported memory aliases the kernel linear memory.", }, { - declaration: "host/src/kernel.ts::WasmPosixKernel.createKernelMemory", + declaration: "host/src/kernel.ts::WasmPosixKernel.#createKernelMemory", target: "return", owner: "kernel", form: "memory", @@ -119,7 +333,7 @@ const ownershipSeeds: OwnershipSeed[] = [ }, { declaration: - "host/src/kernel-worker.ts::CentralizedKernelWorker.kernelMemory", + "host/src/kernel-worker.ts::CentralizedKernelWorker.#kernelMemory", target: "value", owner: "kernel", form: "memory", @@ -127,7 +341,7 @@ const ownershipSeeds: OwnershipSeed[] = [ }, { declaration: - "host/src/kernel-worker.ts::CentralizedKernelWorker.kernelInstance", + "host/src/kernel-worker.ts::CentralizedKernelWorker.#kernelInstance", target: "value", owner: "kernel", form: "instance", @@ -135,49 +349,34 @@ const ownershipSeeds: OwnershipSeed[] = [ }, { declaration: - "apps/browser-demos/test/epoll-repro.ts::KernelWorkerInternals.kernelInstance", - target: "value", - owner: "kernel", - form: "instance", - why: "This diagnostic-only interface is the reviewed structural view of CentralizedKernelWorker's exact private kernel instance.", - }, - { - declaration: - "apps/browser-demos/test/epoll-repro.ts::KernelWorkerInternals.scratchRegion", + "host/src/kernel-worker.ts::CentralizedKernelWorker.#scratchRegion", target: "value", owner: "kernel", form: "scratch-region", - why: "This diagnostic-only interface is the reviewed structural view of CentralizedKernelWorker's allocator-created main scratch region.", - }, - { - declaration: - "apps/browser-demos/test/fixtures/opfs-advisory-lock-client-worker.ts::KernelWorkerInternals.kernelInstance", - target: "value", - owner: "kernel", - form: "instance", - why: "This browser fixture's structural field is populated only by its reviewed cast of the live CentralizedKernelWorker instance.", + why: "This true-private slot may contain only the allocator-authenticated main kernel scratch region; the audit rejects every assignment that does not preserve that exact region provenance.", }, { declaration: - "apps/browser-demos/test/fixtures/opfs-advisory-lock-client-worker.ts::KernelWorkerInternals.scratchRegion", + "host/src/kernel-worker.ts::CentralizedKernelWorker.#tcpScratchRegion", target: "value", owner: "kernel", form: "scratch-region", - why: "This browser fixture's structural field is populated only by its reviewed cast of the allocator-created main scratch region.", + why: "This true-private slot may contain only the allocator-authenticated TCP kernel scratch region; the audit rejects every assignment that does not preserve that exact region provenance.", }, { - declaration: "host/src/browser-kernel-worker-entry.ts::kernelMemory", - target: "value", - owner: "kernel", + declaration: "host/src/process-memory.ts::createProcessMemory", + target: "return", + owner: "process-memory", form: "memory", - why: "This browser-worker diagnostic alias points at kernel memory.", + why: "This factory creates caller-owned process memory, not kernel scratch.", }, { - declaration: "host/src/process-memory.ts::createProcessMemory", + declaration: + "host/src/process-memory.ts::ProcessMemoryAllocator.createMemory", target: "return", owner: "process-memory", form: "memory", - why: "This factory creates caller-owned process memory, not kernel scratch.", + why: "The exact-ownership allocator creates and records only caller process memory, never kernel scratch.", }, { declaration: @@ -232,6 +431,37 @@ const ownershipSeeds: OwnershipSeed[] = [ form: "memory", why: "Each browser process generation owns its exact guest process memory.", }, + { + declaration: + "host/src/worker-protocol.ts::CentralizedWorkerInitMessage.memory", + target: "value", + owner: "process-memory", + form: "memory", + why: "This worker ingress carries the newly launched process's own linear memory, never kernel scratch.", + }, + { + declaration: + "host/src/worker-protocol.ts::CentralizedThreadInitMessage.memory", + target: "value", + owner: "process-memory", + form: "memory", + why: "This thread ingress carries the owning process's shared linear memory, never kernel scratch.", + }, + { + declaration: "host/src/browser-kernel-protocol.ts::FbBindMessage.memory", + target: "value", + owner: "framebuffer", + form: "memory", + why: "This browser message carries a process framebuffer mapping for display binding, not kernel memory.", + }, + { + declaration: + "host/src/browser-kernel-protocol.ts::FbRebindMemoryMessage.memory", + target: "value", + owner: "framebuffer", + form: "memory", + why: "This browser message replaces a framebuffer process-memory binding after growth, not kernel memory.", + }, { declaration: "host/src/wasi-shim.ts::WasiShim.memory", target: "value", @@ -299,7 +529,484 @@ const ownershipSeeds: OwnershipSeed[] = [ }, ]; +const reviewedScalarKernelExportCall = ( + key: string, + count?: number, +): AuditAllowance => ({ + key, + disposition: "kernel-control", + ...(count === undefined ? {} : { count }), + // WHY: the generated snapshot makes every kernel export fail closed. Each + // exact occurrence below was reviewed to carry only control scalars or + // caller-address-space values: it neither borrows host-staged kernel memory + // nor authorizes a Rust scratch reservation. A second/new call remains a + // violation unless it receives its own review. + why: "This exact generated-export call carries no host-staged kernel-memory borrow or scratch-reservation authority.", +}); + +const reviewedScalarKernelExportCalls: AuditAllowance[] = [ + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#attachThreadChannelWithinKernelEntry::kernel-export-direct-use::setMaxAddr(pid, this.toKernelPtr(tlsPageAddr))", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#bindKernelTid::kernel-export-direct-use::setTid(pid, tid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#captureBlockingRetryDisposition::kernel-export-direct-use::getTimeout( channel.pid, origArgs[0]!, timeoutDirection, )", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#captureBlockingRetryDisposition::kernel-export-direct-use::isFdNonblock(channel.pid, fd)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#createTestAuthority::kernel-export-direct-use::forkProcess(parentPid, callerTid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#finalizeAddressSpaceForExecWithinKernelEntry::kernel-export-direct-use::detach(pid, mapping.segId)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.firePosixTimer::kernel-export-direct-use::fire(pid, timerId)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#getProcessExitSignal::kernel-export-direct-use::getExitSignal(pid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#handleSyscallInner::kernel-export-direct-use::messageSizeForDescriptor( channel.pid, this.guestTidForChannel(channel), origArgs[0], )", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#inheritPreparedSharedMappingsWithinKernelEntry::kernel-export-direct-use::kernelShmat!( prepared.childPid, mapping.segId, mapping.mapAddr, mapping.readOnly ? SHM_RDONLY : 0, )", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#injectIncomingVirtualTcpConnection::kernel-export-direct-use::( this.#kernelInstanceForEntry(entry).exports.kernel_inject_connection as ( pid: number, listenerFd: number, a: number, b: number, c: number, d: number, port: number, ) => number )( target.pid, target.fd, remoteAddr[0], remoteAddr[1], remoteAddr[2], remoteAddr[3], remotePort, )", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#kernelThreadHasDeliverable::kernel-export-direct-use::threadHasDeliverable(pid, tid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#killAllBlockedForTeardownWithinKernelEntry::kernel-export-direct-use::getExitStatus(registration.pid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#notifyThreadExitWithinKernelEntry::kernel-export-direct-use::threadExit(pid, tid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#pickKernelSignalTargetTid::kernel-export-direct-use::pickSignalTarget(pid, signum)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#prepareExecFdMirrorPruneWithinKernelEntry::kernel-export-direct-use::fdIsOpen(pid, epfd)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#prepareExecFdMirrorPruneWithinKernelEntry::kernel-export-direct-use::fdIsOpen(pid, interest.fd)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#prepareTcpListenerRegistration::kernel-export-direct-use::getAcceptWake?.(pid, fd)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#releaseBlockingRetrySnapshot::kernel-export-direct-use::release( channel.pid, this.guestTidForChannel(channel), snapshot.retryToken, )", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#rememberBlockingRetrySnapshot::kernel-export-direct-use::tokenForRetry( channel.pid, this.guestTidForChannel(channel), snapshot.syscallNr, )", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#removeFromKernelProcessTableWithinKernelEntry::kernel-export-direct-use::removeProcess(pid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#replaceProcessMetadataWithinKernelEntry::kernel-export-direct-use::clear(pid, kind)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#reserveHostRegionAtWithinKernelEntry::kernel-export-direct-use::reserveHostRegionAtFn( pid, this.toKernelPtr(request.pointer), this.toKernelPtr(request.length), )", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#reserveHostRegionWithinKernelEntry::kernel-export-direct-use::reserveHostRegionFn(pid, this.toKernelPtr(checkedLength))", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#resolveExecListenerFdWithinKernelEntry::kernel-export-direct-use::fdIsOpen(pid, oldFd)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#resolveExecListenerFdWithinKernelEntry::kernel-export-direct-use::findListenerFd?.(pid, wakeIdx)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#resolveExecListenerFdWithinKernelEntry::kernel-export-direct-use::getAcceptWake(pid, fd)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#resolveExecListenerFdWithinKernelEntry::kernel-export-direct-use::getAcceptWake(pid, oldFd)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#retireBlockingRetryCaptureAfterExitedProcess::kernel-export-direct-use::getState(channel.pid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#rollbackInheritedSysvAttachmentsWithinKernelEntry::kernel-export-direct-use::kernelShmdt(childPid, segId)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#rollbackIpcShmatWithinKernelEntry::kernel-export-direct-use::kernelShmdt(channel.pid, shmid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#setBrkBaseWithinKernelEntry::kernel-export-direct-use::setBrkBaseFn(pid, this.toKernelPtr(addr))", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#setBrkLimitWithinKernelEntry::kernel-export-direct-use::setBrkLimitFn(pid, this.toKernelPtr(brkLimit))", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#setCredentialsWithinKernelEntry::kernel-export-direct-use::direct(pid, uid, gid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#setMaxAddrWithinKernelEntry::kernel-export-direct-use::setMaxAddrFn(pid, this.toKernelPtr(maxAddr))", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#setMmapBaseWithinKernelEntry::kernel-export-direct-use::setMmapBaseFn(pid, this.toKernelPtr(mmapBase))", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#setupPtyWithinKernelEntry::kernel-export-direct-use::kernelPtyCreate(pid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#snapshotExecTcpListenerWakeIdsWithinKernelEntry::kernel-export-direct-use::getAcceptWake(pid, fd)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#snapshotExecTcpListenerWakeIdsWithinKernelEntry::kernel-export-direct-use::getAcceptWake(pid, target.fd)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#startProcessWorkerWhenRunnableWithinKernelEntry::kernel-export-direct-use::getState(pid)", + 2, + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.consumeExitedChild::kernel-export-direct-use::reapChild(parentPid, childPid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.createProcess::kernel-export-direct-use::createProcess(stdinKind, stdoutKind, stderrKind)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.fdSupportsMmapWriteback::kernel-export-direct-use::supports(pid, fd)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.finalizeExitedProcessBeforeLifecycleNotification::kernel-export-direct-use::getState(pid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.getForkCount::kernel-export-direct-use::fn(pid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.getKernelMemoryPages::kernel-export-direct-use::fn()", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.getParentPid::kernel-export-direct-use::getParentPid(pid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.getSpawnScratchCapacity::kernel-export-direct-use::fn()", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.handleBlockingRetry::kernel-export-direct-use::getAcceptWakeIdx?.(channel.pid, fd)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.handleBlockingRetry::kernel-export-direct-use::getFdPipeIdx?.(channel.pid, origArgs[0])", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.handleBlockingRetry::kernel-export-direct-use::getSendPipeIdx?.(channel.pid, origArgs[0])", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.handleExit::kernel-export-direct-use::commitProcessExit(exitStatus)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.handleExit::kernel-export-direct-use::getProcessState(channel.pid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.handleFork::kernel-export-direct-use::clearForkChild(childPid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.handleFork::kernel-export-direct-use::kernelForkProcess(parentPid, callerTid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.handleIpcShmat::kernel-export-direct-use::kernelShmat( channel.pid, callerTid, shmid, // The kernel owns attachment accounting but not the process mapping // address; this legacy ABI slot is intentionally ignored by Rust. 0, flags, )", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.handleIpcShmdt::kernel-export-direct-use::kernelShmdt( channel.pid, callerTid, mapping.segId, )", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.handleSemctl::kernel-export-direct-use::arrayBytes( channel.pid, this.guestTidForChannel(channel), semid, rawCmd, )", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.handleSemctl::kernel-export-direct-use::statBytes(processPointerWidth)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.inheritHostFdMirrors::kernel-export-direct-use::fdIsOpen(childPid, entry.fd)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.inheritHostFdMirrors::kernel-export-direct-use::fdIsOpen(childPid, epfd)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.inheritHostFdMirrors::kernel-export-direct-use::getAcceptWake?.(parentPid, parentTarget.fd)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.kernelExecPrepare::kernel-export-direct-use::prepare(pid, callerTid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.kernelExecSetup::kernel-export-direct-use::threadAware(pid, callerTid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.notifyParentOfChildStateTransition::kernel-export-direct-use::hasNoCldStop(parentPid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.notifyParentOfExitedProcess::kernel-export-direct-use::hasNoCldWait(parentPid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.registerProcess::kernel-export-direct-use::getProcessState?.(pid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.releaseAllSysvShmMappingsForProcess::kernel-export-direct-use::kernelShmdt(pid, mapping.segId)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.resolveEpollReadinessIndices::kernel-export-direct-use::getAcceptWakeIdx(pid, interest.fd)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.resolveEpollReadinessIndices::kernel-export-direct-use::getRecvPipe(pid, interest.fd)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.resolveInheritedListenerFd::kernel-export-direct-use::findListenerFd?.(pid, wakeIdx)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.resolveInheritedListenerFd::kernel-export-direct-use::getAcceptWake(pid, fd)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.resolveInheritedListenerFd::kernel-export-direct-use::getAcceptWake(pid, preferredFd)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.resolvePollReadinessIndices::kernel-export-direct-use::getAcceptWakeIdx(pid, fd)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.resolvePollReadinessIndices::kernel-export-direct-use::getRecvPipe(pid, fd)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.resumeStoppedProcess::kernel-export-direct-use::getState(pid)", + 6, + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.validateKernelTid::kernel-export-direct-use::validateTask(pid, tid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel.ts::WasmPosixKernel.audioChannels::kernel-export-direct-use::fn()", + ), + reviewedScalarKernelExportCall( + "host/src/kernel.ts::WasmPosixKernel.audioPending::kernel-export-direct-use::fn()", + ), + reviewedScalarKernelExportCall( + "host/src/kernel.ts::WasmPosixKernel.audioSampleRate::kernel-export-direct-use::fn()", + ), + reviewedScalarKernelExportCall( + "host/src/kernel.ts::WasmPosixKernel.dup3::kernel-export-direct-use::fn(oldfd, newfd, flags)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel.ts::WasmPosixKernel.fchmod::kernel-export-direct-use::fn(fd, mode)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel.ts::WasmPosixKernel.fchown::kernel-export-direct-use::fn(fd, uid, gid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel.ts::WasmPosixKernel.fdatasync::kernel-export-direct-use::fn(fd)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel.ts::WasmPosixKernel.fsync::kernel-export-direct-use::fn(fd)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel.ts::WasmPosixKernel.ftruncate::kernel-export-direct-use::fn(fd, BigInt(length))", + ), + reviewedScalarKernelExportCall( + "host/src/kernel.ts::WasmPosixKernel.getpgrp::kernel-export-direct-use::fn()", + ), + reviewedScalarKernelExportCall( + "host/src/kernel.ts::WasmPosixKernel.getsid::kernel-export-direct-use::fn(pid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel.ts::WasmPosixKernel.setegid::kernel-export-direct-use::fn(egid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel.ts::WasmPosixKernel.seteuid::kernel-export-direct-use::fn(euid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel.ts::WasmPosixKernel.setgid::kernel-export-direct-use::fn(gid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel.ts::WasmPosixKernel.setpgid::kernel-export-direct-use::fn(pid, pgid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel.ts::WasmPosixKernel.setsid::kernel-export-direct-use::fn()", + ), + reviewedScalarKernelExportCall( + "host/src/kernel.ts::WasmPosixKernel.setuid::kernel-export-direct-use::fn(uid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel.ts::WasmPosixKernel.shutdown::kernel-export-direct-use::fn(fd, how)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel.ts::WasmPosixKernel.signal::kernel-export-direct-use::fn(signum, handler)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel.ts::WasmPosixKernel.socket::kernel-export-direct-use::fn(domain, type, protocol)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel.ts::WasmPosixKernel.sysconf::kernel-export-direct-use::fn(name)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel.ts::WasmPosixKernel.umask::kernel-export-direct-use::fn(mask)", + ), +]; + const auditAllowances: AuditAllowance[] = [ + ...reviewedScalarKernelExportCalls, + { + key: "host/src/host-adapter-manifest.ts::::wasm-authority-escape::WebAssembly.Memory.prototype", + disposition: "kernel-read", + why: "Module initialization passes the intrinsic Memory prototype directly to the captured descriptor lookup so the manifest reader can authenticate and measure a genuine current kernel buffer; no Memory instance or buffer is retained.", + }, + { + key: "host/src/kernel-entry-gate.ts::::wasm-authority-escape::WebAssembly.Instance.prototype", + disposition: "kernel-control", + why: "Module initialization passes the intrinsic Instance prototype directly to the captured descriptor lookup so the gate can authenticate raw engine instances without retaining an instance or exports namespace.", + }, + { + key: "host/src/kernel-entry-gate.ts::createFrozenKernelInstanceFacade::wasm-authority-escape::intrinsicWasmInstancePrototype", + disposition: "kernel-control", + why: "The frozen façade deliberately has the intrinsic Instance prototype for nominal compatibility, but it is a slotless plain object: the captured intrinsic exports getter rejects it and only the gate's private WeakMaps authorize it.", + }, + { + key: "host/src/kernel-scratch.ts::::wasm-authority-escape::WebAssembly.Memory.prototype", + disposition: "scratch-core", + why: "Module initialization passes the intrinsic Memory prototype directly to the captured descriptor lookup; the resulting getter authenticates current buffers inside checked scratch operations and exposes no Memory authority.", + }, + { + key: "host/src/kernel-worker.ts::::wasm-authority-escape::WebAssembly.Memory.prototype", + disposition: "kernel-control", + why: "The dedicated worker captures the intrinsic Memory buffer getter before callbacks can mutate built-ins; this exact prototype access retains no Memory instance or backing buffer.", + }, + { + key: "host/src/kernel.ts::::wasm-authority-escape::WebAssembly.Memory.prototype", + disposition: "kernel-control", + why: "The kernel wrapper captures the intrinsic Memory buffer getter before any host hook runs so later range checks cannot be redirected to a fake buffer; no Memory instance is captured here.", + }, + { + key: "host/src/kernel.ts::::wasm-authority-escape::WebAssembly.Instance.prototype", + disposition: "kernel-control", + why: "The kernel wrapper passes the intrinsic Instance prototype directly to a captured descriptor lookup so raw engine exports can be authenticated before gate construction; it retains no instance or namespace.", + }, + { + key: "packages/registry/node-compat/bootstrap.js::runInThisContext::dynamic-code-contract::eval(code)", + disposition: "non-kernel", + why: "This reviewed Node compatibility boundary implements vm.runInThisContext for package JavaScript; the evaluated source runs in its explicit compatibility realm and receives no kernel Memory, Instance, exports namespace, or scratch authority.", + }, + { + key: "packages/registry/node-compat/bootstrap.js::runInThisContext::dynamic-code-contract::eval(this.code)", + disposition: "non-kernel", + why: "This is the matching compiled-script path for the reviewed Node vm compatibility boundary and likewise receives no kernel WebAssembly authority.", + }, + { + key: "packages/registry/spidermonkey/node-compat/adapter.js::evalScriptAsFunction::dynamic-code-contract::(0, eval)(source + '\\n//# sourceURL=' + filename)", + disposition: "non-kernel", + why: "The SpiderMonkey Node-compat adapter intentionally evaluates package JavaScript as its documented script-loader boundary; it is outside the kernel worker and receives no kernel WebAssembly authority.", + }, + { + key: 'apps/browser-demos/pages/network/network-demo-worker.ts::createProcessMemory::wasm-memory-authority::new WebAssembly.Memory({ initial: BigInt(initialPages) as unknown as number, maximum: BigInt(MAX_PAGES) as unknown as number, shared: true, address: "i64", } as WebAssembly.MemoryDescriptor)', + disposition: "non-kernel", + authorityOwner: "process-memory", + why: "This memory64 branch creates only the diagnostic demo process's shared guest memory.", + }, + { + key: "apps/browser-demos/pages/network/network-demo-worker.ts::createProcessMemory::wasm-memory-authority::new WebAssembly.Memory({ initial: initialPages, maximum: MAX_PAGES, shared: true, })", + disposition: "non-kernel", + authorityOwner: "process-memory", + why: "This memory32 branch creates only the diagnostic demo process's shared guest memory.", + }, + { + key: "apps/browser-demos/public/terminate-atomics-worker.js::::wasm-memory-authority::new WebAssembly.Memory({ initial: pages, maximum: 16384, shared: true })", + disposition: "non-kernel", + authorityOwner: "shared-memory", + why: "This isolated browser diagnostic creates only its disposable Atomics termination test memory.", + }, + { + key: "apps/browser-demos/public/wasm-memory-reclaim-worker.js::makeCommittedSharedMemory::wasm-memory-authority::new WebAssembly.Memory({ initial: 1, maximum: MAX_PAGES, shared: true, })", + disposition: "non-kernel", + authorityOwner: "shared-memory", + why: "This isolated browser diagnostic creates disposable shared memory solely to measure reclamation.", + }, + { + key: "apps/browser-demos/test/epoll-repro.ts::main::wasm-memory-authority::new WebAssembly.Memory({ initial: 17, maximum: MAX_PAGES, shared: true })", + disposition: "non-kernel", + authorityOwner: "process-memory", + why: "This browser epoll reproduction creates its test process memory, not the kernel's linear memory.", + }, + { + key: 'host/src/process-memory.ts::ProcessMemoryAllocator.createMemory::wasm-memory-authority::new WebAssembly.Memory({ initial: BigInt(request.initialPages) as any, maximum: BigInt(request.maximumPages) as any, shared: true, address: "i64", } as any)', + disposition: "non-kernel", + authorityOwner: "process-memory", + why: "The memory64 branch creates one allocator-owned process generation and immediately records its exact ownership and byte charge.", + }, + { + key: "host/src/process-memory.ts::ProcessMemoryAllocator.createMemory::wasm-memory-authority::new WebAssembly.Memory({ initial: request.initialPages, maximum: request.maximumPages, shared: true, })", + disposition: "non-kernel", + authorityOwner: "process-memory", + why: "The memory32 branch creates one allocator-owned process generation and immediately records its exact ownership and byte charge.", + }, + { + key: "host/src/dylink.ts::instantiateSharedLibrarySteps::wasm-instance-authority::new WebAssembly.Instance(module, instanceImports)", + disposition: "non-kernel", + authorityOwner: "process-memory", + why: "This instance is a user process's dynamically linked shared library and receives only that activation's wrapped process imports.", + }, + { + key: "host/src/fork-anyref-transit.ts::ForkAnyrefTransitTable.constructor::wasm-instance-authority::new WebAssembly.Instance(compileProviderModule())", + disposition: "non-kernel", + authorityOwner: "process-memory", + why: "The fork transit provider has no imports or linear memory and owns only the process worker's temporary externref table.", + }, + { + key: "host/src/fork-worker-import-exceptions.ts::buildFatalTrap::wasm-instance-authority::new WebAssembly.Instance(new WebAssembly.Module(bytes))", + disposition: "non-kernel", + authorityOwner: "process-memory", + why: "This closed import-free helper has no memory and exposes only the unconditional trap used for Worker exception semantics.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#createKernelMemory::wasm-memory-authority::new IntrinsicWasmMemory({ initial: 24n, maximum: 16384n, shared: true, address: "i64", } as unknown as WebAssembly.MemoryDescriptor)', + disposition: "kernel-control", + authorityOwner: "kernel", + why: "This true-private memory64 branch creates the dedicated kernel linear memory.", + }, + { + key: "host/src/kernel.ts::WasmPosixKernel.#createKernelMemory::wasm-memory-authority::new IntrinsicWasmMemory({ // 24 pages = 1.5 MiB of initial address space. This must remain above // the kernel Wasm's linker-derived minimum and leaves headroom for // future static data without re-tuning host construction each time. initial: 24, maximum: 16384, shared: true, })", + disposition: "kernel-control", + authorityOwner: "kernel", + why: "This true-private memory32 branch creates the dedicated kernel linear memory.", + }, + { + key: "host/src/kernel.ts::WasmPosixKernel.init::wasm-instance-authority::intrinsicApply( intrinsicWasmInstantiate, WebAssembly, [module, importObject], )", + disposition: "kernel-control", + authorityOwner: "kernel", + why: "The captured intrinsic instantiates the kernel module with its private kernel-memory import object.", + }, + { + key: 'host/src/process-memory.ts::createProcessMemory::wasm-memory-authority::new WebAssembly.Memory({ initial: BigInt(layout.initialPages) as any, maximum: BigInt(layout.maximumPages) as any, shared: true, address: "i64", } as any)', + disposition: "non-kernel", + authorityOwner: "process-memory", + why: "This ordinary memory64 factory creates caller-owned process memory.", + }, + { + key: "host/src/process-memory.ts::createProcessMemory::wasm-memory-authority::new WebAssembly.Memory({ initial: layout.initialPages, maximum: layout.maximumPages, shared: true, })", + disposition: "non-kernel", + authorityOwner: "process-memory", + why: "This ordinary memory32 factory creates caller-owned process memory.", + }, + { + key: "host/src/worker-main.ts::centralizedThreadWorkerMain::wasm-instance-authority::new WebAssembly.Instance(module, threadInstanceImports)", + disposition: "non-kernel", + authorityOwner: "process-memory", + why: "This thread worker executes a user module against the activation-owned wrapped view of that process's imports.", + }, + { + key: "host/src/worker-main.ts::centralizedWorkerMain::wasm-instance-authority::WebAssembly.instantiate( module, mainInstantiationImports as unknown as WebAssembly.Imports, )", + disposition: "non-kernel", + authorityOwner: "process-memory", + why: "This replay-capable main activation receives reconstructed process imports and caller-owned process memory, never kernel memory.", + }, + { + key: "host/src/worker-main.ts::centralizedWorkerMain::wasm-instance-authority::WebAssembly.instantiate(module, importObject)", + disposition: "non-kernel", + authorityOwner: "process-memory", + count: 2, + why: "These two non-replay process launch paths instantiate user modules against caller-owned process memory, never kernel memory.", + }, { key: "host/src/kernel-scratch.ts::intrinsicWasmMemoryBuffer::kernel-memory-escape::intrinsicApply( intrinsicMemoryBuffer, memory, [], )", disposition: "scratch-core", @@ -311,144 +1018,357 @@ const auditAllowances: AuditAllowance[] = [ why: "The unforgeable region constructor stores the factory-validated kernel memory in a true private slot so every lease can recheck current bounds.", }, { - key: 'host/src/kernel-scratch.ts::snapshotKernelScratchExports::kernel-pointer-export-bypass::intrinsicObjectGetOwnPropertyDescriptor( value, "length", )', + key: "host/src/kernel-scratch.ts::OwnedKernelScratchRegion.allocate::kernel-memory-escape::intrinsicApply( intrinsicWeakMapSet, ownedKernelScratchRegionOwnerships, [ region, intrinsicObjectFreeze({ memory, pointerWidth, instance: kernelInstance ?? null, }), ], )", disposition: "scratch-core", - why: "The scratch core passes the selected raw Wasm export only to a captured descriptor intrinsic so it can validate exact arity before privately snapshotting the callable.", + why: "The allocator records the exact factory-proven memory, pointer width, and gated instance in a module-private WeakMap so test validation can authenticate the region without exposing its pointer.", }, { - key: "host/src/kernel-scratch.ts::ActiveKernelScratchLease.invokeKernelExport::kernel-pointer-export-bypass::intrinsicApply( kernelExport.call, undefined, convertedArgs, )", + key: "host/src/kernel-scratch.ts::OwnedKernelScratchRegion.allocate::kernel-memory-escape::intrinsicObjectFreeze({ memory, pointerWidth, instance: kernelInstance ?? null, })", disposition: "scratch-core", - why: "This is the sole approved raw invocation after the lease has replaced every pointer argument with a checked owned-range token and matched each adjacent capacity.", + why: "This frozen allocation-ownership record stays reachable only through the module-private WeakMap and is compared against the exact gated generation before any test region is accepted.", }, { - key: "host/src/kernel.ts::WasmPosixKernel.buildImportObject::kernel-memory-return::memory", - disposition: "kernel-control", - why: "The env.memory import is the engine-required kernel memory and is consumed only by the two reviewed instantiation sites.", + key: "host/src/kernel-scratch.ts::OwnedKernelScratchRegion.reserve::kernel-memory-escape::intrinsicApply( intrinsicWeakMapSet, ownedKernelScratchRegionOwnerships, [ region, intrinsicObjectFreeze({ memory, pointerWidth, instance: kernelInstance ?? null, }), ], )", + disposition: "scratch-core", + why: "A single-use reservation records its exact Rust-owned memory and gated generation in the same module-private ownership table before the region is returned.", }, { - key: 'host/src/kernel.ts::WasmPosixKernel.createKernelMemory::kernel-memory-return::return new WebAssembly.Memory({ initial: 24n, maximum: 16384n, shared: true, address: "i64", } as unknown as WebAssembly.MemoryDescriptor);', - disposition: "kernel-control", - why: "This factory branch creates the dedicated memory64 kernel linear memory before instantiation.", + key: "host/src/kernel-scratch.ts::OwnedKernelScratchRegion.reserve::kernel-memory-escape::intrinsicObjectFreeze({ memory, pointerWidth, instance: kernelInstance ?? null, })", + disposition: "scratch-core", + why: "This frozen reservation-ownership record is private metadata, not a caller-visible Memory escape; revocation and validation remain bound to the matching token generation.", }, { - key: "host/src/kernel.ts::WasmPosixKernel.createKernelMemory::kernel-memory-return::return new WebAssembly.Memory({ // 24 pages = 1.5 MiB of initial address space. This must remain above // the kernel Wasm's linker-derived minimum and leaves headroom for // future static data without re-tuning host construction each time. initial: 24, maximum: 16384, shared: true, });", - disposition: "kernel-control", - why: "This factory branch creates the dedicated memory32 kernel linear memory before instantiation.", + key: "host/src/kernel-scratch.ts::OwnedKernelScratchRegion.allocate::scratch-allocator-call::allocator(capacity)", + disposition: "scratch-core", + why: "This is the sole allocator invocation; the returned pointer remains private and is validated with its requested capacity.", }, { - key: "host/src/kernel.ts::WasmPosixKernel.getMemory::kernel-memory-return::return this.memory;", + key: "host/src/host-adapter-manifest.ts::bufferByteLength::kernel-buffer-escape::intrinsicApply( intrinsicSharedArrayBufferByteLength, buffer, [], )", disposition: "kernel-read", - why: "This documented unsafe trusted-embedder API intentionally exposes kernel memory for tests and low-level diagnostics.", + why: "The captured SharedArrayBuffer byteLength getter only authenticates and measures the current kernel buffer before the fixed manifest range check; it cannot retain or mutate the buffer.", }, { - key: "host/src/kernel.ts::WasmPosixKernel.init::kernel-memory-escape::WebAssembly.instantiate(module, importObject)", + key: "host/src/host-adapter-manifest.ts::readKernelHostAdapterManifest::kernel-view::new IntrinsicDataView( buffer, pointer, HOST_ADAPTER_MANIFEST_SIZE, )", + disposition: "kernel-read", + why: "The fixed-size manifest view is created only after a lossless pointer conversion and complete current-buffer range proof, read synchronously, and never returned.", + }, + { + key: "host/src/kernel-worker.ts::CentralizedKernelWorker.init::scratch-address-contract::runtimeAccess.instance()", disposition: "kernel-control", - why: "The engine receives the dedicated memory only as the kernel module's reviewed env.memory import.", + why: "The dedicated worker retrieves the gated façade through the package-private authority; raw callable exports never leave kernel.ts.", }, { - key: "host/src/kernel.ts::WasmPosixKernel.initWithMemory::kernel-memory-escape::WebAssembly.instantiate(module, importObject)", + key: "host/src/kernel-worker.ts::CentralizedKernelWorker.#createTestAuthority::scratch-address-contract::options.instance", disposition: "kernel-control", - why: "The thread-worker path passes its explicitly supplied shared kernel memory only to kernel instantiation.", + why: "The module-secret test initializer assigns this instance only after both the allocator-private region ownership check and the entry-gate ownership check prove the same exact gated generation.", }, { - key: "host/src/host-adapter-manifest.ts::readKernelHostAdapterManifest::kernel-view::new DataView( memory.buffer, pointer, HOST_ADAPTER_MANIFEST_SIZE, )", - disposition: "kernel-read", - why: "The fixed-size adapter manifest is read synchronously after its complete kernel-memory range is checked.", + key: 'host/src/kernel-worker.ts::CentralizedKernelWorker.init::scratch-region-factory-call::allocateKernelScratchRegion( this.#kernelMemory!, allocScratch, SCRATCH_SIZE, this.#kernelPointerWidth, "kernel syscall scratch", // WHY: the allocator call is scoped to this initialization entry, // but the resulting region survives it. Bind ownership to the // persistent gated generation, never the revocable scoped façade. this.#kernelInstance!, instance, )', + disposition: "scratch-core", + why: "The main channel region invokes allocation through the revocable init scope but binds the returned pointer and explicit channel capacity to the same generation's persistent private memory and gate owner.", }, { - key: "host/src/kernel-scratch.ts::OwnedKernelScratchRegion.allocate::scratch-allocator-call::allocator(capacity)", + key: 'host/src/kernel-worker.ts::CentralizedKernelWorker.init::scratch-region-factory-call::allocateKernelScratchRegion( this.#kernelMemory!, allocScratch, 65536, this.#kernelPointerWidth, "kernel TCP scratch", this.#kernelInstance!, instance, )', disposition: "scratch-core", - why: "This is the sole allocator invocation; the returned pointer remains private and is validated with its requested capacity.", + why: "The TCP region invokes allocation through the revocable init scope but binds its reviewed fixed capacity to the same generation's persistent private memory and gate owner before network callbacks can use it.", }, { - key: 'host/src/kernel-worker.ts::CentralizedKernelWorker.init::scratch-region-factory-call::allocateKernelScratchRegion( this.kernelMemory, allocScratch, SCRATCH_SIZE, this.kernel.getKernelPtrWidth(), "kernel syscall scratch", this.kernelInstance, )', + key: 'host/src/kernel-worker.ts::CentralizedKernelWorker.#beginLargeSpawnScratch::scratch-region-factory-call::reserveKernelScratchRegion( this.#kernelMemory!, () => ({ pointer: pointer(rawToken), capacity: capacity(rawToken), }), blobLen, this.#kernelPointerWidth, "kernel reserved spawn scratch", // Bind allocator provenance to the same persistent gated generation as // every I/O reservation. A scoped entry façade cannot outlive this call. this.#kernelInstance!, )', disposition: "scratch-core", - why: "The main channel region binds its memory, allocator, and reviewed fixed capacity to the exact instantiated kernel module.", + why: "The spawn region binds one live Rust token's pointer and actual capacity to the persistent gated generation and is single-use for the matching synchronous commit or cancellation.", }, { - key: 'host/src/kernel-worker.ts::CentralizedKernelWorker.init::scratch-region-factory-call::allocateKernelScratchRegion( this.kernelMemory, allocScratch, 65536, this.kernel.getKernelPtrWidth(), "kernel TCP scratch", this.kernelInstance, )', + key: 'host/src/kernel-worker.ts::CentralizedKernelWorker.#beginLargeTransferScratch::scratch-region-factory-call::reserveKernelScratchRegion( this.#kernelMemory!, () => ({ pointer: pointer(rawToken), capacity: capacity(rawToken), }), minimumCapacity, this.#kernelPointerWidth, "kernel reserved I/O transfer scratch", // The region factory binds allocator ownership to the persistent // gated façade. `entry` proves this reservation belongs to that exact // generation; scoped façades are deliberately non-transferable. this.#kernelInstance!, )', disposition: "scratch-core", - why: "The TCP region binds its memory, allocator, and reviewed fixed capacity to the exact instantiated kernel module.", + why: "The large-I/O region binds a live Rust token's pointer and capacity to the persistent gated façade; the lexical entry proves that exact generation while the single lease is active.", }, { - key: 'host/src/kernel-worker.ts::CentralizedKernelWorker.beginLargeSpawnScratch::scratch-region-factory-call::reserveKernelScratchRegion( this.kernelMemory!, () => ({ pointer: pointer(rawToken), capacity: capacity(rawToken), }), blobLen, this.kernel.getKernelPtrWidth(), "kernel reserved spawn scratch", )', + key: "host/src/kernel-worker.ts::CentralizedKernelWorker.#beginLargeSpawnScratch::scratch-reservation-call::begin(this.toKernelPtr(blobLen))", disposition: "scratch-core", - why: "The spawn region binds the pointer and capacity returned by one active Rust-owned transactional reservation.", + count: 1, + why: "This begins one exclusive Rust-owned spawn reservation after the complete blob length has been validated and losslessly converted to the kernel pointer width.", }, { - key: 'host/src/kernel.ts::WasmPosixKernel.requireApiScratch::scratch-region-factory-call::allocateKernelScratchRegion( this.memory, allocator, WasmPosixKernel.API_SCRATCH_SIZE, this.kernelPtrWidth, "kernel public API scratch", this.instance!, )', + key: "host/src/kernel-worker.ts::CentralizedKernelWorker.#beginLargeSpawnScratch::scratch-reservation-call::capacity(rawToken)", disposition: "scratch-core", - why: "The public API region binds its memory, allocator, and reviewed fixed capacity to this exact kernel instance.", + count: 1, + why: "The capacity query is consumed only while the matching exclusive spawn token is live and is independently checked against the requested complete blob size.", }, { - key: 'host/src/kernel.ts::WasmPosixKernel.ensureAudioScratch::scratch-region-factory-call::allocateKernelScratchRegion( this.memory, alloc, WasmPosixKernel.AUDIO_SCRATCH_SIZE, this.kernelPtrWidth, "kernel audio scratch", this.instance!, )', + key: "host/src/kernel-worker.ts::CentralizedKernelWorker.#beginLargeSpawnScratch::scratch-reservation-call::pointer(rawToken)", disposition: "scratch-core", - why: "The audio region binds its memory, allocator, and reviewed fixed capacity to this exact kernel instance.", + count: 1, + why: "The pointer query is consumed only by the capacity-bearing single-use region factory while the exact matching spawn token remains live.", }, { - key: "host/src/kernel-worker.ts::CentralizedKernelWorker.beginLargeSpawnScratch::spawn-reservation-call::begin(this.toKernelPtr(blobLen))", + key: "host/src/kernel-worker.ts::CentralizedKernelWorker.#cancelLargeSpawnScratch::scratch-reservation-call::cancel(token)", disposition: "scratch-core", - why: "This begins one transactional Rust-owned reservation before any pointer or capacity is observed.", + count: 1, + why: "This cleanup consumes an uncommitted spawn token under the same entry transaction so no later operation can reuse its region while host bytes remain live.", }, { - key: "host/src/kernel-worker.ts::CentralizedKernelWorker.beginLargeSpawnScratch::spawn-reservation-call::capacity(rawToken)", + key: "host/src/kernel-worker.ts::CentralizedKernelWorker.#beginLargeTransferScratch::scratch-reservation-call::begin(this.toKernelPtr(minimumCapacity))", disposition: "scratch-core", - why: "The capacity accessor is consumed only by reserveKernelScratchRegion while the matching transaction is active.", + count: 1, + why: "This begins one exclusive Rust-owned I/O reservation only after the complete transfer footprint has been checked and losslessly converted to the kernel pointer width.", }, { - key: "host/src/kernel-worker.ts::CentralizedKernelWorker.beginLargeSpawnScratch::spawn-reservation-call::pointer(rawToken)", + key: "host/src/kernel-worker.ts::CentralizedKernelWorker.#beginLargeTransferScratch::scratch-reservation-call::capacity(rawToken)", disposition: "scratch-core", - why: "The pointer accessor is consumed only by reserveKernelScratchRegion while the matching transaction is active.", + count: 1, + why: "The capacity query is consumed only while the exact I/O token is live and the returned allocation capacity is rechecked against the requested complete footprint.", }, { - key: "host/src/kernel-worker.ts::CentralizedKernelWorker.cancelLargeSpawnScratch::spawn-reservation-call::cancel(token)", + key: "host/src/kernel-worker.ts::CentralizedKernelWorker.#beginLargeTransferScratch::scratch-reservation-call::pointer(rawToken)", disposition: "scratch-core", - why: "This exact cleanup path releases a reservation that was begun but not consumed by the Rust spawn entry point.", + count: 1, + why: "The pointer query feeds only the capacity-bearing single-use region factory while the matching Rust-owned I/O token remains live.", }, { - key: "host/src/kernel.ts::WasmPosixKernel.getMemoryBuffer::kernel-view-return::return new Uint8Array(this.memory.buffer);", - disposition: "kernel-read", - why: "This private full-memory view is tracked through every caller; only exact checked read and Rust-lent write sinks are admitted below.", + key: "host/src/kernel-worker.ts::CentralizedKernelWorker.#cancelLargeTransferScratch::scratch-reservation-call::cancel(token)", + disposition: "scratch-core", + count: 1, + why: "This settlement consumes exactly one unfinished I/O token after its lease is revoked, preventing later operations from reusing bytes under stale host authority.", + }, + { + key: "host/src/kernel-worker.ts::CentralizedKernelWorker.init::kernel-pointer-export-bypass::abiVersionFn()", + disposition: "kernel-control", + why: "This exact generated-name export takes no arguments and returns only the kernel ABI version scalar.", }, { - key: "host/src/kernel.ts::WasmPosixKernel.getMemoryBuffer::kernel-view::new Uint8Array(this.memory.buffer)", + key: "host/src/kernel-worker.ts::CentralizedKernelWorker.#createTestAuthority::kernel-pointer-export-bypass::drain( this.#kernelPointerWidth === 8 ? 0n : 0, capacity, )", + disposition: "kernel-control", + why: "An allocator-owned KernelScratchExportPointer cannot represent null. This secret-capability fixed mqueue companion is the only intentional direct null call to this export and verifies Rust rejects the missing destination before consuming notification state; guarded destinations still require opaque lease tokens.", + }, + { + key: "host/src/kernel-worker.ts::CentralizedKernelWorker.#createTestAuthority::kernel-pointer-export-bypass::waitPoll( channel.pid, callerTid, childPid, WAIT_EVENT_EXITED, 0, this.#kernelPointerWidth === 8 ? 0n : 0, capacity, )", + disposition: "kernel-control", + why: "An allocator-owned KernelScratchExportPointer cannot represent null. This secret-capability fixed wait companion is the only intentional direct null call to this export and proves Rust rejects the destination before selecting or consuming child status; guarded destinations still require opaque lease tokens.", + }, + { + key: "host/src/kernel-worker.ts::CentralizedKernelWorker.handleIpcControl::kernel-pointer-export-bypass::structureBytes(pointerWidth)", + disposition: "kernel-control", + why: "This exact two-name IPC metadata branch passes only pointer width and returns a structure-size scalar.", + }, + { + key: "host/src/kernel.ts::bufferByteLength::kernel-buffer-escape::intrinsicApply( intrinsicSharedArrayBufferByteLength, buffer, [], )", disposition: "kernel-read", - why: "This private constructor feeds only the separately inventoried synchronous read and checked Rust-lent write helpers.", + why: "The captured byteLength getter authenticates and measures the current kernel buffer for page-count and range checks without retaining or mutating it.", }, { - key: "host/src/kernel.ts::WasmPosixKernel.hostFutexWait::kernel-view::new Int32Array(this.memory.buffer)", + key: "host/src/kernel.ts::WasmPosixKernel.#buildImportObject::kernel-memory-return::memory", disposition: "kernel-control", - why: "The futex word's lossless pointer, four-byte range, and alignment are checked before constructing this current-memory atomic view.", + why: "The dedicated memory is returned only as env.memory inside the private kernel import object consumed by the reviewed instantiation path or module-secret test companion.", }, { - key: "host/src/kernel.ts::WasmPosixKernel.hostFutexWake::kernel-view::new Int32Array(this.memory.buffer)", + key: 'host/src/kernel.ts::WasmPosixKernel.#createKernelMemory::kernel-memory-return::return new IntrinsicWasmMemory({ initial: 24n, maximum: 16384n, shared: true, address: "i64", } as unknown as WebAssembly.MemoryDescriptor);', disposition: "kernel-control", - why: "The futex word's lossless pointer, four-byte range, and alignment are checked before constructing this current-memory atomic view.", + why: "This true-private factory branch creates the dedicated memory64 kernel linear memory before it can be published to an instance or worker.", }, { - key: "host/src/kernel.ts::WasmPosixKernel.writeKernelBytes::kernel-write::this.getMemoryBuffer().set(exactBytes, range.pointer)", - disposition: "rust-lent", - why: "writeKernelBytes proves pointer, explicit capacity, current-memory bounds, and producer length before this write.", + key: "host/src/kernel.ts::WasmPosixKernel.#createKernelMemory::kernel-memory-return::return new IntrinsicWasmMemory({ // 24 pages = 1.5 MiB of initial address space. This must remain above // the kernel Wasm's linker-derived minimum and leaves headroom for // future static data without re-tuning host construction each time. initial: 24, maximum: 16384, shared: true, });", + disposition: "kernel-control", + why: "This true-private factory branch creates the dedicated memory32 kernel linear memory before it can be published to an instance or worker.", + }, + { + key: "host/src/kernel.ts::WasmPosixKernel.#createTestAuthority::kernel-memory-return::this.#buildImportObject(memory)", + disposition: "kernel-control", + why: "The module-secret test companion exposes this import builder only as a frozen named method; supported package entry points expose neither the companion nor a kernel Memory.", + }, + { + key: "host/src/kernel.ts::WasmPosixKernel.#createTestAuthority.defineMethod::kernel-memory-escape::intrinsicObjectDefineProperty(authority, name, { configurable: false, enumerable: true, writable: false, value, })", + disposition: "kernel-control", + why: "This module-secret test-only boundary installs exact frozen method closures rather than a target-bearing proxy; authority and raw memory remain absent from every supported package export.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#requireApiScratch::scratch-region-factory-call::allocateKernelScratchRegion( this.#memory, allocator, WasmPosixKernel.API_SCRATCH_SIZE, this.#kernelPtrWidth, "kernel public API scratch", this.#instance!, )', + disposition: "scratch-core", + why: "The public-wrapper scratch region binds the private Memory, exact gated instance, pointer width, and explicit 65,536-byte allocation before any temporary API transfer.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#ensureAudioScratch::scratch-region-factory-call::allocateKernelScratchRegion( this.#memory, alloc, WasmPosixKernel.AUDIO_SCRATCH_SIZE, this.#kernelPtrWidth, "kernel audio scratch", this.#instance!, )', + disposition: "scratch-core", + why: "The lazily created audio region binds the same private generation and explicit capacity and is used only through synchronous checked leases.", }, { - key: "host/src/host-adapter-manifest.ts::readKernelHostAdapterManifest::kernel-pointer-export-bypass::ptrFn()", + key: "host/src/kernel.ts::WasmPosixKernel.#getMemoryBuffer::kernel-view-return::return new IntrinsicUint8Array(wasmMemoryBuffer(this.#memory));", disposition: "kernel-read", - why: "This exact dynamically selected manifest export returns a scalar offset and accepts no pointer argument.", + why: "This true-private current-buffer view is returned only to the separately audited checked read and Rust-lent write helpers and never crosses a callback or promise.", }, { - key: "host/src/host-adapter-manifest.ts::readKernelHostAdapterManifest::kernel-pointer-export-bypass::lenFn()", + key: "host/src/kernel.ts::WasmPosixKernel.#getMemoryBuffer::kernel-view::new IntrinsicUint8Array(wasmMemoryBuffer(this.#memory))", disposition: "kernel-read", - why: "This exact dynamically selected manifest export returns a scalar length and accepts no pointer argument.", + why: "The private constructor reacquires the current backing buffer for one synchronous checked helper; no cached view survives memory growth.", }, { - key: "host/src/kernel-worker.ts::CentralizedKernelWorker.init::kernel-pointer-export-bypass::abiVersionFn()", + key: "host/src/kernel.ts::WasmPosixKernel.#rustLentKernelDestination::kernel-memory-escape::intrinsicApply( intrinsicWeakMapSet, rustLentKernelDestinationRecords, [ destination, { owner: this, generation: this.#memoryGeneration, memory: this.#memory, pointer: range.pointer, capacity: range.length, label, consumed: false, }, ], )", + disposition: "rust-lent", + why: "This module-private WeakMap authenticates one normalized pointer and explicit capacity to the exact kernel Memory generation; the public frozen token exposes no pointer or Memory and is single-use.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#createTestAuthority::kernel-destination-factory-call::this.#rustLentKernelDestination( pointer, capacity, "test kernel destination", )', + disposition: "rust-lent", + why: "The module-secret test companion passes its exact pointer and capacity formals directly into the authenticated destination factory.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#createTestAuthority::kernel-destination-factory-call::this.#rustLentKernelDestination( statPointer, WASM_STAT_SIZE, "test host_fstat destination", )', + disposition: "rust-lent", + why: "The module-secret fstat companion binds its exact pointer formal to the generated fixed stat capacity before backend work.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#createTestAuthority::kernel-destination-factory-call::this.#rustLentKernelDestination( direntPointer, WASM_DIRENT_SIZE, "test host_readdir dirent destination", )', + disposition: "rust-lent", + why: "The module-secret readdir companion binds its exact dirent pointer formal to the generated fixed record capacity before backend work.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#createTestAuthority::kernel-destination-factory-call::this.#rustLentKernelDestination( namePointer, nameLength, "test host_readdir name destination", )', + disposition: "rust-lent", + why: "The module-secret readdir companion binds its exact name pointer and capacity formals before backend work.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#buildImportObject::kernel-destination-factory-call::this.#rustLentKernelDestination( bufPtr, bufLen, "host_read destination", )', + disposition: "rust-lent", + why: "The host_read Wasm import binds the untouched Rust pointer and capacity formals before invoking any producer.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#buildImportObject::kernel-destination-factory-call::this.#rustLentKernelDestination( bufPtr, bufLen, "host_pread destination", )', + disposition: "rust-lent", + why: "The host_pread Wasm import binds the untouched Rust pointer and capacity formals before invoking any positioned producer.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#buildImportObject::kernel-destination-factory-call::this.#rustLentKernelDestination( statPtr, WASM_STAT_SIZE, "host_fstat destination", )', + disposition: "rust-lent", + why: "The host_fstat import binds its exact pointer formal to the generated fixed stat capacity before the backend consumes the handle.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#buildImportObject::kernel-destination-factory-call::this.#rustLentKernelDestination( statPtr, WASM_STAT_SIZE, "host_stat destination", )', + disposition: "rust-lent", + why: "The host_stat import binds its exact pointer formal to the generated fixed stat capacity before path/backend work.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#buildImportObject::kernel-destination-factory-call::this.#rustLentKernelDestination( statPtr, WASM_STAT_SIZE, "host_lstat destination", )', + disposition: "rust-lent", + why: "The host_lstat import binds its exact pointer formal to the generated fixed stat capacity before path/backend work.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#buildImportObject::kernel-destination-factory-call::this.#rustLentKernelDestination( statfsPtr, WASM_STATFS_SIZE, "host_statfs destination", )', + disposition: "rust-lent", + why: "The host_statfs import binds its exact pointer formal to the generated fixed filesystem-stat capacity before backend work.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#buildImportObject::kernel-destination-factory-call::this.#rustLentKernelDestination( valuePtr, 8, "host_pathconf destination", )', + disposition: "rust-lent", + why: "The host_pathconf import binds its exact pointer formal to the fixed eight-byte result capacity before backend work.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#buildImportObject::kernel-destination-factory-call::this.#rustLentKernelDestination( valuePtr, 8, "host_fpathconf destination", )', + disposition: "rust-lent", + why: "The host_fpathconf import binds its exact pointer formal to the fixed eight-byte result capacity before backend work.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#buildImportObject::kernel-destination-factory-call::this.#rustLentKernelDestination( bufPtr, bufLen, "host_readlink destination", )', + disposition: "rust-lent", + why: "The host_readlink import binds the untouched Rust pointer and capacity formals before resolving the link.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#buildImportObject::kernel-destination-factory-call::this.#rustLentKernelDestination( direntPtr, WASM_DIRENT_SIZE, "host_readdir dirent destination", )', + disposition: "rust-lent", + why: "The host_readdir import authenticates its dirent pointer against the generated fixed record capacity before advancing the iterator.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#buildImportObject::kernel-destination-factory-call::this.#rustLentKernelDestination( namePtr, nameLen, "host_readdir name destination", )', + disposition: "rust-lent", + why: "The host_readdir import authenticates its untouched name pointer and capacity formals before advancing the iterator.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#buildImportObject::kernel-destination-factory-call::this.#rustLentKernelDestination( secPtr, 8, "host_clock_gettime seconds destination", )', + disposition: "rust-lent", + why: "The clock import binds the seconds pointer formal to its fixed eight-byte result before consulting the clock backend.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#buildImportObject::kernel-destination-factory-call::this.#rustLentKernelDestination( nsecPtr, 8, "host_clock_gettime nanoseconds destination", )', + disposition: "rust-lent", + why: "The clock import binds the nanoseconds pointer formal to its fixed eight-byte result before consulting the clock backend.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#buildImportObject::kernel-destination-factory-call::this.#rustLentKernelDestination( bufPtr, bufLen, "host_getrandom destination", )', + disposition: "rust-lent", + why: "The random import authenticates the untouched Rust pointer and capacity before the entropy producer runs.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#buildImportObject::kernel-destination-factory-call::this.#rustLentKernelDestination( statusPtr, 4, "host_waitpid status destination", )', + disposition: "rust-lent", + why: "The wait import binds its nonnull status pointer formal to the fixed four-byte result before either backend can consume child state.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#buildImportObject::kernel-destination-factory-call::this.#rustLentKernelDestination( bufPtr, bufLen, "host_net_recv destination", )', + disposition: "rust-lent", + why: "The network receive import authenticates the untouched Rust pointer and capacity before the network producer runs.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#buildImportObject::kernel-destination-factory-call::this.#rustLentKernelDestination( resultPtr, resultLen, "host_getaddrinfo destination", )', + disposition: "rust-lent", + why: "The address lookup import authenticates the untouched result pointer and capacity before the resolver runs.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#buildImportObject::kernel-destination-factory-call::this.#rustLentKernelDestination( outPtr, outLen, "host_gl_query destination", )', + disposition: "rust-lent", + why: "The graphics query import authenticates its untouched output pointer and capacity before touching WebGL state.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#buildImportObject::kernel-destination-factory-call::this.#rustLentKernelDestination( dst_ptr, len, "host_proc_read_bytes destination", )', + disposition: "rust-lent", + why: "The process-copy import authenticates its untouched kernel destination pointer and capacity before resolving or reading process memory.", + }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#buildImportObject::kernel-destination-factory-call::this.#rustLentKernelDestination( out_ptr, STRUCT_SIZE_WPK_DRM_MODE_MODEINFO, "host_kms_mode_info destination", )', + disposition: "rust-lent", + why: "The display-mode import binds its exact pointer formal to the generated fixed structure capacity before inspecting display state.", + }, + { + key: "host/src/kernel.ts::WasmPosixKernel.#hostFutexWait::kernel-view::new IntrinsicInt32Array(wasmMemoryBuffer(this.#memory))", disposition: "kernel-control", - why: "This exact generated-name export takes no arguments and returns only the kernel ABI version scalar.", + why: "The lossless pointer, four-byte current-memory range, and alignment are proved before constructing this one synchronous futex-wait atomic view.", }, { - key: "host/src/kernel-worker.ts::CentralizedKernelWorker.handleIpcControl::kernel-pointer-export-bypass::structureBytes(pointerWidth)", + key: "host/src/kernel.ts::WasmPosixKernel.#hostFutexWake::kernel-view::new IntrinsicInt32Array(wasmMemoryBuffer(this.#memory))", disposition: "kernel-control", - why: "This exact two-name IPC metadata branch passes only pointer width and returns a structure-size scalar.", + why: "The lossless pointer, four-byte current-memory range, and alignment are proved before constructing this one synchronous futex-wake atomic view.", + }, + { + key: "host/src/kernel.ts::WasmPosixKernel.#writeKernelBytes::kernel-write::intrinsicApply( intrinsicUint8ArraySet, this.#getMemoryBuffer(), [exactBytes, range.pointer], )", + disposition: "rust-lent", + why: "The raw sink runs only after lossless pointer conversion, explicit Rust-lent capacity/current-memory proof, and an intrinsic producer-length check; it publishes once synchronously.", + }, + { + key: "host/src/kernel.ts::WasmPosixKernel.constructor::kernel-memory-escape::intrinsicApply( intrinsicWeakMapSet, wasmPosixKernelRuntimeAccess, [ this, { gate: this.#kernelEntryGate, instance: () => this.#instance, memory: () => this.#memory, }, ], )", + disposition: "kernel-control", + why: "The package-private WeakMap grants only the dedicated worker access to this wrapper's exact gate, gated instance, and memory; none is exported from the public package surface.", + }, + { + key: "host/src/kernel.ts::WasmPosixKernel.constructor::kernel-memory-return::() => this.#memory", + disposition: "kernel-control", + why: "This closure is reachable only through the package-private worker WeakMap and returns the current private Memory to that dedicated worker, not to a public caller.", + }, + { + key: "host/src/kernel.ts::WasmPosixKernel.constructor::scratch-address-contract::testRuntime.instance ?? null", + disposition: "kernel-control", + why: "Only the module-secret test constructor can seed a deterministic instance; the resulting frozen test companion exposes no instance, Memory, export namespace, region, or arbitrary target dispatch.", + }, + { + key: "host/src/kernel.ts::WasmPosixKernel.init::kernel-memory-escape::intrinsicApply( intrinsicWasmInstantiate, WebAssembly, [module, importObject], )", + disposition: "kernel-control", + why: "The captured engine intrinsic receives the dedicated memory only through the private env.memory import object and returns one raw instance that is wrapped before publication.", + }, + { + key: "host/src/kernel.ts::WasmPosixKernel.init::kernel-memory-escape::this.#testEngine.instantiate(module, importObject)", + disposition: "kernel-control", + why: "The module-secret deterministic test engine receives the same private import object; this branch is unreachable from a production wrapper and its result is still gated before assignment.", + }, + { + key: "host/src/kernel.ts::WasmPosixKernel.init::scratch-address-contract::createKernelEntryGatedInstance( rawInstance, this.#kernelEntryGate, )", + disposition: "kernel-control", + why: "The raw engine instance is immediately converted to the frozen gate-bound façade before the private instance slot is assigned or any worker can observe it.", }, { key: "host/src/kernel.ts::WasmPosixKernel.ioctl::kernel-pointer-export-bypass::fn( fd, request, this.toKernelPtr(scalarArgument), bufLen, 4, )", @@ -458,18 +1378,221 @@ const auditAllowances: AuditAllowance[] = [ ]; describe("kernel scratch static contract", () => { + it("keeps host pointer roles aligned with Rust export parameters", () => { + for (const exportName of KERNEL_SCRATCH_EXPORT_NAMES) { + expect(() => + assertKernelScratchPointerRoleContract(kernelWasmApiSource, exportName), + ).not.toThrow(); + } + }); + + it("rejects a same-Wasm-signature pointer-pair reorder", () => { + const original = + 'pub extern "C" fn kernel_send(fd: i32, buf_ptr: *const u8, ' + + "buf_len: u32, flags: u32) -> i32"; + const reordered = + 'pub extern "C" fn kernel_send(buf_ptr: *const u8, buf_len: u32, ' + + "fd: i32, flags: u32) -> i32"; + expect(kernelWasmApiSource).toContain(original); + const mutated = kernelWasmApiSource.replace(original, reordered); + expect(() => + assertKernelScratchPointerRoleContract(mutated, "kernel_send"), + ).toThrow(/kernel_send Rust\/host pointer-role drift/); + }); + + it("publishes only gated exports and package-private raw authority", () => { + expect(hostKernelSource).not.toMatch( + /\bgetMemory\s*\(\s*\)\s*:\s*WebAssembly\.Memory/, + ); + expect(hostIndexSource).not.toContain("getWasmPosixKernelRuntimeAccess"); + expect(kernelEntryGateSource).toContain( + 'if (typeof value !== "function") continue;', + ); + expect(kernelEntryGateSource).toContain("createKernelEntryScopedInstance("); + expect(kernelScratchSource).toContain( + "const binding = validatedKernelEntryCallable(instance, name);", + ); + expect(kernelScratchSource).toMatch( + /snapshot\[name\]\s*=\s*intrinsicObjectFreeze\(\{\s*call:\s*binding\.call as KernelScratchExportFunction,\s*argumentCount:\s*binding\.argumentCount,\s*instance,\s*\}\);/, + ); + expect(kernelScratchSource).toMatch( + /const invoke = \(\) => intrinsicApply\(\s*kernelExport\.call,\s*undefined,\s*convertedArgs,\s*\);\s*const result = scope === undefined\s*\? invoke\(\)\s*: invokeKernelEntryScopedOperation\(\s*scope,\s*kernelExport\.instance,\s*invoke,\s*\);/, + ); + expect(hostKernelWorkerSource).not.toContain( + "rejectReentrantKernelDispatch", + ); + expect(hostKernelWorkerSource).toContain( + "this.#runOrDeferChannelKernelEntry(", + ); + expect( + formatKernelEntryContextViolations( + auditKernelEntryContext(hostKernelWorkerSource), + ), + ).toEqual([]); + }); + it("admits only reviewed kernel-memory views, writes, and allocator calls", () => { const result = auditWasmMemoryWrites({ rootDir: repoRoot, sourceFiles: repositoryRuntimeSourceFiles(repoRoot), ownershipSeeds, allowances: auditAllowances, + kernelExportNames: [...abiKernelExportNames], + kernelDestinationFactoryDeclarations: [ + "host/src/kernel.ts::WasmPosixKernel.#rustLentKernelDestination", + ], + auditWasmAuthorityOrigins: true, }); + if (process.env.KANDELO_DEBUG_SCRATCH_AUDIT === "keys") { + for (const finding of result.violations) { + console.error( + `KANDELO_AUDIT_KEY ${finding.file}:${finding.line} ${finding.key}`, + ); + } + } else if (process.env.KANDELO_DEBUG_SCRATCH_AUDIT === "1") { + const violationKinds = Object.fromEntries( + [...new Set(result.violations.map(({ kind }) => kind))] + .sort() + .map((kind) => [ + kind, + result.violations.filter((finding) => finding.kind === kind).length, + ]), + ); + console.error( + `KANDELO_AUDIT_SUMMARY ${JSON.stringify({ + violations: result.violations.length, + violationKinds, + unusedAllowances: result.unusedAllowances.length, + unresolvedSeeds: result.unresolvedSeeds.length, + contractErrors: result.contractErrors.length, + })}`, + ); + const violationGroups = Object.fromEntries( + [...new Set(result.violations.map(({ file }) => file))] + .sort() + .map((file) => [ + file, + Object.fromEntries( + [ + ...new Set( + result.violations + .filter((finding) => finding.file === file) + .map(({ kind }) => kind), + ), + ] + .sort() + .map((kind) => [ + kind, + result.violations.filter( + (finding) => finding.file === file && finding.kind === kind, + ).length, + ]), + ), + ]), + ); + console.error(`KANDELO_AUDIT_GROUPS ${JSON.stringify(violationGroups)}`); + console.error( + `KANDELO_AUDIT_UNUSED ${JSON.stringify( + result.unusedAllowances.map(({ key }) => key), + )}`, + ); + console.error( + JSON.stringify({ + unresolvedSeeds: result.unresolvedSeeds, + violations: result.violations, + unusedAllowances: result.unusedAllowances, + contractErrors: result.contractErrors, + }), + ); + } expect(formatAuditFailures(result)).toEqual([]); // This intentionally builds one TypeScript program for every repository // runtime source; keep CI headroom above the focused local 25–35 second run. }, 60_000); + it("keeps variable-transfer parsing private and allocation-region-bearing", () => { + const prefixOnlyFixture = kernelExportNamesFromSnapshot( + JSON.stringify({ + kernel_exports: [{ name: "kernel_read_proc_maps" }], + }), + ); + const exactFixture = kernelExportNamesFromSnapshot( + JSON.stringify({ + kernel_exports: [{ name: "kernel_read" }], + }), + ); + expect(prefixOnlyFixture.has("kernel_read")).toBe(false); + expect(exactFixture.has("kernel_read")).toBe(true); + expect(() => + kernelExportNamesFromSnapshot( + JSON.stringify({ + kernel_exports: [ + { name: "kernel_setsockopt" }, + { name: "kernel_setsockopt" }, + ], + }), + ), + ).toThrow(/duplicate names/); + expect(() => + kernelExportNamesFromSnapshot( + JSON.stringify({ + kernel_exports: [{ name: 7 }], + }), + ), + ).toThrow(/has no exact name/); + + for (const obsoleteRawExport of [ + "kernel_read", + "kernel_write", + "kernel_pread", + "kernel_pwrite", + "kernel_readv", + "kernel_writev", + "kernel_preadv", + "kernel_pwritev", + "kernel_prepare_write_operation", + ]) { + expect(kernelWasmApiSource).not.toMatch( + new RegExp( + `#\\[unsafe\\(no_mangle\\)\\]\\s*` + + `pub\\s+extern\\s+\"C\"\\s+fn\\s+${obsoleteRawExport}\\b`, + ), + ); + expect(legacySyscallImportsSource).not.toMatch( + new RegExp(`\\b${obsoleteRawExport}\\b`), + ); + expect(legacySyscallGlueSource).not.toMatch( + new RegExp(`\\b${obsoleteRawExport}\\b`), + ); + // Parse the authoritative export list and compare the complete property + // value. Prefix-related live exports such as kernel_read_proc_maps must + // neither trigger a false failure nor suppress an exact obsolete name. + expect(abiKernelExportNames.has(obsoleteRawExport)).toBe(false); + } + + for (const helper of [ + "channel_readv", + "channel_writev", + "channel_preadv", + "channel_pwritev", + ]) { + expect(kernelWasmApiSource).toMatch( + new RegExp( + `fn\\s+${helper}\\s*\\([\\s\\S]*?` + + `region:\\s*ChannelScratchRegion[\\s\\S]*?\\)\\s*->\\s*i32`, + ), + ); + expect(kernelWasmApiSource).toMatch( + new RegExp(`${helper}\\([\\s\\S]*?scratch_region[\\s\\S]*?\\)`), + ); + } + expect(kernelWasmApiSource).toContain( + "checked_kernel_iovec_entries(iov_ptr, iovcnt, region)", + ); + // Total linear-memory size can never stand in for allocation ownership. + expect(kernelWasmApiSource).not.toContain("current_kernel_memory_bytes"); + }); + it("keeps generated platform and spawn contracts wired into musl", () => { expect(platformLimitsHeader).toContain( `#define KANDELO_POSIX_ARG_MAX_BYTES ${POSIX_ARG_MAX_BYTES}u`, @@ -575,14 +1698,25 @@ describe("kernel scratch static contract", () => { `#define WASM_POSIX_SPAWN_WIRE_MAX_BYTES ${SPAWN_WIRE_MAX_BYTES}u`, ); - // WHY: musl compiles sysconf limits before overlay headers are installed, - // so both generated public headers must be staged into its source tree. + // WHY: musl compiles generated contracts before overlay headers are + // installed. The source tree and incremental sysroot installer must both + // mirror the reserved namespace so a renamed header cannot survive. expect(buildMuslSource).toContain( 'cp "$OVERLAY_DIR/include/limits.h" "$MUSL_DIR/include/limits.h"', ); expect(buildMuslSource).toContain( - 'cp "$OVERLAY_DIR/include/bits/kandelo_limits.h" \\\n' + - ' "$MUSL_DIR/include/bits/kandelo_limits.h"', + 'find "$MUSL_DIR/include/bits" -maxdepth 1 \\\n' + + " \\( -type f -o -type l \\) -name 'kandelo_*.h' -delete", + ); + expect(buildMuslSource).toContain( + 'KANDELO_GENERATED_HEADERS=("$OVERLAY_DIR"/include/bits/kandelo_*.h)', + ); + expect(buildMuslSource).toContain( + 'cp "${KANDELO_GENERATED_HEADERS[@]}" "$MUSL_DIR/include/bits/"', + ); + expect(installOverlayHeadersSource).toContain( + 'find "$SYSROOT/include/bits" -maxdepth 1 \\\n' + + " \\( -type f -o -type l \\) -name 'kandelo_*.h' -delete", ); }); diff --git a/host/test/kernel-scratch-region.test.ts b/host/test/kernel-scratch-region.test.ts index 1d8f5f0707..965e56500e 100644 --- a/host/test/kernel-scratch-region.test.ts +++ b/host/test/kernel-scratch-region.test.ts @@ -10,6 +10,11 @@ import { KernelScratchError, reserveKernelScratchRegion, } from "../src/kernel-scratch"; +import { + createKernelEntryGatedInstance, + createKernelEntryScopedInstance, + KernelEntryGate, +} from "../src/kernel-entry-gate"; function memory(pages = 1): WebAssembly.Memory { return new WebAssembly.Memory({ initial: pages, maximum: pages }); @@ -736,7 +741,7 @@ describe("KernelScratchRegion", () => { kernel_send: vi.fn(() => 0), }, } as unknown as WebAssembly.Instance, - )).toThrow(/genuine WebAssembly\.Instance/i); + )).toThrow(/does not own.*WebAssembly\.Memory/i); expect(allocator).not.toHaveBeenCalled(); }); @@ -773,6 +778,99 @@ describe("KernelScratchRegion", () => { expect(wrapper).not.toHaveBeenCalled(); }); + it("binds a scoped allocator call to its persistent gated generation", () => { + const kernelMemory = memory(); + const allocate = vi.fn(() => 4096); + const rawInstance = importedKernelExportInstance( + kernelMemory, + "kernel_send", + ["i32", "i32", "i32", "i32"], + () => 0, + allocate, + ); + const gate = new KernelEntryGate(); + const owner = createKernelEntryGatedInstance(rawInstance, gate); + let region: ReturnType | undefined; + let staleScoped!: WebAssembly.Instance; + let staleAllocator!: (capacity: number) => number; + + gate.runOrDeferVoidIngress("scoped scratch allocation", (scope) => { + const scoped = createKernelEntryScopedInstance(owner, scope); + const allocator = scoped.exports.kernel_alloc_scratch as + (capacity: number) => number; + staleScoped = scoped; + staleAllocator = allocator; + const alternateScoped = createKernelEntryScopedInstance(owner, scope); + expect(() => allocateKernelScratchRegion( + kernelMemory, + allocator, + 32, + 4, + "mismatched scoped callable scratch", + owner, + alternateScoped, + )).toThrow(/not the bound instance.*allocator/i); + expect(allocate).not.toHaveBeenCalled(); + region = allocateKernelScratchRegion( + kernelMemory, + allocator, + 32, + 4, + "scoped test scratch", + owner, + scoped, + ); + }); + + expect(allocate).toHaveBeenCalledOnce(); + expect(() => region!.withLease((lease) => { + lease.invokeKernelExport("kernel_send", [ + 1, + lease.exportPointer(0, 32), + 32, + 0, + ]); + })).not.toThrow(); + + expect(() => allocateKernelScratchRegion( + kernelMemory, + staleAllocator, + 32, + 4, + "revoked scoped scratch", + owner, + staleScoped, + )).toThrow(/scope is no longer active/); + expect(allocate).toHaveBeenCalledOnce(); + + const foreignMemory = memory(); + const foreignRaw = importedKernelExportInstance( + foreignMemory, + "kernel_send", + ["i32", "i32", "i32", "i32"], + () => 0, + ); + const foreignOwner = createKernelEntryGatedInstance( + foreignRaw, + new KernelEntryGate(), + ); + gate.runOrDeferVoidIngress("foreign scoped scratch rejection", (scope) => { + const scoped = createKernelEntryScopedInstance(owner, scope); + const allocator = scoped.exports.kernel_alloc_scratch as + (capacity: number) => number; + expect(() => allocateKernelScratchRegion( + foreignMemory, + allocator, + 32, + 4, + "foreign scoped scratch", + foreignOwner, + scoped, + )).toThrow(/not the bound instance.*allocator/i); + }); + expect(allocate).toHaveBeenCalledOnce(); + }); + it("uses captured genuine Memory and buffer bounds after prototype replacement", () => { const kernelMemory = memory(); const region = allocateKernelScratchRegion( @@ -826,6 +924,63 @@ describe("KernelScratchRegion", () => { .toEqual(new Uint8Array(32).fill(0x6c)); }); + it("keeps captured shared-memory bounds after prototype replacement", () => { + const kernelMemory = new WebAssembly.Memory({ + initial: 1, + maximum: 1, + shared: true, + }); + const region = allocateKernelScratchRegion( + kernelMemory, + () => 65_504, + 32, + 4, + "captured shared-memory scratch", + ); + const memoryBufferDescriptor = Object.getOwnPropertyDescriptor( + WebAssembly.Memory.prototype, + "buffer", + )!; + const byteLengthDescriptor = Object.getOwnPropertyDescriptor( + SharedArrayBuffer.prototype, + "byteLength", + )!; + + try { + Object.defineProperty(WebAssembly.Memory.prototype, "buffer", { + configurable: true, + get: () => new SharedArrayBuffer(1_000_000), + }); + Object.defineProperty(SharedArrayBuffer.prototype, "byteLength", { + configurable: true, + get: () => 1_000_000, + }); + + expect(() => checkedMemoryRange( + kernelMemory, + 65_535, + 2, + 4, + "captured shared-memory range", + )).toThrow(/outside.*range/i); + region.withLease((scratch) => scratch.fill(0x3d, 0, 32)); + } finally { + Object.defineProperty( + WebAssembly.Memory.prototype, + "buffer", + memoryBufferDescriptor, + ); + Object.defineProperty( + SharedArrayBuffer.prototype, + "byteLength", + byteLengthDescriptor, + ); + } + + expect(new Uint8Array(kernelMemory.buffer, 65_504, 32)) + .toEqual(new Uint8Array(32).fill(0x3d)); + }); + it("rejects structural memory objects even when their reported range fits", () => { expect(() => checkedMemoryRange( { buffer: new ArrayBuffer(65_536) } as WebAssembly.Memory, @@ -1594,6 +1749,22 @@ describe("KernelScratchRegion", () => { )).toBe(0x8000_0000); }); + it("rejects a wasm32 minimum above u32 before invoking the reserver", () => { + const reserver = vi.fn(() => ({ + pointer: 4096, + capacity: 32, + })); + + expect(() => reserveKernelScratchRegion( + memory(), + reserver, + 0x1_0000_0000, + 4, + "oversized wasm32 reservation", + )).toThrow(/does not fit a wasm32 usize/); + expect(reserver).not.toHaveBeenCalled(); + }); + it("cannot reuse or revive a reservation-derived region", () => { const kernelMemory = memory(); const region = reserveKernelScratchRegion( @@ -1693,4 +1864,100 @@ describe("checkedMemoryRange", () => { "empty output", )).toEqual({ pointer: 0, length: 0, end: 0 }); }); + + it.each([ + ["ArrayBuffer", false], + ["SharedArrayBuffer", true], + ] as const)( + "reads live %s bounds after WebAssembly memory growth", + (_name, shared) => { + const kernelMemory = new WebAssembly.Memory({ + initial: 1, + maximum: 2, + shared, + }); + + expect(checkedMemoryRange( + kernelMemory, + 65_504, + 32, + 4, + "pre-growth output", + )).toEqual({ pointer: 65_504, length: 32, end: 65_536 }); + expect(() => checkedMemoryRange( + kernelMemory, + 65_536, + 1, + 4, + "pre-growth output", + )).toThrow(/outside.*range/i); + + kernelMemory.grow(1); + + expect(checkedMemoryRange( + kernelMemory, + 131_040, + 32, + 4, + "post-growth output", + )).toEqual({ pointer: 131_040, length: 32, end: 131_072 }); + }, + ); + + it("probes each shared-memory buffer kind once across repeated checks", async () => { + const byteLengthDescriptor = Object.getOwnPropertyDescriptor( + ArrayBuffer.prototype, + "byteLength", + )!; + const byteLengthGetter = byteLengthDescriptor.get!; + let arrayBufferGetterCalls = 0; + + try { + Object.defineProperty(ArrayBuffer.prototype, "byteLength", { + configurable: byteLengthDescriptor.configurable, + enumerable: byteLengthDescriptor.enumerable, + get(this: ArrayBufferLike): number { + arrayBufferGetterCalls++; + return Reflect.apply(byteLengthGetter, this, []) as number; + }, + }); + vi.resetModules(); + const isolated = await import("../src/kernel-scratch"); + const kernelMemory = new WebAssembly.Memory({ + initial: 1, + maximum: 2, + shared: true, + }); + + for (let index = 0; index < 1_024; index++) { + isolated.checkedMemoryRange( + kernelMemory, + 4096, + 32, + 4, + "repeated shared-memory output", + ); + } + expect(arrayBufferGetterCalls).toBe(1); + + kernelMemory.grow(1); + for (let index = 0; index < 1_024; index++) { + isolated.checkedMemoryRange( + kernelMemory, + 65_536, + 32, + 4, + "grown repeated shared-memory output", + ); + } + expect(arrayBufferGetterCalls).toBe(2); + } finally { + Object.defineProperty( + ArrayBuffer.prototype, + "byteLength", + byteLengthDescriptor, + ); + vi.resetModules(); + } + }); }); diff --git a/host/test/kernel-scratch-runtime.test.ts b/host/test/kernel-scratch-runtime.test.ts new file mode 100644 index 0000000000..858e773150 --- /dev/null +++ b/host/test/kernel-scratch-runtime.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + CH_DATA_SIZE, + CH_TOTAL_SIZE, + KERNEL_IOVEC_WIRE_ALIGN, + POSIX_IOV_MAX, + STRUCT_SIZE_KERNEL_IOVEC_WIRE, +} from "../src/generated/abi"; +import { runCentralizedProgram } from "./centralized-test-helper"; +import { ensureWasm64ExampleFixture } from "./wasm64-example-fixture"; +import { NodeKernelHost } from "../src/node-kernel-host"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const programs = [ + [ + "wasm32", + join(repoRoot, "examples/kernel_scratch_browser_test.wasm"), + ], + [ + "wasm64", + join(repoRoot, "examples/kernel_scratch_browser_test.wasm64.wasm"), + ], +] as const; +const boundaryReadvDataBytes = + CH_DATA_SIZE - POSIX_IOV_MAX * STRUCT_SIZE_KERNEL_IOVEC_WIRE; +const boundaryReadvBytesPerIovec = + boundaryReadvDataBytes / POSIX_IOV_MAX; +const largeIovecCount = 2; +const largeBytesPerIovec = Math.floor(CH_DATA_SIZE / 2) + 1; +const largeBytes = largeIovecCount * largeBytesPerIovec; +const ptyByte = 0x51; +const ptyLength = CH_TOTAL_SIZE + 1; + +if ( + !Number.isInteger(boundaryReadvBytesPerIovec) || + boundaryReadvBytesPerIovec <= 0 || + boundaryReadvBytesPerIovec % KERNEL_IOVEC_WIRE_ALIGN !== 0 +) { + throw new Error("generated readv scratch layout cannot form an exact boundary"); +} +if (largeBytes <= CH_DATA_SIZE) { + throw new Error("large vector fixture must exceed ordinary channel scratch"); +} + +async function runPtyFixture(programPath: string): Promise<{ + exitCode: number; + output: string; + stderr: string; + hostDiagnostics: unknown[]; +}> { + const bytes = readFileSync(programPath); + const program = bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer; + const decoder = new TextDecoder(); + const hostDiagnostics: unknown[] = []; + let output = ""; + let stderr = ""; + let sentInput = false; + let host: NodeKernelHost; + host = new NodeKernelHost({ + maxWorkers: 4, + onPtyOutput: (pid, data) => { + output += decoder.decode(data); + if (!sentInput && output.includes("KERNEL_SCRATCH_PTY_READY")) { + sentInput = true; + host.ptyWrite(pid, new Uint8Array(ptyLength).fill(ptyByte)); + } + }, + onStderr: (_pid, data) => { + stderr += decoder.decode(data); + }, + onHostDiagnostic: (diagnostic) => { + hostDiagnostics.push(diagnostic); + }, + }); + + await host.init(); + let timeout: ReturnType | undefined; + try { + const exitCode = await Promise.race([ + host.spawn( + program, + [ + "kernel-scratch-browser-test", + "pty", + String(ptyLength), + String(ptyByte), + ], + { pty: true }, + ), + new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error("Node PTY fixture timed out after 30 seconds")), + 30_000, + ); + }), + ]); + return { exitCode, output, stderr, hostDiagnostics }; + } finally { + if (timeout !== undefined) clearTimeout(timeout); + await host.destroy().catch(() => {}); + } +} + +describe("owned kernel scratch in the real Node runtime", () => { + it.each(programs)( + "preserves vector and PTY operation boundaries for a %s guest", + async (arch, program) => { + const programPath = arch === "wasm64" + ? ensureWasm64ExampleFixture("kernel_scratch_browser_test.c") + : program; + const cases = [ + { + argv: [ + "kernel-scratch-browser-test", + "readv", + String(POSIX_IOV_MAX), + String(boundaryReadvBytesPerIovec), + ], + marker: + `KERNEL_SCRATCH_READV_PASS iovecs=${POSIX_IOV_MAX} bytes=${boundaryReadvDataBytes}`, + useDefaultRootfs: false, + }, + { + argv: [ + "kernel-scratch-browser-test", + "dgram-vector", + String(largeIovecCount), + String(largeBytesPerIovec), + ], + marker: + `KERNEL_SCRATCH_DGRAM_VECTOR_PASS iovecs=${largeIovecCount} bytes=${largeBytes} datagrams=1`, + useDefaultRootfs: false, + }, + { + argv: [ + "kernel-scratch-browser-test", + "positioned-vector", + String(largeIovecCount), + String(largeBytesPerIovec), + ], + marker: + `KERNEL_SCRATCH_POSITIONED_VECTOR_PASS iovecs=${largeIovecCount} bytes=${largeBytes} offset=4096 cursor=37`, + useDefaultRootfs: false, + }, + { + argv: ["kernel-scratch-browser-test", "append-flags"], + marker: "KERNEL_SCRATCH_APPEND_FLAGS_PASS bytes=5", + // Exact native append outcomes require the uniquely owned session + // scratch mount; raw NodePlatformIO is externally mutable. + useDefaultRootfs: true, + }, + { + argv: ["kernel-scratch-browser-test", "zero-iov"], + marker: + `KERNEL_SCRATCH_ZERO_IOV_PASS pointer_bits=${arch === "wasm64" ? 64 : 32}`, + useDefaultRootfs: false, + }, + ]; + + for (const fixture of cases) { + const result = await runCentralizedProgram({ + programPath, + argv: fixture.argv, + timeout: 30_000, + useDefaultRootfs: fixture.useDefaultRootfs, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain(fixture.marker); + expect(result.stderr).toBe(""); + expect(result.hostDiagnostics).toEqual([]); + } + + const pty = await runPtyFixture(programPath); + expect(pty.exitCode, pty.stderr).toBe(0); + expect(pty.output).toContain("KERNEL_SCRATCH_PTY_READY"); + expect(pty.output).toContain( + `KERNEL_SCRATCH_PTY_PASS bytes=${ptyLength}`, + ); + expect(pty.stderr).toBe(""); + expect(pty.hostDiagnostics).toEqual([]); + }, + 120_000, + ); +}); diff --git a/host/test/kernel-scratch-transfer-boundaries.test.ts b/host/test/kernel-scratch-transfer-boundaries.test.ts index 8841d86cb2..a1f8f63906 100644 --- a/host/test/kernel-scratch-transfer-boundaries.test.ts +++ b/host/test/kernel-scratch-transfer-boundaries.test.ts @@ -1,8 +1,18 @@ import { describe, expect, it, vi } from "vitest"; -import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { + CentralizedKernelWorker, + createCentralizedKernelWorkerTestDouble, +} from "../src/kernel-worker"; +import { + createKernelEntryGatedInstance, + KernelEntryGate, +} from "../src/kernel-entry-gate"; import { allocateKernelScratchRegion } from "../src/kernel-scratch"; -import { WasmPosixKernel } from "../src/kernel"; +import { + createWasmPosixKernelTestHarness, + WasmPosixKernel, +} from "../src/kernel"; import { createKernelScratchTestInstance } from "./support/kernel-scratch-instance"; import { ABI_SYSCALLS, @@ -33,7 +43,9 @@ import { KERNEL_MSGHDR_WIRE_IOVLEN_OFFSET, KERNEL_MSGHDR_WIRE_NAME_OFFSET, KERNEL_MSGHDR_WIRE_NAMELEN_OFFSET, + KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, POSIX_IOV_MAX, + POSIX_NGROUPS_MAX, POSIX_PATH_MAX_BYTES, PROCESS_CMSGHDR_WASM32_ALIGN, PROCESS_CMSGHDR_WASM32_DATA_OFFSET, @@ -76,9 +88,11 @@ import { const EFAULT = 14; const EIO = 5; +const ENOMEM = 12; const EINVAL = 22; const EOVERFLOW = 75; const EAGAIN = 11; +const EMSGSIZE = 90; const IPC_NOWAIT = 0x800; const IOV_MAX = POSIX_IOV_MAX; const MSG_CTRUNC = 0x08; @@ -87,6 +101,7 @@ const SCALAR_IOCTL_REQUESTS = Object.entries(IOCTL_REQUESTS) .filter(([, contract]) => contract.argKind === "scalar-i32") .map(([request]) => Number(request)); const SIOCGIFNAME = 0x8910; +const SIOCGIFCONF = 0x8912; const SIOCGIFADDR = 0x8915; const SIOCGIFHWADDR = 0x8927; const SIOCGIFINDEX = 0x8933; @@ -165,9 +180,22 @@ interface ScratchHarness { channel: TestChannel; kernelBytes: Uint8Array; processBytes: Uint8Array; + kernelExports: Record; + scratchTestInstance: WebAssembly.Instance; + kernelMemory: WebAssembly.Memory; + scratchRegion: ReturnType; + allocateScratchRegionAt: ( + pointer: number, + capacity: number, + label: string, + ) => ReturnType; scratchOffset: number; scratchEnd: number; + transferOffset: number; handleChannel: ReturnType; + blockingRetryToken: ReturnType; + blockingRetryRelease: ReturnType; + handleBlockingRetry: ReturnType; completeChannel: ReturnType; completeChannelRaw: ReturnType; } @@ -193,93 +221,222 @@ function hostileBytes(length: number, reportedLength: number): Uint8Array { return bytes; } -function makeScratchHarness(ptrWidth: 4 | 8 = 4): ScratchHarness { +function makeScratchHarness( + ptrWidth: 4 | 8 = 4, + excludedExports: readonly string[] = [], +): ScratchHarness { const pid = 41; const scratchOffset = 4096; const scratchEnd = scratchOffset + CH_TOTAL_SIZE; const kernelMemory = new WebAssembly.Memory({ initial: 4, maximum: 4 }); - const processMemory = sharedMemory(4); + // Keep the mailbox disjoint from caller address zero and ordinary test + // buffers. The production layout likewise reserves channel storage outside + // application pointer ranges; overlapping them here would make a genuine + // dispatch overwrite the very caller bytes a boundary test is inspecting. + const processMemory = sharedMemory(8); + const channelOffset = 6 * 65_536; const kernelBytes = new Uint8Array(kernelMemory.buffer); const processBytes = new Uint8Array(processMemory.buffer); let worker!: CentralizedKernelWorker & Record; let kernelExports!: Record; - const scratchTestInstance = createKernelScratchTestInstance( + const transferOffset = 2 * 65_536; + let transferCapacity = 0; + let nextTransferToken = 1n; + let nextScratchAllocationPointer = scratchOffset; + const gate = new KernelEntryGate(); + const rawScratchTestInstance = createKernelScratchTestInstance( ptrWidth, kernelMemory, - () => worker?.kernelInstance?.exports ?? kernelExports, - () => ptrWidth === 8 ? BigInt(scratchOffset) : scratchOffset, + () => kernelExports, + () => + ptrWidth === 8 + ? BigInt(nextScratchAllocationPointer) + : nextScratchAllocationPointer, + 4, + undefined, + excludedExports, ); - const scratchRegion = allocateKernelScratchRegion( - kernelMemory, - scratchTestInstance.exports.kernel_alloc_scratch as (size: number) => number, + const scratchTestInstance = createKernelEntryGatedInstance( + rawScratchTestInstance, + gate, + ); + const allocateScratchRegionAt = ( + pointer: number, + capacity: number, + label: string, + ): ReturnType => { + nextScratchAllocationPointer = pointer; + try { + return allocateKernelScratchRegion( + kernelMemory, + scratchTestInstance.exports.kernel_alloc_scratch as ( + size: number, + ) => number | bigint, + capacity, + ptrWidth, + label, + scratchTestInstance, + ); + } finally { + nextScratchAllocationPointer = scratchOffset; + } + }; + const scratchRegion = allocateScratchRegionAt( + scratchOffset, CH_TOTAL_SIZE, - ptrWidth, "test kernel syscall scratch", - scratchTestInstance, ); const channel: TestChannel = { pid, memory: processMemory, - channelOffset: 0, - i32View: new Int32Array(processMemory.buffer), + channelOffset, + i32View: new Int32Array( + processMemory.buffer, + channelOffset, + CH_TOTAL_SIZE / Int32Array.BYTES_PER_ELEMENT, + ), consecutiveSyscalls: 0, handling: true, }; const completeChannelRaw = vi.fn(); const completeChannel = vi.fn(); - const handleChannel = vi.fn(() => { - const view = new DataView(kernelMemory.buffer, scratchOffset); - const iovPtr = Number(view.getBigInt64(CH_ARGS + CH_ARG_SIZE, true)); - const iovLen = iovPtr === 0 - ? 0 - : new DataView(kernelMemory.buffer).getUint32(iovPtr + 4, true); - view.setBigInt64(CH_RETURN, BigInt(iovLen), true); - view.setUint32(CH_ERRNO, 0, true); - return 0; - }); + const handleBlockingRetry = vi.fn(); + const blockingRetryToken = vi.fn( + (_pid: number, _tid: number, _syscallNr: number) => 1n, + ); + const blockingRetryRelease = vi.fn( + (_pid: number, _tid: number, _token: bigint) => 0, + ); + const handleChannel = vi.fn( + ( + pointer: number | bigint = scratchOffset, + _capacity: number = CH_TOTAL_SIZE, + _pid: number = pid, + _retryToken: bigint = 0n, + ) => { + const view = new DataView(kernelMemory.buffer, Number(pointer)); + const syscall = view.getUint32(CH_SYSCALL, true); + const iovPtr = Number(view.getBigInt64(CH_ARGS + CH_ARG_SIZE, true)); + const transferred = + syscall === ABI_SYSCALLS.Read || + syscall === ABI_SYSCALLS.Write || + syscall === ABI_SYSCALLS.Pread || + syscall === ABI_SYSCALLS.Pwrite + ? Number(view.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true)) + : iovPtr === 0 + ? 0 + : new DataView(kernelMemory.buffer).getUint32(iovPtr + 4, true); + view.setBigInt64(CH_RETURN, BigInt(transferred), true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }, + ); kernelExports = { kernel_handle_channel: handleChannel, - kernel_prepare_write_operation: ( + kernel_blocking_retry_token: blockingRetryToken, + kernel_blocking_retry_release: blockingRetryRelease, + kernel_dequeue_signal: () => 0, + kernel_get_socket_timeout_ms: () => -1n, + kernel_is_fd_nonblock: () => 0, + kernel_get_process_exit_signal: () => 0, + kernel_mq_descriptor_msgsize: () => 8_192, + kernel_pick_signal_target_tid: () => 0, + kernel_set_current_tid: () => 0, + kernel_thread_has_deliverable: () => 0, + kernel_transfer_scratch_begin: (minimumCapacity: number | bigint) => { + transferCapacity = Number(minimumCapacity); + return nextTransferToken++; + }, + kernel_transfer_scratch_pointer: () => + ptrWidth === 8 ? BigInt(transferOffset) : transferOffset, + kernel_transfer_scratch_capacity: () => + ptrWidth === 8 ? BigInt(transferCapacity) : transferCapacity, + kernel_transfer_scratch_cancel: () => 0, + kernel_transfer_channel_execute: ( + transferPid: number, + _tid: number, + _token: bigint, + retryToken: bigint, + ) => + handleChannel( + ptrWidth === 8 ? BigInt(transferOffset) : transferOffset, + transferCapacity, + transferPid, + retryToken, + ), + kernel_transfer_io_execute: ( _pid: number, _tid: number, + _token: bigint, + length: number | bigint, + _originalSyscall: number, _fd: number, _offset: bigint, - len: number, - ) => BigInt(len), + _retryToken: bigint, + ) => Number(length), }; - worker = Object.assign( - Object.create(CentralizedKernelWorker.prototype), - { - kernel: { toKernelPtr: (value: number | bigint) => value }, - kernelInstance: { - exports: kernelExports, - }, - scratchTestInstance, - kernelMemory, - scratchOffset, - scratchRegion, - cachedKernelMem: null, - cachedKernelBuffer: null, - currentHandlePid: 0, - getPtrWidth: () => ptrWidth, - guestTidForChannel: () => pid, - bindKernelTidForChannel: () => {}, - finishSignalTermination: () => false, - dequeueSignalForDelivery: () => 0, - handleBlockingRetry: vi.fn(), - deferChannelWhileStopped: () => false, - getReadinessDeadline: () => 0, - pendingSelectRetries: new Map(), - pendingPollRetries: new Map(), - epollInterests: new Map(), - isRegisteredChannel: () => true, - handleSharedMappingsAfterFileSyscall: () => {}, - synchronizeSharedMemoryForBoundary: () => {}, - completeChannel, - completeChannelRaw, - relistenChannel: vi.fn(), + worker = + createCentralizedKernelWorkerTestDouble() as CentralizedKernelWorker & + Record; + worker.testAuthority.initializeKernelForTest({ + instance: scratchTestInstance, + gate, + mainScratch: scratchRegion, + tcpScratch: scratchRegion, + }); + worker.testAuthority.configureScratchBoundaryHooksForTest({ + getPtrWidth: () => ptrWidth, + guestTidForChannel: () => pid, + handleBlockingRetry: (...args: any[]) => { + // Scratch bytes and the lexical entry token are worker-internal retry + // state. Boundary assertions retain the historical channel/syscall/args + // contract. + handleBlockingRetry(...args.slice(0, 3)); + }, + deferChannelWhileStopped: () => false, + getReadinessDeadline: () => 0, + isRegisteredChannel: () => true, + handleSharedMappingsAfterFileSyscall: () => {}, + synchronizeSharedMemoryForBoundary: () => {}, + completeChannel: (...args: any[]) => { + // The suite observes the syscall completion contract, not the private + // lexical entry token threaded through the production implementation. + const semanticArgs = args.slice(0, 7); + if ( + args.length >= 9 && + Array.isArray(args[6]) && + args[6].length === 0 && + (args[4] === -1 || args[3] === undefined) + ) { + // Error-only completion sites now spell the empty detached-output + // slot solely to reach the trailing entry token. Preserve the former + // six-argument observable completion shape. + semanticArgs.length = 6; + } + completeChannel(...semanticArgs); + }, + completeChannelRaw: (...args: any[]) => { + // Likewise, relisten policy and entry authority are internal to the + // worker and must not become assertion inputs. + completeChannelRaw(...args.slice(0, 3)); }, - ) as CentralizedKernelWorker & Record; + relistenChannel: vi.fn(), + }); + worker.currentHandlePid = 0; + worker.processes = new Map([ + [ + pid, + { + pid, + memory: processMemory, + channels: [channel], + ptrWidth, + }, + ], + ]); + worker.pendingSelectRetries = new Map(); + worker.pendingPollRetries = new Map(); + worker.epollInterests = new Map(); kernelBytes.fill(0xa5, scratchEnd, scratchEnd + 16_384); return { @@ -287,36 +444,58 @@ function makeScratchHarness(ptrWidth: 4 | 8 = 4): ScratchHarness { channel, kernelBytes, processBytes, + kernelExports, + scratchTestInstance, + kernelMemory, + scratchRegion, + allocateScratchRegionAt, scratchOffset, scratchEnd, + transferOffset, handleChannel, + blockingRetryToken, + blockingRetryRelease, + handleBlockingRetry, completeChannel, completeChannelRaw, }; } +function useTransferScratchInstance(harness: ScratchHarness): void { + // The harness is always bound to this genuine gated instance. Reservation + // imports remain late-bound through harness.kernelExports. + expect(harness.scratchTestInstance).toBeDefined(); +} + function prepareGenericSyscallHarness( harness: ScratchHarness, ptrWidth: 4 | 8, ): void { - Object.assign(harness.worker, { - config: {}, - syscallRing: new Map(), - syscallTraceEnabled: false, - syscallTraceRing: [], - syscallTraceCap: 64, - channelTids: new Map(), - processes: new Map([[harness.channel.pid, { - pid: harness.channel.pid, - memory: harness.channel.memory, - channels: [harness.channel], - ptrWidth, - }]]), - synchronizeSharedMemoryForBoundary: () => {}, - sharedMmapBackings: new Map(), - hostReaped: new Set(), - getProcessExitSignal: () => 0, - }); + harness.worker.config = {}; + harness.worker.syscallRing = new Map(); + harness.worker.syscallTraceEnabled = false; + harness.worker.syscallTraceRing = []; + harness.worker.syscallTraceCap = 64; + harness.worker.channelTids = new Map(); + harness.worker.processes = new Map([ + [ + harness.channel.pid, + { + pid: harness.channel.pid, + memory: harness.channel.memory, + channels: [harness.channel], + ptrWidth, + }, + ], + ]); + harness.worker.sharedMmapBackings = new Map(); + harness.worker.hostReaped = new Set(); +} + +function dispatchScratchBoundarySyscall(harness: ScratchHarness): void { + harness.worker.testAuthority.dispatchScratchBoundarySyscallForTest( + harness.channel, + ); } function writeChannelSyscall( @@ -324,17 +503,30 @@ function writeChannelSyscall( syscall: number, args: bigint[], ): void { - const request = new DataView(harness.channel.memory.buffer); + const request = new DataView( + harness.channel.memory.buffer, + harness.channel.channelOffset, + CH_TOTAL_SIZE, + ); request.setUint32(CH_SYSCALL, syscall, true); for (let index = 0; index < 6; index++) { - request.setBigInt64( - CH_ARGS + index * CH_ARG_SIZE, - args[index] ?? 0n, - true, - ); + request.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, args[index] ?? 0n, true); } } +function dispatchScratchBoundarySyscallWithArgs( + harness: ScratchHarness, + syscall: number, + args: readonly number[] | readonly bigint[], +): void { + writeChannelSyscall( + harness, + syscall, + args.map((value) => BigInt(value)), + ); + dispatchScratchBoundarySyscall(harness); +} + function writeIfconf( bytes: Uint8Array, pointerWidth: 4 | 8, @@ -356,17 +548,26 @@ function invokeNetworkIoctlHandler( handler: string, pointer: number, ): void { - harness.worker[handler]( - harness.channel, - [7, 0, pointer, 0, 0, 0], + const path = NETWORK_IFREQ_HANDLERS.find( + (candidate) => candidate.handler === handler, ); + const request = handler === "handleIoctlIfconf" ? SIOCGIFCONF : path?.request; + if (request === undefined) { + throw new Error(`unknown network ioctl test handler ${handler}`); + } + writeChannelSyscall(harness, ABI_SYSCALLS.Ioctl, [ + 7n, + BigInt(request), + BigInt(pointer), + ]); + dispatchScratchBoundarySyscall(harness); } function writeNativeIovec( processBytes: Uint8Array, pointerWidth: 4 | 8, iovPointer: number, - base: number, + base: number | bigint, length: number, ): void { const view = new DataView(processBytes.buffer); @@ -374,7 +575,7 @@ function writeNativeIovec( view.setBigUint64(iovPointer, BigInt(base), true); view.setBigUint64(iovPointer + 8, BigInt(length), true); } else { - view.setUint32(iovPointer, base, true); + view.setUint32(iovPointer, Number(base), true); view.setUint32(iovPointer + 4, length, true); } } @@ -480,37 +681,45 @@ function writeNativeRightsRecords( records: number[][], paddingByte = 0x7b, ): number { - const layout = pointerWidth === 8 - ? { - alignment: PROCESS_CMSGHDR_WASM64_ALIGN, - lengthOffset: PROCESS_CMSGHDR_WASM64_LEN_OFFSET, - levelOffset: PROCESS_CMSGHDR_WASM64_LEVEL_OFFSET, - typeOffset: PROCESS_CMSGHDR_WASM64_TYPE_OFFSET, - dataOffset: PROCESS_CMSGHDR_WASM64_DATA_OFFSET, - } - : { - alignment: PROCESS_CMSGHDR_WASM32_ALIGN, - lengthOffset: PROCESS_CMSGHDR_WASM32_LEN_OFFSET, - levelOffset: PROCESS_CMSGHDR_WASM32_LEVEL_OFFSET, - typeOffset: PROCESS_CMSGHDR_WASM32_TYPE_OFFSET, - dataOffset: PROCESS_CMSGHDR_WASM32_DATA_OFFSET, - }; + const layout = + pointerWidth === 8 + ? { + alignment: PROCESS_CMSGHDR_WASM64_ALIGN, + lengthOffset: PROCESS_CMSGHDR_WASM64_LEN_OFFSET, + levelOffset: PROCESS_CMSGHDR_WASM64_LEVEL_OFFSET, + typeOffset: PROCESS_CMSGHDR_WASM64_TYPE_OFFSET, + dataOffset: PROCESS_CMSGHDR_WASM64_DATA_OFFSET, + } + : { + alignment: PROCESS_CMSGHDR_WASM32_ALIGN, + lengthOffset: PROCESS_CMSGHDR_WASM32_LEN_OFFSET, + levelOffset: PROCESS_CMSGHDR_WASM32_LEVEL_OFFSET, + typeOffset: PROCESS_CMSGHDR_WASM32_TYPE_OFFSET, + dataOffset: PROCESS_CMSGHDR_WASM32_DATA_OFFSET, + }; const view = new DataView(processBytes.buffer); let offset = 0; for (const descriptors of records) { - const length = layout.dataOffset + - descriptors.length * SCM_RIGHTS_FD_BYTES; + const length = layout.dataOffset + descriptors.length * SCM_RIGHTS_FD_BYTES; const space = alignUp(length, layout.alignment); processBytes.fill( paddingByte, controlPointer + offset, controlPointer + offset + space, ); - view.setUint32( - controlPointer + offset + layout.lengthOffset, - length, - true, - ); + if (pointerWidth === 8) { + view.setBigUint64( + controlPointer + offset + layout.lengthOffset, + BigInt(length), + true, + ); + } else { + view.setUint32( + controlPointer + offset + layout.lengthOffset, + length, + true, + ); + } view.setUint32( controlPointer + offset + layout.levelOffset, SOCKET_SOL_SOCKET, @@ -523,7 +732,9 @@ function writeNativeRightsRecords( ); descriptors.forEach((descriptor, index) => { view.setInt32( - controlPointer + offset + layout.dataOffset + + controlPointer + + offset + + layout.dataOffset + index * SCM_RIGHTS_FD_BYTES, descriptor, true, @@ -535,14 +746,14 @@ function writeNativeRightsRecords( } function canonicalRightsBytes(records: number[][]): Uint8Array { - const lengths = records.map((descriptors) => - KERNEL_CMSGHDR_WIRE_DATA_OFFSET + - descriptors.length * SCM_RIGHTS_FD_BYTES + const lengths = records.map( + (descriptors) => + KERNEL_CMSGHDR_WIRE_DATA_OFFSET + + descriptors.length * SCM_RIGHTS_FD_BYTES, ); const output = new Uint8Array( lengths.reduce( - (total, length) => - total + alignUp(length, KERNEL_CMSGHDR_WIRE_ALIGN), + (total, length) => total + alignUp(length, KERNEL_CMSGHDR_WIRE_ALIGN), 0, ), ); @@ -550,11 +761,7 @@ function canonicalRightsBytes(records: number[][]): Uint8Array { let offset = 0; records.forEach((descriptors, recordIndex) => { const length = lengths[recordIndex]; - view.setUint32( - offset + KERNEL_CMSGHDR_WIRE_LEN_OFFSET, - length, - true, - ); + view.setUint32(offset + KERNEL_CMSGHDR_WIRE_LEN_OFFSET, length, true); view.setUint32( offset + KERNEL_CMSGHDR_WIRE_LEVEL_OFFSET, SOCKET_SOL_SOCKET, @@ -567,7 +774,8 @@ function canonicalRightsBytes(records: number[][]): Uint8Array { ); descriptors.forEach((descriptor, descriptorIndex) => { view.setInt32( - offset + KERNEL_CMSGHDR_WIRE_DATA_OFFSET + + offset + + KERNEL_CMSGHDR_WIRE_DATA_OFFSET + descriptorIndex * SCM_RIGHTS_FD_BYTES, descriptor, true, @@ -586,23 +794,39 @@ function invokeIovecHandler( ): void { if (path.message) { const messagePointer = 128; - writeNativeMessage( - harness.processBytes, - pointerWidth, - messagePointer, - { iovecPointer: iovPointer, iovecCount: 1 }, - ); - harness.worker[path.handler]( - harness.channel, - [7, messagePointer, 0, 0, 0, 0], - ); + writeNativeMessage(harness.processBytes, pointerWidth, messagePointer, { + iovecPointer: iovPointer, + iovecCount: 1, + }); + invokeIovecMethod(harness, path.handler, [7, messagePointer, 0, 0, 0, 0]); return; } - harness.worker[path.handler]( - harness.channel, - path.syscall, - [7, iovPointer, 1, 0, 0, 0], + invokeIovecMethod(harness, path.handler, [ + 7n, + BigInt(iovPointer), + 1n, + 0n, + 0n, + 0n, + ]); +} + +function invokeIovecMethod( + harness: ScratchHarness, + method: (typeof IOVEC_HANDLER_PATHS)[number]["handler"], + args: readonly number[] | readonly bigint[], + syscallOverride?: number, +): void { + const path = IOVEC_HANDLER_PATHS.find( + (candidate) => candidate.handler === method, ); + if (!path) throw new Error(`unknown iovec test handler ${method}`); + writeChannelSyscall( + harness, + syscallOverride ?? path.syscall, + args.map((value) => BigInt(value)), + ); + dispatchScratchBoundarySyscall(harness); } function respondToSingleKernelIovec( @@ -611,7 +835,10 @@ function respondToSingleKernelIovec( payload: Uint8Array, ): void { harness.handleChannel.mockImplementation((offset: number | bigint) => { - const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); const argumentPointer = Number( channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), ); @@ -619,14 +846,13 @@ function respondToSingleKernelIovec( const kernelIovecPointer = path.message ? kernelView.getUint32(argumentPointer + 8, true) : argumentPointer; - const kernelDataPointer = kernelView.getUint32( - kernelIovecPointer, - true, - ); - expect( - kernelView.getUint32(kernelIovecPointer + 4, true), - path.name, - ).toBe(payload.byteLength); + const kernelDataPointer = path.message + ? kernelView.getUint32(kernelIovecPointer, true) + : kernelIovecPointer; + const kernelLength = path.message + ? kernelView.getUint32(kernelIovecPointer + 4, true) + : Number(channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true)); + expect(kernelLength, path.name).toBe(payload.byteLength); if (path.input) { expect( harness.kernelBytes.slice( @@ -687,21 +913,20 @@ describe("kernel scratch transfer capacity regressions", () => { }); expect(instance.exports.memory).toBe(kernelMemory); - expect(new Uint8Array(kernelMemory.buffer, 4096, 4)) - .toEqual(new Uint8Array([1, 2, 3, 4])); + expect(new Uint8Array(kernelMemory.buffer, 4096, 4)).toEqual( + new Uint8Array([1, 2, 3, 4]), + ); }); it("fails closed when the mqueue notification drain returns an errno", () => { const harness = makeScratchHarness(); const wakePendingSignalWaits = vi.fn(); const sendSignalToProcess = vi.fn(); - Object.assign(harness.worker, { + harness.worker.testAuthority.configureScratchBoundaryHooksForTest({ wakePendingSignalWaits, sendSignalToProcess, }); - Object.assign(harness.worker.kernelInstance.exports, { - kernel_mq_drain_notification: vi.fn(() => -EINVAL), - }); + harness.kernelExports.kernel_mq_drain_notification = vi.fn(() => -EINVAL); // Seed a plausible stale record. A negative kernel return must not make // these reusable bytes observable as a fresh notification. @@ -720,9 +945,9 @@ describe("kernel scratch transfer capacity regressions", () => { expect(sendSignalToProcess).not.toHaveBeenCalled(); }); - it("captures fstat handles and releases capture after handleChannel throws", () => { + it("releases fstat capture and preserves a fatal export failure", () => { const harness = makeScratchHarness(); - const kernelMemory = harness.worker.kernelMemory as WebAssembly.Memory; + const kernelMemory = harness.kernelMemory; const fstat = vi.fn(() => ({ dev: 11n, ino: 22n, @@ -735,36 +960,28 @@ describe("kernel scratch transfer capacity regressions", () => { mtimeMs: 2000, ctimeMs: 3000, })); - const kernel = Object.assign( - Object.create(WasmPosixKernel.prototype), - { - memory: kernelMemory, - kernelPtrWidth: 4, - io: { fstat }, - fstatHandleCapture: null, - }, - ) as WasmPosixKernel & Record; - harness.worker.kernel = kernel; + const kernel = createWasmPosixKernelTestHarness({ + io: { fstat } as never, + memory: kernelMemory, + pointerWidth: 4, + }) as WasmPosixKernel & Record; + harness.worker.testAuthority.replaceKernelForScratchBoundaryTest(kernel); let hostHandle = 501; - harness.handleChannel.mockImplementation(( - offset: number | bigint, - ) => { + harness.handleChannel.mockImplementation((offset: number | bigint) => { const channelView = new DataView( kernelMemory.buffer, Number(offset), CH_TOTAL_SIZE, ); - expect(channelView.getUint32(CH_SYSCALL, true)) - .toBe(ABI_SYSCALLS.Fstat); - const statPointer = channelView.getBigUint64( - CH_ARGS + CH_ARG_SIZE, - true, - ); - expect(kernel.hostFstat( - BigInt(hostHandle), - kernel.toKernelPtr(statPointer), - )).toBe(0); + expect(channelView.getUint32(CH_SYSCALL, true)).toBe(ABI_SYSCALLS.Fstat); + const statPointer = channelView.getBigUint64(CH_ARGS + CH_ARG_SIZE, true); + expect( + kernel.testAuthority.hostFstat( + BigInt(hostHandle), + kernel.toKernelPtr(statPointer), + ), + ).toBe(0); channelView.setBigInt64(CH_RETURN, 0n, true); channelView.setUint32(CH_ERRNO, 0, true); return 0; @@ -785,40 +1002,45 @@ describe("kernel scratch transfer capacity regressions", () => { }); expect(harness.worker.currentHandlePid).toBe(0); + const cause = new Error("synthetic handleChannel failure"); harness.handleChannel.mockImplementationOnce(() => { - throw new Error("synthetic handleChannel failure"); + throw cause; + }); + let exportFailure: unknown; + try { + capture(); + } catch (error) { + exportFailure = error; + } + expect(exportFailure).toMatchObject({ + message: "kernel export kernel_handle_channel failed", }); - expect(capture()).toEqual({ kind: "error", errno: EIO }); + expect((exportFailure as { cause?: unknown }).cause).toBe(cause); expect(harness.worker.currentHandlePid).toBe(0); + // A thrown kernel export poisons this generation. Capture cleanup restores + // host state, but the exact fatal value must escape this catch and every + // later entry instead of being downgraded to a recoverable EIO. hostHandle = 502; - expect(capture()).toEqual({ - kind: "ok", - value: { - dev: 11n, - ino: 22n, - mode: 0o100644, - size: 4096, - hostHandle: 502, - }, - }); + let repeatedFailure: unknown; + try { + capture(); + } catch (error) { + repeatedFailure = error; + } + expect(repeatedFailure).toBe(exportFailure); expect(fstat).toHaveBeenNthCalledWith(1, 501); - expect(fstat).toHaveBeenNthCalledWith(2, 502); + expect(fstat).toHaveBeenCalledTimes(1); }); it("chunks PTY input at the exact scratch capacity and capacity + 1", () => { for (const length of [CH_TOTAL_SIZE, CH_TOTAL_SIZE + 1]) { const harness = makeScratchHarness(); - const ptyWrite = vi.fn(( - _ptyIdx: number, - _pointer: number, - chunkLength: number, - ) => chunkLength); - Object.assign(harness.worker, { - kernelInstance: { - exports: { kernel_pty_master_write: ptyWrite }, - }, - drainPtyOutput: () => {}, + const ptyWrite = vi.fn( + (_ptyIdx: number, _pointer: number, chunkLength: number) => chunkLength, + ); + harness.kernelExports.kernel_pty_master_write = ptyWrite; + harness.worker.testAuthority.configureScratchBoundaryHooksForTest({ scheduleWakeBlockedRetries: () => {}, }); @@ -840,22 +1062,18 @@ describe("kernel scratch transfer capacity regressions", () => { harness.scratchOffset, harness.scratchOffset + 8, ); - const ptyWrite = vi.fn(( - _ptyIdx: number, - pointer: number, - length: number, - ) => { - expect(pointer).toBe(harness.scratchOffset); - expect(length).toBe(1); - expect(harness.kernelBytes.slice(pointer, pointer + length)) - .toEqual(new Uint8Array([0x31])); - return length; - }); - Object.assign(harness.worker, { - kernelInstance: { - exports: { kernel_pty_master_write: ptyWrite }, + const ptyWrite = vi.fn( + (_ptyIdx: number, pointer: number, length: number) => { + expect(pointer).toBe(harness.scratchOffset); + expect(length).toBe(1); + expect(harness.kernelBytes.slice(pointer, pointer + length)).toEqual( + new Uint8Array([0x31]), + ); + return length; }, - drainPtyOutput: () => {}, + ); + harness.kernelExports.kernel_pty_master_write = ptyWrite; + harness.worker.testAuthority.configureScratchBoundaryHooksForTest({ scheduleWakeBlockedRetries: () => {}, }); @@ -880,42 +1098,25 @@ describe("kernel scratch transfer capacity regressions", () => { harness.scratchOffset, harness.scratchOffset + 8, ); - const writeShm = vi.fn(( - _segment: number, - _offset: number, - pointer: number, - length: number, - ) => { - expect(length).toBe(1); - expect(harness.kernelBytes[pointer]).toBe(0x42); - return length; - }); - const writePipe = vi.fn(( - _pid: number, - _pipe: number, - pointer: number, - length: number, - ) => { - expect(length).toBe(1); - expect(harness.kernelBytes[pointer]).toBe(0x42); - return length; - }); - Object.assign(harness.worker, { - tcpScratchRegion: (harness.worker as any).scratchRegion, - kernelInstance: { - exports: { - kernel_ipc_shm_write_chunk: writeShm, - kernel_pipe_write: writePipe, - }, + const writeShm = vi.fn( + (_segment: number, _offset: number, pointer: number, length: number) => { + expect(length).toBe(1); + expect(harness.kernelBytes[pointer]).toBe(0x42); + return length; }, - }); + ); + const writePipe = vi.fn( + (_pid: number, _pipe: number, pointer: number, length: number) => { + expect(length).toBe(1); + expect(harness.kernelBytes[pointer]).toBe(0x42); + return length; + }, + ); + harness.kernelExports.kernel_ipc_shm_write_chunk = writeShm; + harness.kernelExports.kernel_pipe_write = writePipe; expect((harness.worker as any).writeSysvShmRange(7, 0, input)).toBe(true); - expect((harness.worker as any).writePipeChunked( - 41, - 9, - input, - )).toBe(1); + expect((harness.worker as any).writePipeChunked(41, 9, input)).toBe(1); expect(writeShm).toHaveBeenCalledOnce(); expect(writePipe).toHaveBeenCalledOnce(); @@ -936,26 +1137,26 @@ describe("kernel scratch transfer capacity regressions", () => { const length = args[12]; expect(pointer).toBe(harness.scratchOffset); expect(length).toBe(1); - expect(harness.kernelBytes.slice(pointer, pointer + length)) - .toEqual(new Uint8Array([0x55])); + expect(harness.kernelBytes.slice(pointer, pointer + length)).toEqual( + new Uint8Array([0x55]), + ); return 0; }); - Object.assign(harness.worker, { - tcpScratchRegion: (harness.worker as any).scratchRegion, - processes: new Map([[41, {}]]), + harness.worker.processes = new Map([[41, {}]]); + harness.kernelExports.kernel_inject_datagram = inject; + harness.worker.testAuthority.configureScratchBoundaryHooksForTest({ scheduleWakeBlockedRetries: vi.fn(), - kernelInstance: { - exports: { kernel_inject_datagram: inject }, - }, }); - expect((harness.worker as any).injectUdpDatagram(41, { - srcAddr: new Uint8Array([10, 0, 0, 1]), - srcPort: 1000, - dstAddr: new Uint8Array([10, 0, 0, 2]), - dstPort: 2000, - data: input, - })).toBe(0); + expect( + (harness.worker as any).injectUdpDatagram(41, { + srcAddr: new Uint8Array([10, 0, 0, 1]), + srcPort: 1000, + dstAddr: new Uint8Array([10, 0, 0, 2]), + dstPort: 2000, + data: input, + }), + ).toBe(0); expect(inject).toHaveBeenCalledOnce(); expect( @@ -985,28 +1186,18 @@ describe("kernel scratch transfer capacity regressions", () => { expect(harness.kernelBytes[pointer + length - 1]).toBe(0x55); return 0; }); - const tcpScratchInstance = createKernelScratchTestInstance( - 4, - (harness.worker as any).kernelMemory, - () => ({ kernel_inject_datagram: inject }), - () => tcpScratchOffset, - ); - const tcpScratchRegion = allocateKernelScratchRegion( - (harness.worker as any).kernelMemory, - tcpScratchInstance.exports.kernel_alloc_scratch as - (size: number) => number, + const tcpScratchRegion = harness.allocateScratchRegionAt( + tcpScratchOffset, tcpCapacity, - 4, "test kernel TCP scratch", - tcpScratchInstance, ); - Object.assign(harness.worker, { + harness.worker.testAuthority.replaceTcpScratchForScratchBoundaryTest( tcpScratchRegion, - processes: new Map([[41, {}]]), + ); + harness.worker.processes = new Map([[41, {}]]); + harness.kernelExports.kernel_inject_datagram = inject; + harness.worker.testAuthority.configureScratchBoundaryHooksForTest({ scheduleWakeBlockedRetries: vi.fn(), - kernelInstance: { - exports: { kernel_inject_datagram: inject }, - }, }); const datagram = (data: Uint8Array) => ({ srcAddr: new Uint8Array([10, 0, 0, 1]), @@ -1016,14 +1207,18 @@ describe("kernel scratch transfer capacity regressions", () => { data, }); - expect((harness.worker as any).injectUdpDatagram( - 41, - datagram(new Uint8Array(tcpCapacity).fill(0x55)), - )).toBe(0); - expect((harness.worker as any).injectUdpDatagram( - 41, - datagram(new Uint8Array(tcpCapacity + 1).fill(0x66)), - )).toBe(90); + expect( + (harness.worker as any).injectUdpDatagram( + 41, + datagram(new Uint8Array(tcpCapacity).fill(0x55)), + ), + ).toBe(0); + expect( + (harness.worker as any).injectUdpDatagram( + 41, + datagram(new Uint8Array(tcpCapacity + 1).fill(0x66)), + ), + ).toBe(90); expect(inject).toHaveBeenCalledOnce(); expect(tcpTail).toEqual(new Uint8Array(16).fill(0xa5)); @@ -1033,23 +1228,18 @@ describe("kernel scratch transfer capacity regressions", () => { it("accepts PATH_MAX minus one cwd bytes and rejects PATH_MAX before copying", () => { const exact = makeScratchHarness(); const exactPath = "x".repeat(POSIX_PATH_MAX_BYTES - 1); - const exactSetCwd = vi.fn(( - pid: number, - pointer: number, - length: number, - ) => { - expect(pid).toBe(41); - expect(pointer).toBe(exact.scratchOffset); - expect(length).toBe(POSIX_PATH_MAX_BYTES - 1); - expect( - exact.kernelBytes.slice(pointer, pointer + length), - ).toEqual(new TextEncoder().encode(exactPath)); - return 0; - }); - Object.assign(exact.worker, { - initialized: true, - kernelInstance: { exports: { kernel_set_cwd: exactSetCwd } }, - }); + const exactSetCwd = vi.fn( + (pid: number, pointer: number, length: number) => { + expect(pid).toBe(41); + expect(pointer).toBe(exact.scratchOffset); + expect(length).toBe(POSIX_PATH_MAX_BYTES - 1); + expect(exact.kernelBytes.slice(pointer, pointer + length)).toEqual( + new TextEncoder().encode(exactPath), + ); + return 0; + }, + ); + exact.kernelExports.kernel_set_cwd = exactSetCwd; exact.worker.setCwd(41, exactPath); @@ -1058,19 +1248,15 @@ describe("kernel scratch transfer capacity regressions", () => { const oversized = makeScratchHarness(); const oversizedSetCwd = vi.fn(() => -36); - Object.assign(oversized.worker, { - initialized: true, - kernelInstance: { exports: { kernel_set_cwd: oversizedSetCwd } }, - }); + oversized.kernelExports.kernel_set_cwd = oversizedSetCwd; const scratchBeforeRejection = oversized.kernelBytes.slice( oversized.scratchOffset, oversized.scratchEnd, ); expect(() => - oversized.worker.setCwd(41, "x".repeat(POSIX_PATH_MAX_BYTES)) - ) - .toThrow(/cwd|PATH_MAX|too long/i); + oversized.worker.setCwd(41, "x".repeat(POSIX_PATH_MAX_BYTES)), + ).toThrow(/cwd|PATH_MAX|too long/i); expect(oversizedSetCwd).not.toHaveBeenCalled(); expect( @@ -1083,23 +1269,17 @@ describe("kernel scratch transfer capacity regressions", () => { }); it("fails loudly when the required bounded cwd export is absent", () => { - const harness = makeScratchHarness(); - Object.assign(harness.worker, { - initialized: true, - kernelInstance: { exports: {} }, - }); + const harness = makeScratchHarness(4, ["kernel_set_cwd"]); const scratchBefore = harness.kernelBytes.slice( harness.scratchOffset, harness.scratchEnd, ); - expect(() => harness.worker.setCwd(41, "/tmp")) - .toThrow("Kernel missing required kernel_set_cwd export"); + expect(() => harness.worker.setCwd(41, "/tmp")).toThrow( + "Kernel missing required kernel_set_cwd export", + ); expect( - harness.kernelBytes.slice( - harness.scratchOffset, - harness.scratchEnd, - ), + harness.kernelBytes.slice(harness.scratchOffset, harness.scratchEnd), ).toEqual(scratchBefore); expectScratchTailUntouched(harness); }); @@ -1133,19 +1313,24 @@ describe("kernel scratch transfer capacity regressions", () => { return 0; }); - harness.worker.handleGetgroups( - harness.channel, - [1, destination, 0, 0, 0, 0], - [1n, BigInt(destination), 0n, 0n, 0n, 0n], - ); + dispatchScratchBoundarySyscallWithArgs(harness, ABI_SYSCALLS.Getgroups, [ + 1n, + BigInt(destination), + 0n, + 0n, + 0n, + 0n, + ]); expect(harness.completeChannel).toHaveBeenCalledTimes(1); const completion = harness.completeChannel.mock.calls[0]; expect(completion.slice(4, 6)).toEqual([1, 0]); - expect(completion[6]).toEqual([{ - ptr: destination, - bytes: new Uint8Array([0x78, 0x56, 0x34, 0x12]), - }]); + expect(completion[6]).toEqual([ + { + ptr: destination, + bytes: new Uint8Array([0x78, 0x56, 0x34, 0x12]), + }, + ]); expectScratchTailUntouched(harness); }); @@ -1163,16 +1348,21 @@ describe("kernel scratch transfer capacity regressions", () => { return 0; }); - harness.worker.handleGetgroups( - harness.channel, - [0, Number.MAX_SAFE_INTEGER, 0, 0, 0, 0], - [0n, BigInt(Number.MAX_SAFE_INTEGER) + 1n, 0n, 0n, 0n, 0n], - ); + dispatchScratchBoundarySyscallWithArgs(harness, ABI_SYSCALLS.Getgroups, [ + 0n, + BigInt(Number.MAX_SAFE_INTEGER) + 1n, + 0n, + 0n, + 0n, + 0n, + ]); expect(harness.completeChannel).toHaveBeenCalledWith( harness.channel, ABI_SYSCALLS.Getgroups, - [0, Number.MAX_SAFE_INTEGER, 0, 0, 0, 0], + // The pointer is ignored when size is zero. Keep its unsafe wasm64 bits + // out of the Number-valued host-control projection. + [0, 0, 0, 0, 0, 0], undefined, 1, 0, @@ -1189,11 +1379,14 @@ describe("kernel scratch transfer capacity regressions", () => { "rejects an invalid getgroups %s before kernel dispatch", (_name, size, pointer, errno) => { const harness = makeScratchHarness(8); - harness.worker.handleGetgroups( - harness.channel, - [Number(size), Number(pointer), 0, 0, 0, 0], - [size, pointer, 0n, 0n, 0n, 0n], - ); + dispatchScratchBoundarySyscallWithArgs(harness, ABI_SYSCALLS.Getgroups, [ + size, + pointer, + 0n, + 0n, + 0n, + 0n, + ]); expect(harness.handleChannel).not.toHaveBeenCalled(); expect(harness.completeChannelRaw).toHaveBeenCalledWith( @@ -1205,31 +1398,25 @@ describe("kernel scratch transfer capacity regressions", () => { }, ); - it("accepts exact setgroups scratch capacity and rejects capacity plus one", () => { - const exactCount = CH_DATA_SIZE / 4; + it("accepts exact setgroups NGROUPS_MAX and rejects NGROUPS_MAX plus one", () => { + const exactCount = POSIX_NGROUPS_MAX; for (const count of [exactCount, exactCount + 1]) { const harness = makeScratchHarness(8); prepareGenericSyscallHarness(harness, 8); const source = 4096; - harness.processBytes.fill( - 0x4d, - source, - source + count * 4, - ); + harness.processBytes.fill(0x4d, source, source + count * 4); harness.handleChannel.mockImplementation((offset: number | bigint) => { - const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); const scratchPointer = Number( channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), ); expect(scratchPointer).toBe(harness.scratchOffset + CH_DATA); expect( - harness.kernelBytes.slice( - scratchPointer, - scratchPointer + CH_DATA_SIZE, - ), - ).toEqual( - harness.processBytes.slice(source, source + CH_DATA_SIZE), - ); + harness.kernelBytes.slice(scratchPointer, scratchPointer + count * 4), + ).toEqual(harness.processBytes.slice(source, source + count * 4)); channelView.setBigInt64(CH_RETURN, 0n, true); channelView.setUint32(CH_ERRNO, 0, true); return 0; @@ -1239,22 +1426,19 @@ describe("kernel scratch transfer capacity regressions", () => { BigInt(source), ]); - harness.worker._handleSyscallInner(harness.channel); + dispatchScratchBoundarySyscall(harness); expect(harness.handleChannel).toHaveBeenCalledTimes( count === exactCount ? 1 : 0, ); if (count === exactCount) { expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ - 0, - 0, + 0, 0, ]); } else { - expect(harness.completeChannel).toHaveBeenCalledWith( + expect(harness.completeChannel).not.toHaveBeenCalled(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( harness.channel, - ABI_SYSCALLS.Setgroups, - [count, source, 0, 0, 0, 0], - undefined, -1, EINVAL, ); @@ -1268,26 +1452,23 @@ describe("kernel scratch transfer capacity regressions", () => { prepareGenericSyscallHarness(harness, 8); const ignoredPointer = BigInt(Number.MAX_SAFE_INTEGER) + 1n; harness.handleChannel.mockImplementation((offset: number | bigint) => { - const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); - expect( - channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), - ).toBe(BigInt(harness.scratchOffset + CH_DATA)); + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); + expect(channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true)).toBe( + BigInt(harness.scratchOffset + CH_DATA), + ); channelView.setBigInt64(CH_RETURN, 0n, true); channelView.setUint32(CH_ERRNO, 0, true); return 0; }); - writeChannelSyscall(harness, ABI_SYSCALLS.Setgroups, [ - 0n, - ignoredPointer, - ]); + writeChannelSyscall(harness, ABI_SYSCALLS.Setgroups, [0n, ignoredPointer]); - harness.worker._handleSyscallInner(harness.channel); + dispatchScratchBoundarySyscall(harness); expect(harness.handleChannel).toHaveBeenCalledOnce(); - expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ - 0, - 0, - ]); + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([0, 0]); expectScratchTailUntouched(harness); }); @@ -1296,7 +1477,7 @@ describe("kernel scratch transfer capacity regressions", () => { prepareGenericSyscallHarness(harness, 8); writeChannelSyscall(harness, ABI_SYSCALLS.Setgroups, [1n, 0n]); - harness.worker._handleSyscallInner(harness.channel); + dispatchScratchBoundarySyscall(harness); expect(harness.handleChannel).not.toHaveBeenCalled(); expect(harness.completeChannel).toHaveBeenCalledWith( @@ -1322,7 +1503,7 @@ describe("kernel scratch transfer capacity regressions", () => { prepareGenericSyscallHarness(harness, pointerWidth); writeChannelSyscall(harness, syscallNr, [0n]); - harness.worker._handleSyscallInner(harness.channel); + dispatchScratchBoundarySyscall(harness); expect(harness.handleChannel).not.toHaveBeenCalled(); expect(harness.completeChannel).toHaveBeenCalledWith( @@ -1349,7 +1530,7 @@ describe("kernel scratch transfer capacity regressions", () => { prepareGenericSyscallHarness(harness, pointerWidth); writeChannelSyscall(harness, syscallNr, [7n, 0n, 1n]); - harness.worker._handleSyscallInner(harness.channel); + dispatchScratchBoundarySyscall(harness); expect(harness.handleChannel).not.toHaveBeenCalled(); expect(harness.completeChannel).toHaveBeenCalledWith( @@ -1379,45 +1560,212 @@ describe("kernel scratch transfer capacity regressions", () => { harness.kernelBytes.buffer, Number(offset), ); - expect( - channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), - ).toBe(BigInt(harness.scratchOffset + CH_DATA)); + expect(channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true)).toBe( + BigInt(harness.scratchOffset + CH_DATA), + ); channelView.setBigInt64(CH_RETURN, 0n, true); channelView.setUint32(CH_ERRNO, 0, true); return 0; }); writeChannelSyscall(harness, syscallNr, [7n, 0n, 0n]); - harness.worker._handleSyscallInner(harness.channel); + dispatchScratchBoundarySyscall(harness); expect(harness.handleChannel).toHaveBeenCalledOnce(); expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ - 0, - 0, + 0, 0, ]); expectScratchTailUntouched(harness); }, ); - it.each([ - ["wasm32", 4, 1n], - ["wasm64", 8, 0x7fff_ffff_0000_0001n], - ] as const)( - "keeps a scalar %s prctl argument out of scratch", - (_pointerKind, pointerWidth, rawScalar) => { - const harness = makeScratchHarness(pointerWidth); - prepareGenericSyscallHarness(harness, pointerWidth); - harness.handleChannel.mockImplementation((offset: number | bigint) => { - const channelView = new DataView( - harness.kernelBytes.buffer, - Number(offset), - ); - expect(channelView.getBigInt64(CH_ARGS, true)).toBe( - BigInt(PR_SET_NO_NEW_PRIVS), - ); - expect( + it("clamps a u64-wide complete-result capacity before Number conversion", () => { + const harness = makeScratchHarness(8); + prepareGenericSyscallHarness(harness, 8); + const destination = 4096; + const callerCapacity = BigInt(Number.MAX_SAFE_INTEGER) + 1n; + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); + const stagedPointer = Number(channelView.getBigInt64(CH_ARGS, true)); + expect(stagedPointer).toBe(harness.scratchOffset + CH_DATA); + expect(channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true)).toBe( + BigInt(POSIX_PATH_MAX_BYTES), + ); + harness.kernelBytes.set(new TextEncoder().encode("/\0"), stagedPointer); + channelView.setBigInt64(CH_RETURN, 2n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + writeChannelSyscall(harness, ABI_SYSCALLS.Getcwd, [ + BigInt(destination), + callerCapacity, + ]); + + dispatchScratchBoundarySyscall(harness); + + expect(harness.handleChannel).toHaveBeenCalledOnce(); + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([2, 0]); + expectScratchTailUntouched(harness); + }); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "returns %s mq_timedsend EMSGSIZE before a large reservation can fail", + (_pointerKind, pointerWidth) => { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + const descriptor = 0x4000_0000; + const source = 65_536; + const requested = 84 * 1_024; + const query = vi.fn(() => 1_024); + const begin = vi.fn(() => -BigInt(ENOMEM)); + harness.kernelExports.kernel_mq_descriptor_msgsize = query; + harness.kernelExports.kernel_transfer_scratch_begin = begin; + writeChannelSyscall(harness, ABI_SYSCALLS.MqTimedsend, [ + BigInt(descriptor), + BigInt(source), + BigInt(requested), + 0n, + 0n, + ]); + + dispatchScratchBoundarySyscall(harness); + + expect(query).toHaveBeenCalledWith( + harness.channel.pid, + harness.channel.pid, + descriptor, + ); + expect(begin).not.toHaveBeenCalled(); + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ + -1, + EMSGSIZE, + ]); + expectScratchTailUntouched(harness); + }, + ); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "sizes a huge %s mq_timedreceive capacity from the queue maximum", + (_pointerKind, pointerWidth) => { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + const descriptor = 0x4000_0000; + const destination = 65_536; + const requested = 84 * 1_024; + const query = vi.fn(() => 1_024); + const begin = vi.fn(() => -BigInt(ENOMEM)); + harness.kernelExports.kernel_mq_descriptor_msgsize = query; + harness.kernelExports.kernel_transfer_scratch_begin = begin; + harness.processBytes.fill(0x7b, destination, destination + 1_025); + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); + const stagedPointer = Number( channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), - ).toBe(1n); + ); + expect(stagedPointer).toBe(harness.scratchOffset + CH_DATA); + expect(channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true)).toBe( + 1_024n, + ); + harness.kernelBytes.set(Uint8Array.of(0x11, 0x22, 0x33), stagedPointer); + channelView.setBigInt64(CH_RETURN, 3n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + writeChannelSyscall(harness, ABI_SYSCALLS.MqTimedreceive, [ + BigInt(descriptor), + BigInt(destination), + BigInt(requested), + 0n, + 0n, + ]); + + dispatchScratchBoundarySyscall(harness); + + expect(query).toHaveBeenCalledWith( + harness.channel.pid, + harness.channel.pid, + descriptor, + ); + expect(begin).not.toHaveBeenCalled(); + expect(harness.handleChannel).toHaveBeenCalledOnce(); + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ + 3, 0, + ]); + expect(harness.completeChannel.mock.calls[0]?.[6]).toEqual([ + { + ptr: destination, + bytes: Uint8Array.of(0x11, 0x22, 0x33), + }, + ]); + expect(harness.processBytes.slice(destination, destination + 4)).toEqual( + new Uint8Array(4).fill(0x7b), + ); + expectScratchTailUntouched(harness); + }, + ); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "rejects a short %s mq_timedreceive capacity before reservation", + (_pointerKind, pointerWidth) => { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + const query = vi.fn(() => 1_024); + const begin = vi.fn(() => -BigInt(ENOMEM)); + harness.kernelExports.kernel_mq_descriptor_msgsize = query; + harness.kernelExports.kernel_transfer_scratch_begin = begin; + writeChannelSyscall(harness, ABI_SYSCALLS.MqTimedreceive, [ + 0x4000_0000n, + 65_536n, + 1_023n, + 0n, + 0n, + ]); + + dispatchScratchBoundarySyscall(harness); + + expect(begin).not.toHaveBeenCalled(); + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ + -1, + EMSGSIZE, + ]); + expectScratchTailUntouched(harness); + }, + ); + + it.each([ + ["wasm32", 4, 1n], + ["wasm64", 8, 0x7fff_ffff_0000_0001n], + ] as const)( + "keeps a scalar %s prctl argument out of scratch", + (_pointerKind, pointerWidth, rawScalar) => { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); + expect(channelView.getBigInt64(CH_ARGS, true)).toBe( + BigInt(PR_SET_NO_NEW_PRIVS), + ); + expect(channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true)).toBe(1n); channelView.setBigInt64(CH_RETURN, 0n, true); channelView.setUint32(CH_ERRNO, 0, true); return 0; @@ -1427,12 +1775,11 @@ describe("kernel scratch transfer capacity regressions", () => { rawScalar, ]); - harness.worker._handleSyscallInner(harness.channel); + dispatchScratchBoundarySyscall(harness); expect(harness.handleChannel).toHaveBeenCalledOnce(); expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ - 0, - 0, + 0, 0, ]); expectScratchTailUntouched(harness); }, @@ -1488,12 +1835,14 @@ describe("kernel scratch transfer capacity regressions", () => { BigInt(processPointer), ]); - harness.worker._handleSyscallInner(harness.channel); + dispatchScratchBoundarySyscall(harness); expect(harness.handleChannel).toHaveBeenCalledOnce(); if (direction === "out") { - const writes = harness.completeChannel.mock.calls[0]?.[6] as - Array<{ ptr: number; bytes: Uint8Array }>; + const writes = harness.completeChannel.mock.calls[0]?.[6] as Array<{ + ptr: number; + bytes: Uint8Array; + }>; expect(writes).toEqual([{ ptr: processPointer, bytes: output }]); } expectScratchTailUntouched(harness); @@ -1510,12 +1859,9 @@ describe("kernel scratch transfer capacity regressions", () => { (_pointerKind, pointerWidth, option) => { const harness = makeScratchHarness(pointerWidth); prepareGenericSyscallHarness(harness, pointerWidth); - writeChannelSyscall(harness, ABI_SYSCALLS.Prctl, [ - BigInt(option), - 0n, - ]); + writeChannelSyscall(harness, ABI_SYSCALLS.Prctl, [BigInt(option), 0n]); - harness.worker._handleSyscallInner(harness.channel); + dispatchScratchBoundarySyscall(harness); expect(harness.handleChannel).not.toHaveBeenCalled(); expect(harness.completeChannel).toHaveBeenCalledWith( @@ -1530,163 +1876,143 @@ describe("kernel scratch transfer capacity regressions", () => { }, ); - it("accounts for every writev table and alignment byte", () => { - const harness = makeScratchHarness(); - const iovPtr = 256; - const dataPtr = 16_384; - const entries = Array.from( - { length: IOV_MAX }, - (_, index) => ({ - base: dataPtr, - len: index === IOV_MAX - 1 ? 56_321 : 1, - }), - ); - writeWasm32Iovecs(harness.processBytes, iovPtr, entries); - harness.processBytes.fill(0x5c, dataPtr, dataPtr + 56_321); - const kernelIovPointers: number[] = []; - const defaultHandleChannel = harness.handleChannel.getMockImplementation()!; - harness.handleChannel.mockImplementation((...args: unknown[]) => { - const view = new DataView( - harness.kernelBytes.buffer, - harness.scratchOffset, - ); - kernelIovPointers.push( - Number(view.getBigInt64(CH_ARGS + CH_ARG_SIZE, true)), - ); - return defaultHandleChannel(...args); - }); - - harness.worker.handleWritev( - harness.channel, - ABI_SYSCALLS.Writev, - [7, iovPtr, entries.length, 0, 0, 0], - ); - - expectScratchTailUntouched(harness); - for (const kernelIov of kernelIovPointers) { - expect(kernelIov).toBeGreaterThanOrEqual(harness.scratchOffset + CH_DATA); - expect(kernelIov + 8).toBeLessThanOrEqual(harness.scratchEnd); - } - }); - it.each([ - ["writev", "handleWritev", ABI_SYSCALLS.Writev, true], - ["readv", "handleReadv", ABI_SYSCALLS.Readv, false], + ["wasm32", 4, "writev", "handleWritev", ABI_SYSCALLS.Writev, false], + ["wasm32", 4, "readv", "handleReadv", ABI_SYSCALLS.Readv, true], + ["wasm64", 8, "writev", "handleWritev", ABI_SYSCALLS.Writev, false], + ["wasm64", 8, "readv", "handleReadv", ABI_SYSCALLS.Readv, true], ] as const)( - "%s switches from one exact-capacity call to bounded chunks at capacity plus one", - (_name, method, syscallNr, input) => { - const exactDataCapacity = CH_DATA_SIZE - 8; - expect(exactDataCapacity).toBe(65_528); - - for (const length of [exactDataCapacity, exactDataCapacity + 1]) { - const harness = makeScratchHarness(); + "%s %s uses one channel operation at exact capacity and one reservation at capacity + 1", + (_widthName, pointerWidth, _name, method, syscallNr, readOperation) => { + for (const length of [CH_DATA_SIZE, CH_DATA_SIZE + 1]) { + const harness = makeScratchHarness(pointerWidth); const iovPointer = 256; - const dataPointer = 65_536; + const firstPointer = 65_536; + const firstLength = 17; + const secondPointer = firstPointer + firstLength + 32; + const secondLength = length - firstLength; const payload = Uint8Array.from( { length }, (_, index) => (index * 17 + 3) % 251, ); - const callerCanary = 0x7e; writeNativeIovec( harness.processBytes, - 4, + pointerWidth, iovPointer, - dataPointer, - length, + firstPointer, + firstLength, ); - if (input) { - harness.processBytes.set(payload, dataPointer); - } else { - harness.processBytes.fill( - 0x6d, - dataPointer, - dataPointer + length, + writeNativeIovec( + harness.processBytes, + pointerWidth, + iovPointer + 2 * pointerWidth, + secondPointer, + secondLength, + ); + if (!readOperation) { + harness.processBytes.set( + payload.subarray(0, firstLength), + firstPointer, + ); + harness.processBytes.set( + payload.subarray(firstLength), + secondPointer, ); } - harness.processBytes[dataPointer + length] = callerCanary; + const callerCanary = 0x7e; + harness.processBytes[secondPointer + secondLength] = callerCanary; + + const execute = vi.fn( + ( + _pid: number, + _tid: number, + _token: bigint, + offered: number | bigint, + originalSyscall: number, + _fd: number, + _offset: bigint, + retryToken: bigint, + ) => { + expect(Number(offered)).toBe(length); + expect(originalSyscall).toBe(syscallNr); + expect(retryToken).toBe(0n); + if (readOperation) { + harness.kernelBytes.set(payload, harness.transferOffset); + } else { + expect( + harness.kernelBytes.slice( + harness.transferOffset, + harness.transferOffset + length, + ), + ).toEqual(payload); + } + return length; + }, + ); + harness.kernelExports.kernel_transfer_io_execute = execute; - let transferred = 0; - const chunkLengths: number[] = []; - harness.handleChannel.mockImplementation((offset: number | bigint) => { + harness.handleChannel.mockImplementation(() => { const channelView = new DataView( harness.kernelBytes.buffer, - offset, - ); - const kernelIovecPointer = Number( - channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + harness.scratchOffset, ); - const kernelView = new DataView(harness.kernelBytes.buffer); - const kernelDataPointer = kernelView.getUint32( - kernelIovecPointer, - true, - ); - const chunkLength = kernelView.getUint32( - kernelIovecPointer + 4, - true, - ); - expect(kernelIovecPointer).toBe( - harness.scratchOffset + CH_DATA, + expect(channelView.getUint32(CH_SYSCALL, true)).toBe( + readOperation ? ABI_SYSCALLS.Read : ABI_SYSCALLS.Write, ); - expect(kernelDataPointer).toBe(kernelIovecPointer + 8); - expect(kernelDataPointer + chunkLength) - .toBeLessThanOrEqual(harness.scratchEnd); - chunkLengths.push(chunkLength); - const chunk = payload.subarray( - transferred, - transferred + chunkLength, + const dataPointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), ); - if (input) { - expect( - harness.kernelBytes.slice( - kernelDataPointer, - kernelDataPointer + chunkLength, - ), - ).toEqual(chunk); + expect(dataPointer).toBe(harness.scratchOffset + CH_DATA); + expect( + Number(channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true)), + ).toBe(length); + if (readOperation) { + harness.kernelBytes.set(payload, dataPointer); } else { - harness.kernelBytes.set(chunk, kernelDataPointer); + expect( + harness.kernelBytes.slice(dataPointer, dataPointer + length), + ).toEqual(payload); } - transferred += chunkLength; - channelView.setBigInt64(CH_RETURN, BigInt(chunkLength), true); + channelView.setBigInt64(CH_RETURN, BigInt(length), true); channelView.setUint32(CH_ERRNO, 0, true); return 0; }); - harness.worker[method]( - harness.channel, - syscallNr, - [7, iovPointer, 1, 0, 0, 0], - ); + if (length > CH_DATA_SIZE) useTransferScratchInstance(harness); + invokeIovecMethod(harness, method, [ + 7n, + BigInt(iovPointer), + 2n, + 0n, + 0n, + 0n, + ]); - expect(chunkLengths).toEqual( - length === exactDataCapacity - ? [exactDataCapacity] - : [exactDataCapacity, 1], + expect(harness.handleChannel).toHaveBeenCalledTimes( + length === CH_DATA_SIZE ? 1 : 0, ); - expect(transferred).toBe(length); - expect( - harness.processBytes.slice( - dataPointer, - dataPointer + length, - ), - ).toEqual(payload); - expect(harness.processBytes[dataPointer + length]) - .toBe(callerCanary); - if (length === exactDataCapacity) { - expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)) - .toEqual([length, 0]); - expect(harness.completeChannelRaw).not.toHaveBeenCalled(); - } else if (input) { - expect(harness.completeChannel).not.toHaveBeenCalled(); - expect(harness.completeChannelRaw).toHaveBeenCalledWith( - harness.channel, - length, - 0, - ); - } else { - expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)) - .toEqual([length, 0]); - expect(harness.completeChannelRaw).not.toHaveBeenCalled(); + expect(execute).toHaveBeenCalledTimes(length === CH_DATA_SIZE ? 0 : 1); + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ + length, + 0, + ]); + if (readOperation) { + expect( + harness.processBytes.slice( + firstPointer, + firstPointer + firstLength, + ), + ).toEqual(payload.subarray(0, firstLength)); + expect( + harness.processBytes.slice( + secondPointer, + secondPointer + secondLength, + ), + ).toEqual(payload.subarray(firstLength)); } + expect(harness.processBytes[secondPointer + secondLength]).toBe( + callerCanary, + ); expectScratchTailUntouched(harness); } }, @@ -1778,107 +2104,461 @@ describe("kernel scratch transfer capacity regressions", () => { ); it.each([4, 8] as const)( - "accepts a zero-length wasm%s iovec with base address zero", + "accepts a zero-length wasm%s iovec with base address zero", + (pointerWidth) => { + const iovPointer = 512; + for (const path of IOVEC_HANDLER_PATHS) { + const harness = makeScratchHarness(pointerWidth); + harness.processBytes.fill(0x6d, 0, 16); + const addressZeroBefore = harness.processBytes.slice(0, 16); + writeNativeIovec(harness.processBytes, pointerWidth, iovPointer, 0, 0); + respondToSingleKernelIovec(harness, path, new Uint8Array(0)); + + invokeIovecHandler(harness, pointerWidth, path, iovPointer); + + expect(harness.handleChannel, path.name).toHaveBeenCalledOnce(); + expect( + harness.completeChannel.mock.calls[0]?.slice(4, 6), + path.name, + ).toEqual([0, 0]); + expect(harness.processBytes.slice(0, 16), path.name).toEqual( + addressZeroBefore, + ); + expectScratchTailUntouched(harness); + } + }, + ); + + it("accepts a zero-length wasm64 iovec without narrowing its ignored base", () => { + const iovPointer = 512; + const ignoredBase = BigInt(Number.MAX_SAFE_INTEGER) + 1n; + for (const path of IOVEC_HANDLER_PATHS) { + const harness = makeScratchHarness(8); + writeNativeIovec(harness.processBytes, 8, iovPointer, ignoredBase, 0); + respondToSingleKernelIovec(harness, path, new Uint8Array(0)); + + invokeIovecHandler(harness, 8, path, iovPointer); + + expect(harness.handleChannel, path.name).toHaveBeenCalledOnce(); + expect( + harness.completeChannel.mock.calls[0]?.slice(4, 6), + path.name, + ).toEqual([0, 0]); + expectScratchTailUntouched(harness); + } + }); + + it("subtracts the complete readv iovec table from data capacity", () => { + const harness = makeScratchHarness(); + const iovPtr = 256; + const destination = 24_576; + const entries = Array.from({ length: IOV_MAX }, (_, index) => ({ + base: destination, + len: index === IOV_MAX - 1 ? 56 : 64, + })); + writeWasm32Iovecs(harness.processBytes, iovPtr, entries); + harness.handleChannel.mockImplementation(() => { + const view = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + view.setBigInt64(CH_RETURN, 0n, true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + invokeIovecMethod(harness, "handleReadv", [ + 7n, + BigInt(iovPtr), + BigInt(entries.length), + 0n, + 0n, + 0n, + ]); + + expectScratchTailUntouched(harness); + }); + + it.each([ + ["sendmsg", "handleSendmsg", ABI_SYSCALLS.Sendmsg], + ["recvmsg", "handleRecvmsg", ABI_SYSCALLS.Recvmsg], + ] as const)( + "rejects %s iovec counts above IOV_MAX before building a kernel table", + (_name, method, _syscallNr) => { + const harness = makeScratchHarness(); + const msgPtr = 128; + const iovPtr = 1024; + const view = new DataView(harness.processBytes.buffer); + view.setUint32(msgPtr + 8, iovPtr, true); + view.setUint32(msgPtr + 12, IOV_MAX + 1, true); + writeWasm32Iovecs( + harness.processBytes, + iovPtr, + Array.from({ length: IOV_MAX + 1 }, () => ({ base: 0, len: 0 })), + ); + + invokeIovecMethod(harness, method, [7, msgPtr, 0, 0, 0, 0]); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + EINVAL, + ); + expectScratchTailUntouched(harness); + }, + ); + + it.each([4, 8] as const)( + "bounds nested wasm%s sendmsg addresses at sockaddr_storage", + (pointerWidth) => { + const messagePointer = 128; + const namePointer = 4096; + for (const nameLength of [ + KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, + KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES + 1, + ]) { + const harness = makeScratchHarness(pointerWidth); + const expected = Uint8Array.from( + { length: nameLength }, + (_, index) => (index * 17 + 3) % 251, + ); + harness.processBytes.set(expected, namePointer); + writeNativeMessage(harness.processBytes, pointerWidth, messagePointer, { + namePointer, + nameLength, + }); + harness.handleChannel.mockImplementation(() => { + const channelView = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + const kernelMessagePointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + const kernelView = new DataView(harness.kernelBytes.buffer); + const kernelNamePointer = kernelView.getUint32( + kernelMessagePointer + KERNEL_MSGHDR_WIRE_NAME_OFFSET, + true, + ); + expect( + kernelView.getUint32( + kernelMessagePointer + KERNEL_MSGHDR_WIRE_NAMELEN_OFFSET, + true, + ), + ).toBe(nameLength); + expect( + harness.kernelBytes.slice( + kernelNamePointer, + kernelNamePointer + nameLength, + ), + ).toEqual(expected); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + invokeIovecMethod(harness, "handleSendmsg", [ + 7, + messagePointer, + 0, + 0, + 0, + 0, + ]); + + const accepted = nameLength === KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES; + expect(harness.handleChannel).toHaveBeenCalledTimes(accepted ? 1 : 0); + if (!accepted) { + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + EINVAL, + ); + } + expectScratchTailUntouched(harness); + } + }, + ); + + it.each([4, 8] as const)( + "clamps nested wasm%s recvmsg address capacity to sockaddr_storage", + (pointerWidth) => { + const harness = makeScratchHarness(pointerWidth); + const messagePointer = 128; + const namePointer = + harness.channel.channelOffset - KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES; + const expected = Uint8Array.from( + { length: KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES }, + (_, index) => (index * 23 + 7) % 251, + ); + writeNativeMessage(harness.processBytes, pointerWidth, messagePointer, { + namePointer, + nameLength: 0xffff_ffff, + }); + harness.processBytes.fill( + 0x6d, + namePointer, + namePointer + KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, + ); + harness.handleChannel.mockImplementation(() => { + const channelView = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + const kernelMessagePointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + const kernelView = new DataView(harness.kernelBytes.buffer); + const kernelNamePointer = kernelView.getUint32( + kernelMessagePointer + KERNEL_MSGHDR_WIRE_NAME_OFFSET, + true, + ); + expect( + kernelView.getUint32( + kernelMessagePointer + KERNEL_MSGHDR_WIRE_NAMELEN_OFFSET, + true, + ), + ).toBe(KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES); + harness.kernelBytes.set(expected, kernelNamePointer); + kernelView.setUint32( + kernelMessagePointer + KERNEL_MSGHDR_WIRE_NAMELEN_OFFSET, + KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, + true, + ); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + invokeIovecMethod(harness, "handleRecvmsg", [ + 7, + messagePointer, + 0, + 0, + 0, + 0, + ]); + + expect(harness.handleChannel).toHaveBeenCalledOnce(); + expect( + harness.processBytes.slice( + namePointer, + namePointer + KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, + ), + ).toEqual(expected); + const messageView = new DataView(harness.processBytes.buffer); + expect( + messageView.getUint32( + messagePointer + + (pointerWidth === 8 + ? PROCESS_MSGHDR_WASM64_NAMELEN_OFFSET + : PROCESS_MSGHDR_WASM32_NAMELEN_OFFSET), + true, + ), + ).toBe(KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES); + expectScratchTailUntouched(harness); + }, + ); + + it.each([4, 8] as const)( + "rejects a wasm%s recvmsg address result larger than sockaddr_storage", + (pointerWidth) => { + const harness = makeScratchHarness(pointerWidth); + const messagePointer = 128; + const namePointer = 4096; + writeNativeMessage(harness.processBytes, pointerWidth, messagePointer, { + namePointer, + nameLength: KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, + }); + harness.processBytes.fill( + 0x6d, + namePointer, + namePointer + KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, + ); + const before = harness.processBytes.slice( + namePointer, + namePointer + KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, + ); + harness.handleChannel.mockImplementation(() => { + const channelView = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + const kernelMessagePointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + const kernelView = new DataView(harness.kernelBytes.buffer); + kernelView.setUint32( + kernelMessagePointer + KERNEL_MSGHDR_WIRE_NAMELEN_OFFSET, + KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES + 1, + true, + ); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + invokeIovecMethod(harness, "handleRecvmsg", [ + 7, + messagePointer, + 0, + 0, + 0, + 0, + ]); + + expect(harness.completeChannel).not.toHaveBeenCalled(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + EIO, + ); + expect( + harness.processBytes.slice( + namePointer, + namePointer + KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, + ), + ).toEqual(before); + expectScratchTailUntouched(harness); + }, + ); + + it.each([4, 8] as const)( + "ignores stale wasm%s sendmsg name length when msg_name is absent", + (pointerWidth) => { + const harness = makeScratchHarness(pointerWidth); + const messagePointer = 128; + writeNativeMessage(harness.processBytes, pointerWidth, messagePointer, { + namePointer: 0, + nameLength: 0xffff_ffff, + }); + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); + const kernelMessagePointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + const kernelView = new DataView(harness.kernelBytes.buffer); + expect( + kernelView.getUint32( + kernelMessagePointer + KERNEL_MSGHDR_WIRE_NAME_OFFSET, + true, + ), + ).toBe(0); + expect( + kernelView.getUint32( + kernelMessagePointer + KERNEL_MSGHDR_WIRE_NAMELEN_OFFSET, + true, + ), + ).toBe(0); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + invokeIovecMethod(harness, "handleSendmsg", [ + 7, + messagePointer, + 0, + 0, + 0, + 0, + ]); + + expect(harness.handleChannel).toHaveBeenCalledOnce(); + expectScratchTailUntouched(harness); + }, + ); + + it.each([4, 8] as const)( + "distinguishes absent and present zero-capacity wasm%s recvmsg names", (pointerWidth) => { - const iovPointer = 512; - for (const path of IOVEC_HANDLER_PATHS) { + for (const namePresent of [false, true]) { const harness = makeScratchHarness(pointerWidth); - harness.processBytes.fill(0x6d, 0, 16); - const addressZeroBefore = harness.processBytes.slice(0, 16); - writeNativeIovec( - harness.processBytes, - pointerWidth, - iovPointer, + const messagePointer = 128; + const namePointer = namePresent ? 4096 : 0; + const initialNameLength = namePresent ? 0 : 0xffff_ffff; + const nameCanary = 0x6d; + writeNativeMessage(harness.processBytes, pointerWidth, messagePointer, { + namePointer, + nameLength: initialNameLength, + }); + if (namePresent) { + harness.processBytes[namePointer] = nameCanary; + } + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); + const kernelMessagePointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + const kernelView = new DataView(harness.kernelBytes.buffer); + const stagedNamePointer = kernelView.getUint32( + kernelMessagePointer + KERNEL_MSGHDR_WIRE_NAME_OFFSET, + true, + ); + expect(stagedNamePointer === 0).toBe(!namePresent); + expect( + kernelView.getUint32( + kernelMessagePointer + KERNEL_MSGHDR_WIRE_NAMELEN_OFFSET, + true, + ), + ).toBe(0); + if (namePresent) { + expect(stagedNamePointer).toBe( + kernelMessagePointer + STRUCT_SIZE_KERNEL_MSGHDR_WIRE, + ); + kernelView.setUint32( + kernelMessagePointer + KERNEL_MSGHDR_WIRE_NAMELEN_OFFSET, + 2, + true, + ); + } + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + invokeIovecMethod(harness, "handleRecvmsg", [ + 7, + messagePointer, 0, 0, - ); - respondToSingleKernelIovec(harness, path, new Uint8Array(0)); - - invokeIovecHandler(harness, pointerWidth, path, iovPointer); + 0, + 0, + ]); - expect(harness.handleChannel, path.name).toHaveBeenCalledOnce(); + expect(harness.handleChannel).toHaveBeenCalledOnce(); + const nameLengthOffset = pointerWidth === 8 + ? PROCESS_MSGHDR_WASM64_NAMELEN_OFFSET + : PROCESS_MSGHDR_WASM32_NAMELEN_OFFSET; expect( - harness.completeChannel.mock.calls[0]?.slice(4, 6), - path.name, - ).toEqual([0, 0]); - expect(harness.processBytes.slice(0, 16), path.name) - .toEqual(addressZeroBefore); + new DataView(harness.processBytes.buffer).getUint32( + messagePointer + nameLengthOffset, + true, + ), + ).toBe(namePresent ? 2 : initialNameLength); + if (namePresent) { + expect(harness.processBytes[namePointer]).toBe(nameCanary); + } expectScratchTailUntouched(harness); } }, ); - it("subtracts the complete readv iovec table from data capacity", () => { - const harness = makeScratchHarness(); - const iovPtr = 256; - const destination = 24_576; - const entries = Array.from( - { length: IOV_MAX }, - (_, index) => ({ - base: destination, - len: index === IOV_MAX - 1 ? 56 : 64, - }), - ); - writeWasm32Iovecs(harness.processBytes, iovPtr, entries); - harness.handleChannel.mockImplementation(() => { - const view = new DataView( - harness.kernelBytes.buffer, - harness.scratchOffset, - ); - view.setBigInt64(CH_RETURN, 0n, true); - view.setUint32(CH_ERRNO, 0, true); - return 0; - }); - - harness.worker.handleReadv( - harness.channel, - ABI_SYSCALLS.Readv, - [7, iovPtr, entries.length, 0, 0, 0], - ); - - expectScratchTailUntouched(harness); - }); - - it.each([ - ["sendmsg", "handleSendmsg", ABI_SYSCALLS.Sendmsg], - ["recvmsg", "handleRecvmsg", ABI_SYSCALLS.Recvmsg], - ] as const)( - "rejects %s iovec counts above IOV_MAX before building a kernel table", - (_name, method, _syscallNr) => { - const harness = makeScratchHarness(); - const msgPtr = 128; - const iovPtr = 1024; - const view = new DataView(harness.processBytes.buffer); - view.setUint32(msgPtr + 8, iovPtr, true); - view.setUint32(msgPtr + 12, IOV_MAX + 1, true); - writeWasm32Iovecs( - harness.processBytes, - iovPtr, - Array.from({ length: IOV_MAX + 1 }, () => ({ base: 0, len: 0 })), - ); - - harness.worker[method]( - harness.channel, - [7, msgPtr, 0, 0, 0, 0], - ); - - expect(harness.handleChannel).not.toHaveBeenCalled(); - expect(harness.completeChannelRaw).toHaveBeenCalledWith( - harness.channel, - -1, - EINVAL, - ); - expectScratchTailUntouched(harness); - }, - ); - it.each([ ["sendmsg", "handleSendmsg", true], ["recvmsg", "handleRecvmsg", false], ] as const)( - "accepts an exact-capacity one-entry %s layout and rejects capacity plus one", + "uses the ordinary exact-capacity %s layout and a token-owned capacity+1 layout", (_name, method, input) => { - const exactDataCapacity = CH_DATA_SIZE - + const exactDataCapacity = + CH_DATA_SIZE - STRUCT_SIZE_KERNEL_MSGHDR_WIRE - STRUCT_SIZE_KERNEL_IOVEC_WIRE; expect(exactDataCapacity).toBe(65_500); @@ -1906,22 +2586,58 @@ describe("kernel scratch transfer capacity regressions", () => { if (input) { harness.processBytes.set(payload, dataPointer); } else { - harness.processBytes.fill( - 0x6d, - dataPointer, - dataPointer + length, - ); + harness.processBytes.fill(0x6d, dataPointer, dataPointer + length); } harness.processBytes[dataPointer + length] = callerCanary; - const scratchBeforeRejection = harness.kernelBytes.slice( + const mainScratchBefore = harness.kernelBytes.slice( harness.scratchOffset, harness.scratchEnd, ); + const totalCapacity = alignUp( + CH_DATA + + STRUCT_SIZE_KERNEL_MSGHDR_WIRE + + STRUCT_SIZE_KERNEL_IOVEC_WIRE + + length, + 8, + ); + const reserved = length > exactDataCapacity; + const expectedChannelBase = reserved + ? harness.transferOffset + : harness.scratchOffset; + const transferCanary = 0xc7; + if (reserved) { + harness.kernelBytes.fill( + transferCanary, + harness.transferOffset + totalCapacity, + harness.transferOffset + totalCapacity + 16, + ); + } + const begin = vi.fn( + harness.kernelExports.kernel_transfer_scratch_begin as ( + minimumCapacity: number | bigint, + ) => bigint, + ); + const executeReserved = vi.fn( + harness.kernelExports.kernel_transfer_channel_execute as ( + pid: number, + tid: number, + token: bigint, + retryToken: bigint, + ) => number, + ); + const cancel = vi.fn( + harness.kernelExports.kernel_transfer_scratch_cancel as ( + token: bigint, + ) => number, + ); + harness.kernelExports.kernel_transfer_scratch_begin = begin; + harness.kernelExports.kernel_transfer_channel_execute = executeReserved; + harness.kernelExports.kernel_transfer_scratch_cancel = cancel; harness.handleChannel.mockImplementation((offset: number | bigint) => { const channelView = new DataView( harness.kernelBytes.buffer, - offset, + Number(offset), ); const kernelMessagePointer = Number( channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), @@ -1935,20 +2651,25 @@ describe("kernel scratch transfer capacity regressions", () => { kernelIovecPointer, true, ); - expect(kernelMessagePointer).toBe( - harness.scratchOffset + CH_DATA, - ); + expect(kernelMessagePointer).toBe(expectedChannelBase + CH_DATA); expect(kernelIovecPointer).toBe( kernelMessagePointer + STRUCT_SIZE_KERNEL_MSGHDR_WIRE, ); - expect( - kernelView.getUint32(kernelMessagePointer + 12, true), - ).toBe(1); - expect( - kernelView.getUint32(kernelIovecPointer + 4, true), - ).toBe(length); + expect(kernelView.getUint32(kernelMessagePointer + 12, true)).toBe(1); + expect(kernelView.getUint32(kernelIovecPointer + 4, true)).toBe( + length, + ); expect(kernelDataPointer).toBe(kernelIovecPointer + 8); - expect(kernelDataPointer + length).toBe(harness.scratchEnd); + expect(kernelDataPointer + length).toBe( + expectedChannelBase + + CH_DATA + + STRUCT_SIZE_KERNEL_MSGHDR_WIRE + + STRUCT_SIZE_KERNEL_IOVEC_WIRE + + length, + ); + expect(kernelDataPointer + length).toBeLessThanOrEqual( + expectedChannelBase + totalCapacity, + ); if (input) { expect( harness.kernelBytes.slice( @@ -1964,39 +2685,40 @@ describe("kernel scratch transfer capacity regressions", () => { return 0; }); - harness.worker[method]( - harness.channel, - [7, messagePointer, 0, 0, 0, 0], - ); + invokeIovecMethod(harness, method, [7, messagePointer, 0, 0, 0, 0]); - if (length === exactDataCapacity) { - expect(harness.handleChannel).toHaveBeenCalledOnce(); - expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)) - .toEqual([length, 0]); - if (!input) { - expect( - harness.processBytes.slice( - dataPointer, - dataPointer + length, - ), - ).toEqual(payload); - } - } else { - expect(harness.handleChannel).not.toHaveBeenCalled(); - expect(harness.completeChannelRaw).toHaveBeenCalledWith( - harness.channel, - -1, - 90, - ); + expect(harness.handleChannel).toHaveBeenCalledOnce(); + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ + length, + 0, + ]); + expect(begin).toHaveBeenCalledTimes(reserved ? 1 : 0); + expect(executeReserved).toHaveBeenCalledTimes(reserved ? 1 : 0); + expect(cancel).toHaveBeenCalledTimes(reserved ? 1 : 0); + if (reserved) { + expect(executeReserved.mock.calls[0]).toHaveLength(4); + expect(executeReserved.mock.calls[0]?.[3]).toBe(0n); + } + if (!input) { + expect( + harness.processBytes.slice(dataPointer, dataPointer + length), + ).toEqual(payload); + } + if (reserved) { expect( harness.kernelBytes.slice( harness.scratchOffset, harness.scratchEnd, ), - ).toEqual(scratchBeforeRejection); + ).toEqual(mainScratchBefore); + expect( + harness.kernelBytes.slice( + harness.transferOffset + totalCapacity, + harness.transferOffset + totalCapacity + 16, + ), + ).toEqual(new Uint8Array(16).fill(transferCanary)); } - expect(harness.processBytes[dataPointer + length]) - .toBe(callerCanary); + expect(harness.processBytes[dataPointer + length]).toBe(callerCanary); expectScratchTailUntouched(harness); } }, @@ -2020,10 +2742,7 @@ describe("kernel scratch transfer capacity regressions", () => { Array.from({ length: IOV_MAX }, () => ({ base: 0, len: 0 })), ); harness.handleChannel.mockImplementation((offset: number | bigint) => { - const channelView = new DataView( - harness.kernelBytes.buffer, - offset, - ); + const channelView = new DataView(harness.kernelBytes.buffer, offset); const kernelMessagePointer = Number( channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), ); @@ -2032,9 +2751,7 @@ describe("kernel scratch transfer capacity regressions", () => { kernelMessagePointer + 8, true, ); - expect(kernelMessagePointer).toBe( - harness.scratchOffset + CH_DATA, - ); + expect(kernelMessagePointer).toBe(harness.scratchOffset + CH_DATA); expect(kernelIovecPointer).toBe( kernelMessagePointer + STRUCT_SIZE_KERNEL_MSGHDR_WIRE, ); @@ -2058,14 +2775,12 @@ describe("kernel scratch transfer capacity regressions", () => { return 0; }); - harness.worker[method]( - harness.channel, - [7, messagePointer, 0, 0, 0, 0], - ); + invokeIovecMethod(harness, method, [7, messagePointer, 0, 0, 0, 0]); expect(harness.handleChannel).toHaveBeenCalledOnce(); - expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)) - .toEqual([0, 0]); + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ + 0, 0, + ]); expectScratchTailUntouched(harness); }, ); @@ -2106,12 +2821,10 @@ describe("kernel scratch transfer capacity regressions", () => { secondPointer, 3, ); - writeNativeMessage( - harness.processBytes, - pointerWidth, - messagePointer, - { iovecPointer, iovecCount: 3 }, - ); + writeNativeMessage(harness.processBytes, pointerWidth, messagePointer, { + iovecPointer, + iovecCount: 3, + }); if (input) { harness.processBytes.set(payload.subarray(0, 2), firstPointer); harness.processBytes.set(payload.subarray(2), secondPointer); @@ -2123,7 +2836,10 @@ describe("kernel scratch transfer capacity regressions", () => { harness.processBytes[secondPointer + 3] = 0x92; harness.handleChannel.mockImplementation((offset: number | bigint) => { - const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); const kernelMessagePointer = Number( channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), ); @@ -2163,14 +2879,13 @@ describe("kernel scratch transfer capacity regressions", () => { return 0; }); - harness.worker[method]( - harness.channel, - [7, messagePointer, 0, 0, 0, 0], - ); + invokeIovecMethod(harness, method, [7, messagePointer, 0, 0, 0, 0]); expect(harness.handleChannel).toHaveBeenCalledOnce(); - expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)) - .toEqual([payload.length, 0]); + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ + payload.length, + 0, + ]); if (!input) { expect( harness.processBytes.slice(firstPointer, firstPointer + 2), @@ -2201,16 +2916,13 @@ describe("kernel scratch transfer capacity regressions", () => { writeWasm32Iovecs( harness.processBytes, iovPtr, - Array.from( - { length: countThatFillsTheDataArea }, - () => ({ base: 0, len: 0 }), - ), + Array.from({ length: countThatFillsTheDataArea }, () => ({ + base: 0, + len: 0, + })), ); - harness.worker[method]( - harness.channel, - [7, msgPtr, 0, 0, 0, 0], - ); + invokeIovecMethod(harness, method, [7, msgPtr, 0, 0, 0, 0]); expect(harness.handleChannel).not.toHaveBeenCalled(); expectScratchTailUntouched(harness); @@ -2226,11 +2938,9 @@ describe("kernel scratch transfer capacity regressions", () => { const view = new DataView(harness.processBytes.buffer); view.setUint32(msgPtr + 8, iovPtr, true); view.setUint32(msgPtr + 12, 1, true); - writeWasm32Iovecs( - harness.processBytes, - iovPtr, - [{ base: destination, len: 4 }], - ); + writeWasm32Iovecs(harness.processBytes, iovPtr, [ + { base: destination, len: 4 }, + ]); harness.handleChannel.mockImplementation(() => { const channelView = new DataView( harness.kernelBytes.buffer, @@ -2244,20 +2954,24 @@ describe("kernel scratch transfer capacity regressions", () => { kernelMessagePointer + 8, true, ); - const kernelDataPointer = kernelView.getUint32( - kernelIovecPointer, - true, + const kernelDataPointer = kernelView.getUint32(kernelIovecPointer, true); + harness.kernelBytes.set( + new TextEncoder().encode("recv"), + kernelDataPointer, ); - harness.kernelBytes.set(new TextEncoder().encode("recv"), kernelDataPointer); channelView.setBigInt64(CH_RETURN, BigInt(payloadLength), true); channelView.setUint32(CH_ERRNO, 0, true); return 0; }); - harness.worker.handleRecvmsg( - harness.channel, - [7, msgPtr, SOCKET_MSG_TRUNC, 0, 0, 0], - ); + invokeIovecMethod(harness, "handleRecvmsg", [ + 7, + msgPtr, + SOCKET_MSG_TRUNC, + 0, + 0, + 0, + ]); expect(harness.processBytes.slice(destination, destination + 4)).toEqual( new TextEncoder().encode("recv"), @@ -2287,11 +3001,9 @@ describe("kernel scratch transfer capacity regressions", () => { view.setUint32(msgPtr + 12, 1, true); view.setUint32(msgPtr + 16, controlPtr, true); view.setUint32(msgPtr + 20, 16, true); - writeWasm32Iovecs( - harness.processBytes, - iovPtr, - [{ base: dataPtr, len: 16 }], - ); + writeWasm32Iovecs(harness.processBytes, iovPtr, [ + { base: dataPtr, len: 16 }, + ]); harness.processBytes.fill(0x5a, dataPtr, dataPtr + 16); harness.processBytes.fill(0x6b, namePtr, namePtr + 16); harness.processBytes.fill(0x7c, controlPtr, controlPtr + 16); @@ -2305,24 +3017,30 @@ describe("kernel scratch transfer capacity regressions", () => { return 0; }); - harness.worker.handleRecvmsg( - harness.channel, - [7, msgPtr, 0, 0, 0, 0], - ); + invokeIovecMethod(harness, "handleRecvmsg", [7, msgPtr, 0, 0, 0, 0]); - expect(harness.processBytes.slice(dataPtr, dataPtr + 16)) - .toEqual(new Uint8Array(16).fill(0x5a)); - expect(harness.processBytes.slice(namePtr, namePtr + 16)) - .toEqual(new Uint8Array(16).fill(0x6b)); - expect(harness.processBytes.slice(controlPtr, controlPtr + 16)) - .toEqual(new Uint8Array(16).fill(0x7c)); + expect(harness.processBytes.slice(dataPtr, dataPtr + 16)).toEqual( + new Uint8Array(16).fill(0x5a), + ); + expect(harness.processBytes.slice(namePtr, namePtr + 16)).toEqual( + new Uint8Array(16).fill(0x6b), + ); + expect(harness.processBytes.slice(controlPtr, controlPtr + 16)).toEqual( + new Uint8Array(16).fill(0x7c), + ); expect(view.getUint32(msgPtr + 4, true)).toBe(16); expect(view.getUint32(msgPtr + 20, true)).toBe(16); - expect(harness.worker.handleBlockingRetry).toHaveBeenCalledWith( + expect(harness.handleBlockingRetry).toHaveBeenCalledWith( harness.channel, ABI_SYSCALLS.Recvmsg, [7, msgPtr, 0, 0, 0, 0], ); + expect(harness.blockingRetryToken).toHaveBeenCalledWith( + harness.channel.pid, + harness.channel.pid, + ABI_SYSCALLS.Recvmsg, + ); + expect(harness.blockingRetryRelease).not.toHaveBeenCalled(); expectScratchTailUntouched(harness); }); @@ -2342,20 +3060,17 @@ describe("kernel scratch transfer capacity regressions", () => { true, ); view.setUint32( - controlPointer + secondRecordOffset + - PROCESS_CMSGHDR_WASM32_LEN_OFFSET, + controlPointer + secondRecordOffset + PROCESS_CMSGHDR_WASM32_LEN_OFFSET, 0xffff_fff8, true, ); view.setUint32( - controlPointer + secondRecordOffset + - PROCESS_CMSGHDR_WASM32_LEVEL_OFFSET, + controlPointer + secondRecordOffset + PROCESS_CMSGHDR_WASM32_LEVEL_OFFSET, SOCKET_SOL_SOCKET, true, ); view.setUint32( - controlPointer + secondRecordOffset + - PROCESS_CMSGHDR_WASM32_TYPE_OFFSET, + controlPointer + secondRecordOffset + PROCESS_CMSGHDR_WASM32_TYPE_OFFSET, SOCKET_SCM_RIGHTS, true, ); @@ -2368,10 +3083,14 @@ describe("kernel scratch transfer capacity regressions", () => { harness.scratchEnd, ); - harness.worker.handleSendmsg( - harness.channel, - [7, messagePointer, 0, 0, 0, 0], - ); + invokeIovecMethod(harness, "handleSendmsg", [ + 7, + messagePointer, + 0, + 0, + 0, + 0, + ]); expect(harness.handleChannel).not.toHaveBeenCalled(); expect(harness.completeChannelRaw).toHaveBeenCalledWith( @@ -2380,10 +3099,7 @@ describe("kernel scratch transfer capacity regressions", () => { EINVAL, ); expect( - harness.kernelBytes.slice( - harness.scratchOffset, - harness.scratchEnd, - ), + harness.kernelBytes.slice(harness.scratchOffset, harness.scratchEnd), ).toEqual(scratchBefore); expectScratchTailUntouched(harness); }); @@ -2394,14 +3110,14 @@ describe("kernel scratch transfer capacity regressions", () => { const harness = makeScratchHarness(pointerWidth); const messagePointer = 128; const controlPointer = 2048; - const calls = [ - [[17], [23, 24]], - [[31]], - ]; + const calls = [[[17], [23, 24]], [[31]]]; let callIndex = 0; harness.handleChannel.mockImplementation((offset: number | bigint) => { const expected = canonicalRightsBytes(calls[callIndex]); - const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); const kernelMessagePointer = Number( channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), ); @@ -2412,8 +3128,7 @@ describe("kernel scratch transfer capacity regressions", () => { ); expect( kernelView.getUint32( - kernelMessagePointer + - KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET, + kernelMessagePointer + KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET, true, ), ).toBe(expected.length); @@ -2442,12 +3157,10 @@ describe("kernel scratch transfer capacity regressions", () => { controlPointer, records, ); - writeNativeMessage( - harness.processBytes, - pointerWidth, - messagePointer, - { controlPointer, controlLength }, - ); + writeNativeMessage(harness.processBytes, pointerWidth, messagePointer, { + controlPointer, + controlLength, + }); if (pointerWidth === 8) { new DataView(harness.processBytes.buffer).setUint32( messagePointer + PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET + 4, @@ -2455,10 +3168,14 @@ describe("kernel scratch transfer capacity regressions", () => { true, ); } - harness.worker.handleSendmsg( - harness.channel, - [7, messagePointer, 0, 0, 0, 0], - ); + invokeIovecMethod(harness, "handleSendmsg", [ + 7, + messagePointer, + 0, + 0, + 0, + 0, + ]); } expect(harness.handleChannel).toHaveBeenCalledTimes(calls.length); @@ -2562,25 +3279,28 @@ describe("kernel scratch transfer capacity regressions", () => { const messagePointer = 128; const controlPointer = 2048; const controlCanary = 0x6d; - const native = pointerWidth === 8 - ? { - dataOffset: PROCESS_CMSGHDR_WASM64_DATA_OFFSET, - lengthOffset: PROCESS_CMSGHDR_WASM64_LEN_OFFSET, - levelOffset: PROCESS_CMSGHDR_WASM64_LEVEL_OFFSET, - typeOffset: PROCESS_CMSGHDR_WASM64_TYPE_OFFSET, - } - : { - dataOffset: PROCESS_CMSGHDR_WASM32_DATA_OFFSET, - lengthOffset: PROCESS_CMSGHDR_WASM32_LEN_OFFSET, - levelOffset: PROCESS_CMSGHDR_WASM32_LEVEL_OFFSET, - typeOffset: PROCESS_CMSGHDR_WASM32_TYPE_OFFSET, - }; - const messageControlLengthOffset = pointerWidth === 8 - ? PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET - : PROCESS_MSGHDR_WASM32_CONTROLLEN_OFFSET; - const messageFlagsOffset = pointerWidth === 8 - ? PROCESS_MSGHDR_WASM64_FLAGS_OFFSET - : PROCESS_MSGHDR_WASM32_FLAGS_OFFSET; + const native = + pointerWidth === 8 + ? { + dataOffset: PROCESS_CMSGHDR_WASM64_DATA_OFFSET, + lengthOffset: PROCESS_CMSGHDR_WASM64_LEN_OFFSET, + levelOffset: PROCESS_CMSGHDR_WASM64_LEVEL_OFFSET, + typeOffset: PROCESS_CMSGHDR_WASM64_TYPE_OFFSET, + } + : { + dataOffset: PROCESS_CMSGHDR_WASM32_DATA_OFFSET, + lengthOffset: PROCESS_CMSGHDR_WASM32_LEN_OFFSET, + levelOffset: PROCESS_CMSGHDR_WASM32_LEVEL_OFFSET, + typeOffset: PROCESS_CMSGHDR_WASM32_TYPE_OFFSET, + }; + const messageControlLengthOffset = + pointerWidth === 8 + ? PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET + : PROCESS_MSGHDR_WASM32_CONTROLLEN_OFFSET; + const messageFlagsOffset = + pointerWidth === 8 + ? PROCESS_MSGHDR_WASM64_FLAGS_OFFSET + : PROCESS_MSGHDR_WASM32_FLAGS_OFFSET; harness.processBytes.fill( controlCanary, controlPointer, @@ -2600,7 +3320,10 @@ describe("kernel scratch transfer capacity regressions", () => { } harness.handleChannel.mockImplementation((offset: number | bigint) => { - const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); const kernelMessagePointer = Number( channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), ); @@ -2611,21 +3334,20 @@ describe("kernel scratch transfer capacity regressions", () => { ); expect( kernelView.getUint32( - kernelMessagePointer + - KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET, + kernelMessagePointer + KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET, true, ), ).toBe(wireCapacity); expect(kernelControlPointer === 0).toBe(wireCapacity === 0); - const wire = descriptors.length > 0 - ? canonicalRightsBytes([descriptors]) - : new Uint8Array(0); + const wire = + descriptors.length > 0 + ? canonicalRightsBytes([descriptors]) + : new Uint8Array(0); if (wire.length > 0) { harness.kernelBytes.set(wire, kernelControlPointer); } kernelView.setUint32( - kernelMessagePointer + - KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET, + kernelMessagePointer + KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET, wire.length, true, ); @@ -2639,13 +3361,18 @@ describe("kernel scratch transfer capacity regressions", () => { return 0; }); - harness.worker.handleRecvmsg( - harness.channel, - [7, messagePointer, 0, 0, 0, 0], - ); + invokeIovecMethod(harness, "handleRecvmsg", [ + 7, + messagePointer, + 0, + 0, + 0, + 0, + ]); - expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)) - .toEqual([0, 0]); + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ + 0, 0, + ]); expect( processView.getUint32( messagePointer + messageControlLengthOffset, @@ -2661,38 +3388,23 @@ describe("kernel scratch transfer capacity regressions", () => { ).toBe(0xa5a5_a5a5); } expect( - processView.getUint32( - messagePointer + messageFlagsOffset, - true, - ), + processView.getUint32(messagePointer + messageFlagsOffset, true), ).toBe(flags); if (descriptors.length === 0) { expect( - harness.processBytes.slice( - controlPointer, - controlPointer + capacity, - ), + harness.processBytes.slice(controlPointer, controlPointer + capacity), ).toEqual(new Uint8Array(capacity).fill(controlCanary)); } else { - const nativeLength = native.dataOffset + - descriptors.length * SCM_RIGHTS_FD_BYTES; + const nativeLength = + native.dataOffset + descriptors.length * SCM_RIGHTS_FD_BYTES; expect( - processView.getUint32( - controlPointer + native.lengthOffset, - true, - ), + processView.getUint32(controlPointer + native.lengthOffset, true), ).toBe(nativeLength); expect( - processView.getUint32( - controlPointer + native.levelOffset, - true, - ), + processView.getUint32(controlPointer + native.levelOffset, true), ).toBe(SOCKET_SOL_SOCKET); expect( - processView.getUint32( - controlPointer + native.typeOffset, - true, - ), + processView.getUint32(controlPointer + native.typeOffset, true), ).toBe(SOCKET_SCM_RIGHTS); if (pointerWidth === 8) { expect( @@ -2705,8 +3417,7 @@ describe("kernel scratch transfer capacity regressions", () => { descriptors.forEach((descriptor, index) => { expect( processView.getInt32( - controlPointer + native.dataOffset + - index * SCM_RIGHTS_FD_BYTES, + controlPointer + native.dataOffset + index * SCM_RIGHTS_FD_BYTES, true, ), ).toBe(descriptor); @@ -2718,8 +3429,9 @@ describe("kernel scratch transfer capacity regressions", () => { ), ).toEqual(new Uint8Array(reportedLength - nativeLength)); } - expect(harness.processBytes[controlPointer + reportedLength]) - .toBe(controlCanary); + expect(harness.processBytes[controlPointer + reportedLength]).toBe( + controlCanary, + ); expectScratchTailUntouched(harness); }, ); @@ -2732,13 +3444,7 @@ describe("kernel scratch transfer capacity regressions", () => { const controlPointer = 2048; const dataPointer = 4096; const controlCapacity = 20; - writeNativeIovec( - harness.processBytes, - 8, - iovecPointer, - dataPointer, - 4, - ); + writeNativeIovec(harness.processBytes, 8, iovecPointer, dataPointer, 4); writeNativeMessage(harness.processBytes, 8, messagePointer, { namePointer, nameLength: 4, @@ -2767,7 +3473,10 @@ describe("kernel scratch transfer capacity regressions", () => { const dataBefore = harness.processBytes.slice(dataPointer, dataPointer + 4); harness.handleChannel.mockImplementation((offset: number | bigint) => { - const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); const kernelMessagePointer = Number( channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), ); @@ -2820,10 +3529,14 @@ describe("kernel scratch transfer capacity regressions", () => { return 0; }); - harness.worker.handleRecvmsg( - harness.channel, - [7, messagePointer, 0, 0, 0, 0], - ); + invokeIovecMethod(harness, "handleRecvmsg", [ + 7, + messagePointer, + 0, + 0, + 0, + 0, + ]); expect(harness.completeChannel).not.toHaveBeenCalled(); expect(harness.completeChannelRaw).toHaveBeenCalledWith( @@ -2837,16 +3550,18 @@ describe("kernel scratch transfer capacity regressions", () => { messagePointer + PROCESS_MSGHDR_WASM64_SIZE, ), ).toEqual(messageBefore); - expect(harness.processBytes.slice(namePointer, namePointer + 4)) - .toEqual(nameBefore); + expect(harness.processBytes.slice(namePointer, namePointer + 4)).toEqual( + nameBefore, + ); expect( harness.processBytes.slice( controlPointer, controlPointer + controlCapacity, ), ).toEqual(controlBefore); - expect(harness.processBytes.slice(dataPointer, dataPointer + 4)) - .toEqual(dataBefore); + expect(harness.processBytes.slice(dataPointer, dataPointer + 4)).toEqual( + dataBefore, + ); expectScratchTailUntouched(harness); }); @@ -2857,11 +3572,14 @@ describe("kernel scratch transfer capacity regressions", () => { view.setBigUint64(iovPtr, BigInt(Number.MAX_SAFE_INTEGER) + 1n, true); view.setBigUint64(iovPtr + 8, 1n, true); - harness.worker.handleWritev( - harness.channel, - ABI_SYSCALLS.Writev, - [7, iovPtr, 1, 0, 0, 0], - ); + invokeIovecMethod(harness, "handleWritev", [ + 7n, + BigInt(iovPtr), + 1n, + 0n, + 0n, + 0n, + ]); expect(harness.handleChannel).not.toHaveBeenCalled(); expect(harness.completeChannelRaw).toHaveBeenCalledWith( @@ -2881,10 +3599,7 @@ describe("kernel scratch transfer capacity regressions", () => { const harness = makeScratchHarness(8); const msgPtr = harness.processBytes.byteLength - 48; - harness.worker[method]( - harness.channel, - [7, msgPtr, 0, 0, 0, 0], - ); + invokeIovecMethod(harness, method, [7, msgPtr, 0, 0, 0, 0]); expect(harness.handleChannel).not.toHaveBeenCalled(); expect(harness.completeChannelRaw).toHaveBeenCalledWith( @@ -2923,27 +3638,17 @@ describe("kernel scratch transfer capacity regressions", () => { kernelMessagePointer, STRUCT_SIZE_KERNEL_MSGHDR_WIRE, ); - kernelMessage.setUint32( - KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET, - 0, - true, - ); + kernelMessage.setUint32(KERNEL_MSGHDR_WIRE_CONTROLLEN_OFFSET, 0, true); kernelMessage.setUint32(KERNEL_MSGHDR_WIRE_FLAGS_OFFSET, 0x40, true); channelView.setBigInt64(CH_RETURN, 0n, true); channelView.setUint32(CH_ERRNO, 0, true); return 0; }); - harness.worker.handleRecvmsg( - harness.channel, - [7, msgPtr, 0, 0, 0, 0], - ); + invokeIovecMethod(harness, "handleRecvmsg", [7, msgPtr, 0, 0, 0, 0]); expect( - view.getUint32( - msgPtr + PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET, - true, - ), + view.getUint32(msgPtr + PROCESS_MSGHDR_WASM64_CONTROLLEN_OFFSET, true), ).toBe(0); expect( view.getUint32( @@ -2965,16 +3670,14 @@ describe("kernel scratch transfer capacity regressions", () => { (_name, method, syscallNr) => { const harness = makeScratchHarness(); const iovPtr = 256; - writeWasm32Iovecs(harness.processBytes, iovPtr, [{ - base: harness.processBytes.byteLength - 8, - len: CH_DATA_SIZE + 1, - }]); + writeWasm32Iovecs(harness.processBytes, iovPtr, [ + { + base: harness.processBytes.byteLength - 8, + len: CH_DATA_SIZE + 1, + }, + ]); - harness.worker[method]( - harness.channel, - syscallNr, - [7, iovPtr, 1, 0, 0, 0], - ); + invokeIovecMethod(harness, method, [7, iovPtr, 1, 0, 0, 0]); expect(harness.handleChannel).not.toHaveBeenCalled(); expect(harness.completeChannelRaw).toHaveBeenCalledWith( @@ -2986,199 +3689,175 @@ describe("kernel scratch transfer capacity regressions", () => { }, ); - it("preserves an unsigned low offset word in the preadv slow path", () => { - const harness = makeScratchHarness(); - const iovPtr = 256; - const destination = 65_536; - writeWasm32Iovecs(harness.processBytes, iovPtr, [{ - base: destination, - len: CH_DATA_SIZE + 1, - }]); - const offsets: Array<{ low: number; high: number }> = []; - harness.handleChannel.mockImplementation(() => { - const view = new DataView( - harness.kernelBytes.buffer, - harness.scratchOffset, + it.each([ + ["wasm32", "EOF", 4, 0], + ["wasm32", "a short read", 4, 3], + ["wasm64", "EOF", 8, 0], + ["wasm64", "a short read", 8, 3], + ] as const)( + "%s readv performs one reserved operation for %s", + (_widthName, _resultName, pointerWidth, returned) => { + const harness = makeScratchHarness(pointerWidth); + useTransferScratchInstance(harness); + const iovPointer = 256; + const firstDestination = 65_536; + const secondDestination = 196_608; + const firstLength = CH_DATA_SIZE + 1; + writeNativeIovec( + harness.processBytes, + pointerWidth, + iovPointer, + firstDestination, + firstLength, ); - offsets.push({ - low: Number(BigInt.asUintN( - 32, - view.getBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, true), - )), - high: Number(view.getBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, true)), - }); - view.setBigInt64(CH_RETURN, 0n, true); - view.setUint32(CH_ERRNO, 0, true); - return 0; - }); + writeNativeIovec( + harness.processBytes, + pointerWidth, + iovPointer + 2 * pointerWidth, + secondDestination, + 4, + ); + const payload = Uint8Array.of(0x21, 0x43, 0x65); + const execute = vi.fn( + ( + _pid: number, + _tid: number, + _token: bigint, + offered: number | bigint, + originalSyscall: number, + _fd: number, + _offset: bigint, + retryToken: bigint, + ) => { + expect(Number(offered)).toBe(firstLength + 4); + expect(originalSyscall).toBe(ABI_SYSCALLS.Readv); + expect(retryToken).toBe(0n); + if (returned > 0) { + harness.kernelBytes.set( + payload.subarray(0, returned), + harness.transferOffset, + ); + } + return returned; + }, + ); + harness.kernelExports.kernel_transfer_io_execute = execute; - harness.worker.handleReadv( - harness.channel, - ABI_SYSCALLS.Preadv, - [7, iovPtr, 1, 0x8000_0000, 0, 0], - ); + invokeIovecMethod(harness, "handleReadv", [ + 7n, + BigInt(iovPointer), + 2n, + 0n, + 0n, + 0n, + ]); - expect(offsets).toEqual([{ low: 0x8000_0000, high: 0 }]); - expectScratchTailUntouched(harness); - }); + expect(execute).toHaveBeenCalledOnce(); + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ + returned, + 0, + ]); + expect( + harness.processBytes.slice( + firstDestination, + firstDestination + returned, + ), + ).toEqual(payload.subarray(0, returned)); + expect( + harness.processBytes.slice(secondDestination, secondDestination + 4), + ).toEqual(new Uint8Array(4)); + expectScratchTailUntouched(harness); + }, + ); it.each([ - ["pwritev", "handleWritev", ABI_SYSCALLS.Pwritev], - ["preadv", "handleReadv", ABI_SYSCALLS.Preadv], + ["pwritev", "handleWritev", ABI_SYSCALLS.Pwritev, false], + ["preadv", "handleReadv", ABI_SYSCALLS.Preadv, true], + ["pwritev2", "handleWritev", ABI_SYSCALLS.Pwritev2, false], + ["preadv2", "handleReadv", ABI_SYSCALLS.Preadv2, true], ] as const)( - "preserves a %s slow-path offset above Number.MAX_SAFE_INTEGER", - (_name, method, syscallNr) => { - const harness = makeScratchHarness(); - const iovPtr = 256; + "%s preserves one offset above Number.MAX_SAFE_INTEGER in reserved I/O", + (_name, method, syscallNr, readOperation) => { + const harness = makeScratchHarness(8); + useTransferScratchInstance(harness); + const iovPointer = 256; const buffer = 65_536; - writeWasm32Iovecs(harness.processBytes, iovPtr, [{ - base: buffer, - len: CH_DATA_SIZE + 1, - }]); - const offsets: bigint[] = []; - harness.handleChannel.mockImplementation(() => { - const channelView = new DataView( - harness.kernelBytes.buffer, - harness.scratchOffset, - ); - const low = channelView.getBigInt64( - CH_ARGS + 3 * CH_ARG_SIZE, - true, - ); - const high = channelView.getBigInt64( - CH_ARGS + 4 * CH_ARG_SIZE, - true, - ); - offsets.push((high << 32n) | BigInt.asUintN(32, low)); - const kernelIovec = Number( - channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), - ); - const len = new DataView(harness.kernelBytes.buffer).getUint32( - kernelIovec + 4, - true, - ); - channelView.setBigInt64(CH_RETURN, BigInt(len), true); - channelView.setUint32(CH_ERRNO, 0, true); - return 0; - }); - - harness.worker[method]( - harness.channel, - syscallNr, - [7, iovPtr, 1, 1, 0x0020_0000, 0], + const length = CH_DATA_SIZE + 1; + const offset = 9_007_199_254_740_993n; + writeNativeIovec(harness.processBytes, 8, iovPointer, buffer, length); + if (!readOperation) { + harness.processBytes.fill(0x5a, buffer, buffer + length); + } + const observed: Array<{ + syscall: number; + offset: bigint; + }> = []; + const execute = vi.fn( + ( + _pid: number, + _tid: number, + _token: bigint, + offered: bigint, + originalSyscall: number, + _fd: number, + exactOffset: bigint, + retryToken: bigint, + ) => { + observed.push({ + syscall: originalSyscall, + offset: exactOffset, + }); + expect(Number(offered)).toBe(length); + expect(retryToken).toBe(0n); + return 0; + }, ); + harness.kernelExports.kernel_transfer_io_execute = execute; + + writeChannelSyscall(harness, syscallNr, [ + 7n, + BigInt(iovPointer), + 1n, + offset, + offset >> 32n, + 0x4000_0000n, + ]); + prepareGenericSyscallHarness(harness, 8); + dispatchScratchBoundarySyscall(harness); - const initialOffset = 9_007_199_254_740_993n; - expect(offsets).toEqual([ - initialOffset, - initialOffset + BigInt(CH_DATA_SIZE - 8), + expect(observed).toEqual([ + { + syscall: syscallNr, + offset, + }, ]); + expect(execute.mock.calls[0]).toHaveLength(8); expectScratchTailUntouched(harness); }, ); - it("normalizes the wasm64 preadv low word before Number conversion", () => { - const harness = makeScratchHarness(8); - prepareGenericSyscallHarness(harness, 8); - const iovPtr = 256; - const destination = 65_536; - writeNativeIovec( - harness.processBytes, - 8, - iovPtr, - destination, - CH_DATA_SIZE + 1, - ); - const offsets: bigint[] = []; - harness.handleChannel.mockImplementation(() => { - const channelView = new DataView( - harness.kernelBytes.buffer, - harness.scratchOffset, - ); - const low = channelView.getBigInt64( - CH_ARGS + 3 * CH_ARG_SIZE, - true, - ); - const high = channelView.getBigInt64( - CH_ARGS + 4 * CH_ARG_SIZE, - true, - ); - offsets.push((high << 32n) | BigInt.asUintN(32, low)); - channelView.setBigInt64(CH_RETURN, 0n, true); - channelView.setUint32(CH_ERRNO, 0, true); - return 0; - }); - const offset = 9_007_199_254_740_993n; - writeChannelSyscall(harness, ABI_SYSCALLS.Preadv, [ - 7n, - BigInt(iovPtr), - 1n, - offset, - offset >> 32n, - ]); - - harness.worker._handleSyscallInner(harness.channel); - - expect(offsets).toEqual([offset]); - expectScratchTailUntouched(harness); - }); - - it("rejects a writev result larger than the staged caller data", () => { - const harness = makeScratchHarness(); - const iovPtr = 256; - const source = 1024; - writeWasm32Iovecs(harness.processBytes, iovPtr, [{ - base: source, - len: 4, - }]); - harness.processBytes.set([1, 2, 3, 4], source); - harness.handleChannel.mockImplementation(() => { - const view = new DataView( - harness.kernelBytes.buffer, - harness.scratchOffset, - ); - view.setBigInt64(CH_RETURN, 5n, true); - view.setUint32(CH_ERRNO, 0, true); - return 0; - }); - - harness.worker.handleWritev( - harness.channel, - ABI_SYSCALLS.Writev, - [7, iovPtr, 1, 0, 0, 0], - ); - - expect(harness.completeChannel).toHaveBeenCalledWith( - harness.channel, - ABI_SYSCALLS.Writev, - [7, iovPtr, 1, 0, 0, 0], - undefined, - -1, - EIO, - ); - expectScratchTailUntouched(harness); - }); - it.each([ - ["pwritev2", "handleWritev", ABI_SYSCALLS.Pwritev2], - ["preadv2", "handleReadv", ABI_SYSCALLS.Preadv2], + ["pwritev2", "handleWritev", ABI_SYSCALLS.Pwritev2, ABI_SYSCALLS.Pwrite], + ["preadv2", "handleReadv", ABI_SYSCALLS.Preadv2, ABI_SYSCALLS.Pread], ] as const)( - "preserves %s offset words and flags on the fast path", - (_name, method, syscallNr) => { + "%s uses one scalar channel call and keeps the documented ignored-flags behavior", + (_name, method, syscallNr, scalarSyscall) => { const harness = makeScratchHarness(); - const iovPtr = 256; + const iovPointer = 256; const buffer = 1024; - const flags = 0x8000_0000; - writeWasm32Iovecs(harness.processBytes, iovPtr, [{ - base: buffer, - len: 4, - }]); + const offset = 0x0000_0001_8000_0000n; + writeWasm32Iovecs(harness.processBytes, iovPointer, [ + { + base: buffer, + len: 4, + }, + ]); harness.processBytes.set([1, 2, 3, 4], buffer); const calls: Array<{ syscall: number; - low: bigint; - high: bigint; - flags: bigint; + offset: bigint; + unusedFlagsSlot: bigint; }> = []; harness.handleChannel.mockImplementation(() => { const view = new DataView( @@ -3187,81 +3866,72 @@ describe("kernel scratch transfer capacity regressions", () => { ); calls.push({ syscall: view.getUint32(CH_SYSCALL, true), - low: view.getBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, true), - high: view.getBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, true), - flags: view.getBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, true), + offset: view.getBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, true), + unusedFlagsSlot: view.getBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, true), }); - view.setBigInt64(CH_RETURN, 4n, true); + view.setBigInt64(CH_RETURN, 0n, true); view.setUint32(CH_ERRNO, 0, true); return 0; }); - harness.worker[method]( - harness.channel, + invokeIovecMethod( + harness, + method, + [7n, BigInt(iovPointer), 1n, 0x8000_0000n, 1n, 0x4000_0000n], syscallNr, - [7, iovPtr, 1, 0x8000_0000, 1, flags], ); - expect(calls).toEqual([{ - syscall: syscallNr, - low: 0x8000_0000n, - high: 1n, - flags: BigInt(flags), - }]); + expect(calls).toEqual([ + { + syscall: scalarSyscall, + offset, + unusedFlagsSlot: 0n, + }, + ]); expectScratchTailUntouched(harness); }, ); - it.each([ - ["pwritev2", "handleWritev", ABI_SYSCALLS.Pwritev2], - ["preadv2", "handleReadv", ABI_SYSCALLS.Preadv2], - ] as const)( - "preserves %s flags across every capacity-bounded slow-path chunk", - (_name, method, syscallNr) => { - const harness = makeScratchHarness(); - const iovPtr = 256; - const buffer = 65_536; - const flags = 0x4000_0000; - writeWasm32Iovecs(harness.processBytes, iovPtr, [{ - base: buffer, - len: CH_DATA_SIZE + 1, - }]); - const calls: Array<{ syscall: number; flags: bigint }> = []; - harness.handleChannel.mockImplementation(() => { - const view = new DataView( - harness.kernelBytes.buffer, - harness.scratchOffset, - ); - calls.push({ - syscall: view.getUint32(CH_SYSCALL, true), - flags: view.getBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, true), - }); - const kernelIovec = Number( - view.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), - ); - const len = new DataView(harness.kernelBytes.buffer).getUint32( - kernelIovec + 4, - true, - ); - view.setBigInt64(CH_RETURN, BigInt(len), true); - view.setUint32(CH_ERRNO, 0, true); - return 0; - }); - - harness.worker[method]( - harness.channel, - syscallNr, - [7, iovPtr, 1, 0, 0, flags], + it("rejects a writev result larger than the staged caller data", () => { + const harness = makeScratchHarness(); + const iovPtr = 256; + const source = 1024; + writeWasm32Iovecs(harness.processBytes, iovPtr, [ + { + base: source, + len: 4, + }, + ]); + harness.processBytes.set([1, 2, 3, 4], source); + harness.handleChannel.mockImplementation(() => { + const view = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, ); + view.setBigInt64(CH_RETURN, 5n, true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }); - expect(calls.length).toBeGreaterThan(1); - expect(calls).toEqual(calls.map(() => ({ - syscall: syscallNr, - flags: BigInt(flags), - }))); - expectScratchTailUntouched(harness); - }, - ); + invokeIovecMethod(harness, "handleWritev", [ + 7n, + BigInt(iovPtr), + 1n, + 0n, + 0n, + 0n, + ]); + + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + ABI_SYSCALLS.Writev, + [7, iovPtr, 1, 0, 0, 0], + undefined, + -1, + EIO, + ); + expectScratchTailUntouched(harness); + }); it.each([ ["wasm32", 4, "pwrite", ABI_SYSCALLS.Pwrite], @@ -3281,21 +3951,14 @@ describe("kernel scratch transfer capacity regressions", () => { harness.kernelBytes.buffer, harness.scratchOffset, ); - offsets.push( - channelView.getBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, true), - ); + offsets.push(channelView.getBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, true)); channelView.setBigInt64(CH_RETURN, 0n, true); channelView.setUint32(CH_ERRNO, 0, true); return 0; }); - writeChannelSyscall(harness, syscallNr, [ - 7n, - BigInt(buffer), - 4n, - offset, - ]); + writeChannelSyscall(harness, syscallNr, [7n, BigInt(buffer), 4n, offset]); - harness.worker._handleSyscallInner(harness.channel); + dispatchScratchBoundarySyscall(harness); expect(offsets).toEqual([offset]); expectScratchTailUntouched(harness); @@ -3303,66 +3966,85 @@ describe("kernel scratch transfer capacity regressions", () => { ); it.each([ - ["wasm32", 4, "pwrite", ABI_SYSCALLS.Pwrite], - ["wasm32", 4, "pread", ABI_SYSCALLS.Pread], - ["wasm64", 8, "pwrite", ABI_SYSCALLS.Pwrite], - ["wasm64", 8, "pread", ABI_SYSCALLS.Pread], + ["wasm32", 4, "write", ABI_SYSCALLS.Write, false, null], + ["wasm32", 4, "read", ABI_SYSCALLS.Read, true, null], + ["wasm32", 4, "pwrite", ABI_SYSCALLS.Pwrite, false, 9_007_199_254_740_993n], + ["wasm32", 4, "pread", ABI_SYSCALLS.Pread, true, 9_007_199_254_740_993n], + ["wasm64", 8, "write", ABI_SYSCALLS.Write, false, null], + ["wasm64", 8, "read", ABI_SYSCALLS.Read, true, null], + ["wasm64", 8, "pwrite", ABI_SYSCALLS.Pwrite, false, 9_007_199_254_740_993n], + ["wasm64", 8, "pread", ABI_SYSCALLS.Pread, true, 9_007_199_254_740_993n], ] as const)( - "preserves and increments a %s large %s offset above Number.MAX_SAFE_INTEGER", - (_widthName, pointerWidth, _name, syscallNr) => { + "%s large %s uses one reservation and preserves its exact offset", + (_widthName, pointerWidth, _name, syscallNr, readOperation, offset) => { const harness = makeScratchHarness(pointerWidth); prepareGenericSyscallHarness(harness, pointerWidth); + useTransferScratchInstance(harness); const buffer = 65_536; - const totalLength = CH_DATA_SIZE + 1; - const initialOffset = 9_007_199_254_740_993n; - const preparedOffsets: bigint[] = []; - Object.assign(harness.worker.kernelInstance.exports, { - kernel_prepare_write_operation: vi.fn(( - _pid: number, + const length = CH_DATA_SIZE + 1; + const payload = Uint8Array.from( + { length }, + (_, index) => (index * 29 + 5) % 251, + ); + if (!readOperation) { + harness.processBytes.set(payload, buffer); + } + const returned = readOperation ? 3 : length; + const execute = vi.fn( + ( + pid: number, _tid: number, + _token: bigint, + offered: number | bigint, + originalSyscall: number, _fd: number, - offset: bigint, - len: number, + exactOffset: bigint, + retryToken: bigint, ) => { - preparedOffsets.push(offset); - return BigInt(len); - }), - }); - const offsets: bigint[] = []; - harness.handleChannel.mockImplementation(() => { - const channelView = new DataView( - harness.kernelBytes.buffer, - harness.scratchOffset, - ); - offsets.push( - channelView.getBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, true), - ); - const chunkLength = Number( - channelView.getBigInt64( - CH_ARGS + 2 * CH_ARG_SIZE, - true, - ), - ); - channelView.setBigInt64(CH_RETURN, BigInt(chunkLength), true); - channelView.setUint32(CH_ERRNO, 0, true); - return 0; - }); + expect(pid).toBe(harness.channel.pid); + expect(harness.worker.currentHandlePid).toBe(harness.channel.pid); + expect(Number(offered)).toBe(length); + expect(originalSyscall).toBe(syscallNr); + expect(exactOffset).toBe(offset ?? 0n); + expect(retryToken).toBe(0n); + if (readOperation) { + harness.kernelBytes.set( + payload.subarray(0, returned), + harness.transferOffset, + ); + } else { + expect( + harness.kernelBytes.slice( + harness.transferOffset, + harness.transferOffset + length, + ), + ).toEqual(payload); + } + return returned; + }, + ); + harness.kernelExports.kernel_transfer_io_execute = execute; writeChannelSyscall(harness, syscallNr, [ 7n, BigInt(buffer), - BigInt(totalLength), - initialOffset, + BigInt(length), + offset ?? 0n, ]); - harness.worker._handleSyscallInner(harness.channel); + dispatchScratchBoundarySyscall(harness); - expect(offsets).toEqual([ - initialOffset, - initialOffset + BigInt(CH_DATA_SIZE), + expect(execute).toHaveBeenCalledOnce(); + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.worker.currentHandlePid).toBe(0); + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ + returned, + 0, ]); - expect(preparedOffsets).toEqual( - syscallNr === ABI_SYSCALLS.Pwrite ? [initialOffset] : [], - ); + if (readOperation) { + expect(harness.processBytes.slice(buffer, buffer + returned)).toEqual( + payload.subarray(0, returned), + ); + } expectScratchTailUntouched(harness); }, ); @@ -3371,12 +4053,14 @@ describe("kernel scratch transfer capacity regressions", () => { const harness = makeScratchHarness(); const source = harness.processBytes.byteLength - 8; - harness.worker.handleLargeWrite( - harness.channel, - ABI_SYSCALLS.Write, - [7, source, CH_DATA_SIZE + 1, 0, 0, 0], - [7n, BigInt(source), BigInt(CH_DATA_SIZE + 1), 0n, 0n, 0n], - ); + dispatchScratchBoundarySyscallWithArgs(harness, ABI_SYSCALLS.Write, [ + 7n, + BigInt(source), + BigInt(CH_DATA_SIZE + 1), + 0n, + 0n, + 0n, + ]); expect(harness.handleChannel).not.toHaveBeenCalled(); expect(harness.completeChannelRaw).toHaveBeenCalledWith( @@ -3391,12 +4075,14 @@ describe("kernel scratch transfer capacity regressions", () => { const harness = makeScratchHarness(); const destination = harness.processBytes.byteLength - 8; - harness.worker.handleLargeRead( - harness.channel, - ABI_SYSCALLS.Read, - [7, destination, CH_DATA_SIZE + 1, 0, 0, 0], - [7n, BigInt(destination), BigInt(CH_DATA_SIZE + 1), 0n, 0n, 0n], - ); + dispatchScratchBoundarySyscallWithArgs(harness, ABI_SYSCALLS.Read, [ + 7n, + BigInt(destination), + BigInt(CH_DATA_SIZE + 1), + 0n, + 0n, + 0n, + ]); expect(harness.handleChannel).not.toHaveBeenCalled(); expect(harness.completeChannelRaw).toHaveBeenCalledWith( @@ -3412,14 +4098,20 @@ describe("kernel scratch transfer capacity regressions", () => { ["pselect6", "handlePselect6", ABI_SYSCALLS.Pselect6], ] as const)( "rejects an out-of-range %s fd_set before copying it", - (_name, method, _syscall) => { + (_name, _method, syscall) => { const harness = makeScratchHarness(); const invalidSet = harness.processBytes.byteLength - 4; - expect(() => harness.worker[method]( - harness.channel, - [1, invalidSet, 0, 0, 0, 0], - )).not.toThrow(); + expect(() => + dispatchScratchBoundarySyscallWithArgs(harness, syscall, [ + 1, + invalidSet, + 0, + 0, + 0, + 0, + ]), + ).not.toThrow(); expect(harness.handleChannel).not.toHaveBeenCalled(); expect(harness.completeChannelRaw).toHaveBeenCalledWith( @@ -3436,10 +4128,16 @@ describe("kernel scratch transfer capacity regressions", () => { const invalidEvent = harness.processBytes.byteLength - STRUCT_SIZE_WASM_EPOLL_EVENT + 1; - expect(() => harness.worker.handleEpollCtl( - harness.channel, - [3, 1, 7, invalidEvent, 0, 0], - )).not.toThrow(); + expect(() => + dispatchScratchBoundarySyscallWithArgs(harness, ABI_SYSCALLS.EpollCtl, [ + 3, + 1, + 7, + invalidEvent, + 0, + 0, + ]), + ).not.toThrow(); expect(harness.handleChannel).not.toHaveBeenCalled(); expect(harness.completeChannelRaw).toHaveBeenCalledWith( @@ -3495,18 +4193,23 @@ describe("kernel scratch transfer capacity regressions", () => { return 0; }); - harness.worker.handleEpollCtl( - harness.channel, - [3, 1, 7, eventPointer, 0, 0], - [3n, 1n, 7n, BigInt(eventPointer), 0n, 0n], - ); + dispatchScratchBoundarySyscallWithArgs(harness, ABI_SYSCALLS.EpollCtl, [ + 3n, + 1n, + 7n, + BigInt(eventPointer), + 0n, + 0n, + ]); expect(harness.handleChannel).toHaveBeenCalledTimes(1); - expect(harness.worker.epollInterests.get("41:3")).toEqual([{ - fd: 7, - events: 0x1234, - data: expectedData, - }]); + expect(harness.worker.epollInterests.get("41:3")).toEqual([ + { + fd: 7, + events: 0x1234, + data: expectedData, + }, + ]); expectScratchTailUntouched(harness); }); @@ -3514,17 +4217,24 @@ describe("kernel scratch transfer capacity regressions", () => { const harness = makeScratchHarness(); const invalidEvents = harness.processBytes.byteLength - STRUCT_SIZE_WASM_EPOLL_EVENT + 1; - harness.worker.epollInterests.set("41:3", [{ - fd: 7, - events: 1, - data: 9n, - }]); + harness.worker.epollInterests.set("41:3", [ + { + fd: 7, + events: 1, + data: 9n, + }, + ]); - expect(() => harness.worker.handleEpollPwait( - harness.channel, - ABI_SYSCALLS.EpollPwait, - [3, invalidEvents, 1, 0, 0, 0], - )).not.toThrow(); + expect(() => + dispatchScratchBoundarySyscallWithArgs(harness, ABI_SYSCALLS.EpollPwait, [ + 3, + invalidEvents, + 1, + 0, + 0, + 0, + ]), + ).not.toThrow(); expect(harness.handleChannel).not.toHaveBeenCalled(); expect(harness.completeChannelRaw).toHaveBeenCalledWith( @@ -3545,19 +4255,19 @@ describe("kernel scratch transfer capacity regressions", () => { eventsPointer, eventsPointer + STRUCT_SIZE_WASM_EPOLL_EVENT, ); - harness.worker.epollInterests.set("41:3", [{ - fd: 7, - events: 1, - data: expectedData, - }]); + harness.worker.epollInterests.set("41:3", [ + { + fd: 7, + events: 1, + data: expectedData, + }, + ]); harness.handleChannel.mockImplementation(() => { const channelView = new DataView( harness.kernelBytes.buffer, harness.scratchOffset, ); - const pollfdsPointer = Number( - channelView.getBigInt64(CH_ARGS, true), - ); + const pollfdsPointer = Number(channelView.getBigInt64(CH_ARGS, true)); new DataView(harness.kernelBytes.buffer).setInt16( pollfdsPointer + 6, 1, @@ -3568,12 +4278,14 @@ describe("kernel scratch transfer capacity regressions", () => { return 0; }); - harness.worker.handleEpollPwait( - harness.channel, - ABI_SYSCALLS.EpollPwait, - [3, eventsPointer, 1, 0, 0, 0], - [3n, BigInt(eventsPointer), 1n, 0n, 0n, 0n], - ); + dispatchScratchBoundarySyscallWithArgs(harness, ABI_SYSCALLS.EpollPwait, [ + 3n, + BigInt(eventsPointer), + 1n, + 0n, + 0n, + 0n, + ]); const output = new DataView( harness.processBytes.buffer, @@ -3582,8 +4294,9 @@ describe("kernel scratch transfer capacity regressions", () => { ); expect(output.getUint32(WASM_EPOLL_EVENT_EVENTS_OFFSET, true)).toBe(1); expect(output.getUint32(WASM_EPOLL_EVENT_PAD_OFFSET, true)).toBe(0); - expect(output.getBigUint64(WASM_EPOLL_EVENT_DATA_OFFSET, true)) - .toBe(expectedData); + expect(output.getBigUint64(WASM_EPOLL_EVENT_DATA_OFFSET, true)).toBe( + expectedData, + ); expect(harness.completeChannelRaw).toHaveBeenCalledWith( harness.channel, 1, @@ -3622,8 +4335,9 @@ describe("kernel scratch transfer capacity regressions", () => { expect( harness.kernelBytes.slice( kernelMessage + STRUCT_SIZE_WASM_SYSV_MESSAGE_HEADER, - kernelMessage + STRUCT_SIZE_WASM_SYSV_MESSAGE_HEADER - + text.byteLength, + kernelMessage + + STRUCT_SIZE_WASM_SYSV_MESSAGE_HEADER + + text.byteLength, ), ).toEqual(text); expect( @@ -3634,19 +4348,14 @@ describe("kernel scratch transfer capacity regressions", () => { return 0; }); - harness.worker.handleSysvMessage( - harness.channel, - ABI_SYSCALLS.Msgsnd, - [3, messagePointer, text.byteLength, 0, 0, 0], - [ - 3n, - BigInt(messagePointer), - BigInt(text.byteLength), - 0n, - 0n, - 0n, - ], - ); + dispatchScratchBoundarySyscallWithArgs(harness, ABI_SYSCALLS.Msgsnd, [ + 3n, + BigInt(messagePointer), + BigInt(text.byteLength), + 0n, + 0n, + 0n, + ]); expect(harness.handleChannel).toHaveBeenCalledTimes(1); expect(harness.completeChannel).toHaveBeenCalledWith( @@ -3674,16 +4383,14 @@ describe("kernel scratch transfer capacity regressions", () => { const textBytes = 3; const pointer = harness.processBytes.byteLength - pointerWidth - textBytes + 1; - const origArgs = syscallNr === ABI_SYSCALLS.Msgsnd - ? [3, pointer, textBytes, 0, 0, 0] - : [3, pointer, textBytes, 0, 0, 0]; - - harness.worker.handleSysvMessage( - harness.channel, - syscallNr, - origArgs, - [3n, BigInt(pointer), BigInt(textBytes), 0n, 0n, 0n], - ); + dispatchScratchBoundarySyscallWithArgs(harness, syscallNr, [ + 3n, + BigInt(pointer), + BigInt(textBytes), + 0n, + 0n, + 0n, + ]); expect(harness.handleChannel).not.toHaveBeenCalled(); expect(harness.completeChannelRaw).toHaveBeenCalledWith( @@ -3704,16 +4411,15 @@ describe("kernel scratch transfer capacity regressions", () => { const harness = makeScratchHarness(pointerWidth); const messagePointer = 4096; const text = Uint8Array.of(0x71, 0x72, 0x73); - const selectedType = - pointerWidth === 8 ? 0x0102_0304_0506_0708n : 7n; + const selectedType = pointerWidth === 8 ? 0x0102_0304_0506_0708n : 7n; harness.handleChannel.mockImplementation(() => { const channelView = new DataView( harness.kernelBytes.buffer, harness.scratchOffset, ); - expect( - channelView.getBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, true), - ).toBe(selectedType); + expect(channelView.getBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, true)).toBe( + selectedType, + ); const kernelMessage = Number( channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), ); @@ -3728,23 +4434,17 @@ describe("kernel scratch transfer capacity regressions", () => { return 0; }); - harness.worker.handleSysvMessage( - harness.channel, - ABI_SYSCALLS.Msgrcv, - [3, messagePointer, text.byteLength, Number(selectedType), 0, 0], - [ - 3n, - BigInt(messagePointer), - BigInt(text.byteLength), - selectedType, - 0n, - 0n, - ], - ); + dispatchScratchBoundarySyscallWithArgs(harness, ABI_SYSCALLS.Msgrcv, [ + 3n, + BigInt(messagePointer), + BigInt(text.byteLength), + selectedType, + 0n, + 0n, + ]); const outputWrites = harness.completeChannel.mock.calls[0]?.[6] as - | Array<{ ptr: number; bytes: Uint8Array }> - | undefined; + Array<{ ptr: number; bytes: Uint8Array }> | undefined; expect(outputWrites).toHaveLength(1); expect(outputWrites?.[0]?.ptr).toBe(messagePointer); const output = outputWrites![0]!.bytes; @@ -3783,12 +4483,14 @@ describe("kernel scratch transfer capacity regressions", () => { return 0; }); - harness.worker.handleSysvMessage( - harness.channel, - ABI_SYSCALLS.Msgsnd, - [3, messagePointer, messageSize, 0, 0, 0], - [3n, BigInt(messagePointer), BigInt(messageSize), 0n, 0n, 0n], - ); + dispatchScratchBoundarySyscallWithArgs(harness, ABI_SYSCALLS.Msgsnd, [ + 3n, + BigInt(messagePointer), + BigInt(messageSize), + 0n, + 0n, + 0n, + ]); expect(harness.handleChannel).toHaveBeenCalledTimes( messageSize === exact ? 1 : 0, @@ -3817,14 +4519,16 @@ describe("kernel scratch transfer capacity regressions", () => { return 0; }); - harness.worker.handleSysvMessage( - harness.channel, - ABI_SYSCALLS.Msgrcv, - [3, messagePointer, 0, 0, IPC_NOWAIT, 0], - [3n, BigInt(messagePointer), 0n, 0n, BigInt(IPC_NOWAIT), 0n], - ); + dispatchScratchBoundarySyscallWithArgs(harness, ABI_SYSCALLS.Msgrcv, [ + 3n, + BigInt(messagePointer), + 0n, + 0n, + BigInt(IPC_NOWAIT), + 0n, + ]); - expect(harness.worker.handleBlockingRetry).not.toHaveBeenCalled(); + expect(harness.handleBlockingRetry).not.toHaveBeenCalled(); expect(harness.completeChannel).toHaveBeenCalledWith( harness.channel, ABI_SYSCALLS.Msgrcv, @@ -3832,7 +4536,6 @@ describe("kernel scratch transfer capacity regressions", () => { undefined, -1, EAGAIN, - undefined, ); expectScratchTailUntouched(harness); }); @@ -3848,16 +4551,18 @@ describe("kernel scratch transfer capacity regressions", () => { const harness = makeScratchHarness(pointerWidth); const invalidBuffer = harness.processBytes.byteLength - bytes + 1; const statBytes = vi.fn(() => bytes); - Object.assign(harness.worker.kernelInstance.exports, { + Object.assign(harness.kernelExports, { [exportName]: statBytes, }); - harness.worker.handleIpcControl( - harness.channel, - syscallNr, - [3, 2, invalidBuffer, 0, 0, 0], - [3n, 2n, BigInt(invalidBuffer), 0n, 0n, 0n], - ); + dispatchScratchBoundarySyscallWithArgs(harness, syscallNr, [ + 3n, + 2n, + BigInt(invalidBuffer), + 0n, + 0n, + 0n, + ]); expect(statBytes).toHaveBeenCalledWith(pointerWidth); expect(harness.handleChannel).not.toHaveBeenCalled(); @@ -3885,7 +4590,7 @@ describe("kernel scratch transfer capacity regressions", () => { (_, index) => (index * 13) & 0xff, ); const statBytes = vi.fn(() => bytes); - Object.assign(harness.worker.kernelInstance.exports, { + Object.assign(harness.kernelExports, { [exportName]: statBytes, }); harness.handleChannel.mockImplementation(() => { @@ -3895,9 +4600,7 @@ describe("kernel scratch transfer capacity regressions", () => { ); expect(channelView.getUint32(CH_SYSCALL, true)).toBe(syscallNr); expect( - Number( - channelView.getBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, true), - ), + Number(channelView.getBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, true)), ).toBe(pointerWidth); const dataPointer = Number( channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true), @@ -3908,19 +4611,18 @@ describe("kernel scratch transfer capacity regressions", () => { return 0; }); - harness.worker.handleIpcControl( - harness.channel, - syscallNr, - [3, 2, outputPointer, 0, 0, 0], - [3n, 2n, BigInt(outputPointer), 0n, 0n, 0n], - ); + dispatchScratchBoundarySyscallWithArgs(harness, syscallNr, [ + 3n, + 2n, + BigInt(outputPointer), + 0n, + 0n, + 0n, + ]); expect(statBytes).toHaveBeenCalledWith(pointerWidth); expect( - harness.processBytes.slice( - outputPointer, - outputPointer + bytes, - ), + harness.processBytes.slice(outputPointer, outputPointer + bytes), ).toEqual(expected); expectScratchTailUntouched(harness); }, @@ -3935,7 +4637,7 @@ describe("kernel scratch transfer capacity regressions", () => { (_, index) => (index * 7) & 0xff, ); harness.processBytes.set(input, inputPointer); - Object.assign(harness.worker.kernelInstance.exports, { + Object.assign(harness.kernelExports, { kernel_msqid_ds_bytes: vi.fn(() => bytes), }); harness.handleChannel.mockImplementation(() => { @@ -3955,12 +4657,14 @@ describe("kernel scratch transfer capacity regressions", () => { return 0; }); - harness.worker.handleIpcControl( - harness.channel, - ABI_SYSCALLS.Msgctl, - [3, 1, inputPointer, 0, 0, 0], - [3n, 1n, BigInt(inputPointer), 0n, 0n, 0n], - ); + dispatchScratchBoundarySyscallWithArgs(harness, ABI_SYSCALLS.Msgctl, [ + 3n, + 1n, + BigInt(inputPointer), + 0n, + 0n, + 0n, + ]); expect( harness.processBytes.slice(inputPointer, inputPointer + bytes), @@ -3970,19 +4674,24 @@ describe("kernel scratch transfer capacity regressions", () => { it("rejects invalid or missing IPC control sizing exports", () => { for (const configuredSize of [undefined, CH_DATA_SIZE + 1]) { - const harness = makeScratchHarness(); + const harness = makeScratchHarness( + 4, + configuredSize === undefined ? ["kernel_msqid_ds_bytes"] : [], + ); if (configuredSize !== undefined) { - Object.assign(harness.worker.kernelInstance.exports, { + Object.assign(harness.kernelExports, { kernel_msqid_ds_bytes: vi.fn(() => configuredSize), }); } - harness.worker.handleIpcControl( - harness.channel, - ABI_SYSCALLS.Msgctl, - [3, 2, 4096, 0, 0, 0], - [3n, 2n, 4096n, 0n, 0n, 0n], - ); + dispatchScratchBoundarySyscallWithArgs(harness, ABI_SYSCALLS.Msgctl, [ + 3n, + 2n, + 4096n, + 0n, + 0n, + 0n, + ]); expect(harness.handleChannel).not.toHaveBeenCalled(); expect(harness.completeChannelRaw).toHaveBeenCalledWith( @@ -3996,17 +4705,19 @@ describe("kernel scratch transfer capacity regressions", () => { it("does not pass an unsafe wasm64 IPC control pointer to Rust", () => { const harness = makeScratchHarness(8); - Object.assign(harness.worker.kernelInstance.exports, { + Object.assign(harness.kernelExports, { kernel_shmid_ds_bytes: vi.fn(() => 112), }); const unsafePointer = BigInt(Number.MAX_SAFE_INTEGER) + 1n; - harness.worker.handleIpcControl( - harness.channel, - ABI_SYSCALLS.Shmctl, - [3, 2, Number(unsafePointer), 0, 0, 0], - [3n, 2n, unsafePointer, 0n, 0n, 0n], - ); + dispatchScratchBoundarySyscallWithArgs(harness, ABI_SYSCALLS.Shmctl, [ + 3n, + 2n, + unsafePointer, + 0n, + 0n, + 0n, + ]); expect(harness.handleChannel).not.toHaveBeenCalled(); expect(harness.completeChannelRaw).toHaveBeenCalledWith( @@ -4024,20 +4735,20 @@ describe("kernel scratch transfer capacity regressions", () => { harness.kernelBytes.buffer, harness.scratchOffset, ); - expect( - channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true), - ).toBe(0n); + expect(channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true)).toBe(0n); channelView.setBigInt64(CH_RETURN, 0n, true); channelView.setUint32(CH_ERRNO, 0, true); return 0; }); - harness.worker.handleIpcControl( - harness.channel, - ABI_SYSCALLS.Shmctl, - [3, 0, 0, 0, 0, 0], - [3n, 0n, 0n, 0n, 0n, 0n], - ); + dispatchScratchBoundarySyscallWithArgs(harness, ABI_SYSCALLS.Shmctl, [ + 3n, + 0n, + 0n, + 0n, + 0n, + 0n, + ]); expect(harness.handleChannel).toHaveBeenCalledOnce(); expect(harness.completeChannelRaw).toHaveBeenCalledWith( @@ -4060,15 +4771,21 @@ describe("kernel scratch transfer capacity regressions", () => { const invalidBuffer = harness.processBytes.byteLength - bytes + 1; const arrayBytes = vi.fn(() => bytes); const statBytes = vi.fn(() => bytes); - Object.assign(harness.worker.kernelInstance.exports, { + Object.assign(harness.kernelExports, { kernel_semctl_array_bytes: arrayBytes, kernel_semid_ds_bytes: statBytes, }); - expect(() => harness.worker.handleSemctl( - harness.channel, - [3, 0, command, invalidBuffer, 0, 0], - )).not.toThrow(); + expect(() => + dispatchScratchBoundarySyscallWithArgs(harness, ABI_SYSCALLS.Semctl, [ + 3, + 0, + command, + invalidBuffer, + 0, + 0, + ]), + ).not.toThrow(); if (command === 2) { expect(arrayBytes).not.toHaveBeenCalled(); @@ -4101,7 +4818,7 @@ describe("kernel scratch transfer capacity regressions", () => { (_, index) => index & 0xff, ); const arrayBytes = vi.fn(() => outputBytes); - Object.assign(harness.worker.kernelInstance.exports, { + Object.assign(harness.kernelExports, { kernel_semctl_array_bytes: arrayBytes, kernel_semid_ds_bytes: vi.fn(() => 72), }); @@ -4110,17 +4827,15 @@ describe("kernel scratch transfer capacity regressions", () => { harness.kernelBytes.buffer, harness.scratchOffset, ); - const command = Number( - channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true), - ) & ~0x100; + const command = + Number(channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true)) & + ~0x100; const dataPointer = Number( channelView.getBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, true), ); expect(command).toBe(13); expect( - Number( - channelView.getBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, true), - ), + Number(channelView.getBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, true)), ).toBe(4); harness.kernelBytes.set(expected, dataPointer); channelView.setBigInt64(CH_RETURN, 0n, true); @@ -4128,10 +4843,14 @@ describe("kernel scratch transfer capacity regressions", () => { return 0; }); - harness.worker.handleSemctl( - harness.channel, - [3, 0, 13, outputPointer, 0, 0], - ); + dispatchScratchBoundarySyscallWithArgs(harness, ABI_SYSCALLS.Semctl, [ + 3, + 0, + 13, + outputPointer, + 0, + 0, + ]); expect(arrayBytes).toHaveBeenCalledWith( harness.channel.pid, @@ -4141,19 +4860,17 @@ describe("kernel scratch transfer capacity regressions", () => { ); expect(harness.handleChannel).toHaveBeenCalledOnce(); expect( - harness.processBytes.slice( - outputPointer, - outputPointer + outputBytes, - ), + harness.processBytes.slice(outputPointer, outputPointer + outputBytes), ).toEqual(expected); expectScratchTailUntouched(harness); }); it("fails closed when a required semctl sizing export is absent", () => { - const harness = makeScratchHarness(); + const harness = makeScratchHarness(4, ["kernel_semctl_array_bytes"]); - harness.worker.handleSemctl( - harness.channel, + dispatchScratchBoundarySyscallWithArgs( + harness, + ABI_SYSCALLS.Semctl, [3, 0, 13, 4096, 0, 0], ); @@ -4166,38 +4883,25 @@ describe("kernel scratch transfer capacity regressions", () => { expectScratchTailUntouched(harness); }); - it("rejects a negative generic descriptor length before scratch mutation", () => { - const harness = makeScratchHarness(); - Object.assign(harness.worker, { - config: {}, - syscallRing: new Map(), - syscallTraceEnabled: false, - channelTids: new Map(), - processes: new Map([[harness.channel.pid, { - pid: harness.channel.pid, - memory: harness.channel.memory, - channels: [harness.channel], - ptrWidth: 4, - }]]), - synchronizeSharedMemoryForBoundary: () => {}, - sharedMmapBackings: new Map(), - getProcessExitSignal: () => 0, - }); - const request = new DataView(harness.channel.memory.buffer); + it("rejects a negative generic descriptor length before scratch mutation", () => { + const harness = makeScratchHarness(); + prepareGenericSyscallHarness(harness, 4); + const request = new DataView( + harness.channel.memory.buffer, + harness.channel.channelOffset, + CH_TOTAL_SIZE, + ); request.setUint32(CH_SYSCALL, ABI_SYSCALLS.Read, true); request.setBigInt64(CH_ARGS, 7n, true); request.setBigInt64(CH_ARGS + CH_ARG_SIZE, 1024n, true); request.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, -1n, true); - expect(() => harness.worker._handleSyscallInner(harness.channel)) - .not.toThrow(); + expect(() => dispatchScratchBoundarySyscall(harness)).not.toThrow(); expect(harness.handleChannel).not.toHaveBeenCalled(); - expect(harness.completeChannel).toHaveBeenCalledWith( + expect(harness.completeChannel).not.toHaveBeenCalled(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( harness.channel, - ABI_SYSCALLS.Read, - [7, 1024, -1, 0, 0, 0], - undefined, -1, EINVAL, ); @@ -4205,35 +4909,316 @@ describe("kernel scratch transfer capacity regressions", () => { }); it.each([ - ["wasm32", 4], - ["wasm64", 8], + ["bind", ABI_SYSCALLS.Bind, 1, [7n, 0n, 0n]], + ["connect", ABI_SYSCALLS.Connect, 1, [7n, 0n, 0n]], + ["sendto", ABI_SYSCALLS.Sendto, 4, [7n, 0n, 0n, 0n, 0n, 0n]], ] as const)( - "rejects a non-null %s recvfrom address with no capacity pointer", - (_pointerKind, pointerWidth) => { - const harness = makeScratchHarness(pointerWidth); - prepareGenericSyscallHarness(harness, pointerWidth); - const addressPointer = 4096; - writeChannelSyscall(harness, ABI_SYSCALLS.Recvfrom, [ - 7n, - 0n, - 0n, - 0n, - BigInt(addressPointer), - 0n, - ]); + "%s accepts a full sockaddr_storage and rejects one byte more", + (_syscallName, syscallNumber, addressArgIndex, syscallArgs) => { + for (const pointerWidth of [4, 8]) { + const addressPointer = 4096; + for (const addressLength of [ + KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, + KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES + 1, + ]) { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + harness.processBytes.fill( + 0, + addressPointer, + addressPointer + addressLength, + ); + harness.handleChannel.mockImplementation(() => { + const channelView = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + const stagedPointer = Number( + channelView.getBigInt64( + CH_ARGS + addressArgIndex * CH_ARG_SIZE, + true, + ), + ); + expect( + harness.kernelBytes.slice( + stagedPointer, + stagedPointer + addressLength, + ), + ).toEqual( + harness.processBytes.slice( + addressPointer, + addressPointer + addressLength, + ), + ); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + const args = [...syscallArgs]; + args[addressArgIndex] = BigInt(addressPointer); + args[addressArgIndex + 1] = BigInt(addressLength); + writeChannelSyscall(harness, syscallNumber, args); + + dispatchScratchBoundarySyscall(harness); + + const accepted = + addressLength === KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES; + expect(harness.handleChannel).toHaveBeenCalledTimes(accepted ? 1 : 0); + if (!accepted) { + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + EINVAL, + ); + } + expectScratchTailUntouched(harness); + } + } + }, + ); - harness.worker._handleSyscallInner(harness.channel); + it.each([ + ["accept", ABI_SYSCALLS.Accept, 1, [7n, 0n, 0n]], + ["accept4", ABI_SYSCALLS.Accept4, 1, [7n, 0n, 0n, 0n]], + [ + "recvfrom", + ABI_SYSCALLS.Recvfrom, + 4, + [7n, 0n, 0n, 0n, 0n, 0n], + ], + ] as const)( + "%s rejects an active address with no capacity pointer", + (_name, syscall, addressArgIndex, baseArgs) => { + for (const pointerWidth of [4, 8] as const) { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + const addressPointer = 4096; + const args = [...baseArgs]; + args[addressArgIndex] = BigInt(addressPointer); + writeChannelSyscall(harness, syscall, args); - expect(harness.handleChannel).not.toHaveBeenCalled(); - expect(harness.completeChannel).toHaveBeenCalledWith( - harness.channel, - ABI_SYSCALLS.Recvfrom, - [7, 0, 0, 0, addressPointer, 0], - undefined, - -1, - EFAULT, - ); - expectScratchTailUntouched(harness); + dispatchScratchBoundarySyscall(harness); + + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + syscall, + Array.from( + { length: 6 }, + (_, index) => Number(args[index] ?? 0n), + ), + undefined, + -1, + EFAULT, + ); + expectScratchTailUntouched(harness); + } + }, + ); + + it.each([ + ["accept", ABI_SYSCALLS.Accept, 1, 2, [7n, 0n, 0n]], + ["accept4", ABI_SYSCALLS.Accept4, 1, 2, [7n, 0n, 0n, 0n]], + [ + "recvfrom", + ABI_SYSCALLS.Recvfrom, + 4, + 5, + [7n, 0n, 0n, 0n, 0n, 0n], + ], + ] as const)( + "%s ignores the address-length pointer when the address is absent", + (_name, syscall, addressArgIndex, lengthArgIndex, baseArgs) => { + for (const pointerWidth of [4, 8] as const) { + const validLengthPointer = 4096; + for ( + const pointerKind of [ + "valid", + "out-of-range", + "negative", + "unsafe-high", + ] as const + ) { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + const ignoredLengthPointer = pointerKind === "valid" + ? BigInt(validLengthPointer) + : pointerKind === "out-of-range" + ? BigInt(harness.processBytes.byteLength + 4096) + : pointerKind === "negative" + ? -1n + : 1n << 60n; + const preservedValue = pointerKind === "valid" + ? 0x6d5a_4321 + : undefined; + if (preservedValue !== undefined) { + new DataView(harness.processBytes.buffer).setUint32( + validLengthPointer, + preservedValue, + true, + ); + } + harness.handleChannel.mockImplementation( + (offset: number | bigint) => { + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); + expect( + channelView.getBigInt64( + CH_ARGS + addressArgIndex * CH_ARG_SIZE, + true, + ), + ).toBe(0n); + expect( + channelView.getBigInt64( + CH_ARGS + lengthArgIndex * CH_ARG_SIZE, + true, + ), + ).toBe(0n); + channelView.setBigInt64( + CH_RETURN, + syscall === ABI_SYSCALLS.Recvfrom ? 0n : 17n, + true, + ); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }, + ); + const args = [...baseArgs]; + args[addressArgIndex] = 0n; + args[lengthArgIndex] = ignoredLengthPointer; + writeChannelSyscall(harness, syscall, args); + + dispatchScratchBoundarySyscall(harness); + + expect(harness.handleChannel).toHaveBeenCalledOnce(); + if (preservedValue !== undefined) { + expect( + new DataView(harness.processBytes.buffer).getUint32( + validLengthPointer, + true, + ), + ).toBe(preservedValue); + } + expectScratchTailUntouched(harness); + } + } + }, + ); + + it.each([4, 8] as const)( + "accepts a wasm%s generic socket result at sockaddr_storage and rejects capacity plus one", + (pointerWidth) => { + for (const reportedLength of [ + KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, + KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES + 1, + ]) { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + const addressPointer = + harness.processBytes.byteLength - + KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES; + const lengthPointer = 4096; + const callerCanary = 0x6d; + const expected = Uint8Array.from( + { length: KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES }, + (_, index) => (index * 29 + 3) % 251, + ); + harness.processBytes.fill( + callerCanary, + addressPointer, + harness.processBytes.byteLength, + ); + new DataView(harness.processBytes.buffer).setUint32( + lengthPointer, + 0xffff_ffff, + true, + ); + harness.completeChannel.mockImplementation( + ( + _channel: TestChannel, + _syscallNr: number, + _origArgs: number[], + _argDescs: unknown, + _retVal: number, + _errVal: number, + writes: Array<{ ptr: number; bytes: Uint8Array }> = [], + ) => { + for (const write of writes) { + harness.processBytes.set(write.bytes, write.ptr); + } + }, + ); + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); + const stagedAddressPointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + const stagedLengthPointer = Number( + channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true), + ); + const kernelView = new DataView(harness.kernelBytes.buffer); + expect(kernelView.getUint32(stagedLengthPointer, true)).toBe( + KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, + ); + harness.kernelBytes.set(expected, stagedAddressPointer); + kernelView.setUint32(stagedLengthPointer, reportedLength, true); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + writeChannelSyscall(harness, ABI_SYSCALLS.Getsockname, [ + 7n, + BigInt(addressPointer), + BigInt(lengthPointer), + ]); + + dispatchScratchBoundarySyscall(harness); + + if (reportedLength === KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES) { + expect(harness.completeChannel).toHaveBeenCalledOnce(); + expect( + harness.processBytes.slice( + addressPointer, + harness.processBytes.byteLength, + ), + ).toEqual(expected); + expect( + new DataView(harness.processBytes.buffer).getUint32( + lengthPointer, + true, + ), + ).toBe(reportedLength); + } else { + expect(harness.completeChannel).toHaveBeenCalledOnce(); + const completion = harness.completeChannel.mock.calls[0]!; + expect(completion[4]).toBe(-1); + expect(completion[5]).toBe(EIO); + expect(completion[6]).toBeUndefined(); + expect(harness.completeChannelRaw).not.toHaveBeenCalled(); + expect( + harness.processBytes.slice( + addressPointer, + harness.processBytes.byteLength, + ), + ).toEqual( + new Uint8Array(KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES).fill( + callerCanary, + ), + ); + expect( + new DataView(harness.processBytes.buffer).getUint32( + lengthPointer, + true, + ), + ).toBe(0xffff_ffff); + } + expectScratchTailUntouched(harness); + } }, ); @@ -4258,9 +5243,9 @@ describe("kernel scratch transfer capacity regressions", () => { getUint32(byteOffset: number, littleEndian?: boolean): number { const value = super.getUint32(byteOffset, littleEndian); if ( - this.buffer === harness.channel.memory.buffer - && byteOffset === lengthPointer - && capturedReads++ === 0 + this.buffer === harness.channel.memory.buffer && + byteOffset === lengthPointer && + capturedReads++ === 0 ) { // Model a second guest thread changing socklen_t after the sizing // read. Before the fix, the later byte copy staged 28 even though @@ -4322,8 +5307,7 @@ describe("kernel scratch transfer capacity regressions", () => { vi.stubGlobal("DataView", MutatingDataView); try { - expect(() => harness.worker._handleSyscallInner(harness.channel)) - .not.toThrow(); + expect(() => dispatchScratchBoundarySyscall(harness)).not.toThrow(); } finally { vi.unstubAllGlobals(); } @@ -4368,9 +5352,9 @@ describe("kernel scratch transfer capacity regressions", () => { class MutatingDataView extends nativeDataView { getUint32(byteOffset: number, littleEndian?: boolean): number { if ( - this.buffer === harness.channel.memory.buffer - && byteOffset === lengthPointer - && sizingReads++ === 0 + this.buffer === harness.channel.memory.buffer && + byteOffset === lengthPointer && + sizingReads++ === 0 ) { // With the old order-dependent planner, the preceding fixed // descriptor had already staged 28. This mutation then planned only @@ -4423,7 +5407,7 @@ describe("kernel scratch transfer capacity regressions", () => { SYSCALL_ARGS[ABI_SYSCALLS.Recvfrom] = reorderedDescriptors; vi.stubGlobal("DataView", MutatingDataView); try { - harness.worker._handleSyscallInner(harness.channel); + dispatchScratchBoundarySyscall(harness); } finally { vi.unstubAllGlobals(); SYSCALL_ARGS[ABI_SYSCALLS.Recvfrom] = originalDescriptors; @@ -4436,53 +5420,39 @@ describe("kernel scratch transfer capacity regressions", () => { stagedAddressPointer + plannedCapacity, stagedAddressPointer + stagedCapacity, ), - ).toEqual( - new Uint8Array(stagedCapacity - plannedCapacity).fill(0xa5), - ); + ).toEqual(new Uint8Array(stagedCapacity - plannedCapacity).fill(0xa5)); expectScratchTailUntouched(harness); }); it("checks ppoll scalar-conversion sources before scratch mutation", () => { - for (const [ptrWidth, timespecPointer, maskPointer] of [ - [4, BigInt(4 * 65_536 - 8), 0n], - [4, 0n, BigInt(4 * 65_536 - 4)], - [8, BigInt(Number.MAX_SAFE_INTEGER) + 1n, 0n], + for (const [ptrWidth, invalidSource] of [ + [4, "timespec"], + [4, "mask"], + [8, "unsafe"], ] as const) { const harness = makeScratchHarness(ptrWidth); - Object.assign(harness.worker, { - config: {}, - syscallRing: new Map(), - syscallTraceEnabled: false, - channelTids: new Map(), - processes: new Map([[harness.channel.pid, { - pid: harness.channel.pid, - memory: harness.channel.memory, - channels: [harness.channel], - ptrWidth, - }]]), - synchronizeSharedMemoryForBoundary: () => {}, - sharedMmapBackings: new Map(), - hostReaped: new Set(), - getProcessExitSignal: () => 0, - }); - const request = new DataView(harness.channel.memory.buffer); + prepareGenericSyscallHarness(harness, ptrWidth); + const processEnd = BigInt(harness.processBytes.byteLength); + const timespecPointer = + invalidSource === "timespec" + ? processEnd - 8n + : invalidSource === "unsafe" + ? BigInt(Number.MAX_SAFE_INTEGER) + 1n + : 0n; + const maskPointer = invalidSource === "mask" ? processEnd - 4n : 0n; + const request = new DataView( + harness.channel.memory.buffer, + harness.channel.channelOffset, + CH_TOTAL_SIZE, + ); request.setUint32(CH_SYSCALL, ABI_SYSCALLS.Ppoll, true); request.setBigInt64(CH_ARGS, 0n, true); request.setBigInt64(CH_ARGS + CH_ARG_SIZE, 0n, true); - request.setBigInt64( - CH_ARGS + 2 * CH_ARG_SIZE, - timespecPointer, - true, - ); - request.setBigInt64( - CH_ARGS + 3 * CH_ARG_SIZE, - maskPointer, - true, - ); + request.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, timespecPointer, true); + request.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, maskPointer, true); request.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, 8n, true); - expect(() => harness.worker._handleSyscallInner(harness.channel)) - .not.toThrow(); + expect(() => dispatchScratchBoundarySyscall(harness)).not.toThrow(); expect(harness.handleChannel).not.toHaveBeenCalled(); expect(harness.completeChannelRaw).toHaveBeenCalledWith( @@ -4499,28 +5469,13 @@ describe("kernel scratch transfer capacity regressions", () => { const inputPointer = 8192; const input = Uint8Array.from([0x4b, 0x61, 0x6e, 0x64, 0x65, 0x6c, 0x6f]); harness.processBytes.set(input, inputPointer); - Object.assign(harness.worker, { - config: {}, - syscallRing: new Map(), - syscallTraceEnabled: false, - channelTids: new Map(), - processes: new Map([[harness.channel.pid, { - pid: harness.channel.pid, - memory: harness.channel.memory, - channels: [harness.channel], - ptrWidth: 4, - }]]), - synchronizeSharedMemoryForBoundary: () => {}, - sharedMmapBackings: new Map(), - hostReaped: new Set(), - getProcessExitSignal: () => 0, - }); + prepareGenericSyscallHarness(harness, 4); const observed: Uint8Array[] = []; - harness.worker.bindKernelTidForChannel = () => { + harness.kernelExports.kernel_set_current_tid = () => { // Model a synchronous nested host operation that reused main scratch // after descriptor planning but before this syscall's dispatch. - harness.worker.scratchRegion.withLease((lease: any) => { + harness.scratchRegion.withLease((lease: any) => { lease.fill(0xcc, CH_DATA, input.byteLength); }); harness.processBytes.fill( @@ -4528,9 +5483,13 @@ describe("kernel scratch transfer capacity regressions", () => { inputPointer, inputPointer + input.byteLength, ); + return 0; }; harness.handleChannel.mockImplementation((offset: number | bigint) => { - const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); const dataPointer = Number( channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), ); @@ -4546,7 +5505,11 @@ describe("kernel scratch transfer capacity regressions", () => { return 0; }); - const request = new DataView(harness.channel.memory.buffer); + const request = new DataView( + harness.channel.memory.buffer, + harness.channel.channelOffset, + CH_TOTAL_SIZE, + ); request.setUint32(CH_SYSCALL, ABI_SYSCALLS.Write, true); request.setBigInt64(CH_ARGS, 7n, true); request.setBigInt64(CH_ARGS + CH_ARG_SIZE, BigInt(inputPointer), true); @@ -4556,8 +5519,7 @@ describe("kernel scratch transfer capacity regressions", () => { true, ); - expect(() => harness.worker._handleSyscallInner(harness.channel)) - .not.toThrow(); + expect(() => dispatchScratchBoundarySyscall(harness)).not.toThrow(); expect(observed).toEqual([input]); expect(harness.completeChannel).toHaveBeenCalledWith( @@ -4584,24 +5546,12 @@ describe("kernel scratch transfer capacity regressions", () => { ); harness.processBytes.set(canary, resultPointer + result.byteLength); - Object.assign(harness.worker, { - config: {}, - syscallRing: new Map(), - syscallTraceEnabled: false, - channelTids: new Map(), - processes: new Map([[harness.channel.pid, { - pid: harness.channel.pid, - memory: harness.channel.memory, - channels: [harness.channel], - ptrWidth: 4, - }]]), - synchronizeSharedMemoryForBoundary: () => {}, - sharedMmapBackings: new Map(), - hostReaped: new Set(), - getProcessExitSignal: () => 0, - }); + prepareGenericSyscallHarness(harness, 4); harness.handleChannel.mockImplementation((offset: number | bigint) => { - const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); const outputPointer = Number( channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), ); @@ -4626,17 +5576,16 @@ describe("kernel scratch transfer capacity regressions", () => { }, ); - const request = new DataView(harness.channel.memory.buffer); + const request = new DataView( + harness.channel.memory.buffer, + harness.channel.channelOffset, + CH_TOTAL_SIZE, + ); request.setUint32(CH_SYSCALL, ABI_SYSCALLS.Getaddrinfo, true); request.setBigInt64(CH_ARGS, BigInt(namePointer), true); - request.setBigInt64( - CH_ARGS + CH_ARG_SIZE, - BigInt(resultPointer), - true, - ); + request.setBigInt64(CH_ARGS + CH_ARG_SIZE, BigInt(resultPointer), true); - expect(() => harness.worker._handleSyscallInner(harness.channel)) - .not.toThrow(); + expect(() => dispatchScratchBoundarySyscall(harness)).not.toThrow(); expect( harness.processBytes.slice(resultPointer, resultPointer + result.length), @@ -4647,8 +5596,10 @@ describe("kernel scratch transfer capacity regressions", () => { resultPointer + result.length + canary.length, ), ).toEqual(canary); - const detachedWrites = harness.completeChannel.mock.calls[0]?.[6] as - Array<{ ptr: number; bytes: Uint8Array }>; + const detachedWrites = harness.completeChannel.mock.calls[0]?.[6] as Array<{ + ptr: number; + bytes: Uint8Array; + }>; expect(detachedWrites).toHaveLength(1); expect(detachedWrites[0]?.bytes).toHaveLength(4); expectScratchTailUntouched(harness); @@ -4691,23 +5642,18 @@ describe("kernel scratch transfer capacity regressions", () => { (_name, syscallNr, argIndex, size, originalArgs) => { const harness = makeScratchHarness(8); prepareGenericSyscallHarness(harness, 8); - const invalidPointer = - harness.processBytes.byteLength - size + 1; + const invalidPointer = harness.processBytes.byteLength - size + 1; const args = [...originalArgs]; args[argIndex] = BigInt(invalidPointer); writeChannelSyscall(harness, syscallNr, args); - expect(() => harness.worker._handleSyscallInner(harness.channel)) - .not.toThrow(); + expect(() => dispatchScratchBoundarySyscall(harness)).not.toThrow(); expect(harness.handleChannel).not.toHaveBeenCalled(); expect(harness.completeChannel).toHaveBeenCalledWith( harness.channel, syscallNr, - Array.from( - { length: 6 }, - (_, index) => Number(args[index] ?? 0n), - ), + Array.from({ length: 6 }, (_, index) => Number(args[index] ?? 0n)), undefined, -1, EFAULT, @@ -4733,23 +5679,16 @@ describe("kernel scratch transfer capacity regressions", () => { (_name, syscallNr, args) => { const harness = makeScratchHarness(8); prepareGenericSyscallHarness(harness, 8); - harness.processBytes.set( - new TextEncoder().encode("valid\0"), - 4096, - ); + harness.processBytes.set(new TextEncoder().encode("valid\0"), 4096); writeChannelSyscall(harness, syscallNr, [...args]); - expect(() => harness.worker._handleSyscallInner(harness.channel)) - .not.toThrow(); + expect(() => dispatchScratchBoundarySyscall(harness)).not.toThrow(); expect(harness.handleChannel).not.toHaveBeenCalled(); expect(harness.completeChannel).toHaveBeenCalledWith( harness.channel, syscallNr, - Array.from( - { length: 6 }, - (_, index) => Number(args[index] ?? 0n), - ), + Array.from({ length: 6 }, (_, index) => Number(args[index] ?? 0n)), undefined, -1, EFAULT, @@ -4766,32 +5705,15 @@ describe("kernel scratch transfer capacity regressions", () => { (pointerWidth, nativeSize) => { const harness = makeScratchHarness(pointerWidth); const outputPointer = harness.processBytes.byteLength - nativeSize; - Object.assign(harness.worker, { - config: {}, - syscallRing: new Map(), - syscallTraceEnabled: false, - channelTids: new Map(), - processes: new Map([[harness.channel.pid, { - pid: harness.channel.pid, - memory: harness.channel.memory, - channels: [harness.channel], - ptrWidth: pointerWidth, - }]]), - synchronizeSharedMemoryForBoundary: () => {}, - sharedMmapBackings: new Map(), - hostReaped: new Set(), - getProcessExitSignal: () => 0, - }); + prepareGenericSyscallHarness(harness, pointerWidth); harness.handleChannel.mockImplementation((offset: number | bigint) => { - const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); - const scratchPointer = Number( - channelView.getBigInt64(CH_ARGS, true), + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), ); + const scratchPointer = Number(channelView.getBigInt64(CH_ARGS, true)); expect( - Number(channelView.getBigInt64( - CH_ARGS + 5 * CH_ARG_SIZE, - true, - )), + Number(channelView.getBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, true)), ).toBe(pointerWidth); harness.kernelBytes.fill( 0x6b, @@ -4803,17 +5725,19 @@ describe("kernel scratch transfer capacity regressions", () => { return 0; }); - const request = new DataView(harness.channel.memory.buffer); + const request = new DataView( + harness.channel.memory.buffer, + harness.channel.channelOffset, + CH_TOTAL_SIZE, + ); request.setUint32(CH_SYSCALL, ABI_SYSCALLS.Sysinfo, true); request.setBigInt64(CH_ARGS, BigInt(outputPointer), true); - expect(() => harness.worker._handleSyscallInner(harness.channel)) - .not.toThrow(); + expect(() => dispatchScratchBoundarySyscall(harness)).not.toThrow(); expect(harness.handleChannel).toHaveBeenCalledOnce(); const writes = harness.completeChannel.mock.calls[0]?.[6] as - | Array<{ ptr: number; bytes: Uint8Array }> - | undefined; + Array<{ ptr: number; bytes: Uint8Array }> | undefined; expect(writes).toHaveLength(1); expect(writes?.[0]?.ptr).toBe(outputPointer); expect(writes?.[0]?.bytes).toHaveLength(nativeSize); @@ -4829,30 +5753,17 @@ describe("kernel scratch transfer capacity regressions", () => { "rejects a one-byte-short wasm%s sysinfo caller range", (pointerWidth, nativeSize) => { const harness = makeScratchHarness(pointerWidth); - Object.assign(harness.worker, { - config: {}, - syscallRing: new Map(), - syscallTraceEnabled: false, - channelTids: new Map(), - processes: new Map([[harness.channel.pid, { - pid: harness.channel.pid, - memory: harness.channel.memory, - channels: [harness.channel], - ptrWidth: pointerWidth, - }]]), - synchronizeSharedMemoryForBoundary: () => {}, - sharedMmapBackings: new Map(), - hostReaped: new Set(), - getProcessExitSignal: () => 0, - }); - const invalidPointer = - harness.processBytes.byteLength - nativeSize + 1; - const request = new DataView(harness.channel.memory.buffer); + prepareGenericSyscallHarness(harness, pointerWidth); + const invalidPointer = harness.processBytes.byteLength - nativeSize + 1; + const request = new DataView( + harness.channel.memory.buffer, + harness.channel.channelOffset, + CH_TOTAL_SIZE, + ); request.setUint32(CH_SYSCALL, ABI_SYSCALLS.Sysinfo, true); request.setBigInt64(CH_ARGS, BigInt(invalidPointer), true); - expect(() => harness.worker._handleSyscallInner(harness.channel)) - .not.toThrow(); + expect(() => dispatchScratchBoundarySyscall(harness)).not.toThrow(); expect(harness.handleChannel).not.toHaveBeenCalled(); expect(harness.completeChannel).toHaveBeenCalledWith( @@ -4874,8 +5785,7 @@ describe("kernel scratch transfer capacity regressions", () => { prepareGenericSyscallHarness(harness, pointerWidth); writeChannelSyscall(harness, ABI_SYSCALLS.Sysinfo, [0n]); - expect(() => harness.worker._handleSyscallInner(harness.channel)) - .not.toThrow(); + expect(() => dispatchScratchBoundarySyscall(harness)).not.toThrow(); expect(harness.handleChannel).not.toHaveBeenCalled(); expect(harness.completeChannel).toHaveBeenCalledWith( @@ -4942,23 +5852,13 @@ describe("kernel scratch transfer capacity regressions", () => { const exact = makeScratchHarness(pointerWidth); const exactPointer = exact.processBytes.byteLength - ifconfSize; exact.processBytes.fill(0x6d, exactPointer - 16, exactPointer); - writeIfconf( - exact.processBytes, - pointerWidth, - exactPointer, - 0, - 0, - ); + writeIfconf(exact.processBytes, pointerWidth, exactPointer, 0, 0); const exactPrefix = exact.processBytes.slice( exactPointer - 16, exactPointer, ); - invokeNetworkIoctlHandler( - exact, - "handleIoctlIfconf", - exactPointer, - ); + invokeNetworkIoctlHandler(exact, "handleIoctlIfconf", exactPointer); expect(exact.completeChannelRaw).toHaveBeenCalledWith( exact.channel, @@ -4968,9 +5868,9 @@ describe("kernel scratch transfer capacity regressions", () => { expect( new DataView(exact.processBytes.buffer).getInt32(exactPointer, true), ).toBe(2 * ifreqSize); - expect( - exact.processBytes.slice(exactPointer - 16, exactPointer), - ).toEqual(exactPrefix); + expect(exact.processBytes.slice(exactPointer - 16, exactPointer)).toEqual( + exactPrefix, + ); expectScratchTailUntouched(exact); const short = makeScratchHarness(pointerWidth); @@ -4978,11 +5878,7 @@ describe("kernel scratch transfer capacity regressions", () => { short.processBytes.fill(0x6d, shortPointer - 16); const shortBefore = short.processBytes.slice(shortPointer - 16); - invokeNetworkIoctlHandler( - short, - "handleIoctlIfconf", - shortPointer, - ); + invokeNetworkIoctlHandler(short, "handleIoctlIfconf", shortPointer); expect(short.completeChannelRaw).toHaveBeenCalledWith( short.channel, @@ -5032,7 +5928,9 @@ describe("kernel scratch transfer capacity regressions", () => { short.completeChannelRaw, `ioctl 0x${entry.request.toString(16)}`, ).toHaveBeenCalledWith(short.channel, -EFAULT, EFAULT); - expect(short.processBytes.slice(shortPointer - 16)).toEqual(shortBefore); + expect(short.processBytes.slice(shortPointer - 16)).toEqual( + shortBefore, + ); expectScratchTailUntouched(short); } }, @@ -5062,11 +5960,7 @@ describe("kernel scratch transfer capacity regressions", () => { guardEnd, ); - invokeNetworkIoctlHandler( - harness, - "handleIoctlIfconf", - ifconfPointer, - ); + invokeNetworkIoctlHandler(harness, "handleIoctlIfconf", ifconfPointer); expect(harness.completeChannelRaw).toHaveBeenCalledWith( harness.channel, @@ -5084,8 +5978,9 @@ describe("kernel scratch transfer capacity regressions", () => { harness.processBytes.slice(outputPointer, outputPointer + 2), ), ).toBe("lo"); - expect(harness.processBytes.slice(guardStart, outputPointer)) - .toEqual(prefix); + expect(harness.processBytes.slice(guardStart, outputPointer)).toEqual( + prefix, + ); expect( harness.processBytes.slice(outputPointer + ifreqSize, guardEnd), ).toEqual(suffix); @@ -5111,24 +6006,18 @@ describe("kernel scratch transfer capacity regressions", () => { outputPointer, ); - invokeNetworkIoctlHandler( - harness, - "handleIoctlIfconf", - ifconfPointer, - ); + invokeNetworkIoctlHandler(harness, "handleIoctlIfconf", ifconfPointer); expect(harness.completeChannelRaw).toHaveBeenCalledWith( harness.channel, -EFAULT, EFAULT, ); - expect(harness.processBytes.slice(outputPointer - 16)) - .toEqual(outputBefore); + expect(harness.processBytes.slice(outputPointer - 16)).toEqual( + outputBefore, + ); expect( - new DataView(harness.processBytes.buffer).getInt32( - ifconfPointer, - true, - ), + new DataView(harness.processBytes.buffer).getInt32(ifconfPointer, true), ).toBe(ifreqSize); expectScratchTailUntouched(harness); }, @@ -5144,11 +6033,7 @@ describe("kernel scratch transfer capacity regressions", () => { const ifconfPointer = 4096; const lowAlias = Number(nestedPointer & 0xffff_ffffn); const ifreqSize = 40; - harness.processBytes.fill( - 0x6d, - lowAlias - 16, - lowAlias + ifreqSize + 16, - ); + harness.processBytes.fill(0x6d, lowAlias - 16, lowAlias + ifreqSize + 16); const lowBefore = harness.processBytes.slice( lowAlias - 16, lowAlias + ifreqSize + 16, @@ -5161,11 +6046,7 @@ describe("kernel scratch transfer capacity regressions", () => { nestedPointer, ); - invokeNetworkIoctlHandler( - harness, - "handleIoctlIfconf", - ifconfPointer, - ); + invokeNetworkIoctlHandler(harness, "handleIoctlIfconf", ifconfPointer); expect(harness.completeChannelRaw).toHaveBeenCalledWith( harness.channel, @@ -5173,10 +6054,7 @@ describe("kernel scratch transfer capacity regressions", () => { EFAULT, ); expect( - harness.processBytes.slice( - lowAlias - 16, - lowAlias + ifreqSize + 16, - ), + harness.processBytes.slice(lowAlias - 16, lowAlias + ifreqSize + 16), ).toEqual(lowBefore); expectScratchTailUntouched(harness); }, @@ -5190,7 +6068,10 @@ describe("kernel scratch transfer capacity regressions", () => { const canary = new Uint8Array(32).fill(0x6d); harness.processBytes.set(canary, outputPointer + result.byteLength); harness.handleChannel.mockImplementation((offset: number | bigint) => { - const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); const scratchPointer = Number( channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true), ); @@ -5205,26 +6086,28 @@ describe("kernel scratch transfer capacity regressions", () => { channelView.setUint32(CH_ERRNO, 0, true); return 0; }); - harness.completeChannel.mockImplementation(( - _channel: TestChannel, - _syscallNr: number, - _origArgs: number[], - _argDescs: unknown, - _retVal: number, - _errVal: number, - writes: Array<{ ptr: number; bytes: Uint8Array }>, - ) => { - for (const write of writes) { - harness.processBytes.set(write.bytes, write.ptr); - } - }); + harness.completeChannel.mockImplementation( + ( + _channel: TestChannel, + _syscallNr: number, + _origArgs: number[], + _argDescs: unknown, + _retVal: number, + _errVal: number, + writes: Array<{ ptr: number; bytes: Uint8Array }>, + ) => { + for (const write of writes) { + harness.processBytes.set(write.bytes, write.ptr); + } + }, + ); writeChannelSyscall(harness, ABI_SYSCALLS.Ioctl, [ 7n, 0x541bn, BigInt(outputPointer), ]); - harness.worker._handleSyscallInner(harness.channel); + dispatchScratchBoundarySyscall(harness); expect( harness.processBytes.slice(outputPointer, outputPointer + result.length), @@ -5235,8 +6118,10 @@ describe("kernel scratch transfer capacity regressions", () => { outputPointer + result.length + canary.length, ), ).toEqual(canary); - const writes = harness.completeChannel.mock.calls[0]?.[6] as - Array<{ ptr: number; bytes: Uint8Array }>; + const writes = harness.completeChannel.mock.calls[0]?.[6] as Array<{ + ptr: number; + bytes: Uint8Array; + }>; expect(writes[0]?.bytes).toHaveLength(4); expectScratchTailUntouched(harness); }); @@ -5251,13 +6136,12 @@ describe("kernel scratch transfer capacity regressions", () => { const harness = makeScratchHarness(pointerWidth); prepareGenericSyscallHarness(harness, pointerWidth); const processPointer = harness.processBytes.byteLength - size; - harness.processBytes.fill( - 0x4b, - processPointer, - processPointer + size, - ); + harness.processBytes.fill(0x4b, processPointer, processPointer + size); harness.handleChannel.mockImplementation((offset: number | bigint) => { - const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); const scratchPointer = Number( channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true), ); @@ -5280,12 +6164,11 @@ describe("kernel scratch transfer capacity regressions", () => { BigInt(processPointer), ]); - harness.worker._handleSyscallInner(harness.channel); + dispatchScratchBoundarySyscall(harness); expect(harness.handleChannel).toHaveBeenCalledOnce(); expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ - 0, - 0, + 0, 0, ]); expectScratchTailUntouched(harness); }, @@ -5301,7 +6184,7 @@ describe("kernel scratch transfer capacity regressions", () => { BigInt(invalidPointer), ]); - harness.worker._handleSyscallInner(harness.channel); + dispatchScratchBoundarySyscall(harness); expect(harness.handleChannel).not.toHaveBeenCalled(); expect(harness.completeChannel).toHaveBeenCalledWith( @@ -5318,13 +6201,9 @@ describe("kernel scratch transfer capacity regressions", () => { it("rejects a null pointer for a pointer-valued ioctl", () => { const harness = makeScratchHarness(4); prepareGenericSyscallHarness(harness, 4); - writeChannelSyscall(harness, ABI_SYSCALLS.Ioctl, [ - 7n, - 0x541bn, - 0n, - ]); + writeChannelSyscall(harness, ABI_SYSCALLS.Ioctl, [7n, 0x541bn, 0n]); - harness.worker._handleSyscallInner(harness.channel); + dispatchScratchBoundarySyscall(harness); expect(harness.handleChannel).not.toHaveBeenCalled(); expect(harness.completeChannel).toHaveBeenCalledWith( @@ -5346,7 +6225,10 @@ describe("kernel scratch transfer capacity regressions", () => { const harness = makeScratchHarness(8); prepareGenericSyscallHarness(harness, 8); harness.handleChannel.mockImplementation((offset: number | bigint) => { - const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); expect( Number(channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true)), ).toBe(expectedArgument); @@ -5366,12 +6248,11 @@ describe("kernel scratch transfer capacity regressions", () => { argument, ]); - harness.worker._handleSyscallInner(harness.channel); + dispatchScratchBoundarySyscall(harness); expect(harness.handleChannel).toHaveBeenCalledOnce(); expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ - 0, - 0, + 0, 0, ]); expectScratchTailUntouched(harness); } @@ -5393,7 +6274,10 @@ describe("kernel scratch transfer capacity regressions", () => { const harness = makeScratchHarness(8); prepareGenericSyscallHarness(harness, 8); harness.handleChannel.mockImplementation((offset: number | bigint) => { - const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); expect( Number(channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true)), ).toBe(expectedArgument); @@ -5413,12 +6297,11 @@ describe("kernel scratch transfer capacity regressions", () => { argument, ]); - harness.worker._handleSyscallInner(harness.channel); + dispatchScratchBoundarySyscall(harness); expect(harness.handleChannel).toHaveBeenCalledOnce(); expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ - 0, - 0, + 0, 0, ]); expectScratchTailUntouched(harness); } @@ -5429,7 +6312,10 @@ describe("kernel scratch transfer capacity regressions", () => { const harness = makeScratchHarness(8); prepareGenericSyscallHarness(harness, 8); harness.handleChannel.mockImplementation((offset: number | bigint) => { - const channelView = new DataView(harness.kernelBytes.buffer, Number(offset)); + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); expect( Number(channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true)), ).toBe(0); @@ -5446,12 +6332,11 @@ describe("kernel scratch transfer capacity regressions", () => { 0x2000_0000_0000n, ]); - harness.worker._handleSyscallInner(harness.channel); + dispatchScratchBoundarySyscall(harness); expect(harness.handleChannel).toHaveBeenCalledOnce(); expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ - -1, - 25, + -1, 25, ]); expectScratchTailUntouched(harness); }); @@ -5470,13 +6355,13 @@ describe("kernel scratch transfer capacity regressions", () => { 4096n, ]); - harness.worker._handleSyscallInner(harness.channel); + dispatchScratchBoundarySyscall(harness); expect(harness.handleChannel).not.toHaveBeenCalled(); expect(harness.completeChannel).toHaveBeenCalledWith( harness.channel, ABI_SYSCALLS.Ioctl, - [7, request, 4096, 0, 0, 0], + [7, Number(BigInt.asIntN(32, BigInt(request))), 4096, 0, 0, 0], undefined, -1, EOVERFLOW, diff --git a/host/test/kernel-shared-memory-inheritance-entry.test.ts b/host/test/kernel-shared-memory-inheritance-entry.test.ts new file mode 100644 index 0000000000..8020b773bf --- /dev/null +++ b/host/test/kernel-shared-memory-inheritance-entry.test.ts @@ -0,0 +1,682 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createCentralizedKernelWorkerTestDouble, + type CentralizedKernelWorker, +} from "../src/kernel-worker"; +import { KernelReentrantEntryError } from "../src/kernel-entry-gate"; +import type { PlatformIO } from "../src/types"; +import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; + +const KERNEL_EXPORT_NAMES = [ + "kernel_ipc_shm_read_chunk", + "kernel_ipc_shmat_for_process", + "kernel_ipc_shmdt_for_process", +] as const; + +interface TestProcessRegistration { + readonly pid: number; + readonly memory: WebAssembly.Memory; + readonly channels: readonly unknown[]; + readonly ptrWidth: 4 | 8; + readonly explicitMaxAddr: boolean; +} + +interface TestSharedMapping { + readonly fd: number; + readonly fileOffset: number; + readonly len: number; + readonly writable: boolean; + readonly backingKind: "anonymous" | "file"; + readonly backingKey: string; + readonly snapshot: Uint8Array; + readonly seenVersion: number; +} + +interface TestSysvMapping { + readonly segId: number; + readonly size: number; + readonly readOnly: boolean; + readonly snapshot: Uint8Array; + readonly seenVersion: number; +} + +interface TestAnonymousBacking { + readonly key: string; + readonly bytes: Uint8Array; + refCount: number; + version: number; +} + +interface TestFileBacking { + readonly key: string; + readonly handle: number; + readonly writable: boolean; + readonly size: number; + readonly sizeValid: boolean; + readonly pages: Map; + readonly dirtyPages: Set; + refCount: number; + version: number; +} + +interface SharedInheritanceState { + processes: Map; + sharedMappings: Map>; + anonymousSharedBackings: Map; + sharedMmapBackings: Map; + shmMappings: Map>; + shmSegmentVersions: Map; +} + +interface InheritanceHarness { + readonly worker: CentralizedKernelWorker; + readonly state: SharedInheritanceState; + readonly kernelMemory: WebAssembly.Memory; + readonly implementations: Record; +} + +function processMemory(): WebAssembly.Memory { + return new WebAssembly.Memory({ + initial: 1, + maximum: 1, + shared: true, + }); +} + +function processRegistration( + pid: number, + memory: WebAssembly.Memory, +): TestProcessRegistration { + return { + pid, + memory, + channels: [], + ptrWidth: 4, + explicitMaxAddr: true, + }; +} + +function setWorkerState( + worker: CentralizedKernelWorker, + name: keyof SharedInheritanceState, + value: SharedInheritanceState[keyof SharedInheritanceState], +): void { + if (!Object.prototype.hasOwnProperty.call(worker, name)) { + throw new Error(`test worker is missing production state ${name}`); + } + Reflect.set(worker, name, value); +} + +function makeHarness( + options: { + readonly io?: Partial; + readonly implementations?: Record; + } = {}, +): InheritanceHarness { + const implementations: Record = { + kernel_ipc_shm_read_chunk: () => 0, + kernel_ipc_shmat_for_process: () => -1, + kernel_ipc_shmdt_for_process: () => 0, + ...options.implementations, + }; + const worker = createCentralizedKernelWorkerTestDouble({ + io: options.io as PlatformIO | undefined, + }); + const kernelMemory = new WebAssembly.Memory({ + initial: 4, + maximum: 4, + }); + installKernelWorkerTestScratch(worker, kernelMemory, 4_096, 4, { + kernelExports: implementations, + kernelExportNames: KERNEL_EXPORT_NAMES, + }); + const state: SharedInheritanceState = { + processes: new Map(), + sharedMappings: new Map(), + anonymousSharedBackings: new Map(), + sharedMmapBackings: new Map(), + shmMappings: new Map(), + shmSegmentVersions: new Map(), + }; + for (const [name, value] of Object.entries(state)) { + setWorkerState( + worker, + name as keyof SharedInheritanceState, + value, + ); + } + return { worker, state, kernelMemory, implementations }; +} + +describe("shared-memory inheritance entry authority", () => { + it("keeps child ownership private across a reentrant host backing read", () => { + const parentPid = 41; + const childPid = 42; + const mapAddr = 0x1000; + const length = 32; + const backingKey = "file:test"; + const childMemory = processMemory(); + new Uint8Array(childMemory.buffer, mapAddr, length).fill(0x55); + let harness!: InheritanceHarness; + let retainedBackendView: Uint8Array | undefined; + const reentrantErrors: unknown[] = []; + const observations: Array<{ + readonly childMapped: boolean; + readonly refCount: number; + readonly firstByte: number; + }> = []; + const io = { + read: ( + _handle: number, + output: Uint8Array, + _offset: number | bigint | null, + count: number, + ) => { + const backing = + harness.state.sharedMmapBackings.get(backingKey)!; + observations.push({ + childMapped: harness.state.sharedMappings.has(childPid), + refCount: backing.refCount, + firstByte: + new Uint8Array(childMemory.buffer)[mapAddr]!, + }); + try { + harness.worker.inheritProcessSharedMappings(parentPid, childPid); + } catch (error) { + reentrantErrors.push(error); + } + retainedBackendView = output; + output.fill(0xa7, 0, count); + return count; + }, + } as unknown as Partial; + harness = makeHarness({ io }); + const backing: TestFileBacking = { + key: backingKey, + handle: 7, + writable: true, + size: length, + sizeValid: true, + pages: new Map(), + dirtyPages: new Set(), + refCount: 1, + version: 0, + }; + harness.state.processes.set( + childPid, + processRegistration(childPid, childMemory), + ); + harness.state.sharedMmapBackings.set(backingKey, backing); + harness.state.sharedMappings.set(parentPid, new Map([ + [mapAddr, { + fd: 4, + fileOffset: 0, + len: length, + writable: true, + backingKind: "file", + backingKey, + snapshot: new Uint8Array(length), + seenVersion: 0, + }], + ])); + + harness.worker.inheritProcessSharedMappings(parentPid, childPid); + + expect(observations).toEqual([{ + childMapped: false, + refCount: 1, + firstByte: 0x55, + }]); + expect(reentrantErrors).toHaveLength(1); + expect(reentrantErrors[0]).toBeInstanceOf(KernelReentrantEntryError); + expect(backing.refCount).toBe(2); + expect(harness.state.sharedMappings.get(childPid)?.size).toBe(1); + expect( + Array.from( + new Uint8Array(childMemory.buffer, mapAddr, length), + ), + ).toEqual(Array(length).fill(0xa7)); + + // WHY: a PlatformIO implementation may retain the view it was given. + // The staged read must publish an owned copy, not let that backend mutate + // the backing cache or inherited process bytes after the lease commits. + retainedBackendView!.fill(0x19); + expect(backing.pages.get(0)?.[0]).toBe(0xa7); + expect(new Uint8Array(childMemory.buffer)[mapAddr]).toBe(0xa7); + }); + + it("rejects an in-place child memory replacement before Rust attachment or publication", () => { + const parentPid = 45; + const childPid = 46; + const sharedAddr = 0x1000; + const sysvAddr = 0x2000; + const length = 16; + const backingKey = "file:memory-replacement"; + const originalMemory = processMemory(); + const replacementMemory = processMemory(); + new Uint8Array(originalMemory.buffer).fill(0x55); + new Uint8Array(replacementMemory.buffer).fill(0x66); + const childRegistration = + processRegistration(childPid, originalMemory); + const reentrantErrors: unknown[] = []; + let harness!: InheritanceHarness; + const shmat = vi.fn(() => length); + const shmdt = vi.fn(() => 0); + const io = { + read: ( + _handle: number, + output: Uint8Array, + _offset: number | bigint | null, + count: number, + ) => { + try { + harness.worker.inheritProcessSharedMappings(parentPid, childPid); + } catch (error) { + reentrantErrors.push(error); + } + Reflect.set(childRegistration, "memory", replacementMemory); + output.fill(0xa7, 0, count); + return count; + }, + } as unknown as Partial; + harness = makeHarness({ + io, + implementations: { + kernel_ipc_shmat_for_process: shmat, + kernel_ipc_shmdt_for_process: shmdt, + }, + }); + const backing: TestFileBacking = { + key: backingKey, + handle: 8, + writable: true, + size: length, + sizeValid: true, + pages: new Map(), + dirtyPages: new Set(), + refCount: 1, + version: 0, + }; + harness.state.processes.set(childPid, childRegistration); + harness.state.sharedMmapBackings.set(backingKey, backing); + harness.state.sharedMappings.set(parentPid, new Map([ + [sharedAddr, { + fd: 4, + fileOffset: 0, + len: length, + writable: true, + backingKind: "file", + backingKey, + snapshot: new Uint8Array(length), + seenVersion: 0, + }], + ])); + harness.state.shmMappings.set(parentPid, new Map([ + [sysvAddr, { + segId: 11, + size: length, + readOnly: false, + snapshot: new Uint8Array(length), + seenVersion: 0, + }], + ])); + + expect(() => { + harness.worker.inheritProcessSharedMappings(parentPid, childPid); + }).toThrow(/changed during shared mapping inheritance/); + + expect(reentrantErrors).toHaveLength(1); + expect(reentrantErrors[0]).toBeInstanceOf(KernelReentrantEntryError); + expect(shmat).not.toHaveBeenCalled(); + expect(shmdt).not.toHaveBeenCalled(); + expect(backing.refCount).toBe(1); + expect(harness.state.sharedMappings.has(childPid)).toBe(false); + expect(harness.state.shmMappings.has(childPid)).toBe(false); + expect(new Uint8Array(originalMemory.buffer)[sharedAddr]).toBe(0x55); + expect(new Uint8Array(originalMemory.buffer)[sysvAddr]).toBe(0x55); + expect(new Uint8Array(replacementMemory.buffer)[sharedAddr]).toBe(0x66); + expect(new Uint8Array(replacementMemory.buffer)[sysvAddr]).toBe(0x66); + }); + + it("rejects an oversized backing write result without exposing cached bytes", () => { + const pid = 47; + const backingKey = "file:write-result"; + const page = new Uint8Array(4096).fill(0x6a); + let harness!: InheritanceHarness; + let retainedBackendView: Uint8Array | undefined; + const reentrantErrors: unknown[] = []; + const io = { + write: ( + _handle: number, + input: Uint8Array, + _offset: number | bigint | null, + count: number, + ) => { + retainedBackendView = input; + try { + harness.worker.finalizeAddressSpaceForExec(pid + 1); + } catch (error) { + reentrantErrors.push(error); + } + // A backend may be buggy or hostile, but it cannot claim ownership of + // one byte beyond the exact slice supplied by this write iteration. + return count + 1; + }, + } as unknown as Partial; + harness = makeHarness({ io }); + const backing: TestFileBacking = { + key: backingKey, + handle: 9, + writable: true, + size: 16, + sizeValid: true, + pages: new Map([[0, page]]), + dirtyPages: new Set([0]), + refCount: 1, + version: 0, + }; + harness.state.sharedMmapBackings.set(backingKey, backing); + harness.state.sharedMappings.set(pid, new Map([ + [0x1000, { + fd: 4, + fileOffset: 0, + len: 16, + writable: true, + backingKind: "file", + backingKey, + snapshot: new Uint8Array(16), + seenVersion: 0, + }], + ])); + + expect(harness.worker.finalizeAddressSpaceForExec(pid)).toBe(0); + + expect(reentrantErrors).toHaveLength(1); + expect(reentrantErrors[0]).toBeInstanceOf(KernelReentrantEntryError); + expect(retainedBackendView).toHaveLength(16); + expect(backing.refCount).toBe(0); + expect(backing.dirtyPages.has(0)).toBe(true); + expect(harness.state.sharedMmapBackings.get(backingKey)).toBe(backing); + expect(harness.state.sharedMappings.has(pid)).toBe(false); + + // The backend saw only an owned write snapshot. Retaining and mutating it + // after return cannot rewrite the still-dirty authoritative cache. + retainedBackendView!.fill(0x19); + expect(backing.pages.get(0)?.[0]).toBe(0x6a); + expect(harness.worker.finalizeAddressSpaceForExec(pid + 2)).toBe(0); + }); + + it("holds gate ownership across a host-only backing write", () => { + const pid = 48; + const backingKey = "file:host-only-write"; + const page = new Uint8Array(4096).fill(0x72); + let harness!: InheritanceHarness; + let retainedBackendView: Uint8Array | undefined; + const reentrantErrors: unknown[] = []; + const io = { + write: ( + _handle: number, + input: Uint8Array, + _offset: number | bigint | null, + count: number, + ) => { + retainedBackendView = input; + try { + harness.worker.finalizeAddressSpaceForExec(pid); + } catch (error) { + reentrantErrors.push(error); + } + return count; + }, + } as unknown as Partial; + harness = makeHarness({ io }); + const backing: TestFileBacking = { + key: backingKey, + handle: 10, + writable: true, + size: 16, + sizeValid: true, + pages: new Map([[0, page]]), + dirtyPages: new Set([0]), + refCount: 1, + version: 0, + }; + harness.state.sharedMmapBackings.set(backingKey, backing); + + expect((harness.worker as any).flushSharedMmapBackingRange( + backing, + 0, + 16, + )).toBe(true); + + expect(reentrantErrors).toHaveLength(1); + expect(reentrantErrors[0]).toBeInstanceOf(KernelReentrantEntryError); + expect(backing.dirtyPages.has(0)).toBe(false); + retainedBackendView!.fill(0x21); + expect(backing.pages.get(0)?.[0]).toBe(0x72); + expect(harness.worker.finalizeAddressSpaceForExec(pid)).toBe(0); + }); + + it("rolls back an earlier SysV attachment before publishing any child state", async () => { + const parentPid = 51; + const childPid = 52; + const anonymousAddr = 0x1000; + const firstSysvAddr = 0x2000; + const secondSysvAddr = 0x3000; + const size = 16; + const backingKey = "anon:test"; + const anonymousBytes = new Uint8Array(size).fill(0x31); + const segments = new Map([ + [11, new Uint8Array(size).fill(0x41)], + [12, new Uint8Array(size).fill(0x42)], + ]); + const childMemory = processMemory(); + new Uint8Array(childMemory.buffer).fill(0x77); + let harness!: InheritanceHarness; + const shmat = vi.fn((_pid: number, segId: number) => + segId === 11 ? size : -12); + const shmdt = vi.fn(() => 0); + const readChunk = vi.fn(( + segId: number, + offset: number, + pointer: number, + maxLength: number, + ) => { + const segment = segments.get(segId)!; + const length = Math.min(maxLength, segment.byteLength - offset); + new Uint8Array(harness.kernelMemory.buffer).set( + segment.subarray(offset, offset + length), + pointer, + ); + return length; + }); + harness = makeHarness({ + implementations: { + kernel_ipc_shm_read_chunk: readChunk, + kernel_ipc_shmat_for_process: shmat, + kernel_ipc_shmdt_for_process: shmdt, + }, + }); + const backing: TestAnonymousBacking = { + key: backingKey, + bytes: anonymousBytes, + refCount: 1, + version: 0, + }; + harness.state.processes.set( + childPid, + processRegistration(childPid, childMemory), + ); + harness.state.anonymousSharedBackings.set(backingKey, backing); + harness.state.sharedMappings.set(parentPid, new Map([ + [anonymousAddr, { + fd: -1, + fileOffset: 0, + len: size, + writable: true, + backingKind: "anonymous", + backingKey, + snapshot: new Uint8Array(size), + seenVersion: 0, + }], + ])); + const sysvMapping = (segId: number): TestSysvMapping => ({ + segId, + size, + readOnly: false, + snapshot: new Uint8Array(size), + seenVersion: 0, + }); + harness.state.shmMappings.set(parentPid, new Map([ + [firstSysvAddr, sysvMapping(11)], + [secondSysvAddr, sysvMapping(12)], + ])); + + expect(() => { + harness.worker.inheritProcessSharedMappings(parentPid, childPid); + }).toThrow(/SysV shmat inheritance failed for segment 12/); + + expect(shmat.mock.calls).toEqual([ + [childPid, 11, firstSysvAddr, 0], + [childPid, 12, secondSysvAddr, 0], + ]); + expect(readChunk).toHaveBeenCalledOnce(); + expect(shmdt).toHaveBeenCalledExactlyOnceWith(childPid, 11); + expect(backing.refCount).toBe(1); + expect(harness.state.sharedMappings.has(childPid)).toBe(false); + expect(harness.state.shmMappings.has(childPid)).toBe(false); + expect( + new Uint8Array(childMemory.buffer)[anonymousAddr], + ).toBe(0x77); + expect( + new Uint8Array(childMemory.buffer)[firstSysvAddr], + ).toBe(0x77); + + // Expected errno rollback leaves the generation reusable. + await Promise.resolve(); + shmat.mockImplementation(() => size); + harness.worker.inheritProcessSharedMappings(parentPid, childPid); + + expect(backing.refCount).toBe(2); + expect(harness.state.sharedMappings.get(childPid)?.size).toBe(1); + expect(harness.state.shmMappings.get(childPid)?.size).toBe(2); + expect( + new Uint8Array(childMemory.buffer)[anonymousAddr], + ).toBe(0x31); + expect( + new Uint8Array(childMemory.buffer)[firstSysvAddr], + ).toBe(0x41); + expect( + new Uint8Array(childMemory.buffer)[secondSysvAddr], + ).toBe(0x42); + }); + + it("restores bytes and prior refcounts if host publication fails mid-retain", () => { + const parentPid = 61; + const childPid = 62; + const firstAddr = 0x1000; + const secondAddr = 0x2000; + const size = 16; + const childMemory = processMemory(); + new Uint8Array(childMemory.buffer).fill(0x66); + const harness = makeHarness(); + const firstBacking: TestAnonymousBacking = { + key: "anon:first", + bytes: new Uint8Array(size).fill(0x11), + refCount: 1, + version: 0, + }; + let secondRefCount = 1; + const secondBacking = { + key: "anon:second", + bytes: new Uint8Array(size).fill(0x22), + version: 0, + } as TestAnonymousBacking; + Object.defineProperty(secondBacking, "refCount", { + configurable: false, + enumerable: true, + get: () => secondRefCount, + set: (_value: number) => { + throw new Error("injected retain publication failure"); + }, + }); + harness.state.processes.set( + childPid, + processRegistration(childPid, childMemory), + ); + harness.state.anonymousSharedBackings.set( + firstBacking.key, + firstBacking, + ); + harness.state.anonymousSharedBackings.set( + secondBacking.key, + secondBacking, + ); + const mapping = (backing: TestAnonymousBacking): TestSharedMapping => ({ + fd: -1, + fileOffset: 0, + len: size, + writable: true, + backingKind: "anonymous", + backingKey: backing.key, + snapshot: new Uint8Array(size), + seenVersion: 0, + }); + harness.state.sharedMappings.set(parentPid, new Map([ + [firstAddr, mapping(firstBacking)], + [secondAddr, mapping(secondBacking)], + ])); + + let failure: unknown; + try { + harness.worker.inheritProcessSharedMappings(parentPid, childPid); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(Error); + expect( + (failure as Error & { cause?: unknown }).cause, + ).toEqual(expect.objectContaining({ + message: "injected retain publication failure", + })); + expect(firstBacking.refCount).toBe(1); + expect(secondRefCount).toBe(1); + expect(harness.state.sharedMappings.has(childPid)).toBe(false); + expect(harness.state.shmMappings.has(childPid)).toBe(false); + expect(new Uint8Array(childMemory.buffer)[firstAddr]).toBe(0x66); + expect(new Uint8Array(childMemory.buffer)[secondAddr]).toBe(0x66); + }); + + it("rejects a non-lossless wasm64 SysV address before attachment", () => { + const parentPid = 71; + const childPid = 72; + const highAddress = 0x1_0000_0000; + const shmat = vi.fn(() => 16); + const harness = makeHarness({ + implementations: { + kernel_ipc_shmat_for_process: shmat, + }, + }); + harness.state.processes.set( + childPid, + processRegistration(childPid, processMemory()), + ); + harness.state.shmMappings.set(parentPid, new Map([ + [highAddress, { + segId: 11, + size: 16, + readOnly: false, + snapshot: new Uint8Array(16), + seenVersion: 0, + }], + ])); + + expect(() => { + harness.worker.inheritProcessSharedMappings(parentPid, childPid); + }).toThrow(/Cannot inherit SysV mapping/); + expect(shmat).not.toHaveBeenCalled(); + expect(harness.state.shmMappings.has(childPid)).toBe(false); + }); +}); diff --git a/host/test/kernel-teardown-pipe-entry.test.ts b/host/test/kernel-teardown-pipe-entry.test.ts new file mode 100644 index 0000000000..4b27f10ef5 --- /dev/null +++ b/host/test/kernel-teardown-pipe-entry.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createCentralizedKernelWorkerTestDouble, +} from "../src/kernel-worker"; +import { + createKernelEntryGatedInstance, + KernelEntryGate, +} from "../src/kernel-entry-gate"; +import { + allocateKernelScratchRegion, +} from "../src/kernel-scratch"; +import { + ABI_SYSCALLS, + CHANNEL_STATUS_PENDING, + CH_ARGS, + CH_ARGS_COUNT, + CH_ARG_SIZE, + CH_ERRNO, + CH_RETURN, + CH_SIG_HANDLER, + CH_SIG_SIGNUM, + CH_STATUS, + CH_SYSCALL, + CH_TOTAL_SIZE, +} from "../src/generated/abi"; +import { + createKernelScratchTestInstance, +} from "./support/kernel-scratch-instance"; + +const PID = 41; +const PIPE_INDEX = 17; +const SCRATCH_OFFSET = 4096; + +interface TestChannel { + readonly pid: number; + readonly memory: WebAssembly.Memory; + readonly channelOffset: number; + i32View: Int32Array; + consecutiveSyscalls: number; + handling: boolean; +} + +interface EntryHarness { + readonly worker: ReturnType; + readonly gate: KernelEntryGate; + readonly gatedInstance: WebAssembly.Instance; + readonly implementations: Record; + readonly channel: TestChannel; + readonly channelView: DataView; + readonly handleChannel: ReturnType; + readonly completeChannel: ReturnType; +} + +function makeHarness(): EntryHarness { + const kernelMemory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); + const processMemory = new WebAssembly.Memory({ + initial: 2, + maximum: 2, + shared: true, + }); + const channel: TestChannel = { + pid: PID, + memory: processMemory, + channelOffset: 0, + i32View: new Int32Array( + processMemory.buffer, + 0, + CH_TOTAL_SIZE / Int32Array.BYTES_PER_ELEMENT, + ), + consecutiveSyscalls: 0, + handling: true, + }; + const channelView = new DataView(processMemory.buffer); + channelView.setUint32(CH_STATUS, CHANNEL_STATUS_PENDING, true); + channelView.setUint32(CH_SYSCALL, ABI_SYSCALLS.Getpid, true); + for (let index = 0; index < CH_ARGS_COUNT; index++) { + channelView.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, 0n, true); + } + + const kernelBytes = new Uint8Array(kernelMemory.buffer); + const handleChannel = vi.fn((pointer: number | bigint) => { + const view = new DataView(kernelMemory.buffer, Number(pointer)); + view.setBigInt64(CH_RETURN, BigInt(PID), true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }); + const implementations: Record = { + kernel_dequeue_signal: () => 0, + kernel_get_process_exit_signal: () => 0, + kernel_get_process_exit_status: () => -1, + kernel_handle_channel: handleChannel, + kernel_inject_mouse_event: () => 0, + kernel_set_current_tid: () => 0, + }; + const gate = new KernelEntryGate(); + const rawInstance = createKernelScratchTestInstance( + 4, + kernelMemory, + () => implementations, + () => SCRATCH_OFFSET, + ); + const gatedInstance = createKernelEntryGatedInstance(rawInstance, gate); + const scratch = allocateKernelScratchRegion( + kernelMemory, + gatedInstance.exports.kernel_alloc_scratch as + (capacity: number) => number, + CH_TOTAL_SIZE, + 4, + "teardown/pipe entry test scratch", + gatedInstance, + ); + const worker = createCentralizedKernelWorkerTestDouble(); + worker.testAuthority.initializeKernelForTest({ + instance: gatedInstance, + gate, + mainScratch: scratch, + tcpScratch: scratch, + }); + const completeChannel = vi.fn(); + worker.testAuthority.configureScratchBoundaryHooksForTest({ + completeChannel, + }); + + const state = worker as unknown as { + processes: Map; + activeChannels: TestChannel[]; + pendingPipeReaders: Map< + number, + Array<{ channel: TestChannel; pid: number }> + >; + pendingPipeWriters: Map< + number, + Array<{ channel: TestChannel; pid: number }> + >; + pendingPollRetries: Map< + TestChannel, + { + timer: null; + channel: TestChannel; + pipeIndices: number[]; + acceptIndices: number[]; + } + >; + }; + state.processes = new Map([[ + PID, + { + pid: PID, + memory: processMemory, + channels: [channel], + ptrWidth: 4, + }, + ]]); + state.activeChannels = [channel]; + + // Ensure the kernel scratch was not accidentally aliased to process state. + expect(kernelBytes.buffer).not.toBe(processMemory.buffer); + return { + worker, + gate, + gatedInstance, + implementations, + channel, + channelView, + handleChannel, + completeChannel, + }; +} + +function mutableState(harness: EntryHarness) { + return harness.worker as unknown as { + pendingPipeReaders: Map< + number, + Array<{ channel: TestChannel; pid: number }> + >; + pendingPipeWriters: Map< + number, + Array<{ channel: TestChannel; pid: number }> + >; + pendingPollRetries: Map< + TestChannel, + { + timer: null; + channel: TestChannel; + pipeIndices: number[]; + acceptIndices: number[]; + } + >; + }; +} + +describe("teardown and pipe notifications at the kernel entry gate", () => { + it("defers teardown behind an active export and resolves after its wake publication", async () => { + const harness = makeHarness(); + const getExitStatus = vi.fn(() => -1); + harness.implementations.kernel_get_process_exit_status = getExitStatus; + let teardown!: Promise>; + + harness.implementations.kernel_inject_mouse_event = () => { + teardown = harness.worker.killAllBlockedForTeardown(); + expect(getExitStatus).not.toHaveBeenCalled(); + expect(harness.completeChannel).not.toHaveBeenCalled(); + expect(harness.channelView.getUint32(CH_SIG_SIGNUM, true)).toBe(0); + return 0; + }; + const outer = harness.gatedInstance.exports.kernel_inject_mouse_event as + (dx: number, dy: number, buttons: number) => number; + + expect(outer(0, 0, 0)).toBe(0); + expect(getExitStatus).not.toHaveBeenCalled(); + const woken = await teardown; + + expect(getExitStatus).toHaveBeenCalledOnce(); + expect(getExitStatus).toHaveBeenCalledWith(PID); + expect(woken).toEqual(new Set([PID])); + expect(harness.channelView.getUint32(CH_SIG_SIGNUM, true)).toBe(9); + expect(harness.channelView.getUint32(CH_SIG_HANDLER, true)).toBe(0); + expect(harness.completeChannel).toHaveBeenCalledOnce(); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + ABI_SYSCALLS.Getpid, + [0, 0, 0, 0, 0, 0], + undefined, + -1, + 4, + ); + }); + + it("serializes interleaved readable and sequential writable retries", async () => { + const harness = makeHarness(); + const state = mutableState(harness); + state.pendingPipeReaders.set(PIPE_INDEX, [{ + channel: harness.channel, + pid: PID, + }]); + state.pendingPollRetries.set(harness.channel, { + timer: null, + channel: harness.channel, + pipeIndices: [PIPE_INDEX], + acceptIndices: [], + }); + + harness.implementations.kernel_inject_mouse_event = () => { + harness.worker.notifyPipeReadable(PIPE_INDEX); + expect(state.pendingPipeReaders.has(PIPE_INDEX)).toBe(true); + expect(state.pendingPollRetries.has(harness.channel)).toBe(true); + expect(harness.handleChannel).not.toHaveBeenCalled(); + return 0; + }; + const outer = harness.gatedInstance.exports.kernel_inject_mouse_event as + (dx: number, dy: number, buttons: number) => number; + + expect(outer(0, 0, 0)).toBe(0); + expect(harness.handleChannel).not.toHaveBeenCalled(); + await Promise.resolve(); + + expect(state.pendingPipeReaders.has(PIPE_INDEX)).toBe(false); + expect(state.pendingPollRetries.has(harness.channel)).toBe(false); + // The same mailbox appeared in both targeted collections. The gate's + // exact-channel dedupe permits one retry, never two overlapping dispatches. + expect(harness.handleChannel).toHaveBeenCalledOnce(); + + harness.handleChannel.mockClear(); + harness.completeChannel.mockClear(); + state.pendingPipeWriters.set(PIPE_INDEX, [{ + channel: harness.channel, + pid: PID, + }]); + harness.worker.notifyPipeWritable(PIPE_INDEX); + expect(state.pendingPipeWriters.has(PIPE_INDEX)).toBe(false); + expect(harness.handleChannel).not.toHaveBeenCalled(); + await Promise.resolve(); + + expect(harness.handleChannel).toHaveBeenCalledOnce(); + expect(harness.completeChannel).toHaveBeenCalledOnce(); + }); +}); diff --git a/host/test/kernel-telemetry-entry.test.ts b/host/test/kernel-telemetry-entry.test.ts new file mode 100644 index 0000000000..6446a70b01 --- /dev/null +++ b/host/test/kernel-telemetry-entry.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createCentralizedKernelWorkerTestDouble, + type CentralizedKernelWorker, +} from "../src/kernel-worker"; +import { + createKernelEntryGatedInstance, + KernelEntryGate, + KernelReentrantEntryError, +} from "../src/kernel-entry-gate"; +import { allocateKernelScratchRegion } from "../src/kernel-scratch"; +import { CH_TOTAL_SIZE } from "../src/generated/abi"; +import { createKernelScratchTestInstance } from "./support/kernel-scratch-instance"; + +const TELEMETRY_EXPORT_NAMES = [ + "kernel_get_fork_count", + "kernel_get_memory_pages", + "kernel_inject_mouse_event", + "kernel_spawn_scratch_retained_capacity", + "kernel_vblank", +] as const; + +interface TelemetryHarness { + readonly worker: CentralizedKernelWorker; + readonly gate: KernelEntryGate; + readonly gatedInstance: WebAssembly.Instance; + readonly implementations: Record; +} + +function kernelPointer( + pointerWidth: 4 | 8, + value: number, +): number | bigint { + return pointerWidth === 8 ? BigInt(value) : value; +} + +function makeHarness( + pointerWidth: 4 | 8, + options: { + readonly gate?: KernelEntryGate; + readonly exportNames?: readonly string[]; + readonly implementations?: Record; + } = {}, +): TelemetryHarness { + const gate = options.gate ?? new KernelEntryGate(); + const implementations: Record = { + kernel_get_fork_count: () => 11n, + kernel_get_memory_pages: () => 321, + kernel_inject_mouse_event: () => 0, + kernel_spawn_scratch_retained_capacity: () => + kernelPointer(pointerWidth, 84_386), + kernel_vblank: () => 0, + ...options.implementations, + }; + const memory = new WebAssembly.Memory({ + initial: 4, + maximum: 4, + }); + const rawInstance = createKernelScratchTestInstance( + pointerWidth, + memory, + () => implementations, + () => kernelPointer(pointerWidth, 4_096), + 4, + options.exportNames ?? TELEMETRY_EXPORT_NAMES, + ); + const gatedInstance = createKernelEntryGatedInstance(rawInstance, gate); + const mainScratch = allocateKernelScratchRegion( + memory, + gatedInstance.exports.kernel_alloc_scratch as + (capacity: number) => number | bigint, + CH_TOTAL_SIZE, + pointerWidth, + "telemetry entry test scratch", + gatedInstance, + ); + const worker = createCentralizedKernelWorkerTestDouble(); + worker.testAuthority.initializeKernelForTest({ + instance: gatedInstance, + gate, + mainScratch, + }); + return { + worker, + gate, + gatedInstance, + implementations, + }; +} + +describe("kernel telemetry entry authority", () => { + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s queries telemetry through one exact entry and validates capacity outside it", + (_name, pointerWidth) => { + const getForkCount = vi.fn(() => 11n); + const getMemoryPages = vi.fn(() => 321); + const getCapacity = vi.fn(() => + kernelPointer(pointerWidth, 84_386)); + const harness = makeHarness(pointerWidth, { + implementations: { + kernel_get_fork_count: getForkCount, + kernel_get_memory_pages: getMemoryPages, + kernel_spawn_scratch_retained_capacity: getCapacity, + }, + }); + + expect(harness.worker.getForkCount(47)).toBe(11n); + expect(harness.worker.getKernelMemoryPages()).toBe(321); + expect(harness.worker.getSpawnScratchCapacity()).toBe(84_386); + expect(getForkCount).toHaveBeenCalledExactlyOnceWith(47); + expect(getMemoryPages).toHaveBeenCalledOnce(); + expect(getCapacity).toHaveBeenCalledOnce(); + + harness.implementations.kernel_spawn_scratch_retained_capacity = + () => pointerWidth === 8 ? -1n : -1; + expect(() => { + harness.worker.getSpawnScratchCapacity(); + }).toThrow(/invalid spawn scratch capacity/); + + // Invalid telemetry is a rejected diagnostic, not a trapped Rust + // mutation. The same generation remains usable. + harness.implementations.kernel_spawn_scratch_retained_capacity = + () => kernelPointer(pointerWidth, 4_096); + expect(harness.worker.getSpawnScratchCapacity()).toBe(4_096); + }, + ); + + it("rejects result-bearing telemetry during a live export without queueing it", async () => { + const forkCount = vi.fn(() => 13n); + const memoryPages = vi.fn(() => 77); + const capacity = vi.fn(() => 98_304); + const caught: unknown[] = []; + let harness!: TelemetryHarness; + harness = makeHarness(4, { + implementations: { + kernel_get_fork_count: forkCount, + kernel_get_memory_pages: memoryPages, + kernel_spawn_scratch_retained_capacity: capacity, + kernel_vblank: () => { + for (const query of [ + () => harness.worker.getForkCount(51), + () => harness.worker.getKernelMemoryPages(), + () => harness.worker.getSpawnScratchCapacity(), + ]) { + try { + query(); + } catch (error) { + caught.push(error); + } + } + return 0; + }, + }, + }); + + ( + harness.gatedInstance.exports.kernel_vblank as () => number + )(); + await Promise.resolve(); + + expect(caught).toHaveLength(3); + for (const error of caught) { + expect(error).toBeInstanceOf(KernelReentrantEntryError); + } + expect(forkCount).not.toHaveBeenCalled(); + expect(memoryPages).not.toHaveBeenCalled(); + expect(capacity).not.toHaveBeenCalled(); + + expect(harness.worker.getForkCount(51)).toBe(13n); + expect(harness.worker.getKernelMemoryPages()).toBe(77); + expect(harness.worker.getSpawnScratchCapacity()).toBe(98_304); + }); + + it("materializes optional/missing-export outcomes after scope revocation", () => { + const vblank = vi.fn(() => 0); + const harness = makeHarness(4, { + exportNames: ["kernel_vblank"], + implementations: { kernel_vblank: vblank }, + }); + + expect(harness.worker.getForkCount(9)).toBe(0n); + expect(() => { + harness.worker.getKernelMemoryPages(); + }).toThrow("kernel_get_memory_pages export is unavailable"); + expect(() => { + harness.worker.getSpawnScratchCapacity(); + }).toThrow( + "kernel_spawn_scratch_retained_capacity export is unavailable", + ); + + // Missing optional/mismatched diagnostics must not poison the entry gate. + expect( + (harness.gatedInstance.exports.kernel_vblank as () => number)(), + ).toBe(0); + expect(vblank).toHaveBeenCalledOnce(); + }); + + it("registers mouse wake scheduling only in the detached protocol phase", () => { + const gate = new KernelEntryGate(); + const schedulingPhaseErrors: unknown[] = []; + const originalDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + "setImmediate", + ); + const scheduleImmediate = (( + _operation: (...args: unknown[]) => void, + ) => { + try { + gate.invokeKernelExport("scheduler phase probe", () => 0); + } catch (error) { + schedulingPhaseErrors.push(error); + } + return 1 as unknown as ReturnType; + }) as typeof setImmediate; + Object.defineProperty(globalThis, "setImmediate", { + configurable: true, + writable: true, + value: scheduleImmediate, + }); + let harness: TelemetryHarness; + try { + harness = makeHarness(4, { + gate, + exportNames: ["kernel_inject_mouse_event"], + implementations: { + kernel_inject_mouse_event: vi.fn(() => 0), + }, + }); + } finally { + if (originalDescriptor === undefined) { + Reflect.deleteProperty(globalThis, "setImmediate"); + } else { + Object.defineProperty( + globalThis, + "setImmediate", + originalDescriptor, + ); + } + } + const state = harness.worker as unknown as { + readonly pendingPipeReaders: Map>; + }; + state.pendingPipeReaders.set(1, new Set()); + + harness.worker.injectMouseEvent(1, 2, 3); + + expect(schedulingPhaseErrors).toHaveLength(1); + expect(schedulingPhaseErrors[0]).toBeInstanceOf( + KernelReentrantEntryError, + ); + expect( + (schedulingPhaseErrors[0] as KernelReentrantEntryError) + .activeExportName, + ).toBe("detached host phase"); + }); +}); diff --git a/host/test/kernel-wasm-input-snapshot.test.ts b/host/test/kernel-wasm-input-snapshot.test.ts index 5a8d2b7669..f2088c867a 100644 --- a/host/test/kernel-wasm-input-snapshot.test.ts +++ b/host/test/kernel-wasm-input-snapshot.test.ts @@ -1,6 +1,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { WasmPosixKernel } from "../src/kernel"; +import { + createWasmPosixKernelTestHarness, + WasmPosixKernel, +} from "../src/kernel"; function memoryImportModule(pointerWidth: 4 | 8): Uint8Array { return new Uint8Array([ @@ -16,15 +19,23 @@ function memoryImportModule(pointerWidth: 4 | 8): Uint8Array { ]); } -function kernel(): WasmPosixKernel { - return new WasmPosixKernel( - { +function kernel( + compile: (bytes: BufferSource) => Promise, +): WasmPosixKernel { + return createWasmPosixKernelTestHarness({ + config: { maxWorkers: 1, dataBufferSize: 65_536, useSharedMemory: true, }, - {} as never, - ); + engine: { + compile, + instantiate: async () => { + throw new Error("test compile unexpectedly succeeded"); + }, + }, + initialized: false, + }); } function expectBytes(source: BufferSource | undefined, expected: Uint8Array): void { @@ -64,12 +75,12 @@ describe("kernel WebAssembly input snapshots", () => { const source = new SpoofedUint8Array(actual); const compileFailure = new Error("stop after capturing compile input"); let compiledSource: BufferSource | undefined; - vi.spyOn(WebAssembly, "compile").mockImplementation(async (bytes) => { + const compile = vi.fn(async (bytes: BufferSource) => { compiledSource = bytes; throw compileFailure; }); - const instance = kernel(); + const instance = kernel(compile); await expect(instance.init(source)).rejects.toBe(compileFailure); expect(getterReads).toBe(0); @@ -77,7 +88,7 @@ describe("kernel WebAssembly input snapshots", () => { expectBytes(compiledSource, actual); }); - it("uses a DataView subclass's intrinsic window for both width detection and initWithMemory compilation", async () => { + it("uses a DataView subclass's intrinsic window for width detection and init compilation", async () => { const actual = memoryImportModule(8); const decoy = memoryImportModule(4); const prefixLength = 7; @@ -110,18 +121,12 @@ describe("kernel WebAssembly input snapshots", () => { ); const compileFailure = new Error("stop after capturing compile input"); let compiledSource: BufferSource | undefined; - vi.spyOn(WebAssembly, "compile").mockImplementation(async (bytes) => { + const compile = vi.fn(async (bytes: BufferSource) => { compiledSource = bytes; throw compileFailure; }); - const memory = new WebAssembly.Memory({ - initial: 1, - maximum: 1, - shared: true, - }); - - const instance = kernel(); - await expect(instance.initWithMemory(source, memory)) + const instance = kernel(compile); + await expect(instance.init(source)) .rejects.toBe(compileFailure); expect(getterReads).toBe(0); diff --git a/host/test/kernel-worker-copyback.test.ts b/host/test/kernel-worker-copyback.test.ts index a4a7ea5391..71b20ba75f 100644 --- a/host/test/kernel-worker-copyback.test.ts +++ b/host/test/kernel-worker-copyback.test.ts @@ -1,13 +1,15 @@ import { describe, expect, it } from "vitest"; -import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { + createCentralizedKernelWorkerTestDouble, +} from "../src/kernel-worker"; +import { KernelReentrantEntryError } from "../src/kernel-entry-gate"; import { ABI_SYSCALLS, CHANNEL_STATUS_COMPLETE, - CH_DATA, + CHANNEL_STATUS_PENDING, CH_ERRNO, CH_RETURN, CH_STATUS, - type SyscallArgDesc, SYSCALL_ARGS, } from "../src/generated/abi"; import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; @@ -21,22 +23,33 @@ interface TestChannel { handling: boolean; } -interface CopybackHarnessWorker { - completeChannel( - channel: TestChannel, - syscallNr: number, - origArgs: number[], - argDescs: SyscallArgDesc[] | undefined, - retVal: number, - errVal: number, - detachedOutput?: Array<{ ptr: number; bytes: Uint8Array }>, - ): void; - handleBlockingRetry( - channel: TestChannel, - syscallNr: number, - origArgs: number[], - detachedOutput?: Array<{ ptr: number; bytes: Uint8Array }>, - ): void; +type CopybackHarnessWorker = + ReturnType; + +interface MutableCopybackWorkerState { + processes: Map; + activeChannels: TestChannel[]; + usePolling: boolean; +} + +function createTestChannel( + pid: number, + memory: WebAssembly.Memory, +): TestChannel { + return { + pid, + memory, + channelOffset: 0, + i32View: new Int32Array(memory.buffer), + consecutiveSyscalls: 0, + handling: true, + }; } function makeCopybackHarness(ptrWidth: 4 | 8 = 4) { @@ -47,72 +60,62 @@ function makeCopybackHarness(ptrWidth: 4 | 8 = 4) { maximum: 2, shared: true, }); - const channel: TestChannel = { - pid, - memory: processMemory, - channelOffset: 0, - i32View: new Int32Array(processMemory.buffer), - consecutiveSyscalls: 0, - handling: true, - }; - const worker = Object.assign( - Object.create(CentralizedKernelWorker.prototype), - { - kernelMemory, - cachedKernelMem: null, - cachedKernelBuffer: null, - processes: new Map([ - [ - pid, - { - pid, - memory: processMemory, - channels: [channel], - ptrWidth, - explicitMaxAddr: false, - }, - ], - ]), - clearSocketTimeout: () => {}, - clearReadinessWait: () => {}, - drainAllPtyOutputs: () => {}, - flushTcpSendPipes: () => {}, - drainAndProcessWakeupEvents: () => {}, - synchronizeSharedMemoryForBoundary: () => {}, - relistenChannel: () => {}, - pendingCancels: new Set(), - }, - ) as CopybackHarnessWorker; - const scratchPointer = installKernelWorkerTestScratch( - worker as unknown as Record, + const channel = createTestChannel(pid, processMemory); + const worker = createCentralizedKernelWorkerTestDouble(); + installKernelWorkerTestScratch( + worker, kernelMemory, + 128, + ptrWidth, + { kernelExportNames: [] }, + ); + const state = worker as unknown as MutableCopybackWorkerState; + state.processes = new Map([ + [ + pid, + { + pid, + memory: processMemory, + channels: [channel], + ptrWidth, + explicitMaxAddr: false, + }, + ], + ]); + state.activeChannels = [channel]; + // Completion normally relistens the mailbox. Polling mode keeps this + // focused harness synchronous without replacing a worker method. + state.usePolling = true; + Atomics.store( + channel.i32View, + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + CHANNEL_STATUS_PENDING, ); return { worker, channel, - kernelMem: new Uint8Array(kernelMemory.buffer, scratchPointer), + state, processMem: new Uint8Array(processMemory.buffer), }; } describe("CentralizedKernelWorker syscall copy-back", () => { it("leaves the destination unchanged when read reports EOF", () => { - const { worker, channel, kernelMem, processMem } = makeCopybackHarness(); + const { worker, channel, processMem } = makeCopybackHarness(); const dest = 1024; const original = Uint8Array.from({ length: 16 }, (_, i) => 0xa0 + i); processMem.set(original, dest); - kernelMem.fill(0, CH_DATA, CH_DATA + original.length); - - worker.completeChannel( - channel, - ABI_SYSCALLS.Read, - [0, dest, original.length], - SYSCALL_ARGS[ABI_SYSCALLS.Read], - 0, - 0, - ); + worker.testAuthority.completeDetachedCopybackForTest({ + pid: channel.pid, + registrationWitness: channel, + operation: "read", + fd: 0, + destination: dest, + requestedLength: original.length, + returnValue: 0, + }); expect(processMem.slice(dest, dest + original.length)).toEqual(original); const channelView = new DataView(processMem.buffer); @@ -124,22 +127,21 @@ describe("CentralizedKernelWorker syscall copy-back", () => { }); it("copies only the byte count reported by read", () => { - const { worker, channel, kernelMem, processMem } = makeCopybackHarness(); + const { worker, channel, processMem } = makeCopybackHarness(); const dest = 2048; const original = Uint8Array.from({ length: 8 }, (_, i) => 0xc0 + i); processMem.set(original, dest); - kernelMem.set([1, 2, 3, 0, 0, 0, 0, 0], CH_DATA); - - worker.completeChannel( - channel, - ABI_SYSCALLS.Read, - [0, dest, original.length], - SYSCALL_ARGS[ABI_SYSCALLS.Read], - 3, - 0, - [{ ptr: dest, bytes: Uint8Array.of(1, 2, 3) }], - ); + worker.testAuthority.completeDetachedCopybackForTest({ + pid: channel.pid, + registrationWitness: channel, + operation: "read", + fd: 0, + destination: dest, + requestedLength: original.length, + returnValue: 3, + outputBytes: Uint8Array.of(1, 2, 3), + }); expect(Array.from(processMem.slice(dest, dest + original.length))).toEqual([ 1, @@ -149,10 +151,31 @@ describe("CentralizedKernelWorker syscall copy-back", () => { ]); }); + it("rejects a stale registered-channel generation witness", () => { + const { worker, channel, processMem } = makeCopybackHarness(); + const destination = 3072; + processMem.fill(0x7c, destination, destination + 4); + + expectEntryCause( + () => worker.testAuthority.completeDetachedCopybackForTest({ + pid: channel.pid, + registrationWitness: { ...channel }, + operation: "read", + fd: 0, + destination, + requestedLength: 4, + returnValue: 0, + }), + "requires one exact process-owned main channel", + ); + expect(Array.from(processMem.slice(destination, destination + 4))) + .toEqual([0x7c, 0x7c, 0x7c, 0x7c]); + }); + it.each([4, 8] as const)( "copies the complete 112-byte stat record for a wasm%s caller", (ptrWidth) => { - const { worker, channel, kernelMem, processMem } = + const { worker, channel, processMem } = makeCopybackHarness(ptrWidth); const dest = 4096; const size = 112; @@ -167,17 +190,14 @@ describe("CentralizedKernelWorker syscall copy-back", () => { expect(statOutput?.size).toEqual({ type: "fixed", size }); expect(statOutput?.required).toBe(true); processMem.fill(canary, dest - 1, dest + size + 1); - kernelMem.set(output, CH_DATA); - - worker.completeChannel( - channel, - ABI_SYSCALLS.Fstat, - [3, dest], - descriptors, - 0, - 0, - [{ ptr: dest, bytes: output }], - ); + worker.testAuthority.completeDetachedCopybackForTest({ + pid: channel.pid, + registrationWitness: channel, + operation: "fstat", + fd: 3, + destination: dest, + outputBytes: output, + }); expect(processMem.slice(dest, dest + size)).toEqual(output); expect(processMem[dest - 1]).toBe(canary); @@ -188,7 +208,7 @@ describe("CentralizedKernelWorker syscall copy-back", () => { it.each([4, 8] as const)( "copies the complete initialized 48-byte sched_param for a wasm%s caller", (ptrWidth) => { - const { worker, channel, kernelMem, processMem } = + const { worker, channel, processMem } = makeCopybackHarness(ptrWidth); const dest = 8192; const size = 48; @@ -203,17 +223,14 @@ describe("CentralizedKernelWorker syscall copy-back", () => { expect(schedOutput?.size).toEqual({ type: "fixed", size }); expect(schedOutput?.required).toBe(true); processMem.fill(canary, dest - 1, dest + size + 1); - kernelMem.set(output, CH_DATA); - - worker.completeChannel( - channel, - ABI_SYSCALLS.SchedGetparam, - [0, dest], - descriptors, - 0, - 0, - [{ ptr: dest, bytes: output }], - ); + worker.testAuthority.completeDetachedCopybackForTest({ + pid: channel.pid, + registrationWitness: channel, + operation: "sched-getparam", + targetPid: 0, + destination: dest, + outputBytes: output, + }); expect(processMem.slice(dest, dest + size)).toEqual(output); expect(processMem[dest - 1]).toBe(canary); @@ -222,7 +239,7 @@ describe("CentralizedKernelWorker syscall copy-back", () => { ); it("carries detached poll output through an immediate timeout without rereading scratch", () => { - const { worker, channel, kernelMem, processMem } = makeCopybackHarness(); + const { worker, channel, processMem } = makeCopybackHarness(); const pollfd = 12_000; const detached = Uint8Array.of( 3, 0, 0, 0, @@ -230,18 +247,110 @@ describe("CentralizedKernelWorker syscall copy-back", () => { 0, 0, ); processMem.fill(0xa5, pollfd, pollfd + detached.byteLength); - kernelMem.fill(0xee, CH_DATA, CH_DATA + detached.byteLength); - - worker.handleBlockingRetry( - channel, - ABI_SYSCALLS.Poll, - [pollfd, 1, 0], - [{ ptr: pollfd, bytes: detached }], - ); + worker.testAuthority.completeImmediatePollTimeoutForCopybackTest({ + pid: channel.pid, + registrationWitness: channel, + pollfdPointer: pollfd, + outputBytes: detached, + }); expect(processMem.slice(pollfd, pollfd + detached.byteLength)) .toEqual(detached); expect(processMem[pollfd]).not.toBe(0xee); }); + it("rejects a busy copy-back operation without reading or replaying it", async () => { + const { worker, channel, state } = makeCopybackHarness(); + const nestedPid = 101; + const nestedMemory = new WebAssembly.Memory({ + initial: 2, + maximum: 2, + shared: true, + }); + const nestedChannel = createTestChannel(nestedPid, nestedMemory); + state.processes.set(nestedPid, { + pid: nestedPid, + memory: nestedMemory, + channels: [nestedChannel], + ptrWidth: 4, + explicitMaxAddr: false, + }); + state.activeChannels.push(nestedChannel); + Atomics.store( + nestedChannel.i32View, + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + CHANNEL_STATUS_PENDING, + ); + const nestedPollfd = 4096; + const nestedProcessBytes = new Uint8Array(nestedMemory.buffer); + nestedProcessBytes.fill( + 0xa5, + nestedPollfd, + nestedPollfd + 8, + ); + let nestedOptionsRead = false; + const nestedOptions = { + get pid(): number { + nestedOptionsRead = true; + return nestedPid; + }, + registrationWitness: nestedChannel, + pollfdPointer: nestedPollfd, + outputBytes: Uint8Array.of(3, 0, 0, 0, 1, 0, 0, 0), + }; + let reentrantError: unknown; + const statOutput = Uint8Array.from( + { length: 112 }, + (_, index) => index, + ); + + worker.testAuthority.completeDetachedCopybackForTest({ + pid: channel.pid, + registrationWitness: channel, + operation: "fstat", + fd: 3, + destination: 8192, + get outputBytes(): Uint8Array { + try { + worker.testAuthority + .completeImmediatePollTimeoutForCopybackTest(nestedOptions); + } catch (error) { + reentrantError = error; + } + return statOutput; + }, + }); + + expect(reentrantError).toBeInstanceOf(KernelReentrantEntryError); + expect(nestedOptionsRead).toBe(false); + expect(Atomics.load( + nestedChannel.i32View, + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + )).toBe(CHANNEL_STATUS_PENDING); + + // Allow any gate drain microtasks to run. The rejected options must never + // be retained for a later turn. + await Promise.resolve(); + await Promise.resolve(); + expect(nestedOptionsRead).toBe(false); + expect(Array.from( + nestedProcessBytes.slice(nestedPollfd, nestedPollfd + 8), + )).toEqual(new Array(8).fill(0xa5)); + }); }); + +function expectEntryCause( + operation: () => unknown, + expectedMessage: string, +): void { + let thrown: unknown; + try { + operation(); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(Error); + const cause = (thrown as { cause?: unknown }).cause; + expect(cause).toBeInstanceOf(Error); + expect((cause as Error).message).toContain(expectedMessage); +} diff --git a/host/test/kernel-worker-entry-root-contract.test.ts b/host/test/kernel-worker-entry-root-contract.test.ts new file mode 100644 index 0000000000..7eb8488073 --- /dev/null +++ b/host/test/kernel-worker-entry-root-contract.test.ts @@ -0,0 +1,246 @@ +import { readFileSync } from "node:fs"; + +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const ENTRY_SOURCES = [ + "../src/browser-kernel-worker-entry.ts", + "../src/node-kernel-worker-entry.ts", +] as const; + +const REQUIRED_KERNEL_CALLBACKS = [ + "onClone", + "onExec", + "onExit", + "onFork", + "onKernelFatal", + "onProcessMemoryTarget", + "onResolveSpawn", + "onSpawn", + "onThreadExit", +] as const; + +// These names denote raw Wasm authority or mutable queues whose invariants +// belong to CentralizedKernelWorker. An entry adapter may invoke the reviewed +// public API, but must not recover these members through a structural cast. +const FORBIDDEN_WORKER_MEMBERS = new Set([ + "kernel", + "kernelEntryGate", + "kernelInstance", + "kernelMemory", + "largeSpawnScratchInUse", + "largeTransferScratchInUse", + "pendingPipeReaders", + "pendingPipeWriters", + "processes", + "retrySyscall", + "scheduleWakeBlockedRetries", + "scratchOffset", + "scratchRegion", + "stdinBuffers", + "stdinFinite", + "tcpScratchRegion", +]); + +type Finding = { + readonly line: number; + readonly message: string; +}; + +function unwrapExpression(expression: ts.Expression): ts.Expression { + let current = expression; + while ( + ts.isParenthesizedExpression(current) + || ts.isAsExpression(current) + || ts.isTypeAssertionExpression(current) + || ts.isNonNullExpression(current) + || ts.isSatisfiesExpression(current) + ) { + current = current.expression; + } + return current; +} + +function isKernelWorker(expression: ts.Expression): boolean { + const unwrapped = unwrapExpression(expression); + return ts.isIdentifier(unwrapped) && unwrapped.text === "kernelWorker"; +} + +function memberName( + expression: ts.PropertyAccessExpression | ts.ElementAccessExpression, +): string | null { + if (ts.isPropertyAccessExpression(expression)) { + return expression.name.text; + } + const argument = expression.argumentExpression === undefined + ? undefined + : unwrapExpression(expression.argumentExpression); + return argument !== undefined && ( + ts.isStringLiteral(argument) + || ts.isNumericLiteral(argument) + ) + ? argument.text + : null; +} + +function auditEntrySource(sourceText: string, fileName: string): Finding[] { + const source = ts.createSourceFile( + fileName, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const findings: Finding[] = []; + const report = (node: ts.Node, message: string): void => { + findings.push({ + line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1, + message, + }); + }; + + const visit = (node: ts.Node): void => { + if ( + (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) + && isKernelWorker(node.expression) + ) { + report( + node, + "kernelWorker must not be structurally cast to recover hidden authority", + ); + } + + if ( + ts.isVariableDeclaration(node) + && node.initializer !== undefined + && isKernelWorker(node.initializer) + && (!ts.isIdentifier(node.name) || node.name.text !== "kernelWorker") + ) { + report( + node, + "kernelWorker must not be aliased outside its reviewed public surface", + ); + } + + if ( + (ts.isPropertyAccessExpression(node) + || ts.isElementAccessExpression(node)) + ) { + const name = memberName(node); + if (name === "exports") { + report( + node, + "worker entry roots must not invoke WebAssembly exports directly", + ); + } + if (isKernelWorker(node.expression)) { + if (name === null) { + report( + node, + "kernelWorker members must not use computed dynamic access", + ); + } else if (FORBIDDEN_WORKER_MEMBERS.has(name)) { + report( + node, + `kernelWorker.${name} bypasses its reviewed public ingress`, + ); + } + } + } + + if ( + ts.isCallExpression(node) + && node.arguments.some((argument) => isKernelWorker(argument)) + ) { + report( + node, + "kernelWorker must not escape as an argument to an unreviewed helper", + ); + } + + if ( + ts.isReturnStatement(node) + && node.expression !== undefined + && isKernelWorker(node.expression) + ) { + report(node, "kernelWorker must not escape from a worker entry root"); + } + + ts.forEachChild(node, visit); + }; + visit(source); + return findings; +} + +function kernelCallbackNames(sourceText: string, fileName: string): string[] { + const source = ts.createSourceFile( + fileName, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const callbackSets: string[][] = []; + + const visit = (node: ts.Node): void => { + if ( + ts.isNewExpression(node) + && ts.isIdentifier(unwrapExpression(node.expression)) + && unwrapExpression(node.expression).text === "CentralizedKernelWorker" + ) { + const callbacks = node.arguments?.[2]; + if (callbacks !== undefined && ts.isObjectLiteralExpression(callbacks)) { + callbackSets.push(callbacks.properties.flatMap((property) => { + if ( + !ts.isPropertyAssignment(property) + && !ts.isShorthandPropertyAssignment(property) + && !ts.isMethodDeclaration(property) + ) { + return []; + } + const name = property.name; + if ( + ts.isIdentifier(name) + || ts.isStringLiteral(name) + || ts.isNumericLiteral(name) + ) { + return [name.text]; + } + return []; + }).sort()); + } + } + ts.forEachChild(node, visit); + }; + visit(source); + + expect( + callbackSets, + `${fileName} must construct exactly one CentralizedKernelWorker`, + ).toHaveLength(1); + return callbackSets[0]!; +} + +describe("kernel worker entry-root authority contract", () => { + it.each(ENTRY_SOURCES)( + "%s uses only the reviewed worker surface", + (relativePath) => { + const url = new URL(relativePath, import.meta.url); + const sourceText = readFileSync(url, "utf8"); + const findings = auditEntrySource(sourceText, url.pathname); + + expect(findings).toEqual([]); + }, + ); + + it("keeps the Node and browser kernel callback roots in parity", () => { + const callbackSets = ENTRY_SOURCES.map((relativePath) => { + const url = new URL(relativePath, import.meta.url); + const sourceText = readFileSync(url, "utf8"); + return kernelCallbackNames(sourceText, url.pathname); + }); + + expect(callbackSets[0]).toEqual([...REQUIRED_KERNEL_CALLBACKS]); + expect(callbackSets[1]).toEqual(callbackSets[0]); + }); +}); diff --git a/host/test/kernel-worker-test-scratch.ts b/host/test/kernel-worker-test-scratch.ts index d8328b220a..e29fa7c3a2 100644 --- a/host/test/kernel-worker-test-scratch.ts +++ b/host/test/kernel-worker-test-scratch.ts @@ -1,36 +1,89 @@ import { CH_TOTAL_SIZE } from "../src/generated/abi"; +import { + createKernelEntryGatedInstance, + KernelEntryGate, +} from "../src/kernel-entry-gate"; import { allocateKernelScratchRegion } from "../src/kernel-scratch"; import { createKernelScratchTestInstance } from "./support/kernel-scratch-instance"; +interface KernelWorkerTestAuthority { + initializeKernelForTest(options: { + readonly instance: WebAssembly.Instance; + readonly gate: KernelEntryGate; + readonly mainScratch: ReturnType; + }): void; +} + +interface KernelWorkerTestScratchOptions { + readonly boundInstance?: WebAssembly.Instance; + readonly gate?: KernelEntryGate; + readonly kernelExports?: Readonly>; + /** Restrict the genuine fixture to these exports for required-export tests. */ + readonly kernelExportNames?: readonly string[]; +} + /** - * Install the same capacity-carrying main scratch contract that worker.init() - * creates, for white-box tests that intentionally bypass the constructor and - * Wasm allocator. + * Install the same gated, capacity-carrying main scratch contract that + * worker.init() creates. + * + * Structural export mocks remain owned by the test. The worker receives only + * a genuine Wasm instance, its exact gate-bound facade, and an + * allocator-created region for that same generation. */ export function installKernelWorkerTestScratch( - worker: Record, + worker: { + readonly testAuthority?: KernelWorkerTestAuthority; + }, memory: WebAssembly.Memory, pointer = 128, pointerWidth: 4 | 8 = 4, + options: KernelWorkerTestScratchOptions = {}, ): number { - worker.kernelMemory = memory; - const scratchTestInstance = createKernelScratchTestInstance( - pointerWidth, - memory, - () => ( - worker.kernelInstance as { exports?: Record } | undefined - )?.exports ?? {}, - () => pointerWidth === 8 ? BigInt(pointer) : pointer, - ); - worker.scratchTestInstance = scratchTestInstance; - worker.scratchRegion = allocateKernelScratchRegion( + const authority = worker.testAuthority; + if (authority === undefined) { + throw new Error("worker is not a module-authorized kernel test double"); + } + if ((options.boundInstance === undefined) !== (options.gate === undefined)) { + throw new Error( + "a bound test instance and its exact kernel entry gate must be provided together", + ); + } + if ( + options.boundInstance !== undefined && + options.kernelExports !== undefined + ) { + throw new Error( + "kernelExports cannot replace exports on an already-bound test instance", + ); + } + const gate = options.gate ?? new KernelEntryGate(); + const gatedInstance = options.boundInstance ?? (() => { + const rawInstance = createKernelScratchTestInstance( + pointerWidth, + memory, + () => options.kernelExports ?? {}, + () => pointerWidth === 8 ? BigInt(pointer) : pointer, + 4, + options.kernelExportNames, + ); + return createKernelEntryGatedInstance( + rawInstance, + gate, + ); + })(); + const mainScratch = allocateKernelScratchRegion( memory, - scratchTestInstance.exports.kernel_alloc_scratch as + gatedInstance.exports.kernel_alloc_scratch as (size: number) => number | bigint, CH_TOTAL_SIZE, pointerWidth, "test kernel syscall scratch", - scratchTestInstance, + gatedInstance, ); + authority.initializeKernelForTest({ + instance: gatedInstance, + gate, + mainScratch, + }); return pointer; } diff --git a/host/test/kernel.test.ts b/host/test/kernel.test.ts index 674af7f6b0..4ff3ab72b6 100644 --- a/host/test/kernel.test.ts +++ b/host/test/kernel.test.ts @@ -1,30 +1,268 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { readFileSync } from "node:fs"; -import { CAPTURED_STDIO, CentralizedKernelWorker } from "../src/kernel-worker"; +import { + CAPTURED_STDIO, + CentralizedKernelWorker, + createCentralizedKernelWorkerTestDouble, +} from "../src/kernel-worker"; import { resolveBinary } from "../src/binary-resolver"; import { CH_TOTAL_SIZE } from "../src/constants"; import { ABI_SYSCALLS, + CHANNEL_STATUS_PENDING, CH_ARGS, + CH_ARGS_COUNT, CH_ARG_SIZE, - CH_DATA, CH_ERRNO, CH_RETURN, + CH_STATUS, CH_SYSCALL, KERNEL_WAIT_RESULT_SI_CODE_OFFSET, KERNEL_WAIT_RESULT_SI_STATUS_OFFSET, KERNEL_WAIT_RESULT_WAIT_STATUS_OFFSET, KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES, - PROCESS_STATE_EXITED, STRUCT_SIZE_KERNEL_WAIT_RESULT, WAIT_CLD_KILLED, - WAIT_EVENT_EXITED, } from "../src/generated/abi"; -import type { KernelScratchLease } from "../src/kernel-scratch"; import { NodePlatformIO } from "../src/platform/node"; +import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; + +type CapacityProbeDestination = "null" | "guarded"; + +interface RegisteredChannelWitness { + readonly pid: number; + readonly memory: WebAssembly.Memory; + readonly channelOffset: number; +} + +interface CapacityProbeResult { + readonly result: number; + readonly guardedBytes: Uint8Array; +} + +interface MqueueNotificationCapacityProbeOptions { + readonly registrationWitness: RegisteredChannelWitness; + readonly descriptor: number; + readonly triggerNotification: boolean; + readonly destination: CapacityProbeDestination; + readonly capacity: number; +} + +interface WaitableChildCapacityProbeOptions { + readonly registrationWitness: RegisteredChannelWitness; + readonly childPid: number; + readonly destination: CapacityProbeDestination; + readonly capacity: number; +} + +interface CapacityProbeAuthority { + probeMqueueNotificationCapacityForTest( + options: MqueueNotificationCapacityProbeOptions, + ): CapacityProbeResult; + probeWaitableChildCapacityForTest( + options: WaitableChildCapacityProbeOptions, + ): CapacityProbeResult; +} + +function capacityProbeAuthority( + worker: ReturnType, +): CapacityProbeAuthority { + return worker.testAuthority as unknown as CapacityProbeAuthority; +} + +function writeChannelSyscall( + channel: RegisteredChannelWitness, + syscallNr: number, + args: readonly (number | bigint)[], +): void { + const view = new DataView( + channel.memory.buffer, + channel.channelOffset, + ); + view.setUint32(CH_STATUS, CHANNEL_STATUS_PENDING, true); + view.setUint32(CH_SYSCALL, syscallNr, true); + for (let index = 0; index < CH_ARGS_COUNT; index++) { + view.setBigInt64( + CH_ARGS + index * CH_ARG_SIZE, + BigInt(args[index] ?? 0), + true, + ); + } +} + +async function createRealKernelHarness() { + const wasmBytes = readFileSync(resolveBinary("kernel.wasm")); + const worker = createCentralizedKernelWorkerTestDouble({ + config: { + maxWorkers: 4, + dataBufferSize: 65536, + useSharedMemory: true, + }, + io: new NodePlatformIO(), + }); + await worker.init( + wasmBytes.buffer.slice( + wasmBytes.byteOffset, + wasmBytes.byteOffset + wasmBytes.byteLength, + ), + ); + + const processMemory = new WebAssembly.Memory({ + initial: 3, + maximum: 3, + shared: true, + }); + const pid = worker.createProcess(CAPTURED_STDIO); + const [registeredChannel] = + worker.testAuthority.replaceProcessRegistrationForLifecycleTest({ + pid, + memory: processMemory, + channelOffsets: [65536], + pointerWidth: 4, + }); + const channel = registeredChannel as RegisteredChannelWitness; + let completion: { value: number; errno: number } | undefined; + worker.testAuthority.configureScratchBoundaryHooksForTest({ + completeChannel: ( + _channel, + _syscallNr, + _origArgs, + _argDescs, + value, + errno, + ) => { + completion = { value, errno }; + }, + }); + + return { + channel, + issue( + syscallNr: number, + args: readonly (number | bigint)[], + ): { value: number; errno: number } { + completion = undefined; + writeChannelSyscall(channel, syscallNr, args); + worker.testAuthority.dispatchScratchBoundarySyscallForTest(channel); + if (completion === undefined) { + throw new Error(`syscall ${syscallNr} did not complete synchronously`); + } + return completion; + }, + pid, + processMemory, + worker, + }; +} + +function expectUntouchedCanaries( + probe: CapacityProbeResult, + payloadBytes: number, +): void { + expect(probe.guardedBytes).toEqual( + new Uint8Array(payloadBytes + 2).fill(0xa5), + ); +} + +function observedOptions( + values: T, + reads: ReturnType, +): T { + return new Proxy(values, { + get(target, property, receiver) { + reads(String(property)); + return Reflect.get(target, property, receiver); + }, + }); +} + +function createCapacityProbeContractHarness() { + const kernelMemory = new WebAssembly.Memory({ + initial: 2, + maximum: 2, + shared: true, + }); + const processMemory = new WebAssembly.Memory({ + initial: 3, + maximum: 3, + shared: true, + }); + const mqueueProbeExport = vi.fn(() => 0); + const waitableChildProbeExport = vi.fn(() => 0); + let duringKernelHandle = (): void => {}; + const kernelHandle = vi.fn((pointer: number | bigint) => { + duringKernelHandle(); + const channel = new DataView(kernelMemory.buffer, Number(pointer)); + channel.setBigInt64(CH_RETURN, 0n, true); + channel.setUint32(CH_ERRNO, 0, true); + return 0; + }); + const worker = createCentralizedKernelWorkerTestDouble(); + installKernelWorkerTestScratch( + worker, + kernelMemory, + 128, + 4, + { + kernelExportNames: [ + "kernel_dequeue_signal", + "kernel_get_process_exit_signal", + "kernel_handle_channel", + "kernel_mq_drain_notification", + "kernel_set_current_tid", + "kernel_wait_child_poll", + ], + kernelExports: { + kernel_dequeue_signal: vi.fn(() => 0), + kernel_get_process_exit_signal: vi.fn(() => -1), + kernel_handle_channel: kernelHandle, + kernel_mq_drain_notification: mqueueProbeExport, + kernel_set_current_tid: vi.fn(() => 0), + kernel_wait_child_poll: waitableChildProbeExport, + }, + }, + ); + worker.testAuthority.configureScratchBoundaryHooksForTest({ + completeChannel: () => undefined, + }); + const [registeredChannel] = + worker.testAuthority.replaceProcessRegistrationForLifecycleTest({ + pid: 42, + memory: processMemory, + channelOffsets: [0], + pointerWidth: 4, + }); + const channel = registeredChannel as RegisteredChannelWitness; + + return { + authority: capacityProbeAuthority(worker), + channel, + dispatchOuterSyscall(): void { + writeChannelSyscall(channel, ABI_SYSCALLS.Getpid, []); + worker.testAuthority.dispatchScratchBoundarySyscallForTest(channel); + }, + kernelHandle, + mqueueProbeExport, + processMemory, + replaceRegistration(): RegisteredChannelWitness { + const [replacement] = + worker.testAuthority.replaceProcessRegistrationForLifecycleTest({ + pid: channel.pid, + memory: processMemory, + channelOffsets: [CH_TOTAL_SIZE], + pointerWidth: 4, + }); + return replacement as RegisteredChannelWitness; + }, + runDuringKernelHandle(operation: () => void): void { + duringKernelHandle = operation; + }, + waitableChildProbeExport, + }; +} describe("CentralizedKernelWorker", () => { - it("drains queued PTY output when a listener registers", () => { + it("drains queued PTY output when a listener registers", async () => { const encoder = new TextEncoder(); const decoder = new TextDecoder(); const queued = [ @@ -32,15 +270,40 @@ describe("CentralizedKernelWorker", () => { encoder.encode("ready\n"), ]; const received: string[] = []; - const kernelWorker = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - ptyOutputCallbacks: new Map void>(), - ptyMasterRead: () => queued.shift() ?? null, - }) as CentralizedKernelWorker; + const kernelMemory = new WebAssembly.Memory({ + initial: 2, + maximum: 2, + }); + const kernelWorker = createCentralizedKernelWorkerTestDouble(); + installKernelWorkerTestScratch( + kernelWorker, + kernelMemory, + 128, + 4, + { + kernelExports: { + kernel_pty_master_read: ( + _ptyIdx: number, + pointer: number, + capacity: number, + ) => { + const data = queued.shift(); + if (data === undefined) return 0; + expect(data.byteLength).toBeLessThanOrEqual(capacity); + new Uint8Array(kernelMemory.buffer).set(data, pointer); + return data.byteLength; + }, + }, + }, + ); kernelWorker.onPtyOutput(3, (data) => { received.push(decoder.decode(data)); }); + // Each detached callback queues a fresh entry for the next chunk so no + // scratch-bearing scope survives the observer boundary. + for (let index = 0; index < 4; index++) await Promise.resolve(); expect(received).toEqual(["spidermonkey-node$ ", "ready\n"]); }); @@ -77,190 +340,101 @@ describe("CentralizedKernelWorker", () => { }); it("requires a nonnull exact-capacity mqueue notification destination", async () => { - const wasmBytes = readFileSync(resolveBinary("kernel.wasm")); - const kernelWorker = new CentralizedKernelWorker( - { maxWorkers: 4, dataBufferSize: 65536, useSharedMemory: true }, - new NodePlatformIO(), - ); - await kernelWorker.init( - wasmBytes.buffer.slice( - wasmBytes.byteOffset, - wasmBytes.byteOffset + wasmBytes.byteLength, - ), - ); - - const processMemory = new WebAssembly.Memory({ - initial: 17, - maximum: 256, - shared: true, - }); - const channelOffset = (256 - 2) * 65536; - processMemory.grow(256 - 17); - const pid = kernelWorker.createProcess(CAPTURED_STDIO); - kernelWorker.registerProcess(pid, processMemory, [channelOffset]); - - type ScratchArgument = { - readonly offset: number; - readonly length: number; - }; - const scratchArgument = ( - offset: number, - length: number, - ): ScratchArgument => ({ offset, length }); - const internals = kernelWorker as any; - const pointerWidth = internals.kernel.getKernelPtrWidth() as 4 | 8; - const setCurrentTid = internals.kernelInstance.exports - .kernel_set_current_tid as (pid: number, tid: number) => number; - const issue = ( - syscall: number, - prepare: ( - lease: KernelScratchLease, - ) => Array, - ): { value: number; errno: number } => - internals.scratchRegion.withLease((lease: KernelScratchLease) => { - lease.fill(0, 0, CH_TOTAL_SIZE); - const args = prepare(lease); - const channel = lease.dataView(0, CH_TOTAL_SIZE); - channel.setUint32(CH_SYSCALL, syscall, true); - channel.setUint32(CH_ERRNO, 0, true); - channel.setBigInt64(CH_RETURN, 0n, true); - for (let index = 0; index < 6; index++) { - const argument = args[index] ?? 0; - if (typeof argument === "object") { - channel.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, 0n, true); - lease.writeAddress( - CH_ARGS + index * CH_ARG_SIZE, - argument.offset, - argument.length, - pointerWidth === 8 ? "u64-le" : "u32-to-u64-le", - ); - } else { - channel.setBigInt64( - CH_ARGS + index * CH_ARG_SIZE, - BigInt(argument), - true, - ); - } - } - expect(setCurrentTid(pid, pid)).toBe(0); - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - pid, - ]); - return { - value: Number(channel.getBigInt64(CH_RETURN, true)), - errno: channel.getUint32(CH_ERRNO, true), - }; - }); + const harness = await createRealKernelHarness(); + const authority = capacityProbeAuthority(harness.worker); try { + const queueNamePointer = 1024; + const eventPointer = 2048; const queueName = new TextEncoder().encode( - `/kernel-mq-drain-capacity-${pid}\0`, + `/kernel-mq-drain-capacity-${harness.pid}\0`, ); - const opened = issue(ABI_SYSCALLS.MqOpen, (lease) => { - lease.copyFrom(queueName, CH_DATA); - return [ - scratchArgument(CH_DATA, queueName.byteLength), - 0o302, // O_RDWR | O_CREAT | O_EXCL - 0o600, - 0, - 0, - 4, - ]; - }); + new Uint8Array( + harness.processMemory.buffer, + queueNamePointer, + queueName.byteLength, + ).set(queueName); + const opened = harness.issue(ABI_SYSCALLS.MqOpen, [ + queueNamePointer, + 0o302, // O_RDWR | O_CREAT | O_EXCL + 0o600, + 0, + 0, + 4, + ]); expect(opened.errno).toBe(0); expect(opened.value).toBeGreaterThanOrEqual(0x4000_0000); - const notified = issue(ABI_SYSCALLS.MqNotify, (lease) => { - const sigeventSize = 64; - const event = lease.dataView(CH_DATA, sigeventSize); - event.setUint32(0, 0x89ab_cdef, true); - event.setInt32(4, 10, true); - event.setInt32(8, 0, true); // SIGEV_SIGNAL - return [ - opened.value, - scratchArgument(CH_DATA, sigeventSize), - 0, - 0, - 0, - 4, - ]; - }); + const event = new DataView( + harness.processMemory.buffer, + eventPointer, + 64, + ); + event.setUint32(0, 0x89ab_cdef, true); + event.setInt32(4, 10, true); + event.setInt32(8, 0, true); // SIGEV_SIGNAL + const notified = harness.issue(ABI_SYSCALLS.MqNotify, [ + opened.value, + eventPointer, + 0, + 0, + 0, + 4, + ]); expect(notified).toEqual({ value: 0, errno: 0 }); - const sent = issue(ABI_SYSCALLS.MqTimedsend, (lease) => { - lease.copyFrom(new Uint8Array([0x51]), CH_DATA); - return [ - opened.value, - scratchArgument(CH_DATA, 1), - 1, - 0, - 0, - 4, - ]; - }); - expect(sent).toEqual({ value: 0, errno: 0 }); - expect(KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES).toBe(8); - internals.scratchRegion.withLease((lease: KernelScratchLease) => { - const outputOffset = CH_DATA + 64; - const guardedLength = KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES + 2; - lease.fill(0xa5, outputOffset, guardedLength); - const drain = internals.kernelInstance.exports - .kernel_mq_drain_notification as ( - pointer: number | bigint, - capacity: number, - ) => number; - const nullPointer = pointerWidth === 8 ? 0n : 0; - - expect( - drain(nullPointer, KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES), - ).toBe(-14); // EFAULT - expect( - lease.invokeKernelExport("kernel_mq_drain_notification", [ - lease.exportPointer( - outputOffset + 1, - KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES - 1, - ), - KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES - 1, - ]), - ).toBe(-22); // EINVAL - expect( - lease.invokeKernelExport("kernel_mq_drain_notification", [ - lease.exportPointer( - outputOffset + 1, - KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES + 1, - ), - KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES + 1, - ]), - ).toBe(-22); // EINVAL - expect(lease.copyOut(outputOffset, guardedLength)).toEqual( - new Uint8Array(guardedLength).fill(0xa5), - ); + const nullDestination = + authority.probeMqueueNotificationCapacityForTest({ + registrationWitness: harness.channel, + descriptor: opened.value, + triggerNotification: true, + destination: "null", + capacity: KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES, + }); + expect(nullDestination.result).toBe(-14); // EFAULT + expectUntouchedCanaries( + nullDestination, + KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES, + ); - expect( - lease.invokeKernelExport("kernel_mq_drain_notification", [ - lease.exportPointer( - outputOffset + 1, - KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES, - ), - KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES, - ]), - ).toBe(1); - const output = lease.copyOut(outputOffset, guardedLength); - expect(output[0]).toBe(0xa5); - expect(output[guardedLength - 1]).toBe(0xa5); - const notification = new DataView( - output.buffer, - output.byteOffset + 1, + for (const capacity of [ + KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES - 1, + KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES + 1, + ]) { + const rejected = authority.probeMqueueNotificationCapacityForTest({ + registrationWitness: harness.channel, + descriptor: opened.value, + triggerNotification: false, + destination: "guarded", + capacity, + }); + expect(rejected.result).toBe(-22); // EINVAL + expectUntouchedCanaries( + rejected, KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES, ); - expect(notification.getUint32(0, true)).toBe(pid); - expect(notification.getUint32(4, true)).toBe(10); + } + + const accepted = authority.probeMqueueNotificationCapacityForTest({ + registrationWitness: harness.channel, + descriptor: opened.value, + triggerNotification: false, + destination: "guarded", + capacity: KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES, }); + expect(accepted.result).toBe(1); + expect(accepted.guardedBytes[0]).toBe(0xa5); + expect(accepted.guardedBytes.at(-1)).toBe(0xa5); + const notification = new DataView( + accepted.guardedBytes.buffer, + accepted.guardedBytes.byteOffset + 1, + KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES, + ); + expect(notification.getUint32(0, true)).toBe(harness.pid); + expect(notification.getUint32(4, true)).toBe(10); } finally { - kernelWorker.unregisterProcess(pid); + harness.worker.unregisterProcess(harness.pid); } }); @@ -268,137 +442,167 @@ describe("CentralizedKernelWorker", () => { const ECHILD = 10; const EFAULT = 14; const EINVAL = 22; - const ESRCH = 3; const SIGTERM = 15; - const wasmBytes = readFileSync(resolveBinary("kernel.wasm")); - const kernelWorker = new CentralizedKernelWorker( - { maxWorkers: 4, dataBufferSize: 65536, useSharedMemory: true }, - new NodePlatformIO(), - ); - await kernelWorker.init( - wasmBytes.buffer.slice( - wasmBytes.byteOffset, - wasmBytes.byteOffset + wasmBytes.byteLength, - ), - ); - - const processMemory = new WebAssembly.Memory({ - initial: 17, - maximum: 256, - shared: true, - }); - const channelOffset = (256 - 2) * 65536; - processMemory.grow(256 - 17); - const parentPid = kernelWorker.createProcess(CAPTURED_STDIO); - kernelWorker.registerProcess(parentPid, processMemory, [channelOffset]); - - const internals = kernelWorker as any; - const pointerWidth = internals.kernel.getKernelPtrWidth() as 4 | 8; - const exports = internals.kernelInstance.exports as WebAssembly.Exports; - const forkProcess = exports.kernel_fork_process as ( - parentPid: number, - callerTid: number, - ) => number; - const markProcessSignaled = exports.kernel_mark_process_signaled as ( - pid: number, - signum: number, - ) => number; - const getProcessState = exports.kernel_get_process_state as ( - pid: number, - ) => number; - const removeProcess = exports.kernel_remove_process as ( - pid: number, - ) => number; - const waitChildPoll = exports.kernel_wait_child_poll as ( - parentPid: number, - callerTid: number, - targetPid: number, - eventMask: number, - flags: number, - resultPtr: number | bigint, - resultCapacity: number, - ) => number; + const harness = await createRealKernelHarness(); + const authority = capacityProbeAuthority(harness.worker); let childPid = 0; try { - childPid = forkProcess(parentPid, parentPid); + childPid = + harness.worker.testAuthority.forkKernelProcessForAdvisoryLockTest( + harness.pid, + harness.pid, + ); expect(childPid).toBeGreaterThan(0); - expect(markProcessSignaled(childPid, SIGTERM)).toBe(0); - expect(getProcessState(childPid)).toBe(PROCESS_STATE_EXITED); - - internals.scratchRegion.withLease((lease: KernelScratchLease) => { - const outputOffset = CH_DATA + 256; - const guardedLength = STRUCT_SIZE_KERNEL_WAIT_RESULT + 2; - const nullPointer = pointerWidth === 8 ? 0n : 0; - const pollWithCapacity = (capacity: number): number => - lease.invokeKernelExport("kernel_wait_child_poll", [ - parentPid, - parentPid, - childPid, - WAIT_EVENT_EXITED, - 0, - lease.exportPointer(outputOffset + 1, capacity), - capacity, - ]); - lease.fill(0xa5, outputOffset, guardedLength); - - // WHY: destination rejection must precede event selection, or a bad - // host borrow could silently consume the parent's only wait record. - expect(waitChildPoll( - parentPid, - parentPid, + harness.worker.testAuthority.sendSignalForTest(childPid, SIGTERM); + + const nullDestination = + authority.probeWaitableChildCapacityForTest({ + registrationWitness: harness.channel, childPid, - WAIT_EVENT_EXITED, - 0, - nullPointer, - STRUCT_SIZE_KERNEL_WAIT_RESULT, - )).toBe(-EFAULT); - expect(getProcessState(childPid)).toBe(PROCESS_STATE_EXITED); - - expect( - pollWithCapacity(STRUCT_SIZE_KERNEL_WAIT_RESULT - 1), - ).toBe(-EINVAL); - expect(getProcessState(childPid)).toBe(PROCESS_STATE_EXITED); - - expect( - pollWithCapacity(STRUCT_SIZE_KERNEL_WAIT_RESULT + 1), - ).toBe(-EINVAL); - expect(getProcessState(childPid)).toBe(PROCESS_STATE_EXITED); - expect(lease.copyOut(outputOffset, guardedLength)).toEqual( - new Uint8Array(guardedLength).fill(0xa5), - ); + destination: "null", + capacity: STRUCT_SIZE_KERNEL_WAIT_RESULT, + }); + expect(nullDestination.result).toBe(-EFAULT); + expectUntouchedCanaries( + nullDestination, + STRUCT_SIZE_KERNEL_WAIT_RESULT, + ); - expect( - pollWithCapacity(STRUCT_SIZE_KERNEL_WAIT_RESULT), - ).toBe(childPid); - const output = lease.copyOut(outputOffset, guardedLength); - expect(output[0]).toBe(0xa5); - expect(output[guardedLength - 1]).toBe(0xa5); - const result = new DataView( - output.buffer, - output.byteOffset + 1, + for (const capacity of [ + STRUCT_SIZE_KERNEL_WAIT_RESULT - 1, + STRUCT_SIZE_KERNEL_WAIT_RESULT + 1, + ]) { + const rejected = authority.probeWaitableChildCapacityForTest({ + registrationWitness: harness.channel, + childPid, + destination: "guarded", + capacity, + }); + expect(rejected.result).toBe(-EINVAL); + expectUntouchedCanaries( + rejected, STRUCT_SIZE_KERNEL_WAIT_RESULT, ); - expect( - result.getInt32(KERNEL_WAIT_RESULT_WAIT_STATUS_OFFSET, true), - ).toBe(SIGTERM); - expect( - result.getInt32(KERNEL_WAIT_RESULT_SI_CODE_OFFSET, true), - ).toBe(WAIT_CLD_KILLED); - expect( - result.getInt32(KERNEL_WAIT_RESULT_SI_STATUS_OFFSET, true), - ).toBe(SIGTERM); - - expect(getProcessState(childPid)).toBe(-ESRCH); - expect( - pollWithCapacity(STRUCT_SIZE_KERNEL_WAIT_RESULT), - ).toBe(-ECHILD); + } + + // WHY: the exact call returning this same child proves that none of the + // rejected destinations selected or consumed its sole wait record. + const accepted = authority.probeWaitableChildCapacityForTest({ + registrationWitness: harness.channel, + childPid, + destination: "guarded", + capacity: STRUCT_SIZE_KERNEL_WAIT_RESULT, + }); + expect(accepted.result).toBe(childPid); + expect(accepted.guardedBytes[0]).toBe(0xa5); + expect(accepted.guardedBytes.at(-1)).toBe(0xa5); + const result = new DataView( + accepted.guardedBytes.buffer, + accepted.guardedBytes.byteOffset + 1, + STRUCT_SIZE_KERNEL_WAIT_RESULT, + ); + expect( + result.getInt32(KERNEL_WAIT_RESULT_WAIT_STATUS_OFFSET, true), + ).toBe(SIGTERM); + expect( + result.getInt32(KERNEL_WAIT_RESULT_SI_CODE_OFFSET, true), + ).toBe(WAIT_CLD_KILLED); + expect( + result.getInt32(KERNEL_WAIT_RESULT_SI_STATUS_OFFSET, true), + ).toBe(SIGTERM); + + const consumed = authority.probeWaitableChildCapacityForTest({ + registrationWitness: harness.channel, + childPid, + destination: "guarded", + capacity: STRUCT_SIZE_KERNEL_WAIT_RESULT, }); + expect(consumed.result).toBe(-ECHILD); + expectUntouchedCanaries(consumed, STRUCT_SIZE_KERNEL_WAIT_RESULT); } finally { - if (childPid > 0 && getProcessState(childPid) >= 0) { - removeProcess(childPid); + harness.worker.unregisterProcess(harness.pid); + } + }); + + it("rejects busy capacity probes without reading or replaying their options", async () => { + const harness = createCapacityProbeContractHarness(); + const mqueueReads = vi.fn(); + const waitReads = vi.fn(); + const mqueueOptions = observedOptions({ + registrationWitness: harness.channel, + descriptor: 7, + triggerNotification: false, + destination: "guarded" as const, + capacity: KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES, + }, mqueueReads); + const waitOptions = observedOptions({ + registrationWitness: harness.channel, + childPid: 43, + destination: "guarded" as const, + capacity: STRUCT_SIZE_KERNEL_WAIT_RESULT, + }, waitReads); + const errors: unknown[] = []; + harness.runDuringKernelHandle(() => { + for (const probe of [ + () => harness.authority + .probeMqueueNotificationCapacityForTest(mqueueOptions), + () => harness.authority + .probeWaitableChildCapacityForTest(waitOptions), + ]) { + try { + probe(); + } catch (error) { + errors.push(error); + } } - kernelWorker.unregisterProcess(parentPid); + }); + + harness.dispatchOuterSyscall(); + + expect(errors).toHaveLength(2); + for (const error of errors) { + expect(error).toBeInstanceOf(Error); + expect(String(error)).toMatch(/active|busy|cannot run/i); } + expect(mqueueReads).not.toHaveBeenCalled(); + expect(waitReads).not.toHaveBeenCalled(); + expect(harness.mqueueProbeExport).not.toHaveBeenCalled(); + expect(harness.waitableChildProbeExport).not.toHaveBeenCalled(); + + // A rejected immediate probe must not remain queued behind the outer + // kernel export and read or execute after that exact scope is revoked. + for (let turn = 0; turn < 8; turn++) await Promise.resolve(); + expect(mqueueReads).not.toHaveBeenCalled(); + expect(waitReads).not.toHaveBeenCalled(); + expect(harness.mqueueProbeExport).not.toHaveBeenCalled(); + expect(harness.waitableChildProbeExport).not.toHaveBeenCalled(); + expect(harness.kernelHandle).toHaveBeenCalledOnce(); + }); + + it("rejects stale registered-channel witnesses before either capacity export", () => { + const harness = createCapacityProbeContractHarness(); + const staleWitness = harness.channel; + harness.replaceRegistration(); + + expect(() => { + harness.authority.probeMqueueNotificationCapacityForTest({ + registrationWitness: staleWitness, + descriptor: 7, + triggerNotification: false, + destination: "guarded", + capacity: KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES, + }); + }).toThrow(TypeError); + expect(() => { + harness.authority.probeWaitableChildCapacityForTest({ + registrationWitness: staleWitness, + childPid: 43, + destination: "guarded", + capacity: STRUCT_SIZE_KERNEL_WAIT_RESULT, + }); + }).toThrow(TypeError); + expect(harness.mqueueProbeExport).not.toHaveBeenCalled(); + expect(harness.waitableChildProbeExport).not.toHaveBeenCalled(); }); }); diff --git a/host/test/lseek-invalid-guest.test.ts b/host/test/lseek-invalid-guest.test.ts index f1eb6d3074..a3e9c97bfb 100644 --- a/host/test/lseek-invalid-guest.test.ts +++ b/host/test/lseek-invalid-guest.test.ts @@ -7,10 +7,15 @@ import { NodePlatformIO } from "../src/platform/node"; import { runCentralizedProgram } from "./centralized-test-helper"; const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); -const program = join(repoRoot, "examples/lseek_invalid_test.wasm"); +const programs = [ + ["wasm32", join(repoRoot, "examples/lseek_invalid_test.wasm")], + ["wasm64", join(repoRoot, "examples/lseek_invalid_test.wasm64.wasm")], +] as const; -describe.skipIf(!existsSync(program))("invalid lseek guest", () => { - it("keeps the host-file offset unchanged", async () => { +describe("invalid lseek guest", () => { + it.each(programs.filter(([, program]) => existsSync(program)))( + "%s keeps the host-file offset unchanged", + async (_arch, program) => { const tempRoot = mkdtempSync(join(tmpdir(), "kandelo-lseek-")); try { const result = await runCentralizedProgram({ @@ -27,5 +32,6 @@ describe.skipIf(!existsSync(program))("invalid lseek guest", () => { } finally { rmSync(tempRoot, { recursive: true, force: true }); } - }); + }, + ); }); diff --git a/host/test/mmap-tracking.test.ts b/host/test/mmap-tracking.test.ts index 6ae2a2d9ca..da860fdcd2 100644 --- a/host/test/mmap-tracking.test.ts +++ b/host/test/mmap-tracking.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; -import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { + createCentralizedKernelWorkerTestDouble, CentralizedKernelWorker +} from "../src/kernel-worker"; describe("MAP_SHARED host interval tracking", () => { it("splits a mapping around a partial munmap", () => { @@ -56,7 +58,7 @@ describe("MAP_SHARED host interval tracking", () => { }); function createWorker(): any { - return Object.assign(Object.create(CentralizedKernelWorker.prototype), { + return Object.assign(createCentralizedKernelWorkerTestDouble(), { sharedMappings: new Map(), }); } diff --git a/host/test/multi-worker.test.ts b/host/test/multi-worker.test.ts index d973aab341..970cf1024b 100644 --- a/host/test/multi-worker.test.ts +++ b/host/test/multi-worker.test.ts @@ -2,13 +2,23 @@ // // Tests CentralizedKernelWorker process management and fork flow. import { describe, it, expect, vi } from "vitest"; -import { readFileSync } from "node:fs"; +import { + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { + type CentralizedKernelCallbacks, + createCentralizedKernelWorkerTestDouble, CAPTURED_STDIO, CentralizedKernelWorker, shouldDeliverPosixTimerSignal, + } from "../src/kernel-worker"; +import { KernelReentrantEntryError } from "../src/kernel-entry-gate"; import { resolveBinary } from "../src/binary-resolver"; import { NodePlatformIO } from "../src/platform/node"; import { @@ -21,20 +31,24 @@ import { writeForkContinuationAnchor } from "../src/fork-continuation"; import { CH_TOTAL_SIZE, DEFAULT_MAX_PAGES, WASM_PAGE_SIZE } from "../src/constants"; import { ABI_SYSCALLS, + CHANNEL_STATUS_COMPLETE, + CHANNEL_STATUS_PENDING, CH_ARGS, + CH_ARGS_COUNT, CH_ARG_SIZE, CH_DATA, CH_ERRNO, CH_RETURN, + CH_STATUS, CH_SYSCALL, HOST_INTERCEPTED_SYSCALLS, PROCESS_MEMORY_PAGES_PER_THREAD_SLOT, PROCESS_MEMORY_THREAD_SLOT_CHANNEL_PRIMARY_PAGE, PROCESS_STATE_EXITED, + PROCESS_STATE_RUNNING, WPK_FORK_LINKED_FRAME_POINTER_WIDTHS, } from "../src/generated/abi"; import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; -import type { KernelScratchLease } from "../src/kernel-scratch"; const MAX_PAGES = 1024; // 64 MiB: enough to prove initial < maximum. const WASM32_CONTINUATION_HEADER_SIZE = @@ -84,6 +98,195 @@ function createProcessMemory(): { return { memory, channelOffset, layout }; } +function createRegistrationTestWorker( + kernelExports: Readonly>, + kernelExportNames: readonly string[], +): CentralizedKernelWorker { + const worker = createCentralizedKernelWorkerTestDouble(); + const kernelMemory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); + installKernelWorkerTestScratch( + worker, + kernelMemory, + 128, + 4, + { + kernelExports, + kernelExportNames, + }, + ); + return worker; +} + +type TestWorker = ReturnType< + typeof createCentralizedKernelWorkerTestDouble +>; +type TestChannel = ReturnType< + TestWorker["testAuthority"][ + "replaceProcessRegistrationForLifecycleTest" + ] +>[number]; + +interface GatedLifecycleHarness { + readonly worker: TestWorker; + readonly kernelMemory: WebAssembly.Memory; + readonly kernelExports: Record; +} + +function createGatedLifecycleHarness(options: { + readonly callbacks?: CentralizedKernelCallbacks; + readonly kernelExports?: Readonly>; + readonly pointerWidth?: 4 | 8; +} = {}): GatedLifecycleHarness { + const kernelMemory = new WebAssembly.Memory({ + initial: 2, + maximum: 2, + }); + const kernelExports: Record = { + kernel_clear_fork_child: vi.fn(() => 0), + kernel_drain_wakeup_events: vi.fn(() => 0), + kernel_get_parent_pid: vi.fn(() => -1), + kernel_get_process_exit_signal: vi.fn(() => -1), + kernel_get_process_state: vi.fn(() => PROCESS_STATE_RUNNING), + kernel_mark_process_signaled: vi.fn(() => 0), + kernel_remove_process: vi.fn(() => 0), + kernel_set_current_tid: vi.fn(() => 0), + kernel_set_max_addr: vi.fn(() => 0), + kernel_thread_exit: vi.fn(() => 0), + kernel_validate_task: vi.fn(() => 0), + ...(options.kernelExports ?? {}), + }; + const worker = createCentralizedKernelWorkerTestDouble({ + callbacks: options.callbacks, + }); + installKernelWorkerTestScratch( + worker, + kernelMemory, + 128, + options.pointerWidth ?? 4, + { + kernelExports, + kernelExportNames: Object.keys(kernelExports), + }, + ); + return { worker, kernelMemory, kernelExports }; +} + +function registerLifecycleProcess( + harness: GatedLifecycleHarness, + pid: number, + memory: WebAssembly.Memory, + channelOffset: number, + pointerWidth: 4 | 8 = 4, +): void { + harness.worker.registerProcess(pid, memory, [channelOffset], { + ptrWidth: pointerWidth, + maxAddr: memory.buffer.byteLength, + }); +} + +function writePendingSyscall( + memory: WebAssembly.Memory, + channelOffset: number, + syscall: number, + args: readonly number[], +): void { + const view = new DataView(memory.buffer, channelOffset, CH_TOTAL_SIZE); + view.setUint32(CH_SYSCALL, syscall, true); + for (let index = 0; index < CH_ARGS_COUNT; index++) { + view.setBigInt64( + CH_ARGS + index * CH_ARG_SIZE, + BigInt(args[index] ?? 0), + true, + ); + } + const statusView = new Int32Array( + memory.buffer, + channelOffset, + CH_TOTAL_SIZE / Int32Array.BYTES_PER_ELEMENT, + ); + Atomics.store( + statusView, + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + CHANNEL_STATUS_PENDING, + ); + Atomics.notify( + statusView, + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + 1, + ); +} + +async function waitForCondition( + condition: () => boolean, + description: string, +): Promise { + for (let attempt = 0; attempt < 100; attempt++) { + if (condition()) return; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error(`timed out waiting for ${description}`); +} + +async function waitForMailboxCompletion( + memory: WebAssembly.Memory, + channelOffset: number, +): Promise { + const statusView = new Int32Array( + memory.buffer, + channelOffset, + CH_TOTAL_SIZE / Int32Array.BYTES_PER_ELEMENT, + ); + await waitForCondition( + () => Atomics.load( + statusView, + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + ) === CHANNEL_STATUS_COMPLETE, + `channel ${channelOffset} completion`, + ); +} + +function readMailboxResult( + memory: WebAssembly.Memory, + channelOffset: number, +): { readonly value: number; readonly errno: number } { + const view = new DataView(memory.buffer, channelOffset, CH_TOTAL_SIZE); + return { + value: Number(view.getBigInt64(CH_RETURN, true)), + errno: view.getUint32(CH_ERRNO, true), + }; +} + +function expectUnexpectedMailboxEio( + memory: WebAssembly.Memory, + channelOffset: number, +): void { + // WHY: the unexpected-handler boundary deliberately publishes -EIO/EIO. + // CH_ERRNO is authoritative on error: channel_syscall.c returns `-err`, + // then musl maps that to the POSIX-visible -1 with errno=EIO. + expect(readMailboxResult(memory, channelOffset)).toEqual({ + value: -5, + errno: 5, + }); +} + +function kernelChannelResult( + kernelMemory: WebAssembly.Memory, + result: number, + errno = 0, +): ReturnType { + return vi.fn((rawOffset: number | bigint) => { + const offset = Number(rawOffset); + const view = new DataView( + kernelMemory.buffer, + offset, + CH_TOTAL_SIZE, + ); + view.setBigInt64(CH_RETURN, BigInt(result), true); + view.setUint32(CH_ERRNO, errno, true); + return 0; + }); +} + function attachProcess( kw: CentralizedKernelWorker, pid: number, @@ -96,47 +299,6 @@ function attachProcess( }); } -function issueThreadAttachment( - worker: CentralizedKernelWorker, - pid: number, - tid: number, -) { - const channel = (worker as any).processes.get(pid)?.channels[0]; - if (!channel) throw new Error(`No main channel for process ${pid}`); - const kernelMemory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); - let attachment: Parameters[0] - | undefined; - new DataView(channel.memory.buffer, channel.channelOffset) - .setUint32(CH_DATA, 0, true); - new DataView(channel.memory.buffer, channel.channelOffset) - .setUint32(CH_DATA + 4, 0, true); - (worker as any).callbacks = { - onClone: ( - value: Parameters[0], - ) => { - attachment = value; - return new Promise(() => {}); - }, - }; - (worker as any).kernel ??= { - toKernelPtr: (value: number | bigint) => Number(value), - }; - (worker as any).kernelMemory = kernelMemory; - installKernelWorkerTestScratch(worker as any, kernelMemory); - (worker as any).currentHandlePid = 0; - (worker as any).threadCtidPtrs ??= new Map(); - (worker as any).bindKernelTidForChannel = vi.fn(); - (worker as any).kernelInstance.exports.kernel_handle_channel = vi.fn((offset: number) => { - const kernelView = new DataView(kernelMemory.buffer, offset); - kernelView.setBigInt64(CH_RETURN, BigInt(tid), true); - kernelView.setUint32(CH_ERRNO, 0, true); - return 0; - }); - (worker as any).handleClone(channel, [0, 0, 0, 0, 0, 0]); - if (!attachment) throw new Error("clone callback did not receive attachment"); - return attachment; -} - function createAndRegisterProcess( kw: CentralizedKernelWorker, entry: ReturnType, @@ -146,40 +308,29 @@ function createAndRegisterProcess( return pid; } -function issueDirectKernelOpen( +async function issueDirectKernelOpen( worker: CentralizedKernelWorker, pid: number, + process: ReturnType, path: string, -): { value: number; errno: number } { - const region = (worker as any).scratchRegion; + flags = 0, + mode = 0, +): Promise<{ value: number; errno: number }> { + const pathPointer = 4 * WASM_PAGE_SIZE; const encoded = new TextEncoder().encode(`${path}\0`); - const setCurrentTid = (worker as any).kernelInstance.exports - .kernel_set_current_tid as (pid: number, tid: number) => number; - expect(setCurrentTid(pid, pid)).toBe(0); - return region.withLease((lease: KernelScratchLease) => { - lease.copyFrom(encoded, CH_DATA); - const channel = lease.dataView(0, CH_TOTAL_SIZE); - channel.setUint32(CH_SYSCALL, ABI_SYSCALLS.Open, true); - for (let index = 0; index < 6; index++) { - channel.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, 0n, true); - } - lease.writeAddress( - CH_ARGS, - CH_DATA, - encoded.byteLength, - "u32-to-u64-le", - ); - lease.invokeKernelExport("kernel_handle_channel", [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - pid, - ]); - const result = lease.dataView(0, CH_TOTAL_SIZE); - return { - value: Number(result.getBigInt64(CH_RETURN, true)), - errno: result.getUint32(CH_ERRNO, true), - }; - }); + new Uint8Array(process.memory.buffer).set(encoded, pathPointer); + writePendingSyscall( + process.memory, + process.channelOffset, + ABI_SYSCALLS.Open, + [pathPointer, flags, mode], + ); + await waitForMailboxCompletion( + process.memory, + process.channelOffset, + ); + expect(worker.getProcessMemory(pid)).toBe(process.memory); + return readMailboxResult(process.memory, process.channelOffset); } describe("CentralizedKernelWorker Process Management", () => { @@ -192,31 +343,28 @@ describe("CentralizedKernelWorker Process Management", () => { it("uses the kernel-assigned fork PID without host-side retries", async () => { const parentPid = 77; const memory = new WebAssembly.Memory({ initial: 4, maximum: 4, shared: true }); - const channel = { pid: parentPid, channelOffset: WASM_PAGE_SIZE, memory }; - publishMainForkContinuation(memory, channel.channelOffset); + const channelOffset = WASM_PAGE_SIZE; + publishMainForkContinuation(memory, channelOffset); const kernelForkProcess = vi.fn(() => 101); - const completeChannel = vi.fn(); const onFork = vi.fn(() => Promise.resolve([WASM_PAGE_SIZE])); - const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + const harness = createGatedLifecycleHarness({ callbacks: { onFork }, - processes: new Map([[parentPid, { channels: [channel] }]]), - channelTids: new Map(), - threadForkContexts: new Map(), - sharedMappings: new Map(), - tcpListenerTargets: new Map(), - epollInterests: new Map(), - completeChannel, - kernelInstance: { - exports: { - kernel_fork_process: kernelForkProcess, - kernel_clear_fork_child: vi.fn(() => 0), - kernel_get_process_exit_signal: vi.fn(() => -1), - }, - }, - }) as CentralizedKernelWorker; + kernelExports: { kernel_fork_process: kernelForkProcess }, + }); + registerLifecycleProcess( + harness, + parentPid, + memory, + channelOffset, + ); - (kw as any).handleFork(channel, [0]); - await Promise.resolve(); + writePendingSyscall( + memory, + channelOffset, + HOST_INTERCEPTED_SYSCALLS.SYS_FORK, + [0], + ); + await waitForMailboxCompletion(memory, channelOffset); expect(kernelForkProcess).toHaveBeenCalledOnce(); expect(kernelForkProcess).toHaveBeenCalledWith(parentPid, parentPid); @@ -229,14 +377,10 @@ describe("CentralizedKernelWorker Process Management", () => { forkBufAddr: TEST_FORK_CONTINUATION, }, }); - expect(completeChannel).toHaveBeenCalledWith( - channel, - HOST_INTERCEPTED_SYSCALLS.SYS_FORK, - [0], - undefined, - 101, - 0, - ); + expect(readMailboxResult(memory, channelOffset)).toEqual({ + value: 101, + errno: 0, + }); }); it("carries the exact pthread continuation anchor into the fork launch", async () => { @@ -252,16 +396,6 @@ describe("CentralizedKernelWorker Process Management", () => { maximum: 8, shared: true, }); - const mainChannel = { - pid: parentPid, - channelOffset: mainChannelOffset, - memory, - }; - const threadChannel = { - pid: parentPid, - channelOffset: threadChannelOffset, - memory, - }; writeForkContinuationAnchor( memory, threadChannelOffset - FORK_SAVE_BUFFER_SIZE, @@ -269,44 +403,70 @@ describe("CentralizedKernelWorker Process Management", () => { TEST_THREAD_FORK_CONTINUATION, ); const onFork = vi.fn(() => Promise.resolve([threadChannelOffset])); - const reserveHostRegionAt = vi.fn(); - const completeChannel = vi.fn(); - const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - callbacks: { onFork }, - processes: new Map([ - [parentPid, { - channels: [mainChannel, threadChannel], - ptrWidth: 4, - }], - ]), - channelTids: new Map([ - [`${parentPid}:${threadChannelOffset}`, threadTid], - ]), - threadForkContexts: new Map([ - [`${parentPid}:${threadChannelOffset}`, { fnPtr, argPtr }], - ]), - sharedMappings: new Map(), - tcpListenerTargets: new Map(), - epollInterests: new Map(), - reserveHostRegionAt, - completeChannel, - kernelInstance: { - exports: { - kernel_fork_process: vi.fn(() => childPid), - kernel_clear_fork_child: vi.fn(() => 0), - kernel_get_process_exit_signal: vi.fn(() => -1), - }, - }, - }) as CentralizedKernelWorker; - - (kw as any).handleFork(threadChannel, [0]); - await Promise.resolve(); - const slotStart = threadChannelOffset - PROCESS_MEMORY_THREAD_SLOT_CHANNEL_PRIMARY_PAGE * WASM_PAGE_SIZE; const slotLen = PROCESS_MEMORY_PAGES_PER_THREAD_SLOT * WASM_PAGE_SIZE; + const reserveHostRegionAt = vi.fn( + (_pid: number, address: number) => address, + ); + let harness!: GatedLifecycleHarness; + const onClone = vi.fn((attachment) => { + harness.worker.attachThreadChannel( + attachment, + threadChannelOffset, + ); + return Promise.resolve(); + }); + const kernelHandleChannel = vi.fn((rawOffset: number | bigint) => { + const offset = Number(rawOffset); + const kernelView = new DataView( + harness.kernelMemory.buffer, + offset, + CH_TOTAL_SIZE, + ); + kernelView.setBigInt64(CH_RETURN, BigInt(threadTid), true); + kernelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + harness = createGatedLifecycleHarness({ + callbacks: { onClone, onFork }, + kernelExports: { + kernel_fork_process: vi.fn(() => childPid), + kernel_handle_channel: kernelHandleChannel, + kernel_reserve_host_region_at: reserveHostRegionAt, + }, + }); + registerLifecycleProcess( + harness, + parentPid, + memory, + mainChannelOffset, + ); + const mainView = new DataView( + memory.buffer, + mainChannelOffset, + CH_TOTAL_SIZE, + ); + mainView.setUint32(CH_DATA, fnPtr, true); + mainView.setUint32(CH_DATA + 4, argPtr, true); + writePendingSyscall( + memory, + mainChannelOffset, + ABI_SYSCALLS.Clone, + [0, 5 * WASM_PAGE_SIZE, 0, 0, 0], + ); + await waitForMailboxCompletion(memory, mainChannelOffset); + + writePendingSyscall( + memory, + threadChannelOffset, + HOST_INTERCEPTED_SYSCALLS.SYS_FORK, + [0], + ); + await waitForMailboxCompletion(memory, threadChannelOffset); + expect(reserveHostRegionAt).toHaveBeenCalledWith( childPid, slotStart, @@ -325,62 +485,57 @@ describe("CentralizedKernelWorker Process Management", () => { slotLen, }, }); - expect(completeChannel).toHaveBeenCalledWith( - threadChannel, - HOST_INTERCEPTED_SYSCALLS.SYS_FORK, - [0], - undefined, - childPid, - 0, - ); + expect(readMailboxResult(memory, threadChannelOffset)).toEqual({ + value: childPid, + errno: 0, + }); }); it("reads and carries an exact wasm64 continuation anchor with i64 representation", async () => { const parentPid = 77; const childPid = 103; + const channelOffset = WASM_PAGE_SIZE; const memory = new WebAssembly.Memory({ initial: 8, maximum: 8, shared: true, }); - const channel = { pid: parentPid, channelOffset: WASM_PAGE_SIZE, memory }; const continuationAddress = 5 * WASM_PAGE_SIZE + WASM64_CONTINUATION_HEADER_SIZE; publishMainForkContinuation( memory, - channel.channelOffset, + channelOffset, 8, continuationAddress, ); const onFork = vi.fn(() => Promise.resolve([WASM_PAGE_SIZE])); - const completeChannel = vi.fn(); - const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + const harness = createGatedLifecycleHarness({ callbacks: { onFork }, - processes: new Map([ - [parentPid, { channels: [channel], ptrWidth: 8 }], - ]), - channelTids: new Map(), - threadForkContexts: new Map(), - sharedMappings: new Map(), - tcpListenerTargets: new Map(), - epollInterests: new Map(), - completeChannel, - kernelInstance: { - exports: { - kernel_fork_process: vi.fn(() => childPid), - kernel_clear_fork_child: vi.fn(() => 0), - kernel_get_process_exit_signal: vi.fn(() => -1), - }, + pointerWidth: 8, + kernelExports: { + kernel_fork_process: vi.fn(() => childPid), }, - }) as CentralizedKernelWorker; + }); + registerLifecycleProcess( + harness, + parentPid, + memory, + channelOffset, + 8, + ); const readBigUint64 = vi.spyOn(DataView.prototype, "getBigUint64"); try { - (kw as any).handleFork(channel, [0]); - await Promise.resolve(); + writePendingSyscall( + memory, + channelOffset, + HOST_INTERCEPTED_SYSCALLS.SYS_FORK, + [0], + ); + await waitForMailboxCompletion(memory, channelOffset); expect(readBigUint64).toHaveBeenCalledWith( - channel.channelOffset - FORK_SAVE_BUFFER_SIZE, + channelOffset - FORK_SAVE_BUFFER_SIZE, true, ); expect(onFork).toHaveBeenCalledWith({ @@ -392,50 +547,44 @@ describe("CentralizedKernelWorker Process Management", () => { forkBufAddr: continuationAddress, }, }); - expect(completeChannel).toHaveBeenCalledWith( - channel, - HOST_INTERCEPTED_SYSCALLS.SYS_FORK, - [0], - undefined, - childPid, - 0, - ); + expect(readMailboxResult(memory, channelOffset)).toEqual({ + value: childPid, + errno: 0, + }); } finally { readBigUint64.mockRestore(); } }); - it("rejects a missing continuation anchor before allocating a child", () => { + it("rejects a missing continuation anchor before allocating a child", async () => { const parentPid = 77; + const channelOffset = WASM_PAGE_SIZE; const memory = new WebAssembly.Memory({ initial: 4, maximum: 4, shared: true, }); - const channel = { pid: parentPid, channelOffset: WASM_PAGE_SIZE, memory }; const kernelForkProcess = vi.fn(() => 101); const onFork = vi.fn(() => Promise.resolve([WASM_PAGE_SIZE])); - const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + const harness = createGatedLifecycleHarness({ callbacks: { onFork }, - processes: new Map([ - [parentPid, { channels: [channel], ptrWidth: 4 }], - ]), - channelTids: new Map(), - threadForkContexts: new Map(), - sharedMappings: new Map(), - tcpListenerTargets: new Map(), - epollInterests: new Map(), - kernelInstance: { - exports: { - kernel_fork_process: kernelForkProcess, - kernel_get_process_exit_signal: vi.fn(() => -1), - }, - }, - }) as CentralizedKernelWorker; - - expect(() => (kw as any).handleFork(channel, [0])).toThrow( - "invalid fork continuation anchor 0", + kernelExports: { kernel_fork_process: kernelForkProcess }, + }); + registerLifecycleProcess( + harness, + parentPid, + memory, + channelOffset, + ); + writePendingSyscall( + memory, + channelOffset, + HOST_INTERCEPTED_SYSCALLS.SYS_FORK, + [0], ); + await waitForMailboxCompletion(memory, channelOffset); + + expectUnexpectedMailboxEio(memory, channelOffset); expect(kernelForkProcess).not.toHaveBeenCalled(); expect(onFork).not.toHaveBeenCalled(); }); @@ -453,43 +602,41 @@ describe("CentralizedKernelWorker Process Management", () => { }, ])( "rejects a nonzero $label continuation anchor before allocating a child", - ({ continuationAddress }) => { + async ({ continuationAddress }) => { const parentPid = 77; + const channelOffset = WASM_PAGE_SIZE; const memory = new WebAssembly.Memory({ initial: 4, maximum: 4, shared: true, }); - const channel = { pid: parentPid, channelOffset: WASM_PAGE_SIZE, memory }; publishMainForkContinuation( memory, - channel.channelOffset, + channelOffset, 4, continuationAddress, ); const kernelForkProcess = vi.fn(() => 101); const onFork = vi.fn(() => Promise.resolve([WASM_PAGE_SIZE])); - const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + const harness = createGatedLifecycleHarness({ callbacks: { onFork }, - processes: new Map([ - [parentPid, { channels: [channel], ptrWidth: 4 }], - ]), - channelTids: new Map(), - threadForkContexts: new Map(), - sharedMappings: new Map(), - tcpListenerTargets: new Map(), - epollInterests: new Map(), - kernelInstance: { - exports: { - kernel_fork_process: kernelForkProcess, - kernel_get_process_exit_signal: vi.fn(() => -1), - }, - }, - }) as CentralizedKernelWorker; - - expect(() => (kw as any).handleFork(channel, [0])).toThrow( - `invalid fork continuation anchor ${continuationAddress}`, + kernelExports: { kernel_fork_process: kernelForkProcess }, + }); + registerLifecycleProcess( + harness, + parentPid, + memory, + channelOffset, + ); + writePendingSyscall( + memory, + channelOffset, + HOST_INTERCEPTED_SYSCALLS.SYS_FORK, + [0], ); + await waitForMailboxCompletion(memory, channelOffset); + + expectUnexpectedMailboxEio(memory, channelOffset); expect(kernelForkProcess).not.toHaveBeenCalled(); expect(onFork).not.toHaveBeenCalled(); }, @@ -497,157 +644,190 @@ describe("CentralizedKernelWorker Process Management", () => { it("inherits child fd mirrors when the parent channel becomes stale during fork", async () => { const parentPid = 77; - const memory = new WebAssembly.Memory({ initial: 4, maximum: 4, shared: true }); - const oldChannel = { pid: parentPid, channelOffset: WASM_PAGE_SIZE, memory }; - publishMainForkContinuation(memory, oldChannel.channelOffset); - const replacementChannel = { - pid: parentPid, - channelOffset: 2 * WASM_PAGE_SIZE, - memory, - }; - const completeChannel = vi.fn(); + const childPid = 100; + const listenerFd = 4; + const listenerPort = 8080; + const oldChannelOffset = WASM_PAGE_SIZE; + const replacementChannelOffset = 2 * WASM_PAGE_SIZE; + const childChannelOffset = WASM_PAGE_SIZE; + const parentMemory = new WebAssembly.Memory({ + initial: 4, + maximum: 4, + shared: true, + }); + const childMemory = new WebAssembly.Memory({ + initial: 4, + maximum: 4, + shared: true, + }); + publishMainForkContinuation(parentMemory, oldChannelOffset); let finishFork!: (offsets: number[]) => void; const forkLaunch = new Promise((resolve) => { finishFork = resolve; }); - const close = vi.fn(); - const listener = { - server: { close }, - pid: parentPid, - port: 8080, - connections: new Set(), - }; - const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - callbacks: { onFork: vi.fn(() => forkLaunch) }, - processes: new Map([[parentPid, { channels: [oldChannel] }]]), - channelTids: new Map(), - threadForkContexts: new Map(), - sharedMappings: new Map(), - tcpListenerTargets: new Map([[8080, [{ pid: parentPid, fd: 4 }]]]), - tcpListenerRRIndex: new Map([[8080, 0]]), - tcpVirtualListenerKeys: new Map(), - tcpListeners: new Map([[`${parentPid}:4`, listener]]), - tcpConnections: new Map(), - shmMappings: new Map(), - io: { network: undefined }, - epollInterests: new Map([[`${parentPid}:6`, [ - { fd: 8, events: 1, data: 11n }, - ]]]), - completeChannel, - kernelInstance: { - exports: { - kernel_fork_process: vi.fn(() => 100), - kernel_clear_fork_child: vi.fn(() => 0), - kernel_get_process_exit_signal: vi.fn(() => -1), - }, + const onFork = vi.fn(() => forkLaunch); + const harness = createGatedLifecycleHarness({ + callbacks: { onFork }, + kernelExports: { + kernel_fork_process: vi.fn(() => childPid), + kernel_get_fd_accept_wake_idx: ( + _pid: number, + fd: number, + ) => fd === listenerFd ? 41 : -1, }, - }) as CentralizedKernelWorker; - - (kw as any).handleFork(oldChannel, [0]); - (kw as any).processes.set(parentPid, { channels: [replacementChannel] }); - expect((kw as any).tcpListenerTargets.get(8080)).toContainEqual({ pid: 100, fd: 4 }); - (kw as any).cleanupTcpListeners(parentPid); - expect(close).not.toHaveBeenCalled(); - expect((kw as any).tcpListeners.has("100:4")).toBe(true); - finishFork([WASM_PAGE_SIZE]); - await Promise.resolve(); + }); + const [oldChannel] = harness.worker.testAuthority + .replaceProcessRegistrationForLifecycleTest({ + pid: parentPid, + memory: parentMemory, + channelOffsets: [oldChannelOffset], + tcpListener: { + fd: listenerFd, + port: listenerPort, + }, + }); + if (oldChannel === undefined) { + throw new Error("fork lifecycle test did not install its parent channel"); + } + expect(harness.worker.pickListenerTarget(listenerPort)).toEqual({ + pid: parentPid, + fd: listenerFd, + }); + + writePendingSyscall( + parentMemory, + oldChannelOffset, + HOST_INTERCEPTED_SYSCALLS.SYS_FORK, + [0], + ); + harness.worker.testAuthority + .dispatchScratchBoundarySyscallForTest(oldChannel); + await waitForCondition( + () => onFork.mock.calls.length === 1, + "fork worker launch", + ); - expect((kw as any).tcpListenerTargets.get(8080)).toEqual([{ pid: 100, fd: 4 }]); - expect((kw as any).epollInterests.get("100:6")).toEqual([ - { fd: 8, events: 1, data: 11n }, - ]); - expect(completeChannel).not.toHaveBeenCalled(); + // The fork path must install child mirrors before the async worker launch. + // Replace the parent generation while that launch is pending, then remove + // only the replacement registration. The old channel is now stale, but + // the public listener lookup must still find the eagerly inherited child. + harness.worker.testAuthority.replaceProcessRegistrationForLifecycleTest({ + pid: parentPid, + memory: parentMemory, + channelOffsets: [replacementChannelOffset], + }); + registerLifecycleProcess( + harness, + childPid, + childMemory, + childChannelOffset, + ); + harness.worker.unregisterProcess(parentPid); + expect(harness.worker.pickListenerTarget(listenerPort)).toEqual({ + pid: childPid, + fd: listenerFd, + }); + + finishFork([childChannelOffset]); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect( + Atomics.load( + new Int32Array(parentMemory.buffer, oldChannelOffset), + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + ), + ).toBe(CHANNEL_STATUS_PENDING); + expect(harness.worker.pickListenerTarget(listenerPort)).toEqual({ + pid: childPid, + fd: listenerFd, + }); + harness.worker.unregisterProcess(childPid); }); it("removes eager child registrations and mirrors when fork worker launch fails", async () => { const parentPid = 77; const memory = new WebAssembly.Memory({ initial: 4, maximum: 4, shared: true }); - const channel = { pid: parentPid, channelOffset: WASM_PAGE_SIZE, memory }; - publishMainForkContinuation(memory, channel.channelOffset); - const completeChannel = vi.fn(); - const deactivateProcess = vi.fn(); + const channelOffset = WASM_PAGE_SIZE; + publishMainForkContinuation(memory, channelOffset); const removeProcess = vi.fn(() => 0); - const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - callbacks: { onFork: vi.fn(() => Promise.reject(new Error("launch failed"))) }, - processes: new Map([[parentPid, { channels: [channel] }]]), - channelTids: new Map(), - threadForkContexts: new Map(), - tcpListenerTargets: new Map([[8080, [{ pid: parentPid, fd: 4 }]]]), - epollInterests: new Map(), - completeChannel, - deactivateProcess, - kernelInstance: { - exports: { - kernel_fork_process: vi.fn(() => 100), - kernel_clear_fork_child: vi.fn(() => 0), - kernel_remove_process: removeProcess, - kernel_get_process_exit_signal: vi.fn(() => -1), - }, + const onFork = vi.fn(() => Promise.reject(new Error("launch failed"))); + const harness = createGatedLifecycleHarness({ + callbacks: { onFork }, + kernelExports: { + kernel_fork_process: vi.fn(() => 100), + kernel_remove_process: removeProcess, }, - }) as CentralizedKernelWorker; + }); + registerLifecycleProcess(harness, parentPid, memory, channelOffset); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); - (kw as any).handleFork(channel, [0]); - await Promise.resolve(); - await Promise.resolve(); + try { + writePendingSyscall( + memory, + channelOffset, + HOST_INTERCEPTED_SYSCALLS.SYS_FORK, + [0], + ); + await waitForMailboxCompletion(memory, channelOffset); - expect(deactivateProcess).toHaveBeenCalledWith(100); - expect(removeProcess).toHaveBeenCalledWith(100); - expect(completeChannel).toHaveBeenCalledWith( - channel, - HOST_INTERCEPTED_SYSCALLS.SYS_FORK, - [0], - undefined, - -1, - 12, - ); + expect(onFork).toHaveBeenCalledOnce(); + expect(removeProcess).toHaveBeenCalledWith(100); + expect(readMailboxResult(memory, channelOffset)).toEqual({ + value: -1, + errno: 12, + }); + expect(error).toHaveBeenCalledWith( + "[kernel-worker] fork worker launch failed: Error: launch failed", + ); + } finally { + error.mockRestore(); + } }); it("terminates the parent when a failed fork launch cannot remove the child", async () => { const parentPid = 77; const childPid = 100; const memory = new WebAssembly.Memory({ initial: 4, maximum: 4, shared: true }); - const channel = { pid: parentPid, channelOffset: WASM_PAGE_SIZE, memory }; - publishMainForkContinuation(memory, channel.channelOffset); - const completeChannel = vi.fn(); - const deactivateProcess = vi.fn(); + const channelOffset = WASM_PAGE_SIZE; + publishMainForkContinuation(memory, channelOffset); const removeProcess = vi.fn(() => -5); - const notifyHostProcessCrashed = vi.fn(); + const markProcessSignaled = vi.fn(() => 0); const onExit = vi.fn(); - const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + const harness = createGatedLifecycleHarness({ callbacks: { onFork: vi.fn(() => Promise.reject(new Error("launch failed"))), onExit, }, - processes: new Map([[parentPid, { channels: [channel] }]]), - channelTids: new Map(), - threadForkContexts: new Map(), - tcpListenerTargets: new Map([[8080, [{ pid: parentPid, fd: 4 }]]]), - epollInterests: new Map(), - completeChannel, - deactivateProcess, - notifyHostProcessCrashed, - kernelInstance: { - exports: { - kernel_fork_process: vi.fn(() => childPid), - kernel_clear_fork_child: vi.fn(() => 0), - kernel_remove_process: removeProcess, - kernel_get_process_exit_signal: vi.fn(() => -1), - }, + kernelExports: { + kernel_fork_process: vi.fn(() => childPid), + kernel_mark_process_signaled: markProcessSignaled, + kernel_remove_process: removeProcess, }, - }) as CentralizedKernelWorker; + }); + registerLifecycleProcess(harness, parentPid, memory, channelOffset); const error = vi.spyOn(console, "error").mockImplementation(() => {}); try { - (kw as any).handleFork(channel, [0]); - await Promise.resolve(); - await Promise.resolve(); + writePendingSyscall( + memory, + channelOffset, + HOST_INTERCEPTED_SYSCALLS.SYS_FORK, + [0], + ); + await waitForCondition( + () => onExit.mock.calls.length === 1, + "fatal fork rollback parent termination", + ); - expect(deactivateProcess).toHaveBeenCalledWith(childPid); expect(removeProcess).toHaveBeenCalledWith(childPid); - expect(notifyHostProcessCrashed).toHaveBeenCalledWith(parentPid, 11); + expect(markProcessSignaled).toHaveBeenCalledWith(parentPid, 11); expect(onExit).toHaveBeenCalledWith(parentPid, 139); - expect(channel.handling).toBe(true); - expect(completeChannel).not.toHaveBeenCalled(); + expect( + Atomics.load( + new Int32Array(memory.buffer, channelOffset), + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + ), + ).toBe(CHANNEL_STATUS_PENDING); expect(error).toHaveBeenCalledWith( "[handleSyscall] FATAL could not roll back fork child 100: " + "Kernel could not remove process 100: errno 5", @@ -657,7 +837,7 @@ describe("CentralizedKernelWorker Process Management", () => { } }); - it("completes pthread SYS_EXIT channels (clearing the exiting guest's atomic-wait waiter) even when the host terminates the worker", () => { + it("completes pthread SYS_EXIT channels (clearing the exiting guest's atomic-wait waiter) even when the host terminates the worker", async () => { // Regression guard for the reused-slot notify-steal deadlock. On thread // exit the kernel must flip the channel status word off CH_PENDING // (completeChannelRaw) so the exiting guest's in-wasm memory.atomic.wait32 @@ -678,43 +858,70 @@ describe("CentralizedKernelWorker Process Management", () => { maximum: 4, shared: true, }); - const channel = { - pid, - channelOffset: threadChannelOffset, - memory, - handling: true, - }; const onThreadExit = vi.fn(() => true); - const completeChannelRaw = vi.fn((ch: typeof channel) => { - ch.handling = false; + let harness!: GatedLifecycleHarness; + const onClone = vi.fn((attachment) => { + harness.worker.attachThreadChannel(attachment, threadChannelOffset); + return Promise.resolve(); + }); + let kernelMemory!: WebAssembly.Memory; + const handleChannel = vi.fn((rawOffset: number | bigint) => { + const view = new DataView( + kernelMemory.buffer, + Number(rawOffset), + CH_TOTAL_SIZE, + ); + view.setBigInt64(CH_RETURN, BigInt(tid), true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }); + const threadExit = vi.fn(() => 0); + harness = createGatedLifecycleHarness({ + callbacks: { onClone, onThreadExit }, + kernelExports: { + kernel_handle_channel: handleChannel, + kernel_thread_exit: threadExit, + }, }); + kernelMemory = harness.kernelMemory; + registerLifecycleProcess( + harness, + pid, + memory, + mainChannelOffset, + ); - const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - callbacks: { onThreadExit }, - processes: new Map([ - [pid, { channels: [{ channelOffset: mainChannelOffset }] }], - ]), - channelTids: new Map([[`${pid}:${threadChannelOffset}`, tid]]), - threadForkContexts: new Map([ - [`${pid}:${threadChannelOffset}`, { fnPtr: 1, argPtr: 2 }], - ]), - threadCtidPtrs: new Map(), - activeChannels: [channel], - notifyThreadExit: vi.fn(), - removeChannel: vi.fn(), - completeChannelRaw, - }); - - (kw as any).handleExit(channel, ABI_SYSCALLS.Exit, [0]); + writePendingSyscall( + memory, + mainChannelOffset, + ABI_SYSCALLS.Clone, + [0, 0, 0, 0, 0, 0], + ); + await waitForMailboxCompletion(memory, mainChannelOffset); + expect(readMailboxResult(memory, mainChannelOffset)).toEqual({ + value: tid, + errno: 0, + }); - // Still asks the host to tear down the backing thread Worker... + writePendingSyscall( + memory, + threadChannelOffset, + ABI_SYSCALLS.Exit, + [0], + ); + await waitForMailboxCompletion(memory, threadChannelOffset); + + // Still asks the host to tear down the backing thread Worker... expect(onThreadExit).toHaveBeenCalledWith(pid, tid, threadChannelOffset); // ...but now completes the channel so the guest's wait waiter is cleared. - expect(completeChannelRaw).toHaveBeenCalledWith(channel, 0, 0); - expect(channel.handling).toBe(false); + expect(readMailboxResult(memory, threadChannelOffset)).toEqual({ + value: 0, + errno: 0, + }); + expect(threadExit).toHaveBeenCalledWith(pid, tid); }); - it("keeps completing pthread SYS_EXIT channels when no host terminator is installed", () => { + it("keeps completing pthread SYS_EXIT channels when no host terminator is installed", async () => { const pid = 124; const mainChannelOffset = WASM_PAGE_SIZE; const threadChannelOffset = 2 * WASM_PAGE_SIZE; @@ -724,123 +931,289 @@ describe("CentralizedKernelWorker Process Management", () => { maximum: 4, shared: true, }); - const channel = { + let harness!: GatedLifecycleHarness; + const onClone = vi.fn((attachment) => { + harness.worker.attachThreadChannel(attachment, threadChannelOffset); + return Promise.resolve(); + }); + let kernelMemory!: WebAssembly.Memory; + const handleChannel = vi.fn((rawOffset: number | bigint) => { + const view = new DataView( + kernelMemory.buffer, + Number(rawOffset), + CH_TOTAL_SIZE, + ); + view.setBigInt64(CH_RETURN, BigInt(tid), true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }); + const threadExit = vi.fn(() => 0); + harness = createGatedLifecycleHarness({ + callbacks: { onClone }, + kernelExports: { + kernel_handle_channel: handleChannel, + kernel_thread_exit: threadExit, + }, + }); + kernelMemory = harness.kernelMemory; + registerLifecycleProcess( + harness, pid, - channelOffset: threadChannelOffset, memory, - handling: true, - }; - const completeChannelRaw = vi.fn((ch: typeof channel) => { - ch.handling = false; - }); - - const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - callbacks: {}, - processes: new Map([ - [pid, { channels: [{ channelOffset: mainChannelOffset }] }], - ]), - channelTids: new Map([[`${pid}:${threadChannelOffset}`, tid]]), - threadForkContexts: new Map(), - threadCtidPtrs: new Map(), - activeChannels: [channel], - notifyThreadExit: vi.fn(), - removeChannel: vi.fn(), - completeChannelRaw, - abandonChannel: vi.fn(), - }); - - (kw as any).handleExit(channel, ABI_SYSCALLS.Exit, [0]); - - expect(completeChannelRaw).toHaveBeenCalledWith(channel, 0, 0); - expect((kw as any).abandonChannel).not.toHaveBeenCalled(); - expect(channel.handling).toBe(false); + mainChannelOffset, + ); + + writePendingSyscall( + memory, + mainChannelOffset, + ABI_SYSCALLS.Clone, + [0, 0, 0, 0, 0, 0], + ); + await waitForMailboxCompletion(memory, mainChannelOffset); + + writePendingSyscall( + memory, + threadChannelOffset, + ABI_SYSCALLS.Exit, + [0], + ); + await waitForMailboxCompletion(memory, threadChannelOffset); + + expect(readMailboxResult(memory, threadChannelOffset)).toEqual({ + value: 0, + errno: 0, + }); + expect(threadExit).toHaveBeenCalledWith(pid, tid); }); it("rejects pthread exit when the channel lost its kernel-allocated TID", () => { const pid = 124; + const mainChannelOffset = WASM_PAGE_SIZE; + const threadChannelOffset = 2 * WASM_PAGE_SIZE; const memory = new WebAssembly.Memory({ initial: 4, maximum: 4, shared: true, }); - const mainChannel = { - pid, - channelOffset: WASM_PAGE_SIZE, - memory, - }; - const threadChannel = { - pid, - channelOffset: 2 * WASM_PAGE_SIZE, - memory, - }; - const finalizeThreadExit = vi.fn(); - const completeChannelRaw = vi.fn(); - const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - processes: new Map([ - [pid, { channels: [mainChannel, threadChannel], memory }], - ]), - channelTids: new Map(), - finalizeThreadExit, - completeChannelRaw, - callbacks: { onThreadExit: vi.fn() }, - }) as CentralizedKernelWorker; + const threadExit = vi.fn(() => 0); + const onThreadExit = vi.fn(); + const harness = createGatedLifecycleHarness({ + callbacks: { onThreadExit }, + kernelExports: { kernel_thread_exit: threadExit }, + }); + const [registrationWitness] = + harness.worker.testAuthority + .replaceProcessRegistrationForLifecycleTest({ + pid, + memory, + channelOffsets: [mainChannelOffset], + }); const expected = - `No kernel-validated TID for non-main channel ${threadChannel.channelOffset} ` + + `No kernel-validated TID for non-main channel ${threadChannelOffset} ` + `of process ${pid}`; - expect(() => (kw as any).handleExit( - threadChannel, - ABI_SYSCALLS.Exit, - [0], - )).toThrow(expected); - expect(finalizeThreadExit).not.toHaveBeenCalled(); - expect(completeChannelRaw).not.toHaveBeenCalled(); + let failure: unknown; + try { + harness.worker.testAuthority + .dispatchUntrackedThreadExitForTaskAuthorityTest( + pid, + registrationWitness!, + threadChannelOffset, + 0, + ); + } catch (cause) { + failure = cause; + } + expect(failure).toMatchObject({ + message: + "void kernel ingress untracked pthread exit task-authority test failed", + cause: { message: expected }, + }); + expect(threadExit).not.toHaveBeenCalled(); + expect(onThreadExit).not.toHaveBeenCalled(); }); - it("clears pthread child TID when forced thread cleanup skips guest SYS_EXIT", () => { + it("rejects a stale process-generation witness for untracked pthread exit", () => { + const pid = 124; + const mainChannelOffset = WASM_PAGE_SIZE; + const threadChannelOffset = 2 * WASM_PAGE_SIZE; + const oldMemory = new WebAssembly.Memory({ + initial: 4, + maximum: 4, + shared: true, + }); + const newMemory = new WebAssembly.Memory({ + initial: 4, + maximum: 4, + shared: true, + }); + const threadExit = vi.fn(() => 0); + const onThreadExit = vi.fn(); + const createProcess = vi.fn(() => 900); + const harness = createGatedLifecycleHarness({ + callbacks: { onThreadExit }, + kernelExports: { + kernel_create_process_with_stdio: createProcess, + kernel_thread_exit: threadExit, + }, + }); + const [staleWitness] = + harness.worker.testAuthority + .replaceProcessRegistrationForLifecycleTest({ + pid, + memory: oldMemory, + channelOffsets: [mainChannelOffset], + }); + harness.worker.testAuthority + .replaceProcessRegistrationForLifecycleTest({ + pid, + memory: newMemory, + channelOffsets: [mainChannelOffset], + }); + + expect(() => + harness.worker.testAuthority + .dispatchUntrackedThreadExitForTaskAuthorityTest( + pid, + staleWitness!, + threadChannelOffset, + 0, + ) + ).toThrow( + "task-authority test requires an untracked channel in the current process Memory generation", + ); + expect(threadExit).not.toHaveBeenCalled(); + expect(onThreadExit).not.toHaveBeenCalled(); + + // The stale capability is rejected without poisoning the selected + // generation or retaining work for a later entry. + expect(harness.worker.createProcess(CAPTURED_STDIO)).toBe(900); + expect(createProcess).toHaveBeenCalledOnce(); + }); + + it("rejects busy untracked pthread exit before reading or retaining its witness", async () => { + const witnessRead = vi.fn(); + const hostileWitness = new Proxy( + Object.create(null) as TestChannel, + { + get() { + witnessRead(); + throw new Error("busy entry read the caller-owned witness"); + }, + }, + ); + const threadExit = vi.fn(() => 0); + const onThreadExit = vi.fn(); + let harness!: GatedLifecycleHarness; + const createProcess = vi.fn(() => { + expect(() => + harness.worker.testAuthority + .dispatchUntrackedThreadExitForTaskAuthorityTest( + 124, + hostileWitness, + 2 * WASM_PAGE_SIZE, + 0, + ) + ).toThrow(KernelReentrantEntryError); + return 901; + }); + harness = createGatedLifecycleHarness({ + callbacks: { onThreadExit }, + kernelExports: { + kernel_create_process_with_stdio: createProcess, + kernel_thread_exit: threadExit, + }, + }); + + expect(harness.worker.createProcess(CAPTURED_STDIO)).toBe(901); + await Promise.resolve(); + + expect(witnessRead).not.toHaveBeenCalled(); + expect(threadExit).not.toHaveBeenCalled(); + expect(onThreadExit).not.toHaveBeenCalled(); + }); + + it("clears pthread child TID when forced thread cleanup skips guest SYS_EXIT", async () => { const pid = 125; const mainChannelOffset = WASM_PAGE_SIZE; const threadChannelOffset = 2 * WASM_PAGE_SIZE; const tid = 79; + const replacementTid = 80; const ctidPtr = 0x00040000; const memory = new WebAssembly.Memory({ initial: 16, maximum: 16, shared: true, }); - const channel = { + new DataView(memory.buffer).setInt32(ctidPtr, tid, true); + let harness!: GatedLifecycleHarness; + const onClone = vi.fn((attachment) => { + harness.worker.attachThreadChannel(attachment, threadChannelOffset); + return Promise.resolve(); + }); + let kernelMemory!: WebAssembly.Memory; + let cloneCount = 0; + const handleChannel = vi.fn((rawOffset: number | bigint) => { + const view = new DataView( + kernelMemory.buffer, + Number(rawOffset), + CH_TOTAL_SIZE, + ); + view.setBigInt64( + CH_RETURN, + BigInt(cloneCount++ === 0 ? tid : replacementTid), + true, + ); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }); + const threadExit = vi.fn(() => 0); + harness = createGatedLifecycleHarness({ + callbacks: { onClone }, + kernelExports: { + kernel_handle_channel: handleChannel, + kernel_thread_exit: threadExit, + }, + }); + kernelMemory = harness.kernelMemory; + registerLifecycleProcess( + harness, pid, - channelOffset: threadChannelOffset, memory, - i32View: new Int32Array(memory.buffer, threadChannelOffset), - consecutiveSyscalls: 0, - }; - new DataView(memory.buffer).setInt32(ctidPtr, tid, true); + mainChannelOffset, + ); - const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - processes: new Map([ - [pid, { memory, channels: [{ channelOffset: mainChannelOffset }, channel] }], - ]), - activeChannels: [channel], - pendingSleeps: new Map(), - channelTids: new Map([[`${pid}:${threadChannelOffset}`, tid]]), - threadForkContexts: new Map([ - [`${pid}:${threadChannelOffset}`, { fnPtr: 1, argPtr: 2 }], - ]), - threadCtidPtrs: new Map([[`${pid}:${tid}`, ctidPtr]]), - pendingSignalWaits: new Map(), - signalWaitDeadlines: new Map(), - notifyThreadExit: vi.fn(), - }) as CentralizedKernelWorker; - - kw.finalizeThreadExit(pid, tid, threadChannelOffset); + writePendingSyscall( + memory, + mainChannelOffset, + ABI_SYSCALLS.Clone, + [0x00200000, 0, 0, 0, ctidPtr, 0], + ); + await waitForMailboxCompletion(memory, mainChannelOffset); + + harness.worker.finalizeThreadExit(pid, tid, threadChannelOffset); expect(new DataView(memory.buffer).getInt32(ctidPtr, true)).toBe(0); - expect((kw as any).threadCtidPtrs.has(`${pid}:${tid}`)).toBe(false); - expect((kw as any).channelTids.has(`${pid}:${threadChannelOffset}`)).toBe(false); - expect((kw as any).threadForkContexts.has(`${pid}:${threadChannelOffset}`)).toBe(false); - expect((kw as any).activeChannels).toEqual([]); - expect((kw as any).notifyThreadExit).toHaveBeenCalledWith(pid, tid); + expect(threadExit).toHaveBeenCalledWith(pid, tid); + + // Reusing the exact channel offset proves forced cleanup released the + // transport attachment as well as the guest clear-TID word. + writePendingSyscall( + memory, + mainChannelOffset, + ABI_SYSCALLS.Clone, + [0, 0, 0, 0, 0, 0], + ); + await waitForMailboxCompletion(memory, mainChannelOffset); + expect(readMailboxResult(memory, mainChannelOffset)).toEqual({ + value: replacementTid, + errno: 0, + }); + harness.worker.finalizeThreadExit( + pid, + replacementTid, + threadChannelOffset, + ); }); it("registers pthread clear-TID before the host clone callback can complete", async () => { @@ -855,77 +1228,75 @@ describe("CentralizedKernelWorker Process Management", () => { maximum: 16, shared: true, }); - const processView = new DataView(memory.buffer, mainChannelOffset); - processView.setUint32(CH_DATA, 11, true); - processView.setUint32(CH_DATA + 4, 22, true); - - const kernelMemory = new WebAssembly.Memory({ - initial: 2, - maximum: 2, - }); - const threadCtidPtrs = new Map(); let resolveClone!: () => void; - let kw!: CentralizedKernelWorker; + let harness!: GatedLifecycleHarness; const onClone = vi.fn((attachment) => { - expect(threadCtidPtrs.get(`${pid}:${tid}`)).toBe(ctidPtr); - kw.attachThreadChannel(attachment, 2 * WASM_PAGE_SIZE); + harness.worker.attachThreadChannel( + attachment, + 2 * WASM_PAGE_SIZE, + ); return new Promise((resolve) => { resolveClone = resolve; }); }); - const channel = { pid, channelOffset: mainChannelOffset, memory }; - - kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + let kernelMemory!: WebAssembly.Memory; + const handleChannel = vi.fn((rawOffset: number | bigint) => { + const view = new DataView( + kernelMemory.buffer, + Number(rawOffset), + CH_TOTAL_SIZE, + ); + view.setBigInt64(CH_RETURN, BigInt(tid), true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }); + const threadExit = vi.fn(() => 0); + harness = createGatedLifecycleHarness({ callbacks: { onClone }, - kernel: { - toKernelPtr(value: number | bigint): number { - return Number(value); - }, - }, - kernelMemory, - currentHandlePid: 0, - activeChannels: [channel], - channelTids: new Map(), - execHandoffPids: new Set(), - hostReaped: new Set(), - processes: new Map([ - [pid, { channels: [channel], memory, explicitMaxAddr: true }], - ]), - threadCtidPtrs, - threadForkContexts: new Map(), - usePolling: true, - completeChannel: vi.fn(), - bindKernelTidForChannel: vi.fn(), - kernelInstance: { - exports: { - kernel_get_process_exit_signal: vi.fn(() => -1), - kernel_validate_task: vi.fn(() => 0), - kernel_handle_channel: vi.fn((offset: number) => { - const kernelView = new DataView(kernelMemory.buffer, offset); - kernelView.setBigInt64(CH_RETURN, BigInt(tid), true); - kernelView.setUint32(CH_ERRNO, 0, true); - return 0; - }), - }, + kernelExports: { + kernel_handle_channel: handleChannel, + kernel_thread_exit: threadExit, }, }); - installKernelWorkerTestScratch(kw as any, kernelMemory); + kernelMemory = harness.kernelMemory; + registerLifecycleProcess( + harness, + pid, + memory, + mainChannelOffset, + ); - (kw as any).handleClone( - channel, + writePendingSyscall( + memory, + mainChannelOffset, + ABI_SYSCALLS.Clone, [0x00200000, stackPtr, 0, tlsPtr, ctidPtr, 0], ); + await waitForCondition( + () => onClone.mock.calls.length === 1, + "pending clone callback", + ); + + // Forced cleanup can find and clear the word before the callback promise + // settles, proving the clone path published clear-TID ownership first. + new DataView(memory.buffer).setInt32(ctidPtr, tid, true); + harness.worker.finalizeThreadExit( + pid, + tid, + 2 * WASM_PAGE_SIZE, + ); + expect(new DataView(memory.buffer).getInt32(ctidPtr, true)).toBe(0); + expect(threadExit).toHaveBeenCalledWith(pid, tid); - expect(onClone).toHaveBeenCalledTimes(1); - expect(threadCtidPtrs.get(`${pid}:${tid}`)).toBe(ctidPtr); resolveClone(); - await Promise.resolve(); - expect((kw as any).completeChannel).toHaveBeenCalled(); + await waitForMailboxCompletion(memory, mainChannelOffset); }); it("does not erase replacement clear-TID metadata from a stale clone completion", async () => { const pid = 126; const tid = 79; + const oldCtidPtr = 0x00040000; + const newCtidPtr = 0x00050000; const oldMemory = new WebAssembly.Memory({ initial: 16, maximum: 16, @@ -937,113 +1308,181 @@ describe("CentralizedKernelWorker Process Management", () => { shared: true, }); const channelOffset = WASM_PAGE_SIZE; - const oldChannel = { pid, channelOffset, memory: oldMemory }; - const newChannel = { pid, channelOffset, memory: newMemory }; - const processView = new DataView(oldMemory.buffer, channelOffset); - processView.setUint32(CH_DATA, 11, true); - processView.setUint32(CH_DATA + 4, 22, true); - const kernelMemory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); - const threadCtidPtrs = new Map(); + const threadChannelOffset = 2 * WASM_PAGE_SIZE; + new DataView(oldMemory.buffer).setInt32(oldCtidPtr, tid, true); + new DataView(newMemory.buffer).setInt32(newCtidPtr, tid, true); let resolveClone!: () => void; - const onClone = vi.fn(() => new Promise((resolve) => { - resolveClone = resolve; - })); - const completeChannel = vi.fn(); - const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + let cloneCount = 0; + let harness!: GatedLifecycleHarness; + const onClone = vi.fn((attachment) => { + cloneCount++; + if (cloneCount === 1) { + return new Promise((resolve) => { + resolveClone = resolve; + }); + } + harness.worker.attachThreadChannel( + attachment, + threadChannelOffset, + ); + return Promise.resolve(); + }); + let kernelMemory!: WebAssembly.Memory; + const handleChannel = vi.fn((rawOffset: number | bigint) => { + const view = new DataView( + kernelMemory.buffer, + Number(rawOffset), + CH_TOTAL_SIZE, + ); + view.setBigInt64(CH_RETURN, BigInt(tid), true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }); + const threadExit = vi.fn(() => 0); + harness = createGatedLifecycleHarness({ callbacks: { onClone }, - kernel: { toKernelPtr: (value: number | bigint) => Number(value) }, - kernelMemory, - currentHandlePid: 0, - processes: new Map([[pid, { channels: [oldChannel] }]]), - threadCtidPtrs, - completeChannel, - bindKernelTidForChannel: vi.fn(), - kernelInstance: { - exports: { - kernel_handle_channel: vi.fn((offset: number) => { - const kernelView = new DataView(kernelMemory.buffer, offset); - kernelView.setBigInt64(CH_RETURN, BigInt(tid), true); - kernelView.setUint32(CH_ERRNO, 0, true); - return 0; - }), - }, + kernelExports: { + kernel_handle_channel: handleChannel, + kernel_thread_exit: threadExit, }, }); - installKernelWorkerTestScratch(kw as any, kernelMemory); + kernelMemory = harness.kernelMemory; + registerLifecycleProcess( + harness, + pid, + oldMemory, + channelOffset, + ); + + writePendingSyscall( + oldMemory, + channelOffset, + ABI_SYSCALLS.Clone, + [0x00200000, 0x00800000, 0, 0x00900000, oldCtidPtr, 0], + ); + await waitForCondition( + () => onClone.mock.calls.length === 1, + "old-generation clone callback", + ); - (kw as any).handleClone( - oldChannel, - [0, 0x00800000, 0, 0x00900000, 0x00040000, 0], + const [newChannel] = + harness.worker.testAuthority + .replaceProcessRegistrationForLifecycleTest({ + pid, + memory: newMemory, + channelOffsets: [channelOffset], + }); + writePendingSyscall( + newMemory, + channelOffset, + ABI_SYSCALLS.Clone, + [0x00200000, 0x00800000, 0, 0x00900000, newCtidPtr, 0], ); - (kw as any).processes.set(pid, { channels: [newChannel] }); - threadCtidPtrs.set(`${pid}:${tid}`, 0x00050000); + harness.worker.testAuthority + .dispatchScratchBoundarySyscallForTest(newChannel!); + await waitForMailboxCompletion(newMemory, channelOffset); + expect(readMailboxResult(newMemory, channelOffset)).toEqual({ + value: tid, + errno: 0, + }); + resolveClone(); - await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); - expect(threadCtidPtrs.get(`${pid}:${tid}`)).toBe(0x00050000); - expect(completeChannel).not.toHaveBeenCalled(); + harness.worker.finalizeThreadExit( + pid, + tid, + threadChannelOffset, + ); + expect(new DataView(newMemory.buffer).getInt32(newCtidPtr, true)).toBe(0); + expect(threadExit).toHaveBeenCalledTimes(1); + expect(threadExit).toHaveBeenCalledWith(pid, tid); + expect( + Atomics.load( + new Int32Array(oldMemory.buffer, channelOffset), + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + ), + ).toBe(CHANNEL_STATUS_PENDING); }); - it("does not lower compact process max_addr when adding dynamic pthread channels", () => { + it("does not lower compact process max_addr when adding dynamic pthread channels", async () => { const setMaxAddr = vi.fn(() => 0); - const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - initialized: true, - hostReaped: new Set(), - processes: new Map(), - activeChannels: [], - channelTids: new Map(), - threadForkContexts: new Map(), - usePolling: true, - kernel: { - toKernelPtr(value: number | bigint): number { - return Number(value); + const highThreadChannelOffset = 0x04000000 + 2 * WASM_PAGE_SIZE; + let kw!: CentralizedKernelWorker; + kw = createCentralizedKernelWorkerTestDouble({ + callbacks: { + onClone: (attachment) => { + kw.attachThreadChannel(attachment, highThreadChannelOffset); + return Promise.resolve(); }, }, - kernelInstance: { - exports: { + }); + const kernelMemory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); + installKernelWorkerTestScratch( + kw, + kernelMemory, + 128, + 4, + { + kernelExports: { + kernel_drain_wakeup_events: vi.fn(() => 0), + kernel_get_process_exit_signal: vi.fn(() => -1), kernel_get_process_state: vi.fn(() => 0), + kernel_handle_channel: vi.fn((offset: number) => { + const kernelView = new DataView(kernelMemory.buffer, offset); + kernelView.setBigInt64(CH_RETURN, 7n, true); + kernelView.setUint32(CH_ERRNO, 0, true); + return 0; + }), kernel_set_brk_base: vi.fn(() => 0), - kernel_set_mmap_base: vi.fn(() => 0), + kernel_set_current_tid: vi.fn(() => 0), kernel_set_max_addr: setMaxAddr, + kernel_set_mmap_base: vi.fn(() => 0), kernel_validate_task: vi.fn(() => 0), }, + kernelExportNames: [ + "kernel_drain_wakeup_events", + "kernel_get_process_exit_signal", + "kernel_get_process_state", + "kernel_handle_channel", + "kernel_set_brk_base", + "kernel_set_current_tid", + "kernel_set_max_addr", + "kernel_set_mmap_base", + "kernel_validate_task", + ], }, - }) as CentralizedKernelWorker; - const highThreadChannelOffset = 0x04000000 + 2 * WASM_PAGE_SIZE; + ); const memory = new WebAssembly.Memory({ initial: highThreadChannelOffset / WASM_PAGE_SIZE + 1, maximum: DEFAULT_MAX_PAGES, shared: true, }); const maxAddr = 0x20000000; + const mainChannelOffset = 4 * WASM_PAGE_SIZE; - kw.registerProcess(321, memory, [4 * WASM_PAGE_SIZE], { + kw.registerProcess(321, memory, [mainChannelOffset], { brkBase: 4 * WASM_PAGE_SIZE, mmapBase: 4 * WASM_PAGE_SIZE, maxAddr, }); - kw.attachThreadChannel( - issueThreadAttachment(kw, 321, 7), - highThreadChannelOffset, + writePendingSyscall( + memory, + mainChannelOffset, + ABI_SYSCALLS.Clone, + [0, 0, 0, 0, 0, 0], ); + await waitForMailboxCompletion(memory, mainChannelOffset); expect(setMaxAddr).toHaveBeenCalledTimes(1); expect(setMaxAddr).toHaveBeenCalledWith(321, maxAddr); }); it("rejects attaching host state to an unknown kernel process", () => { - const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - initialized: true, - hostReaped: new Set(), - processes: new Map(), - activeChannels: [], - usePolling: true, - kernelInstance: { - exports: { - kernel_get_process_state: vi.fn(() => -3), - }, - }, - }) as CentralizedKernelWorker; + const kw = createRegistrationTestWorker( + { kernel_get_process_state: vi.fn(() => -3) }, + ["kernel_get_process_state"], + ); const memory = new WebAssembly.Memory({ initial: 16, maximum: 16, @@ -1056,18 +1495,10 @@ describe("CentralizedKernelWorker Process Management", () => { }); it("rejects attaching host state to an exited kernel process", () => { - const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - initialized: true, - hostReaped: new Set(), - processes: new Map(), - activeChannels: [], - usePolling: true, - kernelInstance: { - exports: { - kernel_get_process_state: vi.fn(() => PROCESS_STATE_EXITED), - }, - }, - }) as CentralizedKernelWorker; + const kw = createRegistrationTestWorker( + { kernel_get_process_state: vi.fn(() => PROCESS_STATE_EXITED) }, + ["kernel_get_process_state"], + ); const memory = new WebAssembly.Memory({ initial: 16, maximum: 16, @@ -1080,18 +1511,11 @@ describe("CentralizedKernelWorker Process Management", () => { }); it("rejects attaching a host Worker to the kernel-reserved init PID", () => { - const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - initialized: true, - hostReaped: new Set(), - processes: new Map(), - activeChannels: [], - usePolling: true, - kernelInstance: { - exports: { - kernel_get_process_state: vi.fn(() => 0), - }, - }, - }) as CentralizedKernelWorker; + const getProcessState = vi.fn(() => PROCESS_STATE_RUNNING); + const kw = createRegistrationTestWorker( + { kernel_get_process_state: getProcessState }, + ["kernel_get_process_state"], + ); const memory = new WebAssembly.Memory({ initial: 16, maximum: 16, @@ -1101,139 +1525,307 @@ describe("CentralizedKernelWorker Process Management", () => { expect(() => kw.registerProcess(1, memory, [4 * WASM_PAGE_SIZE])).toThrow( "Cannot register the kernel-reserved init process", ); + expect(getProcessState).not.toHaveBeenCalled(); }); - it("rejects a thread channel whose TID is not owned by the kernel process", () => { + it("rejects a thread channel whose TID is not owned by the kernel process", async () => { const pid = 321; const mainChannelOffset = 4 * WASM_PAGE_SIZE; const threadChannelOffset = 8 * WASM_PAGE_SIZE; + const tid = 999; const validateTask = vi.fn(() => -3); const memory = new WebAssembly.Memory({ initial: 16, maximum: 16, shared: true, }); - const mainChannel = { + let attachmentFailure: unknown; + let harness!: GatedLifecycleHarness; + const onClone = vi.fn((attachment) => { + try { + harness.worker.attachThreadChannel( + attachment, + threadChannelOffset, + ); + } catch (cause) { + attachmentFailure = cause; + throw cause; + } + throw new Error("kernel-invalid TID was unexpectedly attached"); + }); + let kernelMemory!: WebAssembly.Memory; + const handleChannel = vi.fn((rawOffset: number | bigint) => { + const view = new DataView( + kernelMemory.buffer, + Number(rawOffset), + CH_TOTAL_SIZE, + ); + view.setBigInt64(CH_RETURN, BigInt(tid), true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }); + const threadExit = vi.fn(() => 0); + harness = createGatedLifecycleHarness({ + callbacks: { onClone }, + kernelExports: { + kernel_handle_channel: handleChannel, + kernel_thread_exit: threadExit, + kernel_validate_task: validateTask, + }, + }); + kernelMemory = harness.kernelMemory; + registerLifecycleProcess( + harness, pid, memory, - channelOffset: mainChannelOffset, - i32View: new Int32Array(memory.buffer, mainChannelOffset), - consecutiveSyscalls: 0, - }; - const channels = [mainChannel]; - const activeChannels = [mainChannel]; - const channelTids = new Map(); - const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - initialized: true, - hostReaped: new Set(), - execHandoffPids: new Set(), - processes: new Map([[pid, { pid, memory, channels }]]), - activeChannels, - channelTids, - threadForkContexts: new Map(), - usePolling: true, - kernelInstance: { - exports: { - kernel_validate_task: validateTask, - }, - }, - }) as CentralizedKernelWorker; + mainChannelOffset, + ); - expect(() => kw.attachThreadChannel( - issueThreadAttachment(kw, pid, 999), - threadChannelOffset, - )).toThrow( - "Kernel rejected tid 999 for process 321: errno 3", + writePendingSyscall( + memory, + mainChannelOffset, + ABI_SYSCALLS.Clone, + [0, 0, 0, 0, 0, 0], ); + await waitForCondition( + () => attachmentFailure !== undefined, + "kernel-invalid thread attachment rejection", + ); + + expect(attachmentFailure).toMatchObject({ + message: "void kernel ingress thread channel attachment failed", + cause: { + message: "Kernel rejected tid 999 for process 321: errno 3", + }, + }); expect(validateTask).toHaveBeenCalledWith(pid, 999); - expect(channels).toHaveLength(1); - expect(activeChannels).toHaveLength(1); - expect(channelTids.size).toBe(0); + expect(threadExit).not.toHaveBeenCalled(); + expect(harness.worker.getProcessMemory(pid)).toBe(memory); + expect( + Atomics.load( + new Int32Array(memory.buffer, mainChannelOffset), + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + ), + ).toBe(CHANNEL_STATUS_PENDING); }); - it("rejects non-canonical or leader identities before attaching a thread channel", () => { + it("rejects non-canonical or leader identities before attaching a thread channel", async () => { const pid = 321; - const memory = new WebAssembly.Memory({ - initial: 16, - maximum: 16, - shared: true, - }); - const mainChannel = { - pid, - memory, - channelOffset: 4 * WASM_PAGE_SIZE, - i32View: new Int32Array(memory.buffer, 4 * WASM_PAGE_SIZE), - consecutiveSyscalls: 0, - }; const validateTask = vi.fn(() => 0); - const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - initialized: true, - hostReaped: new Set(), - execHandoffPids: new Set(), - processes: new Map([[pid, { pid, memory, channels: [mainChannel] }]]), - activeChannels: [mainChannel], - channelTids: new Map(), - threadForkContexts: new Map(), - usePolling: true, - kernelInstance: { - exports: { kernel_validate_task: validateTask }, - }, - }) as CentralizedKernelWorker; - for (const tid of [pid, 0x8000_0000, 0x1_0000_0001]) { - expect(() => kw.attachThreadChannel( - issueThreadAttachment(kw, pid, tid), - 8 * WASM_PAGE_SIZE, - )).toThrow( - "requires a positive, non-leader kernel TID", + const memory = new WebAssembly.Memory({ + initial: 16, + maximum: 16, + shared: true, + }); + const mainChannelOffset = 4 * WASM_PAGE_SIZE; + const threadChannelOffset = 8 * WASM_PAGE_SIZE; + let attachmentFailure: unknown; + let harness!: GatedLifecycleHarness; + const onClone = vi.fn((attachment) => { + try { + harness.worker.attachThreadChannel( + attachment, + threadChannelOffset, + ); + } catch (cause) { + attachmentFailure = cause; + throw cause; + } + throw new Error("non-canonical TID was unexpectedly attached"); + }); + let kernelMemory!: WebAssembly.Memory; + const handleChannel = vi.fn((rawOffset: number | bigint) => { + const view = new DataView( + kernelMemory.buffer, + Number(rawOffset), + CH_TOTAL_SIZE, + ); + view.setBigInt64(CH_RETURN, BigInt(tid), true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }); + harness = createGatedLifecycleHarness({ + callbacks: { onClone }, + kernelExports: { + kernel_handle_channel: handleChannel, + kernel_validate_task: validateTask, + }, + }); + kernelMemory = harness.kernelMemory; + registerLifecycleProcess( + harness, + pid, + memory, + mainChannelOffset, + ); + + writePendingSyscall( + memory, + mainChannelOffset, + ABI_SYSCALLS.Clone, + [0, 0, 0, 0, 0, 0], + ); + await waitForCondition( + () => attachmentFailure !== undefined, + `non-canonical thread attachment rejection for ${tid}`, ); + expect(attachmentFailure).toMatchObject({ + message: "void kernel ingress thread channel attachment failed", + cause: { + message: expect.stringContaining( + "requires a positive, non-leader kernel TID", + ), + }, + }); } expect(validateTask).not.toHaveBeenCalled(); - expect((kw as any).activeChannels).toEqual([mainChannel]); - expect((kw as any).channelTids.size).toBe(0); }); it("should register and unregister processes", async () => { - const kw = new CentralizedKernelWorker( - { maxWorkers: 4, dataBufferSize: 65536, useSharedMemory: true }, - new NodePlatformIO(), - ); - await kw.init(loadKernelWasm()); + const threadChannelOffsets = new Map(); + const threadTids = new Map(); + let harness!: GatedLifecycleHarness; + const onClone = vi.fn((attachment) => { + const channelOffset = threadChannelOffsets.get(attachment.pid); + if (channelOffset === undefined) { + throw new Error( + `missing test thread channel for process ${attachment.pid}`, + ); + } + harness.worker.attachThreadChannel(attachment, channelOffset); + threadTids.set(attachment.pid, attachment.tid); + return Promise.resolve(); + }); + let kernelMemory!: WebAssembly.Memory; + const handleChannel = vi.fn(( + rawOffset: number | bigint, + _capacity: number, + pid: number, + ) => { + const view = new DataView( + kernelMemory.buffer, + Number(rawOffset), + CH_TOTAL_SIZE, + ); + view.setBigInt64(CH_RETURN, BigInt(pid + 1000), true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }); + harness = createGatedLifecycleHarness({ + callbacks: { onClone }, + kernelExports: { kernel_handle_channel: handleChannel }, + }); + kernelMemory = harness.kernelMemory; + const kw = harness.worker; const proc1 = createProcessMemory(); const proc2 = createProcessMemory(); expect(proc1.memory.buffer.byteLength).toBeLessThan(MAX_PAGES * WASM_PAGE_SIZE); expect(proc2.memory.buffer.byteLength).toBeLessThan(MAX_PAGES * WASM_PAGE_SIZE); - const firstPid = createAndRegisterProcess(kw, proc1); - const secondPid = createAndRegisterProcess(kw, proc2); - expect(firstPid).not.toBe(secondPid); + const firstPid = 500; + const secondPid = 501; + registerLifecycleProcess( + harness, + firstPid, + proc1.memory, + proc1.channelOffset, + ); + registerLifecycleProcess( + harness, + secondPid, + proc2.memory, + proc2.channelOffset, + ); - // Process teardown must retire every image-owned thread transport record, - // including metadata for workers that did not reach their own SYS_EXIT. - (kw as any).channelTids.set(`${firstPid}:1000`, 1001); - (kw as any).threadForkContexts.set(`${firstPid}:1000`, { fnPtr: 1, argPtr: 2 }); - (kw as any).threadCtidPtrs.set(`${firstPid}:1001`, 2000); - (kw as any).channelTids.set(`${secondPid}:3000`, 3001); - (kw as any).threadForkContexts.set(`${secondPid}:3000`, { fnPtr: 3, argPtr: 4 }); - (kw as any).threadCtidPtrs.set(`${secondPid}:3001`, 4000); + const firstThreadChannelOffset = + proc1.channelOffset === WASM_PAGE_SIZE + ? 2 * WASM_PAGE_SIZE + : WASM_PAGE_SIZE; + const secondThreadChannelOffset = + proc2.channelOffset === WASM_PAGE_SIZE + ? 2 * WASM_PAGE_SIZE + : WASM_PAGE_SIZE; + const firstCtidPtr = 4 * WASM_PAGE_SIZE; + const secondCtidPtr = 5 * WASM_PAGE_SIZE; + threadChannelOffsets.set(firstPid, firstThreadChannelOffset); + threadChannelOffsets.set(secondPid, secondThreadChannelOffset); + for (const [pid, process, ctidPtr] of [ + [firstPid, proc1, firstCtidPtr], + [secondPid, proc2, secondCtidPtr], + ] as const) { + writePendingSyscall( + process.memory, + process.channelOffset, + ABI_SYSCALLS.Clone, + [0x00200000, 0, 0, 0, ctidPtr, 0], + ); + await waitForMailboxCompletion( + process.memory, + process.channelOffset, + ); + const tid = threadTids.get(pid); + if (tid === undefined) { + throw new Error( + `clone did not attach for process ${pid}: ${ + JSON.stringify(readMailboxResult( + process.memory, + process.channelOffset, + )) + }`, + ); + } + new DataView(process.memory.buffer).setInt32( + ctidPtr, + tid, + true, + ); + expect( + kw.testAuthority + .inspectThreadTransportStateForLifecycleTest(pid), + ).toEqual({ + channelTidEntries: 1, + forkContextEntries: 1, + clearTidEntries: 1, + activeThreadChannels: 1, + }); + } // Unregister both without error kw.unregisterProcess(firstPid); - expect(Array.from((kw as any).channelTids.keys())).toEqual([`${secondPid}:3000`]); - expect(Array.from((kw as any).threadForkContexts.keys())).toEqual([`${secondPid}:3000`]); - expect(Array.from((kw as any).threadCtidPtrs.keys())).toEqual([`${secondPid}:3001`]); + expect( + kw.testAuthority + .inspectThreadTransportStateForLifecycleTest(firstPid), + ).toEqual({ + channelTidEntries: 0, + forkContextEntries: 0, + clearTidEntries: 0, + activeThreadChannels: 0, + }); + expect( + kw.testAuthority + .inspectThreadTransportStateForLifecycleTest(secondPid), + ).toEqual({ + channelTidEntries: 1, + forkContextEntries: 1, + clearTidEntries: 1, + activeThreadChannels: 1, + }); + expect(kw.getProcessMemory(firstPid)).toBeUndefined(); + expect(kw.getProcessMemory(secondPid)).toBe(proc2.memory); + kw.unregisterProcess(secondPid); - expect((kw as any).processes.has(firstPid)).toBe(false); - expect((kw as any).processes.has(secondPid)).toBe(false); expect( - (kw as any).activeChannels.some( - (ch: any) => ch.pid === firstPid || ch.pid === secondPid, - ), - ).toBe(false); - expect((kw as any).channelTids.size).toBe(0); - expect((kw as any).threadForkContexts.size).toBe(0); - expect((kw as any).threadCtidPtrs.size).toBe(0); + kw.testAuthority + .inspectThreadTransportStateForLifecycleTest(secondPid), + ).toEqual({ + channelTidEntries: 0, + forkContextEntries: 0, + clearTidEntries: 0, + activeThreadChannels: 0, + }); + expect(kw.getProcessMemory(secondPid)).toBeUndefined(); // Unregistering non-existent pid should not throw kw.unregisterProcess(999); @@ -1254,9 +1846,10 @@ describe("CentralizedKernelWorker Process Management", () => { // Issue open(2) directly through the real kernel export so the Rust // Process owns the exact host handle that unregisterProcess must release. - const opened = issueDirectKernelOpen( + const opened = await issueDirectKernelOpen( kw, pid, + procMemory, join(process.cwd(), "../Cargo.toml"), ); @@ -1272,72 +1865,82 @@ describe("CentralizedKernelWorker Process Management", () => { }); it("releases a retained mmap handle before forced descriptor teardown", async () => { + const tempDirectory = mkdtempSync( + join(tmpdir(), "kandelo-mmap-teardown-"), + ); + const filePath = join(tempDirectory, "mapped.bin"); + writeFileSync(filePath, new Uint8Array(4096).fill(0x41)); const io = new NodePlatformIO(); const open = vi.spyOn(io, "open"); + const write = vi.spyOn(io, "write"); const close = vi.spyOn(io, "close"); const kw = new CentralizedKernelWorker( { maxWorkers: 4, dataBufferSize: 65536, useSharedMemory: true }, io, ); - await kw.init(loadKernelWasm()); - - const procMemory = createProcessMemory(); - const pid = createAndRegisterProcess(kw, procMemory); - const opened = issueDirectKernelOpen( - kw, - pid, - join(process.cwd(), "../Cargo.toml"), - ); - const guestFd = opened.value; - expect(opened.errno).toBe(0); - expect(guestFd).toBeGreaterThanOrEqual(3); - const hostHandle = open.mock.results[0].value; - const stat = io.fstat(hostHandle); - const backingKey = io.fileHandleIdentity( - hostHandle, - BigInt(stat.dev), - BigInt(stat.ino), - )!; - - const retainedKernel = (kw as any).kernel; - retainedKernel.retainHostFileHandle(hostHandle); - const release = vi.spyOn(retainedKernel, "releaseHostFileHandle"); - (kw as any).sharedMmapBackings.set(backingKey, { - key: backingKey, - handle: hostHandle, - writable: false, - size: stat.size, - sizeValid: true, - pages: new Map(), - dirtyPages: new Set(), - refCount: 1, - version: 0, - }); - (kw as any).sharedMappings.set(pid, new Map([[0x1000, { - fd: guestFd, - fileOffset: 0, - len: 4096, - writable: false, - writeAllowed: false, - backingKind: "file", - backingKey, - snapshot: new Uint8Array(4096), - seenVersion: 0, - }]])); + let pid: number | undefined; + try { + await kw.init(loadKernelWasm()); + + const procMemory = createProcessMemory(); + pid = createAndRegisterProcess(kw, procMemory); + const opened = await issueDirectKernelOpen( + kw, + pid, + procMemory, + filePath, + 2, // O_RDWR + ); + const guestFd = opened.value; + expect(opened.errno).toBe(0); + expect(guestFd).toBeGreaterThanOrEqual(3); + expect(open).toHaveBeenCalledOnce(); + const hostHandle = open.mock.results[0]!.value; + + writePendingSyscall( + procMemory.memory, + procMemory.channelOffset, + ABI_SYSCALLS.Mmap, + [ + 0, + 4096, + 3, // PROT_READ | PROT_WRITE + 1, // MAP_SHARED + guestFd, + 0, + ], + ); + await waitForMailboxCompletion( + procMemory.memory, + procMemory.channelOffset, + ); + const mapped = readMailboxResult( + procMemory.memory, + procMemory.channelOffset, + ); + expect(mapped.errno).toBe(0); + expect(mapped.value).toBeGreaterThan(0); - kw.unregisterProcess(pid); + new Uint8Array(procMemory.memory.buffer)[mapped.value] = 0x42; + kw.unregisterProcess(pid); - expect(release).toHaveBeenCalledWith(hostHandle); - expect(close).toHaveBeenCalledWith(hostHandle); - const hostCloseCall = close.mock.calls.findIndex(([handle]) => handle === hostHandle); - expect(hostCloseCall).toBeGreaterThanOrEqual(0); - expect(release.mock.invocationCallOrder[0]!).toBeLessThan( - close.mock.invocationCallOrder[hostCloseCall]!, - ); - expect((kw as any).sharedMappings.has(pid)).toBe(false); - expect((kw as any).sharedMmapBackings.has(backingKey)).toBe(false); - expect((retainedKernel as any).retainedHostFileHandles.size).toBe(0); - expect(open).toHaveBeenCalledOnce(); + const hostWriteCall = write.mock.calls.findIndex( + ([handle]) => handle === hostHandle, + ); + const hostCloseCall = close.mock.calls.findIndex( + ([handle]) => handle === hostHandle, + ); + expect(hostWriteCall).toBeGreaterThanOrEqual(0); + expect(hostCloseCall).toBeGreaterThanOrEqual(0); + expect(write.mock.invocationCallOrder[hostWriteCall]!).toBeLessThan( + close.mock.invocationCallOrder[hostCloseCall]!, + ); + expect(readFileSync(filePath)[0]).toBe(0x42); + expect(kw.getProcessMemory(pid)).toBeUndefined(); + } finally { + if (pid !== undefined) kw.unregisterProcess(pid); + rmSync(tempDirectory, { recursive: true, force: true }); + } }); it("repeated compact-layout launches do not leave process registrations behind", async () => { @@ -1356,9 +1959,8 @@ describe("CentralizedKernelWorker Process Management", () => { kw.unregisterProcess(pid); } - expect((kw as any).activeChannels.length).toBe(0); for (const pid of pids) { - expect((kw as any).processes.has(pid)).toBe(false); + expect(kw.getProcessMemory(pid)).toBeUndefined(); } }); diff --git a/host/test/native-open-create-race.test.ts b/host/test/native-open-create-race.test.ts new file mode 100644 index 0000000000..2798dbebfd --- /dev/null +++ b/host/test/native-open-create-race.test.ts @@ -0,0 +1,211 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const openRace = vi.hoisted(() => ({ + armedPath: null as string | null, + nativeMode: 0o640, +})); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + const openSync = ((...args: Parameters) => { + const candidate = args[0]; + if ( + openRace.armedPath !== null + && candidate === openRace.armedPath + ) { + const racedPath = openRace.armedPath; + openRace.armedPath = null; + actual.writeFileSync(racedPath, "racer", { + flag: "wx", + mode: openRace.nativeMode, + }); + } + return actual.openSync(...args); + }) as typeof actual.openSync; + + return { + ...actual, + openSync, + }; +}); + +import { + linkSync, + lstatSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + statSync, + symlinkSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { NodePlatformIO } from "../src/platform/node"; +import type { StatResult } from "../src/types"; +import { HostFileSystem } from "../src/vfs/host-fs"; + +const O_RDWR = 0o2; +const O_CREAT = 0o100; +const O_EXCL = 0o200; +const O_TRUNC = 0o1000; +const O_NOFOLLOW = 0o400000; +const PERMISSION_MASK = 0o777; + +interface OpenBackend { + open(path: string, flags: number, mode: number): number; + close(handle: number): number; + fstat(handle: number): StatResult; + stat(path: string): StatResult; + lstat(path: string): StatResult; +} + +interface BackendCase { + backend: OpenBackend; + guestPath: string; + nativePath: string; + guestSibling(name: string): string; + nativeSibling(name: string): string; +} + +const roots: string[] = []; + +afterEach(() => { + openRace.armedPath = null; + while (roots.length > 0) { + rmSync(roots.pop()!, { recursive: true, force: true }); + } +}); + +function withUmask(mask: number, fn: () => T): T { + const previous = process.umask(mask); + try { + return fn(); + } finally { + process.umask(previous); + } +} + +function makeRoot(prefix: string): string { + const root = realpathSync(mkdtempSync(join(tmpdir(), prefix))); + roots.push(root); + return root; +} + +const backendFactories: Array<[string, () => BackendCase]> = [ + [ + "HostFileSystem", + () => { + const root = makeRoot("kandelo-host-fs-create-race-"); + return { + backend: new HostFileSystem(root), + guestPath: "/raced", + nativePath: join(root, "raced"), + guestSibling: (name) => `/${name}`, + nativeSibling: (name) => join(root, name), + }; + }, + ], + [ + "NodePlatformIO", + () => { + const root = makeRoot("kandelo-node-platform-create-race-"); + const nativePath = join(root, "raced"); + return { + backend: new NodePlatformIO() as OpenBackend, + guestPath: nativePath, + nativePath, + guestSibling: (name) => join(root, name), + nativeSibling: (name) => join(root, name), + }; + }, + ], +]; + +describe.each(backendFactories)("%s O_CREAT transaction", (_name, makeCase) => { + it("does not chmod or install create metadata on a race winner", () => { + const c = makeCase(); + openRace.armedPath = c.nativePath; + + const fd = withUmask(0, () => + c.backend.open(c.guestPath, O_RDWR | O_CREAT, 0), + ); + try { + expect(c.backend.fstat(fd).mode & PERMISSION_MASK).toBe(0o640); + } finally { + c.backend.close(fd); + } + + expect(c.backend.stat(c.guestPath).mode & PERMISSION_MASK).toBe(0o640); + expect(statSync(c.nativePath).mode & PERMISSION_MASK).toBe(0o640); + expect(readFileSync(c.nativePath, "utf8")).toBe("racer"); + }); + + it("retains O_TRUNC when opening the race winner", () => { + const c = makeCase(); + openRace.armedPath = c.nativePath; + + const fd = withUmask(0, () => + c.backend.open(c.guestPath, O_RDWR | O_CREAT | O_TRUNC, 0), + ); + try { + expect(c.backend.fstat(fd).mode & PERMISSION_MASK).toBe(0o640); + } finally { + c.backend.close(fd); + } + + expect(c.backend.stat(c.guestPath).mode & PERMISSION_MASK).toBe(0o640); + expect(statSync(c.nativePath).mode & PERMISSION_MASK).toBe(0o640); + expect(readFileSync(c.nativePath)).toHaveLength(0); + }); + + it("retains O_EXCL when another actor wins creation", () => { + const c = makeCase(); + openRace.armedPath = c.nativePath; + + expect(() => + withUmask(0, () => + c.backend.open(c.guestPath, O_RDWR | O_CREAT | O_EXCL, 0), + ), + ).toThrow(/EEXIST/); + + expect(statSync(c.nativePath).mode & PERMISSION_MASK).toBe(0o640); + expect(readFileSync(c.nativePath, "utf8")).toBe("racer"); + }); + + it("retains O_NOFOLLOW for an existing final symlink", () => { + const c = makeCase(); + const target = c.nativeSibling("target"); + symlinkSync("target", c.nativePath); + + expect(() => + c.backend.open(c.guestPath, O_RDWR | O_CREAT | O_NOFOLLOW, 0o600), + ).toThrow(/ELOOP/); + expect(() => statSync(target)).toThrow(); + }); + + it("follows a dangling final symlink for ordinary O_CREAT", () => { + const c = makeCase(); + const target = c.nativeSibling("target"); + const targetGuestPath = c.guestSibling("target"); + const alias = c.nativeSibling("target-alias"); + const aliasGuestPath = c.guestSibling("target-alias"); + symlinkSync("target", c.nativePath); + + const fd = c.backend.open(c.guestPath, O_RDWR | O_CREAT, 0o620); + c.backend.close(fd); + linkSync(target, alias); + + expect(statSync(target).mode & PERMISSION_MASK).toBe(0o600); + expect(c.backend.stat(c.guestPath).mode & PERMISSION_MASK).toBe(0o620); + const targetStat = c.backend.stat(targetGuestPath); + const aliasStat = c.backend.stat(aliasGuestPath); + expect(targetStat.mode & PERMISSION_MASK).toBe(0o620); + expect(aliasStat.mode & PERMISSION_MASK).toBe(0o620); + expect(aliasStat.dev).toBe(targetStat.dev); + expect(aliasStat.ino).toBe(targetStat.ino); + expect(c.backend.lstat(c.guestPath).mode & PERMISSION_MASK).toBe( + lstatSync(c.nativePath).mode & PERMISSION_MASK, + ); + }); +}); diff --git a/host/test/node-host-mounts.test.ts b/host/test/node-host-mounts.test.ts index ba0bc0c0aa..fa36c555f7 100644 --- a/host/test/node-host-mounts.test.ts +++ b/host/test/node-host-mounts.test.ts @@ -1,6 +1,6 @@ /** * Task 4.3 — Node host wires `host/wasm/rootfs.vfs` and applies - * `DEFAULT_MOUNT_SPEC` via `resolveForNode` at boot when the caller + * `DEFAULT_MOUNT_SPEC` via the fresh-session Node resolver at boot when the caller * does not supply a custom `io`. * * Each probe runs `examples/mount_probe_test.wasm` with a mode argv: diff --git a/host/test/node-host-vfs-only-metadata.test.ts b/host/test/node-host-vfs-only-metadata.test.ts index 268d19a189..758edbf760 100644 --- a/host/test/node-host-vfs-only-metadata.test.ts +++ b/host/test/node-host-vfs-only-metadata.test.ts @@ -258,20 +258,23 @@ describe.each(backendFactories)("%s", (_name, makeCase) => { expect(c.backend.stat(c.vfsPath("set-id-dir")).mode & MODE_MASK).toBe(0o6770); }); - it("relays open(O_CREAT) mode to native creation and records it virtually", () => { + it("uses a private native create mode and records the requested guest mode", () => { const c = makeCase(); const fd = withUmask(0, () => c.backend.open(c.vfsPath("created-file"), O_RDWR | O_CREAT | O_TRUNC, 0o751), ); try { expect(c.backend.fstat(fd).mode & MODE_MASK).toBe(0o751); - expect(fstatSync(fd).mode & MODE_MASK).toBe(0o751); + // WHY: creation must not expose a permissive host inode before virtual + // metadata owns the guest-visible mode. The guest still observes its + // requested 0751 through the authoritative virtual metadata. + expect(fstatSync(fd).mode & MODE_MASK).toBe(0o600); } finally { c.backend.close(fd); } expect(c.backend.stat(c.vfsPath("created-file")).mode & MODE_MASK).toBe(0o751); - expect(nativeMode(c.nativePath("created-file"))).toBe(0o751); + expect(nativeMode(c.nativePath("created-file"))).toBe(0o600); }); it("relays mkdir mode to native creation and records it virtually", () => { diff --git a/host/test/node-kernel-fatal.test.ts b/host/test/node-kernel-fatal.test.ts new file mode 100644 index 0000000000..79b896b29c --- /dev/null +++ b/host/test/node-kernel-fatal.test.ts @@ -0,0 +1,189 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { + KernelToMainMessage, + MainToKernelMessage, +} from "../src/node-kernel-protocol"; + +type WorkerEvent = "message" | "error" | "exit"; +type WorkerListener = (...args: any[]) => void; + +const workerMock = vi.hoisted(() => { + class MockNodeWorker { + static instances: MockNodeWorker[] = []; + + readonly sent: MainToKernelMessage[] = []; + readonly terminate = vi.fn(async () => 1); + private readonly listeners = new Map>(); + + constructor(_filename: string | URL, _options?: object) { + MockNodeWorker.instances.push(this); + } + + postMessage( + message: MainToKernelMessage, + _transfer?: readonly ArrayBuffer[], + ): void { + this.sent.push(message); + } + + on(event: WorkerEvent, listener: WorkerListener): this { + let listeners = this.listeners.get(event); + if (listeners === undefined) { + listeners = new Set(); + this.listeners.set(event, listeners); + } + listeners.add(listener); + return this; + } + + once(event: WorkerEvent, listener: WorkerListener): this { + const wrapper: WorkerListener = (...args) => { + this.removeListener(event, wrapper); + listener(...args); + }; + return this.on(event, wrapper); + } + + removeListener(event: WorkerEvent, listener: WorkerListener): this { + this.listeners.get(event)?.delete(listener); + return this; + } + + emit(event: WorkerEvent, ...args: any[]): void { + for (const listener of [...(this.listeners.get(event) ?? [])]) { + listener(...args); + } + } + + lastMessage( + type: T, + ): Extract | undefined { + for (let index = this.sent.length - 1; index >= 0; index--) { + const message = this.sent[index]!; + if (message.type === type) { + return message as Extract; + } + } + return undefined; + } + } + + return { MockNodeWorker }; +}); + +vi.mock("node:worker_threads", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + Worker: workerMock.MockNodeWorker, + }; +}); + +import { NodeKernelHost } from "../src/node-kernel-host"; + +type MockNodeWorker = InstanceType; + +async function initializedHost( + options?: ConstructorParameters[0], +): Promise<{ host: NodeKernelHost; worker: MockNodeWorker }> { + const host = new NodeKernelHost(options); + const initPromise = host.init(new ArrayBuffer(8)); + const worker = workerMock.MockNodeWorker.instances.at(-1); + expect(worker).toBeDefined(); + expect(worker!.lastMessage("init")).toBeDefined(); + worker!.emit("message", { + type: "ready", + } satisfies KernelToMainMessage); + await initPromise; + return { host, worker: worker! }; +} + +async function runningProcess( + host: NodeKernelHost, + worker: MockNodeWorker, +): Promise<{ exit: Promise }> { + const spawning = host.spawnFromVfs("/bin/sleep", ["/bin/sleep"]); + const spawn = worker.lastMessage("spawn"); + expect(spawn).toBeDefined(); + worker.emit("message", { + type: "response", + requestId: spawn!.requestId, + result: 101, + } satisfies KernelToMainMessage); + return { exit: (await spawning).exit }; +} + +describe("NodeKernelHost fatal worker lifecycle", () => { + beforeEach(() => { + workerMock.MockNodeWorker.instances = []; + vi.restoreAllMocks(); + }); + + it("preserves a typed kernel fatal that arrives before ready", async () => { + const host = new NodeKernelHost(); + const initPromise = host.init(new ArrayBuffer(8)); + const worker = workerMock.MockNodeWorker.instances.at(-1)!; + const fatalMessage = "transfer reservation trapped during initialization"; + const rejected = expect(initPromise).rejects.toThrow( + `Kernel worker failed: ${fatalMessage}`, + ); + + worker.emit("message", { + type: "kernel_fatal", + error: fatalMessage, + } satisfies KernelToMainMessage); + + await rejected; + expect(worker.terminate).toHaveBeenCalledOnce(); + }); + + it("rejects pending, process-exit, and future work after kernel_fatal", async () => { + const { host, worker } = await initializedHost(); + const { exit: processExit } = await runningProcess(host, worker); + const pendingRequest = host.getKernelMemoryPages(); + const fatalMessage = "reserved transfer execution trapped"; + const pendingRejection = expect(pendingRequest).rejects.toThrow( + `Kernel worker failed: ${fatalMessage}`, + ); + const exitRejection = expect(processExit).rejects.toThrow( + `Kernel worker failed: ${fatalMessage}`, + ); + const messagesBeforeFatal = worker.sent.length; + + worker.emit("message", { + type: "kernel_fatal", + error: fatalMessage, + } satisfies KernelToMainMessage); + + await Promise.all([pendingRejection, exitRejection]); + expect(worker.terminate).toHaveBeenCalledOnce(); + await expect(host.getKernelMemoryPages()).rejects.toThrow( + `Kernel worker failed: ${fatalMessage}`, + ); + expect(worker.sent).toHaveLength(messagesBeforeFatal); + }); + + it("rejects pending and future work when the worker exits unexpectedly", async () => { + const onHostDiagnostic = vi.fn(); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const { host, worker } = await initializedHost({ onHostDiagnostic }); + const { exit: processExit } = await runningProcess(host, worker); + const pendingRequest = host.getKernelMemoryPages(); + const exitMessage = "Kernel worker exited unexpectedly (code 17)"; + const pendingRejection = expect(pendingRequest).rejects.toThrow(exitMessage); + const processRejection = expect(processExit).rejects.toThrow(exitMessage); + const messagesBeforeExit = worker.sent.length; + + worker.emit("exit", 17); + + await Promise.all([pendingRejection, processRejection]); + expect(onHostDiagnostic).toHaveBeenCalledWith({ + pid: 0, + source: "kernel worker", + message: `[NodeKernelHost] ${exitMessage}`, + }); + expect(consoleError).toHaveBeenCalledWith(`[NodeKernelHost] ${exitMessage}`); + await expect(host.getKernelMemoryPages()).rejects.toThrow(exitMessage); + expect(worker.sent).toHaveLength(messagesBeforeExit); + }); +}); diff --git a/host/test/opfs-channel.test.ts b/host/test/opfs-channel.test.ts index 5cb937fd3c..b19ac1dc0c 100644 --- a/host/test/opfs-channel.test.ts +++ b/host/test/opfs-channel.test.ts @@ -19,6 +19,9 @@ describe("OpfsChannel", () => { ch.opcode = OpfsOpcode.READ; expect(ch.opcode).toBe(OpfsOpcode.READ); + + ch.opcode = OpfsOpcode.APPEND; + expect(ch.opcode).toBe(OpfsOpcode.APPEND); }); it("reads and writes args", () => { diff --git a/host/test/process-wait-lifecycle.test.ts b/host/test/process-wait-lifecycle.test.ts index 617383400a..d2c07a85fb 100644 --- a/host/test/process-wait-lifecycle.test.ts +++ b/host/test/process-wait-lifecycle.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it, vi } from "vitest"; import { ABI_SYSCALLS, + CH_ARGS, + CH_ARG_SIZE, CH_DATA, + CH_ERRNO, + CH_REQUEST_FLAGS, CH_RETURN, CH_SIG_FLAGS, CH_SIG_SIGNUM, @@ -9,12 +13,14 @@ import { CH_SYSCALL, CHANNEL_STATUS_COMPLETE, CHANNEL_STATUS_PENDING, + CHANNEL_REQUEST_FLAG_CANCELLATION_POINT, + CHANNEL_REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED, + CHANNEL_REQUEST_FLAGS_KNOWN_MASK, KERNEL_WAIT_RESULT_CHILD_UID_OFFSET, KERNEL_WAIT_RESULT_RUSAGE_OFFSET, KERNEL_WAIT_RESULT_SI_CODE_OFFSET, KERNEL_WAIT_RESULT_SI_STATUS_OFFSET, KERNEL_WAIT_RESULT_WAIT_STATUS_OFFSET, - PROCESS_STATE_EXITED, PROCESS_SIGINFO_CODE_OFFSET, PROCESS_SIGINFO_SIGNO_OFFSET, PROCESS_SIGINFO_WASM32_PID_OFFSET, @@ -25,6 +31,7 @@ import { PROCESS_SIGINFO_WASM64_SIZE, PROCESS_SIGINFO_WASM64_UID_OFFSET, PROCESS_SIGINFO_WASM64_VALUE_OFFSET, + PROCESS_STATE_EXITED, PROCESS_STATE_RUNNING, PROCESS_STATE_STOPPED, STRUCT_SIZE_KERNEL_WAIT_RESULT, @@ -40,8 +47,15 @@ import { WAKE_PROCESS_CONTINUED, WAKE_PROCESS_STOPPED, } from "../src/generated/abi"; -import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { + createCentralizedKernelWorkerTestDouble, +} from "../src/kernel-worker"; +import { + createKernelEntryGatedInstance, + KernelEntryGate, +} from "../src/kernel-entry-gate"; import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; +import { createKernelScratchTestInstance } from "./support/kernel-scratch-instance"; const SIGCHLD = 17; const SIGCONT = 18; @@ -59,40 +73,38 @@ describe("Rust-owned process wait lifecycle", () => { { length: STRUCT_SIZE_WASM_RUSAGE_WIRE }, (_, index) => index & 0xff, ); - const waitChildPoll = vi.fn( - ( - _parentPid: number, - _callerTid: number, - _targetPid: number, - _eventMask: number, - _flags: number, - resultPtr: number | bigint, - ) => { - writeKernelWaitResult(kernelMemory, Number(resultPtr), { - waitStatus, - siCode: 1, - siStatus: 5, - childUid: 123, - rusage, - }); - return 42; - }, - ); + const waitChildPoll = vi.fn(( + _parentPid: number, + _callerTid: number, + _targetPid: number, + _eventMask: number, + _flags: number, + resultPtr: number | bigint, + ) => { + writeKernelWaitResult(kernelMemory, Number(resultPtr), { + waitStatus, + siCode: 1, + siStatus: 5, + childUid: 123, + rusage, + }); + return 42; + }); const reapExitedChild = vi.fn(() => 0); const worker = createWorkerHarness({ kernel_wait_child_poll: waitChildPoll, kernel_reap_exited_child: reapExitedChild, - }); - worker.kernelMemory = kernelMemory; - installKernelWorkerTestScratch(worker, kernelMemory); - worker.completeWaitpid = vi.fn(); + }, 4, kernelMemory); + const completeChannel = observeMarshalledCompletions(worker); const rusagePtr = 512; - const channel = registerMainChannel( + const channel = registerMainChannel(worker, createChannel(7, processMemory)); + dispatchLifecycleSyscall( worker, - createChannel(7, processMemory), + channel, + ABI_SYSCALLS.Wait4, + syscallArgs(-1, statusPtr, 0, rusagePtr), ); - worker.handleWaitpid(channel, [-1, statusPtr, 0, rusagePtr]); expect(waitChildPoll).toHaveBeenCalledWith( 7, @@ -104,19 +116,17 @@ describe("Rust-owned process wait lifecycle", () => { STRUCT_SIZE_KERNEL_WAIT_RESULT, ); expect(reapExitedChild).not.toHaveBeenCalled(); - expect(new DataView(processMemory.buffer).getInt32(statusPtr, true)).toBe( - waitStatus, - ); - expect( - new Uint8Array( - processMemory.buffer, - rusagePtr, - STRUCT_SIZE_WASM_RUSAGE_WIRE, - ), - ).toEqual(rusage); - expect(worker.completeWaitpid).toHaveBeenCalledWith( - expect.any(Object), - [-1, statusPtr, 0, rusagePtr], + expect(new DataView(processMemory.buffer).getInt32(statusPtr, true)).toBe(waitStatus); + expect(new Uint8Array( + processMemory.buffer, + rusagePtr, + STRUCT_SIZE_WASM_RUSAGE_WIRE, + )).toEqual(rusage); + expect(completeChannel).toHaveBeenCalledWith( + channel, + ABI_SYSCALLS.Wait4, + syscallArgs(-1, statusPtr, 0, rusagePtr), + undefined, 42, 0, ); @@ -124,24 +134,27 @@ describe("Rust-owned process wait lifecycle", () => { it("wait4 leaves blocking waits in the host queue when Rust reports a running child", () => { const waitChildPoll = vi.fn(() => 0); - const worker = createWorkerHarness({ - kernel_wait_child_poll: waitChildPoll, - }); - worker.kernelMemory = createSharedMemory(); - installKernelWorkerTestScratch(worker, worker.kernelMemory); + const worker = createWorkerHarness({ kernel_wait_child_poll: waitChildPoll }); worker.waitingForChild = []; - worker.completeWaitpid = vi.fn(); + const completeChannel = observeMarshalledCompletions(worker); const channel = createChannel(7, createSharedMemory()); registerMainChannel(worker, channel); - worker.handleWaitpid(channel, [-1, 0, 0, 0]); + dispatchLifecycleSyscall( + worker, + channel, + ABI_SYSCALLS.Wait4, + [-1, 0, 0, 0], + ); - expect(worker.completeWaitpid).not.toHaveBeenCalled(); + expect(completeChannel).not.toHaveBeenCalled(); expect(worker.waitingForChild).toEqual([ { + cancellationPoint: false, + cancellationWakeAllowed: false, parentPid: 7, channel, - origArgs: [-1, 0, 0, 0], + origArgs: syscallArgs(-1, 0, 0, 0), pid: -1, options: 0, syscallNr: ABI_SYSCALLS.Wait4, @@ -151,82 +164,313 @@ describe("Rust-owned process wait lifecycle", () => { it("honors cancellation that lands before a wait can enqueue", () => { const waitChildPoll = vi.fn(() => 0); + const kernelMemory = createSharedMemory(); + const cancellationCleanupRequests: Array = []; + const handleChannel = successfulKernelHandle(kernelMemory, (view) => { + cancellationCleanupRequests.push([ + view.getUint32(CH_SYSCALL, true), + view.getBigInt64(CH_ARGS, true), + ]); + }); const processMemory = createSharedMemory(); const channel = createChannel(7, processMemory); const worker = createWorkerHarness({ + kernel_handle_channel: handleChannel, kernel_wait_child_poll: waitChildPoll, - }); - worker.processes = new Map([ - [ - 7, - { - channels: [channel], - memory: processMemory, - }, - ], - ]); + }, 4, kernelMemory); + worker.processes = new Map([[7, { + channels: [channel], + memory: processMemory, + }]]); worker.pendingCancels = new Set([channel]); worker.waitingForChild = []; - worker.completeChannelRaw = vi.fn(); - worker.relistenChannel = vi.fn(); + const relistenChannel = observeRelisten(worker); - worker.handleWaitpid(channel, [-1, 0, 0, 0]); + dispatchLifecycleSyscall( + worker, + channel, + ABI_SYSCALLS.Wait4, + [-1, 0, 0, 0], + true, + ); - expect(waitChildPoll).not.toHaveBeenCalled(); + // Cancellation is consumed at the final boundary immediately before the + // host would register the waiter, after Rust proves the child is still + // running. + expect(waitChildPoll).toHaveBeenCalledOnce(); + // WHY: the host may publish EINTR only after Rust confirms exact-task + // cleanup. This is a real synthetic kernel request, not a test-only + // shortcut around the production cancellation protocol. + expect(cancellationCleanupRequests).toEqual([ + [ABI_SYSCALLS.ThreadCancel, 7n], + ]); expect(worker.waitingForChild).toEqual([]); - expect(worker.completeChannelRaw).toHaveBeenCalledWith(channel, -4, 4); - expect(worker.relistenChannel).toHaveBeenCalledWith(channel); + expect(readCompletion(channel)).toEqual({ + retVal: -4, + errVal: 4, + status: CHANNEL_STATUS_COMPLETE, + }); + expect(relistenChannel).toHaveBeenCalledWith(channel); }); + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s preserves cancellation-point identity until wait registration", + (_name, pointerWidth) => { + const waitChildPoll = vi.fn(() => 0); + const kernelMemory = createSharedMemory(); + const cancellationCleanupRequests: Array = []; + const handleChannel = successfulKernelHandle(kernelMemory, (view) => { + cancellationCleanupRequests.push([ + view.getUint32(CH_SYSCALL, true), + view.getBigInt64(CH_ARGS, true), + ]); + }); + const processMemory = createSharedMemory(); + const channel = createChannel(7, processMemory); + const worker = createWorkerHarness( + { + kernel_handle_channel: handleChannel, + kernel_wait_child_poll: waitChildPoll, + }, + pointerWidth, + kernelMemory, + ); + worker.processes = new Map([[7, { + channels: [channel], + memory: processMemory, + }]]); + worker.pendingCancels = new Set([channel]); + worker.waitingForChild = []; + const relistenChannel = observeRelisten(worker); + + dispatchLifecycleSyscall( + worker, + channel, + ABI_SYSCALLS.Wait4, + [-1, 0, 0, 0], + true, + ); + + expect( + new DataView( + processMemory.buffer, + channel.channelOffset, + ).getUint32(CH_REQUEST_FLAGS, true), + ).toBe(0); + expect(waitChildPoll).toHaveBeenCalledOnce(); + expect(cancellationCleanupRequests).toEqual([ + [ABI_SYSCALLS.ThreadCancel, 7n], + ]); + expect(worker.waitingForChild).toEqual([]); + expect(worker.pendingCancels.has(channel)).toBe(false); + expect(readCompletion(channel)).toEqual({ + retVal: -4, + errVal: 4, + status: CHANNEL_STATUS_COMPLETE, + }); + expect(relistenChannel).toHaveBeenCalledWith(channel); + }, + ); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s clears and rejects unknown request identity bits", + (_name, pointerWidth) => { + const waitChildPoll = vi.fn(() => 0); + const memory = createSharedMemory(); + const channel = createChannel(7, memory); + const worker = createWorkerHarness( + { kernel_wait_child_poll: waitChildPoll }, + pointerWidth, + ); + registerMainChannel(worker, channel); + markPending(channel); + const view = new DataView(memory.buffer, channel.channelOffset); + view.setUint32(CH_SYSCALL, ABI_SYSCALLS.Wait4, true); + view.setUint32( + CH_REQUEST_FLAGS, + CHANNEL_REQUEST_FLAGS_KNOWN_MASK | 8, + true, + ); + + worker.testAuthority.dispatchScratchBoundarySyscallForTest(channel); + + expect(view.getUint32(CH_REQUEST_FLAGS, true)).toBe(0); + expect(waitChildPoll).not.toHaveBeenCalled(); + expect(readCompletion(channel)).toEqual({ + retVal: -1, + errVal: 22, + status: CHANNEL_STATUS_COMPLETE, + }); + }, + ); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s rejects cancellation-wake authority without cancellation-point identity", + (_name, pointerWidth) => { + const waitChildPoll = vi.fn(() => 0); + const memory = createSharedMemory(); + const channel = createChannel(7, memory); + const worker = createWorkerHarness( + { kernel_wait_child_poll: waitChildPoll }, + pointerWidth, + ); + registerMainChannel(worker, channel); + markPending(channel); + const view = new DataView(memory.buffer, channel.channelOffset); + view.setUint32(CH_SYSCALL, ABI_SYSCALLS.Wait4, true); + view.setUint32( + CH_REQUEST_FLAGS, + CHANNEL_REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED, + true, + ); + + worker.testAuthority.dispatchScratchBoundarySyscallForTest(channel); + + expect(view.getUint32(CH_REQUEST_FLAGS, true)).toBe(0); + expect(waitChildPoll).not.toHaveBeenCalled(); + expect(readCompletion(channel)).toEqual({ + retVal: -1, + errVal: 22, + status: CHANNEL_STATUS_COMPLETE, + }); + }, + ); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s does not consume a pending cancel for plain wait4", + (_name, pointerWidth) => { + const waitChildPoll = vi.fn(() => 0); + const processMemory = createSharedMemory(); + const channel = createChannel(7, processMemory); + const worker = createWorkerHarness( + { kernel_wait_child_poll: waitChildPoll }, + pointerWidth, + ); + worker.processes = new Map([[7, { + channels: [channel], + memory: processMemory, + }]]); + worker.pendingCancels = new Set([channel]); + worker.waitingForChild = []; + markPending(channel); + + dispatchLifecycleSyscall( + worker, + channel, + ABI_SYSCALLS.Wait4, + [-1, 0, 0, 0], + false, + ); + + expect(waitChildPoll).toHaveBeenCalledOnce(); + expect(worker.pendingCancels.has(channel)).toBe(true); + expect(worker.waitingForChild).toEqual([ + expect.objectContaining({ + channel, + syscallNr: ABI_SYSCALLS.Wait4, + cancellationPoint: false, + }), + ]); + expect(readStatus(channel)).toBe(CHANNEL_STATUS_PENDING); + }, + ); + it("consumes a pre-enqueue cancel only for the exact FIFO open retry", () => { const memory = createSharedMemory(); const channel = createChannel(7, memory); - const worker = createWorkerHarness({}); + const blockingRetryToken = 91n; + const tokenForRetry = vi.fn(() => blockingRetryToken); + const releaseRetry = vi.fn(() => 0); + const kernelSyscalls: number[] = []; + const handleChannel = vi.fn((channelPtr: number | bigint) => { + const view = new DataView(memory.buffer, Number(channelPtr)); + const syscallNr = view.getUint32(CH_SYSCALL, true); + kernelSyscalls.push(syscallNr); + const isOpen = syscallNr === ABI_SYSCALLS.Open; + view.setBigInt64(CH_RETURN, BigInt(isOpen ? -1 : 0), true); + view.setUint32(CH_ERRNO, isOpen ? 11 : 0, true); + return 0; + }); + const worker = createWorkerHarness({ + kernel_blocking_retry_release: releaseRetry, + kernel_blocking_retry_token: tokenForRetry, + kernel_handle_channel: handleChannel, + }, 4, memory); + registerMainChannel(worker, channel); worker.pendingCancels = new Set([channel]); - worker.cancelParkedFifoOpen = vi.fn(() => true); - worker.completeChannelRaw = vi.fn(); - worker.relistenChannel = vi.fn(); + const relistenChannel = observeRelisten(worker); + new TextEncoder().encodeInto( + "/fifo\0", + new Uint8Array(memory.buffer, 1024, 6), + ); - expect( - worker.interruptPendingFifoOpenCancellation(channel, ABI_SYSCALLS.Getpid), - ).toBe(false); expect(worker.pendingCancels.has(channel)).toBe(true); - expect(worker.cancelParkedFifoOpen).not.toHaveBeenCalled(); - expect( - worker.interruptPendingFifoOpenCancellation(channel, ABI_SYSCALLS.Open), - ).toBe(true); + dispatchLifecycleSyscall( + worker, + channel, + ABI_SYSCALLS.Open, + [1024, 0, 0], + true, + ); + expect(worker.pendingCancels.has(channel)).toBe(false); - expect(worker.cancelParkedFifoOpen).toHaveBeenCalledOnce(); - expect(worker.completeChannelRaw).toHaveBeenCalledOnce(); - expect(worker.completeChannelRaw).toHaveBeenCalledWith(channel, -4, 4); - expect(worker.relistenChannel).toHaveBeenCalledOnce(); - - expect( - worker.interruptPendingFifoOpenCancellation(channel, ABI_SYSCALLS.Open), - ).toBe(false); - expect(worker.completeChannelRaw).toHaveBeenCalledOnce(); + expect(kernelSyscalls).toEqual([ + ABI_SYSCALLS.Open, + ABI_SYSCALLS.ThreadCancel, + ]); + expect(tokenForRetry).toHaveBeenCalledOnce(); + expect(tokenForRetry).toHaveBeenCalledWith( + channel.pid, + channel.pid, + ABI_SYSCALLS.Open, + ); + expect(releaseRetry).toHaveBeenCalledOnce(); + expect(releaseRetry).toHaveBeenCalledWith( + channel.pid, + channel.pid, + blockingRetryToken, + ); + expect(readCompletion(channel)).toEqual({ + retVal: -4, + errVal: 4, + status: CHANNEL_STATUS_COMPLETE, + }); + expect(relistenChannel).toHaveBeenCalledOnce(); + expect(relistenChannel).toHaveBeenCalledWith(channel); }); it("wait4 WNOHANG completes without queuing when Rust reports no event", () => { - const worker = createWorkerHarness({ - kernel_wait_child_poll: vi.fn(() => 0), - }); - worker.kernelMemory = createSharedMemory(); - installKernelWorkerTestScratch(worker, worker.kernelMemory); + const worker = createWorkerHarness({ kernel_wait_child_poll: vi.fn(() => 0) }); worker.waitingForChild = []; - worker.completeWaitpid = vi.fn(); + const completeChannel = observeMarshalledCompletions(worker); - const channel = registerMainChannel( + const channel = registerMainChannel(worker, createChannel(7, createSharedMemory())); + dispatchLifecycleSyscall( worker, - createChannel(7, createSharedMemory()), + channel, + ABI_SYSCALLS.Wait4, + syscallArgs(-1, 0, WAIT_WNOHANG, 0), ); - worker.handleWaitpid(channel, [-1, 0, WAIT_WNOHANG, 0]); expect(worker.waitingForChild).toEqual([]); - expect(worker.completeWaitpid).toHaveBeenCalledWith( - expect.any(Object), - [-1, 0, WAIT_WNOHANG, 0], + expect(completeChannel).toHaveBeenCalledWith( + channel, + ABI_SYSCALLS.Wait4, + syscallArgs(-1, 0, WAIT_WNOHANG, 0), + undefined, 0, 0, ); @@ -234,20 +478,17 @@ describe("Rust-owned process wait lifecycle", () => { it("wait4 passes a bigint status pointer for wasm64 kernels", () => { const waitChildPoll = vi.fn(() => 0); - const worker = createWorkerHarness( - { kernel_wait_child_poll: waitChildPoll }, - 8, - ); - worker.kernelMemory = createSharedMemory(); - installKernelWorkerTestScratch(worker, worker.kernelMemory, 128, 8); + const worker = createWorkerHarness({ kernel_wait_child_poll: waitChildPoll }, 8); worker.waitingForChild = []; - worker.completeWaitpid = vi.fn(); + const completeChannel = observeMarshalledCompletions(worker); - const channel = registerMainChannel( + const channel = registerMainChannel(worker, createChannel(7, createSharedMemory())); + dispatchLifecycleSyscall( worker, - createChannel(7, createSharedMemory()), + channel, + ABI_SYSCALLS.Wait4, + syscallArgs(-1, 0, WAIT_WNOHANG, 0), ); - worker.handleWaitpid(channel, [-1, 0, WAIT_WNOHANG, 0]); expect(waitChildPoll).toHaveBeenCalledWith( 7, @@ -259,9 +500,11 @@ describe("Rust-owned process wait lifecycle", () => { STRUCT_SIZE_KERNEL_WAIT_RESULT, ); expect(worker.waitingForChild).toEqual([]); - expect(worker.completeWaitpid).toHaveBeenCalledWith( - expect.any(Object), - [-1, 0, WAIT_WNOHANG, 0], + expect(completeChannel).toHaveBeenCalledWith( + channel, + ABI_SYSCALLS.Wait4, + syscallArgs(-1, 0, WAIT_WNOHANG, 0), + undefined, 0, 0, ); @@ -269,20 +512,29 @@ describe("Rust-owned process wait lifecycle", () => { it("returns EFAULT before polling or consuming an event for invalid wait4 outputs", () => { const waitChildPoll = vi.fn(() => 42); - const worker = createWorkerHarness({ - kernel_wait_child_poll: waitChildPoll, - }); - worker.completeWaitpid = vi.fn(); + const worker = createWorkerHarness({ kernel_wait_child_poll: waitChildPoll }); + const completeChannel = observeMarshalledCompletions(worker); const processMemory = createSharedMemory(); const invalidStatusPtr = processMemory.buffer.byteLength - 2; const args = [-1, invalidStatusPtr, 0, 0]; + const channel = registerMainChannel( + worker, + createChannel(7, processMemory), + ); - worker.handleWaitpid(createChannel(7, processMemory), args); + dispatchLifecycleSyscall( + worker, + channel, + ABI_SYSCALLS.Wait4, + syscallArgs(...args), + ); expect(waitChildPoll).not.toHaveBeenCalled(); - expect(worker.completeWaitpid).toHaveBeenCalledWith( - expect.any(Object), - args, + expect(completeChannel).toHaveBeenCalledWith( + channel, + ABI_SYSCALLS.Wait4, + syscallArgs(...args), + undefined, -1, 14, ); @@ -294,38 +546,44 @@ describe("Rust-owned process wait lifecycle", () => { const siginfoPtr = 512; const rusagePtr = 1024; const rusage = new Uint8Array(STRUCT_SIZE_WASM_RUSAGE_WIRE).fill(0x5a); - const waitChildPoll = vi.fn( - ( - _parentPid: number, - _callerTid: number, - _targetPid: number, - _eventMask: number, - _flags: number, - resultPtr: number | bigint, - ) => { - writeKernelWaitResult(kernelMemory, Number(resultPtr), { - waitStatus: (19 << 8) | 0x7f, - siCode: WAIT_CLD_STOPPED, - siStatus: 19, - childUid: 4242, - rusage, - }); - return 44; - }, - ); - const worker = createWorkerHarness({ - kernel_wait_child_poll: waitChildPoll, + const waitChildPoll = vi.fn(( + _parentPid: number, + _callerTid: number, + _targetPid: number, + _eventMask: number, + _flags: number, + resultPtr: number | bigint, + ) => { + writeKernelWaitResult(kernelMemory, Number(resultPtr), { + waitStatus: (19 << 8) | 0x7f, + siCode: WAIT_CLD_STOPPED, + siStatus: 19, + childUid: 4242, + rusage, + }); + return 44; }); - worker.kernelMemory = kernelMemory; - installKernelWorkerTestScratch(worker, kernelMemory); - worker.completeWaitid = vi.fn(); - const args = [1, 44, siginfoPtr, WAIT_WSTOPPED | WAIT_WNOWAIT, rusagePtr]; + const worker = createWorkerHarness( + { kernel_wait_child_poll: waitChildPoll }, + 4, + kernelMemory, + ); + const completeChannel = observeMarshalledCompletions(worker); + const args = syscallArgs( + 1, + 44, + siginfoPtr, + WAIT_WSTOPPED | WAIT_WNOWAIT, + rusagePtr, + ); - const channel = registerMainChannel( + const channel = registerMainChannel(worker, createChannel(7, processMemory)); + dispatchLifecycleSyscall( worker, - createChannel(7, processMemory), + channel, + ABI_SYSCALLS.Waitid, + syscallArgs(...args), ); - worker.handleWaitid(channel, args); expect(waitChildPoll).toHaveBeenCalledWith( 7, @@ -337,31 +595,36 @@ describe("Rust-owned process wait lifecycle", () => { STRUCT_SIZE_KERNEL_WAIT_RESULT, ); const siginfo = new DataView(processMemory.buffer); - expect( - siginfo.getInt32(siginfoPtr + PROCESS_SIGINFO_SIGNO_OFFSET, true), - ).toBe(SIGCHLD); - expect( - siginfo.getInt32(siginfoPtr + PROCESS_SIGINFO_CODE_OFFSET, true), - ).toBe(WAIT_CLD_STOPPED); - expect( - siginfo.getInt32(siginfoPtr + PROCESS_SIGINFO_WASM32_PID_OFFSET, true), - ).toBe(44); - expect( - siginfo.getUint32(siginfoPtr + PROCESS_SIGINFO_WASM32_UID_OFFSET, true), - ).toBe(4242); - expect( - siginfo.getInt32(siginfoPtr + PROCESS_SIGINFO_WASM32_VALUE_OFFSET, true), - ).toBe(19); - expect( - new Uint8Array( - processMemory.buffer, - rusagePtr, - STRUCT_SIZE_WASM_RUSAGE_WIRE, - ), - ).toEqual(rusage); - expect(worker.completeWaitid).toHaveBeenCalledWith( - expect.any(Object), + expect(siginfo.getInt32( + siginfoPtr + PROCESS_SIGINFO_SIGNO_OFFSET, + true, + )).toBe(SIGCHLD); + expect(siginfo.getInt32( + siginfoPtr + PROCESS_SIGINFO_CODE_OFFSET, + true, + )).toBe(WAIT_CLD_STOPPED); + expect(siginfo.getInt32( + siginfoPtr + PROCESS_SIGINFO_WASM32_PID_OFFSET, + true, + )).toBe(44); + expect(siginfo.getUint32( + siginfoPtr + PROCESS_SIGINFO_WASM32_UID_OFFSET, + true, + )).toBe(4242); + expect(siginfo.getInt32( + siginfoPtr + PROCESS_SIGINFO_WASM32_VALUE_OFFSET, + true, + )).toBe(19); + expect(new Uint8Array( + processMemory.buffer, + rusagePtr, + STRUCT_SIZE_WASM_RUSAGE_WIRE, + )).toEqual(rusage); + expect(completeChannel).toHaveBeenCalledWith( + channel, + ABI_SYSCALLS.Waitid, args, + undefined, 0, 0, ); @@ -372,42 +635,34 @@ describe("Rust-owned process wait lifecycle", () => { const processMemory = createSharedMemory(); const channel = createChannel(7, processMemory); const siginfoPtr = 512; - const waitChildPoll = vi.fn( - ( - _parentPid: number, - _callerTid: number, - _targetPid: number, - _eventMask: number, - _flags: number, - resultPtr: number | bigint, - ) => { - writeKernelWaitResult(kernelMemory, Number(resultPtr), { - waitStatus: 9 << 8, - siCode: WAIT_CLD_EXITED, - siStatus: 9, - childUid: 5150, - rusage: new Uint8Array(STRUCT_SIZE_WASM_RUSAGE_WIRE), - }); - return 44; - }, - ); + const waitChildPoll = vi.fn(( + _parentPid: number, + _callerTid: number, + _targetPid: number, + _eventMask: number, + _flags: number, + resultPtr: number | bigint, + ) => { + writeKernelWaitResult(kernelMemory, Number(resultPtr), { + waitStatus: 9 << 8, + siCode: WAIT_CLD_EXITED, + siStatus: 9, + childUid: 5150, + rusage: new Uint8Array(STRUCT_SIZE_WASM_RUSAGE_WIRE), + }); + return 44; + }); const worker = createWorkerHarness( { kernel_wait_child_poll: waitChildPoll }, 8, + kernelMemory, ); - worker.kernelMemory = kernelMemory; - installKernelWorkerTestScratch(worker, kernelMemory, 128, 8); - worker.processes = new Map([ - [ - 7, - { - channels: [channel], - memory: processMemory, - ptrWidth: 8, - }, - ], - ]); - worker.completeWaitid = vi.fn(); + worker.processes = new Map([[7, { + channels: [channel], + memory: processMemory, + ptrWidth: 8, + }]]); + observeMarshalledCompletions(worker); const args = [1, 44, siginfoPtr, WAIT_WEXITED, 0]; new Uint8Array( processMemory.buffer, @@ -415,33 +670,41 @@ describe("Rust-owned process wait lifecycle", () => { PROCESS_SIGINFO_WASM64_SIZE, ).fill(0xa5); - worker.handleWaitid(channel, args); + dispatchLifecycleSyscall( + worker, + channel, + ABI_SYSCALLS.Waitid, + args, + ); const siginfo = new DataView(processMemory.buffer); - expect( - siginfo.getInt32(siginfoPtr + PROCESS_SIGINFO_SIGNO_OFFSET, true), - ).toBe(SIGCHLD); - expect( - siginfo.getInt32(siginfoPtr + PROCESS_SIGINFO_CODE_OFFSET, true), - ).toBe(WAIT_CLD_EXITED); - expect( - siginfo.getUint32( - siginfoPtr + PROCESS_SIGINFO_CODE_OFFSET + Int32Array.BYTES_PER_ELEMENT, - true, - ), - ).toBe(0); - expect( - siginfo.getInt32(siginfoPtr + PROCESS_SIGINFO_WASM64_PID_OFFSET, true), - ).toBe(44); - expect( - siginfo.getUint32(siginfoPtr + PROCESS_SIGINFO_WASM64_UID_OFFSET, true), - ).toBe(5150); - expect( - siginfo.getInt32(siginfoPtr + PROCESS_SIGINFO_WASM64_VALUE_OFFSET, true), - ).toBe(9); - expect(siginfo.getUint8(siginfoPtr + PROCESS_SIGINFO_WASM64_SIZE - 1)).toBe( - 0, - ); + expect(siginfo.getInt32( + siginfoPtr + PROCESS_SIGINFO_SIGNO_OFFSET, + true, + )).toBe(SIGCHLD); + expect(siginfo.getInt32( + siginfoPtr + PROCESS_SIGINFO_CODE_OFFSET, + true, + )).toBe(WAIT_CLD_EXITED); + expect(siginfo.getUint32( + siginfoPtr + PROCESS_SIGINFO_CODE_OFFSET + Int32Array.BYTES_PER_ELEMENT, + true, + )).toBe(0); + expect(siginfo.getInt32( + siginfoPtr + PROCESS_SIGINFO_WASM64_PID_OFFSET, + true, + )).toBe(44); + expect(siginfo.getUint32( + siginfoPtr + PROCESS_SIGINFO_WASM64_UID_OFFSET, + true, + )).toBe(5150); + expect(siginfo.getInt32( + siginfoPtr + PROCESS_SIGINFO_WASM64_VALUE_OFFSET, + true, + )).toBe(9); + expect(siginfo.getUint8( + siginfoPtr + PROCESS_SIGINFO_WASM64_SIZE - 1, + )).toBe(0); }); it("waitid WNOHANG zeros all siginfo bytes and leaves rusage untouched", () => { @@ -459,35 +722,39 @@ describe("Rust-owned process wait lifecycle", () => { STRUCT_SIZE_WASM_RUSAGE_WIRE, ).fill(0x6b); const waitChildPoll = vi.fn(() => 0); - const worker = createWorkerHarness({ - kernel_wait_child_poll: waitChildPoll, - }); - worker.completeWaitid = vi.fn(); - const args = [0, 0, siginfoPtr, WAIT_WEXITED | WAIT_WNOHANG, rusagePtr]; + const worker = createWorkerHarness({ kernel_wait_child_poll: waitChildPoll }); + const completeChannel = observeMarshalledCompletions(worker); + const args = syscallArgs( + 0, + 0, + siginfoPtr, + WAIT_WEXITED | WAIT_WNOHANG, + rusagePtr, + ); - const channel = registerMainChannel( + const channel = registerMainChannel(worker, createChannel(7, processMemory)); + dispatchLifecycleSyscall( worker, - createChannel(7, processMemory), + channel, + ABI_SYSCALLS.Waitid, + args, ); - worker.handleWaitid(channel, args); - - expect( - new Uint8Array( - processMemory.buffer, - siginfoPtr, - PROCESS_SIGINFO_WASM32_SIZE, - ), - ).toEqual(new Uint8Array(PROCESS_SIGINFO_WASM32_SIZE)); - expect( - new Uint8Array( - processMemory.buffer, - rusagePtr, - STRUCT_SIZE_WASM_RUSAGE_WIRE, - ), - ).toEqual(new Uint8Array(STRUCT_SIZE_WASM_RUSAGE_WIRE).fill(0x6b)); - expect(worker.completeWaitid).toHaveBeenCalledWith( - expect.any(Object), + + expect(new Uint8Array( + processMemory.buffer, + siginfoPtr, + PROCESS_SIGINFO_WASM32_SIZE, + )).toEqual(new Uint8Array(PROCESS_SIGINFO_WASM32_SIZE)); + expect(new Uint8Array( + processMemory.buffer, + rusagePtr, + STRUCT_SIZE_WASM_RUSAGE_WIRE, + )).toEqual(new Uint8Array(STRUCT_SIZE_WASM_RUSAGE_WIRE).fill(0x6b)); + expect(completeChannel).toHaveBeenCalledWith( + channel, + ABI_SYSCALLS.Waitid, args, + undefined, 0, 0, ); @@ -495,19 +762,29 @@ describe("Rust-owned process wait lifecycle", () => { it("rejects invalid waitid idtypes and required null siginfo before polling", () => { const waitChildPoll = vi.fn(() => 0); - const worker = createWorkerHarness({ - kernel_wait_child_poll: waitChildPoll, - }); - worker.completeWaitid = vi.fn(); - const channel = createChannel(7, createSharedMemory()); + const worker = createWorkerHarness({ kernel_wait_child_poll: waitChildPoll }); + const completeChannel = observeMarshalledCompletions(worker); + const channel = registerMainChannel( + worker, + createChannel(7, createSharedMemory()), + ); - worker.handleWaitid(channel, [99, 0, 512, WAIT_WEXITED, 0]); - worker.handleWaitid(channel, [0, 0, 0, WAIT_WEXITED, 0]); + dispatchLifecycleSyscall( + worker, + channel, + ABI_SYSCALLS.Waitid, + [99, 0, 512, WAIT_WEXITED, 0], + ); + dispatchLifecycleSyscall( + worker, + channel, + ABI_SYSCALLS.Waitid, + [0, 0, 0, WAIT_WEXITED, 0], + ); expect(waitChildPoll).not.toHaveBeenCalled(); - expect( - worker.completeWaitid.mock.calls.map((call: unknown[]) => call[3]), - ).toEqual([22, 14]); + expect(completeChannel.mock.calls.map((call: unknown[]) => call[5])) + .toEqual([22, 14]); }); it("owns a drained wake batch before nested SIGCHLD work reuses scratch", () => { @@ -517,20 +794,108 @@ describe("Rust-owned process wait lifecycle", () => { writeWakeEvent(kernelMemory, outPtr, 1, 43, WAKE_PROCESS_CONTINUED); return 2; }); - const worker = createWorkerHarness({ kernel_drain_wakeup_events: drain }); - worker.kernelMemory = kernelMemory; - installKernelWorkerTestScratch(worker, kernelMemory); + const worker = createWorkerHarness( + { + kernel_drain_wakeup_events: drain, + kernel_get_parent_pid: vi.fn(() => 7), + kernel_has_sa_nocldstop: vi.fn(() => 0), + }, + 4, + kernelMemory, + ); worker.stoppedPids = new Set(); - worker.notifyParentOfChildStateTransition = vi.fn(() => { + const sendSignalToProcess = vi.fn(() => { new Uint8Array(kernelMemory.buffer).fill(0xff); }); - worker.resumeStoppedProcess = vi.fn(() => true); + configureBoundaryHooks(worker, { sendSignalToProcess }); worker.drainAndProcessWakeupEvents(); expect(worker.stoppedPids.has(42)).toBe(true); - expect(worker.resumeStoppedProcess).toHaveBeenCalledWith(43); - expect(worker.notifyParentOfChildStateTransition).toHaveBeenCalledTimes(2); + expect(worker.pendingResumePids.has(43)).toBe(true); + expect(sendSignalToProcess).toHaveBeenCalledTimes(2); + expect(sendSignalToProcess).toHaveBeenCalledWith(7, SIGCHLD, true); + }); + + it("keeps SIGCONT release final after retry and kill publications", async () => { + const kernelMemory = createSharedMemory(); + const parentMemory = createSharedMemory(); + const childMemory = createSharedMemory(); + const parentChannel = createChannel(7, parentMemory); + const childChannel = createChannel(42, childMemory); + let continuedPending = false; + const handleChannel = vi.fn((channelPtr: number | bigint) => { + const view = new DataView(kernelMemory.buffer, Number(channelPtr)); + const syscallNr = view.getUint32(CH_SYSCALL, true); + const targetPid = Number(view.getBigInt64(CH_ARGS, true)); + const signum = Number( + view.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + if ( + syscallNr === ABI_SYSCALLS.Kill + && targetPid === 42 + && signum === SIGCONT + ) { + continuedPending = true; + } + view.setBigInt64(CH_RETURN, 0n, true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }); + const drain = vi.fn((outPtr: number) => { + if (!continuedPending) return 0; + continuedPending = false; + writeWakeEvent( + kernelMemory, + outPtr, + 0, + 42, + WAKE_PROCESS_CONTINUED, + ); + return 1; + }); + const worker = createWorkerHarness({ + kernel_drain_wakeup_events: drain, + kernel_get_parent_pid: vi.fn((pid: number) => pid === 42 ? 7 : 0), + kernel_get_process_state: vi.fn(() => PROCESS_STATE_RUNNING), + kernel_handle_channel: handleChannel, + kernel_has_sa_nocldstop: vi.fn(() => 0), + kernel_pick_signal_target_tid: vi.fn(() => 0), + }, 4, kernelMemory); + worker.processes = new Map([ + [7, { pid: 7, channels: [parentChannel], memory: parentMemory }], + [42, { pid: 42, channels: [childChannel], memory: childMemory }], + ]); + worker.channelTids = new Map([ + ["7:0", 7], + ["42:0", 42], + ]); + worker.stoppedPids = new Set([42]); + worker.parkedChannelCompletions = new Map(); + worker.deferredStoppedChannels = new Map(); + worker.deferredProcessWorkerStarts = new Map(); + const relistenChannel = observeRelisten(worker); + markPending(parentChannel); + + // This is the production ordering that failed in the real worker: + // resume requests its detached transaction while the enclosing kill still + // has to queue retry scheduling and mailbox publication. + dispatchLifecycleSyscall( + worker, + parentChannel, + ABI_SYSCALLS.Kill, + [42, SIGCONT], + ); + await drainLifecycleGate(); + + expect(readCompletion(parentChannel)).toEqual({ + retVal: 0, + errVal: 0, + status: CHANNEL_STATUS_COMPLETE, + }); + expect(worker.stoppedPids.has(42)).toBe(false); + expect(relistenChannel).toHaveBeenCalledWith(parentChannel); + expect(handleChannel).toHaveBeenCalledTimes(2); }); it("does not report CONTINUED when resume preflight stops the process again", () => { @@ -539,23 +904,34 @@ describe("Rust-owned process wait lifecycle", () => { const drain = vi.fn((outPtr: number) => { if (drained) return 0; drained = true; - writeWakeEvent(kernelMemory, outPtr, 0, 43, WAKE_PROCESS_CONTINUED); + writeWakeEvent( + kernelMemory, + outPtr, + 0, + 43, + WAKE_PROCESS_CONTINUED, + ); return 1; }); - const worker = createWorkerHarness({ kernel_drain_wakeup_events: drain }); - worker.kernelMemory = kernelMemory; - installKernelWorkerTestScratch(worker, kernelMemory); + const worker = createWorkerHarness( + { + kernel_drain_wakeup_events: drain, + kernel_get_parent_pid: vi.fn(() => 7), + kernel_get_process_state: vi.fn(() => PROCESS_STATE_STOPPED), + kernel_has_sa_nocldstop: vi.fn(() => 0), + }, + 4, + kernelMemory, + ); worker.pendingPipeReaders = new Map(); worker.pendingPipeWriters = new Map(); - worker.resumeStoppedProcess = vi.fn(() => false); - worker.notifyParentOfChildStateTransition = vi.fn(); - worker.anyPendingRetryNeedsSignalSafeWake = vi.fn(() => false); - worker.scheduleWakeBlockedRetries = vi.fn(); + const sendSignalToProcess = vi.fn(); + configureBoundaryHooks(worker, { sendSignalToProcess }); worker.drainAndProcessWakeupEvents(); - expect(worker.resumeStoppedProcess).toHaveBeenCalledWith(43); - expect(worker.notifyParentOfChildStateTransition).not.toHaveBeenCalled(); + expect(worker.stoppedPids.has(43)).toBe(false); + expect(sendSignalToProcess).not.toHaveBeenCalled(); }); it("drains a STOPPED transition generated while CONTINUED preflight fails", () => { @@ -563,32 +939,51 @@ describe("Rust-owned process wait lifecycle", () => { let batch = 0; const drain = vi.fn((outPtr: number) => { if (batch++ === 0) { - writeWakeEvent(kernelMemory, outPtr, 0, 43, WAKE_PROCESS_CONTINUED); + writeWakeEvent( + kernelMemory, + outPtr, + 0, + 43, + WAKE_PROCESS_CONTINUED, + ); return 1; } if (batch === 2) { - writeWakeEvent(kernelMemory, outPtr, 0, 43, WAKE_PROCESS_STOPPED); + writeWakeEvent( + kernelMemory, + outPtr, + 0, + 43, + WAKE_PROCESS_STOPPED, + ); return 1; } return 0; }); - const worker = createWorkerHarness({ kernel_drain_wakeup_events: drain }); - worker.kernelMemory = kernelMemory; - installKernelWorkerTestScratch(worker, kernelMemory); + const worker = createWorkerHarness( + { + kernel_drain_wakeup_events: drain, + kernel_get_parent_pid: vi.fn(() => 7), + kernel_get_process_state: vi.fn() + .mockReturnValueOnce(PROCESS_STATE_RUNNING) + .mockReturnValue(PROCESS_STATE_STOPPED), + kernel_has_sa_nocldstop: vi.fn(() => 0), + }, + 4, + kernelMemory, + ); worker.stoppedPids = new Set(); worker.pendingPipeReaders = new Map(); worker.pendingPipeWriters = new Map(); - worker.resumeStoppedProcess = vi.fn(() => false); - worker.notifyParentOfChildStateTransition = vi.fn(); - worker.anyPendingRetryNeedsSignalSafeWake = vi.fn(() => false); - worker.scheduleWakeBlockedRetries = vi.fn(); + const sendSignalToProcess = vi.fn(); + configureBoundaryHooks(worker, { sendSignalToProcess }); worker.drainAndProcessWakeupEvents(); expect(drain).toHaveBeenCalledTimes(2); expect(worker.stoppedPids.has(43)).toBe(true); - expect(worker.notifyParentOfChildStateTransition).toHaveBeenCalledOnce(); - expect(worker.notifyParentOfChildStateTransition).toHaveBeenCalledWith(43); + expect(sendSignalToProcess).toHaveBeenCalledOnce(); + expect(sendSignalToProcess).toHaveBeenCalledWith(7, SIGCHLD, true); }); it("drains overflow wake batches until a short batch includes lifecycle events", () => { @@ -604,27 +999,39 @@ describe("Rust-owned process wait lifecycle", () => { writeWakeEvent(kernelMemory, outPtr, 0, 42, WAKE_PROCESS_STOPPED); return 1; }); - const worker = createWorkerHarness({ kernel_drain_wakeup_events: drain }); - worker.kernelMemory = kernelMemory; - installKernelWorkerTestScratch(worker, kernelMemory); + const worker = createWorkerHarness( + { + kernel_drain_wakeup_events: drain, + kernel_get_parent_pid: vi.fn(() => 7), + kernel_has_sa_nocldstop: vi.fn(() => 0), + }, + 4, + kernelMemory, + ); worker.stoppedPids = new Set(); worker.pendingPipeReaders = new Map(); worker.pendingPipeWriters = new Map(); - worker.notifyParentOfChildStateTransition = vi.fn(); - worker.anyPendingRetryNeedsSignalSafeWake = vi.fn(() => false); - worker.scheduleWakeBlockedRetries = vi.fn(); + const sendSignalToProcess = vi.fn(); + configureBoundaryHooks(worker, { + scheduleWakeBlockedRetries: vi.fn(), + sendSignalToProcess, + }); worker.drainAndProcessWakeupEvents(); expect(drain).toHaveBeenCalledTimes(2); expect(worker.stoppedPids.has(42)).toBe(true); + expect(sendSignalToProcess).toHaveBeenCalledOnce(); }); it("finalizes signal death before a stale continue event can notify or reap", () => { const kernelMemory = createSharedMemory(); const processMemory = createSharedMemory(); const channel = createChannel(42, processMemory); + let drained = false; const drain = vi.fn((outPtr: number) => { + if (drained) return 0; + drained = true; writeWakeEvent(kernelMemory, outPtr, 0, 42, WAKE_PROCESS_CONTINUED); return 1; }); @@ -634,76 +1041,106 @@ describe("Rust-owned process wait lifecycle", () => { kernel_drain_wakeup_events: drain, kernel_get_process_state: vi.fn(() => 2), kernel_get_process_exit_signal: vi.fn(() => exitSignal), - }); - worker.kernelMemory = kernelMemory; - installKernelWorkerTestScratch(worker, kernelMemory); - worker.processes = new Map([ - [ - 42, - { - channels: [channel], - memory: processMemory, - }, - ], - ]); + }, 4, kernelMemory); + worker.processes = new Map([[42, { + channels: [channel], + memory: processMemory, + }]]); worker.hostReaped = new Set(); worker.stoppedPids = new Set([42]); worker.parkedChannelCompletions = new Map(); worker.deferredStoppedChannels = new Map(); worker.deferredProcessWorkerStarts = new Map(); worker.pendingSleeps = new Map(); - worker.releaseAllSharedMemoryForProcess = vi.fn(); - worker.notifyParentOfExitedProcess = vi.fn(() => { - exitSignal = -3; - }); - worker.resumeStoppedProcess = vi.fn(); - worker.notifyParentOfChildStateTransition = vi.fn(); worker.callbacks = { onExit }; worker.drainAndProcessWakeupEvents(); - expect(worker.notifyParentOfExitedProcess).toHaveBeenCalledWith(42); + expect(worker.hostReaped.has(42)).toBe(true); expect(onExit).toHaveBeenCalledWith(42, 128 + SIGTERM); - expect(worker.resumeStoppedProcess).not.toHaveBeenCalled(); - expect(worker.notifyParentOfChildStateTransition).not.toHaveBeenCalled(); + expect(worker.pendingResumePids.has(42)).toBe(false); }); it("wakes a matching parent waiter while SA_NOCLDSTOP suppresses only SIGCHLD", () => { + const waitChildPoll = vi.fn(() => 0); + const kernelMemory = createSharedMemory(); + let drained = false; + const drain = vi.fn((outPtr: number) => { + if (drained) return 0; + drained = true; + writeWakeEvent( + kernelMemory, + outPtr, + 0, + 42, + WAKE_PROCESS_STOPPED, + ); + return 1; + }); const worker = createWorkerHarness({ + kernel_drain_wakeup_events: drain, kernel_get_parent_pid: vi.fn(() => 7), kernel_has_sa_nocldstop: vi.fn(() => 1), - }); - worker.sendSignalToProcess = vi.fn(); - worker.wakeWaitingParent = vi.fn(); + kernel_wait_child_poll: waitChildPoll, + }, 4, kernelMemory); + const parentChannel = registerMainChannel( + worker, + createChannel(7, createSharedMemory()), + ); + worker.waitingForChild = [{ + cancellationPoint: false, + cancellationWakeAllowed: false, + parentPid: 7, + channel: parentChannel, + origArgs: [-1, 0, 0, 0], + pid: -1, + options: 0, + syscallNr: ABI_SYSCALLS.Wait4, + }]; + const sendSignalToProcess = vi.fn(); + configureBoundaryHooks(worker, { sendSignalToProcess }); - worker.notifyParentOfChildStateTransition(42); + worker.drainAndProcessWakeupEvents(); - expect(worker.sendSignalToProcess).not.toHaveBeenCalled(); - expect(worker.wakeWaitingParent).toHaveBeenCalledWith(7); + expect(sendSignalToProcess).not.toHaveBeenCalled(); + expect(waitChildPoll).toHaveBeenCalledWith( + 7, + 7, + -1, + WAIT_EVENT_EXITED, + 0, + 128, + STRUCT_SIZE_KERNEL_WAIT_RESULT, + ); + expect(worker.waitingForChild).toHaveLength(1); }); it("uses WNOWAIT for process-group waiter rechecks", () => { const waitChildPoll = vi.fn(() => 0); + const kernelMemory = createSharedMemory(); const processMemory = createSharedMemory(); const channel = createChannel(7, processMemory); const worker = createWorkerHarness({ + kernel_handle_channel: successfulKernelHandle(kernelMemory), kernel_wait_child_poll: waitChildPoll, - }); - worker.processes = new Map([ - [7, { channels: [channel], memory: processMemory }], - ]); - worker.waitingForChild = [ - { - parentPid: 7, - channel, - origArgs: [0, 0, 0, 0], - pid: 0, - options: 0, - syscallNr: ABI_SYSCALLS.Wait4, - }, - ]; - - worker.recheckDeferredWaitpids(); + }, 4, kernelMemory); + worker.processes = new Map([[7, { channels: [channel], memory: processMemory }]]); + worker.waitingForChild = [{ + parentPid: 7, + channel, + origArgs: [0, 0, 0, 0], + pid: 0, + options: 0, + syscallNr: ABI_SYSCALLS.Wait4, + }]; + observeMarshalledCompletions(worker); + + dispatchLifecycleSyscall( + worker, + channel, + ABI_SYSCALLS.Setpgid, + [0, 0], + ); expect(waitChildPoll).toHaveBeenCalledWith( 7, @@ -717,42 +1154,64 @@ describe("Rust-owned process wait lifecycle", () => { }); it("services status that becomes eligible after a process-group change", () => { + const kernelMemory = createSharedMemory(); const channel = createChannel(7, createSharedMemory()); - const worker = createWorkerHarness({}); - worker.processes = new Map([ - [ - 7, - { - channels: [channel], - memory: channel.memory, - }, - ], - ]); - worker.waitingForChild = [ - { - parentPid: 7, - channel, - origArgs: [0, 0, 0, 0], - pid: 0, - options: 0, - syscallNr: ABI_SYSCALLS.Wait4, - }, - ]; - worker.pollWaitableChild = vi.fn(() => ({ - kind: "event", - childPid: 42, - waitStatus: 0, - siCode: WAIT_CLD_EXITED, - siStatus: 0, - childUid: 0, - rusage: new Uint8Array(STRUCT_SIZE_WASM_RUSAGE_WIRE), - })); - worker.wakeWaitingParent = vi.fn(); - - worker.recheckDeferredWaitpids(); - - expect(worker.wakeWaitingParent).toHaveBeenCalledWith(7); - expect(worker.waitingForChild).toHaveLength(1); + let pollCount = 0; + const waitChildPoll = vi.fn(( + _parentPid: number, + _callerTid: number, + _targetPid: number, + _eventMask: number, + _flags: number, + resultPtr: number, + ) => { + pollCount++; + writeKernelWaitResult(kernelMemory, resultPtr, { + waitStatus: 0, + siCode: WAIT_CLD_EXITED, + siStatus: 0, + childUid: 0, + rusage: new Uint8Array(STRUCT_SIZE_WASM_RUSAGE_WIRE), + }); + return 42; + }); + const worker = createWorkerHarness({ + kernel_handle_channel: successfulKernelHandle(kernelMemory), + kernel_wait_child_poll: waitChildPoll, + }, 4, kernelMemory); + worker.processes = new Map([[7, { + channels: [channel], + memory: channel.memory, + }]]); + worker.waitingForChild = [{ + parentPid: 7, + channel, + origArgs: [0, 0, 0, 0], + pid: 0, + options: 0, + syscallNr: ABI_SYSCALLS.Wait4, + }]; + const completeChannel = observeMarshalledCompletions(worker); + + dispatchLifecycleSyscall( + worker, + channel, + ABI_SYSCALLS.Setpgid, + [0, 0], + ); + + expect(pollCount).toBe(2); + expect(waitChildPoll.mock.calls.map((call: unknown[]) => call[4])) + .toEqual([WAIT_WNOWAIT, 0]); + expect(worker.waitingForChild).toEqual([]); + expect(completeChannel).toHaveBeenCalledWith( + channel, + ABI_SYSCALLS.Wait4, + [0, 0, 0, 0], + undefined, + 42, + 0, + ); }); it("completes a consuming waiter and a following ECHILD waiter in one wake", () => { @@ -760,43 +1219,48 @@ describe("Rust-owned process wait lifecycle", () => { const processMemory = createSharedMemory(); const first = createChannel(7, processMemory, 0); const second = createChannel(7, processMemory, 256); + let drained = false; + const drain = vi.fn((outPtr: number) => { + if (drained) return 0; + drained = true; + writeWakeEvent(kernelMemory, outPtr, 0, 42, WAKE_PROCESS_STOPPED); + return 1; + }); let pollCount = 0; - const waitChildPoll = vi.fn( - ( - _parentPid: number, - _callerTid: number, - _targetPid: number, - _eventMask: number, - _flags: number, - resultPtr: number, - ) => { - if (pollCount++ > 0) return -10; // ECHILD after the first wait reaps. - writeKernelWaitResult(kernelMemory, resultPtr, { - waitStatus: 3 << 8, - siCode: WAIT_CLD_EXITED, - siStatus: 3, - childUid: 12, - rusage: new Uint8Array(STRUCT_SIZE_WASM_RUSAGE_WIRE), - }); - return 42; - }, - ); - const worker = createWorkerHarness({ - kernel_wait_child_poll: waitChildPoll, + const waitChildPoll = vi.fn(( + _parentPid: number, + _callerTid: number, + _targetPid: number, + _eventMask: number, + _flags: number, + resultPtr: number, + ) => { + if (pollCount++ > 0) return -10; // ECHILD after the first wait reaps. + writeKernelWaitResult(kernelMemory, resultPtr, { + waitStatus: 3 << 8, + siCode: WAIT_CLD_EXITED, + siStatus: 3, + childUid: 12, + rusage: new Uint8Array(STRUCT_SIZE_WASM_RUSAGE_WIRE), + }); + return 42; }); - worker.kernelMemory = kernelMemory; - installKernelWorkerTestScratch(worker, kernelMemory); - worker.processes = new Map([ - [ - 7, - { - channels: [first, second], - memory: processMemory, - }, - ], - ]); + const worker = createWorkerHarness( + { + kernel_drain_wakeup_events: drain, + kernel_get_parent_pid: vi.fn(() => 7), + kernel_has_sa_nocldstop: vi.fn(() => 1), + kernel_wait_child_poll: waitChildPoll, + }, + 4, + kernelMemory, + ); + worker.processes = new Map([[7, { + channels: [first, second], + memory: processMemory, + }]]); worker.channelTids = new Map([["7:256", 8]]); - worker.completeWaitpid = vi.fn(); + const completeChannel = observeMarshalledCompletions(worker); worker.waitingForChild = [ { parentPid: 7, @@ -816,18 +1280,12 @@ describe("Rust-owned process wait lifecycle", () => { }, ]; - worker.wakeWaitingParent(7); + worker.drainAndProcessWakeupEvents(); expect(worker.waitingForChild).toEqual([]); - expect( - worker.completeWaitpid.mock.calls.map((call: unknown[]) => call.slice(2)), - ).toEqual([ - [42, 0], - [-1, 10], - ]); - expect(new DataView(processMemory.buffer).getInt32(1024, true)).toBe( - 3 << 8, - ); + expect(completeChannel.mock.calls.map((call: unknown[]) => call.slice(4))) + .toEqual([[42, 0], [-1, 10]]); + expect(new DataView(processMemory.buffer).getInt32(1024, true)).toBe(3 << 8); }); it("completes every matching WNOWAIT waiter while leaving a running waiter blocked", () => { @@ -836,45 +1294,50 @@ describe("Rust-owned process wait lifecycle", () => { const first = createChannel(7, processMemory, 0); const second = createChannel(7, processMemory, 256); const running = createChannel(7, processMemory, 512); - const waitChildPoll = vi.fn( - ( - _parentPid: number, - _callerTid: number, - targetPid: number, - _eventMask: number, - _flags: number, - resultPtr: number, - ) => { - if (targetPid === 43) return 0; - writeKernelWaitResult(kernelMemory, resultPtr, { - waitStatus: 0, - siCode: WAIT_CLD_EXITED, - siStatus: 0, - childUid: 99, - rusage: new Uint8Array(STRUCT_SIZE_WASM_RUSAGE_WIRE), - }); - return 42; + let drained = false; + const drain = vi.fn((outPtr: number) => { + if (drained) return 0; + drained = true; + writeWakeEvent(kernelMemory, outPtr, 0, 42, WAKE_PROCESS_STOPPED); + return 1; + }); + const waitChildPoll = vi.fn(( + _parentPid: number, + _callerTid: number, + targetPid: number, + _eventMask: number, + _flags: number, + resultPtr: number, + ) => { + if (targetPid === 43) return 0; + writeKernelWaitResult(kernelMemory, resultPtr, { + waitStatus: 0, + siCode: WAIT_CLD_EXITED, + siStatus: 0, + childUid: 99, + rusage: new Uint8Array(STRUCT_SIZE_WASM_RUSAGE_WIRE), + }); + return 42; + }); + const worker = createWorkerHarness( + { + kernel_drain_wakeup_events: drain, + kernel_get_parent_pid: vi.fn(() => 7), + kernel_has_sa_nocldstop: vi.fn(() => 1), + kernel_wait_child_poll: waitChildPoll, }, + 4, + kernelMemory, ); - const worker = createWorkerHarness({ - kernel_wait_child_poll: waitChildPoll, - }); - worker.kernelMemory = kernelMemory; - installKernelWorkerTestScratch(worker, kernelMemory); - worker.processes = new Map([ - [ - 7, - { - channels: [first, second, running], - memory: processMemory, - }, - ], - ]); + worker.processes = new Map([[7, { + channels: [first, second, running], + memory: processMemory, + }]]); worker.channelTids = new Map([ ["7:256", 8], ["7:512", 9], ]); - worker.completeWaitid = vi.fn(); + const completeChannel = observeMarshalledCompletions(worker); const options = WAIT_WEXITED | WAIT_WNOWAIT; const makeWaiter = (channel: any, pid: number, siginfoPtr: number) => ({ parentPid: 7, @@ -891,44 +1354,39 @@ describe("Rust-owned process wait lifecycle", () => { makeWaiter(second, 42, 1280), ]; - worker.wakeWaitingParent(7); + worker.drainAndProcessWakeupEvents(); expect(worker.waitingForChild).toEqual([runningWaiter]); - expect(worker.completeWaitid).toHaveBeenCalledTimes(2); - expect( - waitChildPoll.mock.calls.filter((call: unknown[]) => call[2] === 42), - ).toEqual([ - [ - 7, - 7, - 42, - WAIT_EVENT_EXITED, - WAIT_WNOWAIT, - 128, - STRUCT_SIZE_KERNEL_WAIT_RESULT, - ], - [ - 7, - 8, - 42, - WAIT_EVENT_EXITED, - WAIT_WNOWAIT, - 128, - STRUCT_SIZE_KERNEL_WAIT_RESULT, - ], - ]); - expect( - new DataView(processMemory.buffer).getInt32( - 1024 + PROCESS_SIGINFO_WASM32_PID_OFFSET, - true, - ), - ).toBe(42); - expect( - new DataView(processMemory.buffer).getInt32( - 1280 + PROCESS_SIGINFO_WASM32_PID_OFFSET, - true, - ), - ).toBe(42); + expect(completeChannel).toHaveBeenCalledTimes(2); + expect(waitChildPoll.mock.calls.filter((call: unknown[]) => call[2] === 42)) + .toEqual([ + [ + 7, + 7, + 42, + WAIT_EVENT_EXITED, + WAIT_WNOWAIT, + 128, + STRUCT_SIZE_KERNEL_WAIT_RESULT, + ], + [ + 7, + 8, + 42, + WAIT_EVENT_EXITED, + WAIT_WNOWAIT, + 128, + STRUCT_SIZE_KERNEL_WAIT_RESULT, + ], + ]); + expect(new DataView(processMemory.buffer).getInt32( + 1024 + PROCESS_SIGINFO_WASM32_PID_OFFSET, + true, + )).toBe(42); + expect(new DataView(processMemory.buffer).getInt32( + 1280 + PROCESS_SIGINFO_WASM32_PID_OFFSET, + true, + )).toBe(42); }); it("interrupts the exact host-deferred wait thread with its caught signal", () => { @@ -952,36 +1410,26 @@ describe("Rust-owned process wait lifecycle", () => { const worker = createWorkerHarness({ kernel_pick_signal_target_tid: vi.fn(() => 7), kernel_dequeue_signal: dequeue, - }); - worker.kernelMemory = kernelMemory; - installKernelWorkerTestScratch(worker, kernelMemory); - worker.processes = new Map([ - [ - 7, - { - channels: [channel], - memory: processMemory, - }, - ], - ]); - worker.waitingForChild = [ - { - parentPid: 7, - channel, - origArgs: [-1, 0, 0, 0], - pid: -1, - options: 0, - syscallNr: ABI_SYSCALLS.Wait4, - }, - ]; - worker.wakeWaitingParent = vi.fn(); - worker.finishSignalTermination = vi.fn(() => false); - worker.completeChannel = vi.fn(); + kernel_wait_child_poll: vi.fn(() => 0), + }, 4, kernelMemory); + worker.processes = new Map([[7, { + channels: [channel], + memory: processMemory, + }]]); + worker.waitingForChild = [{ + parentPid: 7, + channel, + origArgs: [-1, 0, 0, 0], + pid: -1, + options: 0, + syscallNr: ABI_SYSCALLS.Wait4, + }]; + const completeChannel = observeMarshalledCompletions(worker); - expect(worker.interruptWaitingChildForSignal(7, SIGUSR1)).toBe(true); + worker.testAuthority.sendSignalForTest(7, SIGUSR1, false); expect(worker.waitingForChild).toEqual([]); - expect(worker.completeChannel).toHaveBeenCalledWith( + expect(completeChannel).toHaveBeenCalledWith( channel, ABI_SYSCALLS.Wait4, [-1, 0, 0, 0], @@ -995,19 +1443,17 @@ describe("Rust-owned process wait lifecycle", () => { }); it("removes and wakes an exact wait cancellation point", () => { + const kernelMemory = createSharedMemory(); const memory = createSharedMemory(); const caller = createChannel(7, memory, 0); const target = createChannel(7, memory, 256); - const worker = createWorkerHarness({}); - worker.processes = new Map([ - [ - 7, - { - channels: [caller, target], - memory, - }, - ], - ]); + const worker = createWorkerHarness({ + kernel_handle_channel: successfulKernelHandle(kernelMemory), + }, 4, kernelMemory); + worker.processes = new Map([[7, { + channels: [caller, target], + memory, + }]]); worker.channelTids = new Map([["7:256", 99]]); worker.pendingCancels = new Set(); worker.pendingFutexWaits = new Map(); @@ -1015,34 +1461,202 @@ describe("Rust-owned process wait lifecycle", () => { worker.pendingSelectRetries = new Map(); worker.pendingPipeReaders = new Map(); worker.pendingPipeWriters = new Map(); - worker.waitingForChild = [ - { - parentPid: 7, + worker.waitingForChild = [{ + cancellationPoint: true, + cancellationWakeAllowed: true, + parentPid: 7, + channel: target, + origArgs: [-1, 0, 0, 0], + pid: -1, + options: 0, + syscallNr: ABI_SYSCALLS.Wait4, + }]; + const relistenChannel = observeRelisten(worker); + markPending(caller); + markPending(target); + + dispatchLifecycleSyscall( + worker, + caller, + ABI_SYSCALLS.ThreadCancel, + [99], + ); + + expect(worker.waitingForChild).toEqual([]); + // Completing the exact blocked target consumes the host-side token; the + // guest pthread cancellation bit remains the authoritative notification. + expect(worker.pendingCancels.has(target)).toBe(false); + expect(readCompletion(caller)).toEqual({ + retVal: 0, + errVal: 0, + status: CHANNEL_STATUS_COMPLETE, + }); + expect(readCompletion(target)).toEqual({ + retVal: -4, + errVal: 4, + status: CHANNEL_STATUS_COMPLETE, + }); + expect(relistenChannel).toHaveBeenCalledWith(target); + }); + + it("does not wake a plain wait4 parked beside cancellation-point waiters", () => { + const kernelMemory = createSharedMemory(); + const memory = createSharedMemory(); + const caller = createChannel(7, memory, 0); + const target = createChannel(7, memory, 256); + const waitChildPoll = vi.fn(() => 0); + const worker = createWorkerHarness({ + kernel_handle_channel: successfulKernelHandle(kernelMemory), + kernel_wait_child_poll: waitChildPoll, + }, 4, kernelMemory); + worker.processes = new Map([[7, { + channels: [caller, target], + memory, + }]]); + worker.channelTids = new Map([["7:256", 99]]); + worker.pendingCancels = new Set(); + worker.waitingForChild = []; + const relistenChannel = observeRelisten(worker); + markPending(target); + + dispatchLifecycleSyscall( + worker, + target, + ABI_SYSCALLS.Wait4, + [-1, 0, 0, 0], + false, + ); + expect(waitChildPoll).toHaveBeenCalledOnce(); + expect(worker.waitingForChild).toEqual([ + expect.objectContaining({ channel: target, - origArgs: [-1, 0, 0, 0], - pid: -1, - options: 0, syscallNr: ABI_SYSCALLS.Wait4, - }, - ]; - worker.runSyntheticMemorySyscall = vi.fn(() => ({ retVal: 0, errVal: 0 })); - worker.completeChannelRaw = vi.fn(); - worker.relistenChannel = vi.fn(); - - worker.handleThreadCancel(caller, [99]); + cancellationPoint: false, + }), + ]); - expect(worker.runSyntheticMemorySyscall).toHaveBeenCalledWith( + markPending(caller); + dispatchLifecycleSyscall( + worker, caller, ABI_SYSCALLS.ThreadCancel, [99], ); - expect(worker.waitingForChild).toEqual([]); + + expect(worker.waitingForChild).toEqual([ + expect.objectContaining({ + channel: target, + syscallNr: ABI_SYSCALLS.Wait4, + cancellationPoint: false, + }), + ]); expect(worker.pendingCancels.has(target)).toBe(true); - expect(worker.completeChannelRaw).toHaveBeenNthCalledWith(1, caller, 0, 0); - expect(worker.completeChannelRaw).toHaveBeenNthCalledWith(2, target, -4, 4); - expect(worker.relistenChannel).toHaveBeenCalledWith(target); + expect(readStatus(target)).toBe(CHANNEL_STATUS_PENDING); + expect(readCompletion(caller)).toEqual({ + retVal: 0, + errVal: 0, + status: CHANNEL_STATUS_COMPLETE, + }); + expect(relistenChannel).not.toHaveBeenCalledWith(target); }); + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s leaves a cancellation-disabled wait blocked and preserves its pending cancel", + (_name, pointerWidth) => { + const kernelMemory = createSharedMemory(); + const memory = createSharedMemory(); + const caller = createChannel(7, memory, 0); + const target = createChannel(7, memory, 256); + let childReady = false; + let drained = false; + const waitChildPoll = vi.fn(( + _parentPid: number, + _callerTid: number, + _targetPid: number, + _eventMask: number, + _flags: number, + resultPtr: number | bigint, + ) => { + if (!childReady) return 0; + writeKernelWaitResult(kernelMemory, Number(resultPtr), { + waitStatus: 12 << 8, + siCode: WAIT_CLD_EXITED, + siStatus: 12, + childUid: 0, + rusage: new Uint8Array(STRUCT_SIZE_WASM_RUSAGE_WIRE), + }); + return 42; + }); + const worker = createWorkerHarness({ + kernel_drain_wakeup_events: vi.fn((outPtr: number | bigint) => { + if (!childReady || drained) return 0; + drained = true; + writeWakeEvent( + kernelMemory, + Number(outPtr), + 0, + 42, + WAKE_PROCESS_STOPPED, + ); + return 1; + }), + kernel_get_parent_pid: vi.fn(() => 7), + kernel_handle_channel: successfulKernelHandle(kernelMemory), + kernel_has_sa_nocldstop: vi.fn(() => 1), + kernel_wait_child_poll: waitChildPoll, + }, pointerWidth, kernelMemory); + worker.processes = new Map([[7, { + channels: [caller, target], + memory, + }]]); + worker.channelTids = new Map([["7:256", 99]]); + worker.pendingCancels = new Set(); + worker.waitingForChild = []; + const relistenChannel = observeRelisten(worker); + markPending(target); + + // A cancellation point without cancellation-wake authority represents + // PTHREAD_CANCEL_DISABLE. The request stays parked even after the host + // records pthread_cancel for its exact thread. + dispatchLifecycleSyscall( + worker, + target, + ABI_SYSCALLS.Wait4, + [-1, 0, 0, 0], + true, + false, + ); + expect(worker.waitingForChild).toHaveLength(1); + + markPending(caller); + dispatchLifecycleSyscall( + worker, + caller, + ABI_SYSCALLS.ThreadCancel, + [99], + ); + + expect(worker.waitingForChild).toHaveLength(1); + expect(worker.pendingCancels.has(target)).toBe(true); + expect(readStatus(target)).toBe(CHANNEL_STATUS_PENDING); + expect(relistenChannel).not.toHaveBeenCalledWith(target); + + childReady = true; + worker.drainAndProcessWakeupEvents(); + + expect(worker.waitingForChild).toEqual([]); + expect(readCompletion(target)).toEqual({ + retVal: 42, + errVal: 0, + status: CHANNEL_STATUS_COMPLETE, + }); + expect(worker.pendingCancels.has(target)).toBe(true); + }, + ); + it("retires an interrupted engine futex waiter before a later wake quota", async () => { const memory = createSharedMemory(); const first = createChannel(7, memory, 0); @@ -1051,38 +1665,55 @@ describe("Rust-owned process wait lifecycle", () => { const futexPtr = 4096; new Int32Array(memory.buffer)[futexPtr >>> 2] = 0; const worker = createWorkerHarness({}); - worker.processes = new Map([ - [ - 7, - { - channels: [first, second, waker], - memory, - }, - ], - ]); + worker.processes = new Map([[7, { + channels: [first, second, waker], + memory, + }]]); worker.pendingFutexWaits = new Map(); - worker.completeChannelRaw = vi.fn(); - worker.relistenChannel = vi.fn(); + observeRelisten(worker); + markPending(first); + markPending(second); - worker.handleFutex(first, [futexPtr, 0, 0, 0, 0, 0]); - worker.handleFutex(second, [futexPtr, 0, 0, 0, 0, 0]); + dispatchLifecycleSyscall( + worker, + first, + ABI_SYSCALLS.Futex, + [futexPtr, 0, 0, 0, 0, 0], + ); + dispatchLifecycleSyscall( + worker, + second, + ABI_SYSCALLS.Futex, + [futexPtr, 0, 0, 0, 0, 0], + ); expect(worker.pendingFutexWaits.size).toBe(2); worker.pendingFutexWaits.get(first).interrupt(-4, 4); await new Promise((resolve) => setTimeout(resolve, 10)); expect(worker.pendingFutexWaits.size).toBe(0); - expect(worker.completeChannelRaw).toHaveBeenCalledWith(first, -4, 4); - expect(worker.completeChannelRaw).toHaveBeenCalledWith(second, 0, 0); + expect(readCompletion(first)).toMatchObject({ retVal: -4, errVal: 4 }); + expect(readCompletion(second)).toMatchObject({ retVal: 0, errVal: 0 }); - worker.completeChannelRaw.mockClear(); - worker.handleFutex(second, [futexPtr, 0, 0, 0, 0, 0]); + markPending(second); + dispatchLifecycleSyscall( + worker, + second, + ABI_SYSCALLS.Futex, + [futexPtr, 0, 0, 0, 0, 0], + ); expect(worker.pendingFutexWaits.size).toBe(1); - worker.handleFutex(waker, [futexPtr, 1, 1, 0, 0, 0]); + markPending(waker); + dispatchLifecycleSyscall( + worker, + waker, + ABI_SYSCALLS.Futex, + [futexPtr, 1, 1, 0, 0, 0], + ); await new Promise((resolve) => setTimeout(resolve, 10)); - expect(worker.completeChannelRaw).toHaveBeenCalledWith(waker, 1, 0); - expect(worker.completeChannelRaw).toHaveBeenCalledWith(second, 0, 0); + expect(readCompletion(waker)).toMatchObject({ retVal: 1, errVal: 0 }); + expect(readCompletion(second)).toMatchObject({ retVal: 0, errVal: 0 }); expect(worker.pendingFutexWaits.size).toBe(0); }); @@ -1100,15 +1731,10 @@ describe("Rust-owned process wait lifecycle", () => { worker.pendingCancels = new Set([channel]); worker.waitingForChild = []; worker.pendingSleeps = new Map(); - worker.pendingFutexWaits = new Map([ - [ - channel, - { - futexIndex: 1024, - retire, - }, - ], - ]); + worker.pendingFutexWaits = new Map([[channel, { + futexIndex: 1024, + retire, + }]]); worker.pendingPollRetries = new Map(); worker.pendingSelectRetries = new Map(); worker.pendingPipeReaders = new Map(); @@ -1128,70 +1754,89 @@ describe("Rust-owned process wait lifecycle", () => { expect(worker.threadForkContexts.has("7:256")).toBe(false); }); - it("parks exact mailbox notifications while materializing completed output", () => { - const memory = createSharedMemory(); - const first = createChannel(42, memory, 0); - const second = createChannel(42, memory, 256); + it("parks exact mailbox notifications while materializing completed output", async () => { + const kernelMemory = createSharedMemory(); + const processMemory = createSharedMemory(); + const first = createChannel(42, processMemory, 0); + const second = createChannel(42, processMemory, 256); markPending(first); markPending(second); - const worker = createWorkerHarness({}); - worker.processes = new Map([[42, { channels: [first, second], memory }]]); + const handleChannel = vi.fn((channelPtr: number | bigint) => { + const view = new DataView(kernelMemory.buffer, Number(channelPtr)); + const syscallNr = view.getUint32(CH_SYSCALL, true); + const retVal = syscallNr === ABI_SYSCALLS.Read ? 3 : 8; + if (syscallNr === ABI_SYSCALLS.Read) { + new Uint8Array( + kernelMemory.buffer, + Number(channelPtr) + CH_DATA, + 3, + ).set([1, 2, 3]); + } + view.setBigInt64(CH_RETURN, BigInt(retVal), true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }); + const worker = createWorkerHarness({ + kernel_handle_channel: handleChannel, + }, 4, kernelMemory); + worker.processes = new Map([[42, { + channels: [first, second], + memory: processMemory, + }]]); + worker.channelTids = new Map([["42:256", 43]]); worker.stoppedPids = new Set([42]); worker.parkedChannelCompletions = new Map(); worker.deferredStoppedChannels = new Map(); - worker.synchronizeSharedMemoryForBoundary = vi.fn(); - worker.relistenChannel = vi.fn(); - - worker.publishOrParkChannelCompletion(first, { - kind: "marshalled", - outputWrites: [{ ptr: 2048, bytes: Uint8Array.of(1, 2, 3) }], - retVal: 7, - errVal: 0, - relistenRequested: true, - }); - worker.publishOrParkChannelCompletion(second, { - kind: "raw", - outputWrites: [], - retVal: 8, - errVal: 0, - relistenRequested: false, + const synchronizeSharedMemoryForBoundary = vi.fn(); + const relistenChannel = vi.fn(); + configureBoundaryHooks(worker, { + synchronizeSharedMemoryForBoundary, + relistenChannel, }); + dispatchLifecycleSyscall( + worker, + first, + ABI_SYSCALLS.Read, + [3, 2048, 3], + ); + dispatchLifecycleSyscall( + worker, + second, + ABI_SYSCALLS.SchedYield, + [], + ); + expect(worker.parkedChannelCompletions.size).toBe(2); expect(readStatus(first)).toBe(CHANNEL_STATUS_PENDING); expect(readStatus(second)).toBe(CHANNEL_STATUS_PENDING); // A peer mapping the same SharedArrayBuffer observes completed syscall // output even though this stopped process remains parked at CH_PENDING. - expect(new Uint8Array(memory.buffer, 2048, 3)).toEqual( - Uint8Array.of(1, 2, 3), - ); - expect(worker.synchronizeSharedMemoryForBoundary).toHaveBeenCalledTimes(2); + expect(new Uint8Array(processMemory.buffer, 2048, 3)) + .toEqual(Uint8Array.of(1, 2, 3)); + expect(synchronizeSharedMemoryForBoundary).toHaveBeenCalledTimes(4); - worker.resumeStoppedProcess(42); + expect(worker.testAuthority.resumeStoppedProcessForTest(42)).toBe(true); + await drainLifecycleGate(); expect(worker.parkedChannelCompletions.size).toBe(0); expect(readStatus(first)).toBe(CHANNEL_STATUS_COMPLETE); expect(readStatus(second)).toBe(CHANNEL_STATUS_COMPLETE); - expect( - new DataView(memory.buffer, first.channelOffset).getBigInt64( - CH_RETURN, - true, - ), - ).toBe(7n); - expect( - new DataView(memory.buffer, second.channelOffset).getBigInt64( - CH_RETURN, - true, - ), - ).toBe(8n); - expect(new Uint8Array(memory.buffer, 2048, 3)).toEqual( - Uint8Array.of(1, 2, 3), - ); - expect(worker.relistenChannel).toHaveBeenCalledOnce(); - expect(worker.relistenChannel).toHaveBeenCalledWith(first); + expect(new DataView( + processMemory.buffer, + first.channelOffset, + ).getBigInt64(CH_RETURN, true)).toBe(3n); + expect(new DataView( + processMemory.buffer, + second.channelOffset, + ).getBigInt64(CH_RETURN, true)).toBe(8n); + expect(new Uint8Array(processMemory.buffer, 2048, 3)) + .toEqual(Uint8Array.of(1, 2, 3)); + expect(relistenChannel).toHaveBeenCalledWith(first); + expect(relistenChannel).toHaveBeenCalledWith(second); }); - it("delivers a caught SIGCONT before publishing the parked stop boundary", () => { + it("delivers a caught SIGCONT before publishing the parked stop boundary", async () => { const kernelMemory = new WebAssembly.Memory({ initial: 2, maximum: 2, @@ -1210,50 +1855,38 @@ describe("Rust-owned process wait lifecycle", () => { const worker = createWorkerHarness({ kernel_dequeue_signal: dequeue, kernel_get_process_exit_signal: vi.fn(() => -1), - }); - worker.kernelMemory = kernelMemory; - installKernelWorkerTestScratch(worker, kernelMemory); - worker.processes = new Map([ - [ - 42, - { - channels: [channel], - memory: processMemory, - }, - ], - ]); + }, 4, kernelMemory); + worker.processes = new Map([[42, { + channels: [channel], + memory: processMemory, + }]]); worker.channelTids = new Map(); worker.hostReaped = new Set(); - worker.stoppedPids = new Set([42]); - worker.parkedChannelCompletions = new Map([ - [ - channel, - { - prepared: { - kind: "raw", - outputWrites: [], - retVal: 0, - errVal: 0, - relistenRequested: false, - }, - relistenRequested: false, - }, - ], - ]); + worker.stoppedPids = new Set(); + worker.parkedChannelCompletions = new Map(); worker.deferredStoppedChannels = new Map(); worker.deferredProcessWorkerStarts = new Map(); - worker.publishPreparedChannelCompletion = vi.fn(); + markPending(channel); + worker.testAuthority.installParkedCloneCompletionForTest({ + channel, + tid: 101, + parentTidPointer: 2048, + }); - worker.resumeStoppedProcess(42); + expect(worker.testAuthority.resumeStoppedProcessForTest(42)).toBe(true); + await drainLifecycleGate(); expect(dequeue).toHaveBeenCalledOnce(); - expect( - new DataView(processMemory.buffer).getUint32(CH_SIG_SIGNUM, true), - ).toBe(SIGCONT); - expect(worker.publishPreparedChannelCompletion).toHaveBeenCalledOnce(); + expect(new DataView(processMemory.buffer).getUint32(CH_SIG_SIGNUM, true)) + .toBe(SIGCONT); + expect(readCompletion(channel)).toEqual({ + retVal: 101, + errVal: 0, + status: CHANNEL_STATUS_COMPLETE, + }); }); - it("preflights every pthread before starting or publishing after SIGCONT", () => { + it("preflights every pthread before starting or publishing after SIGCONT", async () => { const kernelMemory = createSharedMemory(); const processMemory = createSharedMemory(); const first = createChannel(42, processMemory, 0); @@ -1262,7 +1895,6 @@ describe("Rust-owned process wait lifecycle", () => { markPending(second); let state = PROCESS_STATE_STOPPED; - let currentTid = 0; let secondScans = 0; const dequeue = vi.fn((_pid: number, tid: number, outPtr: number) => { if (tid === 101) { @@ -1275,33 +1907,20 @@ describe("Rust-owned process wait lifecycle", () => { }); const worker = createWorkerHarness({ kernel_get_process_state: vi.fn(() => state), - kernel_set_current_tid: vi.fn((_pid: number, tid: number) => { - currentTid = tid; - return 0; - }), + kernel_set_current_tid: vi.fn(() => 0), kernel_dequeue_signal: dequeue, kernel_get_process_exit_signal: vi.fn(() => -1), - }); - worker.kernelMemory = kernelMemory; - installKernelWorkerTestScratch(worker, kernelMemory); - worker.processes = new Map([ - [ - 42, - { - channels: [first, second], - memory: processMemory, - }, - ], - ]); + }, 4, kernelMemory); + worker.processes = new Map([[42, { + channels: [first, second], + memory: processMemory, + }]]); worker.channelTids = new Map([ ["42:0", 101], ["42:256", 102], ]); - worker.stoppedPids = new Set([42]); - worker.parkedChannelCompletions = new Map([ - [first, parkedRaw(1)], - [second, parkedRaw(2)], - ]); + worker.stoppedPids = new Set(); + worker.parkedChannelCompletions = new Map(); worker.deferredStoppedChannels = new Map(); worker.deferredProcessWorkerStarts = new Map(); worker.pendingSleeps = new Map(); @@ -1312,71 +1931,74 @@ describe("Rust-owned process wait lifecycle", () => { worker.pendingPipeWriters = new Map(); const start = vi.fn(); const cancel = vi.fn(); - const publish = vi.fn(); - worker.publishPreparedChannelCompletion = publish; + worker.testAuthority.installParkedCloneCompletionForTest({ + channel: first, + tid: 101, + parentTidPointer: 2048, + }); + worker.testAuthority.installParkedCloneCompletionForTest({ + channel: second, + tid: 102, + parentTidPointer: 2052, + }); - expect( - worker.startProcessWorkerWhenRunnable(42, processMemory, start, cancel), - ).toBe("deferred"); + expect(worker.startProcessWorkerWhenRunnable( + 42, + processMemory, + start, + cancel, + )).toBe("deferred"); state = PROCESS_STATE_RUNNING; - expect(worker.resumeStoppedProcess(42)).toBe(false); + expect(worker.testAuthority.resumeStoppedProcessForTest(42)).toBe(false); expect(start).not.toHaveBeenCalled(); - expect(publish).not.toHaveBeenCalled(); expect(worker.parkedChannelCompletions.size).toBe(2); - expect( - new DataView(processMemory.buffer).getUint32(CH_SIG_SIGNUM, true), - ).toBe(SIGCONT); + expect(readStatus(first)).toBe(CHANNEL_STATUS_PENDING); + expect(readStatus(second)).toBe(CHANNEL_STATUS_PENDING); + expect(new DataView(processMemory.buffer).getUint32(CH_SIG_SIGNUM, true)) + .toBe(SIGCONT); state = PROCESS_STATE_RUNNING; - expect(worker.resumeStoppedProcess(42)).toBe(true); + expect(worker.testAuthority.resumeStoppedProcessForTest(42)).toBe(true); + await drainLifecycleGate(); expect(start).toHaveBeenCalledOnce(); expect(cancel).not.toHaveBeenCalled(); - expect(publish).toHaveBeenCalledTimes(2); + expect(readStatus(first)).toBe(CHANNEL_STATUS_COMPLETE); + expect(readStatus(second)).toBe(CHANNEL_STATUS_COMPLETE); expect(dequeue).toHaveBeenCalledTimes(3); // The first channel's caught signal was not dequeued/cleared again on the // second resume attempt. - expect( - new DataView(processMemory.buffer).getUint32(CH_SIG_SIGNUM, true), - ).toBe(SIGCONT); + expect(new DataView(processMemory.buffer).getUint32(CH_SIG_SIGNUM, true)) + .toBe(SIGCONT); }); - it("interrupts a stopped exact wait thread with its retained caught signal", () => { + it("interrupts a stopped exact wait thread with its retained caught signal", async () => { const kernelMemory = createSharedMemory(); const processMemory = createSharedMemory(); - const channel = createChannel(7, processMemory); - markPending(channel); - let state = PROCESS_STATE_STOPPED; - const dequeue = vi.fn((_pid: number, _tid: number, outPtr: number) => { - new DataView(kernelMemory.buffer).setUint32(outPtr, SIGUSR1, true); - return SIGUSR1; - }); - const worker = createWorkerHarness({ - kernel_get_process_state: vi.fn(() => state), - kernel_dequeue_signal: dequeue, - kernel_get_process_exit_signal: vi.fn(() => -1), - }); - worker.kernelMemory = kernelMemory; - installKernelWorkerTestScratch(worker, kernelMemory); - worker.processes = new Map([ - [ - 7, - { - channels: [channel], - memory: processMemory, - }, - ], - ]); - worker.waitingForChild = [ - { - parentPid: 7, - channel, - origArgs: [-1, 0, 0, 0], - pid: -1, - options: 0, - syscallNr: ABI_SYSCALLS.Wait4, - }, - ]; + const channel = createChannel(7, processMemory); + markPending(channel); + let state = PROCESS_STATE_STOPPED; + const dequeue = vi.fn((_pid: number, _tid: number, outPtr: number) => { + new DataView(kernelMemory.buffer).setUint32(outPtr, SIGUSR1, true); + return SIGUSR1; + }); + const worker = createWorkerHarness({ + kernel_get_process_state: vi.fn(() => state), + kernel_dequeue_signal: dequeue, + kernel_get_process_exit_signal: vi.fn(() => -1), + }, 4, kernelMemory); + worker.processes = new Map([[7, { + channels: [channel], + memory: processMemory, + }]]); + worker.waitingForChild = [{ + parentPid: 7, + channel, + origArgs: [-1, 0, 0, 0], + pid: -1, + options: 0, + syscallNr: ABI_SYSCALLS.Wait4, + }]; worker.stoppedPids = new Set([7]); worker.parkedChannelCompletions = new Map(); worker.deferredStoppedChannels = new Map(); @@ -1388,158 +2010,153 @@ describe("Rust-owned process wait lifecycle", () => { worker.pendingPipeReaders = new Map(); worker.pendingPipeWriters = new Map(); worker.socketTimeoutTimers = new Map(); - worker.drainAllPtyOutputs = vi.fn(); - worker.flushTcpSendPipes = vi.fn(); - worker.drainAndProcessWakeupEvents = vi.fn(); - worker.synchronizeSharedMemoryForBoundary = vi.fn(); - worker.relistenChannel = vi.fn(); const sequence: string[] = []; + const synchronizeSharedMemoryForBoundary = vi.fn(() => { + sequence.push("sync"); + }); + configureBoundaryHooks(worker, { synchronizeSharedMemoryForBoundary }); const start = vi.fn(() => sequence.push("start")); const cancel = vi.fn(); - worker.publishPreparedChannelCompletion = vi.fn( - ( - _channel: unknown, - prepared: { - retVal: number; - errVal: number; - }, - ) => { - sequence.push("publish"); - expect(prepared.retVal).toBe(-1); - expect(prepared.errVal).toBe(4); - }, - ); - expect( - worker.startProcessWorkerWhenRunnable(7, processMemory, start, cancel), - ).toBe("deferred"); + expect(worker.startProcessWorkerWhenRunnable( + 7, + processMemory, + start, + cancel, + )).toBe("deferred"); state = PROCESS_STATE_RUNNING; - expect(worker.resumeStoppedProcess(7)).toBe(true); + expect(worker.testAuthority.resumeStoppedProcessForTest(7)).toBe(true); + await drainLifecycleGate(); expect(worker.waitingForChild).toEqual([]); expect(dequeue).toHaveBeenCalledOnce(); - expect(sequence).toEqual(["start", "publish"]); - expect( - new DataView(processMemory.buffer).getUint32(CH_SIG_SIGNUM, true), - ).toBe(SIGUSR1); + expect(sequence).toEqual(["sync", "start"]); + expect(readCompletion(channel)).toEqual({ + retVal: -1, + errVal: 4, + status: CHANNEL_STATUS_COMPLETE, + }); + expect(new DataView(processMemory.buffer).getUint32(CH_SIG_SIGNUM, true)) + .toBe(SIGUSR1); }); - it("materializes detached descriptor output before wake scratch is reused", () => { + it("materializes detached descriptor output before wake scratch is reused", async () => { const kernelMemory = createSharedMemory(); const processMemory = createSharedMemory(); const channel = createChannel(42, processMemory); const outputPtr = 2048; markPending(channel); - new Uint8Array(kernelMemory.buffer, 128 + CH_DATA, 4).set([9, 8, 7, 6]); // WHY: completeChannel consumes bytes detached while the scratch lease is // still active. It must never reconstruct output later from shared scratch, // which a lifecycle wake may synchronously reuse. - const detachedOutput = [ - { - ptr: outputPtr, - bytes: new Uint8Array(kernelMemory.buffer, 128 + CH_DATA, 4).slice(), - }, - ]; - const worker = createWorkerHarness({}); - worker.kernelMemory = kernelMemory; - installKernelWorkerTestScratch(worker, kernelMemory); - worker.processes = new Map([ - [42, { channels: [channel], memory: processMemory }], - ]); + const sequence: string[] = []; + const handleChannel = vi.fn((channelPtr: number | bigint) => { + const pointer = Number(channelPtr); + const view = new DataView(kernelMemory.buffer, pointer); + new Uint8Array(kernelMemory.buffer, pointer + CH_DATA, 4) + .set([9, 8, 7, 6]); + view.setBigInt64(CH_RETURN, 4n, true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }); + const drainWakeups = vi.fn((outPtr: number) => { + sequence.push("drain"); + expect(new Uint8Array(processMemory.buffer, outputPtr, 4)) + .toEqual(Uint8Array.of(9, 8, 7, 6)); + new Uint8Array(kernelMemory.buffer, outPtr, 4).fill(0xee); + return 0; + }); + const worker = createWorkerHarness({ + kernel_handle_channel: handleChannel, + kernel_drain_wakeup_events: drainWakeups, + }, 4, kernelMemory); + worker.processes = new Map([[42, { channels: [channel], memory: processMemory }]]); worker.stoppedPids = new Set([42]); worker.parkedChannelCompletions = new Map(); worker.deferredStoppedChannels = new Map(); - worker.clearSocketTimeout = vi.fn(); - worker.clearReadinessWait = vi.fn(); - worker.drainAllPtyOutputs = vi.fn(); - worker.flushTcpSendPipes = vi.fn(); - const sequence: string[] = []; - worker.synchronizeSharedMemoryForBoundary = vi.fn(() => { + const synchronizeSharedMemoryForBoundary = vi.fn(() => { sequence.push("sync"); }); - worker.relistenChannel = vi.fn(); - worker.drainAndProcessWakeupEvents = vi.fn(() => { - sequence.push("drain"); - expect(new Uint8Array(processMemory.buffer, outputPtr, 4)).toEqual( - Uint8Array.of(9, 8, 7, 6), - ); - new Uint8Array(kernelMemory.buffer, 128 + CH_DATA, 4).fill(0xee); + configureBoundaryHooks(worker, { + synchronizeSharedMemoryForBoundary, }); - worker.completeChannel( + dispatchLifecycleSyscall( + worker, channel, ABI_SYSCALLS.Read, [0, outputPtr, 4], - [ - { - argIndex: 1, - direction: "out", - size: { type: "arg", argIndex: 2 }, - }, - ], - 4, - 0, - detachedOutput, ); expect(readStatus(channel)).toBe(CHANNEL_STATUS_PENDING); - expect(new Uint8Array(processMemory.buffer, outputPtr, 4)).toEqual( - Uint8Array.of(9, 8, 7, 6), - ); - expect(worker.synchronizeSharedMemoryForBoundary).toHaveBeenCalledOnce(); - expect(sequence).toEqual(["sync", "drain"]); - worker.resumeStoppedProcess(42); - expect(new Uint8Array(processMemory.buffer, outputPtr, 4)).toEqual( - Uint8Array.of(9, 8, 7, 6), - ); + expect(new Uint8Array(processMemory.buffer, outputPtr, 4)) + .toEqual(Uint8Array.of(9, 8, 7, 6)); + expect(synchronizeSharedMemoryForBoundary).toHaveBeenCalledTimes(2); + expect(sequence).toEqual(["sync", "sync", "drain"]); + expect(worker.testAuthority.resumeStoppedProcessForTest(42)).toBe(true); + await drainLifecycleGate(); + expect(new Uint8Array(processMemory.buffer, outputPtr, 4)) + .toEqual(Uint8Array.of(9, 8, 7, 6)); }); it("synchronizes raw completion before lifecycle wake observers", () => { + const kernelMemory = createSharedMemory(); const memory = createSharedMemory(); const channel = createChannel(42, memory); markPending(channel); - const worker = createWorkerHarness({}); + const sequence: string[] = []; + const worker = createWorkerHarness({ + kernel_handle_channel: successfulKernelHandle(kernelMemory), + kernel_drain_wakeup_events: vi.fn(() => { + sequence.push("drain"); + return 0; + }), + }, 4, kernelMemory); worker.processes = new Map([[42, { channels: [channel], memory }]]); worker.stoppedPids = new Set([42]); worker.parkedChannelCompletions = new Map(); worker.deferredStoppedChannels = new Map(); worker.pendingCancels = new Set(); - worker.clearSocketTimeout = vi.fn(); - worker.clearReadinessWait = vi.fn(); - const sequence: string[] = []; - worker.synchronizeSharedMemoryForBoundary = vi.fn(() => { + const synchronizeSharedMemoryForBoundary = vi.fn(() => { sequence.push("sync"); }); - worker.drainAndProcessWakeupEvents = vi.fn(() => { - sequence.push("drain"); + configureBoundaryHooks(worker, { + synchronizeSharedMemoryForBoundary, }); - worker.completeChannelRaw(channel, 0, 0); + dispatchLifecycleSyscall( + worker, + channel, + ABI_SYSCALLS.ThreadCancel, + [99], + ); - expect(sequence).toEqual(["sync", "drain"]); + expect(sequence).toEqual(["sync", "sync", "drain"]); expect(readStatus(channel)).toBe(CHANNEL_STATUS_PENDING); expect(worker.parkedChannelCompletions.has(channel)).toBe(true); }); - it("defers an exact retry while stopped and re-arms it on continuation", () => { + it("defers an exact stopped channel and re-arms it on continuation", async () => { const channel = createChannel(42, createSharedMemory()); const worker = createWorkerHarness({}); - worker.processes = new Map([ - [42, { channels: [channel], memory: channel.memory }], - ]); + worker.processes = new Map([[42, { channels: [channel], memory: channel.memory }]]); worker.stoppedPids = new Set([42]); worker.deferredStoppedChannels = new Map(); worker.parkedChannelCompletions = new Map(); - const retrySyscall = worker.retrySyscall.bind(worker); - worker.handleSyscall = vi.fn(); - worker.relistenChannel = vi.fn(); + const relistenChannel = observeRelisten(worker); + markPending(channel); + new DataView(channel.memory.buffer).setUint32( + CH_SYSCALL, + ABI_SYSCALLS.SchedYield, + true, + ); - retrySyscall(channel); + worker.handleSyscall(channel); - expect(worker.handleSyscall).not.toHaveBeenCalled(); expect(worker.deferredStoppedChannels.has(channel)).toBe(true); - worker.resumeStoppedProcess(42); - expect(worker.relistenChannel).toHaveBeenCalledWith(channel); + expect(worker.testAuthority.resumeStoppedProcessForTest(42)).toBe(true); + await drainLifecycleGate(); + expect(relistenChannel).toHaveBeenCalledWith(channel); }); it("discards every parked and deferred channel without publication on signal death", () => { @@ -1555,24 +2172,19 @@ describe("Rust-owned process wait lifecycle", () => { worker.processes = new Map([[42, { channels: [first, second], memory }]]); worker.stoppedPids = new Set([42]); worker.parkedChannelCompletions = new Map([ - [ - first, - { - prepared: { - kind: "raw", - outputWrites: [], - retVal: 1, - errVal: 0, - relistenRequested: false, - }, + [first, { + prepared: { + kind: "raw", + outputWrites: [], + retVal: 1, + errVal: 0, relistenRequested: false, }, - ], + relistenRequested: false, + }], ]); worker.deferredStoppedChannels = new Map([[second, true]]); worker.hostReaped = new Set(); - worker.releaseAllSharedMemoryForProcess = vi.fn(); - worker.notifyParentOfExitedProcess = vi.fn(); worker.callbacks = { onExit }; worker.handleProcessTerminated(first); @@ -1596,22 +2208,31 @@ describe("Rust-owned process wait lifecycle", () => { }); worker.processes = new Map([[42, { channels: [channel], memory }]]); worker.hostReaped = new Set([42]); - worker.completeChannelRaw = vi.fn(); - worker.relistenChannel = vi.fn(); + const relistenChannel = observeRelisten(worker); const processView = new DataView(memory.buffer, channel.channelOffset); + markPending(channel); processView.setUint32(CH_SYSCALL, ABI_SYSCALLS.ExitGroup, true); worker.handleSyscall(channel); - expect(worker.completeChannelRaw).toHaveBeenCalledWith(channel, 0, 0); - expect(worker.relistenChannel).toHaveBeenCalledOnce(); - expect(worker.relistenChannel).toHaveBeenCalledWith(channel); + expect(readCompletion(channel)).toEqual({ + retVal: 0, + errVal: 0, + status: CHANNEL_STATUS_COMPLETE, + }); + expect(relistenChannel).toHaveBeenCalledOnce(); + expect(relistenChannel).toHaveBeenCalledWith(channel); + markPending(channel); processView.setUint32(CH_SYSCALL, ABI_SYSCALLS.Exit, true); worker.handleSyscall(channel); - expect(worker.completeChannelRaw).toHaveBeenCalledTimes(2); - expect(worker.relistenChannel).toHaveBeenCalledOnce(); + expect(readCompletion(channel)).toEqual({ + retVal: 0, + errVal: 0, + status: CHANNEL_STATUS_COMPLETE, + }); + expect(relistenChannel).toHaveBeenCalledOnce(); expect(setCurrentTid).not.toHaveBeenCalled(); expect(handleChannel).not.toHaveBeenCalled(); }); @@ -1628,8 +2249,7 @@ describe("Rust-owned process wait lifecycle", () => { }); worker.processes = new Map([[42, { channels: [channel], memory }]]); worker.hostReaped = new Set([42]); - worker.completeChannelRaw = vi.fn(); - worker.relistenChannel = vi.fn(); + const relistenChannel = observeRelisten(worker); new DataView(memory.buffer, channel.channelOffset).setUint32( CH_SYSCALL, ABI_SYSCALLS.SchedYield, @@ -1640,8 +2260,7 @@ describe("Rust-owned process wait lifecycle", () => { expect(readStatus(channel)).toBe(CHANNEL_STATUS_PENDING); expect(channel.handling).toBe(true); - expect(worker.completeChannelRaw).not.toHaveBeenCalled(); - expect(worker.relistenChannel).not.toHaveBeenCalled(); + expect(relistenChannel).not.toHaveBeenCalled(); expect(setCurrentTid).not.toHaveBeenCalled(); expect(handleChannel).not.toHaveBeenCalled(); }); @@ -1652,28 +2271,34 @@ describe("Rust-owned process wait lifecycle", () => { const memory = createSharedMemory(); const channel = createChannel(pid, memory); const setCurrentTid = vi.fn(() => -3); + const markSignaled = vi.fn(() => 0); const onExit = vi.fn(); const worker = createWorkerHarness({ + kernel_mark_process_signaled: markSignaled, kernel_set_current_tid: setCurrentTid, }); worker.processes = new Map([[pid, { channels: [channel], memory }]]); + worker.channelTids = new Map([ + [`${pid}:${channel.channelOffset}`, tid], + ]); worker.hostReaped = new Set(); worker.callbacks = { onExit }; - worker.notifyHostProcessCrashed = vi.fn(); - worker.completeChannelRaw = vi.fn(); - worker.relistenChannel = vi.fn(); - worker._handleSyscallInner = vi.fn(() => worker.bindKernelTid(pid, tid)); + markPending(channel); + new DataView(memory.buffer, channel.channelOffset).setUint32( + CH_SYSCALL, + ABI_SYSCALLS.SchedYield, + true, + ); const error = vi.spyOn(console, "error").mockImplementation(() => {}); try { worker.handleSyscall(channel); expect(setCurrentTid).toHaveBeenCalledWith(pid, tid); - expect(worker.notifyHostProcessCrashed).toHaveBeenCalledWith(pid, 11); + expect(markSignaled).toHaveBeenCalledWith(pid, 11); expect(onExit).toHaveBeenCalledWith(pid, 139); expect(channel.handling).toBe(true); - expect(worker.completeChannelRaw).not.toHaveBeenCalled(); - expect(worker.relistenChannel).not.toHaveBeenCalled(); + expect(readStatus(channel)).toBe(CHANNEL_STATUS_PENDING); expect(error).toHaveBeenCalledWith( "[handleSyscall] FATAL task binding error: " + `Kernel rejected tid ${tid} for process ${pid}: errno 3`, @@ -1688,25 +2313,23 @@ describe("Rust-owned process wait lifecycle", () => { const memory = createSharedMemory(); const mainChannel = createChannel(pid, memory); const threadChannel = createChannel(pid, memory, 256); + const markSignaled = vi.fn(() => 0); const onExit = vi.fn(); - const worker = createWorkerHarness(); - worker.processes = new Map([ - [ - pid, - { - channels: [mainChannel, threadChannel], - memory, - }, - ], - ]); + const worker = createWorkerHarness({ + kernel_mark_process_signaled: markSignaled, + }); + worker.processes = new Map([[pid, { + channels: [mainChannel, threadChannel], + memory, + }]]); worker.channelTids = new Map(); worker.hostReaped = new Set(); worker.callbacks = { onExit }; - worker.notifyHostProcessCrashed = vi.fn(); - worker.completeChannelRaw = vi.fn(); - worker.relistenChannel = vi.fn(); - worker._handleSyscallInner = vi.fn(() => - worker.guestTidForChannel(threadChannel), + markPending(threadChannel); + new DataView(memory.buffer, threadChannel.channelOffset).setUint32( + CH_SYSCALL, + ABI_SYSCALLS.SchedYield, + true, ); const error = vi.spyOn(console, "error").mockImplementation(() => {}); const expected = @@ -1716,11 +2339,10 @@ describe("Rust-owned process wait lifecycle", () => { try { worker.handleSyscall(threadChannel); - expect(worker.notifyHostProcessCrashed).toHaveBeenCalledWith(pid, 11); + expect(markSignaled).toHaveBeenCalledWith(pid, 11); expect(onExit).toHaveBeenCalledWith(pid, 139); expect(threadChannel.handling).toBe(true); - expect(worker.completeChannelRaw).not.toHaveBeenCalled(); - expect(worker.relistenChannel).not.toHaveBeenCalled(); + expect(readStatus(threadChannel)).toBe(CHANNEL_STATUS_PENDING); expect(error).toHaveBeenCalledWith( `[handleSyscall] FATAL task binding error: ${expected}`, ); @@ -1729,34 +2351,47 @@ describe("Rust-owned process wait lifecycle", () => { } }); - it("still requests Worker teardown when recording a binding crash fails", () => { + it("still requests Worker teardown when recording a binding crash fails", async () => { const pid = 42; const tid = 101; const memory = createSharedMemory(); const channel = createChannel(pid, memory); const transitionError = new Error("kernel crash transition failed"); const onExit = vi.fn(); + const onKernelFatal = vi.fn(); + const markSignaled = vi.fn(() => { + throw transitionError; + }); const worker = createWorkerHarness({ + kernel_mark_process_signaled: markSignaled, kernel_set_current_tid: vi.fn(() => -3), }); worker.processes = new Map([[pid, { channels: [channel], memory }]]); + worker.channelTids = new Map([ + [`${pid}:${channel.channelOffset}`, tid], + ]); worker.hostReaped = new Set(); - worker.callbacks = { onExit }; - worker.notifyHostProcessCrashed = vi.fn(() => { - throw transitionError; - }); - worker._handleSyscallInner = vi.fn(() => worker.bindKernelTid(pid, tid)); + worker.callbacks = { onExit, onKernelFatal }; + markPending(channel); + new DataView(memory.buffer, channel.channelOffset).setUint32( + CH_SYSCALL, + ABI_SYSCALLS.SchedYield, + true, + ); const error = vi.spyOn(console, "error").mockImplementation(() => {}); try { - expect(() => worker.handleSyscall(channel)).toThrow(transitionError); + expect(() => worker.handleSyscall(channel)) + .toThrow(/kernel_mark_process_signaled failed/); + await Promise.resolve(); - expect(worker.notifyHostProcessCrashed).toHaveBeenCalledWith(pid, 11); - expect(onExit).toHaveBeenCalledWith(pid, 139); + expect(markSignaled).toHaveBeenCalledWith(pid, 11); + expect(onExit).not.toHaveBeenCalled(); + expect(onKernelFatal).toHaveBeenCalledOnce(); expect(channel.handling).toBe(true); expect(error).toHaveBeenCalledWith( - `[handleSyscall] Failed to record process ${pid} crash in kernel:`, - transitionError, + "[handleSyscall] FATAL task binding error: " + + `Kernel rejected tid ${tid} for process ${pid}: errno 3`, ); } finally { error.mockRestore(); @@ -1766,59 +2401,47 @@ describe("Rust-owned process wait lifecycle", () => { it("retires stale pthread transport metadata when deactivating a zombie", () => { const pid = 42; const otherPid = 420; - const worker = Object.assign( - Object.create(CentralizedKernelWorker.prototype), - { - retireAsyncChannelsForProcess: vi.fn(), - discardStoppedChannelStateForProcess: vi.fn(), - waitingForChild: [], - releaseAllSharedMemoryForProcess: vi.fn(), - activeChannels: [{ pid }, { pid: otherPid }], - channelTids: new Map([ - [`${pid}:1000`, 1001], - [`${otherPid}:2000`, 2001], - ]), - threadForkContexts: new Map([ - [`${pid}:1000`, { fnPtr: 1, argPtr: 2 }], - [`${otherPid}:2000`, { fnPtr: 3, argPtr: 4 }], - ]), - threadCtidPtrs: new Map([ - [`${pid}:1001`, 3000], - [`${otherPid}:2001`, 4000], - ]), - processes: new Map([ - [pid, {}], - [otherPid, {}], - ]), - execHandoffPids: new Set([pid]), - stdinFinite: new Set([pid]), - stdinBuffers: new Map([[pid, new Uint8Array()]]), - alarmTimers: new Map(), - posixTimers: new Map(), - cancelPendingSleepsForProcess: vi.fn(), - cleanupPendingPollRetries: vi.fn(), - cleanupPendingSelectRetries: vi.fn(), - cleanupPendingSignalWaits: vi.fn(), - cleanupUdpBindings: vi.fn(), - cleanupTcpListeners: vi.fn(), - hostReaped: new Set([pid]), - kernel: { releaseProcessViews: vi.fn() }, - }, - ) as any; + const memory = createSharedMemory(); + const otherMemory = createSharedMemory(); + const channel = createChannel(pid, memory); + const otherChannel = createChannel(otherPid, otherMemory); + const worker = createWorkerHarness({}); + worker.waitingForChild = []; + worker.activeChannels = [channel, otherChannel]; + worker.channelTids = new Map([ + [`${pid}:0`, 1001], + [`${otherPid}:0`, 2001], + ]); + worker.threadForkContexts = new Map([ + [`${pid}:0`, { fnPtr: 1, argPtr: 2 }], + [`${otherPid}:0`, { fnPtr: 3, argPtr: 4 }], + ]); + worker.threadCtidPtrs = new Map([ + [`${pid}:1001`, 3000], + [`${otherPid}:2001`, 4000], + ]); + worker.processes = new Map([ + [pid, { channels: [channel], memory }], + [otherPid, { channels: [otherChannel], memory: otherMemory }], + ]); + worker.execHandoffPids = new Set([pid]); + worker.stdinFinite = new Set([pid]); + worker.stdinBuffers = new Map([[pid, new Uint8Array()]]); + worker.hostReaped = new Set([pid]); worker.deactivateProcess(pid); expect(Array.from(worker.channelTids.entries())).toEqual([ - [`${otherPid}:2000`, 2001], + [`${otherPid}:0`, 2001], ]); expect(Array.from(worker.threadForkContexts.entries())).toEqual([ - [`${otherPid}:2000`, { fnPtr: 3, argPtr: 4 }], + [`${otherPid}:0`, { fnPtr: 3, argPtr: 4 }], ]); expect(Array.from(worker.threadCtidPtrs.entries())).toEqual([ [`${otherPid}:2001`, 4000], ]); expect(worker.processes.has(pid)).toBe(false); - expect(worker.activeChannels).toEqual([{ pid: otherPid }]); + expect(worker.activeChannels).toEqual([otherChannel]); }); it("host-observed crashes are marked in Rust before parent notification", () => { @@ -1834,31 +2457,44 @@ describe("Rust-owned process wait lifecycle", () => { }); worker.hostReaped = new Set(); worker.sharedMappings = new Map([[42, new Map()]]); - worker.sendSignalToProcess = vi.fn(() => calls.push("signal")); + const sendSignalToProcess = vi.fn(() => calls.push("signal")); + configureBoundaryHooks(worker, { sendSignalToProcess }); worker.notifyHostProcessCrashed(42, 11); expect(markProcessSignaled).toHaveBeenCalledWith(42, 11); - expect(worker.sendSignalToProcess).toHaveBeenCalledWith(7, SIGCHLD); + expect(sendSignalToProcess).toHaveBeenCalledWith(7, SIGCHLD, true); expect(calls).toEqual(["mark", "signal"]); expect(worker.sharedMappings.has(42)).toBe(false); }); it("does not publish a host crash when the kernel transition export is missing", () => { - const worker = createWorkerHarness({}); + const worker = createWorkerHarness( + {}, + 4, + createSharedMemory(), + ["kernel_mark_process_signaled"], + ); worker.hostReaped = new Set(); - worker.discardStoppedChannelStateForProcess = vi.fn(); - worker.releaseAllSharedMemoryForProcess = vi.fn(); - worker.notifyParentOfExitedProcess = vi.fn(); + worker.stoppedPids = new Set([42]); + worker.sharedMappings = new Map([[42, new Map()]]); - expect(() => worker.notifyHostProcessCrashed(42, 11)).toThrow( - "Kernel missing required kernel_mark_process_signaled export", + let thrown: unknown; + try { + worker.notifyHostProcessCrashed(42, 11); + } catch (error) { + thrown = error; + } + expect((thrown as Error & { cause?: unknown }).cause).toEqual( + expect.objectContaining({ + message: + "Kernel missing required kernel_mark_process_signaled export", + }), ); expect(worker.hostReaped.has(42)).toBe(false); - expect(worker.discardStoppedChannelStateForProcess).not.toHaveBeenCalled(); - expect(worker.releaseAllSharedMemoryForProcess).not.toHaveBeenCalled(); - expect(worker.notifyParentOfExitedProcess).not.toHaveBeenCalled(); + expect(worker.stoppedPids.has(42)).toBe(true); + expect(worker.sharedMappings.has(42)).toBe(true); }); it("does not publish a host crash rejected by the kernel", () => { @@ -1867,196 +2503,147 @@ describe("Rust-owned process wait lifecycle", () => { kernel_mark_process_signaled: markProcessSignaled, }); worker.hostReaped = new Set(); - worker.discardStoppedChannelStateForProcess = vi.fn(); - worker.releaseAllSharedMemoryForProcess = vi.fn(); - worker.notifyParentOfExitedProcess = vi.fn(); + worker.stoppedPids = new Set([42]); + worker.sharedMappings = new Map([[42, new Map()]]); - expect(() => worker.notifyHostProcessCrashed(42, 11)).toThrow( - "Kernel rejected signal-death transition for process 42: errno 3", + let thrown: unknown; + try { + worker.notifyHostProcessCrashed(42, 11); + } catch (error) { + thrown = error; + } + expect((thrown as Error & { cause?: unknown }).cause).toEqual( + expect.objectContaining({ + message: + "Kernel rejected signal-death transition for process 42: errno 3", + }), ); expect(markProcessSignaled).toHaveBeenCalledWith(42, 11); expect(worker.hostReaped.has(42)).toBe(false); - expect(worker.discardStoppedChannelStateForProcess).not.toHaveBeenCalled(); - expect(worker.releaseAllSharedMemoryForProcess).not.toHaveBeenCalled(); - expect(worker.notifyParentOfExitedProcess).not.toHaveBeenCalled(); + expect(worker.stoppedPids.has(42)).toBe(true); + expect(worker.sharedMappings.has(42)).toBe(true); }); - it("marks a host crash reaped before shared-state teardown can re-enter", () => { + it("marks a host crash reaped and releases shared state before parent notification", () => { const worker = createWorkerHarness({ kernel_mark_process_signaled: vi.fn(() => 0), + kernel_get_parent_pid: vi.fn(() => 7), + kernel_has_sa_nocldwait: vi.fn(() => 0), }); worker.hostReaped = new Set(); - worker.releaseAllSharedMemoryForProcess = vi.fn(() => { + worker.sharedMappings = new Map([[42, new Map()]]); + const sendSignalToProcess = vi.fn(() => { expect(worker.hostReaped.has(42)).toBe(true); + expect(worker.sharedMappings.has(42)).toBe(false); }); - worker.notifyParentOfExitedProcess = vi.fn(); + configureBoundaryHooks(worker, { sendSignalToProcess }); worker.notifyHostProcessCrashed(42, 11); - expect(worker.releaseAllSharedMemoryForProcess).toHaveBeenCalledWith(42); - expect(worker.notifyParentOfExitedProcess).toHaveBeenCalledOnce(); - }); - - it("does not overwrite signal death discovered during clean-exit writeback", () => { - let exitSignal = 0; - const kernelHandle = vi.fn(); - const worker = createWorkerHarness({ - kernel_get_process_exit_signal: vi.fn(() => exitSignal), - kernel_handle_channel: kernelHandle, - }); - const channel = createChannel(42, createSharedMemory()); - worker.processes = new Map([[42, { channels: [channel] }]]); - worker.hostReaped = new Set(); - worker.releaseAllSharedMemoryForProcess = vi.fn(() => { - exitSignal = SIGTERM; - }); - worker.handleProcessTerminated = vi.fn(); - - worker.handleExit(channel, ABI_SYSCALLS.ExitGroup, [0]); - - expect(worker.handleProcessTerminated).toHaveBeenCalledWith(channel); - expect(kernelHandle).not.toHaveBeenCalled(); + expect(sendSignalToProcess).toHaveBeenCalledWith(7, SIGCHLD, true); }); - it("accepts a normally returned kernel exit only after observing Exited state", () => { - const pid = 42; - const memory = createSharedMemory(); - const channel = createChannel(pid, memory); - const kernelHandle = vi.fn(); + it("does not commit clean exit when the post-release check reports signal death", () => { + const commitProcessExit = vi.fn(); const onExit = vi.fn(); const worker = createWorkerHarness({ - kernel_get_process_exit_signal: vi.fn(() => 0), - kernel_handle_channel: kernelHandle, - kernel_get_process_state: vi.fn(() => PROCESS_STATE_EXITED), + kernel_commit_process_exit: commitProcessExit, + kernel_get_process_exit_signal: vi.fn(() => SIGTERM), }); - worker.processes = new Map([[pid, { channels: [channel], memory }]]); + const memory = createSharedMemory(); + const channel = createChannel(42, memory); + worker.processes = new Map([[42, { channels: [channel], memory }]]); worker.hostReaped = new Set(); worker.callbacks = { onExit }; - worker.releaseAllSharedMemoryForProcess = vi.fn(); - worker.discardStoppedChannelStateForProcess = vi.fn(); - worker.drainAndProcessWakeupEvents = vi.fn(); - worker.notifyParentOfExitedProcess = vi.fn(); - worker.completeProcessExitHandshake = vi.fn(); - worker.scheduleWakeBlockedRetries = vi.fn(); - - worker.handleExit(channel, ABI_SYSCALLS.ExitGroup, [7]); - - expect(kernelHandle).toHaveBeenCalledOnce(); - expect(worker.hostReaped.has(pid)).toBe(true); - expect(worker.notifyParentOfExitedProcess).toHaveBeenCalledWith(pid); - expect(worker.completeProcessExitHandshake).toHaveBeenCalledWith( + markPending(channel); + + dispatchLifecycleSyscall( + worker, channel, ABI_SYSCALLS.ExitGroup, + [0], ); - expect(onExit).toHaveBeenCalledWith(pid, 7); + + expect(worker.hostReaped.has(42)).toBe(true); + expect(onExit).toHaveBeenCalledWith(42, 128 + SIGTERM); + expect(commitProcessExit).not.toHaveBeenCalled(); + expect(readStatus(channel)).toBe(CHANNEL_STATUS_PENDING); }); - it("accepts a legacy trapped kernel exit only after observing Exited state", () => { + it("fails closed when Rust returns a different committed exit status", () => { const pid = 42; const memory = createSharedMemory(); const channel = createChannel(pid, memory); - const trap = new WebAssembly.RuntimeError("unreachable"); const onExit = vi.fn(); const worker = createWorkerHarness({ - kernel_get_process_exit_signal: vi.fn(() => 0), - kernel_handle_channel: vi.fn(() => { - throw trap; - }), + kernel_commit_process_exit: vi.fn(() => 6), kernel_get_process_state: vi.fn(() => PROCESS_STATE_EXITED), }); worker.processes = new Map([[pid, { channels: [channel], memory }]]); worker.hostReaped = new Set(); worker.callbacks = { onExit }; - worker.releaseAllSharedMemoryForProcess = vi.fn(); - worker.discardStoppedChannelStateForProcess = vi.fn(); - worker.drainAndProcessWakeupEvents = vi.fn(); - worker.notifyParentOfExitedProcess = vi.fn(); - worker.completeProcessExitHandshake = vi.fn(); - worker.scheduleWakeBlockedRetries = vi.fn(); - - worker.handleExit(channel, ABI_SYSCALLS.ExitGroup, [7]); - - expect(worker.hostReaped.has(pid)).toBe(true); - expect(worker.notifyParentOfExitedProcess).toHaveBeenCalledWith(pid); - expect(worker.completeProcessExitHandshake).toHaveBeenCalledWith( - channel, - ABI_SYSCALLS.ExitGroup, + markPending(channel); + let thrown: unknown; + try { + dispatchLifecycleSyscall( + worker, + channel, + ABI_SYSCALLS.ExitGroup, + [7], + ); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error & { cause?: unknown }).cause).toEqual( + expect.objectContaining({ + message: + `kernel committed exit status 6 for process ${pid}; expected 7`, + }), ); - expect(onExit).toHaveBeenCalledWith(pid, 7); - }); - it.each(["return", "trap"] as const)( - "rejects a kernel exit %s that leaves the process live", - (completion) => { - const pid = 42; - const memory = createSharedMemory(); - const channel = createChannel(pid, memory); - const markProcessSignaled = vi.fn(() => 0); - const onExit = vi.fn(); - const worker = createWorkerHarness({ - kernel_handle_channel: vi.fn(() => { - if (completion === "trap") { - throw new WebAssembly.RuntimeError("unreachable"); - } - }), - kernel_get_process_state: vi.fn(() => PROCESS_STATE_RUNNING), - kernel_mark_process_signaled: markProcessSignaled, - }); - worker.processes = new Map([[pid, { channels: [channel], memory }]]); - worker.hostReaped = new Set(); - worker.callbacks = { onExit }; - worker.releaseAllSharedMemoryForProcess = vi.fn(); - worker.discardStoppedChannelStateForProcess = vi.fn(); - worker.drainAndProcessWakeupEvents = vi.fn(); - worker.notifyParentOfExitedProcess = vi.fn(); - worker.completeProcessExitHandshake = vi.fn(); - worker.scheduleWakeBlockedRetries = vi.fn(); - const error = vi.spyOn(console, "error").mockImplementation(() => {}); - - try { - worker.handleExit(channel, ABI_SYSCALLS.ExitGroup, [7]); - - expect(markProcessSignaled).toHaveBeenCalledWith(pid, 11); - expect(worker.hostReaped.has(pid)).toBe(true); - expect(onExit).toHaveBeenCalledWith(pid, 139); - expect(onExit).not.toHaveBeenCalledWith(pid, 7); - expect(worker.completeProcessExitHandshake).not.toHaveBeenCalled(); - expect(error).toHaveBeenCalledWith( - `[handleSyscall] FATAL kernel exit left process ${pid} in state ${PROCESS_STATE_RUNNING}`, - ); - } finally { - error.mockRestore(); - } - }, - ); + expect(worker.hostReaped.has(pid)).toBe(false); + expect(onExit).not.toHaveBeenCalled(); + expect(readStatus(channel)).toBe(CHANNEL_STATUS_PENDING); + }); it("uses the explicit termination signal instead of classifying high exit codes", () => { - const exitSignals = new Map([ - [42, 0], - [43, 15], - ]); + const kernelMemory = createSharedMemory(); + const exitSignals = new Map([[42, 0], [43, 15]]); + const handleChannel = successfulKernelHandle(kernelMemory); + const pickSignalTarget = vi.fn(() => 0); + const onExit = vi.fn(); const worker = createWorkerHarness({ - kernel_get_process_exit_signal: vi.fn( - (pid: number) => exitSignals.get(pid) ?? -1, - ), - }); - const normalChannel = createChannel(42, createSharedMemory()); - const signaledChannel = createChannel(43, createSharedMemory()); + kernel_handle_channel: handleChannel, + kernel_get_process_exit_signal: vi.fn((pid: number) => exitSignals.get(pid) ?? -1), + kernel_pick_signal_target_tid: pickSignalTarget, + }, 4, kernelMemory); + const normalMemory = createSharedMemory(); + const signaledMemory = createSharedMemory(); + const normalChannel = createChannel(42, normalMemory); + const signaledChannel = createChannel(43, signaledMemory); worker.processes = new Map([ - [42, { channels: [normalChannel] }], - [43, { channels: [signaledChannel] }], + [42, { channels: [normalChannel], memory: normalMemory }], + [43, { channels: [signaledChannel], memory: signaledMemory }], ]); worker.pendingSleeps = new Map(); worker.hostReaped = new Set(); - worker.handleProcessTerminated = vi.fn(); - - worker.reapKilledProcessesAfterSyscall(); + worker.callbacks = { onExit }; + markPending(normalChannel); - expect(worker.handleProcessTerminated).toHaveBeenCalledOnce(); - expect(worker.handleProcessTerminated).toHaveBeenCalledWith( - signaledChannel, + dispatchLifecycleSyscall( + worker, + normalChannel, + ABI_SYSCALLS.Kill, + [43, SIGTERM], ); + + expect(worker.hostReaped.has(42)).toBe(false); + expect(worker.hostReaped.has(43)).toBe(true); + expect(pickSignalTarget).toHaveBeenCalledWith(43, SIGTERM); + expect(onExit).toHaveBeenCalledOnce(); + expect(onExit).toHaveBeenCalledWith(43, 128 + SIGTERM); }); it("SA_NOCLDWAIT auto-reaps through Rust without SIGCHLD", () => { @@ -2066,50 +2653,87 @@ describe("Rust-owned process wait lifecycle", () => { kernel_get_parent_pid: vi.fn(() => 7), kernel_has_sa_nocldwait: vi.fn(() => 1), kernel_reap_exited_child: reapExitedChild, + kernel_wait_child_poll: vi.fn(() => -10), }); worker.hostReaped = new Set(); worker.sharedMappings = new Map(); - worker.sendSignalToProcess = vi.fn(); - worker.wakeWaitingParent = vi.fn(); + const parentMemory = createSharedMemory(); + const parentChannel = createChannel(7, parentMemory); + worker.processes = new Map([[7, { + channels: [parentChannel], + memory: parentMemory, + }]]); + worker.waitingForChild = [{ + parentPid: 7, + channel: parentChannel, + origArgs: [42, 0, 0, 0], + pid: 42, + options: 0, + syscallNr: ABI_SYSCALLS.Wait4, + }]; + const sendSignalToProcess = vi.fn(); + const completeChannel = observeMarshalledCompletions(worker); + configureBoundaryHooks(worker, { sendSignalToProcess }); worker.notifyHostProcessCrashed(42, 11); expect(reapExitedChild).toHaveBeenCalledWith(7, 42); - expect(worker.sendSignalToProcess).not.toHaveBeenCalled(); - expect(worker.wakeWaitingParent).toHaveBeenCalledWith(7); + expect(sendSignalToProcess).not.toHaveBeenCalled(); + expect(worker.waitingForChild).toEqual([]); + expect(completeChannel).toHaveBeenCalledWith( + parentChannel, + ABI_SYSCALLS.Wait4, + [42, 0, 0, 0], + undefined, + -1, + 10, + ); }); }); function createWorkerHarness( - exports: Record, + exports: Record = {}, kernelPtrWidth: 4 | 8 = 4, + kernelMemory = createSharedMemory(), + excludedExports: readonly string[] = [], ): any { - const kernelMemory = createSharedMemory(); - const worker = Object.assign( - Object.create(CentralizedKernelWorker.prototype), + const gate = new KernelEntryGate(); + const implementations = { + kernel_dequeue_signal: vi.fn(() => 0), + kernel_drain_wakeup_events: vi.fn(() => 0), + kernel_get_parent_pid: vi.fn(() => 0), + kernel_get_process_exit_signal: vi.fn(() => -1), + kernel_get_process_state: vi.fn(() => PROCESS_STATE_RUNNING), + kernel_mark_process_signaled: vi.fn(() => 0), + kernel_set_current_tid: vi.fn(() => 0), + ...exports, + }; + const rawInstance = createKernelScratchTestInstance( + kernelPtrWidth, + kernelMemory, + () => implementations, + () => kernelPtrWidth === 8 ? 128n : 128, + 4, + undefined, + excludedExports, + ); + const kernelInstance = createKernelEntryGatedInstance(rawInstance, gate); + const worker = Object.assign(createCentralizedKernelWorkerTestDouble(), { + processes: new Map(), + channelTids: new Map(), + pendingCancels: new Set(), + deferredProcessWorkerStarts: new Map(), + }); + installKernelWorkerTestScratch( + worker, + kernelMemory, + 128, + kernelPtrWidth, { - kernel: { - toKernelPtr(value: number | bigint): number | bigint { - const numberValue = typeof value === "bigint" ? Number(value) : value; - return kernelPtrWidth === 8 ? BigInt(numberValue) : numberValue; - }, - }, - kernelInstance: { - exports: { - kernel_get_process_exit_signal: vi.fn(() => -1), - kernel_get_process_state: vi.fn(() => PROCESS_STATE_RUNNING), - kernel_set_current_tid: vi.fn(() => 0), - ...exports, - }, - }, - kernelMemory, - processes: new Map(), - channelTids: new Map(), - pendingCancels: new Set(), - deferredProcessWorkerStarts: new Map(), + boundInstance: kernelInstance, + gate, }, ); - installKernelWorkerTestScratch(worker, kernelMemory, 128, kernelPtrWidth); return worker; } @@ -2121,11 +2745,7 @@ function createSharedMemory(): WebAssembly.Memory { }); } -function createChannel( - pid: number, - memory: WebAssembly.Memory, - channelOffset = 0, -): any { +function createChannel(pid: number, memory: WebAssembly.Memory, channelOffset = 0): any { return { pid, memory, @@ -2144,6 +2764,117 @@ function registerMainChannel(worker: any, channel: any): any { return channel; } +function configureBoundaryHooks( + worker: any, + hooks: Record, +): void { + worker.testAuthority.configureScratchBoundaryHooksForTest(hooks); +} + +function observeMarshalledCompletions(worker: any): ReturnType { + const completion = vi.fn(); + configureBoundaryHooks(worker, { + completeChannel: ( + channel: unknown, + syscallNr: number, + origArgs: number[], + argDescs: unknown, + retVal: number, + errVal: number, + ) => { + completion( + channel, + syscallNr, + origArgs, + argDescs, + retVal, + errVal, + ); + }, + }); + return completion; +} + +function observeRelisten(worker: any): ReturnType { + const relisten = vi.fn(); + configureBoundaryHooks(worker, { relistenChannel: relisten }); + return relisten; +} + +function dispatchLifecycleSyscall( + worker: any, + channel: any, + syscallNr: number, + args: readonly (number | bigint)[], + cancellationPoint = false, + cancellationWakeAllowed = cancellationPoint, +): void { + const view = new DataView( + channel.memory.buffer, + channel.channelOffset, + ); + view.setUint32(CH_SYSCALL, syscallNr, true); + view.setUint32( + CH_REQUEST_FLAGS, + (cancellationPoint + ? CHANNEL_REQUEST_FLAG_CANCELLATION_POINT + : 0) + | (cancellationWakeAllowed + ? CHANNEL_REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED + : 0), + true, + ); + for (let index = 0; index < 6; index++) { + view.setBigInt64( + CH_ARGS + index * CH_ARG_SIZE, + BigInt(args[index] ?? 0), + true, + ); + } + worker.testAuthority.dispatchScratchBoundarySyscallForTest(channel); +} + +function syscallArgs(...args: number[]): number[] { + return Array.from({ length: 6 }, (_, index) => args[index] ?? 0); +} + +function readCompletion(channel: any): { + retVal: number; + errVal: number; + status: number; +} { + const view = new DataView( + channel.memory.buffer, + channel.channelOffset, + ); + return { + retVal: Number(view.getBigInt64(CH_RETURN, true)), + errVal: view.getUint32(CH_ERRNO, true), + status: view.getUint32(CH_STATUS, true), + }; +} + +async function drainLifecycleGate(): Promise { + // Resume publication is a protocol transaction that starts only after the + // exact kernel-entry scope is revoked. + for (let turn = 0; turn < 24; turn++) { + await Promise.resolve(); + } +} + +function successfulKernelHandle( + memory: WebAssembly.Memory, + observeRequest?: (view: DataView) => void, +): ReturnType { + return vi.fn((channelPtr: number | bigint) => { + const view = new DataView(memory.buffer, Number(channelPtr)); + observeRequest?.(view); + view.setBigInt64(CH_RETURN, 0n, true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }); +} + function writeKernelWaitResult( memory: WebAssembly.Memory, ptr: number, @@ -2156,27 +2887,12 @@ function writeKernelWaitResult( }, ): void { const view = new DataView(memory.buffer); - view.setInt32( - ptr + KERNEL_WAIT_RESULT_WAIT_STATUS_OFFSET, - result.waitStatus, - true, - ); + view.setInt32(ptr + KERNEL_WAIT_RESULT_WAIT_STATUS_OFFSET, result.waitStatus, true); view.setInt32(ptr + KERNEL_WAIT_RESULT_SI_CODE_OFFSET, result.siCode, true); - view.setInt32( - ptr + KERNEL_WAIT_RESULT_SI_STATUS_OFFSET, - result.siStatus, - true, - ); - view.setUint32( - ptr + KERNEL_WAIT_RESULT_CHILD_UID_OFFSET, - result.childUid, - true, - ); - new Uint8Array( - memory.buffer, - ptr + KERNEL_WAIT_RESULT_RUSAGE_OFFSET, - result.rusage.length, - ).set(result.rusage); + view.setInt32(ptr + KERNEL_WAIT_RESULT_SI_STATUS_OFFSET, result.siStatus, true); + view.setUint32(ptr + KERNEL_WAIT_RESULT_CHILD_UID_OFFSET, result.childUid, true); + new Uint8Array(memory.buffer, ptr + KERNEL_WAIT_RESULT_RUSAGE_OFFSET, result.rusage.length) + .set(result.rusage); } function writeWakeEvent( @@ -2200,19 +2916,6 @@ function markPending(channel: any): void { ); } -function parkedRaw(retVal: number): any { - return { - prepared: { - kind: "raw", - outputWrites: [], - retVal, - errVal: 0, - relistenRequested: false, - }, - relistenRequested: false, - }; -} - function readStatus(channel: any): number { return Atomics.load( new Int32Array(channel.memory.buffer, channel.channelOffset), diff --git a/host/test/putenv.test.ts b/host/test/putenv.test.ts index 2e56692f9e..3f076d46f7 100644 --- a/host/test/putenv.test.ts +++ b/host/test/putenv.test.ts @@ -1,40 +1,66 @@ import { describe, it, expect } from "vitest"; -import { join, dirname } from "node:path"; +import { dirname, relative } from "node:path"; import { fileURLToPath } from "node:url"; import { runCentralizedProgram } from "./centralized-test-helper"; +import { ensureEnvironmentTransactionFixture } from "./environment-transaction-fixture"; -const __dirname = dirname(fileURLToPath(import.meta.url)); +const architectures = ["wasm32", "wasm64"] as const; +const fixtureDirectory = dirname( + fileURLToPath(new URL("./fixtures/.fixture", import.meta.url)), +); describe("putenv / setenv / unsetenv", () => { - it("populates __environ from kernel at startup and syncs setenv/putenv/unsetenv", async () => { - const { exitCode, stdout } = await runCentralizedProgram({ - programPath: join(__dirname, "../../examples/putenv_test.wasm"), - env: ["HOME=/home/test", "PATH=/usr/bin"], - }); + it.each(architectures)( + "populates and transactionally syncs a %s guest environment", + async (arch) => { + const programPath = ensureEnvironmentTransactionFixture(arch); + const fixtureRelativePath = relative(fixtureDirectory, programPath); + // The link-wrapped failure-injection fixture must never overwrite the + // ordinary examples/putenv_test binaries consumed by browser tests. + expect(dirname(programPath)).toBe(fixtureDirectory); + expect(fixtureRelativePath).not.toMatch(/^\.\.(?:[/\\]|$)/); - // Startup env population from kernel - expect(stdout).toContain("HOME=/home/test"); - expect(stdout).toContain("PATH=/usr/bin"); + const { exitCode, stdout, stderr } = await runCentralizedProgram({ + programPath, + env: ["HOME=/home/test", "PATH=/usr/bin"], + }); - // setenv - expect(stdout).toContain("MY_VAR=hello"); + // Startup env population from kernel + expect(stdout).toContain("HOME=/home/test"); + expect(stdout).toContain("PATH=/usr/bin"); - // setenv overwrite - expect(stdout).toMatch(/MY_VAR=world/); + // setenv + expect(stdout).toContain("MY_VAR=hello"); - // setenv no-overwrite (should still be "world") - const myVarLines = stdout.split("\n").filter(l => l.startsWith("MY_VAR=")); - expect(myVarLines[0]).toBe("MY_VAR=hello"); - expect(myVarLines[1]).toBe("MY_VAR=world"); - expect(myVarLines[2]).toBe("MY_VAR=world"); + // setenv overwrite + expect(stdout).toMatch(/MY_VAR=world/); - // putenv - expect(stdout).toContain("PUT_VAR=from_putenv"); + // setenv no-overwrite (should still be "world") + const myVarLines = stdout + .split("\n") + .filter((l) => l.startsWith("MY_VAR=")); + expect(myVarLines[0]).toBe("MY_VAR=hello"); + expect(myVarLines[1]).toBe("MY_VAR=world"); + expect(myVarLines[2]).toBe("MY_VAR=world"); - // unsetenv - expect(stdout).toContain("MY_VAR="); + // putenv + expect(stdout).toContain("PUT_VAR=from_putenv"); - expect(stdout).toContain("DONE"); - expect(exitCode).toBe(0); - }, 30_000); + // unsetenv + expect(stdout).toContain("MY_VAR="); + + // Exact process-metadata capacity, capacity+1 rejection, and a name well + // beyond the removed 256-byte implementation cutoff all remain coherent + // between libc's environ and the kernel Process environment. + expect(stdout).toContain("SETENV_BOUNDARY_PASS"); + expect(stdout).toContain("PUTENV_LONG_BOUNDARY_PASS"); + expect(stdout).toContain("ENV_TRANSACTION_FAILURE_PASS"); + expect(stdout).toContain("ENV_COHERENCE_PASS"); + + expect(stderr).toBe(""); + expect(stdout).toContain("DONE"); + expect(exitCode).toBe(0); + }, + 30_000, + ); }); diff --git a/host/test/readdir-atomicity.test.ts b/host/test/readdir-atomicity.test.ts index a06b969c2e..42c19d306a 100644 --- a/host/test/readdir-atomicity.test.ts +++ b/host/test/readdir-atomicity.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { WasmPosixKernel } from "../src/kernel"; +import { createWasmPosixKernelTestHarness } from "../src/kernel"; import type { PlatformIO } from "../src/types"; const KERNEL_CONFIG = { @@ -15,36 +15,34 @@ function createKernelBridge(entries: Array<{ name: string; type: number; ino: nu readdir: vi.fn(() => entries[index++] ?? null), closedir: vi.fn(), }; - const kernel = new WasmPosixKernel( - KERNEL_CONFIG, - io as unknown as PlatformIO, - ); const memory = new WebAssembly.Memory({ initial: 1 }); - Object.assign(kernel as object, { memory }); + const kernel = createWasmPosixKernelTestHarness({ + config: KERNEL_CONFIG, + io: io as unknown as PlatformIO, + memory, + pointerWidth: 4, + }); return { io, kernel, memory }; } describe("host readdir retry atomicity", () => { it("replays an entry when Wasm output marshalling fails after the backend read", () => { - const entry = { name: "retry-me", type: 8, ino: 42 }; + let failFirstNameRead = true; + const entry = { + get name(): string { + if (failFirstNameRead) { + failFirstNameRead = false; + throw new Error("malformed first marshalling attempt"); + } + return "retry-me"; + }, + type: 8, + ino: 42, + }; const { io, kernel, memory } = createKernelBridge([entry]); - const hostReaddir = ( - kernel as unknown as { - hostReaddir: ( - handle: bigint, - direntPtr: number, - namePtr: number, - nameLen: number, - ) => number; - } - ).hostReaddir.bind(kernel); + const hostReaddir = kernel.testAuthority.hostReaddir; - const result = hostReaddir( - 7n, - memory.buffer.byteLength - 4, - 128, - 64, - ); + const result = hostReaddir(7n, 16, 128, 64); expect(result).toBeLessThan(0); expect(io.readdir).toHaveBeenCalledTimes(1); @@ -60,28 +58,29 @@ describe("host readdir retry atomicity", () => { new Uint8Array(memory.buffer, 128, entry.name.length), ), ).toBe(entry.name); - expect(hostReaddir(7n, 0, 128, 64)).toBe(0); + expect(hostReaddir(7n, 16, 128, 64)).toBe(0); expect(io.readdir).toHaveBeenCalledTimes(2); }); it("drops a staged entry when a directory handle closes", () => { + let failFirstNameRead = true; const { io, kernel, memory } = createKernelBridge([ - { name: "old-iterator", type: 8, ino: 1 }, + { + get name(): string { + if (failFirstNameRead) { + failFirstNameRead = false; + throw new Error("malformed old iterator entry"); + } + return "old-iterator"; + }, + type: 8, + ino: 1, + }, { name: "new-iterator", type: 4, ino: 2 }, ]); - const bridge = kernel as unknown as { - hostReaddir: ( - handle: bigint, - direntPtr: number, - namePtr: number, - nameLen: number, - ) => number; - hostClosedir: (handle: bigint) => number; - }; + const bridge = kernel.testAuthority; - expect( - bridge.hostReaddir(7n, memory.buffer.byteLength - 4, 128, 64), - ).toBeLessThan(0); + expect(bridge.hostReaddir(7n, 16, 128, 64)).toBeLessThan(0); expect(bridge.hostClosedir(7n)).toBe(0); expect(bridge.hostReaddir(7n, 16, 128, 64)).toBe(1); diff --git a/host/test/readiness-deadline.test.ts b/host/test/readiness-deadline.test.ts index 06bc1315ef..95b86fd0d1 100644 --- a/host/test/readiness-deadline.test.ts +++ b/host/test/readiness-deadline.test.ts @@ -1,14 +1,326 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { ABI_SYSCALLS, + CHANNEL_STATUS_COMPLETE, + CHANNEL_STATUS_PENDING, + CH_ARGS, + CH_ARGS_COUNT, + CH_ARG_SIZE, + CH_ERRNO, + CH_RETURN, CH_SIG_BASE, + CH_STATUS, + CH_SYSCALL, KERNEL_SCRATCH_SIGNAL_DELIVERY_BYTES, + SIGNAL_MASK_BYTES, + STRUCT_SIZE_WASM_EPOLL_EVENT, + STRUCT_SIZE_WASM_POLL_FD, + WASM_EPOLL_EVENT_DATA_OFFSET, + WASM_EPOLL_EVENT_EVENTS_OFFSET, } from "../src/generated/abi"; -import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { + createKernelEntryGatedInstance, + KernelEntryGate, +} from "../src/kernel-entry-gate"; +import { + createCentralizedKernelWorkerTestDouble, +} from "../src/kernel-worker"; import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; +import { + createKernelScratchTestInstance, +} from "./support/kernel-scratch-instance"; + +const EAGAIN = 11; +const EINTR = 4; +const PID = 42; +const SCRATCH_POINTER = 128; +const WAKE_READABLE = 1; + +interface TestChannel { + readonly pid: number; + readonly memory: WebAssembly.Memory; + readonly channelOffset: number; + readonly i32View: Int32Array; + readinessDeadline?: number; + readinessFinalCheck?: boolean; +} + +interface ReadinessState { + readonly blockingRetrySnapshots: Map; + readonly hostReaped: Set; + readonly pendingPollRetries: Map; + readonly pendingSelectRetries: Map; +} + +interface KernelResult { + readonly retVal: number; + readonly errVal: number; +} + +function createSharedMemory(pages = 2): WebAssembly.Memory { + return new WebAssembly.Memory({ + initial: pages, + maximum: pages, + shared: true, + }); +} + +function syscallArgs(...values: number[]): number[] { + return Array.from( + { length: CH_ARGS_COUNT }, + (_, index) => values[index] ?? 0, + ); +} + +function writeSyscall( + channel: TestChannel, + syscallNr: number, + args: readonly number[], +): void { + const view = new DataView( + channel.memory.buffer, + channel.channelOffset, + ); + view.setUint32(CH_STATUS, CHANNEL_STATUS_PENDING, true); + view.setUint32(CH_SYSCALL, syscallNr, true); + for (let index = 0; index < CH_ARGS_COUNT; index++) { + view.setBigInt64( + CH_ARGS + index * CH_ARG_SIZE, + BigInt(args[index] ?? 0), + true, + ); + } +} + +function readRawCompletion(channel: TestChannel): { + readonly retVal: number; + readonly errVal: number; + readonly status: number; +} { + const view = new DataView( + channel.memory.buffer, + channel.channelOffset, + ); + return { + retVal: Number(view.getBigInt64(CH_RETURN, true)), + errVal: view.getUint32(CH_ERRNO, true), + status: view.getUint32(CH_STATUS, true), + }; +} + +function createHarness( + handleResult: ( + syscallNr: number, + scratch: DataView, + ) => KernelResult = (syscallNr) => { + if ( + syscallNr === ABI_SYSCALLS.EpollCreate1 + || syscallNr === ABI_SYSCALLS.EpollCreate + ) { + return { retVal: 7, errVal: 0 }; + } + return { retVal: 0, errVal: 0 }; + }, + pointerWidth: 4 | 8 = 4, +) { + const kernelMemory = createSharedMemory(); + const processMemory = createSharedMemory(); + let handlerSignal = 0; + let exitSignal = -1; + let readableWakeQueued = false; + + const handleChannel = vi.fn((pointer: number | bigint) => { + const scratch = new DataView(kernelMemory.buffer, Number(pointer)); + const result = handleResult( + scratch.getUint32(CH_SYSCALL, true), + scratch, + ); + scratch.setBigInt64(CH_RETURN, BigInt(result.retVal), true); + scratch.setUint32(CH_ERRNO, result.errVal, true); + return 0; + }); + const dequeueSignal = vi.fn(( + _pid: number, + _tid: number, + pointer: number | bigint, + capacity: number, + ) => { + if (handlerSignal <= 0) return 0; + const output = new Uint8Array( + kernelMemory.buffer, + Number(pointer), + capacity, + ); + output.fill(0); + new DataView( + kernelMemory.buffer, + Number(pointer), + capacity, + ).setUint32(0, handlerSignal, true); + return handlerSignal; + }); + const drainWakeupEvents = vi.fn(( + pointer: number | bigint, + _capacity: number, + _maxEvents: number, + ) => { + if (!readableWakeQueued) return 0; + readableWakeQueued = false; + const output = new DataView(kernelMemory.buffer, Number(pointer), 5); + output.setUint32(0, 99, true); + output.setUint8(4, WAKE_READABLE); + return 1; + }); + const setCurrentTid = vi.fn(() => 0); + const blockingRetryRelease = vi.fn(() => 0); + const blockingRetryToken = vi.fn(() => 0n); + const implementations: Record = { + kernel_blocking_retry_release: blockingRetryRelease, + kernel_blocking_retry_token: blockingRetryToken, + kernel_dequeue_signal: dequeueSignal, + kernel_drain_wakeup_events: drainWakeupEvents, + kernel_get_parent_pid: vi.fn(() => 0), + kernel_get_process_exit_signal: vi.fn(() => exitSignal), + kernel_handle_channel: handleChannel, + kernel_set_current_tid: setCurrentTid, + }; + const gate = new KernelEntryGate(); + const kernelInstance = createKernelEntryGatedInstance( + createKernelScratchTestInstance( + 4, + kernelMemory, + () => implementations, + () => SCRATCH_POINTER, + 4, + [ + "kernel_blocking_retry_release", + "kernel_blocking_retry_token", + "kernel_dequeue_signal", + "kernel_drain_wakeup_events", + "kernel_get_parent_pid", + "kernel_get_process_exit_signal", + "kernel_handle_channel", + "kernel_set_current_tid", + ], + ), + gate, + ); + const onExit = vi.fn(); + const worker = createCentralizedKernelWorkerTestDouble({ + callbacks: { onExit }, + }); + installKernelWorkerTestScratch( + worker, + kernelMemory, + SCRATCH_POINTER, + 4, + { boundInstance: kernelInstance, gate }, + ); -function createSharedMemory(pages = 1): WebAssembly.Memory { - return new WebAssembly.Memory({ initial: pages, maximum: pages, shared: true }); + const completeChannel = vi.fn(); + const relistenChannel = vi.fn(); + worker.testAuthority.configureScratchBoundaryHooksForTest({ + completeChannel: ( + channel, + syscallNr, + origArgs, + argDescs, + retVal, + errVal, + ) => { + completeChannel( + channel, + syscallNr, + origArgs, + argDescs, + retVal, + errVal, + ); + }, + relistenChannel, + }); + const [registeredChannel] = + worker.testAuthority.replaceProcessRegistrationForLifecycleTest({ + pid: PID, + memory: processMemory, + channelOffsets: [0], + pointerWidth, + }); + const channel = registeredChannel as TestChannel; + + return { + blockingRetryRelease, + blockingRetryToken, + channel, + completeChannel, + dequeueSignal, + drainWakeupEvents, + handleChannel, + onExit, + processMemory, + queueReadableWake(): void { + readableWakeQueued = true; + }, + relistenChannel, + setExitSignal(signal: number): void { + exitSignal = signal; + }, + setHandlerSignal(signal: number): void { + handlerSignal = signal; + }, + state: worker as unknown as ReadinessState, + worker, + }; +} + +function dispatchSyscall( + harness: ReturnType, + syscallNr: number, + args: readonly number[], +): void { + writeSyscall(harness.channel, syscallNr, args); + harness.worker.testAuthority.dispatchScratchBoundarySyscallForTest( + harness.channel, + ); +} + +function initializeEpoll( + harness: ReturnType, + hasInterest: boolean, +): void { + dispatchSyscall( + harness, + ABI_SYSCALLS.EpollCreate1, + syscallArgs(0), + ); + if (hasInterest) { + const eventPointer = 8192; + const event = new DataView( + harness.processMemory.buffer, + eventPointer, + STRUCT_SIZE_WASM_EPOLL_EVENT, + ); + event.setUint32(WASM_EPOLL_EVENT_EVENTS_OFFSET, 0x001, true); + event.setBigUint64(WASM_EPOLL_EVENT_DATA_OFFSET, 99n, true); + dispatchSyscall( + harness, + ABI_SYSCALLS.EpollCtl, + syscallArgs(7, 1, 3, eventPointer), + ); + } + harness.completeChannel.mockClear(); + harness.dequeueSignal.mockClear(); + harness.handleChannel.mockClear(); + harness.relistenChannel.mockClear(); } afterEach(() => { @@ -16,205 +328,248 @@ afterEach(() => { }); describe("finite readiness deadlines", () => { - it("keeps one poll deadline and performs a final readiness retry", () => { + it("keeps one poll deadline and performs a final readiness retry", async () => { vi.useFakeTimers(); vi.setSystemTime(1_000); - const channel: any = { - pid: 42, - channelOffset: 0, - memory: createSharedMemory(), - }; - const worker: any = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - processes: new Map([[channel.pid, { channels: [channel] }]]), - pendingPollRetries: new Map(), - pendingSelectRetries: new Map(), - pendingPipeReaders: new Map(), - pendingPipeWriters: new Map(), + const observedKernelTimeouts: number[] = []; + const harness = createHarness((syscallNr, scratch) => { + expect(syscallNr).toBe(ABI_SYSCALLS.Poll); + const timeout = Number( + scratch.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true), + ); + observedKernelTimeouts.push(timeout); + return timeout === 0 + ? { retVal: 0, errVal: 0 } + : { retVal: -1, errVal: EAGAIN }; }); - worker.resolvePollReadinessIndices = () => ({ pipeIndices: [], acceptIndices: [] }); - worker.completeChannel = vi.fn(); - - const args = [0, 1, 120, 0, 0, 0]; - const observedDeadlines: number[] = []; - let finalChecks = 0; - worker.retrySyscall = vi.fn(() => { - observedDeadlines.push(channel.readinessDeadline); - if (channel.readinessFinalCheck) { - // Model the zero-time kernel dispatch returning 0 after its final - // readiness check (and, for ppoll, restoring the temporary mask). - finalChecks++; - channel.readinessFinalCheck = false; - worker.completeChannel(channel, ABI_SYSCALLS.Poll, args, undefined, 0, 0); - return; + const pollPointer = 1024; + const pollfd = new DataView( + harness.processMemory.buffer, + pollPointer, + STRUCT_SIZE_WASM_POLL_FD, + ); + pollfd.setInt32(0, -1, true); + pollfd.setInt16(4, 0x001, true); + const args = syscallArgs(pollPointer, 1, 120); + + dispatchSyscall(harness, ABI_SYSCALLS.Poll, args); + const retainedSnapshot = harness.state.blockingRetrySnapshots.get( + harness.channel, + ); + expect(retainedSnapshot?.dispatch.adjustedArgs[2]).toBe(120); + expect(retainedSnapshot?.dispatch.readinessTimeoutMs).toBe(120); + + expect(harness.channel.readinessDeadline).toBe(1_120); + await vi.advanceTimersByTimeAsync(119); + expect(harness.completeChannel).not.toHaveBeenCalled(); + expect(observedKernelTimeouts.length).toBeGreaterThanOrEqual(3); + expect(new Set(observedKernelTimeouts)).toEqual(new Set([120])); + expect(harness.channel.readinessDeadline).toBe(1_120); + + await vi.advanceTimersByTimeAsync(1); + await Promise.resolve(); + + expect(observedKernelTimeouts.at(-1)).toBe(0); + expect(observedKernelTimeouts.filter((value) => value === 0)).toHaveLength(1); + expect(harness.completeChannel).toHaveBeenCalledOnce(); + const completion = harness.completeChannel.mock.calls[0]!; + expect(completion.slice(0, 3)).toEqual([ + harness.channel, + ABI_SYSCALLS.Poll, + args, + ]); + expect(completion.slice(4, 6)).toEqual([0, 0]); + expect(harness.state.pendingPollRetries.size).toBe(0); + expect(harness.state.blockingRetrySnapshots.has(harness.channel)).toBe( + false, + ); + expect(retainedSnapshot?.dispatch.adjustedArgs[2]).toBe(120); + expect(retainedSnapshot?.dispatch.readinessTimeoutMs).toBe(120); + }); + + it("treats a final zero-time ppoll EAGAIN as timeout after mask cleanup", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_500); + + const observedSyscalls: number[] = []; + const observedPpollTimeouts: number[] = []; + const harness = createHarness((syscallNr, scratch) => { + observedSyscalls.push(syscallNr); + if (syscallNr === ABI_SYSCALLS.Ppoll) { + observedPpollTimeouts.push(Number( + scratch.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true), + )); + return { retVal: -1, errVal: EAGAIN }; } - // Model the kernel's next nonblocking check returning EAGAIN again. - worker.handleBlockingRetry(channel, ABI_SYSCALLS.Poll, args); + expect(syscallNr).toBe(ABI_SYSCALLS.ThreadCancel); + return { retVal: 0, errVal: 0 }; }); + const pollPointer = 1024; + const timeoutPointer = 2048; + const maskPointer = 3072; + const processView = new DataView(harness.processMemory.buffer); + processView.setInt32(pollPointer, -1, true); + processView.setInt16(pollPointer + 4, 0x001, true); + processView.setBigInt64(timeoutPointer, 0n, true); + processView.setBigInt64(timeoutPointer + 8, 10_000_000n, true); + processView.setBigUint64(maskPointer, 0x80n, true); + const args = syscallArgs( + pollPointer, + 1, + timeoutPointer, + maskPointer, + SIGNAL_MASK_BYTES, + ); - worker.handleBlockingRetry(channel, ABI_SYSCALLS.Poll, args); - expect(channel.readinessDeadline).toBe(1_120); + dispatchSyscall(harness, ABI_SYSCALLS.Ppoll, args); + const retainedSnapshot = harness.state.blockingRetrySnapshots.get( + harness.channel, + ); + expect(retainedSnapshot?.dispatch.adjustedArgs[2]).toBe(10); + expect(retainedSnapshot?.dispatch.readinessTimeoutMs).toBe(10); - vi.advanceTimersByTime(119); - expect(worker.completeChannel).not.toHaveBeenCalled(); - expect(observedDeadlines).toEqual([1_120, 1_120]); + await vi.advanceTimersByTimeAsync(10); + await Promise.resolve(); - vi.advanceTimersByTime(1); - expect(observedDeadlines).toEqual([1_120, 1_120, 1_120, 1_120]); - expect(finalChecks).toBe(1); - expect(worker.completeChannel).toHaveBeenCalledOnce(); - expect(worker.completeChannel.mock.calls[0].slice(-2)).toEqual([0, 0]); + expect(observedPpollTimeouts.at(-1)).toBe(0); + expect( + observedPpollTimeouts.filter((timeout) => timeout === 0), + ).toHaveLength(1); + expect(observedSyscalls.at(-1)).toBe(ABI_SYSCALLS.ThreadCancel); + expect( + observedSyscalls.filter( + (syscall) => syscall === ABI_SYSCALLS.ThreadCancel, + ), + ).toHaveLength(1); + expect(harness.completeChannel).toHaveBeenCalledOnce(); + expect(harness.completeChannel.mock.calls[0]!.slice(4, 6)).toEqual([ + 0, + 0, + ]); + expect(harness.blockingRetryToken).toHaveBeenCalledOnce(); + expect(harness.blockingRetryRelease).not.toHaveBeenCalled(); + expect(harness.state.pendingPollRetries.size).toBe(0); + expect(harness.state.blockingRetrySnapshots.has(harness.channel)).toBe( + false, + ); + expect(retainedSnapshot?.retryToken).toBe(0n); + expect(retainedSnapshot?.dispatch.adjustedArgs[2]).toBe(10); + expect(retainedSnapshot?.dispatch.readinessTimeoutMs).toBe(10); }); - it("postpones a signal-safe pselect fallback until the deferred wake", () => { + it("postpones a signal-safe pselect fallback until the deferred wake", async () => { vi.useFakeTimers(); vi.setSystemTime(2_000); - const channel: any = { - pid: 42, - channelOffset: 64, - memory: createSharedMemory(), - }; - const earlyFallback = vi.fn(); - const entry: any = { - timer: setTimeout(earlyFallback, 1), - channel, - origArgs: [1, 0, 0, 0, 0, 0], + const harness = createHarness(() => ({ + retVal: -1, + errVal: EAGAIN, + })); + const retrySyscall = vi.fn(); + harness.worker.testAuthority.configureScratchBoundaryHooksForTest({ + retrySyscall, + }); + const readfdsPointer = 1024; + const timespecPointer = 2048; + const maskDescriptorPointer = 3072; + const maskPointer = 4096; + const processView = new DataView(harness.processMemory.buffer); + processView.setUint8(readfdsPointer, 1); + processView.setBigInt64(timespecPointer, 0n, true); + processView.setBigInt64(timespecPointer + 8, 100_000_000n, true); + processView.setUint32(maskDescriptorPointer, maskPointer, true); + processView.setUint32( + maskDescriptorPointer + 4, + SIGNAL_MASK_BYTES, + true, + ); + const args = syscallArgs( + 1, + readfdsPointer, + 0, + 0, + timespecPointer, + maskDescriptorPointer, + ); + + dispatchSyscall(harness, ABI_SYSCALLS.Pselect6, args); + + const initialEntry = harness.state.pendingSelectRetries.get( + harness.channel, + ); + expect(initialEntry).toMatchObject({ deadline: 2_100, needsSignalSafeWake: true, - syscallNr: ABI_SYSCALLS.Pselect6, - }; - const worker: any = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - processes: new Map([[channel.pid, { channels: [channel] }]]), - pendingPollRetries: new Map(), - pendingSelectRetries: new Map([[channel, entry]]), - pendingPipeReaders: new Map(), - pendingPipeWriters: new Map(), - wakeScheduled: false, }); - worker.handlePselect6 = vi.fn(); - worker.wakeAllBlockedRetries = vi.fn(); - - worker.scheduleWakeBlockedRetriesDeferred(); - - expect(entry.deadline).toBe(2_100); - vi.advanceTimersByTime(49); - expect(earlyFallback).not.toHaveBeenCalled(); - expect(worker.handlePselect6).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(1); - expect(earlyFallback).not.toHaveBeenCalled(); - expect(worker.handlePselect6).toHaveBeenCalledOnce(); - expect(worker.handlePselect6).toHaveBeenCalledWith(channel, entry.origArgs); - expect(worker.pendingSelectRetries.has(channel)).toBe(false); - expect(worker.wakeAllBlockedRetries).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(20); + harness.queueReadableWake(); + harness.worker.testAuthority.drainWakeupEventsForTest(); + + expect( + harness.state.pendingSelectRetries.get(harness.channel), + ).toBe(initialEntry); + expect(initialEntry?.deadline).toBe(2_100); + await vi.advanceTimersByTimeAsync(30); + expect(retrySyscall).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(19); + expect(retrySyscall).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(retrySyscall).toHaveBeenCalledOnce(); + expect(retrySyscall).toHaveBeenCalledWith(harness.channel); + expect(harness.state.pendingSelectRetries.has(harness.channel)).toBe(false); + expect(harness.handleChannel).toHaveBeenCalledOnce(); + expect(harness.drainWakeupEvents).toHaveBeenCalledOnce(); }); + }); describe("host-emulated epoll signal delivery", () => { it("interrupts epoll with EINTR after copying a caught handler signal", () => { - const harness = createEpollSignalHarness(15, 0); + const harness = createHarness(); + initializeEpoll(harness, true); + harness.setHandlerSignal(15); + const args = syscallArgs(7, 4096, 1, 1000, 0, SIGNAL_MASK_BYTES); - harness.worker.handleEpollPwait( - harness.channel, - ABI_SYSCALLS.EpollPwait, - [7, 4096, 1, 1000, 0, 8], - ); + dispatchSyscall(harness, ABI_SYSCALLS.EpollPwait, args); expect(harness.dequeueSignal).toHaveBeenCalledWith( - harness.channel.pid, - harness.channel.pid, - harness.scratchPointer + CH_SIG_BASE, + PID, + PID, + SCRATCH_POINTER + CH_SIG_BASE, KERNEL_SCRATCH_SIGNAL_DELIVERY_BYTES, ); expect( new DataView(harness.processMemory.buffer).getUint32(CH_SIG_BASE, true), ).toBe(15); - expect(harness.completeChannelRaw).toHaveBeenCalledWith(harness.channel, -4, 4); + expect(readRawCompletion(harness.channel)).toEqual({ + retVal: -EINTR, + errVal: EINTR, + status: CHANNEL_STATUS_COMPLETE, + }); expect(harness.relistenChannel).toHaveBeenCalledWith(harness.channel); - expect(harness.handleProcessTerminated).not.toHaveBeenCalled(); + expect(harness.onExit).not.toHaveBeenCalled(); + expect(harness.handleChannel).toHaveBeenCalledOnce(); }); it("reaps a default signal death without waking guest epoll code", () => { - const harness = createEpollSignalHarness(0, 11, false); + const harness = createHarness(); + initializeEpoll(harness, false); + harness.setExitSignal(11); + const args = syscallArgs(7, 4096, 1, 1000, 0, SIGNAL_MASK_BYTES); - harness.worker.handleEpollPwait( - harness.channel, - ABI_SYSCALLS.EpollPwait, - [7, 4096, 1, 1000, 0, 8], - ); + dispatchSyscall(harness, ABI_SYSCALLS.EpollPwait, args); - expect(harness.handleProcessTerminated).toHaveBeenCalledWith(harness.channel); - expect(harness.completeChannelRaw).not.toHaveBeenCalled(); + expect(harness.onExit).toHaveBeenCalledWith(PID, 128 + 11); + expect(harness.state.hostReaped.has(PID)).toBe(true); + expect(harness.completeChannel).not.toHaveBeenCalled(); expect(harness.relistenChannel).not.toHaveBeenCalled(); - expect(harness.worker.pendingPollRetries.size).toBe(0); + expect(harness.state.pendingPollRetries.size).toBe(0); expect(harness.handleChannel).not.toHaveBeenCalled(); + expect( + new DataView(harness.processMemory.buffer).getUint32(CH_STATUS, true), + ).toBe(CHANNEL_STATUS_PENDING); }); }); - -function createEpollSignalHarness( - handlerSignal: number, - exitSignal: number, - hasInterest = true, -) { - const kernelMemory = createSharedMemory(2); - const processMemory = createSharedMemory(2); - const channel: any = { - pid: 42, - channelOffset: 0, - memory: processMemory, - }; - const dequeueSignal = vi.fn(( - _pid: number, - _tid: number, - outPtr: number, - _outCapacity: number, - ) => { - if (handlerSignal > 0) { - new DataView(kernelMemory.buffer).setUint32(outPtr, handlerSignal, true); - } - return handlerSignal; - }); - const completeChannelRaw = vi.fn(); - const relistenChannel = vi.fn(); - const handleProcessTerminated = vi.fn(); - const handleChannel = vi.fn(() => 0); - const worker: any = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - kernel: { toKernelPtr: (value: number | bigint) => Number(value) }, - kernelInstance: { - exports: { - kernel_handle_channel: handleChannel, - kernel_dequeue_signal: dequeueSignal, - kernel_get_process_exit_signal: vi.fn(() => exitSignal), - }, - }, - kernelMemory, - processes: new Map([[channel.pid, { - channels: [channel], - ptrWidth: 4, - }]]), - currentHandlePid: 0, - channelTids: new Map([["42:0", 42]]), - epollInterests: new Map([ - ["42:7", hasInterest ? [{ fd: 3, events: 0x001, data: 99n }] : []], - ]), - pendingPollRetries: new Map(), - pendingSleeps: new Map(), - bindKernelTidForChannel: vi.fn(), - completeChannelRaw, - relistenChannel, - handleProcessTerminated, - }); - const scratchPointer = installKernelWorkerTestScratch(worker, kernelMemory); - return { - channel, - completeChannelRaw, - dequeueSignal, - handleChannel, - handleProcessTerminated, - processMemory, - relistenChannel, - scratchPointer, - worker, - }; -} diff --git a/host/test/select-signal-outcome.test.ts b/host/test/select-signal-outcome.test.ts index 08aa9fb58e..7ba5b9db66 100644 --- a/host/test/select-signal-outcome.test.ts +++ b/host/test/select-signal-outcome.test.ts @@ -1,25 +1,84 @@ import { describe, expect, it, vi } from "vitest"; import { ABI_SYSCALLS, + CHANNEL_STATUS_PENDING, + CH_ARGS, + CH_ARGS_COUNT, + CH_ARG_SIZE, CH_ERRNO, CH_RETURN, CH_SIG_BASE, + CH_STATUS, + CH_SYSCALL, } from "../src/generated/abi"; -import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { + createKernelEntryGatedInstance, + KernelEntryGate, +} from "../src/kernel-entry-gate"; +import { + createCentralizedKernelWorkerTestDouble, +} from "../src/kernel-worker"; import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; +import { + createKernelScratchTestInstance, +} from "./support/kernel-scratch-instance"; const EAGAIN = 11; const EINTR = 4; +const PID = 42; +const SCRATCH_POINTER = 128; + +interface TestChannel { + readonly pid: number; + readonly memory: WebAssembly.Memory; + readonly channelOffset: number; +} -function createSharedMemory(pages = 2): WebAssembly.Memory { - return new WebAssembly.Memory({ initial: pages, maximum: pages, shared: true }); +interface SelectState { + readonly hostReaped: Set; + readonly pendingSelectRetries: Map; +} + +function createSharedMemory(): WebAssembly.Memory { + return new WebAssembly.Memory({ + initial: 2, + maximum: 2, + shared: true, + }); +} + +function syscallArgs(...values: number[]): number[] { + return Array.from( + { length: CH_ARGS_COUNT }, + (_, index) => values[index] ?? 0, + ); +} + +function writeSyscall( + channel: TestChannel, + syscallNr: number, + args: readonly number[], +): void { + const view = new DataView( + channel.memory.buffer, + channel.channelOffset, + ); + view.setUint32(CH_STATUS, CHANNEL_STATUS_PENDING, true); + view.setUint32(CH_SYSCALL, syscallNr, true); + for (let index = 0; index < CH_ARGS_COUNT; index++) { + view.setBigInt64( + CH_ARGS + index * CH_ARG_SIZE, + BigInt(args[index] ?? 0), + true, + ); + } } function createHarness(options: { - handlerSignal?: number; - exitSignal?: number; - returnValue?: number; - errno?: number; + readonly handlerSignal?: number; + readonly exitSignal?: number; + readonly returnValue?: number; + readonly errno?: number; } = {}) { const handlerSignal = options.handlerSignal ?? 0; const exitSignal = options.exitSignal ?? -1; @@ -27,145 +86,244 @@ function createHarness(options: { const errno = options.errno ?? EAGAIN; const kernelMemory = createSharedMemory(); const processMemory = createSharedMemory(); - const channel: any = { - pid: 42, - channelOffset: 0, - memory: processMemory, - }; - const handleChannel = vi.fn((offset: number) => { - const view = new DataView(kernelMemory.buffer, offset); + + const handleChannel = vi.fn((pointer: number | bigint) => { + const view = new DataView(kernelMemory.buffer, Number(pointer)); view.setBigInt64(CH_RETURN, BigInt(returnValue), true); view.setUint32(CH_ERRNO, errno, true); return 0; }); - const dequeueSignal = vi.fn((_pid: number, _tid: number, outPtr: number) => { - if (handlerSignal > 0) { - new DataView(kernelMemory.buffer).setUint32(outPtr, handlerSignal, true); - } + const dequeueSignal = vi.fn(( + _pid: number, + _tid: number, + pointer: number | bigint, + capacity: number, + ) => { + if (handlerSignal <= 0) return 0; + const output = new Uint8Array( + kernelMemory.buffer, + Number(pointer), + capacity, + ); + output.fill(0); + new DataView( + kernelMemory.buffer, + Number(pointer), + capacity, + ).setUint32(0, handlerSignal, true); return handlerSignal; }); const setCurrentTid = vi.fn(() => 0); + const implementations: Record = { + kernel_blocking_retry_release: () => 0, + kernel_blocking_retry_token: () => 0n, + kernel_dequeue_signal: dequeueSignal, + kernel_drain_wakeup_events: vi.fn(() => 0), + kernel_get_parent_pid: vi.fn(() => 0), + kernel_get_process_exit_signal: vi.fn(() => exitSignal), + kernel_handle_channel: handleChannel, + kernel_set_current_tid: setCurrentTid, + }; + const gate = new KernelEntryGate(); + const kernelInstance = createKernelEntryGatedInstance( + createKernelScratchTestInstance( + 4, + kernelMemory, + () => implementations, + () => SCRATCH_POINTER, + 4, + [ + "kernel_blocking_retry_release", + "kernel_blocking_retry_token", + "kernel_dequeue_signal", + "kernel_drain_wakeup_events", + "kernel_get_parent_pid", + "kernel_get_process_exit_signal", + "kernel_handle_channel", + "kernel_set_current_tid", + ], + ), + gate, + ); + const onExit = vi.fn(); + const worker = createCentralizedKernelWorkerTestDouble({ + callbacks: { onExit }, + }); + installKernelWorkerTestScratch( + worker, + kernelMemory, + SCRATCH_POINTER, + 4, + { boundInstance: kernelInstance, gate }, + ); + const completeChannel = vi.fn(); - const handleProcessTerminated = vi.fn(); - const worker: any = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - kernel: { toKernelPtr: (value: number | bigint) => Number(value) }, - kernelInstance: { - exports: { - kernel_handle_channel: handleChannel, - kernel_dequeue_signal: dequeueSignal, - kernel_get_process_exit_signal: vi.fn(() => exitSignal), - kernel_set_current_tid: setCurrentTid, - }, + worker.testAuthority.configureScratchBoundaryHooksForTest({ + completeChannel: ( + channel, + syscallNr, + origArgs, + argDescs, + retVal, + errVal, + ) => { + completeChannel( + channel, + syscallNr, + origArgs, + argDescs, + retVal, + errVal, + ); }, - kernelMemory, - currentHandlePid: 0, - processes: new Map([ - [42, { pid: 42, memory: processMemory, channels: [channel], ptrWidth: 4 }], - ]), - activeChannels: [channel], - channelTids: new Map([["42:0", 43]]), - pendingSelectRetries: new Map(), - pendingPollRetries: new Map(), - pendingSleeps: new Map(), - pendingPipeReaders: new Map(), - pendingPipeWriters: new Map(), - completeChannel, - handleProcessTerminated, }); - installKernelWorkerTestScratch(worker, kernelMemory); + const [registeredChannel] = + worker.testAuthority.replaceProcessRegistrationForLifecycleTest({ + pid: PID, + memory: processMemory, + channelOffsets: [0], + pointerWidth: 4, + }); + const channel = registeredChannel as TestChannel; return { channel, completeChannel, dequeueSignal, handleChannel, - handleProcessTerminated, + onExit, processMemory, setCurrentTid, + state: worker as unknown as SelectState, worker, }; } +function dispatchSyscall( + harness: ReturnType, + syscallNr: number, + args: readonly number[], +): void { + writeSyscall(harness.channel, syscallNr, args); + harness.worker.testAuthority.dispatchScratchBoundarySyscallForTest( + harness.channel, + ); +} + +function expectCompletion( + harness: ReturnType, + syscallNr: number, + args: readonly number[], + retVal: number, + errVal: number, +): void { + expect(harness.completeChannel).toHaveBeenCalledOnce(); + const completion = harness.completeChannel.mock.calls[0]!; + expect(completion.slice(0, 3)).toEqual([ + harness.channel, + syscallNr, + args, + ]); + expect(completion.slice(4, 6)).toEqual([retVal, errVal]); +} + describe("select and pselect signal outcomes", () => { it("returns EINTR instead of re-parking pselect after a caught signal", () => { const harness = createHarness({ handlerSignal: 10 }); - const readfdsPtr = 1024; - const timespecPtr = 2048; + const readfdsPointer = 1024; + const timespecPointer = 2048; const view = new DataView(harness.processMemory.buffer); - view.setUint8(readfdsPtr, 1); - view.setBigInt64(timespecPtr, 1n, true); - view.setBigInt64(timespecPtr + 8, 0n, true); - const args = [1, readfdsPtr, 0, 0, timespecPtr, 0]; + view.setUint8(readfdsPointer, 1); + view.setBigInt64(timespecPointer, 1n, true); + view.setBigInt64(timespecPointer + 8, 0n, true); + const args = syscallArgs( + 1, + readfdsPointer, + 0, + 0, + timespecPointer, + 0, + ); - harness.worker.handlePselect6(harness.channel, args); + dispatchSyscall(harness, ABI_SYSCALLS.Pselect6, args); - expect(harness.completeChannel).toHaveBeenCalledWith( - harness.channel, + expectCompletion( + harness, ABI_SYSCALLS.Pselect6, args, - undefined, -1, EINTR, ); - expect(harness.worker.pendingSelectRetries.size).toBe(0); - expect(harness.setCurrentTid).toHaveBeenCalledWith(42, 43); + expect(harness.state.pendingSelectRetries.size).toBe(0); + expect(harness.setCurrentTid).toHaveBeenCalledWith(PID, PID); expect(harness.setCurrentTid.mock.invocationCallOrder.at(-1)).toBeLessThan( - harness.dequeueSignal.mock.invocationCallOrder[0], + harness.dequeueSignal.mock.invocationCallOrder[0]!, ); }); it("interrupts the pure-sleep select fast path without entering the kernel", () => { const harness = createHarness({ handlerSignal: 12 }); - const args = [0, 0, 0, 0, 0]; + const args = syscallArgs(0, 0, 0, 0, 0); - harness.worker.handleSelect(harness.channel, args); + dispatchSyscall(harness, ABI_SYSCALLS.Select, args); expect(harness.handleChannel).not.toHaveBeenCalled(); - expect(harness.completeChannel).toHaveBeenCalledWith( - harness.channel, + expectCompletion( + harness, ABI_SYSCALLS.Select, args, - undefined, -1, EINTR, ); - expect(harness.worker.pendingSelectRetries.size).toBe(0); + expect(harness.state.pendingSelectRetries.size).toBe(0); }); it("re-parks pure-sleep select when no caught signal is delivered", () => { const harness = createHarness(); + const args = syscallArgs(0, 0, 0, 0, 0); - harness.worker.handleSelect(harness.channel, [0, 0, 0, 0, 0]); + dispatchSyscall(harness, ABI_SYSCALLS.Select, args); expect(harness.completeChannel).not.toHaveBeenCalled(); - expect(harness.worker.pendingSelectRetries.has(harness.channel)).toBe(true); + expect( + harness.state.pendingSelectRetries.has(harness.channel), + ).toBe(true); }); it("reaps a default signal death without waking select guest code", () => { const harness = createHarness({ exitSignal: 15 }); + const args = syscallArgs(0, 0, 0, 0, 0); - harness.worker.handleSelect(harness.channel, [0, 0, 0, 0, 0]); + dispatchSyscall(harness, ABI_SYSCALLS.Select, args); - expect(harness.handleProcessTerminated).toHaveBeenCalledWith(harness.channel); + expect(harness.onExit).toHaveBeenCalledWith(PID, 128 + 15); + expect(harness.state.hostReaped.has(PID)).toBe(true); expect(harness.completeChannel).not.toHaveBeenCalled(); - expect(harness.worker.pendingSelectRetries.size).toBe(0); + expect(harness.state.pendingSelectRetries.size).toBe(0); + expect( + new DataView(harness.processMemory.buffer).getUint32(CH_STATUS, true), + ).toBe(CHANNEL_STATUS_PENDING); }); it("preserves a ready select result when a handler signal arrives concurrently", () => { - const harness = createHarness({ handlerSignal: 10, returnValue: 1, errno: 0 }); - const args = [1, 1024, 0, 0, 0]; - new DataView(harness.processMemory.buffer).setUint8(1024, 1); + const harness = createHarness({ + handlerSignal: 10, + returnValue: 1, + errno: 0, + }); + const readfdsPointer = 1024; + const args = syscallArgs(1, readfdsPointer, 0, 0, 0); + new DataView(harness.processMemory.buffer).setUint8(readfdsPointer, 1); - harness.worker.handleSelect(harness.channel, args); + dispatchSyscall(harness, ABI_SYSCALLS.Select, args); expect( new DataView(harness.processMemory.buffer).getUint32(CH_SIG_BASE, true), ).toBe(10); - expect(harness.completeChannel).toHaveBeenCalledWith( - harness.channel, + expectCompletion( + harness, ABI_SYSCALLS.Select, args, - undefined, 1, 0, ); diff --git a/host/test/shared-memory-coherence.test.ts b/host/test/shared-memory-coherence.test.ts index be50ab1b51..334b71416d 100644 --- a/host/test/shared-memory-coherence.test.ts +++ b/host/test/shared-memory-coherence.test.ts @@ -1,11 +1,43 @@ import { describe, expect, it, vi } from "vitest"; -import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { + ABI_SYSCALLS, + CH_ARGS, + CH_ARG_SIZE, + CH_ERRNO, + CH_RETURN, + CH_STATUS, + CH_SYSCALL, + CH_TOTAL_SIZE, + CHANNEL_STATUS_COMPLETE, +} from "../src/generated/abi"; +import { + createCentralizedKernelWorkerTestDouble, +} from "../src/kernel-worker"; import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; function sharedMemory(): WebAssembly.Memory { return new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); } +function writeChannelSyscall( + channel: { memory: WebAssembly.Memory; channelOffset: number }, + syscallNr: number, + args: readonly bigint[], +): void { + const view = new DataView( + channel.memory.buffer, + channel.channelOffset, + ); + view.setUint32(CH_SYSCALL, syscallNr, true); + for (let index = 0; index < 6; index++) { + view.setBigInt64( + CH_ARGS + index * CH_ARG_SIZE, + args[index] ?? 0n, + true, + ); + } +} + function anonymousHarness() { const parentPid = 41; const peerPid = 42; @@ -41,7 +73,7 @@ function anonymousHarness() { const parentChannel = channel(parentPid, parentMemory); const peerChannel = channel(peerPid, peerMemory); const childChannel = channel(childPid, childMemory); - const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + const kw = Object.assign(createCentralizedKernelWorkerTestDouble(), { anonymousSharedBackings: new Map([[key, backing]]), sharedMappings: new Map([ [parentPid, new Map([[mapAddr, mapping()]])], @@ -53,7 +85,14 @@ function anonymousHarness() { [peerPid, { pid: peerPid, memory: peerMemory, channels: [peerChannel] }], [childPid, { pid: childPid, memory: childMemory, channels: [childChannel] }], ]), - }) as CentralizedKernelWorker; + }); + installKernelWorkerTestScratch( + kw as unknown as Record, + new WebAssembly.Memory({ initial: 2, maximum: 2 }), + 128, + 4, + { kernelExportNames: [] }, + ); return { backing, childMemory, @@ -177,24 +216,21 @@ describe("anonymous MAP_SHARED coherence", () => { const process = { pid, memory: sharedMemory() }; const processes = new Map([[pid, process]]); const getProcess = vi.spyOn(processes, "get"); - const syncAnonymous = vi.fn(); - const syncFile = vi.fn(); - const syncSysv = vi.fn(); - const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + const sharedMappings = new Map>(); + const shmMappings = new Map>(); + const getPosixMappings = vi.spyOn(sharedMappings, "get"); + const getSysvMappings = vi.spyOn(shmMappings, "get"); + const kw = Object.assign(createCentralizedKernelWorkerTestDouble(), { processes, - sharedMappings: new Map(), - shmMappings: new Map(), - syncAnonymousSharedMappingsFromProcess: syncAnonymous, - syncFileSharedMappingsFromProcess: syncFile, - syncSysvShmMappingsFromProcess: syncSysv, - }) as CentralizedKernelWorker; + sharedMappings, + shmMappings, + }); (kw as any).synchronizeSharedMemoryForBoundary(process); expect(getProcess).toHaveBeenCalledWith(pid); - expect(syncAnonymous).not.toHaveBeenCalled(); - expect(syncFile).not.toHaveBeenCalled(); - expect(syncSysv).not.toHaveBeenCalled(); + expect(getPosixMappings).not.toHaveBeenCalled(); + expect(getSysvMappings).not.toHaveBeenCalled(); }); it.each(["POSIX", "SysV"])( @@ -202,27 +238,30 @@ describe("anonymous MAP_SHARED coherence", () => { (mappingKind) => { const pid = 52; const process = { pid, memory: sharedMemory() }; - const syncAnonymous = vi.fn(); - const syncFile = vi.fn(); - const syncSysv = vi.fn(); - const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + const sharedMappings = mappingKind === "POSIX" + ? new Map([[pid, new Map()]]) + : new Map>(); + const shmMappings = mappingKind === "SysV" + ? new Map([[pid, new Map()]]) + : new Map>(); + const getPosixMappings = vi.spyOn(sharedMappings, "get"); + const getSysvMappings = vi.spyOn(shmMappings, "get"); + const kw = Object.assign(createCentralizedKernelWorkerTestDouble(), { processes: new Map([[pid, process]]), - sharedMappings: mappingKind === "POSIX" - ? new Map([[pid, new Map([[0x1000, {}]])]]) - : new Map(), - shmMappings: mappingKind === "SysV" - ? new Map([[pid, new Map([[0x2000, {}]])]]) - : new Map(), - syncAnonymousSharedMappingsFromProcess: syncAnonymous, - syncFileSharedMappingsFromProcess: syncFile, - syncSysvShmMappingsFromProcess: syncSysv, - }) as CentralizedKernelWorker; + sharedMappings, + shmMappings, + }); (kw as any).synchronizeSharedMemoryForBoundary(process); - expect(syncAnonymous).toHaveBeenCalledWith(process); - expect(syncFile).toHaveBeenCalledWith(process); - expect(syncSysv).toHaveBeenCalledWith(process); + // Anonymous and file-backed POSIX scans share the same per-pid map; + // SysV has its own map. Observing the real map reads proves all three + // production scans ran without replacing authority-bearing methods. + expect(getPosixMappings).toHaveBeenCalledTimes(2); + expect(getPosixMappings).toHaveBeenNthCalledWith(1, pid); + expect(getPosixMappings).toHaveBeenNthCalledWith(2, pid); + expect(getSysvMappings).toHaveBeenCalledOnce(); + expect(getSysvMappings).toHaveBeenCalledWith(pid); }, ); }); @@ -235,11 +274,33 @@ function sysvHarness() { const memories = new Map(pids.map((pid) => [pid, sharedMemory()])); const kernelMemory = new WebAssembly.Memory({ initial: 2 }); const segment = new Uint8Array(size); + const syntheticMemorySyscalls: Array<{ + syscallNr: number; + args: bigint[]; + }> = []; const shmat = vi.fn(() => size); const shmdt = vi.fn(() => 0); const shmatForTask = vi.fn(() => size); const shmdtForTask = vi.fn(() => 0); const validateTask = vi.fn(() => 0); + const handleChannel = vi.fn((channelPtr: number | bigint) => { + const view = new DataView( + kernelMemory.buffer, + Number(channelPtr), + CH_TOTAL_SIZE, + ); + syntheticMemorySyscalls.push({ + syscallNr: view.getUint32(CH_SYSCALL, true), + args: Array.from( + { length: 6 }, + (_, index) => + view.getBigInt64(CH_ARGS + index * CH_ARG_SIZE, true), + ), + }); + view.setBigInt64(CH_RETURN, -1n, true); + view.setUint32(CH_ERRNO, 12, true); + return 0; + }); const readChunk = vi.fn((id: number, offset: number, outPtr: number, maxLen: number) => { expect(id).toBe(segId); const len = Math.min(maxLen, segment.length - offset); @@ -262,22 +323,9 @@ function sysvHarness() { const memory = memories.get(pid)!; return [pid, { pid, memory, channels: [{ pid, memory, channelOffset: 0 }] }]; })); - const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + const kw = Object.assign(createCentralizedKernelWorkerTestDouble(), { currentHandlePid: 0, channelTids: new Map(), - kernel: { toKernelPtr: (value: number | bigint) => Number(value) }, - kernelMemory, - kernelInstance: { - exports: { - kernel_ipc_shmat_for_process: shmat, - kernel_ipc_shmat_for_task: shmatForTask, - kernel_ipc_shmdt_for_process: shmdt, - kernel_ipc_shmdt_for_task: shmdtForTask, - kernel_ipc_shm_read_chunk: readChunk, - kernel_ipc_shm_write_chunk: writeChunk, - kernel_validate_task: validateTask, - }, - }, processes, sharedMappings: new Map(), anonymousSharedBackings: new Map(), @@ -286,16 +334,46 @@ function sysvHarness() { [pids[1], new Map([[mapAddr, mapping()]])], ]), shmSegmentVersions: new Map([[segId, 0]]), - }) as CentralizedKernelWorker; + }); installKernelWorkerTestScratch( kw as unknown as Record, kernelMemory, + 128, + 4, + { + kernelExports: { + kernel_get_process_exit_signal: () => 0, + kernel_handle_channel: handleChannel, + kernel_ipc_shmat_for_process: shmat, + kernel_ipc_shmat_for_task: shmatForTask, + kernel_ipc_shmdt_for_process: shmdt, + kernel_ipc_shmdt_for_task: shmdtForTask, + kernel_ipc_shm_read_chunk: readChunk, + kernel_ipc_shm_write_chunk: writeChunk, + kernel_set_current_tid: () => 0, + kernel_validate_task: validateTask, + }, + kernelExportNames: [ + "kernel_get_process_exit_signal", + "kernel_handle_channel", + "kernel_ipc_shmat_for_process", + "kernel_ipc_shmat_for_task", + "kernel_ipc_shmdt_for_process", + "kernel_ipc_shmdt_for_task", + "kernel_ipc_shm_read_chunk", + "kernel_ipc_shm_write_chunk", + "kernel_set_current_tid", + "kernel_validate_task", + ], + }, ); return { kw, + handleChannel, mapAddr, memories, pids, + readChunk, segment, segId, shmat, @@ -303,7 +381,9 @@ function sysvHarness() { shmdt, shmdtForTask, size, + syntheticMemorySyscalls, validateTask, + writeChunk, }; } @@ -381,17 +461,20 @@ describe("SysV SHM coherence and lifecycle", () => { it("rolls back kernel nattch when host mmap allocation fails", () => { const h = sysvHarness(); - const complete = vi.fn(); const relisten = vi.fn(); - Object.assign(h.kw as any, { - shmMappings: new Map(), - runSyntheticMemorySyscall: vi.fn(() => ({ retVal: -1, errVal: 12 })), - completeChannelRaw: complete, + (h.kw as any).shmMappings = new Map(); + h.kw.testAuthority.configureScratchBoundaryHooksForTest({ relistenChannel: relisten, }); const channel = (h.kw as any).processes.get(h.pids[2]).channels[0]; - (h.kw as any).handleIpcShmat(channel, [h.segId, 0, 0]); + writeChannelSyscall( + channel, + ABI_SYSCALLS.Shmat, + [BigInt(h.segId), 0n, 0n], + ); + h.kw.testAuthority.dispatchScratchBoundarySyscallForTest(channel); + expect(h.validateTask).toHaveBeenCalledWith(h.pids[2], h.pids[2]); expect(h.shmatForTask).toHaveBeenCalledWith( h.pids[2], @@ -401,23 +484,41 @@ describe("SysV SHM coherence and lifecycle", () => { 0, ); expect(h.shmdt).toHaveBeenCalledTimes(1); - expect(complete).toHaveBeenCalledWith(channel, -12, 12); + const view = new DataView(channel.memory.buffer, channel.channelOffset); + expect(Number(view.getBigInt64(CH_RETURN, true))).toBe(-12); + expect(view.getUint32(CH_ERRNO, true)).toBe(12); + expect(view.getUint32(CH_STATUS, true)).toBe(CHANNEL_STATUS_COMPLETE); expect(relisten).toHaveBeenCalledWith(channel); }); it("rejects a stale task before changing kernel or host attachment state", () => { const h = sysvHarness(); h.validateTask.mockReturnValue(-3); - const syncSegment = vi.fn(); - (h.kw as any).syncSysvShmSegmentFromMappedProcesses = syncSegment; const channel = (h.kw as any).processes.get(h.pids[2]).channels[0]; + writeChannelSyscall( + channel, + ABI_SYSCALLS.Shmat, + [BigInt(h.segId), 0n, 0n], + ); - expect(() => { - (h.kw as any).handleIpcShmat(channel, [h.segId, 0, 0]); - }).toThrow(/rejected tid/); + let failure: unknown; + try { + h.kw.testAuthority.dispatchScratchBoundarySyscallForTest(channel); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toMatch( + /void kernel ingress scratch-boundary test syscall failed/, + ); + expect((failure as Error & { cause?: unknown }).cause).toBeInstanceOf(Error); + expect( + ((failure as Error & { cause: Error }).cause).message, + ).toMatch(/rejected tid/); expect(h.validateTask).toHaveBeenCalledWith(h.pids[2], h.pids[2]); - expect(syncSegment).not.toHaveBeenCalled(); + expect(h.readChunk).not.toHaveBeenCalled(); + expect(h.writeChunk).not.toHaveBeenCalled(); expect(h.shmatForTask).not.toHaveBeenCalled(); expect((h.kw as any).shmMappings.has(h.pids[2])).toBe(false); }); @@ -426,22 +527,19 @@ describe("SysV SHM coherence and lifecycle", () => { const h = sysvHarness(); const process = (h.kw as any).processes.get(h.pids[2]); process.ptrWidth = 8; - const complete = vi.fn(); const relisten = vi.fn(); - const mmap = vi.fn(() => ({ retVal: -1, errVal: 12 })); - Object.assign(h.kw as any, { - completeChannelRaw: complete, + h.kw.testAuthority.configureScratchBoundaryHooksForTest({ relistenChannel: relisten, - runSyntheticMemorySyscall: mmap, }); const channel = process.channels[0]; const highHint = 0x1_0000_0000n; - (h.kw as any).handleIpcShmat( + writeChannelSyscall( channel, - [h.segId, Number(highHint), 0], + ABI_SYSCALLS.Shmat, [BigInt(h.segId), highHint, 0n], ); + h.kw.testAuthority.dispatchScratchBoundarySyscallForTest(channel); // The legacy kernel attachment helper does not own the process mapping // address, but the host mmap path must retain every wasm64 pointer bit. @@ -452,8 +550,13 @@ describe("SysV SHM coherence and lifecycle", () => { 0, 0, ); - expect(mmap.mock.calls[0]?.[2]?.[0]).toBe(Number(highHint)); - expect(complete).toHaveBeenCalledWith(channel, -12, 12); + expect(h.syntheticMemorySyscalls).toHaveLength(1); + expect(h.syntheticMemorySyscalls[0]?.syscallNr).toBe(ABI_SYSCALLS.Mmap); + expect(h.syntheticMemorySyscalls[0]?.args[0]).toBe(highHint); + const view = new DataView(channel.memory.buffer, channel.channelOffset); + expect(Number(view.getBigInt64(CH_RETURN, true))).toBe(-12); + expect(view.getUint32(CH_ERRNO, true)).toBe(12); + expect(view.getUint32(CH_STATUS, true)).toBe(CHANNEL_STATUS_COMPLETE); expect(relisten).toHaveBeenCalledWith(channel); }); @@ -461,23 +564,26 @@ describe("SysV SHM coherence and lifecycle", () => { const h = sysvHarness(); const process = (h.kw as any).processes.get(h.pids[2]); process.ptrWidth = 8; - const complete = vi.fn(); const relisten = vi.fn(); - Object.assign(h.kw as any, { - completeChannelRaw: complete, + h.kw.testAuthority.configureScratchBoundaryHooksForTest({ relistenChannel: relisten, }); const channel = process.channels[0]; const unsafeHint = BigInt(Number.MAX_SAFE_INTEGER) + 1n; - (h.kw as any).handleIpcShmat( + writeChannelSyscall( channel, - [h.segId, Number(unsafeHint), 0], + ABI_SYSCALLS.Shmat, [BigInt(h.segId), unsafeHint, 0n], ); + h.kw.testAuthority.dispatchScratchBoundarySyscallForTest(channel); expect(h.shmatForTask).not.toHaveBeenCalled(); - expect(complete).toHaveBeenCalledWith(channel, -1, 14); + expect(h.handleChannel).not.toHaveBeenCalled(); + const view = new DataView(channel.memory.buffer, channel.channelOffset); + expect(Number(view.getBigInt64(CH_RETURN, true))).toBe(-1); + expect(view.getUint32(CH_ERRNO, true)).toBe(14); + expect(view.getUint32(CH_STATUS, true)).toBe(CHANNEL_STATUS_COMPLETE); expect(relisten).toHaveBeenCalledWith(channel); }); @@ -485,24 +591,27 @@ describe("SysV SHM coherence and lifecycle", () => { const h = sysvHarness(); const process = (h.kw as any).processes.get(h.pids[0]); process.ptrWidth = 8; - const complete = vi.fn(); const relisten = vi.fn(); - Object.assign(h.kw as any, { - completeChannelRaw: complete, + h.kw.testAuthority.configureScratchBoundaryHooksForTest({ relistenChannel: relisten, }); const channel = process.channels[0]; const highAddress = BigInt(h.mapAddr) + 0x1_0000_0000n; - (h.kw as any).handleIpcShmdt( + writeChannelSyscall( channel, - [Number(highAddress)], + ABI_SYSCALLS.Shmdt, [highAddress], ); + h.kw.testAuthority.dispatchScratchBoundarySyscallForTest(channel); expect((h.kw as any).shmMappings.get(h.pids[0]).has(h.mapAddr)).toBe(true); expect(h.shmdtForTask).not.toHaveBeenCalled(); - expect(complete).toHaveBeenCalledWith(channel, -22, 22); + expect(h.handleChannel).not.toHaveBeenCalled(); + const view = new DataView(channel.memory.buffer, channel.channelOffset); + expect(Number(view.getBigInt64(CH_RETURN, true))).toBe(-22); + expect(view.getUint32(CH_ERRNO, true)).toBe(22); + expect(view.getUint32(CH_STATUS, true)).toBe(CHANNEL_STATUS_COMPLETE); expect(relisten).toHaveBeenCalledWith(channel); }); }); diff --git a/host/test/signal-accept-livelock.test.ts b/host/test/signal-accept-livelock.test.ts index 2ac7517136..2b3955c7b2 100644 --- a/host/test/signal-accept-livelock.test.ts +++ b/host/test/signal-accept-livelock.test.ts @@ -1,140 +1,282 @@ /** - * Regression test for a kernel-worker deadlock: delivering a signal to a - * process that is blocked in a non-interruptible re-parking syscall (notably - * `accept()`, which has no EINTR path) must not livelock. + * Regression coverage for signal and pipe wakeups that snapshot retry maps. * - * `sendSignalToProcess` / `notifyPipeReadable` iterate `pendingPollRetries` - * and, for each matching entry, delete it and synchronously `retrySyscall`. - * A blocked `accept()` re-runs, returns EAGAIN, and re-registers under the - * SAME channel key. A raw `for..of` over the live Map revisits the - * re-inserted entry forever (JS Map iterators are not snapshots), spinning - * the single kernel-worker thread and wedging the whole machine. - * - * Observed as: a forking SMTP daemon (msmtpd) delivering a WordPress - * password-reset / new-blog email. Its master sits in `accept()`; the - * per-connection session child exits and the resulting SIGCHLD delivery - * livelocked the kernel — the reset request (and every other request) hung - * forever. Fix: snapshot the entries before iterating (mirrors the existing - * `wakeBlockedPoll` / `wakeAllBlockedRetries` pattern). + * A blocked accept can synchronously re-park under the same channel key. + * Iterating a live Map after deleting and reinserting that key revisits it + * forever, wedging the dedicated kernel worker. These tests use a genuine + * gated Wasm instance and the sealed worker's exact test authority; they do + * not replace methods or install raw kernel exports on the worker. */ import { describe, expect, it, vi } from "vitest"; -import { CentralizedKernelWorker } from "../src/kernel-worker"; -import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; + +import { + createCentralizedKernelWorkerTestDouble, +} from "../src/kernel-worker"; +import { + createKernelEntryGatedInstance, + KernelEntryGate, +} from "../src/kernel-entry-gate"; import { + allocateKernelScratchRegion, +} from "../src/kernel-scratch"; +import { + CHANNEL_STATUS_PENDING, CH_ARGS, + CH_ARGS_COUNT, CH_ARG_SIZE, CH_ERRNO, CH_REQUEST_FLAGS, CH_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY, CH_RETURN, + CH_SIG_BASE, CH_SIG_SIGNUM, + CH_STATUS, CH_SYSCALL, + CH_TOTAL_SIZE, KERNEL_SCRATCH_SIGNAL_DELIVERY_BYTES, } from "../src/generated/abi"; +import { + createKernelScratchTestInstance, +} from "./support/kernel-scratch-instance"; const SIGCHLD = 17; const SIGTERM = 15; const SYS_TKILL = 204; +const SCRATCH_OFFSET = 4096; + +interface TestChannel { + readonly pid: number; + readonly memory: WebAssembly.Memory; + readonly channelOffset: number; + i32View: Int32Array; + consecutiveSyscalls: number; + handling: boolean; +} + +interface SignalHarness { + readonly worker: ReturnType; + readonly implementations: Record; + readonly completeChannel: ReturnType; + readonly onExit: ReturnType; + readonly onKernelFatal: ReturnType; + readonly kernelMemory: WebAssembly.Memory; +} function createSharedMemory(): WebAssembly.Memory { return new WebAssembly.Memory({ initial: 2, maximum: 2, shared: true }); } -function createChannel(pid: number, channelOffset: number): any { - return { pid, memory: createSharedMemory(), channelOffset }; +function createChannel( + pid: number, + channelOffset = 0, + memory = createSharedMemory(), +): TestChannel { + const channel: TestChannel = { + pid, + memory, + channelOffset, + i32View: new Int32Array( + memory.buffer, + channelOffset, + CH_TOTAL_SIZE / Int32Array.BYTES_PER_ELEMENT, + ), + consecutiveSyscalls: 0, + handling: true, + }; + const view = new DataView(memory.buffer, channelOffset); + view.setUint32(CH_STATUS, CHANNEL_STATUS_PENDING, true); + for (let index = 0; index < CH_ARGS_COUNT; index++) { + view.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, 0n, true); + } + return channel; } -/** A worker whose kernel exports are all inert — signal delivery is - * best-effort host bookkeeping, so the kernel side is a no-op here. */ -function createWorkerHarness(): any { - const kernelMemory = createSharedMemory(); - const worker: any = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - kernel: { toKernelPtr: (v: number | bigint) => (typeof v === "bigint" ? Number(v) : v) }, - kernelInstance: { - exports: { - kernel_handle_channel: () => 0, - kernel_set_current_tid: () => 0, - kernel_pick_signal_target_tid: (pid: number) => pid, - kernel_thread_has_deliverable: () => 1, - kernel_get_process_exit_signal: () => -1, - }, - }, +function createWorkerHarness(): SignalHarness { + const kernelMemory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); + const completeChannel = vi.fn(); + const onExit = vi.fn(); + const onKernelFatal = vi.fn(); + const implementations: Record = {}; + implementations.kernel_set_current_tid = () => 0; + implementations.kernel_handle_channel = ( + pointer: number | bigint, + ) => { + const view = new DataView(kernelMemory.buffer, Number(pointer)); + view.setBigInt64(CH_RETURN, 0n, true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }; + implementations.kernel_pick_signal_target_tid = (pid: number) => pid; + implementations.kernel_thread_has_deliverable = () => 1; + implementations.kernel_get_process_exit_signal = () => -1; + implementations.kernel_get_process_exit_status = () => -1; + implementations.kernel_get_parent_pid = () => 0; + implementations.kernel_dequeue_signal = () => 0; + implementations.kernel_drain_wakeup_events = () => 0; + + const gate = new KernelEntryGate(); + const rawInstance = createKernelScratchTestInstance( + 4, + kernelMemory, + () => implementations, + () => SCRATCH_OFFSET, + ); + const gatedInstance = createKernelEntryGatedInstance(rawInstance, gate); + const scratch = allocateKernelScratchRegion( kernelMemory, - processes: new Map(), - channelTids: new Map(), - pendingSleeps: new Map(), - pendingSignalWaits: new Map(), - signalWaitDeadlines: new Map(), - pendingPollRetries: new Map(), - pendingSelectRetries: new Map(), + gatedInstance.exports.kernel_alloc_scratch as + (capacity: number) => number, + CH_TOTAL_SIZE, + 4, + "signal wake test scratch", + gatedInstance, + ); + const worker = createCentralizedKernelWorkerTestDouble({ + callbacks: { onExit, onKernelFatal }, + }); + worker.testAuthority.initializeKernelForTest({ + instance: gatedInstance, + gate, + mainScratch: scratch, + tcpScratch: scratch, }); - worker.testScratchPointer = installKernelWorkerTestScratch( + worker.testAuthority.configureScratchBoundaryHooksForTest({ + completeChannel, + }); + return { worker, + implementations, + completeChannel, + onExit, + onKernelFatal, kernelMemory, - ); - return worker; + }; +} + +function mutableState(harness: SignalHarness) { + return harness.worker as unknown as { + processes: Map; + activeChannels: TestChannel[]; + channelTids: Map; + pendingSleeps: Map; + channel: TestChannel; + syscallNr: number; + origArgs: number[]; + retVal: number; + errVal: number; + }>; + pendingSignalWaits: Map; + signalWaitDeadlines: Map; + pendingPollRetries: Map | null; + channel: TestChannel; + pipeIndices: number[]; + acceptIndices?: number[]; + }>; + pendingSelectRetries: Map | null; + channel: TestChannel; + origArgs: number[]; + syscallNr: number; + }>; + pendingPipeReaders: Map< + number, + Array<{ channel: TestChannel; pid: number }> + >; + pendingPipeWriters: Map< + number, + Array<{ channel: TestChannel; pid: number }> + >; + currentHandlePid: number; + hostReaped: Set; + }; +} + +function registerProcess( + harness: SignalHarness, + pid: number, + channels: TestChannel[], +): void { + const state = mutableState(harness); + state.processes.set(pid, { + pid, + memory: channels[0]!.memory, + channels, + ptrWidth: 4, + }); + state.activeChannels.push(...channels); + channels.forEach((channel, index) => { + state.channelTids.set( + `${pid}:${channel.channelOffset}`, + index === 0 ? pid : pid + index, + ); + }); } describe("signal delivery to a process blocked in accept()", () => { it("does not livelock when retrySyscall re-parks the same poll key", () => { - const worker = createWorkerHarness(); + const harness = createWorkerHarness(); + const state = mutableState(harness); const targetPid = 42; - const channelOffset = 0; - const channel = createChannel(targetPid, channelOffset); - - worker.processes.set(targetPid, { channels: [channel] }); + const channel = createChannel(targetPid); + registerProcess(harness, targetPid, [channel]); - // The accept()'s parked-retry entry, keyed by exact channel (matches - // handleBlockingRetry's registration for SYS_ACCEPT). const makeEntry = () => ({ timer: null, channel, pipeIndices: [], acceptIndices: [7], }); - worker.pendingPollRetries.set(channel, makeEntry()); + state.pendingPollRetries.set(channel, makeEntry()); - // Model accept() re-parking: every retry re-inserts the SAME key, exactly - // as the real EAGAIN path does. Cap the re-insertions so a *regressed* - // (livelocking) implementation still terminates the test with a wrong - // count instead of hanging the whole suite forever. let retryCount = 0; - worker.retrySyscall = vi.fn(() => { - retryCount++; - if (retryCount < 5000) { - worker.pendingPollRetries.set(channel, makeEntry()); - } + harness.worker.testAuthority.configureScratchBoundaryHooksForTest({ + retrySyscall: () => { + retryCount++; + if (retryCount < 5000) { + state.pendingPollRetries.set(channel, makeEntry()); + } + }, }); - worker.sendSignalToProcess(targetPid, SIGCHLD); + harness.worker.testAuthority.sendSignalForTest(targetPid, SIGCHLD); - // With the snapshot fix, the parked accept is retried exactly once. The - // pre-fix live-Map iteration would revisit the re-inserted key until the - // 5000-cap kicks in. expect(retryCount).toBe(1); }); - it("notifyPipeReadable does not livelock on a re-parking poll watching the pipe", () => { - const worker = createWorkerHarness(); + it("notifyPipeReadable does not livelock on a re-parking poll", () => { + const harness = createWorkerHarness(); + const state = mutableState(harness); const targetPid = 43; - const channelOffset = 0; const pipeIdx = 11; - const channel = createChannel(targetPid, channelOffset); - worker.processes.set(targetPid, { channels: [channel] }); - worker.pendingPipeReaders = new Map(); - worker.pendingPipeWriters = new Map(); - worker.scheduleWakeBlockedRetries = () => {}; - - const makeEntry = () => ({ timer: null, channel, pipeIndices: [pipeIdx], acceptIndices: [] }); - worker.pendingPollRetries.set(channel, makeEntry()); + const channel = createChannel(targetPid); + registerProcess(harness, targetPid, [channel]); + const makeEntry = () => ({ + timer: null, + channel, + pipeIndices: [pipeIdx], + acceptIndices: [], + }); + state.pendingPollRetries.set(channel, makeEntry()); let retryCount = 0; - worker.retrySyscall = vi.fn(() => { - retryCount++; - if (retryCount < 5000) worker.pendingPollRetries.set(channel, makeEntry()); + harness.worker.testAuthority.configureScratchBoundaryHooksForTest({ + scheduleWakeBlockedRetries: vi.fn(), + retrySyscall: () => { + retryCount++; + if (retryCount < 5000) { + state.pendingPollRetries.set(channel, makeEntry()); + } + }, }); - worker.notifyPipeReadable(pipeIdx); + harness.worker.notifyPipeReadable(pipeIdx); expect(retryCount).toBe(1); }); @@ -142,15 +284,17 @@ describe("signal delivery to a process blocked in accept()", () => { it("interrupts only the sleeping thread selected for a shared signal", () => { vi.useFakeTimers(); try { - const worker = createWorkerHarness(); + const harness = createWorkerHarness(); + const state = mutableState(harness); const pid = 44; const threadTid = 45; - const mainChannel = createChannel(pid, 0); - const threadChannel = createChannel(pid, 256); - const mainTimer = setTimeout(() => {}, 60_000); - const threadTimer = setTimeout(() => {}, 60_000); + const memory = createSharedMemory(); + const mainChannel = createChannel(pid, 0, memory); + const threadChannel = createChannel(pid, 256, memory); + registerProcess(harness, pid, [mainChannel, threadChannel]); + state.channelTids.set(`${pid}:${threadChannel.channelOffset}`, threadTid); const mainSleep = { - timer: mainTimer, + timer: setTimeout(() => {}, 60_000), channel: mainChannel, syscallNr: 1, origArgs: [], @@ -158,56 +302,50 @@ describe("signal delivery to a process blocked in accept()", () => { errVal: 0, }; const threadSleep = { - timer: threadTimer, + timer: setTimeout(() => {}, 60_000), channel: threadChannel, syscallNr: 1, origArgs: [], retVal: 0, errVal: 0, }; - worker.processes.set(pid, { channels: [mainChannel, threadChannel] }); - worker.channelTids.set(`${pid}:${threadChannel.channelOffset}`, threadTid); - worker.pendingSleeps.set(mainChannel, mainSleep); - worker.pendingSleeps.set(threadChannel, threadSleep); - worker.kernelInstance.exports.kernel_pick_signal_target_tid = vi.fn( - () => threadTid, - ); - // Model a caught SIGCHLD still pending for the selected pthread. - worker.completeSleepWithSignalCheck = vi.fn(); - - worker.sendSignalToProcess(pid, SIGCHLD); - - expect( - worker.kernelInstance.exports.kernel_pick_signal_target_tid, - ).toHaveBeenCalledWith(pid, SIGCHLD); - expect(worker.pendingSleeps.get(mainChannel)).toBe(mainSleep); - expect(worker.pendingSleeps.has(threadChannel)).toBe(false); - expect(worker.completeSleepWithSignalCheck).toHaveBeenCalledOnce(); - expect(worker.completeSleepWithSignalCheck).toHaveBeenCalledWith( - threadChannel, - threadSleep.syscallNr, - threadSleep.origArgs, - threadSleep.retVal, - threadSleep.errVal, - ); + state.pendingSleeps.set(mainChannel, mainSleep); + state.pendingSleeps.set(threadChannel, threadSleep); + const pickSignalTarget = vi.fn(() => threadTid); + harness.implementations.kernel_pick_signal_target_tid = pickSignalTarget; + + harness.worker.testAuthority.sendSignalForTest(pid, SIGCHLD); + + expect(pickSignalTarget).toHaveBeenCalledWith(pid, SIGCHLD); + expect(state.pendingSleeps.get(mainChannel)).toBe(mainSleep); + expect(state.pendingSleeps.has(threadChannel)).toBe(false); + expect(harness.completeChannel).toHaveBeenCalledOnce(); + expect(harness.completeChannel.mock.calls[0]?.[0]).toBe(threadChannel); } finally { vi.useRealTimers(); } }); - it("passes a sleeping pthread's exact TID when dequeuing its pending signal", () => { - const worker = createWorkerHarness(); + it("passes a sleeping pthread's exact TID when dequeuing its signal", () => { + const harness = createWorkerHarness(); + const state = mutableState(harness); const pid = 46; const tid = 47; const channel = createChannel(pid, 256); + registerProcess(harness, pid, [channel]); + state.channelTids.set(`${pid}:${channel.channelOffset}`, tid); const setCurrentTid = vi.fn(() => 0); const dequeueSignal = vi.fn(() => 0); - worker.channelTids.set(`${pid}:${channel.channelOffset}`, tid); - worker.kernelInstance.exports.kernel_set_current_tid = setCurrentTid; - worker.kernelInstance.exports.kernel_dequeue_signal = dequeueSignal; - worker.completeChannel = vi.fn(); + harness.implementations.kernel_set_current_tid = setCurrentTid; + harness.implementations.kernel_dequeue_signal = dequeueSignal; - worker.completeSleepWithSignalCheck(channel, 1, [], 0, 0); + harness.worker.testAuthority.completeSleepWithSignalCheckForTest( + channel, + 1, + [], + 0, + 0, + ); expect(setCurrentTid).not.toHaveBeenCalled(); expect(dequeueSignal).toHaveBeenCalledWith( @@ -219,16 +357,16 @@ describe("signal delivery to a process blocked in accept()", () => { }); it("does not rebind an ordinary synchronous signal dequeue", () => { - const worker = createWorkerHarness(); + const harness = createWorkerHarness(); const pid = 48; - const channel = createChannel(pid, 0); + const channel = createChannel(pid); + registerProcess(harness, pid, [channel]); const setCurrentTid = vi.fn(() => 0); - worker.channelTids.set(`${pid}:${channel.channelOffset}`, pid); - worker.kernelInstance.exports.kernel_set_current_tid = setCurrentTid; const dequeueSignal = vi.fn(() => 0); - worker.kernelInstance.exports.kernel_dequeue_signal = dequeueSignal; + harness.implementations.kernel_set_current_tid = setCurrentTid; + harness.implementations.kernel_dequeue_signal = dequeueSignal; - worker.dequeueSignalForDelivery(channel); + harness.worker.testAuthority.dequeueSignalForDeliveryForTest(channel); expect(setCurrentTid).not.toHaveBeenCalled(); expect(dequeueSignal).toHaveBeenCalledWith( @@ -240,214 +378,243 @@ describe("signal delivery to a process blocked in accept()", () => { }); it("hands a deferred signal from a JavaScript completion to the next guest checkpoint", () => { - const worker = createWorkerHarness(); + const harness = createWorkerHarness(); const pid = 48; - const channel = createChannel(pid, 0); - worker.channelTids.set(`${pid}:${channel.channelOffset}`, pid); - const channelView = new DataView(channel.memory.buffer); + const channel = createChannel(pid); + registerProcess(harness, pid, [channel]); + const channelView = new DataView( + channel.memory.buffer, + channel.channelOffset, + ); channelView.setUint32( CH_REQUEST_FLAGS, CH_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY, true, ); channelView.setUint32(CH_SIG_SIGNUM, 0, true); - const dequeueSignal = vi.fn(() => 10); - worker.kernelInstance.exports.kernel_dequeue_signal = dequeueSignal; + const dequeueSignal = vi.fn( + (_pid: number, _tid: number, outPtr: number | bigint) => { + new DataView(harness.kernelMemory.buffer).setUint32( + Number(outPtr) + CH_SIG_SIGNUM - CH_SIG_BASE, + 10, + true, + ); + return 10; + }, + ); + harness.implementations.kernel_dequeue_signal = dequeueSignal; - expect(worker.dequeueSignalForDelivery(channel)).toBe(0); + expect( + harness.worker.testAuthority.dequeueSignalForDeliveryForTest(channel), + ).toBe(0); expect(dequeueSignal).not.toHaveBeenCalled(); expect(channelView.getUint32(CH_SIG_SIGNUM, true)).toBe(0); // Model libc's ordinary post-import checkpoint. Only that guest-owned // completion may consume the signal into the trampoline's channel record. channelView.setUint32(CH_REQUEST_FLAGS, 0, true); - new DataView(worker.kernelMemory.buffer).setUint32( - worker.scratchOffset + CH_SIG_SIGNUM, - 10, - true, - ); - expect(worker.dequeueSignalForDelivery(channel)).toBe(10); + expect( + harness.worker.testAuthority.dequeueSignalForDeliveryForTest(channel), + ).toBe(10); expect(dequeueSignal).toHaveBeenCalledOnce(); expect(channelView.getUint32(CH_SIG_SIGNUM, true)).toBe(10); }); it("fails closed when Rust rejects an exact signal dequeue task", () => { - const worker = createWorkerHarness(); + const harness = createWorkerHarness(); + const state = mutableState(harness); const pid = 48; const tid = 49; const channel = createChannel(pid, 256); - worker.channelTids.set(`${pid}:${channel.channelOffset}`, tid); - worker.kernelInstance.exports.kernel_dequeue_signal = vi.fn(() => -3); + registerProcess(harness, pid, [channel]); + state.channelTids.set(`${pid}:${channel.channelOffset}`, tid); + harness.implementations.kernel_dequeue_signal = vi.fn(() => -3); - expect(() => worker.dequeueSignalForDelivery(channel)).toThrow( + let caught: unknown; + try { + harness.worker.testAuthority.dequeueSignalForDeliveryForTest(channel); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toMatch(/signal dequeue test/); + expect((caught as Error & { cause?: Error }).cause?.message).toMatch( /Kernel rejected signal dequeue/, ); }); it("does not resume a sleeping pthread after dequeue terminates it", () => { - const worker = createWorkerHarness(); + const harness = createWorkerHarness(); + const state = mutableState(harness); const pid = 49; const tid = 50; const channel = createChannel(pid, 256); + registerProcess(harness, pid, [channel]); + state.channelTids.set(`${pid}:${channel.channelOffset}`, tid); let exited = false; - worker.channelTids.set(`${pid}:${channel.channelOffset}`, tid); - worker.kernelInstance.exports.kernel_dequeue_signal = vi.fn(() => { + harness.implementations.kernel_dequeue_signal = vi.fn(() => { exited = true; return 0; }); - worker.getProcessExitSignal = vi.fn(() => exited ? SIGTERM : -1); - worker.handleProcessTerminated = vi.fn(); - worker.completeChannel = vi.fn(); + harness.implementations.kernel_get_process_exit_signal = + () => exited ? SIGTERM : -1; - worker.completeSleepWithSignalCheck(channel, 1, [], 0, 0); + harness.worker.testAuthority.completeSleepWithSignalCheckForTest( + channel, + 1, + [], + 0, + 0, + ); - expect(worker.handleProcessTerminated).toHaveBeenCalledWith(channel); - expect(worker.completeChannel).not.toHaveBeenCalled(); + expect(state.hostReaped.has(pid)).toBe(true); + expect(harness.onExit).toHaveBeenCalledWith(pid, 128 + SIGTERM); + expect(harness.completeChannel).not.toHaveBeenCalled(); }); - it("leaves a sleep parked when the kernel consumed an ignored signal", () => { + it("leaves waits parked when the kernel consumed an ignored signal", () => { vi.useFakeTimers(); try { - const worker = createWorkerHarness(); + const harness = createWorkerHarness(); + const state = mutableState(harness); const pid = 51; - const channel = createChannel(pid, 0); - const timer = setTimeout(() => {}, 60_000); + const channel = createChannel(pid); + registerProcess(harness, pid, [channel]); const sleep = { - timer, + timer: setTimeout(() => {}, 60_000), channel, syscallNr: 1, origArgs: [], retVal: 0, errVal: 0, }; - worker.processes.set(pid, { channels: [channel] }); - worker.pendingSleeps.set(channel, sleep); - worker.kernelInstance.exports.kernel_thread_has_deliverable = vi.fn( - () => 0, - ); - worker.completeSleepWithSignalCheck = vi.fn(); - worker.retrySyscall = vi.fn(); - worker.handlePselect6 = vi.fn(); - const pollEntry = { timer: null, channel }; + const pollEntry = { + timer: null, + channel, + pipeIndices: [], + }; const selectEntry = { timer: setTimeout(() => {}, 60_000), channel, origArgs: [], syscallNr: 0, }; - worker.pendingPollRetries.set(channel, pollEntry); - worker.pendingSelectRetries.set(channel, selectEntry); - - worker.sendSignalToProcess(pid, SIGCHLD); - - expect(worker.pendingSleeps.get(channel)).toBe(sleep); - expect(worker.pendingPollRetries.get(channel)).toBe(pollEntry); - expect(worker.pendingSelectRetries.get(channel)).toBe(selectEntry); - expect(worker.completeSleepWithSignalCheck).not.toHaveBeenCalled(); - expect(worker.retrySyscall).not.toHaveBeenCalled(); - expect(worker.handlePselect6).not.toHaveBeenCalled(); + state.pendingSleeps.set(channel, sleep); + state.pendingPollRetries.set(channel, pollEntry); + state.pendingSelectRetries.set(channel, selectEntry); + harness.implementations.kernel_thread_has_deliverable = () => 0; + const retrySyscall = vi.fn(); + harness.worker.testAuthority.configureScratchBoundaryHooksForTest({ + retrySyscall, + }); + + harness.worker.testAuthority.sendSignalForTest(pid, SIGCHLD); + + expect(state.pendingSleeps.get(channel)).toBe(sleep); + expect(state.pendingPollRetries.get(channel)).toBe(pollEntry); + expect(state.pendingSelectRetries.get(channel)).toBe(selectEntry); + expect(retrySyscall).not.toHaveBeenCalled(); + expect(harness.completeChannel).not.toHaveBeenCalled(); } finally { vi.useRealTimers(); } }); - it("reaps a default-terminated process without resuming its channel", () => { - const worker = createWorkerHarness(); + it("reaps a default-terminated process without selecting a sleeper", () => { + const harness = createWorkerHarness(); + const state = mutableState(harness); const pid = 52; + const channel = createChannel(pid); + registerProcess(harness, pid, [channel]); const pickSignalTarget = vi.fn(() => pid); - worker.kernelInstance.exports.kernel_pick_signal_target_tid = pickSignalTarget; - worker.reapKilledProcessesAfterSyscall = vi.fn(); - worker.getProcessExitSignal = vi.fn(() => SIGTERM); + let exited = false; + harness.implementations.kernel_handle_channel = ( + pointer: number | bigint, + ) => { + const view = new DataView(harness.kernelMemory.buffer, Number(pointer)); + view.setBigInt64(CH_RETURN, 0n, true); + view.setUint32(CH_ERRNO, 0, true); + exited = true; + return 0; + }; + harness.implementations.kernel_get_process_exit_signal = + () => exited ? SIGTERM : -1; + harness.implementations.kernel_pick_signal_target_tid = pickSignalTarget; - worker.sendSignalToProcess(pid, SIGTERM); + harness.worker.testAuthority.sendSignalForTest(pid, SIGTERM); - expect(worker.reapKilledProcessesAfterSyscall).toHaveBeenCalledOnce(); + expect(state.hostReaped.has(pid)).toBe(true); + expect(harness.onExit).toHaveBeenCalledWith(pid, 128 + SIGTERM); expect(pickSignalTarget).not.toHaveBeenCalled(); }); - it("does not wake blocked channels when queuing the signal traps", () => { - const worker = createWorkerHarness(); + it("does not wake blocked channels when queuing the signal traps", async () => { + const harness = createWorkerHarness(); const pid = 53; + const channel = createChannel(pid); + registerProcess(harness, pid, [channel]); const pickSignalTarget = vi.fn(() => pid); - const reaper = vi.fn(); + harness.implementations.kernel_handle_channel = () => { + throw new Error("synthetic kernel trap"); + }; + harness.implementations.kernel_pick_signal_target_tid = pickSignalTarget; const error = vi.spyOn(console, "error").mockImplementation(() => {}); try { - worker.kernelInstance.exports.kernel_handle_channel = () => { - throw new Error("synthetic kernel trap"); - }; - worker.kernelInstance.exports.kernel_pick_signal_target_tid = pickSignalTarget; - worker.reapKilledProcessesAfterSyscall = reaper; + expect(() => { + harness.worker.testAuthority.sendSignalForTest(pid, SIGTERM); + }).toThrow(/kernel_handle_channel failed/); + await Promise.resolve(); - worker.sendSignalToProcess(pid, SIGTERM); - - expect(reaper).not.toHaveBeenCalled(); + expect(harness.onExit).not.toHaveBeenCalled(); expect(pickSignalTarget).not.toHaveBeenCalled(); + expect(harness.onKernelFatal).toHaveBeenCalledOnce(); } finally { error.mockRestore(); } }); - it("does not change the ambient host PID when signal TID binding is rejected", () => { - const worker = createWorkerHarness(); + it("preserves the ambient host PID when signal TID binding is rejected", () => { + const harness = createWorkerHarness(); + const state = mutableState(harness); const targetPid = 54; const priorPid = 91; const setCurrentTid = vi.fn(() => -3); const handleChannel = vi.fn(); - worker.currentHandlePid = priorPid; - worker.kernelInstance.exports.kernel_set_current_tid = setCurrentTid; - worker.kernelInstance.exports.kernel_handle_channel = handleChannel; + state.currentHandlePid = priorPid; + harness.implementations.kernel_set_current_tid = setCurrentTid; + harness.implementations.kernel_handle_channel = handleChannel; - worker.sendSignalToProcess(targetPid, SIGTERM); + harness.worker.testAuthority.sendSignalForTest(targetPid, SIGTERM); expect(setCurrentTid).toHaveBeenCalledWith(targetPid, targetPid); expect(handleChannel).not.toHaveBeenCalled(); - expect(worker.currentHandlePid).toBe(priorPid); + expect(state.currentHandlePid).toBe(priorPid); }); - it("does not downgrade a successful directed tkill to a shared waiter wake", () => { - const worker = createWorkerHarness(); + it("does not downgrade a successful directed tkill to a shared wake", () => { + const harness = createWorkerHarness(); const pid = 55; const targetTid = 56; - const channel = createChannel(pid, 0); - worker.channelTids.set(`${pid}:${channel.channelOffset}`, pid); + const channel = createChannel(pid); + registerProcess(harness, pid, [channel]); const processView = new DataView(channel.memory.buffer); processView.setUint32(CH_SYSCALL, SYS_TKILL, true); processView.setBigInt64(CH_ARGS, BigInt(targetTid), true); - processView.setBigInt64(CH_ARGS + CH_ARG_SIZE, BigInt(SIGCHLD), true); - - Object.assign(worker, { - config: { enableSyscallLog: false }, - syscallRing: new Map(), - syscallTraceEnabled: false, - sharedMmapBackings: new Map(), - hostReaped: new Set(), - synchronizeSharedMemoryForBoundary: vi.fn(), - dequeueSignalForDelivery: vi.fn(() => false), - handlePendingInetConnect: vi.fn(() => false), - handleFlockConflict: vi.fn(() => false), - handleSleepDelay: vi.fn(() => false), - drainAndProcessWakeupEvents: vi.fn(), - scheduleWakeBlockedRetries: vi.fn(), - reapKilledProcessesAfterSyscall: vi.fn(), - wakePendingSignalWaits: vi.fn(), - completeChannel: vi.fn(), - currentHandlePid: 0, - }); + processView.setBigInt64( + CH_ARGS + CH_ARG_SIZE, + BigInt(SIGCHLD), + true, + ); const exactWake = vi.fn(() => false); const sharedWake = vi.fn(); - worker.interruptWaitingChildForDirectedSignal = exactWake; - worker.interruptWaitingChildrenForGeneratedSignal = sharedWake; - worker.kernelInstance.exports.kernel_handle_channel = vi.fn(() => { - const kernelView = new DataView( - worker.kernelMemory.buffer, - worker.testScratchPointer, - ); - kernelView.setBigInt64(CH_RETURN, 0n, true); - kernelView.setUint32(CH_ERRNO, 0, true); - return 0; + harness.worker.testAuthority.configureScratchBoundaryHooksForTest({ + synchronizeSharedMemoryForBoundary: vi.fn(), + scheduleWakeBlockedRetries: vi.fn(), + interruptWaitingChildForDirectedSignal: exactWake, + interruptWaitingChildrenForGeneratedSignal: sharedWake, }); - worker._handleSyscallInner(channel); + harness.worker.testAuthority.dispatchScratchBoundarySyscallForTest(channel); expect(exactWake).toHaveBeenCalledWith(pid, targetTid); expect(sharedWake).not.toHaveBeenCalled(); diff --git a/host/test/spawn-blob-transport.test.ts b/host/test/spawn-blob-transport.test.ts index 323d649c43..73676d8a03 100644 --- a/host/test/spawn-blob-transport.test.ts +++ b/host/test/spawn-blob-transport.test.ts @@ -1,11 +1,19 @@ import { describe, expect, it, vi } from "vitest"; import { - CentralizedKernelWorker, + type CentralizedKernelCallbacks, + createCentralizedKernelWorkerTestDouble, SPAWN_BLOB_MAX_BYTES, } from "../src/kernel-worker"; import { + createKernelEntryGatedInstance, + KernelEntryGate, + KernelReentrantEntryError, +} from "../src/kernel-entry-gate"; +import { + CHANNEL_STATUS_PENDING, CH_DATA_SIZE, + CH_STATUS, CH_TOTAL_SIZE, HOST_INTERCEPTED_SYSCALLS, POSIX_ARG_MAX_BYTES, @@ -36,17 +44,16 @@ const ENAMETOOLONG = 36; describe("SYS_SPAWN blob transport", () => { it("reports the Rust-owned retained capacity losslessly", () => { const worker = createWorker({ - kernelInstance: { - exports: { - kernel_spawn_scratch_retained_capacity: vi.fn(() => 84_386n), - }, + pointerWidth: 8, + kernelExports: { + kernel_spawn_scratch_retained_capacity: vi.fn(() => 84_386n), }, }); expect(worker.getSpawnScratchCapacity()).toBe(84_386); }); - it("grows one Rust-owned reservation to the requested high-water mark", () => { + it("grows one Rust-owned reservation to the requested high-water mark", async () => { const firstBlob = new Uint8Array(CH_TOTAL_SIZE + 1024).fill(0x31); const reusedBlob = new Uint8Array(firstBlob.byteLength + 512).fill(0x32); const grownBlob = new Uint8Array(firstBlob.byteLength + 4096).fill(0x33); @@ -94,14 +101,12 @@ describe("SYS_SPAWN blob transport", () => { callbacks: { onSpawn: vi.fn(() => new Promise(() => {})) }, kernelMemory, scratchPointer: 1024, - kernelInstance: { - exports: { - kernel_spawn_scratch_begin: beginSpawnScratch, - kernel_spawn_scratch_pointer: spawnScratchPointer, - kernel_spawn_scratch_capacity: spawnScratchCapacity, - kernel_spawn_scratch_cancel: cancelSpawnScratch, - kernel_spawn_reserved_process: kernelReservedSpawn, - }, + kernelExports: { + kernel_spawn_scratch_begin: beginSpawnScratch, + kernel_spawn_scratch_pointer: spawnScratchPointer, + kernel_spawn_scratch_capacity: spawnScratchCapacity, + kernel_spawn_scratch_cancel: cancelSpawnScratch, + kernel_spawn_reserved_process: kernelReservedSpawn, }, }); const invoke = (blob: Uint8Array) => worker.handleSpawnAfterResolve( @@ -117,6 +122,7 @@ describe("SYS_SPAWN blob transport", () => { ); invoke(firstBlob); + await drainSpawnGate(); expect(beginSpawnScratch).toHaveBeenCalledWith(firstBlob.byteLength); expect(kernelReservedSpawn).toHaveBeenLastCalledWith( 7, @@ -126,6 +132,7 @@ describe("SYS_SPAWN blob transport", () => { ); invoke(reusedBlob); + await drainSpawnGate(); expect(beginSpawnScratch).toHaveBeenCalledTimes(2); expect(kernelReservedSpawn).toHaveBeenLastCalledWith( 7, @@ -135,6 +142,7 @@ describe("SYS_SPAWN blob transport", () => { ); invoke(grownBlob); + await drainSpawnGate(); expect(beginSpawnScratch).toHaveBeenCalledTimes(3); expect(beginSpawnScratch).toHaveBeenLastCalledWith(grownBlob.byteLength); expect(kernelReservedSpawn).toHaveBeenLastCalledWith( @@ -157,20 +165,15 @@ describe("SYS_SPAWN blob transport", () => { const beginSpawnScratch = vi.fn(() => 77n); const kernelReservedSpawn = vi.fn(() => 42); const worker = createWorker({ - kernel: { - toKernelPtr: (value: number | bigint) => BigInt(value), - getKernelPtrWidth: () => 8, - }, + pointerWidth: 8, callbacks: { onSpawn: vi.fn(() => new Promise(() => {})) }, kernelMemory, - kernelInstance: { - exports: { - kernel_spawn_scratch_begin: beginSpawnScratch, - kernel_spawn_scratch_pointer: vi.fn(() => pointer), - kernel_spawn_scratch_capacity: vi.fn(() => BigInt(blob.byteLength)), - kernel_spawn_scratch_cancel: vi.fn(() => -EINVAL), - kernel_spawn_reserved_process: kernelReservedSpawn, - }, + kernelExports: { + kernel_spawn_scratch_begin: beginSpawnScratch, + kernel_spawn_scratch_pointer: vi.fn(() => pointer), + kernel_spawn_scratch_capacity: vi.fn(() => BigInt(blob.byteLength)), + kernel_spawn_scratch_cancel: vi.fn(() => -EINVAL), + kernel_spawn_reserved_process: kernelReservedSpawn, }, }); @@ -235,14 +238,12 @@ describe("SYS_SPAWN blob transport", () => { callbacks: { onSpawn: vi.fn() }, completeChannel, kernelMemory: new WebAssembly.Memory({ initial: 2, maximum: 2 }), - kernelInstance: { - exports: { - kernel_spawn_scratch_begin: vi.fn(() => beginResult), - kernel_spawn_scratch_pointer: vi.fn(() => pointer), - kernel_spawn_scratch_capacity: vi.fn(() => capacity), - kernel_spawn_scratch_cancel: cancelSpawnScratch, - kernel_spawn_reserved_process: kernelReservedSpawn, - }, + kernelExports: { + kernel_spawn_scratch_begin: vi.fn(() => beginResult), + kernel_spawn_scratch_pointer: vi.fn(() => pointer), + kernel_spawn_scratch_capacity: vi.fn(() => capacity), + kernel_spawn_scratch_cancel: cancelSpawnScratch, + kernel_spawn_reserved_process: kernelReservedSpawn, }, }); const channel = createChannel(7, sharedMemoryFor(65_536)); @@ -327,26 +328,23 @@ describe("SYS_SPAWN blob transport", () => { return childPid; }); const onSpawn = vi.fn(() => new Promise(() => {})); + const onResolveSpawn = vi.fn(async () => resolvedProgram()); + const completeChannel = vi.fn(); const channel = createChannel(parentPid, processMemory); const worker = createWorker({ callbacks: { - onResolveSpawn: vi.fn(async () => resolvedProgram()), + onResolveSpawn, onSpawn, }, - processes: new Map([[ - parentPid, - { channels: [channel], memory: processMemory, ptrWidth: 4 }, - ]]), + completeChannel, kernelMemory, scratchPointer: generalScratchOffset, - kernelInstance: { - exports: { - kernel_spawn_scratch_begin: beginSpawnScratch, - kernel_spawn_scratch_pointer: vi.fn(() => largeScratchOffset), - kernel_spawn_scratch_capacity: vi.fn(() => blob.byteLength), - kernel_spawn_scratch_cancel: vi.fn(() => -EINVAL), - kernel_spawn_reserved_process: kernelReservedSpawn, - }, + kernelExports: { + kernel_spawn_scratch_begin: beginSpawnScratch, + kernel_spawn_scratch_pointer: vi.fn(() => largeScratchOffset), + kernel_spawn_scratch_capacity: vi.fn(() => blob.byteLength), + kernel_spawn_scratch_cancel: vi.fn(() => -EINVAL), + kernel_spawn_reserved_process: kernelReservedSpawn, }, }); const args = [ @@ -359,9 +357,10 @@ describe("SYS_SPAWN blob transport", () => { ]; worker.handleSpawn(channel, args); - await Promise.resolve(); - await Promise.resolve(); + await drainSpawnGate(); + expect(onResolveSpawn).toHaveBeenCalledOnce(); + expect(completeChannel).not.toHaveBeenCalled(); expect(beginSpawnScratch).toHaveBeenCalledOnce(); expect(beginSpawnScratch).toHaveBeenCalledWith(blob.byteLength); expect(kernelReservedSpawn).toHaveBeenCalledOnce(); @@ -403,11 +402,9 @@ describe("SYS_SPAWN blob transport", () => { callbacks: { onSpawn: vi.fn(() => new Promise(() => {})) }, kernelMemory, scratchPointer, - kernelInstance: { - exports: { - kernel_alloc_scratch: allocScratch, - kernel_spawn_process: kernelSpawn, - }, + kernelExports: { + kernel_alloc_scratch: allocScratch, + kernel_spawn_process: kernelSpawn, }, }); @@ -471,15 +468,13 @@ describe("SYS_SPAWN blob transport", () => { callbacks: { onSpawn: vi.fn(() => new Promise(() => {})) }, kernelMemory, scratchPointer: 1024, - kernelInstance: { - exports: { - kernel_spawn_process: kernelSpawn, - kernel_spawn_scratch_begin: beginSpawnScratch, - kernel_spawn_scratch_pointer: vi.fn(() => 2 * CH_TOTAL_SIZE), - kernel_spawn_scratch_capacity: vi.fn(() => blobLen), - kernel_spawn_scratch_cancel: vi.fn(() => -EINVAL), - kernel_spawn_reserved_process: kernelReservedSpawn, - }, + kernelExports: { + kernel_spawn_process: kernelSpawn, + kernel_spawn_scratch_begin: beginSpawnScratch, + kernel_spawn_scratch_pointer: vi.fn(() => 2 * CH_TOTAL_SIZE), + kernel_spawn_scratch_capacity: vi.fn(() => blobLen), + kernel_spawn_scratch_cancel: vi.fn(() => -EINVAL), + kernel_spawn_reserved_process: kernelReservedSpawn, }, }); @@ -528,14 +523,12 @@ describe("SYS_SPAWN blob transport", () => { const worker = createWorker({ callbacks: { onSpawn: vi.fn(() => new Promise(() => {})) }, kernelMemory, - kernelInstance: { - exports: { - kernel_spawn_scratch_begin: vi.fn(() => 99n), - kernel_spawn_scratch_pointer: vi.fn(() => largeScratchOffset), - kernel_spawn_scratch_capacity: vi.fn(() => blob.byteLength), - kernel_spawn_scratch_cancel: vi.fn(() => -EINVAL), - kernel_spawn_reserved_process: kernelReservedSpawn, - }, + kernelExports: { + kernel_spawn_scratch_begin: vi.fn(() => 99n), + kernel_spawn_scratch_pointer: vi.fn(() => largeScratchOffset), + kernel_spawn_scratch_capacity: vi.fn(() => blob.byteLength), + kernel_spawn_scratch_cancel: vi.fn(() => -EINVAL), + kernel_spawn_reserved_process: kernelReservedSpawn, }, }); @@ -572,14 +565,12 @@ describe("SYS_SPAWN blob transport", () => { completeChannel, kernelMemory: new WebAssembly.Memory({ initial: 2, maximum: 2 }), scratchPointer: 1024, - kernelInstance: { - exports: { - kernel_spawn_scratch_begin: beginSpawnScratch, - kernel_spawn_scratch_pointer: vi.fn(() => scratchPointer), - kernel_spawn_scratch_capacity: vi.fn(() => blob.byteLength), - kernel_spawn_scratch_cancel: vi.fn(() => -EINVAL), - kernel_spawn_reserved_process: kernelReservedSpawn, - }, + kernelExports: { + kernel_spawn_scratch_begin: beginSpawnScratch, + kernel_spawn_scratch_pointer: vi.fn(() => scratchPointer), + kernel_spawn_scratch_capacity: vi.fn(() => blob.byteLength), + kernel_spawn_scratch_cancel: vi.fn(() => -EINVAL), + kernel_spawn_reserved_process: kernelReservedSpawn, }, }); const channel = createChannel(7, sharedMemoryFor(65_536)); @@ -639,11 +630,9 @@ describe("SYS_SPAWN blob transport", () => { completeChannel, kernelMemory: new WebAssembly.Memory({ initial: 2, maximum: 2 }), scratchPointer: 1024, - kernelInstance: { - exports: { - kernel_alloc_scratch: fixedAllocator, - kernel_spawn_reserved_process: kernelReservedSpawn, - }, + kernelExports: { + kernel_alloc_scratch: fixedAllocator, + kernel_spawn_reserved_process: kernelReservedSpawn, }, }); const channel = createChannel(7, sharedMemoryFor(65_536)); @@ -687,14 +676,12 @@ describe("SYS_SPAWN blob transport", () => { callbacks: { onSpawn: vi.fn() }, completeChannel, kernelMemory: new WebAssembly.Memory({ initial: 2, maximum: 2 }), - kernelInstance: { - exports: { - kernel_spawn_scratch_begin: vi.fn(() => 17n), - kernel_spawn_scratch_pointer: vi.fn(() => 4096), - kernel_spawn_scratch_capacity: vi.fn(() => blobLength), - kernel_spawn_scratch_cancel: cancelSpawnScratch, - kernel_spawn_reserved_process: kernelReservedSpawn, - }, + kernelExports: { + kernel_spawn_scratch_begin: vi.fn(() => 17n), + kernel_spawn_scratch_pointer: vi.fn(() => 4096), + kernel_spawn_scratch_capacity: vi.fn(() => blobLength), + kernel_spawn_scratch_cancel: cancelSpawnScratch, + kernel_spawn_reserved_process: kernelReservedSpawn, }, }); const channel = createChannel(7, sharedMemoryFor(65_536)); @@ -724,7 +711,7 @@ describe("SYS_SPAWN blob transport", () => { ); }); - it("cancels a commit rejection before admitting the next large spawn", () => { + it("cancels a commit rejection before admitting the next large spawn", async () => { const blob = new Uint8Array(CH_TOTAL_SIZE + 1); const completeChannel = vi.fn(); const beginSpawnScratch = vi.fn() @@ -741,14 +728,12 @@ describe("SYS_SPAWN blob transport", () => { callbacks: { onSpawn }, completeChannel, kernelMemory: new WebAssembly.Memory({ initial: 2, maximum: 2 }), - kernelInstance: { - exports: { - kernel_spawn_scratch_begin: beginSpawnScratch, - kernel_spawn_scratch_pointer: vi.fn(() => 4096), - kernel_spawn_scratch_capacity: vi.fn(() => blob.byteLength), - kernel_spawn_scratch_cancel: cancelSpawnScratch, - kernel_spawn_reserved_process: kernelReservedSpawn, - }, + kernelExports: { + kernel_spawn_scratch_begin: beginSpawnScratch, + kernel_spawn_scratch_pointer: vi.fn(() => 4096), + kernel_spawn_scratch_capacity: vi.fn(() => blob.byteLength), + kernel_spawn_scratch_cancel: cancelSpawnScratch, + kernel_spawn_reserved_process: kernelReservedSpawn, }, }); const channel = createChannel(7, sharedMemoryFor(65_536)); @@ -778,32 +763,33 @@ describe("SYS_SPAWN blob transport", () => { completeChannel.mockClear(); invoke(); + await drainSpawnGate(); expect(beginSpawnScratch).toHaveBeenCalledTimes(2); expect(kernelReservedSpawn).toHaveBeenCalledTimes(2); expect(cancelSpawnScratch).toHaveBeenNthCalledWith(2, 32n); expect(onSpawn).toHaveBeenCalledOnce(); }); - it("keeps the large-spawn guard closed after cancellation protocol failure", () => { + it("keeps the large-spawn guard closed after cancellation protocol failure", async () => { const blob = new Uint8Array(CH_TOTAL_SIZE + 1); const completeChannel = vi.fn(); const beginSpawnScratch = vi.fn(() => 41n); + const onKernelFatal = vi.fn(); const worker = createWorker({ - callbacks: { onSpawn: vi.fn() }, + callbacks: { + onKernelFatal, + onSpawn: vi.fn(), + }, completeChannel, kernelMemory: new WebAssembly.Memory({ initial: 2, maximum: 2 }), - kernelInstance: { - exports: { - kernel_spawn_scratch_begin: beginSpawnScratch, - kernel_spawn_scratch_pointer: vi.fn(() => 4096), - kernel_spawn_scratch_capacity: vi.fn(() => blob.byteLength), - kernel_spawn_scratch_cancel: vi.fn(() => -EBUSY), - kernel_spawn_reserved_process: vi.fn(() => -EBUSY), - }, + kernelExports: { + kernel_spawn_scratch_begin: beginSpawnScratch, + kernel_spawn_scratch_pointer: vi.fn(() => 4096), + kernel_spawn_scratch_capacity: vi.fn(() => blob.byteLength), + kernel_spawn_scratch_cancel: vi.fn(() => -EBUSY), + kernel_spawn_reserved_process: vi.fn(() => -EBUSY), }, }); - const protocolFailure = vi.fn(); - worker.terminateForKernelProtocolFailure = protocolFailure; const channel = createChannel(7, sharedMemoryFor(65_536)); const args = [0, 0, 0, blob.byteLength, 0, 0]; const invoke = () => worker.handleSpawnAfterResolve( @@ -818,23 +804,36 @@ describe("SYS_SPAWN blob transport", () => { [], ); - invoke(); - expect(protocolFailure).toHaveBeenCalledOnce(); + let firstFailure: unknown; + try { + invoke(); + } catch (error) { + firstFailure = error; + } + expect(firstFailure).toMatchObject({ + message: "void kernel ingress resolved spawn transport test failed", + cause: { + name: "KernelTransferExecuteTrapError", + message: "kernel spawn reservation could not be settled", + }, + }); + await drainSpawnGate(); + expect(onKernelFatal).toHaveBeenCalledOnce(); + expect(onKernelFatal).toHaveBeenCalledWith(firstFailure); expect(completeChannel).not.toHaveBeenCalled(); - invoke(); + let secondFailure: unknown; + try { + invoke(); + } catch (error) { + secondFailure = error; + } + expect(secondFailure).toBe(firstFailure); expect(beginSpawnScratch).toHaveBeenCalledOnce(); - expect(completeChannel).toHaveBeenCalledWith( - channel, - HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN, - args, - undefined, - -1, - EBUSY, - ); + expect(completeChannel).not.toHaveBeenCalled(); }); - it("rejects reentrant large reservation without replacing outer bytes", () => { + it("rejects reentrant large reservation without replacing outer bytes", async () => { const outerBlob = new Uint8Array(CH_TOTAL_SIZE + 1).fill(0x41); const nestedBlob = new Uint8Array(CH_TOTAL_SIZE + 2).fill(0x42); const kernelMemory = new WebAssembly.Memory({ initial: 3, maximum: 3 }); @@ -846,6 +845,7 @@ describe("SYS_SPAWN blob transport", () => { const channel = createChannel(7, sharedMemoryFor(65_536)); const outerArgs = [0, 0, 0, outerBlob.byteLength, 0, 0]; const nestedArgs = [0, 0, 0, nestedBlob.byteLength, 0, 0]; + let reentrantError: unknown; let worker: any; const kernelReservedSpawn = vi.fn(() => { expect( @@ -854,17 +854,21 @@ describe("SYS_SPAWN blob transport", () => { scratchPointer + outerBlob.byteLength, ), ).toEqual(outerBlob); - worker.handleSpawnAfterResolve( - channel, - nestedArgs, - 7, - 7, - 0, - nestedBlob, - nestedBlob.byteLength, - resolvedProgram(), - [], - ); + try { + worker.handleSpawnAfterResolve( + channel, + nestedArgs, + 7, + 7, + 0, + nestedBlob, + nestedBlob.byteLength, + resolvedProgram(), + [], + ); + } catch (error) { + reentrantError = error; + } expect( kernelBytes.slice( scratchPointer, @@ -877,14 +881,12 @@ describe("SYS_SPAWN blob transport", () => { callbacks: { onSpawn: vi.fn(() => new Promise(() => {})) }, completeChannel, kernelMemory, - kernelInstance: { - exports: { - kernel_spawn_scratch_begin: beginSpawnScratch, - kernel_spawn_scratch_pointer: vi.fn(() => scratchPointer), - kernel_spawn_scratch_capacity: vi.fn(() => nestedBlob.byteLength), - kernel_spawn_scratch_cancel: cancelSpawnScratch, - kernel_spawn_reserved_process: kernelReservedSpawn, - }, + kernelExports: { + kernel_spawn_scratch_begin: beginSpawnScratch, + kernel_spawn_scratch_pointer: vi.fn(() => scratchPointer), + kernel_spawn_scratch_capacity: vi.fn(() => nestedBlob.byteLength), + kernel_spawn_scratch_cancel: cancelSpawnScratch, + kernel_spawn_reserved_process: kernelReservedSpawn, }, }); @@ -904,7 +906,8 @@ describe("SYS_SPAWN blob transport", () => { expect(kernelReservedSpawn).toHaveBeenCalledOnce(); expect(cancelSpawnScratch).toHaveBeenCalledOnce(); expect(cancelSpawnScratch).toHaveBeenCalledWith(23n); - expect(completeChannel).toHaveBeenCalledWith( + expect(reentrantError).toBeInstanceOf(KernelReentrantEntryError); + expect(completeChannel).not.toHaveBeenCalledWith( channel, HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN, nestedArgs, @@ -912,6 +915,72 @@ describe("SYS_SPAWN blob transport", () => { -1, EBUSY, ); + await drainSpawnGate(); + expect(beginSpawnScratch).toHaveBeenCalledOnce(); + expect(kernelReservedSpawn).toHaveBeenCalledOnce(); + expect(cancelSpawnScratch).toHaveBeenCalledOnce(); + }); + + it("rejects reentrant preflight without retaining caller-owned argv", async () => { + const blob = new Uint8Array(CH_TOTAL_SIZE + 1).fill(0x41); + const channel = createChannel(7, sharedMemoryFor(65_536)); + const outerArgs = [0, 0, 0, blob.byteLength, 0, 0]; + const callerRead = vi.fn(); + const callerArgs = new Proxy( + [0, 0, 0, 0, 0, 0], + { + get(target, property, receiver) { + callerRead(property); + return Reflect.get(target, property, receiver); + }, + }, + ); + const onResolveSpawn = vi.fn(); + let reentrantError: unknown; + let worker!: SpawnWorkerTestHarness; + const kernelReservedSpawn = vi.fn(() => { + try { + worker.handleSpawn(channel, callerArgs); + } catch (error) { + reentrantError = error; + } + return 42; + }); + worker = createWorker({ + callbacks: { + onResolveSpawn, + onSpawn: vi.fn(() => new Promise(() => {})), + }, + kernelMemory: new WebAssembly.Memory({ initial: 3, maximum: 3 }), + scratchPointer: 4096, + kernelExports: { + kernel_spawn_scratch_begin: vi.fn(() => 23n), + kernel_spawn_scratch_pointer: vi.fn(() => 4096), + kernel_spawn_scratch_capacity: vi.fn(() => blob.byteLength), + kernel_spawn_scratch_cancel: vi.fn(() => -EINVAL), + kernel_spawn_reserved_process: kernelReservedSpawn, + }, + }); + + worker.handleSpawnAfterResolve( + channel, + outerArgs, + 7, + 7, + 0, + blob, + blob.byteLength, + resolvedProgram(), + [], + ); + + expect(reentrantError).toBeInstanceOf(KernelReentrantEntryError); + expect(callerRead).not.toHaveBeenCalled(); + expect(onResolveSpawn).not.toHaveBeenCalled(); + await drainSpawnGate(); + expect(callerRead).not.toHaveBeenCalled(); + expect(onResolveSpawn).not.toHaveBeenCalled(); + expect(kernelReservedSpawn).toHaveBeenCalledOnce(); }); it.each([ @@ -1014,7 +1083,6 @@ describe("SYS_SPAWN blob transport", () => { const onResolveSpawn = vi.fn(); const worker = createWorker({ callbacks: { onResolveSpawn, onSpawn: vi.fn() }, - processes: new Map([[7, { channels: [channel], memory, ptrWidth: 4 }]]), completeChannel, }); const syscallArgs = args(memory.buffer.byteLength); @@ -1039,7 +1107,6 @@ describe("SYS_SPAWN blob transport", () => { const onResolveSpawn = vi.fn(); const worker = createWorker({ callbacks: { onResolveSpawn, onSpawn: vi.fn() }, - processes: new Map([[7, { channels: [channel], memory, ptrWidth: 4 }]]), completeChannel, }); const args = [0, 0, 256, SPAWN_BLOB_MAX_BYTES + 1, 0, 0]; @@ -1071,7 +1138,6 @@ describe("SYS_SPAWN blob transport", () => { const onResolveSpawn = vi.fn(); const worker = createWorker({ callbacks: { onResolveSpawn, onSpawn: vi.fn() }, - processes: new Map([[7, { channels: [channel], memory, ptrWidth: 4 }]]), completeChannel, }); const args = [ @@ -1147,10 +1213,11 @@ describe("SYS_SPAWN blob transport", () => { SPAWN_MAX_ACTION_COUNT, ), }, - ])("admits the exact $name count cap before resolution", ({ blob }) => { + ])("admits the exact $name count cap before resolution", async ({ blob }) => { const harness = createSpawnPreflightHarness(blob(), 4); harness.worker.handleSpawn(harness.channel, harness.args); + await drainSpawnGate(); expect(harness.onResolveSpawn).toHaveBeenCalledOnce(); expect(harness.completeChannel).not.toHaveBeenCalled(); @@ -1199,13 +1266,14 @@ describe("SYS_SPAWN blob transport", () => { it.each([4, 8] as const)( "enforces aggregate ARG_MAX exactly for a wasm%s caller", - (pointerWidth) => { + async (pointerWidth) => { const exact = createSpawnPreflightHarness( buildArgMaxBoundarySpawnBlob(pointerWidth, 0), pointerWidth, ); exact.worker.handleSpawn(exact.channel, exact.args); + await drainSpawnGate(); expect(exact.onResolveSpawn).toHaveBeenCalledOnce(); expect(exact.completeChannel).not.toHaveBeenCalled(); @@ -1246,7 +1314,6 @@ describe("SYS_SPAWN blob transport", () => { const onResolveSpawn = vi.fn(); const worker = createWorker({ callbacks: { onResolveSpawn, onSpawn: vi.fn() }, - processes: new Map([[7, { channels: [channel], memory, ptrWidth: 4 }]]), completeChannel, }); const args = [ @@ -1296,10 +1363,7 @@ function createSpawnPreflightHarness( const onResolveSpawn = vi.fn(() => new Promise(() => {})); const worker = createWorker({ callbacks: { onResolveSpawn, onSpawn: vi.fn() }, - processes: new Map([[ - 7, - { channels: [channel], memory, ptrWidth: pointerWidth }, - ]]), + pointerWidth, completeChannel, }); const args = [ @@ -1319,68 +1383,152 @@ function createSpawnPreflightHarness( }; } -function createWorker(overrides: Record): any { +interface SpawnWorkerTestOptions { + readonly callbacks?: CentralizedKernelCallbacks; + readonly completeChannel?: (...args: any[]) => void; + readonly kernelExports?: Readonly>; + readonly kernelMemory?: WebAssembly.Memory; + readonly pointerWidth?: 4 | 8; + readonly scratchPointer?: number; +} + +interface SpawnWorkerTestHarness { + readonly getSpawnScratchCapacity: () => number; + readonly handleSpawn: (channel: any, origArgs: number[]) => void; + readonly handleSpawnAfterResolve: ( + channel: any, + origArgs: number[], + parentPid: number, + callerTid: number, + pidOutPtr: number, + blobBytes: Uint8Array, + blobLen: number, + program: ReturnType, + envp: string[], + ) => void; +} + +function createWorker( + options: SpawnWorkerTestOptions, +): SpawnWorkerTestHarness { const { scratchPointer, - ...workerOverrides - } = overrides as Record & { scratchPointer?: number }; - const worker = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - kernel: { - toKernelPtr: (value: number | bigint) => Number(value), - getKernelPtrWidth: () => 4, - }, - callbacks: {}, - processes: new Map(), - channelTids: new Map(), - hostReaped: new Set(), - sharedMappings: new Map(), - tcpListenerTargets: new Map(), - tcpListenerRRIndex: new Map(), - tcpListeners: new Map(), - epollInterests: new Map(), - completeChannel: vi.fn(), - ...workerOverrides, + callbacks, + completeChannel, + kernelExports, + kernelMemory: suppliedKernelMemory, + pointerWidth = 4, + } = options; + const kernelMemory = suppliedKernelMemory + ?? new WebAssembly.Memory({ initial: 2, maximum: 2 }); + const mainScratchPointer = + Number.isSafeInteger(scratchPointer) && scratchPointer! > 0 + ? scratchPointer! + : 1024; + const implementations: Record = { + kernel_get_parent_pid: vi.fn(() => -1), + kernel_get_process_exit_signal: vi.fn(() => -1), + kernel_mark_process_signaled: vi.fn(() => 0), + ...(kernelExports ?? {}), + }; + const gate = new KernelEntryGate(); + const rawInstance = createKernelScratchTestInstance( + pointerWidth, + kernelMemory, + () => implementations, + () => + pointerWidth === 8 + ? BigInt(mainScratchPointer) + : mainScratchPointer, + 4, + Object.keys(implementations), + ); + const instance = createKernelEntryGatedInstance(rawInstance, gate); + const mainScratch = allocateKernelScratchRegion( + kernelMemory, + instance.exports.kernel_alloc_scratch as + (size: number) => number | bigint, + CH_TOTAL_SIZE, + pointerWidth, + "spawn transport test main scratch", + instance, + ); + const worker = createCentralizedKernelWorkerTestDouble({ callbacks }); + worker.testAuthority.initializeKernelForTest({ + instance, + gate, + mainScratch, + }); + const activeChannels = new Set(); + worker.testAuthority.configureScratchBoundaryHooksForTest({ + completeChannel: + (completeChannel ?? vi.fn()) as (...args: any[]) => void, + getPtrWidth: () => pointerWidth, + guestTidForChannel: (channel) => channel.pid, + isRegisteredChannel: (channel) => activeChannels.has(channel), }); - const kernelInstance = worker.kernelInstance ?? { exports: {} }; - worker.kernelInstance = { - ...kernelInstance, - exports: { - kernel_get_process_exit_signal: vi.fn(() => -1), - ...(kernelInstance.exports ?? {}), + + return Object.freeze({ + getSpawnScratchCapacity: (): number => + worker.getSpawnScratchCapacity(), + handleSpawn: (channel: any, origArgs: number[]): void => { + activeChannels.add(channel); + worker.testAuthority.dispatchSpawnPreflightForTest( + channel, + origArgs, + ); }, - }; - if ( - worker.kernelMemory && - Number.isSafeInteger(scratchPointer) && - scratchPointer! > 0 && - !worker.scratchRegion - ) { - const scratchTestInstance = createKernelScratchTestInstance( - 4, - worker.kernelMemory, - () => worker.kernelInstance?.exports ?? {}, - () => scratchPointer!, - ); - worker.scratchTestInstance = scratchTestInstance; - worker.scratchRegion = allocateKernelScratchRegion( - worker.kernelMemory, - scratchTestInstance.exports.kernel_alloc_scratch as - (size: number) => number, - CH_TOTAL_SIZE, - 4, - "test kernel syscall scratch", - scratchTestInstance, - ); + handleSpawnAfterResolve: ( + channel: any, + origArgs: number[], + parentPid: number, + callerTid: number, + pidOutPtr: number, + blobBytes: Uint8Array, + blobLen: number, + program: ReturnType, + envp: string[], + ): void => { + activeChannels.add(channel); + worker.testAuthority.dispatchSpawnAfterResolveForTest({ + channel, + origArgs, + parentPid, + callerTid, + pidOutPtr, + blobBytes, + blobLen, + program, + envp, + }); + }, + }) as SpawnWorkerTestHarness; +} + +async function drainSpawnGate(): Promise { + // A successful spawn stage publishes host launch only after its exact + // kernel-entry scope is revoked. Drain the finite queue before beginning a + // second reservation or asserting on that detached callback. + // The complete preflight path crosses resolution, a fresh result ingress, + // and launch publication. Keep a fixed upper bound rather than using a + // timer-based poll that could hide a permanently stuck gate. + for (let turn = 0; turn < 24; turn++) { + await Promise.resolve(); } - return worker; } function createChannel(pid: number, memory: WebAssembly.Memory): any { + const i32View = new Int32Array(memory.buffer); + Atomics.store( + i32View, + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + CHANNEL_STATUS_PENDING, + ); return { pid, memory, channelOffset: 0, - i32View: new Int32Array(memory.buffer), + i32View, consecutiveSyscalls: 0, }; } diff --git a/host/test/spawn-pid-authority.test.ts b/host/test/spawn-pid-authority.test.ts index 06fbee6952..322bdd4c3d 100644 --- a/host/test/spawn-pid-authority.test.ts +++ b/host/test/spawn-pid-authority.test.ts @@ -4,13 +4,21 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it, vi } from "vitest"; import { + type CentralizedKernelCallbacks, + createCentralizedKernelWorkerTestDouble, CAPTURED_STDIO, - CentralizedKernelWorker, } from "../src/kernel-worker"; +import { KernelReentrantEntryError } from "../src/kernel-entry-gate"; import { WASM_PAGE_SIZE } from "../src/constants"; import { writeForkContinuationAnchor } from "../src/fork-continuation"; import { FORK_SAVE_BUFFER_SIZE } from "../src/process-memory"; import { + CHANNEL_STATUS_PENDING, + CH_ARGS, + CH_ARGS_COUNT, + CH_ARG_SIZE, + CH_STATUS, + CH_SYSCALL, HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS, HOST_INTERCEPTED_SYSCALLS, WPK_FORK_LINKED_FRAME_POINTER_WIDTHS, @@ -23,6 +31,7 @@ const WASM32_CONTINUATION_HEADER_SIZE = .chunkHeaderSize; const TEST_FORK_CONTINUATION = 2 * WASM_PAGE_SIZE + WASM32_CONTINUATION_HEADER_SIZE; +const UNTRACKED_THREAD_CHANNEL_OFFSET = 2 * WASM_PAGE_SIZE; function publishMainForkContinuation( memory: WebAssembly.Memory, @@ -39,115 +48,131 @@ function publishMainForkContinuation( describe("kernel task-ID authority", () => { it("does not substitute the process leader for a pthread missing its TID mapping", () => { const parentPid = 77; - const memory = new WebAssembly.Memory({ initial: 4, maximum: 4, shared: true }); - const mainChannel = { pid: parentPid, channelOffset: WASM_PAGE_SIZE, memory }; - const threadChannel = { - pid: parentPid, - channelOffset: 2 * WASM_PAGE_SIZE, - memory, - }; const onFork = vi.fn(); const onResolveSpawn = vi.fn(); const onSpawn = vi.fn(); const kernelForkProcess = vi.fn(() => 100); - const kernelWorker = Object.assign( - Object.create(CentralizedKernelWorker.prototype), - { - callbacks: { onFork, onResolveSpawn, onSpawn }, - processes: new Map([ - [parentPid, { channels: [mainChannel, threadChannel] }], - ]), - channelTids: new Map(), - kernelInstance: { - exports: { kernel_fork_process: kernelForkProcess }, - }, - }, - ) as CentralizedKernelWorker; + const forkHarness = createTaskAuthorityHarness({ + pid: parentPid, + callbacks: { onFork }, + kernelExports: { kernel_fork_process: kernelForkProcess }, + }); const expected = - `No kernel-validated TID for non-main channel ${threadChannel.channelOffset} ` + + `No kernel-validated TID for non-main channel ${UNTRACKED_THREAD_CHANNEL_OFFSET} ` + `of process ${parentPid}`; - expect(() => (kernelWorker as any).handleFork(threadChannel, [0])) - .toThrow(expected); - expect(() => (kernelWorker as any).handleSpawn(threadChannel, [0, 0, 0, 0, 0, 0])) - .toThrow(expected); + expectEntryCause( + () => + forkHarness.worker.testAuthority + .dispatchUntrackedForkForTaskAuthorityTest( + parentPid, + forkHarness.channel, + UNTRACKED_THREAD_CHANNEL_OFFSET, + [0], + ), + expected, + ); expect(kernelForkProcess).not.toHaveBeenCalled(); expect(onFork).not.toHaveBeenCalled(); + + const spawnHarness = createTaskAuthorityHarness({ + pid: parentPid, + callbacks: { onResolveSpawn, onSpawn }, + }); + const spawnThread = untrackedThreadChannel( + parentPid, + spawnHarness.processMemory, + ); + expectEntryCause( + () => + spawnHarness.worker.testAuthority + .dispatchSpawnPreflightForTest( + spawnThread, + [0, 0, 0, 0, 0, 0], + ), + expected, + ); expect(onResolveSpawn).not.toHaveBeenCalled(); expect(onSpawn).not.toHaveBeenCalled(); }); it("does not let an untracked pthread replace the leader's program image", () => { const pid = 77; - const memory = new WebAssembly.Memory({ initial: 4, maximum: 4, shared: true }); - const mainChannel = { pid, channelOffset: WASM_PAGE_SIZE, memory }; - const threadChannel = { pid, channelOffset: 2 * WASM_PAGE_SIZE, memory }; const pathPtr = 16; - new Uint8Array(memory.buffer).set( + const onExec = vi.fn(async () => 0); + const execHarness = createTaskAuthorityHarness({ + pid, + callbacks: { onExec }, + }); + new Uint8Array(execHarness.processMemory.buffer).set( new TextEncoder().encode("/bin/program\0"), pathPtr, ); - const onExec = vi.fn(async () => 0); - const kernelWorker = Object.assign( - Object.create(CentralizedKernelWorker.prototype), - { - callbacks: { onExec }, - processes: new Map([ - [pid, { channels: [mainChannel, threadChannel], ptrWidth: 4 }], - ]), - channelTids: new Map(), - completeChannel: vi.fn(), - }, - ) as CentralizedKernelWorker; const expected = - `No kernel-validated TID for non-main channel ${threadChannel.channelOffset} ` + + `No kernel-validated TID for non-main channel ${UNTRACKED_THREAD_CHANNEL_OFFSET} ` + `of process ${pid}`; - expect(() => (kernelWorker as any).handleExec( - threadChannel, - [pathPtr, 0, 0], - )).toThrow(expected); - expect(() => (kernelWorker as any).handleExecveat( - threadChannel, - [-100, pathPtr, 0, 0, 0], - )).toThrow(expected); + expectEntryCause( + () => + execHarness.worker.testAuthority + .dispatchUntrackedExecForTaskAuthorityTest( + pid, + execHarness.channel, + UNTRACKED_THREAD_CHANNEL_OFFSET, + [pathPtr, 0, 0], + ), + expected, + ); + + const execveatHarness = createTaskAuthorityHarness({ + pid, + callbacks: { onExec }, + }); + new Uint8Array(execveatHarness.processMemory.buffer).set( + new TextEncoder().encode("/bin/program\0"), + pathPtr, + ); + expectEntryCause( + () => + execveatHarness.worker.testAuthority + .dispatchUntrackedExecveatForTaskAuthorityTest( + pid, + execveatHarness.channel, + UNTRACKED_THREAD_CHANNEL_OFFSET, + [-100, pathPtr, 0, 0, 0], + ), + expected, + ); expect(onExec).not.toHaveBeenCalled(); }); it("rejects a zero fork result before launching a child Worker", () => { const parentPid = 77; - const memory = new WebAssembly.Memory({ initial: 4, maximum: 4, shared: true }); - const channel = { pid: parentPid, channelOffset: WASM_PAGE_SIZE, memory }; - publishMainForkContinuation(memory, channel.channelOffset); - const completeChannel = vi.fn(); const onFork = vi.fn(); const kernelForkProcess = vi.fn(() => 0); - const kernelWorker = Object.assign( - Object.create(CentralizedKernelWorker.prototype), - { - callbacks: { onFork }, - processes: new Map([[parentPid, { channels: [channel] }]]), - channelTids: new Map(), - threadForkContexts: new Map(), - sharedMappings: new Map(), - tcpListenerTargets: new Map(), - epollInterests: new Map(), - completeChannel, - kernelInstance: { - exports: { - kernel_fork_process: kernelForkProcess, - kernel_get_process_exit_signal: vi.fn(() => -1), - }, - }, - }, - ) as CentralizedKernelWorker; - const origArgs = [0]; + const harness = createTaskAuthorityHarness({ + pid: parentPid, + callbacks: { onFork }, + kernelExports: { kernel_fork_process: kernelForkProcess }, + }); + publishMainForkContinuation( + harness.processMemory, + harness.channel.channelOffset, + ); + const origArgs = [0, 0, 0, 0, 0, 0]; + writeChannelSyscall( + harness.channel, + HOST_INTERCEPTED_SYSCALLS.SYS_FORK, + origArgs, + ); - (kernelWorker as any).handleFork(channel, origArgs); + harness.worker.testAuthority.dispatchScratchBoundarySyscallForTest( + harness.channel, + ); expect(onFork).not.toHaveBeenCalled(); - expect(completeChannel).toHaveBeenCalledWith( - channel, + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, HOST_INTERCEPTED_SYSCALLS.SYS_FORK, origArgs, undefined, @@ -158,55 +183,36 @@ describe("kernel task-ID authority", () => { it("rejects zero before a host callback can attach an unallocated spawn child", () => { const parentPid = 77; - const memory = new WebAssembly.Memory({ initial: 4, maximum: 4, shared: true }); - const channel = { pid: parentPid, channelOffset: WASM_PAGE_SIZE, memory }; - const kernelMemory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); - const completeChannel = vi.fn(); const onSpawn = vi.fn(async () => 0); const kernelSpawnProcess = vi.fn(() => 0); - const kernelWorker = Object.assign( - Object.create(CentralizedKernelWorker.prototype), - { - callbacks: { onSpawn }, - kernel: { - toKernelPtr(value: number | bigint): number { - return Number(value); - }, - }, - kernelMemory, - completeChannel, - kernelInstance: { - exports: { kernel_spawn_process: kernelSpawnProcess }, - }, - }, - ) as CentralizedKernelWorker; - const scratchPointer = installKernelWorkerTestScratch( - kernelWorker as unknown as Record, - kernelMemory, - ); + const harness = createTaskAuthorityHarness({ + pid: parentPid, + callbacks: { onSpawn }, + kernelExports: { kernel_spawn_process: kernelSpawnProcess }, + }); const origArgs = [1, 2, 3, 4, 5, 0]; - (kernelWorker as any).handleSpawnAfterResolve( - channel, + harness.worker.testAuthority.dispatchSpawnAfterResolveForTest({ + channel: harness.channel, origArgs, parentPid, - parentPid, - 5, - new Uint8Array([1]), - 1, - {}, - [], - ); + callerTid: parentPid, + pidOutPtr: 5, + blobBytes: new Uint8Array([1]), + blobLen: 1, + program: {} as never, + envp: [], + }); expect(kernelSpawnProcess).toHaveBeenCalledWith( parentPid, parentPid, - scratchPointer, + harness.scratchPointer, 1, ); expect(onSpawn).not.toHaveBeenCalled(); - expect(completeChannel).toHaveBeenCalledWith( - channel, + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN, origArgs, undefined, @@ -218,60 +224,57 @@ describe("kernel task-ID authority", () => { it("uses the PID returned by Rust while fork registration is pending", async () => { const parentPid = 77; const childPid = 347; - const memory = new WebAssembly.Memory({ initial: 4, maximum: 4, shared: true }); - const channel = { pid: parentPid, channelOffset: WASM_PAGE_SIZE, memory }; - publishMainForkContinuation(memory, channel.channelOffset); - const completeChannel = vi.fn(); let finishForkRegistration!: (offsets: number[]) => void; const forkRegistration = new Promise((resolve) => { finishForkRegistration = resolve; }); const onFork = vi.fn(() => forkRegistration); const kernelForkProcess = vi.fn(() => childPid); - const kernelWorker = Object.assign( - Object.create(CentralizedKernelWorker.prototype), - { - callbacks: { onFork }, - processes: new Map([[parentPid, { channels: [channel] }]]), - channelTids: new Map(), - threadForkContexts: new Map(), - sharedMappings: new Map(), - tcpListenerTargets: new Map(), - epollInterests: new Map(), - completeChannel, - kernelInstance: { - exports: { - kernel_fork_process: kernelForkProcess, - kernel_clear_fork_child: vi.fn(() => 0), - kernel_get_process_exit_signal: vi.fn(() => -1), - }, - }, + const harness = createTaskAuthorityHarness({ + pid: parentPid, + callbacks: { onFork }, + kernelExports: { + kernel_fork_process: kernelForkProcess, + kernel_clear_fork_child: vi.fn(() => 0), }, - ) as CentralizedKernelWorker; + }); + publishMainForkContinuation( + harness.processMemory, + harness.channel.channelOffset, + ); + const origArgs = [0, 0, 0, 0, 0, 0]; + writeChannelSyscall( + harness.channel, + HOST_INTERCEPTED_SYSCALLS.SYS_FORK, + origArgs, + ); - (kernelWorker as any).handleFork(channel, [0]); + harness.worker.testAuthority.dispatchScratchBoundarySyscallForTest( + harness.channel, + ); + await Promise.resolve(); expect(kernelForkProcess).toHaveBeenCalledOnce(); expect(kernelForkProcess).toHaveBeenCalledWith(parentPid, parentPid); expect(onFork).toHaveBeenCalledWith({ parentPid, childPid, - parentMemory: memory, + parentMemory: harness.processMemory, continuation: { kind: "main", forkBufAddr: TEST_FORK_CONTINUATION, }, }); - expect((kernelWorker as any).processes.has(childPid)).toBe(false); - expect("allocateTopLevelSpawnPid" in kernelWorker).toBe(false); + expect(harness.completeChannel).not.toHaveBeenCalled(); + expect("allocateTopLevelSpawnPid" in harness.worker).toBe(false); finishForkRegistration([WASM_PAGE_SIZE]); await forkRegistration; - await Promise.resolve(); - expect(completeChannel).toHaveBeenCalledWith( - channel, + await drainTaskAuthorityGate(); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, HOST_INTERCEPTED_SYSCALLS.SYS_FORK, - [0], + origArgs, undefined, childPid, 0, @@ -280,60 +283,156 @@ describe("kernel task-ID authority", () => { it("returns the kernel-assigned PID for top-level process creation", () => { const createProcess = vi.fn(() => 912); - const kernelWorker = Object.assign( - Object.create(CentralizedKernelWorker.prototype), - { - initialized: true, - kernelInstance: { - exports: { kernel_create_process_with_stdio: createProcess }, - }, + const harness = createTaskAuthorityHarness({ + kernelExports: { + kernel_create_process_with_stdio: createProcess, }, - ) as CentralizedKernelWorker; + }); - expect(kernelWorker.createProcess(CAPTURED_STDIO)).toBe(912); + expect(harness.worker.createProcess(CAPTURED_STDIO)).toBe(912); expect(createProcess).toHaveBeenCalledWith(0, 0, 0); }); it("accepts ESRCH as idempotent success when Rust already removed a process", () => { const removeProcess = vi.fn(() => -3); - const drainWakeups = vi.fn(); - const kernelWorker = Object.assign( - Object.create(CentralizedKernelWorker.prototype), - { - initialized: true, - kernelInstance: { - exports: { kernel_remove_process: removeProcess }, - }, - drainAndProcessWakeupEvents: drainWakeups, + const drainWakeups = vi.fn(() => 0); + const harness = createTaskAuthorityHarness({ + kernelExports: { + kernel_remove_process: removeProcess, + kernel_drain_wakeup_events: drainWakeups, }, - ) as CentralizedKernelWorker; + }); - expect(() => kernelWorker.removeProcessFromKernelTable(912)).not.toThrow(); + expect(() => + harness.worker.removeProcessFromKernelTable(912) + ).not.toThrow(); expect(removeProcess).toHaveBeenCalledWith(912); expect(drainWakeups).toHaveBeenCalledOnce(); }); it("fails closed when Rust rejects process removal for any other reason", () => { const removeProcess = vi.fn(() => -5); - const drainWakeups = vi.fn(); - const kernelWorker = Object.assign( - Object.create(CentralizedKernelWorker.prototype), - { - initialized: true, - kernelInstance: { - exports: { kernel_remove_process: removeProcess }, - }, - drainAndProcessWakeupEvents: drainWakeups, + const drainWakeups = vi.fn(() => 0); + const harness = createTaskAuthorityHarness({ + kernelExports: { + kernel_remove_process: removeProcess, + kernel_drain_wakeup_events: drainWakeups, }, - ) as CentralizedKernelWorker; + }); - expect(() => kernelWorker.removeProcessFromKernelTable(913)).toThrow( + expectEntryCause( + () => harness.worker.removeProcessFromKernelTable(913), "Kernel could not remove process 913: errno 5", ); expect(removeProcess).toHaveBeenCalledWith(913); expect(drainWakeups).not.toHaveBeenCalled(); }); + it.each(untrackedTaskAuthorityOperations)( + "does not enqueue reentrant $name test dispatch", + async ({ args, invoke }) => { + const callerRead = vi.fn(); + let nestedError: unknown; + let invokeNested = (): void => { + throw new Error("nested test dispatch was not installed"); + }; + const createProcess = vi.fn(() => { + try { + invokeNested(); + } catch (error) { + nestedError = error; + } + return 912; + }); + const harness = createTaskAuthorityHarness({ + callbacks: { + onExec: vi.fn(async () => 0), + onFork: vi.fn(async () => [WASM_PAGE_SIZE]), + }, + kernelExports: { + kernel_create_process_with_stdio: createProcess, + kernel_fork_process: vi.fn(() => 347), + }, + }); + const hostileRegistrationWitness = new Proxy( + harness.channel, + { + get(target, property, receiver) { + callerRead(`channel:${String(property)}`); + return Reflect.get(target, property, receiver); + }, + }, + ); + const hostileArgs = new Proxy([...args], { + get(target, property, receiver) { + callerRead(`args:${String(property)}`); + return Reflect.get(target, property, receiver); + }, + }); + invokeNested = () => + invoke( + harness.worker, + 77, + hostileRegistrationWitness, + UNTRACKED_THREAD_CHANNEL_OFFSET, + hostileArgs, + ); + + expect(harness.worker.createProcess(CAPTURED_STDIO)).toBe(912); + expect(nestedError).toBeInstanceOf(KernelReentrantEntryError); + expect(callerRead).not.toHaveBeenCalled(); + await drainTaskAuthorityGate(); + expect(callerRead).not.toHaveBeenCalled(); + expect(createProcess).toHaveBeenCalledOnce(); + }, + ); + + it.each(untrackedTaskAuthorityOperations)( + "rejects a cross-generation channel for $name test dispatch", + async ({ args, invoke }) => { + const onExec = vi.fn(async () => 0); + const onFork = vi.fn(async () => [WASM_PAGE_SIZE]); + const forkProcess = vi.fn(() => 347); + const createProcess = vi.fn(() => 912); + const harness = createTaskAuthorityHarness({ + callbacks: { onExec, onFork }, + kernelExports: { + kernel_create_process_with_stdio: createProcess, + kernel_fork_process: forkProcess, + }, + }); + const staleRegistrationWitness = harness.channel; + const replacementMemory = new WebAssembly.Memory({ + initial: 4, + maximum: 4, + shared: true, + }); + harness.worker.testAuthority + .replaceProcessRegistrationForLifecycleTest({ + pid: 77, + memory: replacementMemory, + channelOffsets: [WASM_PAGE_SIZE], + }); + + expect(() => + invoke( + harness.worker, + 77, + staleRegistrationWitness, + UNTRACKED_THREAD_CHANNEL_OFFSET, + [...args], + ) + ).toThrow(/current process Memory generation/); + expect(onExec).not.toHaveBeenCalled(); + expect(onFork).not.toHaveBeenCalled(); + expect(forkProcess).not.toHaveBeenCalled(); + expect(harness.completeChannel).not.toHaveBeenCalled(); + await drainTaskAuthorityGate(); + expect(harness.worker.createProcess(CAPTURED_STDIO)).toBe(912); + expect(createProcess).toHaveBeenCalledOnce(); + }, + ); + it("routes Node and browser top-level spawns through Rust creation", () => { const nodeEntry = readFileSync( join(repoRoot, "host", "src", "node-kernel-worker-entry.ts"), @@ -394,3 +493,213 @@ describe("kernel task-ID authority", () => { } }); }); + +type TaskAuthorityWorker = ReturnType< + typeof createCentralizedKernelWorkerTestDouble +>; +type TaskAuthorityChannel = ReturnType< + TaskAuthorityWorker["testAuthority"][ + "replaceProcessRegistrationForLifecycleTest" + ] +>[number]; + +interface UntrackedTaskAuthorityOperation { + readonly name: string; + readonly args: readonly number[]; + readonly invoke: ( + worker: TaskAuthorityWorker, + pid: number, + registrationWitness: TaskAuthorityChannel, + channelOffset: number, + args: number[], + ) => void; +} + +const untrackedTaskAuthorityOperations: + readonly UntrackedTaskAuthorityOperation[] = [ + { + name: "fork", + args: [0], + invoke: ( + worker, + pid, + registrationWitness, + channelOffset, + args, + ) => + worker.testAuthority.dispatchUntrackedForkForTaskAuthorityTest( + pid, + registrationWitness, + channelOffset, + args, + ), + }, + { + name: "exec", + args: [16, 0, 0], + invoke: ( + worker, + pid, + registrationWitness, + channelOffset, + args, + ) => + worker.testAuthority.dispatchUntrackedExecForTaskAuthorityTest( + pid, + registrationWitness, + channelOffset, + args, + ), + }, + { + name: "execveat", + args: [-100, 16, 0, 0, 0], + invoke: ( + worker, + pid, + registrationWitness, + channelOffset, + args, + ) => + worker.testAuthority.dispatchUntrackedExecveatForTaskAuthorityTest( + pid, + registrationWitness, + channelOffset, + args, + ), + }, + ]; + +interface TaskAuthorityHarnessOptions { + readonly pid?: number; + readonly callbacks?: CentralizedKernelCallbacks; + readonly kernelExports?: Readonly>; + readonly processMemory?: WebAssembly.Memory; +} + +interface TaskAuthorityHarness { + readonly worker: TaskAuthorityWorker; + readonly kernelMemory: WebAssembly.Memory; + readonly processMemory: WebAssembly.Memory; + readonly channel: TaskAuthorityChannel; + readonly completeChannel: ReturnType; + readonly scratchPointer: number; +} + +function createTaskAuthorityHarness( + options: TaskAuthorityHarnessOptions = {}, +): TaskAuthorityHarness { + const pid = options.pid ?? 77; + const processMemory = options.processMemory + ?? new WebAssembly.Memory({ + initial: 4, + maximum: 4, + shared: true, + }); + const kernelMemory = new WebAssembly.Memory({ + initial: 2, + maximum: 2, + }); + const implementations: Record = { + kernel_drain_wakeup_events: vi.fn(() => 0), + kernel_get_parent_pid: vi.fn(() => -1), + kernel_get_process_exit_signal: vi.fn(() => -1), + kernel_mark_process_signaled: vi.fn(() => 0), + kernel_set_current_tid: vi.fn(() => 0), + ...(options.kernelExports ?? {}), + }; + const worker = createCentralizedKernelWorkerTestDouble({ + callbacks: options.callbacks, + }); + const scratchPointer = installKernelWorkerTestScratch( + worker, + kernelMemory, + 128, + 4, + { + kernelExports: implementations, + kernelExportNames: Object.keys(implementations), + }, + ); + const [channel] = + worker.testAuthority.replaceProcessRegistrationForLifecycleTest({ + pid, + memory: processMemory, + channelOffsets: [WASM_PAGE_SIZE], + }); + if (channel === undefined) { + throw new Error("task-authority harness did not register a channel"); + } + const completeChannel = vi.fn(); + worker.testAuthority.configureScratchBoundaryHooksForTest({ + completeChannel, + }); + return { + worker, + kernelMemory, + processMemory, + channel, + completeChannel, + scratchPointer, + }; +} + +function untrackedThreadChannel( + pid: number, + memory: WebAssembly.Memory, +): TaskAuthorityChannel { + const channelOffset = UNTRACKED_THREAD_CHANNEL_OFFSET; + return { + pid, + memory, + channelOffset, + i32View: new Int32Array(memory.buffer, channelOffset), + consecutiveSyscalls: 0, + }; +} + +function writeChannelSyscall( + channel: TaskAuthorityChannel, + syscall: number, + args: readonly number[], +): void { + Atomics.store( + channel.i32View, + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + CHANNEL_STATUS_PENDING, + ); + const view = new DataView( + channel.memory.buffer, + channel.channelOffset, + ); + view.setUint32(CH_SYSCALL, syscall, true); + for (let index = 0; index < CH_ARGS_COUNT; index++) { + view.setBigInt64( + CH_ARGS + index * CH_ARG_SIZE, + BigInt(args[index] ?? 0), + true, + ); + } +} + +function expectEntryCause( + operation: () => unknown, + expectedMessage: string, +): void { + let thrown: unknown; + try { + operation(); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(Error); + const cause = (thrown as { cause?: unknown }).cause; + expect(cause).toBeInstanceOf(Error); + expect((cause as Error).message).toContain(expectedMessage); +} + +async function drainTaskAuthorityGate(): Promise { + for (let turn = 0; turn < 24; turn++) { + await Promise.resolve(); + } +} diff --git a/host/test/support/kernel-entry-context-audit.ts b/host/test/support/kernel-entry-context-audit.ts new file mode 100644 index 0000000000..c9351d0702 --- /dev/null +++ b/host/test/support/kernel-entry-context-audit.ts @@ -0,0 +1,2731 @@ +import ts from "typescript"; + +export type KernelEntryContextViolationKind = + | "async-detached-effect" + | "async-export-without-ingress" + | "bare-entry-selector" + | "context-alias" + | "context-async-capture" + | "context-cross-ingress-capture" + | "context-detached-capture" + | "context-return" + | "context-storage" + | "dynamic-entry-method-dispatch" + | "direct-kernel-instance-exports" + | "direct-eval-in-entry-graph" + | "export-call-without-entry-channel" + | "export-from-detached-effect" + | "host-effect-in-scoped-graph" + | "implicit-arguments-entry-authority" + | "indirect-entry-authority" + | "legacy-detached-operation" + | "missing-explicit-entry" + | "mutable-entry-method-dispatch" + | "nonlexical-detached-effect" + | "nonlexical-entry-operation" + | "protocol-effect-from-observer" + | "scoped-method-async" + | "transaction-continuation-without-channel-ingress"; + +export interface KernelEntryContextViolation { + readonly kind: KernelEntryContextViolationKind; + readonly owner: string; + readonly line: number; + readonly text: string; +} + +interface MethodCall { + readonly callee: string; + readonly node: ts.CallExpression; + readonly phase: ExecutionPhase; +} + +type ExecutionPhase = + | "active" + | "async-fresh" + | "detached-observer" + | "detached-protocol" + | "detached-transaction-start" + | "serialized-host" + | "transaction-continuation"; + +type DetachedEffectKind = "observer" | "protocol" | "transaction-start"; + +interface HostEffect { + readonly node: ts.Node; + readonly description: string; +} + +interface IndirectMethodReference { + readonly method: string; + readonly node: ts.PropertyAccessExpression | ts.ElementAccessExpression; +} + +interface DynamicThisDispatch { + readonly node: ts.CallExpression; + readonly phase: ExecutionPhase; +} + +interface ScopeScan { + readonly owner: string; + readonly contextName: string | null; + readonly contextSymbol: ts.Symbol | null; + readonly calls: MethodCall[]; + readonly directEvalCalls: ts.CallExpression[]; + readonly dynamicThisDispatches: DynamicThisDispatch[]; + readonly hostEffects: HostEffect[]; + readonly indirectMethodReferences: IndirectMethodReference[]; +} + +interface MethodInfo extends ScopeScan { + readonly node: ts.MethodDeclaration | ts.ConstructorDeclaration; + readonly contextParameterIndex: number | null; + readonly directlyExportBearing: boolean; +} + +const ROOT_INGRESS_METHODS = new Map([ + ["#runOrDeferKernelEntry", 1], + ["#runOrDeferChannelKernelEntry", 2], +]); +const ENTRY_SELECTORS = new Set([ + "#kernelInstanceForEntry", + "#kernelInstanceIfAvailableForEntry", +]); +const ASYNC_GLOBAL_CALLBACK_POSITIONS = new Map([ + ["queueMicrotask", [0]], + ["setImmediate", [0]], + ["setInterval", [0]], + ["setTimeout", [0]], +]); +const ASYNC_METHOD_CALLBACK_POSITIONS = new Map< + string, + readonly number[] +>([ + ["#continuePromise", [1, 2]], + ["#continueWaitAsyncListenerRoot", [2]], + ["#registerImmediate", [0]], + ["#registerInterval", [0]], + ["#registerTimeout", [0]], + ["#scheduleImmediateListenerRoot", [1]], + ["#scheduleMicrotaskListenerRoot", [1]], +]); +const ASYNC_MEMBER_CALLBACK_POSITIONS = new Map< + string, + readonly number[] +>([ + ["addEventListener", [1]], + ["catch", [0]], + ["finally", [0]], + ["on", [1]], + ["once", [1]], + ["then", [0, 1]], +]); +const PROMISE_CONTINUATION_CALLS = new Set(["catch", "finally", "then"]); +const ASYNC_CALLBACK_OBJECT_CALLS = new Map([ + [ + "bindUdp", + new Map>([ + [3, new Set(["receive"])], + ]), + ], + [ + "listenTcp", + new Map>([ + [3, new Set(["accept"])], + ]), + ], +]); +const ASYNC_CONSTRUCTOR_CALLBACK_OBJECTS = new Map([ + [ + "WasmPosixKernel", + new Map>([ + [ + 2, + new Set([ + "getKmsCanvas", + "getProcessMemory", + "markKmsCanvasGlOwned", + "onAlarm", + "onExec", + "onMapHostAnonymous", + "onMapHostFile", + "onMremapHostFile", + "onNetConnect", + "onNetListen", + "onRandom", + "onSendHttp", + "onShmAttach", + "onShmCreate", + "onShmDetach", + "onShmRemove", + "onStderr", + "onStdin", + "onStdout", + "onUdpBind", + "onUdpUnbind", + "onUnmapHostFile", + "onWaitpid", + "teardown", + ]), + ], + ]), + ], +]); +const STORED_COLLECTION_METHODS = new Set(["add", "push", "set"]); +const DETACHED_EFFECT_METHODS = new Map([ + ["deferObserverEffect", "observer"], + ["deferProtocolEffect", "protocol"], + ["deferProtocolTransactionStart", "transaction-start"], +]); +const SERIALIZED_HOST_OPERATION_METHOD = + "#invokeSharedMmapHostOperation"; +const PROTOCOL_EFFECT_ROOT_METHODS = new Set([ + ...ROOT_INGRESS_METHODS.keys(), + "failKernelInstance", + "publishPreparedChannelCompletion", + "relistenChannel", +]); +const FOUNDATIONAL_ENTRY_METHODS = new Set([ + ...ROOT_INGRESS_METHODS.keys(), + ...ENTRY_SELECTORS, + "#invokeEntryScratchExport", +]); + +function detachedEffectKind( + phase: ExecutionPhase, +): DetachedEffectKind | null { + if (phase === "detached-observer") return "observer"; + if (phase === "detached-protocol") return "protocol"; + if (phase === "detached-transaction-start") return "transaction-start"; + return null; +} + +function propertyNameText( + name: ts.PropertyName | ts.PrivateIdentifier | undefined, + source: ts.SourceFile, +): string | null { + if (name === undefined) return null; + if ( + ts.isIdentifier(name) + || ts.isPrivateIdentifier(name) + || ts.isStringLiteral(name) + || ts.isNumericLiteral(name) + ) { + return name.getText(source).replace(/^["']|["']$/g, ""); + } + return null; +} + +function unwrapExpression(expression: ts.Expression): ts.Expression { + let current = expression; + while ( + ts.isParenthesizedExpression(current) + || ts.isAsExpression(current) + || ts.isTypeAssertionExpression(current) + || ts.isNonNullExpression(current) + || ts.isSatisfiesExpression(current) + ) { + current = current.expression; + } + return current; +} + +type MemberAccessExpression = + | ts.PropertyAccessExpression + | ts.ElementAccessExpression; + +function isMemberAccessExpression( + node: ts.Node, +): node is MemberAccessExpression { + return ts.isPropertyAccessExpression(node) + || ts.isElementAccessExpression(node); +} + +function memberAccessName( + node: MemberAccessExpression, + source: ts.SourceFile, +): string | null { + if (ts.isPropertyAccessExpression(node)) { + return propertyNameText(node.name, source); + } + const argument = node.argumentExpression + ? unwrapExpression(node.argumentExpression) + : undefined; + return argument && ( + ts.isStringLiteral(argument) + || ts.isNumericLiteral(argument) + ) + ? argument.text + : null; +} + +function memberAccessReceiver( + node: MemberAccessExpression, +): ts.Expression { + return unwrapExpression(node.expression); +} + +function thisMethodName( + call: ts.CallExpression, + source: ts.SourceFile, + resolveElementName?: (expression: ts.Expression) => string | null, +): string | null { + const callee = unwrapExpression(call.expression); + if (!isMemberAccessExpression(callee)) return null; + const receiver = memberAccessReceiver(callee); + if (receiver.kind !== ts.SyntaxKind.ThisKeyword) return null; + const directName = memberAccessName(callee, source); + if (directName !== null || !ts.isElementAccessExpression(callee)) { + return directName; + } + return callee.argumentExpression === undefined + ? null + : resolveElementName?.(callee.argumentExpression) ?? null; +} + +function callPropertyName( + call: ts.CallExpression, + source: ts.SourceFile, +): string | null { + const callee = unwrapExpression(call.expression); + return isMemberAccessExpression(callee) + ? memberAccessName(callee, source) + : ts.isIdentifier(callee) + ? callee.text + : null; +} + +function isFunctionExpressionLike( + node: ts.Node | undefined, +): node is ts.ArrowFunction | ts.FunctionExpression { + return Boolean( + node && (ts.isArrowFunction(node) || ts.isFunctionExpression(node)), + ); +} + +function contextParameterIndex( + node: ts.SignatureDeclarationBase, +): number | null { + const isEntryContextType = ( + type: ts.TypeNode | undefined, + ): boolean => { + if (type === undefined) return false; + if (ts.isParenthesizedTypeNode(type)) { + return isEntryContextType(type.type); + } + if (ts.isUnionTypeNode(type)) { + let includesEntryContext = false; + for (const member of type.types) { + if (isEntryContextType(member)) { + includesEntryContext = true; + continue; + } + if ( + member.kind === ts.SyntaxKind.UndefinedKeyword + || ( + ts.isLiteralTypeNode(member) + && member.literal.kind === ts.SyntaxKind.NullKeyword + ) + ) { + continue; + } + return false; + } + return includesEntryContext; + } + return ts.isTypeReferenceNode(type) + && ts.isIdentifier(type.typeName) + && type.typeName.text === "KernelWorkerEntryContext" + && type.typeArguments === undefined; + }; + + for (let index = 0; index < node.parameters.length; index++) { + if (isEntryContextType(node.parameters[index]!.type)) { + return index; + } + } + return null; +} + +function parameterIdentifier( + node: ts.SignatureDeclarationBase, + index: number | null, +): string | null { + if (index === null) return null; + const name = node.parameters[index]?.name; + return name && ts.isIdentifier(name) ? name.text : null; +} + +function bindingIdentifiers(name: ts.BindingName): ts.Identifier[] { + if (ts.isIdentifier(name)) return [name]; + const result: ts.Identifier[] = []; + for (const element of name.elements) { + if (ts.isOmittedExpression(element)) continue; + result.push(...bindingIdentifiers(element.name)); + } + return result; +} + +function isAssignmentOperator(kind: ts.SyntaxKind): boolean { + return kind >= ts.SyntaxKind.FirstAssignment + && kind <= ts.SyntaxKind.LastAssignment; +} + +function isIdentifierValueReference(node: ts.Identifier): boolean { + const parent = node.parent; + if (ts.isPropertyAccessExpression(parent) && parent.name === node) { + return false; + } + if ( + ( + ts.isPropertyAssignment(parent) + || ts.isPropertyDeclaration(parent) + || ts.isMethodDeclaration(parent) + || ts.isGetAccessorDeclaration(parent) + || ts.isSetAccessorDeclaration(parent) + || ts.isMethodSignature(parent) + || ts.isPropertySignature(parent) + ) + && parent.name === node + ) { + return false; + } + if ( + ( + ts.isVariableDeclaration(parent) + || ts.isParameter(parent) + || ts.isBindingElement(parent) + || ts.isFunctionDeclaration(parent) + || ts.isFunctionExpression(parent) + || ts.isClassDeclaration(parent) + || ts.isClassExpression(parent) + ) + && parent.name === node + ) { + return false; + } + if ( + ts.isLabeledStatement(parent) + || ts.isBreakStatement(parent) + || ts.isContinueStatement(parent) + || ts.isImportSpecifier(parent) + || ts.isExportSpecifier(parent) + || ts.isTypeNode(parent) + ) { + return false; + } + return true; +} + +function directThisProperty( + node: MemberAccessExpression, + source: ts.SourceFile, +): string | null { + return memberAccessReceiver(node).kind === ts.SyntaxKind.ThisKeyword + ? memberAccessName(node, source) + : null; +} + +function isDirectKernelInstanceExports( + node: MemberAccessExpression, + source: ts.SourceFile, +): boolean { + if (memberAccessName(node, source) !== "exports") return false; + const receiver = memberAccessReceiver(node); + return isMemberAccessExpression(receiver) + && memberAccessReceiver(receiver).kind === ts.SyntaxKind.ThisKeyword + && memberAccessName(receiver, source) === "#kernelInstance"; +} + +function isDirectCallTarget(node: MemberAccessExpression): boolean { + let candidate: ts.Node = node; + let parent = candidate.parent; + while ( + parent + && ( + ts.isParenthesizedExpression(parent) + || ts.isAsExpression(parent) + || ts.isTypeAssertionExpression(parent) + || ts.isNonNullExpression(parent) + || ts.isSatisfiesExpression(parent) + ) + ) { + candidate = parent; + parent = parent.parent; + } + return Boolean( + parent + && ts.isCallExpression(parent) + && parent.expression === candidate, + ); +} + +function sourceLine(source: ts.SourceFile, node: ts.Node): number { + return source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1; +} + +function compactText(source: ts.SourceFile, node: ts.Node): string { + return node.getText(source).replace(/\s+/g, " ").slice(0, 240); +} + +function formatOwner( + member: ts.MethodDeclaration | ts.ConstructorDeclaration, + source: ts.SourceFile, +): string { + return ts.isConstructorDeclaration(member) + ? "CentralizedKernelWorker.constructor" + : `CentralizedKernelWorker.${propertyNameText(member.name, source)}`; +} + +/** + * Audit the lexical capability used while a void ingress owns the kernel gate. + * + * The check intentionally follows private method calls instead of maintaining + * a hand-written syscall allowlist. Adding a new helper therefore inherits the + * same explicit-context and detached-effect requirements automatically. + */ +export function auditKernelEntryContext( + sourceText: string, +): KernelEntryContextViolation[] { + const virtualFileName = "/kernel-worker.ts"; + const compilerOptions: ts.CompilerOptions = { + noLib: true, + noResolve: true, + target: ts.ScriptTarget.Latest, + }; + const parsedSource = ts.createSourceFile( + virtualFileName, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const compilerHost = ts.createCompilerHost(compilerOptions, true); + compilerHost.fileExists = (fileName) => fileName === virtualFileName; + compilerHost.readFile = (fileName) => + fileName === virtualFileName ? sourceText : undefined; + compilerHost.getSourceFile = (fileName) => + fileName === virtualFileName ? parsedSource : undefined; + const program = ts.createProgram( + [virtualFileName], + compilerOptions, + compilerHost, + ); + const source = program.getSourceFile(virtualFileName); + if (!source) throw new Error("kernel-worker.ts could not be parsed"); + const checker = program.getTypeChecker(); + const identifierSymbol = ( + node: ts.Node | undefined, + ): ts.Symbol | null => { + if (!node || !ts.isIdentifier(node)) return null; + // resolveName performs lexical binding lookup only. getSymbolAtLocation + // can ask the checker for a flow-narrowed type and recurse through the + // entire 20k-line syscall dispatch graph until the TypeScript checker + // exhausts its own stack. + return checker.resolveName( + node.text, + node, + ts.SymbolFlags.Value, + false, + ) ?? null; + }; + const exactImmutableLiteralName = ( + expression: ts.Expression, + visiting = new Set(), + ): string | null => { + const node = unwrapExpression(expression); + if ( + ts.isStringLiteral(node) + || ts.isNumericLiteral(node) + || ts.isNoSubstitutionTemplateLiteral(node) + ) { + return node.text; + } + if (!ts.isIdentifier(node)) return null; + const symbol = identifierSymbol(node); + if (symbol === null || visiting.has(symbol)) return null; + const declarations = symbol.declarations?.filter( + ts.isVariableDeclaration, + ) ?? []; + if (declarations.length !== 1) return null; + const declaration = declarations[0]!; + if ( + declaration.initializer === undefined + || !ts.isVariableDeclarationList(declaration.parent) + || ( + declaration.parent.flags & ts.NodeFlags.Const + ) === 0 + ) { + return null; + } + const nextVisiting = new Set(visiting); + nextVisiting.add(symbol); + return exactImmutableLiteralName( + declaration.initializer, + nextVisiting, + ); + }; + const resolvedThisMethodName = ( + call: ts.CallExpression, + ): string | null => + thisMethodName(call, source, exactImmutableLiteralName); + const isUnresolvedComputedThisDispatch = ( + call: ts.CallExpression, + ): boolean => { + const callee = unwrapExpression(call.expression); + return ts.isElementAccessExpression(callee) + && memberAccessReceiver(callee).kind === ts.SyntaxKind.ThisKeyword + && resolvedThisMethodName(call) === null; + }; + const isGenuineDirectEval = ( + call: ts.CallExpression, + ): boolean => { + if (call.questionDotToken !== undefined) return false; + const callee = unwrapExpression(call.expression); + return ts.isIdentifier(callee) + && callee.text === "eval" + && identifierSymbol(callee) === null; + }; + const exactSymbolIdentifier = ( + expression: ts.Expression | undefined, + expected: ts.Symbol | null, + ): boolean => { + if (expression === undefined || expected === null) return false; + return identifierSymbol(unwrapExpression(expression)) === expected; + }; + const containsAuthoritySymbol = ( + node: ts.Node, + authorities: ReadonlySet, + ): boolean => { + let found = false; + const visit = (candidate: ts.Node): void => { + if (found) return; + const symbol = identifierSymbol(candidate); + if (symbol !== null && authorities.has(symbol)) { + found = true; + return; + } + ts.forEachChild(candidate, visit); + }; + visit(node); + return found; + }; + const violations: KernelEntryContextViolation[] = []; + const seenViolations = new Set(); + const report = ( + kind: KernelEntryContextViolationKind, + owner: string, + node: ts.Node, + text: string, + ): void => { + const line = sourceLine(source, node); + const key = `${kind}:${owner}:${line}:${text}`; + if (seenViolations.has(key)) return; + seenViolations.add(key); + violations.push({ kind, owner, line, text }); + }; + + let workerClass: ts.ClassDeclaration | undefined; + for (const statement of source.statements) { + if ( + ts.isClassDeclaration(statement) + && statement.name?.text === "CentralizedKernelWorker" + ) { + workerClass = statement; + break; + } + } + if (!workerClass) { + throw new Error("CentralizedKernelWorker declaration was not found"); + } + + const capturedObjectIntrinsics = new Map< + "freeze" | "seal", + Set + >([ + ["freeze", new Set()], + ["seal", new Set()], + ]); + for (const statement of source.statements) { + if ( + !ts.isVariableStatement(statement) + || (statement.declarationList.flags & ts.NodeFlags.Const) === 0 + ) { + continue; + } + for (const declaration of statement.declarationList.declarations) { + if ( + !ts.isIdentifier(declaration.name) + || declaration.initializer === undefined + ) { + continue; + } + const initializer = unwrapExpression(declaration.initializer); + if (!ts.isPropertyAccessExpression(initializer)) continue; + const receiver = unwrapExpression(initializer.expression); + if ( + !ts.isIdentifier(receiver) + || receiver.text !== "Object" + || identifierSymbol(receiver) !== null + ) { + continue; + } + const operation = propertyNameText(initializer.name, source); + if (operation !== "freeze" && operation !== "seal") continue; + const symbol = identifierSymbol(declaration.name); + if (symbol !== null) capturedObjectIntrinsics.get(operation)!.add(symbol); + } + } + const isCapturedObjectIntrinsicCall = ( + expression: ts.Expression, + operation: "freeze" | "seal", + ): expression is ts.CallExpression => { + const node = unwrapExpression(expression); + if (!ts.isCallExpression(node)) return false; + const callee = unwrapExpression(node.expression); + return ts.isIdentifier(callee) + && ( + (identifierSymbol(callee) !== null + && capturedObjectIntrinsics + .get(operation)! + .has(identifierSymbol(callee)!)) + ); + }; + const workerClassSymbol = + workerClass.name === undefined + ? null + : identifierSymbol(workerClass.name); + const isWorkerPrototype = ( + expression: ts.Expression | undefined, + ): boolean => { + if (expression === undefined || workerClassSymbol === null) return false; + const node = unwrapExpression(expression); + return ts.isPropertyAccessExpression(node) + && propertyNameText(node.name, source) === "prototype" + && exactSymbolIdentifier(node.expression, workerClassSymbol); + }; + const prototypeIsFrozen = source.statements.some((statement) => { + if (!ts.isExpressionStatement(statement)) return false; + const expression = unwrapExpression(statement.expression); + return isCapturedObjectIntrinsicCall(expression, "freeze") + && isWorkerPrototype(expression.arguments[0]); + }); + const constructor = workerClass.members.find(ts.isConstructorDeclaration); + const isThisSealStatement = (statement: ts.Statement): boolean => { + if (!ts.isExpressionStatement(statement)) return false; + const expression = unwrapExpression(statement.expression); + return isCapturedObjectIntrinsicCall(expression, "seal") + && expression.arguments.length === 1 + && unwrapExpression(expression.arguments[0]!).kind + === ts.SyntaxKind.ThisKeyword; + }; + const testCapabilitySymbol = (() => { + for (const statement of source.statements) { + if ( + !ts.isVariableStatement(statement) + || (statement.declarationList.flags & ts.NodeFlags.Const) === 0 + ) { + continue; + } + for (const declaration of statement.declarationList.declarations) { + if ( + ts.isIdentifier(declaration.name) + && declaration.name.text + === "centralizedKernelWorkerTestCapability" + ) { + return identifierSymbol(declaration.name); + } + } + } + return null; + })(); + const isArgumentsTestCapability = ( + expression: ts.Expression, + ): boolean => { + const node = unwrapExpression(expression); + if (!ts.isBinaryExpression(node)) return false; + if (node.operatorToken.kind !== ts.SyntaxKind.EqualsEqualsEqualsToken) { + return false; + } + const isArgumentsIndexThree = (candidate: ts.Expression): boolean => { + const exact = unwrapExpression(candidate); + if (!ts.isElementAccessExpression(exact)) return false; + const receiver = unwrapExpression(exact.expression); + const index = exact.argumentExpression + ? unwrapExpression(exact.argumentExpression) + : undefined; + return ts.isIdentifier(receiver) + && receiver.text === "arguments" + && index !== undefined + && ts.isNumericLiteral(index) + && index.text === "3"; + }; + const isCapability = (candidate: ts.Expression): boolean => + testCapabilitySymbol !== null + && exactSymbolIdentifier(candidate, testCapabilitySymbol); + return ( + isArgumentsIndexThree(node.left) && isCapability(node.right) + ) || ( + isCapability(node.left) && isArgumentsIndexThree(node.right) + ); + }; + const productionInstancesAreSealed = Boolean( + constructor?.body?.statements.some((statement) => { + if (isThisSealStatement(statement)) return true; + if ( + !ts.isIfStatement(statement) + || !isArgumentsTestCapability(statement.expression) + || statement.elseStatement === undefined + ) { + return false; + } + const alternate = statement.elseStatement; + return ts.isBlock(alternate) + ? alternate.statements.some(isThisSealStatement) + : isThisSealStatement(alternate); + }), + ); + const isNewTarget = (expression: ts.Expression): boolean => { + const node = unwrapExpression(expression); + return ts.isMetaProperty(node) + && node.keywordToken === ts.SyntaxKind.NewKeyword + && node.name.text === "target"; + }; + const alwaysThrows = (statement: ts.Statement): boolean => + ts.isThrowStatement(statement) + || ( + ts.isBlock(statement) + && statement.statements.length > 0 + && ts.isThrowStatement(statement.statements[0]!) + ); + const subclassesAreRejected = Boolean( + constructor?.body?.statements.some((statement) => { + if ( + !ts.isIfStatement(statement) + || statement.elseStatement !== undefined + || !alwaysThrows(statement.thenStatement) + ) { + return false; + } + const condition = unwrapExpression(statement.expression); + if ( + !ts.isBinaryExpression(condition) + || condition.operatorToken.kind + !== ts.SyntaxKind.ExclamationEqualsEqualsToken + ) { + return false; + } + return ( + isNewTarget(condition.left) + && exactSymbolIdentifier(condition.right, workerClassSymbol) + ) || ( + exactSymbolIdentifier(condition.left, workerClassSymbol) + && isNewTarget(condition.right) + ); + }), + ); + const prototypeDispatchIsStable = + prototypeIsFrozen + && productionInstancesAreSealed + && subclassesAreRejected; + + /* + * These summaries are deliberately keyed by the exact class method and + * callback parameter position. A wrapper only inherits asynchronous + * behavior when it forwards one of its own parameters to a reviewed + * scheduler/listener position. This closes the "rename setTimeout behind a + * helper" hole without guessing from method spelling or treating ordinary + * synchronous higher-order functions as schedulers. + */ + const asyncMethodCallbackPositions = new Map>(); + for (const [name, positions] of ASYNC_METHOD_CALLBACK_POSITIONS) { + asyncMethodCallbackPositions.set(name, new Set(positions)); + } + const declaredMethods = new Map< + string, + ts.MethodDeclaration | ts.ConstructorDeclaration + >(); + for (const member of workerClass.members) { + if ( + !ts.isMethodDeclaration(member) + && !ts.isConstructorDeclaration(member) + ) { + continue; + } + const name = ts.isConstructorDeclaration(member) + ? "constructor" + : propertyNameText(member.name, source); + if (name !== null) declaredMethods.set(name, member); + } + const exactParameterIndex = ( + expression: ts.Expression | undefined, + declaration: ts.SignatureDeclarationBase, + ): number | null => { + if (expression === undefined) return null; + const node = unwrapExpression(expression); + if (!ts.isIdentifier(node)) return null; + const symbol = identifierSymbol(node); + if (symbol === null) return null; + for (let index = 0; index < declaration.parameters.length; index++) { + const parameter = declaration.parameters[index]!; + if ( + ts.isIdentifier(parameter.name) + && identifierSymbol(parameter.name) === symbol + ) { + return index; + } + } + return null; + }; + let asyncSummaryChanged = true; + while (asyncSummaryChanged) { + asyncSummaryChanged = false; + for (const [name, declaration] of declaredMethods) { + if (!declaration.body) continue; + let positions = asyncMethodCallbackPositions.get(name); + if (positions === undefined) { + positions = new Set(); + asyncMethodCallbackPositions.set(name, positions); + } + const inspect = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const callee = unwrapExpression(node.expression); + let callbackPositions: readonly number[] | undefined; + if ( + ts.isIdentifier(callee) + && identifierSymbol(callee) === null + ) { + callbackPositions = + ASYNC_GLOBAL_CALLBACK_POSITIONS.get(callee.text); + } + const methodName = resolvedThisMethodName(node); + if (methodName !== null) { + callbackPositions = + asyncMethodCallbackPositions.get(methodName) + ?? callbackPositions; + } + if (callbackPositions === undefined && isMemberAccessExpression(callee)) { + const member = memberAccessName(callee, source); + if ( + member !== null + && ASYNC_MEMBER_CALLBACK_POSITIONS.has(member) + ) { + callbackPositions = + ASYNC_MEMBER_CALLBACK_POSITIONS.get(member); + } + } + for (const callbackPosition of callbackPositions ?? []) { + const parameterIndex = exactParameterIndex( + node.arguments[callbackPosition], + declaration, + ); + if ( + parameterIndex !== null + && !positions!.has(parameterIndex) + ) { + positions!.add(parameterIndex); + asyncSummaryChanged = true; + } + } + } + if ( + ts.isBinaryExpression(node) + && isAssignmentOperator(node.operatorToken.kind) + && isMemberAccessExpression(unwrapExpression(node.left)) + && memberAccessName( + unwrapExpression(node.left) as MemberAccessExpression, + source, + ) === "onmessage" + ) { + const parameterIndex = exactParameterIndex( + node.right, + declaration, + ); + if ( + parameterIndex !== null + && !positions!.has(parameterIndex) + ) { + positions!.add(parameterIndex); + asyncSummaryChanged = true; + } + } + ts.forEachChild(node, inspect); + }; + inspect(declaration.body); + } + } + + const methods = new Map(); + const rootScopes: ScopeScan[] = []; + + const scanScope = ( + body: ts.Node, + owner: string, + contextName: string | null, + contextSymbol: ts.Symbol | null, + localCallables: ReadonlyMap< + ts.Symbol, + ts.ArrowFunction | ts.FunctionExpression + >, + ): Omit & { + directlyExportBearing: boolean; + } => { + const calls: MethodCall[] = []; + const directEvalCalls: ts.CallExpression[] = []; + const dynamicThisDispatches: DynamicThisDispatch[] = []; + const hostEffects: HostEffect[] = []; + const indirectMethodReferences: IndirectMethodReference[] = []; + let directlyExportBearing = false; + const ownsEntryContext = + contextName !== null && contextSymbol !== null; + const trustedSelector = + ENTRY_SELECTORS.has(owner.split(".").at(-1) ?? ""); + const scopedAuthoritySymbols = new Set( + contextSymbol === null ? [] : [contextSymbol], + ); + + const expressionReturnsAuthority = ( + expression: ts.Expression, + authorities: ReadonlySet, + selectorsReturnAuthority: boolean, + ): boolean => { + const node = unwrapExpression(expression); + if (ts.isIdentifier(node)) { + const symbol = identifierSymbol(node); + return symbol !== null && authorities.has(symbol); + } + if (ts.isPropertyAccessExpression(node)) { + return expressionReturnsAuthority( + node.expression, + authorities, + selectorsReturnAuthority, + ); + } + if (ts.isElementAccessExpression(node)) { + return expressionReturnsAuthority( + node.expression, + authorities, + selectorsReturnAuthority, + ); + } + if (ts.isCallExpression(node)) { + const methodName = resolvedThisMethodName(node); + if ( + selectorsReturnAuthority + && methodName !== null + && ENTRY_SELECTORS.has(methodName) + ) { + return true; + } + const callee = unwrapExpression(node.expression); + if ( + isMemberAccessExpression(callee) + && memberAccessName(callee, source) === "bind" + ) { + return expressionReturnsAuthority( + memberAccessReceiver(callee), + authorities, + selectorsReturnAuthority, + ) + || node.arguments.some((argument) => + expressionReturnsAuthority( + argument, + authorities, + selectorsReturnAuthority, + ) + ); + } + // Ordinary calls consume their receiver and arguments. Their return + // value is not authority unless the reviewed selector above says so. + return false; + } + if (isFunctionExpressionLike(node)) { + return containsAuthoritySymbol(node.body, authorities); + } + if (ts.isConditionalExpression(node)) { + return expressionReturnsAuthority( + node.whenTrue, + authorities, + selectorsReturnAuthority, + ) + || expressionReturnsAuthority( + node.whenFalse, + authorities, + selectorsReturnAuthority, + ); + } + if (ts.isBinaryExpression(node)) { + return expressionReturnsAuthority( + node.left, + authorities, + selectorsReturnAuthority, + ) + || expressionReturnsAuthority( + node.right, + authorities, + selectorsReturnAuthority, + ); + } + if (ts.isArrayLiteralExpression(node)) { + return node.elements.some( + (element) => + ts.isExpression(element) + && expressionReturnsAuthority( + element, + authorities, + selectorsReturnAuthority, + ), + ); + } + if (ts.isObjectLiteralExpression(node)) { + return node.properties.some((property) => { + if (ts.isShorthandPropertyAssignment(property)) { + const symbol = identifierSymbol(property.name); + return symbol !== null && authorities.has(symbol); + } + if (ts.isPropertyAssignment(property)) { + return expressionReturnsAuthority( + property.initializer, + authorities, + selectorsReturnAuthority, + ); + } + if (ts.isSpreadAssignment(property)) { + return expressionReturnsAuthority( + property.expression, + authorities, + selectorsReturnAuthority, + ); + } + if ( + ts.isMethodDeclaration(property) + || ts.isGetAccessorDeclaration(property) + || ts.isSetAccessorDeclaration(property) + ) { + return Boolean( + property.body + && containsAuthoritySymbol( + property.body, + authorities, + ), + ); + } + return false; + }); + } + return false; + }; + const expressionReturnsScopedAuthority = ( + expression: ts.Expression, + ): boolean => + expressionReturnsAuthority( + expression, + scopedAuthoritySymbols, + true, + ); + const expressionReturnsDirectContext = ( + expression: ts.Expression, + ): boolean => { + if (contextSymbol === null) return false; + const node = unwrapExpression(expression); + if (ts.isIdentifier(node)) { + return identifierSymbol(node) === contextSymbol; + } + if (isFunctionExpressionLike(node)) { + return containsAuthoritySymbol( + node.body, + new Set([contextSymbol]), + ); + } + if (ts.isCallExpression(node)) { + const callee = unwrapExpression(node.expression); + return isMemberAccessExpression(callee) + && memberAccessName(callee, source) === "bind" + && ( + expressionReturnsDirectContext(memberAccessReceiver(callee)) + || node.arguments.some(expressionReturnsDirectContext) + ); + } + if (ts.isConditionalExpression(node)) { + return expressionReturnsDirectContext(node.whenTrue) + || expressionReturnsDirectContext(node.whenFalse); + } + if (ts.isBinaryExpression(node)) { + return expressionReturnsDirectContext(node.left) + || expressionReturnsDirectContext(node.right); + } + if (ts.isArrayLiteralExpression(node)) { + return node.elements.some( + (element) => + ts.isExpression(element) + && expressionReturnsDirectContext(element), + ); + } + if (ts.isObjectLiteralExpression(node)) { + return node.properties.some((property) => { + if (ts.isShorthandPropertyAssignment(property)) { + return identifierSymbol(property.name) === contextSymbol; + } + if (ts.isPropertyAssignment(property)) { + return expressionReturnsDirectContext(property.initializer); + } + if (ts.isSpreadAssignment(property)) { + return expressionReturnsDirectContext(property.expression); + } + return false; + }); + } + // A property or element selected from the context is a derived scoped + // capability. It may be used synchronously, but the general authority + // tracker above still rejects returning, storing, or capturing it. + return false; + }; + const isScopedEntryInstanceExports = ( + node: MemberAccessExpression, + ): boolean => { + if (memberAccessName(node, source) !== "exports") return false; + const receiver = memberAccessReceiver(node); + return isMemberAccessExpression(receiver) + && memberAccessName(receiver, source) === "instance" + && exactSymbolIdentifier( + memberAccessReceiver(receiver), + contextSymbol, + ); + }; + const resolveLocalCallable = ( + expression: ts.Expression | undefined, + ): ts.ArrowFunction | ts.FunctionExpression | undefined => { + if (isFunctionExpressionLike(expression)) return expression; + if (!expression || !ts.isIdentifier(expression)) return undefined; + const symbol = identifierSymbol(expression); + return symbol === null ? undefined : localCallables.get(symbol); + }; + const visitedCallablePhases = new Map< + ts.ArrowFunction | ts.FunctionExpression, + Set + >(); + const visitCallable = ( + callback: ts.ArrowFunction | ts.FunctionExpression, + phase: ExecutionPhase, + ): void => { + let phases = visitedCallablePhases.get(callback); + if (phases === undefined) { + phases = new Set(); + visitedCallablePhases.set(callback, phases); + } + if (phases.has(phase)) return; + phases.add(phase); + visit(callback.body, phase); + }; + const hostOwnedRoot = ( + expression: ts.Expression, + ): string | null => { + let current = unwrapExpression(expression); + while ( + isMemberAccessExpression(current) + ) { + const direct = directThisProperty(current, source); + if ( + direct === "callbacks" + || direct === "io" + || direct?.endsWith("Observer") + ) { + return direct; + } + current = memberAccessReceiver(current); + } + return null; + }; + const hostEffectAliases = new Set(); + const knownPromiseSymbols = new Set(); + const collectKnownPromiseBindings = (node: ts.Node): void => { + if ( + ( + ts.isVariableDeclaration(node) + || ts.isParameter(node) + ) + && ts.isIdentifier(node.name) + && /\bPromise\s* { + const node = unwrapExpression(expression); + return ts.isIdentifier(node) + && node.text === expected + && identifierSymbol(node) === null; + }; + const isKnownPromiseExpression = ( + expression: ts.Expression, + ): boolean => { + const node = unwrapExpression(expression); + if (ts.isIdentifier(node)) { + const symbol = identifierSymbol(node); + return symbol !== null && knownPromiseSymbols.has(symbol); + } + if (ts.isNewExpression(node)) { + return isUnshadowedGlobal(node.expression, "Promise"); + } + if (ts.isCallExpression(node)) { + const callee = unwrapExpression(node.expression); + if (ts.isIdentifier(callee)) { + const symbol = identifierSymbol(callee); + const local = symbol === null + ? undefined + : localCallables.get(symbol); + return Boolean( + local?.modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword, + ) + || /\bPromise(?:Like)?\s*(), + ): boolean => { + if (visiting.has(callback)) return false; + visiting.add(callback); + if ( + callback.modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword, + ) + || /\bPromise(?:Like)?\s* { + if (launches) return; + if (node !== callback && isFunctionExpressionLike(node)) return; + if (ts.isAwaitExpression(node)) { + launches = true; + return; + } + if ( + ts.isNewExpression(node) + && isUnshadowedGlobal(node.expression, "Promise") + ) { + launches = true; + return; + } + if (ts.isCallExpression(node)) { + if (isKnownPromiseExpression(node)) { + launches = true; + return; + } + const callee = unwrapExpression(node.expression); + if (ts.isIdentifier(callee)) { + const symbol = identifierSymbol(callee); + const local = symbol === null + ? undefined + : localCallables.get(symbol); + if (local && callableLaunchesPromise(local, visiting)) { + launches = true; + return; + } + } + } + ts.forEachChild(node, inspect); + }; + inspect(callback.body); + return launches; + }; + const callableExplicitlyReturnsValue = ( + callback: ts.ArrowFunction | ts.FunctionExpression, + ): boolean => { + if ( + callback.modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword, + ) + ) { + return true; + } + if (!ts.isBlock(callback.body)) { + const expression = unwrapExpression(callback.body); + return !( + ts.isVoidExpression(expression) + || ( + ts.isIdentifier(expression) + && expression.text === "undefined" + ) + ); + } + let returnsValue = false; + const inspect = (node: ts.Node): void => { + if (returnsValue) return; + if (node !== callback && isFunctionExpressionLike(node)) return; + if (ts.isReturnStatement(node) && node.expression !== undefined) { + const expression = unwrapExpression(node.expression); + if ( + !ts.isVoidExpression(expression) + && !( + ts.isIdentifier(expression) + && expression.text === "undefined" + ) + ) { + returnsValue = true; + return; + } + } + ts.forEachChild(node, inspect); + }; + inspect(callback.body); + return returnsValue; + }; + const reviewedAsyncCallbackPositions = ( + call: ts.CallExpression, + ): ReadonlySet => { + const callee = unwrapExpression(call.expression); + if ( + ts.isIdentifier(callee) + && identifierSymbol(callee) === null + ) { + return new Set( + ASYNC_GLOBAL_CALLBACK_POSITIONS.get(callee.text) ?? [], + ); + } + const methodName = resolvedThisMethodName(call); + if (methodName !== null) { + return asyncMethodCallbackPositions.get(methodName) ?? new Set(); + } + if (!isMemberAccessExpression(callee)) return new Set(); + const method = memberAccessName(callee, source); + if (method === null) return new Set(); + if ( + PROMISE_CONTINUATION_CALLS.has(method) + && !isKnownPromiseExpression(memberAccessReceiver(callee)) + ) { + return new Set(); + } + return new Set( + ASYNC_MEMBER_CALLBACK_POSITIONS.get(method) ?? [], + ); + }; + const reviewedObjectCallbacks = ( + call: ts.CallExpression, + ): Array => { + const property = callPropertyName(call, source); + if (property === null) return []; + const argumentsByProperty = ASYNC_CALLBACK_OBJECT_CALLS.get(property); + if (argumentsByProperty === undefined) return []; + const callbacks: Array = []; + for (const [argumentIndex, allowedProperties] of argumentsByProperty) { + const argument = call.arguments[argumentIndex]; + if (argument === undefined) continue; + const object = unwrapExpression(argument); + if (!ts.isObjectLiteralExpression(object)) { + report( + "nonlexical-entry-operation", + owner, + argument, + `${property} callback object must be an inline reviewed object literal`, + ); + continue; + } + for (const member of object.properties) { + const memberName = propertyNameText(member.name, source); + if (memberName === null || !allowedProperties.has(memberName)) { + continue; + } + if (ts.isPropertyAssignment(member)) { + const callback = resolveLocalCallable(member.initializer); + if (callback !== undefined) { + callbacks.push(callback); + } else { + report( + "nonlexical-entry-operation", + owner, + member, + `${property}.${memberName} must be an inline or directly declared callback`, + ); + } + } else if (ts.isShorthandPropertyAssignment(member)) { + const callback = resolveLocalCallable(member.name); + if (callback !== undefined) { + callbacks.push(callback); + } else { + report( + "nonlexical-entry-operation", + owner, + member, + `${property}.${memberName} must resolve to a directly declared callback`, + ); + } + } else { + report( + "nonlexical-entry-operation", + owner, + member, + `${property}.${memberName} must use a lexical function value`, + ); + } + } + } + return callbacks; + }; + const reviewedConstructorObjectCallbacks = ( + node: ts.NewExpression, + ): Array => { + const constructor = unwrapExpression(node.expression); + if (!ts.isIdentifier(constructor)) return []; + const argumentsByProperty = + ASYNC_CONSTRUCTOR_CALLBACK_OBJECTS.get(constructor.text); + if (argumentsByProperty === undefined) return []; + const callbacks: Array = []; + for (const [argumentIndex, allowedProperties] of argumentsByProperty) { + const argument = node.arguments?.[argumentIndex]; + if (argument === undefined) continue; + const object = unwrapExpression(argument); + if (!ts.isObjectLiteralExpression(object)) { + report( + "nonlexical-entry-operation", + owner, + argument, + `${constructor.text} callback contract must be an inline reviewed object literal`, + ); + continue; + } + for (const member of object.properties) { + const memberName = propertyNameText(member.name, source); + if (memberName === null || !allowedProperties.has(memberName)) { + continue; + } + if (ts.isPropertyAssignment(member)) { + const callback = resolveLocalCallable(member.initializer); + if (callback !== undefined) { + callbacks.push(callback); + } else { + report( + "nonlexical-entry-operation", + owner, + member, + `${constructor.text}.${memberName} must be an inline or directly declared callback`, + ); + } + } else if (ts.isShorthandPropertyAssignment(member)) { + const callback = resolveLocalCallable(member.name); + if (callback !== undefined) { + callbacks.push(callback); + } else { + report( + "nonlexical-entry-operation", + owner, + member, + `${constructor.text}.${memberName} must resolve to a directly declared callback`, + ); + } + } else { + report( + "nonlexical-entry-operation", + owner, + member, + `${constructor.text}.${memberName} must use a lexical function value`, + ); + } + } + } + return callbacks; + }; + const directlyOpensChannelIngress = ( + callback: ts.ArrowFunction | ts.FunctionExpression, + ): boolean => { + let found = false; + const inspect = (node: ts.Node): void => { + if (found) return; + if (node !== callback.body && isFunctionExpressionLike(node)) return; + if ( + ts.isCallExpression(node) + && resolvedThisMethodName(node) === "#runOrDeferChannelKernelEntry" + ) { + found = true; + return; + } + ts.forEachChild(node, inspect); + }; + inspect(callback.body); + return found; + }; + const visitAsyncCallback = ( + callback: ts.ArrowFunction | ts.FunctionExpression, + callbackPhase: "async-fresh" | "transaction-continuation", + ): void => { + if ( + containsAuthoritySymbol( + callback.body, + scopedAuthoritySymbols, + ) + ) { + report( + "context-async-capture", + owner, + callback, + "entry context is captured by an asynchronous callback", + ); + } + if ( + callbackPhase === "transaction-continuation" + && !directlyOpensChannelIngress(callback) + ) { + report( + "transaction-continuation-without-channel-ingress", + owner, + callback, + "a protocol transaction continuation must directly re-enter through " + + "#runOrDeferChannelKernelEntry before completion or rollback", + ); + } + visitCallable(callback, callbackPhase); + }; + + const visit = (node: ts.Node, phase: ExecutionPhase): void => { + if ( + ownsEntryContext + && ts.isIdentifier(node) + && node.text === "arguments" + && isIdentifierValueReference(node) + ) { + report( + "implicit-arguments-entry-authority", + owner, + node, + "a scope owning KernelWorkerEntryContext may not recover " + + "authority through the implicit arguments object", + ); + } + let rootOperationIndex: number | null = null; + let asyncCallbacks: + | Array + | null = null; + let directLocalInvocation: + | ts.ArrowFunction + | ts.FunctionExpression + | undefined; + let directLocalInvocationSymbol: ts.Symbol | null = null; + if (ts.isCallExpression(node)) { + if (isGenuineDirectEval(node)) { + directEvalCalls.push(node); + } + if (isUnresolvedComputedThisDispatch(node)) { + dynamicThisDispatches.push({ node, phase }); + } + const methodName = resolvedThisMethodName(node); + const property = callPropertyName(node, source); + const callee = unwrapExpression(node.expression); + const receiver = isMemberAccessExpression(callee) + ? memberAccessReceiver(callee) + : undefined; + const effectKind = + property === null + ? undefined + : DETACHED_EFFECT_METHODS.get(property); + const isContextDefer = + ownsEntryContext + && effectKind !== undefined + && receiver !== undefined + && expressionReturnsScopedAuthority(receiver); + + if (isContextDefer) { + const callbackExpression = node.arguments[0]; + const callback = resolveLocalCallable(callbackExpression); + if (!callback) { + report( + "nonlexical-detached-effect", + owner, + callbackExpression ?? node, + "detached effects must be inline or a directly declared local closure", + ); + } + if ( + callback + && containsAuthoritySymbol( + callback.body, + scopedAuthoritySymbols, + ) + ) { + report( + "context-detached-capture", + owner, + callback, + "detached host effect captures its revoked entry context", + ); + } + if ( + callback + && effectKind !== "transaction-start" + && callableLaunchesPromise(callback) + ) { + report( + "async-detached-effect", + owner, + callback, + `${effectKind} effect must finish synchronously and may not ` + + "return, launch, or await a Promise", + ); + } + if ( + callback + && effectKind === "transaction-start" + && callableExplicitlyReturnsValue(callback) + ) { + report( + "async-detached-effect", + owner, + callback, + "protocol transaction start must return undefined after " + + "synchronously registering captured-Promise continuations", + ); + } + if (callback) { + visitCallable( + callback, + effectKind === "observer" + ? "detached-observer" + : effectKind === "protocol" + ? "detached-protocol" + : "detached-transaction-start", + ); + } + for (let index = 1; index < node.arguments.length; index++) { + visit(node.arguments[index]!, phase); + } + return; + } + + if (methodName === SERIALIZED_HOST_OPERATION_METHOD) { + const callbackExpression = node.arguments[1]; + const callback = resolveLocalCallable(callbackExpression); + if (!callback) { + report( + "nonlexical-entry-operation", + owner, + callbackExpression ?? node, + `${SERIALIZED_HOST_OPERATION_METHOD} requires an inline or ` + + "directly declared synchronous callback", + ); + } else { + if ( + containsAuthoritySymbol( + callback.body, + scopedAuthoritySymbols, + ) + ) { + report( + "context-detached-capture", + owner, + callback, + "serialized host operation captures kernel entry authority", + ); + } + if (callableLaunchesPromise(callback)) { + report( + "scoped-method-async", + owner, + callback, + "serialized host operation must finish synchronously", + ); + } + visitCallable(callback, "serialized-host"); + } + if ( + ownsEntryContext + && !exactSymbolIdentifier(node.arguments[0], contextSymbol) + ) { + report( + "missing-explicit-entry", + owner, + node, + `${SERIALIZED_HOST_OPERATION_METHOD} must receive the exact ` + + "lexical entry argument", + ); + } + visit(node.expression, phase); + for (let index = 0; index < node.arguments.length; index++) { + if (index !== 1) visit(node.arguments[index]!, phase); + } + return; + } + + if ( + methodName !== null + && ROOT_INGRESS_METHODS.has(methodName) + ) { + const operationIndex = ROOT_INGRESS_METHODS.get(methodName)!; + rootOperationIndex = operationIndex; + const operation = node.arguments[operationIndex]; + if (!isFunctionExpressionLike(operation)) { + report( + "nonlexical-entry-operation", + owner, + operation ?? node, + `${methodName} requires an inline lexical operation closure`, + ); + } else if ( + containsAuthoritySymbol( + operation.body, + scopedAuthoritySymbols, + ) + ) { + report( + "context-cross-ingress-capture", + owner, + operation, + "a fresh ingress operation captures authority from its outer entry", + ); + } + if ( + methodName === "#runOrDeferKernelEntry" + && node.arguments.length >= 4 + && node.arguments[3]!.kind !== ts.SyntaxKind.UndefinedKeyword + ) { + report( + "legacy-detached-operation", + owner, + node.arguments[3]!, + "detached effects must be registered through " + + "entry.deferProtocolEffect or entry.deferObserverEffect", + ); + } + } + + if (methodName !== null) { + if (phase === "serialized-host") { + report( + "nonlexical-entry-operation", + owner, + node, + "serialized host operation may invoke only staged host " + + "capabilities, not worker methods", + ); + } + calls.push({ callee: methodName, node, phase }); + if ( + ENTRY_SELECTORS.has(methodName) + || methodName === "#invokeEntryScratchExport" + ) { + directlyExportBearing = true; + if (detachedEffectKind(phase) !== null) { + report( + "export-from-detached-effect", + owner, + node, + `${methodName} is called after the entry scope is revoked`, + ); + } else if (phase === "async-fresh") { + report( + "async-export-without-ingress", + owner, + node, + `${methodName} is called from an async callback without a fresh ingress`, + ); + } else if ( + ownsEntryContext + && !exactSymbolIdentifier( + node.arguments[0], + contextSymbol, + ) + ) { + report( + "bare-entry-selector", + owner, + node, + `${methodName} must receive the exact lexical entry context`, + ); + } + } + } + + const callbackPositions = reviewedAsyncCallbackPositions(node); + const callbacks = reviewedObjectCallbacks(node); + for (const index of callbackPositions) { + const argument = node.arguments[index]; + if (argument === undefined) continue; + const callback = resolveLocalCallable(argument); + if (callback !== undefined) { + callbacks.push(callback); + continue; + } + const exactArgument = unwrapExpression(argument); + if ( + exactArgument.kind !== ts.SyntaxKind.UndefinedKeyword + && !ts.isIdentifier(exactArgument) + ) { + report( + "nonlexical-entry-operation", + owner, + argument, + "reviewed asynchronous callback positions require an inline " + + "or directly declared lexical callback", + ); + } + } + if (callbacks.length > 0) asyncCallbacks = callbacks; + + const directCallee = unwrapExpression(node.expression); + if (ts.isIdentifier(directCallee)) { + directLocalInvocationSymbol = identifierSymbol(directCallee); + directLocalInvocation = + directLocalInvocationSymbol === null + ? undefined + : localCallables.get(directLocalInvocationSymbol); + } else if (isFunctionExpressionLike(directCallee)) { + directLocalInvocation = directCallee; + } + + if (phase === "active") { + const hostRoot = hostOwnedRoot(node.expression); + const calleeIdentifier = unwrapExpression(node.expression); + const aliasedHostEffect = + ts.isIdentifier(calleeIdentifier) + && identifierSymbol(calleeIdentifier) !== null + && hostEffectAliases.has(identifierSymbol(calleeIdentifier)!); + if (hostRoot !== null || aliasedHostEffect) { + hostEffects.push({ + node, + description: hostRoot === null + ? "aliased host-owned callback invocation" + : `host-owned ${hostRoot} invocation`, + }); + } + } + } + + if (ts.isCallExpression(node) && rootOperationIndex !== null) { + // The operation receives a fresh lexical context and is audited as a + // root below. Treating its body as part of the enclosing method would + // falsely make a context-free caller responsible for calls made by + // that new scope. + for (let index = 0; index < node.arguments.length; index++) { + if ( + index === rootOperationIndex + || ( + resolvedThisMethodName(node) === "#runOrDeferKernelEntry" + && index === 3 + ) + ) { + continue; + } + visit(node.arguments[index]!, phase); + } + const detachedPost = node.arguments[3]; + if ( + resolvedThisMethodName(node) === "#runOrDeferKernelEntry" + && isFunctionExpressionLike(detachedPost) + ) { + visit(detachedPost.body, "detached-protocol"); + } + return; + } + + if (ts.isCallExpression(node) && asyncCallbacks !== null) { + // Evaluate the scheduler receiver and ordinary arguments now, but the + // callback body starts later with no authority from this entry. + visit(node.expression, phase); + for (const argument of node.arguments) visit(argument, phase); + const callbackPhase = + phase === "detached-transaction-start" + || phase === "transaction-continuation" + ? "transaction-continuation" + : "async-fresh"; + for (const callback of asyncCallbacks) { + visitAsyncCallback(callback, callbackPhase); + } + return; + } + + if (ts.isCallExpression(node) && directLocalInvocation) { + for (const argument of node.arguments) visit(argument, phase); + visitCallable(directLocalInvocation, phase); + return; + } + + if (ts.isCallExpression(node)) { + // Unknown higher-order functions are conservatively synchronous. + // Only the reviewed ingress, detached-effect, and async scheduler + // branches above are allowed to change the callback's phase. + for (const argument of node.arguments) { + const callback = resolveLocalCallable(argument); + if (callback) visitCallable(callback, phase); + } + } + + if (ts.isNewExpression(node)) { + const constructor = unwrapExpression(node.expression); + if ( + phase === "active" + && ts.isIdentifier(constructor) + && constructor.text === "Promise" + ) { + hostEffects.push({ + node, + description: "async scheduling through Promise", + }); + } + if ( + ts.isIdentifier(constructor) + && constructor.text === "Promise" + ) { + const executor = resolveLocalCallable(node.arguments?.[0]); + if (executor) visitCallable(executor, phase); + } + for (const callback of reviewedConstructorObjectCallbacks(node)) { + visitAsyncCallback(callback, "async-fresh"); + } + } + + if (ts.isAwaitExpression(node) && phase === "active") { + hostEffects.push({ + node, + description: "await in the active scoped graph", + }); + } + + if (isMemberAccessExpression(node)) { + if (isDirectKernelInstanceExports(node, source)) { + report( + "direct-kernel-instance-exports", + owner, + node, + "raw #kernelInstance.exports bypasses the entry selector", + ); + directlyExportBearing = true; + } + if (isScopedEntryInstanceExports(node)) { + directlyExportBearing = true; + } + const receiver = memberAccessReceiver(node); + if ( + receiver.kind === ts.SyntaxKind.ThisKeyword + && !isDirectCallTarget(node) + ) { + const method = memberAccessName(node, source); + if (method !== null) { + indirectMethodReferences.push({ method, node }); + } + } + if ( + ownsEntryContext + && exactSymbolIdentifier( + memberAccessReceiver(node), + contextSymbol, + ) + && DETACHED_EFFECT_METHODS.has( + memberAccessName(node, source) ?? "", + ) + && !isDirectCallTarget(node) + ) { + report( + "indirect-entry-authority", + owner, + node, + "entry detached-effect registration may not be aliased or " + + "invoked through call/apply/bind", + ); + } + } + + if ( + ts.isBinaryExpression(node) + && isAssignmentOperator(node.operatorToken.kind) + && isMemberAccessExpression(unwrapExpression(node.left)) + && memberAccessName( + unwrapExpression(node.left) as MemberAccessExpression, + source, + ) === "onmessage" + ) { + const callback = resolveLocalCallable(node.right); + visit(node.left, phase); + if (callback === undefined) { + report( + "nonlexical-entry-operation", + owner, + node.right, + "MessagePort.onmessage requires an inline or directly declared " + + "lexical listener", + ); + } else { + visitAsyncCallback( + callback, + phase === "detached-transaction-start" + || phase === "transaction-continuation" + ? "transaction-continuation" + : "async-fresh", + ); + } + return; + } + + if ( + phase === "active" + && ts.isBinaryExpression(node) + && isAssignmentOperator(node.operatorToken.kind) + && hostOwnedRoot(node.left) !== null + ) { + hostEffects.push({ + node, + description: `host-owned ${hostOwnedRoot(node.left)} mutation`, + }); + } + + if ( + ownsEntryContext + && ts.isVariableDeclaration(node) + && !ts.isIdentifier(node.name) + && node.initializer + && expressionReturnsScopedAuthority(node.initializer) + ) { + for (const identifier of bindingIdentifiers(node.name)) { + const symbol = identifierSymbol(identifier); + if (symbol !== null) scopedAuthoritySymbols.add(symbol); + } + report( + "context-alias", + owner, + node, + "entry authority is destructured instead of threaded explicitly", + ); + } + + if ( + ts.isVariableDeclaration(node) + && ts.isIdentifier(node.name) + && node.initializer + && hostOwnedRoot(node.initializer) !== null + ) { + const symbol = identifierSymbol(node.name); + if (symbol !== null) hostEffectAliases.add(symbol); + } + + if ( + ownsEntryContext + && + ts.isReturnStatement(node) + && node.expression + && expressionReturnsScopedAuthority(node.expression) + && !trustedSelector + ) { + report( + "context-return", + owner, + node, + "entry context escapes through a return value", + ); + } + + if ( + ownsEntryContext + && ts.isVariableDeclaration(node) + && ts.isIdentifier(node.name) + && identifierSymbol(node.name) !== contextSymbol + && node.initializer + ) { + const declarationSymbol = identifierSymbol(node.name); + const returnsScopedAuthority = + expressionReturnsScopedAuthority(node.initializer); + if ( + returnsScopedAuthority + && declarationSymbol !== null + ) { + scopedAuthoritySymbols.add(declarationSymbol); + } + if ( + expressionReturnsDirectContext(node.initializer) + && !trustedSelector + ) { + report( + "context-alias", + owner, + node, + "entry context is aliased instead of threaded explicitly", + ); + } else if ( + returnsScopedAuthority + && isFunctionExpressionLike(unwrapExpression(node.initializer)) + ) { + report( + "context-alias", + owner, + node, + "a local closure aliases entry-scoped authority", + ); + } + } + + if ( + ownsEntryContext + && + ts.isBinaryExpression(node) + && isAssignmentOperator(node.operatorToken.kind) + && ( + expressionReturnsScopedAuthority(node.right) + || exactSymbolIdentifier(node.left, contextSymbol) + ) + ) { + report( + "context-storage", + owner, + node, + "entry context is assigned into longer-lived state", + ); + } + + if (ts.isCallExpression(node)) { + const property = callPropertyName(node, source); + if ( + ownsEntryContext + && + property + && STORED_COLLECTION_METHODS.has(property) + && node.arguments.some((argument) => + expressionReturnsScopedAuthority(argument) + ) + ) { + report( + "context-storage", + owner, + node, + "entry context is stored in a collection", + ); + } + } + + // Function expressions are declarations, not execution. Their bodies + // are visited only by the direct-call, detached, async, or ingress + // branches above, each with the correct phase. + if (isFunctionExpressionLike(node)) return; + ts.forEachChild(node, (child) => visit(child, phase)); + }; + + try { + visit(body, "active"); + } catch (cause) { + if (cause instanceof RangeError) { + throw new Error( + `kernel entry-context audit recursion overflow in ${owner}`, + { cause }, + ); + } + throw cause; + } + return { + calls, + directEvalCalls, + dynamicThisDispatches, + hostEffects, + indirectMethodReferences, + directlyExportBearing, + }; + }; + + const collectLocalCallables = ( + body: ts.Node, + ): Map => { + const callables = + new Map(); + const collect = (node: ts.Node): void => { + if ( + ts.isVariableDeclaration(node) + && ts.isIdentifier(node.name) + && isFunctionExpressionLike(node.initializer) + ) { + const symbol = identifierSymbol(node.name); + if (symbol !== null) callables.set(symbol, node.initializer); + } + ts.forEachChild(node, collect); + }; + collect(body); + return callables; + }; + + for (const member of workerClass.members) { + if ( + !ts.isMethodDeclaration(member) + && !ts.isConstructorDeclaration(member) + ) { + if ( + ts.isPropertyDeclaration(member) + && member.type?.getText(source).replace(/\s+/g, "") + .match( + /^(?:KernelWorkerEntryContext|null|undefined|\(|\)|\|)+$/, + ) + ) { + report( + "context-storage", + "CentralizedKernelWorker", + member, + "entry context may not be stored in an instance field", + ); + } + continue; + } + if (!member.body) continue; + const name = ts.isConstructorDeclaration(member) + ? "constructor" + : propertyNameText(member.name, source); + if (name === null) continue; + const owner = formatOwner(member, source); + const parameterIndex = contextParameterIndex(member); + const contextName = + parameterIdentifier(member, parameterIndex); + const contextSymbol = + parameterIndex === null + ? null + : identifierSymbol(member.parameters[parameterIndex]!.name); + if (parameterIndex !== null && contextName === null) { + report( + "context-alias", + owner, + member.parameters[parameterIndex]!, + "KernelWorkerEntryContext must use one explicit identifier parameter", + ); + } + if ( + parameterIndex !== null + && member.modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword, + ) + ) { + report( + "scoped-method-async", + owner, + member, + "a method receiving KernelWorkerEntryContext must be synchronous", + ); + } + if ( + name !== "#kernelEntryContext" + && member.type?.getText(source).includes("KernelWorkerEntryContext") + ) { + report( + "context-return", + owner, + member.type, + "only #kernelEntryContext may construct and return an entry context", + ); + } + + const scan = scanScope( + member.body, + owner, + contextName, + contextSymbol, + collectLocalCallables(member.body), + ); + methods.set(name, { + ...scan, + owner, + contextName, + contextSymbol, + node: member, + contextParameterIndex: parameterIndex, + }); + } + + const collectRootScopes = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const methodName = resolvedThisMethodName(node); + const operationIndex = + methodName === null ? undefined : ROOT_INGRESS_METHODS.get(methodName); + if (operationIndex !== undefined) { + const operation = node.arguments[operationIndex]; + if (isFunctionExpressionLike(operation)) { + const parameter = operation.parameters[0]?.name; + if (parameter && ts.isIdentifier(parameter)) { + const owner = + `CentralizedKernelWorker.`; + const contextSymbol = identifierSymbol(parameter); + const scan = scanScope( + operation.body, + owner, + parameter.text, + contextSymbol, + collectLocalCallables(operation.body), + ); + rootScopes.push({ + owner, + contextName: parameter.text, + contextSymbol, + calls: scan.calls, + directEvalCalls: scan.directEvalCalls, + dynamicThisDispatches: scan.dynamicThisDispatches, + hostEffects: scan.hostEffects, + indirectMethodReferences: scan.indirectMethodReferences, + }); + } + } + } + } + ts.forEachChild(node, collectRootScopes); + }; + collectRootScopes(workerClass); + + const exportBearing = new Set(); + for (const [name, method] of methods) { + if (method.directlyExportBearing) exportBearing.add(name); + } + let changed = true; + while (changed) { + changed = false; + for (const [name, method] of methods) { + if ( + !exportBearing.has(name) + && method.calls.some( + (call) => + call.phase === "active" + && !ROOT_INGRESS_METHODS.has(call.callee) + && exportBearing.has(call.callee), + ) + ) { + exportBearing.add(name); + changed = true; + } + } + } + + // Observer effects may notify materialized host observers, but they must not + // publish a channel, relisten, open another kernel ingress, or fatalize the + // generation. Follow ordinary private-method calls from those protocol roots + // so moving the operation behind a helper cannot evade the contract. + const protocolBearing = new Set(); + for (const name of PROTOCOL_EFFECT_ROOT_METHODS) { + if (methods.has(name)) protocolBearing.add(name); + } + changed = true; + while (changed) { + changed = false; + for (const [name, method] of methods) { + if ( + !protocolBearing.has(name) + && method.calls.some( + (call) => + call.phase === "active" && protocolBearing.has(call.callee), + ) + ) { + protocolBearing.add(name); + changed = true; + } + } + } + + const inspectExplicitEdges = (scope: ScopeScan): void => { + for (const call of scope.calls) { + const target = methods.get(call.callee); + if ( + call.phase === "detached-observer" + && protocolBearing.has(call.callee) + ) { + report( + "protocol-effect-from-observer", + scope.owner, + call.node, + `${call.callee} reaches protocol publication, relisten, ` + + "kernel ingress, or fatalization from an observer effect", + ); + } + if (detachedEffectKind(call.phase) !== null) { + if ( + exportBearing.has(call.callee) + && !ENTRY_SELECTORS.has(call.callee) + && call.callee !== "#invokeEntryScratchExport" + ) { + report( + "export-from-detached-effect", + scope.owner, + call.node, + `${call.callee} reaches a kernel export after scope revocation`, + ); + } + continue; + } + if ( + call.phase === "async-fresh" + || call.phase === "transaction-continuation" + ) { + // An asynchronous listener owns no prior entry context. Opening one of + // the two reviewed lexical ingress methods is exactly how it may + // regain export authority. + if (ROOT_INGRESS_METHODS.has(call.callee)) { + if ( + call.phase === "transaction-continuation" + && call.callee !== "#runOrDeferChannelKernelEntry" + ) { + report( + "transaction-continuation-without-channel-ingress", + scope.owner, + call.node, + "protocol transaction completion must use the channel ingress " + + "that validates exact registration and CH_PENDING", + ); + } + continue; + } + if ( + exportBearing.has(call.callee) + && !ENTRY_SELECTORS.has(call.callee) + && call.callee !== "#invokeEntryScratchExport" + ) { + report( + "async-export-without-ingress", + scope.owner, + call.node, + `${call.callee} reaches a kernel export from an async callback without a fresh ingress`, + ); + } + continue; + } + if (!target) continue; + const requiresExplicitEntry = + target.contextParameterIndex !== null + || exportBearing.has(call.callee); + if (!requiresExplicitEntry) continue; + if (target.contextParameterIndex === null) { + report( + "export-call-without-entry-channel", + scope.owner, + call.node, + `${call.callee} reaches a kernel export but declares no entry parameter`, + ); + continue; + } + if ( + scope.contextSymbol === null + || !exactSymbolIdentifier( + call.node.arguments[target.contextParameterIndex], + scope.contextSymbol, + ) + ) { + report( + "missing-explicit-entry", + scope.owner, + call.node, + `${call.callee} must receive the exact lexical entry argument`, + ); + } + } + }; + const inspectIndirectReferences = ( + scope: ScopeScan, + foundationalOnly: boolean, + ): void => { + for (const reference of scope.indirectMethodReferences) { + if ( + !methods.has(reference.method) + || ( + foundationalOnly + && !FOUNDATIONAL_ENTRY_METHODS.has(reference.method) + ) + ) { + continue; + } + report( + "indirect-entry-authority", + scope.owner, + reference.node, + `${reference.method} may only be invoked as a direct method call`, + ); + } + }; + const inspectOpaqueEntryOperations = (scope: ScopeScan): void => { + for (const call of scope.dynamicThisDispatches) { + report( + "dynamic-entry-method-dispatch", + scope.owner, + call.node, + "computed this[...] dispatch in the entry graph must resolve from " + + "an exact immutable literal or use a direct reviewed method call", + ); + } + for (const call of scope.directEvalCalls) { + report( + "direct-eval-in-entry-graph", + scope.owner, + call, + "direct eval may capture lexical entry authority while hiding export " + + "selection from the static entry graph", + ); + } + }; + const opaqueOperationReachable = new Set(); + const opaqueOperationQueue: string[] = []; + const enqueueOpaqueOperationCalls = (scope: ScopeScan): void => { + for (const call of scope.calls) { + if ( + methods.has(call.callee) + && !opaqueOperationReachable.has(call.callee) + ) { + opaqueOperationReachable.add(call.callee); + opaqueOperationQueue.push(call.callee); + } + } + }; + for (const root of rootScopes) { + inspectOpaqueEntryOperations(root); + enqueueOpaqueOperationCalls(root); + } + while (opaqueOperationQueue.length > 0) { + const method = methods.get(opaqueOperationQueue.shift()!); + if (!method) continue; + inspectOpaqueEntryOperations(method); + enqueueOpaqueOperationCalls(method); + } + // A context-bearing method is itself an authority boundary even before a + // current root reaches it. This prevents a later direct call from activating + // opaque dispatch or eval that was already present but outside today's graph. + for (const method of methods.values()) { + if (method.contextParameterIndex !== null) { + inspectOpaqueEntryOperations(method); + } + } + const reachable = new Set(); + const queue: string[] = []; + const enqueueCalls = (scope: ScopeScan): void => { + for (const call of scope.calls) { + if ( + call.phase === "active" + && methods.has(call.callee) + && !reachable.has(call.callee) + ) { + reachable.add(call.callee); + queue.push(call.callee); + } + } + }; + for (const root of rootScopes) { + for (const effect of root.hostEffects) { + report( + "host-effect-in-scoped-graph", + root.owner, + effect.node, + effect.description, + ); + } + inspectIndirectReferences(root, false); + enqueueCalls(root); + } + while (queue.length > 0) { + const name = queue.shift()!; + const method = methods.get(name); + if (!method) continue; + enqueueCalls(method); + } + for (const name of reachable) { + const method = methods.get(name); + if (!method) continue; + for (const effect of method.hostEffects) { + report( + "host-effect-in-scoped-graph", + method.owner, + effect.node, + effect.description, + ); + } + inspectIndirectReferences(method, false); + } + for (const method of methods.values()) { + inspectIndirectReferences(method, true); + } + for (const root of rootScopes) inspectExplicitEdges(root); + // Export authority is a local contract, not merely a property of methods + // currently reachable from the known ingress roots. A newly registered + // timer, MessagePort listener, EventEmitter callback, or host callback + // object can enter any method later, so every selector/caller method must + // independently prove its exact lexical entry edge. + for (const method of methods.values()) inspectExplicitEdges(method); + + if (!prototypeDispatchIsStable) { + const entryGraphReachable = new Set(); + const entryGraphQueue: string[] = []; + const enqueueEntryGraphCalls = (scope: ScopeScan): void => { + for (const call of scope.calls) { + if ( + methods.has(call.callee) + && !entryGraphReachable.has(call.callee) + ) { + entryGraphReachable.add(call.callee); + entryGraphQueue.push(call.callee); + } + } + }; + for (const root of rootScopes) enqueueEntryGraphCalls(root); + while (entryGraphQueue.length > 0) { + const method = methods.get(entryGraphQueue.shift()!); + if (method) enqueueEntryGraphCalls(method); + } + const inspectPrototypeDispatch = (scope: ScopeScan): void => { + for (const call of scope.calls) { + if ( + call.callee.startsWith("#") + || !methods.has(call.callee) + ) { + continue; + } + report( + "mutable-entry-method-dispatch", + scope.owner, + call.node, + `${call.callee} uses mutable prototype dispatch in the entry graph; ` + + "seal each worker instance and freeze " + + "CentralizedKernelWorker.prototype through captured intrinsics, " + + "and reject subclass construction", + ); + } + }; + for (const root of rootScopes) inspectPrototypeDispatch(root); + for (const name of entryGraphReachable) { + const method = methods.get(name); + if (method) inspectPrototypeDispatch(method); + } + } + + return violations.sort( + (left, right) => + left.line - right.line + || left.kind.localeCompare(right.kind) + || left.text.localeCompare(right.text), + ); +} + +export function formatKernelEntryContextViolations( + violations: readonly KernelEntryContextViolation[], +): string[] { + return violations.map( + ({ kind, owner, line, text }) => + `${kind} at ${owner}:${line}: ${text}`, + ); +} diff --git a/host/test/support/kernel-export-failure-audit.ts b/host/test/support/kernel-export-failure-audit.ts new file mode 100644 index 0000000000..e63a63cdf2 --- /dev/null +++ b/host/test/support/kernel-export-failure-audit.ts @@ -0,0 +1,523 @@ +import ts from "typescript"; + +export interface KernelExportFailureCatchAllowance { + /** + * Exact class method that deliberately settles uncertain Rust ownership in + * `finally` before throwing a fatal wrapper. + */ + readonly owner: string; + readonly why: string; +} + +export interface KernelExportFailureViolation { + readonly owner: string; + readonly line: number; + readonly text: string; +} + +export interface KernelExportFailureAuditResult { + readonly violations: readonly KernelExportFailureViolation[]; + readonly unusedAllowances: readonly KernelExportFailureCatchAllowance[]; + readonly contractErrors: readonly string[]; + readonly exportBearingOwners: readonly string[]; +} + +type WorkerMethod = + | ts.MethodDeclaration + | ts.ConstructorDeclaration; + +function unwrapExpression(expression: ts.Expression): ts.Expression { + let current = expression; + while ( + ts.isParenthesizedExpression(current) + || ts.isAsExpression(current) + || ts.isTypeAssertionExpression(current) + || ts.isNonNullExpression(current) + || ts.isSatisfiesExpression(current) + ) { + current = current.expression; + } + return current; +} + +function propertyName( + name: ts.PropertyName | ts.PrivateIdentifier | undefined, +): string | null { + if ( + name === undefined + || !( + ts.isIdentifier(name) + || ts.isPrivateIdentifier(name) + || ts.isStringLiteral(name) + || ts.isNumericLiteral(name) + ) + ) { + return null; + } + return name.getText().replace(/^["']|["']$/g, ""); +} + +function ownerName(method: WorkerMethod): string { + return ts.isConstructorDeclaration(method) + ? "CentralizedKernelWorker.constructor" + : `CentralizedKernelWorker.${propertyName(method.name)}`; +} + +function directThisMemberName(expression: ts.Expression): string | null { + const node = unwrapExpression(expression); + if ( + !ts.isPropertyAccessExpression(node) + && !ts.isElementAccessExpression(node) + ) { + return null; + } + const receiver = unwrapExpression(node.expression); + if (receiver.kind !== ts.SyntaxKind.ThisKeyword) return null; + if (ts.isPropertyAccessExpression(node)) return propertyName(node.name); + const key = node.argumentExpression + ? unwrapExpression(node.argumentExpression) + : undefined; + return key && (ts.isStringLiteral(key) || ts.isNumericLiteral(key)) + ? key.text + : null; +} + +function thisMethodCallName(call: ts.CallExpression): string | null { + return directThisMemberName(call.expression); +} + +function calledMemberName(call: ts.CallExpression): string | null { + const callee = unwrapExpression(call.expression); + if (ts.isPropertyAccessExpression(callee)) return propertyName(callee.name); + if (!ts.isElementAccessExpression(callee)) return null; + const key = callee.argumentExpression + ? unwrapExpression(callee.argumentExpression) + : undefined; + return key && (ts.isStringLiteral(key) || ts.isNumericLiteral(key)) + ? key.text + : null; +} + +function containsExportsSelection(expression: ts.Expression): boolean { + let found = false; + const visit = (node: ts.Node): void => { + if (found) return; + if ( + ( + ts.isPropertyAccessExpression(node) + || ts.isElementAccessExpression(node) + ) + && ( + ts.isPropertyAccessExpression(node) + ? propertyName(node.name) === "exports" + : node.argumentExpression !== undefined + && ts.isStringLiteral( + unwrapExpression(node.argumentExpression), + ) + && ( + unwrapExpression(node.argumentExpression) as ts.StringLiteral + ).text === "exports" + ) + ) { + found = true; + return; + } + ts.forEachChild(node, visit); + }; + visit(expression); + return found; +} + +function hasKernelFacadeReceiver(expression: ts.Expression): boolean { + let current = unwrapExpression(expression); + while ( + ts.isPropertyAccessExpression(current) + || ts.isElementAccessExpression(current) + ) { + const direct = directThisMemberName(current); + if (direct === "#kernel") return true; + current = unwrapExpression(current.expression); + } + return false; +} + +function isDirectKernelExportCall( + call: ts.CallExpression, + exportAliases: ReadonlySet = new Set(), +): boolean { + if (thisMethodCallName(call) === "#invokeEntryScratchExport") return true; + const callee = unwrapExpression(call.expression); + if (ts.isIdentifier(callee) && exportAliases.has(callee.text)) return true; + return containsExportsSelection(callee) + || hasKernelFacadeReceiver(callee); +} + +function kernelExportCallableAliases(root: ts.Node): Set { + const aliases = new Set(); + const candidates: Array<{ + readonly name: string; + readonly initializer: ts.Expression; + }> = []; + const collect = (node: ts.Node): void => { + if ( + ts.isVariableDeclaration(node) + && node.initializer !== undefined + ) { + const names: string[] = []; + const collectBindingNames = (binding: ts.BindingName): void => { + if (ts.isIdentifier(binding)) { + names.push(binding.text); + return; + } + for (const element of binding.elements) { + if (!ts.isOmittedExpression(element)) { + collectBindingNames(element.name); + } + } + }; + collectBindingNames(node.name); + for (const name of names) { + candidates.push({ + name, + initializer: unwrapExpression(node.initializer), + }); + } + } + if ( + ts.isBinaryExpression(node) + && node.operatorToken.kind === ts.SyntaxKind.EqualsToken + && ts.isIdentifier(unwrapExpression(node.left)) + ) { + candidates.push({ + name: (unwrapExpression(node.left) as ts.Identifier).text, + initializer: unwrapExpression(node.right), + }); + } + ts.forEachChild(node, collect); + }; + collect(root); + + let changed = true; + while (changed) { + changed = false; + for (const { name, initializer } of candidates) { + if (aliases.has(name)) continue; + if ( + containsExportsSelection(initializer) + || hasKernelFacadeReceiver(initializer) + || (() => { + let current = initializer; + while ( + ts.isPropertyAccessExpression(current) + || ts.isElementAccessExpression(current) + ) { + current = unwrapExpression(current.expression); + } + return ts.isIdentifier(current) && aliases.has(current.text); + })() + ) { + aliases.add(name); + changed = true; + } + } + } + return aliases; +} + +const SYNCHRONOUS_CALLBACK_CALLS = new Set([ + "#invokeSharedMmapHostOperation", + "#runOrDeferChannelKernelEntry", + "#runOrDeferKernelEntry", + "withLease", +]); + +/** + * Visit only code that can run before the containing call returns. + * + * Timer, Promise, network, and event callbacks are stored host work: a catch + * around their registration cannot receive a later export unwind. Inline + * scratch leases and entry ingresses are synchronous when admitted, so their + * callbacks remain part of the catchable call graph. + */ +function visitSynchronousCalls( + root: ts.Node, + visitor: (call: ts.CallExpression) => void, +): void { + const visit = (node: ts.Node, insideSynchronousClosure: boolean): void => { + if ( + ts.isArrowFunction(node) + || ts.isFunctionExpression(node) + || ts.isFunctionDeclaration(node) + || ts.isMethodDeclaration(node) + ) { + if (insideSynchronousClosure && node.body) { + visit(node.body, true); + } + return; + } + if (ts.isCallExpression(node)) { + visitor(node); + visit(node.expression, insideSynchronousClosure); + const callbackIsSynchronous = SYNCHRONOUS_CALLBACK_CALLS.has( + thisMethodCallName(node) ?? calledMemberName(node) ?? "", + ); + for (const argument of node.arguments) { + if ( + ts.isArrowFunction(argument) + || ts.isFunctionExpression(argument) + ) { + if (callbackIsSynchronous || insideSynchronousClosure) { + visit(argument.body, true); + } + } else { + visit(argument, insideSynchronousClosure); + } + } + return; + } + ts.forEachChild(node, (child) => + visit(child, insideSynchronousClosure) + ); + }; + visit(root, false); +} + +function firstStatementRethrowsBrand( + clause: ts.CatchClause, +): boolean { + const binding = clause.variableDeclaration?.name; + if (!binding || !ts.isIdentifier(binding)) return false; + const statement = clause.block.statements[0]; + if (!statement || !ts.isExpressionStatement(statement)) return false; + const expression = unwrapExpression(statement.expression); + if (!ts.isCallExpression(expression)) return false; + if (thisMethodCallName(expression) !== "#rethrowKernelEntryFatal") { + return false; + } + const argument = expression.arguments[0]; + return Boolean( + argument + && ts.isIdentifier(unwrapExpression(argument)) + && (unwrapExpression(argument) as ts.Identifier).text === binding.text, + ); +} + +function firstStatementDefersBrandedFailure( + clause: ts.CatchClause, +): boolean { + const binding = clause.variableDeclaration?.name; + if (!binding || !ts.isIdentifier(binding)) return false; + const statement = clause.block.statements[0]; + if (!statement || !ts.isIfStatement(statement)) return false; + const condition = unwrapExpression(statement.expression); + if (!ts.isCallExpression(condition)) return false; + const callee = unwrapExpression(condition.expression); + const argument = condition.arguments[0]; + return ts.isIdentifier(callee) + && callee.text === "isKernelExportFailure" + && Boolean( + argument + && ts.isIdentifier(unwrapExpression(argument)) + && (unwrapExpression(argument) as ts.Identifier).text === binding.text, + ); +} + +/** + * Find synchronous catches that can receive a gate-branded Wasm export unwind. + * + * WHY: the gate's fatal observer runs only after its lexical entry is revoked. + * Checking a worker-wide fatal field inside that same scope can therefore turn + * a real Wasm trap into EIO, a fallback value, or a retry. This audit follows + * the worker's private call graph, so a newly introduced wrapper inherits the + * same fail-stop contract without a syscall-name allowlist. + */ +export function auditKernelExportFailureCatches( + sourceText: string, + allowances: readonly KernelExportFailureCatchAllowance[] = [], +): KernelExportFailureAuditResult { + const source = ts.createSourceFile( + "/kernel-worker.ts", + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const worker = source.statements.find( + (statement): statement is ts.ClassDeclaration => + ts.isClassDeclaration(statement) + && statement.name?.text === "CentralizedKernelWorker", + ); + if (!worker) { + throw new Error("CentralizedKernelWorker declaration was not found"); + } + + const methods = new Map(); + for (const member of worker.members) { + if ( + !ts.isMethodDeclaration(member) + && !ts.isConstructorDeclaration(member) + ) { + continue; + } + const name = ts.isConstructorDeclaration(member) + ? "constructor" + : propertyName(member.name); + if (name !== null) methods.set(name, member); + } + + const directExportMethods = new Set(); + const callsByMethod = new Map>(); + const aliasesByMethod = new Map>(); + for (const [name, method] of methods) { + const calls = new Set(); + const exportAliases = method.body + ? kernelExportCallableAliases(method.body) + : new Set(); + aliasesByMethod.set(name, exportAliases); + if (method.body) { + visitSynchronousCalls(method.body, (call) => { + if (isDirectKernelExportCall(call, exportAliases)) { + directExportMethods.add(name); + } + const target = thisMethodCallName(call); + if (target !== null) calls.add(target); + }); + } + callsByMethod.set(name, calls); + } + + const exportBearingMethods = new Set(directExportMethods); + let changed = true; + while (changed) { + changed = false; + for (const [name, calls] of callsByMethod) { + if (exportBearingMethods.has(name)) continue; + if ([...calls].some((call) => exportBearingMethods.has(call))) { + exportBearingMethods.add(name); + changed = true; + } + } + } + + const allowanceByOwner = new Map< + string, + KernelExportFailureCatchAllowance + >(); + const contractErrors: string[] = []; + for (const allowance of allowances) { + if (allowance.owner.trim() === "") { + contractErrors.push("kernel-export catch allowance owner is empty"); + continue; + } + if (allowance.why.trim() === "") { + contractErrors.push( + `kernel-export catch allowance ${allowance.owner} has an empty WHY`, + ); + } + if (allowanceByOwner.has(allowance.owner)) { + contractErrors.push( + `duplicate kernel-export catch allowance: ${allowance.owner}`, + ); + continue; + } + allowanceByOwner.set(allowance.owner, allowance); + } + const allowanceUseCounts = new Map(); + const violations: KernelExportFailureViolation[] = []; + + const tryCanReceiveExportFailure = ( + block: ts.Block, + exportAliases: ReadonlySet, + ): boolean => { + let found = false; + visitSynchronousCalls(block, (node) => { + if (found) return; + if (isDirectKernelExportCall(node, exportAliases)) { + found = true; + return; + } + const target = thisMethodCallName(node); + if (target !== null && exportBearingMethods.has(target)) { + found = true; + } + }); + return found; + }; + + for (const method of methods.values()) { + const owner = ownerName(method); + const methodName = ts.isConstructorDeclaration(method) + ? "constructor" + : propertyName(method.name)!; + const exportAliases = aliasesByMethod.get(methodName) ?? new Set(); + const inspect = (node: ts.Node): void => { + if ( + ts.isCatchClause(node) + && tryCanReceiveExportFailure( + node.parent.tryBlock, + exportAliases, + ) + ) { + if (firstStatementRethrowsBrand(node)) { + ts.forEachChild(node, inspect); + return; + } + if ( + firstStatementDefersBrandedFailure(node) + && allowanceByOwner.has(owner) + ) { + allowanceUseCounts.set( + owner, + (allowanceUseCounts.get(owner) ?? 0) + 1, + ); + ts.forEachChild(node, inspect); + return; + } + violations.push({ + owner, + line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + + 1, + text: node.getText(source).replace(/\s+/g, " ").slice(0, 240), + }); + } + ts.forEachChild(node, inspect); + }; + if (method.body) inspect(method.body); + } + + for (const owner of allowanceByOwner.keys()) { + const uses = allowanceUseCounts.get(owner) ?? 0; + if (uses > 1) { + contractErrors.push( + `kernel-export catch allowance ${owner} matched ${uses} catches; ` + + "each allowance must identify exactly one settlement catch", + ); + } + } + + return { + violations, + unusedAllowances: allowances.filter( + ({ owner }) => (allowanceUseCounts.get(owner) ?? 0) === 0, + ), + contractErrors, + exportBearingOwners: [...exportBearingMethods] + .map((name) => `CentralizedKernelWorker.${name}`) + .sort(), + }; +} + +export function formatKernelExportFailureAudit( + result: KernelExportFailureAuditResult, +): string[] { + return [ + ...result.violations.map( + ({ owner, line, text }) => + `unguarded kernel-export catch ${owner}:${line}: ${text}`, + ), + ...result.unusedAllowances.map( + ({ owner }) => `unused kernel-export catch allowance: ${owner}`, + ), + ...result.contractErrors, + ]; +} diff --git a/host/test/support/kernel-scratch-instance.ts b/host/test/support/kernel-scratch-instance.ts index 18a810d548..6101ff70e3 100644 --- a/host/test/support/kernel-scratch-instance.ts +++ b/host/test/support/kernel-scratch-instance.ts @@ -36,6 +36,22 @@ function signatures( parameters: [i32], result: pointer, }, + kernel_commit_process_exit: { + parameters: [i32], + result: i32, + }, + kernel_clear_process_metadata: { + parameters: [i32, i32], + result: i32, + }, + kernel_clear_fork_child: { + parameters: [i32], + result: i32, + }, + kernel_create_process_with_stdio: { + parameters: [i32, i32, i32], + result: i32, + }, kernel_dequeue_signal: { parameters: [i32, i32, pointer, i32], result: i32, @@ -52,14 +68,86 @@ function signatures( parameters: [pointer, i32], result: i32, }, + kernel_exec_prepare: { + parameters: [i32, i32], + result: i32, + }, + kernel_exec_setup_for_thread: { + parameters: [i32, i32], + result: i32, + }, + kernel_fd_is_open: { + parameters: [i32, i32], + result: i32, + }, + kernel_fd_supports_mmap_writeback: { + parameters: [i32, i32], + result: i32, + }, + kernel_find_listener_fd_by_accept_wake: { + parameters: [i32, i32], + result: i32, + }, + kernel_fork_process: { + parameters: [i32, i32], + result: i32, + }, + kernel_ftruncate: { + parameters: [i32, i64], + result: i32, + }, kernel_get_cwd: { parameters: [i32, pointer, i32], result: i32, }, + kernel_get_fork_count: { + parameters: [i32], + result: i64, + }, + kernel_get_memory_pages: { + parameters: [], + result: i32, + }, + kernel_get_parent_pid: { + parameters: [i32], + result: i32, + }, + kernel_get_process_exit_signal: { + parameters: [i32], + result: i32, + }, + kernel_get_process_exit_status: { + parameters: [i32], + result: i32, + }, kernel_get_fd_path: { parameters: [i32, i32, pointer, i32], result: i32, }, + kernel_get_fd_accept_wake_idx: { + parameters: [i32, i32], + result: i32, + }, + kernel_get_fd_pipe_idx: { + parameters: [i32, i32], + result: i32, + }, + kernel_get_socket_timeout_ms: { + parameters: [i32, i32, i32], + result: i64, + }, + kernel_get_process_state: { + parameters: [i32], + result: i32, + }, + kernel_has_sa_nocldstop: { + parameters: [i32], + result: i32, + }, + kernel_has_sa_nocldwait: { + parameters: [i32], + result: i32, + }, kernel_getrusage: { parameters: [i32, pointer, i32], result: i32, @@ -68,8 +156,20 @@ function signatures( parameters: [i32, i32, i32, pointer, i32, pointer, i32], result: i32, }, + kernel_setsockopt: { + parameters: [i32, i32, i32, pointer, i32], + result: i32, + }, kernel_handle_channel: { - parameters: [pointer, i32, i32], + parameters: [pointer, i32, i32, i64], + result: i32, + }, + kernel_blocking_retry_token: { + parameters: [i32, i32, i32], + result: i64, + }, + kernel_blocking_retry_release: { + parameters: [i32, i32, i64], result: i32, }, kernel_inject_datagram: { @@ -80,10 +180,21 @@ function signatures( ], result: i32, }, + kernel_inject_mouse_event: { + parameters: [i32, i32, i32], + // The production export is void. Returning an ignored i32 keeps this + // compact fixture's one-result encoder simple while still exercising a + // genuine Wasm function and the exact gated export lookup. + result: i32, + }, kernel_ioctl: { parameters: [i32, i32, pointer, i32, i32], result: i32, }, + kernel_is_fd_nonblock: { + parameters: [i32, i32], + result: i32, + }, kernel_ipc_shm_read_chunk: { parameters: [i32, i32, pointer, i32], result: i32, @@ -92,14 +203,70 @@ function signatures( parameters: [i32, i32, pointer, i32], result: i32, }, + kernel_ipc_shmat_for_process: { + parameters: [i32, i32, i32, i32], + result: i32, + }, + kernel_ipc_shmat_for_task: { + parameters: [i32, i32, i32, i32, i32], + result: i32, + }, + kernel_ipc_shmdt_for_process: { + parameters: [i32, i32], + result: i32, + }, + kernel_ipc_shmdt_for_task: { + parameters: [i32, i32, i32], + result: i32, + }, kernel_mq_drain_notification: { parameters: [pointer, i32], result: i32, }, + kernel_mq_descriptor_msgsize: { + parameters: [i32, i32, i32], + result: i32, + }, + kernel_mark_process_signaled: { + parameters: [i32, i32], + result: i32, + }, + kernel_msqid_ds_bytes: { + parameters: [i32], + result: i32, + }, + kernel_kms_commit_count: { + parameters: [i32], + result: i64, + }, + kernel_kms_last_frame_us: { + parameters: [i32], + result: i64, + }, kernel_pipe2: { parameters: [i32, pointer, i32], result: i32, }, + kernel_pipe_close_read: { + parameters: [i32, i32], + result: i32, + }, + kernel_pipe_close_write: { + parameters: [i32, i32], + result: i32, + }, + kernel_pipe_has_readers: { + parameters: [i32, i32], + result: i32, + }, + kernel_pipe_is_read_open: { + parameters: [i32, i32], + result: i32, + }, + kernel_pipe_is_write_open: { + parameters: [i32, i32], + result: i32, + }, kernel_pipe_read: { parameters: [i32, i32, pointer, i32], result: i32, @@ -112,6 +279,14 @@ function signatures( parameters: [pointer, i32, i32, i32], result: i32, }, + kernel_pty_create: { + parameters: [i32], + result: i32, + }, + kernel_pick_signal_target_tid: { + parameters: [i32, i32], + result: i32, + }, kernel_pty_master_read: { parameters: [i32, pointer, i32], result: i32, @@ -128,6 +303,22 @@ function signatures( parameters: [i32, pointer, i32], result: i32, }, + kernel_remove_process: { + parameters: [i32], + result: i32, + }, + kernel_reserve_host_region: { + parameters: [i32, pointer], + result: pointer, + }, + kernel_reserve_host_region_at: { + parameters: [i32, pointer, pointer], + result: pointer, + }, + kernel_reap_exited_child: { + parameters: [i32, i32], + result: i32, + }, kernel_recv: { parameters: [i32, pointer, i32, i32], result: i32, @@ -142,6 +333,14 @@ function signatures( ], result: i32, }, + kernel_semctl_array_bytes: { + parameters: [i32, i32, i32, i32], + result: i32, + }, + kernel_semid_ds_bytes: { + parameters: [i32], + result: i32, + }, kernel_send: { parameters: [i32, pointer, i32, i32], result: i32, @@ -150,6 +349,34 @@ function signatures( parameters: [i32, pointer, i32], result: i32, }, + kernel_set_brk_base: { + parameters: [i32, pointer], + result: i32, + }, + kernel_set_brk_limit: { + parameters: [i32, pointer], + result: i32, + }, + kernel_set_process_credentials: { + parameters: [i32, i32, i32], + result: i32, + }, + kernel_shmid_ds_bytes: { + parameters: [i32], + result: i32, + }, + kernel_set_current_tid: { + parameters: [i32, i32], + result: i32, + }, + kernel_set_max_addr: { + parameters: [i32, pointer], + result: i32, + }, + kernel_set_mmap_base: { + parameters: [i32, pointer], + result: i32, + }, kernel_socketpair: { parameters: [i32, i32, i32, pointer, i32], result: i32, @@ -158,6 +385,30 @@ function signatures( parameters: [i32, i32, pointer, pointer], result: i32, }, + kernel_spawn_reserved_process: { + parameters: [i32, i32, i64, pointer], + result: i32, + }, + kernel_spawn_scratch_begin: { + parameters: [pointer], + result: i64, + }, + kernel_spawn_scratch_pointer: { + parameters: [i64], + result: pointer, + }, + kernel_spawn_scratch_capacity: { + parameters: [i64], + result: pointer, + }, + kernel_spawn_scratch_cancel: { + parameters: [i64], + result: i32, + }, + kernel_spawn_scratch_retained_capacity: { + parameters: [], + result: pointer, + }, kernel_tcgetattr: { parameters: [i32, pointer, i32], result: i32, @@ -166,6 +417,38 @@ function signatures( parameters: [i32, i32, pointer, i32], result: i32, }, + kernel_thread_exit: { + parameters: [i32, i32], + result: i32, + }, + kernel_thread_has_deliverable: { + parameters: [i32, i32], + result: i32, + }, + kernel_transfer_scratch_begin: { + parameters: [pointer], + result: i64, + }, + kernel_transfer_scratch_pointer: { + parameters: [i64], + result: pointer, + }, + kernel_transfer_scratch_capacity: { + parameters: [i64], + result: pointer, + }, + kernel_transfer_scratch_cancel: { + parameters: [i64], + result: i32, + }, + kernel_transfer_io_execute: { + parameters: [i32, i32, i64, pointer, i32, i32, i64, i64], + result: i32, + }, + kernel_transfer_channel_execute: { + parameters: [i32, i32, i64, i64], + result: i32, + }, kernel_truncate: { parameters: [pointer, i32, i64], result: i32, @@ -174,6 +457,17 @@ function signatures( parameters: [pointer, i32], result: i32, }, + kernel_validate_task: { + parameters: [i32, i32], + result: i32, + }, + kernel_vblank: { + parameters: [], + // The production export is void. Returning an ignored i32 keeps this + // compact fixture's one-result encoder simple while exercising a + // genuine gated Wasm call. + result: i32, + }, kernel_wait_child_poll: { parameters: [i32, i32, i32, i32, i32, pointer, i32], result: i32, @@ -195,8 +489,27 @@ export function createKernelScratchTestInstance( memory: WebAssembly.Memory, resolveExports: () => Record, allocator: (capacity: number) => number | bigint, + memoryAddressWidth: 4 | 8 = 4, + includedExports?: readonly string[], + excludedExports: readonly string[] = [], ): WebAssembly.Instance { - const entries = Object.entries(signatures(pointerWidth)); + const selected = includedExports === undefined + ? undefined + : new Set(["kernel_alloc_scratch", ...includedExports]); + const excluded = new Set(excludedExports); + const entries = Object.entries(signatures(pointerWidth)).filter( + ([name]) => + (selected === undefined || selected.has(name)) + && !excluded.has(name), + ); + if (selected !== undefined) { + const known = new Set(entries.map(([name]) => name)); + for (const name of selected) { + if (!known.has(name)) { + throw new Error(`missing test Wasm signature for ${name}`); + } + } + } const memoryIsShared = typeof SharedArrayBuffer !== "undefined" && memory.buffer instanceof SharedArrayBuffer; const valueType = (type: WasmValueType): number => @@ -214,8 +527,12 @@ export function createKernelScratchTestInstance( // Shared memories require an advertised maximum; the broad wasm32 // ceiling accepts every valid test-memory maximum while preserving the // exact shared-state bit that instance identity validation relies on. - ? [0x03, 0, ...unsignedLeb128(65_536)] - : [0x00, 0]), + ? [ + memoryAddressWidth === 8 ? 0x07 : 0x03, + 0, + ...unsignedLeb128(65_536), + ] + : [memoryAddressWidth === 8 ? 0x04 : 0x00, 0]), ]; const exportPayload: number[] = [ ...unsignedLeb128(entries.length + 1), diff --git a/host/test/support/wasm-memory-write-audit.ts b/host/test/support/wasm-memory-write-audit.ts index 145dfc3769..a3a0acb0a8 100644 --- a/host/test/support/wasm-memory-write-audit.ts +++ b/host/test/support/wasm-memory-write-audit.ts @@ -1,22 +1,12 @@ -import { - readdirSync, -} from "node:fs"; +import { readdirSync } from "node:fs"; import path from "node:path"; import ts from "typescript"; export type MemoryOwner = - | "kernel" - | "process-memory" - | "framebuffer" - | "shared-memory" - | "rust-lent"; + "kernel" | "process-memory" | "framebuffer" | "shared-memory" | "rust-lent"; export type OwnershipForm = - | "memory" - | "buffer" - | "view" - | "instance" - | "scratch-region"; + "memory" | "buffer" | "view" | "instance" | "scratch-region"; export interface OwnershipSeed { /** @@ -41,6 +31,14 @@ export interface AuditAllowance { | "kernel-read" | "kernel-control" | "non-kernel"; + /** + * Required only for a WebAssembly Memory/Instance authority origin. + * + * The exact site allowlist must say whose address space the newly created + * authority controls. This prevents an unseeded kernel Memory from being + * silently treated as ordinary process memory. + */ + authorityOwner?: MemoryOwner; /** Exact number of structurally identical sites admitted by this entry. */ count?: number; why: string; @@ -63,10 +61,17 @@ export interface AuditFinding { | "kernel-memory-return" | "kernel-memory-store" | "kernel-pointer-export-bypass" + | "kernel-export-direct-use" | "scratch-address-contract" | "scratch-allocator-call" | "scratch-region-factory-call" - | "spawn-reservation-call"; + | "scratch-reservation-call" + | "kernel-destination-factory-call" + | "kernel-destination-factory-unsafe" + | "wasm-memory-authority" + | "wasm-instance-authority" + | "wasm-authority-escape" + | "dynamic-code-contract"; line: number; text: string; } @@ -85,6 +90,28 @@ export interface AuditOptions { sourceFiles: string[]; ownershipSeeds: readonly OwnershipSeed[]; allowances?: readonly AuditAllowance[]; + /** + * Complete generated kernel export-name set from the ABI snapshot. + * + * When supplied, every named kernel export defaults to the raw-call finding, + * not only exports already present in the runtime scratch whitelist. This + * closes the classification hole where a newly used pointer-bearing export + * could be omitted from that hand-reviewed whitelist. + */ + kernelExportNames?: readonly string[]; + /** + * Exact declarations of authenticated Rust-lent destination factories. + * + * Every call becomes its own finding. The production contract therefore + * reviews where each pointer and explicit capacity enters instead of allowing + * a raw sink body once for all future callers. + */ + kernelDestinationFactoryDeclarations?: readonly string[]; + /** + * Require an exact owner-classified allowance for every intrinsic + * WebAssembly Memory or Instance creation site. + */ + auditWasmAuthorityOrigins?: boolean; compilerOptions?: ts.CompilerOptions; virtualSources?: ReadonlyMap; } @@ -108,9 +135,7 @@ interface ValueState { elements: ValueState | null; } -type StateProjection = - | { kind: "property"; name: string } - | { kind: "element" }; +type StateProjection = { kind: "property"; name: string } | { kind: "element" }; interface Constraint { target: StateKey; @@ -152,11 +177,13 @@ const OWNER_BITS: Record = { const KERNEL_OWNER = OWNER_BITS.kernel; const TYPED_ARRAY_CONSTRUCTOR = 1 << 0; const DATA_VIEW_CONSTRUCTOR = 1 << 1; +const WASM_MEMORY_CONSTRUCTOR = 1 << 0; +const WASM_INSTANCE_CONSTRUCTOR = 1 << 1; +const WASM_INSTANTIATE_FUNCTION = 1 << 2; +const WASM_AUTHORITY_NAMESPACE = 1 << 3; +const WASM_GLOBAL_OBJECT = 1 << 4; const TYPE_PROPERTIES = new WeakMap(); -const INTRINSIC_ARRAY_METHODS = new WeakMap< - ts.CallExpression, - string | null ->(); +const INTRINSIC_ARRAY_METHODS = new WeakMap(); const ARRAY_ELEMENT_RETURNING_METHODS = new Set([ "at", "find", @@ -270,16 +297,17 @@ function frozenStringArray( let value = unwrapExpression(expression); if (ts.isCallExpression(value) && value.arguments.length === 1) { const callee = unwrapExpression(value.expression); - const capturedFreeze = ts.isIdentifier(callee) - && callee.text === "intrinsicObjectFreeze"; + const capturedFreeze = + ts.isIdentifier(callee) && callee.text === "intrinsicObjectFreeze"; const freezeReceiver = ts.isPropertyAccessExpression(callee) ? unwrapExpression(callee.expression) : null; - const directFreeze = ts.isPropertyAccessExpression(callee) - && freezeReceiver !== null - && ts.isIdentifier(freezeReceiver) - && freezeReceiver.text === "Object" - && callee.name.text === "freeze"; + const directFreeze = + ts.isPropertyAccessExpression(callee) && + freezeReceiver !== null && + ts.isIdentifier(freezeReceiver) && + freezeReceiver.text === "Object" && + callee.name.text === "freeze"; if (!capturedFreeze && !directFreeze) return null; value = unwrapExpression(value.arguments[0]); } @@ -299,7 +327,7 @@ function kernelScratchPointerExportContract( readonly errors: readonly string[]; } { const contractFiles = sourceFiles.filter((sourceFile) => - toPosix(sourceFile.fileName).endsWith("/host/src/kernel-scratch.ts") + toPosix(sourceFile.fileName).endsWith("/host/src/kernel-scratch.ts"), ); if (contractFiles.length === 0) { return { names: new Set(), errors: [] }; @@ -309,9 +337,9 @@ function kernelScratchPointerExportContract( for (const sourceFile of contractFiles) { const visit = (node: ts.Node): void => { if ( - ts.isVariableDeclaration(node) - && ts.isIdentifier(node.name) - && node.name.text === "KERNEL_SCRATCH_EXPORT_NAMES" + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.name.text === "KERNEL_SCRATCH_EXPORT_NAMES" ) { declarations.push(node); } @@ -329,7 +357,11 @@ function kernelScratchPointerExportContract( }; } const values = frozenStringArray(declarations[0].initializer); - if (!values || values.length === 0 || new Set(values).size !== values.length) { + if ( + !values || + values.length === 0 || + new Set(values).size !== values.length + ) { return { names: new Set(), errors: [ @@ -396,10 +428,7 @@ function cloneState(state: ValueState): ValueState { return result; } -function unionState( - into: ValueState, - other: ValueState, -): boolean { +function unionState(into: ValueState, other: ValueState): boolean { const beforeMemory = into.memory; const beforeBuffer = into.buffer; const beforeView = into.view; @@ -424,19 +453,18 @@ function unionState( into.scratchRegionFactory ||= other.scratchRegionFactory; into.scratchRegion ||= other.scratchRegion; into.viewConstructors |= other.viewConstructors; - let changed = ( - beforeMemory !== into.memory - || beforeBuffer !== into.buffer - || beforeView !== into.view - || beforeInstance !== into.instance - || beforeExportNamespace !== into.exportNamespace - || beforeKernelExportFunctionCount !== into.kernelExportFunctions.size - || beforeAllocator !== into.allocator - || beforeReserver !== into.reserver - || beforeScratchRegionFactory !== into.scratchRegionFactory - || beforeScratchRegion !== into.scratchRegion - || beforeViewConstructors !== into.viewConstructors - ); + let changed = + beforeMemory !== into.memory || + beforeBuffer !== into.buffer || + beforeView !== into.view || + beforeInstance !== into.instance || + beforeExportNamespace !== into.exportNamespace || + beforeKernelExportFunctionCount !== into.kernelExportFunctions.size || + beforeAllocator !== into.allocator || + beforeReserver !== into.reserver || + beforeScratchRegionFactory !== into.scratchRegionFactory || + beforeScratchRegion !== into.scratchRegion || + beforeViewConstructors !== into.viewConstructors; for (const [name, property] of other.properties) { const existing = into.properties.get(name); if (existing) { @@ -479,17 +507,17 @@ function hasCapability( if (seen.has(state)) return false; seen.add(state); if ( - state.memory !== 0 - || state.buffer !== 0 - || state.view !== 0 - || state.instance !== 0 - || state.exportNamespace !== 0 - || state.kernelExportFunctions.size !== 0 - || state.allocator - || state.reserver - || state.scratchRegionFactory - || state.scratchRegion - || state.viewConstructors !== 0 + state.memory !== 0 || + state.buffer !== 0 || + state.view !== 0 || + state.instance !== 0 || + state.exportNamespace !== 0 || + state.kernelExportFunctions.size !== 0 || + state.allocator || + state.reserver || + state.scratchRegionFactory || + state.scratchRegion || + state.viewConstructors !== 0 ) { return true; } @@ -513,8 +541,8 @@ function propertyState(state: ValueState, name: string): ValueState { result.memory |= state.exportNamespace; } if ( - (state.exportNamespace & KERNEL_OWNER) !== 0 - && name.startsWith("kernel_") + (state.exportNamespace & KERNEL_OWNER) !== 0 && + name.startsWith("kernel_") ) { result.kernelExportFunctions.add(name); } @@ -546,9 +574,10 @@ function projectState( ): ValueState { let result = cloneState(state); for (const projection of projections ?? []) { - result = projection.kind === "property" - ? propertyState(result, projection.name) - : elementState(result); + result = + projection.kind === "property" + ? propertyState(result, projection.name) + : elementState(result); } return result; } @@ -556,11 +585,11 @@ function projectState( function unwrapExpression(expression: ts.Expression): ts.Expression { let current = expression; while ( - ts.isParenthesizedExpression(current) - || ts.isAsExpression(current) - || ts.isTypeAssertionExpression(current) - || ts.isNonNullExpression(current) - || ts.isSatisfiesExpression(current) + ts.isParenthesizedExpression(current) || + ts.isAsExpression(current) || + ts.isTypeAssertionExpression(current) || + ts.isNonNullExpression(current) || + ts.isSatisfiesExpression(current) ) { current = current.expression; } @@ -585,8 +614,8 @@ function accessedPropertyName( return expression.name.text; } const argument = expression.argumentExpression; - return argument - && (ts.isStringLiteralLike(argument) || ts.isNumericLiteral(argument)) + return argument && + (ts.isStringLiteralLike(argument) || ts.isNumericLiteral(argument)) ? argument.text : null; } @@ -605,19 +634,19 @@ function relativeFile(rootDir: string, sourceFile: ts.SourceFile): string { function namedDeclarationPart(node: ts.Node): string | null { if ( - ts.isClassDeclaration(node) - || ts.isInterfaceDeclaration(node) - || ts.isTypeAliasDeclaration(node) - || ts.isEnumDeclaration(node) - || ts.isModuleDeclaration(node) + ts.isClassDeclaration(node) || + ts.isInterfaceDeclaration(node) || + ts.isTypeAliasDeclaration(node) || + ts.isEnumDeclaration(node) || + ts.isModuleDeclaration(node) ) { return node.name?.getText() ?? null; } if ( - ts.isFunctionDeclaration(node) - || ts.isMethodDeclaration(node) - || ts.isGetAccessorDeclaration(node) - || ts.isSetAccessorDeclaration(node) + ts.isFunctionDeclaration(node) || + ts.isMethodDeclaration(node) || + ts.isGetAccessorDeclaration(node) || + ts.isSetAccessorDeclaration(node) ) { return propertyNameText(node.name) ?? null; } @@ -626,7 +655,11 @@ function namedDeclarationPart(node: ts.Node): string | null { function enclosingDeclarationParts(node: ts.Node): string[] { const parts: string[] = []; - for (let current: ts.Node | undefined = node.parent; current; current = current.parent) { + for ( + let current: ts.Node | undefined = node.parent; + current; + current = current.parent + ) { const part = namedDeclarationPart(current); if (part) parts.push(part); } @@ -635,19 +668,19 @@ function enclosingDeclarationParts(node: ts.Node): string[] { function declarationName(node: ts.Declaration): string | null { if ( - ts.isVariableDeclaration(node) - || ts.isPropertyDeclaration(node) - || ts.isPropertySignature(node) - || ts.isParameter(node) - || ts.isBindingElement(node) + ts.isVariableDeclaration(node) || + ts.isPropertyDeclaration(node) || + ts.isPropertySignature(node) || + ts.isParameter(node) || + ts.isBindingElement(node) ) { - return ts.isIdentifier(node.name) ? node.name.text : null; + return propertyNameText(node.name); } if ( - ts.isFunctionDeclaration(node) - || ts.isMethodDeclaration(node) - || ts.isGetAccessorDeclaration(node) - || ts.isSetAccessorDeclaration(node) + ts.isFunctionDeclaration(node) || + ts.isMethodDeclaration(node) || + ts.isGetAccessorDeclaration(node) || + ts.isSetAccessorDeclaration(node) ) { return propertyNameText(node.name); } @@ -671,15 +704,19 @@ function declarationKey( } function callableName(node: ts.Node): string { - for (let current: ts.Node | undefined = node; current; current = current.parent) { + for ( + let current: ts.Node | undefined = node; + current; + current = current.parent + ) { if (ts.isConstructorDeclaration(current)) { const container = enclosingDeclarationParts(current).join("."); return container ? `${container}.constructor` : "constructor"; } if ( - ts.isMethodDeclaration(current) - || ts.isGetAccessorDeclaration(current) - || ts.isSetAccessorDeclaration(current) + ts.isMethodDeclaration(current) || + ts.isGetAccessorDeclaration(current) || + ts.isSetAccessorDeclaration(current) ) { const method = propertyNameText(current.name) ?? ""; const container = enclosingDeclarationParts(current).join("."); @@ -692,9 +729,9 @@ function callableName(node: ts.Node): string { : current.name.text; } if ( - (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) - && ts.isVariableDeclaration(current.parent) - && ts.isIdentifier(current.parent.name) + (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) && + ts.isVariableDeclaration(current.parent) && + ts.isIdentifier(current.parent.name) ) { const container = enclosingDeclarationParts(current.parent).join("."); return container @@ -708,7 +745,11 @@ function callableName(node: ts.Node): string { function sourceScriptKind(fileName: string): ts.ScriptKind { if (fileName.endsWith(".tsx")) return ts.ScriptKind.TSX; if (fileName.endsWith(".jsx")) return ts.ScriptKind.JSX; - if (fileName.endsWith(".js") || fileName.endsWith(".mjs") || fileName.endsWith(".cjs")) { + if ( + fileName.endsWith(".js") || + fileName.endsWith(".mjs") || + fileName.endsWith(".cjs") + ) { return ts.ScriptKind.JS; } return ts.ScriptKind.TS; @@ -748,14 +789,23 @@ function createProgram(options: AuditOptions): ts.Program { return baseHost.directoryExists?.(directoryName) ?? false; }, fileExists(fileName) { - return normalizedVirtualSources.has(path.resolve(fileName)) - || baseHost.fileExists(fileName); + return ( + normalizedVirtualSources.has(path.resolve(fileName)) || + baseHost.fileExists(fileName) + ); }, readFile(fileName) { - return normalizedVirtualSources.get(path.resolve(fileName)) - ?? baseHost.readFile(fileName); + return ( + normalizedVirtualSources.get(path.resolve(fileName)) ?? + baseHost.readFile(fileName) + ); }, - getSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile) { + getSourceFile( + fileName, + languageVersion, + onError, + shouldCreateNewSourceFile, + ) { const source = normalizedVirtualSources.get(path.resolve(fileName)); if (source !== undefined) { return ts.createSourceFile( @@ -784,17 +834,20 @@ function createProgram(options: AuditOptions): ts.Program { function isParameterProperty( declaration: ts.Declaration, ): declaration is ts.ParameterDeclaration { - return ts.isParameter(declaration) - && ts.isIdentifier(declaration.name) - && ts.isConstructorDeclaration(declaration.parent) - && Boolean( - declaration.modifiers?.some((modifier) => - modifier.kind === ts.SyntaxKind.PublicKeyword - || modifier.kind === ts.SyntaxKind.PrivateKeyword - || modifier.kind === ts.SyntaxKind.ProtectedKeyword - || modifier.kind === ts.SyntaxKind.ReadonlyKeyword + return ( + ts.isParameter(declaration) && + ts.isIdentifier(declaration.name) && + ts.isConstructorDeclaration(declaration.parent) && + Boolean( + declaration.modifiers?.some( + (modifier) => + modifier.kind === ts.SyntaxKind.PublicKeyword || + modifier.kind === ts.SyntaxKind.PrivateKeyword || + modifier.kind === ts.SyntaxKind.ProtectedKeyword || + modifier.kind === ts.SyntaxKind.ReadonlyKeyword, ), - ); + ) + ); } function canonicalSymbol( @@ -852,15 +905,29 @@ function symbolAtExpression( return undefined; } -function isScratchRegionFactorySymbol( +function isScratchRegionFactorySymbol(symbol: ts.Symbol | undefined): boolean { + return Boolean( + symbol?.declarations?.some((declaration) => { + const name = declarationName(declaration); + if ( + name !== "allocateKernelScratchRegion" && + name !== "reserveKernelScratchRegion" + ) { + return false; + } + const file = toPosix(declaration.getSourceFile().fileName); + return file.endsWith("/host/src/kernel-scratch.ts"); + }), + ); +} + +function isScratchRegionOwnershipValidatorSymbol( symbol: ts.Symbol | undefined, ): boolean { return Boolean( symbol?.declarations?.some((declaration) => { - const name = declarationName(declaration); if ( - name !== "allocateKernelScratchRegion" - && name !== "reserveKernelScratchRegion" + declarationName(declaration) !== "validateKernelScratchRegionOwnership" ) { return false; } @@ -887,64 +954,60 @@ function isKernelScratchMemberDeclaration( const name = (declaration as ts.NamedDeclaration).name; if (!name || propertyNameText(name) !== member) return false; const file = toPosix(declaration.getSourceFile().fileName); - return file.endsWith("/host/src/kernel-scratch.ts") - && owners.has( - signatureOwnerName(declaration as ts.SignatureDeclaration) ?? "", - ); + return ( + file.endsWith("/host/src/kernel-scratch.ts") && + owners.has(signatureOwnerName(declaration as ts.SignatureDeclaration) ?? "") + ); } -function isScratchAddressSymbol( - symbol: ts.Symbol | undefined, -): boolean { +function isScratchAddressSymbol(symbol: ts.Symbol | undefined): boolean { return Boolean( symbol?.declarations?.some((declaration) => isKernelScratchMemberDeclaration( declaration, "address", SCRATCH_ADDRESS_OWNERS, - ) + ), ), ); } -function isScratchLeaseMemberSymbol( - symbol: ts.Symbol | undefined, -): boolean { +function isScratchLeaseMemberSymbol(symbol: ts.Symbol | undefined): boolean { return Boolean( symbol?.declarations?.some((declaration) => { const file = toPosix(declaration.getSourceFile().fileName); - return file.endsWith("/host/src/kernel-scratch.ts") - && SCRATCH_ADDRESS_OWNERS.has( + return ( + file.endsWith("/host/src/kernel-scratch.ts") && + SCRATCH_ADDRESS_OWNERS.has( signatureOwnerName(declaration as ts.SignatureDeclaration) ?? "", - ); + ) + ); }), ); } -function isScratchWithLeaseSymbol( - symbol: ts.Symbol | undefined, -): boolean { +function isScratchWithLeaseSymbol(symbol: ts.Symbol | undefined): boolean { return Boolean( symbol?.declarations?.some((declaration) => isKernelScratchMemberDeclaration( declaration, "withLease", SCRATCH_REGION_OWNERS, - ) + ), ), ); } -function isScratchRegionMemberSymbol( - symbol: ts.Symbol | undefined, -): boolean { +function isScratchRegionMemberSymbol(symbol: ts.Symbol | undefined): boolean { return Boolean( symbol?.declarations?.some((declaration) => { const file = toPosix(declaration.getSourceFile().fileName); - return file.endsWith("/host/src/kernel-scratch.ts") - && SCRATCH_REGION_OWNERS.has( + return ( + file.endsWith("/host/src/kernel-scratch.ts") && + SCRATCH_REGION_OWNERS.has( signatureOwnerName(declaration as ts.SignatureDeclaration) ?? "", - ); + ) + ); }), ); } @@ -956,8 +1019,8 @@ function isKernelScratchWithLeaseCall( if (callPropertyName(call) !== "withLease") return false; const declaration = checker.getResolvedSignature(call)?.declaration; return Boolean( - declaration - && isKernelScratchMemberDeclaration( + declaration && + isKernelScratchMemberDeclaration( declaration, "withLease", SCRATCH_REGION_OWNERS, @@ -980,10 +1043,10 @@ function parameterPropertySymbol( parameter: ts.ParameterDeclaration, ): ts.Symbol | undefined { if ( - !isParameterProperty(parameter) - || !ts.isIdentifier(parameter.name) - || !ts.isConstructorDeclaration(parameter.parent) - || !ts.isClassLike(parameter.parent.parent) + !isParameterProperty(parameter) || + !ts.isIdentifier(parameter.name) || + !ts.isConstructorDeclaration(parameter.parent) || + !ts.isClassLike(parameter.parent.parent) ) { return undefined; } @@ -1007,11 +1070,7 @@ function parameterPropertySymbol( function hasBody( declaration: ts.Node | undefined, ): declaration is ts.FunctionLikeDeclaration { - return Boolean( - declaration - && "body" in declaration - && declaration.body, - ); + return Boolean(declaration && "body" in declaration && declaration.body); } function callbackDeclarations( @@ -1020,19 +1079,14 @@ function callbackDeclarations( ): ts.FunctionLikeDeclaration[] { const node = unwrapExpression(expression); const declarations = new Set(); - if ( - ts.isArrowFunction(node) - || ts.isFunctionExpression(node) - ) { + if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) { declarations.add(node); } const type = checker.getTypeAtLocation(node); - for ( - const signature of checker.getSignaturesOfType( - type, - ts.SignatureKind.Call, - ) - ) { + for (const signature of checker.getSignaturesOfType( + type, + ts.SignatureKind.Call, + )) { if (hasBody(signature.declaration)) { declarations.add(signature.declaration); } @@ -1048,8 +1102,10 @@ function isInProgram( } function isAssignmentOperator(kind: ts.SyntaxKind): boolean { - return kind >= ts.SyntaxKind.FirstAssignment - && kind <= ts.SyntaxKind.LastAssignment; + return ( + kind >= ts.SyntaxKind.FirstAssignment && + kind <= ts.SyntaxKind.LastAssignment + ); } function isSimpleAssignment(node: ts.BinaryExpression): boolean { @@ -1063,20 +1119,20 @@ function typedArrayConstructorName(expression: ts.Expression): string | null { return null; } -function isIntrinsicLibDeclaration( - declaration: ts.Declaration, -): boolean { +function isIntrinsicLibDeclaration(declaration: ts.Declaration): boolean { const sourceFile = declaration.getSourceFile(); - return sourceFile.isDeclarationFile - && /^lib\..*\.d\.ts$/.test(path.basename(sourceFile.fileName)); + return ( + sourceFile.isDeclarationFile && + /^lib\..*\.d\.ts$/.test(path.basename(sourceFile.fileName)) + ); } function hasIntrinsicLibValueDeclaration( symbol: ts.Symbol | undefined, ): boolean { return Boolean( - symbol?.valueDeclaration - && isIntrinsicLibDeclaration(symbol.valueDeclaration), + symbol?.valueDeclaration && + isIntrinsicLibDeclaration(symbol.valueDeclaration), ); } @@ -1088,29 +1144,30 @@ function isIntrinsicObjectFreezeCall( const callee = unwrapExpression(call.expression); if (ts.isIdentifier(callee) && callee.text === "intrinsicObjectFreeze") { return Boolean( - symbolAtExpression(checker, callee)?.declarations?.some((declaration) => - ts.isVariableDeclaration(declaration) - && toPosix(declaration.getSourceFile().fileName) - .endsWith("/host/src/kernel-scratch.ts") + symbolAtExpression(checker, callee)?.declarations?.some( + (declaration) => + ts.isVariableDeclaration(declaration) && + toPosix(declaration.getSourceFile().fileName).endsWith( + "/host/src/kernel-scratch.ts", + ), ), ); } - if ( - !ts.isPropertyAccessExpression(callee) - || callee.name.text !== "freeze" - ) { + if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== "freeze") { return false; } const receiver = unwrapExpression(callee.expression); const declaration = checker.getResolvedSignature(call)?.declaration; - return ts.isIdentifier(receiver) - && receiver.text === "Object" - && hasIntrinsicLibValueDeclaration(symbolAtExpression(checker, receiver)) - && Boolean( - declaration - && isIntrinsicLibDeclaration(declaration) - && signatureOwnerName(declaration) === "ObjectConstructor", - ); + return ( + ts.isIdentifier(receiver) && + receiver.text === "Object" && + hasIntrinsicLibValueDeclaration(symbolAtExpression(checker, receiver)) && + Boolean( + declaration && + isIntrinsicLibDeclaration(declaration) && + signatureOwnerName(declaration) === "ObjectConstructor", + ) + ); } function intrinsicViewConstructorBits( @@ -1118,2313 +1175,5807 @@ function intrinsicViewConstructorBits( checker: ts.TypeChecker, ): number { const name = typedArrayConstructorName(expression); - if ( - name !== "DataView" - && (!name || !TYPED_ARRAY_CONSTRUCTORS.has(name)) - ) { + if (name !== "DataView" && (!name || !TYPED_ARRAY_CONSTRUCTORS.has(name))) { return 0; } const symbol = symbolAtExpression(checker, expression); if (!hasIntrinsicLibValueDeclaration(symbol)) { return 0; } - return name === "DataView" - ? DATA_VIEW_CONSTRUCTOR - : TYPED_ARRAY_CONSTRUCTOR; + return name === "DataView" ? DATA_VIEW_CONSTRUCTOR : TYPED_ARRAY_CONSTRUCTOR; } -function isIntrinsicBufferFrom( - call: ts.CallExpression, +function immutableAuthorityContainerProjection( + container: ts.Expression, + property: string, +): ts.Expression | null { + const node = unwrapExpression(container); + if (ts.isObjectLiteralExpression(node)) { + const matches: ts.Expression[] = []; + for (const entry of node.properties) { + if ( + ts.isPropertyAssignment(entry) && + propertyNameText(entry.name) === property + ) { + matches.push(entry.initializer); + } else if ( + ts.isShorthandPropertyAssignment(entry) && + entry.name.text === property + ) { + matches.push(entry.name); + } else if ( + ts.isSpreadAssignment(entry) || + ts.isGetAccessorDeclaration(entry) || + ts.isMethodDeclaration(entry) + ) { + return null; + } + } + return matches.length === 1 ? matches[0]! : null; + } + if (ts.isArrayLiteralExpression(node) && /^\d+$/.test(property)) { + const index = Number(property); + const element = node.elements[index]; + return element && + !ts.isOmittedExpression(element) && + !ts.isSpreadElement(element) + ? element + : null; + } + return null; +} + +/** + * Resolve immutable aliases and literal container projections used to hide a + * WebAssembly authority-bearing namespace/function/constructor. + * + * This is deliberately syntax-exact: mutable objects, spreads, getters, and + * computed indexes are never trusted as stable projections. + */ +function immutableAuthorityProjection( + expression: ts.Expression, checker: ts.TypeChecker, -): boolean { - const callee = unwrapExpression(call.expression); + seen = new Set(), +): ts.Expression { + const node = unwrapExpression(expression); if ( - !ts.isPropertyAccessExpression(callee) - || callee.name.text !== "from" - || callee.expression.getText(call.getSourceFile()) !== "Buffer" + ts.isPropertyAccessExpression(node) || + ts.isElementAccessExpression(node) ) { - return false; + const property = accessedPropertyName(node); + if (property !== null) { + const receiver = immutableAuthorityProjection( + node.expression, + checker, + new Set(seen), + ); + const projected = immutableAuthorityContainerProjection( + receiver, + property, + ); + if (projected) { + return immutableAuthorityProjection(projected, checker, seen); + } + } + return node; } - const signatureDeclaration = checker.getResolvedSignature(call)?.declaration; - return Boolean( - signatureDeclaration?.getSourceFile().isDeclarationFile - && signatureOwnerName(signatureDeclaration) === "BufferConstructor", - ); + if (!ts.isIdentifier(node)) return node; + const symbol = symbolAtExpression(checker, node); + if (!symbol || seen.has(symbol)) return node; + const declarations = symbol.declarations ?? []; + if (declarations.length !== 1) return node; + const declaration = declarations[0]!; + const nextSeen = new Set(seen); + nextSeen.add(symbol); + if ( + ts.isVariableDeclaration(declaration) && + declaration.initializer && + ts.isVariableDeclarationList(declaration.parent) && + (declaration.parent.flags & ts.NodeFlags.Const) !== 0 + ) { + return immutableAuthorityProjection( + declaration.initializer, + checker, + nextSeen, + ); + } + if ( + ts.isBindingElement(declaration) && + ts.isVariableDeclaration(declaration.parent.parent) && + declaration.parent.parent.initializer && + ts.isVariableDeclarationList(declaration.parent.parent.parent) && + (declaration.parent.parent.parent.flags & ts.NodeFlags.Const) !== 0 + ) { + let property: string | null = null; + if (ts.isObjectBindingPattern(declaration.parent)) { + property = + propertyNameText(declaration.propertyName) ?? + propertyNameText(declaration.name); + } else if (ts.isArrayBindingPattern(declaration.parent)) { + const index = declaration.parent.elements.indexOf(declaration); + property = index >= 0 ? String(index) : null; + } + if (property !== null) { + const container = immutableAuthorityProjection( + declaration.parent.parent.initializer, + checker, + nextSeen, + ); + const projected = immutableAuthorityContainerProjection( + container, + property, + ); + if (projected) { + return immutableAuthorityProjection(projected, checker, nextSeen); + } + } + } + return node; } -function intrinsicArrayMethod( - call: ts.CallExpression, +function intrinsicWasmAuthorityConstructorBits( + expression: ts.Expression, checker: ts.TypeChecker, -): string | null { - const cached = INTRINSIC_ARRAY_METHODS.get(call); - if (cached !== undefined) return cached; - const method = callPropertyName(call); +): number { + const node = immutableAuthorityProjection(expression, checker); if ( - !method - || ( - !ARRAY_ELEMENT_RETURNING_METHODS.has(method) - && !ARRAY_ELEMENT_CALLBACK_METHODS.has(method) - ) + !ts.isPropertyAccessExpression(node) && + !ts.isElementAccessExpression(node) ) { - INTRINSIC_ARRAY_METHODS.set(call, null); - return null; + return 0; } - const declaration = checker.getResolvedSignature(call)?.declaration; - const owner = signatureOwnerName(declaration); - const result = ( - declaration - && isIntrinsicLibDeclaration(declaration) - && (owner === "Array" || owner === "ReadonlyArray") - ) - ? method - : null; - INTRINSIC_ARRAY_METHODS.set(call, result); - return result; + const name = accessedPropertyName(node); + if (name !== "Memory" && name !== "Instance") return 0; + const receiver = unwrapExpression(node.expression); + if ( + !isIntrinsicNamespaceReference(receiver, "WebAssembly", checker) || + !hasIntrinsicLibValueDeclaration(symbolAtExpression(checker, node)) + ) { + return 0; + } + return name === "Memory" + ? WASM_MEMORY_CONSTRUCTOR + : WASM_INSTANCE_CONSTRUCTOR; } -function intrinsicTypedArrayMethod( - call: ts.CallExpression, +function isIntrinsicNamespaceReference( + expression: ts.Expression, + namespace: "Reflect" | "WebAssembly", checker: ts.TypeChecker, -): string | null { - const method = callPropertyName(call); - if (!method) return null; - const declaration = checker.getResolvedSignature(call)?.declaration; - const owner = signatureOwnerName(declaration); - return ( - declaration - && isIntrinsicLibDeclaration(declaration) - && owner - && TYPED_ARRAY_CONSTRUCTORS.has(owner) - ) - ? method - : null; -} - -function returnFunction(node: ts.Node): ts.FunctionLikeDeclaration | null { - for (let current: ts.Node | undefined = node.parent; current; current = current.parent) { - if (hasBody(current)) return current; + seen = new Set(), +): boolean { + const node = immutableAuthorityProjection(expression, checker, seen); + if ( + namespace === "WebAssembly" && + (ts.isPropertyAccessExpression(node) || + ts.isElementAccessExpression(node)) && + accessedPropertyName(node) === "WebAssembly" + ) { + const receiver = unwrapExpression(node.expression); + if ( + ts.isIdentifier(receiver) && + receiver.text === "globalThis" && + hasIntrinsicLibValueDeclaration(symbolAtExpression(checker, receiver)) + ) { + return true; + } } - return null; + if ( + ts.isIdentifier(node) && + node.text === namespace && + hasIntrinsicLibValueDeclaration(symbolAtExpression(checker, node)) + ) { + return true; + } + if (!ts.isIdentifier(node)) return false; + const symbol = symbolAtExpression(checker, node); + if (!symbol || seen.has(symbol)) return false; + seen.add(symbol); + const declarations = symbol.declarations ?? []; + if (declarations.length !== 1) return false; + const declaration = declarations[0]; + if ( + !ts.isVariableDeclaration(declaration) || + !declaration.initializer || + !ts.isVariableDeclarationList(declaration.parent) || + (declaration.parent.flags & ts.NodeFlags.Const) === 0 + ) { + return false; + } + return isIntrinsicNamespaceReference( + declaration.initializer, + namespace, + checker, + seen, + ); } -function isPersistentStoreTarget( +function isIntrinsicGlobalObjectReference( expression: ts.Expression, checker: ts.TypeChecker, - assignment: ts.Node, ): boolean { const node = unwrapExpression(expression); if ( - ts.isPropertyAccessExpression(node) - || ts.isElementAccessExpression(node) + !ts.isIdentifier(node) || + (node.text !== "globalThis" && + node.text !== "self" && + node.text !== "window") ) { - return true; - } - if (ts.isObjectLiteralExpression(node)) { - return node.properties.some((property) => { - if (ts.isShorthandPropertyAssignment(property)) { - return isPersistentStoreTarget(property.name, checker, assignment); - } - if (ts.isPropertyAssignment(property)) { - return isPersistentStoreTarget( - property.initializer, - checker, - assignment, - ); - } - if (ts.isSpreadAssignment(property)) { - return isPersistentStoreTarget( - property.expression, - checker, - assignment, - ); - } - return false; - }); + return false; } - if (ts.isArrayLiteralExpression(node)) { - return node.elements.some((element) => - !ts.isOmittedExpression(element) - && isPersistentStoreTarget( - ts.isSpreadElement(element) ? element.expression : element, + const symbol = symbolAtExpression(checker, node); + // `globalThis` is a compiler-synthesized global in some programs and has no + // declaration symbol. A same-spelled local always has a source declaration + // and must not be treated as the intrinsic global object. + return !symbol?.declarations?.some( + (declaration) => !isIntrinsicLibDeclaration(declaration), + ); +} + +function intrinsicWasmAuthorityConstructorReferenceBits( + expression: ts.Expression, + checker: ts.TypeChecker, + seen = new Set(), +): number { + const direct = intrinsicWasmAuthorityConstructorBits(expression, checker); + if (direct !== 0) return direct; + const node = immutableAuthorityProjection(expression, checker, seen); + if ( + ts.isCallExpression(node) && + propertyIs(node.expression, "bind") && + node.arguments.length >= 1 + ) { + const receiver = callReceiver(node); + const signature = checker.getResolvedSignature(node)?.declaration; + if (receiver && signature && isIntrinsicLibDeclaration(signature)) { + return intrinsicWasmAuthorityConstructorReferenceBits( + receiver, checker, - assignment, - ) - ); + seen, + ); + } } - if (!ts.isIdentifier(node)) return false; + if (!ts.isIdentifier(node)) return 0; const symbol = symbolAtExpression(checker, node); - const assignmentFunction = returnFunction(assignment); - return Boolean( - symbol?.declarations?.some((declaration) => - returnFunction(declaration) !== assignmentFunction - ), - ); + if (!symbol || seen.has(symbol)) return 0; + seen.add(symbol); + const declarations = symbol.declarations ?? []; + if (declarations.length !== 1) return 0; + const declaration = declarations[0]; + if ( + ts.isVariableDeclaration(declaration) && + declaration.initializer && + ts.isVariableDeclarationList(declaration.parent) && + (declaration.parent.flags & ts.NodeFlags.Const) !== 0 + ) { + return intrinsicWasmAuthorityConstructorReferenceBits( + declaration.initializer, + checker, + seen, + ); + } + if ( + ts.isBindingElement(declaration) && + ts.isObjectBindingPattern(declaration.parent) && + ts.isVariableDeclaration(declaration.parent.parent) && + declaration.parent.parent.initializer && + ts.isVariableDeclarationList(declaration.parent.parent.parent) && + (declaration.parent.parent.parent.flags & ts.NodeFlags.Const) !== 0 && + isIntrinsicNamespaceReference( + declaration.parent.parent.initializer, + "WebAssembly", + checker, + ) + ) { + const property = + propertyNameText(declaration.propertyName) ?? + propertyNameText(declaration.name); + return property === "Memory" + ? WASM_MEMORY_CONSTRUCTOR + : property === "Instance" + ? WASM_INSTANCE_CONSTRUCTOR + : 0; + } + return 0; } -function stateFor( - states: Map, - key: StateKey | undefined, -): ValueState { - return key ? states.get(key) ?? EMPTY_STATE : EMPTY_STATE; -} - -function mergeIntoKey( - states: Map, - key: StateKey, - state: ValueState, - targetProjection: readonly StateProjection[] = [], +function isIntrinsicReflectConstructReference( + expression: ts.Expression, + checker: ts.TypeChecker, + seen = new Set(), ): boolean { - let target = states.get(key); - if (!target) { - target = emptyState(); - states.set(key, target); + const node = immutableAuthorityProjection(expression, checker, seen); + if ( + (ts.isPropertyAccessExpression(node) || + ts.isElementAccessExpression(node)) && + accessedPropertyName(node) === "construct" && + isIntrinsicNamespaceReference(node.expression, "Reflect", checker) && + hasIntrinsicLibValueDeclaration(symbolAtExpression(checker, node)) + ) { + return true; } - for (const projection of targetProjection) { - if (projection.kind === "element") { - if (!target.elements) target.elements = emptyState(); - target = target.elements; - continue; - } - let property = target.properties.get(projection.name); - if (!property) { - property = emptyState(); - target.properties.set(projection.name, property); - } - target = property; + if (!ts.isIdentifier(node)) return false; + const symbol = symbolAtExpression(checker, node); + if (!symbol || seen.has(symbol)) return false; + seen.add(symbol); + const declarations = symbol.declarations ?? []; + if (declarations.length !== 1) return false; + const declaration = declarations[0]; + if ( + !ts.isVariableDeclaration(declaration) || + !declaration.initializer || + !ts.isVariableDeclarationList(declaration.parent) || + (declaration.parent.flags & ts.NodeFlags.Const) === 0 + ) { + return false; } - return unionState(target, state); + return isIntrinsicReflectConstructReference( + declaration.initializer, + checker, + seen, + ); } -function hydrateTypeProperties( - state: ValueState, +function isIntrinsicWasmInstantiateFunction( expression: ts.Expression, checker: ts.TypeChecker, - states: Map, -): ValueState { - const result = cloneState(state); - const type = checker.getTypeAtLocation(expression); - let properties = TYPE_PROPERTIES.get(type); - if (!properties) { - properties = checker.getPropertiesOfType(type); - TYPE_PROPERTIES.set(type, properties); +): boolean { + const node = immutableAuthorityProjection(expression, checker); + if ( + !ts.isPropertyAccessExpression(node) && + !ts.isElementAccessExpression(node) + ) { + return false; } - for (const property of properties) { - const hardPrivate = property.declarations?.some((declaration) => { - const name = (declaration as ts.NamedDeclaration).name; - return Boolean(name && ts.isPrivateIdentifier(name)); - }); - if (hardPrivate) continue; - const hidden = Boolean( - property.declarations?.some((declaration) => - ts.canHaveModifiers(declaration) - && ts.getModifiers(declaration)?.some( - (modifier) => - modifier.kind === ts.SyntaxKind.PrivateKeyword - || modifier.kind === ts.SyntaxKind.ProtectedKeyword, - ) - ), - ); - const propertyValue = cloneState( - stateFor(states, canonicalSymbol(checker, property)), - ); - if (!hasCapability(propertyValue)) continue; - // WHY: private/protected TypeScript slots must remain selectable through - // explicit diagnostic casts, but must not make the whole owning wrapper a - // raw-memory escape. Object spread promotes these ordinary runtime fields. - const target = hidden ? result.hiddenProperties : result.properties; - const existing = target.get(property.name); - if (existing) unionState(existing, propertyValue); - else target.set(property.name, propertyValue); + const member = accessedPropertyName(node); + if (member !== "instantiate" && member !== "instantiateStreaming") { + return false; } - return result; -} - -function propertyIs( - expression: ts.Expression, - expected: string, -): boolean { - const unwrapped = unwrapExpression(expression); + const receiver = unwrapExpression(node.expression); return ( - (ts.isPropertyAccessExpression(unwrapped) - || ts.isElementAccessExpression(unwrapped)) - && accessedPropertyName(unwrapped) === expected + isIntrinsicNamespaceReference(receiver, "WebAssembly", checker) && + hasIntrinsicLibValueDeclaration(symbolAtExpression(checker, node)) ); } -function isJavaScriptKernelMemoryAccessorCall( - node: ts.CallExpression, -): boolean { - const sourceFile = node.getSourceFile(); - if (!/\.(?:c|m)?jsx?$/.test(sourceFile.fileName)) { - return false; +/** + * Return the target of an intrinsic Function.prototype call/apply dispatch. + * + * `WebAssembly.instantiate.call(...)` and `.apply(...)` create the same + * Instance authority as a direct call. Checking the resolved intrinsic + * signature prevents a user-defined `call` property from being mistaken for + * the built-in dispatcher. + */ +function intrinsicCallApplyTarget( + call: ts.CallExpression, + checker: ts.TypeChecker, +): ts.Expression | null { + const callee = unwrapExpression(call.expression); + if ( + !ts.isPropertyAccessExpression(callee) && + !ts.isElementAccessExpression(callee) + ) { + return null; } - // WHY: JavaScript's untyped parameters can erase the receiver type before - // the checker reaches `kernel.getMemory()`. This exact zero-argument method - // is Kandelo's documented raw kernel-memory escape hatch, so seed its result - // syntactically and let the ordinary ownership analysis and exact allowlist - // handle aliases, helper parameters, views, and writes. This is deliberately - // not general JavaScript taint analysis. - return node.arguments.length === 0 && propertyIs(node.expression, "getMemory"); + const member = accessedPropertyName(callee); + if (member !== "call" && member !== "apply") return null; + const signature = checker.getResolvedSignature(call)?.declaration; + return signature && isIntrinsicLibDeclaration(signature) + ? callee.expression + : null; } -function isJavaScriptKernelInstanceAccessorCall( - node: ts.CallExpression, -): boolean { - const sourceFile = node.getSourceFile(); - if (!/\.(?:c|m)?jsx?$/.test(sourceFile.fileName)) { - return false; +function intrinsicFunctionDispatcherKind( + expression: ts.Expression, + checker: ts.TypeChecker, +): "call" | "apply" | null { + const node = immutableAuthorityProjection(expression, checker); + if ( + !ts.isPropertyAccessExpression(node) && + !ts.isElementAccessExpression(node) + ) { + return null; } - // See getMemory above. JavaScript erases the receiver type, but this exact - // trusted-embedder escape exposes the same kernel memory through - // `getInstance().exports.memory` and must remain visible to the audit. - return node.arguments.length === 0 - && propertyIs(node.expression, "getInstance"); + const member = accessedPropertyName(node); + if (member !== "call" && member !== "apply") return null; + return hasIntrinsicLibValueDeclaration(symbolAtExpression(checker, node)) + ? member + : null; } -function isCapturedKernelInstanceExportsCall( - node: ts.CallExpression, -): boolean { +function immutableArrayArgument( + expression: ts.Expression | undefined, + index: number, + checker: ts.TypeChecker, +): ts.Expression | null { + if (!expression) return null; + const node = immutableAuthorityProjection(expression, checker); + if (!ts.isArrayLiteralExpression(node)) return null; + const element = node.elements[index]; + return element && + !ts.isOmittedExpression(element) && + !ts.isSpreadElement(element) + ? element + : null; +} + +function intrinsicReflectConstructInvocationTarget( + call: ts.CallExpression, + checker: ts.TypeChecker, +): ts.Expression | null { + if (isIntrinsicReflectConstructReference(call.expression, checker)) { + return call.arguments[0] ?? null; + } + + const callApplyTarget = intrinsicCallApplyTarget(call, checker); if ( - !toPosix(node.getSourceFile().fileName) - .endsWith("/host/src/kernel-scratch.ts") + callApplyTarget && + isIntrinsicReflectConstructReference(callApplyTarget, checker) ) { - return false; + const member = callPropertyName(call); + return member === "call" + ? (call.arguments[1] ?? null) + : member === "apply" + ? immutableArrayArgument(call.arguments[1], 0, checker) + : null; } - const callee = unwrapExpression(node.expression); - const getter = node.arguments[0] - ? unwrapExpression(node.arguments[0]) - : null; - return ts.isIdentifier(callee) - && callee.text === "intrinsicApply" - && getter !== null - && ts.isIdentifier(getter) - && getter.text === "intrinsicInstanceExports" - && node.arguments.length === 3; + + if ( + isCapturedIntrinsicApply(call.expression, checker) && + call.arguments[0] && + isIntrinsicReflectConstructReference(call.arguments[0], checker) + ) { + return immutableArrayArgument(call.arguments[2], 0, checker); + } + + const invoked = immutableAuthorityProjection(call.expression, checker); + if (ts.isCallExpression(invoked) && propertyIs(invoked.expression, "bind")) { + const receiver = callReceiver(invoked); + const signature = checker.getResolvedSignature(invoked)?.declaration; + if ( + receiver && + signature && + isIntrinsicLibDeclaration(signature) && + isIntrinsicReflectConstructReference(receiver, checker) + ) { + return invoked.arguments[1] ?? null; + } + } + return null; } -function expressionState( +function isIntrinsicWasmInstantiateReference( expression: ts.Expression, checker: ts.TypeChecker, - states: Map, - programSources: ReadonlySet, -): ValueState { - const node = unwrapExpression(expression); - if (ts.isSpreadElement(node)) { - // A spread call/new argument passes the elements, not the container. - // WHY: dropping this projection lets `opaque(...[kernelView])` hide the - // same live view that `opaque(kernelView)` exposes directly. - return elementState( - expressionState(node.expression, checker, states, programSources), - ); + seen = new Set(), +): boolean { + if (isIntrinsicWasmInstantiateFunction(expression, checker)) return true; + const node = immutableAuthorityProjection(expression, checker, seen); + if ( + ts.isCallExpression(node) && + propertyIs(node.expression, "bind") && + node.arguments.length >= 1 + ) { + const receiver = callReceiver(node); + const signature = checker.getResolvedSignature(node)?.declaration; + if (receiver && signature && isIntrinsicLibDeclaration(signature)) { + if (isIntrinsicWasmInstantiateReference(receiver, checker, seen)) { + return true; + } + if ( + intrinsicFunctionDispatcherKind(receiver, checker) !== null && + isIntrinsicWasmInstantiateReference(node.arguments[0]!, checker, seen) + ) { + return true; + } + } } - const direct = ts.isIdentifier(node) - && ts.isShorthandPropertyAssignment(node.parent) - && node.parent.name === node - ? canonicalSymbol( - checker, - checker.getShorthandAssignmentValueSymbol(node.parent), - ) - : symbolAtExpression(checker, node); - const directState = cloneState(stateFor(states, direct)); - if (isScratchRegionFactorySymbol(direct)) { - directState.scratchRegionFactory = true; + if (!ts.isIdentifier(node)) return false; + const symbol = symbolAtExpression(checker, node); + if (!symbol || seen.has(symbol)) return false; + seen.add(symbol); + const declarations = symbol.declarations ?? []; + if (declarations.length !== 1) return false; + const declaration = declarations[0]; + if ( + ts.isVariableDeclaration(declaration) && + declaration.initializer && + ts.isVariableDeclarationList(declaration.parent) && + (declaration.parent.flags & ts.NodeFlags.Const) !== 0 + ) { + return isIntrinsicWasmInstantiateReference( + declaration.initializer, + checker, + seen, + ); } - directState.viewConstructors |= intrinsicViewConstructorBits(node, checker); if ( - (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) + ts.isBindingElement(declaration) && + ts.isObjectBindingPattern(declaration.parent) && + ts.isVariableDeclaration(declaration.parent.parent) && + declaration.parent.parent.initializer && + ts.isVariableDeclarationList(declaration.parent.parent.parent) && + (declaration.parent.parent.parent.flags & ts.NodeFlags.Const) !== 0 && + isIntrinsicNamespaceReference( + declaration.parent.parent.initializer, + "WebAssembly", + checker, + ) && + (propertyNameText(declaration.propertyName) ?? + propertyNameText(declaration.name)) === "instantiate" ) { - const property = accessedPropertyName(node); - if (property === "kernel_alloc_scratch") { - directState.allocator = true; - } else if ( - property === "kernel_spawn_scratch_begin" - || property === "kernel_spawn_scratch_pointer" - || property === "kernel_spawn_scratch_capacity" - || property === "kernel_spawn_scratch_cancel" - ) { - directState.reserver = true; - } + return true; } + return false; +} - if (ts.isConditionalExpression(node)) { - return unionMany([ - directState, - expressionState(node.whenTrue, checker, states, programSources), - expressionState(node.whenFalse, checker, states, programSources), - ]); +function typeContainsIntrinsicWasmInstance( + type: ts.Type, + checker: ts.TypeChecker, + seen = new Set(), +): boolean { + if (seen.has(type)) return false; + seen.add(type); + if (type.isUnionOrIntersection()) { + return type.types.some((part) => + typeContainsIntrinsicWasmInstance(part, checker, seen), + ); } - if (ts.isBinaryExpression(node)) { - if (node.operatorToken.kind === ts.SyntaxKind.CommaToken) { - // The comma expression evaluates to its right operand. - return unionMany([ - directState, - expressionState(node.right, checker, states, programSources), - ]); - } - if ( - node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken - || node.operatorToken.kind === ts.SyntaxKind.BarBarToken - || node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken - ) { - // A logical expression can return either operand without copying it. - return unionMany([ - directState, - expressionState(node.left, checker, states, programSources), - expressionState(node.right, checker, states, programSources), - ]); + const symbol = type.aliasSymbol ?? type.getSymbol(); + if ( + symbol?.getName() === "Instance" && + symbol.declarations?.some( + (declaration) => + isIntrinsicLibDeclaration(declaration) && + (() => { + for ( + let current: ts.Node | undefined = declaration.parent; + current; + current = current.parent + ) { + if ( + ts.isModuleDeclaration(current) && + current.name.getText() === "WebAssembly" + ) { + return true; + } + if (ts.isSourceFile(current)) break; + } + return false; + })(), + ) + ) { + return true; + } + if ( + (type.flags & ts.TypeFlags.Object) !== 0 && + ((type as ts.ObjectType).objectFlags & ts.ObjectFlags.Reference) !== 0 + ) { + const reference = type as ts.TypeReference; + for (const argument of checker.getTypeArguments(reference)) { + if (typeContainsIntrinsicWasmInstance(argument, checker, seen)) { + return true; + } } } - if (ts.isBinaryExpression(node) && isSimpleAssignment(node)) { - return unionMany([ - directState, - expressionState(node.right, checker, states, programSources), - ]); + return false; +} + +function typeContainsIntrinsicWasmMemory( + type: ts.Type, + checker: ts.TypeChecker, + seen = new Set(), +): boolean { + if (seen.has(type)) return false; + seen.add(type); + if (type.isUnionOrIntersection()) { + return type.types.some((part) => + typeContainsIntrinsicWasmMemory(part, checker, seen), + ); } - if (ts.isAwaitExpression(node)) { - return unionMany([ - directState, - expressionState(node.expression, checker, states, programSources), - ]); + const symbol = type.aliasSymbol ?? type.getSymbol(); + if ( + symbol?.getName() === "Memory" && + symbol.declarations?.some( + (declaration) => + isIntrinsicLibDeclaration(declaration) && + (() => { + for ( + let current: ts.Node | undefined = declaration.parent; + current; + current = current.parent + ) { + if ( + ts.isModuleDeclaration(current) && + current.name.getText() === "WebAssembly" + ) { + return true; + } + if (ts.isSourceFile(current)) break; + } + return false; + })(), + ) + ) { + return true; } - if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) { - return unionMany([directState, stateFor(states, node)]); + if ( + (type.flags & ts.TypeFlags.Object) !== 0 && + ((type as ts.ObjectType).objectFlags & ts.ObjectFlags.Reference) !== 0 + ) { + const reference = type as ts.TypeReference; + for (const argument of checker.getTypeArguments(reference)) { + if (typeContainsIntrinsicWasmMemory(argument, checker, seen)) { + return true; + } + } + } + return false; +} + +function authorityTypeAtLocation( + node: ts.Node, + checker: ts.TypeChecker, +): ts.Type { + try { + return checker.getTypeAtLocation(node); + } catch (error) { + if (error instanceof RangeError) { + const sourceFile = node.getSourceFile(); + const { line, character } = sourceFile.getLineAndCharacterOfPosition( + node.getStart(sourceFile), + ); + throw new Error( + `authority type analysis overflowed at ${toPosix(sourceFile.fileName)}:${line + 1}:${character + 1}`, + { cause: error }, + ); + } + throw error; + } +} + +function isTypedWasmInstantiateCall( + call: ts.CallExpression, + checker: ts.TypeChecker, +): boolean { + const member = callPropertyName(call); + if (member !== "instantiate" && member !== "instantiateStreaming") { + return false; + } + return typeContainsIntrinsicWasmInstance( + authorityTypeAtLocation(call, checker), + checker, + ); +} + +function isIntrinsicBufferFrom( + call: ts.CallExpression, + checker: ts.TypeChecker, +): boolean { + const callee = unwrapExpression(call.expression); + if ( + !ts.isPropertyAccessExpression(callee) || + callee.name.text !== "from" || + callee.expression.getText(call.getSourceFile()) !== "Buffer" + ) { + return false; + } + const signatureDeclaration = checker.getResolvedSignature(call)?.declaration; + return Boolean( + signatureDeclaration?.getSourceFile().isDeclarationFile && + signatureOwnerName(signatureDeclaration) === "BufferConstructor", + ); +} + +function intrinsicArrayMethod( + call: ts.CallExpression, + checker: ts.TypeChecker, +): string | null { + const cached = INTRINSIC_ARRAY_METHODS.get(call); + if (cached !== undefined) return cached; + const method = callPropertyName(call); + if ( + !method || + (!ARRAY_ELEMENT_RETURNING_METHODS.has(method) && + !ARRAY_ELEMENT_CALLBACK_METHODS.has(method)) + ) { + INTRINSIC_ARRAY_METHODS.set(call, null); + return null; + } + const declaration = checker.getResolvedSignature(call)?.declaration; + const owner = signatureOwnerName(declaration); + const result = + declaration && + isIntrinsicLibDeclaration(declaration) && + (owner === "Array" || owner === "ReadonlyArray") + ? method + : null; + INTRINSIC_ARRAY_METHODS.set(call, result); + return result; +} + +function intrinsicTypedArrayMethod( + call: ts.CallExpression, + checker: ts.TypeChecker, +): string | null { + const method = callPropertyName(call); + if (!method) return null; + const declaration = checker.getResolvedSignature(call)?.declaration; + const owner = signatureOwnerName(declaration); + return declaration && + isIntrinsicLibDeclaration(declaration) && + owner && + TYPED_ARRAY_CONSTRUCTORS.has(owner) + ? method + : null; +} + +function returnFunction(node: ts.Node): ts.FunctionLikeDeclaration | null { + for ( + let current: ts.Node | undefined = node.parent; + current; + current = current.parent + ) { + if (hasBody(current)) return current; + } + return null; +} + +function isPersistentStoreTarget( + expression: ts.Expression, + checker: ts.TypeChecker, + assignment: ts.Node, +): boolean { + const node = unwrapExpression(expression); + if ( + ts.isPropertyAccessExpression(node) || + ts.isElementAccessExpression(node) + ) { + return true; } if (ts.isObjectLiteralExpression(node)) { - const result = cloneState(directState); - for (const property of node.properties) { + return node.properties.some((property) => { + if (ts.isShorthandPropertyAssignment(property)) { + return isPersistentStoreTarget(property.name, checker, assignment); + } if (ts.isPropertyAssignment(property)) { - const name = propertyNameText(property.name); - const value = expressionState( + return isPersistentStoreTarget( property.initializer, checker, - states, - programSources, - ); - if (!hasCapability(value)) continue; - if (!name) { - if (result.elements) unionState(result.elements, value); - else result.elements = cloneState(value); - continue; - } - const existing = result.properties.get(name); - if (existing) unionState(existing, value); - else result.properties.set(name, value); - } else if (ts.isShorthandPropertyAssignment(property)) { - // getSymbolAtLocation(name) denotes the object-literal property. The - // shorthand value symbol is the outer binding that actually carries - // ownership into the new container. - const value = cloneState( - stateFor( - states, - canonicalSymbol( - checker, - checker.getShorthandAssignmentValueSymbol(property), - ), - ), + assignment, ); - if (!hasCapability(value)) continue; - const existing = result.properties.get(property.name.text); - if (existing) unionState(existing, value); - else result.properties.set(property.name.text, value); - } else if (ts.isSpreadAssignment(property)) { - const spread = expressionState( + } + if (ts.isSpreadAssignment(property)) { + return isPersistentStoreTarget( property.expression, checker, - states, - programSources, + assignment, ); - for (const [name, value] of spread.properties) { - const existing = result.properties.get(name); - if (existing) unionState(existing, value); - else result.properties.set(name, cloneState(value)); - } - for (const [name, value] of spread.hiddenProperties) { - const existing = result.properties.get(name); - if (existing) unionState(existing, value); - else result.properties.set(name, cloneState(value)); - } - if (spread.elements) { - if (result.elements) { - unionState(result.elements, spread.elements); - } else { - result.elements = cloneState(spread.elements); - } - } - } else if ( - ts.isMethodDeclaration(property) - || ts.isGetAccessorDeclaration(property) - ) { - const name = propertyNameText(property.name); - if (!name) continue; - const value = cloneState(stateFor(states, property)); - if (!hasCapability(value)) continue; - const existing = result.properties.get(name); - if (existing) unionState(existing, value); - else result.properties.set(name, value); } - } - return result; + return false; + }); } if (ts.isArrayLiteralExpression(node)) { - const result = cloneState(directState); - for (const element of node.elements) { - let value: ValueState; - if (ts.isSpreadElement(element)) { - value = elementState( - expressionState(element.expression, checker, states, programSources), - ); - } else if (ts.isOmittedExpression(element)) { - continue; - } else { - value = expressionState(element, checker, states, programSources); - } - if (!hasCapability(value)) continue; - if (result.elements) unionState(result.elements, value); - else result.elements = cloneState(value); - } - return result; - } - if ( - (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) - ) { - const receiver = expressionState( - node.expression, - checker, - states, - programSources, + return node.elements.some( + (element) => + !ts.isOmittedExpression(element) && + isPersistentStoreTarget( + ts.isSpreadElement(element) ? element.expression : element, + checker, + assignment, + ), ); - const property = accessedPropertyName(node); - const numericIndex = ts.isElementAccessExpression(node) - && node.argumentExpression - && ts.isNumericLiteral(node.argumentExpression); - const selected = numericIndex - ? unionMany([ - receiver.elements ?? EMPTY_STATE, - property === null ? EMPTY_STATE : propertyState(receiver, property), - ]) - : property === null - ? elementState(receiver) - : propertyState(receiver, property); - const result = cloneState(directState); - unionState(result, selected); - return result; } - if (ts.isNewExpression(node)) { - const constructor = expressionState( - node.expression, - checker, - states, - programSources, - ); - if (constructor.viewConstructors !== 0) { - const source = node.arguments?.[0] - ? expressionState(node.arguments[0], checker, states, programSources) - : EMPTY_STATE; - const result = cloneState(directState); - // A TypedArray constructed from another TypedArray copies. A DataView - // or TypedArray constructed from an ArrayBufferLike aliases it. - result.view |= source.buffer; - result.memory = 0; - result.buffer = 0; - result.instance = 0; - result.exportNamespace = 0; - result.properties.clear(); - result.elements = null; - return result; - } - return hydrateTypeProperties(directState, node, checker, states); + if (!ts.isIdentifier(node)) return false; + const symbol = symbolAtExpression(checker, node); + const assignmentFunction = returnFunction(assignment); + return Boolean( + symbol?.declarations?.some( + (declaration) => returnFunction(declaration) !== assignmentFunction, + ), + ); +} + +function stateFor( + states: Map, + key: StateKey | undefined, +): ValueState { + return key ? (states.get(key) ?? EMPTY_STATE) : EMPTY_STATE; +} + +function mergeIntoKey( + states: Map, + key: StateKey, + state: ValueState, + targetProjection: readonly StateProjection[] = [], +): boolean { + let target = states.get(key); + if (!target) { + target = emptyState(); + states.set(key, target); } - if (ts.isCallExpression(node)) { - if (isIntrinsicObjectFreezeCall(node, checker)) { - // Object.freeze returns the same object and does not hand it to user - // code. Preserve every nested capability so freezing a private export - // snapshot cannot erase the raw callable before its audited invocation. - return unionMany([ - directState, - expressionState( - node.arguments[0], - checker, - states, - programSources, - ), - ]); - } - if (propertyIs(node.expression, "subarray")) { - const receiver = unwrapExpression(node.expression); - if ( - ts.isPropertyAccessExpression(receiver) - || ts.isElementAccessExpression(receiver) - ) { - const source = expressionState( - receiver.expression, - checker, - states, - programSources, - ); - const result = cloneState(directState); - result.view |= source.view; - result.memory = 0; - result.buffer = 0; - return result; - } - } - if (isIntrinsicBufferFrom(node, checker)) { - const source = node.arguments[0] - ? expressionState(node.arguments[0], checker, states, programSources) - : EMPTY_STATE; - const result = cloneState(directState); - result.view |= source.buffer; - result.memory = 0; - result.buffer = 0; - result.instance = 0; - result.exportNamespace = 0; - return result; - } - const typedArrayMethod = intrinsicTypedArrayMethod(node, checker); - if ( - typedArrayMethod - && TYPED_ARRAY_RETAINING_ITERATOR_METHODS.has(typedArrayMethod) - ) { - const receiver = callReceiver(node); - const result = cloneState(directState); - if (receiver) { - const receiverState = expressionState( - receiver, - checker, - states, - programSources, - ); - // Model the iterator as a retained view capability. It is not itself a - // TypedArray, but keeping the stronger state makes return/store/unknown - // calls fail closed instead of losing the backing view at `.values()`. - result.view |= receiverState.view; - } - return result; - } - const arrayMethod = intrinsicArrayMethod(node, checker); - if ( - arrayMethod - && ( - ARRAY_ELEMENT_RETURNING_METHODS.has(arrayMethod) - || arrayMethod === "filter" - || arrayMethod === "map" - ) - ) { - const receiver = callReceiver(node); - const result = cloneState(directState); - if (receiver) { - const receiverElement = elementState( - expressionState(receiver, checker, states, programSources), - ); - if (ARRAY_ELEMENT_RETURNING_METHODS.has(arrayMethod)) { - unionState(result, receiverElement); - } else if (arrayMethod === "filter") { - if (hasCapability(receiverElement)) { - result.elements = receiverElement; - } - } else if (node.arguments[0]) { - const mappedElement = expressionState( - node.arguments[0], - checker, - states, - programSources, - ); - if (hasCapability(mappedElement)) { - result.elements = mappedElement; - } - } - } - return result; - } - const signature = checker.getResolvedSignature(node); - const declaration = signature?.declaration; - const result = cloneState(directState); - if (directState.scratchRegionFactory) { - // The factory function itself is an audited authority; its return value - // is the nominal provenance witness required before withLease can mint a - // live address capability. - result.scratchRegionFactory = false; - result.scratchRegion = true; - } - if (isJavaScriptKernelMemoryAccessorCall(node)) { - result.memory |= KERNEL_OWNER; - } - if (isJavaScriptKernelInstanceAccessorCall(node)) { - result.instance |= KERNEL_OWNER; - } - if (isCapturedKernelInstanceExportsCall(node) && node.arguments[1]) { - result.exportNamespace |= expressionState( - node.arguments[1], - checker, - states, - programSources, - ).instance; - } - if (propertyIs(node.expression, "slice")) { - const receiver = callReceiver(node); - const owner = signatureOwnerName(declaration); - const provenDetachedTypedArraySlice = Boolean( - declaration - && declaration.getSourceFile().isDeclarationFile - && owner - && TYPED_ARRAY_CONSTRUCTORS.has(owner), - ); - if (receiver && !provenDetachedTypedArraySlice) { - // WHY: Uint8Array#slice copies, but Buffer#slice and arbitrary custom - // methods may alias. Method spelling alone cannot prove detachment. - result.view |= expressionState( - receiver, - checker, - states, - programSources, - ).view; - } - } - if ( - declaration - && isInProgram(programSources, declaration) - && hasBody(declaration) - ) { - unionState(result, stateFor(states, declaration)); - } - const returnedKernelExportFunctions = new Set( - result.kernelExportFunctions, - ); - // Higher-order callbacks retain the return capability in the parameter's - // state. Calling such a parameter yields that capability. - unionState( - result, - expressionState( - node.expression, - checker, - states, - programSources, - ), - ); - if (callPropertyName(node) !== "bind") { - // Calling a raw export returns a scalar; the callable capability itself - // does not flow into that scalar. An analyzed identity/helper return is - // already represented by the declaration state captured above. - result.kernelExportFunctions = returnedKernelExportFunctions; + for (const projection of targetProjection) { + if (projection.kind === "element") { + if (!target.elements) target.elements = emptyState(); + target = target.elements; + continue; } - if (result.scratchRegionFactory) { - result.scratchRegionFactory = false; - result.scratchRegion = true; + let property = target.properties.get(projection.name); + if (!property) { + property = emptyState(); + target.properties.set(projection.name, property); } - return result; + target = property; } - return ts.isIdentifier(node) || node.kind === ts.SyntaxKind.ThisKeyword - ? hydrateTypeProperties(directState, node, checker, states) - : directState; + return unionState(target, state); } -function assignmentWritesKernelView( +function hydrateTypeProperties( + state: ValueState, expression: ts.Expression, checker: ts.TypeChecker, states: Map, - programSources: ReadonlySet, -): boolean { - const node = unwrapExpression(expression); - if (ts.isElementAccessExpression(node)) { - return isKernelView( - expressionState(node.expression, checker, states, programSources), - ); - } - if (ts.isArrayLiteralExpression(node)) { - return node.elements.some((element) => - !ts.isOmittedExpression(element) - && assignmentWritesKernelView( - ts.isSpreadElement(element) ? element.expression : element, - checker, - states, - programSources, - ) - ); +): ValueState { + const result = cloneState(state); + const type = checker.getTypeAtLocation(expression); + let properties = TYPE_PROPERTIES.get(type); + if (!properties) { + properties = checker.getPropertiesOfType(type); + TYPE_PROPERTIES.set(type, properties); } - if (ts.isObjectLiteralExpression(node)) { - return node.properties.some((property) => { - if (ts.isPropertyAssignment(property)) { - return assignmentWritesKernelView( - property.initializer, - checker, - states, - programSources, - ); - } - if (ts.isSpreadAssignment(property)) { - return assignmentWritesKernelView( - property.expression, - checker, - states, - programSources, - ); - } - return false; + for (const property of properties) { + const hardPrivate = property.declarations?.some((declaration) => { + const name = (declaration as ts.NamedDeclaration).name; + return Boolean(name && ts.isPrivateIdentifier(name)); }); + if (hardPrivate) continue; + const hidden = Boolean( + property.declarations?.some( + (declaration) => + ts.canHaveModifiers(declaration) && + ts + .getModifiers(declaration) + ?.some( + (modifier) => + modifier.kind === ts.SyntaxKind.PrivateKeyword || + modifier.kind === ts.SyntaxKind.ProtectedKeyword, + ), + ), + ); + const propertyValue = cloneState( + stateFor(states, canonicalSymbol(checker, property)), + ); + if (!hasCapability(propertyValue)) continue; + // WHY: private/protected TypeScript slots must remain selectable through + // explicit diagnostic casts, but must not make the whole owning wrapper a + // raw-memory escape. Object spread promotes these ordinary runtime fields. + const target = hidden ? result.hiddenProperties : result.properties; + const existing = target.get(property.name); + if (existing) unionState(existing, propertyValue); + else target.set(property.name, propertyValue); } - // A default inside an assignment pattern is itself a nested assignment and - // is visited independently, avoiding duplicate findings for one write. - return false; + return result; } -function findingFor( - rootDir: string, - sourceFile: ts.SourceFile, - node: ts.Node, - kind: AuditFinding["kind"], -): AuditFinding { - const file = relativeFile(rootDir, sourceFile); - const enclosing = callableName(node); - const text = normalizeText(node, sourceFile); - const key = `${file}::${enclosing}::${kind}::${text}`; - const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1; - return { key, file, enclosing, kind, line, text }; +function propertyIs(expression: ts.Expression, expected: string): boolean { + const unwrapped = unwrapExpression(expression); + return ( + (ts.isPropertyAccessExpression(unwrapped) || + ts.isElementAccessExpression(unwrapped)) && + accessedPropertyName(unwrapped) === expected + ); } -type KernelOwnershipForm = "memory" | "buffer" | "view"; - -function hasKernelOwnership( - state: ValueState, - form: KernelOwnershipForm, +function isJavaScriptKernelMemoryAccessorCall( + node: ts.CallExpression, ): boolean { - if ((state[form] & KERNEL_OWNER) !== 0) return true; - for (const property of state.properties.values()) { - if (hasKernelOwnership(property, form)) return true; + const sourceFile = node.getSourceFile(); + if (!/\.(?:c|m)?jsx?$/.test(sourceFile.fileName)) { + return false; } - return state.elements - ? hasKernelOwnership(state.elements, form) - : false; -} - -function isKernelView(state: ValueState): boolean { - return (state.view & KERNEL_OWNER) !== 0; + // WHY: JavaScript's untyped parameters can erase the receiver type before + // the checker reaches `kernel.getMemory()`. This exact zero-argument method + // is Kandelo's documented raw kernel-memory escape hatch, so seed its result + // syntactically and let the ordinary ownership analysis and exact allowlist + // handle aliases, helper parameters, views, and writes. This is deliberately + // not general JavaScript taint analysis. + return ( + node.arguments.length === 0 && propertyIs(node.expression, "getMemory") + ); } -function isKernelBuffer(state: ValueState): boolean { - return (state.buffer & KERNEL_OWNER) !== 0; +function isJavaScriptKernelInstanceAccessorCall( + node: ts.CallExpression, +): boolean { + const sourceFile = node.getSourceFile(); + if (!/\.(?:c|m)?jsx?$/.test(sourceFile.fileName)) { + return false; + } + // See getMemory above. JavaScript erases the receiver type, but this exact + // trusted-embedder escape exposes the same kernel memory through + // `getInstance().exports.memory` and must remain visible to the audit. + return ( + node.arguments.length === 0 && propertyIs(node.expression, "getInstance") + ); } -function isKernelMemory(state: ValueState): boolean { - return (state.memory & KERNEL_OWNER) !== 0; +type CapturedOwnershipGetter = "instance-exports" | "memory-buffer"; + +type CapturedIntrinsicOperation = + | "array-buffer-byte-length" + | "atomics-notify" + | "atomics-wait" + | "data-view-get-uint16" + | "data-view-get-uint32" + | "shared-array-buffer-byte-length" + | "uint8-array-set" + | "uint8-array-slice"; + +function immutableConstValue( + expression: ts.Expression, + checker: ts.TypeChecker, + seen = new Set(), +): ts.Expression { + const node = unwrapExpression(expression); + if (!ts.isIdentifier(node)) return node; + const symbol = canonicalSymbol(checker, checker.getSymbolAtLocation(node)); + if (!symbol || seen.has(symbol)) return node; + const declaration = symbol.valueDeclaration; + if ( + !declaration || + !ts.isVariableDeclaration(declaration) || + !declaration.initializer || + !ts.isVariableDeclarationList(declaration.parent) || + (declaration.parent.flags & ts.NodeFlags.Const) === 0 + ) { + return node; + } + const nextSeen = new Set(seen); + nextSeen.add(symbol); + return immutableConstValue(declaration.initializer, checker, nextSeen); } -function hasPointerBearingKernelExport( - state: ValueState, - pointerBearingKernelExports: ReadonlySet, - seen = new Set(), +function isCapturedIntrinsicApply( + expression: ts.Expression, + checker: ts.TypeChecker, ): boolean { - if (seen.has(state)) return false; - seen.add(state); - if (state.kernelExportFunctions.has(UNKNOWN_KERNEL_EXPORT)) return true; - for (const name of state.kernelExportFunctions) { - if (pointerBearingKernelExports.has(name)) return true; + const node = immutableConstValue(expression, checker); + if (!ts.isPropertyAccessExpression(node) || node.name.text !== "apply") { + return false; } - for (const property of state.properties.values()) { - if ( - hasPointerBearingKernelExport( - property, - pointerBearingKernelExports, - seen, - ) - ) { - return true; - } + const receiver = unwrapExpression(node.expression); + const symbol = symbolAtExpression(checker, node); + return ( + ts.isIdentifier(receiver) && + receiver.text === "Reflect" && + Boolean(symbol?.declarations?.some(isIntrinsicLibDeclaration)) + ); +} + +function capturedOwnershipGetter( + expression: ts.Expression, + checker: ts.TypeChecker, +): CapturedOwnershipGetter | null { + const getter = immutableConstValue(expression, checker); + if (!ts.isPropertyAccessExpression(getter) || getter.name.text !== "get") { + return null; } - for (const property of state.hiddenProperties.values()) { - if ( - hasPointerBearingKernelExport( - property, - pointerBearingKernelExports, - seen, - ) - ) { - return true; - } + const descriptor = unwrapExpression(getter.expression); + if (!ts.isCallExpression(descriptor) || descriptor.arguments.length !== 2) { + return null; } - return state.elements - ? hasPointerBearingKernelExport( - state.elements, - pointerBearingKernelExports, - seen, + const descriptorDeclaration = + checker.getResolvedSignature(descriptor)?.declaration; + if ( + !descriptorDeclaration || + !isIntrinsicLibDeclaration(descriptorDeclaration) || + signatureOwnerName(descriptorDeclaration) !== "ObjectConstructor" || + callPropertyName(descriptor) !== "getOwnPropertyDescriptor" + ) { + return null; + } + const prototype = unwrapExpression(descriptor.arguments[0]); + const property = unwrapExpression(descriptor.arguments[1]); + if ( + !ts.isPropertyAccessExpression(prototype) || + prototype.name.text !== "prototype" || + !ts.isStringLiteralLike(property) + ) { + return null; + } + const constructor = unwrapExpression(prototype.expression); + if ( + !ts.isPropertyAccessExpression(constructor) || + !ts.isIdentifier(unwrapExpression(constructor.expression)) || + unwrapExpression(constructor.expression).text !== "WebAssembly" || + !symbolAtExpression(checker, constructor)?.declarations?.some( + isIntrinsicLibDeclaration, ) - : false; + ) { + return null; + } + if (constructor.name.text === "Memory" && property.text === "buffer") { + return "memory-buffer"; + } + if (constructor.name.text === "Instance" && property.text === "exports") { + return "instance-exports"; + } + return null; } -function isViewConstructor( - node: ts.Node, +function capturedByteLengthGetter( + expression: ts.Expression, checker: ts.TypeChecker, - states: Map, - programSources: ReadonlySet, -): boolean { - if (ts.isNewExpression(node)) { - return expressionState( - node.expression, - checker, - states, - programSources, - ).viewConstructors !== 0; +): CapturedIntrinsicOperation | null { + const getter = immutableConstValue(expression, checker); + if (!ts.isPropertyAccessExpression(getter) || getter.name.text !== "get") { + return null; + } + const descriptor = unwrapExpression(getter.expression); + if (!ts.isCallExpression(descriptor) || descriptor.arguments.length !== 2) { + return null; + } + const descriptorDeclaration = + checker.getResolvedSignature(descriptor)?.declaration; + if ( + !descriptorDeclaration || + !isIntrinsicLibDeclaration(descriptorDeclaration) || + signatureOwnerName(descriptorDeclaration) !== "ObjectConstructor" || + callPropertyName(descriptor) !== "getOwnPropertyDescriptor" + ) { + return null; + } + const prototype = unwrapExpression(descriptor.arguments[0]); + const property = unwrapExpression(descriptor.arguments[1]); + if ( + !ts.isPropertyAccessExpression(prototype) || + prototype.name.text !== "prototype" || + !ts.isStringLiteralLike(property) || + property.text !== "byteLength" + ) { + return null; + } + const constructor = unwrapExpression(prototype.expression); + if ( + !ts.isIdentifier(constructor) || + !symbolAtExpression(checker, constructor)?.declarations?.some( + isIntrinsicLibDeclaration, + ) + ) { + return null; + } + if (constructor.text === "ArrayBuffer") { + return "array-buffer-byte-length"; + } + return constructor.text === "SharedArrayBuffer" + ? "shared-array-buffer-byte-length" + : null; +} + +function capturedPrototypeOperation( + expression: ts.Expression, + checker: ts.TypeChecker, +): CapturedIntrinsicOperation | null { + const operation = immutableConstValue(expression, checker); + if (!ts.isPropertyAccessExpression(operation)) return null; + const operationSymbol = symbolAtExpression(checker, operation); + if (!operationSymbol?.declarations?.some(isIntrinsicLibDeclaration)) { + return null; + } + const receiver = unwrapExpression(operation.expression); + if (ts.isIdentifier(receiver) && receiver.text === "Atomics") { + if (operation.name.text === "wait") return "atomics-wait"; + if (operation.name.text === "notify") return "atomics-notify"; + return null; + } + if ( + !ts.isPropertyAccessExpression(receiver) || + receiver.name.text !== "prototype" + ) { + return null; + } + const constructor = unwrapExpression(receiver.expression); + if ( + !ts.isIdentifier(constructor) || + !symbolAtExpression(checker, constructor)?.declarations?.some( + isIntrinsicLibDeclaration, + ) + ) { + return null; + } + if (constructor.text === "DataView") { + if (operation.name.text === "getUint16") { + return "data-view-get-uint16"; + } + return operation.name.text === "getUint32" ? "data-view-get-uint32" : null; + } + if (constructor.text !== "Uint8Array") return null; + if (operation.name.text === "set") return "uint8-array-set"; + return operation.name.text === "slice" ? "uint8-array-slice" : null; +} + +function isPlainIntrinsicArgumentList(expression: ts.Expression): boolean { + const node = unwrapExpression(expression); + if (ts.isConditionalExpression(node)) { + return ( + isPlainIntrinsicArgumentList(node.whenTrue) && + isPlainIntrinsicArgumentList(node.whenFalse) + ); } return ( - ts.isCallExpression(node) - && isIntrinsicBufferFrom(node, checker) + ts.isArrayLiteralExpression(node) && + node.elements.every( + (element) => + !ts.isOmittedExpression(element) && !ts.isSpreadElement(element), + ) ); } -function callPropertyName(call: ts.CallExpression): string | null { - const callee = unwrapExpression(call.expression); +function capturedIntrinsicOperationCall( + node: ts.CallExpression, + checker: ts.TypeChecker, +): CapturedIntrinsicOperation | null { + if ( + node.arguments.length !== 3 || + !isCapturedIntrinsicApply(node.expression, checker) || + !isPlainIntrinsicArgumentList(node.arguments[2]) + ) { + return null; + } return ( - ts.isPropertyAccessExpression(callee) - || ts.isElementAccessExpression(callee) - ) - ? accessedPropertyName(callee) - : null; + capturedByteLengthGetter(node.arguments[0], checker) ?? + capturedPrototypeOperation(node.arguments[0], checker) + ); } -function callReceiver(call: ts.CallExpression): ts.Expression | null { - const callee = unwrapExpression(call.expression); - return ( - ts.isPropertyAccessExpression(callee) - || ts.isElementAccessExpression(callee) - ) - ? callee.expression - : null; +function capturedOwnershipGetterCall( + node: ts.CallExpression, + checker: ts.TypeChecker, +): CapturedOwnershipGetter | null { + if ( + node.arguments.length !== 3 || + !isCapturedIntrinsicApply(node.expression, checker) + ) { + return null; + } + const argumentList = unwrapExpression(node.arguments[2]); + if ( + !ts.isArrayLiteralExpression(argumentList) || + argumentList.elements.length !== 0 + ) { + return null; + } + return capturedOwnershipGetter(node.arguments[0], checker); +} + +function transparentCapturedOwnershipGetterWrapper( + declaration: ts.Declaration | undefined, + checker: ts.TypeChecker, +): { + readonly argumentIndex: number; + readonly getter: CapturedOwnershipGetter; +} | null { + if (!declaration || !hasBody(declaration)) return null; + let returned: ts.Expression | null = null; + if (ts.isBlock(declaration.body)) { + if ( + declaration.body.statements.length !== 1 || + !ts.isReturnStatement(declaration.body.statements[0]) || + !declaration.body.statements[0].expression + ) { + return null; + } + returned = declaration.body.statements[0].expression; + } else { + returned = declaration.body; + } + const call = unwrapExpression(returned); + if (!ts.isCallExpression(call)) return null; + const getter = capturedOwnershipGetterCall(call, checker); + if (getter === null) return null; + const receiver = unwrapExpression(call.arguments[1]); + if (!ts.isIdentifier(receiver)) return null; + const receiverSymbol = symbolAtExpression(checker, receiver); + const argumentIndex = declaration.parameters.findIndex( + (parameter) => + ts.isIdentifier(parameter.name) && + symbolAtExpression(checker, parameter.name) === receiverSymbol, + ); + return argumentIndex < 0 ? null : { argumentIndex, getter }; } -function signatureOwnerName( - declaration: ts.Node | undefined, -): string | undefined { - for (let current = declaration?.parent; current; current = current.parent) { +function expressionState( + expression: ts.Expression, + checker: ts.TypeChecker, + states: Map, + programSources: ReadonlySet, +): ValueState { + const node = unwrapExpression(expression); + if (ts.isSpreadElement(node)) { + // A spread call/new argument passes the elements, not the container. + // WHY: dropping this projection lets `opaque(...[kernelView])` hide the + // same live view that `opaque(kernelView)` exposes directly. + return elementState( + expressionState(node.expression, checker, states, programSources), + ); + } + const direct = + ts.isIdentifier(node) && + ts.isShorthandPropertyAssignment(node.parent) && + node.parent.name === node + ? canonicalSymbol( + checker, + checker.getShorthandAssignmentValueSymbol(node.parent), + ) + : symbolAtExpression(checker, node); + const directState = cloneState(stateFor(states, direct)); + if (isScratchRegionFactorySymbol(direct)) { + directState.scratchRegionFactory = true; + } + if ( + direct?.declarations?.some((declaration) => { + if (!ts.isBindingElement(declaration)) return false; + const property = + propertyNameText(declaration.propertyName) ?? + (ts.isIdentifier(declaration.name) ? declaration.name.text : null); + return ( + property !== null && + (property === "kernel_spawn_scratch_begin" || + property === "kernel_spawn_scratch_pointer" || + property === "kernel_spawn_scratch_capacity" || + property === "kernel_spawn_scratch_cancel" || + property === "kernel_transfer_scratch_begin" || + property === "kernel_transfer_scratch_pointer" || + property === "kernel_transfer_scratch_capacity" || + property === "kernel_transfer_scratch_cancel") + ); + }) + ) { + // A destructured export is the same allocator authority as a dotted or + // bracketed projection. The finite names keep same-spelled unrelated + // callbacks visible rather than silently treating them as ordinary code. + directState.reserver = true; + } + directState.viewConstructors |= intrinsicViewConstructorBits(node, checker); + if ( + ts.isPropertyAccessExpression(node) || + ts.isElementAccessExpression(node) + ) { + const property = accessedPropertyName(node); + if (property === "kernel_alloc_scratch") { + directState.allocator = true; + } else if ( + property === "kernel_spawn_scratch_begin" || + property === "kernel_spawn_scratch_pointer" || + property === "kernel_spawn_scratch_capacity" || + property === "kernel_spawn_scratch_cancel" || + property === "kernel_transfer_scratch_begin" || + property === "kernel_transfer_scratch_pointer" || + property === "kernel_transfer_scratch_capacity" || + property === "kernel_transfer_scratch_cancel" + ) { + directState.reserver = true; + } + } + + if (ts.isConditionalExpression(node)) { + return unionMany([ + directState, + expressionState(node.whenTrue, checker, states, programSources), + expressionState(node.whenFalse, checker, states, programSources), + ]); + } + if (ts.isBinaryExpression(node)) { + if (node.operatorToken.kind === ts.SyntaxKind.CommaToken) { + // The comma expression evaluates to its right operand. + return unionMany([ + directState, + expressionState(node.right, checker, states, programSources), + ]); + } + if ( + node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken || + node.operatorToken.kind === ts.SyntaxKind.BarBarToken || + node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken + ) { + // A logical expression can return either operand without copying it. + return unionMany([ + directState, + expressionState(node.left, checker, states, programSources), + expressionState(node.right, checker, states, programSources), + ]); + } + } + if (ts.isBinaryExpression(node) && isSimpleAssignment(node)) { + return unionMany([ + directState, + expressionState(node.right, checker, states, programSources), + ]); + } + if (ts.isAwaitExpression(node)) { + return unionMany([ + directState, + expressionState(node.expression, checker, states, programSources), + ]); + } + if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) { + return unionMany([directState, stateFor(states, node)]); + } + if (ts.isObjectLiteralExpression(node)) { + const result = cloneState(directState); + for (const property of node.properties) { + if (ts.isPropertyAssignment(property)) { + const name = propertyNameText(property.name); + const value = expressionState( + property.initializer, + checker, + states, + programSources, + ); + if (!hasCapability(value)) continue; + if (!name) { + if (result.elements) unionState(result.elements, value); + else result.elements = cloneState(value); + continue; + } + const existing = result.properties.get(name); + if (existing) unionState(existing, value); + else result.properties.set(name, value); + } else if (ts.isShorthandPropertyAssignment(property)) { + // getSymbolAtLocation(name) denotes the object-literal property. The + // shorthand value symbol is the outer binding that actually carries + // ownership into the new container. + const value = cloneState( + stateFor( + states, + canonicalSymbol( + checker, + checker.getShorthandAssignmentValueSymbol(property), + ), + ), + ); + if (!hasCapability(value)) continue; + const existing = result.properties.get(property.name.text); + if (existing) unionState(existing, value); + else result.properties.set(property.name.text, value); + } else if (ts.isSpreadAssignment(property)) { + const spread = expressionState( + property.expression, + checker, + states, + programSources, + ); + for (const [name, value] of spread.properties) { + const existing = result.properties.get(name); + if (existing) unionState(existing, value); + else result.properties.set(name, cloneState(value)); + } + for (const [name, value] of spread.hiddenProperties) { + const existing = result.properties.get(name); + if (existing) unionState(existing, value); + else result.properties.set(name, cloneState(value)); + } + if (spread.elements) { + if (result.elements) { + unionState(result.elements, spread.elements); + } else { + result.elements = cloneState(spread.elements); + } + } + } else if ( + ts.isMethodDeclaration(property) || + ts.isGetAccessorDeclaration(property) + ) { + const name = propertyNameText(property.name); + if (!name) continue; + const value = cloneState(stateFor(states, property)); + if (!hasCapability(value)) continue; + const existing = result.properties.get(name); + if (existing) unionState(existing, value); + else result.properties.set(name, value); + } + } + return result; + } + if (ts.isArrayLiteralExpression(node)) { + const result = cloneState(directState); + for (const element of node.elements) { + let value: ValueState; + if (ts.isSpreadElement(element)) { + value = elementState( + expressionState(element.expression, checker, states, programSources), + ); + } else if (ts.isOmittedExpression(element)) { + continue; + } else { + value = expressionState(element, checker, states, programSources); + } + if (!hasCapability(value)) continue; + if (result.elements) unionState(result.elements, value); + else result.elements = cloneState(value); + } + return result; + } + if ( + ts.isPropertyAccessExpression(node) || + ts.isElementAccessExpression(node) + ) { + const receiver = expressionState( + node.expression, + checker, + states, + programSources, + ); + const property = accessedPropertyName(node); + const numericIndex = + ts.isElementAccessExpression(node) && + node.argumentExpression && + ts.isNumericLiteral(node.argumentExpression); + const selected = numericIndex + ? unionMany([ + receiver.elements ?? EMPTY_STATE, + property === null ? EMPTY_STATE : propertyState(receiver, property), + ]) + : property === null + ? elementState(receiver) + : propertyState(receiver, property); + const result = cloneState(directState); + unionState(result, selected); + return result; + } + if (ts.isNewExpression(node)) { + const constructor = expressionState( + node.expression, + checker, + states, + programSources, + ); + if (constructor.viewConstructors !== 0) { + const source = node.arguments?.[0] + ? expressionState(node.arguments[0], checker, states, programSources) + : EMPTY_STATE; + const result = cloneState(directState); + // A TypedArray constructed from another TypedArray copies. A DataView + // or TypedArray constructed from an ArrayBufferLike aliases it. + result.view |= source.buffer; + result.memory = 0; + result.buffer = 0; + result.instance = 0; + result.exportNamespace = 0; + result.viewConstructors = 0; + result.properties.clear(); + result.elements = null; + return result; + } + const result = hydrateTypeProperties(directState, node, checker, states); + result.viewConstructors = 0; + return result; + } + if (ts.isCallExpression(node)) { + const capturedGetter = capturedOwnershipGetterCall(node, checker); + if (capturedGetter !== null) { + const receiver = expressionState( + node.arguments[1], + checker, + states, + programSources, + ); + const result = cloneState(directState); + if (capturedGetter === "memory-buffer") { + result.buffer |= receiver.memory; + } else { + result.exportNamespace |= receiver.instance; + } + return result; + } + if (capturedIntrinsicOperationCall(node, checker) !== null) { + // Exact captured reads return only scalars, and Uint8Array#slice returns + // a detached copy. Uint8Array#set is inventoried as a write at the call + // site below; none of these operations returns the live receiver. + return directState; + } + if (isIntrinsicObjectFreezeCall(node, checker)) { + // Object.freeze returns the same object and does not hand it to user + // code. Preserve every nested capability so freezing a private export + // snapshot cannot erase the raw callable before its audited invocation. + return unionMany([ + directState, + expressionState(node.arguments[0], checker, states, programSources), + ]); + } + if (propertyIs(node.expression, "subarray")) { + const receiver = unwrapExpression(node.expression); + if ( + ts.isPropertyAccessExpression(receiver) || + ts.isElementAccessExpression(receiver) + ) { + const source = expressionState( + receiver.expression, + checker, + states, + programSources, + ); + const result = cloneState(directState); + result.view |= source.view; + result.memory = 0; + result.buffer = 0; + return result; + } + } + if (isIntrinsicBufferFrom(node, checker)) { + const source = node.arguments[0] + ? expressionState(node.arguments[0], checker, states, programSources) + : EMPTY_STATE; + const result = cloneState(directState); + result.view |= source.buffer; + result.memory = 0; + result.buffer = 0; + result.instance = 0; + result.exportNamespace = 0; + return result; + } + const typedArrayMethod = intrinsicTypedArrayMethod(node, checker); + if ( + typedArrayMethod && + TYPED_ARRAY_RETAINING_ITERATOR_METHODS.has(typedArrayMethod) + ) { + const receiver = callReceiver(node); + const result = cloneState(directState); + if (receiver) { + const receiverState = expressionState( + receiver, + checker, + states, + programSources, + ); + // Model the iterator as a retained view capability. It is not itself a + // TypedArray, but keeping the stronger state makes return/store/unknown + // calls fail closed instead of losing the backing view at `.values()`. + result.view |= receiverState.view; + } + return result; + } + const arrayMethod = intrinsicArrayMethod(node, checker); + if ( + arrayMethod && + (ARRAY_ELEMENT_RETURNING_METHODS.has(arrayMethod) || + arrayMethod === "filter" || + arrayMethod === "map") + ) { + const receiver = callReceiver(node); + const result = cloneState(directState); + if (receiver) { + const receiverElement = elementState( + expressionState(receiver, checker, states, programSources), + ); + if (ARRAY_ELEMENT_RETURNING_METHODS.has(arrayMethod)) { + unionState(result, receiverElement); + } else if (arrayMethod === "filter") { + if (hasCapability(receiverElement)) { + result.elements = receiverElement; + } + } else if (node.arguments[0]) { + const mappedElement = expressionState( + node.arguments[0], + checker, + states, + programSources, + ); + if (hasCapability(mappedElement)) { + result.elements = mappedElement; + } + } + } + return result; + } + const signature = checker.getResolvedSignature(node); + const declaration = signature?.declaration; + const result = cloneState(directState); + result.viewConstructors = 0; + const transparentGetter = transparentCapturedOwnershipGetterWrapper( + declaration, + checker, + ); + if ( + transparentGetter !== null && + node.arguments[transparentGetter.argumentIndex] + ) { + const receiver = expressionState( + node.arguments[transparentGetter.argumentIndex], + checker, + states, + programSources, + ); + if (transparentGetter.getter === "memory-buffer") { + result.buffer |= receiver.memory; + } else { + result.exportNamespace |= receiver.instance; + } + return result; + } + if (directState.scratchRegionFactory) { + // The factory function itself is an audited authority; its return value + // is the nominal provenance witness required before withLease can mint a + // live address capability. + result.scratchRegionFactory = false; + result.scratchRegion = true; + } + if (isJavaScriptKernelMemoryAccessorCall(node)) { + result.memory |= KERNEL_OWNER; + } + if (isJavaScriptKernelInstanceAccessorCall(node)) { + result.instance |= KERNEL_OWNER; + } + if (propertyIs(node.expression, "slice")) { + const receiver = callReceiver(node); + const owner = signatureOwnerName(declaration); + const provenDetachedTypedArraySlice = Boolean( + declaration && + declaration.getSourceFile().isDeclarationFile && + owner && + TYPED_ARRAY_CONSTRUCTORS.has(owner), + ); + if (receiver && !provenDetachedTypedArraySlice) { + // WHY: Uint8Array#slice copies, but Buffer#slice and arbitrary custom + // methods may alias. Method spelling alone cannot prove detachment. + result.view |= expressionState( + receiver, + checker, + states, + programSources, + ).view; + } + } + if ( + declaration && + isInProgram(programSources, declaration) && + hasBody(declaration) + ) { + unionState(result, stateFor(states, declaration)); + } + const returnedKernelExportFunctions = new Set(result.kernelExportFunctions); + // Higher-order callbacks retain the return capability in the parameter's + // state. Calling such a parameter yields that capability. + unionState( + result, + expressionState(node.expression, checker, states, programSources), + ); + if (callPropertyName(node) !== "bind") { + // Calling a raw export returns a scalar; the callable capability itself + // does not flow into that scalar. An analyzed identity/helper return is + // already represented by the declaration state captured above. + result.kernelExportFunctions = returnedKernelExportFunctions; + } + if (result.scratchRegionFactory) { + result.scratchRegionFactory = false; + result.scratchRegion = true; + } + return result; + } + return ts.isIdentifier(node) || node.kind === ts.SyntaxKind.ThisKeyword + ? hydrateTypeProperties(directState, node, checker, states) + : directState; +} + +function assignmentWritesKernelView( + expression: ts.Expression, + checker: ts.TypeChecker, + states: Map, + programSources: ReadonlySet, +): boolean { + const node = unwrapExpression(expression); + if (ts.isElementAccessExpression(node)) { + return isKernelView( + expressionState(node.expression, checker, states, programSources), + ); + } + if (ts.isArrayLiteralExpression(node)) { + return node.elements.some( + (element) => + !ts.isOmittedExpression(element) && + assignmentWritesKernelView( + ts.isSpreadElement(element) ? element.expression : element, + checker, + states, + programSources, + ), + ); + } + if (ts.isObjectLiteralExpression(node)) { + return node.properties.some((property) => { + if (ts.isPropertyAssignment(property)) { + return assignmentWritesKernelView( + property.initializer, + checker, + states, + programSources, + ); + } + if (ts.isSpreadAssignment(property)) { + return assignmentWritesKernelView( + property.expression, + checker, + states, + programSources, + ); + } + return false; + }); + } + // A default inside an assignment pattern is itself a nested assignment and + // is visited independently, avoiding duplicate findings for one write. + return false; +} + +function findingFor( + rootDir: string, + sourceFile: ts.SourceFile, + node: ts.Node, + kind: AuditFinding["kind"], +): AuditFinding { + const file = relativeFile(rootDir, sourceFile); + const enclosing = callableName(node); + const text = normalizeText(node, sourceFile); + const key = `${file}::${enclosing}::${kind}::${text}`; + const line = + sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1; + return { key, file, enclosing, kind, line, text }; +} + +type KernelOwnershipForm = "memory" | "buffer" | "view"; + +function hasKernelOwnership( + state: ValueState, + form: KernelOwnershipForm, +): boolean { + if ((state[form] & KERNEL_OWNER) !== 0) return true; + for (const property of state.properties.values()) { + if (hasKernelOwnership(property, form)) return true; + } + return state.elements ? hasKernelOwnership(state.elements, form) : false; +} + +function isKernelView(state: ValueState): boolean { + return (state.view & KERNEL_OWNER) !== 0; +} + +function isKernelBuffer(state: ValueState): boolean { + return (state.buffer & KERNEL_OWNER) !== 0; +} + +function isKernelMemory(state: ValueState): boolean { + return (state.memory & KERNEL_OWNER) !== 0; +} + +function hasAuditedKernelExport( + state: ValueState, + auditedKernelExports: ReadonlySet, + seen = new Set(), +): boolean { + if (seen.has(state)) return false; + seen.add(state); + if (state.kernelExportFunctions.has(UNKNOWN_KERNEL_EXPORT)) return true; + for (const name of state.kernelExportFunctions) { + if (auditedKernelExports.has(name)) return true; + } + for (const property of state.properties.values()) { + if (hasAuditedKernelExport(property, auditedKernelExports, seen)) { + return true; + } + } + for (const property of state.hiddenProperties.values()) { + if (hasAuditedKernelExport(property, auditedKernelExports, seen)) { + return true; + } + } + return state.elements + ? hasAuditedKernelExport(state.elements, auditedKernelExports, seen) + : false; +} + +function isViewConstructor( + node: ts.Node, + checker: ts.TypeChecker, + states: Map, + programSources: ReadonlySet, +): boolean { + if (ts.isNewExpression(node)) { + return ( + expressionState(node.expression, checker, states, programSources) + .viewConstructors !== 0 + ); + } + return ts.isCallExpression(node) && isIntrinsicBufferFrom(node, checker); +} + +function callPropertyName(call: ts.CallExpression): string | null { + const callee = unwrapExpression(call.expression); + return ts.isPropertyAccessExpression(callee) || + ts.isElementAccessExpression(callee) + ? accessedPropertyName(callee) + : null; +} + +function callReceiver(call: ts.CallExpression): ts.Expression | null { + const callee = unwrapExpression(call.expression); + return ts.isPropertyAccessExpression(callee) || + ts.isElementAccessExpression(callee) + ? callee.expression + : null; +} + +function signatureOwnerName( + declaration: ts.Node | undefined, +): string | undefined { + for (let current = declaration?.parent; current; current = current.parent) { + if ( + (ts.isInterfaceDeclaration(current) || ts.isClassDeclaration(current)) && + current.name + ) { + return current.name.text; + } + } + return undefined; +} + +function isProvenReadOnlyKernelReceiverCall( + call: ts.CallExpression, + receiverState: ValueState, + checker: ts.TypeChecker, +): boolean { + const method = callPropertyName(call); + if (!method) return false; + const declaration = checker.getResolvedSignature(call)?.declaration; + if (!declaration || !isIntrinsicLibDeclaration(declaration)) return false; + const owner = signatureOwnerName(declaration); + + if ( + isKernelView(receiverState) && + owner && + TYPED_ARRAY_CONSTRUCTORS.has(owner) && + TYPED_ARRAY_NON_RETAINING_METHODS.has(method) + ) { + return true; + } + if ( + isKernelView(receiverState) && + owner === "DataView" && + method.startsWith("get") + ) { + return true; + } + if ( + isKernelBuffer(receiverState) && + (owner === "ArrayBuffer" || owner === "SharedArrayBuffer") && + method === "slice" + ) { + return true; + } + return false; +} + +function isKnownReadOnlyKernelViewArgument( + call: ts.CallExpression, + argumentIndex: number, + checker: ts.TypeChecker, +): boolean { + const method = callPropertyName(call); + const signatureDeclaration = checker.getResolvedSignature(call)?.declaration; + const methodOwner = signatureOwnerName(signatureDeclaration); + // WHY: method spelling alone is not a read-only proof. A Map or custom + // object's `set(kernelView)` can retain that live view. Admit only the + // standard typed-array signature whose receiver write consumes arg0 + // synchronously. + if ( + method === "set" && + argumentIndex === 0 && + methodOwner !== undefined && + TYPED_ARRAY_CONSTRUCTORS.has(methodOwner) && + signatureDeclaration !== undefined && + isIntrinsicLibDeclaration(signatureDeclaration) + ) { + return true; + } + // TextDecoder#decode consumes bytes synchronously; a custom `decode` + // method remains an opaque escape. + if ( + method === "decode" && + argumentIndex === 0 && + methodOwner === "TextDecoder" && + signatureDeclaration !== undefined && + isIntrinsicLibDeclaration(signatureDeclaration) + ) { + return true; + } + if ( + argumentIndex === 0 && + ts.isPropertyAccessExpression(call.expression) && + call.expression.expression.getText(call.getSourceFile()) === "Atomics" && + signatureDeclaration !== undefined && + isIntrinsicLibDeclaration(signatureDeclaration) && + hasIntrinsicLibValueDeclaration( + symbolAtExpression(checker, call.expression.expression), + ) && + !ATOMIC_MUTATORS.has(call.expression.name.text) + ) { + return true; + } + return false; +} + +function validateContractEntries( + ownershipSeeds: readonly OwnershipSeed[], + allowances: readonly AuditAllowance[], + kernelDestinationFactoryDeclarations: readonly string[], +): void { + const seedKeys = new Set(); + for (const seed of ownershipSeeds) { + if ( + seed.declaration.includes("*") || + seed.declaration.includes("?") || + seed.declaration.endsWith("::") + ) { + throw new Error(`ownership seed must be exact: ${seed.declaration}`); + } + if (seed.why.trim().length < 12) { + throw new Error(`ownership seed requires a WHY: ${seed.declaration}`); + } + const key = `${seed.declaration}::${seed.target}::${seed.owner}::${seed.form}`; + if (seedKeys.has(key)) throw new Error(`duplicate ownership seed: ${key}`); + seedKeys.add(key); + } + const allowanceKeys = new Set(); + for (const allowance of allowances) { + // Matching below is strict string equality. `?` is ordinary TypeScript + // source text (notably `??` and `?.`) and therefore can be part of an + // exact finding key; only `*` could plausibly advertise a wildcard. + if (allowance.key.includes("*")) { + throw new Error(`audit allowance must be exact: ${allowance.key}`); + } + if (allowance.why.trim().length < 12) { + throw new Error(`audit allowance requires a WHY: ${allowance.key}`); + } + if ( + allowance.count !== undefined && + (!Number.isSafeInteger(allowance.count) || allowance.count <= 0) + ) { + throw new Error( + `audit allowance count must be positive: ${allowance.key}`, + ); + } + if (allowanceKeys.has(allowance.key)) { + throw new Error(`duplicate audit allowance: ${allowance.key}`); + } + const isAuthorityOrigin = + allowance.key.includes("::wasm-memory-authority::") || + allowance.key.includes("::wasm-instance-authority::"); + if (isAuthorityOrigin !== (allowance.authorityOwner !== undefined)) { + throw new Error( + isAuthorityOrigin + ? `authority allowance must classify its owner: ${allowance.key}` + : `authorityOwner is valid only for an authority origin: ${allowance.key}`, + ); + } + allowanceKeys.add(allowance.key); + } + const sinkKeys = new Set(); + for (const declaration of kernelDestinationFactoryDeclarations) { + if ( + declaration.includes("*") || + declaration.includes("?") || + declaration.endsWith("::") + ) { + throw new Error( + `kernel destination factory must be exact: ${declaration}`, + ); + } + if (sinkKeys.has(declaration)) { + throw new Error(`duplicate kernel destination factory: ${declaration}`); + } + sinkKeys.add(declaration); + } +} + +export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { + const allowances = options.allowances ?? []; + const allowanceByKey = new Map(allowances.map((entry) => [entry.key, entry])); + const kernelDestinationFactoryDeclarations = + options.kernelDestinationFactoryDeclarations ?? []; + validateContractEntries( + options.ownershipSeeds, + allowances, + kernelDestinationFactoryDeclarations, + ); + const program = createProgram(options); + const checker = program.getTypeChecker(); + const requestedFiles = new Set( + options.sourceFiles.map((fileName) => path.resolve(fileName)), + ); + const sourceFiles = program + .getSourceFiles() + .filter( + (sourceFile) => + requestedFiles.has(path.resolve(sourceFile.fileName)) && + !sourceFile.isDeclarationFile, + ); + const programSources = new Set(sourceFiles); + const authorityWrites = new Map(); + const authorityIndexedWrites = new Map< + ts.Symbol, + Map + >(); + const addAuthorityWrite = ( + symbol: ts.Symbol | undefined, + expression: ts.Expression, + ): void => { + if (!symbol) return; + const existing = authorityWrites.get(symbol); + if (existing) existing.push(expression); + else authorityWrites.set(symbol, [expression]); + }; + const addAuthorityIndexedWrite = ( + symbol: ts.Symbol | undefined, + property: string | null, + expression: ts.Expression, + ): void => { + if (!symbol || property === null) return; + let properties = authorityIndexedWrites.get(symbol); + if (!properties) { + properties = new Map(); + authorityIndexedWrites.set(symbol, properties); + } + const existing = properties.get(property); + if (existing) existing.push(expression); + else properties.set(property, [expression]); + }; + const addBindingAuthorityWrites = ( + name: ts.BindingName, + initializer: ts.Expression, + ): void => { + if (ts.isIdentifier(name)) { + addAuthorityWrite(symbolAtExpression(checker, name), initializer); + return; + } + for (let index = 0; index < name.elements.length; index++) { + const element = name.elements[index]; + if (ts.isOmittedExpression(element)) continue; + if (element.initializer) { + addBindingAuthorityWrites(element.name, element.initializer); + } + const property = ts.isObjectBindingPattern(name) + ? (propertyNameText(element.propertyName) ?? + propertyNameText(element.name)) + : String(index); + if (property === null) continue; + const projected = immutableAuthorityContainerProjection( + immutableAuthorityProjection(initializer, checker), + property, + ); + if (projected) { + addBindingAuthorityWrites(element.name, projected); + } else if (ts.isIdentifier(element.name)) { + // Keep the namespace/container as a conservative frontier root. The + // semantic classifier below resolves the exact destructured member. + addAuthorityWrite( + symbolAtExpression(checker, element.name), + initializer, + ); + } + } + }; + for (const sourceFile of sourceFiles) { + const indexAuthorityWrites = (node: ts.Node): void => { + if (ts.isVariableDeclaration(node) && node.initializer) { + addBindingAuthorityWrites(node.name, node.initializer); + } else if (ts.isPropertyDeclaration(node) && node.initializer) { + addAuthorityWrite( + symbolForDeclaration(checker, node), + node.initializer, + ); + } else if (ts.isPropertyAssignment(node)) { + addAuthorityWrite( + symbolForDeclaration(checker, node), + node.initializer, + ); + } else if (ts.isShorthandPropertyAssignment(node)) { + addAuthorityWrite(symbolForDeclaration(checker, node), node.name); + } else if ( + ts.isParameter(node) && + node.initializer && + ts.isIdentifier(node.name) + ) { + addAuthorityWrite( + symbolAtExpression(checker, node.name), + node.initializer, + ); + } else if ( + ts.isBinaryExpression(node) && + isAssignmentOperator(node.operatorToken.kind) + ) { + const target = unwrapExpression(node.left); + if (ts.isIdentifier(target)) { + addAuthorityWrite(symbolAtExpression(checker, target), node.right); + } else if ( + ts.isPropertyAccessExpression(target) || + ts.isElementAccessExpression(target) + ) { + addAuthorityWrite(symbolAtExpression(checker, target), node.right); + addAuthorityIndexedWrite( + symbolAtExpression(checker, target.expression), + accessedPropertyName(target), + node.right, + ); + } + } + ts.forEachChild(node, indexAuthorityWrites); + }; + indexAuthorityWrites(sourceFile); + } + + // Keep the authority pass syntax-first. TypeScript can recurse indefinitely + // while resolving ordinary property/flow symbols in large inferred + // JavaScript object graphs. Only expressions rooted in one of these + // capability kinds (or in an exact tracked alias/container) are eligible for + // the deeper semantic checks below. + const AUTHORITY_CONTAINER = 1 << 8; + const DYNAMIC_CODE_CAPABILITY = 1 << 9; + const authorityFrontier = new Map(); + let authorityFrontierNames = new Set(); + const frontierSymbol = (node: ts.Identifier): ts.Symbol | undefined => { + if (!authorityFrontierNames.has(node.text)) return undefined; + return symbolAtExpression(checker, node); + }; + const frontierBits = ( + expression: ts.Expression, + seen = new Set(), + ): number => { + const node = unwrapExpression(expression); + if (ts.isIdentifier(node)) { + if (node.text === "WebAssembly") { + return WASM_AUTHORITY_NAMESPACE; + } + if ( + node.text === "globalThis" || + node.text === "self" || + node.text === "window" + ) { + return WASM_GLOBAL_OBJECT; + } + if (node.text === "eval" || node.text === "Function") { + return DYNAMIC_CODE_CAPABILITY; + } + const symbol = frontierSymbol(node); + return symbol && !seen.has(symbol) + ? (authorityFrontier.get(symbol) ?? 0) + : 0; + } + if (ts.isConditionalExpression(node)) { + return ( + frontierBits(node.whenTrue, new Set(seen)) | + frontierBits(node.whenFalse, new Set(seen)) + ); + } + if (ts.isBinaryExpression(node)) { + if (node.operatorToken.kind === ts.SyntaxKind.CommaToken) { + return frontierBits(node.right, seen); + } + if ( + node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken || + node.operatorToken.kind === ts.SyntaxKind.BarBarToken || + node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken + ) { + return ( + frontierBits(node.left, new Set(seen)) | + frontierBits(node.right, new Set(seen)) + ); + } + return 0; + } + if ( + ts.isPropertyAccessExpression(node) || + ts.isElementAccessExpression(node) + ) { + const property = accessedPropertyName(node); + const receiverBits = frontierBits(node.expression, new Set(seen)); + let bits = 0; + if ((receiverBits & WASM_GLOBAL_OBJECT) !== 0) { + if (property === "WebAssembly") { + bits |= WASM_AUTHORITY_NAMESPACE; + } else if (property === "eval" || property === "Function") { + bits |= DYNAMIC_CODE_CAPABILITY; + } else if (property === null) { + bits |= + DYNAMIC_CODE_CAPABILITY | + WASM_AUTHORITY_NAMESPACE | + WASM_MEMORY_CONSTRUCTOR | + WASM_INSTANCE_CONSTRUCTOR | + WASM_INSTANTIATE_FUNCTION; + } + } + if ((receiverBits & WASM_AUTHORITY_NAMESPACE) !== 0) { + if (property === "Memory") bits |= WASM_MEMORY_CONSTRUCTOR; + else if (property === "Instance") bits |= WASM_INSTANCE_CONSTRUCTOR; + else if ( + property === "instantiate" || + property === "instantiateStreaming" + ) { + bits |= WASM_INSTANTIATE_FUNCTION; + } else if (property === null) { + bits |= + WASM_MEMORY_CONSTRUCTOR | + WASM_INSTANCE_CONSTRUCTOR | + WASM_INSTANTIATE_FUNCTION; + } + } + if ( + (receiverBits & + (WASM_MEMORY_CONSTRUCTOR | WASM_INSTANCE_CONSTRUCTOR)) !== + 0 && + (property === "prototype" || property === "constructor") + ) { + bits |= + receiverBits & (WASM_MEMORY_CONSTRUCTOR | WASM_INSTANCE_CONSTRUCTOR); + } + if ((receiverBits & AUTHORITY_CONTAINER) !== 0 && property !== null) { + bits |= receiverBits & ~WASM_GLOBAL_OBJECT; + } + + const receiver = unwrapExpression(node.expression); + if ( + ts.isIdentifier(receiver) && + authorityFrontierNames.has(receiver.text) + ) { + const receiverSymbol = symbolAtExpression(checker, receiver); + if (receiverSymbol && !seen.has(receiverSymbol)) { + const nextSeen = new Set(seen); + nextSeen.add(receiverSymbol); + for (const write of authorityIndexedWrites + .get(receiverSymbol) + ?.get(property ?? "") ?? []) { + bits |= frontierBits(write, new Set(nextSeen)); + } + if (property !== null) { + for (const write of authorityWrites.get(receiverSymbol) ?? []) { + const projected = immutableAuthorityContainerProjection( + unwrapExpression(write), + property, + ); + if (projected) { + bits |= frontierBits(projected, new Set(nextSeen)); + } + } + } + } + } + return bits; + } + if (ts.isObjectLiteralExpression(node)) { + let bits = 0; + for (const property of node.properties) { + if (ts.isPropertyAssignment(property)) { + bits |= frontierBits(property.initializer, new Set(seen)); + } else if (ts.isShorthandPropertyAssignment(property)) { + bits |= frontierBits(property.name, new Set(seen)); + } else if (ts.isSpreadAssignment(property)) { + bits |= frontierBits(property.expression, new Set(seen)); + } + } + return bits === 0 ? 0 : bits | AUTHORITY_CONTAINER; + } + if (ts.isArrayLiteralExpression(node)) { + let bits = 0; + for (const element of node.elements) { + if (ts.isOmittedExpression(element)) continue; + bits |= frontierBits( + ts.isSpreadElement(element) ? element.expression : element, + new Set(seen), + ); + } + return bits === 0 ? 0 : bits | AUTHORITY_CONTAINER; + } + if (ts.isCallExpression(node)) { + const callee = unwrapExpression(node.expression); + if (propertyIs(callee, "bind")) { + let bits = 0; + const receiver = callReceiver(node); + if (receiver) bits |= frontierBits(receiver, new Set(seen)); + for (const argument of node.arguments) { + bits |= frontierBits(argument, new Set(seen)); + } + return bits; + } + if ( + (propertyIs(callee, "call") || propertyIs(callee, "apply")) && + (ts.isPropertyAccessExpression(callee) || + ts.isElementAccessExpression(callee)) && + propertyIs(callee.expression, "bind") + ) { + let bits = 0; + for (const argument of node.arguments) { + bits |= frontierBits(argument, new Set(seen)); + } + return bits; + } + return 0; + } + if (ts.isArrowFunction(node) && !ts.isBlock(node.body)) { + const bits = frontierBits(node.body, new Set(seen)); + return bits === 0 ? 0 : bits | AUTHORITY_CONTAINER; + } + // Creating a Memory/Instance or invoking instantiate yields an object or + // promise, not a reusable constructor/namespace capability. + if (ts.isNewExpression(node)) return 0; + return 0; + }; + + // Resolve the small alias/container frontier to a fixed point. Symbol + // queries are performed only for names already known to carry authority. + for (;;) { + let changed = false; + authorityFrontierNames = new Set( + [...authorityFrontier.keys()].map((symbol) => symbol.getName()), + ); + for (const [symbol, writes] of authorityWrites) { + let bits = authorityFrontier.get(symbol) ?? 0; + for (const write of writes) { + bits |= frontierBits(write, new Set([symbol])); + } + if (bits !== (authorityFrontier.get(symbol) ?? 0)) { + authorityFrontier.set(symbol, bits); + changed = true; + } + } + for (const [symbol, properties] of authorityIndexedWrites) { + let bits = authorityFrontier.get(symbol) ?? 0; + let contained = 0; + for (const writes of properties.values()) { + for (const write of writes) { + contained |= frontierBits(write, new Set([symbol])); + } + } + if (contained !== 0) bits |= contained | AUTHORITY_CONTAINER; + if (bits !== (authorityFrontier.get(symbol) ?? 0)) { + authorityFrontier.set(symbol, bits); + changed = true; + } + } + if (!changed) break; + } + authorityFrontierNames = new Set( + [...authorityFrontier.keys()].map((symbol) => symbol.getName()), + ); + const expressionIsAuthorityRelevant = (expression: ts.Expression): boolean => + (frontierBits(expression) & + (WASM_MEMORY_CONSTRUCTOR | + WASM_INSTANCE_CONSTRUCTOR | + WASM_INSTANTIATE_FUNCTION | + WASM_AUTHORITY_NAMESPACE | + WASM_GLOBAL_OBJECT | + AUTHORITY_CONTAINER)) !== + 0; + const expressionIsDynamicCodeRelevant = ( + expression: ts.Expression, + ): boolean => + (frontierBits(expression) & + (DYNAMIC_CODE_CAPABILITY | WASM_GLOBAL_OBJECT)) !== + 0 || + ((ts.isPropertyAccessExpression(unwrapExpression(expression)) || + ts.isElementAccessExpression(unwrapExpression(expression))) && + accessedPropertyName( + unwrapExpression(expression) as + ts.PropertyAccessExpression | ts.ElementAccessExpression, + ) === "constructor" && + expressionIsAuthorityRelevant( + ( + unwrapExpression(expression) as + ts.PropertyAccessExpression | ts.ElementAccessExpression + ).expression, + )); + + const possibleWasmAuthorityBits = ( + expression: ts.Expression, + seen = new Set(), + ): number => { + if (!expressionIsAuthorityRelevant(expression)) return 0; + const node = unwrapExpression(expression); + if (ts.isConditionalExpression(node)) { + return ( + possibleWasmAuthorityBits(node.whenTrue, new Set(seen)) | + possibleWasmAuthorityBits(node.whenFalse, new Set(seen)) + ); + } + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.CommaToken + ) { + return possibleWasmAuthorityBits(node.right, seen); + } + if ( + ts.isBinaryExpression(node) && + (node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken || + node.operatorToken.kind === ts.SyntaxKind.BarBarToken || + node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) + ) { + return ( + possibleWasmAuthorityBits(node.left, new Set(seen)) | + possibleWasmAuthorityBits(node.right, new Set(seen)) + ); + } + + let bits = intrinsicWasmAuthorityConstructorReferenceBits(node, checker); + if (isIntrinsicWasmInstantiateReference(node, checker)) { + bits |= WASM_INSTANTIATE_FUNCTION; + } + if (isIntrinsicNamespaceReference(node, "WebAssembly", checker)) { + bits |= WASM_AUTHORITY_NAMESPACE; + } + if (isIntrinsicGlobalObjectReference(node, checker)) { + bits |= WASM_GLOBAL_OBJECT; + } + + if ( + ts.isPropertyAccessExpression(node) || + ts.isElementAccessExpression(node) + ) { + const property = accessedPropertyName(node); + const receiverBits = possibleWasmAuthorityBits( + node.expression, + new Set(seen), + ); + if ((receiverBits & WASM_GLOBAL_OBJECT) !== 0) { + if (property === "WebAssembly") { + bits |= WASM_AUTHORITY_NAMESPACE; + } else if (property === null) { + bits |= + WASM_AUTHORITY_NAMESPACE | + WASM_MEMORY_CONSTRUCTOR | + WASM_INSTANCE_CONSTRUCTOR | + WASM_INSTANTIATE_FUNCTION; + } + } + if ((receiverBits & WASM_AUTHORITY_NAMESPACE) !== 0) { + if (property === "Memory") bits |= WASM_MEMORY_CONSTRUCTOR; + else if (property === "Instance") bits |= WASM_INSTANCE_CONSTRUCTOR; + else if ( + property === "instantiate" || + property === "instantiateStreaming" + ) { + bits |= WASM_INSTANTIATE_FUNCTION; + } else if (property === null) { + // Unknown computed namespace members are authority-possible. New/call + // sites below fail closed instead of assuming a harmless property. + bits |= + WASM_MEMORY_CONSTRUCTOR | + WASM_INSTANCE_CONSTRUCTOR | + WASM_INSTANTIATE_FUNCTION; + } + } + if ( + (receiverBits & + (WASM_MEMORY_CONSTRUCTOR | WASM_INSTANCE_CONSTRUCTOR)) !== + 0 && + (property === "prototype" || property === "constructor") + ) { + bits |= + receiverBits & (WASM_MEMORY_CONSTRUCTOR | WASM_INSTANCE_CONSTRUCTOR); + } + const receiverSymbol = symbolAtExpression(checker, node.expression); + if (receiverSymbol && property !== null) { + const nextSeen = new Set(seen); + nextSeen.add(receiverSymbol); + for (const write of authorityIndexedWrites + .get(receiverSymbol) + ?.get(property) ?? []) { + bits |= possibleWasmAuthorityBits(write, new Set(nextSeen)); + } + for (const container of authorityWrites.get(receiverSymbol) ?? []) { + const projected = immutableAuthorityContainerProjection( + unwrapExpression(container), + property, + ); + if (projected) { + bits |= possibleWasmAuthorityBits(projected, new Set(nextSeen)); + } + } + } + } + + if (ts.isCallExpression(node)) { + const callee = unwrapExpression(node.expression); + if (propertyIs(callee, "bind")) { + const receiver = callReceiver(node); + if (receiver) { + bits |= + possibleWasmAuthorityBits(receiver, new Set(seen)) & + (WASM_MEMORY_CONSTRUCTOR | + WASM_INSTANCE_CONSTRUCTOR | + WASM_INSTANTIATE_FUNCTION); + } + } + const callTarget = intrinsicCallApplyTarget(node, checker); + if (callTarget && propertyIs(callTarget, "bind") && node.arguments[0]) { + bits |= + possibleWasmAuthorityBits(node.arguments[0], new Set(seen)) & + (WASM_MEMORY_CONSTRUCTOR | + WASM_INSTANCE_CONSTRUCTOR | + WASM_INSTANTIATE_FUNCTION); + } + } + + // Property reads are resolved through their receiver's indexed writes + // above. Asking TypeScript for the symbol of every ordinary property read + // forces full flow analysis of unrelated values and can recurse through + // large inferred object graphs. Identifier symbols are declaration-local + // and sufficient for aliases; authority stored in a property is already a + // fail-closed escape at the write site. + const symbol = ts.isIdentifier(node) + ? symbolAtExpression(checker, node) + : undefined; + if (symbol && !seen.has(symbol)) { + const nextSeen = new Set(seen); + nextSeen.add(symbol); + for (const write of authorityWrites.get(symbol) ?? []) { + bits |= possibleWasmAuthorityBits(write, new Set(nextSeen)); + } + } + return bits; + }; + const WASM_AUTHORITY_CAPABILITY_MASK = + WASM_MEMORY_CONSTRUCTOR | + WASM_INSTANCE_CONSTRUCTOR | + WASM_INSTANTIATE_FUNCTION | + WASM_AUTHORITY_NAMESPACE; + const expressionCarriesWasmAuthority = (expression: ts.Expression): boolean => + (possibleWasmAuthorityBits(expression) & WASM_AUTHORITY_CAPABILITY_MASK) !== + 0; + const callIsReviewedAuthorityInvocation = ( + call: ts.CallExpression, + ): boolean => { + if ( + !expressionIsAuthorityRelevant(call.expression) && + !call.arguments.some(expressionIsAuthorityRelevant) + ) { + return false; + } + if ( + (possibleWasmAuthorityBits(call.expression) & + (WASM_MEMORY_CONSTRUCTOR | + WASM_INSTANCE_CONSTRUCTOR | + WASM_INSTANTIATE_FUNCTION)) !== + 0 || + intrinsicReflectConstructInvocationTarget(call, checker) !== null || + isCapturedIntrinsicApply(call.expression, checker) || + intrinsicCallApplyTarget(call, checker) !== null + ) { + return true; + } + if (propertyIs(call.expression, "bind")) { + const signature = checker.getResolvedSignature(call)?.declaration; + return Boolean(signature && isIntrinsicLibDeclaration(signature)); + } + return false; + }; + const expressionMayGenerateDynamicCode = ( + expression: ts.Expression, + seen = new Set(), + ): boolean => { + if (!expressionIsDynamicCodeRelevant(expression)) return false; + const node = unwrapExpression(expression); + if (ts.isConditionalExpression(node)) { + return ( + expressionMayGenerateDynamicCode(node.whenTrue, new Set(seen)) || + expressionMayGenerateDynamicCode(node.whenFalse, new Set(seen)) + ); + } + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.CommaToken + ) { + return expressionMayGenerateDynamicCode(node.right, seen); + } + if ( + ts.isBinaryExpression(node) && + (node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken || + node.operatorToken.kind === ts.SyntaxKind.BarBarToken || + node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) + ) { + return ( + expressionMayGenerateDynamicCode(node.left, new Set(seen)) || + expressionMayGenerateDynamicCode(node.right, new Set(seen)) + ); + } + if ( + ts.isIdentifier(node) && + (node.text === "eval" || node.text === "Function") && + hasIntrinsicLibValueDeclaration(symbolAtExpression(checker, node)) + ) { + return true; + } + if ( + (ts.isPropertyAccessExpression(node) || + ts.isElementAccessExpression(node)) && + (accessedPropertyName(node) === "eval" || + accessedPropertyName(node) === "Function" || + accessedPropertyName(node) === null) + ) { + if ( + (possibleWasmAuthorityBits(node.expression) & WASM_GLOBAL_OBJECT) !== + 0 + ) { + return true; + } + } + if ( + (ts.isPropertyAccessExpression(node) || + ts.isElementAccessExpression(node)) && + accessedPropertyName(node) === "constructor" && + (possibleWasmAuthorityBits(node.expression) & + (WASM_MEMORY_CONSTRUCTOR | + WASM_INSTANCE_CONSTRUCTOR | + WASM_INSTANTIATE_FUNCTION | + WASM_AUTHORITY_NAMESPACE)) !== + 0 + ) { + return true; + } + const symbol = ts.isIdentifier(node) + ? symbolAtExpression(checker, node) + : undefined; + if (!symbol || seen.has(symbol)) return false; + const nextSeen = new Set(seen); + nextSeen.add(symbol); + return (authorityWrites.get(symbol) ?? []).some((write) => + expressionMayGenerateDynamicCode(write, new Set(nextSeen)), + ); + }; + const kernelScratchExportContract = + kernelScratchPointerExportContract(sourceFiles); + const generatedKernelExportNames = + options.kernelExportNames === undefined + ? null + : new Set(options.kernelExportNames); + const generatedKernelExportContractErrors: string[] = []; + if (generatedKernelExportNames !== null) { + if (generatedKernelExportNames.size !== options.kernelExportNames!.length) { + generatedKernelExportContractErrors.push( + "generated kernel export set contains duplicate names", + ); + } + for (const name of kernelScratchExportContract.names) { + if (!generatedKernelExportNames.has(name)) { + generatedKernelExportContractErrors.push( + `kernel scratch export ${name} is absent from the generated kernel export set`, + ); + } + } + } + const auditedKernelExports = + generatedKernelExportNames ?? kernelScratchExportContract.names; + const wasmAuthorityKindsAtNode = ( + node: ts.Node, + ): Array<"wasm-memory-authority" | "wasm-instance-authority"> => { + if (!options.auditWasmAuthorityOrigins) return []; + if ( + (ts.isNewExpression(node) || ts.isCallExpression(node)) && + !expressionIsAuthorityRelevant(node.expression) && + !( + ts.isCallExpression(node) && + node.arguments.some(expressionIsAuthorityRelevant) + ) + ) { + return []; + } + let constructorBits = 0; + let createsInstance = false; + if (ts.isNewExpression(node)) { + constructorBits = intrinsicWasmAuthorityConstructorReferenceBits( + node.expression, + checker, + ); + constructorBits |= + possibleWasmAuthorityBits(node.expression) & + (WASM_MEMORY_CONSTRUCTOR | WASM_INSTANCE_CONSTRUCTOR); + if ( + typeContainsIntrinsicWasmMemory( + authorityTypeAtLocation(node, checker), + checker, + ) + ) { + constructorBits |= WASM_MEMORY_CONSTRUCTOR; + } + if ( + typeContainsIntrinsicWasmInstance( + authorityTypeAtLocation(node, checker), + checker, + ) + ) { + constructorBits |= WASM_INSTANCE_CONSTRUCTOR; + } + } else if (ts.isCallExpression(node)) { + const callApplyTarget = intrinsicCallApplyTarget(node, checker); + createsInstance = + isIntrinsicWasmInstantiateReference(node.expression, checker) || + (possibleWasmAuthorityBits(node.expression) & + WASM_INSTANTIATE_FUNCTION) !== + 0 || + isTypedWasmInstantiateCall(node, checker) || + (callApplyTarget !== null && + isIntrinsicWasmInstantiateReference(callApplyTarget, checker)) || + (node.arguments[0] !== undefined && + isCapturedIntrinsicApply(node.expression, checker) && + isIntrinsicWasmInstantiateReference(node.arguments[0], checker)) || + (node.arguments[0] !== undefined && + node.arguments[1] !== undefined && + isCapturedIntrinsicApply(node.expression, checker) && + intrinsicFunctionDispatcherKind(node.arguments[0], checker) !== + null && + isIntrinsicWasmInstantiateReference(node.arguments[1], checker)); + const reflectConstructTarget = intrinsicReflectConstructInvocationTarget( + node, + checker, + ); + if (reflectConstructTarget !== null) { + constructorBits = intrinsicWasmAuthorityConstructorReferenceBits( + reflectConstructTarget, + checker, + ); + } + } else { + return []; + } + const kinds: Array<"wasm-memory-authority" | "wasm-instance-authority"> = + []; + if ((constructorBits & WASM_MEMORY_CONSTRUCTOR) !== 0) { + kinds.push("wasm-memory-authority"); + } + if ( + createsInstance || + (constructorBits & WASM_INSTANCE_CONSTRUCTOR) !== 0 + ) { + kinds.push("wasm-instance-authority"); + } + return kinds; + }; + const states = new Map(); + const constraints: Constraint[] = []; + const declarationTargets = new Map(); + const seededReturnStates = new Map(); + const seededValueStates = new Map(); + const leaseOriginCallbacks = new Map(); + const leaseCallbackCalls = new Map< + ts.FunctionLikeDeclaration, + ts.CallExpression + >(); + const inlineScratchLeaseCallback = ( + call: ts.CallExpression, + ): ts.FunctionLikeDeclaration | null => { + if (!isKernelScratchWithLeaseCall(call, checker) || !call.arguments[0]) { + return null; + } + const callback = unwrapExpression(call.arguments[0]); + if ( + !ts.isArrowFunction(callback) || + callback.asteriskToken || + (ts.canHaveModifiers(callback) && + ts + .getModifiers(callback) + ?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword)) || + !callback.parameters[0] || + !ts.isIdentifier(callback.parameters[0].name) + ) { + return null; + } + return callback; + }; + + const addDeclarationTarget = ( + sourceFile: ts.SourceFile, + declaration: ts.Declaration, + target: DeclarationTarget, + ): void => { + const key = declarationKey(options.rootDir, sourceFile, declaration); + if (!key) return; + const existing = declarationTargets.get(key) ?? {}; + if (target.value) existing.value = target.value; + if (target.returns) existing.returns = target.returns; + declarationTargets.set(key, existing); + }; + + const addBindingConstraints = ( + name: ts.BindingName, + expression: ts.Expression, + projection: readonly StateProjection[] = [], + ): void => { + if (ts.isIdentifier(name)) { + const target = canonicalSymbol( + checker, + checker.getSymbolAtLocation(name), + ); + if (target) { + constraints.push({ + target, + expression, + projection, + }); + } + return; + } + if (ts.isObjectBindingPattern(name)) { + for (const element of name.elements) { + const property = + propertyNameText(element.propertyName) ?? + (ts.isIdentifier(element.name) ? element.name.text : null); + const nextProjection: StateProjection = + element.dotDotDotToken || property === null + ? { kind: "element" } + : { kind: "property", name: property }; + addBindingConstraints(element.name, expression, [ + ...projection, + nextProjection, + ]); + if (element.initializer) { + addBindingConstraints(element.name, element.initializer, []); + } + } + return; + } + for (const element of name.elements) { + if (ts.isOmittedExpression(element)) continue; + addBindingConstraints(element.name, expression, [ + ...projection, + { kind: "element" }, + ]); + if (element.initializer) { + addBindingConstraints(element.name, element.initializer, []); + } + } + }; + const addAssignmentConstraints = ( + targetExpression: ts.Expression, + sourceExpression: ts.Expression, + projection: readonly StateProjection[] = [], + ): void => { + const targetNode = unwrapExpression(targetExpression); + if ( + ts.isElementAccessExpression(targetNode) && + accessedPropertyName(targetNode) === null + ) { + const target = symbolAtExpression( + checker, + unwrapExpression(targetNode.expression), + ); + if (target) { + constraints.push({ + target, + expression: sourceExpression, + projection, + targetProjection: [{ kind: "element" }], + }); + return; + } + } + if ( + ts.isIdentifier(targetNode) || + ts.isPropertyAccessExpression(targetNode) || + ts.isElementAccessExpression(targetNode) + ) { + const target = symbolAtExpression(checker, targetNode); + if (target) { + constraints.push({ + target, + expression: sourceExpression, + projection, + }); + } + return; + } + if (ts.isObjectLiteralExpression(targetNode)) { + for (const property of targetNode.properties) { + if (ts.isShorthandPropertyAssignment(property)) { + addAssignmentConstraints(property.name, sourceExpression, [ + ...projection, + { kind: "property", name: property.name.text }, + ]); + if (property.objectAssignmentInitializer) { + addAssignmentConstraints( + property.name, + property.objectAssignmentInitializer, + ); + } + } else if (ts.isPropertyAssignment(property)) { + const name = propertyNameText(property.name); + addAssignmentConstraints(property.initializer, sourceExpression, [ + ...projection, + name === null ? { kind: "element" } : { kind: "property", name }, + ]); + } else if (ts.isSpreadAssignment(property)) { + addAssignmentConstraints(property.expression, sourceExpression, [ + ...projection, + { kind: "element" }, + ]); + } + } + return; + } + if (ts.isArrayLiteralExpression(targetNode)) { + for (const element of targetNode.elements) { + if (ts.isOmittedExpression(element)) continue; + addAssignmentConstraints( + ts.isSpreadElement(element) ? element.expression : element, + sourceExpression, + [...projection, { kind: "element" }], + ); + } + } + }; + for (const sourceFile of sourceFiles) { + const visit = (node: ts.Node): void => { + if ( + ts.isVariableDeclaration(node) || + ts.isPropertyDeclaration(node) || + ts.isPropertySignature(node) || + ts.isParameter(node) + ) { + const symbol = symbolForDeclaration(checker, node); + if (symbol) addDeclarationTarget(sourceFile, node, { value: symbol }); + } + if ( + ts.isFunctionDeclaration(node) || + ts.isMethodDeclaration(node) || + ts.isGetAccessorDeclaration(node) || + ts.isSetAccessorDeclaration(node) + ) { + const symbol = symbolForDeclaration(checker, node); + addDeclarationTarget(sourceFile, node, { + value: symbol, + returns: node, + }); + } + + if ( + (ts.isVariableDeclaration(node) || ts.isPropertyDeclaration(node)) && + node.initializer + ) { + if (ts.isVariableDeclaration(node)) { + addBindingConstraints(node.name, node.initializer); + } else { + const target = symbolForDeclaration(checker, node); + if (target) { + constraints.push({ target, expression: node.initializer }); + } + } + } else if (ts.isBinaryExpression(node) && isSimpleAssignment(node)) { + addAssignmentConstraints(node.left, node.right); + } else if (ts.isReturnStatement(node) && node.expression) { + const fn = returnFunction(node); + if (fn) { + constraints.push({ target: fn, expression: node.expression }); + if (ts.isGetAccessorDeclaration(fn)) { + const target = symbolForDeclaration(checker, fn); + if (target) { + constraints.push({ target, expression: node.expression }); + } + } + } + } else if (ts.isArrowFunction(node) && !ts.isBlock(node.body)) { + constraints.push({ target: node, expression: node.body }); + } + + if (ts.isCallExpression(node) || ts.isNewExpression(node)) { + const signature = checker.getResolvedSignature(node); + const declaration = signature?.declaration; + if ( + declaration && + isInProgram(programSources, declaration) && + hasBody(declaration) + ) { + const parameters = declaration.parameters; + const args = node.arguments ?? []; + for (let index = 0; index < args.length; index++) { + const parameter = + parameters[Math.min(index, parameters.length - 1)]; + if (!parameter) continue; + addBindingConstraints(parameter.name, args[index]); + } + } + } + if (ts.isCallExpression(node)) { + const leaseCallback = inlineScratchLeaseCallback(node); + if (leaseCallback) { + const parameter = leaseCallback.parameters[0]; + const symbol = symbolAtExpression( + checker, + parameter.name as ts.Identifier, + ); + if (symbol) { + leaseOriginCallbacks.set(symbol, leaseCallback); + leaseCallbackCalls.set(leaseCallback, node); + } + } + const method = + intrinsicArrayMethod(node, checker) ?? + intrinsicTypedArrayMethod(node, checker); + const receiver = method ? callReceiver(node) : null; + const callback = node.arguments[0]; + const containerParameterIndex = method + ? CONTAINER_CALLBACK_PARAMETER_INDEX.get(method) + : undefined; + if ( + method && + receiver && + callback && + containerParameterIndex !== undefined + ) { + for (const declaration of callbackDeclarations(callback, checker)) { + if (!isInProgram(programSources, declaration)) continue; + const elementParameter = declaration.parameters[0]; + if (elementParameter) { + addBindingConstraints(elementParameter.name, receiver, [ + { kind: "element" }, + ]); + } + const containerParameter = + declaration.parameters[containerParameterIndex]; + if (containerParameter) { + addBindingConstraints(containerParameter.name, receiver, []); + } + } + } + } + if (ts.isForOfStatement(node)) { + const projection: readonly StateProjection[] = [{ kind: "element" }]; + if (ts.isVariableDeclarationList(node.initializer)) { + for (const declaration of node.initializer.declarations) { + addBindingConstraints( + declaration.name, + node.expression, + projection, + ); + } + } else { + addAssignmentConstraints( + node.initializer, + node.expression, + projection, + ); + } + } + if (ts.isParameter(node) && ts.isIdentifier(node.name)) { + const property = parameterPropertySymbol(checker, node); + if (property) { + constraints.push({ target: property, expression: node.name }); + } + } + if (ts.isParameter(node) && node.initializer) { + addBindingConstraints(node.name, node.initializer); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + } + + const unresolvedDestinationFactoryDeclarations: string[] = []; + const destinationFactorySymbols = new Set(); + for (const declaration of kernelDestinationFactoryDeclarations) { + const target = declarationTargets.get(declaration)?.value; + if (!target) { + unresolvedDestinationFactoryDeclarations.push(declaration); + continue; + } + destinationFactorySymbols.add(target); + } + const mutatedSymbols = new Set(); + for (const sourceFile of sourceFiles) { + const findMutations = (node: ts.Node): void => { + let target: ts.Expression | undefined; + if ( + ts.isBinaryExpression(node) && + isAssignmentOperator(node.operatorToken.kind) + ) { + target = node.left; + } else if ( + ts.isPrefixUnaryExpression(node) || + ts.isPostfixUnaryExpression(node) + ) { + if ( + node.operator === ts.SyntaxKind.PlusPlusToken || + node.operator === ts.SyntaxKind.MinusMinusToken + ) { + target = node.operand; + } + } + if (target) { + const exactTarget = unwrapExpression(target); + if (ts.isIdentifier(exactTarget)) { + const symbol = symbolAtExpression(checker, exactTarget); + if (symbol) mutatedSymbols.add(symbol); + } + } + ts.forEachChild(node, findMutations); + }; + findMutations(sourceFile); + } + const exactUnmodifiedParameter = ( + expression: ts.Expression, + call: ts.CallExpression, + ): boolean => { + const node = unwrapExpression(expression); + if (!ts.isIdentifier(node)) return false; + const symbol = symbolAtExpression(checker, node); + if (!symbol || mutatedSymbols.has(symbol)) return false; + const declaration = symbol.declarations?.find(ts.isParameter); + return Boolean( + declaration && + ts.isIdentifier(declaration.name) && + enclosingFunction(declaration) === enclosingFunction(call), + ); + }; + const reviewedFixedCapacity = ( + expression: ts.Expression, + seen = new Set(), + ): boolean => { + const node = unwrapExpression(expression); + if (ts.isNumericLiteral(node)) { + const value = Number(node.text); + return Number.isSafeInteger(value) && value >= 0; + } + if (ts.isBigIntLiteral(node)) { + try { + const value = BigInt(node.text.slice(0, -1)); + return value >= 0n && value <= BigInt(Number.MAX_SAFE_INTEGER); + } catch { + return false; + } + } + if (!ts.isIdentifier(node)) return false; + const symbol = symbolAtExpression(checker, node); + if (!symbol || seen.has(symbol) || mutatedSymbols.has(symbol)) { + return false; + } + seen.add(symbol); + const declarations = symbol.declarations ?? []; + if (declarations.length !== 1) return false; + const declaration = declarations[0]; + return ( + ts.isVariableDeclaration(declaration) && + declaration.initializer !== undefined && + ts.isVariableDeclarationList(declaration.parent) && + (declaration.parent.flags & ts.NodeFlags.Const) !== 0 && + reviewedFixedCapacity(declaration.initializer, seen) + ); + }; + const destinationFactoryCallIsAtReviewedBoundary = ( + call: ts.CallExpression, + ): boolean => { + const boundary = enclosingFunction(call); + if ( + boundary && + (ts.isArrowFunction(boundary) || ts.isFunctionExpression(boundary)) && + ts.isPropertyAssignment(boundary.parent) && + boundary.parent.initializer === boundary + ) { + const name = propertyNameText(boundary.parent.name); + return name?.startsWith("host_") === true; + } + if ( + boundary && + (ts.isArrowFunction(boundary) || ts.isFunctionExpression(boundary)) && + ts.isCallExpression(boundary.parent) && + boundary.parent.arguments[1] === boundary && + ts.isIdentifier(unwrapExpression(boundary.parent.expression)) && + unwrapExpression(boundary.parent.expression).text === "defineMethod" && + ts.isStringLiteralLike(boundary.parent.arguments[0]) + ) { + // `#createTestAuthority` supplies only frozen module-secret white-box + // closures through this local helper. Exact call-site allowances still + // make every such test boundary independently reviewed. + return true; + } + return false; + }; + const destinationFactoryArgumentsAreExact = ( + call: ts.CallExpression, + ): boolean => + destinationFactoryCallIsAtReviewedBoundary(call) && + call.arguments.length === 3 && + exactUnmodifiedParameter(call.arguments[0]!, call) && + (exactUnmodifiedParameter(call.arguments[1]!, call) || + reviewedFixedCapacity(call.arguments[1]!)) && + ts.isStringLiteralLike(unwrapExpression(call.arguments[2]!)); + + const authorityClassificationErrors: string[] = []; + const authorityBindingTarget = (origin: ts.Node): StateKey | null => { + let child = origin; + for ( + let current: ts.Node | undefined = origin.parent; + current; + child = current, current = current.parent + ) { + if (ts.isVariableDeclaration(current) && current.initializer) { + return symbolForDeclaration(checker, current) ?? null; + } + if (ts.isPropertyDeclaration(current) && current.initializer) { + return symbolForDeclaration(checker, current) ?? null; + } + if ( + ts.isBinaryExpression(current) && + isSimpleAssignment(current) && + current.right === child + ) { + return symbolAtExpression(checker, current.left) ?? null; + } + if (ts.isReturnStatement(current) && current.expression) { + return returnFunction(current); + } + if ( + ts.isArrowFunction(current) && + !ts.isBlock(current.body) && + current.body === child + ) { + return current; + } + if ( + ts.isFunctionLike(current) || + ts.isClassStaticBlockDeclaration(current) + ) { + return null; + } + } + return null; + }; + for (const sourceFile of sourceFiles) { + const classifyAuthorityOrigin = (node: ts.Node): void => { + for (const kind of wasmAuthorityKindsAtNode(node)) { + const finding = findingFor(options.rootDir, sourceFile, node, kind); + const classification = allowanceByKey.get(finding.key); + if (classification?.authorityOwner !== undefined) { + const target = authorityBindingTarget(node); + if (!target) { + authorityClassificationErrors.push( + `authority origin has no stable binding: ${finding.key}`, + ); + } else { + mergeIntoKey( + states, + target, + ownerState( + classification.authorityOwner, + kind === "wasm-memory-authority" ? "memory" : "instance", + ), + ); + } + } + } + ts.forEachChild(node, classifyAuthorityOrigin); + }; + classifyAuthorityOrigin(sourceFile); + } + + const unresolvedSeeds: OwnershipSeed[] = []; + for (const seed of options.ownershipSeeds) { + const target = declarationTargets.get(seed.declaration); + const key = seed.target === "return" ? target?.returns : target?.value; + if (!key) { + unresolvedSeeds.push(seed); + continue; + } + mergeIntoKey(states, key, ownerState(seed.owner, seed.form)); + if (seed.target === "return" && target?.returns) { + mergeIntoKey( + seededReturnStates, + target.returns, + ownerState(seed.owner, seed.form), + ); + } else if (seed.target === "value" && target?.value) { + mergeIntoKey( + seededValueStates, + target.value, + ownerState(seed.owner, seed.form), + ); + } + } + + // Alias/argument/return propagation reaches a fixed point over the complete + // source set. This is what makes a new helper file or a renamed local alias + // visible to the ownership contract. + let changed = true; + for (let pass = 0; changed && pass < constraints.length + 32; pass++) { + changed = false; + for (const constraint of constraints) { + const state = expressionState( + constraint.expression, + checker, + states, + programSources, + ); + const projected = projectState(state, constraint.projection); + changed = + mergeIntoKey( + states, + constraint.target, + projected, + constraint.targetProjection, + ) || changed; + } + } + + const isIntrinsicWebAssemblyInstantiate = ( + expression: ts.Expression, + ): boolean => { + let node = unwrapExpression(expression); + if (ts.isAwaitExpression(node)) node = unwrapExpression(node.expression); + if (!ts.isCallExpression(node)) return false; + const callee = unwrapExpression(node.expression); + if ( + !ts.isPropertyAccessExpression(callee) || + callee.name.text !== "instantiate" + ) { + return false; + } + const receiver = unwrapExpression(callee.expression); + return ( + ts.isIdentifier(receiver) && + receiver.text === "WebAssembly" && + hasIntrinsicLibValueDeclaration(symbolAtExpression(checker, receiver)) + ); + }; + const isNullishSeedInitializer = (expression: ts.Expression): boolean => { + const node = unwrapExpression(expression); + return ( + node.kind === ts.SyntaxKind.NullKeyword || + (ts.isIdentifier(node) && + node.text === "undefined" && + hasIntrinsicLibValueDeclaration(symbolAtExpression(checker, node))) + ); + }; + const immutableConstInitializer = ( + expression: ts.Expression, + ): ts.Expression | null => { + const node = unwrapExpression(expression); + if (!ts.isIdentifier(node)) return null; + const symbol = canonicalSymbol(checker, checker.getSymbolAtLocation(node)); + const declaration = symbol?.valueDeclaration; + if ( + !declaration || + !ts.isVariableDeclaration(declaration) || + !ts.isIdentifier(declaration.name) || + !declaration.initializer || + !ts.isVariableDeclarationList(declaration.parent) || + (declaration.parent.flags & ts.NodeFlags.Const) === 0 + ) { + return null; + } + return declaration.initializer; + }; + const invalidSeedAssignmentExpressions = new Set(); + const invalidSeededScratchRegions = new Set(); + const constraintsByTarget = new Map(); + for (const constraint of constraints) { + if ((constraint.targetProjection?.length ?? 0) !== 0) continue; + const existing = constraintsByTarget.get(constraint.target); + if (existing) existing.push(constraint); + else constraintsByTarget.set(constraint.target, [constraint]); + } + const declaredPropertySymbol = ( + type: ts.Type, + name: string, + ): ts.Symbol | undefined => { + // ECMAScript private names are nominal, owner-rooted symbols. Never look + // one up by the displayed "#name": two classes may legally declare that + // same spelling while referring to different runtime slots. + if (name.startsWith("#")) return undefined; + const property = checker.getPropertyOfType(type, name); + for (const declaration of property?.declarations ?? []) { + const declared = symbolForDeclaration(checker, declaration); + if (declared) return declared; + } + return canonicalSymbol(checker, property); + }; + const isSeededScratchRegionKey = (key: StateKey | undefined): boolean => + Boolean(key && stateFor(seededValueStates, key).scratchRegion); + const isDirectScratchRegionFactoryCall = ( + expression: ts.Expression, + ): boolean => { + const node = unwrapExpression(expression); + return ( + ts.isCallExpression(node) && + isScratchRegionFactorySymbol(symbolAtExpression(checker, node.expression)) + ); + }; + const isValidatedScratchRegionProjection = ( + expression: ts.Expression, + projection: readonly StateProjection[], + ): boolean => { + const node = unwrapExpression(expression); + return ( + ts.isCallExpression(node) && + isScratchRegionOwnershipValidatorSymbol( + symbolAtExpression(checker, node.expression), + ) && + projection.length === 1 && + projection[0]!.kind === "property" && + projection[0]!.name === "region" + ); + }; + const SCRATCH_ORIGIN_UNSAFE = 0; + const SCRATCH_ORIGIN_EMPTY = 1; + const SCRATCH_ORIGIN_EXACT = 2; + type ScratchOriginProof = + | typeof SCRATCH_ORIGIN_UNSAFE + | typeof SCRATCH_ORIGIN_EMPTY + | typeof SCRATCH_ORIGIN_EXACT; + const combineScratchOriginProofs = ( + proofs: readonly ScratchOriginProof[], + ): ScratchOriginProof => { + if ( + proofs.length === 0 || + proofs.some((proof) => proof === SCRATCH_ORIGIN_UNSAFE) + ) { + return SCRATCH_ORIGIN_UNSAFE; + } + return proofs.some((proof) => proof === SCRATCH_ORIGIN_EXACT) + ? SCRATCH_ORIGIN_EXACT + : SCRATCH_ORIGIN_EMPTY; + }; + const scratchOriginSymbolAtExpression = ( + expression: ts.Expression, + ): ts.Symbol | undefined => { + const node = unwrapExpression(expression); + if ( + ts.isIdentifier(node) && + ts.isShorthandPropertyAssignment(node.parent) && + node.parent.name === node + ) { + return canonicalSymbol( + checker, + checker.getShorthandAssignmentValueSymbol(node.parent), + ); + } + return symbolAtExpression(checker, node); + }; + const isExactMethodReceiver = ( + expression: ts.Expression, + method: ts.MethodDeclaration, + seen = new Set(), + ): boolean => { + const node = unwrapExpression(expression); + if (ts.isConditionalExpression(node)) { + return ( + isExactMethodReceiver(node.whenTrue, method, new Set(seen)) && + isExactMethodReceiver(node.whenFalse, method, new Set(seen)) + ); + } + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.CommaToken + ) { + return isExactMethodReceiver(node.right, method, seen); + } + if ( + ts.isBinaryExpression(node) && + (node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken || + node.operatorToken.kind === ts.SyntaxKind.BarBarToken || + node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) + ) { + return ( + isExactMethodReceiver(node.left, method, new Set(seen)) && + isExactMethodReceiver(node.right, method, new Set(seen)) + ); + } + if (node.kind === ts.SyntaxKind.ThisKeyword || ts.isNewExpression(node)) { + if (ts.isNewExpression(node)) { + const constructor = unwrapExpression(node.expression); + if ( + ts.isIdentifier(constructor) && + constructor.text === "Proxy" && + hasIntrinsicLibValueDeclaration( + symbolAtExpression(checker, constructor), + ) + ) { + return false; + } + } + const methodName = propertyNameText(method.name); + if (!methodName) return false; + const expected = symbolForDeclaration(checker, method); + if (!expected) return false; + const receiverType = checker.getTypeAtLocation(node); + if (ts.isPrivateIdentifier(method.name)) { + // WHY: private method calls must keep the declaration symbol selected + // by TypeScript for this exact class owner. A source-name lookup would + // conflate unrelated classes that both declare (for example) + // `#requireRegion`. + return checker + .getPropertiesOfType(receiverType) + .some( + (candidate) => canonicalSymbol(checker, candidate) === expected, + ); + } + return declaredPropertySymbol(receiverType, methodName) === expected; + } + if (ts.isCallExpression(node)) { + const declaration = checker.getResolvedSignature(node)?.declaration; + if ( + !declaration || + !isInProgram(programSources, declaration) || + !hasBody(declaration) || + seen.has(declaration) + ) { + return false; + } + if (ts.isMethodDeclaration(declaration)) { + const receiver = callReceiver(node); + if ( + !receiver || + !isExactMethodReceiver(receiver, declaration, new Set(seen)) + ) { + return false; + } + } + const writes = constraintsByTarget.get(declaration) ?? []; + if (writes.length === 0) return false; + const nextSeen = new Set(seen); + nextSeen.add(declaration); + return writes.every( + (constraint) => + (constraint.projection?.length ?? 0) === 0 && + isExactMethodReceiver( + constraint.expression, + method, + new Set(nextSeen), + ), + ); + } + const symbol = scratchOriginSymbolAtExpression(node); + if (!symbol || seen.has(symbol)) return false; + const writes = constraintsByTarget.get(symbol) ?? []; + if (writes.length === 0) return false; + const nextSeen = new Set(seen); + nextSeen.add(symbol); + return writes.every( + (constraint) => + (constraint.projection?.length ?? 0) === 0 && + isExactMethodReceiver(constraint.expression, method, new Set(nextSeen)), + ); + }; + const proveExactScratchRegionOrigin = ( + expression: ts.Expression, + projection: readonly StateProjection[] = [], + seen = new Set(), + ): ScratchOriginProof => { + const node = unwrapExpression(expression); + if (isNullishSeedInitializer(node)) return SCRATCH_ORIGIN_EMPTY; + if (isDirectScratchRegionFactoryCall(node)) { + return projection.length === 0 + ? SCRATCH_ORIGIN_EXACT + : SCRATCH_ORIGIN_UNSAFE; + } + if (isValidatedScratchRegionProjection(node, projection)) { + // WHY: this exact helper authenticates the structural input against the + // allocator module's private WeakMap and exact gate generation. Only its + // returned `region` projection receives provenance; the raw argument and + // unrelated result fields remain untrusted. + return SCRATCH_ORIGIN_EXACT; + } + if (ts.isConditionalExpression(node)) { + return combineScratchOriginProofs([ + proveExactScratchRegionOrigin(node.whenTrue, projection, new Set(seen)), + proveExactScratchRegionOrigin( + node.whenFalse, + projection, + new Set(seen), + ), + ]); + } + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.CommaToken + ) { + return proveExactScratchRegionOrigin(node.right, projection, seen); + } + if ( + ts.isBinaryExpression(node) && + (node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken || + node.operatorToken.kind === ts.SyntaxKind.BarBarToken || + node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) + ) { + return combineScratchOriginProofs([ + proveExactScratchRegionOrigin(node.left, projection, new Set(seen)), + proveExactScratchRegionOrigin(node.right, projection, new Set(seen)), + ]); + } + if (projection.length > 0 && ts.isObjectLiteralExpression(node)) { + const [head, ...tail] = projection; + if (head.kind !== "property") return SCRATCH_ORIGIN_UNSAFE; + const values: ts.Expression[] = []; + for (const property of node.properties) { + if ( + ts.isPropertyAssignment(property) && + propertyNameText(property.name) === head.name + ) { + values.push(property.initializer); + } else if ( + ts.isShorthandPropertyAssignment(property) && + property.name.text === head.name + ) { + values.push(property.name); + } else if ( + ts.isMethodDeclaration(property) || + ts.isGetAccessorDeclaration(property) || + ts.isSpreadAssignment(property) + ) { + // A getter or spread can compute/replace the projected property at + // runtime. Do not infer provenance from its structural type. + return SCRATCH_ORIGIN_UNSAFE; + } + } + if (values.length === 0) return SCRATCH_ORIGIN_EMPTY; + return combineScratchOriginProofs( + values.map((value) => + proveExactScratchRegionOrigin(value, tail, new Set(seen)), + ), + ); + } + if ( + projection.length > 0 && + (node.kind === ts.SyntaxKind.ThisKeyword || ts.isNewExpression(node)) + ) { + if (ts.isNewExpression(node)) { + const constructor = unwrapExpression(node.expression); + if ( + ts.isIdentifier(constructor) && + constructor.text === "Proxy" && + hasIntrinsicLibValueDeclaration( + symbolAtExpression(checker, constructor), + ) + ) { + return SCRATCH_ORIGIN_UNSAFE; + } + } + const [head, ...tail] = projection; + if (head.kind !== "property") return SCRATCH_ORIGIN_UNSAFE; + const property = declaredPropertySymbol( + checker.getTypeAtLocation(node), + head.name, + ); + if (!property || invalidSeededScratchRegions.has(property)) { + return SCRATCH_ORIGIN_UNSAFE; + } + if (tail.length === 0 && isSeededScratchRegionKey(property)) { + return SCRATCH_ORIGIN_EXACT; + } + const writes = constraintsByTarget.get(property) ?? []; + if (writes.length === 0) return SCRATCH_ORIGIN_UNSAFE; + const nextSeen = new Set(seen); + nextSeen.add(property); + return combineScratchOriginProofs( + writes.map((constraint) => + proveExactScratchRegionOrigin( + constraint.expression, + [...(constraint.projection ?? []), ...tail], + new Set(nextSeen), + ), + ), + ); + } + if ( + ts.isPropertyAccessExpression(node) || + ts.isElementAccessExpression(node) + ) { + const property = accessedPropertyName(node); + if (property === null) return SCRATCH_ORIGIN_UNSAFE; + if (property.startsWith("#")) { + // Preserve TypeScript's class-rooted private symbol instead of + // projecting by the human-readable spelling. This makes declaration, + // assignment, and reads on one owner converge while an unrelated + // class's same-spelled private slot remains a different origin. + const symbol = scratchOriginSymbolAtExpression(node); + if ( + projection.length === 0 && + symbol && + isSeededScratchRegionKey(symbol) && + !invalidSeededScratchRegions.has(symbol) + ) { + return SCRATCH_ORIGIN_EXACT; + } + if ( + !symbol || + seen.has(symbol) || + invalidSeededScratchRegions.has(symbol) + ) { + return SCRATCH_ORIGIN_UNSAFE; + } + const writes = constraintsByTarget.get(symbol) ?? []; + if (writes.length === 0) return SCRATCH_ORIGIN_UNSAFE; + const nextSeen = new Set(seen); + nextSeen.add(symbol); + return combineScratchOriginProofs( + writes.map((constraint) => + proveExactScratchRegionOrigin( + constraint.expression, + [...(constraint.projection ?? []), ...projection], + new Set(nextSeen), + ), + ), + ); + } + return proveExactScratchRegionOrigin( + node.expression, + [{ kind: "property", name: property }, ...projection], + seen, + ); + } + if (ts.isCallExpression(node)) { + const declaration = checker.getResolvedSignature(node)?.declaration; + if ( + declaration && + isInProgram(programSources, declaration) && + hasBody(declaration) && + !seen.has(declaration) + ) { + if (ts.isMethodDeclaration(declaration)) { + const receiver = callReceiver(node); + if (!receiver || !isExactMethodReceiver(receiver, declaration)) { + return SCRATCH_ORIGIN_UNSAFE; + } + } + if (invalidSeededScratchRegions.has(declaration)) { + return SCRATCH_ORIGIN_UNSAFE; + } + const writes = constraintsByTarget.get(declaration) ?? []; + if (writes.length === 0) return SCRATCH_ORIGIN_UNSAFE; + const nextSeen = new Set(seen); + nextSeen.add(declaration); + return combineScratchOriginProofs( + writes.map((constraint) => + proveExactScratchRegionOrigin( + constraint.expression, + [...(constraint.projection ?? []), ...projection], + new Set(nextSeen), + ), + ), + ); + } + } + const symbol = scratchOriginSymbolAtExpression(node); + if ( + projection.length === 0 && + symbol && + isSeededScratchRegionKey(symbol) && + !invalidSeededScratchRegions.has(symbol) + ) { + return SCRATCH_ORIGIN_EXACT; + } + if (!symbol || seen.has(symbol)) return SCRATCH_ORIGIN_UNSAFE; + const writes = constraintsByTarget.get(symbol) ?? []; + if (writes.length === 0) return SCRATCH_ORIGIN_UNSAFE; + const nextSeen = new Set(seen); + nextSeen.add(symbol); + // WHY: region provenance is a must-property. Every value ever written to + // a field/local/helper return must be either nullish or independently + // derived from the reviewed allocator. One fake, projected container + // value, or unresolved helper poisons the origin instead of being hidden + // by the general ownership lattice's may-taint. + return combineScratchOriginProofs( + writes.map((constraint) => + proveExactScratchRegionOrigin( + constraint.expression, + [...(constraint.projection ?? []), ...projection], + new Set(nextSeen), + ), + ), + ); + }; + const isExactScratchRegionOrigin = (expression: ts.Expression): boolean => + proveExactScratchRegionOrigin(expression) === SCRATCH_ORIGIN_EXACT; + + for (const constraint of constraints) { + const seeded = stateFor(seededValueStates, constraint.target); + const source = projectState( + expressionState(constraint.expression, checker, states, programSources), + constraint.projection, + ); if ( - (ts.isInterfaceDeclaration(current) || ts.isClassDeclaration(current)) - && current.name + (seeded.instance & KERNEL_OWNER) !== 0 && + (source.instance & KERNEL_OWNER) === 0 && + !isNullishSeedInitializer(constraint.expression) && + !isIntrinsicWebAssemblyInstantiate(constraint.expression) ) { - return current.name.text; + invalidSeedAssignmentExpressions.add(constraint.expression); } } - return undefined; -} - -function isProvenReadOnlyKernelReceiverCall( - call: ts.CallExpression, - receiverState: ValueState, - checker: ts.TypeChecker, -): boolean { - const method = callPropertyName(call); - if (!method) return false; - const declaration = checker.getResolvedSignature(call)?.declaration; - if (!declaration || !isIntrinsicLibDeclaration(declaration)) return false; - const owner = signatureOwnerName(declaration); - - if ( - isKernelView(receiverState) - && owner - && TYPED_ARRAY_CONSTRUCTORS.has(owner) - && TYPED_ARRAY_NON_RETAINING_METHODS.has(method) - ) { - return true; - } - if ( - isKernelView(receiverState) - && owner === "DataView" - && method.startsWith("get") - ) { - return true; - } - if ( - isKernelBuffer(receiverState) - && (owner === "ArrayBuffer" || owner === "SharedArrayBuffer") - && method === "slice" - ) { - return true; - } - return false; -} -function isKnownReadOnlyKernelViewArgument( - call: ts.CallExpression, - argumentIndex: number, - checker: ts.TypeChecker, -): boolean { - const method = callPropertyName(call); - const signatureDeclaration = checker.getResolvedSignature(call)?.declaration; - const methodOwner = signatureOwnerName(signatureDeclaration); - // WHY: method spelling alone is not a read-only proof. A Map or custom - // object's `set(kernelView)` can retain that live view. Admit only the - // standard typed-array signature whose receiver write consumes arg0 - // synchronously. - if ( - method === "set" - && argumentIndex === 0 - && methodOwner !== undefined - && TYPED_ARRAY_CONSTRUCTORS.has(methodOwner) - && signatureDeclaration !== undefined - && isIntrinsicLibDeclaration(signatureDeclaration) - ) { - return true; - } - // TextDecoder#decode consumes bytes synchronously; a custom `decode` - // method remains an opaque escape. - if ( - method === "decode" - && argumentIndex === 0 - && methodOwner === "TextDecoder" - && signatureDeclaration !== undefined - && isIntrinsicLibDeclaration(signatureDeclaration) - ) { - return true; - } - if ( - argumentIndex === 0 - && ts.isPropertyAccessExpression(call.expression) - && call.expression.expression.getText(call.getSourceFile()) === "Atomics" - && signatureDeclaration !== undefined - && isIntrinsicLibDeclaration(signatureDeclaration) - && hasIntrinsicLibValueDeclaration( - symbolAtExpression(checker, call.expression.expression), - ) - && !ATOMIC_MUTATORS.has(call.expression.name.text) - ) { - return true; + // Scratch-region trust is a must-provenance property. The general ownership + // lattice intentionally records possible capability flow, but a conditional, + // mutable alias, helper return, or container can combine a real factory value + // with a structural fake. Only an exact seed or direct factory result, plus + // immutable const aliases, can mint a lease callback. + let invalidScratchSeedChanged = true; + while (invalidScratchSeedChanged) { + invalidScratchSeedChanged = false; + for (const constraint of constraints) { + if ( + !isSeededScratchRegionKey(constraint.target) || + isNullishSeedInitializer(constraint.expression) || + ((constraint.projection?.length ?? 0) === 0 && + isExactScratchRegionOrigin(constraint.expression)) + ) { + continue; + } + invalidSeedAssignmentExpressions.add(constraint.expression); + if (!invalidSeededScratchRegions.has(constraint.target)) { + invalidSeededScratchRegions.add(constraint.target); + invalidScratchSeedChanged = true; + } + } } - return false; -} -function validateContractEntries( - ownershipSeeds: readonly OwnershipSeed[], - allowances: readonly AuditAllowance[], -): void { - const seedKeys = new Set(); - for (const seed of ownershipSeeds) { + const reflectedSeedMutationCalls = new Set(); + type ReflectiveScratchMutation = + "assign" | "defineProperties" | "defineProperty" | "set" | "setPrototypeOf"; + const REFLECTIVE_SCRATCH_MUTATIONS = new Set([ + "assign", + "defineProperties", + "defineProperty", + "set", + "setPrototypeOf", + ]); + const reflectiveMutationFromDeclaration = ( + declaration: ts.Declaration | undefined, + ): ReflectiveScratchMutation | null => { + if (!declaration || !isIntrinsicLibDeclaration(declaration)) return null; + const declarationProperty = (declaration as ts.NamedDeclaration).name; + const name = + declarationProperty && + (ts.isIdentifier(declarationProperty) || + ts.isStringLiteralLike(declarationProperty) || + ts.isNumericLiteral(declarationProperty)) + ? declarationProperty.text + : null; if ( - seed.declaration.includes("*") - || seed.declaration.includes("?") - || seed.declaration.endsWith("::") + !name || + !REFLECTIVE_SCRATCH_MUTATIONS.has(name as ReflectiveScratchMutation) ) { - throw new Error(`ownership seed must be exact: ${seed.declaration}`); - } - if (seed.why.trim().length < 12) { - throw new Error(`ownership seed requires a WHY: ${seed.declaration}`); - } - const key = `${seed.declaration}::${seed.target}::${seed.owner}::${seed.form}`; - if (seedKeys.has(key)) throw new Error(`duplicate ownership seed: ${key}`); - seedKeys.add(key); - } - const allowanceKeys = new Set(); - for (const allowance of allowances) { - if (allowance.key.includes("*") || allowance.key.includes("?")) { - throw new Error(`audit allowance must be exact: ${allowance.key}`); + return null; } - if (allowance.why.trim().length < 12) { - throw new Error(`audit allowance requires a WHY: ${allowance.key}`); + let owner = signatureOwnerName(declaration); + if (!owner) { + for ( + let current: ts.Node | undefined = declaration.parent; + current; + current = current.parent + ) { + if (ts.isModuleDeclaration(current) && ts.isIdentifier(current.name)) { + owner = current.name.text; + break; + } + } } if ( - allowance.count !== undefined - && (!Number.isSafeInteger(allowance.count) || allowance.count <= 0) + (owner === "ObjectConstructor" && + (name === "assign" || + name === "defineProperties" || + name === "defineProperty" || + name === "setPrototypeOf")) || + (owner === "Reflect" && (name === "set" || name === "setPrototypeOf")) ) { - throw new Error(`audit allowance count must be positive: ${allowance.key}`); + return name as ReflectiveScratchMutation; } - if (allowanceKeys.has(allowance.key)) { - throw new Error(`duplicate audit allowance: ${allowance.key}`); + return null; + }; + const reflectiveMutationIdentity = ( + expression: ts.Expression, + seen = new Set(), + ): ReflectiveScratchMutation | null => { + const node = unwrapExpression(expression); + if (ts.isCallExpression(node) && callPropertyName(node) === "bind") { + const receiver = callReceiver(node); + return receiver ? reflectiveMutationIdentity(receiver, seen) : null; } - allowanceKeys.add(allowance.key); - } -} - -export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { - const allowances = options.allowances ?? []; - validateContractEntries(options.ownershipSeeds, allowances); - const program = createProgram(options); - const checker = program.getTypeChecker(); - const requestedFiles = new Set( - options.sourceFiles.map((fileName) => path.resolve(fileName)), - ); - const sourceFiles = program.getSourceFiles().filter( - (sourceFile) => - requestedFiles.has(path.resolve(sourceFile.fileName)) - && !sourceFile.isDeclarationFile, - ); - const programSources = new Set(sourceFiles); - const kernelScratchExportContract = - kernelScratchPointerExportContract(sourceFiles); - const pointerBearingKernelExports = kernelScratchExportContract.names; - const states = new Map(); - const constraints: Constraint[] = []; - const declarationTargets = new Map(); - const seededReturnStates = new Map(); - const seededValueStates = new Map(); - const leaseOriginCallbacks = new Map< - ts.Symbol, - ts.FunctionLikeDeclaration - >(); - const leaseCallbackCalls = new Map< - ts.FunctionLikeDeclaration, - ts.CallExpression - >(); - const inlineScratchLeaseCallback = ( - call: ts.CallExpression, - ): ts.FunctionLikeDeclaration | null => { - if (!isKernelScratchWithLeaseCall(call, checker) || !call.arguments[0]) { - return null; + const signatures = checker.getSignaturesOfType( + checker.getTypeAtLocation(node), + ts.SignatureKind.Call, + ); + for (const signature of signatures) { + const mutation = reflectiveMutationFromDeclaration(signature.declaration); + if (mutation) return mutation; } - const callback = unwrapExpression(call.arguments[0]); + const symbol = scratchOriginSymbolAtExpression(node); + if (!symbol || seen.has(symbol)) return null; + const declaration = symbol.valueDeclaration; if ( - !ts.isArrowFunction(callback) - || callback.asteriskToken - || ( - ts.canHaveModifiers(callback) - && ts.getModifiers(callback)?.some( - (modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword, - ) - ) - || !callback.parameters[0] - || !ts.isIdentifier(callback.parameters[0].name) + declaration && + ts.isVariableDeclaration(declaration) && + declaration.initializer ) { - return null; + const nextSeen = new Set(seen); + nextSeen.add(symbol); + return reflectiveMutationIdentity(declaration.initializer, nextSeen); } - return callback; + return null; }; - - const addDeclarationTarget = ( - sourceFile: ts.SourceFile, - declaration: ts.Declaration, - target: DeclarationTarget, - ): void => { - const key = declarationKey(options.rootDir, sourceFile, declaration); - if (!key) return; - const existing = declarationTargets.get(key) ?? {}; - if (target.value) existing.value = target.value; - if (target.returns) existing.returns = target.returns; - declarationTargets.set(key, existing); + const reflectiveMutationInvocation = ( + call: ts.CallExpression, + ): { + readonly mutation: ReflectiveScratchMutation; + readonly args: readonly ts.Expression[]; + } | null => { + const callee = unwrapExpression(call.expression); + if ( + (ts.isPropertyAccessExpression(callee) || + ts.isElementAccessExpression(callee)) && + (accessedPropertyName(callee) === "call" || + accessedPropertyName(callee) === "apply") + ) { + const mutation = reflectiveMutationIdentity(callee.expression); + if (!mutation) return null; + if (accessedPropertyName(callee) === "call") { + return { mutation, args: call.arguments.slice(1) }; + } + const applied = call.arguments[1] + ? unwrapExpression(call.arguments[1]) + : null; + return applied && ts.isArrayLiteralExpression(applied) + ? { + mutation, + args: applied.elements.filter( + (element): element is ts.Expression => + !ts.isOmittedExpression(element) && + !ts.isSpreadElement(element), + ), + } + : null; + } + const mutation = reflectiveMutationIdentity(callee); + return mutation ? { mutation, args: call.arguments } : null; }; - - const addBindingConstraints = ( - name: ts.BindingName, - expression: ts.Expression, - projection: readonly StateProjection[] = [], - ): void => { - if (ts.isIdentifier(name)) { - const target = canonicalSymbol( - checker, - checker.getSymbolAtLocation(name), + const trackedScratchProperties = (expression: ts.Expression): ts.Symbol[] => { + const type = checker.getTypeAtLocation(unwrapExpression(expression)); + return checker + .getPropertiesOfType(type) + .map( + (property) => + declaredPropertySymbol(type, property.name) ?? + canonicalSymbol(checker, property) ?? + property, + ) + .filter( + (symbol) => + isSeededScratchRegionKey(symbol) || + stateFor(states, symbol).scratchRegion || + Boolean( + symbol.declarations?.some( + (declaration) => + hasBody(declaration) && + stateFor(states, declaration).scratchRegion, + ), + ), ); - if (target) { - constraints.push({ - target, - expression, - projection, - }); + }; + const markReflectedSeedMutation = ( + call: ts.CallExpression, + target: ts.Expression | undefined, + property: string | null, + ): void => { + if (!target) return; + const seeded = trackedScratchProperties(target); + const matches = + property === null + ? seeded + : seeded.filter((symbol) => symbol.name === property); + if (matches.length === 0) return; + reflectedSeedMutationCalls.add(call); + for (const symbol of matches) { + invalidSeededScratchRegions.add(symbol); + for (const declaration of symbol.declarations ?? []) { + if (hasBody(declaration)) { + invalidSeededScratchRegions.add(declaration); + } } - return; } - if (ts.isObjectBindingPattern(name)) { - for (const element of name.elements) { - const property = propertyNameText(element.propertyName) - ?? (ts.isIdentifier(element.name) ? element.name.text : null); - const nextProjection: StateProjection = element.dotDotDotToken - || property === null - ? { kind: "element" } - : { kind: "property", name: property }; - addBindingConstraints( - element.name, - expression, - [...projection, nextProjection], - ); - if (element.initializer) { - addBindingConstraints( - element.name, - element.initializer, - [], - ); + }; + for (const sourceFile of sourceFiles) { + const findReflectedSeedMutations = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const invocation = reflectiveMutationInvocation(node); + const target = invocation?.args[0]; + if (invocation && target) { + if ( + invocation.mutation === "defineProperty" || + invocation.mutation === "set" + ) { + const propertyArgument = invocation.args[1]; + const property = + propertyArgument && + (ts.isStringLiteralLike(propertyArgument) || + ts.isNumericLiteral(propertyArgument)) + ? propertyArgument.text + : null; + markReflectedSeedMutation(node, target, property); + } else if (invocation.mutation === "setPrototypeOf") { + markReflectedSeedMutation(node, target, null); + } else { + for (const source of invocation.args.slice(1)) { + const value = unwrapExpression(source); + if (!ts.isObjectLiteralExpression(value)) { + markReflectedSeedMutation(node, target, null); + continue; + } + for (const entry of value.properties) { + const property = propertyNameText(entry.name); + markReflectedSeedMutation(node, target, property); + } + } + } } } - return; + ts.forEachChild(node, findReflectedSeedMutations); + }; + findReflectedSeedMutations(sourceFile); + } + + for (const [origin, callback] of leaseOriginCallbacks) { + const call = leaseCallbackCalls.get(callback); + const receiver = call ? callReceiver(call) : null; + if (!receiver || !isExactScratchRegionOrigin(receiver)) { + leaseOriginCallbacks.delete(origin); } - for (const element of name.elements) { - if (ts.isOmittedExpression(element)) continue; - addBindingConstraints( - element.name, - expression, - [...projection, { kind: "element" }], - ); - if (element.initializer) { - addBindingConstraints( - element.name, - element.initializer, - [], - ); + } + const leaseOriginSymbol = ( + expression: ts.Expression, + seen = new Set(), + ): ts.Symbol | undefined => { + const node = unwrapExpression(expression); + const symbol = symbolAtExpression(checker, node); + if (!symbol) return undefined; + if (ts.isIdentifier(node) && !seen.has(symbol)) { + const initializer = immutableConstInitializer(node); + if (initializer) { + seen.add(symbol); + return leaseOriginSymbol(initializer, seen); } } + return symbol; }; - const addAssignmentConstraints = ( - targetExpression: ts.Expression, - sourceExpression: ts.Expression, - projection: readonly StateProjection[] = [], - ): void => { - const targetNode = unwrapExpression(targetExpression); - if ( - ts.isElementAccessExpression(targetNode) - && accessedPropertyName(targetNode) === null + const enclosingFunction = ( + node: ts.Node, + ): ts.FunctionLikeDeclaration | null => { + for ( + let current: ts.Node | undefined = node.parent; + current; + current = current.parent ) { - const target = symbolAtExpression( - checker, - unwrapExpression(targetNode.expression), - ); - if (target) { - constraints.push({ - target, - expression: sourceExpression, - projection, - targetProjection: [{ kind: "element" }], - }); - return; - } + if (hasBody(current)) return current; } - if ( - ts.isIdentifier(targetNode) - || ts.isPropertyAccessExpression(targetNode) - || ts.isElementAccessExpression(targetNode) + return null; + }; + const isTransparentScratchUseWrapper = ( + parent: ts.Node, + child: ts.Node, + ): parent is + | ts.ParenthesizedExpression + | ts.AsExpression + | ts.TypeAssertion + | ts.NonNullExpression + | ts.SatisfiesExpression => + (ts.isParenthesizedExpression(parent) || + ts.isAsExpression(parent) || + ts.isTypeAssertionExpression(parent) || + ts.isNonNullExpression(parent) || + ts.isSatisfiesExpression(parent)) && + parent.expression === child; + const directCallForMember = ( + member: ts.Expression, + ): ts.CallExpression | null => { + let value: ts.Expression = member; + while ( + value.parent && + isTransparentScratchUseWrapper(value.parent, value) ) { - const target = symbolAtExpression(checker, targetNode); - if (target) { - constraints.push({ - target, - expression: sourceExpression, - projection, - }); - } - return; - } - if (ts.isObjectLiteralExpression(targetNode)) { - for (const property of targetNode.properties) { - if (ts.isShorthandPropertyAssignment(property)) { - addAssignmentConstraints( - property.name, - sourceExpression, - [ - ...projection, - { kind: "property", name: property.name.text }, - ], - ); - if (property.objectAssignmentInitializer) { - addAssignmentConstraints( - property.name, - property.objectAssignmentInitializer, - ); - } - } else if (ts.isPropertyAssignment(property)) { - const name = propertyNameText(property.name); - addAssignmentConstraints( - property.initializer, - sourceExpression, - [ - ...projection, - name === null - ? { kind: "element" } - : { kind: "property", name }, - ], - ); - } else if (ts.isSpreadAssignment(property)) { - addAssignmentConstraints( - property.expression, - sourceExpression, - [...projection, { kind: "element" }], - ); - } - } - return; + value = value.parent; } - if (ts.isArrayLiteralExpression(targetNode)) { - for (const element of targetNode.elements) { - if (ts.isOmittedExpression(element)) continue; - addAssignmentConstraints( - ts.isSpreadElement(element) ? element.expression : element, - sourceExpression, - [...projection, { kind: "element" }], + return value.parent && + ts.isCallExpression(value.parent) && + value.parent.expression === value + ? value.parent + : null; + }; + const typeHasScratchMember = ( + expression: ts.Expression, + member: "address" | "invokeKernelExport" | "withLease", + ): boolean => { + const symbol = canonicalSymbol( + checker, + checker.getPropertyOfType( + checker.getTypeAtLocation(unwrapExpression(expression)), + member, + ), + ); + if (member === "address") return isScratchAddressSymbol(symbol); + if (member === "withLease") return isScratchWithLeaseSymbol(symbol); + return isScratchLeaseMemberSymbol(symbol); + }; + const expressionTypeHasScratchMember = ( + expression: ts.Expression, + member: "address" | "invokeKernelExport" | "withLease", + ): boolean => { + const type = checker.getTypeAtLocation(expression); + const members = type.isUnion() + ? type.types.filter( + (part) => + (part.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) === 0, + ) + : [type]; + return ( + members.length > 0 && + members.every((part) => { + const symbol = canonicalSymbol( + checker, + checker.getPropertyOfType(part, member), ); - } - } + if (member === "address") return isScratchAddressSymbol(symbol); + if (member === "withLease") return isScratchWithLeaseSymbol(symbol); + return isScratchLeaseMemberSymbol(symbol); + }) + ); }; - for (const sourceFile of sourceFiles) { - const visit = (node: ts.Node): void => { + interface ReviewedEntryScratchInvoker { + readonly declaration: ts.MethodDeclaration; + readonly leaseCalls: ReadonlySet; + } + const reviewedEntryScratchInvoker = + ((): ReviewedEntryScratchInvoker | null => { + const candidates: ts.MethodDeclaration[] = []; + for (const sourceFile of sourceFiles) { + if ( + !toPosix(sourceFile.fileName).endsWith("/host/src/kernel-worker.ts") + ) { + continue; + } + const collect = (node: ts.Node): void => { + if ( + ts.isMethodDeclaration(node) && + ts.isPrivateIdentifier(node.name) && + node.name.text === "#invokeEntryScratchExport" && + signatureOwnerName(node) === "CentralizedKernelWorker" + ) { + candidates.push(node); + } + ts.forEachChild(node, collect); + }; + collect(sourceFile); + } + if (candidates.length !== 1) return null; + const declaration = candidates[0]!; if ( - ts.isVariableDeclaration(node) - || ts.isPropertyDeclaration(node) - || ts.isPropertySignature(node) - || ts.isParameter(node) + declaration.asteriskToken || + declaration.parameters.length !== 4 || + declaration.body?.statements.length !== 1 || + declaration.modifiers?.some( + (modifier) => + modifier.kind === ts.SyntaxKind.AsyncKeyword || + modifier.kind === ts.SyntaxKind.StaticKeyword, + ) ) { - const symbol = symbolForDeclaration(checker, node); - if (symbol) addDeclarationTarget(sourceFile, node, { value: symbol }); + return null; } + const [entryParameter, leaseParameter, nameParameter, argsParameter] = + declaration.parameters; if ( - ts.isFunctionDeclaration(node) - || ts.isMethodDeclaration(node) - || ts.isGetAccessorDeclaration(node) - || ts.isSetAccessorDeclaration(node) + !entryParameter || + !leaseParameter || + !nameParameter || + !argsParameter || + !ts.isIdentifier(entryParameter.name) || + entryParameter.name.text !== "entry" || + !ts.isIdentifier(leaseParameter.name) || + leaseParameter.name.text !== "lease" || + !ts.isIdentifier(nameParameter.name) || + nameParameter.name.text !== "name" || + !ts.isIdentifier(argsParameter.name) || + argsParameter.name.text !== "args" || + declaration.parameters.some( + (parameter) => + parameter.dotDotDotToken || + parameter.initializer || + parameter.questionToken, + ) || + !expressionTypeHasScratchMember( + leaseParameter.name, + "invokeKernelExport", + ) ) { - const symbol = symbolForDeclaration(checker, node); - addDeclarationTarget(sourceFile, node, { - value: symbol, - returns: node, - }); + return null; } - + const statement = declaration.body.statements[0]; if ( - (ts.isVariableDeclaration(node) || ts.isPropertyDeclaration(node)) - && node.initializer - ) { - if (ts.isVariableDeclaration(node)) { - addBindingConstraints(node.name, node.initializer); - } else { - const target = symbolForDeclaration(checker, node); - if (target) { - constraints.push({ target, expression: node.initializer }); - } - } - } else if ( - ts.isBinaryExpression(node) - && isSimpleAssignment(node) + !statement || + !ts.isReturnStatement(statement) || + !statement.expression || + !ts.isConditionalExpression(statement.expression) ) { - addAssignmentConstraints(node.left, node.right); - } else if (ts.isReturnStatement(node) && node.expression) { - const fn = returnFunction(node); - if (fn) { - constraints.push({ target: fn, expression: node.expression }); - if (ts.isGetAccessorDeclaration(fn)) { - const target = symbolForDeclaration(checker, fn); - if (target) { - constraints.push({ target, expression: node.expression }); - } - } - } - } else if ( - ts.isArrowFunction(node) - && !ts.isBlock(node.body) + return null; + } + const parameterSymbol = ( + parameter: ts.ParameterDeclaration, + ): ts.Symbol | undefined => + ts.isIdentifier(parameter.name) + ? canonicalSymbol( + checker, + checker.getSymbolAtLocation(parameter.name), + ) + : undefined; + const entrySymbol = parameterSymbol(entryParameter); + const leaseSymbol = parameterSymbol(leaseParameter); + const nameSymbol = parameterSymbol(nameParameter); + const argsSymbol = parameterSymbol(argsParameter); + const exactParameterReference = ( + expression: ts.Expression, + expected: ts.Symbol | undefined, + ): boolean => + Boolean( + expected && + canonicalSymbol( + checker, + checker.getSymbolAtLocation(unwrapExpression(expression)), + ) === expected, + ); + const condition = statement.expression.condition; + if ( + !ts.isBinaryExpression(condition) || + condition.operatorToken.kind !== + ts.SyntaxKind.EqualsEqualsEqualsToken || + !exactParameterReference(condition.left, entrySymbol) || + !ts.isIdentifier(unwrapExpression(condition.right)) || + unwrapExpression(condition.right).getText() !== "undefined" ) { - constraints.push({ target: node, expression: node.body }); + return null; } - - if (ts.isCallExpression(node) || ts.isNewExpression(node)) { - const signature = checker.getResolvedSignature(node); - const declaration = signature?.declaration; + const exactLeaseCall = ( + expression: ts.Expression, + memberName: "invokeKernelExport" | "invokeKernelExportScoped", + expectedArguments: readonly ( + | ts.Symbol + | { + readonly receiver: ts.Symbol; + readonly property: string; + } + )[], + ): ts.CallExpression | null => { + const node = unwrapExpression(expression); if ( - declaration - && isInProgram(programSources, declaration) - && hasBody(declaration) + !ts.isCallExpression(node) || + node.arguments.length !== expectedArguments.length ) { - const parameters = declaration.parameters; - const args = node.arguments ?? []; - for (let index = 0; index < args.length; index++) { - const parameter = parameters[Math.min(index, parameters.length - 1)]; - if (!parameter) continue; - addBindingConstraints(parameter.name, args[index]); - } - } - } - if (ts.isCallExpression(node)) { - const leaseCallback = inlineScratchLeaseCallback(node); - if (leaseCallback) { - const parameter = leaseCallback.parameters[0]; - const symbol = symbolAtExpression( - checker, - parameter.name as ts.Identifier, - ); - if (symbol) { - leaseOriginCallbacks.set(symbol, leaseCallback); - leaseCallbackCalls.set(leaseCallback, node); - } + return null; } - const method = intrinsicArrayMethod(node, checker) - ?? intrinsicTypedArrayMethod(node, checker); - const receiver = method ? callReceiver(node) : null; - const callback = node.arguments[0]; - const containerParameterIndex = method - ? CONTAINER_CALLBACK_PARAMETER_INDEX.get(method) - : undefined; + const callee = unwrapExpression(node.expression); if ( - method - && receiver - && callback - && containerParameterIndex !== undefined + !ts.isPropertyAccessExpression(callee) || + callee.name.text !== memberName || + !exactParameterReference(callee.expression, leaseSymbol) || + !isScratchLeaseMemberSymbol(symbolAtExpression(checker, callee)) ) { - for (const declaration of callbackDeclarations(callback, checker)) { - if (!isInProgram(programSources, declaration)) continue; - const elementParameter = declaration.parameters[0]; - if (elementParameter) { - addBindingConstraints( - elementParameter.name, - receiver, - [{ kind: "element" }], - ); - } - const containerParameter = - declaration.parameters[containerParameterIndex]; - if (containerParameter) { - addBindingConstraints( - containerParameter.name, - receiver, - [], - ); + return null; + } + for (let index = 0; index < expectedArguments.length; index++) { + const expected = expectedArguments[index]!; + const argument = node.arguments[index]!; + if ("receiver" in expected) { + const access = unwrapExpression(argument); + if ( + !ts.isPropertyAccessExpression(access) || + access.name.text !== expected.property || + !exactParameterReference(access.expression, expected.receiver) + ) { + return null; } + } else if (!exactParameterReference(argument, expected)) { + return null; } } + return node; + }; + if (!entrySymbol || !leaseSymbol || !nameSymbol || !argsSymbol) { + return null; } - if (ts.isForOfStatement(node)) { - const projection: readonly StateProjection[] = [{ kind: "element" }]; - if (ts.isVariableDeclarationList(node.initializer)) { - for (const declaration of node.initializer.declarations) { - addBindingConstraints( - declaration.name, - node.expression, - projection, - ); - } - } else { - addAssignmentConstraints( - node.initializer, - node.expression, - projection, - ); - } + const unscopedCall = exactLeaseCall( + statement.expression.whenTrue, + "invokeKernelExport", + [nameSymbol, argsSymbol], + ); + const scopedCall = exactLeaseCall( + statement.expression.whenFalse, + "invokeKernelExportScoped", + [{ receiver: entrySymbol, property: "scope" }, nameSymbol, argsSymbol], + ); + if (!unscopedCall || !scopedCall) return null; + return { + declaration, + leaseCalls: new Set([unscopedCall, scopedCall]), + }; + })(); + const isReviewedEntryScratchInvokerCall = ( + call: ts.CallExpression, + ): boolean => { + const declaration = checker.getResolvedSignature(call)?.declaration; + if ( + !reviewedEntryScratchInvoker || + declaration !== reviewedEntryScratchInvoker.declaration || + call.arguments.length !== 4 + ) { + return false; + } + const receiver = callReceiver(call); + return Boolean( + receiver && + isExactMethodReceiver(receiver, reviewedEntryScratchInvoker.declaration), + ); + }; + interface ReviewedLinearLeaseConsumer { + readonly declaration: ts.MethodDeclaration; + readonly leaseArgumentIndex: number; + readonly leaseCalls: ReadonlySet; + } + const reviewedLinearLeaseConsumers = new Map< + ts.MethodDeclaration, + ReviewedLinearLeaseConsumer + >(); + const linearLeaseConsumerSpecs = new Map([ + ["#copyFlattenedTransferInput", "copyFrom"], + ["#copyFlattenedTransferOutput", "copyTo"], + ] as const); + for (const [methodName, allowedLeaseMember] of linearLeaseConsumerSpecs) { + const candidates: ts.MethodDeclaration[] = []; + for (const sourceFile of sourceFiles) { + if ( + !toPosix(sourceFile.fileName).endsWith("/host/src/kernel-worker.ts") + ) { + continue; } - if (ts.isParameter(node) && ts.isIdentifier(node.name)) { - const property = parameterPropertySymbol(checker, node); - if (property) { - constraints.push({ target: property, expression: node.name }); + const collect = (node: ts.Node): void => { + if ( + ts.isMethodDeclaration(node) && + ts.isPrivateIdentifier(node.name) && + node.name.text === methodName && + signatureOwnerName(node) === "CentralizedKernelWorker" + ) { + candidates.push(node); } + ts.forEachChild(node, collect); + }; + collect(sourceFile); + } + if (candidates.length !== 1) continue; + const declaration = candidates[0]!; + const leaseParameter = declaration.parameters[0]; + if ( + declaration.asteriskToken || + !declaration.body || + declaration.modifiers?.some( + (modifier) => + modifier.kind === ts.SyntaxKind.AsyncKeyword || + modifier.kind === ts.SyntaxKind.StaticKeyword, + ) || + !leaseParameter || + !ts.isIdentifier(leaseParameter.name) || + leaseParameter.name.text !== "lease" || + leaseParameter.dotDotDotToken || + leaseParameter.initializer || + leaseParameter.questionToken || + !expressionTypeHasScratchMember(leaseParameter.name, "invokeKernelExport") + ) { + continue; + } + const leaseSymbol = canonicalSymbol( + checker, + checker.getSymbolAtLocation(leaseParameter.name), + ); + if (!leaseSymbol) continue; + const leaseCalls = new Set(); + let valid = true; + const proveLinearUse = (node: ts.Node): void => { + if (!valid) return; + if ( + ts.isCallExpression(node) && + ts.isIdentifier(unwrapExpression(node.expression)) && + unwrapExpression(node.expression).getText() === "eval" + ) { + // Direct eval could name the lease without leaving an identifier + // reference for the proof below. + valid = false; + return; } - if (ts.isParameter(node) && node.initializer) { - addBindingConstraints(node.name, node.initializer); + if (ts.isIdentifier(node) && node.text === "arguments") { + // A helper could otherwise recover its lease as arguments[0] without + // naming the parameter. + valid = false; + return; } - ts.forEachChild(node, visit); + if ( + ts.isIdentifier(node) && + canonicalSymbol(checker, checker.getSymbolAtLocation(node)) === + leaseSymbol + ) { + const access = node.parent; + const call = access?.parent; + if ( + !access || + !ts.isPropertyAccessExpression(access) || + access.expression !== node || + access.name.text !== allowedLeaseMember || + !call || + !ts.isCallExpression(call) || + call.expression !== access || + !isScratchLeaseMemberSymbol(symbolAtExpression(checker, access)) || + enclosingFunction(call) !== declaration + ) { + valid = false; + return; + } + leaseCalls.add(call); + } + ts.forEachChild(node, proveLinearUse); }; - visit(sourceFile); + proveLinearUse(declaration.body); + if (!valid || leaseCalls.size === 0) continue; + reviewedLinearLeaseConsumers.set(declaration, { + declaration, + leaseArgumentIndex: 0, + leaseCalls, + }); } - - const unresolvedSeeds: OwnershipSeed[] = []; - for (const seed of options.ownershipSeeds) { - const target = declarationTargets.get(seed.declaration); - const key = seed.target === "return" ? target?.returns : target?.value; - if (!key) { - unresolvedSeeds.push(seed); - continue; - } - mergeIntoKey(states, key, ownerState(seed.owner, seed.form)); - if (seed.target === "return" && target?.returns) { - mergeIntoKey( - seededReturnStates, - target.returns, - ownerState(seed.owner, seed.form), - ); - } else if (seed.target === "value" && target?.value) { - mergeIntoKey( - seededValueStates, - target.value, - ownerState(seed.owner, seed.form), - ); + const reviewedLinearLeaseConsumerForCall = ( + call: ts.CallExpression, + ): ReviewedLinearLeaseConsumer | null => { + const declaration = checker.getResolvedSignature(call)?.declaration; + if (!declaration || !ts.isMethodDeclaration(declaration)) return null; + const consumer = reviewedLinearLeaseConsumers.get(declaration); + if (!consumer || call.arguments.length !== declaration.parameters.length) { + return null; } + const receiver = callReceiver(call); + return receiver && isExactMethodReceiver(receiver, declaration) + ? consumer + : null; + }; + interface ReviewedTwoPhaseLeaseDispatch { + readonly declaration: ts.MethodDeclaration; + readonly stageParameterIndex: number; + readonly finishParameterIndex: number; + readonly leaseCallbackCalls: ReadonlySet; } + const workerMethodCandidates = ( + methodName: string, + ): ts.MethodDeclaration[] => { + const candidates: ts.MethodDeclaration[] = []; + for (const sourceFile of sourceFiles) { + if ( + !toPosix(sourceFile.fileName).endsWith("/host/src/kernel-worker.ts") + ) { + continue; + } + const collect = (node: ts.Node): void => { + if ( + ts.isMethodDeclaration(node) && + ts.isPrivateIdentifier(node.name) && + node.name.text === methodName && + signatureOwnerName(node) === "CentralizedKernelWorker" + ) { + candidates.push(node); + } + ts.forEachChild(node, collect); + }; + collect(sourceFile); + } + return candidates; + }; + const exactSymbolReference = ( + expression: ts.Expression, + expected: ts.Symbol, + ): boolean => + canonicalSymbol( + checker, + checker.getSymbolAtLocation(unwrapExpression(expression)), + ) === expected; + const parameterSymbol = ( + declaration: ts.MethodDeclaration, + index: number, + expectedName: string, + ): ts.Symbol | null => { + const parameter = declaration.parameters[index]; + if ( + !parameter || + !ts.isIdentifier(parameter.name) || + parameter.name.text !== expectedName || + parameter.dotDotDotToken || + parameter.initializer || + parameter.questionToken + ) { + return null; + } + return ( + canonicalSymbol(checker, checker.getSymbolAtLocation(parameter.name)) ?? + null + ); + }; + const symbolReferencesIn = ( + root: ts.Node, + symbol: ts.Symbol, + ): ts.Identifier[] => { + const references: ts.Identifier[] = []; + const collect = (node: ts.Node): void => { + if ( + ts.isIdentifier(node) && + canonicalSymbol(checker, checker.getSymbolAtLocation(node)) === symbol + ) { + references.push(node); + } + ts.forEachChild(node, collect); + }; + collect(root); + return references; + }; + const directCallbackCallForReference = ( + reference: ts.Identifier, + ): ts.CallExpression | null => { + const callee = unwrapExpression(reference); + const parent = callee.parent; + return parent && + ts.isCallExpression(parent) && + unwrapExpression(parent.expression) === callee + ? parent + : null; + }; + const enclosingInlineLeaseCallback = ( + call: ts.CallExpression, + ): ts.FunctionLikeDeclaration | null => { + const callback = enclosingFunction(call); + if (!callback) return null; + const parent = callback.parent; + if ( + !parent || + !ts.isCallExpression(parent) || + inlineScratchLeaseCallback(parent) !== callback || + !isKernelScratchWithLeaseCall(parent, checker) || + !callReceiver(parent) || + !isExactScratchRegionOrigin(callReceiver(parent)!) + ) { + return null; + } + return callback; + }; + const exactEntryScratchExportName = ( + call: ts.CallExpression, + expectedName: string, + ): boolean => { + if (!isReviewedEntryScratchInvokerCall(call)) return false; + const name = unwrapExpression(call.arguments[2]!); + return ts.isStringLiteralLike(name) && name.text === expectedName; + }; + const reviewTwoPhaseLeaseMethod = ( + methodName: string, + expectedExportName: string, + forwardedTo?: ts.MethodDeclaration, + ): ReviewedTwoPhaseLeaseDispatch | null => { + const candidates = workerMethodCandidates(methodName); + if (candidates.length !== 1) return null; + const declaration = candidates[0]!; + if ( + declaration.asteriskToken || + !declaration.body || + declaration.parameters.length !== 6 || + declaration.modifiers?.some( + (modifier) => + modifier.kind === ts.SyntaxKind.AsyncKeyword || + modifier.kind === ts.SyntaxKind.StaticKeyword, + ) + ) { + return null; + } + const channelSymbol = parameterSymbol(declaration, 0, "channel"); + const capacitySymbol = parameterSymbol(declaration, 1, "totalCapacity"); + const entrySymbol = parameterSymbol(declaration, 2, "entry"); + const stageSymbol = parameterSymbol(declaration, 3, "stage"); + const finishSymbol = parameterSymbol(declaration, 4, "finish"); + const retryTokenParameter = declaration.parameters[5]; + const retryTokenSymbol = + retryTokenParameter && + ts.isIdentifier(retryTokenParameter.name) && + retryTokenParameter.name.text === "retryToken" && + !retryTokenParameter.dotDotDotToken && + !retryTokenParameter.questionToken && + retryTokenParameter.initializer && + ts.isBigIntLiteral(retryTokenParameter.initializer) && + retryTokenParameter.initializer.getText() === "0n" + ? (canonicalSymbol( + checker, + checker.getSymbolAtLocation(retryTokenParameter.name), + ) ?? null) + : null; + const stageType = declaration.parameters[3]!.type; + const finishType = declaration.parameters[4]!.type; + const stageLeaseParameter = + stageType && ts.isFunctionTypeNode(stageType) + ? stageType.parameters[0] + : undefined; + const finishLeaseParameter = + finishType && ts.isFunctionTypeNode(finishType) + ? finishType.parameters[0] + : undefined; + if ( + !channelSymbol || + !capacitySymbol || + !entrySymbol || + !stageSymbol || + !finishSymbol || + !retryTokenSymbol || + !stageLeaseParameter || + !ts.isIdentifier(stageLeaseParameter.name) || + !finishLeaseParameter || + !ts.isIdentifier(finishLeaseParameter.name) || + !expressionTypeHasScratchMember( + stageLeaseParameter.name, + "invokeKernelExport", + ) || + !expressionTypeHasScratchMember( + finishLeaseParameter.name, + "invokeKernelExport", + ) + ) { + return null; + } - // Alias/argument/return propagation reaches a fixed point over the complete - // source set. This is what makes a new helper file or a renamed local alias - // visible to the ownership contract. - let changed = true; - for (let pass = 0; changed && pass < constraints.length + 32; pass++) { - changed = false; - for (const constraint of constraints) { - const state = expressionState( - constraint.expression, - checker, - states, - programSources, + const stageReferences = symbolReferencesIn(declaration.body, stageSymbol); + const finishReferences = symbolReferencesIn(declaration.body, finishSymbol); + const stageCalls = stageReferences + .map(directCallbackCallForReference) + .filter((call): call is ts.CallExpression => call !== null); + const finishCalls = finishReferences + .map(directCallbackCallForReference) + .filter((call): call is ts.CallExpression => call !== null); + if (stageCalls.length !== 1 || finishCalls.length !== 1) return null; + const stageCall = stageCalls[0]!; + const finishCall = finishCalls[0]!; + if ( + stageCall.arguments.length !== 1 || + finishCall.arguments.length !== 1 || + !ts.isExpressionStatement(stageCall.parent) || + !ts.isReturnStatement(finishCall.parent) || + finishCall.parent.expression !== finishCall + ) { + return null; + } + const stageLeaseCallback = enclosingInlineLeaseCallback(stageCall); + const finishLeaseCallback = enclosingInlineLeaseCallback(finishCall); + if ( + !stageLeaseCallback || + stageLeaseCallback !== finishLeaseCallback || + !stageLeaseCallback.parameters[0] || + !ts.isIdentifier(stageLeaseCallback.parameters[0]!.name) || + !stageLeaseCallback.body || + !ts.isBlock(stageLeaseCallback.body) + ) { + return null; + } + const leaseSymbol = canonicalSymbol( + checker, + checker.getSymbolAtLocation(stageLeaseCallback.parameters[0]!.name), + ); + if ( + !leaseSymbol || + !exactSymbolReference(stageCall.arguments[0]!, leaseSymbol) || + !exactSymbolReference(finishCall.arguments[0]!, leaseSymbol) + ) { + return null; + } + const exportCalls: ts.CallExpression[] = []; + const collectExportCalls = (node: ts.Node): void => { + if ( + ts.isCallExpression(node) && + enclosingFunction(node) === stageLeaseCallback && + exactEntryScratchExportName(node, expectedExportName) + ) { + exportCalls.push(node); + } + ts.forEachChild(node, collectExportCalls); + }; + collectExportCalls(stageLeaseCallback.body); + if (exportCalls.length !== 1) { + return null; + } + const callbackBlock = stageLeaseCallback.body; + const stageStatement = directExpressionCallStatement( + stageCall, + callbackBlock, + ); + const finishStatement = + ts.isReturnStatement(finishCall.parent) && + finishCall.parent.expression === finishCall && + finishCall.parent.parent === callbackBlock + ? finishCall.parent + : null; + let exportContainer = directCallStatement(exportCalls[0]!, callbackBlock); + if (!exportContainer) { + const exportTry = callbackBlock.statements.find( + (statement): statement is ts.TryStatement => + ts.isTryStatement(statement) && + !statement.catchClause && + statement.finallyBlock !== undefined && + statement.tryBlock.statements.length === 1 && + directCallStatement(exportCalls[0]!, statement.tryBlock) === + statement.tryBlock.statements[0] && + !statement.finallyBlock.statements.some( + (cleanupStatement) => + ts.isReturnStatement(cleanupStatement) || + ts.isThrowStatement(cleanupStatement), + ), ); - const projected = projectState(state, constraint.projection); - changed = mergeIntoKey( - states, - constraint.target, - projected, - constraint.targetProjection, - ) || changed; + exportContainer = exportTry ?? null; + } + if ( + !stageStatement || + !finishStatement || + !exportContainer || + callbackBlock.statements.indexOf(stageStatement) >= + callbackBlock.statements.indexOf(exportContainer) || + callbackBlock.statements.indexOf(exportContainer) >= + callbackBlock.statements.indexOf(finishStatement) + ) { + return null; + } + const exportArguments = exportCalls[0]!.arguments[3] + ? unwrapExpression(exportCalls[0]!.arguments[3]!) + : null; + if ( + !exportArguments || + !ts.isArrayLiteralExpression(exportArguments) || + exportArguments.elements.length === 0 || + !exactSymbolReference( + exportArguments.elements[exportArguments.elements.length - 1]!, + retryTokenSymbol, + ) + ) { + return null; } - } - const isIntrinsicWebAssemblyInstantiate = ( - expression: ts.Expression, - ): boolean => { - let node = unwrapExpression(expression); - if (ts.isAwaitExpression(node)) node = unwrapExpression(node.expression); - if (!ts.isCallExpression(node)) return false; - const callee = unwrapExpression(node.expression); + let forwardedStageReference: ts.Identifier | null = null; + let forwardedFinishReference: ts.Identifier | null = null; + let forwardedRetryTokenReference: ts.Identifier | null = null; + if (forwardedTo) { + const forwardingCalls: ts.CallExpression[] = []; + const collectForwardingCalls = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const resolved = checker.getResolvedSignature(node)?.declaration; + const receiver = callReceiver(node); + if ( + resolved === forwardedTo && + receiver && + isExactMethodReceiver(receiver, forwardedTo) + ) { + forwardingCalls.push(node); + } + } + ts.forEachChild(node, collectForwardingCalls); + }; + collectForwardingCalls(declaration.body); + if (forwardingCalls.length !== 1) return null; + const forwardingCall = forwardingCalls[0]!; + if ( + forwardingCall.arguments.length !== 6 || + !exactSymbolReference(forwardingCall.arguments[0]!, channelSymbol) || + !exactSymbolReference(forwardingCall.arguments[1]!, capacitySymbol) || + !exactSymbolReference(forwardingCall.arguments[2]!, entrySymbol) || + !exactSymbolReference(forwardingCall.arguments[3]!, stageSymbol) || + !exactSymbolReference(forwardingCall.arguments[4]!, finishSymbol) || + !exactSymbolReference(forwardingCall.arguments[5]!, retryTokenSymbol) || + !ts.isReturnStatement(forwardingCall.parent) || + forwardingCall.parent.expression !== forwardingCall + ) { + return null; + } + forwardedStageReference = unwrapExpression( + forwardingCall.arguments[3]!, + ) as ts.Identifier; + forwardedFinishReference = unwrapExpression( + forwardingCall.arguments[4]!, + ) as ts.Identifier; + forwardedRetryTokenReference = unwrapExpression( + forwardingCall.arguments[5]!, + ) as ts.Identifier; + } + const expectedStageReferences = forwardedTo ? 2 : 1; + const expectedFinishReferences = forwardedTo ? 2 : 1; + const retryTokenReferences = symbolReferencesIn( + declaration.body, + retryTokenSymbol, + ); + const expectedRetryTokenReferences = forwardedTo ? 2 : 1; + if ( + stageReferences.length !== expectedStageReferences || + finishReferences.length !== expectedFinishReferences || + retryTokenReferences.length !== expectedRetryTokenReferences || + (forwardedTo && + (!stageReferences.includes(forwardedStageReference!) || + !finishReferences.includes(forwardedFinishReference!) || + !retryTokenReferences.includes(forwardedRetryTokenReference!))) + ) { + return null; + } + return { + declaration, + stageParameterIndex: 3, + finishParameterIndex: 4, + leaseCallbackCalls: new Set([stageCall, finishCall]), + }; + }; + const exactPropertyReceiverIdentifier = ( + expression: ts.Expression, + propertyName: string, + receiverSymbol: ts.Symbol, + ): ts.Identifier | null => { + const value = unwrapExpression(expression); if ( - !ts.isPropertyAccessExpression(callee) - || callee.name.text !== "instantiate" + !ts.isPropertyAccessExpression(value) || + value.name.text !== propertyName ) { - return false; + return null; } - const receiver = unwrapExpression(callee.expression); - return ts.isIdentifier(receiver) - && receiver.text === "WebAssembly" - && hasIntrinsicLibValueDeclaration( - symbolAtExpression(checker, receiver), - ); + const receiver = unwrapExpression(value.expression); + return ts.isIdentifier(receiver) && + exactSymbolReference(receiver, receiverSymbol) + ? receiver + : null; }; - const isNullishSeedInitializer = ( + const exactPropertyOfSymbol = ( expression: ts.Expression, - ): boolean => { - const node = unwrapExpression(expression); - return node.kind === ts.SyntaxKind.NullKeyword - || ( - ts.isIdentifier(node) - && node.text === "undefined" - && hasIntrinsicLibValueDeclaration( - symbolAtExpression(checker, node), - ) - ); + propertyName: string, + receiverSymbol: ts.Symbol, + ): boolean => + exactPropertyReceiverIdentifier( + expression, + propertyName, + receiverSymbol, + ) !== null; + const privateMethodCallName = (call: ts.CallExpression): string | null => { + const callee = unwrapExpression(call.expression); + return ts.isPropertyAccessExpression(callee) && + ts.isPrivateIdentifier(callee.name) + ? callee.name.text + : null; }; - const immutableConstInitializer = ( - expression: ts.Expression, - ): ts.Expression | null => { - const node = unwrapExpression(expression); - if (!ts.isIdentifier(node)) return null; - const symbol = canonicalSymbol(checker, checker.getSymbolAtLocation(node)); - const declaration = symbol?.valueDeclaration; + const constVariableSymbol = ( + declaration: ts.VariableDeclaration, + ): ts.Symbol | null => { if ( - !declaration - || !ts.isVariableDeclaration(declaration) - || !ts.isIdentifier(declaration.name) - || !declaration.initializer - || !ts.isVariableDeclarationList(declaration.parent) - || (declaration.parent.flags & ts.NodeFlags.Const) === 0 + !ts.isIdentifier(declaration.name) || + !ts.isVariableDeclarationList(declaration.parent) || + (declaration.parent.flags & ts.NodeFlags.Const) === 0 ) { return null; } - return declaration.initializer; - }; - const invalidSeedAssignmentExpressions = new Set(); - const invalidSeededScratchRegions = new Set(); - const constraintsByTarget = new Map(); - for (const constraint of constraints) { - if ((constraint.targetProjection?.length ?? 0) !== 0) continue; - const existing = constraintsByTarget.get(constraint.target); - if (existing) existing.push(constraint); - else constraintsByTarget.set(constraint.target, [constraint]); - } - const declaredPropertySymbol = ( - type: ts.Type, - name: string, - ): ts.Symbol | undefined => { - const property = checker.getPropertyOfType(type, name); - for (const declaration of property?.declarations ?? []) { - const declared = symbolForDeclaration(checker, declaration); - if (declared) return declared; - } - return canonicalSymbol(checker, property); + return ( + canonicalSymbol(checker, checker.getSymbolAtLocation(declaration.name)) ?? + null + ); }; - const isSeededScratchRegionKey = ( - key: StateKey | undefined, - ): boolean => - Boolean(key && stateFor(seededValueStates, key).scratchRegion); - const isDirectScratchRegionFactoryCall = ( + const exactPointerWidthConversion = ( expression: ts.Expression, + valueSymbol: ts.Symbol, ): boolean => { - const node = unwrapExpression(expression); - return ts.isCallExpression(node) - && isScratchRegionFactorySymbol( - symbolAtExpression(checker, node.expression), - ); - }; - const SCRATCH_ORIGIN_UNSAFE = 0; - const SCRATCH_ORIGIN_EMPTY = 1; - const SCRATCH_ORIGIN_EXACT = 2; - type ScratchOriginProof = - | typeof SCRATCH_ORIGIN_UNSAFE - | typeof SCRATCH_ORIGIN_EMPTY - | typeof SCRATCH_ORIGIN_EXACT; - const combineScratchOriginProofs = ( - proofs: readonly ScratchOriginProof[], - ): ScratchOriginProof => { + const value = unwrapExpression(expression); + if (exactSymbolReference(value, valueSymbol)) return true; if ( - proofs.length === 0 - || proofs.some((proof) => proof === SCRATCH_ORIGIN_UNSAFE) + !ts.isCallExpression(value) || + value.arguments.length !== 1 || + !exactSymbolReference(value.arguments[0]!, valueSymbol) ) { - return SCRATCH_ORIGIN_UNSAFE; + return false; } - return proofs.some((proof) => proof === SCRATCH_ORIGIN_EXACT) - ? SCRATCH_ORIGIN_EXACT - : SCRATCH_ORIGIN_EMPTY; + const callee = unwrapExpression(value.expression); + return ( + ts.isPropertyAccessExpression(callee) && + callee.name.text === "toKernelPtr" && + callee.expression.kind === ts.SyntaxKind.ThisKeyword + ); }; - const scratchOriginSymbolAtExpression = ( - expression: ts.Expression, - ): ts.Symbol | undefined => { - const node = unwrapExpression(expression); + const nodeInside = (node: ts.Node, owner: ts.Node): boolean => + owner.getStart() <= node.getStart() && node.getEnd() <= owner.getEnd(); + const directExpressionCallStatement = ( + call: ts.CallExpression, + block: ts.Block, + ): ts.ExpressionStatement | null => + ts.isExpressionStatement(call.parent) && + call.parent.expression === call && + call.parent.parent === block + ? call.parent + : null; + const directCallStatement = ( + call: ts.CallExpression, + block: ts.Block, + ): ts.Statement | null => { + const expressionStatement = directExpressionCallStatement(call, block); + if (expressionStatement) return expressionStatement; + const declaration = call.parent; if ( - ts.isIdentifier(node) - && ts.isShorthandPropertyAssignment(node.parent) - && node.parent.name === node + !ts.isVariableDeclaration(declaration) || + declaration.initializer !== call || + !ts.isVariableDeclarationList(declaration.parent) || + declaration.parent.declarations.length !== 1 || + !ts.isVariableStatement(declaration.parent.parent) || + declaration.parent.parent.parent !== block ) { - return canonicalSymbol( - checker, - checker.getShorthandAssignmentValueSymbol(node.parent), - ); + return null; } - return symbolAtExpression(checker, node); + return declaration.parent.parent; }; - const isExactMethodReceiver = ( + const nullComparisonIdentifier = ( expression: ts.Expression, - method: ts.MethodDeclaration, - seen = new Set(), - ): boolean => { - const node = unwrapExpression(expression); - if (ts.isConditionalExpression(node)) { - return isExactMethodReceiver(node.whenTrue, method, new Set(seen)) - && isExactMethodReceiver(node.whenFalse, method, new Set(seen)); - } + operator: + | ts.SyntaxKind.EqualsEqualsEqualsToken + | ts.SyntaxKind.ExclamationEqualsEqualsToken, + ): ts.Identifier | null => { + const value = unwrapExpression(expression); if ( - ts.isBinaryExpression(node) - && node.operatorToken.kind === ts.SyntaxKind.CommaToken + !ts.isBinaryExpression(value) || + value.operatorToken.kind !== operator ) { - return isExactMethodReceiver(node.right, method, seen); + return null; + } + const left = unwrapExpression(value.left); + const right = unwrapExpression(value.right); + if (ts.isIdentifier(left) && right.kind === ts.SyntaxKind.NullKeyword) { + return left; } + return left.kind === ts.SyntaxKind.NullKeyword && ts.isIdentifier(right) + ? right + : null; + }; + const exactNullComparison = ( + expression: ts.Expression, + symbol: ts.Symbol, + operator: + | ts.SyntaxKind.EqualsEqualsEqualsToken + | ts.SyntaxKind.ExclamationEqualsEqualsToken, + ): boolean => { + const identifier = nullComparisonIdentifier(expression, operator); + return identifier !== null && exactSymbolReference(identifier, symbol); + }; + /** + * Prove the reserved-spawn raw export is one closed transaction. + * + * WHY: the export names no pointer; its token authorizes the Rust Vec whose + * pointer and capacity produced `activeRegion`. Treating that raw call as a + * scalar allowance would lose the only static proof that the staged region, + * commit token, and unconditional cancellation all refer to one reservation. + */ + const reviewReservedSpawnTransaction = (): ts.CallExpression | null => { + const candidates = workerMethodCandidates("#handleSpawnAfterResolve"); + if (candidates.length !== 1) return null; + const declaration = candidates[0]!; if ( - ts.isBinaryExpression(node) - && ( - node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken - || node.operatorToken.kind === ts.SyntaxKind.BarBarToken - || node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken + declaration.asteriskToken || + !declaration.body || + declaration.modifiers?.some( + (modifier) => + modifier.kind === ts.SyntaxKind.AsyncKeyword || + modifier.kind === ts.SyntaxKind.StaticKeyword, ) ) { - return isExactMethodReceiver(node.left, method, new Set(seen)) - && isExactMethodReceiver(node.right, method, new Set(seen)); + return null; } + const namedParameter = (name: string): ts.Symbol | null => { + const parameter = declaration.parameters.find( + (candidate) => + ts.isIdentifier(candidate.name) && + candidate.name.text === name && + !candidate.dotDotDotToken && + !candidate.initializer && + !candidate.questionToken, + ); + return parameter && ts.isIdentifier(parameter.name) + ? (canonicalSymbol( + checker, + checker.getSymbolAtLocation(parameter.name), + ) ?? null) + : null; + }; + const parentPidSymbol = namedParameter("parentPid"); + const callerTidSymbol = namedParameter("callerTid"); + const blobBytesSymbol = namedParameter("blobBytes"); + const blobLenSymbol = namedParameter("blobLen"); + const entrySymbol = namedParameter("entry"); if ( - node.kind === ts.SyntaxKind.ThisKeyword - || ts.isNewExpression(node) + !parentPidSymbol || + !callerTidSymbol || + !blobBytesSymbol || + !blobLenSymbol || + !entrySymbol || + mutatedSymbols.has(parentPidSymbol) || + mutatedSymbols.has(callerTidSymbol) || + mutatedSymbols.has(blobBytesSymbol) || + mutatedSymbols.has(blobLenSymbol) || + mutatedSymbols.has(entrySymbol) ) { - if (ts.isNewExpression(node)) { - const constructor = unwrapExpression(node.expression); + return null; + } + + const reservedSpawnDeclarations: ts.VariableDeclaration[] = []; + const allCalls: ts.CallExpression[] = []; + const allAssignments: ts.BinaryExpression[] = []; + const allTryStatements: ts.TryStatement[] = []; + const collect = (node: ts.Node): void => { + if (ts.isVariableDeclaration(node) && node.initializer) { + const initializer = unwrapExpression(node.initializer); if ( - ts.isIdentifier(constructor) - && constructor.text === "Proxy" - && hasIntrinsicLibValueDeclaration( - symbolAtExpression(checker, constructor), - ) + ts.isIdentifier(node.name) && + node.name.text === "reservedSpawn" && + ts.isPropertyAccessExpression(initializer) && + initializer.name.text === "kernel_spawn_reserved_process" ) { - return false; + reservedSpawnDeclarations.push(node); } } - const methodName = propertyNameText(method.name); - if (!methodName) return false; - const expected = symbolForDeclaration(checker, method); - const actual = declaredPropertySymbol( - checker.getTypeAtLocation(node), - methodName, - ); - return Boolean(expected && actual === expected); - } - if (ts.isCallExpression(node)) { - const declaration = checker.getResolvedSignature(node)?.declaration; + if (ts.isCallExpression(node)) allCalls.push(node); + if (ts.isBinaryExpression(node) && isSimpleAssignment(node)) { + allAssignments.push(node); + } + if (ts.isTryStatement(node)) allTryStatements.push(node); + ts.forEachChild(node, collect); + }; + collect(declaration.body); + if (reservedSpawnDeclarations.length !== 1) return null; + const reservedSpawnDeclaration = reservedSpawnDeclarations[0]!; + const reservedSpawnSymbol = constVariableSymbol(reservedSpawnDeclaration); + if (!reservedSpawnSymbol) return null; + const reservedSpawnReferences = symbolReferencesIn( + declaration.body, + reservedSpawnSymbol, + ); + const commitCalls = reservedSpawnReferences + .map(directCallbackCallForReference) + .filter((call): call is ts.CallExpression => call !== null); + if (commitCalls.length !== 1) return null; + const commitCall = commitCalls[0]!; + for (const reference of reservedSpawnReferences) { if ( - !declaration - || !isInProgram(programSources, declaration) - || !hasBody(declaration) - || seen.has(declaration) + reference === reservedSpawnDeclaration.name || + directCallbackCallForReference(reference) === commitCall || + ts.isTypeOfExpression(reference.parent) ) { - return false; - } - if (ts.isMethodDeclaration(declaration)) { - const receiver = callReceiver(node); - if ( - !receiver - || !isExactMethodReceiver( - receiver, - declaration, - new Set(seen), - ) - ) { - return false; - } + continue; } - const writes = constraintsByTarget.get(declaration) ?? []; - if (writes.length === 0) return false; - const nextSeen = new Set(seen); - nextSeen.add(declaration); - return writes.every( - (constraint) => - (constraint.projection?.length ?? 0) === 0 - && isExactMethodReceiver( - constraint.expression, - method, - new Set(nextSeen), - ), - ); + return null; } - const symbol = scratchOriginSymbolAtExpression(node); - if (!symbol || seen.has(symbol)) return false; - const writes = constraintsByTarget.get(symbol) ?? []; - if (writes.length === 0) return false; - const nextSeen = new Set(seen); - nextSeen.add(symbol); - return writes.every( - (constraint) => - (constraint.projection?.length ?? 0) === 0 - && isExactMethodReceiver( - constraint.expression, - method, - new Set(nextSeen), - ), + if ( + commitCall.arguments.length !== 4 || + !exactSymbolReference(commitCall.arguments[0]!, parentPidSymbol) || + !exactSymbolReference(commitCall.arguments[1]!, callerTidSymbol) + ) { + return null; + } + + const callback = enclosingFunction(commitCall); + if ( + !callback || + !ts.isArrowFunction(callback) || + callback.asteriskToken || + callback.modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword, + ) || + callback.parameters.length !== 1 || + !callback.parameters[0] || + !ts.isIdentifier(callback.parameters[0].name) || + !ts.isCallExpression(callback.parent) || + callback.parent.arguments.length !== 1 || + callback.parent.arguments[0] !== callback || + !ts.isBlock(callback.body) + ) { + return null; + } + const withLeaseCall = callback.parent; + const withLeaseCallee = unwrapExpression(withLeaseCall.expression); + if ( + !ts.isPropertyAccessExpression(withLeaseCallee) || + withLeaseCallee.name.text !== "withLease" + ) { + return null; + } + const activeRegionExpression = unwrapExpression(withLeaseCallee.expression); + if (!ts.isIdentifier(activeRegionExpression)) return null; + const activeRegionSymbol = canonicalSymbol( + checker, + checker.getSymbolAtLocation(activeRegionExpression), ); - }; - const proveExactScratchRegionOrigin = ( - expression: ts.Expression, - projection: readonly StateProjection[] = [], - seen = new Set(), - ): ScratchOriginProof => { - const node = unwrapExpression(expression); - if (isNullishSeedInitializer(node)) return SCRATCH_ORIGIN_EMPTY; - if (isDirectScratchRegionFactoryCall(node)) { - return projection.length === 0 - ? SCRATCH_ORIGIN_EXACT - : SCRATCH_ORIGIN_UNSAFE; + const activeRegionDeclaration = activeRegionSymbol?.valueDeclaration; + if ( + !activeRegionSymbol || + !activeRegionDeclaration || + !ts.isVariableDeclaration(activeRegionDeclaration) || + constVariableSymbol(activeRegionDeclaration) !== activeRegionSymbol || + !activeRegionDeclaration.initializer + ) { + return null; } - if (ts.isConditionalExpression(node)) { - return combineScratchOriginProofs([ - proveExactScratchRegionOrigin( - node.whenTrue, - projection, - new Set(seen), - ), - proveExactScratchRegionOrigin( - node.whenFalse, - projection, - new Set(seen), - ), - ]); + const reservationRegion = unwrapExpression( + activeRegionDeclaration.initializer, + ); + if ( + !ts.isPropertyAccessExpression(reservationRegion) || + reservationRegion.name.text !== "region" || + !ts.isIdentifier(unwrapExpression(reservationRegion.expression)) + ) { + return null; } + const reservationExpression = unwrapExpression( + reservationRegion.expression, + ) as ts.Identifier; + const reservationSymbol = canonicalSymbol( + checker, + checker.getSymbolAtLocation(reservationExpression), + ); + const reservationDeclaration = reservationSymbol?.valueDeclaration; if ( - ts.isBinaryExpression(node) - && node.operatorToken.kind === ts.SyntaxKind.CommaToken + !reservationSymbol || + !reservationDeclaration || + !ts.isVariableDeclaration(reservationDeclaration) || + !ts.isIdentifier(reservationDeclaration.name) || + !reservationDeclaration.initializer || + unwrapExpression(reservationDeclaration.initializer).kind !== + ts.SyntaxKind.NullKeyword || + !ts.isVariableDeclarationList(reservationDeclaration.parent) || + (reservationDeclaration.parent.flags & ts.NodeFlags.Let) === 0 ) { - return proveExactScratchRegionOrigin(node.right, projection, seen); + return null; } + const activeRegionReferences = symbolReferencesIn( + declaration.body, + activeRegionSymbol, + ); if ( - ts.isBinaryExpression(node) - && ( - node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken - || node.operatorToken.kind === ts.SyntaxKind.BarBarToken - || node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken - ) + activeRegionReferences.length !== 2 || + !activeRegionReferences.includes(activeRegionDeclaration.name) || + !activeRegionReferences.includes(activeRegionExpression) ) { - return combineScratchOriginProofs([ - proveExactScratchRegionOrigin( - node.left, - projection, - new Set(seen), - ), - proveExactScratchRegionOrigin( - node.right, - projection, - new Set(seen), - ), - ]); + return null; } - if (projection.length > 0 && ts.isObjectLiteralExpression(node)) { - const [head, ...tail] = projection; - if (head.kind !== "property") return SCRATCH_ORIGIN_UNSAFE; - const values: ts.Expression[] = []; - for (const property of node.properties) { - if ( - ts.isPropertyAssignment(property) - && propertyNameText(property.name) === head.name - ) { - values.push(property.initializer); - } else if ( - ts.isShorthandPropertyAssignment(property) - && property.name.text === head.name - ) { - values.push(property.name); - } else if ( - ts.isMethodDeclaration(property) - || ts.isGetAccessorDeclaration(property) - || ts.isSpreadAssignment(property) - ) { - // A getter or spread can compute/replace the projected property at - // runtime. Do not infer provenance from its structural type. - return SCRATCH_ORIGIN_UNSAFE; - } - } - if (values.length === 0) return SCRATCH_ORIGIN_EMPTY; - return combineScratchOriginProofs( - values.map((value) => - proveExactScratchRegionOrigin(value, tail, new Set(seen)) - ), - ); + + const activeTokenExpression = unwrapExpression(commitCall.arguments[2]!); + if (!ts.isIdentifier(activeTokenExpression)) return null; + const activeTokenSymbol = canonicalSymbol( + checker, + checker.getSymbolAtLocation(activeTokenExpression), + ); + const activeTokenDeclaration = activeTokenSymbol?.valueDeclaration; + if ( + !activeTokenSymbol || + !activeTokenDeclaration || + !ts.isVariableDeclaration(activeTokenDeclaration) || + constVariableSymbol(activeTokenDeclaration) !== activeTokenSymbol || + !activeTokenDeclaration.initializer + ) { + return null; + } + const activeTokenReservationExpression = exactPropertyReceiverIdentifier( + activeTokenDeclaration.initializer, + "token", + reservationSymbol, + ); + if (!activeTokenReservationExpression) return null; + const activeTokenReferences = symbolReferencesIn( + declaration.body, + activeTokenSymbol, + ); + if ( + activeTokenReferences.length !== 2 || + !activeTokenReferences.includes(activeTokenDeclaration.name) || + !activeTokenReferences.includes(activeTokenExpression) + ) { + return null; } if ( - projection.length > 0 - && ( - node.kind === ts.SyntaxKind.ThisKeyword - || ts.isNewExpression(node) - ) + activeRegionDeclaration.parent.parent.parent !== + activeTokenDeclaration.parent.parent.parent || + !( + activeRegionDeclaration.getStart() < activeTokenDeclaration.getStart() + ) || + !(activeTokenDeclaration.getEnd() < withLeaseCall.getStart()) ) { - if (ts.isNewExpression(node)) { - const constructor = unwrapExpression(node.expression); - if ( - ts.isIdentifier(constructor) - && constructor.text === "Proxy" - && hasIntrinsicLibValueDeclaration( - symbolAtExpression(checker, constructor), - ) - ) { - return SCRATCH_ORIGIN_UNSAFE; - } - } - const [head, ...tail] = projection; - if (head.kind !== "property") return SCRATCH_ORIGIN_UNSAFE; - const property = declaredPropertySymbol( - checker.getTypeAtLocation(node), - head.name, - ); - if (!property || invalidSeededScratchRegions.has(property)) { - return SCRATCH_ORIGIN_UNSAFE; - } - if ( - tail.length === 0 - && isSeededScratchRegionKey(property) - ) { - return SCRATCH_ORIGIN_EXACT; - } - const writes = constraintsByTarget.get(property) ?? []; - if (writes.length === 0) return SCRATCH_ORIGIN_UNSAFE; - const nextSeen = new Set(seen); - nextSeen.add(property); - return combineScratchOriginProofs( - writes.map((constraint) => - proveExactScratchRegionOrigin( - constraint.expression, - [...(constraint.projection ?? []), ...tail], - new Set(nextSeen), - ) - ), + return null; + } + + const scratchParameter = callback.parameters[0]!; + const scratchSymbol = canonicalSymbol( + checker, + checker.getSymbolAtLocation(scratchParameter.name as ts.Identifier), + ); + if (!scratchSymbol) return null; + const copyCalls = allCalls.filter((call) => { + if (enclosingFunction(call) !== callback) return false; + const callee = unwrapExpression(call.expression); + return ( + ts.isPropertyAccessExpression(callee) && + callee.name.text === "copyFrom" && + exactSymbolReference(callee.expression, scratchSymbol) ); + }); + if ( + copyCalls.length !== 1 || + copyCalls[0]!.arguments.length !== 4 || + !exactSymbolReference(copyCalls[0]!.arguments[0]!, blobBytesSymbol) || + !( + ts.isNumericLiteral(unwrapExpression(copyCalls[0]!.arguments[1]!)) && + Number( + (unwrapExpression(copyCalls[0]!.arguments[1]!) as ts.NumericLiteral) + .text, + ) === 0 + ) || + !( + ts.isNumericLiteral(unwrapExpression(copyCalls[0]!.arguments[2]!)) && + Number( + (unwrapExpression(copyCalls[0]!.arguments[2]!) as ts.NumericLiteral) + .text, + ) === 0 + ) || + !exactSymbolReference(copyCalls[0]!.arguments[3]!, blobLenSymbol) || + !exactPointerWidthConversion(commitCall.arguments[3]!, blobLenSymbol) + ) { + return null; } + const copyStatement = directExpressionCallStatement( + copyCalls[0]!, + callback.body, + ); + const commitResultDeclaration = commitCall.parent; if ( - ts.isPropertyAccessExpression(node) - || ts.isElementAccessExpression(node) + !copyStatement || + !ts.isVariableDeclaration(commitResultDeclaration) || + commitResultDeclaration.initializer !== commitCall || + !constVariableSymbol(commitResultDeclaration) || + !ts.isVariableDeclarationList(commitResultDeclaration.parent) || + commitResultDeclaration.parent.declarations.length !== 1 || + !ts.isVariableStatement(commitResultDeclaration.parent.parent) || + commitResultDeclaration.parent.parent.parent !== callback.body ) { - const property = accessedPropertyName(node); - if (property === null) return SCRATCH_ORIGIN_UNSAFE; - return proveExactScratchRegionOrigin( - node.expression, - [{ kind: "property", name: property }, ...projection], - seen, - ); + return null; } - if (ts.isCallExpression(node)) { - const declaration = checker.getResolvedSignature(node)?.declaration; - if ( - declaration - && isInProgram(programSources, declaration) - && hasBody(declaration) - && !seen.has(declaration) - ) { - if (ts.isMethodDeclaration(declaration)) { - const receiver = callReceiver(node); - if ( - !receiver - || !isExactMethodReceiver(receiver, declaration) - ) { - return SCRATCH_ORIGIN_UNSAFE; - } - } - if (invalidSeededScratchRegions.has(declaration)) { - return SCRATCH_ORIGIN_UNSAFE; - } - const writes = constraintsByTarget.get(declaration) ?? []; - if (writes.length === 0) return SCRATCH_ORIGIN_UNSAFE; - const nextSeen = new Set(seen); - nextSeen.add(declaration); - return combineScratchOriginProofs( - writes.map((constraint) => - proveExactScratchRegionOrigin( - constraint.expression, - [...(constraint.projection ?? []), ...projection], - new Set(nextSeen), - ) - ), - ); - } + const commitStatement = commitResultDeclaration.parent.parent; + if ( + callback.body.statements.indexOf(copyStatement) >= + callback.body.statements.indexOf(commitStatement) + ) { + return null; } - const symbol = scratchOriginSymbolAtExpression(node); + const scratchReferences = symbolReferencesIn(callback, scratchSymbol); if ( - projection.length === 0 - && symbol - && isSeededScratchRegionKey(symbol) - && !invalidSeededScratchRegions.has(symbol) + scratchReferences.length !== 2 || + !scratchReferences.includes(scratchParameter.name as ts.Identifier) ) { - return SCRATCH_ORIGIN_EXACT; + return null; } - if (!symbol || seen.has(symbol)) return SCRATCH_ORIGIN_UNSAFE; - const writes = constraintsByTarget.get(symbol) ?? []; - if (writes.length === 0) return SCRATCH_ORIGIN_UNSAFE; - const nextSeen = new Set(seen); - nextSeen.add(symbol); - // WHY: region provenance is a must-property. Every value ever written to - // a field/local/helper return must be either nullish or independently - // derived from the reviewed allocator. One fake, projected container - // value, or unresolved helper poisons the origin instead of being hidden - // by the general ownership lattice's may-taint. - return combineScratchOriginProofs( - writes.map((constraint) => - proveExactScratchRegionOrigin( - constraint.expression, - [...(constraint.projection ?? []), ...projection], - new Set(nextSeen), - ) - ), - ); - }; - const isExactScratchRegionOrigin = ( - expression: ts.Expression, - ): boolean => - proveExactScratchRegionOrigin(expression) === SCRATCH_ORIGIN_EXACT; - for (const constraint of constraints) { - const seeded = stateFor(seededValueStates, constraint.target); - const source = projectState( - expressionState( - constraint.expression, - checker, - states, - programSources, - ), - constraint.projection, + const beginCalls = allCalls.filter( + (call) => privateMethodCallName(call) === "#beginLargeSpawnScratch", ); if ( - (seeded.instance & KERNEL_OWNER) !== 0 - && (source.instance & KERNEL_OWNER) === 0 - && !isNullishSeedInitializer(constraint.expression) - && !isIntrinsicWebAssemblyInstantiate(constraint.expression) + beginCalls.length !== 1 || + beginCalls[0]!.arguments.length !== 2 || + !exactSymbolReference(beginCalls[0]!.arguments[0]!, blobLenSymbol) || + !exactSymbolReference(beginCalls[0]!.arguments[1]!, entrySymbol) ) { - invalidSeedAssignmentExpressions.add(constraint.expression); + return null; } - } - - // Scratch-region trust is a must-provenance property. The general ownership - // lattice intentionally records possible capability flow, but a conditional, - // mutable alias, helper return, or container can combine a real factory value - // with a structural fake. Only an exact seed or direct factory result, plus - // immutable const aliases, can mint a lease callback. - let invalidScratchSeedChanged = true; - while (invalidScratchSeedChanged) { - invalidScratchSeedChanged = false; - for (const constraint of constraints) { - if ( - !isSeededScratchRegionKey(constraint.target) - || isNullishSeedInitializer(constraint.expression) - || ( - (constraint.projection?.length ?? 0) === 0 - && isExactScratchRegionOrigin(constraint.expression) - ) - ) { - continue; - } - invalidSeedAssignmentExpressions.add(constraint.expression); - if (!invalidSeededScratchRegions.has(constraint.target)) { - invalidSeededScratchRegions.add(constraint.target); - invalidScratchSeedChanged = true; - } + const beginCall = beginCalls[0]!; + const begunDeclaration = beginCall.parent; + if ( + !ts.isVariableDeclaration(begunDeclaration) || + begunDeclaration.initializer !== beginCall || + !ts.isIdentifier(begunDeclaration.name) + ) { + return null; } - } - - const reflectedSeedMutationCalls = new Set(); - type ReflectiveScratchMutation = - | "assign" - | "defineProperties" - | "defineProperty" - | "set" - | "setPrototypeOf"; - const REFLECTIVE_SCRATCH_MUTATIONS = new Set([ - "assign", - "defineProperties", - "defineProperty", - "set", - "setPrototypeOf", - ]); - const reflectiveMutationFromDeclaration = ( - declaration: ts.Declaration | undefined, - ): ReflectiveScratchMutation | null => { - if (!declaration || !isIntrinsicLibDeclaration(declaration)) return null; - const declarationProperty = (declaration as ts.NamedDeclaration).name; - const name = declarationProperty - && ( - ts.isIdentifier(declarationProperty) - || ts.isStringLiteralLike(declarationProperty) - || ts.isNumericLiteral(declarationProperty) + const begunSymbol = canonicalSymbol( + checker, + checker.getSymbolAtLocation(begunDeclaration.name), + ); + if (!begunSymbol || constVariableSymbol(begunDeclaration) !== begunSymbol) { + return null; + } + const reservationAssignments = allAssignments.filter((assignment) => + exactSymbolReference(assignment.left, reservationSymbol), + ); + const reservationAssignment = reservationAssignments[0]; + const reservationAssignmentLeft = reservationAssignment + ? unwrapExpression(reservationAssignment.left) + : null; + const begunReservationExpression = reservationAssignment + ? exactPropertyReceiverIdentifier( + reservationAssignment.right, + "reservation", + begunSymbol, ) - ? declarationProperty.text : null; if ( - !name - || !REFLECTIVE_SCRATCH_MUTATIONS.has( - name as ReflectiveScratchMutation, - ) + reservationAssignments.length !== 1 || + !reservationAssignment || + !reservationAssignmentLeft || + !ts.isIdentifier(reservationAssignmentLeft) || + !begunReservationExpression ) { return null; } - let owner = signatureOwnerName(declaration); - if (!owner) { - for ( - let current: ts.Node | undefined = declaration.parent; - current; - current = current.parent - ) { + + const transactionTries = allTryStatements.filter( + (statement) => + statement.finallyBlock && + nodeInside(beginCall, statement.tryBlock) && + nodeInside(commitCall, statement.tryBlock), + ); + if (transactionTries.length !== 1) return null; + const transactionTry = transactionTries[0]!; + const beginStatement = directCallStatement( + beginCall, + transactionTry.tryBlock, + ); + const reservationAssignmentStatement = + ts.isExpressionStatement(reservationAssignment.parent) && + reservationAssignment.parent.expression === reservationAssignment && + reservationAssignment.parent.parent === transactionTry.tryBlock + ? reservationAssignment.parent + : null; + + let liveRegionIf: ts.IfStatement | null = null; + let liveRegionConditionReference: ts.Identifier | null = null; + let liveRegionBranch: ts.Statement | null = null; + let ancestor: ts.Node = activeRegionDeclaration; + while (ancestor.parent && ancestor !== transactionTry.tryBlock) { + const parent = ancestor.parent; + if (ts.isIfStatement(parent)) { + let condition = unwrapExpression(parent.expression); + let negated = false; if ( - ts.isModuleDeclaration(current) - && ts.isIdentifier(current.name) + ts.isPrefixUnaryExpression(condition) && + condition.operator === ts.SyntaxKind.ExclamationToken ) { - owner = current.name.text; + negated = true; + condition = unwrapExpression(condition.operand); + } + const conditionReference = exactPropertyReceiverIdentifier( + condition, + "region", + reservationSymbol, + ); + const branch = negated ? parent.elseStatement : parent.thenStatement; + if ( + conditionReference && + branch && + nodeInside(activeRegionDeclaration, branch) + ) { + liveRegionIf = parent; + liveRegionConditionReference = conditionReference; + liveRegionBranch = branch; break; } } + ancestor = parent; } if ( - (owner === "ObjectConstructor" - && ( - name === "assign" - || name === "defineProperties" - || name === "defineProperty" - || name === "setPrototypeOf" - )) - || ( - owner === "Reflect" - && (name === "set" || name === "setPrototypeOf") - ) + !beginStatement || + !reservationAssignmentStatement || + !liveRegionIf || + !liveRegionConditionReference || + !liveRegionBranch || + liveRegionIf.parent !== transactionTry.tryBlock || + !ts.isBlock(liveRegionBranch) || + !nodeInside(activeTokenDeclaration, liveRegionBranch) || + !nodeInside(withLeaseCall, liveRegionBranch) || + transactionTry.tryBlock.statements.length !== 3 || + transactionTry.tryBlock.statements[0] !== beginStatement || + transactionTry.tryBlock.statements[1] !== + reservationAssignmentStatement || + transactionTry.tryBlock.statements[2] !== liveRegionIf ) { - return name as ReflectiveScratchMutation; + return null; } - return null; - }; - const reflectiveMutationIdentity = ( - expression: ts.Expression, - seen = new Set(), - ): ReflectiveScratchMutation | null => { - const node = unwrapExpression(expression); - if (ts.isCallExpression(node) && callPropertyName(node) === "bind") { - const receiver = callReceiver(node); - return receiver - ? reflectiveMutationIdentity(receiver, seen) + const activeRegionStatement = + ts.isVariableDeclarationList(activeRegionDeclaration.parent) && + ts.isVariableStatement(activeRegionDeclaration.parent.parent) && + activeRegionDeclaration.parent.parent.parent === liveRegionBranch + ? activeRegionDeclaration.parent.parent + : null; + const activeTokenStatement = + ts.isVariableDeclarationList(activeTokenDeclaration.parent) && + ts.isVariableStatement(activeTokenDeclaration.parent.parent) && + activeTokenDeclaration.parent.parent.parent === liveRegionBranch + ? activeTokenDeclaration.parent.parent : null; + const withLeaseAssignment = withLeaseCall.parent; + const withLeaseStatement = + ts.isBinaryExpression(withLeaseAssignment) && + isSimpleAssignment(withLeaseAssignment) && + withLeaseAssignment.right === withLeaseCall && + ts.isExpressionStatement(withLeaseAssignment.parent) && + withLeaseAssignment.parent.expression === withLeaseAssignment && + withLeaseAssignment.parent.parent === liveRegionBranch + ? withLeaseAssignment.parent + : null; + if ( + !activeRegionStatement || + !activeTokenStatement || + !withLeaseStatement || + liveRegionBranch.statements.length !== 3 || + liveRegionBranch.statements[0] !== activeRegionStatement || + liveRegionBranch.statements[1] !== activeTokenStatement || + liveRegionBranch.statements[2] !== withLeaseStatement + ) { + return null; } - const signatures = checker.getSignaturesOfType( - checker.getTypeAtLocation(node), - ts.SignatureKind.Call, + + const rawCancelCalls = allCalls.filter( + (call) => privateMethodCallName(call) === "#cancelLargeSpawnScratch", ); - for (const signature of signatures) { - const mutation = reflectiveMutationFromDeclaration( - signature.declaration, + const cancelCall = rawCancelCalls[0]; + const cancelReservationExpression = + cancelCall && + cancelCall.arguments.length === 2 && + exactSymbolReference(cancelCall.arguments[1]!, entrySymbol) + ? exactPropertyReceiverIdentifier( + cancelCall.arguments[0]!, + "token", + reservationSymbol, + ) + : null; + const revokeCalls = allCalls.filter((call) => { + const callee = unwrapExpression(call.expression); + return ( + ts.isPropertyAccessExpression(callee) && + callee.name.text === "revoke" && + exactPropertyOfSymbol(callee.expression, "region", reservationSymbol) ); - if (mutation) return mutation; - } - const symbol = scratchOriginSymbolAtExpression(node); - if (!symbol || seen.has(symbol)) return null; - const declaration = symbol.valueDeclaration; + }); + const revokeCall = revokeCalls[0]; + const revokeCallee = revokeCall + ? unwrapExpression(revokeCall.expression) + : null; + const revokeReservationExpression = + revokeCallee && ts.isPropertyAccessExpression(revokeCallee) + ? exactPropertyReceiverIdentifier( + revokeCallee.expression, + "region", + reservationSymbol, + ) + : null; if ( - declaration - && ts.isVariableDeclaration(declaration) - && declaration.initializer + rawCancelCalls.length !== 1 || + !cancelCall || + !cancelReservationExpression || + revokeCalls.length !== 1 || + !revokeCall || + !revokeReservationExpression ) { - const nextSeen = new Set(seen); - nextSeen.add(symbol); - return reflectiveMutationIdentity( - declaration.initializer, - nextSeen, - ); + return null; } - return null; - }; - const reflectiveMutationInvocation = ( - call: ts.CallExpression, - ): { - readonly mutation: ReflectiveScratchMutation; - readonly args: readonly ts.Expression[]; - } | null => { - const callee = unwrapExpression(call.expression); + + const cleanupGuards = transactionTry.finallyBlock!.statements.filter( + (statement): statement is ts.IfStatement => + ts.isIfStatement(statement) && + !statement.elseStatement && + ts.isIdentifier(unwrapExpression(statement.expression)) && + exactSymbolReference(statement.expression, reservationSymbol) && + ts.isBlock(statement.thenStatement) && + nodeInside(revokeCall, statement.thenStatement) && + nodeInside(cancelCall, statement.thenStatement), + ); + if (cleanupGuards.length !== 1) return null; + const cleanupGuard = cleanupGuards[0]!; + const cleanupGuardReference = unwrapExpression( + cleanupGuard.expression, + ) as ts.Identifier; + const cleanupBlock = cleanupGuard.thenStatement as ts.Block; + + const simpleRevokeStatement = directExpressionCallStatement( + revokeCall, + cleanupBlock, + ); + const simpleCancelStatement = directExpressionCallStatement( + cancelCall, + cleanupBlock, + ); + const simpleCleanup = + cleanupBlock.statements.length === 2 && + cleanupBlock.statements[0] === simpleRevokeStatement && + cleanupBlock.statements[1] === simpleCancelStatement; + + let fatalAwareCleanup = false; if ( - (ts.isPropertyAccessExpression(callee) - || ts.isElementAccessExpression(callee)) - && ( - accessedPropertyName(callee) === "call" - || accessedPropertyName(callee) === "apply" - ) + !simpleCleanup && + cleanupBlock.statements.length === 2 && + ts.isTryStatement(cleanupBlock.statements[0]) && + ts.isIfStatement(cleanupBlock.statements[1]) ) { - const mutation = reflectiveMutationIdentity(callee.expression); - if (!mutation) return null; - if (accessedPropertyName(callee) === "call") { - return { mutation, args: call.arguments.slice(1) }; - } - const applied = call.arguments[1] - ? unwrapExpression(call.arguments[1]) + const revokeTry = cleanupBlock.statements[0]; + const cancelGuard = cleanupBlock.statements[1]; + const fatalIdentifier = nullComparisonIdentifier( + cancelGuard.expression, + ts.SyntaxKind.EqualsEqualsEqualsToken, + ); + const fatalSymbol = fatalIdentifier + ? canonicalSymbol(checker, checker.getSymbolAtLocation(fatalIdentifier)) + : undefined; + const fatalDeclaration = fatalSymbol?.valueDeclaration; + const cancelGuardBody = cancelGuard.thenStatement; + const cancelTry = + ts.isBlock(cancelGuardBody) && + cancelGuardBody.statements.length === 1 && + ts.isTryStatement(cancelGuardBody.statements[0]) + ? cancelGuardBody.statements[0] + : null; + const directRevokeStatement = directExpressionCallStatement( + revokeCall, + revokeTry.tryBlock, + ); + const directCancelStatement = cancelTry + ? directCallStatement(cancelCall, cancelTry.tryBlock) : null; - return applied && ts.isArrayLiteralExpression(applied) - ? { - mutation, - args: applied.elements.filter( - (element): element is ts.Expression => - !ts.isOmittedExpression(element) - && !ts.isSpreadElement(element), - ), - } + const cleanupFatalAssignment = ( + block: ts.Block | undefined, + acceptedOperators: readonly ts.SyntaxKind[], + ): boolean => + Boolean( + block && + fatalSymbol && + block.statements.some((statement) => { + if ( + !ts.isExpressionStatement(statement) || + !ts.isBinaryExpression(statement.expression) || + !acceptedOperators.includes( + statement.expression.operatorToken.kind, + ) || + !exactSymbolReference(statement.expression.left, fatalSymbol) + ) { + return false; + } + const right = unwrapExpression(statement.expression.right); + const callee = ts.isNewExpression(right) + ? unwrapExpression(right.expression) + : null; + return ( + callee !== null && + ts.isIdentifier(callee) && + callee.text === "KernelTransferExecuteTrapError" + ); + }), + ); + const resetGuard = transactionTry.finallyBlock!.statements[1]; + const transactionBlock = transactionTry.parent; + const postTransactionThrow = ts.isBlock(transactionBlock) + ? transactionBlock.statements.find( + (statement) => + statement.getStart() > transactionTry.getEnd() && + ts.isIfStatement(statement) && + fatalSymbol && + exactNullComparison( + statement.expression, + fatalSymbol, + ts.SyntaxKind.ExclamationEqualsEqualsToken, + ) && + ts.isThrowStatement(statement.thenStatement) && + exactSymbolReference( + statement.thenStatement.expression, + fatalSymbol, + ), + ) + : undefined; + const resetAssignment = + resetGuard && + ts.isIfStatement(resetGuard) && + fatalSymbol && + exactNullComparison( + resetGuard.expression, + fatalSymbol, + ts.SyntaxKind.EqualsEqualsEqualsToken, + ) && + ts.isExpressionStatement(resetGuard.thenStatement) && + ts.isBinaryExpression(resetGuard.thenStatement.expression) && + isSimpleAssignment(resetGuard.thenStatement.expression) + ? resetGuard.thenStatement.expression + : null; + const resetTarget = resetAssignment + ? unwrapExpression(resetAssignment.left) : null; + fatalAwareCleanup = Boolean( + fatalSymbol && + fatalDeclaration && + ts.isVariableDeclaration(fatalDeclaration) && + fatalDeclaration.initializer && + unwrapExpression(fatalDeclaration.initializer).kind === + ts.SyntaxKind.NullKeyword && + !cancelGuard.elseStatement && + exactNullComparison( + cancelGuard.expression, + fatalSymbol, + ts.SyntaxKind.EqualsEqualsEqualsToken, + ) && + revokeTry.tryBlock.statements.length === 1 && + revokeTry.tryBlock.statements[0] === directRevokeStatement && + cleanupFatalAssignment(revokeTry.catchClause?.block, [ + ts.SyntaxKind.EqualsToken, + ts.SyntaxKind.QuestionQuestionEqualsToken, + ]) && + cancelTry && + cancelTry.tryBlock.statements[0] === directCancelStatement && + cleanupFatalAssignment(cancelTry.catchClause?.block, [ + ts.SyntaxKind.EqualsToken, + ]) && + transactionTry.finallyBlock!.statements.length === 2 && + resetGuard !== undefined && + resetAssignment && + resetTarget && + ts.isPropertyAccessExpression(resetTarget) && + ts.isPrivateIdentifier(resetTarget.name) && + resetTarget.name.text === "#largeSpawnScratchInUse" && + resetTarget.expression.kind === ts.SyntaxKind.ThisKeyword && + unwrapExpression(resetAssignment.right).kind === + ts.SyntaxKind.FalseKeyword && + postTransactionThrow, + ); } - const mutation = reflectiveMutationIdentity(callee); - return mutation ? { mutation, args: call.arguments } : null; - }; - const trackedScratchProperties = ( - expression: ts.Expression, - ): ts.Symbol[] => { - const type = checker.getTypeAtLocation(unwrapExpression(expression)); - return checker.getPropertiesOfType(type) - .map( - (property) => - declaredPropertySymbol(type, property.name) - ?? canonicalSymbol(checker, property) - ?? property, + if (!simpleCleanup && !fatalAwareCleanup) return null; + + const allowedReservationReferences = new Set([ + reservationDeclaration.name, + reservationAssignmentLeft, + reservationExpression, + activeTokenReservationExpression, + liveRegionConditionReference, + cleanupGuardReference, + revokeReservationExpression, + cancelReservationExpression, + ]); + const reservationReferences = symbolReferencesIn( + declaration.body, + reservationSymbol, + ); + if ( + reservationReferences.length !== allowedReservationReferences.size || + reservationReferences.some( + (reference) => !allowedReservationReferences.has(reference), ) - .filter( - (symbol) => - isSeededScratchRegionKey(symbol) - || stateFor(states, symbol).scratchRegion - || Boolean( - symbol.declarations?.some( - (declaration) => - hasBody(declaration) - && stateFor(states, declaration).scratchRegion, - ), - ), - ); + ) { + return null; + } + return commitCall; }; - const markReflectedSeedMutation = ( + const reviewedReservedSpawnCall = reviewReservedSpawnTransaction(); + const reviewedReservedChannelDispatch = reviewTwoPhaseLeaseMethod( + "#executeReservedChannelDispatch", + "kernel_transfer_channel_execute", + ); + const reviewedCapacityOwnedChannel = reviewedReservedChannelDispatch + ? reviewTwoPhaseLeaseMethod( + "#executeCapacityOwnedChannel", + "kernel_handle_channel", + reviewedReservedChannelDispatch.declaration, + ) + : null; + const reviewedTwoPhaseLeaseCallbackCalls = new Set(); + if (reviewedReservedChannelDispatch) { + for (const call of reviewedReservedChannelDispatch.leaseCallbackCalls) { + reviewedTwoPhaseLeaseCallbackCalls.add(call); + } + } + if (reviewedCapacityOwnedChannel) { + for (const call of reviewedCapacityOwnedChannel.leaseCallbackCalls) { + reviewedTwoPhaseLeaseCallbackCalls.add(call); + } + } + const inlineTwoPhaseLeaseCallback = ( call: ts.CallExpression, - target: ts.Expression | undefined, - property: string | null, - ): void => { - if (!target) return; - const seeded = trackedScratchProperties(target); - const matches = property === null - ? seeded - : seeded.filter((symbol) => symbol.name === property); - if (matches.length === 0) return; - reflectedSeedMutationCalls.add(call); - for (const symbol of matches) { - invalidSeededScratchRegions.add(symbol); - for (const declaration of symbol.declarations ?? []) { - if (hasBody(declaration)) { - invalidSeededScratchRegions.add(declaration); - } - } + argumentIndex: number, + ): ts.FunctionLikeDeclaration | null => { + const argument = call.arguments[argumentIndex]; + if (!argument) return null; + const callback = unwrapExpression(argument); + if ( + !ts.isArrowFunction(callback) || + callback.asteriskToken || + callback.parameters.length !== 1 || + !callback.parameters[0] || + !ts.isIdentifier(callback.parameters[0].name) || + callback.parameters[0].dotDotDotToken || + callback.parameters[0].initializer || + callback.parameters[0].questionToken || + callback.modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword, + ) + ) { + return null; } + return callback; }; - for (const sourceFile of sourceFiles) { - const findReflectedSeedMutations = (node: ts.Node): void => { - if (ts.isCallExpression(node)) { - const invocation = reflectiveMutationInvocation(node); - const target = invocation?.args[0]; - if (invocation && target) { + const invalidTwoPhaseLeaseCalls = new Set(); + if (reviewedCapacityOwnedChannel) { + for (const sourceFile of sourceFiles) { + const collect = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const resolved = checker.getResolvedSignature(node)?.declaration; + const receiver = callReceiver(node); if ( - invocation.mutation === "defineProperty" - || invocation.mutation === "set" + resolved === reviewedCapacityOwnedChannel.declaration && + receiver && + isExactMethodReceiver( + receiver, + reviewedCapacityOwnedChannel.declaration, + ) ) { - const propertyArgument = invocation.args[1]; - const property = propertyArgument - && ( - ts.isStringLiteralLike(propertyArgument) - || ts.isNumericLiteral(propertyArgument) - ) - ? propertyArgument.text - : null; - markReflectedSeedMutation(node, target, property); - } else if (invocation.mutation === "setPrototypeOf") { - markReflectedSeedMutation(node, target, null); - } else { - for (const source of invocation.args.slice(1)) { - const value = unwrapExpression(source); - if (!ts.isObjectLiteralExpression(value)) { - markReflectedSeedMutation(node, target, null); - continue; - } - for (const entry of value.properties) { - const property = propertyNameText(entry.name); - markReflectedSeedMutation(node, target, property); + const stage = inlineTwoPhaseLeaseCallback( + node, + reviewedCapacityOwnedChannel.stageParameterIndex, + ); + const finish = inlineTwoPhaseLeaseCallback( + node, + reviewedCapacityOwnedChannel.finishParameterIndex, + ); + if ( + node.arguments.length !== + reviewedCapacityOwnedChannel.declaration.parameters.length || + !stage || + !finish + ) { + invalidTwoPhaseLeaseCalls.add(node); + } else { + for (const callback of [stage, finish]) { + const parameter = callback.parameters[0]!; + const symbol = canonicalSymbol( + checker, + checker.getSymbolAtLocation(parameter.name as ts.Identifier), + ); + if (symbol) leaseOriginCallbacks.set(symbol, callback); } } } } - } - ts.forEachChild(node, findReflectedSeedMutations); - }; - findReflectedSeedMutations(sourceFile); - } - - for (const [origin, callback] of leaseOriginCallbacks) { - const call = leaseCallbackCalls.get(callback); - const receiver = call ? callReceiver(call) : null; - if ( - !receiver - || !isExactScratchRegionOrigin(receiver) - ) { - leaseOriginCallbacks.delete(origin); + ts.forEachChild(node, collect); + }; + collect(sourceFile); } } const activeLeaseCallbacks = new Set(leaseOriginCallbacks.values()); - - const leaseOriginSymbol = ( - expression: ts.Expression, - seen = new Set(), - ): ts.Symbol | undefined => { - const node = unwrapExpression(expression); - const symbol = symbolAtExpression(checker, node); - if (!symbol) return undefined; - if (ts.isIdentifier(node) && !seen.has(symbol)) { - const initializer = immutableConstInitializer(node); - if (initializer) { - seen.add(symbol); - return leaseOriginSymbol(initializer, seen); - } - } - return symbol; - }; - const enclosingFunction = ( - node: ts.Node, - ): ts.FunctionLikeDeclaration | null => { - for ( - let current: ts.Node | undefined = node.parent; - current; - current = current.parent - ) { - if (hasBody(current)) return current; - } - return null; - }; - const isTransparentScratchUseWrapper = ( - parent: ts.Node, - child: ts.Node, - ): parent is - | ts.ParenthesizedExpression - | ts.AsExpression - | ts.TypeAssertion - | ts.NonNullExpression - | ts.SatisfiesExpression => - ( - ts.isParenthesizedExpression(parent) - || ts.isAsExpression(parent) - || ts.isTypeAssertionExpression(parent) - || ts.isNonNullExpression(parent) - || ts.isSatisfiesExpression(parent) - ) - && parent.expression === child; - const directCallForMember = ( - member: ts.Expression, - ): ts.CallExpression | null => { - let value: ts.Expression = member; - while ( - value.parent - && isTransparentScratchUseWrapper(value.parent, value) - ) { - value = value.parent; - } - return value.parent - && ts.isCallExpression(value.parent) - && value.parent.expression === value - ? value.parent - : null; - }; - const typeHasScratchMember = ( - expression: ts.Expression, - member: "address" | "invokeKernelExport" | "withLease", - ): boolean => { - const symbol = canonicalSymbol( - checker, - checker.getPropertyOfType( - checker.getTypeAtLocation(unwrapExpression(expression)), - member, - ), - ); - if (member === "address") return isScratchAddressSymbol(symbol); - if (member === "withLease") return isScratchWithLeaseSymbol(symbol); - return isScratchLeaseMemberSymbol(symbol); - }; - const expressionTypeHasScratchMember = ( - expression: ts.Expression, - member: "address" | "invokeKernelExport" | "withLease", - ): boolean => { - const type = checker.getTypeAtLocation(expression); - const members = type.isUnion() - ? type.types.filter( - (part) => - (part.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) === 0, - ) - : [type]; - return members.length > 0 && members.every((part) => { - const symbol = canonicalSymbol( - checker, - checker.getPropertyOfType(part, member), - ); - if (member === "address") return isScratchAddressSymbol(symbol); - if (member === "withLease") return isScratchWithLeaseSymbol(symbol); - return isScratchLeaseMemberSymbol(symbol); - }); - }; - const isRealScratchWithLeaseCall = ( - call: ts.CallExpression, - ): boolean => { + const isRealScratchWithLeaseCall = (call: ts.CallExpression): boolean => { if (!isKernelScratchWithLeaseCall(call, checker)) return false; const receiver = callReceiver(call); - return Boolean( - receiver - && isExactScratchRegionOrigin(receiver), - ); + return Boolean(receiver && isExactScratchRegionOrigin(receiver)); }; - const isRealScratchLeaseMemberCall = ( - call: ts.CallExpression, - ): boolean => { + const isRealScratchLeaseMemberCall = (call: ts.CallExpression): boolean => { const callee = unwrapExpression(call.expression); if (!ts.isPropertyAccessExpression(callee)) return false; if (!isScratchLeaseMemberSymbol(symbolAtExpression(checker, callee))) { return false; } + if (reviewedEntryScratchInvoker?.leaseCalls.has(call)) { + return true; + } + for (const consumer of reviewedLinearLeaseConsumers.values()) { + if (consumer.leaseCalls.has(call)) return true; + } const origin = leaseOriginSymbol(callee.expression); const callback = origin ? leaseOriginCallbacks.get(origin) : undefined; - return Boolean( - callback - && enclosingFunction(call) === callback, - ); + return Boolean(callback && enclosingFunction(call) === callback); }; - const isIdentifierValueReference = ( - identifier: ts.Identifier, - ): boolean => { + const isIdentifierValueReference = (identifier: ts.Identifier): boolean => { const parent = identifier.parent; if ( - ts.isShorthandPropertyAssignment(parent) - && parent.name === identifier + ts.isShorthandPropertyAssignment(parent) && + parent.name === identifier ) { return true; } if ( - (parent as ts.NamedDeclaration).name === identifier - || ( - ts.isBindingElement(parent) - && ( - parent.name === identifier - || parent.propertyName === identifier - ) - ) - || ( - ts.isPropertyAccessExpression(parent) - && parent.name === identifier - ) - || ( - ts.isPropertyAssignment(parent) - && parent.name === identifier - ) + (parent as ts.NamedDeclaration).name === identifier || + (ts.isBindingElement(parent) && + (parent.name === identifier || parent.propertyName === identifier)) || + (ts.isPropertyAccessExpression(parent) && parent.name === identifier) || + (ts.isPropertyAssignment(parent) && parent.name === identifier) ) { return false; } @@ -3436,8 +6987,8 @@ export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { ): ts.Expression | null => { let value = expression; while ( - value.parent - && isTransparentScratchUseWrapper(value.parent, value) + value.parent && + isTransparentScratchUseWrapper(value.parent, value) ) { value = value.parent; if (!retainsCapability(value)) return null; @@ -3455,75 +7006,208 @@ export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { if (!value) return false; const declaration = value.parent; return Boolean( - declaration - && ts.isVariableDeclaration(declaration) - && declaration.initializer === value - && ts.isIdentifier(declaration.name) - && ts.isVariableDeclarationList(declaration.parent) - && (declaration.parent.flags & ts.NodeFlags.Const) !== 0 - && retainsCapability(declaration.name), + declaration && + ts.isVariableDeclaration(declaration) && + declaration.initializer === value && + ts.isIdentifier(declaration.name) && + ts.isVariableDeclarationList(declaration.parent) && + (declaration.parent.flags & ts.NodeFlags.Const) !== 0 && + retainsCapability(declaration.name), + ); + }; + const isAllowedActiveLeaseReference = ( + identifier: ts.Identifier, + callback: ts.FunctionLikeDeclaration, + ): boolean => { + const retainsLease = (candidate: ts.Expression): boolean => + expressionTypeHasScratchMember(candidate, "invokeKernelExport"); + const value = transparentCapabilityExpression(identifier, retainsLease); + if (!value) return false; + const parent = value.parent; + if ( + parent && + (ts.isPropertyAccessExpression(parent) || + ts.isElementAccessExpression(parent)) && + parent.expression === value && + retainsLease(value) + ) { + const member = symbolAtExpression(checker, parent); + return ( + isScratchLeaseMemberSymbol(member) && + !isScratchAddressSymbol(member) && + !propertyAccessIsMutation(parent) && + enclosingFunction(identifier) === callback + ); + } + if ( + parent && + ts.isCallExpression(parent) && + parent.arguments[1] === value && + isReviewedEntryScratchInvokerCall(parent) && + enclosingFunction(identifier) === callback + ) { + // WHY: this one true-private worker method is statically proved above + // to synchronously forward the lease only to its two genuine invocation + // members. It cannot retain, return, structurally erase, or reflect the + // lease, so callers keep the exact withLease callback lifetime. + return true; + } + if ( + parent && + ts.isCallExpression(parent) && + parent.arguments[0] === value && + reviewedTwoPhaseLeaseCallbackCalls.has(parent) && + enclosingFunction(identifier) === callback + ) { + // WHY: the two exact private dispatchers are re-proved above as rigid + // stage → one kernel export → finish transactions. Their callback + // parameters are never stored, returned, reflected, or invoked outside + // the active withLease callback. + return true; + } + if (parent && ts.isCallExpression(parent)) { + const consumer = reviewedLinearLeaseConsumerForCall(parent); + if ( + consumer && + parent.arguments[consumer.leaseArgumentIndex] === value && + enclosingFunction(identifier) === callback + ) { + // WHY: each admitted true-private helper is re-proved above on every + // audit run. Every reference to its lease parameter must be one direct + // synchronous call to the single reviewed copy member, so the helper + // cannot store, return, reflect, or asynchronously capture the lease. + return true; + } + } + return ( + enclosingFunction(identifier) === callback && + isImmutableCapabilityAlias(identifier, retainsLease) + ); + }; + const propertyAccessIsMutation = ( + access: ts.PropertyAccessExpression | ts.ElementAccessExpression, + ): boolean => { + const parent = access.parent; + return ( + (ts.isBinaryExpression(parent) && + parent.left === access && + isAssignmentOperator(parent.operatorToken.kind)) || + ((ts.isPrefixUnaryExpression(parent) || + ts.isPostfixUnaryExpression(parent)) && + parent.operand === access) || + (ts.isDeleteExpression(parent) && parent.expression === access) ); }; - const isAllowedActiveLeaseReference = ( - identifier: ts.Identifier, - callback: ts.FunctionLikeDeclaration, + const findings: AuditFinding[] = []; + const addFinding = ( + sourceFile: ts.SourceFile, + node: ts.Node, + kind: AuditFinding["kind"], + ): void => { + findings.push(findingFor(options.rootDir, sourceFile, node, kind)); + }; + const kernelExportUseKind = ( + state: ValueState, + ): "kernel-pointer-export-bypass" | "kernel-export-direct-use" | null => { + if (hasAuditedKernelExport(state, kernelScratchExportContract.names)) { + return "kernel-pointer-export-bypass"; + } + return hasAuditedKernelExport(state, auditedKernelExports) + ? "kernel-export-direct-use" + : null; + }; + const addKernelExportUseFinding = ( + sourceFile: ts.SourceFile, + node: ts.Node, + state: ValueState, + ): void => { + const kind = kernelExportUseKind(state); + if (kind !== null) addFinding(sourceFile, node, kind); + }; + const directReflectedKernelExportUseKind = ( + call: ts.CallExpression, + ): "kernel-pointer-export-bypass" | "kernel-export-direct-use" | null => { + const declaration = checker.getResolvedSignature(call)?.declaration; + if (!declaration || !isIntrinsicLibDeclaration(declaration)) return null; + let owner = signatureOwnerName(declaration); + if (!owner) { + for ( + let current: ts.Node | undefined = declaration.parent; + current; + current = current.parent + ) { + if (ts.isModuleDeclaration(current) && ts.isIdentifier(current.name)) { + owner = current.name.text; + break; + } + } + } + const member = + callPropertyName(call) ?? + propertyNameText((declaration as ts.NamedDeclaration).name) ?? + declarationName(declaration); + const readsOneProperty = + (owner === "Reflect" && + (member === "get" || member === "getOwnPropertyDescriptor")) || + (owner === "ObjectConstructor" && member === "getOwnPropertyDescriptor"); + if (readsOneProperty) { + if (call.arguments.length < 2) return null; + const targetState = expressionState( + call.arguments[0]!, + checker, + states, + programSources, + ); + if ((targetState.exportNamespace & KERNEL_OWNER) === 0) return null; + + // WHY: a reflective read returns the raw callable (or a descriptor whose + // value is that callable) without traversing a property-access node. + // Keep dynamic names in the pointer-bearing class because they may select + // any scratch borrower; literal names retain the generated-name split. + const property = unwrapExpression( + immutableConstValue(call.arguments[1]!, checker), + ); + if (!ts.isStringLiteralLike(property)) { + return "kernel-pointer-export-bypass"; + } + if (kernelScratchExportContract.names.has(property.text)) { + return "kernel-pointer-export-bypass"; + } + return auditedKernelExports.has(property.text) + ? "kernel-export-direct-use" + : null; + } + + return null; + }; + const containsKernelExportNamespace = ( + state: ValueState, + seen = new Set(), ): boolean => { - const retainsLease = (candidate: ts.Expression): boolean => - expressionTypeHasScratchMember(candidate, "invokeKernelExport"); - const value = transparentCapabilityExpression(identifier, retainsLease); - if (!value) return false; - const parent = value.parent; - if ( - parent - && ( - ts.isPropertyAccessExpression(parent) - || ts.isElementAccessExpression(parent) - ) - && parent.expression === value - && retainsLease(value) - ) { - const member = symbolAtExpression(checker, parent); - return isScratchLeaseMemberSymbol(member) - && !isScratchAddressSymbol(member) - && !propertyAccessIsMutation(parent) - && enclosingFunction(identifier) === callback; + if (seen.has(state)) return false; + seen.add(state); + if ((state.exportNamespace & KERNEL_OWNER) !== 0) return true; + for (const property of state.properties.values()) { + if (containsKernelExportNamespace(property, seen)) return true; } - return enclosingFunction(identifier) === callback - && isImmutableCapabilityAlias(identifier, retainsLease); + for (const property of state.hiddenProperties.values()) { + if (containsKernelExportNamespace(property, seen)) return true; + } + return state.elements + ? containsKernelExportNamespace(state.elements, seen) + : false; }; - const propertyAccessIsMutation = ( - access: - | ts.PropertyAccessExpression - | ts.ElementAccessExpression, + const expressionContainsKernelExportNamespace = ( + expression: ts.Expression, ): boolean => { - const parent = access.parent; - return ( - ( - ts.isBinaryExpression(parent) - && parent.left === access - && isAssignmentOperator(parent.operatorToken.kind) - ) - || ( - ( - ts.isPrefixUnaryExpression(parent) - || ts.isPostfixUnaryExpression(parent) - ) - && parent.operand === access - ) - || ( - ts.isDeleteExpression(parent) - && parent.expression === access - ) + const node = unwrapExpression(expression); + if (ts.isSpreadElement(node)) { + return expressionContainsKernelExportNamespace(node.expression); + } + return containsKernelExportNamespace( + expressionState(node, checker, states, programSources), ); }; - const findings: AuditFinding[] = []; - const addFinding = ( - sourceFile: ts.SourceFile, - node: ts.Node, - kind: AuditFinding["kind"], - ): void => { - findings.push(findingFor(options.rootDir, sourceFile, node, kind)); - }; const ownershipWitness = ( expression: ts.Expression, form: KernelOwnershipForm, @@ -3542,8 +7226,8 @@ export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { value = property.expression; } if ( - value - && hasKernelOwnership( + value && + hasKernelOwnership( expressionState(value, checker, states, programSources), form, ) @@ -3590,14 +7274,11 @@ export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { witnessExpression?: ts.Expression, ): void => { if ( - !admitReadOnlyView - && hasKernelOwnership(state, "view") - && !( - seededTarget - && hasKernelOwnership( - stateFor(seededValueStates, seededTarget), - "view", - ) + !admitReadOnlyView && + hasKernelOwnership(state, "view") && + !( + seededTarget && + hasKernelOwnership(stateFor(seededValueStates, seededTarget), "view") ) ) { addFinding( @@ -3609,13 +7290,10 @@ export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { ); } if ( - hasKernelOwnership(state, "buffer") - && !( - seededTarget - && hasKernelOwnership( - stateFor(seededValueStates, seededTarget), - "buffer", - ) + hasKernelOwnership(state, "buffer") && + !( + seededTarget && + hasKernelOwnership(stateFor(seededValueStates, seededTarget), "buffer") ) ) { addFinding( @@ -3627,13 +7305,10 @@ export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { ); } if ( - hasKernelOwnership(state, "memory") - && !( - seededTarget - && hasKernelOwnership( - stateFor(seededValueStates, seededTarget), - "memory", - ) + hasKernelOwnership(state, "memory") && + !( + seededTarget && + hasKernelOwnership(stateFor(seededValueStates, seededTarget), "memory") ) ) { addFinding( @@ -3647,33 +7322,27 @@ export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { }; for (const sourceFile of sourceFiles) { + let lastVisitedNode: ts.Node = sourceFile; const visit = (node: ts.Node): void => { + lastVisitedNode = node; if ( - ( - ts.isMethodSignature(node) - || ts.isMethodDeclaration(node) - || ts.isPropertySignature(node) - || ts.isPropertyDeclaration(node) - ) - && isScratchAddressSymbol(symbolForDeclaration(checker, node)) + (ts.isMethodSignature(node) || + ts.isMethodDeclaration(node) || + ts.isPropertySignature(node) || + ts.isPropertyDeclaration(node)) && + isScratchAddressSymbol(symbolForDeclaration(checker, node)) ) { // The opaque export-pointer token replaced the irrevocable numeric // address. Reintroducing this member would reopen every primitive-flow // bypass the contract is intended to eliminate. addFinding(sourceFile, node, "scratch-address-contract"); } - if ( - ts.isExpression(node) - && invalidSeedAssignmentExpressions.has(node) - ) { + if (ts.isExpression(node) && invalidSeedAssignmentExpressions.has(node)) { // A seed is an ownership root, not a permanent blessing for whatever // value is later assigned to that slot. addFinding(sourceFile, node, "scratch-address-contract"); } - if ( - ts.isCallExpression(node) - && reflectedSeedMutationCalls.has(node) - ) { + if (ts.isCallExpression(node) && reflectedSeedMutationCalls.has(node)) { addFinding(sourceFile, node, "scratch-address-contract"); } if (ts.isIdentifier(node) && isIdentifierValueReference(node)) { @@ -3682,8 +7351,8 @@ export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { ? leaseOriginCallbacks.get(leaseOrigin) : undefined; if ( - leaseCallback - && !isAllowedActiveLeaseReference(node, leaseCallback) + leaseCallback && + !isAllowedActiveLeaseReference(node, leaseCallback) ) { // WHY: only a lease minted by this exact synchronous callback can // create an opaque export pointer or invoke the bound kernel export. @@ -3692,59 +7361,52 @@ export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { } } if ( - ts.isVariableDeclaration(node) - && ts.isIdentifier(node.name) - && node.initializer - && expressionState( - node.initializer, - checker, - states, - programSources, - ).scratchRegion - && !expressionTypeHasScratchMember(node.name, "withLease") + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer && + expressionState(node.initializer, checker, states, programSources) + .scratchRegion && + !expressionTypeHasScratchMember(node.name, "withLease") ) { // WHY: callback-origin checking depends on the exact withLease symbol. // A structurally compatible interface erases that identity and could // manufacture an untracked lease parameter. - addFinding( - sourceFile, - node.initializer, - "scratch-address-contract", - ); + addFinding(sourceFile, node.initializer, "scratch-address-contract"); } if ( - ts.isBinaryExpression(node) - && isSimpleAssignment(node) - && expressionState( - node.right, - checker, - states, - programSources, - ).scratchRegion - && !expressionTypeHasScratchMember(node.left, "withLease") + ts.isBinaryExpression(node) && + isSimpleAssignment(node) && + expressionState(node.right, checker, states, programSources) + .scratchRegion && + !expressionTypeHasScratchMember(node.left, "withLease") ) { addFinding(sourceFile, node.right, "scratch-address-contract"); } if ( - ( - ts.isAsExpression(node) - || ts.isTypeAssertionExpression(node) - || ts.isSatisfiesExpression(node) - ) - && expressionState( - node.expression, - checker, - states, - programSources, - ).scratchRegion - && !expressionTypeHasScratchMember(node, "withLease") + (ts.isAsExpression(node) || + ts.isTypeAssertionExpression(node) || + ts.isSatisfiesExpression(node)) && + expressionState(node.expression, checker, states, programSources) + .scratchRegion && + !expressionTypeHasScratchMember(node, "withLease") ) { addFinding(sourceFile, node, "scratch-address-contract"); } if ( - ts.isPropertyAccessExpression(node) - || ts.isElementAccessExpression(node) + ts.isPropertyAccessExpression(node) || + ts.isElementAccessExpression(node) ) { + const memberSymbol = symbolAtExpression(checker, node); + if ( + memberSymbol && + destinationFactorySymbols.has(memberSymbol) && + !(ts.isCallExpression(node.parent) && node.parent.expression === node) + ) { + // WHY: the factory's nominal return type is not the provenance + // proof. Extraction/bind/call/apply can erase the exact formal + // arguments checked at the direct call site. + addFinding(sourceFile, node, "kernel-destination-factory-unsafe"); + } const receiverState = expressionState( node.expression, checker, @@ -3754,26 +7416,22 @@ export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { const directSymbol = symbolAtExpression(checker, node); const regionMember = isScratchRegionMemberSymbol(directSymbol); const regionProperty = accessedPropertyName(node); - const insideScratchLeaseImplementation = - toPosix(sourceFile.fileName).endsWith("/host/src/kernel-scratch.ts"); + const insideScratchLeaseImplementation = toPosix( + sourceFile.fileName, + ).endsWith("/host/src/kernel-scratch.ts"); const directCall = directCallForMember(node); const exactDirectRegionCall = Boolean( - directCall - && ts.isPropertyAccessExpression(node) - && regionMember - && isExactScratchRegionOrigin(node.expression), + directCall && + ts.isPropertyAccessExpression(node) && + regionMember && + isExactScratchRegionOrigin(node.expression), ); if ( - receiverState.scratchRegion - && ( - !regionMember - || !isExactScratchRegionOrigin(node.expression) - || propertyAccessIsMutation(node) - || ( - regionProperty !== "capacity" - && !exactDirectRegionCall - ) - ) + receiverState.scratchRegion && + (!regionMember || + !isExactScratchRegionOrigin(node.expression) || + propertyAccessIsMutation(node) || + (regionProperty !== "capacity" && !exactDirectRegionCall)) ) { addFinding(sourceFile, node, "scratch-address-contract"); } @@ -3802,80 +7460,261 @@ export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { } } const exactDirectLeaseMemberCall = Boolean( - directCall - && ts.isPropertyAccessExpression(node) - && isRealScratchLeaseMemberCall(directCall), + directCall && + ts.isPropertyAccessExpression(node) && + isRealScratchLeaseMemberCall(directCall), ); const exactDirectWithLeaseCall = Boolean( - directCall - && ts.isPropertyAccessExpression(node) - && isRealScratchWithLeaseCall(directCall), + directCall && + ts.isPropertyAccessExpression(node) && + isRealScratchWithLeaseCall(directCall), ); if ( - addressesScratch - || ( - leaseMember - && !insideScratchLeaseImplementation - && ( - !exactDirectLeaseMemberCall - || propertyAccessIsMutation(node) - ) - ) - || (leasesScratch && !exactDirectWithLeaseCall) + addressesScratch || + (leaseMember && + !insideScratchLeaseImplementation && + (!exactDirectLeaseMemberCall || propertyAccessIsMutation(node))) || + (leasesScratch && !exactDirectWithLeaseCall) ) { addFinding(sourceFile, node, "scratch-address-contract"); } } if ( - ts.isVariableDeclaration(node) - && ts.isObjectBindingPattern(node.name) - && node.initializer + ts.isVariableDeclaration(node) && + ts.isObjectBindingPattern(node.name) && + node.initializer ) { const hasAddress = typeHasScratchMember(node.initializer, "address"); - const hasWithLease = typeHasScratchMember(node.initializer, "withLease"); + const hasWithLease = typeHasScratchMember( + node.initializer, + "withLease", + ); const hasLease = typeHasScratchMember( node.initializer, "invokeKernelExport", ); for (const element of node.name.elements) { - const property = propertyNameText(element.propertyName) - ?? (ts.isIdentifier(element.name) ? element.name.text : null); + const property = + propertyNameText(element.propertyName) ?? + (ts.isIdentifier(element.name) ? element.name.text : null); if ( - (hasAddress && (property === null || property === "address")) - || hasLease - || ( - hasWithLease - && (property === null || property === "withLease") - ) + (hasAddress && (property === null || property === "address")) || + hasLease || + (hasWithLease && (property === null || property === "withLease")) ) { addFinding(sourceFile, element, "scratch-address-contract"); } } } if ( - (ts.isCallExpression(node) || ts.isNewExpression(node)) - && isViewConstructor( - node, - checker, - states, - programSources, - ) + (ts.isCallExpression(node) || ts.isNewExpression(node)) && + isViewConstructor(node, checker, states, programSources) ) { const state = expressionState(node, checker, states, programSources); if (isKernelView(state)) { addFinding(sourceFile, node, "kernel-view"); } } + for (const kind of wasmAuthorityKindsAtNode(node)) { + addFinding(sourceFile, node, kind); + } + if ( + (ts.isCallExpression(node) || ts.isNewExpression(node)) && + expressionMayGenerateDynamicCode(node.expression) + ) { + addFinding(sourceFile, node, "dynamic-code-contract"); + } + if ( + ts.isVariableDeclaration(node) && + node.initializer && + expressionCarriesWasmAuthority(node.initializer) && + (!ts.isIdentifier(node.name) || + !ts.isVariableDeclarationList(node.parent) || + (node.parent.flags & ts.NodeFlags.Const) === 0) + ) { + // Const identifiers are the one reviewed alias form: assigning the + // captured intrinsic once cannot redirect later uses. Mutable aliases + // and container bindings make authority provenance a may-property. + addFinding(sourceFile, node.initializer, "wasm-authority-escape"); + } + if ( + (ts.isPropertyAssignment(node) || ts.isPropertyDeclaration(node)) && + node.initializer && + expressionCarriesWasmAuthority(node.initializer) + ) { + addFinding(sourceFile, node.initializer, "wasm-authority-escape"); + } + if ( + ts.isBinaryExpression(node) && + isAssignmentOperator(node.operatorToken.kind) && + expressionCarriesWasmAuthority(node.right) + ) { + addFinding(sourceFile, node.right, "wasm-authority-escape"); + } + if ( + ts.isReturnStatement(node) && + node.expression && + expressionCarriesWasmAuthority(node.expression) + ) { + addFinding(sourceFile, node.expression, "wasm-authority-escape"); + } + if ( + ts.isArrowFunction(node) && + !ts.isBlock(node.body) && + expressionCarriesWasmAuthority(node.body) + ) { + addFinding(sourceFile, node.body, "wasm-authority-escape"); + } + if ( + ts.isYieldExpression(node) && + node.expression && + expressionCarriesWasmAuthority(node.expression) + ) { + addFinding(sourceFile, node.expression, "wasm-authority-escape"); + } + if ( + ts.isExportAssignment(node) && + expressionCarriesWasmAuthority(node.expression) + ) { + addFinding(sourceFile, node.expression, "wasm-authority-escape"); + } + if ( + ts.isVariableDeclaration(node) && + node.initializer && + expressionCarriesWasmAuthority(node.initializer) && + ts.isVariableDeclarationList(node.parent) && + ts.isVariableStatement(node.parent.parent) && + ts + .getModifiers(node.parent.parent) + ?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) + ) { + addFinding(sourceFile, node.initializer, "wasm-authority-escape"); + } + if ( + ts.isBindingElement(node) && + node.initializer && + expressionCarriesWasmAuthority(node.initializer) + ) { + addFinding(sourceFile, node.initializer, "wasm-authority-escape"); + } + if ( + ts.isExpressionWithTypeArguments(node) && + ts.isHeritageClause(node.parent) && + expressionCarriesWasmAuthority(node.expression) + ) { + addFinding(sourceFile, node.expression, "wasm-authority-escape"); + } + if ( + ts.isShorthandPropertyAssignment(node) && + expressionCarriesWasmAuthority(node.name) + ) { + addFinding(sourceFile, node.name, "wasm-authority-escape"); + } + if (ts.isArrayLiteralExpression(node)) { + for (const element of node.elements) { + if ( + !ts.isOmittedExpression(element) && + !ts.isSpreadElement(element) && + expressionCarriesWasmAuthority(element) + ) { + addFinding(sourceFile, element, "wasm-authority-escape"); + } + } + } + if ( + ts.isSpreadAssignment(node) && + expressionContainsKernelExportNamespace(node.expression) + ) { + // Object spread copies every raw export function into an untracked + // object. Reject the extraction at the authenticated namespace. + addFinding(sourceFile, node, "kernel-pointer-export-bypass"); + } + if ( + ts.isVariableDeclaration(node) && + ts.isObjectBindingPattern(node.name) && + node.name.elements.some((element) => element.dotDotDotToken) && + node.initializer && + expressionContainsKernelExportNamespace(node.initializer) + ) { + // Object-rest binding copies every remaining raw export into a new + // object, erasing the authenticated namespace provenance. + addFinding(sourceFile, node, "kernel-pointer-export-bypass"); + } + if ( + ts.isBinaryExpression(node) && + isSimpleAssignment(node) && + ts.isObjectLiteralExpression(unwrapExpression(node.left)) && + ( + unwrapExpression(node.left) as ts.ObjectLiteralExpression + ).properties.some(ts.isSpreadAssignment) && + expressionContainsKernelExportNamespace(node.right) + ) { + addFinding(sourceFile, node, "kernel-pointer-export-bypass"); + } + if ( + ts.isPropertyAssignment(node) && + propertyNameText(node.name) === "__proto__" && + expressionContainsKernelExportNamespace(node.initializer) + ) { + // `__proto__` in an object literal installs the namespace as the new + // object's prototype; a later ordinary property read would otherwise + // lose the authenticated namespace provenance. + addFinding(sourceFile, node, "kernel-pointer-export-bypass"); + } + if ( + ts.isBinaryExpression(node) && + isSimpleAssignment(node) && + (ts.isPropertyAccessExpression(node.left) || + ts.isElementAccessExpression(node.left)) && + accessedPropertyName(node.left) === "__proto__" && + expressionContainsKernelExportNamespace(node.right) + ) { + addFinding(sourceFile, node, "kernel-pointer-export-bypass"); + } + if ( + ts.isNewExpression(node) && + node.arguments?.some(expressionContainsKernelExportNamespace) + ) { + // A constructor can retain, proxy, or redistribute every raw export. + // No generated export name remains available for exact review. + addFinding(sourceFile, node, "kernel-pointer-export-bypass"); + } + if ( + ts.isTaggedTemplateExpression(node) && + ts.isTemplateExpression(node.template) && + node.template.templateSpans.some((span) => + expressionContainsKernelExportNamespace(span.expression), + ) + ) { + // A tag receives every substitution as an ordinary callable argument. + addFinding(sourceFile, node, "kernel-pointer-export-bypass"); + } if (ts.isCallExpression(node)) { const callee = unwrapExpression(node.expression); + const capturedOperation = capturedIntrinsicOperationCall(node, checker); + if (!callIsReviewedAuthorityInvocation(node)) { + for (const argument of node.arguments) { + if (expressionCarriesWasmAuthority(argument)) { + // A user/helper call can retain or invoke the constructor, + // namespace, or instantiate capability in syntax the origin + // classifier cannot prove. Fail closed at the capability escape. + addFinding(sourceFile, argument, "wasm-authority-escape"); + } + } + } + for (const argument of node.arguments) { + if (expressionMayGenerateDynamicCode(argument)) { + addFinding(sourceFile, argument, "dynamic-code-contract"); + } + } if ( - ts.isIdentifier(callee) - && callee.text === "eval" - && hasIntrinsicLibValueDeclaration( + ts.isIdentifier(callee) && + callee.text === "eval" && + hasIntrinsicLibValueDeclaration( symbolAtExpression(checker, callee), - ) - && activeLeaseCallbacks.has(enclosingFunction(node)!) + ) && + activeLeaseCallbacks.has(enclosingFunction(node)!) ) { // Direct eval can name the lexical lease without an identifier node, // defeating every symbol/provenance check below. @@ -3883,12 +7722,8 @@ export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { } for (const argument of node.arguments) { if ( - expressionState( - argument, - checker, - states, - programSources, - ).scratchRegion + expressionState(argument, checker, states, programSources) + .scratchRegion ) { // Regions may be stored or returned with their exact type, but // passing the capability through an arbitrary call makes @@ -3902,34 +7737,86 @@ export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { states, programSources, ); - if ( - hasPointerBearingKernelExport( - calleeState, - pointerBearingKernelExports, - ) + const directReflectedKernelExportKind = + directReflectedKernelExportUseKind(node); + if (directReflectedKernelExportKind !== null) { + addFinding(sourceFile, node, directReflectedKernelExportKind); + } else if ( + node.arguments.some(expressionContainsKernelExportNamespace) ) { - // WHY: primitive arguments carry no allocation-capacity witness. - // Pointer-bearing kernel exports may be invoked only by the lease, - // which substitutes opaque owned-range tokens immediately before the - // synchronous Wasm call. + // WHY: an arbitrary call can retain or redistribute the entire raw + // namespace, including every pointer-bearing scratch export. Reject + // the crossing here instead of trying to enumerate all reflective, + // proxy, prototype, copying, and higher-order extraction APIs. addFinding(sourceFile, node, "kernel-pointer-export-bypass"); } + const directFactory = symbolAtExpression(checker, node.expression); + const resolvedFactoryDeclaration = + checker.getResolvedSignature(node)?.declaration; + const resolvedFactory = resolvedFactoryDeclaration + ? symbolForDeclaration(checker, resolvedFactoryDeclaration) + : undefined; + const invokesDestinationFactory = + (directFactory && destinationFactorySymbols.has(directFactory)) || + (resolvedFactory && destinationFactorySymbols.has(resolvedFactory)); + if (invokesDestinationFactory) { + addFinding(sourceFile, node, "kernel-destination-factory-call"); + if (!destinationFactoryArgumentsAreExact(node)) { + addFinding(sourceFile, node, "kernel-destination-factory-unsafe"); + } + } + const kernelExportCallKind = kernelExportUseKind(calleeState); + if ( + kernelExportCallKind !== null && + !calleeState.allocator && + !calleeState.reserver && + node !== reviewedReservedSpawnCall + ) { + // WHY: the generated export set is the fail-closed outer boundary. + // Known pointer borrowers still receive the more specific finding; + // every other direct call needs an exact scalar/control review. + addFinding(sourceFile, node, kernelExportCallKind); + } if (calleeState.allocator) { addFinding(sourceFile, node, "scratch-allocator-call"); } if (calleeState.scratchRegionFactory) { addFinding(sourceFile, node, "scratch-region-factory-call"); } - if (calleeState.reserver) { - addFinding(sourceFile, node, "spawn-reservation-call"); + const intrinsicDispatchTarget = + intrinsicCallApplyTarget(node, checker) ?? + (isCapturedIntrinsicApply(node.expression, checker) + ? (node.arguments[0] ?? null) + : null); + if ( + calleeState.reserver || + (intrinsicDispatchTarget !== null && + expressionState( + intrinsicDispatchTarget, + checker, + states, + programSources, + ).reserver) + ) { + addFinding(sourceFile, node, "scratch-reservation-call"); } if ( - isKernelScratchWithLeaseCall(node, checker) - && ( - !isRealScratchWithLeaseCall(node) - || !inlineScratchLeaseCallback(node) + capturedOperation === "uint8-array-set" && + node.arguments[1] && + isKernelView( + expressionState(node.arguments[1], checker, states, programSources), ) ) { + addFinding(sourceFile, node, "kernel-write"); + } + if ( + isKernelScratchWithLeaseCall(node, checker) && + (!isRealScratchWithLeaseCall(node) || + !inlineScratchLeaseCallback(node)) + ) { + addFinding(sourceFile, node, "scratch-address-contract"); + } + if (invalidTwoPhaseLeaseCalls.has(node)) { addFinding(sourceFile, node, "scratch-address-contract"); } @@ -3942,53 +7829,34 @@ export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { states, programSources, ); - const knownViewWrite = ( - isKernelView(receiverState) - && method !== null - && ( - TYPED_ARRAY_MUTATORS.has(method) - || method.startsWith("set") - || method.startsWith("write") - ) - ); + const knownViewWrite = + isKernelView(receiverState) && + method !== null && + (TYPED_ARRAY_MUTATORS.has(method) || + method.startsWith("set") || + method.startsWith("write")); if (knownViewWrite) { addFinding(sourceFile, node, "kernel-write"); } else if ( - ( - hasKernelOwnership(receiverState, "view") - || hasKernelOwnership(receiverState, "buffer") - || hasKernelOwnership(receiverState, "memory") - ) - && !isProvenReadOnlyKernelReceiverCall( - node, - receiverState, - checker, - ) + (hasKernelOwnership(receiverState, "view") || + hasKernelOwnership(receiverState, "buffer") || + hasKernelOwnership(receiverState, "memory")) && + !isProvenReadOnlyKernelReceiverCall(node, receiverState, checker) ) { // WHY: a computed or custom method can be a disguised `.set`, // retain the live receiver, or mutate/detach its backing memory. // Only an exact standard-library method whose contract is // nonmutating may consume an allocator-owned receiver silently. - addOwnershipFindings( - sourceFile, - node, - receiverState, - "escape", - ); + addOwnershipFindings(sourceFile, node, receiverState, "escape"); } } if ( - ts.isPropertyAccessExpression(node.expression) - && node.expression.expression.getText(sourceFile) === "Atomics" - && ATOMIC_MUTATORS.has(node.expression.name.text) - && node.arguments[0] - && isKernelView( - expressionState( - node.arguments[0], - checker, - states, - programSources, - ), + ts.isPropertyAccessExpression(node.expression) && + node.expression.expression.getText(sourceFile) === "Atomics" && + ATOMIC_MUTATORS.has(node.expression.name.text) && + node.arguments[0] && + isKernelView( + expressionState(node.arguments[0], checker, states, programSources), ) ) { addFinding(sourceFile, node, "kernel-write"); @@ -3996,30 +7864,39 @@ export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { const signature = checker.getResolvedSignature(node); const declaration = signature?.declaration; - const analyzedBody = declaration - && isInProgram(programSources, declaration) - && hasBody(declaration); + const analyzedBody = + declaration && + isInProgram(programSources, declaration) && + hasBody(declaration); if ( - !analyzedBody - && !isViewConstructor(node, checker, states, programSources) + !analyzedBody && + !isViewConstructor(node, checker, states, programSources) && + capturedOwnershipGetterCall(node, checker) === null && + capturedOperation === null ) { + const escapedKernelExportStates = node.arguments.map((argument) => + expressionState(argument, checker, states, programSources), + ); + const escapesPointerBearingKernelExport = + escapedKernelExportStates.some((state) => + hasAuditedKernelExport(state, kernelScratchExportContract.names), + ); + const escapesGeneratedKernelExport = escapedKernelExportStates.some( + (state) => hasAuditedKernelExport(state, auditedKernelExports), + ); if ( - !isIntrinsicObjectFreezeCall(node, checker) - && node.arguments.some((argument) => - hasPointerBearingKernelExport( - expressionState( - argument, - checker, - states, - programSources, - ), - pointerBearingKernelExports, - ) - ) + !isIntrinsicObjectFreezeCall(node, checker) && + escapesGeneratedKernelExport ) { // Reflect.apply and opaque helpers can invoke or retain the raw // function without leaving a direct call expression for the audit. - addFinding(sourceFile, node, "kernel-pointer-export-bypass"); + addFinding( + sourceFile, + node, + escapesPointerBearingKernelExport + ? "kernel-pointer-export-bypass" + : "kernel-export-direct-use", + ); } node.arguments.forEach((argument, index) => { addOwnershipFindings( @@ -4033,14 +7910,15 @@ export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { } } if ( - ts.isNewExpression(node) - && !isViewConstructor(node, checker, states, programSources) + ts.isNewExpression(node) && + !isViewConstructor(node, checker, states, programSources) ) { const signature = checker.getResolvedSignature(node); const declaration = signature?.declaration; - const analyzedBody = declaration - && isInProgram(programSources, declaration) - && hasBody(declaration); + const analyzedBody = + declaration && + isInProgram(programSources, declaration) && + hasBody(declaration); if (!analyzedBody) { for (const argument of node.arguments ?? []) { addOwnershipFindings( @@ -4054,28 +7932,31 @@ export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { } if ( - ts.isBinaryExpression(node) - && isAssignmentOperator(node.operatorToken.kind) + ts.isBinaryExpression(node) && + isAssignmentOperator(node.operatorToken.kind) ) { if ( - assignmentWritesKernelView( - node.left, - checker, - states, - programSources, - ) + assignmentWritesKernelView(node.left, checker, states, programSources) ) { addFinding(sourceFile, node, "kernel-write"); } } if ( - (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) - && ts.isElementAccessExpression(unwrapExpression(node.operand)) + (ts.isPrefixUnaryExpression(node) || + ts.isPostfixUnaryExpression(node)) && + ts.isElementAccessExpression(unwrapExpression(node.operand)) ) { - const operand = unwrapExpression(node.operand) as ts.ElementAccessExpression; + const operand = unwrapExpression( + node.operand, + ) as ts.ElementAccessExpression; if ( isKernelView( - expressionState(operand.expression, checker, states, programSources), + expressionState( + operand.expression, + checker, + states, + programSources, + ), ) ) { addFinding(sourceFile, node, "kernel-write"); @@ -4088,22 +7969,25 @@ export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { states, programSources, ); + addKernelExportUseFinding(sourceFile, node, state); const fn = returnFunction(node); if (fn) { - unionState( + unionState(state, stateFor(seededReturnStates, fn)); + } + if ( + !fn || + transparentCapturedOwnershipGetterWrapper(fn, checker) === null + ) { + addOwnershipFindings( + sourceFile, + node, state, - stateFor(seededReturnStates, fn), + "return", + false, + undefined, + node.expression, ); } - addOwnershipFindings( - sourceFile, - node, - state, - "return", - false, - undefined, - node.expression, - ); } if (ts.isArrowFunction(node) && !ts.isBlock(node.body)) { const state = expressionState( @@ -4112,6 +7996,7 @@ export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { states, programSources, ); + addKernelExportUseFinding(sourceFile, node, state); addOwnershipFindings( sourceFile, node, @@ -4123,9 +8008,9 @@ export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { ); } if ( - ts.isBinaryExpression(node) - && isSimpleAssignment(node) - && isPersistentStoreTarget(node.left, checker, node) + ts.isBinaryExpression(node) && + isSimpleAssignment(node) && + isPersistentStoreTarget(node.left, checker, node) ) { const storedState = expressionState( node.right, @@ -4133,6 +8018,7 @@ export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { states, programSources, ); + addKernelExportUseFinding(sourceFile, node, storedState); addOwnershipFindings( sourceFile, node, @@ -4142,16 +8028,14 @@ export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { symbolAtExpression(checker, node.left), ); } - if ( - ts.isPropertyDeclaration(node) - && node.initializer - ) { + if (ts.isPropertyDeclaration(node) && node.initializer) { const storedState = expressionState( node.initializer, checker, states, programSources, ); + addKernelExportUseFinding(sourceFile, node, storedState); addOwnershipFindings( sourceFile, node, @@ -4162,9 +8046,9 @@ export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { ); } if ( - ts.isVariableDeclaration(node) - && node.initializer - && returnFunction(node) === null + ts.isVariableDeclaration(node) && + node.initializer && + returnFunction(node) === null ) { const storedState = expressionState( node.initializer, @@ -4180,6 +8064,33 @@ export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { false, symbolForDeclaration(checker, node), ); + const statement = ts.isVariableDeclarationList(node.parent) + ? node.parent.parent + : undefined; + if ( + statement && + ts.isVariableStatement(statement) && + statement.modifiers?.some( + (modifier) => + modifier.kind === ts.SyntaxKind.ExportKeyword || + modifier.kind === ts.SyntaxKind.DefaultKeyword, + ) + ) { + addKernelExportUseFinding(sourceFile, node, storedState); + } + } + if ( + ts.isExportAssignment(node) || + (ts.isYieldExpression(node) && node.expression) + ) { + const expression = ts.isExportAssignment(node) + ? node.expression + : node.expression!; + addKernelExportUseFinding( + sourceFile, + node, + expressionState(expression, checker, states, programSources), + ); } if (ts.isParameter(node)) { const property = parameterPropertySymbol(checker, node); @@ -4202,11 +8113,24 @@ export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { } ts.forEachChild(node, visit); }; - visit(sourceFile); + try { + visit(sourceFile); + } catch (error) { + if (error instanceof RangeError) { + const owner = lastVisitedNode.getSourceFile(); + const { line, character } = owner.getLineAndCharacterOfPosition( + lastVisitedNode.getStart(owner), + ); + throw new Error( + `audit traversal overflowed at ${toPosix(owner.fileName)}:${line + 1}:${character + 1} (${ts.SyntaxKind[lastVisitedNode.kind]})`, + { cause: error }, + ); + } + throw error; + } } findings.sort((a, b) => a.key.localeCompare(b.key)); - const allowanceByKey = new Map(allowances.map((entry) => [entry.key, entry])); const consumedAllowanceCounts = new Map(); const violations: AuditFinding[] = []; for (const finding of findings) { @@ -4228,7 +8152,15 @@ export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { violations, unusedAllowances, unresolvedSeeds, - contractErrors: [...kernelScratchExportContract.errors], + contractErrors: [ + ...kernelScratchExportContract.errors, + ...generatedKernelExportContractErrors, + ...unresolvedDestinationFactoryDeclarations.map( + (declaration) => + `could not resolve kernel destination factory declaration ${declaration}`, + ), + ...authorityClassificationErrors, + ], sourceFiles: sourceFiles .map((sourceFile) => relativeFile(options.rootDir, sourceFile)) .sort(), @@ -4237,9 +8169,9 @@ export function auditWasmMemoryWrites(options: AuditOptions): AuditResult { function isRuntimeSourceFile(fileName: string): boolean { if ( - fileName.endsWith(".d.ts") - || fileName.endsWith(".d.mts") - || fileName.endsWith(".d.cts") + fileName.endsWith(".d.ts") || + fileName.endsWith(".d.mts") || + fileName.endsWith(".d.cts") ) { return false; } @@ -4248,15 +8180,15 @@ function isRuntimeSourceFile(fileName: string): boolean { function isOrdinaryTestHarness(relativePath: string): boolean { if ( - relativePath === "apps/browser-demos/test/epoll-repro.ts" - || relativePath.startsWith("apps/browser-demos/test/fixtures/") + relativePath === "apps/browser-demos/test/epoll-repro.ts" || + relativePath.startsWith("apps/browser-demos/test/fixtures/") ) { return false; } return ( - relativePath.startsWith("host/test/") - || relativePath.includes("/test/") - || /\.(?:test|spec)\.(?:[cm]?[jt]s|[jt]sx)$/.test(relativePath) + relativePath.startsWith("host/test/") || + relativePath.includes("/test/") || + /\.(?:test|spec)\.(?:[cm]?[jt]s|[jt]sx)$/.test(relativePath) ); } @@ -4279,9 +8211,9 @@ export function repositoryRuntimeSourceFiles(rootDir: string): string[] { // sources and can contain their own nested build products. const relative = toPosix(path.relative(rootDir, absolute)); if ( - relative === "libc/musl" - || relative === "tests/libc/libc-test" - || relative === "tests/sortix/os-test" + relative === "libc/musl" || + relative === "tests/libc/libc-test" || + relative === "tests/sortix/os-test" ) { continue; } @@ -4326,11 +8258,16 @@ export function formatAuditFailures(result: AuditResult): string[] { failures.push(`unresolved ownership seed: ${seed.declaration}`); } for (const finding of result.violations) { - const advice = finding.kind === "scratch-address-contract" - ? ". Use an exact kernel-owned region with an inline synchronous withLease callback; pass lease.exportPointer(...) only to lease.invokeKernelExport(...), and never reintroduce address(), forge or erase the region/lease, mutate its methods, or pass it through an opaque helper." - : finding.kind === "kernel-pointer-export-bypass" - ? ". Invoke pointer-bearing kernel exports only through KernelScratchLease.invokeKernelExport with opaque exportPointer range tokens." - : ""; + const advice = + finding.kind === "scratch-address-contract" + ? ". Use an exact kernel-owned region with an inline synchronous withLease callback; pass lease.exportPointer(...) only to lease.invokeKernelExport(...), and never reintroduce address(), forge or erase the region/lease, mutate its methods, or pass it through an opaque helper." + : finding.kind === "kernel-destination-factory-unsafe" + ? ". Create the authenticated destination only by a direct call whose pointer and variable capacity are exact unmodified parameters of the same host-import function, or whose capacity is a reviewed immutable fixed constant." + : finding.kind === "kernel-pointer-export-bypass" + ? ". Invoke pointer-bearing kernel exports only through KernelScratchLease.invokeKernelExport with opaque exportPointer range tokens." + : finding.kind === "kernel-export-direct-use" + ? ". Add an exact scalar/control occurrence review, use the reservation transaction guard, or route pointer-bearing data through KernelScratchLease." + : ""; failures.push( `${finding.file}:${finding.line} ${finding.kind} in ${finding.enclosing}: ${finding.text}${advice}`, ); diff --git a/host/test/teardown-reclaim.test.ts b/host/test/teardown-reclaim.test.ts index 531ab4e7a7..0dd50bc4cd 100644 --- a/host/test/teardown-reclaim.test.ts +++ b/host/test/teardown-reclaim.test.ts @@ -24,7 +24,12 @@ import { ABI_SYSCALLS } from "../src/generated/abi"; const __dirname = dirname(fileURLToPath(import.meta.url)); const blockForeverBinary = join(__dirname, "../../examples/block-forever.wasm"); -const hasBinary = existsSync(blockForeverBinary); +const worktreeKernelBinary = join( + __dirname, + "../../local-binaries/kernel.wasm", +); +const hasBinaries = + existsSync(blockForeverBinary) && existsSync(worktreeKernelBinary); const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -38,7 +43,7 @@ function loadWasm(path: string): ArrayBuffer { // crash status instead. const COOPERATIVE_EXIT_STATUS = 137; -describe.skipIf(!hasBinary)("teardown reclamation of Atomics.wait-blocked workers", () => { +describe.skipIf(!hasBinaries)("teardown reclamation of Atomics.wait-blocked workers", () => { it("wakes a blocked daemon to a cooperative exit on destroy", async () => { const exits = new Map(); const host = new NodeKernelHost({ @@ -47,7 +52,11 @@ describe.skipIf(!hasBinary)("teardown reclamation of Atomics.wait-blocked worker if (e.kind === "exit") exits.set(e.pid, e.exitStatus); }, }); - await host.init(); + // WHY: this is source-level runtime validation. The ordinary resolver may + // select a previously published global cache generation whose host + // manifest predates the dirty worktree, producing an unrelated init + // failure instead of exercising the teardown code under test. + await host.init(loadWasm(worktreeKernelBinary)); let pid = -1; let resolveBlocked!: () => void; diff --git a/host/test/vfs/sharedfs-positioned-io.test.ts b/host/test/vfs/sharedfs-positioned-io.test.ts index 6579fc96f0..e9df913c70 100644 --- a/host/test/vfs/sharedfs-positioned-io.test.ts +++ b/host/test/vfs/sharedfs-positioned-io.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { Worker } from "node:worker_threads"; import { MemoryFileSystem } from "../../src/vfs/memory-fs"; import { O_APPEND, @@ -17,6 +18,25 @@ function text(bytes: Uint8Array): string { } describe("SharedFS positioned I/O", () => { + it("append is explicit and independent of flags captured at open", () => { + const sab = new SharedArrayBuffer(4 * 1024 * 1024); + const fs = SharedFS.mkfs(sab); + const fd = fs.open("/append.tmp", O_RDWR | O_CREAT | O_TRUNC, 0o600); + + expect(fs.write(fd, encoder.encode("abc"))).toBe(3); + expect(fs.lseek(fd, 1, SEEK_SET)).toBe(1); + expect(fs.append(fd, encoder.encode("!"), null)).toEqual({ + written: 1, + end: 4, + }); + expect(fs.writeAt(fd, encoder.encode("X"), 1)).toBe(1); + + expect(fs.lseek(fd, 0, SEEK_SET)).toBe(0); + const full = new Uint8Array(4); + expect(fs.read(fd, full)).toBe(4); + expect(text(full)).toBe("aXc!"); + }); + it("readAt and writeAt do not mutate the shared fd offset", () => { const sab = new SharedArrayBuffer(4 * 1024 * 1024); const fs = SharedFS.mkfs(sab); @@ -72,4 +92,115 @@ describe("SharedFS positioned I/O", () => { expect(fs.read(fd, full, null, 16)).toBe(16); expect(text(full)).toBe("0123XY6789abcdef"); }); + + it("MemoryFileSystem exposes one explicit append operation", () => { + const sab = new SharedArrayBuffer(4 * 1024 * 1024); + const fs = MemoryFileSystem.create(sab); + const fd = fs.open("/append.tmp", O_RDWR | O_CREAT | O_TRUNC, 0o600); + + expect(fs.write(fd, encoder.encode("abc"), null, 3)).toBe(3); + expect(fs.seek(fd, 0, SEEK_SET)).toBe(0); + expect(fs.append(fd, encoder.encode("!"), 1, null)).toEqual({ + written: 1, + end: 4, + }); + expect(fs.write(fd, encoder.encode("X"), 1, 1)).toBe(1); + + const full = new Uint8Array(4); + expect(fs.read(fd, full, 0, full.length)).toBe(4); + expect(text(full)).toBe("aXc!"); + }); + + it("applies the append limit under the inode lock and reports exact EOF", () => { + const sab = new SharedArrayBuffer(4 * 1024 * 1024); + const first = MemoryFileSystem.create(sab); + const second = MemoryFileSystem.fromExisting(sab); + const firstFd = first.open( + "/limited.tmp", + O_RDWR | O_CREAT | O_TRUNC, + 0o600, + ); + const secondFd = second.open("/limited.tmp", O_RDWR, 0o600); + + expect(first.write(firstFd, encoder.encode("abc"), null, 3)).toBe(3); + expect( + first.append(firstFd, encoder.encode("wxyz"), 4, 5), + ).toEqual({ written: 2, end: 5 }); + expect( + second.append(secondFd, encoder.encode("!"), 1, 5), + ).toEqual({ written: 0, end: 5 }); + expect( + second.append(secondFd, encoder.encode("!"), 1, null), + ).toEqual({ written: 1, end: 6 }); + + const full = new Uint8Array(6); + expect(first.read(firstFd, full, 0, full.length)).toBe(6); + expect(text(full)).toBe("abcwx!"); + }); + + it("serializes two interleaved append actors through the exact limit", async () => { + const sab = new SharedArrayBuffer(4 * 1024 * 1024); + const fs = MemoryFileSystem.create(sab); + const fd = fs.open( + "/append-race", + O_RDWR | O_CREAT | O_TRUNC, + 0o600, + ); + fs.close(fd); + + const markerLength = 5; + const iterations = 200; + const limit = markerLength * iterations * 2; + const controlBuffer = new SharedArrayBuffer(4); + const control = new Int32Array(controlBuffer); + const workerUrl = new URL( + "../fixtures/sharedfs-append-worker.ts", + import.meta.url, + ); + const workers = ["AAAA\n", "BBBB\n"].map( + (marker) => + new Worker(workerUrl, { + execArgv: ["--import", "tsx"], + workerData: { + fsBuffer: sab, + controlBuffer, + marker, + iterations, + limit, + }, + }), + ); + const results = workers.map( + (worker) => + new Promise<{ ok: boolean; error?: string }>((resolve, reject) => { + worker.once("message", resolve); + worker.once("error", reject); + worker.once("exit", (code) => { + if (code !== 0) reject(new Error(`append worker exited ${code}`)); + }); + }), + ); + + Atomics.store(control, 0, 1); + Atomics.notify(control, 0, workers.length); + try { + expect(await Promise.all(results)).toEqual([{ ok: true }, { ok: true }]); + } finally { + await Promise.all(workers.map((worker) => worker.terminate())); + } + + const verifyFd = fs.open("/append-race", O_RDWR, 0); + expect(fs.fstat(verifyFd).size).toBe(limit); + expect( + fs.append(verifyFd, encoder.encode("X"), 1, limit), + ).toEqual({ written: 0, end: limit }); + const bytes = new Uint8Array(limit); + expect(fs.read(verifyFd, bytes, 0, bytes.length)).toBe(bytes.length); + for (let offset = 0; offset < bytes.length; offset += markerLength) { + expect(["AAAA\n", "BBBB\n"]).toContain( + text(bytes.subarray(offset, offset + markerLength)), + ); + } + fs.close(verifyFd); + }, 10_000); }); diff --git a/host/test/wasi-shim.test.ts b/host/test/wasi-shim.test.ts index 8300874be5..2230a7843f 100644 --- a/host/test/wasi-shim.test.ts +++ b/host/test/wasi-shim.test.ts @@ -4,14 +4,97 @@ * Runs hand-written WASI .wasm binaries through CentralizedKernelWorker * and verifies behavior (stdout output, args, etc.). */ -import { describe, it, expect } from "vitest"; +import { afterEach, describe, it, expect, vi } from "vitest"; import { readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { runCentralizedProgram } from "./centralized-test-helper"; +import { WasiShim } from "../src/wasi-shim"; +import { + ABI_SYSCALLS, + CHANNEL_STATUS_COMPLETE, + CH_ARG_SIZE, + CH_ARGS, + CH_DATA, + CH_ERRNO, + CH_RETURN, + CH_STATUS, + CH_SYSCALL, + PROCESS_IOVEC_WASM32_BASE_OFFSET, + PROCESS_IOVEC_WASM32_LEN_OFFSET, +} from "../src/generated/abi"; const __dirname = dirname(fileURLToPath(import.meta.url)); const fixturesDir = join(__dirname, "fixtures"); +const channelOffset = 64 * 1024; + +interface ObservedChannelCall { + syscall: number; + args: bigint[]; +} + +interface ChannelResponse { + result?: bigint; + errno?: number; + data?: Uint8Array; +} + +function createChannelHarness( + respond: ( + call: ObservedChannelCall, + memory: WebAssembly.Memory, + ) => ChannelResponse, +): { + memory: WebAssembly.Memory; + shim: WasiShim; + calls: ObservedChannelCall[]; +} { + const memory = new WebAssembly.Memory({ + initial: 3, + maximum: 3, + shared: true, + }); + const calls: ObservedChannelCall[] = []; + + vi.spyOn(Atomics, "wait").mockImplementation(() => { + const view = new DataView(memory.buffer); + const call = { + syscall: view.getInt32(channelOffset + CH_SYSCALL, true), + args: Array.from( + { length: 6 }, + (_, index) => view.getBigInt64( + channelOffset + CH_ARGS + index * CH_ARG_SIZE, + true, + ), + ), + }; + calls.push(call); + + const response = respond(call, memory); + if (response.data) { + new Uint8Array(memory.buffer, channelOffset + CH_DATA, response.data.length) + .set(response.data); + } + view.setBigInt64(channelOffset + CH_RETURN, response.result ?? 0n, true); + view.setUint32(channelOffset + CH_ERRNO, response.errno ?? 0, true); + Atomics.store( + new Int32Array(memory.buffer), + (channelOffset + CH_STATUS) / Int32Array.BYTES_PER_ELEMENT, + CHANNEL_STATUS_COMPLETE, + ); + return "not-equal"; + }); + + return { + memory, + shim: new WasiShim(memory, channelOffset, [], []), + calls, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); describe("WASI shim", () => { it("hello world via fd_write", async () => { @@ -32,4 +115,180 @@ describe("WASI shim", () => { expect(result.stdout).toBe("test-argument-value\n"); expect(result.exitCode).toBe(0); }); + + it("preserves scalar offsets above 2^53 through the real kernel channel", async () => { + const result = await runCentralizedProgram({ + programPath: join(fixturesDir, "wasi-scalar-abi.wasm"), + timeout: 10_000, + }); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + }); +}); + +describe("WASI shim scalar channel ABI", () => { + it("passes pread and pwrite offsets in their single exact i64 slot", () => { + const { memory, shim, calls } = createChannelHarness((call) => { + if (call.syscall === ABI_SYSCALLS.Pread) { + return { result: 3n, data: Uint8Array.of(1, 2, 3) }; + } + return { result: 3n }; + }); + const view = new DataView(memory.buffer); + const iov = 0x100; + const buffer = 0x200; + const countOut = 0x300; + view.setUint32(iov + PROCESS_IOVEC_WASM32_BASE_OFFSET, buffer, true); + view.setUint32(iov + PROCESS_IOVEC_WASM32_LEN_OFFSET, 3, true); + + const preadOffset = 0x0020_0000_0000_0001n; + expect(shim.fd_pread(7, iov, 1, preadOffset, countOut)).toBe(0); + expect(new Uint8Array(memory.buffer, buffer, 3)).toEqual( + Uint8Array.of(1, 2, 3), + ); + expect(view.getUint32(countOut, true)).toBe(3); + expect(calls[0]).toEqual({ + syscall: ABI_SYSCALLS.Pread, + args: [ + 7n, + BigInt(channelOffset + CH_DATA), + 3n, + preadOffset, + 0n, + 0n, + ], + }); + + new Uint8Array(memory.buffer, buffer, 3).set([4, 5, 6]); + const pwriteOffset = 0x0020_0000_0000_0003n; + expect(shim.fd_pwrite(8, iov, 1, pwriteOffset, countOut)).toBe(0); + expect(calls[1]).toEqual({ + syscall: ABI_SYSCALLS.Pwrite, + args: [ + 8n, + BigInt(channelOffset + CH_DATA), + 3n, + pwriteOffset, + 0n, + 0n, + ], + }); + }); + + it("splits signed lseek input words and preserves its exact i64 result", () => { + const returnedOffset = 0x0020_0000_0000_0007n; + const { memory, shim, calls } = createChannelHarness(() => ({ + result: returnedOffset, + })); + const newOffsetOut = 0x100; + const offset = 0x0020_0001_89ab_cdefn; + + expect(shim.fd_seek(9, offset, 2, newOffsetOut)).toBe(0); + expect(new DataView(memory.buffer).getBigUint64(newOffsetOut, true)) + .toBe(returnedOffset); + expect(calls).toEqual([{ + syscall: ABI_SYSCALLS.Seek, + args: [ + 9n, + BigInt.asUintN(32, offset), + BigInt.asIntN(32, offset >> 32n), + 2n, + 0n, + 0n, + ], + }]); + }); + + it("sign-extends negative lseek offsets and uses SEEK_CUR for fd_tell", () => { + const tellResult = 0x0020_0000_0000_000bn; + const { memory, shim, calls } = createChannelHarness((_call) => ({ + result: calls.length === 1 ? 0n : tellResult, + })); + const seekOut = 0x100; + const tellOut = 0x108; + + expect(shim.fd_seek(10, -1n, 0, seekOut)).toBe(0); + expect(shim.fd_tell(10, tellOut)).toBe(0); + expect(new DataView(memory.buffer).getBigUint64(tellOut, true)) + .toBe(tellResult); + expect(calls).toEqual([ + { + syscall: ABI_SYSCALLS.Seek, + args: [10n, 0xffff_ffffn, -1n, 0n, 0n, 0n], + }, + { + syscall: ABI_SYSCALLS.Seek, + args: [10n, 0n, 0n, 1n, 0n, 0n], + }, + ]); + }); + + it("does not replace the seek output when the channel returns an error", () => { + const { memory, shim } = createChannelHarness(() => ({ + result: -1n, + errno: 22, + })); + const out = 0x100; + const sentinel = 0x1234_5678_9abc_def0n; + new DataView(memory.buffer).setBigUint64(out, sentinel, true); + + expect(shim.fd_seek(11, 0n, 0, out)).toBe(28); + expect(new DataView(memory.buffer).getBigUint64(out, true)).toBe(sentinel); + }); + + it("rejects an invalid WASI whence before issuing a channel syscall", () => { + const { memory, shim, calls } = createChannelHarness(() => ({ + result: 123n, + })); + const out = 0x100; + const sentinel = 0x1234_5678_9abc_def0n; + new DataView(memory.buffer).setBigUint64(out, sentinel, true); + + expect(shim.fd_seek(11, 0n, 3, out)).toBe(28); + expect(calls).toEqual([]); + expect(new DataView(memory.buffer).getBigUint64(out, true)).toBe(sentinel); + }); + + it("passes ftruncate and fallocate scalars in their exact ABI slots", () => { + const { shim, calls } = createChannelHarness(() => ({ result: 0n })); + const size = 0x0020_0000_0000_0001n; + const offset = 0x0020_0000_0000_0003n; + const len = 0x7fff_ffff_ffff_ffffn; + + expect(shim.fd_filestat_set_size(12, size)).toBe(0); + expect(shim.fd_allocate(13, offset, len)).toBe(0); + expect(calls).toEqual([ + { + syscall: ABI_SYSCALLS.Ftruncate, + args: [12n, size, 0n, 0n, 0n, 0n], + }, + { + syscall: ABI_SYSCALLS.Fallocate, + args: [13n, 0n, offset, len, 0n, 0n], + }, + ]); + }); + + it("rejects direct JavaScript scalars that cannot encode signed i64 exactly", () => { + const { shim, calls } = createChannelHarness(() => ({ result: 0n })); + const aboveI64 = 1n << 63n; + const unsafeNumber = Number.MAX_SAFE_INTEGER + 1; + + expect(() => shim.fd_filestat_set_size(14, aboveI64)).toThrow( + /outside signed i64/, + ); + expect(() => + shim.fd_allocate(14, -(1n << 63n) - 1n, 1n) + ).toThrow(/outside signed i64/); + expect(() => + shim.fd_filestat_set_size( + 14, + unsafeNumber as unknown as bigint, + ) + ).toThrow(/safe integer/); + expect(() => + shim.fd_allocate(14, 0.5 as unknown as bigint, 1n) + ).toThrow(/safe integer/); + expect(calls).toEqual([]); + }); }); diff --git a/host/test/wasm-memory-write-audit.test.ts b/host/test/wasm-memory-write-audit.test.ts index 9b01ba7405..4d98bbd7ab 100644 --- a/host/test/wasm-memory-write-audit.test.ts +++ b/host/test/wasm-memory-write-audit.test.ts @@ -1,9 +1,4 @@ -import { - mkdirSync, - mkdtempSync, - rmSync, - writeFileSync, -} from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -41,12 +36,622 @@ function auditVirtual( seeds: readonly OwnershipSeed[] = [kernelMemorySeed()], allowances: readonly AuditAllowance[] = [], ) { - return auditWasmMemoryWrites( - virtualAuditOptions(sources, seeds, allowances), - ); + return auditWasmMemoryWrites(virtualAuditOptions(sources, seeds, allowances)); } describe("WebAssembly memory write audit", () => { + it("requires exact owner classifications and seeds the classified origin", () => { + const sources = { + "kernel.ts": ` + const memory = new WebAssembly.Memory({ initial: 1 }); + export function write(bytes: Uint8Array): void { + new Uint8Array(memory.buffer).set(bytes); + } + `, + }; + const unclassified = auditWasmMemoryWrites({ + ...virtualAuditOptions(sources, []), + auditWasmAuthorityOrigins: true, + }); + const origin = unclassified.violations.find( + (finding) => finding.kind === "wasm-memory-authority", + ); + expect(origin).toBeDefined(); + expect( + unclassified.violations.some( + (finding) => finding.kind === "kernel-write", + ), + ).toBe(false); + + const kernelClassification: AuditAllowance = { + key: origin!.key, + disposition: "kernel-control", + authorityOwner: "kernel", + why: "This exact constructor creates the kernel linear memory.", + }; + const classifiedKernel = auditWasmMemoryWrites({ + ...virtualAuditOptions(sources, [], [kernelClassification]), + auditWasmAuthorityOrigins: true, + }); + expect( + classifiedKernel.violations.some( + (finding) => finding.kind === "wasm-memory-authority", + ), + ).toBe(false); + expect( + classifiedKernel.violations.some( + (finding) => finding.kind === "kernel-write", + ), + ).toBe(true); + + const classifiedProcess = auditWasmMemoryWrites({ + ...virtualAuditOptions( + sources, + [], + [ + { + ...kernelClassification, + disposition: "non-kernel", + authorityOwner: "process-memory", + }, + ], + ), + auditWasmAuthorityOrigins: true, + }); + expect(classifiedProcess.violations).toEqual([]); + expect(classifiedProcess.unusedAllowances).toEqual([]); + + expect(() => + auditWasmMemoryWrites({ + ...virtualAuditOptions( + sources, + [], + [kernelClassification, kernelClassification], + ), + auditWasmAuthorityOrigins: true, + }), + ).toThrow(/duplicate audit allowance/); + expect(() => + auditWasmMemoryWrites({ + ...virtualAuditOptions( + sources, + [], + [ + { + key: origin!.key, + disposition: "kernel-control", + why: "This deliberately omits the required authority owner.", + }, + ], + ), + auditWasmAuthorityOrigins: true, + }), + ).toThrow(/must classify its owner/); + }); + + it("finds aliased, destructured, bound, and reflected Wasm authorities", () => { + const result = auditWasmMemoryWrites({ + ...virtualAuditOptions( + { + "authority.ts": ` + declare const module: WebAssembly.Module; + const Wasm = WebAssembly; + const directAliasMemory = + new Wasm.Memory({ initial: 1 }); + const { Memory: DestructuredMemory } = Wasm; + const BracketMemory = WebAssembly["Memory"]; + const BoundMemory = BracketMemory.bind(null); + const capturedConstruct = Reflect.construct; + const first = new DestructuredMemory({ initial: 1 }); + const second = new BoundMemory({ initial: 1 }); + const third = capturedConstruct( + DestructuredMemory, + [{ initial: 1 }], + ) as WebAssembly.Memory; + + const { Instance: DestructuredInstance } = WebAssembly; + const instance = capturedConstruct( + DestructuredInstance, + [module, {}], + ) as WebAssembly.Instance; + + const { instantiate: destructuredInstantiate } = WebAssembly; + const boundInstantiate = + WebAssembly.instantiate.bind(WebAssembly); + void Wasm.instantiate(module, {}); + void WebAssembly.instantiate.call( + WebAssembly, + module, + {}, + ); + void WebAssembly.instantiate.apply( + WebAssembly, + [module, {}] as any, + ); + void destructuredInstantiate(module, {}); + void boundInstantiate(module, {}); + void directAliasMemory; + void first; + void second; + void third; + void instance; + `, + }, + [], + ), + auditWasmAuthorityOrigins: true, + }); + + expect( + result.violations.filter( + (finding) => finding.kind === "wasm-memory-authority", + ), + ).toHaveLength(4); + expect( + result.violations.filter( + (finding) => finding.kind === "wasm-instance-authority", + ), + ).toHaveLength(6); + }); + + it("finds immutable container and intrinsic dispatcher authority wrappers", () => { + const result = auditWasmMemoryWrites({ + ...virtualAuditOptions( + { + "authority.ts": ` + declare const module: WebAssembly.Module; + + const namespaceBox = { Wasm: WebAssembly }; + const constructorBox = { make: WebAssembly.Memory }; + const constructorList = [WebAssembly.Memory] as const; + void new namespaceBox.Wasm.Memory({ initial: 1 }); + void new constructorBox.make({ initial: 1 }); + void new (constructorList[0])({ initial: 1 }); + + const instantiateBox = { + make: WebAssembly.instantiate, + }; + void instantiateBox.make(module, {}); + void Function.prototype.call.bind( + WebAssembly.instantiate, + )(WebAssembly, module, {}); + void Function.prototype.apply.bind( + WebAssembly.instantiate, + )(WebAssembly, [module, {}]); + void Reflect.apply( + Function.prototype.call, + WebAssembly.instantiate, + [WebAssembly, module, {}], + ); + void Reflect.apply( + Function.prototype.apply, + WebAssembly.instantiate, + [WebAssembly, [module, {}]], + ); + + const { Wasm } = { Wasm: WebAssembly }; + const { Memory } = { Memory: WebAssembly.Memory }; + const [ArrayMemory] = [WebAssembly.Memory]; + void new Wasm.Memory({ initial: 1 }); + void new Memory({ initial: 1 }); + void new ArrayMemory({ initial: 1 }); + const { make } = { + make: WebAssembly.instantiate, + }; + const [arrayInstantiate] = [ + WebAssembly.instantiate, + ]; + void make(module, {}); + void arrayInstantiate(module, {}); + + void Reflect.construct.call( + Reflect, + WebAssembly.Memory, + [{ initial: 1 }], + ); + void Reflect.construct.apply( + Reflect, + [WebAssembly.Memory, [{ initial: 1 }]], + ); + void Reflect.apply( + Reflect.construct, + Reflect, + [WebAssembly.Memory, [{ initial: 1 }]], + ); + void Reflect.construct.bind( + Reflect, + WebAssembly.Memory, + )([{ initial: 1 }]); + `, + }, + [], + ), + auditWasmAuthorityOrigins: true, + }); + + expect( + result.violations.filter( + (finding) => finding.kind === "wasm-memory-authority", + ), + ).toHaveLength(10); + expect( + result.violations.filter( + (finding) => finding.kind === "wasm-instance-authority", + ), + ).toHaveLength(7); + }); + + it("fails closed on mutable, higher-order, and expression authority flows", () => { + const mutable = auditWasmMemoryWrites({ + ...virtualAuditOptions( + { + "mutable.ts": ` + declare const module: WebAssembly.Module; + const Fake: any = class {}; + let Memory = WebAssembly.Memory; + void new Memory({ initial: 1 }); + let AssignedMemory: any = Fake; + AssignedMemory = WebAssembly.Memory; + void new AssignedMemory({ initial: 1 }); + const box = { make: Fake }; + box.make = WebAssembly.Memory; + void new box.make({ initial: 1 }); + + let instantiate = WebAssembly.instantiate; + void instantiate(module, {}); + const instanceBox = { make: Fake }; + instanceBox.make = WebAssembly.instantiate; + void instanceBox.make(module, {}); + `, + }, + [], + ), + auditWasmAuthorityOrigins: true, + }); + expect( + mutable.violations.filter( + (finding) => finding.kind === "wasm-memory-authority", + ), + ).toHaveLength(3); + expect( + mutable.violations.filter( + (finding) => finding.kind === "wasm-instance-authority", + ), + ).toHaveLength(2); + expect( + mutable.violations.filter( + (finding) => finding.kind === "wasm-authority-escape", + ).length, + ).toBeGreaterThanOrEqual(5); + + const expressions = auditWasmMemoryWrites({ + ...virtualAuditOptions( + { + "expressions.ts": ` + declare const module: WebAssembly.Module; + declare const flag: boolean; + declare const key: string; + const Fake: any = class {}; + void new ( + flag ? WebAssembly.Memory : Fake + )({ initial: 1 }); + void new ( + flag && WebAssembly.Memory as any + )({ initial: 1 }); + void new ( + 0, WebAssembly.Memory + )({ initial: 1 }); + void new (WebAssembly as any)[key]({ initial: 1 }); + void (WebAssembly as any)[key](module, {}); + void new globalThis.WebAssembly.Memory({ initial: 1 }); + void globalThis.WebAssembly.instantiate(module, {}); + const BoundMemory = Function.prototype.bind.call( + WebAssembly.Memory, + null, + ); + void new BoundMemory({ initial: 1 }); + `, + }, + [], + ), + auditWasmAuthorityOrigins: true, + }); + expect( + expressions.violations.filter( + (finding) => finding.kind === "wasm-memory-authority", + ), + ).toHaveLength(6); + expect( + expressions.violations.filter( + (finding) => finding.kind === "wasm-instance-authority", + ), + ).toHaveLength(3); + + const helpers = auditWasmMemoryWrites({ + ...virtualAuditOptions( + { + "helpers.ts": ` + declare const module: WebAssembly.Module; + function construct( + Constructor: any, + args: any[], + ): unknown { + return new Constructor(...args); + } + function call( + operation: any, + ...args: any[] + ): unknown { + return operation(...args); + } + void construct( + WebAssembly.Memory, + [{ initial: 1 }], + ); + void call(WebAssembly.instantiate, module, {}); + `, + }, + [], + ), + auditWasmAuthorityOrigins: true, + }); + expect( + helpers.violations.filter( + (finding) => finding.kind === "wasm-authority-escape", + ), + ).toHaveLength(2); + + const dynamicCode = auditWasmMemoryWrites({ + ...virtualAuditOptions( + { + "dynamic.ts": ` + declare function retain(value: unknown): void; + eval("void 0"); + const DynamicFunction = Function; + void new DynamicFunction("return 1"); + retain(eval); + `, + }, + [], + ), + auditWasmAuthorityOrigins: true, + }); + expect( + dynamicCode.violations.filter( + (finding) => finding.kind === "dynamic-code-contract", + ), + ).toHaveLength(3); + }); + + it("fails closed on the finite WebAssembly authority escape policy", () => { + const result = auditWasmMemoryWrites({ + ...virtualAuditOptions( + { + "authority-escape.ts": ` + declare const module: WebAssembly.Module; + declare const response: Response; + declare const source: any; + declare function retain(value: unknown): void; + + const root: any = globalThis; + void new root.WebAssembly.Memory({ initial: 1 }); + void root.WebAssembly.instantiate(module, {}); + void new globalThis["Web" + "Assembly"].Memory({ + initial: 1, + }); + void globalThis["Web" + "Assembly"].instantiate( + module, + {}, + ); + + void self.eval("void 0"); + void self.Function("return 1")(); + void WebAssembly.Memory.constructor("return 1")(); + + void WebAssembly.instantiateStreaming(response, {}); + const stream = WebAssembly.instantiateStreaming; + void stream(response, {}); + + class MemorySubclass extends WebAssembly.Memory {} + class InstanceSubclass extends WebAssembly.Instance {} + void MemorySubclass; + void InstanceSubclass; + void new ( + WebAssembly.Memory.prototype.constructor as any + )({ initial: 1 }); + void new ( + WebAssembly.Instance.prototype.constructor as any + )(module, {}); + + const memoryFactory = () => WebAssembly.Memory; + void memoryFactory; + function* capabilities() { + yield WebAssembly.Memory; + yield WebAssembly.instantiate; + } + void capabilities; + + const box = { WebAssembly }; + retain(box); + const { + MemoryCtor = WebAssembly.Memory, + instantiate = WebAssembly.instantiate, + } = source; + void new (MemoryCtor as any)({ initial: 1 }); + void instantiate(module, {}); + `, + "authority-export.ts": ` + export default WebAssembly; + `, + }, + [], + ), + auditWasmAuthorityOrigins: true, + }); + + const findings = result.violations.map( + (finding) => `${finding.kind}:${finding.text}`, + ); + for (const expected of [ + "wasm-memory-authority:new root.WebAssembly.Memory", + "wasm-instance-authority:root.WebAssembly.instantiate", + 'wasm-memory-authority:new globalThis["Web" + "Assembly"].Memory', + 'wasm-instance-authority:globalThis["Web" + "Assembly"].instantiate', + "wasm-instance-authority:WebAssembly.instantiateStreaming", + "wasm-instance-authority:stream(response", + "wasm-memory-authority:new ( WebAssembly.Memory.prototype.constructor", + "wasm-instance-authority:new ( WebAssembly.Instance.prototype.constructor", + "wasm-memory-authority:new (MemoryCtor as any)", + "wasm-instance-authority:instantiate(module", + "dynamic-code-contract:self.eval", + "dynamic-code-contract:self.Function", + "dynamic-code-contract:WebAssembly.Memory.constructor", + "wasm-authority-escape:WebAssembly.Memory", + "wasm-authority-escape:WebAssembly.instantiate", + "wasm-authority-escape:WebAssembly", + ]) { + expect( + findings.some((finding) => finding.includes(expected)), + `missing authority-policy finding ${expected}\n${findings.join("\n")}`, + ).toBe(true); + } + expect(result.contractErrors).toEqual([]); + }); + + it("proves destination-factory capacity provenance beyond call text", () => { + const source = (capacitySetup: string) => ({ + "caller.ts": ` + class Kernel { + #destination( + pointer: number, + capacity: number, + label: string, + ): object { + return { pointer, capacity, label }; + } + readonly imports = { + host_write: (pointer: number): void => { + ${capacitySetup} + this.#destination(pointer, capacity, "test destination"); + }, + }; + } + `, + }); + const destinationFactoryDeclarations = ["caller.ts::Kernel.#destination"]; + const safe = auditWasmMemoryWrites({ + ...virtualAuditOptions(source("const capacity = 8;"), []), + kernelDestinationFactoryDeclarations: destinationFactoryDeclarations, + }); + expect( + safe.violations.filter( + (finding) => finding.kind === "kernel-destination-factory-call", + ), + ).toHaveLength(1); + expect( + safe.violations.some( + (finding) => finding.kind === "kernel-destination-factory-unsafe", + ), + ).toBe(false); + const reviewedCall: AuditAllowance = { + key: safe.violations.find( + (finding) => finding.kind === "kernel-destination-factory-call", + )!.key, + disposition: "rust-lent", + why: "The pointer is the exact import formal and capacity is fixed.", + }; + + const reassigned = auditWasmMemoryWrites({ + ...virtualAuditOptions( + source("let capacity = 8; capacity = 65536;"), + [], + [reviewedCall], + ), + kernelDestinationFactoryDeclarations: destinationFactoryDeclarations, + }); + expect(reassigned.unusedAllowances).toEqual([]); + expect( + reassigned.violations.some( + (finding) => finding.kind === "kernel-destination-factory-unsafe", + ), + ).toBe(true); + + const extracted = auditWasmMemoryWrites({ + ...virtualAuditOptions( + { + "caller.ts": ` + class Kernel { + #destination( + pointer: number, + capacity: number, + label: string, + ): object { + return { pointer, capacity, label }; + } + write(pointer: number, capacity: number): void { + const factory = this.#destination; + factory(pointer, capacity, "test destination"); + } + } + `, + }, + [], + ), + kernelDestinationFactoryDeclarations: destinationFactoryDeclarations, + }); + expect( + extracted.violations.some( + (finding) => finding.kind === "kernel-destination-factory-unsafe", + ), + ).toBe(true); + + const forwarded = auditWasmMemoryWrites({ + ...virtualAuditOptions( + { + "caller.ts": ` + class Kernel { + #destination( + pointer: number, + capacity: number, + label: string, + ): object { + return { pointer, capacity, label }; + } + #publish(pointer: number, capacity: number): void { + this.#destination( + pointer, + capacity, + "forwarded destination", + ); + } + readonly imports = { + host_write: ( + pointer: number, + capacity: number, + ): void => { + capacity = 65536; + this.#publish(pointer, capacity); + }, + }; + } + `, + }, + [], + ), + kernelDestinationFactoryDeclarations: destinationFactoryDeclarations, + }); + expect( + forwarded.violations.some( + (finding) => finding.kind === "kernel-destination-factory-call", + ), + ).toBe(true); + expect( + forwarded.violations.some( + (finding) => finding.kind === "kernel-destination-factory-unsafe", + ), + ).toBe(true); + }); + it("finds direct, bracketed, and destructured kernel-memory aliases", () => { const result = auditVirtual({ "kernel.ts": ` @@ -66,10 +671,12 @@ describe("WebAssembly memory write audit", () => { }); expect(result.unresolvedSeeds).toEqual([]); - expect(result.findings.filter((finding) => finding.kind === "kernel-view")) - .toHaveLength(2); - expect(result.findings.filter((finding) => finding.kind === "kernel-write")) - .toHaveLength(2); + expect( + result.findings.filter((finding) => finding.kind === "kernel-view"), + ).toHaveLength(2); + expect( + result.findings.filter((finding) => finding.kind === "kernel-write"), + ).toHaveLength(2); }); it("propagates ownership through a helper parameter and return across files", () => { @@ -92,16 +699,345 @@ describe("WebAssembly memory write audit", () => { }); expect(result.unresolvedSeeds).toEqual([]); - expect(result.findings.some( - (finding) => - finding.file === "view.ts" - && finding.kind === "kernel-view-return", - )).toBe(true); - expect(result.findings.some( + expect( + result.findings.some( + (finding) => + finding.file === "view.ts" && finding.kind === "kernel-view-return", + ), + ).toBe(true); + expect( + result.findings.some( + (finding) => + finding.file === "kernel.ts" && finding.kind === "kernel-write", + ), + ).toBe(true); + }); + + it("tracks captured WebAssembly buffer and exports getters", () => { + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` + const KERNEL_SCRATCH_EXPORT_NAMES = Object.freeze([ + "kernel_recv", + ] as const); + `, + "kernel.ts": ` + const intrinsicApply = Reflect.apply; + const intrinsicMemoryBuffer = Object.getOwnPropertyDescriptor( + WebAssembly.Memory.prototype, + "buffer", + )!.get!; + const intrinsicInstanceExports = Object.getOwnPropertyDescriptor( + WebAssembly.Instance.prototype, + "exports", + )!.get!; + + function memoryBuffer( + memory: WebAssembly.Memory, + ): ArrayBufferLike { + return intrinsicApply(intrinsicMemoryBuffer, memory, []); + } + + function instanceExports( + instance: WebAssembly.Instance, + ): WebAssembly.Exports { + return intrinsicApply(intrinsicInstanceExports, instance, []); + } + + class Kernel { + memory!: WebAssembly.Memory; + processMemory!: WebAssembly.Memory; + instance!: WebAssembly.Instance; + + write(bytes: Uint8Array): void { + new Uint8Array(memoryBuffer(this.memory)).set(bytes); + } + + writeProcess(bytes: Uint8Array): void { + new Uint8Array(memoryBuffer(this.processMemory)).set(bytes); + } + + invoke(): void { + const recv = instanceExports(this.instance).kernel_recv as ( + fd: number, + pointer: number, + capacity: number, + ) => number; + recv(1, 4096, 8); + } + } + `, + }, + [ + kernelMemorySeed(), + { + declaration: "kernel.ts::Kernel.instance", + target: "value", + owner: "kernel", + form: "instance", + why: "This fixture field is the exact instantiated kernel module.", + }, + { + declaration: "kernel.ts::Kernel.processMemory", + target: "value", + owner: "process-memory", + form: "memory", + why: "This fixture field is caller process memory, not kernel memory.", + }, + ], + ); + + expect(result.unresolvedSeeds).toEqual([]); + expect( + result.findings.filter( + (finding) => + finding.kind === "kernel-write" && + finding.text.includes(".set(bytes)"), + ), + ).toHaveLength(1); + expect( + result.findings.some( + (finding) => + finding.kind === "kernel-pointer-export-bypass" && + finding.text === "recv(1, 4096, 8)", + ), + ).toBe(true); + expect( + result.findings.some( + (finding) => + finding.kind === "kernel-memory-escape" && + finding.text.includes("intrinsicApply"), + ), + ).toBe(false); + expect( + result.findings.some( + (finding) => + finding.kind === "kernel-buffer-return" && + finding.enclosing === "memoryBuffer", + ), + ).toBe(false); + }); + + it("does not trust same-spelled custom getter helpers", () => { + const result = auditVirtual({ + "kernel.ts": ` + declare const intrinsicApply: ( + getter: unknown, + receiver: unknown, + args: readonly unknown[], + ) => ArrayBuffer; + const intrinsicMemoryBuffer = () => new ArrayBuffer(16); + + class Kernel { + memory!: WebAssembly.Memory; + write(bytes: Uint8Array): void { + const buffer = intrinsicApply( + intrinsicMemoryBuffer, + this.memory, + [], + ); + new Uint8Array(buffer).set(bytes); + } + } + `, + }); + + expect( + result.findings.some((finding) => finding.kind === "kernel-write"), + ).toBe(false); + expect( + result.findings.some( + (finding) => + finding.kind === "kernel-memory-escape" && + finding.text.includes("intrinsicApply"), + ), + ).toBe(true); + }); + + it("keeps captured export provenance through a checked helper", () => { + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` + const KERNEL_SCRATCH_EXPORT_NAMES = Object.freeze([ + "kernel_recv", + ] as const); + `, + "kernel.ts": ` + const intrinsicApply = Reflect.apply; + const intrinsicReflectGet = Reflect.get; + const intrinsicInstanceExports = Object.getOwnPropertyDescriptor( + WebAssembly.Instance.prototype, + "exports", + )!.get!; + + function instanceExports( + instance: WebAssembly.Instance, + ): WebAssembly.Exports { + try { + return intrinsicApply(intrinsicInstanceExports, instance, []); + } catch { + return intrinsicReflectGet( + instance, + "exports", + ) as WebAssembly.Exports; + } + } + + function requiredExport( + instance: WebAssembly.Instance, + name: string, + ): (...args: number[]) => number { + const value = instanceExports(instance)[name]; + if (typeof value !== "function") throw new Error("missing"); + return value as (...args: number[]) => number; + } + + class Kernel { + instance!: WebAssembly.Instance; + invoke(): void { + const recv = requiredExport(this.instance, "kernel_recv"); + recv(1, 4096, 8); + } + } + `, + }, + [ + { + declaration: "kernel.ts::Kernel.instance", + target: "value", + owner: "kernel", + form: "instance", + why: "This fixture field is the exact instantiated kernel module.", + }, + ], + ); + + expect( + result.findings.some( + (finding) => + finding.kind === "kernel-pointer-export-bypass" && + finding.text === "recv(1, 4096, 8)", + ), + result.findings.map(({ key }) => key).join("\n"), + ).toBe(true); + }); + + it("models exact captured reads, detached slice, and Uint8Array set", () => { + const result = auditVirtual({ + "kernel.ts": ` + const intrinsicApply = Reflect.apply; + const intrinsicArrayBufferByteLength = + Object.getOwnPropertyDescriptor( + ArrayBuffer.prototype, + "byteLength", + )!.get!; + const intrinsicSharedArrayBufferByteLength = + Object.getOwnPropertyDescriptor( + SharedArrayBuffer.prototype, + "byteLength", + )!.get!; + const intrinsicDataViewGetUint16 = DataView.prototype.getUint16; + const intrinsicDataViewGetUint32 = DataView.prototype.getUint32; + const intrinsicUint8ArraySet = Uint8Array.prototype.set; + const intrinsicUint8ArraySlice = Uint8Array.prototype.slice; + const intrinsicAtomicsWait = Atomics.wait; + const intrinsicAtomicsNotify = Atomics.notify; + + class Kernel { + memory!: WebAssembly.Memory; + inspect(bytes: Uint8Array): void { + const buffer = this.memory.buffer; + const view = new Uint8Array(buffer); + const data = new DataView(buffer); + const words = new Int32Array(buffer); + intrinsicApply( + intrinsicArrayBufferByteLength, + buffer, + [], + ); + intrinsicApply( + intrinsicSharedArrayBufferByteLength, + buffer, + [], + ); + intrinsicApply( + intrinsicDataViewGetUint16, + data, + [0, true], + ); + intrinsicApply( + intrinsicDataViewGetUint32, + data, + [0, true], + ); + const detached = intrinsicApply( + intrinsicUint8ArraySlice, + view, + [0, 1], + ); + detached.set(bytes); + intrinsicApply( + intrinsicAtomicsWait, + Atomics, + [words, 0, 0, 0], + ); + intrinsicApply( + intrinsicAtomicsNotify, + Atomics, + [words, 0, 1], + ); + intrinsicApply( + intrinsicUint8ArraySet, + view, + [bytes, 0], + ); + } + } + `, + }); + + const intrinsicEscapes = result.findings.filter( (finding) => - finding.file === "kernel.ts" - && finding.kind === "kernel-write", - )).toBe(true); + finding.kind.endsWith("-escape") && + finding.text.includes("intrinsicApply"), + ); + expect(intrinsicEscapes).toEqual([]); + const writes = result.findings.filter( + (finding) => finding.kind === "kernel-write", + ); + expect(writes).toHaveLength(1); + expect(writes[0].text).toContain("intrinsicUint8ArraySet"); + }); + + it("does not admit a same-spelled captured mutator", () => { + const result = auditVirtual({ + "kernel.ts": ` + const intrinsicApply = Reflect.apply; + declare const intrinsicUint8ArraySet: ( + bytes: Uint8Array, + offset: number, + ) => void; + class Kernel { + memory!: WebAssembly.Memory; + write(bytes: Uint8Array): void { + const view = new Uint8Array(this.memory.buffer); + intrinsicApply( + intrinsicUint8ArraySet, + view, + [bytes, 0], + ); + } + } + `, + }); + + expect( + result.findings.some( + (finding) => + finding.kind === "kernel-view-escape" && + finding.text.includes("intrinsicApply"), + ), + ).toBe(true); }); it("covers DataView, element, Atomics, Buffer, and subarray writes", () => { @@ -138,12 +1074,15 @@ describe("WebAssembly memory write audit", () => { (finding) => finding.kind === "kernel-write", ); expect(writes.length).toBeGreaterThanOrEqual(7); - expect(writes.some((finding) => finding.text.includes("Atomics.store"))) - .toBe(true); - expect(writes.some((finding) => finding.text.includes("setBigInt64"))) - .toBe(true); - expect(writes.some((finding) => finding.text.includes("Buffer.from"))) - .toBe(true); + expect( + writes.some((finding) => finding.text.includes("Atomics.store")), + ).toBe(true); + expect(writes.some((finding) => finding.text.includes("setBigInt64"))).toBe( + true, + ); + expect(writes.some((finding) => finding.text.includes("Buffer.from"))).toBe( + true, + ); }); it("treats slice as detached while subarray retains the kernel backing", () => { @@ -193,15 +1132,15 @@ describe("WebAssembly memory write audit", () => { (finding) => finding.kind === "kernel-view-escape", ); expect(escapes).toHaveLength(2); - expect(escapes.some( - (finding) => finding.text.includes("raw[computedSet]"), - )).toBe(true); - expect(escapes.some( - (finding) => finding.text.includes("raw[computedCallable]"), - )).toBe(true); - expect(escapes.some( - (finding) => finding.text.includes("raw.slice()"), - )).toBe(false); + expect( + escapes.some((finding) => finding.text.includes("raw[computedSet]")), + ).toBe(true); + expect( + escapes.some((finding) => finding.text.includes("raw[computedCallable]")), + ).toBe(true); + expect( + escapes.some((finding) => finding.text.includes("raw.slice()")), + ).toBe(false); }); it("finds raw-view returns, persistent stores, and opaque writer escapes", () => { @@ -223,15 +1162,15 @@ describe("WebAssembly memory write audit", () => { `, }); - expect(result.findings.some( - (finding) => finding.kind === "kernel-view-store", - )).toBe(true); - expect(result.findings.some( - (finding) => finding.kind === "kernel-view-escape", - )).toBe(true); - expect(result.findings.some( - (finding) => finding.kind === "kernel-view-return", - )).toBe(true); + expect( + result.findings.some((finding) => finding.kind === "kernel-view-store"), + ).toBe(true); + expect( + result.findings.some((finding) => finding.kind === "kernel-view-escape"), + ).toBe(true); + expect( + result.findings.some((finding) => finding.kind === "kernel-view-return"), + ).toBe(true); }); it("propagates ownership through structured containers and destructuring", () => { @@ -269,18 +1208,23 @@ describe("WebAssembly memory write audit", () => { (finding) => finding.kind === "kernel-write", ); expect(writes).toHaveLength(3); - expect(writes.some((finding) => finding.enclosing === "Holder.write")) - .toBe(true); - expect(result.findings.some( - (finding) => - finding.kind === "kernel-memory-return" - && finding.enclosing === "wrap", - )).toBe(true); - expect(result.findings.some( - (finding) => - finding.kind === "kernel-buffer-return" - && finding.enclosing === "wrap", - )).toBe(true); + expect(writes.some((finding) => finding.enclosing === "Holder.write")).toBe( + true, + ); + expect( + result.findings.some( + (finding) => + finding.kind === "kernel-memory-return" && + finding.enclosing === "wrap", + ), + ).toBe(true); + expect( + result.findings.some( + (finding) => + finding.kind === "kernel-buffer-return" && + finding.enclosing === "wrap", + ), + ).toBe(true); }); it("tracks callback containers and parameter-property symbol aliases", () => { @@ -304,11 +1248,13 @@ describe("WebAssembly memory write audit", () => { `, }); - expect(result.findings.some( - (finding) => - finding.kind === "kernel-buffer-store" - && finding.text === "this.saved = initial.buffer", - )).toBe(true); + expect( + result.findings.some( + (finding) => + finding.kind === "kernel-buffer-store" && + finding.text === "this.saved = initial.buffer", + ), + ).toBe(true); }); it("tracks destructuring assignments and unknown object properties", () => { @@ -331,16 +1277,19 @@ describe("WebAssembly memory write audit", () => { `, }); - expect(result.findings.some( - (finding) => - finding.kind === "kernel-write" - && finding.text === "alias.set(data)", - )).toBe(true); - expect(result.findings.some( - (finding) => - finding.kind === "kernel-memory-escape" - && finding.text === "opaque({ ...first })", - )).toBe(true); + expect( + result.findings.some( + (finding) => + finding.kind === "kernel-write" && finding.text === "alias.set(data)", + ), + ).toBe(true); + expect( + result.findings.some( + (finding) => + finding.kind === "kernel-memory-escape" && + finding.text === "opaque({ ...first })", + ), + ).toBe(true); }); it("finds spread-argument escapes and writes in assignment patterns", () => { @@ -359,21 +1308,25 @@ describe("WebAssembly memory write audit", () => { `, }); - expect(result.findings.some( - (finding) => - finding.kind === "kernel-view-escape" - && finding.text === "opaque(...[view])", - )).toBe(true); + expect( + result.findings.some( + (finding) => + finding.kind === "kernel-view-escape" && + finding.text === "opaque(...[view])", + ), + ).toBe(true); const writes = result.findings.filter( (finding) => finding.kind === "kernel-write", ); expect(writes).toHaveLength(2); - expect(writes.some((finding) => finding.text === "[view[0]] = [1]")) - .toBe(true); - expect(writes.some( - (finding) => - finding.text === "{ value: view[1] } = { value: 2 }", - )).toBe(true); + expect(writes.some((finding) => finding.text === "[view[0]] = [1]")).toBe( + true, + ); + expect( + writes.some( + (finding) => finding.text === "{ value: view[1] } = { value: 2 }", + ), + ).toBe(true); }); it("propagates comma, logical, Array.at, and for-of aliases", () => { @@ -398,14 +1351,18 @@ describe("WebAssembly memory write audit", () => { (finding) => finding.kind === "kernel-write", ); expect(writes).toHaveLength(4); - expect(writes.some((finding) => finding.text === "(0, view).set(data)")) - .toBe(true); - expect(writes.some((finding) => finding.text === "fromAt.set(data)")) - .toBe(true); - expect(writes.some((finding) => finding.text === "item.set(data)")) - .toBe(true); - expect(writes.some((finding) => finding.text === "fromLogical?.set(data)")) - .toBe(true); + expect( + writes.some((finding) => finding.text === "(0, view).set(data)"), + ).toBe(true); + expect(writes.some((finding) => finding.text === "fromAt.set(data)")).toBe( + true, + ); + expect(writes.some((finding) => finding.text === "item.set(data)")).toBe( + true, + ); + expect( + writes.some((finding) => finding.text === "fromLogical?.set(data)"), + ).toBe(true); }); it("covers common intrinsic Array element and callback flows", () => { @@ -457,8 +1414,9 @@ describe("WebAssembly memory write audit", () => { const writes = result.findings.filter( (finding) => finding.kind === "kernel-write", ); - expect(writes.filter((finding) => finding.text === "item.set(data)")) - .toHaveLength(7); + expect( + writes.filter((finding) => finding.text === "item.set(data)"), + ).toHaveLength(7); for (const text of [ "found.set(data)", "foundLast.set(data)", @@ -497,12 +1455,14 @@ describe("WebAssembly memory write audit", () => { (finding) => finding.kind === "kernel-memory-escape", ); expect(escapes).toHaveLength(2); - expect(escapes.some( - (finding) => finding.text === "opaque(new Holder(this.memory))", - )).toBe(true); - expect(escapes.some( - (finding) => finding.text === "opaque(wrapper)", - )).toBe(true); + expect( + escapes.some( + (finding) => finding.text === "opaque(new Holder(this.memory))", + ), + ).toBe(true); + expect(escapes.some((finding) => finding.text === "opaque(wrapper)")).toBe( + true, + ); }); it("does not let casts hide direct access to private owner slots", () => { @@ -523,14 +1483,18 @@ describe("WebAssembly memory write audit", () => { `, }); - expect(result.findings.filter( - (finding) => - finding.kind === "kernel-write" - && finding.text.includes(".set(data)"), - )).toHaveLength(2); - expect(result.findings.filter( - (finding) => finding.kind === "kernel-memory-escape", - )).toHaveLength(1); + expect( + result.findings.filter( + (finding) => + finding.kind === "kernel-write" && + finding.text.includes(".set(data)"), + ), + ).toHaveLength(2); + expect( + result.findings.filter( + (finding) => finding.kind === "kernel-memory-escape", + ), + ).toHaveLength(1); }); it("propagates callable returns through implicit arrows and getters", () => { @@ -550,19 +1514,23 @@ describe("WebAssembly memory write audit", () => { `, }); - expect(result.findings.filter( - (finding) => finding.kind === "kernel-write", - )).toHaveLength(1); - expect(result.findings.some( - (finding) => - finding.kind === "kernel-memory-return" - && finding.enclosing === "Kernel.currentMemory", - )).toBe(true); - expect(result.findings.some( - (finding) => - finding.kind === "kernel-buffer-return" - && finding.enclosing.endsWith(".currentBuffer"), - )).toBe(true); + expect( + result.findings.filter((finding) => finding.kind === "kernel-write"), + ).toHaveLength(1); + expect( + result.findings.some( + (finding) => + finding.kind === "kernel-memory-return" && + finding.enclosing === "Kernel.currentMemory", + ), + ).toBe(true); + expect( + result.findings.some( + (finding) => + finding.kind === "kernel-buffer-return" && + finding.enclosing.endsWith(".currentBuffer"), + ), + ).toBe(true); }); it("does not treat custom slice or from methods as detached copies", () => { @@ -584,9 +1552,9 @@ describe("WebAssembly memory write audit", () => { `, }); - expect(result.findings.filter( - (finding) => finding.kind === "kernel-write", - )).toHaveLength(2); + expect( + result.findings.filter((finding) => finding.kind === "kernel-write"), + ).toHaveLength(2); }); it("recognizes aliased Uint8Array and DataView constructors", () => { @@ -604,12 +1572,12 @@ describe("WebAssembly memory write audit", () => { `, }); - expect(result.findings.filter( - (finding) => finding.kind === "kernel-view", - )).toHaveLength(2); - expect(result.findings.filter( - (finding) => finding.kind === "kernel-write", - )).toHaveLength(2); + expect( + result.findings.filter((finding) => finding.kind === "kernel-view"), + ).toHaveLength(2); + expect( + result.findings.filter((finding) => finding.kind === "kernel-write"), + ).toHaveLength(2); }); it("does not exempt shadowed view constructors or Buffer.from", () => { @@ -630,9 +1598,11 @@ describe("WebAssembly memory write audit", () => { `, }); - expect(result.findings.filter( - (finding) => finding.kind === "kernel-memory-escape", - )).toHaveLength(2); + expect( + result.findings.filter( + (finding) => finding.kind === "kernel-memory-escape", + ), + ).toHaveLength(2); }); it("finds raw memory and buffer calls, returns, and persistent stores", () => { @@ -679,49 +1649,62 @@ describe("WebAssembly memory write audit", () => { `expected ${kind}`, ).toBe(true); } - expect(result.findings.filter( - (finding) => finding.kind === "kernel-memory-store", - ).length).toBeGreaterThanOrEqual(2); - expect(result.findings.some( - (finding) => - finding.kind === "kernel-memory-store" - && finding.text === "retained = memory", - )).toBe(true); + expect( + result.findings.filter( + (finding) => finding.kind === "kernel-memory-store", + ).length, + ).toBeGreaterThanOrEqual(2); + expect( + result.findings.some( + (finding) => + finding.kind === "kernel-memory-store" && + finding.text === "retained = memory", + ), + ).toBe(true); }); it("finds module initializers and parameter-property defaults", () => { - const result = auditVirtual({ - "factory.ts": ` + const result = auditVirtual( + { + "factory.ts": ` export function kernelMemory(): WebAssembly.Memory { throw new Error("fixture"); } `, - "kernel.ts": ` + "kernel.ts": ` import { kernelMemory } from "./factory"; const retained = kernelMemory(); class Holder { constructor(readonly memory = kernelMemory()) {} } `, - }, [{ - declaration: "factory.ts::kernelMemory", - target: "return", - owner: "kernel", - form: "memory", - why: "This fixture factory returns only kernel memory.", - }]); + }, + [ + { + declaration: "factory.ts::kernelMemory", + target: "return", + owner: "kernel", + form: "memory", + why: "This fixture factory returns only kernel memory.", + }, + ], + ); expect(result.unresolvedSeeds).toEqual([]); - expect(result.findings.some( - (finding) => - finding.kind === "kernel-memory-store" - && finding.text === "retained = kernelMemory()", - )).toBe(true); - expect(result.findings.some( - (finding) => - finding.kind === "kernel-memory-store" - && finding.enclosing === "Holder.constructor", - )).toBe(true); + expect( + result.findings.some( + (finding) => + finding.kind === "kernel-memory-store" && + finding.text === "retained = kernelMemory()", + ), + ).toBe(true); + expect( + result.findings.some( + (finding) => + finding.kind === "kernel-memory-store" && + finding.enclosing === "Holder.constructor", + ), + ).toBe(true); }); it("does not confuse custom set/decode methods with synchronous platform readers", () => { @@ -751,12 +1734,15 @@ describe("WebAssembly memory write audit", () => { (finding) => finding.kind === "kernel-view-escape", ); expect(escapes).toHaveLength(2); - expect(escapes.some((finding) => finding.text.includes("sink.set"))) - .toBe(true); - expect(escapes.some((finding) => finding.text.includes("sink.decode"))) - .toBe(true); - expect(escapes.some((finding) => finding.text.includes("TextDecoder"))) - .toBe(false); + expect(escapes.some((finding) => finding.text.includes("sink.set"))).toBe( + true, + ); + expect( + escapes.some((finding) => finding.text.includes("sink.decode")), + ).toBe(true); + expect( + escapes.some((finding) => finding.text.includes("TextDecoder")), + ).toBe(false); }); it("does not exempt shadowed typed-array, decoder, or Atomics readers", () => { @@ -788,16 +1774,19 @@ describe("WebAssembly memory write audit", () => { (finding) => finding.kind === "kernel-view-escape", ); expect(escapes).toHaveLength(3); - expect(escapes.some((finding) => finding.text.includes("Uint8Array"))) - .toBe(true); - expect(escapes.some((finding) => finding.text.includes("TextDecoder"))) - .toBe(true); - expect(escapes.some((finding) => finding.text.includes("Atomics.load"))) - .toBe(true); + expect(escapes.some((finding) => finding.text.includes("Uint8Array"))).toBe( + true, + ); + expect( + escapes.some((finding) => finding.text.includes("TextDecoder")), + ).toBe(true); + expect( + escapes.some((finding) => finding.text.includes("Atomics.load")), + ).toBe(true); }); - it("finds direct and multiply-aliased scratch allocator calls", () => { - const result = auditVirtual({ + it("finds aliased and reflected scratch allocation/reservation calls", () => { + const sources = { "kernel.ts": ` class Kernel { memory!: WebAssembly.Memory; @@ -812,17 +1801,73 @@ describe("WebAssembly memory write audit", () => { const pointer = exports.kernel_spawn_scratch_pointer as () => bigint; pointer(); + const transferBegin = + exports.kernel_transfer_scratch_begin as + (n: bigint) => bigint; + transferBegin.call(undefined, 256n); + const transferPointer = + exports["kernel_transfer_scratch_pointer"] as + (token: bigint) => bigint; + transferPointer.apply(undefined, [1n]); + const { + kernel_transfer_scratch_capacity: rawTransferCapacity, + } = exports; + const transferCapacity = rawTransferCapacity as + (token: bigint) => bigint; + transferCapacity(1n); + const transferCancel = + exports.kernel_transfer_scratch_cancel as + (token: bigint) => number; + const cancelAlias = transferCancel; + cancelAlias(1n); + cancelAlias(1n); } } `, - }); + }; + const result = auditVirtual(sources); - expect(result.findings.filter( - (finding) => finding.kind === "scratch-allocator-call", - )).toHaveLength(1); - expect(result.findings.filter( - (finding) => finding.kind === "spawn-reservation-call", - )).toHaveLength(2); + expect( + result.findings.filter( + (finding) => finding.kind === "scratch-allocator-call", + ), + ).toHaveLength(1); + const reservationFindings = result.findings.filter( + (finding) => finding.kind === "scratch-reservation-call", + ); + expect(reservationFindings.map((finding) => finding.text)).toEqual([ + "beginAlias(128n)", + "cancelAlias(1n)", + "cancelAlias(1n)", + "pointer()", + "transferBegin.call(undefined, 256n)", + "transferCapacity(1n)", + "transferPointer.apply(undefined, [1n])", + ]); + + const duplicateKey = reservationFindings.find( + (finding) => finding.text === "cancelAlias(1n)", + )!.key; + const allowedOnce = auditVirtual( + sources, + [kernelMemorySeed()], + [ + { + key: duplicateKey, + disposition: "scratch-core", + count: 1, + why: "The fixture deliberately admits only one exact cancellation.", + }, + ], + ); + expect( + allowedOnce.violations.filter( + (finding) => + finding.kind === "scratch-reservation-call" && + finding.key === duplicateKey, + ), + ).toHaveLength(1); + expect(allowedOnce.unusedAllowances).toEqual([]); }); it("keeps non-scratch ownership roots explicit without treating them as kernel", () => { @@ -856,8 +1901,9 @@ describe("WebAssembly memory write audit", () => { why: "Rust lends this checked destination for one synchronous call.", }, ]; - const result = auditVirtual({ - "owners.ts": ` + const result = auditVirtual( + { + "owners.ts": ` class Process { memory!: WebAssembly.Memory; write(data: Uint8Array): void { @@ -877,7 +1923,9 @@ describe("WebAssembly memory write audit", () => { write(data: Uint8Array): void { this.destination.set(data); } } `, - }, seeds); + }, + seeds, + ); expect(result.unresolvedSeeds).toEqual([]); expect(result.findings).toEqual([]); @@ -904,8 +1952,9 @@ describe("WebAssembly memory write audit", () => { const admitted = auditVirtual(sources, [kernelMemorySeed()], allowances); expect(formatAuditFailures(admitted)).toEqual([]); - const duplicate = auditVirtual({ - "kernel.ts": ` + const duplicate = auditVirtual( + { + "kernel.ts": ` class Kernel { memory!: WebAssembly.Memory; write(data: Uint8Array): void { @@ -915,25 +1964,49 @@ describe("WebAssembly memory write audit", () => { } } `, - }, [kernelMemorySeed()], allowances); - expect(duplicate.violations.some( - (finding) => finding.kind === "kernel-write", - )).toBe(true); - - const stale = auditVirtual(sources, [kernelMemorySeed()], [ - ...allowances, - { - key: "new-file.ts::missing::kernel-write::missing()", - disposition: "scratch-core", - why: "This deliberately stale entry must be rejected by the audit.", }, - ]); + [kernelMemorySeed()], + allowances, + ); + expect( + duplicate.violations.some((finding) => finding.kind === "kernel-write"), + ).toBe(true); + + const stale = auditVirtual( + sources, + [kernelMemorySeed()], + [ + ...allowances, + { + key: "new-file.ts::missing::kernel-write::missing()", + disposition: "scratch-core", + why: "This deliberately stale entry must be rejected by the audit.", + }, + ], + ); expect(stale.unusedAllowances).toHaveLength(1); + + const literalQuestionMarks = auditVirtual( + sources, + [kernelMemorySeed()], + [ + ...allowances, + { + key: "new-file.ts::missing::scratch-address-contract::value ?? null", + disposition: "scratch-core", + why: "Question marks are literal source text under exact key matching.", + }, + ], + ); + expect(literalQuestionMarks.unusedAllowances.map(({ key }) => key)).toEqual( + ["new-file.ts::missing::scratch-address-contract::value ?? null"], + ); }); it("audits a newly introduced source path without a filename allowlist", () => { - const result = auditVirtual({ - "new/subsystem/transfer.ts": ` + const result = auditVirtual( + { + "new/subsystem/transfer.ts": ` export class Kernel { memory!: WebAssembly.Memory; write(data: Uint8Array): void { @@ -941,20 +2014,25 @@ describe("WebAssembly memory write audit", () => { } } `, - }, [kernelMemorySeed("new/subsystem/transfer.ts::Kernel.memory")]); + }, + [kernelMemorySeed("new/subsystem/transfer.ts::Kernel.memory")], + ); expect(result.sourceFiles).toContain("new/subsystem/transfer.ts"); - expect(result.violations.some( - (finding) => finding.file === "new/subsystem/transfer.ts", - )).toBe(true); + expect( + result.violations.some( + (finding) => finding.file === "new/subsystem/transfer.ts", + ), + ).toBe(true); }); it.each(["js", "jsx", "mjs", "cjs"])( "audits a raw write introduced in a .%s runtime source", (extension) => { const file = `new/subsystem/transfer.${extension}`; - const result = auditVirtual({ - [file]: ` + const result = auditVirtual( + { + [file]: ` export class Kernel { memory; write(data) { @@ -962,14 +2040,16 @@ describe("WebAssembly memory write audit", () => { } } `, - }, [kernelMemorySeed(`${file}::Kernel.memory`)]); + }, + [kernelMemorySeed(`${file}::Kernel.memory`)], + ); expect(result.unresolvedSeeds).toEqual([]); - expect(result.violations.some( - (finding) => - finding.file === file - && finding.kind === "kernel-write", - )).toBe(true); + expect( + result.violations.some( + (finding) => finding.file === file && finding.kind === "kernel-write", + ), + ).toBe(true); }, ); @@ -989,16 +2069,18 @@ describe("WebAssembly memory write audit", () => { }); expect(result.unresolvedSeeds).toEqual([]); - expect(result.violations.some( - (finding) => - finding.file === "transfer.mjs" - && finding.kind === "kernel-write", - )).toBe(true); + expect( + result.violations.some( + (finding) => + finding.file === "transfer.mjs" && finding.kind === "kernel-write", + ), + ).toBe(true); }); it("tracks JavaScript raw-memory and view aliases", () => { - const result = auditVirtual({ - "transfer.mjs": ` + const result = auditVirtual( + { + "transfer.mjs": ` export function write(kernel, data) { const memory = kernel.getMemory(); const buffer = memory.buffer; @@ -1006,18 +2088,22 @@ describe("WebAssembly memory write audit", () => { bytes.set(data); } `, - }, []); + }, + [], + ); - expect(result.violations.some( - (finding) => - finding.file === "transfer.mjs" - && finding.kind === "kernel-write", - )).toBe(true); + expect( + result.violations.some( + (finding) => + finding.file === "transfer.mjs" && finding.kind === "kernel-write", + ), + ).toBe(true); }); it("propagates a JavaScript raw-memory argument into a helper parameter", () => { - const result = auditVirtual({ - "transfer.mjs": ` + const result = auditVirtual( + { + "transfer.mjs": ` function publish(memory, data) { const view = new DataView(memory.buffer); view.setUint32(0, data.byteLength, true); @@ -1026,18 +2112,22 @@ describe("WebAssembly memory write audit", () => { publish(kernel.getMemory(), data); } `, - }, []); + }, + [], + ); - expect(result.violations.some( - (finding) => - finding.file === "transfer.mjs" - && finding.kind === "kernel-write", - )).toBe(true); + expect( + result.violations.some( + (finding) => + finding.file === "transfer.mjs" && finding.kind === "kernel-write", + ), + ).toBe(true); }); it("does not turn JavaScript reads or ordinary buffer writes into kernel writes", () => { - const result = auditVirtual({ - "transfer.mjs": ` + const result = auditVirtual( + { + "transfer.mjs": ` export function inspect(kernel, ordinary, data) { const byteLength = kernel.getMemory().buffer.byteLength; const raw = new DataView(kernel.getMemory().buffer); @@ -1046,14 +2136,16 @@ describe("WebAssembly memory write audit", () => { return { byteLength, value }; } `, - }, []); + }, + [], + ); - expect(result.findings.some( - (finding) => finding.kind === "kernel-view", - )).toBe(true); - expect(result.findings.some( - (finding) => finding.kind === "kernel-write", - )).toBe(false); + expect( + result.findings.some((finding) => finding.kind === "kernel-view"), + ).toBe(true); + expect( + result.findings.some((finding) => finding.kind === "kernel-write"), + ).toBe(false); }); it("requires exact allowances for an unrelated JavaScript getMemory API", () => { @@ -1071,17 +2163,18 @@ describe("WebAssembly memory write audit", () => { why: "This fixture's unrelated cache API deliberately shares the reviewed getMemory spelling.", })); - expect(initial.violations.some( - (finding) => finding.kind === "kernel-write", - )).toBe(true); - expect(formatAuditFailures( - auditVirtual(sources, [], allowances), - )).toEqual([]); + expect( + initial.violations.some((finding) => finding.kind === "kernel-write"), + ).toBe(true); + expect(formatAuditFailures(auditVirtual(sources, [], allowances))).toEqual( + [], + ); }); it("flags direct, aliased, and JavaScript scratch-region factories", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": ` + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` export function allocateKernelScratchRegion(..._args: unknown[]): object { return {}; } @@ -1089,7 +2182,7 @@ describe("WebAssembly memory write audit", () => { return {}; } `, - "caller.ts": ` + "caller.ts": ` import { allocateKernelScratchRegion, reserveKernelScratchRegion as reserve, @@ -1098,11 +2191,13 @@ describe("WebAssembly memory write audit", () => { const alias = reserve; alias({}, () => ({ pointer: 4096, capacity: 32 }), 32, 4, "forged"); `, - "caller.js": ` + "caller.js": ` import { allocateKernelScratchRegion as make } from "./host/src/kernel-scratch"; make({}, () => 4096, 32, 4, "forged-js"); `, - }, []); + }, + [], + ); const factoryFindings = result.findings.filter( (finding) => finding.kind === "scratch-region-factory-call", @@ -1114,8 +2209,9 @@ describe("WebAssembly memory write audit", () => { }); it("tracks kernel instance export memory through TypeScript and JavaScript", () => { - const result = auditVirtual({ - "kernel.ts": ` + const result = auditVirtual( + { + "kernel.ts": ` export class Kernel { instance!: WebAssembly.Instance; getInstance(): WebAssembly.Instance { return this.instance; } @@ -1131,40 +2227,47 @@ describe("WebAssembly memory write audit", () => { } } `, - "diagnostic.js": ` + "diagnostic.js": ` export function overwrite(kernel, data) { const instance = kernel.getInstance(); const { exports } = instance; new Uint8Array(exports.memory.buffer).set(data); } `, - }, [{ - declaration: "kernel.ts::Kernel.instance", - target: "value", - owner: "kernel", - form: "instance", - why: "This fixture field is the instantiated kernel module.", - }]); + }, + [ + { + declaration: "kernel.ts::Kernel.instance", + target: "value", + owner: "kernel", + form: "instance", + why: "This fixture field is the instantiated kernel module.", + }, + ], + ); expect(result.unresolvedSeeds).toEqual([]); const writes = result.findings.filter( (finding) => finding.kind === "kernel-write", ); - expect(writes.filter((finding) => finding.file === "kernel.ts")) - .toHaveLength(2); - expect(writes.filter((finding) => finding.file === "diagnostic.js")) - .toHaveLength(1); + expect( + writes.filter((finding) => finding.file === "kernel.ts"), + ).toHaveLength(2); + expect( + writes.filter((finding) => finding.file === "diagnostic.js"), + ).toHaveLength(1); }); it("rejects every raw pointer-bearing kernel-export invocation shape", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": ` + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` const KERNEL_SCRATCH_EXPORT_NAMES = Object.freeze([ "kernel_ioctl", "kernel_recv", ] as const); `, - "caller.ts": ` + "caller.ts": ` declare function opaque(value: unknown): void; class Kernel { instance!: WebAssembly.Instance; @@ -1196,13 +2299,17 @@ describe("WebAssembly memory write audit", () => { } } `, - }, [{ - declaration: "caller.ts::Kernel.instance", - target: "value", - owner: "kernel", - form: "instance", - why: "This fixture field is the exact instantiated kernel module.", - }]); + }, + [ + { + declaration: "caller.ts::Kernel.instance", + target: "value", + owner: "kernel", + form: "instance", + why: "This fixture field is the exact instantiated kernel module.", + }, + ], + ); expect(result.contractErrors).toEqual([]); const bypasses = result.findings.filter( @@ -1231,7 +2338,355 @@ describe("WebAssembly memory write audit", () => { } }); - it("keeps raw-call exclusions exact and token-only exports out of scope", () => { + it("defaults generated kernel exports to denied even when the scratch list omits them", () => { + const result = auditWasmMemoryWrites({ + ...virtualAuditOptions( + { + "host/src/kernel-scratch.ts": ` + const KERNEL_SCRATCH_EXPORT_NAMES = Object.freeze([ + "kernel_recv", + ] as const); + `, + "caller.ts": ` + declare function retainExportNamespace( + strings: TemplateStringsArray, + namespace: WebAssembly.Exports, + ): unknown; + class Kernel { + instance!: WebAssembly.Instance; + leaked: unknown; + setSocketOption(value: number): number { + const set = this.instance.exports.kernel_setsockopt as ( + fd: number, + level: number, + name: number, + pointer: number, + length: number, + ) => number; + return set(7, 1, 2, value); + } + leakSocketOption(): unknown { + this.leaked = this.instance.exports.kernel_setsockopt; + return this.instance.exports.kernel_setsockopt; + } + reflectSocketOption(value: number): number { + const set = Reflect.get( + this.instance.exports, + "kernel_setsockopt", + ) as (...args: number[]) => number; + return set(7, 1, 2, value); + } + reflectNamedSocketOption(value: number): number { + const name = "kernel_setsockopt"; + const set = Reflect.get( + this.instance.exports, + name, + ) as (...args: number[]) => number; + return set(7, 1, 2, value); + } + reflectDynamicSocketOption( + name: string, + value: number, + ): number { + const set = Reflect.get( + this.instance.exports, + name, + ) as (...args: number[]) => number; + return set(7, 1, 2, value); + } + describeSocketOption(value: number): number { + const set = Object.getOwnPropertyDescriptor( + this.instance.exports, + "kernel_setsockopt", + )!.value as (...args: number[]) => number; + return set(7, 1, 2, value); + } + reflectDescribeSocketOption(value: number): number { + const descriptor = Reflect.getOwnPropertyDescriptor( + this.instance.exports, + "kernel_setsockopt", + )!; + const set = descriptor.value as (...args: number[]) => number; + return set(7, 1, 2, value); + } + describeAllSocketOptions(value: number): number { + const set = Object.getOwnPropertyDescriptors( + this.instance.exports, + ).kernel_setsockopt.value as (...args: number[]) => number; + return set(7, 1, 2, value); + } + valuesSocketOption(value: number): number { + const set = Object.values( + this.instance.exports, + )[0] as (...args: number[]) => number; + return set(7, 1, 2, value); + } + entriesSocketOption(value: number): number { + const set = Object.entries( + this.instance.exports, + )[0]![1] as (...args: number[]) => number; + return set(7, 1, 2, value); + } + assignedSocketOption(value: number): number { + const set = Object.assign( + {}, + this.instance.exports, + ).kernel_setsockopt as (...args: number[]) => number; + return set(7, 1, 2, value); + } + spreadSocketOption(value: number): number { + const set = { + ...this.instance.exports, + }.kernel_setsockopt as (...args: number[]) => number; + return set(7, 1, 2, value); + } + restSocketOption(value: number): number { + const { + ...copied + } = this.instance.exports; + const set = copied.kernel_setsockopt as + (...args: number[]) => number; + return set(7, 1, 2, value); + } + restAssignmentSocketOption(value: number): number { + let copied: WebAssembly.Exports; + ({ + ...copied + } = this.instance.exports); + const set = copied.kernel_setsockopt as + (...args: number[]) => number; + return set(7, 1, 2, value); + } + inheritedSocketOption(value: number): number { + const derived = Object.create(this.instance.exports) as + WebAssembly.Exports; + const set = derived.kernel_setsockopt as + (...args: number[]) => number; + return set(7, 1, 2, value); + } + setPrototypeSocketOption(value: number): number { + const derived = Object.setPrototypeOf( + {}, + this.instance.exports, + ) as WebAssembly.Exports; + const set = derived.kernel_setsockopt as + (...args: number[]) => number; + return set(7, 1, 2, value); + } + reflectSetPrototypeSocketOption(value: number): number { + const derived: WebAssembly.Exports = {}; + Reflect.setPrototypeOf(derived, this.instance.exports); + const set = derived.kernel_setsockopt as + (...args: number[]) => number; + return set(7, 1, 2, value); + } + protoLiteralSocketOption(value: number): number { + const derived = { + __proto__: this.instance.exports, + } as WebAssembly.Exports; + const set = derived.kernel_setsockopt as + (...args: number[]) => number; + return set(7, 1, 2, value); + } + proxySocketOption(value: number): number { + const derived = new Proxy(this.instance.exports, {}); + const set = derived.kernel_setsockopt as + (...args: number[]) => number; + return set(7, 1, 2, value); + } + reflectCallSocketOption(value: number): number { + const set = Reflect.get.call( + Reflect, + this.instance.exports, + "kernel_setsockopt", + ) as (...args: number[]) => number; + return set(7, 1, 2, value); + } + reflectApplySocketOption(value: number): number { + const set = Reflect.get.apply(Reflect, [ + this.instance.exports, + "kernel_setsockopt", + ]) as (...args: number[]) => number; + return set(7, 1, 2, value); + } + intrinsicApplySocketOption(value: number): number { + const set = Reflect.apply(Reflect.get, Reflect, [ + this.instance.exports, + "kernel_setsockopt", + ]) as (...args: number[]) => number; + return set(7, 1, 2, value); + } + descriptorCallSocketOption(value: number): number { + const descriptor = Object.getOwnPropertyDescriptor.call( + Object, + this.instance.exports, + "kernel_setsockopt", + )!; + const set = descriptor.value as (...args: number[]) => number; + return set(7, 1, 2, value); + } + descriptorApplySocketOption(value: number): number { + const descriptor = Reflect.apply( + Reflect.getOwnPropertyDescriptor, + Reflect, + [this.instance.exports, "kernel_setsockopt"], + )!; + const set = descriptor.value as (...args: number[]) => number; + return set(7, 1, 2, value); + } + spreadDispatchSocketOption(value: number): number { + const args: [WebAssembly.Exports, string] = [ + this.instance.exports, + "kernel_setsockopt", + ]; + const set = Reflect.get( + ...args + ) as (...args: number[]) => number; + return set(7, 1, 2, value); + } + taggedSocketOption(value: number): number { + const set = retainExportNamespace\`\${this.instance.exports}\` + as (...args: number[]) => number; + return set(7, 1, 2, value); + } + listExportNames(): string[] { + return Object.keys(this.instance.exports); + } + listExportKeys(): (string | symbol)[] { + return Reflect.ownKeys(this.instance.exports); + } + } + `, + }, + [ + { + declaration: "caller.ts::Kernel.instance", + target: "value", + owner: "kernel", + form: "instance", + why: "This fixture field is the exact instantiated kernel module.", + }, + ], + ), + kernelExportNames: ["kernel_recv", "kernel_setsockopt"], + }); + + expect(result.contractErrors).toEqual([]); + expect( + result.findings.some( + (finding) => + finding.kind === "kernel-export-direct-use" && + finding.text === "set(7, 1, 2, value)", + ), + ).toBe(true); + expect( + result.findings.some( + (finding) => + finding.kind === "kernel-export-direct-use" && + finding.text.includes( + "this.leaked = this.instance.exports.kernel_setsockopt", + ), + ), + ).toBe(true); + expect( + result.findings.some( + (finding) => + finding.kind === "kernel-export-direct-use" && + finding.text.includes( + "return this.instance.exports.kernel_setsockopt", + ), + ), + ).toBe(true); + expect( + result.findings.some( + (finding) => + finding.kind === "kernel-export-direct-use" && + finding.enclosing.endsWith(".reflectSocketOption"), + ), + ).toBe(true); + expect( + result.findings.some( + (finding) => + finding.kind === "kernel-export-direct-use" && + finding.enclosing.endsWith(".reflectNamedSocketOption"), + ), + ).toBe(true); + expect( + result.findings.some( + (finding) => + finding.kind === "kernel-pointer-export-bypass" && + finding.enclosing.endsWith(".reflectDynamicSocketOption"), + ), + ).toBe(true); + expect( + result.findings.some( + (finding) => + finding.kind === "kernel-export-direct-use" && + finding.enclosing.endsWith(".describeSocketOption"), + ), + ).toBe(true); + expect( + result.findings.some( + (finding) => + finding.kind === "kernel-export-direct-use" && + finding.enclosing.endsWith(".reflectDescribeSocketOption"), + ), + ).toBe(true); + const unmodeledExtractionMethods = [ + "describeAllSocketOptions", + "valuesSocketOption", + "entriesSocketOption", + "assignedSocketOption", + "spreadSocketOption", + "restSocketOption", + "restAssignmentSocketOption", + "inheritedSocketOption", + "setPrototypeSocketOption", + "reflectSetPrototypeSocketOption", + "protoLiteralSocketOption", + "proxySocketOption", + "reflectCallSocketOption", + "reflectApplySocketOption", + "intrinsicApplySocketOption", + "descriptorCallSocketOption", + "descriptorApplySocketOption", + "spreadDispatchSocketOption", + "taggedSocketOption", + "listExportNames", + "listExportKeys", + ].filter( + (method) => + !result.findings.some( + (finding) => + (finding.kind === "kernel-export-direct-use" || + finding.kind === "kernel-pointer-export-bypass") && + finding.enclosing.endsWith(`.${method}`), + ), + ); + expect(unmodeledExtractionMethods).toEqual([]); + }); + + it("requires every runtime scratch export to exist in the generated export set", () => { + const result = auditWasmMemoryWrites({ + ...virtualAuditOptions( + { + "host/src/kernel-scratch.ts": ` + const KERNEL_SCRATCH_EXPORT_NAMES = Object.freeze([ + "kernel_recv", + ] as const); + `, + }, + [], + ), + kernelExportNames: ["kernel_setsockopt"], + }); + + expect(result.contractErrors).toContain( + "kernel scratch export kernel_recv is absent from the generated kernel export set", + ); + }); + + it("keeps raw-call allowances exact and does not broadly exclude token-only exports", () => { const sources = { "host/src/kernel-scratch.ts": ` const KERNEL_SCRATCH_EXPORT_NAMES = Object.freeze([ @@ -1256,50 +2711,75 @@ describe("WebAssembly memory write audit", () => { } `, }; - const seeds: OwnershipSeed[] = [{ - declaration: "caller.ts::Kernel.instance", - target: "value", - owner: "kernel", - form: "instance", - why: "This fixture field is the exact instantiated kernel module.", - }]; + const seeds: OwnershipSeed[] = [ + { + declaration: "caller.ts::Kernel.instance", + target: "value", + owner: "kernel", + form: "instance", + why: "This fixture field is the exact instantiated kernel module.", + }, + ]; const initial = auditVirtual(sources, seeds); const ioctlFinding = initial.findings.find( (finding) => - finding.kind === "kernel-pointer-export-bypass" - && finding.text === "ioctl(1, 2, 3, 0, 4)", + finding.kind === "kernel-pointer-export-bypass" && + finding.text === "ioctl(1, 2, 3, 0, 4)", ); expect(ioctlFinding).toBeDefined(); - expect(initial.findings.some( - (finding) => - finding.kind === "kernel-pointer-export-bypass" - && finding.text.includes("reserved("), - )).toBe(false); + expect( + initial.findings.some( + (finding) => + finding.kind === "kernel-pointer-export-bypass" && + finding.text.includes("reserved("), + ), + ).toBe(false); + const generated = auditWasmMemoryWrites({ + ...virtualAuditOptions(sources, seeds), + kernelExportNames: ["kernel_ioctl", "kernel_spawn_reserved_process"], + }); + expect( + generated.findings.some( + (finding) => + finding.kind === "kernel-export-direct-use" && + finding.text === "reserved(1, 2, 3n, 4)", + ), + ).toBe(true); const allowance: AuditAllowance = { key: ioctlFinding!.key, disposition: "kernel-control", why: "This exact fixture models a reviewed scalar-only ioctl call.", }; - expect(formatAuditFailures(auditVirtual(sources, seeds, [allowance]))) - .toEqual([]); + expect( + formatAuditFailures(auditVirtual(sources, seeds, [allowance])), + ).toEqual([]); - const duplicated = auditVirtual({ - ...sources, - "caller.ts": sources["caller.ts"].replace( - "ioctl(1, 2, 3, 0, 4);", - "ioctl(1, 2, 3, 0, 4); ioctl(1, 2, 3, 0, 4);", + const duplicated = auditVirtual( + { + ...sources, + "caller.ts": sources["caller.ts"].replace( + "ioctl(1, 2, 3, 0, 4);", + "ioctl(1, 2, 3, 0, 4); ioctl(1, 2, 3, 0, 4);", + ), + }, + seeds, + [allowance], + ); + expect( + duplicated.violations.some( + (finding) => finding.kind === "kernel-pointer-export-bypass", ), - }, seeds, [allowance]); - expect(duplicated.violations.some( - (finding) => finding.kind === "kernel-pointer-export-bypass", - )).toBe(true); + ).toBe(true); }); it("fails closed when the authoritative pointer-export contract disappears", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": "export {};", - }, []); + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": "export {};", + }, + [], + ); expect(result.contractErrors).toHaveLength(1); expect(formatAuditFailures(result)[0]).toContain( "KERNEL_SCRATCH_EXPORT_NAMES", @@ -1307,8 +2787,9 @@ describe("WebAssembly memory write audit", () => { }); it("flags typed-array callbacks and iterators that retain a kernel view", () => { - const result = auditVirtual({ - "kernel.ts": ` + const result = auditVirtual( + { + "kernel.ts": ` class Kernel { raw!: Uint8Array; use(): void { @@ -1322,32 +2803,39 @@ describe("WebAssembly memory write audit", () => { } declare function opaque(...values: unknown[]): void; `, - }, [{ - declaration: "kernel.ts::Kernel.raw", - target: "value", - owner: "kernel", - form: "view", - why: "This fixture view aliases the kernel linear memory.", - }]); + }, + [ + { + declaration: "kernel.ts::Kernel.raw", + target: "value", + owner: "kernel", + form: "view", + why: "This fixture view aliases the kernel linear memory.", + }, + ], + ); - expect(result.findings.some( - (finding) => - finding.kind === "kernel-write" - && finding.text.includes("whole[0] = 1"), - )).toBe(true); - expect(result.findings.filter( - (finding) => - finding.kind === "kernel-view-escape" - && ( - finding.text.includes(".values()") - || finding.text.includes(".entries()") - ), - ).length).toBeGreaterThanOrEqual(2); + expect( + result.findings.some( + (finding) => + finding.kind === "kernel-write" && + finding.text.includes("whole[0] = 1"), + ), + ).toBe(true); + expect( + result.findings.filter( + (finding) => + finding.kind === "kernel-view-escape" && + (finding.text.includes(".values()") || + finding.text.includes(".entries()")), + ).length, + ).toBeGreaterThanOrEqual(2); }); it("accepts exact lease operations from a genuine exact region", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": ` + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` export interface KernelScratchDataView { setBigInt64(offset: number, value: bigint, littleEndian?: boolean): void; } @@ -1380,7 +2868,7 @@ describe("WebAssembly memory write audit", () => { withLease(operation: (lease: KernelScratchLease) => T): T; } `, - "caller.ts": ` + "caller.ts": ` import type { KernelScratchLease, KernelScratchRegion, @@ -1402,16 +2890,21 @@ describe("WebAssembly memory write audit", () => { } } `, - }, [scratchRegionSeed("caller.ts::Kernel.consume.$param:region")]); + }, + [scratchRegionSeed("caller.ts::Kernel.consume.$param:region")], + ); - expect(result.findings.filter( - (finding) => finding.kind === "scratch-address-contract", - )).toEqual([]); + expect( + result.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + ), + ).toEqual([]); }); it("accepts allocator-only fields and exact projected helper returns", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": ` + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` export interface KernelScratchExportPointer { readonly opaque: unique symbol; } @@ -1430,7 +2923,7 @@ describe("WebAssembly memory write audit", () => { throw new Error("fixture"); } `, - "caller.ts": ` + "caller.ts": ` import { allocateKernelScratchRegion, reserveKernelScratchRegion, @@ -1492,16 +2985,213 @@ describe("WebAssembly memory write audit", () => { ); }); `, - }, []); + }, + [], + ); + + expect( + result.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + ), + ).toEqual([]); + }); + + it("accepts only the exact WeakMap-validated scratch region projection", () => { + const scratchModule = ` + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + exportPointer( + offset: number, + length: number, + ): KernelScratchExportPointer; + invokeKernelExport( + name: string, + args: readonly unknown[], + ): number; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + } + export function validateKernelScratchRegionOwnership( + _candidate: KernelScratchRegion, + ): { readonly region: KernelScratchRegion } { + throw new Error("fixture private WeakMap validation"); + } + `; + const good = auditVirtual( + { + "host/src/kernel-scratch.ts": scratchModule, + "caller.ts": ` + import { + validateKernelScratchRegionOwnership, + type KernelScratchRegion, + } from "./host/src/kernel-scratch"; + class Kernel { + private region: KernelScratchRegion | null = null; + initialize(candidate: KernelScratchRegion): void { + const owner = + validateKernelScratchRegionOwnership(candidate); + this.region = owner.region; + } + run(): void { + if (!this.region) throw new Error("not initialized"); + this.region.withLease((lease) => { + const pointer = lease.exportPointer(0, 1); + lease.invokeKernelExport( + "kernel_recv", + [1, pointer, 1, 0], + ); + }); + } + } + `, + }, + [scratchRegionSeed("caller.ts::Kernel.region")], + ); + expect( + good.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + ), + ).toEqual([]); + + const unsafe = auditVirtual( + { + "host/src/kernel-scratch.ts": scratchModule, + "caller.ts": ` + import type { + KernelScratchRegion, + } from "./host/src/kernel-scratch"; + function validateKernelScratchRegionOwnership( + candidate: KernelScratchRegion, + ): { readonly region: KernelScratchRegion } { + return { region: candidate }; + } + class Kernel { + private region: KernelScratchRegion | null = null; + initialize(candidate: KernelScratchRegion): void { + const fakeOwner = + validateKernelScratchRegionOwnership(candidate); + this.region = fakeOwner.region; + } + run(): void { + if (!this.region) throw new Error("not initialized"); + this.region.withLease((lease) => { + const pointer = lease.exportPointer(0, 1); + lease.invokeKernelExport( + "kernel_recv", + [1, pointer, 1, 0], + ); + }); + } + } + `, + }, + [scratchRegionSeed("caller.ts::Kernel.region")], + ); + expect( + unsafe.findings.some( + (finding) => + finding.kind === "scratch-address-contract" && + finding.text.includes("this.region.withLease"), + ), + unsafe.findings.map((finding) => finding.text).join("\n"), + ).toBe(true); + }); + + it("canonicalizes #private roots only within their exact class owner", () => { + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + exportPointer(offset: number, length: number): KernelScratchExportPointer; + invokeKernelExport(name: string, args: readonly unknown[]): number; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + } + export function allocateKernelScratchRegion(): KernelScratchRegion { + throw new Error("fixture"); + } + `, + "caller.ts": ` + import { + allocateKernelScratchRegion, + type KernelScratchRegion, + } from "./host/src/kernel-scratch"; + class ExactOwner { + #region: KernelScratchRegion | null = null; + init(): void { + this.#region = allocateKernelScratchRegion(); + } + #requireRegion(): KernelScratchRegion { + if (!this.#region) throw new Error("not initialized"); + return this.#region; + } + #sameOwnerAlias(): KernelScratchRegion { + return this.#requireRegion(); + } + run(): void { + this.#sameOwnerAlias().withLease((lease) => { + const pointer = lease.exportPointer(0, 1); + lease.invokeKernelExport( + "kernel_recv", + [1, pointer, 1, 0], + ); + }); + } + } + class SameSpellingButUntrusted { + #region: KernelScratchRegion; + constructor(region: KernelScratchRegion) { + this.#region = region; + } + #requireRegion(): KernelScratchRegion { + return this.#region; + } + #sameOwnerAlias(): KernelScratchRegion { + return this.#requireRegion(); + } + run(): void { + this.#sameOwnerAlias().withLease((lease) => { + const pointer = lease.exportPointer(1, 1); + lease.invokeKernelExport( + "kernel_recv", + [1, pointer, 1, 0], + ); + }); + } + } + `, + }, + [], + ); - expect(result.findings.filter( + const violations = result.findings.filter( (finding) => finding.kind === "scratch-address-contract", - )).toEqual([]); + ); + expect( + violations.some((finding) => finding.enclosing.includes("ExactOwner")), + violations.map((finding) => finding.text).join("\n"), + ).toBe(false); + expect( + violations.some( + (finding) => + finding.enclosing.includes("SameSpellingButUntrusted") && + finding.text.includes("#sameOwnerAlias().withLease"), + ), + violations.map((finding) => finding.text).join("\n"), + ).toBe(true); }); it("rejects returned and persistently stored scratch-address aliases", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": ` + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` export interface KernelScratchLease { address(offset: number, length: number): number; } @@ -1509,7 +3199,7 @@ describe("WebAssembly memory write audit", () => { withLease(operation: (lease: KernelScratchLease) => T): T; } `, - "caller.ts": ` + "caller.ts": ` import type { KernelScratchRegion } from "./host/src/kernel-scratch"; declare const region: KernelScratchRegion; let retained = 0; @@ -1522,72 +3212,810 @@ describe("WebAssembly memory write audit", () => { return alias; }); `, - }, [scratchRegionSeed()]); + }, + [scratchRegionSeed()], + ); + + expect( + result.findings.some( + (finding) => + finding.kind === "scratch-address-contract" && + finding.text.includes("lease.address"), + ), + ).toBe(true); + }); + + it("rejects a lease retained by a deferred callback", () => { + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + copyFrom(source: Uint8Array, destinationOffset?: number): void; + exportPointer(offset: number, length: number): KernelScratchExportPointer; + invokeKernelExport( + name: string, + args: readonly ( + | number + | bigint + | KernelScratchExportPointer + )[], + ): number; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + } + `, + "caller.ts": ` + import type { KernelScratchRegion } from "./host/src/kernel-scratch"; + declare const region: KernelScratchRegion; + let deferred: () => void = () => {}; + + region.withLease((lease) => { + const pointer = lease.exportPointer(0, 8); + deferred = () => { + lease.copyFrom(new Uint8Array([1]), 0); + lease.invokeKernelExport( + "kernel_recv", + [1, pointer, 8, 0], + ); + }; + }); + deferred(); + `, + }, + [scratchRegionSeed()], + ); + + const violations = result.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + ); + expect( + violations.some((finding) => finding.text.includes("lease.copyFrom")), + violations.map((finding) => finding.text).join("\n"), + ).toBe(true); + expect( + violations.some((finding) => + finding.text.includes("lease.invokeKernelExport"), + ), + violations.map((finding) => finding.text).join("\n"), + ).toBe(true); + }); + + it("admits only the exact private worker entry scratch invoker", () => { + const scratchModule = ` + const KERNEL_SCRATCH_EXPORT_NAMES = Object.freeze([ + "kernel_recv", + ] as const); + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + invokeKernelExport( + name: string, + args: readonly ( + | number + | bigint + | KernelScratchExportPointer + )[], + ): number; + invokeKernelExportScoped( + scope: unknown, + name: string, + args: readonly ( + | number + | bigint + | KernelScratchExportPointer + )[], + ): number; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + } + `; + const exactWorker = (extraStatement = "") => ` + import type { + KernelScratchExportPointer, + KernelScratchLease, + KernelScratchRegion, + } from "./kernel-scratch"; + interface Entry { + readonly scope: unknown; + } + class CentralizedKernelWorker { + #invokeEntryScratchExport( + entry: Entry | undefined, + lease: KernelScratchLease, + name: string, + args: readonly ( + | number + | bigint + | KernelScratchExportPointer + )[], + ): number { + ${extraStatement} + return entry === undefined + ? lease.invokeKernelExport(name, args) + : lease.invokeKernelExportScoped(entry.scope, name, args); + } + run(region: KernelScratchRegion): void { + region.withLease((lease) => { + this.#invokeEntryScratchExport( + undefined, + lease, + "kernel_recv", + [], + ); + }); + } + } + `; + const seed = scratchRegionSeed( + "host/src/kernel-worker.ts::CentralizedKernelWorker.run.$param:region", + ); + + const exact = auditVirtual( + { + "host/src/kernel-scratch.ts": scratchModule, + "host/src/kernel-worker.ts": exactWorker(), + }, + [seed], + ); + expect( + exact.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + ), + ).toEqual([]); + + const bodyChanged = auditVirtual( + { + "host/src/kernel-scratch.ts": scratchModule, + "host/src/kernel-worker.ts": exactWorker("void lease;"), + }, + [seed], + ); + expect( + bodyChanged.findings.some( + (finding) => + finding.kind === "scratch-address-contract" && + finding.text === "lease", + ), + ).toBe(true); + + const wrongFile = auditVirtual( + { + "host/src/kernel-scratch.ts": scratchModule, + "host/src/not-kernel-worker.ts": exactWorker(), + }, + [ + scratchRegionSeed( + "host/src/not-kernel-worker.ts::CentralizedKernelWorker.run.$param:region", + ), + ], + ); + expect( + wrongFile.findings.some( + (finding) => + finding.kind === "scratch-address-contract" && + finding.text === "lease", + ), + ).toBe(true); + }); + + it("proves the capacity dispatcher is one synchronous two-phase lease", () => { + const scratchModule = ` + export interface KernelScratchExportPointer { + readonly opaque: unique symbol; + } + export interface KernelScratchLease { + copyFrom(source: Uint8Array, offset: number): void; + copyOut(offset: number, length: number): Uint8Array; + invokeKernelExport( + name: string, + args: readonly ( + | number + | bigint + | KernelScratchExportPointer + )[], + ): number; + invokeKernelExportScoped( + scope: unknown, + name: string, + args: readonly ( + | number + | bigint + | KernelScratchExportPointer + )[], + ): number; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + } + `; + const worker = ( + runBody: string, + { + conditionalReservedStage = false, + conditionalReservedExport = false, + conditionalCapacityStage = false, + conditionalCapacityExport = false, + }: { + conditionalReservedStage?: boolean; + conditionalReservedExport?: boolean; + conditionalCapacityStage?: boolean; + conditionalCapacityExport?: boolean; + } = {}, + ) => ` + import type { + KernelScratchExportPointer, + KernelScratchLease, + KernelScratchRegion, + } from "./kernel-scratch"; + interface KernelWorkerEntryContext { + readonly scope: unknown; + } + interface ChannelInfo { + readonly pid: number; + } + class CentralizedKernelWorker { + region!: KernelScratchRegion; + #invokeEntryScratchExport( + entry: KernelWorkerEntryContext | undefined, + lease: KernelScratchLease, + name: string, + args: readonly ( + | number + | bigint + | KernelScratchExportPointer + )[], + ): number { + return entry === undefined + ? lease.invokeKernelExport(name, args) + : lease.invokeKernelExportScoped(entry.scope, name, args); + } + #executeReservedChannelDispatch( + channel: ChannelInfo, + totalCapacity: number, + entry: KernelWorkerEntryContext, + stage: (lease: KernelScratchLease) => void, + finish: (lease: KernelScratchLease) => T, + retryToken = 0n, + ): { value: T | null; errno: number } { + void channel; + void totalCapacity; + const value = this.region.withLease((lease) => { + ${conditionalReservedStage ? "if (totalCapacity > 0) {" : ""} + stage(lease); + ${conditionalReservedStage ? "}" : ""} + ${conditionalReservedExport ? "if (totalCapacity > 0) {" : ""} + this.#invokeEntryScratchExport( + entry, + lease, + "kernel_transfer_channel_execute", + [retryToken], + ); + ${conditionalReservedExport ? "}" : ""} + return finish(lease); + }); + return { value, errno: 0 }; + } + #executeCapacityOwnedChannel( + channel: ChannelInfo, + totalCapacity: number, + entry: KernelWorkerEntryContext, + stage: (lease: KernelScratchLease) => void, + finish: (lease: KernelScratchLease) => T, + retryToken = 0n, + ): { value: T | null; errno: number } { + if (totalCapacity > 64) { + return this.#executeReservedChannelDispatch( + channel, + totalCapacity, + entry, + stage, + finish, + retryToken, + ); + } + const value = this.region.withLease((lease) => { + ${conditionalCapacityStage ? "if (totalCapacity > 0) {" : ""} + stage(lease); + ${conditionalCapacityStage ? "}" : ""} + ${conditionalCapacityExport ? "if (totalCapacity > 0) {" : ""} + this.#invokeEntryScratchExport( + entry, + lease, + "kernel_handle_channel", + [retryToken], + ); + ${conditionalCapacityExport ? "}" : ""} + return finish(lease); + }); + return { value, errno: 0 }; + } + run( + entry: KernelWorkerEntryContext, + bytes: Uint8Array, + ): void { + const channel = { pid: 1 }; + ${runBody} + } + } + `; + const seed = scratchRegionSeed( + "host/src/kernel-worker.ts::CentralizedKernelWorker.region", + ); + const safe = auditVirtual( + { + "host/src/kernel-scratch.ts": scratchModule, + "host/src/kernel-worker.ts": worker(` + this.#executeCapacityOwnedChannel( + channel, + 64, + entry, + (lease) => { + lease.copyFrom(bytes, 0); + }, + (lease) => lease.copyOut(0, bytes.length).byteLength, + 0n, + ); + `), + }, + [seed], + ); + expect( + safe.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + ), + ).toEqual([]); + + const safeRunBody = ` + this.#executeCapacityOwnedChannel( + channel, + 64, + entry, + (lease) => { + lease.copyFrom(bytes, 0); + }, + (lease) => lease.copyOut(0, bytes.length).byteLength, + 0n, + ); + `; + for (const alteredWorker of [ + worker(safeRunBody, { conditionalReservedStage: true }), + worker(safeRunBody, { conditionalReservedExport: true }), + worker(safeRunBody, { conditionalCapacityStage: true }), + worker(safeRunBody, { conditionalCapacityExport: true }), + ]) { + const conditional = auditVirtual( + { + "host/src/kernel-scratch.ts": scratchModule, + "host/src/kernel-worker.ts": alteredWorker, + }, + [seed], + ); + expect( + conditional.findings.some( + (finding) => finding.kind === "scratch-address-contract", + ), + ).toBe(true); + } + + const asyncStage = auditVirtual( + { + "host/src/kernel-scratch.ts": scratchModule, + "host/src/kernel-worker.ts": worker(` + this.#executeCapacityOwnedChannel( + channel, + 64, + entry, + async (lease) => { + lease.copyFrom(bytes, 0); + }, + (lease) => lease.copyOut(0, 1).byteLength, + 0n, + ); + `), + }, + [seed], + ); + expect( + asyncStage.findings.some( + (finding) => + finding.kind === "scratch-address-contract" && + finding.text.includes("#executeCapacityOwnedChannel"), + ), + ).toBe(true); + + const omittedRetryToken = auditVirtual( + { + "host/src/kernel-scratch.ts": scratchModule, + "host/src/kernel-worker.ts": worker(` + this.#executeCapacityOwnedChannel( + channel, + 64, + entry, + (lease) => { + lease.copyFrom(bytes, 0); + }, + (lease) => lease.copyOut(0, 1).byteLength, + ); + `), + }, + [seed], + ); + expect( + omittedRetryToken.findings.some( + (finding) => + finding.kind === "scratch-address-contract" && + finding.text.includes("#executeCapacityOwnedChannel"), + ), + ).toBe(true); + + const thenableCapture = auditVirtual( + { + "host/src/kernel-scratch.ts": scratchModule, + "host/src/kernel-worker.ts": worker(` + this.#executeCapacityOwnedChannel( + channel, + 64, + entry, + (lease) => { + lease.copyFrom(bytes, 0); + }, + (lease) => Promise.resolve().then( + () => lease.copyOut(0, 1).byteLength, + ), + 0n, + ); + `), + }, + [seed], + ); + expect( + thenableCapture.findings.some( + (finding) => + finding.kind === "scratch-address-contract" && + finding.text.includes("lease.copyOut"), + ), + ).toBe(true); + + const retained = auditVirtual( + { + "host/src/kernel-scratch.ts": scratchModule, + "host/src/kernel-worker.ts": worker(` + let retainedLease: KernelScratchLease | undefined; + this.#executeCapacityOwnedChannel( + channel, + 64, + entry, + (lease) => { + retainedLease = lease; + }, + (lease) => lease.copyOut(0, 1).byteLength, + 0n, + ); + void retainedLease; + `), + }, + [seed], + ); + expect( + retained.findings.some( + (finding) => + finding.kind === "scratch-address-contract" && + finding.text === "lease", + ), + ).toBe(true); + }); - expect(result.findings.some( - (finding) => - finding.kind === "scratch-address-contract" - && finding.text.includes("lease.address"), - )).toBe(true); + it("proves reserved spawn keeps one token paired with one staged region", () => { + const scratchModule = ` + const KERNEL_SCRATCH_EXPORT_NAMES = Object.freeze([ + "kernel_spawn_process", + ] as const); + export interface KernelScratchLease { + copyFrom( + source: Uint8Array, + destinationOffset: number, + sourceOffset: number, + length: number, + ): void; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + revoke(): void; + } + `; + const worker = ({ + token = "reservation.token", + beforeLease = "", + spawnBeforeCopy = false, + conditionalCopy = false, + conditionalCleanup = false, + }: { + token?: string; + beforeLease?: string; + spawnBeforeCopy?: boolean; + conditionalCopy?: boolean; + conditionalCleanup?: boolean; + } = {}) => ` + import type { + KernelScratchLease, + KernelScratchRegion, + } from "./kernel-scratch"; + declare function opaque(...values: unknown[]): void; + interface KernelWorkerEntryContext {} + interface ReservedSpawnScratch { + readonly region: KernelScratchRegion | null; + readonly token: bigint; + } + class CentralizedKernelWorker { + instance!: WebAssembly.Instance; + otherReservation!: ReservedSpawnScratch; + #beginLargeSpawnScratch( + _blobLen: number, + _entry: KernelWorkerEntryContext, + ): { + reservation: ReservedSpawnScratch | null; + errno: number; + } { + throw new Error("fixture"); + } + #cancelLargeSpawnScratch( + _token: bigint, + _entry: KernelWorkerEntryContext, + ): void {} + #handleSpawnAfterResolve( + parentPid: number, + callerTid: number, + blobBytes: Uint8Array, + blobLen: number, + entry: KernelWorkerEntryContext, + ): number { + const reservedSpawn = this.instance.exports + .kernel_spawn_reserved_process as ( + parentPid: number, + callerTid: number, + token: bigint, + length: number, + ) => number; + let reservation: ReservedSpawnScratch | null = null; + let result = -1; + try { + const begun = this.#beginLargeSpawnScratch(blobLen, entry); + reservation = begun.reservation; + if (reservation?.region) { + const activeRegion = reservation.region; + const activeToken = ${token}; + ${beforeLease} + result = activeRegion.withLease((scratch) => { + ${ + spawnBeforeCopy + ? ` + const spawnResult = reservedSpawn( + parentPid, + callerTid, + activeToken, + blobLen, + ); + scratch.copyFrom(blobBytes, 0, 0, blobLen); + ` + : ` + ${conditionalCopy ? "if (blobLen > 1) {" : ""} + scratch.copyFrom(blobBytes, 0, 0, blobLen); + ${conditionalCopy ? "}" : ""} + const spawnResult = reservedSpawn( + parentPid, + callerTid, + activeToken, + blobLen, + ); + ` + } + return spawnResult; + }); + } + } finally { + if (reservation) { + ${conditionalCleanup ? "if (blobLen > 1) {" : ""} + reservation.region?.revoke(); + this.#cancelLargeSpawnScratch(reservation.token, entry); + ${conditionalCleanup ? "}" : ""} + } + } + return result; + } + } + `; + const audit = (workerSource: string) => + auditWasmMemoryWrites({ + ...virtualAuditOptions( + { + "host/src/kernel-scratch.ts": scratchModule, + "host/src/kernel-worker.ts": workerSource, + }, + [ + { + declaration: + "host/src/kernel-worker.ts::CentralizedKernelWorker.instance", + target: "value", + owner: "kernel", + form: "instance", + why: "This fixture field is the exact instantiated kernel module.", + }, + ], + ), + kernelExportNames: [ + "kernel_spawn_process", + "kernel_spawn_reserved_process", + ], + }); + const hasReservedSpawnBypass = (workerSource: string): boolean => + audit(workerSource).findings.some( + (finding) => + finding.kind === "kernel-export-direct-use" && + finding.text.includes("reservedSpawn("), + ); + + expect(hasReservedSpawnBypass(worker())).toBe(false); + expect( + hasReservedSpawnBypass( + worker({ + token: "this.otherReservation.token", + }), + ), + ).toBe(true); + expect( + hasReservedSpawnBypass( + worker({ + spawnBeforeCopy: true, + }), + ), + ).toBe(true); + expect( + hasReservedSpawnBypass( + worker({ + beforeLease: "opaque(activeRegion, activeToken);", + }), + ), + ).toBe(true); + expect( + hasReservedSpawnBypass( + worker({ + conditionalCopy: true, + }), + ), + ).toBe(true); + expect( + hasReservedSpawnBypass( + worker({ + conditionalCleanup: true, + }), + ), + ).toBe(true); + expect( + hasReservedSpawnBypass( + worker({ + beforeLease: "reservation = this.otherReservation;", + }), + ), + ).toBe(true); + expect( + hasReservedSpawnBypass( + worker({ + beforeLease: "opaque(reservation);", + }), + ), + ).toBe(true); }); - it("rejects a lease retained by a deferred callback", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": ` - export interface KernelScratchExportPointer { - readonly opaque: unique symbol; + it("proves the two private flattened-copy helpers retain no lease", () => { + const scratchModule = ` + const KERNEL_SCRATCH_EXPORT_NAMES = Object.freeze([ + "kernel_recv", + ] as const); + export interface KernelScratchLease { + invokeKernelExport(name: string, args: readonly unknown[]): number; + copyFrom( + source: Uint8Array, + destinationOffset: number, + sourceOffset: number, + length: number, + ): void; + copyTo( + destination: Uint8Array, + sourceOffset: number, + destinationOffset: number, + length: number, + ): void; + } + export interface KernelScratchRegion { + withLease(operation: (lease: KernelScratchLease) => T): T; + } + `; + const worker = (extraInputStatement = "") => ` + import type { + KernelScratchLease, + KernelScratchRegion, + } from "./kernel-scratch"; + class CentralizedKernelWorker { + #copyFlattenedTransferInput( + lease: KernelScratchLease, + bytes: Uint8Array, + count: number, + offset: number, + ): void { + ${extraInputStatement} + for (let index = 0; index < count; index++) { + lease.copyFrom(bytes, offset + index, index, 1); + } } - export interface KernelScratchLease { - copyFrom(source: Uint8Array, destinationOffset?: number): void; - exportPointer(offset: number, length: number): KernelScratchExportPointer; - invokeKernelExport( - name: string, - args: readonly ( - | number - | bigint - | KernelScratchExportPointer - )[], - ): number; + #copyFlattenedTransferOutput( + lease: KernelScratchLease, + bytes: Uint8Array, + count: number, + offset: number, + length: number, + ): void { + for (let index = 0; index < count && index < length; index++) { + lease.copyTo(bytes, offset + index, index, 1); + } } - export interface KernelScratchRegion { - withLease(operation: (lease: KernelScratchLease) => T): T; + run(region: KernelScratchRegion, bytes: Uint8Array): void { + region.withLease((lease) => { + this.#copyFlattenedTransferInput(lease, bytes, 2, 0); + this.#copyFlattenedTransferOutput(lease, bytes, 2, 0, 2); + }); } - `, - "caller.ts": ` - import type { KernelScratchRegion } from "./host/src/kernel-scratch"; - declare const region: KernelScratchRegion; - let deferred: () => void = () => {}; - - region.withLease((lease) => { - const pointer = lease.exportPointer(0, 8); - deferred = () => { - lease.copyFrom(new Uint8Array([1]), 0); - lease.invokeKernelExport( - "kernel_recv", - [1, pointer, 8, 0], - ); - }; - }); - deferred(); - `, - }, [scratchRegionSeed()]); + } + `; + const seed = scratchRegionSeed( + "host/src/kernel-worker.ts::CentralizedKernelWorker.run.$param:region", + ); - const violations = result.findings.filter( - (finding) => finding.kind === "scratch-address-contract", + const exact = auditVirtual( + { + "host/src/kernel-scratch.ts": scratchModule, + "host/src/kernel-worker.ts": worker(), + }, + [seed], ); - expect(violations.some( - (finding) => - finding.text.includes("lease.copyFrom"), - ), violations.map((finding) => finding.text).join("\n")).toBe(true); - expect(violations.some( - (finding) => - finding.text.includes("lease.invokeKernelExport"), - ), violations.map((finding) => finding.text).join("\n")).toBe(true); + expect( + exact.findings.filter( + (finding) => finding.kind === "scratch-address-contract", + ), + ).toEqual([]); + + for (const hiddenUse of [ + "void lease;", + 'eval("lease");', + "void arguments[0];", + ]) { + const changed = auditVirtual( + { + "host/src/kernel-scratch.ts": scratchModule, + "host/src/kernel-worker.ts": worker(hiddenUse), + }, + [seed], + ); + expect( + changed.findings.some( + (finding) => + finding.kind === "scratch-address-contract" && + finding.text === "lease", + ), + `${hiddenUse}\n${changed.findings.map(({ text }) => text).join("\n")}`, + ).toBe(true); + } }); it("rejects a lease before it can cross an opaque helper boundary", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": ` + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` export interface KernelScratchExportPointer { readonly opaque: unique symbol; } @@ -1606,7 +4034,7 @@ describe("WebAssembly memory write audit", () => { withLease(operation: (lease: KernelScratchLease) => T): T; } `, - "caller.ts": ` + "caller.ts": ` import type { KernelScratchLease, KernelScratchRegion, @@ -1625,19 +4053,20 @@ describe("WebAssembly memory write audit", () => { invokeLater(lease); }); `, - }, [scratchRegionSeed()]); + }, + [scratchRegionSeed()], + ); const violations = result.findings.filter( (finding) => finding.kind === "scratch-address-contract", ); - expect(violations.some( - (finding) => finding.text === "lease", - )).toBe(true); + expect(violations.some((finding) => finding.text === "lease")).toBe(true); }); it("rejects an exact-typed lease without a genuine region origin", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": ` + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` export interface KernelScratchExportPointer { readonly opaque: unique symbol; } @@ -1653,7 +4082,7 @@ describe("WebAssembly memory write audit", () => { ): number; } `, - "caller.ts": ` + "caller.ts": ` import type { KernelScratchLease } from "./host/src/kernel-scratch"; declare const forged: KernelScratchLease; const pointer = forged.exportPointer(0, 8); @@ -1662,22 +4091,29 @@ describe("WebAssembly memory write audit", () => { [1, pointer, 8, 0], ); `, - }, []); + }, + [], + ); const violations = result.findings.filter( (finding) => finding.kind === "scratch-address-contract", ); - expect(violations.some( - (finding) => finding.text.includes("forged.exportPointer"), - )).toBe(true); - expect(violations.some( - (finding) => finding.text.includes("forged.invokeKernelExport"), - )).toBe(true); + expect( + violations.some((finding) => + finding.text.includes("forged.exportPointer"), + ), + ).toBe(true); + expect( + violations.some((finding) => + finding.text.includes("forged.invokeKernelExport"), + ), + ).toBe(true); }); it("rejects region and lease method extraction, reflection, and helpers", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": ` + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` export interface KernelScratchExportPointer { readonly opaque: unique symbol; } @@ -1696,7 +4132,7 @@ describe("WebAssembly memory write audit", () => { withLease(operation: (lease: KernelScratchLease) => T): T; } `, - "caller.ts": ` + "caller.ts": ` import type { KernelScratchLease, KernelScratchRegion, @@ -1725,7 +4161,9 @@ describe("WebAssembly memory write audit", () => { opaque(lease); }); `, - }, [scratchRegionSeed()]); + }, + [scratchRegionSeed()], + ); const violations = result.findings.filter( (finding) => finding.kind === "scratch-address-contract", @@ -1738,14 +4176,16 @@ describe("WebAssembly memory write audit", () => { "lease[key]", "lease", ]) { - expect(violations.some((finding) => finding.text.includes(snippet))) - .toBe(true); + expect(violations.some((finding) => finding.text.includes(snippet))).toBe( + true, + ); } }); it("rejects stored, returned, non-inline, and async leases", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": ` + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` export interface KernelScratchExportPointer { readonly opaque: unique symbol; } @@ -1764,7 +4204,7 @@ describe("WebAssembly memory write audit", () => { withLease(operation: (lease: KernelScratchLease) => T): T; } `, - "caller.ts": ` + "caller.ts": ` import type { KernelScratchLease, KernelScratchRegion, @@ -1784,7 +4224,9 @@ describe("WebAssembly memory write audit", () => { return lease; }); `, - }, [scratchRegionSeed()]); + }, + [scratchRegionSeed()], + ); const violations = result.findings.filter( (finding) => finding.kind === "scratch-address-contract", @@ -1802,8 +4244,9 @@ describe("WebAssembly memory write audit", () => { }); it("rejects structurally erased and forged scratch leases", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": ` + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` export interface KernelScratchExportPointer { readonly opaque: unique symbol; } @@ -1822,7 +4265,7 @@ describe("WebAssembly memory write audit", () => { withLease(operation: (lease: KernelScratchLease) => T): T; } `, - "caller.ts": ` + "caller.ts": ` import type { KernelScratchExportPointer, KernelScratchLease, @@ -1856,23 +4299,28 @@ describe("WebAssembly memory write audit", () => { ] as const; fake.invokeKernelExport(...args); `, - }, [scratchRegionSeed()]); + }, + [scratchRegionSeed()], + ); const violations = result.findings.filter( (finding) => finding.kind === "scratch-address-contract", ); expect(violations.some((finding) => finding.text === "lease")).toBe(true); - expect(violations.some( - (finding) => finding.text.includes("fake.exportPointer"), - )).toBe(true); - expect(violations.some( - (finding) => finding.text.includes("fake.invokeKernelExport"), - )).toBe(true); + expect( + violations.some((finding) => finding.text.includes("fake.exportPointer")), + ).toBe(true); + expect( + violations.some((finding) => + finding.text.includes("fake.invokeKernelExport"), + ), + ).toBe(true); }); it("rejects reintroducing or using a numeric scratch address member", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": ` + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` export interface KernelScratchLease { address(offset: number, length: number): number; } @@ -1880,29 +4328,32 @@ describe("WebAssembly memory write audit", () => { withLease(operation: (lease: KernelScratchLease) => T): T; } `, - "caller.ts": ` + "caller.ts": ` import type { KernelScratchRegion } from "./host/src/kernel-scratch"; declare const region: KernelScratchRegion; region.withLease((lease) => { lease.address(70, 1); }); `, - }, [scratchRegionSeed()]); + }, + [scratchRegionSeed()], + ); const violations = result.findings.filter( (finding) => finding.kind === "scratch-address-contract", ); - expect(violations.some( - (finding) => finding.text.includes("address(offset"), - )).toBe(true); - expect(violations.some( - (finding) => finding.text.includes("lease.address"), - )).toBe(true); + expect( + violations.some((finding) => finding.text.includes("address(offset")), + ).toBe(true); + expect( + violations.some((finding) => finding.text.includes("lease.address")), + ).toBe(true); }); it("rejects replacement of a seeded scratch region with a structural fake", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": ` + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` export interface KernelScratchExportPointer { readonly opaque: unique symbol; } @@ -1922,7 +4373,7 @@ describe("WebAssembly memory write audit", () => { revoke(): void; } `, - "caller.ts": ` + "caller.ts": ` import type { KernelScratchRegion, } from "./host/src/kernel-scratch"; @@ -1940,19 +4391,25 @@ describe("WebAssembly memory write audit", () => { } } `, - }, [scratchRegionSeed("caller.ts::Kernel.scratchRegion")]); + }, + [scratchRegionSeed("caller.ts::Kernel.scratchRegion")], + ); const violations = result.findings.filter( (finding) => finding.kind === "scratch-address-contract", ); - expect(violations.some( - (finding) => finding.text.includes("this.scratchRegion.withLease"), - ), violations.map((finding) => finding.text).join("\n")).toBe(true); + expect( + violations.some((finding) => + finding.text.includes("this.scratchRegion.withLease"), + ), + violations.map((finding) => finding.text).join("\n"), + ).toBe(true); }); it("rejects interposed and structurally forged seeded-field receivers", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": ` + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` export interface KernelScratchExportPointer { readonly opaque: unique symbol; } @@ -1964,7 +4421,7 @@ describe("WebAssembly memory write audit", () => { withLease(operation: (lease: KernelScratchLease) => T): T; } `, - "caller.ts": ` + "caller.ts": ` import type { KernelScratchRegion, } from "./host/src/kernel-scratch"; @@ -2033,36 +4490,41 @@ describe("WebAssembly memory write audit", () => { } } `, - }, [scratchRegionSeed("caller.ts::Kernel.region")]); + }, + [scratchRegionSeed("caller.ts::Kernel.region")], + ); const violations = result.findings.filter( (finding) => finding.kind === "scratch-address-contract", ); - for ( - const receiver of [ - "proxy", - "holder", - "castHolder", - "inherited", - "cloned", - ] - ) { - expect(violations.some( - (finding) => + for (const receiver of [ + "proxy", + "holder", + "castHolder", + "inherited", + "cloned", + ]) { + expect( + violations.some((finding) => finding.text.includes(`${receiver}.region.withLease`), - ), violations.map((finding) => finding.text).join("\n")).toBe(true); + ), + violations.map((finding) => finding.text).join("\n"), + ).toBe(true); } for (const receiver of ["proxyMethod", "methodHolder"]) { - expect(violations.some( - (finding) => + expect( + violations.some((finding) => finding.text.includes(`${receiver}.requireRegion().withLease`), - ), violations.map((finding) => finding.text).join("\n")).toBe(true); + ), + violations.map((finding) => finding.text).join("\n"), + ).toBe(true); } }); it("rejects immutable and reassigned structural erasure of a scratch lease", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": ` + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` export interface KernelScratchExportPointer { readonly opaque: unique symbol; } @@ -2077,7 +4539,7 @@ describe("WebAssembly memory write audit", () => { withLease(operation: (lease: KernelScratchLease) => T): T; } `, - "caller.ts": ` + "caller.ts": ` import type { KernelScratchExportPointer, KernelScratchLease, @@ -2110,25 +4572,32 @@ describe("WebAssembly memory write audit", () => { ); }); `, - }, [scratchRegionSeed()]); + }, + [scratchRegionSeed()], + ); const violations = result.findings.filter( (finding) => finding.kind === "scratch-address-contract", ); - expect(violations.some( - (finding) => finding.text.includes("lease"), - )).toBe(true); - expect(violations.some( - (finding) => finding.text.includes("exact.exportPointer"), - )).toBe(false); - expect(violations.some( - (finding) => finding.text.includes("exact.invokeKernelExport"), - )).toBe(false); + expect(violations.some((finding) => finding.text.includes("lease"))).toBe( + true, + ); + expect( + violations.some((finding) => + finding.text.includes("exact.exportPointer"), + ), + ).toBe(false); + expect( + violations.some((finding) => + finding.text.includes("exact.invokeKernelExport"), + ), + ).toBe(false); }); it("rejects an inline unknown cast that erases a scratch lease receiver", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": ` + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` export interface KernelScratchExportPointer { readonly opaque: unique symbol; } @@ -2143,7 +4612,7 @@ describe("WebAssembly memory write audit", () => { withLease(operation: (lease: KernelScratchLease) => T): T; } `, - "caller.ts": ` + "caller.ts": ` import type { KernelScratchExportPointer, KernelScratchRegion, @@ -2164,16 +4633,21 @@ describe("WebAssembly memory write audit", () => { ); }); `, - }, [scratchRegionSeed()]); + }, + [scratchRegionSeed()], + ); - expect(result.findings.some( - (finding) => finding.kind === "scratch-address-contract", - )).toBe(true); + expect( + result.findings.some( + (finding) => finding.kind === "scratch-address-contract", + ), + ).toBe(true); }); it("rejects destructured lease methods from declarations and assignments", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": ` + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` export interface KernelScratchExportPointer { readonly opaque: unique symbol; } @@ -2188,7 +4662,7 @@ describe("WebAssembly memory write audit", () => { withLease(operation: (lease: KernelScratchLease) => T): T; } `, - "caller.ts": ` + "caller.ts": ` import type { KernelScratchLease, KernelScratchRegion, @@ -2204,7 +4678,9 @@ describe("WebAssembly memory write audit", () => { reassigned.call(lease, "kernel_recv", []); }); `, - }, [scratchRegionSeed()]); + }, + [scratchRegionSeed()], + ); const violations = result.findings.filter( (finding) => finding.kind === "scratch-address-contract", @@ -2213,8 +4689,9 @@ describe("WebAssembly memory write audit", () => { }); it("rejects Reflect.get and Reflect.apply lease-method extraction", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": ` + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` export interface KernelScratchExportPointer { readonly opaque: unique symbol; } @@ -2229,7 +4706,7 @@ describe("WebAssembly memory write audit", () => { withLease(operation: (lease: KernelScratchLease) => T): T; } `, - "caller.ts": ` + "caller.ts": ` import type { KernelScratchLease, KernelScratchRegion, @@ -2251,7 +4728,9 @@ describe("WebAssembly memory write audit", () => { reassigned = () => 0; }); `, - }, [scratchRegionSeed()]); + }, + [scratchRegionSeed()], + ); const violations = result.findings.filter( (finding) => finding.kind === "scratch-address-contract", @@ -2260,8 +4739,9 @@ describe("WebAssembly memory write audit", () => { }); it("rejects scratch leases passed through destructured helper parameters", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": ` + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` export interface KernelScratchExportPointer { readonly opaque: unique symbol; } @@ -2276,7 +4756,7 @@ describe("WebAssembly memory write audit", () => { withLease(operation: (lease: KernelScratchLease) => T): T; } `, - "caller.ts": ` + "caller.ts": ` import type { KernelScratchLease, KernelScratchRegion, @@ -2303,7 +4783,9 @@ describe("WebAssembly memory write audit", () => { reassigned = () => 0; }); `, - }, [scratchRegionSeed()]); + }, + [scratchRegionSeed()], + ); const violations = result.findings.filter( (finding) => finding.kind === "scratch-address-contract", @@ -2312,8 +4794,9 @@ describe("WebAssembly memory write audit", () => { }); it("rejects structural erasure of the scratch region origin gate", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": ` + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` export interface KernelScratchExportPointer { readonly opaque: unique symbol; } @@ -2328,7 +4811,7 @@ describe("WebAssembly memory write audit", () => { withLease(operation: (lease: KernelScratchLease) => T): T; } `, - "caller.ts": ` + "caller.ts": ` import type { KernelScratchExportPointer, KernelScratchRegion, @@ -2351,16 +4834,21 @@ describe("WebAssembly memory write audit", () => { ); }); `, - }, [scratchRegionSeed()]); + }, + [scratchRegionSeed()], + ); - expect(result.findings.some( - (finding) => finding.kind === "scratch-address-contract", - )).toBe(true); + expect( + result.findings.some( + (finding) => finding.kind === "scratch-address-contract", + ), + ).toBe(true); }); it("rejects mutation and reflective interposition of scratch methods", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": ` + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` export interface KernelScratchExportPointer { readonly opaque: unique symbol; } @@ -2375,7 +4863,7 @@ describe("WebAssembly memory write audit", () => { withLease(operation: (lease: KernelScratchLease) => T): T; } `, - "caller.ts": ` + "caller.ts": ` import type { KernelScratchExportPointer, KernelScratchLease, @@ -2399,7 +4887,9 @@ describe("WebAssembly memory write audit", () => { Reflect.set(lease, "invokeKernelExport", () => 0); }); `, - }, [scratchRegionSeed()]); + }, + [scratchRegionSeed()], + ); const violations = result.findings.filter( (finding) => finding.kind === "scratch-address-contract", @@ -2408,8 +4898,9 @@ describe("WebAssembly memory write audit", () => { }); it("rejects function arguments and direct eval as hidden lease receivers", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": ` + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` export interface KernelScratchExportPointer { readonly opaque: unique symbol; } @@ -2424,7 +4915,7 @@ describe("WebAssembly memory write audit", () => { withLease(operation: (lease: KernelScratchLease) => T): T; } `, - "caller.ts": ` + "caller.ts": ` import type { KernelScratchExportPointer, KernelScratchRegion, @@ -2450,7 +4941,9 @@ describe("WebAssembly memory write audit", () => { ); }); `, - }, [scratchRegionSeed()]); + }, + [scratchRegionSeed()], + ); const violations = result.findings.filter( (finding) => finding.kind === "scratch-address-contract", @@ -2459,8 +4952,9 @@ describe("WebAssembly memory write audit", () => { }); it("rejects a conditional that mixes a real region with a structural fake", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": ` + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` export interface KernelScratchExportPointer { readonly opaque: unique symbol; } @@ -2483,7 +4977,7 @@ describe("WebAssembly memory write audit", () => { throw new Error("fixture"); } `, - "caller.ts": ` + "caller.ts": ` import { allocateKernelScratchRegion, type KernelScratchRegion, @@ -2500,19 +4994,23 @@ describe("WebAssembly memory write audit", () => { ); }); `, - }, []); + }, + [], + ); const violations = result.findings.filter( (finding) => finding.kind === "scratch-address-contract", ); - expect(violations.some( - (finding) => finding.text.includes("selected.withLease"), - ), violations.map((finding) => finding.text).join("\n")).toBe(true); + expect( + violations.some((finding) => finding.text.includes("selected.withLease")), + violations.map((finding) => finding.text).join("\n"), + ).toBe(true); }); it("rejects mutable and container contamination of a real scratch region", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": ` + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` export interface KernelScratchExportPointer { readonly opaque: unique symbol; } @@ -2535,7 +5033,7 @@ describe("WebAssembly memory write audit", () => { throw new Error("fixture"); } `, - "caller.ts": ` + "caller.ts": ` import { allocateKernelScratchRegion, type KernelScratchRegion, @@ -2563,21 +5061,27 @@ describe("WebAssembly memory write audit", () => { ); }); `, - }, []); + }, + [], + ); const violations = result.findings.filter( (finding) => finding.kind === "scratch-address-contract", ); for (const receiver of ["reassigned", "regions[index]"]) { - expect(violations.some( - (finding) => finding.text.includes(`${receiver}.withLease`), - ), violations.map((finding) => finding.text).join("\n")).toBe(true); + expect( + violations.some((finding) => + finding.text.includes(`${receiver}.withLease`), + ), + violations.map((finding) => finding.text).join("\n"), + ).toBe(true); } }); it("rejects mutable helper wrappers around a scratch lease", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": ` + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` export interface KernelScratchExportPointer { readonly opaque: unique symbol; } @@ -2595,7 +5099,7 @@ describe("WebAssembly memory write audit", () => { withLease(operation: (lease: KernelScratchLease) => T): T; } `, - "caller.ts": ` + "caller.ts": ` import type { KernelScratchLease, KernelScratchRegion, @@ -2617,22 +5121,29 @@ describe("WebAssembly memory write audit", () => { ); }); `, - }, [scratchRegionSeed()]); + }, + [scratchRegionSeed()], + ); const violations = result.findings.filter( (finding) => finding.kind === "scratch-address-contract", ); - expect(violations.some( - (finding) => finding.text === "lease", - ), violations.map((finding) => finding.text).join("\n")).toBe(true); - expect(violations.some( - (finding) => finding.text.includes("escaped.exportPointer"), - ), violations.map((finding) => finding.text).join("\n")).toBe(true); + expect( + violations.some((finding) => finding.text === "lease"), + violations.map((finding) => finding.text).join("\n"), + ).toBe(true); + expect( + violations.some((finding) => + finding.text.includes("escaped.exportPointer"), + ), + violations.map((finding) => finding.text).join("\n"), + ).toBe(true); }); it("rejects reflective replacement of seeded scratch authorities", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": ` + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` export interface KernelScratchExportPointer { readonly opaque: unique symbol; } @@ -2650,7 +5161,7 @@ describe("WebAssembly memory write audit", () => { withLease(operation: (lease: KernelScratchLease) => T): T; } `, - "caller.ts": ` + "caller.ts": ` import type { KernelScratchRegion, } from "./host/src/kernel-scratch"; @@ -2682,32 +5193,44 @@ describe("WebAssembly memory write audit", () => { } } `, - }, [ - scratchRegionSeed("caller.ts::Kernel.helperRegion"), - scratchRegionSeed("caller.ts::Kernel.region"), - ]); + }, + [ + scratchRegionSeed("caller.ts::Kernel.helperRegion"), + scratchRegionSeed("caller.ts::Kernel.region"), + ], + ); const violations = result.findings.filter( (finding) => finding.kind === "scratch-address-contract", ); - expect(violations.some( - (finding) => finding.text.includes("Object.defineProperty"), - ), violations.map((finding) => finding.text).join("\n")).toBe(true); - expect(violations.some( - (finding) => + expect( + violations.some((finding) => + finding.text.includes("Object.defineProperty"), + ), + violations.map((finding) => finding.text).join("\n"), + ).toBe(true); + expect( + violations.some((finding) => finding.text.includes("this.requireHelperRegion().withLease"), - ), violations.map((finding) => finding.text).join("\n")).toBe(true); - expect(violations.some( - (finding) => finding.text.includes("Reflect.set"), - ), violations.map((finding) => finding.text).join("\n")).toBe(true); - expect(violations.some( - (finding) => finding.text.includes("this.region.withLease"), - ), violations.map((finding) => finding.text).join("\n")).toBe(true); + ), + violations.map((finding) => finding.text).join("\n"), + ).toBe(true); + expect( + violations.some((finding) => finding.text.includes("Reflect.set")), + violations.map((finding) => finding.text).join("\n"), + ).toBe(true); + expect( + violations.some((finding) => + finding.text.includes("this.region.withLease"), + ), + violations.map((finding) => finding.text).join("\n"), + ).toBe(true); }); it("rejects aliased, bracketed, and call-wrapped reflective replacement", () => { - const result = auditVirtual({ - "host/src/kernel-scratch.ts": ` + const result = auditVirtual( + { + "host/src/kernel-scratch.ts": ` export interface KernelScratchExportPointer { readonly opaque: unique symbol; } @@ -2719,7 +5242,7 @@ describe("WebAssembly memory write audit", () => { withLease(operation: (lease: KernelScratchLease) => T): T; } `, - "caller.ts": ` + "caller.ts": ` import type { KernelScratchRegion } from "./host/src/kernel-scratch"; class Kernel { region!: KernelScratchRegion; @@ -2746,27 +5269,31 @@ describe("WebAssembly memory write audit", () => { } } `, - }, [scratchRegionSeed("caller.ts::Kernel.region")]); + }, + [scratchRegionSeed("caller.ts::Kernel.region")], + ); const violations = result.findings.filter( (finding) => finding.kind === "scratch-address-contract", ); - for ( - const call of [ - "defineAlias(", - 'Object["defineProperty"]', - "Object.defineProperty.call", - 'Reflect["set"]', - "assignAlias(", - ] - ) { - expect(violations.some( - (finding) => finding.text.includes(call), - ), violations.map((finding) => finding.text).join("\n")).toBe(true); + for (const call of [ + "defineAlias(", + 'Object["defineProperty"]', + "Object.defineProperty.call", + 'Reflect["set"]', + "assignAlias(", + ]) { + expect( + violations.some((finding) => finding.text.includes(call)), + violations.map((finding) => finding.text).join("\n"), + ).toBe(true); } - expect(violations.some( - (finding) => finding.text.includes("this.region.withLease"), - ), violations.map((finding) => finding.text).join("\n")).toBe(true); + expect( + violations.some((finding) => + finding.text.includes("this.region.withLease"), + ), + violations.map((finding) => finding.text).join("\n"), + ).toBe(true); }); it("discovers every JavaScript and TypeScript runtime extension", () => { @@ -2797,7 +5324,7 @@ describe("WebAssembly memory write audit", () => { expect( repositoryRuntimeSourceFiles(root).map((file) => - path.relative(root, file).split(path.sep).join("/") + path.relative(root, file).split(path.sep).join("/"), ), ).toEqual(expected); } finally { diff --git a/host/test/worker-kernel-import-contract.test.ts b/host/test/worker-kernel-import-contract.test.ts new file mode 100644 index 0000000000..1f90f71e8b --- /dev/null +++ b/host/test/worker-kernel-import-contract.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; + +import { assertSupportedKernelFunctionImports } from "../src/worker-main"; + +function section(id: number, payload: number[]): number[] { + return [id, payload.length, ...payload]; +} + +function wasmString(value: string): number[] { + const bytes = [...new TextEncoder().encode(value)]; + return [bytes.length, ...bytes]; +} + +function moduleImportingKernelFunction(name: string): WebAssembly.Module { + const typeSection = section(1, [1, 0x60, 0, 0]); + const importSection = section(2, [ + 1, + ...wasmString("kernel"), + ...wasmString(name), + 0, + 0, + ]); + return new WebAssembly.Module(new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, + 0x01, 0x00, 0x00, 0x00, + ...typeSection, + ...importSection, + ])); +} + +describe("process kernel-import contract", () => { + it("accepts only an explicitly callable channel-mode kernel import", () => { + const module = moduleImportingKernelFunction("kernel_fork"); + const kernelFork = vi.fn(); + + expect(() => + assertSupportedKernelFunctionImports(module, { + kernel_fork: kernelFork, + }) + ).not.toThrow(); + expect(kernelFork).not.toHaveBeenCalled(); + }); + + it.each([ + "kernel_readv", + "kernel_writev", + "kernel_preadv", + "kernel_pwritev", + ])("rejects obsolete direct import %s before instantiation", (name) => { + const module = moduleImportingKernelFunction(name); + const kernelImports: Record = {}; + + expect(() => + assertSupportedKernelFunctionImports(module, kernelImports) + ).toThrow( + `Unsupported kernel import kernel.${name}; ` + + "rebuild this program with the current Kandelo SDK", + ); + expect(kernelImports).toEqual({}); + }); + + it("rejects a non-callable placeholder instead of treating it as support", () => { + const module = moduleImportingKernelFunction("kernel_readv"); + + expect(() => + assertSupportedKernelFunctionImports(module, { + kernel_readv: 0 as unknown as WebAssembly.ExportValue, + }) + ).toThrow(/Unsupported kernel import kernel\.kernel_readv/); + }); + + it("does not mistake an inherited object function for an explicit import", () => { + const module = moduleImportingKernelFunction("toString"); + + expect(() => + assertSupportedKernelFunctionImports(module, {}) + ).toThrow(/Unsupported kernel import kernel\.toString/); + }); +}); diff --git a/libc/glue/abi_constants.h b/libc/glue/abi_constants.h index de8481c51c..4e59950c40 100644 --- a/libc/glue/abi_constants.h +++ b/libc/glue/abi_constants.h @@ -40,7 +40,10 @@ #define WASM_POSIX_CHANNEL_ERRNO_SIZE 4u #define WASM_POSIX_CHANNEL_REQUEST_FLAGS_OFFSET 68u #define WASM_POSIX_CHANNEL_REQUEST_FLAGS_SIZE 4u -#define WASM_POSIX_CHANNEL_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY 1u +#define WASM_POSIX_CHANNEL_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY 4u +#define WASM_POSIX_CHANNEL_REQUEST_FLAG_CANCELLATION_POINT 1u +#define WASM_POSIX_CHANNEL_REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED 2u +#define WASM_POSIX_CHANNEL_REQUEST_FLAGS_KNOWN_MASK 7u #define WASM_POSIX_CHANNEL_DATA_OFFSET 72u #define WASM_POSIX_CHANNEL_DATA_SIZE 65536u #define WASM_POSIX_CHANNEL_HEADER_SIZE 72u diff --git a/libc/glue/channel_syscall.c b/libc/glue/channel_syscall.c index e0885d1b99..04204dceda 100644 --- a/libc/glue/channel_syscall.c +++ b/libc/glue/channel_syscall.c @@ -18,7 +18,10 @@ #include #include +#include #include +#include +#include #include #include "abi_constants.h" @@ -78,6 +81,10 @@ int *__errno_location(void); #define CH_RETURN WASM_POSIX_CHANNEL_RETURN_OFFSET #define CH_ERRNO WASM_POSIX_CHANNEL_ERRNO_OFFSET #define CH_REQUEST_FLAGS WASM_POSIX_CHANNEL_REQUEST_FLAGS_OFFSET +#define CH_REQUEST_FLAG_CANCELLATION_POINT \ + WASM_POSIX_CHANNEL_REQUEST_FLAG_CANCELLATION_POINT +#define CH_REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED \ + WASM_POSIX_CHANNEL_REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED #define CH_SIG_SIGNUM WASM_POSIX_CHANNEL_SIG_SIGNUM_OFFSET #define CH_SIG_HANDLER WASM_POSIX_CHANNEL_SIG_HANDLER_OFFSET #define CH_SIG_FLAGS WASM_POSIX_CHANNEL_SIG_FLAGS_OFFSET @@ -91,6 +98,15 @@ int *__errno_location(void); _Static_assert(WASM_POSIX_CHANNEL_ARGS_COUNT == 6u, "channel syscall glue requires six argument slots"); +_Static_assert(WASM_POSIX_CHANNEL_REQUEST_FLAGS_SIZE == sizeof(uint32_t), + "channel request flags must remain one u32"); +_Static_assert( + WASM_POSIX_CHANNEL_REQUEST_FLAGS_KNOWN_MASK + == (WASM_POSIX_CHANNEL_REQUEST_FLAG_CANCELLATION_POINT + | WASM_POSIX_CHANNEL_REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED + | WASM_POSIX_CHANNEL_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY), + "channel request flag mask drift" +); _Static_assert(WASM_POSIX_CHANNEL_SIG_DELIVERY_SIZE <= WASM_POSIX_CHANNEL_SIG_AREA_SIZE, "signal delivery wire must fit its reserved channel area"); @@ -108,13 +124,88 @@ _Static_assert(sizeof(uint64_t) == WASM_POSIX_CHANNEL_SIG_ALT_SIZE_BYTES, #define EFAULT 14 #define EINTR 4 #define EINVAL 22 -#define SYS_OPEN 1 -#define SYS_OPENAT 69 -#define SYS_SIGACTION 36 -#define SYS_WAIT4 139 -#define SYS_WAITID 288 -#define SYS_SIGPROCMASK 37 -#define SYS_RT_SIGRETURN 208 +#define SYS_SIGACTION __NR_sigaction +#define SYS_WAIT4 __NR_wait4 +#define SYS_WAITID __NR_waitid +#define SYS_SIGPROCMASK __NR_sigprocmask +#define SYS_RT_SIGRETURN __NR_rt_sigreturn + +#define KANDELO_FUTEX_WAIT 0 +#define KANDELO_FUTEX_WAIT_BITSET 9 +#define KANDELO_FUTEX_CMD_MASK 0x7f + +/* + * Classify only operations whose zero-progress interruption may be submitted + * again after the caught handler runs. + * + * WHY: CH_SIG_FLAGS carries the effective action flags for this interruption. + * The host clears SA_RESTART in its owned signal record when an exact socket + * OFD has SO_RCVTIMEO/SO_SNDTIMEO, so the socket cases below cannot reset a + * live deadline. Relative-time readiness calls, signal waits, sleeps, and + * SysV IPC are deliberately absent: Linux exposes EINTR for them even when + * the action was installed with SA_RESTART. + */ +static int kandelo_should_restart_after_handler( + long n, + long long a1, + long long a2, + long long a3, + long long a4, + long long a5, + long long a6) +{ + (void)a1; + (void)a3; + (void)a5; + (void)a6; + + switch (n) { + case __NR_open: + case __NR_openat: + case __NR_wait4: + case __NR_waitid: + case __NR_read: + case __NR_write: + case __NR_pread: + case __NR_pwrite: + case __NR_readv: + case __NR_writev: + case __NR_preadv: + case __NR_pwritev: + case __NR_preadv2: + case __NR_pwritev2: + case __NR_accept: + case __NR_accept4: + case __NR_connect: + case __NR_send: + case __NR_recv: + case __NR_sendto: + case __NR_recvfrom: + case __NR_sendmsg: + case __NR_recvmsg: + case __NR_mq_timedsend: + case __NR_mq_timedreceive: + return 1; + case __NR_fcntl: + /* + * musl aliases the feature-gated F_SETLKW64 spelling to this same + * target command. Classify the canonical value so ordinary builds + * do not depend on _LARGEFILE64_SOURCE exposing the alias. + */ + return a2 == F_SETLKW + || a2 == F_OFD_SETLKW; + case __NR_flock: + return (a2 & LOCK_NB) == 0; + case __NR_futex: { + const long long command = a2 & KANDELO_FUTEX_CMD_MASK; + return a4 == 0 + && (command == KANDELO_FUTEX_WAIT + || command == KANDELO_FUTEX_WAIT_BITSET); + } + default: + return 0; + } +} /* The kernel ABI deliberately keeps sigaction's transport record fixed at * 16 bytes: u32 table index, u32 flags, u64 mask. musl's internal @@ -317,7 +408,7 @@ int vfork(void) /* ------------------------------------------------------------------ */ extern long __syscall_cp_check(long r); -extern int __syscall_cp_cancel_pending_disabled(void); +extern int __syscall_cp_cancel_wake_allowed(void); static uint32_t __deliver_pending_signal(uintptr_t base, int *delivered) { @@ -572,7 +663,24 @@ static long __do_syscall_impl(long n, long long a1, long long a2, long long a3, *(int64_t *)(uintptr_t)(base + CH_ARGS + 3 * CH_ARG_SIZE) = (int64_t)a4; *(int64_t *)(uintptr_t)(base + CH_ARGS + 4 * CH_ARG_SIZE) = (int64_t)a5; *(int64_t *)(uintptr_t)(base + CH_ARGS + 5 * CH_ARG_SIZE) = (int64_t)a6; - *(uint32_t *)(uintptr_t)(base + CH_REQUEST_FLAGS) = 0; + /* WHY: syscall number alone cannot distinguish a public cancellation + * point from an internal plain syscall using the same number (for example + * waitpid and wait4). Publish the call-site identity before the + * release-ordered PENDING store. The host consumes and clears it with this + * request, so mailbox reuse cannot inherit cancellation authority. */ + uint32_t request_flags = 0u; + if (cancellation_point) { + request_flags |= CH_REQUEST_FLAG_CANCELLATION_POINT; + /* + * WHY: the host cannot inspect musl's private pthread state. Freeze + * whether this exact cancellation point may be woken before PENDING + * is published. A disabled target keeps the operation and any finite + * deadline intact while pthread_cancel remains pending. + */ + if (__syscall_cp_cancel_wake_allowed()) + request_flags |= CH_REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED; + } + *(uint32_t *)(uintptr_t)(base + CH_REQUEST_FLAGS) = request_flags; /* Set status to PENDING and wake the kernel worker. * Use inline asm to read __channel_base directly from the wasm global, @@ -650,16 +758,13 @@ static long __do_syscall_impl(long n, long long a1, long long a2, long long a3, &delivered_signal ); - /* wait4()/waitid() and blocking FIFO open/openat are host-deferred, so a - * caught signal completes the channel with EINTR in order to run its handler. - * SA_RESTART makes that interruption transparent: after the handler and - * mask restoration finish, submit the same operation again. Keep the - * retry list deliberately narrow; several other EINTR-returning calls have - * timeout/cancellation rules that forbid this generic treatment. */ + /* A host-deferred blocking operation completes the channel with EINTR so + * the caught handler runs at the real interruption boundary. SA_RESTART + * resubmits only the explicitly classified zero-progress operations after + * handler mask restoration and cancellation preflight. */ if (err == EINTR && delivered_signal && (delivered_flags & SA_RESTART) != 0 && - (n == SYS_WAIT4 || n == SYS_WAITID || - n == SYS_OPEN || n == SYS_OPENAT)) { + kandelo_should_restart_after_handler(n, a1, a2, a3, a4, a5, a6)) { /* __syscall_cp's outer cancellation check has not run yet. A signal * handler may have enabled a cancellation that was already pending, * or the host may have used this EINTR completion to wake a canceled @@ -674,19 +779,6 @@ static long __do_syscall_impl(long n, long long a1, long long a2, long long a3, goto restart_wait_syscall; } - /* pthread_cancel wakes a host-deferred FIFO open with EINTR so an enabled - * target can unwind through __syscall_cp_check. If cancellation is - * disabled, POSIX requires the request to remain pending while open keeps - * blocking. The host has already released the exact FIFO reservation, so - * resubmit the operation to establish a fresh waiter. A separate - * delivered_signal bit is essential here: sigaction flags may legitimately - * be zero, and a real non-SA_RESTART handler must leave EINTR observable. */ - if (err == EINTR && cancellation_point && !delivered_signal && - (n == SYS_OPEN || n == SYS_OPENAT) && - __syscall_cp_cancel_pending_disabled()) { - goto restart_wait_syscall; - } - /* Return in musl's expected format: negative errno on error. * musl's __syscall_ret() converts this to set errno and return -1. */ if (err) { @@ -748,12 +840,13 @@ long __syscall6(long n, long long a1, long long a2, long long a3, long long a4, * handler can interrupt and re-direct to __cp_cancel. Wasm has no * equivalent, so we implement deferred cancellation on the guest side: * libc/musl-overlay/src/thread/wasm32posix/pthread_cancel.c provides - * __testcancel (pthread_exit path) and __syscall_cp_check (the + * __syscall_cp_cancel_preflight and __syscall_cp_check (the * one-function moral equivalent of stock __syscall_cp_asm + * __syscall_cp_c). We invoke them here around the blocking dispatch. * - * - Pre-dispatch: __testcancel() — if cancellation is pending and - * enabled, pthread_exit(PTHREAD_CANCELED) before we block. + * - Pre-dispatch: enabled cancellation exits before dispatch; MASKED + * cancellation returns ECANCELED so condition waits can relock first; + * DISABLE leaves the operation live. * - Post-dispatch: __syscall_cp_check(r) — if cancellation arrived * while we were blocked (host woke us with -EINTR on cancel), this * either calls pthread_exit (ENABLE state) or synthesizes @@ -767,12 +860,13 @@ long __syscall6(long n, long long a1, long long a2, long long a3, long long a4, * Async cancellation of a pure-CPU loop is not supported: there is no * wasm facility to preempt a running thread mid-computation. */ -extern void __testcancel(void); +extern long __syscall_cp_cancel_preflight(void); long __syscall_cp(long n, long long a1, long long a2, long long a3, long long a4, long long a5, long long a6) { - __testcancel(); + long pending = __syscall_cp_cancel_preflight(); + if (pending) return pending; long r = __do_syscall_impl(n, a1, a2, a3, a4, a5, a6, 1); return __syscall_cp_check(r); } diff --git a/libc/glue/syscall_glue.c b/libc/glue/syscall_glue.c index 43a86641ac..ff9106469b 100644 --- a/libc/glue/syscall_glue.c +++ b/libc/glue/syscall_glue.c @@ -388,17 +388,6 @@ static long __do_syscall(long n, long a1, long a2, long a3, case SYS_CLOSE: return (long)kernel_close((int32_t)a1); - /* read — (fd, buf, count) */ - case SYS_READ: - return (long)kernel_read((int32_t)a1, (uint8_t *)(uintptr_t)a2, - (uint32_t)a3); - - /* write — (fd, buf, count) */ - case SYS_WRITE: - return (long)kernel_write((int32_t)a1, - (const uint8_t *)(uintptr_t)a2, - (uint32_t)a3); - /* lseek — (fd, offset_lo, offset_hi, whence) * * Direct __syscall path: 4 args after splitting via __SYSCALL_LL_E. */ @@ -419,22 +408,6 @@ static long __do_syscall(long n, long a1, long a2, long a3, return 0; } - /* pread — (fd, buf, count, off_lo, off_hi) - * musl: syscall_cp(SYS_pread, fd, buf, size, __SYSCALL_LL_PRW(ofs)) - * __SYSCALL_LL_PRW → lo, hi → 5 data args */ - case SYS_PREAD: - return (long)kernel_pread((int32_t)a1, - (uint8_t *)(uintptr_t)a2, - (uint32_t)a3, - (uint32_t)a4, (int32_t)a5); - - /* pwrite — (fd, buf, count, off_lo, off_hi) */ - case SYS_PWRITE: - return (long)kernel_pwrite((int32_t)a1, - (const uint8_t *)(uintptr_t)a2, - (uint32_t)a3, - (uint32_t)a4, (int32_t)a5); - /* ============================================================== */ /* FD operations */ /* ============================================================== */ @@ -728,6 +701,8 @@ static long __do_syscall(long n, long a1, long a2, long a3, /* signal — (signum, handler) */ case SYS_SIGNAL: + if ((uint64_t)(uintptr_t)a2 > UINT32_MAX) + return -22; /* EINVAL */ return (long)kernel_signal((uint32_t)a1, (uint32_t)a2); /* @@ -911,22 +886,6 @@ static long __do_syscall(long n, long a1, long a2, long a3, return (long)kernel_fchown((int32_t)a1, (uint32_t)a2, (uint32_t)a3); - /* ============================================================== */ - /* Scatter-gather I/O */ - /* ============================================================== */ - - /* writev — (fd, iov, iovcnt) */ - case SYS_WRITEV: - return (long)kernel_writev((int32_t)a1, - (const uint8_t *)(uintptr_t)a2, - (int32_t)a3); - - /* readv — (fd, iov, iovcnt) */ - case SYS_READV: - return (long)kernel_readv((int32_t)a1, - (uint8_t *)(uintptr_t)a2, - (int32_t)a3); - /* ============================================================== */ /* Resource limits */ /* ============================================================== */ @@ -1343,13 +1302,13 @@ static long __do_syscall(long n, long a1, long a2, long a3, case SYS_SENDMSG: return (long)kernel_sendmsg((int32_t)a1, (const uint8_t *)(uintptr_t)a2, - (uint32_t)a3); + (uint32_t)a3, (int64_t)0); /* recvmsg — (fd, msg_ptr, flags) */ case SYS_RECVMSG: return (long)kernel_recvmsg((int32_t)a1, (uint8_t *)(uintptr_t)a2, - (uint32_t)a3); + (uint32_t)a3, (int64_t)0); /* getaddrinfo — (name, result_ptr) */ case SYS_GETADDRINFO: { @@ -1417,13 +1376,14 @@ static long __do_syscall(long n, long a1, long a2, long a3, case SYS_SET_TID_ADDRESS: /* STUB: single-threaded — ignore tidptr, return pid */ - return (long)kernel_set_tid_address((uint32_t)(uintptr_t)a1); + return (long)kernel_set_tid_address((uintptr_t)a1); case SYS_SET_ROBUST_LIST: - return (long)kernel_set_robust_list((uint32_t)(uintptr_t)a1, (uint32_t)a2); + return (long)kernel_set_robust_list((uintptr_t)a1, (size_t)a2); case SYS_GET_ROBUST_LIST: - return (long)kernel_get_robust_list((uint32_t)a1, (uint32_t)(uintptr_t)a2, (uint32_t)(uintptr_t)a3); + return (long)kernel_get_robust_list((uint32_t)a1, (uintptr_t)a2, + (uintptr_t)a3); /* ============================================================== */ /* Futex stub (single-threaded) */ @@ -1451,7 +1411,12 @@ static long __do_syscall(long n, long a1, long a2, long a3, return (long)kernel_epoll_ctl((int32_t)a1, (int32_t)a2, (int32_t)a3, (uint8_t *)(uintptr_t)a4); case SYS_EPOLL_PWAIT: - return (long)kernel_epoll_pwait((int32_t)a1, (uint8_t *)(uintptr_t)a2, (int32_t)a3, (int32_t)a4, (uint32_t)(uintptr_t)a5); + if (a5 && (size_t)a6 != 8) + return -22; /* EINVAL */ + return (long)kernel_epoll_pwait((int32_t)a1, + (uint8_t *)(uintptr_t)a2, + (int32_t)a3, (int32_t)a4, + (const uint8_t *)(uintptr_t)a5); /* ============================================================== */ /* ppoll — poll with signal mask */ @@ -1475,6 +1440,8 @@ static long __do_syscall(long n, long a1, long a2, long a3, timeout_ms = 1; /* round up to at least 1ms */ } const uint32_t *sigmask = (const uint32_t *)(uintptr_t)a4; + if (sigmask && (size_t)a5 != 8) + return -22; /* EINVAL */ uint32_t mask_lo = sigmask ? sigmask[0] : 0; uint32_t mask_hi = sigmask ? sigmask[1] : 0; return (long)kernel_ppoll(fds_ptr, nfds, timeout_ms, mask_lo, mask_hi); @@ -1504,12 +1471,19 @@ static long __do_syscall(long n, long a1, long a2, long a3, if (timeout_ms == 0 && (sec > 0 || nsec > 0)) timeout_ms = 1; } - /* a6 is pointer to {sigset_t *mask, size_t size} */ - const uint32_t *sigmask_struct = (const uint32_t *)(uintptr_t)a6; + /* a6 is pointer to {sigset_t *mask, size_t size}. */ + struct pselect6_sigmask { + const uint32_t *mask; + size_t size; + }; + const struct pselect6_sigmask *sigmask_struct = + (const struct pselect6_sigmask *)(uintptr_t)a6; uint32_t mask_lo = 0, mask_hi = 0; if (sigmask_struct) { - const uint32_t *mask_ptr = (const uint32_t *)(uintptr_t)sigmask_struct[0]; + const uint32_t *mask_ptr = sigmask_struct->mask; if (mask_ptr) { + if (sigmask_struct->size != 8) + return -22; /* EINVAL */ mask_lo = mask_ptr[0]; mask_hi = mask_ptr[1]; } @@ -1563,42 +1537,11 @@ static long __do_syscall(long n, long a1, long a2, long a3, case SYS_RT_SIGRETURN: return 0; - /* ============================================================== */ - /* Scatter-gather I/O with offset */ - /* ============================================================== */ - - /* preadv — (fd, iov, iovcnt, off_lo, off_hi) */ - case SYS_PREADV: - return (long)kernel_preadv((int32_t)a1, - (uint8_t *)(uintptr_t)a2, - (int32_t)a3, - (uint32_t)a4, (int32_t)a5); - - /* pwritev — (fd, iov, iovcnt, off_lo, off_hi) */ - case SYS_PWRITEV: - return (long)kernel_pwritev((int32_t)a1, - (const uint8_t *)(uintptr_t)a2, - (int32_t)a3, - (uint32_t)a4, (int32_t)a5); - - /* preadv2/pwritev2 — delegate to preadv/pwritev (ignore flags in a6) */ - case SYS_PREADV2: - return (long)kernel_preadv((int32_t)a1, - (uint8_t *)(uintptr_t)a2, - (int32_t)a3, - (uint32_t)a4, (int32_t)a5); - - case SYS_PWRITEV2: - return (long)kernel_pwritev((int32_t)a1, - (const uint8_t *)(uintptr_t)a2, - (int32_t)a3, - (uint32_t)a4, (int32_t)a5); - /* sendfile — (out_fd, in_fd, offset_ptr, count) */ case SYS_SENDFILE: return (long)kernel_sendfile((int32_t)a1, (int32_t)a2, (uint8_t *)(uintptr_t)a3, - (uint32_t)a4); + (size_t)a4); /* ============================================================== */ /* statx — extended stat */ @@ -1814,9 +1757,13 @@ static long __do_syscall(long n, long a1, long a2, long a3, return (long)kernel_eventfd2((uint32_t)a1, 0); case SYS_SIGNALFD4: - return (long)kernel_signalfd4((int32_t)a1, (uint32_t)(uintptr_t)a2, (uint32_t)a3, (uint32_t)a4); + return (long)kernel_signalfd4((int32_t)a1, + (const uint8_t *)(uintptr_t)a2, + (size_t)a3, (uint32_t)a4); case SYS_SIGNALFD: - return (long)kernel_signalfd4((int32_t)a1, (uint32_t)(uintptr_t)a2, (uint32_t)a3, 0); + return (long)kernel_signalfd4((int32_t)a1, + (const uint8_t *)(uintptr_t)a2, + (size_t)a3, 0); case SYS_TIMERFD_CREATE: return (long)kernel_timerfd_create((uint32_t)a1, (uint32_t)a2); diff --git a/libc/glue/syscall_imports.h b/libc/glue/syscall_imports.h index c633284996..e9eb8ad4f4 100644 --- a/libc/glue/syscall_imports.h +++ b/libc/glue/syscall_imports.h @@ -36,24 +36,10 @@ int32_t kernel_open(const uint8_t *path_ptr, uint32_t path_len, KERNEL_IMPORT(kernel_close) int32_t kernel_close(int32_t fd); -KERNEL_IMPORT(kernel_read) -int32_t kernel_read(int32_t fd, uint8_t *buf_ptr, uint32_t buf_len); - -KERNEL_IMPORT(kernel_write) -int32_t kernel_write(int32_t fd, const uint8_t *buf_ptr, uint32_t buf_len); - KERNEL_IMPORT(kernel_lseek) int64_t kernel_lseek(int32_t fd, uint32_t offset_lo, int32_t offset_hi, uint32_t whence); -KERNEL_IMPORT(kernel_pread) -int32_t kernel_pread(int32_t fd, uint8_t *buf_ptr, uint32_t buf_len, - uint32_t offset_lo, int32_t offset_hi); - -KERNEL_IMPORT(kernel_pwrite) -int32_t kernel_pwrite(int32_t fd, const uint8_t *buf_ptr, uint32_t buf_len, - uint32_t offset_lo, int32_t offset_hi); - /* ------------------------------------------------------------------ */ /* FD operations */ /* ------------------------------------------------------------------ */ @@ -83,7 +69,9 @@ KERNEL_IMPORT(kernel_epoll_ctl) int32_t kernel_epoll_ctl(int32_t epfd, int32_t op, int32_t fd, uint8_t *event_ptr); KERNEL_IMPORT(kernel_epoll_pwait) -int32_t kernel_epoll_pwait(int32_t epfd, uint8_t *events_ptr, int32_t maxevents, int32_t timeout, uint32_t sigmask_ptr); +int32_t kernel_epoll_pwait(int32_t epfd, uint8_t *events_ptr, + int32_t maxevents, int32_t timeout, + const uint8_t *sigmask_ptr); KERNEL_IMPORT(kernel_timerfd_create) int32_t kernel_timerfd_create(uint32_t clock_id, uint32_t flags); @@ -95,7 +83,8 @@ KERNEL_IMPORT(kernel_timerfd_gettime) int32_t kernel_timerfd_gettime(int32_t fd, uint8_t *cur_ptr); KERNEL_IMPORT(kernel_signalfd4) -int32_t kernel_signalfd4(int32_t fd, uint32_t mask_ptr, uint32_t sigsetsize, uint32_t flags); +int32_t kernel_signalfd4(int32_t fd, const uint8_t *mask_ptr, + size_t sigsetsize, uint32_t flags); KERNEL_IMPORT(kernel_fcntl) int32_t kernel_fcntl(int32_t fd, uint32_t cmd, uint32_t arg); @@ -381,27 +370,9 @@ int32_t kernel_fchmod(int32_t fd, uint32_t mode); KERNEL_IMPORT(kernel_fchown) int32_t kernel_fchown(int32_t fd, uint32_t uid, uint32_t gid); -/* ------------------------------------------------------------------ */ -/* Scatter-gather I/O */ -/* ------------------------------------------------------------------ */ - -KERNEL_IMPORT(kernel_writev) -int32_t kernel_writev(int32_t fd, const uint8_t *iov_ptr, int32_t iovcnt); - -KERNEL_IMPORT(kernel_readv) -int32_t kernel_readv(int32_t fd, uint8_t *iov_ptr, int32_t iovcnt); - -KERNEL_IMPORT(kernel_preadv) -int32_t kernel_preadv(int32_t fd, uint8_t *iov_ptr, int32_t iovcnt, - uint32_t offset_lo, int32_t offset_hi); - -KERNEL_IMPORT(kernel_pwritev) -int32_t kernel_pwritev(int32_t fd, const uint8_t *iov_ptr, int32_t iovcnt, - uint32_t offset_lo, int32_t offset_hi); - KERNEL_IMPORT(kernel_sendfile) int32_t kernel_sendfile(int32_t out_fd, int32_t in_fd, uint8_t *offset_ptr, - uint32_t count); + size_t count); KERNEL_IMPORT(kernel_statx) int32_t kernel_statx(int32_t dirfd, const uint8_t *path_ptr, @@ -416,13 +387,14 @@ KERNEL_IMPORT(kernel_gettid) int32_t kernel_gettid(void); KERNEL_IMPORT(kernel_set_tid_address) -int32_t kernel_set_tid_address(uint32_t tidptr); +int32_t kernel_set_tid_address(uintptr_t tidptr); KERNEL_IMPORT(kernel_set_robust_list) -int32_t kernel_set_robust_list(uint32_t head, uint32_t len); +int32_t kernel_set_robust_list(uintptr_t head, size_t len); KERNEL_IMPORT(kernel_get_robust_list) -int32_t kernel_get_robust_list(uint32_t pid, uint32_t head_ptr, uint32_t len_ptr); +int32_t kernel_get_robust_list(uint32_t pid, uintptr_t head_ptr, + uintptr_t len_ptr); KERNEL_IMPORT(kernel_futex) int32_t kernel_futex(uint32_t uaddr, uint32_t op, uint32_t val, @@ -724,10 +696,12 @@ int32_t kernel_setgroups(uint32_t size, const uint32_t *list_ptr); /* ------------------------------------------------------------------ */ KERNEL_IMPORT(kernel_sendmsg) -int32_t kernel_sendmsg(int32_t fd, const uint8_t *msg_ptr, uint32_t flags); +int32_t kernel_sendmsg(int32_t fd, const uint8_t *msg_ptr, uint32_t flags, + int64_t retry_token); KERNEL_IMPORT(kernel_recvmsg) -int32_t kernel_recvmsg(int32_t fd, uint8_t *msg_ptr, uint32_t flags); +int32_t kernel_recvmsg(int32_t fd, uint8_t *msg_ptr, uint32_t flags, + int64_t retry_token); KERNEL_IMPORT(kernel_getaddrinfo) int32_t kernel_getaddrinfo(const uint8_t *name_ptr, uint32_t name_len, diff --git a/libc/musl-overlay/arch/wasm32posix/bits/syscall.h.in b/libc/musl-overlay/arch/wasm32posix/bits/syscall.h.in index fc3e89064d..d34cdf642f 100644 --- a/libc/musl-overlay/arch/wasm32posix/bits/syscall.h.in +++ b/libc/musl-overlay/arch/wasm32posix/bits/syscall.h.in @@ -319,12 +319,6 @@ #define __NR_recvmsg 138 #define __NR_sendmsg 137 -/* Thread cancellation wake-up (host-handled). Sets a per-thread cancel - * flag in the kernel's view and completes any in-flight cancel-point - * syscall on the target with -ECANCELED so it drops out of Atomics.wait. - * See libc/musl-overlay/src/thread/wasm32posix/pthread_cancel.c. */ -#define __NR_thread_cancel 415 - /* Aliases that musl expects (64-bit variants and rt_sig*) */ #define __NR_mmap2 __NR_mmap #define __NR_fstat64 __NR_fstat diff --git a/libc/musl-overlay/arch/wasm64posix/bits/syscall.h.in b/libc/musl-overlay/arch/wasm64posix/bits/syscall.h.in index bc0bef227e..3ebfb9f8ab 100644 --- a/libc/musl-overlay/arch/wasm64posix/bits/syscall.h.in +++ b/libc/musl-overlay/arch/wasm64posix/bits/syscall.h.in @@ -311,10 +311,6 @@ #define __NR_execveat 386 #define __NR_exit_group 387 -/* Thread cancellation wake-up (host-handled). See - * libc/musl-overlay/src/thread/wasm32posix/pthread_cancel.c. */ -#define __NR_thread_cancel 415 - /* Aliases that musl expects. * On wasm64 (LP64), we don't need time64 aliases because sizeof(time_t) * == sizeof(long) == 8 and musl uses the non-time64 code paths. diff --git a/libc/musl-overlay/include/bits/kandelo_channel_scalars.h b/libc/musl-overlay/include/bits/kandelo_channel_scalars.h new file mode 100644 index 0000000000..38940962e6 --- /dev/null +++ b/libc/musl-overlay/include/bits/kandelo_channel_scalars.h @@ -0,0 +1,321 @@ +/* GENERATED by `cargo xtask dump-abi`. Do not edit by hand. */ +#ifndef KANDELO_CHANNEL_SCALARS_H +#define KANDELO_CHANNEL_SCALARS_H + +#include + +/* WHY: the shared scalar table is authoritative, but musl still owns +* the public target syscall-number headers. Compile both together so +* a renumbering cannot silently reinterpret an i64 channel slot. */ +#ifndef __NR_read +#error "musl is missing __NR_read required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_read == 3u, +"musl __NR_read drifted from the Kandelo channel scalar contract"); +#ifndef __NR_write +#error "musl is missing __NR_write required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_write == 4u, +"musl __NR_write drifted from the Kandelo channel scalar contract"); +#ifndef __NR_lseek +#error "musl is missing __NR_lseek required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_lseek == 5u, +"musl __NR_lseek drifted from the Kandelo channel scalar contract"); +#ifndef __NR_readlink +#error "musl is missing __NR_readlink required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_readlink == 19u, +"musl __NR_readlink drifted from the Kandelo channel scalar contract"); +#ifndef __NR_getcwd +#error "musl is missing __NR_getcwd required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_getcwd == 23u, +"musl __NR_getcwd drifted from the Kandelo channel scalar contract"); +#ifndef __NR_readdir +#error "musl is missing __NR_readdir required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_readdir == 26u, +"musl __NR_readdir drifted from the Kandelo channel scalar contract"); +#ifndef __NR_getenv +#error "musl is missing __NR_getenv required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_getenv == 43u, +"musl __NR_getenv drifted from the Kandelo channel scalar contract"); +#ifndef __NR_mmap +#error "musl is missing __NR_mmap required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_mmap == 46u, +"musl __NR_mmap drifted from the Kandelo channel scalar contract"); +#ifndef __NR_munmap +#error "musl is missing __NR_munmap required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_munmap == 47u, +"musl __NR_munmap drifted from the Kandelo channel scalar contract"); +#ifndef __NR_brk +#error "musl is missing __NR_brk required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_brk == 48u, +"musl __NR_brk drifted from the Kandelo channel scalar contract"); +#ifndef __NR_mprotect +#error "musl is missing __NR_mprotect required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_mprotect == 49u, +"musl __NR_mprotect drifted from the Kandelo channel scalar contract"); +#ifndef __NR_bind +#error "musl is missing __NR_bind required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_bind == 51u, +"musl __NR_bind drifted from the Kandelo channel scalar contract"); +#ifndef __NR_connect +#error "musl is missing __NR_connect required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_connect == 54u, +"musl __NR_connect drifted from the Kandelo channel scalar contract"); +#ifndef __NR_send +#error "musl is missing __NR_send required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_send == 55u, +"musl __NR_send drifted from the Kandelo channel scalar contract"); +#ifndef __NR_recv +#error "musl is missing __NR_recv required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_recv == 56u, +"musl __NR_recv drifted from the Kandelo channel scalar contract"); +#ifndef __NR_setsockopt +#error "musl is missing __NR_setsockopt required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_setsockopt == 59u, +"musl __NR_setsockopt drifted from the Kandelo channel scalar contract"); +#ifndef __NR_poll +#error "musl is missing __NR_poll required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_poll == 60u, +"musl __NR_poll drifted from the Kandelo channel scalar contract"); +#ifndef __NR_sendto +#error "musl is missing __NR_sendto required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_sendto == 62u, +"musl __NR_sendto drifted from the Kandelo channel scalar contract"); +#ifndef __NR_recvfrom +#error "musl is missing __NR_recvfrom required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_recvfrom == 63u, +"musl __NR_recvfrom drifted from the Kandelo channel scalar contract"); +#ifndef __NR_pread +#error "musl is missing __NR_pread required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_pread == 64u, +"musl __NR_pread drifted from the Kandelo channel scalar contract"); +#ifndef __NR_pwrite +#error "musl is missing __NR_pwrite required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_pwrite == 65u, +"musl __NR_pwrite drifted from the Kandelo channel scalar contract"); +#ifndef __NR_time +#error "musl is missing __NR_time required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_time == 66u, +"musl __NR_time drifted from the Kandelo channel scalar contract"); +#ifndef __NR_signal +#error "musl is missing __NR_signal required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_signal == 73u, +"musl __NR_signal drifted from the Kandelo channel scalar contract"); +#ifndef __NR_ftruncate +#error "musl is missing __NR_ftruncate required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_ftruncate == 79u, +"musl __NR_ftruncate drifted from the Kandelo channel scalar contract"); +#ifndef __NR_truncate +#error "musl is missing __NR_truncate required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_truncate == 85u, +"musl __NR_truncate drifted from the Kandelo channel scalar contract"); +#ifndef __NR_readlinkat +#error "musl is missing __NR_readlinkat required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_readlinkat == 102u, +"musl __NR_readlinkat drifted from the Kandelo channel scalar contract"); +#ifndef __NR_realpath +#error "musl is missing __NR_realpath required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_realpath == 109u, +"musl __NR_realpath drifted from the Kandelo channel scalar contract"); +#ifndef __NR__llseek +#error "musl is missing __NR__llseek required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR__llseek == 119u, +"musl __NR__llseek drifted from the Kandelo channel scalar contract"); +#ifndef __NR_getrandom +#error "musl is missing __NR_getrandom required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_getrandom == 120u, +"musl __NR_getrandom drifted from the Kandelo channel scalar contract"); +#ifndef __NR_getdents64 +#error "musl is missing __NR_getdents64 required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_getdents64 == 122u, +"musl __NR_getdents64 drifted from the Kandelo channel scalar contract"); +#ifndef __NR_mremap +#error "musl is missing __NR_mremap required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_mremap == 126u, +"musl __NR_mremap drifted from the Kandelo channel scalar contract"); +#ifndef __NR_madvise +#error "musl is missing __NR_madvise required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_madvise == 128u, +"musl __NR_madvise drifted from the Kandelo channel scalar contract"); +#ifndef __NR_setgroups +#error "musl is missing __NR_setgroups required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_setgroups == 136u, +"musl __NR_setgroups drifted from the Kandelo channel scalar contract"); +#ifndef __NR_futex +#error "musl is missing __NR_futex required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_futex == 200u, +"musl __NR_futex drifted from the Kandelo channel scalar contract"); +#ifndef __NR_set_tid_address +#error "musl is missing __NR_set_tid_address required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_set_tid_address == 203u, +"musl __NR_set_tid_address drifted from the Kandelo channel scalar contract"); +#ifndef __NR_sched_setaffinity +#error "musl is missing __NR_sched_setaffinity required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_sched_setaffinity == 237u, +"musl __NR_sched_setaffinity drifted from the Kandelo channel scalar contract"); +#ifndef __NR_sched_getaffinity +#error "musl is missing __NR_sched_getaffinity required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_sched_getaffinity == 238u, +"musl __NR_sched_getaffinity drifted from the Kandelo channel scalar contract"); +#ifndef __NR_epoll_pwait +#error "musl is missing __NR_epoll_pwait required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_epoll_pwait == 241u, +"musl __NR_epoll_pwait drifted from the Kandelo channel scalar contract"); +#ifndef __NR_signalfd4 +#error "musl is missing __NR_signalfd4 required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_signalfd4 == 246u, +"musl __NR_signalfd4 drifted from the Kandelo channel scalar contract"); +#ifndef __NR_ppoll +#error "musl is missing __NR_ppoll required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_ppoll == 251u, +"musl __NR_ppoll drifted from the Kandelo channel scalar contract"); +#ifndef __NR_set_robust_list +#error "musl is missing __NR_set_robust_list required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_set_robust_list == 261u, +"musl __NR_set_robust_list drifted from the Kandelo channel scalar contract"); +#ifndef __NR_get_robust_list +#error "musl is missing __NR_get_robust_list required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_get_robust_list == 262u, +"musl __NR_get_robust_list drifted from the Kandelo channel scalar contract"); +#ifndef __NR_msync +#error "musl is missing __NR_msync required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_msync == 278u, +"musl __NR_msync drifted from the Kandelo channel scalar contract"); +#ifndef __NR_mlock +#error "musl is missing __NR_mlock required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_mlock == 279u, +"musl __NR_mlock drifted from the Kandelo channel scalar contract"); +#ifndef __NR_mlock2 +#error "musl is missing __NR_mlock2 required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_mlock2 == 280u, +"musl __NR_mlock2 drifted from the Kandelo channel scalar contract"); +#ifndef __NR_munlock +#error "musl is missing __NR_munlock required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_munlock == 281u, +"musl __NR_munlock drifted from the Kandelo channel scalar contract"); +#ifndef __NR_copy_file_range +#error "musl is missing __NR_copy_file_range required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_copy_file_range == 290u, +"musl __NR_copy_file_range drifted from the Kandelo channel scalar contract"); +#ifndef __NR_splice +#error "musl is missing __NR_splice required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_splice == 291u, +"musl __NR_splice drifted from the Kandelo channel scalar contract"); +#ifndef __NR_readahead +#error "musl is missing __NR_readahead required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_readahead == 293u, +"musl __NR_readahead drifted from the Kandelo channel scalar contract"); +#ifndef __NR_sendfile +#error "musl is missing __NR_sendfile required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_sendfile == 294u, +"musl __NR_sendfile drifted from the Kandelo channel scalar contract"); +#ifndef __NR_preadv +#error "musl is missing __NR_preadv required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_preadv == 295u, +"musl __NR_preadv drifted from the Kandelo channel scalar contract"); +#ifndef __NR_pwritev +#error "musl is missing __NR_pwritev required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_pwritev == 296u, +"musl __NR_pwritev drifted from the Kandelo channel scalar contract"); +#ifndef __NR_preadv2 +#error "musl is missing __NR_preadv2 required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_preadv2 == 297u, +"musl __NR_preadv2 drifted from the Kandelo channel scalar contract"); +#ifndef __NR_pwritev2 +#error "musl is missing __NR_pwritev2 required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_pwritev2 == 298u, +"musl __NR_pwritev2 drifted from the Kandelo channel scalar contract"); +#ifndef __NR_fallocate +#error "musl is missing __NR_fallocate required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_fallocate == 308u, +"musl __NR_fallocate drifted from the Kandelo channel scalar contract"); +#ifndef __NR_mq_timedsend +#error "musl is missing __NR_mq_timedsend required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_mq_timedsend == 333u, +"musl __NR_mq_timedsend drifted from the Kandelo channel scalar contract"); +#ifndef __NR_mq_timedreceive +#error "musl is missing __NR_mq_timedreceive required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_mq_timedreceive == 334u, +"musl __NR_mq_timedreceive drifted from the Kandelo channel scalar contract"); +#ifndef __NR_msgrcv +#error "musl is missing __NR_msgrcv required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_msgrcv == 338u, +"musl __NR_msgrcv drifted from the Kandelo channel scalar contract"); +#ifndef __NR_msgsnd +#error "musl is missing __NR_msgsnd required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_msgsnd == 339u, +"musl __NR_msgsnd drifted from the Kandelo channel scalar contract"); +#ifndef __NR_semop +#error "musl is missing __NR_semop required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_semop == 342u, +"musl __NR_semop drifted from the Kandelo channel scalar contract"); +#ifndef __NR_shmget +#error "musl is missing __NR_shmget required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_shmget == 344u, +"musl __NR_shmget drifted from the Kandelo channel scalar contract"); +#ifndef __NR_signalfd +#error "musl is missing __NR_signalfd required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_signalfd == 377u, +"musl __NR_signalfd drifted from the Kandelo channel scalar contract"); + +#endif diff --git a/libc/musl-overlay/include/bits/kandelo_limits.h b/libc/musl-overlay/include/bits/kandelo_limits.h index 578c92b6f6..e74b89d274 100644 --- a/libc/musl-overlay/include/bits/kandelo_limits.h +++ b/libc/musl-overlay/include/bits/kandelo_limits.h @@ -6,5 +6,7 @@ #define KANDELO_POSIX_ARG_MAX_BYTES 4194304u #define KANDELO_POSIX_PATH_MAX_BYTES 4096u #define KANDELO_POSIX_IOV_MAX 1024u +#define KANDELO_PROCESS_METADATA_ENTRY_MAX_BYTES 65536u +#define KANDELO_MAX_REPORTABLE_TRANSFER_BYTES 2147483647u #endif /* KANDELO_PLATFORM_LIMITS_H */ diff --git a/libc/musl-overlay/include/bits/kandelo_process_layouts.h b/libc/musl-overlay/include/bits/kandelo_process_layouts.h index 29e2a10456..11c9bf6acb 100644 --- a/libc/musl-overlay/include/bits/kandelo_process_layouts.h +++ b/libc/musl-overlay/include/bits/kandelo_process_layouts.h @@ -71,6 +71,10 @@ #define KANDELO_SOCKET_SCM_RIGHTS 1u #define KANDELO_SOCKET_MSG_TRUNC 32u #define KANDELO_SCM_RIGHTS_FD_BYTES 4u +#define KANDELO_SOCKADDR_STORAGE_BYTES 128u +#define KANDELO_SOCKADDR_UNIX_BYTES 110u +#define KANDELO_SOCKADDR_UNIX_PATH_OFFSET_BYTES 2u +#define KANDELO_SOCKADDR_UNIX_PATH_BYTES 108u #define KANDELO_KERNEL_POLLFD_SIZE 8u #define KANDELO_KERNEL_POLLFD_FD_OFFSET 0u diff --git a/libc/musl-overlay/include/bits/kandelo_thread_syscalls.h b/libc/musl-overlay/include/bits/kandelo_thread_syscalls.h new file mode 100644 index 0000000000..5b2c13f9bb --- /dev/null +++ b/libc/musl-overlay/include/bits/kandelo_thread_syscalls.h @@ -0,0 +1,8 @@ +/* GENERATED by `cargo xtask dump-abi`. Do not edit by hand. */ +/* Regenerated by scripts/check-abi-version.sh; drift is a CI failure. */ +#ifndef KANDELO_THREAD_SYSCALLS_H +#define KANDELO_THREAD_SYSCALLS_H + +#define KANDELO_SYS_THREAD_CANCEL 415u + +#endif /* KANDELO_THREAD_SYSCALLS_H */ diff --git a/libc/musl-overlay/src/env/putenv.c b/libc/musl-overlay/src/env/putenv.c index f0d2270be4..5065bb05c1 100644 --- a/libc/musl-overlay/src/env/putenv.c +++ b/libc/musl-overlay/src/env/putenv.c @@ -1,68 +1,154 @@ /* * putenv.c — Wasm-POSIX override of musl's putenv / __putenv. * - * Keeps the original musl logic for __environ management, then calls - * SYS_setenv or SYS_unsetenv to sync the kernel's proc.environ store. + * Prepares a fallible local mutation before synchronously updating the kernel + * Process environment, then publishes the now-infallible libc mutation. */ #include #include #include +#include +#include #include "syscall.h" static void dummy(char *old, char *new) {} weak_alias(dummy, __env_rm_add); -int __putenv(char *s, size_t l, char *r) +static int dummy_prepare(char *old, char *new) { return 0; } +weak_alias(dummy_prepare, __env_rm_prepare); + +static char **oldenv; + +struct putenv_plan { + char *s; + char *r; + char **replacement; + char **newenv; +}; + +static void putenv_plan_abort(struct putenv_plan *plan) +{ + free(plan->newenv); + free(plan->r); +} + +static int putenv_plan_prepare( + struct putenv_plan *plan, char *s, size_t l, char *r) { size_t i=0; + *plan = (struct putenv_plan) { + .s = s, + .r = r, + }; + if (__environ) { for (char **e = __environ; *e; e++, i++) if (!strncmp(s, *e, l+1)) { - char *tmp = *e; - *e = s; - __env_rm_add(tmp, r); + if (__env_rm_prepare(*e, r) < 0) { + free(r); + return -1; + } + plan->replacement = e; return 0; } } - static char **oldenv; - char **newenv; - if (__environ == oldenv) { - newenv = realloc(oldenv, sizeof *newenv * (i+2)); - if (!newenv) goto oom; - } else { - newenv = malloc(sizeof *newenv * (i+2)); - if (!newenv) goto oom; - if (i) memcpy(newenv, __environ, sizeof *newenv * i); - free(oldenv); + if (i > (size_t)-1 / sizeof *plan->newenv - 2) { + errno = ENOMEM; + free(r); + return -1; + } + plan->newenv = malloc(sizeof *plan->newenv * (i+2)); + if (!plan->newenv) { + free(r); + return -1; + } + if (i) memcpy(plan->newenv, __environ, sizeof *plan->newenv * i); + plan->newenv[i] = s; + plan->newenv[i+1] = 0; + if (__env_rm_prepare(0, r) < 0) { + int saved_errno = errno; + putenv_plan_abort(plan); + errno = saved_errno; + return -1; + } + return 0; +} + +static void putenv_plan_commit(struct putenv_plan *plan) +{ + if (plan->replacement) { + char *tmp = *plan->replacement; + *plan->replacement = plan->s; + __env_rm_add(tmp, plan->r); + return; } - newenv[i] = s; - newenv[i+1] = 0; - __environ = oldenv = newenv; - if (r) __env_rm_add(0, r); + + /* + * All allocation, including the setenv-owned string tracking slot, was + * reserved before the kernel mutation. Publishing the pointer array and + * retiring the prior libc-owned array are therefore infallible. + */ + char **previous_oldenv = oldenv; + __environ = oldenv = plan->newenv; + plan->newenv = 0; + free(previous_oldenv); + if (plan->r) __env_rm_add(0, plan->r); +} + +int __putenv(char *s, size_t l, char *r) +{ + struct putenv_plan plan; + if (putenv_plan_prepare(&plan, s, l, r) < 0) return -1; + putenv_plan_commit(&plan); + return 0; +} + +/* + * Atomically synchronize one already-validated KEY=VALUE mutation as observed + * at the public function boundary. + * + * WHY: kernel-first is safe only because putenv_plan_prepare has made the + * following local commit allocation-free. Conversely, a kernel errno aborts + * the unpublished plan, so libc cannot report a value the Process does not + * own. SYS_setenv is synchronous and this libc environment API has musl's + * existing non-concurrent mutation contract. + */ +hidden int __putenv_kernel_sync( + char *s, size_t l, char *r, const char *name, const char *value) +{ + struct putenv_plan plan; + if (putenv_plan_prepare(&plan, s, l, r) < 0) return -1; + + long result = __syscall3( + SYS_setenv, (long)name, (long)value, 1); + if (result < 0) { + putenv_plan_abort(&plan); + return __syscall_ret(result); + } + + putenv_plan_commit(&plan); return 0; -oom: - free(r); - return -1; } int putenv(char *s) { size_t l = __strchrnul(s, '=') - s; if (!l || !s[l]) return unsetenv(s); - int r = __putenv(s, l, 0); - if (r == 0) { - /* - * Sync with kernel. Extract name and value from "KEY=VALUE". - * We pass the full name (length l) and value (after '=') - * through the SYS_setenv syscall which expects (name, value, overwrite). - */ - char name_buf[256]; - if (l < sizeof(name_buf)) { - memcpy(name_buf, s, l); - name_buf[l] = '\0'; - __syscall3(SYS_setenv, (long)name_buf, (long)(s + l + 1), 1); - } + size_t entry_len = strlen(s); + if (entry_len > KANDELO_PROCESS_METADATA_ENTRY_MAX_BYTES) { + errno = E2BIG; + return -1; } - return r; + + char *name = malloc(l + 1); + if (!name) return -1; + memcpy(name, s, l); + name[l] = 0; + + int result = __putenv_kernel_sync(s, l, 0, name, s+l+1); + int saved_errno = errno; + free(name); + errno = saved_errno; + return result; } diff --git a/libc/musl-overlay/src/env/setenv.c b/libc/musl-overlay/src/env/setenv.c index d3754e5563..c968ab880d 100644 --- a/libc/musl-overlay/src/env/setenv.c +++ b/libc/musl-overlay/src/env/setenv.c @@ -1,20 +1,51 @@ /* * setenv.c — Wasm-POSIX override of musl's setenv / __env_rm_add. * - * Keeps the original musl logic for __environ management, then also - * calls SYS_setenv to sync the kernel's proc.environ store. This ensures - * env vars survive fork/exec (where __environ is rebuilt from proc.environ). + * Keeps libc's __environ and the kernel Process environment coherent across + * successful calls and failures. Environment variables therefore survive + * fork/exec, where __environ is rebuilt from the kernel-owned representation. */ #include #include #include +#include #include "syscall.h" +static char **env_alloced; +static size_t env_alloced_n; + +hidden int __putenv_kernel_sync( + char *, size_t, char *, const char *, const char *); + +/* + * Reserve any ownership-table slot that __env_rm_add will need later. + * + * WHY: the Wasm-POSIX environment has two authoritative representations: + * libc's environ and the kernel Process environment. Callers reserve every + * fallible libc allocation before asking the kernel to mutate its copy, so + * the local commit after kernel success must not fail. + */ +hidden int __env_rm_prepare(char *old, char *new) +{ + if (!new) return 0; + for (size_t i=0; i < env_alloced_n; i++) + if (env_alloced[i] == old || !env_alloced[i]) + return 0; + if (env_alloced_n >= (size_t)-1 / sizeof *env_alloced) { + errno = ENOMEM; + return -1; + } + char **t = realloc(env_alloced, + sizeof *t * (env_alloced_n+1)); + if (!t) return -1; + env_alloced = t; + env_alloced[env_alloced_n++] = 0; + return 0; +} + void __env_rm_add(char *old, char *new) { - static char **env_alloced; - static size_t env_alloced_n; for (size_t i=0; i < env_alloced_n; i++) if (env_alloced[i] == old) { env_alloced[i] = new; @@ -42,15 +73,16 @@ int setenv(const char *var, const char *value, int overwrite) if (!overwrite && getenv(var)) return 0; l2 = strlen(value); - s = malloc(l1+l2+2); + if (l1 >= KANDELO_PROCESS_METADATA_ENTRY_MAX_BYTES || + l2 > KANDELO_PROCESS_METADATA_ENTRY_MAX_BYTES - l1 - 1) { + errno = E2BIG; + return -1; + } + size_t entry_len = l1 + 1 + l2; + s = malloc(entry_len + 1); if (!s) return -1; memcpy(s, var, l1); s[l1] = '='; memcpy(s+l1+1, value, l2+1); - int r = __putenv(s, l1, s); - if (r == 0) { - /* Sync with kernel's proc.environ */ - __syscall3(SYS_setenv, (long)var, (long)value, (long)overwrite); - } - return r; + return __putenv_kernel_sync(s, l1, s, var, value); } diff --git a/libc/musl-overlay/src/env/unsetenv.c b/libc/musl-overlay/src/env/unsetenv.c index c73133d4a9..62a87c5b72 100644 --- a/libc/musl-overlay/src/env/unsetenv.c +++ b/libc/musl-overlay/src/env/unsetenv.c @@ -1,14 +1,15 @@ /* * unsetenv.c — Wasm-POSIX override of musl's unsetenv. * - * Keeps original musl logic for __environ compaction, then calls - * SYS_unsetenv to remove the variable from the kernel's proc.environ. + * Removes the kernel Process value first, then performs an allocation-free + * libc __environ commit so either both representations change or neither does. */ #include #include #include #include +#include #include "syscall.h" static void dummy(char *old, char *new) {} @@ -21,6 +22,19 @@ int unsetenv(const char *name) errno = EINVAL; return -1; } + if (l > KANDELO_PROCESS_METADATA_ENTRY_MAX_BYTES) { + errno = E2BIG; + return -1; + } + + /* + * The local removal below only compacts pointers and releases strings; + * it cannot fail. Ask the kernel first so an errno leaves environ exactly + * as the caller observed it on entry. + */ + long result = __syscall1(SYS_unsetenv, (long)name); + if (result < 0) return __syscall_ret(result); + if (__environ) { char **e = __environ, **eo = e; for (; *e; e++) @@ -32,7 +46,5 @@ int unsetenv(const char *name) eo++; if (eo != e) *eo = 0; } - /* Sync with kernel's proc.environ */ - __syscall1(SYS_unsetenv, (long)name); return 0; } diff --git a/libc/musl-overlay/src/thread/wasm32posix/pthread_cancel.c b/libc/musl-overlay/src/thread/wasm32posix/pthread_cancel.c index b9ec42c8a9..8790de9c13 100644 --- a/libc/musl-overlay/src/thread/wasm32posix/pthread_cancel.c +++ b/libc/musl-overlay/src/thread/wasm32posix/pthread_cancel.c @@ -6,35 +6,33 @@ * a signal and redirect the instruction pointer. Wasm has no equivalent * of either facility, so we implement *deferred* cancellation only. * - * Design — we chose per-thread state option (c): - * Use stock musl's `pthread_t->cancel` field as the cancel-pending flag. - * - * (a) a reserved slot in the channel buffer, or - * (b) a thread-local global in the guest. + * Design: + * Use stock musl's `pthread_t->cancel` field as the authoritative + * cancel-pending flag. * * pthread_t->cancel is already: * - atomic (`a_store`/`a_cas`) * - thread-local (pinned to TLS via __pthread_self()) * - writable from any thread since all threads share linear memory - * so adding another slot would be redundant bookkeeping, and neither - * (a) nor (b) buys us anything except duplicated state. The ABI-level - * addition is therefore limited to a single wake-up syscall - * (SYS_thread_cancel), not a channel-layout change. + * so a second pending flag would be redundant bookkeeping. The channel's + * generated one-shot request flags carry only transport authority: whether + * this request came through __syscall_cp, and whether the target's frozen + * cancellation state allows the host to wake that request. * * Flow: * 1. pthread_cancel(t) atomically sets `t->cancel = 1` and invokes * SYS_thread_cancel(t->tid). - * 2. The host intercepts SYS_thread_cancel and, if the target's channel - * is in a pending cancel-point syscall, completes it with -ECANCELED - * so the target wakes from Atomics.wait. If the target is not - * blocked, the call is a no-op — the next cancel point will observe - * the flag. - * 3. libc/glue/channel_syscall.c::__syscall_cp calls __testcancel() before - * the blocking dispatch and __syscall_cp_check() after it. A pending - * cancel terminates the thread only before dispatch or when the host - * interrupted an in-flight cancellation point with EINTR. A syscall - * that already completed keeps its result and leaves cancellation - * pending for the next cancellation point. + * 2. The host intercepts SYS_thread_cancel. It interrupts an in-flight + * cancellation point with EINTR only when that exact request also + * advertised cancellation-wake authority. PTHREAD_CANCEL_DISABLE omits + * that authority, so the operation and any finite deadline remain live + * while cancellation stays pending. + * 3. libc/glue/channel_syscall.c::__syscall_cp calls + * __syscall_cp_cancel_preflight() before the blocking dispatch and + * __syscall_cp_check() after it. ENABLE exits immediately; MASKED + * returns ECANCELED so condition-wait code can relock first; DISABLE + * leaves the operation live. A syscall that already completed keeps + * its result and leaves cancellation pending for the next point. * * Async cancellation (PTHREAD_CANCEL_ASYNCHRONOUS) is explicitly not * supported: wasm cannot preempt a running thread mid-computation. @@ -46,9 +44,7 @@ #include #include "pthread_impl.h" #include "syscall.h" - -/* Must match crates/shared/src/lib.rs and host/src/kernel-worker.ts. */ -#define SYS_thread_cancel 415 +#include /* Replaces libc/musl/src/thread/pthread_cancel.c::__cancel. * If cancellation is enabled on this thread, terminate with @@ -73,8 +69,30 @@ void __testcancel(void) __cancel(); } -/* Check-for-cancel hook called by libc/glue/channel_syscall.c::__syscall_cp - * both *before* and *after* a cancellation-point syscall. This is the +/* Check-for-cancel hook called before a cancellation-point syscall. This is + * the guest-side pre-registration half of the cancellation transport: + * + * - ENABLE exits immediately. + * - DISABLE leaves the operation live. + * - MASKED returns -ECANCELED and switches to DISABLE so a condition wait + * can remove its waiter and reacquire its mutex before exiting. + * + * A host pending-cancel marker covers cross-thread cancellation that raced a + * blocking registration. This preflight is still required for self-pending + * MASKED cancellation, where pthread_cancel intentionally makes no host + * syscall and therefore cannot install such a marker. */ +hidden long __syscall_cp_cancel_preflight(void) +{ + pthread_t self = __pthread_self(); + if (!self->cancel) return 0; + if (self->canceldisable == PTHREAD_CANCEL_DISABLE) return 0; + if (self->canceldisable == PTHREAD_CANCEL_ENABLE || self->cancelasync) + pthread_exit(PTHREAD_CANCELED); + self->canceldisable = PTHREAD_CANCEL_DISABLE; + return -ECANCELED; +} + +/* Check-for-cancel hook called after a cancellation-point syscall. This is the * one-function moral equivalent of stock musl's __syscall_cp_asm + * __syscall_cp_c combo: * @@ -97,26 +115,20 @@ void __testcancel(void) */ hidden long __syscall_cp_check(long r) { - pthread_t self = __pthread_self(); if (r != -EINTR) return r; - if (!self->cancel) return r; - if (self->canceldisable == PTHREAD_CANCEL_DISABLE) return r; - if (self->canceldisable == PTHREAD_CANCEL_ENABLE || self->cancelasync) - pthread_exit(PTHREAD_CANCELED); - /* MASKED: synthesize -ECANCELED and block further cancellation. */ - self->canceldisable = PTHREAD_CANCEL_DISABLE; - return -ECANCELED; + long cancel = __syscall_cp_cancel_preflight(); + return cancel ? cancel : r; } -/* True only for the cancellation-disabled state that must keep a deferred - * cancellation-point operation blocked. channel_syscall.c uses this after a - * handler-free host cancellation wake; enabled and masked states must instead - * unwind through __syscall_cp_check. */ -hidden int __syscall_cp_cancel_pending_disabled(void) +/* Freeze whether pthread_cancel may interrupt this exact request. + * + * MASKED is intentionally wakeable: pthread_cond_timedwait relies on the + * EINTR -> ECANCELED handoff so it can reacquire the mutex before enabling + * cancellation and exiting. DISABLE instead keeps the operation live. */ +hidden int __syscall_cp_cancel_wake_allowed(void) { pthread_t self = __pthread_self(); - return self->cancel && - self->canceldisable == PTHREAD_CANCEL_DISABLE; + return self->canceldisable != PTHREAD_CANCEL_DISABLE; } int pthread_cancel(pthread_t t) @@ -145,7 +157,7 @@ int pthread_cancel(pthread_t t) * returns 0; the target will observe self->cancel on its next * cancel-point entry. */ if (t->tid > 0) { - __syscall(SYS_thread_cancel, t->tid); + __syscall(KANDELO_SYS_THREAD_CANCEL, t->tid); } return 0; } diff --git a/packages/registry/erlang-vfs/build.toml b/packages/registry/erlang-vfs/build.toml index 62390e7e5d..7d2924cec5 100644 --- a/packages/registry/erlang-vfs/build.toml +++ b/packages/registry/erlang-vfs/build.toml @@ -5,6 +5,7 @@ inputs = [ "images/vfs/scripts/build-erlang-vfs-image.sh", "images/vfs/scripts/vfs-image-helpers.ts", "host/src/constants.ts", + "host/src/file-offset.ts", "host/src/generated/abi.ts", "host/src/homebrew-bottle-relocation.ts", "host/src/homebrew-guest-layout.ts", diff --git a/packages/registry/git/test/git.test.ts b/packages/registry/git/test/git.test.ts index a699325f57..e93dfdf27f 100644 --- a/packages/registry/git/test/git.test.ts +++ b/packages/registry/git/test/git.test.ts @@ -4,22 +4,35 @@ * Git is built with wpk_fork_* instrumentation for fork() support so that * subprocesses (git gc --auto, git-remote-http, index-pack) work correctly. * - * Each runCentralizedProgram call creates a fresh kernel instance, - * but the host filesystem persists, so we use unique temp dirs. + * Persistent native paths are rooted in random, test-owned directories so + * append ownership remains explicit across the complete guest lifetime. */ import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { tmpdir } from "node:os"; import { join } from "node:path"; -import { existsSync, rmSync, mkdirSync, writeFileSync, statSync } from "node:fs"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + mkdirSync, + writeFileSync, + statSync, +} from "node:fs"; import { createServer, type Server } from "node:http"; -import { readFileSync } from "node:fs"; import { execSync } from "node:child_process"; import { runCentralizedProgram } from "../../../../host/test/centralized-test-helper"; -import { NodePlatformIO } from "../../../../host/src/platform/node"; import { FetchNetworkBackend } from "../../../../host/src/networking/fetch-backend"; import { tryResolveBinary } from "../../../../host/src/binary-resolver"; +import { NodeKernelHost } from "../../../../host/src/node-kernel-host"; +import { createSessionOwnedHostFileSystem } from "../../../../host/src/vfs/host-fs"; +import { NodeTimeProvider } from "../../../../host/src/vfs/time"; +import { VirtualPlatformIO } from "../../../../host/src/vfs/vfs"; const gitBinary = tryResolveBinary("programs/git/git.wasm"); -const gitRemoteHttpBinary = tryResolveBinary("programs/git/git-remote-http.wasm"); +const gitRemoteHttpBinary = tryResolveBinary( + "programs/git/git-remote-http.wasm", +); // Phase 7: skip git tests when the resolved binaries predate the // wasm-fork-instrument flip (i.e. they still export asyncify_* instead @@ -37,7 +50,23 @@ function hasWpkForkExports(path: string | null): boolean { } const hasGit = !!gitBinary && hasWpkForkExports(gitBinary); -const hasGitRemoteHttp = !!gitRemoteHttpBinary && hasWpkForkExports(gitRemoteHttpBinary); +const hasGitRemoteHttp = + !!gitRemoteHttpBinary && hasWpkForkExports(gitRemoteHttpBinary); + +function createOwnedGuestIo(root: string): VirtualPlatformIO { + mkdirSync(root, { recursive: true }); + // WHY: the random root is exclusively owned by this test for the complete + // guest lifetime, so the backend can truthfully publish exact append ends. + return new VirtualPlatformIO( + [ + { + mountPoint: "/", + backend: createSessionOwnedHostFileSystem(root), + }, + ], + new NodeTimeProvider(), + ); +} // Git config via environment const gitEnv = [ @@ -77,39 +106,53 @@ describe.skipIf(!hasGit)("Git", () => { expect(result.stdout + result.stderr).toContain("nitialized"); }); - it("creates a commit without spurious help output (wpk_fork instrumentation)", { timeout: 30_000 }, async () => { - // git commit triggers fork+exec for `git gc --auto`. Without fork - // instrumentation, the fork child restarts from _start() with empty argv - // and prints help. - const dir = `/tmp/git-commit-test-${Date.now()}`; - // The two runs share state via /tmp; under the new mount-based VFS - // each NodeKernelHost boot owns its own scratch session dir, so the - // second invocation can't see the first's repo. Opt out with raw - // NodePlatformIO so /tmp resolves to the actual host fs and persists - // across runs. (A migration to a single shared kernel boot — or to - // a fixture-managed session dir — is follow-up work to PR 4/5.) - const ioForPersistence = () => new NodePlatformIO(); - const initResult = await runCentralizedProgram({ - programPath: gitBinary!, - argv: ["git", "init", dir], - env: gitEnv, - io: ioForPersistence(), - timeout: 15_000, - }); - expect(initResult.exitCode).toBe(0); - // Commit with fork - const result = await runCentralizedProgram({ - programPath: gitBinary!, - argv: ["git", "-C", dir, "commit", "--allow-empty", "-m", "test commit"], - env: gitEnv, - io: ioForPersistence(), - timeout: 20_000, - }); - expect(result.exitCode).toBe(0); - const output = result.stdout + result.stderr; - expect(output).toContain("test commit"); - expect(output).not.toContain("usage: git"); - }); + it( + "creates a commit without spurious help output (wpk_fork instrumentation)", + { timeout: 30_000 }, + async () => { + // git commit triggers fork+exec for `git gc --auto`. Without fork + // instrumentation, the fork child restarts from _start() with empty argv + // and prints help. + const dir = "/tmp/repo"; + const program = readFileSync(gitBinary!); + const programBytes = program.buffer.slice( + program.byteOffset, + program.byteOffset + program.byteLength, + ); + let output = ""; + const host = new NodeKernelHost({ + rootfsImage: "default", + onStdout: (_pid, data) => { + output += new TextDecoder().decode(data); + }, + onStderr: (_pid, data) => { + output += new TextDecoder().decode(data); + }, + }); + try { + await host.init(); + expect( + await host.spawn(programBytes, ["git", "init", dir], { + env: gitEnv, + }), + ).toBe(0); + output = ""; + // WHY: both operations share one dedicated kernel session, so /tmp + // retains its lifecycle-owned append authority across the process exit. + expect( + await host.spawn( + programBytes, + ["git", "-C", dir, "commit", "--allow-empty", "-m", "test commit"], + { env: gitEnv }, + ), + ).toBe(0); + expect(output).toContain("test commit"); + expect(output).not.toContain("usage: git"); + } finally { + await host.destroy(); + } + }, + ); }); /** @@ -129,10 +172,13 @@ describe.skipIf(!hasGit || !hasGitRemoteHttp)("Git HTTP clone", () => { let httpServer: Server; let httpPort: number; let tmpBase: string; + let guestRoot: string; const hostAlias = "kandelo-host.test"; beforeAll(async () => { - tmpBase = `/tmp/git-http-test-${Date.now()}`; + tmpBase = mkdtempSync(join(tmpdir(), "kandelo-git-http-")); + guestRoot = join(tmpBase, "guest"); + mkdirSync(guestRoot); const workDir = `${tmpBase}/work`; const bareRepoDir = `${tmpBase}/repo.git`; @@ -185,66 +231,81 @@ describe.skipIf(!hasGit || !hasGitRemoteHttp)("Git HTTP clone", () => { afterAll(() => { httpServer?.close(); - try { rmSync(tmpBase, { recursive: true, force: true }); } catch { /* ignore */ } + try { + rmSync(tmpBase, { recursive: true, force: true }); + } catch { + /* ignore */ + } }); - it("clones a repository via HTTP (dumb protocol)", { timeout: 60_000 }, async () => { - const io = new NodePlatformIO(); - (io as any).network = new FetchNetworkBackend({ - hostAliases: { [hostAlias]: "127.0.0.1" }, - }); + it( + "clones a repository via HTTP (dumb protocol)", + { timeout: 60_000 }, + async () => { + const io = createOwnedGuestIo(guestRoot); + io.network = new FetchNetworkBackend({ + hostAliases: { [hostAlias]: "127.0.0.1" }, + }); - const cloneDir = `/tmp/git-clone-http-${Date.now()}`; - - // Git's prepare_cmd() resolves helper commands via locate_in_PATH(), - // which uses access() against the host filesystem. We create a - // temporary GIT_EXEC_PATH with placeholder executables so that - // access() succeeds, then register those paths in execPrograms so - // the kernel's exec handler maps them to the correct .wasm binary. - const gitExecPath = `${tmpBase}/exec`; - mkdirSync(gitExecPath, { recursive: true }); - writeFileSync(join(gitExecPath, "git-remote-http"), "placeholder", { mode: 0o755 }); - // Also create a "git" placeholder so git can re-exec itself - writeFileSync(join(gitExecPath, "git"), "placeholder", { mode: 0o755 }); - - const execPrograms = new Map([ - [`${gitExecPath}/git-remote-http`, gitRemoteHttpBinary!], - [`${gitExecPath}/git`, gitBinary!], - // Fallback paths git may also try - ["/usr/libexec/git-core/git-remote-http", gitRemoteHttpBinary!], - ["/usr/bin/git-remote-http", gitRemoteHttpBinary!], - ["/usr/bin/git", gitBinary!], - ]); - - const cloneEnv = [ - ...gitEnv, - `GIT_EXEC_PATH=${gitExecPath}`, - ]; + const cloneDir = `/clone-${Date.now()}`; + const cloneHostDir = join(guestRoot, cloneDir.slice(1)); - const result = await runCentralizedProgram({ - programPath: gitBinary!, - argv: ["git", "clone", `http://${hostAlias}:${httpPort}/`, cloneDir], - env: cloneEnv, - io, - execPrograms, - timeout: 60_000, - }); + // Git's prepare_cmd() resolves helper commands via locate_in_PATH(), + // which uses access() against the host filesystem. We create a + // temporary GIT_EXEC_PATH with placeholder executables so that + // access() succeeds, then register those paths in execPrograms so + // the kernel's exec handler maps them to the correct .wasm binary. + const gitExecPath = "/exec"; + const hostGitExecPath = join(guestRoot, "exec"); + mkdirSync(hostGitExecPath, { recursive: true }); + writeFileSync(join(hostGitExecPath, "git-remote-http"), "placeholder", { + mode: 0o755, + }); + // Also create a "git" placeholder so git can re-exec itself + writeFileSync(join(hostGitExecPath, "git"), "placeholder", { + mode: 0o755, + }); - const output = result.stdout + result.stderr; - if (result.exitCode !== 0) { - console.error("Git clone failed with exit code:", result.exitCode); - console.error("stdout:", result.stdout); - console.error("stderr:", result.stderr); - } - expect(result.exitCode).toBe(0); - expect(output).toContain("Cloning into"); + const execPrograms = new Map([ + [`${gitExecPath}/git-remote-http`, gitRemoteHttpBinary!], + [`${gitExecPath}/git`, gitBinary!], + // Fallback paths git may also try + ["/usr/libexec/git-core/git-remote-http", gitRemoteHttpBinary!], + ["/usr/bin/git-remote-http", gitRemoteHttpBinary!], + ["/usr/bin/git", gitBinary!], + ]); - // Verify the cloned repo has the expected file - expect(existsSync(join(cloneDir, ".git"))).toBe(true); - const testFile = readFileSync(join(cloneDir, "test.txt"), "utf-8"); - expect(testFile.trim()).toBe("hello from kandelo"); + const cloneEnv = [...gitEnv, `GIT_EXEC_PATH=${gitExecPath}`]; - // Cleanup - try { rmSync(cloneDir, { recursive: true, force: true }); } catch { /* ignore */ } - }); + const result = await runCentralizedProgram({ + programPath: gitBinary!, + argv: ["git", "clone", `http://${hostAlias}:${httpPort}/`, cloneDir], + env: cloneEnv, + io, + execPrograms, + timeout: 60_000, + }); + + const output = result.stdout + result.stderr; + if (result.exitCode !== 0) { + console.error("Git clone failed with exit code:", result.exitCode); + console.error("stdout:", result.stdout); + console.error("stderr:", result.stderr); + } + expect(result.exitCode).toBe(0); + expect(output).toContain("Cloning into"); + + // Verify the cloned repo has the expected file + expect(existsSync(join(cloneHostDir, ".git"))).toBe(true); + const testFile = readFileSync(join(cloneHostDir, "test.txt"), "utf-8"); + expect(testFile.trim()).toBe("hello from kandelo"); + + // Cleanup + try { + rmSync(cloneHostDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + }, + ); }); diff --git a/packages/registry/kandelo-sdk/build.toml b/packages/registry/kandelo-sdk/build.toml index 16ba74d188..98ced876b6 100644 --- a/packages/registry/kandelo-sdk/build.toml +++ b/packages/registry/kandelo-sdk/build.toml @@ -16,6 +16,7 @@ inputs = [ "sdk/src", "sdk/tsconfig.json", "host/src/constants.ts", + "host/src/file-offset.ts", "host/src/generated/abi.ts", "host/src/homebrew-bottle-relocation.ts", "host/src/homebrew-guest-layout.ts", diff --git a/packages/registry/kernel/build-kernel.sh b/packages/registry/kernel/build-kernel.sh index 8d309664ee..a646cb04fe 100755 --- a/packages/registry/kernel/build-kernel.sh +++ b/packages/registry/kernel/build-kernel.sh @@ -27,6 +27,10 @@ fi wasm_require_exports "$OUT" \ __abi_version \ kernel_alloc_scratch \ + kernel_blocking_retry_release \ + kernel_blocking_retry_token \ + kernel_clear_process_metadata \ + kernel_commit_process_exit \ kernel_create_process \ kernel_create_process_with_stdio \ kernel_dequeue_signal \ @@ -36,6 +40,7 @@ wasm_require_exports "$OUT" \ kernel_get_parent_pid \ kernel_get_process_exit_signal \ kernel_get_process_state \ + kernel_get_socket_timeout_ms \ kernel_handle_channel \ kernel_has_sa_nocldstop \ kernel_host_adapter_manifest_len \ @@ -44,15 +49,36 @@ wasm_require_exports "$OUT" \ kernel_ipc_shmat_for_task \ kernel_ipc_shmdt_for_process \ kernel_ipc_shmdt_for_task \ + kernel_is_fd_nonblock \ kernel_mark_process_signaled \ + kernel_mq_descriptor_msgsize \ + kernel_msqid_ds_bytes \ + kernel_pick_signal_target_tid \ kernel_pipe_has_readers \ kernel_posix_timer_fire \ - kernel_prepare_write_operation \ + kernel_push_process_metadata_entry \ kernel_reap_exited_child \ kernel_remove_process \ + kernel_semctl_array_bytes \ + kernel_semid_ds_bytes \ kernel_set_current_tid \ + kernel_set_cwd \ + kernel_shmid_ds_bytes \ kernel_spawn_process \ + kernel_spawn_reserved_process \ + kernel_spawn_scratch_begin \ + kernel_spawn_scratch_cancel \ + kernel_spawn_scratch_capacity \ + kernel_spawn_scratch_pointer \ + kernel_spawn_scratch_retained_capacity \ kernel_thread_exit \ + kernel_thread_has_deliverable \ + kernel_transfer_channel_execute \ + kernel_transfer_io_execute \ + kernel_transfer_scratch_begin \ + kernel_transfer_scratch_cancel \ + kernel_transfer_scratch_capacity \ + kernel_transfer_scratch_pointer \ kernel_validate_task \ kernel_wait_child_poll diff --git a/packages/registry/mariadb-test/build.toml b/packages/registry/mariadb-test/build.toml index 5497d15493..e430c7aaa7 100644 --- a/packages/registry/mariadb-test/build.toml +++ b/packages/registry/mariadb-test/build.toml @@ -11,6 +11,7 @@ inputs = [ "images/vfs/scripts/vfs-image-helpers.ts", "host/src/binary-resolver.ts", "host/src/constants.ts", + "host/src/file-offset.ts", "host/src/generated/abi.ts", "host/src/homebrew-bottle-relocation.ts", "host/src/homebrew-guest-layout.ts", diff --git a/packages/registry/mariadb-vfs/build.toml b/packages/registry/mariadb-vfs/build.toml index e2fa784eb0..6228af3eb5 100644 --- a/packages/registry/mariadb-vfs/build.toml +++ b/packages/registry/mariadb-vfs/build.toml @@ -11,6 +11,7 @@ inputs = [ "images/vfs/scripts/vfs-image-helpers.ts", "host/src/binary-resolver.ts", "host/src/constants.ts", + "host/src/file-offset.ts", "host/src/generated/abi.ts", "host/src/homebrew-bottle-relocation.ts", "host/src/homebrew-guest-layout.ts", diff --git a/packages/registry/nginx-vfs/build.toml b/packages/registry/nginx-vfs/build.toml index 71f723945c..6c12dc788a 100644 --- a/packages/registry/nginx-vfs/build.toml +++ b/packages/registry/nginx-vfs/build.toml @@ -16,6 +16,7 @@ inputs = [ # this image. "host/src/binary-resolver.ts", "host/src/constants.ts", + "host/src/file-offset.ts", "host/src/generated/abi.ts", "host/src/homebrew-bottle-relocation.ts", "host/src/homebrew-guest-layout.ts", diff --git a/packages/registry/node-vfs/build.toml b/packages/registry/node-vfs/build.toml index 5e9264a2ee..c631a9d254 100644 --- a/packages/registry/node-vfs/build.toml +++ b/packages/registry/node-vfs/build.toml @@ -15,6 +15,7 @@ inputs = [ "images/vfs/lib/init/spidermonkey-npm-runtime.ts", "host/src/binary-resolver.ts", "host/src/constants.ts", + "host/src/file-offset.ts", "host/src/generated/abi.ts", "host/src/homebrew-bottle-relocation.ts", "host/src/homebrew-guest-layout.ts", diff --git a/packages/registry/perl-vfs/build.toml b/packages/registry/perl-vfs/build.toml index 728fb1632a..42cdade815 100644 --- a/packages/registry/perl-vfs/build.toml +++ b/packages/registry/perl-vfs/build.toml @@ -7,6 +7,7 @@ inputs = [ "images/vfs/scripts/source-extract-helper.ts", "images/vfs/scripts/vfs-image-helpers.ts", "host/src/constants.ts", + "host/src/file-offset.ts", "host/src/generated/abi.ts", "host/src/homebrew-bottle-relocation.ts", "host/src/homebrew-guest-layout.ts", diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index fb125bc8e8..7704050172 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -4,344 +4,344 @@ "bash": { "manifestSha256": "5dc4d97dc4f154f265ff57e9d972e7d5a64a2e15fadedfa246733b830dda3497", "cacheKeys": { - "wasm32": "f45b7eb0ff367bac6331bedd038598b0708ca5f3400ffdc97064fb73d23982f6", - "wasm64": "e388eda3d16e01b1ef4cd7b3394dad13b189e49196eca5741e30a98d8d6cfeb9" + "wasm32": "b23ed586d38fc7aaf6a4da4acdb9663f99b18431ea9268737524d312e69ed18c", + "wasm64": "02bbcbca553b5d6b8a23c8562bb374e4e8c7e2bcd4cef343029edf015d0c69b6" } }, "bc": { "manifestSha256": "43b61e92c29098720bc7f4871a3b611cc01ffd124024290d42967cebb6ffd459", "cacheKeys": { - "wasm32": "b9f425f193548cb0585c55bebdab2e7f7e22c4bc6aa5a407682874ecb6448795", - "wasm64": "b6fae2a06ad7e254e7e10dc0c747290f0f09a96b6dfb70c96cfeaa903ae7f98e" + "wasm32": "fcb2a8e63865ec30a3ce397d368715ff51be01a2dd3268897a71be324bb7fed6", + "wasm64": "ece0eb487708585e4ea0cd987275d0c7f5a6ce1c5728ad1a8385ecd65ec28126" } }, "bzip2": { "manifestSha256": "59e1e53f5675e7c9148634933ed0f4c7192743727025cae4e843b6924c489abf", "cacheKeys": { - "wasm32": "15d8ef2530cf8a5cdf384890dfca7a328b66b7041ee31c6cc52e66050a6389a2", - "wasm64": "69c0d7b9fc2999d82dbbf06d764867a6119edd4cbc1a3b1ee5b16f412e8302fc" + "wasm32": "d76042b18566555fcdd155cd6f93cd4f800464b03701033678433b7a01aaf28b", + "wasm64": "78877cd00b538fa713dfefbf39401670d463a33444635c8c63a00843ce8675ae" } }, "coreutils": { "manifestSha256": "72fc9c3818fb11cebaa047a623743252395dc729163fa804a7272d59f87fe82d", "cacheKeys": { - "wasm32": "025c1324e522da0331d6145a0adcc5205c70368fe1e35cb15cc25bb13e8616c3", - "wasm64": "258bfcc358a310c5b86a4b48d05163840083ff2a38713118be92208e932522d8" + "wasm32": "5e780a3a00fdfeb141fd21e895b4cc9005970a0ca7f87d422f1acb072ae7fc4e", + "wasm64": "c898f50e143b676de8181c94482f29f4afe421585d0f1db05770fd12f55f54eb" } }, "cpython": { "manifestSha256": "7dd4f446697a73941ec940c2ccba4d53be73fe6947f1e701031a4d0aa964c4ff", "cacheKeys": { - "wasm32": "4bd6e5948f8b59105b5159709dfbc7b1e6b1851acc4b34ceb4c23c51fab643c1", - "wasm64": "55cedd53b74378ecbcad641455c1e2419e6cbd16029ecda31a1ae0e308cab38a" + "wasm32": "08c8e564afadabf660259f5030bd9711356a4245c317315853bb6b0a6d904b55", + "wasm64": "0b1caec73a9e4bb196dcfc49c59dd330edfa9ff46dd0e6fde653640114547a25" } }, "curl": { "manifestSha256": "55523d50261f46dc4aaaff458d1cd87c6f96eaecd687a7540ead35c96906366e", "cacheKeys": { - "wasm32": "60fdd5816eeda424dc84875008821348c87f45ce44af03755514eaf184fae225", - "wasm64": "282ebeb16d5b27a0b962b5a39d944e3536929f12f907a0fc60da586f52a712b0" + "wasm32": "f79be2fb854ff7855444354b36736b904f7c34279c098f14af6a779f1b5499ac", + "wasm64": "49e6f520a35bb0415208883b759b0df7a2bbeb9ee60e57d07aa54f73d8e512f6" } }, "dash": { "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", "cacheKeys": { - "wasm32": "f0373b19c4e22a4359403372bdc43ed56f35cebbc939c3e34b2c34560ab466c8", - "wasm64": "4050007a409933f2b5988c77ac7e8ff1b1a06be2a3d5a6b5b627539e790c1ec1" + "wasm32": "4af6e234e2447445d4f18882a2d7bb8d9d4fbd67ee258ef88d661140749109b3", + "wasm64": "2dd53c014af59fc455b6a86abbd034e7cbce505b1d3a8cc55877691446c46c5b" } }, "diffutils": { "manifestSha256": "2286203eb762eca487939963e38ea1b901ac2dc9a830d3260c2fb291757df208", "cacheKeys": { - "wasm32": "c5390d8a92f04562466236d79a225c7c611f075ed02d1d21fb5b080fb7f01a0f", - "wasm64": "c2b20fe9e29f6c0b39ed483099cadd139cea50c5813f9a692be1b7c32faef79a" + "wasm32": "6b75f917e8dad9908406972a72f06ed67fe9f31813bedc70b9bf0bc078b0244e", + "wasm64": "5df04924097a40f5a9d724c342eb9a1887e0b692d05f117fd49d2485056315d9" } }, "dinit": { "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", "cacheKeys": { - "wasm32": "3f86ef60f36863f0437258c43d10333a65c9412c8899688242fb16207361fa6b", - "wasm64": "d390cee24c29c28de3914085cb36459d4265e4f3a538ddf08096d460ec442ac5" + "wasm32": "b81a453c2dcd0f838af57a6d3c3f30afddec3605aab599cbba531f9b74e0d8ff", + "wasm64": "fe199c277d863f294d5a0723581e93c41dd20e0569b24dbbc329a3517acfaeb2" } }, "erlang": { "manifestSha256": "475d6037e73a0f1e2f4381067194b2422751206979ea17209e10efa5a2a93bbb", "cacheKeys": { - "wasm32": "7240df3edd54fd8ca538f915c4a49eb66fb06df6eb9bda51678c5c30fe97f0a4", - "wasm64": "1f1fa44946d45d8e99a9a6743064d886497c9ec3daf84cd2925e259fc9447de7" + "wasm32": "452d0aadb894214945f5559c44cd1c1effdb3847a5ccb693150b61a7a60f9ff3", + "wasm64": "d445dd3c5eea63d40841dd208cb7ace237796a63fa0cbaabeaaa7673a4204fa8" } }, "erlang-vfs": { "manifestSha256": "15efb74f1825e2b85bedfeb8d98c19fa648c4be39cb9defb65330a8f21eb9141", "cacheKeys": { - "wasm32": "948a07db1bd03c14d9ea6b13f0efa01e5decb92cade7750ecd319107008ef80d", - "wasm64": "819c179c21719df86a5d4792dc8818b469e0af641203f6018b0bb4131fbeba8b" + "wasm32": "44a626ae5a57adcf86657d51a557c4782add19c3f516da1ebbaa363bc9ba03e1", + "wasm64": "ce71d2d55080554891d044ce7f9e88cbf497cbb20fa4d261d143a8be29783391" } }, "fbdoom": { "manifestSha256": "a00e0d9c84fcdbb3bd95f296cb3422d60b86dcff4c40734eea1bb0bec4c7d902", "cacheKeys": { - "wasm32": "0b567960b1b6b829bdd14304dafab6a508e23dfbc114a999892e1c411f2c63a6", - "wasm64": "3b18c5f787c15a5beb5c2c82f504d897e84add456a6f30f7fd48d4dffdda3c49" + "wasm32": "5442a5d8bc65c0436f25c86329dd6ba6c51b96482ebcb1b0cab2d4a69ac1e4ea", + "wasm64": "9b944a9eb195529fb0dd258ac90420e6dbd8d73862989ea7fbd76a2d5fa235ad" } }, "file": { "manifestSha256": "7874c1affcbbf2c8ab8c8d4beb087f275af57b5caae6a8947bf5a63a181552b2", "cacheKeys": { - "wasm32": "8112d62ebb8c99a5a1714799f366be25fecf6b4c81f1e4e07aacd57c91535e47", - "wasm64": "48902f4ee247661b59cc4ada31455957ad1028383f26e5de4677a3a6c71b2fcd" + "wasm32": "15c796674ad2487d06c372fc6ed24a78c170ea1d96f28fc725352c69b4ec8300", + "wasm64": "a28ecd569e4c866bc9eb3bf32b675f5b6e300a9c3df8bdc00fa18c35b87e6a7f" } }, "findutils": { "manifestSha256": "a9969cb102403cef105e758ef602692500fddd75ec96e4e3f168c50094b6c931", "cacheKeys": { - "wasm32": "8e97787e868fd0e18a9855b9f82bb410cb2e358d1f585cfc1e87cf21985737ee", - "wasm64": "b2f16c41fe1012115cdf1aebeb25b818743e88a95ed11419e623fcb08e7383a6" + "wasm32": "2ae9e1bdc0a5b6d2b9a3f1c943768d319f8553264702a12f66f9ba8029453f67", + "wasm64": "77c50d234f34d3a139d231f860daf27b5753e0dc2a6660055298f36bf78fd736" } }, "gawk": { "manifestSha256": "b788f3de724cd363ea68d08826586ddf544a75fb5dd9df1369ffafe06d8681b7", "cacheKeys": { - "wasm32": "c388eef100553698e390f5a207c418afff2a075547ec5f3536460748b7df55e6", - "wasm64": "0a3abc9b7cdbac13255dcedf2a3674f4212c3e185fa5796132eb77e28d8005e6" + "wasm32": "a4ef112a7a00e3fa65b15143127f114794a28cf65cccb1c4bddad97b31b20800", + "wasm64": "3323f45c5c4aaf0ca4ef2f2d157ac634fe8a8cbc3c95566b4105dd8b038a4987" } }, "git": { "manifestSha256": "c2bc79e62c9a4e840ae94a16af0f41070460eaf3976a990dc60d2db977bee952", "cacheKeys": { - "wasm32": "3c1a3fe79b8f32a26d67e407f10c9c34a60035179024bc4b4d6064e52f6948a3", - "wasm64": "bcf2dfd3b2b19516cea40cc48f1f04b63bef4fc6b787c8a5c0a3221590be6d9c" + "wasm32": "2d4e9f21749d4a27b8de3de736f8491e8d078ebb0e3509009d53dab44ca27d9d", + "wasm64": "c8e57155e76528cc2ecdee896eda5a7e4536f0386331a22ac5191bb16c521f61" } }, "grep": { "manifestSha256": "7c61b894807b831c7e8816cb1f39c78e3a69a4ced2fa3d18f0abdf00bf18334b", "cacheKeys": { - "wasm32": "3e0a00c54f9d2086dc1efbd06bcf54b6bc5d35919d58dc4267bdfd32cfb838a2", - "wasm64": "23c3722692f3301df220915062af2ae3d7a3b06ac830402f6bc0f752cdd8d50f" + "wasm32": "25e15778d49302390f9882880818560d7d48c5bc60d78dd520ac3f7625c394f5", + "wasm64": "79484cdba7276b7581c22eccf81c767be2db9c6bfcb258922c90662cc554ddf2" } }, "gzip": { "manifestSha256": "1485add843bbcd744244e707c353208fe11192b7b248feed1550875d3b75a917", "cacheKeys": { - "wasm32": "96085853b6c4c622d3b60acbdc0c982b1c51b079b79e942bdc02aa684c4321a2", - "wasm64": "d64ae559c979df527f91c0fa9795564a6c2efabbc23e13b6ac9cf4299eb62a5f" + "wasm32": "83b7c2e0896cb8f2838a21d07b55113e47620a5c1d5cb95fab734c816a7f07fd", + "wasm64": "ebea7061bda3d99ba97176d536e720a8d57c19782eb3e27e0445fa0f7f7d02c0" } }, "homebrew-bootstrap": { "manifestSha256": "183004a8385ab5afc388cd43401341b82812c67f97b08ad349ebd7f4dbbebd0f", "cacheKeys": { - "wasm32": "d3865a95335c0f22ea155825dd8d1b1f680bf90ca00d4024b59c4e0928459912", - "wasm64": "52f7769154a133f64aa5d3ebb1daf567eb8932f03cf936ffcc147bdc17005cbe" + "wasm32": "b7181df86393240dd66b4176c0f038a2f3d8a340756542bbe55c1040848c2ed8", + "wasm64": "eeb9e30525706fcee2c1ff59c26c392254cfb21318212b285dbf262ca5c8b3d2" } }, "icu": { "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", "cacheKeys": { - "wasm32": "d129add0d6c230b17232aef83127a269c5c1c11349d9331d06fcc6889b56f5d0", - "wasm64": "1b350f7d1d9411561a0f7fece1ab0927f2774d22e7c41f3ab5b12bfe4e5288b5" + "wasm32": "5b87a5e6802b617851d5fa4efb16b3762eac06f0f683e2c3bb5887225cb7262f", + "wasm64": "177367152f6f5fc3596e3abd49c44013089a28493c2c1d52bdf1ac708253735c" } }, "kandelo-sdk": { "manifestSha256": "c081879f1becd855917cff96f17429bcf2b9fd61bf95211eca9ff8fb625bb2eb", "cacheKeys": { - "wasm32": "2d67aa6a7aaca3edfb622a3888ffe8b3282ba212e12e814116550357245e2748", - "wasm64": "ea12e46306452389af056b145ad25ecc5b1b7fe7467e1fcf8909573024193913" + "wasm32": "f4a952253f40457f8a8b855af6980e9deece7a6f3f2110169dd85127e7812953", + "wasm64": "717c80ea22826301e1da6d8ee24228eb2c01c9cfd01c7cdda93cc576a8d411e1" } }, "kernel": { "manifestSha256": "db1d66db8575562ac7b3e720dbaa06d7dd5eef577d80220ea4167dc2d69b863b", "cacheKeys": { - "wasm32": "a135dbbd66f558b2f6d41a16db4352b97e58d7e61f3afe9b3eed8accaab4564d", - "wasm64": "011b52b75f0a7e1bac867e5469c03c7db40dfaa22500005b0c942bce4e88b4b0" + "wasm32": "30dfa5f78ede4eaf3259a41e60c2b9ed1ea76a68c4e62b6929fb557c6760e3e8", + "wasm64": "ec47fd0885aec989c752326704e0c9df20b0ff42e93d750660f052785956aa5e" } }, "lamp": { "manifestSha256": "250b64635d64f8537178188fb5488dbfd2980d79a1c2ffbf346af67008088ba0", "cacheKeys": { - "wasm32": "c156d707a51eef48fdc4764ec7a07fbe35a70ec0ebc172284582ad65b924ccf6", - "wasm64": "ee089e939f439872026712b41defad4bfbd3e3b6ad511eae23ffcd67c324725b" + "wasm32": "7895c6e87025c059b131a332b12763603ec10a945f9aa5b43d16d6ed53e21b60", + "wasm64": "ed6f51cd9a18f7bd8e207b5db8b56e2e1bd06c112a54b7f64e24de7ff2694614" } }, "less": { "manifestSha256": "996d61545cfe83dcb663a33e8763e0edbd4db4a605fd69a91b243cf79b1f9b17", "cacheKeys": { - "wasm32": "7b4ae641cb4f9310e48636f98f955cb3f79557159bdb1e80b3fe6badfaf5689a", - "wasm64": "98ae78164d78e1cef5d6efc0323252cfa3e8956450148b39171b96b6df31e371" + "wasm32": "ebfbcb7880115cc263c2c44c2d82f7808c71fd564f60965d4c25ef4533f831ee", + "wasm64": "b2e405f8a4ed93a5a3fe3eabfb2233acf9b7f4f4531d464c34f9908719146328" } }, "libcurl": { "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", "cacheKeys": { - "wasm32": "2c986bb0488a9ff05b1ef2ec3c0c44be940504ff9b7f33a4e235f981dc016e17", - "wasm64": "80713e045d536147a60d892c18230a85a41e32877a3107d081d7ea48e2efcdc2" + "wasm32": "086705244733c6e7d585c44fc921eba478d0508902d91c2a83246a7d4952874f", + "wasm64": "52d162d098883374cf8da7f7575603ab8d480a0d8a4b4e5520ae78f2e4777d88" } }, "libcxx": { "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", "cacheKeys": { - "wasm32": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877", - "wasm64": "a8535610a175d2c3c443596be03f123f2c7a007a26bba163bd09d4fba10d8b42" + "wasm32": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f", + "wasm64": "1a456dd94e95867f80ecb9457234d7b758ba5454160a8be1a7cc55ced3739908" } }, "libiconv": { "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", "cacheKeys": { - "wasm32": "23113207cb8ec3b213b054284770c9ad92d738b225044d01563f1bc3a677622c", - "wasm64": "a6dcc2c2fd20afea3ba0f289664a7c4a98df91b322519b02ddbb7d88cdebaed4" + "wasm32": "d0e75465fa21c6a4090bc2cc92653dae900f85aa79cae5ca9bf67ee0578b7d1c", + "wasm64": "7c6ae8ff3601fb8f72c76c250e9b266d92502ac3aadd146792ec171951e9bd94" } }, "libpng": { "manifestSha256": "79ed5c5c072a0267cce5c7a2fe37d8494571b17e44b952f619e04ec1c4a9db10", "cacheKeys": { - "wasm32": "6415b2ef14cf038d99c24521c5f7272ce81e929cff5e94ffeb3031b5c9140195", - "wasm64": "1158eba3e006f03e0863be9b883d3cd96a7bebab57b09db106054f73f780a0c9" + "wasm32": "d18ea038aeb10c81c3a24ca550333669722713cd6c6dd828f5b0c463d91f7ecd", + "wasm64": "86f8d085b084a7f5769c73f614765eefb8ecbc154b3803e8693a1eae3473f1c3" } }, "libxml2": { "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", "cacheKeys": { - "wasm32": "70356b9298ebe5b6d58e7d08093d71b1137c2ff99617beb3d8134f3bc919b088", - "wasm64": "ac76eaa8521d7cf511b4c13fdb3d31afddd657d0a0f276bc62fe8e603b1b6a61" + "wasm32": "0daf9f05a267210322bf2a64a77cb05b1b077fc12b340a531e0a900abd0add92", + "wasm64": "c375c1777915398610208a184698dc92bdac896f4217d2e051cbd26d07290e15" } }, "libzip": { "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", "cacheKeys": { - "wasm32": "4aab2056a613043f30ebf493e466227b595740de70ba210e80919a6d3c1069f5", - "wasm64": "b5e87ca26e9d446c840029d840dad4457380a6b890f2b768fd9fda00bc059e6d" + "wasm32": "9fa4575b5833bbf94a6ff9e7931519536dc85ffb2a42f9d019e3e7331a7b50c9", + "wasm64": "d15368badc5154f410557e052f2fdea94c1e940596074e29b18f13bdd12197ce" } }, "lsof": { "manifestSha256": "cfd199bbe082435f7a2e703e2738a368af1eeb5b76b6e93c376054f21ce94419", "cacheKeys": { - "wasm32": "5a380c75d0d25b820bc140e5b9e4d01ffa4439679b5b5b8fe0dfe9ca8e67b00a", - "wasm64": "17c5eba50177599c535cb3b60d810c5412258f064c7872300e8a38b1e6950a40" + "wasm32": "c934ea1907968b2dd08601165cd9c75533e2d97d480f8f0587f9faf7dd1fc10a", + "wasm64": "330675549efa7766a4b5fd0f6d775e5e37cecd6da674dfbd885b81caa3bf59cd" } }, "m4": { "manifestSha256": "9b5838bdde8531079f23a89e135aa3832bbbbb7ad6099dd1503db877d52afcad", "cacheKeys": { - "wasm32": "13c48d5828987e67dc653829de378a172f8a486e1427c888168526e2536747ce", - "wasm64": "798ba69e59627b5e9ce7a753728f2ccafb3b19bf7d9b72d0727b3351ef231015" + "wasm32": "c700b1d1cf477b97829dfdc7daacaedb4087db777764eadea49b13c2cb7669d0", + "wasm64": "fb83e2f3e2d6bda1257eb11ce9f24569c894b09c3bea57bd5b53459759d8c00e" } }, "make": { "manifestSha256": "62e8b36a6df7e981d191061a5bb7f2db85b2deaf365352a7d590a6c6c06f2b1e", "cacheKeys": { - "wasm32": "b326ee0fce5b1d3f5ed8c0d865a6f351d162b3848bec1084baef1de028418a82", - "wasm64": "b07fb4d5ce35772d1e9683a9464af917118c23e37162d3c59377002daa909b86" + "wasm32": "f217b621932606018491738a5e93e7d34f2cc48bafd9b807d80454b203235051", + "wasm64": "ead71f93f2c9bc4a3044ac54716da9406546c3cb3ae657c57e02ccf91a8f4509" } }, "mariadb": { "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", "cacheKeys": { - "wasm32": "141ed56befae2c701961c53a06f1d3dcd1949ce5012e6c62209c45092b13e3ff", - "wasm64": "ee47227abc6a8059e1e4501515ac18656cca0501e087018708fd15c9f38aebab" + "wasm32": "3fa3d08a22b4a8473779430950bbf027b4673c96b36f937bcac7faf8105badc9", + "wasm64": "adbe9a05a3d242dad683a79e017662de9775e1be312fd01a1f18d32b93561e47" } }, "mariadb-test": { "manifestSha256": "d4ccce161d659a5a87c80fa1117054bbccea870e0d4a46bd8a070d3930ad0e3f", "cacheKeys": { - "wasm32": "784f34c97f112d3348013e6a7450975c631cd7c5de12907c924ca4167f074e37", - "wasm64": "5ee6bfea76fa5225e28c8ee29e853b7451d90cb15c9447438f741446d9ae2a16" + "wasm32": "50feef6cbd0b15dc2594d22d0eaa40dc349beb55e76900ec2c2ac8324a0ca6c5", + "wasm64": "55c691c94675baa939e8a6cad64787a0e1ef76077c9cba955261be1832c0cfd8" } }, "mariadb-vfs": { "manifestSha256": "26e74ac84d89b2a839ed72783437061a23022ef2f88ad0f52b6aacd0442ee1cf", "cacheKeys": { - "wasm32": "4a858c3f8add0dc39eb56a08b7041ccd84e47fd6fb14a526efdd87539a77468b", - "wasm64": "6a3b1cf6fa442cf7868b8f4beddce967f623f19fe83a7e483f8532523b8472ab" + "wasm32": "98e31fd13a253ab211aeaa1c0ca91d255365d888831a9761273d851c65498543", + "wasm64": "238e701a44e8e8c8cd66ef9cb453e310cfa12d4918cd40983977d81fce716c8b" } }, "modeset": { "manifestSha256": "36a8683c1c7309701d361c5f77e543a6c1531397b26c72dbb864528c59c42954", "cacheKeys": { - "wasm32": "d1c2c44f0bea233667911c79cc5a5ef8fc9b41210b2913526cec1888647b71ec", - "wasm64": "8cbc1083bfff0976338ee38c652d5b23e9a6d5b9611b8996f0cbfe09ea1335e2" + "wasm32": "b9892a9a50b3119cb27c5621d0c41c7c7384eed6ea3213dc09cb19d66330422c", + "wasm64": "dfed1df60b79cd0273abc37b183cedd29d2f62f3192d3347f59f0b41f0a35cde" } }, "msmtpd": { "manifestSha256": "686ff665b5e7907e67618790b1a3eddce2ff47ed665595d17209bdcda1e0b274", "cacheKeys": { - "wasm32": "ea09f8a7c8a43ddeed1fef59c3a9a7b987e0bda7be881d4d1ae4019d73a7ef30", - "wasm64": "806cdbf16860e704425233d997efcece113925eb0ec32b21e22e8ddbfd1da6b7" + "wasm32": "29b34d2aa3442f7d4a8b2126936735a3606af82831c45e13c36e79930d147a88", + "wasm64": "13484d5a0b0881e773ec2d8a59bb790d69e07798bbe13e1cb064e6ec91bd0a6f" } }, "nano": { "manifestSha256": "c5a52f0c37e08b475bb815367b1f0d63d0d2b06649d99fe9f461b72d0b9cff51", "cacheKeys": { - "wasm32": "f4b8af0d2e6dfdd05c495ed26d2fdb80bb3987ac1eb58e324e17272ea6060bd9", - "wasm64": "ac2dfa7aaf8016d4370782ff807bd884816079e6142f5b8a54582aa5b4290095" + "wasm32": "f2e860fe0e0653890e6f20bb984a5b6ffae3aba7e16a52410aa11c5c3e8aa9b1", + "wasm64": "5e8250ca56fd7c6fc2c14ea76f049300bd1663e437d0724a615df644cfcc9db9" } }, "ncurses": { "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", "cacheKeys": { - "wasm32": "9c9b20d1316df92a12cc0e0a5fb327b10278220cecd94a2adc3ed8ac9a29ecd5", - "wasm64": "6f4760c0721d6fd8dbe935ca3baf60823df66d8c35d0e3e2d7f39b1b0ce2b2d9" + "wasm32": "21b315a238800c2b0aba33b9e312d672133518d9752fc5627c67c5852cac3095", + "wasm64": "54708d925f7d639c5fdb4b29630353214c213f4dc49314a57f1fbf545c7ff8a1" } }, "netcat": { "manifestSha256": "4cf33cd1ab768b3ad0108da8b68c2ff72e98469cd86a0bf2d0da54a7d4f6fa16", "cacheKeys": { - "wasm32": "1827799c81052b95dbc22e913e556ffd801c1ee2e3cb4435673f1d342fded3d4", - "wasm64": "0559f1cb68123265fd3a3cb3a57dcd1ff0459aa00c5b57e94bf61d471bebaf36" + "wasm32": "4a70153037218964d9e135865889287d38bb59a9364cd53889e2d3eaa795654b", + "wasm64": "9e24ff1aad0139288878cc3cdcf1b85d0a2bebf10d89001a4d58e5c961d2d6c1" } }, "nethack": { "manifestSha256": "1a2f12ec2770bd8d40006d6c878a3493b97c71c2bb63ad08c7f6ad99bfd53504", "cacheKeys": { - "wasm32": "be86dd34e94fcc32fd7c067f81df5685df966e3a8e957292b2ab09d8f6bd159d", - "wasm64": "08cb42ff41eaee8c545e3aa19022ec01b700be46adbfa669b1c9316651847eaa" + "wasm32": "baa5470b14814fe2d07abcdfb041336fb215045159d8ac42a1e7410166f52a44", + "wasm64": "8ee996953d6678d3bdb7da526285637848b51fbb6e008fffa5c3b856c75b6779" } }, "nethack-browser-bundle": { "manifestSha256": "b8294981da7ce4299dfb271d4aecb90ababa97a4d1acc9b716ae05bbe52beb53", "cacheKeys": { - "wasm32": "2f2221697fcd9c2e54dfa6cb38ed9c307dd6e018fc871530a1d307cbd5cfadb7", - "wasm64": "baab49df68ba8b41ddd0fe950b69f837de789b724787cdb80d6cef9b9a02ef47" + "wasm32": "c1a9bc10cf611c917af84f1b7e5a7c22556d3abe6f25e9d9715215490f00ff61", + "wasm64": "ddee80e33ed727248be4d461081b17419f9ef741df373b634598c685ab24ec76" } }, "nginx": { "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", "cacheKeys": { - "wasm32": "cd58a900319c46bbc268ac664900527414e9c6ffa40683b3be6bcd97d4d47283", - "wasm64": "7b13bf7c29feeb2d6b27aa951f0d90784cde35db279639202c75f5815b106331" + "wasm32": "d09b3ed121683c8dec908fdcb49e5b852e5da476b06bcaf58158fa7fbd734eb0", + "wasm64": "84e99a7659ddbaa0729526deed0bf077ba1f912b65e026553d5aacaff381344a" } }, "nginx-php-vfs": { "manifestSha256": "97976410cb02f8ba710d856b4ac904bcf976d677b50eefdee39fb64176070d4b", "cacheKeys": { - "wasm32": "abe26676465686f26ddfe4f83d5763d72521a6a94e0735af17a93b94307d643c", - "wasm64": "bf401007a0447bcf58bf952ffc8f4be9c6b3b8f28ac7b410edf153633b7a141f" + "wasm32": "03e55aba7d25d8f94becb9254646e49acd77b6e56f39bbaa7acef5934bfc3cf7", + "wasm64": "a5230177423cf83644fdebc49b18712f3cd38c6c6066531033bcb1c8c09a1ce1" } }, "nginx-vfs": { "manifestSha256": "46aa2d3250ac5cd0f102a85c3a36a086c7a50ba1f9e05fa1c2aae3d07ad16f10", "cacheKeys": { - "wasm32": "505ca1af0a7383a40519de0982e53852d66ad50f164d12e08e815d2d36187985", - "wasm64": "c288069581585615b2433700e37e8c795996e5af8955f66899623b2cfb91b268" + "wasm32": "355355d35471171ee51a3ea30cd70ba4a90fe04753174e046030da5c802f84c8", + "wasm64": "f810eb0d3de4cf4f2e5144d31667d5f35b684697a107e3db1f07692fc811f6f0" } }, "node": { "manifestSha256": "2131a24dfda8587860e57fc65b573086477d376b065b3b9ca4e1dfe4d79f5790", "cacheKeys": { - "wasm32": "59917f32e95cf3c65e899cf230ed19154321d18280246fe9c0e75e970af77e47", - "wasm64": "1c03b2b72c7ab0de8ece40eb3c75fd6934fade6e9b2a0f7ee0c30d9b6b190076" + "wasm32": "6cc8859cf1fc632ff4da532cd90a2379dc72f538c18f1de71e7526b40251d003", + "wasm64": "749594c4ca21f4604866927e31e5404e4ccb7684ae781974f43a049985b22285" } }, "node-vfs": { "manifestSha256": "977f219defe57e18017ecc963159df32efdd21c53d6fea05943703d04739d8de", "cacheKeys": { - "wasm32": "cba98a6b26b1f906c3db99c2e9da398f3a8251cd62915b3b0d62e879f4e5ab9f", - "wasm64": "f8c52ad33c11057fce64da56c21dd167e70bcfa587a5b84641e1b2ebf9659ff3" + "wasm32": "7cf996ad121c1c7bb8e72fee19bc493ed828c12e41361c901cca1e2acf58a4ad", + "wasm64": "86a649bea043d2d6fb67d31b1c3c8958a19b75221c5cb1224d43475541a07c7a" } }, "openssl": { "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", "cacheKeys": { - "wasm32": "7b7c878fcf8eadc0689030d9a8f4e12a15e34933c2aef7192136be4d51e42db2", - "wasm64": "eea1a203ec2dd0578f2237c9d04fd1c74c93f420d32073e12ec8b56598bce89a" + "wasm32": "438683df9d94eddd952daf0cb9928db0dd4910fdd7ff73a6ed95949854dc9d75", + "wasm64": "dfca720319a67d1be7336201e717001fed5ec83d2b7b552ab3dfc5c24e68bc56" } }, "pcre2-source": { @@ -354,197 +354,197 @@ "perl": { "manifestSha256": "6cdc4dbc54d0e4008cff41f82ec8918c0e44b4aef23903539c1bc0f538fe3ad2", "cacheKeys": { - "wasm32": "3f6af76550825313cdc09758c3cf2480b65a1a1c64e0f4a3cee95fff09f467d8", - "wasm64": "2da511425e8d8feba01275eca44738e24fc03fe12b6839d95d8f97b170f8c61d" + "wasm32": "0f6a2264707ab7e996490e4622837995d3fb587496b0821a5397c7b65d30eb28", + "wasm64": "f619e548cc7bb9c21526c00146c13d9ae35bb420bb26ba2db5c2688de4627559" } }, "perl-vfs": { "manifestSha256": "1345cc102bc8c24a6bc276410c00b4fcc99ba5f6adc9b031f882aaa35d53c8bc", "cacheKeys": { - "wasm32": "1c6a0edb1823313d0cc6538e668a6778ade866347392adb8ffbedb1054628128", - "wasm64": "f3cd909068a0627334a9f4f5515e32116fc1864b37587e04c5d1fc32e8d96a0a" + "wasm32": "1ae380c4abae7259272368e5f0e8851436bab4b419af26e1301b63ddd81fa72e", + "wasm64": "f5b228e651cec9077a8526b35a3c10adebb69eeb42e6fff009ddce779c4e8a64" } }, "php": { "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", "cacheKeys": { - "wasm32": "cba27c5df3f7ce7a296d1e672d5ce6671052bcb19e2893b73f926cf7e498d6ae", - "wasm64": "dc015cd2f3d8120109fe2fa2161e450c7a43f8daf74559965ad3b04e6c1de267" + "wasm32": "0b2a2819b64601e21e9c246dc987cda1592e3448550f3df8bb2f7607a136c082", + "wasm64": "04d03536336a96f57b454354f5c7e32cf27a55aeff287a6f1b36351a55cecf2f" } }, "posix-utils-lite": { "manifestSha256": "8fd7190b2848ef80143adc7b9268c79e13cd4db3430f7105faf49a4b4407a06f", "cacheKeys": { - "wasm32": "971093bac83c0c75229da3992210c599002943dd6b21d1e98ac332a22379b221", - "wasm64": "470d1e020c536930dde028f7db846e5566fe32a8706caa9fdf3230ecad9e1871" + "wasm32": "3f3b688a40fc18b4c494053635c53aeff2a90f21592ed7737b54651605a4bcf4", + "wasm64": "626d8dda2f2ca7c5e401d31ea1a32e9a5b5a4e1993b29f261273e79ec5fb28d0" } }, "python-vfs": { "manifestSha256": "d1aa99feae65f06c0ca8cf52c2176534b2723fd4ee6eba6e72c205245e1c9c26", "cacheKeys": { - "wasm32": "413df2ad2183a8750dd0792d526fdc8644f2d26a8a44ae47e950dc41a0fcd86c", - "wasm64": "d903447e95920158a8b8c899bd83e967df0bb924b6a68394b1673da2b492da9e" + "wasm32": "359fa720ef703a8dbb3f84405cfb0aad8e5dafe6e87698b199f329bc6a11948f", + "wasm64": "ae81bffece157a2e5dd3e2312c87a55bf7b387a06a8c25849c7036e4bc79b77a" } }, "redis": { "manifestSha256": "151fb507de953ba2ba94b8f881bc7d66b4c741c1359a2f590cc07f9a4cd368a3", "cacheKeys": { - "wasm32": "0b6e849447c2d14076954fcd4eefd2794d5dd31b220c23a6aa8dc042292e0496", - "wasm64": "a1b37b8f8805e0639b81ea5bfa16ed7cdd78fedcbd7685a3cf768b6dc313fed4" + "wasm32": "65c6ae8b1759de4e7cf5e9238b543a6f72e46ddbb056d568655659d62e744760", + "wasm64": "fe4841fbfc9175e38e1ed37d26674d6d8108329c04aeeabeee1c6355158e22b5" } }, "redis-vfs": { "manifestSha256": "234c45c40f94e2a89f98295313b82cb9fce15a412ac829d9e87394e0dc731813", "cacheKeys": { - "wasm32": "ac89c13d91d1f3fd452d8fb3e1da15b1d2d1dc6402b776cb9b87dcc1c2e44ebd", - "wasm64": "8ab5e8085c6cfa645aa833745085b4ebcff0f25a8e7d2de3833da7076a8232d0" + "wasm32": "e07bc2e3d9383e24d752efcd793c777913c30ff79af8d8f308d81f45799dbf25", + "wasm64": "514a3e14a6370318e3f548a258549252ff6b02939e923fad3c8fe5ea3a8acba2" } }, "rootfs": { "manifestSha256": "0420201025170f48c32949ac62707d4577cdc13a53ad1f01f59d318ec07c8d6e", "cacheKeys": { - "wasm32": "fc7c012003326b1ba75eb8acc08b62b38d000a8d1f03e179a6103430b8cc3c58", - "wasm64": "10c99ab5ab2643bb59a7e56e0288d30ef05d1851edb8b5b167bfbd72ffeda864" + "wasm32": "c123756aae930c768a6d4aa763afc7e76fd37def700aefd071fac72a908291ec", + "wasm64": "e3ecffb16ff23e299fcc0cd9e9eeda4ae4d30e0f2026cffa6f1e1d07cb0c9a24" } }, "ruby": { "manifestSha256": "6ea67a246c29cd927a19decbb36ae4c4d478ff6642d2c77e08a6b2cfeb8251d2", "cacheKeys": { - "wasm32": "3e8b426cf871a3ba7e040d2ca161466440a4f2b4ad961cf8d35ac62e974302b6", - "wasm64": "bd76e5b2112fa18e47d7c9cec112012cd845640fc6d919e64de95112be0a8481" + "wasm32": "f029a5c249b4651b80348d6362174acb848a475957e988cf8df057c4a344101b", + "wasm64": "dac5d4d7499a8cd70cd1523a6fef1d55a99fe6ab6c70b3e18780473ae242bd8e" } }, "sed": { "manifestSha256": "0c0d82d53907c19825941501f833252cf9adf35ebcebf6786baae28e5eb6958b", "cacheKeys": { - "wasm32": "23b9bbdb70b972dd95e51ec76d4e96b102a872e4424ec192d38fe1153279bbec", - "wasm64": "01348196bfccec61e51d8d59660f51a2aff1c368f3520f4f7cfcbef70e6d07b9" + "wasm32": "8772fc5aeaa8aac3ecd57cfdcbfe803db7c62f424e738d789efe438f1b3fcc3f", + "wasm64": "e60b72705bc5a935c96b7a4b94a2eedbc834899da7a125e001f547eb3ad9acb1" } }, "shell": { "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", "cacheKeys": { - "wasm32": "735c2c0ecb8edde3797fb59fd6e38f5f8c86a81fced93e3f24e87db5aafcc02b", - "wasm64": "c519186db48ee230f06cd9638e489a95297855c08d4590662e3bcf312f96b59d" + "wasm32": "c8b616ca96ecef41bac1f4d949b483da723edab4976fd2b5b9d47aa11a734613", + "wasm64": "f775c4a901bf07822ab7dd16b9ac0a6230376f5be769c78858e1e01ad697147c" } }, "spidermonkey": { "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", "cacheKeys": { - "wasm32": "703273c0bfc90bf5db63384125331fd60b5ea8dd8910ffd49be251503bce2d79", - "wasm64": "d49e05a0bb553594e2904cc1ad5e44b851a12276d153e37765db14ddf3849d2b" + "wasm32": "b1264b3565e9f7bc4268cb38ef5de19052b7833bb48e4b2b19d572f5b2af3833", + "wasm64": "a11fe2b767e31ce058f7802017c2e24276e1d31684470086e4275c165f818967" } }, "spidermonkey-node": { "manifestSha256": "f156c4c5044d2a9447324e7a2a35f8c3e6fa367b3e44f674dcc30ab6b946fae7", "cacheKeys": { - "wasm32": "68906120c4f5bd6aa5212078f54eb64c038148c34ccdad65eae0b8c97906d397", - "wasm64": "10bf45f75df20ed6ba00f53f7232d354d35b00c5e7dde54032364b6547f99c1b" + "wasm32": "7120b26b20fe4b60b75021299fd08f5063ca380167decd2d96592c0298354bf4", + "wasm64": "9ff683ca1288110e9f076797475cd9b3ab2435578aaf882bd97309539b588155" } }, "sqlite": { "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", "cacheKeys": { - "wasm32": "63f8f0bc003c78665088d6ebdfbd495325454537fba1e6a9df9c1c8a9857cf1b", - "wasm64": "a4202a397c9abdb33ebf2df8fc9187ac9923c2a45a5ace25c802832432b1aa0b" + "wasm32": "2ce2ccd99e2994765e2f2b4b7823a582b9997999a1654d378c31bac8f5e8aa89", + "wasm64": "dd92998048fe185f87775091642d11a79b35562e6ab88db8ea4ac45ba604feb4" } }, "sqlite-cli": { "manifestSha256": "1cebb768f9473dc58fc6e72c9b24565760aedaec4cbdb29ad43b22a0a5fd7030", "cacheKeys": { - "wasm32": "2496e0db5e6a54dffbc592517d1e185c5ea13b2f6642ee5dda39837d6af5c750", - "wasm64": "5f6b1ffa1139ca96feb02834fff8ca62af81b75506b7bf0a82a116e28b1d2e0a" + "wasm32": "5f083d2358cbfcdc112e69f5c3929cbaea4dda0c385c27b12470986ef647db4a", + "wasm64": "fd5666f0e9ec7b2bed6b18c544fb0e957afd59b2e119379faaf1e8561728e9c3" } }, "tar": { "manifestSha256": "4502355c246362a2035e52aa7b35d274882fad4b4404c03470458baee723ae68", "cacheKeys": { - "wasm32": "ce558d30e4aa291b0b83c51e095925c2e3a680b6a2bb3064bd681b616dd92271", - "wasm64": "b8b3ed3975eb2f4ec641fade3afa11158300776aa7b08576c665e9c253d91557" + "wasm32": "33c149eb0dc0bc85afadba8e2e1a1fa4dc6778232f52fb9f86f1402d364b78ac", + "wasm64": "286330aee30dc770c2dabbafc31e7da689d8a776c665e538ea7cbd523bbd5831" } }, "tcl": { "manifestSha256": "b98f237f741b11f5f5da55db75530b421755ba4c8a88d314553106faa1cca1ff", "cacheKeys": { - "wasm32": "ecea22d76a1820ffcd5505b296e17beb353389ba0149fc26c8a5dd9ff17b7dfa", - "wasm64": "81a36b370a61de2315122ec2a152a343a96af99148a6164a838700ac28465b00" + "wasm32": "39eeb88d2870d125a5eb44f93494b760bf4675d03f5b1e3b1a087e301fad359f", + "wasm64": "39227c427426f06c172a8be75c97f34e826c0089d91238511360f9ab2c3eea15" } }, "texlive": { "manifestSha256": "028ada023f8a8f9966c8a28575242a339aaaf0e8870fe4a221986133b381c3c0", "cacheKeys": { - "wasm32": "75b47041b034c633195921d54ef957186261e02e40fc3ca22c48f2a8de91b88e", - "wasm64": "9f366d3724f41f069b7ac801d2431bafe5e78b52ec5b87caad330e65c3aedad3" + "wasm32": "deb61b52478e6081693b6cac1407439534a90ddb5a286f62d78a07e8c887190a", + "wasm64": "fa5ba86cfaef88c0053d8c0d243134a8332357131d0754090e1599d21d5acc21" } }, "unzip": { "manifestSha256": "ef4e5e34258827ab3cbc1930da3fd9514f08f2d7d56c7c3e0d5c495a4d3a20e9", "cacheKeys": { - "wasm32": "c2b4f93663a414b7ff31235fa6e9cade4955e6fcf39a027a3da1bea53f93a375", - "wasm64": "f38e84db74cf56c11d944a6593dea3037808380d7358a20dba857ca47ca4802d" + "wasm32": "2999d9f0cdaa6d26f12a1c5c1051a02d06c13fa170b2361bb6822d16ab276364", + "wasm64": "ce5c801e59d58696b25543e9673d0a4d84224cd4f6e09cb8f2ecbef7bddb7eb5" } }, "userspace": { "manifestSha256": "221176f2a096dee19bbefd89da5c7f50138ecd92d37ec9d7d6b35dbb25e86924", "cacheKeys": { - "wasm32": "c8be6ea7d13eba7dddf88594522a447aa4be183a9e8c115f639465ff95f7f032", - "wasm64": "e342beaa5f5d88e3b578f4dd713d9ef2d4c132f5ce63eb31c10837efc8b4d9eb" + "wasm32": "5816f508452249eecc499cfa6c67d7c683ff4ec389f710575ec2012a3a76c3aa", + "wasm64": "ea2f7c0df48c20691cb6bdefaf55144e98d8d1a5253a97f07d7468568a381f0a" } }, "vim": { "manifestSha256": "c211660ef41d01f95f3fbdf675b74891e897e3f304e04f1bf5586f326007382c", "cacheKeys": { - "wasm32": "7312cc6859af08e6f5d3676cc2edc6476bd198a76ceb81ba5cb19ac01cdb8026", - "wasm64": "123c03848b099f46455792fc38163683d7f36c126d24f86571f42da4d4fc8d12" + "wasm32": "5c29e15a62d267a4402222e629c8f5f20fabcd6ae11c27348523ca05c20a81ba", + "wasm64": "79afa8ec7d65d299ab27832ef5578e7860597c6fa4cbb9d7e7970aa567217701" } }, "vim-browser-bundle": { "manifestSha256": "e31d84057db49c3534381604d0c8f3c7d765e33ede560a7ea7b01a5a8f1aa0d3", "cacheKeys": { - "wasm32": "6659da0384bbb9555a57c0036bded1f84e335e73993d80afe10645002fb6d47a", - "wasm64": "fff7c3b3d2197334172970cdd437f356b48db15fedbb9159c3fbfec95455886b" + "wasm32": "c7031a9a4a8b27ee47adec035ed9db2149b8190ace6535fe782fc783b506c242", + "wasm64": "8bad00d451eb090356161f993b8cd521bb347bbe1f34e60e21e6a7260af1fe63" } }, "wget": { "manifestSha256": "61420b6cb1be12471f425b0aebf5aec58c49d0b43d939dd54508b305accbf54e", "cacheKeys": { - "wasm32": "ae40de1e39e8cfac1fa45208a3c703f287b45aec4c779bdf4b326272675e3d2d", - "wasm64": "fbb4e62ed79ac4abae7c4727b6efabd498451b38b23a1537635b2c6baa3a557a" + "wasm32": "e54400b07b4e30c13c9a4d65b09c86681e42c9bb7dedf7c63df5962f791c1011", + "wasm64": "5cf2e7c0373b368568ee2bf1dd9a0cf796ec6bd9c8823e8453b0bb76e797a5ec" } }, "wordpress": { "manifestSha256": "36465e0596a06e855a8524eecfd87da98643bc13b37ee12939f5aab44bf33418", "cacheKeys": { - "wasm32": "3aa00b98f514b8dcb37d7f0fc573be579181e2974f0f14d8ce7b4858dfa34ffe", - "wasm64": "5d614572fb99c1c4e468ff3ae807701cc16363eb4ecabebd50923af96c3cbeb2" + "wasm32": "53d9e1c6dec85a414423ada333f775228f53368f15c62ec32514f3514d33cbf2", + "wasm64": "f8ae3cd9ea1a8a77a1a578e19964f270189cf69d5f87b8e3e3ed8c25e1dd014a" } }, "xz": { "manifestSha256": "8707d35b8647b1af8fa165dcb6ce7b2a6a007e19ab2daca2680c39395864a737", "cacheKeys": { - "wasm32": "8fc65e59b93272069a522a4cf231aa0ade69f6eafe5a28b1000930f7fa0a1c33", - "wasm64": "30d4bc446fdad29c373bf49dc62406d47994d12dff8c1f54da1a6aa31748db68" + "wasm32": "27fb8caf7f527257a7ea22fadca00656a4e882b69a717449f862c09d008e3253", + "wasm64": "3b7268bbca08f76cc49f0378505b9c8c049296c346ce1deeb1b9ba1a77c8d11e" } }, "zip": { "manifestSha256": "9c9cfa8220aaeaea26736b55f7cd0a7517b8853c07f9f0d241ad67dd43592e36", "cacheKeys": { - "wasm32": "bb1192b88018259570833ea86709ab549c82a2417ba284f6749a08e194c4d95d", - "wasm64": "321f40bd6d677e55cf556bc6ef3c9d68cf0c50d3cc1b4da211c31d0196053f55" + "wasm32": "e4759f4fd4530df51be9219fecff09bf45bd273b974673ace48ca93e7b0a258e", + "wasm64": "f49f432b7c6ee80f2bb7bc458985dca5dd0f5ba53dda5823e80d4ddb02877695" } }, "zlib": { "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", "cacheKeys": { - "wasm32": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc", - "wasm64": "4e2a72e052a8280b6445cb73cd1db388237be137c41c06f89ae9f442d8ad6d3f" + "wasm32": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037", + "wasm64": "10047c02b14e17debfbf751f20765bcd477c6f99766a3c0bdd7cec75b20486d2" } }, "zstd": { "manifestSha256": "89f266938c2c253700a838e6352c9de66c976c84883325aaa979f72b732357e4", "cacheKeys": { - "wasm32": "2f8312b61940bc336be5489d90f21fdf59dc314a956e1fdf903017159312e7cd", - "wasm64": "5f420fdcabee2d8c05f7277f96af0f258e2850f2c7574ac07af019c23abbea93" + "wasm32": "b2d9b6d6c02ae09990083aac08b2b774fc6885ce54edf88420cf633d816aac97", + "wasm64": "87f48799e0c7f8e59bbb4d2b0408ab5cc7993ffccc1cf676fe0eddcdc4dba11f" } } }, @@ -555,14 +555,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f45b7eb0ff367bac6331bedd038598b0708ca5f3400ffdc97064fb73d23982f6" + "wasm32": "b23ed586d38fc7aaf6a4da4acdb9663f99b18431ea9268737524d312e69ed18c" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "9c9b20d1316df92a12cc0e0a5fb327b10278220cecd94a2adc3ed8ac9a29ecd5" + "cacheKey": "21b315a238800c2b0aba33b9e312d672133518d9752fc5627c67c5852cac3095" } ] }, @@ -582,7 +582,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b9f425f193548cb0585c55bebdab2e7f7e22c4bc6aa5a407682874ecb6448795" + "wasm32": "fcb2a8e63865ec30a3ce397d368715ff51be01a2dd3268897a71be324bb7fed6" }, "dependencyClosures": { "wasm32": [] @@ -603,7 +603,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "15d8ef2530cf8a5cdf384890dfca7a328b66b7041ee31c6cc52e66050a6389a2" + "wasm32": "d76042b18566555fcdd155cd6f93cd4f800464b03701033678433b7a01aaf28b" }, "dependencyClosures": { "wasm32": [] @@ -624,7 +624,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "025c1324e522da0331d6145a0adcc5205c70368fe1e35cb15cc25bb13e8616c3" + "wasm32": "5e780a3a00fdfeb141fd21e895b4cc9005970a0ca7f87d422f1acb072ae7fc4e" }, "dependencyClosures": { "wasm32": [] @@ -645,14 +645,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "4bd6e5948f8b59105b5159709dfbc7b1e6b1851acc4b34ceb4c23c51fab643c1" + "wasm32": "08c8e564afadabf660259f5030bd9711356a4245c317315853bb6b0a6d904b55" }, "dependencyClosures": { "wasm32": [ { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc" + "cacheKey": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037" } ] }, @@ -679,19 +679,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "60fdd5816eeda424dc84875008821348c87f45ce44af03755514eaf184fae225" + "wasm32": "f79be2fb854ff7855444354b36736b904f7c34279c098f14af6a779f1b5499ac" }, "dependencyClosures": { "wasm32": [ { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "7b7c878fcf8eadc0689030d9a8f4e12a15e34933c2aef7192136be4d51e42db2" + "cacheKey": "438683df9d94eddd952daf0cb9928db0dd4910fdd7ff73a6ed95949854dc9d75" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc" + "cacheKey": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037" } ] }, @@ -711,7 +711,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f0373b19c4e22a4359403372bdc43ed56f35cebbc939c3e34b2c34560ab466c8" + "wasm32": "4af6e234e2447445d4f18882a2d7bb8d9d4fbd67ee258ef88d661140749109b3" }, "dependencyClosures": { "wasm32": [] @@ -732,7 +732,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c5390d8a92f04562466236d79a225c7c611f075ed02d1d21fb5b080fb7f01a0f" + "wasm32": "6b75f917e8dad9908406972a72f06ed67fe9f31813bedc70b9bf0bc078b0244e" }, "dependencyClosures": { "wasm32": [] @@ -774,14 +774,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "3f86ef60f36863f0437258c43d10333a65c9412c8899688242fb16207361fa6b" + "wasm32": "b81a453c2dcd0f838af57a6d3c3f30afddec3605aab599cbba531f9b74e0d8ff" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" + "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" } ] }, @@ -815,7 +815,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "7240df3edd54fd8ca538f915c4a49eb66fb06df6eb9bda51678c5c30fe97f0a4" + "wasm32": "452d0aadb894214945f5559c44cd1c1effdb3847a5ccb693150b61a7a60f9ff3" }, "dependencyClosures": { "wasm32": [] @@ -843,14 +843,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "948a07db1bd03c14d9ea6b13f0efa01e5decb92cade7750ecd319107008ef80d" + "wasm32": "44a626ae5a57adcf86657d51a557c4782add19c3f516da1ebbaa363bc9ba03e1" }, "dependencyClosures": { "wasm32": [ { "packageName": "erlang", "manifestSha256": "475d6037e73a0f1e2f4381067194b2422751206979ea17209e10efa5a2a93bbb", - "cacheKey": "7240df3edd54fd8ca538f915c4a49eb66fb06df6eb9bda51678c5c30fe97f0a4" + "cacheKey": "452d0aadb894214945f5559c44cd1c1effdb3847a5ccb693150b61a7a60f9ff3" } ] }, @@ -870,7 +870,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0b567960b1b6b829bdd14304dafab6a508e23dfbc114a999892e1c411f2c63a6" + "wasm32": "5442a5d8bc65c0436f25c86329dd6ba6c51b96482ebcb1b0cab2d4a69ac1e4ea" }, "dependencyClosures": { "wasm32": [] @@ -891,7 +891,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "8112d62ebb8c99a5a1714799f366be25fecf6b4c81f1e4e07aacd57c91535e47" + "wasm32": "15c796674ad2487d06c372fc6ed24a78c170ea1d96f28fc725352c69b4ec8300" }, "dependencyClosures": { "wasm32": [] @@ -919,7 +919,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "8e97787e868fd0e18a9855b9f82bb410cb2e358d1f585cfc1e87cf21985737ee" + "wasm32": "2ae9e1bdc0a5b6d2b9a3f1c943768d319f8553264702a12f66f9ba8029453f67" }, "dependencyClosures": { "wasm32": [] @@ -947,7 +947,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c388eef100553698e390f5a207c418afff2a075547ec5f3536460748b7df55e6" + "wasm32": "a4ef112a7a00e3fa65b15143127f114794a28cf65cccb1c4bddad97b31b20800" }, "dependencyClosures": { "wasm32": [] @@ -968,7 +968,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "3c1a3fe79b8f32a26d67e407f10c9c34a60035179024bc4b4d6064e52f6948a3" + "wasm32": "2d4e9f21749d4a27b8de3de736f8491e8d078ebb0e3509009d53dab44ca27d9d" }, "dependencyClosures": { "wasm32": [] @@ -996,7 +996,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "3e0a00c54f9d2086dc1efbd06bcf54b6bc5d35919d58dc4267bdfd32cfb838a2" + "wasm32": "25e15778d49302390f9882880818560d7d48c5bc60d78dd520ac3f7625c394f5" }, "dependencyClosures": { "wasm32": [] @@ -1017,7 +1017,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "96085853b6c4c622d3b60acbdc0c982b1c51b079b79e942bdc02aa684c4321a2" + "wasm32": "83b7c2e0896cb8f2838a21d07b55113e47620a5c1d5cb95fab734c816a7f07fd" }, "dependencyClosures": { "wasm32": [] @@ -1038,7 +1038,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "d3865a95335c0f22ea155825dd8d1b1f680bf90ca00d4024b59c4e0928459912" + "wasm32": "b7181df86393240dd66b4176c0f038a2f3d8a340756542bbe55c1040848c2ed8" }, "dependencyClosures": { "wasm32": [] @@ -1066,14 +1066,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2d67aa6a7aaca3edfb622a3888ffe8b3282ba212e12e814116550357245e2748" + "wasm32": "f4a952253f40457f8a8b855af6980e9deece7a6f3f2110169dd85127e7812953" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" + "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" } ] }, @@ -1093,64 +1093,64 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c156d707a51eef48fdc4764ec7a07fbe35a70ec0ebc172284582ad65b924ccf6" + "wasm32": "7895c6e87025c059b131a332b12763603ec10a945f9aa5b43d16d6ed53e21b60" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "3f86ef60f36863f0437258c43d10333a65c9412c8899688242fb16207361fa6b" + "cacheKey": "b81a453c2dcd0f838af57a6d3c3f30afddec3605aab599cbba531f9b74e0d8ff" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "d129add0d6c230b17232aef83127a269c5c1c11349d9331d06fcc6889b56f5d0" + "cacheKey": "5b87a5e6802b617851d5fa4efb16b3762eac06f0f683e2c3bb5887225cb7262f" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "2c986bb0488a9ff05b1ef2ec3c0c44be940504ff9b7f33a4e235f981dc016e17" + "cacheKey": "086705244733c6e7d585c44fc921eba478d0508902d91c2a83246a7d4952874f" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" + "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "23113207cb8ec3b213b054284770c9ad92d738b225044d01563f1bc3a677622c" + "cacheKey": "d0e75465fa21c6a4090bc2cc92653dae900f85aa79cae5ca9bf67ee0578b7d1c" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "70356b9298ebe5b6d58e7d08093d71b1137c2ff99617beb3d8134f3bc919b088" + "cacheKey": "0daf9f05a267210322bf2a64a77cb05b1b077fc12b340a531e0a900abd0add92" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "4aab2056a613043f30ebf493e466227b595740de70ba210e80919a6d3c1069f5" + "cacheKey": "9fa4575b5833bbf94a6ff9e7931519536dc85ffb2a42f9d019e3e7331a7b50c9" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "141ed56befae2c701961c53a06f1d3dcd1949ce5012e6c62209c45092b13e3ff" + "cacheKey": "3fa3d08a22b4a8473779430950bbf027b4673c96b36f937bcac7faf8105badc9" }, { "packageName": "msmtpd", "manifestSha256": "09de04a422ddf631a29a259d816e081f8d317d1077808ede55d8c500baae748f", - "cacheKey": "ea09f8a7c8a43ddeed1fef59c3a9a7b987e0bda7be881d4d1ae4019d73a7ef30" + "cacheKey": "29b34d2aa3442f7d4a8b2126936735a3606af82831c45e13c36e79930d147a88" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "cd58a900319c46bbc268ac664900527414e9c6ffa40683b3be6bcd97d4d47283" + "cacheKey": "d09b3ed121683c8dec908fdcb49e5b852e5da476b06bcaf58158fa7fbd734eb0" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "7b7c878fcf8eadc0689030d9a8f4e12a15e34933c2aef7192136be4d51e42db2" + "cacheKey": "438683df9d94eddd952daf0cb9928db0dd4910fdd7ff73a6ed95949854dc9d75" }, { "packageName": "pcre2-source", @@ -1160,22 +1160,22 @@ { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "cba27c5df3f7ce7a296d1e672d5ce6671052bcb19e2893b73f926cf7e498d6ae" + "cacheKey": "0b2a2819b64601e21e9c246dc987cda1592e3448550f3df8bb2f7607a136c082" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "735c2c0ecb8edde3797fb59fd6e38f5f8c86a81fced93e3f24e87db5aafcc02b" + "cacheKey": "c8b616ca96ecef41bac1f4d949b483da723edab4976fd2b5b9d47aa11a734613" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "63f8f0bc003c78665088d6ebdfbd495325454537fba1e6a9df9c1c8a9857cf1b" + "cacheKey": "2ce2ccd99e2994765e2f2b4b7823a582b9997999a1654d378c31bac8f5e8aa89" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc" + "cacheKey": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037" } ] }, @@ -1195,7 +1195,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "7b4ae641cb4f9310e48636f98f955cb3f79557159bdb1e80b3fe6badfaf5689a" + "wasm32": "ebfbcb7880115cc263c2c44c2d82f7808c71fd564f60965d4c25ef4533f831ee" }, "dependencyClosures": { "wasm32": [] @@ -1216,7 +1216,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "5a380c75d0d25b820bc140e5b9e4d01ffa4439679b5b5b8fe0dfe9ca8e67b00a" + "wasm32": "c934ea1907968b2dd08601165cd9c75533e2d97d480f8f0587f9faf7dd1fc10a" }, "dependencyClosures": { "wasm32": [] @@ -1237,7 +1237,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "13c48d5828987e67dc653829de378a172f8a486e1427c888168526e2536747ce" + "wasm32": "c700b1d1cf477b97829dfdc7daacaedb4087db777764eadea49b13c2cb7669d0" }, "dependencyClosures": { "wasm32": [] @@ -1258,7 +1258,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b326ee0fce5b1d3f5ed8c0d865a6f351d162b3848bec1084baef1de028418a82" + "wasm32": "f217b621932606018491738a5e93e7d34f2cc48bafd9b807d80454b203235051" }, "dependencyClosures": { "wasm32": [] @@ -1280,15 +1280,15 @@ "wasm64" ], "cacheKeys": { - "wasm32": "141ed56befae2c701961c53a06f1d3dcd1949ce5012e6c62209c45092b13e3ff", - "wasm64": "ee47227abc6a8059e1e4501515ac18656cca0501e087018708fd15c9f38aebab" + "wasm32": "3fa3d08a22b4a8473779430950bbf027b4673c96b36f937bcac7faf8105badc9", + "wasm64": "adbe9a05a3d242dad683a79e017662de9775e1be312fd01a1f18d32b93561e47" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" + "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" }, { "packageName": "pcre2-source", @@ -1300,7 +1300,7 @@ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "a8535610a175d2c3c443596be03f123f2c7a007a26bba163bd09d4fba10d8b42" + "cacheKey": "1a456dd94e95867f80ecb9457234d7b758ba5454160a8be1a7cc55ced3739908" }, { "packageName": "pcre2-source", @@ -1332,34 +1332,34 @@ "wasm32" ], "cacheKeys": { - "wasm32": "784f34c97f112d3348013e6a7450975c631cd7c5de12907c924ca4167f074e37" + "wasm32": "50feef6cbd0b15dc2594d22d0eaa40dc349beb55e76900ec2c2ac8324a0ca6c5" }, "dependencyClosures": { "wasm32": [ { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "025c1324e522da0331d6145a0adcc5205c70368fe1e35cb15cc25bb13e8616c3" + "cacheKey": "5e780a3a00fdfeb141fd21e895b4cc9005970a0ca7f87d422f1acb072ae7fc4e" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "f0373b19c4e22a4359403372bdc43ed56f35cebbc939c3e34b2c34560ab466c8" + "cacheKey": "4af6e234e2447445d4f18882a2d7bb8d9d4fbd67ee258ef88d661140749109b3" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "3f86ef60f36863f0437258c43d10333a65c9412c8899688242fb16207361fa6b" + "cacheKey": "b81a453c2dcd0f838af57a6d3c3f30afddec3605aab599cbba531f9b74e0d8ff" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" + "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "141ed56befae2c701961c53a06f1d3dcd1949ce5012e6c62209c45092b13e3ff" + "cacheKey": "3fa3d08a22b4a8473779430950bbf027b4673c96b36f937bcac7faf8105badc9" }, { "packageName": "pcre2-source", @@ -1385,35 +1385,35 @@ "wasm64" ], "cacheKeys": { - "wasm32": "4a858c3f8add0dc39eb56a08b7041ccd84e47fd6fb14a526efdd87539a77468b", - "wasm64": "6a3b1cf6fa442cf7868b8f4beddce967f623f19fe83a7e483f8532523b8472ab" + "wasm32": "98e31fd13a253ab211aeaa1c0ca91d255365d888831a9761273d851c65498543", + "wasm64": "238e701a44e8e8c8cd66ef9cb453e310cfa12d4918cd40983977d81fce716c8b" }, "dependencyClosures": { "wasm32": [ { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "025c1324e522da0331d6145a0adcc5205c70368fe1e35cb15cc25bb13e8616c3" + "cacheKey": "5e780a3a00fdfeb141fd21e895b4cc9005970a0ca7f87d422f1acb072ae7fc4e" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "f0373b19c4e22a4359403372bdc43ed56f35cebbc939c3e34b2c34560ab466c8" + "cacheKey": "4af6e234e2447445d4f18882a2d7bb8d9d4fbd67ee258ef88d661140749109b3" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "3f86ef60f36863f0437258c43d10333a65c9412c8899688242fb16207361fa6b" + "cacheKey": "b81a453c2dcd0f838af57a6d3c3f30afddec3605aab599cbba531f9b74e0d8ff" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" + "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "141ed56befae2c701961c53a06f1d3dcd1949ce5012e6c62209c45092b13e3ff" + "cacheKey": "3fa3d08a22b4a8473779430950bbf027b4673c96b36f937bcac7faf8105badc9" }, { "packageName": "pcre2-source", @@ -1425,27 +1425,27 @@ { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "258bfcc358a310c5b86a4b48d05163840083ff2a38713118be92208e932522d8" + "cacheKey": "c898f50e143b676de8181c94482f29f4afe421585d0f1db05770fd12f55f54eb" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "4050007a409933f2b5988c77ac7e8ff1b1a06be2a3d5a6b5b627539e790c1ec1" + "cacheKey": "2dd53c014af59fc455b6a86abbd034e7cbce505b1d3a8cc55877691446c46c5b" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "d390cee24c29c28de3914085cb36459d4265e4f3a538ddf08096d460ec442ac5" + "cacheKey": "fe199c277d863f294d5a0723581e93c41dd20e0569b24dbbc329a3517acfaeb2" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "a8535610a175d2c3c443596be03f123f2c7a007a26bba163bd09d4fba10d8b42" + "cacheKey": "1a456dd94e95867f80ecb9457234d7b758ba5454160a8be1a7cc55ced3739908" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "ee47227abc6a8059e1e4501515ac18656cca0501e087018708fd15c9f38aebab" + "cacheKey": "adbe9a05a3d242dad683a79e017662de9775e1be312fd01a1f18d32b93561e47" }, { "packageName": "pcre2-source", @@ -1470,7 +1470,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "d1c2c44f0bea233667911c79cc5a5ef8fc9b41210b2913526cec1888647b71ec" + "wasm32": "b9892a9a50b3119cb27c5621d0c41c7c7384eed6ea3213dc09cb19d66330422c" }, "dependencyClosures": { "wasm32": [] @@ -1491,7 +1491,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ea09f8a7c8a43ddeed1fef59c3a9a7b987e0bda7be881d4d1ae4019d73a7ef30" + "wasm32": "29b34d2aa3442f7d4a8b2126936735a3606af82831c45e13c36e79930d147a88" }, "dependencyClosures": { "wasm32": [] @@ -1512,7 +1512,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f4b8af0d2e6dfdd05c495ed26d2fdb80bb3987ac1eb58e324e17272ea6060bd9" + "wasm32": "f2e860fe0e0653890e6f20bb984a5b6ffae3aba7e16a52410aa11c5c3e8aa9b1" }, "dependencyClosures": { "wasm32": [] @@ -1533,7 +1533,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "9c9b20d1316df92a12cc0e0a5fb327b10278220cecd94a2adc3ed8ac9a29ecd5" + "wasm32": "21b315a238800c2b0aba33b9e312d672133518d9752fc5627c67c5852cac3095" }, "dependencyClosures": { "wasm32": [] @@ -1617,7 +1617,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "1827799c81052b95dbc22e913e556ffd801c1ee2e3cb4435673f1d342fded3d4" + "wasm32": "4a70153037218964d9e135865889287d38bb59a9364cd53889e2d3eaa795654b" }, "dependencyClosures": { "wasm32": [] @@ -1638,14 +1638,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "be86dd34e94fcc32fd7c067f81df5685df966e3a8e957292b2ab09d8f6bd159d" + "wasm32": "baa5470b14814fe2d07abcdfb041336fb215045159d8ac42a1e7410166f52a44" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "9c9b20d1316df92a12cc0e0a5fb327b10278220cecd94a2adc3ed8ac9a29ecd5" + "cacheKey": "21b315a238800c2b0aba33b9e312d672133518d9752fc5627c67c5852cac3095" } ] }, @@ -1665,19 +1665,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2f2221697fcd9c2e54dfa6cb38ed9c307dd6e018fc871530a1d307cbd5cfadb7" + "wasm32": "c1a9bc10cf611c917af84f1b7e5a7c22556d3abe6f25e9d9715215490f00ff61" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "9c9b20d1316df92a12cc0e0a5fb327b10278220cecd94a2adc3ed8ac9a29ecd5" + "cacheKey": "21b315a238800c2b0aba33b9e312d672133518d9752fc5627c67c5852cac3095" }, { "packageName": "nethack", "manifestSha256": "1a2f12ec2770bd8d40006d6c878a3493b97c71c2bb63ad08c7f6ad99bfd53504", - "cacheKey": "be86dd34e94fcc32fd7c067f81df5685df966e3a8e957292b2ab09d8f6bd159d" + "cacheKey": "baa5470b14814fe2d07abcdfb041336fb215045159d8ac42a1e7410166f52a44" } ] }, @@ -1697,7 +1697,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "cd58a900319c46bbc268ac664900527414e9c6ffa40683b3be6bcd97d4d47283" + "wasm32": "d09b3ed121683c8dec908fdcb49e5b852e5da476b06bcaf58158fa7fbd734eb0" }, "dependencyClosures": { "wasm32": [] @@ -1718,79 +1718,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "abe26676465686f26ddfe4f83d5763d72521a6a94e0735af17a93b94307d643c" + "wasm32": "03e55aba7d25d8f94becb9254646e49acd77b6e56f39bbaa7acef5934bfc3cf7" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "3f86ef60f36863f0437258c43d10333a65c9412c8899688242fb16207361fa6b" + "cacheKey": "b81a453c2dcd0f838af57a6d3c3f30afddec3605aab599cbba531f9b74e0d8ff" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "d129add0d6c230b17232aef83127a269c5c1c11349d9331d06fcc6889b56f5d0" + "cacheKey": "5b87a5e6802b617851d5fa4efb16b3762eac06f0f683e2c3bb5887225cb7262f" }, { "packageName": "kernel", "manifestSha256": "db1d66db8575562ac7b3e720dbaa06d7dd5eef577d80220ea4167dc2d69b863b", - "cacheKey": "a135dbbd66f558b2f6d41a16db4352b97e58d7e61f3afe9b3eed8accaab4564d" + "cacheKey": "30dfa5f78ede4eaf3259a41e60c2b9ed1ea76a68c4e62b6929fb557c6760e3e8" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "2c986bb0488a9ff05b1ef2ec3c0c44be940504ff9b7f33a4e235f981dc016e17" + "cacheKey": "086705244733c6e7d585c44fc921eba478d0508902d91c2a83246a7d4952874f" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" + "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "23113207cb8ec3b213b054284770c9ad92d738b225044d01563f1bc3a677622c" + "cacheKey": "d0e75465fa21c6a4090bc2cc92653dae900f85aa79cae5ca9bf67ee0578b7d1c" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "70356b9298ebe5b6d58e7d08093d71b1137c2ff99617beb3d8134f3bc919b088" + "cacheKey": "0daf9f05a267210322bf2a64a77cb05b1b077fc12b340a531e0a900abd0add92" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "4aab2056a613043f30ebf493e466227b595740de70ba210e80919a6d3c1069f5" + "cacheKey": "9fa4575b5833bbf94a6ff9e7931519536dc85ffb2a42f9d019e3e7331a7b50c9" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "cd58a900319c46bbc268ac664900527414e9c6ffa40683b3be6bcd97d4d47283" + "cacheKey": "d09b3ed121683c8dec908fdcb49e5b852e5da476b06bcaf58158fa7fbd734eb0" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "7b7c878fcf8eadc0689030d9a8f4e12a15e34933c2aef7192136be4d51e42db2" + "cacheKey": "438683df9d94eddd952daf0cb9928db0dd4910fdd7ff73a6ed95949854dc9d75" }, { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "cba27c5df3f7ce7a296d1e672d5ce6671052bcb19e2893b73f926cf7e498d6ae" + "cacheKey": "0b2a2819b64601e21e9c246dc987cda1592e3448550f3df8bb2f7607a136c082" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "735c2c0ecb8edde3797fb59fd6e38f5f8c86a81fced93e3f24e87db5aafcc02b" + "cacheKey": "c8b616ca96ecef41bac1f4d949b483da723edab4976fd2b5b9d47aa11a734613" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "63f8f0bc003c78665088d6ebdfbd495325454537fba1e6a9df9c1c8a9857cf1b" + "cacheKey": "2ce2ccd99e2994765e2f2b4b7823a582b9997999a1654d378c31bac8f5e8aa89" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc" + "cacheKey": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037" } ] }, @@ -1810,29 +1810,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "505ca1af0a7383a40519de0982e53852d66ad50f164d12e08e815d2d36187985" + "wasm32": "355355d35471171ee51a3ea30cd70ba4a90fe04753174e046030da5c802f84c8" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "3f86ef60f36863f0437258c43d10333a65c9412c8899688242fb16207361fa6b" + "cacheKey": "b81a453c2dcd0f838af57a6d3c3f30afddec3605aab599cbba531f9b74e0d8ff" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" + "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "cd58a900319c46bbc268ac664900527414e9c6ffa40683b3be6bcd97d4d47283" + "cacheKey": "d09b3ed121683c8dec908fdcb49e5b852e5da476b06bcaf58158fa7fbd734eb0" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "735c2c0ecb8edde3797fb59fd6e38f5f8c86a81fced93e3f24e87db5aafcc02b" + "cacheKey": "c8b616ca96ecef41bac1f4d949b483da723edab4976fd2b5b9d47aa11a734613" } ] }, @@ -1852,29 +1852,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "59917f32e95cf3c65e899cf230ed19154321d18280246fe9c0e75e970af77e47" + "wasm32": "6cc8859cf1fc632ff4da532cd90a2379dc72f538c18f1de71e7526b40251d003" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" + "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "7b7c878fcf8eadc0689030d9a8f4e12a15e34933c2aef7192136be4d51e42db2" + "cacheKey": "438683df9d94eddd952daf0cb9928db0dd4910fdd7ff73a6ed95949854dc9d75" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "703273c0bfc90bf5db63384125331fd60b5ea8dd8910ffd49be251503bce2d79" + "cacheKey": "b1264b3565e9f7bc4268cb38ef5de19052b7833bb48e4b2b19d572f5b2af3833" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc" + "cacheKey": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037" } ] }, @@ -1894,39 +1894,39 @@ "wasm32" ], "cacheKeys": { - "wasm32": "cba98a6b26b1f906c3db99c2e9da398f3a8251cd62915b3b0d62e879f4e5ab9f" + "wasm32": "7cf996ad121c1c7bb8e72fee19bc493ed828c12e41361c901cca1e2acf58a4ad" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" + "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" }, { "packageName": "node", "manifestSha256": "2131a24dfda8587860e57fc65b573086477d376b065b3b9ca4e1dfe4d79f5790", - "cacheKey": "59917f32e95cf3c65e899cf230ed19154321d18280246fe9c0e75e970af77e47" + "cacheKey": "6cc8859cf1fc632ff4da532cd90a2379dc72f538c18f1de71e7526b40251d003" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "7b7c878fcf8eadc0689030d9a8f4e12a15e34933c2aef7192136be4d51e42db2" + "cacheKey": "438683df9d94eddd952daf0cb9928db0dd4910fdd7ff73a6ed95949854dc9d75" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "735c2c0ecb8edde3797fb59fd6e38f5f8c86a81fced93e3f24e87db5aafcc02b" + "cacheKey": "c8b616ca96ecef41bac1f4d949b483da723edab4976fd2b5b9d47aa11a734613" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "703273c0bfc90bf5db63384125331fd60b5ea8dd8910ffd49be251503bce2d79" + "cacheKey": "b1264b3565e9f7bc4268cb38ef5de19052b7833bb48e4b2b19d572f5b2af3833" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc" + "cacheKey": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037" } ] }, @@ -1946,7 +1946,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "3f6af76550825313cdc09758c3cf2480b65a1a1c64e0f4a3cee95fff09f467d8" + "wasm32": "0f6a2264707ab7e996490e4622837995d3fb587496b0821a5397c7b65d30eb28" }, "dependencyClosures": { "wasm32": [] @@ -1967,14 +1967,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "1c6a0edb1823313d0cc6538e668a6778ade866347392adb8ffbedb1054628128" + "wasm32": "1ae380c4abae7259272368e5f0e8851436bab4b419af26e1301b63ddd81fa72e" }, "dependencyClosures": { "wasm32": [ { "packageName": "perl", "manifestSha256": "6cdc4dbc54d0e4008cff41f82ec8918c0e44b4aef23903539c1bc0f538fe3ad2", - "cacheKey": "3f6af76550825313cdc09758c3cf2480b65a1a1c64e0f4a3cee95fff09f467d8" + "cacheKey": "0f6a2264707ab7e996490e4622837995d3fb587496b0821a5397c7b65d30eb28" } ] }, @@ -1994,54 +1994,54 @@ "wasm32" ], "cacheKeys": { - "wasm32": "cba27c5df3f7ce7a296d1e672d5ce6671052bcb19e2893b73f926cf7e498d6ae" + "wasm32": "0b2a2819b64601e21e9c246dc987cda1592e3448550f3df8bb2f7607a136c082" }, "dependencyClosures": { "wasm32": [ { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "d129add0d6c230b17232aef83127a269c5c1c11349d9331d06fcc6889b56f5d0" + "cacheKey": "5b87a5e6802b617851d5fa4efb16b3762eac06f0f683e2c3bb5887225cb7262f" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "2c986bb0488a9ff05b1ef2ec3c0c44be940504ff9b7f33a4e235f981dc016e17" + "cacheKey": "086705244733c6e7d585c44fc921eba478d0508902d91c2a83246a7d4952874f" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" + "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "23113207cb8ec3b213b054284770c9ad92d738b225044d01563f1bc3a677622c" + "cacheKey": "d0e75465fa21c6a4090bc2cc92653dae900f85aa79cae5ca9bf67ee0578b7d1c" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "70356b9298ebe5b6d58e7d08093d71b1137c2ff99617beb3d8134f3bc919b088" + "cacheKey": "0daf9f05a267210322bf2a64a77cb05b1b077fc12b340a531e0a900abd0add92" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "4aab2056a613043f30ebf493e466227b595740de70ba210e80919a6d3c1069f5" + "cacheKey": "9fa4575b5833bbf94a6ff9e7931519536dc85ffb2a42f9d019e3e7331a7b50c9" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "7b7c878fcf8eadc0689030d9a8f4e12a15e34933c2aef7192136be4d51e42db2" + "cacheKey": "438683df9d94eddd952daf0cb9928db0dd4910fdd7ff73a6ed95949854dc9d75" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "63f8f0bc003c78665088d6ebdfbd495325454537fba1e6a9df9c1c8a9857cf1b" + "cacheKey": "2ce2ccd99e2994765e2f2b4b7823a582b9997999a1654d378c31bac8f5e8aa89" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc" + "cacheKey": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037" } ] }, @@ -2117,7 +2117,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "971093bac83c0c75229da3992210c599002943dd6b21d1e98ac332a22379b221" + "wasm32": "3f3b688a40fc18b4c494053635c53aeff2a90f21592ed7737b54651605a4bcf4" }, "dependencyClosures": { "wasm32": [] @@ -2390,19 +2390,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "413df2ad2183a8750dd0792d526fdc8644f2d26a8a44ae47e950dc41a0fcd86c" + "wasm32": "359fa720ef703a8dbb3f84405cfb0aad8e5dafe6e87698b199f329bc6a11948f" }, "dependencyClosures": { "wasm32": [ { "packageName": "cpython", "manifestSha256": "7dd4f446697a73941ec940c2ccba4d53be73fe6947f1e701031a4d0aa964c4ff", - "cacheKey": "4bd6e5948f8b59105b5159709dfbc7b1e6b1851acc4b34ceb4c23c51fab643c1" + "cacheKey": "08c8e564afadabf660259f5030bd9711356a4245c317315853bb6b0a6d904b55" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc" + "cacheKey": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037" } ] }, @@ -2422,7 +2422,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0b6e849447c2d14076954fcd4eefd2794d5dd31b220c23a6aa8dc042292e0496" + "wasm32": "65c6ae8b1759de4e7cf5e9238b543a6f72e46ddbb056d568655659d62e744760" }, "dependencyClosures": { "wasm32": [] @@ -2450,24 +2450,24 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ac89c13d91d1f3fd452d8fb3e1da15b1d2d1dc6402b776cb9b87dcc1c2e44ebd" + "wasm32": "e07bc2e3d9383e24d752efcd793c777913c30ff79af8d8f308d81f45799dbf25" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "3f86ef60f36863f0437258c43d10333a65c9412c8899688242fb16207361fa6b" + "cacheKey": "b81a453c2dcd0f838af57a6d3c3f30afddec3605aab599cbba531f9b74e0d8ff" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" + "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" }, { "packageName": "redis", "manifestSha256": "151fb507de953ba2ba94b8f881bc7d66b4c741c1359a2f590cc07f9a4cd368a3", - "cacheKey": "0b6e849447c2d14076954fcd4eefd2794d5dd31b220c23a6aa8dc042292e0496" + "cacheKey": "65c6ae8b1759de4e7cf5e9238b543a6f72e46ddbb056d568655659d62e744760" } ] }, @@ -2487,79 +2487,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "fc7c012003326b1ba75eb8acc08b62b38d000a8d1f03e179a6103430b8cc3c58" + "wasm32": "c123756aae930c768a6d4aa763afc7e76fd37def700aefd071fac72a908291ec" }, "dependencyClosures": { "wasm32": [ { "packageName": "bash", "manifestSha256": "6478060f28d430d18a6ebe7c603392d35a41d9bcf6bc74322351d1450b9c5335", - "cacheKey": "f45b7eb0ff367bac6331bedd038598b0708ca5f3400ffdc97064fb73d23982f6" + "cacheKey": "b23ed586d38fc7aaf6a4da4acdb9663f99b18431ea9268737524d312e69ed18c" }, { "packageName": "bc", "manifestSha256": "a65661463bb7047b91ff00153bd99fd962c96e571934ab4e923f5215fb6ecfbd", - "cacheKey": "b9f425f193548cb0585c55bebdab2e7f7e22c4bc6aa5a407682874ecb6448795" + "cacheKey": "fcb2a8e63865ec30a3ce397d368715ff51be01a2dd3268897a71be324bb7fed6" }, { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "025c1324e522da0331d6145a0adcc5205c70368fe1e35cb15cc25bb13e8616c3" + "cacheKey": "5e780a3a00fdfeb141fd21e895b4cc9005970a0ca7f87d422f1acb072ae7fc4e" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "f0373b19c4e22a4359403372bdc43ed56f35cebbc939c3e34b2c34560ab466c8" + "cacheKey": "4af6e234e2447445d4f18882a2d7bb8d9d4fbd67ee258ef88d661140749109b3" }, { "packageName": "diffutils", "manifestSha256": "3a78f0a46bae43ce5ea235c6638c2cbd1d8559b0b62b1fb42443b35968c1aca3", - "cacheKey": "c5390d8a92f04562466236d79a225c7c611f075ed02d1d21fb5b080fb7f01a0f" + "cacheKey": "6b75f917e8dad9908406972a72f06ed67fe9f31813bedc70b9bf0bc078b0244e" }, { "packageName": "file", "manifestSha256": "7874c1affcbbf2c8ab8c8d4beb087f275af57b5caae6a8947bf5a63a181552b2", - "cacheKey": "8112d62ebb8c99a5a1714799f366be25fecf6b4c81f1e4e07aacd57c91535e47" + "cacheKey": "15c796674ad2487d06c372fc6ed24a78c170ea1d96f28fc725352c69b4ec8300" }, { "packageName": "findutils", "manifestSha256": "cceedf52aea67fbb0da03cfce6f2e9b1a7b5561fa1656c2762b43c651a625d02", - "cacheKey": "8e97787e868fd0e18a9855b9f82bb410cb2e358d1f585cfc1e87cf21985737ee" + "cacheKey": "2ae9e1bdc0a5b6d2b9a3f1c943768d319f8553264702a12f66f9ba8029453f67" }, { "packageName": "gawk", "manifestSha256": "a2567b8b0778805e385e1d3a41ac8b5d74aaf9d293b222001b17d09b11e5a0ba", - "cacheKey": "c388eef100553698e390f5a207c418afff2a075547ec5f3536460748b7df55e6" + "cacheKey": "a4ef112a7a00e3fa65b15143127f114794a28cf65cccb1c4bddad97b31b20800" }, { "packageName": "grep", "manifestSha256": "59270d3bfb33167b32d13246bf855b49308f8c9c0a66d9635c804f702421fbc6", - "cacheKey": "3e0a00c54f9d2086dc1efbd06bcf54b6bc5d35919d58dc4267bdfd32cfb838a2" + "cacheKey": "25e15778d49302390f9882880818560d7d48c5bc60d78dd520ac3f7625c394f5" }, { "packageName": "m4", "manifestSha256": "2c6582d99d6eabfb9da52badf49b899e9fdcba76a728536b25bc3741f3331543", - "cacheKey": "13c48d5828987e67dc653829de378a172f8a486e1427c888168526e2536747ce" + "cacheKey": "c700b1d1cf477b97829dfdc7daacaedb4087db777764eadea49b13c2cb7669d0" }, { "packageName": "make", "manifestSha256": "f878d2d730f36a4c6dfe1fff1b4ccc704757ea22d8c4c8ccb95b11cd641e5fed", - "cacheKey": "b326ee0fce5b1d3f5ed8c0d865a6f351d162b3848bec1084baef1de028418a82" + "cacheKey": "f217b621932606018491738a5e93e7d34f2cc48bafd9b807d80454b203235051" }, { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "9c9b20d1316df92a12cc0e0a5fb327b10278220cecd94a2adc3ed8ac9a29ecd5" + "cacheKey": "21b315a238800c2b0aba33b9e312d672133518d9752fc5627c67c5852cac3095" }, { "packageName": "posix-utils-lite", "manifestSha256": "8fd7190b2848ef80143adc7b9268c79e13cd4db3430f7105faf49a4b4407a06f", - "cacheKey": "971093bac83c0c75229da3992210c599002943dd6b21d1e98ac332a22379b221" + "cacheKey": "3f3b688a40fc18b4c494053635c53aeff2a90f21592ed7737b54651605a4bcf4" }, { "packageName": "sed", "manifestSha256": "2a08e9c5dacc5facc8983c1ff35ff2a72db328405e9a1f464cc7ad155c4d08af", - "cacheKey": "23b9bbdb70b972dd95e51ec76d4e96b102a872e4424ec192d38fe1153279bbec" + "cacheKey": "8772fc5aeaa8aac3ecd57cfdcbfe803db7c62f424e738d789efe438f1b3fcc3f" } ] }, @@ -2579,14 +2579,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "3e8b426cf871a3ba7e040d2ca161466440a4f2b4ad961cf8d35ac62e974302b6" + "wasm32": "f029a5c249b4651b80348d6362174acb848a475957e988cf8df057c4a344101b" }, "dependencyClosures": { "wasm32": [ { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc" + "cacheKey": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037" } ] }, @@ -2613,7 +2613,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "23b9bbdb70b972dd95e51ec76d4e96b102a872e4424ec192d38fe1153279bbec" + "wasm32": "8772fc5aeaa8aac3ecd57cfdcbfe803db7c62f424e738d789efe438f1b3fcc3f" }, "dependencyClosures": { "wasm32": [] @@ -2634,7 +2634,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "735c2c0ecb8edde3797fb59fd6e38f5f8c86a81fced93e3f24e87db5aafcc02b" + "wasm32": "c8b616ca96ecef41bac1f4d949b483da723edab4976fd2b5b9d47aa11a734613" }, "dependencyClosures": { "wasm32": [] @@ -2655,24 +2655,24 @@ "wasm32" ], "cacheKeys": { - "wasm32": "703273c0bfc90bf5db63384125331fd60b5ea8dd8910ffd49be251503bce2d79" + "wasm32": "b1264b3565e9f7bc4268cb38ef5de19052b7833bb48e4b2b19d572f5b2af3833" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" + "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "7b7c878fcf8eadc0689030d9a8f4e12a15e34933c2aef7192136be4d51e42db2" + "cacheKey": "438683df9d94eddd952daf0cb9928db0dd4910fdd7ff73a6ed95949854dc9d75" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc" + "cacheKey": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037" } ] }, @@ -2692,29 +2692,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "68906120c4f5bd6aa5212078f54eb64c038148c34ccdad65eae0b8c97906d397" + "wasm32": "7120b26b20fe4b60b75021299fd08f5063ca380167decd2d96592c0298354bf4" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" + "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "7b7c878fcf8eadc0689030d9a8f4e12a15e34933c2aef7192136be4d51e42db2" + "cacheKey": "438683df9d94eddd952daf0cb9928db0dd4910fdd7ff73a6ed95949854dc9d75" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "703273c0bfc90bf5db63384125331fd60b5ea8dd8910ffd49be251503bce2d79" + "cacheKey": "b1264b3565e9f7bc4268cb38ef5de19052b7833bb48e4b2b19d572f5b2af3833" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc" + "cacheKey": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037" } ] }, @@ -2734,7 +2734,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2496e0db5e6a54dffbc592517d1e185c5ea13b2f6642ee5dda39837d6af5c750" + "wasm32": "5f083d2358cbfcdc112e69f5c3929cbaea4dda0c385c27b12470986ef647db4a" }, "dependencyClosures": { "wasm32": [] @@ -2755,7 +2755,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ce558d30e4aa291b0b83c51e095925c2e3a680b6a2bb3064bd681b616dd92271" + "wasm32": "33c149eb0dc0bc85afadba8e2e1a1fa4dc6778232f52fb9f86f1402d364b78ac" }, "dependencyClosures": { "wasm32": [] @@ -2776,7 +2776,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ecea22d76a1820ffcd5505b296e17beb353389ba0149fc26c8a5dd9ff17b7dfa" + "wasm32": "39eeb88d2870d125a5eb44f93494b760bf4675d03f5b1e3b1a087e301fad359f" }, "dependencyClosures": { "wasm32": [] @@ -2797,19 +2797,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "75b47041b034c633195921d54ef957186261e02e40fc3ca22c48f2a8de91b88e" + "wasm32": "deb61b52478e6081693b6cac1407439534a90ddb5a286f62d78a07e8c887190a" }, "dependencyClosures": { "wasm32": [ { "packageName": "libpng", "manifestSha256": "79ed5c5c072a0267cce5c7a2fe37d8494571b17e44b952f619e04ec1c4a9db10", - "cacheKey": "6415b2ef14cf038d99c24521c5f7272ce81e929cff5e94ffeb3031b5c9140195" + "cacheKey": "d18ea038aeb10c81c3a24ca550333669722713cd6c6dd828f5b0c463d91f7ecd" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc" + "cacheKey": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037" } ] }, @@ -2836,7 +2836,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c2b4f93663a414b7ff31235fa6e9cade4955e6fcf39a027a3da1bea53f93a375" + "wasm32": "2999d9f0cdaa6d26f12a1c5c1051a02d06c13fa170b2361bb6822d16ab276364" }, "dependencyClosures": { "wasm32": [] @@ -2857,7 +2857,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "7312cc6859af08e6f5d3676cc2edc6476bd198a76ceb81ba5cb19ac01cdb8026" + "wasm32": "5c29e15a62d267a4402222e629c8f5f20fabcd6ae11c27348523ca05c20a81ba" }, "dependencyClosures": { "wasm32": [] @@ -2878,14 +2878,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "6659da0384bbb9555a57c0036bded1f84e335e73993d80afe10645002fb6d47a" + "wasm32": "c7031a9a4a8b27ee47adec035ed9db2149b8190ace6535fe782fc783b506c242" }, "dependencyClosures": { "wasm32": [ { "packageName": "vim", "manifestSha256": "c211660ef41d01f95f3fbdf675b74891e897e3f304e04f1bf5586f326007382c", - "cacheKey": "7312cc6859af08e6f5d3676cc2edc6476bd198a76ceb81ba5cb19ac01cdb8026" + "cacheKey": "5c29e15a62d267a4402222e629c8f5f20fabcd6ae11c27348523ca05c20a81ba" } ] }, @@ -2905,7 +2905,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ae40de1e39e8cfac1fa45208a3c703f287b45aec4c779bdf4b326272675e3d2d" + "wasm32": "e54400b07b4e30c13c9a4d65b09c86681e42c9bb7dedf7c63df5962f791c1011" }, "dependencyClosures": { "wasm32": [] @@ -2926,79 +2926,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "3aa00b98f514b8dcb37d7f0fc573be579181e2974f0f14d8ce7b4858dfa34ffe" + "wasm32": "53d9e1c6dec85a414423ada333f775228f53368f15c62ec32514f3514d33cbf2" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "3f86ef60f36863f0437258c43d10333a65c9412c8899688242fb16207361fa6b" + "cacheKey": "b81a453c2dcd0f838af57a6d3c3f30afddec3605aab599cbba531f9b74e0d8ff" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "d129add0d6c230b17232aef83127a269c5c1c11349d9331d06fcc6889b56f5d0" + "cacheKey": "5b87a5e6802b617851d5fa4efb16b3762eac06f0f683e2c3bb5887225cb7262f" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "2c986bb0488a9ff05b1ef2ec3c0c44be940504ff9b7f33a4e235f981dc016e17" + "cacheKey": "086705244733c6e7d585c44fc921eba478d0508902d91c2a83246a7d4952874f" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0d72b309370cb35e361fc7bedcef5106e2ea482d7ea6e7d10209f81054c25877" + "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "23113207cb8ec3b213b054284770c9ad92d738b225044d01563f1bc3a677622c" + "cacheKey": "d0e75465fa21c6a4090bc2cc92653dae900f85aa79cae5ca9bf67ee0578b7d1c" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "70356b9298ebe5b6d58e7d08093d71b1137c2ff99617beb3d8134f3bc919b088" + "cacheKey": "0daf9f05a267210322bf2a64a77cb05b1b077fc12b340a531e0a900abd0add92" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "4aab2056a613043f30ebf493e466227b595740de70ba210e80919a6d3c1069f5" + "cacheKey": "9fa4575b5833bbf94a6ff9e7931519536dc85ffb2a42f9d019e3e7331a7b50c9" }, { "packageName": "msmtpd", "manifestSha256": "09de04a422ddf631a29a259d816e081f8d317d1077808ede55d8c500baae748f", - "cacheKey": "ea09f8a7c8a43ddeed1fef59c3a9a7b987e0bda7be881d4d1ae4019d73a7ef30" + "cacheKey": "29b34d2aa3442f7d4a8b2126936735a3606af82831c45e13c36e79930d147a88" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "cd58a900319c46bbc268ac664900527414e9c6ffa40683b3be6bcd97d4d47283" + "cacheKey": "d09b3ed121683c8dec908fdcb49e5b852e5da476b06bcaf58158fa7fbd734eb0" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "7b7c878fcf8eadc0689030d9a8f4e12a15e34933c2aef7192136be4d51e42db2" + "cacheKey": "438683df9d94eddd952daf0cb9928db0dd4910fdd7ff73a6ed95949854dc9d75" }, { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "cba27c5df3f7ce7a296d1e672d5ce6671052bcb19e2893b73f926cf7e498d6ae" + "cacheKey": "0b2a2819b64601e21e9c246dc987cda1592e3448550f3df8bb2f7607a136c082" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "735c2c0ecb8edde3797fb59fd6e38f5f8c86a81fced93e3f24e87db5aafcc02b" + "cacheKey": "c8b616ca96ecef41bac1f4d949b483da723edab4976fd2b5b9d47aa11a734613" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "63f8f0bc003c78665088d6ebdfbd495325454537fba1e6a9df9c1c8a9857cf1b" + "cacheKey": "2ce2ccd99e2994765e2f2b4b7823a582b9997999a1654d378c31bac8f5e8aa89" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "fef1b81fc93ff756b4742aa146d9ce3d2887bd4aa53f5a9b48a0098400a74ccc" + "cacheKey": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037" } ] }, @@ -3018,7 +3018,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "8fc65e59b93272069a522a4cf231aa0ade69f6eafe5a28b1000930f7fa0a1c33" + "wasm32": "27fb8caf7f527257a7ea22fadca00656a4e882b69a717449f862c09d008e3253" }, "dependencyClosures": { "wasm32": [] @@ -3039,7 +3039,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "bb1192b88018259570833ea86709ab549c82a2417ba284f6749a08e194c4d95d" + "wasm32": "e4759f4fd4530df51be9219fecff09bf45bd273b974673ace48ca93e7b0a258e" }, "dependencyClosures": { "wasm32": [] @@ -3060,7 +3060,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2f8312b61940bc336be5489d90f21fdf59dc314a956e1fdf903017159312e7cd" + "wasm32": "b2d9b6d6c02ae09990083aac08b2b774fc6885ce54edf88420cf633d816aac97" }, "dependencyClosures": { "wasm32": [] diff --git a/packages/registry/python-vfs/build.toml b/packages/registry/python-vfs/build.toml index df7b883518..a32a7919de 100644 --- a/packages/registry/python-vfs/build.toml +++ b/packages/registry/python-vfs/build.toml @@ -5,6 +5,7 @@ inputs = [ "images/vfs/scripts/kandelo-demo-config.ts", "images/vfs/scripts/vfs-image-helpers.ts", "host/src/constants.ts", + "host/src/file-offset.ts", "host/src/generated/abi.ts", "host/src/homebrew-bottle-relocation.ts", "host/src/homebrew-guest-layout.ts", diff --git a/packages/registry/redis-vfs/build.toml b/packages/registry/redis-vfs/build.toml index c00a47fbcd..104965d0b4 100644 --- a/packages/registry/redis-vfs/build.toml +++ b/packages/registry/redis-vfs/build.toml @@ -10,6 +10,7 @@ inputs = [ # this image. "host/src/binary-resolver.ts", "host/src/constants.ts", + "host/src/file-offset.ts", "host/src/generated/abi.ts", "host/src/homebrew-bottle-relocation.ts", "host/src/homebrew-guest-layout.ts", diff --git a/packages/registry/rootfs/build.toml b/packages/registry/rootfs/build.toml index d23bdcc9d0..01b907894c 100644 --- a/packages/registry/rootfs/build.toml +++ b/packages/registry/rootfs/build.toml @@ -12,6 +12,7 @@ inputs = [ "tools/mkrootfs/package-lock.json", "tools/mkrootfs/tsconfig.json", "tools/mkrootfs/src", + "host/src/file-offset.ts", "host/src/generated/abi.ts", "host/src/homebrew-bottle-relocation.ts", "host/src/homebrew-guest-layout.ts", diff --git a/packages/registry/shell/build.toml b/packages/registry/shell/build.toml index 026ce68390..bcfeaf2d7c 100644 --- a/packages/registry/shell/build.toml +++ b/packages/registry/shell/build.toml @@ -21,6 +21,7 @@ inputs = [ "package-lock.json", "crates/shared/src/lib.rs", "host/src/constants.ts", + "host/src/file-offset.ts", "host/src/generated/abi.ts", "host/src/homebrew-bottle-descriptor.ts", "host/src/homebrew-bottle-relocation.ts", diff --git a/run.sh b/run.sh index 9f04733333..f4b44c57d6 100755 --- a/run.sh +++ b/run.sh @@ -266,6 +266,10 @@ has_resolvable() { KERNEL_REQUIRED_EXPORTS=( __abi_version kernel_alloc_scratch + kernel_blocking_retry_release + kernel_blocking_retry_token + kernel_clear_process_metadata + kernel_commit_process_exit kernel_create_process kernel_create_process_with_stdio kernel_dequeue_signal @@ -275,6 +279,7 @@ KERNEL_REQUIRED_EXPORTS=( kernel_get_parent_pid kernel_get_process_exit_signal kernel_get_process_state + kernel_get_socket_timeout_ms kernel_handle_channel kernel_has_sa_nocldstop kernel_host_adapter_manifest_len @@ -283,15 +288,36 @@ KERNEL_REQUIRED_EXPORTS=( kernel_ipc_shmat_for_task kernel_ipc_shmdt_for_process kernel_ipc_shmdt_for_task + kernel_is_fd_nonblock kernel_mark_process_signaled + kernel_mq_descriptor_msgsize + kernel_msqid_ds_bytes + kernel_pick_signal_target_tid kernel_pipe_has_readers kernel_posix_timer_fire - kernel_prepare_write_operation + kernel_push_process_metadata_entry kernel_reap_exited_child kernel_remove_process + kernel_semctl_array_bytes + kernel_semid_ds_bytes kernel_set_current_tid + kernel_set_cwd + kernel_shmid_ds_bytes kernel_spawn_process + kernel_spawn_reserved_process + kernel_spawn_scratch_begin + kernel_spawn_scratch_cancel + kernel_spawn_scratch_capacity + kernel_spawn_scratch_pointer + kernel_spawn_scratch_retained_capacity kernel_thread_exit + kernel_thread_has_deliverable + kernel_transfer_channel_execute + kernel_transfer_io_execute + kernel_transfer_scratch_begin + kernel_transfer_scratch_cancel + kernel_transfer_scratch_capacity + kernel_transfer_scratch_pointer kernel_validate_task kernel_wait_child_poll ) diff --git a/scripts/browser-memory64-example-fixtures.txt b/scripts/browser-memory64-example-fixtures.txt index fcfc4a42db..0f824895d5 100644 --- a/scripts/browser-memory64-example-fixtures.txt +++ b/scripts/browser-memory64-example-fixtures.txt @@ -4,7 +4,9 @@ # `.wasm64.wasm`. Keep this list sorted. Browser specs, build-programs, # run.sh readiness, and prepared CI workspace packing are contract-checked # against this file so browser-only jobs cannot depend on ambient artifacts. +examples/kernel_scratch_browser_test.c examples/process_native_layout_test.c +examples/putenv_test.c examples/sysv_ipc_test.c examples/terminal_attributes_api_test.c examples/timerfd_signalfd_scratch_test.c diff --git a/scripts/build-musl.sh b/scripts/build-musl.sh index 73717d58ac..836a6389a6 100755 --- a/scripts/build-musl.sh +++ b/scripts/build-musl.sh @@ -95,13 +95,23 @@ if [ -d "$OVERLAY_DIR/src" ]; then fi # The installed overlay headers are normally copied after `make install`, but -# limits are also compiled into musl's sysconf implementation. Stage these two -# generated/consumer headers in the source tree before `make` so the runtime -# answer and the public header cannot advertise different Kandelo contracts. +# limits are also compiled into musl's sysconf implementation, and pthread +# overrides consume generated syscall numbers. Stage the complete reserved +# Kandelo header namespace in the source tree before `make`. +# +# WHY: this must be a mirror, not an additive copy. Otherwise renaming a +# generated header leaves the old contract in musl's source tree, and +# `make install` republishes it into every subsequently rebuilt sysroot. cp "$OVERLAY_DIR/include/limits.h" "$MUSL_DIR/include/limits.h" mkdir -p "$MUSL_DIR/include/bits" -cp "$OVERLAY_DIR/include/bits/kandelo_limits.h" \ - "$MUSL_DIR/include/bits/kandelo_limits.h" +find "$MUSL_DIR/include/bits" -maxdepth 1 \ + \( -type f -o -type l \) -name 'kandelo_*.h' -delete +KANDELO_GENERATED_HEADERS=("$OVERLAY_DIR"/include/bits/kandelo_*.h) +if [ ! -f "${KANDELO_GENERATED_HEADERS[0]}" ]; then + echo "Error: no generated Kandelo bits headers found in $OVERLAY_DIR/include/bits" >&2 + exit 1 +fi +cp "${KANDELO_GENERATED_HEADERS[@]}" "$MUSL_DIR/include/bits/" # musl's src/internal/syscall.h uses syscall_arg_t for the public # varargs syscall() path and also hard-codes it into the non-varargs diff --git a/scripts/install-overlay-headers.sh b/scripts/install-overlay-headers.sh index f4124db226..9afb33217d 100755 --- a/scripts/install-overlay-headers.sh +++ b/scripts/install-overlay-headers.sh @@ -30,6 +30,15 @@ if [ ! -d "$OVERLAY_DIR/include" ]; then exit 0 fi +# WHY: `run.sh` invokes this incrementally, so copying alone would retain a +# generated header after its authoritative overlay is renamed or removed. +# Mirror only Kandelo's reserved bits-header namespace; unrelated musl and +# third-party headers in the caller-provided sysroot remain untouched. +if [ -d "$SYSROOT/include/bits" ]; then + find "$SYSROOT/include/bits" -maxdepth 1 \ + \( -type f -o -type l \) -name 'kandelo_*.h' -delete +fi + cd "$OVERLAY_DIR/include" find . -name '*.h' | while read -r f; do mkdir -p "$SYSROOT/include/$(dirname "$f")" diff --git a/scripts/resolve-binary.bundle.mjs b/scripts/resolve-binary.bundle.mjs index e6daf45fa2..e0f842dc5a 100644 --- a/scripts/resolve-binary.bundle.mjs +++ b/scripts/resolve-binary.bundle.mjs @@ -1,13 +1,13 @@ // Generated by scripts/build-resolve-binary-bundle.sh; do not edit. Third-party notices: resolve-binary.bundle.LICENSES.txt -var Fo=Object.defineProperty;var On=(r,e,t)=>()=>{if(t)throw t[0];try{return r&&(e=r(r=0)),e}catch(n){throw t=[n],n}};var Nr=(r,e)=>{for(var t in e)Fo(r,t,{get:e[t],enumerable:!0})};import{createRequire as Ds}from"module";function Li(r,e){return ki(r,{i:2},e&&e.out,e&&e.dictionary)}var Us,pt,Gs,Ks,te,ht,Ws,wi,vi,Zs,Si,pt,Ei,Hs,zi,Vs,ud,Qn,Ae,M,$t,Ft,M,M,M,M,bi,M,js,qs,Xn,ge,Jn,Ai,yn,Ys,fe,ki,Xs,Js,yt,xi,Qs,ea,er=On(()=>{Us=Ds("/");try{pt=Us("worker_threads"),Gs=pt.Worker,Ks=pt.isMarkedAsUntransferable}catch{}te=Uint8Array,ht=Uint16Array,Ws=Int32Array,wi=new te([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),vi=new te([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),Zs=new te([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),Si=function(r,e){for(var t=new ht(31),n=0;n<31;++n)t[n]=e+=1<>1|(M&21845)<<1,Ae=(Ae&52428)>>2|(Ae&13107)<<2,Ae=(Ae&61680)>>4|(Ae&3855)<<4,Qn[M]=((Ae&65280)>>8|(Ae&255)<<8)>>1;$t=(function(r,e,t){for(var n=r.length,i=0,s=new ht(e);i>c]=d}else for(a=new ht(n),i=0;i>15-r[i]);return a}),Ft=new te(288);for(M=0;M<144;++M)Ft[M]=8;for(M=144;M<256;++M)Ft[M]=9;for(M=256;M<280;++M)Ft[M]=7;for(M=280;M<288;++M)Ft[M]=8;bi=new te(32);for(M=0;M<32;++M)bi[M]=5;js=$t(Ft,9,1),qs=$t(bi,5,1),Xn=function(r){for(var e=r[0],t=1;te&&(e=r[t]);return e},ge=function(r,e,t){var n=e/8|0;return(r[n]|r[n+1]<<8)>>(e&7)&t},Jn=function(r,e){var t=e/8|0;return(r[t]|r[t+1]<<8|r[t+2]<<16)>>(e&7)},Ai=function(r){return(r+7)/8|0},yn=function(r,e,t){return(e==null||e<0)&&(e=0),(t==null||t>r.length)&&(t=r.length),new te(r.subarray(e,t))},Ys=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],fe=function(r,e,t){var n=new Error(e||Ys[r]);if(n.code=r,Error.captureStackTrace&&Error.captureStackTrace(n,fe),!t)throw n;return n},ki=function(r,e,t,n){var i=r.length,s=n?n.length:0;if(!i||e.f&&!e.l)return t||new te(0);var o=!t,a=o||e.i!=2,c=e.i;o&&(t=new te(i*3));var d=function(Oe){var Te=t.length;if(Oe>Te){var tn=new te(Math.max(Te*2,Oe));tn.set(t),t=tn}},u=e.f||0,l=e.p||0,f=e.b||0,y=e.l,h=e.d,p=e.m,w=e.n,m=i*8;do{if(!y){u=ge(r,l,1);var g=ge(r,l+1,3);if(l+=3,g)if(g==1)y=js,h=qs,p=9,w=5;else if(g==2){var S=ge(r,l,31)+257,b=ge(r,l+10,15)+4,k=S+ge(r,l+5,31)+1;l+=14;for(var x=new te(k),I=new te(19),_=0;_>4;if(v<16)x[_++]=v;else{var G=0,ce=0;for(v==16?(ce=3+ge(r,l,3),l+=2,G=x[_-1]):v==17?(ce=3+ge(r,l,7),l+=3):v==18&&(ce=11+ge(r,l,127),l+=7);ce--;)x[_++]=G}}var N=x.subarray(0,S),Z=x.subarray(S);p=Xn(N),w=Xn(Z),y=$t(N,p,1),h=$t(Z,w,1)}else fe(1);else{var v=Ai(l)+4,z=r[v-4]|r[v-3]<<8,E=v+z;if(E>i){c&&fe(0);break}a&&d(f+z),t.set(r.subarray(v,E),f),e.b=f+=z,e.p=l=E*8,e.f=u;continue}if(l>m){c&&fe(0);break}}a&&d(f+131072);for(var vt=(1<>4;if(l+=G&15,l>m){c&&fe(0);break}if(G||fe(2),Ee<256)t[f++]=Ee;else if(Ee==256){Ue=l,y=null;break}else{var St=Ee-254;if(Ee>264){var _=Ee-257,_e=wi[_];St=ge(r,l,(1<<_e)-1)+Ei[_],l+=_e}var Qe=h[Jn(r,l)&en],pe=Qe>>4;Qe||fe(3),l+=Qe&15;var Z=Vs[pe];if(pe>3){var _e=vi[pe];Z+=Jn(r,l)&(1<<_e)-1,l+=_e}if(l>m){c&&fe(0);break}a&&d(f+131072);var Pe=f+St;if(f>3&1)+(e>>4&1);n>0;n-=!r[t++]);return t+(e&2)},yt=(function(){function r(e,t){typeof e=="function"&&(t=e,e={}),this.ondata=t;var n=e&&e.dictionary&&e.dictionary.subarray(-32768);this.s={i:0,b:n?n.length:0},this.o=new te(32768),this.p=new te(0),n&&this.o.set(n)}return r.prototype.e=function(e){if(this.ondata||fe(5),this.d&&fe(4),!this.p.length)this.p=e;else if(e.length){var t=new te(this.p.length+e.length);t.set(this.p),t.set(e,this.p.length),this.p=t}},r.prototype.c=function(e){this.s.i=+(this.d=e||!1);var t=this.s.b,n=ki(this.p,this.s,this.o);this.ondata(yn(n,t,this.s.b),this.d),this.o=yn(n,this.s.b-32768),this.s.b=this.o.length,this.p=yn(this.p,this.s.p/8|0),this.s.p&=7},r.prototype.push=function(e,t){this.e(e),this.c(t)},r})();xi=(function(){function r(e,t){this.v=1,this.r=0,yt.call(this,e,t)}return r.prototype.push=function(e,t){if(yt.prototype.e.call(this,e),this.r+=e.length,this.v){var n=this.p.subarray(this.v-1),i=n.length>3?Js(n):4;if(i>n.length){if(!t)return}else this.v>1&&this.onmember&&this.onmember(this.r-n.length);this.p=n.subarray(i),this.v=0}yt.prototype.c.call(this,0),this.s.f&&!this.s.l?(this.v=Ai(this.s.p)+9,this.s={i:0},this.o=new te(0),this.push(new te(0),t)):t&&yt.prototype.c.call(this,t)},r})(),Qs=typeof TextDecoder<"u"&&new TextDecoder,ea=0;try{Qs.decode(Xs,{stream:!0}),ea=1}catch{}});var rr={};Nr(rr,{extractZipEntry:()=>ca,extractZipEntryBounded:()=>da,fetchZipCentralDirectory:()=>ua,parseZipCentralDirectory:()=>Dt});function Ri(r){let e=new DataView(r.buffer,r.byteOffset,r.byteLength),t=Math.max(0,r.length-Pi);for(let n=r.length-ra;n>=t;n--)if(e.getUint32(n,!0)===ta)return n;throw new Error("Zip EOCD record not found")}function Dt(r){let e=new DataView(r.buffer,r.byteOffset,r.byteLength),t=Ri(r),n=e.getUint16(t+10,!0),i=e.getUint32(t+16,!0),s=[],o=i;for(let a=0;a>8,z;v===Ii?z=p>>16&65535:g.startsWith("bin/")||g.startsWith("sbin/")||g.includes("/bin/")||g.includes("/sbin/")?z=493:z=420;let E=g.endsWith("/"),S=v===Ii&&(z&oa)===ia;s.push({fileName:g,fileNameBytes:m,compressedSize:u,uncompressedSize:l,compressionMethod:d,localHeaderOffset:w,mode:z,isDirectory:E,isSymlink:S,externalAttrs:p,creatorOS:v}),o+=tr+f+y+h}return s}function Bi(r,e){if(r.byteLength!==e.byteLength)return!1;for(let t=0;t{if(a.byteLength>t-s)throw new Error(`ZIP member ${e.fileName} expands beyond ${t} bytes`);i.set(a,s),s+=a.byteLength}).push(n,!0),s!==t)throw new Error(`ZIP member ${e.fileName} expanded ${s} bytes, expected ${t}`);return i}function la(r,e){let t=new DataView(r.buffer,r.byteOffset,r.byteLength),n=e.localHeaderOffset;if(n<0||n>r.byteLength-nr||t.getUint32(n,!0)!==_i)throw new Error(`Invalid local file header signature at offset ${n}`);let i=t.getUint16(n+8,!0),s=t.getUint16(n+26,!0),o=t.getUint16(n+28,!0),a=n+nr,c=a+s+o,d=c+e.compressedSize;if(i!==e.compressionMethod||cr.byteLength||!Bi(r.subarray(a,a+s),e.fileNameBytes))throw new Error(`ZIP member ${e.fileName} has inconsistent local metadata`);return r.subarray(c,d)}async function ua(r){let e=await fetch(r,{method:"HEAD"});if(!e.ok)throw new Error(`HEAD request failed: ${e.status} ${e.statusText}`);let t=parseInt(e.headers.get("content-length")||"0",10),n=e.headers.get("accept-ranges");if(!t||n!=="bytes"){let m=await fetch(r);if(!m.ok)throw new Error(`Fetch failed: ${m.status} ${m.statusText}`);let g=new Uint8Array(await m.arrayBuffer());return{entries:Dt(g),totalSize:g.length}}let i=Math.min(t,Pi),s=t-i,o=await fetch(r,{headers:{Range:`bytes=${s}-${t-1}`}});if(o.status!==206){let m=await fetch(r);if(!m.ok)throw new Error(`Fetch failed: ${m.status} ${m.statusText}`);let g=new Uint8Array(await m.arrayBuffer());return{entries:Dt(g),totalSize:g.length}}let a=new Uint8Array(await o.arrayBuffer()),c=new DataView(a.buffer,a.byteOffset,a.byteLength),d=Ri(a),u=c.getUint32(d+12,!0),l=c.getUint32(d+16,!0);if(l>=s){let m=t,g=new Uint8Array(m);return g.set(a,s),{entries:Dt(g),totalSize:m}}let f=l+u-1,y=await fetch(r,{headers:{Range:`bytes=${l}-${f}`}});if(y.status!==206)throw new Error(`Range request for CD failed: ${y.status}`);let h=new Uint8Array(await y.arrayBuffer()),p=t,w=new Uint8Array(p);return w.set(h,l),w.set(a,s),{entries:Dt(w),totalSize:p}}var ta,na,_i,Pi,ra,tr,nr,Oi,Ti,Ii,ia,oa,sa,aa,ir=On(()=>{"use strict";er();ta=101010256,na=33639248,_i=67324752,Pi=65557,ra=22,tr=46,nr=30,Oi=0,Ti=8,Ii=3,ia=40960,oa=61440,sa=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),aa=new TextEncoder});var Ki={};Nr(Ki,{DEFAULT_TAR_GZIP_LIMITS:()=>Di,TarParseError:()=>P,parseTarBytes:()=>ma,parseTarGzip:()=>pa});function pa(r,e={}){let t=e.label??"TAR gzip archive",n=Gi(e.limits,t);if(r.byteLength===0||r.byteLength>n.maxCompressedBytes)throw new P(`${t}: compressed byte count ${r.byteLength} is outside 1..${n.maxCompressedBytes}`);let i=ga(r,t);if(i===0||i>n.maxUncompressedBytes)throw new P(`${t}: declared uncompressed byte count ${i} is outside 1..${n.maxUncompressedBytes}`);let s=wa(r,t,i);if(s.byteLength!==i)throw new P(`${t}: gzip expanded to ${s.byteLength} bytes, expected ${i}`);let o=new DataView(r.buffer,r.byteOffset,r.byteLength).getUint32(r.byteLength-8,!0);if(va(s)!==o)throw new P(`${t}: gzip CRC32 mismatch`);return Ui(s,t,n)}function ma(r,e={}){let t=e.label??"TAR archive",n=Gi(e.limits,t);if(r.byteLength===0||r.byteLength>n.maxUncompressedBytes)throw new P(`${t}: byte count ${r.byteLength} is outside 1..${n.maxUncompressedBytes}`);return Ui(r,t,n)}function Ui(r,e,t){if(r.byteLength%ke!==0)throw new P(`${e}: TAR byte count is not block-aligned`);let n=[],i=0,s=0,o=0,a=null,c={},d=!1;for(;i+ke<=r.byteLength;){let u=r.subarray(i,i+ke);if(i+=ke,or(u)){if(i+ke>r.byteLength)throw new P(`${e}: TAR end marker is truncated`);let E=r.subarray(i,i+ke);if(!or(E))throw new P(`${e}: TAR has only one zero end block`);if(i+=ke,!or(r.subarray(i)))throw new P(`${e}: TAR has nonzero data after its end marker`);d=!0;break}ba(u,e);let l=Ut(u,156,1,e)||"0",f=ar(u,124,12,`${e}: TAR entry size`),y=ar(u,100,8,`${e}: TAR entry mode`)&fa,h=Aa(u,e,t.maxPathBytes),p=Ut(u,157,100,e);if(l==="x"||l==="g"){if(o+=1,o>t.maxEntries+1)throw new P(`${e}: TAR extension header count exceeds ${t.maxEntries+1}`);let E=Ni(r,i,f,e);i=Mi(i,f,r.byteLength,e);let S=Ea(E,e,t);l==="x"?a=S:c={...c,...S};continue}if(s+=1,s>t.maxEntries)throw new P(`${e}: TAR entry count exceeds ${t.maxEntries}`);let w={...c,...a??{}};a=null;let m=w.size===void 0?f:za(w.size,`${e}: PAX entry size`),g=Ni(r,i,m,e);i=Mi(i,m,r.byteLength,e);let v=sr(w.path??h,e,t.maxPathBytes),z=w.linkpath??p;if(v==="."){if(l!=="5")throw new P(`${e}: TAR root marker is not a directory`);pn(m,e,"directory",v);continue}switch(l){case"0":case"\0":n.push({path:v,type:"file",mode:y,data:g});break;case"5":pn(m,e,"directory",v),n.push({path:v,type:"directory",mode:y});break;case"2":pn(m,e,"symlink",v),$i(z,e,v,t.maxLinkBytes,!1),n.push({path:v,type:"symlink",mode:y,linkName:z});break;case"1":pn(m,e,"hardlink",v),$i(z,e,v,t.maxLinkBytes,!0),n.push({path:v,type:"hardlink",mode:y,linkName:sr(z,`${e}: hardlink target`,t.maxPathBytes)});break;case"3":case"4":case"6":throw new P(`${e}: unsupported TAR device/FIFO entry ${v}`);default:throw new P(`${e}: unsupported TAR entry type ${JSON.stringify(l)} for ${v}`)}}if(!d)throw new P(`${e}: TAR is missing its two-block end marker`);if(a!==null)throw new P(`${e}: local PAX header has no following entry`);return n}function Gi(r,e){let t={...Di,...r};for(let[n,i]of Object.entries(t))if(!Number.isSafeInteger(i)||i<=0)throw new P(`${e}: ${n} must be a positive safe integer`);return t}function ga(r,e){if(r.byteLength<18||r[0]!==31||r[1]!==139||r[2]!==8)throw new P(`${e}: invalid gzip header`);return new DataView(r.buffer,r.byteOffset,r.byteLength).getUint32(r.byteLength-4,!0)}function wa(r,e,t){let n=new Uint8Array(t),i=0,s=!1,o=new xi(a=>{if(a.byteLength>t-i)throw new P(`${e}: gzip expansion exceeds its declared ${t} bytes`);n.set(a,i),i+=a.byteLength});o.onmember=()=>{throw s=!0,new P(`${e}: concatenated gzip members are unsupported`)};try{o.push(r,!0)}catch(a){throw a instanceof P?a:new P(`${e}: cannot gunzip archive: ${La(a)}`)}if(s)throw new P(`${e}: concatenated gzip members are unsupported`);return n.subarray(0,i)}function va(r){let e=4294967295;for(let t of r)e=ya[(e^t)&255]^e>>>8;return(e^4294967295)>>>0}function Sa(){let r=new Uint32Array(256);for(let e=0;e>>1^((t&1)===0?0:3988292384);r[e]=t>>>0}return r}function Ni(r,e,t,n){if(t>r.byteLength-e)throw new P(`${n}: TAR entry is truncated`);return r.subarray(e,e+t)}function Mi(r,e,t,n){let s=Math.ceil(e/ke)*ke;if(!Number.isSafeInteger(s)||s>t-r)throw new P(`${n}: TAR entry padding is truncated`);return r+s}function Ea(r,e,t){let n={},i=0;for(;i9)throw new P(`${e}: invalid PAX record length`);if(o=o*10+p,!Number.isSafeInteger(o))throw new P(`${e}: invalid PAX record length`)}let a=i+o;if(o<=s-i+2||a>r.byteLength||r[a-1]!==10)throw new P(`${e}: truncated PAX record`);let c=s+1;for(;c=a-1)throw new P(`${e}: invalid PAX record`);let d=r.subarray(s+1,c);if(d.byteLength>256)throw new P(`${e}: PAX record key is too long`);let u=cr(d,`${e}: PAX record key`),l=r.subarray(c+1,a-1),f=u==="path"?t.maxPathBytes:u==="linkpath"?t.maxLinkBytes:u==="size"?32:0;if(f===0){i=a;continue}if(l.byteLength>f)throw new P(`${e}: PAX ${u} value is too long`);let y=cr(l,`${e}: PAX record value`);n[u]=y,i=a}return n}function za(r,e){if(!/^(0|[1-9][0-9]*)$/.test(r))throw new P(`${e} is invalid`);let t=Number(r);if(!Number.isSafeInteger(t)||t<0)throw new P(`${e} is invalid`);return t}function ba(r,e){let t=ar(r,148,8,`${e}: TAR checksum`),n=0;for(let i=0;i=148&&i<156?32:r[i];if(t!==n)throw new P(`${e}: TAR checksum mismatch`)}function Aa(r,e,t){let n=Ut(r,0,100,e),i=Ut(r,345,155,e);return sr(i?`${i}/${n}`:n,e,t)}function sr(r,e,t){if(r==="."||r==="./")return".";let n=r;for(;n.startsWith("./");)n=n.slice(2);return n=n.replace(/\/+$/g,""),ka(n,`${e}: TAR path`,t),n}function Ut(r,e,t,n){let i=e,s=e+t;for(;in||r.includes("\0"))throw new P(`${e}: link target for ${t} is invalid`);if(i&&r.includes("\\"))throw new P(`${e}: hardlink target for ${t} is invalid`)}function ka(r,e,t){if(r.length===0||r.startsWith("/")||r.includes("\0")||r.includes("\\")||Fi.encode(r).byteLength>t)throw new P(`${e} ${JSON.stringify(r)} must be a bounded relative POSIX path`);for(let n of r.split("/"))if(n.length===0||n==="."||n==="..")throw new P(`${e} ${JSON.stringify(r)} contains an unsafe path segment`)}function or(r){for(let e of r)if(e!==0)return!1;return!0}function cr(r,e){try{return ha.decode(r)}catch{throw new P(`${e} contains non-UTF-8 text`)}}function La(r){return r instanceof Error?r.message:String(r)}var ke,fa,Ci,ha,Fi,ya,Di,P,Wi=On(()=>{"use strict";er();ke=512,fa=4095,Ci=1024*1024,ha=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),Fi=new TextEncoder,ya=Sa(),Di=Object.freeze({maxCompressedBytes:256*Ci,maxUncompressedBytes:512*Ci,maxEntries:1e5,maxPathBytes:4096,maxLinkBytes:65536}),P=class extends Error{constructor(e){super(e),this.name="TarParseError"}}});import{existsSync as Xt,lstatSync as _n,readdirSync as xo,readFileSync as Ye,realpathSync as ve,statSync as Fe}from"node:fs";import{createHash as Io}from"node:crypto";import{spawnSync as xr}from"node:child_process";import{basename as pc,dirname as Qt,isAbsolute as Pn,join as D,relative as mc,resolve as we,sep as gc}from"node:path";import{fileURLToPath as wc}from"node:url";var Et="kandelo.wpk_fork.linked_frames";var Mr=[75,76,67,70],zt=24,$r=8,Tn=3,Fr=[{bytes:4,chunkHeaderSize:32,nodeHeaderSize:24},{bytes:8,chunkHeaderSize:56,nodeHeaderSize:32}],tt=[{module:"env",name:"__wpk_fork_frame_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_frame_next",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_reserve",params:["ptr"],results:["ptr"]}],bt=[{name:"wpk_fork_abort_begin",params:["ptr"],results:[]},{name:"wpk_fork_abort_end",params:[],results:[]},{name:"wpk_fork_rewind_begin",params:["ptr"],results:[]},{name:"wpk_fork_rewind_end",params:[],results:[]},{name:"wpk_fork_state",params:[],results:["i32"]},{name:"wpk_fork_unwind_begin",params:["ptr"],results:[]},{name:"wpk_fork_unwind_end",params:[],results:[]}];var Dr=["__abi_version","kernel_alloc_scratch","kernel_create_process","kernel_create_process_with_stdio","kernel_dequeue_signal","kernel_exec_prepare","kernel_exec_setup_for_thread","kernel_fork_process","kernel_get_parent_pid","kernel_get_process_exit_signal","kernel_get_process_state","kernel_handle_channel","kernel_has_sa_nocldstop","kernel_host_adapter_manifest_len","kernel_host_adapter_manifest_ptr","kernel_ipc_shmat_for_process","kernel_ipc_shmat_for_task","kernel_ipc_shmdt_for_process","kernel_ipc_shmdt_for_task","kernel_mark_process_signaled","kernel_pipe_has_readers","kernel_posix_timer_fire","kernel_prepare_write_operation","kernel_reap_exited_child","kernel_remove_process","kernel_set_current_tid","kernel_spawn_process","kernel_thread_exit","kernel_validate_task","kernel_wait_child_poll"];var H={LINK_MAX:0,MAX_CANON:1,MAX_INPUT:2,NAME_MAX:3,PATH_MAX:4,PIPE_BUF:5,CHOWN_RESTRICTED:6,NO_TRUNC:7,VDISABLE:8,SYNC_IO:9,ASYNC_IO:10,PRIO_IO:11,SOCK_MAXBUF:12,FILESIZEBITS:13,REC_INCR_XFER_SIZE:14,REC_MAX_XFER_SIZE:15,REC_MIN_XFER_SIZE:16,REC_XFER_ALIGN:17,ALLOC_SIZE_MIN:18,SYMLINK_MAX:19,POSIX2_SYMLINKS:20,FALLOC:21,TEXTDOMAIN_MAX:22,TIMESTAMP_RESOLUTION:23};function L(r,e){let t=0,n=0,i=e;for(;;){let s=r[i++];if(t|=(s&127)<=21&&n<=34?At(e,t):n===84||n>=92&&n<=99||n>=112&&n<=123||n>=124&&n<=131||n>=156&&n<=159?t+1:t:r===254?n===0||n===1||n===2?At(e,t):n===3?t:n>=16&&n<=79?At(e,t):null:null}function Wo(r,e,t){let[n,i]=L(r,e);e+=i+n;let[s,o]=L(r,e);e+=o+s;let a=r[e++];if(a===0){t.funcImports++;let[,c]=L(r,e);e+=c}else if(a===1){e++;let c=r[e++],[,d]=L(r,e);if(e+=d,c&1){let[,u]=L(r,e);e+=u}}else if(a===2){let c=r[e++],[,d]=L(r,e);if(e+=d,c&1){let[,u]=L(r,e);e+=u}}else a===3&&(t.globalImports++,e+=2);return e}function nn(r){return r.length>=8&&r[0]===0&&r[1]===97&&r[2]===115&&r[3]===109}function Re(r,e){let[t,n]=L(r,e);return e+=n,[new TextDecoder().decode(r.subarray(e,e+t)),e+t]}function Zo(r,e){if(e.length===0)return!0;let t=new TextEncoder().encode(e);e:for(let n=0;n<=r.length-t.length;n++){for(let i=0;ir);function Ur(r,e,t){if(!t)throw new Error(`function ${e} refers to an unknown type`);let n=r.get(e)??[];n.push(t),r.set(e,n)}function Rn(r,e){let[t,n]=L(r,e);e+=n;let[,i]=L(r,e);if(e+=i,(t&1)!==0){let[,s]=L(r,e);e+=s}return{flags:t,next:e}}function Vo(r){let e=new Uint8Array(r);if(!nn(e))throw new Error("not a wasm binary");let t=[],n=[],i=[],s={functionImports:new Map,functionExports:new Map,memoryPointerWidths:[],linkedFrameDescriptors:[],importsKernelFork:!1},o=8;for(;oe.length)throw new Error("wasm section exceeds file size");let f=u,y=!1;if(a===0){let[h,p]=Re(e,f);h===Et&&s.linkedFrameDescriptors.push(e.slice(p,l))}else if(a===1){y=!0;let[h,p]=L(e,f);f+=p;for(let w=0;wr[c]===a))throw new Error("linked-frame descriptor has invalid magic");let e=new DataView(r.buffer,r.byteOffset,r.byteLength),t=e.getUint16(4,!0);if(t!==1)throw new Error(`linked-frame descriptor version ${t} is unsupported`);let n=e.getUint16(6,!0);if(n!==zt)throw new Error(`linked-frame descriptor declares size ${n}, expected ${zt}`);let i=e.getUint8(8),s=Fr.find(({bytes:a})=>a===i);if(!s)throw new Error(`linked-frame descriptor pointer width ${i} is unsupported`);if(e.getUint8(9)!==$r)throw new Error(`linked-frame descriptor alignment ${e.getUint8(9)} is unsupported`);let o=e.getUint16(10,!0);if(o!==Tn)throw new Error(`linked-frame descriptor flags 0x${o.toString(16)} do not equal required flags 0x${Tn.toString(16)}`);if(e.getUint32(12,!0)!==s.chunkHeaderSize||e.getUint32(16,!0)!==s.nodeHeaderSize)throw new Error(`linked-frame descriptor header sizes do not match its ${i}-byte pointer width`);return s.bytes}function Gr(r,e){return r==="i32"?127:e===8?126:127}function Kr(r,e,t,n){return r.params.length===e.length&&r.results.length===t.length&&r.params.every((i,s)=>i===Gr(e[s],n))&&r.results.every((i,s)=>i===Gr(t[s],n))}function Wr(r,e,t){let n=i=>i==="ptr"&&t===8?"i64":"i32";return`(${r.map(n).join(", ")}) -> (${e.map(n).join(", ")})`}function qo(r){let e=[];for(let o of bt){let a=r.functionExports.get(o.name);a&&a.length!==1&&e.push(`duplicate ABI 42 wasm-fork-instrument export ${o.name}`)}let t=bt.filter(({name:o})=>!r.functionExports.has(o)).map(({name:o})=>o);t.length>0&&e.push(`incomplete wasm-fork-instrument exports; missing ${t.join(", ")}`);let n=null;if(r.linkedFrameDescriptors.length===0)e.push(`missing required ${Et} descriptor`);else if(r.linkedFrameDescriptors.length!==1)e.push(`has ${r.linkedFrameDescriptors.length} ${Et} descriptors, expected exactly one`);else try{n=jo(r.linkedFrameDescriptors[0])}catch(o){e.push(o instanceof Error?o.message:String(o))}let i=tt.filter(({module:o,name:a})=>r.functionImports.has(`${o}.${a}`)),s=r.importsKernelFork||i.length>0;if(s){let o=tt.filter(({module:a,name:c})=>!r.functionImports.has(`${a}.${c}`)).map(({module:a,name:c})=>`${a}.${c}`);o.length>0&&e.push(`incomplete ABI 42 linked-frame imports; missing ${o.join(", ")}`);for(let a of tt){let c=`${a.module}.${a.name}`,d=r.functionImports.get(c);d&&d.length!==1&&e.push(`duplicate ABI 42 linked-frame import ${c}`)}}if(n!==null){if(r.memoryPointerWidths.length!==1)e.push(`ABI 42 fork instrumentation requires exactly one module memory, found ${r.memoryPointerWidths.length}`);else if(r.memoryPointerWidths[0]!==n){let o=n===8?"an":"a";e.push(`ABI 42 linked-frame descriptor declares ${o} ${n}-byte pointer but the module memory uses ${r.memoryPointerWidths[0]}-byte addresses`)}for(let o of bt){let a=r.functionExports.get(o.name);a?.length===1&&!Kr(a[0],o.params,o.results,n)&&e.push(`ABI 42 wasm-fork-instrument export ${o.name} has the wrong signature; expected ${Wr(o.params,o.results,n)}`)}if(s)for(let o of tt){let a=`${o.module}.${o.name}`,c=r.functionImports.get(a);c?.length===1&&!Kr(c[0],o.params,o.results,n)&&e.push(`ABI 42 linked-frame import ${a} has the wrong signature; expected ${Wr(o.params,o.results,n)}`)}}return e}function Yo(r){let e=new Uint8Array(r);if(!nn(e))return[];let t=[],n=8;for(;nt.startsWith("reloc."))}function Hr(r,e={}){let t=[];if(Jo(r)&&t.push("contains asyncify_"),e.expectedAbi!==void 0&&e.expectedAbi!==null){let l=ts(r);l!==null&&l!==e.expectedAbi&&t.push(`ABI ${l}, expected ${e.expectedAbi}`)}let n=new Set(Xo(r));if(e.requiredExports){let l=e.requiredExports.filter(f=>!n.has(f));l.length>0&&t.push(`missing required exports: ${l.join(", ")}`)}let i=Ho.filter(l=>n.has(l)),s=Yo(r),o=Zr(r),a=tt.filter(({module:l,name:f})=>s.includes(`${l}.${f}`)),c=o.filter(l=>l===Et).length,d=i.length>0||a.length>0||c>0;if(e.forbidForkInstrumentation&&d&&t.push("contains ABI 42 wasm-fork-instrument metadata, imports, or exports"),(e.requireForkInstrumentation??!Qo(r))&&(d||s.includes("kernel.kernel_fork")))try{t.push(...qo(Vo(r)))}catch(l){t.push(`cannot validate ABI 42 fork-artifact contract: ${l instanceof Error?l.message:String(l)}`)}return t}function es(r,e){let t=new Uint8Array(r);if(t.length<8)return null;let n=0,i=null,s=null,o=8;for(;o=c)return null;let p=a;for(let g=0;g=h)return null;let[p,w]=L(t,y);y+=w;for(let m=0;mh)return null}return y}function f(y,h=0){if(h>4)return null;let p=u(y);if(!p)return null;let w=l(p.start,p.end);if(w===null)return null;let m=w,g=p.end;for(;m=32&&v<=38||v===208){let[,z]=L(t,m);m+=z}else if(v>=40&&v<=62)m=At(t,m);else if(v===63||v===64)m++;else if(v===66){let[,z]=Uo(t,m);m+=z}else if(v===67)m+=4;else if(v===68)m+=8;else if(v===252||v===253||v===254){let z=Ko(v,t,m);if(z===null)return null;m=z}}return null}return f(i)}function ts(r){return es(r,"__abi_version")}var ns=ArrayBuffer,q=Uint8Array,rn=Uint16Array,rs=Int16Array;var on=Int32Array,Cn=function(r,e,t){if(q.prototype.slice)return q.prototype.slice.call(r,e,t);(e==null||e<0)&&(e=0),(t==null||t>r.length)&&(t=r.length);var n=new q(t-e);return n.set(r.subarray(e,t)),n},Lt=function(r,e,t,n){if(q.prototype.fill)return q.prototype.fill.call(r,e,t,n);for((t==null||t<0)&&(t=0),(n==null||n>r.length)&&(n=r.length);tr.length)&&(n=r.length);t2046MB)","invalid block type","FSE accuracy too high","match distance too far back","unexpected EOF"],Y=function(r,e,t){var n=new Error(e||os[r]);if(n.code=r,Error.captureStackTrace&&Error.captureStackTrace(n,Y),!t)throw n;return n},Vr=function(r,e,t){for(var n=0,i=0;n>>0},as=function(r,e){var t=r[0]|r[1]<<8|r[2]<<16;if(t==3126568&&r[3]==253){var n=r[4],i=n>>5&1,s=n>>2&1,o=n&3,a=n>>6;n&8&&Y(0);var c=6-i,d=o==3?4:o,u=Vr(r,c,d);c+=d;var l=a?1<>3);y=h+(h>>3)*(r[5]&7)}y>2145386496&&Y(1);var p=new q((e==1?f||y:e?0:y)+12);return p[0]=1,p[4]=4,p[8]=8,{b:c+l,y:0,l:0,d:u,w:e&&e!=1?e:p.subarray(12),e:y,o:new on(p.buffer,0,3),u:f,c:s,m:Math.min(131072,y)}}else if((t>>4|r[3]<<20)==25481893)return ss(r,4)+8;Y(0)},Ke=function(r){for(var e=0;1<t&&Y(3);for(var s=1<0;){var g=Ke(o+1),v=n>>3,z=(1<>(n&7)&z,S=(1<S&&(E-=b)),f[++a]=--E,E==-1?(o+=E,w[--u]=a):o-=E,!E)do{var x=n>>3;c=(r[x]|r[x+1]<<8)>>(n&7)&3,n+=2,a+=c}while(c==3)}(a>255||o)&&Y(0);for(var I=0,_=(s>>1)+(s>>3)+3,U=s-1,V=0;V<=a;++V){var C=f[V];if(C<1){y[V]=-C;continue}for(d=0;d=u)}}for(I&&Y(0),d=0;d>3,{b:i,s:w,n:m,t:h}]},cs=function(r,e){var t=0,n=-1,i=new q(292),s=r[e],o=i.subarray(0,256),a=i.subarray(256,268),c=new rn(i.buffer,268);if(s<128){var d=xt(r,e+1,6),u=d[0],l=d[1];e+=s;var f=u<<3,y=r[e];y||Y(0);for(var h=0,p=0,w=l.b,m=w,g=(++e<<3)-8+Ke(y);g-=w,!(g>3;if(h+=(r[v]|r[v+1]<<8)>>(g&7)&(1<>3,p+=(r[v]|r[v+1]<<8)>>(g&7)&(1<255&&Y(0)}else{for(n=s-127;t>4,o[t+1]=z&15}++e}var E=0;for(t=0;t11&&Y(0),E+=S&&1<0;--t){var V=c[t];Lt(U,t,V,c[t-1]=V+a[t]*(1<a&&l>3,y=(r[f]|r[f+1]<<8|r[f+2]<<16)>>(u&7);c=(c<>2,o=s<<1,a=s+o;kt(r.subarray(n,n+=r[0]|r[1]<<8),e.subarray(0,s),t),kt(r.subarray(n,n+=r[2]|r[3]<<8),e.subarray(s,o),t),kt(r.subarray(n,n+=r[4]|r[5]<<8),e.subarray(o,a),t),kt(r.subarray(n),e.subarray(a),t)},ps=function(r,e,t){var n,i=e.b,s=r[i],o=s>>1&3;e.l=s&1;var a=s>>3|r[i+1]<<5|r[i+2]<<13,c=(i+=3)+a;if(o==1)return i>=r.length?void 0:(e.b=i+1,t?(Lt(t,r[i],e.y,e.y+=a),t):Lt(new q(a),r[i]));if(!(c>r.length)){if(o==0)return e.b=c,t?(t.set(r.subarray(i,c),e.y),e.y+=a,t):Cn(r,i,c);if(o==2){var d=r[i],u=d&3,l=d>>2&3,f=d>>4,y=0,h=0;u<2?l&1?f|=r[++i]<<4|(l&2&&r[++i]<<12):f=d>>3:(h=l,l<2?(f|=(r[++i]&63)<<4,y=r[i]>>6|r[++i]<<2):l==2?(f|=r[++i]<<4|(r[++i]&3)<<12,y=r[i]>>2|r[++i]<<6):(f|=r[++i]<<4|(r[++i]&63)<<12,y=r[i]>>6|r[++i]<<2|r[++i]<<10)),++i;var p=t?t.subarray(e.y,e.y+e.m):new q(e.m),w=p.length-f;if(u==0)p.set(r.subarray(i,i+=f),w);else if(u==1)Lt(p,r[i++],w);else{var m=e.h;if(u==2){var g=cs(r,i);y+=i-(i=g[0]),e.h=m=g[1]}else m||Y(0);(h?ys:kt)(r.subarray(i,i+=y),p.subarray(w),m)}var v=r[i++];if(v){v==255?v=(r[i++]|r[i++]<<8)+32512:v>127&&(v=v-128<<8|r[i++]);var z=r[i++];z&3&&Y(0);for(var E=[ls,us,ds],S=2;S>-1;--S){var b=z>>(S<<1)+2&3;if(b==1){var k=new q([0,0,r[i++]]);E[S]={s:k.subarray(2,3),n:k.subarray(0,1),t:new rn(k.buffer,0,1),b:0}}else b==2?(n=xt(r,i,9-(S&1)),i=n[0],E[S]=n[1]):b==3&&(e.t||Y(0),E[S]=e.t[S])}var x=e.t=E,I=x[0],_=x[1],U=x[2],V=r[c-1];V||Y(0);var C=(c<<3)-8+Ke(V)-U.b,O=C>>3,G=0,ce=(r[O]|r[O+1]<<8)>>(C&7)&(1<>3;var N=(r[O]|r[O+1]<<8)>>(C&7)&(1<<_.b)-1;O=(C-=I.b)>>3;var Z=(r[O]|r[O+1]<<8)>>(C&7)&(1<>3;var Qe=1<>>(C&7)&Qe-1);O=(C-=Mn[Ue])>>3;var Pe=hs[Ue]+((r[O]|r[O+1]<<8|r[O+2]<<16)>>(C&7)&(1<>3;var Ge=fs[vt]+((r[O]|r[O+1]<<8|r[O+2]<<16)>>(C&7)&(1<>3,ce=U.t[ce]+((r[O]|r[O+1]<<8)>>(C&7)&(1<>3,Z=I.t[Z]+((r[O]|r[O+1]<<8)>>(C&7)&(1<>3,N=_.t[N]+((r[O]|r[O+1]<<8)>>(C&7)&(1<<_e)-1),pe>3)e.o[2]=e.o[1],e.o[1]=e.o[0],e.o[0]=pe-=3;else{var et=pe-(Ge!=0);et?(pe=et==3?e.o[0]-1:e.o[et],et>1&&(e.o[2]=e.o[1]),e.o[1]=e.o[0],e.o[0]=pe):pe=e.o[0]}for(var S=0;SPe&&(Te=Pe);for(var S=0;S=i){let k=(y+1)*4096;try{e.grow(k)}catch{throw new A(ee)}if(i=Math.floor(e.byteLength/4096),y>=i)throw new A(ee)}new Uint8Array(e).fill(0);let h=new r(e);h.w32(Gn,Dn),h.w32(Kn,Un),h.w32(cn,4096),h.w32(it,i),h.w32(Be,o),h.w32(We,u),h.w32(ln,l),h.w32(Jr,f),h.w32(un,y),h.w32(Ls,a),h.w32(xs,c),h.w32(Is,d),h.w32(_t,s),h.w32(Qr,256);let p=l*4096;for(let k=0;k>2)+(k>>5);h.i32[x]|=1<<(k&31)}let w=i-y;Atomics.store(h.i32,ot>>2,w),h.blockAllocHint=y;let m=u*4096;h.i32[m>>2]|=3,Atomics.store(h.i32,dn>>2,o-2),h.inodeAllocHint=2;let g=h.inodeOffset(1);h.w32(g+B,W|493),h.w32(g+$,2),h.w64(g+se,1);let v=h.blockAlloc();if(v<0)throw new A(ee);h.w32(g+Q,v);let z=v*4096,E=Ne(R+1),S=Ne(R+2);h.w32(z,1),h.view.setUint16(z+4,E,!0),h.view.setUint16(z+6,1,!0),h.u8[z+R]=46;let b=z+E;return h.w32(b,1),h.view.setUint16(b+4,S,!0),h.view.setUint16(b+6,2,!0),h.u8[b+R]=46,h.u8[b+R+1]=46,h.w64(g+T,E+S),Atomics.store(h.i32,Wn>>2,1),h}static inspectImageCapacity(e){if(e.byteLength<_t+4)throw new A(j,"SharedFS image is too small");let t=new DataView(e.buffer,e.byteOffset,e.byteLength);if(t.getUint32(Gn,!0)!==Dn)throw new A(j,"Bad magic");if(t.getUint32(Kn,!0)!==Un)throw new A(j,"Bad version");let n=t.getUint32(cn,!0);if(n!==4096)throw new A(j,"Bad block size");let i=t.getUint32(_t,!0)*n;return{byteLength:e.byteLength,maxByteLength:Math.max(e.byteLength,i)}}static mount(e,t){let n=new r(e);if(n.r32(Gn)!==Dn)throw new A(j,"Bad magic");if(n.r32(Kn)!==Un)throw new A(j,"Bad version");if(n.r32(cn)!==4096)throw new A(j,"Bad block size");return t?.restoreImage&&n.resetRestoredRuntimeState(),n.resetAllocationHints(),n}snapshotBytes(e){return this.withNamespaceLock(()=>this.snapshotBytesUnlocked(e))}snapshotState(e){return this.withNamespaceLock(()=>({bytes:this.snapshotBytesUnlocked(e),identities:this.collectIdentityStateUnlocked()}))}identityState(){return this.withNamespaceLock(()=>this.collectIdentityStateUnlocked())}snapshotBytesUnlocked(e){let t=e?.normalizeTimestampsMs;if(t!==void 0&&(!Number.isSafeInteger(t)||t<0))throw new A(j,"Snapshot timestamp must be a non-negative safe integer in milliseconds");let n=t===void 0?void 0:BigInt(t);for(let a=0;a>2)!==0)throw new A(Hn,"Cannot save a VFS image with open descriptors")}let i=this.r32(Be);for(let a=0;a=1&&this.inodeIsAllocated(a)?n:0n;o.setBigUint64(c+Pt,d,!0),o.setBigUint64(c+de,d,!0),o.setBigUint64(c+X,d,!0)}}return s}collectIdentityStateUnlocked(){let e=new Map,t=this.inodeOffset(1),n=this.r64(t+se);e.set(`1:${n}`,{ino:1,generation:n,dataSequence:Atomics.load(this.i32,t+ae>>2)>>>0,mode:this.r32(t+B),linkCount:this.r32(t+$),size:this.r64(t+T),uid:this.r32(t+Tt),gid:this.r32(t+Rt),paths:["/"]});let i=[{ino:1,path:"/"}],s=new Set;for(;i.length>0;){let o=i.pop();if(s.has(o.ino))throw new A(F);s.add(o.ino);let a=this.inodeOffset(o.ino);if((this.r32(a+B)&K)!==W)throw new A(F);let c=this.r64(a+T),d=0;for(;d>2)>>>0,mode:I,linkCount:this.r32(S+$),size:this.r64(S+T),uid:this.r32(S+Tt),gid:this.r32(S+Rt),...(I&K)===nt?{symlinkTarget:this.readSymlinkInodeUnlocked(m)}:{},paths:[]},e.set(k,x)}x.paths.push(E),(this.r32(S+B)&K)===W&&i.push({ino:m,path:E})}}p+=g}d+=h}}return e}statfs(){let e=this.r32(cn),t=this.r32(it),n=this.r32(_t),i=typeof this.buffer.maxByteLength=="number"?this.buffer.maxByteLength:this.buffer.byteLength,s=Math.floor(i/e),o=Math.max(t,Math.min(n,s)),a=Atomics.load(this.i32,ot>>2),c=Math.max(0,o-t);return{blockSize:e,totalBlocks:o,freeBlocks:a+c,totalInodes:this.r32(Be),freeInodes:Atomics.load(this.i32,dn>>2),maxName:255}}r32(e){return this.view.getUint32(e,!0)}w32(e,t){this.view.setUint32(e,t,!0)}r64(e){return Number(this.view.getBigUint64(e,!0))}w64(e,t){this.view.setBigUint64(e,BigInt(t),!0)}waitForAtomicChange(e,t){if(this.atomicsWaitAllowed!==!1)try{Atomics.wait(this.i32,e,t),this.atomicsWaitAllowed=!0;return}catch(n){if(!(n instanceof TypeError))throw n;this.atomicsWaitAllowed=!1}for(;Atomics.load(this.i32,e)===t;);}resetAllocationHints(){this.blockAllocHint=this.findNextFreeBlockHint(),this.inodeAllocHint=this.findNextFreeInodeHint()}findNextFreeBlockHint(){let e=this.r32(it),t=this.r32(un),n=this.r32(ln)*4096;for(let i=t;i>2)+(i>>5),o=i&31;if((Atomics.load(this.i32,s)&1<>2)+(n>>5),s=n&31;if((Atomics.load(this.i32,i)&1<>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}sbUnlock(){let e=fn>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}namespaceLock(){let e=hn>>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}namespaceUnlock(){let e=hn>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}withNamespaceLock(e){this.namespaceLock();try{return e()}finally{this.namespaceUnlock()}}resetRestoredRuntimeState(){Atomics.store(this.i32,fn>>2,0),Atomics.store(this.i32,hn>>2,0),this.u8.fill(0,256,4096);let e=this.r32(Be),t=this.r32(We)*4096;for(let n=0;n>5)*4)&1<<(n&31))===0||this.r32(i+$)!==0)continue;let o=this.r32(i+B),a=this.r64(i+T);(o&K)===nt&&a<=40?(this.u8.fill(0,i+Q,i+Q+40),this.w64(i+T,0)):this.inodeTruncate(n,0),this.inodeFree(n)}}blockAlloc(){let e=this.r32(it),t=this.r32(ln)*4096,n=this.r32(un),i=this.blockAllocHint>=n&&this.blockAllocHint>2)+(a>>5),d=a&31,u=Atomics.load(this.i32,c);if(u&1<>2,1),this.blockAllocHint=a+1>2)+(e>>5),i=e&31;for(;;){let s=Atomics.load(this.i32,n),o=s&~(1<>2,1),e>=this.r32(un)&&e>2)>0)return 0;let e=this.r32(it),t=this.r32(_t),n=this.r32(Qr),i=e+n;if(i>t&&(i=t,n=i-e,n===0))return ee;let s=i*4096;if(this.buffer.byteLength>2,n),Atomics.add(this.i32,Wn>>2,1),this.blockAllocHint=e,0}finally{this.sbUnlock()}}inodeOffset(e){let n=this.r32(Jr)+Math.floor(e/32),i=e%32*128;return n*4096+i}inodeAlloc(){let e=this.r32(Be),t=this.r32(We)*4096,n=this.inodeAllocHint>=2&&this.inodeAllocHint>2)+(o>>5),c=o&31,d=Atomics.load(this.i32,a);if(d&1<>2,1),this.inodeAllocHint=o+1>2,1)+1}inodeFree(e){let n=(this.r32(We)*4096>>2)+(e>>5),i=e&31;for(;;){let s=Atomics.load(this.i32,n);if((s&1<>2,1),e>=2&&e0&&this.w32(n+Ce,i-1),i<=1&&this.r32(n+$)===0&&(this.inodeTruncate(e,0),t=!0)}finally{this.inodeWriteUnlock(e)}t&&this.inodeFree(e)}inodeDropLinkRefLocked(e){let t=this.inodeOffset(e),n=this.r32(t+$);return n>1?(this.w32(t+$,n-1),this.w64(t+X,Date.now()),!1):this.inodeOrphanLocked(e)}inodeOrphanLocked(e){let t=this.inodeOffset(e);if(this.w32(t+$,0),this.w64(t+X,Date.now()),this.r32(t+Ce)>0)return!1;let n=this.r32(t+B),i=this.r64(t+T);return(n&K)===nt&&i<=40?(this.u8.fill(0,t+Q,t+Q+40),this.w64(t+T,0)):this.inodeTruncate(e,0),!0}inodeReadLock(e){let t=this.inodeOffset(e)+st>>2;for(;;){let n=Atomics.load(this.i32,t);if(n&ii){this.waitForAtomicChange(t,n);continue}if(Atomics.compareExchange(this.i32,t,n,n+1)===n)return}}inodeReadUnlock(e){let t=this.inodeOffset(e)+st>>2;(Atomics.sub(this.i32,t,1)&_s)===1&&Atomics.notify(this.i32,t,1)}inodeWriteLock(e){let t=this.inodeOffset(e)+st>>2;for(;;){let n=Atomics.load(this.i32,t);if(n!==0){this.waitForAtomicChange(t,n);continue}if(Atomics.compareExchange(this.i32,t,0,ii)===0)return}}inodeWriteUnlock(e){let t=this.inodeOffset(e)+st>>2;Atomics.store(this.i32,t,0),Atomics.notify(this.i32,t,1/0)}inodeBlockMap(e,t,n){let i=this.inodeOffset(e);if(t<10){let s=this.r32(i+Q+t*4);if(s!==0)return s;if(!n)return 0;let o=this.blockAllocWithGrow();return o<0||this.w32(i+Q+t*4,o),o}if(t-=10,t<1024){let s=this.r32(i+Ot),o=!1;if(s===0){if(!n)return 0;if(s=this.blockAllocWithGrow(),s<0)return s;this.w32(i+Ot,s),o=!0}let a=s*4096+t*4,c=this.r32(a);if(c!==0)return c;if(!n)return 0;let d=this.blockAllocWithGrow();return d<0?(o&&(this.w32(i+Ot,0),this.blockFree(s)),d):(this.w32(a,d),d)}if(t-=1024,t<1024*1024){let s=Math.floor(t/1024),o=t%1024,a=this.r32(i+at),c=!1;if(a===0){if(!n)return 0;if(a=this.blockAllocWithGrow(),a<0)return a;this.w32(i+at,a),c=!0}let d=a*4096+s*4,u=this.r32(d),l=!1;if(u===0){if(!n)return 0;if(u=this.blockAllocWithGrow(),u<0)return c&&(this.w32(i+at,0),this.blockFree(a)),u;this.w32(d,u),l=!0}let f=u*4096+o*4,y=this.r32(f);if(y!==0)return y;if(!n)return 0;let h=this.blockAllocWithGrow();return h<0?(l&&(this.w32(d,0),this.blockFree(u)),c&&(this.w32(i+at,0),this.blockFree(a)),h):(this.w32(f,h),h)}return j}inodeReadData(e,t,n,i){let s=this.inodeOffset(e),o=this.r64(s+T);if(t>=o)return 0;t+i>o&&(i=o-t);let a=0,c=0;for(;i>0;){let d=Math.floor(t/4096),u=t%4096,l=4096-u;l>i&&(l=i);let f=this.inodeBlockMap(e,d,!1);if(f<=0)n.fill(0,c,c+l);else{let y=f*4096+u;n.set(this.u8.subarray(y,y+l),c)}c+=l,t+=l,i-=l,a+=l}return a}inodeWriteData(e,t,n,i){let s=this.inodeOffset(e),o=this.r64(s+T);t>o&&this.zeroOldEofTail(e,o);let a=0,c=0;for(;i>0;){let d=Math.floor(t/4096),u=t%4096,l=4096-u;l>i&&(l=i);let f=this.inodeBlockMap(e,d,!0);if(f<0){if(a===0)return f;break}let y=f*4096+u;this.u8.set(n.subarray(c,c+l),y),c+=l,t+=l,i-=l,a+=l}if(a>0&&t>this.r64(s+T)&&this.w64(s+T,t),a>0){let d=Date.now();this.w64(s+de,d),this.w64(s+X,d),Atomics.add(this.i32,s+ae>>2,1)}return a}zeroInodeRange(e,t,n){for(;t0){let c=a*4096+s;this.u8.fill(0,c,c+o)}t+=o}}zeroOldEofTail(e,t){let n=t%4096;if(n===0)return;let i=Math.floor(t/4096),s=this.inodeBlockMap(e,i,!1);if(s<=0)return;let o=s*4096+n;this.u8.fill(0,o,s*4096+4096)}freeBlocksFrom(e,t){let n=this.inodeOffset(e);for(let o=t;o<10;o++){let a=this.r32(n+Q+o*4);a&&(this.blockFree(a),this.w32(n+Q+o*4,0))}let i=this.r32(n+Ot);if(i){let o=t>10?t-10:0;for(let a=o;a<1024;a++){let c=i*4096+a*4,d=this.r32(c);d&&(this.blockFree(d),this.w32(c,0))}o===0&&(this.blockFree(i),this.w32(n+Ot,0))}let s=this.r32(n+at);if(s){let o=t>1034?t-10-1024:0,a=Math.floor(o/1024);for(let c=a;c<1024;c++){let d=s*4096+c*4,u=this.r32(d);if(!u)continue;let l=c===a?o%1024:0;for(let f=l;f<1024;f++){let y=u*4096+f*4,h=this.r32(y);h&&(this.blockFree(h),this.w32(y,0))}l===0&&(this.blockFree(u),this.w32(d,0))}a===0&&(this.blockFree(s),this.w32(n+at,0))}}inodeTruncate(e,t,n=!1){let i=this.inodeOffset(e),s=this.r64(i+T),o=t!==s;if(t>=s){if(t>s&&this.zeroOldEofTail(e,s),this.w64(i+T,t),o||n){let c=Date.now();this.w64(i+de,c),this.w64(i+X,c),Atomics.add(this.i32,i+ae>>2,1)}return}t%4096!==0&&this.zeroInodeRange(e,t,Math.ceil(t/4096)*4096);let a=Math.ceil(t/4096);if(this.freeBlocksFrom(e,a),this.w64(i+T,t),o||n){let c=Date.now();this.w64(i+de,c),this.w64(i+X,c),Atomics.add(this.i32,i+ae>>2,1)}}validateFileSize(e){if(!Number.isSafeInteger(e)||e<0)throw new A(j);if(e>ct)throw new A(Bt)}validateSeekPosition(e){if(!Number.isSafeInteger(e))throw new A(ci);if(e<0)throw new A(j);if(e>ct)throw new A(Bt)}touchDirectoryMutation(e){let t=this.inodeOffset(e),n=Date.now();this.w64(t+de,n),this.w64(t+X,n);let i=Atomics.add(this.i32,t+ei>>2,1)+1>>>0,s=this.dirIndexes.get(e);s&&(s.mutationSequence=i,s.size=this.r64(t+T))}dirNameKey(e){return dt(e)}dirEntryNameMatches(e,t){if(this.view.getUint16(e+6,!0)!==t.length)return!1;for(let i=0;i=R&&n%4===0&&e+n<=t&&i<=n-R}inodeIsAllocated(e){let t=this.r32(Be);if(e<=0||e>=t)return!1;let n=this.r32(We)*4096;return(Atomics.load(this.i32,(n>>2)+(e>>5))&1<<(e&31))!==0}rebuildDirIndex(e,t,n,i){let s=new Map,o=[],a=0;for(;a4096-u&&(y=4096-u);let h=u;for(;h=R&&o.push({abs:p,recLen:m});h+=m}a+=y}let c={generation:t,mutationSequence:n,size:i,entries:s,free:o};return this.dirIndexes.set(e,c),c}getDirIndex(e){let t=this.inodeOffset(e),n=this.r64(t+T),i=this.r64(t+se),s=Atomics.load(this.i32,t+ei>>2)>>>0,o=this.dirIndexes.get(e);return o&&o.generation===i&&o.mutationSequence===s&&o.size===n?o:(o&&this.dirIndexes.delete(e),n=0;o--){let a=e.free[o];if(!(a.recLen4096-c&&(l=4096-c);let f=c;for(;fn)return-1;a=c,o+=d}return o===n?a:-1}dirAppendEntry(e,t,n,i=-1){let s=this.inodeOffset(e),o=this.r64(s+T),a=Ne(R+t.length),c=o,d=Math.floor(c/4096),u=c%4096,l=0;if(u!==0&&u+a>4096){let h=4096-u,p=0;if(h>=R){if(p=this.inodeBlockMap(e,d,!1),p<=0)return F}else if(i<0&&(i=this.findLastDirEntryInBlock(e,d,u)),i<0)return F;if(l=this.inodeBlockMap(e,d+1,!0),l<0)return l;if(h>=R){let w=p*4096+u;this.w32(w,0),this.view.setUint16(w+4,h,!0),this.view.setUint16(w+6,0,!0)}else{let m=this.view.getUint16(i+4,!0)+h;this.view.setUint16(i+4,m,!0),this.updateDirIndexRecLen(e,i,m)}c=(d+1)*4096,d++,u=0}let f;if(u===0){if(f=l||this.inodeBlockMap(e,d,!0),f<0)return f}else if(f=this.inodeBlockMap(e,d,!1),f<=0)return F;let y=f*4096+u;return this.w32(y,n),this.view.setUint16(y+4,a,!0),this.view.setUint16(y+6,t.length,!0),this.u8.set(t,y+R),this.w64(s+T,c+a),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,n,y,a),0}dirAddEntry(e,t,n){let i=this.getDirIndex(e);if(typeof i=="number")return i;if(i)return this.useDirIndexFreeSlot(i,e,t,n)?0:this.dirAppendEntry(e,t,n);let s=this.inodeOffset(e),o=this.r64(s+T),a=Ne(R+t.length),c=-1,d=0;for(;d4096-l&&(h=4096-l);let p=l;for(;pl+h||v>g-R)return F;if(m===0&&g>=a)return this.w32(w,n),this.view.setUint16(w+6,t.length,!0),this.u8.set(t,w+R),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,n,w,g),0;let z=Ne(R+v),E=g-z;if(m!==0&&E>=a){this.view.setUint16(w+4,z,!0);let S=w+z;return this.w32(S,n),this.view.setUint16(S+4,E,!0),this.view.setUint16(S+6,t.length,!0),this.u8.set(t,S+R),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,n,S,E),0}c=w,p+=g}d+=h}return this.dirAppendEntry(e,t,n,c)}dirRemoveEntry(e,t){let n=this.getDirIndex(e);if(typeof n=="number")return n;if(n){let a=this.dirNameKey(t),c=n.entries.get(a);if(!c)return me;if(this.r32(c.abs)===c.ino&&this.view.getUint16(c.abs+4,!0)===c.recLen&&this.view.getUint16(c.abs+6,!0)===c.nameLen&&this.dirEntryNameMatches(c.abs,t))return this.w32(c.abs,0),n.entries.delete(a),n.free.push({abs:c.abs,recLen:c.recLen}),this.touchDirectoryMutation(e),0;n.entries.delete(a)}let i=this.inodeOffset(e),s=this.r64(i+T),o=0;for(;o4096-c&&(l=4096-c);let f=c;for(;f4096-d&&(f=4096-d);let y=d;for(;y4096-o&&(d=4096-o);let u=o;for(;uo+d||h>y-R)throw new A(F);if(f!==0){if(h===1&&this.u8[l+R]===46){u+=y;continue}if(h===2&&this.u8[l+R]===46&&this.u8[l+R+1]===46){u+=y;continue}return!1}u+=y}i+=d}return!0}dirIsAncestor(e,t){let n=t;for(let i=0;i<8*1024;i++){if(n===e)return!0;if(n===1)return!1;let s=this.dirLookup(n,oi);if(s<0||s===n)throw new A(F);n=s}throw new A(F)}pathResolve(e,t){if(!e.startsWith("/"))return me;let n=1,i=e.split("/").filter(o=>o.length>0),s=0;for(let o=0;o255)return Vn;let c=ue.encode(a),d;this.inodeReadLock(n);try{let f=this.inodeOffset(n);if((this.r32(f+B)&K)!==W)return ze;d=this.dirLookup(n,c)}finally{this.inodeReadUnlock(n)}if(d<0)return d;let u=this.inodeOffset(d);if((this.r32(u+B)&K)===nt&&(!(o===i.length-1)||t)){if(++s>8)return ai;let y=this.r64(u+T),h;if(y<=40)h=dt(this.u8.subarray(u+Q,u+Q+y));else{let p=new Uint8Array(y);this.inodeReadData(d,0,p,y),h=Nt.decode(p)}if(h.startsWith("/")){n=1;let p=h.split("/").filter(m=>m.length>0),w=i.slice(o+1);i.length=0,i.push(...p,...w),o=-1}else{let p=h.split("/").filter(m=>m.length>0),w=i.slice(o+1);i.length=o,i.push(...p,...w),o--}continue}n=d}return n}pathResolveParent(e){if(!e.startsWith("/"))throw new A(j,"Path must be absolute");let t=e.split("/").filter(c=>c.length>0);if(t.length===0)throw new A(j,"Cannot operate on /");let n=t.pop();if(n.length>255)throw new A(Vn);let i="/"+t.join("/"),s=this.pathResolve(i,!0);if(s<0)throw new A(s);let o=this.inodeOffset(s);if((this.r32(o+B)&K)!==W)throw new A(ze);return{parentIno:s,name:n}}fdAlloc(e,t,n){for(let i=0;i>2;if(Atomics.compareExchange(this.i32,o,0,1)===0)return this.w32(s+ti,e),this.w64(s+Ze,0),this.w32(s+ni,t),this.w32(s+ri,n?1:0),this.inodeAddOpenRef(e)?i:(Atomics.store(this.i32,o,0),me)}return si}fdGet(e){if(e<0||e>=sn)return null;let t=256+e*24;return Atomics.load(this.i32,t>>2)?{base:t,ino:this.r32(t+ti),offset:this.r64(t+Ze),flags:this.r32(t+ni),isDir:this.r32(t+ri)!==0}:null}fdFree(e){if(e>=0&&e>2,0)}}buildStat(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+se),dataSequence:this.r32(t+ae),mode:this.r32(t+B),linkCount:this.r32(t+$),size:this.r64(t+T),mtime:this.r64(t+de),ctime:this.r64(t+X),atime:this.r64(t+Pt),uid:this.r32(t+Tt),gid:this.r32(t+Rt)}}namespaceEntryIdentity(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+se),linkCount:this.r32(t+$),mode:this.r32(t+B)}}open(e,t,n=420){return this.withNamespaceLock(()=>this.openUnlocked(e,t,n))}createLazyStub(e,t){return this.withNamespaceLock(()=>{let n=this.openUnlocked(e,Xr|Ct,t);try{let i=this.fdGet(n);if(!i)throw new A(ne);this.inodeWriteLock(i.ino);try{return this.inodeTruncate(i.ino,0,!0),this.buildStat(i.ino)}finally{this.inodeWriteUnlock(i.ino)}}finally{this.closeUnlocked(n)}})}replaceIfIdentity(e,t,n,i,s){return this.withNamespaceLock(()=>{let o=this.pathResolve(e,!0);if(o<0||o!==t)return!1;let a=this.inodeOffset(o);if(this.r64(a+se)!==n||this.r32(a+ae)!==i||(this.r32(a+B)&K)!==It)return!1;this.validateFileSize(s.byteLength),this.inodeWriteLock(o);try{if(this.r64(a+se)!==n||this.r32(a+ae)!==i||this.r64(a+T)!==0)return!1;let c=this.r64(a+de),d=this.r64(a+X);this.inodeTruncate(o,0,!0);let u=s.byteLength>0?this.inodeWriteData(o,0,s,s.byteLength):0;if(u!==s.byteLength)throw this.inodeTruncate(o,0,!0),Atomics.store(this.i32,a+ae>>2,i),this.w64(a+de,c),this.w64(a+X,d),new A(u<0?u:ee);return!0}finally{this.inodeWriteUnlock(o)}})}replaceManyIfIdentities(e,t=[]){return e.length===0&&t.length===0?!0:this.withNamespaceLock(()=>{let n=[],i=new Set,s=a=>{let c=this.pathResolve(a.path,!1);if(c<0||c!==a.expectedIno)return!1;let d=this.inodeOffset(c);return this.r64(d+se)===a.expectedGeneration&&this.r32(d+ae)===a.expectedDataSequence&&this.r32(d+B)===a.expectedMode&&this.r32(d+$)===a.expectedLinkCount&&this.r64(d+T)===a.expectedSize&&this.r32(d+Tt)===a.expectedUid&&this.r32(d+Rt)===a.expectedGid};for(let a of t)if(!s(a))return!1;for(let a of e){this.validateFileSize(a.data.byteLength);let c=-1;for(let d of a.paths){let u=this.pathResolve(d,!0);if(u!==a.expectedIno)continue;let l=this.inodeOffset(u);if(this.r64(l+se)===a.expectedGeneration&&this.r32(l+ae)===a.expectedDataSequence&&(this.r32(l+B)&K)===It&&this.r64(l+T)===0){c=u;break}}if(c<0)return!1;if(i.has(c))throw new A(j,"duplicate conditional replacement inode");i.add(c),n.push({...a,ino:c})}let o=[...i].sort((a,c)=>a-c);for(let a of o)this.inodeWriteLock(a);try{for(let d of n){let u=this.inodeOffset(d.ino);if(this.r64(u+se)!==d.expectedGeneration||this.r32(u+ae)!==d.expectedDataSequence||(this.r32(u+B)&K)!==It||this.r64(u+T)!==0)return!1}for(let d of t)if(!s(d))return!1;let a=n.map(d=>{let u=this.inodeOffset(d.ino);return{ino:d.ino,dataSequence:this.r32(u+ae),mtime:this.r64(u+de),ctime:this.r64(u+X)}}),c=0;try{for(let d of n){c++,this.inodeTruncate(d.ino,0,!0);let u=d.data.byteLength>0?this.inodeWriteData(d.ino,0,d.data,d.data.byteLength):0;if(u!==d.data.byteLength)throw new A(u<0?u:ee)}}catch(d){for(let u=c-1;u>=0;u--){let l=a[u],f=this.inodeOffset(l.ino);this.inodeTruncate(l.ino,0,!0),Atomics.store(this.i32,f+ae>>2,l.dataSequence),this.w64(f+de,l.mtime),this.w64(f+X,l.ctime)}throw d}return!0}finally{for(let a=o.length-1;a>=0;a--)this.inodeWriteUnlock(o[a])}})}openUnlocked(e,t,n=420){let i=t&an,s=(t&Ct)!==0,o=(t&qn)!==0;if(s&&o){let l=this.pathResolve(e,!1);if(l>=0)throw new A(lt);if(l!==me)throw new A(l)}let a=this.pathResolve(e,!0);if(a<0&&a===me&&s){let{parentIno:l,name:f}=this.pathResolveParent(e);this.inodeWriteLock(l);try{let y=ue.encode(f),h=this.dirLookup(l,y);if(h>=0){if(o)throw new A(lt);a=h}else{let p=this.inodeAlloc();if(p<0)throw new A(ee);let w=this.inodeOffset(p);this.w32(w+B,It|n&4095),this.w32(w+$,1),this.w64(w+T,0);let m=Date.now();this.w64(w+Pt,m),this.w64(w+de,m),this.w64(w+X,m);let g=this.dirAddEntry(l,y,p);if(g<0)throw this.inodeFree(p),new A(g);a=p}}finally{this.inodeWriteUnlock(l)}}if(a<0)throw new A(a);let c=this.inodeOffset(a),d=this.r32(c+B);if((d&K)===W&&i!==rt)throw new A(He);if(t&Es&&(d&K)!==W)throw new A(ze);if(t&Mt){if((d&K)===W)throw new A(He);this.inodeWriteLock(a),this.inodeTruncate(a,0,!0),this.inodeWriteUnlock(a)}let u=this.fdAlloc(a,t,!1);if(u<0)throw new A(u);return u}close(e){this.withNamespaceLock(()=>this.closeUnlocked(e))}closeUnlocked(e){let t=this.fdGet(e);if(!t)throw new A(ne);this.fdFree(e),this.inodeDropOpenRef(t.ino)}read(e,t){let n=this.fdGet(e);if(!n)throw new A(ne);let i=this.inodeOffset(n.ino);if((this.r32(i+B)&K)===W)throw new A(He);this.inodeReadLock(n.ino);try{let o=this.inodeReadData(n.ino,n.offset,t,t.length),a=256+e*24;return this.w64(a+Ze,n.offset+o),o}finally{this.inodeReadUnlock(n.ino)}}readAt(e,t,n){let i=this.fdGet(e);if(!i)throw new A(ne);let s=this.inodeOffset(i.ino);if((this.r32(s+B)&K)===W)throw new A(He);this.validateSeekPosition(n),this.inodeReadLock(i.ino);try{return this.inodeReadData(i.ino,n,t,t.length)}finally{this.inodeReadUnlock(i.ino)}}write(e,t){let n=this.fdGet(e);if(!n)throw new A(ne);if((n.flags&an)===rt)throw new A(ne);this.inodeWriteLock(n.ino);try{let s=n.offset;if(n.flags&Ss){let c=this.inodeOffset(n.ino);s=this.r64(c+T)}if(!Number.isSafeInteger(s)||s<0)throw new A(j);if(s>ct||t.length>ct-s)throw new A(Bt);let o=this.inodeWriteData(n.ino,s,t,t.length);if(o<0)return o;let a=256+e*24;return this.w64(a+Ze,s+o),o}finally{this.inodeWriteUnlock(n.ino)}}writeAt(e,t,n){let i=this.fdGet(e);if(!i)throw new A(ne);if((i.flags&an)===rt)throw new A(ne);this.validateSeekPosition(n),this.inodeWriteLock(i.ino);try{if(n>ct||t.length>ct-n)throw new A(Bt);return this.inodeWriteData(i.ino,n,t,t.length)}finally{this.inodeWriteUnlock(i.ino)}}lseek(e,t,n){let i=this.fdGet(e);if(!i)throw new A(ne);let s;if(n===zs)s=t;else if(n===bs)s=i.offset+t;else if(n===As){let a=this.inodeOffset(i.ino);s=this.r64(a+T)+t}else throw new A(j);this.validateSeekPosition(s);let o=256+e*24;return this.w64(o+Ze,s),s}ftruncate(e,t){let n=this.fdGet(e);if(!n)throw new A(ne);if((n.flags&an)===rt)throw new A(ne);this.validateFileSize(t),this.inodeWriteLock(n.ino);try{this.inodeTruncate(n.ino,t,!0)}finally{this.inodeWriteUnlock(n.ino)}}fstat(e){let t=this.fdGet(e);if(!t)throw new A(ne);this.inodeReadLock(t.ino);try{return this.buildStat(t.ino)}finally{this.inodeReadUnlock(t.ino)}}stat(e){return this.withNamespaceLock(()=>this.statUnlocked(e))}statUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new A(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}lstat(e){return this.withNamespaceLock(()=>this.lstatUnlocked(e))}lstatUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new A(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}unlink(e){return this.withNamespaceLock(()=>this.unlinkUnlocked(e))}unlinkUnlocked(e){let{parentIno:t,name:n}=this.pathResolveParent(e),i=ue.encode(n),s=e.length>1&&e.endsWith("/");this.inodeWriteLock(t);try{let o=this.dirLookup(t,i);if(o<0)throw new A(o);let a=this.inodeOffset(o),c=this.r32(a+B);if(s&&(c&K)!==W)throw new A(ze);if((c&K)===W)throw new A(He);let d=this.namespaceEntryIdentity(o),u=this.dirRemoveEntry(t,i);if(u<0)throw new A(u);let l=!1;this.inodeWriteLock(o);try{l=this.inodeDropLinkRefLocked(o)}finally{this.inodeWriteUnlock(o)}return l&&this.inodeFree(o),d}finally{this.inodeWriteUnlock(t)}}rename(e,t){return this.withNamespaceLock(()=>this.renameUnlocked(e,t))}renameUnlocked(e,t){let{parentIno:n,name:i}=this.pathResolveParent(e),{parentIno:s,name:o}=this.pathResolveParent(t);if(Zn(i)||Zn(o))throw new A(j);let a=ue.encode(i),c=ue.encode(o),d=e.length>1&&e.endsWith("/"),u=t.length>1&&t.endsWith("/"),l=Math.min(n,s),f=Math.max(n,s);this.inodeWriteLock(l),l!==f&&this.inodeWriteLock(f);try{let y=this.dirLookup(n,a);if(y<0)throw new A(y);let h=this.inodeOffset(y),w=this.r32(h+B)&K,m=this.namespaceEntryIdentity(y);if((d||u)&&w!==W)throw new A(ze);if(w===W&&this.dirIsAncestor(y,s))throw new A(j);let g=this.dirLookup(s,c),v=!1,z;if(g>=0){if(g===y)return{source:m,replaced:m};z=this.namespaceEntryIdentity(g);let S=this.inodeOffset(g),k=this.r32(S+B)&K;if(w===W&&k!==W)throw new A(ze);if(w!==W&&k===W)throw new A(He);let x=!1,I=g===n||g===s;I||this.inodeWriteLock(g);try{if(k===W&&!this.dirIsEmpty(g))throw new A(jn);let _=this.dirReplaceEntryIno(s,c,y);if(_<0)throw new A(_);x=k===W?this.inodeOrphanLocked(g):this.inodeDropLinkRefLocked(g)}finally{I||this.inodeWriteUnlock(g)}x&&this.inodeFree(g),v=k===W}else{let S=this.dirAddEntry(s,c,y);if(S<0)throw new A(S)}let E=this.dirRemoveEntry(n,a);if(E<0)throw new A(E);if(w===W){if(n!==s){let S=this.inodeOffset(n);this.w32(S+$,this.r32(S+$)-1);let b=this.inodeOffset(s);this.w32(b+$,this.r32(b+$)+1),this.inodeWriteLock(y);try{let k=this.dirReplaceEntryIno(y,oi,s);if(k<0)throw new A(k);this.w64(h+X,Date.now())}finally{this.inodeWriteUnlock(y)}}if(v){let S=this.inodeOffset(s);this.w32(S+$,this.r32(S+$)-1)}}else if(v){let S=this.inodeOffset(s);this.w32(S+$,this.r32(S+$)-1)}return{source:m,replaced:z}}finally{l!==f&&this.inodeWriteUnlock(f),this.inodeWriteUnlock(l)}}mkdir(e,t=493){this.withNamespaceLock(()=>this.mkdirUnlocked(e,t))}mkdirUnlocked(e,t=493){let{parentIno:n,name:i}=this.pathResolveParent(e),s=ue.encode(i);this.inodeWriteLock(n);try{if(this.dirLookup(n,s)>=0)throw new A(lt);let a=this.inodeAlloc();if(a<0)throw new A(ee);let c=this.inodeOffset(a);this.w32(c+B,W|t),this.w32(c+$,2),this.w64(c+T,0);let d=Date.now();this.w64(c+Pt,d),this.w64(c+de,d),this.w64(c+X,d);let u=this.blockAllocWithGrow();if(u<0)throw this.inodeFree(a),new A(ee);this.w32(c+Q,u);let l=u*4096,f=Ne(R+1),y=Ne(R+2);this.w32(l,a),this.view.setUint16(l+4,f,!0),this.view.setUint16(l+6,1,!0),this.u8[l+R]=46;let h=l+f;this.w32(h,n),this.view.setUint16(h+4,y,!0),this.view.setUint16(h+6,2,!0),this.u8[h+R]=46,this.u8[h+R+1]=46,this.w64(c+T,f+y);let p=this.dirAddEntry(n,s,a);if(p<0)throw this.blockFree(u),this.inodeFree(a),new A(p);let w=this.inodeOffset(n);this.w32(w+$,this.r32(w+$)+1)}finally{this.inodeWriteUnlock(n)}}rmdir(e){this.withNamespaceLock(()=>this.rmdirUnlocked(e))}rmdirUnlocked(e){let{parentIno:t,name:n}=this.pathResolveParent(e);if(Zn(n))throw new A(j);let i=ue.encode(n);this.inodeWriteLock(t);try{let s=this.dirLookup(t,i);if(s<0)throw new A(s);let o=this.inodeOffset(s);if((this.r32(o+B)&K)!==W)throw new A(ze);let c=!1;this.inodeWriteLock(s);try{if(!this.dirIsEmpty(s))throw new A(jn);let u=this.dirRemoveEntry(t,i);if(u<0)throw new A(u);c=this.inodeOrphanLocked(s)}finally{this.inodeWriteUnlock(s)}c&&this.inodeFree(s);let d=this.inodeOffset(t);this.w32(d+$,this.r32(d+$)-1)}finally{this.inodeWriteUnlock(t)}}symlink(e,t){this.withNamespaceLock(()=>this.symlinkUnlocked(e,t))}symlinkUnlocked(e,t){let{parentIno:n,name:i}=this.pathResolveParent(t),s=ue.encode(i),o=ue.encode(e);this.inodeWriteLock(n);try{if(this.dirLookup(n,s)>=0)throw new A(lt);let c=this.inodeAlloc();if(c<0)throw new A(ee);let d=this.inodeOffset(c);if(this.w32(d+B,nt|511),this.w32(d+$,1),o.length<=40)this.u8.set(o,d+Q),this.w64(d+T,o.length);else{this.w64(d+T,0);let l=this.inodeWriteData(c,0,o,o.length);if(l!==o.length)throw l>0&&this.inodeTruncate(c,0),this.inodeFree(c),new A(l<0?l:ee)}let u=this.dirAddEntry(n,s,c);if(u<0)throw o.length<=40?(this.u8.fill(0,d+Q,d+Q+40),this.w64(d+T,0)):this.inodeTruncate(c,0),this.inodeFree(c),new A(u)}finally{this.inodeWriteUnlock(n)}}chmod(e,t){this.withNamespaceLock(()=>this.chmodUnlocked(e,t))}chmodUnlocked(e,t){let n=this.pathResolve(e,!0);if(n<0)throw new A(n);this.inodeWriteLock(n);try{let i=this.inodeOffset(n),s=this.r32(i+B);this.w32(i+B,s&K|t&4095),this.w64(i+X,Date.now())}finally{this.inodeWriteUnlock(n)}}fchmod(e,t){let n=this.fdGet(e);if(!n)throw new A(ne);this.inodeWriteLock(n.ino);try{let i=this.inodeOffset(n.ino),s=this.r32(i+B);this.w32(i+B,s&K|t&4095),this.w64(i+X,Date.now())}finally{this.inodeWriteUnlock(n.ino)}}chown(e,t,n){this.withNamespaceLock(()=>this.chownUnlocked(e,t,n))}chownUnlocked(e,t,n){let i=this.pathResolve(e,!0);if(i<0)throw new A(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,n)}finally{this.inodeWriteUnlock(i)}}fchown(e,t,n){let i=this.fdGet(e);if(!i)throw new A(ne);this.inodeWriteLock(i.ino);try{this.chownInodeUnlocked(i.ino,t,n)}finally{this.inodeWriteUnlock(i.ino)}}lchown(e,t,n){this.withNamespaceLock(()=>this.lchownUnlocked(e,t,n))}lchownUnlocked(e,t,n){let i=this.pathResolve(e,!1);if(i<0)throw new A(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,n)}finally{this.inodeWriteUnlock(i)}}chownInodeUnlocked(e,t,n){let i=this.inodeOffset(e);t!==Yr&&this.w32(i+Tt,t),n!==Yr&&this.w32(i+Rt,n);let s=this.r32(i+B);(s&K)===It&&(s&vs)!==0&&this.w32(i+B,s&~(gs|ws)),this.w64(i+X,Date.now())}utimens(e,t,n,i,s){this.withNamespaceLock(()=>this.utimensUnlocked(e,t,n,i,s))}utimensUnlocked(e,t,n,i,s){let o=this.pathResolve(e,!0);if(o<0)throw new A(o);this.inodeWriteLock(o);try{let a=this.inodeOffset(o),c=1073741823,d=1073741822,u=Date.now();if(n!==d){let l=n===c?u:t*1e3+Math.floor(n/1e6);this.w64(a+Pt,l)}if(s!==d){let l=s===c?u:i*1e3+Math.floor(s/1e6);this.w64(a+de,l)}this.w64(a+X,u)}finally{this.inodeWriteUnlock(o)}}link(e,t){return this.withNamespaceLock(()=>this.linkUnlocked(e,t))}linkUnlocked(e,t){let n=this.pathResolve(e,!1);if(n<0)throw new A(n);let i=this.inodeOffset(n);if((this.r32(i+B)&K)===W)throw new A(ks);let{parentIno:o,name:a}=this.pathResolveParent(t),c=ue.encode(a);this.inodeWriteLock(o);try{if(this.dirLookup(o,c)>=0)throw new A(lt);let u=this.dirAddEntry(o,c,n);if(u<0)throw new A(u);this.inodeWriteLock(n);try{let l=this.r32(i+$);this.w32(i+$,l+1),this.w64(i+X,Date.now())}finally{this.inodeWriteUnlock(n)}return{...this.namespaceEntryIdentity(n),linkCount:this.r32(i+$)}}finally{this.inodeWriteUnlock(o)}}readlink(e){return this.withNamespaceLock(()=>this.readlinkUnlocked(e))}readlinkUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new A(t);return this.readSymlinkInodeUnlocked(t)}readSymlinkInodeUnlocked(e){let t=this.inodeOffset(e);if((this.r32(t+B)&K)!==nt)throw new A(j);let i=this.r64(t+T);if(i<=40)return dt(this.u8.subarray(t+Q,t+Q+i));this.inodeReadLock(e);try{let s=new Uint8Array(i);return this.inodeReadData(e,0,s,i),Nt.decode(s)}finally{this.inodeReadUnlock(e)}}opendir(e){return this.withNamespaceLock(()=>this.opendirUnlocked(e))}opendirUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new A(t);let n=this.inodeOffset(t);if((this.r32(n+B)&K)!==W)throw new A(ze);let s=this.fdAlloc(t,rt,!0);if(s<0)throw new A(s);return s}readdirEntry(e){return this.withNamespaceLock(()=>this.readdirEntryUnlocked(e))}readdirEntryUnlocked(e){let t=this.fdGet(e);if(!t||!t.isDir)throw new A(ne);let n=this.inodeOffset(t.ino),i=this.r64(n+T);for(;t.offset=this.r32(Be))throw new A(F);let p=this.r32(We)*4096;if((this.r32(p+(u>>5)*4)&1<<(u&31))===0)throw new A(F);let m=dt(this.u8.subarray(d+R,d+R+f)),g=this.buildStat(u);return this.w64(h+Ze,y),t.offset=y,{name:m,stat:g}}return null}closedir(e){this.close(e)}readdir(e){let t=this.opendir(e),n=[];try{let i;for(;(i=this.readdirEntry(t))!==null;)i.name!=="."&&i.name!==".."&&n.push(i.name)}finally{this.closedir(t)}return n}writeFile(e,t){let n=typeof t=="string"?ue.encode(t):t,i=this.open(e,Xr|Ct|Mt);try{this.write(i,n)}finally{this.close(i)}}readFile(e){let t=this.open(e,rt);try{let n=this.fstat(t),i=new Uint8Array(n.size);return this.read(t,i),i}finally{this.close(t)}}readFileText(e){return Nt.decode(this.readFile(e))}};function di(r,e){let t=new Map,n=new Map;for(let o of r){if(t.has(o.path))throw new Error(`${e} duplicates path ${o.path}`);if(t.set(o.path,o),o.type==="file"){if(!o.inodeGroup)throw new Error(`${e} file ${o.path} has no inode group`);if(n.has(o.inodeGroup))throw new Error(`${e} inode group ${o.inodeGroup} has multiple files`);n.set(o.inodeGroup,o)}}let i=new Set,s=new Map;for(let o of r){if(o.type!=="hardlink"||s.has(o.path))continue;let a=[],c=o,d;for(;c.type==="hardlink";){let l=s.get(c.path);if(l){d=l;break}if(i.has(c.path))throw new Error(`${e} hardlink cycle reaches ${c.path}`);if(i.add(c.path),a.push(c),!c.target)throw new Error(`${e} hardlink ${c.path} has no target`);let f=t.get(c.target);if(!f)throw new Error(`${e} hardlink ${c.path} target ${c.target} is missing`);if(f.type!=="file"&&f.type!=="hardlink"||!c.inodeGroup||f.inodeGroup!==c.inodeGroup||f.size!==c.size||f.mode!==c.mode)throw new Error(`${e} hardlink ${c.path} has an invalid target`);c=f}d??=c.type==="file"?c:void 0;let u=n.get(o.inodeGroup??"");if(!d||d!==u)throw new Error(`${e} hardlink ${o.path} does not resolve to its inode`);for(let l=a.length-1;l>=0;l-=1){let f=a[l];if(n.get(f.inodeGroup??"")!==d)throw new Error(`${e} hardlink ${f.path} does not resolve to its inode`);i.delete(f.path),s.set(f.path,d)}}return{canonicalByGroup:n,canonicalTargetByPath:s}}var le={maxArchiveBytes:268435456,maxExpandedBytes:268435456,maxPayloadBytes:268435456,maxEntries:1e5,maxPathBytes:4096,maxSymlinkTargetBytes:65536,maxStringBytes:8192,maxTransportsPerTree:8,maxActivationCapabilities:32,maxActivationRoots:64,maxActivationCapabilityBytes:255},Se={maxArchiveBytes:512*1024*1024,maxExpandedBytes:512*1024*1024,maxPayloadBytes:512*1024*1024,maxEntries:1e5,maxGroups:512};function li(r,e="Deferred tree collection"){for(let[t,n]of Object.entries(r))if(!Number.isSafeInteger(n)||n<0)throw new Error(`${e} ${t} usage is invalid`);if(r.groups>Se.maxGroups)throw new Error(`${e} exceeds the ${Se.maxGroups}-group cap`);if(r.archiveBytes>Se.maxArchiveBytes)throw new Error(`${e} exceeds the archive-byte cap`);if(r.expandedBytes>Se.maxExpandedBytes)throw new Error(`${e} exceeds the expansion cap`);if(r.payloadBytes>Se.maxPayloadBytes)throw new Error(`${e} exceeds the payload-byte cap`);if(r.entries>Se.maxEntries)throw new Error(`${e} exceeds the entry-count cap`)}var ui=Object.freeze({prefix:"/opt/kandelo/homebrew",cellar:"/opt/kandelo/homebrew/Cellar",repository:"/opt/kandelo/homebrew",stableEntrypoint:"/usr/bin/brew"});var fi=1e5,Ts=4096;var ut=ui.prefix,pi=[["@@HOMEBREW_PREFIX@@",ut],["@@HOMEBREW_CELLAR@@",`${ut}/Cellar`],["@@HOMEBREW_REPOSITORY@@",ut],["@@HOMEBREW_LIBRARY@@",`${ut}/Library`],["@@HOMEBREW_PERL@@",`${ut}/opt/perl/bin/perl`]],Yn="@@HOMEBREW_JAVA@@",Rs=/^openjdk(?:@\d+(?:\.\d+)*)?/,ft=new TextEncoder,Bs=[...pi.map(([r])=>r),Yn].map(r=>({placeholder:r,bytes:ft.encode(r)}));function mi(r){let e=Cs(r),t=e.changed_files;if(t!=null&&!Array.isArray(t))throw new Error("INSTALL_RECEIPT.json changed_files must be an array or null when present");let n=Array.isArray(t)?t:[];if(n.length>fi)throw new Error(`INSTALL_RECEIPT.json declares ${n.length} changed files, limit ${fi}`);let i=[],s=new Set;for(let[o,a]of n.entries()){if(typeof a!="string")throw new Error(`INSTALL_RECEIPT.json changed_files[${o}] is not a string`);if(Ms(a,"Homebrew changed file"),s.has(a))throw new Error(`INSTALL_RECEIPT.json repeats changed file ${a}`);s.add(a),i.push(a)}return{changedFiles:i,runtimeDependencies:e.runtime_dependencies}}function Cs(r){let e;try{e=JSON.parse(new TextDecoder("utf-8",{fatal:!0}).decode(r))}catch(t){throw new Error("INSTALL_RECEIPT.json is not valid UTF-8 JSON: "+Fs(t))}if(typeof e!="object"||e===null||Array.isArray(e))throw new Error("INSTALL_RECEIPT.json must contain an object");return e}function gi(r,e,t){let n=r;for(let[o,a]of pi)n=yi(n,ft.encode(o),ft.encode(a));let i=ft.encode(Yn);if(hi(n,i)){let o=Ns(e.runtimeDependencies);if(o===void 0)throw new Error(`Homebrew changed file ${t} uses ${Yn} without exactly one OpenJDK runtime dependency`);n=yi(n,i,ft.encode(o))}let s=Bs.find(({bytes:o})=>hi(n,o));if(s!==void 0)throw new Error(`Homebrew changed file ${t} retains ${s.placeholder}`);return n}function Ns(r){if(!Array.isArray(r))return;let e=[];for(let n of r){if(typeof n!="object"||n===null||Array.isArray(n))continue;let i=n,s=typeof i.full_name=="string"?i.full_name.split("/").at(-1):typeof i.name=="string"?i.name.split("/").at(-1):void 0,o=s===void 0?null:Rs.exec(s);s!==void 0&&o?.[0]===s&&e.push(s)}let t=[...new Set(e)];return t.length===1?`${ut}/opt/${t[0]}/libexec`:void 0}function Ms(r,e){if(r.length===0||r.startsWith("/")||r.includes("\\")||r.includes("\0")||$s(r)||ft.encode(r).byteLength>Ts||r.split("/").some(t=>t===""||t==="."||t===".."))throw new Error(`${e} has an unsafe path segment: ${r}`)}function $s(r){for(let e=0;e57343)){if(t<=56319&&e+1=56320&&r.charCodeAt(e+1)<=57343){e+=1;continue}return!0}}return!1}function hi(r,e){if(e.byteLength===0||e.byteLength>r.byteLength)return!1;e:for(let t=0;t<=r.byteLength-e.byteLength;t+=1){for(let n=0;nkn||r.includes("\0")||r.includes("\\"))throw new Error(`Lazy archive mount prefix must be an absolute POSIX path: ${JSON.stringify(r)}`);let e=r.replace(/\/+$/,"");if(e==="")return"/";if(e.slice(1).split("/").some(n=>n===""||n==="."||n===".."))throw new Error(`Lazy archive mount prefix is not canonical: ${JSON.stringify(r)}`);return e}function Ga(r,e,t,n){let i=qt(t),s=new Map,o=e.map(a=>{let c=a.fileName,d=`Lazy archive ${JSON.stringify(r)} member ${JSON.stringify(c)}`;if(c.length===0)throw new Error(`${d} has an empty path`);if(c.includes("\0"))throw new Error(`${d} contains a NUL byte`);if(c.includes("\\"))throw new Error(`${d} contains a backslash`);if(c.startsWith("/")||/^[A-Za-z]:\//.test(c))throw new Error(`${d} must be relative, not absolute`);if(a.isDirectory&&a.isSymlink)throw new Error(`${d} has conflicting directory and symlink types`);if(a.isDirectory!==c.endsWith("/"))throw new Error(`${d} has inconsistent directory metadata`);let u=a.isDirectory?c.slice(0,-1):c,l=u.split("/");if(u.length===0||l.some(f=>f===""||f==="."||f===".."))throw new Error(`${d} is not a canonical relative POSIX path`);if(s.has(u))throw new Error(`${d} collides with another member at ${JSON.stringify(u)}`);if(a.isSymlink&&!n?.has(c))throw new Error(`Lazy archive symlink target was not provided: ${c}`);return s.set(u,a),{entry:a,archivePath:u,vfsPath:i==="/"?`/${u}`:`${i}/${u}`}});for(let{archivePath:a}of o){let c=a.split("/");for(let d=1;dqe)throw new Error(`VFS image metadata exceeds ${qe} bytes`);let e;try{e=JSON.parse(new TextDecoder().decode(r))}catch(t){let n=t instanceof Error?t.message:String(t);throw new Error(`Invalid VFS image metadata JSON: ${n}`)}return zr(e)}function Za(r){if(r===null)return new Uint8Array(0);let e=zr(r),t=new TextEncoder().encode(JSON.stringify(e));if(t.byteLength>qe)throw new Error(`VFS image metadata exceeds ${qe} bytes`);return t}function Ha(r,e=Hi){if(!Number.isSafeInteger(e)||eHi)throw new Error("VFS image decompressed byte bound is invalid");if(r.byteLength>=Gt.length&&r[0]===Gt[0]&&r[1]===Gt[1]&&r[2]===Gt[2]&&r[3]===Gt[3]){Va(r,e);let t=yc(r);if(t.byteLength>e)throw new Error("zstd VFS image exceeds its decompressed byte bound");return t}if(r.byteLength>e)throw new Error("VFS image exceeds its decompressed byte bound");return r}function Va(r,e){let t=new DataView(r.buffer,r.byteOffset,r.byteLength),n=0,i=0,s=0,o=(d,u)=>{if(d<0||n+d>r.byteLength)throw new Error(`zstd VFS image has a truncated ${u}`)},a=d=>{if(i+=d,!Number.isSafeInteger(i)||i>e)throw new Error("zstd VFS image exceeds its decompressed byte bound")},c=d=>{o(d,"frame header");let u=0n;for(let l=0;l=_a&&d<=Pa){o(4,"skippable frame size");let v=t.getUint32(n,!0);n+=4,o(v,"skippable frame"),n+=v;continue}if(d!==Ia)throw new Error("zstd VFS image contains an invalid frame magic");s++,o(1,"frame descriptor");let u=r[n++];if((u&8)!==0)throw new Error("zstd VFS image uses a reserved frame descriptor bit");let l=(u&32)!==0,f=(u&4)!==0,y=[0,1,2,4][u&3],h=u>>>6,p;if(!l){o(1,"window descriptor");let v=r[n++],z=10+(v>>>3),E=1n<>3n)*BigInt(v&7)}o(y,"dictionary identity"),n+=y;let w=h===0?l?1:0:h===1?2:h===2?4:8,m;if(w>0&&(m=c(w),h===1&&(m+=256n),l&&(p=m)),p!==void 0&&p>BigInt(e))throw new Error("zstd VFS image exceeds its decompressed window bound");if(m!==void 0&&m>BigInt(e))throw new Error("zstd VFS image exceeds its decompressed byte bound");let g=0;for(;;){o(3,"block header");let v=r[n]|r[n+1]<<8|r[n+2]<<16;n+=3;let z=(v&1)!==0,E=v>>>1&3,S=v>>>3;if(E===3||S>Zi)throw new Error("zstd VFS image contains an invalid block header");if(g+=E===2?Zi:S,!Number.isSafeInteger(g)||m===void 0&&g>e)throw new Error("zstd VFS image exceeds its decompressed byte bound");let b=E===1?1:S;if(o(b,"block payload"),n+=b,z)break}if(f&&(o(4,"content checksum"),n+=4),m!==void 0&&m>BigInt(g))throw new Error("zstd VFS image frame content exceeds its block bound");a(m===void 0?g:Number(m))}if(s===0)throw new Error("zstd VFS image contains no data frame")}function gn(r,e){let t=Ha(r,e);if(t.byteLengthZt)throw new Error(`VFS image lazy metadata exceeds ${Zt} bytes`);if(r.byteLengthHt)throw new Error(`VFS image lazy archive metadata exceeds ${Ht} bytes`);if(r.byteLength=0?n:void 0}function Ya(r){return r===408||r===429||r>=500&&r<=599}function Xa(r,e=Date.now()){let t=r?.get("retry-after")?.trim();if(!t)return;let n;if(/^\d+$/.test(t))n=Number(t)*1e3;else{let i=Date.parse(t);if(!Number.isFinite(i))return;n=Math.max(0,i-e)}if(!(!Number.isSafeInteger(n)||n<0))return Math.min(n,ao)}function Ja(r){if(!(typeof r!="object"||r===null||!("cause"in r)))return r.cause}function co(r){if(!(typeof r!="object"||r===null||!("name"in r)))return typeof r.name=="string"?r.name:void 0}function lo(r){if(!(typeof r!="object"||r===null||!("code"in r)))return typeof r.code=="string"?r.code:void 0}function uo(r,e){let t=new Set,n=r;for(let i=0;n!==void 0&&i<8;i+=1){if(t.has(n))return!1;if(t.add(n),e(n))return!0;n=Ja(n)}return!1}function fo(r){return uo(r,e=>co(e)==="AbortError"||lo(e)==="ABORT_ERR")}function Qa(r){return fo(r)?!1:uo(r,e=>{let t=co(e),n=lo(e);return e instanceof TypeError||t==="NetworkError"||t==="TimeoutError"||n!==void 0&&Ua.has(n)})}function ec(r,e){if(r instanceof En){if(!Ya(r.status))return null;if(r.retryAfterMs!==void 0)return r.retryAfterMs}else if(!Qa(r))return null;return Math.min(Da*2**e,ao)}function J(r){if(r?.aborted)throw r.reason}function tc(r,e){return J(e),r===0?Promise.resolve():new Promise((t,n)=>{let i=setTimeout(()=>a(!1),r),s=()=>a(!0,e.reason),o=!1;function a(c,d){o||(o=!0,clearTimeout(i),e?.removeEventListener("abort",s),c?n(d):t())}e?.addEventListener("abort",s,{once:!0}),e?.aborted&&s()})}async function fr(r,e){try{await r.body?.cancel(e)}catch{}}function nc(r,e){if(r.length===1)return r[0];let t=new Uint8Array(e),n=0;for(let i of r)t.set(i,n),n+=i.byteLength;return t}function Yt(r){if(r===void 0)return;if(typeof r!="object"||r===null||Array.isArray(r))throw new Error("Lazy archive integrity must be an object");let e=r;if(Object.keys(e).length!==2||!("sha256"in e)||!("bytes"in e))throw new Error("Lazy archive integrity has unexpected fields");if(typeof e.sha256!="string"||!mr.test(e.sha256))throw new Error("Lazy archive integrity has an invalid SHA-256 digest");if(!Number.isSafeInteger(e.bytes)||Number(e.bytes)<=0||Number(e.bytes)>Vi)throw new Error(`Lazy archive integrity byte count must be between 1 and ${Vi}`);return{sha256:e.sha256,bytes:Number(e.bytes)}}function Me(r,e,t){if(typeof r!="object"||r===null||Array.isArray(r))throw new Error(`${t} must be an object`);let n=r;if(Object.keys(n).length!==e.length||e.some(s=>!Object.prototype.hasOwnProperty.call(n,s)))throw new Error(`${t} has unexpected or missing fields`);return n}function wr(r,e,t,n){if(typeof r!="object"||r===null||Array.isArray(r))throw new Error(`${n} must be an object`);let i=r,s=new Set(e);if(Object.keys(i).some(o=>!s.has(o))||t.some(o=>!Object.prototype.hasOwnProperty.call(i,o)))throw new Error(`${n} has unexpected or missing fields`);return i}function xe(r,e,t,n){if(!Array.isArray(r)||r.lengthn)throw new Error(`${e} must contain ${t} to ${n} items`);return r}function he(r,e,t){if(typeof r!="string"||r.length===0||r.includes("\0")||new TextEncoder().encode(r).byteLength>t)throw new Error(`${e} is invalid or exceeds ${t} bytes`);return r}function re(r,e,t,n){if(!Number.isSafeInteger(r)||Number(r)n)throw new Error(`${e} must be an integer between ${t} and ${n}`);return Number(r)}function zn(r,e=1){let t=r,n=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.source!==void 0,i=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.modePolicy!==void 0,s=Me(r,["decoder","mediaType","sha256","bytes","expandedBytes","sourceEntryCount","transports",...i?["modePolicy"]:[],...n?["source"]:[]],"Lazy tree content"),o=s.decoder==="zip-v1"?"application/zip":s.decoder==="homebrew-bottle-tar-gzip-v1"?"application/vnd.oci.image.layer.v1.tar+gzip":null;if(o===null||s.mediaType!==o)throw new Error("Lazy tree decoder and media type are inconsistent");let a=Yt({sha256:s.sha256,bytes:s.bytes});if(!a)throw new Error("Lazy tree integrity is required");let c=xe(s.transports,"Lazy tree transports",e,le.maxTransportsPerTree).map((y,h)=>he(y,`Lazy tree transport ${h}`,Er));if(new Set(c).size!==c.length)throw new Error("Lazy tree transports contain duplicates");let d=re(s.expandedBytes,"Lazy tree expanded byte count",0,Ca),u=re(s.sourceEntryCount,"Lazy tree source entry count",1,gt),l=n?oc(s.source,s.decoder):void 0,f=i?s.modePolicy:void 0;if(f!==void 0&&(f!=="portable-posix-v1"||s.decoder!=="zip-v1"||n))throw new Error("Lazy tree mode policy is invalid for its decoder");if(l!==void 0&&l.entries.length!==u)throw new Error("Lazy tree source inventory count differs from its content");return{decoder:s.decoder,mediaType:o,sha256:a.sha256,bytes:a.bytes,expandedBytes:d,sourceEntryCount:u,transports:c,...f===void 0?{}:{modePolicy:f},...l===void 0?{}:{source:l}}}function ho(r){let e={groups:r.length,archiveBytes:0,expandedBytes:0,payloadBytes:0,entries:0};for(let t of r)t.content===void 0||t.inventory===void 0||(e.archiveBytes+=t.content.bytes,e.expandedBytes+=t.content.expandedBytes,e.payloadBytes+=t.inventory.filter(n=>n.type==="file").reduce((n,i)=>n+i.size,0),e.entries+=t.inventory.length+(t.content.source?.entries.length??0));return e}function vr(r){li(r,"Serialized lazy tree collection")}function rc(r){vr(ho(r))}function ic(r){let e=new Map;for(let t of r){let n=t.activation?.atomicGroup;if(n===void 0)continue;if(!mt(n))throw new Error(`Serialized lazy atomic activation group ${n.id} is unsealed`);let i=e.get(n.id);if(i===void 0)i={expectedCount:n.expectedCount,cohortSha256:n.cohortSha256,members:new Set,descriptors:new Set},e.set(n.id,i);else if(i.expectedCount!==n.expectedCount||i.cohortSha256!==n.cohortSha256)throw new Error(`Serialized lazy atomic activation group ${n.id} has inconsistent seals`);if(i.members.has(n.member)||i.descriptors.has(n.descriptorSha256))throw new Error(`Serialized lazy atomic activation group ${n.id} duplicates a member`);i.members.add(n.member),i.descriptors.add(n.descriptorSha256)}for(let[t,n]of e)if(n.members.size!==n.expectedCount)throw new Error(`Serialized lazy atomic activation group ${t} has ${n.members.size} of ${n.expectedCount} members`)}function Qi(r){for(let[e,t]of r.entries())if(t.kind===jt||t.kind===gr||t.kind===je)go(t,t.kind);else if(t.kind===Vt)Sr(t,!1);else throw new Error(`Serialized lazy archive group ${e} has an unsupported kind`);rc(r),ic(r)}function oc(r,e){if(e!=="homebrew-bottle-tar-gzip-v1")throw new Error("Lazy tree source inventory is valid only for original bottles");let t=Me(r,["schema","kind","entries"],"Lazy tree source inventory");if(t.schema!==1||t.kind!=="homebrew-bottle-tar-gzip-v1")throw new Error("Lazy tree source inventory has an unsupported identity");let n=new Map,i=xe(t.entries,"Lazy tree source entries",1,gt).map((o,a)=>{let c=o,d=typeof c=="object"&&c!==null&&!Array.isArray(c)?c.type:void 0,u=d==="directory"||d==="file"?["sourcePath","type","mode","size"]:d==="symlink"||d==="hardlink"?["sourcePath","type","mode","size","target"]:null;if(u===null)throw new Error(`Lazy tree source entry ${a} has invalid type`);let l=Me(o,u,`Lazy tree source entry ${a}`),f=ye(l.sourcePath,!1,`Lazy tree source entry ${a} path`);if(n.has(f))throw new Error(`Lazy tree source inventory duplicates ${f}`);let y=re(l.mode,`Lazy tree source entry ${f} mode`,0,4095),h=re(l.size,`Lazy tree source entry ${f} size`,0,Sn),p;if((d==="directory"||d==="symlink"||d==="hardlink")&&h!==0)throw new Error(`Lazy tree source ${f} has payload for ${String(d)}`);d==="symlink"?p=he(l.target,`Lazy tree source symlink ${f} target`,so):d==="hardlink"&&(p=ye(l.target,!1,`Lazy tree source hardlink ${f} target`));let w={sourcePath:f,type:d,mode:y,size:h,...p===void 0?{}:{target:p}};return n.set(f,w),w}),s=i.map(o=>o.sourcePath);if(s.some((o,a)=>a>0&&s[a-1]>=o))throw new Error("Lazy tree source inventory is not in canonical path order");return{schema:1,kind:"homebrew-bottle-tar-gzip-v1",entries:i}}function yo(r){let e=new Map(r.map(n=>[n.sourcePath,n])),t=new Map;for(let n of r){if(n.type!=="hardlink"||t.has(n.sourcePath))continue;let i=[],s=new Set,o=n,a;for(;o.type==="hardlink"&&(a=t.get(o.sourcePath),a===void 0);){if(s.has(o.sourcePath))throw new Error(`Lazy tree source hardlink cycle includes ${o.sourcePath}`);s.add(o.sourcePath),i.push(o);let c=e.get(o.target);if(c===void 0)throw new Error(`Lazy tree source hardlink ${o.sourcePath} target is absent`);if(c.type!=="file"&&c.type!=="hardlink")throw new Error(`Lazy tree source hardlink ${o.sourcePath} target is not regular`);o=c}a===void 0&&(a=o);for(let c of i)t.set(c.sourcePath,a)}return t}function ye(r,e,t,n=!1){if(typeof r!="string"||r.length===0||new TextEncoder().encode(r).byteLength>kn||r.includes("\0")||r.includes("\\")||r.startsWith("/")!==e)throw new Error(`${t} is not a canonical ${e?"absolute":"relative"} path`);if(n&&e&&r==="/")return r;if(r.slice(e?1:0).split("/").some(s=>s===""||s==="."||s===".."))throw new Error(`${t} has an unsafe path segment`);return r}function po(r){let e=typeof r=="object"&&r!==null&&!Array.isArray(r)?r:null,t=e!==null&&(Object.hasOwn(e,"descriptorSha256")||Object.hasOwn(e,"expectedCount")||Object.hasOwn(e,"cohortSha256")),n=Me(r,t?["id","member","descriptorSha256","expectedCount","cohortSha256"]:["id","member"],"Lazy tree atomic activation membership"),i=he(n.id,"Lazy tree atomic activation group",ji),s=he(n.member,"Lazy tree atomic activation member",ji);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(i)||!/^[a-z0-9][a-z0-9:+._/-]*$/.test(s)||s.includes("//")||s.endsWith("/"))throw new Error("Lazy tree atomic activation membership is invalid");if(!t)return{id:i,member:s};let o=he(n.descriptorSha256,"Lazy tree atomic member descriptor digest",64),a=he(n.cohortSha256,"Lazy tree atomic cohort digest",64);if(!mr.test(o)||!mr.test(a))throw new Error("Lazy tree atomic activation digest is invalid");return{id:i,member:s,descriptorSha256:o,expectedCount:re(n.expectedCount,"Lazy tree atomic activation expected member count",1,oo),cohortSha256:a}}function mt(r){return r.descriptorSha256!==void 0&&r.expectedCount!==void 0&&r.cohortSha256!==void 0}function sc(r){let e=Me(r,["uid","gid"],"Lazy tree registration owner");return{uid:re(e.uid,"Lazy tree registration owner uid",0,qi),gid:re(e.gid,"Lazy tree registration owner gid",0,qi)}}function mo(r,e,t,n,i=1){let s=zn(r,i),o=qt(t),a=["mode","capabilities","roots",...typeof n=="object"&&n!==null&&!Array.isArray(n)&&Object.hasOwn(n,"atomicGroup")?["atomicGroup"]:[]],c=Me(n,a,"Lazy tree activation");if(c.mode!=="boot-prefetch"&&c.mode!=="first-use")throw new Error("Lazy tree activation mode is invalid");let d=xe(c.capabilities,"Lazy tree activation capabilities",1,$a).map((S,b)=>{let k=he(S,`Lazy tree activation capability ${b}`,le.maxActivationCapabilityBytes);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(k))throw new Error(`Lazy tree activation capability ${b} is invalid`);return k}),u=xe(c.roots,"Lazy tree activation roots",1,Fa).map((S,b)=>ye(S,!0,`Lazy tree activation root ${b}`,!0));if(new Set(d).size!==d.length||new Set(u).size!==u.length)throw new Error("Lazy tree activation contains duplicates");let l=c.atomicGroup===void 0?void 0:po(c.atomicGroup);if(l!==void 0&&c.mode!=="first-use")throw new Error("Lazy tree atomic activation group requires a valid first-use identity");let f={mode:c.mode,capabilities:d,roots:u,...l===void 0?{}:{atomicGroup:l}},y=xe(e,"Lazy tree inventory",1,gt),h=[],p=new Map,w=new Map,m=s.source===void 0?void 0:new Map(s.source.entries.map(S=>[S.sourcePath,S])),g=s.source===void 0?void 0:yo(s.source.entries),v=0;for(let[S,b]of y.entries()){if(typeof b!="object"||b===null||Array.isArray(b))throw new Error(`Lazy tree entry ${S} must be an object`);let k=b.type,x=k==="directory"?["vfsPath","sourcePath","type","mode","size"]:k==="file"?["vfsPath","sourcePath","type","mode","size","inodeGroup"]:k==="symlink"?["vfsPath","sourcePath","type","mode","size","target"]:k==="hardlink"?["vfsPath","sourcePath","type","mode","size","target","inodeGroup"]:null;if(!x)throw new Error(`Lazy tree entry ${S} has an invalid type`);let I=Me(b,[...x,...m===void 0?[]:["materialization"]],`Lazy tree entry ${S}`),_=ye(I.vfsPath,!0,`Lazy tree entry ${S} VFS path`),U=ye(I.sourcePath,!1,`Lazy tree entry ${S} source path`),V=m===void 0?void 0:I.materialization;if(m!==void 0&&V!=="archive"&&V!=="archive-homebrew-relocate"&&V!=="archive-copy"&&V!=="archive-copy-mode"&&V!=="descriptor")throw new Error(`Lazy tree entry ${_} has invalid materialization provenance`);if(o!=="/"&&_!==o&&!_.startsWith(`${o}/`))throw new Error(`Lazy tree entry ${_} escapes its mount prefix`);if(p.has(_))throw new Error(`Lazy tree duplicates VFS path ${_}`);let C=re(I.mode,`Lazy tree entry ${_} mode`,0,4095),O=re(I.size,`Lazy tree entry ${_} size`,0,Sn),G,ce;if(k==="directory"){if(O!==0)throw new Error(`Lazy tree directory ${_} has nonzero size`)}else if(k==="symlink"){if(G=he(I.target,`Lazy tree symlink ${_} target`,so),new TextEncoder().encode(G).byteLength!==O)throw new Error(`Lazy tree symlink ${_} size differs from its target`)}else ce=he(I.inodeGroup,`Lazy tree entry ${_} inode group`,kn),k==="hardlink"&&(G=ye(I.target,!0,`Lazy tree hardlink ${_} target`));if(k!=="hardlink"&&(v+=O,v>Sn))throw new Error("Lazy tree inventory exceeds the expansion limit");let N={vfsPath:_,sourcePath:U,...V===void 0?{}:{materialization:V},type:k,mode:C,size:O,...G===void 0?{}:{target:G},...ce===void 0?{}:{inodeGroup:ce}};if(m===void 0){let Z=w.get(U);if(Z){if(s.decoder!=="zip-v1"||N.type!=="hardlink"||Z.inodeGroup!==N.inodeGroup)throw new Error(`Lazy tree duplicates source path ${U}`)}else{if(s.decoder==="zip-v1"&&N.type==="hardlink")throw new Error(`Lazy ZIP hardlink ${_} does not reuse a canonical source path`);w.set(U,N)}}else if(N.materialization==="descriptor"){if(N.type!=="directory"&&N.type!=="symlink")throw new Error(`Lazy tree descriptor entry ${_} is not structural`);if(m.has(U))throw new Error(`Lazy tree descriptor entry ${_} impersonates a source member`)}else{let Z=m.get(U);if(Z===void 0)throw new Error(`Lazy tree entry ${_} names absent source ${U}`);if(N.materialization==="archive-copy"||N.materialization==="archive-copy-mode"){if(N.type!=="file"||Z.type!=="file"||N.materialization==="archive-copy"&&N.mode!==Z.mode)throw new Error(`Lazy tree archive copy ${_} differs from its source`)}else if(N.materialization==="archive-homebrew-relocate"){if(N.type!=="file"&&N.type!=="hardlink"||Z.type!==N.type||N.type==="file"&&Z.mode!==N.mode)throw new Error(`Lazy tree receipt-relocated entry ${_} differs from its source`)}else if(Z.type!==N.type||N.type==="symlink"&&Z.target!==N.target||N.type!=="hardlink"&&Z.mode!==N.mode)throw new Error(`Lazy tree archive entry ${_} differs from its source`)}h.push(N),p.set(_,N)}for(let S of h){let b=S.vfsPath.split("/").filter(Boolean);for(let k=1;k({path:S.vfsPath,type:S.type,mode:S.mode,size:S.size,target:S.target,inodeGroup:S.inodeGroup})),"Lazy tree");if(m!==void 0){let S=new Set;for(let b of h){if(b.materialization!=="archive-homebrew-relocate")continue;let k=m.get(b.sourcePath),x=k.type==="file"?k:g.get(k.sourcePath);if(x?.type!=="file")throw new Error(`Lazy tree receipt-relocated entry ${b.vfsPath} is not regular`);S.add(x.sourcePath)}for(let b of h){if(b.materialization==="descriptor"||b.type!=="file"&&b.type!=="hardlink")continue;let k=m.get(b.sourcePath),x=k.type==="file"?k:g.get(k.sourcePath);if(x?.type!=="file"||!S.has(x.sourcePath)&&b.size!==x.size)throw new Error(`Lazy tree archive entry ${b.vfsPath} differs from its source`)}for(let b of h){if(b.type!=="hardlink"||b.materialization!=="archive"&&b.materialization!=="archive-homebrew-relocate")continue;let k=m.get(b.sourcePath),x=p.get(b.target),I=g.get(k.sourcePath);if(k.target!==x?.sourcePath||I?.type!=="file"||I.mode!==b.mode||x?.mode!==b.mode)throw new Error(`Lazy tree hardlink ${b.vfsPath} differs from its source`)}}if(s.sourceEntryCount!==(m===void 0?w.size:m.size))throw new Error("Lazy tree source entry count differs from its inventory");if(s.source===void 0&&s.expandedBytesb.vfsPath===S||b.vfsPath.startsWith(`${S}/`)))throw new Error(`Lazy tree activation root ${S} is not owned by its inventory`);let E=new Map;for(let S of h)S.type==="file"&&E.set(S.inodeGroup,S);if(E.size!==z.canonicalByGroup.size)throw new Error("Lazy tree regular inode inventory is inconsistent");return{content:s,entries:h,mountPrefix:o,activation:f,canonicalByGroup:E}}function bn(r){return JSON.stringify([r.sourcePath,r.type,r.inodeGroup,r.target])}function Sr(r,e){let t=wr(r,["kind","content","url","mountPrefix","integrity","materialized","entries"],["url","mountPrefix","materialized","entries"],"Serialized legacy lazy archive");if(t.kind===void 0){if(!e)throw new Error("Serialized lazy archive is missing its kind discriminator")}else if(t.kind!==Vt)throw new Error("Serialized legacy lazy archive has an unsupported kind");let n=he(t.url,"Serialized legacy lazy archive URL",Er),i=qt(t.mountPrefix),s=Yt(t.integrity);if(t.content!==void 0){if(!e||t.kind!==void 0)throw new Error("Typed legacy lazy archives cannot carry generic content");let c=zn(t.content);if(c.decoder!=="zip-v1"||c.transports.length!==1||c.transports[0]!==n||!s||c.sha256!==s.sha256||c.bytes!==s.bytes)throw new Error("Untagged legacy ZIP content identity is inconsistent")}if(t.materialized!==!1)throw new Error("Serialized legacy lazy archive must describe pending content");let o=new Set,a=xe(t.entries,"Serialized legacy lazy archive entries",1,gt).map((c,d)=>{let u=wr(c,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","size","isSymlink","deleted"],`Serialized legacy lazy archive entry ${d}`),l=ye(u.vfsPath,!0,`Serialized legacy lazy archive entry ${d} VFS path`);if(o.has(l))throw new Error(`Serialized legacy lazy archive duplicates path ${l}`);o.add(l);let f=re(u.ino,`Serialized legacy lazy archive entry ${l} inode`,1,Number.MAX_SAFE_INTEGER),y=u.generation===void 0?void 0:re(u.generation,`Serialized legacy lazy archive entry ${l} generation`,0,Number.MAX_SAFE_INTEGER),h=u.dataSequence===void 0?void 0:re(u.dataSequence,`Serialized legacy lazy archive entry ${l} data sequence`,0,Number.MAX_SAFE_INTEGER),p=re(u.size,`Serialized legacy lazy archive entry ${l} size`,0,Sn);if(u.isSymlink!==!1||u.deleted!==!1||u.materialized!==void 0&&u.materialized!==!1)throw new Error(`Serialized legacy lazy archive entry ${l} is not pending`);if(u.type!==void 0&&u.type!=="file")throw new Error(`Serialized legacy lazy archive entry ${l} has an invalid type`);let w=u.archivePath===void 0?void 0:ye(u.archivePath,!1,`Serialized legacy lazy archive entry ${l} archive path`),m=u.sourcePath===void 0?void 0:ye(u.sourcePath,!1,`Serialized legacy lazy archive entry ${l} source path`),g=u.inodeGroup===void 0?void 0:he(u.inodeGroup,`Serialized legacy lazy archive entry ${l} inode group`,kn);if(u.target!==void 0)throw new Error(`Serialized legacy lazy archive entry ${l} has a link target`);return{vfsPath:l,ino:f,...y===void 0?{}:{generation:y},...h===void 0?{}:{dataSequence:h},size:p,isSymlink:!1,deleted:!1,materialized:!1,...w===void 0?{}:{archivePath:w},...m===void 0?{}:{sourcePath:m},type:"file",...g===void 0?{}:{inodeGroup:g}}});return{kind:Vt,url:n,mountPrefix:i,...s===void 0?{}:{integrity:s},materialized:!1,entries:a}}function go(r,e){let t=Me(r,["kind","content","inventory","activation","url","mountPrefix","integrity","materialized","entries"],"Serialized lazy tree");if(t.kind!==e)throw new Error("Serialized lazy tree has an unsupported kind");let n=mo(t.content,t.inventory,t.mountPrefix,t.activation);if(e!==je&&e===jt!=(n.content.source===void 0))throw new Error(e===jt?"Serialized deferred-tree-v1 cannot contain original-bottle source metadata":"Serialized deferred-tree-v2 requires original-bottle source metadata");let i=n.activation.atomicGroup;if(e===je?i===void 0||!mt(i):i!==void 0)throw new Error(e===je?"Serialized deferred-tree-v3 requires a sealed atomic activation":"Atomic activation requires serialized deferred-tree-v3");let s=he(t.url,"Serialized lazy tree URL",Er);if(s!==n.content.transports[0])throw new Error("Serialized lazy tree URL differs from its primary transport");let o=Yt(t.integrity);if(!o||o.sha256!==n.content.sha256||o.bytes!==n.content.bytes)throw new Error("Serialized lazy tree integrity differs from its content");if(t.materialized!==!1)throw new Error("Serialized lazy tree must describe pending content");let a=new Map(n.entries.map(f=>[f.vfsPath,f])),c=new Map(n.entries.map(f=>[bn(f),f])),d=xe(t.entries,"Serialized lazy tree entries",0,gt),u=new Set,l=d.map((f,y)=>{let h=wr(f,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup"],`Serialized lazy tree entry ${y}`),p=ye(h.vfsPath,!0,`Serialized lazy tree entry ${y} VFS path`);if(u.has(p))throw new Error(`Serialized lazy tree duplicates pending path ${p}`);u.add(p);let w=ye(h.sourcePath,!1,`Serialized lazy tree entry ${y} source path`),m=ye(h.archivePath,!1,`Serialized lazy tree entry ${y} archive path`),g=a.get(p),v=c.get(bn({sourcePath:w,type:typeof h.type=="string"?h.type:void 0,inodeGroup:typeof h.inodeGroup=="string"?h.inodeGroup:void 0,target:typeof h.target=="string"?h.target:void 0}))??g;if(!v||v.type!=="file"&&v.type!=="hardlink"||g?.inodeGroup!==void 0&&g.inodeGroup!==v.inodeGroup)throw new Error(`Serialized lazy tree entry ${p} is absent from its inventory`);let z=n.canonicalByGroup.get(v.inodeGroup);if(h.type!==v.type||h.inodeGroup!==v.inodeGroup||h.size!==v.size||m!==z?.sourcePath||h.target!==v.target||h.isSymlink!==!1||h.deleted!==!1||h.materialized!==!1)throw new Error(`Serialized lazy tree entry ${p} disagrees with its inventory`);let E=re(h.ino,`Serialized lazy tree entry ${p} inode`,1,Number.MAX_SAFE_INTEGER),S=re(h.generation,`Serialized lazy tree entry ${p} generation`,0,Number.MAX_SAFE_INTEGER),b=re(h.dataSequence,`Serialized lazy tree entry ${p} data sequence`,0,Number.MAX_SAFE_INTEGER);return{vfsPath:p,ino:E,generation:S,dataSequence:b,size:v.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:m,sourcePath:w,type:v.type,inodeGroup:v.inodeGroup,...v.target===void 0?{}:{target:v.target}}});for(let f of n.entries)if(n.activation.atomicGroup!==void 0&&(f.type==="file"||f.type==="hardlink")&&!u.has(f.vfsPath))throw new Error(`Serialized lazy tree omits pending path ${f.vfsPath}`);return{kind:e,content:n.content,inventory:n.entries,activation:n.activation,url:s,mountPrefix:n.mountPrefix,integrity:o,materialized:!1,entries:l}}async function Wt(r,e){let t=globalThis.crypto?.subtle;if(!t)throw new Error(`${e} SHA-256 verification is unavailable`);let n=new Uint8Array(r.byteLength);n.set(r);let i=new Uint8Array(await t.digest("SHA-256",n));return Array.from(i,s=>s.toString(16).padStart(2,"0")).join("")}async function hr(r,e,t){if(t===void 0)return;if(r.byteLength!==t.bytes)throw new Error(`Lazy ${e} byte count ${r.byteLength} does not match expected ${t.bytes}`);let n=await Wt(r,`Lazy ${e}`);if(n!==t.sha256)throw new Error(`Lazy ${e} SHA-256 ${n} does not match expected ${t.sha256}`)}function ac(r,e,t,n){let i=n.atomicGroup;if(i===void 0)throw new Error("Lazy atomic member is missing its typed tree descriptor");let s={schema:1,content:{decoder:r.decoder,mediaType:r.mediaType,sha256:r.sha256,bytes:r.bytes,expandedBytes:r.expandedBytes,sourceEntryCount:r.sourceEntryCount,...r.modePolicy===void 0?{}:{modePolicy:r.modePolicy},...r.source===void 0?{}:{source:r.source}},mountPrefix:t,inventory:[...e].sort((o,a)=>o.vfsPatha.vfsPath?1:0),activation:{mode:n.mode,capabilities:n.capabilities,roots:n.roots,atomicGroup:{id:i.id,member:i.member}}};return new TextEncoder().encode(JSON.stringify(s))}function eo(r,e){let t=e.map(({member:n,descriptorSha256:i})=>({member:n,descriptorSha256:i}));return new TextEncoder().encode(JSON.stringify({schema:1,id:r,members:t.sort((n,i)=>n.memberi.member?1:0)}))}function cc(r,e){if(r.byteLength!==e.byteLength)return!1;for(let t=0;tObject.freeze({...i}))};return n!==void 0&&(Object.freeze(n.entries),Object.freeze(n)),Object.freeze({decoder:r.decoder,mediaType:r.mediaType,sha256:r.sha256,bytes:r.bytes,expandedBytes:r.expandedBytes,sourceEntryCount:r.sourceEntryCount,transports:t,...r.modePolicy===void 0?{}:{modePolicy:r.modePolicy},...n===void 0?{}:{source:n}})}function to(r){return{decoder:r.decoder,mediaType:r.mediaType,sha256:r.sha256,bytes:r.bytes,expandedBytes:r.expandedBytes,sourceEntryCount:r.sourceEntryCount,transports:[...r.transports],...r.modePolicy===void 0?{}:{modePolicy:r.modePolicy},...r.source===void 0?{}:{source:{schema:1,kind:"homebrew-bottle-tar-gzip-v1",entries:r.source.entries.map(e=>({...e}))}}}}function dc(r){let e=r.map(t=>Object.freeze({...t}));return Object.freeze(e),e}function lc(r,e,t){let n=[...r.capabilities],i=[...r.roots];return Object.freeze(n),Object.freeze(i),Object.freeze({mode:r.mode,capabilities:n,roots:i,atomicGroup:Object.freeze({id:e,member:t})})}function uc(r){return{mode:r.activation.mode,capabilities:[...r.activation.capabilities],roots:[...r.activation.roots],atomicGroup:{id:r.id,member:r.member,descriptorSha256:r.descriptorSha256,expectedCount:r.expectedCount,cohortSha256:r.cohortSha256}}}function fc(r,e){return r.ino===e.ino&&r.generation===e.generation&&r.dataSequence===e.dataSequence&&r.size===e.size&&r.isSymlink===e.isSymlink&&r.deleted===e.deleted&&r.materialized===e.materialized&&r.archivePath===e.archivePath&&r.sourcePath===e.sourcePath&&r.type===e.type&&r.inodeGroup===e.inodeGroup&&r.target===e.target}function wn(r,e,t){let n=r.content,i=r.inventory,s=r.activation,o=r.integrity,a=r.entries,c=r.url,d=r.mountPrefix,u=r.materialized,l=s?.atomicGroup;if(n===void 0||i===void 0||s===void 0||l===void 0||s.mode!=="first-use"||l.id!==e||l.member!==t||u)throw new Error(`Lazy atomic activation member ${t} changed before snapshot`);if(o?.sha256!==n.sha256||o?.bytes!==n.bytes||c!==(n.transports[0]??""))throw new Error(`Lazy atomic activation member ${t} has inconsistent integrity`);let f=wo(n),y=dc(i),h=lc(s,e,t),p=new Map;for(let z of y)z.type==="file"&&p.set(z.inodeGroup,z.sourcePath);let w=y.filter(z=>z.type!=="directory");if(a.size!==w.length)throw new Error(`Lazy atomic activation member ${t} has inconsistent runtime entries`);let m=w.map(z=>{let E=a.get(z.vfsPath),S=z.type==="symlink",b=S?z.sourcePath:p.get(z.inodeGroup),k=E!==void 0&&(E.sourcePath===z.sourcePath&&E.type===z.type&&E.target===z.target||z.type==="hardlink"&&E.sourcePath===b&&E.type==="file"&&E.target===void 0),x=E===void 0?["missing"]:[b===void 0?"archivePath source":void 0,E.generation===void 0?"generation":void 0,E.dataSequence===void 0?"dataSequence":void 0,E.size!==z.size?"size":void 0,E.isSymlink!==S?"symlink kind":void 0,E.deleted?"deletion state":void 0,E.materialized!==S?"materialization state":void 0,E.archivePath!==b?"archivePath":void 0,k?void 0:"descriptor mapping",E.inodeGroup!==z.inodeGroup?"inode group":void 0].filter(_=>_!==void 0);if(x.length>0)throw new Error(`Lazy atomic activation member ${t} has inconsistent mapping at ${z.vfsPath}: ${x.join(", ")}`);let I=E;return Object.freeze({vfsPath:z.vfsPath,ino:I.ino,generation:I.generation,dataSequence:I.dataSequence,size:I.size,isSymlink:I.isSymlink,deleted:!1,materialized:I.materialized,archivePath:b,sourcePath:z.sourcePath,type:z.type,...z.inodeGroup===void 0?{}:{inodeGroup:z.inodeGroup},...z.target===void 0?{}:{target:z.target}})});Object.freeze(m);let g=Object.freeze({sha256:f.sha256,bytes:f.bytes}),v=ac(f,y,d,h);return Object.freeze({id:e,member:t,descriptorBytes:v,content:f,inventory:y,activation:h,url:f.transports[0]??"",mountPrefix:d,integrity:g,entries:m})}function no(r,e,t,n){return Object.freeze({...r,descriptorSha256:e,expectedCount:t,cohortSha256:n})}function ro(r,e){return r.id!==e.id||r.member!==e.member||r.url!==e.url||r.mountPrefix!==e.mountPrefix||r.integrity.sha256!==e.integrity.sha256||r.integrity.bytes!==e.integrity.bytes||r.content.transports.length!==e.content.transports.length||r.content.transports.some((t,n)=>t!==e.content.transports[n])||!cc(r.descriptorBytes,e.descriptorBytes)||r.entries.length!==e.entries.length?!1:r.entries.every((t,n)=>{let i=e.entries[n];return i!==void 0&&t.vfsPath===i.vfsPath&&fc(t,i)})}function hc(r,e){let t=wo(r.content,r.content.transports.map(e));return Object.freeze({...r,content:t,url:t.transports[0]??""})}function io(r){return{paths:Array.from(r.paths),expectedIno:r.ino,expectedGeneration:r.generation,expectedDataSequence:r.dataSequence,data:r.content}}var An=class r{fs;imageMetadata;lazyFiles=new Map;lazyArchiveGroups=[];deferredTreeMaterializationHandles=new WeakMap;lazyArchiveInodes=new Map;lazyAtomicGroups=new Map;lazyAtomicGroupByTree=new WeakMap;sealedLazyAtomicStates=new WeakMap;lazyDownloadListeners=new Set;lazyPreparations=new Map;lazyTransport={fetcher:(e,t)=>t===void 0?globalThis.fetch(e):globalThis.fetch(e,t)};constructor(e,t=null){this.fs=e,this.imageMetadata=t}static inodeKey(e,t){return`${e}:${t}`}static canAdoptLegacyLazyStub(e){return(e.mode&Le)===Kt&&e.size===0&&e.dataSequence<=1}reconcileLazyIdentityState(e){for(let[t,n]of this.lazyFiles){let i=e.get(t);if(!i||i.dataSequence!==n.dataSequence||i.paths.length===0){this.lazyFiles.delete(t);continue}n.paths=new Set(i.paths),n.paths.has(n.path)||(n.path=i.paths[0])}this.lazyArchiveInodes.clear();for(let t of this.lazyArchiveGroups){let n=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(!n?.committed)for(let c of i.snapshot.entries){if(c.isSymlink||c.materialized||c.generation===void 0)continue;let d=r.inodeKey(c.ino,c.generation),u=e.get(d);u!==void 0&&u.dataSequence===c.dataSequence&&u.paths.length>0&&this.lazyArchiveInodes.set(d,t)}continue}let s=t.content!==void 0&&t.inventory!==void 0&&!t.materialized,o=new Map;for(let c of t.entries.values()){if(c.deleted||c.materialized||c.generation===void 0)continue;let d=r.inodeKey(c.ino,c.generation);o.has(d)||o.set(d,c)}let a=new Map(Array.from(t.entries.entries()).filter(([,c])=>c.deleted||c.isSymlink&&!c.deleted));for(let[c,d]of o){let u=e.get(c);if(!(!u||u.dataSequence!==(d.dataSequence??0))){for(let l of u.paths)a.set(l,{...d,ino:u.ino,generation:u.generation,dataSequence:u.dataSequence,deleted:!1,materialized:!1});u.paths.length>0&&this.lazyArchiveInodes.set(c,t)}}t.entries=a,t.materialized=!Array.from(a.values()).some(c=>!c.isSymlink&&!c.materialized)&&!s&&(n===void 0||n.committed)}}validatePendingLazyTreeNamespaceState(e){let t=new Map;for(let n of e.values())for(let i of n.paths){if(t.has(i))throw new Error(`SharedFS namespace identity is ambiguous at ${i}`);t.set(i,n)}for(let n of this.lazyArchiveGroups){let i=this.lazyAtomicGroupByTree.get(n),o=this.sealedLazyAtomicStates.get(n)?.snapshot;if(i?.committed||o===void 0&&n.materialized||o===void 0&&(n.content===void 0||n.inventory===void 0))continue;let a=o?.inventory??n.inventory,c=o===void 0?n.entries:new Map(o.entries.map(y=>[y.vfsPath,y])),d=new Map,u=new Map,l=new Set;for(let y of c.values())y.deleted&&y.inodeGroup!==void 0&&l.add(y.inodeGroup);for(let y of a){if(y.type!=="file"&&y.type!=="hardlink")continue;d.set(y.inodeGroup,(d.get(y.inodeGroup)??0)+1);let h=u.get(y.inodeGroup)??[];h.push(y.vfsPath),u.set(y.inodeGroup,h)}let f=new Set([...l].filter(y=>u.get(y)?.every(h=>!t.has(h))));for(let y of a){let h=t.get(y.vfsPath);if(h===void 0){if(y.inodeGroup!==void 0&&f.has(y.inodeGroup))continue;throw new Error(`Lazy tree namespace entry ${y.vfsPath} is missing from the captured filesystem state`)}let p=y.type==="directory"?Ve:y.type==="symlink"?mn:Kt;if((h.mode&Le)!==p||(h.mode&4095)!==y.mode)throw new Error(`Lazy tree namespace entry ${y.vfsPath} disagrees with its captured type or mode`);if(y.type==="directory")continue;let w=c.get(y.vfsPath);if(w===void 0||w.ino!==h.ino||w.generation!==h.generation||w.dataSequence!==h.dataSequence)throw new Error(`Lazy tree namespace entry ${y.vfsPath} changed identity before serialization`);if(y.type==="symlink"){let m=new TextEncoder().encode(y.target).byteLength;if(h.linkCount!==1||h.size!==y.size||h.size!==m||h.symlinkTarget!==y.target)throw new Error(`Lazy tree symlink ${y.vfsPath} disagrees with its captured inventory`);continue}if(h.size!==0||h.linkCount!==d.get(y.inodeGroup))throw new Error(`Lazy tree stub ${y.vfsPath} has changed data or undeclared aliases`)}}}lazyFileForStat(e){let t=r.inodeKey(e.ino,e.generation),n=this.lazyFiles.get(t);if(n&&n.dataSequence!==e.dataSequence){this.lazyFiles.delete(t);return}return n}lazyArchiveEntriesForRead(e){let t=this.lazyAtomicGroupByTree.get(e),n=this.sealedLazyAtomicStates.get(e);return n!==void 0&&!t?.committed?n.snapshot.entries:Array.from(e.entries,([i,s])=>({vfsPath:i,...s}))}lazyArchiveForStat(e){let t=r.inodeKey(e.ino,e.generation),n=this.lazyArchiveInodes.get(t);if(!n)return;let i=this.lazyArchiveEntriesForRead(n).filter(o=>o.ino===e.ino&&o.generation===e.generation&&!o.deleted&&!o.materialized);if(i.some(o=>o.dataSequence===e.dataSequence))return n;if(this.lazyArchiveInodes.delete(t),this.sealedLazyAtomicStates.get(n)===void 0)for(let o of i)o.materialized=!0}lazyBackingForStat(e){let t=r.inodeKey(e.ino,e.generation),n=this.lazyFiles.get(t);if(n)return{token:n,path:n.path};let i=this.lazyArchiveInodes.get(t);if(!i)return null;let s=this.lazyArchiveEntriesForRead(i).find(a=>a.ino===e.ino&&a.generation===e.generation&&!a.deleted&&!a.materialized)?.vfsPath;if(s===void 0)return null;let o=this.lazyAtomicGroupByTree.get(i);return o===void 0?{token:i,path:s}:{token:o.token,path:s,atomicGroup:o}}lazyBackingForPath(e){let t=this.lazyArchiveGroups.find(n=>{let i=this.lazyAtomicGroupByTree.get(n),s=this.sealedLazyAtomicStates.get(n)?.snapshot,o=s===void 0?!n.materialized:!i?.committed,a=s?.content??n.content,c=s?.inventory??n.inventory,d=s?.activation??n.activation,u=s?.entries??Array.from(n.entries.values());return o&&a!==void 0&&c!==void 0&&d!==void 0&&u.every(l=>l.deleted||l.materialized||l.isSymlink)&&d.roots.some(l=>l==="/"||e===l||e.startsWith(`${l}/`))});if(t){let n=this.lazyAtomicGroupByTree.get(t);return{token:n?.token??t,path:e,directGroup:t,...n===void 0?{}:{atomicGroup:n}}}try{let n=this.fs.stat(e),i=this.lazyBackingForStat(n);return i?{...i,path:e}:null}catch{return null}}startLazyPreparation(e){let{path:t,token:n}=e,i={status:"pending",promise:Promise.resolve(!1)},s=e.atomicGroup?this.ensureAtomicLazyGroupMaterialized(e.atomicGroup).then(()=>!0):e.directGroup?this.ensureArchiveMaterialized(e.directGroup).then(()=>!0):this.materializePath(t);return i.promise=s.then(o=>(i.status="fulfilled",this.lazyPreparations.get(n)===i&&this.lazyPreparations.delete(n),o),o=>{throw i.status="rejected",i.error=o,o}),i.promise.catch(()=>{}),this.lazyPreparations.set(n,i),i}registerLazyAtomicGroupMembership(e,t=!1){let n=e.activation?.atomicGroup;if(n===void 0)return;let{id:i,member:s}=n;if(e.content===void 0||e.inventory===void 0||e.activation?.mode!=="first-use")throw new Error(`Lazy atomic activation group ${i} accepts only typed first-use trees`);let o=this.lazyAtomicGroups.get(i);if(o===void 0)o={id:i,token:Object.freeze({id:i}),groups:new Map,committed:!1},this.lazyAtomicGroups.set(i,o);else if(o.committed)throw new Error(`Lazy atomic activation group ${i} is already materialized`);if(o.groups.has(s))throw new Error(`Lazy atomic activation group ${i} duplicates member ${s}`);if(mt(n)){if(o.expectedCount!==void 0&&(o.expectedCount!==n.expectedCount||o.cohortSha256!==n.cohortSha256))throw new Error(`Lazy atomic activation group ${i} has inconsistent seals`);o.expectedCount=n.expectedCount,o.cohortSha256=n.cohortSha256;let a=wn(e,i,s);this.sealedLazyAtomicStates.set(e,{snapshot:no(a,n.descriptorSha256,n.expectedCount,n.cohortSha256),verified:t})}else if(o.expectedCount!==void 0)throw new Error(`Lazy atomic activation group ${i} mixes sealed and unsealed members`);o.groups.set(s,e),this.lazyAtomicGroupByTree.set(e,o)}async sealLazyAtomicGroup(e,t){let n=t.map(d=>po({id:e,member:d}).member).sort();if(n.length===0||new Set(n).size!==n.length)throw new Error(`Lazy atomic activation group ${e} expected members are invalid`);let i=this.lazyAtomicGroups.get(e);if(i===void 0||i.committed)throw new Error(`Lazy atomic activation group ${e} is not pending`);let s=[...i.groups.keys()].sort();if(JSON.stringify(s)!==JSON.stringify(n))throw new Error(`Lazy atomic activation group ${e} members differ from its seal`);if(i.expectedCount!==void 0){if(i.expectedCount!==n.length||i.cohortSha256===void 0)throw new Error(`Lazy atomic activation group ${e} has an invalid existing seal`);await this.ensureLazyAtomicGroupSealValidated(i,n.map(d=>i.groups.get(d)),!0);return}let o=n.map(d=>wn(i.groups.get(d),e,d)),a=[];for(let d of o)a.push({member:d.member,descriptorSha256:await Wt(d.descriptorBytes,`Lazy atomic member ${d.member}`),source:d});let c=await Wt(eo(e,a),`Lazy atomic activation group ${e}`);for(let d of a){let u=i.groups.get(d.member),l=wn(u,e,d.member);if(!ro(d.source,l))throw new Error(`Lazy atomic activation member ${d.member} changed while sealing`)}for(let d of a){let u=i.groups.get(d.member);u.activation.atomicGroup={id:e,member:d.member,descriptorSha256:d.descriptorSha256,expectedCount:a.length,cohortSha256:c},this.sealedLazyAtomicStates.set(u,{snapshot:no(d.source,d.descriptorSha256,a.length,c),verified:!0})}i.expectedCount=a.length,i.cohortSha256=c}async verifyImportedLazyAtomicGroupSeals(){await this.validatePendingLazyAtomicGroupSeals(!0)}guardSynchronousLazyAccess(e){let t=this.lazyBackingForPath(e);if(!t)return;let n=this.lazyPreparations.get(t.token);if(n?.status==="fulfilled"){this.lazyPreparations.delete(t.token);let s=this.lazyBackingForPath(e);if(!s)return;n=this.lazyPreparations.get(s.token)??this.startLazyPreparation(s)}else if(n?.status==="rejected"){this.lazyPreparations.delete(t.token);let s=n.error instanceof Error?n.error.message:String(n.error),o=new Error(`EIO: lazy backing for ${e} failed: ${s}`);throw o.code="EIO",o.cause=n.error,o}else n||(n=this.startLazyPreparation(t));let i=new Error(`EAGAIN: lazy backing for ${e} is being prepared`);throw i.code="EAGAIN",i}invalidateLazyData(e){let t=r.inodeKey(e.ino,e.generation);this.lazyFiles.delete(t);let n=this.lazyArchiveInodes.get(t);if(n){this.lazyArchiveInodes.delete(t);for(let i of n.entries.values())i.ino===e.ino&&i.generation===e.generation&&(i.materialized=!0)}}rewriteLazyNamespacePaths(e,t,n){let i=t.length>1?t.replace(/\/+$/,""):t,s=n.length>1?n.replace(/\/+$/,""):n,o=`${i}/`,a=`${s}/`,c=r.inodeKey(e.ino,e.generation),d=(e.mode&Le)===Ve,u=l=>l===i?s:d&&l.startsWith(o)?a+l.slice(o.length):l;for(let[l,f]of this.lazyFiles)!d&&l!==c||(f.paths=new Set(Array.from(f.paths,u)),f.path=u(f.path));for(let l of this.lazyArchiveGroups){let f=new Map;for(let[y,h]of l.entries){let p=h.generation===void 0?null:r.inodeKey(h.ino,h.generation);f.set(d||p===c?u(y):y,h)}l.entries=f,l.inventory&&(l.inventory=l.inventory.map(y=>({...y,vfsPath:u(y.vfsPath),...y.type==="hardlink"&&y.target!==void 0?{target:u(y.target)}:{}}))),l.activation&&(l.activation={...l.activation,roots:l.activation.roots.map(u)})}}get sharedBuffer(){return this.fs.buffer}static create(e,t){return new r(be.mkfs(e,t))}static fromExisting(e){return new r(be.mount(e))}rebaseToNewFileSystem(e){if(!Number.isSafeInteger(e)||e<=0)throw new Error(`Invalid MemoryFileSystem maxByteLength: ${e}`);let t=SharedArrayBuffer,{bytes:n,identities:i}=this.fs.snapshotState();this.reconcileLazyIdentityState(i);let s=this.serializeLazyEntries(),o=this.serializeValidatedLazyArchiveEntries(i),a=new t(n.byteLength);new Uint8Array(a).set(n);let c=new r(be.mount(a,{restoreImage:!0}),this.imageMetadata);c.importLazyEntries(s),c.importLazyArchiveEntriesInternal(o,!1,!0,"verified");let d=Math.min(e,Math.max(n.byteLength,Ba)),u=new t(d,{maxByteLength:e}),l=r.create(u,e);l.setImageMetadata(this.imageMetadata);let f=new Set(s.flatMap(h=>h.paths??[h.path])),y=new Set;for(let h of o)if(!h.materialized)for(let p of h.entries)!p.deleted&&!p.isSymlink&&y.add(p.vfsPath);return c.copyPathToFreshFileSystem("/",l,f,y,new Map),l.importLazyEntries(s.map(h=>{let p=l.fs.lstat(h.path);return{...h,ino:p.ino,generation:p.generation,dataSequence:p.dataSequence}})),l.importLazyArchiveEntriesInternal(o.map(h=>({...h,entries:h.entries.map(p=>{if(p.deleted)return{...p,ino:0,generation:void 0};let w=l.fs.lstat(p.vfsPath);return{...p,ino:w.ino,generation:w.generation,dataSequence:w.dataSequence}})})),!1,!0,"verified"),l}getImageMetadata(){return Ka(this.imageMetadata)}setImageMetadata(e){this.imageMetadata=e===null?null:zr(e)}subscribeLazyDownloads(e){return this.lazyDownloadListeners.add(e),()=>this.lazyDownloadListeners.delete(e)}setLazyFetcher(e,t={}){this.lazyTransport={fetcher:e,...t.signal===void 0?{}:{signal:t.signal}}}emitLazyDownload(e){if(this.lazyDownloadListeners.size===0)return;let t={...e,t:ja()};for(let n of this.lazyDownloadListeners)try{n(t)}catch{}}async fetchLazyBytes(e,t){let n=0,i=e.integrity?.bytes??e.fallbackTotalBytes,s={id:e.id,kind:e.kind,url:e.url,path:e.path,mountPrefix:e.mountPrefix};for(let o=0;oe.integrity.bytes)throw new Error(`Lazy ${e.kind} exceeded expected byte count ${e.integrity.bytes}`);this.emitLazyDownload({...s,status:"progress",loadedBytes:n,totalBytes:i})}}}catch(l){try{await c.cancel(l)}catch{}throw l}}finally{c.releaseLock()}let u=nc(d,n);return J(t.signal),await hr(u,e.kind,e.integrity),J(t.signal),this.emitLazyDownload({...s,status:"complete",loadedBytes:n,totalBytes:i??n}),u}catch(a){if(t.signal?.aborted){let u=t.signal.reason,l=u instanceof Error?u.message:String(u);throw this.emitLazyDownload({...s,status:"error",loadedBytes:n,totalBytes:i,error:l}),u}let c=o+1({...g})),activation:l,entries:new Map},w=g=>{let v=g.split("/").filter(Boolean),z="";for(let E=0;Ev.vfsPath.split("/").length-z.vfsPath.split("/").length))if(g.type==="directory"){w(g.vfsPath);try{this.fs.mkdir(g.vfsPath,g.mode),this.fs.chmod(g.vfsPath,g.mode)}catch{if((this.fs.lstat(g.vfsPath).mode&Le)!==Ve)throw new Error(`Lazy tree directory collides at ${g.vfsPath}`)}}for(let g of d){if(g.type!=="symlink")continue;w(g.vfsPath),this.fs.symlink(g.target,g.vfsPath);let v=this.fs.lstat(g.vfsPath);p.entries.set(g.vfsPath,{ino:v.ino,generation:v.generation,dataSequence:v.dataSequence,size:g.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:g.sourcePath,sourcePath:g.sourcePath,type:"symlink",target:g.target})}let m=new Map;for(let g of d){if(g.type!=="file")continue;w(g.vfsPath);let v=this.fs.createLazyStub(g.vfsPath,g.mode);this.invalidateLazyData(v),m.set(g.inodeGroup,v);let z={ino:v.ino,generation:v.generation,dataSequence:v.dataSequence,size:g.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:g.sourcePath,sourcePath:g.sourcePath,type:"file",inodeGroup:g.inodeGroup};p.entries.set(g.vfsPath,z)}for(let g of d){if(g.type!=="hardlink")continue;let v=f.get(g.inodeGroup);w(g.vfsPath),this.fs.link(v.vfsPath,g.vfsPath);let z=this.fs.lstat(g.vfsPath),E=m.get(g.inodeGroup);if(z.ino!==E.ino||z.generation!==E.generation)throw new Error(`Lazy tree hardlink ${g.vfsPath} did not share its inode`);p.entries.set(g.vfsPath,{ino:z.ino,generation:z.generation,dataSequence:z.dataSequence,size:g.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:v.sourcePath,sourcePath:g.sourcePath,type:"hardlink",inodeGroup:g.inodeGroup,target:g.target})}if(y!==void 0)for(let g of d)this.lchown(g.vfsPath,y.uid,y.gid);for(let g of p.entries.values())g.isSymlink||g.generation===void 0||this.lazyArchiveInodes.set(r.inodeKey(g.ino,g.generation),p);return this.lazyArchiveGroups.push(p),this.registerLazyAtomicGroupMembership(p),p}registerLazyTreeWithMaterializationHandle(e,t,n="/",i,s){let o=this.registerLazyTreeInternal(e,t,n,i,!0,s),a=Object.freeze({[xa]:!0});return this.deferredTreeMaterializationHandles.set(a,o),a}registerLazyArchiveFromEntries(e,t,n,i,s){let o=qt(n),a=Ga(e,t,o,i);a.some(({entry:d})=>!d.isDirectory&&!d.isSymlink)&&this.assertCanRegisterPendingLazyArchiveGroup();let c={...s?{content:zn({decoder:"zip-v1",mediaType:"application/zip",sha256:s.sha256,bytes:s.bytes,expandedBytes:a.reduce((d,u)=>d+u.entry.uncompressedSize,0),sourceEntryCount:a.length,transports:[e]})}:{},url:e,mountPrefix:o,integrity:Yt(s),materialized:!1,entries:new Map};for(let{entry:d,vfsPath:u}of a){if(d.isDirectory)continue;let l=u.split("/").filter(Boolean),f="";for(let y=0;yd.deleted||d.materialized),this.lazyArchiveGroups.push(c),c}importLazyArchiveEntries(e){this.importLazyArchiveEntriesInternal(e,!1,!0,"reject")}async importVerifiedLazyArchiveEntries(e){let t=structuredClone(e),n=this.exportLazyArchiveEntries(),i=r.fromExisting(this.sharedBuffer);i.importLazyArchiveEntriesInternal([...n,...t],!1,!0,"pending"),await i.verifyImportedLazyAtomicGroupSeals(),this.importLazyArchiveEntriesInternal(t,!1,!0,"verified")}importLazyArchiveEntriesInternal(e,t,n,i){let s=xe(e,"Serialized lazy archive groups",0,oo).map((u,l)=>{if(typeof u!="object"||u===null||Array.isArray(u))throw new Error(`Serialized lazy archive group ${l} must be an object`);let f=u.kind;if(f===jt||f===gr||f===je)return go(u,f);if(f===Vt)return Sr(u,!1);if(f!==void 0)throw new Error(`Serialized lazy archive group ${l} has an unsupported kind`);if(n)throw new Error(`Serialized lazy archive group ${l} is missing its kind discriminator`);return Sr(u,!0)}),o=this.fs.identityState();this.reconcileLazyIdentityState(o);let a=[...this.serializeValidatedLazyArchiveEntries(o),...s];Qi(a);let c=[],d=new Map;for(let u of s){let l=new Map,f=u.mountPrefix.replace(/\/+$/,""),y=u.content!==void 0&&u.inventory!==void 0&&u.activation!==void 0,h=y?new Map(u.inventory.map(E=>[E.vfsPath,E])):null,p=y?new Map(u.inventory.map(E=>[bn(E),E])):null,w=new Map,m=new Map,g=new Map;for(let E of u.entries){let S=null,b=u.materialized||E.materialized===!0||E.isSymlink;if(!E.deleted&&!b){if((E.generation===void 0||E.dataSequence===void 0)&&!t)throw new Error("Live lazy-archive metadata requires inode generation and data sequence");try{S=this.fs.lstat(E.vfsPath)}catch{if(y)throw new Error(`Serialized lazy tree stub ${E.vfsPath} is missing from the filesystem`);continue}if(S.ino!==E.ino){if(y)throw new Error(`Serialized lazy tree stub ${E.vfsPath} has a different inode`);continue}if(E.generation!==void 0&&S.generation!==E.generation){if(y)throw new Error(`Serialized lazy tree stub ${E.vfsPath} has a different generation`);continue}if(E.dataSequence===void 0){if(!r.canAdoptLegacyLazyStub(S)){if(y)throw new Error(`Serialized lazy tree stub ${E.vfsPath} is not pristine`);continue}}else if(S.dataSequence!==E.dataSequence){if(y)throw new Error(`Serialized lazy tree stub ${E.vfsPath} has a different data sequence`);continue}if(y){g.set(E.vfsPath,S);let x=h.get(E.vfsPath),I=p.get(bn(E))??x;if(!I||(S.mode&Le)!==Kt||S.size!==0||(S.mode&4095)!==I.mode||x?.inodeGroup!==void 0&&x.inodeGroup!==I.inodeGroup)throw new Error(`Serialized lazy tree stub ${E.vfsPath} disagrees with its inventory`);let _=r.inodeKey(S.ino,S.generation),U=E.inodeGroup,V=w.get(U),C=m.get(_);if(V!==void 0&&V!==_||C!==void 0&&C!==U)throw new Error(`Serialized lazy tree inode group ${U} disagrees with the filesystem`);w.set(U,_),m.set(_,U)}}l.set(E.vfsPath,{ino:E.ino,generation:S?.generation??E.generation,dataSequence:S?.dataSequence??E.dataSequence,size:E.size,isSymlink:E.isSymlink,deleted:E.deleted,materialized:b,archivePath:E.archivePath??E.vfsPath.slice(f.length+1),sourcePath:E.sourcePath??E.archivePath??E.vfsPath.slice(f.length+1),type:E.type??(E.isSymlink?"symlink":"file"),inodeGroup:E.inodeGroup,target:E.target})}if(y){let E=new Map;for(let S of u.inventory){if(S.type==="file"||S.type==="hardlink"){E.set(S.inodeGroup,(E.get(S.inodeGroup)??0)+1);continue}let b;try{b=this.fs.lstat(S.vfsPath)}catch{throw new Error(`Serialized lazy tree namespace entry ${S.vfsPath} is missing from the filesystem`)}let k=S.type==="directory"?Ve:mn;if((b.mode&Le)!==k||(b.mode&4095)!==S.mode||S.type==="symlink"&&(b.size!==new TextEncoder().encode(S.target).byteLength||this.fs.readlink(S.vfsPath)!==S.target))throw new Error(`Serialized lazy tree namespace entry ${S.vfsPath} disagrees with its inventory`);S.type==="symlink"&&l.set(S.vfsPath,{ino:b.ino,generation:b.generation,dataSequence:b.dataSequence,size:S.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:S.sourcePath,sourcePath:S.sourcePath,type:"symlink",target:S.target})}if(u.activation?.atomicGroup!==void 0)for(let S of u.inventory){if(S.type!=="file"&&S.type!=="hardlink")continue;if(g.get(S.vfsPath).linkCount!==E.get(S.inodeGroup))throw new Error(`Serialized lazy atomic tree inode group ${S.inodeGroup} has undeclared aliases`)}}let v=u.content===void 0?void 0:zn(u.content),z={content:v,url:v?.transports[0]??u.url,mountPrefix:u.mountPrefix,integrity:v?{sha256:v.sha256,bytes:v.bytes}:Yt(u.integrity),materialized:u.materialized||!(v&&u.inventory)&&Array.from(l.values()).every(E=>E.deleted||E.materialized),inventory:u.inventory?.map(E=>({...E})),activation:u.activation?{mode:u.activation.mode,capabilities:[...u.activation.capabilities],roots:[...u.activation.roots],...u.activation.atomicGroup===void 0?{}:{atomicGroup:{...u.activation.atomicGroup}}}:void 0,entries:l};if(c.push(z),!z.materialized){for(let[,E]of l)if(!E.deleted&&!E.materialized&&E.generation!==void 0){let S=r.inodeKey(E.ino,E.generation),b=d.get(S);if(b!==void 0&&b!==z)throw new Error(`Serialized lazy archive groups share pending inode ${S}`);if(this.lazyArchiveInodes.has(S))throw new Error(`Serialized lazy archive group collides with pending inode ${S}`);d.set(S,z)}}}for(let u of c){let l=u.activation?.atomicGroup;if(l!==void 0&&this.lazyAtomicGroups.get(l.id)?.committed)throw new Error(`Lazy atomic activation group ${l.id} is already materialized`)}if(i==="reject"&&c.some(u=>{let l=u.activation?.atomicGroup;return l!==void 0&&mt(l)}))throw new Error("Sealed lazy archive registrations require importVerifiedLazyArchiveEntries()");this.lazyArchiveGroups.push(...c);for(let u of c)this.registerLazyAtomicGroupMembership(u,i==="verified");for(let[u,l]of d)this.lazyArchiveInodes.set(u,l)}rewriteLazyArchiveUrls(e){for(let t of this.lazyArchiveGroups){let n=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0&&!n?.committed){this.assertLazyAtomicSnapshotMatchesPublic(t);let s=hc(i.snapshot,e);t.content=to(s.content),t.url=s.url,t.integrity={...s.integrity},i.snapshot=s;continue}t.content?(t.content={...t.content,transports:t.content.transports.map(e)},t.url=t.content.transports[0]):t.url=e(t.url)}}serializeLazyArchiveEntries(){let e=[];for(let t of this.lazyArchiveGroups){let n=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(n?.committed)continue;let c=i.snapshot;if(c.content.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");e.push({kind:je,content:to(c.content),inventory:c.inventory.map(d=>({...d})),activation:uc(c),url:c.url,mountPrefix:c.mountPrefix,integrity:{...c.integrity},materialized:!1,entries:c.entries.filter(d=>!d.deleted&&!d.materialized).map(({vfsPath:d,...u})=>({vfsPath:d,...u}))});continue}let s=Array.from(t.entries,([c,d])=>({vfsPath:c,ino:d.ino,generation:d.generation,dataSequence:d.dataSequence,size:d.size,isSymlink:d.isSymlink,deleted:d.deleted,materialized:d.materialized,archivePath:d.archivePath,sourcePath:d.sourcePath,type:d.type,inodeGroup:d.inodeGroup,target:d.target})).filter(c=>!c.deleted&&!c.materialized);if(s.length===0&&!(t.content&&t.inventory&&!t.materialized))continue;let o=t.content!==void 0&&t.inventory!==void 0&&t.activation!==void 0;if(o&&t.content.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");let a=t.activation?.atomicGroup;if(a!==void 0&&!mt(a))throw new Error(`Lazy atomic activation group ${a.id} must be sealed before serialization`);e.push(o?{kind:a!==void 0?je:t.content.source===void 0?jt:gr,content:t.content,inventory:t.inventory,activation:t.activation,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:s}:{kind:Vt,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:s})}return e}serializeValidatedLazyArchiveEntries(e){this.assertPendingLazyAtomicSnapshotsReadyForSerialization();let t=this.serializeLazyArchiveEntries();return Qi(t),this.validatePendingLazyTreeNamespaceState(e),t}exportLazyArchiveEntries(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),this.serializeValidatedLazyArchiveEntries(e)}pendingDeferredTreeUsage(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),ho(this.serializeValidatedLazyArchiveEntries(e))}assertCanAppendDeferredTreeUsage(e){vr(e);let t=this.pendingDeferredTreeUsage();vr({groups:t.groups+e.groups,archiveBytes:t.archiveBytes+e.archiveBytes,expandedBytes:t.expandedBytes+e.expandedBytes,payloadBytes:t.payloadBytes+e.payloadBytes,entries:t.entries+e.entries})}assertCanRegisterPendingLazyArchiveGroup(){if(this.reconcileLazyIdentityState(this.fs.identityState()),this.lazyArchiveGroups.filter(t=>{let n=this.lazyAtomicGroupByTree.get(t);return this.sealedLazyAtomicStates.get(t)?.snapshot!==void 0?!n?.committed:!t.materialized&&(t.content!==void 0&&t.inventory!==void 0||Array.from(t.entries.values()).some(s=>!s.deleted&&!s.materialized))}).length>=Se.maxGroups)throw new Error(`Cannot register another lazy archive group: ${Se.maxGroups} pending groups already exist`)}async preparePath(e){let t=!1,n=Math.max(3,this.lazyArchiveGroups.length+1);for(let i=0;i!s.materialized&&s.activation?.mode==="boot-prefetch"),t=0,n,i=Array.from({length:Math.min(e.length,Na)},async()=>{for(;n===void 0;){let s=t;if(t+=1,s>=e.length)return;try{await this.prepareLazyTreeGroup(e[s])}catch(o){n??=o}}});if(await Promise.all(i),n!==void 0)throw n;return e.length}async materializeRegisteredDeferredTree(e,t){let n=this.deferredTreeMaterializationHandles.get(e);if(n===void 0)throw new Error("Deferred-tree handle was not issued by this filesystem");let i=this.lazyAtomicGroupByTree.get(n);if(i!==void 0)throw new Error(`Deferred tree belongs to atomic activation group ${i.id}; materialize the complete group instead`);if(n.materialized)return!1;let s=this.lazyPreparations.get(n);if(s!==void 0)return s.promise;let o=new Uint8Array(t.byteLength);o.set(t);let a={status:"pending",promise:Promise.resolve(!1)};a.promise=Promise.resolve().then(async()=>(await hr(o,"tree",n.integrity),await this.materializeArchiveBytes(n,o),!0)).then(c=>(a.status="fulfilled",c),c=>{throw a.status="rejected",a.error=c,c}),a.promise.catch(()=>{}),this.lazyPreparations.set(n,a);try{return await a.promise}finally{this.lazyPreparations.get(n)===a&&this.lazyPreparations.delete(n)}}async prepareLazyTreeGroup(e){let t=this.lazyAtomicGroupByTree.get(e);if(t?.committed||t===void 0&&e.materialized)return!1;let n=this.sealedLazyAtomicStates.get(e)?.snapshot,i={token:t?.token??e,path:n?.activation.roots[0]??e.activation?.roots[0]??e.mountPrefix,directGroup:e,...t===void 0?{}:{atomicGroup:t}},s=this.lazyPreparations.get(i.token)??this.startLazyPreparation(i);try{return await s.promise}finally{this.lazyPreparations.get(i.token)===s&&this.lazyPreparations.delete(i.token)}}async ensureMaterialized(e){return this.preparePath(e)}async materializePath(e){if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0)return!1;let t;try{t=this.fs.stat(e)}catch{return!1}let n=r.inodeKey(t.ino,t.generation),i=this.lazyFiles.get(n);if(i){let o=this.lazyTransport,a=await this.fetchLazyBytes({id:`file:${t.ino}`,kind:"file",url:i.url,path:i.path,fallbackTotalBytes:i.size},o);for(let c=0;c<3;c++){if(this.lazyFiles.get(n)!==i)return!1;for(let d of new Set([e,...i.paths]))if(J(o.signal),this.fs.replaceIfIdentity(d,i.ino,i.generation,i.dataSequence,a))return i.path=d,this.lazyFiles.delete(n),!0;this.reconcileLazyIdentityState(this.fs.identityState())}throw new Error(`Lazy file kept changing names while materializing: ${e}`)}let s=this.lazyArchiveInodes.get(n);return s?(await this.ensureArchiveMaterialized(s,{path:e,ino:t.ino,generation:t.generation}),!this.lazyArchiveInodes.has(n)):!1}async decodeAndValidateLazyTree(e,t,n){let i=n?.content??e.content,s=n?.inventory??e.inventory;if(!i||!s)throw new Error("Lazy tree is missing its decoder or complete inventory");let o=new Map,a=new Map(s.map(f=>[f.vfsPath,f]));if(i.source!==void 0)for(let f of i.source.entries)o.set(f.sourcePath,f);else for(let f of s){if(f.type==="hardlink"){let h=a.get(f.target);if(!h)throw new Error(`Lazy tree hardlink target disappeared: ${f.target}`);if(f.sourcePath===h.sourcePath)continue}if(o.get(f.sourcePath))throw new Error(`Lazy tree inventory duplicates source member ${f.sourcePath}`);o.set(f.sourcePath,{sourcePath:f.sourcePath,type:f.type,mode:f.mode,size:f.size,...f.type==="symlink"?{target:f.target}:{},...f.type==="hardlink"?{target:a.get(f.target)?.sourcePath}:{}})}let c=new Map,d=0;if(i.decoder==="zip-v1"){let{parseZipCentralDirectory:f,extractZipEntryBounded:y}=await Promise.resolve().then(()=>(ir(),rr)),h=f(t);if(h.length!==i.sourceEntryCount||h.length!==o.size)throw new Error("Lazy ZIP tree decoded inventory counts differ from its descriptor");for(let p of h){let w=p.isDirectory?p.fileName.replace(/\/$/,""):p.fileName;if(c.has(w))throw new Error(`Lazy ZIP tree duplicates source member ${w}`);let m=o.get(w);if(!m)throw new Error(`Lazy ZIP tree has undeclared source member ${w}`);if(d+=p.uncompressedSize,d>i.expandedBytes||p.uncompressedSize!==m.size)throw new Error(`Lazy ZIP tree member ${w} exceeds its inventory`);let g=p.isDirectory?"directory":p.isSymlink?"symlink":"file",v=i.modePolicy==="portable-posix-v1"?g==="directory"?493:g==="symlink"?511:(p.mode&73)!==0?493:420:p.mode&4095;if(g!==m.type||v!==m.mode)throw new Error(`Lazy ZIP tree member ${w} differs from inventory`);if(p.isDirectory)c.set(w,{type:"directory",mode:v});else{let z=y(t,p,m.size);if(p.isSymlink){let E;try{E=new TextDecoder("utf-8",{fatal:!0}).decode(z)}catch{throw new Error(`Lazy ZIP tree symlink ${w} is not UTF-8`)}c.set(w,{type:"symlink",mode:v,target:E})}else c.set(w,{type:"file",mode:v,data:z})}}}else{let{parseTarGzip:f}=await Promise.resolve().then(()=>(Wi(),Ki)),y=f(t,{label:`Lazy tree ${i.sha256}`,limits:{maxCompressedBytes:i.bytes,maxUncompressedBytes:i.expandedBytes,maxEntries:i.sourceEntryCount}});d=new DataView(t.buffer,t.byteOffset,t.byteLength).getUint32(t.byteLength-4,!0);for(let h of y){if(c.has(h.path))throw new Error(`Lazy TAR tree duplicates source member ${h.path}`);h.type==="file"?c.set(h.path,{type:"file",mode:h.mode,data:h.data}):h.type==="directory"?c.set(h.path,{type:"directory",mode:h.mode}):c.set(h.path,{type:h.type,mode:h.mode,target:h.linkName})}}if(c.size!==i.sourceEntryCount||c.size!==o.size||d!==i.expandedBytes)throw new Error("Lazy tree decoded inventory counts differ from its descriptor");for(let[f,y]of o){let h=c.get(f);if(!h)throw new Error(`Lazy tree is missing source member ${f}`);let p=y.type;if(h.type!==p)throw new Error(`Lazy tree member ${f} is ${h.type}, expected ${p}`);if((h.mode&4095)!==y.mode)throw new Error(`Lazy tree member ${f} mode differs from inventory`);if(p==="file"&&h.data?.byteLength!==y.size)throw new Error(`Lazy tree member ${f} size differs from inventory`);if(p==="symlink"&&h.target!==y.target)throw new Error(`Lazy tree symlink ${f} target differs from inventory`);if(p==="hardlink"&&h.target!==y.target)throw new Error(`Lazy tree hardlink ${f} target differs from inventory`)}let u=new Set(s.flatMap(f=>f.materialization==="archive-homebrew-relocate"?[f.sourcePath]:[]));if(i.source!==void 0){let f=new Map(i.source.entries.map(p=>[p.sourcePath,p])),y=yo(i.source.entries),h=i.source.entries.filter(p=>p.sourcePath==="INSTALL_RECEIPT.json"||p.sourcePath.endsWith("/INSTALL_RECEIPT.json"));if(h.length>1)throw new Error(`Lazy Homebrew bottle has ${h.length} INSTALL_RECEIPT.json source members, expected at most one`);if(h.length===0){if(u.size>0)throw new Error("Lazy Homebrew bottle marks receipt relocation without INSTALL_RECEIPT.json")}else{let p=h[0],w=p.type==="file"?p:y.get(p.sourcePath),m=w===void 0?void 0:c.get(w.sourcePath);if(w?.type!=="file"||m?.type!=="file"||m.data===void 0)throw new Error("Lazy Homebrew bottle INSTALL_RECEIPT.json is not regular");let g=mi(m.data),v=p.sourcePath.lastIndexOf("/"),z=v<0?"":p.sourcePath.slice(0,v),E=new Set(g.changedFiles.map(b=>z.length===0?b:`${z}/${b}`));if(u.size!==E.size||[...u].some(b=>!E.has(b)))throw new Error("Lazy Homebrew bottle relocation markers differ from INSTALL_RECEIPT.json");let S=new Set;for(let b of E){let k=f.get(b),x=k?.type==="file"?k:k===void 0?void 0:y.get(k.sourcePath),I=x===void 0?void 0:c.get(x.sourcePath);if(x?.type!=="file"||I?.type!=="file"||I.data===void 0)throw new Error(`Lazy Homebrew bottle changed source ${b} is not regular`);S.has(x.sourcePath)||(I.data=gi(I.data,g,b),S.add(x.sourcePath))}}}else if(u.size>0)throw new Error("Lazy tree receipt relocation requires original-bottle source truth");let l=new Map;for(let f of s){if(f.type!=="file"||f.materialization==="descriptor")continue;let y=c.get(f.sourcePath);if(y?.type!=="file"||!y.data)throw new Error(`Lazy tree has no file content for ${f.sourcePath}`);l.set(f.sourcePath,y.data)}return l}async ensureArchiveMaterialized(e,t){let n=this.lazyAtomicGroupByTree.get(e);if(n!==void 0){if(n.committed)return;let o=this.sealedLazyAtomicStates.get(e)?.snapshot,a=this.lazyPreparations.get(n.token)??this.startLazyPreparation({token:n.token,path:o?.activation.roots[0]??e.activation?.roots[0]??e.mountPrefix,atomicGroup:n});try{await a.promise}finally{this.lazyPreparations.get(n.token)===a&&this.lazyPreparations.delete(n.token)}return}if(e.materialized)return;let i=this.lazyTransport,s=await this.fetchLazyArchiveData(e,i);J(i.signal),await this.materializeArchiveBytes(e,s,t,i.signal)}async fetchLazyArchiveData(e,t,n){let i=n?.content??e.content,s=n?.inventory??e.inventory,o=i!==void 0&&s!==void 0,a=n?.mountPrefix??e.mountPrefix,c=n?.integrity??e.integrity,d=o?i.transports:[n?.url??e.url],u=[],l=null;for(let[f,y]of d.entries())try{l=await this.fetchLazyBytes({id:`archive:${a}:${i?.sha256??y}:${f}`,kind:o?"tree":"archive",url:y,mountPrefix:a,integrity:c},t);break}catch(h){if(J(t.signal),fo(h))throw h;u.push(h instanceof Error?h.message:String(h))}if(J(t.signal),l===null)throw new Error(`All ${d.length} lazy ${o?"tree":"archive"} transports failed: ${u.join("; ")}`);return l}async materializeArchiveBytes(e,t,n,i){if(J(i),e.materialized)return;let s=await this.prepareLazyArchiveContents(e,t,i),o=n?r.inodeKey(n.ino,n.generation):null;for(let a=0;a<3;a++){let c=this.collectLazyArchiveReplacements(e,s,n);if(c.size>0&&(J(i),!this.fs.replaceManyIfIdentities(Array.from(c.values(),io)))){if(this.reconcileLazyIdentityState(this.fs.identityState()),o&&!this.lazyArchiveInodes.has(o))return;continue}if(J(i),this.publishLazyArchiveReplacements(e,c),e.materialized||(this.reconcileLazyIdentityState(this.fs.identityState()),o&&!this.lazyArchiveInodes.has(o)))return}if(o&&this.lazyArchiveInodes.has(o))throw new Error(`Lazy archive member kept changing names while materializing: ${n?.path}`)}async prepareLazyArchiveContents(e,t,n,i){J(n);let s=i?.content??e.content,o=i?.inventory??e.inventory,c=s!==void 0&&o!==void 0?await this.decodeAndValidateLazyTree(e,t,i):null;J(n);let{parseZipCentralDirectory:d,extractZipEntry:u}=await Promise.resolve().then(()=>(ir(),rr));J(n);let l=c?[]:d(t),f=new Map;for(let m of l){if(f.has(m.fileName))throw new Error(`Lazy archive contains duplicate member: ${m.fileName}`);f.set(m.fileName,m)}let h=(i?.mountPrefix??e.mountPrefix).replace(/\/+$/,""),p=new Map,w=i===void 0?Array.from(e.entries):i.entries.map(m=>[m.vfsPath,m]);for(let[m,g]of w){if(g.deleted||g.materialized)continue;let v=g.archivePath??m.slice(h.length+1),z=c?void 0:f.get(v),E=c?.get(v);if(c){if(E===void 0||E.byteLength!==g.size)throw new Error(`Lazy tree member ${v} does not match its registered metadata`)}else if(z===void 0||z.isDirectory||z.isSymlink||z.uncompressedSize!==g.size)throw new Error(`Lazy archive member ${v} does not match its registered metadata`);if(g.generation===void 0)continue;let S=r.inodeKey(g.ino,g.generation),b=p.get(S);if(b&&b.archivePath!==v)throw new Error(`Lazy archive aliases for inode ${S} name different members`);if(!b){let k=E??u(t,z);if(k.byteLength!==g.size)throw new Error(`Lazy archive member ${v} extracted ${k.byteLength} bytes, expected ${g.size}`);p.set(S,{archivePath:v,content:k})}}return p}collectLazyArchiveReplacements(e,t,n,i){let s=new Map,o=i===void 0?Array.from(e.entries):i.entries.map(a=>[a.vfsPath,a]);for(let[a,c]of o){if(c.deleted||c.materialized||c.generation===void 0)continue;let d=r.inodeKey(c.ino,c.generation);if(this.lazyArchiveInodes.get(d)!==e)continue;let u=t.get(d);if(!u)throw new Error(`Lazy archive has no extracted content for inode ${d}`);let l=s.get(d);l||(l={ino:c.ino,generation:c.generation,dataSequence:c.dataSequence??0,paths:new Set,content:u.content},s.set(d,l)),l.paths.add(a),n&&n.ino===c.ino&&n.generation===c.generation&&l.paths.add(n.path)}return s}publishLazyArchiveReplacements(e,t){for(let[n,i]of t){this.lazyArchiveInodes.delete(n);for(let s of e.entries.values())s.ino===i.ino&&s.generation===i.generation&&(s.materialized=!0)}e.materialized=Array.from(e.entries.values()).every(n=>n.deleted||n.materialized)}collectAtomicTreeNamespace(e,t){let n=new Set,i=new Map,s=new Map,o=new Map(t.entries.map(c=>[c.vfsPath,c]));for(let c of t.inventory)(c.type==="file"||c.type==="hardlink")&&s.set(c.inodeGroup,(s.get(c.inodeGroup)??0)+1);let a=[];for(let c of t.inventory){let d;try{d=this.fs.lstat(c.vfsPath)}catch{throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`)}let u=c.type==="directory"?Ve:c.type==="symlink"?mn:Kt;if((d.mode&Le)!==u||(d.mode&4095)!==c.mode)throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`);if(c.type==="symlink"){let l=o.get(c.vfsPath);if(l===void 0||!l.isSymlink||l.deleted||l.ino!==d.ino||l.generation!==d.generation||l.dataSequence!==d.dataSequence||this.fs.readlink(c.vfsPath)!==c.target)throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`)}else if(c.type==="file"||c.type==="hardlink"){let l=o.get(c.vfsPath);if(l===void 0||l.deleted||l.materialized||l.isSymlink||l.generation===void 0||l.inodeGroup!==c.inodeGroup||l.ino!==d.ino||l.generation!==d.generation||l.dataSequence!==d.dataSequence||d.size!==0||d.linkCount!==s.get(c.inodeGroup))throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`);let f=r.inodeKey(l.ino,l.generation);if(this.lazyArchiveInodes.get(f)!==e)throw new Error(`Lazy atomic tree lost deferred ownership of ${c.vfsPath}`);let y=i.get(c.inodeGroup);if(y!==void 0&&y!==f)throw new Error(`Lazy atomic tree split hard links at ${c.vfsPath}`);i.set(c.inodeGroup,f),n.add(f)}a.push({path:c.vfsPath,expectedIno:d.ino,expectedGeneration:d.generation,expectedDataSequence:d.dataSequence,expectedMode:d.mode,expectedLinkCount:d.linkCount,expectedSize:d.size,expectedUid:d.uid,expectedGid:d.gid})}return{guards:a,pendingIdentities:n.size}}assertLazyAtomicSnapshotMatchesPublic(e){let t=this.sealedLazyAtomicStates.get(e),n=t?.snapshot,i=e.activation?.atomicGroup,s=n?.member??i?.member??"unknown",o;if(n!==void 0)try{o=wn(e,n.id,n.member)}catch{o=void 0}if(t===void 0||n===void 0||i===void 0||!mt(i)||i.id!==n.id||i.member!==n.member||i.descriptorSha256!==n.descriptorSha256||i.expectedCount!==n.expectedCount||i.cohortSha256!==n.cohortSha256||o===void 0||!ro(n,o))throw new Error(`Lazy atomic activation member ${s} changed after sealing`);return t}assertPendingLazyAtomicSnapshotsReadyForSerialization(){for(let e of this.lazyArchiveGroups){if(!this.sealedLazyAtomicStates.has(e)||this.lazyAtomicGroupByTree.get(e)?.committed)continue;let n=this.assertLazyAtomicSnapshotMatchesPublic(e);if(!n.verified)throw new Error(`Lazy atomic activation group ${n.snapshot.id} has not been cryptographically verified after import`)}}async validatePendingLazyAtomicGroupSeals(e){for(let t of this.lazyAtomicGroups.values()){if(t.committed||t.expectedCount===void 0||t.cohortSha256===void 0)continue;let n=[...t.groups.entries()].sort(([i],[s])=>is?1:0).map(([,i])=>i);await this.ensureLazyAtomicGroupSealValidated(t,n,e)}}async ensureLazyAtomicGroupSealValidated(e,t,n){if(this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,n))return;let i=e.sealValidationFlight;if(i===void 0&&(i=this.validateLazyAtomicGroupSealOnce(e,t),e.sealValidationFlight=i,i.then(()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)},()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)})),await i,!this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,n))throw new Error(`Lazy atomic activation group ${e.id} seal verification did not authenticate every member`)}assertLazyAtomicGroupSealValidatedAtLinearization(e,t,n){if(e.committed)return!0;if(e.expectedCount===void 0||e.cohortSha256===void 0||t.length!==e.expectedCount)throw new Error(`Lazy atomic activation group ${e.id} is not completely sealed`);let i=!0,s=[];for(let o of t){let a=this.assertLazyAtomicSnapshotMatchesPublic(o),c=a.snapshot;if(c.id!==e.id||c.expectedCount!==e.expectedCount||c.cohortSha256!==e.cohortSha256||e.groups.get(c.member)!==o)throw new Error(`Lazy atomic activation group ${e.id} has an inconsistent member`);i&&=a.verified,s.push(a)}if(i&&n)for(let o=0;ohp?1:0).map(([,h])=>h);if(t.length===0)throw new Error(`Lazy atomic activation group ${e.id} has no trees`);if(await this.ensureLazyAtomicGroupSealValidated(e,t,!0),e.committed)return;let n=t.map(h=>this.sealedLazyAtomicStates.get(h).snapshot),i=t.map((h,p)=>({group:h,...this.collectAtomicTreeNamespace(h,n[p])})),s=this.lazyTransport,o=new Array(t.length),a=0,c=!1,d,u=Array.from({length:Math.min(Ma,t.length)},async()=>{for(;!c;){let h=a++;if(h>=t.length)return;let p=t[h],w=n[h];try{let m=await this.fetchLazyArchiveData(p,s,w);J(s.signal),o[h]={group:p,snapshot:w,contents:await this.prepareLazyArchiveContents(p,m,s.signal,w)}}catch(m){c||(c=!0,d=m)}}});if(await Promise.all(u),c)throw o.fill(void 0),d;J(s.signal);for(let h of t)this.assertLazyAtomicSnapshotMatchesPublic(h);let l=[],f=[],y=[];for(let h=0;h{let a=this.lazyAtomicGroupByTree.get(o);return this.sealedLazyAtomicStates.get(o)?.snapshot===void 0?!o.materialized&&o.content!==void 0&&o.inventory!==void 0:!a?.committed});if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0&&n.length===0)return;let i=Array.from(this.lazyFiles.values(),o=>o.path);for(let o of i)await this.ensureMaterialized(o);let s=new Set(this.lazyArchiveInodes.values());for(let o of n)s.add(o);for(let o of s)await this.prepareLazyTreeGroup(o)}this.reconcileLazyIdentityState(this.fs.identityState());let e=this.lazyArchiveGroups.some(t=>{let n=this.lazyAtomicGroupByTree.get(t);return this.sealedLazyAtomicStates.get(t)?.snapshot===void 0?!t.materialized&&t.content!==void 0&&t.inventory!==void 0:!n?.committed});if(this.lazyFiles.size!==0||this.lazyArchiveInodes.size!==0||e)throw new Error("Cannot create a self-contained VFS image while lazy entries remain pending")}async saveImage(e){e?.materializeAll&&await this.materializeAllLazyEntries(),await this.validatePendingLazyAtomicGroupSeals(!1);let{bytes:t,identities:n}=this.fs.snapshotState({normalizeTimestampsMs:e?.normalizeTimestampsMs});this.reconcileLazyIdentityState(n);let i=this.serializeLazyEntries(),s=i.length>0,o=s?new TextEncoder().encode(JSON.stringify(i)):new Uint8Array(0);if(o.byteLength>Zt)throw new Error(`VFS image lazy metadata exceeds ${Zt} bytes`);let a=this.serializeValidatedLazyArchiveEntries(n),c=a.length>0,d=c?new TextEncoder().encode(JSON.stringify(a)):new Uint8Array(0);if(d.byteLength>Ht)throw new Error(`VFS image lazy archive metadata exceeds ${Ht} bytes`);let u=e?.metadata===void 0?this.imageMetadata:e.metadata,l=Za(u),f=l.byteLength>0,y=c?4+d.byteLength:0,h=f?4+l.byteLength:0,p=ie+t.byteLength+4+o.byteLength+y+h,w=new Uint8Array(p),m=new DataView(w.buffer);m.setUint32(0,yr,!0),m.setUint32(4,pr,!0),m.setUint32(8,(s?dr:0)|(c?vn:0)|(c?ur:0)|(f?lr:0),!0),m.setUint32(12,t.byteLength,!0),w.set(t,ie);let g=ie+t.byteLength;if(m.setUint32(g,o.byteLength,!0),o.byteLength>0&&w.set(o,g+4),c){let v=g+4+o.byteLength;m.setUint32(v,d.byteLength,!0),w.set(d,v+4)}if(f){let v=g+4+o.byteLength+y;m.setUint32(v,l.byteLength,!0),w.set(l,v+4)}return w}static readImageMetadata(e){let t=gn(e);if(!(t.flags&lr))return null;let{metadataOffset:n}=Xi(t.image,t.view,t.flags,t.sabLen);if(t.image.byteLengthqe)throw new Error(`VFS image metadata exceeds ${qe} bytes`);if(t.image.byteLength0){let w=n.subarray(h+4,h+4+p),m=xe(Ji(w,"VFS image lazy metadata"),"VFS image lazy entries",0,gt);y.importLazyEntriesInternal(m,!0)}if(s&vn){let w=a.archiveOffset,m=i.getUint32(w,!0);if(m>0){let g=n.subarray(w+4,w+4+m),v=Ji(g,"VFS image lazy archive metadata");y.importLazyArchiveEntriesInternal(v,!0,!!(s&ur),"pending")}}return y}adaptStat(e){return{dev:0,ino:e.ino,mode:e.mode,nlink:e.linkCount,uid:e.uid,gid:e.gid,size:e.size,atimeMs:e.atime,mtimeMs:e.mtime,ctimeMs:e.ctime}}adaptStatWithLazySize(e){let t=this.adaptStat(e),n=this.lazyFileForStat(e);if(n)return t.size=n.size,t;let i=this.lazyArchiveForStat(e);if(i){for(let s of this.lazyArchiveEntriesForRead(i))if(s.ino===e.ino&&s.generation===e.generation&&!s.deleted){t.size=s.size;break}}return t}open(e,t,n){(t&Mt)===0&&!((t&Ct)!==0&&(t&qn)!==0)&&this.guardSynchronousLazyAccess(e);let i=this.fs.open(e,t,n);return(t&Mt)!==0&&this.invalidateLazyData(this.fs.fstat(i)),i}close(e){return this.fs.close(e),0}read(e,t,n,i){if(i>0){let s=this.lazyBackingForStat(this.fs.fstat(e));s&&(this.reconcileLazyIdentityState(this.fs.identityState()),s=this.lazyBackingForStat(this.fs.fstat(e)),s&&this.guardSynchronousLazyAccess(s.path))}return n!==null?this.fs.readAt(e,t.subarray(0,i),n):this.fs.read(e,t.subarray(0,i))}write(e,t,n,i){if(n!==null){let o=this.fs.writeAt(e,t.subarray(0,i),n);return o>0&&this.invalidateLazyData(this.fs.fstat(e)),o}let s=this.fs.write(e,t.subarray(0,i));return s>0&&this.invalidateLazyData(this.fs.fstat(e)),s}seek(e,t,n){return this.fs.lseek(e,t,n)}fstat(e){return this.adaptStatWithLazySize(this.fs.fstat(e))}fpathconf(e,t){let n=this.fstat(e);return Fn(n,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}ftruncate(e,t){this.fs.ftruncate(e,t),this.invalidateLazyData(this.fs.fstat(e))}fsync(e){}fchmod(e,t){this.fs.fchmod(e,t)}fchown(e,t,n){this.fs.fchown(e,t,n)}stat(e){return this.adaptStatWithLazySize(this.fs.stat(e))}lstat(e){return this.adaptStatWithLazySize(this.fs.lstat(e))}statfs(e){this.fs.stat(e);let t=this.fs.statfs();return{type:1397114451,bsize:t.blockSize,blocks:t.totalBlocks,bfree:t.freeBlocks,bavail:t.freeBlocks,files:t.totalInodes,ffree:t.freeInodes,fsid:0,namelen:t.maxName,frsize:t.blockSize,flags:0}}pathconf(e,t){let n=this.stat(e);return Fn(n,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}mkdir(e,t){this.fs.mkdir(e,t)}rmdir(e){this.fs.rmdir(e)}unlink(e){let t=this.fs.unlink(e),n=r.inodeKey(t.ino,t.generation);if(t.linkCount>1&&(this.lazyFiles.has(n)||this.lazyArchiveInodes.has(n))){this.reconcileLazyIdentityState(this.fs.identityState());return}let i=this.lazyFiles.get(n);i&&(i.paths.delete(e),t.linkCount<=1?this.lazyFiles.delete(n):i.path===e&&(i.path=i.paths.values().next().value));let s=this.lazyArchiveInodes.get(n);if(s){let o=s.entries.get(e);if(t.linkCount<=1){for(let a of s.entries.values())a.ino===t.ino&&a.generation===t.generation&&(a.deleted=!0);this.lazyArchiveInodes.delete(n)}else o&&s.entries.delete(e)}}rename(e,t){let{source:n,replaced:i}=this.fs.rename(e,t);if(i&&i.ino===n.ino&&i.generation===n.generation)return;let s=!1;if(i){let o=r.inodeKey(i.ino,i.generation);i.linkCount>1&&(this.lazyFiles.has(o)||this.lazyArchiveInodes.has(o))&&(this.reconcileLazyIdentityState(this.fs.identityState()),s=!0);let a=this.lazyFiles.get(o);!s&&a&&(a.paths.delete(t),i.linkCount<=1?this.lazyFiles.delete(o):a.path===t&&(a.path=a.paths.values().next().value));let c=this.lazyArchiveInodes.get(o);if(!s&&c){let d=c.entries.get(t);i.linkCount<=1?(d&&(d.deleted=!0),this.lazyArchiveInodes.delete(o)):d&&c.entries.delete(t)}}s||this.rewriteLazyNamespacePaths(n,e,t)}link(e,t){let n=this.fs.link(e,t),i=r.inodeKey(n.ino,n.generation),s=this.lazyFiles.get(i);s&&s.paths.add(t);let o=this.lazyArchiveInodes.get(i);if(o){let a=Array.from(o.entries.values()).find(c=>c.ino===n.ino&&c.generation===n.generation);a&&o.entries.set(t,{...a})}}symlink(e,t){this.fs.symlink(e,t)}readlink(e){return this.fs.readlink(e)}chmod(e,t){this.fs.chmod(e,t)}chown(e,t,n){this.fs.chown(e,t,n)}lchown(e,t,n){this.fs.lchown(e,t,n)}createFileWithOwner(e,t,n,i,s){let o=this.open(e,577,t);s.length>0&&this.write(o,s,null,s.length),this.close(o),this.chown(e,n,i),this.chmod(e,t)}mkdirWithOwner(e,t,n,i){this.mkdir(e,t),this.chown(e,n,i),this.chmod(e,t)}symlinkWithOwner(e,t,n,i){this.symlink(e,t),this.lchown(t,n,i)}copyPathToFreshFileSystem(e,t,n,i,s){let o=this.lstat(e),a=o.mode&Le,c=o.mode&4095;if(a===Ve){e==="/"?(t.chown(e,o.uid,o.gid),t.chmod(e,c)):t.mkdirWithOwner(e,c,o.uid,o.gid);let f=this.opendir(e);try{for(;;){let y=this.readdir(f);if(!y)break;y.name==="."||y.name===".."||this.copyPathToFreshFileSystem(e==="/"?`/${y.name}`:`${e}/${y.name}`,t,n,i,s)}}finally{this.closedir(f)}r.applyTimes(t,e,o);return}let d=o.nlink>1?`${o.dev}:${o.ino}`:null,u=d?s.get(d):void 0;if(u){t.link(u,e);return}if(a===mn){t.symlinkWithOwner(this.readlink(e),e,o.uid,o.gid),d&&s.set(d,e);return}if(a!==Kt)throw new Error(`Unsupported file type while rebasing VFS: ${e}`);if(n.has(e)||i.has(e)){t.createFileWithOwner(e,c,o.uid,o.gid,new Uint8Array(0)),r.applyTimes(t,e,o),d&&s.set(d,e);return}this.copyRegularFileToFreshFileSystem(e,t,o,c),d&&s.set(d,e)}copyRegularFileToFreshFileSystem(e,t,n,i){let s=this.open(e,Oa,0),o=null;try{o=t.open(e,Ta,i);let a=new Uint8Array(Math.min(Ra,Math.max(1,n.size))),c=n.size;for(;c>0;){let d=Math.min(a.byteLength,c),u=this.read(s,a,null,d);if(u<=0)throw new Error(`Unexpected EOF while rebasing VFS file: ${e}`);let l=0;for(;l!e||e==="."||e===".."))throw new Error(`Binary resolver path must be a normalized portable relative path: ${JSON.stringify(r)}`);return r}var Je=new Set(["wasm32","wasm64"]);function De(r){if(bc(r),!r.startsWith("programs/"))return r;let e=r.slice(9),t=e.split("/",1)[0];return Je.has(t)?r:`programs/wasm32/${e}`}function Ac(r,e=D(_r(),"wasm")){let t=De(r),n=[D(e,t)];return r==="kernel.wasm"?n.push(D(e,"kandelo-kernel.wasm")):r==="userspace.wasm"?n.push(D(e,"wasm_posix_userspace.wasm")):r==="rootfs.vfs"&&n.push(D(e,"rootfs.vfs")),n}var In=class extends Error{constructor(e){super(e),this.name="BinaryNotFoundError"}};function Po(){let r=[],e=!1;try{let n=Xe();e=!0;for(let[i,s]of[["local-binaries",D(n,"local-binaries")],["binaries",D(n,"binaries")]])r.push({label:i,root:s,identity:i==="local-binaries"?"local-generation":"program-cache",allowRegularFileClosure:!1,candidatesFor(o){return[D(s,De(o))]}})}catch{}let t=D(_r(),"wasm");return r.push({label:"installed package",root:t,identity:"installed-package",allowRegularFileClosure:!e,candidatesFor(n){return Ac(n,t)}}),r}function wt(r,e){return new Error(`Invalid package manifest ${r}: ${e}`)}function oe(r){try{return _n(r),!0}catch(e){if(e instanceof Error&&"code"in e&&e.code==="ENOENT")return!1;throw e}}function So(r,e,t){if(r.length===0||r.startsWith("/")||r.includes("\\")||r.includes("\0")||r.split("/").some(n=>!n||n==="."||n===".."))throw wt(e,`${t} must be a normalized portable relative path`);return r}function Ln(r,e,t,n=!0){if(r.length===0||r==="."||r===".."||r.includes("/")||r.includes("\\")||r.includes("\0")||!n&&r.includes("@"))throw wt(e,`${t} must be a safe single path component`);return r}var Eo="kandelo-program-packages-v2",Ie="program-packages.json",zo=null,kc=null,xn=null,Ar=0;function Pr(){return kc??D(_r(),"wasm",Ie)}function Oo(){if(Object.prototype.hasOwnProperty.call(process.env,"WASM_POSIX_DEPS_REGISTRY")){let t=null;return(process.env.WASM_POSIX_DEPS_REGISTRY??"").split(":").filter(Boolean).map(n=>n.startsWith("~/")&&process.env.HOME!==void 0?D(process.env.HOME,n.slice(2)):Pn(n)?we(n):(t??=Xe(),we(t,n)))}let r;try{r=D(Xe(),"packages","registry")}catch{return null}let e=!1;if(oe(r)){if(!Fe(r).isDirectory())return[r];e=xo(r,{withFileTypes:!0}).filter(t=>t.isDirectory()||t.isSymbolicLink()).some(t=>oe(D(r,t.name,"package.toml")))}return!e&&To()===null&&oe(Pr())?null:[r]}function To(){let r;try{r=Xe()}catch{return null}if(!Xt(D(r,"tools","xtask","Cargo.toml"))||!Xt(D(r,"scripts","dev-shell.sh")))return null;try{let e=ve(Ir()),t=ve(r);return[D(t,"host"),D(t,"scripts")].some(i=>Xt(i)&&Cr(ve(i),e))?t:null}catch{return null}}function Or(r,e,t){let n=[typeof t.stderr=="string"?t.stderr.trim():"",typeof t.stdout=="string"?t.stdout.trim():"",t.error?.message??""].filter(Boolean).join(` -`);return`${r} ${e.join(" ")} failed${t.status===null?"":` with status ${t.status}`}${n?`: -${n}`:""}`}function Lc(r){let e=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,t=e?"rustc":"bash",n=e?["-vV"]:[D(r,"scripts","dev-shell.sh"),"rustc","-vV"],i=xr(t,n,{cwd:r,encoding:"utf8"});if(i.status!==0)throw new Error(Or(t,n,i));let s=i.stdout.split(/\r?\n/).find(o=>o.startsWith("host: "))?.slice(6).trim();if(!s)throw new Error(`Could not determine the Rust host target for ${r}`);return s}function kr(r){try{if(_n(r).isFile())return ve(r)}catch{}throw new Error(`Prepared xtask is not a regular file: ${r}`)}function xc(r){let e=process.env.WASM_POSIX_XTASK_BIN;if(e!==void 0){let d=Pn(e)?we(e):we(r,e);return kr(d)}if(xn?.sourceRepoRoot===r)return kr(xn.xtaskPath);let t=Lc(r),n=D(r,"target",t,"release",process.platform==="win32"?"xtask.exe":"xtask"),i=["build","--release","-p","xtask","--target",t,"--quiet"],s=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,o=s?"cargo":"bash",a=s?i:[D(r,"scripts","dev-shell.sh"),"cargo",...i],c=xr(o,a,{cwd:r,encoding:"utf8"});if(c.status!==0)throw new Error(Or(o,a,c));return xn={sourceRepoRoot:r,xtaskPath:kr(n)},xn.xtaskPath}function Ic(){let r=To();if(r===null)return;let e=Oo();if(e===null)return;if(zo){zo(r,e);return}let t=xc(r),n=["build-deps","program-index-context-check","--source-repo-root",r],i=xr(t,n,{cwd:r,encoding:"utf8",env:{...process.env,WASM_POSIX_DEPS_REGISTRY:e.join(":")}});if(i.status!==0)throw new Error(`Program package source projection is not current: -${Or(t,n,i)}`)}function _c(r,e){if(Ar>0||!r.some(t=>t.startsWith("programs/")))return e();Ar+=1;try{return Ic(),e()}finally{Ar-=1}}function $e(r,e){let t=Object.keys(r).sort(),n=[...e].sort();return t.length===n.length&&t.every((i,s)=>i===n[s])}function Lr(r){let e;try{e=JSON.parse(Ye(r,"utf8"))}catch(o){throw new Error(`Invalid program package index ${r}: ${o instanceof Error?o.message:String(o)}`)}if(typeof e!="object"||e===null||!$e(e,["format","identities","packages"])||e.format!==Eo||typeof e.identities!="object"||e.identities===null||Array.isArray(e.identities)||typeof e.packages!="object"||e.packages===null||Array.isArray(e.packages))throw new Error(`Invalid program package index ${r}: expected ${Eo}`);let t=new Map,n=e.identities;for(let[o,a]of Object.entries(n)){if(Ln(o,r,"identity package name",!1),typeof a!="object"||a===null||!$e(a,["manifestSha256","cacheKeys"])||typeof a.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(a.manifestSha256)||typeof a.cacheKeys!="object"||a.cacheKeys===null||Array.isArray(a.cacheKeys))throw new Error(`Invalid program package index ${r}: malformed identity ${JSON.stringify(o)}`);let c=a.cacheKeys;if(!$e(c,["wasm32","wasm64"])||Object.values(c).some(d=>typeof d!="string"||!/^[a-f0-9]{64}$/.test(d)))throw new Error(`Invalid program package index ${r}: identity ${JSON.stringify(o)} has invalid contextual cache keys`);t.set(o,{manifestSha256:a.manifestSha256,cacheKeys:c})}let i=new Map,s=e.packages;for(let[o,a]of Object.entries(s)){if(Ln(o,r,"package name",!1),typeof a!="object"||a===null||!$e(a,["manifestSha256","arches","cacheKeys","dependencyClosures","members"])||!Array.isArray(a.arches)||typeof a.cacheKeys!="object"||a.cacheKeys===null||Array.isArray(a.cacheKeys)||typeof a.dependencyClosures!="object"||a.dependencyClosures===null||Array.isArray(a.dependencyClosures)||!Array.isArray(a.members)||typeof a.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(a.manifestSha256))throw new Error(`Invalid program package index ${r}: malformed package ${JSON.stringify(o)}`);let c=a.arches;if(c.length===0||new Set(c).size!==c.length||c.some(p=>typeof p!="string"||!Je.has(p)))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(o)} has invalid arches`);let d=a.cacheKeys;if(!$e(d,c)||Object.values(d).some(p=>typeof p!="string"||!/^[a-f0-9]{64}$/.test(p)))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(o)} has invalid cache keys`);let u=a.dependencyClosures;if(!$e(u,c))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(o)} has invalid dependency closure arches`);let l={};for(let p of c){let w=u[p];if(!Array.isArray(w))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(o)} has a malformed dependency closure for ${p}`);let m=new Set;l[p]=w.map((g,v)=>{if(typeof g!="object"||g===null||!$e(g,["packageName","manifestSha256","cacheKey"])||typeof g.packageName!="string"||typeof g.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(g.manifestSha256)||typeof g.cacheKey!="string"||!/^[a-f0-9]{64}$/.test(g.cacheKey))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(o)} dependency ${v+1} for ${p} is malformed`);let z=g;if(Ln(z.packageName,r,`${o} dependency packageName`,!1),z.packageName===o||m.has(z.packageName))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(o)} dependency closure for ${p} must contain unique dependencies other than itself`);m.add(z.packageName);let E=t.get(z.packageName);if(!E||E.manifestSha256!==z.manifestSha256||E.cacheKeys[p]!==z.cacheKey)throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(o)} dependency ${JSON.stringify(z.packageName)} for ${p} does not match the index's authoritative contextual identity`);return z})}let f=a.members.map((p,w)=>{if(typeof p!="object"||p===null||p.kind!=="output"&&p.kind!=="runtime-file"||typeof p.sourceArtifact!="string"||typeof p.mirrorPath!="string")throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(o)} member ${w+1} is malformed`);let m=p,g=m.kind==="output"?["kind","sourceArtifact","mirrorPath","outputName","forkInstrumentation"]:["kind","sourceArtifact","mirrorPath","guestPath","mode"];if(!$e(m,g))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(o)} member ${w+1} has unknown or missing fields`);if(So(m.sourceArtifact,r,`${o} sourceArtifact`),So(m.mirrorPath,r,`${o} mirrorPath`),m.kind==="output"){if(typeof m.outputName!="string"||m.forkInstrumentation!=="auto"&&m.forkInstrumentation!=="disabled")throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(o)} output member lacks outputName or forkInstrumentation`);Ln(m.outputName,r,`${o} outputName`)}else if(typeof m.guestPath!="string"||!m.guestPath.startsWith("/")||!Number.isInteger(m.mode)||m.mode<0||m.mode>511)throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(o)} runtime member lacks valid guestPath or mode`);return m});if(f.length===0||new Set(f.map(p=>p.sourceArtifact)).size!==f.length||new Set(f.map(p=>p.mirrorPath)).size!==f.length||f.length===1&&f[0].mirrorPath.includes("/")||f.length>1&&f.some(p=>!p.mirrorPath.startsWith(`${o}/`)))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(o)} members are empty, collide, or violate scalar/package-directory layout`);let y=a.manifestSha256,h=t.get(o);if(!h||h.manifestSha256!==y||c.some(p=>h.cacheKeys[p]!==d[p]))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(o)} does not match its contextual package identity`);i.set(o,{manifestSha256:y,arches:c,cacheKeys:d,dependencyClosures:l,members:f})}return{identities:t,packages:i,indexPath:r}}function Ro(r){return JSON.stringify({manifestSha256:r.manifestSha256,arches:r.arches,cacheKeys:Object.fromEntries(r.arches.map(e=>[e,r.cacheKeys[e]])),dependencyClosures:Object.fromEntries(r.arches.map(e=>[e,[...r.dependencyClosures[e]].sort((t,n)=>t.packageNamen.packageName?1:0)])),members:r.members.map(e=>e.kind==="output"?{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,outputName:e.outputName,forkInstrumentation:e.forkInstrumentation}:{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,guestPath:e.guestPath,mode:e.mode})})}function Tr(){let r=Pr();return oe(r)?Lr(r):null}function Pc(r){let e=Tr();if(!e)return null;let t=r.split("/");if(t[0]!=="programs"||!Je.has(t[1]))return null;let n=t[1];if(t.length>=4){let s=t[2];return e.packages.get(s)?.arches.includes(n)?s:null}if(t.length!==3)return null;let i=t[2];for(let[s,o]of e.packages)if(o.arches.includes(n)&&o.members.some(a=>a.kind==="output"&&a.mirrorPath.split("/").at(-1)===i))return s;return null}function bo(r){let e=Pc(r);if(e)throw new Error(`Installed package resolver path ${JSON.stringify(r)} is owned by ${JSON.stringify(e)}, but that package is not selected by the configured program registry`)}function Bo(){let r=Oo(),e=new Map,t=new Map,n=new Map,i=new Map,s=[];if(r===null){let d=Pr();if(!oe(d))return{identities:e,unidentifiedPackages:t,packages:n,unprojectedPackages:i,physicalProgramClaims:s};let u=Lr(d);for(let[l,f]of u.identities)e.set(l,{...f,packageName:l,policyPath:`${u.indexPath}#identities.${l}`});for(let[l,f]of u.packages)s.push({packageName:l,projection:f,selected:!0}),n.set(l,{...f,packageName:l,policyPath:`${u.indexPath}#${l}`});return{identities:e,unidentifiedPackages:t,packages:n,unprojectedPackages:i,physicalProgramClaims:s}}let o=new Set,a=null,c=null;for(let d of r){if(!oe(d))continue;if(!Fe(d).isDirectory())throw new Error(`Program registry root is not a directory: ${d}`);let u=D(d,Ie);if(!oe(u))throw new Error(`Program registry ${d} is missing ${Ie}; generate it with xtask build-deps program-index`);let l=Lr(u);a??=l.identities,c??=l.packages;let f=xo(d,{withFileTypes:!0}).filter(y=>y.isDirectory()||y.isSymbolicLink()).sort((y,h)=>y.name.localeCompare(h.name));for(let y of f){let h=y.name,p=D(d,h,"package.toml");if(!oe(p))continue;let w=!1;try{w=Fe(p).isFile()}catch{w=!1}if(!w)continue;let m=l.packages.get(h),g=!o.has(h);if(m&&s.push({packageName:h,projection:m,selected:g}),!g)continue;o.add(h);let v=a.get(h);v?e.set(h,{...v,packageName:h,manifestPath:p,policyPath:p}):t.set(h,p);let z=c.get(h);if(!z){i.set(h,p);continue}n.set(h,{...z,packageName:h,manifestPath:p,policyPath:p})}}return{identities:e,unidentifiedPackages:t,packages:n,unprojectedPackages:i,physicalProgramClaims:s}}function Ao(r){if(!r.manifestPath)return;let e;try{e=Ye(r.manifestPath)}catch(n){throw new Error(`Program package identity cannot verify ${r.manifestPath}: ${n instanceof Error?n.message:String(n)}`)}if(Io("sha256").update(e).digest("hex")!==r.manifestSha256)throw new Error(`Program package identity is stale for ${r.manifestPath}; regenerate ${Ie}`)}function Oc(r){if(!r.manifestPath)return;let e;try{e=Ye(r.manifestPath)}catch(n){throw new Error(`Program package projection cannot verify ${r.manifestPath}: ${n instanceof Error?n.message:String(n)}`)}if(Io("sha256").update(e).digest("hex")!==r.manifestSha256)throw new Error(`Program package projection is stale for ${r.manifestPath}; regenerate ${Ie}`)}function Jt(r){let e=Rr(),t=e.packages.get(r);if(t)return Oc(t),t;let n=e.unprojectedPackages.get(r);if(n)throw new Error(`Package ${JSON.stringify(r)} is selected at ${n} but is absent from ${Ie}; regenerate the registry projection`);return null}function Tc(r,e){let t=r.dependencyClosures[e];if(!t)throw wt(r.policyPath,`package ${JSON.stringify(r.packageName)} lacks a dependency identity closure for ${e}`);let n=Bo(),i=n.identities.get(r.packageName);if(!i){let o=n.unidentifiedPackages.get(r.packageName);throw new Error(`Program package ${JSON.stringify(r.packageName)} has no authoritative contextual identity for ${e}${o?` at ${o}`:""}; regenerate ${Ie} with the exact ordered registry roots`)}Ao(i);let s=i.cacheKeys[e];if(i.manifestSha256!==r.manifestSha256||s!==r.cacheKeys[e])throw new Error(`Program package ${JSON.stringify(r.packageName)} was projected with manifest ${r.manifestSha256} and cache key ${r.cacheKeys[e]} for ${e}, but the authoritative first-hit registry context at ${i.policyPath} requires manifest ${i.manifestSha256} and cache key ${s??""}. Regenerate this program projection with the exact ordered registry roots; the highest-priority index must carry the complete combined-context projection rather than relying on a lower suffix-context build identity.`);for(let o of t){let a=n.identities.get(o.packageName);if(!a){let d=n.unidentifiedPackages.get(o.packageName);throw d?new Error(`Program package ${JSON.stringify(r.packageName)} was generated against dependency ${JSON.stringify(o.packageName)}, but the first-hit package at ${d} has no contextual identity in ${Ie}`):new Error(`Program package ${JSON.stringify(r.packageName)} was generated against dependency ${JSON.stringify(o.packageName)}, but that dependency is absent from the configured first-hit registry roots`)}Ao(a);let c=a.cacheKeys[e];if(a.manifestSha256!==o.manifestSha256||c!==o.cacheKey)throw new Error(`Program package ${JSON.stringify(r.packageName)} has a contextual cache identity mismatch for ${e}: its projection expects dependency ${JSON.stringify(o.packageName)} manifest ${o.manifestSha256} and cache key ${o.cacheKey}, but first-hit selection at ${a.policyPath} provides manifest ${a.manifestSha256} and cache key ${c??""}. Regenerate the program projection with the exact ordered registry roots; the complete highest-priority projection must bind every selected program to the same combined dependency context.`)}}function Rr(){let r=Bo(),{physicalProgramClaims:e,...t}=r,n={...t,legacyFlatOutputs:new Map,forkInstrumentationDisabledOutputs:new Map},i=[];for(let s of r.packages.values()){let o=s.members.length>1;for(let a of s.arches)for(let c of s.members){let d=i.find(y=>y.arch===a&&(y.path===c.mirrorPath||y.path.startsWith(`${c.mirrorPath}/`)||c.mirrorPath.startsWith(`${y.path}/`)));if(d)throw new Error(`Program resolver paths programs/${a}/${d.path} and programs/${a}/${c.mirrorPath} conflict between selected packages ${JSON.stringify(d.packageName)} and ${JSON.stringify(s.packageName)}`);if(i.push({arch:a,path:c.mirrorPath,packageName:s.packageName}),c.kind!=="output")continue;let u=c.mirrorPath.split("/").at(-1),l=`${a}/${u}`,f=n.legacyFlatOutputs.get(l);f||(f={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},n.legacyFlatOutputs.set(l,f)),o?f.packagePaths.set(`programs/${a}/${c.mirrorPath}`,s.packageName):f.scalarOwners.add(s.packageName),c.forkInstrumentation==="disabled"&&n.forkInstrumentationDisabledOutputs.set(`${a}/${c.mirrorPath}`,s.packageName)}}for(let{packageName:s,projection:o,selected:a}of e)if(!(a&&r.packages.has(s)))for(let c of o.arches)for(let d of o.members){if(d.kind!=="output")continue;let u=d.mirrorPath.split("/").at(-1),l=`${c}/${u}`,f=n.legacyFlatOutputs.get(l);f||(f={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},n.legacyFlatOutputs.set(l,f)),f.shadowedOwners.add(s)}return n}function Rc(r){let e=r.split("/");if(e.length!==3||e[0]!=="programs"||!Je.has(e[1]))return null;let t=Rr().legacyFlatOutputs.get(`${e[1]}/${e[2]}`);if(!t)return null;for(let n of t.scalarOwners){let i=Jt(n);if(i)return i}for(let n of t.packagePaths.values())Jt(n);if(t.packagePaths.size>0)throw new Error(`Legacy flat resolver path ${JSON.stringify(r)} belongs to a multi-member package; use ${[...t.packagePaths.keys()].sort().map(n=>JSON.stringify(n)).join(" or ")}`);for(let n of t.shadowedOwners){let i=Jt(n);if(i)return i;throw new Error(`Legacy flat resolver path ${JSON.stringify(r)} is claimed by a lower-root program package ${JSON.stringify(n)}, but its first-hit selected package does not project that program; stale scalar mirror fallback is forbidden`)}return null}function ko(r,e,t){if(!r.arches.includes(e))throw wt(r.policyPath,`package ${JSON.stringify(r.packageName)} does not declare resolver artifacts for ${e}`);let n=r.cacheKeys[e];if(!n)throw wt(r.policyPath,`package ${JSON.stringify(r.packageName)} lacks a cache identity for ${e}`);Tc(r,e);let i=Ro(r),s=r.members.map(o=>({packageName:r.packageName,relPath:`programs/${e}/${o.mirrorPath}`,sourceArtifact:o.sourceArtifact,cacheKey:n,forkInstrumentation:o.kind==="output"?o.forkInstrumentation??null:null,projectionIdentity:i}));if(!s.some(o=>o.relPath===t))throw wt(r.policyPath,`resolver path ${JSON.stringify(t)} is not a declared member of package ${JSON.stringify(r.packageName)}`);return{manifestPath:r.policyPath,packageName:r.packageName,members:s}}function Bc(r){let e=De(r),t=e.split("/");if(t[0]==="programs"&&!Ec()&&Tr()===null)throw new Error(`Installed host package is missing wasm/${Ie}; program artifacts cannot be resolved without packaged policy`);if(t.length===3){let o=Rc(e);return o?ko(o,t[1],e):(bo(e),null)}if(t.length<4||t[0]!=="programs"||!Je.has(t[1]))return null;let n=t[1],i=t[2],s=Jt(i);return s?ko(s,n,e):(bo(e),null)}function Cc(r){let e=De(r);for(let t of["programs/wasm32/","programs/wasm64/"])if(e.startsWith(t))return e.slice(t.length);return null}function Nc(r){let e=De(r);for(let t of Je){let n=`programs/${t}/`;if(e.startsWith(n)){let i=Rr().forkInstrumentationDisabledOutputs.get(`${t}/${e.slice(n.length)}`);return i?Jt(i)!==null:!1}}return!1}function Mc(r){let e=De(r);if(e==="kernel.wasm")return Dr;let t=Cc(e);if(t&&t.endsWith(".wasm"))return vc}function $c(r,e,t){if(!r.endsWith(".wasm"))return!1;try{let n=Ye(r),i=n.buffer.slice(n.byteOffset,n.byteOffset+n.byteLength),s=t===void 0?Nc(e):t==="disabled";return Hr(i,{expectedAbi:42,requiredExports:Mc(e),requireForkInstrumentation:s?!1:void 0,forbidForkInstrumentation:s}).length>0}catch{return!0}}function Fc(r){if(!r.endsWith(".vfs")&&!r.endsWith(".vfs.zst"))return!1;try{let t=An.readImageMetadata(Ye(r))?.kernelAbi;return t!==void 0&&t!==42}catch{return!0}}function Br(r,e,t){return $c(r,e,t)||Fc(r)}function Co(r,e,t){let n=r.filter(oe);return n.length===0?null:n.find(i=>{try{return Fe(i).isFile()&&!Br(i,e,t)}catch{return!1}})??null}function No(r,e,t){try{if(!_n(r).isSymbolicLink())return r;let i=ve(r);if(!Fe(i).isFile()||Br(i,e,t))throw new Error("canonical target is not an accepted regular file");if(De(e).startsWith("programs/")&&Dc(i))throw new Error("resolver-owned program generation has no matching selected package projection");return i}catch(n){throw new Error(`Binary changed or became invalid while pinning ${e}: ${n instanceof Error?n.message:String(n)}`)}}function Dc(r){let e=[_o()];try{e.push(D(Xe(),"local-binaries",".kandelo-local-generations"))}catch{}return e.some(t=>{try{return oe(t)&&Cr(ve(t),r)}catch{return!1}})}function Cr(r,e){let t=mc(r,e);return t===""||t!==".."&&!t.startsWith(`..${gc}`)&&!Pn(t)}function Uc(r,e){let t=e.split("/"),n=r;for(let i=0;ia.packageName!==s))return"declared package members do not share a valid program namespace";if(!Fe(e).isDirectory())return"shared package generation root is not a directory";let o=t[0].cacheKey;if(!/^[a-f0-9]{64}$/.test(o)||t.some(a=>a.cacheKey!==o))return"declared package members do not share one valid cache identity";if(r.identity==="local-generation"){let a=D(r.root,".kandelo-local-generations",i,s,o);if(!oe(a))return"local mirror targets are not one direct immutable local generation";let c=ve(a);return Qt(e)===c?null:"local mirror targets are not one direct immutable local generation"}if(r.identity==="program-cache"){let a=_o();if(!oe(a))return"fetched mirror targets are not one canonical program-cache generation";let c=ve(a),d=pc(e),u=d.startsWith(`${s}-`)&&new RegExp(`-rev[0-9]+-${i}-${o}$`).test(d);return Qt(e)===c&&u?null:"fetched mirror targets are not one canonical program-cache generation"}return"installed-package symlink closures are not an immutable installed identity"}function Kc(r,e,t){if(e.length!==t.length)return{failure:"internal member/path count mismatch"};try{let n=e.map(d=>{let u=_n(d);return u.isSymbolicLink()?"symlink":u.isFile()?"file":"other"});if(n.includes("other"))return{failure:"a selected mirror member is neither a regular file nor a symlink"};let i=n.every(d=>d==="symlink"),s=n.every(d=>d==="file");if(!i&&!s)return{failure:"regular files and symlinks cannot share one package identity"};if(s){if(!r.allowRegularFileClosure)return{failure:"a mutable source-checkout wasm tree is not an installed package identity"};let d=t[0].packageName,u=t[0].projectionIdentity;if(t.some(p=>p.packageName!==d||p.projectionIdentity!==u))return{failure:"declared members do not share one selected package projection"};let f=Tr()?.packages.get(d);if(!f||Ro(f)!==u)return{failure:"installed bytes do not match the selected package projection"};let y=ve(r.root),h=[];for(let p of e){let w=ve(p);if(!Cr(y,w)||!Fe(w).isFile())return{failure:"an installed-package member escapes its immutable wasm tree"};h.push(w)}return{paths:h}}let o=null,a=[];for(let d=0;dWc(r))}function Wc(r){let e=De(r),t=Bc(e);if(t){let o=Zc(t.members.map(a=>a.relPath),t.members);if(o)return o[t.members.findIndex(a=>a.relPath===e)];throw new In(`Package artifacts not found for ${t.packageName}: ${e}`)}let n=[],i=[];for(let o of Po())for(let a of o.candidatesFor(r))n.push(a),i.push(a);let s=Co(i,r);if(s)return No(s,r);throw i.some(oe)?new Error(`Binary exists but was rejected by artifact policy: ${r} -`+n.map(o=>` checked: ${o}`).join(` -`)):new In(`Binary not found: ${r} -`+n.map(o=>` checked: ${o}`).join(` +var ra=Object.defineProperty;var nn=(n,e,t)=>()=>{if(t)throw t[0];try{return n&&(e=n(n=0)),e}catch(r){throw t=[r],r}};var Ri=(n,e)=>{for(var t in e)ra(n,t,{get:e[t],enumerable:!0})};import{createRequire as Oc}from"module";function Zo(n,e){return qo(n,{i:2},e&&e.out,e&&e.dictionary)}var Ic,xt,xc,vc,oe,Ot,Rc,Bo,$o,Lc,Uo,xt,Wo,bc,Go,Tc,Vu,Wn,ze,M,nr,ir,M,M,M,M,Ho,M,zc,kc,$n,Ae,Un,Vo,Kr,Pc,me,qo,Nc,Fc,It,Xo,Cc,Mc,Gn=nn(()=>{Ic=Oc("/");try{xt=Ic("worker_threads"),xc=xt.Worker,vc=xt.isMarkedAsUntransferable}catch{}oe=Uint8Array,Ot=Uint16Array,Rc=Int32Array,Bo=new oe([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),$o=new oe([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),Lc=new oe([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),Uo=function(n,e){for(var t=new Ot(31),r=0;r<31;++r)t[r]=e+=1<>1|(M&21845)<<1,ze=(ze&52428)>>2|(ze&13107)<<2,ze=(ze&61680)>>4|(ze&3855)<<4,Wn[M]=((ze&65280)>>8|(ze&255)<<8)>>1;nr=(function(n,e,t){for(var r=n.length,i=0,s=new Ot(e);i>c]=l}else for(a=new Ot(r),i=0;i>15-n[i]);return a}),ir=new oe(288);for(M=0;M<144;++M)ir[M]=8;for(M=144;M<256;++M)ir[M]=9;for(M=256;M<280;++M)ir[M]=7;for(M=280;M<288;++M)ir[M]=8;Ho=new oe(32);for(M=0;M<32;++M)Ho[M]=5;zc=nr(ir,9,1),kc=nr(Ho,5,1),$n=function(n){for(var e=n[0],t=1;te&&(e=n[t]);return e},Ae=function(n,e,t){var r=e/8|0;return(n[r]|n[r+1]<<8)>>(e&7)&t},Un=function(n,e){var t=e/8|0;return(n[t]|n[t+1]<<8|n[t+2]<<16)>>(e&7)},Vo=function(n){return(n+7)/8|0},Kr=function(n,e,t){return(e==null||e<0)&&(e=0),(t==null||t>n.length)&&(t=n.length),new oe(n.subarray(e,t))},Pc=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],me=function(n,e,t){var r=new Error(e||Pc[n]);if(r.code=n,Error.captureStackTrace&&Error.captureStackTrace(r,me),!t)throw r;return r},qo=function(n,e,t,r){var i=n.length,s=r?r.length:0;if(!i||e.f&&!e.l)return t||new oe(0);var o=!t,a=o||e.i!=2,c=e.i;o&&(t=new oe(i*3));var l=function(De){var Ke=t.length;if(De>Ke){var gr=new oe(Math.max(Ke*2,De));gr.set(t),t=gr}},d=e.f||0,u=e.p||0,p=e.b||0,m=e.l,f=e.d,h=e.m,g=e.n,_=i*8;do{if(!m){d=Ae(n,u,1);var y=Ae(n,u+1,3);if(u+=3,y)if(y==1)m=zc,f=kc,h=9,g=5;else if(y==2){var S=Ae(n,u,31)+257,O=Ae(n,u+10,15)+4,x=S+Ae(n,u+5,31)+1;u+=14;for(var v=new oe(x),L=new oe(19),b=0;b>4;if(E<16)v[b++]=E;else{var U=0,ue=0;for(E==16?(ue=3+Ae(n,u,3),u+=2,U=v[b-1]):E==17?(ue=3+Ae(n,u,7),u+=3):E==18&&(ue=11+Ae(n,u,127),u+=7);ue--;)v[b++]=U}}var C=v.subarray(0,S),H=v.subarray(S);h=$n(C),g=$n(H),m=nr(C,h,1),f=nr(H,g,1)}else me(1);else{var E=Vo(u)+4,A=n[E-4]|n[E-3]<<8,w=E+A;if(w>i){c&&me(0);break}a&&l(p+A),t.set(n.subarray(E,w),p),e.b=p+=A,e.p=u=w*8,e.f=d;continue}if(u>_){c&&me(0);break}}a&&l(p+131072);for(var Tt=(1<>4;if(u+=U&15,u>_){c&&me(0);break}if(U||me(2),ve<256)t[p++]=ve;else if(ve==256){je=u,m=null;break}else{var zt=ve-254;if(ve>264){var b=ve-257,Ce=Bo[b];zt=Ae(n,u,(1<>4;lt||me(3),u+=lt&15;var H=Tc[ge];if(ge>3){var Ce=$o[ge];H+=Un(n,u)&(1<_){c&&me(0);break}a&&l(p+131072);var Me=p+zt;if(p>3&1)+(e>>4&1);r>0;r-=!n[t++]);return t+(e&2)},It=(function(){function n(e,t){typeof e=="function"&&(t=e,e={}),this.ondata=t;var r=e&&e.dictionary&&e.dictionary.subarray(-32768);this.s={i:0,b:r?r.length:0},this.o=new oe(32768),this.p=new oe(0),r&&this.o.set(r)}return n.prototype.e=function(e){if(this.ondata||me(5),this.d&&me(4),!this.p.length)this.p=e;else if(e.length){var t=new oe(this.p.length+e.length);t.set(this.p),t.set(e,this.p.length),this.p=t}},n.prototype.c=function(e){this.s.i=+(this.d=e||!1);var t=this.s.b,r=qo(this.p,this.s,this.o);this.ondata(Kr(r,t,this.s.b),this.d),this.o=Kr(r,this.s.b-32768),this.s.b=this.o.length,this.p=Kr(this.p,this.s.p/8|0),this.s.p&=7},n.prototype.push=function(e,t){this.e(e),this.c(t)},n})();Xo=(function(){function n(e,t){this.v=1,this.r=0,It.call(this,e,t)}return n.prototype.push=function(e,t){if(It.prototype.e.call(this,e),this.r+=e.length,this.v){var r=this.p.subarray(this.v-1),i=r.length>3?Fc(r):4;if(i>r.length){if(!t)return}else this.v>1&&this.onmember&&this.onmember(this.r-r.length);this.p=r.subarray(i),this.v=0}It.prototype.c.call(this,0),this.s.f&&!this.s.l?(this.v=Vo(this.s.p)+9,this.s={i:0},this.o=new oe(0),this.push(new oe(0),t)):t&&It.prototype.c.call(this,t)},n})(),Cc=typeof TextDecoder<"u"&&new TextDecoder,Mc=0;try{Cc.decode(Nc,{stream:!0}),Mc=1}catch{}});var qn={};Ri(qn,{extractZipEntry:()=>Hc,extractZipEntryBounded:()=>Vc,fetchZipCentralDirectory:()=>Zc,parseZipCentralDirectory:()=>or});function ts(n){let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=Math.max(0,n.length-Jo);for(let r=n.length-Bc;r>=t;r--)if(e.getUint32(r,!0)===Dc)return r;throw new Error("Zip EOCD record not found")}function or(n){let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=ts(n),r=e.getUint16(t+10,!0),i=e.getUint32(t+16,!0),s=[],o=i;for(let a=0;a>8,A;E===Yo?A=h>>16&65535:y.startsWith("bin/")||y.startsWith("sbin/")||y.includes("/bin/")||y.includes("/sbin/")?A=493:A=420;let w=y.endsWith("/"),S=E===Yo&&(A&Uc)===$c;s.push({fileName:y,fileNameBytes:_,compressedSize:d,uncompressedSize:u,compressionMethod:l,localHeaderOffset:g,mode:A,isDirectory:w,isSymlink:S,externalAttrs:h,creatorOS:E}),o+=Hn+p+m+f}return s}function rs(n,e){if(n.byteLength!==e.byteLength)return!1;for(let t=0;t{if(a.byteLength>t-s)throw new Error(`ZIP member ${e.fileName} expands beyond ${t} bytes`);i.set(a,s),s+=a.byteLength}).push(r,!0),s!==t)throw new Error(`ZIP member ${e.fileName} expanded ${s} bytes, expected ${t}`);return i}function qc(n,e){let t=new DataView(n.buffer,n.byteOffset,n.byteLength),r=e.localHeaderOffset;if(r<0||r>n.byteLength-Vn||t.getUint32(r,!0)!==jo)throw new Error(`Invalid local file header signature at offset ${r}`);let i=t.getUint16(r+8,!0),s=t.getUint16(r+26,!0),o=t.getUint16(r+28,!0),a=r+Vn,c=a+s+o,l=c+e.compressedSize;if(i!==e.compressionMethod||cn.byteLength||!rs(n.subarray(a,a+s),e.fileNameBytes))throw new Error(`ZIP member ${e.fileName} has inconsistent local metadata`);return n.subarray(c,l)}async function Zc(n){let e=await fetch(n,{method:"HEAD"});if(!e.ok)throw new Error(`HEAD request failed: ${e.status} ${e.statusText}`);let t=parseInt(e.headers.get("content-length")||"0",10),r=e.headers.get("accept-ranges");if(!t||r!=="bytes"){let _=await fetch(n);if(!_.ok)throw new Error(`Fetch failed: ${_.status} ${_.statusText}`);let y=new Uint8Array(await _.arrayBuffer());return{entries:or(y),totalSize:y.length}}let i=Math.min(t,Jo),s=t-i,o=await fetch(n,{headers:{Range:`bytes=${s}-${t-1}`}});if(o.status!==206){let _=await fetch(n);if(!_.ok)throw new Error(`Fetch failed: ${_.status} ${_.statusText}`);let y=new Uint8Array(await _.arrayBuffer());return{entries:or(y),totalSize:y.length}}let a=new Uint8Array(await o.arrayBuffer()),c=new DataView(a.buffer,a.byteOffset,a.byteLength),l=ts(a),d=c.getUint32(l+12,!0),u=c.getUint32(l+16,!0);if(u>=s){let _=t,y=new Uint8Array(_);return y.set(a,s),{entries:or(y),totalSize:_}}let p=u+d-1,m=await fetch(n,{headers:{Range:`bytes=${u}-${p}`}});if(m.status!==206)throw new Error(`Range request for CD failed: ${m.status}`);let f=new Uint8Array(await m.arrayBuffer()),h=t,g=new Uint8Array(h);return g.set(f,u),g.set(a,s),{entries:or(g),totalSize:h}}var Dc,Kc,jo,Jo,Bc,Hn,Vn,Qo,es,Yo,$c,Uc,Wc,Gc,Zn=nn(()=>{"use strict";Gn();Dc=101010256,Kc=33639248,jo=67324752,Jo=65557,Bc=22,Hn=46,Vn=30,Qo=0,es=8,Yo=3,$c=40960,Uc=61440,Wc=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),Gc=new TextEncoder});var ls={};Ri(ls,{DEFAULT_TAR_GZIP_LIMITS:()=>cs,TarParseError:()=>T,parseTarGzip:()=>Jc});function Jc(n,e={}){let t=e.label??"TAR gzip archive",r=el(e.limits,t);if(n.byteLength===0||n.byteLength>r.maxCompressedBytes)throw new T(`${t}: compressed byte count ${n.byteLength} is outside 1..${r.maxCompressedBytes}`);let i=tl(n,t);if(i===0||i>r.maxUncompressedBytes)throw new T(`${t}: declared uncompressed byte count ${i} is outside 1..${r.maxUncompressedBytes}`);let s=rl(n,t,i);if(s.byteLength!==i)throw new T(`${t}: gzip expanded to ${s.byteLength} bytes, expected ${i}`);let o=new DataView(n.buffer,n.byteOffset,n.byteLength).getUint32(n.byteLength-8,!0);if(nl(s)!==o)throw new T(`${t}: gzip CRC32 mismatch`);return Qc(s,t,r)}function Qc(n,e,t){if(n.byteLength%ke!==0)throw new T(`${e}: TAR byte count is not block-aligned`);let r=[],i=0,s=0,o=0,a=null,c={},l=!1;for(;i+ke<=n.byteLength;){let d=n.subarray(i,i+ke);if(i+=ke,Yn(d)){if(i+ke>n.byteLength)throw new T(`${e}: TAR end marker is truncated`);let w=n.subarray(i,i+ke);if(!Yn(w))throw new T(`${e}: TAR has only one zero end block`);if(i+=ke,!Yn(n.subarray(i)))throw new T(`${e}: TAR has nonzero data after its end marker`);l=!0;break}al(d,e);let u=sr(d,156,1,e)||"0",p=Jn(d,124,12,`${e}: TAR entry size`),m=Jn(d,100,8,`${e}: TAR entry mode`)&Xc,f=cl(d,e,t.maxPathBytes),h=sr(d,157,100,e);if(u==="x"||u==="g"){if(o+=1,o>t.maxEntries+1)throw new T(`${e}: TAR extension header count exceeds ${t.maxEntries+1}`);let w=is(n,i,p,e);i=os(i,p,n.byteLength,e);let S=ol(w,e,t);u==="x"?a=S:c={...c,...S};continue}if(s+=1,s>t.maxEntries)throw new T(`${e}: TAR entry count exceeds ${t.maxEntries}`);let g={...c,...a??{}};a=null;let _=g.size===void 0?p:sl(g.size,`${e}: PAX entry size`),y=is(n,i,_,e);i=os(i,_,n.byteLength,e);let E=jn(g.path??f,e,t.maxPathBytes),A=g.linkpath??h;switch(u){case"0":case"\0":r.push({path:E,type:"file",mode:m,data:y});break;case"5":Xn(_,e,"directory",E),r.push({path:E,type:"directory",mode:m});break;case"2":Xn(_,e,"symlink",E),ss(A,e,E,t.maxLinkBytes,!1),r.push({path:E,type:"symlink",mode:m,linkName:A});break;case"1":Xn(_,e,"hardlink",E),ss(A,e,E,t.maxLinkBytes,!0),r.push({path:E,type:"hardlink",mode:m,linkName:jn(A,`${e}: hardlink target`,t.maxPathBytes)});break;case"3":case"4":case"6":throw new T(`${e}: unsupported TAR device/FIFO entry ${E}`);default:throw new T(`${e}: unsupported TAR entry type ${JSON.stringify(u)} for ${E}`)}}if(!l)throw new T(`${e}: TAR is missing its two-block end marker`);if(a!==null)throw new T(`${e}: local PAX header has no following entry`);return r}function el(n,e){let t={...cs,...n};for(let[r,i]of Object.entries(t))if(!Number.isSafeInteger(i)||i<=0)throw new T(`${e}: ${r} must be a positive safe integer`);return t}function tl(n,e){if(n.byteLength<18||n[0]!==31||n[1]!==139||n[2]!==8)throw new T(`${e}: invalid gzip header`);return new DataView(n.buffer,n.byteOffset,n.byteLength).getUint32(n.byteLength-4,!0)}function rl(n,e,t){let r=new Uint8Array(t),i=0,s=!1,o=new Xo(a=>{if(a.byteLength>t-i)throw new T(`${e}: gzip expansion exceeds its declared ${t} bytes`);r.set(a,i),i+=a.byteLength});o.onmember=()=>{throw s=!0,new T(`${e}: concatenated gzip members are unsupported`)};try{o.push(n,!0)}catch(a){throw a instanceof T?a:new T(`${e}: cannot gunzip archive: ${ul(a)}`)}if(s)throw new T(`${e}: concatenated gzip members are unsupported`);return r.subarray(0,i)}function nl(n){let e=4294967295;for(let t of n)e=jc[(e^t)&255]^e>>>8;return(e^4294967295)>>>0}function il(){let n=new Uint32Array(256);for(let e=0;e>>1^((t&1)===0?0:3988292384);n[e]=t>>>0}return n}function is(n,e,t,r){if(t>n.byteLength-e)throw new T(`${r}: TAR entry is truncated`);return n.subarray(e,e+t)}function os(n,e,t,r){let s=Math.ceil(e/ke)*ke;if(!Number.isSafeInteger(s)||s>t-n)throw new T(`${r}: TAR entry padding is truncated`);return n+s}function ol(n,e,t){let r={},i=0;for(;i9)throw new T(`${e}: invalid PAX record length`);if(o=o*10+h,!Number.isSafeInteger(o))throw new T(`${e}: invalid PAX record length`)}let a=i+o;if(o<=s-i+2||a>n.byteLength||n[a-1]!==10)throw new T(`${e}: truncated PAX record`);let c=s+1;for(;c=a-1)throw new T(`${e}: invalid PAX record`);let l=n.subarray(s+1,c);if(l.byteLength>256)throw new T(`${e}: PAX record key is too long`);let d=Qn(l,`${e}: PAX record key`),u=n.subarray(c+1,a-1),p=d==="path"?t.maxPathBytes:d==="linkpath"?t.maxLinkBytes:d==="size"?32:0;if(p===0){i=a;continue}if(u.byteLength>p)throw new T(`${e}: PAX ${d} value is too long`);let m=Qn(u,`${e}: PAX record value`);r[d]=m,i=a}return r}function sl(n,e){if(!/^(0|[1-9][0-9]*)$/.test(n))throw new T(`${e} is invalid`);let t=Number(n);if(!Number.isSafeInteger(t)||t<0)throw new T(`${e} is invalid`);return t}function al(n,e){let t=Jn(n,148,8,`${e}: TAR checksum`),r=0;for(let i=0;i=148&&i<156?32:n[i];if(t!==r)throw new T(`${e}: TAR checksum mismatch`)}function cl(n,e,t){let r=sr(n,0,100,e),i=sr(n,345,155,e);return jn(i?`${i}/${r}`:r,e,t)}function jn(n,e,t){let r=n;for(;r.startsWith("./");)r=r.slice(2);return r=r.replace(/\/+$/g,""),ll(r,`${e}: TAR path`,t),r}function sr(n,e,t,r){let i=e,s=e+t;for(;ir||n.includes("\0"))throw new T(`${e}: link target for ${t} is invalid`);if(i&&n.includes("\\"))throw new T(`${e}: hardlink target for ${t} is invalid`)}function ll(n,e,t){if(n.length===0||n.startsWith("/")||n.includes("\0")||n.includes("\\")||as.encode(n).byteLength>t)throw new T(`${e} ${JSON.stringify(n)} must be a bounded relative POSIX path`);for(let r of n.split("/"))if(r.length===0||r==="."||r==="..")throw new T(`${e} ${JSON.stringify(n)} contains an unsafe path segment`)}function Yn(n){for(let e of n)if(e!==0)return!1;return!0}function Qn(n,e){try{return Yc.decode(n)}catch{throw new T(`${e} contains non-UTF-8 text`)}}function ul(n){return n instanceof Error?n.message:String(n)}var ke,Xc,ns,Yc,as,jc,cs,T,us=nn(()=>{"use strict";Gn();ke=512,Xc=4095,ns=1024*1024,Yc=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),as=new TextEncoder,jc=il(),cs=Object.freeze({maxCompressedBytes:256*ns,maxUncompressedBytes:512*ns,maxEntries:1e5,maxPathBytes:4096,maxLinkBytes:65536}),T=class extends Error{constructor(e){super(e),this.name="TarParseError"}}});import{existsSync as pr,lstatSync as tn,readdirSync as Gs,readFileSync as st,realpathSync as Ie,statSync as Xe}from"node:fs";import{createHash as Hs}from"node:crypto";import{spawnSync as gi}from"node:child_process";import{basename as Xl,dirname as _r,isAbsolute as rn,join as $,relative as Yl,resolve as Oe,sep as jl}from"node:path";import{fileURLToPath as Jl}from"node:url";var kt="kandelo.wpk_fork.linked_frames";var Li=[75,76,67,70],Pt=24,bi=8,on=3,Ti=[{bytes:4,chunkHeaderSize:32,nodeHeaderSize:24},{bytes:8,chunkHeaderSize:56,nodeHeaderSize:32}],re="kandelo.wpk_fork.module_state",zi=1,ki=[75,70,77,68],Er=24,Pi=8;var sn=7;var Ni=1,Fi=1,Ci=1;var Mi=[{bytes:4,chunkHeaderSize:40},{bytes:8,chunkHeaderSize:56}];var Sr="__wpk_fork_global_";var wr="__wpk_fork_table_",an=1,cn=2,ln=3,un=4,dn=5,dt=6,Nt=7,Ft=8,Ct=9,Re="kandelo.wpk_fork.capabilities",Di=1;var Ki=7,Ar=4,Ee="kandelo.wpk_fork.exception_codec",Bi=1,Or=8,fn=16;var Ir="env",xr="__wpk_fork_unwind",ft="kandelo.wpk_fork.unwind_transport",Mt="__wpk_fork_static_root_catalog",Be="kandelo.wpk_fork.static_root_catalog";var hn=1,pn=0,$i=1,vr=12,Ui=[75,70,83,82],Z="kandelo.wpk_fork.imported_globals";var Wi=[75,70,73,71],Gi=1,Rr=16,Dt=24,Hi=1,Vi=2,qi=3,X="kandelo.wpk_fork.imported_tables",Zi=[75,70,73,84],Xi=1,Lr=16,Kt=24,Yi=1,ji=1,mn="env",_n="__wpk_fork_module_activation";var ht=[{module:"env",name:"__wpk_fork_frame_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_frame_next",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_peek",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_reserve",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_record_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_module_state_record_find",params:["i32","i32","i32","i32"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_record_reserve",params:["i32","i32","i32","ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_table_dirty_count",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_module_state_table_dirty_mark",params:["i32","i64","i64"],results:[]},{module:"env",name:"__wpk_fork_module_state_table_dirty_page",params:["i32","i32"],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_mutation_abort",params:[],results:[]},{module:"env",name:"__wpk_fork_module_state_table_mutation_begin",params:[],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_mutation_commit",params:["i32","i64","i64"],results:[]},{module:"env",name:"__wpk_fork_module_state_table_reconcile",params:[],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_state_owned",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_decode_funcref",params:["i32"],results:["funcref"]},{module:"env",name:"__wpk_fork_ref_encode_funcref",params:["funcref"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_broker_encode",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_broker_throw_recipe",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_cache_index",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_claim",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_define",params:["i32","i32","i32","i32","ptr","i32","ptr","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_ingress_throw",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_load",params:["i32","i32","i32","i32","ptr","i32","ptr","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_lookup",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_route",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_broker_encode",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_capture_layout",params:["i32","i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_claim",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_define",params:["i32","i32","i32","i32","i32","ptr","i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_i31",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_load",params:["i32","i32","i32","i32","i32","ptr","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_lookup",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_payload_len",params:["i32","i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_provenance_begin",params:["i32","i32","i32","i32","i64","i64","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_provenance_end",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_provenance_ref",params:["i32","i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_route",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_scratch_release",params:["ptr","ptr"],results:[]},{module:"env",name:"__wpk_fork_ref_scratch_reserve",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_ref_vector_append",params:["i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_vector_begin",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_vector_finish",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_vector_get",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_resume_peek",params:["i32"],results:["i32"]}],yn=[{module:"env",name:"__wpk_fork_ref_gc_transit",table64:!1,element:"anyref",minimum:1,maximum:null},{module:"env",name:"__wpk_fork_resume_table",table64:!1,element:"funcref",minimum:1,maximum:null}],Bt=[{name:"__wpk_fork_exception_materialize",params:["i32"],results:[]},{name:"__wpk_fork_ref_decode_exnref",params:["i32"],results:["exnref"]},{name:"__wpk_fork_ref_encode_exnref",params:["exnref"],results:["i32"]},{name:"__wpk_fork_ref_exn_abort",params:[],results:[]},{name:"__wpk_fork_ref_exn_clear",params:[],results:[]},{name:"__wpk_fork_ref_exn_encode_ingress",params:["i32"],results:["i32"]},{name:"__wpk_fork_ref_exn_throw_recipe",params:["i32"],results:[]},{name:"__wpk_fork_ref_exn_throw_slot",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_allocate",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_encode_slot",params:["i32"],results:["i32"]},{name:"__wpk_fork_ref_gc_fill",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_probe",params:["i32"],results:["i64"]},{name:"__wpk_fork_ref_gc_publish_externref",params:["i32","externref"],results:[]},{name:"__wpk_fork_static_root_harvest",params:[],results:[]},{name:"wpk_fork_abort_begin",params:["ptr"],results:[]},{name:"wpk_fork_abort_end",params:[],results:[]},{name:"wpk_fork_module_bootstrap",params:[],results:[]},{name:"wpk_fork_module_state_finish_restore",params:["i32"],results:[]},{name:"wpk_fork_module_state_restore",params:["i32"],results:[]},{name:"wpk_fork_module_state_save",params:["i32"],results:[]},{name:"wpk_fork_module_table_state_restore",params:["i32"],results:[]},{name:"wpk_fork_module_table_state_save",params:["i32"],results:[]},{name:"wpk_fork_module_thread_bootstrap",params:[],results:[]},{name:"wpk_fork_rewind_begin",params:["ptr"],results:[]},{name:"wpk_fork_rewind_end",params:[],results:[]},{name:"wpk_fork_state",params:[],results:["i32"]},{name:"wpk_fork_unwind_begin",params:["ptr"],results:[]},{name:"wpk_fork_unwind_end",params:[],results:[]}];var Ji=4096;var Qi=["__abi_version","kernel_alloc_scratch","kernel_blocking_retry_release","kernel_blocking_retry_token","kernel_clear_process_metadata","kernel_commit_process_exit","kernel_create_process","kernel_create_process_with_stdio","kernel_dequeue_signal","kernel_exec_prepare","kernel_exec_setup_for_thread","kernel_fork_process","kernel_get_parent_pid","kernel_get_process_exit_signal","kernel_get_process_state","kernel_get_socket_timeout_ms","kernel_handle_channel","kernel_has_sa_nocldstop","kernel_host_adapter_manifest_len","kernel_host_adapter_manifest_ptr","kernel_ipc_shmat_for_process","kernel_ipc_shmat_for_task","kernel_ipc_shmdt_for_process","kernel_ipc_shmdt_for_task","kernel_is_fd_nonblock","kernel_mark_process_signaled","kernel_mq_descriptor_msgsize","kernel_msqid_ds_bytes","kernel_pick_signal_target_tid","kernel_pipe_has_readers","kernel_posix_timer_fire","kernel_push_process_metadata_entry","kernel_reap_exited_child","kernel_remove_process","kernel_semctl_array_bytes","kernel_semid_ds_bytes","kernel_set_current_tid","kernel_set_cwd","kernel_shmid_ds_bytes","kernel_spawn_process","kernel_spawn_reserved_process","kernel_spawn_scratch_begin","kernel_spawn_scratch_cancel","kernel_spawn_scratch_capacity","kernel_spawn_scratch_pointer","kernel_spawn_scratch_retained_capacity","kernel_thread_exit","kernel_thread_has_deliverable","kernel_transfer_channel_execute","kernel_transfer_io_execute","kernel_transfer_scratch_begin","kernel_transfer_scratch_cancel","kernel_transfer_scratch_capacity","kernel_transfer_scratch_pointer","kernel_validate_task","kernel_wait_child_poll"];var V={LINK_MAX:0,MAX_CANON:1,MAX_INPUT:2,NAME_MAX:3,PATH_MAX:4,PIPE_BUF:5,CHOWN_RESTRICTED:6,NO_TRUNC:7,VDISABLE:8,SYNC_IO:9,ASYNC_IO:10,PRIO_IO:11,SOCK_MAXBUF:12,FILESIZEBITS:13,REC_INCR_XFER_SIZE:14,REC_MAX_XFER_SIZE:15,REC_MIN_XFER_SIZE:16,REC_XFER_ALIGN:17,ALLOC_SIZE_MIN:18,SYMLINK_MAX:19,POSIX2_SYMLINKS:20,FALLOC:21,TEXTDOMAIN_MAX:22,TIMESTAMP_RESOLUTION:23};var ia=Uint8Array.from(Ui);function R(n,e){let t=0,r=0,i=e;for(;;){let s=n[i++];if(t|=(s&127)<=n.length)throw new Error(`${r} is truncated`);if((n[e++]&128)===0)return e}throw new Error(`${r} has an overlong LEB128 encoding`)}function Se(n,e,t){if(e>=n.length)throw new Error(`${t} is truncated`);let r=n[e++];switch(r){case 127:case 126:case 125:case 124:case 123:case 117:case 116:case 115:case 114:case 113:case 112:case 111:case 110:case 109:case 108:case 107:case 106:case 105:case 104:return{code:r,shared:!1,next:e};case 98:case 99:case 100:{let i=n[e]===101;i&&e++;let s=co(n,e,5,`${t} heap type`),[o]=ao(n,e);return{code:r,heapType:Number(o),shared:i,next:s}}default:throw new Error(`${t} has unknown value type 0x${r.toString(16)}`)}}function oa(n,e,t){return n[e]===120||n[e]===119?{code:n[e],shared:!1,next:e+1}:Se(n,e,t)}function sa(n,e){let t=n[e];if(t===64||t===127||t===126||t===125||t===124||t===123||t===112||t===111)return e+1;let[,r]=En(n,e);return e+r}function aa(n,e,t){let[r,i]=R(n,e);e+=i;let s=[],o=[];for(let u=0;u=n.length)throw new Error(`${t} mutability is truncated`);let i=n[e++];if(i!==0&&i!==1)throw new Error(`${t} has invalid mutability ${i}`);return e}function ca(n,e,t,r){if(e===101){if(t>=n.length)throw new Error(`${r} shared type is truncated`);e=n[t++]}for(let i of[76,77]){if(e!==i)continue;let[,s]=R(n,t);if(t+=s,t>=n.length)throw new Error(`${r} descriptor is truncated`);e=n[t++]}if(e===96)return aa(n,t,r);if(e===95){let[i,s]=R(n,t);t+=s;for(let o=0;o=n.length)throw new Error(`${t} is truncated`);let r=n[e++];if(r===79||r===80){let[i,s]=R(n,e);e+=s;for(let o=0;o=n.length)throw new Error(`${t} body is truncated`);r=n[e++]}return ca(n,r,e,t)}function la(n,e){let[t,r]=R(n,e);e+=r;let i=[];for(let s=0;s=21&&r<=34?Ut(e,t):r===84||r>=92&&r<=99||r>=112&&r<=123||r>=124&&r<=131||r>=156&&r<=159?t+1:t:n===254?r===0||r===1||r===2?Ut(e,t):r===3?t:r>=16&&r<=79?Ut(e,t):null:null}function da(n,e,t){let[r,i]=R(n,e);e+=i+r;let[s,o]=R(n,e);e+=o+s;let a=n[e++];if(a===0){t.funcImports++;let[,c]=R(n,e);e+=c}else if(a===1)e=Se(n,e,"table import type").next,e=Ue(n,e).next;else if(a===2)e=Ue(n,e).next;else if(a===3)t.globalImports++,e=Se(n,e,"global import type").next,e++;else if(a===4){e++;let[,c]=R(n,e);e+=c}return e}function br(n){return n.length>=8&&n[0]===0&&n[1]===97&&n[2]===115&&n[3]===109}function $e(n,e){let[t,r]=R(n,e);return e+=r,[new TextDecoder().decode(n.subarray(e,e+t)),e+t]}function fa(n,e){if(e.length===0)return!0;let t=new TextEncoder().encode(e);e:for(let r=0;r<=n.length-t.length;r++){for(let i=0;in);function ro(n,e){switch(n.code){case 127:return an;case 126:return cn;case 125:return ln;case 124:return un;case 123:return dn;case 112:case 115:return dt;case 111:case 114:return Nt;case 105:case 116:return Ft;case 104:case 106:case 107:case 108:case 109:case 110:case 113:case 117:return Ct;case 98:case 99:case 100:{let t=n.heapType;return t===void 0?null:t===-16||t===-13?dt:t===-17||t===-14?Nt:t===-23||t===-12?Ft:t>=0&&e[t]!==void 0?dt:Ct}default:return null}}function gn(n,e,t){if(!t)throw new Error(`function ${e} refers to an unknown type`);let r=n.get(e)??[];r.push(t),n.set(e,r)}function $t(n,e,t){let r=n.get(e)??[];r.push(t),n.set(e,r)}function Ue(n,e){let[t,r]=R(n,e);e+=r;let[i,s]=R(n,e);e+=s;let o=null;if((t&1)!==0){let[a,c]=R(n,e);e+=c,o=a}return{flags:t,minimum:i,maximum:o,next:e}}function pa(n){let e=new Uint8Array(n);if(!br(e))throw new Error("not a wasm binary");let t=[],r=[],i=[],s={functionImports:new Map,functionImportEntries:[],globalImports:new Map,tableImports:new Map,tables:[],tagImports:new Map,functionExports:new Map,globalExports:new Map,tableExports:new Map,exports:new Map,memoryPointerWidths:[],forkCapabilities:[],linkedFrameDescriptors:[],exceptionCodecDescriptors:[],importedGlobalsDescriptors:[],importedTablesDescriptors:[],moduleStateDescriptors:[],staticRootDescriptors:[],unwindTransportDescriptors:[],nativeStartCount:0,importsKernelFork:!1},o=0,a=0,c=8;for(;ce.length)throw new Error("wasm section exceeds file size");let f=p,h=!1;if(l===0){let[g,_]=$e(e,f);g===kt?s.linkedFrameDescriptors.push(e.slice(_,m)):g===Re?s.forkCapabilities.push(e.slice(_,m)):g===Ee?s.exceptionCodecDescriptors.push(e.slice(_,m)):g===Z?s.importedGlobalsDescriptors.push(e.slice(_,m)):g===X?s.importedTablesDescriptors.push(e.slice(_,m)):g===re?s.moduleStateDescriptors.push(e.slice(_,m)):g===Be?s.staticRootDescriptors.push(e.slice(_,m)):g===ft&&s.unwindTransportDescriptors.push(e.slice(_,m))}else if(l===1){h=!0;let g=la(e,f);t.push(...g.types),f=g.next}else if(l===2){h=!0;let[g,_]=R(e,f);f+=_;for(let y=0;y=e.length)throw new Error(`global import ${E}.${w} is truncated`);let v=e[f++];if((v&-4)!==0)throw new Error(`global import ${E}.${w} has invalid flags ${v}`);$t(s.globalImports,`${E}.${w}`,{module:E,name:w,importOrdinal:y,index:o++,valueType:x.code,recipeTypeCode:ro(x,t),mutable:(v&1)!==0,shared:(v&2)!==0})}else if(O===4){let x=e[f++];if(x!==0)throw new Error(`unsupported wasm tag attribute ${x}`);let[v,L]=R(e,f);f+=L,gn(s.tagImports,`${E}.${w}`,t[v])}else throw new Error(`unsupported wasm import kind ${O}`)}}else if(l===3){h=!0;let[g,_]=R(e,f);f+=_;for(let y=0;yn[c]===a))throw new Error("linked-frame descriptor has invalid magic");let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=e.getUint16(4,!0);if(t!==1)throw new Error(`linked-frame descriptor version ${t} is unsupported`);let r=e.getUint16(6,!0);if(r!==Pt)throw new Error(`linked-frame descriptor declares size ${r}, expected ${Pt}`);let i=e.getUint8(8),s=Ti.find(({bytes:a})=>a===i);if(!s)throw new Error(`linked-frame descriptor pointer width ${i} is unsupported`);if(e.getUint8(9)!==bi)throw new Error(`linked-frame descriptor alignment ${e.getUint8(9)} is unsupported`);let o=e.getUint16(10,!0);if(o!==on)throw new Error(`linked-frame descriptor flags 0x${o.toString(16)} do not equal required flags 0x${on.toString(16)}`);if(e.getUint32(12,!0)!==s.chunkHeaderSize||e.getUint32(16,!0)!==s.nodeHeaderSize)throw new Error(`linked-frame descriptor header sizes do not match its ${i}-byte pointer width`);return s.bytes}function _a(n){if(n.length===0)return[`missing required ${Re} capability`];if(n.length!==1)return[`has ${n.length} ${Re} sections, expected exactly one`];let e=n[0];if(e.byteLength!==2)return[`${Re} has ${e.byteLength} bytes, expected 2`];if(e[0]!==Di)return[`${Re} version ${e[0]} is unsupported`];let t=e[1];return(t&~Ki)!==0?[`${Re} has unknown flags 0x${t.toString(16)}`]:(t&Ar)!==Ar?[`${Re} flags 0x${t.toString(16)} omit required activation-state safety flags 0x${Ar.toString(16)}`]:[]}function ya(n){let e=[],t=`${Ir}.${xr}`,r=n.tagImports.get(t);if(r?r.length!==1?e.push(`duplicate private fork-unwind tag import ${t}`):(r[0].params.length!==0||r[0].results.length!==0)&&e.push(`private fork-unwind tag ${t} must have an empty payload`):e.push(`missing required private fork-unwind tag import ${t}`),n.unwindTransportDescriptors.length===0)e.push(`missing required ${ft} descriptor`);else if(n.unwindTransportDescriptors.length!==1)e.push(`has ${n.unwindTransportDescriptors.length} ${ft} descriptors, expected exactly one`);else{let i=n.unwindTransportDescriptors[0];(i.length!==2||i[0]!==hn||i[1]!==pn)&&e.push(`${ft} must be [${hn}, ${pn}]`)}return e}function ga(n,e){if(n.length===0)return[`missing required ${re} descriptor`];if(n.length!==1)return[`has ${n.length} ${re} descriptors, expected exactly one`];let t=n[0];if(t.byteLength!==Er)return[`${re} has ${t.byteLength} bytes, expected ${Er}`];if(!ki.every((h,g)=>t[g]===h))return[`${re} has invalid magic`];let r=new DataView(t.buffer,t.byteOffset,t.byteLength),i=r.getUint16(4,!0),s=r.getUint16(6,!0),o=r.getUint8(8),a=Mi.find(({bytes:h})=>h===o),c=r.getUint8(9),l=r.getUint16(10,!0),d=r.getUint16(12,!0),u=r.getUint16(14,!0),p=r.getUint32(16,!0),m=r.getUint32(20,!0),f=[];return i!==zi&&f.push(`${re} version ${i} is unsupported`),s!==Er&&f.push(`${re} declares size ${s}`),a?e!==null&&o!==e&&f.push(`${re} pointer width ${o} does not match linked frames ${e}`):f.push(`${re} pointer width ${o} is unsupported`),c!==Pi&&f.push(`${re} alignment ${c} is unsupported`),l!==sn&&f.push(`${re} flags 0x${l.toString(16)} do not equal required flags 0x${sn.toString(16)}`),d!==Ni&&f.push(`${re} arena version ${d} is unsupported`),u!==Fi&&f.push(`${re} record version ${u} is unsupported`),p!==Ci&&f.push(`${re} root word ${p} is unsupported`),m!==0&&f.push(`${re} reserved field is nonzero`),f}function Ea(n){if(n.length===0)return[`missing required ${Ee} descriptor`];if(n.length!==1)return[`has ${n.length} ${Ee} descriptors, expected exactly one`];let e=n[0];if(e.byteLength2147483647||o.has(d))&&r.push(`${Ee} layout id ${d} is invalid or duplicated`),o.add(d)}return r}var Sa=new Set([an,cn,ln,un,dn,dt,Nt,Ft,Ct]);function no(n){return!(n.module===mn&&(n.name===_n||n.name==="__channel_base"||n.name==="__wpk_fork_module_state_table_generation_addr"))}function wa(n){let e=n.importedGlobalsDescriptors;if(e.length===0)return[`missing required ${Z} descriptor`];if(e.length!==1)return[`has ${e.length} ${Z} descriptors, expected exactly one`];let t=e[0];if(t.byteLengtht[g]===h)||i.push(`${Z} has invalid magic`),r.getUint16(4,!0)!==Gi&&i.push(`${Z} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Rr&&i.push(`${Z} declares an invalid header size`);let s=r.getUint32(8,!0);r.getUint32(12,!0)!==0&&i.push(`${Z} reserved field is nonzero`);let o=new Set,a=new Set,c=new TextDecoder("utf-8",{fatal:!0}),l=[],d=-1,u=Rr;for(let h=0;ht.byteLength)return i.push(`${Z} record ${h} header is truncated`),i;let g=r.getUint32(u,!0),_=r.getUint32(u+4,!0),y=r.getUint8(u+8),E=r.getUint8(u+9),A=r.getUint32(u+12,!0),w=r.getUint32(u+16,!0),S=r.getUint32(u+20,!0),O=Dt+A+w;if(!Number.isSafeInteger(O)||g!==O||gt.byteLength)return i.push(`${Z} record ${h} has invalid bounds`),i;(_===0||o.has(_))&&i.push(`${Z} record ${h} has invalid or duplicated owner ${_}`),o.add(_),Sa.has(y)||i.push(`${Z} record ${h} has unknown value type ${y}`),(E&~qi)!==0&&i.push(`${Z} record ${h} has unknown flags 0x${E.toString(16)}`),r.getUint16(u+10,!0)!==0&&i.push(`${Z} record ${h} reserved fields are nonzero`),(a.has(S)||S<=d)&&i.push(`${Z} record ${h} has duplicated or unordered import ordinal`),a.add(S),d=S;let x=u+Dt;try{let v=c.decode(t.subarray(x,x+A)),L=c.decode(t.subarray(x+A,x+A+w));l.push({ownerId:_,typeCode:y,flags:E,importOrdinal:S,module:v,name:L})}catch{i.push(`${Z} record ${h} contains invalid UTF-8`)}u+=g}u!==t.byteLength&&i.push(`${Z} has trailing bytes`);let p=[...n.globalImports.values()].flat(),m=new Map(p.map(h=>[h.index,h])),f=new Set;for(let h of l){let g=`${Sr}${h.ownerId}`,_=n.exports.get(g);if(!_||_.length!==1||_[0].kind!==3){i.push(`${Z} owner ${h.ownerId} lacks exactly one global catalog export ${g}`);continue}let y=m.get(_[0].index);if(!y||!no(y)){i.push(`${Z} owner ${h.ownerId} does not identify a reconstructible imported global`);continue}if(y.module!==h.module||y.name!==h.name||y.importOrdinal!==h.importOrdinal||y.recipeTypeCode!==h.typeCode||y.mutable!==((h.flags&Hi)!==0)||y.shared!==((h.flags&Vi)!==0)){i.push(`${Z} owner ${h.ownerId} does not match its imported global declaration`);continue}if(f.has(y.index)){i.push(`${Z} repeats imported global index ${y.index}`);continue}f.add(y.index)}for(let h of p)no(h)&&!f.has(h.index)&&i.push(`${Z} omits imported global ${h.module}.${h.name} at index ${h.index}`);for(let[h,g]of n.exports){if(!h.startsWith(Sr))continue;let _=h.slice(Sr.length),y=Number(_);(!/^[1-9][0-9]*$/.test(_)||!Number.isSafeInteger(y)||y>4294967295||g.length!==1||g[0].kind!==3)&&i.push(`malformed reserved fork global catalog export ${h}`)}return i}var Aa=new Set([dt,Nt,Ft,Ct]);function io(n){return!yn.some(({module:e,name:t})=>n.module===e&&n.name===t)}function Oa(n){let e=n.importedTablesDescriptors;if(e.length===0)return[`missing required ${X} descriptor`];if(e.length!==1)return[`has ${e.length} ${X} descriptors, expected exactly one`];let t=e[0];if(t.byteLengtht[g]===h)||i.push(`${X} has invalid magic`),r.getUint16(4,!0)!==Xi&&i.push(`${X} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Lr&&i.push(`${X} declares an invalid header size`);let s=r.getUint32(8,!0);r.getUint32(12,!0)!==0&&i.push(`${X} reserved field is nonzero`);let o=new Set,a=new Set,c=new TextDecoder("utf-8",{fatal:!0}),l=[],d=-1,u=Lr;for(let h=0;ht.byteLength)return i.push(`${X} record ${h} header is truncated`),i;let g=r.getUint32(u,!0),_=r.getUint32(u+4,!0),y=r.getUint8(u+8),E=r.getUint8(u+9),A=r.getUint32(u+12,!0),w=r.getUint32(u+16,!0),S=r.getUint32(u+20,!0),O=Kt+A+w;if(!Number.isSafeInteger(O)||g!==O||gt.byteLength)return i.push(`${X} record ${h} has invalid bounds`),i;(_===0||o.has(_))&&i.push(`${X} record ${h} has invalid or duplicated owner ${_}`),o.add(_),Aa.has(y)||i.push(`${X} record ${h} has unknown element type ${y}`),(E&~ji)!==0&&i.push(`${X} record ${h} has unknown flags 0x${E.toString(16)}`),r.getUint16(u+10,!0)!==0&&i.push(`${X} record ${h} reserved fields are nonzero`),(a.has(S)||S<=d)&&i.push(`${X} record ${h} has duplicated or unordered import ordinal`),a.add(S),d=S;let x=u+Kt;try{let v=c.decode(t.subarray(x,x+A)),L=c.decode(t.subarray(x+A,x+A+w));l.push({ownerId:_,typeCode:y,flags:E,importOrdinal:S,module:v,name:L})}catch{i.push(`${X} record ${h} contains invalid UTF-8`)}u+=g}u!==t.byteLength&&i.push(`${X} has trailing bytes`);let p=[...n.tableImports.values()].flat(),m=new Map(p.map(h=>[h.index,h])),f=new Set;for(let h of l){let g=`${wr}${h.ownerId}`,_=n.exports.get(g);if(!_||_.length!==1||_[0].kind!==1){i.push(`${X} owner ${h.ownerId} lacks exactly one table catalog export ${g}`);continue}let y=m.get(_[0].index);if(!y||!io(y)){i.push(`${X} owner ${h.ownerId} does not identify a reconstructible imported table`);continue}if(y.module!==h.module||y.name!==h.name||y.importOrdinal!==h.importOrdinal||y.recipeTypeCode!==h.typeCode||y.table64!==((h.flags&Yi)!==0)){i.push(`${X} owner ${h.ownerId} does not match its imported table declaration`);continue}if(f.has(y.index)){i.push(`${X} repeats imported table index ${y.index}`);continue}f.add(y.index)}for(let h of p)io(h)&&!f.has(h.index)&&i.push(`${X} omits imported table ${h.module}.${h.name} at index ${h.index}`);for(let[h,g]of n.exports){if(!h.startsWith(wr))continue;let _=h.slice(wr.length),y=Number(_);(!/^[1-9][0-9]*$/.test(_)||!Number.isSafeInteger(y)||y>4294967295||g.length!==1||g[0].kind!==1)&&i.push(`malformed reserved fork table catalog export ${h}`)}return i}function Sn(n,e){switch(n){case"ptr":return e===8?126:127;case"i32":return 127;case"i64":return 126;case"anyref":return 110;case"exnref":return 105;case"externref":return 111;case"funcref":return 112}}function oo(n,e,t,r){return n.params.length===e.length&&n.results.length===t.length&&n.params.every((i,s)=>i===Sn(e[s],r))&&n.results.every((i,s)=>i===Sn(t[s],r))}function so(n,e,t){let r=i=>i==="ptr"?t===8?"i64":"i32":i;return`(${n.map(r).join(", ")}) -> (${e.map(r).join(", ")})`}function Ia(n){let e=`${mn}.${_n}`,t=n.globalImports.get(e);return t?t.length!==1?[`duplicate exception-codec activation import ${e}`]:t[0].valueType!==127||t[0].mutable?[`exception-codec activation import ${e} must be immutable i32`]:[]:[`missing required immutable exception-codec activation import ${e}`]}function xa(n){let e=[];for(let t of yn){let r=`${t.module}.${t.name}`,i=n.tableImports.get(r);if(!i){e.push(`missing required ABI 43 fork-runtime table import ${r}`);continue}if(i.length!==1){e.push(`duplicate ABI 43 fork-runtime table import ${r}`);continue}let s=i[0],o=Sn(t.element,4);(s.elementType!==o||s.table64!==t.table64||s.minimum!==t.minimum||s.maximum!==t.maximum)&&e.push(`ABI 43 fork-runtime table import ${r} has the wrong type or limits`)}return e}function va(n){if(n.staticRootDescriptors.length===0)return[`missing required ${Be} descriptor`];if(n.staticRootDescriptors.length!==1)return[`has ${n.staticRootDescriptors.length} ${Be} descriptors, expected exactly one`];let e=n.staticRootDescriptors[0];if(e.byteLength!==vr)return[`${Be} has ${e.byteLength} bytes, expected ${vr}`];let t=[];ia.some((l,d)=>e[d]!==l)&&t.push(`${Be} has invalid magic`);let r=new DataView(e.buffer,e.byteOffset,e.byteLength);r.getUint16(4,!0)!==$i&&t.push(`${Be} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==vr&&t.push(`${Be} declares an invalid header size`);let i=r.getUint32(8,!0),s=n.tableExports.get(Mt);if(!s||s.length!==1)return t.push(`missing exactly one table export ${Mt}`),t;let o=[...n.tableImports.values()].reduce((l,d)=>l+d.length,0),a=s[0],c=n.tables[a];return a!n.functionExports.has(c)).map(({name:c})=>c);t.length>0&&e.push(`incomplete wasm-fork-instrument exports; missing ${t.join(", ")}`);let r=null;if(n.linkedFrameDescriptors.length===0)e.push(`missing required ${kt} descriptor`);else if(n.linkedFrameDescriptors.length!==1)e.push(`has ${n.linkedFrameDescriptors.length} ${kt} descriptors, expected exactly one`);else try{r=ma(n.linkedFrameDescriptors[0])}catch(c){e.push(c instanceof Error?c.message:String(c))}e.push(...ga(n.moduleStateDescriptors,r));let i=ht.filter(({module:c,name:l})=>n.functionImports.has(`${c}.${l}`)),s=`${Ir}.${xr}`,o=n.importsKernelFork||i.length>0;if((o||n.tagImports.has(s)||n.unwindTransportDescriptors.length>0)&&e.push(...ya(n)),o){let c=ht.filter(({module:l,name:d})=>!n.functionImports.has(`${l}.${d}`)).map(({module:l,name:d})=>`${l}.${d}`);c.length>0&&e.push(`incomplete ABI 43 fork-runtime imports; missing ${c.join(", ")}`);for(let l of ht){let d=`${l.module}.${l.name}`,u=n.functionImports.get(d);u&&u.length!==1&&e.push(`duplicate ABI 43 fork-runtime import ${d}`)}}if(r!==null){if(n.memoryPointerWidths.length!==1)e.push(`ABI 43 fork instrumentation requires exactly one module memory, found ${n.memoryPointerWidths.length}`);else if(n.memoryPointerWidths[0]!==r){let c=r===8?"an":"a";e.push(`ABI 43 linked-frame descriptor declares ${c} ${r}-byte pointer but the module memory uses ${n.memoryPointerWidths[0]}-byte addresses`)}for(let c of Bt){let l=n.functionExports.get(c.name);l?.length===1&&!oo(l[0],c.params,c.results,r)&&e.push(`ABI 43 wasm-fork-instrument export ${c.name} has the wrong signature; expected ${so(c.params,c.results,r)}`)}if(o)for(let c of ht){let l=`${c.module}.${c.name}`,d=n.functionImports.get(l);d?.length===1&&!oo(d[0],c.params,c.results,r)&&e.push(`ABI 43 fork-runtime import ${l} has the wrong signature; expected ${so(c.params,c.results,r)}`)}}return e}function La(n){let e=new Uint8Array(n);if(!br(e))return[];let t=[],r=8;for(;rt.startsWith("reloc."))}function uo(n,e={}){let t=[],r=null;Ta(n)&&t.push("contains asyncify_"),e.expectedAbi!==void 0&&e.expectedAbi!==null&&(r=Pa(n),r!==null&&r!==e.expectedAbi&&t.push(`ABI ${r}, expected ${e.expectedAbi}`));let i=new Set(ba(n));if(e.requiredExports){let E=e.requiredExports.filter(A=>!i.has(A));E.length>0&&t.push(`missing required exports: ${E.join(", ")}`)}let s=ha.filter(E=>i.has(E)),o=La(n),a=lo(n),c=ht.filter(({module:E,name:A})=>o.includes(`${E}.${A}`)),l=a.filter(E=>E===kt).length,d=a.filter(E=>E===Re).length,u=a.filter(E=>E===re).length,p=a.filter(E=>E===Ee).length,m=a.filter(E=>E===Z).length,f=a.filter(E=>E===X).length,h=a.filter(E=>E===ft).length,g=o.includes(`${Ir}.${xr}`),_=s.length>0||c.length>0||l>0||d>0||u>0||p>0||m>0||f>0||h>0||g;if(e.expectedAbi!==void 0&&e.expectedAbi!==null&&_&&r===null&&t.push(`ABI ${e.expectedAbi} fork artifact is missing __abi_version; the activation-state capability epoch cannot be verified`),e.forbidForkInstrumentation&&_&&t.push("contains ABI 43 wasm-fork-instrument metadata, imports, or exports"),(e.requireForkInstrumentation??!za(n))&&(_||o.includes("kernel.kernel_fork")))try{t.push(...Ra(pa(n)))}catch(E){t.push(`cannot validate ABI 43 fork-artifact contract: ${E instanceof Error?E.message:String(E)}`)}return t}function ka(n,e){let t=new Uint8Array(n);if(t.length<8)return null;let r=0,i=null,s=null,o=8;for(;o=c)return null;let h=a;for(let y=0;y=f)return null;let[h,g]=R(t,m);m+=g;for(let _=0;_f)return null}return m}function p(m,f=0){if(f>4)return null;let h=d(m);if(!h)return null;let g=u(h.start,h.end);if(g===null)return null;let _=g,y=h.end;for(;_=32&&E<=38||E===208){let[,A]=R(t,_);_+=A}else if(E>=40&&E<=62)_=Ut(t,_);else if(E===63||E===64)_++;else if(E===66){let[,A]=ao(t,_);_+=A}else if(E===67)_+=4;else if(E===68)_+=8;else if(E===252||E===253||E===254){let A=ua(E,t,_);if(A===null)return null;_=A}}return null}return p(i)}function Pa(n){return ka(n,"__abi_version")}var Na=ArrayBuffer,j=Uint8Array,Tr=Uint16Array,Fa=Int16Array;var zr=Int32Array,wn=function(n,e,t){if(j.prototype.slice)return j.prototype.slice.call(n,e,t);(e==null||e<0)&&(e=0),(t==null||t>n.length)&&(t=n.length);var r=new j(t-e);return r.set(n.subarray(e,t)),r},Gt=function(n,e,t,r){if(j.prototype.fill)return j.prototype.fill.call(n,e,t,r);for((t==null||t<0)&&(t=0),(r==null||r>n.length)&&(r=n.length);tn.length)&&(r=n.length);t2046MB)","invalid block type","FSE accuracy too high","match distance too far back","unexpected EOF"],J=function(n,e,t){var r=new Error(e||Ma[n]);if(r.code=n,Error.captureStackTrace&&Error.captureStackTrace(r,J),!t)throw r;return r},fo=function(n,e,t){for(var r=0,i=0;r>>0},Ka=function(n,e){var t=n[0]|n[1]<<8|n[2]<<16;if(t==3126568&&n[3]==253){var r=n[4],i=r>>5&1,s=r>>2&1,o=r&3,a=r>>6;r&8&&J(0);var c=6-i,l=o==3?4:o,d=fo(n,c,l);c+=l;var u=a?1<>3);m=f+(f>>3)*(n[5]&7)}m>2145386496&&J(1);var h=new j((e==1?p||m:e?0:m)+12);return h[0]=1,h[4]=4,h[8]=8,{b:c+u,y:0,l:0,d,w:e&&e!=1?e:h.subarray(12),e:m,o:new zr(h.buffer,0,3),u:p,c:s,m:Math.min(131072,m)}}else if((t>>4|n[3]<<20)==25481893)return Da(n,4)+8;J(0)},Qe=function(n){for(var e=0;1<t&&J(3);for(var s=1<0;){var y=Qe(o+1),E=r>>3,A=(1<>(r&7)&A,S=(1<S&&(w-=O)),p[++a]=--w,w==-1?(o+=w,g[--d]=a):o-=w,!w)do{var v=r>>3;c=(n[v]|n[v+1]<<8)>>(r&7)&3,r+=2,a+=c}while(c==3)}(a>255||o)&&J(0);for(var L=0,b=(s>>1)+(s>>3)+3,D=s-1,q=0;q<=a;++q){var F=p[q];if(F<1){m[q]=-F;continue}for(l=0;l=d)}}for(L&&J(0),l=0;l>3,{b:i,s:g,n:_,t:f}]},Ba=function(n,e){var t=0,r=-1,i=new j(292),s=n[e],o=i.subarray(0,256),a=i.subarray(256,268),c=new Tr(i.buffer,268);if(s<128){var l=Ht(n,e+1,6),d=l[0],u=l[1];e+=s;var p=d<<3,m=n[e];m||J(0);for(var f=0,h=0,g=u.b,_=g,y=(++e<<3)-8+Qe(m);y-=g,!(y>3;if(f+=(n[E]|n[E+1]<<8)>>(y&7)&(1<>3,h+=(n[E]|n[E+1]<<8)>>(y&7)&(1<<_)-1,o[++r]=u.s[h],g=u.n[f],f=u.t[f],_=u.n[h],h=u.t[h]}++r>255&&J(0)}else{for(r=s-127;t>4,o[t+1]=A&15}++e}var w=0;for(t=0;t11&&J(0),w+=S&&1<0;--t){var q=c[t];Gt(D,t,q,c[t-1]=q+a[t]*(1<a&&u>3,m=(n[p]|n[p+1]<<8|n[p+2]<<16)>>(d&7);c=(c<>2,o=s<<1,a=s+o;Wt(n.subarray(r,r+=n[0]|n[1]<<8),e.subarray(0,s),t),Wt(n.subarray(r,r+=n[2]|n[3]<<8),e.subarray(s,o),t),Wt(n.subarray(r,r+=n[4]|n[5]<<8),e.subarray(o,a),t),Wt(n.subarray(r),e.subarray(a),t)},qa=function(n,e,t){var r,i=e.b,s=n[i],o=s>>1&3;e.l=s&1;var a=s>>3|n[i+1]<<5|n[i+2]<<13,c=(i+=3)+a;if(o==1)return i>=n.length?void 0:(e.b=i+1,t?(Gt(t,n[i],e.y,e.y+=a),t):Gt(new j(a),n[i]));if(!(c>n.length)){if(o==0)return e.b=c,t?(t.set(n.subarray(i,c),e.y),e.y+=a,t):wn(n,i,c);if(o==2){var l=n[i],d=l&3,u=l>>2&3,p=l>>4,m=0,f=0;d<2?u&1?p|=n[++i]<<4|(u&2&&n[++i]<<12):p=l>>3:(f=u,u<2?(p|=(n[++i]&63)<<4,m=n[i]>>6|n[++i]<<2):u==2?(p|=n[++i]<<4|(n[++i]&3)<<12,m=n[i]>>2|n[++i]<<6):(p|=n[++i]<<4|(n[++i]&63)<<12,m=n[i]>>6|n[++i]<<2|n[++i]<<10)),++i;var h=t?t.subarray(e.y,e.y+e.m):new j(e.m),g=h.length-p;if(d==0)h.set(n.subarray(i,i+=p),g);else if(d==1)Gt(h,n[i++],g);else{var _=e.h;if(d==2){var y=Ba(n,i);m+=i-(i=y[0]),e.h=_=y[1]}else _||J(0);(f?Va:Wt)(n.subarray(i,i+=m),h.subarray(g),_)}var E=n[i++];if(E){E==255?E=(n[i++]|n[i++]<<8)+32512:E>127&&(E=E-128<<8|n[i++]);var A=n[i++];A&3&&J(0);for(var w=[Ua,Wa,$a],S=2;S>-1;--S){var O=A>>(S<<1)+2&3;if(O==1){var x=new j([0,0,n[i++]]);w[S]={s:x.subarray(2,3),n:x.subarray(0,1),t:new Tr(x.buffer,0,1),b:0}}else O==2?(r=Ht(n,i,9-(S&1)),i=r[0],w[S]=r[1]):O==3&&(e.t||J(0),w[S]=e.t[S])}var v=e.t=w,L=v[0],b=v[1],D=v[2],q=n[c-1];q||J(0);var F=(c<<3)-8+Qe(q)-D.b,z=F>>3,U=0,ue=(n[z]|n[z+1]<<8)>>(F&7)&(1<>3;var C=(n[z]|n[z+1]<<8)>>(F&7)&(1<>3;var H=(n[z]|n[z+1]<<8)>>(F&7)&(1<>3;var lt=1<>>(F&7)<-1);z=(F-=On[je])>>3;var Me=Ha[je]+((n[z]|n[z+1]<<8|n[z+2]<<16)>>(F&7)&(1<>3;var Je=Ga[Tt]+((n[z]|n[z+1]<<8|n[z+2]<<16)>>(F&7)&(1<>3,ue=D.t[ue]+((n[z]|n[z+1]<<8)>>(F&7)&(1<>3,H=L.t[H]+((n[z]|n[z+1]<<8)>>(F&7)&(1<>3,C=b.t[C]+((n[z]|n[z+1]<<8)>>(F&7)&(1<3)e.o[2]=e.o[1],e.o[1]=e.o[0],e.o[0]=ge-=3;else{var ut=ge-(Je!=0);ut?(ge=ut==3?e.o[0]-1:e.o[ut],ut>1&&(e.o[2]=e.o[1]),e.o[1]=e.o[0],e.o[0]=ge):ge=e.o[0]}for(var S=0;SMe&&(Ke=Me);for(var S=0;Sja)throw Vt("EOVERFLOW","file offset is outside signed i64");return n}function Qa(n){if(xn(n)<0n)throw Vt("EINVAL","negative positioned I/O offset");return n}function vn(n){let e=xn(n);if(e_o)throw Vt("EOVERFLOW","backend cannot represent the file offset exactly");return mo(e)}function Rn(n){let e=Qa(n);return vn(e)}function yo(n){if(n===null)return null;let e=xn(n);if(e<0n)throw Vt("EINVAL","negative file-size limit");return e>_o?null:mo(e)}function Ln(n){let e=new Error(`EINVAL: pathconf name ${n} is not associated with this object`);throw e.code="EINVAL",e}function bn(n,e,t){switch(e){case V.LINK_MAX:return null;case V.NAME_MAX:return 255;case V.PATH_MAX:return Ji;case V.CHOWN_RESTRICTED:return 1;case V.NO_TRUNC:return 1;case V.ASYNC_IO:return(n.mode&61440)===32768?1:Ln(e);case V.SYNC_IO:case V.PRIO_IO:case V.FILESIZEBITS:case V.REC_INCR_XFER_SIZE:case V.REC_MAX_XFER_SIZE:case V.REC_MIN_XFER_SIZE:case V.REC_XFER_ALIGN:case V.ALLOC_SIZE_MIN:case V.SYMLINK_MAX:case V.FALLOC:return null;case V.POSIX2_SYMLINKS:return t.supportsSymlinks?1:null;case V.TEXTDOMAIN_MAX:return 255;case V.TIMESTAMP_RESOLUTION:return t.timestampResolutionNs;case V.PIPE_BUF:{let r=n.mode&61440;return r===4096||r===16384?null:Ln(e)}case V.MAX_CANON:case V.MAX_INPUT:case V.VDISABLE:case V.SOCK_MAXBUF:return Ln(e);default:{let r=new Error(`EINVAL: invalid pathconf name ${e}`);throw r.code="EINVAL",r}}}var kr=Math.floor(160),Tn=1397114451,zn=1,qt=32768,G=16384,pt=40960,W=61440,ec=2048,tc=1024,rc=73,go=4294967295,et=0,Eo=1;var er=64,Kn=128,rr=512,nc=1024,ic=65536,Zt=3,oc=0,sc=1,ac=2,P=8,cc=-1,we=-2,B=-5,te=-9,Cn=-16,St=-17,be=-20,rt=-21,Y=-22,Lo=-24,nt=-27,ie=-28,Mn=-36,Dn=-39,bo=-40,To=-75,kn=0,Pn=4,Pr=8,mt=12,We=16,_t=20,Nr=24,tt=28,Fr=32,So=36,Cr=40,lc=44,uc=48,dc=52,Nn=56,Mr=60,Dr=64,Xt=68,wo=72,yt=0,N=8,K=12,k=16,de=24,Q=32,Yt=40,ne=48,jt=88,gt=92,Jt=96,Qt=100,ce=104,Ge=112,Ao=116,le=120,Oo=4,Le=8,Io=16,xo=20,vo=-2147483648,fc=2147483647,hc=1034+1024*1024,He=hc*4096,pc={[we]:"No such file or directory",[B]:"I/O error",[te]:"Bad file descriptor",[Cn]:"Device or resource busy",[St]:"File exists",[be]:"Not a directory",[rt]:"Is a directory",[Y]:"Invalid argument",[Lo]:"Too many open files",[nt]:"File too large",[ie]:"No space left on device",[Mn]:"File name too long",[Dn]:"Directory not empty",[bo]:"Too many symbolic links",[To]:"Value too large for data type"},I=class extends Error{constructor(t,r){super(r||pc[t]||`Error ${t}`);this.code=t;this.name="SFSError"}code},pe=new TextEncoder,tr=new TextDecoder,Ro=pe.encode("..");function Fn(n){return n==="."||n===".."}function Et(n){return n.buffer instanceof SharedArrayBuffer?tr.decode(new Uint8Array(n)):tr.decode(n)}function Ve(n){return n+3&-4}var Te=class n{constructor(e){this.buffer=e;this.view=new DataView(e),this.i32=new Int32Array(e),this.u8=new Uint8Array(e)}buffer;view;i32;u8;dirIndexes=new Map;blockAllocHint=0;inodeAllocHint=2;atomicsWaitAllowed;static DIR_INDEX_MIN_SIZE=64*1024;static mkfs(e,t){let r=e.byteLength;if(r<4096*16)throw new I(Y);let i=Math.floor(r/4096),s=t?Math.floor(t/4096):i*4,o=Math.floor(s/4);o<32&&(o=32),o=Math.ceil(o/32)*32;let a=Math.ceil(o/(4096*8)),c=Math.ceil(s/(4096*8)),l=Math.ceil(o*128/4096),d=1,u=d+a,p=u+c,m=p+l;if(m>=i){let x=(m+1)*4096;try{e.grow(x)}catch{throw new I(ie)}if(i=Math.floor(e.byteLength/4096),m>=i)throw new I(ie)}new Uint8Array(e).fill(0);let f=new n(e);f.w32(kn,Tn),f.w32(Pn,zn),f.w32(Pr,4096),f.w32(mt,i),f.w32(We,o),f.w32(tt,d),f.w32(Fr,u),f.w32(So,p),f.w32(Cr,m),f.w32(lc,a),f.w32(uc,c),f.w32(dc,l),f.w32(Xt,s),f.w32(wo,256);let h=u*4096;for(let x=0;x>2)+(x>>5);f.i32[v]|=1<<(x&31)}let g=i-m;Atomics.store(f.i32,_t>>2,g),f.blockAllocHint=m;let _=d*4096;f.i32[_>>2]|=3,Atomics.store(f.i32,Nr>>2,o-2),f.inodeAllocHint=2;let y=f.inodeOffset(1);f.w32(y+N,G|493),f.w32(y+K,2),f.w64(y+ce,1);let E=f.blockAlloc();if(E<0)throw new I(ie);f.w32(y+ne,E);let A=E*4096,w=Ve(P+1),S=Ve(P+2);f.w32(A,1),f.view.setUint16(A+4,w,!0),f.view.setUint16(A+6,1,!0),f.u8[A+P]=46;let O=A+w;return f.w32(O,1),f.view.setUint16(O+4,S,!0),f.view.setUint16(O+6,2,!0),f.u8[O+P]=46,f.u8[O+P+1]=46,f.w64(y+k,w+S),Atomics.store(f.i32,Nn>>2,1),f}static inspectImageCapacity(e){if(e.byteLengththis.snapshotBytesUnlocked(e))}snapshotState(e){return this.withNamespaceLock(()=>({bytes:this.snapshotBytesUnlocked(e),identities:this.collectIdentityStateUnlocked()}))}identityState(){return this.withNamespaceLock(()=>this.collectIdentityStateUnlocked())}snapshotBytesUnlocked(e){let t=e?.normalizeTimestampsMs;if(t!==void 0&&(!Number.isSafeInteger(t)||t<0))throw new I(Y,"Snapshot timestamp must be a non-negative safe integer in milliseconds");let r=t===void 0?void 0:BigInt(t);for(let a=0;a>2)!==0)throw new I(Cn,"Cannot save a VFS image with open descriptors")}let i=this.r32(We);for(let a=0;a=1&&this.inodeIsAllocated(a)?r:0n;o.setBigUint64(c+Yt,l,!0),o.setBigUint64(c+de,l,!0),o.setBigUint64(c+Q,l,!0)}}return s}collectIdentityStateUnlocked(){let e=new Map,t=this.inodeOffset(1),r=this.r64(t+ce);e.set(`1:${r}`,{ino:1,generation:r,dataSequence:Atomics.load(this.i32,t+le>>2)>>>0,mode:this.r32(t+N),linkCount:this.r32(t+K),size:this.r64(t+k),uid:this.r32(t+Jt),gid:this.r32(t+Qt),paths:["/"]});let i=[{ino:1,path:"/"}],s=new Set;for(;i.length>0;){let o=i.pop();if(s.has(o.ino))throw new I(B);s.add(o.ino);let a=this.inodeOffset(o.ino);if((this.r32(a+N)&W)!==G)throw new I(B);let c=this.r64(a+k),l=0;for(;l>2)>>>0,mode:L,linkCount:this.r32(S+K),size:this.r64(S+k),uid:this.r32(S+Jt),gid:this.r32(S+Qt),...(L&W)===pt?{symlinkTarget:this.readSymlinkInodeUnlocked(_)}:{},paths:[]},e.set(x,v)}v.paths.push(w),(this.r32(S+N)&W)===G&&i.push({ino:_,path:w})}}h+=y}l+=f}}return e}statfs(){let e=this.r32(Pr),t=this.r32(mt),r=this.r32(Xt),i=typeof this.buffer.maxByteLength=="number"?this.buffer.maxByteLength:this.buffer.byteLength,s=Math.floor(i/e),o=Math.max(t,Math.min(r,s)),a=Atomics.load(this.i32,_t>>2),c=Math.max(0,o-t);return{blockSize:e,totalBlocks:o,freeBlocks:a+c,totalInodes:this.r32(We),freeInodes:Atomics.load(this.i32,Nr>>2),maxName:255}}r32(e){return this.view.getUint32(e,!0)}w32(e,t){this.view.setUint32(e,t,!0)}r64(e){return Number(this.view.getBigUint64(e,!0))}w64(e,t){this.view.setBigUint64(e,BigInt(t),!0)}waitForAtomicChange(e,t){if(this.atomicsWaitAllowed!==!1)try{Atomics.wait(this.i32,e,t),this.atomicsWaitAllowed=!0;return}catch(r){if(!(r instanceof TypeError))throw r;this.atomicsWaitAllowed=!1}for(;Atomics.load(this.i32,e)===t;);}resetAllocationHints(){this.blockAllocHint=this.findNextFreeBlockHint(),this.inodeAllocHint=this.findNextFreeInodeHint()}findNextFreeBlockHint(){let e=this.r32(mt),t=this.r32(Cr),r=this.r32(Fr)*4096;for(let i=t;i>2)+(i>>5),o=i&31;if((Atomics.load(this.i32,s)&1<>2)+(r>>5),s=r&31;if((Atomics.load(this.i32,i)&1<>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}sbUnlock(){let e=Mr>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}namespaceLock(){let e=Dr>>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}namespaceUnlock(){let e=Dr>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}withNamespaceLock(e){this.namespaceLock();try{return e()}finally{this.namespaceUnlock()}}resetRestoredRuntimeState(){Atomics.store(this.i32,Mr>>2,0),Atomics.store(this.i32,Dr>>2,0),this.u8.fill(0,256,4096);let e=this.r32(We),t=this.r32(tt)*4096;for(let r=0;r>5)*4)&1<<(r&31))===0||this.r32(i+K)!==0)continue;let o=this.r32(i+N),a=this.r64(i+k);(o&W)===pt&&a<=40?(this.u8.fill(0,i+ne,i+ne+40),this.w64(i+k,0)):this.inodeTruncate(r,0),this.inodeFree(r)}}blockAlloc(){let e=this.r32(mt),t=this.r32(Fr)*4096,r=this.r32(Cr),i=this.blockAllocHint>=r&&this.blockAllocHint>2)+(a>>5),l=a&31,d=Atomics.load(this.i32,c);if(d&1<>2,1),this.blockAllocHint=a+1>2)+(e>>5),i=e&31;for(;;){let s=Atomics.load(this.i32,r),o=s&~(1<>2,1),e>=this.r32(Cr)&&e>2)>0)return 0;let e=this.r32(mt),t=this.r32(Xt),r=this.r32(wo),i=e+r;if(i>t&&(i=t,r=i-e,r===0))return ie;let s=i*4096;if(this.buffer.byteLength>2,r),Atomics.add(this.i32,Nn>>2,1),this.blockAllocHint=e,0}finally{this.sbUnlock()}}inodeOffset(e){let r=this.r32(So)+Math.floor(e/32),i=e%32*128;return r*4096+i}inodeAlloc(){let e=this.r32(We),t=this.r32(tt)*4096,r=this.inodeAllocHint>=2&&this.inodeAllocHint>2)+(o>>5),c=o&31,l=Atomics.load(this.i32,a);if(l&1<>2,1),this.inodeAllocHint=o+1>2,1)+1}inodeFree(e){let r=(this.r32(tt)*4096>>2)+(e>>5),i=e&31;for(;;){let s=Atomics.load(this.i32,r);if((s&1<>2,1),e>=2&&e0&&this.w32(r+Ge,i-1),i<=1&&this.r32(r+K)===0&&(this.inodeTruncate(e,0),t=!0)}finally{this.inodeWriteUnlock(e)}t&&this.inodeFree(e)}inodeDropLinkRefLocked(e){let t=this.inodeOffset(e),r=this.r32(t+K);return r>1?(this.w32(t+K,r-1),this.w64(t+Q,Date.now()),!1):this.inodeOrphanLocked(e)}inodeOrphanLocked(e){let t=this.inodeOffset(e);if(this.w32(t+K,0),this.w64(t+Q,Date.now()),this.r32(t+Ge)>0)return!1;let r=this.r32(t+N),i=this.r64(t+k);return(r&W)===pt&&i<=40?(this.u8.fill(0,t+ne,t+ne+40),this.w64(t+k,0)):this.inodeTruncate(e,0),!0}inodeReadLock(e){let t=this.inodeOffset(e)+yt>>2;for(;;){let r=Atomics.load(this.i32,t);if(r&vo){this.waitForAtomicChange(t,r);continue}if(Atomics.compareExchange(this.i32,t,r,r+1)===r)return}}inodeReadUnlock(e){let t=this.inodeOffset(e)+yt>>2;(Atomics.sub(this.i32,t,1)&fc)===1&&Atomics.notify(this.i32,t,1)}inodeWriteLock(e){let t=this.inodeOffset(e)+yt>>2;for(;;){let r=Atomics.load(this.i32,t);if(r!==0){this.waitForAtomicChange(t,r);continue}if(Atomics.compareExchange(this.i32,t,0,vo)===0)return}}inodeWriteUnlock(e){let t=this.inodeOffset(e)+yt>>2;Atomics.store(this.i32,t,0),Atomics.notify(this.i32,t,1/0)}inodeBlockMap(e,t,r){let i=this.inodeOffset(e);if(t<10){let s=this.r32(i+ne+t*4);if(s!==0)return s;if(!r)return 0;let o=this.blockAllocWithGrow();return o<0||this.w32(i+ne+t*4,o),o}if(t-=10,t<1024){let s=this.r32(i+jt),o=!1;if(s===0){if(!r)return 0;if(s=this.blockAllocWithGrow(),s<0)return s;this.w32(i+jt,s),o=!0}let a=s*4096+t*4,c=this.r32(a);if(c!==0)return c;if(!r)return 0;let l=this.blockAllocWithGrow();return l<0?(o&&(this.w32(i+jt,0),this.blockFree(s)),l):(this.w32(a,l),l)}if(t-=1024,t<1024*1024){let s=Math.floor(t/1024),o=t%1024,a=this.r32(i+gt),c=!1;if(a===0){if(!r)return 0;if(a=this.blockAllocWithGrow(),a<0)return a;this.w32(i+gt,a),c=!0}let l=a*4096+s*4,d=this.r32(l),u=!1;if(d===0){if(!r)return 0;if(d=this.blockAllocWithGrow(),d<0)return c&&(this.w32(i+gt,0),this.blockFree(a)),d;this.w32(l,d),u=!0}let p=d*4096+o*4,m=this.r32(p);if(m!==0)return m;if(!r)return 0;let f=this.blockAllocWithGrow();return f<0?(u&&(this.w32(l,0),this.blockFree(d)),c&&(this.w32(i+gt,0),this.blockFree(a)),f):(this.w32(p,f),f)}return Y}inodeReadData(e,t,r,i){let s=this.inodeOffset(e),o=this.r64(s+k);if(t>=o)return 0;t+i>o&&(i=o-t);let a=0,c=0;for(;i>0;){let l=Math.floor(t/4096),d=t%4096,u=4096-d;u>i&&(u=i);let p=this.inodeBlockMap(e,l,!1);if(p<=0)r.fill(0,c,c+u);else{let m=p*4096+d;r.set(this.u8.subarray(m,m+u),c)}c+=u,t+=u,i-=u,a+=u}return a}inodeWriteData(e,t,r,i){let s=this.inodeOffset(e),o=this.r64(s+k);t>o&&this.zeroOldEofTail(e,o);let a=0,c=0;for(;i>0;){let l=Math.floor(t/4096),d=t%4096,u=4096-d;u>i&&(u=i);let p=this.inodeBlockMap(e,l,!0);if(p<0){if(a===0)return p;break}let m=p*4096+d;this.u8.set(r.subarray(c,c+u),m),c+=u,t+=u,i-=u,a+=u}if(a>0&&t>this.r64(s+k)&&this.w64(s+k,t),a>0){let l=Date.now();this.w64(s+de,l),this.w64(s+Q,l),Atomics.add(this.i32,s+le>>2,1)}return a}zeroInodeRange(e,t,r){for(;t0){let c=a*4096+s;this.u8.fill(0,c,c+o)}t+=o}}zeroOldEofTail(e,t){let r=t%4096;if(r===0)return;let i=Math.floor(t/4096),s=this.inodeBlockMap(e,i,!1);if(s<=0)return;let o=s*4096+r;this.u8.fill(0,o,s*4096+4096)}freeBlocksFrom(e,t){let r=this.inodeOffset(e);for(let o=t;o<10;o++){let a=this.r32(r+ne+o*4);a&&(this.blockFree(a),this.w32(r+ne+o*4,0))}let i=this.r32(r+jt);if(i){let o=t>10?t-10:0;for(let a=o;a<1024;a++){let c=i*4096+a*4,l=this.r32(c);l&&(this.blockFree(l),this.w32(c,0))}o===0&&(this.blockFree(i),this.w32(r+jt,0))}let s=this.r32(r+gt);if(s){let o=t>1034?t-10-1024:0,a=Math.floor(o/1024);for(let c=a;c<1024;c++){let l=s*4096+c*4,d=this.r32(l);if(!d)continue;let u=c===a?o%1024:0;for(let p=u;p<1024;p++){let m=d*4096+p*4,f=this.r32(m);f&&(this.blockFree(f),this.w32(m,0))}u===0&&(this.blockFree(d),this.w32(l,0))}a===0&&(this.blockFree(s),this.w32(r+gt,0))}}inodeTruncate(e,t,r=!1){let i=this.inodeOffset(e),s=this.r64(i+k),o=t!==s;if(t>=s){if(t>s&&this.zeroOldEofTail(e,s),this.w64(i+k,t),o||r){let c=Date.now();this.w64(i+de,c),this.w64(i+Q,c),Atomics.add(this.i32,i+le>>2,1)}return}t%4096!==0&&this.zeroInodeRange(e,t,Math.ceil(t/4096)*4096);let a=Math.ceil(t/4096);if(this.freeBlocksFrom(e,a),this.w64(i+k,t),o||r){let c=Date.now();this.w64(i+de,c),this.w64(i+Q,c),Atomics.add(this.i32,i+le>>2,1)}}validateFileSize(e){if(!Number.isSafeInteger(e)||e<0)throw new I(Y);if(e>He)throw new I(nt)}validateSeekPosition(e){if(!Number.isSafeInteger(e))throw new I(To);if(e<0)throw new I(Y);if(e>He)throw new I(nt)}touchDirectoryMutation(e){let t=this.inodeOffset(e),r=Date.now();this.w64(t+de,r),this.w64(t+Q,r);let i=Atomics.add(this.i32,t+Ao>>2,1)+1>>>0,s=this.dirIndexes.get(e);s&&(s.mutationSequence=i,s.size=this.r64(t+k))}dirNameKey(e){return Et(e)}dirEntryNameMatches(e,t){if(this.view.getUint16(e+6,!0)!==t.length)return!1;for(let i=0;i=P&&r%4===0&&e+r<=t&&i<=r-P}inodeIsAllocated(e){let t=this.r32(We);if(e<=0||e>=t)return!1;let r=this.r32(tt)*4096;return(Atomics.load(this.i32,(r>>2)+(e>>5))&1<<(e&31))!==0}rebuildDirIndex(e,t,r,i){let s=new Map,o=[],a=0;for(;a4096-d&&(m=4096-d);let f=d;for(;f=P&&o.push({abs:h,recLen:_});f+=_}a+=m}let c={generation:t,mutationSequence:r,size:i,entries:s,free:o};return this.dirIndexes.set(e,c),c}getDirIndex(e){let t=this.inodeOffset(e),r=this.r64(t+k),i=this.r64(t+ce),s=Atomics.load(this.i32,t+Ao>>2)>>>0,o=this.dirIndexes.get(e);return o&&o.generation===i&&o.mutationSequence===s&&o.size===r?o:(o&&this.dirIndexes.delete(e),r=0;o--){let a=e.free[o];if(!(a.recLen4096-c&&(u=4096-c);let p=c;for(;pr)return-1;a=c,o+=l}return o===r?a:-1}dirAppendEntry(e,t,r,i=-1){let s=this.inodeOffset(e),o=this.r64(s+k),a=Ve(P+t.length),c=o,l=Math.floor(c/4096),d=c%4096,u=0;if(d!==0&&d+a>4096){let f=4096-d,h=0;if(f>=P){if(h=this.inodeBlockMap(e,l,!1),h<=0)return B}else if(i<0&&(i=this.findLastDirEntryInBlock(e,l,d)),i<0)return B;if(u=this.inodeBlockMap(e,l+1,!0),u<0)return u;if(f>=P){let g=h*4096+d;this.w32(g,0),this.view.setUint16(g+4,f,!0),this.view.setUint16(g+6,0,!0)}else{let _=this.view.getUint16(i+4,!0)+f;this.view.setUint16(i+4,_,!0),this.updateDirIndexRecLen(e,i,_)}c=(l+1)*4096,l++,d=0}let p;if(d===0){if(p=u||this.inodeBlockMap(e,l,!0),p<0)return p}else if(p=this.inodeBlockMap(e,l,!1),p<=0)return B;let m=p*4096+d;return this.w32(m,r),this.view.setUint16(m+4,a,!0),this.view.setUint16(m+6,t.length,!0),this.u8.set(t,m+P),this.w64(s+k,c+a),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,m,a),0}dirAddEntry(e,t,r){let i=this.getDirIndex(e);if(typeof i=="number")return i;if(i)return this.useDirIndexFreeSlot(i,e,t,r)?0:this.dirAppendEntry(e,t,r);let s=this.inodeOffset(e),o=this.r64(s+k),a=Ve(P+t.length),c=-1,l=0;for(;l4096-u&&(f=4096-u);let h=u;for(;hu+f||E>y-P)return B;if(_===0&&y>=a)return this.w32(g,r),this.view.setUint16(g+6,t.length,!0),this.u8.set(t,g+P),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,g,y),0;let A=Ve(P+E),w=y-A;if(_!==0&&w>=a){this.view.setUint16(g+4,A,!0);let S=g+A;return this.w32(S,r),this.view.setUint16(S+4,w,!0),this.view.setUint16(S+6,t.length,!0),this.u8.set(t,S+P),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,S,w),0}c=g,h+=y}l+=f}return this.dirAppendEntry(e,t,r,c)}dirRemoveEntry(e,t){let r=this.getDirIndex(e);if(typeof r=="number")return r;if(r){let a=this.dirNameKey(t),c=r.entries.get(a);if(!c)return we;if(this.r32(c.abs)===c.ino&&this.view.getUint16(c.abs+4,!0)===c.recLen&&this.view.getUint16(c.abs+6,!0)===c.nameLen&&this.dirEntryNameMatches(c.abs,t))return this.w32(c.abs,0),r.entries.delete(a),r.free.push({abs:c.abs,recLen:c.recLen}),this.touchDirectoryMutation(e),0;r.entries.delete(a)}let i=this.inodeOffset(e),s=this.r64(i+k),o=0;for(;o4096-c&&(u=4096-c);let p=c;for(;p4096-l&&(p=4096-l);let m=l;for(;m4096-o&&(l=4096-o);let d=o;for(;do+l||f>m-P)throw new I(B);if(p!==0){if(f===1&&this.u8[u+P]===46){d+=m;continue}if(f===2&&this.u8[u+P]===46&&this.u8[u+P+1]===46){d+=m;continue}return!1}d+=m}i+=l}return!0}dirIsAncestor(e,t){let r=t;for(let i=0;i<8*1024;i++){if(r===e)return!0;if(r===1)return!1;let s=this.dirLookup(r,Ro);if(s<0||s===r)throw new I(B);r=s}throw new I(B)}pathResolve(e,t){if(!e.startsWith("/"))return we;let r=1,i=e.split("/").filter(o=>o.length>0),s=0;for(let o=0;o255)return Mn;let c=pe.encode(a),l;this.inodeReadLock(r);try{let p=this.inodeOffset(r);if((this.r32(p+N)&W)!==G)return be;l=this.dirLookup(r,c)}finally{this.inodeReadUnlock(r)}if(l<0)return l;let d=this.inodeOffset(l);if((this.r32(d+N)&W)===pt&&(!(o===i.length-1)||t)){if(++s>8)return bo;let m=this.r64(d+k),f;if(m<=40)f=Et(this.u8.subarray(d+ne,d+ne+m));else{let h=new Uint8Array(m);this.inodeReadData(l,0,h,m),f=tr.decode(h)}if(f.startsWith("/")){r=1;let h=f.split("/").filter(_=>_.length>0),g=i.slice(o+1);i.length=0,i.push(...h,...g),o=-1}else{let h=f.split("/").filter(_=>_.length>0),g=i.slice(o+1);i.length=o,i.push(...h,...g),o--}continue}r=l}return r}pathResolveParent(e){if(!e.startsWith("/"))throw new I(Y,"Path must be absolute");let t=e.split("/").filter(c=>c.length>0);if(t.length===0)throw new I(Y,"Cannot operate on /");let r=t.pop();if(r.length>255)throw new I(Mn);let i="/"+t.join("/"),s=this.pathResolve(i,!0);if(s<0)throw new I(s);let o=this.inodeOffset(s);if((this.r32(o+N)&W)!==G)throw new I(be);return{parentIno:s,name:r}}fdAlloc(e,t,r){for(let i=0;i>2;if(Atomics.compareExchange(this.i32,o,0,1)===0)return this.w32(s+Oo,e),this.w64(s+Le,0),this.w32(s+Io,t),this.w32(s+xo,r?1:0),this.inodeAddOpenRef(e)?i:(Atomics.store(this.i32,o,0),we)}return Lo}fdGet(e){if(e<0||e>=kr)return null;let t=256+e*24;return Atomics.load(this.i32,t>>2)?{base:t,ino:this.r32(t+Oo),offset:this.r64(t+Le),flags:this.r32(t+Io),isDir:this.r32(t+xo)!==0}:null}fdFree(e){if(e>=0&&e>2,0)}}buildStat(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+ce),dataSequence:this.r32(t+le),mode:this.r32(t+N),linkCount:this.r32(t+K),size:this.r64(t+k),mtime:this.r64(t+de),ctime:this.r64(t+Q),atime:this.r64(t+Yt),uid:this.r32(t+Jt),gid:this.r32(t+Qt)}}namespaceEntryIdentity(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+ce),linkCount:this.r32(t+K),mode:this.r32(t+N)}}open(e,t,r=420){return this.withNamespaceLock(()=>this.openUnlocked(e,t,r))}createLazyStub(e,t){return this.withNamespaceLock(()=>{let r=this.openUnlocked(e,Eo|er,t);try{let i=this.fdGet(r);if(!i)throw new I(te);this.inodeWriteLock(i.ino);try{return this.inodeTruncate(i.ino,0,!0),this.buildStat(i.ino)}finally{this.inodeWriteUnlock(i.ino)}}finally{this.closeUnlocked(r)}})}replaceIfIdentity(e,t,r,i,s){return this.withNamespaceLock(()=>{let o=this.pathResolve(e,!0);if(o<0||o!==t)return!1;let a=this.inodeOffset(o);if(this.r64(a+ce)!==r||this.r32(a+le)!==i||(this.r32(a+N)&W)!==qt)return!1;this.validateFileSize(s.byteLength),this.inodeWriteLock(o);try{if(this.r64(a+ce)!==r||this.r32(a+le)!==i||this.r64(a+k)!==0)return!1;let c=this.r64(a+de),l=this.r64(a+Q);this.inodeTruncate(o,0,!0);let d=s.byteLength>0?this.inodeWriteData(o,0,s,s.byteLength):0;if(d!==s.byteLength)throw this.inodeTruncate(o,0,!0),Atomics.store(this.i32,a+le>>2,i),this.w64(a+de,c),this.w64(a+Q,l),new I(d<0?d:ie);return!0}finally{this.inodeWriteUnlock(o)}})}replaceManyIfIdentities(e,t=[]){return e.length===0&&t.length===0?!0:this.withNamespaceLock(()=>{let r=[],i=new Set,s=a=>{let c=this.pathResolve(a.path,!1);if(c<0||c!==a.expectedIno)return!1;let l=this.inodeOffset(c);return this.r64(l+ce)===a.expectedGeneration&&this.r32(l+le)===a.expectedDataSequence&&this.r32(l+N)===a.expectedMode&&this.r32(l+K)===a.expectedLinkCount&&this.r64(l+k)===a.expectedSize&&this.r32(l+Jt)===a.expectedUid&&this.r32(l+Qt)===a.expectedGid};for(let a of t)if(!s(a))return!1;for(let a of e){this.validateFileSize(a.data.byteLength);let c=-1;for(let l of a.paths){let d=this.pathResolve(l,!0);if(d!==a.expectedIno)continue;let u=this.inodeOffset(d);if(this.r64(u+ce)===a.expectedGeneration&&this.r32(u+le)===a.expectedDataSequence&&(this.r32(u+N)&W)===qt&&this.r64(u+k)===0){c=d;break}}if(c<0)return!1;if(i.has(c))throw new I(Y,"duplicate conditional replacement inode");i.add(c),r.push({...a,ino:c})}let o=[...i].sort((a,c)=>a-c);for(let a of o)this.inodeWriteLock(a);try{for(let l of r){let d=this.inodeOffset(l.ino);if(this.r64(d+ce)!==l.expectedGeneration||this.r32(d+le)!==l.expectedDataSequence||(this.r32(d+N)&W)!==qt||this.r64(d+k)!==0)return!1}for(let l of t)if(!s(l))return!1;let a=r.map(l=>{let d=this.inodeOffset(l.ino);return{ino:l.ino,dataSequence:this.r32(d+le),mtime:this.r64(d+de),ctime:this.r64(d+Q)}}),c=0;try{for(let l of r){c++,this.inodeTruncate(l.ino,0,!0);let d=l.data.byteLength>0?this.inodeWriteData(l.ino,0,l.data,l.data.byteLength):0;if(d!==l.data.byteLength)throw new I(d<0?d:ie)}}catch(l){for(let d=c-1;d>=0;d--){let u=a[d],p=this.inodeOffset(u.ino);this.inodeTruncate(u.ino,0,!0),Atomics.store(this.i32,p+le>>2,u.dataSequence),this.w64(p+de,u.mtime),this.w64(p+Q,u.ctime)}throw l}return!0}finally{for(let a=o.length-1;a>=0;a--)this.inodeWriteUnlock(o[a])}})}openUnlocked(e,t,r=420){let i=t&Zt,s=(t&er)!==0,o=(t&Kn)!==0;if(s&&o){let u=this.pathResolve(e,!1);if(u>=0)throw new I(St);if(u!==we)throw new I(u)}let a=this.pathResolve(e,!0);if(a<0&&a===we&&s){let{parentIno:u,name:p}=this.pathResolveParent(e);this.inodeWriteLock(u);try{let m=pe.encode(p),f=this.dirLookup(u,m);if(f>=0){if(o)throw new I(St);a=f}else{let h=this.inodeAlloc();if(h<0)throw new I(ie);let g=this.inodeOffset(h);this.w32(g+N,qt|r&4095),this.w32(g+K,1),this.w64(g+k,0);let _=Date.now();this.w64(g+Yt,_),this.w64(g+de,_),this.w64(g+Q,_);let y=this.dirAddEntry(u,m,h);if(y<0)throw this.inodeFree(h),new I(y);a=h}}finally{this.inodeWriteUnlock(u)}}if(a<0)throw new I(a);let c=this.inodeOffset(a),l=this.r32(c+N);if((l&W)===G&&i!==et)throw new I(rt);if(t&ic&&(l&W)!==G)throw new I(be);if(t&rr){if((l&W)===G)throw new I(rt);this.inodeWriteLock(a),this.inodeTruncate(a,0,!0),this.inodeWriteUnlock(a)}let d=this.fdAlloc(a,t,!1);if(d<0)throw new I(d);return d}close(e){this.withNamespaceLock(()=>this.closeUnlocked(e))}closeUnlocked(e){let t=this.fdGet(e);if(!t)throw new I(te);this.fdFree(e),this.inodeDropOpenRef(t.ino)}read(e,t){let r=this.fdGet(e);if(!r)throw new I(te);let i=this.inodeOffset(r.ino);if((this.r32(i+N)&W)===G)throw new I(rt);this.inodeReadLock(r.ino);try{let o=this.inodeReadData(r.ino,r.offset,t,t.length),a=256+e*24;return this.w64(a+Le,r.offset+o),o}finally{this.inodeReadUnlock(r.ino)}}readAt(e,t,r){let i=this.fdGet(e);if(!i)throw new I(te);let s=this.inodeOffset(i.ino);if((this.r32(s+N)&W)===G)throw new I(rt);this.validateSeekPosition(r),this.inodeReadLock(i.ino);try{return this.inodeReadData(i.ino,r,t,t.length)}finally{this.inodeReadUnlock(i.ino)}}write(e,t){let r=this.fdGet(e);if(!r)throw new I(te);if((r.flags&Zt)===et)throw new I(te);this.inodeWriteLock(r.ino);try{let s=r.offset;if(r.flags&nc){let c=this.inodeOffset(r.ino);s=this.r64(c+k)}if(!Number.isSafeInteger(s)||s<0)throw new I(Y);if(s>He||t.length>He-s)throw new I(nt);let o=this.inodeWriteData(r.ino,s,t,t.length);if(o<0)return o;let a=256+e*24;return this.w64(a+Le,s+o),o}finally{this.inodeWriteUnlock(r.ino)}}append(e,t,r){let i=this.fdGet(e);if(!i)throw new I(te);if((i.flags&Zt)===et)throw new I(te);if(r!==null&&(!Number.isSafeInteger(r)||r<0))throw new I(Y);this.inodeWriteLock(i.ino);try{let o=this.inodeOffset(i.ino),a=this.r64(o+k);if(!Number.isSafeInteger(a)||a<0)throw new I(Y);if(a>He)throw new I(nt);if(r!==null&&a>=r){let f=256+e*24;return this.w64(f+Le,a),{written:0,end:a}}let c=r===null?t.length:Math.min(t.length,r-a),l=He-a;if(c>l)throw new I(nt);let d=t.subarray(0,c),u=this.inodeWriteData(i.ino,a,d,d.length);if(u<0)throw new I(u);let p=256+e*24,m=a+u;return this.w64(p+Le,m),{written:u,end:m}}finally{this.inodeWriteUnlock(i.ino)}}writeAt(e,t,r){let i=this.fdGet(e);if(!i)throw new I(te);if((i.flags&Zt)===et)throw new I(te);this.validateSeekPosition(r),this.inodeWriteLock(i.ino);try{if(r>He||t.length>He-r)throw new I(nt);return this.inodeWriteData(i.ino,r,t,t.length)}finally{this.inodeWriteUnlock(i.ino)}}lseek(e,t,r){let i=this.fdGet(e);if(!i)throw new I(te);let s;if(r===oc)s=t;else if(r===sc)s=i.offset+t;else if(r===ac){let a=this.inodeOffset(i.ino);s=this.r64(a+k)+t}else throw new I(Y);this.validateSeekPosition(s);let o=256+e*24;return this.w64(o+Le,s),s}ftruncate(e,t){let r=this.fdGet(e);if(!r)throw new I(te);if((r.flags&Zt)===et)throw new I(te);this.validateFileSize(t),this.inodeWriteLock(r.ino);try{this.inodeTruncate(r.ino,t,!0)}finally{this.inodeWriteUnlock(r.ino)}}fstat(e){let t=this.fdGet(e);if(!t)throw new I(te);this.inodeReadLock(t.ino);try{return this.buildStat(t.ino)}finally{this.inodeReadUnlock(t.ino)}}stat(e){return this.withNamespaceLock(()=>this.statUnlocked(e))}statUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new I(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}lstat(e){return this.withNamespaceLock(()=>this.lstatUnlocked(e))}lstatUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new I(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}unlink(e){return this.withNamespaceLock(()=>this.unlinkUnlocked(e))}unlinkUnlocked(e){let{parentIno:t,name:r}=this.pathResolveParent(e),i=pe.encode(r),s=e.length>1&&e.endsWith("/");this.inodeWriteLock(t);try{let o=this.dirLookup(t,i);if(o<0)throw new I(o);let a=this.inodeOffset(o),c=this.r32(a+N);if(s&&(c&W)!==G)throw new I(be);if((c&W)===G)throw new I(rt);let l=this.namespaceEntryIdentity(o),d=this.dirRemoveEntry(t,i);if(d<0)throw new I(d);let u=!1;this.inodeWriteLock(o);try{u=this.inodeDropLinkRefLocked(o)}finally{this.inodeWriteUnlock(o)}return u&&this.inodeFree(o),l}finally{this.inodeWriteUnlock(t)}}rename(e,t){return this.withNamespaceLock(()=>this.renameUnlocked(e,t))}renameUnlocked(e,t){let{parentIno:r,name:i}=this.pathResolveParent(e),{parentIno:s,name:o}=this.pathResolveParent(t);if(Fn(i)||Fn(o))throw new I(Y);let a=pe.encode(i),c=pe.encode(o),l=e.length>1&&e.endsWith("/"),d=t.length>1&&t.endsWith("/"),u=Math.min(r,s),p=Math.max(r,s);this.inodeWriteLock(u),u!==p&&this.inodeWriteLock(p);try{let m=this.dirLookup(r,a);if(m<0)throw new I(m);let f=this.inodeOffset(m),g=this.r32(f+N)&W,_=this.namespaceEntryIdentity(m);if((l||d)&&g!==G)throw new I(be);if(g===G&&this.dirIsAncestor(m,s))throw new I(Y);let y=this.dirLookup(s,c),E=!1,A;if(y>=0){if(y===m)return{source:_,replaced:_};A=this.namespaceEntryIdentity(y);let S=this.inodeOffset(y),x=this.r32(S+N)&W;if(g===G&&x!==G)throw new I(be);if(g!==G&&x===G)throw new I(rt);let v=!1,L=y===r||y===s;L||this.inodeWriteLock(y);try{if(x===G&&!this.dirIsEmpty(y))throw new I(Dn);let b=this.dirReplaceEntryIno(s,c,m);if(b<0)throw new I(b);v=x===G?this.inodeOrphanLocked(y):this.inodeDropLinkRefLocked(y)}finally{L||this.inodeWriteUnlock(y)}v&&this.inodeFree(y),E=x===G}else{let S=this.dirAddEntry(s,c,m);if(S<0)throw new I(S)}let w=this.dirRemoveEntry(r,a);if(w<0)throw new I(w);if(g===G){if(r!==s){let S=this.inodeOffset(r);this.w32(S+K,this.r32(S+K)-1);let O=this.inodeOffset(s);this.w32(O+K,this.r32(O+K)+1),this.inodeWriteLock(m);try{let x=this.dirReplaceEntryIno(m,Ro,s);if(x<0)throw new I(x);this.w64(f+Q,Date.now())}finally{this.inodeWriteUnlock(m)}}if(E){let S=this.inodeOffset(s);this.w32(S+K,this.r32(S+K)-1)}}else if(E){let S=this.inodeOffset(s);this.w32(S+K,this.r32(S+K)-1)}return{source:_,replaced:A}}finally{u!==p&&this.inodeWriteUnlock(p),this.inodeWriteUnlock(u)}}mkdir(e,t=493){this.withNamespaceLock(()=>this.mkdirUnlocked(e,t))}mkdirUnlocked(e,t=493){let{parentIno:r,name:i}=this.pathResolveParent(e),s=pe.encode(i);this.inodeWriteLock(r);try{if(this.dirLookup(r,s)>=0)throw new I(St);let a=this.inodeAlloc();if(a<0)throw new I(ie);let c=this.inodeOffset(a);this.w32(c+N,G|t),this.w32(c+K,2),this.w64(c+k,0);let l=Date.now();this.w64(c+Yt,l),this.w64(c+de,l),this.w64(c+Q,l);let d=this.blockAllocWithGrow();if(d<0)throw this.inodeFree(a),new I(ie);this.w32(c+ne,d);let u=d*4096,p=Ve(P+1),m=Ve(P+2);this.w32(u,a),this.view.setUint16(u+4,p,!0),this.view.setUint16(u+6,1,!0),this.u8[u+P]=46;let f=u+p;this.w32(f,r),this.view.setUint16(f+4,m,!0),this.view.setUint16(f+6,2,!0),this.u8[f+P]=46,this.u8[f+P+1]=46,this.w64(c+k,p+m);let h=this.dirAddEntry(r,s,a);if(h<0)throw this.blockFree(d),this.inodeFree(a),new I(h);let g=this.inodeOffset(r);this.w32(g+K,this.r32(g+K)+1)}finally{this.inodeWriteUnlock(r)}}rmdir(e){this.withNamespaceLock(()=>this.rmdirUnlocked(e))}rmdirUnlocked(e){let{parentIno:t,name:r}=this.pathResolveParent(e);if(Fn(r))throw new I(Y);let i=pe.encode(r);this.inodeWriteLock(t);try{let s=this.dirLookup(t,i);if(s<0)throw new I(s);let o=this.inodeOffset(s);if((this.r32(o+N)&W)!==G)throw new I(be);let c=!1;this.inodeWriteLock(s);try{if(!this.dirIsEmpty(s))throw new I(Dn);let d=this.dirRemoveEntry(t,i);if(d<0)throw new I(d);c=this.inodeOrphanLocked(s)}finally{this.inodeWriteUnlock(s)}c&&this.inodeFree(s);let l=this.inodeOffset(t);this.w32(l+K,this.r32(l+K)-1)}finally{this.inodeWriteUnlock(t)}}symlink(e,t){this.withNamespaceLock(()=>this.symlinkUnlocked(e,t))}symlinkUnlocked(e,t){let{parentIno:r,name:i}=this.pathResolveParent(t),s=pe.encode(i),o=pe.encode(e);this.inodeWriteLock(r);try{if(this.dirLookup(r,s)>=0)throw new I(St);let c=this.inodeAlloc();if(c<0)throw new I(ie);let l=this.inodeOffset(c);if(this.w32(l+N,pt|511),this.w32(l+K,1),o.length<=40)this.u8.set(o,l+ne),this.w64(l+k,o.length);else{this.w64(l+k,0);let u=this.inodeWriteData(c,0,o,o.length);if(u!==o.length)throw u>0&&this.inodeTruncate(c,0),this.inodeFree(c),new I(u<0?u:ie)}let d=this.dirAddEntry(r,s,c);if(d<0)throw o.length<=40?(this.u8.fill(0,l+ne,l+ne+40),this.w64(l+k,0)):this.inodeTruncate(c,0),this.inodeFree(c),new I(d)}finally{this.inodeWriteUnlock(r)}}chmod(e,t){this.withNamespaceLock(()=>this.chmodUnlocked(e,t))}chmodUnlocked(e,t){let r=this.pathResolve(e,!0);if(r<0)throw new I(r);this.inodeWriteLock(r);try{let i=this.inodeOffset(r),s=this.r32(i+N);this.w32(i+N,s&W|t&4095),this.w64(i+Q,Date.now())}finally{this.inodeWriteUnlock(r)}}fchmod(e,t){let r=this.fdGet(e);if(!r)throw new I(te);this.inodeWriteLock(r.ino);try{let i=this.inodeOffset(r.ino),s=this.r32(i+N);this.w32(i+N,s&W|t&4095),this.w64(i+Q,Date.now())}finally{this.inodeWriteUnlock(r.ino)}}chown(e,t,r){this.withNamespaceLock(()=>this.chownUnlocked(e,t,r))}chownUnlocked(e,t,r){let i=this.pathResolve(e,!0);if(i<0)throw new I(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,r)}finally{this.inodeWriteUnlock(i)}}fchown(e,t,r){let i=this.fdGet(e);if(!i)throw new I(te);this.inodeWriteLock(i.ino);try{this.chownInodeUnlocked(i.ino,t,r)}finally{this.inodeWriteUnlock(i.ino)}}lchown(e,t,r){this.withNamespaceLock(()=>this.lchownUnlocked(e,t,r))}lchownUnlocked(e,t,r){let i=this.pathResolve(e,!1);if(i<0)throw new I(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,r)}finally{this.inodeWriteUnlock(i)}}chownInodeUnlocked(e,t,r){let i=this.inodeOffset(e);t!==go&&this.w32(i+Jt,t),r!==go&&this.w32(i+Qt,r);let s=this.r32(i+N);(s&W)===qt&&(s&rc)!==0&&this.w32(i+N,s&~(ec|tc)),this.w64(i+Q,Date.now())}utimens(e,t,r,i,s){this.withNamespaceLock(()=>this.utimensUnlocked(e,t,r,i,s))}utimensUnlocked(e,t,r,i,s){let o=this.pathResolve(e,!0);if(o<0)throw new I(o);this.inodeWriteLock(o);try{let a=this.inodeOffset(o),c=1073741823,l=1073741822,d=Date.now();if(r!==l){let u=r===c?d:t*1e3+Math.floor(r/1e6);this.w64(a+Yt,u)}if(s!==l){let u=s===c?d:i*1e3+Math.floor(s/1e6);this.w64(a+de,u)}this.w64(a+Q,d)}finally{this.inodeWriteUnlock(o)}}link(e,t){return this.withNamespaceLock(()=>this.linkUnlocked(e,t))}linkUnlocked(e,t){let r=this.pathResolve(e,!1);if(r<0)throw new I(r);let i=this.inodeOffset(r);if((this.r32(i+N)&W)===G)throw new I(cc);let{parentIno:o,name:a}=this.pathResolveParent(t),c=pe.encode(a);this.inodeWriteLock(o);try{if(this.dirLookup(o,c)>=0)throw new I(St);let d=this.dirAddEntry(o,c,r);if(d<0)throw new I(d);this.inodeWriteLock(r);try{let u=this.r32(i+K);this.w32(i+K,u+1),this.w64(i+Q,Date.now())}finally{this.inodeWriteUnlock(r)}return{...this.namespaceEntryIdentity(r),linkCount:this.r32(i+K)}}finally{this.inodeWriteUnlock(o)}}readlink(e){return this.withNamespaceLock(()=>this.readlinkUnlocked(e))}readlinkUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new I(t);return this.readSymlinkInodeUnlocked(t)}readSymlinkInodeUnlocked(e){let t=this.inodeOffset(e);if((this.r32(t+N)&W)!==pt)throw new I(Y);let i=this.r64(t+k);if(i<=40)return Et(this.u8.subarray(t+ne,t+ne+i));this.inodeReadLock(e);try{let s=new Uint8Array(i);return this.inodeReadData(e,0,s,i),tr.decode(s)}finally{this.inodeReadUnlock(e)}}opendir(e){return this.withNamespaceLock(()=>this.opendirUnlocked(e))}opendirUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new I(t);let r=this.inodeOffset(t);if((this.r32(r+N)&W)!==G)throw new I(be);let s=this.fdAlloc(t,et,!0);if(s<0)throw new I(s);return s}readdirEntry(e){return this.withNamespaceLock(()=>this.readdirEntryUnlocked(e))}readdirEntryUnlocked(e){let t=this.fdGet(e);if(!t||!t.isDir)throw new I(te);let r=this.inodeOffset(t.ino),i=this.r64(r+k);for(;t.offset=this.r32(We))throw new I(B);let h=this.r32(tt)*4096;if((this.r32(h+(d>>5)*4)&1<<(d&31))===0)throw new I(B);let _=Et(this.u8.subarray(l+P,l+P+p)),y=this.buildStat(d);return this.w64(f+Le,m),t.offset=m,{name:_,stat:y}}return null}closedir(e){this.close(e)}readdir(e){let t=this.opendir(e),r=[];try{let i;for(;(i=this.readdirEntry(t))!==null;)i.name!=="."&&i.name!==".."&&r.push(i.name)}finally{this.closedir(t)}return r}writeFile(e,t){let r=typeof t=="string"?pe.encode(t):t,i=this.open(e,Eo|er|rr);try{this.write(i,r)}finally{this.close(i)}}readFile(e){let t=this.open(e,et);try{let r=this.fstat(t),i=new Uint8Array(r.size);return this.read(t,i),i}finally{this.close(t)}}readFileText(e){return tr.decode(this.readFile(e))}};function zo(n,e){let t=new Map,r=new Map;for(let o of n){if(t.has(o.path))throw new Error(`${e} duplicates path ${o.path}`);if(t.set(o.path,o),o.type==="file"){if(!o.inodeGroup)throw new Error(`${e} file ${o.path} has no inode group`);if(r.has(o.inodeGroup))throw new Error(`${e} inode group ${o.inodeGroup} has multiple files`);r.set(o.inodeGroup,o)}}let i=new Set,s=new Map;for(let o of n){if(o.type!=="hardlink"||s.has(o.path))continue;let a=[],c=o,l;for(;c.type==="hardlink";){let u=s.get(c.path);if(u){l=u;break}if(i.has(c.path))throw new Error(`${e} hardlink cycle reaches ${c.path}`);if(i.add(c.path),a.push(c),!c.target)throw new Error(`${e} hardlink ${c.path} has no target`);let p=t.get(c.target);if(!p)throw new Error(`${e} hardlink ${c.path} target ${c.target} is missing`);if(p.type!=="file"&&p.type!=="hardlink"||!c.inodeGroup||p.inodeGroup!==c.inodeGroup||p.size!==c.size||p.mode!==c.mode)throw new Error(`${e} hardlink ${c.path} has an invalid target`);c=p}l??=c.type==="file"?c:void 0;let d=r.get(o.inodeGroup??"");if(!l||l!==d)throw new Error(`${e} hardlink ${o.path} does not resolve to its inode`);for(let u=a.length-1;u>=0;u-=1){let p=a[u];if(r.get(p.inodeGroup??"")!==l)throw new Error(`${e} hardlink ${p.path} does not resolve to its inode`);i.delete(p.path),s.set(p.path,l)}}return{canonicalByGroup:r,canonicalTargetByPath:s}}var fe={maxArchiveBytes:268435456,maxExpandedBytes:268435456,maxPayloadBytes:268435456,maxEntries:1e5,maxPathBytes:4096,maxSymlinkTargetBytes:65536,maxStringBytes:8192,maxTransportsPerTree:8,maxActivationCapabilities:32,maxActivationRoots:64,maxActivationCapabilityBytes:255},xe={maxArchiveBytes:512*1024*1024,maxExpandedBytes:512*1024*1024,maxPayloadBytes:512*1024*1024,maxEntries:1e5,maxGroups:512};function ko(n,e="Deferred tree collection"){for(let[t,r]of Object.entries(n))if(!Number.isSafeInteger(r)||r<0)throw new Error(`${e} ${t} usage is invalid`);if(n.groups>xe.maxGroups)throw new Error(`${e} exceeds the ${xe.maxGroups}-group cap`);if(n.archiveBytes>xe.maxArchiveBytes)throw new Error(`${e} exceeds the archive-byte cap`);if(n.expandedBytes>xe.maxExpandedBytes)throw new Error(`${e} exceeds the expansion cap`);if(n.payloadBytes>xe.maxPayloadBytes)throw new Error(`${e} exceeds the payload-byte cap`);if(n.entries>xe.maxEntries)throw new Error(`${e} exceeds the entry-count cap`)}var Po=Object.freeze({prefix:"/opt/kandelo/homebrew",cellar:"/opt/kandelo/homebrew/Cellar",repository:"/opt/kandelo/homebrew",stableEntrypoint:"/usr/bin/brew"});var No=1e5,mc=4096;var wt=Po.prefix,Mo=[["@@HOMEBREW_PREFIX@@",wt],["@@HOMEBREW_CELLAR@@",`${wt}/Cellar`],["@@HOMEBREW_REPOSITORY@@",wt],["@@HOMEBREW_LIBRARY@@",`${wt}/Library`],["@@HOMEBREW_PERL@@",`${wt}/opt/perl/bin/perl`]],Bn="@@HOMEBREW_JAVA@@",_c=/^openjdk(?:@\d+(?:\.\d+)*)?/,At=new TextEncoder,yc=[...Mo.map(([n])=>n),Bn].map(n=>({placeholder:n,bytes:At.encode(n)}));function Do(n){let e=gc(n),t=e.changed_files;if(t!=null&&!Array.isArray(t))throw new Error("INSTALL_RECEIPT.json changed_files must be an array or null when present");let r=Array.isArray(t)?t:[];if(r.length>No)throw new Error(`INSTALL_RECEIPT.json declares ${r.length} changed files, limit ${No}`);let i=[],s=new Set;for(let[o,a]of r.entries()){if(typeof a!="string")throw new Error(`INSTALL_RECEIPT.json changed_files[${o}] is not a string`);if(Sc(a,"Homebrew changed file"),s.has(a))throw new Error(`INSTALL_RECEIPT.json repeats changed file ${a}`);s.add(a),i.push(a)}return{changedFiles:i,runtimeDependencies:e.runtime_dependencies}}function gc(n){let e;try{e=JSON.parse(new TextDecoder("utf-8",{fatal:!0}).decode(n))}catch(t){throw new Error("INSTALL_RECEIPT.json is not valid UTF-8 JSON: "+Ac(t))}if(typeof e!="object"||e===null||Array.isArray(e))throw new Error("INSTALL_RECEIPT.json must contain an object");return e}function Ko(n,e,t){let r=n;for(let[o,a]of Mo)r=Co(r,At.encode(o),At.encode(a));let i=At.encode(Bn);if(Fo(r,i)){let o=Ec(e.runtimeDependencies);if(o===void 0)throw new Error(`Homebrew changed file ${t} uses ${Bn} without exactly one OpenJDK runtime dependency`);r=Co(r,i,At.encode(o))}let s=yc.find(({bytes:o})=>Fo(r,o));if(s!==void 0)throw new Error(`Homebrew changed file ${t} retains ${s.placeholder}`);return r}function Ec(n){if(!Array.isArray(n))return;let e=[];for(let r of n){if(typeof r!="object"||r===null||Array.isArray(r))continue;let i=r,s=typeof i.full_name=="string"?i.full_name.split("/").at(-1):typeof i.name=="string"?i.name.split("/").at(-1):void 0,o=s===void 0?null:_c.exec(s);s!==void 0&&o?.[0]===s&&e.push(s)}let t=[...new Set(e)];return t.length===1?`${wt}/opt/${t[0]}/libexec`:void 0}function Sc(n,e){if(n.length===0||n.startsWith("/")||n.includes("\\")||n.includes("\0")||wc(n)||At.encode(n).byteLength>mc||n.split("/").some(t=>t===""||t==="."||t===".."))throw new Error(`${e} has an unsafe path segment: ${n}`)}function wc(n){for(let e=0;e57343)){if(t<=56319&&e+1=56320&&n.charCodeAt(e+1)<=57343){e+=1;continue}return!0}}return!1}function Fo(n,e){if(e.byteLength===0||e.byteLength>n.byteLength)return!1;e:for(let t=0;t<=n.byteLength-e.byteLength;t+=1){for(let r=0;rjr||n.includes("\0")||n.includes("\\"))throw new Error(`Lazy archive mount prefix must be an absolute POSIX path: ${JSON.stringify(n)}`);let e=n.replace(/\/+$/,"");if(e==="")return"/";if(e.slice(1).split("/").some(r=>r===""||r==="."||r===".."))throw new Error(`Lazy archive mount prefix is not canonical: ${JSON.stringify(n)}`);return e}function Ol(n,e,t,r){let i=fr(t),s=new Map,o=e.map(a=>{let c=a.fileName,l=`Lazy archive ${JSON.stringify(n)} member ${JSON.stringify(c)}`;if(c.length===0)throw new Error(`${l} has an empty path`);if(c.includes("\0"))throw new Error(`${l} contains a NUL byte`);if(c.includes("\\"))throw new Error(`${l} contains a backslash`);if(c.startsWith("/")||/^[A-Za-z]:\//.test(c))throw new Error(`${l} must be relative, not absolute`);if(a.isDirectory&&a.isSymlink)throw new Error(`${l} has conflicting directory and symlink types`);if(a.isDirectory!==c.endsWith("/"))throw new Error(`${l} has inconsistent directory metadata`);let d=a.isDirectory?c.slice(0,-1):c,u=d.split("/");if(d.length===0||u.some(p=>p===""||p==="."||p===".."))throw new Error(`${l} is not a canonical relative POSIX path`);if(s.has(d))throw new Error(`${l} collides with another member at ${JSON.stringify(d)}`);if(a.isSymlink&&!r?.has(c))throw new Error(`Lazy archive symlink target was not provided: ${c}`);return s.set(d,a),{entry:a,archivePath:d,vfsPath:i==="/"?`/${d}`:`${i}/${d}`}});for(let{archivePath:a}of o){let c=a.split("/");for(let l=1;lRt)throw new Error(`VFS image metadata exceeds ${Rt} bytes`);let e;try{e=JSON.parse(new TextDecoder().decode(n))}catch(t){let r=t instanceof Error?t.message:String(t);throw new Error(`Invalid VFS image metadata JSON: ${r}`)}return hi(e)}function vl(n){if(n===null)return new Uint8Array(0);let e=hi(n),t=new TextEncoder().encode(JSON.stringify(e));if(t.byteLength>Rt)throw new Error(`VFS image metadata exceeds ${Rt} bytes`);return t}function Rl(n){return n.byteLength>=ar.length&&n[0]===ar[0]&&n[1]===ar[1]&&n[2]===ar[2]&&n[3]===ar[3]?Zl(n):n}function $r(n){let e=Rl(n);if(e.byteLengthGr)throw new Error(`VFS image lazy metadata exceeds ${Gr} bytes`);if(n.byteLengthHr)throw new Error(`VFS image lazy archive metadata exceeds ${Hr} bytes`);if(n.byteLength=0?r:void 0}function Tl(n){return n===408||n===429||n>=500&&n<=599}function zl(n,e=Date.now()){let t=n?.get("retry-after")?.trim();if(!t)return;let r;if(/^\d+$/.test(t))r=Number(t)*1e3;else{let i=Date.parse(t);if(!Number.isFinite(i))return;r=Math.max(0,i-e)}if(!(!Number.isSafeInteger(r)||r<0))return Math.min(r,xs)}function kl(n){if(!(typeof n!="object"||n===null||!("cause"in n)))return n.cause}function vs(n){if(!(typeof n!="object"||n===null||!("name"in n)))return typeof n.name=="string"?n.name:void 0}function Rs(n){if(!(typeof n!="object"||n===null||!("code"in n)))return typeof n.code=="string"?n.code:void 0}function Ls(n,e){let t=new Set,r=n;for(let i=0;r!==void 0&&i<8;i+=1){if(t.has(r))return!1;if(t.add(r),e(r))return!0;r=kl(r)}return!1}function bs(n){return Ls(n,e=>vs(e)==="AbortError"||Rs(e)==="ABORT_ERR")}function Pl(n){return bs(n)?!1:Ls(n,e=>{let t=vs(e),r=Rs(e);return e instanceof TypeError||t==="NetworkError"||t==="TimeoutError"||r!==void 0&&Al.has(r)})}function Nl(n,e){if(n instanceof qr){if(!Tl(n.status))return null;if(n.retryAfterMs!==void 0)return n.retryAfterMs}else if(!Pl(n))return null;return Math.min(wl*2**e,xs)}function ee(n){if(n?.aborted)throw n.reason}function Fl(n,e){return ee(e),n===0?Promise.resolve():new Promise((t,r)=>{let i=setTimeout(()=>a(!1),n),s=()=>a(!0,e.reason),o=!1;function a(c,l){o||(o=!0,clearTimeout(i),e?.removeEventListener("abort",s),c?r(l):t())}e?.addEventListener("abort",s,{once:!0}),e?.aborted&&s()})}async function ni(n,e){try{await n.body?.cancel(e)}catch{}}function Cl(n,e){if(n.length===1)return n[0];let t=new Uint8Array(e),r=0;for(let i of n)t.set(i,r),r+=i.byteLength;return t}function hr(n){if(n===void 0)return;if(typeof n!="object"||n===null||Array.isArray(n))throw new Error("Lazy archive integrity must be an object");let e=n;if(Object.keys(e).length!==2||!("sha256"in e)||!("bytes"in e))throw new Error("Lazy archive integrity has unexpected fields");if(typeof e.sha256!="string"||!ai.test(e.sha256))throw new Error("Lazy archive integrity has an invalid SHA-256 digest");if(!Number.isSafeInteger(e.bytes)||Number(e.bytes)<=0||Number(e.bytes)>ds)throw new Error(`Lazy archive integrity byte count must be between 1 and ${ds}`);return{sha256:e.sha256,bytes:Number(e.bytes)}}function qe(n,e,t){if(typeof n!="object"||n===null||Array.isArray(n))throw new Error(`${t} must be an object`);let r=n;if(Object.keys(r).length!==e.length||e.some(s=>!Object.prototype.hasOwnProperty.call(r,s)))throw new Error(`${t} has unexpected or missing fields`);return r}function li(n,e,t,r){if(typeof n!="object"||n===null||Array.isArray(n))throw new Error(`${r} must be an object`);let i=n,s=new Set(e);if(Object.keys(i).some(o=>!s.has(o))||t.some(o=>!Object.prototype.hasOwnProperty.call(i,o)))throw new Error(`${r} has unexpected or missing fields`);return i}function Ne(n,e,t,r){if(!Array.isArray(n)||n.lengthr)throw new Error(`${e} must contain ${t} to ${r} items`);return n}function _e(n,e,t){if(typeof n!="string"||n.length===0||n.includes("\0")||new TextEncoder().encode(n).byteLength>t)throw new Error(`${e} is invalid or exceeds ${t} bytes`);return n}function se(n,e,t,r){if(!Number.isSafeInteger(n)||Number(n)r)throw new Error(`${e} must be an integer between ${t} and ${r}`);return Number(n)}function Zr(n,e=1){let t=n,r=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.source!==void 0,i=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.modePolicy!==void 0,s=qe(n,["decoder","mediaType","sha256","bytes","expandedBytes","sourceEntryCount","transports",...i?["modePolicy"]:[],...r?["source"]:[]],"Lazy tree content"),o=s.decoder==="zip-v1"?"application/zip":s.decoder==="homebrew-bottle-tar-gzip-v1"?"application/vnd.oci.image.layer.v1.tar+gzip":null;if(o===null||s.mediaType!==o)throw new Error("Lazy tree decoder and media type are inconsistent");let a=hr({sha256:s.sha256,bytes:s.bytes});if(!a)throw new Error("Lazy tree integrity is required");let c=Ne(s.transports,"Lazy tree transports",e,fe.maxTransportsPerTree).map((m,f)=>_e(m,`Lazy tree transport ${f}`,fi));if(new Set(c).size!==c.length)throw new Error("Lazy tree transports contain duplicates");let l=se(s.expandedBytes,"Lazy tree expanded byte count",0,_l),d=se(s.sourceEntryCount,"Lazy tree source entry count",1,Lt),u=r?Kl(s.source,s.decoder):void 0,p=i?s.modePolicy:void 0;if(p!==void 0&&(p!=="portable-posix-v1"||s.decoder!=="zip-v1"||r))throw new Error("Lazy tree mode policy is invalid for its decoder");if(u!==void 0&&u.entries.length!==d)throw new Error("Lazy tree source inventory count differs from its content");return{decoder:s.decoder,mediaType:o,sha256:a.sha256,bytes:a.bytes,expandedBytes:l,sourceEntryCount:d,transports:c,...p===void 0?{}:{modePolicy:p},...u===void 0?{}:{source:u}}}function Ts(n){let e={groups:n.length,archiveBytes:0,expandedBytes:0,payloadBytes:0,entries:0};for(let t of n)t.content===void 0||t.inventory===void 0||(e.archiveBytes+=t.content.bytes,e.expandedBytes+=t.content.expandedBytes,e.payloadBytes+=t.inventory.filter(r=>r.type==="file").reduce((r,i)=>r+i.size,0),e.entries+=t.inventory.length+(t.content.source?.entries.length??0));return e}function ui(n){ko(n,"Serialized lazy tree collection")}function Ml(n){ui(Ts(n))}function Dl(n){let e=new Map;for(let t of n){let r=t.activation?.atomicGroup;if(r===void 0)continue;if(!vt(r))throw new Error(`Serialized lazy atomic activation group ${r.id} is unsealed`);let i=e.get(r.id);if(i===void 0)i={expectedCount:r.expectedCount,cohortSha256:r.cohortSha256,members:new Set,descriptors:new Set},e.set(r.id,i);else if(i.expectedCount!==r.expectedCount||i.cohortSha256!==r.cohortSha256)throw new Error(`Serialized lazy atomic activation group ${r.id} has inconsistent seals`);if(i.members.has(r.member)||i.descriptors.has(r.descriptorSha256))throw new Error(`Serialized lazy atomic activation group ${r.id} duplicates a member`);i.members.add(r.member),i.descriptors.add(r.descriptorSha256)}for(let[t,r]of e)if(r.members.size!==r.expectedCount)throw new Error(`Serialized lazy atomic activation group ${t} has ${r.members.size} of ${r.expectedCount} members`)}function ys(n){for(let[e,t]of n.entries())if(t.kind===dr||t.kind===ci||t.kind===ot)Ns(t,t.kind);else if(t.kind===ur)di(t,!1);else throw new Error(`Serialized lazy archive group ${e} has an unsupported kind`);Ml(n),Dl(n)}function Kl(n,e){if(e!=="homebrew-bottle-tar-gzip-v1")throw new Error("Lazy tree source inventory is valid only for original bottles");let t=qe(n,["schema","kind","entries"],"Lazy tree source inventory");if(t.schema!==1||t.kind!=="homebrew-bottle-tar-gzip-v1")throw new Error("Lazy tree source inventory has an unsupported identity");let r=new Map,i=Ne(t.entries,"Lazy tree source entries",1,Lt).map((o,a)=>{let c=o,l=typeof c=="object"&&c!==null&&!Array.isArray(c)?c.type:void 0,d=l==="directory"||l==="file"?["sourcePath","type","mode","size"]:l==="symlink"||l==="hardlink"?["sourcePath","type","mode","size","target"]:null;if(d===null)throw new Error(`Lazy tree source entry ${a} has invalid type`);let u=qe(o,d,`Lazy tree source entry ${a}`),p=ye(u.sourcePath,!1,`Lazy tree source entry ${a} path`);if(r.has(p))throw new Error(`Lazy tree source inventory duplicates ${p}`);let m=se(u.mode,`Lazy tree source entry ${p} mode`,0,4095),f=se(u.size,`Lazy tree source entry ${p} size`,0,Vr),h;if((l==="directory"||l==="symlink"||l==="hardlink")&&f!==0)throw new Error(`Lazy tree source ${p} has payload for ${String(l)}`);l==="symlink"?h=_e(u.target,`Lazy tree source symlink ${p} target`,Is):l==="hardlink"&&(h=ye(u.target,!1,`Lazy tree source hardlink ${p} target`));let g={sourcePath:p,type:l,mode:m,size:f,...h===void 0?{}:{target:h}};return r.set(p,g),g}),s=i.map(o=>o.sourcePath);if(s.some((o,a)=>a>0&&s[a-1]>=o))throw new Error("Lazy tree source inventory is not in canonical path order");return{schema:1,kind:"homebrew-bottle-tar-gzip-v1",entries:i}}function zs(n){let e=new Map(n.map(r=>[r.sourcePath,r])),t=new Map;for(let r of n){if(r.type!=="hardlink"||t.has(r.sourcePath))continue;let i=[],s=new Set,o=r,a;for(;o.type==="hardlink"&&(a=t.get(o.sourcePath),a===void 0);){if(s.has(o.sourcePath))throw new Error(`Lazy tree source hardlink cycle includes ${o.sourcePath}`);s.add(o.sourcePath),i.push(o);let c=e.get(o.target);if(c===void 0)throw new Error(`Lazy tree source hardlink ${o.sourcePath} target is absent`);if(c.type!=="file"&&c.type!=="hardlink")throw new Error(`Lazy tree source hardlink ${o.sourcePath} target is not regular`);o=c}a===void 0&&(a=o);for(let c of i)t.set(c.sourcePath,a)}return t}function ye(n,e,t,r=!1){if(typeof n!="string"||n.length===0||new TextEncoder().encode(n).byteLength>jr||n.includes("\0")||n.includes("\\")||n.startsWith("/")!==e)throw new Error(`${t} is not a canonical ${e?"absolute":"relative"} path`);if(r&&e&&n==="/")return n;if(n.slice(e?1:0).split("/").some(s=>s===""||s==="."||s===".."))throw new Error(`${t} has an unsafe path segment`);return n}function ks(n){let e=typeof n=="object"&&n!==null&&!Array.isArray(n)?n:null,t=e!==null&&(Object.hasOwn(e,"descriptorSha256")||Object.hasOwn(e,"expectedCount")||Object.hasOwn(e,"cohortSha256")),r=qe(n,t?["id","member","descriptorSha256","expectedCount","cohortSha256"]:["id","member"],"Lazy tree atomic activation membership"),i=_e(r.id,"Lazy tree atomic activation group",fs),s=_e(r.member,"Lazy tree atomic activation member",fs);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(i)||!/^[a-z0-9][a-z0-9:+._/-]*$/.test(s)||s.includes("//")||s.endsWith("/"))throw new Error("Lazy tree atomic activation membership is invalid");if(!t)return{id:i,member:s};let o=_e(r.descriptorSha256,"Lazy tree atomic member descriptor digest",64),a=_e(r.cohortSha256,"Lazy tree atomic cohort digest",64);if(!ai.test(o)||!ai.test(a))throw new Error("Lazy tree atomic activation digest is invalid");return{id:i,member:s,descriptorSha256:o,expectedCount:se(r.expectedCount,"Lazy tree atomic activation expected member count",1,Os),cohortSha256:a}}function vt(n){return n.descriptorSha256!==void 0&&n.expectedCount!==void 0&&n.cohortSha256!==void 0}function Bl(n){let e=qe(n,["uid","gid"],"Lazy tree registration owner");return{uid:se(e.uid,"Lazy tree registration owner uid",0,hs),gid:se(e.gid,"Lazy tree registration owner gid",0,hs)}}function Ps(n,e,t,r,i=1){let s=Zr(n,i),o=fr(t),a=["mode","capabilities","roots",...typeof r=="object"&&r!==null&&!Array.isArray(r)&&Object.hasOwn(r,"atomicGroup")?["atomicGroup"]:[]],c=qe(r,a,"Lazy tree activation");if(c.mode!=="boot-prefetch"&&c.mode!=="first-use")throw new Error("Lazy tree activation mode is invalid");let l=Ne(c.capabilities,"Lazy tree activation capabilities",1,El).map((S,O)=>{let x=_e(S,`Lazy tree activation capability ${O}`,fe.maxActivationCapabilityBytes);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(x))throw new Error(`Lazy tree activation capability ${O} is invalid`);return x}),d=Ne(c.roots,"Lazy tree activation roots",1,Sl).map((S,O)=>ye(S,!0,`Lazy tree activation root ${O}`,!0));if(new Set(l).size!==l.length||new Set(d).size!==d.length)throw new Error("Lazy tree activation contains duplicates");let u=c.atomicGroup===void 0?void 0:ks(c.atomicGroup);if(u!==void 0&&c.mode!=="first-use")throw new Error("Lazy tree atomic activation group requires a valid first-use identity");let p={mode:c.mode,capabilities:l,roots:d,...u===void 0?{}:{atomicGroup:u}},m=Ne(e,"Lazy tree inventory",1,Lt),f=[],h=new Map,g=new Map,_=s.source===void 0?void 0:new Map(s.source.entries.map(S=>[S.sourcePath,S])),y=s.source===void 0?void 0:zs(s.source.entries),E=0;for(let[S,O]of m.entries()){if(typeof O!="object"||O===null||Array.isArray(O))throw new Error(`Lazy tree entry ${S} must be an object`);let x=O.type,v=x==="directory"?["vfsPath","sourcePath","type","mode","size"]:x==="file"?["vfsPath","sourcePath","type","mode","size","inodeGroup"]:x==="symlink"?["vfsPath","sourcePath","type","mode","size","target"]:x==="hardlink"?["vfsPath","sourcePath","type","mode","size","target","inodeGroup"]:null;if(!v)throw new Error(`Lazy tree entry ${S} has an invalid type`);let L=qe(O,[...v,..._===void 0?[]:["materialization"]],`Lazy tree entry ${S}`),b=ye(L.vfsPath,!0,`Lazy tree entry ${S} VFS path`),D=ye(L.sourcePath,!1,`Lazy tree entry ${S} source path`),q=_===void 0?void 0:L.materialization;if(_!==void 0&&q!=="archive"&&q!=="archive-homebrew-relocate"&&q!=="archive-copy"&&q!=="archive-copy-mode"&&q!=="descriptor")throw new Error(`Lazy tree entry ${b} has invalid materialization provenance`);if(o!=="/"&&b!==o&&!b.startsWith(`${o}/`))throw new Error(`Lazy tree entry ${b} escapes its mount prefix`);if(h.has(b))throw new Error(`Lazy tree duplicates VFS path ${b}`);let F=se(L.mode,`Lazy tree entry ${b} mode`,0,4095),z=se(L.size,`Lazy tree entry ${b} size`,0,Vr),U,ue;if(x==="directory"){if(z!==0)throw new Error(`Lazy tree directory ${b} has nonzero size`)}else if(x==="symlink"){if(U=_e(L.target,`Lazy tree symlink ${b} target`,Is),new TextEncoder().encode(U).byteLength!==z)throw new Error(`Lazy tree symlink ${b} size differs from its target`)}else ue=_e(L.inodeGroup,`Lazy tree entry ${b} inode group`,jr),x==="hardlink"&&(U=ye(L.target,!0,`Lazy tree hardlink ${b} target`));if(x!=="hardlink"&&(E+=z,E>Vr))throw new Error("Lazy tree inventory exceeds the expansion limit");let C={vfsPath:b,sourcePath:D,...q===void 0?{}:{materialization:q},type:x,mode:F,size:z,...U===void 0?{}:{target:U},...ue===void 0?{}:{inodeGroup:ue}};if(_===void 0){let H=g.get(D);if(H){if(s.decoder!=="zip-v1"||C.type!=="hardlink"||H.inodeGroup!==C.inodeGroup)throw new Error(`Lazy tree duplicates source path ${D}`)}else{if(s.decoder==="zip-v1"&&C.type==="hardlink")throw new Error(`Lazy ZIP hardlink ${b} does not reuse a canonical source path`);g.set(D,C)}}else if(C.materialization==="descriptor"){if(C.type!=="directory"&&C.type!=="symlink")throw new Error(`Lazy tree descriptor entry ${b} is not structural`);if(_.has(D))throw new Error(`Lazy tree descriptor entry ${b} impersonates a source member`)}else{let H=_.get(D);if(H===void 0)throw new Error(`Lazy tree entry ${b} names absent source ${D}`);if(C.materialization==="archive-copy"||C.materialization==="archive-copy-mode"){if(C.type!=="file"||H.type!=="file"||C.materialization==="archive-copy"&&C.mode!==H.mode)throw new Error(`Lazy tree archive copy ${b} differs from its source`)}else if(C.materialization==="archive-homebrew-relocate"){if(C.type!=="file"&&C.type!=="hardlink"||H.type!==C.type||C.type==="file"&&H.mode!==C.mode)throw new Error(`Lazy tree receipt-relocated entry ${b} differs from its source`)}else if(H.type!==C.type||C.type==="symlink"&&H.target!==C.target||C.type!=="hardlink"&&H.mode!==C.mode)throw new Error(`Lazy tree archive entry ${b} differs from its source`)}f.push(C),h.set(b,C)}for(let S of f){let O=S.vfsPath.split("/").filter(Boolean);for(let x=1;x({path:S.vfsPath,type:S.type,mode:S.mode,size:S.size,target:S.target,inodeGroup:S.inodeGroup})),"Lazy tree");if(_!==void 0){let S=new Set;for(let O of f){if(O.materialization!=="archive-homebrew-relocate")continue;let x=_.get(O.sourcePath),v=x.type==="file"?x:y.get(x.sourcePath);if(v?.type!=="file")throw new Error(`Lazy tree receipt-relocated entry ${O.vfsPath} is not regular`);S.add(v.sourcePath)}for(let O of f){if(O.materialization==="descriptor"||O.type!=="file"&&O.type!=="hardlink")continue;let x=_.get(O.sourcePath),v=x.type==="file"?x:y.get(x.sourcePath);if(v?.type!=="file"||!S.has(v.sourcePath)&&O.size!==v.size)throw new Error(`Lazy tree archive entry ${O.vfsPath} differs from its source`)}for(let O of f){if(O.type!=="hardlink"||O.materialization!=="archive"&&O.materialization!=="archive-homebrew-relocate")continue;let x=_.get(O.sourcePath),v=h.get(O.target),L=y.get(x.sourcePath);if(x.target!==v?.sourcePath||L?.type!=="file"||L.mode!==O.mode||v?.mode!==O.mode)throw new Error(`Lazy tree hardlink ${O.vfsPath} differs from its source`)}}if(s.sourceEntryCount!==(_===void 0?g.size:_.size))throw new Error("Lazy tree source entry count differs from its inventory");if(s.source===void 0&&s.expandedBytesO.vfsPath===S||O.vfsPath.startsWith(`${S}/`)))throw new Error(`Lazy tree activation root ${S} is not owned by its inventory`);let w=new Map;for(let S of f)S.type==="file"&&w.set(S.inodeGroup,S);if(w.size!==A.canonicalByGroup.size)throw new Error("Lazy tree regular inode inventory is inconsistent");return{content:s,entries:f,mountPrefix:o,activation:p,canonicalByGroup:w}}function Xr(n){return JSON.stringify([n.sourcePath,n.type,n.inodeGroup,n.target])}function di(n,e){let t=li(n,["kind","content","url","mountPrefix","integrity","materialized","entries"],["url","mountPrefix","materialized","entries"],"Serialized legacy lazy archive");if(t.kind===void 0){if(!e)throw new Error("Serialized lazy archive is missing its kind discriminator")}else if(t.kind!==ur)throw new Error("Serialized legacy lazy archive has an unsupported kind");let r=_e(t.url,"Serialized legacy lazy archive URL",fi),i=fr(t.mountPrefix),s=hr(t.integrity);if(t.content!==void 0){if(!e||t.kind!==void 0)throw new Error("Typed legacy lazy archives cannot carry generic content");let c=Zr(t.content);if(c.decoder!=="zip-v1"||c.transports.length!==1||c.transports[0]!==r||!s||c.sha256!==s.sha256||c.bytes!==s.bytes)throw new Error("Untagged legacy ZIP content identity is inconsistent")}if(t.materialized!==!1)throw new Error("Serialized legacy lazy archive must describe pending content");let o=new Set,a=Ne(t.entries,"Serialized legacy lazy archive entries",1,Lt).map((c,l)=>{let d=li(c,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","size","isSymlink","deleted"],`Serialized legacy lazy archive entry ${l}`),u=ye(d.vfsPath,!0,`Serialized legacy lazy archive entry ${l} VFS path`);if(o.has(u))throw new Error(`Serialized legacy lazy archive duplicates path ${u}`);o.add(u);let p=se(d.ino,`Serialized legacy lazy archive entry ${u} inode`,1,Number.MAX_SAFE_INTEGER),m=d.generation===void 0?void 0:se(d.generation,`Serialized legacy lazy archive entry ${u} generation`,0,Number.MAX_SAFE_INTEGER),f=d.dataSequence===void 0?void 0:se(d.dataSequence,`Serialized legacy lazy archive entry ${u} data sequence`,0,Number.MAX_SAFE_INTEGER),h=se(d.size,`Serialized legacy lazy archive entry ${u} size`,0,Vr);if(d.isSymlink!==!1||d.deleted!==!1||d.materialized!==void 0&&d.materialized!==!1)throw new Error(`Serialized legacy lazy archive entry ${u} is not pending`);if(d.type!==void 0&&d.type!=="file")throw new Error(`Serialized legacy lazy archive entry ${u} has an invalid type`);let g=d.archivePath===void 0?void 0:ye(d.archivePath,!1,`Serialized legacy lazy archive entry ${u} archive path`),_=d.sourcePath===void 0?void 0:ye(d.sourcePath,!1,`Serialized legacy lazy archive entry ${u} source path`),y=d.inodeGroup===void 0?void 0:_e(d.inodeGroup,`Serialized legacy lazy archive entry ${u} inode group`,jr);if(d.target!==void 0)throw new Error(`Serialized legacy lazy archive entry ${u} has a link target`);return{vfsPath:u,ino:p,...m===void 0?{}:{generation:m},...f===void 0?{}:{dataSequence:f},size:h,isSymlink:!1,deleted:!1,materialized:!1,...g===void 0?{}:{archivePath:g},..._===void 0?{}:{sourcePath:_},type:"file",...y===void 0?{}:{inodeGroup:y}}});return{kind:ur,url:r,mountPrefix:i,...s===void 0?{}:{integrity:s},materialized:!1,entries:a}}function Ns(n,e){let t=qe(n,["kind","content","inventory","activation","url","mountPrefix","integrity","materialized","entries"],"Serialized lazy tree");if(t.kind!==e)throw new Error("Serialized lazy tree has an unsupported kind");let r=Ps(t.content,t.inventory,t.mountPrefix,t.activation);if(e!==ot&&e===dr!=(r.content.source===void 0))throw new Error(e===dr?"Serialized deferred-tree-v1 cannot contain original-bottle source metadata":"Serialized deferred-tree-v2 requires original-bottle source metadata");let i=r.activation.atomicGroup;if(e===ot?i===void 0||!vt(i):i!==void 0)throw new Error(e===ot?"Serialized deferred-tree-v3 requires a sealed atomic activation":"Atomic activation requires serialized deferred-tree-v3");let s=_e(t.url,"Serialized lazy tree URL",fi);if(s!==r.content.transports[0])throw new Error("Serialized lazy tree URL differs from its primary transport");let o=hr(t.integrity);if(!o||o.sha256!==r.content.sha256||o.bytes!==r.content.bytes)throw new Error("Serialized lazy tree integrity differs from its content");if(t.materialized!==!1)throw new Error("Serialized lazy tree must describe pending content");let a=new Map(r.entries.map(p=>[p.vfsPath,p])),c=new Map(r.entries.map(p=>[Xr(p),p])),l=Ne(t.entries,"Serialized lazy tree entries",0,Lt),d=new Set,u=l.map((p,m)=>{let f=li(p,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup"],`Serialized lazy tree entry ${m}`),h=ye(f.vfsPath,!0,`Serialized lazy tree entry ${m} VFS path`);if(d.has(h))throw new Error(`Serialized lazy tree duplicates pending path ${h}`);d.add(h);let g=ye(f.sourcePath,!1,`Serialized lazy tree entry ${m} source path`),_=ye(f.archivePath,!1,`Serialized lazy tree entry ${m} archive path`),y=a.get(h),E=c.get(Xr({sourcePath:g,type:typeof f.type=="string"?f.type:void 0,inodeGroup:typeof f.inodeGroup=="string"?f.inodeGroup:void 0,target:typeof f.target=="string"?f.target:void 0}))??y;if(!E||E.type!=="file"&&E.type!=="hardlink"||y?.inodeGroup!==void 0&&y.inodeGroup!==E.inodeGroup)throw new Error(`Serialized lazy tree entry ${h} is absent from its inventory`);let A=r.canonicalByGroup.get(E.inodeGroup);if(f.type!==E.type||f.inodeGroup!==E.inodeGroup||f.size!==E.size||_!==A?.sourcePath||f.target!==E.target||f.isSymlink!==!1||f.deleted!==!1||f.materialized!==!1)throw new Error(`Serialized lazy tree entry ${h} disagrees with its inventory`);let w=se(f.ino,`Serialized lazy tree entry ${h} inode`,1,Number.MAX_SAFE_INTEGER),S=se(f.generation,`Serialized lazy tree entry ${h} generation`,0,Number.MAX_SAFE_INTEGER),O=se(f.dataSequence,`Serialized lazy tree entry ${h} data sequence`,0,Number.MAX_SAFE_INTEGER);return{vfsPath:h,ino:w,generation:S,dataSequence:O,size:E.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:_,sourcePath:g,type:E.type,inodeGroup:E.inodeGroup,...E.target===void 0?{}:{target:E.target}}});for(let p of r.entries)if(r.activation.atomicGroup!==void 0&&(p.type==="file"||p.type==="hardlink")&&!d.has(p.vfsPath))throw new Error(`Serialized lazy tree omits pending path ${p.vfsPath}`);return{kind:e,content:r.content,inventory:r.entries,activation:r.activation,url:s,mountPrefix:r.mountPrefix,integrity:o,materialized:!1,entries:u}}async function lr(n,e){let t=globalThis.crypto?.subtle;if(!t)throw new Error(`${e} SHA-256 verification is unavailable`);let r=new Uint8Array(n.byteLength);r.set(n);let i=new Uint8Array(await t.digest("SHA-256",r));return Array.from(i,s=>s.toString(16).padStart(2,"0")).join("")}async function ii(n,e,t){if(t===void 0)return;if(n.byteLength!==t.bytes)throw new Error(`Lazy ${e} byte count ${n.byteLength} does not match expected ${t.bytes}`);let r=await lr(n,`Lazy ${e}`);if(r!==t.sha256)throw new Error(`Lazy ${e} SHA-256 ${r} does not match expected ${t.sha256}`)}function $l(n,e,t,r){let i=r.atomicGroup;if(i===void 0)throw new Error("Lazy atomic member is missing its typed tree descriptor");let s={schema:1,content:{decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...n.source===void 0?{}:{source:n.source}},mountPrefix:t,inventory:[...e].sort((o,a)=>o.vfsPatha.vfsPath?1:0),activation:{mode:r.mode,capabilities:r.capabilities,roots:r.roots,atomicGroup:{id:i.id,member:i.member}}};return new TextEncoder().encode(JSON.stringify(s))}function gs(n,e){let t=e.map(({member:r,descriptorSha256:i})=>({member:r,descriptorSha256:i}));return new TextEncoder().encode(JSON.stringify({schema:1,id:n,members:t.sort((r,i)=>r.memberi.member?1:0)}))}function Ul(n,e){if(n.byteLength!==e.byteLength)return!1;for(let t=0;tObject.freeze({...i}))};return r!==void 0&&(Object.freeze(r.entries),Object.freeze(r)),Object.freeze({decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,transports:t,...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...r===void 0?{}:{source:r}})}function Es(n){return{decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,transports:[...n.transports],...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...n.source===void 0?{}:{source:{schema:1,kind:"homebrew-bottle-tar-gzip-v1",entries:n.source.entries.map(e=>({...e}))}}}}function Wl(n){let e=n.map(t=>Object.freeze({...t}));return Object.freeze(e),e}function Gl(n,e,t){let r=[...n.capabilities],i=[...n.roots];return Object.freeze(r),Object.freeze(i),Object.freeze({mode:n.mode,capabilities:r,roots:i,atomicGroup:Object.freeze({id:e,member:t})})}function Hl(n){return{mode:n.activation.mode,capabilities:[...n.activation.capabilities],roots:[...n.activation.roots],atomicGroup:{id:n.id,member:n.member,descriptorSha256:n.descriptorSha256,expectedCount:n.expectedCount,cohortSha256:n.cohortSha256}}}function Vl(n,e){return n.ino===e.ino&&n.generation===e.generation&&n.dataSequence===e.dataSequence&&n.size===e.size&&n.isSymlink===e.isSymlink&&n.deleted===e.deleted&&n.materialized===e.materialized&&n.archivePath===e.archivePath&&n.sourcePath===e.sourcePath&&n.type===e.type&&n.inodeGroup===e.inodeGroup&&n.target===e.target}function Ur(n,e,t){let r=n.content,i=n.inventory,s=n.activation,o=n.integrity,a=n.entries,c=n.url,l=n.mountPrefix,d=n.materialized,u=s?.atomicGroup;if(r===void 0||i===void 0||s===void 0||u===void 0||s.mode!=="first-use"||u.id!==e||u.member!==t||d)throw new Error(`Lazy atomic activation member ${t} changed before snapshot`);if(o?.sha256!==r.sha256||o?.bytes!==r.bytes||c!==(r.transports[0]??""))throw new Error(`Lazy atomic activation member ${t} has inconsistent integrity`);let p=Fs(r),m=Wl(i),f=Gl(s,e,t),h=new Map;for(let A of m)A.type==="file"&&h.set(A.inodeGroup,A.sourcePath);let g=m.filter(A=>A.type!=="directory");if(a.size!==g.length)throw new Error(`Lazy atomic activation member ${t} has inconsistent runtime entries`);let _=g.map(A=>{let w=a.get(A.vfsPath),S=A.type==="symlink",O=S?A.sourcePath:h.get(A.inodeGroup),x=w!==void 0&&(w.sourcePath===A.sourcePath&&w.type===A.type&&w.target===A.target||A.type==="hardlink"&&w.sourcePath===O&&w.type==="file"&&w.target===void 0),v=w===void 0?["missing"]:[O===void 0?"archivePath source":void 0,w.generation===void 0?"generation":void 0,w.dataSequence===void 0?"dataSequence":void 0,w.size!==A.size?"size":void 0,w.isSymlink!==S?"symlink kind":void 0,w.deleted?"deletion state":void 0,w.materialized!==S?"materialization state":void 0,w.archivePath!==O?"archivePath":void 0,x?void 0:"descriptor mapping",w.inodeGroup!==A.inodeGroup?"inode group":void 0].filter(b=>b!==void 0);if(v.length>0)throw new Error(`Lazy atomic activation member ${t} has inconsistent mapping at ${A.vfsPath}: ${v.join(", ")}`);let L=w;return Object.freeze({vfsPath:A.vfsPath,ino:L.ino,generation:L.generation,dataSequence:L.dataSequence,size:L.size,isSymlink:L.isSymlink,deleted:!1,materialized:L.materialized,archivePath:O,sourcePath:A.sourcePath,type:A.type,...A.inodeGroup===void 0?{}:{inodeGroup:A.inodeGroup},...A.target===void 0?{}:{target:A.target}})});Object.freeze(_);let y=Object.freeze({sha256:p.sha256,bytes:p.bytes}),E=$l(p,m,l,f);return Object.freeze({id:e,member:t,descriptorBytes:E,content:p,inventory:m,activation:f,url:p.transports[0]??"",mountPrefix:l,integrity:y,entries:_})}function Ss(n,e,t,r){return Object.freeze({...n,descriptorSha256:e,expectedCount:t,cohortSha256:r})}function ws(n,e){return n.id!==e.id||n.member!==e.member||n.url!==e.url||n.mountPrefix!==e.mountPrefix||n.integrity.sha256!==e.integrity.sha256||n.integrity.bytes!==e.integrity.bytes||n.content.transports.length!==e.content.transports.length||n.content.transports.some((t,r)=>t!==e.content.transports[r])||!Ul(n.descriptorBytes,e.descriptorBytes)||n.entries.length!==e.entries.length?!1:n.entries.every((t,r)=>{let i=e.entries[r];return i!==void 0&&t.vfsPath===i.vfsPath&&Vl(t,i)})}function ql(n,e){let t=Fs(n.content,n.content.transports.map(e));return Object.freeze({...n,content:t,url:t.transports[0]??""})}function As(n){return{paths:Array.from(n.paths),expectedIno:n.ino,expectedGeneration:n.generation,expectedDataSequence:n.dataSequence,data:n.content}}var Yr=class n{fs;imageMetadata;lazyFiles=new Map;lazyArchiveGroups=[];deferredTreeMaterializationHandles=new WeakMap;lazyArchiveInodes=new Map;lazyAtomicGroups=new Map;lazyAtomicGroupByTree=new WeakMap;sealedLazyAtomicStates=new WeakMap;lazyDownloadListeners=new Set;lazyPreparations=new Map;lazyTransport={fetcher:(e,t)=>t===void 0?globalThis.fetch(e):globalThis.fetch(e,t)};constructor(e,t=null){this.fs=e,this.imageMetadata=t}static inodeKey(e,t){return`${e}:${t}`}static canAdoptLegacyLazyStub(e){return(e.mode&Pe)===cr&&e.size===0&&e.dataSequence<=1}reconcileLazyIdentityState(e){for(let[t,r]of this.lazyFiles){let i=e.get(t);if(!i||i.dataSequence!==r.dataSequence||i.paths.length===0){this.lazyFiles.delete(t);continue}r.paths=new Set(i.paths),r.paths.has(r.path)||(r.path=i.paths[0])}this.lazyArchiveInodes.clear();for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(!r?.committed)for(let c of i.snapshot.entries){if(c.isSymlink||c.materialized||c.generation===void 0)continue;let l=n.inodeKey(c.ino,c.generation),d=e.get(l);d!==void 0&&d.dataSequence===c.dataSequence&&d.paths.length>0&&this.lazyArchiveInodes.set(l,t)}continue}let s=t.content!==void 0&&t.inventory!==void 0&&!t.materialized,o=new Map;for(let c of t.entries.values()){if(c.deleted||c.materialized||c.generation===void 0)continue;let l=n.inodeKey(c.ino,c.generation);o.has(l)||o.set(l,c)}let a=new Map(Array.from(t.entries.entries()).filter(([,c])=>c.deleted||c.isSymlink&&!c.deleted));for(let[c,l]of o){let d=e.get(c);if(!(!d||d.dataSequence!==(l.dataSequence??0))){for(let u of d.paths)a.set(u,{...l,ino:d.ino,generation:d.generation,dataSequence:d.dataSequence,deleted:!1,materialized:!1});d.paths.length>0&&this.lazyArchiveInodes.set(c,t)}}t.entries=a,t.materialized=!Array.from(a.values()).some(c=>!c.isSymlink&&!c.materialized)&&!s&&(r===void 0||r.committed)}}validatePendingLazyTreeNamespaceState(e){let t=new Map;for(let r of e.values())for(let i of r.paths){if(t.has(i))throw new Error(`SharedFS namespace identity is ambiguous at ${i}`);t.set(i,r)}for(let r of this.lazyArchiveGroups){let i=this.lazyAtomicGroupByTree.get(r),o=this.sealedLazyAtomicStates.get(r)?.snapshot;if(i?.committed||o===void 0&&r.materialized||o===void 0&&(r.content===void 0||r.inventory===void 0))continue;let a=o?.inventory??r.inventory,c=o===void 0?r.entries:new Map(o.entries.map(m=>[m.vfsPath,m])),l=new Map,d=new Map,u=new Set;for(let m of c.values())m.deleted&&m.inodeGroup!==void 0&&u.add(m.inodeGroup);for(let m of a){if(m.type!=="file"&&m.type!=="hardlink")continue;l.set(m.inodeGroup,(l.get(m.inodeGroup)??0)+1);let f=d.get(m.inodeGroup)??[];f.push(m.vfsPath),d.set(m.inodeGroup,f)}let p=new Set([...u].filter(m=>d.get(m)?.every(f=>!t.has(f))));for(let m of a){let f=t.get(m.vfsPath);if(f===void 0){if(m.inodeGroup!==void 0&&p.has(m.inodeGroup))continue;throw new Error(`Lazy tree namespace entry ${m.vfsPath} is missing from the captured filesystem state`)}let h=m.type==="directory"?it:m.type==="symlink"?Br:cr;if((f.mode&Pe)!==h||(f.mode&4095)!==m.mode)throw new Error(`Lazy tree namespace entry ${m.vfsPath} disagrees with its captured type or mode`);if(m.type==="directory")continue;let g=c.get(m.vfsPath);if(g===void 0||g.ino!==f.ino||g.generation!==f.generation||g.dataSequence!==f.dataSequence)throw new Error(`Lazy tree namespace entry ${m.vfsPath} changed identity before serialization`);if(m.type==="symlink"){let _=new TextEncoder().encode(m.target).byteLength;if(f.linkCount!==1||f.size!==m.size||f.size!==_||f.symlinkTarget!==m.target)throw new Error(`Lazy tree symlink ${m.vfsPath} disagrees with its captured inventory`);continue}if(f.size!==0||f.linkCount!==l.get(m.inodeGroup))throw new Error(`Lazy tree stub ${m.vfsPath} has changed data or undeclared aliases`)}}}lazyFileForStat(e){let t=n.inodeKey(e.ino,e.generation),r=this.lazyFiles.get(t);if(r&&r.dataSequence!==e.dataSequence){this.lazyFiles.delete(t);return}return r}lazyArchiveEntriesForRead(e){let t=this.lazyAtomicGroupByTree.get(e),r=this.sealedLazyAtomicStates.get(e);return r!==void 0&&!t?.committed?r.snapshot.entries:Array.from(e.entries,([i,s])=>({vfsPath:i,...s}))}lazyArchiveForStat(e){let t=n.inodeKey(e.ino,e.generation),r=this.lazyArchiveInodes.get(t);if(!r)return;let i=this.lazyArchiveEntriesForRead(r).filter(o=>o.ino===e.ino&&o.generation===e.generation&&!o.deleted&&!o.materialized);if(i.some(o=>o.dataSequence===e.dataSequence))return r;if(this.lazyArchiveInodes.delete(t),this.sealedLazyAtomicStates.get(r)===void 0)for(let o of i)o.materialized=!0}lazyBackingForStat(e){let t=n.inodeKey(e.ino,e.generation),r=this.lazyFiles.get(t);if(r)return{token:r,path:r.path};let i=this.lazyArchiveInodes.get(t);if(!i)return null;let s=this.lazyArchiveEntriesForRead(i).find(a=>a.ino===e.ino&&a.generation===e.generation&&!a.deleted&&!a.materialized)?.vfsPath;if(s===void 0)return null;let o=this.lazyAtomicGroupByTree.get(i);return o===void 0?{token:i,path:s}:{token:o.token,path:s,atomicGroup:o}}lazyBackingForPath(e){let t=this.lazyArchiveGroups.find(r=>{let i=this.lazyAtomicGroupByTree.get(r),s=this.sealedLazyAtomicStates.get(r)?.snapshot,o=s===void 0?!r.materialized:!i?.committed,a=s?.content??r.content,c=s?.inventory??r.inventory,l=s?.activation??r.activation,d=s?.entries??Array.from(r.entries.values());return o&&a!==void 0&&c!==void 0&&l!==void 0&&d.every(u=>u.deleted||u.materialized||u.isSymlink)&&l.roots.some(u=>u==="/"||e===u||e.startsWith(`${u}/`))});if(t){let r=this.lazyAtomicGroupByTree.get(t);return{token:r?.token??t,path:e,directGroup:t,...r===void 0?{}:{atomicGroup:r}}}try{let r=this.fs.stat(e),i=this.lazyBackingForStat(r);return i?{...i,path:e}:null}catch{return null}}startLazyPreparation(e){let{path:t,token:r}=e,i={status:"pending",promise:Promise.resolve(!1)},s=e.atomicGroup?this.ensureAtomicLazyGroupMaterialized(e.atomicGroup).then(()=>!0):e.directGroup?this.ensureArchiveMaterialized(e.directGroup).then(()=>!0):this.materializePath(t);return i.promise=s.then(o=>(i.status="fulfilled",this.lazyPreparations.get(r)===i&&this.lazyPreparations.delete(r),o),o=>{throw i.status="rejected",i.error=o,o}),i.promise.catch(()=>{}),this.lazyPreparations.set(r,i),i}registerLazyAtomicGroupMembership(e,t=!1){let r=e.activation?.atomicGroup;if(r===void 0)return;let{id:i,member:s}=r;if(e.content===void 0||e.inventory===void 0||e.activation?.mode!=="first-use")throw new Error(`Lazy atomic activation group ${i} accepts only typed first-use trees`);let o=this.lazyAtomicGroups.get(i);if(o===void 0)o={id:i,token:Object.freeze({id:i}),groups:new Map,committed:!1},this.lazyAtomicGroups.set(i,o);else if(o.committed)throw new Error(`Lazy atomic activation group ${i} is already materialized`);if(o.groups.has(s))throw new Error(`Lazy atomic activation group ${i} duplicates member ${s}`);if(vt(r)){if(o.expectedCount!==void 0&&(o.expectedCount!==r.expectedCount||o.cohortSha256!==r.cohortSha256))throw new Error(`Lazy atomic activation group ${i} has inconsistent seals`);o.expectedCount=r.expectedCount,o.cohortSha256=r.cohortSha256;let a=Ur(e,i,s);this.sealedLazyAtomicStates.set(e,{snapshot:Ss(a,r.descriptorSha256,r.expectedCount,r.cohortSha256),verified:t})}else if(o.expectedCount!==void 0)throw new Error(`Lazy atomic activation group ${i} mixes sealed and unsealed members`);o.groups.set(s,e),this.lazyAtomicGroupByTree.set(e,o)}async sealLazyAtomicGroup(e,t){let r=t.map(l=>ks({id:e,member:l}).member).sort();if(r.length===0||new Set(r).size!==r.length)throw new Error(`Lazy atomic activation group ${e} expected members are invalid`);let i=this.lazyAtomicGroups.get(e);if(i===void 0||i.committed)throw new Error(`Lazy atomic activation group ${e} is not pending`);let s=[...i.groups.keys()].sort();if(JSON.stringify(s)!==JSON.stringify(r))throw new Error(`Lazy atomic activation group ${e} members differ from its seal`);if(i.expectedCount!==void 0){if(i.expectedCount!==r.length||i.cohortSha256===void 0)throw new Error(`Lazy atomic activation group ${e} has an invalid existing seal`);await this.ensureLazyAtomicGroupSealValidated(i,r.map(l=>i.groups.get(l)),!0);return}let o=r.map(l=>Ur(i.groups.get(l),e,l)),a=[];for(let l of o)a.push({member:l.member,descriptorSha256:await lr(l.descriptorBytes,`Lazy atomic member ${l.member}`),source:l});let c=await lr(gs(e,a),`Lazy atomic activation group ${e}`);for(let l of a){let d=i.groups.get(l.member),u=Ur(d,e,l.member);if(!ws(l.source,u))throw new Error(`Lazy atomic activation member ${l.member} changed while sealing`)}for(let l of a){let d=i.groups.get(l.member);d.activation.atomicGroup={id:e,member:l.member,descriptorSha256:l.descriptorSha256,expectedCount:a.length,cohortSha256:c},this.sealedLazyAtomicStates.set(d,{snapshot:Ss(l.source,l.descriptorSha256,a.length,c),verified:!0})}i.expectedCount=a.length,i.cohortSha256=c}async verifyImportedLazyAtomicGroupSeals(){await this.validatePendingLazyAtomicGroupSeals(!0)}guardSynchronousLazyAccess(e){let t=this.lazyBackingForPath(e);if(!t)return;let r=this.lazyPreparations.get(t.token);if(r?.status==="fulfilled"){this.lazyPreparations.delete(t.token);let s=this.lazyBackingForPath(e);if(!s)return;r=this.lazyPreparations.get(s.token)??this.startLazyPreparation(s)}else if(r?.status==="rejected"){this.lazyPreparations.delete(t.token);let s=r.error instanceof Error?r.error.message:String(r.error),o=new Error(`EIO: lazy backing for ${e} failed: ${s}`);throw o.code="EIO",o.cause=r.error,o}else r||(r=this.startLazyPreparation(t));let i=new Error(`EAGAIN: lazy backing for ${e} is being prepared`);throw i.code="EAGAIN",i}invalidateLazyData(e){let t=n.inodeKey(e.ino,e.generation);this.lazyFiles.delete(t);let r=this.lazyArchiveInodes.get(t);if(r){this.lazyArchiveInodes.delete(t);for(let i of r.entries.values())i.ino===e.ino&&i.generation===e.generation&&(i.materialized=!0)}}rewriteLazyNamespacePaths(e,t,r){let i=t.length>1?t.replace(/\/+$/,""):t,s=r.length>1?r.replace(/\/+$/,""):r,o=`${i}/`,a=`${s}/`,c=n.inodeKey(e.ino,e.generation),l=(e.mode&Pe)===it,d=u=>u===i?s:l&&u.startsWith(o)?a+u.slice(o.length):u;for(let[u,p]of this.lazyFiles)!l&&u!==c||(p.paths=new Set(Array.from(p.paths,d)),p.path=d(p.path));for(let u of this.lazyArchiveGroups){let p=new Map;for(let[m,f]of u.entries){let h=f.generation===void 0?null:n.inodeKey(f.ino,f.generation);p.set(l||h===c?d(m):m,f)}u.entries=p,u.inventory&&(u.inventory=u.inventory.map(m=>({...m,vfsPath:d(m.vfsPath),...m.type==="hardlink"&&m.target!==void 0?{target:d(m.target)}:{}}))),u.activation&&(u.activation={...u.activation,roots:u.activation.roots.map(d)})}}get sharedBuffer(){return this.fs.buffer}static create(e,t){return new n(Te.mkfs(e,t))}static fromExisting(e){return new n(Te.mount(e))}rebaseToNewFileSystem(e){if(!Number.isSafeInteger(e)||e<=0)throw new Error(`Invalid MemoryFileSystem maxByteLength: ${e}`);let t=SharedArrayBuffer,{bytes:r,identities:i}=this.fs.snapshotState();this.reconcileLazyIdentityState(i);let s=this.serializeLazyEntries(),o=this.serializeValidatedLazyArchiveEntries(i),a=new t(r.byteLength);new Uint8Array(a).set(r);let c=new n(Te.mount(a,{restoreImage:!0}),this.imageMetadata);c.importLazyEntries(s),c.importLazyArchiveEntriesInternal(o,!1,!0,"verified");let l=Math.min(e,Math.max(r.byteLength,ml)),d=new t(l,{maxByteLength:e}),u=n.create(d,e);u.setImageMetadata(this.imageMetadata);let p=new Set(s.flatMap(f=>f.paths??[f.path])),m=new Set;for(let f of o)if(!f.materialized)for(let h of f.entries)!h.deleted&&!h.isSymlink&&m.add(h.vfsPath);return c.copyPathToFreshFileSystem("/",u,p,m,new Map),u.importLazyEntries(s.map(f=>{let h=u.fs.lstat(f.path);return{...f,ino:h.ino,generation:h.generation,dataSequence:h.dataSequence}})),u.importLazyArchiveEntriesInternal(o.map(f=>({...f,entries:f.entries.map(h=>{if(h.deleted)return{...h,ino:0,generation:void 0};let g=u.fs.lstat(h.vfsPath);return{...h,ino:g.ino,generation:g.generation,dataSequence:g.dataSequence}})})),!1,!0,"verified"),u}getImageMetadata(){return Il(this.imageMetadata)}setImageMetadata(e){this.imageMetadata=e===null?null:hi(e)}subscribeLazyDownloads(e){return this.lazyDownloadListeners.add(e),()=>this.lazyDownloadListeners.delete(e)}setLazyFetcher(e,t={}){this.lazyTransport={fetcher:e,...t.signal===void 0?{}:{signal:t.signal}}}emitLazyDownload(e){if(this.lazyDownloadListeners.size===0)return;let t={...e,t:Ll()};for(let r of this.lazyDownloadListeners)try{r(t)}catch{}}async fetchLazyBytes(e,t){let r=0,i=e.integrity?.bytes??e.fallbackTotalBytes,s={id:e.id,kind:e.kind,url:e.url,path:e.path,mountPrefix:e.mountPrefix};for(let o=0;oe.integrity.bytes)throw new Error(`Lazy ${e.kind} exceeded expected byte count ${e.integrity.bytes}`);this.emitLazyDownload({...s,status:"progress",loadedBytes:r,totalBytes:i})}}}catch(u){try{await c.cancel(u)}catch{}throw u}}finally{c.releaseLock()}let d=Cl(l,r);return ee(t.signal),await ii(d,e.kind,e.integrity),ee(t.signal),this.emitLazyDownload({...s,status:"complete",loadedBytes:r,totalBytes:i??r}),d}catch(a){if(t.signal?.aborted){let d=t.signal.reason,u=d instanceof Error?d.message:String(d);throw this.emitLazyDownload({...s,status:"error",loadedBytes:r,totalBytes:i,error:u}),d}let c=o+1({...y})),activation:u,entries:new Map},g=y=>{let E=y.split("/").filter(Boolean),A="";for(let w=0;wE.vfsPath.split("/").length-A.vfsPath.split("/").length))if(y.type==="directory"){g(y.vfsPath);try{this.fs.mkdir(y.vfsPath,y.mode),this.fs.chmod(y.vfsPath,y.mode)}catch{if((this.fs.lstat(y.vfsPath).mode&Pe)!==it)throw new Error(`Lazy tree directory collides at ${y.vfsPath}`)}}for(let y of l){if(y.type!=="symlink")continue;g(y.vfsPath),this.fs.symlink(y.target,y.vfsPath);let E=this.fs.lstat(y.vfsPath);h.entries.set(y.vfsPath,{ino:E.ino,generation:E.generation,dataSequence:E.dataSequence,size:y.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:y.sourcePath,sourcePath:y.sourcePath,type:"symlink",target:y.target})}let _=new Map;for(let y of l){if(y.type!=="file")continue;g(y.vfsPath);let E=this.fs.createLazyStub(y.vfsPath,y.mode);this.invalidateLazyData(E),_.set(y.inodeGroup,E);let A={ino:E.ino,generation:E.generation,dataSequence:E.dataSequence,size:y.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:y.sourcePath,sourcePath:y.sourcePath,type:"file",inodeGroup:y.inodeGroup};h.entries.set(y.vfsPath,A)}for(let y of l){if(y.type!=="hardlink")continue;let E=p.get(y.inodeGroup);g(y.vfsPath),this.fs.link(E.vfsPath,y.vfsPath);let A=this.fs.lstat(y.vfsPath),w=_.get(y.inodeGroup);if(A.ino!==w.ino||A.generation!==w.generation)throw new Error(`Lazy tree hardlink ${y.vfsPath} did not share its inode`);h.entries.set(y.vfsPath,{ino:A.ino,generation:A.generation,dataSequence:A.dataSequence,size:y.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:E.sourcePath,sourcePath:y.sourcePath,type:"hardlink",inodeGroup:y.inodeGroup,target:y.target})}if(m!==void 0)for(let y of l)this.lchown(y.vfsPath,m.uid,m.gid);for(let y of h.entries.values())y.isSymlink||y.generation===void 0||this.lazyArchiveInodes.set(n.inodeKey(y.ino,y.generation),h);return this.lazyArchiveGroups.push(h),this.registerLazyAtomicGroupMembership(h),h}registerLazyTreeWithMaterializationHandle(e,t,r="/",i,s){let o=this.registerLazyTreeInternal(e,t,r,i,!0,s),a=Object.freeze({[dl]:!0});return this.deferredTreeMaterializationHandles.set(a,o),a}registerLazyArchiveFromEntries(e,t,r,i,s){let o=fr(r),a=Ol(e,t,o,i);a.some(({entry:l})=>!l.isDirectory&&!l.isSymlink)&&this.assertCanRegisterPendingLazyArchiveGroup();let c={...s?{content:Zr({decoder:"zip-v1",mediaType:"application/zip",sha256:s.sha256,bytes:s.bytes,expandedBytes:a.reduce((l,d)=>l+d.entry.uncompressedSize,0),sourceEntryCount:a.length,transports:[e]})}:{},url:e,mountPrefix:o,integrity:hr(s),materialized:!1,entries:new Map};for(let{entry:l,vfsPath:d}of a){if(l.isDirectory)continue;let u=d.split("/").filter(Boolean),p="";for(let m=0;ml.deleted||l.materialized),this.lazyArchiveGroups.push(c),c}importLazyArchiveEntries(e){this.importLazyArchiveEntriesInternal(e,!1,!0,"reject")}async importVerifiedLazyArchiveEntries(e){let t=structuredClone(e),r=this.exportLazyArchiveEntries(),i=n.fromExisting(this.sharedBuffer);i.importLazyArchiveEntriesInternal([...r,...t],!1,!0,"pending"),await i.verifyImportedLazyAtomicGroupSeals(),this.importLazyArchiveEntriesInternal(t,!1,!0,"verified")}importLazyArchiveEntriesInternal(e,t,r,i){let s=Ne(e,"Serialized lazy archive groups",0,Os).map((d,u)=>{if(typeof d!="object"||d===null||Array.isArray(d))throw new Error(`Serialized lazy archive group ${u} must be an object`);let p=d.kind;if(p===dr||p===ci||p===ot)return Ns(d,p);if(p===ur)return di(d,!1);if(p!==void 0)throw new Error(`Serialized lazy archive group ${u} has an unsupported kind`);if(r)throw new Error(`Serialized lazy archive group ${u} is missing its kind discriminator`);return di(d,!0)}),o=this.fs.identityState();this.reconcileLazyIdentityState(o);let a=[...this.serializeValidatedLazyArchiveEntries(o),...s];ys(a);let c=[],l=new Map;for(let d of s){let u=new Map,p=d.mountPrefix.replace(/\/+$/,""),m=d.content!==void 0&&d.inventory!==void 0&&d.activation!==void 0,f=m?new Map(d.inventory.map(w=>[w.vfsPath,w])):null,h=m?new Map(d.inventory.map(w=>[Xr(w),w])):null,g=new Map,_=new Map,y=new Map;for(let w of d.entries){let S=null,O=d.materialized||w.materialized===!0||w.isSymlink;if(!w.deleted&&!O){if((w.generation===void 0||w.dataSequence===void 0)&&!t)throw new Error("Live lazy-archive metadata requires inode generation and data sequence");try{S=this.fs.lstat(w.vfsPath)}catch{if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} is missing from the filesystem`);continue}if(S.ino!==w.ino){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} has a different inode`);continue}if(w.generation!==void 0&&S.generation!==w.generation){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} has a different generation`);continue}if(w.dataSequence===void 0){if(!n.canAdoptLegacyLazyStub(S)){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} is not pristine`);continue}}else if(S.dataSequence!==w.dataSequence){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} has a different data sequence`);continue}if(m){y.set(w.vfsPath,S);let v=f.get(w.vfsPath),L=h.get(Xr(w))??v;if(!L||(S.mode&Pe)!==cr||S.size!==0||(S.mode&4095)!==L.mode||v?.inodeGroup!==void 0&&v.inodeGroup!==L.inodeGroup)throw new Error(`Serialized lazy tree stub ${w.vfsPath} disagrees with its inventory`);let b=n.inodeKey(S.ino,S.generation),D=w.inodeGroup,q=g.get(D),F=_.get(b);if(q!==void 0&&q!==b||F!==void 0&&F!==D)throw new Error(`Serialized lazy tree inode group ${D} disagrees with the filesystem`);g.set(D,b),_.set(b,D)}}u.set(w.vfsPath,{ino:w.ino,generation:S?.generation??w.generation,dataSequence:S?.dataSequence??w.dataSequence,size:w.size,isSymlink:w.isSymlink,deleted:w.deleted,materialized:O,archivePath:w.archivePath??w.vfsPath.slice(p.length+1),sourcePath:w.sourcePath??w.archivePath??w.vfsPath.slice(p.length+1),type:w.type??(w.isSymlink?"symlink":"file"),inodeGroup:w.inodeGroup,target:w.target})}if(m){let w=new Map;for(let S of d.inventory){if(S.type==="file"||S.type==="hardlink"){w.set(S.inodeGroup,(w.get(S.inodeGroup)??0)+1);continue}let O;try{O=this.fs.lstat(S.vfsPath)}catch{throw new Error(`Serialized lazy tree namespace entry ${S.vfsPath} is missing from the filesystem`)}let x=S.type==="directory"?it:Br;if((O.mode&Pe)!==x||(O.mode&4095)!==S.mode||S.type==="symlink"&&(O.size!==new TextEncoder().encode(S.target).byteLength||this.fs.readlink(S.vfsPath)!==S.target))throw new Error(`Serialized lazy tree namespace entry ${S.vfsPath} disagrees with its inventory`);S.type==="symlink"&&u.set(S.vfsPath,{ino:O.ino,generation:O.generation,dataSequence:O.dataSequence,size:S.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:S.sourcePath,sourcePath:S.sourcePath,type:"symlink",target:S.target})}if(d.activation?.atomicGroup!==void 0)for(let S of d.inventory){if(S.type!=="file"&&S.type!=="hardlink")continue;if(y.get(S.vfsPath).linkCount!==w.get(S.inodeGroup))throw new Error(`Serialized lazy atomic tree inode group ${S.inodeGroup} has undeclared aliases`)}}let E=d.content===void 0?void 0:Zr(d.content),A={content:E,url:E?.transports[0]??d.url,mountPrefix:d.mountPrefix,integrity:E?{sha256:E.sha256,bytes:E.bytes}:hr(d.integrity),materialized:d.materialized||!(E&&d.inventory)&&Array.from(u.values()).every(w=>w.deleted||w.materialized),inventory:d.inventory?.map(w=>({...w})),activation:d.activation?{mode:d.activation.mode,capabilities:[...d.activation.capabilities],roots:[...d.activation.roots],...d.activation.atomicGroup===void 0?{}:{atomicGroup:{...d.activation.atomicGroup}}}:void 0,entries:u};if(c.push(A),!A.materialized){for(let[,w]of u)if(!w.deleted&&!w.materialized&&w.generation!==void 0){let S=n.inodeKey(w.ino,w.generation),O=l.get(S);if(O!==void 0&&O!==A)throw new Error(`Serialized lazy archive groups share pending inode ${S}`);if(this.lazyArchiveInodes.has(S))throw new Error(`Serialized lazy archive group collides with pending inode ${S}`);l.set(S,A)}}}for(let d of c){let u=d.activation?.atomicGroup;if(u!==void 0&&this.lazyAtomicGroups.get(u.id)?.committed)throw new Error(`Lazy atomic activation group ${u.id} is already materialized`)}if(i==="reject"&&c.some(d=>{let u=d.activation?.atomicGroup;return u!==void 0&&vt(u)}))throw new Error("Sealed lazy archive registrations require importVerifiedLazyArchiveEntries()");this.lazyArchiveGroups.push(...c);for(let d of c)this.registerLazyAtomicGroupMembership(d,i==="verified");for(let[d,u]of l)this.lazyArchiveInodes.set(d,u)}rewriteLazyArchiveUrls(e){for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0&&!r?.committed){this.assertLazyAtomicSnapshotMatchesPublic(t);let s=ql(i.snapshot,e);t.content=Es(s.content),t.url=s.url,t.integrity={...s.integrity},i.snapshot=s;continue}t.content?(t.content={...t.content,transports:t.content.transports.map(e)},t.url=t.content.transports[0]):t.url=e(t.url)}}serializeLazyArchiveEntries(){let e=[];for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(r?.committed)continue;let c=i.snapshot;if(c.content.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");e.push({kind:ot,content:Es(c.content),inventory:c.inventory.map(l=>({...l})),activation:Hl(c),url:c.url,mountPrefix:c.mountPrefix,integrity:{...c.integrity},materialized:!1,entries:c.entries.filter(l=>!l.deleted&&!l.materialized).map(({vfsPath:l,...d})=>({vfsPath:l,...d}))});continue}let s=Array.from(t.entries,([c,l])=>({vfsPath:c,ino:l.ino,generation:l.generation,dataSequence:l.dataSequence,size:l.size,isSymlink:l.isSymlink,deleted:l.deleted,materialized:l.materialized,archivePath:l.archivePath,sourcePath:l.sourcePath,type:l.type,inodeGroup:l.inodeGroup,target:l.target})).filter(c=>!c.deleted&&!c.materialized);if(s.length===0&&!(t.content&&t.inventory&&!t.materialized))continue;let o=t.content!==void 0&&t.inventory!==void 0&&t.activation!==void 0;if(o&&t.content.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");let a=t.activation?.atomicGroup;if(a!==void 0&&!vt(a))throw new Error(`Lazy atomic activation group ${a.id} must be sealed before serialization`);e.push(o?{kind:a!==void 0?ot:t.content.source===void 0?dr:ci,content:t.content,inventory:t.inventory,activation:t.activation,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:s}:{kind:ur,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:s})}return e}serializeValidatedLazyArchiveEntries(e){this.assertPendingLazyAtomicSnapshotsReadyForSerialization();let t=this.serializeLazyArchiveEntries();return ys(t),this.validatePendingLazyTreeNamespaceState(e),t}exportLazyArchiveEntries(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),this.serializeValidatedLazyArchiveEntries(e)}pendingDeferredTreeUsage(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),Ts(this.serializeValidatedLazyArchiveEntries(e))}assertCanAppendDeferredTreeUsage(e){ui(e);let t=this.pendingDeferredTreeUsage();ui({groups:t.groups+e.groups,archiveBytes:t.archiveBytes+e.archiveBytes,expandedBytes:t.expandedBytes+e.expandedBytes,payloadBytes:t.payloadBytes+e.payloadBytes,entries:t.entries+e.entries})}assertCanRegisterPendingLazyArchiveGroup(){if(this.reconcileLazyIdentityState(this.fs.identityState()),this.lazyArchiveGroups.filter(t=>{let r=this.lazyAtomicGroupByTree.get(t);return this.sealedLazyAtomicStates.get(t)?.snapshot!==void 0?!r?.committed:!t.materialized&&(t.content!==void 0&&t.inventory!==void 0||Array.from(t.entries.values()).some(s=>!s.deleted&&!s.materialized))}).length>=xe.maxGroups)throw new Error(`Cannot register another lazy archive group: ${xe.maxGroups} pending groups already exist`)}async preparePath(e){let t=!1,r=Math.max(3,this.lazyArchiveGroups.length+1);for(let i=0;i!s.materialized&&s.activation?.mode==="boot-prefetch"),t=0,r,i=Array.from({length:Math.min(e.length,yl)},async()=>{for(;r===void 0;){let s=t;if(t+=1,s>=e.length)return;try{await this.prepareLazyTreeGroup(e[s])}catch(o){r??=o}}});if(await Promise.all(i),r!==void 0)throw r;return e.length}async materializeRegisteredDeferredTree(e,t){let r=this.deferredTreeMaterializationHandles.get(e);if(r===void 0)throw new Error("Deferred-tree handle was not issued by this filesystem");let i=this.lazyAtomicGroupByTree.get(r);if(i!==void 0)throw new Error(`Deferred tree belongs to atomic activation group ${i.id}; materialize the complete group instead`);if(r.materialized)return!1;let s=this.lazyPreparations.get(r);if(s!==void 0)return s.promise;let o=new Uint8Array(t.byteLength);o.set(t);let a={status:"pending",promise:Promise.resolve(!1)};a.promise=Promise.resolve().then(async()=>(await ii(o,"tree",r.integrity),await this.materializeArchiveBytes(r,o),!0)).then(c=>(a.status="fulfilled",c),c=>{throw a.status="rejected",a.error=c,c}),a.promise.catch(()=>{}),this.lazyPreparations.set(r,a);try{return await a.promise}finally{this.lazyPreparations.get(r)===a&&this.lazyPreparations.delete(r)}}async prepareLazyTreeGroup(e){let t=this.lazyAtomicGroupByTree.get(e);if(t?.committed||t===void 0&&e.materialized)return!1;let r=this.sealedLazyAtomicStates.get(e)?.snapshot,i={token:t?.token??e,path:r?.activation.roots[0]??e.activation?.roots[0]??e.mountPrefix,directGroup:e,...t===void 0?{}:{atomicGroup:t}},s=this.lazyPreparations.get(i.token)??this.startLazyPreparation(i);try{return await s.promise}finally{this.lazyPreparations.get(i.token)===s&&this.lazyPreparations.delete(i.token)}}async ensureMaterialized(e){return this.preparePath(e)}async materializePath(e){if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0)return!1;let t;try{t=this.fs.stat(e)}catch{return!1}let r=n.inodeKey(t.ino,t.generation),i=this.lazyFiles.get(r);if(i){let o=this.lazyTransport,a=await this.fetchLazyBytes({id:`file:${t.ino}`,kind:"file",url:i.url,path:i.path,fallbackTotalBytes:i.size},o);for(let c=0;c<3;c++){if(this.lazyFiles.get(r)!==i)return!1;for(let l of new Set([e,...i.paths]))if(ee(o.signal),this.fs.replaceIfIdentity(l,i.ino,i.generation,i.dataSequence,a))return i.path=l,this.lazyFiles.delete(r),!0;this.reconcileLazyIdentityState(this.fs.identityState())}throw new Error(`Lazy file kept changing names while materializing: ${e}`)}let s=this.lazyArchiveInodes.get(r);return s?(await this.ensureArchiveMaterialized(s,{path:e,ino:t.ino,generation:t.generation}),!this.lazyArchiveInodes.has(r)):!1}async decodeAndValidateLazyTree(e,t,r){let i=r?.content??e.content,s=r?.inventory??e.inventory;if(!i||!s)throw new Error("Lazy tree is missing its decoder or complete inventory");let o=new Map,a=new Map(s.map(p=>[p.vfsPath,p]));if(i.source!==void 0)for(let p of i.source.entries)o.set(p.sourcePath,p);else for(let p of s){if(p.type==="hardlink"){let f=a.get(p.target);if(!f)throw new Error(`Lazy tree hardlink target disappeared: ${p.target}`);if(p.sourcePath===f.sourcePath)continue}if(o.get(p.sourcePath))throw new Error(`Lazy tree inventory duplicates source member ${p.sourcePath}`);o.set(p.sourcePath,{sourcePath:p.sourcePath,type:p.type,mode:p.mode,size:p.size,...p.type==="symlink"?{target:p.target}:{},...p.type==="hardlink"?{target:a.get(p.target)?.sourcePath}:{}})}let c=new Map,l=0;if(i.decoder==="zip-v1"){let{parseZipCentralDirectory:p,extractZipEntryBounded:m}=await Promise.resolve().then(()=>(Zn(),qn)),f=p(t);if(f.length!==i.sourceEntryCount||f.length!==o.size)throw new Error("Lazy ZIP tree decoded inventory counts differ from its descriptor");for(let h of f){let g=h.isDirectory?h.fileName.replace(/\/$/,""):h.fileName;if(c.has(g))throw new Error(`Lazy ZIP tree duplicates source member ${g}`);let _=o.get(g);if(!_)throw new Error(`Lazy ZIP tree has undeclared source member ${g}`);if(l+=h.uncompressedSize,l>i.expandedBytes||h.uncompressedSize!==_.size)throw new Error(`Lazy ZIP tree member ${g} exceeds its inventory`);let y=h.isDirectory?"directory":h.isSymlink?"symlink":"file",E=i.modePolicy==="portable-posix-v1"?y==="directory"?493:y==="symlink"?511:(h.mode&73)!==0?493:420:h.mode&4095;if(y!==_.type||E!==_.mode)throw new Error(`Lazy ZIP tree member ${g} differs from inventory`);if(h.isDirectory)c.set(g,{type:"directory",mode:E});else{let A=m(t,h,_.size);if(h.isSymlink){let w;try{w=new TextDecoder("utf-8",{fatal:!0}).decode(A)}catch{throw new Error(`Lazy ZIP tree symlink ${g} is not UTF-8`)}c.set(g,{type:"symlink",mode:E,target:w})}else c.set(g,{type:"file",mode:E,data:A})}}}else{let{parseTarGzip:p}=await Promise.resolve().then(()=>(us(),ls)),m=p(t,{label:`Lazy tree ${i.sha256}`,limits:{maxCompressedBytes:i.bytes,maxUncompressedBytes:i.expandedBytes,maxEntries:i.sourceEntryCount}});l=new DataView(t.buffer,t.byteOffset,t.byteLength).getUint32(t.byteLength-4,!0);for(let f of m){if(c.has(f.path))throw new Error(`Lazy TAR tree duplicates source member ${f.path}`);f.type==="file"?c.set(f.path,{type:"file",mode:f.mode,data:f.data}):f.type==="directory"?c.set(f.path,{type:"directory",mode:f.mode}):c.set(f.path,{type:f.type,mode:f.mode,target:f.linkName})}}if(c.size!==i.sourceEntryCount||c.size!==o.size||l!==i.expandedBytes)throw new Error("Lazy tree decoded inventory counts differ from its descriptor");for(let[p,m]of o){let f=c.get(p);if(!f)throw new Error(`Lazy tree is missing source member ${p}`);let h=m.type;if(f.type!==h)throw new Error(`Lazy tree member ${p} is ${f.type}, expected ${h}`);if((f.mode&4095)!==m.mode)throw new Error(`Lazy tree member ${p} mode differs from inventory`);if(h==="file"&&f.data?.byteLength!==m.size)throw new Error(`Lazy tree member ${p} size differs from inventory`);if(h==="symlink"&&f.target!==m.target)throw new Error(`Lazy tree symlink ${p} target differs from inventory`);if(h==="hardlink"&&f.target!==m.target)throw new Error(`Lazy tree hardlink ${p} target differs from inventory`)}let d=new Set(s.flatMap(p=>p.materialization==="archive-homebrew-relocate"?[p.sourcePath]:[]));if(i.source!==void 0){let p=new Map(i.source.entries.map(h=>[h.sourcePath,h])),m=zs(i.source.entries),f=i.source.entries.filter(h=>h.sourcePath==="INSTALL_RECEIPT.json"||h.sourcePath.endsWith("/INSTALL_RECEIPT.json"));if(f.length>1)throw new Error(`Lazy Homebrew bottle has ${f.length} INSTALL_RECEIPT.json source members, expected at most one`);if(f.length===0){if(d.size>0)throw new Error("Lazy Homebrew bottle marks receipt relocation without INSTALL_RECEIPT.json")}else{let h=f[0],g=h.type==="file"?h:m.get(h.sourcePath),_=g===void 0?void 0:c.get(g.sourcePath);if(g?.type!=="file"||_?.type!=="file"||_.data===void 0)throw new Error("Lazy Homebrew bottle INSTALL_RECEIPT.json is not regular");let y=Do(_.data),E=h.sourcePath.lastIndexOf("/"),A=E<0?"":h.sourcePath.slice(0,E),w=new Set(y.changedFiles.map(O=>A.length===0?O:`${A}/${O}`));if(d.size!==w.size||[...d].some(O=>!w.has(O)))throw new Error("Lazy Homebrew bottle relocation markers differ from INSTALL_RECEIPT.json");let S=new Set;for(let O of w){let x=p.get(O),v=x?.type==="file"?x:x===void 0?void 0:m.get(x.sourcePath),L=v===void 0?void 0:c.get(v.sourcePath);if(v?.type!=="file"||L?.type!=="file"||L.data===void 0)throw new Error(`Lazy Homebrew bottle changed source ${O} is not regular`);S.has(v.sourcePath)||(L.data=Ko(L.data,y,O),S.add(v.sourcePath))}}}else if(d.size>0)throw new Error("Lazy tree receipt relocation requires original-bottle source truth");let u=new Map;for(let p of s){if(p.type!=="file"||p.materialization==="descriptor")continue;let m=c.get(p.sourcePath);if(m?.type!=="file"||!m.data)throw new Error(`Lazy tree has no file content for ${p.sourcePath}`);u.set(p.sourcePath,m.data)}return u}async ensureArchiveMaterialized(e,t){let r=this.lazyAtomicGroupByTree.get(e);if(r!==void 0){if(r.committed)return;let o=this.sealedLazyAtomicStates.get(e)?.snapshot,a=this.lazyPreparations.get(r.token)??this.startLazyPreparation({token:r.token,path:o?.activation.roots[0]??e.activation?.roots[0]??e.mountPrefix,atomicGroup:r});try{await a.promise}finally{this.lazyPreparations.get(r.token)===a&&this.lazyPreparations.delete(r.token)}return}if(e.materialized)return;let i=this.lazyTransport,s=await this.fetchLazyArchiveData(e,i);ee(i.signal),await this.materializeArchiveBytes(e,s,t,i.signal)}async fetchLazyArchiveData(e,t,r){let i=r?.content??e.content,s=r?.inventory??e.inventory,o=i!==void 0&&s!==void 0,a=r?.mountPrefix??e.mountPrefix,c=r?.integrity??e.integrity,l=o?i.transports:[r?.url??e.url],d=[],u=null;for(let[p,m]of l.entries())try{u=await this.fetchLazyBytes({id:`archive:${a}:${i?.sha256??m}:${p}`,kind:o?"tree":"archive",url:m,mountPrefix:a,integrity:c},t);break}catch(f){if(ee(t.signal),bs(f))throw f;d.push(f instanceof Error?f.message:String(f))}if(ee(t.signal),u===null)throw new Error(`All ${l.length} lazy ${o?"tree":"archive"} transports failed: ${d.join("; ")}`);return u}async materializeArchiveBytes(e,t,r,i){if(ee(i),e.materialized)return;let s=await this.prepareLazyArchiveContents(e,t,i),o=r?n.inodeKey(r.ino,r.generation):null;for(let a=0;a<3;a++){let c=this.collectLazyArchiveReplacements(e,s,r);if(c.size>0&&(ee(i),!this.fs.replaceManyIfIdentities(Array.from(c.values(),As)))){if(this.reconcileLazyIdentityState(this.fs.identityState()),o&&!this.lazyArchiveInodes.has(o))return;continue}if(ee(i),this.publishLazyArchiveReplacements(e,c),e.materialized||(this.reconcileLazyIdentityState(this.fs.identityState()),o&&!this.lazyArchiveInodes.has(o)))return}if(o&&this.lazyArchiveInodes.has(o))throw new Error(`Lazy archive member kept changing names while materializing: ${r?.path}`)}async prepareLazyArchiveContents(e,t,r,i){ee(r);let s=i?.content??e.content,o=i?.inventory??e.inventory,c=s!==void 0&&o!==void 0?await this.decodeAndValidateLazyTree(e,t,i):null;ee(r);let{parseZipCentralDirectory:l,extractZipEntry:d}=await Promise.resolve().then(()=>(Zn(),qn));ee(r);let u=c?[]:l(t),p=new Map;for(let _ of u){if(p.has(_.fileName))throw new Error(`Lazy archive contains duplicate member: ${_.fileName}`);p.set(_.fileName,_)}let f=(i?.mountPrefix??e.mountPrefix).replace(/\/+$/,""),h=new Map,g=i===void 0?Array.from(e.entries):i.entries.map(_=>[_.vfsPath,_]);for(let[_,y]of g){if(y.deleted||y.materialized)continue;let E=y.archivePath??_.slice(f.length+1),A=c?void 0:p.get(E),w=c?.get(E);if(c){if(w===void 0||w.byteLength!==y.size)throw new Error(`Lazy tree member ${E} does not match its registered metadata`)}else if(A===void 0||A.isDirectory||A.isSymlink||A.uncompressedSize!==y.size)throw new Error(`Lazy archive member ${E} does not match its registered metadata`);if(y.generation===void 0)continue;let S=n.inodeKey(y.ino,y.generation),O=h.get(S);if(O&&O.archivePath!==E)throw new Error(`Lazy archive aliases for inode ${S} name different members`);if(!O){let x=w??d(t,A);if(x.byteLength!==y.size)throw new Error(`Lazy archive member ${E} extracted ${x.byteLength} bytes, expected ${y.size}`);h.set(S,{archivePath:E,content:x})}}return h}collectLazyArchiveReplacements(e,t,r,i){let s=new Map,o=i===void 0?Array.from(e.entries):i.entries.map(a=>[a.vfsPath,a]);for(let[a,c]of o){if(c.deleted||c.materialized||c.generation===void 0)continue;let l=n.inodeKey(c.ino,c.generation);if(this.lazyArchiveInodes.get(l)!==e)continue;let d=t.get(l);if(!d)throw new Error(`Lazy archive has no extracted content for inode ${l}`);let u=s.get(l);u||(u={ino:c.ino,generation:c.generation,dataSequence:c.dataSequence??0,paths:new Set,content:d.content},s.set(l,u)),u.paths.add(a),r&&r.ino===c.ino&&r.generation===c.generation&&u.paths.add(r.path)}return s}publishLazyArchiveReplacements(e,t){for(let[r,i]of t){this.lazyArchiveInodes.delete(r);for(let s of e.entries.values())s.ino===i.ino&&s.generation===i.generation&&(s.materialized=!0)}e.materialized=Array.from(e.entries.values()).every(r=>r.deleted||r.materialized)}collectAtomicTreeNamespace(e,t){let r=new Set,i=new Map,s=new Map,o=new Map(t.entries.map(c=>[c.vfsPath,c]));for(let c of t.inventory)(c.type==="file"||c.type==="hardlink")&&s.set(c.inodeGroup,(s.get(c.inodeGroup)??0)+1);let a=[];for(let c of t.inventory){let l;try{l=this.fs.lstat(c.vfsPath)}catch{throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`)}let d=c.type==="directory"?it:c.type==="symlink"?Br:cr;if((l.mode&Pe)!==d||(l.mode&4095)!==c.mode)throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`);if(c.type==="symlink"){let u=o.get(c.vfsPath);if(u===void 0||!u.isSymlink||u.deleted||u.ino!==l.ino||u.generation!==l.generation||u.dataSequence!==l.dataSequence||this.fs.readlink(c.vfsPath)!==c.target)throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`)}else if(c.type==="file"||c.type==="hardlink"){let u=o.get(c.vfsPath);if(u===void 0||u.deleted||u.materialized||u.isSymlink||u.generation===void 0||u.inodeGroup!==c.inodeGroup||u.ino!==l.ino||u.generation!==l.generation||u.dataSequence!==l.dataSequence||l.size!==0||l.linkCount!==s.get(c.inodeGroup))throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`);let p=n.inodeKey(u.ino,u.generation);if(this.lazyArchiveInodes.get(p)!==e)throw new Error(`Lazy atomic tree lost deferred ownership of ${c.vfsPath}`);let m=i.get(c.inodeGroup);if(m!==void 0&&m!==p)throw new Error(`Lazy atomic tree split hard links at ${c.vfsPath}`);i.set(c.inodeGroup,p),r.add(p)}a.push({path:c.vfsPath,expectedIno:l.ino,expectedGeneration:l.generation,expectedDataSequence:l.dataSequence,expectedMode:l.mode,expectedLinkCount:l.linkCount,expectedSize:l.size,expectedUid:l.uid,expectedGid:l.gid})}return{guards:a,pendingIdentities:r.size}}assertLazyAtomicSnapshotMatchesPublic(e){let t=this.sealedLazyAtomicStates.get(e),r=t?.snapshot,i=e.activation?.atomicGroup,s=r?.member??i?.member??"unknown",o;if(r!==void 0)try{o=Ur(e,r.id,r.member)}catch{o=void 0}if(t===void 0||r===void 0||i===void 0||!vt(i)||i.id!==r.id||i.member!==r.member||i.descriptorSha256!==r.descriptorSha256||i.expectedCount!==r.expectedCount||i.cohortSha256!==r.cohortSha256||o===void 0||!ws(r,o))throw new Error(`Lazy atomic activation member ${s} changed after sealing`);return t}assertPendingLazyAtomicSnapshotsReadyForSerialization(){for(let e of this.lazyArchiveGroups){if(!this.sealedLazyAtomicStates.has(e)||this.lazyAtomicGroupByTree.get(e)?.committed)continue;let r=this.assertLazyAtomicSnapshotMatchesPublic(e);if(!r.verified)throw new Error(`Lazy atomic activation group ${r.snapshot.id} has not been cryptographically verified after import`)}}async validatePendingLazyAtomicGroupSeals(e){for(let t of this.lazyAtomicGroups.values()){if(t.committed||t.expectedCount===void 0||t.cohortSha256===void 0)continue;let r=[...t.groups.entries()].sort(([i],[s])=>is?1:0).map(([,i])=>i);await this.ensureLazyAtomicGroupSealValidated(t,r,e)}}async ensureLazyAtomicGroupSealValidated(e,t,r){if(this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r))return;let i=e.sealValidationFlight;if(i===void 0&&(i=this.validateLazyAtomicGroupSealOnce(e,t),e.sealValidationFlight=i,i.then(()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)},()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)})),await i,!this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r))throw new Error(`Lazy atomic activation group ${e.id} seal verification did not authenticate every member`)}assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r){if(e.committed)return!0;if(e.expectedCount===void 0||e.cohortSha256===void 0||t.length!==e.expectedCount)throw new Error(`Lazy atomic activation group ${e.id} is not completely sealed`);let i=!0,s=[];for(let o of t){let a=this.assertLazyAtomicSnapshotMatchesPublic(o),c=a.snapshot;if(c.id!==e.id||c.expectedCount!==e.expectedCount||c.cohortSha256!==e.cohortSha256||e.groups.get(c.member)!==o)throw new Error(`Lazy atomic activation group ${e.id} has an inconsistent member`);i&&=a.verified,s.push(a)}if(i&&r)for(let o=0;ofh?1:0).map(([,f])=>f);if(t.length===0)throw new Error(`Lazy atomic activation group ${e.id} has no trees`);if(await this.ensureLazyAtomicGroupSealValidated(e,t,!0),e.committed)return;let r=t.map(f=>this.sealedLazyAtomicStates.get(f).snapshot),i=t.map((f,h)=>({group:f,...this.collectAtomicTreeNamespace(f,r[h])})),s=this.lazyTransport,o=new Array(t.length),a=0,c=!1,l,d=Array.from({length:Math.min(gl,t.length)},async()=>{for(;!c;){let f=a++;if(f>=t.length)return;let h=t[f],g=r[f];try{let _=await this.fetchLazyArchiveData(h,s,g);ee(s.signal),o[f]={group:h,snapshot:g,contents:await this.prepareLazyArchiveContents(h,_,s.signal,g)}}catch(_){c||(c=!0,l=_)}}});if(await Promise.all(d),c)throw o.fill(void 0),l;ee(s.signal);for(let f of t)this.assertLazyAtomicSnapshotMatchesPublic(f);let u=[],p=[],m=[];for(let f=0;f{let a=this.lazyAtomicGroupByTree.get(o);return this.sealedLazyAtomicStates.get(o)?.snapshot===void 0?!o.materialized&&o.content!==void 0&&o.inventory!==void 0:!a?.committed});if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0&&r.length===0)return;let i=Array.from(this.lazyFiles.values(),o=>o.path);for(let o of i)await this.ensureMaterialized(o);let s=new Set(this.lazyArchiveInodes.values());for(let o of r)s.add(o);for(let o of s)await this.prepareLazyTreeGroup(o)}this.reconcileLazyIdentityState(this.fs.identityState());let e=this.lazyArchiveGroups.some(t=>{let r=this.lazyAtomicGroupByTree.get(t);return this.sealedLazyAtomicStates.get(t)?.snapshot===void 0?!t.materialized&&t.content!==void 0&&t.inventory!==void 0:!r?.committed});if(this.lazyFiles.size!==0||this.lazyArchiveInodes.size!==0||e)throw new Error("Cannot create a self-contained VFS image while lazy entries remain pending")}async saveImage(e){e?.materializeAll&&await this.materializeAllLazyEntries(),await this.validatePendingLazyAtomicGroupSeals(!1);let{bytes:t,identities:r}=this.fs.snapshotState({normalizeTimestampsMs:e?.normalizeTimestampsMs});this.reconcileLazyIdentityState(r);let i=this.serializeLazyEntries(),s=i.length>0,o=s?new TextEncoder().encode(JSON.stringify(i)):new Uint8Array(0);if(o.byteLength>Gr)throw new Error(`VFS image lazy metadata exceeds ${Gr} bytes`);let a=this.serializeValidatedLazyArchiveEntries(r),c=a.length>0,l=c?new TextEncoder().encode(JSON.stringify(a)):new Uint8Array(0);if(l.byteLength>Hr)throw new Error(`VFS image lazy archive metadata exceeds ${Hr} bytes`);let d=e?.metadata===void 0?this.imageMetadata:e.metadata,u=vl(d),p=u.byteLength>0,m=c?4+l.byteLength:0,f=p?4+u.byteLength:0,h=he+t.byteLength+4+o.byteLength+m+f,g=new Uint8Array(h),_=new DataView(g.buffer);_.setUint32(0,oi,!0),_.setUint32(4,si,!0),_.setUint32(8,(s?ei:0)|(c?Wr:0)|(c?ri:0)|(p?ti:0),!0),_.setUint32(12,t.byteLength,!0),g.set(t,he);let y=he+t.byteLength;if(_.setUint32(y,o.byteLength,!0),o.byteLength>0&&g.set(o,y+4),c){let E=y+4+o.byteLength;_.setUint32(E,l.byteLength,!0),g.set(l,E+4)}if(p){let E=y+4+o.byteLength+m;_.setUint32(E,u.byteLength,!0),g.set(u,E+4)}return g}static readImageMetadata(e){let t=$r(e);if(!(t.flags&ti))return null;let{metadataOffset:r}=ms(t.image,t.view,t.flags,t.sabLen);if(t.image.byteLengthRt)throw new Error(`VFS image metadata exceeds ${Rt} bytes`);if(t.image.byteLength0){let g=r.subarray(f+4,f+4+h),_=Ne(_s(g,"VFS image lazy metadata"),"VFS image lazy entries",0,Lt);m.importLazyEntriesInternal(_,!0)}if(s&Wr){let g=a.archiveOffset,_=i.getUint32(g,!0);if(_>0){let y=r.subarray(g+4,g+4+_),E=_s(y,"VFS image lazy archive metadata");m.importLazyArchiveEntriesInternal(E,!0,!!(s&ri),"pending")}}return m}adaptStat(e){return{dev:0,ino:e.ino,mode:e.mode,nlink:e.linkCount,uid:e.uid,gid:e.gid,size:e.size,atimeMs:e.atime,mtimeMs:e.mtime,ctimeMs:e.ctime}}adaptStatWithLazySize(e){let t=this.adaptStat(e),r=this.lazyFileForStat(e);if(r)return t.size=r.size,t;let i=this.lazyArchiveForStat(e);if(i){for(let s of this.lazyArchiveEntriesForRead(i))if(s.ino===e.ino&&s.generation===e.generation&&!s.deleted){t.size=s.size;break}}return t}open(e,t,r){(t&rr)===0&&!((t&er)!==0&&(t&Kn)!==0)&&this.guardSynchronousLazyAccess(e);let i=this.fs.open(e,t,r);return(t&rr)!==0&&this.invalidateLazyData(this.fs.fstat(i)),i}close(e){return this.fs.close(e),0}read(e,t,r,i){if(i>0){let s=this.lazyBackingForStat(this.fs.fstat(e));s&&(this.reconcileLazyIdentityState(this.fs.identityState()),s=this.lazyBackingForStat(this.fs.fstat(e)),s&&this.guardSynchronousLazyAccess(s.path))}return r!==null?this.fs.readAt(e,t.subarray(0,i),typeof r=="bigint"?Rn(r):r):this.fs.read(e,t.subarray(0,i))}write(e,t,r,i){if(r!==null){let o=this.fs.writeAt(e,t.subarray(0,i),typeof r=="bigint"?Rn(r):r);return o>0&&this.invalidateLazyData(this.fs.fstat(e)),o}let s=this.fs.write(e,t.subarray(0,i));return s>0&&this.invalidateLazyData(this.fs.fstat(e)),s}append(e,t,r,i){let s=this.fs.append(e,t.subarray(0,r),yo(i));return s.written>0&&this.invalidateLazyData(this.fs.fstat(e)),s}seek(e,t,r){return this.fs.lseek(e,typeof t=="bigint"?vn(t):t,r)}fstat(e){return this.adaptStatWithLazySize(this.fs.fstat(e))}fpathconf(e,t){let r=this.fstat(e);return bn(r,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}ftruncate(e,t){this.fs.ftruncate(e,t),this.invalidateLazyData(this.fs.fstat(e))}fsync(e){}fchmod(e,t){this.fs.fchmod(e,t)}fchown(e,t,r){this.fs.fchown(e,t,r)}stat(e){return this.adaptStatWithLazySize(this.fs.stat(e))}lstat(e){return this.adaptStatWithLazySize(this.fs.lstat(e))}statfs(e){this.fs.stat(e);let t=this.fs.statfs();return{type:1397114451,bsize:t.blockSize,blocks:t.totalBlocks,bfree:t.freeBlocks,bavail:t.freeBlocks,files:t.totalInodes,ffree:t.freeInodes,fsid:0,namelen:t.maxName,frsize:t.blockSize,flags:0}}pathconf(e,t){let r=this.stat(e);return bn(r,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}mkdir(e,t){this.fs.mkdir(e,t)}rmdir(e){this.fs.rmdir(e)}unlink(e){let t=this.fs.unlink(e),r=n.inodeKey(t.ino,t.generation);if(t.linkCount>1&&(this.lazyFiles.has(r)||this.lazyArchiveInodes.has(r))){this.reconcileLazyIdentityState(this.fs.identityState());return}let i=this.lazyFiles.get(r);i&&(i.paths.delete(e),t.linkCount<=1?this.lazyFiles.delete(r):i.path===e&&(i.path=i.paths.values().next().value));let s=this.lazyArchiveInodes.get(r);if(s){let o=s.entries.get(e);if(t.linkCount<=1){for(let a of s.entries.values())a.ino===t.ino&&a.generation===t.generation&&(a.deleted=!0);this.lazyArchiveInodes.delete(r)}else o&&s.entries.delete(e)}}rename(e,t){let{source:r,replaced:i}=this.fs.rename(e,t);if(i&&i.ino===r.ino&&i.generation===r.generation)return;let s=!1;if(i){let o=n.inodeKey(i.ino,i.generation);i.linkCount>1&&(this.lazyFiles.has(o)||this.lazyArchiveInodes.has(o))&&(this.reconcileLazyIdentityState(this.fs.identityState()),s=!0);let a=this.lazyFiles.get(o);!s&&a&&(a.paths.delete(t),i.linkCount<=1?this.lazyFiles.delete(o):a.path===t&&(a.path=a.paths.values().next().value));let c=this.lazyArchiveInodes.get(o);if(!s&&c){let l=c.entries.get(t);i.linkCount<=1?(l&&(l.deleted=!0),this.lazyArchiveInodes.delete(o)):l&&c.entries.delete(t)}}s||this.rewriteLazyNamespacePaths(r,e,t)}link(e,t){let r=this.fs.link(e,t),i=n.inodeKey(r.ino,r.generation),s=this.lazyFiles.get(i);s&&s.paths.add(t);let o=this.lazyArchiveInodes.get(i);if(o){let a=Array.from(o.entries.values()).find(c=>c.ino===r.ino&&c.generation===r.generation);a&&o.entries.set(t,{...a})}}symlink(e,t){this.fs.symlink(e,t)}readlink(e){return this.fs.readlink(e)}chmod(e,t){this.fs.chmod(e,t)}chown(e,t,r){this.fs.chown(e,t,r)}lchown(e,t,r){this.fs.lchown(e,t,r)}createFileWithOwner(e,t,r,i,s){let o=this.open(e,577,t);s.length>0&&this.write(o,s,null,s.length),this.close(o),this.chown(e,r,i),this.chmod(e,t)}mkdirWithOwner(e,t,r,i){this.mkdir(e,t),this.chown(e,r,i),this.chmod(e,t)}symlinkWithOwner(e,t,r,i){this.symlink(e,t),this.lchown(t,r,i)}copyPathToFreshFileSystem(e,t,r,i,s){let o=this.lstat(e),a=o.mode&Pe,c=o.mode&4095;if(a===it){e==="/"?(t.chown(e,o.uid,o.gid),t.chmod(e,c)):t.mkdirWithOwner(e,c,o.uid,o.gid);let p=this.opendir(e);try{for(;;){let m=this.readdir(p);if(!m)break;m.name==="."||m.name===".."||this.copyPathToFreshFileSystem(e==="/"?`/${m.name}`:`${e}/${m.name}`,t,r,i,s)}}finally{this.closedir(p)}n.applyTimes(t,e,o);return}let l=o.nlink>1?`${o.dev}:${o.ino}`:null,d=l?s.get(l):void 0;if(d){t.link(d,e);return}if(a===Br){t.symlinkWithOwner(this.readlink(e),e,o.uid,o.gid),l&&s.set(l,e);return}if(a!==cr)throw new Error(`Unsupported file type while rebasing VFS: ${e}`);if(r.has(e)||i.has(e)){t.createFileWithOwner(e,c,o.uid,o.gid,new Uint8Array(0)),n.applyTimes(t,e,o),l&&s.set(l,e);return}this.copyRegularFileToFreshFileSystem(e,t,o,c),l&&s.set(l,e)}copyRegularFileToFreshFileSystem(e,t,r,i){let s=this.open(e,fl,0),o=null;try{o=t.open(e,hl,i);let a=new Uint8Array(Math.min(pl,Math.max(1,r.size))),c=r.size;for(;c>0;){let l=Math.min(a.byteLength,c),d=this.read(s,a,null,l);if(d<=0)throw new Error(`Unexpected EOF while rebasing VFS file: ${e}`);let u=0;for(;u!e||e==="."||e===".."))throw new Error(`Binary resolver path must be a normalized portable relative path: ${JSON.stringify(n)}`);return n}var ct=new Set(["wasm32","wasm64"]);function Ye(n){if(nu(n),!n.startsWith("programs/"))return n;let e=n.slice(9),t=e.split("/",1)[0];return ct.has(t)?n:`programs/wasm32/${e}`}function iu(n,e=$(Si(),"wasm")){let t=Ye(n),r=[$(e,t)];return n==="kernel.wasm"?r.push($(e,"kandelo-kernel.wasm")):n==="userspace.wasm"?r.push($(e,"wasm_posix_userspace.wasm")):n==="rootfs.vfs"&&r.push($(e,"rootfs.vfs")),r}var en=class extends Error{constructor(e){super(e),this.name="BinaryNotFoundError"}};function qs(){let n=[],e=!1;try{let r=at();e=!0;for(let[i,s]of[["local-binaries",$(r,"local-binaries")],["binaries",$(r,"binaries")]])n.push({label:i,root:s,identity:i==="local-binaries"?"local-generation":"program-cache",allowRegularFileClosure:!1,candidatesFor(o){return[$(s,Ye(o))]}})}catch{}let t=$(Si(),"wasm");return n.push({label:"installed package",root:t,identity:"installed-package",allowRegularFileClosure:!e,candidatesFor(r){return iu(r,t)}}),n}function bt(n,e){return new Error(`Invalid package manifest ${n}: ${e}`)}function ae(n){try{return tn(n),!0}catch(e){if(e instanceof Error&&"code"in e&&e.code==="ENOENT")return!1;throw e}}function Ms(n,e,t){if(n.length===0||n.startsWith("/")||n.includes("\\")||n.includes("\0")||n.split("/").some(r=>!r||r==="."||r===".."))throw bt(e,`${t} must be a normalized portable relative path`);return n}function Jr(n,e,t,r=!0){if(n.length===0||n==="."||n===".."||n.includes("/")||n.includes("\\")||n.includes("\0")||!r&&n.includes("@"))throw bt(e,`${t} must be a safe single path component`);return n}var Ds="kandelo-program-packages-v2",Fe="program-packages.json",Ks=null,ou=null,Qr=null,mi=0;function wi(){return ou??$(Si(),"wasm",Fe)}function Zs(){if(Object.prototype.hasOwnProperty.call(process.env,"WASM_POSIX_DEPS_REGISTRY")){let t=null;return(process.env.WASM_POSIX_DEPS_REGISTRY??"").split(":").filter(Boolean).map(r=>r.startsWith("~/")&&process.env.HOME!==void 0?$(process.env.HOME,r.slice(2)):rn(r)?Oe(r):(t??=at(),Oe(t,r)))}let n;try{n=$(at(),"packages","registry")}catch{return null}let e=!1;if(ae(n)){if(!Xe(n).isDirectory())return[n];e=Gs(n,{withFileTypes:!0}).filter(t=>t.isDirectory()||t.isSymbolicLink()).some(t=>ae($(n,t.name,"package.toml")))}return!e&&Xs()===null&&ae(wi())?null:[n]}function Xs(){let n;try{n=at()}catch{return null}if(!pr($(n,"tools","xtask","Cargo.toml"))||!pr($(n,"scripts","dev-shell.sh")))return null;try{let e=Ie(Ei()),t=Ie(n);return[$(t,"host"),$(t,"scripts")].some(i=>pr(i)&&vi(Ie(i),e))?t:null}catch{return null}}function Ai(n,e,t){let r=[typeof t.stderr=="string"?t.stderr.trim():"",typeof t.stdout=="string"?t.stdout.trim():"",t.error?.message??""].filter(Boolean).join(` +`);return`${n} ${e.join(" ")} failed${t.status===null?"":` with status ${t.status}`}${r?`: +${r}`:""}`}function su(n){let e=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,t=e?"rustc":"bash",r=e?["-vV"]:[$(n,"scripts","dev-shell.sh"),"rustc","-vV"],i=gi(t,r,{cwd:n,encoding:"utf8"});if(i.status!==0)throw new Error(Ai(t,r,i));let s=i.stdout.split(/\r?\n/).find(o=>o.startsWith("host: "))?.slice(6).trim();if(!s)throw new Error(`Could not determine the Rust host target for ${n}`);return s}function _i(n){try{if(tn(n).isFile())return Ie(n)}catch{}throw new Error(`Prepared xtask is not a regular file: ${n}`)}function au(n){let e=process.env.WASM_POSIX_XTASK_BIN;if(e!==void 0){let l=rn(e)?Oe(e):Oe(n,e);return _i(l)}if(Qr?.sourceRepoRoot===n)return _i(Qr.xtaskPath);let t=su(n),r=$(n,"target",t,"release",process.platform==="win32"?"xtask.exe":"xtask"),i=["build","--release","-p","xtask","--target",t,"--quiet"],s=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,o=s?"cargo":"bash",a=s?i:[$(n,"scripts","dev-shell.sh"),"cargo",...i],c=gi(o,a,{cwd:n,encoding:"utf8"});if(c.status!==0)throw new Error(Ai(o,a,c));return Qr={sourceRepoRoot:n,xtaskPath:_i(r)},Qr.xtaskPath}function cu(){let n=Xs();if(n===null)return;let e=Zs();if(e===null)return;if(Ks){Ks(n,e);return}let t=au(n),r=["build-deps","program-index-context-check","--source-repo-root",n],i=gi(t,r,{cwd:n,encoding:"utf8",env:{...process.env,WASM_POSIX_DEPS_REGISTRY:e.join(":")}});if(i.status!==0)throw new Error(`Program package source projection is not current: +${Ai(t,r,i)}`)}function lu(n,e){if(mi>0||!n.some(t=>t.startsWith("programs/")))return e();mi+=1;try{return cu(),e()}finally{mi-=1}}function Ze(n,e){let t=Object.keys(n).sort(),r=[...e].sort();return t.length===r.length&&t.every((i,s)=>i===r[s])}function yi(n){let e;try{e=JSON.parse(st(n,"utf8"))}catch(o){throw new Error(`Invalid program package index ${n}: ${o instanceof Error?o.message:String(o)}`)}if(typeof e!="object"||e===null||!Ze(e,["format","identities","packages"])||e.format!==Ds||typeof e.identities!="object"||e.identities===null||Array.isArray(e.identities)||typeof e.packages!="object"||e.packages===null||Array.isArray(e.packages))throw new Error(`Invalid program package index ${n}: expected ${Ds}`);let t=new Map,r=e.identities;for(let[o,a]of Object.entries(r)){if(Jr(o,n,"identity package name",!1),typeof a!="object"||a===null||!Ze(a,["manifestSha256","cacheKeys"])||typeof a.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(a.manifestSha256)||typeof a.cacheKeys!="object"||a.cacheKeys===null||Array.isArray(a.cacheKeys))throw new Error(`Invalid program package index ${n}: malformed identity ${JSON.stringify(o)}`);let c=a.cacheKeys;if(!Ze(c,["wasm32","wasm64"])||Object.values(c).some(l=>typeof l!="string"||!/^[a-f0-9]{64}$/.test(l)))throw new Error(`Invalid program package index ${n}: identity ${JSON.stringify(o)} has invalid contextual cache keys`);t.set(o,{manifestSha256:a.manifestSha256,cacheKeys:c})}let i=new Map,s=e.packages;for(let[o,a]of Object.entries(s)){if(Jr(o,n,"package name",!1),typeof a!="object"||a===null||!Ze(a,["manifestSha256","arches","cacheKeys","dependencyClosures","members"])||!Array.isArray(a.arches)||typeof a.cacheKeys!="object"||a.cacheKeys===null||Array.isArray(a.cacheKeys)||typeof a.dependencyClosures!="object"||a.dependencyClosures===null||Array.isArray(a.dependencyClosures)||!Array.isArray(a.members)||typeof a.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(a.manifestSha256))throw new Error(`Invalid program package index ${n}: malformed package ${JSON.stringify(o)}`);let c=a.arches;if(c.length===0||new Set(c).size!==c.length||c.some(h=>typeof h!="string"||!ct.has(h)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has invalid arches`);let l=a.cacheKeys;if(!Ze(l,c)||Object.values(l).some(h=>typeof h!="string"||!/^[a-f0-9]{64}$/.test(h)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has invalid cache keys`);let d=a.dependencyClosures;if(!Ze(d,c))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has invalid dependency closure arches`);let u={};for(let h of c){let g=d[h];if(!Array.isArray(g))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has a malformed dependency closure for ${h}`);let _=new Set;u[h]=g.map((y,E)=>{if(typeof y!="object"||y===null||!Ze(y,["packageName","manifestSha256","cacheKey"])||typeof y.packageName!="string"||typeof y.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(y.manifestSha256)||typeof y.cacheKey!="string"||!/^[a-f0-9]{64}$/.test(y.cacheKey))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} dependency ${E+1} for ${h} is malformed`);let A=y;if(Jr(A.packageName,n,`${o} dependency packageName`,!1),A.packageName===o||_.has(A.packageName))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} dependency closure for ${h} must contain unique dependencies other than itself`);_.add(A.packageName);let w=t.get(A.packageName);if(!w||w.manifestSha256!==A.manifestSha256||w.cacheKeys[h]!==A.cacheKey)throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} dependency ${JSON.stringify(A.packageName)} for ${h} does not match the index's authoritative contextual identity`);return A})}let p=a.members.map((h,g)=>{if(typeof h!="object"||h===null||h.kind!=="output"&&h.kind!=="runtime-file"||typeof h.sourceArtifact!="string"||typeof h.mirrorPath!="string")throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} member ${g+1} is malformed`);let _=h,y=_.kind==="output"?["kind","sourceArtifact","mirrorPath","outputName","forkInstrumentation"]:["kind","sourceArtifact","mirrorPath","guestPath","mode"];if(!Ze(_,y))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} member ${g+1} has unknown or missing fields`);if(Ms(_.sourceArtifact,n,`${o} sourceArtifact`),Ms(_.mirrorPath,n,`${o} mirrorPath`),_.kind==="output"){if(typeof _.outputName!="string"||_.forkInstrumentation!=="auto"&&_.forkInstrumentation!=="disabled")throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} output member lacks outputName or forkInstrumentation`);Jr(_.outputName,n,`${o} outputName`)}else if(typeof _.guestPath!="string"||!_.guestPath.startsWith("/")||!Number.isInteger(_.mode)||_.mode<0||_.mode>511)throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} runtime member lacks valid guestPath or mode`);return _});if(p.length===0||new Set(p.map(h=>h.sourceArtifact)).size!==p.length||new Set(p.map(h=>h.mirrorPath)).size!==p.length||p.length===1&&p[0].mirrorPath.includes("/")||p.length>1&&p.some(h=>!h.mirrorPath.startsWith(`${o}/`)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} members are empty, collide, or violate scalar/package-directory layout`);let m=a.manifestSha256,f=t.get(o);if(!f||f.manifestSha256!==m||c.some(h=>f.cacheKeys[h]!==l[h]))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} does not match its contextual package identity`);i.set(o,{manifestSha256:m,arches:c,cacheKeys:l,dependencyClosures:u,members:p})}return{identities:t,packages:i,indexPath:n}}function Ys(n){return JSON.stringify({manifestSha256:n.manifestSha256,arches:n.arches,cacheKeys:Object.fromEntries(n.arches.map(e=>[e,n.cacheKeys[e]])),dependencyClosures:Object.fromEntries(n.arches.map(e=>[e,[...n.dependencyClosures[e]].sort((t,r)=>t.packageNamer.packageName?1:0)])),members:n.members.map(e=>e.kind==="output"?{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,outputName:e.outputName,forkInstrumentation:e.forkInstrumentation}:{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,guestPath:e.guestPath,mode:e.mode})})}function Oi(){let n=wi();return ae(n)?yi(n):null}function uu(n){let e=Oi();if(!e)return null;let t=n.split("/");if(t[0]!=="programs"||!ct.has(t[1]))return null;let r=t[1];if(t.length>=4){let s=t[2];return e.packages.get(s)?.arches.includes(r)?s:null}if(t.length!==3)return null;let i=t[2];for(let[s,o]of e.packages)if(o.arches.includes(r)&&o.members.some(a=>a.kind==="output"&&a.mirrorPath.split("/").at(-1)===i))return s;return null}function Bs(n){let e=uu(n);if(e)throw new Error(`Installed package resolver path ${JSON.stringify(n)} is owned by ${JSON.stringify(e)}, but that package is not selected by the configured program registry`)}function js(){let n=Zs(),e=new Map,t=new Map,r=new Map,i=new Map,s=[];if(n===null){let l=wi();if(!ae(l))return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:s};let d=yi(l);for(let[u,p]of d.identities)e.set(u,{...p,packageName:u,policyPath:`${d.indexPath}#identities.${u}`});for(let[u,p]of d.packages)s.push({packageName:u,projection:p,selected:!0}),r.set(u,{...p,packageName:u,policyPath:`${d.indexPath}#${u}`});return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:s}}let o=new Set,a=null,c=null;for(let l of n){if(!ae(l))continue;if(!Xe(l).isDirectory())throw new Error(`Program registry root is not a directory: ${l}`);let d=$(l,Fe);if(!ae(d))throw new Error(`Program registry ${l} is missing ${Fe}; generate it with xtask build-deps program-index`);let u=yi(d);a??=u.identities,c??=u.packages;let p=Gs(l,{withFileTypes:!0}).filter(m=>m.isDirectory()||m.isSymbolicLink()).sort((m,f)=>m.name.localeCompare(f.name));for(let m of p){let f=m.name,h=$(l,f,"package.toml");if(!ae(h))continue;let g=!1;try{g=Xe(h).isFile()}catch{g=!1}if(!g)continue;let _=u.packages.get(f),y=!o.has(f);if(_&&s.push({packageName:f,projection:_,selected:y}),!y)continue;o.add(f);let E=a.get(f);E?e.set(f,{...E,packageName:f,manifestPath:h,policyPath:h}):t.set(f,h);let A=c.get(f);if(!A){i.set(f,h);continue}r.set(f,{...A,packageName:f,manifestPath:h,policyPath:h})}}return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:s}}function $s(n){if(!n.manifestPath)return;let e;try{e=st(n.manifestPath)}catch(r){throw new Error(`Program package identity cannot verify ${n.manifestPath}: ${r instanceof Error?r.message:String(r)}`)}if(Hs("sha256").update(e).digest("hex")!==n.manifestSha256)throw new Error(`Program package identity is stale for ${n.manifestPath}; regenerate ${Fe}`)}function du(n){if(!n.manifestPath)return;let e;try{e=st(n.manifestPath)}catch(r){throw new Error(`Program package projection cannot verify ${n.manifestPath}: ${r instanceof Error?r.message:String(r)}`)}if(Hs("sha256").update(e).digest("hex")!==n.manifestSha256)throw new Error(`Program package projection is stale for ${n.manifestPath}; regenerate ${Fe}`)}function mr(n){let e=Ii(),t=e.packages.get(n);if(t)return du(t),t;let r=e.unprojectedPackages.get(n);if(r)throw new Error(`Package ${JSON.stringify(n)} is selected at ${r} but is absent from ${Fe}; regenerate the registry projection`);return null}function fu(n,e){let t=n.dependencyClosures[e];if(!t)throw bt(n.policyPath,`package ${JSON.stringify(n.packageName)} lacks a dependency identity closure for ${e}`);let r=js(),i=r.identities.get(n.packageName);if(!i){let o=r.unidentifiedPackages.get(n.packageName);throw new Error(`Program package ${JSON.stringify(n.packageName)} has no authoritative contextual identity for ${e}${o?` at ${o}`:""}; regenerate ${Fe} with the exact ordered registry roots`)}$s(i);let s=i.cacheKeys[e];if(i.manifestSha256!==n.manifestSha256||s!==n.cacheKeys[e])throw new Error(`Program package ${JSON.stringify(n.packageName)} was projected with manifest ${n.manifestSha256} and cache key ${n.cacheKeys[e]} for ${e}, but the authoritative first-hit registry context at ${i.policyPath} requires manifest ${i.manifestSha256} and cache key ${s??""}. Regenerate this program projection with the exact ordered registry roots; the highest-priority index must carry the complete combined-context projection rather than relying on a lower suffix-context build identity.`);for(let o of t){let a=r.identities.get(o.packageName);if(!a){let l=r.unidentifiedPackages.get(o.packageName);throw l?new Error(`Program package ${JSON.stringify(n.packageName)} was generated against dependency ${JSON.stringify(o.packageName)}, but the first-hit package at ${l} has no contextual identity in ${Fe}`):new Error(`Program package ${JSON.stringify(n.packageName)} was generated against dependency ${JSON.stringify(o.packageName)}, but that dependency is absent from the configured first-hit registry roots`)}$s(a);let c=a.cacheKeys[e];if(a.manifestSha256!==o.manifestSha256||c!==o.cacheKey)throw new Error(`Program package ${JSON.stringify(n.packageName)} has a contextual cache identity mismatch for ${e}: its projection expects dependency ${JSON.stringify(o.packageName)} manifest ${o.manifestSha256} and cache key ${o.cacheKey}, but first-hit selection at ${a.policyPath} provides manifest ${a.manifestSha256} and cache key ${c??""}. Regenerate the program projection with the exact ordered registry roots; the complete highest-priority projection must bind every selected program to the same combined dependency context.`)}}function Ii(){let n=js(),{physicalProgramClaims:e,...t}=n,r={...t,legacyFlatOutputs:new Map,forkInstrumentationDisabledOutputs:new Map},i=[];for(let s of n.packages.values()){let o=s.members.length>1;for(let a of s.arches)for(let c of s.members){let l=i.find(m=>m.arch===a&&(m.path===c.mirrorPath||m.path.startsWith(`${c.mirrorPath}/`)||c.mirrorPath.startsWith(`${m.path}/`)));if(l)throw new Error(`Program resolver paths programs/${a}/${l.path} and programs/${a}/${c.mirrorPath} conflict between selected packages ${JSON.stringify(l.packageName)} and ${JSON.stringify(s.packageName)}`);if(i.push({arch:a,path:c.mirrorPath,packageName:s.packageName}),c.kind!=="output")continue;let d=c.mirrorPath.split("/").at(-1),u=`${a}/${d}`,p=r.legacyFlatOutputs.get(u);p||(p={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},r.legacyFlatOutputs.set(u,p)),o?p.packagePaths.set(`programs/${a}/${c.mirrorPath}`,s.packageName):p.scalarOwners.add(s.packageName),c.forkInstrumentation==="disabled"&&r.forkInstrumentationDisabledOutputs.set(`${a}/${c.mirrorPath}`,s.packageName)}}for(let{packageName:s,projection:o,selected:a}of e)if(!(a&&n.packages.has(s)))for(let c of o.arches)for(let l of o.members){if(l.kind!=="output")continue;let d=l.mirrorPath.split("/").at(-1),u=`${c}/${d}`,p=r.legacyFlatOutputs.get(u);p||(p={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},r.legacyFlatOutputs.set(u,p)),p.shadowedOwners.add(s)}return r}function hu(n){let e=n.split("/");if(e.length!==3||e[0]!=="programs"||!ct.has(e[1]))return null;let t=Ii().legacyFlatOutputs.get(`${e[1]}/${e[2]}`);if(!t)return null;for(let r of t.scalarOwners){let i=mr(r);if(i)return i}for(let r of t.packagePaths.values())mr(r);if(t.packagePaths.size>0)throw new Error(`Legacy flat resolver path ${JSON.stringify(n)} belongs to a multi-member package; use ${[...t.packagePaths.keys()].sort().map(r=>JSON.stringify(r)).join(" or ")}`);for(let r of t.shadowedOwners){let i=mr(r);if(i)return i;throw new Error(`Legacy flat resolver path ${JSON.stringify(n)} is claimed by a lower-root program package ${JSON.stringify(r)}, but its first-hit selected package does not project that program; stale scalar mirror fallback is forbidden`)}return null}function Us(n,e,t){if(!n.arches.includes(e))throw bt(n.policyPath,`package ${JSON.stringify(n.packageName)} does not declare resolver artifacts for ${e}`);let r=n.cacheKeys[e];if(!r)throw bt(n.policyPath,`package ${JSON.stringify(n.packageName)} lacks a cache identity for ${e}`);fu(n,e);let i=Ys(n),s=n.members.map(o=>({packageName:n.packageName,relPath:`programs/${e}/${o.mirrorPath}`,sourceArtifact:o.sourceArtifact,cacheKey:r,forkInstrumentation:o.kind==="output"?o.forkInstrumentation??null:null,projectionIdentity:i}));if(!s.some(o=>o.relPath===t))throw bt(n.policyPath,`resolver path ${JSON.stringify(t)} is not a declared member of package ${JSON.stringify(n.packageName)}`);return{manifestPath:n.policyPath,packageName:n.packageName,members:s}}function pu(n){let e=Ye(n),t=e.split("/");if(t[0]==="programs"&&!tu()&&Oi()===null)throw new Error(`Installed host package is missing wasm/${Fe}; program artifacts cannot be resolved without packaged policy`);if(t.length===3){let o=hu(e);return o?Us(o,t[1],e):(Bs(e),null)}if(t.length<4||t[0]!=="programs"||!ct.has(t[1]))return null;let r=t[1],i=t[2],s=mr(i);return s?Us(s,r,e):(Bs(e),null)}function mu(n){let e=Ye(n);for(let t of["programs/wasm32/","programs/wasm64/"])if(e.startsWith(t))return e.slice(t.length);return null}function _u(n){let e=Ye(n);for(let t of ct){let r=`programs/${t}/`;if(e.startsWith(r)){let i=Ii().forkInstrumentationDisabledOutputs.get(`${t}/${e.slice(r.length)}`);return i?mr(i)!==null:!1}}return!1}function yu(n){let e=Ye(n);if(e==="kernel.wasm")return Qi;let t=mu(e);if(t&&t.endsWith(".wasm"))return Ql}function gu(n,e,t){if(!n.endsWith(".wasm"))return!1;try{let r=st(n),i=r.buffer.slice(r.byteOffset,r.byteOffset+r.byteLength),s=t===void 0?_u(e):t==="disabled";return uo(i,{expectedAbi:43,requiredExports:yu(e),requireForkInstrumentation:s?!1:void 0,forbidForkInstrumentation:s}).length>0}catch{return!0}}function Eu(n){if(!n.endsWith(".vfs")&&!n.endsWith(".vfs.zst"))return!1;try{let t=Yr.readImageMetadata(st(n))?.kernelAbi;return t!==void 0&&t!==43}catch{return!0}}function xi(n,e,t){return gu(n,e,t)||Eu(n)}function Js(n,e,t){let r=n.filter(ae);return r.length===0?null:r.find(i=>{try{return Xe(i).isFile()&&!xi(i,e,t)}catch{return!1}})??null}function Qs(n,e,t){try{if(!tn(n).isSymbolicLink())return n;let i=Ie(n);if(!Xe(i).isFile()||xi(i,e,t))throw new Error("canonical target is not an accepted regular file");if(Ye(e).startsWith("programs/")&&Su(i))throw new Error("resolver-owned program generation has no matching selected package projection");return i}catch(r){throw new Error(`Binary changed or became invalid while pinning ${e}: ${r instanceof Error?r.message:String(r)}`)}}function Su(n){let e=[Vs()];try{e.push($(at(),"local-binaries",".kandelo-local-generations"))}catch{}return e.some(t=>{try{return ae(t)&&vi(Ie(t),n)}catch{return!1}})}function vi(n,e){let t=Yl(n,e);return t===""||t!==".."&&!t.startsWith(`..${jl}`)&&!rn(t)}function wu(n,e){let t=e.split("/"),r=n;for(let i=0;ia.packageName!==s))return"declared package members do not share a valid program namespace";if(!Xe(e).isDirectory())return"shared package generation root is not a directory";let o=t[0].cacheKey;if(!/^[a-f0-9]{64}$/.test(o)||t.some(a=>a.cacheKey!==o))return"declared package members do not share one valid cache identity";if(n.identity==="local-generation"){let a=$(n.root,".kandelo-local-generations",i,s,o);if(!ae(a))return"local mirror targets are not one direct immutable local generation";let c=Ie(a);return _r(e)===c?null:"local mirror targets are not one direct immutable local generation"}if(n.identity==="program-cache"){let a=Vs();if(!ae(a))return"fetched mirror targets are not one canonical program-cache generation";let c=Ie(a),l=Xl(e),d=l.startsWith(`${s}-`)&&new RegExp(`-rev[0-9]+-${i}-${o}$`).test(l);return _r(e)===c&&d?null:"fetched mirror targets are not one canonical program-cache generation"}return"installed-package symlink closures are not an immutable installed identity"}function Ou(n,e,t){if(e.length!==t.length)return{failure:"internal member/path count mismatch"};try{let r=e.map(l=>{let d=tn(l);return d.isSymbolicLink()?"symlink":d.isFile()?"file":"other"});if(r.includes("other"))return{failure:"a selected mirror member is neither a regular file nor a symlink"};let i=r.every(l=>l==="symlink"),s=r.every(l=>l==="file");if(!i&&!s)return{failure:"regular files and symlinks cannot share one package identity"};if(s){if(!n.allowRegularFileClosure)return{failure:"a mutable source-checkout wasm tree is not an installed package identity"};let l=t[0].packageName,d=t[0].projectionIdentity;if(t.some(h=>h.packageName!==l||h.projectionIdentity!==d))return{failure:"declared members do not share one selected package projection"};let p=Oi()?.packages.get(l);if(!p||Ys(p)!==d)return{failure:"installed bytes do not match the selected package projection"};let m=Ie(n.root),f=[];for(let h of e){let g=Ie(h);if(!vi(m,g)||!Xe(g).isFile())return{failure:"an installed-package member escapes its immutable wasm tree"};f.push(g)}return{paths:f}}let o=null,a=[];for(let l=0;lIu(n))}function Iu(n){let e=Ye(n),t=pu(e);if(t){let o=xu(t.members.map(a=>a.relPath),t.members);if(o)return o[t.members.findIndex(a=>a.relPath===e)];throw new en(`Package artifacts not found for ${t.packageName}: ${e}`)}let r=[],i=[];for(let o of qs())for(let a of o.candidatesFor(n))r.push(a),i.push(a);let s=Js(i,n);if(s)return Qs(s,n);throw i.some(ae)?new Error(`Binary exists but was rejected by artifact policy: ${n} +`+r.map(o=>` checked: ${o}`).join(` +`)):new en(`Binary not found: ${n} +`+r.map(o=>` checked: ${o}`).join(` `)+` - Run scripts/fetch-binaries.sh, place a file at local-binaries/${e}, or install a package that includes wasm/${r}.`)}function Zc(r,e){if(r.length===0)return[];let t=!1,n=[];for(let i of Po()){let s=[],o=[];if(e){let[a,c,d]=e[0].relPath.split("/");a==="programs"&&c&&d&&(t||=oe(D(i.root,a,c,d)))}for(let[a,c]of r.entries()){let d=i.candidatesFor(c),u=d.filter(oe);t||=u.length>0;let l=Co(d,c,e?.[a]?.forkInstrumentation);l?s.push(l):u.length>0?o.push(`${c} (rejected by artifact policy)`):o.push(`${c} (missing)`)}if(o.length===0&&e){let a=Kc(i,s,e);if("failure"in a)o.push(`shared package identity rejected: ${a.failure}`);else{let c=a.paths.flatMap((d,u)=>Br(d,r[u],e[u].forkInstrumentation)?[r[u]]:[]);if(c.length>0)o.push(`pinned package generation rejected by artifact policy: ${c.join(", ")}`);else return a.paths}}if(o.length===0)return s.map((a,c)=>No(a,r[c],e?.[c]?.forkInstrumentation));n.push(` ${i.label} (${i.root}): ${o.join(", ")}`)}if(!t)return null;throw new Error(`Package artifact closure is incomplete: no single provenance tier contains every accepted artifact, and tiers will not be mixed. -`+n.join(` -`))}var[$o,...Hc]=process.argv.slice(2);(!$o||Hc.length>0)&&(console.error("usage: scripts/resolve-binary.sh "),process.exit(2));try{process.stdout.write(`${Mo($o)} -`)}catch(r){console.error(r instanceof Error?r.message:String(r)),process.exit(1)} + Run scripts/fetch-binaries.sh, place a file at local-binaries/${e}, or install a package that includes wasm/${n}.`)}function xu(n,e){if(n.length===0)return[];let t=!1,r=[];for(let i of qs()){let s=[],o=[];if(e){let[a,c,l]=e[0].relPath.split("/");a==="programs"&&c&&l&&(t||=ae($(i.root,a,c,l)))}for(let[a,c]of n.entries()){let l=i.candidatesFor(c),d=l.filter(ae);t||=d.length>0;let u=Js(l,c,e?.[a]?.forkInstrumentation);u?s.push(u):d.length>0?o.push(`${c} (rejected by artifact policy)`):o.push(`${c} (missing)`)}if(o.length===0&&e){let a=Ou(i,s,e);if("failure"in a)o.push(`shared package identity rejected: ${a.failure}`);else{let c=a.paths.flatMap((l,d)=>xi(l,n[d],e[d].forkInstrumentation)?[n[d]]:[]);if(c.length>0)o.push(`pinned package generation rejected by artifact policy: ${c.join(", ")}`);else return a.paths}}if(o.length===0)return s.map((a,c)=>Qs(a,n[c],e?.[c]?.forkInstrumentation));r.push(` ${i.label} (${i.root}): ${o.join(", ")}`)}if(!t)return null;throw new Error(`Package artifact closure is incomplete: no single provenance tier contains every accepted artifact, and tiers will not be mixed. +`+r.join(` +`))}var[ta,...vu]=process.argv.slice(2);(!ta||vu.length>0)&&(console.error("usage: scripts/resolve-binary.sh "),process.exit(2));try{process.stdout.write(`${ea(ta)} +`)}catch(n){console.error(n instanceof Error?n.message:String(n)),process.exit(1)} diff --git a/scripts/test-install-local-generation.sh b/scripts/test-install-local-generation.sh index 2fe6785f12..50beeeb139 100755 --- a/scripts/test-install-local-generation.sh +++ b/scripts/test-install-local-generation.sh @@ -288,42 +288,22 @@ EOF write_kernel_wat() { local marker="$1" local output="$2" + local required_exports + required_exports="$( + jq -er '.host_adapter.required_kernel_exports[]' \ + "$REPO_ROOT/abi/snapshot.json" + )" || fail "could not read required kernel exports from the ABI snapshot" + [ -n "$required_exports" ] || + fail "ABI snapshot has no required kernel exports" { printf '%s\n' '(module' \ ' (func $entry (result i32) i32.const 0)' - for export_name in \ - __abi_version \ - kernel_alloc_scratch \ - kernel_create_process \ - kernel_create_process_with_stdio \ - kernel_dequeue_signal \ - kernel_exec_prepare \ - kernel_exec_setup_for_thread \ - kernel_fork_process \ - kernel_get_parent_pid \ - kernel_get_process_exit_signal \ - kernel_get_process_state \ - kernel_handle_channel \ - kernel_has_sa_nocldstop \ - kernel_host_adapter_manifest_len \ - kernel_host_adapter_manifest_ptr \ - kernel_ipc_shmat_for_process \ - kernel_ipc_shmat_for_task \ - kernel_ipc_shmdt_for_process \ - kernel_ipc_shmdt_for_task \ - kernel_mark_process_signaled \ - kernel_pipe_has_readers \ - kernel_posix_timer_fire \ - kernel_prepare_write_operation \ - kernel_reap_exited_child \ - kernel_remove_process \ - kernel_set_current_tid \ - kernel_spawn_process \ - kernel_thread_exit \ - kernel_validate_task \ - kernel_wait_child_poll; do + # WHY: this fixture validates relocation, not an independent adapter + # protocol. Reading the generated ABI evidence prevents every required + # export change from creating a second hand-maintained manifest here. + while IFS= read -r export_name; do printf ' (export "%s" (func $entry))\n' "$export_name" - done + done <<<"$required_exports" printf ' (global (export "%s") i32 (i32.const 1)))\n' "$marker" } >"$work/kernel-$marker.wat" wat2wasm "$work/kernel-$marker.wat" -o "$output" @@ -381,16 +361,30 @@ mkdir -p \ "$composed_repo/examples" \ "$composed_repo/benchmarks/wasm" \ "$composed_repo/target/$HOST_TARGET/release" -cp "$REPO_ROOT/scripts/pack-ci-test-workspace.sh" "$composed_repo/scripts/" +for packer_support in \ + pack-ci-test-workspace.sh \ + browser-memory64-example-fixtures.sh \ + browser-memory64-example-fixtures.txt; do + cp "$REPO_ROOT/scripts/$packer_support" "$composed_repo/scripts/" +done : >"$composed_repo/host/wasm/rootfs.vfs" for required in \ gencat.wasm \ pthread_channel_reuse_test.wasm \ - wait_lifecycle_test.wasm \ - wait_lifecycle_test.wasm64.wasm \ - terminal_attributes_api_test.wasm64.wasm; do + wait_lifecycle_test.wasm; do : >"$composed_repo/examples/$required" done +memory64_sources="$( + BROWSER_MEMORY64_FIXTURES_REPO_ROOT="$REPO_ROOT" + BROWSER_MEMORY64_FIXTURES_MANIFEST="$REPO_ROOT/scripts/browser-memory64-example-fixtures.txt" + # shellcheck source=/dev/null + source "$REPO_ROOT/scripts/browser-memory64-example-fixtures.sh" + browser_memory64_fixture_sources +)" || fail "could not read the browser memory64 fixture contract" +while IFS= read -r source; do + cp "$REPO_ROOT/$source" "$composed_repo/$source" + : >"$composed_repo/${source%.c}.wasm64.wasm" +done <<<"$memory64_sources" for required in \ pipe-throughput.wasm \ file-throughput.wasm \ diff --git a/tests/abi/process-native-layouts.c b/tests/abi/process-native-layouts.c index 11c0e381e9..1e55f7a34f 100644 --- a/tests/abi/process-native-layouts.c +++ b/tests/abi/process-native-layouts.c @@ -11,6 +11,7 @@ #include #include #include +#include #define ASSERT_OFFSET(type, field, expected) \ _Static_assert(offsetof(type, field) == (expected), #type "." #field) @@ -23,6 +24,17 @@ _Static_assert(SOL_SOCKET == KANDELO_SOCKET_SOL_SOCKET, "generated SOL_SOCKET value"); _Static_assert(SCM_RIGHTS == KANDELO_SOCKET_SCM_RIGHTS, "generated SCM_RIGHTS value"); +_Static_assert(sizeof(struct sockaddr_storage) == + KANDELO_SOCKADDR_STORAGE_BYTES, + "generated sockaddr_storage size"); +ASSERT_OFFSET(struct sockaddr_storage, ss_family, 0); +_Static_assert(sizeof(struct sockaddr_un) == KANDELO_SOCKADDR_UNIX_BYTES, + "generated sockaddr_un size"); +ASSERT_OFFSET(struct sockaddr_un, sun_path, + KANDELO_SOCKADDR_UNIX_PATH_OFFSET_BYTES); +_Static_assert(sizeof(((struct sockaddr_un *)0)->sun_path) == + KANDELO_SOCKADDR_UNIX_PATH_BYTES, + "generated sockaddr_un sun_path size"); _Static_assert(FD_SETSIZE == KANDELO_SELECT_FD_SETSIZE, "generated FD_SETSIZE"); _Static_assert(sizeof(fd_set) == KANDELO_SELECT_FD_SET_BYTES, diff --git a/tools/xtask/src/dump_abi.rs b/tools/xtask/src/dump_abi.rs index 3b9f28454f..63b5a0b35a 100644 --- a/tools/xtask/src/dump_abi.rs +++ b/tools/xtask/src/dump_abi.rs @@ -87,6 +87,8 @@ pub fn run(args: Vec) -> Result<(), String> { let header = render_c_header(); let platform_limits_header = render_platform_limits_header(); let process_layouts_header = render_process_layouts_header(); + let channel_scalars_header = render_channel_scalars_header(); + let thread_syscalls_header = render_thread_syscalls_header(); let spawn_header = render_spawn_contract_header(); let ts_module = render_ts_module(); @@ -96,6 +98,10 @@ pub fn run(args: Vec) -> Result<(), String> { repo_root().join("libc/musl-overlay/include/bits/kandelo_limits.h"); let process_layouts_header_out = repo_root().join("libc/musl-overlay/include/bits/kandelo_process_layouts.h"); + let channel_scalars_header_out = + repo_root().join("libc/musl-overlay/include/bits/kandelo_channel_scalars.h"); + let thread_syscalls_header_out = + repo_root().join("libc/musl-overlay/include/bits/kandelo_thread_syscalls.h"); let spawn_header_out = repo_root().join("libc/musl-overlay/src/process/wasm32posix/spawn_contract.h"); let ts_out = repo_root().join("host/src/generated/abi.ts"); @@ -113,6 +119,16 @@ pub fn run(args: Vec) -> Result<(), String> { &process_layouts_header, "musl Kandelo process layouts header", )?; + check_file( + &channel_scalars_header_out, + &channel_scalars_header, + "musl Kandelo channel scalars header", + )?; + check_file( + &thread_syscalls_header_out, + &thread_syscalls_header, + "musl Kandelo thread syscall header", + )?; check_file(&spawn_header_out, &spawn_header, "musl spawn_contract.h")?; check_file(&ts_out, &ts_module, "host/src/generated/abi.ts")?; println!("abi snapshot up-to-date: {}", out.display()); @@ -125,6 +141,14 @@ pub fn run(args: Vec) -> Result<(), String> { "process layouts header up-to-date: {}", process_layouts_header_out.display(), ); + println!( + "channel scalars header up-to-date: {}", + channel_scalars_header_out.display(), + ); + println!( + "thread syscall header up-to-date: {}", + thread_syscalls_header_out.display(), + ); println!( "spawn contract header up-to-date: {}", spawn_header_out.display(), @@ -141,6 +165,10 @@ pub fn run(args: Vec) -> Result<(), String> { println!("wrote {}", platform_limits_header_out.display()); write_file(&process_layouts_header_out, &process_layouts_header)?; println!("wrote {}", process_layouts_header_out.display()); + write_file(&channel_scalars_header_out, &channel_scalars_header)?; + println!("wrote {}", channel_scalars_header_out.display()); + write_file(&thread_syscalls_header_out, &thread_syscalls_header)?; + println!("wrote {}", thread_syscalls_header_out.display()); write_file(&spawn_header_out, &spawn_header)?; println!("wrote {}", spawn_header_out.display()); write_file(&ts_out, &ts_module)?; @@ -148,6 +176,22 @@ pub fn run(args: Vec) -> Result<(), String> { Ok(()) } +fn render_thread_syscalls_header() -> String { + use shared::abi::extended_syscalls as syscall_numbers; + + format!( + "/* GENERATED by `cargo xtask dump-abi`. Do not edit by hand. */\n\ + /* Regenerated by scripts/check-abi-version.sh; drift is a CI failure. */\n\ + #ifndef KANDELO_THREAD_SYSCALLS_H\n\ + #define KANDELO_THREAD_SYSCALLS_H\n\ + \n\ + #define KANDELO_SYS_THREAD_CANCEL {thread_cancel}u\n\ + \n\ + #endif /* KANDELO_THREAD_SYSCALLS_H */\n", + thread_cancel = syscall_numbers::SYS_THREAD_CANCEL, + ) +} + fn render_platform_limits_header() -> String { use shared::platform_limits; @@ -160,14 +204,46 @@ fn render_platform_limits_header() -> String { #define KANDELO_POSIX_ARG_MAX_BYTES {arg_max}u\n\ #define KANDELO_POSIX_PATH_MAX_BYTES {path_max}u\n\ #define KANDELO_POSIX_IOV_MAX {iov_max}u\n\ + #define KANDELO_PROCESS_METADATA_ENTRY_MAX_BYTES {metadata_entry_max}u\n\ + #define KANDELO_MAX_REPORTABLE_TRANSFER_BYTES {reportable_max}u\n\ \n\ #endif /* KANDELO_PLATFORM_LIMITS_H */\n", arg_max = platform_limits::ARG_MAX_BYTES, path_max = platform_limits::PATH_MAX_BYTES, iov_max = platform_limits::IOV_MAX, + metadata_entry_max = platform_limits::PROCESS_METADATA_ENTRY_MAX_BYTES, + reportable_max = platform_limits::MAX_REPORTABLE_TRANSFER_BYTES, ) } +fn render_channel_scalars_header() -> String { + let mut out = String::from( + "/* GENERATED by `cargo xtask dump-abi`. Do not edit by hand. */\n\ + #ifndef KANDELO_CHANNEL_SCALARS_H\n\ + #define KANDELO_CHANNEL_SCALARS_H\n\n\ + #include \n\n\ + /* WHY: the shared scalar table is authoritative, but musl still owns\n\ + * the public target syscall-number headers. Compile both together so\n\ + * a renumbering cannot silently reinterpret an i64 channel slot. */\n", + ); + for contract in shared::channel_scalar::SYSCALLS { + out.push_str(&format!( + "#ifndef __NR_{}\n\ + #error \"musl is missing __NR_{} required by the Kandelo channel scalar contract\"\n\ + #endif\n\ + _Static_assert(__NR_{} == {}u,\n\ + \"musl __NR_{} drifted from the Kandelo channel scalar contract\");\n", + contract.musl_name, + contract.musl_name, + contract.musl_name, + contract.syscall_number, + contract.musl_name, + )); + } + out.push_str("\n#endif\n"); + out +} + fn render_process_layouts_header() -> String { use shared::process_layout::{cmsghdr, iovec, msghdr, rt_sigqueueinfo, sigevent}; @@ -245,6 +321,10 @@ fn render_process_layouts_header() -> String { #define KANDELO_SOCKET_SCM_RIGHTS {scm_rights}u\n\ #define KANDELO_SOCKET_MSG_TRUNC {msg_trunc}u\n\ #define KANDELO_SCM_RIGHTS_FD_BYTES {scm_rights_fd_bytes}u\n\ + #define KANDELO_SOCKADDR_STORAGE_BYTES {sockaddr_storage_bytes}u\n\ + #define KANDELO_SOCKADDR_UNIX_BYTES {sockaddr_unix_bytes}u\n\ + #define KANDELO_SOCKADDR_UNIX_PATH_OFFSET_BYTES {sockaddr_unix_path_offset_bytes}u\n\ + #define KANDELO_SOCKADDR_UNIX_PATH_BYTES {sockaddr_unix_path_bytes}u\n\ \n\ #define KANDELO_KERNEL_POLLFD_SIZE {pollfd_size}u\n\ #define KANDELO_KERNEL_POLLFD_FD_OFFSET {pollfd_fd}u\n\ @@ -318,6 +398,11 @@ fn render_process_layouts_header() -> String { scm_rights = shared::socket::SCM_RIGHTS, msg_trunc = shared::socket::MSG_TRUNC, scm_rights_fd_bytes = shared::socket::SCM_RIGHTS_FD_BYTES, + sockaddr_storage_bytes = shared::kernel_scratch_wire::SOCKADDR_STORAGE_BYTES, + sockaddr_unix_bytes = shared::kernel_scratch_wire::SOCKADDR_UNIX_BYTES, + sockaddr_unix_path_offset_bytes = + shared::kernel_scratch_wire::SOCKADDR_UNIX_PATH_OFFSET_BYTES, + sockaddr_unix_path_bytes = shared::kernel_scratch_wire::SOCKADDR_UNIX_PATH_BYTES, pollfd_size = size_of::(), pollfd_fd = offset_of!(shared::WasmPollFd, fd), pollfd_events = offset_of!(shared::WasmPollFd, events), @@ -566,6 +651,9 @@ fn render_c_channel_contract() -> String { #define WASM_POSIX_CHANNEL_REQUEST_FLAGS_OFFSET {request_flags_offset}u\n\ #define WASM_POSIX_CHANNEL_REQUEST_FLAGS_SIZE {request_flags_size}u\n\ #define WASM_POSIX_CHANNEL_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY {defer_signal_delivery}u\n\ + #define WASM_POSIX_CHANNEL_REQUEST_FLAG_CANCELLATION_POINT {request_flag_cancellation_point}u\n\ + #define WASM_POSIX_CHANNEL_REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED {request_flag_cancellation_wake_allowed}u\n\ + #define WASM_POSIX_CHANNEL_REQUEST_FLAGS_KNOWN_MASK {request_flags_known_mask}u\n\ #define WASM_POSIX_CHANNEL_DATA_OFFSET {data_offset}u\n\ #define WASM_POSIX_CHANNEL_DATA_SIZE {data_size}u\n\ #define WASM_POSIX_CHANNEL_HEADER_SIZE {header_size}u\n\ @@ -609,6 +697,9 @@ fn render_c_channel_contract() -> String { request_flags_offset = channel::REQUEST_FLAGS_OFFSET, request_flags_size = channel::REQUEST_FLAGS_SIZE, defer_signal_delivery = channel::REQUEST_FLAG_DEFER_SIGNAL_DELIVERY, + request_flag_cancellation_point = channel::REQUEST_FLAG_CANCELLATION_POINT, + request_flag_cancellation_wake_allowed = channel::REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED, + request_flags_known_mask = channel::REQUEST_FLAGS_KNOWN_MASK, data_offset = channel::DATA_OFFSET, data_size = channel::DATA_SIZE, header_size = channel::HEADER_SIZE, @@ -1741,6 +1832,30 @@ fn render_ts_module() -> String { "export const KERNEL_SCRATCH_SOCKLEN_BYTES = {} as const;\n", shared::kernel_scratch_wire::SOCKLEN_BYTES )); + out.push_str(&format!( + "export const KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES = {} as const;\n", + shared::kernel_scratch_wire::SOCKADDR_STORAGE_BYTES + )); + out.push_str(&format!( + "export const KERNEL_SCRATCH_SOCKADDR_UNIX_BYTES = {} as const;\n", + shared::kernel_scratch_wire::SOCKADDR_UNIX_BYTES + )); + out.push_str(&format!( + "export const KERNEL_SCRATCH_SOCKADDR_UNIX_PATH_OFFSET_BYTES = {} as const;\n", + shared::kernel_scratch_wire::SOCKADDR_UNIX_PATH_OFFSET_BYTES + )); + out.push_str(&format!( + "export const KERNEL_SCRATCH_SOCKADDR_UNIX_PATH_BYTES = {} as const;\n", + shared::kernel_scratch_wire::SOCKADDR_UNIX_PATH_BYTES + )); + out.push_str(&format!( + "export const KERNEL_SCRATCH_SOCKET_OPTION_MAX_BYTES = {} as const;\n", + shared::kernel_scratch_wire::SOCKET_OPTION_MAX_BYTES + )); + out.push_str(&format!( + "export const KERNEL_SCRATCH_SOCKET_OPTION_INPUT_MAX_BYTES = {} as const;\n", + shared::kernel_scratch_wire::SOCKET_OPTION_INPUT_MAX_BYTES + )); out.push_str(&format!( "export const PR_SET_NAME = {} as const;\n", shared::prctl::PR_SET_NAME @@ -1769,6 +1884,30 @@ fn render_ts_module() -> String { "export const POSIX_PATH_MAX_BYTES = {} as const;\n", shared::platform_limits::PATH_MAX_BYTES )); + out.push_str(&format!( + "export const POSIX_NAME_MAX_BYTES = {} as const;\n", + shared::platform_limits::NAME_MAX_BYTES + )); + out.push_str(&format!( + "export const PROCESS_METADATA_ENTRY_MAX_BYTES = {} as const;\n", + shared::platform_limits::PROCESS_METADATA_ENTRY_MAX_BYTES + )); + out.push_str(&format!( + "export const POSIX_NGROUPS_MAX = {} as const;\n", + shared::platform_limits::NGROUPS_MAX + )); + out.push_str(&format!( + "export const SYSV_MSG_MAX_BYTES = {} as const;\n", + shared::platform_limits::SYSV_MSG_MAX_BYTES + )); + out.push_str(&format!( + "export const MAX_REPORTABLE_TRANSFER_BYTES = {} as const;\n", + shared::platform_limits::MAX_REPORTABLE_TRANSFER_BYTES + )); + out.push_str(&format!( + "export const MAX_TRANSFER_ALLOCATION_BYTES = {} as const;\n", + shared::platform_limits::MAX_TRANSFER_ALLOCATION_BYTES + )); out.push_str(&format!( "export const POSIX_IOV_MAX = {} as const;\n", shared::platform_limits::IOV_MAX @@ -2242,6 +2381,18 @@ fn render_ts_module() -> String { "export const CH_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY = {} as const;\n", channel::REQUEST_FLAG_DEFER_SIGNAL_DELIVERY )); + out.push_str(&format!( + "export const CHANNEL_REQUEST_FLAG_CANCELLATION_POINT = {} as const;\n", + channel::REQUEST_FLAG_CANCELLATION_POINT + )); + out.push_str(&format!( + "export const CHANNEL_REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED = {} as const;\n", + channel::REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED + )); + out.push_str(&format!( + "export const CHANNEL_REQUEST_FLAGS_KNOWN_MASK = {} as const;\n", + channel::REQUEST_FLAGS_KNOWN_MASK + )); out.push_str(&format!( "export const CH_DATA = {} as const;\n", channel::DATA_OFFSET @@ -2388,6 +2539,10 @@ fn render_ts_module() -> String { "export const CH_SIG_ALT_SIZE = {} as const;\n\n", channel::SIG_ALT_SIZE )); + out.push_str(&format!( + "export const SIGNAL_ACTION_RESTART = {} as const;\n\n", + shared::signal::SA_RESTART + )); out.push_str(&format!( "export const WAIT_EVENT_EXITED = {} as const;\n", @@ -2638,6 +2793,56 @@ fn render_ts_module() -> String { } out.push_str("} as const;\n\n"); + out.push_str( + "export type ChannelScalarSlotKind =\n\ + \x20 | \"i32\"\n\ + \x20 | \"u32\"\n\ + \x20 | \"exact-u32\"\n\ + \x20 | \"process-size\"\n\ + \x20 | \"process-address\"\n\ + \x20 | \"i64\"\n\ + \x20 | \"split-i64-low-u32\"\n\ + \x20 | \"split-i64-high-i32\";\n\ + export type ChannelResultKind = \"i32\" | \"i64\" | \"process-address\";\n\ + export type ChannelArgumentIndex = 0 | 1 | 2 | 3 | 4 | 5;\n\n\ + export const CHANNEL_SCALAR_DEFAULT_SLOT_KIND = \"i32\" as const;\n\ + export const CHANNEL_RESULT_DEFAULT_KIND = \"i32\" as const;\n\ + export const CHANNEL_SCALAR_SLOT_CONTRACTS: Readonly<\n\ + \x20 Record>>>\n\ + > = {\n", + ); + for contract in shared::channel_scalar::SYSCALLS { + if contract.arguments.is_empty() { + continue; + } + out.push_str(&format!(" {}: {{", contract.syscall_number)); + for argument in contract.arguments { + out.push_str(&format!( + " {}: {:?},", + argument.index, + argument.kind.abi_name(), + )); + } + out.push_str(" },\n"); + } + out.push_str("} as const;\n"); + out.push_str( + "export const CHANNEL_RESULT_CONTRACTS: Readonly<\n\ + \x20 Partial>\n\ + > = {\n", + ); + for contract in shared::channel_scalar::SYSCALLS { + if contract.result == shared::channel_scalar::ChannelResultKind::I32 { + continue; + } + out.push_str(&format!( + " {}: {:?},\n", + contract.syscall_number, + contract.result.abi_name(), + )); + } + out.push_str("} as const;\n\n"); + out.push_str("export const PATHCONF_NAMES = {\n"); for (name, number) in shared::pathconf::ABI_NAMES { out.push_str(&format!(" {name}: {number},\n")); @@ -2652,7 +2857,7 @@ fn render_ts_module() -> String { out.push_str("export type SyscallArgDirection = \"in\" | \"out\" | \"inout\";\n\n"); out.push_str("export type SyscallArgSizeSpec =\n"); - out.push_str(" | { type: \"cstring\" }\n"); + out.push_str(" | { type: \"cstring\"; maxBytes: number; tooLongErrno: number }\n"); out.push_str(" | { type: \"arg\"; argIndex: number; multiplier?: number; add?: number }\n"); out.push_str(" | { type: \"deref\"; argIndex: number }\n"); out.push_str(" | { type: \"fixed\"; size: number }\n"); @@ -2746,7 +2951,14 @@ fn ts_syscall_arg_size(size: shared::host_abi::SyscallArgSize) -> String { use shared::host_abi::SyscallArgSize; match size { - SyscallArgSize::CString => "{ type: \"cstring\" }".into(), + SyscallArgSize::CString { + max_bytes, + too_long_errno, + } => { + format!( + "{{ type: \"cstring\", maxBytes: {max_bytes}, tooLongErrno: {too_long_errno} }}" + ) + } SyscallArgSize::Arg { arg_index, multiplier, @@ -2931,6 +3143,7 @@ fn build_snapshot(kernel_wasm: &std::path::Path) -> Result { root.insert("channel_request_flags".into(), channel_request_flags()); root.insert("channel_signal_area".into(), channel_signal_area()); root.insert("channel_buffers".into(), channel_buffers()); + root.insert("channel_scalar_contract".into(), channel_scalar_contract()); root.insert("marshalled_structs".into(), marshalled_structs()); root.insert("syscalls".into(), syscalls()); @@ -2968,7 +3181,43 @@ fn platform_limits() -> Value { "fd_set_bytes": shared::select::FD_SET_BYTES, "fd_setsize": shared::select::FD_SETSIZE, "iov_max": shared::platform_limits::IOV_MAX, + "max_reportable_transfer_bytes": + shared::platform_limits::MAX_REPORTABLE_TRANSFER_BYTES, + "max_transfer_allocation_bytes": + shared::platform_limits::MAX_TRANSFER_ALLOCATION_BYTES, + "ngroups_max": shared::platform_limits::NGROUPS_MAX, "path_max_bytes": shared::platform_limits::PATH_MAX_BYTES, + "sysv_msg_max_bytes": shared::platform_limits::SYSV_MSG_MAX_BYTES, + }) +} + +fn channel_scalar_contract() -> Value { + let syscalls: Vec = shared::channel_scalar::SYSCALLS + .iter() + .map(|contract| { + let arguments: Vec = contract + .arguments + .iter() + .map(|argument| { + json!({ + "index": argument.index, + "kind": argument.kind.abi_name(), + }) + }) + .collect(); + json!({ + "arguments": arguments, + "musl_name": contract.musl_name, + "number": contract.syscall_number, + "result": contract.result.abi_name(), + }) + }) + .collect(); + + json!({ + "default_argument_kind": shared::channel_scalar::ChannelScalarKind::I32.abi_name(), + "default_result_kind": shared::channel_scalar::ChannelResultKind::I32.abi_name(), + "syscalls": syscalls, }) } @@ -3191,6 +3440,14 @@ fn channel_header() -> Value { let mut m: JsonMap = BTreeMap::new(); m.insert("size".into(), json!(HEADER_SIZE)); m.insert("fields".into(), Value::Array(fields_json)); + m.insert( + "request_flags".into(), + json!({ + "cancellation_point": REQUEST_FLAG_CANCELLATION_POINT, + "cancellation_wake_allowed": REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED, + "known_mask": REQUEST_FLAGS_KNOWN_MASK, + }), + ); Value::Object(m.into_iter().collect()) } @@ -4194,8 +4451,13 @@ fn syscall_arg_size_json(size: shared::host_abi::SyscallArgSize) -> Value { let mut m: JsonMap = BTreeMap::new(); match size { - SyscallArgSize::CString => { + SyscallArgSize::CString { + max_bytes, + too_long_errno, + } => { m.insert("type".into(), json!("cstring")); + m.insert("maxBytes".into(), json!(max_bytes)); + m.insert("tooLongErrno".into(), json!(too_long_errno)); } SyscallArgSize::Arg { arg_index, @@ -5745,6 +6007,18 @@ mod tests { assert!(rendered.contains("export const PRCTL_NAME_BYTES = 16 as const;")); assert!(rendered.contains("export const FCNTL_FLOCK_BYTES = 32 as const;")); assert!(rendered.contains("export const SIGNAL_MASK_BYTES = 8 as const;")); + assert!(rendered.contains( + "export const KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES = 128 as const;" + )); + assert!(rendered.contains( + "export const KERNEL_SCRATCH_SOCKADDR_UNIX_BYTES = 110 as const;" + )); + assert!(rendered.contains( + "export const KERNEL_SCRATCH_SOCKADDR_UNIX_PATH_OFFSET_BYTES = 2 as const;" + )); + assert!(rendered.contains( + "export const KERNEL_SCRATCH_SOCKADDR_UNIX_PATH_BYTES = 108 as const;" + )); assert!(rendered.contains("export const SELECT_FD_SETSIZE = 1024 as const;")); assert!(rendered.contains("export const SELECT_FD_SET_BYTES = 128 as const;")); assert!(rendered.contains("export const PROCESS_IOVEC_WASM32_SIZE = 8 as const;")); @@ -5775,6 +6049,41 @@ mod tests { assert_eq!(names.as_object().unwrap().len(), 24); } + #[test] + fn generated_channel_scalar_consumers_share_one_contract() { + let typescript = render_ts_module(); + for expected in [ + "export const CHANNEL_SCALAR_DEFAULT_SLOT_KIND = \"i32\" as const;", + "export const CHANNEL_RESULT_DEFAULT_KIND = \"i32\" as const;", + " 5: { 1: \"split-i64-low-u32\", 2: \"split-i64-high-i32\", },", + " 64: { 2: \"process-size\", 3: \"i64\", },", + " 295: { 3: \"split-i64-low-u32\", 4: \"split-i64-high-i32\", },", + " 5: \"i64\",", + " 46: \"process-address\",", + ] { + assert!( + typescript.contains(expected), + "missing generated TypeScript scalar contract: {expected}", + ); + } + + let header = render_channel_scalars_header(); + for contract in shared::channel_scalar::SYSCALLS { + assert!(header.contains(&format!( + "_Static_assert(__NR_{} == {}u,", + contract.musl_name, contract.syscall_number, + ))); + } + + let snapshot = channel_scalar_contract(); + assert_eq!(snapshot["default_argument_kind"], json!("i32")); + assert_eq!(snapshot["default_result_kind"], json!("i32")); + assert_eq!( + snapshot["syscalls"].as_array().unwrap().len(), + shared::channel_scalar::SYSCALLS.len(), + ); + } + #[test] fn generated_channel_contract_covers_status_layout_and_signal_wire() { let header = render_c_header(); @@ -5792,7 +6101,10 @@ mod tests { "#define WASM_POSIX_CHANNEL_ERRNO_OFFSET 64u", "#define WASM_POSIX_CHANNEL_REQUEST_FLAGS_OFFSET 68u", "#define WASM_POSIX_CHANNEL_REQUEST_FLAGS_SIZE 4u", - "#define WASM_POSIX_CHANNEL_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY 1u", + "#define WASM_POSIX_CHANNEL_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY 4u", + "#define WASM_POSIX_CHANNEL_REQUEST_FLAG_CANCELLATION_POINT 1u", + "#define WASM_POSIX_CHANNEL_REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED 2u", + "#define WASM_POSIX_CHANNEL_REQUEST_FLAGS_KNOWN_MASK 7u", "#define WASM_POSIX_CHANNEL_DATA_OFFSET 72u", "#define WASM_POSIX_CHANNEL_DATA_SIZE 65536u", "#define WASM_POSIX_CHANNEL_HEADER_SIZE 72u", @@ -5821,6 +6133,11 @@ mod tests { let typescript = render_ts_module(); for expected in [ + "export const CH_REQUEST_FLAGS = 68 as const;", + "export const CH_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY = 4 as const;", + "export const CHANNEL_REQUEST_FLAG_CANCELLATION_POINT = 1 as const;", + "export const CHANNEL_REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED = 2 as const;", + "export const CHANNEL_REQUEST_FLAGS_KNOWN_MASK = 7 as const;", "export const CH_SIG_AREA_SIZE = 56 as const;", "export const CH_SIG_DELIVERY_SIZE = 56 as const;", "export const CH_SIG_SI_VALUE = 65564 as const;", @@ -5873,7 +6190,13 @@ mod tests { "fd_set_bytes": shared::select::FD_SET_BYTES, "fd_setsize": shared::select::FD_SETSIZE, "iov_max": shared::platform_limits::IOV_MAX, + "max_reportable_transfer_bytes": + shared::platform_limits::MAX_REPORTABLE_TRANSFER_BYTES, + "max_transfer_allocation_bytes": + shared::platform_limits::MAX_TRANSFER_ALLOCATION_BYTES, + "ngroups_max": shared::platform_limits::NGROUPS_MAX, "path_max_bytes": shared::platform_limits::PATH_MAX_BYTES, + "sysv_msg_max_bytes": shared::platform_limits::SYSV_MSG_MAX_BYTES, }), ); @@ -6009,6 +6332,10 @@ mod tests { assert!(header.contains("#define KANDELO_PROCESS_SIGEVENT_WASM32_SIZE 64u")); assert!(header.contains("#define KANDELO_PROCESS_SIGEVENT_WASM64_VALUE_SIZE 8u")); assert!(header.contains("#define KANDELO_SOCKET_MSG_TRUNC 32u")); + assert!(header.contains("#define KANDELO_SOCKADDR_STORAGE_BYTES 128u")); + assert!(header.contains("#define KANDELO_SOCKADDR_UNIX_BYTES 110u")); + assert!(header.contains("#define KANDELO_SOCKADDR_UNIX_PATH_OFFSET_BYTES 2u")); + assert!(header.contains("#define KANDELO_SOCKADDR_UNIX_PATH_BYTES 108u")); assert!(header.contains("#define KANDELO_SELECT_FD_SET_BYTES 128u")); let structs = marshalled_structs(); diff --git a/web-libs/kandelo-session/src/kernel-host.ts b/web-libs/kandelo-session/src/kernel-host.ts index 25959ff825..221fd9be5a 100644 --- a/web-libs/kandelo-session/src/kernel-host.ts +++ b/web-libs/kandelo-session/src/kernel-host.ts @@ -80,7 +80,14 @@ export interface KernelSyscallEvent { t: number; pid: number; nr: number; - args: [number, number, number, number, number, number]; + args: [ + number | bigint, + number | bigint, + number | bigint, + number | bigint, + number | bigint, + number | bigint, + ]; } export type LazyDownloadKind = "file" | "tree" | "archive"; @@ -1584,7 +1591,9 @@ export class LiveKernelHost implements KernelHost { t: `+${((raw.t - t0) / 1000).toFixed(6)}`, pid: raw.pid, call: name, - args: raw.args.filter((a) => a !== 0).join(", ") || "—", + args: raw.args + .filter((a) => a !== 0 && a !== 0n) + .join(", ") || "—", // Return value isn't available at trace-emit time (we only see // the entry, not the completion). v0 leaves this blank; future // work can pair entry/return events. From 73924842c3bdaa7387a648401e125dc44b32b0f1 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Mon, 27 Jul 2026 17:47:48 -0400 Subject: [PATCH 08/82] Host: Publish and validate kernel scratch atomically Publish each kernel scratch region and its generated contract as one atomic ABI state. Fail closed when the host observes a missing, stale, or partially initialized region rather than continuing with mixed metadata. Refresh the ABI projection and exercise publication, reusable kernel stacks, and fail-stop validation in the shared Node and browser paths. --- abi/snapshot.json | 124 +- .../pages/network/network-demo-worker.ts | 6 + .../reusable-kernel-export-stack-worker.ts | 35 +- crates/kernel/src/channel_scratch.rs | 1 + crates/kernel/src/complete_copy.rs | 121 + crates/kernel/src/lib.rs | 2 + crates/kernel/src/process.rs | 332 ++- crates/kernel/src/process_snapshot_wire.rs | 227 ++ crates/kernel/src/spawn.rs | 141 +- crates/kernel/src/syscalls.rs | 1204 ++++++++- crates/kernel/src/wasm_api.rs | 358 +-- crates/shared/src/host_abi.rs | 87 +- crates/shared/src/lib.rs | 56 +- crates/shared/src/process_layout.rs | 18 + docs/abi-versioning.md | 127 +- docs/architecture.md | 154 +- ...026-07-25-kernel-scratch-transfer-audit.md | 28 +- docs/posix-status.md | 6 +- host/src/generated/abi.ts | 43 +- host/src/kernel-scratch.ts | 19 +- host/src/kernel-worker.ts | 1109 ++++++--- host/src/kernel.ts | 26 +- host/src/worker-main.ts | 171 +- host/test/exec-state-tracking.test.ts | 195 +- host/test/fixtures/startup-crt-contract.c | 195 ++ host/test/fixtures/startup-crt-include/libc.h | 6 + host/test/generated-abi.test.ts | 76 + host/test/host-adapter-manifest.test.ts | 9 +- host/test/kernel-authority-boundary.test.ts | 1 + host/test/kernel-export-failure-audit.test.ts | 16 + .../kernel-process-registration-entry.test.ts | 288 ++- host/test/kernel-public-entry-roots.test.ts | 143 +- host/test/kernel-scratch-contract.test.ts | 76 +- ...kernel-scratch-transfer-boundaries.test.ts | 2162 +++++++++++++++-- host/test/readdir-atomicity.test.ts | 25 + .../test/reusable-kernel-export-stack.test.ts | 34 +- host/test/spawn-pid-authority.test.ts | 94 + host/test/startup-crt-contract.test.ts | 40 + host/test/startup-metadata-capacity.test.ts | 302 +++ host/test/support/kernel-scratch-instance.ts | 28 +- host/test/support/wasm-memory-write-audit.ts | 8 +- host/test/wasm-memory-write-audit.test.ts | 7 + libc/glue/syscall_imports.h | 2 +- libc/musl-overlay/arch/wasm32posix/crt_arch.h | 125 +- libc/musl-overlay/arch/wasm64posix/crt_arch.h | 88 +- libc/musl-overlay/crt/crt1.c | 150 ++ .../include/bits/kandelo_limits.h | 2 + .../include/bits/kandelo_process_layouts.h | 9 + packages/registry/kernel/build-kernel.sh | 9 +- packages/registry/program-packages.json | 690 +++--- run.sh | 9 +- scripts/resolve-binary.bundle.mjs | 6 +- tests/abi/process-native-layouts.c | 21 + tools/xtask/src/dump_abi.rs | 347 ++- 54 files changed, 7898 insertions(+), 1660 deletions(-) create mode 100644 crates/kernel/src/complete_copy.rs create mode 100644 crates/kernel/src/process_snapshot_wire.rs create mode 100644 host/test/fixtures/startup-crt-contract.c create mode 100644 host/test/fixtures/startup-crt-include/libc.h create mode 100644 host/test/startup-crt-contract.test.ts create mode 100644 host/test/startup-metadata-capacity.test.ts diff --git a/abi/snapshot.json b/abi/snapshot.json index b695caf034..628a1ffb10 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -1046,7 +1046,6 @@ "kernel_alloc_scratch", "kernel_blocking_retry_release", "kernel_blocking_retry_token", - "kernel_clear_process_metadata", "kernel_commit_process_exit", "kernel_create_process", "kernel_create_process_with_stdio", @@ -1054,6 +1053,9 @@ "kernel_exec_prepare", "kernel_exec_setup_for_thread", "kernel_fork_process", + "kernel_get_cwd", + "kernel_get_dirfd_path", + "kernel_get_fd_path", "kernel_get_parent_pid", "kernel_get_process_exit_signal", "kernel_get_process_state", @@ -1073,7 +1075,10 @@ "kernel_pick_signal_target_tid", "kernel_pipe_has_readers", "kernel_posix_timer_fire", - "kernel_push_process_metadata_entry", + "kernel_process_metadata_begin", + "kernel_process_metadata_cancel", + "kernel_process_metadata_commit", + "kernel_process_metadata_stage", "kernel_reap_exited_child", "kernel_remove_process", "kernel_semctl_array_bytes", @@ -1643,11 +1648,6 @@ "name": "kernel_clear_fork_exec", "signature": "() -> (i32)" }, - { - "kind": "func", - "name": "kernel_clear_process_metadata", - "signature": "(i32,i32) -> (i32)" - }, { "kind": "func", "name": "kernel_clock_getres", @@ -1908,6 +1908,11 @@ "name": "kernel_get_cwd", "signature": "(i32,i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_get_dirfd_path", + "signature": "(i32,i32,i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_get_exit_status", @@ -2453,6 +2458,26 @@ "name": "kernel_prctl", "signature": "(i32,i32,i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_process_metadata_begin", + "signature": "(i32) -> (i32)" + }, + { + "kind": "func", + "name": "kernel_process_metadata_cancel", + "signature": "(i32,i32) -> (i32)" + }, + { + "kind": "func", + "name": "kernel_process_metadata_commit", + "signature": "(i32,i32) -> (i32)" + }, + { + "kind": "func", + "name": "kernel_process_metadata_stage", + "signature": "(i32,i32,i32,i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_pselect6", @@ -2483,11 +2508,6 @@ "name": "kernel_push_argv", "signature": "(i32,i32) -> ()" }, - { - "kind": "func", - "name": "kernel_push_process_metadata_entry", - "signature": "(i32,i32,i32,i32) -> (i32)" - }, { "kind": "func", "name": "kernel_raise", @@ -2668,11 +2688,6 @@ "name": "kernel_set_mmap_base", "signature": "(i32,i32) -> (i32)" }, - { - "kind": "func", - "name": "kernel_set_process_argv", - "signature": "(i32,i32,i32) -> (i32)" - }, { "kind": "func", "name": "kernel_set_process_credentials", @@ -4595,6 +4610,8 @@ "max_transfer_allocation_bytes": 4294967295, "ngroups_max": 32, "path_max_bytes": 4096, + "process_startup_max_argv_count": 4096, + "process_startup_max_envp_count": 4096, "sysv_msg_max_bytes": 8192 }, "process_expected_globals": [ @@ -4665,6 +4682,10 @@ }, "wasm_page_size": 65536 }, + "process_metadata_contract": { + "kind_argv": 0, + "kind_environment": 1 + }, "process_native_layouts": { "cmsghdr": { "wasm32": { @@ -4721,6 +4742,20 @@ "size": 56 } }, + "multicast_group_request": { + "wasm32": { + "group_offset": 4, + "group_req_size": 132, + "group_source_req_size": 260, + "source_offset": 132 + }, + "wasm64": { + "group_offset": 8, + "group_req_size": 136, + "group_source_req_size": 264, + "source_offset": 136 + } + }, "scm_rights": { "fd_bytes": 4, "level": 1, @@ -4767,6 +4802,56 @@ "trunc": 32 } }, + "process_snapshot_wire": { + "count_offset": 0, + "count_size": 4, + "header": { + "fields": [ + { + "name": "pid", + "offset": 0, + "span": 4 + }, + { + "name": "ppid", + "offset": 4, + "span": 4 + }, + { + "name": "uid", + "offset": 8, + "span": 4 + }, + { + "name": "gid", + "offset": 12, + "span": 4 + }, + { + "name": "vsize", + "offset": 16, + "span": 8 + }, + { + "name": "state", + "offset": 24, + "span": 4 + }, + { + "name": "comm_len", + "offset": 28, + "span": 4 + }, + { + "name": "cmdline_len", + "offset": 32, + "span": 4 + } + ], + "size": 36 + }, + "records_offset": 4 + }, "program_artifact": { "fork_instrumentation": { "capabilities": { @@ -7282,6 +7367,11 @@ }, { "argIndex": 2, + "copyOutLength": { + "argIndex": 1, + "offset": 12, + "type": "u32-field" + }, "direction": "out", "required": true, "size": { diff --git a/apps/browser-demos/pages/network/network-demo-worker.ts b/apps/browser-demos/pages/network/network-demo-worker.ts index 51b179fb2c..50aeb12fcf 100644 --- a/apps/browser-demos/pages/network/network-demo-worker.ts +++ b/apps/browser-demos/pages/network/network-demo-worker.ts @@ -236,8 +236,13 @@ async function runProgram( new Uint8Array(memory.buffer, channelOffset, CH_TOTAL_SIZE).fill(0); pid = kernelWorker.createProcess(CAPTURED_STDIO); + // WHY: registration replaces argv and environment as one Rust-owned + // transaction, so this harness's intentionally empty environment must be + // explicit rather than inferred after argv has already been published. + const environment: string[] = []; kernelWorker.registerProcess(pid, memory, [channelOffset], { argv: options.argv, + env: environment, ptrWidth, }); const initialHeapBase = extractHeapBase(options.programBytes); @@ -253,6 +258,7 @@ async function runProgram( memory, channelOffset, argv: options.argv, + env: environment, ptrWidth, }; const mainWorker = workerAdapter.createWorker(initData); diff --git a/apps/browser-demos/test/fixtures/reusable-kernel-export-stack-worker.ts b/apps/browser-demos/test/fixtures/reusable-kernel-export-stack-worker.ts index b0525cabd8..9a5c287194 100644 --- a/apps/browser-demos/test/fixtures/reusable-kernel-export-stack-worker.ts +++ b/apps/browser-demos/test/fixtures/reusable-kernel-export-stack-worker.ts @@ -1,4 +1,4 @@ -import { WasmPosixKernel } from "../../../../host/src/kernel"; +import { createWasmPosixKernelTestHarness } from "../../../../host/src/kernel"; import { MemoryFileSystem } from "../../../../host/src/vfs/memory-fs"; import { BrowserTimeProvider } from "../../../../host/src/vfs/time"; import { VirtualPlatformIO } from "../../../../host/src/vfs/vfs"; @@ -75,26 +75,37 @@ async function runProbe({ } const rootfs = MemoryFileSystem.create(new SharedArrayBuffer(1024 * 1024)); - const kernel = new WasmPosixKernel( - { + const capture: { instance: WebAssembly.Instance | null } = { + instance: null, + }; + const kernel = createWasmPosixKernelTestHarness({ + config: { maxWorkers: 1, dataBufferSize: 65_536, useSharedMemory: true, }, - new VirtualPlatformIO( + io: new VirtualPlatformIO( [{ mountPoint: "/", backend: rootfs }], new BrowserTimeProvider(), ), - ); + engine: { + compile: (bytes) => WebAssembly.compile(bytes), + instantiate: async (module, imports) => { + const instance = await WebAssembly.instantiate(module, imports); + capture.instance = instance; + return instance; + }, + }, + }); await kernel.init(kernelBytes); - const internals = kernel as unknown as { - instance: WebAssembly.Instance | null; - }; - if (!internals.instance) { - throw new Error("kernel instance was not initialized"); - } - const exports = internals.instance.exports as ReusableKernelExports; + // WHY: this dedicated test Worker must call the real returning export to + // observe whether the Wasm epilogue restores its shadow stack. The + // module-secret harness engine captures the raw instance only for this + // regression; production still publishes only the gated kernel facade. + const instance = capture.instance; + if (instance === null) throw new Error("test engine did not instantiate"); + const exports = instance.exports as ReusableKernelExports; const baselineStackPointer = exports.kernel_get_stack_pointer(); for (let iteration = 0; iteration < iterations; iteration++) { diff --git a/crates/kernel/src/channel_scratch.rs b/crates/kernel/src/channel_scratch.rs index ff7a1ede08..7571f20bfc 100644 --- a/crates/kernel/src/channel_scratch.rs +++ b/crates/kernel/src/channel_scratch.rs @@ -1323,6 +1323,7 @@ mod tests { }, nullable: false, required: true, + copy_out_length: None, }; let mut exact = vec![b'a'; capacity]; *exact.last_mut().unwrap() = 0; diff --git a/crates/kernel/src/complete_copy.rs b/crates/kernel/src/complete_copy.rs new file mode 100644 index 0000000000..797b61f8ef --- /dev/null +++ b/crates/kernel/src/complete_copy.rs @@ -0,0 +1,121 @@ +//! Complete, capacity-aware copies for kernel-owned byte state. +//! +//! Canonical paths can legitimately exceed `PATH_MAX`: that limit bounds one +//! caller-supplied pathname, not the canonical path formed from an already +//! deep current working directory. A short destination must therefore remain +//! untouched and report `ERANGE`; silently publishing a prefix can name a +//! different executable or shared-mapping backing. The same primitive keeps +//! future variable byte exports from silently reviving prefix semantics. +//! +//! This module proves only complete-or-error byte-count behavior. The host +//! scratch lease must independently prove allocator ownership, capacity, +//! current-memory bounds, pointer width, lifetime, and reentrancy. + +use wasm_posix_shared::Errno; + +fn checked_complete_copy_length( + source_length: usize, + capacity: u32, +) -> Result { + i32::try_from(source_length).map_err(|_| Errno::EOVERFLOW)?; + if capacity > 0 && source_length > capacity as usize { + return Err(Errno::ERANGE); + } + Ok(source_length) +} + +/// Copy all bytes, query the required length, or fail without a partial copy. +/// +/// # Safety +/// +/// When `capacity` is positive and at least `source.len()`, `destination` +/// must name a writable allocation of at least `source.len()` bytes that does +/// not overlap `source`. Query and short-capacity calls do not dereference it. +pub(crate) unsafe fn copy_complete_bytes( + source: &[u8], + destination: *mut u8, + capacity: u32, +) -> i32 { + let required = match checked_complete_copy_length(source.len(), capacity) { + Ok(length) => length, + Err(error) => return -(error as i32), + }; + if capacity == 0 { + // A zero-capacity call is the required-length query. Its pointer is + // deliberately ignored so wasm32 and wasm64 hosts need no sentinel + // address outside an allocator-owned lease. + return required as i32; + } + if destination.is_null() { + return -(Errno::EFAULT as i32); + } + let output = unsafe { core::slice::from_raw_parts_mut(destination, source.len()) }; + output.copy_from_slice(source); + required as i32 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn short_destination_is_atomic_and_exact_capacity_retries() { + let source = b"/deep/canonical/path"; + let mut guarded = [0xa5; 24]; + + assert_eq!( + unsafe { + copy_complete_bytes(source, guarded.as_mut_ptr(), source.len() as u32 - 1) + }, + -(Errno::ERANGE as i32), + ); + assert!(guarded.iter().all(|byte| *byte == 0xa5)); + + assert_eq!( + unsafe { + copy_complete_bytes(source, guarded.as_mut_ptr(), source.len() as u32) + }, + source.len() as i32, + ); + assert_eq!(&guarded[..source.len()], source); + assert!(guarded[source.len()..].iter().all(|byte| *byte == 0xa5)); + + guarded.fill(0xa5); + assert_eq!( + unsafe { + copy_complete_bytes( + source, + guarded.as_mut_ptr(), + source.len() as u32 + 1, + ) + }, + source.len() as i32, + ); + assert_eq!(&guarded[..source.len()], source); + assert_eq!(guarded[source.len()], 0xa5); + } + + #[test] + fn zero_capacity_queries_without_dereferencing_and_positive_capacity_needs_pointer() { + let source = b"/query"; + assert_eq!( + unsafe { copy_complete_bytes(source, core::ptr::null_mut(), 0) }, + source.len() as i32, + ); + assert_eq!( + unsafe { + copy_complete_bytes(source, core::ptr::null_mut(), source.len() as u32) + }, + -(Errno::EFAULT as i32), + ); + } + + #[test] + fn unreportable_length_fails_without_allocating_the_source() { + let oversized = (i32::MAX as usize).checked_add(1).unwrap(); + assert_eq!( + checked_complete_copy_length(oversized, 0), + Err(Errno::EOVERFLOW), + ); + } +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 41a4c7277e..770bfbf29f 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -23,8 +23,10 @@ pub mod mouse; pub mod mqueue; pub mod ofd; pub mod path; +pub(crate) mod complete_copy; pub mod pipe; pub mod process; +pub(crate) mod process_snapshot_wire; pub mod process_table; pub(crate) mod process_wire; pub mod procfs; diff --git a/crates/kernel/src/process.rs b/crates/kernel/src/process.rs index 26ec4d5923..722c74aa7a 100644 --- a/crates/kernel/src/process.rs +++ b/crates/kernel/src/process.rs @@ -2,7 +2,10 @@ extern crate alloc; use alloc::vec::Vec; use core::ops::Deref; -use wasm_posix_shared::{Errno, KernelRusage, WasmStat, WasmStatfs}; +use wasm_posix_shared::{ + platform_limits, process_metadata_contract, Errno, KernelRusage, WasmStat, + WasmStatfs, +}; use crate::fd::FdTable; use crate::memory::MemoryManager; @@ -746,6 +749,11 @@ pub struct Process { pub terminal: TerminalState, pub environ: Vec>, pub argv: Vec>, + /// In-progress host replacement, invisible until one token-bound commit + /// swaps the complete argv/environment pair. + metadata_replacement: Option, + /// Positive transaction tokens are never reused for this Process. + next_metadata_replacement_token: u32, pub umask: u32, /// Scheduling priority nice value (-20 to 19, default 0). pub nice: i32, @@ -870,8 +878,72 @@ impl StdioConfig { } } -pub(crate) const PROCESS_METADATA_ARGV: u32 = 0; -pub(crate) const PROCESS_METADATA_ENVIRONMENT: u32 = 1; +struct ProcessMetadataReplacement { + token: u32, + argv: Vec>, + environment: Vec>, + failed: bool, +} + +impl ProcessMetadataReplacement { + fn entries_mut( + &mut self, + kind: u32, + ) -> Result<(&mut Vec>, usize), Errno> { + match kind { + process_metadata_contract::KIND_ARGV => Ok(( + &mut self.argv, + platform_limits::PROCESS_STARTUP_MAX_ARGV_COUNT, + )), + process_metadata_contract::KIND_ENVIRONMENT => Ok(( + &mut self.environment, + platform_limits::PROCESS_STARTUP_MAX_ENVP_COUNT, + )), + _ => Err(Errno::EINVAL), + } + } + + fn stage_entry_with( + &mut self, + kind: u32, + entry: &[u8], + allocate: F, + ) -> Result<(), Errno> + where + F: FnOnce(&[u8]) -> Result, Errno>, + { + if self.failed { + return Err(Errno::EINVAL); + } + + let result = (|| { + if entry.len() > platform_limits::PROCESS_METADATA_ENTRY_MAX_BYTES { + return Err(Errno::E2BIG); + } + let (entries, maximum_entries) = self.entries_mut(kind)?; + if entries.len() >= maximum_entries { + return Err(Errno::E2BIG); + } + + // Allocate the entry and the vector slot before publishing either + // one into the transaction. A failure therefore leaves the + // already-staged prefix intact for cancellation and cannot touch + // the live process metadata. + let owned = allocate(entry)?; + entries.try_reserve(1).map_err(|_| Errno::ENOMEM)?; + entries.push(owned); + Ok(()) + })(); + + // WHY: after any matching stage fails, committing the successfully + // staged prefix would recreate the partial-replacement bug even if a + // future host forgot its cancel path. + if result.is_err() { + self.failed = true; + } + result + } +} impl Process { /// Create a process for an identity allocated by `ProcessTable`. @@ -980,6 +1052,8 @@ impl Process { terminal, environ: Vec::new(), argv: Vec::new(), + metadata_replacement: None, + next_metadata_replacement_token: 1, umask: 0o022, nice: 0, rlimits, @@ -1744,29 +1818,87 @@ impl Process { out } - fn metadata_vector_mut(&mut self, kind: u32) -> Result<&mut Vec>, Errno> { - match kind { - PROCESS_METADATA_ARGV => Ok(&mut self.argv), - PROCESS_METADATA_ENVIRONMENT => Ok(&mut self.environ), - _ => Err(Errno::EINVAL), + pub(crate) fn begin_metadata_replacement(&mut self) -> Result { + if self.metadata_replacement.is_some() { + return Err(Errno::EBUSY); + } + + let token = self.next_metadata_replacement_token; + if token == 0 || token > i32::MAX as u32 { + return Err(Errno::EOVERFLOW); } + self.next_metadata_replacement_token = token + 1; + self.metadata_replacement = Some(ProcessMetadataReplacement { + token, + argv: Vec::new(), + environment: Vec::new(), + failed: false, + }); + Ok(token) } - pub(crate) fn clear_metadata(&mut self, kind: u32) -> Result<(), Errno> { - self.metadata_vector_mut(kind)?.clear(); + fn stage_metadata_entry_with( + &mut self, + token: u32, + kind: u32, + entry: &[u8], + allocate: F, + ) -> Result<(), Errno> + where + F: FnOnce(&[u8]) -> Result, Errno>, + { + let transaction = self + .metadata_replacement + .as_mut() + .filter(|transaction| transaction.token == token) + .ok_or(Errno::EINVAL)?; + transaction.stage_entry_with(kind, entry, allocate) + } + + pub(crate) fn stage_metadata_entry( + &mut self, + token: u32, + kind: u32, + entry: &[u8], + ) -> Result<(), Errno> { + self.stage_metadata_entry_with(token, kind, entry, |source| { + let mut owned = Vec::new(); + owned + .try_reserve_exact(source.len()) + .map_err(|_| Errno::ENOMEM)?; + owned.extend_from_slice(source); + Ok(owned) + }) + } + + pub(crate) fn commit_metadata_replacement(&mut self, token: u32) -> Result<(), Errno> { + let transaction = self + .metadata_replacement + .as_ref() + .filter(|transaction| transaction.token == token) + .ok_or(Errno::EINVAL)?; + if transaction.failed { + return Err(Errno::EINVAL); + } + + let transaction = self + .metadata_replacement + .take() + .expect("validated metadata transaction disappeared"); + // WHY: both new vectors are fully Rust-owned before either live field + // changes. No host import or fallible allocation occurs between these + // assignments, so one export makes the pair visible atomically. + self.argv = transaction.argv; + self.environ = transaction.environment; Ok(()) } - pub(crate) fn push_metadata_entry(&mut self, kind: u32, entry: &[u8]) -> Result<(), Errno> { - let mut owned = Vec::new(); - owned - .try_reserve_exact(entry.len()) - .map_err(|_| Errno::ENOMEM)?; - owned.extend_from_slice(entry); - - let entries = self.metadata_vector_mut(kind)?; - entries.try_reserve(1).map_err(|_| Errno::ENOMEM)?; - entries.push(owned); + pub(crate) fn cancel_metadata_replacement(&mut self, token: u32) -> Result<(), Errno> { + self.metadata_replacement + .as_ref() + .filter(|transaction| transaction.token == token) + .ok_or(Errno::EINVAL)?; + self.metadata_replacement = None; Ok(()) } } @@ -2073,27 +2205,151 @@ mod tests { } #[test] - fn metadata_entry_transport_preserves_empty_values_and_empty_environment() { + fn metadata_replacement_commits_both_vectors_and_preserves_empty_values() { let mut proc = Process::new(77); proc.argv = vec![b"old".to_vec()]; proc.environ = vec![b"OLD=value".to_vec()]; - proc.clear_metadata(PROCESS_METADATA_ARGV).unwrap(); - proc.push_metadata_entry(PROCESS_METADATA_ARGV, b"new") - .unwrap(); - proc.push_metadata_entry(PROCESS_METADATA_ARGV, b"") - .unwrap(); - proc.clear_metadata(PROCESS_METADATA_ENVIRONMENT).unwrap(); + let token = proc.begin_metadata_replacement().unwrap(); + proc.stage_metadata_entry( + token, + process_metadata_contract::KIND_ARGV, + b"new", + ) + .unwrap(); + proc.stage_metadata_entry( + token, + process_metadata_contract::KIND_ARGV, + b"", + ) + .unwrap(); + proc.commit_metadata_replacement(token).unwrap(); assert_eq!(proc.argv, vec![b"new".to_vec(), Vec::new()]); assert!(proc.environ.is_empty()); } #[test] - fn metadata_entry_transport_rejects_unknown_vector_kind() { + fn metadata_replacement_later_environment_allocation_failure_rolls_back_pair() { let mut proc = Process::new(78); - assert_eq!(proc.clear_metadata(99), Err(Errno::EINVAL)); - assert_eq!(proc.push_metadata_entry(99, b"value"), Err(Errno::EINVAL)); + proc.argv = vec![b"old-program".to_vec(), b"old-argument".to_vec()]; + proc.environ = vec![b"OLD=value".to_vec()]; + + let token = proc.begin_metadata_replacement().unwrap(); + proc.stage_metadata_entry( + token, + process_metadata_contract::KIND_ARGV, + b"new-program", + ) + .unwrap(); + proc.stage_metadata_entry( + token, + process_metadata_contract::KIND_ENVIRONMENT, + b"NEW=first", + ) + .unwrap(); + assert_eq!( + proc.stage_metadata_entry_with( + token, + process_metadata_contract::KIND_ENVIRONMENT, + b"NEW=second", + |_| Err(Errno::ENOMEM), + ), + Err(Errno::ENOMEM), + ); + + // A failed transaction is not committable even if a host omits its + // required cancel path. + assert_eq!( + proc.commit_metadata_replacement(token), + Err(Errno::EINVAL), + ); + assert_eq!( + proc.argv, + vec![b"old-program".to_vec(), b"old-argument".to_vec()] + ); + assert_eq!(proc.environ, vec![b"OLD=value".to_vec()]); + proc.cancel_metadata_replacement(token).unwrap(); + assert_eq!( + proc.argv, + vec![b"old-program".to_vec(), b"old-argument".to_vec()] + ); + assert_eq!(proc.environ, vec![b"OLD=value".to_vec()]); + } + + #[test] + fn metadata_replacement_rejects_overlap_stale_tokens_and_unknown_kinds() { + let mut proc = Process::new(79); + proc.argv = vec![b"old".to_vec()]; + proc.environ = vec![b"OLD=value".to_vec()]; + + let first = proc.begin_metadata_replacement().unwrap(); + assert_eq!( + proc.begin_metadata_replacement(), + Err(Errno::EBUSY), + ); + assert_eq!( + proc.stage_metadata_entry( + first + 1, + process_metadata_contract::KIND_ARGV, + b"stale", + ), + Err(Errno::EINVAL), + ); + assert_eq!( + proc.cancel_metadata_replacement(first + 1), + Err(Errno::EINVAL), + ); + assert_eq!( + proc.stage_metadata_entry( + first, + 99, + b"unknown-kind", + ), + Err(Errno::EINVAL), + ); + assert_eq!( + proc.commit_metadata_replacement(first), + Err(Errno::EINVAL), + ); + proc.cancel_metadata_replacement(first).unwrap(); + assert_eq!(proc.argv, vec![b"old".to_vec()]); + assert_eq!(proc.environ, vec![b"OLD=value".to_vec()]); + + let second = proc.begin_metadata_replacement().unwrap(); + assert!(second > first); + proc.stage_metadata_entry( + second, + process_metadata_contract::KIND_ARGV, + b"second", + ) + .unwrap(); + proc.stage_metadata_entry( + second, + process_metadata_contract::KIND_ENVIRONMENT, + b"SECOND=value", + ) + .unwrap(); + proc.commit_metadata_replacement(second).unwrap(); + assert_eq!(proc.argv, vec![b"second".to_vec()]); + assert_eq!(proc.environ, vec![b"SECOND=value".to_vec()]); + + let third = proc.begin_metadata_replacement().unwrap(); + proc.stage_metadata_entry( + third, + process_metadata_contract::KIND_ARGV, + b"argv-only", + ) + .unwrap(); + proc.stage_metadata_entry( + third, + process_metadata_contract::KIND_ENVIRONMENT, + b"THIRD=value", + ) + .unwrap(); + proc.commit_metadata_replacement(third).unwrap(); + assert_eq!(proc.argv, vec![b"argv-only".to_vec()]); + assert_eq!(proc.environ, vec![b"THIRD=value".to_vec()]); } #[test] @@ -2848,9 +3104,9 @@ mod tests { } #[test] - fn spawn_child_action_failure_drops_partial_child() { - // Dup2 from a closed source fd must fail with EBADF and leave the - // parent's process table unchanged. + fn spawn_child_late_action_failure_drops_partial_child() { + // A successful first action followed by Dup2 from a closed source fd + // must fail with EBADF and leave the parent's process table unchanged. use crate::process_table::ProcessTable; use crate::spawn::{FileAction, SpawnAttrs}; use wasm_posix_shared::Errno; @@ -2859,6 +3115,7 @@ mod tests { let parent_pid = table.create_process().unwrap(); let pids_before: Vec = table.all_pids(); let parent_fork_count_before = table.get(parent_pid).unwrap().fork_count(); + assert!(table.get(parent_pid).unwrap().fd_table.get(0).is_ok()); let mut host = test_host::NoopHost; let err = table @@ -2867,7 +3124,10 @@ mod tests { parent_pid, &[b"a".as_slice()], &[], - &[FileAction::Dup2 { srcfd: 999, fd: 1 }], + &[ + FileAction::Close { fd: 0 }, + FileAction::Dup2 { srcfd: 999, fd: 1 }, + ], &SpawnAttrs::empty(), &mut host, ) @@ -2877,6 +3137,10 @@ mod tests { // No new pid leaked. let pids_after: Vec = table.all_pids(); assert_eq!(pids_before, pids_after, "no partial child must remain"); + assert!( + table.get(parent_pid).unwrap().fd_table.get(0).is_ok(), + "the successful child-only action must not affect the parent" + ); // fork_count still 0. assert_eq!( table.get(parent_pid).unwrap().fork_count(), diff --git a/crates/kernel/src/process_snapshot_wire.rs b/crates/kernel/src/process_snapshot_wire.rs new file mode 100644 index 0000000000..9221adb3b8 --- /dev/null +++ b/crates/kernel/src/process_snapshot_wire.rs @@ -0,0 +1,227 @@ +//! Capacity-checked host process-snapshot record encoding. +//! +//! This stays separate from the Wasm export module so native unit tests can +//! prove the exact record capacity and atomic short-buffer behavior. + +use wasm_posix_shared::{process_snapshot_wire as wire, Errno}; + +pub(crate) struct ProcessSnapshotHeader { + pub(crate) pid: u32, + pub(crate) ppid: u32, + pub(crate) uid: u32, + pub(crate) gid: u32, + pub(crate) vsize: u64, + pub(crate) state: u32, + pub(crate) comm_len: u32, + pub(crate) cmdline_len: u32, +} + +pub(crate) fn write_process_snapshot_header( + buf: &mut [u8], + off: &mut usize, + header: &ProcessSnapshotHeader, +) -> Result<(), Errno> { + let end = (*off) + .checked_add(wire::HEADER_BYTES) + .ok_or(Errno::EOVERFLOW)?; + if end > buf.len() { + return Err(Errno::ENOSPC); + } + let base = *off; + write_u32_at(buf, base + wire::PID_OFFSET, header.pid); + write_u32_at(buf, base + wire::PPID_OFFSET, header.ppid); + write_u32_at(buf, base + wire::UID_OFFSET, header.uid); + write_u32_at(buf, base + wire::GID_OFFSET, header.gid); + write_u64_at(buf, base + wire::VSIZE_OFFSET, header.vsize); + write_u32_at(buf, base + wire::STATE_OFFSET, header.state); + write_u32_at(buf, base + wire::COMM_LEN_OFFSET, header.comm_len); + write_u32_at( + buf, + base + wire::CMDLINE_LEN_OFFSET, + header.cmdline_len, + ); + *off = end; + Ok(()) +} + +pub(crate) fn process_snapshot_record_bytes( + comm_len: usize, + cmdline_len: usize, +) -> Result { + wire::HEADER_BYTES + .checked_add(comm_len) + .and_then(|bytes| bytes.checked_add(cmdline_len)) + .ok_or(Errno::EOVERFLOW) +} + +/// Write one complete variable-sized record or leave the destination untouched. +/// +/// WHY: preflighting the complete header plus both byte strings prevents a +/// short allocation from observing a valid-looking header for a partial +/// record, and keeps the host's all-or-nothing parser contract truthful. +pub(crate) fn write_process_snapshot_record( + buf: &mut [u8], + off: &mut usize, + header: &ProcessSnapshotHeader, + comm: &[u8], + cmdline: &[u8], +) -> Result<(), Errno> { + if usize::try_from(header.comm_len).map_err(|_| Errno::EOVERFLOW)? != comm.len() + || usize::try_from(header.cmdline_len).map_err(|_| Errno::EOVERFLOW)? != cmdline.len() + { + return Err(Errno::EINVAL); + } + let record_bytes = process_snapshot_record_bytes(comm.len(), cmdline.len())?; + let end = (*off) + .checked_add(record_bytes) + .ok_or(Errno::EOVERFLOW)?; + if end > buf.len() { + return Err(Errno::ENOSPC); + } + + let mut cursor = *off; + write_process_snapshot_header(buf, &mut cursor, header)?; + let comm_end = cursor + comm.len(); + buf[cursor..comm_end].copy_from_slice(comm); + cursor = comm_end; + let cmdline_end = cursor + cmdline.len(); + buf[cursor..cmdline_end].copy_from_slice(cmdline); + debug_assert_eq!(cmdline_end, end); + *off = end; + Ok(()) +} + +fn write_u32_at(buf: &mut [u8], offset: usize, value: u32) { + buf[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); +} + +fn write_u64_at(buf: &mut [u8], offset: usize, value: u64) { + buf[offset..offset + 8].copy_from_slice(&value.to_le_bytes()); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn header() -> ProcessSnapshotHeader { + ProcessSnapshotHeader { + pid: 1, + ppid: 2, + uid: 3, + gid: 4, + vsize: 5, + state: b'R' as u32, + comm_len: 6, + cmdline_len: 7, + } + } + + fn record_header(comm: &[u8], cmdline: &[u8]) -> ProcessSnapshotHeader { + ProcessSnapshotHeader { + comm_len: u32::try_from(comm.len()).unwrap(), + cmdline_len: u32::try_from(cmdline.len()).unwrap(), + ..header() + } + } + + #[test] + fn header_fits_its_exact_declared_capacity() { + let mut bytes = [0xa5; wire::HEADER_BYTES]; + let mut offset = 0; + + assert_eq!( + write_process_snapshot_header(&mut bytes, &mut offset, &header()), + Ok(()) + ); + assert_eq!(offset, wire::HEADER_BYTES); + assert_eq!(wire::HEADER_BYTES, 36); + let view = &bytes[..]; + assert_eq!( + u32::from_le_bytes( + view[wire::PID_OFFSET..wire::PID_OFFSET + 4] + .try_into() + .unwrap() + ), + 1 + ); + assert_eq!( + u64::from_le_bytes( + view[wire::VSIZE_OFFSET..wire::VSIZE_OFFSET + 8] + .try_into() + .unwrap() + ), + 5 + ); + assert_eq!( + u32::from_le_bytes( + view[wire::CMDLINE_LEN_OFFSET..wire::CMDLINE_LEN_OFFSET + 4] + .try_into() + .unwrap() + ), + 7 + ); + } + + #[test] + fn header_rejects_one_short_without_mutation() { + let mut bytes = [0xa5; wire::HEADER_BYTES - 1]; + let before = bytes; + let mut offset = 0; + + assert_eq!( + write_process_snapshot_header(&mut bytes, &mut offset, &header()), + Err(Errno::ENOSPC) + ); + assert_eq!(offset, 0); + assert_eq!(bytes, before); + } + + #[test] + fn complete_record_fits_exact_capacity() { + let comm = b"demo"; + let cmdline = b"demo\0--safe\0"; + let required = process_snapshot_record_bytes(comm.len(), cmdline.len()).unwrap(); + let mut bytes = vec![0xa5; required]; + let mut offset = 0; + + assert_eq!( + write_process_snapshot_record( + &mut bytes, + &mut offset, + &record_header(comm, cmdline), + comm, + cmdline, + ), + Ok(()) + ); + assert_eq!(offset, required); + assert_eq!( + &bytes[wire::HEADER_BYTES..wire::HEADER_BYTES + comm.len()], + comm + ); + assert_eq!(&bytes[wire::HEADER_BYTES + comm.len()..], cmdline); + } + + #[test] + fn complete_record_rejects_one_short_without_mutation() { + let comm = b"demo"; + let cmdline = b"demo\0--safe\0"; + let required = process_snapshot_record_bytes(comm.len(), cmdline.len()).unwrap(); + let mut bytes = vec![0xa5; required - 1]; + let before = bytes.clone(); + let mut offset = 0; + + assert_eq!( + write_process_snapshot_record( + &mut bytes, + &mut offset, + &record_header(comm, cmdline), + comm, + cmdline, + ), + Err(Errno::ENOSPC) + ); + assert_eq!(offset, 0); + assert_eq!(bytes, before); + } +} diff --git a/crates/kernel/src/spawn.rs b/crates/kernel/src/spawn.rs index 6afb0e34c5..1df8212390 100644 --- a/crates/kernel/src/spawn.rs +++ b/crates/kernel/src/spawn.rs @@ -842,6 +842,108 @@ mod parser_tests { assert_eq!(parsed.attrs.pgrp, 7); } + #[test] + fn parse_blob_round_trips_the_complete_sortix_file_action_surface_in_order() { + fn append_action( + blob: &mut Vec, + op: u32, + fd: i32, + newfd: i32, + path_off: u32, + path_len: u32, + oflag: i32, + mode: u32, + ) { + let mut record = [0u8; spawn_contract::WIRE_ACTION_RECORD_BYTES]; + record + [spawn_contract::WIRE_ACTION_OP_OFFSET..spawn_contract::WIRE_ACTION_OP_OFFSET + 4] + .copy_from_slice(&op.to_le_bytes()); + record + [spawn_contract::WIRE_ACTION_FD_OFFSET..spawn_contract::WIRE_ACTION_FD_OFFSET + 4] + .copy_from_slice(&fd.to_le_bytes()); + record[spawn_contract::WIRE_ACTION_NEWFD_OFFSET + ..spawn_contract::WIRE_ACTION_NEWFD_OFFSET + 4] + .copy_from_slice(&newfd.to_le_bytes()); + record[spawn_contract::WIRE_ACTION_PATH_OFF_OFFSET + ..spawn_contract::WIRE_ACTION_PATH_OFF_OFFSET + 4] + .copy_from_slice(&path_off.to_le_bytes()); + record[spawn_contract::WIRE_ACTION_PATH_LEN_OFFSET + ..spawn_contract::WIRE_ACTION_PATH_LEN_OFFSET + 4] + .copy_from_slice(&path_len.to_le_bytes()); + record[spawn_contract::WIRE_ACTION_OFLAG_OFFSET + ..spawn_contract::WIRE_ACTION_OFLAG_OFFSET + 4] + .copy_from_slice(&oflag.to_le_bytes()); + record[spawn_contract::WIRE_ACTION_MODE_OFFSET + ..spawn_contract::WIRE_ACTION_MODE_OFFSET + 4] + .copy_from_slice(&mode.to_le_bytes()); + blob.extend_from_slice(&record); + } + + let all_attr_bits = spawn_contract::ATTR_RESETIDS + | spawn_contract::ATTR_SETPGROUP + | spawn_contract::ATTR_SETSIGDEF + | spawn_contract::ATTR_SETSIGMASK + | spawn_contract::ATTR_SETSCHEDPARAM + | spawn_contract::ATTR_SETSCHEDULER + | spawn_contract::ATTR_USEVFORK + | spawn_contract::ATTR_SETSID; + let mut blob = header(0, 0, 5); + blob[spawn_contract::WIRE_HEADER_ATTR_FLAGS_OFFSET + ..spawn_contract::WIRE_HEADER_ATTR_FLAGS_OFFSET + 4] + .copy_from_slice(&all_attr_bits.to_le_bytes()); + blob[spawn_contract::WIRE_HEADER_PGRP_OFFSET..spawn_contract::WIRE_HEADER_PGRP_OFFSET + 4] + .copy_from_slice(&(-17i32).to_le_bytes()); + blob[spawn_contract::WIRE_HEADER_SIGDEF_OFFSET + ..spawn_contract::WIRE_HEADER_SIGDEF_OFFSET + 8] + .copy_from_slice(&0x0102_0304_0506_0708u64.to_le_bytes()); + blob[spawn_contract::WIRE_HEADER_SIGMASK_OFFSET + ..spawn_contract::WIRE_HEADER_SIGMASK_OFFSET + 8] + .copy_from_slice(&0x8877_6655_4433_2211u64.to_le_bytes()); + + append_action(&mut blob, fdop::OPEN, 3, 0, 0, 12, 0x1234, 0o640); + append_action(&mut blob, fdop::CLOSE, 4, 0, 0, 0, 0, 0); + append_action(&mut blob, fdop::DUP2, 5, 6, 0, 0, 0, 0); + append_action(&mut blob, fdop::CHDIR, 0, 0, 12, 7, 0, 0); + append_action(&mut blob, fdop::FCHDIR, 7, 0, 0, 0, 0, 0); + blob.extend_from_slice(b"open-target\0subdir\0"); + + let parsed = parse_blob(&blob).expect("complete action surface"); + assert_eq!(parsed.attrs.flags, all_attr_bits); + assert_eq!(parsed.attrs.pgrp, -17); + assert_eq!(parsed.attrs.sigdef, 0x0102_0304_0506_0708); + assert_eq!(parsed.attrs.sigmask, 0x8877_6655_4433_2211); + assert_eq!(parsed.file_actions.len(), 5); + + match &parsed.file_actions[0] { + FileAction::Open { + fd, + path, + oflag, + mode, + } => { + assert_eq!((*fd, *oflag, *mode), (3, 0x1234, 0o640)); + assert_eq!(path, b"open-target"); + } + _ => panic!("first action must be Open"), + } + assert!(matches!( + &parsed.file_actions[1], + FileAction::Close { fd: 4 } + )); + assert!(matches!( + &parsed.file_actions[2], + FileAction::Dup2 { srcfd: 5, fd: 6 } + )); + match &parsed.file_actions[3] { + FileAction::Chdir { path } => assert_eq!(path, b"subdir"), + _ => panic!("fourth action must be Chdir"), + } + assert!(matches!( + &parsed.file_actions[4], + FileAction::Fchdir { fd: 7 } + )); + } + #[test] fn parse_blob_rejects_short_header() { // Truncate to 39 bytes. @@ -991,6 +1093,23 @@ mod parser_tests { assert_eq!(actions.file_actions.len(), spawn_contract::MAX_ACTION_COUNT,); } + #[test] + fn parse_blob_accepts_all_exact_count_caps_together_and_rejects_a_truncated_tail() { + let exact = exact_count_blob( + spawn_contract::MAX_ARGV_COUNT, + spawn_contract::MAX_ENVP_COUNT, + spawn_contract::MAX_ACTION_COUNT, + ); + let parsed = parse_blob(&exact).expect("all exact count caps"); + assert_eq!(parsed.argv.len(), spawn_contract::MAX_ARGV_COUNT); + assert_eq!(parsed.envp.len(), spawn_contract::MAX_ENVP_COUNT); + assert_eq!(parsed.file_actions.len(), spawn_contract::MAX_ACTION_COUNT); + + let mut truncated = exact; + truncated.pop(); + assert!(matches!(parse_blob(&truncated), Err(Errno::EINVAL))); + } + #[test] fn parse_blob_rejects_each_count_at_limit_plus_one() { for (argc, envc, n_actions) in [ @@ -1061,9 +1180,9 @@ mod parser_tests { #[test] fn parse_blob_bounds_action_paths_by_path_max() { - fn action_blob(path_bytes: usize) -> Vec { + fn action_blob(op: u32, path_bytes: usize) -> Vec { let mut blob = header(0, 0, 1); - blob.extend_from_slice(&fdop::CHDIR.to_le_bytes()); + blob.extend_from_slice(&op.to_le_bytes()); blob.extend_from_slice(&0i32.to_le_bytes()); blob.extend_from_slice(&0i32.to_le_bytes()); blob.extend_from_slice(&0u32.to_le_bytes()); @@ -1075,11 +1194,19 @@ mod parser_tests { blob } - assert!(parse_blob(&action_blob(spawn_contract::POSIX_PATH_MAX_BYTES)).is_ok()); - assert!(matches!( - parse_blob(&action_blob(spawn_contract::POSIX_PATH_MAX_BYTES + 1)), - Err(Errno::ENAMETOOLONG) - )); + for op in [fdop::OPEN, fdop::CHDIR] { + assert!( + parse_blob(&action_blob(op, spawn_contract::POSIX_PATH_MAX_BYTES)).is_ok(), + "op {op} must accept PATH_MAX bytes including NUL", + ); + assert!( + matches!( + parse_blob(&action_blob(op, spawn_contract::POSIX_PATH_MAX_BYTES + 1)), + Err(Errno::ENAMETOOLONG) + ), + "op {op} must reject PATH_MAX+1 bytes including NUL", + ); + } } #[test] diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 2270e203ef..1c9a388864 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -7259,6 +7259,12 @@ pub fn sys_readdir( let host_handle = stream.host_handle; let synth_state = stream.synth_dot_state; let stream_path = stream.path.clone(); + let dirent_size = core::mem::size_of::(); + if dirent_buf.len() < dirent_size { + // WHY: consuming either a synthetic or host entry before proving the + // fixed result fits would make an ERANGE retry silently skip it. + return Err(Errno::ERANGE); + } // Synthesize "." and ".." entries before host entries if synth_state < 2 { @@ -7267,21 +7273,21 @@ pub fn sys_readdir( } else { (b".." as &[u8], 2usize) }; + if name_buf.len() < name_len { + // Keep the synthetic cursor unchanged until both outputs can be + // replaced as one complete entry. + return Err(Errno::ERANGE); + } let dirent = WasmDirent { d_ino: 1, d_type: 4, // DT_DIR d_namlen: name_len as u32, }; - let dirent_size = core::mem::size_of::(); - if dirent_buf.len() >= dirent_size { - let dirent_bytes = unsafe { - core::slice::from_raw_parts(&dirent as *const WasmDirent as *const u8, dirent_size) - }; - dirent_buf[..dirent_size].copy_from_slice(dirent_bytes); - } - if name_len <= name_buf.len() { - name_buf[..name_len].copy_from_slice(name); - } + let dirent_bytes = unsafe { + core::slice::from_raw_parts(&dirent as *const WasmDirent as *const u8, dirent_size) + }; + dirent_buf[..dirent_size].copy_from_slice(dirent_bytes); + name_buf[..name_len].copy_from_slice(name); if let Some(stream) = proc.dir_streams.get_mut(idx).and_then(|s| s.as_mut()) { stream.synth_dot_state += 1; stream.position += 1; @@ -7291,6 +7297,12 @@ pub fn sys_readdir( match host.host_readdir(host_handle, name_buf)? { Some((d_ino, host_d_type, name_len)) => { + if name_len > name_buf.len() { + // HostIO must return ERANGE without consuming an entry when + // its complete name does not fit. A larger reported length is + // therefore a broken host contract, not a safe partial result. + return Err(Errno::EIO); + } // Skip host "." and ".." entries (we already synthesized them) if name_len == 1 && name_buf[0] == b'.' { // Recurse to get the next entry @@ -7300,7 +7312,7 @@ pub fn sys_readdir( return sys_readdir(proc, host, dir_handle, dirent_buf, name_buf); } - // Write WasmDirent to dirent_buf if it fits + // Both output capacities are proven before exposing this entry. let entry_path = directory_entry_path(&stream_path, &name_buf[..name_len]); let d_type = if unsafe { crate::fifo::global_fifo_table() } .lookup(&entry_path) @@ -7315,16 +7327,13 @@ pub fn sys_readdir( d_type, d_namlen: name_len as u32, }; - let dirent_size = core::mem::size_of::(); - if dirent_buf.len() >= dirent_size { - let dirent_bytes = unsafe { - core::slice::from_raw_parts( - &dirent as *const WasmDirent as *const u8, - dirent_size, - ) - }; - dirent_buf[..dirent_size].copy_from_slice(dirent_bytes); - } + let dirent_bytes = unsafe { + core::slice::from_raw_parts( + &dirent as *const WasmDirent as *const u8, + dirent_size, + ) + }; + dirent_buf[..dirent_size].copy_from_slice(dirent_bytes); // Increment position if let Some(stream) = proc.dir_streams.get_mut(idx).and_then(|s| s.as_mut()) { stream.position += 1; @@ -9339,6 +9348,12 @@ pub(crate) fn checked_sockaddr_un_path(addr: &[u8]) -> Result<&[u8], Errno> { { return Err(Errno::EINVAL); } + // WHY: the path bytes are meaningful only after the independently supplied + // address family selects sockaddr_un. Validate that discriminator before + // any caller can resolve a VFS path or mutate socket/registry state. + if sockaddr_family(addr)? as u32 != wasm_posix_shared::socket::AF_UNIX { + return Err(Errno::EAFNOSUPPORT); + } Ok(&addr[path_offset..]) } @@ -9544,52 +9559,139 @@ pub(crate) fn ipv4_multicast_interface_from_index(ifindex: u32) -> Result<[u8; 4 } } -/// Resolve musl's wasm32/wasm64 `group_req` and `group_source_req` -/// sockaddr_storage offsets from the option buffer's canonical size (or, -/// for oversized buffers, from unambiguous embedded AF_INET families). +/// Resolve musl's wasm32/wasm64 `group_req` and `group_source_req` layout +/// from the authoritative calling-process data model. +/// +/// WHY: `optlen` is a caller capacity and padding is ordinary caller data. +/// Neither can identify the ABI. One kernel instance serves both wasm32 and +/// wasm64 processes, so the host carries their width independently. pub(crate) fn multicast_group_request_offsets( buf: &[u8], with_source: bool, + pointer_width: u32, ) -> Result<(usize, Option), Errno> { - use wasm_posix_shared::socket::AF_INET; - - const GROUP_REQ_WASM32_SIZE: usize = 132; - const GROUP_REQ_WASM64_SIZE: usize = 136; - const GROUP_SOURCE_REQ_WASM32_SIZE: usize = 260; - const GROUP_SOURCE_REQ_WASM64_SIZE: usize = 264; + use wasm_posix_shared::process_layout::{ + multicast_group_request as layout, WASM32_POINTER_WIDTH, WASM64_POINTER_WIDTH, + }; - let (size32, size64, source32, source64) = if with_source { - ( - GROUP_SOURCE_REQ_WASM32_SIZE, - GROUP_SOURCE_REQ_WASM64_SIZE, - Some(132), - Some(136), - ) - } else { - (GROUP_REQ_WASM32_SIZE, GROUP_REQ_WASM64_SIZE, None, None) + let (required, group, source) = match (pointer_width, with_source) { + (WASM32_POINTER_WIDTH, false) => ( + layout::WASM32_GROUP_REQ_SIZE, + layout::WASM32_GROUP_OFFSET, + None, + ), + (WASM32_POINTER_WIDTH, true) => ( + layout::WASM32_GROUP_SOURCE_REQ_SIZE, + layout::WASM32_GROUP_OFFSET, + Some(layout::WASM32_SOURCE_OFFSET), + ), + (WASM64_POINTER_WIDTH, false) => ( + layout::WASM64_GROUP_REQ_SIZE, + layout::WASM64_GROUP_OFFSET, + None, + ), + (WASM64_POINTER_WIDTH, true) => ( + layout::WASM64_GROUP_SOURCE_REQ_SIZE, + layout::WASM64_GROUP_OFFSET, + Some(layout::WASM64_SOURCE_OFFSET), + ), + _ => return Err(Errno::EINVAL), }; - if buf.len() < size32 { - return Err(Errno::EINVAL); - } - if buf.len() < size64 { - return Ok((4, source32)); + if buf.len() < required as usize { + Err(Errno::EINVAL) + } else { + Ok((group as usize, source.map(|offset| offset as usize))) } - if buf.len() == size64 { - return Ok((8, source64)); +} + +pub(crate) fn parse_ipv4_multicast_request( + buf: &[u8], + optname: u32, + pointer_width: u32, +) -> Result<([u8; 4], [u8; 4], Option<[u8; 4]>), Errno> { + use wasm_posix_shared::socket::*; + + if !matches!( + pointer_width, + wasm_posix_shared::process_layout::WASM32_POINTER_WIDTH + | wasm_posix_shared::process_layout::WASM64_POINTER_WIDTH + ) { + return Err(Errno::EINVAL); } - let family_is_inet = |offset: usize| { - buf.get(offset..offset + 2) - .map(|family| u16::from_le_bytes([family[0], family[1]]) as u32 == AF_INET) - .unwrap_or(false) + let parse_sockaddr_in_at = |offset: usize| -> Result<[u8; 4], Errno> { + if buf.len() < offset + 8 { + return Err(Errno::EINVAL); + } + let family = u16::from_le_bytes([buf[offset], buf[offset + 1]]); + if family as u32 != AF_INET { + return Err(Errno::EAFNOSUPPORT); + } + Ok([ + buf[offset + 4], + buf[offset + 5], + buf[offset + 6], + buf[offset + 7], + ]) }; - let wasm32 = family_is_inet(4) && source32.map(|o| family_is_inet(o)).unwrap_or(true); - let wasm64 = family_is_inet(8) && source64.map(|o| family_is_inet(o)).unwrap_or(true); - match (wasm32, wasm64) { - (true, false) => Ok((4, source32)), - (false, true) => Ok((8, source64)), - (true, true) => Err(Errno::EINVAL), - (false, false) => Ok((8, source64)), + let parse_ifindex_at = |offset: usize| -> Result<[u8; 4], Errno> { + if buf.len() < offset + 4 { + return Err(Errno::EINVAL); + } + let ifindex = u32::from_le_bytes(buf[offset..offset + 4].try_into().unwrap()); + ipv4_multicast_interface_from_index(ifindex) + }; + + match optname { + IP_ADD_MEMBERSHIP | IP_DROP_MEMBERSHIP => { + if buf.len() < 8 { + Err(Errno::EINVAL) + } else { + Ok(( + [buf[0], buf[1], buf[2], buf[3]], + [buf[4], buf[5], buf[6], buf[7]], + None, + )) + } + } + IP_BLOCK_SOURCE + | IP_UNBLOCK_SOURCE + | IP_ADD_SOURCE_MEMBERSHIP + | IP_DROP_SOURCE_MEMBERSHIP => { + if buf.len() < 12 { + Err(Errno::EINVAL) + } else { + Ok(( + [buf[0], buf[1], buf[2], buf[3]], + [buf[4], buf[5], buf[6], buf[7]], + Some([buf[8], buf[9], buf[10], buf[11]]), + )) + } + } + MCAST_JOIN_GROUP | MCAST_LEAVE_GROUP => { + let (group_offset, _) = + multicast_group_request_offsets(buf, false, pointer_width)?; + Ok(( + parse_sockaddr_in_at(group_offset)?, + parse_ifindex_at(0)?, + None, + )) + } + MCAST_BLOCK_SOURCE + | MCAST_UNBLOCK_SOURCE + | MCAST_JOIN_SOURCE_GROUP + | MCAST_LEAVE_SOURCE_GROUP => { + let (group_offset, source_offset) = + multicast_group_request_offsets(buf, true, pointer_width)?; + Ok(( + parse_sockaddr_in_at(group_offset)?, + parse_ifindex_at(0)?, + Some(parse_sockaddr_in_at( + source_offset.expect("source request has source offset"), + )?), + )) + } + _ => Err(Errno::ENOPROTOOPT), } } @@ -11349,18 +11451,10 @@ pub fn sys_bind( match sock.domain { SocketDomain::Inet => { - // sockaddr_in: family(2) + port(2 BE) + addr(4) = 8 bytes min - if addr.len() < 8 { - return Err(Errno::EINVAL); - } - let (ip, port) = if sock.sock_type == SocketType::Dgram { - parse_sockaddr_in(addr)? - } else { - ( - [addr[4], addr[5], addr[6], addr[7]], - u16::from_be_bytes([addr[2], addr[3]]), - ) - }; + // Validate the family and complete minimum layout before either + // stream or datagram binding can allocate a port or register + // socket state. + let (ip, port) = parse_sockaddr_in(addr)?; if sock.sock_type == SocketType::Dgram { return udp_bind_socket(proc, host, sock_idx, ip, port); @@ -11981,12 +12075,10 @@ pub fn sys_connect( return Ok(()); } - // Parse sockaddr_in: family(2) + port(2 big-endian) + addr(4) - if addr.len() < 8 { - return Err(Errno::EINVAL); - } - let port = u16::from_be_bytes([addr[2], addr[3]]); - let ip = [addr[4], addr[5], addr[6], addr[7]]; + // Validate the family and complete minimum layout before a local + // connection allocates pipes or an external connection reaches + // HostIO and changes the socket to Connecting. + let (ip, port) = parse_sockaddr_in(addr)?; if !bind_device_allows_ipv4(sock, ip, false) { return Err(Errno::ENETUNREACH); } @@ -16706,6 +16798,7 @@ mod tests { dir_entry_names: Option>>, dir_opendir_error: Option, dir_readdir_error: Option<(usize, Errno)>, + dir_readdir_reported_len: Option, sigsuspend_signal: u32, sigsuspend_error: bool, clock_time: (i64, i64), @@ -16793,6 +16886,7 @@ mod tests { dir_entry_names: None, dir_opendir_error: None, dir_readdir_error: None, + dir_readdir_reported_len: None, sigsuspend_signal: 0, sigsuspend_error: false, clock_time: (1234567890, 123456789), @@ -17309,9 +17403,6 @@ mod tests { } } if idx < self.dir_entry_count { - self.dir_entry_index = idx + 1; - self.dir_entry_indices.insert(handle, idx + 1); - self.dir_entry_returned = true; // Generate distinct entries based on index let default_names: [&[u8]; 5] = [b"test.txt", b"foo.txt", b"bar.txt", b"baz.txt", b"qux.txt"]; @@ -17321,9 +17412,20 @@ mod tests { .and_then(|names| names.get(idx)) .map(Vec::as_slice) .unwrap_or_else(|| default_names.get(idx).copied().unwrap_or(b"test.txt")); - let n = name_buf.len().min(name.len()); - name_buf[..n].copy_from_slice(&name[..n]); - Ok(Some(((42 + idx as u64), 8, n))) // d_ino varies, d_type=DT_REG=8 + if name_buf.len() < name.len() { + // Match HostIO's retry contract: an ERANGE result neither + // mutates the destination nor consumes this host entry. + return Err(Errno::ERANGE); + } + self.dir_entry_index = idx + 1; + self.dir_entry_indices.insert(handle, idx + 1); + self.dir_entry_returned = true; + name_buf[..name.len()].copy_from_slice(name); + Ok(Some(( + (42 + idx as u64), + 8, + self.dir_readdir_reported_len.unwrap_or(name.len()), + ))) // d_ino varies, d_type=DT_REG=8 } else { Ok(None) // end of directory } @@ -20136,6 +20238,63 @@ mod tests { assert_eq!(&buf[..n], b"/target"); } + #[test] + fn test_readlink_variants_preserve_large_caller_capacity_and_truncation_boundary() { + let target_len = wasm_posix_shared::channel::DATA_SIZE + 1; + let target: Vec = (0..target_len) + .map(|index| b'a' + (index % 26) as u8) + .collect(); + + for use_readlinkat in [false, true] { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.set_symlink(b"/tmp/link", &target); + + let invoke = |proc: &mut Process, + host: &mut MockHostIO, + buf: &mut [u8]| + -> Result { + if use_readlinkat { + sys_readlinkat( + proc, + host, + wasm_posix_shared::flags::AT_FDCWD, + b"/tmp/link", + buf, + ) + } else { + sys_readlink(proc, host, b"/tmp/link", buf) + } + }; + + // POSIX readlink truncation is controlled by the caller's real + // capacity. A transport-sized intermediate must not impose a + // shorter operation when the caller provided the complete extent. + let mut one_short = vec![0xa5; target_len]; + let n = invoke(&mut proc, &mut host, &mut one_short[..target_len - 1]).unwrap(); + assert_eq!(n, target_len - 1); + assert_eq!(&one_short[..n], &target[..n]); + assert_eq!(one_short[n], 0xa5); + + let mut exact = vec![0xa5; target_len + 1]; + let n = invoke(&mut proc, &mut host, &mut exact[..target_len]).unwrap(); + assert_eq!(n, target_len); + assert_eq!(&exact[..n], target.as_slice()); + assert_eq!(exact[n], 0xa5); + + let mut one_extra = vec![0xa5; target_len + 2]; + let n = invoke( + &mut proc, + &mut host, + &mut one_extra[..target_len + 1], + ) + .unwrap(); + assert_eq!(n, target_len); + assert_eq!(&one_extra[..n], target.as_slice()); + assert!(one_extra[n..].iter().all(|&byte| byte == 0xa5)); + } + } + #[test] fn test_rename_delegates_to_host() { let mut proc = Process::new(1); @@ -20431,6 +20590,65 @@ mod tests { assert_eq!(result, Err(Errno::ERANGE)); } + #[test] + fn test_getcwd_path_max_boundary_is_complete_and_atomic() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let accepted = namespace_boundary_path(254, true); + assert_eq!(accepted.len(), NAMESPACE_PATH_MAX - 1); + sys_chdir(&mut proc, &mut host, &accepted).unwrap(); + + let mut one_short = vec![0xa5; NAMESPACE_PATH_MAX - 1]; + assert_eq!( + sys_getcwd(&proc, &mut host, &mut one_short), + Err(Errno::ERANGE) + ); + assert!(one_short.iter().all(|&byte| byte == 0xa5)); + + let mut exact = vec![0xa5; NAMESPACE_PATH_MAX]; + let n = sys_getcwd(&proc, &mut host, &mut exact).unwrap(); + assert_eq!(n, NAMESPACE_PATH_MAX); + assert_eq!(&exact[..accepted.len()], accepted.as_slice()); + assert_eq!(exact[accepted.len()], 0); + + let mut one_extra = vec![0xa5; NAMESPACE_PATH_MAX + 1]; + let n = sys_getcwd(&proc, &mut host, &mut one_extra).unwrap(); + assert_eq!(n, NAMESPACE_PATH_MAX); + assert_eq!(&one_extra[..accepted.len()], accepted.as_slice()); + assert_eq!(one_extra[accepted.len()], 0); + assert_eq!(one_extra[n], 0xa5); + + let rejected = namespace_boundary_path(255, true); + assert_eq!(rejected.len(), NAMESPACE_PATH_MAX); + assert_eq!( + sys_chdir(&mut proc, &mut host, &rejected), + Err(Errno::ENAMETOOLONG) + ); + assert_eq!(proc.cwd, accepted); + + // PATH_MAX bounds the pathname supplied by the caller, not a + // canonical path formed from a valid short relative pathname and an + // already-deep CWD. getcwd must return that complete internal state. + sys_chdir(&mut proc, &mut host, b"childdir").unwrap(); + let mut deep_cwd = accepted; + deep_cwd.extend_from_slice(b"/childdir"); + assert_eq!(proc.cwd, deep_cwd); + assert!(deep_cwd.len() > NAMESPACE_PATH_MAX); + + let mut deep_one_short = vec![0xa5; deep_cwd.len()]; + assert_eq!( + sys_getcwd(&proc, &mut host, &mut deep_one_short), + Err(Errno::ERANGE) + ); + assert!(deep_one_short.iter().all(|&byte| byte == 0xa5)); + + let mut deep_exact = vec![0xa5; deep_cwd.len() + 1]; + let n = sys_getcwd(&proc, &mut host, &mut deep_exact).unwrap(); + assert_eq!(n, deep_cwd.len() + 1); + assert_eq!(&deep_exact[..deep_cwd.len()], deep_cwd.as_slice()); + assert_eq!(deep_exact[deep_cwd.len()], 0); + } + #[test] fn test_opendir_closedir_cycle() { let mut proc = Process::new(1); @@ -20489,6 +20707,207 @@ mod tests { sys_closedir(&mut proc, &mut host, dh).unwrap(); } + #[test] + fn test_readdir_synthetic_entry_retries_after_short_dirent_or_name_buffer() { + let dirent_size = core::mem::size_of::(); + + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let dh = sys_opendir(&mut proc, &mut host, b"/tmp").unwrap(); + let mut short_dirent = vec![0xa5; dirent_size - 1]; + let mut name_buf = [0xa5; 2]; + + assert_eq!( + sys_readdir( + &mut proc, + &mut host, + dh, + &mut short_dirent, + &mut name_buf, + ), + Err(Errno::ERANGE), + ); + assert!(short_dirent.iter().all(|byte| *byte == 0xa5)); + assert!(name_buf.iter().all(|byte| *byte == 0xa5)); + assert_eq!(sys_telldir(&proc, dh), Ok(0)); + + let mut dirent_buf = vec![0xa5; dirent_size]; + assert_eq!( + sys_readdir( + &mut proc, + &mut host, + dh, + &mut dirent_buf, + &mut name_buf, + ), + Ok(1), + ); + assert_eq!(&name_buf[..1], b"."); + assert_eq!(sys_telldir(&proc, dh), Ok(1)); + + dirent_buf.fill(0xa5); + name_buf.fill(0xa5); + assert_eq!( + sys_readdir( + &mut proc, + &mut host, + dh, + &mut dirent_buf, + &mut name_buf[..1], + ), + Err(Errno::ERANGE), + ); + assert!(dirent_buf.iter().all(|byte| *byte == 0xa5)); + assert!(name_buf.iter().all(|byte| *byte == 0xa5)); + assert_eq!(sys_telldir(&proc, dh), Ok(1)); + + assert_eq!( + sys_readdir( + &mut proc, + &mut host, + dh, + &mut dirent_buf, + &mut name_buf, + ), + Ok(1), + ); + assert_eq!(&name_buf[..2], b".."); + assert_eq!(sys_telldir(&proc, dh), Ok(2)); + } + + #[test] + fn test_readdir_host_entry_retries_after_short_dirent_or_name_buffer() { + let dirent_size = core::mem::size_of::(); + let maximum_name = vec![b'n'; wasm_posix_shared::platform_limits::NAME_MAX_BYTES - 1]; + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.dir_entry_count = 2; + host.dir_entry_names = Some(vec![b"first".to_vec(), maximum_name.clone()]); + let dh = sys_opendir(&mut proc, &mut host, b"/tmp").unwrap(); + let host_handle = proc.dir_streams[dh as usize].as_ref().unwrap().host_handle; + let mut dirent_buf = vec![0u8; dirent_size]; + let mut name_buf = vec![0u8; maximum_name.len()]; + + for expected in [b".".as_slice(), b"..".as_slice()] { + assert_eq!( + sys_readdir( + &mut proc, + &mut host, + dh, + &mut dirent_buf, + &mut name_buf, + ), + Ok(1), + ); + assert_eq!(&name_buf[..expected.len()], expected); + } + assert_eq!(sys_telldir(&proc, dh), Ok(2)); + assert_eq!(host.dir_entry_indices.get(&host_handle), Some(&0)); + + let mut short_dirent = vec![0xa5; dirent_size - 1]; + name_buf.fill(0xa5); + assert_eq!( + sys_readdir( + &mut proc, + &mut host, + dh, + &mut short_dirent, + &mut name_buf, + ), + Err(Errno::ERANGE), + ); + assert!(short_dirent.iter().all(|byte| *byte == 0xa5)); + assert!(name_buf.iter().all(|byte| *byte == 0xa5)); + assert_eq!(sys_telldir(&proc, dh), Ok(2)); + assert_eq!(host.dir_entry_indices.get(&host_handle), Some(&0)); + + dirent_buf.fill(0xa5); + assert_eq!( + sys_readdir( + &mut proc, + &mut host, + dh, + &mut dirent_buf, + &mut name_buf, + ), + Ok(1), + ); + assert_eq!(&name_buf[..5], b"first"); + assert_eq!(sys_telldir(&proc, dh), Ok(3)); + assert_eq!(host.dir_entry_indices.get(&host_handle), Some(&1)); + + dirent_buf.fill(0xa5); + name_buf.fill(0xa5); + let short_name_len = maximum_name.len() - 1; + assert_eq!( + sys_readdir( + &mut proc, + &mut host, + dh, + &mut dirent_buf, + &mut name_buf[..short_name_len], + ), + Err(Errno::ERANGE), + ); + assert!(dirent_buf.iter().all(|byte| *byte == 0xa5)); + assert!(name_buf.iter().all(|byte| *byte == 0xa5)); + assert_eq!(sys_telldir(&proc, dh), Ok(3)); + assert_eq!(host.dir_entry_indices.get(&host_handle), Some(&1)); + + assert_eq!( + sys_readdir( + &mut proc, + &mut host, + dh, + &mut dirent_buf, + &mut name_buf, + ), + Ok(1), + ); + assert_eq!(name_buf, maximum_name); + assert_eq!(sys_telldir(&proc, dh), Ok(4)); + assert_eq!(host.dir_entry_indices.get(&host_handle), Some(&2)); + } + + #[test] + fn test_readdir_rejects_impossible_host_reported_name_length() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.dir_entry_names = Some(vec![b"x".to_vec()]); + let dh = sys_opendir(&mut proc, &mut host, b"/tmp").unwrap(); + let mut dirent_buf = [0u8; core::mem::size_of::()]; + let mut name_buf = [0u8; 4]; + + for _ in 0..2 { + assert_eq!( + sys_readdir( + &mut proc, + &mut host, + dh, + &mut dirent_buf, + &mut name_buf, + ), + Ok(1), + ); + } + + dirent_buf.fill(0xa5); + name_buf.fill(0xa5); + host.dir_readdir_reported_len = Some(name_buf.len() + 1); + assert_eq!( + sys_readdir( + &mut proc, + &mut host, + dh, + &mut dirent_buf, + &mut name_buf, + ), + Err(Errno::EIO), + ); + assert!(dirent_buf.iter().all(|byte| *byte == 0xa5)); + assert_eq!(sys_telldir(&proc, dh), Ok(2)); + } + #[test] fn test_readdir_invalid_handle() { let mut proc = Process::new(1); @@ -24224,6 +24643,43 @@ mod tests { assert_eq!(&buf[..n], b"/home/user"); } + #[test] + fn test_getenv_maximum_entry_capacity_is_complete_and_atomic() { + let mut proc = Process::new(1); + let entry_limit = + wasm_posix_shared::platform_limits::PROCESS_METADATA_ENTRY_MAX_BYTES; + let value = vec![b'v'; entry_limit - b"X=".len()]; + sys_setenv(&mut proc, b"X", &value, true).unwrap(); + + let mut one_short = vec![0xa5; value.len() - 1]; + assert_eq!( + sys_getenv(&proc, b"X", &mut one_short), + Err(Errno::ERANGE) + ); + assert!(one_short.iter().all(|&byte| byte == 0xa5)); + + let mut exact = vec![0xa5; value.len()]; + let n = sys_getenv(&proc, b"X", &mut exact).unwrap(); + assert_eq!(n, value.len()); + assert_eq!(exact, value); + + let mut one_extra = vec![0xa5; value.len() + 1]; + let n = sys_getenv(&proc, b"X", &mut one_extra).unwrap(); + assert_eq!(n, value.len()); + assert_eq!(&one_extra[..n], value.as_slice()); + assert_eq!(one_extra[n], 0xa5); + + let oversized = vec![b'w'; value.len() + 1]; + assert_eq!( + sys_setenv(&mut proc, b"X", &oversized, true), + Err(Errno::E2BIG) + ); + let mut retained = vec![0u8; value.len()]; + let n = sys_getenv(&proc, b"X", &mut retained).unwrap(); + assert_eq!(n, value.len()); + assert_eq!(retained, value); + } + #[test] fn test_setenv_no_overwrite() { let mut proc = Process::new(1); @@ -25615,6 +26071,44 @@ mod tests { assert_eq!(result, Ok(())); } + #[test] + fn test_bind_inet_stream_validates_sockaddr_before_state_mutation() { + use crate::socket::SocketState; + use wasm_posix_shared::socket::*; + + let mut proc = Process::new(9070); + let mut host = MockHostIO::new(); + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + let sock_idx = test_socket_idx(&proc, fd); + let initial_ephemeral_port = proc.next_ephemeral_port; + let mut address = [0u8; 16]; + address[0] = AF_UNIX as u8; + address[4..8].copy_from_slice(&[127, 0, 0, 1]); + + assert_eq!( + sys_bind(&mut proc, &mut host, fd, &address).unwrap_err(), + Errno::EAFNOSUPPORT, + ); + assert_eq!( + sys_bind(&mut proc, &mut host, fd, &address[..7]).unwrap_err(), + Errno::EINVAL, + ); + let socket = proc.sockets.get(sock_idx).unwrap(); + assert_eq!(socket.state, SocketState::Unbound); + assert_eq!(socket.bind_addr, [0; 4]); + assert_eq!(socket.bind_port, 0); + assert_eq!(proc.next_ephemeral_port, initial_ephemeral_port); + + address[0] = AF_INET as u8; + address[2..4].copy_from_slice(&41_731u16.to_be_bytes()); + sys_bind(&mut proc, &mut host, fd, &address).unwrap(); + assert_eq!( + proc.sockets.get(sock_idx).unwrap().state, + SocketState::Bound, + "the matching-family control must still bind normally", + ); + } + #[test] fn test_bind_enotsock() { let mut proc = Process::new(1); @@ -25655,6 +26149,65 @@ mod tests { unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); } + #[test] + fn test_bind_unix_validates_family_before_vfs_or_registry_mutation() { + use crate::socket::SocketState; + use wasm_posix_shared::socket::*; + + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + let mut proc = Process::new(9071); + let mut host = MockHostIO::new(); + let path = b"/tmp/family_bind_9071.sock"; + let resolved = crate::path::resolve_path(path, &proc.cwd); + let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; + registry.unregister(&resolved); + let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + let sock_idx = test_socket_idx(&proc, fd); + let mut address = [ + 0u8; + wasm_posix_shared::kernel_scratch_wire::SOCKADDR_UNIX_BYTES as usize + ]; + address[0] = AF_INET as u8; + address[2..2 + path.len()].copy_from_slice(path); + let address_len = 2 + path.len() + 1; + + assert_eq!( + sys_bind( + &mut proc, + &mut host, + fd, + &address[..address_len], + ) + .unwrap_err(), + Errno::EAFNOSUPPORT, + ); + assert_eq!( + proc.sockets.get(sock_idx).unwrap().state, + SocketState::Unbound, + ); + assert!(registry.lookup(&resolved).is_none()); + assert!( + !host.handle_paths.values().any(|opened| opened == &resolved), + "wrong-family sockaddr bytes must not create a VFS entry", + ); + + address[0] = AF_UNIX as u8; + sys_bind( + &mut proc, + &mut host, + fd, + &address[..address_len], + ) + .unwrap(); + assert_eq!( + proc.sockets.get(sock_idx).unwrap().state, + SocketState::Bound, + "the matching-family control must still bind normally", + ); + assert!(registry.lookup(&resolved).is_some()); + registry.unregister(&resolved); + } + #[test] fn test_bind_unix_duplicate_fails() { let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); @@ -25716,10 +26269,70 @@ mod tests { let mut host = MockHostIO::new(); use wasm_posix_shared::socket::*; let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); - let result = sys_connect(&mut proc, &mut host, fd, &[0u8; 16]); + let mut address = [0u8; 16]; + address[0] = AF_INET as u8; + let result = sys_connect(&mut proc, &mut host, fd, &address); assert_eq!(result, Err(Errno::ECONNREFUSED)); } + #[test] + fn test_connect_inet_stream_validates_sockaddr_before_host_or_state_mutation() { + use crate::socket::SocketState; + use wasm_posix_shared::socket::*; + + let mut proc = Process::new(9072); + let mut host = MockHostIO::new(); + host.net_connect_result = Ok(()); + host.net_connect_status_result = Err(Errno::EAGAIN); + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + let sock_idx = test_socket_idx(&proc, fd); + let initial_ephemeral_port = proc.next_ephemeral_port; + let mut address = [ + AF_UNIX as u8, + 0, + 0, + 80, + 203, + 0, + 113, + 7, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ]; + + assert_eq!( + sys_connect(&mut proc, &mut host, fd, &address).unwrap_err(), + Errno::EAFNOSUPPORT, + ); + assert_eq!( + sys_connect(&mut proc, &mut host, fd, &address[..7]).unwrap_err(), + Errno::EINVAL, + ); + let socket = proc.sockets.get(sock_idx).unwrap(); + assert_eq!(socket.state, SocketState::Unbound); + assert_eq!(socket.host_net_handle, None); + assert_eq!(proc.next_ephemeral_port, initial_ephemeral_port); + assert!(host.net_connect_calls.is_empty()); + + address[0] = AF_INET as u8; + assert_eq!( + sys_connect(&mut proc, &mut host, fd, &address).unwrap_err(), + Errno::EINPROGRESS, + "the matching-family control must reach HostIO normally", + ); + assert_eq!(host.net_connect_calls.len(), 1); + assert_eq!( + proc.sockets.get(sock_idx).unwrap().state, + SocketState::Connecting, + ); + } + #[test] fn test_external_nonblocking_connect_reports_pending_errnos_once_then_writable() { use wasm_posix_shared::fcntl_cmd::F_SETFL; @@ -30030,6 +30643,59 @@ mod tests { assert_eq!(result, Err(Errno::ERANGE)); } + #[test] + fn test_realpath_path_max_boundary_is_complete_and_atomic() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let path = namespace_boundary_path(254, false); + assert_eq!(path.len(), NAMESPACE_PATH_MAX - 1); + + let mut one_short = vec![0xa5; path.len() - 1]; + assert_eq!( + sys_realpath(&mut proc, &mut host, &path, &mut one_short), + Err(Errno::ERANGE) + ); + assert!(one_short.iter().all(|&byte| byte == 0xa5)); + + let mut exact = vec![0xa5; path.len()]; + let n = sys_realpath(&mut proc, &mut host, &path, &mut exact).unwrap(); + assert_eq!(n, path.len()); + assert_eq!(exact, path); + + let mut one_extra = vec![0xa5; path.len() + 1]; + let n = sys_realpath(&mut proc, &mut host, &path, &mut one_extra).unwrap(); + assert_eq!(n, path.len()); + assert_eq!(&one_extra[..n], path.as_slice()); + assert_eq!(one_extra[n], 0xa5); + + let mut channel_plus_one = vec![0xa5; wasm_posix_shared::channel::DATA_SIZE + 1]; + let n = sys_realpath(&mut proc, &mut host, &path, &mut channel_plus_one).unwrap(); + assert_eq!(n, path.len()); + assert_eq!(&channel_plus_one[..n], path.as_slice()); + assert!(channel_plus_one[n..].iter().all(|&byte| byte == 0xa5)); + + // The input limit is independent of canonical-output length: a short + // relative path from a valid deep CWD may resolve beyond PATH_MAX. + let deep_cwd = namespace_boundary_path(254, true); + assert_eq!(deep_cwd.len(), NAMESPACE_PATH_MAX - 1); + sys_chdir(&mut proc, &mut host, &deep_cwd).unwrap(); + let mut deep_result = deep_cwd; + deep_result.extend_from_slice(b"/child"); + assert!(deep_result.len() > NAMESPACE_PATH_MAX); + + let mut deep_one_short = vec![0xa5; deep_result.len() - 1]; + assert_eq!( + sys_realpath(&mut proc, &mut host, b"child", &mut deep_one_short), + Err(Errno::ERANGE) + ); + assert!(deep_one_short.iter().all(|&byte| byte == 0xa5)); + + let mut deep_exact = vec![0xa5; deep_result.len()]; + let n = sys_realpath(&mut proc, &mut host, b"child", &mut deep_exact).unwrap(); + assert_eq!(n, deep_result.len()); + assert_eq!(deep_exact, deep_result); + } + #[test] fn test_fork_child_fields_default_to_false() { let proc = Process::new(1); @@ -30763,6 +31429,71 @@ mod tests { assert_eq!(err, Errno::ECONNREFUSED); } + #[test] + fn test_unix_stream_connect_validates_family_before_resolution_or_state_mutation() { + use crate::socket::SocketState; + use wasm_posix_shared::socket::*; + + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + let mut proc = Process::new(9073); + let mut host = MockHostIO::new(); + let path = b"/tmp/family_connect_9073.sock"; + let resolved = crate::path::resolve_path(path, &proc.cwd); + let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; + registry.unregister(&resolved); + let mut address = [ + 0u8; + wasm_posix_shared::kernel_scratch_wire::SOCKADDR_UNIX_BYTES as usize + ]; + address[0] = AF_UNIX as u8; + address[2..2 + path.len()].copy_from_slice(path); + let address_len = 2 + path.len() + 1; + let server = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + sys_bind( + &mut proc, + &mut host, + server, + &address[..address_len], + ) + .unwrap(); + sys_listen(&mut proc, &mut host, server, 1).unwrap(); + let client = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + let client_idx = test_socket_idx(&proc, client); + let resolved_paths_before = host.lstat_paths.len(); + + address[0] = AF_INET as u8; + assert_eq!( + sys_connect( + &mut proc, + &mut host, + client, + &address[..address_len], + ) + .unwrap_err(), + Errno::EAFNOSUPPORT, + ); + assert_eq!( + proc.sockets.get(client_idx).unwrap().state, + SocketState::Unbound, + ); + assert_eq!(host.lstat_paths.len(), resolved_paths_before); + + address[0] = AF_UNIX as u8; + sys_connect( + &mut proc, + &mut host, + client, + &address[..address_len], + ) + .unwrap(); + assert_eq!( + proc.sockets.get(client_idx).unwrap().state, + SocketState::Connected, + "the matching-family control must still connect normally", + ); + registry.unregister(&resolved); + } + #[test] fn test_unix_dgram_connect_missing_peer_is_econnrefused() { let mut proc = Process::new(1); @@ -32395,6 +33126,104 @@ mod tests { ); } + #[test] + fn test_sendto_validates_sockaddr_before_datagram_state_mutation() { + use crate::socket::SocketState; + use wasm_posix_shared::socket::*; + + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + let mut proc = Process::new(9074); + let mut host = MockHostIO::new(); + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); + let sock_idx = test_socket_idx(&proc, fd); + let initial_ephemeral_port = proc.next_ephemeral_port; + let mut destination = [0u8; 16]; + destination[0] = AF_UNIX as u8; + destination[3] = 9; + destination[4..8].copy_from_slice(&[127, 0, 0, 1]); + + assert_eq!( + sys_sendto( + &mut proc, + &mut host, + fd, + b"x", + 0, + &destination, + ) + .unwrap_err(), + Errno::EAFNOSUPPORT, + ); + assert_eq!( + sys_sendto( + &mut proc, + &mut host, + fd, + b"x", + 0, + &destination[..7], + ) + .unwrap_err(), + Errno::EINVAL, + ); + let socket = proc.sockets.get(sock_idx).unwrap(); + assert_eq!(socket.state, SocketState::Unbound); + assert_eq!(socket.bind_port, 0); + assert_eq!(proc.next_ephemeral_port, initial_ephemeral_port); + assert!(host.net_connect_calls.is_empty()); + + destination[0] = AF_INET as u8; + let matching_family = + sys_sendto(&mut proc, &mut host, fd, b"x", 0, &destination); + assert!( + !matches!( + matching_family, + Err(Errno::EAFNOSUPPORT | Errno::EINVAL) + ), + "the matching-family control must pass address parsing", + ); + + let unix_fd = + sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + let unix_idx = test_socket_idx(&proc, unix_fd); + let wrong_unix_family = [ + AF_INET as u8, + 0, + 0, + b'x', + ]; + assert_eq!( + sys_sendto( + &mut proc, + &mut host, + unix_fd, + b"x", + 0, + &wrong_unix_family, + ) + .unwrap_err(), + Errno::EAFNOSUPPORT, + ); + assert_eq!( + proc.sockets.get(unix_idx).unwrap().state, + SocketState::Unbound, + ); + let matching_unix_family = [AF_UNIX as u8, 0, 0, b'x']; + assert_eq!( + sys_sendto( + &mut proc, + &mut host, + unix_fd, + b"x", + 0, + &matching_unix_family, + ) + .unwrap_err(), + Errno::ECONNREFUSED, + "the matching AF_UNIX control must pass family validation", + ); + } + #[test] fn test_socket_buffer_requests_do_not_fabricate_applied_capacity() { let mut proc = Process::new(9061); @@ -32824,41 +33653,230 @@ mod tests { #[test] fn test_multicast_group_request_offsets_cover_wasm32_and_wasm64() { + use wasm_posix_shared::process_layout::{ + multicast_group_request as layout, WASM32_POINTER_WIDTH, WASM64_POINTER_WIDTH, + }; + assert_eq!( - multicast_group_request_offsets(&[0u8; 132], false).unwrap(), + multicast_group_request_offsets( + &[0u8; layout::WASM32_GROUP_REQ_SIZE as usize], + false, + WASM32_POINTER_WIDTH, + ) + .unwrap(), (4, None) ); assert_eq!( - multicast_group_request_offsets(&[0u8; 136], false).unwrap(), + multicast_group_request_offsets( + &[0u8; layout::WASM64_GROUP_REQ_SIZE as usize], + false, + WASM64_POINTER_WIDTH, + ) + .unwrap(), (8, None) ); assert_eq!( - multicast_group_request_offsets(&[0u8; 260], true).unwrap(), + multicast_group_request_offsets( + &[0u8; layout::WASM32_GROUP_SOURCE_REQ_SIZE as usize], + true, + WASM32_POINTER_WIDTH, + ) + .unwrap(), (4, Some(132)) ); assert_eq!( - multicast_group_request_offsets(&[0u8; 264], true).unwrap(), + multicast_group_request_offsets( + &[0u8; layout::WASM64_GROUP_SOURCE_REQ_SIZE as usize], + true, + WASM64_POINTER_WIDTH, + ) + .unwrap(), (8, Some(136)) ); assert_eq!( - multicast_group_request_offsets(&[0u8; 131], false).unwrap_err(), + multicast_group_request_offsets( + &[0u8; layout::WASM32_GROUP_REQ_SIZE as usize - 1], + false, + WASM32_POINTER_WIDTH, + ) + .unwrap_err(), + Errno::EINVAL + ); + assert_eq!( + multicast_group_request_offsets( + &[0u8; layout::WASM64_GROUP_SOURCE_REQ_SIZE as usize - 1], + true, + WASM64_POINTER_WIDTH, + ) + .unwrap_err(), Errno::EINVAL ); - let mut oversized32 = [0u8; 140]; - oversized32[4] = wasm_posix_shared::socket::AF_INET as u8; + // Alternate-offset family-looking padding can never steer the model. + let mut oversized = [0u8; 264]; + oversized[4] = wasm_posix_shared::socket::AF_INET as u8; + oversized[8] = wasm_posix_shared::socket::AF_INET as u8; assert_eq!( - multicast_group_request_offsets(&oversized32, false).unwrap(), + multicast_group_request_offsets(&oversized, false, WASM32_POINTER_WIDTH).unwrap(), (4, None) ); - let mut ambiguous = oversized32; - ambiguous[8] = wasm_posix_shared::socket::AF_INET as u8; assert_eq!( - multicast_group_request_offsets(&ambiguous, false).unwrap_err(), + multicast_group_request_offsets(&oversized, false, WASM64_POINTER_WIDTH).unwrap(), + (8, None) + ); + assert_eq!( + multicast_group_request_offsets(&oversized, false, 16).unwrap_err(), Errno::EINVAL ); } + #[test] + fn test_parse_ipv4_multicast_request_covers_every_structured_option_and_model() { + use wasm_posix_shared::process_layout::{ + multicast_group_request as layout, WASM32_POINTER_WIDTH, WASM64_POINTER_WIDTH, + }; + use wasm_posix_shared::socket::*; + + const GROUP: [u8; 4] = [239, 1, 2, 3]; + const INTERFACE: [u8; 4] = [127, 0, 0, 1]; + const SOURCE: [u8; 4] = [10, 20, 30, 40]; + + let write_sockaddr = |buf: &mut [u8], offset: usize, address: [u8; 4]| { + buf[offset..offset + 2].copy_from_slice(&(AF_INET as u16).to_le_bytes()); + buf[offset + 4..offset + 8].copy_from_slice(&address); + }; + + for pointer_width in [WASM32_POINTER_WIDTH, WASM64_POINTER_WIDTH] { + for optname in [IP_ADD_MEMBERSHIP, IP_DROP_MEMBERSHIP] { + let mut exact = [0u8; 8]; + exact[0..4].copy_from_slice(&GROUP); + exact[4..8].copy_from_slice(&INTERFACE); + assert_eq!( + parse_ipv4_multicast_request(&exact, optname, pointer_width).unwrap(), + (GROUP, INTERFACE, None) + ); + assert_eq!( + parse_ipv4_multicast_request(&exact[..7], optname, pointer_width).unwrap_err(), + Errno::EINVAL + ); + } + + for optname in [ + IP_BLOCK_SOURCE, + IP_UNBLOCK_SOURCE, + IP_ADD_SOURCE_MEMBERSHIP, + IP_DROP_SOURCE_MEMBERSHIP, + ] { + let mut exact = [0u8; 12]; + exact[0..4].copy_from_slice(&GROUP); + exact[4..8].copy_from_slice(&INTERFACE); + exact[8..12].copy_from_slice(&SOURCE); + assert_eq!( + parse_ipv4_multicast_request(&exact, optname, pointer_width).unwrap(), + (GROUP, INTERFACE, Some(SOURCE)) + ); + assert_eq!( + parse_ipv4_multicast_request(&exact[..11], optname, pointer_width).unwrap_err(), + Errno::EINVAL + ); + } + + let (group_size, group_offset, source_size, source_offset) = + if pointer_width == WASM32_POINTER_WIDTH { + ( + layout::WASM32_GROUP_REQ_SIZE as usize, + layout::WASM32_GROUP_OFFSET as usize, + layout::WASM32_GROUP_SOURCE_REQ_SIZE as usize, + layout::WASM32_SOURCE_OFFSET as usize, + ) + } else { + ( + layout::WASM64_GROUP_REQ_SIZE as usize, + layout::WASM64_GROUP_OFFSET as usize, + layout::WASM64_GROUP_SOURCE_REQ_SIZE as usize, + layout::WASM64_SOURCE_OFFSET as usize, + ) + }; + + for optname in [MCAST_JOIN_GROUP, MCAST_LEAVE_GROUP] { + let mut oversized = vec![0u8; group_size + 8]; + oversized[0..4].copy_from_slice(&1u32.to_le_bytes()); + write_sockaddr(&mut oversized, group_offset, GROUP); + assert_eq!( + parse_ipv4_multicast_request( + &oversized[..group_size], + optname, + pointer_width, + ) + .unwrap(), + (GROUP, INTERFACE, None) + ); + assert_eq!( + parse_ipv4_multicast_request(&oversized, optname, pointer_width).unwrap(), + (GROUP, INTERFACE, None) + ); + assert_eq!( + parse_ipv4_multicast_request( + &oversized[..group_size - 1], + optname, + pointer_width, + ) + .unwrap_err(), + Errno::EINVAL + ); + } + + for optname in [ + MCAST_BLOCK_SOURCE, + MCAST_UNBLOCK_SOURCE, + MCAST_JOIN_SOURCE_GROUP, + MCAST_LEAVE_SOURCE_GROUP, + ] { + let mut oversized = vec![0u8; source_size + 8]; + oversized[0..4].copy_from_slice(&1u32.to_le_bytes()); + write_sockaddr(&mut oversized, group_offset, GROUP); + write_sockaddr(&mut oversized, source_offset, SOURCE); + assert_eq!( + parse_ipv4_multicast_request( + &oversized[..source_size], + optname, + pointer_width, + ) + .unwrap(), + (GROUP, INTERFACE, Some(SOURCE)) + ); + assert_eq!( + parse_ipv4_multicast_request(&oversized, optname, pointer_width).unwrap(), + (GROUP, INTERFACE, Some(SOURCE)) + ); + assert_eq!( + parse_ipv4_multicast_request( + &oversized[..source_size - 1], + optname, + pointer_width, + ) + .unwrap_err(), + Errno::EINVAL + ); + } + + let invalid_size = if pointer_width == WASM32_POINTER_WIDTH { + layout::WASM32_GROUP_REQ_SIZE + } else { + layout::WASM64_GROUP_REQ_SIZE + } as usize; + assert_eq!( + parse_ipv4_multicast_request( + &vec![0u8; invalid_size], + MCAST_JOIN_GROUP, + pointer_width, + ) + .unwrap_err(), + Errno::EAFNOSUPPORT + ); + } + } + #[test] fn test_ipv4_multicast_source_membership_and_interface_match() { let mut proc = Process::new(1); diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index 693626febf..b995febd3e 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -23,7 +23,8 @@ use core::slice; use wasm_posix_shared::{ abi::extended_syscalls as syscall_numbers, channel_scalar::{self, ChannelResultKind}, - platform_limits, Errno, KernelWaitResult, WasmDirent, WasmStat, WasmStatfs, WasmTimespec, + platform_limits, process_snapshot_wire, Errno, KernelWaitResult, WasmDirent, WasmStat, + WasmStatfs, WasmTimespec, }; use crate::channel_result::{checked_mmap_byte_offset, ChannelDispatchOutcome}; @@ -35,6 +36,9 @@ use crate::process::{ normalize_posix_timer_signo, HostAppendOutcome, HostIO, Process, ProcessState, StdioConfig, StdioKind, }; +use crate::process_snapshot_wire::{ + process_snapshot_record_bytes, write_process_snapshot_record, ProcessSnapshotHeader, +}; use crate::signal::{ apply_default_signal_action_with_locks, deliver_pending_signals_for_tid_with_locks, deliver_pending_signals_with_locks, dequeue_signal_for, terminate_process_by_signal_with_locks, @@ -1620,53 +1624,30 @@ pub extern "C" fn kernel_set_process_credentials(pid: u32, uid: u32, gid: u32) - } } -/// Set the argv for a process. -/// The argv is a null-separated concatenation of arguments. -/// Called by host to populate /proc//cmdline. -/// Returns 0 on success, -ESRCH if pid not found. -#[unsafe(no_mangle)] -pub extern "C" fn kernel_set_process_argv(pid: u32, data_ptr: *const u8, data_len: u32) -> i32 { - let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - if let Some(proc) = table.get_mut(pid) { - let data = unsafe { core::slice::from_raw_parts(data_ptr, data_len as usize) }; - proc.argv.clear(); - // Split on null bytes - for arg in data.split(|&b| b == 0) { - if !arg.is_empty() { - proc.argv.push(arg.to_vec()); - } - } - 0 - } else { - -(Errno::ESRCH as i32) - } -} - -/// Clear one process string vector before bounded, entry-at-a-time replacement. +/// Begin one Rust-owned argv/environment replacement. /// -/// `kind == 0` selects argv and `kind == 1` selects the environment. The host -/// uses this together with `kernel_push_process_metadata_entry` instead of -/// copying an arbitrarily large NUL-joined payload into its fixed-size scratch -/// allocation. Clearing without any subsequent pushes deliberately represents -/// an empty vector, which is required when exec installs an empty environment. +/// The returned positive token owns two initially empty staging vectors. The +/// live Process remains unchanged until one matching commit replaces both. #[unsafe(no_mangle)] -pub extern "C" fn kernel_clear_process_metadata(pid: u32, kind: u32) -> i32 { +pub extern "C" fn kernel_process_metadata_begin(pid: u32) -> i32 { let table = unsafe { &mut *PROCESS_TABLE.0.get() }; let Some(proc) = table.get_mut(pid) else { return -(Errno::ESRCH as i32); }; - match proc.clear_metadata(kind) { - Ok(()) => 0, + match proc.begin_metadata_replacement() { + Ok(token) => token as i32, Err(e) => -(e as i32), } } -/// Append one argv or environment entry from the host's bounded scratch area. -/// Empty entries are preserved; entry boundaries are supplied by the call -/// itself rather than inferred from NUL bytes. +/// Stage one argv or environment entry from the host's bounded scratch lease. +/// +/// Rust copies the complete entry before returning. Empty entries are +/// preserved; a failed stage permanently makes this token uncommittable. #[unsafe(no_mangle)] -pub extern "C" fn kernel_push_process_metadata_entry( +pub extern "C" fn kernel_process_metadata_stage( pid: u32, + token: u32, kind: u32, data_ptr: *const u8, data_len: u32, @@ -1676,7 +1657,36 @@ pub extern "C" fn kernel_push_process_metadata_entry( return -(Errno::ESRCH as i32); }; let data = unsafe { core::slice::from_raw_parts(data_ptr, data_len as usize) }; - match proc.push_metadata_entry(kind, data) { + match proc.stage_metadata_entry(token, kind, data) { + Ok(()) => 0, + Err(e) => -(e as i32), + } +} + +/// Atomically publish both vectors owned by the matching transaction. +/// +/// All entry allocations completed during staging. Commit performs no host +/// import and no fallible allocation between the argv and environment swaps. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_process_metadata_commit(pid: u32, token: u32) -> i32 { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let Some(proc) = table.get_mut(pid) else { + return -(Errno::ESRCH as i32); + }; + match proc.commit_metadata_replacement(token) { + Ok(()) => 0, + Err(e) => -(e as i32), + } +} + +/// Drop one uncommitted replacement without changing live process metadata. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_process_metadata_cancel(pid: u32, token: u32) -> i32 { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let Some(proc) = table.get_mut(pid) else { + return -(Errno::ESRCH as i32); + }; + match proc.cancel_metadata_replacement(token) { Ok(()) => 0, Err(e) => -(e as i32), } @@ -2185,26 +2195,54 @@ pub extern "C" fn kernel_get_fork_exec_path_pid(pid: u32, buf_ptr: *mut u8, buf_ } /// Get the CWD for a specific process. -/// Writes CWD to buf, returns bytes written, negative errno on error. +/// +/// A zero capacity queries the complete byte length without dereferencing the +/// pointer. A positive short capacity returns `-ERANGE` without writing. +/// Otherwise this writes the complete CWD and returns its byte length. #[unsafe(no_mangle)] pub extern "C" fn kernel_get_cwd(pid: u32, buf_ptr: *mut u8, buf_len: u32) -> i32 { let table = unsafe { &mut *PROCESS_TABLE.0.get() }; match table.get(pid) { - Some(proc) => { - let len = proc.cwd.len().min(buf_len as usize); - let buf = unsafe { core::slice::from_raw_parts_mut(buf_ptr, len) }; - buf.copy_from_slice(&proc.cwd[..len]); - len as i32 - } + Some(proc) => unsafe { + crate::complete_copy::copy_complete_bytes(&proc.cwd, buf_ptr, buf_len) + }, None => -(Errno::ESRCH as i32), } } /// Get the file path for an fd in a specific process. /// Used by the host to resolve fexecve fd paths. -/// Writes path to buf, returns bytes written, negative errno on error. +/// +/// The zero-capacity query and complete-or-`ERANGE` copy contract matches +/// `kernel_get_cwd`. #[unsafe(no_mangle)] pub extern "C" fn kernel_get_fd_path(pid: u32, fd: i32, buf_ptr: *mut u8, buf_len: u32) -> i32 { + kernel_copy_fd_path(pid, fd, buf_ptr, buf_len, false) +} + +/// Get the directory path for a dirfd in a specific process. +/// +/// Relative `execveat` and *at-style host lookups must prove that their base +/// descriptor is a directory before joining its path. This has the same +/// zero-capacity query and complete-or-`ERANGE` copy contract as +/// `kernel_get_fd_path`, but returns `-ENOTDIR` for another file type. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_get_dirfd_path( + pid: u32, + fd: i32, + buf_ptr: *mut u8, + buf_len: u32, +) -> i32 { + kernel_copy_fd_path(pid, fd, buf_ptr, buf_len, true) +} + +fn kernel_copy_fd_path( + pid: u32, + fd: i32, + buf_ptr: *mut u8, + buf_len: u32, + require_directory: bool, +) -> i32 { let table = unsafe { &mut *PROCESS_TABLE.0.get() }; match table.get(pid) { Some(proc) => match proc.fd_table.get(fd) { @@ -2212,13 +2250,19 @@ pub extern "C" fn kernel_get_fd_path(pid: u32, fd: i32, buf_ptr: *mut u8, buf_le let ofd_idx = entry.ofd_ref.0; match proc.ofd_table.get(ofd_idx) { Some(ofd) => { + if require_directory && ofd.file_type != FileType::Directory { + return -(Errno::ENOTDIR as i32); + } if ofd.path.is_empty() { return -(Errno::ENOENT as i32); } - let len = ofd.path.len().min(buf_len as usize); - let buf = unsafe { core::slice::from_raw_parts_mut(buf_ptr, len) }; - buf.copy_from_slice(&ofd.path[..len]); - len as i32 + unsafe { + crate::complete_copy::copy_complete_bytes( + &ofd.path, + buf_ptr, + buf_len, + ) + } } None => -(Errno::EBADF as i32), } @@ -2286,8 +2330,7 @@ pub extern "C" fn kernel_enum_procs(out_ptr: *mut u8, out_len: u32) -> i32 { // First pass: compute total bytes we need to write so we can fail fast // on a too-small buffer rather than partial-writing. Skip zombies on // the count too so the size estimate matches what we actually emit. - const HDR_BYTES: usize = 4 + 4 + 4 + 4 + 4 + 8 + 4 + 4 + 4; // 40 bytes per record - let mut need: usize = 4; // count u32 + let mut need: usize = process_snapshot_wire::RECORDS_OFFSET; for pid in &pids { let proc = match table.get(*pid) { Some(p) => p, @@ -2301,17 +2344,32 @@ pub extern "C" fn kernel_enum_procs(out_ptr: *mut u8, out_len: u32) -> i32 { } let cmdline = crate::procfs::generate_cmdline(proc); let comm = process_name_bytes(proc); - need += HDR_BYTES + comm.len() + cmdline.len(); + let record_bytes = match process_snapshot_record_bytes(comm.len(), cmdline.len()) { + Ok(bytes) => bytes, + Err(errno) => return -(errno as i32), + }; + need = match need.checked_add(record_bytes) { + Some(size) => size, + None => return -(Errno::EOVERFLOW as i32), + }; } if need > out_len as usize { return -(Errno::ENOSPC as i32); } + if need > i32::MAX as usize { + return -(Errno::EOVERFLOW as i32); + } + if out_ptr.is_null() { + return -(Errno::EFAULT as i32); + } - let buf = unsafe { core::slice::from_raw_parts_mut(out_ptr, out_len as usize) }; - let mut off: usize = 0; + let buf = unsafe { core::slice::from_raw_parts_mut(out_ptr, need) }; + let mut off = process_snapshot_wire::RECORDS_OFFSET; // count placeholder — patched after we finish walking. - write_u32(buf, &mut off, 0); + buf[process_snapshot_wire::COUNT_OFFSET + ..process_snapshot_wire::COUNT_OFFSET + process_snapshot_wire::COUNT_BYTES] + .copy_from_slice(&0_u32.to_le_bytes()); let mut written: u32 = 0; for pid in &pids { @@ -2338,25 +2396,41 @@ pub extern "C" fn kernel_enum_procs(out_ptr: *mut u8, out_len: u32) -> i32 { }; let vsize: u64 = proc.memory.mappings().iter().map(|r| r.len as u64).sum(); - write_u32(buf, &mut off, proc.pid); - write_u32(buf, &mut off, proc.ppid); - write_u32(buf, &mut off, proc.euid); - write_u32(buf, &mut off, proc.egid); - write_u64(buf, &mut off, vsize); - write_u32(buf, &mut off, state); - write_u32(buf, &mut off, comm.len() as u32); - write_u32(buf, &mut off, cmdline.len() as u32); - let cn = comm.len(); - buf[off..off + cn].copy_from_slice(&comm); - off += cn; - let cm = cmdline.len(); - buf[off..off + cm].copy_from_slice(&cmdline); - off += cm; + let comm_len = match u32::try_from(comm.len()) { + Ok(length) => length, + Err(_) => return -(Errno::EOVERFLOW as i32), + }; + let cmdline_len = match u32::try_from(cmdline.len()) { + Ok(length) => length, + Err(_) => return -(Errno::EOVERFLOW as i32), + }; + if write_process_snapshot_record( + buf, + &mut off, + &ProcessSnapshotHeader { + pid: proc.pid, + ppid: proc.ppid, + uid: proc.euid, + gid: proc.egid, + vsize, + state, + comm_len, + cmdline_len, + }, + &comm, + &cmdline, + ) + .is_err() + { + return -(Errno::EIO as i32); + } written += 1; } // Patch the count. let count_bytes = written.to_le_bytes(); - buf[0..4].copy_from_slice(&count_bytes); + buf[process_snapshot_wire::COUNT_OFFSET + ..process_snapshot_wire::COUNT_OFFSET + process_snapshot_wire::COUNT_BYTES] + .copy_from_slice(&count_bytes); off as i32 } @@ -2383,15 +2457,6 @@ pub extern "C" fn kernel_read_proc_maps(pid: u32, out_ptr: *mut u8, out_len: u32 // they're tied to the host-callable wire format, not the user-visible procfs // text generators. -fn write_u32(buf: &mut [u8], off: &mut usize, v: u32) { - buf[*off..*off + 4].copy_from_slice(&v.to_le_bytes()); - *off += 4; -} -fn write_u64(buf: &mut [u8], off: &mut usize, v: u64) { - buf[*off..*off + 8].copy_from_slice(&v.to_le_bytes()); - *off += 8; -} - /// Process name (basename of argv[0], or "[kernel]" for an empty argv). /// Mirrors `process_name(proc)` from procfs.rs but returns bytes directly so /// we don't bounce through `&str` formatting. @@ -3733,12 +3798,13 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr }, ) } // SYS_GETSOCKOPT - 59 => kernel_setsockopt( + 59 => kernel_setsockopt_for_process_width( a1, a2 as u32, a3 as u32, channel_const_ptr!(3, u8), channel_scalar::u32_argument(59, args, 4), + a6 as u32, ), // SYS_SETSOCKOPT 114 => kernel_getsockname(a1, channel_mut_ptr!(1, u8), channel_mut_ptr!(2, u32)), // SYS_GETSOCKNAME 115 => kernel_getpeername(a1, channel_mut_ptr!(1, u8), channel_mut_ptr!(2, u32)), // SYS_GETPEERNAME @@ -8794,7 +8860,8 @@ pub extern "C" fn kernel_environ_count() -> u32 { } /// Read the environment variable at `index` as "KEY=VALUE" into buf. -/// Returns the number of bytes written, or negative errno on error. +/// The zero-capacity query and complete-or-`ERANGE` copy contract matches +/// `kernel_argv_read`. #[unsafe(no_mangle)] pub extern "C" fn kernel_environ_get(index: u32, buf_ptr: *mut u8, buf_len: u32) -> i32 { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; @@ -8803,12 +8870,7 @@ pub extern "C" fn kernel_environ_get(index: u32, buf_ptr: *mut u8, buf_len: u32) return -(Errno::EINVAL as i32); } let entry = &proc.environ[idx]; - let buf = unsafe { slice::from_raw_parts_mut(buf_ptr, buf_len as usize) }; - if buf.len() < entry.len() { - return -(Errno::ERANGE as i32); - } - buf[..entry.len()].copy_from_slice(entry); - entry.len() as i32 + unsafe { crate::complete_copy::copy_complete_bytes(entry, buf_ptr, buf_len) } } // --------------------------------------------------------------------------- @@ -8837,17 +8899,18 @@ pub extern "C" fn kernel_get_argc() -> u32 { proc.argv.len() as u32 } -/// Copy argument at `index` into `buf_ptr`. Returns bytes written. +/// Copy argument at `index` into `buf_ptr`. +/// +/// A zero capacity queries the complete length. A positive short capacity +/// returns `-ERANGE` without writing; otherwise the complete entry is copied. #[unsafe(no_mangle)] -pub extern "C" fn kernel_argv_read(index: u32, buf_ptr: *mut u8, buf_max: u32) -> u32 { +pub extern "C" fn kernel_argv_read(index: u32, buf_ptr: *mut u8, buf_max: u32) -> i32 { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; - if let Some(arg) = proc.argv.get(index as usize) { - let len = arg.len().min(buf_max as usize); - let dst = unsafe { slice::from_raw_parts_mut(buf_ptr, len) }; - dst.copy_from_slice(&arg[..len]); - len as u32 - } else { - 0 + match proc.argv.get(index as usize) { + Some(arg) => unsafe { + crate::complete_copy::copy_complete_bytes(arg, buf_ptr, buf_max) + }, + None => -(Errno::EINVAL as i32), } } @@ -9942,8 +10005,33 @@ pub extern "C" fn kernel_setsockopt( optname: u32, optval_ptr: *const u8, optlen: u32, +) -> i32 { + kernel_setsockopt_for_process_width( + fd, + level, + optname, + optval_ptr, + optlen, + size_of::() as u32, + ) +} + +fn kernel_setsockopt_for_process_width( + fd: i32, + level: u32, + optname: u32, + optval_ptr: *const u8, + optlen: u32, + process_pointer_width: u32, ) -> i32 { use wasm_posix_shared::socket::*; + if !matches!( + process_pointer_width, + wasm_posix_shared::process_layout::WASM32_POINTER_WIDTH + | wasm_posix_shared::process_layout::WASM64_POINTER_WIDTH + ) { + return -(Errno::EINVAL as i32); + } let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; // Handle struct timeval options (SO_RCVTIMEO, SO_SNDTIMEO). @@ -10053,82 +10141,8 @@ pub extern "C" fn kernel_setsockopt( } let buf = unsafe { slice::from_raw_parts(optval_ptr, optlen as usize) }; - let parse_sockaddr_in_at = |offset: usize| -> Result<[u8; 4], Errno> { - if buf.len() < offset + 8 { - return Err(Errno::EINVAL); - } - let family = u16::from_le_bytes([buf[offset], buf[offset + 1]]); - if family as u32 != AF_INET { - return Err(Errno::EAFNOSUPPORT); - } - Ok([ - buf[offset + 4], - buf[offset + 5], - buf[offset + 6], - buf[offset + 7], - ]) - }; - - let parse_ifindex_at = |offset: usize| -> Result<[u8; 4], Errno> { - if buf.len() < offset + 4 { - return Err(Errno::EINVAL); - } - let ifindex = u32::from_le_bytes(buf[offset..offset + 4].try_into().unwrap()); - syscalls::ipv4_multicast_interface_from_index(ifindex) - }; - - let parsed = (|| -> Result<([u8; 4], [u8; 4], Option<[u8; 4]>), Errno> { - match optname { - IP_ADD_MEMBERSHIP | IP_DROP_MEMBERSHIP => { - if buf.len() < 8 { - Err(Errno::EINVAL) - } else { - Ok(( - [buf[0], buf[1], buf[2], buf[3]], - [buf[4], buf[5], buf[6], buf[7]], - None, - )) - } - } - IP_BLOCK_SOURCE - | IP_UNBLOCK_SOURCE - | IP_ADD_SOURCE_MEMBERSHIP - | IP_DROP_SOURCE_MEMBERSHIP => { - if buf.len() < 12 { - Err(Errno::EINVAL) - } else { - Ok(( - [buf[0], buf[1], buf[2], buf[3]], - [buf[4], buf[5], buf[6], buf[7]], - Some([buf[8], buf[9], buf[10], buf[11]]), - )) - } - } - MCAST_JOIN_GROUP | MCAST_LEAVE_GROUP => { - let (group_offset, _) = syscalls::multicast_group_request_offsets(buf, false)?; - Ok(( - parse_sockaddr_in_at(group_offset)?, - parse_ifindex_at(0)?, - None, - )) - } - MCAST_BLOCK_SOURCE - | MCAST_UNBLOCK_SOURCE - | MCAST_JOIN_SOURCE_GROUP - | MCAST_LEAVE_SOURCE_GROUP => { - let (group_offset, source_offset) = - syscalls::multicast_group_request_offsets(buf, true)?; - Ok(( - parse_sockaddr_in_at(group_offset)?, - parse_ifindex_at(0)?, - Some(parse_sockaddr_in_at( - source_offset.expect("source request has source offset"), - )?), - )) - } - _ => unreachable!(), - } - })(); + let parsed = + syscalls::parse_ipv4_multicast_request(buf, optname, process_pointer_width); let result = match parsed.and_then(|(group, interface_addr, source)| { syscalls::sys_setsockopt_ipv4_multicast( diff --git a/crates/shared/src/host_abi.rs b/crates/shared/src/host_abi.rs index bcd17ede3b..4dd06b6849 100644 --- a/crates/shared/src/host_abi.rs +++ b/crates/shared/src/host_abi.rs @@ -5,7 +5,7 @@ //! calling `kernel_handle_channel`. The host still owns the memory copies and //! platform scheduling; Rust owns the ABI-sensitive syscall argument shapes. -use core::mem::size_of; +use core::mem::{offset_of, size_of}; use crate::abi::extended_syscalls as extra_syscalls; use crate::process_layout; @@ -51,6 +51,19 @@ pub enum SyscallArgSize { ProcessLayout { wasm32_size: u32, wasm64_size: u32 }, } +/// An exceptional source for the number of output bytes copied back to the +/// caller. +/// +/// Most `Out` arguments either publish their declared fixed capacity, the +/// syscall return value for `Arg`-sized byte buffers, or a dereferenced length. +/// Protocols whose return value has a different unit must declare the actual +/// byte count explicitly rather than relying on the host's default convention. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyscallArgCopyOutLength { + /// Read a little-endian `u32` field from another staged argument. + U32Field { arg_index: u8, offset: u32 }, +} + /// One pointer argument descriptor for host-side marshalling. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SyscallArgDesc { @@ -65,6 +78,19 @@ pub struct SyscallArgDesc { pub nullable: bool, /// Whether a positive-sized pointer must be non-null. pub required: bool, + /// Overrides the ordinary copy-back length convention when the syscall + /// return value is not a byte count. + pub copy_out_length: Option, +} + +impl SyscallArgDesc { + const fn with_copy_out_u32_field(mut self, arg_index: u8, offset: u32) -> Self { + self.copy_out_length = Some(SyscallArgCopyOutLength::U32Field { + arg_index, + offset, + }); + self + } } /// All pointer argument descriptors for one syscall number. @@ -150,6 +176,7 @@ macro_rules! desc { size: $size, nullable: true, required: false, + copy_out_length: None, } }; ($arg_index:expr, $direction:ident, $size:expr, required) => { @@ -159,6 +186,7 @@ macro_rules! desc { size: $size, nullable: false, required: true, + copy_out_length: None, } }; } @@ -247,7 +275,10 @@ pub const SYSCALL_ARG_DESCRIPTORS: &[SyscallArgDescriptor] = &[ Syscall::Readdir as u32, [ desc!(1, Out, fixed!(16), required), - desc!(2, Out, arg!(3), required), + desc!(2, Out, arg!(3), required).with_copy_out_u32_field( + 1, + offset_of!(crate::WasmDirent, d_namlen) as u32, + ), ] ), entry!( @@ -1600,6 +1631,58 @@ mod tests { } } + #[test] + fn exceptional_copy_out_lengths_are_bounded_by_staged_records() { + let readdir = find(Syscall::Readdir as u32); + let name = readdir + .args + .iter() + .find(|arg| arg.arg_index == 2) + .expect("missing readdir name output"); + assert_eq!( + name.copy_out_length, + Some(SyscallArgCopyOutLength::U32Field { + arg_index: 1, + offset: offset_of!(crate::WasmDirent, d_namlen) as u32, + }) + ); + + for entry in SYSCALL_ARG_DESCRIPTORS { + for desc in entry.args { + let Some(SyscallArgCopyOutLength::U32Field { arg_index, offset }) = + desc.copy_out_length + else { + continue; + }; + assert_eq!( + desc.direction, + SyscallArgDirection::Out, + "syscall {} arg {} copy-out override must describe output", + entry.syscall_number, + desc.arg_index, + ); + assert!( + matches!(desc.size, SyscallArgSize::Arg { .. }), + "syscall {} arg {} copy-out override needs an explicit caller capacity", + entry.syscall_number, + desc.arg_index, + ); + let source = entry + .args + .iter() + .find(|candidate| candidate.arg_index == arg_index) + .expect("copy-out length source must be staged"); + let SyscallArgSize::Fixed { size } = source.size else { + panic!("copy-out u32 source must have a fixed staged size"); + }; + assert!( + offset.checked_add(size_of::() as u32).is_some_and(|end| end <= size), + "copy-out u32 field must fit its staged source", + ); + } + } + } + #[test] fn nested_pointer_syscalls_stay_out_of_simple_descriptors() { for syscall in [ diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index a10f3563e1..c936714e6e 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -136,6 +136,16 @@ pub mod platform_limits { /// This is not POSIX ARG_MAX; complete argv+env representation remains /// governed independently by ARG_MAX_BYTES. pub const PROCESS_METADATA_ENTRY_MAX_BYTES: usize = 65_536; + /// Maximum argv entries admitted through process creation and reconstructed + /// by the guest startup code. + /// + /// This is a defensive representation bound, not an additional POSIX + /// `ARG_MAX` promise. The complete pointer-plus-string representation must + /// still fit `ARG_MAX_BYTES`. + pub const PROCESS_STARTUP_MAX_ARGV_COUNT: usize = 4096; + /// Maximum environment entries admitted through process creation and + /// reconstructed by the guest startup code. + pub const PROCESS_STARTUP_MAX_ENVP_COUNT: usize = 4096; pub const NGROUPS_MAX: usize = 32; pub const SYSV_MSG_MAX_BYTES: usize = 8192; /// Largest successful byte count representable by the signed-i32 channel @@ -147,6 +157,39 @@ pub mod platform_limits { pub const IOV_MAX: usize = 1024; } +/// Host/kernel selectors for one atomic argv/environment replacement. +/// +/// These values cross the Wasm export boundary. Keep TypeScript consumers on +/// the generated constants rather than repeating kind literals in the host. +pub mod process_metadata_contract { + pub const KIND_ARGV: u32 = 0; + pub const KIND_ENVIRONMENT: u32 = 1; +} + +/// Packed host/kernel wire layout for one process-table snapshot record. +/// +/// This record is not a native Rust or C structure: the `u64` field is +/// deliberately packed at byte 16, so a native `repr(C)` structure would add +/// tail padding and report 40 bytes instead of the 36 bytes actually written. +/// Keep every producer and consumer on these generated offsets. +pub mod process_snapshot_wire { + use core::mem::size_of; + + pub const COUNT_OFFSET: usize = 0; + pub const COUNT_BYTES: usize = size_of::(); + pub const RECORDS_OFFSET: usize = COUNT_OFFSET + COUNT_BYTES; + + pub const PID_OFFSET: usize = 0; + pub const PPID_OFFSET: usize = PID_OFFSET + size_of::(); + pub const UID_OFFSET: usize = PPID_OFFSET + size_of::(); + pub const GID_OFFSET: usize = UID_OFFSET + size_of::(); + pub const VSIZE_OFFSET: usize = GID_OFFSET + size_of::(); + pub const STATE_OFFSET: usize = VSIZE_OFFSET + size_of::(); + pub const COMM_LEN_OFFSET: usize = STATE_OFFSET + size_of::(); + pub const CMDLINE_LEN_OFFSET: usize = COMM_LEN_OFFSET + size_of::(); + pub const HEADER_BYTES: usize = CMDLINE_LEN_OFFSET + size_of::(); +} + /// Cross-layer layout values and defensive limits for the non-forking spawn /// protocol. /// @@ -200,8 +243,8 @@ pub mod spawn_contract { pub const ATTR_SETSCHEDULER: u32 = 0x20; pub const ATTR_USEVFORK: u32 = 0x40; pub const ATTR_SETSID: u32 = 0x80; - pub const MAX_ARGV_COUNT: usize = 4096; - pub const MAX_ENVP_COUNT: usize = 4096; + pub const MAX_ARGV_COUNT: usize = platform_limits::PROCESS_STARTUP_MAX_ARGV_COUNT; + pub const MAX_ENVP_COUNT: usize = platform_limits::PROCESS_STARTUP_MAX_ENVP_COUNT; pub const MAX_ACTION_COUNT: usize = 1024; /// Complete transport ceiling: POSIX argv/environment budget plus the @@ -2833,7 +2876,6 @@ pub mod abi { "kernel_alloc_scratch", "kernel_blocking_retry_release", "kernel_blocking_retry_token", - "kernel_clear_process_metadata", "kernel_commit_process_exit", "kernel_create_process", "kernel_create_process_with_stdio", @@ -2841,6 +2883,9 @@ pub mod abi { "kernel_exec_prepare", "kernel_exec_setup_for_thread", "kernel_fork_process", + "kernel_get_cwd", + "kernel_get_dirfd_path", + "kernel_get_fd_path", "kernel_get_parent_pid", "kernel_get_process_exit_signal", "kernel_get_process_state", @@ -2860,7 +2905,10 @@ pub mod abi { "kernel_pick_signal_target_tid", "kernel_pipe_has_readers", "kernel_posix_timer_fire", - "kernel_push_process_metadata_entry", + "kernel_process_metadata_begin", + "kernel_process_metadata_cancel", + "kernel_process_metadata_commit", + "kernel_process_metadata_stage", "kernel_reap_exited_child", "kernel_remove_process", "kernel_semctl_array_bytes", diff --git a/crates/shared/src/process_layout.rs b/crates/shared/src/process_layout.rs index 23ecd58b8b..61daccf099 100644 --- a/crates/shared/src/process_layout.rs +++ b/crates/shared/src/process_layout.rs @@ -86,6 +86,24 @@ pub mod cmsghdr { pub const WASM64_DATA_OFFSET: u32 = 16; } +/// Caller-native multicast `group_req` and `group_source_req`. +/// +/// The embedded `sockaddr_storage` fields are four-byte aligned on wasm32 and +/// eight-byte aligned on wasm64. Option-buffer length and padding contents are +/// not a data-model discriminator; the host carries the caller width in the +/// channel's private sixth argument. +pub mod multicast_group_request { + pub const WASM32_GROUP_REQ_SIZE: u32 = 132; + pub const WASM32_GROUP_OFFSET: u32 = 4; + pub const WASM32_GROUP_SOURCE_REQ_SIZE: u32 = 260; + pub const WASM32_SOURCE_OFFSET: u32 = 132; + + pub const WASM64_GROUP_REQ_SIZE: u32 = 136; + pub const WASM64_GROUP_OFFSET: u32 = 8; + pub const WASM64_GROUP_SOURCE_REQ_SIZE: u32 = 264; + pub const WASM64_SOURCE_OFFSET: u32 = 136; +} + /// `stack_t` / `struct sigaltstack`. pub mod sigaltstack { pub const WASM32_SIZE: u32 = 12; diff --git a/docs/abi-versioning.md b/docs/abi-versioning.md index 0601d2650e..636347606b 100644 --- a/docs/abi-versioning.md +++ b/docs/abi-versioning.md @@ -495,6 +495,27 @@ import. A normal return makes it `Ready`; `kernel_transfer_scratch_cancel` then drops the allocation. The execute exports accept no host-selected pointer, so allocation capacity cannot be separated from ownership. +ABI 43 also permits a narrower, read-only use while a transfer token remains +`Reserved`. `kernel_get_cwd`, `kernel_get_fd_path`, and +`kernel_get_dirfd_path` produce complete canonical path snapshots before an +asynchronous spawn, exec, or shared-mapping callback. A zero destination +capacity queries the exact byte length without dereferencing the pointer; a +positive short capacity returns `ERANGE` without writing. The host first tries +the ordinary region, reserves the exact required transfer capacity only when +needed, invokes the producer synchronously, detaches every byte, revokes the +region, and cancels the still-`Reserved` token. It does not call an execute +export for this case: the getter itself is the one Rust operation, and the +`Reserved` state already prevents another reservation from moving or replacing +the allocation. + +Canonical CWD and descriptor paths are not limited to `PATH_MAX`. `PATH_MAX` +bounds one caller-supplied pathname; resolving that input against an +already-deep directory can create a longer internal absolute spelling. +Publishing a truncated prefix could select a different executable or mapping +backing. `kernel_get_dirfd_path` additionally requires the descriptor to name +a directory and returns `ENOTDIR` otherwise, while `kernel_get_fd_path` +retains the ordinary descriptor behavior required by `AT_EMPTY_PATH`. + A host-import trap can strand a reservation in `Executing`, where cancellation must reject rather than free memory that a callback may still have partially observed. The host therefore treats such a trap as a fatal kernel-generation @@ -569,6 +590,17 @@ truncating a wasm64 process record. Fixed generated descriptors separately carry `stat` (112 bytes) and `sched_param` (48 bytes); those records do not use width selection or the private process-width slot. +The channel `setsockopt` path uses the otherwise private sixth dispatch slot +for the same independently known caller width. The generated native +`group_req` layout is 132 bytes with its group at offset 4 on wasm32 and 136 +bytes with its group at offset 8 on wasm64; `group_source_req` is 260/264 +bytes with its source at offset 132/136. Rust accepts only widths 4 and 8. +Neither `optlen` nor padding bytes may select a data model. The public +five-argument `kernel_setsockopt` export is structurally unchanged and uses +the kernel's native width for direct calls; only channel dispatch consumes the +host-private width. Adding the generated layout constants and correcting this +interpretation remain part of unpublished ABI 43 and do not create ABI 44. + Signal and timer transport also change incompatibly in ABI 43. The `kernel_timer_create` export grows from three arguments to `(clock_id, sigevent_ptr, timerid_ptr, process_pointer_width)`, and its second @@ -588,11 +620,13 @@ The ABI 43 required host-adapter export set retains the ABI 42-required `kernel_spawn_process` and adds `kernel_blocking_retry_release`, `kernel_blocking_retry_token`, -`kernel_clear_process_metadata`, `kernel_commit_process_exit`, +`kernel_get_cwd`, `kernel_get_dirfd_path`, `kernel_get_fd_path`, `kernel_msqid_ds_bytes`, `kernel_semctl_array_bytes`, `kernel_semid_ds_bytes`, `kernel_shmid_ds_bytes`, -`kernel_push_process_metadata_entry`, `kernel_set_cwd`, +`kernel_process_metadata_begin`, `kernel_process_metadata_cancel`, +`kernel_process_metadata_commit`, `kernel_process_metadata_stage`, +`kernel_set_cwd`, `kernel_spawn_reserved_process`, `kernel_spawn_scratch_begin`, `kernel_spawn_scratch_cancel`, `kernel_spawn_scratch_capacity`, @@ -605,17 +639,55 @@ bookkeeping around additive constants. Kernels, hosts, packages, guest binaries, and VFS images from ABI 42 must be rebuilt rather than mixed with ABI 43 artifacts. +`kernel_enum_procs` keeps its existing two-argument export signature, but its +producer contract is now atomic and capacity-derived. Rust computes the +complete snapshot with checked arithmetic using the packed 36-byte +per-process header defined by +`wasm_posix_shared::process_snapshot_wire`, returns `ENOSPC` before any write +when the supplied allocation is short, and constructs only the exact required +output slice on success. Generated TypeScript constants and the ABI snapshot +pin the same count/header sizes and every field offset; the host fails closed +on a malformed later record instead of returning a valid prefix. This corrects +the former 40-versus-36-byte accounting mismatch and removes cross-language +layout duplication without granting authority from the remainder of linear +memory or introducing another ABI epoch. + ABI 43 has not been published as a compatibility epoch. The retry-token, large-transfer, fatal-lifetime, positioned-I/O, and append corrections amend that same pending ABI-43 contract and snapshot. They do not justify inventing ABI 44 merely to preserve an unreleased draft, and they must not be hidden under released ABI 42. -The metadata pair and cwd setter are required because process registration -uses them unconditionally. A same-version kernel may not fall back to the -historical aggregate argv setter or silently ignore initial cwd: either path -would accept boot while losing the bounded, capacity-owned transfer contract -that ABI 43 advertises. +The same pending epoch also makes process-startup argv/environment reads +complete-or-`ERANGE`. A zero destination capacity queries the exact immutable +entry length; a positive short capacity writes nothing, and an exact-capacity +retry must return the same complete length. The signed C/Rust result still has +the same Wasm `i32` function type, but the error semantics and rebuilt CRT are +an observable contract change. The CRT validates the generated 4,096/4,096 +entry caps and pointer-width-aware 4 MiB representation before allocating +guest-process memory through ordinary `mmap`; it never clamps a count or +copies a prefix into fixed 64/128 KiB buffers. This semantic correction belongs +in unpublished ABI 43 rather than being hidden under released ABI 42 or +creating an ABI 44 for an unreleased intermediate draft. + +The four metadata-transaction exports and cwd setter are required because +process registration uses them unconditionally. Begin returns a positive +process-bound token; each stage synchronously copies one capacity-checked +scratch entry into Rust-owned storage; commit swaps the complete argv and +environment pair without a fallible allocation or host import; and cancel +drops an uncommitted token without changing live metadata. A failed stage +permanently makes its token uncommittable. Partial replacement is not part of +the contract: the host supplies both vectors or neither, so its generated +count and aggregate `ARG_MAX` validation never ignores bytes preserved from +an earlier pair. This prevents both an allocation overflow and the subtler +clear-then-push failure in which a later `ENOMEM` exposed a live prefix. The +complete CWD, fd-path, and directory-fd-path getters are required +because relative spawn/exec and shared-mapping resolution cannot safely fall +back to a fixed or truncating query. A same-version kernel may not use the +historical aggregate argv setter or clear/push metadata exports, silently +ignore initial cwd, or omit one of those path queries: each fallback would +accept boot while losing the bounded, capacity-owned transfer and atomic +replacement contracts that ABI 43 advertises. The authoritative platform and spawn-wire constants remain generated from the Rust ABI sources. Moving identical constants to that generation path would not @@ -630,15 +702,13 @@ Rust authority and generated TypeScript consumers. Centralizing those unchanged values is bookkeeping, not another ABI change. The channel-handler signature, exhaustive pointer-nullability semantics, and option-sensitive `prctl` marshalling are also incompatible contract changes -within ABI 43, not bookkeeping-only generation changes. The historical -fixed-buffer baseline is exact #1094-based evidence. The growable design has -also been measured after retargeting onto #1097 in Node.js and real Chromium: -the dirty-source result retained 84,386 scratch bytes and ended at 17,694,720 -bytes of kernel linear memory in both hosts, with complete source and runtime -artifact fingerprints. It is historical evidence rather than the mutable -exact-head result. Exact-head Node.js and Chromium measurements belong in the -draft PR ledger after the commit is frozen, and the three-round timing samples -establish neither a latency improvement nor broad performance no-regression. +within ABI 43, not bookkeeping-only generation changes. Buffer sizing was +selected first for ownership and lifetime correctness. Retained-capacity, +peak-memory, and timing comparisons are not ABI facts: exact baseline and +candidate source identities, runtime-artifact fingerprints, workloads, and +separate Node.js/real-Chromium results belong in the draft PR evidence ledger +after the candidate is frozen. No latency improvement or broad performance +no-regression is claimed here. ## The snapshot @@ -648,7 +718,13 @@ captures: - `abi_version` — the integer [`ABI_VERSION`](../crates/shared/src/lib.rs). - `platform_limits` — the advertised `ARG_MAX`, `PATH_MAX`, and `IOV_MAX` - values generated into the TypeScript host and public musl headers. + values plus defensive process-startup argv/environment count caps generated + into the TypeScript host and public musl headers. +- `process_metadata_contract` — generated argv/environment kind selectors + consumed by the replace-both token-bound host/kernel transaction. +- `process_snapshot_wire` — the packed process-table count prefix, 36-byte + header, and every field offset shared by the Rust producer and TypeScript + parser. - `spawn_contract` — the complete non-forking spawn wire contract: syscall number, header and action layouts, opcodes, transported attribute bits, defensive count caps, public-limit aliases, and derived whole-blob ceiling. @@ -670,8 +746,9 @@ captures: layout shift. - `process_native_layouts` — the generated wasm32/wasm64 musl layouts used when the host reads native process records, including `iovec`, `msghdr`, - `cmsghdr`, `siginfo_t`, and `sigevent`, plus the shared socket constants - needed to interpret `SCM_RIGHTS`. + `cmsghdr`, `siginfo_t`, `sigevent`, `group_req`, and + `group_source_req`, plus the shared socket constants needed to interpret + `SCM_RIGHTS`. - `syscalls` — every syscall number named by the shared ABI metadata: the core `Syscall::from_u32` table plus `abi::extended_syscalls` entries for host-visible kernel/control syscalls that are not yet in @@ -725,23 +802,25 @@ writes the same bytes for the same input — the snapshot is a pure function of the checked-in source. The same generator also owns the cross-language consumers of these snapshotted -constants. Advertised `ARG_MAX`, `PATH_MAX`, and `IOV_MAX` live in +constants. Advertised `ARG_MAX`, `PATH_MAX`, and `IOV_MAX`, plus the +process-startup argv/environment count caps, live in `crates/shared/src/lib.rs::platform_limits`; `cargo xtask dump-abi` writes their TypeScript consumer and the public musl `bits/kandelo_limits.h`. The non-forking spawn wire contract lives separately in `crates/shared/src/lib.rs::spawn_contract`; the generator writes its C consumer to `libc/musl-overlay/src/process/wasm32posix/spawn_contract.h`. The private spawn -header aliases the public generated limits and adds the four-byte string-offset +header aliases the public generated limits—including the startup count +caps—and adds the four-byte string-offset width; all field offsets in the 40-byte header and 28-byte action record; the five action opcodes; musl's complete transported attribute byte; the -argv/environment/action count caps; and the derived 8,417,320-byte whole-blob +spawn-only action count cap; and the derived 8,417,320-byte whole-blob ceiling. Rust, TypeScript, and C therefore consume the same numeric wire contract. Transporting all eight attribute bits is distinct from implementing them: the kernel currently acts on `SETPGROUP`, `SETSIGDEF`, `SETSIGMASK`, and `SETSID`, while `RESETIDS`, `SETSCHEDPARAM`, `SETSCHEDULER`, and `USEVFORK` -remain uninterpreted. The count and complete-wire caps are defensive -parser/transport limits, not new POSIX promises. +remain uninterpreted. The shared startup counts and spawn-only action/complete +wire caps are defensive representation limits, not new POSIX promises. Channel scalar widths are likewise Rust-owned. The generator writes `host/src/generated/abi.ts` and diff --git a/docs/architecture.md b/docs/architecture.md index cadb13221e..74b3b3e6cd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -106,7 +106,14 @@ kernel_msqid_ds_bytes(process_pointer_width) → bytes | -errno kernel_semctl_array_bytes(pid, tid, semid, command) → bytes | -errno kernel_semid_ds_bytes(process_pointer_width) → bytes | -errno kernel_shmid_ds_bytes(process_pointer_width) → bytes | -errno -kernel_get_cwd(pid, buf, len) → bytes_written +kernel_get_cwd(pid, buf, capacity) → required_or_written_bytes | -errno +kernel_get_fd_path(pid, fd, buf, capacity) → required_or_written_bytes | -errno +kernel_get_dirfd_path(pid, fd, buf, capacity) → required_or_written_bytes | -errno +kernel_enum_procs(buf, capacity) → complete_snapshot_bytes | -errno +kernel_process_metadata_begin(pid) → transaction_token | -errno +kernel_process_metadata_stage(pid, transaction_token, kind, buf, len) → 0 | -errno +kernel_process_metadata_commit(pid, transaction_token) → 0 | -errno +kernel_process_metadata_cancel(pid, transaction_token) → 0 | -errno kernel_set_max_addr(pid, addr) → 0 kernel_set_brk_base(pid, addr) → 0 kernel_set_mmap_base(pid, addr) → 0 @@ -358,6 +365,13 @@ A retry or malformed kernel result publishes none of those detached outputs. This flatten/scatter design preserves the public multi-iovec behavior while keeping the ordinary transport allocation fixed and cheap. +Guest process memory is a separate owner, not another spelling for kernel +scratch. `CentralizedKernelWorker.registerProcess` rejects the active kernel +`WebAssembly.Memory` object as a process memory before entering any export or +publishing a channel. This identity check keeps later process-memory ranges, +framebuffers, mappings, and worker transport outside the kernel-allocation +model even if an internal caller accidentally passes the wrong memory object. + Scalar and vectored reads or writes at most `CH_DATA_SIZE` use the main channel region. The host validates the complete caller range or native iovec table, flattens a vector directly into the data area, and dispatches one scalar @@ -369,11 +383,34 @@ Larger operations reserve `crates/kernel/src/transfer.rs::TransferScratch`. Begin creates a fresh, initialized Rust-owned allocation and returns a positive token. The host reads the token's pointer and explicit capacity together, proves the current-memory -range, and copies under one synchronous lease. Execute changes the reservation -from `Reserved` to `Executing` before releasing the mutex and entering exactly -one scalar kernel operation. A normal return, including an errno, changes it -to `Ready`; cancellation then drops the allocation. No pointer-only execute -path exists. +range, and copies under one synchronous lease. For widened syscall and channel +execution, execute changes the reservation from `Reserved` to `Executing` +before releasing the mutex and entering exactly one kernel operation. A +normal return, including an errno, changes it to `Ready`; cancellation then +drops the allocation. No pointer-only execute path exists. + +Canonical CWD and open-file-description path snapshots use the same allocation +owner with a deliberately different `Reserved`-state lifetime. The host first +tries its ordinary main region. `ERANGE` triggers a zero-capacity required-size +query, followed by an exact `TransferScratch` reservation when necessary. +`kernel_get_cwd`, `kernel_get_fd_path`, or `kernel_get_dirfd_path` then writes +the complete result while that token remains `Reserved`; the host detaches the +bytes, revokes the region, and cancels the token before leaving the same +synchronous kernel entry. No promise, callback, or second reservation can +overlap that snapshot. A positive short capacity writes nothing and returns +`ERANGE`; zero capacity never dereferences its pointer. The directory-only +export additionally returns `ENOTDIR` for a non-directory descriptor, so +relative `execveat` and shared-mapping lookup cannot join a path against an +ordinary file. + +These returned canonical paths are not capped by `PATH_MAX`. That limit +constrains one caller-supplied pathname, not the absolute spelling produced by +resolving a short relative name from an already-deep CWD. Treating the +canonical output as a 4,096-byte object would either produce a false failure +or, worse, publish a prefix that names a different executable or mapping +backing. Host admission therefore requires all three complete-copy exports in +ABI 43 and retains only detached bytes across the later spawn, exec, or +mapping callback. A host-import trap can prevent Rust from leaving `Executing`. Cancellation must not free or reuse a region whose callback may have observed only a prefix, @@ -428,25 +465,17 @@ fail without replacing live bytes. The reservation-derived host region is single-use and is revoked after the attempt, so a later Rust-owned `Vec` growth cannot revive its old pointer/capacity pair. -The allocation lives until the kernel instance ends and may retain the -largest accepted blob seen. A three-round historical comparison used the -fixed-buffer kernel from exact #1094 head plus a fingerprinted host-only -telemetry shim. A post-retarget dirty-worktree rerun exercised the same -deterministic Homebrew-like workload on Node.js and real Chromium. In both -hosts, the growable design reported 84,386 bytes of retained scratch capacity -instead of 8,417,320 bytes, and post-run kernel linear memory was 17,694,720 -rather than the fixed design's 26,017,792 bytes. Because WebAssembly memory -cannot shrink, post-run memory was also that workload's peak. The dirty-source -run has complete source and runtime-artifact fingerprints but is historical -evidence. The three-round timing sample and baseline-harness provenance -establish neither a speedup nor broad no-regression. The exact -workload, fingerprints, host-specific medians, and remaining -application-suite block are recorded in -`docs/plans/2026-07-25-kernel-scratch-transfer-audit.md`. ABI 43 requires the -complete transactional export set. There is no older-kernel fixed-buffer -fallback under the same ABI version. Exact-head Node.js and Chromium results -belong in the draft PR ledger after the commit is frozen, so the evidence names -the head it actually exercised. +The allocation lives until the kernel instance ends and may retain the largest +accepted blob seen. Because WebAssembly memory cannot shrink, freeing or +replacing Rust allocations does not reduce the visible linear-memory +high-water mark. The growable design is selected for its ownership and +lifetime contract, not an unrecorded performance claim. Before/after retained +capacity, peak kernel memory, and timing are mutable validation evidence rather +than architecture: the draft PR ledger must record the exact baseline, +candidate head/tree, workload, runtime-artifact fingerprints, and separate +Node.js and real-Chromium results after the candidate is frozen. ABI 43 +requires the complete transactional export set and has no older-kernel +fixed-buffer fallback under the same version. Rust-lent host-import destinations are deliberately separate. Rust supplies a pointer and capacity valid for that synchronous import, so @@ -491,11 +520,21 @@ old method spellings as fail-closed regression seeds so reintroducing an unreviewed raw accessor becomes a contract failure. Current host-adapter admission requires `kernel_set_cwd`, -`kernel_clear_process_metadata`, and -`kernel_push_process_metadata_entry`. Initial cwd and process argv/environment -therefore cannot silently fall back to an older pointer-only aggregate setter -or a no-op after the runtime has negotiated the capacity-owned scratch -contract. +`kernel_get_cwd`, `kernel_get_fd_path`, `kernel_get_dirfd_path`, +`kernel_process_metadata_begin`, `kernel_process_metadata_stage`, +`kernel_process_metadata_commit`, and `kernel_process_metadata_cancel`. +Initial cwd and process argv/environment therefore cannot silently fall back +to an older pointer-only aggregate setter, clear-then-push sequence, or no-op +after the runtime has negotiated the capacity-owned scratch contract. One +positive metadata token stages a complete argv/environment pair in Rust-owned +vectors while the live pair remains unchanged. The host supplies both vectors +or neither; it rejects a partial replacement before entering the kernel so the +aggregate `ARG_MAX` proof can never omit preserved live bytes. A failed stage +makes the token uncommittable, and the host cancels every uncommitted token in +a `finally` path. Commit performs no fallible allocation or host import while +it swaps both vectors, so observers see either the old pair or the complete +replacement, never a staged prefix. Relative spawn/exec and mapping resolution +likewise cannot fall back to a fixed or truncating canonical-path query. System V control operations use the same capacity-bearing main region, but their wire sizes also depend on the caller. The required structure-size exports @@ -541,6 +580,33 @@ The generated fixed-size descriptors separately carry `stat` (112 bytes) and `sched_param` (48 bytes); those two records do not use width selection or the private process-width dispatch slot. +`setsockopt` carries the same independent caller-width fact for native IPv4 +multicast group records. `group_req` is 132 bytes with its group at offset 4 +on wasm32 and 136 bytes with the group at offset 8 on wasm64. +`group_source_req` is 260/264 bytes with its source address at offset 132/136. +The syscall has five public arguments, so the host writes the process width to +the otherwise private sixth channel slot before dispatch. Rust accepts only 4 +or 8 and selects these generated layouts from that value. `optlen` is merely a +caller byte extent, and padding is caller data; neither may be used to guess +the process data model. The public five-argument `kernel_setsockopt` export +keeps its signature and uses the kernel's native width for direct calls, while +the channel path uses the calling process's width. + +Process enumeration uses a separate complete-output rule on the fixed main +region. `kernel_enum_procs` first computes the entire snapshot with checked +arithmetic: one four-byte count, one exact packed 36-byte header per live +process, and the variable `comm` and command-line bytes. The packed header is +not a native `repr(C)` structure (which would be 40 bytes after alignment); +`wasm_posix_shared::process_snapshot_wire` owns every offset and generates the +TypeScript consumer plus ABI snapshot evidence. If the complete total exceeds +the supplied capacity Rust returns `ENOSPC` before touching the destination. +On success Rust preflights each complete header-plus-payload record and creates +only the exact required output slice. The host rejects an over-reported byte +count or any malformed count, truncated record, unsafe numeric field, or +trailing byte before returning a process list. Total Wasm memory beyond the +supplied allocation is irrelevant, and neither a short buffer nor a malformed +later record exposes a partial list. + ### Kernel heap lifetime The Rust kernel uses a reclaiming `dlmalloc` heap inside its own Wasm linear @@ -1059,7 +1125,7 @@ remaining POSIX gap is tracked in [posix-status.md](posix-status.md) and 1. User calls `execve(path, argv, envp)` → kernel returns exec request to host 2. Host resolves `path` to a Wasm binary (via filesystem or program map) -3. The host compiles the replacement module, checks its ABI marker, and preallocates its fresh `WebAssembly.Memory` before the irreversible transition. It also validates a 4 MiB combined argv/environment representation (UTF-8 strings, NUL terminators, and caller-width pointer entries). Independently, each string must fit the current 64 KiB process-metadata transfer; that is an implementation transport limit, not part of the public aggregate `ARG_MAX` definition. Oversized metadata returns `E2BIG` to the old image. After commit, argv and environment entries cross into the kernel one at a time, so the fixed host scratch allocation is never overrun and an empty environment explicitly clears the prior one. +3. The host compiles the replacement module, checks its ABI marker, and preallocates its fresh `WebAssembly.Memory` before the irreversible transition. It also validates a 4 MiB combined argv/environment representation (UTF-8 strings, NUL terminators, and caller-width pointer entries), plus the generated defensive caps of 4,096 entries in each vector. Independently, each string must fit the current 64 KiB process-metadata transfer; that is an implementation transport limit, not part of the public aggregate `ARG_MAX` definition. Oversized metadata returns `E2BIG` to the old image. After commit, argv and environment entries cross the fixed host scratch allocation one at a time into a token-bound Rust staging transaction. The live process metadata remains unchanged until one allocation-free commit swaps both complete vectors; a later entry-allocation failure cancels the transaction instead of exposing a prefix, and an empty vector deliberately replaces its prior vector with empty state. Supplying only argv or only environment is not a supported transaction, because validating that partial input would not prove the aggregate size of the preserved pair. 4. The host calls `kernel_exec_prepare(pid, caller_tid)` while the old image is still live. The kernel validates that the exact caller is a live task owned by the process and applies deferred `posix_spawn` file actions; any failure @@ -1086,7 +1152,18 @@ remaining POSIX gap is tracked in [posix-status.md](posix-status.md) and 5. Host terminates the old process and sibling-thread workers, then re-registers the PID with the preallocated memory 6. Host parses the new binary's `__heap_base` export and calls `kernel_set_brk_base(pid, __heap_base)` so `brk(0)` returns a value above the new program's data + stack region 7. Host spawns a new worker with the new program binary -8. New program starts from `_start` with the given argv/envp +8. New program starts from `_start` with the given argv/envp. The process + worker holds one immutable UTF-8 snapshot for the complete launch. The CRT + first queries every entry length with zero destination capacity, verifies + the generated count, per-entry, and caller-width aggregate limits, and then + obtains an exact-lifetime anonymous mapping through the ordinary syscall + channel. Each second call carries that entry's exact capacity and must + either copy the complete unchanged bytes or return `ERANGE`; allocation + failure, an invalid guest pointer/range, or a query/copy mismatch traps + before `_start_c` publishes any argv or environment pointer. The mapping + remains live because libc's `argv` and `environ` retain those pointers. + This is guest-process memory, not kernel scratch, and it avoids reserving a + 4 MiB worst-case static buffer in every program. Step 6 is required: without it, `MemoryManager` falls back to a hardcoded 16MB `INITIAL_BRK`, which can land *inside* the stack region of programs whose data section pushes `__heap_base` above 16MB (mariadbd's `__heap_base ≈ 16.32MB`). Heap allocations there collide with shadow-stack frames during C++ static initialization, corrupting memory and hanging in `__wasm_call_ctors`. @@ -1134,17 +1211,20 @@ caller now take. advertised 4 MiB `ARG_MAX`, 4,096-byte `PATH_MAX`, and 1,024-entry `IOV_MAX` live in `crates/shared/src/lib.rs::platform_limits` and generate the Rust, - TypeScript, and musl consumers. The separate authoritative spawn wire + TypeScript, and musl consumers. The same platform module owns the defensive + 4,096-entry process-startup caps; the spawn parser aliases them rather than + repeating the values. The separate authoritative spawn wire contract generates the four-byte string-offset width; every offset in the 40-byte header and 28-byte action record; the `OPEN`, `CLOSE`, `DUP2`, `CHDIR`, and `FCHDIR` opcodes; musl's complete transported spawn-attribute - byte; the 4,096 argv and environment entry caps; 1,024 actions; and the + byte; the shared argv and environment entry caps; 1,024 actions; and the complete ceiling. Transporting an attribute bit does not claim its behavior is implemented: the kernel currently interprets only `SETPGROUP`, `SETSIGDEF`, `SETSIGMASK`, and `SETSID`; `RESETIDS`, - `SETSCHEDPARAM`, `SETSCHEDULER`, and `USEVFORK` remain unimplemented. Count - caps are defensive parser limits; they are not additional POSIX platform - limits. + `SETSCHEDPARAM`, `SETSCHEDULER`, and `USEVFORK` remain unimplemented. The + argv/environment count caps defend the admitted process representation and + are not additional POSIX `ARG_MAX` promises. The action count remains a + spawn-parser limit. 3. Kernel parses the blob (`crates/kernel/src/spawn.rs::parse_blob` — the trust boundary; bails with EINVAL on any malformed offset), validates `caller_tid` as a live task belonging to the parent, and calls diff --git a/docs/plans/2026-07-25-kernel-scratch-transfer-audit.md b/docs/plans/2026-07-25-kernel-scratch-transfer-audit.md index 5b47712ddc..49e0dd28a9 100644 --- a/docs/plans/2026-07-25-kernel-scratch-transfer-audit.md +++ b/docs/plans/2026-07-25-kernel-scratch-transfer-audit.md @@ -324,17 +324,17 @@ reach the fallback. Those maxima need exact-boundary and drift coverage. |---|---|---|---| | `read(3)` `a1/a2`; `write(4)` `a1/a2`; `pread(64)` `a1/a2`; `pwrite(65)` `a1/a2` | The ordinary descriptor is never shortened: a count above `CH_DATA_SIZE` diverts before generic planning to `#handleLargeRead` or `#handleLargeWrite`. | Keep the one-operation Rust-owned large-transfer reservation. This is required even though ordinary files and streams may return short: `read`/`write` can name a datagram socket, and splitting or pre-shortening would change one message. | Existing exact/capacity+1 scalar and vector transfer tests cover reservation failure, sequential/interleaved attempts, wasm32/wasm64 ranges, and one-datagram behavior. Retain a direct scalar datagram boundary case. | | `getrandom(120)` `a0/a1`; `getdents64(122)` `a1/a2` | **Short operation permitted.** Random generation may return a prefix. `getdents64` may return a whole-record prefix and resume at the next cookie. | The generic count cap is semantically legal only for these two records. `getdents64` must retain its pending-entry/cookie invariant so an entry is never split or lost. | Cover channel capacity and capacity+1. The directory case must prove every returned record is complete and the following call returns the unconsumed suffix. | -| `getcwd(23)` `a0/a1` | **Bounded contract, not short.** The result is complete with its NUL or `ERANGE`; kernel CWD state is strictly shorter than generated `PATH_MAX` (4,096). | A valid result fits one channel independently of the caller's larger capacity. Preserve the CWD admission invariant and generated-limit drift test; never document a partial `getcwd` result as legal. | Set/query `PATH_MAX-1`, reject a CWD at `PATH_MAX`, and pass caller capacities at channel capacity and capacity+1 without changing the complete result. | -| `realpath(109)` path `a0`, output `a1/a2` | **Bounded contract, not short.** Both the accepted input and canonical result are shorter than generated `PATH_MAX`. | The two maximum path extents fit together below `CH_DATA_SIZE`. Preserve the namespace resolver's `PATH_MAX` enforcement and an aggregate drift assertion. | Exercise an exact maximum canonical path plus output capacities at its exact size/size+1 and at channel capacity+1. | +| `getcwd(23)` `a0/a1` | **Complete-or-`ERANGE`, not short.** A canonical CWD may exceed generated `PATH_MAX`: that limit applies to one caller-supplied pathname, not the absolute spelling formed from an already-deep directory. | Preserve the caller's real output capacity. The generic fixed-or-tokenized planner lends that complete extent, and Rust writes the whole CWD plus NUL or returns `ERANGE` without mutation. Total kernel memory beyond the selected allocation grants no extra capacity. | Exercise a canonical CWD larger than `PATH_MAX`, exact capacity and one short with unchanged bytes, and caller capacities at channel capacity and capacity+1. | +| `realpath(109)` path `a0`, output `a1/a2` | **Complete-or-`ERANGE`, not short.** The caller-supplied input remains bounded by generated `PATH_MAX`, but resolving it against a deep CWD may produce a longer canonical result. | Stage the bounded input plus the caller's actual output capacity through the fixed-or-tokenized owned layout. Rust either publishes the complete canonical path or leaves a short destination untouched; an internal channel remainder cannot become a second path limit. | Exercise a `PATH_MAX`-valid relative input whose canonical result exceeds `PATH_MAX`, exact/one-short output capacities, and channel capacity/capacity+1. | | legacy `readdir(26)` fixed record `a1`, name `a2/a3` | **Bounded contract, not short.** One 16-byte record plus a `NAME_MAX` (255) name fits. | Preserve the generated/namespace `NAME_MAX` relationship and require the host iterator to return one complete name. A shortened name after advancing the iterator would lose directory state. | Exact 255-byte name, 254-byte destination failure behavior, and channel-capacity+1 caller capacity with one complete entry. | | `readlink(19)` path `a0`, output `a1/a2`; `readlinkat(102)` path `a1`, output `a2/a3` | **Must not generically shorten.** The caller's `bufsiz`, not an internal transport cap, decides whether POSIX truncation occurs. Direct readlink does not impose `PATH_MAX`, and `_PC_SYMLINK_MAX` is currently indeterminate. | Use an exact/large owned region, or define and enforce a real cross-layer symbolic-link target maximum that leaves room for the path. “Readlink may truncate” is not permission to truncate a target that fits the caller's actual buffer. | Create or inject a target larger than the ordinary remaining channel extent but smaller than caller `bufsiz`; require the complete target. Also cover exact caller capacity and capacity-1 truncation. | | `getenv(43)` name `a0`, output `a1/a2` | **Confirmed live false-`ERANGE` edge.** The operation is complete-or-`ERANGE`; it does not return a prefix. A process metadata entry may occupy 65,536 bytes, while the name and eight-byte alignment leave less output space. | Use aggregate exact/large ownership or reduce and document the metadata-entry contract consistently at every admission point. Do not silently lower only this call's capacity. | Install a maximum entry such as `X=` plus a value that fits the current metadata-entry ceiling. A caller buffer that holds the value must receive it; exact value capacity succeeds and one byte less returns `ERANGE`. | | `mq_timedsend(333)` message `a1/a2`; `mq_timedreceive(334)` destination `a1/a2` | **Confirmed atomic-message defect.** Send must enqueue the complete message or fail. Receive must compare the caller's real capacity with the authoritative open queue's `mq_msgsize` before dequeue. | Query `mq_msgsize` before allocation: report `EMSGSIZE` for an oversized send or undersized receive, stage exactly the queue maximum for receive, and route the complete message plus priority/timeout records through the fixed-or-tokenized capacity-owned channel. Rust caps queue creation at the reportable-result domain and makes allocation failure atomic. The generic immutable snapshot freezes the request/deadline while Rust pins the exact mqueue descriptor. | Queue `mq_msgsize` at fixed-channel capacity and capacity+1; send exact/+1, receive exact/+1, verify no prefix enqueue, no dequeue on `EMSGSIZE`, allocation failure before mutation, sequential reuse, blocked-wake immutability, and descriptor close/reuse. | -| `bind(51)` `a1/a2`; `connect(54)` `a1/a2` | **All-or-nothing input object.** A socket address is not a short byte stream. | Validate family-specific minimum and maximum native `sockaddr` lengths and reject unsupported excess before copying. Do not allocate a caller-requested giant address and do not change its length to the channel remainder. In particular, bound AF_UNIX names so later address-producing calls have a finite maximum. | Supported IPv4, IPv6, pathname and abstract AF_UNIX exact maxima/+1; an oversized range with a valid prefix must reject without binding or connecting. | -| `setsockopt(59)` `a3/a4` | **All-or-nothing option object.** Supported scalar, timeout, linger, string, and multicast records have option-specific layouts; no “short setsockopt” result exists. | Select an option-specific exact/canonical maximum and reject invalid lengths. Never let a channel cap choose wasm32 versus wasm64 multicast layout or turn a future variable option into a prefix operation. | Exact/short/long cases for each structured option, including unambiguous wasm32/wasm64 group records and an oversized buffer with a valid fixed prefix. | +| `bind(51)` `a1/a2`; `connect(54)` `a1/a2` | **Confirmed all-or-nothing and family-discriminator defects.** A socket address is not a short byte stream, and bytes from one family must not be interpreted as another family's port, path, or registry key. | Validate the embedded `sa_family` plus family-specific minimum and maximum native `sockaddr` lengths before copying or performing any port allocation, VFS lookup/create, AF_UNIX registry operation, host dispatch, or socket-state mutation. Do not allocate a caller-requested giant address or change its length to the channel remainder. | `test_bind_inet_stream_validates_sockaddr_before_state_mutation`, `test_bind_unix_validates_family_before_vfs_or_registry_mutation`, `test_connect_inet_stream_validates_sockaddr_before_host_or_state_mutation`, and `test_unix_stream_connect_validates_family_before_resolution_or_state_mutation` cover wrong-family no-state/no-host-effect rejection followed by a matching-family control. Exact maxima/+1 retain the independent capacity boundary. | +| `setsockopt(59)` `a3/a4` | **All-or-nothing option object.** Supported scalar, timeout, linger, string, and multicast records have option-specific layouts; no “short setsockopt” result exists. | The host rejects input above the generated 264-byte maximum, stages the complete admitted extent, and carries the independently known process width in the private sixth channel slot. Rust accepts only width 4 or 8 and selects the generated `group_req`/`group_source_req` layouts (132/260 bytes on wasm32, 136/264 on wasm64). `optlen` and padding cannot choose the data model. | Exact/short/long cases cover every structured option and all 12 IPv4 multicast operations on both widths. A 264-byte padded input cannot steer wasm32 parsing, while 265 rejects before scratch mutation or dispatch. | | `send(55)` `a1/a2` | **Must preserve one operation.** Stream sends may return short, but the same syscall sends atomic datagrams. | Route the real count through the Rust-owned large transaction, or perform an authoritative socket-type/datagram-limit preflight before any count rewrite. A generic prefix success is invalid. | AF_UNIX and IP datagram at channel capacity/+1: receive either the complete one message or observe the correct error, never a successful prefix. Retain a stream short-send case. | | `recv(56)` `a1/a2` | **Must preserve the caller's capacity.** Streams may return short; datagram receive consumes one message and reports truncation relative to the caller's buffer. | Use an exact region, or generate/enforce a datagram ceiling no greater than the independently staged capacity. An internal cap must not create `MSG_TRUNC` or discard bytes that fit the caller's actual buffer. | Queue a datagram at the supported maximum and receive into capacity, capacity-1, and channel-capacity+1 buffers with/without `MSG_PEEK` and `MSG_TRUNC`. | -| `sendto(62)` payload `a1/a2`, address `a4/a5` | **Two all-or-nothing inputs.** Payload is one datagram and the address is one native object. | Plan one checked aggregate region: exact payload plus a bounded native address. The current descriptor order can let payload consume the channel, reduce address length to zero, and change the destination or produce `EDESTADDRREQ`. | Maximum payload with IPv4/IPv6/AF_UNIX address, aggregate exact/+1, oversized address, and an unconnected socket proving no zero-address fallback. | +| `sendto(62)` payload `a1/a2`, address `a4/a5` | **Confirmed aggregate-capacity and family-discriminator defects.** Payload is one datagram, the address is one native object, and a wrong family must not trigger implicit bind or host delivery. | Plan one checked aggregate region for the exact payload plus a bounded native address, then validate `sa_family` before ephemeral-port allocation, socket mutation, registry lookup, or host dispatch. The old descriptor order could let payload consume the channel, reduce address length to zero, and change the destination or produce `EDESTADDRREQ`. | `test_sendto_validates_sockaddr_before_datagram_state_mutation` proves wrong-family and short addresses preserve unbound state, port allocation, and host-call counts for AF_INET and AF_UNIX. Maximum payload/address aggregate exact/+1 and an unconnected socket retain the capacity and no-zero-address cases. | | `recvfrom(63)` destination `a1/a2`, address `a4` via `*a5` | **One message plus value-result address.** Shortening data can discard a datagram the caller could hold; reserving the caller's entire address capacity can reject before receiving even though the actual address is small. | Plan exact data capacity plus `min(caller address capacity, supported sockaddr maximum)` in one owned aggregate. Copy back only the detached actual address and length. | Maximum datagram with non-null address, data/address aggregate exact/+1, caller address capacity above channel size, truncation/peek, and no dequeue on preflight failure. | The related generated `Deref` value-result paths do not use the simple cap: @@ -374,7 +374,7 @@ draft PR ledger, independently of these source-safety rows. |---|---|---|---|---|---|---| | Raw allocator boundary, `crates/kernel/src/wasm_api.rs::kernel_alloc_scratch`; `crates/kernel/src/scratch_alloc.rs::layout` | Rust global allocator; successful pointer owns exactly the validated `Layout` size | The export accepts a `u32` request, but a successful allocation is further bounded by the aligned Rust `Layout`/`isize::MAX` domain | Allocation is retained for the kernel lifetime; no host-side free or growth workaround | Node/browser; wasm32/64 kernel | **Unsafe failure boundary.** Invalid `Layout` construction could trap instead of reporting allocation failure | **Implemented; validation pending.** Zero/invalid layouts and allocator-null return zero; the host rejects an invalid zero or out-of-memory-range result before constructing a region | | Main syscall scratch, `CentralizedKernelWorker.#scratchRegion` | Rust `kernel_alloc_scratch`; `KernelScratchRegion`, 65,608 bytes (`CH_TOTAL_SIZE`) | Each layout is checked against the region; ordinary data payload is at most 65,536 bytes (`CH_DATA_SIZE`) | Kernel lifetime; one synchronous lease per dispatch/copy; nested leases fail | Node and browser; wasm32/64 kernel | **Unsafe contract.** Bare `scratchOffset`; several live overflows | **Implemented; validation pending.** All allocator-owned access is lease-mediated, and reflection cannot recover the region | -| Generic widened transfer scratch, `crates/kernel/src/transfer.rs::{TransferScratch,GlobalTransferScratch}`; `kernel_transfer_scratch_{begin,pointer,capacity,cancel}`; `kernel_transfer_{channel,io}_execute` | Rust owns a fresh initialized, eight-byte-aligned `Vec` byte prefix. A positive opaque token is the sole authority for its pointer and exact authorized byte capacity; spare vector capacity is never exposed | The allocator boundary is generated `MAX_TRANSFER_ALLOCATION_BYTES` (`u32::MAX`). Each consumer applies its narrower semantic/result ceiling before effects, including `MAX_REPORTABLE_TRANSFER_BYTES` for scalar/vector and message payloads | One exclusive Reserved → Executing → Ready transaction. Pointer/capacity queries work only while Reserved; ordinary completion revokes the host region and cancels/drops the vector. Executing rejects begin/query/cancel/reuse; an export trap leaves ownership uncertain and fail-stops the kernel generation rather than reusing bytes | Node/browser shared host path; kernel wasm32/64 and guest wasm32/64 | **Missing on the audited head.** Variable transfers either overfilled the fixed mailbox or required a protocol-specific large allocation | **Capacity-safe in current source; static-contract rerun pending.** Rust proves base alignment, initialized exact capacity, allocation failure, token exhaustion, sequential/interleaved exclusion, and invalid state transitions. The host must still receive a clean final rerun of the reservation-authority and entry-context gates before this row can be called validated | +| Generic widened transfer scratch, `crates/kernel/src/transfer.rs::{TransferScratch,GlobalTransferScratch}`; `kernel_transfer_scratch_{begin,pointer,capacity,cancel}`; `kernel_transfer_{channel,io}_execute`; complete CWD/fd/dirfd path producers | Rust owns a fresh initialized, eight-byte-aligned `Vec` byte prefix. A positive opaque token is the sole authority for its pointer and exact authorized byte capacity; spare vector capacity is never exposed | The allocator boundary is generated `MAX_TRANSFER_ALLOCATION_BYTES` (`u32::MAX`). Each consumer applies its narrower semantic/result ceiling before effects, including `MAX_REPORTABLE_TRANSFER_BYTES` for scalar/vector, message, and canonical-path output | Widened syscall/channel calls use one exclusive Reserved → Executing → Ready transaction. Executing rejects begin/query/cancel/reuse; a trap leaves ownership uncertain and fail-stops the generation. Complete canonical-path reads instead keep the token Reserved for one synchronous Rust producer, detach the exact result, revoke the host region, and cancel/drop the vector. Reserved prevents movement or replacement, and the host reentry guard excludes a second reservation; no promise or callback observes the live bytes | Node/browser shared host path; kernel wasm32/64 and guest wasm32/64 | **Missing on the audited head.** Variable transfers either overfilled the fixed mailbox or required a protocol-specific large allocation | **Capacity-safe in current source; static-contract rerun pending.** Rust proves base alignment, initialized exact capacity, allocation failure, token exhaustion, sequential/interleaved exclusion, and invalid state transitions. Focused path coverage additionally targets zero-capacity query, exact/short/no-mutation, detach/revoke/cancel, allocation failure, and canonical output above `PATH_MAX`. The host must still receive a clean final rerun of the reservation-authority and entry-context gates before this row can be called validated | | TCP/pipe scratch, `CentralizedKernelWorker.#tcpScratchRegion` and `#requireTcpScratchRegion` | Rust `kernel_alloc_scratch`; `KernelScratchRegion`, 65,536 bytes | One checked transport chunk, at most 65,536 bytes | Kernel lifetime; worker callbacks/messages detach bytes before yielding | Node/browser; wasm32/64 kernel | **Safe sizes, weak contract.** Private pointer reached other code | **Implemented; validation pending.** The region and accessor are runtime-private and all access is synchronously leased | | Large spawn scratch, `beginLargeSpawnScratch`, `SpawnScratchBuffer` | Rust `Vec` through required `kernel_spawn_scratch_begin/pointer/capacity/cancel`; the returned token gates both pointer and capacity, while separate pointer-free retained-capacity telemetry grants no write authority | Complete blob at most 8,417,320 bytes; ordinary blobs use main scratch | Kernel-lifetime high-water allocation, but a fresh exclusive token and single-use host region per operation. Begin and queries are nonblocking; begin may move only while idle. After every successful begin, host cleanup runs in `finally`. Commit/cancel wait on the same no-import lock and return with a definitive token state; cleanup failure is fatal and leaves the host reentry guard closed | Node/browser; wasm32/64 kernel | **Safe after #1094, weak contract.** Fixed 8,417,320-byte allocation retained after first large use | **Safe in current source.** `kernel_spawn_reserved_process` accepts token+length rather than a bare pointer, with no ABI-42 fallback. This document makes no retained-memory or performance claim | | Audio drain, `WasmPosixKernel.#audioScratchRegion` | Rust `kernel_alloc_scratch`; 65,536-byte `KernelScratchRegion` bound to the exact Wasm instance and memory that allocated it | `min(out.byteLength, capacity)` and checked Rust return count | One kernel-wrapper generation; one synchronous drain lease. `init` is one-shot, and the cached region is runtime-private, so it cannot survive an instance replacement or escape to a caller | Node/browser; wasm32/64 kernel | **Confirmed unsafe/uncertain.** Pointer/range and producer count were incomplete, and a later second initialization could leave the cached region bound to the old generation | **Safe in current source.** Allocation, requested bytes, current range, returned count, and one-generation lifetime are checked | @@ -432,11 +432,11 @@ scratch lease has already ended. | `host/src/kernel.ts::{intrinsicBufferSourceSpan,bufferSourceToArrayBuffer,WasmPosixKernel.init}` | Caller supplies kernel module bytes; the host immediately owns one detached `ArrayBuffer` snapshot, then publishes one exact instance/memory generation | Exact intrinsic `ArrayBuffer`, typed-array, or `DataView` byte window accepted by the WebAssembly compiler | Captured native internal-slot getters reject non-genuine/detached sources and ignore subclass span getters; pointer-width detection and compilation consume the same snapshot. An explicit initialization state rejects a concurrent or post-success initializer before it mutates width, memory, instance, or cached scratch authority | Snapshot completes before the asynchronous compile; later caller mutation cannot replace either consumer's bytes. A failed first instantiation clears partial state and permits one clean retry; a successful wrapper is one-shot | Node/browser; kernel wasm32/64 | **Confirmed pointer-width and generation-lifetime defects.** A view subclass could make width detection parse decoy bytes while the engine compiled its intrinsic bytes; a second init could leave cached scratch authorized against the old instance | **Safe in current source.** Spoofed-input, wasm32/wasm64 cached public/audio scratch, rejected reinit, concurrent init, and failed-init retry regressions cover the contract | | `host/src/host-adapter-manifest.ts::readKernelHostAdapterManifest` | Rust owns one static host-adapter manifest in the exact kernel instance; the export supplies its pointer/length and generated `HOST_ADAPTER_MANIFEST_SIZE` supplies the reviewed read extent | Exactly the generated fixed manifest size; extra exported length grants no larger view | The instance/Memory pair is authenticated first, the export pointer is converted losslessly, and the fixed extent is checked against the current genuine `Memory.buffer` before constructing a private `DataView` | Synchronous scalar reads only; the view and buffer are never returned, stored, or written | Node/browser; kernel wasm32/64 | **Reviewed read-only raw-memory path.** It does not match allocator-owned scratch even though it constructs a view over kernel memory | **Reviewed `kernel-read` exclusion; static-gate rerun pending.** The exact view site is allowlisted because it reads a fixed Rust-owned record after the full range proof and grants no variable-write authority | | `host/src/kernel.ts::WasmPosixKernel::{#hostFutexWait,#hostFutexWake}` | Rust lends one four-byte aligned atomic word in the kernel's shared `Memory`; no allocator-scratch pointer or variable byte region is involved | Exactly four bytes per import; wake count and timeout are scalars | `checkedWasmImportMemoryRange` normalizes wasm32/wasm64 pointers losslessly, proves the current four-byte range, and requires four-byte alignment before constructing the private `Int32Array`; captured `Atomics.wait`/`notify` intrinsics receive only the proved index | One synchronous import. The atomic view is local and does not escape; wait may block the calling worker but retains no host callback or reusable scratch lease | Node/browser where shared-memory Atomics are supported; kernel wasm32/64 | **Reviewed atomic-control path.** It observes/wakes a Rust-owned futex word rather than copying variable host data | **Reviewed `kernel-control` exclusion; static-gate rerun pending.** The two exact view sites are allowlisted, and neither authorizes `set`, `fill`, `DataView` writes, or a caller-selected scratch capacity | -| `host/src/kernel-worker.ts::replaceProcessMetadata` | Rust main allocation; private `scratchRegion`, 65,608 bytes; each entry begins at allocation-relative offset 0 | One metadata entry at most `CH_DATA_SIZE` (65,536); exec argv/environment aggregate at most generated `ARG_MAX` | Detached caller bytes; lease proves owned allocation and current memory; Rust return count is bounded | One lease and Rust call per entry; view is reacquired after possible growth; no overlap | Node/browser; kernel and guest wasm32/64 | Sizes fit, but a bare pointer represented ownership | **Implemented; validation pending.** Lease-mediated staging | -| `host/src/kernel-worker.ts::{handleExec,handleExecveat,readExecPathFromProcess,readStringArrayFromProcess,resolveExecPathAgainstCwd,checkedScratchProducerByteLength}` | Exec pathname/argv/environment are detached JS strings read from caller process memory; only CWD/fd-path queries use the 65,608-byte main allocation | Path scan is bounded by generated `PATH_MAX` 4,096; each string by 65,536; complete argv/environment representation, including pointers and NULs, by generated `ARG_MAX` 4 MiB; CWD/fd-path output by 4,096 | Native pointer-array entries are read at guest width and wasm64 values must be losslessly representable; every string must terminate in its caller range; each direct `withLease` query passes exact pointer/capacity, validates Rust's count with `checkedScratchProducerByteLength`, and detaches with `copyOut` before releasing the lease | No scratch view crosses `callbacks.onExec`'s promise; only detached strings/arrays do. Each CWD/fd-path query completes its lease before the callback | Node/browser; guest wasm32/64 independent of kernel width | **Unsafe/uncertain edge.** Async exec and bounded-string paths used bare scratch queries and lossy/incomplete pointer scans | **Implemented; validation pending.** Explicit `PATH_MAX`/`ARG_MAX`, lossless native-pointer, checked producer count, and no-view-across-promise contract | +| `host/src/kernel-worker.ts::{registerProcess,#replaceProcessMetadataWithinKernelEntry}`; `crates/kernel/src/process.rs::{ProcessMetadataReplacement,Process::begin_metadata_replacement,Process::stage_metadata_entry,Process::commit_metadata_replacement,Process::cancel_metadata_replacement}`; `crates/kernel/src/wasm_api.rs::kernel_process_metadata_{begin,stage,commit,cancel}`; `crates/shared/src/lib.rs::process_metadata_contract` | Each host entry uses one lease from the Rust main allocation (65,608 bytes) at allocation-relative offset zero. A positive process-bound token owns separate Rust staging vectors until commit or cancel; the live `Process::argv` and `Process::environ` remain their prior complete values | One encoded entry at most generated `PROCESS_METADATA_ENTRY_MAX_BYTES` (65,536), no more than the generated 4,096 entries per vector, and the complete argv/environment representation at most generated `ARG_MAX`. The sole live caller admits both vectors or neither and validates their caller-width aggregate before begin; there is no second wrapper that can bypass that proof | Detached caller bytes; each lease independently proves owned allocation capacity, current-memory range, and lossless kernel-pointer conversion. Generated kind constants select the staged vector. Rust copies the complete entry synchronously, poisons the token after any matching stage failure, and accepts commit only for the exact live token | One serialized kernel-entry scope spans begin, every per-entry lease/export, and commit. No view crosses an export. Commit performs no fallible allocation or host import between the two vector swaps; every ordinary error runs token cancellation in `finally`, and overlapping/stale tokens reject | Node/browser shared path; kernel and guest wasm32/64 | **Confirmed unsafe ownership and publication defects.** A bare pointer represented ownership, and the former clear-then-push protocol changed live metadata before all later entry allocations had succeeded, so a late `ENOMEM` could leave a visible prefix. Intermediate partial-mask and unvalidated-wrapper designs would also have bypassed the aggregate proof | **Implemented in current source; final validation pending.** Coverage targets success and explicit empty vectors, rejection of exactly-one-vector input before begin, later-environment allocation failure with both old vectors and every layout field intact, exact token/cancel rules, stale and overlapping operations, manifest-required exports, entry/count/aggregate bounds, sequential replacements, post-growth scratch reacquisition, and both kernel pointer widths | +| `host/src/kernel-worker.ts::{handleSpawn,handleExec,handleExecveat,readExecPathFromProcess,readStringArrayFromProcess,resolveExecPathAgainstCwd,#readKernelOwnedPath}`; Rust exports `kernel_get_cwd`, `kernel_get_fd_path`, and `kernel_get_dirfd_path` | Exec/spawn pathname, argv, and environment inputs become detached host values after checked caller-memory reads. Canonical CWD/OFD snapshots first use the 65,608-byte main allocation; an `ERANGE` result selects a fresh exact-capacity Rust-owned `TransferScratch` reservation | Caller path scans remain bounded by generated `PATH_MAX` 4,096, each metadata string by 65,536, and the complete argv/environment representation by generated `ARG_MAX` 4 MiB. Canonical CWD/OFD output is independently allowed through `MAX_REPORTABLE_TRANSFER_BYTES`; it is not `PATH_MAX`-capped | Native pointer-array entries remain at guest width and must convert losslessly; every caller string terminates in its checked range. A positive short path query writes nothing and returns `ERANGE`; zero capacity reports the required length. The host validates that result, reserves exactly when it exceeds main scratch, and passes the reservation-derived pointer and capacity together. Relative `execveat` and shared mappings use the directory-only export, while `AT_EMPTY_PATH` uses the ordinary fd export | Attempt, size query, reserve, exact retry, detach, revoke, and cancel remain in one synchronous kernel entry. Each producer invocation stays lexically inside the exact fixed or reservation-derived lease; no opaque helper receives a transferable lease. The `Vec` stays `Reserved`, so it cannot move or be reused; only detached strings/arrays cross `callbacks.onExec`/`onSpawn` promises. Cancellation drops the allocation before leaving the entry | Node/browser; guest wasm32/64 independent of kernel width | **Unsafe/uncertain edge.** Async exec/spawn resolution used bare fixed-capacity queries, assumed canonical output was at most `PATH_MAX`, and had lossy/incomplete pointer scans | **Safe in current source; final validation pending.** Coverage targets complete main/exact-reserved queries, zero-capacity size discovery, exact/one-short/no-mutation, canonical paths above `PATH_MAX`, regular-file `ENOTDIR`, `AT_EMPTY_PATH`, allocation failure, invalid reservation range, lossless native pointers, and no view across a promise | | `host/src/kernel-worker.ts::{ptyMasterWrite,ptyMasterRead}` | Rust main allocation, full 65,608-byte region | Write chunks are `min(remaining, lease.capacity)`; read request is `min(4,096, lease.capacity)` | Write source slice and destination are independently checked; returned write/read count must be a safe integer no larger than the offered chunk/request | One lease per chunk/call; read bytes are detached before `drainPtyOutput`; a second operation cannot enter the active lease | Node/browser; kernel wasm32/64 | **Confirmed unsafe.** `ptyMasterWrite` copied arbitrary `data.length` into the allocation; read trusted the producer count | **Implemented; validation pending.** Exact 65,608 and 65,609 regression | | `host/src/kernel-worker.ts::setCwd` | Rust main allocation, 65,608 bytes | Encoded path must be shorter than generated `POSIX_PATH_MAX_BYTES` (4,096, including the NUL contract) | Length is rejected before acquiring/copying; lease then proves allocation and current-memory bounds | One synchronous lease and `kernel_set_cwd` call; no retained view | Node/browser; kernel wasm32/64 | **Confirmed unsafe.** Copy happened before Rust's `PATH_MAX` rejection | **Implemented; validation pending.** Pre-copy oversized-CWD regression | -| `host/src/kernel-worker.ts::{enumProcs,readProcMaps,checkedScratchProducerByteLength}`; Rust exports `kernel_get_cwd`, `kernel_get_fd_path`, and wait/wake/mqueue query helpers | Rust main allocation, 65,608 bytes | Fixed or explicit producer requests, presently no more than 4,096 bytes for paths and 1,280 bytes for listed fixed records | Requested capacity is passed to Rust; returned byte/count value must be safe and fit that capacity before the same lease calls `copyOut` | Producer runs inside one direct checked lease; detached bytes cross any callback/retry boundary | Node/browser; kernel/guest wasm32/64 | Fixed requests fit; several producer counts were trusted | **Implemented; validation pending.** Inline checked leases and `checkedScratchProducerByteLength` replace the removed aggregate helper | +| `host/src/kernel-worker.ts::{enumProcs,#enumProcsWithinKernelEntry,parseProcSnapshots,readProcMaps,checkedScratchProducerByteLength}`; `crates/shared/src/lib.rs::process_snapshot_wire`; generated `host/src/generated/abi.ts::PROCESS_SNAPSHOT_*`; `crates/kernel/src/{process_snapshot_wire.rs,wasm_api.rs::kernel_enum_procs}`; fixed wait/wake/mqueue query helpers | Rust main allocation, 65,608 bytes | Process enumeration is one complete four-byte count plus a generated packed 36-byte header and variable `comm`/command-line bytes per live process; other listed producers have their own explicit fixed request | Rust computes and preflights each complete header-plus-payload record with checked arithmetic before any write. A short capacity returns `ENOSPC` without mutation; success slices exactly the required bytes. The generated offsets are the sole Rust/TypeScript authority and the ABI snapshot pins every field. The host rejects non-integer/over-capacity producer counts and malformed count, header, payload length, unsafe virtual size, or trailing bytes | Each producer runs inside one synchronous checked lease and only detached bytes cross a callback or retry boundary. The parser throws before returning any list if any declared record is incomplete, so a valid prefix is never published as the complete process table | Node/browser; kernel/guest wasm32/64 | **Confirmed false-`ENOSPC`, drift, and partial-publication risks.** Enumeration reserved 40 bytes for a header whose wire size is 36; Rust, TypeScript, and tests separately spelled the layout; several producer counts were trusted; and the host silently returned a valid prefix on malformed trailing records | **Safe in current source; final validation pending.** Native full-record exact/one-short/no-mutation tests, generated freshness/drift tests, malformed multi-record host cases, complete-snapshot preflight, and host producer-overreport coverage replace the stale estimate and literals | | `host/src/kernel-worker.ts::{#handleSyscallInner,#executeCapacityOwnedChannel,#executeReservedChannelDispatch}`; `host/src/generated/abi.ts::SYSCALL_ARGS`; `crates/shared/src/host_abi.rs::{SyscallArgDesc,SyscallArgSize}`; `crates/kernel/src/channel_scratch.rs::{ChannelScratchRegion,validate_channel_scratch_arguments,validate_prctl_layout,checked_cstr_len}`; `crates/kernel/src/wasm_api.rs::{dispatch_channel_syscall,kernel_transfer_channel_execute}` | A complete aligned channel at most 65,608 bytes uses the reusable main allocation; a larger footprint uses one fresh token-bound `TransferScratch` whose initialized capacity is exactly the planned aligned channel size | The sum of all descriptor-sized arguments and alignment is checked against generated `MAX_TRANSFER_ALLOCATION_BYTES`; each syscall's public or implementation limit may be smaller. Every pointer descriptor is explicitly required or nullable, and every C string must terminate inside its remaining owned subrange | The host rejects negative, fractional, unsafe-integer, multiplication/addition/alignment overflow, positive null unless explicitly nullable, and a non-null `Deref` outer buffer without its length pointer. It captures every `Deref` length before planning. The fixed path passes `kernel_handle_channel` its exact 65,608-byte allocation; the widened path passes no host pointer or capacity to `kernel_transfer_channel_execute`, which derives both from the Reserved token. Rust verifies canonical pointer order, alignment, non-overlap, complete allocation bounds, descriptor nullability, bespoke layouts, and in-region C strings before dispatch | `#executeCapacityOwnedChannel` owns one rigid stage → execute → finish transaction. Callers receive neither an execute closure nor entry authority; all writes precede the one fixed/token execution and all readback is detached before lease revocation. Nested, promise-escaping, duplicate, omitted, or reordered execution is structurally unavailable | Node/browser; guest wasm32/64 independent of kernel width; kernel wasm32/64 | **Confirmed unsafe/uncertain domain edges.** Some raw pointers bypassed descriptors, fixed outputs such as `pipe(NULL)` were implicitly treated as nullable, `prctl` scalars were treated as pointers, `Deref` planning could reread mutable lengths, staging was not ownership-bearing, and Rust's bare-pointer scanner used `PATH_MAX` as both an allocation and semantic bound | **Capacity-safe in current source; final static-gate rerun pending.** Exact/capacity+1, positive-null and owned-empty, explicit-nullability drift, option-sensitive `prctl`, reordered/mutated `Deref`, bounded C strings, fixed/widened selection, reservation failure, and token settlement have focused coverage. Blocking-retry request and target ownership is independently complete as recorded in the checkpoint above; this capacity row does not substitute for that lifetime proof | | `host/src/kernel-worker.ts::{#handleSyscallInner,completeChannel,handleBlockingRetry,handleSleepDelay}`; `PreparedChannelCompletion` | Output belongs to the just-completed fixed or widened scratch lease, but the only byte state allowed to outlive it is a detached `Uint8Array` plus its already-validated process destination | Exactly the output descriptors and successful byte counts are detached inline before lease release; error and interrupted completions publish no staged output | `completeChannel` has no scratch-read fallback. Timeout, stopped-process, signal, and teardown state accept only explicit detached writes; absent output means an empty list | Detachment occurs synchronously in the dispatch lease; later callbacks may overlap another scratch use without observing its bytes | Node/browser; guest/kernel wasm32/64 | **Confirmed scratch-lifetime defect.** Deferred completion could reread the shared allocation after another operation replaced it | **Safe in current source; validation pending.** Scratch-byte lifetime is complete here, while immutable request/target ownership is proved independently by the following retry row | | `host/src/kernel-worker.ts::{#handleSyscallInner,handleBlockingRetry,#rememberBlockingRetrySnapshot,#replayBlockingRetrySnapshot,#releaseBlockingRetrySnapshot,#forgetBlockingRetrySnapshotAfterKernelLifecycle,#retrySyscallWithinKernelEntry,#retireExactChannelAsyncState}`; `crates/kernel/src/{blocked_retry.rs,syscalls.rs,wasm_api.rs}` | No scratch lease or Wasm view crosses the wait. For all seven snapshot shapes, the host owns detached immutable request state. Rust owns one opaque-token binding when its single authority maps the operation; zero records a host-only immutable snapshot | The represented scalar/vector/channel/message request, including its captured fd/mqd/qid, nested layouts, payload or output destinations, flags, priorities, and deadlines. The token is scoped to the exact pid, tid, and normalized operation and is never a substitute for allocation capacity | On first `EAGAIN`, Rust pins the stable one/two-OFD, MQ, or SysV target before control returns to JavaScript. The host then queries the positive token or authoritative zero and retains the first immutable snapshot only. Replays stage from that snapshot and activate the exact binding; they do not resolve a reused numeric name. Missing exports and negative, out-of-range, mismatched, or stale target-token results fail closed; zero is accepted only when Rust classifies the snapshot host-only. The union is exhaustive for blocking-dispatch replay | The snapshot/token may span promises, timers, and wake callbacks, but no live scratch view does. Terminal completion/cancellation/retirement releases the exact token. Exec, task exit, process exit, signal exit, and forced removal consume Rust pins first; only then does the host forget its snapshot without double release | Node/browser shared source; guest wasm32/64 and kernel wasm32/64 | **Confirmed adjacent request-identity defect, present independently of #1094.** Re-executing from live mailbox/process memory can redirect a blocked request without any scratch overflow | **Safe in current source; exact-head execution is not claimed here.** Focused regression targets cover all seven immutable replay shapes, token/zero classification, mismatch/failure, close-and-reuse, one/two-target bindings, and task/process lifecycle retirement | @@ -461,6 +461,7 @@ scratch lease has already ended. | `crates/kernel/src/wasm_api.rs::{channel_readv,channel_writev,channel_preadv,channel_pwritev,checked_kernel_iovec_entries}`; `libc/glue/{channel_syscall.c,syscall_glue.c,syscall_imports.h}`; `host/src/worker-main.ts::assertSupportedKernelFunctionImports` | Private channel helpers receive `ChannelScratchRegion { start, capacity }`; there is no host-callable bare vector pointer | Canonical channel allocation and generated `IOV_MAX`; current programs use `channel_syscall.c` | Table and every payload range are checked against the same allocation-bearing region. The four raw vector exports/declarations are absent from the source and ABI snapshot. Unknown `kernel.*` function imports fail before process instantiation; they are never replaced with zero-success stubs | Private synchronous channel dispatch only; no compatibility caller can overlap an unowned raw pointer | Node/browser; kernel wasm32/64; guest wasm32/64 through channel IPC | **Confirmed unprovable compatibility surface.** The removed signatures checked total kernel memory but carried no allocation capacity; historical direct glue passed process-memory native iovecs into a distinct kernel address space/layout | **Safe in current source; execution not claimed here.** Required targets are the static source/snapshot guard, callable-import admission tests, and declared-shell artifact scan | | `host/src/kernel-worker.ts::{checkedProcessMessage,nativeControlToKernelWire,kernelMessageLayout,handleSendmsg,#executeCapacityOwnedChannel}`; `crates/kernel/src/socket_wire.rs`; fixed `Kernel{Msghdr,Iovec,Cmsghdr}Wire` | The complete aligned canonical message uses main scratch when it fits 65,608 bytes and a fresh token-bound `TransferScratch` when larger. Both contain one 28-byte kernel header, optional name/control, zero or one eight-byte canonical iovec, and the flattened payload | Caller-native `msghdr` is generated as 28 bytes on wasm32 or 56 on wasm64; native iovec count is 0..generated `IOV_MAX` 1,024; aggregate payload is bounded by `MAX_REPORTABLE_TRANSFER_BYTES`, control conversion retains its explicit protocol bound, and the complete aligned allocation must fit `MAX_TRANSFER_ALLOCATION_BYTES` | Full native header/table and every nested source range are checked losslessly before reservation. Native `cmsghdr` records are validated and translated to the generated 12-byte-header/alignment-4 wire; all caller iovecs are flattened into one owned payload. The fixed path proves 65,608-byte capacity; the widened token path derives pointer/capacity in Rust. Rust revalidates the complete canonical ancillary stream and zero/one-iovec wire, and the returned count cannot exceed staged payload | One rigid stage → fixed/token execute → finish lease covers header/control/flatten/call. Only detached parsed metadata exists before it and no scratch view survives. On `EAGAIN`, `SendmsgBlockingRetrySnapshot` retains the checked message/layout plus detached name, control, and payload. Rust pins the carrier OFD and its frozen in-flight ancillary descriptor template; replay uses the same token even if the numeric fd is closed and reused | Node/browser; guest wasm32/64; kernel wasm32/64 | **Confirmed live allocation overflow plus mixed-width protocol defect.** Count/layout capacity was incomplete, only the first caller iovec reached Rust, and wasm64 ancillary headers were interpreted as wasm32 | **Synchronous transfer capacity and the represented `sendmsg` retry ownership are implemented in current source; exact-final-head static and runtime validation remain pending.** `IOV_MAX+1`, exact/capacity+1, fixed/widened selection, multi-iovec/zero-entry flattening, malformed control, invalid descriptors, sequential exclusion, wasm32/64 wire translation, immutable replay, and carrier close/reuse have focused coverage | | `host/src/kernel-worker.ts::{checkedProcessMessage,kernelControlCapacityForRecv,kernelControlToNative,kernelMessageLayout,handleRecvmsg,#executeCapacityOwnedChannel}`; `crates/kernel/src/wasm_api.rs::kernel_recvmsg` | The same canonical layout uses the fixed main allocation when its aligned total fits and one token-bound `TransferScratch` otherwise; caller name, native control, and every native iovec destination retain separate checked process-memory capacities | Native header 28/56; count 0..1,024; aggregate destination capacity is bounded by `MAX_REPORTABLE_TRANSFER_BYTES`; canonical ancillary capacity is derived from what the caller-native control layout can represent; the complete aligned allocation must fit `MAX_TRANSFER_ALLOCATION_BYTES` | Complete caller table/destination ranges are proved before reservation. The fixed or token-owned region holds one contiguous receive payload plus name/control. Returned wire length, alignment, type, descriptor width, and producer byte count are validated before expansion; the host detaches and scatters only the bounded prefix across all caller iovecs. `MSG_TRUNC` may report the full datagram while only that prefix is copied | One rigid stage → fixed/token execute → finish lease snapshots all output, and caller publication uses detached arrays after release. Error paths publish nothing. On `EAGAIN`, `RecvmsgBlockingRetrySnapshot` retains the checked native header, iovec/name/control destinations, canonical layout, flags, and capacities. Rust pins the exact carrier OFD. Replay never reparses a replacement msghdr or numeric fd, and detached output publishes only to the originally validated destinations | Node/browser; guest wasm32/64; kernel wasm32/64 | **Confirmed live allocation overwrite plus mixed-width/first-iovec defects.** Complete count/footprint was unproven, only one destination received bytes, and wasm64 `cmsghdr` capacity could install descriptors that could not be represented on copy-back | **Synchronous transfer capacity and the represented `recvmsg` retry ownership are implemented in current source; exact-final-head static and runtime validation remain pending.** Exact/capacity+1, fixed/widened selection, multi-iovec scatter with a zero middle entry, EAGAIN/no-publish, malformed output, `MSG_CTRUNC`, flags, padding, wasm32/64 matrices, immutable destination replay, and carrier close/reuse have focused coverage | +| `crates/kernel/src/syscalls.rs::{sockaddr_family,checked_sockaddr_un_path,parse_sockaddr_in,parse_sockaddr_in6,sys_bind,sys_connect,sys_sendto}` | The already capacity-bounded channel input remains one immutable address object until the syscall returns; socket/VFS/registry/HostIO state are separate authoritative owners | A complete admitted `sockaddr_storage` at most 128 bytes, with the family-specific concrete parser applying the narrower minimum/maximum | The parser validates the embedded family before reading family-specific path/address fields. Rejection precedes ephemeral-port allocation, VFS creation or lookup, AF_UNIX registry lookup, `HostIO` network dispatch, and socket state changes | One synchronous Rust dispatch; failure publishes no external effect and the same descriptor remains usable by a following matching-family control | Node/browser shared kernel; guest/kernel wasm32/64 | **Confirmed unsafe adjacent state mutation, independently present at the audited base.** Wrong-family bytes could reach address/path interpretation before the discriminator was proved, allowing bind/connect/sendto to mutate or dispatch using the wrong address model | **Safe in current source; exact-final-head execution pending.** The five named Rust regressions cover AF_INET and AF_UNIX bind/connect/sendto wrong-family rejection, unchanged socket/port/VFS/registry/host-call state, and matching-family controls | | `host/src/kernel-worker.ts::{handleSpawn,decodeSpawnBlobStrings,handleSpawnAfterResolve,beginLargeSpawnScratch,cancelLargeSpawnScratch}`; `crates/kernel/src/spawn.rs::{SpawnScratchBuffer,measure_strings_by_offset,decode_measured_strings}`; `crates/kernel/src/wasm_api.rs::kernel_spawn_reserved_process` | Ordinary blob uses main allocation; large blob uses token-bound Rust `Vec` whose pointer and actual capacity are returned only while reserved | Complete blob at most generated 8,417,320; argv/environment representation at most 4 MiB; path/action/count caps from generated contracts | Caller ranges, parsed counts, paths, complete blob length, allocation capacity, current memory, pointer width, token, and reservation state are independent checks. Host and Rust first measure every referenced string against one aggregate budget, then allocate/decode | Async lookup owns a JS copy; begin/copy/commit have no await. Begin and pointer/capacity queries fail without waiting on contention. After every successful begin, cancellation runs in `finally`, including setup/copy failure. Commit and cancellation wait on the same no-host-import mutex and return only after the token is consumed, released, or shown stale; host/Rust guards reject overlap. Duplicate maximum-count offsets cannot amplify allocations before rejection | Node/browser; guest/kernel wasm32/64 | #1094 spawn fix was capacity-safe but retained a fixed 8,417,320-byte region after first large use; decoding still admitted allocation amplification from duplicate offsets | **Safe in current source; execution and sizing measurements are not claimed here.** Coverage targets include the growable Rust-owned reservation, pre-allocation aggregate accounting, and exact-count/`ARG_MAX` boundaries | | `host/src/kernel-worker.ts::{runSharedMappingHostOperation,populateMmapFromFile,pwriteFromProcessMemory,readSysvShmRange,writeSysvShmRange}`; `KernelEntryGate::{runSerializedHostOperation,KernelVoidIngressScope.invokeSerializedHostOperation}` | Main data allocation for transit, 65,536 bytes per chunk; mapped/shared bytes have separate owners | One `CH_DATA_SIZE` chunk; overall mapping/segment size comes from checked mapping/kernel state | Complete process/mapping range and each Rust producer/consumer count; transit lease separately proves scratch capacity/current memory. Each synchronous backing read/write holds either the exact active entry's host-operation marker or the gate-owned host-only marker; it returns no Promise/thenable or retained backend view | One synchronous lease per chunk; authoritative shared bytes/snapshots live outside scratch. Reentrant void ingress queues, result-bearing ingress and a second host operation reject, and host-only teardown can run only while the gate is otherwise idle | Node/browser; guest/kernel wasm32/64 | **Confirmed ownership/overlap gap.** Capacity fit, but a bare scratch pointer and an unscoped synchronous backend callback could overlap or reenter the operation that was validating and committing its staged result | **Safe in current source; exact-final-head validation pending.** Allocation-bearing transit plus scoped/host-only serialization is covered by gate tests and shared-mapping inheritance regressions, including a hostile synchronous backend callback | | `host/src/kernel-worker.ts::{handleIpcShmat,handleIpcShmdt}` | Process `Memory` mapping and host `SysvShmMapping.snapshot`, not kernel scratch; address key is the checked native guest pointer | Segment size returned by the kernel attachment operation; full mapped range must fit process memory | Raw bigint address is checked losslessly for guest width before attachment/map lookup; mmap result and full mapped range are checked; failure rolls back attachment; shmdt uses the exact checked key | Coherence/attach/detach steps are synchronous; snapshot owns bytes between boundaries; no kernel scratch view is retained | Node/browser; guest wasm32/64 | **Confirmed high-address alias defect.** `>>> 0` narrowed wasm64 hints/detach keys so an address above 4 GiB could alias a low mapping | **Implemented; validation pending.** High hint, unsafe integer, and non-aliasing detach regressions | @@ -472,7 +473,8 @@ scratch lease has already ended. | `host/src/kernel.ts::WasmPosixKernel.setsockopt`; `host/src/kernel-scratch.ts::{KERNEL_SCRATCH_EXPORT_NAMES,kernelScratchRequiredPointerArguments}`; `crates/kernel/src/wasm_api.rs::kernel_setsockopt` | The runtime-private allocator-owned API region; the lease lends an exact four-byte subrange and its derived wasm32/wasm64 pointer | Exactly one JavaScript scalar option value encoded as little-endian `u32` | The lease writes only the four-byte allocation subrange, proves current-memory bounds and pointer width, and invokes the existing five-argument export with `{ optval_ptr, optlen: 4 }`. The scratch contract classifies argument 3 as required, while the compiler audit defaults every generated kernel export to denied even if it is absent from the narrower runtime scratch list | One synchronous lease and one Rust call; nested public scratch use rejects and no pointer/view escapes | Node/browser shared wrapper; kernel wasm32/64 | **Confirmed live ownership/signature defect found by the widened audit.** The direct public wrapper passed only four arguments, treated the scalar `value` as `optval_ptr`, omitted `optlen`, and was absent from the scratch export list; rejection therefore occurred only after unowned address authority crossed the boundary | **Safe in current source; exact-head execution not claimed here.** Focused wasm32/wasm64 coverage targets the exact pointer type, four staged bytes, low-memory and post-capacity canaries, five-argument call, and generated-export default-deny regression | | `host/src/kernel.ts::{#hostRead,#readKernelBytes,#writeKernelBytes}` and VFS (`stat`, `statfs`, `pathconf`, `readlink`, `readdir`), clock, random, waitpid, network/getaddrinfo, GL, proc, and KMS import callers | Rust-owned slice/local/struct lent as pointer plus explicit capacity for one import; `host_kms_mode_info` instead derives its exact 68-byte capacity from generated `WpkDrmModeModeinfo`; producer backends receive host-owned staging buffers rather than a live kernel view | Genuine intrinsic backend span no larger than the Rust-supplied or generated capacity; fixed formats use their exact generated/Rust size | Raw signed wasm32 or bigint wasm64 import pointer is normalized losslessly; nonnegative safe length, complete current-memory range, detached/staged producer data, and producer count precede one `#writeKernelBytes` publish; no typed-array clamping or subclass getter counts as validation | Synchronous import only; neither backend nor callback receives a kernel-memory view | Node/browser; kernel wasm32/64 | Correct owner but incomplete conversions/result checks and live-view lending | **Implemented; validation pending.** Checked Rust-lent range plus host staging; high-bit wasm32 KMS and hostile-producer regressions; raw sink is explicitly allowlisted below | | `apps/browser-demos/test/fixtures/opfs-advisory-lock-client-worker.ts::issue`; `apps/browser-demos/test/epoll-repro.ts::main` | Test kernel allocations represented as `KernelScratchRegion`; one complete channel | Fixed diagnostic channel and event records | Same lease capacity/current-memory rules as production | One lease covers stage/dispatch/snapshot | OPFS: real Chromium wasm32; epoll diagnostic: Node wasm32 | Sizes fit, bare diagnostic pointers/views | **Safe in current source; browser execution is not claimed here.** The static contract includes both selected diagnostic sources; their exact-head runtime checks remain external validation targets | -| `host/src/process-memory.ts::createProcessMemory`; `host/src/{node,browser}-kernel-worker-entry.ts` clone/transport; process-worker argv/environment | Each process owns its own `WebAssembly.Memory`; worker transport owns detached `ArrayBuffer`/`SharedArrayBuffer` values. None is the kernel `Memory` or a pointer returned by `kernel_alloc_scratch` | Process layout, guest address-space, and worker-protocol limits | Guest-width range checks and transport-specific validation; the static audit seeds these exact constructors/messages as `process-memory`, never as allocator scratch | Process/worker generation lifetime; asynchronous transport may retain its own detached/shared process backing but no kernel-scratch view | Node/browser; guest wasm32/64 | Outside allocator model | **Reviewed process-memory exclusion; final transport tests pending.** Its separate owner is explicit rather than hidden under a generic raw-memory allowance | +| `host/src/worker-main.ts::{encodeStartupMetadata,buildKernelImports}`; `libc/musl-overlay/crt/crt1.c::{_start,add_startup_entry_length}`; `crates/kernel/src/wasm_api.rs::{kernel_argv_read,kernel_environ_get}` | The guest process owns an exact-lifetime anonymous `mmap` containing the native-width argv/env pointer table and strings. The CRT's fixed 32 KiB length table retains only query metadata until the mapping is complete. Neither region is kernel allocator scratch | Generated maxima of 4,096 argv entries, 4,096 environment entries, and 65,536 encoded bytes per entry; the complete strings, NULs, native-width pointers, and two terminators must fit generated `ARG_MAX` (4 MiB) | The host encodes one immutable launch snapshot. A zero-capacity import queries its exact length; a positive short capacity returns `ERANGE` without writing; an exact copy proves an integer capacity, a lossless wasm32/wasm64 pointer, and the complete current guest-memory range. The CRT retains the first lengths across `mmap`, passes each exact capacity, and traps before publication on allocation failure, error, count mismatch, or aggregate drift | Imports copy synchronously from the immutable snapshot. The CRT publishes no `argv`/`envp` pointer until every query, allocation, exact copy, and terminator has succeeded; the mapping then lives for the complete libc lifetime | Node/browser shared worker source; guest wasm32/64. Focused host tests exercise both widths; the real CRT is compiled for both widths and a native harness executes allocation-failure and query/copy-mismatch branches | **Confirmed unsafe at the audited base.** `Math.min` silently prefix-copied into undersized destinations, unchecked `Number(bigint)` could lose a wasm64 pointer, and CRT-local 1,024-entry plus 64/128 KiB buffers truncated otherwise admitted metadata | **Safe in focused source validation; final end-to-end candidate validation pending.** Exact/capacity+1, invalid pointers and lengths, memory-end boundaries, exact/oversized `ARG_MAX`, entry/count ceilings, immutable sequential/interleaved reads, allocation failure, and retry mismatch have executable regressions | +| `host/src/process-memory.ts::createProcessMemory`; `host/src/kernel-worker.ts::CentralizedKernelWorker.registerProcess`; `host/src/{node,browser}-kernel-worker-entry.ts` clone/transport, excluding the separately inventoried startup imports above | Each process owns its own `WebAssembly.Memory`; worker transport owns detached `ArrayBuffer`/`SharedArrayBuffer` values. None is the kernel `Memory` or a pointer returned by `kernel_alloc_scratch` | Process layout, guest address-space, and worker-protocol limits | Guest-width range checks and transport-specific validation; `registerProcess` rejects object identity with the active kernel `Memory` before any export or channel publication; the static audit seeds these exact constructors/messages as `process-memory`, never as allocator scratch | Process/worker generation lifetime; asynchronous transport may retain its own detached/shared process backing but no kernel-scratch view | Node/browser; guest wasm32/64 | Outside allocator model | **Reviewed process-memory exclusion; final transport tests pending.** Its separate owner is explicit and executable rather than hidden under a generic raw-memory allowance; focused registration coverage proves the kernel-memory identity rejection has no export side effect | | `host/src/framebuffer/registry.ts::{FramebufferRegistry,FbBinding.hostBuffer}` and browser framebuffer binding/rebinding messages | An mmap framebuffer view belongs to one process `Memory`; a write-based framebuffer owns a host `ArrayBuffer`/`Uint8ClampedArray` sized from checked geometry. Neither backing is kernel scratch | Binding `addr/len` or `height * stride`, plus framebuffer/device format limits | Registry binding and process-range/geometry checks select the exact backing; memory growth invalidates cached process views. Static seeds classify only these exact values as `framebuffer` | Mapping/binding lifetime and renderer callbacks may be asynchronous. Cached views are dropped on grow/unbind/teardown, and no allocator-scratch lease enters the registry | Browser presentation plus shared Node/browser host code; guest wasm32/64 | Outside allocator model | **Reviewed framebuffer exclusion, not a scratch-safety claim.** Framebuffer runtime and teardown coverage remains subsystem-specific | | `host/src/dri/registry.ts::{GbmBoRegistry,InternalEntry.sab}` and DRI/GBM bind/unbind synchronization | Each buffer object owns an explicit host `SharedArrayBuffer`; per-process mmap ranges belong to their respective process memories | Kernel-reported buffer-object size and each checked binding `addr/len` | The registry validates object/binding identity and copies only between the buffer object's canonical SAB and checked process ranges. Static seeds classify the SAB separately from both kernel and process memory | Buffer-object reference/binding lifetime; synchronization occurs at bind/unbind boundaries and may span processes, but never retains allocator scratch | Node/browser shared host path; guest wasm32/64 | Outside allocator model | **Reviewed explicit shared-backing exclusion.** Coherence is the DRI registry's snapshot contract, not a kernel-scratch lease | | `host/src/kernel-worker.ts::{populateMmapFromFile,pwriteFromProcessMemory,readSysvShmRange,writeSysvShmRange}` mapped-file and System V shared-memory backings | Authoritative VFS storage, process mappings, and `SysvShmMapping.snapshot`/shared backing own the durable bytes; main scratch is only the separately inventoried 65,536-byte transit chunk | Checked mapping/segment size, processed in bounded transit chunks | Mapping/process ranges and backing lengths are proved independently; each transit chunk uses its own main-scratch lease under the serialized host-operation contract | Backing/mapping lifetime may outlive a transit call. No scratch view survives a chunk or becomes the authoritative mapped/shared state | Node/browser; guest/kernel wasm32/64 | Outside allocator model except for the already reviewed transit lease | **Reviewed mapped/shared-backing exclusion.** Ownership is explicit; mapping coherence and serialization retain their own runtime validation | @@ -528,6 +530,7 @@ claim a separate old-head test execution. | Positive-count null dynamic buffers and zero-count null buffers | The old generic host path did not make a positive `Arg` extent independently imply a non-null source/destination, while a raw zero-count process pointer could cross address spaces even though no caller bytes were borrowed | wasm32 and wasm64 `read`/`write` with count 1 and pointer 0 fail with `EFAULT` before dispatch. Count 0 with pointer 0 reaches Rust only as the allocation start with zero extent, never as the caller address | | Positive-size fixed output nullability (`pipe(NULL)` and `uname(NULL)`) | Absence of `required` metadata was interpreted as permission for null, so fixed outputs could reach Rust without an owned destination and write through kernel address zero | Every generated pointer descriptor is exactly one of required or nullable; the reviewed nullable set is asserted exactly. wasm32 and wasm64 `pipe`/`uname` null outputs fail before kernel dispatch | | Non-null `Deref` outer buffer with a null length pointer | `accept`, `accept4`, `recvfrom`, `getsockname`, and `getpeername` derive output capacity from a separate `socklen_t *`. The old host could leave the non-null caller outer pointer in adjusted kernel args when that capacity pointer was null, crossing address spaces before later rejection | wasm32 and wasm64 `accept`, `accept4`, and `recvfrom` reject the malformed optional pair before scratch mutation or dispatch; the same shared planner covers every `Deref` descriptor | +| Legacy `readdir` zero-capacity producer over-report | The zero-length output planner retained ordinary byte-count producers but dropped outputs whose actual length comes from another staged record. A hostile successful `d_namlen = 1` could therefore publish the fixed dirent while evading the zero-byte name capacity | wasm32 and wasm64 retain the empty owned output record even with generated `copyOutLength`, reject the positive name length with `EIO`, publish neither dirent nor name, and preserve process and scratch canaries | | Absent versus zero-capacity optional socket-address output | The descriptor planner staged and later copied an `accept`/`accept4`/`recvfrom` length pointer even when the address pointer was null, although POSIX makes that field ignored. Nested `recvmsg` likewise collapsed absent `msg_name` and a supplied zero-capacity name into the same null kernel pointer, so it could overwrite an ignored native `msg_namelen` or fail to report the complete length for a present zero-capacity result | The generic nullable-`Deref` planner now canonicalizes an absent outer/length pair before any caller-memory read, while non-null output still requires its length. The canonical message wire represents a present zero-capacity name with the next allocation-owned cursor and represents absence with null; Rust and the host publish the complete length only for the former. wasm32/wasm64 regressions cover valid, out-of-range, negative, and unsafe-high-bit ignored length pointers, stale absent send/receive name lengths, present zero capacity, unchanged canaries, and both fixed socket descriptor forms | | One-snapshot, order-independent `Deref` planning | The old planner could size a destination from one `socklen_t` read and stage a later, mutated value; it also depended on the generated dynamic descriptor preceding the fixed length descriptor. A larger staged value could authorize Rust to use bytes the host had not reserved | The regressions mutate 4 to 28 between hypothetical reads and reverse the generated `recvfrom` descriptor order. The host performs one caller-memory read, stages that same value, and leaves the adjacent canary unchanged. Rust validates allocation order/range, with the documented alignment-bucket limitation because no separate unpadded capacity is encoded | | Option-sensitive `prctl` argument 1 | The generic descriptor treated argument 1 as a fixed 16-byte pointer for every option, so scalar operations such as `PR_SET_NO_NEW_PRIVS` had their value replaced by a scratch address | wasm32/wasm64 scalar options preserve the canonical low-32-bit value and stage no buffer; `PR_SET_NAME`/`PR_GET_NAME` stage exactly 16 bytes in the correct direction and reject null before dispatch | @@ -942,8 +945,9 @@ measurements and artifact fingerprints. `kernel_get_socket_timeout_ms`, `kernel_is_fd_nonblock`, `kernel_pick_signal_target_tid`, `kernel_thread_has_deliverable`, `kernel_spawn_reserved_process`, - `kernel_clear_process_metadata`, - `kernel_push_process_metadata_entry`, `kernel_set_cwd`, + `kernel_process_metadata_begin`, `kernel_process_metadata_cancel`, + `kernel_process_metadata_commit`, `kernel_process_metadata_stage`, + `kernel_set_cwd`, `kernel_spawn_scratch_begin`, `kernel_spawn_scratch_pointer`, `kernel_spawn_scratch_capacity`, `kernel_spawn_scratch_retained_capacity`, `kernel_spawn_scratch_cancel`, diff --git a/docs/posix-status.md b/docs/posix-status.md index b4b99f7dce..397e663aa7 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -128,7 +128,7 @@ same final-OFD lifetime rules. | Function | Status | Notes | |----------|--------|-------| | `fork()` | Partial | The kernel validates the calling task, allocates the child PID, and copies process state; the host starts a child Worker with copied Memory. The child inherits the calling task's blocked signal mask, and libc refreshes a copied pthread TID from the kernel before returning from `fork()`. Host-owned continuation and fork channel requests leave caught signals kernel-pending; after the import returns, libc performs an ordinary syscall checkpoint so the guest signal trampoline owns handler invocation and mask restoration without host-to-Wasm reentrancy. Initial launch mirrors the environment into kernel-owned process state; fork copies that metadata while instrumented rewind preserves the live libc `environ` in copied Memory, and `execve()` replaces both from its supplied `envp`. `wasm-fork-instrument` resumes the child at the call site with scalar locals in linked frames and versioned reconstruction recipes for references, exceptions, globals, tables, and dynamic-link activations. Root or later continuation-allocation failure and a negative `SYS_FORK` result unwind transactionally, create no child, and return the failure to the still-running parent. Main-thread and pthread fork are supported, including nested main/side-module stacks and process-owned dynamic-link/table replay. Pipes, sockets, PTYs, eventfd/timerfd/signalfd, memfd, procfs snapshots, and shared mappings retain their existing backings; signal and wait lifecycle state is copied/coordinated by the kernel. An inherited directory drops the parent's process-local host iterator and lazily reopens at the copied next-record cookie, so handles cannot alias, but later parent/child cursor movement is not shared. Ordinary regular-file OFD seek positions/status flags have the same copied rather than shared boundary. See [fork-instrumentation.md](fork-instrumentation.md) and the known OFD gap below. | -| `exec()` | Partial | Kernel-initiated via SYS_EXECVE (syscall 211). The host preflights the module, ABI, replacement memory, caller, deferred file actions, and a 4 MiB combined argv/environment representation (strings, terminators, and pointer entries) before replacing the image in place. Independently, each string is limited to the current 64 KiB process-metadata transfer; this is an implementation transport ceiling, not part of aggregate `ARG_MAX`, and oversize returns `E2BIG` without truncation. Preserves PID, non-CLOEXEC fds and their exact kernel-backed object state, new argv/envp (including an explicitly empty environment), CWD, the calling pthread's signal mask and directed queue, terminal queues, and `alarm()`/`ITIMER_REAL`; closes directory streams, deletes `timer_create()` timers, publishes and detaches old mappings, terminates sibling threads, and resets the program break before installing the new `__heap_base`. File mappings retain a stable writeback handle even after their original fd closes. Remaining gaps: POSIX message-queue descriptors are not process-owned and therefore cannot yet be closed on exec; epoll registrations track numeric fds rather than OFD identity, so close/dup and same-number replacement cases are incomplete; and main-thread-directed signals share the process-pending queue and therefore cannot be distinguished from process-directed signals when a worker pthread execs. | +| `exec()` | Partial | Kernel-initiated via SYS_EXECVE (syscall 211). The host preflights the module, ABI, replacement memory, caller, generated 4,096/4,096 argv/environment count caps, deferred file actions, and a 4 MiB combined argv/environment representation (strings, terminators, and pointer entries) before replacing the image in place. Independently, each string is limited to the current 64 KiB process-metadata transfer; this is an implementation transport ceiling, not part of aggregate `ARG_MAX`, and oversize returns `E2BIG` without truncation. At `_start`, immutable entry reads are zero-capacity queried and then copied complete-or-`ERANGE` into one exact-lifetime guest `mmap`; allocation failure or changed length traps before libc publishes a partial vector. Preserves PID, non-CLOEXEC fds and their exact kernel-backed object state, new argv/envp (including an explicitly empty environment), CWD, the calling pthread's signal mask and directed queue, terminal queues, and `alarm()`/`ITIMER_REAL`; closes directory streams, deletes `timer_create()` timers, publishes and detaches old mappings, terminates sibling threads, and resets the program break before installing the new `__heap_base`. File mappings retain a stable writeback handle even after their original fd closes. Remaining gaps: POSIX message-queue descriptors are not process-owned and therefore cannot yet be closed on exec; epoll registrations track numeric fds rather than OFD identity, so close/dup and same-number replacement cases are incomplete; and main-thread-directed signals share the process-pending queue and therefore cannot be distinguished from process-directed signals when a worker pthread execs. | | `wait()` / `waitpid()` / `wait4()` / `waitid()` | Partial | Rust-owned child status covers stop, continue, normal exit, and signal death. New status replaces older unconsumed status; `waitid(WNOWAIT)` preserves the current record. `WNOHANG`, `WUNTRACED`/`WSTOPPED`, `WEXITED`, and `WCONTINUED` are supported, as are specific-PID, any-child, same-process-group, and specific-process-group selection. Stop/continue reports do not reap; consuming exit status does. A top-level host launch has `ppid=0`; its status is consumed by the host API, and the host asks Rust to reap it only after its Workers can issue no more syscalls. `wait4()` returns the zero-filled resource-usage wire record described under `getrusage()`. Remaining gap: a blocked `pid == 0` / `P_PGID,id == 0` wait currently re-evaluates the caller's process group on each host retry instead of freezing it at call entry. | | `exit()` / `_exit()` | Partial | Closes all fds and dir streams, releases locks and mapping/backing ownership, and retains the low eight status bits. Normal codes 128–255 remain distinct from signal termination, which is stored separately. SIGCHLD is delivered to a guest parent and guest-child zombie state remains until `waitpid()` reaps it. The host separately reaps only exited direct children of `ppid=0` after Worker teardown. Orphan adoption is not yet implemented when a guest parent exits. | | `getpid()` | Full | Returns pid from Process struct. | @@ -149,10 +149,10 @@ same final-OFD lifetime rules. | `set_robust_list()` | Stub | No-op. Robust futex list tracking deferred until threading is fully tested. | | `futex()` | Partial | FUTEX_WAIT, FUTEX_WAKE, FUTEX_REQUEUE, FUTEX_CMP_REQUEUE, and FUTEX_WAKE_OP operate on one process's shared memory. Main-process WAIT uses host `Atomics.waitAsync`; pthread workers use direct `Atomics.wait`. Separate processes have separate `SharedArrayBuffer` objects, so these operations do not wake or synchronize a peer PID even when the futex word lies in a host-coordinated MAP_SHARED mapping. | | `execve()` | Partial | Delegates to the in-place `exec()` path and has the same remaining descriptor/signal/mapping limitations described above. | -| `execveat()` | Partial | SYS_EXECVEAT (386). Resolves fd path via `kernel_get_fd_path`, supports AT_EMPTY_PATH for `fexecve()`, and resolves relative paths against process CWD; otherwise has the same remaining `exec()` limitations. | +| `execveat()` | Partial | SYS_EXECVEAT (386). `AT_EMPTY_PATH` resolves the supplied fd through `kernel_get_fd_path` for `fexecve()`. Other relative paths resolve against the supplied directory fd through `kernel_get_dirfd_path` (`AT_FDCWD` selects the process CWD); absolute paths are independent of the fd. It otherwise has the same remaining `exec()` limitations. | | `fork()` (syscall) | Partial | Glue traps through channel IPC; the kernel copies process state, the host starts a child Worker, and `wasm-fork-instrument` replays the call stack so parent/child receive the POSIX return values. ABI 43 requires the activation-state-safe artifact capability before launch and validates the linked-frame, reference/exception recipe, mutable module-state, table-journal, and activation-catalog contracts. Unsafe ABI 42, malformed, or mixed-version artifacts fail before execution. Negative results replay to the caller without terminating the parent, including continuation-allocation failure before or during unwind. The ordinary-OFD limitations in the main `fork()` row still apply. | | `vfork()` | Partial | Alias for `fork()` and therefore has the same continuation/OFD limitations. It neither suspends the calling parent thread nor shares that process memory with the child until `exec()` or `_exit()`, so it cannot avoid Kandelo's eager fork-memory copy. | -| `posix_spawn()` | Partial | **Non-forking implementation** (this kernel's invention; no Linux equivalent). Glue issues `SYS_SPAWN` (500) with a marshalled blob (argv + envp + file actions + spawn attrs). Generated platform limits supply the advertised 4 MiB combined argv/environment `ARG_MAX` and 4,096-byte `PATH_MAX` including NUL; the separate generated wire contract defines a 40-byte header, 28-byte action records, defensive caps of 4,096 argv entries, 4,096 environment entries, and 1,024 actions, and an 8,417,320-byte complete transport ceiling. The count and transport caps defend this representation; they are not additional POSIX limits. Independently, each argv/environment string must fit the current 64 KiB process-metadata transfer. That host implementation ceiling is separate from aggregate `ARG_MAX`. The host proves caller ranges, parsed limits, the selected kernel-owned allocation capacity, and the current kernel-memory range independently; fitting inside total kernel Wasm memory is not proof that the destination allocation owns those bytes. Ordinary blobs reuse channel scratch. Each larger blob begins a fresh exclusive reservation on a Rust-owned reusable high-water buffer, reads its pointer and capacity, copies under one synchronous lease, and commits with the matching opaque token. Begin and pointer/capacity queries are nonblocking; commit and cancellation wait on a no-host-import critical section. After every successful begin, the host cancels in a `finally` block, including setup and copy failures, so it returns with either a released unconsumed token or a definitive already-consumed/stale result. Overlapping or reentrant large-spawn attempts cannot replace live bytes. The host passes the calling TID to `kernel_spawn_process` or `kernel_spawn_reserved_process`; the Rust `ProcessTable` validates that task, allocates the child PID from the global task-ID sequence, and builds the child Process descriptor before `onSpawn` launches a fresh Worker. The child inherits the calling task's signal mask unless `POSIX_SPAWN_SETSIGMASK` replaces it. No fork, no `wpk_fork_*` rewind, no exec replay. Supports POSIX_SPAWN_SETSID / SETPGROUP / SETSIGMASK / SETSIGDEF and FDOP_OPEN / CLOSE / DUP2 / CHDIR / FCHDIR. SIG_IGN dispositions persist across the implicit exec; custom handlers reset to SIG_DFL (POSIX exec semantics). An inherited directory never aliases the parent's live host iterator: spawn preserves its next-record cookie and lazily reopens a child-owned iterator there. Its later cursor movement and ordinary-file OFD metadata are still process-local rather than shared; see the known OFD gap below. Regression-guarded: `kernel_get_fork_count` exposes a per-process counter the test suite asserts is unchanged across SYS_SPAWN. See `docs/plans/2026-05-04-non-forking-posix-spawn-design.md`. | +| `posix_spawn()` | Partial | **Non-forking implementation** (this kernel's invention; no Linux equivalent). Glue issues `SYS_SPAWN` (500) with a marshalled blob (argv + envp + file actions + spawn attrs). Generated platform limits supply the advertised 4 MiB combined argv/environment `ARG_MAX`, 4,096-byte `PATH_MAX` including NUL, and defensive 4,096-entry caps for each process-startup vector; the separate generated wire contract aliases those counts and defines a 40-byte header, 28-byte action records, 1,024 actions, and an 8,417,320-byte complete transport ceiling. These representation caps are not additional POSIX limits. Independently, each argv/environment string must fit the current 64 KiB process-metadata transfer. That host implementation ceiling is separate from aggregate `ARG_MAX`. Child startup uses the same immutable query/exact-copy guest-mapping contract as `exec()`, so it cannot silently clamp counts or keep only 64/128 KiB prefixes. The host proves caller ranges, parsed limits, the selected kernel-owned allocation capacity, and the current kernel-memory range independently; fitting inside total kernel Wasm memory is not proof that the destination allocation owns those bytes. Ordinary blobs reuse channel scratch. Each larger blob begins a fresh exclusive reservation on a Rust-owned reusable high-water buffer, reads its pointer and capacity, copies under one synchronous lease, and commits with the matching opaque token. Begin and pointer/capacity queries are nonblocking; commit and cancellation wait on a no-host-import critical section. After every successful begin, the host cancels in a `finally` block, including setup and copy failures, so it returns with either a released unconsumed token or a definitive already-consumed/stale result. Overlapping or reentrant large-spawn attempts cannot replace live bytes. The host passes the calling TID to `kernel_spawn_process` or `kernel_spawn_reserved_process`; the Rust `ProcessTable` validates that task, allocates the child PID from the global task-ID sequence, and builds the child Process descriptor before `onSpawn` launches a fresh Worker. The child inherits the calling task's signal mask unless `POSIX_SPAWN_SETSIGMASK` replaces it. No fork, no `wpk_fork_*` rewind, no exec replay. Supports POSIX_SPAWN_SETSID / SETPGROUP / SETSIGMASK / SETSIGDEF and FDOP_OPEN / CLOSE / DUP2 / CHDIR / FCHDIR. SIG_IGN dispositions persist across the implicit exec; custom handlers reset to SIG_DFL (POSIX exec semantics). An inherited directory never aliases the parent's live host iterator: spawn preserves its next-record cookie and lazily reopens a child-owned iterator there. Its later cursor movement and ordinary-file OFD metadata are still process-local rather than shared; see the known OFD gap below. Regression-guarded: `kernel_get_fork_count` exposes a per-process counter the test suite asserts is unchanged across SYS_SPAWN. See `docs/plans/2026-05-04-non-forking-posix-spawn-design.md`. | | `posix_spawnp()` | Partial | PATH search lives in libc (`libc/musl-overlay/src/process/wasm32posix/posix_spawnp.c`); resolves the absolute path then delegates to `posix_spawn()`. Empty PATH entries are treated as `.` and EACCES is deferred per `__execvpe` policy. It inherits `posix_spawn()`'s cross-process open-file-description limitation. | | `clone()` | Partial | Thread-style clone (CLONE_VM\|CLONE_THREAD) supported. The Rust `ProcessTable` allocates the TID from the same global task-ID sequence as every PID, and the host spawns a thread Worker sharing the parent's Memory. Normal pthread return, pthread_exit, and cancellation cleanup remain per-thread and wake join/clear-TID waiters; uncaught fatal Wasm traps in a pthread worker terminate the whole process with signal-style wait status. | | `personality()` | Stub | Returns 0 (PER_LINUX). | diff --git a/host/src/generated/abi.ts b/host/src/generated/abi.ts index 3dbb29646e..67b3591121 100644 --- a/host/src/generated/abi.ts +++ b/host/src/generated/abi.ts @@ -343,6 +343,19 @@ export const WPK_FORK_REQUIRED_EXPORTS = [ export const SCHED_AFFINITY_MASK_SIZE = 4 as const; +export const PROCESS_SNAPSHOT_COUNT_OFFSET = 0 as const; +export const PROCESS_SNAPSHOT_COUNT_BYTES = 4 as const; +export const PROCESS_SNAPSHOT_RECORDS_OFFSET = 4 as const; +export const PROCESS_SNAPSHOT_HEADER_BYTES = 36 as const; +export const PROCESS_SNAPSHOT_PID_OFFSET = 0 as const; +export const PROCESS_SNAPSHOT_PPID_OFFSET = 4 as const; +export const PROCESS_SNAPSHOT_UID_OFFSET = 8 as const; +export const PROCESS_SNAPSHOT_GID_OFFSET = 12 as const; +export const PROCESS_SNAPSHOT_VSIZE_OFFSET = 16 as const; +export const PROCESS_SNAPSHOT_STATE_OFFSET = 24 as const; +export const PROCESS_SNAPSHOT_COMM_LEN_OFFSET = 28 as const; +export const PROCESS_SNAPSHOT_CMDLINE_LEN_OFFSET = 32 as const; + export const KERNEL_SCRATCH_SIGNAL_DELIVERY_BYTES = 56 as const; export const KERNEL_SCRATCH_FD_PAIR_BYTES = 8 as const; export const KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES = 8 as const; @@ -363,6 +376,10 @@ export const POSIX_ARG_MAX_BYTES = 4194304 as const; export const POSIX_PATH_MAX_BYTES = 4096 as const; export const POSIX_NAME_MAX_BYTES = 256 as const; export const PROCESS_METADATA_ENTRY_MAX_BYTES = 65536 as const; +export const PROCESS_STARTUP_MAX_ARGV_COUNT = 4096 as const; +export const PROCESS_STARTUP_MAX_ENVP_COUNT = 4096 as const; +export const PROCESS_METADATA_KIND_ARGV = 0 as const; +export const PROCESS_METADATA_KIND_ENVIRONMENT = 1 as const; export const POSIX_NGROUPS_MAX = 32 as const; export const SYSV_MSG_MAX_BYTES = 8192 as const; export const MAX_REPORTABLE_TRANSFER_BYTES = 2147483647 as const; @@ -404,6 +421,14 @@ export const PROCESS_CMSGHDR_WASM64_LEN_OFFSET = 0 as const; export const PROCESS_CMSGHDR_WASM64_LEVEL_OFFSET = 8 as const; export const PROCESS_CMSGHDR_WASM64_TYPE_OFFSET = 12 as const; export const PROCESS_CMSGHDR_WASM64_DATA_OFFSET = 16 as const; +export const PROCESS_GROUP_REQ_WASM32_SIZE = 132 as const; +export const PROCESS_GROUP_REQ_WASM32_GROUP_OFFSET = 4 as const; +export const PROCESS_GROUP_SOURCE_REQ_WASM32_SIZE = 260 as const; +export const PROCESS_GROUP_SOURCE_REQ_WASM32_SOURCE_OFFSET = 132 as const; +export const PROCESS_GROUP_REQ_WASM64_SIZE = 136 as const; +export const PROCESS_GROUP_REQ_WASM64_GROUP_OFFSET = 8 as const; +export const PROCESS_GROUP_SOURCE_REQ_WASM64_SIZE = 264 as const; +export const PROCESS_GROUP_SOURCE_REQ_WASM64_SOURCE_OFFSET = 136 as const; export const PROCESS_SIGINFO_SIGNO_OFFSET = 0 as const; export const PROCESS_SIGINFO_ERRNO_OFFSET = 4 as const; export const PROCESS_SIGINFO_CODE_OFFSET = 8 as const; @@ -476,7 +501,6 @@ export const HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS = [ "kernel_alloc_scratch", "kernel_blocking_retry_release", "kernel_blocking_retry_token", - "kernel_clear_process_metadata", "kernel_commit_process_exit", "kernel_create_process", "kernel_create_process_with_stdio", @@ -484,6 +508,9 @@ export const HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS = [ "kernel_exec_prepare", "kernel_exec_setup_for_thread", "kernel_fork_process", + "kernel_get_cwd", + "kernel_get_dirfd_path", + "kernel_get_fd_path", "kernel_get_parent_pid", "kernel_get_process_exit_signal", "kernel_get_process_state", @@ -503,7 +530,10 @@ export const HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS = [ "kernel_pick_signal_target_tid", "kernel_pipe_has_readers", "kernel_posix_timer_fire", - "kernel_push_process_metadata_entry", + "kernel_process_metadata_begin", + "kernel_process_metadata_cancel", + "kernel_process_metadata_commit", + "kernel_process_metadata_stage", "kernel_reap_exited_child", "kernel_remove_process", "kernel_semctl_array_bytes", @@ -637,6 +667,9 @@ export const WAKE_PROCESS_CONTINUED = 32 as const; export const STRUCT_SIZE_WASM_STAT = 88 as const; export const STRUCT_SIZE_WASM_DIRENT = 16 as const; +export const WASM_DIRENT_INO_OFFSET = 0 as const; +export const WASM_DIRENT_TYPE_OFFSET = 8 as const; +export const WASM_DIRENT_NAME_LENGTH_OFFSET = 12 as const; export const STRUCT_SIZE_WASM_TIMESPEC = 16 as const; export const STRUCT_SIZE_WASM_POLL_FD = 8 as const; export const WASM_POLL_FD_FD_OFFSET = 0 as const; @@ -1276,12 +1309,16 @@ export type SyscallArgSizeSpec = | { type: "fixed"; size: number } | { type: "process-layout"; wasm32Size: number; wasm64Size: number }; +export type SyscallArgCopyOutLengthSpec = + { type: "u32-field"; argIndex: number; offset: number }; + export const PROCESS_POINTER_WIDTH_ARG_INDEX = 5 as const; export interface SyscallArgDesc { argIndex: number; direction: SyscallArgDirection; size: SyscallArgSizeSpec; + copyOutLength?: SyscallArgCopyOutLengthSpec; nullable?: boolean; required?: boolean; } @@ -1435,7 +1472,7 @@ export const SYSCALL_ARGS: Record = { ], 26: [ { argIndex: 1, direction: "out", size: { type: "fixed", size: 16 }, required: true }, - { argIndex: 2, direction: "out", size: { type: "arg", argIndex: 3 }, required: true }, + { argIndex: 2, direction: "out", size: { type: "arg", argIndex: 3 }, required: true, copyOutLength: { type: "u32-field", argIndex: 1, offset: 12 } }, ], 36: [ { argIndex: 1, direction: "in", size: { type: "fixed", size: 16 }, nullable: true }, diff --git a/host/src/kernel-scratch.ts b/host/src/kernel-scratch.ts index 107ab0edbe..1191e04792 100644 --- a/host/src/kernel-scratch.ts +++ b/host/src/kernel-scratch.ts @@ -117,10 +117,12 @@ const typedArrayByteLength = intrinsicObjectGetOwnPropertyDescriptor( * borrowed bytes before returning. `kernel_handle_channel` scopes its raw * mailbox view to decoding/publishing and clears the active task binding; * `kernel_spawn_process` parses the complete blob into owned Rust values - * before it enters process-table or host work. The transfer execute export - * names no raw pointer, but its token authorizes Rust to borrow the allocation - * represented by this exact lease. Adding a name requires the same lifetime - * review and a pointer-position update below. + * before it enters process-table or host work; and + * `kernel_process_metadata_stage` copies one complete entry into a token-owned + * Rust vector before returning. The transfer execute export names no raw + * pointer, but its token authorizes Rust to borrow the allocation represented + * by this exact lease. Adding a name requires the same lifetime review and a + * pointer-position update below. */ /** @internal Exported only for the Rust/host semantic-role drift contract. */ export const KERNEL_SCRATCH_EXPORT_NAMES = intrinsicObjectFreeze([ @@ -129,6 +131,7 @@ export const KERNEL_SCRATCH_EXPORT_NAMES = intrinsicObjectFreeze([ "kernel_drain_wakeup_events", "kernel_enum_procs", "kernel_get_cwd", + "kernel_get_dirfd_path", "kernel_get_fd_path", "kernel_getrusage", "kernel_getsockopt", @@ -142,9 +145,9 @@ export const KERNEL_SCRATCH_EXPORT_NAMES = intrinsicObjectFreeze([ "kernel_pipe_read", "kernel_pipe_write", "kernel_poll", + "kernel_process_metadata_stage", "kernel_pty_master_read", "kernel_pty_master_write", - "kernel_push_process_metadata_entry", "kernel_read_proc_maps", "kernel_recv", "kernel_select", @@ -231,16 +234,17 @@ export function kernelScratchRequiredPointerArguments( case "kernel_tcgetattr": return REQUIRED_POINTER_1; case "kernel_dequeue_signal": + case "kernel_get_dirfd_path": case "kernel_get_fd_path": case "kernel_ioctl": case "kernel_ipc_shm_read_chunk": case "kernel_ipc_shm_write_chunk": case "kernel_pipe_read": case "kernel_pipe_write": - case "kernel_push_process_metadata_entry": case "kernel_spawn_process": case "kernel_tcsetattr": return REQUIRED_POINTER_2; + case "kernel_process_metadata_stage": case "kernel_setsockopt": case "kernel_socketpair": return REQUIRED_POINTER_3; @@ -287,6 +291,7 @@ function isKernelScratchExportName( case "kernel_drain_wakeup_events": case "kernel_enum_procs": case "kernel_get_cwd": + case "kernel_get_dirfd_path": case "kernel_get_fd_path": case "kernel_getrusage": case "kernel_getsockopt": @@ -300,9 +305,9 @@ function isKernelScratchExportName( case "kernel_pipe_read": case "kernel_pipe_write": case "kernel_poll": + case "kernel_process_metadata_stage": case "kernel_pty_master_read": case "kernel_pty_master_write": - case "kernel_push_process_metadata_entry": case "kernel_read_proc_maps": case "kernel_recv": case "kernel_select": diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index 77eda05de7..bebec3dd79 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -145,6 +145,22 @@ import { MAX_REPORTABLE_TRANSFER_BYTES, MAX_TRANSFER_ALLOCATION_BYTES, PROCESS_METADATA_ENTRY_MAX_BYTES, + PROCESS_METADATA_KIND_ARGV, + PROCESS_METADATA_KIND_ENVIRONMENT, + PROCESS_STARTUP_MAX_ARGV_COUNT, + PROCESS_STARTUP_MAX_ENVP_COUNT, + PROCESS_SNAPSHOT_CMDLINE_LEN_OFFSET, + PROCESS_SNAPSHOT_COMM_LEN_OFFSET, + PROCESS_SNAPSHOT_COUNT_BYTES, + PROCESS_SNAPSHOT_COUNT_OFFSET, + PROCESS_SNAPSHOT_GID_OFFSET, + PROCESS_SNAPSHOT_HEADER_BYTES, + PROCESS_SNAPSHOT_PID_OFFSET, + PROCESS_SNAPSHOT_PPID_OFFSET, + PROCESS_SNAPSHOT_RECORDS_OFFSET, + PROCESS_SNAPSHOT_STATE_OFFSET, + PROCESS_SNAPSHOT_UID_OFFSET, + PROCESS_SNAPSHOT_VSIZE_OFFSET, PROCESS_CMSGHDR_WASM32_ALIGN, PROCESS_CMSGHDR_WASM32_DATA_OFFSET, PROCESS_CMSGHDR_WASM32_LEN_OFFSET, @@ -396,6 +412,7 @@ const EMSGSIZE = 90; const EOVERFLOW = 75; const ENODEV = 19; const ENOMEM = 12; +const ERANGE = 34; const ENAMETOOLONG = 36; const ENOENT = 2; const ENOSYS = 38; @@ -578,12 +595,6 @@ function boundedChannelArgumentSize( syscallNr: number, argIndex: number, ): number | undefined { - if (syscallNr === ABI_SYSCALLS.Getcwd && argIndex === 0) { - return POSIX_PATH_MAX_BYTES; - } - if (syscallNr === ABI_SYSCALLS.Realpath && argIndex === 1) { - return POSIX_PATH_MAX_BYTES; - } if (syscallNr === ABI_SYSCALLS.Readdir && argIndex === 2) { return POSIX_NAME_MAX_BYTES; } @@ -672,6 +683,19 @@ function dereferencedChannelOutputMaximum( return undefined; } +function isInputChannelByteCountResult( + syscallNr: number, + argIndex: number, +): boolean { + return argIndex === 1 + && ( + syscallNr === ABI_SYSCALLS.Write + || syscallNr === ABI_SYSCALLS.Pwrite + || syscallNr === ABI_SYSCALLS.Send + || syscallNr === ABI_SYSCALLS.Sendto + ); +} + function applyNullableDereferencePairPresence( descriptors: SyscallArgDesc[], rawArgs: readonly bigint[], @@ -801,9 +825,6 @@ function isValidMemoryRange( * each list's terminating null pointer. This matches the advertised 4 MiB * _SC_ARG_MAX boundary without imposing a separate argument-count ceiling. */ -const PROCESS_METADATA_ARGV = 0; -const PROCESS_METADATA_ENVIRONMENT = 1; - /** * Largest complete SYS_SPAWN wire blob accepted by the host. * @@ -1315,8 +1336,8 @@ export interface SyscallTraceEvent { /** * A snapshot of one process from the kernel's table. Mirrors the binary - * record kernel_enum_procs writes — see crates/kernel/src/wasm_api.rs - * (kernel_enum_procs) for the authoritative wire format. + * record kernel_enum_procs writes. The generated PROCESS_SNAPSHOT_* values + * come from crates/shared/src/lib.rs::process_snapshot_wire. */ export interface ProcessSnapshot { pid: number; @@ -1337,31 +1358,105 @@ export interface ProcessSnapshot { } function parseProcSnapshots(mem: Uint8Array): ProcessSnapshot[] { - if (mem.byteLength < 4) return []; + const malformed = (reason: string): never => { + throw new KernelScratchError( + `malformed kernel process snapshot: ${reason}`, + EIO, + ); + }; + if (mem.byteLength < PROCESS_SNAPSHOT_RECORDS_OFFSET) { + malformed("missing count prefix"); + } const dv = new DataView(mem.buffer, mem.byteOffset, mem.byteLength); - const count = dv.getUint32(0, true); - let off = 4; + const count = dv.getUint32(PROCESS_SNAPSHOT_COUNT_OFFSET, true); + if ( + PROCESS_SNAPSHOT_COUNT_OFFSET + PROCESS_SNAPSHOT_COUNT_BYTES + !== PROCESS_SNAPSHOT_RECORDS_OFFSET + || count * PROCESS_SNAPSHOT_HEADER_BYTES + > mem.byteLength - PROCESS_SNAPSHOT_RECORDS_OFFSET + ) { + malformed("record count exceeds the returned byte range"); + } + let off = PROCESS_SNAPSHOT_RECORDS_OFFSET; const out: ProcessSnapshot[] = []; const dec = new TextDecoder("utf-8", { fatal: false }); for (let i = 0; i < count; i++) { - if (off + 36 > mem.byteLength) break; - const pid = dv.getUint32(off, true); off += 4; - const ppid = dv.getUint32(off, true); off += 4; - const uid = dv.getUint32(off, true); off += 4; - const gid = dv.getUint32(off, true); off += 4; - const vsizeBytes = Number(dv.getBigUint64(off, true)); off += 8; - const state = String.fromCharCode(dv.getUint32(off, true)) as ProcessSnapshot["state"]; off += 4; - const commLen = dv.getUint32(off, true); off += 4; - const cmdLen = dv.getUint32(off, true); off += 4; - if (off + commLen + cmdLen > mem.byteLength) break; + if (PROCESS_SNAPSHOT_HEADER_BYTES > mem.byteLength - off) { + malformed(`record ${i} has a truncated header`); + } + const headerOffset = off; + const pid = dv.getUint32( + headerOffset + PROCESS_SNAPSHOT_PID_OFFSET, + true, + ); + const ppid = dv.getUint32( + headerOffset + PROCESS_SNAPSHOT_PPID_OFFSET, + true, + ); + const uid = dv.getUint32( + headerOffset + PROCESS_SNAPSHOT_UID_OFFSET, + true, + ); + const gid = dv.getUint32( + headerOffset + PROCESS_SNAPSHOT_GID_OFFSET, + true, + ); + const vsize = dv.getBigUint64( + headerOffset + PROCESS_SNAPSHOT_VSIZE_OFFSET, + true, + ); + if (vsize > BigInt(Number.MAX_SAFE_INTEGER)) { + malformed(`record ${i} has an unrepresentable virtual size`); + } + const vsizeBytes = Number(vsize); + const stateCode = dv.getUint32( + headerOffset + PROCESS_SNAPSHOT_STATE_OFFSET, + true, + ); + const state = ((): ProcessSnapshot["state"] => { + switch (stateCode) { + case 0x52: + return "R"; + case 0x5a: + return "Z"; + case 0x53: + return "S"; + case 0x44: + return "D"; + case 0x54: + return "T"; + case 0x49: + return "I"; + default: + return malformed(`record ${i} has an invalid process state`); + } + })(); + const commLen = dv.getUint32( + headerOffset + PROCESS_SNAPSHOT_COMM_LEN_OFFSET, + true, + ); + const cmdLen = dv.getUint32( + headerOffset + PROCESS_SNAPSHOT_CMDLINE_LEN_OFFSET, + true, + ); + off += PROCESS_SNAPSHOT_HEADER_BYTES; + if (commLen > mem.byteLength - off) { + malformed(`record ${i} has a truncated command name`); + } const comm = dec.decode(mem.subarray(off, off + commLen)); off += commLen; + if (cmdLen > mem.byteLength - off) { + malformed(`record ${i} has a truncated command line`); + } const cmdRaw = mem.subarray(off, off + cmdLen); off += cmdLen; // /proc//cmdline is null-separated; convert to space-separated. const cmdline = dec.decode(cmdRaw).replace(/\0/g, " ").trimEnd(); out.push({ pid, ppid, uid, gid, vsizeBytes, state, comm, cmdline: cmdline || `[${comm}]` }); } + if (off !== mem.byteLength) { + malformed("trailing bytes follow the declared records"); + } return out; } @@ -2475,6 +2570,11 @@ interface CentralizedKernelWorkerTestAuthority { probeWaitableChildCapacityForTest( options: WaitableChildCapacityProbeTestOptions, ): KernelCapacityProbeResult; + readKernelOwnedPathForTest( + registrationWitness: ChannelInfo, + fd: number | null, + directoryOnly?: boolean, + ): SharedMmapHostResult; inspectThreadTransportStateForLifecycleTest( pid: number, ): ThreadTransportStateTestResult; @@ -4201,6 +4301,54 @@ export class CentralizedKernelWorker { } return outcome.value; }, + readKernelOwnedPathForTest: ( + registrationWitness, + fd, + directoryOnly = false, + ): SharedMmapHostResult => { + const outcome: { + inputError?: TypeError; + value?: SharedMmapHostResult; + } = {}; + this.#runImmediateKernelEntry( + "kernel-owned path transfer test", + (entry) => { + const registration = this.processes.get( + registrationWitness.pid, + ); + if ( + registration === undefined + || registration.memory !== registrationWitness.memory + || !registration.channels.includes(registrationWitness) + || ( + fd !== null + && ( + !Number.isSafeInteger(fd) + || fd < -0x8000_0000 + || fd > 0x7fff_ffff + ) + ) + ) { + outcome.inputError = new TypeError( + "kernel-owned path test identity is invalid", + ); + return undefined; + } + outcome.value = this.#readKernelOwnedPath( + registration.pid, + fd, + entry, + directoryOnly, + ); + return undefined; + }, + ); + if (outcome.inputError !== undefined) throw outcome.inputError; + if (outcome.value === undefined) { + throw new Error("kernel-owned path transfer returned no result"); + } + return outcome.value; + }, inspectThreadTransportStateForLifecycleTest: (pid) => { const outcome: { inputError?: TypeError; @@ -4938,6 +5086,193 @@ export class CentralizedKernelWorker { return result; } + /** + * Read one complete kernel-owned canonical path through capacity-carrying + * scratch. Canonical CWD/OFD paths may exceed PATH_MAX after resolving a + * short relative name against an already-deep directory. + */ + #readKernelOwnedPath( + pid: number, + fd: number | null, + entry: KernelWorkerEntryContext, + directoryOnly = false, + ): SharedMmapHostResult { + if (fd === null && directoryOnly) { + return { kind: "error", errno: EINVAL }; + } + const exportName = fd === null + ? "kernel_get_cwd" + : directoryOnly + ? "kernel_get_dirfd_path" + : "kernel_get_fd_path"; + if (typeof entry.instance.exports[exportName] !== "function") { + return { kind: "error", errno: ENOSYS }; + } + const errnoForResult = (result: number): number => { + if (!Number.isSafeInteger(result) || result >= 0) return EIO; + const errno = -result; + return errno > 0 && errno <= MAX_KERNEL_TASK_ID ? errno : EIO; + }; + + const mainRegion = this.#requireMainScratchRegion(); + let initial: { result: number; bytes: Uint8Array }; + try { + initial = mainRegion.withLease((lease) => { + const pointer = lease.exportPointer(0, mainRegion.capacity); + const result = this.#invokeEntryScratchExport( + entry, + lease, + exportName, + fd === null + ? [pid, pointer, mainRegion.capacity] + : [pid, fd, pointer, mainRegion.capacity], + ); + if (!Number.isSafeInteger(result)) { + throw new KernelScratchError( + `${exportName} returned a non-integer byte count`, + EIO, + ); + } + return { + result, + bytes: result > 0 && result <= mainRegion.capacity + ? lease.copyOut(0, result) + : new Uint8Array(0), + }; + }); + } catch (error) { + this.#rethrowKernelEntryFatal(error); + return { kind: "error", errno: EIO }; + } + if (initial.result >= 0) { + if (initial.result > mainRegion.capacity) { + return { kind: "error", errno: EIO }; + } + return { kind: "ok", value: initial.bytes }; + } + if (initial.result !== -ERANGE) { + return { kind: "error", errno: errnoForResult(initial.result) }; + } + + let required: number; + try { + // WHY: the attempt, size query, reservation, and exact retry all remain + // inside this one synchronous kernel entry. Neither getter calls a host + // import, so process path state cannot change between these observations. + required = mainRegion.withLease((lease) => { + const pointer = lease.exportPointer(0, 0); + return this.#invokeEntryScratchExport( + entry, + lease, + exportName, + fd === null ? [pid, pointer, 0] : [pid, fd, pointer, 0], + ); + }); + } catch (error) { + this.#rethrowKernelEntryFatal(error); + return { kind: "error", errno: EIO }; + } + if ( + !Number.isSafeInteger(required) + || required <= mainRegion.capacity + || required > MAX_REPORTABLE_TRANSFER_BYTES + ) { + return { + kind: "error", + errno: required > MAX_REPORTABLE_TRANSFER_BYTES ? EOVERFLOW : EIO, + }; + } + if (this.#largeTransferScratchInUse) { + throw new KernelReentrantEntryError( + `${exportName} transfer reservation`, + ); + } + this.#largeTransferScratchInUse = true; + + let reservation: ReservedTransferScratch | null = null; + let output: Uint8Array | null = null; + let errno = EIO; + let fatalError: KernelTransferExecuteTrapError | null = null; + try { + const begun = this.#beginLargeTransferScratch(required, entry); + reservation = begun.reservation; + errno = begun.errno; + if (reservation?.region) { + output = reservation.region.withLease((lease) => { + // WHY: the Rust-owned Vec remains in its exclusive Reserved state, + // so its allocation cannot move or be dropped. This synchronous + // getter receives only this lease's exact pointer/capacity pair; + // detach every byte before revocation and token cancellation. + const pointer = lease.exportPointer(0, required); + const result = this.#invokeEntryScratchExport( + entry, + lease, + exportName, + fd === null + ? [pid, pointer, required] + : [pid, fd, pointer, required], + ); + if (result !== required) { + errno = result < 0 ? errnoForResult(result) : EIO; + return null; + } + return lease.copyOut(0, required); + }); + if (output !== null) errno = 0; + } + } catch (error) { + if (isKernelExportFailure(error)) { + // WHY: Rust may have trapped after consuming the reservation. Record + // the branded failure before finally decides that cancellation is no + // longer safe; the exact static allowance reviews this settlement. + fatalError = new KernelTransferExecuteTrapError( + `${exportName} transfer reservation trapped`, + error, + ); + } else if ( + error instanceof KernelTransferExecuteTrapError + || this.#kernelFatalError !== null + ) { + fatalError = error instanceof KernelTransferExecuteTrapError + ? error + : new KernelTransferExecuteTrapError( + `${exportName} transfer reservation trapped`, + error, + ); + } else { + errno = error instanceof KernelScratchError ? error.errno : EIO; + } + } finally { + if (reservation?.region) { + try { + reservation.region.revoke(); + } catch (error) { + fatalError ??= new KernelTransferExecuteTrapError( + `${exportName} transfer lease could not be revoked`, + error, + ); + } + } + if (reservation && fatalError === null) { + try { + this.#cancelLargeTransferScratch(reservation.token, entry); + } catch (error) { + this.#rethrowKernelEntryFatal(error); + fatalError = new KernelTransferExecuteTrapError( + `${exportName} transfer reservation could not be cancelled`, + error, + ); + } + } + if (fatalError === null) this.#largeTransferScratchInUse = false; + } + + if (fatalError !== null) throw fatalError; + return output === null + ? { kind: "error", errno } + : { kind: "ok", value: output }; + } + private checkedProcessRange( channel: ChannelInfo, pointer: number | bigint, @@ -5480,13 +5815,85 @@ export class CentralizedKernelWorker { let outputContractViolation = false; for (const planned of plan.plannedChannelScratchArgs) { const { desc } = planned; + if ( + desc.direction === "in" + && retVal >= 0 + && isInputChannelByteCountResult( + plan.syscallNr, + desc.argIndex, + ) + && retVal > planned.size + ) { + // WHY: a successful byte-count producer cannot have consumed more + // caller bytes than this exact detached input subregion contained. + // Treat an over-report as a protocol failure just like an output + // producer exceeding its owned destination. + outputContractViolation = true; + break; + } if (desc.direction !== "out" && desc.direction !== "inout") { continue; } if (desc.direction === "out" && retVal < 0) continue; let copySize = planned.size; - if (desc.direction === "out" && desc.size.type === "arg") { + const copyOutLength = desc.copyOutLength; + if ( + desc.direction === "out" + && copyOutLength?.type === "u32-field" + ) { + const lengthSource = plan.plannedChannelScratchArgs.find( + (candidate) => + candidate.desc.argIndex === copyOutLength.argIndex, + ); + const lengthEnd = + copyOutLength.offset + Uint32Array.BYTES_PER_ELEMENT; + if ( + lengthSource === undefined + || !Number.isSafeInteger(lengthEnd) + || lengthEnd > lengthSource.size + ) { + throw new KernelScratchError( + "generated output-length field is outside its staged argument", + EIO, + ); + } + const actualLength = lease + .dataView( + lengthSource.scratchOffset + copyOutLength.offset, + Uint32Array.BYTES_PER_ELEMENT, + ) + .getUint32(0, true); + // WHY: a non-byte-count syscall may identify its complete output + // through another generated record, but that producer field never + // grants authority beyond this argument's owned capacity. + if (actualLength > planned.size) { + outputContractViolation = true; + break; + } + copySize = actualLength; + } else if ( + desc.direction === "out" + && desc.size.type === "arg" + ) { + const permitsTruncatedDatagramLength = + ( + plan.syscallNr === SYS_RECV + || plan.syscallNr === SYS_RECVFROM + ) + && desc.argIndex === 1 + && ( + Number(plan.adjustedArgs[3] ?? 0) + & SOCKET_MSG_TRUNC + ) !== 0; + // WHY: except for POSIX MSG_TRUNC datagram semantics, a successful + // byte count is also the producer's claimed write extent. Silently + // clamping an over-report would publish success for bytes that + // never fit the owned allocation. + if (retVal > planned.size && !permitsTruncatedDatagramLength) { + outputContractViolation = true; + break; + } copySize = Math.min(retVal, copySize); } else if ( desc.direction === "out" @@ -6256,6 +6663,12 @@ export class CentralizedKernelWorker { if (pid === 1) { throw new Error("Cannot register the kernel-reserved init process"); } + if (memory === this.#kernelMemory) { + // WHY: process memory and kernel scratch have different owners and + // lifetimes. Reject the identity at admission so no channel path can + // accidentally turn a process-memory write into a kernel-memory write. + throw new Error("Process Memory must not alias kernel Memory"); + } // WHY: exports can synchronously call host imports. Snapshot every // caller-owned value before the first export so a reentrant callback // cannot replace argv/env or layout fields halfway through registration. @@ -6265,6 +6678,13 @@ export class CentralizedKernelWorker { const env = options?.env === undefined ? undefined : [...options.env]; + if ((argv === undefined) !== (env === undefined)) { + // WHY: Rust publishes argv and environment as one pair. Preserving an + // omitted old vector would make the host's ARG_MAX proof incomplete. + throw new Error( + "Process registration must replace argv and environment together", + ); + } const ptrWidth = options?.ptrWidth ?? 4; const metadataPtrWidth = options?.metadataPtrWidth ?? ptrWidth; const preserveProcessState = options?.preserveProcessState === true; @@ -6339,37 +6759,28 @@ export class CentralizedKernelWorker { return undefined; } - if ( - brkBase !== undefined - && !this.#setBrkBaseWithinKernelEntry(pid, brkBase, entry) - ) { - throw new Error( - "Kernel export kernel_set_brk_base is required for compact process memory layout", - ); - } - - // Set process argv in kernel for /proc//cmdline. - if (argv !== undefined) { + if (argv !== undefined && env !== undefined) { registrationPreconditionError = this.#replaceProcessMetadataWithinKernelEntry( pid, - PROCESS_METADATA_ARGV, argv, + env, entry, ); if (registrationPreconditionError !== null) return undefined; } - // Exec must synchronize an explicitly empty replacement environment. - if (env !== undefined) { - registrationPreconditionError = - this.#replaceProcessMetadataWithinKernelEntry( - pid, - PROCESS_METADATA_ENVIRONMENT, - env, - entry, - ); - if (registrationPreconditionError !== null) return undefined; + // WHY: metadata allocation errors are recoverable and return without + // publishing a host registration. Complete that transaction before + // mutating any layout field so a failed attempt leaves the whole + // kernel Process unchanged and can be retried safely. + if ( + brkBase !== undefined + && !this.#setBrkBaseWithinKernelEntry(pid, brkBase, entry) + ) { + throw new Error( + "Kernel export kernel_set_brk_base is required for compact process memory layout", + ); } // New layouts supply an explicit ceiling. Legacy layouts retain the @@ -6455,10 +6866,17 @@ export class CentralizedKernelWorker { env: readonly string[], ptrWidth: 4 | 8 = 4, ): number { + if ( + argv.length > PROCESS_STARTUP_MAX_ARGV_COUNT + || env.length > PROCESS_STARTUP_MAX_ENVP_COUNT + ) { + return -E2BIG; + } const encoder = new TextEncoder(); // Account for the null pointer terminating each vector even when it is - // explicitly empty. Pointer accounting both matches ARG_MAX semantics and - // bounds the number of zero-length entries without an arbitrary count cap. + // explicitly empty. The independently checked generated count ceilings + // are the startup representation contract; pointer/string bytes remain a + // separate ARG_MAX contract. let totalBytes = 2 * ptrWidth; for (const value of [...argv, ...env]) { const encodedLength = encoder.encode(value).byteLength; @@ -6471,103 +6889,146 @@ export class CentralizedKernelWorker { return 0; } - /** Replace argv or environ using bounded, entry-at-a-time scratch copies. */ - private replaceProcessMetadata( - pid: number, - kind: number, - values: readonly string[], - ): void { - const stableValues = [...values]; - if (this.#kernelFatalError !== null) throw this.#kernelFatalError; - if (this.#kernelEntryGate.shouldDeferVoidIngress) { - throw new KernelReentrantEntryError( - `process metadata replacement pid=${pid}`, - ); - } - let preconditionError: Error | null = null; - const deferred = this.#runOrDeferKernelEntry( - `process metadata replacement pid=${pid}`, - (entry) => { - preconditionError = this.#replaceProcessMetadataWithinKernelEntry( - pid, - kind, - stableValues, - entry, - ); - return undefined; - }, - ); - if (preconditionError !== null) throw preconditionError; - if (deferred) { - throw new KernelReentrantEntryError( - `process metadata replacement pid=${pid}`, - ); - } - } - #replaceProcessMetadataWithinKernelEntry( pid: number, - kind: number, - values: readonly string[], + argv: readonly string[], + environment: readonly string[], entry: KernelWorkerEntryContext, ): Error | null { - const clear = this.#kernelInstanceForEntry(entry).exports.kernel_clear_process_metadata as - ((pid: number, kind: number) => number) | undefined; - const push = this.#kernelInstanceForEntry(entry).exports - .kernel_push_process_metadata_entry as + const exports = this.#kernelInstanceForEntry(entry).exports; + const begin = exports.kernel_process_metadata_begin as + ((pid: number) => number) | undefined; + const stage = exports.kernel_process_metadata_stage as | (( pid: number, + token: number, kind: number, dataPtr: KernelPointer, dataLen: number, ) => number) | undefined; - if (typeof clear !== "function" || typeof push !== "function") { - // WHY: current-ABI admission requires both bounded entry-at-a-time - // exports. Falling back to the historical aggregate argv setter would - // silently lose environment replacement and revive a pointer-only - // transport after the capacity-safe contract was negotiated. + const commit = exports.kernel_process_metadata_commit as + ((pid: number, token: number) => number) | undefined; + const cancel = exports.kernel_process_metadata_cancel as + ((pid: number, token: number) => number) | undefined; + if ( + typeof begin !== "function" + || typeof stage !== "function" + || typeof commit !== "function" + || typeof cancel !== "function" + ) { + // WHY: an aggregate setter or clear-then-push fallback either revives a + // bare variable pointer or exposes a prefix after a later allocation + // failure. ABI 43 admits only the token-bound build-then-swap protocol. return new Error( - "Kernel missing required bounded process metadata exports", + "Kernel missing required atomic process metadata exports", ); } - const clearResult = clear(pid, kind); - if (clearResult < 0) { + const token = begin(pid); + if (Number.isInteger(token) && token < 0) { + return new Error( + `Failed to begin process metadata replacement for pid ${pid}: errno ${-token}`, + ); + } + if (!Number.isInteger(token) || token === 0) { throw new Error( - `Failed to clear process metadata for pid ${pid}: errno ${-clearResult}`, + `Process metadata begin returned invalid token ${String(token)} for pid ${pid}`, ); } const encoder = new TextEncoder(); - for (const value of values) { - const encoded = encoder.encode(value); - if (encoded.byteLength > PROCESS_METADATA_ENTRY_MAX_BYTES) { - throw new Error( - `Process metadata entry exceeds bounded scratch transport: errno ${E2BIG}`, - ); + let committed = false; + let cancelAllowed = true; + let operationError: Error | null = null; + try { + const vectors = [ + [PROCESS_METADATA_KIND_ARGV, argv], + [PROCESS_METADATA_KIND_ENVIRONMENT, environment], + ] as const; + for (const [kind, values] of vectors) { + for (const value of values) { + const encoded = encoder.encode(value); + if (encoded.byteLength > PROCESS_METADATA_ENTRY_MAX_BYTES) { + operationError = new Error( + `Process metadata entry exceeds bounded scratch transport: errno ${E2BIG}`, + ); + break; + } + // A Rust stage can grow memory. A fresh lease rechecks the + // replacement buffer and owns the bytes through the complete + // synchronous copy into the transaction. + const stageResult = this.#requireMainScratchRegion().withLease((scratch) => { + scratch.copyFrom(encoded); + return this.#invokeEntryScratchExport( + entry, + scratch, + "kernel_process_metadata_stage", + [ + pid, + token, + kind, + scratch.exportPointer(0, encoded.byteLength), + encoded.byteLength, + ], + ); + }); + if (stageResult < 0) { + operationError = new Error( + `Failed to stage process metadata for pid ${pid}: errno ${-stageResult}`, + ); + break; + } + if (stageResult !== 0) { + throw new Error( + `Process metadata stage returned invalid result ${stageResult} for pid ${pid}`, + ); + } + } + if (operationError !== null) break; } - // A Rust push can grow memory. A fresh lease rechecks the replacement - // buffer and owns the bytes through the complete synchronous parse. - const pushResult = this.#requireMainScratchRegion().withLease((scratch) => { - scratch.copyFrom(encoded); - return this.#invokeEntryScratchExport( - entry, - scratch, - "kernel_push_process_metadata_entry", - [ - pid, - kind, - scratch.exportPointer(0, encoded.byteLength), - encoded.byteLength, - ], - ); - }); - if (pushResult < 0) { - throw new Error(`Failed to append process metadata for pid ${pid}: errno ${-pushResult}`); + + if (operationError === null) { + const commitResult = commit(pid, token); + if (commitResult < 0) { + operationError = new Error( + `Failed to commit process metadata for pid ${pid}: errno ${-commitResult}`, + ); + } else if (commitResult !== 0) { + throw new Error( + `Process metadata commit returned invalid result ${commitResult} for pid ${pid}`, + ); + } else { + committed = true; + } + } + } catch (error) { + if (isKernelExportFailure(error)) { + // A trap leaves the Rust transition uncertain. The entry gate poisons + // this entire generation; entering another export to cancel would + // pretend the trapped instance were still reusable. + cancelAllowed = false; + } + throw error; + } finally { + if (!committed && cancelAllowed) { + // WHY: Rust keeps the old pair live until commit. Always release the + // staged allocation on every ordinary error so a later replacement + // cannot overlap or inherit bytes from this failed operation. + const cancelResult = cancel(pid, token); + if (cancelResult < 0) { + throw new Error( + `Failed to cancel process metadata for pid ${pid}: errno ${-cancelResult}`, + ); + } + if (cancelResult !== 0) { + throw new Error( + `Process metadata cancel returned invalid result ${cancelResult} for pid ${pid}`, + ); + } } } - return null; + return operationError; } /** @@ -10755,6 +11216,13 @@ export class CentralizedKernelWorker { adjustedArgs, ); } + if (syscallNr === ABI_SYSCALLS.Setsockopt) { + // WHY: optlen is only the caller's supplied byte extent. It cannot + // identify whether embedded sockaddr_storage fields use wasm32 or + // wasm64 alignment, so carry the independently known process model in + // setsockopt's otherwise-unused private sixth channel slot. + adjustedArgs[PROCESS_POINTER_WIDTH_ARG_INDEX] = pointerWidth; + } if (syscallNr === SYS_PRCTL) { const option = Number(BigInt.asUintN(32, rawArgs[0]!)); adjustedArgs[0] = option; @@ -11045,6 +11513,32 @@ export class CentralizedKernelWorker { // Rust slices require non-null pointers even at length zero. adjustedArgs[desc.argIndex] = 0; plannedZeroLengthScratchArgMask |= 1 << desc.argIndex; + if ( + ( + desc.direction === "in" + && isInputChannelByteCountResult( + syscallNr, + desc.argIndex, + ) + ) + || ( + desc.direction === "out" + ) + ) { + // WHY: zero caller capacity does not erase a byte-count + // producer contract, including one whose byte count comes from + // another staged output record (for example readdir d_namlen). + // Retain the empty owned region in the plan so a kernel that + // claims a positive transfer is rejected unless the syscall + // explicitly permits a complete truncated datagram length. + plannedChannelScratchArgs.push({ + desc, + processPointer: 0, + scratchOffset: CH_DATA + dataOffset, + size: 0, + inputBytes: null, + }); + } continue; } } @@ -11228,6 +11722,23 @@ export class CentralizedKernelWorker { // a non-null allocator-owned empty address for Rust slice validity. adjustedArgs[desc.argIndex] = 0; plannedZeroLengthScratchArgMask |= 1 << desc.argIndex; + if ( + desc.size.type === "deref" + && (desc.direction === "out" || desc.direction === "inout") + ) { + // WHY: zero caller capacity still leaves a value-result producer + // contract: the paired socklen_t may report the complete result, + // but never more than the generated platform maximum. Retain the + // zero-capacity region in the plan so post-dispatch validation + // cannot be skipped merely because there are no bytes to copy. + plannedChannelScratchArgs.push({ + desc, + processPointer: ptr, + scratchOffset: CH_DATA + dataOffset, + size: 0, + inputBytes: null, + }); + } continue; } @@ -20849,7 +21360,26 @@ export class CentralizedKernelWorker { } const rawPath = path; if (path && !path.startsWith("/")) { - path = this.resolveExecPathAgainstCwd(parentPid, path, entry); + const resolvedPath = this.resolveExecPathAgainstCwd( + parentPid, + path, + entry, + ); + if (resolvedPath.kind === "error") { + this.completeChannel( + channel, + SYS_SPAWN, + origArgs, + undefined, + -1, + resolvedPath.errno, + [], + undefined, + entry, + ); + return; + } + path = resolvedPath.value; } // .slice copies into a regular ArrayBuffer (TextDecoder rejects SAB views). @@ -21574,15 +22104,15 @@ export class CentralizedKernelWorker { /** * Read a null-terminated exec argv/envp pointer array without truncation. - * Each entry may occupy one process-metadata transfer. That implementation - * ceiling is separate from the advertised aggregate ARG_MAX budget, which - * includes pointer entries and bounds the scan without an unrelated - * argument-count limit. + * Each entry may occupy one process-metadata transfer. The generated count + * ceiling is the maximum the startup representation can hold; it remains + * separate from the aggregate ARG_MAX byte budget, which includes pointers. */ private readStringArrayFromProcess( mem: Uint8Array, arrayPtr: number, ptrWidth: 4 | 8 = 4, + maxCount: number = PROCESS_STARTUP_MAX_ARGV_COUNT, ): { values: string[] } | { errno: number } { if (arrayPtr === 0) return { values: [] }; const values: string[] = []; @@ -21606,6 +22136,7 @@ export class CentralizedKernelWorker { strPtr = view.getUint32(pointerOffset, true); } if (strPtr === 0) return { values }; + if (values.length >= maxCount) return { errno: E2BIG }; if (strPtr < 0 || strPtr >= mem.byteLength) return { errno: EFAULT }; const scanLength = Math.min( @@ -21694,8 +22225,18 @@ export class CentralizedKernelWorker { return; } let path = pathResult.value; - const argvResult = this.readStringArrayFromProcess(processMem, origArgs[1], pw); - const envResult = this.readStringArrayFromProcess(processMem, origArgs[2], pw); + const argvResult = this.readStringArrayFromProcess( + processMem, + origArgs[1], + pw, + PROCESS_STARTUP_MAX_ARGV_COUNT, + ); + const envResult = this.readStringArrayFromProcess( + processMem, + origArgs[2], + pw, + PROCESS_STARTUP_MAX_ENVP_COUNT, + ); if ("errno" in argvResult) { this.completeChannel( channel, @@ -21726,11 +22267,45 @@ export class CentralizedKernelWorker { } const argv = argvResult.values; const envp = envResult.values; + const metadataResult = this.validateExecMetadata(argv, envp, pw); + if (metadataResult < 0) { + this.completeChannel( + channel, + SYS_EXECVE, + origArgs, + undefined, + -1, + -metadataResult, + [], + undefined, + entry, + ); + return; + } // Resolve relative exec paths against process CWD (not initial KERNEL_CWD). // Critical for posix_spawn with chdir file actions where child CWD != parent CWD. if (path && !path.startsWith("/")) { - path = this.resolveExecPathAgainstCwd(channel.pid, path, entry); + const resolvedPath = this.resolveExecPathAgainstCwd( + channel.pid, + path, + entry, + ); + if (resolvedPath.kind === "error") { + this.completeChannel( + channel, + SYS_EXECVE, + origArgs, + undefined, + -1, + resolvedPath.errno, + [], + undefined, + entry, + ); + return; + } + path = resolvedPath.value; } if (!this.callbacks.onExec) { @@ -21825,47 +22400,20 @@ export class CentralizedKernelWorker { /** * Resolve a relative exec path against the process's kernel CWD. - * Returns absolute path if CWD can be queried, otherwise returns path unchanged. + * Query failures stay visible: using the unresolved token could execute a + * different host program than the process's authoritative CWD names. */ private resolveExecPathAgainstCwd( pid: number, path: string, entry: KernelWorkerEntryContext, - ): string { - const getCwd = this.#kernelInstanceForEntry(entry).exports.kernel_get_cwd as - ((pid: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; - if (!getCwd) return path; - let output: { result: number; bytes: Uint8Array }; - try { - output = this.#requireMainScratchRegion().withLease((lease) => { - const result = this.#invokeEntryScratchExport( - entry, - lease, - "kernel_get_cwd", - [ - pid, - lease.exportPointer(0, POSIX_PATH_MAX_BYTES), - POSIX_PATH_MAX_BYTES, - ], - ); - const byteLength = this.#checkedScratchProducerByteLength( - result, - POSIX_PATH_MAX_BYTES, - "kernel_get_cwd", - ); - return { - result, - bytes: byteLength === 0 - ? new Uint8Array(0) - : lease.copyOut(0, byteLength), - }; - }); - } catch (error) { - this.#rethrowKernelEntryFatal(error); - return path; + ): SharedMmapHostResult { + const output = this.#readKernelOwnedPath(pid, null, entry); + if (output.kind === "error") return output; + if (output.value.byteLength === 0) { + return { kind: "error", errno: ENOENT }; } - if (output.result <= 0) return path; - const cwd = new TextDecoder().decode(output.bytes); + const cwd = new TextDecoder().decode(output.value); const joined = cwd.endsWith("/") ? cwd + path : cwd + "/" + path; // Normalize . and .. components (e.g. /data/spawn/./prog → /data/spawn/prog) const parts = joined.split("/"); @@ -21875,7 +22423,7 @@ export class CentralizedKernelWorker { if (part === ".." && normalized.length > 0) { normalized.pop(); continue; } normalized.push(part); } - return "/" + normalized.join("/"); + return { kind: "ok", value: "/" + normalized.join("/") }; } /** @@ -21912,8 +22460,18 @@ export class CentralizedKernelWorker { return; } const pathStr = pathResult.value; - const argvResult = this.readStringArrayFromProcess(processMem, origArgs[2], pw); - const envResult = this.readStringArrayFromProcess(processMem, origArgs[3], pw); + const argvResult = this.readStringArrayFromProcess( + processMem, + origArgs[2], + pw, + PROCESS_STARTUP_MAX_ARGV_COUNT, + ); + const envResult = this.readStringArrayFromProcess( + processMem, + origArgs[3], + pw, + PROCESS_STARTUP_MAX_ENVP_COUNT, + ); if ("errno" in argvResult) { this.completeChannel( channel, @@ -21944,125 +22502,68 @@ export class CentralizedKernelWorker { } const argv = argvResult.values; const envp = envResult.values; + const metadataResult = this.validateExecMetadata(argv, envp, pw); + if (metadataResult < 0) { + this.completeChannel( + channel, + SYS_EXECVEAT, + origArgs, + undefined, + -1, + -metadataResult, + [], + undefined, + entry, + ); + return; + } let execPath: string; if ((flags & AT_EMPTY_PATH) !== 0 && pathStr === "") { - // fexecve path: resolve fd to file path via kernel - const getFdPath = this.#kernelInstanceForEntry(entry).exports.kernel_get_fd_path as - ((pid: number, fd: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; - if (!getFdPath) { + const output = this.#readKernelOwnedPath(channel.pid, dirfd, entry); + if (output.kind === "error" || output.value.byteLength === 0) { this.completeChannel( channel, SYS_EXECVEAT, origArgs, undefined, -1, - 38, + output.kind === "error" ? output.errno : ENOENT, [], undefined, entry, - ); // ENOSYS - return; - } - let output: { result: number; bytes: Uint8Array }; - try { - output = this.#requireMainScratchRegion().withLease((lease) => { - const result = this.#invokeEntryScratchExport( - entry, - lease, - "kernel_get_fd_path", - [ - channel.pid, - dirfd, - lease.exportPointer(0, POSIX_PATH_MAX_BYTES), - POSIX_PATH_MAX_BYTES, - ], - ); - const byteLength = this.#checkedScratchProducerByteLength( - result, - POSIX_PATH_MAX_BYTES, - "kernel_get_fd_path", - ); - return { - result, - bytes: byteLength === 0 - ? new Uint8Array(0) - : lease.copyOut(0, byteLength), - }; - }); - } catch (error) { - this.#rethrowKernelEntryFatal(error); - this.#rejectScratchTransfer(channel, error, entry); + ); return; } - if (output.result <= 0) { - const errno = output.result < 0 - ? (-output.result) >>> 0 - : 2; // ENOENT + execPath = new TextDecoder().decode(output.value); + } else if (pathStr.startsWith("/")) { + execPath = pathStr; + } else { + // The host intercepts execveat before Rust resolves it. Preserve the + // syscall's dirfd semantics instead of silently substituting CWD. + const output = this.#readKernelOwnedPath( + channel.pid, + dirfd === AT_FDCWD ? null : dirfd, + entry, + dirfd !== AT_FDCWD, + ); + if (output.kind === "error" || output.value.byteLength === 0) { this.completeChannel( channel, SYS_EXECVEAT, origArgs, undefined, -1, - errno, + output.kind === "error" ? output.errno : ENOENT, [], undefined, entry, ); return; } - execPath = new TextDecoder().decode(output.bytes); - } else if (pathStr.startsWith("/")) { - execPath = pathStr; - } else { - // Relative path — let kernel resolve against dirfd/CWD. - // For simplicity, resolve against process CWD here. - // The kernel's sys_execveat already resolves this, but since we intercept - // host-side, we need to do it ourselves. - const getCwd = this.#kernelInstanceForEntry(entry).exports.kernel_get_cwd as - ((pid: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; - if (getCwd) { - let output: { result: number; bytes: Uint8Array }; - try { - output = this.#requireMainScratchRegion().withLease((lease) => { - const result = this.#invokeEntryScratchExport( - entry, - lease, - "kernel_get_cwd", - [ - channel.pid, - lease.exportPointer(0, POSIX_PATH_MAX_BYTES), - POSIX_PATH_MAX_BYTES, - ], - ); - const byteLength = this.#checkedScratchProducerByteLength( - result, - POSIX_PATH_MAX_BYTES, - "kernel_get_cwd", - ); - return { - result, - bytes: byteLength === 0 - ? new Uint8Array(0) - : lease.copyOut(0, byteLength), - }; - }); - } catch (error) { - this.#rethrowKernelEntryFatal(error); - this.#rejectScratchTransfer(channel, error, entry); - return; - } - if (output.result > 0) { - const cwd = new TextDecoder().decode(output.bytes); - execPath = cwd.endsWith("/") ? cwd + pathStr : cwd + "/" + pathStr; - } else { - execPath = pathStr; - } - } else { - execPath = pathStr; - } + const base = new TextDecoder().decode(output.value); + execPath = this.normalizeSharedMmapPath(`${base}/${pathStr}`); } if (!this.callbacks.onExec) { @@ -25153,46 +25654,20 @@ export class CentralizedKernelWorker { const testHook = this.#scratchBoundaryTestHooks?.getFdPathForSharedMapping; if (testHook) return testHook(channel, fd); - const getFdPath = this.#kernelInstanceForEntry(entry).exports.kernel_get_fd_path as - ((pid: number, fd: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; - if (!getFdPath) return { kind: "error", errno: ENOSYS }; - let output: { result: number; bytes: Uint8Array }; - try { - const capacity = Math.min(POSIX_PATH_MAX_BYTES, CH_DATA_SIZE); - output = this.#requireMainScratchRegion().withLease((lease) => { - const result = this.#invokeEntryScratchExport( - entry, - lease, - "kernel_get_fd_path", - [ - channel.pid, - fd, - lease.exportPointer(0, capacity), - capacity, - ], - ); - const byteLength = this.#checkedScratchProducerByteLength( - result, - capacity, - "kernel_get_fd_path", - ); - return { - result, - bytes: byteLength === 0 - ? new Uint8Array(0) - : lease.copyOut(0, byteLength), - }; - }); - } catch (error) { - this.#rethrowKernelEntryFatal(error); - return { kind: "error", errno: EIO }; + if (entry === undefined) return { kind: "error", errno: EIO }; + const output = this.#readKernelOwnedPath( + channel.pid, + fd, + entry, + true, + ); + if (output.kind === "error") return output; + if (output.value.byteLength === 0) { + return { kind: "error", errno: ENOENT }; } - const len = output.result; - if (len < 0) return { kind: "error", errno: -len }; - if (len === 0) return { kind: "error", errno: ENOENT }; return { kind: "ok", - value: new TextDecoder().decode(output.bytes), + value: new TextDecoder().decode(output.value), }; } @@ -25901,37 +26376,13 @@ export class CentralizedKernelWorker { if (baseResult.kind === "error") return baseResult; base = baseResult.value; } else { - const getCwd = this.#kernelInstanceForEntry(entry).exports.kernel_get_cwd as - ((pid: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; - if (!getCwd) return { kind: "error", errno: ENOSYS }; - const capacity = Math.min(POSIX_PATH_MAX_BYTES, CH_DATA_SIZE); - const output = this.#requireMainScratchRegion().withLease((lease) => { - const result = this.#invokeEntryScratchExport( - entry, - lease, - "kernel_get_cwd", - [ - channel.pid, - lease.exportPointer(0, capacity), - capacity, - ], - ); - const byteLength = this.#checkedScratchProducerByteLength( - result, - capacity, - "kernel_get_cwd", - ); - return { - result, - bytes: byteLength === 0 - ? new Uint8Array(0) - : lease.copyOut(0, byteLength), - }; - }); - const cwdLen = output.result; - if (cwdLen < 0) return { kind: "error", errno: -cwdLen }; - if (cwdLen === 0) return { kind: "error", errno: ENOENT }; - base = new TextDecoder().decode(output.bytes); + if (entry === undefined) return { kind: "error", errno: EIO }; + const output = this.#readKernelOwnedPath(channel.pid, null, entry); + if (output.kind === "error") return output; + if (output.value.byteLength === 0) { + return { kind: "error", errno: ENOENT }; + } + base = new TextDecoder().decode(output.value); } return { kind: "ok", diff --git a/host/src/kernel.ts b/host/src/kernel.ts index b2e0250173..b69aa0b6fa 100644 --- a/host/src/kernel.ts +++ b/host/src/kernel.ts @@ -49,6 +49,9 @@ import { STRUCT_SIZE_WASM_STAT, STRUCT_SIZE_WASM_STATFS, STRUCT_SIZE_WPK_DRM_MODE_MODEINFO, + WASM_DIRENT_INO_OFFSET, + WASM_DIRENT_NAME_LENGTH_OFFSET, + WASM_DIRENT_TYPE_OFFSET, WASM_POLL_FD_EVENTS_OFFSET, WASM_POLL_FD_FD_OFFSET, WASM_POLL_FD_REVENTS_OFFSET, @@ -3481,14 +3484,27 @@ export class WasmPosixKernel { dirEntry = next; } - // Write WasmDirent: d_ino(u64) + d_type(u32) + d_namlen(u32) + // Write the generated WasmDirent layout. Rust owns these offsets so a + // future layout change cannot silently desynchronize host copy-back. const encoded = new TextEncoder().encode(dirEntry.name); - const n = Math.min(encoded.length, nameDestination.capacity); + if (encoded.length > nameDestination.capacity) { + // WHY: this legacy iterator consumes one complete entry per success. + // Truncating the name would both publish a false result and lose the + // entry. Leave it pending so an exact-capacity retry sees the same + // bytes, and mutate neither caller-visible destination on failure. + return NEG_ERRNO_BY_NAME.ERANGE; + } + const n = encoded.length; const dirent = new IntrinsicUint8Array(WASM_DIRENT_SIZE); const view = new IntrinsicDataView(typedArrayBuffer(dirent)); - dataViewSetBigUint64(view, 0, BigInt(dirEntry.ino), true); - dataViewSetUint32(view, 8, dirEntry.type, true); - dataViewSetUint32(view, 12, n, true); + dataViewSetBigUint64( + view, + WASM_DIRENT_INO_OFFSET, + BigInt(dirEntry.ino), + true, + ); + dataViewSetUint32(view, WASM_DIRENT_TYPE_OFFSET, dirEntry.type, true); + dataViewSetUint32(view, WASM_DIRENT_NAME_LENGTH_OFFSET, n, true); this.#writeKernelBytes( direntDestination, dirent, diff --git a/host/src/worker-main.ts b/host/src/worker-main.ts index 8c1009767a..5dd5bdf839 100644 --- a/host/src/worker-main.ts +++ b/host/src/worker-main.ts @@ -51,6 +51,10 @@ import { CH_SYSCALL, CH_TOTAL_SIZE, HOST_INTERCEPTED_SYSCALLS, + POSIX_ARG_MAX_BYTES, + PROCESS_METADATA_ENTRY_MAX_BYTES, + PROCESS_STARTUP_MAX_ARGV_COUNT, + PROCESS_STARTUP_MAX_ENVP_COUNT, WPK_FORK_EXPORT_MODULE_THREAD_BOOTSTRAP, WPK_FORK_MODULE_STATE_IMPORT_RECORD_COMMIT, WPK_FORK_MODULE_STATE_IMPORT_RECORD_FIND, @@ -263,59 +267,145 @@ function continuationMunmap( * Both process and thread workers need these because the musl overlay CRT * imports kernel.* functions for argc/argv, environ, fork state, and clone. * - * On wasm64, pointer params arrive as BigInt (i64). The helper `n()` converts - * BigInt|number → number for memory access (all addresses < 4GB). + * Startup metadata pointers are checked against their declared process + * pointer width before any guest-memory view is created. */ type KernelImports = Record & { kernel_exit: (status: number) => void; kernel_fork: (...args: unknown[]) => number; }; +const STARTUP_E2BIG = 7; +const STARTUP_EFAULT = 14; +const STARTUP_EINVAL = 22; +const STARTUP_ERANGE = 34; + +interface EncodedStartupMetadata { + argv: readonly Uint8Array[]; + env: readonly Uint8Array[]; +} + +function encodeStartupMetadata( + argv: readonly string[], + env: readonly string[], + ptrWidth: 4 | 8, +): EncodedStartupMetadata { + if (argv.length > PROCESS_STARTUP_MAX_ARGV_COUNT) { + throw new RangeError( + `startup argv count exceeds ${PROCESS_STARTUP_MAX_ARGV_COUNT}: errno ${STARTUP_E2BIG}`, + ); + } + if (env.length > PROCESS_STARTUP_MAX_ENVP_COUNT) { + throw new RangeError( + `startup environment count exceeds ${PROCESS_STARTUP_MAX_ENVP_COUNT}: errno ${STARTUP_E2BIG}`, + ); + } + + const encoder = new TextEncoder(); + // The two terminating null pointers count even for empty vectors. + let representedBytes = 2 * ptrWidth; + const encodeVector = ( + values: readonly string[], + label: string, + ): readonly Uint8Array[] => values.map((value, index) => { + if (typeof value !== "string") { + throw new TypeError(`${label}[${index}] must be a string`); + } + const encoded = encoder.encode(value); + if (encoded.byteLength > PROCESS_METADATA_ENTRY_MAX_BYTES) { + throw new RangeError( + `${label}[${index}] exceeds the per-entry startup transfer limit: ` + + `errno ${STARTUP_E2BIG}`, + ); + } + representedBytes += ptrWidth + encoded.byteLength + 1; + if ( + !Number.isSafeInteger(representedBytes) + || representedBytes > POSIX_ARG_MAX_BYTES + ) { + throw new RangeError( + `startup argv/environment representation exceeds ARG_MAX: ` + + `errno ${STARTUP_E2BIG}`, + ); + } + return encoded; + }); + + // WHY: startup imports can be queried twice (size, then exact copy). Encode + // once so no caller mutation or coercion can make the second observation + // name different bytes after the guest has allocated its lifetime region. + return { + argv: encodeVector(argv, "startup argv"), + env: encodeVector(env, "startup environment"), + }; +} + function buildKernelImports( memory: WebAssembly.Memory, channelOffset: number, + ptrWidth: 4 | 8, argv?: string[], envVars?: string[], onKernelExit?: (status: number) => void, ): KernelImports { - const _argv = argv || []; - const _envVars = envVars || []; - const encoder = new TextEncoder(); - /** Convert wasm64 BigInt pointer to number (safe since addresses < 4GB) */ - const n = (v: number | bigint): number => - typeof v === "bigint" ? Number(v) : v; + const metadata = encodeStartupMetadata(argv ?? [], envVars ?? [], ptrWidth); + // The legacy clone payload remains a fixed wasm32 pair in CH_DATA. + const n = (value: number | bigint): number => + typeof value === "bigint" ? Number(value) : value; + const copyEntry = ( + entries: readonly Uint8Array[], + index: number, + bufPtr: WasmGuestPointer, + bufCapacity: number, + label: string, + ): number => { + if ( + !Number.isSafeInteger(index) + || index < 0 + || index >= entries.length + || !Number.isSafeInteger(bufCapacity) + || bufCapacity < 0 + ) { + return -STARTUP_EINVAL; + } + const encoded = entries[index]; + if (bufCapacity === 0) { + // Zero capacity is a side-effect-free complete-length query. The CRT + // follows it with one exact-capacity copy into its mmap-owned region. + return encoded.byteLength; + } + if (bufCapacity < encoded.byteLength) return -STARTUP_ERANGE; + if (bufPtr === 0 || bufPtr === 0n) return -STARTUP_EFAULT; + + let range: { offset: number; length: number }; + try { + range = checkedWasmMemoryRange( + memory, + bufPtr, + encoded.byteLength, + ptrWidth, + label, + ); + } catch { + return -STARTUP_EFAULT; + } + // The encoded source is an immutable launch snapshot, and this direct + // import has no await or callback between the range proof and full copy. + new Uint8Array(memory.buffer, range.offset, range.length).set(encoded); + return encoded.byteLength; + }; return { // CRT argv support - kernel_get_argc: (): number => _argv.length, - kernel_argv_read: ( - index: number, - bufPtr: number | bigint, - bufMax: number, - ): number => { - if (index >= _argv.length) return 0; - const encoded = encoder.encode(_argv[index]); - const len = Math.min(encoded.length, bufMax); - new Uint8Array(memory.buffer, n(bufPtr), len).set( - encoded.subarray(0, len), - ); - return len; + kernel_get_argc: (): number => metadata.argv.length, + kernel_argv_read: (index: number, bufPtr: number | bigint, bufMax: number): number => { + return copyEntry(metadata.argv, index, bufPtr, bufMax, "kernel_argv_read"); }, // CRT environ support - kernel_environ_count: (): number => _envVars.length, - kernel_environ_get: ( - index: number, - bufPtr: number | bigint, - bufMax: number, - ): number => { - if (index >= _envVars.length) return -1; - const encoded = encoder.encode(_envVars[index]); - const len = Math.min(encoded.length, bufMax); - new Uint8Array(memory.buffer, n(bufPtr), len).set( - encoded.subarray(0, len), - ); - return len; + kernel_environ_count: (): number => metadata.env.length, + kernel_environ_get: (index: number, bufPtr: number | bigint, bufMax: number): number => { + return copyEntry(metadata.env, index, bufPtr, bufMax, "kernel_environ_get"); }, // Fork/exec state — not a fork child. @@ -455,6 +545,17 @@ function buildKernelImports( }; } +/** @internal Exported for focused startup import contract tests. */ +export function buildKernelImportsForTest( + memory: WebAssembly.Memory, + channelOffset: number, + ptrWidth: 4 | 8, + argv: string[] = [], + env: string[] = [], +): Record { + return buildKernelImports(memory, channelOffset, ptrWidth, argv, env); +} + export interface DlopenSupport { imports: Record; /** Validate and return the compact copied live-module closure. */ @@ -3065,6 +3166,7 @@ export async function centralizedWorkerMain( const kernelImports = buildKernelImports( memory, channelOffset, + ptrWidth, initData.argv || [], initData.env || [], (status) => { @@ -5113,6 +5215,7 @@ export async function centralizedThreadWorkerMain( const kernelImports = buildKernelImports( memory, channelOffset, + ptrWidth, undefined, undefined, (status) => { diff --git a/host/test/exec-state-tracking.test.ts b/host/test/exec-state-tracking.test.ts index ccf1d2ce69..6c01ecf831 100644 --- a/host/test/exec-state-tracking.test.ts +++ b/host/test/exec-state-tracking.test.ts @@ -21,6 +21,8 @@ import { CH_STATUS, CH_SYSCALL, HOST_INTERCEPTED_SYSCALLS, + PROCESS_STARTUP_MAX_ARGV_COUNT, + PROCESS_STARTUP_MAX_ENVP_COUNT, } from "../src/generated/abi"; import { EXEC_RETIRE_SIGNAL_CODE } from "../src/worker-protocol"; import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; @@ -747,6 +749,24 @@ describe("exec host-state transition", () => { const aboveHistoricalLimit = Array.from({ length: 20 }, () => "x".repeat(4096)); expect(worker.validateExecMetadata(["program"], aboveHistoricalLimit)).toBe(0); + expect( + worker.validateExecMetadata( + Array(PROCESS_STARTUP_MAX_ARGV_COUNT).fill(""), + Array(PROCESS_STARTUP_MAX_ENVP_COUNT).fill(""), + ), + ).toBe(0); + expect( + worker.validateExecMetadata( + Array(PROCESS_STARTUP_MAX_ARGV_COUNT + 1).fill(""), + [], + ), + ).toBe(-7); + expect( + worker.validateExecMetadata( + [], + Array(PROCESS_STARTUP_MAX_ENVP_COUNT + 1).fill(""), + ), + ).toBe(-7); expect(worker.validateExecMetadata(["x".repeat(65_537)], [])).toBe(-7); expect(worker.validateExecMetadata([], Array.from({ length: 1024 }, () => "x".repeat(4096)))) .toBe(-7); @@ -754,7 +774,8 @@ describe("exec host-state transition", () => { it("accounts ARG_MAX using the exec caller's pointer width", () => { const worker = createWorker({}); - const nearBoundary = Array(8192).fill("x".repeat(504)); + const nearBoundary = Array(PROCESS_STARTUP_MAX_ARGV_COUNT) + .fill("x".repeat(1016)); expect(worker.validateExecMetadata(nearBoundary, [], 4)).toBe(0); expect(worker.validateExecMetadata(nearBoundary, [], 8)).toBe(-7); @@ -798,6 +819,107 @@ describe("exec host-state transition", () => { expect("values" in parsed && parsed.values).toHaveLength(1024); }); + it.each([4, 8] as const)( + "accepts exactly the wasm%s startup argv count and rejects one more", + (pointerWidth) => { + const memory = new WebAssembly.Memory({ + initial: 2, + maximum: 2, + shared: true, + }); + const bytes = new Uint8Array(memory.buffer); + const view = new DataView(memory.buffer); + const arrayPtr = 0x1000; + const stringPtr = 0xa000; + bytes[stringPtr] = 0; + const setPointer = (index: number, value: number) => { + const offset = arrayPtr + index * pointerWidth; + if (pointerWidth === 8) { + view.setBigUint64(offset, BigInt(value), true); + } else { + view.setUint32(offset, value, true); + } + }; + for (let index = 0; index < PROCESS_STARTUP_MAX_ARGV_COUNT; index++) { + setPointer(index, stringPtr); + } + setPointer(PROCESS_STARTUP_MAX_ARGV_COUNT, 0); + const worker = createWorker({}); + + const exact = worker.readStringArrayFromProcess( + bytes, + arrayPtr, + pointerWidth, + PROCESS_STARTUP_MAX_ARGV_COUNT, + ); + expect("values" in exact && exact.values) + .toHaveLength(PROCESS_STARTUP_MAX_ARGV_COUNT); + + setPointer(PROCESS_STARTUP_MAX_ARGV_COUNT, stringPtr); + expect( + worker.readStringArrayFromProcess( + bytes, + arrayPtr, + pointerWidth, + PROCESS_STARTUP_MAX_ARGV_COUNT, + ), + ).toEqual({ errno: 7 }); + }, + ); + + it.each([4, 8] as const)( + "rejects wasm%s exec argv count overflow before launching a replacement", + (pointerWidth) => { + const memory = new WebAssembly.Memory({ + initial: 2, + maximum: 2, + shared: true, + }); + const channel = createChannel(7, memory, 0); + const bytes = new Uint8Array(memory.buffer); + const view = new DataView(memory.buffer); + const pathPtr = 0x800; + const arrayPtr = 0x1000; + const stringPtr = 0xa000; + bytes.set(new TextEncoder().encode("/bin/next\0"), pathPtr); + bytes[stringPtr] = 0; + for ( + let index = 0; + index <= PROCESS_STARTUP_MAX_ARGV_COUNT; + index++ + ) { + const offset = arrayPtr + index * pointerWidth; + if (pointerWidth === 8) { + view.setBigUint64(offset, BigInt(stringPtr), true); + } else { + view.setUint32(offset, stringPtr, true); + } + } + const onExec = vi.fn(async () => 0); + const worker = createWorker({ + processes: new Map([[ + 7, + { channels: [channel], memory, ptrWidth: pointerWidth }, + ]]), + callbacks: { onExec }, + }); + + writeChannelSyscall( + channel, + HOST_INTERCEPTED_SYSCALLS.SYS_EXECVE, + [pathPtr, arrayPtr, 0], + ); + worker.handleSyscall(channel); + + expect(onExec).not.toHaveBeenCalled(); + expect(readChannelCompletion(channel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: -1, + errno: 7, + }); + }, + ); + it("rejects overlong or inaccessible exec paths instead of truncating them", () => { const memory = new WebAssembly.Memory({ initial: 2, maximum: 2, shared: true }); const bytes = new Uint8Array(memory.buffer); @@ -819,77 +941,6 @@ describe("exec host-state transition", () => { expect(worker.readExecPathFromProcess(bytes, 0)).toEqual({ errno: 14 }); }); - it("replaces metadata entry by entry and clears an empty environment", () => { - const kernelMemory = new WebAssembly.Memory({ initial: 2 }); - const clears: Array<[number, number]> = []; - const pushes: Array<{ pid: number; kind: number; bytes: Uint8Array }> = []; - const worker = createWorker({ - kernelMemory, - toKernelPtr: (value: number) => value, - kernelInstance: { - exports: { - kernel_clear_process_metadata: (pid: number, kind: number) => { - clears.push([pid, kind]); - return 0; - }, - kernel_push_process_metadata_entry: ( - pid: number, - kind: number, - ptr: number, - len: number, - ) => { - pushes.push({ - pid, - kind, - bytes: new Uint8Array(kernelMemory.buffer, ptr, len).slice(), - }); - if (pushes.length === 1) kernelMemory.grow(1); - return 0; - }, - }, - }, - }); - - worker.replaceProcessMetadata(7, 0, ["program", ""]); - worker.replaceProcessMetadata(7, 1, []); - - expect(clears).toEqual([[7, 0], [7, 1]]); - expect(pushes.map(entry => ({ - pid: entry.pid, - kind: entry.kind, - value: new TextDecoder().decode(entry.bytes), - }))).toEqual([ - { pid: 7, kind: 0, value: "program" }, - { pid: 7, kind: 0, value: "" }, - ]); - }); - - it.each([ - "kernel_clear_process_metadata", - "kernel_push_process_metadata_entry", - ])("fails loudly when required metadata export %s is absent", (missing) => { - const kernelMemory = new WebAssembly.Memory({ initial: 2 }); - const clear = vi.fn(() => 0); - const push = vi.fn(() => 0); - const exports: Record = { - kernel_clear_process_metadata: clear, - kernel_push_process_metadata_entry: push, - }; - delete exports[missing]; - const worker = createWorker({ - kernelMemory, - toKernelPtr: (value: number) => value, - kernelInstance: { exports }, - }); - const scratchBefore = new Uint8Array(kernelMemory.buffer).slice(); - - expect(() => worker.replaceProcessMetadata(7, 0, ["program", "arg"])) - .toThrow(/required bounded process metadata exports/); - expect(clear).not.toHaveBeenCalled(); - expect(push).not.toHaveBeenCalled(); - expect(new Uint8Array(kernelMemory.buffer)).toEqual(scratchBefore); - }); - it("flushes file-backed mappings before commit and forgets them afterward", () => { const memory = new WebAssembly.Memory({ initial: 1 }); const channel = { pid: 7, memory }; diff --git a/host/test/fixtures/startup-crt-contract.c b/host/test/fixtures/startup-crt-contract.c new file mode 100644 index 0000000000..96cc791bbc --- /dev/null +++ b/host/test/fixtures/startup-crt-contract.c @@ -0,0 +1,195 @@ +#include +#include +#include +#include +#include +#include + +static jmp_buf failure_jump; +static _Noreturn void test_trap(void); + +#define _start test_start +#define _start_c test_start_c +#define __libc_start_main test_libc_start_main +#define __main_argc_argv test_main_argc_argv +#define _init test_init +#define _fini test_fini +#define kernel_get_argc test_kernel_get_argc +#define kernel_argv_read test_kernel_argv_read +#define kernel_environ_count test_kernel_environ_count +#define kernel_environ_get test_kernel_environ_get +#define mmap test_mmap +#define __builtin_trap() test_trap() + +void *test_mmap(void *, size_t, int, int, int, off_t); + +#include "../../../libc/musl-overlay/crt/crt1.c" + +static const char *test_argv[] = { "program", "argument" }; +static const char *test_env[] = { "FIRST=value", "SECOND=value" }; +static unsigned test_argc; +static unsigned test_envc; +static int fail_allocation; +static int mismatch_copy; +static int published; +static void *last_mapping; +static size_t last_mapping_bytes; + +static _Noreturn void test_trap(void) +{ + longjmp(failure_jump, 1); +} + +int test_kernel_get_argc(void) +{ + return (int)test_argc; +} + +int test_kernel_environ_count(void) +{ + return (int)test_envc; +} + +static int read_entry( + const char *const *entries, + unsigned count, + unsigned index, + unsigned char *buffer, + unsigned capacity) +{ + if (index >= count) return -22; + size_t length = strlen(entries[index]); + if (!capacity) return (int)length; + if (capacity < length) return -34; + if (mismatch_copy && index == 0) return (int)length - 1; + memcpy(buffer, entries[index], length); + return (int)length; +} + +int test_kernel_argv_read( + unsigned index, + unsigned char *buffer, + unsigned capacity) +{ + return read_entry(test_argv, test_argc, index, buffer, capacity); +} + +int test_kernel_environ_get( + unsigned index, + unsigned char *buffer, + unsigned capacity) +{ + return read_entry(test_env, test_envc, index, buffer, capacity); +} + +void *test_mmap( + void *address, + size_t length, + int protection, + int flags, + int fd, + off_t offset) +{ + (void)address; + (void)protection; + (void)flags; + (void)fd; + (void)offset; + if (fail_allocation) return MAP_FAILED; + last_mapping = calloc(1, length); + last_mapping_bytes = length; + return last_mapping ? last_mapping : MAP_FAILED; +} + +int test_main_argc_argv(int argc, char **argv) +{ + (void)argc; + (void)argv; + return 0; +} + +void test_init(void) {} +void test_fini(void) {} + +int test_libc_start_main( + int (*main_function)(int, char **), + int argc, + char **argv, + void (*init_function)(void), + void (*fini_function)(void), + void (*loader_fini)(void)) +{ + (void)main_function; + (void)init_function; + (void)fini_function; + (void)loader_fini; + published = 1; + if ((unsigned)argc != test_argc) return 91; + for (unsigned i = 0; i < test_argc; i++) + if (strcmp(argv[i], test_argv[i])) return 92; + if (argv[test_argc]) return 93; + char **env = argv + test_argc + 1; + for (unsigned i = 0; i < test_envc; i++) + if (strcmp(env[i], test_env[i])) return 94; + if (env[test_envc]) return 95; + return 0; +} + +static int run_expect_success(void) +{ + test_argc = 2; + test_envc = 2; + fail_allocation = 0; + mismatch_copy = 0; + published = 0; + last_mapping = 0; + last_mapping_bytes = 0; + if (setjmp(failure_jump)) return 1; + test_start(); + if (!published || !last_mapping || last_mapping_bytes >= 4096) return 2; + free(last_mapping); + return 0; +} + +static int run_expect_allocation_failure(void) +{ + test_argc = 1; + test_envc = 0; + fail_allocation = 1; + mismatch_copy = 0; + published = 0; + if (!setjmp(failure_jump)) { + test_start(); + return 3; + } + return published ? 4 : 0; +} + +static int run_expect_retry_mismatch(void) +{ + test_argc = 1; + test_envc = 0; + fail_allocation = 0; + mismatch_copy = 1; + published = 0; + last_mapping = 0; + if (!setjmp(failure_jump)) { + test_start(); + return 5; + } + free(last_mapping); + return published ? 6 : 0; +} + +int main(void) +{ + int result = run_expect_success(); + if (!result) result = run_expect_allocation_failure(); + if (!result) result = run_expect_retry_mismatch(); + if (result) { + fprintf(stderr, "startup-crt-contract failure %d\n", result); + return result; + } + puts("startup-crt-contract: ok"); + return 0; +} diff --git a/host/test/fixtures/startup-crt-include/libc.h b/host/test/fixtures/startup-crt-include/libc.h new file mode 100644 index 0000000000..afe5a6581a --- /dev/null +++ b/host/test/fixtures/startup-crt-include/libc.h @@ -0,0 +1,6 @@ +#ifndef KANDELO_STARTUP_CRT_TEST_LIBC_H +#define KANDELO_STARTUP_CRT_TEST_LIBC_H + +#define weak __attribute__((weak)) + +#endif diff --git a/host/test/generated-abi.test.ts b/host/test/generated-abi.test.ts index dc0a5706ce..bbd17c633e 100644 --- a/host/test/generated-abi.test.ts +++ b/host/test/generated-abi.test.ts @@ -65,6 +65,20 @@ import { PROCESS_MEMORY_THREAD_SLOTS_NONE, PROCESS_MEMORY_THREAD_SLOTS_USE_HOST_DEFAULT, PROCESS_MEMORY_WASM_PAGE_SIZE, + PROCESS_METADATA_KIND_ARGV, + PROCESS_METADATA_KIND_ENVIRONMENT, + PROCESS_SNAPSHOT_CMDLINE_LEN_OFFSET, + PROCESS_SNAPSHOT_COMM_LEN_OFFSET, + PROCESS_SNAPSHOT_COUNT_BYTES, + PROCESS_SNAPSHOT_COUNT_OFFSET, + PROCESS_SNAPSHOT_GID_OFFSET, + PROCESS_SNAPSHOT_HEADER_BYTES, + PROCESS_SNAPSHOT_PID_OFFSET, + PROCESS_SNAPSHOT_PPID_OFFSET, + PROCESS_SNAPSHOT_RECORDS_OFFSET, + PROCESS_SNAPSHOT_STATE_OFFSET, + PROCESS_SNAPSHOT_UID_OFFSET, + PROCESS_SNAPSHOT_VSIZE_OFFSET, PROCESS_SIGINFO_CODE_OFFSET, PROCESS_SIGINFO_ERRNO_OFFSET, PROCESS_SIGINFO_SIGNO_OFFSET, @@ -84,6 +98,9 @@ import { STRUCT_SIZE_WASM_STATFS, STRUCT_SIZE_WASM_TIMESPEC, SYSCALL_ARGS, + WASM_DIRENT_INO_OFFSET, + WASM_DIRENT_NAME_LENGTH_OFFSET, + WASM_DIRENT_TYPE_OFFSET, WPK_FORK_CAPABILITIES_SECTION, WPK_FORK_CAPABILITIES_VERSION, WPK_FORK_CAP_ACTIVATION_STATE_SAFE, @@ -162,6 +179,26 @@ function fieldOffset(name: string): number { return field.offset; } +function structFieldOffset(structName: string, fieldName: string): number { + const field = snapshot.marshalled_structs[structName].fields.find( + (candidate: { name: string }) => candidate.name === fieldName, + ); + if (!field) { + throw new Error(`missing ${structName} field ${fieldName}`); + } + return field.offset; +} + +function processSnapshotFieldOffset(fieldName: string): number { + const field = snapshot.process_snapshot_wire.header.fields.find( + (candidate: { name: string }) => candidate.name === fieldName, + ); + if (!field) { + throw new Error(`missing process snapshot field ${fieldName}`); + } + return field.offset; +} + function statusNumber(name: string): number { const status = snapshot.channel_status_codes.find((s: { name: string }) => s.name === name); if (!status) throw new Error(`missing channel_status_codes entry ${name}`); @@ -401,6 +438,40 @@ describe("generated host ABI bindings", () => { expect(CH_TOTAL_SIZE).toBe(snapshot.channel_buffers.min_channel_size); }); + it("match the packed process-snapshot wire contract", () => { + expect(PROCESS_SNAPSHOT_COUNT_OFFSET) + .toBe(snapshot.process_snapshot_wire.count_offset); + expect(PROCESS_SNAPSHOT_COUNT_BYTES) + .toBe(snapshot.process_snapshot_wire.count_size); + expect(PROCESS_SNAPSHOT_RECORDS_OFFSET) + .toBe(snapshot.process_snapshot_wire.records_offset); + expect(PROCESS_SNAPSHOT_HEADER_BYTES) + .toBe(snapshot.process_snapshot_wire.header.size); + expect(PROCESS_SNAPSHOT_PID_OFFSET) + .toBe(processSnapshotFieldOffset("pid")); + expect(PROCESS_SNAPSHOT_PPID_OFFSET) + .toBe(processSnapshotFieldOffset("ppid")); + expect(PROCESS_SNAPSHOT_UID_OFFSET) + .toBe(processSnapshotFieldOffset("uid")); + expect(PROCESS_SNAPSHOT_GID_OFFSET) + .toBe(processSnapshotFieldOffset("gid")); + expect(PROCESS_SNAPSHOT_VSIZE_OFFSET) + .toBe(processSnapshotFieldOffset("vsize")); + expect(PROCESS_SNAPSHOT_STATE_OFFSET) + .toBe(processSnapshotFieldOffset("state")); + expect(PROCESS_SNAPSHOT_COMM_LEN_OFFSET) + .toBe(processSnapshotFieldOffset("comm_len")); + expect(PROCESS_SNAPSHOT_CMDLINE_LEN_OFFSET) + .toBe(processSnapshotFieldOffset("cmdline_len")); + }); + + it("match the atomic process-metadata transaction contract", () => { + expect(PROCESS_METADATA_KIND_ARGV) + .toBe(snapshot.process_metadata_contract.kind_argv); + expect(PROCESS_METADATA_KIND_ENVIRONMENT) + .toBe(snapshot.process_metadata_contract.kind_environment); + }); + it("match status and signal delivery metadata", () => { expect(CHANNEL_STATUS.Idle).toBe(statusNumber("Idle")); expect(CHANNEL_STATUS.Pending).toBe(statusNumber("Pending")); @@ -441,6 +512,11 @@ describe("generated host ABI bindings", () => { expect(STRUCT_SIZE_WASM_STAT).toBe(snapshot.marshalled_structs.WasmStat.size); expect(STRUCT_SIZE_WASM_DIRENT).toBe(snapshot.marshalled_structs.WasmDirent.size); + expect(WASM_DIRENT_INO_OFFSET).toBe(structFieldOffset("WasmDirent", "d_ino")); + expect(WASM_DIRENT_TYPE_OFFSET).toBe(structFieldOffset("WasmDirent", "d_type")); + expect(WASM_DIRENT_NAME_LENGTH_OFFSET).toBe( + structFieldOffset("WasmDirent", "d_namlen"), + ); expect(STRUCT_SIZE_WASM_TIMESPEC).toBe(snapshot.marshalled_structs.WasmTimespec.size); expect(STRUCT_SIZE_WASM_POLL_FD).toBe(snapshot.marshalled_structs.WasmPollFd.size); expect(STRUCT_SIZE_WASM_STATFS).toBe(snapshot.marshalled_structs.WasmStatfs.size); diff --git a/host/test/host-adapter-manifest.test.ts b/host/test/host-adapter-manifest.test.ts index 8bd594b5d5..e68f6b439d 100644 --- a/host/test/host-adapter-manifest.test.ts +++ b/host/test/host-adapter-manifest.test.ts @@ -158,8 +158,13 @@ describe("host adapter manifest validation", () => { }); it.each([ - "kernel_clear_process_metadata", - "kernel_push_process_metadata_entry", + "kernel_get_cwd", + "kernel_get_dirfd_path", + "kernel_get_fd_path", + "kernel_process_metadata_begin", + "kernel_process_metadata_cancel", + "kernel_process_metadata_commit", + "kernel_process_metadata_stage", "kernel_set_cwd", ])("rejects a kernel missing required scratch transfer export %s", (name) => { const memory = createMemory(); diff --git a/host/test/kernel-authority-boundary.test.ts b/host/test/kernel-authority-boundary.test.ts index 6b1048214a..59ce2bad3a 100644 --- a/host/test/kernel-authority-boundary.test.ts +++ b/host/test/kernel-authority-boundary.test.ts @@ -225,6 +225,7 @@ describe("kernel authority boundary", () => { "installParkedCloneCompletionForTest", "probeMqueueNotificationCapacityForTest", "probeWaitableChildCapacityForTest", + "readKernelOwnedPathForTest", "replaceKernelForScratchBoundaryTest", "replaceProcessRegistrationForLifecycleTest", "replaceTcpScratchForScratchBoundaryTest", diff --git a/host/test/kernel-export-failure-audit.test.ts b/host/test/kernel-export-failure-audit.test.ts index aff4c73658..d15d133bff 100644 --- a/host/test/kernel-export-failure-audit.test.ts +++ b/host/test/kernel-export-failure-audit.test.ts @@ -31,6 +31,22 @@ const reservationSettlementAllowances = [ + "finally revokes the lease and skips cancellation because Rust " + "settlement is unknown before throwing one fatal wrapper.", }, + { + owner: + "CentralizedKernelWorker.#readKernelOwnedPath", + why: + "The large canonical-path catch records a branded reservation or copy " + + "trap, then its finally revokes the lease and skips cancellation " + + "because Rust settlement is unknown before throwing one fatal wrapper.", + }, + { + owner: + "CentralizedKernelWorker.#replaceProcessMetadataWithinKernelEntry", + why: + "The metadata transaction catch records a branded stage or commit trap " + + "before finally decides whether cancellation is still safe. A trapped " + + "Rust instance must be poisoned without entering its cancel export.", + }, ] satisfies KernelExportFailureCatchAllowance[]; describe("kernel export-failure catch audit", () => { diff --git a/host/test/kernel-process-registration-entry.test.ts b/host/test/kernel-process-registration-entry.test.ts index 8966ffd20d..2e13c6582e 100644 --- a/host/test/kernel-process-registration-entry.test.ts +++ b/host/test/kernel-process-registration-entry.test.ts @@ -13,15 +13,19 @@ import { import { allocateKernelScratchRegion } from "../src/kernel-scratch"; import { CH_TOTAL_SIZE, + PROCESS_METADATA_KIND_ARGV, + PROCESS_METADATA_KIND_ENVIRONMENT, PROCESS_STATE_RUNNING, } from "../src/generated/abi"; import { createKernelScratchTestInstance } from "./support/kernel-scratch-instance"; const KERNEL_EXPORT_NAMES = [ - "kernel_clear_process_metadata", "kernel_create_process_with_stdio", "kernel_get_process_state", - "kernel_push_process_metadata_entry", + "kernel_process_metadata_begin", + "kernel_process_metadata_cancel", + "kernel_process_metadata_commit", + "kernel_process_metadata_stage", "kernel_set_brk_base", "kernel_set_brk_limit", "kernel_set_max_addr", @@ -46,26 +50,28 @@ function processMemory(): WebAssembly.Memory { function makeHarness( implementations: Record, + pointerWidth: 4 | 8 = 4, ): ProcessEntryHarness { const kernelMemory = new WebAssembly.Memory({ initial: 4, - maximum: 4, + maximum: 8, }); const gate = new KernelEntryGate(); const rawInstance = createKernelScratchTestInstance( - 4, + pointerWidth, kernelMemory, () => implementations, - () => 4_096, + () => pointerWidth === 8 ? 4_096n : 4_096, 4, KERNEL_EXPORT_NAMES, ); const gatedInstance = createKernelEntryGatedInstance(rawInstance, gate); const mainScratch = allocateKernelScratchRegion( kernelMemory, - gatedInstance.exports.kernel_alloc_scratch as (size: number) => number, + gatedInstance.exports.kernel_alloc_scratch as + (size: number) => number | bigint, CH_TOTAL_SIZE, - 4, + pointerWidth, "process registration entry test scratch", gatedInstance, ); @@ -95,10 +101,34 @@ function makeHarness( } describe("kernel process registration entry authority", () => { - it("publishes one complete registration from immutable metadata snapshots", () => { + it("rejects kernel Memory as process Memory before any export runs", () => { + const calls = Object.fromEntries(KERNEL_EXPORT_NAMES.map((name) => [ + name, + vi.fn(() => name === "kernel_get_process_state" + ? PROCESS_STATE_RUNNING + : 0), + ])); + const harness = makeHarness(calls); + + expect(() => harness.worker.registerProcess( + 41, + harness.kernelMemory, + [65_536], + )).toThrow("Process Memory must not alias kernel Memory"); + + for (const call of Object.values(calls)) { + expect(call).not.toHaveBeenCalled(); + } + }); + + it.each([4, 8] as const)( + "publishes one complete wasm%s registration from immutable metadata snapshots", + (pointerWidth) => { const argv = ["program", "first"]; const env = ["A=original"]; - const clear = vi.fn(() => 0); + const begin = vi.fn(() => 41); + const cancel = vi.fn(() => 0); + const commit = vi.fn(() => 0); const createProcess = vi.fn(() => 47); const getProcessState = vi.fn(() => PROCESS_STATE_RUNNING); const setBrkBase = vi.fn(() => 0); @@ -110,10 +140,11 @@ describe("kernel process registration entry authority", () => { readonly bytes: Uint8Array; }> = []; let harness!: ProcessEntryHarness; - const push = vi.fn(( + const stage = vi.fn(( _pid: number, + _token: number, kind: number, - pointer: number, + pointer: number | bigint, length: number, ) => { pushed.push({ @@ -121,7 +152,7 @@ describe("kernel process registration entry authority", () => { bytes: new Uint8Array( new Uint8Array( harness.kernelMemory.buffer, - pointer, + Number(pointer), length, ), ), @@ -131,20 +162,25 @@ describe("kernel process registration entry authority", () => { if (pushed.length === 1) { argv[1] = "replaced"; env.push("B=late"); + // Rust entry allocation may grow kernel memory. The next entry must + // reacquire the current buffer through a fresh scratch lease. + harness.kernelMemory.grow(1); } return 0; }); harness = makeHarness({ - kernel_clear_process_metadata: clear, kernel_create_process_with_stdio: createProcess, kernel_get_process_state: getProcessState, - kernel_push_process_metadata_entry: push, + kernel_process_metadata_begin: begin, + kernel_process_metadata_cancel: cancel, + kernel_process_metadata_commit: commit, + kernel_process_metadata_stage: stage, kernel_set_brk_base: setBrkBase, kernel_set_brk_limit: setBrkLimit, kernel_set_max_addr: setMaxAddr, kernel_set_mmap_base: setMmapBase, kernel_vblank: () => 0, - }); + }, pointerWidth); const pid = harness.worker.createProcess(CAPTURED_STDIO); const memory = processMemory(); @@ -155,36 +191,40 @@ describe("kernel process registration entry authority", () => { brkLimit: 120_000, maxAddr: 130_000, mmapBase: 80_000, - ptrWidth: 4, + ptrWidth: pointerWidth, }); expect(createProcess).toHaveBeenCalledWith(0, 0, 0); expect(getProcessState).toHaveBeenCalledWith(pid); - expect(clear.mock.calls).toEqual([ - [pid, 0], - [pid, 1], - ]); + expect(begin).toHaveBeenCalledWith(pid); + expect(commit).toHaveBeenCalledWith(pid, 41); + expect(cancel).not.toHaveBeenCalled(); expect(pushed.map(({ kind, bytes }) => ({ kind, text: new TextDecoder().decode(bytes), }))).toEqual([ - { kind: 0, text: "program" }, - { kind: 0, text: "first" }, - { kind: 1, text: "A=original" }, + { kind: PROCESS_METADATA_KIND_ARGV, text: "program" }, + { kind: PROCESS_METADATA_KIND_ARGV, text: "first" }, + { kind: PROCESS_METADATA_KIND_ENVIRONMENT, text: "A=original" }, ]); - expect(setBrkBase).toHaveBeenCalledWith(pid, 70_000); - expect(setBrkLimit).toHaveBeenCalledWith(pid, 120_000); - expect(setMaxAddr).toHaveBeenCalledWith(pid, 130_000); - expect(setMmapBase).toHaveBeenCalledWith(pid, 80_000); + const kernelPointer = (value: number): number | bigint => + pointerWidth === 8 ? BigInt(value) : value; + expect(setBrkBase).toHaveBeenCalledWith(pid, kernelPointer(70_000)); + expect(setBrkLimit).toHaveBeenCalledWith(pid, kernelPointer(120_000)); + expect(setMaxAddr).toHaveBeenCalledWith(pid, kernelPointer(130_000)); + expect(setMmapBase).toHaveBeenCalledWith(pid, kernelPointer(80_000)); expect(harness.worker.getProcessMemory(pid)).toBe(memory); - }); + }, + ); it("rejects synchronous authority roots during a live kernel export", async () => { const exportCalls = { - clear: vi.fn(() => 0), create: vi.fn(() => 51), getState: vi.fn(() => PROCESS_STATE_RUNNING), - push: vi.fn(() => 0), + metadataBegin: vi.fn(() => 1), + metadataCancel: vi.fn(() => 0), + metadataCommit: vi.fn(() => 0), + metadataStage: vi.fn(() => 0), setBrkBase: vi.fn(() => 0), setBrkLimit: vi.fn(() => 0), setMaxAddr: vi.fn(() => 0), @@ -194,10 +234,12 @@ describe("kernel process registration entry authority", () => { let harness!: ProcessEntryHarness; const guestMemory = processMemory(); harness = makeHarness({ - kernel_clear_process_metadata: exportCalls.clear, kernel_create_process_with_stdio: exportCalls.create, kernel_get_process_state: exportCalls.getState, - kernel_push_process_metadata_entry: exportCalls.push, + kernel_process_metadata_begin: exportCalls.metadataBegin, + kernel_process_metadata_cancel: exportCalls.metadataCancel, + kernel_process_metadata_commit: exportCalls.metadataCommit, + kernel_process_metadata_stage: exportCalls.metadataStage, kernel_set_brk_base: exportCalls.setBrkBase, kernel_set_brk_limit: exportCalls.setBrkLimit, kernel_set_max_addr: exportCalls.setMaxAddr, @@ -209,20 +251,12 @@ describe("kernel process registration entry authority", () => { 51, guestMemory, [65_536], + { argv: ["program"], env: [] }, ), () => harness.worker.setBrkBase(51, 70_000), () => harness.worker.setBrkLimit(51, 120_000), () => harness.worker.setMaxAddr(51, 130_000), () => harness.worker.setMmapBase(51, 80_000), - () => ( - harness.worker as unknown as { - replaceProcessMetadata( - pid: number, - kind: number, - values: readonly string[], - ): void; - } - ).replaceProcessMetadata(51, 0, ["program"]), ]; for (const attempt of attempts) { try { @@ -240,7 +274,7 @@ describe("kernel process registration entry authority", () => { )(); await Promise.resolve(); - expect(caught).toHaveLength(7); + expect(caught).toHaveLength(6); for (const error of caught) { expect(error).toBeInstanceOf(KernelReentrantEntryError); } @@ -250,26 +284,176 @@ describe("kernel process registration entry authority", () => { expect(harness.worker.getProcessMemory(51)).toBeUndefined(); }); - it("does not publish host registration after a metadata-stage failure", () => { + it.each([ + { argv: ["program"] }, + { env: ["A=value"] }, + ])("rejects a partial metadata pair before entering the kernel", (metadata) => { + const harness = makeHarness({}); + expect(() => harness.worker.registerProcess( + 52, + processMemory(), + [65_536], + metadata, + )).toThrow(/replace argv and environment together/); + }); + + it.each([4, 8] as const)( + "cancels a later wasm%s environment-stage ENOMEM without publishing either vector", + (pointerWidth) => { + let liveArgv = ["old-program", "old-argument"]; + let liveEnvironment = ["OLD=value"]; + let nextToken = 70; + let active: { + readonly token: number; + readonly argv: string[]; + readonly environment: string[]; + } | undefined; + let failLaterEnvironmentStage = true; + let harness!: ProcessEntryHarness; + const begin = vi.fn((_pid: number) => { + const transaction = { + token: nextToken++, + argv: [] as string[], + environment: [] as string[], + }; + active = transaction; + return transaction.token; + }); + const stage = vi.fn(( + _pid: number, + token: number, + kind: number, + pointer: number | bigint, + length: number, + ) => { + if (active?.token !== token) return -22; + const value = new TextDecoder().decode(new Uint8Array( + harness.kernelMemory.buffer, + Number(pointer), + length, + )); + if ( + failLaterEnvironmentStage + && kind === PROCESS_METADATA_KIND_ENVIRONMENT + && active.environment.length === 1 + ) { + failLaterEnvironmentStage = false; + return -12; + } + const destination = kind === PROCESS_METADATA_KIND_ARGV + ? active.argv + : active.environment; + destination.push(value); + return 0; + }); + const commit = vi.fn((_pid: number, token: number) => { + if (active?.token !== token) return -22; + liveArgv = active.argv; + liveEnvironment = active.environment; + active = undefined; + return 0; + }); + const cancel = vi.fn((_pid: number, token: number) => { + if (active?.token !== token) return -22; + active = undefined; + return 0; + }); + const setBrkBase = vi.fn(() => 0); + const setBrkLimit = vi.fn(() => 0); + const setMaxAddr = vi.fn(() => 0); + const setMmapBase = vi.fn(() => 0); + harness = makeHarness({ + kernel_create_process_with_stdio: () => 63, + kernel_get_process_state: () => PROCESS_STATE_RUNNING, + kernel_process_metadata_begin: begin, + kernel_process_metadata_cancel: cancel, + kernel_process_metadata_commit: commit, + kernel_process_metadata_stage: stage, + kernel_set_brk_base: setBrkBase, + kernel_set_brk_limit: setBrkLimit, + kernel_set_max_addr: setMaxAddr, + kernel_set_mmap_base: setMmapBase, + kernel_vblank: () => 0, + }, pointerWidth); + const memory = processMemory(); + + expect(() => harness.worker.registerProcess( + 63, + memory, + [65_536], + { + argv: ["new-program"], + env: ["NEW=first", "NEW=second"], + brkBase: 70_000, + brkLimit: 120_000, + maxAddr: 130_000, + mmapBase: 80_000, + ptrWidth: pointerWidth, + }, + )).toThrow(/errno 12/); + expect(commit).not.toHaveBeenCalled(); + expect(cancel).toHaveBeenCalledWith(63, 70); + expect(active).toBeUndefined(); + expect(liveArgv).toEqual(["old-program", "old-argument"]); + expect(liveEnvironment).toEqual(["OLD=value"]); + expect(harness.worker.getProcessMemory(63)).toBeUndefined(); + expect(setBrkBase).not.toHaveBeenCalled(); + expect(setBrkLimit).not.toHaveBeenCalled(); + expect(setMaxAddr).not.toHaveBeenCalled(); + expect(setMmapBase).not.toHaveBeenCalled(); + + harness.worker.registerProcess(63, memory, [65_536], { + argv: ["retry-program", ""], + env: [], + brkBase: 70_000, + brkLimit: 120_000, + maxAddr: 130_000, + mmapBase: 80_000, + ptrWidth: pointerWidth, + }); + expect(begin.mock.calls).toEqual([ + [63], + [63], + ]); + expect(commit).toHaveBeenCalledWith(63, 71); + expect(cancel).toHaveBeenCalledTimes(1); + expect(liveArgv).toEqual(["retry-program", ""]); + expect(liveEnvironment).toEqual([]); + const kernelPointer = (value: number): number | bigint => + pointerWidth === 8 ? BigInt(value) : value; + expect(setBrkBase).toHaveBeenCalledWith(63, kernelPointer(70_000)); + expect(setBrkLimit).toHaveBeenCalledWith(63, kernelPointer(120_000)); + expect(setMaxAddr).toHaveBeenCalledWith(63, kernelPointer(130_000)); + expect(setMmapBase).toHaveBeenCalledWith(63, kernelPointer(80_000)); + expect(harness.worker.getProcessMemory(63)).toBe(memory); + }, + ); + + it("does not enter cancellation after a metadata-stage Wasm trap", () => { + const cancel = vi.fn(() => 0); const harness = makeHarness({ - kernel_clear_process_metadata: () => 0, - kernel_create_process_with_stdio: () => 63, + kernel_create_process_with_stdio: () => 64, kernel_get_process_state: () => PROCESS_STATE_RUNNING, - kernel_push_process_metadata_entry: () => -5, + kernel_process_metadata_begin: () => 90, + kernel_process_metadata_cancel: cancel, + kernel_process_metadata_commit: () => 0, + kernel_process_metadata_stage: () => { + throw new Error("metadata stage trap"); + }, kernel_set_brk_base: () => 0, kernel_set_brk_limit: () => 0, kernel_set_max_addr: () => 0, kernel_set_mmap_base: () => 0, kernel_vblank: () => 0, }); - const memory = processMemory(); expect(() => harness.worker.registerProcess( - 63, - memory, + 64, + processMemory(), [65_536], - { argv: ["program"] }, - )).toThrow(); - expect(harness.worker.getProcessMemory(63)).toBeUndefined(); + { argv: ["program"], env: [] }, + )).toThrow(/kernel export kernel_process_metadata_stage failed/); + expect(cancel).not.toHaveBeenCalled(); + expect(harness.worker.getProcessMemory(64)).toBeUndefined(); }); }); diff --git a/host/test/kernel-public-entry-roots.test.ts b/host/test/kernel-public-entry-roots.test.ts index 532ebbeb20..283695427c 100644 --- a/host/test/kernel-public-entry-roots.test.ts +++ b/host/test/kernel-public-entry-roots.test.ts @@ -12,6 +12,19 @@ import { allocateKernelScratchRegion, KernelScratchError, } from "../src/kernel-scratch"; +import { + PROCESS_SNAPSHOT_CMDLINE_LEN_OFFSET, + PROCESS_SNAPSHOT_COMM_LEN_OFFSET, + PROCESS_SNAPSHOT_COUNT_OFFSET, + PROCESS_SNAPSHOT_GID_OFFSET, + PROCESS_SNAPSHOT_HEADER_BYTES, + PROCESS_SNAPSHOT_PID_OFFSET, + PROCESS_SNAPSHOT_PPID_OFFSET, + PROCESS_SNAPSHOT_RECORDS_OFFSET, + PROCESS_SNAPSHOT_STATE_OFFSET, + PROCESS_SNAPSHOT_UID_OFFSET, + PROCESS_SNAPSHOT_VSIZE_OFFSET, +} from "../src/generated/abi"; import { createKernelScratchTestInstance } from "./support/kernel-scratch-instance"; const SCRATCH_OFFSET = 4096; @@ -79,18 +92,36 @@ function processSnapshotBytes(): Uint8Array { const encoder = new TextEncoder(); const comm = encoder.encode("demo"); const cmdline = encoder.encode("demo\0--safe\0"); - const bytes = new Uint8Array(4 + 36 + comm.length + cmdline.length); + const bytes = new Uint8Array( + PROCESS_SNAPSHOT_RECORDS_OFFSET + + PROCESS_SNAPSHOT_HEADER_BYTES + + comm.length + + cmdline.length, + ); const view = new DataView(bytes.buffer); - let offset = 0; - view.setUint32(offset, 1, true); offset += 4; - view.setUint32(offset, 41, true); offset += 4; - view.setUint32(offset, 1, true); offset += 4; - view.setUint32(offset, 501, true); offset += 4; - view.setUint32(offset, 20, true); offset += 4; - view.setBigUint64(offset, 8192n, true); offset += 8; - view.setUint32(offset, "R".charCodeAt(0), true); offset += 4; - view.setUint32(offset, comm.length, true); offset += 4; - view.setUint32(offset, cmdline.length, true); offset += 4; + view.setUint32(PROCESS_SNAPSHOT_COUNT_OFFSET, 1, true); + const header = PROCESS_SNAPSHOT_RECORDS_OFFSET; + view.setUint32(header + PROCESS_SNAPSHOT_PID_OFFSET, 41, true); + view.setUint32(header + PROCESS_SNAPSHOT_PPID_OFFSET, 1, true); + view.setUint32(header + PROCESS_SNAPSHOT_UID_OFFSET, 501, true); + view.setUint32(header + PROCESS_SNAPSHOT_GID_OFFSET, 20, true); + view.setBigUint64(header + PROCESS_SNAPSHOT_VSIZE_OFFSET, 8192n, true); + view.setUint32( + header + PROCESS_SNAPSHOT_STATE_OFFSET, + "R".charCodeAt(0), + true, + ); + view.setUint32( + header + PROCESS_SNAPSHOT_COMM_LEN_OFFSET, + comm.length, + true, + ); + view.setUint32( + header + PROCESS_SNAPSHOT_CMDLINE_LEN_OFFSET, + cmdline.length, + true, + ); + let offset = header + PROCESS_SNAPSHOT_HEADER_BYTES; bytes.set(comm, offset); offset += comm.length; bytes.set(cmdline, offset); return bytes; @@ -222,6 +253,73 @@ describe("CentralizedKernelWorker public kernel-entry roots", () => { } }); + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "fails closed on malformed %s process snapshot records", + (_pointerKind, pointerWidth) => { + const valid = processSnapshotBytes(); + const truncatedSecond = new Uint8Array( + valid.byteLength + PROCESS_SNAPSHOT_HEADER_BYTES - 1, + ); + truncatedSecond.set(valid); + new DataView(truncatedSecond.buffer).setUint32( + PROCESS_SNAPSHOT_COUNT_OFFSET, + 2, + true, + ); + + const oversizedCmdline = valid.slice(); + const view = new DataView(oversizedCmdline.buffer); + view.setUint32( + PROCESS_SNAPSHOT_RECORDS_OFFSET + + PROCESS_SNAPSHOT_CMDLINE_LEN_OFFSET, + view.getUint32( + PROCESS_SNAPSHOT_RECORDS_OFFSET + + PROCESS_SNAPSHOT_CMDLINE_LEN_OFFSET, + true, + ) + 1, + true, + ); + + const invalidState = valid.slice(); + new DataView(invalidState.buffer).setUint32( + PROCESS_SNAPSHOT_RECORDS_OFFSET + PROCESS_SNAPSHOT_STATE_OFFSET, + "X".charCodeAt(0), + true, + ); + const invalidWideState = valid.slice(); + new DataView(invalidWideState.buffer).setUint32( + PROCESS_SNAPSHOT_RECORDS_OFFSET + PROCESS_SNAPSHOT_STATE_OFFSET, + 0x1_0000, + true, + ); + + for (const malformed of [ + valid.slice(0, PROCESS_SNAPSHOT_RECORDS_OFFSET - 1), + truncatedSecond, + oversizedCmdline, + invalidState, + invalidWideState, + ]) { + const harness = makeRootHarness(pointerWidth); + harness.implementations.kernel_enum_procs = ( + pointer: number | bigint, + capacity: number, + ) => { + expect(malformed.byteLength).toBeLessThanOrEqual(capacity); + harness.kernelBytes.set(malformed, Number(pointer)); + return malformed.byteLength; + }; + + expect(() => harness.worker.enumProcs()).toThrow( + /malformed kernel process snapshot/, + ); + } + }, + ); + it("keeps ordinary PTY/cwd/credential errnos process-local", () => { const harness = makeRootHarness(4); @@ -295,11 +393,32 @@ describe("CentralizedKernelWorker public kernel-entry roots", () => { }, ])( "accepts exact capacity and rejects capacity+1 for $name", - ({ request, install, invoke }) => { + ({ name, request, install, invoke }) => { const exact = makeRootHarness(4); install(exact, (pointer, capacity) => { expect(capacity).toBe(request); exact.kernelBytes.fill(0, Number(pointer), Number(pointer) + capacity); + if (name === "process enumeration") { + const view = new DataView(exact.kernelBytes.buffer); + const base = Number(pointer); + view.setUint32(base + PROCESS_SNAPSHOT_COUNT_OFFSET, 1, true); + view.setUint32( + base + + PROCESS_SNAPSHOT_RECORDS_OFFSET + + PROCESS_SNAPSHOT_STATE_OFFSET, + "R".charCodeAt(0), + true, + ); + view.setUint32( + base + + PROCESS_SNAPSHOT_RECORDS_OFFSET + + PROCESS_SNAPSHOT_COMM_LEN_OFFSET, + capacity + - PROCESS_SNAPSHOT_RECORDS_OFFSET + - PROCESS_SNAPSHOT_HEADER_BYTES, + true, + ); + } return capacity; }); expect(() => invoke(exact)).not.toThrow(); diff --git a/host/test/kernel-scratch-contract.test.ts b/host/test/kernel-scratch-contract.test.ts index 85d7bc1115..a3260e3057 100644 --- a/host/test/kernel-scratch-contract.test.ts +++ b/host/test/kernel-scratch-contract.test.ts @@ -6,6 +6,8 @@ import { POSIX_ARG_MAX_BYTES, POSIX_IOV_MAX, POSIX_PATH_MAX_BYTES, + PROCESS_STARTUP_MAX_ARGV_COUNT, + PROCESS_STARTUP_MAX_ENVP_COUNT, SELECT_FD_SET_BYTES, SELECT_FD_SETSIZE, SPAWN_ATTR_RESETIDS, @@ -72,8 +74,15 @@ const publicLimitsHeader = readFileSync( new URL("../../libc/musl-overlay/include/limits.h", import.meta.url), "utf8", ); -const muslSelectHeader = readFileSync( - new URL("../../libc/musl/include/sys/select.h", import.meta.url), +const processLayoutsHeader = readFileSync( + new URL( + "../../libc/musl-overlay/include/bits/kandelo_process_layouts.h", + import.meta.url, + ), + "utf8", +); +const processNativeLayoutsSource = readFileSync( + new URL("../../tests/abi/process-native-layouts.c", import.meta.url), "utf8", ); const spawnContractHeader = readFileSync( @@ -545,6 +554,22 @@ const reviewedScalarKernelExportCall = ( }); const reviewedScalarKernelExportCalls: AuditAllowance[] = [ + reviewedScalarKernelExportCall( + "apps/browser-demos/test/fixtures/reusable-kernel-export-stack-worker.ts::runProbe::kernel-export-direct-use::exports.kernel_create_process()", + ), + reviewedScalarKernelExportCall( + "apps/browser-demos/test/fixtures/reusable-kernel-export-stack-worker.ts::runProbe::kernel-export-direct-use::exports.kernel_exit(0)", + ), + reviewedScalarKernelExportCall( + "apps/browser-demos/test/fixtures/reusable-kernel-export-stack-worker.ts::runProbe::kernel-export-direct-use::exports.kernel_get_stack_pointer()", + 3, + ), + reviewedScalarKernelExportCall( + "apps/browser-demos/test/fixtures/reusable-kernel-export-stack-worker.ts::runProbe::kernel-export-direct-use::exports.kernel_reap_process(pid)", + ), + reviewedScalarKernelExportCall( + "apps/browser-demos/test/fixtures/reusable-kernel-export-stack-worker.ts::runProbe::kernel-export-direct-use::exports.kernel_set_current_tid(pid, pid)", + ), reviewedScalarKernelExportCall( "host/src/kernel-worker.ts::CentralizedKernelWorker.#attachThreadChannelWithinKernelEntry::kernel-export-direct-use::setMaxAddr(pid, this.toKernelPtr(tlsPageAddr))", ), @@ -609,7 +634,13 @@ const reviewedScalarKernelExportCalls: AuditAllowance[] = [ "host/src/kernel-worker.ts::CentralizedKernelWorker.#removeFromKernelProcessTableWithinKernelEntry::kernel-export-direct-use::removeProcess(pid)", ), reviewedScalarKernelExportCall( - "host/src/kernel-worker.ts::CentralizedKernelWorker.#replaceProcessMetadataWithinKernelEntry::kernel-export-direct-use::clear(pid, kind)", + "host/src/kernel-worker.ts::CentralizedKernelWorker.#replaceProcessMetadataWithinKernelEntry::kernel-export-direct-use::begin(pid)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#replaceProcessMetadataWithinKernelEntry::kernel-export-direct-use::cancel(pid, token)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#replaceProcessMetadataWithinKernelEntry::kernel-export-direct-use::commit(pid, token)", ), reviewedScalarKernelExportCall( "host/src/kernel-worker.ts::CentralizedKernelWorker.#reserveHostRegionAtWithinKernelEntry::kernel-export-direct-use::reserveHostRegionAtFn( pid, this.toKernelPtr(request.pointer), this.toKernelPtr(request.length), )", @@ -928,6 +959,12 @@ const auditAllowances: AuditAllowance[] = [ authorityOwner: "process-memory", why: "This browser epoll reproduction creates its test process memory, not the kernel's linear memory.", }, + { + key: "apps/browser-demos/test/fixtures/reusable-kernel-export-stack-worker.ts::runProbe::wasm-instance-authority::WebAssembly.instantiate(module, imports)", + disposition: "kernel-control", + authorityOwner: "kernel", + why: "This exact dedicated test-worker site is injected through the module-secret kernel harness and retains the raw instance only long enough to verify returning kernel exports restore the Wasm shadow stack; production still publishes only the gated facade.", + }, { key: 'host/src/process-memory.ts::ProcessMemoryAllocator.createMemory::wasm-memory-authority::new WebAssembly.Memory({ initial: BigInt(request.initialPages) as any, maximum: BigInt(request.maximumPages) as any, shared: true, address: "i64", } as any)', disposition: "non-kernel", @@ -1507,8 +1544,10 @@ describe("kernel scratch static contract", () => { } expect(formatAuditFailures(result)).toEqual([]); // This intentionally builds one TypeScript program for every repository - // runtime source; keep CI headroom above the focused local 25–35 second run. - }, 60_000); + // runtime source. Focused local runs take roughly 35 seconds, while the + // exact-head parallel CI run exceeded 60 seconds. Keep a finite two-minute + // watchdog without weakening the default-deny audit's scope or assertions. + }, 120_000); it("keeps variable-transfer parsing private and allocation-region-bearing", () => { const prefixOnlyFixture = kernelExportNamesFromSnapshot( @@ -1603,6 +1642,14 @@ describe("kernel scratch static contract", () => { expect(platformLimitsHeader).toContain( `#define KANDELO_POSIX_IOV_MAX ${POSIX_IOV_MAX}u`, ); + expect(platformLimitsHeader).toContain( + `#define KANDELO_PROCESS_STARTUP_MAX_ARGV_COUNT ` + + `${PROCESS_STARTUP_MAX_ARGV_COUNT}u`, + ); + expect(platformLimitsHeader).toContain( + `#define KANDELO_PROCESS_STARTUP_MAX_ENVP_COUNT ` + + `${PROCESS_STARTUP_MAX_ENVP_COUNT}u`, + ); expect(publicLimitsHeader).toContain("#include "); expect(publicLimitsHeader).toContain( @@ -1614,8 +1661,21 @@ describe("kernel scratch static contract", () => { expect(publicLimitsHeader).toContain( "#define IOV_MAX KANDELO_POSIX_IOV_MAX", ); - expect(muslSelectHeader).toContain( - `#define FD_SETSIZE ${SELECT_FD_SETSIZE}`, + expect(processLayoutsHeader).toContain( + `#define KANDELO_SELECT_FD_SETSIZE ${SELECT_FD_SETSIZE}u`, + ); + expect(processLayoutsHeader).toContain( + `#define KANDELO_SELECT_FD_SET_BYTES ${SELECT_FD_SET_BYTES}u`, + ); + // WHY: Vitest checkouts intentionally need not initialize the musl + // submodule. This tracked compile-time probe is exercised against both + // installed musl sysroots by the ABI check, so it detects real C layout + // drift without making an ordinary host test depend on submodule state. + expect(processNativeLayoutsSource).toContain( + "_Static_assert(FD_SETSIZE == KANDELO_SELECT_FD_SETSIZE,", + ); + expect(processNativeLayoutsSource).toContain( + "_Static_assert(sizeof(fd_set) == KANDELO_SELECT_FD_SET_BYTES,", ); expect(SELECT_FD_SET_BYTES).toBe(SELECT_FD_SETSIZE / 8); @@ -1691,6 +1751,8 @@ describe("kernel scratch static contract", () => { expect(spawnContractHeader).toContain( `#define WASM_POSIX_SPAWN_MAX_ENVP_COUNT ${SPAWN_MAX_ENVP_COUNT}u`, ); + expect(SPAWN_MAX_ARGV_COUNT).toBe(PROCESS_STARTUP_MAX_ARGV_COUNT); + expect(SPAWN_MAX_ENVP_COUNT).toBe(PROCESS_STARTUP_MAX_ENVP_COUNT); expect(spawnContractHeader).toContain( `#define WASM_POSIX_SPAWN_MAX_ACTION_COUNT ${SPAWN_MAX_ACTION_COUNT}u`, ); diff --git a/host/test/kernel-scratch-transfer-boundaries.test.ts b/host/test/kernel-scratch-transfer-boundaries.test.ts index a1f8f63906..a7d579edd7 100644 --- a/host/test/kernel-scratch-transfer-boundaries.test.ts +++ b/host/test/kernel-scratch-transfer-boundaries.test.ts @@ -44,9 +44,12 @@ import { KERNEL_MSGHDR_WIRE_NAME_OFFSET, KERNEL_MSGHDR_WIRE_NAMELEN_OFFSET, KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, + KERNEL_SCRATCH_SOCKET_OPTION_MAX_BYTES, POSIX_IOV_MAX, + POSIX_NAME_MAX_BYTES, POSIX_NGROUPS_MAX, POSIX_PATH_MAX_BYTES, + PROCESS_METADATA_ENTRY_MAX_BYTES, PROCESS_CMSGHDR_WASM32_ALIGN, PROCESS_CMSGHDR_WASM32_DATA_OFFSET, PROCESS_CMSGHDR_WASM32_LEN_OFFSET, @@ -78,9 +81,11 @@ import { SOCKET_SOL_SOCKET, STRUCT_SIZE_KERNEL_IOVEC_WIRE, STRUCT_SIZE_KERNEL_MSGHDR_WIRE, + STRUCT_SIZE_WASM_DIRENT, STRUCT_SIZE_WASM_EPOLL_EVENT, STRUCT_SIZE_WASM_SYSV_MESSAGE_HEADER, SYSCALL_ARGS, + WASM_DIRENT_NAME_LENGTH_OFFSET, WASM_EPOLL_EVENT_DATA_OFFSET, WASM_EPOLL_EVENT_EVENTS_OFFSET, WASM_EPOLL_EVENT_PAD_OFFSET, @@ -90,7 +95,9 @@ const EFAULT = 14; const EIO = 5; const ENOMEM = 12; const EINVAL = 22; +const ENOTDIR = 20; const EOVERFLOW = 75; +const ERANGE = 34; const EAGAIN = 11; const EMSGSIZE = 90; const IPC_NOWAIT = 0x800; @@ -563,6 +570,46 @@ function invokeNetworkIoctlHandler( dispatchScratchBoundarySyscall(harness); } +function installCompletePathProducer( + harness: ScratchHarness, + path: Uint8Array, + fd: number | null, + directoryOnly = false, +): ReturnType { + const copy = ( + pointer: number | bigint, + capacity: number, + ): number => { + if (capacity === 0) return path.byteLength; + if (capacity < path.byteLength) return -ERANGE; + harness.kernelBytes.set(path, Number(pointer)); + return path.byteLength; + }; + const producer = fd === null + ? vi.fn(( + _pid: number, + pointer: number | bigint, + capacity: number, + ) => copy(pointer, capacity)) + : vi.fn(( + _pid: number, + actualFd: number, + pointer: number | bigint, + capacity: number, + ) => { + expect(actualFd).toBe(fd); + return copy(pointer, capacity); + }); + harness.kernelExports[ + fd === null + ? "kernel_get_cwd" + : directoryOnly + ? "kernel_get_dirfd_path" + : "kernel_get_fd_path" + ] = producer; + return producer; +} + function writeNativeIovec( processBytes: Uint8Array, pointerWidth: 4 | 8, @@ -890,6 +937,218 @@ function expectScratchTailUntouched(harness: ScratchHarness): void { expect(tail.every((byte) => byte === 0xa5)).toBe(true); } +describe("complete kernel-owned path transfers", () => { + it.each([ + [4, null], + [8, null], + [4, 17], + [8, 17], + ] as const)( + "copies the exact main allocation capacity for wasm%s fd=%s", + (pointerWidth, fd) => { + const harness = makeScratchHarness(pointerWidth); + const path = new Uint8Array(CH_TOTAL_SIZE).fill(0x61); + path[0] = 0x2f; + const producer = installCompletePathProducer(harness, path, fd); + const begin = vi.spyOn( + harness.kernelExports, + "kernel_transfer_scratch_begin", + ); + + const result = harness.worker.testAuthority.readKernelOwnedPathForTest( + harness.channel, + fd, + ); + + expect(result).toEqual({ kind: "ok", value: path }); + expect(producer).toHaveBeenCalledOnce(); + expect(producer.mock.calls[0]?.at(-1)).toBe(CH_TOTAL_SIZE); + expect(begin).not.toHaveBeenCalled(); + expect( + harness.kernelBytes.slice(harness.scratchOffset, harness.scratchEnd), + ).toEqual(path); + expect( + harness.kernelBytes.slice(harness.scratchEnd, harness.scratchEnd + 32), + ).toEqual(new Uint8Array(32).fill(0xa5)); + }, + ); + + it.each([ + [4, null], + [8, null], + [4, 23], + [8, 23], + ] as const)( + "queries and reserves main capacity plus one for wasm%s fd=%s", + (pointerWidth, fd) => { + const harness = makeScratchHarness(pointerWidth); + const path = new Uint8Array(CH_TOTAL_SIZE + 1).fill(0x62); + path[0] = 0x2f; + const producer = installCompletePathProducer(harness, path, fd); + const begin = vi.spyOn( + harness.kernelExports, + "kernel_transfer_scratch_begin", + ); + const cancel = vi.spyOn( + harness.kernelExports, + "kernel_transfer_scratch_cancel", + ); + harness.kernelBytes[harness.transferOffset + path.byteLength] = 0x5a; + + const result = harness.worker.testAuthority.readKernelOwnedPathForTest( + harness.channel, + fd, + ); + + expect(result).toEqual({ kind: "ok", value: path }); + expect( + producer.mock.calls.map((call) => call.at(-1)), + ).toEqual([CH_TOTAL_SIZE, 0, CH_TOTAL_SIZE + 1]); + expect(begin).toHaveBeenCalledWith( + pointerWidth === 8 ? BigInt(path.byteLength) : path.byteLength, + ); + expect(cancel).toHaveBeenCalledOnce(); + expect( + harness.kernelBytes.slice( + harness.transferOffset, + harness.transferOffset + path.byteLength, + ), + ).toEqual(path); + expect( + harness.kernelBytes[harness.transferOffset + path.byteLength], + ).toBe(0x5a); + }, + ); + + it.each([4, 8] as const)( + "propagates allocation failure without publishing a partial wasm%s path", + (pointerWidth) => { + const harness = makeScratchHarness(pointerWidth); + const path = new Uint8Array(CH_TOTAL_SIZE + 1).fill(0x63); + path[0] = 0x2f; + installCompletePathProducer(harness, path, null); + harness.kernelExports.kernel_transfer_scratch_begin = vi.fn( + () => BigInt(-ENOMEM), + ); + const beforeMain = harness.kernelBytes.slice( + harness.scratchOffset, + harness.scratchEnd, + ); + + expect( + harness.worker.testAuthority.readKernelOwnedPathForTest( + harness.channel, + null, + ), + ).toEqual({ kind: "error", errno: ENOMEM }); + expect( + harness.kernelBytes.slice(harness.scratchOffset, harness.scratchEnd), + ).toEqual(beforeMain); + }, + ); + + it.each([4, 8] as const)( + "cancels an invalid wasm%s allocator range and fails closed", + (pointerWidth) => { + const harness = makeScratchHarness(pointerWidth); + const path = new Uint8Array(CH_TOTAL_SIZE + 1).fill(0x64); + path[0] = 0x2f; + installCompletePathProducer(harness, path, null); + const token = 91n; + harness.kernelExports.kernel_transfer_scratch_begin = vi.fn( + () => token, + ); + harness.kernelExports.kernel_transfer_scratch_pointer = vi.fn( + () => pointerWidth === 8 + ? BigInt(harness.kernelMemory.buffer.byteLength - path.byteLength + 1) + : harness.kernelMemory.buffer.byteLength - path.byteLength + 1, + ); + harness.kernelExports.kernel_transfer_scratch_capacity = vi.fn( + () => pointerWidth === 8 + ? BigInt(path.byteLength) + : path.byteLength, + ); + const cancel = vi.fn(() => 0); + harness.kernelExports.kernel_transfer_scratch_cancel = cancel; + + expect( + harness.worker.testAuthority.readKernelOwnedPathForTest( + harness.channel, + null, + ), + ).toEqual({ kind: "error", errno: EIO }); + expect(cancel).toHaveBeenCalledWith(token); + }, + ); + + it.each([4, 8] as const)( + "rejects a mismatched exact retry and permits the next wasm%s transfer", + (pointerWidth) => { + const harness = makeScratchHarness(pointerWidth); + let path = new Uint8Array(CH_TOTAL_SIZE + 1).fill(0x65); + path[0] = 0x2f; + const producer = installCompletePathProducer(harness, path, null); + producer.mockImplementationOnce(( + _pid: number, + _pointer: number | bigint, + _capacity: number, + ) => -ERANGE); + producer.mockImplementationOnce(( + _pid: number, + _pointer: number | bigint, + _capacity: number, + ) => path.byteLength); + producer.mockImplementationOnce(( + _pid: number, + _pointer: number | bigint, + _capacity: number, + ) => path.byteLength - 1); + + expect( + harness.worker.testAuthority.readKernelOwnedPathForTest( + harness.channel, + null, + ), + ).toEqual({ kind: "error", errno: EIO }); + + path = new TextEncoder().encode("/next"); + installCompletePathProducer(harness, path, null); + expect( + harness.worker.testAuthority.readKernelOwnedPathForTest( + harness.channel, + null, + ), + ).toEqual({ kind: "ok", value: path }); + }, + ); + + it.each([4, 8] as const)( + "uses the directory-only wasm%s producer for a relative dirfd base", + (pointerWidth) => { + const harness = makeScratchHarness(pointerWidth); + const fd = 23; + const ordinary = installCompletePathProducer( + harness, + new TextEncoder().encode("/regular"), + fd, + ); + const directory = vi.fn(() => -ENOTDIR); + harness.kernelExports.kernel_get_dirfd_path = directory; + + expect( + harness.worker.testAuthority.readKernelOwnedPathForTest( + harness.channel, + fd, + true, + ), + ).toEqual({ kind: "error", errno: ENOTDIR }); + expect(directory).toHaveBeenCalledOnce(); + expect(ordinary).not.toHaveBeenCalled(); + expectScratchTailUntouched(harness); + }, + ); +}); + describe("kernel scratch transfer capacity regressions", () => { it("binds allocator authority to the same shared kernel memory", () => { const kernelMemory = sharedMemory(2); @@ -1579,26 +1838,12 @@ describe("kernel scratch transfer capacity regressions", () => { }, ); - it("clamps a u64-wide complete-result capacity before Number conversion", () => { + it("rejects a u64-wide output capacity that cannot be converted losslessly", () => { const harness = makeScratchHarness(8); prepareGenericSyscallHarness(harness, 8); const destination = 4096; const callerCapacity = BigInt(Number.MAX_SAFE_INTEGER) + 1n; - harness.handleChannel.mockImplementation((offset: number | bigint) => { - const channelView = new DataView( - harness.kernelBytes.buffer, - Number(offset), - ); - const stagedPointer = Number(channelView.getBigInt64(CH_ARGS, true)); - expect(stagedPointer).toBe(harness.scratchOffset + CH_DATA); - expect(channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true)).toBe( - BigInt(POSIX_PATH_MAX_BYTES), - ); - harness.kernelBytes.set(new TextEncoder().encode("/\0"), stagedPointer); - channelView.setBigInt64(CH_RETURN, 2n, true); - channelView.setUint32(CH_ERRNO, 0, true); - return 0; - }); + harness.processBytes.fill(0x7d, destination, destination + 16); writeChannelSyscall(harness, ABI_SYSCALLS.Getcwd, [ BigInt(destination), callerCapacity, @@ -1606,11 +1851,234 @@ describe("kernel scratch transfer capacity regressions", () => { dispatchScratchBoundarySyscall(harness); - expect(harness.handleChannel).toHaveBeenCalledOnce(); - expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([2, 0]); + expect(harness.handleChannel).not.toHaveBeenCalled(); + expect(harness.completeChannel).not.toHaveBeenCalled(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + EINVAL, + ); + expect(harness.processBytes.slice(destination, destination + 16)).toEqual( + new Uint8Array(16).fill(0x7d), + ); expectScratchTailUntouched(harness); }); + it.each([ + ["getcwd", ABI_SYSCALLS.Getcwd, 0], + ["realpath", ABI_SYSCALLS.Realpath, 1], + ] as const)( + "preserves a complete %s result beyond PATH_MAX when the caller owns a widened buffer", + (_name, syscallNr, outputArgIndex) => { + for (const pointerWidth of [4, 8] as const) { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + const pathPointer = 1024; + const destination = 8192; + const callerCapacity = CH_DATA_SIZE + 1; + const result = new Uint8Array(POSIX_PATH_MAX_BYTES + 1).fill(0x72); + if (syscallNr === ABI_SYSCALLS.Getcwd) { + result[0] = 0x2f; + result[result.length - 1] = 0; + } else { + harness.processBytes.set(new Uint8Array([0x2e, 0]), pathPointer); + result[0] = 0x2f; + } + harness.handleChannel.mockImplementation((offset: number | bigint) => { + expect(Number(offset)).toBe(harness.transferOffset); + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); + const stagedPointer = Number( + channelView.getBigInt64( + CH_ARGS + outputArgIndex * CH_ARG_SIZE, + true, + ), + ); + const capacityArgIndex = + syscallNr === ABI_SYSCALLS.Getcwd ? 1 : 2; + expect( + channelView.getBigInt64( + CH_ARGS + capacityArgIndex * CH_ARG_SIZE, + true, + ), + ).toBe(BigInt(callerCapacity)); + harness.kernelBytes.set(result, stagedPointer); + channelView.setBigInt64(CH_RETURN, BigInt(result.length), true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + writeChannelSyscall( + harness, + syscallNr, + syscallNr === ABI_SYSCALLS.Getcwd + ? [BigInt(destination), BigInt(callerCapacity)] + : [ + BigInt(pathPointer), + BigInt(destination), + BigInt(callerCapacity), + ], + ); + + dispatchScratchBoundarySyscall(harness); + + expect(harness.handleChannel).toHaveBeenCalledOnce(); + const writes = harness.completeChannel.mock.calls[0]?.[6] as Array<{ + ptr: number; + bytes: Uint8Array; + }>; + expect(writes).toEqual([{ ptr: destination, bytes: result }]); + expectScratchTailUntouched(harness); + } + }, + ); + + it.each([4, 8] as const)( + "copies a complete wasm%s legacy readdir name from a channel-capacity-plus-one caller", + (pointerWidth) => { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + const direntPointer = 4096; + const namePointer = 8192; + const name = new Uint8Array(POSIX_NAME_MAX_BYTES - 1).fill(0x6e); + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); + const stagedDirentPointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + const stagedNamePointer = Number( + channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true), + ); + expect(channelView.getBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, true)) + .toBe(BigInt(POSIX_NAME_MAX_BYTES)); + new DataView(harness.kernelBytes.buffer).setUint32( + stagedDirentPointer + WASM_DIRENT_NAME_LENGTH_OFFSET, + name.byteLength, + true, + ); + harness.kernelBytes.set(name, stagedNamePointer); + channelView.setBigInt64(CH_RETURN, 1n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + writeChannelSyscall(harness, ABI_SYSCALLS.Readdir, [ + 7n, + BigInt(direntPointer), + BigInt(namePointer), + BigInt(CH_DATA_SIZE + 1), + ]); + + dispatchScratchBoundarySyscall(harness); + + expect(harness.handleChannel).toHaveBeenCalledOnce(); + const writes = harness.completeChannel.mock.calls[0]?.[6] as Array<{ + ptr: number; + bytes: Uint8Array; + }>; + expect(writes).toHaveLength(2); + expect(writes[0]?.ptr).toBe(direntPointer); + expect(writes[0]?.bytes).toHaveLength(STRUCT_SIZE_WASM_DIRENT); + expect(writes[1]).toEqual({ ptr: namePointer, bytes: name }); + expectScratchTailUntouched(harness); + }, + ); + + it.each([4, 8] as const)( + "rejects a wasm%s readdir name length beyond its owned capacity atomically", + (pointerWidth) => { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + const direntPointer = 4096; + const namePointer = 8192; + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); + const stagedDirentPointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + new DataView(harness.kernelBytes.buffer).setUint32( + stagedDirentPointer + WASM_DIRENT_NAME_LENGTH_OFFSET, + POSIX_NAME_MAX_BYTES + 1, + true, + ); + channelView.setBigInt64(CH_RETURN, 1n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + writeChannelSyscall(harness, ABI_SYSCALLS.Readdir, [ + 7n, + BigInt(direntPointer), + BigInt(namePointer), + BigInt(CH_DATA_SIZE + 1), + ]); + + dispatchScratchBoundarySyscall(harness); + + expect(harness.handleChannel).toHaveBeenCalledOnce(); + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ + -1, + EIO, + ]); + expectScratchTailUntouched(harness); + }, + ); + + it.each([4, 8] as const)( + "rejects a wasm%s readdir name over-report against zero caller capacity atomically", + (pointerWidth) => { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + const direntPointer = 4096; + const beforeDirent = new Uint8Array(STRUCT_SIZE_WASM_DIRENT).fill(0x5a); + harness.processBytes.set(beforeDirent, direntPointer); + harness.handleChannel.mockImplementation((offset: number | bigint) => { + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); + const stagedDirentPointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + new DataView(harness.kernelBytes.buffer).setUint32( + stagedDirentPointer + WASM_DIRENT_NAME_LENGTH_OFFSET, + 1, + true, + ); + channelView.setBigInt64(CH_RETURN, 1n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + writeChannelSyscall(harness, ABI_SYSCALLS.Readdir, [ + 7n, + BigInt(direntPointer), + pointerWidth === 8 ? -1n : 0xffff_ffffn, + 0n, + ]); + + dispatchScratchBoundarySyscall(harness); + + expect(harness.handleChannel).toHaveBeenCalledOnce(); + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ + -1, + EIO, + ]); + expect(harness.completeChannel.mock.calls[0]?.[6] ?? []).toEqual([]); + expect( + harness.processBytes.slice( + direntPointer, + direntPointer + STRUCT_SIZE_WASM_DIRENT, + ), + ).toEqual(beforeDirent); + expectScratchTailUntouched(harness); + }, + ); + it.each([ ["wasm32", 4], ["wasm64", 8], @@ -4908,21 +5376,1142 @@ describe("kernel scratch transfer capacity regressions", () => { expectScratchTailUntouched(harness); }); - it.each([ - ["bind", ABI_SYSCALLS.Bind, 1, [7n, 0n, 0n]], - ["connect", ABI_SYSCALLS.Connect, 1, [7n, 0n, 0n]], - ["sendto", ABI_SYSCALLS.Sendto, 4, [7n, 0n, 0n, 0n, 0n, 0n]], - ] as const)( - "%s accepts a full sockaddr_storage and rejects one byte more", - (_syscallName, syscallNumber, addressArgIndex, syscallArgs) => { - for (const pointerWidth of [4, 8]) { - const addressPointer = 4096; - for (const addressLength of [ - KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, - KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES + 1, - ]) { - const harness = makeScratchHarness(pointerWidth); - prepareGenericSyscallHarness(harness, pointerWidth); + it.each([4, 8] as const)( + "rejects wasm%s generic byte-output over-reports without publishing partial bytes", + (pointerWidth) => { + const capacity = 31; + const destination = 8192; + const callerCanary = 0x6d; + for (const testCase of [ + { + name: "read", + syscall: ABI_SYSCALLS.Read, + args: [7n, BigInt(destination), BigInt(capacity)], + pointerArgIndex: 1, + }, + { + name: "getrandom", + syscall: ABI_SYSCALLS.Getrandom, + args: [BigInt(destination), BigInt(capacity), 0n], + pointerArgIndex: 0, + }, + { + name: "getdents64", + syscall: ABI_SYSCALLS.Getdents64, + args: [7n, BigInt(destination), BigInt(capacity)], + pointerArgIndex: 1, + }, + ] as const) { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + harness.processBytes.fill( + callerCanary, + destination, + destination + capacity + 1, + ); + harness.handleChannel.mockImplementation( + (offset: number | bigint) => { + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); + const stagedPointer = Number( + channelView.getBigInt64( + CH_ARGS + testCase.pointerArgIndex * CH_ARG_SIZE, + true, + ), + ); + harness.kernelBytes.fill( + 0x3c, + stagedPointer, + stagedPointer + capacity, + ); + channelView.setBigInt64(CH_RETURN, BigInt(capacity + 1), true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }, + ); + writeChannelSyscall( + harness, + testCase.syscall, + [...testCase.args], + ); + + dispatchScratchBoundarySyscall(harness); + + expect( + harness.completeChannel, + testCase.name, + ).toHaveBeenCalledOnce(); + const completion = harness.completeChannel.mock.calls[0]!; + expect(completion[4], testCase.name).toBe(-1); + expect(completion[5], testCase.name).toBe(EIO); + // The test adapter drops an empty detached-output slot on errors. A + // prefix-clamping implementation would leave a non-empty write here. + expect(completion[6], testCase.name).toBeUndefined(); + expect( + harness.processBytes.slice( + destination, + destination + capacity + 1, + ), + testCase.name, + ).toEqual(new Uint8Array(capacity + 1).fill(callerCanary)); + expectScratchTailUntouched(harness); + } + }, + ); + + it.each([4, 8] as const)( + "uses capacity-carrying scratch for wasm%s readlink outputs at the remaining mailbox capacity and capacity plus one", + (pointerWidth) => { + const pathPointer = 256; + const destination = 65_536; + const path = Uint8Array.of(0x78, 0); + // The two-byte path occupies one aligned eight-byte subregion before + // the output. These are therefore the exact largest ordinary output and + // its first widened successor, not merely the raw CH_DATA_SIZE values. + const exactOutputCapacity = CH_DATA_SIZE - 8; + const target = Uint8Array.from( + { length: exactOutputCapacity + 1 }, + (_, index) => (index * 37 + 11) % 251, + ); + + for (const testCase of [ + { + name: "readlink", + syscall: ABI_SYSCALLS.Readlink, + pathArgIndex: 0, + outputArgIndex: 1, + args: (capacity: number) => [ + BigInt(pathPointer), + BigInt(destination), + BigInt(capacity), + ], + }, + { + name: "readlinkat", + syscall: ABI_SYSCALLS.Readlinkat, + pathArgIndex: 1, + outputArgIndex: 2, + args: (capacity: number) => [ + 9n, + BigInt(pathPointer), + BigInt(destination), + BigInt(capacity), + ], + }, + ] as const) { + for ( + const capacity of [ + exactOutputCapacity, + exactOutputCapacity + 1, + ] + ) { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + harness.processBytes.set(path, pathPointer); + // The exact-capacity call returns the complete widened target. The + // one-byte-short call models readlink's caller-controlled prefix + // truncation at the last fixed-mailbox capacity. + const payload = target.slice(0, capacity); + const begin = vi.spyOn( + harness.kernelExports, + "kernel_transfer_scratch_begin", + ); + harness.handleChannel.mockImplementation( + (offset: number | bigint) => { + const channelBase = Number(offset); + const reserved = capacity > exactOutputCapacity; + expect(channelBase, testCase.name).toBe( + reserved ? harness.transferOffset : harness.scratchOffset, + ); + const channelView = new DataView( + harness.kernelBytes.buffer, + channelBase, + ); + expect(channelView.getUint32(CH_SYSCALL, true)).toBe( + testCase.syscall, + ); + const stagedPath = Number( + channelView.getBigInt64( + CH_ARGS + testCase.pathArgIndex * CH_ARG_SIZE, + true, + ), + ); + const stagedOutput = Number( + channelView.getBigInt64( + CH_ARGS + testCase.outputArgIndex * CH_ARG_SIZE, + true, + ), + ); + expect( + harness.kernelBytes.slice( + stagedPath, + stagedPath + path.byteLength, + ), + testCase.name, + ).toEqual(path); + expect(stagedOutput, testCase.name).toBe( + channelBase + CH_DATA + 8, + ); + harness.kernelBytes.set(payload, stagedOutput); + channelView.setBigInt64(CH_RETURN, BigInt(capacity), true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }, + ); + writeChannelSyscall( + harness, + testCase.syscall, + testCase.args(capacity), + ); + + dispatchScratchBoundarySyscall(harness); + + expect(begin, testCase.name).toHaveBeenCalledTimes( + capacity === exactOutputCapacity ? 0 : 1, + ); + expect( + harness.completeChannel.mock.calls[0]?.slice(4, 6), + testCase.name, + ).toEqual([capacity, 0]); + expect( + harness.completeChannel.mock.calls[0]?.[6], + testCase.name, + ).toEqual([{ ptr: destination, bytes: payload }]); + expectScratchTailUntouched(harness); + } + } + }, + ); + + it.each([4, 8] as const)( + "preserves the complete wasm%s maximum environment value and one-short ERANGE atomically", + (pointerWidth) => { + const namePointer = 256; + const destination = 65_536; + const name = Uint8Array.of(0x58, 0); + // An admitted `X=` entry may consume the complete metadata-entry + // ceiling. The returned value excludes `X=`. + const value = new Uint8Array( + PROCESS_METADATA_ENTRY_MAX_BYTES - 2, + ).fill(0x76); + const callerCanary = 0x6d; + + for (const capacity of [value.byteLength, value.byteLength - 1]) { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + harness.processBytes.set(name, namePointer); + harness.processBytes.fill( + callerCanary, + destination, + destination + value.byteLength + 1, + ); + const plannedCapacity = alignUp( + CH_DATA + 8 + capacity, + 8, + ); + const transferTail = harness.transferOffset + plannedCapacity; + harness.kernelBytes.fill(0xc7, transferTail, transferTail + 16); + const begin = vi.spyOn( + harness.kernelExports, + "kernel_transfer_scratch_begin", + ); + harness.completeChannel.mockImplementation( + ( + _channel: TestChannel, + _syscall: number, + _origArgs: number[], + _descs: unknown, + _retVal: number, + _errno: number, + writes: Array<{ ptr: number; bytes: Uint8Array }> = [], + ) => { + for (const write of writes) { + harness.processBytes.set(write.bytes, write.ptr); + } + }, + ); + harness.handleChannel.mockImplementation( + (offset: number | bigint) => { + const channelBase = Number(offset); + expect(channelBase).toBe(harness.transferOffset); + const channelView = new DataView( + harness.kernelBytes.buffer, + channelBase, + ); + const stagedName = Number( + channelView.getBigInt64(CH_ARGS, true), + ); + const stagedOutput = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + expect( + harness.kernelBytes.slice( + stagedName, + stagedName + name.byteLength, + ), + ).toEqual(name); + expect(stagedOutput).toBe(channelBase + CH_DATA + 8); + expect( + Number( + channelView.getBigInt64( + CH_ARGS + 2 * CH_ARG_SIZE, + true, + ), + ), + ).toBe(capacity); + if (capacity === value.byteLength) { + harness.kernelBytes.set(value, stagedOutput); + channelView.setBigInt64( + CH_RETURN, + BigInt(value.byteLength), + true, + ); + channelView.setUint32(CH_ERRNO, 0, true); + } else { + // Even hostile scratch bytes must not become an observable + // prefix when the complete value does not fit. + harness.kernelBytes.fill( + 0x3c, + stagedOutput, + stagedOutput + capacity, + ); + channelView.setBigInt64(CH_RETURN, -1n, true); + channelView.setUint32(CH_ERRNO, ERANGE, true); + } + return 0; + }, + ); + writeChannelSyscall(harness, ABI_SYSCALLS.GetEnv, [ + BigInt(namePointer), + BigInt(destination), + BigInt(capacity), + ]); + + dispatchScratchBoundarySyscall(harness); + + expect(begin).toHaveBeenCalledOnce(); + expect(Number(begin.mock.calls[0]?.[0])).toBe(plannedCapacity); + expect( + harness.completeChannel.mock.calls[0]?.slice(4, 6), + ).toEqual( + capacity === value.byteLength + ? [value.byteLength, 0] + : [-1, ERANGE], + ); + if (capacity === value.byteLength) { + expect( + harness.processBytes.slice( + destination, + destination + value.byteLength, + ), + ).toEqual(value); + expect( + harness.processBytes[destination + value.byteLength], + ).toBe(callerCanary); + } else { + expect( + harness.processBytes.slice( + destination, + destination + value.byteLength + 1, + ), + ).toEqual( + new Uint8Array(value.byteLength + 1).fill(callerCanary), + ); + } + expect( + harness.kernelBytes.slice(transferTail, transferTail + 16), + ).toEqual(new Uint8Array(16).fill(0xc7)); + expectScratchTailUntouched(harness); + } + }, + ); + + it.each([4, 8] as const)( + "caps only short-safe wasm%s outputs at channel capacity and capacity plus one", + (pointerWidth) => { + const destination = 65_536; + const randomBytes = Uint8Array.from( + { length: CH_DATA_SIZE }, + (_, index) => (index * 43 + 17) % 251, + ); + const direntRecordBytes = 24; + const direntBytes = new Uint8Array( + Math.floor(CH_DATA_SIZE / direntRecordBytes) + * direntRecordBytes, + ); + const direntView = new DataView(direntBytes.buffer); + for ( + let offset = 0, record = 0; + offset < direntBytes.byteLength; + offset += direntRecordBytes, record++ + ) { + direntView.setBigUint64(offset, BigInt(record + 1), true); + direntView.setBigInt64(offset + 8, BigInt(record + 1), true); + direntView.setUint16(offset + 16, direntRecordBytes, true); + direntView.setUint8(offset + 18, 8); + direntView.setUint8(offset + 19, 0x61 + (record % 26)); + direntView.setUint8(offset + 20, 0); + } + + for (const testCase of [ + { + name: "getrandom", + syscall: ABI_SYSCALLS.Getrandom, + pointerArgIndex: 0, + countArgIndex: 1, + payload: randomBytes, + args: (capacity: number) => [ + BigInt(destination), + BigInt(capacity), + 0n, + ], + }, + { + name: "getdents64", + syscall: ABI_SYSCALLS.Getdents64, + pointerArgIndex: 1, + countArgIndex: 2, + payload: direntBytes, + args: (capacity: number) => [ + 7n, + BigInt(destination), + BigInt(capacity), + ], + }, + ] as const) { + for ( + const callerCapacity of [ + CH_DATA_SIZE, + CH_DATA_SIZE + 1, + ] + ) { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + const callerCanary = 0x6d; + harness.processBytes.fill( + callerCanary, + destination, + destination + callerCapacity + 1, + ); + const begin = vi.spyOn( + harness.kernelExports, + "kernel_transfer_scratch_begin", + ); + harness.completeChannel.mockImplementation( + ( + _channel: TestChannel, + _syscall: number, + _origArgs: number[], + _descs: unknown, + _retVal: number, + _errno: number, + writes: Array<{ ptr: number; bytes: Uint8Array }> = [], + ) => { + for (const write of writes) { + harness.processBytes.set(write.bytes, write.ptr); + } + }, + ); + harness.handleChannel.mockImplementation( + (offset: number | bigint) => { + const channelBase = Number(offset); + expect(channelBase, testCase.name).toBe( + harness.scratchOffset, + ); + const channelView = new DataView( + harness.kernelBytes.buffer, + channelBase, + ); + expect( + Number( + channelView.getBigInt64( + CH_ARGS + testCase.countArgIndex * CH_ARG_SIZE, + true, + ), + ), + testCase.name, + ).toBe(CH_DATA_SIZE); + const stagedOutput = Number( + channelView.getBigInt64( + CH_ARGS + testCase.pointerArgIndex * CH_ARG_SIZE, + true, + ), + ); + expect(stagedOutput, testCase.name).toBe( + channelBase + CH_DATA, + ); + harness.kernelBytes.set(testCase.payload, stagedOutput); + channelView.setBigInt64( + CH_RETURN, + BigInt(testCase.payload.byteLength), + true, + ); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }, + ); + writeChannelSyscall( + harness, + testCase.syscall, + testCase.args(callerCapacity), + ); + + dispatchScratchBoundarySyscall(harness); + + expect(begin, testCase.name).not.toHaveBeenCalled(); + expect( + harness.completeChannel.mock.calls[0]?.slice(4, 6), + testCase.name, + ).toEqual([testCase.payload.byteLength, 0]); + expect( + harness.processBytes.slice( + destination, + destination + testCase.payload.byteLength, + ), + testCase.name, + ).toEqual(testCase.payload); + expect( + harness.processBytes.slice( + destination + testCase.payload.byteLength, + destination + callerCapacity + 1, + ), + testCase.name, + ).toEqual( + new Uint8Array( + callerCapacity + 1 - testCase.payload.byteLength, + ).fill(callerCanary), + ); + expectScratchTailUntouched(harness); + } + } + }, + ); + + it.each([4, 8] as const)( + "preserves wasm%s MSG_TRUNC scalar receive semantics within owned capacity", + (pointerWidth) => { + const capacity = 4; + const reportedLength = 13; + const destination = 8192; + const expected = new TextEncoder().encode("recv"); + for (const testCase of [ + { + name: "recv", + syscall: ABI_SYSCALLS.Recv, + args: [ + 7n, + BigInt(destination), + BigInt(capacity), + BigInt(SOCKET_MSG_TRUNC), + ], + }, + { + name: "recvfrom", + syscall: ABI_SYSCALLS.Recvfrom, + args: [ + 7n, + BigInt(destination), + BigInt(capacity), + BigInt(SOCKET_MSG_TRUNC), + 0n, + 0n, + ], + }, + ] as const) { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + harness.handleChannel.mockImplementation( + (offset: number | bigint) => { + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); + const stagedPointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + harness.kernelBytes.set(expected, stagedPointer); + channelView.setBigInt64( + CH_RETURN, + BigInt(reportedLength), + true, + ); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }, + ); + writeChannelSyscall( + harness, + testCase.syscall, + [...testCase.args], + ); + + dispatchScratchBoundarySyscall(harness); + + expect( + harness.completeChannel, + testCase.name, + ).toHaveBeenCalledOnce(); + const completion = harness.completeChannel.mock.calls[0]!; + expect(completion[4], testCase.name).toBe(reportedLength); + expect(completion[5], testCase.name).toBe(0); + expect(completion[6], testCase.name).toEqual([ + { + ptr: destination, + bytes: expected, + }, + ]); + expectScratchTailUntouched(harness); + } + }, + ); + + it.each([4, 8] as const)( + "uses capacity-carrying scratch for wasm%s scalar socket data at channel capacity and capacity plus one", + (pointerWidth) => { + for (const testCase of [ + { + name: "send", + syscall: ABI_SYSCALLS.Send, + input: true, + args: [7n, 0n, 0n, 0n], + }, + { + name: "recv", + syscall: ABI_SYSCALLS.Recv, + input: false, + args: [7n, 0n, 0n, 0n], + }, + { + name: "sendto", + syscall: ABI_SYSCALLS.Sendto, + input: true, + args: [7n, 0n, 0n, 0n, 0n, 0n], + }, + { + name: "recvfrom", + syscall: ABI_SYSCALLS.Recvfrom, + input: false, + args: [7n, 0n, 0n, 0n, 0n, 0n], + }, + ] as const) { + for (const length of [CH_DATA_SIZE, CH_DATA_SIZE + 1]) { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + const processPointer = harness.processBytes.byteLength - length; + const payload = Uint8Array.from( + { length }, + (_, index) => (index * 31 + 7) % 251, + ); + if (testCase.input) { + harness.processBytes.set(payload, processPointer); + } else { + harness.processBytes.fill( + 0x6d, + processPointer, + processPointer + length, + ); + } + const begin = vi.spyOn( + harness.kernelExports, + "kernel_transfer_scratch_begin", + ); + harness.handleChannel.mockImplementation( + (offset: number | bigint) => { + expect(Number(offset), testCase.name).toBe( + length === CH_DATA_SIZE + ? harness.scratchOffset + : harness.transferOffset, + ); + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); + const stagedPointer = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + if (testCase.input) { + expect( + harness.kernelBytes.slice( + stagedPointer, + stagedPointer + length, + ), + testCase.name, + ).toEqual(payload); + } else { + harness.kernelBytes.set(payload, stagedPointer); + } + channelView.setBigInt64(CH_RETURN, BigInt(length), true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }, + ); + const args = [...testCase.args]; + args[1] = BigInt(processPointer); + args[2] = BigInt(length); + writeChannelSyscall(harness, testCase.syscall, args); + + dispatchScratchBoundarySyscall(harness); + + expect( + harness.handleChannel, + testCase.name, + ).toHaveBeenCalledOnce(); + expect(begin, testCase.name).toHaveBeenCalledTimes( + length === CH_DATA_SIZE ? 0 : 1, + ); + expect( + harness.completeChannel.mock.calls[0]?.slice(4, 6), + testCase.name, + ).toEqual([length, 0]); + if (!testCase.input) { + const writes = harness.completeChannel.mock.calls[0]?.[6] as + Array<{ ptr: number; bytes: Uint8Array }>; + expect(writes, testCase.name).toEqual([ + { ptr: processPointer, bytes: payload }, + ]); + } + expectScratchTailUntouched(harness); + } + } + }, + ); + + it.each([4, 8] as const)( + "maps null zero-length wasm%s scalar socket data to owned empty scratch", + (pointerWidth) => { + for (const testCase of [ + ["send", ABI_SYSCALLS.Send], + ["recv", ABI_SYSCALLS.Recv], + ["sendto", ABI_SYSCALLS.Sendto], + ["recvfrom", ABI_SYSCALLS.Recvfrom], + ] as const) { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + harness.handleChannel.mockImplementation( + (offset: number | bigint) => { + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); + expect( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + testCase[0], + ).toBe(BigInt(harness.scratchOffset + CH_DATA)); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }, + ); + writeChannelSyscall(harness, testCase[1], [ + 7n, + 0n, + 0n, + 0n, + 0n, + 0n, + ]); + + dispatchScratchBoundarySyscall(harness); + + expect(harness.handleChannel, testCase[0]).toHaveBeenCalledOnce(); + expect( + harness.completeChannel.mock.calls[0]?.slice(4, 6), + testCase[0], + ).toEqual([0, 0]); + expectScratchTailUntouched(harness); + } + }, + ); + + it.each([4, 8] as const)( + "validates wasm%s zero-capacity scalar byte-count results", + (pointerWidth) => { + for (const testCase of [ + ["write", ABI_SYSCALLS.Write, [7n, 0n, 0n]], + ["pwrite", ABI_SYSCALLS.Pwrite, [7n, 0n, 0n, 0n]], + ["send", ABI_SYSCALLS.Send, [7n, 0n, 0n, 0n]], + ["recv", ABI_SYSCALLS.Recv, [7n, 0n, 0n, 0n]], + ["sendto", ABI_SYSCALLS.Sendto, [7n, 0n, 0n, 0n, 0n, 0n]], + ["recvfrom", ABI_SYSCALLS.Recvfrom, [7n, 0n, 0n, 0n, 0n, 0n]], + ] as const) { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + harness.handleChannel.mockImplementation( + (offset: number | bigint) => { + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); + channelView.setBigInt64(CH_RETURN, 1n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }, + ); + writeChannelSyscall(harness, testCase[1], [...testCase[2]]); + + dispatchScratchBoundarySyscall(harness); + + expect(harness.handleChannel, testCase[0]).toHaveBeenCalledOnce(); + const completion = harness.completeChannel.mock.calls[0]!; + expect(completion[4], testCase[0]).toBe(-1); + expect(completion[5], testCase[0]).toBe(EIO); + expect(completion[6], testCase[0]).toBeUndefined(); + expectScratchTailUntouched(harness); + } + }, + ); + + it.each([4, 8] as const)( + "permits wasm%s zero-capacity MSG_TRUNC receive lengths without copying", + (pointerWidth) => { + const completeDatagramLength = 73; + for (const testCase of [ + ["recv", ABI_SYSCALLS.Recv, [7n, 0n, 0n, BigInt(SOCKET_MSG_TRUNC)]], + [ + "recvfrom", + ABI_SYSCALLS.Recvfrom, + [7n, 0n, 0n, BigInt(SOCKET_MSG_TRUNC), 0n, 0n], + ], + ] as const) { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + harness.handleChannel.mockImplementation( + (offset: number | bigint) => { + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); + channelView.setBigInt64( + CH_RETURN, + BigInt(completeDatagramLength), + true, + ); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }, + ); + writeChannelSyscall(harness, testCase[1], [...testCase[2]]); + + dispatchScratchBoundarySyscall(harness); + + expect(harness.handleChannel, testCase[0]).toHaveBeenCalledOnce(); + const completion = harness.completeChannel.mock.calls[0]!; + expect(completion[4], testCase[0]).toBe(completeDatagramLength); + expect(completion[5], testCase[0]).toBe(0); + expect(completion[6], testCase[0]).toEqual([]); + expectScratchTailUntouched(harness); + } + }, + ); + + it.each([4, 8] as const)( + "rejects invalid positive-length wasm%s scalar socket data ranges before dispatch", + (pointerWidth) => { + const length = 16; + for (const testCase of [ + ["send", ABI_SYSCALLS.Send], + ["recv", ABI_SYSCALLS.Recv], + ["sendto", ABI_SYSCALLS.Sendto], + ["recvfrom", ABI_SYSCALLS.Recvfrom], + ] as const) { + for (const pointerKind of [ + "null", + "one-byte-short", + "negative", + "unsafe-high", + ] as const) { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + const rawPointer = pointerKind === "null" + ? 0n + : pointerKind === "one-byte-short" + ? BigInt(harness.processBytes.byteLength - length + 1) + : pointerKind === "negative" + ? -1n + : 1n << 60n; + writeChannelSyscall(harness, testCase[1], [ + 7n, + rawPointer, + BigInt(length), + 0n, + 0n, + 0n, + ]); + + dispatchScratchBoundarySyscall(harness); + + expect(harness.handleChannel, testCase[0]).not.toHaveBeenCalled(); + const completedErrnos = [ + ...harness.completeChannel.mock.calls.map((call) => call[5]), + ...harness.completeChannelRaw.mock.calls.map((call) => call[2]), + ]; + expect(completedErrnos, testCase[0]).toEqual([EFAULT]); + expectScratchTailUntouched(harness); + } + } + }, + ); + + it.each([4, 8] as const)( + "rejects wasm%s generic byte-input over-reports as producer failures", + (pointerWidth) => { + const capacity = 31; + const source = 8192; + for (const testCase of [ + ["write", ABI_SYSCALLS.Write, [7n, 0n, 0n]], + ["pwrite", ABI_SYSCALLS.Pwrite, [7n, 0n, 0n, 0n]], + ["send", ABI_SYSCALLS.Send, [7n, 0n, 0n, 0n]], + ["sendto", ABI_SYSCALLS.Sendto, [7n, 0n, 0n, 0n, 0n, 0n]], + ] as const) { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + harness.processBytes.fill(0x4d, source, source + capacity); + harness.handleChannel.mockImplementation( + (offset: number | bigint) => { + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); + channelView.setBigInt64(CH_RETURN, BigInt(capacity + 1), true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }, + ); + const args = [...testCase[2]]; + args[1] = BigInt(source); + args[2] = BigInt(capacity); + writeChannelSyscall(harness, testCase[1], args); + + dispatchScratchBoundarySyscall(harness); + + expect(harness.completeChannel, testCase[0]).toHaveBeenCalledOnce(); + const completion = harness.completeChannel.mock.calls[0]!; + expect(completion[4], testCase[0]).toBe(-1); + expect(completion[5], testCase[0]).toBe(EIO); + expect(completion[6], testCase[0]).toBeUndefined(); + expectScratchTailUntouched(harness); + } + }, + ); + + it.each([4, 8] as const)( + "rejects wasm%s scalar socket receive over-reports without partial data or address publication", + (pointerWidth) => { + const capacity = 31; + const destination = 8192; + const addressCapacity = 16; + const address = 12_288; + const lengthPointer = 16_384; + for (const testCase of [ + ["recv", ABI_SYSCALLS.Recv, false], + ["recvfrom", ABI_SYSCALLS.Recvfrom, true], + ] as const) { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + harness.processBytes.fill( + 0x6d, + destination, + destination + capacity, + ); + harness.processBytes.fill( + 0x7e, + address, + address + addressCapacity, + ); + new DataView(harness.processBytes.buffer).setUint32( + lengthPointer, + addressCapacity, + true, + ); + harness.completeChannel.mockImplementation( + ( + _channel: TestChannel, + _syscall: number, + _origArgs: number[], + _descs: unknown, + _retVal: number, + _errno: number, + writes: Array<{ ptr: number; bytes: Uint8Array }> = [], + ) => { + for (const write of writes) { + harness.processBytes.set(write.bytes, write.ptr); + } + }, + ); + harness.handleChannel.mockImplementation( + (offset: number | bigint) => { + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); + const stagedData = Number( + channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), + ); + harness.kernelBytes.fill( + 0x3c, + stagedData, + stagedData + capacity, + ); + if (testCase[2]) { + const stagedAddress = Number( + channelView.getBigInt64( + CH_ARGS + 4 * CH_ARG_SIZE, + true, + ), + ); + const stagedLength = Number( + channelView.getBigInt64( + CH_ARGS + 5 * CH_ARG_SIZE, + true, + ), + ); + harness.kernelBytes.fill( + 0x2a, + stagedAddress, + stagedAddress + addressCapacity, + ); + new DataView(harness.kernelBytes.buffer).setUint32( + stagedLength, + addressCapacity, + true, + ); + } + channelView.setBigInt64(CH_RETURN, BigInt(capacity + 1), true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }, + ); + writeChannelSyscall( + harness, + testCase[1], + testCase[2] + ? [ + 7n, + BigInt(destination), + BigInt(capacity), + 0n, + BigInt(address), + BigInt(lengthPointer), + ] + : [7n, BigInt(destination), BigInt(capacity), 0n], + ); + + dispatchScratchBoundarySyscall(harness); + + const completion = harness.completeChannel.mock.calls[0]!; + expect(completion[4], testCase[0]).toBe(-1); + expect(completion[5], testCase[0]).toBe(EIO); + expect(completion[6], testCase[0]).toBeUndefined(); + expect( + harness.processBytes.slice( + destination, + destination + capacity, + ), + testCase[0], + ).toEqual(new Uint8Array(capacity).fill(0x6d)); + expect( + harness.processBytes.slice( + address, + address + addressCapacity, + ), + testCase[0], + ).toEqual(new Uint8Array(addressCapacity).fill(0x7e)); + expect( + new DataView(harness.processBytes.buffer).getUint32( + lengthPointer, + true, + ), + testCase[0], + ).toBe(addressCapacity); + expectScratchTailUntouched(harness); + } + }, + ); + + it.each([4, 8] as const)( + "carries wasm%s setsockopt layout width independently of bounded optlen", + (pointerWidth) => { + const optionPointer = 8192; + const maximum = 264; + for (const optionLength of [maximum, maximum + 1]) { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + const option = Uint8Array.from( + { length: optionLength }, + (_, index) => (index * 17 + 5) & 0xff, + ); + harness.processBytes.set(option, optionPointer); + harness.handleChannel.mockImplementation( + (offset: number | bigint) => { + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); + const stagedPointer = Number( + channelView.getBigInt64( + CH_ARGS + 3 * CH_ARG_SIZE, + true, + ), + ); + expect( + channelView.getBigInt64( + CH_ARGS + 5 * CH_ARG_SIZE, + true, + ), + ).toBe(BigInt(pointerWidth)); + expect( + harness.kernelBytes.slice( + stagedPointer, + stagedPointer + optionLength, + ), + ).toEqual(option); + channelView.setBigInt64(CH_RETURN, 0n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }, + ); + writeChannelSyscall(harness, ABI_SYSCALLS.Setsockopt, [ + 7n, + 0n, + 0n, + BigInt(optionPointer), + BigInt(optionLength), + 99n, + ]); + + dispatchScratchBoundarySyscall(harness); + + const accepted = optionLength === maximum; + expect(harness.handleChannel).toHaveBeenCalledTimes(accepted ? 1 : 0); + if (!accepted) { + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + EINVAL, + ); + } + expectScratchTailUntouched(harness); + } + }, + ); + + it.each([ + ["bind", ABI_SYSCALLS.Bind, 1, [7n, 0n, 0n]], + ["connect", ABI_SYSCALLS.Connect, 1, [7n, 0n, 0n]], + ["sendto", ABI_SYSCALLS.Sendto, 4, [7n, 0n, 0n, 0n, 0n, 0n]], + ] as const)( + "%s accepts a full sockaddr_storage and rejects one byte more", + (_syscallName, syscallNumber, addressArgIndex, syscallArgs) => { + for (const pointerWidth of [4, 8]) { + const addressPointer = 4096; + for (const addressLength of [ + KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, + KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES + 1, + ]) { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); harness.processBytes.fill( 0, addressPointer, @@ -4980,6 +6569,14 @@ describe("kernel scratch transfer capacity regressions", () => { it.each([ ["accept", ABI_SYSCALLS.Accept, 1, [7n, 0n, 0n]], ["accept4", ABI_SYSCALLS.Accept4, 1, [7n, 0n, 0n, 0n]], + ["getsockname", ABI_SYSCALLS.Getsockname, 1, [7n, 0n, 0n]], + ["getpeername", ABI_SYSCALLS.Getpeername, 1, [7n, 0n, 0n]], + [ + "getsockopt", + ABI_SYSCALLS.Getsockopt, + 3, + [7n, 1n, 2n, 0n, 0n], + ], [ "recvfrom", ABI_SYSCALLS.Recvfrom, @@ -5016,6 +6613,210 @@ describe("kernel scratch transfer capacity regressions", () => { }, ); + it.each([4, 8] as const)( + "rejects invalid active wasm%s scalar socket value-result ranges before dispatch", + (pointerWidth) => { + for (const testCase of [ + { + name: "accept", + syscall: ABI_SYSCALLS.Accept, + addressArgIndex: 1, + lengthArgIndex: 2, + maximum: KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, + nullable: true, + args: [7n, 0n, 0n], + }, + { + name: "accept4", + syscall: ABI_SYSCALLS.Accept4, + addressArgIndex: 1, + lengthArgIndex: 2, + maximum: KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, + nullable: true, + args: [7n, 0n, 0n, 0n], + }, + { + name: "getsockname", + syscall: ABI_SYSCALLS.Getsockname, + addressArgIndex: 1, + lengthArgIndex: 2, + maximum: KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, + nullable: false, + args: [7n, 0n, 0n], + }, + { + name: "getpeername", + syscall: ABI_SYSCALLS.Getpeername, + addressArgIndex: 1, + lengthArgIndex: 2, + maximum: KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, + nullable: false, + args: [7n, 0n, 0n], + }, + { + name: "getsockopt", + syscall: ABI_SYSCALLS.Getsockopt, + addressArgIndex: 3, + lengthArgIndex: 4, + maximum: KERNEL_SCRATCH_SOCKET_OPTION_MAX_BYTES, + nullable: false, + args: [7n, 1n, 2n, 0n, 0n], + }, + { + name: "recvfrom", + syscall: ABI_SYSCALLS.Recvfrom, + addressArgIndex: 4, + lengthArgIndex: 5, + maximum: KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, + nullable: true, + args: [7n, 0n, 0n, 0n, 0n, 0n], + }, + ] as const) { + for (const pointerKind of [ + "null", + "one-byte-short", + "negative", + "unsafe-high", + ] as const) { + if (testCase.nullable && pointerKind === "null") continue; + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + const lengthPointer = 4096; + new DataView(harness.processBytes.buffer).setUint32( + lengthPointer, + testCase.maximum, + true, + ); + harness.processBytes.fill( + 0x6d, + harness.processBytes.byteLength - testCase.maximum, + harness.processBytes.byteLength, + ); + const invalidAddress = pointerKind === "null" + ? 0n + : pointerKind === "one-byte-short" + ? BigInt( + harness.processBytes.byteLength - testCase.maximum + 1, + ) + : pointerKind === "negative" + ? -1n + : 1n << 60n; + const args = [...testCase.args]; + args[testCase.addressArgIndex] = invalidAddress; + args[testCase.lengthArgIndex] = BigInt(lengthPointer); + writeChannelSyscall(harness, testCase.syscall, args); + + dispatchScratchBoundarySyscall(harness); + + expect( + harness.handleChannel, + `${testCase.name}/${pointerKind}`, + ).not.toHaveBeenCalled(); + const completedErrnos = [ + ...harness.completeChannel.mock.calls.map((call) => call[5]), + ...harness.completeChannelRaw.mock.calls.map((call) => call[2]), + ]; + expect( + completedErrnos, + `${testCase.name}/${pointerKind}`, + ).toEqual([EFAULT]); + expect( + new DataView(harness.processBytes.buffer).getUint32( + lengthPointer, + true, + ), + ).toBe(testCase.maximum); + expectScratchTailUntouched(harness); + } + } + }, + ); + + it.each([4, 8] as const)( + "rejects invalid active wasm%s scalar socket length-result ranges before dispatch", + (pointerWidth) => { + for (const testCase of [ + [ + "accept", + ABI_SYSCALLS.Accept, + 1, + 2, + [7n, 0n, 0n], + ], + [ + "accept4", + ABI_SYSCALLS.Accept4, + 1, + 2, + [7n, 0n, 0n, 0n], + ], + [ + "getsockname", + ABI_SYSCALLS.Getsockname, + 1, + 2, + [7n, 0n, 0n], + ], + [ + "getpeername", + ABI_SYSCALLS.Getpeername, + 1, + 2, + [7n, 0n, 0n], + ], + [ + "getsockopt", + ABI_SYSCALLS.Getsockopt, + 3, + 4, + [7n, 1n, 2n, 0n, 0n], + ], + [ + "recvfrom", + ABI_SYSCALLS.Recvfrom, + 4, + 5, + [7n, 0n, 0n, 0n, 0n, 0n], + ], + ] as const) { + for (const pointerKind of [ + "one-byte-short", + "negative", + "unsafe-high", + ] as const) { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + const addressPointer = 8192; + const invalidLengthPointer = pointerKind === "one-byte-short" + ? BigInt(harness.processBytes.byteLength - 3) + : pointerKind === "negative" + ? -1n + : 1n << 60n; + const args = [...testCase[4]]; + args[testCase[2]] = BigInt(addressPointer); + args[testCase[3]] = invalidLengthPointer; + writeChannelSyscall(harness, testCase[1], args); + + dispatchScratchBoundarySyscall(harness); + + expect( + harness.handleChannel, + `${testCase[0]}/${pointerKind}`, + ).not.toHaveBeenCalled(); + const completedErrnos = [ + ...harness.completeChannel.mock.calls.map((call) => call[5]), + ...harness.completeChannelRaw.mock.calls.map((call) => call[2]), + ]; + expect( + completedErrnos, + `${testCase[0]}/${pointerKind}`, + ).toEqual([EFAULT]); + expectScratchTailUntouched(harness); + } + } + }, + ); + it.each([ ["accept", ABI_SYSCALLS.Accept, 1, 2, [7n, 0n, 0n]], ["accept4", ABI_SYSCALLS.Accept4, 1, 2, [7n, 0n, 0n, 0n]], @@ -5108,116 +6909,203 @@ describe("kernel scratch transfer capacity regressions", () => { ); it.each([4, 8] as const)( - "accepts a wasm%s generic socket result at sockaddr_storage and rejects capacity plus one", + "bounds every wasm%s scalar socket value-result output at zero, exact capacity, and capacity plus one", (pointerWidth) => { - for (const reportedLength of [ - KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, - KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES + 1, - ]) { - const harness = makeScratchHarness(pointerWidth); - prepareGenericSyscallHarness(harness, pointerWidth); - const addressPointer = - harness.processBytes.byteLength - - KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES; - const lengthPointer = 4096; - const callerCanary = 0x6d; - const expected = Uint8Array.from( - { length: KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES }, - (_, index) => (index * 29 + 3) % 251, - ); - harness.processBytes.fill( - callerCanary, - addressPointer, - harness.processBytes.byteLength, - ); - new DataView(harness.processBytes.buffer).setUint32( - lengthPointer, - 0xffff_ffff, - true, - ); - harness.completeChannel.mockImplementation( - ( - _channel: TestChannel, - _syscallNr: number, - _origArgs: number[], - _argDescs: unknown, - _retVal: number, - _errVal: number, - writes: Array<{ ptr: number; bytes: Uint8Array }> = [], - ) => { - for (const write of writes) { - harness.processBytes.set(write.bytes, write.ptr); - } - }, - ); - harness.handleChannel.mockImplementation((offset: number | bigint) => { - const channelView = new DataView( - harness.kernelBytes.buffer, - Number(offset), - ); - const stagedAddressPointer = Number( - channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), - ); - const stagedLengthPointer = Number( - channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true), - ); - const kernelView = new DataView(harness.kernelBytes.buffer); - expect(kernelView.getUint32(stagedLengthPointer, true)).toBe( - KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, - ); - harness.kernelBytes.set(expected, stagedAddressPointer); - kernelView.setUint32(stagedLengthPointer, reportedLength, true); - channelView.setBigInt64(CH_RETURN, 0n, true); - channelView.setUint32(CH_ERRNO, 0, true); - return 0; - }); - writeChannelSyscall(harness, ABI_SYSCALLS.Getsockname, [ - 7n, - BigInt(addressPointer), - BigInt(lengthPointer), - ]); - - dispatchScratchBoundarySyscall(harness); - - if (reportedLength === KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES) { - expect(harness.completeChannel).toHaveBeenCalledOnce(); - expect( - harness.processBytes.slice( - addressPointer, - harness.processBytes.byteLength, - ), - ).toEqual(expected); - expect( - new DataView(harness.processBytes.buffer).getUint32( - lengthPointer, - true, - ), - ).toBe(reportedLength); - } else { - expect(harness.completeChannel).toHaveBeenCalledOnce(); - const completion = harness.completeChannel.mock.calls[0]!; - expect(completion[4]).toBe(-1); - expect(completion[5]).toBe(EIO); - expect(completion[6]).toBeUndefined(); - expect(harness.completeChannelRaw).not.toHaveBeenCalled(); - expect( - harness.processBytes.slice( + for (const testCase of [ + { + name: "accept", + syscall: ABI_SYSCALLS.Accept, + addressArgIndex: 1, + lengthArgIndex: 2, + maximum: KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, + args: [7n, 0n, 0n], + returnValue: 17, + }, + { + name: "accept4", + syscall: ABI_SYSCALLS.Accept4, + addressArgIndex: 1, + lengthArgIndex: 2, + maximum: KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, + args: [7n, 0n, 0n, 0n], + returnValue: 17, + }, + { + name: "getsockname", + syscall: ABI_SYSCALLS.Getsockname, + addressArgIndex: 1, + lengthArgIndex: 2, + maximum: KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, + args: [7n, 0n, 0n], + returnValue: 0, + }, + { + name: "getpeername", + syscall: ABI_SYSCALLS.Getpeername, + addressArgIndex: 1, + lengthArgIndex: 2, + maximum: KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, + args: [7n, 0n, 0n], + returnValue: 0, + }, + { + name: "getsockopt", + syscall: ABI_SYSCALLS.Getsockopt, + addressArgIndex: 3, + lengthArgIndex: 4, + maximum: KERNEL_SCRATCH_SOCKET_OPTION_MAX_BYTES, + args: [7n, 1n, 2n, 0n, 0n], + returnValue: 0, + }, + { + name: "recvfrom", + syscall: ABI_SYSCALLS.Recvfrom, + addressArgIndex: 4, + lengthArgIndex: 5, + maximum: KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES, + args: [7n, 0n, 0n, 0n, 0n, 0n], + returnValue: 0, + }, + ] as const) { + for (const callerCapacity of [ + 0, + testCase.maximum, + testCase.maximum + 1, + ]) { + for (const reportedLength of [ + testCase.maximum, + testCase.maximum + 1, + ]) { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + const stagedCapacity = Math.min( + callerCapacity, + testCase.maximum, + ); + const addressPointer = + harness.processBytes.byteLength - stagedCapacity; + const lengthPointer = 4096; + const callerCanary = 0x6d; + const expected = Uint8Array.from( + { length: stagedCapacity }, + (_, index) => (index * 29 + 3) % 251, + ); + harness.processBytes.fill( + callerCanary, addressPointer, harness.processBytes.byteLength, - ), - ).toEqual( - new Uint8Array(KERNEL_SCRATCH_SOCKADDR_STORAGE_BYTES).fill( - callerCanary, - ), - ); - expect( - new DataView(harness.processBytes.buffer).getUint32( + ); + new DataView(harness.processBytes.buffer).setUint32( lengthPointer, + callerCapacity, true, - ), - ).toBe(0xffff_ffff); + ); + harness.completeChannel.mockImplementation( + ( + _channel: TestChannel, + _syscallNr: number, + _origArgs: number[], + _argDescs: unknown, + _retVal: number, + _errVal: number, + writes: Array<{ ptr: number; bytes: Uint8Array }> = [], + ) => { + for (const write of writes) { + harness.processBytes.set(write.bytes, write.ptr); + } + }, + ); + harness.handleChannel.mockImplementation( + (offset: number | bigint) => { + const channelView = new DataView( + harness.kernelBytes.buffer, + Number(offset), + ); + const stagedAddressPointer = Number( + channelView.getBigInt64( + CH_ARGS + testCase.addressArgIndex * CH_ARG_SIZE, + true, + ), + ); + const stagedLengthPointer = Number( + channelView.getBigInt64( + CH_ARGS + testCase.lengthArgIndex * CH_ARG_SIZE, + true, + ), + ); + const kernelView = new DataView(harness.kernelBytes.buffer); + expect( + kernelView.getUint32(stagedLengthPointer, true), + testCase.name, + ).toBe(stagedCapacity); + if (stagedCapacity > 0) { + harness.kernelBytes.set(expected, stagedAddressPointer); + } + kernelView.setUint32( + stagedLengthPointer, + reportedLength, + true, + ); + channelView.setBigInt64( + CH_RETURN, + BigInt(testCase.returnValue), + true, + ); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }, + ); + const args = [...testCase.args]; + args[testCase.addressArgIndex] = BigInt(addressPointer); + args[testCase.lengthArgIndex] = BigInt(lengthPointer); + writeChannelSyscall(harness, testCase.syscall, args); + + dispatchScratchBoundarySyscall(harness); + + expect( + harness.completeChannel, + testCase.name, + ).toHaveBeenCalledOnce(); + if (reportedLength === testCase.maximum) { + expect( + harness.processBytes.slice( + addressPointer, + harness.processBytes.byteLength, + ), + testCase.name, + ).toEqual(expected); + expect( + new DataView(harness.processBytes.buffer).getUint32( + lengthPointer, + true, + ), + testCase.name, + ).toBe(reportedLength); + } else { + const completion = harness.completeChannel.mock.calls[0]!; + expect(completion[4], testCase.name).toBe(-1); + expect(completion[5], testCase.name).toBe(EIO); + expect(completion[6], testCase.name).toBeUndefined(); + expect( + harness.processBytes.slice( + addressPointer, + harness.processBytes.byteLength, + ), + testCase.name, + ).toEqual( + new Uint8Array(stagedCapacity).fill(callerCanary), + ); + expect( + new DataView(harness.processBytes.buffer).getUint32( + lengthPointer, + true, + ), + testCase.name, + ).toBe(callerCapacity); + } + expectScratchTailUntouched(harness); + } } - expectScratchTailUntouched(harness); } }, ); diff --git a/host/test/readdir-atomicity.test.ts b/host/test/readdir-atomicity.test.ts index 42c19d306a..d9f99cb208 100644 --- a/host/test/readdir-atomicity.test.ts +++ b/host/test/readdir-atomicity.test.ts @@ -26,6 +26,31 @@ function createKernelBridge(entries: Array<{ name: string; type: number; ino: nu } describe("host readdir retry atomicity", () => { + it("keeps a NAME_MAX entry pending when the caller is one byte short", () => { + const name = "n".repeat(255); + const { io, kernel, memory } = createKernelBridge([ + { name, type: 8, ino: 42 }, + ]); + const hostReaddir = kernel.testAuthority.hostReaddir; + const bytes = new Uint8Array(memory.buffer); + bytes.fill(0x6d, 16, 32); + bytes.fill(0x7e, 128, 128 + name.length); + + expect(hostReaddir(7n, 16, 128, name.length - 1)).toBe(-34); // ERANGE + expect(io.readdir).toHaveBeenCalledTimes(1); + expect(bytes.slice(16, 32)).toEqual(new Uint8Array(16).fill(0x6d)); + expect(bytes.slice(128, 128 + name.length)).toEqual( + new Uint8Array(name.length).fill(0x7e), + ); + + expect(hostReaddir(7n, 16, 128, name.length)).toBe(1); + expect(io.readdir).toHaveBeenCalledTimes(1); + const view = new DataView(memory.buffer); + expect(view.getUint32(28, true)).toBe(name.length); + expect(new TextDecoder().decode(bytes.slice(128, 128 + name.length))) + .toBe(name); + }); + it("replays an entry when Wasm output marshalling fails after the backend read", () => { let failFirstNameRead = true; const entry = { diff --git a/host/test/reusable-kernel-export-stack.test.ts b/host/test/reusable-kernel-export-stack.test.ts index 6a02a9e45b..fa9753b5d5 100644 --- a/host/test/reusable-kernel-export-stack.test.ts +++ b/host/test/reusable-kernel-export-stack.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { resolveBinary } from "../src/binary-resolver"; -import { WasmPosixKernel } from "../src/kernel"; +import { createWasmPosixKernelTestHarness } from "../src/kernel"; import { MemoryFileSystem } from "../src/vfs/memory-fs"; import { NodeTimeProvider } from "../src/vfs/time"; import { VirtualPlatformIO } from "../src/vfs/vfs"; @@ -17,23 +17,37 @@ interface ReusableKernelExports extends WebAssembly.Exports { async function reusableKernel(): Promise { const rootfs = MemoryFileSystem.create(new SharedArrayBuffer(1024 * 1024)); - const kernel = new WasmPosixKernel( - { + const capture: { instance: WebAssembly.Instance | null } = { + instance: null, + }; + const kernel = createWasmPosixKernelTestHarness({ + config: { maxWorkers: 1, dataBufferSize: 65_536, useSharedMemory: true, }, - new VirtualPlatformIO( + io: new VirtualPlatformIO( [{ mountPoint: "/", backend: rootfs }], new NodeTimeProvider(), ), - ); + engine: { + compile: (bytes) => WebAssembly.compile(bytes), + instantiate: async (module, imports) => { + const instance = await WebAssembly.instantiate(module, imports); + capture.instance = instance; + return instance; + }, + }, + }); await kernel.init(readFileSync(resolveBinary("kernel.wasm"))); - const internals = kernel as unknown as { - instance: WebAssembly.Instance | null; - }; - expect(internals.instance).not.toBeNull(); - return internals.instance!.exports as ReusableKernelExports; + // WHY: the module-secret harness engine owns this raw instance only inside + // the test. Production still installs and exposes only its gated facade, but + // this regression must call the real returning export directly to observe + // whether the Wasm shadow-stack epilogue restores its stack pointer. + const instance = capture.instance; + expect(instance).not.toBeNull(); + if (instance === null) throw new Error("test engine did not instantiate"); + return instance.exports as ReusableKernelExports; } describe("reusable kernel export shadow-stack lifetime", () => { diff --git a/host/test/spawn-pid-authority.test.ts b/host/test/spawn-pid-authority.test.ts index 322bdd4c3d..8b119c92ad 100644 --- a/host/test/spawn-pid-authority.test.ts +++ b/host/test/spawn-pid-authority.test.ts @@ -146,6 +146,100 @@ describe("kernel task-ID authority", () => { expect(onExec).not.toHaveBeenCalled(); }); + it("distinguishes relative dirfd exec from AT_EMPTY_PATH on a regular fd", async () => { + const pid = 77; + const fd = 17; + const pathPtr = 16; + const relativePath = new TextEncoder().encode("program\0"); + const onRelativeExec = vi.fn(async () => -2); + const getDirectoryPath = vi.fn(() => -20); + const relative = createTaskAuthorityHarness({ + pid, + callbacks: { onExec: onRelativeExec }, + kernelExports: { + kernel_get_dirfd_path: getDirectoryPath, + kernel_get_fd_path: vi.fn(() => { + throw new Error("relative execveat used a non-directory path getter"); + }), + }, + }); + new Uint8Array(relative.processMemory.buffer).set( + relativePath, + pathPtr, + ); + const relativeArgs = [fd, pathPtr, 0, 0, 0]; + writeChannelSyscall( + relative.channel, + HOST_INTERCEPTED_SYSCALLS.SYS_EXECVEAT, + relativeArgs, + ); + + relative.worker.testAuthority.dispatchScratchBoundarySyscallForTest( + relative.channel, + ); + + expect(getDirectoryPath).toHaveBeenCalledOnce(); + expect(onRelativeExec).not.toHaveBeenCalled(); + expect(relative.completeChannel).toHaveBeenCalledWith( + relative.channel, + HOST_INTERCEPTED_SYSCALLS.SYS_EXECVEAT, + [...relativeArgs, 0], + undefined, + -1, + 20, + ); + + const executable = new TextEncoder().encode("/bin/program"); + let kernelBytes!: Uint8Array; + const getRegularPath = vi.fn( + ( + _pid: number, + actualFd: number, + pointer: number, + capacity: number, + ) => { + expect(actualFd).toBe(fd); + if (capacity === 0) return executable.byteLength; + if (capacity < executable.byteLength) return -34; + kernelBytes.set(executable, pointer); + return executable.byteLength; + }, + ); + const onEmptyPathExec = vi.fn(async () => -2); + const emptyPath = createTaskAuthorityHarness({ + pid, + callbacks: { onExec: onEmptyPathExec }, + kernelExports: { + kernel_get_dirfd_path: vi.fn(() => { + throw new Error("AT_EMPTY_PATH used a directory-only path getter"); + }), + kernel_get_fd_path: getRegularPath, + }, + }); + kernelBytes = new Uint8Array(emptyPath.kernelMemory.buffer); + new Uint8Array(emptyPath.processMemory.buffer)[pathPtr] = 0; + const emptyPathArgs = [fd, pathPtr, 0, 0, 0x1000]; + writeChannelSyscall( + emptyPath.channel, + HOST_INTERCEPTED_SYSCALLS.SYS_EXECVEAT, + emptyPathArgs, + ); + + emptyPath.worker.testAuthority.dispatchScratchBoundarySyscallForTest( + emptyPath.channel, + ); + await drainTaskAuthorityGate(); + + expect(getRegularPath).toHaveBeenCalledOnce(); + expect(onEmptyPathExec).toHaveBeenCalledWith( + pid, + "/bin/program", + [], + [], + pid, + ); + }); + it("rejects a zero fork result before launching a child Worker", () => { const parentPid = 77; const onFork = vi.fn(); diff --git a/host/test/startup-crt-contract.test.ts b/host/test/startup-crt-contract.test.ts new file mode 100644 index 0000000000..01365dbb80 --- /dev/null +++ b/host/test/startup-crt-contract.test.ts @@ -0,0 +1,40 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("musl startup metadata materialization", () => { + it("keeps allocation failure and query/copy mismatch atomic", () => { + const repoRoot = resolve(import.meta.dirname, "../.."); + const temp = mkdtempSync(join(tmpdir(), "kandelo-startup-crt-")); + try { + const compiler = process.env.LLVM_BIN + ? join(process.env.LLVM_BIN, "clang") + : "clang"; + const executable = join(temp, "startup-crt-contract"); + execFileSync(compiler, [ + "-std=c11", + "-Werror", + "-Wno-ignored-attributes", + "-Wno-unknown-attributes", + "-I", + join(repoRoot, "host/test/fixtures/startup-crt-include"), + "-I", + join(repoRoot, "libc/musl-overlay/arch/wasm64posix"), + "-idirafter", + join(repoRoot, "libc/musl-overlay/include"), + "-idirafter", + join(repoRoot, "libc/musl/include"), + join(repoRoot, "host/test/fixtures/startup-crt-contract.c"), + "-o", + executable, + ], { cwd: repoRoot, stdio: "pipe" }); + + expect(execFileSync(executable, { encoding: "utf8" })) + .toBe("startup-crt-contract: ok\n"); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); +}); diff --git a/host/test/startup-metadata-capacity.test.ts b/host/test/startup-metadata-capacity.test.ts new file mode 100644 index 0000000000..77cbd0add8 --- /dev/null +++ b/host/test/startup-metadata-capacity.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it } from "vitest"; +import { + POSIX_ARG_MAX_BYTES, + PROCESS_METADATA_ENTRY_MAX_BYTES, + PROCESS_STARTUP_MAX_ARGV_COUNT, + PROCESS_STARTUP_MAX_ENVP_COUNT, +} from "../src/generated/abi"; +import { buildKernelImportsForTest } from "../src/worker-main"; + +const E2BIG = 7; +const EFAULT = 14; +const EINVAL = 22; +const ERANGE = 34; + +type EntryReader = ( + index: number, + pointer: number | bigint, + capacity: number, +) => number; + +function reader( + pointerWidth: 4 | 8, + argv: string[], + env: string[], + kind: "argv" | "env", + pages = 2, +): { + memory: WebAssembly.Memory; + read: EntryReader; + imports: Record; +} { + const memory = new WebAssembly.Memory({ initial: pages }); + const imports = buildKernelImportsForTest( + memory, + 0, + pointerWidth, + argv, + env, + ); + return { + memory, + read: imports[ + kind === "argv" ? "kernel_argv_read" : "kernel_environ_get" + ] as EntryReader, + imports, + }; +} + +function pointer(pointerWidth: 4 | 8, offset: number): number | bigint { + return pointerWidth === 8 ? BigInt(offset) : offset; +} + +function metadataAtRepresentedBytes( + pointerWidth: 4 | 8, + representedBytes: number, +): string[] { + const vectorNullBytes = 2 * pointerWidth; + const remaining = representedBytes - vectorNullBytes; + const largestContribution = + pointerWidth + PROCESS_METADATA_ENTRY_MAX_BYTES + 1; + const count = Math.ceil(remaining / largestContribution); + const contentBytes = remaining - count * (pointerWidth + 1); + if ( + count < 0 + || count > PROCESS_STARTUP_MAX_ARGV_COUNT + || contentBytes < 0 + || contentBytes > count * PROCESS_METADATA_ENTRY_MAX_BYTES + ) { + throw new Error("cannot construct requested startup metadata boundary"); + } + let left = contentBytes; + return Array.from({ length: count }, () => { + const length = Math.min(left, PROCESS_METADATA_ENTRY_MAX_BYTES); + left -= length; + return "x".repeat(length); + }); +} + +describe("process startup metadata capacity contract", () => { + it.each([4, 8] as const)( + "copies exact argv/environment entries, rejects one-short, and preserves larger-capacity tails for wasm%s", + (pointerWidth) => { + for (const kind of ["argv", "env"] as const) { + const value = kind === "argv" ? "argument" : "NAME=value"; + const { memory, read } = reader( + pointerWidth, + kind === "argv" ? [value] : [], + kind === "env" ? [value] : [], + kind, + ); + const encoded = new TextEncoder().encode(value); + const offset = 128; + const bytes = new Uint8Array(memory.buffer); + bytes.fill(0x5a, offset, offset + encoded.byteLength + 2); + + expect(read(0, pointer(pointerWidth, 0), 0)).toBe(encoded.byteLength); + expect(read( + 0, + pointer(pointerWidth, offset), + encoded.byteLength - 1, + )).toBe(-ERANGE); + expect([...bytes.slice(offset, offset + encoded.byteLength + 2)]) + .toEqual(Array(encoded.byteLength + 2).fill(0x5a)); + + expect(read( + 0, + pointer(pointerWidth, offset), + encoded.byteLength, + )).toBe(encoded.byteLength); + expect([...bytes.slice(offset, offset + encoded.byteLength)]) + .toEqual([...encoded]); + expect(bytes[offset + encoded.byteLength]).toBe(0x5a); + + bytes.fill(0x6b, offset, offset + encoded.byteLength + 2); + expect(read( + 0, + pointer(pointerWidth, offset), + encoded.byteLength + 1, + )).toBe(encoded.byteLength); + expect([...bytes.slice(offset, offset + encoded.byteLength)]) + .toEqual([...encoded]); + expect([...bytes.slice( + offset + encoded.byteLength, + offset + encoded.byteLength + 2, + )]).toEqual([0x6b, 0x6b]); + } + }, + ); + + it.each([4, 8] as const)( + "validates startup pointers and current-memory boundaries for wasm%s", + (pointerWidth) => { + const { memory, read } = reader(pointerWidth, ["four"], [], "argv"); + const bytes = new Uint8Array(memory.buffer); + const exactEnd = bytes.byteLength - 4; + + expect(read(0, pointer(pointerWidth, exactEnd), 4)).toBe(4); + expect(new TextDecoder().decode(bytes.slice(exactEnd))).toBe("four"); + + bytes.fill(0x6b, exactEnd - 1); + expect(read(0, pointer(pointerWidth, exactEnd + 1), 4)).toBe(-EFAULT); + expect([...bytes.slice(exactEnd - 1)]) + .toEqual(Array(5).fill(0x6b)); + + expect(read(0, pointer(pointerWidth, 0), 4)).toBe(-EFAULT); + expect(read(0, pointerWidth === 8 ? -1n : -1, 4)).toBe(-EFAULT); + expect(read(0, pointerWidth === 8 ? 1 : 1.5, 4)).toBe(-EFAULT); + expect(read( + 0, + pointerWidth === 8 ? Number.MAX_SAFE_INTEGER + 1 : 0x1_0000_0000, + 4, + )).toBe(-EFAULT); + + const empty = reader(pointerWidth, [""], [], "argv").read; + expect(empty(0, pointer(pointerWidth, 0), 0)).toBe(0); + expect(empty(0, pointer(pointerWidth, 0), 1)).toBe(-EFAULT); + expect(empty(0, pointer(pointerWidth, exactEnd), 1)).toBe(0); + }, + ); + + it.each([4, 8] as const)( + "rejects malformed indices and capacities without touching wasm%s memory", + (pointerWidth) => { + const { memory, read } = reader(pointerWidth, ["value"], [], "argv"); + const bytes = new Uint8Array(memory.buffer); + bytes.fill(0x3c, 64, 80); + + for (const index of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1, 1]) { + expect(read(index, pointer(pointerWidth, 64), 16)).toBe(-EINVAL); + } + for (const capacity of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1]) { + expect(read(0, pointer(pointerWidth, 64), capacity)).toBe(-EINVAL); + } + expect([...bytes.slice(64, 80)]).toEqual(Array(16).fill(0x3c)); + }, + ); + + it.each([4, 8] as const)( + "accepts exact ARG_MAX and rejects ARG_MAX+1 for wasm%s", + (pointerWidth) => { + const exact = metadataAtRepresentedBytes( + pointerWidth, + POSIX_ARG_MAX_BYTES, + ); + const imports = buildKernelImportsForTest( + new WebAssembly.Memory({ initial: 1 }), + 0, + pointerWidth, + exact, + [], + ); + expect( + (imports.kernel_get_argc as () => number)(), + ).toBe(exact.length); + + const oversized = metadataAtRepresentedBytes( + pointerWidth, + POSIX_ARG_MAX_BYTES + 1, + ); + expect(() => buildKernelImportsForTest( + new WebAssembly.Memory({ initial: 1 }), + 0, + pointerWidth, + oversized, + [], + )).toThrow(`errno ${E2BIG}`); + }, + ); + + it.each([4, 8] as const)( + "accepts exact startup count limits and rejects count+1 for wasm%s", + (pointerWidth) => { + const argv = Array(PROCESS_STARTUP_MAX_ARGV_COUNT).fill(""); + const env = Array(PROCESS_STARTUP_MAX_ENVP_COUNT).fill(""); + const imports = buildKernelImportsForTest( + new WebAssembly.Memory({ initial: 1 }), + 0, + pointerWidth, + argv, + env, + ); + expect((imports.kernel_get_argc as () => number)()) + .toBe(PROCESS_STARTUP_MAX_ARGV_COUNT); + expect((imports.kernel_environ_count as () => number)()) + .toBe(PROCESS_STARTUP_MAX_ENVP_COUNT); + + expect(() => buildKernelImportsForTest( + new WebAssembly.Memory({ initial: 1 }), + 0, + pointerWidth, + [...argv, ""], + [], + )).toThrow(`errno ${E2BIG}`); + expect(() => buildKernelImportsForTest( + new WebAssembly.Memory({ initial: 1 }), + 0, + pointerWidth, + [], + [...env, ""], + )).toThrow(`errno ${E2BIG}`); + }, + ); + + it.each([4, 8] as const)( + "accepts exact per-entry size and rejects one byte more for wasm%s", + (pointerWidth) => { + const exact = "x".repeat(PROCESS_METADATA_ENTRY_MAX_BYTES); + const imports = buildKernelImportsForTest( + new WebAssembly.Memory({ initial: 1 }), + 0, + pointerWidth, + [exact], + [], + ); + expect( + (imports.kernel_argv_read as EntryReader)( + 0, + pointer(pointerWidth, 0), + 0, + ), + ).toBe(PROCESS_METADATA_ENTRY_MAX_BYTES); + + expect(() => buildKernelImportsForTest( + new WebAssembly.Memory({ initial: 1 }), + 0, + pointerWidth, + [`${exact}x`], + [], + )).toThrow(`errno ${E2BIG}`); + }, + ); + + it.each([4, 8] as const)( + "holds one immutable launch snapshot across sequential and interleaved reads for wasm%s", + (pointerWidth) => { + const argv = ["before"]; + const env = ["NAME=before"]; + const memory = new WebAssembly.Memory({ initial: 2 }); + const imports = buildKernelImportsForTest( + memory, + 0, + pointerWidth, + argv, + env, + ); + const readArgv = imports.kernel_argv_read as EntryReader; + const readEnv = imports.kernel_environ_get as EntryReader; + argv[0] = "after-with-a-different-length"; + env[0] = "NAME=after-with-a-different-length"; + + expect(readArgv(0, pointer(pointerWidth, 0), 0)).toBe(6); + expect(readEnv(0, pointer(pointerWidth, 0), 0)).toBe(11); + expect(readEnv(0, pointer(pointerWidth, 64), 11)).toBe(11); + expect(readArgv(0, pointer(pointerWidth, 96), 6)).toBe(6); + expect(readArgv(0, pointer(pointerWidth, 128), 6)).toBe(6); + const bytes = new Uint8Array(memory.buffer); + expect(new TextDecoder().decode(bytes.slice(64, 75))).toBe("NAME=before"); + expect(new TextDecoder().decode(bytes.slice(96, 102))).toBe("before"); + expect(new TextDecoder().decode(bytes.slice(128, 134))).toBe("before"); + }, + ); +}); diff --git a/host/test/support/kernel-scratch-instance.ts b/host/test/support/kernel-scratch-instance.ts index 6101ff70e3..171e0e33af 100644 --- a/host/test/support/kernel-scratch-instance.ts +++ b/host/test/support/kernel-scratch-instance.ts @@ -40,10 +40,6 @@ function signatures( parameters: [i32], result: i32, }, - kernel_clear_process_metadata: { - parameters: [i32, i32], - result: i32, - }, kernel_clear_fork_child: { parameters: [i32], result: i32, @@ -100,6 +96,10 @@ function signatures( parameters: [i32, pointer, i32], result: i32, }, + kernel_get_dirfd_path: { + parameters: [i32, i32, pointer, i32], + result: i32, + }, kernel_get_fork_count: { parameters: [i32], result: i64, @@ -279,6 +279,22 @@ function signatures( parameters: [pointer, i32, i32, i32], result: i32, }, + kernel_process_metadata_begin: { + parameters: [i32], + result: i32, + }, + kernel_process_metadata_cancel: { + parameters: [i32, i32], + result: i32, + }, + kernel_process_metadata_commit: { + parameters: [i32, i32], + result: i32, + }, + kernel_process_metadata_stage: { + parameters: [i32, i32, i32, pointer, i32], + result: i32, + }, kernel_pty_create: { parameters: [i32], result: i32, @@ -295,10 +311,6 @@ function signatures( parameters: [i32, pointer, i32], result: i32, }, - kernel_push_process_metadata_entry: { - parameters: [i32, i32, pointer, i32], - result: i32, - }, kernel_read_proc_maps: { parameters: [i32, pointer, i32], result: i32, diff --git a/host/test/support/wasm-memory-write-audit.ts b/host/test/support/wasm-memory-write-audit.ts index a3a0acb0a8..490297e948 100644 --- a/host/test/support/wasm-memory-write-audit.ts +++ b/host/test/support/wasm-memory-write-audit.ts @@ -1,4 +1,4 @@ -import { readdirSync } from "node:fs"; +import { existsSync, readdirSync } from "node:fs"; import path from "node:path"; import ts from "typescript"; @@ -8207,6 +8207,12 @@ export function repositoryRuntimeSourceFiles(rootDir: string): string[] { } const absolute = path.join(directory, entry.name); if (entry.isDirectory()) { + if (existsSync(path.join(absolute, ".git"))) { + // WHY: a nested checkout is a different repository, not another + // Kandelo source directory. Scanning it duplicates declarations and + // lets local worktrees/artifacts change this repository's contract. + continue; + } // These checked-out upstream trees are not Kandelo TypeScript runtime // sources and can contain their own nested build products. const relative = toPosix(path.relative(rootDir, absolute)); diff --git a/host/test/wasm-memory-write-audit.test.ts b/host/test/wasm-memory-write-audit.test.ts index 4d98bbd7ab..133f36ecd7 100644 --- a/host/test/wasm-memory-write-audit.test.ts +++ b/host/test/wasm-memory-write-audit.test.ts @@ -5321,6 +5321,13 @@ describe("WebAssembly memory write audit", () => { writeFileSync(path.join(runtime, "ignored.spec.mjs"), "export {};\n"); mkdirSync(path.join(root, "dist"), { recursive: true }); writeFileSync(path.join(root, "dist", "ignored.js"), "export {};\n"); + mkdirSync(path.join(root, "nested-checkout", ".git"), { + recursive: true, + }); + writeFileSync( + path.join(root, "nested-checkout", "ignored.ts"), + "export {};\n", + ); expect( repositoryRuntimeSourceFiles(root).map((file) => diff --git a/libc/glue/syscall_imports.h b/libc/glue/syscall_imports.h index e9eb8ad4f4..784f3fffd0 100644 --- a/libc/glue/syscall_imports.h +++ b/libc/glue/syscall_imports.h @@ -726,7 +726,7 @@ KERNEL_IMPORT(kernel_get_argc) uint32_t kernel_get_argc(void); KERNEL_IMPORT(kernel_argv_read) -uint32_t kernel_argv_read(uint32_t index, uint8_t *buf_ptr, uint32_t buf_max); +int32_t kernel_argv_read(uint32_t index, uint8_t *buf_ptr, uint32_t buf_max); /* ------------------------------------------------------------------ */ /* SysV IPC */ diff --git a/libc/musl-overlay/arch/wasm32posix/crt_arch.h b/libc/musl-overlay/arch/wasm32posix/crt_arch.h index caa76aaca3..6ce3418107 100644 --- a/libc/musl-overlay/arch/wasm32posix/crt_arch.h +++ b/libc/musl-overlay/arch/wasm32posix/crt_arch.h @@ -1,124 +1,3 @@ -/* - * crt_arch.h — Wasm CRT entry point. - * - * On real architectures this file contains an __asm__ block that defines - * the _start symbol (which sets up the stack pointer and calls _start_c). - * - * Wasm has no stack-pointer setup concerns. We define _start as a regular - * C function exported to the host. It calls _start_c (defined in crt1.c) - * with an argc/argv pointer block. - * - * If the host pushed argv via kernel_push_argv before calling _start, - * we fetch argc/argv from the kernel. Otherwise we fall back to argc=1, - * argv[0]="a.out". - * - * Environment variables are populated from the kernel's proc.environ via - * kernel_environ_count / kernel_environ_get so that getenv() works and - * __environ reflects the host-set environment. - * - * The SHARED guard prevents this from conflicting with ldso/dlstart.c - * which also includes crt_arch.h but defines its own _start_c variant. - */ +/* Wasm has no architecture-specific stack setup. The common Kandelo crt1.c + * owns the exported _start and its capacity-bearing argv/environment setup. */ #define START "_start" - -#ifndef SHARED -/* - * _start_c is defined in crt1.c after this header is included. - * The __asm__ block normally references it by name; we forward-declare - * it so our C _start can call it. - */ -void _start_c(long *); - -/* Kernel imports for argv support */ -__attribute__((import_module("kernel"), import_name("kernel_get_argc"))) -unsigned kernel_get_argc(void); -__attribute__((import_module("kernel"), import_name("kernel_argv_read"))) -unsigned kernel_argv_read(unsigned index, unsigned char *buf, unsigned buf_max); - -/* Kernel imports for environ support */ -__attribute__((import_module("kernel"), import_name("kernel_environ_count"))) -unsigned kernel_environ_count(void); -__attribute__((import_module("kernel"), import_name("kernel_environ_get"))) -int kernel_environ_get(unsigned index, unsigned char *buf, unsigned buf_max); - -__attribute__((export_name("_start"))) -void _start(void) -{ - /* Note: LLVM TLS for the main thread is initialized by the Wasm - * module's start function (__wasm_init_memory), which runs before - * _start. Do NOT call __wasm_init_tls here — the passive data - * segments have already been dropped by that point. */ - - /* - * _start_c expects a pointer p where: - * p[0] = argc - * p[1..argc] = argv pointers - * p[argc+1] = NULL (end of argv) - * p[argc+2..argc+1+envc] = envp pointers - * p[argc+2+envc] = NULL (end of envp) - * p[argc+3+envc] = 0 (AT_NULL auxv key) - * p[argc+4+envc] = 0 (AT_NULL auxv value) - */ - /* Sized for real-world parent environments. The original 16KB env - * cap dropped trailing vars on GitHub Actions Linux runners (PATH - * with nix store paths + GHA_* + RUNNER_* exceeds 16KB), which - * silently dropped any var added by the parent via setenv() right - * before posix_spawn — sortix's `basic/spawn/posix_spawn{,p}` set - * `OS_TEST_POSIX_SPAWN` last and require the child to see it. The - * old fork-based spawn hid the bug because env was inherited via - * memory copy. 128KB matches typical Linux execve env limits. */ - #define MAX_ARGC 1024 - #define MAX_ENVC 1024 - #define ARGV_BUF_SIZE (64 * 1024) - #define ENV_BUF_SIZE (128 * 1024) - - static char argv_buf[ARGV_BUF_SIZE]; - static char env_buf[ENV_BUF_SIZE]; - /* argc + argv ptrs + NULL + envp ptrs + NULL + auxv(2) */ - static long start_data[MAX_ARGC + MAX_ENVC + 5]; - - unsigned argc = kernel_get_argc(); - if (argc == 0) { - /* No args set by host — default to "a.out" */ - static char prog_name[] = "a.out"; - argc = 1; - start_data[1] = (long)prog_name; - } else { - if (argc > MAX_ARGC) argc = MAX_ARGC; - unsigned offset = 0; - unsigned i; - for (i = 0; i < argc && offset < ARGV_BUF_SIZE - 1; i++) { - unsigned len = kernel_argv_read(i, (unsigned char *)&argv_buf[offset], - ARGV_BUF_SIZE - offset - 1); - argv_buf[offset + len] = '\0'; - start_data[1 + i] = (long)&argv_buf[offset]; - offset += len + 1; - } - argc = i; - } - - start_data[0] = argc; - start_data[1 + argc] = 0; /* argv NULL terminator */ - - /* Populate envp from kernel's proc.environ */ - unsigned envc = kernel_environ_count(); - if (envc > MAX_ENVC) envc = MAX_ENVC; - unsigned env_offset = 0; - unsigned ei; - for (ei = 0; ei < envc && env_offset < ENV_BUF_SIZE - 1; ei++) { - int len = kernel_environ_get(ei, (unsigned char *)&env_buf[env_offset], - ENV_BUF_SIZE - env_offset - 1); - if (len < 0) break; - env_buf[env_offset + len] = '\0'; - start_data[2 + argc + ei] = (long)&env_buf[env_offset]; - env_offset += len + 1; - } - envc = ei; - - start_data[2 + argc + envc] = 0; /* envp NULL terminator */ - start_data[3 + argc + envc] = 0; /* auxv AT_NULL */ - start_data[4 + argc + envc] = 0; /* auxv value */ - - _start_c(start_data); -} -#endif /* !SHARED */ diff --git a/libc/musl-overlay/arch/wasm64posix/crt_arch.h b/libc/musl-overlay/arch/wasm64posix/crt_arch.h index d6ec50da2e..6ce3418107 100644 --- a/libc/musl-overlay/arch/wasm64posix/crt_arch.h +++ b/libc/musl-overlay/arch/wasm64posix/crt_arch.h @@ -1,87 +1,3 @@ -/* - * crt_arch.h — Wasm64 CRT entry point. - * - * Same as wasm32posix but with 64-bit pointer params for kernel imports. - */ +/* Wasm has no architecture-specific stack setup. The common Kandelo crt1.c + * owns the exported _start and its capacity-bearing argv/environment setup. */ #define START "_start" - -#ifndef SHARED -void _start_c(long *); - -/* Kernel imports for argv support — wasm64 uses i64 for pointers */ -__attribute__((import_module("kernel"), import_name("kernel_get_argc"))) -unsigned kernel_get_argc(void); -__attribute__((import_module("kernel"), import_name("kernel_argv_read"))) -unsigned kernel_argv_read(unsigned index, unsigned char *buf, unsigned buf_max); - -/* Kernel imports for environ support */ -__attribute__((import_module("kernel"), import_name("kernel_environ_count"))) -unsigned kernel_environ_count(void); -__attribute__((import_module("kernel"), import_name("kernel_environ_get"))) -int kernel_environ_get(unsigned index, unsigned char *buf, unsigned buf_max); - -__attribute__((export_name("_start"))) -void _start(void) -{ - /* Sized for real-world parent environments. The original 16KB env - * cap dropped trailing vars on GitHub Actions Linux runners (PATH - * with nix store paths + GHA_* + RUNNER_* exceeds 16KB), which - * silently dropped any var added by the parent via setenv() right - * before posix_spawn — sortix's `basic/spawn/posix_spawn{,p}` set - * `OS_TEST_POSIX_SPAWN` last and require the child to see it. The - * old fork-based spawn hid the bug because env was inherited via - * memory copy. 128KB matches typical Linux execve env limits. */ - #define MAX_ARGC 1024 - #define MAX_ENVC 1024 - #define ARGV_BUF_SIZE (64 * 1024) - #define ENV_BUF_SIZE (128 * 1024) - - static char argv_buf[ARGV_BUF_SIZE]; - static char env_buf[ENV_BUF_SIZE]; - /* argc + argv ptrs + NULL + envp ptrs + NULL + auxv(2) */ - static long start_data[MAX_ARGC + MAX_ENVC + 5]; - - unsigned argc = kernel_get_argc(); - if (argc == 0) { - static char prog_name[] = "a.out"; - argc = 1; - start_data[1] = (long)prog_name; - } else { - if (argc > MAX_ARGC) argc = MAX_ARGC; - unsigned offset = 0; - unsigned i; - for (i = 0; i < argc && offset < ARGV_BUF_SIZE - 1; i++) { - unsigned len = kernel_argv_read(i, (unsigned char *)&argv_buf[offset], - ARGV_BUF_SIZE - offset - 1); - argv_buf[offset + len] = '\0'; - start_data[1 + i] = (long)&argv_buf[offset]; - offset += len + 1; - } - argc = i; - } - - start_data[0] = argc; - start_data[1 + argc] = 0; /* argv NULL terminator */ - - /* Populate envp from kernel's proc.environ */ - unsigned envc = kernel_environ_count(); - if (envc > MAX_ENVC) envc = MAX_ENVC; - unsigned env_offset = 0; - unsigned ei; - for (ei = 0; ei < envc && env_offset < ENV_BUF_SIZE - 1; ei++) { - int len = kernel_environ_get(ei, (unsigned char *)&env_buf[env_offset], - ENV_BUF_SIZE - env_offset - 1); - if (len < 0) break; - env_buf[env_offset + len] = '\0'; - start_data[2 + argc + ei] = (long)&env_buf[env_offset]; - env_offset += len + 1; - } - envc = ei; - - start_data[2 + argc + envc] = 0; /* envp NULL terminator */ - start_data[3 + argc + envc] = 0; /* auxv AT_NULL */ - start_data[4 + argc + envc] = 0; /* auxv value */ - - _start_c(start_data); -} -#endif /* !SHARED */ diff --git a/libc/musl-overlay/crt/crt1.c b/libc/musl-overlay/crt/crt1.c index ba27e10983..be9d8957cd 100644 --- a/libc/musl-overlay/crt/crt1.c +++ b/libc/musl-overlay/crt/crt1.c @@ -13,6 +13,9 @@ */ #include +#include +#include +#include #include "libc.h" #define START "_start" @@ -32,3 +35,150 @@ void _start_c(long *p) char **argv = (void *)(p+1); __libc_start_main(__main_argc_argv, argc, argv, _init, _fini, 0); } + +__attribute__((import_module("kernel"), import_name("kernel_get_argc"))) +int kernel_get_argc(void); +__attribute__((import_module("kernel"), import_name("kernel_argv_read"))) +int kernel_argv_read(unsigned, unsigned char *, unsigned); +__attribute__((import_module("kernel"), import_name("kernel_environ_count"))) +int kernel_environ_count(void); +__attribute__((import_module("kernel"), import_name("kernel_environ_get"))) +int kernel_environ_get(unsigned, unsigned char *, unsigned); + +/* + * Query results are retained across the allocation boundary. This is small, + * fixed metadata (32 KiB at the generated count maxima), not a destination + * for variable strings. + * + * WHY: querying again after mmap would create a time-of-check/time-of-use gap. + * Keeping every exact length lets the copy return ERANGE or a mismatched count + * if launch metadata changes, before _start_c publishes any argv/env pointers. + */ +static uint32_t startup_entry_lengths[ + KANDELO_PROCESS_STARTUP_MAX_ARGV_COUNT + + KANDELO_PROCESS_STARTUP_MAX_ENVP_COUNT +]; + +static _Noreturn void startup_contract_failure(void) +{ + /* No libc state has been published yet. A trap is the only truthful + * failure at this boundary; continuing would launch with partial metadata. */ + __builtin_trap(); +} + +static void add_startup_entry_length( + size_t *string_bytes, + unsigned length_index, + int length) +{ + if (length < 0 + || (unsigned)length > KANDELO_PROCESS_METADATA_ENTRY_MAX_BYTES + || (size_t)length + 1 > KANDELO_POSIX_ARG_MAX_BYTES - *string_bytes) + startup_contract_failure(); + startup_entry_lengths[length_index] = (uint32_t)length; + *string_bytes += (size_t)length + 1; +} + +__attribute__((export_name("_start"))) +void _start(void) +{ + /* + * LLVM initializes main-thread TLS before this exported entry runs. The + * syscall channel is also live, so an ordinary anonymous mmap can own the + * exact argv/env table for the complete libc lifetime. + */ + int raw_argc = kernel_get_argc(); + int raw_envc = kernel_environ_count(); + if (raw_argc < 0 + || (unsigned)raw_argc > KANDELO_PROCESS_STARTUP_MAX_ARGV_COUNT + || raw_envc < 0 + || (unsigned)raw_envc > KANDELO_PROCESS_STARTUP_MAX_ENVP_COUNT) + startup_contract_failure(); + + unsigned argc = (unsigned)raw_argc; + unsigned envc = (unsigned)raw_envc; + size_t pointer_entries = (size_t)argc + envc + 2; + if (pointer_entries > SIZE_MAX / sizeof(char *)) + startup_contract_failure(); + size_t represented_bytes = pointer_entries * sizeof(char *); + if (represented_bytes > KANDELO_POSIX_ARG_MAX_BYTES) + startup_contract_failure(); + + size_t string_bytes = 0; + unsigned i; + for (i = 0; i < argc; i++) { + int length = kernel_argv_read(i, 0, 0); + add_startup_entry_length(&string_bytes, i, length); + } + for (i = 0; i < envc; i++) { + int length = kernel_environ_get(i, 0, 0); + add_startup_entry_length(&string_bytes, argc + i, length); + } + if (string_bytes > KANDELO_POSIX_ARG_MAX_BYTES - represented_bytes) + startup_contract_failure(); + + /* + * Preserve the historical empty-argv fallback without charging its static + * "a.out" bytes to caller-provided ARG_MAX metadata. + */ + unsigned startup_argc = argc ? argc : 1; + size_t start_words = (size_t)startup_argc + envc + 5; + if (start_words > SIZE_MAX / sizeof(long)) + startup_contract_failure(); + size_t start_bytes = start_words * sizeof(long); + if (string_bytes > SIZE_MAX - start_bytes) + startup_contract_failure(); + size_t mapping_bytes = start_bytes + string_bytes; + + long *start_data = mmap( + 0, + mapping_bytes, + PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, + -1, + 0); + if (start_data == MAP_FAILED) + startup_contract_failure(); + unsigned char *strings = (unsigned char *)start_data + start_bytes; + size_t cursor = 0; + + if (!argc) { + static char prog_name[] = "a.out"; + start_data[1] = (long)prog_name; + } else { + for (i = 0; i < argc; i++) { + uint32_t capacity = startup_entry_lengths[i]; + if (cursor > string_bytes + || (size_t)capacity + 1 > string_bytes - cursor) + startup_contract_failure(); + int copied = kernel_argv_read(i, strings + cursor, capacity); + if (copied < 0 || (uint32_t)copied != capacity) + startup_contract_failure(); + strings[cursor + capacity] = 0; + start_data[1 + i] = (long)(strings + cursor); + cursor += (size_t)capacity + 1; + } + } + + start_data[0] = startup_argc; + start_data[1 + startup_argc] = 0; + for (i = 0; i < envc; i++) { + uint32_t capacity = startup_entry_lengths[argc + i]; + if (cursor > string_bytes + || (size_t)capacity + 1 > string_bytes - cursor) + startup_contract_failure(); + int copied = kernel_environ_get(i, strings + cursor, capacity); + if (copied < 0 || (uint32_t)copied != capacity) + startup_contract_failure(); + strings[cursor + capacity] = 0; + start_data[2 + startup_argc + i] = (long)(strings + cursor); + cursor += (size_t)capacity + 1; + } + if (cursor != string_bytes) + startup_contract_failure(); + + start_data[2 + startup_argc + envc] = 0; + start_data[3 + startup_argc + envc] = 0; + start_data[4 + startup_argc + envc] = 0; + _start_c(start_data); +} diff --git a/libc/musl-overlay/include/bits/kandelo_limits.h b/libc/musl-overlay/include/bits/kandelo_limits.h index e74b89d274..9c933b4715 100644 --- a/libc/musl-overlay/include/bits/kandelo_limits.h +++ b/libc/musl-overlay/include/bits/kandelo_limits.h @@ -7,6 +7,8 @@ #define KANDELO_POSIX_PATH_MAX_BYTES 4096u #define KANDELO_POSIX_IOV_MAX 1024u #define KANDELO_PROCESS_METADATA_ENTRY_MAX_BYTES 65536u +#define KANDELO_PROCESS_STARTUP_MAX_ARGV_COUNT 4096u +#define KANDELO_PROCESS_STARTUP_MAX_ENVP_COUNT 4096u #define KANDELO_MAX_REPORTABLE_TRANSFER_BYTES 2147483647u #endif /* KANDELO_PLATFORM_LIMITS_H */ diff --git a/libc/musl-overlay/include/bits/kandelo_process_layouts.h b/libc/musl-overlay/include/bits/kandelo_process_layouts.h index 11c9bf6acb..d57dd75dd5 100644 --- a/libc/musl-overlay/include/bits/kandelo_process_layouts.h +++ b/libc/musl-overlay/include/bits/kandelo_process_layouts.h @@ -40,6 +40,15 @@ #define KANDELO_PROCESS_CMSGHDR_WASM64_TYPE_OFFSET 12u #define KANDELO_PROCESS_CMSGHDR_WASM64_DATA_OFFSET 16u +#define KANDELO_PROCESS_GROUP_REQ_WASM32_SIZE 132u +#define KANDELO_PROCESS_GROUP_REQ_WASM32_GROUP_OFFSET 4u +#define KANDELO_PROCESS_GROUP_SOURCE_REQ_WASM32_SIZE 260u +#define KANDELO_PROCESS_GROUP_SOURCE_REQ_WASM32_SOURCE_OFFSET 132u +#define KANDELO_PROCESS_GROUP_REQ_WASM64_SIZE 136u +#define KANDELO_PROCESS_GROUP_REQ_WASM64_GROUP_OFFSET 8u +#define KANDELO_PROCESS_GROUP_SOURCE_REQ_WASM64_SIZE 264u +#define KANDELO_PROCESS_GROUP_SOURCE_REQ_WASM64_SOURCE_OFFSET 136u + #define KANDELO_PROCESS_SIGINFO_SIGNO_OFFSET 0u #define KANDELO_PROCESS_SIGINFO_ERRNO_OFFSET 4u #define KANDELO_PROCESS_SIGINFO_CODE_OFFSET 8u diff --git a/packages/registry/kernel/build-kernel.sh b/packages/registry/kernel/build-kernel.sh index a646cb04fe..8ca9a3bae2 100755 --- a/packages/registry/kernel/build-kernel.sh +++ b/packages/registry/kernel/build-kernel.sh @@ -29,7 +29,6 @@ wasm_require_exports "$OUT" \ kernel_alloc_scratch \ kernel_blocking_retry_release \ kernel_blocking_retry_token \ - kernel_clear_process_metadata \ kernel_commit_process_exit \ kernel_create_process \ kernel_create_process_with_stdio \ @@ -37,6 +36,9 @@ wasm_require_exports "$OUT" \ kernel_exec_prepare \ kernel_exec_setup_for_thread \ kernel_fork_process \ + kernel_get_cwd \ + kernel_get_dirfd_path \ + kernel_get_fd_path \ kernel_get_parent_pid \ kernel_get_process_exit_signal \ kernel_get_process_state \ @@ -56,7 +58,10 @@ wasm_require_exports "$OUT" \ kernel_pick_signal_target_tid \ kernel_pipe_has_readers \ kernel_posix_timer_fire \ - kernel_push_process_metadata_entry \ + kernel_process_metadata_begin \ + kernel_process_metadata_cancel \ + kernel_process_metadata_commit \ + kernel_process_metadata_stage \ kernel_reap_exited_child \ kernel_remove_process \ kernel_semctl_array_bytes \ diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index 7704050172..a1085b4801 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -4,344 +4,344 @@ "bash": { "manifestSha256": "5dc4d97dc4f154f265ff57e9d972e7d5a64a2e15fadedfa246733b830dda3497", "cacheKeys": { - "wasm32": "b23ed586d38fc7aaf6a4da4acdb9663f99b18431ea9268737524d312e69ed18c", - "wasm64": "02bbcbca553b5d6b8a23c8562bb374e4e8c7e2bcd4cef343029edf015d0c69b6" + "wasm32": "3bb0b25c26be9fa2fac163fb9f5853958cb1a67d7a6bc6249b2af9e49ba5a63b", + "wasm64": "610dcc2f5af2c23cf666396a85ab4b4a455ab40e73cb38902fe68a3127679e30" } }, "bc": { "manifestSha256": "43b61e92c29098720bc7f4871a3b611cc01ffd124024290d42967cebb6ffd459", "cacheKeys": { - "wasm32": "fcb2a8e63865ec30a3ce397d368715ff51be01a2dd3268897a71be324bb7fed6", - "wasm64": "ece0eb487708585e4ea0cd987275d0c7f5a6ce1c5728ad1a8385ecd65ec28126" + "wasm32": "b088a88d58385a6cadd463fbbb94944c52790205c08bdf463fbfd5e496fa430e", + "wasm64": "05c026b8e9099ba9837228bba138f1638f63f8d893b504ffdd4d3ffc65be3389" } }, "bzip2": { "manifestSha256": "59e1e53f5675e7c9148634933ed0f4c7192743727025cae4e843b6924c489abf", "cacheKeys": { - "wasm32": "d76042b18566555fcdd155cd6f93cd4f800464b03701033678433b7a01aaf28b", - "wasm64": "78877cd00b538fa713dfefbf39401670d463a33444635c8c63a00843ce8675ae" + "wasm32": "9fa1eb2f8edc0ab1f0a983b603cf8c08c03867d429a73bc2994b9bc001646538", + "wasm64": "6c023377e8de285cd3879bdfdd95b417b98c690b23cf79a2304692a454f2ee88" } }, "coreutils": { "manifestSha256": "72fc9c3818fb11cebaa047a623743252395dc729163fa804a7272d59f87fe82d", "cacheKeys": { - "wasm32": "5e780a3a00fdfeb141fd21e895b4cc9005970a0ca7f87d422f1acb072ae7fc4e", - "wasm64": "c898f50e143b676de8181c94482f29f4afe421585d0f1db05770fd12f55f54eb" + "wasm32": "88e8aec625b11e1cf53112017c6b12206c3fb5d4c495bea02297ae4c3a45db1f", + "wasm64": "44ff689fd0db89fcec6c8006e243c7eef825775f93c054b2c116a31f49584088" } }, "cpython": { "manifestSha256": "7dd4f446697a73941ec940c2ccba4d53be73fe6947f1e701031a4d0aa964c4ff", "cacheKeys": { - "wasm32": "08c8e564afadabf660259f5030bd9711356a4245c317315853bb6b0a6d904b55", - "wasm64": "0b1caec73a9e4bb196dcfc49c59dd330edfa9ff46dd0e6fde653640114547a25" + "wasm32": "a0ef6affbcfb19fc83326cb35501026e63b93f7849329d6ce190cf197eddce41", + "wasm64": "987e3dff9ca4f3dab397214b10ed0436fe07cfddca0696f0ea592e2fef315a6a" } }, "curl": { "manifestSha256": "55523d50261f46dc4aaaff458d1cd87c6f96eaecd687a7540ead35c96906366e", "cacheKeys": { - "wasm32": "f79be2fb854ff7855444354b36736b904f7c34279c098f14af6a779f1b5499ac", - "wasm64": "49e6f520a35bb0415208883b759b0df7a2bbeb9ee60e57d07aa54f73d8e512f6" + "wasm32": "3eb3d0113cc00ca53713eab7c2c68646a77484c332eb82ddd6389ac41d1568cd", + "wasm64": "c78baea32795627a879263af99ae70df02dd35e52fe46cb043d09ff427eeb2e4" } }, "dash": { "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", "cacheKeys": { - "wasm32": "4af6e234e2447445d4f18882a2d7bb8d9d4fbd67ee258ef88d661140749109b3", - "wasm64": "2dd53c014af59fc455b6a86abbd034e7cbce505b1d3a8cc55877691446c46c5b" + "wasm32": "292795420bea85f6f35af3a2399696e790b7ae22af2385c440e79751d5e8e9ad", + "wasm64": "9de9e85d91e122625a02db27626c24ce1051077fd908bc6545177814c7a78d0e" } }, "diffutils": { "manifestSha256": "2286203eb762eca487939963e38ea1b901ac2dc9a830d3260c2fb291757df208", "cacheKeys": { - "wasm32": "6b75f917e8dad9908406972a72f06ed67fe9f31813bedc70b9bf0bc078b0244e", - "wasm64": "5df04924097a40f5a9d724c342eb9a1887e0b692d05f117fd49d2485056315d9" + "wasm32": "0cfbd31c5b125cdc4ed2a0d4116ea69c94cd5eb3dabff3045b7db3b21feaec30", + "wasm64": "feca794225fd4c00c2006f2739f396bdbaf96799aa5cc513141c25bd03a4de5c" } }, "dinit": { "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", "cacheKeys": { - "wasm32": "b81a453c2dcd0f838af57a6d3c3f30afddec3605aab599cbba531f9b74e0d8ff", - "wasm64": "fe199c277d863f294d5a0723581e93c41dd20e0569b24dbbc329a3517acfaeb2" + "wasm32": "afabec4ac51bd35f27451be1ab27dbcd29abe2bdc93ee24d7ba39faf65c583c9", + "wasm64": "324b974aed89280168647e7c5cd771468d9ae5cc5878944539eed5af2b7e4a33" } }, "erlang": { "manifestSha256": "475d6037e73a0f1e2f4381067194b2422751206979ea17209e10efa5a2a93bbb", "cacheKeys": { - "wasm32": "452d0aadb894214945f5559c44cd1c1effdb3847a5ccb693150b61a7a60f9ff3", - "wasm64": "d445dd3c5eea63d40841dd208cb7ace237796a63fa0cbaabeaaa7673a4204fa8" + "wasm32": "2e4bcee403f1127d7a0b8eb59c95458e437acce60cdfdc3d3d44ca8d7c7d4f30", + "wasm64": "348df25cdc4695e5a74120f49de746aed4608ff7a1b43eb080f446e27244077c" } }, "erlang-vfs": { "manifestSha256": "15efb74f1825e2b85bedfeb8d98c19fa648c4be39cb9defb65330a8f21eb9141", "cacheKeys": { - "wasm32": "44a626ae5a57adcf86657d51a557c4782add19c3f516da1ebbaa363bc9ba03e1", - "wasm64": "ce71d2d55080554891d044ce7f9e88cbf497cbb20fa4d261d143a8be29783391" + "wasm32": "d01d2df6791faa1befb7f407585f436e908f8977b8f1fe24a1221927873d5ce7", + "wasm64": "bd35dc636e918356ec55b7d704bd16ce0ce32a26b1d3cf9504b847f75e673ca3" } }, "fbdoom": { "manifestSha256": "a00e0d9c84fcdbb3bd95f296cb3422d60b86dcff4c40734eea1bb0bec4c7d902", "cacheKeys": { - "wasm32": "5442a5d8bc65c0436f25c86329dd6ba6c51b96482ebcb1b0cab2d4a69ac1e4ea", - "wasm64": "9b944a9eb195529fb0dd258ac90420e6dbd8d73862989ea7fbd76a2d5fa235ad" + "wasm32": "92097da44dd8a57901cf771311b84d1bb395996c9b9f8abd5c144fc042a4ae40", + "wasm64": "7c546ebe90f363e9277188f557abf6080d811fd434b7cb41de929431fb459210" } }, "file": { "manifestSha256": "7874c1affcbbf2c8ab8c8d4beb087f275af57b5caae6a8947bf5a63a181552b2", "cacheKeys": { - "wasm32": "15c796674ad2487d06c372fc6ed24a78c170ea1d96f28fc725352c69b4ec8300", - "wasm64": "a28ecd569e4c866bc9eb3bf32b675f5b6e300a9c3df8bdc00fa18c35b87e6a7f" + "wasm32": "7860fb07d0c2e45be9c06246b3545f408e3f5e18f3f721a052e84ab6fd5aab55", + "wasm64": "7127826b50f421c41d3d1090ae55b67873b32feb6a0735eac8c89c056c01fa7b" } }, "findutils": { "manifestSha256": "a9969cb102403cef105e758ef602692500fddd75ec96e4e3f168c50094b6c931", "cacheKeys": { - "wasm32": "2ae9e1bdc0a5b6d2b9a3f1c943768d319f8553264702a12f66f9ba8029453f67", - "wasm64": "77c50d234f34d3a139d231f860daf27b5753e0dc2a6660055298f36bf78fd736" + "wasm32": "f8bd60aa473359e98a1138a6329a8113f0d2e867fa427bb81d3cf8089ed1c5d5", + "wasm64": "8f5bee39b36df0b510799fc316190c1a9cdc170997b3aa48aa7b42ede9999610" } }, "gawk": { "manifestSha256": "b788f3de724cd363ea68d08826586ddf544a75fb5dd9df1369ffafe06d8681b7", "cacheKeys": { - "wasm32": "a4ef112a7a00e3fa65b15143127f114794a28cf65cccb1c4bddad97b31b20800", - "wasm64": "3323f45c5c4aaf0ca4ef2f2d157ac634fe8a8cbc3c95566b4105dd8b038a4987" + "wasm32": "e77e891fee5d2107b8326a0dd97f184d3d79faf0d87733c11514bd8889e9d30f", + "wasm64": "ffce14a7187436f4a273b99a9bde4988887296b473e52ba9136b11cdc9c5346d" } }, "git": { "manifestSha256": "c2bc79e62c9a4e840ae94a16af0f41070460eaf3976a990dc60d2db977bee952", "cacheKeys": { - "wasm32": "2d4e9f21749d4a27b8de3de736f8491e8d078ebb0e3509009d53dab44ca27d9d", - "wasm64": "c8e57155e76528cc2ecdee896eda5a7e4536f0386331a22ac5191bb16c521f61" + "wasm32": "b42be77b20c056922c3f8af6c8b40d84ec0e3b24eb9165f46dd7280f5e9d6de0", + "wasm64": "36568ecaa24dda8a6ea6d8f0707fbc769f4d3ce8fe24f75bb319716258443f0e" } }, "grep": { "manifestSha256": "7c61b894807b831c7e8816cb1f39c78e3a69a4ced2fa3d18f0abdf00bf18334b", "cacheKeys": { - "wasm32": "25e15778d49302390f9882880818560d7d48c5bc60d78dd520ac3f7625c394f5", - "wasm64": "79484cdba7276b7581c22eccf81c767be2db9c6bfcb258922c90662cc554ddf2" + "wasm32": "a4ecf6c6a921fb6a7b6cf2e305637df1d6a23e86a3f05c8badcb26f6a9267a56", + "wasm64": "2e1cc9e3a26b9d9251e5ced660de01b17eeea3c0c30c9f71871e0f3e69d56b7d" } }, "gzip": { "manifestSha256": "1485add843bbcd744244e707c353208fe11192b7b248feed1550875d3b75a917", "cacheKeys": { - "wasm32": "83b7c2e0896cb8f2838a21d07b55113e47620a5c1d5cb95fab734c816a7f07fd", - "wasm64": "ebea7061bda3d99ba97176d536e720a8d57c19782eb3e27e0445fa0f7f7d02c0" + "wasm32": "0f67d1d73c59bdae42cbbdb487e328a356909b7ff6b1316bb84824c62cce245e", + "wasm64": "2d00bc905cc74a21a424f742983c45a8c16c595ec19db9bc898a517b586ccdf1" } }, "homebrew-bootstrap": { "manifestSha256": "183004a8385ab5afc388cd43401341b82812c67f97b08ad349ebd7f4dbbebd0f", "cacheKeys": { - "wasm32": "b7181df86393240dd66b4176c0f038a2f3d8a340756542bbe55c1040848c2ed8", - "wasm64": "eeb9e30525706fcee2c1ff59c26c392254cfb21318212b285dbf262ca5c8b3d2" + "wasm32": "9893ff14c81802d32de5f313e79c232c45e803f6e1eee3b751fe128b952cfe81", + "wasm64": "329410b5e4f92231855de007163870ffca6e42c744ce10cc1352a57ca342006f" } }, "icu": { "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", "cacheKeys": { - "wasm32": "5b87a5e6802b617851d5fa4efb16b3762eac06f0f683e2c3bb5887225cb7262f", - "wasm64": "177367152f6f5fc3596e3abd49c44013089a28493c2c1d52bdf1ac708253735c" + "wasm32": "ed82530ca56a059fcb3ded9e7eba665878dd476a4e15fb0c67ec3fbab88ec961", + "wasm64": "2e6e5ec90f438b9949d61d5fb5a00110bb8c4a0ea7d8d81d934e6105fe0f7ba1" } }, "kandelo-sdk": { "manifestSha256": "c081879f1becd855917cff96f17429bcf2b9fd61bf95211eca9ff8fb625bb2eb", "cacheKeys": { - "wasm32": "f4a952253f40457f8a8b855af6980e9deece7a6f3f2110169dd85127e7812953", - "wasm64": "717c80ea22826301e1da6d8ee24228eb2c01c9cfd01c7cdda93cc576a8d411e1" + "wasm32": "58a80626efbbee0e7271150ac3cd20cb54562bd9f2c96b2e5091e46a25b2306b", + "wasm64": "e47d59d838bfd451e68a3c59d78e8769d6bcda8a547586d3f51f6e48d4534616" } }, "kernel": { "manifestSha256": "db1d66db8575562ac7b3e720dbaa06d7dd5eef577d80220ea4167dc2d69b863b", "cacheKeys": { - "wasm32": "30dfa5f78ede4eaf3259a41e60c2b9ed1ea76a68c4e62b6929fb557c6760e3e8", - "wasm64": "ec47fd0885aec989c752326704e0c9df20b0ff42e93d750660f052785956aa5e" + "wasm32": "223146b18c49321399b8008bdac9397c9af91f4bc3f1b60a936a8684bd9f233b", + "wasm64": "eb7ba1b16cc6abd55137760cd0a01ea683d97bfb356899324712e5b450b6d234" } }, "lamp": { "manifestSha256": "250b64635d64f8537178188fb5488dbfd2980d79a1c2ffbf346af67008088ba0", "cacheKeys": { - "wasm32": "7895c6e87025c059b131a332b12763603ec10a945f9aa5b43d16d6ed53e21b60", - "wasm64": "ed6f51cd9a18f7bd8e207b5db8b56e2e1bd06c112a54b7f64e24de7ff2694614" + "wasm32": "d7328fe44911df283fd9d32ca2d1feb1ec90f9154ab0a321ad02a4092591a541", + "wasm64": "e9d8e8262ea4d63aed6956a8eeab6c3cd368f4010625e781ec8d280a20bb49ae" } }, "less": { "manifestSha256": "996d61545cfe83dcb663a33e8763e0edbd4db4a605fd69a91b243cf79b1f9b17", "cacheKeys": { - "wasm32": "ebfbcb7880115cc263c2c44c2d82f7808c71fd564f60965d4c25ef4533f831ee", - "wasm64": "b2e405f8a4ed93a5a3fe3eabfb2233acf9b7f4f4531d464c34f9908719146328" + "wasm32": "bbae9262108e82a170c06628207705bb64d55794cdc97969b71e1f23c4c1da98", + "wasm64": "3c0ea075f180db83e6f6187c450fecf5ea8ffcda17866b0cd952548fc0ca37b1" } }, "libcurl": { "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", "cacheKeys": { - "wasm32": "086705244733c6e7d585c44fc921eba478d0508902d91c2a83246a7d4952874f", - "wasm64": "52d162d098883374cf8da7f7575603ab8d480a0d8a4b4e5520ae78f2e4777d88" + "wasm32": "08ad5e096eb7909993a2aa2d75a3fc0282b5112818f9de92d7a21b9db3f7312b", + "wasm64": "b0e39ce50de688397c7acf2a5d492f2c737d31fc66d1dde7fe14b1c5863d2848" } }, "libcxx": { "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", "cacheKeys": { - "wasm32": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f", - "wasm64": "1a456dd94e95867f80ecb9457234d7b758ba5454160a8be1a7cc55ced3739908" + "wasm32": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9", + "wasm64": "e6304f31d7a30e10501b57a82aae97e1bb5f00a00dd8196e9672ffe5b4704d6b" } }, "libiconv": { "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", "cacheKeys": { - "wasm32": "d0e75465fa21c6a4090bc2cc92653dae900f85aa79cae5ca9bf67ee0578b7d1c", - "wasm64": "7c6ae8ff3601fb8f72c76c250e9b266d92502ac3aadd146792ec171951e9bd94" + "wasm32": "8289b6195a813388b7673810c77749e055904b6ce1f80872c87f617e7c9fe26f", + "wasm64": "7470a5ec8cccedb719e250211b527040a4dfc3ae7c6e11a4ee18961a1855d955" } }, "libpng": { "manifestSha256": "79ed5c5c072a0267cce5c7a2fe37d8494571b17e44b952f619e04ec1c4a9db10", "cacheKeys": { - "wasm32": "d18ea038aeb10c81c3a24ca550333669722713cd6c6dd828f5b0c463d91f7ecd", - "wasm64": "86f8d085b084a7f5769c73f614765eefb8ecbc154b3803e8693a1eae3473f1c3" + "wasm32": "ca264fd3613420d6d6545ce77fb9f3dd9e70c505bc8752cdfe7ff09cab8df4b8", + "wasm64": "831654519ecafb66d72a1638da17c59ef4d92728930dd2b6e19284c43fe055ee" } }, "libxml2": { "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", "cacheKeys": { - "wasm32": "0daf9f05a267210322bf2a64a77cb05b1b077fc12b340a531e0a900abd0add92", - "wasm64": "c375c1777915398610208a184698dc92bdac896f4217d2e051cbd26d07290e15" + "wasm32": "a16f2e59186fef56229883d91bd5b09c9fd24ec96137287ecec4fba963500822", + "wasm64": "4af3cc7d9e3fafea01ad834ae7b48c073fbb8277a22c591c724714e3fc166152" } }, "libzip": { "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", "cacheKeys": { - "wasm32": "9fa4575b5833bbf94a6ff9e7931519536dc85ffb2a42f9d019e3e7331a7b50c9", - "wasm64": "d15368badc5154f410557e052f2fdea94c1e940596074e29b18f13bdd12197ce" + "wasm32": "0f052ffc6c568fec083752495f6245511974745f30743d961cbed6296da68f32", + "wasm64": "5083a7fa8eb1204bee34fb9453cd411eec449e83acb1aa1faf560495a7e2044c" } }, "lsof": { "manifestSha256": "cfd199bbe082435f7a2e703e2738a368af1eeb5b76b6e93c376054f21ce94419", "cacheKeys": { - "wasm32": "c934ea1907968b2dd08601165cd9c75533e2d97d480f8f0587f9faf7dd1fc10a", - "wasm64": "330675549efa7766a4b5fd0f6d775e5e37cecd6da674dfbd885b81caa3bf59cd" + "wasm32": "80a6104542b21e23812feb4376257da964060f48f89b33ae23f0f5e4b02ea7de", + "wasm64": "de1cd85864201cc5981f0bc836c48083f691fd9f093eda6ee4a82c9d862a009a" } }, "m4": { "manifestSha256": "9b5838bdde8531079f23a89e135aa3832bbbbb7ad6099dd1503db877d52afcad", "cacheKeys": { - "wasm32": "c700b1d1cf477b97829dfdc7daacaedb4087db777764eadea49b13c2cb7669d0", - "wasm64": "fb83e2f3e2d6bda1257eb11ce9f24569c894b09c3bea57bd5b53459759d8c00e" + "wasm32": "b1f9a8a551e8240c6ceff2fd93866f1a459f2de4d35f7e2e796fafc1decf414f", + "wasm64": "ac3958dfd09aea7b5d1fdb80b5f29e2eacd93ccf5c2c27df28c47a315a4392f8" } }, "make": { "manifestSha256": "62e8b36a6df7e981d191061a5bb7f2db85b2deaf365352a7d590a6c6c06f2b1e", "cacheKeys": { - "wasm32": "f217b621932606018491738a5e93e7d34f2cc48bafd9b807d80454b203235051", - "wasm64": "ead71f93f2c9bc4a3044ac54716da9406546c3cb3ae657c57e02ccf91a8f4509" + "wasm32": "7ffa37065290a8237c7c1aa53d14b19dd993f736628ec2f03372959d57f793e1", + "wasm64": "5636f67b39e72b393128bd128c7fcef9419fd38561ed729009bcdaa6a208cedf" } }, "mariadb": { "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", "cacheKeys": { - "wasm32": "3fa3d08a22b4a8473779430950bbf027b4673c96b36f937bcac7faf8105badc9", - "wasm64": "adbe9a05a3d242dad683a79e017662de9775e1be312fd01a1f18d32b93561e47" + "wasm32": "a337766281607a68326c2c51b7b54e3a4bd1a71a0a1ea81afa52be09c1e0b2be", + "wasm64": "3a4f8b9c13bd52dc2db0f6945ba0ebf78d28806cf9e7b773e8224e2b7c0beb1f" } }, "mariadb-test": { "manifestSha256": "d4ccce161d659a5a87c80fa1117054bbccea870e0d4a46bd8a070d3930ad0e3f", "cacheKeys": { - "wasm32": "50feef6cbd0b15dc2594d22d0eaa40dc349beb55e76900ec2c2ac8324a0ca6c5", - "wasm64": "55c691c94675baa939e8a6cad64787a0e1ef76077c9cba955261be1832c0cfd8" + "wasm32": "e5bffb7916977e035ce9cc59fe417aebe8b17f6df633a4309eb42015d45fac1a", + "wasm64": "c77517cb6f6ac94a314f67a79e93ae7b2660aab926762a9e9a49babbc685e229" } }, "mariadb-vfs": { "manifestSha256": "26e74ac84d89b2a839ed72783437061a23022ef2f88ad0f52b6aacd0442ee1cf", "cacheKeys": { - "wasm32": "98e31fd13a253ab211aeaa1c0ca91d255365d888831a9761273d851c65498543", - "wasm64": "238e701a44e8e8c8cd66ef9cb453e310cfa12d4918cd40983977d81fce716c8b" + "wasm32": "cdef3e378496ba28675f3810196078013dcf9bc50e440cb4aa95695866b5bdec", + "wasm64": "c006ddaad6e3eed8106d674e50db8906eb42aa267acaade1b7b07c0fde2c57ae" } }, "modeset": { "manifestSha256": "36a8683c1c7309701d361c5f77e543a6c1531397b26c72dbb864528c59c42954", "cacheKeys": { - "wasm32": "b9892a9a50b3119cb27c5621d0c41c7c7384eed6ea3213dc09cb19d66330422c", - "wasm64": "dfed1df60b79cd0273abc37b183cedd29d2f62f3192d3347f59f0b41f0a35cde" + "wasm32": "965d4fca032e68491635b0aebe3498afa07a564d0525877142ac53170adf5193", + "wasm64": "3a31c841e8b235ca011c93d94082276ed7efd1b620f218d1be06046973dd3c5d" } }, "msmtpd": { "manifestSha256": "686ff665b5e7907e67618790b1a3eddce2ff47ed665595d17209bdcda1e0b274", "cacheKeys": { - "wasm32": "29b34d2aa3442f7d4a8b2126936735a3606af82831c45e13c36e79930d147a88", - "wasm64": "13484d5a0b0881e773ec2d8a59bb790d69e07798bbe13e1cb064e6ec91bd0a6f" + "wasm32": "382f58897a42df23508ddc227f96d3229141399a510a350647f0ce7cc14e81ac", + "wasm64": "27df17d81b2ac9f200907686194920dfc3ea88c5e0f611bd86df1d45b2c839ba" } }, "nano": { "manifestSha256": "c5a52f0c37e08b475bb815367b1f0d63d0d2b06649d99fe9f461b72d0b9cff51", "cacheKeys": { - "wasm32": "f2e860fe0e0653890e6f20bb984a5b6ffae3aba7e16a52410aa11c5c3e8aa9b1", - "wasm64": "5e8250ca56fd7c6fc2c14ea76f049300bd1663e437d0724a615df644cfcc9db9" + "wasm32": "40f555a2b949327f54a4d779f19a955d7d3c9e7f76c8e21d090720bda5426e9d", + "wasm64": "b32e35f051ae1236d7b0bf255bb2a541d6f888ef5db7da7c9ba37a5400d3673b" } }, "ncurses": { "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", "cacheKeys": { - "wasm32": "21b315a238800c2b0aba33b9e312d672133518d9752fc5627c67c5852cac3095", - "wasm64": "54708d925f7d639c5fdb4b29630353214c213f4dc49314a57f1fbf545c7ff8a1" + "wasm32": "70871736d22780051a9fcd7aa3b7fb9064f75d524d87d47ed3e1653eba5b2651", + "wasm64": "3b03426d42f4d0861c98fcc7fb9f0e6ce6b801c2b5c246210e4d9e72de6c291b" } }, "netcat": { "manifestSha256": "4cf33cd1ab768b3ad0108da8b68c2ff72e98469cd86a0bf2d0da54a7d4f6fa16", "cacheKeys": { - "wasm32": "4a70153037218964d9e135865889287d38bb59a9364cd53889e2d3eaa795654b", - "wasm64": "9e24ff1aad0139288878cc3cdcf1b85d0a2bebf10d89001a4d58e5c961d2d6c1" + "wasm32": "e1d8c6bbe0bcee8911bf65f50ffd3db7b2a2c4cc06b012b688e2a490b717701d", + "wasm64": "376e440cd8c4c61178363f34ea157a6ed0ac7e3519ebf99108eb2788b97eb67b" } }, "nethack": { "manifestSha256": "1a2f12ec2770bd8d40006d6c878a3493b97c71c2bb63ad08c7f6ad99bfd53504", "cacheKeys": { - "wasm32": "baa5470b14814fe2d07abcdfb041336fb215045159d8ac42a1e7410166f52a44", - "wasm64": "8ee996953d6678d3bdb7da526285637848b51fbb6e008fffa5c3b856c75b6779" + "wasm32": "4a3754f3c1f16793ee5887577fd45d23eb9ae77c47bdc1d2f3985ed687003e3a", + "wasm64": "5a7c7bfbcbb550c27a400909b3826f7d59c94b0472dce6678b860b80a1ded6fa" } }, "nethack-browser-bundle": { "manifestSha256": "b8294981da7ce4299dfb271d4aecb90ababa97a4d1acc9b716ae05bbe52beb53", "cacheKeys": { - "wasm32": "c1a9bc10cf611c917af84f1b7e5a7c22556d3abe6f25e9d9715215490f00ff61", - "wasm64": "ddee80e33ed727248be4d461081b17419f9ef741df373b634598c685ab24ec76" + "wasm32": "e549fa92d9ebd9c1bc8f42dbb3a7fc8a189142046ae339d17db38eee0f41bb93", + "wasm64": "3a6fd4676a0631692d7950b198e3cc6092b31ba49b0ef11832b79afcee363a6e" } }, "nginx": { "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", "cacheKeys": { - "wasm32": "d09b3ed121683c8dec908fdcb49e5b852e5da476b06bcaf58158fa7fbd734eb0", - "wasm64": "84e99a7659ddbaa0729526deed0bf077ba1f912b65e026553d5aacaff381344a" + "wasm32": "313a503d66a7a8ad435c9aee08a4b44f332d84197caa6bc43e5d84336e3228ec", + "wasm64": "1d24f1d2790600c2c23789c6e93e73356e0a8fa7168b75a16a78df579ea6784c" } }, "nginx-php-vfs": { "manifestSha256": "97976410cb02f8ba710d856b4ac904bcf976d677b50eefdee39fb64176070d4b", "cacheKeys": { - "wasm32": "03e55aba7d25d8f94becb9254646e49acd77b6e56f39bbaa7acef5934bfc3cf7", - "wasm64": "a5230177423cf83644fdebc49b18712f3cd38c6c6066531033bcb1c8c09a1ce1" + "wasm32": "ce2ad449bb35359fcb68dd95f2aa791c8124ecc51ca64c8e24d5c4bb2693d8ac", + "wasm64": "789cfd7efa85fd6f600c0f00dc18e0beb69c1c55100dc046c636829cfbf3b6b0" } }, "nginx-vfs": { "manifestSha256": "46aa2d3250ac5cd0f102a85c3a36a086c7a50ba1f9e05fa1c2aae3d07ad16f10", "cacheKeys": { - "wasm32": "355355d35471171ee51a3ea30cd70ba4a90fe04753174e046030da5c802f84c8", - "wasm64": "f810eb0d3de4cf4f2e5144d31667d5f35b684697a107e3db1f07692fc811f6f0" + "wasm32": "10929f2b992c5077f555249014e14860b9d6892d71c4025716f97f1d322a8fbc", + "wasm64": "4c9ab0d541e601ef08683e1526f229f1be4ed96bc4a1f77bdc06cebf3277e755" } }, "node": { "manifestSha256": "2131a24dfda8587860e57fc65b573086477d376b065b3b9ca4e1dfe4d79f5790", "cacheKeys": { - "wasm32": "6cc8859cf1fc632ff4da532cd90a2379dc72f538c18f1de71e7526b40251d003", - "wasm64": "749594c4ca21f4604866927e31e5404e4ccb7684ae781974f43a049985b22285" + "wasm32": "2aced04cced76dd3005469ac293751a4994e81de403d3fd06e102f8aa980f35f", + "wasm64": "f09b1db3e1e347b4ed10d08f574e7f599feaae43dacccbc32bf3fb86091d8fca" } }, "node-vfs": { "manifestSha256": "977f219defe57e18017ecc963159df32efdd21c53d6fea05943703d04739d8de", "cacheKeys": { - "wasm32": "7cf996ad121c1c7bb8e72fee19bc493ed828c12e41361c901cca1e2acf58a4ad", - "wasm64": "86a649bea043d2d6fb67d31b1c3c8958a19b75221c5cb1224d43475541a07c7a" + "wasm32": "86879198bf456995df812f95849a1f417cffb50f5179c21d003dc08e3c666805", + "wasm64": "b919b171146119a344c0e96ee0a257b9ecd9661a386955abcb93608f0ae4f8b5" } }, "openssl": { "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", "cacheKeys": { - "wasm32": "438683df9d94eddd952daf0cb9928db0dd4910fdd7ff73a6ed95949854dc9d75", - "wasm64": "dfca720319a67d1be7336201e717001fed5ec83d2b7b552ab3dfc5c24e68bc56" + "wasm32": "23b54aad82b4105ad8f5ea2b66e5c15f30d69455dc4cd28a92a936e87e60914b", + "wasm64": "fb3097dfb43d45ce0c9b184d0701d5f9716a2a5f48ad9641b81d25891a519dba" } }, "pcre2-source": { @@ -354,197 +354,197 @@ "perl": { "manifestSha256": "6cdc4dbc54d0e4008cff41f82ec8918c0e44b4aef23903539c1bc0f538fe3ad2", "cacheKeys": { - "wasm32": "0f6a2264707ab7e996490e4622837995d3fb587496b0821a5397c7b65d30eb28", - "wasm64": "f619e548cc7bb9c21526c00146c13d9ae35bb420bb26ba2db5c2688de4627559" + "wasm32": "f7862f341b5ccecda88730fc4ee4af950e7e488113b4701be0268c80f67e13a0", + "wasm64": "3dfbdf1e0a34a0025571dff4ff54d7bef7032fc4f34b6f00cab06d26675ac9a1" } }, "perl-vfs": { "manifestSha256": "1345cc102bc8c24a6bc276410c00b4fcc99ba5f6adc9b031f882aaa35d53c8bc", "cacheKeys": { - "wasm32": "1ae380c4abae7259272368e5f0e8851436bab4b419af26e1301b63ddd81fa72e", - "wasm64": "f5b228e651cec9077a8526b35a3c10adebb69eeb42e6fff009ddce779c4e8a64" + "wasm32": "1ede74eb16abfadc77060fb09c60ba8e84ce2e10bb07e2fcf9531a5b84d77922", + "wasm64": "c342faee5c8e5f6050cde9f981c5f5f4a06d39ae65d016eac87c892859a6ab6b" } }, "php": { "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", "cacheKeys": { - "wasm32": "0b2a2819b64601e21e9c246dc987cda1592e3448550f3df8bb2f7607a136c082", - "wasm64": "04d03536336a96f57b454354f5c7e32cf27a55aeff287a6f1b36351a55cecf2f" + "wasm32": "c069c011716dd51e586649af498a77fe7f9623c3f87d94517d504d65d59fc7ba", + "wasm64": "63e43ed24fcd90eeef9e671a8f3ccd912f16c48ab6ed24420975eb8dd8465a2a" } }, "posix-utils-lite": { "manifestSha256": "8fd7190b2848ef80143adc7b9268c79e13cd4db3430f7105faf49a4b4407a06f", "cacheKeys": { - "wasm32": "3f3b688a40fc18b4c494053635c53aeff2a90f21592ed7737b54651605a4bcf4", - "wasm64": "626d8dda2f2ca7c5e401d31ea1a32e9a5b5a4e1993b29f261273e79ec5fb28d0" + "wasm32": "cdb66e1b49ac036686b638f39724ec85da465e9eb43e2328d4ccc043a76f8d39", + "wasm64": "4cc2b148b9ba4335dffb29f0f2b4cc55b118c767cd9fdc224fa534c871a81951" } }, "python-vfs": { "manifestSha256": "d1aa99feae65f06c0ca8cf52c2176534b2723fd4ee6eba6e72c205245e1c9c26", "cacheKeys": { - "wasm32": "359fa720ef703a8dbb3f84405cfb0aad8e5dafe6e87698b199f329bc6a11948f", - "wasm64": "ae81bffece157a2e5dd3e2312c87a55bf7b387a06a8c25849c7036e4bc79b77a" + "wasm32": "a375f2d91505ab06716eb34d7283c0e7ff89725c46b8e0d2b49aa127c7af45bb", + "wasm64": "1d65fa01870c6a2e342053d553b38af72e2dd17c75a88032a1130dfe19d3044d" } }, "redis": { "manifestSha256": "151fb507de953ba2ba94b8f881bc7d66b4c741c1359a2f590cc07f9a4cd368a3", "cacheKeys": { - "wasm32": "65c6ae8b1759de4e7cf5e9238b543a6f72e46ddbb056d568655659d62e744760", - "wasm64": "fe4841fbfc9175e38e1ed37d26674d6d8108329c04aeeabeee1c6355158e22b5" + "wasm32": "53d782b84d5df6927c247fd042ab1099c8d70ac38056b6f16d3c81839626c478", + "wasm64": "d1fc566b4ce0b8f30440f179b900ef6a88676415b52a34693ab01abc6b3a1760" } }, "redis-vfs": { "manifestSha256": "234c45c40f94e2a89f98295313b82cb9fce15a412ac829d9e87394e0dc731813", "cacheKeys": { - "wasm32": "e07bc2e3d9383e24d752efcd793c777913c30ff79af8d8f308d81f45799dbf25", - "wasm64": "514a3e14a6370318e3f548a258549252ff6b02939e923fad3c8fe5ea3a8acba2" + "wasm32": "4ce06c0ed98f3ea31feca6b2a876aa60476e0a974c98c51666f827ace19c7e68", + "wasm64": "fe5a28309e798fdb0140e366dd6450235600a6ba6ff9a38ca0c56f7e6600edaa" } }, "rootfs": { "manifestSha256": "0420201025170f48c32949ac62707d4577cdc13a53ad1f01f59d318ec07c8d6e", "cacheKeys": { - "wasm32": "c123756aae930c768a6d4aa763afc7e76fd37def700aefd071fac72a908291ec", - "wasm64": "e3ecffb16ff23e299fcc0cd9e9eeda4ae4d30e0f2026cffa6f1e1d07cb0c9a24" + "wasm32": "7f5e181f3f49fd31c5d6d5a51ee6da8b411760ad6d3bceef6388aeb8a0f3fbb6", + "wasm64": "eb962c76a8e47611c665b30d0746ba80ff0aaaa6e7fa6599e31e2aeed366ea8b" } }, "ruby": { "manifestSha256": "6ea67a246c29cd927a19decbb36ae4c4d478ff6642d2c77e08a6b2cfeb8251d2", "cacheKeys": { - "wasm32": "f029a5c249b4651b80348d6362174acb848a475957e988cf8df057c4a344101b", - "wasm64": "dac5d4d7499a8cd70cd1523a6fef1d55a99fe6ab6c70b3e18780473ae242bd8e" + "wasm32": "0f83557241b795469469b3b9547fdfd1f2b67b45cd35e3cef4aef5d3a30e8786", + "wasm64": "f2895d3b87df6065aa515a8d56f20ad611965bd337f8b0f13c5ca1c49a46c872" } }, "sed": { "manifestSha256": "0c0d82d53907c19825941501f833252cf9adf35ebcebf6786baae28e5eb6958b", "cacheKeys": { - "wasm32": "8772fc5aeaa8aac3ecd57cfdcbfe803db7c62f424e738d789efe438f1b3fcc3f", - "wasm64": "e60b72705bc5a935c96b7a4b94a2eedbc834899da7a125e001f547eb3ad9acb1" + "wasm32": "e3c2e6258fc5d07bb75d188f2efc391db5c1f2c5474e80ff528075cdda67ae85", + "wasm64": "d235406b0fb236c7cf940b8486988c5543b23bef3d787566f07c4062e53606f6" } }, "shell": { "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", "cacheKeys": { - "wasm32": "c8b616ca96ecef41bac1f4d949b483da723edab4976fd2b5b9d47aa11a734613", - "wasm64": "f775c4a901bf07822ab7dd16b9ac0a6230376f5be769c78858e1e01ad697147c" + "wasm32": "a017aa5de279b7f77c0f283db5fb4f89e43e495da413b4634776574e4040e71a", + "wasm64": "3b2f3ff64c91120325f1488266c32a93dd838e7ba8a9a4798843073494faaf6c" } }, "spidermonkey": { "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", "cacheKeys": { - "wasm32": "b1264b3565e9f7bc4268cb38ef5de19052b7833bb48e4b2b19d572f5b2af3833", - "wasm64": "a11fe2b767e31ce058f7802017c2e24276e1d31684470086e4275c165f818967" + "wasm32": "bb965948ea0c564fe96b832a9c819003eb7f0d6c3a9957e9c2e52845c9123f6e", + "wasm64": "f47364607db02da84c2be533396eff7b3baaf8e4c83fc2d5b2eac5dae262c7ae" } }, "spidermonkey-node": { "manifestSha256": "f156c4c5044d2a9447324e7a2a35f8c3e6fa367b3e44f674dcc30ab6b946fae7", "cacheKeys": { - "wasm32": "7120b26b20fe4b60b75021299fd08f5063ca380167decd2d96592c0298354bf4", - "wasm64": "9ff683ca1288110e9f076797475cd9b3ab2435578aaf882bd97309539b588155" + "wasm32": "137fb610878789d6a3fa9cf4c7dea22fca50171bde6bfcec426a47622e4af83d", + "wasm64": "1b451bb5a4e945212e4935e38791600efa97a1a17f12f13d6409415a5d8630e3" } }, "sqlite": { "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", "cacheKeys": { - "wasm32": "2ce2ccd99e2994765e2f2b4b7823a582b9997999a1654d378c31bac8f5e8aa89", - "wasm64": "dd92998048fe185f87775091642d11a79b35562e6ab88db8ea4ac45ba604feb4" + "wasm32": "5b3fda5e074c97b803e622d48b5ab3e87b43230d3f68d668296fb64a27f1568b", + "wasm64": "d4e987f027dcbfdbd590f148d6b55db03bd7f5124c63a5c8d0cf24d2baa109f7" } }, "sqlite-cli": { "manifestSha256": "1cebb768f9473dc58fc6e72c9b24565760aedaec4cbdb29ad43b22a0a5fd7030", "cacheKeys": { - "wasm32": "5f083d2358cbfcdc112e69f5c3929cbaea4dda0c385c27b12470986ef647db4a", - "wasm64": "fd5666f0e9ec7b2bed6b18c544fb0e957afd59b2e119379faaf1e8561728e9c3" + "wasm32": "f5dae689e9c9de8c8ad4fd72f90ee8b4eefd4b10fb2e19478451068b75f565ee", + "wasm64": "668a02a3d79ec15d704f00c1a70851b73a7eeee98d024fd28e2040093c04065c" } }, "tar": { "manifestSha256": "4502355c246362a2035e52aa7b35d274882fad4b4404c03470458baee723ae68", "cacheKeys": { - "wasm32": "33c149eb0dc0bc85afadba8e2e1a1fa4dc6778232f52fb9f86f1402d364b78ac", - "wasm64": "286330aee30dc770c2dabbafc31e7da689d8a776c665e538ea7cbd523bbd5831" + "wasm32": "41dbda340b968a687157d02ec36f3a93c04dc62adcf406e5b388465966bcdefa", + "wasm64": "6180499ea90dbadd916c97a1d616678c7b4addd9f04d2dfb8877a75bb684765f" } }, "tcl": { "manifestSha256": "b98f237f741b11f5f5da55db75530b421755ba4c8a88d314553106faa1cca1ff", "cacheKeys": { - "wasm32": "39eeb88d2870d125a5eb44f93494b760bf4675d03f5b1e3b1a087e301fad359f", - "wasm64": "39227c427426f06c172a8be75c97f34e826c0089d91238511360f9ab2c3eea15" + "wasm32": "48d99083556ccfb2c63736771c11b92e462a40b12d3850cb3ea26aae33826beb", + "wasm64": "ac2c62b8affcfba521f977ba37d466fdf6d0704c32e77f96dc439a7da4822d55" } }, "texlive": { "manifestSha256": "028ada023f8a8f9966c8a28575242a339aaaf0e8870fe4a221986133b381c3c0", "cacheKeys": { - "wasm32": "deb61b52478e6081693b6cac1407439534a90ddb5a286f62d78a07e8c887190a", - "wasm64": "fa5ba86cfaef88c0053d8c0d243134a8332357131d0754090e1599d21d5acc21" + "wasm32": "73cea7cd1849aca34f37ae2eb4c6c841328a17ae38e5c1abeeda4a83323a2361", + "wasm64": "7714c13b1cf7ad9306f7ac6289626e9d9b4cca3715e44dab3384524a8ecdfd10" } }, "unzip": { "manifestSha256": "ef4e5e34258827ab3cbc1930da3fd9514f08f2d7d56c7c3e0d5c495a4d3a20e9", "cacheKeys": { - "wasm32": "2999d9f0cdaa6d26f12a1c5c1051a02d06c13fa170b2361bb6822d16ab276364", - "wasm64": "ce5c801e59d58696b25543e9673d0a4d84224cd4f6e09cb8f2ecbef7bddb7eb5" + "wasm32": "73d6a9db88d94927e2d5bc14d7f79c8eae82b902e890a9bee516cf4196b45046", + "wasm64": "1c98124ac54e8833e5fd324bda257247c304459b6ff7da28c3583983ec80e9e9" } }, "userspace": { "manifestSha256": "221176f2a096dee19bbefd89da5c7f50138ecd92d37ec9d7d6b35dbb25e86924", "cacheKeys": { - "wasm32": "5816f508452249eecc499cfa6c67d7c683ff4ec389f710575ec2012a3a76c3aa", - "wasm64": "ea2f7c0df48c20691cb6bdefaf55144e98d8d1a5253a97f07d7468568a381f0a" + "wasm32": "c5a871390a64931e91a74d28998768a34469670bec9a60563b9eceab70108872", + "wasm64": "c78a43c17ffce7d63e756037f1aab417b0c1290a8562c25cf9ee432e7fa737df" } }, "vim": { "manifestSha256": "c211660ef41d01f95f3fbdf675b74891e897e3f304e04f1bf5586f326007382c", "cacheKeys": { - "wasm32": "5c29e15a62d267a4402222e629c8f5f20fabcd6ae11c27348523ca05c20a81ba", - "wasm64": "79afa8ec7d65d299ab27832ef5578e7860597c6fa4cbb9d7e7970aa567217701" + "wasm32": "f268b7542ce0d20e3e242dfd0bc33206208045fe35a2e9679a92f095afdee7fd", + "wasm64": "1281136757d461966e824e51c63a923420df5e64ed0a88d6660e6dcb5afaa9bf" } }, "vim-browser-bundle": { "manifestSha256": "e31d84057db49c3534381604d0c8f3c7d765e33ede560a7ea7b01a5a8f1aa0d3", "cacheKeys": { - "wasm32": "c7031a9a4a8b27ee47adec035ed9db2149b8190ace6535fe782fc783b506c242", - "wasm64": "8bad00d451eb090356161f993b8cd521bb347bbe1f34e60e21e6a7260af1fe63" + "wasm32": "5569fee9f1d9e9c5efcd112ee55b32774f208f1e000a68fdada86b0a5246684e", + "wasm64": "b4060be2180ae3abecde2eba0e41c229a51800f95edd7aa081a31882e4b750ee" } }, "wget": { "manifestSha256": "61420b6cb1be12471f425b0aebf5aec58c49d0b43d939dd54508b305accbf54e", "cacheKeys": { - "wasm32": "e54400b07b4e30c13c9a4d65b09c86681e42c9bb7dedf7c63df5962f791c1011", - "wasm64": "5cf2e7c0373b368568ee2bf1dd9a0cf796ec6bd9c8823e8453b0bb76e797a5ec" + "wasm32": "dbf745ec60dc75ca96a470f6b07db9b239b3ae518ca75a897dff3346388d4358", + "wasm64": "aa368e415c9fb3c1f53e151c2aa33315c0d8746d047632a705d3bcfce67d8c5b" } }, "wordpress": { "manifestSha256": "36465e0596a06e855a8524eecfd87da98643bc13b37ee12939f5aab44bf33418", "cacheKeys": { - "wasm32": "53d9e1c6dec85a414423ada333f775228f53368f15c62ec32514f3514d33cbf2", - "wasm64": "f8ae3cd9ea1a8a77a1a578e19964f270189cf69d5f87b8e3e3ed8c25e1dd014a" + "wasm32": "64cef76ee3b8bd1926c4f26fb84c11abb98a97737e62e739da80544da35af131", + "wasm64": "efe17013e08bb79ec2af1056febf070f87e2f5f5b20ae4ec86e90956add5e20d" } }, "xz": { "manifestSha256": "8707d35b8647b1af8fa165dcb6ce7b2a6a007e19ab2daca2680c39395864a737", "cacheKeys": { - "wasm32": "27fb8caf7f527257a7ea22fadca00656a4e882b69a717449f862c09d008e3253", - "wasm64": "3b7268bbca08f76cc49f0378505b9c8c049296c346ce1deeb1b9ba1a77c8d11e" + "wasm32": "2fbd8cf66a89cf6f38fff3f23ac0bcf5bed0358acfaf7440d29f9a5dfc68064d", + "wasm64": "ed0acf53fb6bc530d55cdb177432edfa626ac0fb71b12f24df8a16184a978c32" } }, "zip": { "manifestSha256": "9c9cfa8220aaeaea26736b55f7cd0a7517b8853c07f9f0d241ad67dd43592e36", "cacheKeys": { - "wasm32": "e4759f4fd4530df51be9219fecff09bf45bd273b974673ace48ca93e7b0a258e", - "wasm64": "f49f432b7c6ee80f2bb7bc458985dca5dd0f5ba53dda5823e80d4ddb02877695" + "wasm32": "fea91b2310de8577c77761a062d1ba542223df19ca4bbc2ed6df8479a5e7837c", + "wasm64": "cf1b53d4fc9500e29db204a4275fd72da2abb7d893d766ea163b253a26007ba7" } }, "zlib": { "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", "cacheKeys": { - "wasm32": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037", - "wasm64": "10047c02b14e17debfbf751f20765bcd477c6f99766a3c0bdd7cec75b20486d2" + "wasm32": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2", + "wasm64": "f13fdf9a989ce6368e3ddef149979bb301f6fd4534db061e880f1091c39e7d94" } }, "zstd": { "manifestSha256": "89f266938c2c253700a838e6352c9de66c976c84883325aaa979f72b732357e4", "cacheKeys": { - "wasm32": "b2d9b6d6c02ae09990083aac08b2b774fc6885ce54edf88420cf633d816aac97", - "wasm64": "87f48799e0c7f8e59bbb4d2b0408ab5cc7993ffccc1cf676fe0eddcdc4dba11f" + "wasm32": "a8ccfb874b8b263391a73976b9fa0592c0826a8ee6f0347bcf8ed2cdb9b2e9ed", + "wasm64": "ab5d9613da8725d9be28cac5720bd6a01af41d4af929055b8b142f30c8c1914d" } } }, @@ -555,14 +555,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b23ed586d38fc7aaf6a4da4acdb9663f99b18431ea9268737524d312e69ed18c" + "wasm32": "3bb0b25c26be9fa2fac163fb9f5853958cb1a67d7a6bc6249b2af9e49ba5a63b" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "21b315a238800c2b0aba33b9e312d672133518d9752fc5627c67c5852cac3095" + "cacheKey": "70871736d22780051a9fcd7aa3b7fb9064f75d524d87d47ed3e1653eba5b2651" } ] }, @@ -582,7 +582,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "fcb2a8e63865ec30a3ce397d368715ff51be01a2dd3268897a71be324bb7fed6" + "wasm32": "b088a88d58385a6cadd463fbbb94944c52790205c08bdf463fbfd5e496fa430e" }, "dependencyClosures": { "wasm32": [] @@ -603,7 +603,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "d76042b18566555fcdd155cd6f93cd4f800464b03701033678433b7a01aaf28b" + "wasm32": "9fa1eb2f8edc0ab1f0a983b603cf8c08c03867d429a73bc2994b9bc001646538" }, "dependencyClosures": { "wasm32": [] @@ -624,7 +624,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "5e780a3a00fdfeb141fd21e895b4cc9005970a0ca7f87d422f1acb072ae7fc4e" + "wasm32": "88e8aec625b11e1cf53112017c6b12206c3fb5d4c495bea02297ae4c3a45db1f" }, "dependencyClosures": { "wasm32": [] @@ -645,14 +645,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "08c8e564afadabf660259f5030bd9711356a4245c317315853bb6b0a6d904b55" + "wasm32": "a0ef6affbcfb19fc83326cb35501026e63b93f7849329d6ce190cf197eddce41" }, "dependencyClosures": { "wasm32": [ { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037" + "cacheKey": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2" } ] }, @@ -679,19 +679,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f79be2fb854ff7855444354b36736b904f7c34279c098f14af6a779f1b5499ac" + "wasm32": "3eb3d0113cc00ca53713eab7c2c68646a77484c332eb82ddd6389ac41d1568cd" }, "dependencyClosures": { "wasm32": [ { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "438683df9d94eddd952daf0cb9928db0dd4910fdd7ff73a6ed95949854dc9d75" + "cacheKey": "23b54aad82b4105ad8f5ea2b66e5c15f30d69455dc4cd28a92a936e87e60914b" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037" + "cacheKey": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2" } ] }, @@ -711,7 +711,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "4af6e234e2447445d4f18882a2d7bb8d9d4fbd67ee258ef88d661140749109b3" + "wasm32": "292795420bea85f6f35af3a2399696e790b7ae22af2385c440e79751d5e8e9ad" }, "dependencyClosures": { "wasm32": [] @@ -732,7 +732,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "6b75f917e8dad9908406972a72f06ed67fe9f31813bedc70b9bf0bc078b0244e" + "wasm32": "0cfbd31c5b125cdc4ed2a0d4116ea69c94cd5eb3dabff3045b7db3b21feaec30" }, "dependencyClosures": { "wasm32": [] @@ -774,14 +774,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b81a453c2dcd0f838af57a6d3c3f30afddec3605aab599cbba531f9b74e0d8ff" + "wasm32": "afabec4ac51bd35f27451be1ab27dbcd29abe2bdc93ee24d7ba39faf65c583c9" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" + "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" } ] }, @@ -815,7 +815,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "452d0aadb894214945f5559c44cd1c1effdb3847a5ccb693150b61a7a60f9ff3" + "wasm32": "2e4bcee403f1127d7a0b8eb59c95458e437acce60cdfdc3d3d44ca8d7c7d4f30" }, "dependencyClosures": { "wasm32": [] @@ -843,14 +843,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "44a626ae5a57adcf86657d51a557c4782add19c3f516da1ebbaa363bc9ba03e1" + "wasm32": "d01d2df6791faa1befb7f407585f436e908f8977b8f1fe24a1221927873d5ce7" }, "dependencyClosures": { "wasm32": [ { "packageName": "erlang", "manifestSha256": "475d6037e73a0f1e2f4381067194b2422751206979ea17209e10efa5a2a93bbb", - "cacheKey": "452d0aadb894214945f5559c44cd1c1effdb3847a5ccb693150b61a7a60f9ff3" + "cacheKey": "2e4bcee403f1127d7a0b8eb59c95458e437acce60cdfdc3d3d44ca8d7c7d4f30" } ] }, @@ -870,7 +870,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "5442a5d8bc65c0436f25c86329dd6ba6c51b96482ebcb1b0cab2d4a69ac1e4ea" + "wasm32": "92097da44dd8a57901cf771311b84d1bb395996c9b9f8abd5c144fc042a4ae40" }, "dependencyClosures": { "wasm32": [] @@ -891,7 +891,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "15c796674ad2487d06c372fc6ed24a78c170ea1d96f28fc725352c69b4ec8300" + "wasm32": "7860fb07d0c2e45be9c06246b3545f408e3f5e18f3f721a052e84ab6fd5aab55" }, "dependencyClosures": { "wasm32": [] @@ -919,7 +919,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2ae9e1bdc0a5b6d2b9a3f1c943768d319f8553264702a12f66f9ba8029453f67" + "wasm32": "f8bd60aa473359e98a1138a6329a8113f0d2e867fa427bb81d3cf8089ed1c5d5" }, "dependencyClosures": { "wasm32": [] @@ -947,7 +947,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a4ef112a7a00e3fa65b15143127f114794a28cf65cccb1c4bddad97b31b20800" + "wasm32": "e77e891fee5d2107b8326a0dd97f184d3d79faf0d87733c11514bd8889e9d30f" }, "dependencyClosures": { "wasm32": [] @@ -968,7 +968,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2d4e9f21749d4a27b8de3de736f8491e8d078ebb0e3509009d53dab44ca27d9d" + "wasm32": "b42be77b20c056922c3f8af6c8b40d84ec0e3b24eb9165f46dd7280f5e9d6de0" }, "dependencyClosures": { "wasm32": [] @@ -996,7 +996,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "25e15778d49302390f9882880818560d7d48c5bc60d78dd520ac3f7625c394f5" + "wasm32": "a4ecf6c6a921fb6a7b6cf2e305637df1d6a23e86a3f05c8badcb26f6a9267a56" }, "dependencyClosures": { "wasm32": [] @@ -1017,7 +1017,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "83b7c2e0896cb8f2838a21d07b55113e47620a5c1d5cb95fab734c816a7f07fd" + "wasm32": "0f67d1d73c59bdae42cbbdb487e328a356909b7ff6b1316bb84824c62cce245e" }, "dependencyClosures": { "wasm32": [] @@ -1038,7 +1038,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b7181df86393240dd66b4176c0f038a2f3d8a340756542bbe55c1040848c2ed8" + "wasm32": "9893ff14c81802d32de5f313e79c232c45e803f6e1eee3b751fe128b952cfe81" }, "dependencyClosures": { "wasm32": [] @@ -1066,14 +1066,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f4a952253f40457f8a8b855af6980e9deece7a6f3f2110169dd85127e7812953" + "wasm32": "58a80626efbbee0e7271150ac3cd20cb54562bd9f2c96b2e5091e46a25b2306b" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" + "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" } ] }, @@ -1093,64 +1093,64 @@ "wasm32" ], "cacheKeys": { - "wasm32": "7895c6e87025c059b131a332b12763603ec10a945f9aa5b43d16d6ed53e21b60" + "wasm32": "d7328fe44911df283fd9d32ca2d1feb1ec90f9154ab0a321ad02a4092591a541" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "b81a453c2dcd0f838af57a6d3c3f30afddec3605aab599cbba531f9b74e0d8ff" + "cacheKey": "afabec4ac51bd35f27451be1ab27dbcd29abe2bdc93ee24d7ba39faf65c583c9" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "5b87a5e6802b617851d5fa4efb16b3762eac06f0f683e2c3bb5887225cb7262f" + "cacheKey": "ed82530ca56a059fcb3ded9e7eba665878dd476a4e15fb0c67ec3fbab88ec961" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "086705244733c6e7d585c44fc921eba478d0508902d91c2a83246a7d4952874f" + "cacheKey": "08ad5e096eb7909993a2aa2d75a3fc0282b5112818f9de92d7a21b9db3f7312b" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" + "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "d0e75465fa21c6a4090bc2cc92653dae900f85aa79cae5ca9bf67ee0578b7d1c" + "cacheKey": "8289b6195a813388b7673810c77749e055904b6ce1f80872c87f617e7c9fe26f" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "0daf9f05a267210322bf2a64a77cb05b1b077fc12b340a531e0a900abd0add92" + "cacheKey": "a16f2e59186fef56229883d91bd5b09c9fd24ec96137287ecec4fba963500822" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "9fa4575b5833bbf94a6ff9e7931519536dc85ffb2a42f9d019e3e7331a7b50c9" + "cacheKey": "0f052ffc6c568fec083752495f6245511974745f30743d961cbed6296da68f32" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "3fa3d08a22b4a8473779430950bbf027b4673c96b36f937bcac7faf8105badc9" + "cacheKey": "a337766281607a68326c2c51b7b54e3a4bd1a71a0a1ea81afa52be09c1e0b2be" }, { "packageName": "msmtpd", "manifestSha256": "09de04a422ddf631a29a259d816e081f8d317d1077808ede55d8c500baae748f", - "cacheKey": "29b34d2aa3442f7d4a8b2126936735a3606af82831c45e13c36e79930d147a88" + "cacheKey": "382f58897a42df23508ddc227f96d3229141399a510a350647f0ce7cc14e81ac" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "d09b3ed121683c8dec908fdcb49e5b852e5da476b06bcaf58158fa7fbd734eb0" + "cacheKey": "313a503d66a7a8ad435c9aee08a4b44f332d84197caa6bc43e5d84336e3228ec" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "438683df9d94eddd952daf0cb9928db0dd4910fdd7ff73a6ed95949854dc9d75" + "cacheKey": "23b54aad82b4105ad8f5ea2b66e5c15f30d69455dc4cd28a92a936e87e60914b" }, { "packageName": "pcre2-source", @@ -1160,22 +1160,22 @@ { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "0b2a2819b64601e21e9c246dc987cda1592e3448550f3df8bb2f7607a136c082" + "cacheKey": "c069c011716dd51e586649af498a77fe7f9623c3f87d94517d504d65d59fc7ba" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "c8b616ca96ecef41bac1f4d949b483da723edab4976fd2b5b9d47aa11a734613" + "cacheKey": "a017aa5de279b7f77c0f283db5fb4f89e43e495da413b4634776574e4040e71a" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "2ce2ccd99e2994765e2f2b4b7823a582b9997999a1654d378c31bac8f5e8aa89" + "cacheKey": "5b3fda5e074c97b803e622d48b5ab3e87b43230d3f68d668296fb64a27f1568b" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037" + "cacheKey": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2" } ] }, @@ -1195,7 +1195,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ebfbcb7880115cc263c2c44c2d82f7808c71fd564f60965d4c25ef4533f831ee" + "wasm32": "bbae9262108e82a170c06628207705bb64d55794cdc97969b71e1f23c4c1da98" }, "dependencyClosures": { "wasm32": [] @@ -1216,7 +1216,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c934ea1907968b2dd08601165cd9c75533e2d97d480f8f0587f9faf7dd1fc10a" + "wasm32": "80a6104542b21e23812feb4376257da964060f48f89b33ae23f0f5e4b02ea7de" }, "dependencyClosures": { "wasm32": [] @@ -1237,7 +1237,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c700b1d1cf477b97829dfdc7daacaedb4087db777764eadea49b13c2cb7669d0" + "wasm32": "b1f9a8a551e8240c6ceff2fd93866f1a459f2de4d35f7e2e796fafc1decf414f" }, "dependencyClosures": { "wasm32": [] @@ -1258,7 +1258,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f217b621932606018491738a5e93e7d34f2cc48bafd9b807d80454b203235051" + "wasm32": "7ffa37065290a8237c7c1aa53d14b19dd993f736628ec2f03372959d57f793e1" }, "dependencyClosures": { "wasm32": [] @@ -1280,15 +1280,15 @@ "wasm64" ], "cacheKeys": { - "wasm32": "3fa3d08a22b4a8473779430950bbf027b4673c96b36f937bcac7faf8105badc9", - "wasm64": "adbe9a05a3d242dad683a79e017662de9775e1be312fd01a1f18d32b93561e47" + "wasm32": "a337766281607a68326c2c51b7b54e3a4bd1a71a0a1ea81afa52be09c1e0b2be", + "wasm64": "3a4f8b9c13bd52dc2db0f6945ba0ebf78d28806cf9e7b773e8224e2b7c0beb1f" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" + "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" }, { "packageName": "pcre2-source", @@ -1300,7 +1300,7 @@ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "1a456dd94e95867f80ecb9457234d7b758ba5454160a8be1a7cc55ced3739908" + "cacheKey": "e6304f31d7a30e10501b57a82aae97e1bb5f00a00dd8196e9672ffe5b4704d6b" }, { "packageName": "pcre2-source", @@ -1332,34 +1332,34 @@ "wasm32" ], "cacheKeys": { - "wasm32": "50feef6cbd0b15dc2594d22d0eaa40dc349beb55e76900ec2c2ac8324a0ca6c5" + "wasm32": "e5bffb7916977e035ce9cc59fe417aebe8b17f6df633a4309eb42015d45fac1a" }, "dependencyClosures": { "wasm32": [ { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "5e780a3a00fdfeb141fd21e895b4cc9005970a0ca7f87d422f1acb072ae7fc4e" + "cacheKey": "88e8aec625b11e1cf53112017c6b12206c3fb5d4c495bea02297ae4c3a45db1f" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "4af6e234e2447445d4f18882a2d7bb8d9d4fbd67ee258ef88d661140749109b3" + "cacheKey": "292795420bea85f6f35af3a2399696e790b7ae22af2385c440e79751d5e8e9ad" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "b81a453c2dcd0f838af57a6d3c3f30afddec3605aab599cbba531f9b74e0d8ff" + "cacheKey": "afabec4ac51bd35f27451be1ab27dbcd29abe2bdc93ee24d7ba39faf65c583c9" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" + "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "3fa3d08a22b4a8473779430950bbf027b4673c96b36f937bcac7faf8105badc9" + "cacheKey": "a337766281607a68326c2c51b7b54e3a4bd1a71a0a1ea81afa52be09c1e0b2be" }, { "packageName": "pcre2-source", @@ -1385,35 +1385,35 @@ "wasm64" ], "cacheKeys": { - "wasm32": "98e31fd13a253ab211aeaa1c0ca91d255365d888831a9761273d851c65498543", - "wasm64": "238e701a44e8e8c8cd66ef9cb453e310cfa12d4918cd40983977d81fce716c8b" + "wasm32": "cdef3e378496ba28675f3810196078013dcf9bc50e440cb4aa95695866b5bdec", + "wasm64": "c006ddaad6e3eed8106d674e50db8906eb42aa267acaade1b7b07c0fde2c57ae" }, "dependencyClosures": { "wasm32": [ { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "5e780a3a00fdfeb141fd21e895b4cc9005970a0ca7f87d422f1acb072ae7fc4e" + "cacheKey": "88e8aec625b11e1cf53112017c6b12206c3fb5d4c495bea02297ae4c3a45db1f" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "4af6e234e2447445d4f18882a2d7bb8d9d4fbd67ee258ef88d661140749109b3" + "cacheKey": "292795420bea85f6f35af3a2399696e790b7ae22af2385c440e79751d5e8e9ad" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "b81a453c2dcd0f838af57a6d3c3f30afddec3605aab599cbba531f9b74e0d8ff" + "cacheKey": "afabec4ac51bd35f27451be1ab27dbcd29abe2bdc93ee24d7ba39faf65c583c9" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" + "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "3fa3d08a22b4a8473779430950bbf027b4673c96b36f937bcac7faf8105badc9" + "cacheKey": "a337766281607a68326c2c51b7b54e3a4bd1a71a0a1ea81afa52be09c1e0b2be" }, { "packageName": "pcre2-source", @@ -1425,27 +1425,27 @@ { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "c898f50e143b676de8181c94482f29f4afe421585d0f1db05770fd12f55f54eb" + "cacheKey": "44ff689fd0db89fcec6c8006e243c7eef825775f93c054b2c116a31f49584088" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "2dd53c014af59fc455b6a86abbd034e7cbce505b1d3a8cc55877691446c46c5b" + "cacheKey": "9de9e85d91e122625a02db27626c24ce1051077fd908bc6545177814c7a78d0e" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "fe199c277d863f294d5a0723581e93c41dd20e0569b24dbbc329a3517acfaeb2" + "cacheKey": "324b974aed89280168647e7c5cd771468d9ae5cc5878944539eed5af2b7e4a33" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "1a456dd94e95867f80ecb9457234d7b758ba5454160a8be1a7cc55ced3739908" + "cacheKey": "e6304f31d7a30e10501b57a82aae97e1bb5f00a00dd8196e9672ffe5b4704d6b" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "adbe9a05a3d242dad683a79e017662de9775e1be312fd01a1f18d32b93561e47" + "cacheKey": "3a4f8b9c13bd52dc2db0f6945ba0ebf78d28806cf9e7b773e8224e2b7c0beb1f" }, { "packageName": "pcre2-source", @@ -1470,7 +1470,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b9892a9a50b3119cb27c5621d0c41c7c7384eed6ea3213dc09cb19d66330422c" + "wasm32": "965d4fca032e68491635b0aebe3498afa07a564d0525877142ac53170adf5193" }, "dependencyClosures": { "wasm32": [] @@ -1491,7 +1491,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "29b34d2aa3442f7d4a8b2126936735a3606af82831c45e13c36e79930d147a88" + "wasm32": "382f58897a42df23508ddc227f96d3229141399a510a350647f0ce7cc14e81ac" }, "dependencyClosures": { "wasm32": [] @@ -1512,7 +1512,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f2e860fe0e0653890e6f20bb984a5b6ffae3aba7e16a52410aa11c5c3e8aa9b1" + "wasm32": "40f555a2b949327f54a4d779f19a955d7d3c9e7f76c8e21d090720bda5426e9d" }, "dependencyClosures": { "wasm32": [] @@ -1533,7 +1533,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "21b315a238800c2b0aba33b9e312d672133518d9752fc5627c67c5852cac3095" + "wasm32": "70871736d22780051a9fcd7aa3b7fb9064f75d524d87d47ed3e1653eba5b2651" }, "dependencyClosures": { "wasm32": [] @@ -1617,7 +1617,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "4a70153037218964d9e135865889287d38bb59a9364cd53889e2d3eaa795654b" + "wasm32": "e1d8c6bbe0bcee8911bf65f50ffd3db7b2a2c4cc06b012b688e2a490b717701d" }, "dependencyClosures": { "wasm32": [] @@ -1638,14 +1638,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "baa5470b14814fe2d07abcdfb041336fb215045159d8ac42a1e7410166f52a44" + "wasm32": "4a3754f3c1f16793ee5887577fd45d23eb9ae77c47bdc1d2f3985ed687003e3a" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "21b315a238800c2b0aba33b9e312d672133518d9752fc5627c67c5852cac3095" + "cacheKey": "70871736d22780051a9fcd7aa3b7fb9064f75d524d87d47ed3e1653eba5b2651" } ] }, @@ -1665,19 +1665,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c1a9bc10cf611c917af84f1b7e5a7c22556d3abe6f25e9d9715215490f00ff61" + "wasm32": "e549fa92d9ebd9c1bc8f42dbb3a7fc8a189142046ae339d17db38eee0f41bb93" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "21b315a238800c2b0aba33b9e312d672133518d9752fc5627c67c5852cac3095" + "cacheKey": "70871736d22780051a9fcd7aa3b7fb9064f75d524d87d47ed3e1653eba5b2651" }, { "packageName": "nethack", "manifestSha256": "1a2f12ec2770bd8d40006d6c878a3493b97c71c2bb63ad08c7f6ad99bfd53504", - "cacheKey": "baa5470b14814fe2d07abcdfb041336fb215045159d8ac42a1e7410166f52a44" + "cacheKey": "4a3754f3c1f16793ee5887577fd45d23eb9ae77c47bdc1d2f3985ed687003e3a" } ] }, @@ -1697,7 +1697,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "d09b3ed121683c8dec908fdcb49e5b852e5da476b06bcaf58158fa7fbd734eb0" + "wasm32": "313a503d66a7a8ad435c9aee08a4b44f332d84197caa6bc43e5d84336e3228ec" }, "dependencyClosures": { "wasm32": [] @@ -1718,79 +1718,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "03e55aba7d25d8f94becb9254646e49acd77b6e56f39bbaa7acef5934bfc3cf7" + "wasm32": "ce2ad449bb35359fcb68dd95f2aa791c8124ecc51ca64c8e24d5c4bb2693d8ac" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "b81a453c2dcd0f838af57a6d3c3f30afddec3605aab599cbba531f9b74e0d8ff" + "cacheKey": "afabec4ac51bd35f27451be1ab27dbcd29abe2bdc93ee24d7ba39faf65c583c9" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "5b87a5e6802b617851d5fa4efb16b3762eac06f0f683e2c3bb5887225cb7262f" + "cacheKey": "ed82530ca56a059fcb3ded9e7eba665878dd476a4e15fb0c67ec3fbab88ec961" }, { "packageName": "kernel", "manifestSha256": "db1d66db8575562ac7b3e720dbaa06d7dd5eef577d80220ea4167dc2d69b863b", - "cacheKey": "30dfa5f78ede4eaf3259a41e60c2b9ed1ea76a68c4e62b6929fb557c6760e3e8" + "cacheKey": "223146b18c49321399b8008bdac9397c9af91f4bc3f1b60a936a8684bd9f233b" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "086705244733c6e7d585c44fc921eba478d0508902d91c2a83246a7d4952874f" + "cacheKey": "08ad5e096eb7909993a2aa2d75a3fc0282b5112818f9de92d7a21b9db3f7312b" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" + "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "d0e75465fa21c6a4090bc2cc92653dae900f85aa79cae5ca9bf67ee0578b7d1c" + "cacheKey": "8289b6195a813388b7673810c77749e055904b6ce1f80872c87f617e7c9fe26f" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "0daf9f05a267210322bf2a64a77cb05b1b077fc12b340a531e0a900abd0add92" + "cacheKey": "a16f2e59186fef56229883d91bd5b09c9fd24ec96137287ecec4fba963500822" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "9fa4575b5833bbf94a6ff9e7931519536dc85ffb2a42f9d019e3e7331a7b50c9" + "cacheKey": "0f052ffc6c568fec083752495f6245511974745f30743d961cbed6296da68f32" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "d09b3ed121683c8dec908fdcb49e5b852e5da476b06bcaf58158fa7fbd734eb0" + "cacheKey": "313a503d66a7a8ad435c9aee08a4b44f332d84197caa6bc43e5d84336e3228ec" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "438683df9d94eddd952daf0cb9928db0dd4910fdd7ff73a6ed95949854dc9d75" + "cacheKey": "23b54aad82b4105ad8f5ea2b66e5c15f30d69455dc4cd28a92a936e87e60914b" }, { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "0b2a2819b64601e21e9c246dc987cda1592e3448550f3df8bb2f7607a136c082" + "cacheKey": "c069c011716dd51e586649af498a77fe7f9623c3f87d94517d504d65d59fc7ba" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "c8b616ca96ecef41bac1f4d949b483da723edab4976fd2b5b9d47aa11a734613" + "cacheKey": "a017aa5de279b7f77c0f283db5fb4f89e43e495da413b4634776574e4040e71a" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "2ce2ccd99e2994765e2f2b4b7823a582b9997999a1654d378c31bac8f5e8aa89" + "cacheKey": "5b3fda5e074c97b803e622d48b5ab3e87b43230d3f68d668296fb64a27f1568b" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037" + "cacheKey": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2" } ] }, @@ -1810,29 +1810,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "355355d35471171ee51a3ea30cd70ba4a90fe04753174e046030da5c802f84c8" + "wasm32": "10929f2b992c5077f555249014e14860b9d6892d71c4025716f97f1d322a8fbc" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "b81a453c2dcd0f838af57a6d3c3f30afddec3605aab599cbba531f9b74e0d8ff" + "cacheKey": "afabec4ac51bd35f27451be1ab27dbcd29abe2bdc93ee24d7ba39faf65c583c9" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" + "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "d09b3ed121683c8dec908fdcb49e5b852e5da476b06bcaf58158fa7fbd734eb0" + "cacheKey": "313a503d66a7a8ad435c9aee08a4b44f332d84197caa6bc43e5d84336e3228ec" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "c8b616ca96ecef41bac1f4d949b483da723edab4976fd2b5b9d47aa11a734613" + "cacheKey": "a017aa5de279b7f77c0f283db5fb4f89e43e495da413b4634776574e4040e71a" } ] }, @@ -1852,29 +1852,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "6cc8859cf1fc632ff4da532cd90a2379dc72f538c18f1de71e7526b40251d003" + "wasm32": "2aced04cced76dd3005469ac293751a4994e81de403d3fd06e102f8aa980f35f" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" + "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "438683df9d94eddd952daf0cb9928db0dd4910fdd7ff73a6ed95949854dc9d75" + "cacheKey": "23b54aad82b4105ad8f5ea2b66e5c15f30d69455dc4cd28a92a936e87e60914b" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "b1264b3565e9f7bc4268cb38ef5de19052b7833bb48e4b2b19d572f5b2af3833" + "cacheKey": "bb965948ea0c564fe96b832a9c819003eb7f0d6c3a9957e9c2e52845c9123f6e" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037" + "cacheKey": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2" } ] }, @@ -1894,39 +1894,39 @@ "wasm32" ], "cacheKeys": { - "wasm32": "7cf996ad121c1c7bb8e72fee19bc493ed828c12e41361c901cca1e2acf58a4ad" + "wasm32": "86879198bf456995df812f95849a1f417cffb50f5179c21d003dc08e3c666805" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" + "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" }, { "packageName": "node", "manifestSha256": "2131a24dfda8587860e57fc65b573086477d376b065b3b9ca4e1dfe4d79f5790", - "cacheKey": "6cc8859cf1fc632ff4da532cd90a2379dc72f538c18f1de71e7526b40251d003" + "cacheKey": "2aced04cced76dd3005469ac293751a4994e81de403d3fd06e102f8aa980f35f" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "438683df9d94eddd952daf0cb9928db0dd4910fdd7ff73a6ed95949854dc9d75" + "cacheKey": "23b54aad82b4105ad8f5ea2b66e5c15f30d69455dc4cd28a92a936e87e60914b" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "c8b616ca96ecef41bac1f4d949b483da723edab4976fd2b5b9d47aa11a734613" + "cacheKey": "a017aa5de279b7f77c0f283db5fb4f89e43e495da413b4634776574e4040e71a" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "b1264b3565e9f7bc4268cb38ef5de19052b7833bb48e4b2b19d572f5b2af3833" + "cacheKey": "bb965948ea0c564fe96b832a9c819003eb7f0d6c3a9957e9c2e52845c9123f6e" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037" + "cacheKey": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2" } ] }, @@ -1946,7 +1946,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0f6a2264707ab7e996490e4622837995d3fb587496b0821a5397c7b65d30eb28" + "wasm32": "f7862f341b5ccecda88730fc4ee4af950e7e488113b4701be0268c80f67e13a0" }, "dependencyClosures": { "wasm32": [] @@ -1967,14 +1967,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "1ae380c4abae7259272368e5f0e8851436bab4b419af26e1301b63ddd81fa72e" + "wasm32": "1ede74eb16abfadc77060fb09c60ba8e84ce2e10bb07e2fcf9531a5b84d77922" }, "dependencyClosures": { "wasm32": [ { "packageName": "perl", "manifestSha256": "6cdc4dbc54d0e4008cff41f82ec8918c0e44b4aef23903539c1bc0f538fe3ad2", - "cacheKey": "0f6a2264707ab7e996490e4622837995d3fb587496b0821a5397c7b65d30eb28" + "cacheKey": "f7862f341b5ccecda88730fc4ee4af950e7e488113b4701be0268c80f67e13a0" } ] }, @@ -1994,54 +1994,54 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0b2a2819b64601e21e9c246dc987cda1592e3448550f3df8bb2f7607a136c082" + "wasm32": "c069c011716dd51e586649af498a77fe7f9623c3f87d94517d504d65d59fc7ba" }, "dependencyClosures": { "wasm32": [ { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "5b87a5e6802b617851d5fa4efb16b3762eac06f0f683e2c3bb5887225cb7262f" + "cacheKey": "ed82530ca56a059fcb3ded9e7eba665878dd476a4e15fb0c67ec3fbab88ec961" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "086705244733c6e7d585c44fc921eba478d0508902d91c2a83246a7d4952874f" + "cacheKey": "08ad5e096eb7909993a2aa2d75a3fc0282b5112818f9de92d7a21b9db3f7312b" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" + "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "d0e75465fa21c6a4090bc2cc92653dae900f85aa79cae5ca9bf67ee0578b7d1c" + "cacheKey": "8289b6195a813388b7673810c77749e055904b6ce1f80872c87f617e7c9fe26f" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "0daf9f05a267210322bf2a64a77cb05b1b077fc12b340a531e0a900abd0add92" + "cacheKey": "a16f2e59186fef56229883d91bd5b09c9fd24ec96137287ecec4fba963500822" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "9fa4575b5833bbf94a6ff9e7931519536dc85ffb2a42f9d019e3e7331a7b50c9" + "cacheKey": "0f052ffc6c568fec083752495f6245511974745f30743d961cbed6296da68f32" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "438683df9d94eddd952daf0cb9928db0dd4910fdd7ff73a6ed95949854dc9d75" + "cacheKey": "23b54aad82b4105ad8f5ea2b66e5c15f30d69455dc4cd28a92a936e87e60914b" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "2ce2ccd99e2994765e2f2b4b7823a582b9997999a1654d378c31bac8f5e8aa89" + "cacheKey": "5b3fda5e074c97b803e622d48b5ab3e87b43230d3f68d668296fb64a27f1568b" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037" + "cacheKey": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2" } ] }, @@ -2117,7 +2117,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "3f3b688a40fc18b4c494053635c53aeff2a90f21592ed7737b54651605a4bcf4" + "wasm32": "cdb66e1b49ac036686b638f39724ec85da465e9eb43e2328d4ccc043a76f8d39" }, "dependencyClosures": { "wasm32": [] @@ -2390,19 +2390,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "359fa720ef703a8dbb3f84405cfb0aad8e5dafe6e87698b199f329bc6a11948f" + "wasm32": "a375f2d91505ab06716eb34d7283c0e7ff89725c46b8e0d2b49aa127c7af45bb" }, "dependencyClosures": { "wasm32": [ { "packageName": "cpython", "manifestSha256": "7dd4f446697a73941ec940c2ccba4d53be73fe6947f1e701031a4d0aa964c4ff", - "cacheKey": "08c8e564afadabf660259f5030bd9711356a4245c317315853bb6b0a6d904b55" + "cacheKey": "a0ef6affbcfb19fc83326cb35501026e63b93f7849329d6ce190cf197eddce41" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037" + "cacheKey": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2" } ] }, @@ -2422,7 +2422,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "65c6ae8b1759de4e7cf5e9238b543a6f72e46ddbb056d568655659d62e744760" + "wasm32": "53d782b84d5df6927c247fd042ab1099c8d70ac38056b6f16d3c81839626c478" }, "dependencyClosures": { "wasm32": [] @@ -2450,24 +2450,24 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e07bc2e3d9383e24d752efcd793c777913c30ff79af8d8f308d81f45799dbf25" + "wasm32": "4ce06c0ed98f3ea31feca6b2a876aa60476e0a974c98c51666f827ace19c7e68" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "b81a453c2dcd0f838af57a6d3c3f30afddec3605aab599cbba531f9b74e0d8ff" + "cacheKey": "afabec4ac51bd35f27451be1ab27dbcd29abe2bdc93ee24d7ba39faf65c583c9" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" + "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" }, { "packageName": "redis", "manifestSha256": "151fb507de953ba2ba94b8f881bc7d66b4c741c1359a2f590cc07f9a4cd368a3", - "cacheKey": "65c6ae8b1759de4e7cf5e9238b543a6f72e46ddbb056d568655659d62e744760" + "cacheKey": "53d782b84d5df6927c247fd042ab1099c8d70ac38056b6f16d3c81839626c478" } ] }, @@ -2487,79 +2487,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c123756aae930c768a6d4aa763afc7e76fd37def700aefd071fac72a908291ec" + "wasm32": "7f5e181f3f49fd31c5d6d5a51ee6da8b411760ad6d3bceef6388aeb8a0f3fbb6" }, "dependencyClosures": { "wasm32": [ { "packageName": "bash", "manifestSha256": "6478060f28d430d18a6ebe7c603392d35a41d9bcf6bc74322351d1450b9c5335", - "cacheKey": "b23ed586d38fc7aaf6a4da4acdb9663f99b18431ea9268737524d312e69ed18c" + "cacheKey": "3bb0b25c26be9fa2fac163fb9f5853958cb1a67d7a6bc6249b2af9e49ba5a63b" }, { "packageName": "bc", "manifestSha256": "a65661463bb7047b91ff00153bd99fd962c96e571934ab4e923f5215fb6ecfbd", - "cacheKey": "fcb2a8e63865ec30a3ce397d368715ff51be01a2dd3268897a71be324bb7fed6" + "cacheKey": "b088a88d58385a6cadd463fbbb94944c52790205c08bdf463fbfd5e496fa430e" }, { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "5e780a3a00fdfeb141fd21e895b4cc9005970a0ca7f87d422f1acb072ae7fc4e" + "cacheKey": "88e8aec625b11e1cf53112017c6b12206c3fb5d4c495bea02297ae4c3a45db1f" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "4af6e234e2447445d4f18882a2d7bb8d9d4fbd67ee258ef88d661140749109b3" + "cacheKey": "292795420bea85f6f35af3a2399696e790b7ae22af2385c440e79751d5e8e9ad" }, { "packageName": "diffutils", "manifestSha256": "3a78f0a46bae43ce5ea235c6638c2cbd1d8559b0b62b1fb42443b35968c1aca3", - "cacheKey": "6b75f917e8dad9908406972a72f06ed67fe9f31813bedc70b9bf0bc078b0244e" + "cacheKey": "0cfbd31c5b125cdc4ed2a0d4116ea69c94cd5eb3dabff3045b7db3b21feaec30" }, { "packageName": "file", "manifestSha256": "7874c1affcbbf2c8ab8c8d4beb087f275af57b5caae6a8947bf5a63a181552b2", - "cacheKey": "15c796674ad2487d06c372fc6ed24a78c170ea1d96f28fc725352c69b4ec8300" + "cacheKey": "7860fb07d0c2e45be9c06246b3545f408e3f5e18f3f721a052e84ab6fd5aab55" }, { "packageName": "findutils", "manifestSha256": "cceedf52aea67fbb0da03cfce6f2e9b1a7b5561fa1656c2762b43c651a625d02", - "cacheKey": "2ae9e1bdc0a5b6d2b9a3f1c943768d319f8553264702a12f66f9ba8029453f67" + "cacheKey": "f8bd60aa473359e98a1138a6329a8113f0d2e867fa427bb81d3cf8089ed1c5d5" }, { "packageName": "gawk", "manifestSha256": "a2567b8b0778805e385e1d3a41ac8b5d74aaf9d293b222001b17d09b11e5a0ba", - "cacheKey": "a4ef112a7a00e3fa65b15143127f114794a28cf65cccb1c4bddad97b31b20800" + "cacheKey": "e77e891fee5d2107b8326a0dd97f184d3d79faf0d87733c11514bd8889e9d30f" }, { "packageName": "grep", "manifestSha256": "59270d3bfb33167b32d13246bf855b49308f8c9c0a66d9635c804f702421fbc6", - "cacheKey": "25e15778d49302390f9882880818560d7d48c5bc60d78dd520ac3f7625c394f5" + "cacheKey": "a4ecf6c6a921fb6a7b6cf2e305637df1d6a23e86a3f05c8badcb26f6a9267a56" }, { "packageName": "m4", "manifestSha256": "2c6582d99d6eabfb9da52badf49b899e9fdcba76a728536b25bc3741f3331543", - "cacheKey": "c700b1d1cf477b97829dfdc7daacaedb4087db777764eadea49b13c2cb7669d0" + "cacheKey": "b1f9a8a551e8240c6ceff2fd93866f1a459f2de4d35f7e2e796fafc1decf414f" }, { "packageName": "make", "manifestSha256": "f878d2d730f36a4c6dfe1fff1b4ccc704757ea22d8c4c8ccb95b11cd641e5fed", - "cacheKey": "f217b621932606018491738a5e93e7d34f2cc48bafd9b807d80454b203235051" + "cacheKey": "7ffa37065290a8237c7c1aa53d14b19dd993f736628ec2f03372959d57f793e1" }, { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "21b315a238800c2b0aba33b9e312d672133518d9752fc5627c67c5852cac3095" + "cacheKey": "70871736d22780051a9fcd7aa3b7fb9064f75d524d87d47ed3e1653eba5b2651" }, { "packageName": "posix-utils-lite", "manifestSha256": "8fd7190b2848ef80143adc7b9268c79e13cd4db3430f7105faf49a4b4407a06f", - "cacheKey": "3f3b688a40fc18b4c494053635c53aeff2a90f21592ed7737b54651605a4bcf4" + "cacheKey": "cdb66e1b49ac036686b638f39724ec85da465e9eb43e2328d4ccc043a76f8d39" }, { "packageName": "sed", "manifestSha256": "2a08e9c5dacc5facc8983c1ff35ff2a72db328405e9a1f464cc7ad155c4d08af", - "cacheKey": "8772fc5aeaa8aac3ecd57cfdcbfe803db7c62f424e738d789efe438f1b3fcc3f" + "cacheKey": "e3c2e6258fc5d07bb75d188f2efc391db5c1f2c5474e80ff528075cdda67ae85" } ] }, @@ -2579,14 +2579,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f029a5c249b4651b80348d6362174acb848a475957e988cf8df057c4a344101b" + "wasm32": "0f83557241b795469469b3b9547fdfd1f2b67b45cd35e3cef4aef5d3a30e8786" }, "dependencyClosures": { "wasm32": [ { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037" + "cacheKey": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2" } ] }, @@ -2613,7 +2613,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "8772fc5aeaa8aac3ecd57cfdcbfe803db7c62f424e738d789efe438f1b3fcc3f" + "wasm32": "e3c2e6258fc5d07bb75d188f2efc391db5c1f2c5474e80ff528075cdda67ae85" }, "dependencyClosures": { "wasm32": [] @@ -2634,7 +2634,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c8b616ca96ecef41bac1f4d949b483da723edab4976fd2b5b9d47aa11a734613" + "wasm32": "a017aa5de279b7f77c0f283db5fb4f89e43e495da413b4634776574e4040e71a" }, "dependencyClosures": { "wasm32": [] @@ -2655,24 +2655,24 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b1264b3565e9f7bc4268cb38ef5de19052b7833bb48e4b2b19d572f5b2af3833" + "wasm32": "bb965948ea0c564fe96b832a9c819003eb7f0d6c3a9957e9c2e52845c9123f6e" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" + "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "438683df9d94eddd952daf0cb9928db0dd4910fdd7ff73a6ed95949854dc9d75" + "cacheKey": "23b54aad82b4105ad8f5ea2b66e5c15f30d69455dc4cd28a92a936e87e60914b" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037" + "cacheKey": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2" } ] }, @@ -2692,29 +2692,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "7120b26b20fe4b60b75021299fd08f5063ca380167decd2d96592c0298354bf4" + "wasm32": "137fb610878789d6a3fa9cf4c7dea22fca50171bde6bfcec426a47622e4af83d" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" + "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "438683df9d94eddd952daf0cb9928db0dd4910fdd7ff73a6ed95949854dc9d75" + "cacheKey": "23b54aad82b4105ad8f5ea2b66e5c15f30d69455dc4cd28a92a936e87e60914b" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "b1264b3565e9f7bc4268cb38ef5de19052b7833bb48e4b2b19d572f5b2af3833" + "cacheKey": "bb965948ea0c564fe96b832a9c819003eb7f0d6c3a9957e9c2e52845c9123f6e" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037" + "cacheKey": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2" } ] }, @@ -2734,7 +2734,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "5f083d2358cbfcdc112e69f5c3929cbaea4dda0c385c27b12470986ef647db4a" + "wasm32": "f5dae689e9c9de8c8ad4fd72f90ee8b4eefd4b10fb2e19478451068b75f565ee" }, "dependencyClosures": { "wasm32": [] @@ -2755,7 +2755,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "33c149eb0dc0bc85afadba8e2e1a1fa4dc6778232f52fb9f86f1402d364b78ac" + "wasm32": "41dbda340b968a687157d02ec36f3a93c04dc62adcf406e5b388465966bcdefa" }, "dependencyClosures": { "wasm32": [] @@ -2776,7 +2776,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "39eeb88d2870d125a5eb44f93494b760bf4675d03f5b1e3b1a087e301fad359f" + "wasm32": "48d99083556ccfb2c63736771c11b92e462a40b12d3850cb3ea26aae33826beb" }, "dependencyClosures": { "wasm32": [] @@ -2797,19 +2797,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "deb61b52478e6081693b6cac1407439534a90ddb5a286f62d78a07e8c887190a" + "wasm32": "73cea7cd1849aca34f37ae2eb4c6c841328a17ae38e5c1abeeda4a83323a2361" }, "dependencyClosures": { "wasm32": [ { "packageName": "libpng", "manifestSha256": "79ed5c5c072a0267cce5c7a2fe37d8494571b17e44b952f619e04ec1c4a9db10", - "cacheKey": "d18ea038aeb10c81c3a24ca550333669722713cd6c6dd828f5b0c463d91f7ecd" + "cacheKey": "ca264fd3613420d6d6545ce77fb9f3dd9e70c505bc8752cdfe7ff09cab8df4b8" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037" + "cacheKey": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2" } ] }, @@ -2836,7 +2836,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2999d9f0cdaa6d26f12a1c5c1051a02d06c13fa170b2361bb6822d16ab276364" + "wasm32": "73d6a9db88d94927e2d5bc14d7f79c8eae82b902e890a9bee516cf4196b45046" }, "dependencyClosures": { "wasm32": [] @@ -2857,7 +2857,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "5c29e15a62d267a4402222e629c8f5f20fabcd6ae11c27348523ca05c20a81ba" + "wasm32": "f268b7542ce0d20e3e242dfd0bc33206208045fe35a2e9679a92f095afdee7fd" }, "dependencyClosures": { "wasm32": [] @@ -2878,14 +2878,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c7031a9a4a8b27ee47adec035ed9db2149b8190ace6535fe782fc783b506c242" + "wasm32": "5569fee9f1d9e9c5efcd112ee55b32774f208f1e000a68fdada86b0a5246684e" }, "dependencyClosures": { "wasm32": [ { "packageName": "vim", "manifestSha256": "c211660ef41d01f95f3fbdf675b74891e897e3f304e04f1bf5586f326007382c", - "cacheKey": "5c29e15a62d267a4402222e629c8f5f20fabcd6ae11c27348523ca05c20a81ba" + "cacheKey": "f268b7542ce0d20e3e242dfd0bc33206208045fe35a2e9679a92f095afdee7fd" } ] }, @@ -2905,7 +2905,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e54400b07b4e30c13c9a4d65b09c86681e42c9bb7dedf7c63df5962f791c1011" + "wasm32": "dbf745ec60dc75ca96a470f6b07db9b239b3ae518ca75a897dff3346388d4358" }, "dependencyClosures": { "wasm32": [] @@ -2926,79 +2926,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "53d9e1c6dec85a414423ada333f775228f53368f15c62ec32514f3514d33cbf2" + "wasm32": "64cef76ee3b8bd1926c4f26fb84c11abb98a97737e62e739da80544da35af131" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "b81a453c2dcd0f838af57a6d3c3f30afddec3605aab599cbba531f9b74e0d8ff" + "cacheKey": "afabec4ac51bd35f27451be1ab27dbcd29abe2bdc93ee24d7ba39faf65c583c9" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "5b87a5e6802b617851d5fa4efb16b3762eac06f0f683e2c3bb5887225cb7262f" + "cacheKey": "ed82530ca56a059fcb3ded9e7eba665878dd476a4e15fb0c67ec3fbab88ec961" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "086705244733c6e7d585c44fc921eba478d0508902d91c2a83246a7d4952874f" + "cacheKey": "08ad5e096eb7909993a2aa2d75a3fc0282b5112818f9de92d7a21b9db3f7312b" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "a5e9d1bda0ccb1e964ac99f398f24c0a1cd9328eeb56c80258b1670964f4fb6f" + "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "d0e75465fa21c6a4090bc2cc92653dae900f85aa79cae5ca9bf67ee0578b7d1c" + "cacheKey": "8289b6195a813388b7673810c77749e055904b6ce1f80872c87f617e7c9fe26f" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "0daf9f05a267210322bf2a64a77cb05b1b077fc12b340a531e0a900abd0add92" + "cacheKey": "a16f2e59186fef56229883d91bd5b09c9fd24ec96137287ecec4fba963500822" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "9fa4575b5833bbf94a6ff9e7931519536dc85ffb2a42f9d019e3e7331a7b50c9" + "cacheKey": "0f052ffc6c568fec083752495f6245511974745f30743d961cbed6296da68f32" }, { "packageName": "msmtpd", "manifestSha256": "09de04a422ddf631a29a259d816e081f8d317d1077808ede55d8c500baae748f", - "cacheKey": "29b34d2aa3442f7d4a8b2126936735a3606af82831c45e13c36e79930d147a88" + "cacheKey": "382f58897a42df23508ddc227f96d3229141399a510a350647f0ce7cc14e81ac" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "d09b3ed121683c8dec908fdcb49e5b852e5da476b06bcaf58158fa7fbd734eb0" + "cacheKey": "313a503d66a7a8ad435c9aee08a4b44f332d84197caa6bc43e5d84336e3228ec" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "438683df9d94eddd952daf0cb9928db0dd4910fdd7ff73a6ed95949854dc9d75" + "cacheKey": "23b54aad82b4105ad8f5ea2b66e5c15f30d69455dc4cd28a92a936e87e60914b" }, { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "0b2a2819b64601e21e9c246dc987cda1592e3448550f3df8bb2f7607a136c082" + "cacheKey": "c069c011716dd51e586649af498a77fe7f9623c3f87d94517d504d65d59fc7ba" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "c8b616ca96ecef41bac1f4d949b483da723edab4976fd2b5b9d47aa11a734613" + "cacheKey": "a017aa5de279b7f77c0f283db5fb4f89e43e495da413b4634776574e4040e71a" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "2ce2ccd99e2994765e2f2b4b7823a582b9997999a1654d378c31bac8f5e8aa89" + "cacheKey": "5b3fda5e074c97b803e622d48b5ab3e87b43230d3f68d668296fb64a27f1568b" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "3d79a636e2f4144f57eb364bc8aedee29d010ca2be743290fec34b2bf0c0e037" + "cacheKey": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2" } ] }, @@ -3018,7 +3018,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "27fb8caf7f527257a7ea22fadca00656a4e882b69a717449f862c09d008e3253" + "wasm32": "2fbd8cf66a89cf6f38fff3f23ac0bcf5bed0358acfaf7440d29f9a5dfc68064d" }, "dependencyClosures": { "wasm32": [] @@ -3039,7 +3039,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e4759f4fd4530df51be9219fecff09bf45bd273b974673ace48ca93e7b0a258e" + "wasm32": "fea91b2310de8577c77761a062d1ba542223df19ca4bbc2ed6df8479a5e7837c" }, "dependencyClosures": { "wasm32": [] @@ -3060,7 +3060,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b2d9b6d6c02ae09990083aac08b2b774fc6885ce54edf88420cf633d816aac97" + "wasm32": "a8ccfb874b8b263391a73976b9fa0592c0826a8ee6f0347bcf8ed2cdb9b2e9ed" }, "dependencyClosures": { "wasm32": [] diff --git a/run.sh b/run.sh index f4b44c57d6..e423987e93 100755 --- a/run.sh +++ b/run.sh @@ -268,7 +268,6 @@ KERNEL_REQUIRED_EXPORTS=( kernel_alloc_scratch kernel_blocking_retry_release kernel_blocking_retry_token - kernel_clear_process_metadata kernel_commit_process_exit kernel_create_process kernel_create_process_with_stdio @@ -276,6 +275,9 @@ KERNEL_REQUIRED_EXPORTS=( kernel_exec_prepare kernel_exec_setup_for_thread kernel_fork_process + kernel_get_cwd + kernel_get_dirfd_path + kernel_get_fd_path kernel_get_parent_pid kernel_get_process_exit_signal kernel_get_process_state @@ -295,7 +297,10 @@ KERNEL_REQUIRED_EXPORTS=( kernel_pick_signal_target_tid kernel_pipe_has_readers kernel_posix_timer_fire - kernel_push_process_metadata_entry + kernel_process_metadata_begin + kernel_process_metadata_cancel + kernel_process_metadata_commit + kernel_process_metadata_stage kernel_reap_exited_child kernel_remove_process kernel_semctl_array_bytes diff --git a/scripts/resolve-binary.bundle.mjs b/scripts/resolve-binary.bundle.mjs index e0f842dc5a..3fedc2a78b 100644 --- a/scripts/resolve-binary.bundle.mjs +++ b/scripts/resolve-binary.bundle.mjs @@ -1,13 +1,13 @@ // Generated by scripts/build-resolve-binary-bundle.sh; do not edit. Third-party notices: resolve-binary.bundle.LICENSES.txt -var ra=Object.defineProperty;var nn=(n,e,t)=>()=>{if(t)throw t[0];try{return n&&(e=n(n=0)),e}catch(r){throw t=[r],r}};var Ri=(n,e)=>{for(var t in e)ra(n,t,{get:e[t],enumerable:!0})};import{createRequire as Oc}from"module";function Zo(n,e){return qo(n,{i:2},e&&e.out,e&&e.dictionary)}var Ic,xt,xc,vc,oe,Ot,Rc,Bo,$o,Lc,Uo,xt,Wo,bc,Go,Tc,Vu,Wn,ze,M,nr,ir,M,M,M,M,Ho,M,zc,kc,$n,Ae,Un,Vo,Kr,Pc,me,qo,Nc,Fc,It,Xo,Cc,Mc,Gn=nn(()=>{Ic=Oc("/");try{xt=Ic("worker_threads"),xc=xt.Worker,vc=xt.isMarkedAsUntransferable}catch{}oe=Uint8Array,Ot=Uint16Array,Rc=Int32Array,Bo=new oe([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),$o=new oe([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),Lc=new oe([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),Uo=function(n,e){for(var t=new Ot(31),r=0;r<31;++r)t[r]=e+=1<>1|(M&21845)<<1,ze=(ze&52428)>>2|(ze&13107)<<2,ze=(ze&61680)>>4|(ze&3855)<<4,Wn[M]=((ze&65280)>>8|(ze&255)<<8)>>1;nr=(function(n,e,t){for(var r=n.length,i=0,s=new Ot(e);i>c]=l}else for(a=new Ot(r),i=0;i>15-n[i]);return a}),ir=new oe(288);for(M=0;M<144;++M)ir[M]=8;for(M=144;M<256;++M)ir[M]=9;for(M=256;M<280;++M)ir[M]=7;for(M=280;M<288;++M)ir[M]=8;Ho=new oe(32);for(M=0;M<32;++M)Ho[M]=5;zc=nr(ir,9,1),kc=nr(Ho,5,1),$n=function(n){for(var e=n[0],t=1;te&&(e=n[t]);return e},Ae=function(n,e,t){var r=e/8|0;return(n[r]|n[r+1]<<8)>>(e&7)&t},Un=function(n,e){var t=e/8|0;return(n[t]|n[t+1]<<8|n[t+2]<<16)>>(e&7)},Vo=function(n){return(n+7)/8|0},Kr=function(n,e,t){return(e==null||e<0)&&(e=0),(t==null||t>n.length)&&(t=n.length),new oe(n.subarray(e,t))},Pc=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],me=function(n,e,t){var r=new Error(e||Pc[n]);if(r.code=n,Error.captureStackTrace&&Error.captureStackTrace(r,me),!t)throw r;return r},qo=function(n,e,t,r){var i=n.length,s=r?r.length:0;if(!i||e.f&&!e.l)return t||new oe(0);var o=!t,a=o||e.i!=2,c=e.i;o&&(t=new oe(i*3));var l=function(De){var Ke=t.length;if(De>Ke){var gr=new oe(Math.max(Ke*2,De));gr.set(t),t=gr}},d=e.f||0,u=e.p||0,p=e.b||0,m=e.l,f=e.d,h=e.m,g=e.n,_=i*8;do{if(!m){d=Ae(n,u,1);var y=Ae(n,u+1,3);if(u+=3,y)if(y==1)m=zc,f=kc,h=9,g=5;else if(y==2){var S=Ae(n,u,31)+257,O=Ae(n,u+10,15)+4,x=S+Ae(n,u+5,31)+1;u+=14;for(var v=new oe(x),L=new oe(19),b=0;b>4;if(E<16)v[b++]=E;else{var U=0,ue=0;for(E==16?(ue=3+Ae(n,u,3),u+=2,U=v[b-1]):E==17?(ue=3+Ae(n,u,7),u+=3):E==18&&(ue=11+Ae(n,u,127),u+=7);ue--;)v[b++]=U}}var C=v.subarray(0,S),H=v.subarray(S);h=$n(C),g=$n(H),m=nr(C,h,1),f=nr(H,g,1)}else me(1);else{var E=Vo(u)+4,A=n[E-4]|n[E-3]<<8,w=E+A;if(w>i){c&&me(0);break}a&&l(p+A),t.set(n.subarray(E,w),p),e.b=p+=A,e.p=u=w*8,e.f=d;continue}if(u>_){c&&me(0);break}}a&&l(p+131072);for(var Tt=(1<>4;if(u+=U&15,u>_){c&&me(0);break}if(U||me(2),ve<256)t[p++]=ve;else if(ve==256){je=u,m=null;break}else{var zt=ve-254;if(ve>264){var b=ve-257,Ce=Bo[b];zt=Ae(n,u,(1<>4;lt||me(3),u+=lt&15;var H=Tc[ge];if(ge>3){var Ce=$o[ge];H+=Un(n,u)&(1<_){c&&me(0);break}a&&l(p+131072);var Me=p+zt;if(p>3&1)+(e>>4&1);r>0;r-=!n[t++]);return t+(e&2)},It=(function(){function n(e,t){typeof e=="function"&&(t=e,e={}),this.ondata=t;var r=e&&e.dictionary&&e.dictionary.subarray(-32768);this.s={i:0,b:r?r.length:0},this.o=new oe(32768),this.p=new oe(0),r&&this.o.set(r)}return n.prototype.e=function(e){if(this.ondata||me(5),this.d&&me(4),!this.p.length)this.p=e;else if(e.length){var t=new oe(this.p.length+e.length);t.set(this.p),t.set(e,this.p.length),this.p=t}},n.prototype.c=function(e){this.s.i=+(this.d=e||!1);var t=this.s.b,r=qo(this.p,this.s,this.o);this.ondata(Kr(r,t,this.s.b),this.d),this.o=Kr(r,this.s.b-32768),this.s.b=this.o.length,this.p=Kr(this.p,this.s.p/8|0),this.s.p&=7},n.prototype.push=function(e,t){this.e(e),this.c(t)},n})();Xo=(function(){function n(e,t){this.v=1,this.r=0,It.call(this,e,t)}return n.prototype.push=function(e,t){if(It.prototype.e.call(this,e),this.r+=e.length,this.v){var r=this.p.subarray(this.v-1),i=r.length>3?Fc(r):4;if(i>r.length){if(!t)return}else this.v>1&&this.onmember&&this.onmember(this.r-r.length);this.p=r.subarray(i),this.v=0}It.prototype.c.call(this,0),this.s.f&&!this.s.l?(this.v=Vo(this.s.p)+9,this.s={i:0},this.o=new oe(0),this.push(new oe(0),t)):t&&It.prototype.c.call(this,t)},n})(),Cc=typeof TextDecoder<"u"&&new TextDecoder,Mc=0;try{Cc.decode(Nc,{stream:!0}),Mc=1}catch{}});var qn={};Ri(qn,{extractZipEntry:()=>Hc,extractZipEntryBounded:()=>Vc,fetchZipCentralDirectory:()=>Zc,parseZipCentralDirectory:()=>or});function ts(n){let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=Math.max(0,n.length-Jo);for(let r=n.length-Bc;r>=t;r--)if(e.getUint32(r,!0)===Dc)return r;throw new Error("Zip EOCD record not found")}function or(n){let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=ts(n),r=e.getUint16(t+10,!0),i=e.getUint32(t+16,!0),s=[],o=i;for(let a=0;a>8,A;E===Yo?A=h>>16&65535:y.startsWith("bin/")||y.startsWith("sbin/")||y.includes("/bin/")||y.includes("/sbin/")?A=493:A=420;let w=y.endsWith("/"),S=E===Yo&&(A&Uc)===$c;s.push({fileName:y,fileNameBytes:_,compressedSize:d,uncompressedSize:u,compressionMethod:l,localHeaderOffset:g,mode:A,isDirectory:w,isSymlink:S,externalAttrs:h,creatorOS:E}),o+=Hn+p+m+f}return s}function rs(n,e){if(n.byteLength!==e.byteLength)return!1;for(let t=0;t{if(a.byteLength>t-s)throw new Error(`ZIP member ${e.fileName} expands beyond ${t} bytes`);i.set(a,s),s+=a.byteLength}).push(r,!0),s!==t)throw new Error(`ZIP member ${e.fileName} expanded ${s} bytes, expected ${t}`);return i}function qc(n,e){let t=new DataView(n.buffer,n.byteOffset,n.byteLength),r=e.localHeaderOffset;if(r<0||r>n.byteLength-Vn||t.getUint32(r,!0)!==jo)throw new Error(`Invalid local file header signature at offset ${r}`);let i=t.getUint16(r+8,!0),s=t.getUint16(r+26,!0),o=t.getUint16(r+28,!0),a=r+Vn,c=a+s+o,l=c+e.compressedSize;if(i!==e.compressionMethod||cn.byteLength||!rs(n.subarray(a,a+s),e.fileNameBytes))throw new Error(`ZIP member ${e.fileName} has inconsistent local metadata`);return n.subarray(c,l)}async function Zc(n){let e=await fetch(n,{method:"HEAD"});if(!e.ok)throw new Error(`HEAD request failed: ${e.status} ${e.statusText}`);let t=parseInt(e.headers.get("content-length")||"0",10),r=e.headers.get("accept-ranges");if(!t||r!=="bytes"){let _=await fetch(n);if(!_.ok)throw new Error(`Fetch failed: ${_.status} ${_.statusText}`);let y=new Uint8Array(await _.arrayBuffer());return{entries:or(y),totalSize:y.length}}let i=Math.min(t,Jo),s=t-i,o=await fetch(n,{headers:{Range:`bytes=${s}-${t-1}`}});if(o.status!==206){let _=await fetch(n);if(!_.ok)throw new Error(`Fetch failed: ${_.status} ${_.statusText}`);let y=new Uint8Array(await _.arrayBuffer());return{entries:or(y),totalSize:y.length}}let a=new Uint8Array(await o.arrayBuffer()),c=new DataView(a.buffer,a.byteOffset,a.byteLength),l=ts(a),d=c.getUint32(l+12,!0),u=c.getUint32(l+16,!0);if(u>=s){let _=t,y=new Uint8Array(_);return y.set(a,s),{entries:or(y),totalSize:_}}let p=u+d-1,m=await fetch(n,{headers:{Range:`bytes=${u}-${p}`}});if(m.status!==206)throw new Error(`Range request for CD failed: ${m.status}`);let f=new Uint8Array(await m.arrayBuffer()),h=t,g=new Uint8Array(h);return g.set(f,u),g.set(a,s),{entries:or(g),totalSize:h}}var Dc,Kc,jo,Jo,Bc,Hn,Vn,Qo,es,Yo,$c,Uc,Wc,Gc,Zn=nn(()=>{"use strict";Gn();Dc=101010256,Kc=33639248,jo=67324752,Jo=65557,Bc=22,Hn=46,Vn=30,Qo=0,es=8,Yo=3,$c=40960,Uc=61440,Wc=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),Gc=new TextEncoder});var ls={};Ri(ls,{DEFAULT_TAR_GZIP_LIMITS:()=>cs,TarParseError:()=>T,parseTarGzip:()=>Jc});function Jc(n,e={}){let t=e.label??"TAR gzip archive",r=el(e.limits,t);if(n.byteLength===0||n.byteLength>r.maxCompressedBytes)throw new T(`${t}: compressed byte count ${n.byteLength} is outside 1..${r.maxCompressedBytes}`);let i=tl(n,t);if(i===0||i>r.maxUncompressedBytes)throw new T(`${t}: declared uncompressed byte count ${i} is outside 1..${r.maxUncompressedBytes}`);let s=rl(n,t,i);if(s.byteLength!==i)throw new T(`${t}: gzip expanded to ${s.byteLength} bytes, expected ${i}`);let o=new DataView(n.buffer,n.byteOffset,n.byteLength).getUint32(n.byteLength-8,!0);if(nl(s)!==o)throw new T(`${t}: gzip CRC32 mismatch`);return Qc(s,t,r)}function Qc(n,e,t){if(n.byteLength%ke!==0)throw new T(`${e}: TAR byte count is not block-aligned`);let r=[],i=0,s=0,o=0,a=null,c={},l=!1;for(;i+ke<=n.byteLength;){let d=n.subarray(i,i+ke);if(i+=ke,Yn(d)){if(i+ke>n.byteLength)throw new T(`${e}: TAR end marker is truncated`);let w=n.subarray(i,i+ke);if(!Yn(w))throw new T(`${e}: TAR has only one zero end block`);if(i+=ke,!Yn(n.subarray(i)))throw new T(`${e}: TAR has nonzero data after its end marker`);l=!0;break}al(d,e);let u=sr(d,156,1,e)||"0",p=Jn(d,124,12,`${e}: TAR entry size`),m=Jn(d,100,8,`${e}: TAR entry mode`)&Xc,f=cl(d,e,t.maxPathBytes),h=sr(d,157,100,e);if(u==="x"||u==="g"){if(o+=1,o>t.maxEntries+1)throw new T(`${e}: TAR extension header count exceeds ${t.maxEntries+1}`);let w=is(n,i,p,e);i=os(i,p,n.byteLength,e);let S=ol(w,e,t);u==="x"?a=S:c={...c,...S};continue}if(s+=1,s>t.maxEntries)throw new T(`${e}: TAR entry count exceeds ${t.maxEntries}`);let g={...c,...a??{}};a=null;let _=g.size===void 0?p:sl(g.size,`${e}: PAX entry size`),y=is(n,i,_,e);i=os(i,_,n.byteLength,e);let E=jn(g.path??f,e,t.maxPathBytes),A=g.linkpath??h;switch(u){case"0":case"\0":r.push({path:E,type:"file",mode:m,data:y});break;case"5":Xn(_,e,"directory",E),r.push({path:E,type:"directory",mode:m});break;case"2":Xn(_,e,"symlink",E),ss(A,e,E,t.maxLinkBytes,!1),r.push({path:E,type:"symlink",mode:m,linkName:A});break;case"1":Xn(_,e,"hardlink",E),ss(A,e,E,t.maxLinkBytes,!0),r.push({path:E,type:"hardlink",mode:m,linkName:jn(A,`${e}: hardlink target`,t.maxPathBytes)});break;case"3":case"4":case"6":throw new T(`${e}: unsupported TAR device/FIFO entry ${E}`);default:throw new T(`${e}: unsupported TAR entry type ${JSON.stringify(u)} for ${E}`)}}if(!l)throw new T(`${e}: TAR is missing its two-block end marker`);if(a!==null)throw new T(`${e}: local PAX header has no following entry`);return r}function el(n,e){let t={...cs,...n};for(let[r,i]of Object.entries(t))if(!Number.isSafeInteger(i)||i<=0)throw new T(`${e}: ${r} must be a positive safe integer`);return t}function tl(n,e){if(n.byteLength<18||n[0]!==31||n[1]!==139||n[2]!==8)throw new T(`${e}: invalid gzip header`);return new DataView(n.buffer,n.byteOffset,n.byteLength).getUint32(n.byteLength-4,!0)}function rl(n,e,t){let r=new Uint8Array(t),i=0,s=!1,o=new Xo(a=>{if(a.byteLength>t-i)throw new T(`${e}: gzip expansion exceeds its declared ${t} bytes`);r.set(a,i),i+=a.byteLength});o.onmember=()=>{throw s=!0,new T(`${e}: concatenated gzip members are unsupported`)};try{o.push(n,!0)}catch(a){throw a instanceof T?a:new T(`${e}: cannot gunzip archive: ${ul(a)}`)}if(s)throw new T(`${e}: concatenated gzip members are unsupported`);return r.subarray(0,i)}function nl(n){let e=4294967295;for(let t of n)e=jc[(e^t)&255]^e>>>8;return(e^4294967295)>>>0}function il(){let n=new Uint32Array(256);for(let e=0;e>>1^((t&1)===0?0:3988292384);n[e]=t>>>0}return n}function is(n,e,t,r){if(t>n.byteLength-e)throw new T(`${r}: TAR entry is truncated`);return n.subarray(e,e+t)}function os(n,e,t,r){let s=Math.ceil(e/ke)*ke;if(!Number.isSafeInteger(s)||s>t-n)throw new T(`${r}: TAR entry padding is truncated`);return n+s}function ol(n,e,t){let r={},i=0;for(;i9)throw new T(`${e}: invalid PAX record length`);if(o=o*10+h,!Number.isSafeInteger(o))throw new T(`${e}: invalid PAX record length`)}let a=i+o;if(o<=s-i+2||a>n.byteLength||n[a-1]!==10)throw new T(`${e}: truncated PAX record`);let c=s+1;for(;c=a-1)throw new T(`${e}: invalid PAX record`);let l=n.subarray(s+1,c);if(l.byteLength>256)throw new T(`${e}: PAX record key is too long`);let d=Qn(l,`${e}: PAX record key`),u=n.subarray(c+1,a-1),p=d==="path"?t.maxPathBytes:d==="linkpath"?t.maxLinkBytes:d==="size"?32:0;if(p===0){i=a;continue}if(u.byteLength>p)throw new T(`${e}: PAX ${d} value is too long`);let m=Qn(u,`${e}: PAX record value`);r[d]=m,i=a}return r}function sl(n,e){if(!/^(0|[1-9][0-9]*)$/.test(n))throw new T(`${e} is invalid`);let t=Number(n);if(!Number.isSafeInteger(t)||t<0)throw new T(`${e} is invalid`);return t}function al(n,e){let t=Jn(n,148,8,`${e}: TAR checksum`),r=0;for(let i=0;i=148&&i<156?32:n[i];if(t!==r)throw new T(`${e}: TAR checksum mismatch`)}function cl(n,e,t){let r=sr(n,0,100,e),i=sr(n,345,155,e);return jn(i?`${i}/${r}`:r,e,t)}function jn(n,e,t){let r=n;for(;r.startsWith("./");)r=r.slice(2);return r=r.replace(/\/+$/g,""),ll(r,`${e}: TAR path`,t),r}function sr(n,e,t,r){let i=e,s=e+t;for(;ir||n.includes("\0"))throw new T(`${e}: link target for ${t} is invalid`);if(i&&n.includes("\\"))throw new T(`${e}: hardlink target for ${t} is invalid`)}function ll(n,e,t){if(n.length===0||n.startsWith("/")||n.includes("\0")||n.includes("\\")||as.encode(n).byteLength>t)throw new T(`${e} ${JSON.stringify(n)} must be a bounded relative POSIX path`);for(let r of n.split("/"))if(r.length===0||r==="."||r==="..")throw new T(`${e} ${JSON.stringify(n)} contains an unsafe path segment`)}function Yn(n){for(let e of n)if(e!==0)return!1;return!0}function Qn(n,e){try{return Yc.decode(n)}catch{throw new T(`${e} contains non-UTF-8 text`)}}function ul(n){return n instanceof Error?n.message:String(n)}var ke,Xc,ns,Yc,as,jc,cs,T,us=nn(()=>{"use strict";Gn();ke=512,Xc=4095,ns=1024*1024,Yc=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),as=new TextEncoder,jc=il(),cs=Object.freeze({maxCompressedBytes:256*ns,maxUncompressedBytes:512*ns,maxEntries:1e5,maxPathBytes:4096,maxLinkBytes:65536}),T=class extends Error{constructor(e){super(e),this.name="TarParseError"}}});import{existsSync as pr,lstatSync as tn,readdirSync as Gs,readFileSync as st,realpathSync as Ie,statSync as Xe}from"node:fs";import{createHash as Hs}from"node:crypto";import{spawnSync as gi}from"node:child_process";import{basename as Xl,dirname as _r,isAbsolute as rn,join as $,relative as Yl,resolve as Oe,sep as jl}from"node:path";import{fileURLToPath as Jl}from"node:url";var kt="kandelo.wpk_fork.linked_frames";var Li=[75,76,67,70],Pt=24,bi=8,on=3,Ti=[{bytes:4,chunkHeaderSize:32,nodeHeaderSize:24},{bytes:8,chunkHeaderSize:56,nodeHeaderSize:32}],re="kandelo.wpk_fork.module_state",zi=1,ki=[75,70,77,68],Er=24,Pi=8;var sn=7;var Ni=1,Fi=1,Ci=1;var Mi=[{bytes:4,chunkHeaderSize:40},{bytes:8,chunkHeaderSize:56}];var Sr="__wpk_fork_global_";var wr="__wpk_fork_table_",an=1,cn=2,ln=3,un=4,dn=5,dt=6,Nt=7,Ft=8,Ct=9,Re="kandelo.wpk_fork.capabilities",Di=1;var Ki=7,Ar=4,Ee="kandelo.wpk_fork.exception_codec",Bi=1,Or=8,fn=16;var Ir="env",xr="__wpk_fork_unwind",ft="kandelo.wpk_fork.unwind_transport",Mt="__wpk_fork_static_root_catalog",Be="kandelo.wpk_fork.static_root_catalog";var hn=1,pn=0,$i=1,vr=12,Ui=[75,70,83,82],Z="kandelo.wpk_fork.imported_globals";var Wi=[75,70,73,71],Gi=1,Rr=16,Dt=24,Hi=1,Vi=2,qi=3,X="kandelo.wpk_fork.imported_tables",Zi=[75,70,73,84],Xi=1,Lr=16,Kt=24,Yi=1,ji=1,mn="env",_n="__wpk_fork_module_activation";var ht=[{module:"env",name:"__wpk_fork_frame_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_frame_next",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_peek",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_reserve",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_record_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_module_state_record_find",params:["i32","i32","i32","i32"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_record_reserve",params:["i32","i32","i32","ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_table_dirty_count",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_module_state_table_dirty_mark",params:["i32","i64","i64"],results:[]},{module:"env",name:"__wpk_fork_module_state_table_dirty_page",params:["i32","i32"],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_mutation_abort",params:[],results:[]},{module:"env",name:"__wpk_fork_module_state_table_mutation_begin",params:[],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_mutation_commit",params:["i32","i64","i64"],results:[]},{module:"env",name:"__wpk_fork_module_state_table_reconcile",params:[],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_state_owned",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_decode_funcref",params:["i32"],results:["funcref"]},{module:"env",name:"__wpk_fork_ref_encode_funcref",params:["funcref"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_broker_encode",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_broker_throw_recipe",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_cache_index",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_claim",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_define",params:["i32","i32","i32","i32","ptr","i32","ptr","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_ingress_throw",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_load",params:["i32","i32","i32","i32","ptr","i32","ptr","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_lookup",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_route",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_broker_encode",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_capture_layout",params:["i32","i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_claim",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_define",params:["i32","i32","i32","i32","i32","ptr","i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_i31",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_load",params:["i32","i32","i32","i32","i32","ptr","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_lookup",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_payload_len",params:["i32","i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_provenance_begin",params:["i32","i32","i32","i32","i64","i64","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_provenance_end",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_provenance_ref",params:["i32","i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_route",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_scratch_release",params:["ptr","ptr"],results:[]},{module:"env",name:"__wpk_fork_ref_scratch_reserve",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_ref_vector_append",params:["i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_vector_begin",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_vector_finish",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_vector_get",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_resume_peek",params:["i32"],results:["i32"]}],yn=[{module:"env",name:"__wpk_fork_ref_gc_transit",table64:!1,element:"anyref",minimum:1,maximum:null},{module:"env",name:"__wpk_fork_resume_table",table64:!1,element:"funcref",minimum:1,maximum:null}],Bt=[{name:"__wpk_fork_exception_materialize",params:["i32"],results:[]},{name:"__wpk_fork_ref_decode_exnref",params:["i32"],results:["exnref"]},{name:"__wpk_fork_ref_encode_exnref",params:["exnref"],results:["i32"]},{name:"__wpk_fork_ref_exn_abort",params:[],results:[]},{name:"__wpk_fork_ref_exn_clear",params:[],results:[]},{name:"__wpk_fork_ref_exn_encode_ingress",params:["i32"],results:["i32"]},{name:"__wpk_fork_ref_exn_throw_recipe",params:["i32"],results:[]},{name:"__wpk_fork_ref_exn_throw_slot",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_allocate",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_encode_slot",params:["i32"],results:["i32"]},{name:"__wpk_fork_ref_gc_fill",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_probe",params:["i32"],results:["i64"]},{name:"__wpk_fork_ref_gc_publish_externref",params:["i32","externref"],results:[]},{name:"__wpk_fork_static_root_harvest",params:[],results:[]},{name:"wpk_fork_abort_begin",params:["ptr"],results:[]},{name:"wpk_fork_abort_end",params:[],results:[]},{name:"wpk_fork_module_bootstrap",params:[],results:[]},{name:"wpk_fork_module_state_finish_restore",params:["i32"],results:[]},{name:"wpk_fork_module_state_restore",params:["i32"],results:[]},{name:"wpk_fork_module_state_save",params:["i32"],results:[]},{name:"wpk_fork_module_table_state_restore",params:["i32"],results:[]},{name:"wpk_fork_module_table_state_save",params:["i32"],results:[]},{name:"wpk_fork_module_thread_bootstrap",params:[],results:[]},{name:"wpk_fork_rewind_begin",params:["ptr"],results:[]},{name:"wpk_fork_rewind_end",params:[],results:[]},{name:"wpk_fork_state",params:[],results:["i32"]},{name:"wpk_fork_unwind_begin",params:["ptr"],results:[]},{name:"wpk_fork_unwind_end",params:[],results:[]}];var Ji=4096;var Qi=["__abi_version","kernel_alloc_scratch","kernel_blocking_retry_release","kernel_blocking_retry_token","kernel_clear_process_metadata","kernel_commit_process_exit","kernel_create_process","kernel_create_process_with_stdio","kernel_dequeue_signal","kernel_exec_prepare","kernel_exec_setup_for_thread","kernel_fork_process","kernel_get_parent_pid","kernel_get_process_exit_signal","kernel_get_process_state","kernel_get_socket_timeout_ms","kernel_handle_channel","kernel_has_sa_nocldstop","kernel_host_adapter_manifest_len","kernel_host_adapter_manifest_ptr","kernel_ipc_shmat_for_process","kernel_ipc_shmat_for_task","kernel_ipc_shmdt_for_process","kernel_ipc_shmdt_for_task","kernel_is_fd_nonblock","kernel_mark_process_signaled","kernel_mq_descriptor_msgsize","kernel_msqid_ds_bytes","kernel_pick_signal_target_tid","kernel_pipe_has_readers","kernel_posix_timer_fire","kernel_push_process_metadata_entry","kernel_reap_exited_child","kernel_remove_process","kernel_semctl_array_bytes","kernel_semid_ds_bytes","kernel_set_current_tid","kernel_set_cwd","kernel_shmid_ds_bytes","kernel_spawn_process","kernel_spawn_reserved_process","kernel_spawn_scratch_begin","kernel_spawn_scratch_cancel","kernel_spawn_scratch_capacity","kernel_spawn_scratch_pointer","kernel_spawn_scratch_retained_capacity","kernel_thread_exit","kernel_thread_has_deliverable","kernel_transfer_channel_execute","kernel_transfer_io_execute","kernel_transfer_scratch_begin","kernel_transfer_scratch_cancel","kernel_transfer_scratch_capacity","kernel_transfer_scratch_pointer","kernel_validate_task","kernel_wait_child_poll"];var V={LINK_MAX:0,MAX_CANON:1,MAX_INPUT:2,NAME_MAX:3,PATH_MAX:4,PIPE_BUF:5,CHOWN_RESTRICTED:6,NO_TRUNC:7,VDISABLE:8,SYNC_IO:9,ASYNC_IO:10,PRIO_IO:11,SOCK_MAXBUF:12,FILESIZEBITS:13,REC_INCR_XFER_SIZE:14,REC_MAX_XFER_SIZE:15,REC_MIN_XFER_SIZE:16,REC_XFER_ALIGN:17,ALLOC_SIZE_MIN:18,SYMLINK_MAX:19,POSIX2_SYMLINKS:20,FALLOC:21,TEXTDOMAIN_MAX:22,TIMESTAMP_RESOLUTION:23};var ia=Uint8Array.from(Ui);function R(n,e){let t=0,r=0,i=e;for(;;){let s=n[i++];if(t|=(s&127)<=n.length)throw new Error(`${r} is truncated`);if((n[e++]&128)===0)return e}throw new Error(`${r} has an overlong LEB128 encoding`)}function Se(n,e,t){if(e>=n.length)throw new Error(`${t} is truncated`);let r=n[e++];switch(r){case 127:case 126:case 125:case 124:case 123:case 117:case 116:case 115:case 114:case 113:case 112:case 111:case 110:case 109:case 108:case 107:case 106:case 105:case 104:return{code:r,shared:!1,next:e};case 98:case 99:case 100:{let i=n[e]===101;i&&e++;let s=co(n,e,5,`${t} heap type`),[o]=ao(n,e);return{code:r,heapType:Number(o),shared:i,next:s}}default:throw new Error(`${t} has unknown value type 0x${r.toString(16)}`)}}function oa(n,e,t){return n[e]===120||n[e]===119?{code:n[e],shared:!1,next:e+1}:Se(n,e,t)}function sa(n,e){let t=n[e];if(t===64||t===127||t===126||t===125||t===124||t===123||t===112||t===111)return e+1;let[,r]=En(n,e);return e+r}function aa(n,e,t){let[r,i]=R(n,e);e+=i;let s=[],o=[];for(let u=0;u=n.length)throw new Error(`${t} mutability is truncated`);let i=n[e++];if(i!==0&&i!==1)throw new Error(`${t} has invalid mutability ${i}`);return e}function ca(n,e,t,r){if(e===101){if(t>=n.length)throw new Error(`${r} shared type is truncated`);e=n[t++]}for(let i of[76,77]){if(e!==i)continue;let[,s]=R(n,t);if(t+=s,t>=n.length)throw new Error(`${r} descriptor is truncated`);e=n[t++]}if(e===96)return aa(n,t,r);if(e===95){let[i,s]=R(n,t);t+=s;for(let o=0;o=n.length)throw new Error(`${t} is truncated`);let r=n[e++];if(r===79||r===80){let[i,s]=R(n,e);e+=s;for(let o=0;o=n.length)throw new Error(`${t} body is truncated`);r=n[e++]}return ca(n,r,e,t)}function la(n,e){let[t,r]=R(n,e);e+=r;let i=[];for(let s=0;s=21&&r<=34?Ut(e,t):r===84||r>=92&&r<=99||r>=112&&r<=123||r>=124&&r<=131||r>=156&&r<=159?t+1:t:n===254?r===0||r===1||r===2?Ut(e,t):r===3?t:r>=16&&r<=79?Ut(e,t):null:null}function da(n,e,t){let[r,i]=R(n,e);e+=i+r;let[s,o]=R(n,e);e+=o+s;let a=n[e++];if(a===0){t.funcImports++;let[,c]=R(n,e);e+=c}else if(a===1)e=Se(n,e,"table import type").next,e=Ue(n,e).next;else if(a===2)e=Ue(n,e).next;else if(a===3)t.globalImports++,e=Se(n,e,"global import type").next,e++;else if(a===4){e++;let[,c]=R(n,e);e+=c}return e}function br(n){return n.length>=8&&n[0]===0&&n[1]===97&&n[2]===115&&n[3]===109}function $e(n,e){let[t,r]=R(n,e);return e+=r,[new TextDecoder().decode(n.subarray(e,e+t)),e+t]}function fa(n,e){if(e.length===0)return!0;let t=new TextEncoder().encode(e);e:for(let r=0;r<=n.length-t.length;r++){for(let i=0;in);function ro(n,e){switch(n.code){case 127:return an;case 126:return cn;case 125:return ln;case 124:return un;case 123:return dn;case 112:case 115:return dt;case 111:case 114:return Nt;case 105:case 116:return Ft;case 104:case 106:case 107:case 108:case 109:case 110:case 113:case 117:return Ct;case 98:case 99:case 100:{let t=n.heapType;return t===void 0?null:t===-16||t===-13?dt:t===-17||t===-14?Nt:t===-23||t===-12?Ft:t>=0&&e[t]!==void 0?dt:Ct}default:return null}}function gn(n,e,t){if(!t)throw new Error(`function ${e} refers to an unknown type`);let r=n.get(e)??[];r.push(t),n.set(e,r)}function $t(n,e,t){let r=n.get(e)??[];r.push(t),n.set(e,r)}function Ue(n,e){let[t,r]=R(n,e);e+=r;let[i,s]=R(n,e);e+=s;let o=null;if((t&1)!==0){let[a,c]=R(n,e);e+=c,o=a}return{flags:t,minimum:i,maximum:o,next:e}}function pa(n){let e=new Uint8Array(n);if(!br(e))throw new Error("not a wasm binary");let t=[],r=[],i=[],s={functionImports:new Map,functionImportEntries:[],globalImports:new Map,tableImports:new Map,tables:[],tagImports:new Map,functionExports:new Map,globalExports:new Map,tableExports:new Map,exports:new Map,memoryPointerWidths:[],forkCapabilities:[],linkedFrameDescriptors:[],exceptionCodecDescriptors:[],importedGlobalsDescriptors:[],importedTablesDescriptors:[],moduleStateDescriptors:[],staticRootDescriptors:[],unwindTransportDescriptors:[],nativeStartCount:0,importsKernelFork:!1},o=0,a=0,c=8;for(;ce.length)throw new Error("wasm section exceeds file size");let f=p,h=!1;if(l===0){let[g,_]=$e(e,f);g===kt?s.linkedFrameDescriptors.push(e.slice(_,m)):g===Re?s.forkCapabilities.push(e.slice(_,m)):g===Ee?s.exceptionCodecDescriptors.push(e.slice(_,m)):g===Z?s.importedGlobalsDescriptors.push(e.slice(_,m)):g===X?s.importedTablesDescriptors.push(e.slice(_,m)):g===re?s.moduleStateDescriptors.push(e.slice(_,m)):g===Be?s.staticRootDescriptors.push(e.slice(_,m)):g===ft&&s.unwindTransportDescriptors.push(e.slice(_,m))}else if(l===1){h=!0;let g=la(e,f);t.push(...g.types),f=g.next}else if(l===2){h=!0;let[g,_]=R(e,f);f+=_;for(let y=0;y=e.length)throw new Error(`global import ${E}.${w} is truncated`);let v=e[f++];if((v&-4)!==0)throw new Error(`global import ${E}.${w} has invalid flags ${v}`);$t(s.globalImports,`${E}.${w}`,{module:E,name:w,importOrdinal:y,index:o++,valueType:x.code,recipeTypeCode:ro(x,t),mutable:(v&1)!==0,shared:(v&2)!==0})}else if(O===4){let x=e[f++];if(x!==0)throw new Error(`unsupported wasm tag attribute ${x}`);let[v,L]=R(e,f);f+=L,gn(s.tagImports,`${E}.${w}`,t[v])}else throw new Error(`unsupported wasm import kind ${O}`)}}else if(l===3){h=!0;let[g,_]=R(e,f);f+=_;for(let y=0;yn[c]===a))throw new Error("linked-frame descriptor has invalid magic");let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=e.getUint16(4,!0);if(t!==1)throw new Error(`linked-frame descriptor version ${t} is unsupported`);let r=e.getUint16(6,!0);if(r!==Pt)throw new Error(`linked-frame descriptor declares size ${r}, expected ${Pt}`);let i=e.getUint8(8),s=Ti.find(({bytes:a})=>a===i);if(!s)throw new Error(`linked-frame descriptor pointer width ${i} is unsupported`);if(e.getUint8(9)!==bi)throw new Error(`linked-frame descriptor alignment ${e.getUint8(9)} is unsupported`);let o=e.getUint16(10,!0);if(o!==on)throw new Error(`linked-frame descriptor flags 0x${o.toString(16)} do not equal required flags 0x${on.toString(16)}`);if(e.getUint32(12,!0)!==s.chunkHeaderSize||e.getUint32(16,!0)!==s.nodeHeaderSize)throw new Error(`linked-frame descriptor header sizes do not match its ${i}-byte pointer width`);return s.bytes}function _a(n){if(n.length===0)return[`missing required ${Re} capability`];if(n.length!==1)return[`has ${n.length} ${Re} sections, expected exactly one`];let e=n[0];if(e.byteLength!==2)return[`${Re} has ${e.byteLength} bytes, expected 2`];if(e[0]!==Di)return[`${Re} version ${e[0]} is unsupported`];let t=e[1];return(t&~Ki)!==0?[`${Re} has unknown flags 0x${t.toString(16)}`]:(t&Ar)!==Ar?[`${Re} flags 0x${t.toString(16)} omit required activation-state safety flags 0x${Ar.toString(16)}`]:[]}function ya(n){let e=[],t=`${Ir}.${xr}`,r=n.tagImports.get(t);if(r?r.length!==1?e.push(`duplicate private fork-unwind tag import ${t}`):(r[0].params.length!==0||r[0].results.length!==0)&&e.push(`private fork-unwind tag ${t} must have an empty payload`):e.push(`missing required private fork-unwind tag import ${t}`),n.unwindTransportDescriptors.length===0)e.push(`missing required ${ft} descriptor`);else if(n.unwindTransportDescriptors.length!==1)e.push(`has ${n.unwindTransportDescriptors.length} ${ft} descriptors, expected exactly one`);else{let i=n.unwindTransportDescriptors[0];(i.length!==2||i[0]!==hn||i[1]!==pn)&&e.push(`${ft} must be [${hn}, ${pn}]`)}return e}function ga(n,e){if(n.length===0)return[`missing required ${re} descriptor`];if(n.length!==1)return[`has ${n.length} ${re} descriptors, expected exactly one`];let t=n[0];if(t.byteLength!==Er)return[`${re} has ${t.byteLength} bytes, expected ${Er}`];if(!ki.every((h,g)=>t[g]===h))return[`${re} has invalid magic`];let r=new DataView(t.buffer,t.byteOffset,t.byteLength),i=r.getUint16(4,!0),s=r.getUint16(6,!0),o=r.getUint8(8),a=Mi.find(({bytes:h})=>h===o),c=r.getUint8(9),l=r.getUint16(10,!0),d=r.getUint16(12,!0),u=r.getUint16(14,!0),p=r.getUint32(16,!0),m=r.getUint32(20,!0),f=[];return i!==zi&&f.push(`${re} version ${i} is unsupported`),s!==Er&&f.push(`${re} declares size ${s}`),a?e!==null&&o!==e&&f.push(`${re} pointer width ${o} does not match linked frames ${e}`):f.push(`${re} pointer width ${o} is unsupported`),c!==Pi&&f.push(`${re} alignment ${c} is unsupported`),l!==sn&&f.push(`${re} flags 0x${l.toString(16)} do not equal required flags 0x${sn.toString(16)}`),d!==Ni&&f.push(`${re} arena version ${d} is unsupported`),u!==Fi&&f.push(`${re} record version ${u} is unsupported`),p!==Ci&&f.push(`${re} root word ${p} is unsupported`),m!==0&&f.push(`${re} reserved field is nonzero`),f}function Ea(n){if(n.length===0)return[`missing required ${Ee} descriptor`];if(n.length!==1)return[`has ${n.length} ${Ee} descriptors, expected exactly one`];let e=n[0];if(e.byteLength2147483647||o.has(d))&&r.push(`${Ee} layout id ${d} is invalid or duplicated`),o.add(d)}return r}var Sa=new Set([an,cn,ln,un,dn,dt,Nt,Ft,Ct]);function no(n){return!(n.module===mn&&(n.name===_n||n.name==="__channel_base"||n.name==="__wpk_fork_module_state_table_generation_addr"))}function wa(n){let e=n.importedGlobalsDescriptors;if(e.length===0)return[`missing required ${Z} descriptor`];if(e.length!==1)return[`has ${e.length} ${Z} descriptors, expected exactly one`];let t=e[0];if(t.byteLengtht[g]===h)||i.push(`${Z} has invalid magic`),r.getUint16(4,!0)!==Gi&&i.push(`${Z} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Rr&&i.push(`${Z} declares an invalid header size`);let s=r.getUint32(8,!0);r.getUint32(12,!0)!==0&&i.push(`${Z} reserved field is nonzero`);let o=new Set,a=new Set,c=new TextDecoder("utf-8",{fatal:!0}),l=[],d=-1,u=Rr;for(let h=0;ht.byteLength)return i.push(`${Z} record ${h} header is truncated`),i;let g=r.getUint32(u,!0),_=r.getUint32(u+4,!0),y=r.getUint8(u+8),E=r.getUint8(u+9),A=r.getUint32(u+12,!0),w=r.getUint32(u+16,!0),S=r.getUint32(u+20,!0),O=Dt+A+w;if(!Number.isSafeInteger(O)||g!==O||gt.byteLength)return i.push(`${Z} record ${h} has invalid bounds`),i;(_===0||o.has(_))&&i.push(`${Z} record ${h} has invalid or duplicated owner ${_}`),o.add(_),Sa.has(y)||i.push(`${Z} record ${h} has unknown value type ${y}`),(E&~qi)!==0&&i.push(`${Z} record ${h} has unknown flags 0x${E.toString(16)}`),r.getUint16(u+10,!0)!==0&&i.push(`${Z} record ${h} reserved fields are nonzero`),(a.has(S)||S<=d)&&i.push(`${Z} record ${h} has duplicated or unordered import ordinal`),a.add(S),d=S;let x=u+Dt;try{let v=c.decode(t.subarray(x,x+A)),L=c.decode(t.subarray(x+A,x+A+w));l.push({ownerId:_,typeCode:y,flags:E,importOrdinal:S,module:v,name:L})}catch{i.push(`${Z} record ${h} contains invalid UTF-8`)}u+=g}u!==t.byteLength&&i.push(`${Z} has trailing bytes`);let p=[...n.globalImports.values()].flat(),m=new Map(p.map(h=>[h.index,h])),f=new Set;for(let h of l){let g=`${Sr}${h.ownerId}`,_=n.exports.get(g);if(!_||_.length!==1||_[0].kind!==3){i.push(`${Z} owner ${h.ownerId} lacks exactly one global catalog export ${g}`);continue}let y=m.get(_[0].index);if(!y||!no(y)){i.push(`${Z} owner ${h.ownerId} does not identify a reconstructible imported global`);continue}if(y.module!==h.module||y.name!==h.name||y.importOrdinal!==h.importOrdinal||y.recipeTypeCode!==h.typeCode||y.mutable!==((h.flags&Hi)!==0)||y.shared!==((h.flags&Vi)!==0)){i.push(`${Z} owner ${h.ownerId} does not match its imported global declaration`);continue}if(f.has(y.index)){i.push(`${Z} repeats imported global index ${y.index}`);continue}f.add(y.index)}for(let h of p)no(h)&&!f.has(h.index)&&i.push(`${Z} omits imported global ${h.module}.${h.name} at index ${h.index}`);for(let[h,g]of n.exports){if(!h.startsWith(Sr))continue;let _=h.slice(Sr.length),y=Number(_);(!/^[1-9][0-9]*$/.test(_)||!Number.isSafeInteger(y)||y>4294967295||g.length!==1||g[0].kind!==3)&&i.push(`malformed reserved fork global catalog export ${h}`)}return i}var Aa=new Set([dt,Nt,Ft,Ct]);function io(n){return!yn.some(({module:e,name:t})=>n.module===e&&n.name===t)}function Oa(n){let e=n.importedTablesDescriptors;if(e.length===0)return[`missing required ${X} descriptor`];if(e.length!==1)return[`has ${e.length} ${X} descriptors, expected exactly one`];let t=e[0];if(t.byteLengtht[g]===h)||i.push(`${X} has invalid magic`),r.getUint16(4,!0)!==Xi&&i.push(`${X} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Lr&&i.push(`${X} declares an invalid header size`);let s=r.getUint32(8,!0);r.getUint32(12,!0)!==0&&i.push(`${X} reserved field is nonzero`);let o=new Set,a=new Set,c=new TextDecoder("utf-8",{fatal:!0}),l=[],d=-1,u=Lr;for(let h=0;ht.byteLength)return i.push(`${X} record ${h} header is truncated`),i;let g=r.getUint32(u,!0),_=r.getUint32(u+4,!0),y=r.getUint8(u+8),E=r.getUint8(u+9),A=r.getUint32(u+12,!0),w=r.getUint32(u+16,!0),S=r.getUint32(u+20,!0),O=Kt+A+w;if(!Number.isSafeInteger(O)||g!==O||gt.byteLength)return i.push(`${X} record ${h} has invalid bounds`),i;(_===0||o.has(_))&&i.push(`${X} record ${h} has invalid or duplicated owner ${_}`),o.add(_),Aa.has(y)||i.push(`${X} record ${h} has unknown element type ${y}`),(E&~ji)!==0&&i.push(`${X} record ${h} has unknown flags 0x${E.toString(16)}`),r.getUint16(u+10,!0)!==0&&i.push(`${X} record ${h} reserved fields are nonzero`),(a.has(S)||S<=d)&&i.push(`${X} record ${h} has duplicated or unordered import ordinal`),a.add(S),d=S;let x=u+Kt;try{let v=c.decode(t.subarray(x,x+A)),L=c.decode(t.subarray(x+A,x+A+w));l.push({ownerId:_,typeCode:y,flags:E,importOrdinal:S,module:v,name:L})}catch{i.push(`${X} record ${h} contains invalid UTF-8`)}u+=g}u!==t.byteLength&&i.push(`${X} has trailing bytes`);let p=[...n.tableImports.values()].flat(),m=new Map(p.map(h=>[h.index,h])),f=new Set;for(let h of l){let g=`${wr}${h.ownerId}`,_=n.exports.get(g);if(!_||_.length!==1||_[0].kind!==1){i.push(`${X} owner ${h.ownerId} lacks exactly one table catalog export ${g}`);continue}let y=m.get(_[0].index);if(!y||!io(y)){i.push(`${X} owner ${h.ownerId} does not identify a reconstructible imported table`);continue}if(y.module!==h.module||y.name!==h.name||y.importOrdinal!==h.importOrdinal||y.recipeTypeCode!==h.typeCode||y.table64!==((h.flags&Yi)!==0)){i.push(`${X} owner ${h.ownerId} does not match its imported table declaration`);continue}if(f.has(y.index)){i.push(`${X} repeats imported table index ${y.index}`);continue}f.add(y.index)}for(let h of p)io(h)&&!f.has(h.index)&&i.push(`${X} omits imported table ${h.module}.${h.name} at index ${h.index}`);for(let[h,g]of n.exports){if(!h.startsWith(wr))continue;let _=h.slice(wr.length),y=Number(_);(!/^[1-9][0-9]*$/.test(_)||!Number.isSafeInteger(y)||y>4294967295||g.length!==1||g[0].kind!==1)&&i.push(`malformed reserved fork table catalog export ${h}`)}return i}function Sn(n,e){switch(n){case"ptr":return e===8?126:127;case"i32":return 127;case"i64":return 126;case"anyref":return 110;case"exnref":return 105;case"externref":return 111;case"funcref":return 112}}function oo(n,e,t,r){return n.params.length===e.length&&n.results.length===t.length&&n.params.every((i,s)=>i===Sn(e[s],r))&&n.results.every((i,s)=>i===Sn(t[s],r))}function so(n,e,t){let r=i=>i==="ptr"?t===8?"i64":"i32":i;return`(${n.map(r).join(", ")}) -> (${e.map(r).join(", ")})`}function Ia(n){let e=`${mn}.${_n}`,t=n.globalImports.get(e);return t?t.length!==1?[`duplicate exception-codec activation import ${e}`]:t[0].valueType!==127||t[0].mutable?[`exception-codec activation import ${e} must be immutable i32`]:[]:[`missing required immutable exception-codec activation import ${e}`]}function xa(n){let e=[];for(let t of yn){let r=`${t.module}.${t.name}`,i=n.tableImports.get(r);if(!i){e.push(`missing required ABI 43 fork-runtime table import ${r}`);continue}if(i.length!==1){e.push(`duplicate ABI 43 fork-runtime table import ${r}`);continue}let s=i[0],o=Sn(t.element,4);(s.elementType!==o||s.table64!==t.table64||s.minimum!==t.minimum||s.maximum!==t.maximum)&&e.push(`ABI 43 fork-runtime table import ${r} has the wrong type or limits`)}return e}function va(n){if(n.staticRootDescriptors.length===0)return[`missing required ${Be} descriptor`];if(n.staticRootDescriptors.length!==1)return[`has ${n.staticRootDescriptors.length} ${Be} descriptors, expected exactly one`];let e=n.staticRootDescriptors[0];if(e.byteLength!==vr)return[`${Be} has ${e.byteLength} bytes, expected ${vr}`];let t=[];ia.some((l,d)=>e[d]!==l)&&t.push(`${Be} has invalid magic`);let r=new DataView(e.buffer,e.byteOffset,e.byteLength);r.getUint16(4,!0)!==$i&&t.push(`${Be} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==vr&&t.push(`${Be} declares an invalid header size`);let i=r.getUint32(8,!0),s=n.tableExports.get(Mt);if(!s||s.length!==1)return t.push(`missing exactly one table export ${Mt}`),t;let o=[...n.tableImports.values()].reduce((l,d)=>l+d.length,0),a=s[0],c=n.tables[a];return a!n.functionExports.has(c)).map(({name:c})=>c);t.length>0&&e.push(`incomplete wasm-fork-instrument exports; missing ${t.join(", ")}`);let r=null;if(n.linkedFrameDescriptors.length===0)e.push(`missing required ${kt} descriptor`);else if(n.linkedFrameDescriptors.length!==1)e.push(`has ${n.linkedFrameDescriptors.length} ${kt} descriptors, expected exactly one`);else try{r=ma(n.linkedFrameDescriptors[0])}catch(c){e.push(c instanceof Error?c.message:String(c))}e.push(...ga(n.moduleStateDescriptors,r));let i=ht.filter(({module:c,name:l})=>n.functionImports.has(`${c}.${l}`)),s=`${Ir}.${xr}`,o=n.importsKernelFork||i.length>0;if((o||n.tagImports.has(s)||n.unwindTransportDescriptors.length>0)&&e.push(...ya(n)),o){let c=ht.filter(({module:l,name:d})=>!n.functionImports.has(`${l}.${d}`)).map(({module:l,name:d})=>`${l}.${d}`);c.length>0&&e.push(`incomplete ABI 43 fork-runtime imports; missing ${c.join(", ")}`);for(let l of ht){let d=`${l.module}.${l.name}`,u=n.functionImports.get(d);u&&u.length!==1&&e.push(`duplicate ABI 43 fork-runtime import ${d}`)}}if(r!==null){if(n.memoryPointerWidths.length!==1)e.push(`ABI 43 fork instrumentation requires exactly one module memory, found ${n.memoryPointerWidths.length}`);else if(n.memoryPointerWidths[0]!==r){let c=r===8?"an":"a";e.push(`ABI 43 linked-frame descriptor declares ${c} ${r}-byte pointer but the module memory uses ${n.memoryPointerWidths[0]}-byte addresses`)}for(let c of Bt){let l=n.functionExports.get(c.name);l?.length===1&&!oo(l[0],c.params,c.results,r)&&e.push(`ABI 43 wasm-fork-instrument export ${c.name} has the wrong signature; expected ${so(c.params,c.results,r)}`)}if(o)for(let c of ht){let l=`${c.module}.${c.name}`,d=n.functionImports.get(l);d?.length===1&&!oo(d[0],c.params,c.results,r)&&e.push(`ABI 43 fork-runtime import ${l} has the wrong signature; expected ${so(c.params,c.results,r)}`)}}return e}function La(n){let e=new Uint8Array(n);if(!br(e))return[];let t=[],r=8;for(;rt.startsWith("reloc."))}function uo(n,e={}){let t=[],r=null;Ta(n)&&t.push("contains asyncify_"),e.expectedAbi!==void 0&&e.expectedAbi!==null&&(r=Pa(n),r!==null&&r!==e.expectedAbi&&t.push(`ABI ${r}, expected ${e.expectedAbi}`));let i=new Set(ba(n));if(e.requiredExports){let E=e.requiredExports.filter(A=>!i.has(A));E.length>0&&t.push(`missing required exports: ${E.join(", ")}`)}let s=ha.filter(E=>i.has(E)),o=La(n),a=lo(n),c=ht.filter(({module:E,name:A})=>o.includes(`${E}.${A}`)),l=a.filter(E=>E===kt).length,d=a.filter(E=>E===Re).length,u=a.filter(E=>E===re).length,p=a.filter(E=>E===Ee).length,m=a.filter(E=>E===Z).length,f=a.filter(E=>E===X).length,h=a.filter(E=>E===ft).length,g=o.includes(`${Ir}.${xr}`),_=s.length>0||c.length>0||l>0||d>0||u>0||p>0||m>0||f>0||h>0||g;if(e.expectedAbi!==void 0&&e.expectedAbi!==null&&_&&r===null&&t.push(`ABI ${e.expectedAbi} fork artifact is missing __abi_version; the activation-state capability epoch cannot be verified`),e.forbidForkInstrumentation&&_&&t.push("contains ABI 43 wasm-fork-instrument metadata, imports, or exports"),(e.requireForkInstrumentation??!za(n))&&(_||o.includes("kernel.kernel_fork")))try{t.push(...Ra(pa(n)))}catch(E){t.push(`cannot validate ABI 43 fork-artifact contract: ${E instanceof Error?E.message:String(E)}`)}return t}function ka(n,e){let t=new Uint8Array(n);if(t.length<8)return null;let r=0,i=null,s=null,o=8;for(;o=c)return null;let h=a;for(let y=0;y=f)return null;let[h,g]=R(t,m);m+=g;for(let _=0;_f)return null}return m}function p(m,f=0){if(f>4)return null;let h=d(m);if(!h)return null;let g=u(h.start,h.end);if(g===null)return null;let _=g,y=h.end;for(;_=32&&E<=38||E===208){let[,A]=R(t,_);_+=A}else if(E>=40&&E<=62)_=Ut(t,_);else if(E===63||E===64)_++;else if(E===66){let[,A]=ao(t,_);_+=A}else if(E===67)_+=4;else if(E===68)_+=8;else if(E===252||E===253||E===254){let A=ua(E,t,_);if(A===null)return null;_=A}}return null}return p(i)}function Pa(n){return ka(n,"__abi_version")}var Na=ArrayBuffer,j=Uint8Array,Tr=Uint16Array,Fa=Int16Array;var zr=Int32Array,wn=function(n,e,t){if(j.prototype.slice)return j.prototype.slice.call(n,e,t);(e==null||e<0)&&(e=0),(t==null||t>n.length)&&(t=n.length);var r=new j(t-e);return r.set(n.subarray(e,t)),r},Gt=function(n,e,t,r){if(j.prototype.fill)return j.prototype.fill.call(n,e,t,r);for((t==null||t<0)&&(t=0),(r==null||r>n.length)&&(r=n.length);tn.length)&&(r=n.length);t2046MB)","invalid block type","FSE accuracy too high","match distance too far back","unexpected EOF"],J=function(n,e,t){var r=new Error(e||Ma[n]);if(r.code=n,Error.captureStackTrace&&Error.captureStackTrace(r,J),!t)throw r;return r},fo=function(n,e,t){for(var r=0,i=0;r>>0},Ka=function(n,e){var t=n[0]|n[1]<<8|n[2]<<16;if(t==3126568&&n[3]==253){var r=n[4],i=r>>5&1,s=r>>2&1,o=r&3,a=r>>6;r&8&&J(0);var c=6-i,l=o==3?4:o,d=fo(n,c,l);c+=l;var u=a?1<>3);m=f+(f>>3)*(n[5]&7)}m>2145386496&&J(1);var h=new j((e==1?p||m:e?0:m)+12);return h[0]=1,h[4]=4,h[8]=8,{b:c+u,y:0,l:0,d,w:e&&e!=1?e:h.subarray(12),e:m,o:new zr(h.buffer,0,3),u:p,c:s,m:Math.min(131072,m)}}else if((t>>4|n[3]<<20)==25481893)return Da(n,4)+8;J(0)},Qe=function(n){for(var e=0;1<t&&J(3);for(var s=1<0;){var y=Qe(o+1),E=r>>3,A=(1<>(r&7)&A,S=(1<S&&(w-=O)),p[++a]=--w,w==-1?(o+=w,g[--d]=a):o-=w,!w)do{var v=r>>3;c=(n[v]|n[v+1]<<8)>>(r&7)&3,r+=2,a+=c}while(c==3)}(a>255||o)&&J(0);for(var L=0,b=(s>>1)+(s>>3)+3,D=s-1,q=0;q<=a;++q){var F=p[q];if(F<1){m[q]=-F;continue}for(l=0;l=d)}}for(L&&J(0),l=0;l>3,{b:i,s:g,n:_,t:f}]},Ba=function(n,e){var t=0,r=-1,i=new j(292),s=n[e],o=i.subarray(0,256),a=i.subarray(256,268),c=new Tr(i.buffer,268);if(s<128){var l=Ht(n,e+1,6),d=l[0],u=l[1];e+=s;var p=d<<3,m=n[e];m||J(0);for(var f=0,h=0,g=u.b,_=g,y=(++e<<3)-8+Qe(m);y-=g,!(y>3;if(f+=(n[E]|n[E+1]<<8)>>(y&7)&(1<>3,h+=(n[E]|n[E+1]<<8)>>(y&7)&(1<<_)-1,o[++r]=u.s[h],g=u.n[f],f=u.t[f],_=u.n[h],h=u.t[h]}++r>255&&J(0)}else{for(r=s-127;t>4,o[t+1]=A&15}++e}var w=0;for(t=0;t11&&J(0),w+=S&&1<0;--t){var q=c[t];Gt(D,t,q,c[t-1]=q+a[t]*(1<a&&u>3,m=(n[p]|n[p+1]<<8|n[p+2]<<16)>>(d&7);c=(c<>2,o=s<<1,a=s+o;Wt(n.subarray(r,r+=n[0]|n[1]<<8),e.subarray(0,s),t),Wt(n.subarray(r,r+=n[2]|n[3]<<8),e.subarray(s,o),t),Wt(n.subarray(r,r+=n[4]|n[5]<<8),e.subarray(o,a),t),Wt(n.subarray(r),e.subarray(a),t)},qa=function(n,e,t){var r,i=e.b,s=n[i],o=s>>1&3;e.l=s&1;var a=s>>3|n[i+1]<<5|n[i+2]<<13,c=(i+=3)+a;if(o==1)return i>=n.length?void 0:(e.b=i+1,t?(Gt(t,n[i],e.y,e.y+=a),t):Gt(new j(a),n[i]));if(!(c>n.length)){if(o==0)return e.b=c,t?(t.set(n.subarray(i,c),e.y),e.y+=a,t):wn(n,i,c);if(o==2){var l=n[i],d=l&3,u=l>>2&3,p=l>>4,m=0,f=0;d<2?u&1?p|=n[++i]<<4|(u&2&&n[++i]<<12):p=l>>3:(f=u,u<2?(p|=(n[++i]&63)<<4,m=n[i]>>6|n[++i]<<2):u==2?(p|=n[++i]<<4|(n[++i]&3)<<12,m=n[i]>>2|n[++i]<<6):(p|=n[++i]<<4|(n[++i]&63)<<12,m=n[i]>>6|n[++i]<<2|n[++i]<<10)),++i;var h=t?t.subarray(e.y,e.y+e.m):new j(e.m),g=h.length-p;if(d==0)h.set(n.subarray(i,i+=p),g);else if(d==1)Gt(h,n[i++],g);else{var _=e.h;if(d==2){var y=Ba(n,i);m+=i-(i=y[0]),e.h=_=y[1]}else _||J(0);(f?Va:Wt)(n.subarray(i,i+=m),h.subarray(g),_)}var E=n[i++];if(E){E==255?E=(n[i++]|n[i++]<<8)+32512:E>127&&(E=E-128<<8|n[i++]);var A=n[i++];A&3&&J(0);for(var w=[Ua,Wa,$a],S=2;S>-1;--S){var O=A>>(S<<1)+2&3;if(O==1){var x=new j([0,0,n[i++]]);w[S]={s:x.subarray(2,3),n:x.subarray(0,1),t:new Tr(x.buffer,0,1),b:0}}else O==2?(r=Ht(n,i,9-(S&1)),i=r[0],w[S]=r[1]):O==3&&(e.t||J(0),w[S]=e.t[S])}var v=e.t=w,L=v[0],b=v[1],D=v[2],q=n[c-1];q||J(0);var F=(c<<3)-8+Qe(q)-D.b,z=F>>3,U=0,ue=(n[z]|n[z+1]<<8)>>(F&7)&(1<>3;var C=(n[z]|n[z+1]<<8)>>(F&7)&(1<>3;var H=(n[z]|n[z+1]<<8)>>(F&7)&(1<>3;var lt=1<>>(F&7)<-1);z=(F-=On[je])>>3;var Me=Ha[je]+((n[z]|n[z+1]<<8|n[z+2]<<16)>>(F&7)&(1<>3;var Je=Ga[Tt]+((n[z]|n[z+1]<<8|n[z+2]<<16)>>(F&7)&(1<>3,ue=D.t[ue]+((n[z]|n[z+1]<<8)>>(F&7)&(1<>3,H=L.t[H]+((n[z]|n[z+1]<<8)>>(F&7)&(1<>3,C=b.t[C]+((n[z]|n[z+1]<<8)>>(F&7)&(1<3)e.o[2]=e.o[1],e.o[1]=e.o[0],e.o[0]=ge-=3;else{var ut=ge-(Je!=0);ut?(ge=ut==3?e.o[0]-1:e.o[ut],ut>1&&(e.o[2]=e.o[1]),e.o[1]=e.o[0],e.o[0]=ge):ge=e.o[0]}for(var S=0;SMe&&(Ke=Me);for(var S=0;Sja)throw Vt("EOVERFLOW","file offset is outside signed i64");return n}function Qa(n){if(xn(n)<0n)throw Vt("EINVAL","negative positioned I/O offset");return n}function vn(n){let e=xn(n);if(e_o)throw Vt("EOVERFLOW","backend cannot represent the file offset exactly");return mo(e)}function Rn(n){let e=Qa(n);return vn(e)}function yo(n){if(n===null)return null;let e=xn(n);if(e<0n)throw Vt("EINVAL","negative file-size limit");return e>_o?null:mo(e)}function Ln(n){let e=new Error(`EINVAL: pathconf name ${n} is not associated with this object`);throw e.code="EINVAL",e}function bn(n,e,t){switch(e){case V.LINK_MAX:return null;case V.NAME_MAX:return 255;case V.PATH_MAX:return Ji;case V.CHOWN_RESTRICTED:return 1;case V.NO_TRUNC:return 1;case V.ASYNC_IO:return(n.mode&61440)===32768?1:Ln(e);case V.SYNC_IO:case V.PRIO_IO:case V.FILESIZEBITS:case V.REC_INCR_XFER_SIZE:case V.REC_MAX_XFER_SIZE:case V.REC_MIN_XFER_SIZE:case V.REC_XFER_ALIGN:case V.ALLOC_SIZE_MIN:case V.SYMLINK_MAX:case V.FALLOC:return null;case V.POSIX2_SYMLINKS:return t.supportsSymlinks?1:null;case V.TEXTDOMAIN_MAX:return 255;case V.TIMESTAMP_RESOLUTION:return t.timestampResolutionNs;case V.PIPE_BUF:{let r=n.mode&61440;return r===4096||r===16384?null:Ln(e)}case V.MAX_CANON:case V.MAX_INPUT:case V.VDISABLE:case V.SOCK_MAXBUF:return Ln(e);default:{let r=new Error(`EINVAL: invalid pathconf name ${e}`);throw r.code="EINVAL",r}}}var kr=Math.floor(160),Tn=1397114451,zn=1,qt=32768,G=16384,pt=40960,W=61440,ec=2048,tc=1024,rc=73,go=4294967295,et=0,Eo=1;var er=64,Kn=128,rr=512,nc=1024,ic=65536,Zt=3,oc=0,sc=1,ac=2,P=8,cc=-1,we=-2,B=-5,te=-9,Cn=-16,St=-17,be=-20,rt=-21,Y=-22,Lo=-24,nt=-27,ie=-28,Mn=-36,Dn=-39,bo=-40,To=-75,kn=0,Pn=4,Pr=8,mt=12,We=16,_t=20,Nr=24,tt=28,Fr=32,So=36,Cr=40,lc=44,uc=48,dc=52,Nn=56,Mr=60,Dr=64,Xt=68,wo=72,yt=0,N=8,K=12,k=16,de=24,Q=32,Yt=40,ne=48,jt=88,gt=92,Jt=96,Qt=100,ce=104,Ge=112,Ao=116,le=120,Oo=4,Le=8,Io=16,xo=20,vo=-2147483648,fc=2147483647,hc=1034+1024*1024,He=hc*4096,pc={[we]:"No such file or directory",[B]:"I/O error",[te]:"Bad file descriptor",[Cn]:"Device or resource busy",[St]:"File exists",[be]:"Not a directory",[rt]:"Is a directory",[Y]:"Invalid argument",[Lo]:"Too many open files",[nt]:"File too large",[ie]:"No space left on device",[Mn]:"File name too long",[Dn]:"Directory not empty",[bo]:"Too many symbolic links",[To]:"Value too large for data type"},I=class extends Error{constructor(t,r){super(r||pc[t]||`Error ${t}`);this.code=t;this.name="SFSError"}code},pe=new TextEncoder,tr=new TextDecoder,Ro=pe.encode("..");function Fn(n){return n==="."||n===".."}function Et(n){return n.buffer instanceof SharedArrayBuffer?tr.decode(new Uint8Array(n)):tr.decode(n)}function Ve(n){return n+3&-4}var Te=class n{constructor(e){this.buffer=e;this.view=new DataView(e),this.i32=new Int32Array(e),this.u8=new Uint8Array(e)}buffer;view;i32;u8;dirIndexes=new Map;blockAllocHint=0;inodeAllocHint=2;atomicsWaitAllowed;static DIR_INDEX_MIN_SIZE=64*1024;static mkfs(e,t){let r=e.byteLength;if(r<4096*16)throw new I(Y);let i=Math.floor(r/4096),s=t?Math.floor(t/4096):i*4,o=Math.floor(s/4);o<32&&(o=32),o=Math.ceil(o/32)*32;let a=Math.ceil(o/(4096*8)),c=Math.ceil(s/(4096*8)),l=Math.ceil(o*128/4096),d=1,u=d+a,p=u+c,m=p+l;if(m>=i){let x=(m+1)*4096;try{e.grow(x)}catch{throw new I(ie)}if(i=Math.floor(e.byteLength/4096),m>=i)throw new I(ie)}new Uint8Array(e).fill(0);let f=new n(e);f.w32(kn,Tn),f.w32(Pn,zn),f.w32(Pr,4096),f.w32(mt,i),f.w32(We,o),f.w32(tt,d),f.w32(Fr,u),f.w32(So,p),f.w32(Cr,m),f.w32(lc,a),f.w32(uc,c),f.w32(dc,l),f.w32(Xt,s),f.w32(wo,256);let h=u*4096;for(let x=0;x>2)+(x>>5);f.i32[v]|=1<<(x&31)}let g=i-m;Atomics.store(f.i32,_t>>2,g),f.blockAllocHint=m;let _=d*4096;f.i32[_>>2]|=3,Atomics.store(f.i32,Nr>>2,o-2),f.inodeAllocHint=2;let y=f.inodeOffset(1);f.w32(y+N,G|493),f.w32(y+K,2),f.w64(y+ce,1);let E=f.blockAlloc();if(E<0)throw new I(ie);f.w32(y+ne,E);let A=E*4096,w=Ve(P+1),S=Ve(P+2);f.w32(A,1),f.view.setUint16(A+4,w,!0),f.view.setUint16(A+6,1,!0),f.u8[A+P]=46;let O=A+w;return f.w32(O,1),f.view.setUint16(O+4,S,!0),f.view.setUint16(O+6,2,!0),f.u8[O+P]=46,f.u8[O+P+1]=46,f.w64(y+k,w+S),Atomics.store(f.i32,Nn>>2,1),f}static inspectImageCapacity(e){if(e.byteLengththis.snapshotBytesUnlocked(e))}snapshotState(e){return this.withNamespaceLock(()=>({bytes:this.snapshotBytesUnlocked(e),identities:this.collectIdentityStateUnlocked()}))}identityState(){return this.withNamespaceLock(()=>this.collectIdentityStateUnlocked())}snapshotBytesUnlocked(e){let t=e?.normalizeTimestampsMs;if(t!==void 0&&(!Number.isSafeInteger(t)||t<0))throw new I(Y,"Snapshot timestamp must be a non-negative safe integer in milliseconds");let r=t===void 0?void 0:BigInt(t);for(let a=0;a>2)!==0)throw new I(Cn,"Cannot save a VFS image with open descriptors")}let i=this.r32(We);for(let a=0;a=1&&this.inodeIsAllocated(a)?r:0n;o.setBigUint64(c+Yt,l,!0),o.setBigUint64(c+de,l,!0),o.setBigUint64(c+Q,l,!0)}}return s}collectIdentityStateUnlocked(){let e=new Map,t=this.inodeOffset(1),r=this.r64(t+ce);e.set(`1:${r}`,{ino:1,generation:r,dataSequence:Atomics.load(this.i32,t+le>>2)>>>0,mode:this.r32(t+N),linkCount:this.r32(t+K),size:this.r64(t+k),uid:this.r32(t+Jt),gid:this.r32(t+Qt),paths:["/"]});let i=[{ino:1,path:"/"}],s=new Set;for(;i.length>0;){let o=i.pop();if(s.has(o.ino))throw new I(B);s.add(o.ino);let a=this.inodeOffset(o.ino);if((this.r32(a+N)&W)!==G)throw new I(B);let c=this.r64(a+k),l=0;for(;l>2)>>>0,mode:L,linkCount:this.r32(S+K),size:this.r64(S+k),uid:this.r32(S+Jt),gid:this.r32(S+Qt),...(L&W)===pt?{symlinkTarget:this.readSymlinkInodeUnlocked(_)}:{},paths:[]},e.set(x,v)}v.paths.push(w),(this.r32(S+N)&W)===G&&i.push({ino:_,path:w})}}h+=y}l+=f}}return e}statfs(){let e=this.r32(Pr),t=this.r32(mt),r=this.r32(Xt),i=typeof this.buffer.maxByteLength=="number"?this.buffer.maxByteLength:this.buffer.byteLength,s=Math.floor(i/e),o=Math.max(t,Math.min(r,s)),a=Atomics.load(this.i32,_t>>2),c=Math.max(0,o-t);return{blockSize:e,totalBlocks:o,freeBlocks:a+c,totalInodes:this.r32(We),freeInodes:Atomics.load(this.i32,Nr>>2),maxName:255}}r32(e){return this.view.getUint32(e,!0)}w32(e,t){this.view.setUint32(e,t,!0)}r64(e){return Number(this.view.getBigUint64(e,!0))}w64(e,t){this.view.setBigUint64(e,BigInt(t),!0)}waitForAtomicChange(e,t){if(this.atomicsWaitAllowed!==!1)try{Atomics.wait(this.i32,e,t),this.atomicsWaitAllowed=!0;return}catch(r){if(!(r instanceof TypeError))throw r;this.atomicsWaitAllowed=!1}for(;Atomics.load(this.i32,e)===t;);}resetAllocationHints(){this.blockAllocHint=this.findNextFreeBlockHint(),this.inodeAllocHint=this.findNextFreeInodeHint()}findNextFreeBlockHint(){let e=this.r32(mt),t=this.r32(Cr),r=this.r32(Fr)*4096;for(let i=t;i>2)+(i>>5),o=i&31;if((Atomics.load(this.i32,s)&1<>2)+(r>>5),s=r&31;if((Atomics.load(this.i32,i)&1<>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}sbUnlock(){let e=Mr>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}namespaceLock(){let e=Dr>>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}namespaceUnlock(){let e=Dr>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}withNamespaceLock(e){this.namespaceLock();try{return e()}finally{this.namespaceUnlock()}}resetRestoredRuntimeState(){Atomics.store(this.i32,Mr>>2,0),Atomics.store(this.i32,Dr>>2,0),this.u8.fill(0,256,4096);let e=this.r32(We),t=this.r32(tt)*4096;for(let r=0;r>5)*4)&1<<(r&31))===0||this.r32(i+K)!==0)continue;let o=this.r32(i+N),a=this.r64(i+k);(o&W)===pt&&a<=40?(this.u8.fill(0,i+ne,i+ne+40),this.w64(i+k,0)):this.inodeTruncate(r,0),this.inodeFree(r)}}blockAlloc(){let e=this.r32(mt),t=this.r32(Fr)*4096,r=this.r32(Cr),i=this.blockAllocHint>=r&&this.blockAllocHint>2)+(a>>5),l=a&31,d=Atomics.load(this.i32,c);if(d&1<>2,1),this.blockAllocHint=a+1>2)+(e>>5),i=e&31;for(;;){let s=Atomics.load(this.i32,r),o=s&~(1<>2,1),e>=this.r32(Cr)&&e>2)>0)return 0;let e=this.r32(mt),t=this.r32(Xt),r=this.r32(wo),i=e+r;if(i>t&&(i=t,r=i-e,r===0))return ie;let s=i*4096;if(this.buffer.byteLength>2,r),Atomics.add(this.i32,Nn>>2,1),this.blockAllocHint=e,0}finally{this.sbUnlock()}}inodeOffset(e){let r=this.r32(So)+Math.floor(e/32),i=e%32*128;return r*4096+i}inodeAlloc(){let e=this.r32(We),t=this.r32(tt)*4096,r=this.inodeAllocHint>=2&&this.inodeAllocHint>2)+(o>>5),c=o&31,l=Atomics.load(this.i32,a);if(l&1<>2,1),this.inodeAllocHint=o+1>2,1)+1}inodeFree(e){let r=(this.r32(tt)*4096>>2)+(e>>5),i=e&31;for(;;){let s=Atomics.load(this.i32,r);if((s&1<>2,1),e>=2&&e0&&this.w32(r+Ge,i-1),i<=1&&this.r32(r+K)===0&&(this.inodeTruncate(e,0),t=!0)}finally{this.inodeWriteUnlock(e)}t&&this.inodeFree(e)}inodeDropLinkRefLocked(e){let t=this.inodeOffset(e),r=this.r32(t+K);return r>1?(this.w32(t+K,r-1),this.w64(t+Q,Date.now()),!1):this.inodeOrphanLocked(e)}inodeOrphanLocked(e){let t=this.inodeOffset(e);if(this.w32(t+K,0),this.w64(t+Q,Date.now()),this.r32(t+Ge)>0)return!1;let r=this.r32(t+N),i=this.r64(t+k);return(r&W)===pt&&i<=40?(this.u8.fill(0,t+ne,t+ne+40),this.w64(t+k,0)):this.inodeTruncate(e,0),!0}inodeReadLock(e){let t=this.inodeOffset(e)+yt>>2;for(;;){let r=Atomics.load(this.i32,t);if(r&vo){this.waitForAtomicChange(t,r);continue}if(Atomics.compareExchange(this.i32,t,r,r+1)===r)return}}inodeReadUnlock(e){let t=this.inodeOffset(e)+yt>>2;(Atomics.sub(this.i32,t,1)&fc)===1&&Atomics.notify(this.i32,t,1)}inodeWriteLock(e){let t=this.inodeOffset(e)+yt>>2;for(;;){let r=Atomics.load(this.i32,t);if(r!==0){this.waitForAtomicChange(t,r);continue}if(Atomics.compareExchange(this.i32,t,0,vo)===0)return}}inodeWriteUnlock(e){let t=this.inodeOffset(e)+yt>>2;Atomics.store(this.i32,t,0),Atomics.notify(this.i32,t,1/0)}inodeBlockMap(e,t,r){let i=this.inodeOffset(e);if(t<10){let s=this.r32(i+ne+t*4);if(s!==0)return s;if(!r)return 0;let o=this.blockAllocWithGrow();return o<0||this.w32(i+ne+t*4,o),o}if(t-=10,t<1024){let s=this.r32(i+jt),o=!1;if(s===0){if(!r)return 0;if(s=this.blockAllocWithGrow(),s<0)return s;this.w32(i+jt,s),o=!0}let a=s*4096+t*4,c=this.r32(a);if(c!==0)return c;if(!r)return 0;let l=this.blockAllocWithGrow();return l<0?(o&&(this.w32(i+jt,0),this.blockFree(s)),l):(this.w32(a,l),l)}if(t-=1024,t<1024*1024){let s=Math.floor(t/1024),o=t%1024,a=this.r32(i+gt),c=!1;if(a===0){if(!r)return 0;if(a=this.blockAllocWithGrow(),a<0)return a;this.w32(i+gt,a),c=!0}let l=a*4096+s*4,d=this.r32(l),u=!1;if(d===0){if(!r)return 0;if(d=this.blockAllocWithGrow(),d<0)return c&&(this.w32(i+gt,0),this.blockFree(a)),d;this.w32(l,d),u=!0}let p=d*4096+o*4,m=this.r32(p);if(m!==0)return m;if(!r)return 0;let f=this.blockAllocWithGrow();return f<0?(u&&(this.w32(l,0),this.blockFree(d)),c&&(this.w32(i+gt,0),this.blockFree(a)),f):(this.w32(p,f),f)}return Y}inodeReadData(e,t,r,i){let s=this.inodeOffset(e),o=this.r64(s+k);if(t>=o)return 0;t+i>o&&(i=o-t);let a=0,c=0;for(;i>0;){let l=Math.floor(t/4096),d=t%4096,u=4096-d;u>i&&(u=i);let p=this.inodeBlockMap(e,l,!1);if(p<=0)r.fill(0,c,c+u);else{let m=p*4096+d;r.set(this.u8.subarray(m,m+u),c)}c+=u,t+=u,i-=u,a+=u}return a}inodeWriteData(e,t,r,i){let s=this.inodeOffset(e),o=this.r64(s+k);t>o&&this.zeroOldEofTail(e,o);let a=0,c=0;for(;i>0;){let l=Math.floor(t/4096),d=t%4096,u=4096-d;u>i&&(u=i);let p=this.inodeBlockMap(e,l,!0);if(p<0){if(a===0)return p;break}let m=p*4096+d;this.u8.set(r.subarray(c,c+u),m),c+=u,t+=u,i-=u,a+=u}if(a>0&&t>this.r64(s+k)&&this.w64(s+k,t),a>0){let l=Date.now();this.w64(s+de,l),this.w64(s+Q,l),Atomics.add(this.i32,s+le>>2,1)}return a}zeroInodeRange(e,t,r){for(;t0){let c=a*4096+s;this.u8.fill(0,c,c+o)}t+=o}}zeroOldEofTail(e,t){let r=t%4096;if(r===0)return;let i=Math.floor(t/4096),s=this.inodeBlockMap(e,i,!1);if(s<=0)return;let o=s*4096+r;this.u8.fill(0,o,s*4096+4096)}freeBlocksFrom(e,t){let r=this.inodeOffset(e);for(let o=t;o<10;o++){let a=this.r32(r+ne+o*4);a&&(this.blockFree(a),this.w32(r+ne+o*4,0))}let i=this.r32(r+jt);if(i){let o=t>10?t-10:0;for(let a=o;a<1024;a++){let c=i*4096+a*4,l=this.r32(c);l&&(this.blockFree(l),this.w32(c,0))}o===0&&(this.blockFree(i),this.w32(r+jt,0))}let s=this.r32(r+gt);if(s){let o=t>1034?t-10-1024:0,a=Math.floor(o/1024);for(let c=a;c<1024;c++){let l=s*4096+c*4,d=this.r32(l);if(!d)continue;let u=c===a?o%1024:0;for(let p=u;p<1024;p++){let m=d*4096+p*4,f=this.r32(m);f&&(this.blockFree(f),this.w32(m,0))}u===0&&(this.blockFree(d),this.w32(l,0))}a===0&&(this.blockFree(s),this.w32(r+gt,0))}}inodeTruncate(e,t,r=!1){let i=this.inodeOffset(e),s=this.r64(i+k),o=t!==s;if(t>=s){if(t>s&&this.zeroOldEofTail(e,s),this.w64(i+k,t),o||r){let c=Date.now();this.w64(i+de,c),this.w64(i+Q,c),Atomics.add(this.i32,i+le>>2,1)}return}t%4096!==0&&this.zeroInodeRange(e,t,Math.ceil(t/4096)*4096);let a=Math.ceil(t/4096);if(this.freeBlocksFrom(e,a),this.w64(i+k,t),o||r){let c=Date.now();this.w64(i+de,c),this.w64(i+Q,c),Atomics.add(this.i32,i+le>>2,1)}}validateFileSize(e){if(!Number.isSafeInteger(e)||e<0)throw new I(Y);if(e>He)throw new I(nt)}validateSeekPosition(e){if(!Number.isSafeInteger(e))throw new I(To);if(e<0)throw new I(Y);if(e>He)throw new I(nt)}touchDirectoryMutation(e){let t=this.inodeOffset(e),r=Date.now();this.w64(t+de,r),this.w64(t+Q,r);let i=Atomics.add(this.i32,t+Ao>>2,1)+1>>>0,s=this.dirIndexes.get(e);s&&(s.mutationSequence=i,s.size=this.r64(t+k))}dirNameKey(e){return Et(e)}dirEntryNameMatches(e,t){if(this.view.getUint16(e+6,!0)!==t.length)return!1;for(let i=0;i=P&&r%4===0&&e+r<=t&&i<=r-P}inodeIsAllocated(e){let t=this.r32(We);if(e<=0||e>=t)return!1;let r=this.r32(tt)*4096;return(Atomics.load(this.i32,(r>>2)+(e>>5))&1<<(e&31))!==0}rebuildDirIndex(e,t,r,i){let s=new Map,o=[],a=0;for(;a4096-d&&(m=4096-d);let f=d;for(;f=P&&o.push({abs:h,recLen:_});f+=_}a+=m}let c={generation:t,mutationSequence:r,size:i,entries:s,free:o};return this.dirIndexes.set(e,c),c}getDirIndex(e){let t=this.inodeOffset(e),r=this.r64(t+k),i=this.r64(t+ce),s=Atomics.load(this.i32,t+Ao>>2)>>>0,o=this.dirIndexes.get(e);return o&&o.generation===i&&o.mutationSequence===s&&o.size===r?o:(o&&this.dirIndexes.delete(e),r=0;o--){let a=e.free[o];if(!(a.recLen4096-c&&(u=4096-c);let p=c;for(;pr)return-1;a=c,o+=l}return o===r?a:-1}dirAppendEntry(e,t,r,i=-1){let s=this.inodeOffset(e),o=this.r64(s+k),a=Ve(P+t.length),c=o,l=Math.floor(c/4096),d=c%4096,u=0;if(d!==0&&d+a>4096){let f=4096-d,h=0;if(f>=P){if(h=this.inodeBlockMap(e,l,!1),h<=0)return B}else if(i<0&&(i=this.findLastDirEntryInBlock(e,l,d)),i<0)return B;if(u=this.inodeBlockMap(e,l+1,!0),u<0)return u;if(f>=P){let g=h*4096+d;this.w32(g,0),this.view.setUint16(g+4,f,!0),this.view.setUint16(g+6,0,!0)}else{let _=this.view.getUint16(i+4,!0)+f;this.view.setUint16(i+4,_,!0),this.updateDirIndexRecLen(e,i,_)}c=(l+1)*4096,l++,d=0}let p;if(d===0){if(p=u||this.inodeBlockMap(e,l,!0),p<0)return p}else if(p=this.inodeBlockMap(e,l,!1),p<=0)return B;let m=p*4096+d;return this.w32(m,r),this.view.setUint16(m+4,a,!0),this.view.setUint16(m+6,t.length,!0),this.u8.set(t,m+P),this.w64(s+k,c+a),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,m,a),0}dirAddEntry(e,t,r){let i=this.getDirIndex(e);if(typeof i=="number")return i;if(i)return this.useDirIndexFreeSlot(i,e,t,r)?0:this.dirAppendEntry(e,t,r);let s=this.inodeOffset(e),o=this.r64(s+k),a=Ve(P+t.length),c=-1,l=0;for(;l4096-u&&(f=4096-u);let h=u;for(;hu+f||E>y-P)return B;if(_===0&&y>=a)return this.w32(g,r),this.view.setUint16(g+6,t.length,!0),this.u8.set(t,g+P),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,g,y),0;let A=Ve(P+E),w=y-A;if(_!==0&&w>=a){this.view.setUint16(g+4,A,!0);let S=g+A;return this.w32(S,r),this.view.setUint16(S+4,w,!0),this.view.setUint16(S+6,t.length,!0),this.u8.set(t,S+P),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,S,w),0}c=g,h+=y}l+=f}return this.dirAppendEntry(e,t,r,c)}dirRemoveEntry(e,t){let r=this.getDirIndex(e);if(typeof r=="number")return r;if(r){let a=this.dirNameKey(t),c=r.entries.get(a);if(!c)return we;if(this.r32(c.abs)===c.ino&&this.view.getUint16(c.abs+4,!0)===c.recLen&&this.view.getUint16(c.abs+6,!0)===c.nameLen&&this.dirEntryNameMatches(c.abs,t))return this.w32(c.abs,0),r.entries.delete(a),r.free.push({abs:c.abs,recLen:c.recLen}),this.touchDirectoryMutation(e),0;r.entries.delete(a)}let i=this.inodeOffset(e),s=this.r64(i+k),o=0;for(;o4096-c&&(u=4096-c);let p=c;for(;p4096-l&&(p=4096-l);let m=l;for(;m4096-o&&(l=4096-o);let d=o;for(;do+l||f>m-P)throw new I(B);if(p!==0){if(f===1&&this.u8[u+P]===46){d+=m;continue}if(f===2&&this.u8[u+P]===46&&this.u8[u+P+1]===46){d+=m;continue}return!1}d+=m}i+=l}return!0}dirIsAncestor(e,t){let r=t;for(let i=0;i<8*1024;i++){if(r===e)return!0;if(r===1)return!1;let s=this.dirLookup(r,Ro);if(s<0||s===r)throw new I(B);r=s}throw new I(B)}pathResolve(e,t){if(!e.startsWith("/"))return we;let r=1,i=e.split("/").filter(o=>o.length>0),s=0;for(let o=0;o255)return Mn;let c=pe.encode(a),l;this.inodeReadLock(r);try{let p=this.inodeOffset(r);if((this.r32(p+N)&W)!==G)return be;l=this.dirLookup(r,c)}finally{this.inodeReadUnlock(r)}if(l<0)return l;let d=this.inodeOffset(l);if((this.r32(d+N)&W)===pt&&(!(o===i.length-1)||t)){if(++s>8)return bo;let m=this.r64(d+k),f;if(m<=40)f=Et(this.u8.subarray(d+ne,d+ne+m));else{let h=new Uint8Array(m);this.inodeReadData(l,0,h,m),f=tr.decode(h)}if(f.startsWith("/")){r=1;let h=f.split("/").filter(_=>_.length>0),g=i.slice(o+1);i.length=0,i.push(...h,...g),o=-1}else{let h=f.split("/").filter(_=>_.length>0),g=i.slice(o+1);i.length=o,i.push(...h,...g),o--}continue}r=l}return r}pathResolveParent(e){if(!e.startsWith("/"))throw new I(Y,"Path must be absolute");let t=e.split("/").filter(c=>c.length>0);if(t.length===0)throw new I(Y,"Cannot operate on /");let r=t.pop();if(r.length>255)throw new I(Mn);let i="/"+t.join("/"),s=this.pathResolve(i,!0);if(s<0)throw new I(s);let o=this.inodeOffset(s);if((this.r32(o+N)&W)!==G)throw new I(be);return{parentIno:s,name:r}}fdAlloc(e,t,r){for(let i=0;i>2;if(Atomics.compareExchange(this.i32,o,0,1)===0)return this.w32(s+Oo,e),this.w64(s+Le,0),this.w32(s+Io,t),this.w32(s+xo,r?1:0),this.inodeAddOpenRef(e)?i:(Atomics.store(this.i32,o,0),we)}return Lo}fdGet(e){if(e<0||e>=kr)return null;let t=256+e*24;return Atomics.load(this.i32,t>>2)?{base:t,ino:this.r32(t+Oo),offset:this.r64(t+Le),flags:this.r32(t+Io),isDir:this.r32(t+xo)!==0}:null}fdFree(e){if(e>=0&&e>2,0)}}buildStat(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+ce),dataSequence:this.r32(t+le),mode:this.r32(t+N),linkCount:this.r32(t+K),size:this.r64(t+k),mtime:this.r64(t+de),ctime:this.r64(t+Q),atime:this.r64(t+Yt),uid:this.r32(t+Jt),gid:this.r32(t+Qt)}}namespaceEntryIdentity(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+ce),linkCount:this.r32(t+K),mode:this.r32(t+N)}}open(e,t,r=420){return this.withNamespaceLock(()=>this.openUnlocked(e,t,r))}createLazyStub(e,t){return this.withNamespaceLock(()=>{let r=this.openUnlocked(e,Eo|er,t);try{let i=this.fdGet(r);if(!i)throw new I(te);this.inodeWriteLock(i.ino);try{return this.inodeTruncate(i.ino,0,!0),this.buildStat(i.ino)}finally{this.inodeWriteUnlock(i.ino)}}finally{this.closeUnlocked(r)}})}replaceIfIdentity(e,t,r,i,s){return this.withNamespaceLock(()=>{let o=this.pathResolve(e,!0);if(o<0||o!==t)return!1;let a=this.inodeOffset(o);if(this.r64(a+ce)!==r||this.r32(a+le)!==i||(this.r32(a+N)&W)!==qt)return!1;this.validateFileSize(s.byteLength),this.inodeWriteLock(o);try{if(this.r64(a+ce)!==r||this.r32(a+le)!==i||this.r64(a+k)!==0)return!1;let c=this.r64(a+de),l=this.r64(a+Q);this.inodeTruncate(o,0,!0);let d=s.byteLength>0?this.inodeWriteData(o,0,s,s.byteLength):0;if(d!==s.byteLength)throw this.inodeTruncate(o,0,!0),Atomics.store(this.i32,a+le>>2,i),this.w64(a+de,c),this.w64(a+Q,l),new I(d<0?d:ie);return!0}finally{this.inodeWriteUnlock(o)}})}replaceManyIfIdentities(e,t=[]){return e.length===0&&t.length===0?!0:this.withNamespaceLock(()=>{let r=[],i=new Set,s=a=>{let c=this.pathResolve(a.path,!1);if(c<0||c!==a.expectedIno)return!1;let l=this.inodeOffset(c);return this.r64(l+ce)===a.expectedGeneration&&this.r32(l+le)===a.expectedDataSequence&&this.r32(l+N)===a.expectedMode&&this.r32(l+K)===a.expectedLinkCount&&this.r64(l+k)===a.expectedSize&&this.r32(l+Jt)===a.expectedUid&&this.r32(l+Qt)===a.expectedGid};for(let a of t)if(!s(a))return!1;for(let a of e){this.validateFileSize(a.data.byteLength);let c=-1;for(let l of a.paths){let d=this.pathResolve(l,!0);if(d!==a.expectedIno)continue;let u=this.inodeOffset(d);if(this.r64(u+ce)===a.expectedGeneration&&this.r32(u+le)===a.expectedDataSequence&&(this.r32(u+N)&W)===qt&&this.r64(u+k)===0){c=d;break}}if(c<0)return!1;if(i.has(c))throw new I(Y,"duplicate conditional replacement inode");i.add(c),r.push({...a,ino:c})}let o=[...i].sort((a,c)=>a-c);for(let a of o)this.inodeWriteLock(a);try{for(let l of r){let d=this.inodeOffset(l.ino);if(this.r64(d+ce)!==l.expectedGeneration||this.r32(d+le)!==l.expectedDataSequence||(this.r32(d+N)&W)!==qt||this.r64(d+k)!==0)return!1}for(let l of t)if(!s(l))return!1;let a=r.map(l=>{let d=this.inodeOffset(l.ino);return{ino:l.ino,dataSequence:this.r32(d+le),mtime:this.r64(d+de),ctime:this.r64(d+Q)}}),c=0;try{for(let l of r){c++,this.inodeTruncate(l.ino,0,!0);let d=l.data.byteLength>0?this.inodeWriteData(l.ino,0,l.data,l.data.byteLength):0;if(d!==l.data.byteLength)throw new I(d<0?d:ie)}}catch(l){for(let d=c-1;d>=0;d--){let u=a[d],p=this.inodeOffset(u.ino);this.inodeTruncate(u.ino,0,!0),Atomics.store(this.i32,p+le>>2,u.dataSequence),this.w64(p+de,u.mtime),this.w64(p+Q,u.ctime)}throw l}return!0}finally{for(let a=o.length-1;a>=0;a--)this.inodeWriteUnlock(o[a])}})}openUnlocked(e,t,r=420){let i=t&Zt,s=(t&er)!==0,o=(t&Kn)!==0;if(s&&o){let u=this.pathResolve(e,!1);if(u>=0)throw new I(St);if(u!==we)throw new I(u)}let a=this.pathResolve(e,!0);if(a<0&&a===we&&s){let{parentIno:u,name:p}=this.pathResolveParent(e);this.inodeWriteLock(u);try{let m=pe.encode(p),f=this.dirLookup(u,m);if(f>=0){if(o)throw new I(St);a=f}else{let h=this.inodeAlloc();if(h<0)throw new I(ie);let g=this.inodeOffset(h);this.w32(g+N,qt|r&4095),this.w32(g+K,1),this.w64(g+k,0);let _=Date.now();this.w64(g+Yt,_),this.w64(g+de,_),this.w64(g+Q,_);let y=this.dirAddEntry(u,m,h);if(y<0)throw this.inodeFree(h),new I(y);a=h}}finally{this.inodeWriteUnlock(u)}}if(a<0)throw new I(a);let c=this.inodeOffset(a),l=this.r32(c+N);if((l&W)===G&&i!==et)throw new I(rt);if(t&ic&&(l&W)!==G)throw new I(be);if(t&rr){if((l&W)===G)throw new I(rt);this.inodeWriteLock(a),this.inodeTruncate(a,0,!0),this.inodeWriteUnlock(a)}let d=this.fdAlloc(a,t,!1);if(d<0)throw new I(d);return d}close(e){this.withNamespaceLock(()=>this.closeUnlocked(e))}closeUnlocked(e){let t=this.fdGet(e);if(!t)throw new I(te);this.fdFree(e),this.inodeDropOpenRef(t.ino)}read(e,t){let r=this.fdGet(e);if(!r)throw new I(te);let i=this.inodeOffset(r.ino);if((this.r32(i+N)&W)===G)throw new I(rt);this.inodeReadLock(r.ino);try{let o=this.inodeReadData(r.ino,r.offset,t,t.length),a=256+e*24;return this.w64(a+Le,r.offset+o),o}finally{this.inodeReadUnlock(r.ino)}}readAt(e,t,r){let i=this.fdGet(e);if(!i)throw new I(te);let s=this.inodeOffset(i.ino);if((this.r32(s+N)&W)===G)throw new I(rt);this.validateSeekPosition(r),this.inodeReadLock(i.ino);try{return this.inodeReadData(i.ino,r,t,t.length)}finally{this.inodeReadUnlock(i.ino)}}write(e,t){let r=this.fdGet(e);if(!r)throw new I(te);if((r.flags&Zt)===et)throw new I(te);this.inodeWriteLock(r.ino);try{let s=r.offset;if(r.flags&nc){let c=this.inodeOffset(r.ino);s=this.r64(c+k)}if(!Number.isSafeInteger(s)||s<0)throw new I(Y);if(s>He||t.length>He-s)throw new I(nt);let o=this.inodeWriteData(r.ino,s,t,t.length);if(o<0)return o;let a=256+e*24;return this.w64(a+Le,s+o),o}finally{this.inodeWriteUnlock(r.ino)}}append(e,t,r){let i=this.fdGet(e);if(!i)throw new I(te);if((i.flags&Zt)===et)throw new I(te);if(r!==null&&(!Number.isSafeInteger(r)||r<0))throw new I(Y);this.inodeWriteLock(i.ino);try{let o=this.inodeOffset(i.ino),a=this.r64(o+k);if(!Number.isSafeInteger(a)||a<0)throw new I(Y);if(a>He)throw new I(nt);if(r!==null&&a>=r){let f=256+e*24;return this.w64(f+Le,a),{written:0,end:a}}let c=r===null?t.length:Math.min(t.length,r-a),l=He-a;if(c>l)throw new I(nt);let d=t.subarray(0,c),u=this.inodeWriteData(i.ino,a,d,d.length);if(u<0)throw new I(u);let p=256+e*24,m=a+u;return this.w64(p+Le,m),{written:u,end:m}}finally{this.inodeWriteUnlock(i.ino)}}writeAt(e,t,r){let i=this.fdGet(e);if(!i)throw new I(te);if((i.flags&Zt)===et)throw new I(te);this.validateSeekPosition(r),this.inodeWriteLock(i.ino);try{if(r>He||t.length>He-r)throw new I(nt);return this.inodeWriteData(i.ino,r,t,t.length)}finally{this.inodeWriteUnlock(i.ino)}}lseek(e,t,r){let i=this.fdGet(e);if(!i)throw new I(te);let s;if(r===oc)s=t;else if(r===sc)s=i.offset+t;else if(r===ac){let a=this.inodeOffset(i.ino);s=this.r64(a+k)+t}else throw new I(Y);this.validateSeekPosition(s);let o=256+e*24;return this.w64(o+Le,s),s}ftruncate(e,t){let r=this.fdGet(e);if(!r)throw new I(te);if((r.flags&Zt)===et)throw new I(te);this.validateFileSize(t),this.inodeWriteLock(r.ino);try{this.inodeTruncate(r.ino,t,!0)}finally{this.inodeWriteUnlock(r.ino)}}fstat(e){let t=this.fdGet(e);if(!t)throw new I(te);this.inodeReadLock(t.ino);try{return this.buildStat(t.ino)}finally{this.inodeReadUnlock(t.ino)}}stat(e){return this.withNamespaceLock(()=>this.statUnlocked(e))}statUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new I(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}lstat(e){return this.withNamespaceLock(()=>this.lstatUnlocked(e))}lstatUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new I(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}unlink(e){return this.withNamespaceLock(()=>this.unlinkUnlocked(e))}unlinkUnlocked(e){let{parentIno:t,name:r}=this.pathResolveParent(e),i=pe.encode(r),s=e.length>1&&e.endsWith("/");this.inodeWriteLock(t);try{let o=this.dirLookup(t,i);if(o<0)throw new I(o);let a=this.inodeOffset(o),c=this.r32(a+N);if(s&&(c&W)!==G)throw new I(be);if((c&W)===G)throw new I(rt);let l=this.namespaceEntryIdentity(o),d=this.dirRemoveEntry(t,i);if(d<0)throw new I(d);let u=!1;this.inodeWriteLock(o);try{u=this.inodeDropLinkRefLocked(o)}finally{this.inodeWriteUnlock(o)}return u&&this.inodeFree(o),l}finally{this.inodeWriteUnlock(t)}}rename(e,t){return this.withNamespaceLock(()=>this.renameUnlocked(e,t))}renameUnlocked(e,t){let{parentIno:r,name:i}=this.pathResolveParent(e),{parentIno:s,name:o}=this.pathResolveParent(t);if(Fn(i)||Fn(o))throw new I(Y);let a=pe.encode(i),c=pe.encode(o),l=e.length>1&&e.endsWith("/"),d=t.length>1&&t.endsWith("/"),u=Math.min(r,s),p=Math.max(r,s);this.inodeWriteLock(u),u!==p&&this.inodeWriteLock(p);try{let m=this.dirLookup(r,a);if(m<0)throw new I(m);let f=this.inodeOffset(m),g=this.r32(f+N)&W,_=this.namespaceEntryIdentity(m);if((l||d)&&g!==G)throw new I(be);if(g===G&&this.dirIsAncestor(m,s))throw new I(Y);let y=this.dirLookup(s,c),E=!1,A;if(y>=0){if(y===m)return{source:_,replaced:_};A=this.namespaceEntryIdentity(y);let S=this.inodeOffset(y),x=this.r32(S+N)&W;if(g===G&&x!==G)throw new I(be);if(g!==G&&x===G)throw new I(rt);let v=!1,L=y===r||y===s;L||this.inodeWriteLock(y);try{if(x===G&&!this.dirIsEmpty(y))throw new I(Dn);let b=this.dirReplaceEntryIno(s,c,m);if(b<0)throw new I(b);v=x===G?this.inodeOrphanLocked(y):this.inodeDropLinkRefLocked(y)}finally{L||this.inodeWriteUnlock(y)}v&&this.inodeFree(y),E=x===G}else{let S=this.dirAddEntry(s,c,m);if(S<0)throw new I(S)}let w=this.dirRemoveEntry(r,a);if(w<0)throw new I(w);if(g===G){if(r!==s){let S=this.inodeOffset(r);this.w32(S+K,this.r32(S+K)-1);let O=this.inodeOffset(s);this.w32(O+K,this.r32(O+K)+1),this.inodeWriteLock(m);try{let x=this.dirReplaceEntryIno(m,Ro,s);if(x<0)throw new I(x);this.w64(f+Q,Date.now())}finally{this.inodeWriteUnlock(m)}}if(E){let S=this.inodeOffset(s);this.w32(S+K,this.r32(S+K)-1)}}else if(E){let S=this.inodeOffset(s);this.w32(S+K,this.r32(S+K)-1)}return{source:_,replaced:A}}finally{u!==p&&this.inodeWriteUnlock(p),this.inodeWriteUnlock(u)}}mkdir(e,t=493){this.withNamespaceLock(()=>this.mkdirUnlocked(e,t))}mkdirUnlocked(e,t=493){let{parentIno:r,name:i}=this.pathResolveParent(e),s=pe.encode(i);this.inodeWriteLock(r);try{if(this.dirLookup(r,s)>=0)throw new I(St);let a=this.inodeAlloc();if(a<0)throw new I(ie);let c=this.inodeOffset(a);this.w32(c+N,G|t),this.w32(c+K,2),this.w64(c+k,0);let l=Date.now();this.w64(c+Yt,l),this.w64(c+de,l),this.w64(c+Q,l);let d=this.blockAllocWithGrow();if(d<0)throw this.inodeFree(a),new I(ie);this.w32(c+ne,d);let u=d*4096,p=Ve(P+1),m=Ve(P+2);this.w32(u,a),this.view.setUint16(u+4,p,!0),this.view.setUint16(u+6,1,!0),this.u8[u+P]=46;let f=u+p;this.w32(f,r),this.view.setUint16(f+4,m,!0),this.view.setUint16(f+6,2,!0),this.u8[f+P]=46,this.u8[f+P+1]=46,this.w64(c+k,p+m);let h=this.dirAddEntry(r,s,a);if(h<0)throw this.blockFree(d),this.inodeFree(a),new I(h);let g=this.inodeOffset(r);this.w32(g+K,this.r32(g+K)+1)}finally{this.inodeWriteUnlock(r)}}rmdir(e){this.withNamespaceLock(()=>this.rmdirUnlocked(e))}rmdirUnlocked(e){let{parentIno:t,name:r}=this.pathResolveParent(e);if(Fn(r))throw new I(Y);let i=pe.encode(r);this.inodeWriteLock(t);try{let s=this.dirLookup(t,i);if(s<0)throw new I(s);let o=this.inodeOffset(s);if((this.r32(o+N)&W)!==G)throw new I(be);let c=!1;this.inodeWriteLock(s);try{if(!this.dirIsEmpty(s))throw new I(Dn);let d=this.dirRemoveEntry(t,i);if(d<0)throw new I(d);c=this.inodeOrphanLocked(s)}finally{this.inodeWriteUnlock(s)}c&&this.inodeFree(s);let l=this.inodeOffset(t);this.w32(l+K,this.r32(l+K)-1)}finally{this.inodeWriteUnlock(t)}}symlink(e,t){this.withNamespaceLock(()=>this.symlinkUnlocked(e,t))}symlinkUnlocked(e,t){let{parentIno:r,name:i}=this.pathResolveParent(t),s=pe.encode(i),o=pe.encode(e);this.inodeWriteLock(r);try{if(this.dirLookup(r,s)>=0)throw new I(St);let c=this.inodeAlloc();if(c<0)throw new I(ie);let l=this.inodeOffset(c);if(this.w32(l+N,pt|511),this.w32(l+K,1),o.length<=40)this.u8.set(o,l+ne),this.w64(l+k,o.length);else{this.w64(l+k,0);let u=this.inodeWriteData(c,0,o,o.length);if(u!==o.length)throw u>0&&this.inodeTruncate(c,0),this.inodeFree(c),new I(u<0?u:ie)}let d=this.dirAddEntry(r,s,c);if(d<0)throw o.length<=40?(this.u8.fill(0,l+ne,l+ne+40),this.w64(l+k,0)):this.inodeTruncate(c,0),this.inodeFree(c),new I(d)}finally{this.inodeWriteUnlock(r)}}chmod(e,t){this.withNamespaceLock(()=>this.chmodUnlocked(e,t))}chmodUnlocked(e,t){let r=this.pathResolve(e,!0);if(r<0)throw new I(r);this.inodeWriteLock(r);try{let i=this.inodeOffset(r),s=this.r32(i+N);this.w32(i+N,s&W|t&4095),this.w64(i+Q,Date.now())}finally{this.inodeWriteUnlock(r)}}fchmod(e,t){let r=this.fdGet(e);if(!r)throw new I(te);this.inodeWriteLock(r.ino);try{let i=this.inodeOffset(r.ino),s=this.r32(i+N);this.w32(i+N,s&W|t&4095),this.w64(i+Q,Date.now())}finally{this.inodeWriteUnlock(r.ino)}}chown(e,t,r){this.withNamespaceLock(()=>this.chownUnlocked(e,t,r))}chownUnlocked(e,t,r){let i=this.pathResolve(e,!0);if(i<0)throw new I(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,r)}finally{this.inodeWriteUnlock(i)}}fchown(e,t,r){let i=this.fdGet(e);if(!i)throw new I(te);this.inodeWriteLock(i.ino);try{this.chownInodeUnlocked(i.ino,t,r)}finally{this.inodeWriteUnlock(i.ino)}}lchown(e,t,r){this.withNamespaceLock(()=>this.lchownUnlocked(e,t,r))}lchownUnlocked(e,t,r){let i=this.pathResolve(e,!1);if(i<0)throw new I(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,r)}finally{this.inodeWriteUnlock(i)}}chownInodeUnlocked(e,t,r){let i=this.inodeOffset(e);t!==go&&this.w32(i+Jt,t),r!==go&&this.w32(i+Qt,r);let s=this.r32(i+N);(s&W)===qt&&(s&rc)!==0&&this.w32(i+N,s&~(ec|tc)),this.w64(i+Q,Date.now())}utimens(e,t,r,i,s){this.withNamespaceLock(()=>this.utimensUnlocked(e,t,r,i,s))}utimensUnlocked(e,t,r,i,s){let o=this.pathResolve(e,!0);if(o<0)throw new I(o);this.inodeWriteLock(o);try{let a=this.inodeOffset(o),c=1073741823,l=1073741822,d=Date.now();if(r!==l){let u=r===c?d:t*1e3+Math.floor(r/1e6);this.w64(a+Yt,u)}if(s!==l){let u=s===c?d:i*1e3+Math.floor(s/1e6);this.w64(a+de,u)}this.w64(a+Q,d)}finally{this.inodeWriteUnlock(o)}}link(e,t){return this.withNamespaceLock(()=>this.linkUnlocked(e,t))}linkUnlocked(e,t){let r=this.pathResolve(e,!1);if(r<0)throw new I(r);let i=this.inodeOffset(r);if((this.r32(i+N)&W)===G)throw new I(cc);let{parentIno:o,name:a}=this.pathResolveParent(t),c=pe.encode(a);this.inodeWriteLock(o);try{if(this.dirLookup(o,c)>=0)throw new I(St);let d=this.dirAddEntry(o,c,r);if(d<0)throw new I(d);this.inodeWriteLock(r);try{let u=this.r32(i+K);this.w32(i+K,u+1),this.w64(i+Q,Date.now())}finally{this.inodeWriteUnlock(r)}return{...this.namespaceEntryIdentity(r),linkCount:this.r32(i+K)}}finally{this.inodeWriteUnlock(o)}}readlink(e){return this.withNamespaceLock(()=>this.readlinkUnlocked(e))}readlinkUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new I(t);return this.readSymlinkInodeUnlocked(t)}readSymlinkInodeUnlocked(e){let t=this.inodeOffset(e);if((this.r32(t+N)&W)!==pt)throw new I(Y);let i=this.r64(t+k);if(i<=40)return Et(this.u8.subarray(t+ne,t+ne+i));this.inodeReadLock(e);try{let s=new Uint8Array(i);return this.inodeReadData(e,0,s,i),tr.decode(s)}finally{this.inodeReadUnlock(e)}}opendir(e){return this.withNamespaceLock(()=>this.opendirUnlocked(e))}opendirUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new I(t);let r=this.inodeOffset(t);if((this.r32(r+N)&W)!==G)throw new I(be);let s=this.fdAlloc(t,et,!0);if(s<0)throw new I(s);return s}readdirEntry(e){return this.withNamespaceLock(()=>this.readdirEntryUnlocked(e))}readdirEntryUnlocked(e){let t=this.fdGet(e);if(!t||!t.isDir)throw new I(te);let r=this.inodeOffset(t.ino),i=this.r64(r+k);for(;t.offset=this.r32(We))throw new I(B);let h=this.r32(tt)*4096;if((this.r32(h+(d>>5)*4)&1<<(d&31))===0)throw new I(B);let _=Et(this.u8.subarray(l+P,l+P+p)),y=this.buildStat(d);return this.w64(f+Le,m),t.offset=m,{name:_,stat:y}}return null}closedir(e){this.close(e)}readdir(e){let t=this.opendir(e),r=[];try{let i;for(;(i=this.readdirEntry(t))!==null;)i.name!=="."&&i.name!==".."&&r.push(i.name)}finally{this.closedir(t)}return r}writeFile(e,t){let r=typeof t=="string"?pe.encode(t):t,i=this.open(e,Eo|er|rr);try{this.write(i,r)}finally{this.close(i)}}readFile(e){let t=this.open(e,et);try{let r=this.fstat(t),i=new Uint8Array(r.size);return this.read(t,i),i}finally{this.close(t)}}readFileText(e){return tr.decode(this.readFile(e))}};function zo(n,e){let t=new Map,r=new Map;for(let o of n){if(t.has(o.path))throw new Error(`${e} duplicates path ${o.path}`);if(t.set(o.path,o),o.type==="file"){if(!o.inodeGroup)throw new Error(`${e} file ${o.path} has no inode group`);if(r.has(o.inodeGroup))throw new Error(`${e} inode group ${o.inodeGroup} has multiple files`);r.set(o.inodeGroup,o)}}let i=new Set,s=new Map;for(let o of n){if(o.type!=="hardlink"||s.has(o.path))continue;let a=[],c=o,l;for(;c.type==="hardlink";){let u=s.get(c.path);if(u){l=u;break}if(i.has(c.path))throw new Error(`${e} hardlink cycle reaches ${c.path}`);if(i.add(c.path),a.push(c),!c.target)throw new Error(`${e} hardlink ${c.path} has no target`);let p=t.get(c.target);if(!p)throw new Error(`${e} hardlink ${c.path} target ${c.target} is missing`);if(p.type!=="file"&&p.type!=="hardlink"||!c.inodeGroup||p.inodeGroup!==c.inodeGroup||p.size!==c.size||p.mode!==c.mode)throw new Error(`${e} hardlink ${c.path} has an invalid target`);c=p}l??=c.type==="file"?c:void 0;let d=r.get(o.inodeGroup??"");if(!l||l!==d)throw new Error(`${e} hardlink ${o.path} does not resolve to its inode`);for(let u=a.length-1;u>=0;u-=1){let p=a[u];if(r.get(p.inodeGroup??"")!==l)throw new Error(`${e} hardlink ${p.path} does not resolve to its inode`);i.delete(p.path),s.set(p.path,l)}}return{canonicalByGroup:r,canonicalTargetByPath:s}}var fe={maxArchiveBytes:268435456,maxExpandedBytes:268435456,maxPayloadBytes:268435456,maxEntries:1e5,maxPathBytes:4096,maxSymlinkTargetBytes:65536,maxStringBytes:8192,maxTransportsPerTree:8,maxActivationCapabilities:32,maxActivationRoots:64,maxActivationCapabilityBytes:255},xe={maxArchiveBytes:512*1024*1024,maxExpandedBytes:512*1024*1024,maxPayloadBytes:512*1024*1024,maxEntries:1e5,maxGroups:512};function ko(n,e="Deferred tree collection"){for(let[t,r]of Object.entries(n))if(!Number.isSafeInteger(r)||r<0)throw new Error(`${e} ${t} usage is invalid`);if(n.groups>xe.maxGroups)throw new Error(`${e} exceeds the ${xe.maxGroups}-group cap`);if(n.archiveBytes>xe.maxArchiveBytes)throw new Error(`${e} exceeds the archive-byte cap`);if(n.expandedBytes>xe.maxExpandedBytes)throw new Error(`${e} exceeds the expansion cap`);if(n.payloadBytes>xe.maxPayloadBytes)throw new Error(`${e} exceeds the payload-byte cap`);if(n.entries>xe.maxEntries)throw new Error(`${e} exceeds the entry-count cap`)}var Po=Object.freeze({prefix:"/opt/kandelo/homebrew",cellar:"/opt/kandelo/homebrew/Cellar",repository:"/opt/kandelo/homebrew",stableEntrypoint:"/usr/bin/brew"});var No=1e5,mc=4096;var wt=Po.prefix,Mo=[["@@HOMEBREW_PREFIX@@",wt],["@@HOMEBREW_CELLAR@@",`${wt}/Cellar`],["@@HOMEBREW_REPOSITORY@@",wt],["@@HOMEBREW_LIBRARY@@",`${wt}/Library`],["@@HOMEBREW_PERL@@",`${wt}/opt/perl/bin/perl`]],Bn="@@HOMEBREW_JAVA@@",_c=/^openjdk(?:@\d+(?:\.\d+)*)?/,At=new TextEncoder,yc=[...Mo.map(([n])=>n),Bn].map(n=>({placeholder:n,bytes:At.encode(n)}));function Do(n){let e=gc(n),t=e.changed_files;if(t!=null&&!Array.isArray(t))throw new Error("INSTALL_RECEIPT.json changed_files must be an array or null when present");let r=Array.isArray(t)?t:[];if(r.length>No)throw new Error(`INSTALL_RECEIPT.json declares ${r.length} changed files, limit ${No}`);let i=[],s=new Set;for(let[o,a]of r.entries()){if(typeof a!="string")throw new Error(`INSTALL_RECEIPT.json changed_files[${o}] is not a string`);if(Sc(a,"Homebrew changed file"),s.has(a))throw new Error(`INSTALL_RECEIPT.json repeats changed file ${a}`);s.add(a),i.push(a)}return{changedFiles:i,runtimeDependencies:e.runtime_dependencies}}function gc(n){let e;try{e=JSON.parse(new TextDecoder("utf-8",{fatal:!0}).decode(n))}catch(t){throw new Error("INSTALL_RECEIPT.json is not valid UTF-8 JSON: "+Ac(t))}if(typeof e!="object"||e===null||Array.isArray(e))throw new Error("INSTALL_RECEIPT.json must contain an object");return e}function Ko(n,e,t){let r=n;for(let[o,a]of Mo)r=Co(r,At.encode(o),At.encode(a));let i=At.encode(Bn);if(Fo(r,i)){let o=Ec(e.runtimeDependencies);if(o===void 0)throw new Error(`Homebrew changed file ${t} uses ${Bn} without exactly one OpenJDK runtime dependency`);r=Co(r,i,At.encode(o))}let s=yc.find(({bytes:o})=>Fo(r,o));if(s!==void 0)throw new Error(`Homebrew changed file ${t} retains ${s.placeholder}`);return r}function Ec(n){if(!Array.isArray(n))return;let e=[];for(let r of n){if(typeof r!="object"||r===null||Array.isArray(r))continue;let i=r,s=typeof i.full_name=="string"?i.full_name.split("/").at(-1):typeof i.name=="string"?i.name.split("/").at(-1):void 0,o=s===void 0?null:_c.exec(s);s!==void 0&&o?.[0]===s&&e.push(s)}let t=[...new Set(e)];return t.length===1?`${wt}/opt/${t[0]}/libexec`:void 0}function Sc(n,e){if(n.length===0||n.startsWith("/")||n.includes("\\")||n.includes("\0")||wc(n)||At.encode(n).byteLength>mc||n.split("/").some(t=>t===""||t==="."||t===".."))throw new Error(`${e} has an unsafe path segment: ${n}`)}function wc(n){for(let e=0;e57343)){if(t<=56319&&e+1=56320&&n.charCodeAt(e+1)<=57343){e+=1;continue}return!0}}return!1}function Fo(n,e){if(e.byteLength===0||e.byteLength>n.byteLength)return!1;e:for(let t=0;t<=n.byteLength-e.byteLength;t+=1){for(let r=0;rjr||n.includes("\0")||n.includes("\\"))throw new Error(`Lazy archive mount prefix must be an absolute POSIX path: ${JSON.stringify(n)}`);let e=n.replace(/\/+$/,"");if(e==="")return"/";if(e.slice(1).split("/").some(r=>r===""||r==="."||r===".."))throw new Error(`Lazy archive mount prefix is not canonical: ${JSON.stringify(n)}`);return e}function Ol(n,e,t,r){let i=fr(t),s=new Map,o=e.map(a=>{let c=a.fileName,l=`Lazy archive ${JSON.stringify(n)} member ${JSON.stringify(c)}`;if(c.length===0)throw new Error(`${l} has an empty path`);if(c.includes("\0"))throw new Error(`${l} contains a NUL byte`);if(c.includes("\\"))throw new Error(`${l} contains a backslash`);if(c.startsWith("/")||/^[A-Za-z]:\//.test(c))throw new Error(`${l} must be relative, not absolute`);if(a.isDirectory&&a.isSymlink)throw new Error(`${l} has conflicting directory and symlink types`);if(a.isDirectory!==c.endsWith("/"))throw new Error(`${l} has inconsistent directory metadata`);let d=a.isDirectory?c.slice(0,-1):c,u=d.split("/");if(d.length===0||u.some(p=>p===""||p==="."||p===".."))throw new Error(`${l} is not a canonical relative POSIX path`);if(s.has(d))throw new Error(`${l} collides with another member at ${JSON.stringify(d)}`);if(a.isSymlink&&!r?.has(c))throw new Error(`Lazy archive symlink target was not provided: ${c}`);return s.set(d,a),{entry:a,archivePath:d,vfsPath:i==="/"?`/${d}`:`${i}/${d}`}});for(let{archivePath:a}of o){let c=a.split("/");for(let l=1;lRt)throw new Error(`VFS image metadata exceeds ${Rt} bytes`);let e;try{e=JSON.parse(new TextDecoder().decode(n))}catch(t){let r=t instanceof Error?t.message:String(t);throw new Error(`Invalid VFS image metadata JSON: ${r}`)}return hi(e)}function vl(n){if(n===null)return new Uint8Array(0);let e=hi(n),t=new TextEncoder().encode(JSON.stringify(e));if(t.byteLength>Rt)throw new Error(`VFS image metadata exceeds ${Rt} bytes`);return t}function Rl(n){return n.byteLength>=ar.length&&n[0]===ar[0]&&n[1]===ar[1]&&n[2]===ar[2]&&n[3]===ar[3]?Zl(n):n}function $r(n){let e=Rl(n);if(e.byteLengthGr)throw new Error(`VFS image lazy metadata exceeds ${Gr} bytes`);if(n.byteLengthHr)throw new Error(`VFS image lazy archive metadata exceeds ${Hr} bytes`);if(n.byteLength=0?r:void 0}function Tl(n){return n===408||n===429||n>=500&&n<=599}function zl(n,e=Date.now()){let t=n?.get("retry-after")?.trim();if(!t)return;let r;if(/^\d+$/.test(t))r=Number(t)*1e3;else{let i=Date.parse(t);if(!Number.isFinite(i))return;r=Math.max(0,i-e)}if(!(!Number.isSafeInteger(r)||r<0))return Math.min(r,xs)}function kl(n){if(!(typeof n!="object"||n===null||!("cause"in n)))return n.cause}function vs(n){if(!(typeof n!="object"||n===null||!("name"in n)))return typeof n.name=="string"?n.name:void 0}function Rs(n){if(!(typeof n!="object"||n===null||!("code"in n)))return typeof n.code=="string"?n.code:void 0}function Ls(n,e){let t=new Set,r=n;for(let i=0;r!==void 0&&i<8;i+=1){if(t.has(r))return!1;if(t.add(r),e(r))return!0;r=kl(r)}return!1}function bs(n){return Ls(n,e=>vs(e)==="AbortError"||Rs(e)==="ABORT_ERR")}function Pl(n){return bs(n)?!1:Ls(n,e=>{let t=vs(e),r=Rs(e);return e instanceof TypeError||t==="NetworkError"||t==="TimeoutError"||r!==void 0&&Al.has(r)})}function Nl(n,e){if(n instanceof qr){if(!Tl(n.status))return null;if(n.retryAfterMs!==void 0)return n.retryAfterMs}else if(!Pl(n))return null;return Math.min(wl*2**e,xs)}function ee(n){if(n?.aborted)throw n.reason}function Fl(n,e){return ee(e),n===0?Promise.resolve():new Promise((t,r)=>{let i=setTimeout(()=>a(!1),n),s=()=>a(!0,e.reason),o=!1;function a(c,l){o||(o=!0,clearTimeout(i),e?.removeEventListener("abort",s),c?r(l):t())}e?.addEventListener("abort",s,{once:!0}),e?.aborted&&s()})}async function ni(n,e){try{await n.body?.cancel(e)}catch{}}function Cl(n,e){if(n.length===1)return n[0];let t=new Uint8Array(e),r=0;for(let i of n)t.set(i,r),r+=i.byteLength;return t}function hr(n){if(n===void 0)return;if(typeof n!="object"||n===null||Array.isArray(n))throw new Error("Lazy archive integrity must be an object");let e=n;if(Object.keys(e).length!==2||!("sha256"in e)||!("bytes"in e))throw new Error("Lazy archive integrity has unexpected fields");if(typeof e.sha256!="string"||!ai.test(e.sha256))throw new Error("Lazy archive integrity has an invalid SHA-256 digest");if(!Number.isSafeInteger(e.bytes)||Number(e.bytes)<=0||Number(e.bytes)>ds)throw new Error(`Lazy archive integrity byte count must be between 1 and ${ds}`);return{sha256:e.sha256,bytes:Number(e.bytes)}}function qe(n,e,t){if(typeof n!="object"||n===null||Array.isArray(n))throw new Error(`${t} must be an object`);let r=n;if(Object.keys(r).length!==e.length||e.some(s=>!Object.prototype.hasOwnProperty.call(r,s)))throw new Error(`${t} has unexpected or missing fields`);return r}function li(n,e,t,r){if(typeof n!="object"||n===null||Array.isArray(n))throw new Error(`${r} must be an object`);let i=n,s=new Set(e);if(Object.keys(i).some(o=>!s.has(o))||t.some(o=>!Object.prototype.hasOwnProperty.call(i,o)))throw new Error(`${r} has unexpected or missing fields`);return i}function Ne(n,e,t,r){if(!Array.isArray(n)||n.lengthr)throw new Error(`${e} must contain ${t} to ${r} items`);return n}function _e(n,e,t){if(typeof n!="string"||n.length===0||n.includes("\0")||new TextEncoder().encode(n).byteLength>t)throw new Error(`${e} is invalid or exceeds ${t} bytes`);return n}function se(n,e,t,r){if(!Number.isSafeInteger(n)||Number(n)r)throw new Error(`${e} must be an integer between ${t} and ${r}`);return Number(n)}function Zr(n,e=1){let t=n,r=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.source!==void 0,i=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.modePolicy!==void 0,s=qe(n,["decoder","mediaType","sha256","bytes","expandedBytes","sourceEntryCount","transports",...i?["modePolicy"]:[],...r?["source"]:[]],"Lazy tree content"),o=s.decoder==="zip-v1"?"application/zip":s.decoder==="homebrew-bottle-tar-gzip-v1"?"application/vnd.oci.image.layer.v1.tar+gzip":null;if(o===null||s.mediaType!==o)throw new Error("Lazy tree decoder and media type are inconsistent");let a=hr({sha256:s.sha256,bytes:s.bytes});if(!a)throw new Error("Lazy tree integrity is required");let c=Ne(s.transports,"Lazy tree transports",e,fe.maxTransportsPerTree).map((m,f)=>_e(m,`Lazy tree transport ${f}`,fi));if(new Set(c).size!==c.length)throw new Error("Lazy tree transports contain duplicates");let l=se(s.expandedBytes,"Lazy tree expanded byte count",0,_l),d=se(s.sourceEntryCount,"Lazy tree source entry count",1,Lt),u=r?Kl(s.source,s.decoder):void 0,p=i?s.modePolicy:void 0;if(p!==void 0&&(p!=="portable-posix-v1"||s.decoder!=="zip-v1"||r))throw new Error("Lazy tree mode policy is invalid for its decoder");if(u!==void 0&&u.entries.length!==d)throw new Error("Lazy tree source inventory count differs from its content");return{decoder:s.decoder,mediaType:o,sha256:a.sha256,bytes:a.bytes,expandedBytes:l,sourceEntryCount:d,transports:c,...p===void 0?{}:{modePolicy:p},...u===void 0?{}:{source:u}}}function Ts(n){let e={groups:n.length,archiveBytes:0,expandedBytes:0,payloadBytes:0,entries:0};for(let t of n)t.content===void 0||t.inventory===void 0||(e.archiveBytes+=t.content.bytes,e.expandedBytes+=t.content.expandedBytes,e.payloadBytes+=t.inventory.filter(r=>r.type==="file").reduce((r,i)=>r+i.size,0),e.entries+=t.inventory.length+(t.content.source?.entries.length??0));return e}function ui(n){ko(n,"Serialized lazy tree collection")}function Ml(n){ui(Ts(n))}function Dl(n){let e=new Map;for(let t of n){let r=t.activation?.atomicGroup;if(r===void 0)continue;if(!vt(r))throw new Error(`Serialized lazy atomic activation group ${r.id} is unsealed`);let i=e.get(r.id);if(i===void 0)i={expectedCount:r.expectedCount,cohortSha256:r.cohortSha256,members:new Set,descriptors:new Set},e.set(r.id,i);else if(i.expectedCount!==r.expectedCount||i.cohortSha256!==r.cohortSha256)throw new Error(`Serialized lazy atomic activation group ${r.id} has inconsistent seals`);if(i.members.has(r.member)||i.descriptors.has(r.descriptorSha256))throw new Error(`Serialized lazy atomic activation group ${r.id} duplicates a member`);i.members.add(r.member),i.descriptors.add(r.descriptorSha256)}for(let[t,r]of e)if(r.members.size!==r.expectedCount)throw new Error(`Serialized lazy atomic activation group ${t} has ${r.members.size} of ${r.expectedCount} members`)}function ys(n){for(let[e,t]of n.entries())if(t.kind===dr||t.kind===ci||t.kind===ot)Ns(t,t.kind);else if(t.kind===ur)di(t,!1);else throw new Error(`Serialized lazy archive group ${e} has an unsupported kind`);Ml(n),Dl(n)}function Kl(n,e){if(e!=="homebrew-bottle-tar-gzip-v1")throw new Error("Lazy tree source inventory is valid only for original bottles");let t=qe(n,["schema","kind","entries"],"Lazy tree source inventory");if(t.schema!==1||t.kind!=="homebrew-bottle-tar-gzip-v1")throw new Error("Lazy tree source inventory has an unsupported identity");let r=new Map,i=Ne(t.entries,"Lazy tree source entries",1,Lt).map((o,a)=>{let c=o,l=typeof c=="object"&&c!==null&&!Array.isArray(c)?c.type:void 0,d=l==="directory"||l==="file"?["sourcePath","type","mode","size"]:l==="symlink"||l==="hardlink"?["sourcePath","type","mode","size","target"]:null;if(d===null)throw new Error(`Lazy tree source entry ${a} has invalid type`);let u=qe(o,d,`Lazy tree source entry ${a}`),p=ye(u.sourcePath,!1,`Lazy tree source entry ${a} path`);if(r.has(p))throw new Error(`Lazy tree source inventory duplicates ${p}`);let m=se(u.mode,`Lazy tree source entry ${p} mode`,0,4095),f=se(u.size,`Lazy tree source entry ${p} size`,0,Vr),h;if((l==="directory"||l==="symlink"||l==="hardlink")&&f!==0)throw new Error(`Lazy tree source ${p} has payload for ${String(l)}`);l==="symlink"?h=_e(u.target,`Lazy tree source symlink ${p} target`,Is):l==="hardlink"&&(h=ye(u.target,!1,`Lazy tree source hardlink ${p} target`));let g={sourcePath:p,type:l,mode:m,size:f,...h===void 0?{}:{target:h}};return r.set(p,g),g}),s=i.map(o=>o.sourcePath);if(s.some((o,a)=>a>0&&s[a-1]>=o))throw new Error("Lazy tree source inventory is not in canonical path order");return{schema:1,kind:"homebrew-bottle-tar-gzip-v1",entries:i}}function zs(n){let e=new Map(n.map(r=>[r.sourcePath,r])),t=new Map;for(let r of n){if(r.type!=="hardlink"||t.has(r.sourcePath))continue;let i=[],s=new Set,o=r,a;for(;o.type==="hardlink"&&(a=t.get(o.sourcePath),a===void 0);){if(s.has(o.sourcePath))throw new Error(`Lazy tree source hardlink cycle includes ${o.sourcePath}`);s.add(o.sourcePath),i.push(o);let c=e.get(o.target);if(c===void 0)throw new Error(`Lazy tree source hardlink ${o.sourcePath} target is absent`);if(c.type!=="file"&&c.type!=="hardlink")throw new Error(`Lazy tree source hardlink ${o.sourcePath} target is not regular`);o=c}a===void 0&&(a=o);for(let c of i)t.set(c.sourcePath,a)}return t}function ye(n,e,t,r=!1){if(typeof n!="string"||n.length===0||new TextEncoder().encode(n).byteLength>jr||n.includes("\0")||n.includes("\\")||n.startsWith("/")!==e)throw new Error(`${t} is not a canonical ${e?"absolute":"relative"} path`);if(r&&e&&n==="/")return n;if(n.slice(e?1:0).split("/").some(s=>s===""||s==="."||s===".."))throw new Error(`${t} has an unsafe path segment`);return n}function ks(n){let e=typeof n=="object"&&n!==null&&!Array.isArray(n)?n:null,t=e!==null&&(Object.hasOwn(e,"descriptorSha256")||Object.hasOwn(e,"expectedCount")||Object.hasOwn(e,"cohortSha256")),r=qe(n,t?["id","member","descriptorSha256","expectedCount","cohortSha256"]:["id","member"],"Lazy tree atomic activation membership"),i=_e(r.id,"Lazy tree atomic activation group",fs),s=_e(r.member,"Lazy tree atomic activation member",fs);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(i)||!/^[a-z0-9][a-z0-9:+._/-]*$/.test(s)||s.includes("//")||s.endsWith("/"))throw new Error("Lazy tree atomic activation membership is invalid");if(!t)return{id:i,member:s};let o=_e(r.descriptorSha256,"Lazy tree atomic member descriptor digest",64),a=_e(r.cohortSha256,"Lazy tree atomic cohort digest",64);if(!ai.test(o)||!ai.test(a))throw new Error("Lazy tree atomic activation digest is invalid");return{id:i,member:s,descriptorSha256:o,expectedCount:se(r.expectedCount,"Lazy tree atomic activation expected member count",1,Os),cohortSha256:a}}function vt(n){return n.descriptorSha256!==void 0&&n.expectedCount!==void 0&&n.cohortSha256!==void 0}function Bl(n){let e=qe(n,["uid","gid"],"Lazy tree registration owner");return{uid:se(e.uid,"Lazy tree registration owner uid",0,hs),gid:se(e.gid,"Lazy tree registration owner gid",0,hs)}}function Ps(n,e,t,r,i=1){let s=Zr(n,i),o=fr(t),a=["mode","capabilities","roots",...typeof r=="object"&&r!==null&&!Array.isArray(r)&&Object.hasOwn(r,"atomicGroup")?["atomicGroup"]:[]],c=qe(r,a,"Lazy tree activation");if(c.mode!=="boot-prefetch"&&c.mode!=="first-use")throw new Error("Lazy tree activation mode is invalid");let l=Ne(c.capabilities,"Lazy tree activation capabilities",1,El).map((S,O)=>{let x=_e(S,`Lazy tree activation capability ${O}`,fe.maxActivationCapabilityBytes);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(x))throw new Error(`Lazy tree activation capability ${O} is invalid`);return x}),d=Ne(c.roots,"Lazy tree activation roots",1,Sl).map((S,O)=>ye(S,!0,`Lazy tree activation root ${O}`,!0));if(new Set(l).size!==l.length||new Set(d).size!==d.length)throw new Error("Lazy tree activation contains duplicates");let u=c.atomicGroup===void 0?void 0:ks(c.atomicGroup);if(u!==void 0&&c.mode!=="first-use")throw new Error("Lazy tree atomic activation group requires a valid first-use identity");let p={mode:c.mode,capabilities:l,roots:d,...u===void 0?{}:{atomicGroup:u}},m=Ne(e,"Lazy tree inventory",1,Lt),f=[],h=new Map,g=new Map,_=s.source===void 0?void 0:new Map(s.source.entries.map(S=>[S.sourcePath,S])),y=s.source===void 0?void 0:zs(s.source.entries),E=0;for(let[S,O]of m.entries()){if(typeof O!="object"||O===null||Array.isArray(O))throw new Error(`Lazy tree entry ${S} must be an object`);let x=O.type,v=x==="directory"?["vfsPath","sourcePath","type","mode","size"]:x==="file"?["vfsPath","sourcePath","type","mode","size","inodeGroup"]:x==="symlink"?["vfsPath","sourcePath","type","mode","size","target"]:x==="hardlink"?["vfsPath","sourcePath","type","mode","size","target","inodeGroup"]:null;if(!v)throw new Error(`Lazy tree entry ${S} has an invalid type`);let L=qe(O,[...v,..._===void 0?[]:["materialization"]],`Lazy tree entry ${S}`),b=ye(L.vfsPath,!0,`Lazy tree entry ${S} VFS path`),D=ye(L.sourcePath,!1,`Lazy tree entry ${S} source path`),q=_===void 0?void 0:L.materialization;if(_!==void 0&&q!=="archive"&&q!=="archive-homebrew-relocate"&&q!=="archive-copy"&&q!=="archive-copy-mode"&&q!=="descriptor")throw new Error(`Lazy tree entry ${b} has invalid materialization provenance`);if(o!=="/"&&b!==o&&!b.startsWith(`${o}/`))throw new Error(`Lazy tree entry ${b} escapes its mount prefix`);if(h.has(b))throw new Error(`Lazy tree duplicates VFS path ${b}`);let F=se(L.mode,`Lazy tree entry ${b} mode`,0,4095),z=se(L.size,`Lazy tree entry ${b} size`,0,Vr),U,ue;if(x==="directory"){if(z!==0)throw new Error(`Lazy tree directory ${b} has nonzero size`)}else if(x==="symlink"){if(U=_e(L.target,`Lazy tree symlink ${b} target`,Is),new TextEncoder().encode(U).byteLength!==z)throw new Error(`Lazy tree symlink ${b} size differs from its target`)}else ue=_e(L.inodeGroup,`Lazy tree entry ${b} inode group`,jr),x==="hardlink"&&(U=ye(L.target,!0,`Lazy tree hardlink ${b} target`));if(x!=="hardlink"&&(E+=z,E>Vr))throw new Error("Lazy tree inventory exceeds the expansion limit");let C={vfsPath:b,sourcePath:D,...q===void 0?{}:{materialization:q},type:x,mode:F,size:z,...U===void 0?{}:{target:U},...ue===void 0?{}:{inodeGroup:ue}};if(_===void 0){let H=g.get(D);if(H){if(s.decoder!=="zip-v1"||C.type!=="hardlink"||H.inodeGroup!==C.inodeGroup)throw new Error(`Lazy tree duplicates source path ${D}`)}else{if(s.decoder==="zip-v1"&&C.type==="hardlink")throw new Error(`Lazy ZIP hardlink ${b} does not reuse a canonical source path`);g.set(D,C)}}else if(C.materialization==="descriptor"){if(C.type!=="directory"&&C.type!=="symlink")throw new Error(`Lazy tree descriptor entry ${b} is not structural`);if(_.has(D))throw new Error(`Lazy tree descriptor entry ${b} impersonates a source member`)}else{let H=_.get(D);if(H===void 0)throw new Error(`Lazy tree entry ${b} names absent source ${D}`);if(C.materialization==="archive-copy"||C.materialization==="archive-copy-mode"){if(C.type!=="file"||H.type!=="file"||C.materialization==="archive-copy"&&C.mode!==H.mode)throw new Error(`Lazy tree archive copy ${b} differs from its source`)}else if(C.materialization==="archive-homebrew-relocate"){if(C.type!=="file"&&C.type!=="hardlink"||H.type!==C.type||C.type==="file"&&H.mode!==C.mode)throw new Error(`Lazy tree receipt-relocated entry ${b} differs from its source`)}else if(H.type!==C.type||C.type==="symlink"&&H.target!==C.target||C.type!=="hardlink"&&H.mode!==C.mode)throw new Error(`Lazy tree archive entry ${b} differs from its source`)}f.push(C),h.set(b,C)}for(let S of f){let O=S.vfsPath.split("/").filter(Boolean);for(let x=1;x({path:S.vfsPath,type:S.type,mode:S.mode,size:S.size,target:S.target,inodeGroup:S.inodeGroup})),"Lazy tree");if(_!==void 0){let S=new Set;for(let O of f){if(O.materialization!=="archive-homebrew-relocate")continue;let x=_.get(O.sourcePath),v=x.type==="file"?x:y.get(x.sourcePath);if(v?.type!=="file")throw new Error(`Lazy tree receipt-relocated entry ${O.vfsPath} is not regular`);S.add(v.sourcePath)}for(let O of f){if(O.materialization==="descriptor"||O.type!=="file"&&O.type!=="hardlink")continue;let x=_.get(O.sourcePath),v=x.type==="file"?x:y.get(x.sourcePath);if(v?.type!=="file"||!S.has(v.sourcePath)&&O.size!==v.size)throw new Error(`Lazy tree archive entry ${O.vfsPath} differs from its source`)}for(let O of f){if(O.type!=="hardlink"||O.materialization!=="archive"&&O.materialization!=="archive-homebrew-relocate")continue;let x=_.get(O.sourcePath),v=h.get(O.target),L=y.get(x.sourcePath);if(x.target!==v?.sourcePath||L?.type!=="file"||L.mode!==O.mode||v?.mode!==O.mode)throw new Error(`Lazy tree hardlink ${O.vfsPath} differs from its source`)}}if(s.sourceEntryCount!==(_===void 0?g.size:_.size))throw new Error("Lazy tree source entry count differs from its inventory");if(s.source===void 0&&s.expandedBytesO.vfsPath===S||O.vfsPath.startsWith(`${S}/`)))throw new Error(`Lazy tree activation root ${S} is not owned by its inventory`);let w=new Map;for(let S of f)S.type==="file"&&w.set(S.inodeGroup,S);if(w.size!==A.canonicalByGroup.size)throw new Error("Lazy tree regular inode inventory is inconsistent");return{content:s,entries:f,mountPrefix:o,activation:p,canonicalByGroup:w}}function Xr(n){return JSON.stringify([n.sourcePath,n.type,n.inodeGroup,n.target])}function di(n,e){let t=li(n,["kind","content","url","mountPrefix","integrity","materialized","entries"],["url","mountPrefix","materialized","entries"],"Serialized legacy lazy archive");if(t.kind===void 0){if(!e)throw new Error("Serialized lazy archive is missing its kind discriminator")}else if(t.kind!==ur)throw new Error("Serialized legacy lazy archive has an unsupported kind");let r=_e(t.url,"Serialized legacy lazy archive URL",fi),i=fr(t.mountPrefix),s=hr(t.integrity);if(t.content!==void 0){if(!e||t.kind!==void 0)throw new Error("Typed legacy lazy archives cannot carry generic content");let c=Zr(t.content);if(c.decoder!=="zip-v1"||c.transports.length!==1||c.transports[0]!==r||!s||c.sha256!==s.sha256||c.bytes!==s.bytes)throw new Error("Untagged legacy ZIP content identity is inconsistent")}if(t.materialized!==!1)throw new Error("Serialized legacy lazy archive must describe pending content");let o=new Set,a=Ne(t.entries,"Serialized legacy lazy archive entries",1,Lt).map((c,l)=>{let d=li(c,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","size","isSymlink","deleted"],`Serialized legacy lazy archive entry ${l}`),u=ye(d.vfsPath,!0,`Serialized legacy lazy archive entry ${l} VFS path`);if(o.has(u))throw new Error(`Serialized legacy lazy archive duplicates path ${u}`);o.add(u);let p=se(d.ino,`Serialized legacy lazy archive entry ${u} inode`,1,Number.MAX_SAFE_INTEGER),m=d.generation===void 0?void 0:se(d.generation,`Serialized legacy lazy archive entry ${u} generation`,0,Number.MAX_SAFE_INTEGER),f=d.dataSequence===void 0?void 0:se(d.dataSequence,`Serialized legacy lazy archive entry ${u} data sequence`,0,Number.MAX_SAFE_INTEGER),h=se(d.size,`Serialized legacy lazy archive entry ${u} size`,0,Vr);if(d.isSymlink!==!1||d.deleted!==!1||d.materialized!==void 0&&d.materialized!==!1)throw new Error(`Serialized legacy lazy archive entry ${u} is not pending`);if(d.type!==void 0&&d.type!=="file")throw new Error(`Serialized legacy lazy archive entry ${u} has an invalid type`);let g=d.archivePath===void 0?void 0:ye(d.archivePath,!1,`Serialized legacy lazy archive entry ${u} archive path`),_=d.sourcePath===void 0?void 0:ye(d.sourcePath,!1,`Serialized legacy lazy archive entry ${u} source path`),y=d.inodeGroup===void 0?void 0:_e(d.inodeGroup,`Serialized legacy lazy archive entry ${u} inode group`,jr);if(d.target!==void 0)throw new Error(`Serialized legacy lazy archive entry ${u} has a link target`);return{vfsPath:u,ino:p,...m===void 0?{}:{generation:m},...f===void 0?{}:{dataSequence:f},size:h,isSymlink:!1,deleted:!1,materialized:!1,...g===void 0?{}:{archivePath:g},..._===void 0?{}:{sourcePath:_},type:"file",...y===void 0?{}:{inodeGroup:y}}});return{kind:ur,url:r,mountPrefix:i,...s===void 0?{}:{integrity:s},materialized:!1,entries:a}}function Ns(n,e){let t=qe(n,["kind","content","inventory","activation","url","mountPrefix","integrity","materialized","entries"],"Serialized lazy tree");if(t.kind!==e)throw new Error("Serialized lazy tree has an unsupported kind");let r=Ps(t.content,t.inventory,t.mountPrefix,t.activation);if(e!==ot&&e===dr!=(r.content.source===void 0))throw new Error(e===dr?"Serialized deferred-tree-v1 cannot contain original-bottle source metadata":"Serialized deferred-tree-v2 requires original-bottle source metadata");let i=r.activation.atomicGroup;if(e===ot?i===void 0||!vt(i):i!==void 0)throw new Error(e===ot?"Serialized deferred-tree-v3 requires a sealed atomic activation":"Atomic activation requires serialized deferred-tree-v3");let s=_e(t.url,"Serialized lazy tree URL",fi);if(s!==r.content.transports[0])throw new Error("Serialized lazy tree URL differs from its primary transport");let o=hr(t.integrity);if(!o||o.sha256!==r.content.sha256||o.bytes!==r.content.bytes)throw new Error("Serialized lazy tree integrity differs from its content");if(t.materialized!==!1)throw new Error("Serialized lazy tree must describe pending content");let a=new Map(r.entries.map(p=>[p.vfsPath,p])),c=new Map(r.entries.map(p=>[Xr(p),p])),l=Ne(t.entries,"Serialized lazy tree entries",0,Lt),d=new Set,u=l.map((p,m)=>{let f=li(p,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup"],`Serialized lazy tree entry ${m}`),h=ye(f.vfsPath,!0,`Serialized lazy tree entry ${m} VFS path`);if(d.has(h))throw new Error(`Serialized lazy tree duplicates pending path ${h}`);d.add(h);let g=ye(f.sourcePath,!1,`Serialized lazy tree entry ${m} source path`),_=ye(f.archivePath,!1,`Serialized lazy tree entry ${m} archive path`),y=a.get(h),E=c.get(Xr({sourcePath:g,type:typeof f.type=="string"?f.type:void 0,inodeGroup:typeof f.inodeGroup=="string"?f.inodeGroup:void 0,target:typeof f.target=="string"?f.target:void 0}))??y;if(!E||E.type!=="file"&&E.type!=="hardlink"||y?.inodeGroup!==void 0&&y.inodeGroup!==E.inodeGroup)throw new Error(`Serialized lazy tree entry ${h} is absent from its inventory`);let A=r.canonicalByGroup.get(E.inodeGroup);if(f.type!==E.type||f.inodeGroup!==E.inodeGroup||f.size!==E.size||_!==A?.sourcePath||f.target!==E.target||f.isSymlink!==!1||f.deleted!==!1||f.materialized!==!1)throw new Error(`Serialized lazy tree entry ${h} disagrees with its inventory`);let w=se(f.ino,`Serialized lazy tree entry ${h} inode`,1,Number.MAX_SAFE_INTEGER),S=se(f.generation,`Serialized lazy tree entry ${h} generation`,0,Number.MAX_SAFE_INTEGER),O=se(f.dataSequence,`Serialized lazy tree entry ${h} data sequence`,0,Number.MAX_SAFE_INTEGER);return{vfsPath:h,ino:w,generation:S,dataSequence:O,size:E.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:_,sourcePath:g,type:E.type,inodeGroup:E.inodeGroup,...E.target===void 0?{}:{target:E.target}}});for(let p of r.entries)if(r.activation.atomicGroup!==void 0&&(p.type==="file"||p.type==="hardlink")&&!d.has(p.vfsPath))throw new Error(`Serialized lazy tree omits pending path ${p.vfsPath}`);return{kind:e,content:r.content,inventory:r.entries,activation:r.activation,url:s,mountPrefix:r.mountPrefix,integrity:o,materialized:!1,entries:u}}async function lr(n,e){let t=globalThis.crypto?.subtle;if(!t)throw new Error(`${e} SHA-256 verification is unavailable`);let r=new Uint8Array(n.byteLength);r.set(n);let i=new Uint8Array(await t.digest("SHA-256",r));return Array.from(i,s=>s.toString(16).padStart(2,"0")).join("")}async function ii(n,e,t){if(t===void 0)return;if(n.byteLength!==t.bytes)throw new Error(`Lazy ${e} byte count ${n.byteLength} does not match expected ${t.bytes}`);let r=await lr(n,`Lazy ${e}`);if(r!==t.sha256)throw new Error(`Lazy ${e} SHA-256 ${r} does not match expected ${t.sha256}`)}function $l(n,e,t,r){let i=r.atomicGroup;if(i===void 0)throw new Error("Lazy atomic member is missing its typed tree descriptor");let s={schema:1,content:{decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...n.source===void 0?{}:{source:n.source}},mountPrefix:t,inventory:[...e].sort((o,a)=>o.vfsPatha.vfsPath?1:0),activation:{mode:r.mode,capabilities:r.capabilities,roots:r.roots,atomicGroup:{id:i.id,member:i.member}}};return new TextEncoder().encode(JSON.stringify(s))}function gs(n,e){let t=e.map(({member:r,descriptorSha256:i})=>({member:r,descriptorSha256:i}));return new TextEncoder().encode(JSON.stringify({schema:1,id:n,members:t.sort((r,i)=>r.memberi.member?1:0)}))}function Ul(n,e){if(n.byteLength!==e.byteLength)return!1;for(let t=0;tObject.freeze({...i}))};return r!==void 0&&(Object.freeze(r.entries),Object.freeze(r)),Object.freeze({decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,transports:t,...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...r===void 0?{}:{source:r}})}function Es(n){return{decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,transports:[...n.transports],...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...n.source===void 0?{}:{source:{schema:1,kind:"homebrew-bottle-tar-gzip-v1",entries:n.source.entries.map(e=>({...e}))}}}}function Wl(n){let e=n.map(t=>Object.freeze({...t}));return Object.freeze(e),e}function Gl(n,e,t){let r=[...n.capabilities],i=[...n.roots];return Object.freeze(r),Object.freeze(i),Object.freeze({mode:n.mode,capabilities:r,roots:i,atomicGroup:Object.freeze({id:e,member:t})})}function Hl(n){return{mode:n.activation.mode,capabilities:[...n.activation.capabilities],roots:[...n.activation.roots],atomicGroup:{id:n.id,member:n.member,descriptorSha256:n.descriptorSha256,expectedCount:n.expectedCount,cohortSha256:n.cohortSha256}}}function Vl(n,e){return n.ino===e.ino&&n.generation===e.generation&&n.dataSequence===e.dataSequence&&n.size===e.size&&n.isSymlink===e.isSymlink&&n.deleted===e.deleted&&n.materialized===e.materialized&&n.archivePath===e.archivePath&&n.sourcePath===e.sourcePath&&n.type===e.type&&n.inodeGroup===e.inodeGroup&&n.target===e.target}function Ur(n,e,t){let r=n.content,i=n.inventory,s=n.activation,o=n.integrity,a=n.entries,c=n.url,l=n.mountPrefix,d=n.materialized,u=s?.atomicGroup;if(r===void 0||i===void 0||s===void 0||u===void 0||s.mode!=="first-use"||u.id!==e||u.member!==t||d)throw new Error(`Lazy atomic activation member ${t} changed before snapshot`);if(o?.sha256!==r.sha256||o?.bytes!==r.bytes||c!==(r.transports[0]??""))throw new Error(`Lazy atomic activation member ${t} has inconsistent integrity`);let p=Fs(r),m=Wl(i),f=Gl(s,e,t),h=new Map;for(let A of m)A.type==="file"&&h.set(A.inodeGroup,A.sourcePath);let g=m.filter(A=>A.type!=="directory");if(a.size!==g.length)throw new Error(`Lazy atomic activation member ${t} has inconsistent runtime entries`);let _=g.map(A=>{let w=a.get(A.vfsPath),S=A.type==="symlink",O=S?A.sourcePath:h.get(A.inodeGroup),x=w!==void 0&&(w.sourcePath===A.sourcePath&&w.type===A.type&&w.target===A.target||A.type==="hardlink"&&w.sourcePath===O&&w.type==="file"&&w.target===void 0),v=w===void 0?["missing"]:[O===void 0?"archivePath source":void 0,w.generation===void 0?"generation":void 0,w.dataSequence===void 0?"dataSequence":void 0,w.size!==A.size?"size":void 0,w.isSymlink!==S?"symlink kind":void 0,w.deleted?"deletion state":void 0,w.materialized!==S?"materialization state":void 0,w.archivePath!==O?"archivePath":void 0,x?void 0:"descriptor mapping",w.inodeGroup!==A.inodeGroup?"inode group":void 0].filter(b=>b!==void 0);if(v.length>0)throw new Error(`Lazy atomic activation member ${t} has inconsistent mapping at ${A.vfsPath}: ${v.join(", ")}`);let L=w;return Object.freeze({vfsPath:A.vfsPath,ino:L.ino,generation:L.generation,dataSequence:L.dataSequence,size:L.size,isSymlink:L.isSymlink,deleted:!1,materialized:L.materialized,archivePath:O,sourcePath:A.sourcePath,type:A.type,...A.inodeGroup===void 0?{}:{inodeGroup:A.inodeGroup},...A.target===void 0?{}:{target:A.target}})});Object.freeze(_);let y=Object.freeze({sha256:p.sha256,bytes:p.bytes}),E=$l(p,m,l,f);return Object.freeze({id:e,member:t,descriptorBytes:E,content:p,inventory:m,activation:f,url:p.transports[0]??"",mountPrefix:l,integrity:y,entries:_})}function Ss(n,e,t,r){return Object.freeze({...n,descriptorSha256:e,expectedCount:t,cohortSha256:r})}function ws(n,e){return n.id!==e.id||n.member!==e.member||n.url!==e.url||n.mountPrefix!==e.mountPrefix||n.integrity.sha256!==e.integrity.sha256||n.integrity.bytes!==e.integrity.bytes||n.content.transports.length!==e.content.transports.length||n.content.transports.some((t,r)=>t!==e.content.transports[r])||!Ul(n.descriptorBytes,e.descriptorBytes)||n.entries.length!==e.entries.length?!1:n.entries.every((t,r)=>{let i=e.entries[r];return i!==void 0&&t.vfsPath===i.vfsPath&&Vl(t,i)})}function ql(n,e){let t=Fs(n.content,n.content.transports.map(e));return Object.freeze({...n,content:t,url:t.transports[0]??""})}function As(n){return{paths:Array.from(n.paths),expectedIno:n.ino,expectedGeneration:n.generation,expectedDataSequence:n.dataSequence,data:n.content}}var Yr=class n{fs;imageMetadata;lazyFiles=new Map;lazyArchiveGroups=[];deferredTreeMaterializationHandles=new WeakMap;lazyArchiveInodes=new Map;lazyAtomicGroups=new Map;lazyAtomicGroupByTree=new WeakMap;sealedLazyAtomicStates=new WeakMap;lazyDownloadListeners=new Set;lazyPreparations=new Map;lazyTransport={fetcher:(e,t)=>t===void 0?globalThis.fetch(e):globalThis.fetch(e,t)};constructor(e,t=null){this.fs=e,this.imageMetadata=t}static inodeKey(e,t){return`${e}:${t}`}static canAdoptLegacyLazyStub(e){return(e.mode&Pe)===cr&&e.size===0&&e.dataSequence<=1}reconcileLazyIdentityState(e){for(let[t,r]of this.lazyFiles){let i=e.get(t);if(!i||i.dataSequence!==r.dataSequence||i.paths.length===0){this.lazyFiles.delete(t);continue}r.paths=new Set(i.paths),r.paths.has(r.path)||(r.path=i.paths[0])}this.lazyArchiveInodes.clear();for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(!r?.committed)for(let c of i.snapshot.entries){if(c.isSymlink||c.materialized||c.generation===void 0)continue;let l=n.inodeKey(c.ino,c.generation),d=e.get(l);d!==void 0&&d.dataSequence===c.dataSequence&&d.paths.length>0&&this.lazyArchiveInodes.set(l,t)}continue}let s=t.content!==void 0&&t.inventory!==void 0&&!t.materialized,o=new Map;for(let c of t.entries.values()){if(c.deleted||c.materialized||c.generation===void 0)continue;let l=n.inodeKey(c.ino,c.generation);o.has(l)||o.set(l,c)}let a=new Map(Array.from(t.entries.entries()).filter(([,c])=>c.deleted||c.isSymlink&&!c.deleted));for(let[c,l]of o){let d=e.get(c);if(!(!d||d.dataSequence!==(l.dataSequence??0))){for(let u of d.paths)a.set(u,{...l,ino:d.ino,generation:d.generation,dataSequence:d.dataSequence,deleted:!1,materialized:!1});d.paths.length>0&&this.lazyArchiveInodes.set(c,t)}}t.entries=a,t.materialized=!Array.from(a.values()).some(c=>!c.isSymlink&&!c.materialized)&&!s&&(r===void 0||r.committed)}}validatePendingLazyTreeNamespaceState(e){let t=new Map;for(let r of e.values())for(let i of r.paths){if(t.has(i))throw new Error(`SharedFS namespace identity is ambiguous at ${i}`);t.set(i,r)}for(let r of this.lazyArchiveGroups){let i=this.lazyAtomicGroupByTree.get(r),o=this.sealedLazyAtomicStates.get(r)?.snapshot;if(i?.committed||o===void 0&&r.materialized||o===void 0&&(r.content===void 0||r.inventory===void 0))continue;let a=o?.inventory??r.inventory,c=o===void 0?r.entries:new Map(o.entries.map(m=>[m.vfsPath,m])),l=new Map,d=new Map,u=new Set;for(let m of c.values())m.deleted&&m.inodeGroup!==void 0&&u.add(m.inodeGroup);for(let m of a){if(m.type!=="file"&&m.type!=="hardlink")continue;l.set(m.inodeGroup,(l.get(m.inodeGroup)??0)+1);let f=d.get(m.inodeGroup)??[];f.push(m.vfsPath),d.set(m.inodeGroup,f)}let p=new Set([...u].filter(m=>d.get(m)?.every(f=>!t.has(f))));for(let m of a){let f=t.get(m.vfsPath);if(f===void 0){if(m.inodeGroup!==void 0&&p.has(m.inodeGroup))continue;throw new Error(`Lazy tree namespace entry ${m.vfsPath} is missing from the captured filesystem state`)}let h=m.type==="directory"?it:m.type==="symlink"?Br:cr;if((f.mode&Pe)!==h||(f.mode&4095)!==m.mode)throw new Error(`Lazy tree namespace entry ${m.vfsPath} disagrees with its captured type or mode`);if(m.type==="directory")continue;let g=c.get(m.vfsPath);if(g===void 0||g.ino!==f.ino||g.generation!==f.generation||g.dataSequence!==f.dataSequence)throw new Error(`Lazy tree namespace entry ${m.vfsPath} changed identity before serialization`);if(m.type==="symlink"){let _=new TextEncoder().encode(m.target).byteLength;if(f.linkCount!==1||f.size!==m.size||f.size!==_||f.symlinkTarget!==m.target)throw new Error(`Lazy tree symlink ${m.vfsPath} disagrees with its captured inventory`);continue}if(f.size!==0||f.linkCount!==l.get(m.inodeGroup))throw new Error(`Lazy tree stub ${m.vfsPath} has changed data or undeclared aliases`)}}}lazyFileForStat(e){let t=n.inodeKey(e.ino,e.generation),r=this.lazyFiles.get(t);if(r&&r.dataSequence!==e.dataSequence){this.lazyFiles.delete(t);return}return r}lazyArchiveEntriesForRead(e){let t=this.lazyAtomicGroupByTree.get(e),r=this.sealedLazyAtomicStates.get(e);return r!==void 0&&!t?.committed?r.snapshot.entries:Array.from(e.entries,([i,s])=>({vfsPath:i,...s}))}lazyArchiveForStat(e){let t=n.inodeKey(e.ino,e.generation),r=this.lazyArchiveInodes.get(t);if(!r)return;let i=this.lazyArchiveEntriesForRead(r).filter(o=>o.ino===e.ino&&o.generation===e.generation&&!o.deleted&&!o.materialized);if(i.some(o=>o.dataSequence===e.dataSequence))return r;if(this.lazyArchiveInodes.delete(t),this.sealedLazyAtomicStates.get(r)===void 0)for(let o of i)o.materialized=!0}lazyBackingForStat(e){let t=n.inodeKey(e.ino,e.generation),r=this.lazyFiles.get(t);if(r)return{token:r,path:r.path};let i=this.lazyArchiveInodes.get(t);if(!i)return null;let s=this.lazyArchiveEntriesForRead(i).find(a=>a.ino===e.ino&&a.generation===e.generation&&!a.deleted&&!a.materialized)?.vfsPath;if(s===void 0)return null;let o=this.lazyAtomicGroupByTree.get(i);return o===void 0?{token:i,path:s}:{token:o.token,path:s,atomicGroup:o}}lazyBackingForPath(e){let t=this.lazyArchiveGroups.find(r=>{let i=this.lazyAtomicGroupByTree.get(r),s=this.sealedLazyAtomicStates.get(r)?.snapshot,o=s===void 0?!r.materialized:!i?.committed,a=s?.content??r.content,c=s?.inventory??r.inventory,l=s?.activation??r.activation,d=s?.entries??Array.from(r.entries.values());return o&&a!==void 0&&c!==void 0&&l!==void 0&&d.every(u=>u.deleted||u.materialized||u.isSymlink)&&l.roots.some(u=>u==="/"||e===u||e.startsWith(`${u}/`))});if(t){let r=this.lazyAtomicGroupByTree.get(t);return{token:r?.token??t,path:e,directGroup:t,...r===void 0?{}:{atomicGroup:r}}}try{let r=this.fs.stat(e),i=this.lazyBackingForStat(r);return i?{...i,path:e}:null}catch{return null}}startLazyPreparation(e){let{path:t,token:r}=e,i={status:"pending",promise:Promise.resolve(!1)},s=e.atomicGroup?this.ensureAtomicLazyGroupMaterialized(e.atomicGroup).then(()=>!0):e.directGroup?this.ensureArchiveMaterialized(e.directGroup).then(()=>!0):this.materializePath(t);return i.promise=s.then(o=>(i.status="fulfilled",this.lazyPreparations.get(r)===i&&this.lazyPreparations.delete(r),o),o=>{throw i.status="rejected",i.error=o,o}),i.promise.catch(()=>{}),this.lazyPreparations.set(r,i),i}registerLazyAtomicGroupMembership(e,t=!1){let r=e.activation?.atomicGroup;if(r===void 0)return;let{id:i,member:s}=r;if(e.content===void 0||e.inventory===void 0||e.activation?.mode!=="first-use")throw new Error(`Lazy atomic activation group ${i} accepts only typed first-use trees`);let o=this.lazyAtomicGroups.get(i);if(o===void 0)o={id:i,token:Object.freeze({id:i}),groups:new Map,committed:!1},this.lazyAtomicGroups.set(i,o);else if(o.committed)throw new Error(`Lazy atomic activation group ${i} is already materialized`);if(o.groups.has(s))throw new Error(`Lazy atomic activation group ${i} duplicates member ${s}`);if(vt(r)){if(o.expectedCount!==void 0&&(o.expectedCount!==r.expectedCount||o.cohortSha256!==r.cohortSha256))throw new Error(`Lazy atomic activation group ${i} has inconsistent seals`);o.expectedCount=r.expectedCount,o.cohortSha256=r.cohortSha256;let a=Ur(e,i,s);this.sealedLazyAtomicStates.set(e,{snapshot:Ss(a,r.descriptorSha256,r.expectedCount,r.cohortSha256),verified:t})}else if(o.expectedCount!==void 0)throw new Error(`Lazy atomic activation group ${i} mixes sealed and unsealed members`);o.groups.set(s,e),this.lazyAtomicGroupByTree.set(e,o)}async sealLazyAtomicGroup(e,t){let r=t.map(l=>ks({id:e,member:l}).member).sort();if(r.length===0||new Set(r).size!==r.length)throw new Error(`Lazy atomic activation group ${e} expected members are invalid`);let i=this.lazyAtomicGroups.get(e);if(i===void 0||i.committed)throw new Error(`Lazy atomic activation group ${e} is not pending`);let s=[...i.groups.keys()].sort();if(JSON.stringify(s)!==JSON.stringify(r))throw new Error(`Lazy atomic activation group ${e} members differ from its seal`);if(i.expectedCount!==void 0){if(i.expectedCount!==r.length||i.cohortSha256===void 0)throw new Error(`Lazy atomic activation group ${e} has an invalid existing seal`);await this.ensureLazyAtomicGroupSealValidated(i,r.map(l=>i.groups.get(l)),!0);return}let o=r.map(l=>Ur(i.groups.get(l),e,l)),a=[];for(let l of o)a.push({member:l.member,descriptorSha256:await lr(l.descriptorBytes,`Lazy atomic member ${l.member}`),source:l});let c=await lr(gs(e,a),`Lazy atomic activation group ${e}`);for(let l of a){let d=i.groups.get(l.member),u=Ur(d,e,l.member);if(!ws(l.source,u))throw new Error(`Lazy atomic activation member ${l.member} changed while sealing`)}for(let l of a){let d=i.groups.get(l.member);d.activation.atomicGroup={id:e,member:l.member,descriptorSha256:l.descriptorSha256,expectedCount:a.length,cohortSha256:c},this.sealedLazyAtomicStates.set(d,{snapshot:Ss(l.source,l.descriptorSha256,a.length,c),verified:!0})}i.expectedCount=a.length,i.cohortSha256=c}async verifyImportedLazyAtomicGroupSeals(){await this.validatePendingLazyAtomicGroupSeals(!0)}guardSynchronousLazyAccess(e){let t=this.lazyBackingForPath(e);if(!t)return;let r=this.lazyPreparations.get(t.token);if(r?.status==="fulfilled"){this.lazyPreparations.delete(t.token);let s=this.lazyBackingForPath(e);if(!s)return;r=this.lazyPreparations.get(s.token)??this.startLazyPreparation(s)}else if(r?.status==="rejected"){this.lazyPreparations.delete(t.token);let s=r.error instanceof Error?r.error.message:String(r.error),o=new Error(`EIO: lazy backing for ${e} failed: ${s}`);throw o.code="EIO",o.cause=r.error,o}else r||(r=this.startLazyPreparation(t));let i=new Error(`EAGAIN: lazy backing for ${e} is being prepared`);throw i.code="EAGAIN",i}invalidateLazyData(e){let t=n.inodeKey(e.ino,e.generation);this.lazyFiles.delete(t);let r=this.lazyArchiveInodes.get(t);if(r){this.lazyArchiveInodes.delete(t);for(let i of r.entries.values())i.ino===e.ino&&i.generation===e.generation&&(i.materialized=!0)}}rewriteLazyNamespacePaths(e,t,r){let i=t.length>1?t.replace(/\/+$/,""):t,s=r.length>1?r.replace(/\/+$/,""):r,o=`${i}/`,a=`${s}/`,c=n.inodeKey(e.ino,e.generation),l=(e.mode&Pe)===it,d=u=>u===i?s:l&&u.startsWith(o)?a+u.slice(o.length):u;for(let[u,p]of this.lazyFiles)!l&&u!==c||(p.paths=new Set(Array.from(p.paths,d)),p.path=d(p.path));for(let u of this.lazyArchiveGroups){let p=new Map;for(let[m,f]of u.entries){let h=f.generation===void 0?null:n.inodeKey(f.ino,f.generation);p.set(l||h===c?d(m):m,f)}u.entries=p,u.inventory&&(u.inventory=u.inventory.map(m=>({...m,vfsPath:d(m.vfsPath),...m.type==="hardlink"&&m.target!==void 0?{target:d(m.target)}:{}}))),u.activation&&(u.activation={...u.activation,roots:u.activation.roots.map(d)})}}get sharedBuffer(){return this.fs.buffer}static create(e,t){return new n(Te.mkfs(e,t))}static fromExisting(e){return new n(Te.mount(e))}rebaseToNewFileSystem(e){if(!Number.isSafeInteger(e)||e<=0)throw new Error(`Invalid MemoryFileSystem maxByteLength: ${e}`);let t=SharedArrayBuffer,{bytes:r,identities:i}=this.fs.snapshotState();this.reconcileLazyIdentityState(i);let s=this.serializeLazyEntries(),o=this.serializeValidatedLazyArchiveEntries(i),a=new t(r.byteLength);new Uint8Array(a).set(r);let c=new n(Te.mount(a,{restoreImage:!0}),this.imageMetadata);c.importLazyEntries(s),c.importLazyArchiveEntriesInternal(o,!1,!0,"verified");let l=Math.min(e,Math.max(r.byteLength,ml)),d=new t(l,{maxByteLength:e}),u=n.create(d,e);u.setImageMetadata(this.imageMetadata);let p=new Set(s.flatMap(f=>f.paths??[f.path])),m=new Set;for(let f of o)if(!f.materialized)for(let h of f.entries)!h.deleted&&!h.isSymlink&&m.add(h.vfsPath);return c.copyPathToFreshFileSystem("/",u,p,m,new Map),u.importLazyEntries(s.map(f=>{let h=u.fs.lstat(f.path);return{...f,ino:h.ino,generation:h.generation,dataSequence:h.dataSequence}})),u.importLazyArchiveEntriesInternal(o.map(f=>({...f,entries:f.entries.map(h=>{if(h.deleted)return{...h,ino:0,generation:void 0};let g=u.fs.lstat(h.vfsPath);return{...h,ino:g.ino,generation:g.generation,dataSequence:g.dataSequence}})})),!1,!0,"verified"),u}getImageMetadata(){return Il(this.imageMetadata)}setImageMetadata(e){this.imageMetadata=e===null?null:hi(e)}subscribeLazyDownloads(e){return this.lazyDownloadListeners.add(e),()=>this.lazyDownloadListeners.delete(e)}setLazyFetcher(e,t={}){this.lazyTransport={fetcher:e,...t.signal===void 0?{}:{signal:t.signal}}}emitLazyDownload(e){if(this.lazyDownloadListeners.size===0)return;let t={...e,t:Ll()};for(let r of this.lazyDownloadListeners)try{r(t)}catch{}}async fetchLazyBytes(e,t){let r=0,i=e.integrity?.bytes??e.fallbackTotalBytes,s={id:e.id,kind:e.kind,url:e.url,path:e.path,mountPrefix:e.mountPrefix};for(let o=0;oe.integrity.bytes)throw new Error(`Lazy ${e.kind} exceeded expected byte count ${e.integrity.bytes}`);this.emitLazyDownload({...s,status:"progress",loadedBytes:r,totalBytes:i})}}}catch(u){try{await c.cancel(u)}catch{}throw u}}finally{c.releaseLock()}let d=Cl(l,r);return ee(t.signal),await ii(d,e.kind,e.integrity),ee(t.signal),this.emitLazyDownload({...s,status:"complete",loadedBytes:r,totalBytes:i??r}),d}catch(a){if(t.signal?.aborted){let d=t.signal.reason,u=d instanceof Error?d.message:String(d);throw this.emitLazyDownload({...s,status:"error",loadedBytes:r,totalBytes:i,error:u}),d}let c=o+1({...y})),activation:u,entries:new Map},g=y=>{let E=y.split("/").filter(Boolean),A="";for(let w=0;wE.vfsPath.split("/").length-A.vfsPath.split("/").length))if(y.type==="directory"){g(y.vfsPath);try{this.fs.mkdir(y.vfsPath,y.mode),this.fs.chmod(y.vfsPath,y.mode)}catch{if((this.fs.lstat(y.vfsPath).mode&Pe)!==it)throw new Error(`Lazy tree directory collides at ${y.vfsPath}`)}}for(let y of l){if(y.type!=="symlink")continue;g(y.vfsPath),this.fs.symlink(y.target,y.vfsPath);let E=this.fs.lstat(y.vfsPath);h.entries.set(y.vfsPath,{ino:E.ino,generation:E.generation,dataSequence:E.dataSequence,size:y.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:y.sourcePath,sourcePath:y.sourcePath,type:"symlink",target:y.target})}let _=new Map;for(let y of l){if(y.type!=="file")continue;g(y.vfsPath);let E=this.fs.createLazyStub(y.vfsPath,y.mode);this.invalidateLazyData(E),_.set(y.inodeGroup,E);let A={ino:E.ino,generation:E.generation,dataSequence:E.dataSequence,size:y.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:y.sourcePath,sourcePath:y.sourcePath,type:"file",inodeGroup:y.inodeGroup};h.entries.set(y.vfsPath,A)}for(let y of l){if(y.type!=="hardlink")continue;let E=p.get(y.inodeGroup);g(y.vfsPath),this.fs.link(E.vfsPath,y.vfsPath);let A=this.fs.lstat(y.vfsPath),w=_.get(y.inodeGroup);if(A.ino!==w.ino||A.generation!==w.generation)throw new Error(`Lazy tree hardlink ${y.vfsPath} did not share its inode`);h.entries.set(y.vfsPath,{ino:A.ino,generation:A.generation,dataSequence:A.dataSequence,size:y.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:E.sourcePath,sourcePath:y.sourcePath,type:"hardlink",inodeGroup:y.inodeGroup,target:y.target})}if(m!==void 0)for(let y of l)this.lchown(y.vfsPath,m.uid,m.gid);for(let y of h.entries.values())y.isSymlink||y.generation===void 0||this.lazyArchiveInodes.set(n.inodeKey(y.ino,y.generation),h);return this.lazyArchiveGroups.push(h),this.registerLazyAtomicGroupMembership(h),h}registerLazyTreeWithMaterializationHandle(e,t,r="/",i,s){let o=this.registerLazyTreeInternal(e,t,r,i,!0,s),a=Object.freeze({[dl]:!0});return this.deferredTreeMaterializationHandles.set(a,o),a}registerLazyArchiveFromEntries(e,t,r,i,s){let o=fr(r),a=Ol(e,t,o,i);a.some(({entry:l})=>!l.isDirectory&&!l.isSymlink)&&this.assertCanRegisterPendingLazyArchiveGroup();let c={...s?{content:Zr({decoder:"zip-v1",mediaType:"application/zip",sha256:s.sha256,bytes:s.bytes,expandedBytes:a.reduce((l,d)=>l+d.entry.uncompressedSize,0),sourceEntryCount:a.length,transports:[e]})}:{},url:e,mountPrefix:o,integrity:hr(s),materialized:!1,entries:new Map};for(let{entry:l,vfsPath:d}of a){if(l.isDirectory)continue;let u=d.split("/").filter(Boolean),p="";for(let m=0;ml.deleted||l.materialized),this.lazyArchiveGroups.push(c),c}importLazyArchiveEntries(e){this.importLazyArchiveEntriesInternal(e,!1,!0,"reject")}async importVerifiedLazyArchiveEntries(e){let t=structuredClone(e),r=this.exportLazyArchiveEntries(),i=n.fromExisting(this.sharedBuffer);i.importLazyArchiveEntriesInternal([...r,...t],!1,!0,"pending"),await i.verifyImportedLazyAtomicGroupSeals(),this.importLazyArchiveEntriesInternal(t,!1,!0,"verified")}importLazyArchiveEntriesInternal(e,t,r,i){let s=Ne(e,"Serialized lazy archive groups",0,Os).map((d,u)=>{if(typeof d!="object"||d===null||Array.isArray(d))throw new Error(`Serialized lazy archive group ${u} must be an object`);let p=d.kind;if(p===dr||p===ci||p===ot)return Ns(d,p);if(p===ur)return di(d,!1);if(p!==void 0)throw new Error(`Serialized lazy archive group ${u} has an unsupported kind`);if(r)throw new Error(`Serialized lazy archive group ${u} is missing its kind discriminator`);return di(d,!0)}),o=this.fs.identityState();this.reconcileLazyIdentityState(o);let a=[...this.serializeValidatedLazyArchiveEntries(o),...s];ys(a);let c=[],l=new Map;for(let d of s){let u=new Map,p=d.mountPrefix.replace(/\/+$/,""),m=d.content!==void 0&&d.inventory!==void 0&&d.activation!==void 0,f=m?new Map(d.inventory.map(w=>[w.vfsPath,w])):null,h=m?new Map(d.inventory.map(w=>[Xr(w),w])):null,g=new Map,_=new Map,y=new Map;for(let w of d.entries){let S=null,O=d.materialized||w.materialized===!0||w.isSymlink;if(!w.deleted&&!O){if((w.generation===void 0||w.dataSequence===void 0)&&!t)throw new Error("Live lazy-archive metadata requires inode generation and data sequence");try{S=this.fs.lstat(w.vfsPath)}catch{if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} is missing from the filesystem`);continue}if(S.ino!==w.ino){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} has a different inode`);continue}if(w.generation!==void 0&&S.generation!==w.generation){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} has a different generation`);continue}if(w.dataSequence===void 0){if(!n.canAdoptLegacyLazyStub(S)){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} is not pristine`);continue}}else if(S.dataSequence!==w.dataSequence){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} has a different data sequence`);continue}if(m){y.set(w.vfsPath,S);let v=f.get(w.vfsPath),L=h.get(Xr(w))??v;if(!L||(S.mode&Pe)!==cr||S.size!==0||(S.mode&4095)!==L.mode||v?.inodeGroup!==void 0&&v.inodeGroup!==L.inodeGroup)throw new Error(`Serialized lazy tree stub ${w.vfsPath} disagrees with its inventory`);let b=n.inodeKey(S.ino,S.generation),D=w.inodeGroup,q=g.get(D),F=_.get(b);if(q!==void 0&&q!==b||F!==void 0&&F!==D)throw new Error(`Serialized lazy tree inode group ${D} disagrees with the filesystem`);g.set(D,b),_.set(b,D)}}u.set(w.vfsPath,{ino:w.ino,generation:S?.generation??w.generation,dataSequence:S?.dataSequence??w.dataSequence,size:w.size,isSymlink:w.isSymlink,deleted:w.deleted,materialized:O,archivePath:w.archivePath??w.vfsPath.slice(p.length+1),sourcePath:w.sourcePath??w.archivePath??w.vfsPath.slice(p.length+1),type:w.type??(w.isSymlink?"symlink":"file"),inodeGroup:w.inodeGroup,target:w.target})}if(m){let w=new Map;for(let S of d.inventory){if(S.type==="file"||S.type==="hardlink"){w.set(S.inodeGroup,(w.get(S.inodeGroup)??0)+1);continue}let O;try{O=this.fs.lstat(S.vfsPath)}catch{throw new Error(`Serialized lazy tree namespace entry ${S.vfsPath} is missing from the filesystem`)}let x=S.type==="directory"?it:Br;if((O.mode&Pe)!==x||(O.mode&4095)!==S.mode||S.type==="symlink"&&(O.size!==new TextEncoder().encode(S.target).byteLength||this.fs.readlink(S.vfsPath)!==S.target))throw new Error(`Serialized lazy tree namespace entry ${S.vfsPath} disagrees with its inventory`);S.type==="symlink"&&u.set(S.vfsPath,{ino:O.ino,generation:O.generation,dataSequence:O.dataSequence,size:S.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:S.sourcePath,sourcePath:S.sourcePath,type:"symlink",target:S.target})}if(d.activation?.atomicGroup!==void 0)for(let S of d.inventory){if(S.type!=="file"&&S.type!=="hardlink")continue;if(y.get(S.vfsPath).linkCount!==w.get(S.inodeGroup))throw new Error(`Serialized lazy atomic tree inode group ${S.inodeGroup} has undeclared aliases`)}}let E=d.content===void 0?void 0:Zr(d.content),A={content:E,url:E?.transports[0]??d.url,mountPrefix:d.mountPrefix,integrity:E?{sha256:E.sha256,bytes:E.bytes}:hr(d.integrity),materialized:d.materialized||!(E&&d.inventory)&&Array.from(u.values()).every(w=>w.deleted||w.materialized),inventory:d.inventory?.map(w=>({...w})),activation:d.activation?{mode:d.activation.mode,capabilities:[...d.activation.capabilities],roots:[...d.activation.roots],...d.activation.atomicGroup===void 0?{}:{atomicGroup:{...d.activation.atomicGroup}}}:void 0,entries:u};if(c.push(A),!A.materialized){for(let[,w]of u)if(!w.deleted&&!w.materialized&&w.generation!==void 0){let S=n.inodeKey(w.ino,w.generation),O=l.get(S);if(O!==void 0&&O!==A)throw new Error(`Serialized lazy archive groups share pending inode ${S}`);if(this.lazyArchiveInodes.has(S))throw new Error(`Serialized lazy archive group collides with pending inode ${S}`);l.set(S,A)}}}for(let d of c){let u=d.activation?.atomicGroup;if(u!==void 0&&this.lazyAtomicGroups.get(u.id)?.committed)throw new Error(`Lazy atomic activation group ${u.id} is already materialized`)}if(i==="reject"&&c.some(d=>{let u=d.activation?.atomicGroup;return u!==void 0&&vt(u)}))throw new Error("Sealed lazy archive registrations require importVerifiedLazyArchiveEntries()");this.lazyArchiveGroups.push(...c);for(let d of c)this.registerLazyAtomicGroupMembership(d,i==="verified");for(let[d,u]of l)this.lazyArchiveInodes.set(d,u)}rewriteLazyArchiveUrls(e){for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0&&!r?.committed){this.assertLazyAtomicSnapshotMatchesPublic(t);let s=ql(i.snapshot,e);t.content=Es(s.content),t.url=s.url,t.integrity={...s.integrity},i.snapshot=s;continue}t.content?(t.content={...t.content,transports:t.content.transports.map(e)},t.url=t.content.transports[0]):t.url=e(t.url)}}serializeLazyArchiveEntries(){let e=[];for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(r?.committed)continue;let c=i.snapshot;if(c.content.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");e.push({kind:ot,content:Es(c.content),inventory:c.inventory.map(l=>({...l})),activation:Hl(c),url:c.url,mountPrefix:c.mountPrefix,integrity:{...c.integrity},materialized:!1,entries:c.entries.filter(l=>!l.deleted&&!l.materialized).map(({vfsPath:l,...d})=>({vfsPath:l,...d}))});continue}let s=Array.from(t.entries,([c,l])=>({vfsPath:c,ino:l.ino,generation:l.generation,dataSequence:l.dataSequence,size:l.size,isSymlink:l.isSymlink,deleted:l.deleted,materialized:l.materialized,archivePath:l.archivePath,sourcePath:l.sourcePath,type:l.type,inodeGroup:l.inodeGroup,target:l.target})).filter(c=>!c.deleted&&!c.materialized);if(s.length===0&&!(t.content&&t.inventory&&!t.materialized))continue;let o=t.content!==void 0&&t.inventory!==void 0&&t.activation!==void 0;if(o&&t.content.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");let a=t.activation?.atomicGroup;if(a!==void 0&&!vt(a))throw new Error(`Lazy atomic activation group ${a.id} must be sealed before serialization`);e.push(o?{kind:a!==void 0?ot:t.content.source===void 0?dr:ci,content:t.content,inventory:t.inventory,activation:t.activation,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:s}:{kind:ur,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:s})}return e}serializeValidatedLazyArchiveEntries(e){this.assertPendingLazyAtomicSnapshotsReadyForSerialization();let t=this.serializeLazyArchiveEntries();return ys(t),this.validatePendingLazyTreeNamespaceState(e),t}exportLazyArchiveEntries(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),this.serializeValidatedLazyArchiveEntries(e)}pendingDeferredTreeUsage(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),Ts(this.serializeValidatedLazyArchiveEntries(e))}assertCanAppendDeferredTreeUsage(e){ui(e);let t=this.pendingDeferredTreeUsage();ui({groups:t.groups+e.groups,archiveBytes:t.archiveBytes+e.archiveBytes,expandedBytes:t.expandedBytes+e.expandedBytes,payloadBytes:t.payloadBytes+e.payloadBytes,entries:t.entries+e.entries})}assertCanRegisterPendingLazyArchiveGroup(){if(this.reconcileLazyIdentityState(this.fs.identityState()),this.lazyArchiveGroups.filter(t=>{let r=this.lazyAtomicGroupByTree.get(t);return this.sealedLazyAtomicStates.get(t)?.snapshot!==void 0?!r?.committed:!t.materialized&&(t.content!==void 0&&t.inventory!==void 0||Array.from(t.entries.values()).some(s=>!s.deleted&&!s.materialized))}).length>=xe.maxGroups)throw new Error(`Cannot register another lazy archive group: ${xe.maxGroups} pending groups already exist`)}async preparePath(e){let t=!1,r=Math.max(3,this.lazyArchiveGroups.length+1);for(let i=0;i!s.materialized&&s.activation?.mode==="boot-prefetch"),t=0,r,i=Array.from({length:Math.min(e.length,yl)},async()=>{for(;r===void 0;){let s=t;if(t+=1,s>=e.length)return;try{await this.prepareLazyTreeGroup(e[s])}catch(o){r??=o}}});if(await Promise.all(i),r!==void 0)throw r;return e.length}async materializeRegisteredDeferredTree(e,t){let r=this.deferredTreeMaterializationHandles.get(e);if(r===void 0)throw new Error("Deferred-tree handle was not issued by this filesystem");let i=this.lazyAtomicGroupByTree.get(r);if(i!==void 0)throw new Error(`Deferred tree belongs to atomic activation group ${i.id}; materialize the complete group instead`);if(r.materialized)return!1;let s=this.lazyPreparations.get(r);if(s!==void 0)return s.promise;let o=new Uint8Array(t.byteLength);o.set(t);let a={status:"pending",promise:Promise.resolve(!1)};a.promise=Promise.resolve().then(async()=>(await ii(o,"tree",r.integrity),await this.materializeArchiveBytes(r,o),!0)).then(c=>(a.status="fulfilled",c),c=>{throw a.status="rejected",a.error=c,c}),a.promise.catch(()=>{}),this.lazyPreparations.set(r,a);try{return await a.promise}finally{this.lazyPreparations.get(r)===a&&this.lazyPreparations.delete(r)}}async prepareLazyTreeGroup(e){let t=this.lazyAtomicGroupByTree.get(e);if(t?.committed||t===void 0&&e.materialized)return!1;let r=this.sealedLazyAtomicStates.get(e)?.snapshot,i={token:t?.token??e,path:r?.activation.roots[0]??e.activation?.roots[0]??e.mountPrefix,directGroup:e,...t===void 0?{}:{atomicGroup:t}},s=this.lazyPreparations.get(i.token)??this.startLazyPreparation(i);try{return await s.promise}finally{this.lazyPreparations.get(i.token)===s&&this.lazyPreparations.delete(i.token)}}async ensureMaterialized(e){return this.preparePath(e)}async materializePath(e){if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0)return!1;let t;try{t=this.fs.stat(e)}catch{return!1}let r=n.inodeKey(t.ino,t.generation),i=this.lazyFiles.get(r);if(i){let o=this.lazyTransport,a=await this.fetchLazyBytes({id:`file:${t.ino}`,kind:"file",url:i.url,path:i.path,fallbackTotalBytes:i.size},o);for(let c=0;c<3;c++){if(this.lazyFiles.get(r)!==i)return!1;for(let l of new Set([e,...i.paths]))if(ee(o.signal),this.fs.replaceIfIdentity(l,i.ino,i.generation,i.dataSequence,a))return i.path=l,this.lazyFiles.delete(r),!0;this.reconcileLazyIdentityState(this.fs.identityState())}throw new Error(`Lazy file kept changing names while materializing: ${e}`)}let s=this.lazyArchiveInodes.get(r);return s?(await this.ensureArchiveMaterialized(s,{path:e,ino:t.ino,generation:t.generation}),!this.lazyArchiveInodes.has(r)):!1}async decodeAndValidateLazyTree(e,t,r){let i=r?.content??e.content,s=r?.inventory??e.inventory;if(!i||!s)throw new Error("Lazy tree is missing its decoder or complete inventory");let o=new Map,a=new Map(s.map(p=>[p.vfsPath,p]));if(i.source!==void 0)for(let p of i.source.entries)o.set(p.sourcePath,p);else for(let p of s){if(p.type==="hardlink"){let f=a.get(p.target);if(!f)throw new Error(`Lazy tree hardlink target disappeared: ${p.target}`);if(p.sourcePath===f.sourcePath)continue}if(o.get(p.sourcePath))throw new Error(`Lazy tree inventory duplicates source member ${p.sourcePath}`);o.set(p.sourcePath,{sourcePath:p.sourcePath,type:p.type,mode:p.mode,size:p.size,...p.type==="symlink"?{target:p.target}:{},...p.type==="hardlink"?{target:a.get(p.target)?.sourcePath}:{}})}let c=new Map,l=0;if(i.decoder==="zip-v1"){let{parseZipCentralDirectory:p,extractZipEntryBounded:m}=await Promise.resolve().then(()=>(Zn(),qn)),f=p(t);if(f.length!==i.sourceEntryCount||f.length!==o.size)throw new Error("Lazy ZIP tree decoded inventory counts differ from its descriptor");for(let h of f){let g=h.isDirectory?h.fileName.replace(/\/$/,""):h.fileName;if(c.has(g))throw new Error(`Lazy ZIP tree duplicates source member ${g}`);let _=o.get(g);if(!_)throw new Error(`Lazy ZIP tree has undeclared source member ${g}`);if(l+=h.uncompressedSize,l>i.expandedBytes||h.uncompressedSize!==_.size)throw new Error(`Lazy ZIP tree member ${g} exceeds its inventory`);let y=h.isDirectory?"directory":h.isSymlink?"symlink":"file",E=i.modePolicy==="portable-posix-v1"?y==="directory"?493:y==="symlink"?511:(h.mode&73)!==0?493:420:h.mode&4095;if(y!==_.type||E!==_.mode)throw new Error(`Lazy ZIP tree member ${g} differs from inventory`);if(h.isDirectory)c.set(g,{type:"directory",mode:E});else{let A=m(t,h,_.size);if(h.isSymlink){let w;try{w=new TextDecoder("utf-8",{fatal:!0}).decode(A)}catch{throw new Error(`Lazy ZIP tree symlink ${g} is not UTF-8`)}c.set(g,{type:"symlink",mode:E,target:w})}else c.set(g,{type:"file",mode:E,data:A})}}}else{let{parseTarGzip:p}=await Promise.resolve().then(()=>(us(),ls)),m=p(t,{label:`Lazy tree ${i.sha256}`,limits:{maxCompressedBytes:i.bytes,maxUncompressedBytes:i.expandedBytes,maxEntries:i.sourceEntryCount}});l=new DataView(t.buffer,t.byteOffset,t.byteLength).getUint32(t.byteLength-4,!0);for(let f of m){if(c.has(f.path))throw new Error(`Lazy TAR tree duplicates source member ${f.path}`);f.type==="file"?c.set(f.path,{type:"file",mode:f.mode,data:f.data}):f.type==="directory"?c.set(f.path,{type:"directory",mode:f.mode}):c.set(f.path,{type:f.type,mode:f.mode,target:f.linkName})}}if(c.size!==i.sourceEntryCount||c.size!==o.size||l!==i.expandedBytes)throw new Error("Lazy tree decoded inventory counts differ from its descriptor");for(let[p,m]of o){let f=c.get(p);if(!f)throw new Error(`Lazy tree is missing source member ${p}`);let h=m.type;if(f.type!==h)throw new Error(`Lazy tree member ${p} is ${f.type}, expected ${h}`);if((f.mode&4095)!==m.mode)throw new Error(`Lazy tree member ${p} mode differs from inventory`);if(h==="file"&&f.data?.byteLength!==m.size)throw new Error(`Lazy tree member ${p} size differs from inventory`);if(h==="symlink"&&f.target!==m.target)throw new Error(`Lazy tree symlink ${p} target differs from inventory`);if(h==="hardlink"&&f.target!==m.target)throw new Error(`Lazy tree hardlink ${p} target differs from inventory`)}let d=new Set(s.flatMap(p=>p.materialization==="archive-homebrew-relocate"?[p.sourcePath]:[]));if(i.source!==void 0){let p=new Map(i.source.entries.map(h=>[h.sourcePath,h])),m=zs(i.source.entries),f=i.source.entries.filter(h=>h.sourcePath==="INSTALL_RECEIPT.json"||h.sourcePath.endsWith("/INSTALL_RECEIPT.json"));if(f.length>1)throw new Error(`Lazy Homebrew bottle has ${f.length} INSTALL_RECEIPT.json source members, expected at most one`);if(f.length===0){if(d.size>0)throw new Error("Lazy Homebrew bottle marks receipt relocation without INSTALL_RECEIPT.json")}else{let h=f[0],g=h.type==="file"?h:m.get(h.sourcePath),_=g===void 0?void 0:c.get(g.sourcePath);if(g?.type!=="file"||_?.type!=="file"||_.data===void 0)throw new Error("Lazy Homebrew bottle INSTALL_RECEIPT.json is not regular");let y=Do(_.data),E=h.sourcePath.lastIndexOf("/"),A=E<0?"":h.sourcePath.slice(0,E),w=new Set(y.changedFiles.map(O=>A.length===0?O:`${A}/${O}`));if(d.size!==w.size||[...d].some(O=>!w.has(O)))throw new Error("Lazy Homebrew bottle relocation markers differ from INSTALL_RECEIPT.json");let S=new Set;for(let O of w){let x=p.get(O),v=x?.type==="file"?x:x===void 0?void 0:m.get(x.sourcePath),L=v===void 0?void 0:c.get(v.sourcePath);if(v?.type!=="file"||L?.type!=="file"||L.data===void 0)throw new Error(`Lazy Homebrew bottle changed source ${O} is not regular`);S.has(v.sourcePath)||(L.data=Ko(L.data,y,O),S.add(v.sourcePath))}}}else if(d.size>0)throw new Error("Lazy tree receipt relocation requires original-bottle source truth");let u=new Map;for(let p of s){if(p.type!=="file"||p.materialization==="descriptor")continue;let m=c.get(p.sourcePath);if(m?.type!=="file"||!m.data)throw new Error(`Lazy tree has no file content for ${p.sourcePath}`);u.set(p.sourcePath,m.data)}return u}async ensureArchiveMaterialized(e,t){let r=this.lazyAtomicGroupByTree.get(e);if(r!==void 0){if(r.committed)return;let o=this.sealedLazyAtomicStates.get(e)?.snapshot,a=this.lazyPreparations.get(r.token)??this.startLazyPreparation({token:r.token,path:o?.activation.roots[0]??e.activation?.roots[0]??e.mountPrefix,atomicGroup:r});try{await a.promise}finally{this.lazyPreparations.get(r.token)===a&&this.lazyPreparations.delete(r.token)}return}if(e.materialized)return;let i=this.lazyTransport,s=await this.fetchLazyArchiveData(e,i);ee(i.signal),await this.materializeArchiveBytes(e,s,t,i.signal)}async fetchLazyArchiveData(e,t,r){let i=r?.content??e.content,s=r?.inventory??e.inventory,o=i!==void 0&&s!==void 0,a=r?.mountPrefix??e.mountPrefix,c=r?.integrity??e.integrity,l=o?i.transports:[r?.url??e.url],d=[],u=null;for(let[p,m]of l.entries())try{u=await this.fetchLazyBytes({id:`archive:${a}:${i?.sha256??m}:${p}`,kind:o?"tree":"archive",url:m,mountPrefix:a,integrity:c},t);break}catch(f){if(ee(t.signal),bs(f))throw f;d.push(f instanceof Error?f.message:String(f))}if(ee(t.signal),u===null)throw new Error(`All ${l.length} lazy ${o?"tree":"archive"} transports failed: ${d.join("; ")}`);return u}async materializeArchiveBytes(e,t,r,i){if(ee(i),e.materialized)return;let s=await this.prepareLazyArchiveContents(e,t,i),o=r?n.inodeKey(r.ino,r.generation):null;for(let a=0;a<3;a++){let c=this.collectLazyArchiveReplacements(e,s,r);if(c.size>0&&(ee(i),!this.fs.replaceManyIfIdentities(Array.from(c.values(),As)))){if(this.reconcileLazyIdentityState(this.fs.identityState()),o&&!this.lazyArchiveInodes.has(o))return;continue}if(ee(i),this.publishLazyArchiveReplacements(e,c),e.materialized||(this.reconcileLazyIdentityState(this.fs.identityState()),o&&!this.lazyArchiveInodes.has(o)))return}if(o&&this.lazyArchiveInodes.has(o))throw new Error(`Lazy archive member kept changing names while materializing: ${r?.path}`)}async prepareLazyArchiveContents(e,t,r,i){ee(r);let s=i?.content??e.content,o=i?.inventory??e.inventory,c=s!==void 0&&o!==void 0?await this.decodeAndValidateLazyTree(e,t,i):null;ee(r);let{parseZipCentralDirectory:l,extractZipEntry:d}=await Promise.resolve().then(()=>(Zn(),qn));ee(r);let u=c?[]:l(t),p=new Map;for(let _ of u){if(p.has(_.fileName))throw new Error(`Lazy archive contains duplicate member: ${_.fileName}`);p.set(_.fileName,_)}let f=(i?.mountPrefix??e.mountPrefix).replace(/\/+$/,""),h=new Map,g=i===void 0?Array.from(e.entries):i.entries.map(_=>[_.vfsPath,_]);for(let[_,y]of g){if(y.deleted||y.materialized)continue;let E=y.archivePath??_.slice(f.length+1),A=c?void 0:p.get(E),w=c?.get(E);if(c){if(w===void 0||w.byteLength!==y.size)throw new Error(`Lazy tree member ${E} does not match its registered metadata`)}else if(A===void 0||A.isDirectory||A.isSymlink||A.uncompressedSize!==y.size)throw new Error(`Lazy archive member ${E} does not match its registered metadata`);if(y.generation===void 0)continue;let S=n.inodeKey(y.ino,y.generation),O=h.get(S);if(O&&O.archivePath!==E)throw new Error(`Lazy archive aliases for inode ${S} name different members`);if(!O){let x=w??d(t,A);if(x.byteLength!==y.size)throw new Error(`Lazy archive member ${E} extracted ${x.byteLength} bytes, expected ${y.size}`);h.set(S,{archivePath:E,content:x})}}return h}collectLazyArchiveReplacements(e,t,r,i){let s=new Map,o=i===void 0?Array.from(e.entries):i.entries.map(a=>[a.vfsPath,a]);for(let[a,c]of o){if(c.deleted||c.materialized||c.generation===void 0)continue;let l=n.inodeKey(c.ino,c.generation);if(this.lazyArchiveInodes.get(l)!==e)continue;let d=t.get(l);if(!d)throw new Error(`Lazy archive has no extracted content for inode ${l}`);let u=s.get(l);u||(u={ino:c.ino,generation:c.generation,dataSequence:c.dataSequence??0,paths:new Set,content:d.content},s.set(l,u)),u.paths.add(a),r&&r.ino===c.ino&&r.generation===c.generation&&u.paths.add(r.path)}return s}publishLazyArchiveReplacements(e,t){for(let[r,i]of t){this.lazyArchiveInodes.delete(r);for(let s of e.entries.values())s.ino===i.ino&&s.generation===i.generation&&(s.materialized=!0)}e.materialized=Array.from(e.entries.values()).every(r=>r.deleted||r.materialized)}collectAtomicTreeNamespace(e,t){let r=new Set,i=new Map,s=new Map,o=new Map(t.entries.map(c=>[c.vfsPath,c]));for(let c of t.inventory)(c.type==="file"||c.type==="hardlink")&&s.set(c.inodeGroup,(s.get(c.inodeGroup)??0)+1);let a=[];for(let c of t.inventory){let l;try{l=this.fs.lstat(c.vfsPath)}catch{throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`)}let d=c.type==="directory"?it:c.type==="symlink"?Br:cr;if((l.mode&Pe)!==d||(l.mode&4095)!==c.mode)throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`);if(c.type==="symlink"){let u=o.get(c.vfsPath);if(u===void 0||!u.isSymlink||u.deleted||u.ino!==l.ino||u.generation!==l.generation||u.dataSequence!==l.dataSequence||this.fs.readlink(c.vfsPath)!==c.target)throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`)}else if(c.type==="file"||c.type==="hardlink"){let u=o.get(c.vfsPath);if(u===void 0||u.deleted||u.materialized||u.isSymlink||u.generation===void 0||u.inodeGroup!==c.inodeGroup||u.ino!==l.ino||u.generation!==l.generation||u.dataSequence!==l.dataSequence||l.size!==0||l.linkCount!==s.get(c.inodeGroup))throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`);let p=n.inodeKey(u.ino,u.generation);if(this.lazyArchiveInodes.get(p)!==e)throw new Error(`Lazy atomic tree lost deferred ownership of ${c.vfsPath}`);let m=i.get(c.inodeGroup);if(m!==void 0&&m!==p)throw new Error(`Lazy atomic tree split hard links at ${c.vfsPath}`);i.set(c.inodeGroup,p),r.add(p)}a.push({path:c.vfsPath,expectedIno:l.ino,expectedGeneration:l.generation,expectedDataSequence:l.dataSequence,expectedMode:l.mode,expectedLinkCount:l.linkCount,expectedSize:l.size,expectedUid:l.uid,expectedGid:l.gid})}return{guards:a,pendingIdentities:r.size}}assertLazyAtomicSnapshotMatchesPublic(e){let t=this.sealedLazyAtomicStates.get(e),r=t?.snapshot,i=e.activation?.atomicGroup,s=r?.member??i?.member??"unknown",o;if(r!==void 0)try{o=Ur(e,r.id,r.member)}catch{o=void 0}if(t===void 0||r===void 0||i===void 0||!vt(i)||i.id!==r.id||i.member!==r.member||i.descriptorSha256!==r.descriptorSha256||i.expectedCount!==r.expectedCount||i.cohortSha256!==r.cohortSha256||o===void 0||!ws(r,o))throw new Error(`Lazy atomic activation member ${s} changed after sealing`);return t}assertPendingLazyAtomicSnapshotsReadyForSerialization(){for(let e of this.lazyArchiveGroups){if(!this.sealedLazyAtomicStates.has(e)||this.lazyAtomicGroupByTree.get(e)?.committed)continue;let r=this.assertLazyAtomicSnapshotMatchesPublic(e);if(!r.verified)throw new Error(`Lazy atomic activation group ${r.snapshot.id} has not been cryptographically verified after import`)}}async validatePendingLazyAtomicGroupSeals(e){for(let t of this.lazyAtomicGroups.values()){if(t.committed||t.expectedCount===void 0||t.cohortSha256===void 0)continue;let r=[...t.groups.entries()].sort(([i],[s])=>is?1:0).map(([,i])=>i);await this.ensureLazyAtomicGroupSealValidated(t,r,e)}}async ensureLazyAtomicGroupSealValidated(e,t,r){if(this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r))return;let i=e.sealValidationFlight;if(i===void 0&&(i=this.validateLazyAtomicGroupSealOnce(e,t),e.sealValidationFlight=i,i.then(()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)},()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)})),await i,!this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r))throw new Error(`Lazy atomic activation group ${e.id} seal verification did not authenticate every member`)}assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r){if(e.committed)return!0;if(e.expectedCount===void 0||e.cohortSha256===void 0||t.length!==e.expectedCount)throw new Error(`Lazy atomic activation group ${e.id} is not completely sealed`);let i=!0,s=[];for(let o of t){let a=this.assertLazyAtomicSnapshotMatchesPublic(o),c=a.snapshot;if(c.id!==e.id||c.expectedCount!==e.expectedCount||c.cohortSha256!==e.cohortSha256||e.groups.get(c.member)!==o)throw new Error(`Lazy atomic activation group ${e.id} has an inconsistent member`);i&&=a.verified,s.push(a)}if(i&&r)for(let o=0;ofh?1:0).map(([,f])=>f);if(t.length===0)throw new Error(`Lazy atomic activation group ${e.id} has no trees`);if(await this.ensureLazyAtomicGroupSealValidated(e,t,!0),e.committed)return;let r=t.map(f=>this.sealedLazyAtomicStates.get(f).snapshot),i=t.map((f,h)=>({group:f,...this.collectAtomicTreeNamespace(f,r[h])})),s=this.lazyTransport,o=new Array(t.length),a=0,c=!1,l,d=Array.from({length:Math.min(gl,t.length)},async()=>{for(;!c;){let f=a++;if(f>=t.length)return;let h=t[f],g=r[f];try{let _=await this.fetchLazyArchiveData(h,s,g);ee(s.signal),o[f]={group:h,snapshot:g,contents:await this.prepareLazyArchiveContents(h,_,s.signal,g)}}catch(_){c||(c=!0,l=_)}}});if(await Promise.all(d),c)throw o.fill(void 0),l;ee(s.signal);for(let f of t)this.assertLazyAtomicSnapshotMatchesPublic(f);let u=[],p=[],m=[];for(let f=0;f{let a=this.lazyAtomicGroupByTree.get(o);return this.sealedLazyAtomicStates.get(o)?.snapshot===void 0?!o.materialized&&o.content!==void 0&&o.inventory!==void 0:!a?.committed});if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0&&r.length===0)return;let i=Array.from(this.lazyFiles.values(),o=>o.path);for(let o of i)await this.ensureMaterialized(o);let s=new Set(this.lazyArchiveInodes.values());for(let o of r)s.add(o);for(let o of s)await this.prepareLazyTreeGroup(o)}this.reconcileLazyIdentityState(this.fs.identityState());let e=this.lazyArchiveGroups.some(t=>{let r=this.lazyAtomicGroupByTree.get(t);return this.sealedLazyAtomicStates.get(t)?.snapshot===void 0?!t.materialized&&t.content!==void 0&&t.inventory!==void 0:!r?.committed});if(this.lazyFiles.size!==0||this.lazyArchiveInodes.size!==0||e)throw new Error("Cannot create a self-contained VFS image while lazy entries remain pending")}async saveImage(e){e?.materializeAll&&await this.materializeAllLazyEntries(),await this.validatePendingLazyAtomicGroupSeals(!1);let{bytes:t,identities:r}=this.fs.snapshotState({normalizeTimestampsMs:e?.normalizeTimestampsMs});this.reconcileLazyIdentityState(r);let i=this.serializeLazyEntries(),s=i.length>0,o=s?new TextEncoder().encode(JSON.stringify(i)):new Uint8Array(0);if(o.byteLength>Gr)throw new Error(`VFS image lazy metadata exceeds ${Gr} bytes`);let a=this.serializeValidatedLazyArchiveEntries(r),c=a.length>0,l=c?new TextEncoder().encode(JSON.stringify(a)):new Uint8Array(0);if(l.byteLength>Hr)throw new Error(`VFS image lazy archive metadata exceeds ${Hr} bytes`);let d=e?.metadata===void 0?this.imageMetadata:e.metadata,u=vl(d),p=u.byteLength>0,m=c?4+l.byteLength:0,f=p?4+u.byteLength:0,h=he+t.byteLength+4+o.byteLength+m+f,g=new Uint8Array(h),_=new DataView(g.buffer);_.setUint32(0,oi,!0),_.setUint32(4,si,!0),_.setUint32(8,(s?ei:0)|(c?Wr:0)|(c?ri:0)|(p?ti:0),!0),_.setUint32(12,t.byteLength,!0),g.set(t,he);let y=he+t.byteLength;if(_.setUint32(y,o.byteLength,!0),o.byteLength>0&&g.set(o,y+4),c){let E=y+4+o.byteLength;_.setUint32(E,l.byteLength,!0),g.set(l,E+4)}if(p){let E=y+4+o.byteLength+m;_.setUint32(E,u.byteLength,!0),g.set(u,E+4)}return g}static readImageMetadata(e){let t=$r(e);if(!(t.flags&ti))return null;let{metadataOffset:r}=ms(t.image,t.view,t.flags,t.sabLen);if(t.image.byteLengthRt)throw new Error(`VFS image metadata exceeds ${Rt} bytes`);if(t.image.byteLength0){let g=r.subarray(f+4,f+4+h),_=Ne(_s(g,"VFS image lazy metadata"),"VFS image lazy entries",0,Lt);m.importLazyEntriesInternal(_,!0)}if(s&Wr){let g=a.archiveOffset,_=i.getUint32(g,!0);if(_>0){let y=r.subarray(g+4,g+4+_),E=_s(y,"VFS image lazy archive metadata");m.importLazyArchiveEntriesInternal(E,!0,!!(s&ri),"pending")}}return m}adaptStat(e){return{dev:0,ino:e.ino,mode:e.mode,nlink:e.linkCount,uid:e.uid,gid:e.gid,size:e.size,atimeMs:e.atime,mtimeMs:e.mtime,ctimeMs:e.ctime}}adaptStatWithLazySize(e){let t=this.adaptStat(e),r=this.lazyFileForStat(e);if(r)return t.size=r.size,t;let i=this.lazyArchiveForStat(e);if(i){for(let s of this.lazyArchiveEntriesForRead(i))if(s.ino===e.ino&&s.generation===e.generation&&!s.deleted){t.size=s.size;break}}return t}open(e,t,r){(t&rr)===0&&!((t&er)!==0&&(t&Kn)!==0)&&this.guardSynchronousLazyAccess(e);let i=this.fs.open(e,t,r);return(t&rr)!==0&&this.invalidateLazyData(this.fs.fstat(i)),i}close(e){return this.fs.close(e),0}read(e,t,r,i){if(i>0){let s=this.lazyBackingForStat(this.fs.fstat(e));s&&(this.reconcileLazyIdentityState(this.fs.identityState()),s=this.lazyBackingForStat(this.fs.fstat(e)),s&&this.guardSynchronousLazyAccess(s.path))}return r!==null?this.fs.readAt(e,t.subarray(0,i),typeof r=="bigint"?Rn(r):r):this.fs.read(e,t.subarray(0,i))}write(e,t,r,i){if(r!==null){let o=this.fs.writeAt(e,t.subarray(0,i),typeof r=="bigint"?Rn(r):r);return o>0&&this.invalidateLazyData(this.fs.fstat(e)),o}let s=this.fs.write(e,t.subarray(0,i));return s>0&&this.invalidateLazyData(this.fs.fstat(e)),s}append(e,t,r,i){let s=this.fs.append(e,t.subarray(0,r),yo(i));return s.written>0&&this.invalidateLazyData(this.fs.fstat(e)),s}seek(e,t,r){return this.fs.lseek(e,typeof t=="bigint"?vn(t):t,r)}fstat(e){return this.adaptStatWithLazySize(this.fs.fstat(e))}fpathconf(e,t){let r=this.fstat(e);return bn(r,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}ftruncate(e,t){this.fs.ftruncate(e,t),this.invalidateLazyData(this.fs.fstat(e))}fsync(e){}fchmod(e,t){this.fs.fchmod(e,t)}fchown(e,t,r){this.fs.fchown(e,t,r)}stat(e){return this.adaptStatWithLazySize(this.fs.stat(e))}lstat(e){return this.adaptStatWithLazySize(this.fs.lstat(e))}statfs(e){this.fs.stat(e);let t=this.fs.statfs();return{type:1397114451,bsize:t.blockSize,blocks:t.totalBlocks,bfree:t.freeBlocks,bavail:t.freeBlocks,files:t.totalInodes,ffree:t.freeInodes,fsid:0,namelen:t.maxName,frsize:t.blockSize,flags:0}}pathconf(e,t){let r=this.stat(e);return bn(r,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}mkdir(e,t){this.fs.mkdir(e,t)}rmdir(e){this.fs.rmdir(e)}unlink(e){let t=this.fs.unlink(e),r=n.inodeKey(t.ino,t.generation);if(t.linkCount>1&&(this.lazyFiles.has(r)||this.lazyArchiveInodes.has(r))){this.reconcileLazyIdentityState(this.fs.identityState());return}let i=this.lazyFiles.get(r);i&&(i.paths.delete(e),t.linkCount<=1?this.lazyFiles.delete(r):i.path===e&&(i.path=i.paths.values().next().value));let s=this.lazyArchiveInodes.get(r);if(s){let o=s.entries.get(e);if(t.linkCount<=1){for(let a of s.entries.values())a.ino===t.ino&&a.generation===t.generation&&(a.deleted=!0);this.lazyArchiveInodes.delete(r)}else o&&s.entries.delete(e)}}rename(e,t){let{source:r,replaced:i}=this.fs.rename(e,t);if(i&&i.ino===r.ino&&i.generation===r.generation)return;let s=!1;if(i){let o=n.inodeKey(i.ino,i.generation);i.linkCount>1&&(this.lazyFiles.has(o)||this.lazyArchiveInodes.has(o))&&(this.reconcileLazyIdentityState(this.fs.identityState()),s=!0);let a=this.lazyFiles.get(o);!s&&a&&(a.paths.delete(t),i.linkCount<=1?this.lazyFiles.delete(o):a.path===t&&(a.path=a.paths.values().next().value));let c=this.lazyArchiveInodes.get(o);if(!s&&c){let l=c.entries.get(t);i.linkCount<=1?(l&&(l.deleted=!0),this.lazyArchiveInodes.delete(o)):l&&c.entries.delete(t)}}s||this.rewriteLazyNamespacePaths(r,e,t)}link(e,t){let r=this.fs.link(e,t),i=n.inodeKey(r.ino,r.generation),s=this.lazyFiles.get(i);s&&s.paths.add(t);let o=this.lazyArchiveInodes.get(i);if(o){let a=Array.from(o.entries.values()).find(c=>c.ino===r.ino&&c.generation===r.generation);a&&o.entries.set(t,{...a})}}symlink(e,t){this.fs.symlink(e,t)}readlink(e){return this.fs.readlink(e)}chmod(e,t){this.fs.chmod(e,t)}chown(e,t,r){this.fs.chown(e,t,r)}lchown(e,t,r){this.fs.lchown(e,t,r)}createFileWithOwner(e,t,r,i,s){let o=this.open(e,577,t);s.length>0&&this.write(o,s,null,s.length),this.close(o),this.chown(e,r,i),this.chmod(e,t)}mkdirWithOwner(e,t,r,i){this.mkdir(e,t),this.chown(e,r,i),this.chmod(e,t)}symlinkWithOwner(e,t,r,i){this.symlink(e,t),this.lchown(t,r,i)}copyPathToFreshFileSystem(e,t,r,i,s){let o=this.lstat(e),a=o.mode&Pe,c=o.mode&4095;if(a===it){e==="/"?(t.chown(e,o.uid,o.gid),t.chmod(e,c)):t.mkdirWithOwner(e,c,o.uid,o.gid);let p=this.opendir(e);try{for(;;){let m=this.readdir(p);if(!m)break;m.name==="."||m.name===".."||this.copyPathToFreshFileSystem(e==="/"?`/${m.name}`:`${e}/${m.name}`,t,r,i,s)}}finally{this.closedir(p)}n.applyTimes(t,e,o);return}let l=o.nlink>1?`${o.dev}:${o.ino}`:null,d=l?s.get(l):void 0;if(d){t.link(d,e);return}if(a===Br){t.symlinkWithOwner(this.readlink(e),e,o.uid,o.gid),l&&s.set(l,e);return}if(a!==cr)throw new Error(`Unsupported file type while rebasing VFS: ${e}`);if(r.has(e)||i.has(e)){t.createFileWithOwner(e,c,o.uid,o.gid,new Uint8Array(0)),n.applyTimes(t,e,o),l&&s.set(l,e);return}this.copyRegularFileToFreshFileSystem(e,t,o,c),l&&s.set(l,e)}copyRegularFileToFreshFileSystem(e,t,r,i){let s=this.open(e,fl,0),o=null;try{o=t.open(e,hl,i);let a=new Uint8Array(Math.min(pl,Math.max(1,r.size))),c=r.size;for(;c>0;){let l=Math.min(a.byteLength,c),d=this.read(s,a,null,l);if(d<=0)throw new Error(`Unexpected EOF while rebasing VFS file: ${e}`);let u=0;for(;u!e||e==="."||e===".."))throw new Error(`Binary resolver path must be a normalized portable relative path: ${JSON.stringify(n)}`);return n}var ct=new Set(["wasm32","wasm64"]);function Ye(n){if(nu(n),!n.startsWith("programs/"))return n;let e=n.slice(9),t=e.split("/",1)[0];return ct.has(t)?n:`programs/wasm32/${e}`}function iu(n,e=$(Si(),"wasm")){let t=Ye(n),r=[$(e,t)];return n==="kernel.wasm"?r.push($(e,"kandelo-kernel.wasm")):n==="userspace.wasm"?r.push($(e,"wasm_posix_userspace.wasm")):n==="rootfs.vfs"&&r.push($(e,"rootfs.vfs")),r}var en=class extends Error{constructor(e){super(e),this.name="BinaryNotFoundError"}};function qs(){let n=[],e=!1;try{let r=at();e=!0;for(let[i,s]of[["local-binaries",$(r,"local-binaries")],["binaries",$(r,"binaries")]])n.push({label:i,root:s,identity:i==="local-binaries"?"local-generation":"program-cache",allowRegularFileClosure:!1,candidatesFor(o){return[$(s,Ye(o))]}})}catch{}let t=$(Si(),"wasm");return n.push({label:"installed package",root:t,identity:"installed-package",allowRegularFileClosure:!e,candidatesFor(r){return iu(r,t)}}),n}function bt(n,e){return new Error(`Invalid package manifest ${n}: ${e}`)}function ae(n){try{return tn(n),!0}catch(e){if(e instanceof Error&&"code"in e&&e.code==="ENOENT")return!1;throw e}}function Ms(n,e,t){if(n.length===0||n.startsWith("/")||n.includes("\\")||n.includes("\0")||n.split("/").some(r=>!r||r==="."||r===".."))throw bt(e,`${t} must be a normalized portable relative path`);return n}function Jr(n,e,t,r=!0){if(n.length===0||n==="."||n===".."||n.includes("/")||n.includes("\\")||n.includes("\0")||!r&&n.includes("@"))throw bt(e,`${t} must be a safe single path component`);return n}var Ds="kandelo-program-packages-v2",Fe="program-packages.json",Ks=null,ou=null,Qr=null,mi=0;function wi(){return ou??$(Si(),"wasm",Fe)}function Zs(){if(Object.prototype.hasOwnProperty.call(process.env,"WASM_POSIX_DEPS_REGISTRY")){let t=null;return(process.env.WASM_POSIX_DEPS_REGISTRY??"").split(":").filter(Boolean).map(r=>r.startsWith("~/")&&process.env.HOME!==void 0?$(process.env.HOME,r.slice(2)):rn(r)?Oe(r):(t??=at(),Oe(t,r)))}let n;try{n=$(at(),"packages","registry")}catch{return null}let e=!1;if(ae(n)){if(!Xe(n).isDirectory())return[n];e=Gs(n,{withFileTypes:!0}).filter(t=>t.isDirectory()||t.isSymbolicLink()).some(t=>ae($(n,t.name,"package.toml")))}return!e&&Xs()===null&&ae(wi())?null:[n]}function Xs(){let n;try{n=at()}catch{return null}if(!pr($(n,"tools","xtask","Cargo.toml"))||!pr($(n,"scripts","dev-shell.sh")))return null;try{let e=Ie(Ei()),t=Ie(n);return[$(t,"host"),$(t,"scripts")].some(i=>pr(i)&&vi(Ie(i),e))?t:null}catch{return null}}function Ai(n,e,t){let r=[typeof t.stderr=="string"?t.stderr.trim():"",typeof t.stdout=="string"?t.stdout.trim():"",t.error?.message??""].filter(Boolean).join(` +var ra=Object.defineProperty;var nn=(n,e,t)=>()=>{if(t)throw t[0];try{return n&&(e=n(n=0)),e}catch(r){throw t=[r],r}};var vi=(n,e)=>{for(var t in e)ra(n,t,{get:e[t],enumerable:!0})};import{createRequire as Oc}from"module";function Zo(n,e){return qo(n,{i:2},e&&e.out,e&&e.dictionary)}var Ic,xt,xc,Rc,oe,Ot,vc,Bo,$o,Tc,Uo,xt,Wo,Lc,Go,bc,Vu,Wn,ze,M,nr,ir,M,M,M,M,Ho,M,zc,kc,$n,Ae,Un,Vo,Kr,Pc,me,qo,Nc,Fc,It,Xo,Cc,Mc,Gn=nn(()=>{Ic=Oc("/");try{xt=Ic("worker_threads"),xc=xt.Worker,Rc=xt.isMarkedAsUntransferable}catch{}oe=Uint8Array,Ot=Uint16Array,vc=Int32Array,Bo=new oe([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),$o=new oe([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),Tc=new oe([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),Uo=function(n,e){for(var t=new Ot(31),r=0;r<31;++r)t[r]=e+=1<>1|(M&21845)<<1,ze=(ze&52428)>>2|(ze&13107)<<2,ze=(ze&61680)>>4|(ze&3855)<<4,Wn[M]=((ze&65280)>>8|(ze&255)<<8)>>1;nr=(function(n,e,t){for(var r=n.length,i=0,s=new Ot(e);i>c]=l}else for(a=new Ot(r),i=0;i>15-n[i]);return a}),ir=new oe(288);for(M=0;M<144;++M)ir[M]=8;for(M=144;M<256;++M)ir[M]=9;for(M=256;M<280;++M)ir[M]=7;for(M=280;M<288;++M)ir[M]=8;Ho=new oe(32);for(M=0;M<32;++M)Ho[M]=5;zc=nr(ir,9,1),kc=nr(Ho,5,1),$n=function(n){for(var e=n[0],t=1;te&&(e=n[t]);return e},Ae=function(n,e,t){var r=e/8|0;return(n[r]|n[r+1]<<8)>>(e&7)&t},Un=function(n,e){var t=e/8|0;return(n[t]|n[t+1]<<8|n[t+2]<<16)>>(e&7)},Vo=function(n){return(n+7)/8|0},Kr=function(n,e,t){return(e==null||e<0)&&(e=0),(t==null||t>n.length)&&(t=n.length),new oe(n.subarray(e,t))},Pc=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],me=function(n,e,t){var r=new Error(e||Pc[n]);if(r.code=n,Error.captureStackTrace&&Error.captureStackTrace(r,me),!t)throw r;return r},qo=function(n,e,t,r){var i=n.length,s=r?r.length:0;if(!i||e.f&&!e.l)return t||new oe(0);var o=!t,a=o||e.i!=2,c=e.i;o&&(t=new oe(i*3));var l=function(De){var Ke=t.length;if(De>Ke){var gr=new oe(Math.max(Ke*2,De));gr.set(t),t=gr}},d=e.f||0,u=e.p||0,p=e.b||0,m=e.l,f=e.d,h=e.m,g=e.n,_=i*8;do{if(!m){d=Ae(n,u,1);var y=Ae(n,u+1,3);if(u+=3,y)if(y==1)m=zc,f=kc,h=9,g=5;else if(y==2){var S=Ae(n,u,31)+257,O=Ae(n,u+10,15)+4,x=S+Ae(n,u+5,31)+1;u+=14;for(var R=new oe(x),T=new oe(19),L=0;L>4;if(E<16)R[L++]=E;else{var U=0,ue=0;for(E==16?(ue=3+Ae(n,u,3),u+=2,U=R[L-1]):E==17?(ue=3+Ae(n,u,7),u+=3):E==18&&(ue=11+Ae(n,u,127),u+=7);ue--;)R[L++]=U}}var C=R.subarray(0,S),H=R.subarray(S);h=$n(C),g=$n(H),m=nr(C,h,1),f=nr(H,g,1)}else me(1);else{var E=Vo(u)+4,A=n[E-4]|n[E-3]<<8,w=E+A;if(w>i){c&&me(0);break}a&&l(p+A),t.set(n.subarray(E,w),p),e.b=p+=A,e.p=u=w*8,e.f=d;continue}if(u>_){c&&me(0);break}}a&&l(p+131072);for(var bt=(1<>4;if(u+=U&15,u>_){c&&me(0);break}if(U||me(2),Re<256)t[p++]=Re;else if(Re==256){je=u,m=null;break}else{var zt=Re-254;if(Re>264){var L=Re-257,Ce=Bo[L];zt=Ae(n,u,(1<>4;lt||me(3),u+=lt&15;var H=bc[ge];if(ge>3){var Ce=$o[ge];H+=Un(n,u)&(1<_){c&&me(0);break}a&&l(p+131072);var Me=p+zt;if(p>3&1)+(e>>4&1);r>0;r-=!n[t++]);return t+(e&2)},It=(function(){function n(e,t){typeof e=="function"&&(t=e,e={}),this.ondata=t;var r=e&&e.dictionary&&e.dictionary.subarray(-32768);this.s={i:0,b:r?r.length:0},this.o=new oe(32768),this.p=new oe(0),r&&this.o.set(r)}return n.prototype.e=function(e){if(this.ondata||me(5),this.d&&me(4),!this.p.length)this.p=e;else if(e.length){var t=new oe(this.p.length+e.length);t.set(this.p),t.set(e,this.p.length),this.p=t}},n.prototype.c=function(e){this.s.i=+(this.d=e||!1);var t=this.s.b,r=qo(this.p,this.s,this.o);this.ondata(Kr(r,t,this.s.b),this.d),this.o=Kr(r,this.s.b-32768),this.s.b=this.o.length,this.p=Kr(this.p,this.s.p/8|0),this.s.p&=7},n.prototype.push=function(e,t){this.e(e),this.c(t)},n})();Xo=(function(){function n(e,t){this.v=1,this.r=0,It.call(this,e,t)}return n.prototype.push=function(e,t){if(It.prototype.e.call(this,e),this.r+=e.length,this.v){var r=this.p.subarray(this.v-1),i=r.length>3?Fc(r):4;if(i>r.length){if(!t)return}else this.v>1&&this.onmember&&this.onmember(this.r-r.length);this.p=r.subarray(i),this.v=0}It.prototype.c.call(this,0),this.s.f&&!this.s.l?(this.v=Vo(this.s.p)+9,this.s={i:0},this.o=new oe(0),this.push(new oe(0),t)):t&&It.prototype.c.call(this,t)},n})(),Cc=typeof TextDecoder<"u"&&new TextDecoder,Mc=0;try{Cc.decode(Nc,{stream:!0}),Mc=1}catch{}});var qn={};vi(qn,{extractZipEntry:()=>Hc,extractZipEntryBounded:()=>Vc,fetchZipCentralDirectory:()=>Zc,parseZipCentralDirectory:()=>or});function ts(n){let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=Math.max(0,n.length-Jo);for(let r=n.length-Bc;r>=t;r--)if(e.getUint32(r,!0)===Dc)return r;throw new Error("Zip EOCD record not found")}function or(n){let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=ts(n),r=e.getUint16(t+10,!0),i=e.getUint32(t+16,!0),s=[],o=i;for(let a=0;a>8,A;E===Yo?A=h>>16&65535:y.startsWith("bin/")||y.startsWith("sbin/")||y.includes("/bin/")||y.includes("/sbin/")?A=493:A=420;let w=y.endsWith("/"),S=E===Yo&&(A&Uc)===$c;s.push({fileName:y,fileNameBytes:_,compressedSize:d,uncompressedSize:u,compressionMethod:l,localHeaderOffset:g,mode:A,isDirectory:w,isSymlink:S,externalAttrs:h,creatorOS:E}),o+=Hn+p+m+f}return s}function rs(n,e){if(n.byteLength!==e.byteLength)return!1;for(let t=0;t{if(a.byteLength>t-s)throw new Error(`ZIP member ${e.fileName} expands beyond ${t} bytes`);i.set(a,s),s+=a.byteLength}).push(r,!0),s!==t)throw new Error(`ZIP member ${e.fileName} expanded ${s} bytes, expected ${t}`);return i}function qc(n,e){let t=new DataView(n.buffer,n.byteOffset,n.byteLength),r=e.localHeaderOffset;if(r<0||r>n.byteLength-Vn||t.getUint32(r,!0)!==jo)throw new Error(`Invalid local file header signature at offset ${r}`);let i=t.getUint16(r+8,!0),s=t.getUint16(r+26,!0),o=t.getUint16(r+28,!0),a=r+Vn,c=a+s+o,l=c+e.compressedSize;if(i!==e.compressionMethod||cn.byteLength||!rs(n.subarray(a,a+s),e.fileNameBytes))throw new Error(`ZIP member ${e.fileName} has inconsistent local metadata`);return n.subarray(c,l)}async function Zc(n){let e=await fetch(n,{method:"HEAD"});if(!e.ok)throw new Error(`HEAD request failed: ${e.status} ${e.statusText}`);let t=parseInt(e.headers.get("content-length")||"0",10),r=e.headers.get("accept-ranges");if(!t||r!=="bytes"){let _=await fetch(n);if(!_.ok)throw new Error(`Fetch failed: ${_.status} ${_.statusText}`);let y=new Uint8Array(await _.arrayBuffer());return{entries:or(y),totalSize:y.length}}let i=Math.min(t,Jo),s=t-i,o=await fetch(n,{headers:{Range:`bytes=${s}-${t-1}`}});if(o.status!==206){let _=await fetch(n);if(!_.ok)throw new Error(`Fetch failed: ${_.status} ${_.statusText}`);let y=new Uint8Array(await _.arrayBuffer());return{entries:or(y),totalSize:y.length}}let a=new Uint8Array(await o.arrayBuffer()),c=new DataView(a.buffer,a.byteOffset,a.byteLength),l=ts(a),d=c.getUint32(l+12,!0),u=c.getUint32(l+16,!0);if(u>=s){let _=t,y=new Uint8Array(_);return y.set(a,s),{entries:or(y),totalSize:_}}let p=u+d-1,m=await fetch(n,{headers:{Range:`bytes=${u}-${p}`}});if(m.status!==206)throw new Error(`Range request for CD failed: ${m.status}`);let f=new Uint8Array(await m.arrayBuffer()),h=t,g=new Uint8Array(h);return g.set(f,u),g.set(a,s),{entries:or(g),totalSize:h}}var Dc,Kc,jo,Jo,Bc,Hn,Vn,Qo,es,Yo,$c,Uc,Wc,Gc,Zn=nn(()=>{"use strict";Gn();Dc=101010256,Kc=33639248,jo=67324752,Jo=65557,Bc=22,Hn=46,Vn=30,Qo=0,es=8,Yo=3,$c=40960,Uc=61440,Wc=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),Gc=new TextEncoder});var ls={};vi(ls,{DEFAULT_TAR_GZIP_LIMITS:()=>cs,TarParseError:()=>b,parseTarGzip:()=>Jc});function Jc(n,e={}){let t=e.label??"TAR gzip archive",r=el(e.limits,t);if(n.byteLength===0||n.byteLength>r.maxCompressedBytes)throw new b(`${t}: compressed byte count ${n.byteLength} is outside 1..${r.maxCompressedBytes}`);let i=tl(n,t);if(i===0||i>r.maxUncompressedBytes)throw new b(`${t}: declared uncompressed byte count ${i} is outside 1..${r.maxUncompressedBytes}`);let s=rl(n,t,i);if(s.byteLength!==i)throw new b(`${t}: gzip expanded to ${s.byteLength} bytes, expected ${i}`);let o=new DataView(n.buffer,n.byteOffset,n.byteLength).getUint32(n.byteLength-8,!0);if(nl(s)!==o)throw new b(`${t}: gzip CRC32 mismatch`);return Qc(s,t,r)}function Qc(n,e,t){if(n.byteLength%ke!==0)throw new b(`${e}: TAR byte count is not block-aligned`);let r=[],i=0,s=0,o=0,a=null,c={},l=!1;for(;i+ke<=n.byteLength;){let d=n.subarray(i,i+ke);if(i+=ke,Yn(d)){if(i+ke>n.byteLength)throw new b(`${e}: TAR end marker is truncated`);let w=n.subarray(i,i+ke);if(!Yn(w))throw new b(`${e}: TAR has only one zero end block`);if(i+=ke,!Yn(n.subarray(i)))throw new b(`${e}: TAR has nonzero data after its end marker`);l=!0;break}al(d,e);let u=sr(d,156,1,e)||"0",p=Jn(d,124,12,`${e}: TAR entry size`),m=Jn(d,100,8,`${e}: TAR entry mode`)&Xc,f=cl(d,e,t.maxPathBytes),h=sr(d,157,100,e);if(u==="x"||u==="g"){if(o+=1,o>t.maxEntries+1)throw new b(`${e}: TAR extension header count exceeds ${t.maxEntries+1}`);let w=is(n,i,p,e);i=os(i,p,n.byteLength,e);let S=ol(w,e,t);u==="x"?a=S:c={...c,...S};continue}if(s+=1,s>t.maxEntries)throw new b(`${e}: TAR entry count exceeds ${t.maxEntries}`);let g={...c,...a??{}};a=null;let _=g.size===void 0?p:sl(g.size,`${e}: PAX entry size`),y=is(n,i,_,e);i=os(i,_,n.byteLength,e);let E=jn(g.path??f,e,t.maxPathBytes),A=g.linkpath??h;switch(u){case"0":case"\0":r.push({path:E,type:"file",mode:m,data:y});break;case"5":Xn(_,e,"directory",E),r.push({path:E,type:"directory",mode:m});break;case"2":Xn(_,e,"symlink",E),ss(A,e,E,t.maxLinkBytes,!1),r.push({path:E,type:"symlink",mode:m,linkName:A});break;case"1":Xn(_,e,"hardlink",E),ss(A,e,E,t.maxLinkBytes,!0),r.push({path:E,type:"hardlink",mode:m,linkName:jn(A,`${e}: hardlink target`,t.maxPathBytes)});break;case"3":case"4":case"6":throw new b(`${e}: unsupported TAR device/FIFO entry ${E}`);default:throw new b(`${e}: unsupported TAR entry type ${JSON.stringify(u)} for ${E}`)}}if(!l)throw new b(`${e}: TAR is missing its two-block end marker`);if(a!==null)throw new b(`${e}: local PAX header has no following entry`);return r}function el(n,e){let t={...cs,...n};for(let[r,i]of Object.entries(t))if(!Number.isSafeInteger(i)||i<=0)throw new b(`${e}: ${r} must be a positive safe integer`);return t}function tl(n,e){if(n.byteLength<18||n[0]!==31||n[1]!==139||n[2]!==8)throw new b(`${e}: invalid gzip header`);return new DataView(n.buffer,n.byteOffset,n.byteLength).getUint32(n.byteLength-4,!0)}function rl(n,e,t){let r=new Uint8Array(t),i=0,s=!1,o=new Xo(a=>{if(a.byteLength>t-i)throw new b(`${e}: gzip expansion exceeds its declared ${t} bytes`);r.set(a,i),i+=a.byteLength});o.onmember=()=>{throw s=!0,new b(`${e}: concatenated gzip members are unsupported`)};try{o.push(n,!0)}catch(a){throw a instanceof b?a:new b(`${e}: cannot gunzip archive: ${ul(a)}`)}if(s)throw new b(`${e}: concatenated gzip members are unsupported`);return r.subarray(0,i)}function nl(n){let e=4294967295;for(let t of n)e=jc[(e^t)&255]^e>>>8;return(e^4294967295)>>>0}function il(){let n=new Uint32Array(256);for(let e=0;e>>1^((t&1)===0?0:3988292384);n[e]=t>>>0}return n}function is(n,e,t,r){if(t>n.byteLength-e)throw new b(`${r}: TAR entry is truncated`);return n.subarray(e,e+t)}function os(n,e,t,r){let s=Math.ceil(e/ke)*ke;if(!Number.isSafeInteger(s)||s>t-n)throw new b(`${r}: TAR entry padding is truncated`);return n+s}function ol(n,e,t){let r={},i=0;for(;i9)throw new b(`${e}: invalid PAX record length`);if(o=o*10+h,!Number.isSafeInteger(o))throw new b(`${e}: invalid PAX record length`)}let a=i+o;if(o<=s-i+2||a>n.byteLength||n[a-1]!==10)throw new b(`${e}: truncated PAX record`);let c=s+1;for(;c=a-1)throw new b(`${e}: invalid PAX record`);let l=n.subarray(s+1,c);if(l.byteLength>256)throw new b(`${e}: PAX record key is too long`);let d=Qn(l,`${e}: PAX record key`),u=n.subarray(c+1,a-1),p=d==="path"?t.maxPathBytes:d==="linkpath"?t.maxLinkBytes:d==="size"?32:0;if(p===0){i=a;continue}if(u.byteLength>p)throw new b(`${e}: PAX ${d} value is too long`);let m=Qn(u,`${e}: PAX record value`);r[d]=m,i=a}return r}function sl(n,e){if(!/^(0|[1-9][0-9]*)$/.test(n))throw new b(`${e} is invalid`);let t=Number(n);if(!Number.isSafeInteger(t)||t<0)throw new b(`${e} is invalid`);return t}function al(n,e){let t=Jn(n,148,8,`${e}: TAR checksum`),r=0;for(let i=0;i=148&&i<156?32:n[i];if(t!==r)throw new b(`${e}: TAR checksum mismatch`)}function cl(n,e,t){let r=sr(n,0,100,e),i=sr(n,345,155,e);return jn(i?`${i}/${r}`:r,e,t)}function jn(n,e,t){let r=n;for(;r.startsWith("./");)r=r.slice(2);return r=r.replace(/\/+$/g,""),ll(r,`${e}: TAR path`,t),r}function sr(n,e,t,r){let i=e,s=e+t;for(;ir||n.includes("\0"))throw new b(`${e}: link target for ${t} is invalid`);if(i&&n.includes("\\"))throw new b(`${e}: hardlink target for ${t} is invalid`)}function ll(n,e,t){if(n.length===0||n.startsWith("/")||n.includes("\0")||n.includes("\\")||as.encode(n).byteLength>t)throw new b(`${e} ${JSON.stringify(n)} must be a bounded relative POSIX path`);for(let r of n.split("/"))if(r.length===0||r==="."||r==="..")throw new b(`${e} ${JSON.stringify(n)} contains an unsafe path segment`)}function Yn(n){for(let e of n)if(e!==0)return!1;return!0}function Qn(n,e){try{return Yc.decode(n)}catch{throw new b(`${e} contains non-UTF-8 text`)}}function ul(n){return n instanceof Error?n.message:String(n)}var ke,Xc,ns,Yc,as,jc,cs,b,us=nn(()=>{"use strict";Gn();ke=512,Xc=4095,ns=1024*1024,Yc=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),as=new TextEncoder,jc=il(),cs=Object.freeze({maxCompressedBytes:256*ns,maxUncompressedBytes:512*ns,maxEntries:1e5,maxPathBytes:4096,maxLinkBytes:65536}),b=class extends Error{constructor(e){super(e),this.name="TarParseError"}}});import{existsSync as pr,lstatSync as tn,readdirSync as Gs,readFileSync as st,realpathSync as Ie,statSync as Xe}from"node:fs";import{createHash as Hs}from"node:crypto";import{spawnSync as gi}from"node:child_process";import{basename as Xl,dirname as _r,isAbsolute as rn,join as $,relative as Yl,resolve as Oe,sep as jl}from"node:path";import{fileURLToPath as Jl}from"node:url";var kt="kandelo.wpk_fork.linked_frames";var Ti=[75,76,67,70],Pt=24,Li=8,on=3,bi=[{bytes:4,chunkHeaderSize:32,nodeHeaderSize:24},{bytes:8,chunkHeaderSize:56,nodeHeaderSize:32}],re="kandelo.wpk_fork.module_state",zi=1,ki=[75,70,77,68],Er=24,Pi=8;var sn=7;var Ni=1,Fi=1,Ci=1;var Mi=[{bytes:4,chunkHeaderSize:40},{bytes:8,chunkHeaderSize:56}];var Sr="__wpk_fork_global_";var wr="__wpk_fork_table_",an=1,cn=2,ln=3,un=4,dn=5,dt=6,Nt=7,Ft=8,Ct=9,ve="kandelo.wpk_fork.capabilities",Di=1;var Ki=7,Ar=4,Ee="kandelo.wpk_fork.exception_codec",Bi=1,Or=8,fn=16;var Ir="env",xr="__wpk_fork_unwind",ft="kandelo.wpk_fork.unwind_transport",Mt="__wpk_fork_static_root_catalog",Be="kandelo.wpk_fork.static_root_catalog";var hn=1,pn=0,$i=1,Rr=12,Ui=[75,70,83,82],Z="kandelo.wpk_fork.imported_globals";var Wi=[75,70,73,71],Gi=1,vr=16,Dt=24,Hi=1,Vi=2,qi=3,X="kandelo.wpk_fork.imported_tables",Zi=[75,70,73,84],Xi=1,Tr=16,Kt=24,Yi=1,ji=1,mn="env",_n="__wpk_fork_module_activation";var ht=[{module:"env",name:"__wpk_fork_frame_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_frame_next",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_peek",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_reserve",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_record_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_module_state_record_find",params:["i32","i32","i32","i32"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_record_reserve",params:["i32","i32","i32","ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_table_dirty_count",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_module_state_table_dirty_mark",params:["i32","i64","i64"],results:[]},{module:"env",name:"__wpk_fork_module_state_table_dirty_page",params:["i32","i32"],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_mutation_abort",params:[],results:[]},{module:"env",name:"__wpk_fork_module_state_table_mutation_begin",params:[],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_mutation_commit",params:["i32","i64","i64"],results:[]},{module:"env",name:"__wpk_fork_module_state_table_reconcile",params:[],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_state_owned",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_decode_funcref",params:["i32"],results:["funcref"]},{module:"env",name:"__wpk_fork_ref_encode_funcref",params:["funcref"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_broker_encode",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_broker_throw_recipe",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_cache_index",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_claim",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_define",params:["i32","i32","i32","i32","ptr","i32","ptr","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_ingress_throw",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_load",params:["i32","i32","i32","i32","ptr","i32","ptr","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_lookup",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_route",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_broker_encode",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_capture_layout",params:["i32","i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_claim",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_define",params:["i32","i32","i32","i32","i32","ptr","i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_i31",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_load",params:["i32","i32","i32","i32","i32","ptr","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_lookup",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_payload_len",params:["i32","i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_provenance_begin",params:["i32","i32","i32","i32","i64","i64","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_provenance_end",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_provenance_ref",params:["i32","i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_route",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_scratch_release",params:["ptr","ptr"],results:[]},{module:"env",name:"__wpk_fork_ref_scratch_reserve",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_ref_vector_append",params:["i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_vector_begin",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_vector_finish",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_vector_get",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_resume_peek",params:["i32"],results:["i32"]}],yn=[{module:"env",name:"__wpk_fork_ref_gc_transit",table64:!1,element:"anyref",minimum:1,maximum:null},{module:"env",name:"__wpk_fork_resume_table",table64:!1,element:"funcref",minimum:1,maximum:null}],Bt=[{name:"__wpk_fork_exception_materialize",params:["i32"],results:[]},{name:"__wpk_fork_ref_decode_exnref",params:["i32"],results:["exnref"]},{name:"__wpk_fork_ref_encode_exnref",params:["exnref"],results:["i32"]},{name:"__wpk_fork_ref_exn_abort",params:[],results:[]},{name:"__wpk_fork_ref_exn_clear",params:[],results:[]},{name:"__wpk_fork_ref_exn_encode_ingress",params:["i32"],results:["i32"]},{name:"__wpk_fork_ref_exn_throw_recipe",params:["i32"],results:[]},{name:"__wpk_fork_ref_exn_throw_slot",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_allocate",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_encode_slot",params:["i32"],results:["i32"]},{name:"__wpk_fork_ref_gc_fill",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_probe",params:["i32"],results:["i64"]},{name:"__wpk_fork_ref_gc_publish_externref",params:["i32","externref"],results:[]},{name:"__wpk_fork_static_root_harvest",params:[],results:[]},{name:"wpk_fork_abort_begin",params:["ptr"],results:[]},{name:"wpk_fork_abort_end",params:[],results:[]},{name:"wpk_fork_module_bootstrap",params:[],results:[]},{name:"wpk_fork_module_state_finish_restore",params:["i32"],results:[]},{name:"wpk_fork_module_state_restore",params:["i32"],results:[]},{name:"wpk_fork_module_state_save",params:["i32"],results:[]},{name:"wpk_fork_module_table_state_restore",params:["i32"],results:[]},{name:"wpk_fork_module_table_state_save",params:["i32"],results:[]},{name:"wpk_fork_module_thread_bootstrap",params:[],results:[]},{name:"wpk_fork_rewind_begin",params:["ptr"],results:[]},{name:"wpk_fork_rewind_end",params:[],results:[]},{name:"wpk_fork_state",params:[],results:["i32"]},{name:"wpk_fork_unwind_begin",params:["ptr"],results:[]},{name:"wpk_fork_unwind_end",params:[],results:[]}];var Ji=4096;var Qi=["__abi_version","kernel_alloc_scratch","kernel_blocking_retry_release","kernel_blocking_retry_token","kernel_commit_process_exit","kernel_create_process","kernel_create_process_with_stdio","kernel_dequeue_signal","kernel_exec_prepare","kernel_exec_setup_for_thread","kernel_fork_process","kernel_get_cwd","kernel_get_dirfd_path","kernel_get_fd_path","kernel_get_parent_pid","kernel_get_process_exit_signal","kernel_get_process_state","kernel_get_socket_timeout_ms","kernel_handle_channel","kernel_has_sa_nocldstop","kernel_host_adapter_manifest_len","kernel_host_adapter_manifest_ptr","kernel_ipc_shmat_for_process","kernel_ipc_shmat_for_task","kernel_ipc_shmdt_for_process","kernel_ipc_shmdt_for_task","kernel_is_fd_nonblock","kernel_mark_process_signaled","kernel_mq_descriptor_msgsize","kernel_msqid_ds_bytes","kernel_pick_signal_target_tid","kernel_pipe_has_readers","kernel_posix_timer_fire","kernel_process_metadata_begin","kernel_process_metadata_cancel","kernel_process_metadata_commit","kernel_process_metadata_stage","kernel_reap_exited_child","kernel_remove_process","kernel_semctl_array_bytes","kernel_semid_ds_bytes","kernel_set_current_tid","kernel_set_cwd","kernel_shmid_ds_bytes","kernel_spawn_process","kernel_spawn_reserved_process","kernel_spawn_scratch_begin","kernel_spawn_scratch_cancel","kernel_spawn_scratch_capacity","kernel_spawn_scratch_pointer","kernel_spawn_scratch_retained_capacity","kernel_thread_exit","kernel_thread_has_deliverable","kernel_transfer_channel_execute","kernel_transfer_io_execute","kernel_transfer_scratch_begin","kernel_transfer_scratch_cancel","kernel_transfer_scratch_capacity","kernel_transfer_scratch_pointer","kernel_validate_task","kernel_wait_child_poll"];var V={LINK_MAX:0,MAX_CANON:1,MAX_INPUT:2,NAME_MAX:3,PATH_MAX:4,PIPE_BUF:5,CHOWN_RESTRICTED:6,NO_TRUNC:7,VDISABLE:8,SYNC_IO:9,ASYNC_IO:10,PRIO_IO:11,SOCK_MAXBUF:12,FILESIZEBITS:13,REC_INCR_XFER_SIZE:14,REC_MAX_XFER_SIZE:15,REC_MIN_XFER_SIZE:16,REC_XFER_ALIGN:17,ALLOC_SIZE_MIN:18,SYMLINK_MAX:19,POSIX2_SYMLINKS:20,FALLOC:21,TEXTDOMAIN_MAX:22,TIMESTAMP_RESOLUTION:23};var ia=Uint8Array.from(Ui);function v(n,e){let t=0,r=0,i=e;for(;;){let s=n[i++];if(t|=(s&127)<=n.length)throw new Error(`${r} is truncated`);if((n[e++]&128)===0)return e}throw new Error(`${r} has an overlong LEB128 encoding`)}function Se(n,e,t){if(e>=n.length)throw new Error(`${t} is truncated`);let r=n[e++];switch(r){case 127:case 126:case 125:case 124:case 123:case 117:case 116:case 115:case 114:case 113:case 112:case 111:case 110:case 109:case 108:case 107:case 106:case 105:case 104:return{code:r,shared:!1,next:e};case 98:case 99:case 100:{let i=n[e]===101;i&&e++;let s=co(n,e,5,`${t} heap type`),[o]=ao(n,e);return{code:r,heapType:Number(o),shared:i,next:s}}default:throw new Error(`${t} has unknown value type 0x${r.toString(16)}`)}}function oa(n,e,t){return n[e]===120||n[e]===119?{code:n[e],shared:!1,next:e+1}:Se(n,e,t)}function sa(n,e){let t=n[e];if(t===64||t===127||t===126||t===125||t===124||t===123||t===112||t===111)return e+1;let[,r]=En(n,e);return e+r}function aa(n,e,t){let[r,i]=v(n,e);e+=i;let s=[],o=[];for(let u=0;u=n.length)throw new Error(`${t} mutability is truncated`);let i=n[e++];if(i!==0&&i!==1)throw new Error(`${t} has invalid mutability ${i}`);return e}function ca(n,e,t,r){if(e===101){if(t>=n.length)throw new Error(`${r} shared type is truncated`);e=n[t++]}for(let i of[76,77]){if(e!==i)continue;let[,s]=v(n,t);if(t+=s,t>=n.length)throw new Error(`${r} descriptor is truncated`);e=n[t++]}if(e===96)return aa(n,t,r);if(e===95){let[i,s]=v(n,t);t+=s;for(let o=0;o=n.length)throw new Error(`${t} is truncated`);let r=n[e++];if(r===79||r===80){let[i,s]=v(n,e);e+=s;for(let o=0;o=n.length)throw new Error(`${t} body is truncated`);r=n[e++]}return ca(n,r,e,t)}function la(n,e){let[t,r]=v(n,e);e+=r;let i=[];for(let s=0;s=21&&r<=34?Ut(e,t):r===84||r>=92&&r<=99||r>=112&&r<=123||r>=124&&r<=131||r>=156&&r<=159?t+1:t:n===254?r===0||r===1||r===2?Ut(e,t):r===3?t:r>=16&&r<=79?Ut(e,t):null:null}function da(n,e,t){let[r,i]=v(n,e);e+=i+r;let[s,o]=v(n,e);e+=o+s;let a=n[e++];if(a===0){t.funcImports++;let[,c]=v(n,e);e+=c}else if(a===1)e=Se(n,e,"table import type").next,e=Ue(n,e).next;else if(a===2)e=Ue(n,e).next;else if(a===3)t.globalImports++,e=Se(n,e,"global import type").next,e++;else if(a===4){e++;let[,c]=v(n,e);e+=c}return e}function Lr(n){return n.length>=8&&n[0]===0&&n[1]===97&&n[2]===115&&n[3]===109}function $e(n,e){let[t,r]=v(n,e);return e+=r,[new TextDecoder().decode(n.subarray(e,e+t)),e+t]}function fa(n,e){if(e.length===0)return!0;let t=new TextEncoder().encode(e);e:for(let r=0;r<=n.length-t.length;r++){for(let i=0;in);function ro(n,e){switch(n.code){case 127:return an;case 126:return cn;case 125:return ln;case 124:return un;case 123:return dn;case 112:case 115:return dt;case 111:case 114:return Nt;case 105:case 116:return Ft;case 104:case 106:case 107:case 108:case 109:case 110:case 113:case 117:return Ct;case 98:case 99:case 100:{let t=n.heapType;return t===void 0?null:t===-16||t===-13?dt:t===-17||t===-14?Nt:t===-23||t===-12?Ft:t>=0&&e[t]!==void 0?dt:Ct}default:return null}}function gn(n,e,t){if(!t)throw new Error(`function ${e} refers to an unknown type`);let r=n.get(e)??[];r.push(t),n.set(e,r)}function $t(n,e,t){let r=n.get(e)??[];r.push(t),n.set(e,r)}function Ue(n,e){let[t,r]=v(n,e);e+=r;let[i,s]=v(n,e);e+=s;let o=null;if((t&1)!==0){let[a,c]=v(n,e);e+=c,o=a}return{flags:t,minimum:i,maximum:o,next:e}}function pa(n){let e=new Uint8Array(n);if(!Lr(e))throw new Error("not a wasm binary");let t=[],r=[],i=[],s={functionImports:new Map,functionImportEntries:[],globalImports:new Map,tableImports:new Map,tables:[],tagImports:new Map,functionExports:new Map,globalExports:new Map,tableExports:new Map,exports:new Map,memoryPointerWidths:[],forkCapabilities:[],linkedFrameDescriptors:[],exceptionCodecDescriptors:[],importedGlobalsDescriptors:[],importedTablesDescriptors:[],moduleStateDescriptors:[],staticRootDescriptors:[],unwindTransportDescriptors:[],nativeStartCount:0,importsKernelFork:!1},o=0,a=0,c=8;for(;ce.length)throw new Error("wasm section exceeds file size");let f=p,h=!1;if(l===0){let[g,_]=$e(e,f);g===kt?s.linkedFrameDescriptors.push(e.slice(_,m)):g===ve?s.forkCapabilities.push(e.slice(_,m)):g===Ee?s.exceptionCodecDescriptors.push(e.slice(_,m)):g===Z?s.importedGlobalsDescriptors.push(e.slice(_,m)):g===X?s.importedTablesDescriptors.push(e.slice(_,m)):g===re?s.moduleStateDescriptors.push(e.slice(_,m)):g===Be?s.staticRootDescriptors.push(e.slice(_,m)):g===ft&&s.unwindTransportDescriptors.push(e.slice(_,m))}else if(l===1){h=!0;let g=la(e,f);t.push(...g.types),f=g.next}else if(l===2){h=!0;let[g,_]=v(e,f);f+=_;for(let y=0;y=e.length)throw new Error(`global import ${E}.${w} is truncated`);let R=e[f++];if((R&-4)!==0)throw new Error(`global import ${E}.${w} has invalid flags ${R}`);$t(s.globalImports,`${E}.${w}`,{module:E,name:w,importOrdinal:y,index:o++,valueType:x.code,recipeTypeCode:ro(x,t),mutable:(R&1)!==0,shared:(R&2)!==0})}else if(O===4){let x=e[f++];if(x!==0)throw new Error(`unsupported wasm tag attribute ${x}`);let[R,T]=v(e,f);f+=T,gn(s.tagImports,`${E}.${w}`,t[R])}else throw new Error(`unsupported wasm import kind ${O}`)}}else if(l===3){h=!0;let[g,_]=v(e,f);f+=_;for(let y=0;yn[c]===a))throw new Error("linked-frame descriptor has invalid magic");let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=e.getUint16(4,!0);if(t!==1)throw new Error(`linked-frame descriptor version ${t} is unsupported`);let r=e.getUint16(6,!0);if(r!==Pt)throw new Error(`linked-frame descriptor declares size ${r}, expected ${Pt}`);let i=e.getUint8(8),s=bi.find(({bytes:a})=>a===i);if(!s)throw new Error(`linked-frame descriptor pointer width ${i} is unsupported`);if(e.getUint8(9)!==Li)throw new Error(`linked-frame descriptor alignment ${e.getUint8(9)} is unsupported`);let o=e.getUint16(10,!0);if(o!==on)throw new Error(`linked-frame descriptor flags 0x${o.toString(16)} do not equal required flags 0x${on.toString(16)}`);if(e.getUint32(12,!0)!==s.chunkHeaderSize||e.getUint32(16,!0)!==s.nodeHeaderSize)throw new Error(`linked-frame descriptor header sizes do not match its ${i}-byte pointer width`);return s.bytes}function _a(n){if(n.length===0)return[`missing required ${ve} capability`];if(n.length!==1)return[`has ${n.length} ${ve} sections, expected exactly one`];let e=n[0];if(e.byteLength!==2)return[`${ve} has ${e.byteLength} bytes, expected 2`];if(e[0]!==Di)return[`${ve} version ${e[0]} is unsupported`];let t=e[1];return(t&~Ki)!==0?[`${ve} has unknown flags 0x${t.toString(16)}`]:(t&Ar)!==Ar?[`${ve} flags 0x${t.toString(16)} omit required activation-state safety flags 0x${Ar.toString(16)}`]:[]}function ya(n){let e=[],t=`${Ir}.${xr}`,r=n.tagImports.get(t);if(r?r.length!==1?e.push(`duplicate private fork-unwind tag import ${t}`):(r[0].params.length!==0||r[0].results.length!==0)&&e.push(`private fork-unwind tag ${t} must have an empty payload`):e.push(`missing required private fork-unwind tag import ${t}`),n.unwindTransportDescriptors.length===0)e.push(`missing required ${ft} descriptor`);else if(n.unwindTransportDescriptors.length!==1)e.push(`has ${n.unwindTransportDescriptors.length} ${ft} descriptors, expected exactly one`);else{let i=n.unwindTransportDescriptors[0];(i.length!==2||i[0]!==hn||i[1]!==pn)&&e.push(`${ft} must be [${hn}, ${pn}]`)}return e}function ga(n,e){if(n.length===0)return[`missing required ${re} descriptor`];if(n.length!==1)return[`has ${n.length} ${re} descriptors, expected exactly one`];let t=n[0];if(t.byteLength!==Er)return[`${re} has ${t.byteLength} bytes, expected ${Er}`];if(!ki.every((h,g)=>t[g]===h))return[`${re} has invalid magic`];let r=new DataView(t.buffer,t.byteOffset,t.byteLength),i=r.getUint16(4,!0),s=r.getUint16(6,!0),o=r.getUint8(8),a=Mi.find(({bytes:h})=>h===o),c=r.getUint8(9),l=r.getUint16(10,!0),d=r.getUint16(12,!0),u=r.getUint16(14,!0),p=r.getUint32(16,!0),m=r.getUint32(20,!0),f=[];return i!==zi&&f.push(`${re} version ${i} is unsupported`),s!==Er&&f.push(`${re} declares size ${s}`),a?e!==null&&o!==e&&f.push(`${re} pointer width ${o} does not match linked frames ${e}`):f.push(`${re} pointer width ${o} is unsupported`),c!==Pi&&f.push(`${re} alignment ${c} is unsupported`),l!==sn&&f.push(`${re} flags 0x${l.toString(16)} do not equal required flags 0x${sn.toString(16)}`),d!==Ni&&f.push(`${re} arena version ${d} is unsupported`),u!==Fi&&f.push(`${re} record version ${u} is unsupported`),p!==Ci&&f.push(`${re} root word ${p} is unsupported`),m!==0&&f.push(`${re} reserved field is nonzero`),f}function Ea(n){if(n.length===0)return[`missing required ${Ee} descriptor`];if(n.length!==1)return[`has ${n.length} ${Ee} descriptors, expected exactly one`];let e=n[0];if(e.byteLength2147483647||o.has(d))&&r.push(`${Ee} layout id ${d} is invalid or duplicated`),o.add(d)}return r}var Sa=new Set([an,cn,ln,un,dn,dt,Nt,Ft,Ct]);function no(n){return!(n.module===mn&&(n.name===_n||n.name==="__channel_base"||n.name==="__wpk_fork_module_state_table_generation_addr"))}function wa(n){let e=n.importedGlobalsDescriptors;if(e.length===0)return[`missing required ${Z} descriptor`];if(e.length!==1)return[`has ${e.length} ${Z} descriptors, expected exactly one`];let t=e[0];if(t.byteLengtht[g]===h)||i.push(`${Z} has invalid magic`),r.getUint16(4,!0)!==Gi&&i.push(`${Z} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==vr&&i.push(`${Z} declares an invalid header size`);let s=r.getUint32(8,!0);r.getUint32(12,!0)!==0&&i.push(`${Z} reserved field is nonzero`);let o=new Set,a=new Set,c=new TextDecoder("utf-8",{fatal:!0}),l=[],d=-1,u=vr;for(let h=0;ht.byteLength)return i.push(`${Z} record ${h} header is truncated`),i;let g=r.getUint32(u,!0),_=r.getUint32(u+4,!0),y=r.getUint8(u+8),E=r.getUint8(u+9),A=r.getUint32(u+12,!0),w=r.getUint32(u+16,!0),S=r.getUint32(u+20,!0),O=Dt+A+w;if(!Number.isSafeInteger(O)||g!==O||gt.byteLength)return i.push(`${Z} record ${h} has invalid bounds`),i;(_===0||o.has(_))&&i.push(`${Z} record ${h} has invalid or duplicated owner ${_}`),o.add(_),Sa.has(y)||i.push(`${Z} record ${h} has unknown value type ${y}`),(E&~qi)!==0&&i.push(`${Z} record ${h} has unknown flags 0x${E.toString(16)}`),r.getUint16(u+10,!0)!==0&&i.push(`${Z} record ${h} reserved fields are nonzero`),(a.has(S)||S<=d)&&i.push(`${Z} record ${h} has duplicated or unordered import ordinal`),a.add(S),d=S;let x=u+Dt;try{let R=c.decode(t.subarray(x,x+A)),T=c.decode(t.subarray(x+A,x+A+w));l.push({ownerId:_,typeCode:y,flags:E,importOrdinal:S,module:R,name:T})}catch{i.push(`${Z} record ${h} contains invalid UTF-8`)}u+=g}u!==t.byteLength&&i.push(`${Z} has trailing bytes`);let p=[...n.globalImports.values()].flat(),m=new Map(p.map(h=>[h.index,h])),f=new Set;for(let h of l){let g=`${Sr}${h.ownerId}`,_=n.exports.get(g);if(!_||_.length!==1||_[0].kind!==3){i.push(`${Z} owner ${h.ownerId} lacks exactly one global catalog export ${g}`);continue}let y=m.get(_[0].index);if(!y||!no(y)){i.push(`${Z} owner ${h.ownerId} does not identify a reconstructible imported global`);continue}if(y.module!==h.module||y.name!==h.name||y.importOrdinal!==h.importOrdinal||y.recipeTypeCode!==h.typeCode||y.mutable!==((h.flags&Hi)!==0)||y.shared!==((h.flags&Vi)!==0)){i.push(`${Z} owner ${h.ownerId} does not match its imported global declaration`);continue}if(f.has(y.index)){i.push(`${Z} repeats imported global index ${y.index}`);continue}f.add(y.index)}for(let h of p)no(h)&&!f.has(h.index)&&i.push(`${Z} omits imported global ${h.module}.${h.name} at index ${h.index}`);for(let[h,g]of n.exports){if(!h.startsWith(Sr))continue;let _=h.slice(Sr.length),y=Number(_);(!/^[1-9][0-9]*$/.test(_)||!Number.isSafeInteger(y)||y>4294967295||g.length!==1||g[0].kind!==3)&&i.push(`malformed reserved fork global catalog export ${h}`)}return i}var Aa=new Set([dt,Nt,Ft,Ct]);function io(n){return!yn.some(({module:e,name:t})=>n.module===e&&n.name===t)}function Oa(n){let e=n.importedTablesDescriptors;if(e.length===0)return[`missing required ${X} descriptor`];if(e.length!==1)return[`has ${e.length} ${X} descriptors, expected exactly one`];let t=e[0];if(t.byteLengtht[g]===h)||i.push(`${X} has invalid magic`),r.getUint16(4,!0)!==Xi&&i.push(`${X} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Tr&&i.push(`${X} declares an invalid header size`);let s=r.getUint32(8,!0);r.getUint32(12,!0)!==0&&i.push(`${X} reserved field is nonzero`);let o=new Set,a=new Set,c=new TextDecoder("utf-8",{fatal:!0}),l=[],d=-1,u=Tr;for(let h=0;ht.byteLength)return i.push(`${X} record ${h} header is truncated`),i;let g=r.getUint32(u,!0),_=r.getUint32(u+4,!0),y=r.getUint8(u+8),E=r.getUint8(u+9),A=r.getUint32(u+12,!0),w=r.getUint32(u+16,!0),S=r.getUint32(u+20,!0),O=Kt+A+w;if(!Number.isSafeInteger(O)||g!==O||gt.byteLength)return i.push(`${X} record ${h} has invalid bounds`),i;(_===0||o.has(_))&&i.push(`${X} record ${h} has invalid or duplicated owner ${_}`),o.add(_),Aa.has(y)||i.push(`${X} record ${h} has unknown element type ${y}`),(E&~ji)!==0&&i.push(`${X} record ${h} has unknown flags 0x${E.toString(16)}`),r.getUint16(u+10,!0)!==0&&i.push(`${X} record ${h} reserved fields are nonzero`),(a.has(S)||S<=d)&&i.push(`${X} record ${h} has duplicated or unordered import ordinal`),a.add(S),d=S;let x=u+Kt;try{let R=c.decode(t.subarray(x,x+A)),T=c.decode(t.subarray(x+A,x+A+w));l.push({ownerId:_,typeCode:y,flags:E,importOrdinal:S,module:R,name:T})}catch{i.push(`${X} record ${h} contains invalid UTF-8`)}u+=g}u!==t.byteLength&&i.push(`${X} has trailing bytes`);let p=[...n.tableImports.values()].flat(),m=new Map(p.map(h=>[h.index,h])),f=new Set;for(let h of l){let g=`${wr}${h.ownerId}`,_=n.exports.get(g);if(!_||_.length!==1||_[0].kind!==1){i.push(`${X} owner ${h.ownerId} lacks exactly one table catalog export ${g}`);continue}let y=m.get(_[0].index);if(!y||!io(y)){i.push(`${X} owner ${h.ownerId} does not identify a reconstructible imported table`);continue}if(y.module!==h.module||y.name!==h.name||y.importOrdinal!==h.importOrdinal||y.recipeTypeCode!==h.typeCode||y.table64!==((h.flags&Yi)!==0)){i.push(`${X} owner ${h.ownerId} does not match its imported table declaration`);continue}if(f.has(y.index)){i.push(`${X} repeats imported table index ${y.index}`);continue}f.add(y.index)}for(let h of p)io(h)&&!f.has(h.index)&&i.push(`${X} omits imported table ${h.module}.${h.name} at index ${h.index}`);for(let[h,g]of n.exports){if(!h.startsWith(wr))continue;let _=h.slice(wr.length),y=Number(_);(!/^[1-9][0-9]*$/.test(_)||!Number.isSafeInteger(y)||y>4294967295||g.length!==1||g[0].kind!==1)&&i.push(`malformed reserved fork table catalog export ${h}`)}return i}function Sn(n,e){switch(n){case"ptr":return e===8?126:127;case"i32":return 127;case"i64":return 126;case"anyref":return 110;case"exnref":return 105;case"externref":return 111;case"funcref":return 112}}function oo(n,e,t,r){return n.params.length===e.length&&n.results.length===t.length&&n.params.every((i,s)=>i===Sn(e[s],r))&&n.results.every((i,s)=>i===Sn(t[s],r))}function so(n,e,t){let r=i=>i==="ptr"?t===8?"i64":"i32":i;return`(${n.map(r).join(", ")}) -> (${e.map(r).join(", ")})`}function Ia(n){let e=`${mn}.${_n}`,t=n.globalImports.get(e);return t?t.length!==1?[`duplicate exception-codec activation import ${e}`]:t[0].valueType!==127||t[0].mutable?[`exception-codec activation import ${e} must be immutable i32`]:[]:[`missing required immutable exception-codec activation import ${e}`]}function xa(n){let e=[];for(let t of yn){let r=`${t.module}.${t.name}`,i=n.tableImports.get(r);if(!i){e.push(`missing required ABI 43 fork-runtime table import ${r}`);continue}if(i.length!==1){e.push(`duplicate ABI 43 fork-runtime table import ${r}`);continue}let s=i[0],o=Sn(t.element,4);(s.elementType!==o||s.table64!==t.table64||s.minimum!==t.minimum||s.maximum!==t.maximum)&&e.push(`ABI 43 fork-runtime table import ${r} has the wrong type or limits`)}return e}function Ra(n){if(n.staticRootDescriptors.length===0)return[`missing required ${Be} descriptor`];if(n.staticRootDescriptors.length!==1)return[`has ${n.staticRootDescriptors.length} ${Be} descriptors, expected exactly one`];let e=n.staticRootDescriptors[0];if(e.byteLength!==Rr)return[`${Be} has ${e.byteLength} bytes, expected ${Rr}`];let t=[];ia.some((l,d)=>e[d]!==l)&&t.push(`${Be} has invalid magic`);let r=new DataView(e.buffer,e.byteOffset,e.byteLength);r.getUint16(4,!0)!==$i&&t.push(`${Be} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Rr&&t.push(`${Be} declares an invalid header size`);let i=r.getUint32(8,!0),s=n.tableExports.get(Mt);if(!s||s.length!==1)return t.push(`missing exactly one table export ${Mt}`),t;let o=[...n.tableImports.values()].reduce((l,d)=>l+d.length,0),a=s[0],c=n.tables[a];return a!n.functionExports.has(c)).map(({name:c})=>c);t.length>0&&e.push(`incomplete wasm-fork-instrument exports; missing ${t.join(", ")}`);let r=null;if(n.linkedFrameDescriptors.length===0)e.push(`missing required ${kt} descriptor`);else if(n.linkedFrameDescriptors.length!==1)e.push(`has ${n.linkedFrameDescriptors.length} ${kt} descriptors, expected exactly one`);else try{r=ma(n.linkedFrameDescriptors[0])}catch(c){e.push(c instanceof Error?c.message:String(c))}e.push(...ga(n.moduleStateDescriptors,r));let i=ht.filter(({module:c,name:l})=>n.functionImports.has(`${c}.${l}`)),s=`${Ir}.${xr}`,o=n.importsKernelFork||i.length>0;if((o||n.tagImports.has(s)||n.unwindTransportDescriptors.length>0)&&e.push(...ya(n)),o){let c=ht.filter(({module:l,name:d})=>!n.functionImports.has(`${l}.${d}`)).map(({module:l,name:d})=>`${l}.${d}`);c.length>0&&e.push(`incomplete ABI 43 fork-runtime imports; missing ${c.join(", ")}`);for(let l of ht){let d=`${l.module}.${l.name}`,u=n.functionImports.get(d);u&&u.length!==1&&e.push(`duplicate ABI 43 fork-runtime import ${d}`)}}if(r!==null){if(n.memoryPointerWidths.length!==1)e.push(`ABI 43 fork instrumentation requires exactly one module memory, found ${n.memoryPointerWidths.length}`);else if(n.memoryPointerWidths[0]!==r){let c=r===8?"an":"a";e.push(`ABI 43 linked-frame descriptor declares ${c} ${r}-byte pointer but the module memory uses ${n.memoryPointerWidths[0]}-byte addresses`)}for(let c of Bt){let l=n.functionExports.get(c.name);l?.length===1&&!oo(l[0],c.params,c.results,r)&&e.push(`ABI 43 wasm-fork-instrument export ${c.name} has the wrong signature; expected ${so(c.params,c.results,r)}`)}if(o)for(let c of ht){let l=`${c.module}.${c.name}`,d=n.functionImports.get(l);d?.length===1&&!oo(d[0],c.params,c.results,r)&&e.push(`ABI 43 fork-runtime import ${l} has the wrong signature; expected ${so(c.params,c.results,r)}`)}}return e}function Ta(n){let e=new Uint8Array(n);if(!Lr(e))return[];let t=[],r=8;for(;rt.startsWith("reloc."))}function uo(n,e={}){let t=[],r=null;ba(n)&&t.push("contains asyncify_"),e.expectedAbi!==void 0&&e.expectedAbi!==null&&(r=Pa(n),r!==null&&r!==e.expectedAbi&&t.push(`ABI ${r}, expected ${e.expectedAbi}`));let i=new Set(La(n));if(e.requiredExports){let E=e.requiredExports.filter(A=>!i.has(A));E.length>0&&t.push(`missing required exports: ${E.join(", ")}`)}let s=ha.filter(E=>i.has(E)),o=Ta(n),a=lo(n),c=ht.filter(({module:E,name:A})=>o.includes(`${E}.${A}`)),l=a.filter(E=>E===kt).length,d=a.filter(E=>E===ve).length,u=a.filter(E=>E===re).length,p=a.filter(E=>E===Ee).length,m=a.filter(E=>E===Z).length,f=a.filter(E=>E===X).length,h=a.filter(E=>E===ft).length,g=o.includes(`${Ir}.${xr}`),_=s.length>0||c.length>0||l>0||d>0||u>0||p>0||m>0||f>0||h>0||g;if(e.expectedAbi!==void 0&&e.expectedAbi!==null&&_&&r===null&&t.push(`ABI ${e.expectedAbi} fork artifact is missing __abi_version; the activation-state capability epoch cannot be verified`),e.forbidForkInstrumentation&&_&&t.push("contains ABI 43 wasm-fork-instrument metadata, imports, or exports"),(e.requireForkInstrumentation??!za(n))&&(_||o.includes("kernel.kernel_fork")))try{t.push(...va(pa(n)))}catch(E){t.push(`cannot validate ABI 43 fork-artifact contract: ${E instanceof Error?E.message:String(E)}`)}return t}function ka(n,e){let t=new Uint8Array(n);if(t.length<8)return null;let r=0,i=null,s=null,o=8;for(;o=c)return null;let h=a;for(let y=0;y=f)return null;let[h,g]=v(t,m);m+=g;for(let _=0;_f)return null}return m}function p(m,f=0){if(f>4)return null;let h=d(m);if(!h)return null;let g=u(h.start,h.end);if(g===null)return null;let _=g,y=h.end;for(;_=32&&E<=38||E===208){let[,A]=v(t,_);_+=A}else if(E>=40&&E<=62)_=Ut(t,_);else if(E===63||E===64)_++;else if(E===66){let[,A]=ao(t,_);_+=A}else if(E===67)_+=4;else if(E===68)_+=8;else if(E===252||E===253||E===254){let A=ua(E,t,_);if(A===null)return null;_=A}}return null}return p(i)}function Pa(n){return ka(n,"__abi_version")}var Na=ArrayBuffer,j=Uint8Array,br=Uint16Array,Fa=Int16Array;var zr=Int32Array,wn=function(n,e,t){if(j.prototype.slice)return j.prototype.slice.call(n,e,t);(e==null||e<0)&&(e=0),(t==null||t>n.length)&&(t=n.length);var r=new j(t-e);return r.set(n.subarray(e,t)),r},Gt=function(n,e,t,r){if(j.prototype.fill)return j.prototype.fill.call(n,e,t,r);for((t==null||t<0)&&(t=0),(r==null||r>n.length)&&(r=n.length);tn.length)&&(r=n.length);t2046MB)","invalid block type","FSE accuracy too high","match distance too far back","unexpected EOF"],J=function(n,e,t){var r=new Error(e||Ma[n]);if(r.code=n,Error.captureStackTrace&&Error.captureStackTrace(r,J),!t)throw r;return r},fo=function(n,e,t){for(var r=0,i=0;r>>0},Ka=function(n,e){var t=n[0]|n[1]<<8|n[2]<<16;if(t==3126568&&n[3]==253){var r=n[4],i=r>>5&1,s=r>>2&1,o=r&3,a=r>>6;r&8&&J(0);var c=6-i,l=o==3?4:o,d=fo(n,c,l);c+=l;var u=a?1<>3);m=f+(f>>3)*(n[5]&7)}m>2145386496&&J(1);var h=new j((e==1?p||m:e?0:m)+12);return h[0]=1,h[4]=4,h[8]=8,{b:c+u,y:0,l:0,d,w:e&&e!=1?e:h.subarray(12),e:m,o:new zr(h.buffer,0,3),u:p,c:s,m:Math.min(131072,m)}}else if((t>>4|n[3]<<20)==25481893)return Da(n,4)+8;J(0)},Qe=function(n){for(var e=0;1<t&&J(3);for(var s=1<0;){var y=Qe(o+1),E=r>>3,A=(1<>(r&7)&A,S=(1<S&&(w-=O)),p[++a]=--w,w==-1?(o+=w,g[--d]=a):o-=w,!w)do{var R=r>>3;c=(n[R]|n[R+1]<<8)>>(r&7)&3,r+=2,a+=c}while(c==3)}(a>255||o)&&J(0);for(var T=0,L=(s>>1)+(s>>3)+3,D=s-1,q=0;q<=a;++q){var F=p[q];if(F<1){m[q]=-F;continue}for(l=0;l=d)}}for(T&&J(0),l=0;l>3,{b:i,s:g,n:_,t:f}]},Ba=function(n,e){var t=0,r=-1,i=new j(292),s=n[e],o=i.subarray(0,256),a=i.subarray(256,268),c=new br(i.buffer,268);if(s<128){var l=Ht(n,e+1,6),d=l[0],u=l[1];e+=s;var p=d<<3,m=n[e];m||J(0);for(var f=0,h=0,g=u.b,_=g,y=(++e<<3)-8+Qe(m);y-=g,!(y>3;if(f+=(n[E]|n[E+1]<<8)>>(y&7)&(1<>3,h+=(n[E]|n[E+1]<<8)>>(y&7)&(1<<_)-1,o[++r]=u.s[h],g=u.n[f],f=u.t[f],_=u.n[h],h=u.t[h]}++r>255&&J(0)}else{for(r=s-127;t>4,o[t+1]=A&15}++e}var w=0;for(t=0;t11&&J(0),w+=S&&1<0;--t){var q=c[t];Gt(D,t,q,c[t-1]=q+a[t]*(1<a&&u>3,m=(n[p]|n[p+1]<<8|n[p+2]<<16)>>(d&7);c=(c<>2,o=s<<1,a=s+o;Wt(n.subarray(r,r+=n[0]|n[1]<<8),e.subarray(0,s),t),Wt(n.subarray(r,r+=n[2]|n[3]<<8),e.subarray(s,o),t),Wt(n.subarray(r,r+=n[4]|n[5]<<8),e.subarray(o,a),t),Wt(n.subarray(r),e.subarray(a),t)},qa=function(n,e,t){var r,i=e.b,s=n[i],o=s>>1&3;e.l=s&1;var a=s>>3|n[i+1]<<5|n[i+2]<<13,c=(i+=3)+a;if(o==1)return i>=n.length?void 0:(e.b=i+1,t?(Gt(t,n[i],e.y,e.y+=a),t):Gt(new j(a),n[i]));if(!(c>n.length)){if(o==0)return e.b=c,t?(t.set(n.subarray(i,c),e.y),e.y+=a,t):wn(n,i,c);if(o==2){var l=n[i],d=l&3,u=l>>2&3,p=l>>4,m=0,f=0;d<2?u&1?p|=n[++i]<<4|(u&2&&n[++i]<<12):p=l>>3:(f=u,u<2?(p|=(n[++i]&63)<<4,m=n[i]>>6|n[++i]<<2):u==2?(p|=n[++i]<<4|(n[++i]&3)<<12,m=n[i]>>2|n[++i]<<6):(p|=n[++i]<<4|(n[++i]&63)<<12,m=n[i]>>6|n[++i]<<2|n[++i]<<10)),++i;var h=t?t.subarray(e.y,e.y+e.m):new j(e.m),g=h.length-p;if(d==0)h.set(n.subarray(i,i+=p),g);else if(d==1)Gt(h,n[i++],g);else{var _=e.h;if(d==2){var y=Ba(n,i);m+=i-(i=y[0]),e.h=_=y[1]}else _||J(0);(f?Va:Wt)(n.subarray(i,i+=m),h.subarray(g),_)}var E=n[i++];if(E){E==255?E=(n[i++]|n[i++]<<8)+32512:E>127&&(E=E-128<<8|n[i++]);var A=n[i++];A&3&&J(0);for(var w=[Ua,Wa,$a],S=2;S>-1;--S){var O=A>>(S<<1)+2&3;if(O==1){var x=new j([0,0,n[i++]]);w[S]={s:x.subarray(2,3),n:x.subarray(0,1),t:new br(x.buffer,0,1),b:0}}else O==2?(r=Ht(n,i,9-(S&1)),i=r[0],w[S]=r[1]):O==3&&(e.t||J(0),w[S]=e.t[S])}var R=e.t=w,T=R[0],L=R[1],D=R[2],q=n[c-1];q||J(0);var F=(c<<3)-8+Qe(q)-D.b,z=F>>3,U=0,ue=(n[z]|n[z+1]<<8)>>(F&7)&(1<>3;var C=(n[z]|n[z+1]<<8)>>(F&7)&(1<>3;var H=(n[z]|n[z+1]<<8)>>(F&7)&(1<>3;var lt=1<>>(F&7)<-1);z=(F-=On[je])>>3;var Me=Ha[je]+((n[z]|n[z+1]<<8|n[z+2]<<16)>>(F&7)&(1<>3;var Je=Ga[bt]+((n[z]|n[z+1]<<8|n[z+2]<<16)>>(F&7)&(1<>3,ue=D.t[ue]+((n[z]|n[z+1]<<8)>>(F&7)&(1<>3,H=T.t[H]+((n[z]|n[z+1]<<8)>>(F&7)&(1<>3,C=L.t[C]+((n[z]|n[z+1]<<8)>>(F&7)&(1<3)e.o[2]=e.o[1],e.o[1]=e.o[0],e.o[0]=ge-=3;else{var ut=ge-(Je!=0);ut?(ge=ut==3?e.o[0]-1:e.o[ut],ut>1&&(e.o[2]=e.o[1]),e.o[1]=e.o[0],e.o[0]=ge):ge=e.o[0]}for(var S=0;SMe&&(Ke=Me);for(var S=0;Sja)throw Vt("EOVERFLOW","file offset is outside signed i64");return n}function Qa(n){if(xn(n)<0n)throw Vt("EINVAL","negative positioned I/O offset");return n}function Rn(n){let e=xn(n);if(e_o)throw Vt("EOVERFLOW","backend cannot represent the file offset exactly");return mo(e)}function vn(n){let e=Qa(n);return Rn(e)}function yo(n){if(n===null)return null;let e=xn(n);if(e<0n)throw Vt("EINVAL","negative file-size limit");return e>_o?null:mo(e)}function Tn(n){let e=new Error(`EINVAL: pathconf name ${n} is not associated with this object`);throw e.code="EINVAL",e}function Ln(n,e,t){switch(e){case V.LINK_MAX:return null;case V.NAME_MAX:return 255;case V.PATH_MAX:return Ji;case V.CHOWN_RESTRICTED:return 1;case V.NO_TRUNC:return 1;case V.ASYNC_IO:return(n.mode&61440)===32768?1:Tn(e);case V.SYNC_IO:case V.PRIO_IO:case V.FILESIZEBITS:case V.REC_INCR_XFER_SIZE:case V.REC_MAX_XFER_SIZE:case V.REC_MIN_XFER_SIZE:case V.REC_XFER_ALIGN:case V.ALLOC_SIZE_MIN:case V.SYMLINK_MAX:case V.FALLOC:return null;case V.POSIX2_SYMLINKS:return t.supportsSymlinks?1:null;case V.TEXTDOMAIN_MAX:return 255;case V.TIMESTAMP_RESOLUTION:return t.timestampResolutionNs;case V.PIPE_BUF:{let r=n.mode&61440;return r===4096||r===16384?null:Tn(e)}case V.MAX_CANON:case V.MAX_INPUT:case V.VDISABLE:case V.SOCK_MAXBUF:return Tn(e);default:{let r=new Error(`EINVAL: invalid pathconf name ${e}`);throw r.code="EINVAL",r}}}var kr=Math.floor(160),bn=1397114451,zn=1,qt=32768,G=16384,pt=40960,W=61440,ec=2048,tc=1024,rc=73,go=4294967295,et=0,Eo=1;var er=64,Kn=128,rr=512,nc=1024,ic=65536,Zt=3,oc=0,sc=1,ac=2,P=8,cc=-1,we=-2,B=-5,te=-9,Cn=-16,St=-17,Le=-20,rt=-21,Y=-22,To=-24,nt=-27,ie=-28,Mn=-36,Dn=-39,Lo=-40,bo=-75,kn=0,Pn=4,Pr=8,mt=12,We=16,_t=20,Nr=24,tt=28,Fr=32,So=36,Cr=40,lc=44,uc=48,dc=52,Nn=56,Mr=60,Dr=64,Xt=68,wo=72,yt=0,N=8,K=12,k=16,de=24,Q=32,Yt=40,ne=48,jt=88,gt=92,Jt=96,Qt=100,ce=104,Ge=112,Ao=116,le=120,Oo=4,Te=8,Io=16,xo=20,Ro=-2147483648,fc=2147483647,hc=1034+1024*1024,He=hc*4096,pc={[we]:"No such file or directory",[B]:"I/O error",[te]:"Bad file descriptor",[Cn]:"Device or resource busy",[St]:"File exists",[Le]:"Not a directory",[rt]:"Is a directory",[Y]:"Invalid argument",[To]:"Too many open files",[nt]:"File too large",[ie]:"No space left on device",[Mn]:"File name too long",[Dn]:"Directory not empty",[Lo]:"Too many symbolic links",[bo]:"Value too large for data type"},I=class extends Error{constructor(t,r){super(r||pc[t]||`Error ${t}`);this.code=t;this.name="SFSError"}code},pe=new TextEncoder,tr=new TextDecoder,vo=pe.encode("..");function Fn(n){return n==="."||n===".."}function Et(n){return n.buffer instanceof SharedArrayBuffer?tr.decode(new Uint8Array(n)):tr.decode(n)}function Ve(n){return n+3&-4}var be=class n{constructor(e){this.buffer=e;this.view=new DataView(e),this.i32=new Int32Array(e),this.u8=new Uint8Array(e)}buffer;view;i32;u8;dirIndexes=new Map;blockAllocHint=0;inodeAllocHint=2;atomicsWaitAllowed;static DIR_INDEX_MIN_SIZE=64*1024;static mkfs(e,t){let r=e.byteLength;if(r<4096*16)throw new I(Y);let i=Math.floor(r/4096),s=t?Math.floor(t/4096):i*4,o=Math.floor(s/4);o<32&&(o=32),o=Math.ceil(o/32)*32;let a=Math.ceil(o/(4096*8)),c=Math.ceil(s/(4096*8)),l=Math.ceil(o*128/4096),d=1,u=d+a,p=u+c,m=p+l;if(m>=i){let x=(m+1)*4096;try{e.grow(x)}catch{throw new I(ie)}if(i=Math.floor(e.byteLength/4096),m>=i)throw new I(ie)}new Uint8Array(e).fill(0);let f=new n(e);f.w32(kn,bn),f.w32(Pn,zn),f.w32(Pr,4096),f.w32(mt,i),f.w32(We,o),f.w32(tt,d),f.w32(Fr,u),f.w32(So,p),f.w32(Cr,m),f.w32(lc,a),f.w32(uc,c),f.w32(dc,l),f.w32(Xt,s),f.w32(wo,256);let h=u*4096;for(let x=0;x>2)+(x>>5);f.i32[R]|=1<<(x&31)}let g=i-m;Atomics.store(f.i32,_t>>2,g),f.blockAllocHint=m;let _=d*4096;f.i32[_>>2]|=3,Atomics.store(f.i32,Nr>>2,o-2),f.inodeAllocHint=2;let y=f.inodeOffset(1);f.w32(y+N,G|493),f.w32(y+K,2),f.w64(y+ce,1);let E=f.blockAlloc();if(E<0)throw new I(ie);f.w32(y+ne,E);let A=E*4096,w=Ve(P+1),S=Ve(P+2);f.w32(A,1),f.view.setUint16(A+4,w,!0),f.view.setUint16(A+6,1,!0),f.u8[A+P]=46;let O=A+w;return f.w32(O,1),f.view.setUint16(O+4,S,!0),f.view.setUint16(O+6,2,!0),f.u8[O+P]=46,f.u8[O+P+1]=46,f.w64(y+k,w+S),Atomics.store(f.i32,Nn>>2,1),f}static inspectImageCapacity(e){if(e.byteLengththis.snapshotBytesUnlocked(e))}snapshotState(e){return this.withNamespaceLock(()=>({bytes:this.snapshotBytesUnlocked(e),identities:this.collectIdentityStateUnlocked()}))}identityState(){return this.withNamespaceLock(()=>this.collectIdentityStateUnlocked())}snapshotBytesUnlocked(e){let t=e?.normalizeTimestampsMs;if(t!==void 0&&(!Number.isSafeInteger(t)||t<0))throw new I(Y,"Snapshot timestamp must be a non-negative safe integer in milliseconds");let r=t===void 0?void 0:BigInt(t);for(let a=0;a>2)!==0)throw new I(Cn,"Cannot save a VFS image with open descriptors")}let i=this.r32(We);for(let a=0;a=1&&this.inodeIsAllocated(a)?r:0n;o.setBigUint64(c+Yt,l,!0),o.setBigUint64(c+de,l,!0),o.setBigUint64(c+Q,l,!0)}}return s}collectIdentityStateUnlocked(){let e=new Map,t=this.inodeOffset(1),r=this.r64(t+ce);e.set(`1:${r}`,{ino:1,generation:r,dataSequence:Atomics.load(this.i32,t+le>>2)>>>0,mode:this.r32(t+N),linkCount:this.r32(t+K),size:this.r64(t+k),uid:this.r32(t+Jt),gid:this.r32(t+Qt),paths:["/"]});let i=[{ino:1,path:"/"}],s=new Set;for(;i.length>0;){let o=i.pop();if(s.has(o.ino))throw new I(B);s.add(o.ino);let a=this.inodeOffset(o.ino);if((this.r32(a+N)&W)!==G)throw new I(B);let c=this.r64(a+k),l=0;for(;l>2)>>>0,mode:T,linkCount:this.r32(S+K),size:this.r64(S+k),uid:this.r32(S+Jt),gid:this.r32(S+Qt),...(T&W)===pt?{symlinkTarget:this.readSymlinkInodeUnlocked(_)}:{},paths:[]},e.set(x,R)}R.paths.push(w),(this.r32(S+N)&W)===G&&i.push({ino:_,path:w})}}h+=y}l+=f}}return e}statfs(){let e=this.r32(Pr),t=this.r32(mt),r=this.r32(Xt),i=typeof this.buffer.maxByteLength=="number"?this.buffer.maxByteLength:this.buffer.byteLength,s=Math.floor(i/e),o=Math.max(t,Math.min(r,s)),a=Atomics.load(this.i32,_t>>2),c=Math.max(0,o-t);return{blockSize:e,totalBlocks:o,freeBlocks:a+c,totalInodes:this.r32(We),freeInodes:Atomics.load(this.i32,Nr>>2),maxName:255}}r32(e){return this.view.getUint32(e,!0)}w32(e,t){this.view.setUint32(e,t,!0)}r64(e){return Number(this.view.getBigUint64(e,!0))}w64(e,t){this.view.setBigUint64(e,BigInt(t),!0)}waitForAtomicChange(e,t){if(this.atomicsWaitAllowed!==!1)try{Atomics.wait(this.i32,e,t),this.atomicsWaitAllowed=!0;return}catch(r){if(!(r instanceof TypeError))throw r;this.atomicsWaitAllowed=!1}for(;Atomics.load(this.i32,e)===t;);}resetAllocationHints(){this.blockAllocHint=this.findNextFreeBlockHint(),this.inodeAllocHint=this.findNextFreeInodeHint()}findNextFreeBlockHint(){let e=this.r32(mt),t=this.r32(Cr),r=this.r32(Fr)*4096;for(let i=t;i>2)+(i>>5),o=i&31;if((Atomics.load(this.i32,s)&1<>2)+(r>>5),s=r&31;if((Atomics.load(this.i32,i)&1<>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}sbUnlock(){let e=Mr>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}namespaceLock(){let e=Dr>>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}namespaceUnlock(){let e=Dr>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}withNamespaceLock(e){this.namespaceLock();try{return e()}finally{this.namespaceUnlock()}}resetRestoredRuntimeState(){Atomics.store(this.i32,Mr>>2,0),Atomics.store(this.i32,Dr>>2,0),this.u8.fill(0,256,4096);let e=this.r32(We),t=this.r32(tt)*4096;for(let r=0;r>5)*4)&1<<(r&31))===0||this.r32(i+K)!==0)continue;let o=this.r32(i+N),a=this.r64(i+k);(o&W)===pt&&a<=40?(this.u8.fill(0,i+ne,i+ne+40),this.w64(i+k,0)):this.inodeTruncate(r,0),this.inodeFree(r)}}blockAlloc(){let e=this.r32(mt),t=this.r32(Fr)*4096,r=this.r32(Cr),i=this.blockAllocHint>=r&&this.blockAllocHint>2)+(a>>5),l=a&31,d=Atomics.load(this.i32,c);if(d&1<>2,1),this.blockAllocHint=a+1>2)+(e>>5),i=e&31;for(;;){let s=Atomics.load(this.i32,r),o=s&~(1<>2,1),e>=this.r32(Cr)&&e>2)>0)return 0;let e=this.r32(mt),t=this.r32(Xt),r=this.r32(wo),i=e+r;if(i>t&&(i=t,r=i-e,r===0))return ie;let s=i*4096;if(this.buffer.byteLength>2,r),Atomics.add(this.i32,Nn>>2,1),this.blockAllocHint=e,0}finally{this.sbUnlock()}}inodeOffset(e){let r=this.r32(So)+Math.floor(e/32),i=e%32*128;return r*4096+i}inodeAlloc(){let e=this.r32(We),t=this.r32(tt)*4096,r=this.inodeAllocHint>=2&&this.inodeAllocHint>2)+(o>>5),c=o&31,l=Atomics.load(this.i32,a);if(l&1<>2,1),this.inodeAllocHint=o+1>2,1)+1}inodeFree(e){let r=(this.r32(tt)*4096>>2)+(e>>5),i=e&31;for(;;){let s=Atomics.load(this.i32,r);if((s&1<>2,1),e>=2&&e0&&this.w32(r+Ge,i-1),i<=1&&this.r32(r+K)===0&&(this.inodeTruncate(e,0),t=!0)}finally{this.inodeWriteUnlock(e)}t&&this.inodeFree(e)}inodeDropLinkRefLocked(e){let t=this.inodeOffset(e),r=this.r32(t+K);return r>1?(this.w32(t+K,r-1),this.w64(t+Q,Date.now()),!1):this.inodeOrphanLocked(e)}inodeOrphanLocked(e){let t=this.inodeOffset(e);if(this.w32(t+K,0),this.w64(t+Q,Date.now()),this.r32(t+Ge)>0)return!1;let r=this.r32(t+N),i=this.r64(t+k);return(r&W)===pt&&i<=40?(this.u8.fill(0,t+ne,t+ne+40),this.w64(t+k,0)):this.inodeTruncate(e,0),!0}inodeReadLock(e){let t=this.inodeOffset(e)+yt>>2;for(;;){let r=Atomics.load(this.i32,t);if(r&Ro){this.waitForAtomicChange(t,r);continue}if(Atomics.compareExchange(this.i32,t,r,r+1)===r)return}}inodeReadUnlock(e){let t=this.inodeOffset(e)+yt>>2;(Atomics.sub(this.i32,t,1)&fc)===1&&Atomics.notify(this.i32,t,1)}inodeWriteLock(e){let t=this.inodeOffset(e)+yt>>2;for(;;){let r=Atomics.load(this.i32,t);if(r!==0){this.waitForAtomicChange(t,r);continue}if(Atomics.compareExchange(this.i32,t,0,Ro)===0)return}}inodeWriteUnlock(e){let t=this.inodeOffset(e)+yt>>2;Atomics.store(this.i32,t,0),Atomics.notify(this.i32,t,1/0)}inodeBlockMap(e,t,r){let i=this.inodeOffset(e);if(t<10){let s=this.r32(i+ne+t*4);if(s!==0)return s;if(!r)return 0;let o=this.blockAllocWithGrow();return o<0||this.w32(i+ne+t*4,o),o}if(t-=10,t<1024){let s=this.r32(i+jt),o=!1;if(s===0){if(!r)return 0;if(s=this.blockAllocWithGrow(),s<0)return s;this.w32(i+jt,s),o=!0}let a=s*4096+t*4,c=this.r32(a);if(c!==0)return c;if(!r)return 0;let l=this.blockAllocWithGrow();return l<0?(o&&(this.w32(i+jt,0),this.blockFree(s)),l):(this.w32(a,l),l)}if(t-=1024,t<1024*1024){let s=Math.floor(t/1024),o=t%1024,a=this.r32(i+gt),c=!1;if(a===0){if(!r)return 0;if(a=this.blockAllocWithGrow(),a<0)return a;this.w32(i+gt,a),c=!0}let l=a*4096+s*4,d=this.r32(l),u=!1;if(d===0){if(!r)return 0;if(d=this.blockAllocWithGrow(),d<0)return c&&(this.w32(i+gt,0),this.blockFree(a)),d;this.w32(l,d),u=!0}let p=d*4096+o*4,m=this.r32(p);if(m!==0)return m;if(!r)return 0;let f=this.blockAllocWithGrow();return f<0?(u&&(this.w32(l,0),this.blockFree(d)),c&&(this.w32(i+gt,0),this.blockFree(a)),f):(this.w32(p,f),f)}return Y}inodeReadData(e,t,r,i){let s=this.inodeOffset(e),o=this.r64(s+k);if(t>=o)return 0;t+i>o&&(i=o-t);let a=0,c=0;for(;i>0;){let l=Math.floor(t/4096),d=t%4096,u=4096-d;u>i&&(u=i);let p=this.inodeBlockMap(e,l,!1);if(p<=0)r.fill(0,c,c+u);else{let m=p*4096+d;r.set(this.u8.subarray(m,m+u),c)}c+=u,t+=u,i-=u,a+=u}return a}inodeWriteData(e,t,r,i){let s=this.inodeOffset(e),o=this.r64(s+k);t>o&&this.zeroOldEofTail(e,o);let a=0,c=0;for(;i>0;){let l=Math.floor(t/4096),d=t%4096,u=4096-d;u>i&&(u=i);let p=this.inodeBlockMap(e,l,!0);if(p<0){if(a===0)return p;break}let m=p*4096+d;this.u8.set(r.subarray(c,c+u),m),c+=u,t+=u,i-=u,a+=u}if(a>0&&t>this.r64(s+k)&&this.w64(s+k,t),a>0){let l=Date.now();this.w64(s+de,l),this.w64(s+Q,l),Atomics.add(this.i32,s+le>>2,1)}return a}zeroInodeRange(e,t,r){for(;t0){let c=a*4096+s;this.u8.fill(0,c,c+o)}t+=o}}zeroOldEofTail(e,t){let r=t%4096;if(r===0)return;let i=Math.floor(t/4096),s=this.inodeBlockMap(e,i,!1);if(s<=0)return;let o=s*4096+r;this.u8.fill(0,o,s*4096+4096)}freeBlocksFrom(e,t){let r=this.inodeOffset(e);for(let o=t;o<10;o++){let a=this.r32(r+ne+o*4);a&&(this.blockFree(a),this.w32(r+ne+o*4,0))}let i=this.r32(r+jt);if(i){let o=t>10?t-10:0;for(let a=o;a<1024;a++){let c=i*4096+a*4,l=this.r32(c);l&&(this.blockFree(l),this.w32(c,0))}o===0&&(this.blockFree(i),this.w32(r+jt,0))}let s=this.r32(r+gt);if(s){let o=t>1034?t-10-1024:0,a=Math.floor(o/1024);for(let c=a;c<1024;c++){let l=s*4096+c*4,d=this.r32(l);if(!d)continue;let u=c===a?o%1024:0;for(let p=u;p<1024;p++){let m=d*4096+p*4,f=this.r32(m);f&&(this.blockFree(f),this.w32(m,0))}u===0&&(this.blockFree(d),this.w32(l,0))}a===0&&(this.blockFree(s),this.w32(r+gt,0))}}inodeTruncate(e,t,r=!1){let i=this.inodeOffset(e),s=this.r64(i+k),o=t!==s;if(t>=s){if(t>s&&this.zeroOldEofTail(e,s),this.w64(i+k,t),o||r){let c=Date.now();this.w64(i+de,c),this.w64(i+Q,c),Atomics.add(this.i32,i+le>>2,1)}return}t%4096!==0&&this.zeroInodeRange(e,t,Math.ceil(t/4096)*4096);let a=Math.ceil(t/4096);if(this.freeBlocksFrom(e,a),this.w64(i+k,t),o||r){let c=Date.now();this.w64(i+de,c),this.w64(i+Q,c),Atomics.add(this.i32,i+le>>2,1)}}validateFileSize(e){if(!Number.isSafeInteger(e)||e<0)throw new I(Y);if(e>He)throw new I(nt)}validateSeekPosition(e){if(!Number.isSafeInteger(e))throw new I(bo);if(e<0)throw new I(Y);if(e>He)throw new I(nt)}touchDirectoryMutation(e){let t=this.inodeOffset(e),r=Date.now();this.w64(t+de,r),this.w64(t+Q,r);let i=Atomics.add(this.i32,t+Ao>>2,1)+1>>>0,s=this.dirIndexes.get(e);s&&(s.mutationSequence=i,s.size=this.r64(t+k))}dirNameKey(e){return Et(e)}dirEntryNameMatches(e,t){if(this.view.getUint16(e+6,!0)!==t.length)return!1;for(let i=0;i=P&&r%4===0&&e+r<=t&&i<=r-P}inodeIsAllocated(e){let t=this.r32(We);if(e<=0||e>=t)return!1;let r=this.r32(tt)*4096;return(Atomics.load(this.i32,(r>>2)+(e>>5))&1<<(e&31))!==0}rebuildDirIndex(e,t,r,i){let s=new Map,o=[],a=0;for(;a4096-d&&(m=4096-d);let f=d;for(;f=P&&o.push({abs:h,recLen:_});f+=_}a+=m}let c={generation:t,mutationSequence:r,size:i,entries:s,free:o};return this.dirIndexes.set(e,c),c}getDirIndex(e){let t=this.inodeOffset(e),r=this.r64(t+k),i=this.r64(t+ce),s=Atomics.load(this.i32,t+Ao>>2)>>>0,o=this.dirIndexes.get(e);return o&&o.generation===i&&o.mutationSequence===s&&o.size===r?o:(o&&this.dirIndexes.delete(e),r=0;o--){let a=e.free[o];if(!(a.recLen4096-c&&(u=4096-c);let p=c;for(;pr)return-1;a=c,o+=l}return o===r?a:-1}dirAppendEntry(e,t,r,i=-1){let s=this.inodeOffset(e),o=this.r64(s+k),a=Ve(P+t.length),c=o,l=Math.floor(c/4096),d=c%4096,u=0;if(d!==0&&d+a>4096){let f=4096-d,h=0;if(f>=P){if(h=this.inodeBlockMap(e,l,!1),h<=0)return B}else if(i<0&&(i=this.findLastDirEntryInBlock(e,l,d)),i<0)return B;if(u=this.inodeBlockMap(e,l+1,!0),u<0)return u;if(f>=P){let g=h*4096+d;this.w32(g,0),this.view.setUint16(g+4,f,!0),this.view.setUint16(g+6,0,!0)}else{let _=this.view.getUint16(i+4,!0)+f;this.view.setUint16(i+4,_,!0),this.updateDirIndexRecLen(e,i,_)}c=(l+1)*4096,l++,d=0}let p;if(d===0){if(p=u||this.inodeBlockMap(e,l,!0),p<0)return p}else if(p=this.inodeBlockMap(e,l,!1),p<=0)return B;let m=p*4096+d;return this.w32(m,r),this.view.setUint16(m+4,a,!0),this.view.setUint16(m+6,t.length,!0),this.u8.set(t,m+P),this.w64(s+k,c+a),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,m,a),0}dirAddEntry(e,t,r){let i=this.getDirIndex(e);if(typeof i=="number")return i;if(i)return this.useDirIndexFreeSlot(i,e,t,r)?0:this.dirAppendEntry(e,t,r);let s=this.inodeOffset(e),o=this.r64(s+k),a=Ve(P+t.length),c=-1,l=0;for(;l4096-u&&(f=4096-u);let h=u;for(;hu+f||E>y-P)return B;if(_===0&&y>=a)return this.w32(g,r),this.view.setUint16(g+6,t.length,!0),this.u8.set(t,g+P),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,g,y),0;let A=Ve(P+E),w=y-A;if(_!==0&&w>=a){this.view.setUint16(g+4,A,!0);let S=g+A;return this.w32(S,r),this.view.setUint16(S+4,w,!0),this.view.setUint16(S+6,t.length,!0),this.u8.set(t,S+P),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,S,w),0}c=g,h+=y}l+=f}return this.dirAppendEntry(e,t,r,c)}dirRemoveEntry(e,t){let r=this.getDirIndex(e);if(typeof r=="number")return r;if(r){let a=this.dirNameKey(t),c=r.entries.get(a);if(!c)return we;if(this.r32(c.abs)===c.ino&&this.view.getUint16(c.abs+4,!0)===c.recLen&&this.view.getUint16(c.abs+6,!0)===c.nameLen&&this.dirEntryNameMatches(c.abs,t))return this.w32(c.abs,0),r.entries.delete(a),r.free.push({abs:c.abs,recLen:c.recLen}),this.touchDirectoryMutation(e),0;r.entries.delete(a)}let i=this.inodeOffset(e),s=this.r64(i+k),o=0;for(;o4096-c&&(u=4096-c);let p=c;for(;p4096-l&&(p=4096-l);let m=l;for(;m4096-o&&(l=4096-o);let d=o;for(;do+l||f>m-P)throw new I(B);if(p!==0){if(f===1&&this.u8[u+P]===46){d+=m;continue}if(f===2&&this.u8[u+P]===46&&this.u8[u+P+1]===46){d+=m;continue}return!1}d+=m}i+=l}return!0}dirIsAncestor(e,t){let r=t;for(let i=0;i<8*1024;i++){if(r===e)return!0;if(r===1)return!1;let s=this.dirLookup(r,vo);if(s<0||s===r)throw new I(B);r=s}throw new I(B)}pathResolve(e,t){if(!e.startsWith("/"))return we;let r=1,i=e.split("/").filter(o=>o.length>0),s=0;for(let o=0;o255)return Mn;let c=pe.encode(a),l;this.inodeReadLock(r);try{let p=this.inodeOffset(r);if((this.r32(p+N)&W)!==G)return Le;l=this.dirLookup(r,c)}finally{this.inodeReadUnlock(r)}if(l<0)return l;let d=this.inodeOffset(l);if((this.r32(d+N)&W)===pt&&(!(o===i.length-1)||t)){if(++s>8)return Lo;let m=this.r64(d+k),f;if(m<=40)f=Et(this.u8.subarray(d+ne,d+ne+m));else{let h=new Uint8Array(m);this.inodeReadData(l,0,h,m),f=tr.decode(h)}if(f.startsWith("/")){r=1;let h=f.split("/").filter(_=>_.length>0),g=i.slice(o+1);i.length=0,i.push(...h,...g),o=-1}else{let h=f.split("/").filter(_=>_.length>0),g=i.slice(o+1);i.length=o,i.push(...h,...g),o--}continue}r=l}return r}pathResolveParent(e){if(!e.startsWith("/"))throw new I(Y,"Path must be absolute");let t=e.split("/").filter(c=>c.length>0);if(t.length===0)throw new I(Y,"Cannot operate on /");let r=t.pop();if(r.length>255)throw new I(Mn);let i="/"+t.join("/"),s=this.pathResolve(i,!0);if(s<0)throw new I(s);let o=this.inodeOffset(s);if((this.r32(o+N)&W)!==G)throw new I(Le);return{parentIno:s,name:r}}fdAlloc(e,t,r){for(let i=0;i>2;if(Atomics.compareExchange(this.i32,o,0,1)===0)return this.w32(s+Oo,e),this.w64(s+Te,0),this.w32(s+Io,t),this.w32(s+xo,r?1:0),this.inodeAddOpenRef(e)?i:(Atomics.store(this.i32,o,0),we)}return To}fdGet(e){if(e<0||e>=kr)return null;let t=256+e*24;return Atomics.load(this.i32,t>>2)?{base:t,ino:this.r32(t+Oo),offset:this.r64(t+Te),flags:this.r32(t+Io),isDir:this.r32(t+xo)!==0}:null}fdFree(e){if(e>=0&&e>2,0)}}buildStat(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+ce),dataSequence:this.r32(t+le),mode:this.r32(t+N),linkCount:this.r32(t+K),size:this.r64(t+k),mtime:this.r64(t+de),ctime:this.r64(t+Q),atime:this.r64(t+Yt),uid:this.r32(t+Jt),gid:this.r32(t+Qt)}}namespaceEntryIdentity(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+ce),linkCount:this.r32(t+K),mode:this.r32(t+N)}}open(e,t,r=420){return this.withNamespaceLock(()=>this.openUnlocked(e,t,r))}createLazyStub(e,t){return this.withNamespaceLock(()=>{let r=this.openUnlocked(e,Eo|er,t);try{let i=this.fdGet(r);if(!i)throw new I(te);this.inodeWriteLock(i.ino);try{return this.inodeTruncate(i.ino,0,!0),this.buildStat(i.ino)}finally{this.inodeWriteUnlock(i.ino)}}finally{this.closeUnlocked(r)}})}replaceIfIdentity(e,t,r,i,s){return this.withNamespaceLock(()=>{let o=this.pathResolve(e,!0);if(o<0||o!==t)return!1;let a=this.inodeOffset(o);if(this.r64(a+ce)!==r||this.r32(a+le)!==i||(this.r32(a+N)&W)!==qt)return!1;this.validateFileSize(s.byteLength),this.inodeWriteLock(o);try{if(this.r64(a+ce)!==r||this.r32(a+le)!==i||this.r64(a+k)!==0)return!1;let c=this.r64(a+de),l=this.r64(a+Q);this.inodeTruncate(o,0,!0);let d=s.byteLength>0?this.inodeWriteData(o,0,s,s.byteLength):0;if(d!==s.byteLength)throw this.inodeTruncate(o,0,!0),Atomics.store(this.i32,a+le>>2,i),this.w64(a+de,c),this.w64(a+Q,l),new I(d<0?d:ie);return!0}finally{this.inodeWriteUnlock(o)}})}replaceManyIfIdentities(e,t=[]){return e.length===0&&t.length===0?!0:this.withNamespaceLock(()=>{let r=[],i=new Set,s=a=>{let c=this.pathResolve(a.path,!1);if(c<0||c!==a.expectedIno)return!1;let l=this.inodeOffset(c);return this.r64(l+ce)===a.expectedGeneration&&this.r32(l+le)===a.expectedDataSequence&&this.r32(l+N)===a.expectedMode&&this.r32(l+K)===a.expectedLinkCount&&this.r64(l+k)===a.expectedSize&&this.r32(l+Jt)===a.expectedUid&&this.r32(l+Qt)===a.expectedGid};for(let a of t)if(!s(a))return!1;for(let a of e){this.validateFileSize(a.data.byteLength);let c=-1;for(let l of a.paths){let d=this.pathResolve(l,!0);if(d!==a.expectedIno)continue;let u=this.inodeOffset(d);if(this.r64(u+ce)===a.expectedGeneration&&this.r32(u+le)===a.expectedDataSequence&&(this.r32(u+N)&W)===qt&&this.r64(u+k)===0){c=d;break}}if(c<0)return!1;if(i.has(c))throw new I(Y,"duplicate conditional replacement inode");i.add(c),r.push({...a,ino:c})}let o=[...i].sort((a,c)=>a-c);for(let a of o)this.inodeWriteLock(a);try{for(let l of r){let d=this.inodeOffset(l.ino);if(this.r64(d+ce)!==l.expectedGeneration||this.r32(d+le)!==l.expectedDataSequence||(this.r32(d+N)&W)!==qt||this.r64(d+k)!==0)return!1}for(let l of t)if(!s(l))return!1;let a=r.map(l=>{let d=this.inodeOffset(l.ino);return{ino:l.ino,dataSequence:this.r32(d+le),mtime:this.r64(d+de),ctime:this.r64(d+Q)}}),c=0;try{for(let l of r){c++,this.inodeTruncate(l.ino,0,!0);let d=l.data.byteLength>0?this.inodeWriteData(l.ino,0,l.data,l.data.byteLength):0;if(d!==l.data.byteLength)throw new I(d<0?d:ie)}}catch(l){for(let d=c-1;d>=0;d--){let u=a[d],p=this.inodeOffset(u.ino);this.inodeTruncate(u.ino,0,!0),Atomics.store(this.i32,p+le>>2,u.dataSequence),this.w64(p+de,u.mtime),this.w64(p+Q,u.ctime)}throw l}return!0}finally{for(let a=o.length-1;a>=0;a--)this.inodeWriteUnlock(o[a])}})}openUnlocked(e,t,r=420){let i=t&Zt,s=(t&er)!==0,o=(t&Kn)!==0;if(s&&o){let u=this.pathResolve(e,!1);if(u>=0)throw new I(St);if(u!==we)throw new I(u)}let a=this.pathResolve(e,!0);if(a<0&&a===we&&s){let{parentIno:u,name:p}=this.pathResolveParent(e);this.inodeWriteLock(u);try{let m=pe.encode(p),f=this.dirLookup(u,m);if(f>=0){if(o)throw new I(St);a=f}else{let h=this.inodeAlloc();if(h<0)throw new I(ie);let g=this.inodeOffset(h);this.w32(g+N,qt|r&4095),this.w32(g+K,1),this.w64(g+k,0);let _=Date.now();this.w64(g+Yt,_),this.w64(g+de,_),this.w64(g+Q,_);let y=this.dirAddEntry(u,m,h);if(y<0)throw this.inodeFree(h),new I(y);a=h}}finally{this.inodeWriteUnlock(u)}}if(a<0)throw new I(a);let c=this.inodeOffset(a),l=this.r32(c+N);if((l&W)===G&&i!==et)throw new I(rt);if(t&ic&&(l&W)!==G)throw new I(Le);if(t&rr){if((l&W)===G)throw new I(rt);this.inodeWriteLock(a),this.inodeTruncate(a,0,!0),this.inodeWriteUnlock(a)}let d=this.fdAlloc(a,t,!1);if(d<0)throw new I(d);return d}close(e){this.withNamespaceLock(()=>this.closeUnlocked(e))}closeUnlocked(e){let t=this.fdGet(e);if(!t)throw new I(te);this.fdFree(e),this.inodeDropOpenRef(t.ino)}read(e,t){let r=this.fdGet(e);if(!r)throw new I(te);let i=this.inodeOffset(r.ino);if((this.r32(i+N)&W)===G)throw new I(rt);this.inodeReadLock(r.ino);try{let o=this.inodeReadData(r.ino,r.offset,t,t.length),a=256+e*24;return this.w64(a+Te,r.offset+o),o}finally{this.inodeReadUnlock(r.ino)}}readAt(e,t,r){let i=this.fdGet(e);if(!i)throw new I(te);let s=this.inodeOffset(i.ino);if((this.r32(s+N)&W)===G)throw new I(rt);this.validateSeekPosition(r),this.inodeReadLock(i.ino);try{return this.inodeReadData(i.ino,r,t,t.length)}finally{this.inodeReadUnlock(i.ino)}}write(e,t){let r=this.fdGet(e);if(!r)throw new I(te);if((r.flags&Zt)===et)throw new I(te);this.inodeWriteLock(r.ino);try{let s=r.offset;if(r.flags&nc){let c=this.inodeOffset(r.ino);s=this.r64(c+k)}if(!Number.isSafeInteger(s)||s<0)throw new I(Y);if(s>He||t.length>He-s)throw new I(nt);let o=this.inodeWriteData(r.ino,s,t,t.length);if(o<0)return o;let a=256+e*24;return this.w64(a+Te,s+o),o}finally{this.inodeWriteUnlock(r.ino)}}append(e,t,r){let i=this.fdGet(e);if(!i)throw new I(te);if((i.flags&Zt)===et)throw new I(te);if(r!==null&&(!Number.isSafeInteger(r)||r<0))throw new I(Y);this.inodeWriteLock(i.ino);try{let o=this.inodeOffset(i.ino),a=this.r64(o+k);if(!Number.isSafeInteger(a)||a<0)throw new I(Y);if(a>He)throw new I(nt);if(r!==null&&a>=r){let f=256+e*24;return this.w64(f+Te,a),{written:0,end:a}}let c=r===null?t.length:Math.min(t.length,r-a),l=He-a;if(c>l)throw new I(nt);let d=t.subarray(0,c),u=this.inodeWriteData(i.ino,a,d,d.length);if(u<0)throw new I(u);let p=256+e*24,m=a+u;return this.w64(p+Te,m),{written:u,end:m}}finally{this.inodeWriteUnlock(i.ino)}}writeAt(e,t,r){let i=this.fdGet(e);if(!i)throw new I(te);if((i.flags&Zt)===et)throw new I(te);this.validateSeekPosition(r),this.inodeWriteLock(i.ino);try{if(r>He||t.length>He-r)throw new I(nt);return this.inodeWriteData(i.ino,r,t,t.length)}finally{this.inodeWriteUnlock(i.ino)}}lseek(e,t,r){let i=this.fdGet(e);if(!i)throw new I(te);let s;if(r===oc)s=t;else if(r===sc)s=i.offset+t;else if(r===ac){let a=this.inodeOffset(i.ino);s=this.r64(a+k)+t}else throw new I(Y);this.validateSeekPosition(s);let o=256+e*24;return this.w64(o+Te,s),s}ftruncate(e,t){let r=this.fdGet(e);if(!r)throw new I(te);if((r.flags&Zt)===et)throw new I(te);this.validateFileSize(t),this.inodeWriteLock(r.ino);try{this.inodeTruncate(r.ino,t,!0)}finally{this.inodeWriteUnlock(r.ino)}}fstat(e){let t=this.fdGet(e);if(!t)throw new I(te);this.inodeReadLock(t.ino);try{return this.buildStat(t.ino)}finally{this.inodeReadUnlock(t.ino)}}stat(e){return this.withNamespaceLock(()=>this.statUnlocked(e))}statUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new I(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}lstat(e){return this.withNamespaceLock(()=>this.lstatUnlocked(e))}lstatUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new I(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}unlink(e){return this.withNamespaceLock(()=>this.unlinkUnlocked(e))}unlinkUnlocked(e){let{parentIno:t,name:r}=this.pathResolveParent(e),i=pe.encode(r),s=e.length>1&&e.endsWith("/");this.inodeWriteLock(t);try{let o=this.dirLookup(t,i);if(o<0)throw new I(o);let a=this.inodeOffset(o),c=this.r32(a+N);if(s&&(c&W)!==G)throw new I(Le);if((c&W)===G)throw new I(rt);let l=this.namespaceEntryIdentity(o),d=this.dirRemoveEntry(t,i);if(d<0)throw new I(d);let u=!1;this.inodeWriteLock(o);try{u=this.inodeDropLinkRefLocked(o)}finally{this.inodeWriteUnlock(o)}return u&&this.inodeFree(o),l}finally{this.inodeWriteUnlock(t)}}rename(e,t){return this.withNamespaceLock(()=>this.renameUnlocked(e,t))}renameUnlocked(e,t){let{parentIno:r,name:i}=this.pathResolveParent(e),{parentIno:s,name:o}=this.pathResolveParent(t);if(Fn(i)||Fn(o))throw new I(Y);let a=pe.encode(i),c=pe.encode(o),l=e.length>1&&e.endsWith("/"),d=t.length>1&&t.endsWith("/"),u=Math.min(r,s),p=Math.max(r,s);this.inodeWriteLock(u),u!==p&&this.inodeWriteLock(p);try{let m=this.dirLookup(r,a);if(m<0)throw new I(m);let f=this.inodeOffset(m),g=this.r32(f+N)&W,_=this.namespaceEntryIdentity(m);if((l||d)&&g!==G)throw new I(Le);if(g===G&&this.dirIsAncestor(m,s))throw new I(Y);let y=this.dirLookup(s,c),E=!1,A;if(y>=0){if(y===m)return{source:_,replaced:_};A=this.namespaceEntryIdentity(y);let S=this.inodeOffset(y),x=this.r32(S+N)&W;if(g===G&&x!==G)throw new I(Le);if(g!==G&&x===G)throw new I(rt);let R=!1,T=y===r||y===s;T||this.inodeWriteLock(y);try{if(x===G&&!this.dirIsEmpty(y))throw new I(Dn);let L=this.dirReplaceEntryIno(s,c,m);if(L<0)throw new I(L);R=x===G?this.inodeOrphanLocked(y):this.inodeDropLinkRefLocked(y)}finally{T||this.inodeWriteUnlock(y)}R&&this.inodeFree(y),E=x===G}else{let S=this.dirAddEntry(s,c,m);if(S<0)throw new I(S)}let w=this.dirRemoveEntry(r,a);if(w<0)throw new I(w);if(g===G){if(r!==s){let S=this.inodeOffset(r);this.w32(S+K,this.r32(S+K)-1);let O=this.inodeOffset(s);this.w32(O+K,this.r32(O+K)+1),this.inodeWriteLock(m);try{let x=this.dirReplaceEntryIno(m,vo,s);if(x<0)throw new I(x);this.w64(f+Q,Date.now())}finally{this.inodeWriteUnlock(m)}}if(E){let S=this.inodeOffset(s);this.w32(S+K,this.r32(S+K)-1)}}else if(E){let S=this.inodeOffset(s);this.w32(S+K,this.r32(S+K)-1)}return{source:_,replaced:A}}finally{u!==p&&this.inodeWriteUnlock(p),this.inodeWriteUnlock(u)}}mkdir(e,t=493){this.withNamespaceLock(()=>this.mkdirUnlocked(e,t))}mkdirUnlocked(e,t=493){let{parentIno:r,name:i}=this.pathResolveParent(e),s=pe.encode(i);this.inodeWriteLock(r);try{if(this.dirLookup(r,s)>=0)throw new I(St);let a=this.inodeAlloc();if(a<0)throw new I(ie);let c=this.inodeOffset(a);this.w32(c+N,G|t),this.w32(c+K,2),this.w64(c+k,0);let l=Date.now();this.w64(c+Yt,l),this.w64(c+de,l),this.w64(c+Q,l);let d=this.blockAllocWithGrow();if(d<0)throw this.inodeFree(a),new I(ie);this.w32(c+ne,d);let u=d*4096,p=Ve(P+1),m=Ve(P+2);this.w32(u,a),this.view.setUint16(u+4,p,!0),this.view.setUint16(u+6,1,!0),this.u8[u+P]=46;let f=u+p;this.w32(f,r),this.view.setUint16(f+4,m,!0),this.view.setUint16(f+6,2,!0),this.u8[f+P]=46,this.u8[f+P+1]=46,this.w64(c+k,p+m);let h=this.dirAddEntry(r,s,a);if(h<0)throw this.blockFree(d),this.inodeFree(a),new I(h);let g=this.inodeOffset(r);this.w32(g+K,this.r32(g+K)+1)}finally{this.inodeWriteUnlock(r)}}rmdir(e){this.withNamespaceLock(()=>this.rmdirUnlocked(e))}rmdirUnlocked(e){let{parentIno:t,name:r}=this.pathResolveParent(e);if(Fn(r))throw new I(Y);let i=pe.encode(r);this.inodeWriteLock(t);try{let s=this.dirLookup(t,i);if(s<0)throw new I(s);let o=this.inodeOffset(s);if((this.r32(o+N)&W)!==G)throw new I(Le);let c=!1;this.inodeWriteLock(s);try{if(!this.dirIsEmpty(s))throw new I(Dn);let d=this.dirRemoveEntry(t,i);if(d<0)throw new I(d);c=this.inodeOrphanLocked(s)}finally{this.inodeWriteUnlock(s)}c&&this.inodeFree(s);let l=this.inodeOffset(t);this.w32(l+K,this.r32(l+K)-1)}finally{this.inodeWriteUnlock(t)}}symlink(e,t){this.withNamespaceLock(()=>this.symlinkUnlocked(e,t))}symlinkUnlocked(e,t){let{parentIno:r,name:i}=this.pathResolveParent(t),s=pe.encode(i),o=pe.encode(e);this.inodeWriteLock(r);try{if(this.dirLookup(r,s)>=0)throw new I(St);let c=this.inodeAlloc();if(c<0)throw new I(ie);let l=this.inodeOffset(c);if(this.w32(l+N,pt|511),this.w32(l+K,1),o.length<=40)this.u8.set(o,l+ne),this.w64(l+k,o.length);else{this.w64(l+k,0);let u=this.inodeWriteData(c,0,o,o.length);if(u!==o.length)throw u>0&&this.inodeTruncate(c,0),this.inodeFree(c),new I(u<0?u:ie)}let d=this.dirAddEntry(r,s,c);if(d<0)throw o.length<=40?(this.u8.fill(0,l+ne,l+ne+40),this.w64(l+k,0)):this.inodeTruncate(c,0),this.inodeFree(c),new I(d)}finally{this.inodeWriteUnlock(r)}}chmod(e,t){this.withNamespaceLock(()=>this.chmodUnlocked(e,t))}chmodUnlocked(e,t){let r=this.pathResolve(e,!0);if(r<0)throw new I(r);this.inodeWriteLock(r);try{let i=this.inodeOffset(r),s=this.r32(i+N);this.w32(i+N,s&W|t&4095),this.w64(i+Q,Date.now())}finally{this.inodeWriteUnlock(r)}}fchmod(e,t){let r=this.fdGet(e);if(!r)throw new I(te);this.inodeWriteLock(r.ino);try{let i=this.inodeOffset(r.ino),s=this.r32(i+N);this.w32(i+N,s&W|t&4095),this.w64(i+Q,Date.now())}finally{this.inodeWriteUnlock(r.ino)}}chown(e,t,r){this.withNamespaceLock(()=>this.chownUnlocked(e,t,r))}chownUnlocked(e,t,r){let i=this.pathResolve(e,!0);if(i<0)throw new I(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,r)}finally{this.inodeWriteUnlock(i)}}fchown(e,t,r){let i=this.fdGet(e);if(!i)throw new I(te);this.inodeWriteLock(i.ino);try{this.chownInodeUnlocked(i.ino,t,r)}finally{this.inodeWriteUnlock(i.ino)}}lchown(e,t,r){this.withNamespaceLock(()=>this.lchownUnlocked(e,t,r))}lchownUnlocked(e,t,r){let i=this.pathResolve(e,!1);if(i<0)throw new I(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,r)}finally{this.inodeWriteUnlock(i)}}chownInodeUnlocked(e,t,r){let i=this.inodeOffset(e);t!==go&&this.w32(i+Jt,t),r!==go&&this.w32(i+Qt,r);let s=this.r32(i+N);(s&W)===qt&&(s&rc)!==0&&this.w32(i+N,s&~(ec|tc)),this.w64(i+Q,Date.now())}utimens(e,t,r,i,s){this.withNamespaceLock(()=>this.utimensUnlocked(e,t,r,i,s))}utimensUnlocked(e,t,r,i,s){let o=this.pathResolve(e,!0);if(o<0)throw new I(o);this.inodeWriteLock(o);try{let a=this.inodeOffset(o),c=1073741823,l=1073741822,d=Date.now();if(r!==l){let u=r===c?d:t*1e3+Math.floor(r/1e6);this.w64(a+Yt,u)}if(s!==l){let u=s===c?d:i*1e3+Math.floor(s/1e6);this.w64(a+de,u)}this.w64(a+Q,d)}finally{this.inodeWriteUnlock(o)}}link(e,t){return this.withNamespaceLock(()=>this.linkUnlocked(e,t))}linkUnlocked(e,t){let r=this.pathResolve(e,!1);if(r<0)throw new I(r);let i=this.inodeOffset(r);if((this.r32(i+N)&W)===G)throw new I(cc);let{parentIno:o,name:a}=this.pathResolveParent(t),c=pe.encode(a);this.inodeWriteLock(o);try{if(this.dirLookup(o,c)>=0)throw new I(St);let d=this.dirAddEntry(o,c,r);if(d<0)throw new I(d);this.inodeWriteLock(r);try{let u=this.r32(i+K);this.w32(i+K,u+1),this.w64(i+Q,Date.now())}finally{this.inodeWriteUnlock(r)}return{...this.namespaceEntryIdentity(r),linkCount:this.r32(i+K)}}finally{this.inodeWriteUnlock(o)}}readlink(e){return this.withNamespaceLock(()=>this.readlinkUnlocked(e))}readlinkUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new I(t);return this.readSymlinkInodeUnlocked(t)}readSymlinkInodeUnlocked(e){let t=this.inodeOffset(e);if((this.r32(t+N)&W)!==pt)throw new I(Y);let i=this.r64(t+k);if(i<=40)return Et(this.u8.subarray(t+ne,t+ne+i));this.inodeReadLock(e);try{let s=new Uint8Array(i);return this.inodeReadData(e,0,s,i),tr.decode(s)}finally{this.inodeReadUnlock(e)}}opendir(e){return this.withNamespaceLock(()=>this.opendirUnlocked(e))}opendirUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new I(t);let r=this.inodeOffset(t);if((this.r32(r+N)&W)!==G)throw new I(Le);let s=this.fdAlloc(t,et,!0);if(s<0)throw new I(s);return s}readdirEntry(e){return this.withNamespaceLock(()=>this.readdirEntryUnlocked(e))}readdirEntryUnlocked(e){let t=this.fdGet(e);if(!t||!t.isDir)throw new I(te);let r=this.inodeOffset(t.ino),i=this.r64(r+k);for(;t.offset=this.r32(We))throw new I(B);let h=this.r32(tt)*4096;if((this.r32(h+(d>>5)*4)&1<<(d&31))===0)throw new I(B);let _=Et(this.u8.subarray(l+P,l+P+p)),y=this.buildStat(d);return this.w64(f+Te,m),t.offset=m,{name:_,stat:y}}return null}closedir(e){this.close(e)}readdir(e){let t=this.opendir(e),r=[];try{let i;for(;(i=this.readdirEntry(t))!==null;)i.name!=="."&&i.name!==".."&&r.push(i.name)}finally{this.closedir(t)}return r}writeFile(e,t){let r=typeof t=="string"?pe.encode(t):t,i=this.open(e,Eo|er|rr);try{this.write(i,r)}finally{this.close(i)}}readFile(e){let t=this.open(e,et);try{let r=this.fstat(t),i=new Uint8Array(r.size);return this.read(t,i),i}finally{this.close(t)}}readFileText(e){return tr.decode(this.readFile(e))}};function zo(n,e){let t=new Map,r=new Map;for(let o of n){if(t.has(o.path))throw new Error(`${e} duplicates path ${o.path}`);if(t.set(o.path,o),o.type==="file"){if(!o.inodeGroup)throw new Error(`${e} file ${o.path} has no inode group`);if(r.has(o.inodeGroup))throw new Error(`${e} inode group ${o.inodeGroup} has multiple files`);r.set(o.inodeGroup,o)}}let i=new Set,s=new Map;for(let o of n){if(o.type!=="hardlink"||s.has(o.path))continue;let a=[],c=o,l;for(;c.type==="hardlink";){let u=s.get(c.path);if(u){l=u;break}if(i.has(c.path))throw new Error(`${e} hardlink cycle reaches ${c.path}`);if(i.add(c.path),a.push(c),!c.target)throw new Error(`${e} hardlink ${c.path} has no target`);let p=t.get(c.target);if(!p)throw new Error(`${e} hardlink ${c.path} target ${c.target} is missing`);if(p.type!=="file"&&p.type!=="hardlink"||!c.inodeGroup||p.inodeGroup!==c.inodeGroup||p.size!==c.size||p.mode!==c.mode)throw new Error(`${e} hardlink ${c.path} has an invalid target`);c=p}l??=c.type==="file"?c:void 0;let d=r.get(o.inodeGroup??"");if(!l||l!==d)throw new Error(`${e} hardlink ${o.path} does not resolve to its inode`);for(let u=a.length-1;u>=0;u-=1){let p=a[u];if(r.get(p.inodeGroup??"")!==l)throw new Error(`${e} hardlink ${p.path} does not resolve to its inode`);i.delete(p.path),s.set(p.path,l)}}return{canonicalByGroup:r,canonicalTargetByPath:s}}var fe={maxArchiveBytes:268435456,maxExpandedBytes:268435456,maxPayloadBytes:268435456,maxEntries:1e5,maxPathBytes:4096,maxSymlinkTargetBytes:65536,maxStringBytes:8192,maxTransportsPerTree:8,maxActivationCapabilities:32,maxActivationRoots:64,maxActivationCapabilityBytes:255},xe={maxArchiveBytes:512*1024*1024,maxExpandedBytes:512*1024*1024,maxPayloadBytes:512*1024*1024,maxEntries:1e5,maxGroups:512};function ko(n,e="Deferred tree collection"){for(let[t,r]of Object.entries(n))if(!Number.isSafeInteger(r)||r<0)throw new Error(`${e} ${t} usage is invalid`);if(n.groups>xe.maxGroups)throw new Error(`${e} exceeds the ${xe.maxGroups}-group cap`);if(n.archiveBytes>xe.maxArchiveBytes)throw new Error(`${e} exceeds the archive-byte cap`);if(n.expandedBytes>xe.maxExpandedBytes)throw new Error(`${e} exceeds the expansion cap`);if(n.payloadBytes>xe.maxPayloadBytes)throw new Error(`${e} exceeds the payload-byte cap`);if(n.entries>xe.maxEntries)throw new Error(`${e} exceeds the entry-count cap`)}var Po=Object.freeze({prefix:"/opt/kandelo/homebrew",cellar:"/opt/kandelo/homebrew/Cellar",repository:"/opt/kandelo/homebrew",stableEntrypoint:"/usr/bin/brew"});var No=1e5,mc=4096;var wt=Po.prefix,Mo=[["@@HOMEBREW_PREFIX@@",wt],["@@HOMEBREW_CELLAR@@",`${wt}/Cellar`],["@@HOMEBREW_REPOSITORY@@",wt],["@@HOMEBREW_LIBRARY@@",`${wt}/Library`],["@@HOMEBREW_PERL@@",`${wt}/opt/perl/bin/perl`]],Bn="@@HOMEBREW_JAVA@@",_c=/^openjdk(?:@\d+(?:\.\d+)*)?/,At=new TextEncoder,yc=[...Mo.map(([n])=>n),Bn].map(n=>({placeholder:n,bytes:At.encode(n)}));function Do(n){let e=gc(n),t=e.changed_files;if(t!=null&&!Array.isArray(t))throw new Error("INSTALL_RECEIPT.json changed_files must be an array or null when present");let r=Array.isArray(t)?t:[];if(r.length>No)throw new Error(`INSTALL_RECEIPT.json declares ${r.length} changed files, limit ${No}`);let i=[],s=new Set;for(let[o,a]of r.entries()){if(typeof a!="string")throw new Error(`INSTALL_RECEIPT.json changed_files[${o}] is not a string`);if(Sc(a,"Homebrew changed file"),s.has(a))throw new Error(`INSTALL_RECEIPT.json repeats changed file ${a}`);s.add(a),i.push(a)}return{changedFiles:i,runtimeDependencies:e.runtime_dependencies}}function gc(n){let e;try{e=JSON.parse(new TextDecoder("utf-8",{fatal:!0}).decode(n))}catch(t){throw new Error("INSTALL_RECEIPT.json is not valid UTF-8 JSON: "+Ac(t))}if(typeof e!="object"||e===null||Array.isArray(e))throw new Error("INSTALL_RECEIPT.json must contain an object");return e}function Ko(n,e,t){let r=n;for(let[o,a]of Mo)r=Co(r,At.encode(o),At.encode(a));let i=At.encode(Bn);if(Fo(r,i)){let o=Ec(e.runtimeDependencies);if(o===void 0)throw new Error(`Homebrew changed file ${t} uses ${Bn} without exactly one OpenJDK runtime dependency`);r=Co(r,i,At.encode(o))}let s=yc.find(({bytes:o})=>Fo(r,o));if(s!==void 0)throw new Error(`Homebrew changed file ${t} retains ${s.placeholder}`);return r}function Ec(n){if(!Array.isArray(n))return;let e=[];for(let r of n){if(typeof r!="object"||r===null||Array.isArray(r))continue;let i=r,s=typeof i.full_name=="string"?i.full_name.split("/").at(-1):typeof i.name=="string"?i.name.split("/").at(-1):void 0,o=s===void 0?null:_c.exec(s);s!==void 0&&o?.[0]===s&&e.push(s)}let t=[...new Set(e)];return t.length===1?`${wt}/opt/${t[0]}/libexec`:void 0}function Sc(n,e){if(n.length===0||n.startsWith("/")||n.includes("\\")||n.includes("\0")||wc(n)||At.encode(n).byteLength>mc||n.split("/").some(t=>t===""||t==="."||t===".."))throw new Error(`${e} has an unsafe path segment: ${n}`)}function wc(n){for(let e=0;e57343)){if(t<=56319&&e+1=56320&&n.charCodeAt(e+1)<=57343){e+=1;continue}return!0}}return!1}function Fo(n,e){if(e.byteLength===0||e.byteLength>n.byteLength)return!1;e:for(let t=0;t<=n.byteLength-e.byteLength;t+=1){for(let r=0;rjr||n.includes("\0")||n.includes("\\"))throw new Error(`Lazy archive mount prefix must be an absolute POSIX path: ${JSON.stringify(n)}`);let e=n.replace(/\/+$/,"");if(e==="")return"/";if(e.slice(1).split("/").some(r=>r===""||r==="."||r===".."))throw new Error(`Lazy archive mount prefix is not canonical: ${JSON.stringify(n)}`);return e}function Ol(n,e,t,r){let i=fr(t),s=new Map,o=e.map(a=>{let c=a.fileName,l=`Lazy archive ${JSON.stringify(n)} member ${JSON.stringify(c)}`;if(c.length===0)throw new Error(`${l} has an empty path`);if(c.includes("\0"))throw new Error(`${l} contains a NUL byte`);if(c.includes("\\"))throw new Error(`${l} contains a backslash`);if(c.startsWith("/")||/^[A-Za-z]:\//.test(c))throw new Error(`${l} must be relative, not absolute`);if(a.isDirectory&&a.isSymlink)throw new Error(`${l} has conflicting directory and symlink types`);if(a.isDirectory!==c.endsWith("/"))throw new Error(`${l} has inconsistent directory metadata`);let d=a.isDirectory?c.slice(0,-1):c,u=d.split("/");if(d.length===0||u.some(p=>p===""||p==="."||p===".."))throw new Error(`${l} is not a canonical relative POSIX path`);if(s.has(d))throw new Error(`${l} collides with another member at ${JSON.stringify(d)}`);if(a.isSymlink&&!r?.has(c))throw new Error(`Lazy archive symlink target was not provided: ${c}`);return s.set(d,a),{entry:a,archivePath:d,vfsPath:i==="/"?`/${d}`:`${i}/${d}`}});for(let{archivePath:a}of o){let c=a.split("/");for(let l=1;lvt)throw new Error(`VFS image metadata exceeds ${vt} bytes`);let e;try{e=JSON.parse(new TextDecoder().decode(n))}catch(t){let r=t instanceof Error?t.message:String(t);throw new Error(`Invalid VFS image metadata JSON: ${r}`)}return hi(e)}function Rl(n){if(n===null)return new Uint8Array(0);let e=hi(n),t=new TextEncoder().encode(JSON.stringify(e));if(t.byteLength>vt)throw new Error(`VFS image metadata exceeds ${vt} bytes`);return t}function vl(n){return n.byteLength>=ar.length&&n[0]===ar[0]&&n[1]===ar[1]&&n[2]===ar[2]&&n[3]===ar[3]?Zl(n):n}function $r(n){let e=vl(n);if(e.byteLengthGr)throw new Error(`VFS image lazy metadata exceeds ${Gr} bytes`);if(n.byteLengthHr)throw new Error(`VFS image lazy archive metadata exceeds ${Hr} bytes`);if(n.byteLength=0?r:void 0}function bl(n){return n===408||n===429||n>=500&&n<=599}function zl(n,e=Date.now()){let t=n?.get("retry-after")?.trim();if(!t)return;let r;if(/^\d+$/.test(t))r=Number(t)*1e3;else{let i=Date.parse(t);if(!Number.isFinite(i))return;r=Math.max(0,i-e)}if(!(!Number.isSafeInteger(r)||r<0))return Math.min(r,xs)}function kl(n){if(!(typeof n!="object"||n===null||!("cause"in n)))return n.cause}function Rs(n){if(!(typeof n!="object"||n===null||!("name"in n)))return typeof n.name=="string"?n.name:void 0}function vs(n){if(!(typeof n!="object"||n===null||!("code"in n)))return typeof n.code=="string"?n.code:void 0}function Ts(n,e){let t=new Set,r=n;for(let i=0;r!==void 0&&i<8;i+=1){if(t.has(r))return!1;if(t.add(r),e(r))return!0;r=kl(r)}return!1}function Ls(n){return Ts(n,e=>Rs(e)==="AbortError"||vs(e)==="ABORT_ERR")}function Pl(n){return Ls(n)?!1:Ts(n,e=>{let t=Rs(e),r=vs(e);return e instanceof TypeError||t==="NetworkError"||t==="TimeoutError"||r!==void 0&&Al.has(r)})}function Nl(n,e){if(n instanceof qr){if(!bl(n.status))return null;if(n.retryAfterMs!==void 0)return n.retryAfterMs}else if(!Pl(n))return null;return Math.min(wl*2**e,xs)}function ee(n){if(n?.aborted)throw n.reason}function Fl(n,e){return ee(e),n===0?Promise.resolve():new Promise((t,r)=>{let i=setTimeout(()=>a(!1),n),s=()=>a(!0,e.reason),o=!1;function a(c,l){o||(o=!0,clearTimeout(i),e?.removeEventListener("abort",s),c?r(l):t())}e?.addEventListener("abort",s,{once:!0}),e?.aborted&&s()})}async function ni(n,e){try{await n.body?.cancel(e)}catch{}}function Cl(n,e){if(n.length===1)return n[0];let t=new Uint8Array(e),r=0;for(let i of n)t.set(i,r),r+=i.byteLength;return t}function hr(n){if(n===void 0)return;if(typeof n!="object"||n===null||Array.isArray(n))throw new Error("Lazy archive integrity must be an object");let e=n;if(Object.keys(e).length!==2||!("sha256"in e)||!("bytes"in e))throw new Error("Lazy archive integrity has unexpected fields");if(typeof e.sha256!="string"||!ai.test(e.sha256))throw new Error("Lazy archive integrity has an invalid SHA-256 digest");if(!Number.isSafeInteger(e.bytes)||Number(e.bytes)<=0||Number(e.bytes)>ds)throw new Error(`Lazy archive integrity byte count must be between 1 and ${ds}`);return{sha256:e.sha256,bytes:Number(e.bytes)}}function qe(n,e,t){if(typeof n!="object"||n===null||Array.isArray(n))throw new Error(`${t} must be an object`);let r=n;if(Object.keys(r).length!==e.length||e.some(s=>!Object.prototype.hasOwnProperty.call(r,s)))throw new Error(`${t} has unexpected or missing fields`);return r}function li(n,e,t,r){if(typeof n!="object"||n===null||Array.isArray(n))throw new Error(`${r} must be an object`);let i=n,s=new Set(e);if(Object.keys(i).some(o=>!s.has(o))||t.some(o=>!Object.prototype.hasOwnProperty.call(i,o)))throw new Error(`${r} has unexpected or missing fields`);return i}function Ne(n,e,t,r){if(!Array.isArray(n)||n.lengthr)throw new Error(`${e} must contain ${t} to ${r} items`);return n}function _e(n,e,t){if(typeof n!="string"||n.length===0||n.includes("\0")||new TextEncoder().encode(n).byteLength>t)throw new Error(`${e} is invalid or exceeds ${t} bytes`);return n}function se(n,e,t,r){if(!Number.isSafeInteger(n)||Number(n)r)throw new Error(`${e} must be an integer between ${t} and ${r}`);return Number(n)}function Zr(n,e=1){let t=n,r=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.source!==void 0,i=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.modePolicy!==void 0,s=qe(n,["decoder","mediaType","sha256","bytes","expandedBytes","sourceEntryCount","transports",...i?["modePolicy"]:[],...r?["source"]:[]],"Lazy tree content"),o=s.decoder==="zip-v1"?"application/zip":s.decoder==="homebrew-bottle-tar-gzip-v1"?"application/vnd.oci.image.layer.v1.tar+gzip":null;if(o===null||s.mediaType!==o)throw new Error("Lazy tree decoder and media type are inconsistent");let a=hr({sha256:s.sha256,bytes:s.bytes});if(!a)throw new Error("Lazy tree integrity is required");let c=Ne(s.transports,"Lazy tree transports",e,fe.maxTransportsPerTree).map((m,f)=>_e(m,`Lazy tree transport ${f}`,fi));if(new Set(c).size!==c.length)throw new Error("Lazy tree transports contain duplicates");let l=se(s.expandedBytes,"Lazy tree expanded byte count",0,_l),d=se(s.sourceEntryCount,"Lazy tree source entry count",1,Tt),u=r?Kl(s.source,s.decoder):void 0,p=i?s.modePolicy:void 0;if(p!==void 0&&(p!=="portable-posix-v1"||s.decoder!=="zip-v1"||r))throw new Error("Lazy tree mode policy is invalid for its decoder");if(u!==void 0&&u.entries.length!==d)throw new Error("Lazy tree source inventory count differs from its content");return{decoder:s.decoder,mediaType:o,sha256:a.sha256,bytes:a.bytes,expandedBytes:l,sourceEntryCount:d,transports:c,...p===void 0?{}:{modePolicy:p},...u===void 0?{}:{source:u}}}function bs(n){let e={groups:n.length,archiveBytes:0,expandedBytes:0,payloadBytes:0,entries:0};for(let t of n)t.content===void 0||t.inventory===void 0||(e.archiveBytes+=t.content.bytes,e.expandedBytes+=t.content.expandedBytes,e.payloadBytes+=t.inventory.filter(r=>r.type==="file").reduce((r,i)=>r+i.size,0),e.entries+=t.inventory.length+(t.content.source?.entries.length??0));return e}function ui(n){ko(n,"Serialized lazy tree collection")}function Ml(n){ui(bs(n))}function Dl(n){let e=new Map;for(let t of n){let r=t.activation?.atomicGroup;if(r===void 0)continue;if(!Rt(r))throw new Error(`Serialized lazy atomic activation group ${r.id} is unsealed`);let i=e.get(r.id);if(i===void 0)i={expectedCount:r.expectedCount,cohortSha256:r.cohortSha256,members:new Set,descriptors:new Set},e.set(r.id,i);else if(i.expectedCount!==r.expectedCount||i.cohortSha256!==r.cohortSha256)throw new Error(`Serialized lazy atomic activation group ${r.id} has inconsistent seals`);if(i.members.has(r.member)||i.descriptors.has(r.descriptorSha256))throw new Error(`Serialized lazy atomic activation group ${r.id} duplicates a member`);i.members.add(r.member),i.descriptors.add(r.descriptorSha256)}for(let[t,r]of e)if(r.members.size!==r.expectedCount)throw new Error(`Serialized lazy atomic activation group ${t} has ${r.members.size} of ${r.expectedCount} members`)}function ys(n){for(let[e,t]of n.entries())if(t.kind===dr||t.kind===ci||t.kind===ot)Ns(t,t.kind);else if(t.kind===ur)di(t,!1);else throw new Error(`Serialized lazy archive group ${e} has an unsupported kind`);Ml(n),Dl(n)}function Kl(n,e){if(e!=="homebrew-bottle-tar-gzip-v1")throw new Error("Lazy tree source inventory is valid only for original bottles");let t=qe(n,["schema","kind","entries"],"Lazy tree source inventory");if(t.schema!==1||t.kind!=="homebrew-bottle-tar-gzip-v1")throw new Error("Lazy tree source inventory has an unsupported identity");let r=new Map,i=Ne(t.entries,"Lazy tree source entries",1,Tt).map((o,a)=>{let c=o,l=typeof c=="object"&&c!==null&&!Array.isArray(c)?c.type:void 0,d=l==="directory"||l==="file"?["sourcePath","type","mode","size"]:l==="symlink"||l==="hardlink"?["sourcePath","type","mode","size","target"]:null;if(d===null)throw new Error(`Lazy tree source entry ${a} has invalid type`);let u=qe(o,d,`Lazy tree source entry ${a}`),p=ye(u.sourcePath,!1,`Lazy tree source entry ${a} path`);if(r.has(p))throw new Error(`Lazy tree source inventory duplicates ${p}`);let m=se(u.mode,`Lazy tree source entry ${p} mode`,0,4095),f=se(u.size,`Lazy tree source entry ${p} size`,0,Vr),h;if((l==="directory"||l==="symlink"||l==="hardlink")&&f!==0)throw new Error(`Lazy tree source ${p} has payload for ${String(l)}`);l==="symlink"?h=_e(u.target,`Lazy tree source symlink ${p} target`,Is):l==="hardlink"&&(h=ye(u.target,!1,`Lazy tree source hardlink ${p} target`));let g={sourcePath:p,type:l,mode:m,size:f,...h===void 0?{}:{target:h}};return r.set(p,g),g}),s=i.map(o=>o.sourcePath);if(s.some((o,a)=>a>0&&s[a-1]>=o))throw new Error("Lazy tree source inventory is not in canonical path order");return{schema:1,kind:"homebrew-bottle-tar-gzip-v1",entries:i}}function zs(n){let e=new Map(n.map(r=>[r.sourcePath,r])),t=new Map;for(let r of n){if(r.type!=="hardlink"||t.has(r.sourcePath))continue;let i=[],s=new Set,o=r,a;for(;o.type==="hardlink"&&(a=t.get(o.sourcePath),a===void 0);){if(s.has(o.sourcePath))throw new Error(`Lazy tree source hardlink cycle includes ${o.sourcePath}`);s.add(o.sourcePath),i.push(o);let c=e.get(o.target);if(c===void 0)throw new Error(`Lazy tree source hardlink ${o.sourcePath} target is absent`);if(c.type!=="file"&&c.type!=="hardlink")throw new Error(`Lazy tree source hardlink ${o.sourcePath} target is not regular`);o=c}a===void 0&&(a=o);for(let c of i)t.set(c.sourcePath,a)}return t}function ye(n,e,t,r=!1){if(typeof n!="string"||n.length===0||new TextEncoder().encode(n).byteLength>jr||n.includes("\0")||n.includes("\\")||n.startsWith("/")!==e)throw new Error(`${t} is not a canonical ${e?"absolute":"relative"} path`);if(r&&e&&n==="/")return n;if(n.slice(e?1:0).split("/").some(s=>s===""||s==="."||s===".."))throw new Error(`${t} has an unsafe path segment`);return n}function ks(n){let e=typeof n=="object"&&n!==null&&!Array.isArray(n)?n:null,t=e!==null&&(Object.hasOwn(e,"descriptorSha256")||Object.hasOwn(e,"expectedCount")||Object.hasOwn(e,"cohortSha256")),r=qe(n,t?["id","member","descriptorSha256","expectedCount","cohortSha256"]:["id","member"],"Lazy tree atomic activation membership"),i=_e(r.id,"Lazy tree atomic activation group",fs),s=_e(r.member,"Lazy tree atomic activation member",fs);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(i)||!/^[a-z0-9][a-z0-9:+._/-]*$/.test(s)||s.includes("//")||s.endsWith("/"))throw new Error("Lazy tree atomic activation membership is invalid");if(!t)return{id:i,member:s};let o=_e(r.descriptorSha256,"Lazy tree atomic member descriptor digest",64),a=_e(r.cohortSha256,"Lazy tree atomic cohort digest",64);if(!ai.test(o)||!ai.test(a))throw new Error("Lazy tree atomic activation digest is invalid");return{id:i,member:s,descriptorSha256:o,expectedCount:se(r.expectedCount,"Lazy tree atomic activation expected member count",1,Os),cohortSha256:a}}function Rt(n){return n.descriptorSha256!==void 0&&n.expectedCount!==void 0&&n.cohortSha256!==void 0}function Bl(n){let e=qe(n,["uid","gid"],"Lazy tree registration owner");return{uid:se(e.uid,"Lazy tree registration owner uid",0,hs),gid:se(e.gid,"Lazy tree registration owner gid",0,hs)}}function Ps(n,e,t,r,i=1){let s=Zr(n,i),o=fr(t),a=["mode","capabilities","roots",...typeof r=="object"&&r!==null&&!Array.isArray(r)&&Object.hasOwn(r,"atomicGroup")?["atomicGroup"]:[]],c=qe(r,a,"Lazy tree activation");if(c.mode!=="boot-prefetch"&&c.mode!=="first-use")throw new Error("Lazy tree activation mode is invalid");let l=Ne(c.capabilities,"Lazy tree activation capabilities",1,El).map((S,O)=>{let x=_e(S,`Lazy tree activation capability ${O}`,fe.maxActivationCapabilityBytes);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(x))throw new Error(`Lazy tree activation capability ${O} is invalid`);return x}),d=Ne(c.roots,"Lazy tree activation roots",1,Sl).map((S,O)=>ye(S,!0,`Lazy tree activation root ${O}`,!0));if(new Set(l).size!==l.length||new Set(d).size!==d.length)throw new Error("Lazy tree activation contains duplicates");let u=c.atomicGroup===void 0?void 0:ks(c.atomicGroup);if(u!==void 0&&c.mode!=="first-use")throw new Error("Lazy tree atomic activation group requires a valid first-use identity");let p={mode:c.mode,capabilities:l,roots:d,...u===void 0?{}:{atomicGroup:u}},m=Ne(e,"Lazy tree inventory",1,Tt),f=[],h=new Map,g=new Map,_=s.source===void 0?void 0:new Map(s.source.entries.map(S=>[S.sourcePath,S])),y=s.source===void 0?void 0:zs(s.source.entries),E=0;for(let[S,O]of m.entries()){if(typeof O!="object"||O===null||Array.isArray(O))throw new Error(`Lazy tree entry ${S} must be an object`);let x=O.type,R=x==="directory"?["vfsPath","sourcePath","type","mode","size"]:x==="file"?["vfsPath","sourcePath","type","mode","size","inodeGroup"]:x==="symlink"?["vfsPath","sourcePath","type","mode","size","target"]:x==="hardlink"?["vfsPath","sourcePath","type","mode","size","target","inodeGroup"]:null;if(!R)throw new Error(`Lazy tree entry ${S} has an invalid type`);let T=qe(O,[...R,..._===void 0?[]:["materialization"]],`Lazy tree entry ${S}`),L=ye(T.vfsPath,!0,`Lazy tree entry ${S} VFS path`),D=ye(T.sourcePath,!1,`Lazy tree entry ${S} source path`),q=_===void 0?void 0:T.materialization;if(_!==void 0&&q!=="archive"&&q!=="archive-homebrew-relocate"&&q!=="archive-copy"&&q!=="archive-copy-mode"&&q!=="descriptor")throw new Error(`Lazy tree entry ${L} has invalid materialization provenance`);if(o!=="/"&&L!==o&&!L.startsWith(`${o}/`))throw new Error(`Lazy tree entry ${L} escapes its mount prefix`);if(h.has(L))throw new Error(`Lazy tree duplicates VFS path ${L}`);let F=se(T.mode,`Lazy tree entry ${L} mode`,0,4095),z=se(T.size,`Lazy tree entry ${L} size`,0,Vr),U,ue;if(x==="directory"){if(z!==0)throw new Error(`Lazy tree directory ${L} has nonzero size`)}else if(x==="symlink"){if(U=_e(T.target,`Lazy tree symlink ${L} target`,Is),new TextEncoder().encode(U).byteLength!==z)throw new Error(`Lazy tree symlink ${L} size differs from its target`)}else ue=_e(T.inodeGroup,`Lazy tree entry ${L} inode group`,jr),x==="hardlink"&&(U=ye(T.target,!0,`Lazy tree hardlink ${L} target`));if(x!=="hardlink"&&(E+=z,E>Vr))throw new Error("Lazy tree inventory exceeds the expansion limit");let C={vfsPath:L,sourcePath:D,...q===void 0?{}:{materialization:q},type:x,mode:F,size:z,...U===void 0?{}:{target:U},...ue===void 0?{}:{inodeGroup:ue}};if(_===void 0){let H=g.get(D);if(H){if(s.decoder!=="zip-v1"||C.type!=="hardlink"||H.inodeGroup!==C.inodeGroup)throw new Error(`Lazy tree duplicates source path ${D}`)}else{if(s.decoder==="zip-v1"&&C.type==="hardlink")throw new Error(`Lazy ZIP hardlink ${L} does not reuse a canonical source path`);g.set(D,C)}}else if(C.materialization==="descriptor"){if(C.type!=="directory"&&C.type!=="symlink")throw new Error(`Lazy tree descriptor entry ${L} is not structural`);if(_.has(D))throw new Error(`Lazy tree descriptor entry ${L} impersonates a source member`)}else{let H=_.get(D);if(H===void 0)throw new Error(`Lazy tree entry ${L} names absent source ${D}`);if(C.materialization==="archive-copy"||C.materialization==="archive-copy-mode"){if(C.type!=="file"||H.type!=="file"||C.materialization==="archive-copy"&&C.mode!==H.mode)throw new Error(`Lazy tree archive copy ${L} differs from its source`)}else if(C.materialization==="archive-homebrew-relocate"){if(C.type!=="file"&&C.type!=="hardlink"||H.type!==C.type||C.type==="file"&&H.mode!==C.mode)throw new Error(`Lazy tree receipt-relocated entry ${L} differs from its source`)}else if(H.type!==C.type||C.type==="symlink"&&H.target!==C.target||C.type!=="hardlink"&&H.mode!==C.mode)throw new Error(`Lazy tree archive entry ${L} differs from its source`)}f.push(C),h.set(L,C)}for(let S of f){let O=S.vfsPath.split("/").filter(Boolean);for(let x=1;x({path:S.vfsPath,type:S.type,mode:S.mode,size:S.size,target:S.target,inodeGroup:S.inodeGroup})),"Lazy tree");if(_!==void 0){let S=new Set;for(let O of f){if(O.materialization!=="archive-homebrew-relocate")continue;let x=_.get(O.sourcePath),R=x.type==="file"?x:y.get(x.sourcePath);if(R?.type!=="file")throw new Error(`Lazy tree receipt-relocated entry ${O.vfsPath} is not regular`);S.add(R.sourcePath)}for(let O of f){if(O.materialization==="descriptor"||O.type!=="file"&&O.type!=="hardlink")continue;let x=_.get(O.sourcePath),R=x.type==="file"?x:y.get(x.sourcePath);if(R?.type!=="file"||!S.has(R.sourcePath)&&O.size!==R.size)throw new Error(`Lazy tree archive entry ${O.vfsPath} differs from its source`)}for(let O of f){if(O.type!=="hardlink"||O.materialization!=="archive"&&O.materialization!=="archive-homebrew-relocate")continue;let x=_.get(O.sourcePath),R=h.get(O.target),T=y.get(x.sourcePath);if(x.target!==R?.sourcePath||T?.type!=="file"||T.mode!==O.mode||R?.mode!==O.mode)throw new Error(`Lazy tree hardlink ${O.vfsPath} differs from its source`)}}if(s.sourceEntryCount!==(_===void 0?g.size:_.size))throw new Error("Lazy tree source entry count differs from its inventory");if(s.source===void 0&&s.expandedBytesO.vfsPath===S||O.vfsPath.startsWith(`${S}/`)))throw new Error(`Lazy tree activation root ${S} is not owned by its inventory`);let w=new Map;for(let S of f)S.type==="file"&&w.set(S.inodeGroup,S);if(w.size!==A.canonicalByGroup.size)throw new Error("Lazy tree regular inode inventory is inconsistent");return{content:s,entries:f,mountPrefix:o,activation:p,canonicalByGroup:w}}function Xr(n){return JSON.stringify([n.sourcePath,n.type,n.inodeGroup,n.target])}function di(n,e){let t=li(n,["kind","content","url","mountPrefix","integrity","materialized","entries"],["url","mountPrefix","materialized","entries"],"Serialized legacy lazy archive");if(t.kind===void 0){if(!e)throw new Error("Serialized lazy archive is missing its kind discriminator")}else if(t.kind!==ur)throw new Error("Serialized legacy lazy archive has an unsupported kind");let r=_e(t.url,"Serialized legacy lazy archive URL",fi),i=fr(t.mountPrefix),s=hr(t.integrity);if(t.content!==void 0){if(!e||t.kind!==void 0)throw new Error("Typed legacy lazy archives cannot carry generic content");let c=Zr(t.content);if(c.decoder!=="zip-v1"||c.transports.length!==1||c.transports[0]!==r||!s||c.sha256!==s.sha256||c.bytes!==s.bytes)throw new Error("Untagged legacy ZIP content identity is inconsistent")}if(t.materialized!==!1)throw new Error("Serialized legacy lazy archive must describe pending content");let o=new Set,a=Ne(t.entries,"Serialized legacy lazy archive entries",1,Tt).map((c,l)=>{let d=li(c,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","size","isSymlink","deleted"],`Serialized legacy lazy archive entry ${l}`),u=ye(d.vfsPath,!0,`Serialized legacy lazy archive entry ${l} VFS path`);if(o.has(u))throw new Error(`Serialized legacy lazy archive duplicates path ${u}`);o.add(u);let p=se(d.ino,`Serialized legacy lazy archive entry ${u} inode`,1,Number.MAX_SAFE_INTEGER),m=d.generation===void 0?void 0:se(d.generation,`Serialized legacy lazy archive entry ${u} generation`,0,Number.MAX_SAFE_INTEGER),f=d.dataSequence===void 0?void 0:se(d.dataSequence,`Serialized legacy lazy archive entry ${u} data sequence`,0,Number.MAX_SAFE_INTEGER),h=se(d.size,`Serialized legacy lazy archive entry ${u} size`,0,Vr);if(d.isSymlink!==!1||d.deleted!==!1||d.materialized!==void 0&&d.materialized!==!1)throw new Error(`Serialized legacy lazy archive entry ${u} is not pending`);if(d.type!==void 0&&d.type!=="file")throw new Error(`Serialized legacy lazy archive entry ${u} has an invalid type`);let g=d.archivePath===void 0?void 0:ye(d.archivePath,!1,`Serialized legacy lazy archive entry ${u} archive path`),_=d.sourcePath===void 0?void 0:ye(d.sourcePath,!1,`Serialized legacy lazy archive entry ${u} source path`),y=d.inodeGroup===void 0?void 0:_e(d.inodeGroup,`Serialized legacy lazy archive entry ${u} inode group`,jr);if(d.target!==void 0)throw new Error(`Serialized legacy lazy archive entry ${u} has a link target`);return{vfsPath:u,ino:p,...m===void 0?{}:{generation:m},...f===void 0?{}:{dataSequence:f},size:h,isSymlink:!1,deleted:!1,materialized:!1,...g===void 0?{}:{archivePath:g},..._===void 0?{}:{sourcePath:_},type:"file",...y===void 0?{}:{inodeGroup:y}}});return{kind:ur,url:r,mountPrefix:i,...s===void 0?{}:{integrity:s},materialized:!1,entries:a}}function Ns(n,e){let t=qe(n,["kind","content","inventory","activation","url","mountPrefix","integrity","materialized","entries"],"Serialized lazy tree");if(t.kind!==e)throw new Error("Serialized lazy tree has an unsupported kind");let r=Ps(t.content,t.inventory,t.mountPrefix,t.activation);if(e!==ot&&e===dr!=(r.content.source===void 0))throw new Error(e===dr?"Serialized deferred-tree-v1 cannot contain original-bottle source metadata":"Serialized deferred-tree-v2 requires original-bottle source metadata");let i=r.activation.atomicGroup;if(e===ot?i===void 0||!Rt(i):i!==void 0)throw new Error(e===ot?"Serialized deferred-tree-v3 requires a sealed atomic activation":"Atomic activation requires serialized deferred-tree-v3");let s=_e(t.url,"Serialized lazy tree URL",fi);if(s!==r.content.transports[0])throw new Error("Serialized lazy tree URL differs from its primary transport");let o=hr(t.integrity);if(!o||o.sha256!==r.content.sha256||o.bytes!==r.content.bytes)throw new Error("Serialized lazy tree integrity differs from its content");if(t.materialized!==!1)throw new Error("Serialized lazy tree must describe pending content");let a=new Map(r.entries.map(p=>[p.vfsPath,p])),c=new Map(r.entries.map(p=>[Xr(p),p])),l=Ne(t.entries,"Serialized lazy tree entries",0,Tt),d=new Set,u=l.map((p,m)=>{let f=li(p,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup"],`Serialized lazy tree entry ${m}`),h=ye(f.vfsPath,!0,`Serialized lazy tree entry ${m} VFS path`);if(d.has(h))throw new Error(`Serialized lazy tree duplicates pending path ${h}`);d.add(h);let g=ye(f.sourcePath,!1,`Serialized lazy tree entry ${m} source path`),_=ye(f.archivePath,!1,`Serialized lazy tree entry ${m} archive path`),y=a.get(h),E=c.get(Xr({sourcePath:g,type:typeof f.type=="string"?f.type:void 0,inodeGroup:typeof f.inodeGroup=="string"?f.inodeGroup:void 0,target:typeof f.target=="string"?f.target:void 0}))??y;if(!E||E.type!=="file"&&E.type!=="hardlink"||y?.inodeGroup!==void 0&&y.inodeGroup!==E.inodeGroup)throw new Error(`Serialized lazy tree entry ${h} is absent from its inventory`);let A=r.canonicalByGroup.get(E.inodeGroup);if(f.type!==E.type||f.inodeGroup!==E.inodeGroup||f.size!==E.size||_!==A?.sourcePath||f.target!==E.target||f.isSymlink!==!1||f.deleted!==!1||f.materialized!==!1)throw new Error(`Serialized lazy tree entry ${h} disagrees with its inventory`);let w=se(f.ino,`Serialized lazy tree entry ${h} inode`,1,Number.MAX_SAFE_INTEGER),S=se(f.generation,`Serialized lazy tree entry ${h} generation`,0,Number.MAX_SAFE_INTEGER),O=se(f.dataSequence,`Serialized lazy tree entry ${h} data sequence`,0,Number.MAX_SAFE_INTEGER);return{vfsPath:h,ino:w,generation:S,dataSequence:O,size:E.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:_,sourcePath:g,type:E.type,inodeGroup:E.inodeGroup,...E.target===void 0?{}:{target:E.target}}});for(let p of r.entries)if(r.activation.atomicGroup!==void 0&&(p.type==="file"||p.type==="hardlink")&&!d.has(p.vfsPath))throw new Error(`Serialized lazy tree omits pending path ${p.vfsPath}`);return{kind:e,content:r.content,inventory:r.entries,activation:r.activation,url:s,mountPrefix:r.mountPrefix,integrity:o,materialized:!1,entries:u}}async function lr(n,e){let t=globalThis.crypto?.subtle;if(!t)throw new Error(`${e} SHA-256 verification is unavailable`);let r=new Uint8Array(n.byteLength);r.set(n);let i=new Uint8Array(await t.digest("SHA-256",r));return Array.from(i,s=>s.toString(16).padStart(2,"0")).join("")}async function ii(n,e,t){if(t===void 0)return;if(n.byteLength!==t.bytes)throw new Error(`Lazy ${e} byte count ${n.byteLength} does not match expected ${t.bytes}`);let r=await lr(n,`Lazy ${e}`);if(r!==t.sha256)throw new Error(`Lazy ${e} SHA-256 ${r} does not match expected ${t.sha256}`)}function $l(n,e,t,r){let i=r.atomicGroup;if(i===void 0)throw new Error("Lazy atomic member is missing its typed tree descriptor");let s={schema:1,content:{decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...n.source===void 0?{}:{source:n.source}},mountPrefix:t,inventory:[...e].sort((o,a)=>o.vfsPatha.vfsPath?1:0),activation:{mode:r.mode,capabilities:r.capabilities,roots:r.roots,atomicGroup:{id:i.id,member:i.member}}};return new TextEncoder().encode(JSON.stringify(s))}function gs(n,e){let t=e.map(({member:r,descriptorSha256:i})=>({member:r,descriptorSha256:i}));return new TextEncoder().encode(JSON.stringify({schema:1,id:n,members:t.sort((r,i)=>r.memberi.member?1:0)}))}function Ul(n,e){if(n.byteLength!==e.byteLength)return!1;for(let t=0;tObject.freeze({...i}))};return r!==void 0&&(Object.freeze(r.entries),Object.freeze(r)),Object.freeze({decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,transports:t,...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...r===void 0?{}:{source:r}})}function Es(n){return{decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,transports:[...n.transports],...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...n.source===void 0?{}:{source:{schema:1,kind:"homebrew-bottle-tar-gzip-v1",entries:n.source.entries.map(e=>({...e}))}}}}function Wl(n){let e=n.map(t=>Object.freeze({...t}));return Object.freeze(e),e}function Gl(n,e,t){let r=[...n.capabilities],i=[...n.roots];return Object.freeze(r),Object.freeze(i),Object.freeze({mode:n.mode,capabilities:r,roots:i,atomicGroup:Object.freeze({id:e,member:t})})}function Hl(n){return{mode:n.activation.mode,capabilities:[...n.activation.capabilities],roots:[...n.activation.roots],atomicGroup:{id:n.id,member:n.member,descriptorSha256:n.descriptorSha256,expectedCount:n.expectedCount,cohortSha256:n.cohortSha256}}}function Vl(n,e){return n.ino===e.ino&&n.generation===e.generation&&n.dataSequence===e.dataSequence&&n.size===e.size&&n.isSymlink===e.isSymlink&&n.deleted===e.deleted&&n.materialized===e.materialized&&n.archivePath===e.archivePath&&n.sourcePath===e.sourcePath&&n.type===e.type&&n.inodeGroup===e.inodeGroup&&n.target===e.target}function Ur(n,e,t){let r=n.content,i=n.inventory,s=n.activation,o=n.integrity,a=n.entries,c=n.url,l=n.mountPrefix,d=n.materialized,u=s?.atomicGroup;if(r===void 0||i===void 0||s===void 0||u===void 0||s.mode!=="first-use"||u.id!==e||u.member!==t||d)throw new Error(`Lazy atomic activation member ${t} changed before snapshot`);if(o?.sha256!==r.sha256||o?.bytes!==r.bytes||c!==(r.transports[0]??""))throw new Error(`Lazy atomic activation member ${t} has inconsistent integrity`);let p=Fs(r),m=Wl(i),f=Gl(s,e,t),h=new Map;for(let A of m)A.type==="file"&&h.set(A.inodeGroup,A.sourcePath);let g=m.filter(A=>A.type!=="directory");if(a.size!==g.length)throw new Error(`Lazy atomic activation member ${t} has inconsistent runtime entries`);let _=g.map(A=>{let w=a.get(A.vfsPath),S=A.type==="symlink",O=S?A.sourcePath:h.get(A.inodeGroup),x=w!==void 0&&(w.sourcePath===A.sourcePath&&w.type===A.type&&w.target===A.target||A.type==="hardlink"&&w.sourcePath===O&&w.type==="file"&&w.target===void 0),R=w===void 0?["missing"]:[O===void 0?"archivePath source":void 0,w.generation===void 0?"generation":void 0,w.dataSequence===void 0?"dataSequence":void 0,w.size!==A.size?"size":void 0,w.isSymlink!==S?"symlink kind":void 0,w.deleted?"deletion state":void 0,w.materialized!==S?"materialization state":void 0,w.archivePath!==O?"archivePath":void 0,x?void 0:"descriptor mapping",w.inodeGroup!==A.inodeGroup?"inode group":void 0].filter(L=>L!==void 0);if(R.length>0)throw new Error(`Lazy atomic activation member ${t} has inconsistent mapping at ${A.vfsPath}: ${R.join(", ")}`);let T=w;return Object.freeze({vfsPath:A.vfsPath,ino:T.ino,generation:T.generation,dataSequence:T.dataSequence,size:T.size,isSymlink:T.isSymlink,deleted:!1,materialized:T.materialized,archivePath:O,sourcePath:A.sourcePath,type:A.type,...A.inodeGroup===void 0?{}:{inodeGroup:A.inodeGroup},...A.target===void 0?{}:{target:A.target}})});Object.freeze(_);let y=Object.freeze({sha256:p.sha256,bytes:p.bytes}),E=$l(p,m,l,f);return Object.freeze({id:e,member:t,descriptorBytes:E,content:p,inventory:m,activation:f,url:p.transports[0]??"",mountPrefix:l,integrity:y,entries:_})}function Ss(n,e,t,r){return Object.freeze({...n,descriptorSha256:e,expectedCount:t,cohortSha256:r})}function ws(n,e){return n.id!==e.id||n.member!==e.member||n.url!==e.url||n.mountPrefix!==e.mountPrefix||n.integrity.sha256!==e.integrity.sha256||n.integrity.bytes!==e.integrity.bytes||n.content.transports.length!==e.content.transports.length||n.content.transports.some((t,r)=>t!==e.content.transports[r])||!Ul(n.descriptorBytes,e.descriptorBytes)||n.entries.length!==e.entries.length?!1:n.entries.every((t,r)=>{let i=e.entries[r];return i!==void 0&&t.vfsPath===i.vfsPath&&Vl(t,i)})}function ql(n,e){let t=Fs(n.content,n.content.transports.map(e));return Object.freeze({...n,content:t,url:t.transports[0]??""})}function As(n){return{paths:Array.from(n.paths),expectedIno:n.ino,expectedGeneration:n.generation,expectedDataSequence:n.dataSequence,data:n.content}}var Yr=class n{fs;imageMetadata;lazyFiles=new Map;lazyArchiveGroups=[];deferredTreeMaterializationHandles=new WeakMap;lazyArchiveInodes=new Map;lazyAtomicGroups=new Map;lazyAtomicGroupByTree=new WeakMap;sealedLazyAtomicStates=new WeakMap;lazyDownloadListeners=new Set;lazyPreparations=new Map;lazyTransport={fetcher:(e,t)=>t===void 0?globalThis.fetch(e):globalThis.fetch(e,t)};constructor(e,t=null){this.fs=e,this.imageMetadata=t}static inodeKey(e,t){return`${e}:${t}`}static canAdoptLegacyLazyStub(e){return(e.mode&Pe)===cr&&e.size===0&&e.dataSequence<=1}reconcileLazyIdentityState(e){for(let[t,r]of this.lazyFiles){let i=e.get(t);if(!i||i.dataSequence!==r.dataSequence||i.paths.length===0){this.lazyFiles.delete(t);continue}r.paths=new Set(i.paths),r.paths.has(r.path)||(r.path=i.paths[0])}this.lazyArchiveInodes.clear();for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(!r?.committed)for(let c of i.snapshot.entries){if(c.isSymlink||c.materialized||c.generation===void 0)continue;let l=n.inodeKey(c.ino,c.generation),d=e.get(l);d!==void 0&&d.dataSequence===c.dataSequence&&d.paths.length>0&&this.lazyArchiveInodes.set(l,t)}continue}let s=t.content!==void 0&&t.inventory!==void 0&&!t.materialized,o=new Map;for(let c of t.entries.values()){if(c.deleted||c.materialized||c.generation===void 0)continue;let l=n.inodeKey(c.ino,c.generation);o.has(l)||o.set(l,c)}let a=new Map(Array.from(t.entries.entries()).filter(([,c])=>c.deleted||c.isSymlink&&!c.deleted));for(let[c,l]of o){let d=e.get(c);if(!(!d||d.dataSequence!==(l.dataSequence??0))){for(let u of d.paths)a.set(u,{...l,ino:d.ino,generation:d.generation,dataSequence:d.dataSequence,deleted:!1,materialized:!1});d.paths.length>0&&this.lazyArchiveInodes.set(c,t)}}t.entries=a,t.materialized=!Array.from(a.values()).some(c=>!c.isSymlink&&!c.materialized)&&!s&&(r===void 0||r.committed)}}validatePendingLazyTreeNamespaceState(e){let t=new Map;for(let r of e.values())for(let i of r.paths){if(t.has(i))throw new Error(`SharedFS namespace identity is ambiguous at ${i}`);t.set(i,r)}for(let r of this.lazyArchiveGroups){let i=this.lazyAtomicGroupByTree.get(r),o=this.sealedLazyAtomicStates.get(r)?.snapshot;if(i?.committed||o===void 0&&r.materialized||o===void 0&&(r.content===void 0||r.inventory===void 0))continue;let a=o?.inventory??r.inventory,c=o===void 0?r.entries:new Map(o.entries.map(m=>[m.vfsPath,m])),l=new Map,d=new Map,u=new Set;for(let m of c.values())m.deleted&&m.inodeGroup!==void 0&&u.add(m.inodeGroup);for(let m of a){if(m.type!=="file"&&m.type!=="hardlink")continue;l.set(m.inodeGroup,(l.get(m.inodeGroup)??0)+1);let f=d.get(m.inodeGroup)??[];f.push(m.vfsPath),d.set(m.inodeGroup,f)}let p=new Set([...u].filter(m=>d.get(m)?.every(f=>!t.has(f))));for(let m of a){let f=t.get(m.vfsPath);if(f===void 0){if(m.inodeGroup!==void 0&&p.has(m.inodeGroup))continue;throw new Error(`Lazy tree namespace entry ${m.vfsPath} is missing from the captured filesystem state`)}let h=m.type==="directory"?it:m.type==="symlink"?Br:cr;if((f.mode&Pe)!==h||(f.mode&4095)!==m.mode)throw new Error(`Lazy tree namespace entry ${m.vfsPath} disagrees with its captured type or mode`);if(m.type==="directory")continue;let g=c.get(m.vfsPath);if(g===void 0||g.ino!==f.ino||g.generation!==f.generation||g.dataSequence!==f.dataSequence)throw new Error(`Lazy tree namespace entry ${m.vfsPath} changed identity before serialization`);if(m.type==="symlink"){let _=new TextEncoder().encode(m.target).byteLength;if(f.linkCount!==1||f.size!==m.size||f.size!==_||f.symlinkTarget!==m.target)throw new Error(`Lazy tree symlink ${m.vfsPath} disagrees with its captured inventory`);continue}if(f.size!==0||f.linkCount!==l.get(m.inodeGroup))throw new Error(`Lazy tree stub ${m.vfsPath} has changed data or undeclared aliases`)}}}lazyFileForStat(e){let t=n.inodeKey(e.ino,e.generation),r=this.lazyFiles.get(t);if(r&&r.dataSequence!==e.dataSequence){this.lazyFiles.delete(t);return}return r}lazyArchiveEntriesForRead(e){let t=this.lazyAtomicGroupByTree.get(e),r=this.sealedLazyAtomicStates.get(e);return r!==void 0&&!t?.committed?r.snapshot.entries:Array.from(e.entries,([i,s])=>({vfsPath:i,...s}))}lazyArchiveForStat(e){let t=n.inodeKey(e.ino,e.generation),r=this.lazyArchiveInodes.get(t);if(!r)return;let i=this.lazyArchiveEntriesForRead(r).filter(o=>o.ino===e.ino&&o.generation===e.generation&&!o.deleted&&!o.materialized);if(i.some(o=>o.dataSequence===e.dataSequence))return r;if(this.lazyArchiveInodes.delete(t),this.sealedLazyAtomicStates.get(r)===void 0)for(let o of i)o.materialized=!0}lazyBackingForStat(e){let t=n.inodeKey(e.ino,e.generation),r=this.lazyFiles.get(t);if(r)return{token:r,path:r.path};let i=this.lazyArchiveInodes.get(t);if(!i)return null;let s=this.lazyArchiveEntriesForRead(i).find(a=>a.ino===e.ino&&a.generation===e.generation&&!a.deleted&&!a.materialized)?.vfsPath;if(s===void 0)return null;let o=this.lazyAtomicGroupByTree.get(i);return o===void 0?{token:i,path:s}:{token:o.token,path:s,atomicGroup:o}}lazyBackingForPath(e){let t=this.lazyArchiveGroups.find(r=>{let i=this.lazyAtomicGroupByTree.get(r),s=this.sealedLazyAtomicStates.get(r)?.snapshot,o=s===void 0?!r.materialized:!i?.committed,a=s?.content??r.content,c=s?.inventory??r.inventory,l=s?.activation??r.activation,d=s?.entries??Array.from(r.entries.values());return o&&a!==void 0&&c!==void 0&&l!==void 0&&d.every(u=>u.deleted||u.materialized||u.isSymlink)&&l.roots.some(u=>u==="/"||e===u||e.startsWith(`${u}/`))});if(t){let r=this.lazyAtomicGroupByTree.get(t);return{token:r?.token??t,path:e,directGroup:t,...r===void 0?{}:{atomicGroup:r}}}try{let r=this.fs.stat(e),i=this.lazyBackingForStat(r);return i?{...i,path:e}:null}catch{return null}}startLazyPreparation(e){let{path:t,token:r}=e,i={status:"pending",promise:Promise.resolve(!1)},s=e.atomicGroup?this.ensureAtomicLazyGroupMaterialized(e.atomicGroup).then(()=>!0):e.directGroup?this.ensureArchiveMaterialized(e.directGroup).then(()=>!0):this.materializePath(t);return i.promise=s.then(o=>(i.status="fulfilled",this.lazyPreparations.get(r)===i&&this.lazyPreparations.delete(r),o),o=>{throw i.status="rejected",i.error=o,o}),i.promise.catch(()=>{}),this.lazyPreparations.set(r,i),i}registerLazyAtomicGroupMembership(e,t=!1){let r=e.activation?.atomicGroup;if(r===void 0)return;let{id:i,member:s}=r;if(e.content===void 0||e.inventory===void 0||e.activation?.mode!=="first-use")throw new Error(`Lazy atomic activation group ${i} accepts only typed first-use trees`);let o=this.lazyAtomicGroups.get(i);if(o===void 0)o={id:i,token:Object.freeze({id:i}),groups:new Map,committed:!1},this.lazyAtomicGroups.set(i,o);else if(o.committed)throw new Error(`Lazy atomic activation group ${i} is already materialized`);if(o.groups.has(s))throw new Error(`Lazy atomic activation group ${i} duplicates member ${s}`);if(Rt(r)){if(o.expectedCount!==void 0&&(o.expectedCount!==r.expectedCount||o.cohortSha256!==r.cohortSha256))throw new Error(`Lazy atomic activation group ${i} has inconsistent seals`);o.expectedCount=r.expectedCount,o.cohortSha256=r.cohortSha256;let a=Ur(e,i,s);this.sealedLazyAtomicStates.set(e,{snapshot:Ss(a,r.descriptorSha256,r.expectedCount,r.cohortSha256),verified:t})}else if(o.expectedCount!==void 0)throw new Error(`Lazy atomic activation group ${i} mixes sealed and unsealed members`);o.groups.set(s,e),this.lazyAtomicGroupByTree.set(e,o)}async sealLazyAtomicGroup(e,t){let r=t.map(l=>ks({id:e,member:l}).member).sort();if(r.length===0||new Set(r).size!==r.length)throw new Error(`Lazy atomic activation group ${e} expected members are invalid`);let i=this.lazyAtomicGroups.get(e);if(i===void 0||i.committed)throw new Error(`Lazy atomic activation group ${e} is not pending`);let s=[...i.groups.keys()].sort();if(JSON.stringify(s)!==JSON.stringify(r))throw new Error(`Lazy atomic activation group ${e} members differ from its seal`);if(i.expectedCount!==void 0){if(i.expectedCount!==r.length||i.cohortSha256===void 0)throw new Error(`Lazy atomic activation group ${e} has an invalid existing seal`);await this.ensureLazyAtomicGroupSealValidated(i,r.map(l=>i.groups.get(l)),!0);return}let o=r.map(l=>Ur(i.groups.get(l),e,l)),a=[];for(let l of o)a.push({member:l.member,descriptorSha256:await lr(l.descriptorBytes,`Lazy atomic member ${l.member}`),source:l});let c=await lr(gs(e,a),`Lazy atomic activation group ${e}`);for(let l of a){let d=i.groups.get(l.member),u=Ur(d,e,l.member);if(!ws(l.source,u))throw new Error(`Lazy atomic activation member ${l.member} changed while sealing`)}for(let l of a){let d=i.groups.get(l.member);d.activation.atomicGroup={id:e,member:l.member,descriptorSha256:l.descriptorSha256,expectedCount:a.length,cohortSha256:c},this.sealedLazyAtomicStates.set(d,{snapshot:Ss(l.source,l.descriptorSha256,a.length,c),verified:!0})}i.expectedCount=a.length,i.cohortSha256=c}async verifyImportedLazyAtomicGroupSeals(){await this.validatePendingLazyAtomicGroupSeals(!0)}guardSynchronousLazyAccess(e){let t=this.lazyBackingForPath(e);if(!t)return;let r=this.lazyPreparations.get(t.token);if(r?.status==="fulfilled"){this.lazyPreparations.delete(t.token);let s=this.lazyBackingForPath(e);if(!s)return;r=this.lazyPreparations.get(s.token)??this.startLazyPreparation(s)}else if(r?.status==="rejected"){this.lazyPreparations.delete(t.token);let s=r.error instanceof Error?r.error.message:String(r.error),o=new Error(`EIO: lazy backing for ${e} failed: ${s}`);throw o.code="EIO",o.cause=r.error,o}else r||(r=this.startLazyPreparation(t));let i=new Error(`EAGAIN: lazy backing for ${e} is being prepared`);throw i.code="EAGAIN",i}invalidateLazyData(e){let t=n.inodeKey(e.ino,e.generation);this.lazyFiles.delete(t);let r=this.lazyArchiveInodes.get(t);if(r){this.lazyArchiveInodes.delete(t);for(let i of r.entries.values())i.ino===e.ino&&i.generation===e.generation&&(i.materialized=!0)}}rewriteLazyNamespacePaths(e,t,r){let i=t.length>1?t.replace(/\/+$/,""):t,s=r.length>1?r.replace(/\/+$/,""):r,o=`${i}/`,a=`${s}/`,c=n.inodeKey(e.ino,e.generation),l=(e.mode&Pe)===it,d=u=>u===i?s:l&&u.startsWith(o)?a+u.slice(o.length):u;for(let[u,p]of this.lazyFiles)!l&&u!==c||(p.paths=new Set(Array.from(p.paths,d)),p.path=d(p.path));for(let u of this.lazyArchiveGroups){let p=new Map;for(let[m,f]of u.entries){let h=f.generation===void 0?null:n.inodeKey(f.ino,f.generation);p.set(l||h===c?d(m):m,f)}u.entries=p,u.inventory&&(u.inventory=u.inventory.map(m=>({...m,vfsPath:d(m.vfsPath),...m.type==="hardlink"&&m.target!==void 0?{target:d(m.target)}:{}}))),u.activation&&(u.activation={...u.activation,roots:u.activation.roots.map(d)})}}get sharedBuffer(){return this.fs.buffer}static create(e,t){return new n(be.mkfs(e,t))}static fromExisting(e){return new n(be.mount(e))}rebaseToNewFileSystem(e){if(!Number.isSafeInteger(e)||e<=0)throw new Error(`Invalid MemoryFileSystem maxByteLength: ${e}`);let t=SharedArrayBuffer,{bytes:r,identities:i}=this.fs.snapshotState();this.reconcileLazyIdentityState(i);let s=this.serializeLazyEntries(),o=this.serializeValidatedLazyArchiveEntries(i),a=new t(r.byteLength);new Uint8Array(a).set(r);let c=new n(be.mount(a,{restoreImage:!0}),this.imageMetadata);c.importLazyEntries(s),c.importLazyArchiveEntriesInternal(o,!1,!0,"verified");let l=Math.min(e,Math.max(r.byteLength,ml)),d=new t(l,{maxByteLength:e}),u=n.create(d,e);u.setImageMetadata(this.imageMetadata);let p=new Set(s.flatMap(f=>f.paths??[f.path])),m=new Set;for(let f of o)if(!f.materialized)for(let h of f.entries)!h.deleted&&!h.isSymlink&&m.add(h.vfsPath);return c.copyPathToFreshFileSystem("/",u,p,m,new Map),u.importLazyEntries(s.map(f=>{let h=u.fs.lstat(f.path);return{...f,ino:h.ino,generation:h.generation,dataSequence:h.dataSequence}})),u.importLazyArchiveEntriesInternal(o.map(f=>({...f,entries:f.entries.map(h=>{if(h.deleted)return{...h,ino:0,generation:void 0};let g=u.fs.lstat(h.vfsPath);return{...h,ino:g.ino,generation:g.generation,dataSequence:g.dataSequence}})})),!1,!0,"verified"),u}getImageMetadata(){return Il(this.imageMetadata)}setImageMetadata(e){this.imageMetadata=e===null?null:hi(e)}subscribeLazyDownloads(e){return this.lazyDownloadListeners.add(e),()=>this.lazyDownloadListeners.delete(e)}setLazyFetcher(e,t={}){this.lazyTransport={fetcher:e,...t.signal===void 0?{}:{signal:t.signal}}}emitLazyDownload(e){if(this.lazyDownloadListeners.size===0)return;let t={...e,t:Tl()};for(let r of this.lazyDownloadListeners)try{r(t)}catch{}}async fetchLazyBytes(e,t){let r=0,i=e.integrity?.bytes??e.fallbackTotalBytes,s={id:e.id,kind:e.kind,url:e.url,path:e.path,mountPrefix:e.mountPrefix};for(let o=0;oe.integrity.bytes)throw new Error(`Lazy ${e.kind} exceeded expected byte count ${e.integrity.bytes}`);this.emitLazyDownload({...s,status:"progress",loadedBytes:r,totalBytes:i})}}}catch(u){try{await c.cancel(u)}catch{}throw u}}finally{c.releaseLock()}let d=Cl(l,r);return ee(t.signal),await ii(d,e.kind,e.integrity),ee(t.signal),this.emitLazyDownload({...s,status:"complete",loadedBytes:r,totalBytes:i??r}),d}catch(a){if(t.signal?.aborted){let d=t.signal.reason,u=d instanceof Error?d.message:String(d);throw this.emitLazyDownload({...s,status:"error",loadedBytes:r,totalBytes:i,error:u}),d}let c=o+1({...y})),activation:u,entries:new Map},g=y=>{let E=y.split("/").filter(Boolean),A="";for(let w=0;wE.vfsPath.split("/").length-A.vfsPath.split("/").length))if(y.type==="directory"){g(y.vfsPath);try{this.fs.mkdir(y.vfsPath,y.mode),this.fs.chmod(y.vfsPath,y.mode)}catch{if((this.fs.lstat(y.vfsPath).mode&Pe)!==it)throw new Error(`Lazy tree directory collides at ${y.vfsPath}`)}}for(let y of l){if(y.type!=="symlink")continue;g(y.vfsPath),this.fs.symlink(y.target,y.vfsPath);let E=this.fs.lstat(y.vfsPath);h.entries.set(y.vfsPath,{ino:E.ino,generation:E.generation,dataSequence:E.dataSequence,size:y.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:y.sourcePath,sourcePath:y.sourcePath,type:"symlink",target:y.target})}let _=new Map;for(let y of l){if(y.type!=="file")continue;g(y.vfsPath);let E=this.fs.createLazyStub(y.vfsPath,y.mode);this.invalidateLazyData(E),_.set(y.inodeGroup,E);let A={ino:E.ino,generation:E.generation,dataSequence:E.dataSequence,size:y.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:y.sourcePath,sourcePath:y.sourcePath,type:"file",inodeGroup:y.inodeGroup};h.entries.set(y.vfsPath,A)}for(let y of l){if(y.type!=="hardlink")continue;let E=p.get(y.inodeGroup);g(y.vfsPath),this.fs.link(E.vfsPath,y.vfsPath);let A=this.fs.lstat(y.vfsPath),w=_.get(y.inodeGroup);if(A.ino!==w.ino||A.generation!==w.generation)throw new Error(`Lazy tree hardlink ${y.vfsPath} did not share its inode`);h.entries.set(y.vfsPath,{ino:A.ino,generation:A.generation,dataSequence:A.dataSequence,size:y.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:E.sourcePath,sourcePath:y.sourcePath,type:"hardlink",inodeGroup:y.inodeGroup,target:y.target})}if(m!==void 0)for(let y of l)this.lchown(y.vfsPath,m.uid,m.gid);for(let y of h.entries.values())y.isSymlink||y.generation===void 0||this.lazyArchiveInodes.set(n.inodeKey(y.ino,y.generation),h);return this.lazyArchiveGroups.push(h),this.registerLazyAtomicGroupMembership(h),h}registerLazyTreeWithMaterializationHandle(e,t,r="/",i,s){let o=this.registerLazyTreeInternal(e,t,r,i,!0,s),a=Object.freeze({[dl]:!0});return this.deferredTreeMaterializationHandles.set(a,o),a}registerLazyArchiveFromEntries(e,t,r,i,s){let o=fr(r),a=Ol(e,t,o,i);a.some(({entry:l})=>!l.isDirectory&&!l.isSymlink)&&this.assertCanRegisterPendingLazyArchiveGroup();let c={...s?{content:Zr({decoder:"zip-v1",mediaType:"application/zip",sha256:s.sha256,bytes:s.bytes,expandedBytes:a.reduce((l,d)=>l+d.entry.uncompressedSize,0),sourceEntryCount:a.length,transports:[e]})}:{},url:e,mountPrefix:o,integrity:hr(s),materialized:!1,entries:new Map};for(let{entry:l,vfsPath:d}of a){if(l.isDirectory)continue;let u=d.split("/").filter(Boolean),p="";for(let m=0;ml.deleted||l.materialized),this.lazyArchiveGroups.push(c),c}importLazyArchiveEntries(e){this.importLazyArchiveEntriesInternal(e,!1,!0,"reject")}async importVerifiedLazyArchiveEntries(e){let t=structuredClone(e),r=this.exportLazyArchiveEntries(),i=n.fromExisting(this.sharedBuffer);i.importLazyArchiveEntriesInternal([...r,...t],!1,!0,"pending"),await i.verifyImportedLazyAtomicGroupSeals(),this.importLazyArchiveEntriesInternal(t,!1,!0,"verified")}importLazyArchiveEntriesInternal(e,t,r,i){let s=Ne(e,"Serialized lazy archive groups",0,Os).map((d,u)=>{if(typeof d!="object"||d===null||Array.isArray(d))throw new Error(`Serialized lazy archive group ${u} must be an object`);let p=d.kind;if(p===dr||p===ci||p===ot)return Ns(d,p);if(p===ur)return di(d,!1);if(p!==void 0)throw new Error(`Serialized lazy archive group ${u} has an unsupported kind`);if(r)throw new Error(`Serialized lazy archive group ${u} is missing its kind discriminator`);return di(d,!0)}),o=this.fs.identityState();this.reconcileLazyIdentityState(o);let a=[...this.serializeValidatedLazyArchiveEntries(o),...s];ys(a);let c=[],l=new Map;for(let d of s){let u=new Map,p=d.mountPrefix.replace(/\/+$/,""),m=d.content!==void 0&&d.inventory!==void 0&&d.activation!==void 0,f=m?new Map(d.inventory.map(w=>[w.vfsPath,w])):null,h=m?new Map(d.inventory.map(w=>[Xr(w),w])):null,g=new Map,_=new Map,y=new Map;for(let w of d.entries){let S=null,O=d.materialized||w.materialized===!0||w.isSymlink;if(!w.deleted&&!O){if((w.generation===void 0||w.dataSequence===void 0)&&!t)throw new Error("Live lazy-archive metadata requires inode generation and data sequence");try{S=this.fs.lstat(w.vfsPath)}catch{if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} is missing from the filesystem`);continue}if(S.ino!==w.ino){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} has a different inode`);continue}if(w.generation!==void 0&&S.generation!==w.generation){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} has a different generation`);continue}if(w.dataSequence===void 0){if(!n.canAdoptLegacyLazyStub(S)){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} is not pristine`);continue}}else if(S.dataSequence!==w.dataSequence){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} has a different data sequence`);continue}if(m){y.set(w.vfsPath,S);let R=f.get(w.vfsPath),T=h.get(Xr(w))??R;if(!T||(S.mode&Pe)!==cr||S.size!==0||(S.mode&4095)!==T.mode||R?.inodeGroup!==void 0&&R.inodeGroup!==T.inodeGroup)throw new Error(`Serialized lazy tree stub ${w.vfsPath} disagrees with its inventory`);let L=n.inodeKey(S.ino,S.generation),D=w.inodeGroup,q=g.get(D),F=_.get(L);if(q!==void 0&&q!==L||F!==void 0&&F!==D)throw new Error(`Serialized lazy tree inode group ${D} disagrees with the filesystem`);g.set(D,L),_.set(L,D)}}u.set(w.vfsPath,{ino:w.ino,generation:S?.generation??w.generation,dataSequence:S?.dataSequence??w.dataSequence,size:w.size,isSymlink:w.isSymlink,deleted:w.deleted,materialized:O,archivePath:w.archivePath??w.vfsPath.slice(p.length+1),sourcePath:w.sourcePath??w.archivePath??w.vfsPath.slice(p.length+1),type:w.type??(w.isSymlink?"symlink":"file"),inodeGroup:w.inodeGroup,target:w.target})}if(m){let w=new Map;for(let S of d.inventory){if(S.type==="file"||S.type==="hardlink"){w.set(S.inodeGroup,(w.get(S.inodeGroup)??0)+1);continue}let O;try{O=this.fs.lstat(S.vfsPath)}catch{throw new Error(`Serialized lazy tree namespace entry ${S.vfsPath} is missing from the filesystem`)}let x=S.type==="directory"?it:Br;if((O.mode&Pe)!==x||(O.mode&4095)!==S.mode||S.type==="symlink"&&(O.size!==new TextEncoder().encode(S.target).byteLength||this.fs.readlink(S.vfsPath)!==S.target))throw new Error(`Serialized lazy tree namespace entry ${S.vfsPath} disagrees with its inventory`);S.type==="symlink"&&u.set(S.vfsPath,{ino:O.ino,generation:O.generation,dataSequence:O.dataSequence,size:S.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:S.sourcePath,sourcePath:S.sourcePath,type:"symlink",target:S.target})}if(d.activation?.atomicGroup!==void 0)for(let S of d.inventory){if(S.type!=="file"&&S.type!=="hardlink")continue;if(y.get(S.vfsPath).linkCount!==w.get(S.inodeGroup))throw new Error(`Serialized lazy atomic tree inode group ${S.inodeGroup} has undeclared aliases`)}}let E=d.content===void 0?void 0:Zr(d.content),A={content:E,url:E?.transports[0]??d.url,mountPrefix:d.mountPrefix,integrity:E?{sha256:E.sha256,bytes:E.bytes}:hr(d.integrity),materialized:d.materialized||!(E&&d.inventory)&&Array.from(u.values()).every(w=>w.deleted||w.materialized),inventory:d.inventory?.map(w=>({...w})),activation:d.activation?{mode:d.activation.mode,capabilities:[...d.activation.capabilities],roots:[...d.activation.roots],...d.activation.atomicGroup===void 0?{}:{atomicGroup:{...d.activation.atomicGroup}}}:void 0,entries:u};if(c.push(A),!A.materialized){for(let[,w]of u)if(!w.deleted&&!w.materialized&&w.generation!==void 0){let S=n.inodeKey(w.ino,w.generation),O=l.get(S);if(O!==void 0&&O!==A)throw new Error(`Serialized lazy archive groups share pending inode ${S}`);if(this.lazyArchiveInodes.has(S))throw new Error(`Serialized lazy archive group collides with pending inode ${S}`);l.set(S,A)}}}for(let d of c){let u=d.activation?.atomicGroup;if(u!==void 0&&this.lazyAtomicGroups.get(u.id)?.committed)throw new Error(`Lazy atomic activation group ${u.id} is already materialized`)}if(i==="reject"&&c.some(d=>{let u=d.activation?.atomicGroup;return u!==void 0&&Rt(u)}))throw new Error("Sealed lazy archive registrations require importVerifiedLazyArchiveEntries()");this.lazyArchiveGroups.push(...c);for(let d of c)this.registerLazyAtomicGroupMembership(d,i==="verified");for(let[d,u]of l)this.lazyArchiveInodes.set(d,u)}rewriteLazyArchiveUrls(e){for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0&&!r?.committed){this.assertLazyAtomicSnapshotMatchesPublic(t);let s=ql(i.snapshot,e);t.content=Es(s.content),t.url=s.url,t.integrity={...s.integrity},i.snapshot=s;continue}t.content?(t.content={...t.content,transports:t.content.transports.map(e)},t.url=t.content.transports[0]):t.url=e(t.url)}}serializeLazyArchiveEntries(){let e=[];for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(r?.committed)continue;let c=i.snapshot;if(c.content.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");e.push({kind:ot,content:Es(c.content),inventory:c.inventory.map(l=>({...l})),activation:Hl(c),url:c.url,mountPrefix:c.mountPrefix,integrity:{...c.integrity},materialized:!1,entries:c.entries.filter(l=>!l.deleted&&!l.materialized).map(({vfsPath:l,...d})=>({vfsPath:l,...d}))});continue}let s=Array.from(t.entries,([c,l])=>({vfsPath:c,ino:l.ino,generation:l.generation,dataSequence:l.dataSequence,size:l.size,isSymlink:l.isSymlink,deleted:l.deleted,materialized:l.materialized,archivePath:l.archivePath,sourcePath:l.sourcePath,type:l.type,inodeGroup:l.inodeGroup,target:l.target})).filter(c=>!c.deleted&&!c.materialized);if(s.length===0&&!(t.content&&t.inventory&&!t.materialized))continue;let o=t.content!==void 0&&t.inventory!==void 0&&t.activation!==void 0;if(o&&t.content.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");let a=t.activation?.atomicGroup;if(a!==void 0&&!Rt(a))throw new Error(`Lazy atomic activation group ${a.id} must be sealed before serialization`);e.push(o?{kind:a!==void 0?ot:t.content.source===void 0?dr:ci,content:t.content,inventory:t.inventory,activation:t.activation,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:s}:{kind:ur,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:s})}return e}serializeValidatedLazyArchiveEntries(e){this.assertPendingLazyAtomicSnapshotsReadyForSerialization();let t=this.serializeLazyArchiveEntries();return ys(t),this.validatePendingLazyTreeNamespaceState(e),t}exportLazyArchiveEntries(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),this.serializeValidatedLazyArchiveEntries(e)}pendingDeferredTreeUsage(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),bs(this.serializeValidatedLazyArchiveEntries(e))}assertCanAppendDeferredTreeUsage(e){ui(e);let t=this.pendingDeferredTreeUsage();ui({groups:t.groups+e.groups,archiveBytes:t.archiveBytes+e.archiveBytes,expandedBytes:t.expandedBytes+e.expandedBytes,payloadBytes:t.payloadBytes+e.payloadBytes,entries:t.entries+e.entries})}assertCanRegisterPendingLazyArchiveGroup(){if(this.reconcileLazyIdentityState(this.fs.identityState()),this.lazyArchiveGroups.filter(t=>{let r=this.lazyAtomicGroupByTree.get(t);return this.sealedLazyAtomicStates.get(t)?.snapshot!==void 0?!r?.committed:!t.materialized&&(t.content!==void 0&&t.inventory!==void 0||Array.from(t.entries.values()).some(s=>!s.deleted&&!s.materialized))}).length>=xe.maxGroups)throw new Error(`Cannot register another lazy archive group: ${xe.maxGroups} pending groups already exist`)}async preparePath(e){let t=!1,r=Math.max(3,this.lazyArchiveGroups.length+1);for(let i=0;i!s.materialized&&s.activation?.mode==="boot-prefetch"),t=0,r,i=Array.from({length:Math.min(e.length,yl)},async()=>{for(;r===void 0;){let s=t;if(t+=1,s>=e.length)return;try{await this.prepareLazyTreeGroup(e[s])}catch(o){r??=o}}});if(await Promise.all(i),r!==void 0)throw r;return e.length}async materializeRegisteredDeferredTree(e,t){let r=this.deferredTreeMaterializationHandles.get(e);if(r===void 0)throw new Error("Deferred-tree handle was not issued by this filesystem");let i=this.lazyAtomicGroupByTree.get(r);if(i!==void 0)throw new Error(`Deferred tree belongs to atomic activation group ${i.id}; materialize the complete group instead`);if(r.materialized)return!1;let s=this.lazyPreparations.get(r);if(s!==void 0)return s.promise;let o=new Uint8Array(t.byteLength);o.set(t);let a={status:"pending",promise:Promise.resolve(!1)};a.promise=Promise.resolve().then(async()=>(await ii(o,"tree",r.integrity),await this.materializeArchiveBytes(r,o),!0)).then(c=>(a.status="fulfilled",c),c=>{throw a.status="rejected",a.error=c,c}),a.promise.catch(()=>{}),this.lazyPreparations.set(r,a);try{return await a.promise}finally{this.lazyPreparations.get(r)===a&&this.lazyPreparations.delete(r)}}async prepareLazyTreeGroup(e){let t=this.lazyAtomicGroupByTree.get(e);if(t?.committed||t===void 0&&e.materialized)return!1;let r=this.sealedLazyAtomicStates.get(e)?.snapshot,i={token:t?.token??e,path:r?.activation.roots[0]??e.activation?.roots[0]??e.mountPrefix,directGroup:e,...t===void 0?{}:{atomicGroup:t}},s=this.lazyPreparations.get(i.token)??this.startLazyPreparation(i);try{return await s.promise}finally{this.lazyPreparations.get(i.token)===s&&this.lazyPreparations.delete(i.token)}}async ensureMaterialized(e){return this.preparePath(e)}async materializePath(e){if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0)return!1;let t;try{t=this.fs.stat(e)}catch{return!1}let r=n.inodeKey(t.ino,t.generation),i=this.lazyFiles.get(r);if(i){let o=this.lazyTransport,a=await this.fetchLazyBytes({id:`file:${t.ino}`,kind:"file",url:i.url,path:i.path,fallbackTotalBytes:i.size},o);for(let c=0;c<3;c++){if(this.lazyFiles.get(r)!==i)return!1;for(let l of new Set([e,...i.paths]))if(ee(o.signal),this.fs.replaceIfIdentity(l,i.ino,i.generation,i.dataSequence,a))return i.path=l,this.lazyFiles.delete(r),!0;this.reconcileLazyIdentityState(this.fs.identityState())}throw new Error(`Lazy file kept changing names while materializing: ${e}`)}let s=this.lazyArchiveInodes.get(r);return s?(await this.ensureArchiveMaterialized(s,{path:e,ino:t.ino,generation:t.generation}),!this.lazyArchiveInodes.has(r)):!1}async decodeAndValidateLazyTree(e,t,r){let i=r?.content??e.content,s=r?.inventory??e.inventory;if(!i||!s)throw new Error("Lazy tree is missing its decoder or complete inventory");let o=new Map,a=new Map(s.map(p=>[p.vfsPath,p]));if(i.source!==void 0)for(let p of i.source.entries)o.set(p.sourcePath,p);else for(let p of s){if(p.type==="hardlink"){let f=a.get(p.target);if(!f)throw new Error(`Lazy tree hardlink target disappeared: ${p.target}`);if(p.sourcePath===f.sourcePath)continue}if(o.get(p.sourcePath))throw new Error(`Lazy tree inventory duplicates source member ${p.sourcePath}`);o.set(p.sourcePath,{sourcePath:p.sourcePath,type:p.type,mode:p.mode,size:p.size,...p.type==="symlink"?{target:p.target}:{},...p.type==="hardlink"?{target:a.get(p.target)?.sourcePath}:{}})}let c=new Map,l=0;if(i.decoder==="zip-v1"){let{parseZipCentralDirectory:p,extractZipEntryBounded:m}=await Promise.resolve().then(()=>(Zn(),qn)),f=p(t);if(f.length!==i.sourceEntryCount||f.length!==o.size)throw new Error("Lazy ZIP tree decoded inventory counts differ from its descriptor");for(let h of f){let g=h.isDirectory?h.fileName.replace(/\/$/,""):h.fileName;if(c.has(g))throw new Error(`Lazy ZIP tree duplicates source member ${g}`);let _=o.get(g);if(!_)throw new Error(`Lazy ZIP tree has undeclared source member ${g}`);if(l+=h.uncompressedSize,l>i.expandedBytes||h.uncompressedSize!==_.size)throw new Error(`Lazy ZIP tree member ${g} exceeds its inventory`);let y=h.isDirectory?"directory":h.isSymlink?"symlink":"file",E=i.modePolicy==="portable-posix-v1"?y==="directory"?493:y==="symlink"?511:(h.mode&73)!==0?493:420:h.mode&4095;if(y!==_.type||E!==_.mode)throw new Error(`Lazy ZIP tree member ${g} differs from inventory`);if(h.isDirectory)c.set(g,{type:"directory",mode:E});else{let A=m(t,h,_.size);if(h.isSymlink){let w;try{w=new TextDecoder("utf-8",{fatal:!0}).decode(A)}catch{throw new Error(`Lazy ZIP tree symlink ${g} is not UTF-8`)}c.set(g,{type:"symlink",mode:E,target:w})}else c.set(g,{type:"file",mode:E,data:A})}}}else{let{parseTarGzip:p}=await Promise.resolve().then(()=>(us(),ls)),m=p(t,{label:`Lazy tree ${i.sha256}`,limits:{maxCompressedBytes:i.bytes,maxUncompressedBytes:i.expandedBytes,maxEntries:i.sourceEntryCount}});l=new DataView(t.buffer,t.byteOffset,t.byteLength).getUint32(t.byteLength-4,!0);for(let f of m){if(c.has(f.path))throw new Error(`Lazy TAR tree duplicates source member ${f.path}`);f.type==="file"?c.set(f.path,{type:"file",mode:f.mode,data:f.data}):f.type==="directory"?c.set(f.path,{type:"directory",mode:f.mode}):c.set(f.path,{type:f.type,mode:f.mode,target:f.linkName})}}if(c.size!==i.sourceEntryCount||c.size!==o.size||l!==i.expandedBytes)throw new Error("Lazy tree decoded inventory counts differ from its descriptor");for(let[p,m]of o){let f=c.get(p);if(!f)throw new Error(`Lazy tree is missing source member ${p}`);let h=m.type;if(f.type!==h)throw new Error(`Lazy tree member ${p} is ${f.type}, expected ${h}`);if((f.mode&4095)!==m.mode)throw new Error(`Lazy tree member ${p} mode differs from inventory`);if(h==="file"&&f.data?.byteLength!==m.size)throw new Error(`Lazy tree member ${p} size differs from inventory`);if(h==="symlink"&&f.target!==m.target)throw new Error(`Lazy tree symlink ${p} target differs from inventory`);if(h==="hardlink"&&f.target!==m.target)throw new Error(`Lazy tree hardlink ${p} target differs from inventory`)}let d=new Set(s.flatMap(p=>p.materialization==="archive-homebrew-relocate"?[p.sourcePath]:[]));if(i.source!==void 0){let p=new Map(i.source.entries.map(h=>[h.sourcePath,h])),m=zs(i.source.entries),f=i.source.entries.filter(h=>h.sourcePath==="INSTALL_RECEIPT.json"||h.sourcePath.endsWith("/INSTALL_RECEIPT.json"));if(f.length>1)throw new Error(`Lazy Homebrew bottle has ${f.length} INSTALL_RECEIPT.json source members, expected at most one`);if(f.length===0){if(d.size>0)throw new Error("Lazy Homebrew bottle marks receipt relocation without INSTALL_RECEIPT.json")}else{let h=f[0],g=h.type==="file"?h:m.get(h.sourcePath),_=g===void 0?void 0:c.get(g.sourcePath);if(g?.type!=="file"||_?.type!=="file"||_.data===void 0)throw new Error("Lazy Homebrew bottle INSTALL_RECEIPT.json is not regular");let y=Do(_.data),E=h.sourcePath.lastIndexOf("/"),A=E<0?"":h.sourcePath.slice(0,E),w=new Set(y.changedFiles.map(O=>A.length===0?O:`${A}/${O}`));if(d.size!==w.size||[...d].some(O=>!w.has(O)))throw new Error("Lazy Homebrew bottle relocation markers differ from INSTALL_RECEIPT.json");let S=new Set;for(let O of w){let x=p.get(O),R=x?.type==="file"?x:x===void 0?void 0:m.get(x.sourcePath),T=R===void 0?void 0:c.get(R.sourcePath);if(R?.type!=="file"||T?.type!=="file"||T.data===void 0)throw new Error(`Lazy Homebrew bottle changed source ${O} is not regular`);S.has(R.sourcePath)||(T.data=Ko(T.data,y,O),S.add(R.sourcePath))}}}else if(d.size>0)throw new Error("Lazy tree receipt relocation requires original-bottle source truth");let u=new Map;for(let p of s){if(p.type!=="file"||p.materialization==="descriptor")continue;let m=c.get(p.sourcePath);if(m?.type!=="file"||!m.data)throw new Error(`Lazy tree has no file content for ${p.sourcePath}`);u.set(p.sourcePath,m.data)}return u}async ensureArchiveMaterialized(e,t){let r=this.lazyAtomicGroupByTree.get(e);if(r!==void 0){if(r.committed)return;let o=this.sealedLazyAtomicStates.get(e)?.snapshot,a=this.lazyPreparations.get(r.token)??this.startLazyPreparation({token:r.token,path:o?.activation.roots[0]??e.activation?.roots[0]??e.mountPrefix,atomicGroup:r});try{await a.promise}finally{this.lazyPreparations.get(r.token)===a&&this.lazyPreparations.delete(r.token)}return}if(e.materialized)return;let i=this.lazyTransport,s=await this.fetchLazyArchiveData(e,i);ee(i.signal),await this.materializeArchiveBytes(e,s,t,i.signal)}async fetchLazyArchiveData(e,t,r){let i=r?.content??e.content,s=r?.inventory??e.inventory,o=i!==void 0&&s!==void 0,a=r?.mountPrefix??e.mountPrefix,c=r?.integrity??e.integrity,l=o?i.transports:[r?.url??e.url],d=[],u=null;for(let[p,m]of l.entries())try{u=await this.fetchLazyBytes({id:`archive:${a}:${i?.sha256??m}:${p}`,kind:o?"tree":"archive",url:m,mountPrefix:a,integrity:c},t);break}catch(f){if(ee(t.signal),Ls(f))throw f;d.push(f instanceof Error?f.message:String(f))}if(ee(t.signal),u===null)throw new Error(`All ${l.length} lazy ${o?"tree":"archive"} transports failed: ${d.join("; ")}`);return u}async materializeArchiveBytes(e,t,r,i){if(ee(i),e.materialized)return;let s=await this.prepareLazyArchiveContents(e,t,i),o=r?n.inodeKey(r.ino,r.generation):null;for(let a=0;a<3;a++){let c=this.collectLazyArchiveReplacements(e,s,r);if(c.size>0&&(ee(i),!this.fs.replaceManyIfIdentities(Array.from(c.values(),As)))){if(this.reconcileLazyIdentityState(this.fs.identityState()),o&&!this.lazyArchiveInodes.has(o))return;continue}if(ee(i),this.publishLazyArchiveReplacements(e,c),e.materialized||(this.reconcileLazyIdentityState(this.fs.identityState()),o&&!this.lazyArchiveInodes.has(o)))return}if(o&&this.lazyArchiveInodes.has(o))throw new Error(`Lazy archive member kept changing names while materializing: ${r?.path}`)}async prepareLazyArchiveContents(e,t,r,i){ee(r);let s=i?.content??e.content,o=i?.inventory??e.inventory,c=s!==void 0&&o!==void 0?await this.decodeAndValidateLazyTree(e,t,i):null;ee(r);let{parseZipCentralDirectory:l,extractZipEntry:d}=await Promise.resolve().then(()=>(Zn(),qn));ee(r);let u=c?[]:l(t),p=new Map;for(let _ of u){if(p.has(_.fileName))throw new Error(`Lazy archive contains duplicate member: ${_.fileName}`);p.set(_.fileName,_)}let f=(i?.mountPrefix??e.mountPrefix).replace(/\/+$/,""),h=new Map,g=i===void 0?Array.from(e.entries):i.entries.map(_=>[_.vfsPath,_]);for(let[_,y]of g){if(y.deleted||y.materialized)continue;let E=y.archivePath??_.slice(f.length+1),A=c?void 0:p.get(E),w=c?.get(E);if(c){if(w===void 0||w.byteLength!==y.size)throw new Error(`Lazy tree member ${E} does not match its registered metadata`)}else if(A===void 0||A.isDirectory||A.isSymlink||A.uncompressedSize!==y.size)throw new Error(`Lazy archive member ${E} does not match its registered metadata`);if(y.generation===void 0)continue;let S=n.inodeKey(y.ino,y.generation),O=h.get(S);if(O&&O.archivePath!==E)throw new Error(`Lazy archive aliases for inode ${S} name different members`);if(!O){let x=w??d(t,A);if(x.byteLength!==y.size)throw new Error(`Lazy archive member ${E} extracted ${x.byteLength} bytes, expected ${y.size}`);h.set(S,{archivePath:E,content:x})}}return h}collectLazyArchiveReplacements(e,t,r,i){let s=new Map,o=i===void 0?Array.from(e.entries):i.entries.map(a=>[a.vfsPath,a]);for(let[a,c]of o){if(c.deleted||c.materialized||c.generation===void 0)continue;let l=n.inodeKey(c.ino,c.generation);if(this.lazyArchiveInodes.get(l)!==e)continue;let d=t.get(l);if(!d)throw new Error(`Lazy archive has no extracted content for inode ${l}`);let u=s.get(l);u||(u={ino:c.ino,generation:c.generation,dataSequence:c.dataSequence??0,paths:new Set,content:d.content},s.set(l,u)),u.paths.add(a),r&&r.ino===c.ino&&r.generation===c.generation&&u.paths.add(r.path)}return s}publishLazyArchiveReplacements(e,t){for(let[r,i]of t){this.lazyArchiveInodes.delete(r);for(let s of e.entries.values())s.ino===i.ino&&s.generation===i.generation&&(s.materialized=!0)}e.materialized=Array.from(e.entries.values()).every(r=>r.deleted||r.materialized)}collectAtomicTreeNamespace(e,t){let r=new Set,i=new Map,s=new Map,o=new Map(t.entries.map(c=>[c.vfsPath,c]));for(let c of t.inventory)(c.type==="file"||c.type==="hardlink")&&s.set(c.inodeGroup,(s.get(c.inodeGroup)??0)+1);let a=[];for(let c of t.inventory){let l;try{l=this.fs.lstat(c.vfsPath)}catch{throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`)}let d=c.type==="directory"?it:c.type==="symlink"?Br:cr;if((l.mode&Pe)!==d||(l.mode&4095)!==c.mode)throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`);if(c.type==="symlink"){let u=o.get(c.vfsPath);if(u===void 0||!u.isSymlink||u.deleted||u.ino!==l.ino||u.generation!==l.generation||u.dataSequence!==l.dataSequence||this.fs.readlink(c.vfsPath)!==c.target)throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`)}else if(c.type==="file"||c.type==="hardlink"){let u=o.get(c.vfsPath);if(u===void 0||u.deleted||u.materialized||u.isSymlink||u.generation===void 0||u.inodeGroup!==c.inodeGroup||u.ino!==l.ino||u.generation!==l.generation||u.dataSequence!==l.dataSequence||l.size!==0||l.linkCount!==s.get(c.inodeGroup))throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`);let p=n.inodeKey(u.ino,u.generation);if(this.lazyArchiveInodes.get(p)!==e)throw new Error(`Lazy atomic tree lost deferred ownership of ${c.vfsPath}`);let m=i.get(c.inodeGroup);if(m!==void 0&&m!==p)throw new Error(`Lazy atomic tree split hard links at ${c.vfsPath}`);i.set(c.inodeGroup,p),r.add(p)}a.push({path:c.vfsPath,expectedIno:l.ino,expectedGeneration:l.generation,expectedDataSequence:l.dataSequence,expectedMode:l.mode,expectedLinkCount:l.linkCount,expectedSize:l.size,expectedUid:l.uid,expectedGid:l.gid})}return{guards:a,pendingIdentities:r.size}}assertLazyAtomicSnapshotMatchesPublic(e){let t=this.sealedLazyAtomicStates.get(e),r=t?.snapshot,i=e.activation?.atomicGroup,s=r?.member??i?.member??"unknown",o;if(r!==void 0)try{o=Ur(e,r.id,r.member)}catch{o=void 0}if(t===void 0||r===void 0||i===void 0||!Rt(i)||i.id!==r.id||i.member!==r.member||i.descriptorSha256!==r.descriptorSha256||i.expectedCount!==r.expectedCount||i.cohortSha256!==r.cohortSha256||o===void 0||!ws(r,o))throw new Error(`Lazy atomic activation member ${s} changed after sealing`);return t}assertPendingLazyAtomicSnapshotsReadyForSerialization(){for(let e of this.lazyArchiveGroups){if(!this.sealedLazyAtomicStates.has(e)||this.lazyAtomicGroupByTree.get(e)?.committed)continue;let r=this.assertLazyAtomicSnapshotMatchesPublic(e);if(!r.verified)throw new Error(`Lazy atomic activation group ${r.snapshot.id} has not been cryptographically verified after import`)}}async validatePendingLazyAtomicGroupSeals(e){for(let t of this.lazyAtomicGroups.values()){if(t.committed||t.expectedCount===void 0||t.cohortSha256===void 0)continue;let r=[...t.groups.entries()].sort(([i],[s])=>is?1:0).map(([,i])=>i);await this.ensureLazyAtomicGroupSealValidated(t,r,e)}}async ensureLazyAtomicGroupSealValidated(e,t,r){if(this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r))return;let i=e.sealValidationFlight;if(i===void 0&&(i=this.validateLazyAtomicGroupSealOnce(e,t),e.sealValidationFlight=i,i.then(()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)},()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)})),await i,!this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r))throw new Error(`Lazy atomic activation group ${e.id} seal verification did not authenticate every member`)}assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r){if(e.committed)return!0;if(e.expectedCount===void 0||e.cohortSha256===void 0||t.length!==e.expectedCount)throw new Error(`Lazy atomic activation group ${e.id} is not completely sealed`);let i=!0,s=[];for(let o of t){let a=this.assertLazyAtomicSnapshotMatchesPublic(o),c=a.snapshot;if(c.id!==e.id||c.expectedCount!==e.expectedCount||c.cohortSha256!==e.cohortSha256||e.groups.get(c.member)!==o)throw new Error(`Lazy atomic activation group ${e.id} has an inconsistent member`);i&&=a.verified,s.push(a)}if(i&&r)for(let o=0;ofh?1:0).map(([,f])=>f);if(t.length===0)throw new Error(`Lazy atomic activation group ${e.id} has no trees`);if(await this.ensureLazyAtomicGroupSealValidated(e,t,!0),e.committed)return;let r=t.map(f=>this.sealedLazyAtomicStates.get(f).snapshot),i=t.map((f,h)=>({group:f,...this.collectAtomicTreeNamespace(f,r[h])})),s=this.lazyTransport,o=new Array(t.length),a=0,c=!1,l,d=Array.from({length:Math.min(gl,t.length)},async()=>{for(;!c;){let f=a++;if(f>=t.length)return;let h=t[f],g=r[f];try{let _=await this.fetchLazyArchiveData(h,s,g);ee(s.signal),o[f]={group:h,snapshot:g,contents:await this.prepareLazyArchiveContents(h,_,s.signal,g)}}catch(_){c||(c=!0,l=_)}}});if(await Promise.all(d),c)throw o.fill(void 0),l;ee(s.signal);for(let f of t)this.assertLazyAtomicSnapshotMatchesPublic(f);let u=[],p=[],m=[];for(let f=0;f{let a=this.lazyAtomicGroupByTree.get(o);return this.sealedLazyAtomicStates.get(o)?.snapshot===void 0?!o.materialized&&o.content!==void 0&&o.inventory!==void 0:!a?.committed});if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0&&r.length===0)return;let i=Array.from(this.lazyFiles.values(),o=>o.path);for(let o of i)await this.ensureMaterialized(o);let s=new Set(this.lazyArchiveInodes.values());for(let o of r)s.add(o);for(let o of s)await this.prepareLazyTreeGroup(o)}this.reconcileLazyIdentityState(this.fs.identityState());let e=this.lazyArchiveGroups.some(t=>{let r=this.lazyAtomicGroupByTree.get(t);return this.sealedLazyAtomicStates.get(t)?.snapshot===void 0?!t.materialized&&t.content!==void 0&&t.inventory!==void 0:!r?.committed});if(this.lazyFiles.size!==0||this.lazyArchiveInodes.size!==0||e)throw new Error("Cannot create a self-contained VFS image while lazy entries remain pending")}async saveImage(e){e?.materializeAll&&await this.materializeAllLazyEntries(),await this.validatePendingLazyAtomicGroupSeals(!1);let{bytes:t,identities:r}=this.fs.snapshotState({normalizeTimestampsMs:e?.normalizeTimestampsMs});this.reconcileLazyIdentityState(r);let i=this.serializeLazyEntries(),s=i.length>0,o=s?new TextEncoder().encode(JSON.stringify(i)):new Uint8Array(0);if(o.byteLength>Gr)throw new Error(`VFS image lazy metadata exceeds ${Gr} bytes`);let a=this.serializeValidatedLazyArchiveEntries(r),c=a.length>0,l=c?new TextEncoder().encode(JSON.stringify(a)):new Uint8Array(0);if(l.byteLength>Hr)throw new Error(`VFS image lazy archive metadata exceeds ${Hr} bytes`);let d=e?.metadata===void 0?this.imageMetadata:e.metadata,u=Rl(d),p=u.byteLength>0,m=c?4+l.byteLength:0,f=p?4+u.byteLength:0,h=he+t.byteLength+4+o.byteLength+m+f,g=new Uint8Array(h),_=new DataView(g.buffer);_.setUint32(0,oi,!0),_.setUint32(4,si,!0),_.setUint32(8,(s?ei:0)|(c?Wr:0)|(c?ri:0)|(p?ti:0),!0),_.setUint32(12,t.byteLength,!0),g.set(t,he);let y=he+t.byteLength;if(_.setUint32(y,o.byteLength,!0),o.byteLength>0&&g.set(o,y+4),c){let E=y+4+o.byteLength;_.setUint32(E,l.byteLength,!0),g.set(l,E+4)}if(p){let E=y+4+o.byteLength+m;_.setUint32(E,u.byteLength,!0),g.set(u,E+4)}return g}static readImageMetadata(e){let t=$r(e);if(!(t.flags&ti))return null;let{metadataOffset:r}=ms(t.image,t.view,t.flags,t.sabLen);if(t.image.byteLengthvt)throw new Error(`VFS image metadata exceeds ${vt} bytes`);if(t.image.byteLength0){let g=r.subarray(f+4,f+4+h),_=Ne(_s(g,"VFS image lazy metadata"),"VFS image lazy entries",0,Tt);m.importLazyEntriesInternal(_,!0)}if(s&Wr){let g=a.archiveOffset,_=i.getUint32(g,!0);if(_>0){let y=r.subarray(g+4,g+4+_),E=_s(y,"VFS image lazy archive metadata");m.importLazyArchiveEntriesInternal(E,!0,!!(s&ri),"pending")}}return m}adaptStat(e){return{dev:0,ino:e.ino,mode:e.mode,nlink:e.linkCount,uid:e.uid,gid:e.gid,size:e.size,atimeMs:e.atime,mtimeMs:e.mtime,ctimeMs:e.ctime}}adaptStatWithLazySize(e){let t=this.adaptStat(e),r=this.lazyFileForStat(e);if(r)return t.size=r.size,t;let i=this.lazyArchiveForStat(e);if(i){for(let s of this.lazyArchiveEntriesForRead(i))if(s.ino===e.ino&&s.generation===e.generation&&!s.deleted){t.size=s.size;break}}return t}open(e,t,r){(t&rr)===0&&!((t&er)!==0&&(t&Kn)!==0)&&this.guardSynchronousLazyAccess(e);let i=this.fs.open(e,t,r);return(t&rr)!==0&&this.invalidateLazyData(this.fs.fstat(i)),i}close(e){return this.fs.close(e),0}read(e,t,r,i){if(i>0){let s=this.lazyBackingForStat(this.fs.fstat(e));s&&(this.reconcileLazyIdentityState(this.fs.identityState()),s=this.lazyBackingForStat(this.fs.fstat(e)),s&&this.guardSynchronousLazyAccess(s.path))}return r!==null?this.fs.readAt(e,t.subarray(0,i),typeof r=="bigint"?vn(r):r):this.fs.read(e,t.subarray(0,i))}write(e,t,r,i){if(r!==null){let o=this.fs.writeAt(e,t.subarray(0,i),typeof r=="bigint"?vn(r):r);return o>0&&this.invalidateLazyData(this.fs.fstat(e)),o}let s=this.fs.write(e,t.subarray(0,i));return s>0&&this.invalidateLazyData(this.fs.fstat(e)),s}append(e,t,r,i){let s=this.fs.append(e,t.subarray(0,r),yo(i));return s.written>0&&this.invalidateLazyData(this.fs.fstat(e)),s}seek(e,t,r){return this.fs.lseek(e,typeof t=="bigint"?Rn(t):t,r)}fstat(e){return this.adaptStatWithLazySize(this.fs.fstat(e))}fpathconf(e,t){let r=this.fstat(e);return Ln(r,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}ftruncate(e,t){this.fs.ftruncate(e,t),this.invalidateLazyData(this.fs.fstat(e))}fsync(e){}fchmod(e,t){this.fs.fchmod(e,t)}fchown(e,t,r){this.fs.fchown(e,t,r)}stat(e){return this.adaptStatWithLazySize(this.fs.stat(e))}lstat(e){return this.adaptStatWithLazySize(this.fs.lstat(e))}statfs(e){this.fs.stat(e);let t=this.fs.statfs();return{type:1397114451,bsize:t.blockSize,blocks:t.totalBlocks,bfree:t.freeBlocks,bavail:t.freeBlocks,files:t.totalInodes,ffree:t.freeInodes,fsid:0,namelen:t.maxName,frsize:t.blockSize,flags:0}}pathconf(e,t){let r=this.stat(e);return Ln(r,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}mkdir(e,t){this.fs.mkdir(e,t)}rmdir(e){this.fs.rmdir(e)}unlink(e){let t=this.fs.unlink(e),r=n.inodeKey(t.ino,t.generation);if(t.linkCount>1&&(this.lazyFiles.has(r)||this.lazyArchiveInodes.has(r))){this.reconcileLazyIdentityState(this.fs.identityState());return}let i=this.lazyFiles.get(r);i&&(i.paths.delete(e),t.linkCount<=1?this.lazyFiles.delete(r):i.path===e&&(i.path=i.paths.values().next().value));let s=this.lazyArchiveInodes.get(r);if(s){let o=s.entries.get(e);if(t.linkCount<=1){for(let a of s.entries.values())a.ino===t.ino&&a.generation===t.generation&&(a.deleted=!0);this.lazyArchiveInodes.delete(r)}else o&&s.entries.delete(e)}}rename(e,t){let{source:r,replaced:i}=this.fs.rename(e,t);if(i&&i.ino===r.ino&&i.generation===r.generation)return;let s=!1;if(i){let o=n.inodeKey(i.ino,i.generation);i.linkCount>1&&(this.lazyFiles.has(o)||this.lazyArchiveInodes.has(o))&&(this.reconcileLazyIdentityState(this.fs.identityState()),s=!0);let a=this.lazyFiles.get(o);!s&&a&&(a.paths.delete(t),i.linkCount<=1?this.lazyFiles.delete(o):a.path===t&&(a.path=a.paths.values().next().value));let c=this.lazyArchiveInodes.get(o);if(!s&&c){let l=c.entries.get(t);i.linkCount<=1?(l&&(l.deleted=!0),this.lazyArchiveInodes.delete(o)):l&&c.entries.delete(t)}}s||this.rewriteLazyNamespacePaths(r,e,t)}link(e,t){let r=this.fs.link(e,t),i=n.inodeKey(r.ino,r.generation),s=this.lazyFiles.get(i);s&&s.paths.add(t);let o=this.lazyArchiveInodes.get(i);if(o){let a=Array.from(o.entries.values()).find(c=>c.ino===r.ino&&c.generation===r.generation);a&&o.entries.set(t,{...a})}}symlink(e,t){this.fs.symlink(e,t)}readlink(e){return this.fs.readlink(e)}chmod(e,t){this.fs.chmod(e,t)}chown(e,t,r){this.fs.chown(e,t,r)}lchown(e,t,r){this.fs.lchown(e,t,r)}createFileWithOwner(e,t,r,i,s){let o=this.open(e,577,t);s.length>0&&this.write(o,s,null,s.length),this.close(o),this.chown(e,r,i),this.chmod(e,t)}mkdirWithOwner(e,t,r,i){this.mkdir(e,t),this.chown(e,r,i),this.chmod(e,t)}symlinkWithOwner(e,t,r,i){this.symlink(e,t),this.lchown(t,r,i)}copyPathToFreshFileSystem(e,t,r,i,s){let o=this.lstat(e),a=o.mode&Pe,c=o.mode&4095;if(a===it){e==="/"?(t.chown(e,o.uid,o.gid),t.chmod(e,c)):t.mkdirWithOwner(e,c,o.uid,o.gid);let p=this.opendir(e);try{for(;;){let m=this.readdir(p);if(!m)break;m.name==="."||m.name===".."||this.copyPathToFreshFileSystem(e==="/"?`/${m.name}`:`${e}/${m.name}`,t,r,i,s)}}finally{this.closedir(p)}n.applyTimes(t,e,o);return}let l=o.nlink>1?`${o.dev}:${o.ino}`:null,d=l?s.get(l):void 0;if(d){t.link(d,e);return}if(a===Br){t.symlinkWithOwner(this.readlink(e),e,o.uid,o.gid),l&&s.set(l,e);return}if(a!==cr)throw new Error(`Unsupported file type while rebasing VFS: ${e}`);if(r.has(e)||i.has(e)){t.createFileWithOwner(e,c,o.uid,o.gid,new Uint8Array(0)),n.applyTimes(t,e,o),l&&s.set(l,e);return}this.copyRegularFileToFreshFileSystem(e,t,o,c),l&&s.set(l,e)}copyRegularFileToFreshFileSystem(e,t,r,i){let s=this.open(e,fl,0),o=null;try{o=t.open(e,hl,i);let a=new Uint8Array(Math.min(pl,Math.max(1,r.size))),c=r.size;for(;c>0;){let l=Math.min(a.byteLength,c),d=this.read(s,a,null,l);if(d<=0)throw new Error(`Unexpected EOF while rebasing VFS file: ${e}`);let u=0;for(;u!e||e==="."||e===".."))throw new Error(`Binary resolver path must be a normalized portable relative path: ${JSON.stringify(n)}`);return n}var ct=new Set(["wasm32","wasm64"]);function Ye(n){if(nu(n),!n.startsWith("programs/"))return n;let e=n.slice(9),t=e.split("/",1)[0];return ct.has(t)?n:`programs/wasm32/${e}`}function iu(n,e=$(Si(),"wasm")){let t=Ye(n),r=[$(e,t)];return n==="kernel.wasm"?r.push($(e,"kandelo-kernel.wasm")):n==="userspace.wasm"?r.push($(e,"wasm_posix_userspace.wasm")):n==="rootfs.vfs"&&r.push($(e,"rootfs.vfs")),r}var en=class extends Error{constructor(e){super(e),this.name="BinaryNotFoundError"}};function qs(){let n=[],e=!1;try{let r=at();e=!0;for(let[i,s]of[["local-binaries",$(r,"local-binaries")],["binaries",$(r,"binaries")]])n.push({label:i,root:s,identity:i==="local-binaries"?"local-generation":"program-cache",allowRegularFileClosure:!1,candidatesFor(o){return[$(s,Ye(o))]}})}catch{}let t=$(Si(),"wasm");return n.push({label:"installed package",root:t,identity:"installed-package",allowRegularFileClosure:!e,candidatesFor(r){return iu(r,t)}}),n}function Lt(n,e){return new Error(`Invalid package manifest ${n}: ${e}`)}function ae(n){try{return tn(n),!0}catch(e){if(e instanceof Error&&"code"in e&&e.code==="ENOENT")return!1;throw e}}function Ms(n,e,t){if(n.length===0||n.startsWith("/")||n.includes("\\")||n.includes("\0")||n.split("/").some(r=>!r||r==="."||r===".."))throw Lt(e,`${t} must be a normalized portable relative path`);return n}function Jr(n,e,t,r=!0){if(n.length===0||n==="."||n===".."||n.includes("/")||n.includes("\\")||n.includes("\0")||!r&&n.includes("@"))throw Lt(e,`${t} must be a safe single path component`);return n}var Ds="kandelo-program-packages-v2",Fe="program-packages.json",Ks=null,ou=null,Qr=null,mi=0;function wi(){return ou??$(Si(),"wasm",Fe)}function Zs(){if(Object.prototype.hasOwnProperty.call(process.env,"WASM_POSIX_DEPS_REGISTRY")){let t=null;return(process.env.WASM_POSIX_DEPS_REGISTRY??"").split(":").filter(Boolean).map(r=>r.startsWith("~/")&&process.env.HOME!==void 0?$(process.env.HOME,r.slice(2)):rn(r)?Oe(r):(t??=at(),Oe(t,r)))}let n;try{n=$(at(),"packages","registry")}catch{return null}let e=!1;if(ae(n)){if(!Xe(n).isDirectory())return[n];e=Gs(n,{withFileTypes:!0}).filter(t=>t.isDirectory()||t.isSymbolicLink()).some(t=>ae($(n,t.name,"package.toml")))}return!e&&Xs()===null&&ae(wi())?null:[n]}function Xs(){let n;try{n=at()}catch{return null}if(!pr($(n,"tools","xtask","Cargo.toml"))||!pr($(n,"scripts","dev-shell.sh")))return null;try{let e=Ie(Ei()),t=Ie(n);return[$(t,"host"),$(t,"scripts")].some(i=>pr(i)&&Ri(Ie(i),e))?t:null}catch{return null}}function Ai(n,e,t){let r=[typeof t.stderr=="string"?t.stderr.trim():"",typeof t.stdout=="string"?t.stdout.trim():"",t.error?.message??""].filter(Boolean).join(` `);return`${n} ${e.join(" ")} failed${t.status===null?"":` with status ${t.status}`}${r?`: ${r}`:""}`}function su(n){let e=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,t=e?"rustc":"bash",r=e?["-vV"]:[$(n,"scripts","dev-shell.sh"),"rustc","-vV"],i=gi(t,r,{cwd:n,encoding:"utf8"});if(i.status!==0)throw new Error(Ai(t,r,i));let s=i.stdout.split(/\r?\n/).find(o=>o.startsWith("host: "))?.slice(6).trim();if(!s)throw new Error(`Could not determine the Rust host target for ${n}`);return s}function _i(n){try{if(tn(n).isFile())return Ie(n)}catch{}throw new Error(`Prepared xtask is not a regular file: ${n}`)}function au(n){let e=process.env.WASM_POSIX_XTASK_BIN;if(e!==void 0){let l=rn(e)?Oe(e):Oe(n,e);return _i(l)}if(Qr?.sourceRepoRoot===n)return _i(Qr.xtaskPath);let t=su(n),r=$(n,"target",t,"release",process.platform==="win32"?"xtask.exe":"xtask"),i=["build","--release","-p","xtask","--target",t,"--quiet"],s=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,o=s?"cargo":"bash",a=s?i:[$(n,"scripts","dev-shell.sh"),"cargo",...i],c=gi(o,a,{cwd:n,encoding:"utf8"});if(c.status!==0)throw new Error(Ai(o,a,c));return Qr={sourceRepoRoot:n,xtaskPath:_i(r)},Qr.xtaskPath}function cu(){let n=Xs();if(n===null)return;let e=Zs();if(e===null)return;if(Ks){Ks(n,e);return}let t=au(n),r=["build-deps","program-index-context-check","--source-repo-root",n],i=gi(t,r,{cwd:n,encoding:"utf8",env:{...process.env,WASM_POSIX_DEPS_REGISTRY:e.join(":")}});if(i.status!==0)throw new Error(`Program package source projection is not current: -${Ai(t,r,i)}`)}function lu(n,e){if(mi>0||!n.some(t=>t.startsWith("programs/")))return e();mi+=1;try{return cu(),e()}finally{mi-=1}}function Ze(n,e){let t=Object.keys(n).sort(),r=[...e].sort();return t.length===r.length&&t.every((i,s)=>i===r[s])}function yi(n){let e;try{e=JSON.parse(st(n,"utf8"))}catch(o){throw new Error(`Invalid program package index ${n}: ${o instanceof Error?o.message:String(o)}`)}if(typeof e!="object"||e===null||!Ze(e,["format","identities","packages"])||e.format!==Ds||typeof e.identities!="object"||e.identities===null||Array.isArray(e.identities)||typeof e.packages!="object"||e.packages===null||Array.isArray(e.packages))throw new Error(`Invalid program package index ${n}: expected ${Ds}`);let t=new Map,r=e.identities;for(let[o,a]of Object.entries(r)){if(Jr(o,n,"identity package name",!1),typeof a!="object"||a===null||!Ze(a,["manifestSha256","cacheKeys"])||typeof a.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(a.manifestSha256)||typeof a.cacheKeys!="object"||a.cacheKeys===null||Array.isArray(a.cacheKeys))throw new Error(`Invalid program package index ${n}: malformed identity ${JSON.stringify(o)}`);let c=a.cacheKeys;if(!Ze(c,["wasm32","wasm64"])||Object.values(c).some(l=>typeof l!="string"||!/^[a-f0-9]{64}$/.test(l)))throw new Error(`Invalid program package index ${n}: identity ${JSON.stringify(o)} has invalid contextual cache keys`);t.set(o,{manifestSha256:a.manifestSha256,cacheKeys:c})}let i=new Map,s=e.packages;for(let[o,a]of Object.entries(s)){if(Jr(o,n,"package name",!1),typeof a!="object"||a===null||!Ze(a,["manifestSha256","arches","cacheKeys","dependencyClosures","members"])||!Array.isArray(a.arches)||typeof a.cacheKeys!="object"||a.cacheKeys===null||Array.isArray(a.cacheKeys)||typeof a.dependencyClosures!="object"||a.dependencyClosures===null||Array.isArray(a.dependencyClosures)||!Array.isArray(a.members)||typeof a.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(a.manifestSha256))throw new Error(`Invalid program package index ${n}: malformed package ${JSON.stringify(o)}`);let c=a.arches;if(c.length===0||new Set(c).size!==c.length||c.some(h=>typeof h!="string"||!ct.has(h)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has invalid arches`);let l=a.cacheKeys;if(!Ze(l,c)||Object.values(l).some(h=>typeof h!="string"||!/^[a-f0-9]{64}$/.test(h)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has invalid cache keys`);let d=a.dependencyClosures;if(!Ze(d,c))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has invalid dependency closure arches`);let u={};for(let h of c){let g=d[h];if(!Array.isArray(g))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has a malformed dependency closure for ${h}`);let _=new Set;u[h]=g.map((y,E)=>{if(typeof y!="object"||y===null||!Ze(y,["packageName","manifestSha256","cacheKey"])||typeof y.packageName!="string"||typeof y.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(y.manifestSha256)||typeof y.cacheKey!="string"||!/^[a-f0-9]{64}$/.test(y.cacheKey))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} dependency ${E+1} for ${h} is malformed`);let A=y;if(Jr(A.packageName,n,`${o} dependency packageName`,!1),A.packageName===o||_.has(A.packageName))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} dependency closure for ${h} must contain unique dependencies other than itself`);_.add(A.packageName);let w=t.get(A.packageName);if(!w||w.manifestSha256!==A.manifestSha256||w.cacheKeys[h]!==A.cacheKey)throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} dependency ${JSON.stringify(A.packageName)} for ${h} does not match the index's authoritative contextual identity`);return A})}let p=a.members.map((h,g)=>{if(typeof h!="object"||h===null||h.kind!=="output"&&h.kind!=="runtime-file"||typeof h.sourceArtifact!="string"||typeof h.mirrorPath!="string")throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} member ${g+1} is malformed`);let _=h,y=_.kind==="output"?["kind","sourceArtifact","mirrorPath","outputName","forkInstrumentation"]:["kind","sourceArtifact","mirrorPath","guestPath","mode"];if(!Ze(_,y))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} member ${g+1} has unknown or missing fields`);if(Ms(_.sourceArtifact,n,`${o} sourceArtifact`),Ms(_.mirrorPath,n,`${o} mirrorPath`),_.kind==="output"){if(typeof _.outputName!="string"||_.forkInstrumentation!=="auto"&&_.forkInstrumentation!=="disabled")throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} output member lacks outputName or forkInstrumentation`);Jr(_.outputName,n,`${o} outputName`)}else if(typeof _.guestPath!="string"||!_.guestPath.startsWith("/")||!Number.isInteger(_.mode)||_.mode<0||_.mode>511)throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} runtime member lacks valid guestPath or mode`);return _});if(p.length===0||new Set(p.map(h=>h.sourceArtifact)).size!==p.length||new Set(p.map(h=>h.mirrorPath)).size!==p.length||p.length===1&&p[0].mirrorPath.includes("/")||p.length>1&&p.some(h=>!h.mirrorPath.startsWith(`${o}/`)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} members are empty, collide, or violate scalar/package-directory layout`);let m=a.manifestSha256,f=t.get(o);if(!f||f.manifestSha256!==m||c.some(h=>f.cacheKeys[h]!==l[h]))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} does not match its contextual package identity`);i.set(o,{manifestSha256:m,arches:c,cacheKeys:l,dependencyClosures:u,members:p})}return{identities:t,packages:i,indexPath:n}}function Ys(n){return JSON.stringify({manifestSha256:n.manifestSha256,arches:n.arches,cacheKeys:Object.fromEntries(n.arches.map(e=>[e,n.cacheKeys[e]])),dependencyClosures:Object.fromEntries(n.arches.map(e=>[e,[...n.dependencyClosures[e]].sort((t,r)=>t.packageNamer.packageName?1:0)])),members:n.members.map(e=>e.kind==="output"?{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,outputName:e.outputName,forkInstrumentation:e.forkInstrumentation}:{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,guestPath:e.guestPath,mode:e.mode})})}function Oi(){let n=wi();return ae(n)?yi(n):null}function uu(n){let e=Oi();if(!e)return null;let t=n.split("/");if(t[0]!=="programs"||!ct.has(t[1]))return null;let r=t[1];if(t.length>=4){let s=t[2];return e.packages.get(s)?.arches.includes(r)?s:null}if(t.length!==3)return null;let i=t[2];for(let[s,o]of e.packages)if(o.arches.includes(r)&&o.members.some(a=>a.kind==="output"&&a.mirrorPath.split("/").at(-1)===i))return s;return null}function Bs(n){let e=uu(n);if(e)throw new Error(`Installed package resolver path ${JSON.stringify(n)} is owned by ${JSON.stringify(e)}, but that package is not selected by the configured program registry`)}function js(){let n=Zs(),e=new Map,t=new Map,r=new Map,i=new Map,s=[];if(n===null){let l=wi();if(!ae(l))return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:s};let d=yi(l);for(let[u,p]of d.identities)e.set(u,{...p,packageName:u,policyPath:`${d.indexPath}#identities.${u}`});for(let[u,p]of d.packages)s.push({packageName:u,projection:p,selected:!0}),r.set(u,{...p,packageName:u,policyPath:`${d.indexPath}#${u}`});return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:s}}let o=new Set,a=null,c=null;for(let l of n){if(!ae(l))continue;if(!Xe(l).isDirectory())throw new Error(`Program registry root is not a directory: ${l}`);let d=$(l,Fe);if(!ae(d))throw new Error(`Program registry ${l} is missing ${Fe}; generate it with xtask build-deps program-index`);let u=yi(d);a??=u.identities,c??=u.packages;let p=Gs(l,{withFileTypes:!0}).filter(m=>m.isDirectory()||m.isSymbolicLink()).sort((m,f)=>m.name.localeCompare(f.name));for(let m of p){let f=m.name,h=$(l,f,"package.toml");if(!ae(h))continue;let g=!1;try{g=Xe(h).isFile()}catch{g=!1}if(!g)continue;let _=u.packages.get(f),y=!o.has(f);if(_&&s.push({packageName:f,projection:_,selected:y}),!y)continue;o.add(f);let E=a.get(f);E?e.set(f,{...E,packageName:f,manifestPath:h,policyPath:h}):t.set(f,h);let A=c.get(f);if(!A){i.set(f,h);continue}r.set(f,{...A,packageName:f,manifestPath:h,policyPath:h})}}return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:s}}function $s(n){if(!n.manifestPath)return;let e;try{e=st(n.manifestPath)}catch(r){throw new Error(`Program package identity cannot verify ${n.manifestPath}: ${r instanceof Error?r.message:String(r)}`)}if(Hs("sha256").update(e).digest("hex")!==n.manifestSha256)throw new Error(`Program package identity is stale for ${n.manifestPath}; regenerate ${Fe}`)}function du(n){if(!n.manifestPath)return;let e;try{e=st(n.manifestPath)}catch(r){throw new Error(`Program package projection cannot verify ${n.manifestPath}: ${r instanceof Error?r.message:String(r)}`)}if(Hs("sha256").update(e).digest("hex")!==n.manifestSha256)throw new Error(`Program package projection is stale for ${n.manifestPath}; regenerate ${Fe}`)}function mr(n){let e=Ii(),t=e.packages.get(n);if(t)return du(t),t;let r=e.unprojectedPackages.get(n);if(r)throw new Error(`Package ${JSON.stringify(n)} is selected at ${r} but is absent from ${Fe}; regenerate the registry projection`);return null}function fu(n,e){let t=n.dependencyClosures[e];if(!t)throw bt(n.policyPath,`package ${JSON.stringify(n.packageName)} lacks a dependency identity closure for ${e}`);let r=js(),i=r.identities.get(n.packageName);if(!i){let o=r.unidentifiedPackages.get(n.packageName);throw new Error(`Program package ${JSON.stringify(n.packageName)} has no authoritative contextual identity for ${e}${o?` at ${o}`:""}; regenerate ${Fe} with the exact ordered registry roots`)}$s(i);let s=i.cacheKeys[e];if(i.manifestSha256!==n.manifestSha256||s!==n.cacheKeys[e])throw new Error(`Program package ${JSON.stringify(n.packageName)} was projected with manifest ${n.manifestSha256} and cache key ${n.cacheKeys[e]} for ${e}, but the authoritative first-hit registry context at ${i.policyPath} requires manifest ${i.manifestSha256} and cache key ${s??""}. Regenerate this program projection with the exact ordered registry roots; the highest-priority index must carry the complete combined-context projection rather than relying on a lower suffix-context build identity.`);for(let o of t){let a=r.identities.get(o.packageName);if(!a){let l=r.unidentifiedPackages.get(o.packageName);throw l?new Error(`Program package ${JSON.stringify(n.packageName)} was generated against dependency ${JSON.stringify(o.packageName)}, but the first-hit package at ${l} has no contextual identity in ${Fe}`):new Error(`Program package ${JSON.stringify(n.packageName)} was generated against dependency ${JSON.stringify(o.packageName)}, but that dependency is absent from the configured first-hit registry roots`)}$s(a);let c=a.cacheKeys[e];if(a.manifestSha256!==o.manifestSha256||c!==o.cacheKey)throw new Error(`Program package ${JSON.stringify(n.packageName)} has a contextual cache identity mismatch for ${e}: its projection expects dependency ${JSON.stringify(o.packageName)} manifest ${o.manifestSha256} and cache key ${o.cacheKey}, but first-hit selection at ${a.policyPath} provides manifest ${a.manifestSha256} and cache key ${c??""}. Regenerate the program projection with the exact ordered registry roots; the complete highest-priority projection must bind every selected program to the same combined dependency context.`)}}function Ii(){let n=js(),{physicalProgramClaims:e,...t}=n,r={...t,legacyFlatOutputs:new Map,forkInstrumentationDisabledOutputs:new Map},i=[];for(let s of n.packages.values()){let o=s.members.length>1;for(let a of s.arches)for(let c of s.members){let l=i.find(m=>m.arch===a&&(m.path===c.mirrorPath||m.path.startsWith(`${c.mirrorPath}/`)||c.mirrorPath.startsWith(`${m.path}/`)));if(l)throw new Error(`Program resolver paths programs/${a}/${l.path} and programs/${a}/${c.mirrorPath} conflict between selected packages ${JSON.stringify(l.packageName)} and ${JSON.stringify(s.packageName)}`);if(i.push({arch:a,path:c.mirrorPath,packageName:s.packageName}),c.kind!=="output")continue;let d=c.mirrorPath.split("/").at(-1),u=`${a}/${d}`,p=r.legacyFlatOutputs.get(u);p||(p={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},r.legacyFlatOutputs.set(u,p)),o?p.packagePaths.set(`programs/${a}/${c.mirrorPath}`,s.packageName):p.scalarOwners.add(s.packageName),c.forkInstrumentation==="disabled"&&r.forkInstrumentationDisabledOutputs.set(`${a}/${c.mirrorPath}`,s.packageName)}}for(let{packageName:s,projection:o,selected:a}of e)if(!(a&&n.packages.has(s)))for(let c of o.arches)for(let l of o.members){if(l.kind!=="output")continue;let d=l.mirrorPath.split("/").at(-1),u=`${c}/${d}`,p=r.legacyFlatOutputs.get(u);p||(p={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},r.legacyFlatOutputs.set(u,p)),p.shadowedOwners.add(s)}return r}function hu(n){let e=n.split("/");if(e.length!==3||e[0]!=="programs"||!ct.has(e[1]))return null;let t=Ii().legacyFlatOutputs.get(`${e[1]}/${e[2]}`);if(!t)return null;for(let r of t.scalarOwners){let i=mr(r);if(i)return i}for(let r of t.packagePaths.values())mr(r);if(t.packagePaths.size>0)throw new Error(`Legacy flat resolver path ${JSON.stringify(n)} belongs to a multi-member package; use ${[...t.packagePaths.keys()].sort().map(r=>JSON.stringify(r)).join(" or ")}`);for(let r of t.shadowedOwners){let i=mr(r);if(i)return i;throw new Error(`Legacy flat resolver path ${JSON.stringify(n)} is claimed by a lower-root program package ${JSON.stringify(r)}, but its first-hit selected package does not project that program; stale scalar mirror fallback is forbidden`)}return null}function Us(n,e,t){if(!n.arches.includes(e))throw bt(n.policyPath,`package ${JSON.stringify(n.packageName)} does not declare resolver artifacts for ${e}`);let r=n.cacheKeys[e];if(!r)throw bt(n.policyPath,`package ${JSON.stringify(n.packageName)} lacks a cache identity for ${e}`);fu(n,e);let i=Ys(n),s=n.members.map(o=>({packageName:n.packageName,relPath:`programs/${e}/${o.mirrorPath}`,sourceArtifact:o.sourceArtifact,cacheKey:r,forkInstrumentation:o.kind==="output"?o.forkInstrumentation??null:null,projectionIdentity:i}));if(!s.some(o=>o.relPath===t))throw bt(n.policyPath,`resolver path ${JSON.stringify(t)} is not a declared member of package ${JSON.stringify(n.packageName)}`);return{manifestPath:n.policyPath,packageName:n.packageName,members:s}}function pu(n){let e=Ye(n),t=e.split("/");if(t[0]==="programs"&&!tu()&&Oi()===null)throw new Error(`Installed host package is missing wasm/${Fe}; program artifacts cannot be resolved without packaged policy`);if(t.length===3){let o=hu(e);return o?Us(o,t[1],e):(Bs(e),null)}if(t.length<4||t[0]!=="programs"||!ct.has(t[1]))return null;let r=t[1],i=t[2],s=mr(i);return s?Us(s,r,e):(Bs(e),null)}function mu(n){let e=Ye(n);for(let t of["programs/wasm32/","programs/wasm64/"])if(e.startsWith(t))return e.slice(t.length);return null}function _u(n){let e=Ye(n);for(let t of ct){let r=`programs/${t}/`;if(e.startsWith(r)){let i=Ii().forkInstrumentationDisabledOutputs.get(`${t}/${e.slice(r.length)}`);return i?mr(i)!==null:!1}}return!1}function yu(n){let e=Ye(n);if(e==="kernel.wasm")return Qi;let t=mu(e);if(t&&t.endsWith(".wasm"))return Ql}function gu(n,e,t){if(!n.endsWith(".wasm"))return!1;try{let r=st(n),i=r.buffer.slice(r.byteOffset,r.byteOffset+r.byteLength),s=t===void 0?_u(e):t==="disabled";return uo(i,{expectedAbi:43,requiredExports:yu(e),requireForkInstrumentation:s?!1:void 0,forbidForkInstrumentation:s}).length>0}catch{return!0}}function Eu(n){if(!n.endsWith(".vfs")&&!n.endsWith(".vfs.zst"))return!1;try{let t=Yr.readImageMetadata(st(n))?.kernelAbi;return t!==void 0&&t!==43}catch{return!0}}function xi(n,e,t){return gu(n,e,t)||Eu(n)}function Js(n,e,t){let r=n.filter(ae);return r.length===0?null:r.find(i=>{try{return Xe(i).isFile()&&!xi(i,e,t)}catch{return!1}})??null}function Qs(n,e,t){try{if(!tn(n).isSymbolicLink())return n;let i=Ie(n);if(!Xe(i).isFile()||xi(i,e,t))throw new Error("canonical target is not an accepted regular file");if(Ye(e).startsWith("programs/")&&Su(i))throw new Error("resolver-owned program generation has no matching selected package projection");return i}catch(r){throw new Error(`Binary changed or became invalid while pinning ${e}: ${r instanceof Error?r.message:String(r)}`)}}function Su(n){let e=[Vs()];try{e.push($(at(),"local-binaries",".kandelo-local-generations"))}catch{}return e.some(t=>{try{return ae(t)&&vi(Ie(t),n)}catch{return!1}})}function vi(n,e){let t=Yl(n,e);return t===""||t!==".."&&!t.startsWith(`..${jl}`)&&!rn(t)}function wu(n,e){let t=e.split("/"),r=n;for(let i=0;ia.packageName!==s))return"declared package members do not share a valid program namespace";if(!Xe(e).isDirectory())return"shared package generation root is not a directory";let o=t[0].cacheKey;if(!/^[a-f0-9]{64}$/.test(o)||t.some(a=>a.cacheKey!==o))return"declared package members do not share one valid cache identity";if(n.identity==="local-generation"){let a=$(n.root,".kandelo-local-generations",i,s,o);if(!ae(a))return"local mirror targets are not one direct immutable local generation";let c=Ie(a);return _r(e)===c?null:"local mirror targets are not one direct immutable local generation"}if(n.identity==="program-cache"){let a=Vs();if(!ae(a))return"fetched mirror targets are not one canonical program-cache generation";let c=Ie(a),l=Xl(e),d=l.startsWith(`${s}-`)&&new RegExp(`-rev[0-9]+-${i}-${o}$`).test(l);return _r(e)===c&&d?null:"fetched mirror targets are not one canonical program-cache generation"}return"installed-package symlink closures are not an immutable installed identity"}function Ou(n,e,t){if(e.length!==t.length)return{failure:"internal member/path count mismatch"};try{let r=e.map(l=>{let d=tn(l);return d.isSymbolicLink()?"symlink":d.isFile()?"file":"other"});if(r.includes("other"))return{failure:"a selected mirror member is neither a regular file nor a symlink"};let i=r.every(l=>l==="symlink"),s=r.every(l=>l==="file");if(!i&&!s)return{failure:"regular files and symlinks cannot share one package identity"};if(s){if(!n.allowRegularFileClosure)return{failure:"a mutable source-checkout wasm tree is not an installed package identity"};let l=t[0].packageName,d=t[0].projectionIdentity;if(t.some(h=>h.packageName!==l||h.projectionIdentity!==d))return{failure:"declared members do not share one selected package projection"};let p=Oi()?.packages.get(l);if(!p||Ys(p)!==d)return{failure:"installed bytes do not match the selected package projection"};let m=Ie(n.root),f=[];for(let h of e){let g=Ie(h);if(!vi(m,g)||!Xe(g).isFile())return{failure:"an installed-package member escapes its immutable wasm tree"};f.push(g)}return{paths:f}}let o=null,a=[];for(let l=0;lIu(n))}function Iu(n){let e=Ye(n),t=pu(e);if(t){let o=xu(t.members.map(a=>a.relPath),t.members);if(o)return o[t.members.findIndex(a=>a.relPath===e)];throw new en(`Package artifacts not found for ${t.packageName}: ${e}`)}let r=[],i=[];for(let o of qs())for(let a of o.candidatesFor(n))r.push(a),i.push(a);let s=Js(i,n);if(s)return Qs(s,n);throw i.some(ae)?new Error(`Binary exists but was rejected by artifact policy: ${n} +${Ai(t,r,i)}`)}function lu(n,e){if(mi>0||!n.some(t=>t.startsWith("programs/")))return e();mi+=1;try{return cu(),e()}finally{mi-=1}}function Ze(n,e){let t=Object.keys(n).sort(),r=[...e].sort();return t.length===r.length&&t.every((i,s)=>i===r[s])}function yi(n){let e;try{e=JSON.parse(st(n,"utf8"))}catch(o){throw new Error(`Invalid program package index ${n}: ${o instanceof Error?o.message:String(o)}`)}if(typeof e!="object"||e===null||!Ze(e,["format","identities","packages"])||e.format!==Ds||typeof e.identities!="object"||e.identities===null||Array.isArray(e.identities)||typeof e.packages!="object"||e.packages===null||Array.isArray(e.packages))throw new Error(`Invalid program package index ${n}: expected ${Ds}`);let t=new Map,r=e.identities;for(let[o,a]of Object.entries(r)){if(Jr(o,n,"identity package name",!1),typeof a!="object"||a===null||!Ze(a,["manifestSha256","cacheKeys"])||typeof a.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(a.manifestSha256)||typeof a.cacheKeys!="object"||a.cacheKeys===null||Array.isArray(a.cacheKeys))throw new Error(`Invalid program package index ${n}: malformed identity ${JSON.stringify(o)}`);let c=a.cacheKeys;if(!Ze(c,["wasm32","wasm64"])||Object.values(c).some(l=>typeof l!="string"||!/^[a-f0-9]{64}$/.test(l)))throw new Error(`Invalid program package index ${n}: identity ${JSON.stringify(o)} has invalid contextual cache keys`);t.set(o,{manifestSha256:a.manifestSha256,cacheKeys:c})}let i=new Map,s=e.packages;for(let[o,a]of Object.entries(s)){if(Jr(o,n,"package name",!1),typeof a!="object"||a===null||!Ze(a,["manifestSha256","arches","cacheKeys","dependencyClosures","members"])||!Array.isArray(a.arches)||typeof a.cacheKeys!="object"||a.cacheKeys===null||Array.isArray(a.cacheKeys)||typeof a.dependencyClosures!="object"||a.dependencyClosures===null||Array.isArray(a.dependencyClosures)||!Array.isArray(a.members)||typeof a.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(a.manifestSha256))throw new Error(`Invalid program package index ${n}: malformed package ${JSON.stringify(o)}`);let c=a.arches;if(c.length===0||new Set(c).size!==c.length||c.some(h=>typeof h!="string"||!ct.has(h)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has invalid arches`);let l=a.cacheKeys;if(!Ze(l,c)||Object.values(l).some(h=>typeof h!="string"||!/^[a-f0-9]{64}$/.test(h)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has invalid cache keys`);let d=a.dependencyClosures;if(!Ze(d,c))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has invalid dependency closure arches`);let u={};for(let h of c){let g=d[h];if(!Array.isArray(g))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has a malformed dependency closure for ${h}`);let _=new Set;u[h]=g.map((y,E)=>{if(typeof y!="object"||y===null||!Ze(y,["packageName","manifestSha256","cacheKey"])||typeof y.packageName!="string"||typeof y.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(y.manifestSha256)||typeof y.cacheKey!="string"||!/^[a-f0-9]{64}$/.test(y.cacheKey))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} dependency ${E+1} for ${h} is malformed`);let A=y;if(Jr(A.packageName,n,`${o} dependency packageName`,!1),A.packageName===o||_.has(A.packageName))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} dependency closure for ${h} must contain unique dependencies other than itself`);_.add(A.packageName);let w=t.get(A.packageName);if(!w||w.manifestSha256!==A.manifestSha256||w.cacheKeys[h]!==A.cacheKey)throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} dependency ${JSON.stringify(A.packageName)} for ${h} does not match the index's authoritative contextual identity`);return A})}let p=a.members.map((h,g)=>{if(typeof h!="object"||h===null||h.kind!=="output"&&h.kind!=="runtime-file"||typeof h.sourceArtifact!="string"||typeof h.mirrorPath!="string")throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} member ${g+1} is malformed`);let _=h,y=_.kind==="output"?["kind","sourceArtifact","mirrorPath","outputName","forkInstrumentation"]:["kind","sourceArtifact","mirrorPath","guestPath","mode"];if(!Ze(_,y))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} member ${g+1} has unknown or missing fields`);if(Ms(_.sourceArtifact,n,`${o} sourceArtifact`),Ms(_.mirrorPath,n,`${o} mirrorPath`),_.kind==="output"){if(typeof _.outputName!="string"||_.forkInstrumentation!=="auto"&&_.forkInstrumentation!=="disabled")throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} output member lacks outputName or forkInstrumentation`);Jr(_.outputName,n,`${o} outputName`)}else if(typeof _.guestPath!="string"||!_.guestPath.startsWith("/")||!Number.isInteger(_.mode)||_.mode<0||_.mode>511)throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} runtime member lacks valid guestPath or mode`);return _});if(p.length===0||new Set(p.map(h=>h.sourceArtifact)).size!==p.length||new Set(p.map(h=>h.mirrorPath)).size!==p.length||p.length===1&&p[0].mirrorPath.includes("/")||p.length>1&&p.some(h=>!h.mirrorPath.startsWith(`${o}/`)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} members are empty, collide, or violate scalar/package-directory layout`);let m=a.manifestSha256,f=t.get(o);if(!f||f.manifestSha256!==m||c.some(h=>f.cacheKeys[h]!==l[h]))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} does not match its contextual package identity`);i.set(o,{manifestSha256:m,arches:c,cacheKeys:l,dependencyClosures:u,members:p})}return{identities:t,packages:i,indexPath:n}}function Ys(n){return JSON.stringify({manifestSha256:n.manifestSha256,arches:n.arches,cacheKeys:Object.fromEntries(n.arches.map(e=>[e,n.cacheKeys[e]])),dependencyClosures:Object.fromEntries(n.arches.map(e=>[e,[...n.dependencyClosures[e]].sort((t,r)=>t.packageNamer.packageName?1:0)])),members:n.members.map(e=>e.kind==="output"?{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,outputName:e.outputName,forkInstrumentation:e.forkInstrumentation}:{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,guestPath:e.guestPath,mode:e.mode})})}function Oi(){let n=wi();return ae(n)?yi(n):null}function uu(n){let e=Oi();if(!e)return null;let t=n.split("/");if(t[0]!=="programs"||!ct.has(t[1]))return null;let r=t[1];if(t.length>=4){let s=t[2];return e.packages.get(s)?.arches.includes(r)?s:null}if(t.length!==3)return null;let i=t[2];for(let[s,o]of e.packages)if(o.arches.includes(r)&&o.members.some(a=>a.kind==="output"&&a.mirrorPath.split("/").at(-1)===i))return s;return null}function Bs(n){let e=uu(n);if(e)throw new Error(`Installed package resolver path ${JSON.stringify(n)} is owned by ${JSON.stringify(e)}, but that package is not selected by the configured program registry`)}function js(){let n=Zs(),e=new Map,t=new Map,r=new Map,i=new Map,s=[];if(n===null){let l=wi();if(!ae(l))return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:s};let d=yi(l);for(let[u,p]of d.identities)e.set(u,{...p,packageName:u,policyPath:`${d.indexPath}#identities.${u}`});for(let[u,p]of d.packages)s.push({packageName:u,projection:p,selected:!0}),r.set(u,{...p,packageName:u,policyPath:`${d.indexPath}#${u}`});return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:s}}let o=new Set,a=null,c=null;for(let l of n){if(!ae(l))continue;if(!Xe(l).isDirectory())throw new Error(`Program registry root is not a directory: ${l}`);let d=$(l,Fe);if(!ae(d))throw new Error(`Program registry ${l} is missing ${Fe}; generate it with xtask build-deps program-index`);let u=yi(d);a??=u.identities,c??=u.packages;let p=Gs(l,{withFileTypes:!0}).filter(m=>m.isDirectory()||m.isSymbolicLink()).sort((m,f)=>m.name.localeCompare(f.name));for(let m of p){let f=m.name,h=$(l,f,"package.toml");if(!ae(h))continue;let g=!1;try{g=Xe(h).isFile()}catch{g=!1}if(!g)continue;let _=u.packages.get(f),y=!o.has(f);if(_&&s.push({packageName:f,projection:_,selected:y}),!y)continue;o.add(f);let E=a.get(f);E?e.set(f,{...E,packageName:f,manifestPath:h,policyPath:h}):t.set(f,h);let A=c.get(f);if(!A){i.set(f,h);continue}r.set(f,{...A,packageName:f,manifestPath:h,policyPath:h})}}return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:s}}function $s(n){if(!n.manifestPath)return;let e;try{e=st(n.manifestPath)}catch(r){throw new Error(`Program package identity cannot verify ${n.manifestPath}: ${r instanceof Error?r.message:String(r)}`)}if(Hs("sha256").update(e).digest("hex")!==n.manifestSha256)throw new Error(`Program package identity is stale for ${n.manifestPath}; regenerate ${Fe}`)}function du(n){if(!n.manifestPath)return;let e;try{e=st(n.manifestPath)}catch(r){throw new Error(`Program package projection cannot verify ${n.manifestPath}: ${r instanceof Error?r.message:String(r)}`)}if(Hs("sha256").update(e).digest("hex")!==n.manifestSha256)throw new Error(`Program package projection is stale for ${n.manifestPath}; regenerate ${Fe}`)}function mr(n){let e=Ii(),t=e.packages.get(n);if(t)return du(t),t;let r=e.unprojectedPackages.get(n);if(r)throw new Error(`Package ${JSON.stringify(n)} is selected at ${r} but is absent from ${Fe}; regenerate the registry projection`);return null}function fu(n,e){let t=n.dependencyClosures[e];if(!t)throw Lt(n.policyPath,`package ${JSON.stringify(n.packageName)} lacks a dependency identity closure for ${e}`);let r=js(),i=r.identities.get(n.packageName);if(!i){let o=r.unidentifiedPackages.get(n.packageName);throw new Error(`Program package ${JSON.stringify(n.packageName)} has no authoritative contextual identity for ${e}${o?` at ${o}`:""}; regenerate ${Fe} with the exact ordered registry roots`)}$s(i);let s=i.cacheKeys[e];if(i.manifestSha256!==n.manifestSha256||s!==n.cacheKeys[e])throw new Error(`Program package ${JSON.stringify(n.packageName)} was projected with manifest ${n.manifestSha256} and cache key ${n.cacheKeys[e]} for ${e}, but the authoritative first-hit registry context at ${i.policyPath} requires manifest ${i.manifestSha256} and cache key ${s??""}. Regenerate this program projection with the exact ordered registry roots; the highest-priority index must carry the complete combined-context projection rather than relying on a lower suffix-context build identity.`);for(let o of t){let a=r.identities.get(o.packageName);if(!a){let l=r.unidentifiedPackages.get(o.packageName);throw l?new Error(`Program package ${JSON.stringify(n.packageName)} was generated against dependency ${JSON.stringify(o.packageName)}, but the first-hit package at ${l} has no contextual identity in ${Fe}`):new Error(`Program package ${JSON.stringify(n.packageName)} was generated against dependency ${JSON.stringify(o.packageName)}, but that dependency is absent from the configured first-hit registry roots`)}$s(a);let c=a.cacheKeys[e];if(a.manifestSha256!==o.manifestSha256||c!==o.cacheKey)throw new Error(`Program package ${JSON.stringify(n.packageName)} has a contextual cache identity mismatch for ${e}: its projection expects dependency ${JSON.stringify(o.packageName)} manifest ${o.manifestSha256} and cache key ${o.cacheKey}, but first-hit selection at ${a.policyPath} provides manifest ${a.manifestSha256} and cache key ${c??""}. Regenerate the program projection with the exact ordered registry roots; the complete highest-priority projection must bind every selected program to the same combined dependency context.`)}}function Ii(){let n=js(),{physicalProgramClaims:e,...t}=n,r={...t,legacyFlatOutputs:new Map,forkInstrumentationDisabledOutputs:new Map},i=[];for(let s of n.packages.values()){let o=s.members.length>1;for(let a of s.arches)for(let c of s.members){let l=i.find(m=>m.arch===a&&(m.path===c.mirrorPath||m.path.startsWith(`${c.mirrorPath}/`)||c.mirrorPath.startsWith(`${m.path}/`)));if(l)throw new Error(`Program resolver paths programs/${a}/${l.path} and programs/${a}/${c.mirrorPath} conflict between selected packages ${JSON.stringify(l.packageName)} and ${JSON.stringify(s.packageName)}`);if(i.push({arch:a,path:c.mirrorPath,packageName:s.packageName}),c.kind!=="output")continue;let d=c.mirrorPath.split("/").at(-1),u=`${a}/${d}`,p=r.legacyFlatOutputs.get(u);p||(p={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},r.legacyFlatOutputs.set(u,p)),o?p.packagePaths.set(`programs/${a}/${c.mirrorPath}`,s.packageName):p.scalarOwners.add(s.packageName),c.forkInstrumentation==="disabled"&&r.forkInstrumentationDisabledOutputs.set(`${a}/${c.mirrorPath}`,s.packageName)}}for(let{packageName:s,projection:o,selected:a}of e)if(!(a&&n.packages.has(s)))for(let c of o.arches)for(let l of o.members){if(l.kind!=="output")continue;let d=l.mirrorPath.split("/").at(-1),u=`${c}/${d}`,p=r.legacyFlatOutputs.get(u);p||(p={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},r.legacyFlatOutputs.set(u,p)),p.shadowedOwners.add(s)}return r}function hu(n){let e=n.split("/");if(e.length!==3||e[0]!=="programs"||!ct.has(e[1]))return null;let t=Ii().legacyFlatOutputs.get(`${e[1]}/${e[2]}`);if(!t)return null;for(let r of t.scalarOwners){let i=mr(r);if(i)return i}for(let r of t.packagePaths.values())mr(r);if(t.packagePaths.size>0)throw new Error(`Legacy flat resolver path ${JSON.stringify(n)} belongs to a multi-member package; use ${[...t.packagePaths.keys()].sort().map(r=>JSON.stringify(r)).join(" or ")}`);for(let r of t.shadowedOwners){let i=mr(r);if(i)return i;throw new Error(`Legacy flat resolver path ${JSON.stringify(n)} is claimed by a lower-root program package ${JSON.stringify(r)}, but its first-hit selected package does not project that program; stale scalar mirror fallback is forbidden`)}return null}function Us(n,e,t){if(!n.arches.includes(e))throw Lt(n.policyPath,`package ${JSON.stringify(n.packageName)} does not declare resolver artifacts for ${e}`);let r=n.cacheKeys[e];if(!r)throw Lt(n.policyPath,`package ${JSON.stringify(n.packageName)} lacks a cache identity for ${e}`);fu(n,e);let i=Ys(n),s=n.members.map(o=>({packageName:n.packageName,relPath:`programs/${e}/${o.mirrorPath}`,sourceArtifact:o.sourceArtifact,cacheKey:r,forkInstrumentation:o.kind==="output"?o.forkInstrumentation??null:null,projectionIdentity:i}));if(!s.some(o=>o.relPath===t))throw Lt(n.policyPath,`resolver path ${JSON.stringify(t)} is not a declared member of package ${JSON.stringify(n.packageName)}`);return{manifestPath:n.policyPath,packageName:n.packageName,members:s}}function pu(n){let e=Ye(n),t=e.split("/");if(t[0]==="programs"&&!tu()&&Oi()===null)throw new Error(`Installed host package is missing wasm/${Fe}; program artifacts cannot be resolved without packaged policy`);if(t.length===3){let o=hu(e);return o?Us(o,t[1],e):(Bs(e),null)}if(t.length<4||t[0]!=="programs"||!ct.has(t[1]))return null;let r=t[1],i=t[2],s=mr(i);return s?Us(s,r,e):(Bs(e),null)}function mu(n){let e=Ye(n);for(let t of["programs/wasm32/","programs/wasm64/"])if(e.startsWith(t))return e.slice(t.length);return null}function _u(n){let e=Ye(n);for(let t of ct){let r=`programs/${t}/`;if(e.startsWith(r)){let i=Ii().forkInstrumentationDisabledOutputs.get(`${t}/${e.slice(r.length)}`);return i?mr(i)!==null:!1}}return!1}function yu(n){let e=Ye(n);if(e==="kernel.wasm")return Qi;let t=mu(e);if(t&&t.endsWith(".wasm"))return Ql}function gu(n,e,t){if(!n.endsWith(".wasm"))return!1;try{let r=st(n),i=r.buffer.slice(r.byteOffset,r.byteOffset+r.byteLength),s=t===void 0?_u(e):t==="disabled";return uo(i,{expectedAbi:43,requiredExports:yu(e),requireForkInstrumentation:s?!1:void 0,forbidForkInstrumentation:s}).length>0}catch{return!0}}function Eu(n){if(!n.endsWith(".vfs")&&!n.endsWith(".vfs.zst"))return!1;try{let t=Yr.readImageMetadata(st(n))?.kernelAbi;return t!==void 0&&t!==43}catch{return!0}}function xi(n,e,t){return gu(n,e,t)||Eu(n)}function Js(n,e,t){let r=n.filter(ae);return r.length===0?null:r.find(i=>{try{return Xe(i).isFile()&&!xi(i,e,t)}catch{return!1}})??null}function Qs(n,e,t){try{if(!tn(n).isSymbolicLink())return n;let i=Ie(n);if(!Xe(i).isFile()||xi(i,e,t))throw new Error("canonical target is not an accepted regular file");if(Ye(e).startsWith("programs/")&&Su(i))throw new Error("resolver-owned program generation has no matching selected package projection");return i}catch(r){throw new Error(`Binary changed or became invalid while pinning ${e}: ${r instanceof Error?r.message:String(r)}`)}}function Su(n){let e=[Vs()];try{e.push($(at(),"local-binaries",".kandelo-local-generations"))}catch{}return e.some(t=>{try{return ae(t)&&Ri(Ie(t),n)}catch{return!1}})}function Ri(n,e){let t=Yl(n,e);return t===""||t!==".."&&!t.startsWith(`..${jl}`)&&!rn(t)}function wu(n,e){let t=e.split("/"),r=n;for(let i=0;ia.packageName!==s))return"declared package members do not share a valid program namespace";if(!Xe(e).isDirectory())return"shared package generation root is not a directory";let o=t[0].cacheKey;if(!/^[a-f0-9]{64}$/.test(o)||t.some(a=>a.cacheKey!==o))return"declared package members do not share one valid cache identity";if(n.identity==="local-generation"){let a=$(n.root,".kandelo-local-generations",i,s,o);if(!ae(a))return"local mirror targets are not one direct immutable local generation";let c=Ie(a);return _r(e)===c?null:"local mirror targets are not one direct immutable local generation"}if(n.identity==="program-cache"){let a=Vs();if(!ae(a))return"fetched mirror targets are not one canonical program-cache generation";let c=Ie(a),l=Xl(e),d=l.startsWith(`${s}-`)&&new RegExp(`-rev[0-9]+-${i}-${o}$`).test(l);return _r(e)===c&&d?null:"fetched mirror targets are not one canonical program-cache generation"}return"installed-package symlink closures are not an immutable installed identity"}function Ou(n,e,t){if(e.length!==t.length)return{failure:"internal member/path count mismatch"};try{let r=e.map(l=>{let d=tn(l);return d.isSymbolicLink()?"symlink":d.isFile()?"file":"other"});if(r.includes("other"))return{failure:"a selected mirror member is neither a regular file nor a symlink"};let i=r.every(l=>l==="symlink"),s=r.every(l=>l==="file");if(!i&&!s)return{failure:"regular files and symlinks cannot share one package identity"};if(s){if(!n.allowRegularFileClosure)return{failure:"a mutable source-checkout wasm tree is not an installed package identity"};let l=t[0].packageName,d=t[0].projectionIdentity;if(t.some(h=>h.packageName!==l||h.projectionIdentity!==d))return{failure:"declared members do not share one selected package projection"};let p=Oi()?.packages.get(l);if(!p||Ys(p)!==d)return{failure:"installed bytes do not match the selected package projection"};let m=Ie(n.root),f=[];for(let h of e){let g=Ie(h);if(!Ri(m,g)||!Xe(g).isFile())return{failure:"an installed-package member escapes its immutable wasm tree"};f.push(g)}return{paths:f}}let o=null,a=[];for(let l=0;lIu(n))}function Iu(n){let e=Ye(n),t=pu(e);if(t){let o=xu(t.members.map(a=>a.relPath),t.members);if(o)return o[t.members.findIndex(a=>a.relPath===e)];throw new en(`Package artifacts not found for ${t.packageName}: ${e}`)}let r=[],i=[];for(let o of qs())for(let a of o.candidatesFor(n))r.push(a),i.push(a);let s=Js(i,n);if(s)return Qs(s,n);throw i.some(ae)?new Error(`Binary exists but was rejected by artifact policy: ${n} `+r.map(o=>` checked: ${o}`).join(` `)):new en(`Binary not found: ${n} `+r.map(o=>` checked: ${o}`).join(` `)+` Run scripts/fetch-binaries.sh, place a file at local-binaries/${e}, or install a package that includes wasm/${n}.`)}function xu(n,e){if(n.length===0)return[];let t=!1,r=[];for(let i of qs()){let s=[],o=[];if(e){let[a,c,l]=e[0].relPath.split("/");a==="programs"&&c&&l&&(t||=ae($(i.root,a,c,l)))}for(let[a,c]of n.entries()){let l=i.candidatesFor(c),d=l.filter(ae);t||=d.length>0;let u=Js(l,c,e?.[a]?.forkInstrumentation);u?s.push(u):d.length>0?o.push(`${c} (rejected by artifact policy)`):o.push(`${c} (missing)`)}if(o.length===0&&e){let a=Ou(i,s,e);if("failure"in a)o.push(`shared package identity rejected: ${a.failure}`);else{let c=a.paths.flatMap((l,d)=>xi(l,n[d],e[d].forkInstrumentation)?[n[d]]:[]);if(c.length>0)o.push(`pinned package generation rejected by artifact policy: ${c.join(", ")}`);else return a.paths}}if(o.length===0)return s.map((a,c)=>Qs(a,n[c],e?.[c]?.forkInstrumentation));r.push(` ${i.label} (${i.root}): ${o.join(", ")}`)}if(!t)return null;throw new Error(`Package artifact closure is incomplete: no single provenance tier contains every accepted artifact, and tiers will not be mixed. `+r.join(` -`))}var[ta,...vu]=process.argv.slice(2);(!ta||vu.length>0)&&(console.error("usage: scripts/resolve-binary.sh "),process.exit(2));try{process.stdout.write(`${ea(ta)} +`))}var[ta,...Ru]=process.argv.slice(2);(!ta||Ru.length>0)&&(console.error("usage: scripts/resolve-binary.sh "),process.exit(2));try{process.stdout.write(`${ea(ta)} `)}catch(n){console.error(n instanceof Error?n.message:String(n)),process.exit(1)} diff --git a/tests/abi/process-native-layouts.c b/tests/abi/process-native-layouts.c index 1e55f7a34f..4e01fbd683 100644 --- a/tests/abi/process-native-layouts.c +++ b/tests/abi/process-native-layouts.c @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -104,6 +105,16 @@ _Static_assert(CMSG_SPACE(KANDELO_SCM_RIGHTS_FD_BYTES) == KANDELO_PROCESS_CMSGHDR_WASM32_DATA_OFFSET + KANDELO_PROCESS_CMSGHDR_WASM32_ALIGN, "generated wasm32 SCM_RIGHTS one-fd space"); +_Static_assert(sizeof(struct group_req) == + KANDELO_PROCESS_GROUP_REQ_WASM32_SIZE, + "generated wasm32 group_req size"); +ASSERT_OFFSET(struct group_req, gr_group, + KANDELO_PROCESS_GROUP_REQ_WASM32_GROUP_OFFSET); +_Static_assert(sizeof(struct group_source_req) == + KANDELO_PROCESS_GROUP_SOURCE_REQ_WASM32_SIZE, + "generated wasm32 group_source_req size"); +ASSERT_OFFSET(struct group_source_req, gsr_source, + KANDELO_PROCESS_GROUP_SOURCE_REQ_WASM32_SOURCE_OFFSET); _Static_assert(sizeof(stack_t) == 12, "wasm32 stack_t size"); ASSERT_OFFSET(stack_t, ss_sp, 0); @@ -237,6 +248,16 @@ _Static_assert(CMSG_SPACE(KANDELO_SCM_RIGHTS_FD_BYTES) == KANDELO_PROCESS_CMSGHDR_WASM64_DATA_OFFSET + KANDELO_PROCESS_CMSGHDR_WASM64_ALIGN, "generated wasm64 SCM_RIGHTS one-fd space"); +_Static_assert(sizeof(struct group_req) == + KANDELO_PROCESS_GROUP_REQ_WASM64_SIZE, + "generated wasm64 group_req size"); +ASSERT_OFFSET(struct group_req, gr_group, + KANDELO_PROCESS_GROUP_REQ_WASM64_GROUP_OFFSET); +_Static_assert(sizeof(struct group_source_req) == + KANDELO_PROCESS_GROUP_SOURCE_REQ_WASM64_SIZE, + "generated wasm64 group_source_req size"); +ASSERT_OFFSET(struct group_source_req, gsr_source, + KANDELO_PROCESS_GROUP_SOURCE_REQ_WASM64_SOURCE_OFFSET); _Static_assert(sizeof(stack_t) == 24, "wasm64 stack_t size"); ASSERT_OFFSET(stack_t, ss_sp, 0); diff --git a/tools/xtask/src/dump_abi.rs b/tools/xtask/src/dump_abi.rs index 63b5a0b35a..7b697725e0 100644 --- a/tools/xtask/src/dump_abi.rs +++ b/tools/xtask/src/dump_abi.rs @@ -205,6 +205,8 @@ fn render_platform_limits_header() -> String { #define KANDELO_POSIX_PATH_MAX_BYTES {path_max}u\n\ #define KANDELO_POSIX_IOV_MAX {iov_max}u\n\ #define KANDELO_PROCESS_METADATA_ENTRY_MAX_BYTES {metadata_entry_max}u\n\ + #define KANDELO_PROCESS_STARTUP_MAX_ARGV_COUNT {startup_argv_max}u\n\ + #define KANDELO_PROCESS_STARTUP_MAX_ENVP_COUNT {startup_envp_max}u\n\ #define KANDELO_MAX_REPORTABLE_TRANSFER_BYTES {reportable_max}u\n\ \n\ #endif /* KANDELO_PLATFORM_LIMITS_H */\n", @@ -212,6 +214,8 @@ fn render_platform_limits_header() -> String { path_max = platform_limits::PATH_MAX_BYTES, iov_max = platform_limits::IOV_MAX, metadata_entry_max = platform_limits::PROCESS_METADATA_ENTRY_MAX_BYTES, + startup_argv_max = platform_limits::PROCESS_STARTUP_MAX_ARGV_COUNT, + startup_envp_max = platform_limits::PROCESS_STARTUP_MAX_ENVP_COUNT, reportable_max = platform_limits::MAX_REPORTABLE_TRANSFER_BYTES, ) } @@ -245,7 +249,9 @@ fn render_channel_scalars_header() -> String { } fn render_process_layouts_header() -> String { - use shared::process_layout::{cmsghdr, iovec, msghdr, rt_sigqueueinfo, sigevent}; + use shared::process_layout::{ + cmsghdr, iovec, msghdr, multicast_group_request, rt_sigqueueinfo, sigevent, + }; format!( "/* GENERATED by `cargo xtask dump-abi`. Do not edit by hand. */\n\ @@ -290,6 +296,15 @@ fn render_process_layouts_header() -> String { #define KANDELO_PROCESS_CMSGHDR_WASM64_TYPE_OFFSET {cmsg64_type}u\n\ #define KANDELO_PROCESS_CMSGHDR_WASM64_DATA_OFFSET {cmsg64_data}u\n\ \n\ + #define KANDELO_PROCESS_GROUP_REQ_WASM32_SIZE {group_req32_size}u\n\ + #define KANDELO_PROCESS_GROUP_REQ_WASM32_GROUP_OFFSET {group32_offset}u\n\ + #define KANDELO_PROCESS_GROUP_SOURCE_REQ_WASM32_SIZE {group_source_req32_size}u\n\ + #define KANDELO_PROCESS_GROUP_SOURCE_REQ_WASM32_SOURCE_OFFSET {source32_offset}u\n\ + #define KANDELO_PROCESS_GROUP_REQ_WASM64_SIZE {group_req64_size}u\n\ + #define KANDELO_PROCESS_GROUP_REQ_WASM64_GROUP_OFFSET {group64_offset}u\n\ + #define KANDELO_PROCESS_GROUP_SOURCE_REQ_WASM64_SIZE {group_source_req64_size}u\n\ + #define KANDELO_PROCESS_GROUP_SOURCE_REQ_WASM64_SOURCE_OFFSET {source64_offset}u\n\ + \n\ #define KANDELO_PROCESS_SIGINFO_SIGNO_OFFSET {siginfo_signo}u\n\ #define KANDELO_PROCESS_SIGINFO_ERRNO_OFFSET {siginfo_errno}u\n\ #define KANDELO_PROCESS_SIGINFO_CODE_OFFSET {siginfo_code}u\n\ @@ -369,6 +384,16 @@ fn render_process_layouts_header() -> String { cmsg64_level = cmsghdr::WASM64_LEVEL_OFFSET, cmsg64_type = cmsghdr::WASM64_TYPE_OFFSET, cmsg64_data = cmsghdr::WASM64_DATA_OFFSET, + group_req32_size = multicast_group_request::WASM32_GROUP_REQ_SIZE, + group32_offset = multicast_group_request::WASM32_GROUP_OFFSET, + group_source_req32_size = + multicast_group_request::WASM32_GROUP_SOURCE_REQ_SIZE, + source32_offset = multicast_group_request::WASM32_SOURCE_OFFSET, + group_req64_size = multicast_group_request::WASM64_GROUP_REQ_SIZE, + group64_offset = multicast_group_request::WASM64_GROUP_OFFSET, + group_source_req64_size = + multicast_group_request::WASM64_GROUP_SOURCE_REQ_SIZE, + source64_offset = multicast_group_request::WASM64_SOURCE_OFFSET, siginfo_signo = rt_sigqueueinfo::SIGNO_OFFSET, siginfo_errno = rt_sigqueueinfo::ERRNO_OFFSET, siginfo_code = rt_sigqueueinfo::CODE_OFFSET, @@ -1816,6 +1841,54 @@ fn render_ts_module() -> String { "export const SCHED_AFFINITY_MASK_SIZE = {} as const;\n\n", shared::SCHED_AFFINITY_MASK_SIZE )); + out.push_str(&format!( + "export const PROCESS_SNAPSHOT_COUNT_OFFSET = {} as const;\n", + shared::process_snapshot_wire::COUNT_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_SNAPSHOT_COUNT_BYTES = {} as const;\n", + shared::process_snapshot_wire::COUNT_BYTES + )); + out.push_str(&format!( + "export const PROCESS_SNAPSHOT_RECORDS_OFFSET = {} as const;\n", + shared::process_snapshot_wire::RECORDS_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_SNAPSHOT_HEADER_BYTES = {} as const;\n", + shared::process_snapshot_wire::HEADER_BYTES + )); + out.push_str(&format!( + "export const PROCESS_SNAPSHOT_PID_OFFSET = {} as const;\n", + shared::process_snapshot_wire::PID_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_SNAPSHOT_PPID_OFFSET = {} as const;\n", + shared::process_snapshot_wire::PPID_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_SNAPSHOT_UID_OFFSET = {} as const;\n", + shared::process_snapshot_wire::UID_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_SNAPSHOT_GID_OFFSET = {} as const;\n", + shared::process_snapshot_wire::GID_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_SNAPSHOT_VSIZE_OFFSET = {} as const;\n", + shared::process_snapshot_wire::VSIZE_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_SNAPSHOT_STATE_OFFSET = {} as const;\n", + shared::process_snapshot_wire::STATE_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_SNAPSHOT_COMM_LEN_OFFSET = {} as const;\n", + shared::process_snapshot_wire::COMM_LEN_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_SNAPSHOT_CMDLINE_LEN_OFFSET = {} as const;\n\n", + shared::process_snapshot_wire::CMDLINE_LEN_OFFSET + )); out.push_str(&format!( "export const KERNEL_SCRATCH_SIGNAL_DELIVERY_BYTES = {} as const;\n", shared::kernel_scratch_wire::SIGNAL_DELIVERY_BYTES @@ -1892,6 +1965,22 @@ fn render_ts_module() -> String { "export const PROCESS_METADATA_ENTRY_MAX_BYTES = {} as const;\n", shared::platform_limits::PROCESS_METADATA_ENTRY_MAX_BYTES )); + out.push_str(&format!( + "export const PROCESS_STARTUP_MAX_ARGV_COUNT = {} as const;\n", + shared::platform_limits::PROCESS_STARTUP_MAX_ARGV_COUNT + )); + out.push_str(&format!( + "export const PROCESS_STARTUP_MAX_ENVP_COUNT = {} as const;\n", + shared::platform_limits::PROCESS_STARTUP_MAX_ENVP_COUNT + )); + out.push_str(&format!( + "export const PROCESS_METADATA_KIND_ARGV = {} as const;\n", + shared::process_metadata_contract::KIND_ARGV + )); + out.push_str(&format!( + "export const PROCESS_METADATA_KIND_ENVIRONMENT = {} as const;\n", + shared::process_metadata_contract::KIND_ENVIRONMENT + )); out.push_str(&format!( "export const POSIX_NGROUPS_MAX = {} as const;\n", shared::platform_limits::NGROUPS_MAX @@ -2056,6 +2145,38 @@ fn render_ts_module() -> String { "export const PROCESS_CMSGHDR_WASM64_DATA_OFFSET = {} as const;\n", shared::process_layout::cmsghdr::WASM64_DATA_OFFSET )); + out.push_str(&format!( + "export const PROCESS_GROUP_REQ_WASM32_SIZE = {} as const;\n", + shared::process_layout::multicast_group_request::WASM32_GROUP_REQ_SIZE + )); + out.push_str(&format!( + "export const PROCESS_GROUP_REQ_WASM32_GROUP_OFFSET = {} as const;\n", + shared::process_layout::multicast_group_request::WASM32_GROUP_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_GROUP_SOURCE_REQ_WASM32_SIZE = {} as const;\n", + shared::process_layout::multicast_group_request::WASM32_GROUP_SOURCE_REQ_SIZE + )); + out.push_str(&format!( + "export const PROCESS_GROUP_SOURCE_REQ_WASM32_SOURCE_OFFSET = {} as const;\n", + shared::process_layout::multicast_group_request::WASM32_SOURCE_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_GROUP_REQ_WASM64_SIZE = {} as const;\n", + shared::process_layout::multicast_group_request::WASM64_GROUP_REQ_SIZE + )); + out.push_str(&format!( + "export const PROCESS_GROUP_REQ_WASM64_GROUP_OFFSET = {} as const;\n", + shared::process_layout::multicast_group_request::WASM64_GROUP_OFFSET + )); + out.push_str(&format!( + "export const PROCESS_GROUP_SOURCE_REQ_WASM64_SIZE = {} as const;\n", + shared::process_layout::multicast_group_request::WASM64_GROUP_SOURCE_REQ_SIZE + )); + out.push_str(&format!( + "export const PROCESS_GROUP_SOURCE_REQ_WASM64_SOURCE_OFFSET = {} as const;\n", + shared::process_layout::multicast_group_request::WASM64_SOURCE_OFFSET + )); out.push_str(&format!( "export const PROCESS_SIGINFO_SIGNO_OFFSET = {} as const;\n", shared::process_layout::rt_sigqueueinfo::SIGNO_OFFSET @@ -2625,6 +2746,18 @@ fn render_ts_module() -> String { "export const STRUCT_SIZE_WASM_DIRENT = {} as const;\n", size_of::() )); + out.push_str(&format!( + "export const WASM_DIRENT_INO_OFFSET = {} as const;\n", + offset_of!(shared::WasmDirent, d_ino) + )); + out.push_str(&format!( + "export const WASM_DIRENT_TYPE_OFFSET = {} as const;\n", + offset_of!(shared::WasmDirent, d_type) + )); + out.push_str(&format!( + "export const WASM_DIRENT_NAME_LENGTH_OFFSET = {} as const;\n", + offset_of!(shared::WasmDirent, d_namlen) + )); out.push_str(&format!( "export const STRUCT_SIZE_WASM_TIMESPEC = {} as const;\n", size_of::() @@ -2862,6 +2995,8 @@ fn render_ts_module() -> String { out.push_str(" | { type: \"deref\"; argIndex: number }\n"); out.push_str(" | { type: \"fixed\"; size: number }\n"); out.push_str(" | { type: \"process-layout\"; wasm32Size: number; wasm64Size: number };\n\n"); + out.push_str("export type SyscallArgCopyOutLengthSpec =\n"); + out.push_str(" { type: \"u32-field\"; argIndex: number; offset: number };\n\n"); out.push_str(&format!( "export const PROCESS_POINTER_WIDTH_ARG_INDEX = {} as const;\n\n", shared::host_abi::PROCESS_POINTER_WIDTH_ARG_INDEX @@ -2870,6 +3005,7 @@ fn render_ts_module() -> String { out.push_str(" argIndex: number;\n"); out.push_str(" direction: SyscallArgDirection;\n"); out.push_str(" size: SyscallArgSizeSpec;\n"); + out.push_str(" copyOutLength?: SyscallArgCopyOutLengthSpec;\n"); out.push_str(" nullable?: boolean;\n"); out.push_str(" required?: boolean;\n"); out.push_str("}\n\n"); @@ -2943,10 +3079,30 @@ fn ts_syscall_arg_desc(desc: &shared::host_abi::SyscallArgDesc) -> String { if desc.required { s.push_str(", required: true"); } + if let Some(copy_out_length) = desc.copy_out_length { + s.push_str(&format!( + ", copyOutLength: {}", + ts_syscall_arg_copy_out_length(copy_out_length) + )); + } s.push_str(" }"); s } +fn ts_syscall_arg_copy_out_length( + length: shared::host_abi::SyscallArgCopyOutLength, +) -> String { + use shared::host_abi::SyscallArgCopyOutLength; + + match length { + SyscallArgCopyOutLength::U32Field { arg_index, offset } => { + format!( + "{{ type: \"u32-field\", argIndex: {arg_index}, offset: {offset} }}" + ) + } + } +} + fn ts_syscall_arg_size(size: shared::host_abi::SyscallArgSize) -> String { use shared::host_abi::SyscallArgSize; @@ -3137,6 +3293,14 @@ fn build_snapshot(kernel_wasm: &std::path::Path) -> Result { root.insert("abi_version".into(), json!(shared::ABI_VERSION)); root.insert("platform_limits".into(), platform_limits()); + root.insert( + "process_metadata_contract".into(), + process_metadata_contract(), + ); + root.insert( + "process_snapshot_wire".into(), + process_snapshot_wire(), + ); root.insert("spawn_contract".into(), spawn_contract()); root.insert("channel_header".into(), channel_header()); @@ -3187,10 +3351,46 @@ fn platform_limits() -> Value { shared::platform_limits::MAX_TRANSFER_ALLOCATION_BYTES, "ngroups_max": shared::platform_limits::NGROUPS_MAX, "path_max_bytes": shared::platform_limits::PATH_MAX_BYTES, + "process_startup_max_argv_count": + shared::platform_limits::PROCESS_STARTUP_MAX_ARGV_COUNT, + "process_startup_max_envp_count": + shared::platform_limits::PROCESS_STARTUP_MAX_ENVP_COUNT, "sysv_msg_max_bytes": shared::platform_limits::SYSV_MSG_MAX_BYTES, }) } +fn process_metadata_contract() -> Value { + use shared::process_metadata_contract as contract; + + json!({ + "kind_argv": contract::KIND_ARGV, + "kind_environment": contract::KIND_ENVIRONMENT, + }) +} + +fn process_snapshot_wire() -> Value { + use shared::process_snapshot_wire as wire; + + json!({ + "count_offset": wire::COUNT_OFFSET, + "count_size": wire::COUNT_BYTES, + "records_offset": wire::RECORDS_OFFSET, + "header": build_struct_layout( + wire::HEADER_BYTES, + vec![ + ("pid", wire::PID_OFFSET), + ("ppid", wire::PPID_OFFSET), + ("uid", wire::UID_OFFSET), + ("gid", wire::GID_OFFSET), + ("vsize", wire::VSIZE_OFFSET), + ("state", wire::STATE_OFFSET), + ("comm_len", wire::COMM_LEN_OFFSET), + ("cmdline_len", wire::CMDLINE_LEN_OFFSET), + ], + ), + }) +} + fn channel_scalar_contract() -> Value { let syscalls: Vec = shared::channel_scalar::SYSCALLS .iter() @@ -3222,7 +3422,9 @@ fn channel_scalar_contract() -> Value { } fn process_native_layouts() -> Value { - use shared::process_layout::{cmsghdr, iovec, msghdr, rt_sigqueueinfo, sigevent}; + use shared::process_layout::{ + cmsghdr, iovec, msghdr, multicast_group_request, rt_sigqueueinfo, sigevent, + }; json!({ "cmsghdr": { @@ -3267,6 +3469,22 @@ fn process_native_layouts() -> Value { "size": iovec::WASM64_SIZE, }, }, + "multicast_group_request": { + "wasm32": { + "group_req_size": multicast_group_request::WASM32_GROUP_REQ_SIZE, + "group_offset": multicast_group_request::WASM32_GROUP_OFFSET, + "group_source_req_size": + multicast_group_request::WASM32_GROUP_SOURCE_REQ_SIZE, + "source_offset": multicast_group_request::WASM32_SOURCE_OFFSET, + }, + "wasm64": { + "group_req_size": multicast_group_request::WASM64_GROUP_REQ_SIZE, + "group_offset": multicast_group_request::WASM64_GROUP_OFFSET, + "group_source_req_size": + multicast_group_request::WASM64_GROUP_SOURCE_REQ_SIZE, + "source_offset": multicast_group_request::WASM64_SOURCE_OFFSET, + }, + }, "msghdr": { "wasm32": { "control_offset": msghdr::WASM32_CONTROL_OFFSET, @@ -4443,6 +4661,28 @@ fn syscall_arg_desc_json(desc: &shared::host_abi::SyscallArgDesc) -> Value { if desc.required { m.insert("required".into(), json!(true)); } + if let Some(copy_out_length) = desc.copy_out_length { + m.insert( + "copyOutLength".into(), + syscall_arg_copy_out_length_json(copy_out_length), + ); + } + Value::Object(m.into_iter().collect()) +} + +fn syscall_arg_copy_out_length_json( + length: shared::host_abi::SyscallArgCopyOutLength, +) -> Value { + use shared::host_abi::SyscallArgCopyOutLength; + + let mut m: JsonMap = BTreeMap::new(); + match length { + SyscallArgCopyOutLength::U32Field { arg_index, offset } => { + m.insert("type".into(), json!("u32-field")); + m.insert("argIndex".into(), json!(arg_index)); + m.insert("offset".into(), json!(offset)); + } + } Value::Object(m.into_iter().collect()) } @@ -6002,6 +6242,13 @@ mod tests { fn generated_typescript_contains_pathconf_names_and_required_outputs() { let rendered = render_ts_module(); assert!(rendered.contains("export const SCHED_AFFINITY_MASK_SIZE = 4 as const;")); + assert!(rendered.contains("export const PROCESS_SNAPSHOT_COUNT_BYTES = 4 as const;")); + assert!(rendered.contains("export const PROCESS_SNAPSHOT_RECORDS_OFFSET = 4 as const;")); + assert!(rendered.contains("export const PROCESS_SNAPSHOT_HEADER_BYTES = 36 as const;")); + assert!(rendered.contains("export const PROCESS_SNAPSHOT_VSIZE_OFFSET = 16 as const;")); + assert!( + rendered.contains("export const PROCESS_SNAPSHOT_CMDLINE_LEN_OFFSET = 32 as const;") + ); assert!(rendered.contains("export const PR_SET_NAME = 15 as const;")); assert!(rendered.contains("export const PR_GET_NAME = 16 as const;")); assert!(rendered.contains("export const PRCTL_NAME_BYTES = 16 as const;")); @@ -6023,11 +6270,33 @@ mod tests { assert!(rendered.contains("export const SELECT_FD_SET_BYTES = 128 as const;")); assert!(rendered.contains("export const PROCESS_IOVEC_WASM32_SIZE = 8 as const;")); assert!(rendered.contains("export const PROCESS_IOVEC_WASM64_SIZE = 16 as const;")); + assert!(rendered.contains("export const PROCESS_GROUP_REQ_WASM32_SIZE = 132 as const;")); + assert!(rendered.contains( + "export const PROCESS_GROUP_REQ_WASM32_GROUP_OFFSET = 4 as const;" + )); + assert!(rendered.contains( + "export const PROCESS_GROUP_SOURCE_REQ_WASM32_SIZE = 260 as const;" + )); + assert!(rendered.contains( + "export const PROCESS_GROUP_SOURCE_REQ_WASM32_SOURCE_OFFSET = 132 as const;" + )); + assert!(rendered.contains("export const PROCESS_GROUP_REQ_WASM64_SIZE = 136 as const;")); + assert!(rendered.contains( + "export const PROCESS_GROUP_REQ_WASM64_GROUP_OFFSET = 8 as const;" + )); + assert!(rendered.contains( + "export const PROCESS_GROUP_SOURCE_REQ_WASM64_SIZE = 264 as const;" + )); + assert!(rendered.contains( + "export const PROCESS_GROUP_SOURCE_REQ_WASM64_SOURCE_OFFSET = 136 as const;" + )); assert!(rendered.contains("export const PROCESS_MSGHDR_WASM64_SIZE = 56 as const;")); assert!(rendered.contains("export const PROCESS_CMSGHDR_WASM64_ALIGN = 8 as const;")); assert!(rendered.contains("export const STRUCT_SIZE_KERNEL_IOVEC_WIRE = 8 as const;")); assert!(rendered.contains("export const STRUCT_SIZE_KERNEL_MSGHDR_WIRE = 28 as const;")); assert!(rendered.contains("export const STRUCT_SIZE_KERNEL_CMSGHDR_WIRE = 12 as const;")); + assert!(rendered.contains("export const PROCESS_METADATA_KIND_ARGV = 0 as const;")); + assert!(rendered.contains("export const PROCESS_METADATA_KIND_ENVIRONMENT = 1 as const;")); assert!( rendered .contains("export const KERNEL_MESSAGE_WIRE_FLATTENED_IOVEC_COUNT = 1 as const;") @@ -6049,6 +6318,39 @@ mod tests { assert_eq!(names.as_object().unwrap().len(), 24); } + #[test] + fn generated_process_snapshot_wire_is_packed_and_complete() { + let wire = process_snapshot_wire(); + assert_eq!(wire["count_offset"], json!(0)); + assert_eq!(wire["count_size"], json!(4)); + assert_eq!(wire["records_offset"], json!(4)); + assert_eq!(wire["header"]["size"], json!(36)); + assert_eq!( + wire["header"]["fields"], + json!([ + { "name": "pid", "offset": 0, "span": 4 }, + { "name": "ppid", "offset": 4, "span": 4 }, + { "name": "uid", "offset": 8, "span": 4 }, + { "name": "gid", "offset": 12, "span": 4 }, + { "name": "vsize", "offset": 16, "span": 8 }, + { "name": "state", "offset": 24, "span": 4 }, + { "name": "comm_len", "offset": 28, "span": 4 }, + { "name": "cmdline_len", "offset": 32, "span": 4 }, + ]) + ); + } + + #[test] + fn generated_process_metadata_contract_keeps_kinds_together() { + assert_eq!( + process_metadata_contract(), + json!({ + "kind_argv": 0, + "kind_environment": 1, + }), + ); + } + #[test] fn generated_channel_scalar_consumers_share_one_contract() { let typescript = render_ts_module(); @@ -6196,6 +6498,10 @@ mod tests { shared::platform_limits::MAX_TRANSFER_ALLOCATION_BYTES, "ngroups_max": shared::platform_limits::NGROUPS_MAX, "path_max_bytes": shared::platform_limits::PATH_MAX_BYTES, + "process_startup_max_argv_count": + shared::platform_limits::PROCESS_STARTUP_MAX_ARGV_COUNT, + "process_startup_max_envp_count": + shared::platform_limits::PROCESS_STARTUP_MAX_ENVP_COUNT, "sysv_msg_max_bytes": shared::platform_limits::SYSV_MSG_MAX_BYTES, }), ); @@ -6246,6 +6552,23 @@ mod tests { "wasm64": {"base_offset": 0, "len_offset": 8, "size": 16}, }), ); + assert_eq!( + layouts["multicast_group_request"], + json!({ + "wasm32": { + "group_req_size": 132, + "group_offset": 4, + "group_source_req_size": 260, + "source_offset": 132, + }, + "wasm64": { + "group_req_size": 136, + "group_offset": 8, + "group_source_req_size": 264, + "source_offset": 136, + }, + }), + ); assert_eq!( layouts["msghdr"]["wasm64"], json!({ @@ -6325,6 +6648,26 @@ mod tests { let header = render_process_layouts_header(); assert!(header.contains("#define KANDELO_PROCESS_CMSGHDR_WASM32_SIZE 12u")); assert!(header.contains("#define KANDELO_PROCESS_CMSGHDR_WASM64_SIZE 16u")); + assert!(header.contains("#define KANDELO_PROCESS_GROUP_REQ_WASM32_SIZE 132u")); + assert!(header.contains( + "#define KANDELO_PROCESS_GROUP_REQ_WASM32_GROUP_OFFSET 4u" + )); + assert!(header.contains( + "#define KANDELO_PROCESS_GROUP_SOURCE_REQ_WASM32_SIZE 260u" + )); + assert!(header.contains( + "#define KANDELO_PROCESS_GROUP_SOURCE_REQ_WASM32_SOURCE_OFFSET 132u" + )); + assert!(header.contains("#define KANDELO_PROCESS_GROUP_REQ_WASM64_SIZE 136u")); + assert!(header.contains( + "#define KANDELO_PROCESS_GROUP_REQ_WASM64_GROUP_OFFSET 8u" + )); + assert!(header.contains( + "#define KANDELO_PROCESS_GROUP_SOURCE_REQ_WASM64_SIZE 264u" + )); + assert!(header.contains( + "#define KANDELO_PROCESS_GROUP_SOURCE_REQ_WASM64_SOURCE_OFFSET 136u" + )); assert!(header.contains("#define KANDELO_PROCESS_SIGINFO_SIGNO_OFFSET 0u")); assert!(header.contains("#define KANDELO_PROCESS_SIGINFO_WASM32_PID_OFFSET 12u")); assert!(header.contains("#define KANDELO_PROCESS_SIGINFO_WASM64_PID_OFFSET 16u")); From 3a6155cf00aa5307890440d24b5c501b35bd123a Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Mon, 27 Jul 2026 19:15:12 -0400 Subject: [PATCH 09/82] Host: Stop pumping relinquished TCP endpoints Honor the Node bridge endpoint-ownership flags after FIN or cleanup. A close may synchronously reclaim the global pipe slot, so later pumps must neither query nor notify its reusable numeric index. --- host/src/kernel-worker.ts | 25 +++++++++++++++++++------ packages/registry/program-packages.json | 18 +++++++++--------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index bebec3dd79..8d1c434093 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -30061,9 +30061,12 @@ export class CentralizedKernelWorker { `Node TCP pump pid=${pid}`, (entry) => { let wroteAny = false; + let closedRecvPipeWrite = false; + let closedSendPipeRead = false; const outbound: Uint8Array[] = []; if ( - !abortRequested + !recvPipeWriteClosed + && !abortRequested && this.#tcpPipeReadOpenWithinKernelEntry(recvPipeIdx, entry) ) { while (inboundQueue.length > 0) { @@ -30089,20 +30092,28 @@ export class CentralizedKernelWorker { if (!recvPipeWriteClosed) { this.#closeTcpPipeWriteWithinKernelEntry(recvPipeIdx, entry); recvPipeWriteClosed = true; + closedRecvPipeWrite = true; } } - if (!abortRequested) { + if (!abortRequested && !sendPipeReadClosed) { for (;;) { const bytes = this.readPipeChunk(0, sendPipeIdx, entry); if (!bytes) break; outbound.push(bytes); } } + // WHY: closing the host endpoint can synchronously reclaim the + // global pipe slot once the guest endpoint is already closed. The + // local ownership bits are authoritative after that transition; + // querying or notifying the raw index again could target a later + // reused pipe. const writeOpen = - this.#tcpPipeWriteOpenWithinKernelEntry(sendPipeIdx, entry); + !sendPipeReadClosed + && this.#tcpPipeWriteOpenWithinKernelEntry(sendPipeIdx, entry); const hasReaders = - this.#tcpPipeHasReadersWithinKernelEntry(recvPipeIdx, entry); + !recvPipeWriteClosed + && this.#tcpPipeHasReadersWithinKernelEntry(recvPipeIdx, entry); const shouldEndGuestWrite = !writeOpen && outbound.length === 0 @@ -30116,18 +30127,20 @@ export class CentralizedKernelWorker { if (!recvPipeWriteClosed) { this.#closeTcpPipeWriteWithinKernelEntry(recvPipeIdx, entry); recvPipeWriteClosed = true; + closedRecvPipeWrite = true; } if (!sendPipeReadClosed) { this.#closeTcpPipeReadWithinKernelEntry(sendPipeIdx, entry); sendPipeReadClosed = true; + closedSendPipeRead = true; } } entry.deferProtocolEffect(() => { - if (wroteAny || recvPipeWriteClosed) { + if (wroteAny || closedRecvPipeWrite) { this.notifyPipeReadable(recvPipeIdx); } - if (outbound.length > 0 || sendPipeReadClosed) { + if (outbound.length > 0 || closedSendPipeRead) { this.notifyPipeWritable(sendPipeIdx); } for (const bytes of outbound) { diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index a1085b4801..87a7a9c0a9 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -158,8 +158,8 @@ "lamp": { "manifestSha256": "250b64635d64f8537178188fb5488dbfd2980d79a1c2ffbf346af67008088ba0", "cacheKeys": { - "wasm32": "d7328fe44911df283fd9d32ca2d1feb1ec90f9154ab0a321ad02a4092591a541", - "wasm64": "e9d8e8262ea4d63aed6956a8eeab6c3cd368f4010625e781ec8d280a20bb49ae" + "wasm32": "8085776999cc7294e67319a78c17e77cc2ae49ec6b1004db0b0c1180ebd07bb3", + "wasm64": "d0d60c463a9da1972646f54347bebeb5b76ef49ef8a183a00c4414d44e3f6aaf" } }, "less": { @@ -312,8 +312,8 @@ "nginx-php-vfs": { "manifestSha256": "97976410cb02f8ba710d856b4ac904bcf976d677b50eefdee39fb64176070d4b", "cacheKeys": { - "wasm32": "ce2ad449bb35359fcb68dd95f2aa791c8124ecc51ca64c8e24d5c4bb2693d8ac", - "wasm64": "789cfd7efa85fd6f600c0f00dc18e0beb69c1c55100dc046c636829cfbf3b6b0" + "wasm32": "455fdfa5cd64c0693a1e8bce0e131535f5390d982eb9093e92e22fa48914c02f", + "wasm64": "39c7747d3f54ff21722fd26d006ce5aede33ccefd9af98ec7ee6a3929152a9b5" } }, "nginx-vfs": { @@ -515,8 +515,8 @@ "wordpress": { "manifestSha256": "36465e0596a06e855a8524eecfd87da98643bc13b37ee12939f5aab44bf33418", "cacheKeys": { - "wasm32": "64cef76ee3b8bd1926c4f26fb84c11abb98a97737e62e739da80544da35af131", - "wasm64": "efe17013e08bb79ec2af1056febf070f87e2f5f5b20ae4ec86e90956add5e20d" + "wasm32": "34fc17691302a51f9908216cff9f481eacff2fbd46d03bf296de47dc0ea467a7", + "wasm64": "02eda2a59d9c55ed343c5620d601b51205ed7a3586aedec3a0384f13e4017686" } }, "xz": { @@ -1093,7 +1093,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "d7328fe44911df283fd9d32ca2d1feb1ec90f9154ab0a321ad02a4092591a541" + "wasm32": "8085776999cc7294e67319a78c17e77cc2ae49ec6b1004db0b0c1180ebd07bb3" }, "dependencyClosures": { "wasm32": [ @@ -1718,7 +1718,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ce2ad449bb35359fcb68dd95f2aa791c8124ecc51ca64c8e24d5c4bb2693d8ac" + "wasm32": "455fdfa5cd64c0693a1e8bce0e131535f5390d982eb9093e92e22fa48914c02f" }, "dependencyClosures": { "wasm32": [ @@ -2926,7 +2926,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "64cef76ee3b8bd1926c4f26fb84c11abb98a97737e62e739da80544da35af131" + "wasm32": "34fc17691302a51f9908216cff9f481eacff2fbd46d03bf296de47dc0ea467a7" }, "dependencyClosures": { "wasm32": [ From c902725d4777dfd4da1e81bc7667eec9e8bd3767 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Mon, 27 Jul 2026 22:04:01 -0400 Subject: [PATCH 10/82] Host: Run conformance on owned storage Give Node conformance runs an explicit, session-owned VFS root and mount lifecycle. Prevent tests from depending on or mutating ambient host storage, and make teardown retire the same mount state that was admitted. Route the libc, POSIX, and Sortix runners through that production host path and cover mount creation, export, and cleanup. --- docs/architecture.md | 14 +- ...026-07-25-kernel-scratch-transfer-audit.md | 27 ++ docs/posix-status.md | 4 +- examples/README.md | 12 + examples/run-example-vfs.ts | 241 ++++++++++++++ examples/run-example.ts | 70 +++-- host/src/node-kernel-host.ts | 29 +- host/src/node-kernel-protocol.ts | 12 +- host/src/node-kernel-worker-entry.ts | 12 +- host/src/vfs/default-mounts-node.ts | 224 ++++++++++++- host/test/node-host-mounts.test.ts | 80 ++++- host/test/run-example-vfs.test.ts | 150 +++++++++ host/test/vfs/default-mounts.test.ts | 294 +++++++++++++++++- packages/registry/program-packages.json | 36 +-- scripts/run-libc-tests.sh | 10 +- scripts/run-posix-tests.sh | 10 +- scripts/run-sortix-tests.sh | 138 ++++---- 17 files changed, 1228 insertions(+), 135 deletions(-) create mode 100644 examples/run-example-vfs.ts create mode 100644 host/test/run-example-vfs.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index 74b3b3e6cd..715e12635d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1498,6 +1498,18 @@ image state and allocate browser scratch filesystems or create Node scratch directories. A forged later image therefore cannot leave an earlier mount normalized or a host scratch directory published as a partial boot. +Node may also seed a strict descendant of an existing scratch mount through +`NodeKernelHost.sessionSeedTrees`. The worker authenticates the complete root +image first, copies every quiescent source tree into opaque staging paths using +new regular-file inodes, and renames all completed trees into the private +session before constructing any session-owned `HostFileSystem` backend or +publishing `ready`. Symlinks, special files, overlapping destinations, image +destinations, and destinations shadowed by another mount are rejected. Guest +changes are never written back to the source. This copy boundary matters +because access to a path somewhere inside a Node process is not proof that +Kandelo exclusively owns the inode; exact append and related stateful +operations require a lifecycle-owned backing, not merely a reachable one. + | Mount point | Source | Browser backend | Node backend | |-------------|--------|-----------------|--------------| | `/` | image (advisory readonly) | awaited verified `MemoryFileSystem` restore | awaited verified `MemoryFileSystem` restore | @@ -1525,7 +1537,7 @@ VFS images can also carry image-level metadata outside the guest file tree. The ### Node host -`NodeKernelHost` accepts `rootfsImage: "default" | ArrayBuffer | Uint8Array | undefined`. With `"default"` (the path used by the vitest suite), the worker reads `host/wasm/rootfs.vfs`, applies `DEFAULT_MOUNT_SPEC` via `resolveForNode`, and constructs a `VirtualPlatformIO` for the kernel. The image supplies both `/etc/ssl/cert.pem` and `/etc/ssl/certs/ca-certificates.crt`; Node does not silently add them to caller-supplied images. Without a rootfs image, the worker falls back to raw `NodePlatformIO` (every host path reachable) — kept for legacy callers that haven't migrated. +`NodeKernelHost` accepts `rootfsImage: "default" | ArrayBuffer | Uint8Array | undefined`. With `"default"` (the path used by the vitest suite), the worker reads `host/wasm/rootfs.vfs`, applies `DEFAULT_MOUNT_SPEC` via the private-session Node resolver, and constructs a `VirtualPlatformIO` for the kernel. The image supplies both `/etc/ssl/cert.pem` and `/etc/ssl/certs/ca-certificates.crt`; Node does not silently add them to caller-supplied images. Optional `sessionSeedTrees` require a rootfs image and absolute host source paths; each source must remain quiescent until `init()` resolves. Graceful destroy, initialization failure, and fatal worker paths attempt to remove the complete session tree; abrupt process termination cannot run that best-effort hook, so cleanup is not the ownership proof. New private inodes and publication-before-`ready` establish ownership. Without a rootfs image, the worker falls back to raw `NodePlatformIO` (every host path reachable) — kept for legacy callers that haven't migrated. ### Browser host diff --git a/docs/plans/2026-07-25-kernel-scratch-transfer-audit.md b/docs/plans/2026-07-25-kernel-scratch-transfer-audit.md index 49e0dd28a9..844ddd7e5b 100644 --- a/docs/plans/2026-07-25-kernel-scratch-transfer-audit.md +++ b/docs/plans/2026-07-25-kernel-scratch-transfer-audit.md @@ -847,6 +847,28 @@ browser, or timing results. If the PR makes a memory or performance claim, the external exact-head ledger must contain the performance guide's matching measurements and artifact fingerprints. +## Conformance storage ownership + +The conformance suites must exercise the same append and inode-ownership rules +as ordinary guests. Pointing them at the checkout through raw +`NodePlatformIO`, then weakening exact append for one failing test, would be a +test-specific exception and would conceal the real externally mutable backing. +Instead, the generic Node boot contract may copy a quiescent source tree into a +strict descendant of an existing per-boot scratch mount. It authenticates the +root image first, copies regular files and directories into unpublished staging +paths without preserving hardlink aliases, rejects symlinks and special files, +then renames the complete trees before constructing the branded scratch +backends or publishing readiness. Guest writes never reach the source tree. + +Libc and Open POSIX tests use the canonical root image and `/tmp`; the initial +program crosses as an immutable value, and its exact self-exec alias resolves +only to those cached bytes. Each Sortix invocation stages only its executable, +source file when present, and shared object when present as regular files in a +private fixture, then launches the worker-owned VFS path. The suite/parent +layout remains intact for tests that open `..`, inspect their source, load a +shared object, or exec/spawn themselves. Parallel invocations receive separate +session copies. This is lifecycle ownership rather than a test allowlist. + ## ABI decision `ABI_VERSION` is 43 in the post-retarget implementation: @@ -968,6 +990,11 @@ measurements and artifact fingerprints. compatibility fallback. - `KernelScratchRegion` remains an internal TypeScript value, but that fact does not neutralize the required export and synchronization changes. +- `NodeKernelHost.sessionSeedTrees` and its main-thread/worker initialization + field are optional Node configuration. They change no Wasm export, import, + syscall layout, pointer interpretation, required adapter capability, or + accepted guest limit. They therefore require no epoch beyond the already + unpublished ABI 43. PR #1097 merged as `c7d039794a43788acfa0b0aea30a700c257f57cb` with ABI 42. Retargeting is diff --git a/docs/posix-status.md b/docs/posix-status.md index 397e663aa7..5f6bc5dccf 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -57,7 +57,7 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve | `close()` | Partial | Ref-counted OFD cleanup. Host handle closed when last ref dropped. Closing any descriptor for a file releases every process lock held by that PID on the file; OFD locks survive duplicated/inherited references and disappear only with the final machine-wide OFD reference. EINTR not yet handled. | | `read()` | Partial | Host-delegated for files. Pipe/socket reads from kernel ring buffer with blocking when empty (EINTR on signal). Short reads permitted. O_NONBLOCK returns EAGAIN. | | `pread()` | Partial | Host-backed files use one positioned backend read without changing the OFD cursor; in-kernel files retain their native positioned path. Rejects pipes/sockets with ESPIPE. Signed-i64 offsets stay exact through the host contract; number-only backends return EOVERFLOW rather than rounding an unrepresentable offset. | -| `write()` | Partial | Host-delegated for files. Pipe writes to kernel ring buffer with blocking when full (EINTR on signal). EPIPE + SIGPIPE on closed read end (POSIX-compliant). `O_APPEND` is one EOF/limit/write transaction that returns the exact written prefix and ending offset: memfds and shared-memory files serialize under their backing lock, OPFS serializes in its channel handler, and lifecycle-owned Node scratch mounts use a verified native append route. Externally mutable `HostFileSystem` mounts and the legacy raw Node adapter cannot prove the exact ending offset and return `EOPNOTSUPP` before mutation. For regular files and memfds, `RLIMIT_FSIZE` applies once per logical operation: a crossing operation returns the prefix that fits without a signal; a later non-empty operation with no room fails with `EFBIG` and generates thread-directed `SIGXFSZ`. | +| `write()` | Partial | Host-delegated for files. Pipe writes to kernel ring buffer with blocking when full (EINTR on signal). EPIPE + SIGPIPE on closed read end (POSIX-compliant). `O_APPEND` is one EOF/limit/write transaction that returns the exact written prefix and ending offset: memfds and shared-memory files serialize under their backing lock, OPFS serializes in its channel handler, and lifecycle-owned Node scratch mounts use a verified native append route. Node session seeds are copied to new private inodes before readiness and therefore retain that lifecycle-owned route; no mutation is written back to the source tree. Externally mutable `HostFileSystem` mounts and the legacy raw Node adapter cannot prove the exact ending offset and return `EOPNOTSUPP` before mutation. For regular files and memfds, `RLIMIT_FSIZE` applies once per logical operation: a crossing operation returns the prefix that fits without a signal; a later non-empty operation with no room fails with `EFBIG` and generates thread-directed `SIGXFSZ`. | | `pwrite()` | Partial | Host-backed files use one positioned backend write without changing the OFD cursor; in-kernel files retain their native positioned path. Rejects pipes/sockets with ESPIPE. Uses the same operation-wide RLIMIT_FSIZE rule as write. Number-only backends, including Node's synchronous positioned-write API above JavaScript's safe-integer range, return EOVERFLOW rather than rounding. | | `lseek()` | Partial | Regular files support SEEK_SET, SEEK_CUR, and SEEK_END; SEEK_END delegates to the host for size calculation. Directories accept a nonnegative next-record cookie with SEEK_SET and expose the current cookie through SEEK_CUR with offset zero; other directory seeks fail with EINVAL without changing the cursor. A regular-file seek whose result would be negative likewise fails with EINVAL, and arithmetic or host-number overflow fails with EOVERFLOW. Ordinary-file and directory positions still have the cross-process OFD boundary documented below. | | `dup()` | Full | Lowest available fd. FD_CLOEXEC cleared. Shares OFD with original. | @@ -453,7 +453,7 @@ Systematic audit of all subsystems against POSIX specifications. Gaps are catego |-----|-----------|-------------| | **EINTR partially implemented** | all | read, write, recv, poll, select return EINTR when a signal is pending during a blocking wait. close() and other non-blocking syscalls do not check. Tied to signal handler invocation gap. | | **PIPE_BUF guarantee at host-backed stdio boundary** | pipe / host | In-kernel pipes guarantee atomic writes through 4096 bytes and report that value from `fpathconf()`. Captured stdio uses host-backed pipe OFDs; its callback/native-write boundary has not been proven all-or-nothing through the compile-time `PIPE_BUF` value, so `fpathconf()` reports the limit as indeterminate. Do not treat the global `` promise as fully reconciled until that boundary is enforced or stdio is modeled differently. | -| Host-backed `O_APPEND` on externally mutable native mounts | write | Managed shared-memory, OPFS, memfd, and lifecycle-owned Node scratch backings perform one exact EOF/limit/write operation. Public or extra native mounts return `EOPNOTSUPP` before mutation because Node does not expose the ending offset of its atomic append; supporting that boundary requires a native broker/capability that can return the exact outcome. | +| Host-backed `O_APPEND` on externally mutable native mounts | write | Managed shared-memory, OPFS, memfd, lifecycle-owned Node scratch backings, and private copies imported into those scratch backings before boot readiness perform one exact EOF/limit/write operation. Public or extra native mounts return `EOPNOTSUPP` before mutation because Node does not expose the ending offset of its atomic append; supporting that boundary requires a native broker/capability that can return the exact outcome. | | ~~**sigaction() missing sa_flags**~~ | signals | **Resolved.** SA_RESTART supported (auto-restart blocking syscalls). sa_flags and sa_mask stored. SA_SIGINFO handler delivery with siginfo_t. SA_NOCLDWAIT auto-reaps children. SA_NOCLDSTOP suppresses stop/continue SIGCHLD notification while preserving waitable status. | | ~~**No signal queuing**~~ | signals | **Resolved.** RT signals (32-63) are now queued in a VecDeque; standard signals (1-31) remain coalesced per POSIX. | | ~~**`*at()` functions with real dirfd**~~ | filesystem | **Resolved.** All *at() syscalls now support real dirfd via stored OFD paths. | diff --git a/examples/README.md b/examples/README.md index 840aa47088..4c2598f377 100644 --- a/examples/README.md +++ b/examples/README.md @@ -23,6 +23,18 @@ npx tsx examples/run-example.ts hello different initial user or group. The maximum unsigned 32-bit value is reserved by the host protocol and is rejected rather than being mistaken for an ID. +The runner has two explicit filesystem modes. Its default `raw` mode preserves +the legacy direct host-filesystem behavior and accepts `KERNEL_CWD`. +`KANDELO_RUNNER_VFS=isolated` instead boots the canonical root image with +lifecycle-owned scratch storage and uses `/tmp` as the guest working directory. +Conformance harnesses may additionally provide one quiescent fixture directory +with `KANDELO_RUNNER_FIXTURE_ROOT`, a relative +`KANDELO_RUNNER_FIXTURE_CWD`, and an optional relative +`KANDELO_RUNNER_GUEST_PROGRAM`. The worker copies that tree under +`/tmp/kandelo-run` before readiness; the guest cannot mutate the source, and +ambient host executable lookup is disabled. These controls are runner plumbing, +not guest environment variables. + See [docs/sdk-guide.md](../docs/sdk-guide.md) for full SDK documentation. ## Programs diff --git a/examples/run-example-vfs.ts b/examples/run-example-vfs.ts new file mode 100644 index 0000000000..bd23ccd657 --- /dev/null +++ b/examples/run-example-vfs.ts @@ -0,0 +1,241 @@ +import { + lstatSync, + realpathSync, +} from "node:fs"; +import { + isAbsolute, + join, + relative, + sep, +} from "node:path"; +import { posix as guestPath } from "node:path"; + +import type { NodeSessionSeedTree } from "../host/src/vfs/default-mounts-node"; + +const ISOLATED_FIXTURE_DESTINATION = "/tmp/kandelo-run"; +const ISOLATED_PATH_ENV = new Set([ + "GIT_SSL_CAINFO", + "HOME", + "NODE_EXTRA_CA_CERTS", + "NIX_SSL_CERT_FILE", + "OLDPWD", + "PWD", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "TEMP", + "TMP", + "TMPDIR", +]); + +type RunnerEnvironment = Readonly>; + +export interface RunExampleFilesystem { + isolated: boolean; + guestCwd: string; + guestProgram?: string; + rootfsImage?: "default"; + sessionSeedTrees?: readonly NodeSessionSeedTree[]; +} + +function nonEmptyControl( + env: RunnerEnvironment, + name: string, +): string | undefined { + const value = env[name]; + return value === undefined || value === "" ? undefined : value; +} + +function containsPath(parent: string, child: string): boolean { + const rel = relative(parent, child); + return rel === "" || ( + rel !== ".." + && !rel.startsWith(`..${sep}`) + && !isAbsolute(rel) + ); +} + +function relativeFixturePath(value: string, name: string): string[] { + if (value.includes("\0") || guestPath.isAbsolute(value)) { + throw new Error(`${name} must be a relative guest path`); + } + const segments = value.split("/"); + if ( + segments.length === 0 + || segments.some( + (segment) => segment === "" || segment === "." || segment === "..", + ) + ) { + throw new Error( + `${name} must contain only non-empty relative path segments`, + ); + } + return segments; +} + +function fixtureEntry( + sourceRoot: string, + value: string, + name: string, + kind: "directory" | "file", +): { guest: string; host: string } { + const segments = relativeFixturePath(value, name); + const candidate = join(sourceRoot, ...segments); + const stat = lstatSync(candidate); + if ( + (kind === "directory" && !stat.isDirectory()) + || (kind === "file" && !stat.isFile()) + || stat.isSymbolicLink() + ) { + throw new Error(`${name} must name a ${kind} inside the fixture root`); + } + const physical = realpathSync(candidate); + if (!containsPath(sourceRoot, physical)) { + throw new Error(`${name} escapes the fixture root`); + } + return { + guest: guestPath.join(ISOLATED_FIXTURE_DESTINATION, ...segments), + host: physical, + }; +} + +/** + * Resolve the CLI runner's filesystem authority before a worker is started. + * + * Raw mode preserves the legacy host-filesystem runner. Isolated mode uses the + * canonical rootfs plus lifecycle-owned scratch. Optional fixtures are copied + * into that scratch by the worker, so fixture-backed cwd and program paths are + * guest paths. A byte-launched program may retain its legacy host path as + * argv[0], but only an immutable exact-path self-exec alias can resolve it. + */ +export function resolveRunExampleFilesystem( + env: RunnerEnvironment, + hostCwd: string, +): RunExampleFilesystem { + const requestedMode = nonEmptyControl(env, "KANDELO_RUNNER_VFS"); + if (requestedMode !== undefined && requestedMode !== "raw" && requestedMode !== "isolated") { + throw new Error('KANDELO_RUNNER_VFS must be "raw" or "isolated"'); + } + + const isolated = requestedMode === "isolated"; + const fixtureRootInput = nonEmptyControl( + env, + "KANDELO_RUNNER_FIXTURE_ROOT", + ); + const fixtureCwdInput = nonEmptyControl( + env, + "KANDELO_RUNNER_FIXTURE_CWD", + ); + const guestProgramInput = nonEmptyControl( + env, + "KANDELO_RUNNER_GUEST_PROGRAM", + ); + + if (!isolated) { + if ( + fixtureRootInput !== undefined + || fixtureCwdInput !== undefined + || guestProgramInput !== undefined + ) { + throw new Error("runner fixture controls require isolated VFS mode"); + } + return { + guestCwd: nonEmptyControl(env, "KERNEL_CWD") ?? hostCwd, + isolated: false, + }; + } + + if (nonEmptyControl(env, "KERNEL_CWD") !== undefined) { + throw new Error( + "KERNEL_CWD is a raw-host control; isolated mode requires runner fixture paths", + ); + } + if (fixtureRootInput === undefined) { + if (fixtureCwdInput !== undefined) { + throw new Error( + "KANDELO_RUNNER_FIXTURE_CWD requires KANDELO_RUNNER_FIXTURE_ROOT", + ); + } + if (guestProgramInput !== undefined) { + throw new Error( + "KANDELO_RUNNER_GUEST_PROGRAM requires KANDELO_RUNNER_FIXTURE_ROOT", + ); + } + return { + guestCwd: "/tmp", + isolated: true, + rootfsImage: "default", + }; + } + if (fixtureCwdInput === undefined) { + throw new Error( + "KANDELO_RUNNER_FIXTURE_ROOT requires KANDELO_RUNNER_FIXTURE_CWD", + ); + } + + const sourceRoot = realpathSync(fixtureRootInput); + const sourceStat = lstatSync(sourceRoot); + if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) { + throw new Error("KANDELO_RUNNER_FIXTURE_ROOT must name a directory"); + } + const cwd = fixtureEntry( + sourceRoot, + fixtureCwdInput, + "KANDELO_RUNNER_FIXTURE_CWD", + "directory", + ); + const guestProgram = guestProgramInput === undefined + ? undefined + : fixtureEntry( + sourceRoot, + guestProgramInput, + "KANDELO_RUNNER_GUEST_PROGRAM", + "file", + ).guest; + + return { + guestCwd: cwd.guest, + ...(guestProgram === undefined ? {} : { guestProgram }), + isolated: true, + rootfsImage: "default", + sessionSeedTrees: [{ + destinationPath: ISOLATED_FIXTURE_DESTINATION, + sourcePath: sourceRoot, + }], + }; +} + +/** + * Copy host environment values as guest strings without retaining host-only + * runner controls. Isolated boots replace host path variables with paths that + * actually exist in the canonical VFS. + */ +export function buildRunExampleGuestEnvironment( + env: RunnerEnvironment, + guestCwd: string, + isolated: boolean, + kernelPath: string, + guestHome = "/root", +): string[] { + const inherited = Object.entries(env) + .filter(([name, value]) => + value !== undefined + && name !== "PATH" + && name !== "KANDELO_GUEST_OUTPUT_FILE" + && !name.startsWith("KANDELO_RUNNER_") + && (!isolated || !name.startsWith("KERNEL_")) + && (!isolated || !ISOLATED_PATH_ENV.has(name)) + ) + .map(([name, value]) => `${name}=${value}`); + + return [ + ...inherited, + `PATH=${kernelPath}`, + ...(isolated + ? [ + `PWD=${guestCwd}`, + "TMPDIR=/tmp", + `HOME=${guestHome}`, + ] + : []), + ]; +} diff --git a/examples/run-example.ts b/examples/run-example.ts index 4ea1fc535e..4c23aa4ed0 100644 --- a/examples/run-example.ts +++ b/examples/run-example.ts @@ -19,6 +19,10 @@ import { NodeKernelHost } from "../host/src/node-kernel-host"; import { tryResolveBinaries } from "../host/src/binary-resolver"; import { writeAllSync } from "./run-example-output"; import { isWithinRealDirectory } from "./run-example-paths"; +import { + buildRunExampleGuestEnvironment, + resolveRunExampleFilesystem, +} from "./run-example-vfs"; const repoRoot = resolve(dirname(new URL(import.meta.url).pathname), ".."); @@ -359,11 +363,13 @@ function tryLoadGuestCandidate(candidate: string, kernelCwd: string): ArrayBuffe function resolveProgram( path: string, builtinPrograms: Record, + allowAmbientHostCandidates: boolean, ): ArrayBuffer | null { const mapped = builtinPrograms[path]; if (mapped) { return loadBytes(mapped); } + if (!allowAmbientHostCandidates) return null; const kernelCwd = resolve(process.env.KERNEL_CWD || process.cwd()); const candidates = [ // Resolve relative to kernel CWD (sortix tests exec themselves by relative path) @@ -380,18 +386,6 @@ function resolveProgram( return null; } -function guestEnv(): string[] { - const kernelPath = process.env.KERNEL_PATH ?? "/usr/local/bin:/usr/bin:/bin"; - const inherited = Object.entries(process.env) - .filter(([k, v]) => - v !== undefined && - k !== "PATH" && - k !== "KANDELO_GUEST_OUTPUT_FILE" - ) - .map(([k, v]) => `${k}=${v}`); - return [...inherited, `PATH=${kernelPath}`]; -} - async function main() { const name = process.argv[2]; if (!name) { @@ -400,6 +394,10 @@ async function main() { } const uid = parseKernelCredential("KERNEL_UID"); const gid = parseKernelCredential("KERNEL_GID"); + const runnerFilesystem = resolveRunExampleFilesystem( + process.env, + process.cwd(), + ); const builtinPrograms = resolveBuiltinPrograms(); let programPath: string; @@ -410,6 +408,12 @@ async function main() { } else { programPath = resolve(`examples/${name}.wasm`); } + // WHY: an isolated value-copy launch and any exact self-exec alias must + // observe one immutable program generation. Read once before worker init; + // later source replacement cannot silently change the executable. + const initialProgramBytes = runnerFilesystem.guestProgram === undefined + ? loadBytes(programPath) + : undefined; // Git system config via environment (Node.js VFS is the host filesystem, // so we can't write /etc/gitconfig; use GIT_CONFIG_COUNT instead). @@ -460,25 +464,55 @@ async function main() { try { host = new NodeKernelHost({ maxWorkers: 4, + rootfsImage: runnerFilesystem.rootfsImage, + sessionSeedTrees: runnerFilesystem.sessionSeedTrees, onStdout: (_pid, data) => writeGuestOutput(process.stdout, data), onStderr: (_pid, data) => writeGuestOutput(process.stderr, data), - onResolveExec: (path) => resolveProgram(path, builtinPrograms), + onResolveExec: (path) => { + if ( + initialProgramBytes !== undefined + && path === programPath + ) { + return initialProgramBytes.slice(0); + } + return resolveProgram( + path, + builtinPrograms, + !runnerFilesystem.isolated, + ); + }, }); await host.init(); - const processArgv = [programPath, ...process.argv.slice(3)]; + const processArgv = [ + runnerFilesystem.guestProgram ?? programPath, + ...process.argv.slice(3), + ]; const timeoutMs = parseInt(process.env.TIMEOUT || "30000", 10); - const exitPromise = host.spawn(loadBytes(programPath), processArgv, { + const spawnOptions = { env: [ - ...guestEnv(), + ...buildRunExampleGuestEnvironment( + process.env, + runnerFilesystem.guestCwd, + runnerFilesystem.isolated, + process.env.KERNEL_PATH ?? "/usr/local/bin:/usr/bin:/bin", + uid !== undefined && uid !== 0 ? "/home/user" : "/root", + ), ...gitEnv, ], - cwd: process.env.KERNEL_CWD || process.cwd(), + cwd: runnerFilesystem.guestCwd, uid, gid, stdin: stdinData, - }); + }; + const exitPromise = runnerFilesystem.guestProgram === undefined + ? host.spawn(initialProgramBytes!, processArgv, spawnOptions) + : host.spawnFromVfs( + runnerFilesystem.guestProgram, + processArgv, + spawnOptions, + ).then(({ exit }) => exit); const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error("Process timed out")), timeoutMs); }); diff --git a/host/src/node-kernel-host.ts b/host/src/node-kernel-host.ts index fddc698d5c..cd469be6fd 100644 --- a/host/src/node-kernel-host.ts +++ b/host/src/node-kernel-host.ts @@ -41,7 +41,7 @@ import { WASM_PAGE_SIZE, } from "./constants"; import { awaitGracefulKernelRealmDestroy } from "./kernel-realm-destroy"; -import type { MountSpec } from "./vfs/default-mounts"; +import type { NodeSessionSeedTree } from "./vfs/default-mounts-node"; export type { HttpRequest, HttpResponse }; @@ -145,6 +145,16 @@ export interface NodeKernelHostOptions { /** Virtual group for existing host-backed mount entries. Defaults to root. */ gid?: number; }>; + /** + * Seed an existing per-boot scratch mount from an absolute, quiescent host + * directory. + * + * Initialization copies each tree before the worker publishes readiness and + * never writes changes back. Destinations must be strict descendants of a + * declared scratch mount. The source must remain quiescent until init() + * resolves. + */ + sessionSeedTrees?: readonly NodeSessionSeedTree[]; } export interface SpawnOptions { @@ -222,9 +232,19 @@ export class NodeKernelHost { const rootfsLazyAssets = this.options.rootfsLazyAssets === undefined ? undefined : snapshotClosedLazyAssets(this.options.rootfsLazyAssets); - const rootfsLazyAssetSources = this.options.rootfsLazyAssetSources === undefined - ? undefined - : snapshotClosedLazyAssetSources(this.options.rootfsLazyAssetSources); + const sessionSeedTrees = this.options.sessionSeedTrees?.map( + (seed) => ({ + sourcePath: seed.sourcePath, + destinationPath: seed.destinationPath, + }), + ); + if ( + sessionSeedTrees !== undefined + && sessionSeedTrees.length > 0 + && rootfsImage === null + ) { + throw new Error("sessionSeedTrees requires rootfsImage"); + } this.worker = spawnKernelWorkerThread(); this.workerStarted = true; @@ -354,6 +374,7 @@ export class NodeKernelHost { rootfsLazyAssets, rootfsLazyAssetSources, extraMounts: this.options.extraMounts, + sessionSeedTrees, enableTcpNetwork: this.options.enableTcpNetwork, }; const transfer = (rootfsLazyAssets ?? []).map( diff --git a/host/src/node-kernel-protocol.ts b/host/src/node-kernel-protocol.ts index 0a5b980bd5..d55baf5a11 100644 --- a/host/src/node-kernel-protocol.ts +++ b/host/src/node-kernel-protocol.ts @@ -14,11 +14,8 @@ import type { HttpRequest, HttpResponse } from "./networking/in-kernel-http"; import type { HostDiagnosticMessage } from "./host-diagnostic"; import type { LazyDownloadEvent } from "./vfs/memory-fs"; -import type { - ClosedLazyAsset, - ClosedLazyAssetSource, -} from "./vfs/closed-lazy-assets"; -import type { MountSpec } from "./vfs/default-mounts"; +import type { ClosedLazyAsset } from "./vfs/closed-lazy-assets"; +import type { NodeSessionSeedTree } from "./vfs/default-mounts-node"; export type { HttpRequest, HttpResponse }; export type { HostDiagnostic } from "./host-diagnostic"; @@ -66,6 +63,11 @@ export interface InitMessage { uid?: number; gid?: number; }>; + /** + * Quiescent host trees copied beneath existing worker-owned scratch mounts + * before ready. Guest mutations never write back to the source. + */ + sessionSeedTrees?: NodeSessionSeedTree[]; /** Attach a real-TCP backend (TcpNetworkBackend) to the worker's PlatformIO * so wasm programs can dial external hosts via Node `net.Socket`. */ enableTcpNetwork?: boolean; diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts index f25b9a6e90..5fc092c1ae 100644 --- a/host/src/node-kernel-worker-entry.ts +++ b/host/src/node-kernel-worker-entry.ts @@ -862,6 +862,7 @@ async function buildVirtualPlatformIO( uid?: number; gid?: number; }>, + sessionSeedTrees?: InitMessage["sessionSeedTrees"], rootfsLazyUrlBase?: InitMessage["rootfsLazyUrlBase"], rootfsLazyAssets?: InitMessage["rootfsLazyAssets"], rootfsLazyAssetSources?: InitMessage["rootfsLazyAssetSources"], @@ -874,6 +875,8 @@ async function buildVirtualPlatformIO( DEFAULT_MOUNT_SPEC, new Uint8Array(rootfsImage), bootSessionDir, + sessionSeedTrees, + (extraMounts ?? []).map((mount) => mount.mountPoint), ); } catch (error) { // WHY: imported-seal rejection occurs before scratch setup, but the Node @@ -938,7 +941,10 @@ function cleanupSessionDir(): void { try { rmSync(sessionDir, { recursive: true, force: true }); } catch { - // best-effort: tests should still pass even if cleanup races a hold + // WHY: a graceful/fatal worker path must attempt cleanup, but native + // handles can transiently retain files and abrupt process termination + // cannot run this hook. Never treat this best-effort cleanup as the + // ownership proof; private inode creation before ready is that proof. } } sessionDir = null; @@ -964,12 +970,16 @@ async function handleInit(msg: InitMessage) { }); execPrograms = msg.execPrograms ?? {}; workerAdapter = new NodeWorkerAdapter(); + if (!msg.rootfsImage && (msg.sessionSeedTrees?.length ?? 0) > 0) { + throw new Error("sessionSeedTrees requires rootfsImage"); + } const io: PlatformIO = msg.rootfsImage ? await buildVirtualPlatformIO( msg.rootfsImage, msg.rootfsMountSpec, msg.extraMounts, + msg.sessionSeedTrees, msg.rootfsLazyUrlBase, msg.rootfsLazyAssets, msg.rootfsLazyAssetSources, diff --git a/host/src/vfs/default-mounts-node.ts b/host/src/vfs/default-mounts-node.ts index 4c27dff8c1..4f49910cdc 100644 --- a/host/src/vfs/default-mounts-node.ts +++ b/host/src/vfs/default-mounts-node.ts @@ -4,8 +4,24 @@ * `node:path` / `HostFileSystem` into browser bundles. */ -import { join } from "node:path"; -import { mkdirSync } from "node:fs"; +import { + constants, + cpSync, + existsSync, + lstatSync, + mkdirSync, + realpathSync, + renameSync, + rmSync, +} from "node:fs"; +import { + dirname, + isAbsolute, + join, + posix as guestPath, + relative, + sep, +} from "node:path"; import type { MountConfig } from "./types"; import { MemoryFileSystem } from "./memory-fs"; import { @@ -43,8 +59,26 @@ async function resolveValidatedForNode( rootfsImage: Uint8Array, sessionDir: string, sessionOwned: boolean, + sessionSeedTrees: readonly NodeSessionSeedTree[] = [], + shadowingMountPoints: readonly string[] = [], ): Promise { const imageMounts = await restoreVerifiedImageMounts(spec, rootfsImage); + for (const m of spec) { + if (m.source !== "scratch") continue; + const hostDir = join(sessionDir, m.path); + mkdirSync(hostDir, { recursive: true, mode: m.mode }); + } + if (sessionOwned) { + materializeSessionSeedTrees( + spec, + sessionDir, + sessionSeedTrees, + shadowingMountPoints, + ); + } else if (sessionSeedTrees.length > 0) { + throw new Error("session seed trees require a worker-owned session"); + } + const out: MountConfig[] = []; for (const m of spec) { if (m.source === "image") { @@ -59,7 +93,6 @@ async function resolveValidatedForNode( }); } else { const hostDir = join(sessionDir, m.path); - mkdirSync(hostDir, { recursive: true, mode: m.mode }); const backend = sessionOwned ? createSessionOwnedHostFileSystem(hostDir) : new HostFileSystem(hostDir); @@ -89,7 +122,190 @@ export function resolveForNodeKernelSession( spec: MountSpec[], rootfsImage: Uint8Array, sessionDir: string, + sessionSeedTrees: readonly NodeSessionSeedTree[] = [], + shadowingMountPoints: readonly string[] = [], ): Promise { validateSpec(spec); - return resolveValidatedForNode(spec, rootfsImage, sessionDir, true); + return resolveValidatedForNode( + spec, + rootfsImage, + sessionDir, + true, + sessionSeedTrees, + shadowingMountPoints, + ); +} + +export interface NodeSessionSeedTree { + /** Absolute host path to a quiescent directory. */ + sourcePath: string; + /** Absolute guest destination strictly beneath a declared scratch mount. */ + destinationPath: string; +} + +function containsPath(parent: string, child: string): boolean { + const rel = relative(parent, child); + return rel === "" || ( + rel !== ".." + && !rel.startsWith(`..${sep}`) + && !isAbsolute(rel) + ); +} + +function guestPathContains(parent: string, child: string): boolean { + return child === parent || ( + parent === "/" + ? child.startsWith("/") + : child.startsWith(`${parent}/`) + ); +} + +function guestPathStrictlyContains(parent: string, child: string): boolean { + return child !== parent && guestPathContains(parent, child); +} + +function requireCanonicalGuestPath(path: string, kind: string): void { + if (path.includes("\0")) { + throw new Error(`${kind} contains NUL`); + } + if (guestPath.normalize(path) !== path) { + throw new Error(`${kind} must be a canonical POSIX path: ${path}`); + } +} + +function supportedSeedEntry(path: string): boolean { + const stat = lstatSync(path); + if (stat.isDirectory() || stat.isFile()) return true; + throw new Error( + `session seed source contains a symlink or unsupported special entry: ${path}`, + ); +} + +/** + * Materialize quiescent caller fixtures inside already-declared scratch roots. + * Every tree is copied to an opaque private staging path first. Only after all + * copies succeed are they renamed into place, and only after that does the + * caller construct the session-owned HostFileSystem backends. + */ +function materializeSessionSeedTrees( + spec: readonly MountSpec[], + sessionDir: string, + seeds: readonly NodeSessionSeedTree[], + shadowingMountPoints: readonly string[], +): void { + if (seeds.length === 0) return; + const sessionRoot = realpathSync(sessionDir); + const scratchRoots = spec + .filter((mount) => mount.source === "scratch") + .map((mount) => mount.path) + .sort((left, right) => right.length - left.length); + + validateSpec(seeds.map((seed) => ({ + path: seed.destinationPath, + source: "scratch" as const, + }))); + validateSpec(shadowingMountPoints.map((path) => ({ + path, + source: "scratch" as const, + }))); + for (const seed of seeds) { + requireCanonicalGuestPath( + seed.destinationPath, + "session seed destination", + ); + } + for (const mountPoint of shadowingMountPoints) { + requireCanonicalGuestPath(mountPoint, "shadowing mount point"); + } + const prepared = seeds.map((seed, index) => { + const scratchRoot = scratchRoots.find( + (root) => guestPathStrictlyContains(root, seed.destinationPath), + ); + if (scratchRoot === undefined) { + throw new Error( + `session seed destination must be below a scratch mount: ${seed.destinationPath}`, + ); + } + for (const mountPoint of shadowingMountPoints) { + if ( + guestPathContains(mountPoint, seed.destinationPath) + || guestPathContains(seed.destinationPath, mountPoint) + ) { + throw new Error( + `session seed destination overlaps another mount: ${seed.destinationPath} and ${mountPoint}`, + ); + } + } + for (const other of seeds) { + if ( + other !== seed + && ( + guestPathContains(seed.destinationPath, other.destinationPath) + || guestPathContains(other.destinationPath, seed.destinationPath) + ) + ) { + throw new Error( + `session seed destinations overlap: ${seed.destinationPath} and ${other.destinationPath}`, + ); + } + } + if (typeof seed.sourcePath !== "string" || seed.sourcePath.length === 0) { + throw new Error("session seed source path must not be empty"); + } + if (!isAbsolute(seed.sourcePath)) { + throw new Error( + `session seed source path must be absolute: ${seed.sourcePath}`, + ); + } + const sourceStat = lstatSync(seed.sourcePath); + if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) { + throw new Error( + `session seed source must name a directory: ${seed.sourcePath}`, + ); + } + const sourceRoot = realpathSync(seed.sourcePath); + if ( + containsPath(sourceRoot, sessionRoot) + || containsPath(sessionRoot, sourceRoot) + ) { + throw new Error( + `session seed source overlaps or contains the private session: ${sourceRoot}`, + ); + } + const destination = join(sessionRoot, seed.destinationPath); + if (!containsPath(sessionRoot, destination)) { + throw new Error( + `session seed destination escapes the private session: ${seed.destinationPath}`, + ); + } + if (existsSync(destination)) { + throw new Error( + `session seed destination already exists: ${seed.destinationPath}`, + ); + } + return { + destination, + sourceRoot, + staging: join(sessionRoot, `.seed-staging-${index}`), + }; + }); + + for (const { sourceRoot, staging } of prepared) { + mkdirSync(staging, { mode: 0o700 }); + cpSync(sourceRoot, join(staging, "tree"), { + recursive: true, + force: false, + errorOnExist: true, + preserveTimestamps: true, + // COPYFILE_FICLONE falls back to an ordinary copy when the filesystem + // has no clone primitive; unlike a hardlink, either result owns writes. + mode: constants.COPYFILE_FICLONE, + filter: (source) => supportedSeedEntry(source), + }); + } + for (const { destination, staging } of prepared) { + mkdirSync(dirname(destination), { recursive: true, mode: 0o700 }); + renameSync(join(staging, "tree"), destination); + rmSync(staging, { recursive: true, force: true }); + } } diff --git a/host/test/node-host-mounts.test.ts b/host/test/node-host-mounts.test.ts index fa36c555f7..a04a334b57 100644 --- a/host/test/node-host-mounts.test.ts +++ b/host/test/node-host-mounts.test.ts @@ -22,7 +22,15 @@ */ import { describe, it, expect } from "vitest"; -import { readFileSync, existsSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { runCentralizedProgram } from "./centralized-test-helper"; @@ -41,6 +49,24 @@ const opensslConfigSource = join(repoRoot, "images/rootfs/etc/ssl/openssl.cnf"); const haveProbe = existsSync(probeWasm); const haveRootfs = existsSync(rootfsImage); +describe("node session seed configuration", () => { + it("rejects seeds without a rootfs before starting a worker", async () => { + const host = new NodeKernelHost({ + sessionSeedTrees: [{ + sourcePath: "/not-consulted", + destinationPath: "/tmp/seed", + }], + }); + try { + await expect(host.init(new ArrayBuffer(0))).rejects.toThrow( + "sessionSeedTrees requires rootfsImage", + ); + } finally { + await host.destroy(); + } + }); +}); + describe.skipIf(!haveProbe || !haveRootfs)("node-host default mount setup", () => { it("stores exact root-owned OpenSSL files in the canonical image", () => { const fs = MemoryFileSystem.fromImage( @@ -99,6 +125,58 @@ describe.skipIf(!haveProbe || !haveRootfs)("node-host default mount setup", () = expect(result.stdout).toContain("content=scratch-mount-roundtrip"); }); + it("gives concurrent boots independent private copies of one seed tree", async () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "kandelo-node-seed-")); + const sourceFile = join(fixtureRoot, "suite", "fixture"); + mkdirSync(dirname(sourceFile), { recursive: true }); + writeFileSync(sourceFile, "seed"); + const program = readFileSync(probeWasm); + const programBytes = program.buffer.slice( + program.byteOffset, + program.byteOffset + program.byteLength, + ); + const options = { + rootfsImage: "default" as const, + sessionSeedTrees: [{ + sourcePath: fixtureRoot, + destinationPath: "/tmp/kandelo-run", + }], + }; + const first = new NodeKernelHost(options); + const second = new NodeKernelHost(options); + + try { + await Promise.all([first.init(), second.init()]); + writeFileSync(sourceFile, "external"); + + for (const host of [first, second]) { + await expect( + host.readFileFromVfs("/tmp/kandelo-run/suite/fixture"), + ).resolves.toEqual(new TextEncoder().encode("seed")); + } + + expect( + await first.spawn(programBytes, [ + "mount_probe_test", + "scratch", + "/tmp/kandelo-run/suite/fixture", + ]), + ).toBe(0); + await expect( + first.readFileFromVfs("/tmp/kandelo-run/suite/fixture"), + ).resolves.toEqual( + new TextEncoder().encode("scratch-mount-roundtrip\n"), + ); + await expect( + second.readFileFromVfs("/tmp/kandelo-run/suite/fixture"), + ).resolves.toEqual(new TextEncoder().encode("seed")); + expect(readFileSync(sourceFile, "utf8")).toBe("external"); + } finally { + await Promise.allSettled([first.destroy(), second.destroy()]); + rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + it.each([ ["/etc/ssl/openssl.cnf", opensslConfigSource], ["/etc/ssl/cert.pem", caCertSource], diff --git a/host/test/run-example-vfs.test.ts b/host/test/run-example-vfs.test.ts new file mode 100644 index 0000000000..e1126fdddd --- /dev/null +++ b/host/test/run-example-vfs.test.ts @@ -0,0 +1,150 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + buildRunExampleGuestEnvironment, + resolveRunExampleFilesystem, +} from "../../examples/run-example-vfs"; + +describe("run-example isolated filesystem", () => { + it("preserves the legacy raw-host mode unless isolation is explicit", () => { + expect(resolveRunExampleFilesystem({}, "/host/cwd")).toEqual({ + guestCwd: "/host/cwd", + isolated: false, + }); + expect(resolveRunExampleFilesystem({ + KANDELO_RUNNER_VFS: "raw", + KERNEL_CWD: "/explicit/raw/cwd", + }, "/host/cwd")).toEqual({ + guestCwd: "/explicit/raw/cwd", + isolated: false, + }); + }); + + it("uses the canonical rootfs and lifecycle-owned /tmp without a fixture cwd", () => { + expect(resolveRunExampleFilesystem({ + KANDELO_RUNNER_VFS: "isolated", + }, "/host/cwd")).toEqual({ + guestCwd: "/tmp", + isolated: true, + rootfsImage: "default", + }); + }); + + it("snapshots an explicit fixture root and resolves the initial program inside it", () => { + const sourceRoot = mkdtempSync(join(tmpdir(), "kandelo-runner-fixture-")); + const sourceCwd = join(sourceRoot, "basic"); + const sourceProgram = join(sourceCwd, "stdio", "fopen"); + mkdirSync(join(sourceCwd, "stdio"), { recursive: true }); + writeFileSync(sourceProgram, "wasm fixture"); + try { + expect(resolveRunExampleFilesystem({ + KANDELO_RUNNER_VFS: "isolated", + KANDELO_RUNNER_FIXTURE_ROOT: sourceRoot, + KANDELO_RUNNER_FIXTURE_CWD: "basic", + KANDELO_RUNNER_GUEST_PROGRAM: "basic/stdio/fopen", + }, "/ignored")).toEqual({ + guestCwd: "/tmp/kandelo-run/basic", + guestProgram: "/tmp/kandelo-run/basic/stdio/fopen", + isolated: true, + rootfsImage: "default", + sessionSeedTrees: [{ + destinationPath: "/tmp/kandelo-run", + sourcePath: realpathSync(sourceRoot), + }], + }); + } finally { + rmSync(sourceRoot, { recursive: true, force: true }); + } + }); + + it("rejects ambiguous modes and guest programs outside the owned snapshot", () => { + expect(() => + resolveRunExampleFilesystem({ + KANDELO_RUNNER_VFS: "sometimes", + }, "/host/cwd") + ).toThrow(/KANDELO_RUNNER_VFS/); + + expect(() => + resolveRunExampleFilesystem({ + KANDELO_RUNNER_VFS: "isolated", + KANDELO_RUNNER_GUEST_PROGRAM: "program", + }, "/host/cwd") + ).toThrow(/requires KANDELO_RUNNER_FIXTURE_ROOT/); + + expect(() => + resolveRunExampleFilesystem({ + KANDELO_RUNNER_VFS: "isolated", + KERNEL_CWD: "/", + }, "/host/cwd") + ).toThrow(/KERNEL_CWD.*raw/i); + }); + + it("does not leak runner controls or host-only path variables into a guest", () => { + const entries = buildRunExampleGuestEnvironment( + { + KEEP: "yes", + PATH: "/host/bin", + PWD: "/host/cwd", + OLDPWD: "/host/old", + TMPDIR: "/host/tmp", + TMP: "/host/tmp-short", + TEMP: "/host/temp", + HOME: "/host/home", + KERNEL_CWD: "/host/fixtures", + KERNEL_PATH: "/host/programs", + KERNEL_UID: "1000", + KERNEL_GID: "1000", + KANDELO_GUEST_OUTPUT_FILE: "/host/output", + KANDELO_RUNNER_VFS: "isolated", + KANDELO_RUNNER_FIXTURE_ROOT: "/host/fixture-root", + KANDELO_RUNNER_FIXTURE_CWD: "suite", + KANDELO_RUNNER_GUEST_PROGRAM: "suite/test", + }, + "/guest/cwd", + true, + "/usr/bin:/bin", + ); + + expect(entries).toContain("KEEP=yes"); + expect(entries).toContain("PATH=/usr/bin:/bin"); + expect(entries).toContain("PWD=/guest/cwd"); + expect(entries).toContain("TMPDIR=/tmp"); + expect(entries).toContain("HOME=/root"); + expect(entries.some((entry) => entry.startsWith("OLDPWD="))).toBe(false); + expect(entries.some((entry) => entry.startsWith("KERNEL_CWD="))).toBe(false); + expect(entries.some((entry) => entry.startsWith("KERNEL_"))).toBe(false); + expect(entries.some((entry) => entry.startsWith("KANDELO_"))).toBe(false); + }); + + it("keeps legacy KERNEL_* guest entries only in raw-host mode", () => { + const raw = buildRunExampleGuestEnvironment( + { + KERNEL_APPLICATION_VALUE: "visible", + KANDELO_RUNNER_VFS: "raw", + }, + "/host/cwd", + false, + "/guest/bin", + ); + expect(raw).toContain("KERNEL_APPLICATION_VALUE=visible"); + expect(raw).not.toContain("KANDELO_RUNNER_VFS=raw"); + + const isolated = buildRunExampleGuestEnvironment( + { KERNEL_APPLICATION_VALUE: "hidden" }, + "/tmp", + true, + "/guest/bin", + ); + expect(isolated).not.toContain("KERNEL_APPLICATION_VALUE=hidden"); + }); +}); diff --git a/host/test/vfs/default-mounts.test.ts b/host/test/vfs/default-mounts.test.ts index b3b05b6eb8..39c053d0dd 100644 --- a/host/test/vfs/default-mounts.test.ts +++ b/host/test/vfs/default-mounts.test.ts @@ -1,7 +1,19 @@ import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; -import { mkdtempSync, rmSync, readFileSync, existsSync, statSync } from "node:fs"; +import { + existsSync, + linkSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; import { MemoryFileSystem } from "../../src/vfs/memory-fs"; import { HostFileSystem } from "../../src/vfs/host-fs"; import { @@ -10,8 +22,10 @@ import { resolveForBrowser, type MountSpec, } from "../../src/vfs/default-mounts"; -import { resolveForNode } from "../../src/vfs/default-mounts-node"; -import { restoreBrowserKernelInitMounts } from "../../src/browser-kernel-vfs-init"; +import { + resolveForNode, + resolveForNodeKernelSession, +} from "../../src/vfs/default-mounts-node"; import { addSealedLazyAtomicTestTree, forgeLazyAtomicSeal, @@ -22,6 +36,7 @@ const O_RDONLY = 0x0000; const O_WRONLY = 0x0001; const O_CREAT = 0x0040; const O_TRUNC = 0x0200; +const O_APPEND = 0x0400; const PERMISSION_MASK = 0o777; const FILE_TYPE_MASK = 0xf000; const DIRECTORY_MODE = 0x4000; @@ -87,6 +102,19 @@ async function withUmask( } } +function typeScriptSources(root: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (entry.isDirectory()) { + files.push(...typeScriptSources(path)); + } else if (entry.isFile() && entry.name.endsWith(".ts")) { + files.push(path); + } + } + return files; +} + describe("DEFAULT_MOUNT_SPEC", () => { it("includes the eight canonical mount points", () => { const paths = DEFAULT_MOUNT_SPEC.map((m) => m.path).sort(); @@ -287,6 +315,264 @@ describe("resolveForNode", () => { }); }); +describe("Node worker session seed trees", () => { + it("keeps exact native append branding limited to the private-session resolver", () => { + const sourceRoot = fileURLToPath(new URL("../../src/", import.meta.url)); + const token = "createSessionOwnedHostFileSystem"; + const uses = typeScriptSources(sourceRoot) + .map((path) => ({ + count: readFileSync(path, "utf8").split(token).length - 1, + file: relative(sourceRoot, path).replaceAll("\\", "/"), + })) + .filter(({ count }) => count > 0) + .sort((left, right) => left.file.localeCompare(right.file)); + + expect(uses).toEqual([ + { count: 2, file: "vfs/default-mounts-node.ts" }, + { count: 1, file: "vfs/host-fs.ts" }, + ]); + }); + + it("authenticates the root image before inspecting or staging seeds", async () => { + const sessionRoot = mkdtempSync(join(tmpdir(), "kandelo-seed-auth-")); + const forgedImage = await buildForgedLegacyDinitImage("member"); + try { + await expect( + resolveForNodeKernelSession( + DEFAULT_MOUNT_SPEC, + forgedImage, + sessionRoot, + [{ + sourcePath: join(sessionRoot, "does-not-exist"), + destinationPath: "/tmp/kandelo-run", + }], + ), + ).rejects.toThrow(/activation member/i); + expect(existsSync(join(sessionRoot, "tmp"))).toBe(false); + expect(existsSync(join(sessionRoot, ".seed-staging-0"))).toBe(false); + } finally { + rmSync(sessionRoot, { recursive: true, force: true }); + } + }); + + it("breaks external hardlink aliases before granting exact append authority", async () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "kandelo-snapshot-source-")); + const sessionRoot = mkdtempSync(join(tmpdir(), "kandelo-snapshot-session-")); + const outside = join(fixtureRoot, "outside"); + const source = join(fixtureRoot, "fixtures"); + const staged = join(source, "suite", "fixture"); + try { + mkdirSync(join(source, "suite"), { recursive: true }); + writeFileSync(outside, "seed"); + linkSync(outside, staged); + + const mounts = await resolveForNodeKernelSession( + DEFAULT_MOUNT_SPEC, + await buildFixtureImage(), + sessionRoot, + [{ + sourcePath: source, + destinationPath: "/tmp/kandelo-run", + }], + ); + const mount = mounts.find((entry) => entry.mountPoint === "/tmp")!; + + // The source entry still aliases the external file. Mutating it after + // initialization must not replace bytes inside the worker-owned copy. + writeFileSync(outside, "external"); + expect(readFileSync(staged, "utf8")).toBe("external"); + expect(new TextDecoder().decode( + readMountFile(mount.backend, "/kandelo-run/suite/fixture"), + )).toBe("seed"); + + const bytes = new TextEncoder().encode("+guest"); + const fd = mount.backend.open( + "/kandelo-run/suite/fixture", + O_WRONLY | O_APPEND, + 0, + ); + try { + expect(mount.backend.append(fd, bytes, bytes.byteLength, null)).toEqual({ + written: bytes.byteLength, + end: 10, + }); + } finally { + mount.backend.close(fd); + } + + expect(new TextDecoder().decode( + readMountFile(mount.backend, "/kandelo-run/suite/fixture"), + )).toBe("seed+guest"); + expect(readFileSync(outside, "utf8")).toBe("external"); + } finally { + rmSync(sessionRoot, { recursive: true, force: true }); + rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + + it("rejects image destinations and a source that contains the private session", async () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "kandelo-snapshot-validation-")); + const source = join(fixtureRoot, "source"); + const nestedSession = join(source, "worker-session"); + mkdirSync(nestedSession, { recursive: true }); + try { + await expect( + resolveForNodeKernelSession( + DEFAULT_MOUNT_SPEC, + await buildFixtureImage(), + nestedSession, + [{ sourcePath: source, destinationPath: "/etc/fixtures" }], + ) + ).rejects.toThrow(/below a scratch mount/i); + + await expect( + resolveForNodeKernelSession( + DEFAULT_MOUNT_SPEC, + await buildFixtureImage(), + nestedSession, + [{ + sourcePath: source, + destinationPath: "/tmp/kandelo-run", + }], + ) + ).rejects.toThrow(/contains the private session/i); + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + + it("rejects source symlinks before publishing a seeded backend", async () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "kandelo-seed-symlink-")); + const sessionRoot = mkdtempSync(join(tmpdir(), "kandelo-seed-session-")); + const source = join(fixtureRoot, "source"); + mkdirSync(source); + writeFileSync(join(fixtureRoot, "outside"), "outside"); + symlinkSync("../outside", join(source, "escape")); + try { + await expect( + resolveForNodeKernelSession( + DEFAULT_MOUNT_SPEC, + await buildFixtureImage(), + sessionRoot, + [{ + sourcePath: source, + destinationPath: "/tmp/kandelo-run", + }], + ), + ).rejects.toThrow(/symlink or unsupported special entry/i); + } finally { + rmSync(sessionRoot, { recursive: true, force: true }); + rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + + it("publishes no final destination when a later seed copy fails", async () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "kandelo-seed-atomic-")); + const sessionRoot = mkdtempSync(join(tmpdir(), "kandelo-seed-session-")); + const valid = join(fixtureRoot, "valid"); + const invalid = join(fixtureRoot, "invalid"); + mkdirSync(valid); + mkdirSync(invalid); + writeFileSync(join(valid, "complete"), "complete"); + writeFileSync(join(fixtureRoot, "outside"), "outside"); + symlinkSync("../outside", join(invalid, "escape")); + try { + await expect( + resolveForNodeKernelSession( + DEFAULT_MOUNT_SPEC, + await buildFixtureImage(), + sessionRoot, + [ + { sourcePath: valid, destinationPath: "/tmp/first" }, + { sourcePath: invalid, destinationPath: "/tmp/second" }, + ], + ), + ).rejects.toThrow(/symlink or unsupported special entry/i); + expect(existsSync(join(sessionRoot, "tmp", "first"))).toBe(false); + expect(existsSync(join(sessionRoot, "tmp", "second"))).toBe(false); + } finally { + rmSync(sessionRoot, { recursive: true, force: true }); + rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + + it("rejects overlapping, mount-shadowed, and mount-root destinations", async () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "kandelo-seed-overlap-")); + const sessionRoot = mkdtempSync(join(tmpdir(), "kandelo-seed-session-")); + const first = join(fixtureRoot, "first"); + const second = join(fixtureRoot, "second"); + mkdirSync(first); + mkdirSync(second); + const image = await buildFixtureImage(); + try { + await expect( + resolveForNodeKernelSession( + DEFAULT_MOUNT_SPEC, + image, + sessionRoot, + [ + { sourcePath: first, destinationPath: "/tmp/fixtures" }, + { sourcePath: second, destinationPath: "/tmp/fixtures/nested" }, + ], + ), + ).rejects.toThrow(/destinations overlap/i); + + await expect( + resolveForNodeKernelSession( + DEFAULT_MOUNT_SPEC, + image, + sessionRoot, + [{ sourcePath: first, destinationPath: "/tmp/extra/fixtures" }], + ["/tmp/extra"], + ), + ).rejects.toThrow(/overlaps another mount/i); + + await expect( + resolveForNodeKernelSession( + DEFAULT_MOUNT_SPEC, + image, + sessionRoot, + [{ sourcePath: first, destinationPath: "/tmp" }], + ), + ).rejects.toThrow(/below a scratch mount/i); + + await expect( + resolveForNodeKernelSession( + DEFAULT_MOUNT_SPEC, + image, + sessionRoot, + [ + { sourcePath: first, destinationPath: "/tmp/fixtures" }, + { sourcePath: second, destinationPath: "/tmp//fixtures" }, + ], + ), + ).rejects.toThrow(/canonical POSIX path/i); + + await expect( + resolveForNodeKernelSession( + DEFAULT_MOUNT_SPEC, + image, + sessionRoot, + [{ sourcePath: first, destinationPath: "/tmp/extra/fixtures" }], + ["/tmp//extra"], + ), + ).rejects.toThrow(/canonical POSIX path/i); + + await expect( + resolveForNodeKernelSession( + DEFAULT_MOUNT_SPEC, + image, + sessionRoot, + [{ sourcePath: "relative", destinationPath: "/tmp/relative" }], + ), + ).rejects.toThrow(/source path must be absolute/i); + } finally { + rmSync(sessionRoot, { recursive: true, force: true }); + rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); +}); + describe("resolveForBrowser", () => { let image: Uint8Array; // Shrink scratch SABs so the 7 scratch mounts × default 16 MiB don't diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index 87a7a9c0a9..4dccc3c09c 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -158,8 +158,8 @@ "lamp": { "manifestSha256": "250b64635d64f8537178188fb5488dbfd2980d79a1c2ffbf346af67008088ba0", "cacheKeys": { - "wasm32": "8085776999cc7294e67319a78c17e77cc2ae49ec6b1004db0b0c1180ebd07bb3", - "wasm64": "d0d60c463a9da1972646f54347bebeb5b76ef49ef8a183a00c4414d44e3f6aaf" + "wasm32": "6c26a65cfa35135b60eed070558e6fb2ea0945d6330ef7528fd3565a2501a5e4", + "wasm64": "484ef7c28d94acb8bb58fa8a07df7e4f7dae049bc918146ca9d598edaf96c3f8" } }, "less": { @@ -312,15 +312,15 @@ "nginx-php-vfs": { "manifestSha256": "97976410cb02f8ba710d856b4ac904bcf976d677b50eefdee39fb64176070d4b", "cacheKeys": { - "wasm32": "455fdfa5cd64c0693a1e8bce0e131535f5390d982eb9093e92e22fa48914c02f", - "wasm64": "39c7747d3f54ff21722fd26d006ce5aede33ccefd9af98ec7ee6a3929152a9b5" + "wasm32": "feea3fbd8d70dfeacb3df791603c5e88b7ac68e69a094f087ea2995325a3e3be", + "wasm64": "3b1adc8243ed2774e5702a2d2b7d9b958ab408bf4756fb7aaffea9091f186662" } }, "nginx-vfs": { "manifestSha256": "46aa2d3250ac5cd0f102a85c3a36a086c7a50ba1f9e05fa1c2aae3d07ad16f10", "cacheKeys": { - "wasm32": "10929f2b992c5077f555249014e14860b9d6892d71c4025716f97f1d322a8fbc", - "wasm64": "4c9ab0d541e601ef08683e1526f229f1be4ed96bc4a1f77bdc06cebf3277e755" + "wasm32": "b0b3989229df0012a0862874b36394c0211e78e06a94430a3badda2563528755", + "wasm64": "bce2985422b0b94ebcaa9c6869e6eff6e1378673f5b04b9d9ccf4a8c6ef54460" } }, "node": { @@ -333,8 +333,8 @@ "node-vfs": { "manifestSha256": "977f219defe57e18017ecc963159df32efdd21c53d6fea05943703d04739d8de", "cacheKeys": { - "wasm32": "86879198bf456995df812f95849a1f417cffb50f5179c21d003dc08e3c666805", - "wasm64": "b919b171146119a344c0e96ee0a257b9ecd9661a386955abcb93608f0ae4f8b5" + "wasm32": "8222a97d7830cf1a808c2e37c9308d95fdf38f498860e892c7f7dddbf016f6be", + "wasm64": "a2ad8305d312eda3229c4f957d158b9e06439a07c562fdae87a861866489919a" } }, "openssl": { @@ -396,8 +396,8 @@ "redis-vfs": { "manifestSha256": "234c45c40f94e2a89f98295313b82cb9fce15a412ac829d9e87394e0dc731813", "cacheKeys": { - "wasm32": "4ce06c0ed98f3ea31feca6b2a876aa60476e0a974c98c51666f827ace19c7e68", - "wasm64": "fe5a28309e798fdb0140e366dd6450235600a6ba6ff9a38ca0c56f7e6600edaa" + "wasm32": "2aed064a8c4f2d1dceb514a0f329ec69a30eecdbcb652ca5959ef8fb83263577", + "wasm64": "5914af395a444964197ed1f21b0f8b53b4406d4ae7b27e146c4d76c2cadba4b7" } }, "rootfs": { @@ -515,8 +515,8 @@ "wordpress": { "manifestSha256": "36465e0596a06e855a8524eecfd87da98643bc13b37ee12939f5aab44bf33418", "cacheKeys": { - "wasm32": "34fc17691302a51f9908216cff9f481eacff2fbd46d03bf296de47dc0ea467a7", - "wasm64": "02eda2a59d9c55ed343c5620d601b51205ed7a3586aedec3a0384f13e4017686" + "wasm32": "72342d9e4e6ef53705191d4dcdeb6b2e2cd86e18520bd4b2600933aaebc05cf0", + "wasm64": "66a742d2899c0d2ac128fba25b924ded8ccc42959ff6f42a797fb57a6c4a4b6d" } }, "xz": { @@ -1093,7 +1093,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "8085776999cc7294e67319a78c17e77cc2ae49ec6b1004db0b0c1180ebd07bb3" + "wasm32": "6c26a65cfa35135b60eed070558e6fb2ea0945d6330ef7528fd3565a2501a5e4" }, "dependencyClosures": { "wasm32": [ @@ -1718,7 +1718,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "455fdfa5cd64c0693a1e8bce0e131535f5390d982eb9093e92e22fa48914c02f" + "wasm32": "feea3fbd8d70dfeacb3df791603c5e88b7ac68e69a094f087ea2995325a3e3be" }, "dependencyClosures": { "wasm32": [ @@ -1810,7 +1810,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "10929f2b992c5077f555249014e14860b9d6892d71c4025716f97f1d322a8fbc" + "wasm32": "b0b3989229df0012a0862874b36394c0211e78e06a94430a3badda2563528755" }, "dependencyClosures": { "wasm32": [ @@ -1894,7 +1894,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "86879198bf456995df812f95849a1f417cffb50f5179c21d003dc08e3c666805" + "wasm32": "8222a97d7830cf1a808c2e37c9308d95fdf38f498860e892c7f7dddbf016f6be" }, "dependencyClosures": { "wasm32": [ @@ -2450,7 +2450,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "4ce06c0ed98f3ea31feca6b2a876aa60476e0a974c98c51666f827ace19c7e68" + "wasm32": "2aed064a8c4f2d1dceb514a0f329ec69a30eecdbcb652ca5959ef8fb83263577" }, "dependencyClosures": { "wasm32": [ @@ -2926,7 +2926,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "34fc17691302a51f9908216cff9f481eacff2fbd46d03bf296de47dc0ea467a7" + "wasm32": "72342d9e4e6ef53705191d4dcdeb6b2e2cd86e18520bd4b2600933aaebc05cf0" }, "dependencyClosures": { "wasm32": [ diff --git a/scripts/run-libc-tests.sh b/scripts/run-libc-tests.sh index 53a50423be..674edaa987 100755 --- a/scripts/run-libc-tests.sh +++ b/scripts/run-libc-tests.sh @@ -290,7 +290,15 @@ run_test() { # stdin redirected to /dev/null: run-example.ts reads process.stdin # when not a TTY, which would drain any pipe the caller supplies. set +e - output=$(cd "$REPO_ROOT" && timeout "$TEST_TIMEOUT" node --experimental-wasm-exnref --import tsx/esm examples/run-example.ts "${wasm}" &1) + output=$(cd "$REPO_ROOT" && \ + KERNEL_CWD= \ + KANDELO_RUNNER_FIXTURE_ROOT= \ + KANDELO_RUNNER_FIXTURE_CWD= \ + KANDELO_RUNNER_GUEST_PROGRAM= \ + KANDELO_RUNNER_VFS=isolated \ + timeout "$TEST_TIMEOUT" node --experimental-wasm-exnref \ + --import tsx/esm examples/run-example.ts "${wasm}" \ + &1) rc=$? set -e diff --git a/scripts/run-posix-tests.sh b/scripts/run-posix-tests.sh index f3d02b4582..5e28e28227 100755 --- a/scripts/run-posix-tests.sh +++ b/scripts/run-posix-tests.sh @@ -188,7 +188,15 @@ run_test() { # process.stdin when not a TTY) does not drain the outer while-loop's # process-substitution pipe and cause it to exit after the first test. set +e - output=$(cd "$REPO_ROOT" && timeout "$TEST_TIMEOUT" node --experimental-wasm-exnref --import tsx/esm examples/run-example.ts "${wasm}" &1) + output=$(cd "$REPO_ROOT" && \ + KERNEL_CWD= \ + KANDELO_RUNNER_FIXTURE_ROOT= \ + KANDELO_RUNNER_FIXTURE_CWD= \ + KANDELO_RUNNER_GUEST_PROGRAM= \ + KANDELO_RUNNER_VFS=isolated \ + timeout "$TEST_TIMEOUT" node --experimental-wasm-exnref \ + --import tsx/esm examples/run-example.ts "${wasm}" \ + &1) rc=$? set -e diff --git a/scripts/run-sortix-tests.sh b/scripts/run-sortix-tests.sh index d1c3eead4e..863117cd76 100755 --- a/scripts/run-sortix-tests.sh +++ b/scripts/run-sortix-tests.sh @@ -646,18 +646,9 @@ _run_runtime_test_worker() { this_timeout="$XFAIL_TIMEOUT" fi - # If a matching .so file was built, symlink it where the test expects it - local so="$BUILD_DIR/$suite/${test_name}.so" - local so_link="" - if [ -f "$so" ]; then - local so_dir="${SORTIX_DATA_DIR:-$REPO_ROOT}" - so_link="$so_dir/${test_name}.so" - mkdir -p "$(dirname "$so_link")" - ln -sf "$so" "$so_link" 2>/dev/null || true - fi - - # Run with timeout. KERNEL_CWD is the data directory containing symlinks - # to test binaries at their expected relative paths (e.g., fcntl/open). + # Run with timeout against the complete private fixture prepared by + # _run_runtime_test_with_private_fixture. The worker snapshots that + # quiescent tree into lifecycle-owned /tmp storage before publishing ready. local output rc local result_base="${test_name//\//__}" local guest_output_file="$result_dir/${result_base}.guest-output" @@ -667,8 +658,12 @@ _run_runtime_test_worker() { # when not a TTY, which would drain any pipe the caller supplies. set +e (cd "$REPO_ROOT" && \ - KERNEL_CWD="${SORTIX_DATA_DIR:-$REPO_ROOT}" \ + KERNEL_CWD= \ KANDELO_GUEST_OUTPUT_FILE="$guest_output_file" \ + KANDELO_RUNNER_FIXTURE_ROOT="${SORTIX_FIXTURE_ROOT:?}" \ + KANDELO_RUNNER_FIXTURE_CWD="$suite" \ + KANDELO_RUNNER_GUEST_PROGRAM="$suite/$test_name" \ + KANDELO_RUNNER_VFS=isolated \ run_with_timeout "$this_timeout" node --experimental-wasm-exnref \ --import tsx/esm examples/run-example.ts "${wasm}" \ "$host_diagnostic_file" 2>&1) @@ -676,9 +671,6 @@ _run_runtime_test_worker() { set -e output=$(cat "$guest_output_file" 2>/dev/null || true) - # Clean up .so symlink - [ -n "$so_link" ] && rm -f "$so_link" 2>/dev/null || true - # Sortix convention: if output is empty or exit code >= 2, # append "exit: N" to the output (matches tests/sortix/os-test/misc/run.sh) if [ -z "$output" ] || [ "$rc" -ge 2 ]; then @@ -781,6 +773,57 @@ exit: $rc" fi } +# Stage exactly one test's guest-visible files before Node starts. A subshell +# owns the temporary tree and removes it on every ordinary/error return. +# Regular copies (including .so) keep the source tree free of symlink/hardlink +# aliases; the worker still takes its own private snapshot before granting +# exact-append authority. +_run_runtime_test_with_private_fixture() ( + local suite="$1" + local test_name="$2" + local result_dir="$3" + local result_file="$result_dir/${test_name//\//__}.result" + local wasm="$BUILD_DIR/$suite/${test_name}.wasm" + + if [ ! -f "$wasm" ]; then + _run_runtime_test_worker "$suite" "$test_name" "$result_dir" + return + fi + + local fixture_root + fixture_root=$(mktemp -d) + trap 'rm -rf "$fixture_root"' EXIT + local fixture_cwd="$fixture_root/$suite" + local fixture_program="$fixture_cwd/$test_name" + mkdir -p "$(dirname "$fixture_program")" + if ! cp "$wasm" "$fixture_program"; then + { echo "BUILD"; echo "failed to stage: $wasm"; } > "$result_file" + return + fi + + local src="$OS_TEST/$suite/${test_name}.c" + if [ -f "$OS_TEST_LOCAL/$suite/${test_name}.c" ]; then + src="$OS_TEST_LOCAL/$suite/${test_name}.c" + fi + if [ -f "$src" ]; then + if ! cp "$src" "${fixture_program}.c"; then + { echo "BUILD"; echo "failed to stage: $src"; } > "$result_file" + return + fi + fi + + local so="$BUILD_DIR/$suite/${test_name}.so" + if [ -f "$so" ]; then + if ! cp "$so" "${fixture_program}.so"; then + { echo "BUILD"; echo "failed to stage: $so"; } > "$result_file" + return + fi + fi + + SORTIX_FIXTURE_ROOT="$fixture_root" \ + _run_runtime_test_worker "$suite" "$test_name" "$result_dir" +) + # Run a runtime test (compile + execute) — sequential wrapper run_runtime_test() { local suite="$1" @@ -791,28 +834,7 @@ run_runtime_test() { # Build the test first (sequential path) build_runtime_test "$suite" "$test_name" 2>/dev/null || true - # Create data directory nested under suite name so tests that open ".." - # and access "$suite/" (e.g. fstatat opens ".." + "basic/sys_stat/fstatat") - # find their files correctly. - local data_parent - data_parent=$(mktemp -d) - local data_dir="$data_parent/$suite" - mkdir -p "$data_dir" - local wasm="$BUILD_DIR/$suite/${test_name}.wasm" - if [ -f "$wasm" ]; then - mkdir -p "$data_dir/$(dirname "$test_name")" - ln -f "$wasm" "$data_dir/$test_name" 2>/dev/null || \ - cp "$wasm" "$data_dir/$test_name" - fi - # Link source file for tests like faccessat that check for .c files - local src - src="$(runtime_test_src "$suite" "$test_name")" - if [ -f "$src" ]; then - ln -f "$src" "$data_dir/${test_name}.c" 2>/dev/null || \ - cp "$src" "$data_dir/${test_name}.c" 2>/dev/null || true - fi - SORTIX_DATA_DIR="$data_dir" _run_runtime_test_worker "$suite" "$test_name" "$result_dir" - rm -rf "$data_parent" + _run_runtime_test_with_private_fixture "$suite" "$test_name" "$result_dir" _collect_result "$suite" "$test_name" "$result_dir" } @@ -1020,51 +1042,17 @@ run_suite() { echo " Running $count tests ($PARALLEL parallel)..." - # Create a temp directory with hardlinks to test binaries at their - # expected relative paths. Many sortix tests open their own binary - # via paths like "fcntl/open" from CWD — this makes them accessible. - # Hardlinks (not symlinks) so lstat/fstatat see S_ISREG, not S_ISLNK. - # Nest under $suite/ so tests that open ".." find "$suite/" - # (e.g. fstatat opens ".." + "basic/sys_stat/fstatat"). - SORTIX_DATA_PARENT=$(mktemp -d) - SORTIX_DATA_DIR="$SORTIX_DATA_PARENT/$suite" - mkdir -p "$SORTIX_DATA_DIR" - for wasm_file in "$BUILD_DIR/$suite"/**/*.wasm; do - [ -f "$wasm_file" ] || continue - local relpath="${wasm_file#"$BUILD_DIR/$suite/"}" - local name="${relpath%.wasm}" - mkdir -p "$SORTIX_DATA_DIR/$(dirname "$name")" - ln -f "$wasm_file" "$SORTIX_DATA_DIR/$name" 2>/dev/null || \ - cp "$wasm_file" "$SORTIX_DATA_DIR/$name" - done - # Link source files for tests like faccessat that check for .c files - for src_root in "$OS_TEST" "$OS_TEST_LOCAL"; do - [ -d "$src_root/$suite" ] || continue - while IFS= read -r src_file; do - [ -f "$src_file" ] || continue - local relpath="${src_file#"$src_root/$suite/"}" - local dest="$SORTIX_DATA_DIR/$relpath" - [ -f "$dest" ] && continue - mkdir -p "$(dirname "$dest")" 2>/dev/null || true - ln -f "$src_file" "$dest" 2>/dev/null || true - done < <(find "$src_root/$suite" -name "*.c" -type f) - done - export SORTIX_DATA_DIR - # Export everything needed by the worker function export REPO_ROOT BUILD_DIR OS_TEST OS_TEST_LOCAL SYSROOT GLUE_DIR TEST_TIMEOUT XFAIL_TIMEOUT - export -f _run_runtime_test_worker _check_xfail_serialized run_with_timeout + export -f _run_runtime_test_worker _run_runtime_test_with_private_fixture + export -f _check_xfail_serialized run_with_timeout # Export serialized XFAIL list for this suite _export_xfail_for_suite "$suite" # Run tests in parallel using xargs printf '%s\n' "${tests[@]}" | xargs -P "$PARALLEL" -I{} \ - bash -c '_run_runtime_test_worker "$1" "$2" "$3"' _ "$suite" {} "$result_dir" - - # Clean up data directory - rm -rf "$SORTIX_DATA_PARENT" - unset SORTIX_DATA_DIR SORTIX_DATA_PARENT + bash -c '_run_runtime_test_with_private_fixture "$1" "$2" "$3"' _ "$suite" {} "$result_dir" # Collect results for test_name in "${tests[@]}"; do From 5c61d96531e500786f3a104d8d7704f9d6e87844 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Mon, 27 Jul 2026 22:22:41 -0400 Subject: [PATCH 11/82] Host: Reject shadowed session seeds Reject VFS seed entries whose normalized paths shadow an existing session mount or another seed. This keeps the admitted image authoritative and prevents path ordering from silently changing machine state. --- ...26-05-04-non-forking-posix-spawn-design.md | 9 ++- host/src/vfs/default-mounts-node.ts | 31 ++++++--- host/test/vfs/default-mounts.test.ts | 64 +++++++++++++++++++ packages/registry/program-packages.json | 36 +++++------ 4 files changed, 109 insertions(+), 31 deletions(-) diff --git a/docs/plans/2026-05-04-non-forking-posix-spawn-design.md b/docs/plans/2026-05-04-non-forking-posix-spawn-design.md index 8740e52768..7f88c50ded 100644 --- a/docs/plans/2026-05-04-non-forking-posix-spawn-design.md +++ b/docs/plans/2026-05-04-non-forking-posix-spawn-design.md @@ -150,11 +150,10 @@ The authoritative advertised `ARG_MAX` and `PATH_MAX` live in `crates/shared/src/lib.rs::spawn_contract`; together they generate the C and TypeScript consumers. See `docs/plans/2026-07-25-kernel-scratch-transfer-audit.md` for the ownership -audit and the focused rehearsal Node.js and Chromium measurements, including -their baseline-harness provenance and final-base limitations. -Those focused results establish the retained-capacity and kernel-memory effect -for their deterministic workload, but do not establish a speedup or broad -performance no-regression claim. +audit and the exact measurement plan. That audit does not currently record +uncontended exact-head Node.js or Chromium retained-memory results, so it +supports no retained-capacity, speedup, or broad performance no-regression +claim. ## Section 2 — Kernel side diff --git a/host/src/vfs/default-mounts-node.ts b/host/src/vfs/default-mounts-node.ts index 4f49910cdc..c824e85c00 100644 --- a/host/src/vfs/default-mounts-node.ts +++ b/host/src/vfs/default-mounts-node.ts @@ -195,10 +195,8 @@ function materializeSessionSeedTrees( ): void { if (seeds.length === 0) return; const sessionRoot = realpathSync(sessionDir); - const scratchRoots = spec - .filter((mount) => mount.source === "scratch") - .map((mount) => mount.path) - .sort((left, right) => right.length - left.length); + const routingMounts = [...spec] + .sort((left, right) => right.path.length - left.path.length); validateSpec(seeds.map((seed) => ({ path: seed.destinationPath, @@ -218,14 +216,31 @@ function materializeSessionSeedTrees( requireCanonicalGuestPath(mountPoint, "shadowing mount point"); } const prepared = seeds.map((seed, index) => { - const scratchRoot = scratchRoots.find( - (root) => guestPathStrictlyContains(root, seed.destinationPath), + const owner = routingMounts.find( + (mount) => guestPathContains(mount.path, seed.destinationPath), ); - if (scratchRoot === undefined) { + if ( + owner === undefined + || owner.source !== "scratch" + || !guestPathStrictlyContains(owner.path, seed.destinationPath) + ) { throw new Error( - `session seed destination must be below a scratch mount: ${seed.destinationPath}`, + `session seed destination must be below a scratch mount and routed through a scratch mount: ${seed.destinationPath}`, ); } + // WHY: the VFS routes by longest mount prefix. Even when the destination + // itself belongs to `owner`, a nested declared mount would hide part of + // the copied tree and make publication differ from the owned bytes. + for (const mount of spec) { + if ( + mount !== owner + && guestPathContains(seed.destinationPath, mount.path) + ) { + throw new Error( + `session seed destination overlaps another declared mount: ${seed.destinationPath} and ${mount.path}`, + ); + } + } for (const mountPoint of shadowingMountPoints) { if ( guestPathContains(mountPoint, seed.destinationPath) diff --git a/host/test/vfs/default-mounts.test.ts b/host/test/vfs/default-mounts.test.ts index 39c053d0dd..3eddd93fc8 100644 --- a/host/test/vfs/default-mounts.test.ts +++ b/host/test/vfs/default-mounts.test.ts @@ -496,6 +496,39 @@ describe("Node worker session seed trees", () => { } }); + it("publishes a seed through the deepest declared scratch mount", async () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "kandelo-seed-routing-")); + const sessionRoot = mkdtempSync(join(tmpdir(), "kandelo-seed-session-")); + const source = join(fixtureRoot, "source"); + mkdirSync(source); + writeFileSync(join(source, "value"), "seed"); + const nestedScratchSpec: MountSpec[] = [ + { path: "/", source: "image", readonly: true }, + { path: "/tmp", source: "scratch" }, + { path: "/tmp/nested", source: "scratch" }, + ]; + try { + const mounts = await resolveForNodeKernelSession( + nestedScratchSpec, + await buildFixtureImage(), + sessionRoot, + [{ + sourcePath: source, + destinationPath: "/tmp/nested/fixtures", + }], + ); + const owner = mounts.find( + (mount) => mount.mountPoint === "/tmp/nested", + )!; + expect(new TextDecoder().decode( + readMountFile(owner.backend, "/fixtures/value"), + )).toBe("seed"); + } finally { + rmSync(sessionRoot, { recursive: true, force: true }); + rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + it("rejects overlapping, mount-shadowed, and mount-root destinations", async () => { const fixtureRoot = mkdtempSync(join(tmpdir(), "kandelo-seed-overlap-")); const sessionRoot = mkdtempSync(join(tmpdir(), "kandelo-seed-session-")); @@ -527,6 +560,37 @@ describe("Node worker session seed trees", () => { ), ).rejects.toThrow(/overlaps another mount/i); + const nestedImageSpec: MountSpec[] = [ + { path: "/", source: "image", readonly: true }, + { path: "/tmp", source: "scratch" }, + { path: "/tmp/shadow", source: "image", readonly: true }, + ]; + await expect( + resolveForNodeKernelSession( + nestedImageSpec, + image, + sessionRoot, + [{ + sourcePath: first, + destinationPath: "/tmp/shadow/fixtures", + }], + ), + ).rejects.toThrow(/routed through a scratch mount/i); + + const nestedScratchSpec: MountSpec[] = [ + { path: "/", source: "image", readonly: true }, + { path: "/tmp", source: "scratch" }, + { path: "/tmp/fixtures/nested", source: "scratch" }, + ]; + await expect( + resolveForNodeKernelSession( + nestedScratchSpec, + image, + sessionRoot, + [{ sourcePath: first, destinationPath: "/tmp/fixtures" }], + ), + ).rejects.toThrow(/overlaps another declared mount/i); + await expect( resolveForNodeKernelSession( DEFAULT_MOUNT_SPEC, diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index 4dccc3c09c..29075cada4 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -158,8 +158,8 @@ "lamp": { "manifestSha256": "250b64635d64f8537178188fb5488dbfd2980d79a1c2ffbf346af67008088ba0", "cacheKeys": { - "wasm32": "6c26a65cfa35135b60eed070558e6fb2ea0945d6330ef7528fd3565a2501a5e4", - "wasm64": "484ef7c28d94acb8bb58fa8a07df7e4f7dae049bc918146ca9d598edaf96c3f8" + "wasm32": "ef6e44eab17c41f328b8c2a6bd73c84264ab8a1947212494ad0fb6909bb914ca", + "wasm64": "cf5b8d20613e8f842f465683d228c444c703f4095a5409b206d64d8735ccf740" } }, "less": { @@ -312,15 +312,15 @@ "nginx-php-vfs": { "manifestSha256": "97976410cb02f8ba710d856b4ac904bcf976d677b50eefdee39fb64176070d4b", "cacheKeys": { - "wasm32": "feea3fbd8d70dfeacb3df791603c5e88b7ac68e69a094f087ea2995325a3e3be", - "wasm64": "3b1adc8243ed2774e5702a2d2b7d9b958ab408bf4756fb7aaffea9091f186662" + "wasm32": "2a13b76f7acc764b385b282fd7ceffbde3569052139a304dc7e5852c45f86027", + "wasm64": "e41c2a831c7237d7e8848cf669e06629526306aa5a789ad0fa0f121a397adb0f" } }, "nginx-vfs": { "manifestSha256": "46aa2d3250ac5cd0f102a85c3a36a086c7a50ba1f9e05fa1c2aae3d07ad16f10", "cacheKeys": { - "wasm32": "b0b3989229df0012a0862874b36394c0211e78e06a94430a3badda2563528755", - "wasm64": "bce2985422b0b94ebcaa9c6869e6eff6e1378673f5b04b9d9ccf4a8c6ef54460" + "wasm32": "efa2faa3f5213e40ab5de3d87cd5398cfb8823c6ab8a43e614462360e209a4c5", + "wasm64": "a22b9dc96b9f68c0fd0f9a925c404f53ce7fa36e9b8eaae00b3b75965dd05c0b" } }, "node": { @@ -333,8 +333,8 @@ "node-vfs": { "manifestSha256": "977f219defe57e18017ecc963159df32efdd21c53d6fea05943703d04739d8de", "cacheKeys": { - "wasm32": "8222a97d7830cf1a808c2e37c9308d95fdf38f498860e892c7f7dddbf016f6be", - "wasm64": "a2ad8305d312eda3229c4f957d158b9e06439a07c562fdae87a861866489919a" + "wasm32": "b7bf43e041330e9b3759de3a4f3fb8ceb6408afdc19983004a011c320d01161d", + "wasm64": "9f6a7fe67d183d1320057d6f3ababacae20b89ee5d429a2505c05a9bc63c9754" } }, "openssl": { @@ -396,8 +396,8 @@ "redis-vfs": { "manifestSha256": "234c45c40f94e2a89f98295313b82cb9fce15a412ac829d9e87394e0dc731813", "cacheKeys": { - "wasm32": "2aed064a8c4f2d1dceb514a0f329ec69a30eecdbcb652ca5959ef8fb83263577", - "wasm64": "5914af395a444964197ed1f21b0f8b53b4406d4ae7b27e146c4d76c2cadba4b7" + "wasm32": "535dc5d20e12a3f58533c644abc83b53a9e9bf4e2daea2a288910a3b24d909e3", + "wasm64": "a09d3635abb11f04902744c1ff443638e69bd2a6cdaf52fc34dd09f0aa7484ff" } }, "rootfs": { @@ -515,8 +515,8 @@ "wordpress": { "manifestSha256": "36465e0596a06e855a8524eecfd87da98643bc13b37ee12939f5aab44bf33418", "cacheKeys": { - "wasm32": "72342d9e4e6ef53705191d4dcdeb6b2e2cd86e18520bd4b2600933aaebc05cf0", - "wasm64": "66a742d2899c0d2ac128fba25b924ded8ccc42959ff6f42a797fb57a6c4a4b6d" + "wasm32": "d5fd9f36f385849620845343bda389e24a0ad6cfb229fdbead759f2c9bd84b05", + "wasm64": "efe0a756bdad634e2705dad6c54a37c1c5d7052a802ca01d63b7fb6c1ddc3370" } }, "xz": { @@ -1093,7 +1093,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "6c26a65cfa35135b60eed070558e6fb2ea0945d6330ef7528fd3565a2501a5e4" + "wasm32": "ef6e44eab17c41f328b8c2a6bd73c84264ab8a1947212494ad0fb6909bb914ca" }, "dependencyClosures": { "wasm32": [ @@ -1718,7 +1718,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "feea3fbd8d70dfeacb3df791603c5e88b7ac68e69a094f087ea2995325a3e3be" + "wasm32": "2a13b76f7acc764b385b282fd7ceffbde3569052139a304dc7e5852c45f86027" }, "dependencyClosures": { "wasm32": [ @@ -1810,7 +1810,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b0b3989229df0012a0862874b36394c0211e78e06a94430a3badda2563528755" + "wasm32": "efa2faa3f5213e40ab5de3d87cd5398cfb8823c6ab8a43e614462360e209a4c5" }, "dependencyClosures": { "wasm32": [ @@ -1894,7 +1894,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "8222a97d7830cf1a808c2e37c9308d95fdf38f498860e892c7f7dddbf016f6be" + "wasm32": "b7bf43e041330e9b3759de3a4f3fb8ceb6408afdc19983004a011c320d01161d" }, "dependencyClosures": { "wasm32": [ @@ -2450,7 +2450,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2aed064a8c4f2d1dceb514a0f329ec69a30eecdbcb652ca5959ef8fb83263577" + "wasm32": "535dc5d20e12a3f58533c644abc83b53a9e9bf4e2daea2a288910a3b24d909e3" }, "dependencyClosures": { "wasm32": [ @@ -2926,7 +2926,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "72342d9e4e6ef53705191d4dcdeb6b2e2cd86e18520bd4b2600933aaebc05cf0" + "wasm32": "d5fd9f36f385849620845343bda389e24a0ad6cfb229fdbead759f2c9bd84b05" }, "dependencyClosures": { "wasm32": [ From 745fc9ee95e68f3be22dd9d44ced5100d49550fb Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Mon, 27 Jul 2026 23:17:20 -0400 Subject: [PATCH 12/82] Host: Pin mutable executable bytes Snapshot executable bytes when the resolver admits a program instead of rereading a mutable host file during worker launch. The process now runs the exact image that passed ABI and artifact validation. Cover package resolution and rootfs export while preserving the same behavior in the shared Node and browser launch machinery. --- docs/architecture.md | 2 +- ...026-07-25-kernel-scratch-transfer-audit.md | 31 ++++-- examples/run-example.ts | 90 +++++++++++---- host/src/node-kernel-host.ts | 71 +++++++++++- host/src/node-kernel-protocol.ts | 4 +- host/src/node-kernel-worker-entry.ts | 14 ++- host/test/node-rootfs-export.test.ts | 105 ++++++++++++++++++ host/test/run-example-resolver.test.ts | 28 +++++ packages/registry/program-packages.json | 18 +-- scripts/run-php-upstream-tests.ts | 18 ++- 10 files changed, 328 insertions(+), 53 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 715e12635d..9ae593810b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1537,7 +1537,7 @@ VFS images can also carry image-level metadata outside the guest file tree. The ### Node host -`NodeKernelHost` accepts `rootfsImage: "default" | ArrayBuffer | Uint8Array | undefined`. With `"default"` (the path used by the vitest suite), the worker reads `host/wasm/rootfs.vfs`, applies `DEFAULT_MOUNT_SPEC` via the private-session Node resolver, and constructs a `VirtualPlatformIO` for the kernel. The image supplies both `/etc/ssl/cert.pem` and `/etc/ssl/certs/ca-certificates.crt`; Node does not silently add them to caller-supplied images. Optional `sessionSeedTrees` require a rootfs image and absolute host source paths; each source must remain quiescent until `init()` resolves. Graceful destroy, initialization failure, and fatal worker paths attempt to remove the complete session tree; abrupt process termination cannot run that best-effort hook, so cleanup is not the ownership proof. New private inodes and publication-before-`ready` establish ownership. Without a rootfs image, the worker falls back to raw `NodePlatformIO` (every host path reachable) — kept for legacy callers that haven't migrated. +`NodeKernelHost` accepts `rootfsImage: "default" | ArrayBuffer | Uint8Array | undefined`. With `"default"` (the path used by the vitest suite), the worker reads `host/wasm/rootfs.vfs`, applies `DEFAULT_MOUNT_SPEC` via the private-session Node resolver, and constructs a `VirtualPlatformIO` for the kernel. The image supplies both `/etc/ssl/cert.pem` and `/etc/ssl/certs/ca-certificates.crt`; Node does not silently add them to caller-supplied images. Optional `sessionSeedTrees` require a rootfs image and absolute host source paths; each source must remain quiescent until `init()` resolves. Graceful destroy, initialization failure, and fatal worker paths attempt to remove the complete session tree; abrupt process termination cannot run that best-effort hook, so cleanup is not the ownership proof. New private inodes and publication-before-`ready` establish ownership. Explicit exec mappings are considered before VFS lookup: `execPrograms` names host paths whose generations must remain immutable, while `execProgramBytes` is copied during `init()` and retained by the worker so later caller mutation or replacement cannot change a launch. A virtual path cannot use both sources. Without a rootfs image, the worker falls back to raw `NodePlatformIO` (every host path reachable) — kept for legacy callers that haven't migrated. ### Browser host diff --git a/docs/plans/2026-07-25-kernel-scratch-transfer-audit.md b/docs/plans/2026-07-25-kernel-scratch-transfer-audit.md index 844ddd7e5b..a43739e96c 100644 --- a/docs/plans/2026-07-25-kernel-scratch-transfer-audit.md +++ b/docs/plans/2026-07-25-kernel-scratch-transfer-audit.md @@ -862,12 +862,21 @@ backends or publishing readiness. Guest writes never reach the source tree. Libc and Open POSIX tests use the canonical root image and `/tmp`; the initial program crosses as an immutable value, and its exact self-exec alias resolves -only to those cached bytes. Each Sortix invocation stages only its executable, -source file when present, and shared object when present as regular files in a -private fixture, then launches the worker-owned VFS path. The suite/parent -layout remains intact for tests that open `..`, inspect their source, load a -shared object, or exec/spawn themselves. Parallel invocations receive separate -session copies. This is lifecycle ownership rather than a test allowlist. +only to those cached bytes. Explicit runner-provided tools use a pre-VFS worker +capability, so a same-named lazy root-image stub cannot start an ambient fetch +before the capability is considered. Resolver-owned immutable generations use +`execPrograms`; direct checkout or build outputs are copied during +`NodeKernelHost.init` and cross as worker-lifetime `execProgramBytes`. Every +execution receives a fresh copy of those retained exact bytes, so later source +replacement cannot change either sequential or asynchronous resolution. In +isolated mode the main-thread `onResolveExec` fallback serves only the immutable +self-exec alias and otherwise returns no program. Each Sortix invocation stages +only its executable, source file when present, and shared object when present as +regular files in a private fixture, then launches the worker-owned VFS path. The +suite/parent layout remains intact for tests that open `..`, inspect their +source, load a shared object, or exec/spawn themselves. Parallel invocations +receive separate session copies. This is lifecycle ownership rather than a test +allowlist. ## ABI decision @@ -990,11 +999,11 @@ session copies. This is lifecycle ownership rather than a test allowlist. compatibility fallback. - `KernelScratchRegion` remains an internal TypeScript value, but that fact does not neutralize the required export and synchronization changes. -- `NodeKernelHost.sessionSeedTrees` and its main-thread/worker initialization - field are optional Node configuration. They change no Wasm export, import, - syscall layout, pointer interpretation, required adapter capability, or - accepted guest limit. They therefore require no epoch beyond the already - unpublished ABI 43. +- `NodeKernelHost.sessionSeedTrees`, `execProgramBytes`, and their + main-thread/worker initialization fields are optional Node configuration. + They change no Wasm export, import, syscall layout, pointer interpretation, + required adapter capability, or accepted guest limit. They therefore require + no epoch beyond the already unpublished ABI 43. PR #1097 merged as `c7d039794a43788acfa0b0aea30a700c257f57cb` with ABI 42. Retargeting is diff --git a/examples/run-example.ts b/examples/run-example.ts index 4c23aa4ed0..36ce078b4a 100644 --- a/examples/run-example.ts +++ b/examples/run-example.ts @@ -52,14 +52,18 @@ function parseKernelCredential(name: "KERNEL_UID" | "KERNEL_GID"): number | unde interface OptionalBinary { readonly relPaths: readonly string[]; readonly fallback?: string; + readonly snapshotResolved?: boolean; } function optionalBinary(...relPaths: string[]): OptionalBinary { return { relPaths }; } -function optionalBinaryWithFallback(fallback: string, ...relPaths: string[]): OptionalBinary { - return { relPaths, fallback }; +function snapshotOptionalBinaryWithFallback( + fallback: string, + ...relPaths: string[] +): OptionalBinary { + return { relPaths, fallback, snapshotResolved: true }; } // These declarations describe optional program sources without probing package @@ -107,7 +111,7 @@ const testfixtureBuild = resolve( ); const testfixtureWasm = existsSync(testfixtureBuild) ? testfixtureBuild : null; const mysqltestWasm = optionalBinary("programs/mariadb/mysqltest.wasm"); -const echoWasm = optionalBinaryWithFallback( +const echoWasm = snapshotOptionalBinaryWithFallback( resolve(repoRoot, "examples/echo.wasm"), "programs/echo.wasm", ); @@ -299,7 +303,12 @@ for (const name of coreutilsNames) { builtinProgramSources[`/usr/bin/${name}`] = coreutilsWasm; } -function resolveBuiltinPrograms(): Record { +interface ResolvedBuiltinPrograms { + programs: Record; + snapshotNames: ReadonlySet; +} + +function resolveBuiltinPrograms(): ResolvedBuiltinPrograms { const references = Array.from(new Set( Object.values(builtinProgramSources).filter( (source): source is OptionalBinary => @@ -315,25 +324,33 @@ function resolveBuiltinPrograms(): Record { const resolvedByPath = new Map( relPaths.map((relPath, index) => [relPath, resolvedPaths[index]]), ); - const resolvedByReference = new Map( - references.map((reference) => [ + const resolvedByReference = new Map(references.map((reference) => { + const resolved = reference.relPaths + .map((relPath) => resolvedByPath.get(relPath) ?? null) + .find((path) => path !== null); + return [ reference, - reference.relPaths - .map((relPath) => resolvedByPath.get(relPath) ?? null) - .find((path) => path !== null) ?? - reference.fallback ?? - null, - ]), - ); + { + path: resolved ?? reference.fallback ?? null, + snapshot: reference.snapshotResolved === true + || (resolved === undefined && reference.fallback !== undefined), + }, + ] as const; + })); const programs: Record = {}; + const snapshotNames = new Set(); for (const [name, source] of Object.entries(builtinProgramSources)) { - programs[name] = - typeof source === "object" && source !== null - ? resolvedByReference.get(source) ?? null - : source; + if (typeof source === "object" && source !== null) { + const resolved = resolvedByReference.get(source); + programs[name] = resolved?.path ?? null; + if (resolved?.snapshot) snapshotNames.add(name); + } else { + programs[name] = source; + if (source !== null) snapshotNames.add(name); + } } - return programs; + return { programs, snapshotNames }; } function loadBytes(path: string): ArrayBuffer { @@ -398,7 +415,30 @@ async function main() { process.env, process.cwd(), ); - const builtinPrograms = resolveBuiltinPrograms(); + const resolvedBuiltins = resolveBuiltinPrograms(); + const builtinPrograms = resolvedBuiltins.programs; + let isolatedExecPrograms: Record | undefined; + let isolatedExecProgramBytes: Record | undefined; + if (runnerFilesystem.isolated) { + const paths: Record = {}; + const bytes: Record = {}; + const snapshotsByPath = new Map(); + for (const [programName, programPath] of Object.entries(builtinPrograms)) { + if (programPath === null || !existsSync(programPath)) continue; + if (resolvedBuiltins.snapshotNames.has(programName)) { + let snapshot = snapshotsByPath.get(programPath); + if (snapshot === undefined) { + snapshot = loadBytes(programPath); + snapshotsByPath.set(programPath, snapshot); + } + bytes[programName] = snapshot; + } else { + paths[programName] = programPath; + } + } + if (Object.keys(paths).length > 0) isolatedExecPrograms = paths; + if (Object.keys(bytes).length > 0) isolatedExecProgramBytes = bytes; + } let programPath: string; if (name.endsWith(".wasm")) { @@ -466,6 +506,15 @@ async function main() { maxWorkers: 4, rootfsImage: runnerFilesystem.rootfsImage, sessionSeedTrees: runnerFilesystem.sessionSeedTrees, + // WHY: isolated mode must give explicitly resolved guest tools + // precedence over same-named lazy rootfs stubs. `execPrograms` is + // the worker's narrow, pre-VFS capability; waiting for the + // fallback callback would let the stub start transport I/O first. + execPrograms: isolatedExecPrograms, + // Direct build outputs are not immutable resolver generations. + // Snapshot their exact bytes before worker startup so replacement + // cannot change a later asynchronous exec. + execProgramBytes: isolatedExecProgramBytes, onStdout: (_pid, data) => writeGuestOutput(process.stdout, data), onStderr: (_pid, data) => writeGuestOutput(process.stderr, data), onResolveExec: (path) => { @@ -475,10 +524,11 @@ async function main() { ) { return initialProgramBytes.slice(0); } + if (runnerFilesystem.isolated) return null; return resolveProgram( path, builtinPrograms, - !runnerFilesystem.isolated, + true, ); }, }); diff --git a/host/src/node-kernel-host.ts b/host/src/node-kernel-host.ts index cd469be6fd..028a04f6da 100644 --- a/host/src/node-kernel-host.ts +++ b/host/src/node-kernel-host.ts @@ -80,8 +80,22 @@ export interface NodeKernelHostOptions { /** Size of the data buffer for syscall data transfer (default: 65536). * Increase for programs that do large pwrite() calls (e.g. InnoDB). */ dataBufferSize?: number; - /** Virtual path → host filesystem path for exec resolution inside the worker */ + /** + * Virtual path → immutable host filesystem generation for exec resolution + * inside the worker. + */ execPrograms?: Record; + /** + * Virtual path → exact program bytes for pre-VFS exec resolution. + * + * Ordinary ArrayBuffer-backed bytes are copied during init and owned by the + * worker for its complete lifetime; concurrently mutable SharedArrayBuffer + * views are rejected. Use this for mutable build outputs; `execPrograms` is + * suitable only when its host path names a generation that remains immutable. + */ + execProgramBytes?: Readonly< + Record> + >; /** Attach a real-TCP backend in the worker so wasm programs can dial * external hosts via Node `net.Socket`. */ enableTcpNetwork?: boolean; @@ -229,6 +243,21 @@ export class NodeKernelHost { if (this.options.rootfsLazyUrlBase === "") { throw new Error("rootfsLazyUrlBase must not be empty"); } + const execProgramBytes = snapshotExecProgramBytes( + this.options.execProgramBytes, + ); + for (const path of Object.keys(execProgramBytes ?? {})) { + if ( + Object.prototype.hasOwnProperty.call( + this.options.execPrograms ?? {}, + path, + ) + ) { + throw new Error( + `exec program ${JSON.stringify(path)} has both path and byte sources`, + ); + } + } const rootfsLazyAssets = this.options.rootfsLazyAssets === undefined ? undefined : snapshotClosedLazyAssets(this.options.rootfsLazyAssets); @@ -366,6 +395,7 @@ export class NodeKernelHost { useSharedMemory: true, }, execPrograms: this.options.execPrograms, + execProgramBytes, rootfsImage: rootfsImage ?? undefined, rootfsMountSpec: this.options.rootfsMountSpec === undefined ? undefined @@ -377,9 +407,12 @@ export class NodeKernelHost { sessionSeedTrees, enableTcpNetwork: this.options.enableTcpNetwork, }; - const transfer = (rootfsLazyAssets ?? []).map( - (asset) => asset.bytes.buffer as ArrayBuffer, - ); + const transfer = [ + ...(rootfsLazyAssets ?? []).map( + (asset) => asset.bytes.buffer as ArrayBuffer, + ), + ...new Set(Object.values(execProgramBytes ?? {})), + ]; this.worker.postMessage(initMsg, transfer); }); } catch (error) { @@ -1074,6 +1107,36 @@ function loadKernelWasm(): ArrayBuffer { return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); } +function snapshotExecProgramBytes( + sources: NodeKernelHostOptions["execProgramBytes"], +): Record | undefined { + if (sources === undefined) return undefined; + const snapshots: Record = Object.create(null); + const copies = new WeakMap(); + for (const [path, source] of Object.entries(sources)) { + if ( + !(source instanceof ArrayBuffer) + && (!(source instanceof Uint8Array) + || !(source.buffer instanceof ArrayBuffer)) + ) { + throw new Error( + `exec program ${JSON.stringify(path)} bytes must use an ordinary ArrayBuffer`, + ); + } + let snapshot = copies.get(source); + if (snapshot === undefined) { + const bytes = source instanceof ArrayBuffer + ? new Uint8Array(source) + : source; + snapshot = new ArrayBuffer(bytes.byteLength); + new Uint8Array(snapshot).set(bytes); + copies.set(source, snapshot); + } + snapshots[path] = snapshot; + } + return snapshots; +} + /** * Materialise the rootfs image bytes the worker will mount at `/`. * Returns `null` when the caller hasn't opted in; the worker then diff --git a/host/src/node-kernel-protocol.ts b/host/src/node-kernel-protocol.ts index d55baf5a11..cf2ea46859 100644 --- a/host/src/node-kernel-protocol.ts +++ b/host/src/node-kernel-protocol.ts @@ -38,8 +38,10 @@ export interface InitMessage { dataBufferSize?: number; useSharedMemory?: boolean; }; - /** Virtual path → host filesystem path for exec resolution */ + /** Virtual path → immutable host filesystem generation for exec resolution. */ execPrograms?: Record; + /** Virtual path → worker-owned exact program bytes for pre-VFS resolution. */ + execProgramBytes?: Record; /** * Bytes of `host/wasm/rootfs.vfs`, read on the main thread and forwarded * to the worker. When present, the worker materialises the default mount diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts index 5fc092c1ae..38f545f32f 100644 --- a/host/src/node-kernel-worker-entry.ts +++ b/host/src/node-kernel-worker-entry.ts @@ -167,6 +167,7 @@ const processMemoryRetirementPressureHook = reclamationMeasurementPressure, ); let execPrograms: Record = {}; +let execProgramBytes: Record = {}; let vfsExecIO: PlatformIO | null = null; let rootfsMemfs: MemoryFileSystem | null = null; let initReady = false; @@ -749,7 +750,17 @@ function bufferToArrayBuffer(bytes: Uint8Array): ArrayBuffer { } function resolveExecLocal(path: string): ArrayBuffer | null { - const mapped = execPrograms[path]; + const owned = Object.prototype.hasOwnProperty.call(execProgramBytes, path) + ? execProgramBytes[path] + : undefined; + if (owned !== undefined) { + // WHY: process-worker launch transfers its program buffer. Preserve the + // worker-lifetime snapshot by lending a fresh copy to every execution. + return owned.slice(0); + } + const mapped = Object.prototype.hasOwnProperty.call(execPrograms, path) + ? execPrograms[path] + : undefined; if (mapped && existsSync(mapped)) { const bytes = readFileSync(mapped); return bufferToArrayBuffer(bytes); @@ -969,6 +980,7 @@ async function handleInit(msg: InitMessage) { retirementPressureHook: processMemoryRetirementPressureHook, }); execPrograms = msg.execPrograms ?? {}; + execProgramBytes = msg.execProgramBytes ?? {}; workerAdapter = new NodeWorkerAdapter(); if (!msg.rootfsImage && (msg.sessionSeedTrees?.length ?? 0) > 0) { throw new Error("sessionSeedTrees requires rootfsImage"); diff --git a/host/test/node-rootfs-export.test.ts b/host/test/node-rootfs-export.test.ts index 3b687f908f..b7b41bd4c4 100644 --- a/host/test/node-rootfs-export.test.ts +++ b/host/test/node-rootfs-export.test.ts @@ -12,9 +12,11 @@ const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(here, "../.."); const kernelPath = tryResolveBinary("kernel.wasm"); const blockForeverPath = join(repoRoot, "examples/block-forever.wasm"); +const spawnSmokePath = join(repoRoot, "examples/spawn-smoke.wasm"); const wasiHelloPath = join(here, "fixtures/wasi-hello.wasm"); const haveKernel = kernelPath !== null; const haveBlockForever = existsSync(blockForeverPath); +const haveSpawnSmoke = existsSync(spawnSmokePath); const haveWasiHello = existsSync(wasiHelloPath); function asArrayBuffer(bytes: Uint8Array): ArrayBuffer { @@ -91,6 +93,37 @@ async function createExecutableRootfs( } describe("NodeKernelHost rootfs export contract", () => { + it("rejects ambiguous path and byte exec sources before starting a worker", async () => { + const host = new NodeKernelHost({ + execPrograms: { "/bin/tool": wasiHelloPath }, + execProgramBytes: { "/bin/tool": new Uint8Array([0]) }, + }); + try { + await expect(host.init(new ArrayBuffer(0))).rejects.toThrow( + 'exec program "/bin/tool" has both path and byte sources', + ); + } finally { + await host.destroy(); + } + }); + + it("rejects concurrently mutable shared exec bytes before starting a worker", async () => { + const host = new NodeKernelHost({ + execProgramBytes: { + "/bin/tool": new Uint8Array( + new SharedArrayBuffer(1), + ) as unknown as Uint8Array, + }, + }); + try { + await expect(host.init(new ArrayBuffer(0))).rejects.toThrow( + "bytes must use an ordinary ArrayBuffer", + ); + } finally { + await host.destroy(); + } + }); + it("rejects export before initialization without starting a worker", async () => { const host = new NodeKernelHost({ rootfsImage: new Uint8Array() }); await expect(host.readFileFromVfs("/missing")).rejects.toThrow( @@ -201,6 +234,78 @@ describe("NodeKernelHost rootfs export contract", () => { }, ); + it.skipIf(!haveKernel || !haveSpawnSmoke || !haveWasiHello)( + "uses worker-owned exact bytes before a same-path lazy VFS entry", + async () => { + const kernel = new Uint8Array(readFileSync(kernelPath!)); + const spawnSmoke = new Uint8Array(readFileSync(spawnSmokePath)); + const programSource = new Uint8Array(readFileSync(wasiHelloPath)); + const fs = MemoryFileSystem.create( + new SharedArrayBuffer(8 * 1024 * 1024), + ); + fs.mkdir("/bin", 0o755); + fs.registerLazyFile( + "/bin/exact-tool", + "https://packages.example.test/must-not-fetch.wasm", + programSource.byteLength, + 0o755, + ); + const rootfs = await fs.saveImage(); + let stdout = ""; + let lazyDownloads = 0; + let ambientResolveRequests = 0; + const host = new NodeKernelHost({ + rootfsImage: rootfs, + rootfsLazyAssets: [{ + url: "https://packages.example.test/must-not-fetch.wasm", + sha256: "6e340b9cffb37a989ca544e6bb780a2c78901d3fb33738768511a30617afa01d", + size: 1, + bytes: new Uint8Array([0]), + }], + execProgramBytes: { "/bin/exact-tool": programSource }, + onLazyDownload: () => { + lazyDownloads += 1; + }, + onResolveExec: () => { + ambientResolveRequests += 1; + return null; + }, + onStdout: (_pid, bytes) => { + stdout += new TextDecoder().decode(bytes); + }, + }); + try { + await host.init(asArrayBuffer(kernel)); + // The host copied the exact generation during init. Later caller + // replacement must not affect sequential reuse, and each overlapping + // launch must receive an independently transferable copy. + programSource.fill(0); + for (let invocation = 0; invocation < 2; invocation += 1) { + await expect(host.spawn( + asArrayBuffer(spawnSmoke), + ["spawn-smoke", "/bin/exact-tool"], + )).resolves.toBe(0); + } + await expect(Promise.all([ + host.spawn( + asArrayBuffer(spawnSmoke), + ["spawn-smoke", "/bin/exact-tool"], + ), + host.spawn( + asArrayBuffer(spawnSmoke), + ["spawn-smoke", "/bin/exact-tool"], + ), + ])).resolves.toEqual([0, 0]); + expect(stdout.match(/Hello from WASI\n/g)).toHaveLength(4); + expect(stdout.match(/OK\n/g)).toHaveLength(4); + expect(lazyDownloads).toBe(0); + expect(ambientResolveRequests).toBe(0); + } finally { + await host.destroy(); + } + }, + ); + it.skipIf(!haveKernel || !haveWasiHello)( "spawns an executable by path from the existing worker-owned rootfs", async () => { diff --git a/host/test/run-example-resolver.test.ts b/host/test/run-example-resolver.test.ts index 6f17d62e33..55573c8f9f 100644 --- a/host/test/run-example-resolver.test.ts +++ b/host/test/run-example-resolver.test.ts @@ -138,4 +138,32 @@ describe("run-example exec resolver", () => { rmSync(tempDir, { recursive: true, force: true }); } }); + + it("keeps explicit isolated exec mappings ahead of lazy rootfs stubs", () => { + const result = spawnSync( + process.execPath, + [ + "--experimental-wasm-exnref", + "--import", + "tsx/esm", + runExample, + spawnSmokeWasm, + ], + { + cwd: repoRoot, + env: { + ...process.env, + KANDELO_RUNNER_VFS: "isolated", + TIMEOUT: "30000", + }, + encoding: "utf8", + timeout: 45_000, + }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("spawned-ok"); + expect(result.stdout).toContain("OK"); + expect(result.stderr).not.toContain("LazyHttpResponseError"); + }, 45_000); }); diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index 29075cada4..f05ede03be 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -158,8 +158,8 @@ "lamp": { "manifestSha256": "250b64635d64f8537178188fb5488dbfd2980d79a1c2ffbf346af67008088ba0", "cacheKeys": { - "wasm32": "ef6e44eab17c41f328b8c2a6bd73c84264ab8a1947212494ad0fb6909bb914ca", - "wasm64": "cf5b8d20613e8f842f465683d228c444c703f4095a5409b206d64d8735ccf740" + "wasm32": "77db0a7aaa0a78130e2689a24ac6250a67d358bc65cd1e2919ce95c5ca2e20ea", + "wasm64": "8d4775122fac5b2eb7e50f2debbe21e5c22ed6813e3e8353588b79474390797e" } }, "less": { @@ -312,8 +312,8 @@ "nginx-php-vfs": { "manifestSha256": "97976410cb02f8ba710d856b4ac904bcf976d677b50eefdee39fb64176070d4b", "cacheKeys": { - "wasm32": "2a13b76f7acc764b385b282fd7ceffbde3569052139a304dc7e5852c45f86027", - "wasm64": "e41c2a831c7237d7e8848cf669e06629526306aa5a789ad0fa0f121a397adb0f" + "wasm32": "86f5a03d7a0e391e5e4b2ca4aef89768a5c5c34c69adb990ee9a9b4e6b70eb03", + "wasm64": "d0c6a5a4e1f76d8230ef33df341ccd61918db47ce803b782d2ea21a65e7546f5" } }, "nginx-vfs": { @@ -515,8 +515,8 @@ "wordpress": { "manifestSha256": "36465e0596a06e855a8524eecfd87da98643bc13b37ee12939f5aab44bf33418", "cacheKeys": { - "wasm32": "d5fd9f36f385849620845343bda389e24a0ad6cfb229fdbead759f2c9bd84b05", - "wasm64": "efe0a756bdad634e2705dad6c54a37c1c5d7052a802ca01d63b7fb6c1ddc3370" + "wasm32": "bfb506462d4179365315850df70adaaf957016f705b97675354e8ae9620dfaff", + "wasm64": "a2bba82adaf6b04c65250b66744339ebec68c190888318b3dd6c0cf6a9cbb452" } }, "xz": { @@ -1093,7 +1093,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ef6e44eab17c41f328b8c2a6bd73c84264ab8a1947212494ad0fb6909bb914ca" + "wasm32": "77db0a7aaa0a78130e2689a24ac6250a67d358bc65cd1e2919ce95c5ca2e20ea" }, "dependencyClosures": { "wasm32": [ @@ -1718,7 +1718,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2a13b76f7acc764b385b282fd7ceffbde3569052139a304dc7e5852c45f86027" + "wasm32": "86f5a03d7a0e391e5e4b2ca4aef89768a5c5c34c69adb990ee9a9b4e6b70eb03" }, "dependencyClosures": { "wasm32": [ @@ -2926,7 +2926,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "d5fd9f36f385849620845343bda389e24a0ad6cfb229fdbead759f2c9bd84b05" + "wasm32": "bfb506462d4179365315850df70adaaf957016f705b97675354e8ae9620dfaff" }, "dependencyClosures": { "wasm32": [ diff --git a/scripts/run-php-upstream-tests.ts b/scripts/run-php-upstream-tests.ts index 6b352d7d80..9a636da12c 100644 --- a/scripts/run-php-upstream-tests.ts +++ b/scripts/run-php-upstream-tests.ts @@ -1073,19 +1073,25 @@ class NodePhpRunner implements PhpRunner { private async ensureHost(): Promise { if (this.host) return this.host; this.phpBytes = loadBytes(this.phpPath); + const phpFpmBytes = this.phpFpmPath && existsSync(this.phpFpmPath) + ? loadBytes(this.phpFpmPath) + : null; const binaryMountRoot = this.ensureBinaryMountRoot(); const extensionMountRoot = this.ensureExtensionMountRoot(); const host = new NodeKernelHost({ maxWorkers: 4, rootfsImage: "default", enableTcpNetwork: this.enableTcpNetwork, - execPrograms: { - [this.virtualPhpPath]: this.phpPath, - "/kandelo-bin/php": this.phpPath, - ...(this.phpFpmPath + // WHY: PHP_WASM and the normal local CLI/FPM outputs are mutable build + // paths, not resolver-owned generations. Pin the bytes for this worker + // lifetime so a rebuild cannot replace a later exec asynchronously. + execProgramBytes: { + [this.virtualPhpPath]: this.phpBytes, + "/kandelo-bin/php": this.phpBytes, + ...(phpFpmBytes ? { - "/kandelo-bin/sbin/php-fpm": this.phpFpmPath, - "/kandelo-bin/fpm/php-fpm": this.phpFpmPath, + "/kandelo-bin/sbin/php-fpm": phpFpmBytes, + "/kandelo-bin/fpm/php-fpm": phpFpmBytes, } : {}), }, From 97d1b5608bd418c1bcd7d80c2aa953fd5858be5c Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Fri, 31 Jul 2026 14:13:48 -0400 Subject: [PATCH 13/82] Host: Reject saturated ordinary forks before cloning Apply retired-memory debt admission synchronously before ordinary fork creates or copies a child WebAssembly.Memory. Return EAGAIN when the bounded debt limit is saturated, without yielding across the parent snapshot or allocating another complete address space. Keep ordinary fork semantics independent from vfork and prove that the rejected path performs no child allocation or copy. --- docs/architecture.md | 15 ++++-- docs/browser-support.md | 7 ++- host/src/browser-kernel-worker-entry.ts | 10 +--- host/src/node-kernel-worker-entry.ts | 9 +--- host/src/process-memory.ts | 32 +++--------- host/test/multi-worker.test.ts | 61 ++++++++++++++++++++++ host/test/process-memory-allocator.test.ts | 35 ++++++++----- 7 files changed, 112 insertions(+), 57 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 9ae593810b..ba16c6ab07 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1436,10 +1436,17 @@ dated Node, Chromium, Firefox, and WebKit measurements and their limitations are recorded in [`docs/measurements/2026-07-28-process-memory-retirement-rss.md`](measurements/2026-07-28-process-memory-retirement-rss.md). -Fork children synchronously acquire an exactly sized fresh backing and copy the -parent's current memory length before the first asynchronous host operation. -This preserves the syscall-time snapshot even if a sibling thread execs while -Worker launch waits for retirement admission. The child copies the current +Fork first checks live-memory capacity and the retired-generation count and +byte thresholds synchronously. If retired debt is already saturated, fork +returns `EAGAIN` before constructing or copying another address space. It +cannot wait asynchronously at that point: a sibling thread could mutate the +parent memory while the caller yielded, changing the purported syscall-time +snapshot. + +Once admitted, the host synchronously acquires an exactly sized fresh backing +and copies the parent's current memory length before the first asynchronous +host operation. This preserves the syscall-time snapshot even if a sibling +thread execs while the child Worker is prepared. The child copies the current length, not the configured maximum, because `memory.size()` and the accessible address-space boundary are part of the state fork duplicates. Pthread workers share the owning process memory plus that process's thread allocator. A fork diff --git a/docs/browser-support.md b/docs/browser-support.md index da03584854..62bd64512d 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -935,7 +935,12 @@ later process. `maxMemoryPages` still caps each backing's guest brk/mmap growth and should be tuned for workloads that need large address spaces. Fork synchronously copies the parent's exact current byte length into another fresh backing so its observable `memory.size()` and accessible address-space boundary -match the parent before any asynchronous Worker launch work can yield. +match the parent before any asynchronous Worker launch work can yield. Before +constructing that backing, fork synchronously checks both live capacity and +the retired-generation count and byte thresholds. Saturated retirement debt +returns `EAGAIN` without allocating or copying another full address space; an +asynchronous pre-copy wait would let another parent thread invalidate the +fork-time snapshot. Browser `Worker.terminate()` is not treated as proof that a Worker stopped touching shared memory. Cooperative exit and exec publish an exact terminal diff --git a/host/src/browser-kernel-worker-entry.ts b/host/src/browser-kernel-worker-entry.ts index 06fce7d13c..5e0939e79d 100644 --- a/host/src/browser-kernel-worker-entry.ts +++ b/host/src/browser-kernel-worker-entry.ts @@ -1551,8 +1551,8 @@ async function handleFork( const ptrWidth = parentInfo.ptrWidth; const childLayout = parentInfo.layout; // WHY: teardown and compilation below yield. A sibling exec may then retire - // the parent's exact generation, so the committed fork must own its clone - // before the first await. + // the parent's exact generation, so the committed fork must pass + // retired-memory admission and own its clone before the first await. const childMemoryLease = acquireForkMemoryClone( processMemoryAllocator, parentMemory, @@ -1573,12 +1573,6 @@ async function handleFork( ); try { await waitForProcessTeardowns(); - // Preserve fork's exact syscall-time snapshot before any await, then hold - // only Worker launch until the retirement admission gate reopens. - await processMemoryAllocator.waitForRetirementBacklogCapacity( - childMemory.buffer.byteLength, - ); - // Pre-compile module for TurboFan-optimized code (smaller stack frames). if (!parentInfo.programModule) { parentInfo.programModule = await WebAssembly.compile(parentInfo.programBytes); diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts index 38f545f32f..eeb0246189 100644 --- a/host/src/node-kernel-worker-entry.ts +++ b/host/src/node-kernel-worker-entry.ts @@ -1302,8 +1302,8 @@ async function handleFork( const ptrWidth = parentInfo.ptrWidth; const childLayout = parentInfo.layout; // WHY: compilation below yields. A sibling exec may then retire the parent's - // exact generation, so the committed fork must own its clone before the - // first await. + // exact generation, so the committed fork must pass retired-memory + // admission and own its clone before the first await. const childMemoryLease = acquireForkMemoryClone( processMemoryAllocator, parentMemory, @@ -1323,11 +1323,6 @@ async function handleFork( `fork child pid=${childPid}`, ); try { - // The committed child already owns its exact syscall-time snapshot. - // Delay only Worker launch while a short retirement burst drains. - await processMemoryAllocator.waitForRetirementBacklogCapacity( - childMemory.buffer.byteLength, - ); if (!parentInfo.programModule) { parentInfo.programModule = await WebAssembly.compile(parentProgram); } diff --git a/host/src/process-memory.ts b/host/src/process-memory.ts index d328618393..99a127ca2c 100644 --- a/host/src/process-memory.ts +++ b/host/src/process-memory.ts @@ -723,7 +723,7 @@ export class ProcessMemoryAllocator { } acquire(request: ProcessMemoryAllocationRequest): ProcessMemoryLease { - return this.acquireInternal(request, false); + return this.acquireInternal(request); } /** @@ -755,21 +755,6 @@ export class ProcessMemoryAllocator { } } - /** - * Acquire the child's exact syscall-time fork snapshot synchronously. - * - * WHY: awaiting retirement admission before copying would let a sibling - * thread mutate the parent address space after fork committed. This narrow - * bypass still obeys the hard live count and sampled byte admission budget; - * callers must await `waitForRetirementBacklogCapacity()` before launching - * the child Worker. - */ - acquireForForkSnapshot( - request: ProcessMemoryAllocationRequest, - ): ProcessMemoryLease { - return this.acquireInternal(request, true); - } - async waitForRetirementBacklogCapacity( requestedBytes: number, timeoutMs = Math.max(250, this.retirementBackpressureMs * 4), @@ -810,7 +795,6 @@ export class ProcessMemoryAllocator { private acquireInternal( request: ProcessMemoryAllocationRequest, - bypassRetirementBacklog: boolean, ): ProcessMemoryLease { this.validateRequest(request); this.refreshOwnedBytes(); @@ -823,10 +807,7 @@ export class ProcessMemoryAllocator { this.options.maxTotalBytes, ); } - this.requireAllocationCapacity( - requestedBytes, - bypassRetirementBacklog, - ); + this.requireAllocationCapacity(requestedBytes); const memory = this.createMemory(request); const record: ProcessMemoryRecord = { allocationId: this.nextAllocationId++, @@ -989,9 +970,8 @@ export class ProcessMemoryAllocator { private requireAllocationCapacity( requestedBytes: number, - bypassRetirementBacklog = false, ): void { - if (!bypassRetirementBacklog && this.retirementBacklogSaturated()) { + if (this.retirementBacklogSaturated()) { throw this.createRetirementBacklogError(requestedBytes); } if (this.liveMemories >= this.options.maxMemories) { @@ -1242,7 +1222,11 @@ export function acquireForkMemoryClone( if (parentBytes % WASM_PAGE_SIZE !== 0) { throw new Error(`fork parent memory is not page-aligned: ${parentBytes}`); } - const lease = allocator.acquireForForkSnapshot({ + // WHY: this synchronous admission check is part of the fork snapshot + // transaction. It cannot await, because another parent thread could mutate + // Memory during a yield, but it must reject saturated retired-memory debt + // before constructing and copying another complete address space. + const lease = allocator.acquire({ ptrWidth, initialPages: parentBytes / WASM_PAGE_SIZE, maximumPages, diff --git a/host/test/multi-worker.test.ts b/host/test/multi-worker.test.ts index 970cf1024b..966ac68883 100644 --- a/host/test/multi-worker.test.ts +++ b/host/test/multi-worker.test.ts @@ -25,6 +25,7 @@ import { computeProcessMemoryLayout, createProcessMemory as createLayoutMemory, FORK_SAVE_BUFFER_SIZE, + ProcessMemoryRetirementBacklogError, type ProcessMemoryLayout, } from "../src/process-memory"; import { writeForkContinuationAnchor } from "../src/fork-continuation"; @@ -784,6 +785,66 @@ describe("CentralizedKernelWorker Process Management", () => { } }); + it("reports retired-memory fork admission failure as EAGAIN", async () => { + const parentPid = 77; + const memory = new WebAssembly.Memory({ + initial: 4, + maximum: 4, + shared: true, + }); + const channel = { pid: parentPid, channelOffset: WASM_PAGE_SIZE, memory }; + publishMainForkContinuation(memory, channel.channelOffset); + const completeChannel = vi.fn(); + const deactivateProcess = vi.fn(); + const removeProcess = vi.fn(() => 0); + const admissionError = new ProcessMemoryRetirementBacklogError( + "retired process-memory debt is saturated", + 4 * WASM_PAGE_SIZE, + 1, + 4 * WASM_PAGE_SIZE, + 1, + 4 * WASM_PAGE_SIZE, + ); + const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + callbacks: { onFork: vi.fn(() => Promise.reject(admissionError)) }, + processes: new Map([[parentPid, { channels: [channel] }]]), + channelTids: new Map(), + threadForkContexts: new Map(), + tcpListenerTargets: new Map([[8080, [{ pid: parentPid, fd: 4 }]]]), + epollInterests: new Map(), + completeChannel, + deactivateProcess, + kernelInstance: { + exports: { + kernel_fork_process: vi.fn(() => 100), + kernel_clear_fork_child: vi.fn(() => 0), + kernel_remove_process: removeProcess, + kernel_get_process_exit_signal: vi.fn(() => -1), + }, + }, + }) as CentralizedKernelWorker; + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + (kw as any).handleFork(channel, [0]); + await Promise.resolve(); + await Promise.resolve(); + + expect(deactivateProcess).toHaveBeenCalledWith(100); + expect(removeProcess).toHaveBeenCalledWith(100); + expect(completeChannel).toHaveBeenCalledWith( + channel, + HOST_INTERCEPTED_SYSCALLS.SYS_FORK, + [0], + undefined, + -1, + 11, + ); + } finally { + error.mockRestore(); + } + }); + it("terminates the parent when a failed fork launch cannot remove the child", async () => { const parentPid = 77; const childPid = 100; diff --git a/host/test/process-memory-allocator.test.ts b/host/test/process-memory-allocator.test.ts index cb368a4314..a880b01b83 100644 --- a/host/test/process-memory-allocator.test.ts +++ b/host/test/process-memory-allocator.test.ts @@ -203,13 +203,13 @@ describe("ProcessMemoryAllocator", () => { allocator.clear(); }); - it("captures fork synchronously while holding Worker launch for retirement admission", async () => { + it("rejects fork before allocating or copying when retirement debt is saturated", () => { const allocator = new ProcessMemoryAllocator({ maxMemories: 3, maxTotalBytes: 12 * WASM_PAGE_SIZE, retirementAdmissionMemoryThreshold: 1, retirementAdmissionByteThreshold: 4 * WASM_PAGE_SIZE, - retirementBackpressureMs: 10, + retirementBackpressureMs: 1_000, maxRetirementTelemetryRecords: 0, }); const parent = allocator.acquire(request(4)); @@ -217,19 +217,28 @@ describe("ProcessMemoryAllocator", () => { retiring.release(); new Uint8Array(parent.memory.buffer).fill(0x5a); - const child = acquireForkMemoryClone( - allocator, - parent.memory, - 4, - 32, - ); - expect(new Uint8Array(child.memory.buffer)[17]).toBe(0x5a); + const memoryConstructor = vi.spyOn(WebAssembly, "Memory"); + try { + expect(() => acquireForkMemoryClone( + allocator, + parent.memory, + 4, + 32, + )).toThrow(ProcessMemoryRetirementBacklogError); + expect(memoryConstructor).not.toHaveBeenCalled(); + } finally { + memoryConstructor.mockRestore(); + } + expect(allocator.getRetirementStats()).toMatchObject({ + liveMemories: 1, + liveBytes: 4 * WASM_PAGE_SIZE, + retirementBacklogMemories: 1, + retirementBacklogBytes: 4 * WASM_PAGE_SIZE, + }); + expect(new Uint8Array(parent.memory.buffer)[17]).toBe(0x5a); - await allocator.waitForRetirementBacklogCapacity( - child.memory.buffer.byteLength, - ); parent.release(); - child.release(); + allocator.clear(); }); it("keeps EAGAIN as a bounded admission fallback", async () => { From c8e12e4d3d4261024e3c5b8f70daf83c23f450ea Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Fri, 31 Jul 2026 14:29:31 -0400 Subject: [PATCH 14/82] Host: Retain exact shared memory aliases Add explicit ownership tokens for host workers that intentionally share one process-memory backing. Count the backing once, retire it only after the final alias crosses its quiescence fence, and propagate forced termination until final release. This ownership mechanism does not reinterpret ordinary fork: only callers with an explicit shared-memory transaction may retain an alias. --- host/src/process-memory.ts | 111 +++++++++++++++++---- host/test/process-memory-allocator.test.ts | 78 +++++++++++++++ 2 files changed, 170 insertions(+), 19 deletions(-) diff --git a/host/src/process-memory.ts b/host/src/process-memory.ts index 99a127ca2c..4b7a832e78 100644 --- a/host/src/process-memory.ts +++ b/host/src/process-memory.ts @@ -372,19 +372,25 @@ export interface ProcessMemoryLease { readonly ptrWidth: 4 | 8; readonly maximumPages: number; /** - * Retire this exact backing after every Worker and channel that could touch - * it has crossed its explicit quiescence fence. A lease is single-owner - * authority and may be consumed exactly once. + * Retain one additional explicit host ownership token for this exact + * backing. This does not create a new POSIX address space and must never be + * used to implement ordinary fork, which requires an independent Memory. + */ + retainAlias(): ProcessMemoryLease; + /** + * Release this alias after its Worker and channel have crossed their exact + * quiescence fence. The backing retires only after every retained alias is + * released. Each lease is one ownership token and may be consumed once. */ release(): void; /** * Drop this host's alias after force-terminating an owner that cannot * acknowledge quiescence. * - * Fresh-only allocation makes this safe: the terminated worker may keep its - * own Memory alive briefly, but the address space is never handed to another - * process. The allocator therefore applies temporary retirement admission - * backpressure without deliberately retaining the Memory. + * A terminated Worker may keep its own Memory alive briefly. If other + * explicit aliases remain, the allocator remembers this ambiguity and marks + * the whole backing forced when the final alias is released. New aliases + * cannot be created after that point. */ releaseAfterForcedTermination(): void; } @@ -402,6 +408,8 @@ export interface ProcessMemoryRetirementStats { readonly observedRetirements: number; readonly observedFinalizations: number; readonly liveMemories: number; + /** Number of explicit leases across the live backing records. */ + readonly liveAliases: number; readonly liveBytes: number; readonly pendingRetirements: number; readonly pendingRetiredBytes: number; @@ -550,6 +558,8 @@ type ProcessMemoryRecord = { maximumPages: number; accountedBytes: number; state: "leased" | "retiring"; + activeAliases: number; + forcedTerminationObserved: boolean; retirementMode?: "quiescent" | "forced"; retirementBackpressureActive: boolean; retirementBackpressureTimer?: ReturnType; @@ -560,6 +570,7 @@ type ProcessMemoryRecord = { class OwnedProcessMemoryLease implements ProcessMemoryLease { private ownedMemory: WebAssembly.Memory | undefined; + private retainOwnedRecord: (() => ProcessMemoryLease) | undefined; private consumeOwnedRecord: | ((retirementMode: "quiescent" | "forced") => void) | undefined; @@ -568,9 +579,11 @@ class OwnedProcessMemoryLease implements ProcessMemoryLease { memory: WebAssembly.Memory, readonly ptrWidth: 4 | 8, readonly maximumPages: number, + retainOwnedRecord: () => ProcessMemoryLease, consumeOwnedRecord: (retirementMode: "quiescent" | "forced") => void, ) { this.ownedMemory = memory; + this.retainOwnedRecord = retainOwnedRecord; this.consumeOwnedRecord = consumeOwnedRecord; } @@ -581,6 +594,13 @@ class OwnedProcessMemoryLease implements ProcessMemoryLease { return this.ownedMemory; } + retainAlias(): ProcessMemoryLease { + if (!this.retainOwnedRecord || !this.ownedMemory) { + throw new Error("Process memory lease was already consumed"); + } + return this.retainOwnedRecord(); + } + release(): void { this.consume("quiescent"); } @@ -598,6 +618,7 @@ class OwnedProcessMemoryLease implements ProcessMemoryLease { // WHY: a consumed lease may itself outlive the process record. Sever both // strong paths so merely retaining the lease cannot retain the Memory. this.ownedMemory = undefined; + this.retainOwnedRecord = undefined; this.consumeOwnedRecord = undefined; } } @@ -605,10 +626,12 @@ class OwnedProcessMemoryLease implements ProcessMemoryLease { /** * Session-owned allocator for fresh process Shared WebAssembly.Memory objects. * - * Every allocation is a new POSIX address space. Safe retirement drops the - * allocator's strong reference only after the host proves that the exact - * Worker generation and all channel listeners are quiescent. Ambiguous - * forced termination uses a separately tracked retirement mode instead. + * Every allocation is a new POSIX address space. `retainAlias()` is the only + * deliberate exception: it creates another ownership token for the same + * backing without charging the address space again. Safe retirement drops the + * allocator's strong reference only after every exact alias proves that its + * Worker generation and channel listeners are quiescent. Ambiguous forced + * termination taints the record until its final release. * * A bounded FinalizationRegistry ledger records observed Memory/buffer/view * wrappers without retaining them. It is telemetry, not ownership authority: @@ -816,6 +839,8 @@ export class ProcessMemoryAllocator { maximumPages: request.maximumPages, accountedBytes: memory.buffer.byteLength, state: "leased", + activeAliases: 1, + forcedTerminationObserved: false, retirementBackpressureActive: false, finalizationObserved: false, telemetryQueued: false, @@ -827,14 +852,42 @@ export class ProcessMemoryAllocator { this.liveBytes = this.safeAdd(this.liveBytes, record.accountedBytes); this.observeTargetRecord(record, memory); this.observeTargetRecord(record, memory.buffer); + return this.createLease(record); + } + + private createLease(record: ProcessMemoryRecord): ProcessMemoryLease { + const memory = record.memory; + if (!memory || record.state !== "leased") { + throw new Error("Cannot create a lease for retired process memory"); + } return new OwnedProcessMemoryLease( memory, record.ptrWidth, record.maximumPages, + () => this.retainRecord(record), (retirementMode) => this.releaseRecord(record, retirementMode), ); } + private retainRecord(record: ProcessMemoryRecord): ProcessMemoryLease { + if ( + this.records.get(record.allocationId) !== record + || record.state !== "leased" + || !record.memory + || record.activeAliases <= 0 + ) { + throw new Error("Process memory record is not an active lease"); + } + if (record.forcedTerminationObserved) { + throw new Error("Cannot retain process memory after forced termination"); + } + if (record.activeAliases === Number.MAX_SAFE_INTEGER) { + throw new Error("Process memory alias count exceeds JavaScript precision"); + } + record.activeAliases += 1; + return this.createLease(record); + } + /** * Record a persistent wrapper or buffer generation that can keep one process * address space reachable from the kernel Worker realm. @@ -874,18 +927,23 @@ export class ProcessMemoryAllocator { getRetirementStats(): ProcessMemoryRetirementStats { let pendingRetirements = 0; let pendingRetiredBytes = 0; + let liveAliases = 0; for (const record of this.records.values()) { - if (record.state !== "retiring") continue; - pendingRetirements += 1; - pendingRetiredBytes = this.safeAdd( - pendingRetiredBytes, - record.accountedBytes, - ); + if (record.state === "leased") { + liveAliases = this.safeAdd(liveAliases, record.activeAliases); + } else { + pendingRetirements += 1; + pendingRetiredBytes = this.safeAdd( + pendingRetiredBytes, + record.accountedBytes, + ); + } } return { observedRetirements: this.observedRetirements, observedFinalizations: this.observedFinalizations, liveMemories: this.liveMemories, + liveAliases, liveBytes: this.liveBytes, pendingRetirements, pendingRetiredBytes, @@ -907,10 +965,19 @@ export class ProcessMemoryAllocator { if ( this.records.get(record.allocationId) !== record || record.state !== "leased" + || record.activeAliases <= 0 ) { throw new Error("Process memory record is not an active lease"); } + if (retirementMode === "forced") { + record.forcedTerminationObserved = true; + } + if (record.activeAliases > 1) { + record.activeAliases -= 1; + return; + } + const memory = record.memory; if (!memory) { throw new Error("Process memory record lost its active Memory"); @@ -924,12 +991,18 @@ export class ProcessMemoryAllocator { this.liveBytes, actualBytes - record.accountedBytes, ); + // Publish the final token consumption only after every fallible backing + // check. If an integrity check throws, the caller still owns a retryable + // lease rather than leaving a leased record with zero aliases. + record.activeAliases = 0; record.accountedBytes = actualBytes; this.liveBytes = Math.max(0, this.liveBytes - actualBytes); this.liveMemories = Math.max(0, this.liveMemories - 1); this.recordsByMemory.delete(memory); record.state = "retiring"; - record.retirementMode = retirementMode; + record.retirementMode = record.forcedTerminationObserved + ? "forced" + : "quiescent"; record.retirementBackpressureActive = true; record.memory = undefined; this.retirementBacklogMemories += 1; @@ -940,7 +1013,7 @@ export class ProcessMemoryAllocator { this.observedRetirements += 1; const notice: ProcessMemoryRetirementNotice = Object.freeze({ retirementId: record.allocationId, - retirementMode, + retirementMode: record.retirementMode, ptrWidth: record.ptrWidth, maximumPages: record.maximumPages, byteLength: record.accountedBytes, diff --git a/host/test/process-memory-allocator.test.ts b/host/test/process-memory-allocator.test.ts index a880b01b83..cf12f53fa8 100644 --- a/host/test/process-memory-allocator.test.ts +++ b/host/test/process-memory-allocator.test.ts @@ -57,6 +57,83 @@ describe("ProcessMemoryAllocator", () => { wasm64.release(); }); + it("counts retained aliases once and retires only after the final release", () => { + const allocator = new ProcessMemoryAllocator({ + maxMemories: 1, + maxTotalBytes: 4 * WASM_PAGE_SIZE, + retirementBackpressureMs: 1_000, + }); + const owner = allocator.acquire(request(4)); + const alias = owner.retainAlias(); + + expect(alias.memory).toBe(owner.memory); + expect(allocator.getRetirementStats()).toMatchObject({ + liveMemories: 1, + liveAliases: 2, + liveBytes: 4 * WASM_PAGE_SIZE, + pendingRetirements: 0, + retirementBacklogMemories: 0, + }); + expect(() => allocator.acquire(request(1))).toThrow( + ProcessMemoryCapacityError, + ); + + owner.release(); + expect(() => owner.memory).toThrow("already consumed"); + new Uint8Array(alias.memory.buffer)[17] = 0x5a; + expect(allocator.getRetirementStats()).toMatchObject({ + liveMemories: 1, + liveAliases: 1, + liveBytes: 4 * WASM_PAGE_SIZE, + pendingRetirements: 0, + }); + + alias.release(); + expect(allocator.getRetirementStats()).toMatchObject({ + liveMemories: 0, + liveAliases: 0, + liveBytes: 0, + pendingRetirements: 1, + retirementBacklogMemories: 1, + retirementBacklogBytes: 4 * WASM_PAGE_SIZE, + }); + allocator.clear(); + }); + + it("taints all aliases after forced termination until final retirement", async () => { + const retirements: string[] = []; + const allocator = new ProcessMemoryAllocator({ + maxMemories: 1, + maxTotalBytes: 4 * WASM_PAGE_SIZE, + retirementBackpressureMs: 1_000, + retirementPressureHook: ({ retirementMode }) => { + retirements.push(retirementMode); + }, + }); + const owner = allocator.acquire(request(4)); + const alias = owner.retainAlias(); + + alias.releaseAfterForcedTermination(); + expect(allocator.getRetirementStats()).toMatchObject({ + liveMemories: 1, + liveAliases: 1, + pendingRetirements: 0, + }); + expect(() => owner.retainAlias()).toThrow( + "Cannot retain process memory after forced termination", + ); + + owner.release(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(retirements).toEqual(["forced"]); + expect(allocator.getRetirementStats()).toMatchObject({ + liveMemories: 0, + liveAliases: 0, + pendingRetirements: 1, + }); + allocator.clear(); + }); + it("owns a fork snapshot before an async parent retirement", async () => { const allocator = new ProcessMemoryAllocator({ maxMemories: 3, @@ -331,6 +408,7 @@ describe("ProcessMemoryAllocator", () => { expect(() => lease.releaseAfterForcedTermination()).toThrow( "already consumed", ); + expect(() => lease.retainAlias()).toThrow("already consumed"); }); it("rejects one observed wrapper being assigned to two memory generations", () => { From 76a6c4e5f724c771f7735c5fba8f86147d2d7732 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sat, 1 Aug 2026 00:15:47 -0400 Subject: [PATCH 15/82] Tests: Exercise fork admission through sealed authority Drive ordinary-fork admission through the production kernel entry gate instead of constructing a partial worker object. Verify that a retired-memory EAGAIN removes the provisional child and completes the parent mailbox with the truthful error. --- host/test/multi-worker.test.ts | 53 ++++++++++++++-------------------- 1 file changed, 21 insertions(+), 32 deletions(-) diff --git a/host/test/multi-worker.test.ts b/host/test/multi-worker.test.ts index 966ac68883..a3d7acac26 100644 --- a/host/test/multi-worker.test.ts +++ b/host/test/multi-worker.test.ts @@ -792,10 +792,8 @@ describe("CentralizedKernelWorker Process Management", () => { maximum: 4, shared: true, }); - const channel = { pid: parentPid, channelOffset: WASM_PAGE_SIZE, memory }; - publishMainForkContinuation(memory, channel.channelOffset); - const completeChannel = vi.fn(); - const deactivateProcess = vi.fn(); + const channelOffset = WASM_PAGE_SIZE; + publishMainForkContinuation(memory, channelOffset); const removeProcess = vi.fn(() => 0); const admissionError = new ProcessMemoryRetirementBacklogError( "retired process-memory debt is saturated", @@ -805,41 +803,32 @@ describe("CentralizedKernelWorker Process Management", () => { 1, 4 * WASM_PAGE_SIZE, ); - const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { - callbacks: { onFork: vi.fn(() => Promise.reject(admissionError)) }, - processes: new Map([[parentPid, { channels: [channel] }]]), - channelTids: new Map(), - threadForkContexts: new Map(), - tcpListenerTargets: new Map([[8080, [{ pid: parentPid, fd: 4 }]]]), - epollInterests: new Map(), - completeChannel, - deactivateProcess, - kernelInstance: { - exports: { - kernel_fork_process: vi.fn(() => 100), - kernel_clear_fork_child: vi.fn(() => 0), - kernel_remove_process: removeProcess, - kernel_get_process_exit_signal: vi.fn(() => -1), - }, + const onFork = vi.fn(() => Promise.reject(admissionError)); + const harness = createGatedLifecycleHarness({ + callbacks: { onFork }, + kernelExports: { + kernel_fork_process: vi.fn(() => 100), + kernel_remove_process: removeProcess, }, - }) as CentralizedKernelWorker; + }); + registerLifecycleProcess(harness, parentPid, memory, channelOffset); const error = vi.spyOn(console, "error").mockImplementation(() => {}); try { - (kw as any).handleFork(channel, [0]); - await Promise.resolve(); - await Promise.resolve(); - - expect(deactivateProcess).toHaveBeenCalledWith(100); - expect(removeProcess).toHaveBeenCalledWith(100); - expect(completeChannel).toHaveBeenCalledWith( - channel, + writePendingSyscall( + memory, + channelOffset, HOST_INTERCEPTED_SYSCALLS.SYS_FORK, [0], - undefined, - -1, - 11, ); + await waitForMailboxCompletion(memory, channelOffset); + + expect(onFork).toHaveBeenCalledOnce(); + expect(removeProcess).toHaveBeenCalledWith(100); + expect(readMailboxResult(memory, channelOffset)).toEqual({ + value: -1, + errno: 11, + }); } finally { error.mockRestore(); } From 212ca76035c18522ddca9161b8ee3157291314d5 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Fri, 31 Jul 2026 14:30:44 -0400 Subject: [PATCH 16/82] Fork: Borrow replay workers across main and side modules Allow a separately launched child worker to borrow the parent process continuation while keeping worker-local replay control state independent. Rebuild main-module and active side-module references from admitted bytes without handing the child authority over parent continuation storage. Gate completion through an explicit lifetime coordinator and cover main, dynamic-link, and active-side replay in Node, Chromium, Firefox, and WebKit fixtures. --- .../test/borrowed-fork-replay.spec.ts | 706 ++++++++++++++++++ ...rowed-active-side-replay-browser-worker.ts | 132 ++++ .../borrowed-dylink-replay-browser-worker.ts | 64 ++ .../borrowed-fork-replay-browser-worker.ts | 120 +++ .../test/fixtures/borrowed-process-runtime.ts | 174 +++++ docs/fork-instrumentation.md | 11 + host/src/dylink.ts | 158 +++- host/src/fork-continuation.ts | 166 +++- host/src/fork-module-state.ts | 52 +- host/src/fork-process-continuation.ts | 171 ++++- host/src/vfork-lifetime.ts | 338 +++++++++ host/src/worker-main.ts | 14 +- host/test/dylink.test.ts | 158 ++++ .../fixtures/borrowed-fork-replay-worker.ts | 86 +++ host/test/fork-borrowed-replay.test.ts | 188 +++++ host/test/fork-continuation.test.ts | 158 ++++ host/test/fork-process-continuation.test.ts | 193 +++++ host/test/vfork-lifetime.test.ts | 278 +++++++ 18 files changed, 3130 insertions(+), 37 deletions(-) create mode 100644 apps/browser-demos/test/borrowed-fork-replay.spec.ts create mode 100644 apps/browser-demos/test/fixtures/borrowed-active-side-replay-browser-worker.ts create mode 100644 apps/browser-demos/test/fixtures/borrowed-dylink-replay-browser-worker.ts create mode 100644 apps/browser-demos/test/fixtures/borrowed-fork-replay-browser-worker.ts create mode 100644 apps/browser-demos/test/fixtures/borrowed-process-runtime.ts create mode 100644 host/src/vfork-lifetime.ts create mode 100644 host/test/fixtures/borrowed-fork-replay-worker.ts create mode 100644 host/test/fork-borrowed-replay.test.ts create mode 100644 host/test/vfork-lifetime.test.ts diff --git a/apps/browser-demos/test/borrowed-fork-replay.spec.ts b/apps/browser-demos/test/borrowed-fork-replay.spec.ts new file mode 100644 index 0000000000..a776dd88d1 --- /dev/null +++ b/apps/browser-demos/test/borrowed-fork-replay.spec.ts @@ -0,0 +1,706 @@ +import { expect, test } from "@playwright/test"; +import { execFileSync } from "node:child_process"; +import { + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const continuationModulePath = resolve( + __dirname, + "../../../host/src/fork-continuation.ts", +); +const moduleStateModulePath = resolve( + __dirname, + "../../../host/src/fork-module-state.ts", +); +const runtimeHarnessPath = resolve( + __dirname, + "../../../host/test/fork-instrument-runtime-harness.ts", +); +const dylinkModulePath = resolve( + __dirname, + "../../../host/src/dylink.ts", +); +const childWorkerPath = resolve( + __dirname, + "fixtures/borrowed-fork-replay-browser-worker.ts", +); +const childDylinkWorkerPath = resolve( + __dirname, + "fixtures/borrowed-dylink-replay-browser-worker.ts", +); +const processRuntimePath = resolve( + __dirname, + "fixtures/borrowed-process-runtime.ts", +); +const childActiveSideWorkerPath = resolve( + __dirname, + "fixtures/borrowed-active-side-replay-browser-worker.ts", +); + +function buildBorrowedReplayFixture(): { bytes: number[]; cleanup(): void } { + const dir = mkdtempSync(join(tmpdir(), "kandelo-browser-fork-borrow-")); + try { + const rawPath = join(dir, "borrow.wasm"); + const instrumentedPath = join(dir, "borrow.instrumented.wasm"); + const watPath = join(dir, "borrow.wat"); + writeFileSync(watPath, `(module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (import "env" "memory" (memory 16 16 shared)) + (func $leaf (result i32) call $fork) + (func (export "run") (result i32) (local $saved i32) + i32.const 7 + local.set $saved + call $leaf + local.get $saved + i32.add))`); + execFileSync("wat2wasm", [ + "--enable-threads", + watPath, + "-o", + rawPath, + ]); + execFileSync(fileURLToPath(new URL( + "../../../tools/bin/wasm-fork-instrument", + import.meta.url, + )), [ + rawPath, + "-o", + instrumentedPath, + ]); + return { + bytes: [...readFileSync(instrumentedPath)], + cleanup: () => rmSync(dir, { recursive: true, force: true }), + }; + } catch (error) { + rmSync(dir, { recursive: true, force: true }); + throw error; + } +} + +function buildBorrowedDylinkFixture(): { bytes: number[]; cleanup(): void } { + const dir = mkdtempSync(join(tmpdir(), "kandelo-browser-dylink-borrow-")); + try { + const sourcePath = join(dir, "borrowed-side.c"); + const modulePath = join(dir, "borrowed-side.so"); + writeFileSync(sourcePath, ` + static int counter = 41; + int get_counter(void) { return counter; } + void inc_counter(void) { counter++; } + `); + execFileSync("wasm32posix-cc", [ + "-shared", + "-fPIC", + "-O2", + sourcePath, + "-o", + modulePath, + ]); + return { + bytes: [...readFileSync(modulePath)], + cleanup: () => rmSync(dir, { recursive: true, force: true }), + }; + } catch (error) { + rmSync(dir, { recursive: true, force: true }); + throw error; + } +} + +function buildBorrowedActiveSideFixture(): { + mainBytes: number[]; + sideBytes: number[]; + cleanup(): void; +} { + const dir = mkdtempSync(join(tmpdir(), "kandelo-browser-active-side-borrow-")); + try { + const build = (name: string, wat: string, side: boolean): number[] => { + const watPath = join(dir, `${name}.wat`); + const rawPath = join(dir, `${name}.wasm`); + const instrumentedPath = join(dir, `${name}.instrumented.wasm`); + writeFileSync(watPath, wat); + execFileSync("wat2wasm", [ + "--enable-threads", + watPath, + "-o", + rawPath, + ]); + execFileSync(fileURLToPath(new URL( + "../../../tools/bin/wasm-fork-instrument", + import.meta.url, + )), [ + ...(side ? ["--entry", "env.fork"] : []), + rawPath, + "-o", + instrumentedPath, + ]); + return [...readFileSync(instrumentedPath)]; + }; + return { + mainBytes: build("borrowed-main", `(module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (import "env" "memory" (memory 16 16 shared)) + (func (export "main_fork") (result i32) call $fork))`, false), + sideBytes: build("borrowed-side", `(module + (import "env" "memory" (memory 16 16 shared)) + (import "env" "fork" (func $fork (result i32))) + (func $leaf (result i32) call $fork) + (func (export "run") (result i32) (local $saved i32) + i32.const 7 + local.set $saved + call $leaf + local.get $saved + i32.add))`, true), + cleanup: () => rmSync(dir, { recursive: true, force: true }), + }; + } catch (error) { + rmSync(dir, { recursive: true, force: true }); + throw error; + } +} + +test("separate browser Worker borrows ABI 43 replay without consuming its parent", async ({ + page, + baseURL, + browserName, +}) => { + test.setTimeout(120_000); + expect(baseURL).toBeTruthy(); + const fixture = buildBorrowedReplayFixture(); + try { + await page.goto(new URL("/trap-signal-test.html", baseURL!).href); + const asViteFsUrl = (path: string) => + new URL(`/@fs/${path}`, baseURL!).href; + const result = await page.evaluate( + async ({ + bytes, + continuationModuleUrl, + moduleStateModuleUrl, + runtimeHarnessUrl, + childWorkerUrl, + }) => { + const { + LinkedForkContinuation, + readLinkedFrameFormat, + } = await import(/* @vite-ignore */ continuationModuleUrl); + const { + ForkModuleStateArena, + readForkModuleStateRoot, + } = await import(/* @vite-ignore */ moduleStateModuleUrl); + const { SingleActivationForkRuntime } = await import( + /* @vite-ignore */ runtimeHarnessUrl + ); + const moduleBytes = new Uint8Array(bytes); + const module = await WebAssembly.compile(moduleBytes); + const linkedFormat = readLinkedFrameFormat(module); + const memory = new WebAssembly.Memory({ + initial: 16, + maximum: 16, + shared: true, + }); + const allocations: Array<{ addr: number; size: number }> = []; + const releases: Array<{ addr: number; size: number }> = []; + const arenaReleases: Array<{ addr: number; size: number }> = []; + let nextAddress = 65_536; + let nextArenaAddress = 12 * 65_536; + const parentContinuation = new LinkedForkContinuation( + memory, + linkedFormat, + (size: number) => { + const addr = nextAddress; + nextAddress += size; + allocations.push({ addr, size }); + return addr; + }, + (addr: number, size: number) => releases.push({ addr, size }), + "borrow-parent-browser-e2e", + ); + const parentRuntime = new SingleActivationForkRuntime({ + module, + moduleBytes, + memory, + continuation: parentContinuation, + newArena: () => new ForkModuleStateArena( + memory, + linkedFormat.ptrWidth, + (size: number) => { + const address = nextArenaAddress; + nextArenaAddress += size; + return address; + }, + (addr: number, size: number) => { + arenaReleases.push({ addr, size }); + }, + "borrow-parent-browser module state", + ), + label: "borrow-parent-browser-e2e", + }); + let parentInstance: WebAssembly.Instance; + let parentForkResult = 0; + parentInstance = new WebAssembly.Instance(module, { + env: { + memory, + ...parentRuntime.envImports, + }, + kernel: { + kernel_fork: () => { + if (parentRuntime.coordinator.phaseName() === "parent-replay") { + parentRuntime.coordinator.finishReplay(); + return parentForkResult; + } + parentRuntime.beginCapture(); + return 0; + }, + }, + }); + parentRuntime.register(parentInstance); + const parentRun = parentInstance.exports.run as () => number; + + parentRuntime.expectCaptureTransport(parentRun); + parentRuntime.coordinator.sealCapture(); + const moduleBuffer = parentRuntime.coordinator.rootFor(0); + const moduleStateRoot = readForkModuleStateRoot( + memory, + moduleBuffer, + linkedFormat.ptrWidth, + ); + const savedChunks = allocations.map(({ addr, size }) => ({ + addr, + bytes: new Uint8Array(memory.buffer, addr, size).slice(), + })); + const savedArena = new Uint8Array( + memory.buffer, + moduleStateRoot, + 65_536, + ).slice(); + + const privateModuleBuffer = 15 * 65_536; + const childWorker = new Worker(childWorkerUrl, { type: "module" }); + let childResult: { + result: number; + active: boolean; + arenaActive: boolean; + }; + try { + childResult = await new Promise((resolve, reject) => { + childWorker.onmessage = (event) => { + if (event.data?.error) { + reject(new Error(event.data.error)); + return; + } + resolve(event.data); + }; + childWorker.onerror = (event) => { + reject(new Error(event.message)); + }; + childWorker.postMessage({ + module, + moduleBytes, + memory, + linkedFormat, + moduleBuffer, + moduleStateRoot, + privateModuleBuffer, + }); + }); + } finally { + childWorker.terminate(); + } + + const chunksUnchanged = savedChunks.every(({ addr, bytes }) => { + const current = new Uint8Array(memory.buffer, addr, bytes.length); + return bytes.every((value, index) => current[index] === value); + }); + const arenaUnchanged = savedArena.every((value, index) => + new Uint8Array(memory.buffer, moduleStateRoot, 65_536)[index] === value + ); + const childReleases = releases.length; + const childArenaReleases = arenaReleases.length; + + parentRuntime.coordinator.beginParentReplay(); + parentForkResult = 123; + const finalParentResult = parentRun(); + + return { + childResult, + childReleases, + childArenaReleases, + chunksUnchanged, + arenaUnchanged, + finalParentResult, + parentActive: parentContinuation.hasActiveContinuation(), + releasedInReverseOrder: + JSON.stringify(releases) + === JSON.stringify([...allocations].reverse()), + arenaReleased: arenaReleases.length === 1, + }; + }, + { + bytes: fixture.bytes, + continuationModuleUrl: asViteFsUrl(continuationModulePath), + moduleStateModuleUrl: asViteFsUrl(moduleStateModulePath), + runtimeHarnessUrl: asViteFsUrl(runtimeHarnessPath), + childWorkerUrl: asViteFsUrl(childWorkerPath), + }, + ); + + expect(result, browserName).toEqual({ + childResult: { result: 7, active: false, arenaActive: false }, + childReleases: 0, + childArenaReleases: 0, + chunksUnchanged: true, + arenaUnchanged: true, + finalParentResult: 130, + parentActive: false, + releasedInReverseOrder: true, + arenaReleased: true, + }); + } finally { + fixture.cleanup(); + } +}); + +test("borrowed side-module reconstruction does not write parent memory", async ({ + page, + baseURL, + browserName, +}) => { + test.setTimeout(120_000); + expect(baseURL).toBeTruthy(); + const fixture = buildBorrowedDylinkFixture(); + try { + await page.goto(new URL("/trap-signal-test.html", baseURL!).href); + const asViteFsUrl = (path: string) => + new URL(`/@fs/${path}`, baseURL!).href; + const result = await page.evaluate( + async ({ bytes, dylinkModuleUrl, childWorkerUrl }) => { + const { loadSharedLibrarySync } = await import( + /* @vite-ignore */ dylinkModuleUrl + ); + const memory = new WebAssembly.Memory({ + initial: 4, + maximum: 100, + shared: true, + }); + const parent = loadSharedLibrarySync( + "libborrowed-browser-side.so", + new Uint8Array(bytes), + { + memory, + table: new WebAssembly.Table({ initial: 1, element: "anyfunc" }), + stackPointer: new WebAssembly.Global( + { value: "i32", mutable: true }, + 65_536, + ), + heapPointer: { value: 4_096 }, + globalSymbols: new Map(), + got: new Map(), + loadedLibraries: new Map(), + }, + ); + (parent.exports.inc_counter as () => void)(); + const parentBefore = (parent.exports.get_counter as () => number)(); + const savedData = new Uint8Array( + memory.buffer, + parent.memoryBase, + parent.metadata.memorySize, + ).slice(); + + const childWorker = new Worker(childWorkerUrl, { type: "module" }); + let childResult: { value?: number; error?: string }; + try { + childResult = await new Promise((resolve, reject) => { + childWorker.onmessage = (event) => resolve(event.data); + childWorker.onerror = (event) => reject(new Error(event.message)); + childWorker.postMessage({ + bytes, + memory, + memoryBase: parent.memoryBase, + tableBase: parent.tableBase, + tlsBase: parent.tlsBase, + }); + }); + } finally { + childWorker.terminate(); + } + if (childResult.error) throw new Error(childResult.error); + + const dataUnchanged = savedData.every((value, index) => + new Uint8Array( + memory.buffer, + parent.memoryBase, + parent.metadata.memorySize, + )[index] === value + ); + return { + parentBefore, + childValue: childResult.value, + dataUnchanged, + parentAfter: (parent.exports.get_counter as () => number)(), + }; + }, + { + bytes: fixture.bytes, + dylinkModuleUrl: asViteFsUrl(dylinkModulePath), + childWorkerUrl: asViteFsUrl(childDylinkWorkerPath), + }, + ); + + expect(result, browserName).toEqual({ + parentBefore: 42, + childValue: 42, + dataUnchanged: true, + parentAfter: 42, + }); + } finally { + fixture.cleanup(); + } +}); + +test("active side activation remains owned by the browser parent", async ({ + page, + baseURL, + browserName, +}) => { + test.setTimeout(120_000); + expect(baseURL).toBeTruthy(); + const fixture = buildBorrowedActiveSideFixture(); + try { + await page.goto(new URL("/trap-signal-test.html", baseURL!).href); + const asViteFsUrl = (path: string) => + new URL(`/@fs/${path}`, baseURL!).href; + const result = await page.evaluate( + async ({ + mainBytes, + sideBytes, + continuationModuleUrl, + moduleStateModuleUrl, + processRuntimeUrl, + childWorkerUrl, + }) => { + const { + LinkedForkContinuation, + readLinkedFrameFormat, + } = await import(/* @vite-ignore */ continuationModuleUrl); + const { + ForkModuleStateArena, + readForkModuleStateRoot, + } = await import(/* @vite-ignore */ moduleStateModuleUrl); + const { BorrowedProcessTestRuntime } = await import( + /* @vite-ignore */ processRuntimeUrl + ); + const mainModuleBytes = new Uint8Array(mainBytes); + const sideModuleBytes = new Uint8Array(sideBytes); + const mainModule = await WebAssembly.compile(mainModuleBytes); + const sideModule = await WebAssembly.compile(sideModuleBytes); + const memory = new WebAssembly.Memory({ + initial: 16, + maximum: 16, + shared: true, + }); + const allocations: Array<{ + activationId: number; + addr: number; + size: number; + }> = []; + const releases: Array<{ addr: number; size: number }> = []; + const arenaReleases: Array<{ addr: number; size: number }> = []; + let nextContinuation = 65_536; + let nextArena = 12 * 65_536; + const newContinuation = ( + activationId: number, + module: WebAssembly.Module, + ) => new LinkedForkContinuation( + memory, + readLinkedFrameFormat(module), + (size: number) => { + const addr = nextContinuation; + nextContinuation += size; + allocations.push({ activationId, addr, size }); + return addr; + }, + (addr: number, size: number) => releases.push({ addr, size }), + `borrowed browser parent activation ${activationId}`, + ); + const runtime = new BorrowedProcessTestRuntime( + memory, + "borrowed active-side browser parent", + ); + const mainContinuation = newContinuation(0, mainModule); + const sideContinuation = newContinuation(1, sideModule); + let parentForkResult = 0; + let parentArena: InstanceType | null = null; + const processFork = (): number => { + if (runtime.coordinator.phaseName() === "parent-replay") { + runtime.coordinator.finishReplay(); + return parentForkResult; + } + parentArena = new ForkModuleStateArena( + memory, + readLinkedFrameFormat(mainModule).ptrWidth, + (size: number) => { + const address = nextArena; + nextArena += size; + return address; + }, + (addr: number, size: number) => { + arenaReleases.push({ addr, size }); + }, + "borrowed active-side browser parent arena", + ); + parentArena.begin(); + runtime.coordinator.beginCapture(parentArena); + return 0; + }; + const mainEnv = runtime.prepareActivation({ + activationId: 0, + module: mainModule, + moduleBytes: mainModuleBytes, + continuation: mainContinuation, + }); + const sideEnv = runtime.prepareActivation({ + activationId: 1, + module: sideModule, + moduleBytes: sideModuleBytes, + continuation: sideContinuation, + invokeFork: processFork, + }); + const mainInstance = new WebAssembly.Instance(mainModule, { + env: { memory, ...mainEnv }, + kernel: { kernel_fork: processFork }, + }); + const sideInstance = new WebAssembly.Instance(sideModule, { + env: { memory, ...sideEnv }, + }); + runtime.registerActivation(0, mainInstance, true); + runtime.registerActivation(1, sideInstance, true); + const sideRun = sideInstance.exports.run as () => number; + + runtime.expectCaptureTransport(sideRun); + runtime.coordinator.sealCapture(); + if (!parentArena) throw new Error("active-side capture lost its arena"); + const processLaunchRoot = runtime.coordinator.rootFor(1); + if (runtime.coordinator.rootFor(0) !== 0) { + throw new Error("inactive main activation retained a continuation"); + } + const moduleStateRoot = readForkModuleStateRoot( + memory, + processLaunchRoot, + readLinkedFrameFormat(sideModule).ptrWidth, + ); + const sideChunkAddress = processLaunchRoot + - readLinkedFrameFormat(sideModule).chunkHeaderSize; + const savedSideChunk = new Uint8Array( + memory.buffer, + sideChunkAddress, + 65_536, + ).slice(); + const savedArena = new Uint8Array( + memory.buffer, + moduleStateRoot, + 65_536, + ).slice(); + const releasesBeforeChild = releases.length; + + const childWorker = new Worker(childWorkerUrl, { type: "module" }); + let childResult: { + result: number; + prefixActivations: number[]; + mainActive: boolean; + sideActive: boolean; + arenaActive: boolean; + }; + try { + childResult = await new Promise((resolve, reject) => { + childWorker.onmessage = (event) => { + if (event.data?.error) { + reject(new Error(event.data.error)); + return; + } + resolve(event.data); + }; + childWorker.onerror = (event) => reject(new Error(event.message)); + childWorker.postMessage({ + mainModule, + mainBytes: mainModuleBytes, + sideModule, + sideBytes: sideModuleBytes, + memory, + processLaunchRoot, + moduleStateRoot, + privatePrefix: 15 * 65_536, + }); + }); + } finally { + childWorker.terminate(); + } + + const sideChunkUnchanged = savedSideChunk.every((value, index) => + new Uint8Array(memory.buffer, sideChunkAddress, 65_536)[index] === value + ); + const arenaUnchanged = savedArena.every((value, index) => + new Uint8Array(memory.buffer, moduleStateRoot, 65_536)[index] === value + ); + const releasesAfterChild = releases.length; + parentForkResult = 123; + runtime.coordinator.beginParentReplay(); + const parentResult = sideRun(); + const releasedAddresses = [...releases] + .map(({ addr }) => addr) + .sort((left, right) => left - right); + const allocatedAddresses = allocations + .map(({ addr }) => addr) + .sort((left, right) => left - right); + + return { + childResult, + sideChunkUnchanged, + arenaUnchanged, + releasesBeforeChild, + releasesAfterChild, + parentResult, + parentMainActive: mainContinuation.hasActiveContinuation(), + parentSideActive: sideContinuation.hasActiveContinuation(), + releasedEveryAllocation: + JSON.stringify(releasedAddresses) + === JSON.stringify(allocatedAddresses), + arenaReleased: arenaReleases.length === 1, + }; + }, + { + mainBytes: fixture.mainBytes, + sideBytes: fixture.sideBytes, + continuationModuleUrl: asViteFsUrl(continuationModulePath), + moduleStateModuleUrl: asViteFsUrl(moduleStateModulePath), + processRuntimeUrl: asViteFsUrl(processRuntimePath), + childWorkerUrl: asViteFsUrl(childActiveSideWorkerPath), + }, + ); + + expect(result, browserName).toEqual({ + childResult: { + result: 7, + prefixActivations: [1], + mainActive: false, + sideActive: false, + arenaActive: false, + }, + sideChunkUnchanged: true, + arenaUnchanged: true, + releasesBeforeChild: 1, + releasesAfterChild: 1, + parentResult: 130, + parentMainActive: false, + parentSideActive: false, + releasedEveryAllocation: true, + arenaReleased: true, + }); + } finally { + fixture.cleanup(); + } +}); diff --git a/apps/browser-demos/test/fixtures/borrowed-active-side-replay-browser-worker.ts b/apps/browser-demos/test/fixtures/borrowed-active-side-replay-browser-worker.ts new file mode 100644 index 0000000000..f67c61ba2c --- /dev/null +++ b/apps/browser-demos/test/fixtures/borrowed-active-side-replay-browser-worker.ts @@ -0,0 +1,132 @@ +import { + LinkedForkContinuation, + readLinkedFrameFormat, +} from "../../../../host/src/fork-continuation"; +import { ForkModuleStateArena } from "../../../../host/src/fork-module-state"; +import { BorrowedProcessTestRuntime } from "./borrowed-process-runtime"; + +interface BorrowedActiveSideReplayRequest { + mainModule: WebAssembly.Module; + mainBytes: Uint8Array; + sideModule: WebAssembly.Module; + sideBytes: Uint8Array; + memory: WebAssembly.Memory; + processLaunchRoot: number; + moduleStateRoot: number; + privatePrefix: number; +} + +interface BorrowedActiveSideReplayResult { + result?: number; + prefixActivations?: number[]; + mainActive?: boolean; + sideActive?: boolean; + arenaActive?: boolean; + error?: string; +} + +const workerScope = globalThis as unknown as { + close(): void; + onmessage: ( + (event: MessageEvent) => void + ) | null; + postMessage(message: BorrowedActiveSideReplayResult): void; +}; + +workerScope.onmessage = (event) => { + try { + const { + mainModule, + mainBytes, + sideModule, + sideBytes, + memory, + processLaunchRoot, + moduleStateRoot, + privatePrefix, + } = event.data; + const runtime = new BorrowedProcessTestRuntime( + memory, + "borrowed active-side browser child", + ); + const mainContinuation = new LinkedForkContinuation( + memory, + readLinkedFrameFormat(mainModule), + () => { throw new Error("borrowed main activation must not allocate"); }, + () => { throw new Error("borrowed main activation must not release"); }, + "borrowed browser child main", + ); + const sideContinuation = new LinkedForkContinuation( + memory, + readLinkedFrameFormat(sideModule), + () => { throw new Error("borrowed side activation must not allocate"); }, + () => { throw new Error("borrowed side activation must not release"); }, + "borrowed browser child side", + ); + const finishBorrowedFork = (): number => { + if (runtime.coordinator.phaseName() !== "child-replay") { + throw new Error( + `borrowed side reached fork while ${runtime.coordinator.phaseName()}`, + ); + } + runtime.coordinator.finishReplay(); + return 0; + }; + const mainEnv = runtime.prepareActivation({ + activationId: 0, + module: mainModule, + moduleBytes: mainBytes, + continuation: mainContinuation, + }); + const sideEnv = runtime.prepareActivation({ + activationId: 1, + module: sideModule, + moduleBytes: sideBytes, + continuation: sideContinuation, + invokeFork: finishBorrowedFork, + }); + const mainInstance = new WebAssembly.Instance(mainModule, { + env: { memory, ...mainEnv }, + kernel: { kernel_fork: finishBorrowedFork }, + }); + const sideInstance = new WebAssembly.Instance(sideModule, { + env: { memory, ...sideEnv }, + }); + runtime.registerActivation(0, mainInstance, false); + runtime.registerActivation(1, sideInstance, false); + runtime.setProcessLaunchRoot(processLaunchRoot); + + const arena = new ForkModuleStateArena( + memory, + readLinkedFrameFormat(mainModule).ptrWidth, + () => { throw new Error("borrowed child must not allocate module state"); }, + () => { throw new Error("borrowed child must not release module state"); }, + "borrowed active-side browser child arena", + ); + arena.attachBorrowed(moduleStateRoot); + const prefixActivations: number[] = []; + runtime.coordinator.attachBorrowedChild(arena, ({ activationId }) => { + prefixActivations.push(activationId); + if (activationId !== 1) { + throw new Error(`inactive main activation requested prefix ${activationId}`); + } + return privatePrefix; + }); + + workerScope.postMessage({ + result: (sideInstance.exports.run as () => number)(), + prefixActivations, + mainActive: mainContinuation.hasActiveContinuation(), + sideActive: sideContinuation.hasActiveContinuation(), + arenaActive: arena.hasActiveArena(), + }); + } catch (error) { + workerScope.postMessage({ + error: error instanceof Error + ? `${error.message}\n${error.stack ?? ""}` + : String(error), + }); + } finally { + workerScope.close(); + } +}; diff --git a/apps/browser-demos/test/fixtures/borrowed-dylink-replay-browser-worker.ts b/apps/browser-demos/test/fixtures/borrowed-dylink-replay-browser-worker.ts new file mode 100644 index 0000000000..6e80fd34e3 --- /dev/null +++ b/apps/browser-demos/test/fixtures/borrowed-dylink-replay-browser-worker.ts @@ -0,0 +1,64 @@ +import { loadSharedLibrarySync } from "../../../../host/src/dylink"; + +interface BorrowedDylinkReplayRequest { + bytes: number[]; + memory: WebAssembly.Memory; + memoryBase: number; + tableBase: number; + tlsBase?: number; +} + +interface BorrowedDylinkReplayResult { + value?: number; + error?: string; +} + +const workerScope = globalThis as unknown as { + close(): void; + onmessage: ((event: MessageEvent) => void) | null; + postMessage(message: BorrowedDylinkReplayResult): void; +}; + +workerScope.onmessage = (event) => { + try { + const { bytes, memory, memoryBase, tableBase, tlsBase } = event.data; + const library = loadSharedLibrarySync( + "libborrowed-browser-side.so", + new Uint8Array(bytes), + { + memory, + table: new WebAssembly.Table({ initial: 1, element: "anyfunc" }), + stackPointer: new WebAssembly.Global( + { value: "i32", mutable: true }, + 65_536, + ), + allocateMemory: () => { + throw new Error("borrowed browser side child must not allocate"); + }, + deallocateMemory: () => { + throw new Error("borrowed browser side child must not release"); + }, + globalSymbols: new Map(), + got: new Map(), + loadedLibraries: new Map(), + }, + { + memoryBase, + tableBase, + tlsBase, + memoryOwnership: "borrowed", + }, + ); + workerScope.postMessage({ + value: (library.exports.get_counter as () => number)(), + }); + } catch (error) { + workerScope.postMessage({ + error: error instanceof Error + ? `${error.message}\n${error.stack ?? ""}` + : String(error), + }); + } finally { + workerScope.close(); + } +}; diff --git a/apps/browser-demos/test/fixtures/borrowed-fork-replay-browser-worker.ts b/apps/browser-demos/test/fixtures/borrowed-fork-replay-browser-worker.ts new file mode 100644 index 0000000000..860ed63ab4 --- /dev/null +++ b/apps/browser-demos/test/fixtures/borrowed-fork-replay-browser-worker.ts @@ -0,0 +1,120 @@ +import { + LinkedForkContinuation, + type LinkedFrameFormatDescriptor, +} from "../../../../host/src/fork-continuation"; +import { ForkModuleStateArena } from "../../../../host/src/fork-module-state"; +import { SingleActivationForkRuntime } from "../../../../host/test/fork-instrument-runtime-harness"; + +interface BorrowedReplayRequest { + module: WebAssembly.Module; + moduleBytes: Uint8Array; + memory: WebAssembly.Memory; + linkedFormat: LinkedFrameFormatDescriptor; + moduleBuffer: number; + moduleStateRoot: number; + privateModuleBuffer: number; +} + +interface BorrowedReplayResult { + result?: number; + active?: boolean; + arenaActive?: boolean; + error?: string; +} + +const workerScope = globalThis as unknown as { + close(): void; + onmessage: ((event: MessageEvent) => void) | null; + postMessage(message: BorrowedReplayResult): void; +}; + +workerScope.onmessage = (event) => { + try { + const { + module, + moduleBytes, + memory, + linkedFormat, + moduleBuffer, + moduleStateRoot, + privateModuleBuffer, + } = event.data; + const continuation = new LinkedForkContinuation( + memory, + linkedFormat, + () => { + throw new Error("borrowed browser child must not allocate continuation state"); + }, + () => { + throw new Error("borrowed browser child must not release continuation state"); + }, + "borrow-child-browser-e2e", + ); + const newArena = () => new ForkModuleStateArena( + memory, + linkedFormat.ptrWidth, + () => { + throw new Error("borrowed browser child must not allocate module state"); + }, + () => { + throw new Error("borrowed browser child must not release module state"); + }, + "borrow-child-browser module state", + ); + const runtime = new SingleActivationForkRuntime({ + module, + moduleBytes, + memory, + continuation, + newArena, + label: "borrow-child-browser-e2e", + }); + let instance: WebAssembly.Instance; + instance = new WebAssembly.Instance(module, { + env: { + memory, + ...runtime.envImports, + }, + kernel: { + kernel_fork: () => { + if (runtime.coordinator.phaseName() !== "child-replay") { + throw new Error( + `borrowed browser child reached fork while ` + + runtime.coordinator.phaseName(), + ); + } + runtime.coordinator.finishReplay(); + return 0; + }, + }, + }); + runtime.register(instance, { bootstrap: false }); + runtime.setCopiedProcessLaunchRoot(moduleBuffer); + const arena = newArena(); + arena.attachBorrowed( + linkedFormat.ptrWidth === 8 ? BigInt(moduleStateRoot) : moduleStateRoot, + ); + runtime.coordinator.attachBorrowedChild(arena, ({ activationId }) => { + if (activationId !== 0) { + throw new Error(`unexpected borrowed browser activation ${activationId}`); + } + return linkedFormat.ptrWidth === 8 + ? BigInt(privateModuleBuffer) + : privateModuleBuffer; + }); + + workerScope.postMessage({ + result: (instance.exports.run as () => number)(), + active: continuation.hasActiveContinuation(), + arenaActive: arena.hasActiveArena(), + }); + } catch (error) { + workerScope.postMessage({ + error: error instanceof Error + ? `${error.message}\n${error.stack ?? ""}` + : String(error), + }); + } finally { + workerScope.close(); + } +}; diff --git a/apps/browser-demos/test/fixtures/borrowed-process-runtime.ts b/apps/browser-demos/test/fixtures/borrowed-process-runtime.ts new file mode 100644 index 0000000000..355d36d037 --- /dev/null +++ b/apps/browser-demos/test/fixtures/borrowed-process-runtime.ts @@ -0,0 +1,174 @@ +import { + buildForkActivationStateImports, + ForkActivationRegistry, + forkActivationRegistrationFromInstance, +} from "../../../../host/src/fork-activation-registry"; +import type { LinkedForkContinuation } from "../../../../host/src/fork-continuation"; +import { + buildForkExceptionImports, + ForkExceptionBroker, + forkExceptionProviderFromInstance, + type ForkExceptionProvider, +} from "../../../../host/src/fork-exception-provider"; +import { computeForkModuleTemplateIdSync } from "../../../../host/src/fork-module-state"; +import { ForkProcessContinuationCoordinator } from "../../../../host/src/fork-process-continuation"; +import { forkResumeTargetsFromInstance } from "../../../../host/src/fork-resume-catalog"; +import { + createForkUnwindTag, + FORK_UNWIND_TAG_IMPORT_NAME, + isForkUnwindException, +} from "../../../../host/src/fork-unwind-transport"; + +interface PreparedActivation { + readonly module: WebAssembly.Module; + readonly moduleBytes: ArrayBufferView; + readonly continuation: LinkedForkContinuation; + exceptionProvider: ForkExceptionProvider | null; + registered: boolean; +} + +/** Browser-test owner for a real ABI 43 multi-activation transaction. */ +export class BorrowedProcessTestRuntime { + readonly registry: ForkActivationRegistry; + readonly coordinator: ForkProcessContinuationCoordinator; + + private readonly unwindTag = createForkUnwindTag(); + private readonly exceptionBroker: ForkExceptionBroker; + private readonly prepared = new Map(); + private processLaunchRoot = 0; + + constructor( + private readonly memory: WebAssembly.Memory, + private readonly label: string, + ) { + this.registry = new ForkActivationRegistry( + memory, + { + capture: () => { + throw new Error(`${label}: fixture unexpectedly captured externref`); + }, + materialize: () => { + throw new Error(`${label}: fixture unexpectedly replayed externref`); + }, + }, + `${label}: activation registry`, + ); + this.coordinator = new ForkProcessContinuationCoordinator( + memory, + this.registry, + `${label}: process continuation`, + ); + this.exceptionBroker = new ForkExceptionBroker( + this.registry, + `${label}: exception broker`, + ); + } + + prepareActivation(options: { + readonly activationId: number; + readonly module: WebAssembly.Module; + readonly moduleBytes: ArrayBufferView; + readonly continuation: LinkedForkContinuation; + readonly invokeFork?: () => number; + }): Record { + const { + activationId, + module, + moduleBytes, + continuation, + invokeFork, + } = options; + if (this.prepared.has(activationId)) { + throw new Error(`${this.label}: activation ${activationId} was prepared twice`); + } + this.coordinator.prepareActivation({ + activationId, + continuation, + ...(activationId === 0 + ? { + publishProcessLaunchRoot: (address: number) => { + this.processLaunchRoot = address; + }, + readProcessLaunchRoot: () => this.processLaunchRoot, + } + : {}), + }); + const prepared: PreparedActivation = { + module, + moduleBytes, + continuation, + exceptionProvider: null, + registered: false, + }; + this.prepared.set(activationId, prepared); + return { + ...(invokeFork ? { fork: invokeFork } : {}), + [FORK_UNWIND_TAG_IMPORT_NAME]: + this.unwindTag as unknown as WebAssembly.ImportValue, + ...this.coordinator.continuationImports(activationId, (errno) => { + this.coordinator.beginCaptureAbort(errno); + }), + ...buildForkActivationStateImports(activationId, this.registry), + ...buildForkExceptionImports({ + activationId, + ptrWidth: continuation.format.ptrWidth, + registry: this.registry, + broker: this.exceptionBroker, + provider: () => { + if (!prepared.exceptionProvider) { + throw new Error( + `${this.label}: activation ${activationId} has no exception provider`, + ); + } + return prepared.exceptionProvider; + }, + }), + }; + } + + registerActivation( + activationId: number, + instance: WebAssembly.Instance, + bootstrap: boolean, + ): void { + const prepared = this.prepared.get(activationId); + if (!prepared || prepared.registered) { + throw new Error( + `${this.label}: activation ${activationId} cannot be registered`, + ); + } + prepared.exceptionProvider = forkExceptionProviderFromInstance( + activationId, + instance, + ); + this.coordinator.registerActivation( + forkActivationRegistrationFromInstance({ + activationId, + module: prepared.module, + instance, + templateId: computeForkModuleTemplateIdSync(prepared.moduleBytes), + exceptionProvider: prepared.exceptionProvider, + }), + forkResumeTargetsFromInstance(prepared.module, instance), + ); + prepared.registered = true; + if (bootstrap) this.registry.bootstrapActivation(activationId); + } + + setProcessLaunchRoot(address: number): void { + if (!Number.isSafeInteger(address) || address <= 0) { + throw new RangeError(`${this.label}: invalid process launch root`); + } + this.processLaunchRoot = address; + } + + expectCaptureTransport(invoke: () => unknown): void { + try { + invoke(); + } catch (error) { + if (isForkUnwindException(error, this.unwindTag)) return; + throw error; + } + throw new Error(`${this.label}: capture returned without unwind transport`); + } +} diff --git a/docs/fork-instrumentation.md b/docs/fork-instrumentation.md index 1d92d03e76..6fdddc3bb4 100644 --- a/docs/fork-instrumentation.md +++ b/docs/fork-instrumentation.md @@ -419,6 +419,17 @@ pointer word, saved scalar globals, and a 16-byte abort selector. fixed-prefix size is `frames_start_offset + 16`. Frame nodes and tagged-catch activation state are not stored in that prefix. +The prefix is mutable during rewind. Each generated function preamble stores +the payload returned by `__wpk_fork_frame_next` in its active-frame word at +offset zero. A host controller that borrows another instance's frame chain +must therefore copy all `fixed_prefix_size` bytes to separately reserved +scratch, pass the scratch address to `wpk_fork_rewind_begin`, and keep frame +callbacks pointed at the borrowed nodes. Making only the host replay cursor +read-only is insufficient: passing the owner's prefix would overwrite the +owner's active-frame word. Borrowed replay must leave node states and mappings +untouched so the owner can later replay and release them. This is an internal +host invariant; it does not make ABI 42 `vfork()` functional. + This does not introduce a new linked-frame encoding. `fixed_prefix_size` has always been a module-specific value in the version-1 descriptor, and each node already declares its function-specific payload size. Existing artifacts retain diff --git a/host/src/dylink.ts b/host/src/dylink.ts index c156a55e1b..5e7f723025 100644 --- a/host/src/dylink.ts +++ b/host/src/dylink.ts @@ -147,6 +147,110 @@ function readString(data: Uint8Array, offset: { value: number }): string { return new TextDecoder().decode(bytes); } +/** + * Reject automatic linear-memory writes before instantiating over a borrow. + * + * wasm-ld side modules use passive data plus a guarded start function. An + * arbitrary active data segment would instead write imported Memory inside + * WebAssembly.Instance(), before JavaScript can recover from the mutation. + * The source module has already passed engine validation, so this parser only + * distinguishes the standardized data-segment encodings. + */ +function requirePassiveDataSegmentsForBorrowedReplay( + wasmBytes: Uint8Array, + name: string, +): void { + const offset = { value: 8 }; + while (offset.value < wasmBytes.length) { + const sectionId = wasmBytes[offset.value++]!; + const sectionSize = readVarUint(wasmBytes, offset); + const sectionEnd = offset.value + sectionSize; + if (sectionId !== 11) { + offset.value = sectionEnd; + continue; + } + const count = readVarUint(wasmBytes, offset); + for (let index = 0; index < count; index++) { + const flags = readVarUint(wasmBytes, offset); + if (flags !== 1) { + throw new Error( + `${name}: borrowed replay requires passive data segments; ` + + `segment ${index} has flags ${flags}`, + ); + } + const length = readVarUint(wasmBytes, offset); + offset.value += length; + } + if (offset.value !== sectionEnd) { + throw new Error(`${name}: malformed data section during borrowed replay`); + } + return; + } +} + +/** + * Remove only wasm-ld's recognized memory-initialization start section. + * + * WHY: start executes during WebAssembly.Instance() and can write the + * suspended parent's live Memory. Complete fork replay reconstructs fresh + * instance globals from ABI 43 state and already owns the parent's initialized + * bytes, so wasm-ld's exported `__wasm_init_memory` start is unnecessary. An + * arbitrary start is rejected rather than silently changing its semantics. + */ +function withoutBorrowedReplayStart( + wasmBytes: Uint8Array, + name: string, +): Uint8Array { + const retained: Uint8Array[] = [wasmBytes.subarray(0, 8)]; + const offset = { value: 8 }; + let retainedLength = 8; + let startFunctionIndex: number | undefined; + let wasmLdInitFunctionIndex: number | undefined; + while (offset.value < wasmBytes.length) { + const sectionStart = offset.value; + const sectionId = wasmBytes[offset.value++]!; + const sectionSize = readVarUint(wasmBytes, offset); + const sectionEnd = offset.value + sectionSize; + if (sectionId === 8) { + startFunctionIndex = readVarUint(wasmBytes, offset); + if (offset.value !== sectionEnd) { + throw new Error(`${name}: malformed start section during borrowed replay`); + } + } else { + const section = wasmBytes.subarray(sectionStart, sectionEnd); + retained.push(section); + retainedLength += section.length; + if (sectionId === 7) { + const exportOffset = { value: offset.value }; + const exportCount = readVarUint(wasmBytes, exportOffset); + for (let index = 0; index < exportCount; index++) { + const exportName = readString(wasmBytes, exportOffset); + const kind = wasmBytes[exportOffset.value++]!; + const exportIndex = readVarUint(wasmBytes, exportOffset); + if (exportName === "__wasm_init_memory" && kind === 0) { + wasmLdInitFunctionIndex = exportIndex; + } + } + } + } + offset.value = sectionEnd; + } + if (startFunctionIndex === undefined) return wasmBytes; + if (startFunctionIndex !== wasmLdInitFunctionIndex) { + throw new Error( + `${name}: borrowed replay cannot suppress unrecognized start function ` + + `${startFunctionIndex}; expected exported __wasm_init_memory`, + ); + } + const result = new Uint8Array(retainedLength); + let writeOffset = 0; + for (const section of retained) { + result.set(section, writeOffset); + writeOffset += section.length; + } + return result; +} + /** * Parse the dylink.0 custom section from a Wasm binary. * Returns null if the section is not found. @@ -637,6 +741,15 @@ export interface DylinkReplayOptions { providerDependencies?: readonly string[]; /** Exact live mapping ownership copied from the parent process. */ allocations?: readonly DylinkForkMemoryAllocation[]; + /** + * A borrowed vfork replay shares the suspended parent's linear Memory. + * Loader-controlled instantiation must therefore be provably read-only. + */ + memoryOwnership?: "copied" | "borrowed"; +} + +export interface DylinkForkReconcileOptions { + readonly memoryOwnership?: "copied" | "borrowed"; } /** @@ -1032,7 +1145,29 @@ function* instantiateSharedLibrarySteps( validateLongjmpConfiguration(options); const ptrWidth = options.ptrWidth ?? 4; const pointerGlobalType = ptrWidth === 8 ? "i64" : "i32"; - const module = new WebAssembly.Module(wasmBytes as unknown as BufferSource); + // Compile the exact archive bytes first. Besides producing clearer engine + // diagnostics, this makes the narrow section parser below operate only on a + // structurally valid module. + const sourceModule = new WebAssembly.Module( + wasmBytes as unknown as BufferSource, + ); + const borrowsMemory = replay?.memoryOwnership === "borrowed"; + if (borrowsMemory) { + if (!(options.memory.buffer instanceof SharedArrayBuffer)) { + throw new Error(`${name}: borrowed replay requires Shared Memory`); + } + if (replay?.initializationStage !== undefined) { + throw new Error( + `${name}: borrowed replay cannot resume an in-flight dlopen initializer`, + ); + } + requirePassiveDataSegmentsForBorrowedReplay(wasmBytes, name); + } + const module = borrowsMemory + ? new WebAssembly.Module( + withoutBorrowedReplayStart(wasmBytes, name) as unknown as BufferSource, + ) + : sourceModule; const moduleImports = WebAssembly.Module.imports(module); const moduleExports = WebAssembly.Module.exports(module); const moduleExportKinds = new Map( @@ -2496,7 +2631,25 @@ export class DynamicLinker { * verified rather than re-instantiated, so a generation check makes the * steady-state path O(1). */ - reconcileForkModules(state: DylinkForkState): void { + reconcileForkModules( + state: DylinkForkState, + options: DylinkForkReconcileOptions = {}, + ): void { + const memoryOwnership = options.memoryOwnership ?? "copied"; + if ( + memoryOwnership === "borrowed" + && ( + (state.transactions?.length ?? 0) !== 0 + || state.libraries.some((library) => library.initialization !== undefined) + ) + ) { + // An issued bootstrap/relocation/constructor entry is guest code that + // may mutate arbitrary process memory. A later vfork design can resume + // it only with a stronger write-isolation contract. + throw new Error( + "borrowed dynamic-linker replay cannot restore an in-flight dlopen transaction", + ); + } const archivedNames = new Set(); let visibilityChanged = false; let dependencyStateChanged = false; @@ -2582,6 +2735,7 @@ export class DynamicLinker { committedGlobalRoot: archived.committedGlobalRoot, providerDependencies: archived.providerDependencies, allocations: archived.allocations, + memoryOwnership, }, archived.globalVisibility, false, diff --git a/host/src/fork-continuation.ts b/host/src/fork-continuation.ts index 4797379555..95f442003b 100644 --- a/host/src/fork-continuation.ts +++ b/host/src/fork-continuation.ts @@ -238,6 +238,8 @@ interface ValidatedReplayNode { }; } +type ReplayOwnership = "owned" | "borrowed"; + /** * Host-side owner and validator for one module instance's linked fork frames. * Allocations are ordinary anonymous process mappings, so kernel brk/mmap @@ -256,6 +258,7 @@ export class LinkedForkContinuation { private committedFrames = 0n; private committedBytes = 0n; private abortFailure: AbortFailure | null = null; + private replayOwnership: ReplayOwnership | null = null; constructor( private readonly memory: WebAssembly.Memory, @@ -277,6 +280,7 @@ export class LinkedForkContinuation { this.committedFrames = 0n; this.committedBytes = 0n; this.abortFailure = null; + this.replayOwnership = "owned"; let root: number; try { root = this.allocateChunk(capacity, 0, 0); @@ -295,6 +299,71 @@ export class LinkedForkContinuation { } attachForReplay(moduleBuffer: number | bigint): void { + this.attachForReplayWithOwnership(moduleBuffer, "owned"); + } + + /** + * Attach a read-only replay cursor to continuation storage owned elsewhere + * and copy its mutable module prefix into caller-owned scratch memory. + * + * WHY: a vfork child shares the parent's Memory and may reconstruct its + * Wasm stack from the saved frames, but consuming nodes or unmapping chunks + * would destroy the only continuation from which the parent can resume. + * Generated replay code also writes the active-frame pointer at offset zero + * of the module prefix, so passing the parent's prefix to rewind would still + * corrupt the parent even with a read-only JavaScript cursor. The returned + * address is the private prefix that must be passed to + * `wpk_fork_rewind_begin`; frame imports continue reading the borrowed + * linked nodes. The caller must retain both the Memory backing and the + * scratch reservation independently for the complete borrow. + */ + attachForBorrowedReplay( + moduleBuffer: number | bigint, + privateModuleBuffer: number | bigint, + ): number | bigint { + this.attachForReplayWithOwnership(moduleBuffer, "borrowed"); + try { + const source = this.fromGuestPtr(moduleBuffer); + const target = this.fromGuestPtr(privateModuleBuffer); + const targetEnd = checkedEnd(target, this.format.fixedPrefixSize); + if ( + target <= 0 + || target % this.format.alignment !== 0 + || targetEnd > this.memory.buffer.byteLength + ) { + throw new Error(`${this.label}: invalid borrowed replay prefix range`); + } + for (const chunk of this.chunks) { + if (target < chunk.addr + chunk.size && targetEnd > chunk.addr) { + throw new Error(`${this.label}: borrowed replay prefix overlaps continuation storage`); + } + } + + // Slice first so an accidental future relaxation of the overlap guard + // cannot turn this into a partially self-overwriting copy. + const prefix = new Uint8Array( + this.memory.buffer, + source, + this.format.fixedPrefixSize, + ).slice(); + new Uint8Array( + this.memory.buffer, + target, + this.format.fixedPrefixSize, + ).set(prefix); + return this.asGuestPtr(target); + } catch (error) { + // Attachment is transactional. The scratch reservation still belongs to + // the caller, but no failed validation may leave this controller active. + this.clearControllerState(); + throw error; + } + } + + private attachForReplayWithOwnership( + moduleBuffer: number | bigint, + ownership: ReplayOwnership, + ): void { if (this.root !== 0) { throw new Error(`${this.label}: linked continuation already active`); } @@ -360,11 +429,17 @@ export class LinkedForkContinuation { this.root = root; this.chunks = chunks; this.activeChunk = chunks[chunks.length - 1]!.addr; + this.replayOwnership = ownership; this.setReplayCursor(replayNode); } beginReplay(): void { - if (this.root === 0 || this.pending || this.abortFailure) { + if ( + this.root === 0 + || this.pending + || this.abortFailure + || this.replayOwnership !== "owned" + ) { throw new Error(`${this.label}: cannot begin replay from incomplete continuation`); } this.resetReplay(this.readPtr(this.root + 8 + 5 * this.format.ptrWidth)); @@ -372,7 +447,11 @@ export class LinkedForkContinuation { reserveFrame(payloadSize: number | bigint): number | bigint { const size = this.fromGuestPtr(payloadSize); - if (this.root === 0 || this.activeChunk === 0) { + if ( + this.root === 0 + || this.activeChunk === 0 + || this.replayOwnership !== "owned" + ) { throw new Error(`${this.label}: frame reservation outside unwind`); } if (this.pending) { @@ -430,6 +509,9 @@ export class LinkedForkContinuation { } commitFrame(payload: number | bigint): void { + if (this.replayOwnership !== "owned") { + throw new Error(`${this.label}: frame commit outside owned unwind`); + } if (this.abortFailure) { throw new Error(`${this.label}: frame commit after abort began`); } @@ -474,13 +556,15 @@ export class LinkedForkContinuation { this.replayNode = previous; this.replayChunkIndex = nextReplay.chunkIndex; this.replayExpectedEnd = nextReplay.expectedEnd; - this.view().setUint16(node + 6, NODE_CONSUMED, true); + if (this.replayOwnership === "owned") { + this.view().setUint16(node + 6, NODE_CONSUMED, true); + } return this.asGuestPtr(payload); } private validateNextFrame(expected: number): ValidatedReplayNode { const node = this.replayNode; - if (this.root === 0 || node === 0) { + if (this.root === 0 || node === 0 || this.replayOwnership === null) { throw new Error(`${this.label}: linked continuation replay exhausted early`); } const chunk = this.replayChunk(node); @@ -520,12 +604,15 @@ export class LinkedForkContinuation { if (this.pending) { throw new Error(`${this.label}: unwind ended with an uncommitted frame`); } - if (this.root === 0) { + if (this.root === 0 || this.replayOwnership !== "owned") { throw new Error(`${this.label}: unwind ended without a continuation`); } } finishReplayAndRelease(): void { + if (this.replayOwnership !== "owned") { + throw new Error(`${this.label}: borrowed replay cannot release continuation storage`); + } if (this.abortFailure) { throw new Error(`${this.label}: normal replay ended during abort recovery`); } @@ -535,6 +622,36 @@ export class LinkedForkContinuation { this.release(); } + /** Finish a complete borrowed replay without writing or releasing storage. */ + finishBorrowedReplay(): void { + if (this.replayOwnership !== "borrowed") { + throw new Error(`${this.label}: no borrowed continuation replay is active`); + } + if (this.abortFailure) { + throw new Error(`${this.label}: borrowed replay cannot own abort recovery`); + } + if (this.replayNode !== 0) { + throw new Error(`${this.label}: rewind ended before all linked frames were read`); + } + this.clearControllerState(); + } + + /** + * Drop a failed borrowed cursor without touching its owner's bytes. + * + * WHY: fresh-child activation or module reconstruction can trap after one + * continuation has attached but before replay starts. The parent still owns + * every linked node, so rollback may clear only this Worker's controller + * state; consuming or releasing the chain would make the suspended parent + * impossible to resume. + */ + cancelBorrowedReplay(): void { + if (this.replayOwnership !== "borrowed") { + throw new Error(`${this.label}: no borrowed continuation replay is active`); + } + this.clearControllerState(); + } + beginAbortReplay( errno: number, requestedFrame?: number, @@ -543,7 +660,11 @@ export class LinkedForkContinuation { if (!Number.isInteger(errno) || errno <= 0) { throw new Error(`${this.label}: invalid abort errno ${errno}`); } - if (this.root === 0 || this.pending) { + if ( + this.root === 0 + || this.pending + || this.replayOwnership !== "owned" + ) { throw new Error(`${this.label}: cannot abort-replay an incomplete continuation`); } if (this.abortFailure && this.abortFailure.errno !== errno) { @@ -561,6 +682,9 @@ export class LinkedForkContinuation { } finishAbortReplayAndRelease(): void { + if (this.replayOwnership !== "owned") { + throw new Error(`${this.label}: borrowed replay cannot release abort storage`); + } if (!this.abortFailure) { throw new Error(`${this.label}: abort replay ended without an allocation failure`); } @@ -574,13 +698,16 @@ export class LinkedForkContinuation { if (this.pending) { throw new Error(`${this.label}: cannot cancel an unwind with a pending frame`); } - if (this.root === 0) { + if (this.root === 0 || this.replayOwnership !== "owned") { throw new Error(`${this.label}: cannot cancel an inactive unwind`); } this.release(); } abortAndRelease(requestedNextFrame?: number, cause?: unknown): never { + if (this.replayOwnership !== "owned") { + throw new Error(`${this.label}: borrowed replay cannot abort owned storage`); + } const details = `committed_frames=${this.committedFrames} committed_bytes=${this.committedBytes}` + (requestedNextFrame === undefined ? "" : ` requested_next_frame=${requestedNextFrame}`) + (cause === undefined @@ -837,14 +964,10 @@ export class LinkedForkContinuation { } private release(): void { - const chunks = this.chunks.splice(0).reverse(); - this.pending = null; - this.root = 0; - this.activeChunk = 0; - this.replayNode = 0; - this.replayChunkIndex = -1; - this.replayExpectedEnd = 0; - this.abortFailure = null; + if (this.replayOwnership === "borrowed") { + throw new Error(`${this.label}: borrowed replay cannot release continuation storage`); + } + const chunks = this.clearControllerState().reverse(); let firstError: unknown; for (const chunk of chunks) { try { @@ -856,6 +979,19 @@ export class LinkedForkContinuation { if (firstError !== undefined) throw firstError; } + private clearControllerState(): ContinuationChunk[] { + const chunks = this.chunks.splice(0); + this.pending = null; + this.root = 0; + this.activeChunk = 0; + this.replayNode = 0; + this.replayChunkIndex = -1; + this.replayExpectedEnd = 0; + this.abortFailure = null; + this.replayOwnership = null; + return chunks; + } + private releaseAfterFailure(error: unknown): never { try { this.release(); diff --git a/host/src/fork-module-state.ts b/host/src/fork-module-state.ts index 029c02ad81..5128580fc8 100644 --- a/host/src/fork-module-state.ts +++ b/host/src/fork-module-state.ts @@ -2789,6 +2789,7 @@ export class ForkModuleStateArena { private tail = 0; private chunks: ArenaChunk[] = []; private sealed = false; + private ownership: "owned" | "borrowed" | null = null; private pending: PendingRecord | null = null; private readonly payloadIndex = new Map(); @@ -2811,10 +2812,29 @@ export class ForkModuleStateArena { const root = this.allocateChunk(WASM_PAGE_SIZE, 0, true); this.root = root; this.tail = root; + this.ownership = "owned"; return root; } attach(root: number | bigint): void { + this.attachWithOwnership(root, "owned"); + } + + /** + * Validate a sealed arena while retaining ownership in another process. + * + * A vfork child reads the parent's module recipes from shared Memory. It + * must detach its JavaScript indexes after replay, never munmap the parent's + * arena mappings. + */ + attachBorrowed(root: number | bigint): void { + this.attachWithOwnership(root, "borrowed"); + } + + private attachWithOwnership( + root: number | bigint, + ownership: "owned" | "borrowed", + ): void { if (this.root !== 0) { throw new Error(`${this.label}: module-state arena is already active`); } @@ -2842,6 +2862,7 @@ export class ForkModuleStateArena { this.payloadIndex.set(key, addresses); } this.sealed = true; + this.ownership = ownership; } /** @@ -3343,16 +3364,19 @@ export class ForkModuleStateArena { return this.sealed; } + ownershipMode(): "owned" | "borrowed" | null { + return this.ownership; + } + release(): void { if (this.root === 0) { throw new Error(`${this.label}: no active module-state arena to release`); } + if (this.ownership !== "owned") { + throw new Error(`${this.label}: borrowed module-state arena cannot be released`); + } const chunks = this.chunks.splice(0).reverse(); - this.pending = null; - this.payloadIndex.clear(); - this.root = 0; - this.tail = 0; - this.sealed = false; + this.clearControllerState(); let firstError: unknown; for (const chunk of chunks) { try { @@ -3364,6 +3388,24 @@ export class ForkModuleStateArena { if (firstError !== undefined) throw firstError; } + /** Drop borrowed indexes without deallocating their parent's mappings. */ + detachBorrowed(): void { + if (this.root === 0 || this.ownership !== "borrowed") { + throw new Error(`${this.label}: no borrowed module-state arena to detach`); + } + this.chunks = []; + this.clearControllerState(); + } + + private clearControllerState(): void { + this.pending = null; + this.payloadIndex.clear(); + this.root = 0; + this.tail = 0; + this.sealed = false; + this.ownership = null; + } + private requireWritable(): void { if (this.root === 0) { throw new Error(`${this.label}: module-state arena has not begun`); diff --git a/host/src/fork-process-continuation.ts b/host/src/fork-process-continuation.ts index 45f9a888d4..af85dd817b 100644 --- a/host/src/fork-process-continuation.ts +++ b/host/src/fork-process-continuation.ts @@ -50,6 +50,18 @@ type ProcessContinuationPhase = | "child-replay" | "abort-replay"; +type ProcessReplayOwnership = "owned" | "borrowed"; + +export interface ForkBorrowedReplayPrefixRequest { + readonly activationId: number; + readonly byteLength: number; + readonly alignment: number; +} + +export type ForkBorrowedReplayPrefixAllocator = ( + request: ForkBorrowedReplayPrefixRequest, +) => WasmGuestPointer; + export interface ForkProcessActivationBinding { readonly activationId: number; readonly continuation: LinkedForkContinuation; @@ -67,7 +79,10 @@ export interface ForkProcessActivationBinding { interface CompleteForkProcessActivation extends ForkProcessActivationBinding { readonly registration: ForkActivationRegistration; + /** Parent-owned linked continuation prefix. */ root: number; + /** Prefix passed to this Worker's replay entry point. */ + replayRoot: number; } function assertActivationId(value: number): void { @@ -110,6 +125,7 @@ export class ForkProcessContinuationCoordinator { private readonly events = new ForkReplayEventJournal(); private phase: ProcessContinuationPhase = "idle"; private arena: ForkModuleStateArena | null = null; + private replayOwnership: ProcessReplayOwnership | null = null; constructor( private readonly memory: WebAssembly.Memory, @@ -180,6 +196,7 @@ export class ForkProcessContinuationCoordinator { ...binding, registration, root: 0, + replayRoot: 0, }); } @@ -284,6 +301,11 @@ export class ForkProcessContinuationCoordinator { */ beginCapture(arena: ForkModuleStateArena): void { this.requirePhase("idle", "begin process continuation capture"); + if (arena.ownershipMode() !== "owned" || arena.isSealed()) { + throw new Error( + `${this.label}: capture requires a writable owned module-state arena`, + ); + } if (this.prepared.size !== 0) { throw new Error( `${this.label}: cannot fork with ${this.prepared.size} incomplete activation(s)`, @@ -291,6 +313,7 @@ export class ForkProcessContinuationCoordinator { } this.events.beginCapture(); this.arena = arena; + this.replayOwnership = "owned"; try { this.publishProcessLaunchRoot(0); this.registry.beginCapture(arena); @@ -298,6 +321,7 @@ export class ForkProcessContinuationCoordinator { for (const activation of this.orderedActivations()) { const root = Number(activation.continuation.beginUnwind()); activation.root = root; + activation.replayRoot = root; // WHY: the main Wasm activation need not be on a side-module fork // stack. Every activation prefix therefore carries the process arena // root, allowing the deterministic launch root chosen after unwind @@ -339,6 +363,7 @@ export class ForkProcessContinuationCoordinator { } else { activation.continuation.cancelUnwindAndRelease(); activation.root = 0; + activation.replayRoot = 0; } } if (active.size === 0) { @@ -392,7 +417,13 @@ export class ForkProcessContinuationCoordinator { `${this.label}: child has ${this.prepared.size} incomplete activation(s)`, ); } + if (arena.ownershipMode() !== "owned") { + throw new Error( + `${this.label}: copied child replay requires an owned module-state arena`, + ); + } this.arena = arena; + this.replayOwnership = "owned"; try { this.registry.attachChild(arena, decodedReferences); // Imported immutable references may have forced a strict prefix of the @@ -427,6 +458,7 @@ export class ForkProcessContinuationCoordinator { const root = roots.get(activation.activationId) ?? 0; if (root === 0) { activation.root = 0; + activation.replayRoot = 0; continue; } if (!Number.isSafeInteger(root) || root <= 0) { @@ -436,6 +468,7 @@ export class ForkProcessContinuationCoordinator { ); } activation.root = root; + activation.replayRoot = root; activation.continuation.attachForReplay( activation.continuation.format.ptrWidth === 8 ? BigInt(root) : root, ); @@ -447,6 +480,97 @@ export class ForkProcessContinuationCoordinator { } } + /** + * Attach a fresh vfork child to parent-owned continuation and module state. + * + * Every active activation gets a child-owned mutable prefix. Linked frames, + * replay events, reference recipes, and module-state records remain borrowed + * from the suspended parent and are detached rather than consumed or freed. + */ + attachBorrowedChild( + arena: ForkModuleStateArena, + reservePrefix: ForkBorrowedReplayPrefixAllocator, + adoptPreinstantiatedReferences?: () => void, + decodedReferences?: DecodedSegmentedForkReferenceTransaction, + ): void { + this.requirePhase("idle", "attach borrowed child process replay"); + if (this.prepared.size !== 0) { + throw new Error( + `${this.label}: borrowed child has ${this.prepared.size} incomplete activation(s)`, + ); + } + if (arena.ownershipMode() !== "borrowed") { + throw new Error( + `${this.label}: borrowed child replay requires a borrowed module-state arena`, + ); + } + this.arena = arena; + this.replayOwnership = "borrowed"; + try { + this.registry.attachChild(arena, decodedReferences); + adoptPreinstantiatedReferences?.(); + const records = arena.recordViews(); + this.events.attachChild(replayEventsForChild(records)); + const continuations = activationContinuationsForChild( + records, + arena.ptrWidth, + ); + const parentLaunchRoot = this.readProcessLaunchRoot(); + const expectedLaunchRoot = this.selectProcessLaunchRoot(continuations); + if (parentLaunchRoot !== expectedLaunchRoot) { + throw new Error( + `${this.label}: borrowed process launch root ${parentLaunchRoot} ` + + `does not match manifest root ${expectedLaunchRoot}`, + ); + } + this.phase = "child-replay"; + this.registry.restoreModuleState(); + const roots = new Map( + continuations.map(({ activationId, root }) => [ + activationId, + Number(root), + ]), + ); + for (const activation of this.orderedActivations()) { + const root = roots.get(activation.activationId) ?? 0; + if (root === 0) { + activation.root = 0; + activation.replayRoot = 0; + continue; + } + if (!Number.isSafeInteger(root) || root <= 0) { + throw new Error( + `${this.label}: active borrowed activation ${activation.activationId} ` + + "has no parent continuation root", + ); + } + const privatePrefix = reservePrefix({ + activationId: activation.activationId, + byteLength: activation.continuation.format.fixedPrefixSize, + alignment: activation.continuation.format.alignment, + }); + const replayRoot = Number( + activation.continuation.attachForBorrowedReplay( + activation.continuation.format.ptrWidth === 8 ? BigInt(root) : root, + privatePrefix, + ), + ); + if (!Number.isSafeInteger(replayRoot) || replayRoot <= 0) { + throw new Error( + `${this.label}: borrowed activation ${activation.activationId} ` + + "received an invalid private replay prefix", + ); + } + activation.root = root; + activation.replayRoot = replayRoot; + } + this.beginActivationReplay(WPK_FORK_REWINDING, false); + } catch (error) { + this.abort(); + throw error; + } + } + /** * Switch a sealed parent transaction to allocation-failure replay. * @@ -553,19 +677,24 @@ export class ForkProcessContinuationCoordinator { abort(): void { let failure: unknown; + const borrowed = this.replayOwnership === "borrowed"; for (const activation of this.orderedActivations()) { if (!activation.continuation.hasActiveContinuation()) continue; try { - activation.continuation.cancelUnwindAndRelease(); + if (borrowed) activation.continuation.cancelBorrowedReplay(); + else activation.continuation.cancelUnwindAndRelease(); } catch (error) { failure ??= error; } activation.root = 0; + activation.replayRoot = 0; } - try { - this.publishProcessLaunchRoot(0, false); - } catch (error) { - failure ??= error; + if (!borrowed) { + try { + this.publishProcessLaunchRoot(0, false); + } catch (error) { + failure ??= error; + } } try { this.events.abort(); @@ -582,6 +711,7 @@ export class ForkProcessContinuationCoordinator { } catch (error) { failure ??= error; } + this.replayOwnership = null; this.phase = "idle"; if (failure !== undefined) throw failure; } @@ -602,7 +732,7 @@ export class ForkProcessContinuationCoordinator { if (beginContinuation) activation.continuation.beginReplay(); invokeForkContinuationBegin( requireExportFunction(activation, "wpk_fork_rewind_begin"), - activation.root, + activation.replayRoot, activation.continuation.format.ptrWidth, `${this.label}: activation ${activation.activationId} replay`, ); @@ -612,6 +742,10 @@ export class ForkProcessContinuationCoordinator { private finishTransaction(abortReplay: boolean): void { let failure: unknown; + const borrowed = this.replayOwnership === "borrowed"; + if (borrowed && abortReplay) { + throw new Error(`${this.label}: borrowed child cannot own abort replay`); + } for (const activation of this.activeActivations()) { try { requireExportFunction( @@ -640,22 +774,34 @@ export class ForkProcessContinuationCoordinator { for (const activation of this.activeActivations()) { try { if (abortReplay) activation.continuation.finishAbortReplayAndRelease(); + else if (borrowed) activation.continuation.finishBorrowedReplay(); else activation.continuation.finishReplayAndRelease(); } catch (error) { failure ??= error; + if (borrowed && activation.continuation.hasActiveContinuation()) { + try { + activation.continuation.cancelBorrowedReplay(); + } catch (cleanupError) { + failure ??= cleanupError; + } + } } activation.root = 0; + activation.replayRoot = 0; } - try { - this.publishProcessLaunchRoot(0); - } catch (error) { - failure ??= error; + if (!borrowed) { + try { + this.publishProcessLaunchRoot(0); + } catch (error) { + failure ??= error; + } } try { this.releaseArena(); } catch (error) { failure ??= error; } + this.replayOwnership = null; this.phase = "idle"; if (failure !== undefined) throw failure; } @@ -677,6 +823,7 @@ export class ForkProcessContinuationCoordinator { failure ??= error; } activation.root = 0; + activation.replayRoot = 0; } try { this.publishProcessLaunchRoot(0); @@ -698,6 +845,7 @@ export class ForkProcessContinuationCoordinator { } catch (error) { failure ??= error; } + this.replayOwnership = null; this.phase = "idle"; if (failure !== undefined) throw failure; } @@ -776,7 +924,8 @@ export class ForkProcessContinuationCoordinator { // Clear ownership first so a failing deallocator cannot make stale KFMS // bytes look reusable by a later fork transaction. this.arena = null; - arena.release(); + if (arena.ownershipMode() === "borrowed") arena.detachBorrowed(); + else arena.release(); } private activeActivations(): CompleteForkProcessActivation[] { diff --git a/host/src/vfork-lifetime.ts b/host/src/vfork-lifetime.ts new file mode 100644 index 0000000000..7537e72606 --- /dev/null +++ b/host/src/vfork-lifetime.ts @@ -0,0 +1,338 @@ +const EAGAIN = 11; + +export interface VforkProcessGeneration { + readonly memory: WebAssembly.Memory; +} + +export type VforkExactCompletionReason = + | "exec" + | "exit" + | "signal" + | "trap"; + +export type VforkLifetimePhase = + | "starting" + | "borrowing" + | "settled"; + +export type VforkLifetimeDisposition = + | { + readonly kind: "resume-parent"; + readonly parentGeneration: TGeneration; + readonly childPid: number; + readonly reason: VforkExactCompletionReason; + } + | { + readonly kind: "return-error"; + readonly parentGeneration: TGeneration; + readonly childPid: number; + readonly errno: number; + } + | { + readonly kind: "contain-address-space"; + readonly parentGeneration: TGeneration; + readonly childPid: number; + readonly cause: unknown; + }; + +export interface VforkLifetime< + TGeneration extends VforkProcessGeneration, +> { + readonly parentPid: number; + readonly childPid: number; + readonly parentGeneration: TGeneration; + readonly childGeneration: TGeneration; + readonly memory: WebAssembly.Memory; + readonly phase: VforkLifetimePhase; + readonly failedExecAttempts: number; + readonly completion: Promise>; +} + +interface MutableVforkLifetime< + TGeneration extends VforkProcessGeneration, +> { + readonly handle: VforkLifetime; + readonly parentPid: number; + readonly childPid: number; + readonly parentGeneration: TGeneration; + readonly childGeneration: TGeneration; + readonly memory: WebAssembly.Memory; + readonly resolve: ( + disposition: VforkLifetimeDisposition, + ) => void; + phase: VforkLifetimePhase; + failedExecAttempts: number; +} + +export class VforkAddressSpaceBusyError extends Error { + readonly errno = EAGAIN; + + constructor() { + super("address space already has an active vfork lifetime"); + this.name = "VforkAddressSpaceBusyError"; + } +} + +/** + * Coordinates the host-only lifetime of a child borrowing parent Memory. + * + * This class deliberately does not infer exact retirement from a Worker exit + * event or timeout. Node and browser hosts must first use their existing + * exec-retirement, Worker-quiescence, and exact-generation detach fences, then + * call `completeAfterExactTeardown`. A caller that cannot establish that proof + * must choose `requireAddressSpaceContainment`; that disposition never grants + * permission to resume the suspended parent. + * + * The asynchronous `onFork` callback remains the actual caller-thread parking + * mechanism. Its consumer must also compare `parentGeneration` with the host's + * current PID registration before completing the channel, because a sibling + * pthread may have replaced or exited the parent generation while it waited. + */ +export class VforkLifetimeCoordinator< + TGeneration extends VforkProcessGeneration, +> { + private readonly byMemory = new Map< + WebAssembly.Memory, + MutableVforkLifetime + >(); + private readonly byChild = new Map< + TGeneration, + MutableVforkLifetime + >(); + private readonly completedChildren = new WeakSet(); + + get activeCount(): number { + return this.byMemory.size; + } + + hasActiveAddressSpace(memory: WebAssembly.Memory): boolean { + return this.byMemory.has(memory); + } + + isActiveBorrower(generation: TGeneration): boolean { + const lifetime = this.byChild.get(generation); + return lifetime?.phase === "borrowing"; + } + + begin( + parentPid: number, + childPid: number, + parentGeneration: TGeneration, + childGeneration: TGeneration, + ): VforkLifetime { + this.validatePid(parentPid, "parent"); + this.validatePid(childPid, "child"); + if (parentPid === childPid) { + throw new Error("vfork parent and child PIDs must differ"); + } + if (parentGeneration === childGeneration) { + throw new Error("vfork parent and child generations must differ"); + } + const memory = parentGeneration.memory; + if (childGeneration.memory !== memory) { + throw new Error("vfork child does not alias the parent Memory"); + } + if (!(memory.buffer instanceof SharedArrayBuffer)) { + throw new Error("vfork requires Shared WebAssembly.Memory"); + } + if (this.byMemory.has(memory)) { + throw new VforkAddressSpaceBusyError(); + } + if ( + this.byChild.has(childGeneration) + || this.completedChildren.has(childGeneration) + ) { + throw new Error("vfork child generation was already used"); + } + + let resolve!: ( + disposition: VforkLifetimeDisposition, + ) => void; + const completion = new Promise>( + (done) => { + resolve = done; + }, + ); + const lifetime = {} as MutableVforkLifetime; + const handle: VforkLifetime = Object.freeze({ + parentPid, + childPid, + parentGeneration, + childGeneration, + memory, + get phase() { + return lifetime.phase; + }, + get failedExecAttempts() { + return lifetime.failedExecAttempts; + }, + completion, + }); + Object.assign(lifetime, { + handle, + parentPid, + childPid, + parentGeneration, + childGeneration, + memory, + resolve, + phase: "starting" as const, + failedExecAttempts: 0, + }); + this.byMemory.set(memory, lifetime); + this.byChild.set(childGeneration, lifetime); + return handle; + } + + /** + * Cross the point after which the child Worker may access shared memory. + * + * WHY: call this immediately before starting the Worker, not after a ready + * message. If start throws after partially launching the realm, ordinary + * rollback cannot prove quiescence and must contain the whole address space. + */ + markChildMayAccessMemory(childGeneration: TGeneration): boolean { + const lifetime = this.requireActive(childGeneration); + if (lifetime.phase === "borrowing") return false; + if (lifetime.phase !== "starting") { + throw new Error("settled vfork lifetime cannot start a child"); + } + lifetime.phase = "borrowing"; + return true; + } + + /** Record a truthful child exec failure without releasing the parent. */ + noteFailedExec(childGeneration: TGeneration, errno: number): number { + this.validateErrno(errno); + const lifetime = this.requireActive(childGeneration); + if (lifetime.phase !== "borrowing") { + throw new Error("vfork exec failure reported before child launch"); + } + lifetime.failedExecAttempts += 1; + return lifetime.failedExecAttempts; + } + + /** + * Abort launch while it is still proven that no child realm touched Memory. + */ + abortBeforeChildStart( + childGeneration: TGeneration, + errno: number, + ): boolean { + this.validateErrno(errno); + const lifetime = this.activeOrCompleted(childGeneration); + if (!lifetime) return false; + if (lifetime.phase !== "starting") { + throw new Error( + "cannot return a vfork launch error after the child may access Memory", + ); + } + return this.settle(lifetime, { + kind: "return-error", + parentGeneration: lifetime.parentGeneration, + childPid: lifetime.childPid, + errno, + }); + } + + /** + * Complete a kernel child that died before its Worker could be started. + */ + completeWithoutBorrow( + childGeneration: TGeneration, + reason: "exit" | "signal", + ): boolean { + const lifetime = this.activeOrCompleted(childGeneration); + if (!lifetime) return false; + if (lifetime.phase !== "starting") { + throw new Error("vfork child already entered the shared address space"); + } + return this.settle(lifetime, { + kind: "resume-parent", + parentGeneration: lifetime.parentGeneration, + childPid: lifetime.childPid, + reason, + }); + } + + /** + * Authorize parent resumption only after exact old-generation teardown. + */ + completeAfterExactTeardown( + childGeneration: TGeneration, + reason: VforkExactCompletionReason, + ): boolean { + const lifetime = this.activeOrCompleted(childGeneration); + if (!lifetime) return false; + if (lifetime.phase !== "borrowing") { + throw new Error("vfork child did not enter the shared address space"); + } + return this.settle(lifetime, { + kind: "resume-parent", + parentGeneration: lifetime.parentGeneration, + childPid: lifetime.childPid, + reason, + }); + } + + /** + * End bookkeeping without authorizing access to the backing by the parent. + */ + requireAddressSpaceContainment( + childGeneration: TGeneration, + cause: unknown, + ): boolean { + const lifetime = this.activeOrCompleted(childGeneration); + if (!lifetime) return false; + return this.settle(lifetime, { + kind: "contain-address-space", + parentGeneration: lifetime.parentGeneration, + childPid: lifetime.childPid, + cause, + }); + } + + private activeOrCompleted( + childGeneration: TGeneration, + ): MutableVforkLifetime | undefined { + const lifetime = this.byChild.get(childGeneration); + if (lifetime) return lifetime; + if (this.completedChildren.has(childGeneration)) return undefined; + throw new Error("unknown vfork child generation"); + } + + private requireActive( + childGeneration: TGeneration, + ): MutableVforkLifetime { + const lifetime = this.activeOrCompleted(childGeneration); + if (!lifetime) throw new Error("vfork child lifetime is already settled"); + return lifetime; + } + + private settle( + lifetime: MutableVforkLifetime, + disposition: VforkLifetimeDisposition, + ): boolean { + if (lifetime.phase === "settled") return false; + lifetime.phase = "settled"; + if (this.byMemory.get(lifetime.memory) === lifetime) { + this.byMemory.delete(lifetime.memory); + } + this.byChild.delete(lifetime.childGeneration); + this.completedChildren.add(lifetime.childGeneration); + lifetime.resolve(disposition); + return true; + } + + private validatePid(pid: number, role: string): void { + if (!Number.isSafeInteger(pid) || pid <= 0) { + throw new Error(`invalid vfork ${role} PID: ${pid}`); + } + } + + private validateErrno(errno: number): void { + if (!Number.isSafeInteger(errno) || errno <= 0) { + throw new Error(`invalid vfork errno: ${errno}`); + } + } +} diff --git a/host/src/worker-main.ts b/host/src/worker-main.ts index 5dd5bdf839..5337694746 100644 --- a/host/src/worker-main.ts +++ b/host/src/worker-main.ts @@ -561,8 +561,11 @@ export interface DlopenSupport { /** Validate and return the compact copied live-module closure. */ readForkState: () => DylinkForkState; /** Recreate the parent's live module and handle state from linear memory. */ - replayDlopens: (validatedState?: DylinkForkState) => void; - /** Clear a fork parent's copied archive lock in the child's private memory. */ + replayDlopens: ( + validatedState?: DylinkForkState, + options?: { readonly memoryOwnership?: "copied" | "borrowed" }, + ) => void; + /** Clear a fork parent's copied archive lock in ordinary child memory. */ resetForkChildLock: () => void; readonly archive: DylinkForkArchive; /** Acquire one reentrant process-archive writer depth, blocking if needed. */ @@ -1791,7 +1794,10 @@ export function buildDlopenImports( const readForkState = (): DylinkForkState => forkArchive.read(); - const replayDlopens = (validatedState?: DylinkForkState): void => { + const replayDlopens = ( + validatedState?: DylinkForkState, + options: { readonly memoryOwnership?: "copied" | "borrowed" } = {}, + ): void => { const state = validatedState ?? readForkState(); if ( state.nextHandle === 2 && @@ -1804,7 +1810,7 @@ export function buildDlopenImports( // view. Pthread Workers can call this for every process generation. const lk = getLinker(); try { - lk.reconcileForkModules(state); + lk.reconcileForkModules(state, options); lk.reconcileForkHandleState(state); } catch (error) { abortLinkerOperation(); diff --git a/host/test/dylink.test.ts b/host/test/dylink.test.ts index 7e16d497bd..fcbdd618cb 100644 --- a/host/test/dylink.test.ts +++ b/host/test/dylink.test.ts @@ -1813,6 +1813,115 @@ describe("dylink symbol interposition", () => { }); describe("dylink replay layout and rollback", () => { + it("rebuilds borrowed instances without running start or data relocations", () => { + const wasmBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "__memory_base" (global $memory_base i32)) + (func $write (param $value i32) + global.get $memory_base + local.get $value + i32.store) + (func $start (export "__wasm_init_memory") + i32.const 91 + call $write) + (start $start) + (func (export "__wasm_apply_data_relocs") + i32.const 92 + call $write) + (func (export "read_value") (result i32) + global.get $memory_base + i32.load)) + `, "borrowed-no-loader-writes", undefined, 0, 4); + const options = createSideForkLoadOptions(); + const memoryBase = 4_096; + new DataView(options.memory.buffer).setInt32(memoryBase, 77, true); + + const library = loadSharedLibrarySync( + "libborrowed-no-loader-writes.so", + wasmBytes, + options, + { + memoryBase, + tableBase: options.table.length, + memoryOwnership: "borrowed", + }, + ); + + expect((library.exports.read_value as () => number)()).toBe(77); + expect(new DataView(options.memory.buffer).getInt32(memoryBase, true)) + .toBe(77); + }); + + it("rejects active data before borrowed instantiation can write", () => { + const wasmBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (data (i32.const 4096) "\\01\\00\\00\\00")) + `, "borrowed-active-data", undefined, 0, 4); + const options = createSideForkLoadOptions(); + const memoryBase = 4_096; + new DataView(options.memory.buffer).setInt32(memoryBase, 77, true); + + expect(() => loadSharedLibrarySync( + "libborrowed-active-data.so", + wasmBytes, + options, + { + memoryBase, + tableBase: options.table.length, + memoryOwnership: "borrowed", + }, + )).toThrow(/borrowed replay requires passive data segments/); + expect(new DataView(options.memory.buffer).getInt32(memoryBase, true)) + .toBe(77); + }); + + it("rejects an unrecognized start instead of silently skipping it", () => { + const wasmBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "__memory_base" (global $memory_base i32)) + (func $start + global.get $memory_base + i32.const 93 + i32.store) + (start $start)) + `, "borrowed-unknown-start", undefined, 0, 4); + const options = createSideForkLoadOptions(); + const memoryBase = 4_096; + new DataView(options.memory.buffer).setInt32(memoryBase, 77, true); + + expect(() => loadSharedLibrarySync( + "libborrowed-unknown-start.so", + wasmBytes, + options, + { + memoryBase, + tableBase: options.table.length, + memoryOwnership: "borrowed", + }, + )).toThrow(/cannot suppress unrecognized start function/); + expect(new DataView(options.memory.buffer).getInt32(memoryBase, true)) + .toBe(77); + }); + + it("rejects in-flight loader transactions at the borrowed boundary", () => { + const linker = new DynamicLinker(createSideForkLoadOptions()); + expect(() => linker.reconcileForkModules({ + nextHandle: 2, + libraries: [], + transactions: [{ + token: 1, + name: "libpending.so", + moduleBytes: new Uint8Array(), + globalVisibility: false, + }], + }, { + memoryOwnership: "borrowed", + })).toThrow(/cannot restore an in-flight dlopen transaction/); + }); + it("does not apply data relocations twice to copied child memory", () => { const wasmBytes = buildDylinkWat(` (module @@ -2100,6 +2209,55 @@ describe("dylink replay layout and rollback", () => { }); }); +describe.skipIf(!hasCompiler())("borrowed wasm-ld replay", () => { + it("reconstructs passive-data state in shared Memory without writes", () => { + const wasmBytes = buildSharedLib( + ` + static int counter = 41; + int get_counter(void) { return counter; } + void inc_counter(void) { counter++; } + `, + "borrowed-standard-side", + ); + const parentOptions = createSideForkLoadOptions(); + const parent = new DynamicLinker(parentOptions); + expect(parent.dlopenSync("libborrowed-standard-side.so", wasmBytes)).toBe(2); + const parentLibrary = parentOptions.loadedLibraries.get( + "libborrowed-standard-side.so", + )!; + (parentLibrary.exports.inc_counter as () => void)(); + expect((parentLibrary.exports.get_counter as () => number)()).toBe(42); + const archived = parent.forkState(); + const savedData = new Uint8Array( + parentOptions.memory.buffer, + parentLibrary.memoryBase, + parentLibrary.metadata.memorySize, + ).slice(); + + const childOptions = createSideForkLoadOptions(); + childOptions.memory = parentOptions.memory; + childOptions.allocateMemory = () => { + throw new Error("borrowed side replay must not allocate process memory"); + }; + childOptions.deallocateMemory = () => { + throw new Error("borrowed side replay must not release process memory"); + }; + const child = new DynamicLinker(childOptions); + child.reconcileForkModules(archived, { memoryOwnership: "borrowed" }); + const childLibrary = childOptions.loadedLibraries.get( + "libborrowed-standard-side.so", + )!; + + expect((childLibrary.exports.get_counter as () => number)()).toBe(42); + expect(new Uint8Array( + parentOptions.memory.buffer, + parentLibrary.memoryBase, + parentLibrary.metadata.memorySize, + )).toEqual(savedData); + expect((parentLibrary.exports.get_counter as () => number)()).toBe(42); + }); +}); + describe.skipIf(!hasCompiler())("DynamicLinker", () => { function createLinker(): DynamicLinker { const memory = new WebAssembly.Memory({ initial: 1, maximum: 100, shared: true }); diff --git a/host/test/fixtures/borrowed-fork-replay-worker.ts b/host/test/fixtures/borrowed-fork-replay-worker.ts new file mode 100644 index 0000000000..05633c17a4 --- /dev/null +++ b/host/test/fixtures/borrowed-fork-replay-worker.ts @@ -0,0 +1,86 @@ +import { parentPort, workerData } from "node:worker_threads"; +import { + LinkedForkContinuation, + type LinkedFrameFormatDescriptor, +} from "../../src/fork-continuation"; +import { ForkModuleStateArena } from "../../src/fork-module-state"; +import { SingleActivationForkRuntime } from "../fork-instrument-runtime-harness"; + +const { + module, + moduleBytes, + memory, + linkedFormat, + moduleBuffer, + moduleStateRoot, + privateModuleBuffer, +} = workerData as { + module: WebAssembly.Module; + moduleBytes: Uint8Array; + memory: WebAssembly.Memory; + linkedFormat: LinkedFrameFormatDescriptor; + moduleBuffer: number; + moduleStateRoot: number; + privateModuleBuffer: number; +}; + +const continuation = new LinkedForkContinuation( + memory, + linkedFormat, + () => { throw new Error("borrowed child must not allocate continuation state"); }, + () => { throw new Error("borrowed child must not release continuation state"); }, + "borrow-child-worker-e2e", +); +const newArena = () => new ForkModuleStateArena( + memory, + linkedFormat.ptrWidth, + () => { throw new Error("borrowed child must not allocate module state"); }, + () => { throw new Error("borrowed child must not release module state"); }, + "borrow-child-worker module state", +); +const runtime = new SingleActivationForkRuntime({ + module, + moduleBytes, + memory, + continuation, + newArena, + label: "borrow-child-worker-e2e", +}); +let instance: WebAssembly.Instance; +instance = new WebAssembly.Instance(module, { + env: { + memory, + ...runtime.envImports, + }, + kernel: { + kernel_fork: () => { + if (runtime.coordinator.phaseName() !== "child-replay") { + throw new Error( + `borrowed child reached fork while ${runtime.coordinator.phaseName()}`, + ); + } + runtime.coordinator.finishReplay(); + return 0; + }, + }, +}); +runtime.register(instance, { bootstrap: false }); +runtime.setCopiedProcessLaunchRoot(moduleBuffer); +const arena = newArena(); +arena.attachBorrowed( + linkedFormat.ptrWidth === 8 ? BigInt(moduleStateRoot) : moduleStateRoot, +); +runtime.coordinator.attachBorrowedChild(arena, ({ activationId }) => { + if (activationId !== 0) { + throw new Error(`unexpected borrowed activation ${activationId}`); + } + return linkedFormat.ptrWidth === 8 + ? BigInt(privateModuleBuffer) + : privateModuleBuffer; +}); + +parentPort!.postMessage({ + result: (instance.exports.run as () => number)(), + active: continuation.hasActiveContinuation(), + arenaActive: arena.hasActiveArena(), +}); diff --git a/host/test/fork-borrowed-replay.test.ts b/host/test/fork-borrowed-replay.test.ts new file mode 100644 index 0000000000..2a1d671275 --- /dev/null +++ b/host/test/fork-borrowed-replay.test.ts @@ -0,0 +1,188 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Worker } from "node:worker_threads"; +import { describe, expect, it } from "vitest"; +import { + LinkedForkContinuation, + readLinkedFrameFormat, +} from "../src/fork-continuation"; +import { + ForkModuleStateArena, + readForkModuleStateRoot, +} from "../src/fork-module-state"; +import { SingleActivationForkRuntime } from "./fork-instrument-runtime-harness"; + +const PAGE_SIZE = 65_536; + +describe("borrowed fork replay", () => { + it("lets a fresh ABI 43 shared-memory Worker replay before its parent", async () => { + const dir = mkdtempSync(join(tmpdir(), "kandelo-fork-borrow-")); + try { + const rawPath = join(dir, "borrow.wasm"); + const instrumentedPath = join(dir, "borrow.instrumented.wasm"); + const wat = `(module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (import "env" "memory" (memory 8 8 shared)) + (func $leaf (result i32) call $fork) + (func (export "run") (result i32) (local $saved i32) + i32.const 7 + local.set $saved + call $leaf + local.get $saved + i32.add))`; + const watPath = join(dir, "borrow.wat"); + writeFileSync(watPath, wat); + execFileSync("wat2wasm", [ + "--enable-threads", + watPath, + "-o", + rawPath, + ]); + execFileSync(fileURLToPath(new URL( + "../../tools/bin/wasm-fork-instrument", + import.meta.url, + )), [ + rawPath, + "-o", + instrumentedPath, + ]); + + const instrumentedBytes = readFileSync(instrumentedPath); + const module = new WebAssembly.Module(instrumentedBytes); + const linkedFormat = readLinkedFrameFormat(module); + const memory = new WebAssembly.Memory({ + initial: 8, + maximum: 8, + shared: true, + }); + const allocations: Array<{ addr: number; size: number }> = []; + const releases: Array<{ addr: number; size: number }> = []; + let nextAddress = PAGE_SIZE; + let nextArenaAddress = 5 * PAGE_SIZE; + const parentContinuation = new LinkedForkContinuation( + memory, + linkedFormat, + (size) => { + const addr = nextAddress; + nextAddress += size; + allocations.push({ addr, size }); + return addr; + }, + (addr, size) => releases.push({ addr, size }), + "borrow-parent-e2e", + ); + const parentRuntime = new SingleActivationForkRuntime({ + module, + moduleBytes: instrumentedBytes, + memory, + continuation: parentContinuation, + newArena: () => new ForkModuleStateArena( + memory, + linkedFormat.ptrWidth, + (size) => { + const address = nextArenaAddress; + nextArenaAddress += size; + return address; + }, + () => {}, + "borrow-parent module state", + ), + label: "borrow-parent-e2e", + }); + let parentInstance: WebAssembly.Instance; + let parentForkResult = 0; + parentInstance = new WebAssembly.Instance(module, { + env: { + memory, + ...parentRuntime.envImports, + }, + kernel: { + kernel_fork: () => { + if (parentRuntime.coordinator.phaseName() === "parent-replay") { + parentRuntime.coordinator.finishReplay(); + return parentForkResult; + } + parentRuntime.beginCapture(); + return 0; + }, + }, + }); + parentRuntime.register(parentInstance); + const parentRun = parentInstance.exports.run as () => number; + + parentRuntime.expectCaptureTransport(parentRun); + parentRuntime.coordinator.sealCapture(); + const moduleBuffer = parentRuntime.coordinator.rootFor(0); + const moduleStateRoot = readForkModuleStateRoot( + memory, + moduleBuffer, + linkedFormat.ptrWidth, + ); + const savedChunks = allocations.map(({ addr, size }) => ({ + addr, + bytes: new Uint8Array(memory.buffer, addr, size).slice(), + })); + + // Generated replay writes its active-frame pointer into this prefix. The + // final page models a child-owned mapping in the shared vfork address + // space; parent continuation and module-state pages remain read-only. + const childModuleBuffer = 7 * PAGE_SIZE; + const childWorker = new Worker( + new URL("./fixtures/borrowed-fork-replay-worker.ts", import.meta.url), + { + execArgv: ["--import", "tsx"], + workerData: { + module, + moduleBytes: new Uint8Array(instrumentedBytes), + memory, + linkedFormat, + moduleBuffer, + moduleStateRoot, + privateModuleBuffer: childModuleBuffer, + }, + }, + ); + try { + const childResult = await new Promise<{ + result: number; + active: boolean; + arenaActive: boolean; + }>((resolve, reject) => { + childWorker.once("message", resolve); + childWorker.once("error", reject); + childWorker.once("exit", (code) => { + if (code !== 0) { + reject(new Error(`borrowed replay Worker exited ${code}`)); + } + }); + }); + expect(childResult).toEqual({ + result: 7, + active: false, + arenaActive: false, + }); + } finally { + await childWorker.terminate(); + } + expect(releases).toEqual([]); + for (const saved of savedChunks) { + expect(new Uint8Array( + memory.buffer, + saved.addr, + saved.bytes.length, + )).toEqual(saved.bytes); + } + + parentRuntime.coordinator.beginParentReplay(); + parentForkResult = 123; + expect(parentRun()).toBe(130); + expect(parentContinuation.hasActiveContinuation()).toBe(false); + expect(releases).toEqual([...allocations].reverse()); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/host/test/fork-continuation.test.ts b/host/test/fork-continuation.test.ts index cfee45c449..87b7249a61 100644 --- a/host/test/fork-continuation.test.ts +++ b/host/test/fork-continuation.test.ts @@ -288,6 +288,164 @@ describe("LinkedForkContinuation", () => { }, ); + it.each([4, 8] as const)( + "borrows a wasm%s continuation without consuming or releasing its frames", + (ptrWidth) => { + const format = formatFor(ptrWidth); + const memory = new WebAssembly.Memory({ initial: 8 }); + const parentAllocator = allocator(memory); + const parent = new LinkedForkContinuation( + memory, + format, + parentAllocator.allocate, + parentAllocator.deallocate, + `borrow-parent-wasm${ptrWidth * 8}`, + ); + const moduleBuffer = parent.beginUnwind(); + const frames = Array.from({ length: 4 }, (_, index) => ({ + size: 40_000 + index * format.alignment, + byte: 0x31 + index, + })); + for (const frame of frames) { + const payload = parent.reserveFrame( + guestPointer(frame.size, ptrWidth), + ); + new Uint8Array(memory.buffer, Number(payload), frame.size).fill( + frame.byte, + ); + parent.commitFrame(payload); + } + parent.finishUnwind(); + expect(parentAllocator.allocations.length).toBeGreaterThan(1); + const privateModuleBuffer = 7 * PAGE_SIZE; + new Uint8Array( + memory.buffer, + Number(moduleBuffer), + format.fixedPrefixSize, + ).fill(0x6d); + const savedChunks = parentAllocator.allocations.map(({ addr, size }) => ({ + addr, + bytes: new Uint8Array(memory.buffer, addr, size).slice(), + })); + + const borrower = new LinkedForkContinuation( + memory, + format, + () => { throw new Error("borrowed replay must not allocate"); }, + () => { throw new Error("borrowed replay must not release"); }, + `borrow-child-wasm${ptrWidth * 8}`, + ); + expect(borrower.attachForBorrowedReplay( + moduleBuffer, + guestPointer(privateModuleBuffer, ptrWidth), + )).toBe(guestPointer(privateModuleBuffer, ptrWidth)); + expect(new Uint8Array( + memory.buffer, + privateModuleBuffer, + format.fixedPrefixSize, + )).toEqual(new Uint8Array( + memory.buffer, + Number(moduleBuffer), + format.fixedPrefixSize, + )); + expect(() => borrower.reserveFrame(guestPointer(8, ptrWidth))).toThrow( + "frame reservation outside unwind", + ); + expect(() => borrower.commitFrame(guestPointer(8, ptrWidth))).toThrow( + "frame commit outside owned unwind", + ); + expect(() => borrower.beginReplay()).toThrow( + "cannot begin replay from incomplete continuation", + ); + expect(() => borrower.beginAbortReplay(12)).toThrow( + "cannot abort-replay an incomplete continuation", + ); + expect(() => borrower.cancelUnwindAndRelease()).toThrow( + "cannot cancel an inactive unwind", + ); + expect(() => borrower.finishBorrowedReplay()).toThrow( + "rewind ended before all linked frames were read", + ); + for (const frame of [...frames].reverse()) { + const payload = borrower.nextFrame( + guestPointer(frame.size, ptrWidth), + ); + const bytes = new Uint8Array( + memory.buffer, + Number(payload), + frame.size, + ); + expect(bytes[0]).toBe(frame.byte); + expect(bytes[bytes.length - 1]).toBe(frame.byte); + } + expect(() => borrower.finishReplayAndRelease()).toThrow( + "borrowed replay cannot release continuation storage", + ); + borrower.finishBorrowedReplay(); + expect(() => borrower.finishBorrowedReplay()).toThrow( + "no borrowed continuation replay is active", + ); + expect(borrower.hasActiveContinuation()).toBe(false); + for (const saved of savedChunks) { + expect(new Uint8Array( + memory.buffer, + saved.addr, + saved.bytes.length, + )).toEqual(saved.bytes); + } + expect(parentAllocator.releases).toEqual([]); + + parent.beginReplay(); + for (const frame of [...frames].reverse()) { + const payload = parent.nextFrame( + guestPointer(frame.size, ptrWidth), + ); + expect(new Uint8Array(memory.buffer, Number(payload), frame.size)[0]) + .toBe(frame.byte); + } + parent.finishReplayAndRelease(); + expect(parentAllocator.releases).toEqual( + [...parentAllocator.allocations].reverse(), + ); + }, + ); + + it.each([4, 8] as const)( + "rejects a wasm%s borrowed replay prefix that aliases owned chunks", + (ptrWidth) => { + const format = formatFor(ptrWidth); + const memory = new WebAssembly.Memory({ initial: 4 }); + const parentAllocator = allocator(memory); + const parent = new LinkedForkContinuation( + memory, + format, + parentAllocator.allocate, + parentAllocator.deallocate, + `borrow-overlap-parent-wasm${ptrWidth * 8}`, + ); + const moduleBuffer = parent.beginUnwind(); + const payload = parent.reserveFrame(guestPointer(32, ptrWidth)); + parent.commitFrame(payload); + parent.finishUnwind(); + + const borrower = new LinkedForkContinuation( + memory, + format, + () => { throw new Error("borrowed replay must not allocate"); }, + () => { throw new Error("borrowed replay must not release"); }, + `borrow-overlap-child-wasm${ptrWidth * 8}`, + ); + expect(() => borrower.attachForBorrowedReplay( + moduleBuffer, + moduleBuffer, + )).toThrow("borrowed replay prefix overlaps continuation storage"); + expect(borrower.hasActiveContinuation()).toBe(false); + expect(parentAllocator.releases).toEqual([]); + + parent.cancelUnwindAndRelease(); + }, + ); + it.each([4, 8] as const)( "rejects a two-node wasm%s chunk cycle at its first repeated address", (ptrWidth) => { diff --git a/host/test/fork-process-continuation.test.ts b/host/test/fork-process-continuation.test.ts index 8c29f31fad..1723498108 100644 --- a/host/test/fork-process-continuation.test.ts +++ b/host/test/fork-process-continuation.test.ts @@ -154,6 +154,7 @@ function makeCoordinator( ): { coordinator: ForkProcessContinuationCoordinator; arena: ForkModuleStateArena; + continuations: Map; } { const registry = new ForkActivationRegistry(memory, externrefs(), `${label}: registry`); const coordinator = new ForkProcessContinuationCoordinator( @@ -161,6 +162,7 @@ function makeCoordinator( registry, label, ); + const continuations = new Map(); for (const activationId of [0, 4, 9]) { const continuation = new LinkedForkContinuation( memory, @@ -169,6 +171,7 @@ function makeCoordinator( owner.deallocate, `${label}: activation ${activationId}`, ); + continuations.set(activationId, continuation); coordinator.prepareActivation({ activationId, continuation, @@ -189,6 +192,7 @@ function makeCoordinator( } return { coordinator, + continuations, arena: new ForkModuleStateArena( memory, 4, @@ -208,6 +212,195 @@ function writeOrdinal( } describe("ForkProcessContinuationCoordinator", () => { + it("borrows every active activation without consuming parent state", () => { + const memory = new WebAssembly.Memory({ + initial: 16, + maximum: 16, + shared: true, + }); + const parentOwner = allocationOwner(memory); + const launchRoots = new Map(); + const parent = makeCoordinator( + memory, + parentOwner, + [], + launchRoots, + "borrowed parent", + ); + const arenaRoot = parent.arena.begin(); + parent.coordinator.beginCapture(parent.arena); + + const sideImports = parent.coordinator.continuationImports(4); + const sidePayload = ( + sideImports.__wpk_fork_frame_reserve as (size: number) => number + )(16); + writeOrdinal(memory, sidePayload, 8); + ( + sideImports.__wpk_fork_frame_commit as (payload: number) => void + )(sidePayload); + const mainImports = parent.coordinator.continuationImports(0); + const mainPayload = ( + mainImports.__wpk_fork_frame_reserve as (size: number) => number + )(16); + writeOrdinal(memory, mainPayload, 11); + ( + mainImports.__wpk_fork_frame_commit as (payload: number) => void + )(mainPayload); + parent.coordinator.sealCapture(); + + const borrowedRanges = [arenaRoot, ...[0, 4].map((activationId) => + parent.coordinator.rootFor(activationId) - linkedFormat().chunkHeaderSize + )].map((address) => ({ + address, + bytes: new Uint8Array(memory.buffer, address, PAGE_SIZE).slice(), + })); + const privatePrefixes = new Map([ + [0, 14 * PAGE_SIZE], + [4, 15 * PAGE_SIZE], + ]); + const childReleases: Array<{ address: number; size: number }> = []; + const childOwner: AllocationOwner = { + allocate() { + throw new Error("borrowed child must not allocate continuation state"); + }, + deallocate(address, size) { + childReleases.push({ address, size }); + }, + }; + const child = makeCoordinator( + memory, + childOwner, + [], + launchRoots, + "borrowed child", + ); + child.arena.attachBorrowed(arenaRoot); + const prefixRequests: number[] = []; + child.coordinator.attachBorrowedChild(child.arena, (request) => { + prefixRequests.push(request.activationId); + expect(request).toMatchObject({ byteLength: 64, alignment: 16 }); + return privatePrefixes.get(request.activationId)!; + }); + + expect(prefixRequests).toEqual([0, 4]); + for (const activationId of prefixRequests) { + const source = parent.coordinator.rootFor(activationId); + const target = privatePrefixes.get(activationId)!; + expect(new Uint8Array(memory.buffer, target, 64)).toEqual( + new Uint8Array(memory.buffer, source, 64), + ); + } + // Process replay is global and therefore consumes the reverse commit order. + ( + child.coordinator.continuationImports(0) + .__wpk_fork_frame_next as (size: number) => number + )(16); + ( + child.coordinator.continuationImports(4) + .__wpk_fork_frame_next as (size: number) => number + )(16); + child.coordinator.finishReplay(); + + expect(child.coordinator.phaseName()).toBe("idle"); + expect(child.arena.hasActiveArena()).toBe(false); + expect(childReleases).toEqual([]); + expect(launchRoots.get(0)).toBe(parent.coordinator.rootFor(0)); + for (const range of borrowedRanges) { + expect(new Uint8Array( + memory.buffer, + range.address, + range.bytes.length, + )).toEqual(range.bytes); + } + + parent.coordinator.beginParentReplay(); + ( + parent.coordinator.continuationImports(0) + .__wpk_fork_frame_next as (size: number) => number + )(16); + ( + parent.coordinator.continuationImports(4) + .__wpk_fork_frame_next as (size: number) => number + )(16); + parent.coordinator.finishReplay(); + expect(launchRoots.get(0)).toBe(0); + }); + + it("rolls back a partial borrowed attach without wedging the parent", () => { + const memory = new WebAssembly.Memory({ + initial: 16, + maximum: 16, + shared: true, + }); + const parentOwner = allocationOwner(memory); + const launchRoots = new Map(); + const parent = makeCoordinator( + memory, + parentOwner, + [], + launchRoots, + "rollback parent", + ); + const arenaRoot = parent.arena.begin(); + parent.coordinator.beginCapture(parent.arena); + for (const [activationId, ordinal] of [[4, 8], [0, 11]] as const) { + const imports = parent.coordinator.continuationImports(activationId); + const payload = ( + imports.__wpk_fork_frame_reserve as (size: number) => number + )(16); + writeOrdinal(memory, payload, ordinal); + ( + imports.__wpk_fork_frame_commit as (address: number) => void + )(payload); + } + parent.coordinator.sealCapture(); + const parentLaunchRoot = launchRoots.get(0); + + const child = makeCoordinator( + memory, + { + allocate() { + throw new Error("borrowed rollback child must not allocate"); + }, + deallocate() { + throw new Error("borrowed rollback child must not deallocate"); + }, + }, + [], + launchRoots, + "rollback child", + ); + child.arena.attachBorrowed(arenaRoot); + expect(() => child.coordinator.attachBorrowedChild( + child.arena, + ({ activationId }) => { + if (activationId === 4) throw new Error("private prefix exhausted"); + return 15 * PAGE_SIZE; + }, + )).toThrow("private prefix exhausted"); + + expect(child.coordinator.phaseName()).toBe("idle"); + expect(child.arena.hasActiveArena()).toBe(false); + expect( + [...child.continuations.values()].every( + (continuation) => !continuation.hasActiveContinuation(), + ), + ).toBe(true); + expect(launchRoots.get(0)).toBe(parentLaunchRoot); + + parent.coordinator.beginParentReplay(); + ( + parent.coordinator.continuationImports(0) + .__wpk_fork_frame_next as (size: number) => number + )(16); + ( + parent.coordinator.continuationImports(4) + .__wpk_fork_frame_next as (size: number) => number + )(16); + parent.coordinator.finishReplay(); + expect(parent.coordinator.phaseName()).toBe("idle"); + }); + it("reconstructs cross-activation frame order in a fresh child", () => { const parentMemory = new WebAssembly.Memory({ initial: 16 }); const parentOwner = allocationOwner(parentMemory); diff --git a/host/test/vfork-lifetime.test.ts b/host/test/vfork-lifetime.test.ts new file mode 100644 index 0000000000..2c4c0b4748 --- /dev/null +++ b/host/test/vfork-lifetime.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from "vitest"; +import { + VforkAddressSpaceBusyError, + VforkLifetimeCoordinator, + type VforkProcessGeneration, +} from "../src/vfork-lifetime"; + +interface Generation extends VforkProcessGeneration { + readonly name: string; +} + +function sharedMemory(): WebAssembly.Memory { + return new WebAssembly.Memory({ + initial: 2, + maximum: 2, + shared: true, + }); +} + +function generation(name: string, memory = sharedMemory()): Generation { + return { name, memory }; +} + +async function expectPending(promise: Promise): Promise { + let settled = false; + void promise.then(() => { + settled = true; + }); + await Promise.resolve(); + await Promise.resolve(); + expect(settled).toBe(false); +} + +describe("shared vfork lifetime coordinator", () => { + it("requires an exact Shared Memory alias and distinct process identities", () => { + const coordinator = new VforkLifetimeCoordinator(); + const parent = generation("parent"); + + expect(() => coordinator.begin( + 10, + 11, + parent, + generation("copied-child"), + )).toThrow("does not alias"); + + const privateMemory = new WebAssembly.Memory({ initial: 1, maximum: 1 }); + expect(() => coordinator.begin( + 10, + 11, + generation("private-parent", privateMemory), + generation("private-child", privateMemory), + )).toThrow("requires Shared"); + + expect(() => coordinator.begin(10, 10, parent, generation("child", parent.memory))) + .toThrow("PIDs must differ"); + expect(() => coordinator.begin(10, 11, parent, parent)) + .toThrow("generations must differ"); + }); + + it("parks the caller while sibling work and failed execs continue", async () => { + const coordinator = new VforkLifetimeCoordinator(); + const memory = sharedMemory(); + const parent = generation("parent", memory); + const child = generation("child", memory); + const lifetime = coordinator.begin(20, 21, parent, child); + coordinator.markChildMayAccessMemory(child); + + let siblingRan = false; + queueMicrotask(() => { + siblingRan = true; + }); + await expectPending(lifetime.completion); + expect(siblingRan).toBe(true); + + expect(coordinator.noteFailedExec(child, 2)).toBe(1); + expect(coordinator.noteFailedExec(child, 13)).toBe(2); + expect(lifetime.failedExecAttempts).toBe(2); + await expectPending(lifetime.completion); + + coordinator.completeAfterExactTeardown(child, "exit"); + await expect(lifetime.completion).resolves.toEqual({ + kind: "resume-parent", + parentGeneration: parent, + childPid: 21, + reason: "exit", + }); + }); + + it.each(["exec", "exit", "signal", "trap"] as const)( + "accepts exact %s teardown evidence once", + async (reason) => { + const coordinator = new VforkLifetimeCoordinator(); + const memory = sharedMemory(); + const parent = generation("parent", memory); + const child = generation("child", memory); + const lifetime = coordinator.begin(30, 31, parent, child); + coordinator.markChildMayAccessMemory(child); + + expect(coordinator.completeAfterExactTeardown(child, reason)).toBe(true); + expect(coordinator.completeAfterExactTeardown(child, reason)).toBe(false); + expect(coordinator.requireAddressSpaceContainment(child, new Error("late"))) + .toBe(false); + await expect(lifetime.completion).resolves.toMatchObject({ + kind: "resume-parent", + parentGeneration: parent, + childPid: 31, + reason, + }); + expect(lifetime.phase).toBe("settled"); + expect(coordinator.activeCount).toBe(0); + }, + ); + + it("returns a launch error only before the child may touch memory", async () => { + const coordinator = new VforkLifetimeCoordinator(); + const memory = sharedMemory(); + const parent = generation("parent", memory); + const child = generation("child", memory); + const lifetime = coordinator.begin(40, 41, parent, child); + + expect(coordinator.abortBeforeChildStart(child, 12)).toBe(true); + await expect(lifetime.completion).resolves.toEqual({ + kind: "return-error", + parentGeneration: parent, + childPid: 41, + errno: 12, + }); + + const nextChild = generation("next-child", memory); + const next = coordinator.begin(40, 42, parent, nextChild); + coordinator.markChildMayAccessMemory(nextChild); + expect(() => coordinator.abortBeforeChildStart(nextChild, 12)).toThrow( + "cannot return", + ); + coordinator.completeAfterExactTeardown(nextChild, "exec"); + await expect(next.completion).resolves.toMatchObject({ + kind: "resume-parent", + reason: "exec", + }); + }); + + it("resumes for an exact kernel death before Worker launch", async () => { + const coordinator = new VforkLifetimeCoordinator(); + const memory = sharedMemory(); + const parent = generation("parent", memory); + const child = generation("child", memory); + const lifetime = coordinator.begin(50, 51, parent, child); + + coordinator.completeWithoutBorrow(child, "signal"); + await expect(lifetime.completion).resolves.toEqual({ + kind: "resume-parent", + parentGeneration: parent, + childPid: 51, + reason: "signal", + }); + }); + + it("requires whole-address-space containment after ambiguous termination", async () => { + const coordinator = new VforkLifetimeCoordinator(); + const memory = sharedMemory(); + const parent = generation("parent", memory); + const child = generation("child", memory); + const lifetime = coordinator.begin(60, 61, parent, child); + coordinator.markChildMayAccessMemory(child); + const crash = new Error("Worker stopped without memory_quiescent"); + + expect(coordinator.requireAddressSpaceContainment(child, crash)).toBe(true); + expect(coordinator.completeAfterExactTeardown(child, "trap")).toBe(false); + await expect(lifetime.completion).resolves.toEqual({ + kind: "contain-address-space", + parentGeneration: parent, + childPid: 61, + cause: crash, + }); + }); + + it("rejects overlapping and nested borrowers with EAGAIN", async () => { + const coordinator = new VforkLifetimeCoordinator(); + const memory = sharedMemory(); + const parent = generation("parent", memory); + const firstChild = generation("first-child", memory); + const first = coordinator.begin(70, 71, parent, firstChild); + coordinator.markChildMayAccessMemory(firstChild); + + for (const [parentPid, childPid, initiator] of [ + [70, 72, parent], + [71, 73, firstChild], + ] as const) { + let error: unknown; + try { + coordinator.begin( + parentPid, + childPid, + initiator, + generation(`child-${childPid}`, memory), + ); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(VforkAddressSpaceBusyError); + expect((error as VforkAddressSpaceBusyError).errno).toBe(11); + } + + coordinator.completeAfterExactTeardown(firstChild, "exec"); + await first.completion; + }); + + it("allows concurrent borrowers only for distinct address spaces", async () => { + const coordinator = new VforkLifetimeCoordinator(); + const firstMemory = sharedMemory(); + const secondMemory = sharedMemory(); + const firstChild = generation("first-child", firstMemory); + const secondChild = generation("second-child", secondMemory); + const first = coordinator.begin( + 80, + 81, + generation("first-parent", firstMemory), + firstChild, + ); + const second = coordinator.begin( + 90, + 91, + generation("second-parent", secondMemory), + secondChild, + ); + + expect(coordinator.activeCount).toBe(2); + coordinator.markChildMayAccessMemory(firstChild); + coordinator.markChildMayAccessMemory(secondChild); + coordinator.completeAfterExactTeardown(firstChild, "exit"); + coordinator.completeAfterExactTeardown(secondChild, "exit"); + await Promise.all([first.completion, second.completion]); + expect(coordinator.activeCount).toBe(0); + }); + + it("supports repeated lifetimes but never reuses a child generation", async () => { + const coordinator = new VforkLifetimeCoordinator(); + const memory = sharedMemory(); + const parent = generation("parent", memory); + const firstChild = generation("first-child", memory); + const first = coordinator.begin(100, 101, parent, firstChild); + coordinator.markChildMayAccessMemory(firstChild); + coordinator.completeAfterExactTeardown(firstChild, "exit"); + await first.completion; + + const secondChild = generation("second-child", memory); + const second = coordinator.begin(100, 102, parent, secondChild); + coordinator.markChildMayAccessMemory(secondChild); + coordinator.completeAfterExactTeardown(secondChild, "exec"); + await expect(second.completion).resolves.toMatchObject({ + childPid: 102, + reason: "exec", + }); + + expect(() => coordinator.begin(100, 103, parent, firstChild)).toThrow( + "already used", + ); + }); + + it("retains the exact parent generation for stale completion suppression", async () => { + const coordinator = new VforkLifetimeCoordinator(); + const memory = sharedMemory(); + const oldParent = generation("old-parent", memory); + const successor = generation("exec-successor"); + const child = generation("child", memory); + const current = new Map([[110, oldParent]]); + const lifetime = coordinator.begin(110, 111, oldParent, child); + coordinator.markChildMayAccessMemory(child); + + current.set(110, successor); + coordinator.completeAfterExactTeardown(child, "exit"); + const result = await lifetime.completion; + + expect(result.parentGeneration).toBe(oldParent); + expect(current.get(110)).toBe(successor); + expect(current.get(110)).not.toBe(result.parentGeneration); + }); +}); From 8120ee193d2e9c3222c68e0b06a4f483735d0314 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Fri, 31 Jul 2026 14:14:06 -0400 Subject: [PATCH 17/82] Performance: Record affordable-fork design and replay evidence Document the fork memory root cause, the shared-memory vfork design, ordinary-fork admission, rejected alternatives, ABI implications, and the evidence still required for release. Add a component resident-set-size harness that separates worker, module, shared-memory, full-clone, and sparse-clone costs. Its results are scoped to those components and do not claim Homebrew application performance. --- benchmarks/measure-fork-memory-components.mjs | 214 +++++ docs/fork-instrumentation.md | 33 +- .../2026-07-31-affordable-fork-then-exec.md | 834 ++++++++++++++++++ 3 files changed, 1080 insertions(+), 1 deletion(-) create mode 100644 benchmarks/measure-fork-memory-components.mjs create mode 100644 docs/measurements/2026-07-31-affordable-fork-then-exec.md diff --git a/benchmarks/measure-fork-memory-components.mjs b/benchmarks/measure-fork-memory-components.mjs new file mode 100644 index 0000000000..fc8ab7f538 --- /dev/null +++ b/benchmarks/measure-fork-memory-components.mjs @@ -0,0 +1,214 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Worker } from "node:worker_threads"; + +const MIB = 1024 * 1024; +const WASM_PAGE_SIZE = 64 * 1024; +const MEMORY_MIB = 256; +const MEMORY_PAGES = MEMORY_MIB * MIB / WASM_PAGE_SIZE; +const NONZERO_PAGE_STRIDE = 16; +const WORKER_ITERATIONS = 32; +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const workerSource = String.raw` + const { parentPort } = require("node:worker_threads"); + parentPort.once("message", ({ module, memory }) => { + const exports = module ? WebAssembly.Module.exports(module).length : 0; + const marker = memory ? new Uint8Array(memory.buffer)[0] : 0; + parentPort.postMessage({ exports, marker }); + }); +`; + +function rssBytes() { + return process.memoryUsage.rss(); +} + +function delay(ms) { + return new Promise((resolveDelay) => setTimeout(resolveDelay, ms)); +} + +function fillSparsePages(memory) { + const bytes = new Uint8Array(memory.buffer); + let nonzeroBytes = 0; + for (let page = 0; page < MEMORY_PAGES; page += NONZERO_PAGE_STRIDE) { + bytes.fill(0xa5, page * WASM_PAGE_SIZE, (page + 1) * WASM_PAGE_SIZE); + nonzeroBytes += WASM_PAGE_SIZE; + } + return nonzeroBytes; +} + +async function runWorker(payload) { + const worker = new Worker(workerSource, { eval: true }); + try { + const response = new Promise((resolveResponse, rejectResponse) => { + worker.once("message", resolveResponse); + worker.once("error", rejectResponse); + }); + worker.postMessage(payload); + return await response; + } finally { + await worker.terminate(); + } +} + +async function measureWorkerChurn(withModule) { + const module = withModule + ? await WebAssembly.compile(readFileSync( + resolve(repoRoot, "benchmarks/wasm/fork-bench.wasm"), + )) + : undefined; + const baselineRssBytes = rssBytes(); + let peakRssBytes = baselineRssBytes; + const started = performance.now(); + for (let iteration = 0; iteration < WORKER_ITERATIONS; iteration += 1) { + await runWorker(module ? { module } : {}); + peakRssBytes = Math.max(peakRssBytes, rssBytes()); + } + await delay(100); + return { + iterations: WORKER_ITERATIONS, + elapsedMs: performance.now() - started, + baselineRssBytes, + peakRssBytes, + endRssBytes: rssBytes(), + peakGrowthBytes: peakRssBytes - baselineRssBytes, + }; +} + +async function measureSharedMemoryWorker() { + const module = await WebAssembly.compile(readFileSync( + resolve(repoRoot, "benchmarks/wasm/fork-bench.wasm"), + )); + const baselineRssBytes = rssBytes(); + const memory = new WebAssembly.Memory({ + initial: MEMORY_PAGES, + maximum: MEMORY_PAGES, + shared: true, + }); + const nonzeroBytes = fillSparsePages(memory); + const afterTouchRssBytes = rssBytes(); + const started = performance.now(); + const response = await runWorker({ module, memory }); + const afterWorkerRssBytes = rssBytes(); + if (response.marker !== 0xa5) { + throw new Error(`shared-memory Worker read ${response.marker}, expected 165`); + } + return { + memoryBytes: memory.buffer.byteLength, + nonzeroBytes, + elapsedMs: performance.now() - started, + baselineRssBytes, + afterTouchRssBytes, + afterWorkerRssBytes, + workerGrowthBytes: afterWorkerRssBytes - afterTouchRssBytes, + }; +} + +function pageIsZero(words, firstWord, wordsPerPage) { + const end = firstWord + wordsPerPage; + for (let word = firstWord; word < end; word += 1) { + if (words[word] !== 0n) return false; + } + return true; +} + +async function measureClone(sparse) { + const baselineRssBytes = rssBytes(); + const parent = new WebAssembly.Memory({ + initial: MEMORY_PAGES, + maximum: MEMORY_PAGES, + shared: true, + }); + const nonzeroBytes = fillSparsePages(parent); + const parentRssBytes = rssBytes(); + const started = performance.now(); + const child = new WebAssembly.Memory({ + initial: MEMORY_PAGES, + maximum: MEMORY_PAGES, + shared: true, + }); + let copiedBytes = parent.buffer.byteLength; + if (sparse) { + copiedBytes = 0; + const sourceWords = new BigUint64Array(parent.buffer); + const sourceBytes = new Uint8Array(parent.buffer); + const childBytes = new Uint8Array(child.buffer); + const wordsPerPage = WASM_PAGE_SIZE / BigUint64Array.BYTES_PER_ELEMENT; + for (let page = 0; page < MEMORY_PAGES; page += 1) { + if (pageIsZero(sourceWords, page * wordsPerPage, wordsPerPage)) continue; + const start = page * WASM_PAGE_SIZE; + childBytes.set(sourceBytes.subarray(start, start + WASM_PAGE_SIZE), start); + copiedBytes += WASM_PAGE_SIZE; + } + } else { + new Uint8Array(child.buffer).set(new Uint8Array(parent.buffer)); + } + const elapsedMs = performance.now() - started; + await delay(100); + const cloneRssBytes = rssBytes(); + if (new Uint8Array(child.buffer)[0] !== 0xa5) { + throw new Error("clone did not preserve the nonzero marker"); + } + return { + memoryBytes: parent.buffer.byteLength, + nonzeroBytes, + copiedBytes, + elapsedMs, + baselineRssBytes, + parentRssBytes, + cloneRssBytes, + cloneGrowthBytes: cloneRssBytes - parentRssBytes, + }; +} + +async function runCase(name) { + if (typeof globalThis.gc === "function") { + throw new Error("component measurements must run without --expose-gc"); + } + switch (name) { + case "worker-only": return measureWorkerChurn(false); + case "module-worker": return measureWorkerChurn(true); + case "shared-memory-worker": return measureSharedMemoryWorker(); + case "full-clone": return measureClone(false); + case "sparse-clone": return measureClone(true); + default: throw new Error(`unknown component measurement: ${name}`); + } +} + +const selectedCase = process.argv[2]; +if (selectedCase) { + process.stdout.write(`${JSON.stringify(await runCase(selectedCase))}\n`); +} else { + const results = {}; + for (const name of [ + "worker-only", + "module-worker", + "shared-memory-worker", + "full-clone", + "sparse-clone", + ]) { + const child = spawnSync(process.execPath, [fileURLToPath(import.meta.url), name], { + cwd: repoRoot, + encoding: "utf8", + maxBuffer: 1024 * 1024, + }); + if (child.status !== 0) { + throw new Error( + `${name} failed (${child.status}): ${child.stderr || child.stdout}`, + ); + } + results[name] = JSON.parse(child.stdout.trim()); + } + process.stdout.write(`${JSON.stringify({ + timestamp: new Date().toISOString(), + node: process.version, + platform: process.platform, + arch: process.arch, + memoryMiB: MEMORY_MIB, + nonzeroPageStride: NONZERO_PAGE_STRIDE, + results, + }, null, 2)}\n`); +} diff --git a/docs/fork-instrumentation.md b/docs/fork-instrumentation.md index 6fdddc3bb4..ef72e41d9a 100644 --- a/docs/fork-instrumentation.md +++ b/docs/fork-instrumentation.md @@ -388,6 +388,37 @@ main nodes may occupy several mappings and may contain a frame larger than one WebAssembly page. The coordinator completes and validates both continuations before it sends `SYS_FORK`. +### Borrowed replay foundation (not guest-visible vfork) + +A genuine vfork child cannot run ordinary copied activation or side-module +replay over the parent's live `Shared WebAssembly.Memory`. The ABI 43 host now +has an unwired foundation for that future launch mode. A fresh child Worker +validates the parent's process-wide module-state arena as borrowed, rebuilds +the complete activation registry, and gives every active main or side +activation its own child-private fixed prefix. Replay reads the parent's +committed frame nodes and recipe records but never marks nodes consumed, +releases mappings, clears the process launch anchor, or deallocates the +module-state arena. Failure midway through attachment detaches every child +controller so the suspended parent can still replay the original transaction. + +Dynamic-linker reconstruction has a matching fail-closed mode. It accepts +only shared Memory, passive data segments, and a complete loader transaction. +For ordinary wasm-ld modules it suppresses only a start function exported as +`__wasm_init_memory`; an arbitrary start or active data segment is rejected +before instantiation. Complete replay does not invoke relocations or +constructors because the borrowed address space already contains the parent's +live initialized bytes. ABI 43 instrumented side modules already lower their +start and active segments into the explicit staged bootstrap described above. +An in-flight bootstrap, relocation, or constructor is rejected because guest +code at that boundary may write arbitrary shared process memory. + +The ordinary no-option path remains copied fork replay and retains independent +address-space ownership. These primitives do not make guest-visible vfork +functional: Kandelo's libc `vfork()` is still an alias for `fork()`, and no +kernel fork mode, child launch protocol, or parent suspension path calls the +borrowed APIs. That remaining semantic/protocol work must be explicit in the +ABI 43 batch and snapshot rather than hidden under the existing fork contract. + ## Save buffer format All values are little-endian and all records are eight-byte aligned. `P` is @@ -428,7 +459,7 @@ callbacks pointed at the borrowed nodes. Making only the host replay cursor read-only is insufficient: passing the owner's prefix would overwrite the owner's active-frame word. Borrowed replay must leave node states and mappings untouched so the owner can later replay and release them. This is an internal -host invariant; it does not make ABI 42 `vfork()` functional. +host invariant; it does not make ABI 43 `vfork()` functional. This does not introduce a new linked-frame encoding. `fixed_prefix_size` has always been a module-specific value in the version-1 descriptor, and each node diff --git a/docs/measurements/2026-07-31-affordable-fork-then-exec.md b/docs/measurements/2026-07-31-affordable-fork-then-exec.md new file mode 100644 index 0000000000..978c3bb901 --- /dev/null +++ b/docs/measurements/2026-07-31-affordable-fork-then-exec.md @@ -0,0 +1,834 @@ +# Affordable fork-then-exec design and measurements — 2026-07-31 + +## Decision + +Kandelo's excessive fork-then-exec memory cost is a platform defect, not a +CRuby defect. Ordinary `fork()` must retain its independent-address-space +semantics. The platform-owned endpoint for eligible upstream software is a +genuine `vfork()` implementation with these properties: + +- it creates a distinct kernel Process and a distinct child Worker; +- the child Worker temporarily borrows the parent's existing + `Shared WebAssembly.Memory` rather than allocating or copying one; +- the calling parent thread remains parked until that child successfully + commits `exec()` or completes `_exit()`/signal-death teardown; +- other parent pthreads remain runnable; +- the child owns a distinct syscall channel, Wasm instance, mutable imported + globals, and host continuation controller; and +- child replay reads, but does not consume or deallocate, the parent's saved + fork continuation. The parent remains its sole owner and consumes it when + resumed. + +A same-Worker handoff was rejected. It would couple two Process identities to +one import closure and instance-global state, make pthread callers and side +modules substantially harder to isolate, and make a child `exec()` retire the +Worker that must later resume the parent. + +This record establishes that architecture but does **not** claim that genuine +`vfork()` is implemented. The integration branch now carries the proposed ABI +43 activation-state protocol, but it still has no vfork guest import or fork +mode, kernel marker, child launch protocol, libc selection path, or parent +suspension path. Those semantic and structural choices must be explicit in the +same ABI 43 batch and snapshot, with the required approval, rather than hidden +under the copied-fork contract. + +Five independently reviewable foundations are implemented now: + +- ordinary `fork()` performs retired-memory admission before constructing or + copying child memory and returns `EAGAIN` when the retirement ledger is + saturated; +- the process-memory allocator can retain explicitly counted aliases to one + backing and retires that backing only after the final exact alias fence; +- the linked-continuation controller can replay borrowed frame nodes without + consuming or releasing them while giving generated Wasm a child-private + mutable prefix; +- a cross-host lifetime coordinator admits only one borrower per address + space and distinguishes exact parent resumption, safe pre-launch failure, + and ambiguous termination that requires whole-address-space containment; and +- ABI 43 activation and dynamic-linker reconstruction can borrow the sealed + process manifest and parent frame nodes while giving every active main/side + activation a private prefix and refusing loader-controlled memory writes. + +The last four are not connected to a guest-visible vfork path in ABI 43. They +prove and enforce host ownership, replay, and terminal-gating primitives the +selected architecture needs; they do not by themselves implement the guest +mode, child launch, or parent-channel completion. + +Sparse exact cloning reduced resident set size (RSS) in a controlled sparse +memory case, but increased scan/copy time and has no real Homebrew result. It +is therefore not selected. Worker and module churn were measurable but small +next to the address-space copy, so worker rotation or module-specific caching +is not selected as the primary architecture. + +## Scope and source state + +The source baseline was commit +`2f5b3c4118c7b38f28ff60c7c8a4da89e5c67f43` on +`emdash/better-affordable-forking-8v7si`, with ABI 42. The only pre-existing +dirty state was the `tests/sortix/os-test` submodule and was left untouched. + +The investigation covered, in order: + +1. genuine `vfork()` semantics; +2. pre-copy fork admission and retirement accounting; +3. sparse exact cloning; +4. Worker/module churn and upstream-selected process creation; and +5. larger alternatives only where the preceding evidence left a gap. + +The reviewable implementation slices on this branch are: + +- `6830562e6`, pre-copy ordinary-fork admission; +- `bdbd9f641`, exact refcounted aliases to one process-memory backing; +- `32c563f20`, borrowed linked-frame replay with a private mutable prefix; +- `8accc5ed3`, exact-generation shared-vfork lifetime gating; +- `2822cb109`, separate-Worker borrowed replay in Chromium, Firefox, and + WebKit; +- `ab2873a25`, the ABI 43 forward-port of borrowed process-wide activation and + write-free side-module reconstruction; and +- `6e71ac438`, ABI 43 main-continuation and wasm-ld reconstruction proofs in + Chromium, Firefox, and WebKit; and +- `6f481ba85`, ABI 43 active-side-continuation borrowing in separate Workers on + Chromium, Firefox, and WebKit. + +All slices after the ordinary-fork admission guard are deliberately unwired +foundations or component proofs. The guest import, libc, kernel state, Worker +protocol, Node/browser lifecycle integration, and fork-instrument seed changes +remain in the coordinated ABI series. + +The temporary CRuby change on +[PR #1166](https://github.com/Automattic/kandelo/pull/1166) was inspected from +its separate Git ref only to define removal. It was not checked out, modified, +generalized, or used as the platform design. + +## Root cause + +Kandelo's current ordinary fork transaction is: + +1. unwind the caller through `wasm-fork-instrument` and serialize its linked + continuation into guest mappings; +2. clone the authoritative Rust Process state, including the calling task's + signal mask, file descriptor and open-file-description (OFD) state, current + directory, credentials, process group/session state, and wait parentage; +3. construct a new `Shared WebAssembly.Memory` at the parent's complete + current length; +4. synchronously copy every byte, including the linked continuation; and +5. start a child Worker and replay the copied continuation. + +That is correct for ordinary fork isolation but materially different from a +native copy-on-write fork. A long-lived Ruby process with a large linear +memory can create hundreds of children that immediately discard the clone in +`exec()`. Kandelo writes the complete new mapping before the child runs, and +the JavaScript engine independently decides when unreachable retired shared +backings return physical memory. Exact Kandelo ownership and eventual engine +reclamation therefore do not prevent a transient allocation rate from +exceeding renderer capacity. + +The July 30 stock-Homebrew measurements in +[`2026-07-30-node-process-worker-init-ownership.md`](2026-07-30-node-process-worker-init-ownership.md) +separated a Node `workerData` retention issue from this underlying cost. The +one-shot initialization transport reduced maximum RSS from 14,665,089,024 to +12,883,050,496 bytes, but the surviving peak remained far beyond the live +process-memory ledger. The ownership fix was material and correct; it did not +make eager fork copies affordable. + +## Genuine vfork design + +### Required semantics + +The applicable +[Open Group `vfork()` contract](https://pubs.opengroup.org/onlinepubs/7908799/xsh/vfork.html) +permits the child to share the caller's address space only as a constrained +prelude to successful `exec()` or `_exit()`. The child has a distinct Process +identity and return value, while the calling parent thread cannot return from +`vfork()` until that lifetime ends. Misuse by the child does not permit +Kandelo to reinterpret unrelated ordinary forks as vforks. + +Kandelo should expose that distinction explicitly. The proposed guest import +is: + +```c +int32_t kernel_fork(int32_t mode); +``` + +with snapshot-owned constants for ordinary fork and vfork. `_Fork()` and +`fork()` pass the ordinary mode. `vfork()` passes the vfork mode directly and +does not run `pthread_atfork` handlers. Both modes retain one instrumenter seed +and one call graph. + +A new `kernel_vfork` import was rejected because every module and side module +would need a multi-seed call-graph union plus a new artifact-role claim. A +guest memory marker or getter was rejected because it makes invocation mode +implicit, creates another shared-memory race to validate, and is still an ABI +semantic change. Reusing `SYS_VFORK` only after unwind cannot work by itself: +the host must know which continuation operation the imported call initiated. + +### Separate child Worker sharing one memory + +Kandelo already shares one process memory across pthread Workers. A vfork +child should use the same engine capability but remain a separate Process +Worker and Wasm instance: + +```text +parent Process / caller Worker + | + | kernel_fork(VFORK), unwind, SYS_VFORK + v +kernel child Process + vfork lifetime coordinator + | + +---- retained alias to the same Shared WebAssembly.Memory + | + +---- child Worker / child Wasm instance + separate channel and __channel_base + private replay prefix in its control slot + borrowed continuation replay + | + +---- successful exec or exact child teardown + release child alias, resolve parent syscall +``` + +The parent Worker is already awaiting the fork syscall after its unwind. The +kernel Worker's `onFork` promise can remain pending for the vfork lifetime; +the original channel is completed with the child PID only after the +coordinator resolves. A pthread caller parks only that Worker. Sibling parent +threads continue on their own channels, matching the required calling-thread +suspension rather than freezing the whole process. + +The child gets a newly reserved host control slot in the shared memory. Its +channel cannot be the caller's channel: syscall arguments, results, signal +delivery fields, and blocking state would otherwise overwrite the suspended +parent. The slot must be allocated from the parent generation's shared thread +slot allocator and reserved at the same range in the child Process map. That +prevents a runnable sibling pthread from reusing the bytes. The generation +owns the reservation until exact child teardown releases it. Modern process +objects already import a per-instance mutable `env.__channel_base`; the child +instance receives its own value. Stale objects that depend on the legacy +shared-memory channel-base fallback must fail the new ABI epoch rather than +enter vfork. + +The slot's fork-save/scratch page also supplies the child-private replay +prefix described below. The main prefix and an active side-module prefix must +fit before launch; otherwise vfork returns `EAGAIN` before creating a borrower. +The serialized descriptor already gives the exact prefix size. A future +implementation must not assume every possible pair fits merely because normal +programs use a small prefix. + +The host memory allocator needs retained leases for this one explicit sharing +case. A memory record is charged once by actual `memory.buffer.byteLength`, +holds an alias count, and enters retirement only after its final parent/child +alias crosses the exact quiescence fence. A forced release is remembered on +the shared record so a later final release cannot accidentally classify an +ambiguous generation as cooperatively quiescent. + +### Borrowed continuation replay + +The current copied-memory child owns its continuation copy. During replay, +`LinkedForkContinuation.nextFrame()` changes each node from `COMMITTED` to +`CONSUMED`, and `finishReplayAndRelease()` unmaps every chunk. Performing those +writes in a shared vfork memory would destroy the parent's only continuation +before it can resume. + +Vfork therefore needs a read-only borrowed replay mode: + +- attach and fully validate the same linked chunk chain; +- maintain replay cursors only in the child Worker's JavaScript state; +- require each node to remain `COMMITTED` but never write `CONSUMED`; +- copy the module's fixed prefix into child-owned scratch and pass that address + to `wpk_fork_rewind_begin`; +- finish by detaching the child controller without `munmap`; and +- leave the root anchor and all chunks for the suspended parent to replay, + consume, and release. + +The private prefix is required, not optional. A first two-instance experiment +made only the JavaScript replay cursor read-only and failed: generated replay +preambles still store each address returned by `__wpk_fork_frame_next` at +offset zero of `_wpk_fork_buf`, changing the parent's root prefix. Passing a +copy of the fixed prefix to the child instance isolates that generated write +while frame callbacks continue returning payloads from the borrowed chain. + +The integrated host regression uses the real ABI 43 instrumenter, a shared +`WebAssembly.Memory`, and a fresh Node Worker/Wasm instance. It snapshots every +parent continuation chunk and the sealed module-state arena, lets the Worker +rebuild the real activation registry and replay with allocation/deallocation +callbacks that throw if called, verifies the snapshots remain byte-identical, +and then successfully replays and releases the same transaction in the parent +instance. + +The matching Playwright component regression sends the compiled instrumented +module and the same shared Memory to a module Worker. Chromium, Firefox, and +WebKit each replayed through a private prefix without child allocation or +release, left every parent chunk byte-identical, and then allowed the parent +instance to replay and release the chain. This is cross-engine component +evidence for safe separate-Worker replay. It is not yet a guest-visible vfork +process-lifecycle test. + +The host now has the corresponding unwired process-wide side-module +foundation. Borrowed dynamic-linker replay reconstructs each complete archive +entry at the parent's exact memory and table bases while creating fresh +Worker-local instances, tables, imported globals, symbol maps, and activation +controllers. After all activations exist, the process manifest identifies the +exact active set and continuation root for each main or side activation. The +child copies each mutable fixed prefix into separately reserved scratch, reads +the owner nodes without consuming them, and detaches both continuation and +module-state controllers without clearing the parent's process launch anchor. + +Instantiation itself is a write-free boundary. Standard Kandelo wasm-ld side +modules use passive data segments and export their guarded start as +`__wasm_init_memory`. Borrowed replay first compiles the original bytes for +full Wasm validation, rejects an active data segment or unrecognized start, +strips only that recognized start, and relies on complete replay's existing +relocation/constructor suppression. ABI 43 instrumented modules already carry +passive segments and an explicit staged bootstrap. Any in-flight loader stage +is rejected because resuming guest bootstrap, relocation, or constructor code +could write the suspended parent's Memory. + +Focused Node tests prove that a failed second-prefix reservation rolls back the +first attached activation, no borrowed mapping is released, the sealed arena +and frame nodes remain byte-identical, and the parent can subsequently replay. +A current-SDK wasm-ld module with mutable static data reconstructs without +changing its live bytes. Matching Playwright tests send the module, ABI 43 +manifest, and shared Memory to separate module Workers; Chromium, Firefox, and +WebKit all observe the live values while leaving the parent state unchanged. A +second cross-engine fixture instruments a real `env.fork` side call graph. Its +child borrows only the active side activation through the process manifest and +a private mutable prefix, while the parent bytes remain unchanged and the +parent can later consume and release the same frames. This closes the +side-controller component proof; it is still not a guest-visible +process-lifecycle test. + +This code remains disconnected from process launch. The eventual vfork child +must select borrowed dynamic-linker reconciliation, reserve all activation +prefixes independently, and avoid `resetForkChildLock()`, because its lock +bytes still belong to the suspended parent. The existing no-option replay and +lock reset remain the ordinary copied-fork path. + +The child's independent syscall slot cannot be used as the inherited dlopen +archive anchor. Worker initialization needs a separate inherited process +control offset so side-module reconstruction reads the parent's archive while +all child syscalls use the new channel. + +No new linked-frame bytes are required by this design. Borrowed versus owned +node traversal is host controller state, and `fixed_prefix_size` already +describes the exact bytes to copy. Worker initialization nevertheless needs +distinct owner-continuation and private-replay-prefix addresses. Vfork is also +an ABI semantic change because old hosts would consume shared frames and old +programs cannot request the mode safely. + +### Kernel state and process behavior + +`ProcessTable::fork_process_for_caller()` is the right kernel-state starting +point for both modes. It already: + +- validates the exact calling task; +- creates a globally unique PID and one-task child; +- inherits the caller's blocked signal mask; +- copies descriptor and OFD metadata while retaining kernel-global backing + references; +- copies cwd, credentials, process group, session, umask, limits, and signal + dispositions; +- preserves parentage and wait/reaping state; and +- excludes other parent pthread tasks from the child. + +The vfork difference is host address-space ownership and caller suspension, +not an alternate fake Process record. The child retains its independent +kernel metadata while borrowing the bytes. Descriptor actions, cwd changes, +credential checks, process-group changes, `exec()` commit, signal death, +zombie state, and `waitpid()` therefore continue through the ordinary kernel +path. + +The coordinator must add an explicit vfork-child state so operations that +would create ambiguous shared-memory ownership fail before mutation. The +initial implementation should return `EAGAIN` for: + +- another active vfork from the same address space; +- nested vfork or ordinary fork from a vfork child; +- pthread creation by a vfork child; and +- host operations that require allocating another owner for the borrowed + address space. + +Those calls are outside the permitted vfork-child pre-exec use. Returning a +truthful failure is safer than silently treating them as ordinary fork or +allowing channel/control collisions. Sequential vfork calls after the prior +child completes remain supported and must be tested. + +For nested `fork()`/`vfork()`, the vfork-child Worker must reject directly in +its `kernel_fork` import before `beginUnwind()`, frame reservation, anchor +writes, or side-module lock changes. The kernel Process marker is a second +defense for raw host-intercepted requests. Rejecting only after continuation +serialization could allocate mappings that the parent does not know about or +overwrite the continuation it still owns. + +### Exec, exit, trap, crash, and signal completion + +A failed `exec()` is not the end of a vfork lifetime. It returns its real +errno on the child channel; the child may report the failure and call +`_exit()`. The parent remains parked, the child retains its memory alias, and +the saved continuation remains borrowed. + +A successful `exec()` becomes the completion boundary only after: + +1. the replacement Process generation is committed; +2. the old vfork child Worker reports `exec_retired` and + `memory_quiescent` for the exact shared generation; +3. the child continuation controller has detached without writes or unmap; +4. the child drops its retained alias; and +5. the parent generation is still the generation whose channel is parked. + +`_exit()` and signal death use the equivalent exact process teardown fence. +The existing Worker error/exit safety net converts uncaught traps and crashes +to signal-style process death; vfork coordination must settle from that one +terminal path, not from a second best-effort listener. + +Signals selected for the parked parent caller must remain pending without +completing or reusing its fork channel, then follow the ordinary delivery path +after vfork resolves. Process-directed signals may still select another +eligible parent task. The child has its copied signal mask and dispositions +but independent pending state; a terminating child signal completes the vfork +lifetime through exact child teardown before the caller resumes. + +An unacknowledged forced termination is different. Kandelo cannot prove that +the terminated Worker stopped touching shared memory. It must not resume the +parent into that backing. The safe containment policy is to terminate the +whole address-space owner group (parent Process Workers and the borrower), +record a loud host diagnostic, and complete process death. Leaving the parent +parked forever or resuming it after an ambiguous `Worker.terminate()` would +both violate the platform contract. + +If another parent thread commits parent `exec()` or exit while the caller is +parked, that transition invalidates the pending parent channel. Retained +leases keep the old backing alive for the child, but the coordinator suppresses +the stale parent completion and releases the parent alias through the normal +generation ledger. + +### Host lifetime coordinator foundation + +The shared `VforkLifetimeCoordinator` records exact parent and child generation +objects and keys active borrowing by `WebAssembly.Memory`, not numeric PID. It +admits only one active lifetime per address space, so overlapping calls by +sibling threads and nested calls by the borrower receive `EAGAIN`. A completed +child generation cannot be reused, while a later child generation can begin a +new sequential lifetime over the same parent Memory. + +The launch path has an explicit point of no return. Before +`markChildMayAccessMemory()`, a setup failure may return an errno because no +child realm could have touched the backing. The host must call that method +immediately before Worker start. After it, only exact exec/exit/signal/trap +teardown can produce a `resume-parent` disposition. A missing quiescence fence +produces `contain-address-space`, never a normal return. Failed exec merely +increments diagnostic state and leaves the lifetime pending. + +The coordinator retains the exact parent generation in every disposition. +The eventual Node/browser integration must still compare it with the current +PID registration before completing the parked channel; this preserves the +existing stale-generation suppression when a sibling pthread execs or exits +the parent. The 13 focused state-machine tests cover an unresolved caller gate +with unrelated event-loop progress, repeated failed exec, all exact terminal +reasons, pre-launch rollback, pre-launch signal death, ambiguous termination, +competing terminal notifications, overlapping/nested `EAGAIN`, distinct +concurrent address spaces, sequential reuse, child-generation reuse rejection, +and stale-parent identity. + +This coordinator is deliberately unwired until the ABI mode and Process marker +are coordinated. Existing async `onFork` completion is the actual caller-thread +parking transport, and existing Worker-quiescence and exact-generation detach +ledgers remain the source of terminal evidence. + +### Test matrix for the vfork series + +The vfork implementation is not complete until tests prove all of the +following on Node and the applicable browser hosts: + +- no `WebAssembly.Memory` constructor and no full-memory copy occur on vfork; +- a main-thread caller cannot pass the call site before child exec/_exit; +- a pthread caller parks while a sibling parent thread continues; +- the child channel cannot change the caller's pending channel bytes; +- failed exec returns to the child and leaves the parent parked; +- successful exec, `_exit`, caught signal death, trap, and Worker crash each + settle exactly once and cannot wedge or prematurely resume the parent; +- descriptors/OFDs, cwd, credentials, signal masks/dispositions, process + groups, parentage, zombie state, and wait/reaping match the kernel contract; +- repeated sequential calls work and unsupported overlapping/nested calls + return `EAGAIN` without a child or leaked slot; +- side-module replay borrows both chains and restores the parent archive; +- fork-instrument abort replay and resource-failure rollback leave the parent + continuation usable; and +- Chromium, Firefox, and WebKit run the same lifecycle wherever their Worker + path applies. + +## Pre-copy admission for ordinary fork + +Before this change, `acquireForForkSnapshot()` deliberately bypassed the +retired-generation gate. The host first constructed and copied the complete +child memory, then asynchronously waited to launch its Worker. That preserved +snapshot timing, but it defeated the purpose of retirement admission: the +expensive allocation had already happened. + +Fork now uses the allocator's normal synchronous admission path. The check +runs after refreshing all live records to their actual current byte lengths +and before `new WebAssembly.Memory(...)`. Saturation is based on both retired +count and retired actual bytes. The default thresholds are: + +- count: `max(4, min(32, maxWorkers * 2))`; and +- bytes: `min(maxProcessMemoryBytes, 256 MiB)`. + +Retirement uses the actual final buffer length, including unmediated guest +growth. Exact Worker/channel/framebuffer teardown remains the authority for +dropping Kandelo's strong alias. `FinalizationRegistry` remains optional +telemetry. The separate retirement backpressure record lasts for a bounded +50 ms by default; spawn and exec may wait for the bounded allocator retry +window, while fork cannot yield before its snapshot and returns `EAGAIN` +immediately when saturated. + +This is a burst guard, not a portable physical-memory ceiling. JavaScript +provides neither a collection deadline nor physical backing usage. Already +live generations can retire together above the thresholds, and a backing may +remain resident after its time-bounded admission charge expires. The guard is +still necessary: it prevents the known ordering error where a saturated +ledger admitted one more complete clone before checking anything. + +The regression test constructs a saturated four-page retired debt, spies on +the `WebAssembly.Memory` constructor, and verifies that fork throws +`ProcessMemoryRetirementBacklogError` (`errno == EAGAIN`) with zero constructor +calls. Node and browser fork handlers share the same helper, and both retain +the required clone-before-first-`await` ordering after successful admission. + +## Measurements + +### Environment + +- Host: Apple Silicon Mac17,6, 48 GiB RAM, macOS 26.6 (25G72) +- Node: v24.15.0 from `scripts/dev-shell.sh` +- Playwright: repository lockfile installation +- Chromium: 149.0.7827.55 +- Firefox: 151.0 +- WebKit: 26.5 (Playwright WebKit, not shipping Safari) +- Source: the baseline commit above plus the admission and measurement changes + documented here + +RSS is process resident set size. It includes engine heaps, JIT code, Worker +stacks, and shared mappings; it is not a direct count of private process-memory +pages. + +### Component isolation + +The committed harness +[`benchmarks/measure-fork-memory-components.mjs`](../../benchmarks/measure-fork-memory-components.mjs) +was run twice: + +```sh +bash scripts/dev-shell.sh \ + node benchmarks/measure-fork-memory-components.mjs +``` + +Every case runs in a fresh subprocess without `--expose-gc`. Worker cases use +32 sequential Workers. Module cases transport the real 56,987-byte +`fork-bench.wasm` compiled module. Memory cases use a 256 MiB shared memory +with one complete nonzero Wasm page out of every 16 (16 MiB nonzero). + +| Case | Run A | Run B | Interpretation | +|---|---:|---:|---| +| Worker-only peak growth | 13.297 MiB | 13.313 MiB | bounded Worker cost | +| Module-Worker peak growth | 13.563 MiB | 13.906 MiB | only 0.266–0.593 MiB above Worker-only | +| Shared-memory Worker growth | 10.969 MiB | 10.984 MiB | no second 256 MiB copy | +| Full-clone RSS growth | 496.344 MiB | 496.344 MiB | read faults parent and writes child | +| Sparse-clone RSS growth | 262.531 MiB | 262.594 MiB | scan faults parent; writes 16 MiB child subset | +| Full-clone elapsed | 34.186 ms | 36.970 ms | copies 256 MiB | +| Sparse-clone elapsed | 85.694 ms | 81.976 ms | scans 256 MiB, copies 16 MiB | + +The compiled module is reused in Kandelo's normal fork path, and the measured +module transport increment is tiny relative to a complete large address-space +copy. Sharing the already-touched sparse memory with another Worker adds only +the Worker-scale increment and does not commit another 256 MiB mapping. These +results support the separate-Worker vfork architecture and reject module +churn as the primary cause. + +Full clone increases RSS by almost twice the logical memory size in this +synthetic case because the copy reads zero pages from the previously sparse +parent and writes every page in the child. Sparse clone avoids most child +writes and saves about 233.8 MiB relative to full clone, but it still scans and +faults the complete parent mapping and takes 2.22–2.51 times as long. This is a +component measurement, not application evidence. Sparse clone remains +unselected until a real large guest workload shows lower peak RSS without an +unacceptable latency or CPU cost on Node and browsers. + +### Current lifecycle measurements + +The Node process-lifecycle suite ran three rounds: + +```sh +bash scripts/dev-shell.sh npm run bench -- \ + --suite=process-lifecycle --rounds=3 +``` + +| Metric | Median | +|---|---:| +| hello start | 194.61 ms | +| fork + child exit | 52.81 ms | +| exec | 253.48 ms | +| pthread clone | 49.47 ms | +| non-forking posix_spawn | 50.28 ms | + +This latency microbenchmark does not reproduce Ruby's memory size or +Homebrew's process count. It proves only the selected artifact paths and their +local medians. + +The repository's real Node retirement fixture then ran 48 sequential 8 MiB +children after warm-up. RSS rose from 354.484 MiB to a 482.547 MiB early peak +and ended at 316.563 MiB. Its late slope was -4.674 MiB per child, late growth +was -165.984 MiB, and guest stderr/host diagnostics were empty. That confirms +current exact-fenced generations remain collectible in this run. It does not +remove the pre-reclamation peak that motivates vfork. + +The browser process-retirement integration ran 100 real fork/exec iterations +per engine and passed in Chromium, Firefox, and WebKit. This validates the +ordinary fork/exec and retirement path affected by pre-copy admission. It is +not evidence for unimplemented vfork behavior or a browser RSS ceiling. + +## Upstream CRuby integration + +Kandelo's Ruby package is version 4.0.5 from the checksum-pinned upstream +tarball. Upstream `process.c` already contains the desired selection logic in +`retry_fork_async_signal_safe()` when `HAVE_WORKING_VFORK` is defined: + +```c +if (!has_privilege()) + pid = vfork(); +else + pid = rb_fork(); +``` + +`has_privilege()` rejects effective uid 0, uid/gid mismatches, saved-id +mismatches, and `issetugid()`. Thus a normal Kandelo Homebrew process running +as uid 1000 naturally selects upstream vfork for this eligible async-safe +fork/exec path. Root and other privileged shapes intentionally retain +ordinary fork. No Ruby-specific command classification is needed. + +The worktree-local SDK already defaults `ac_cv_func_vfork=yes`, but +`packages/registry/ruby/build-ruby.sh` overrides it with +`ac_cv_func_vfork=no`. That override is truthful today because Kandelo's +`vfork()` is only an alias for fork. It must change to `yes` only after the +platform implementation and conformance evidence land in the coordinated ABI +epoch. + +PR #1166 adds `kandelo-posix-spawn.patch` and applies it to `process.c`. Its +tests correctly constrain the temporary exception to command shapes that the +current spawn contract can reproduce. That source patch is not part of the +vfork design and must be deleted, not generalized. + +## ABI and release impact + +Changing `kernel.kernel_fork` from `() -> i32` to `(i32) -> i32` is an +incompatible process ABI change. The coordinated implementation must include: + +- an `ABI_VERSION` bump from the active batch's base; +- regenerated `abi/snapshot.json` and generated TypeScript constants; +- libc `_Fork()`/`fork()`/`vfork()` callers with explicit mode constants; +- host import closures for main and pthread Workers; +- side-module `env.fork` mode propagation; +- `ForkLaunchRequest` and Worker-init protocol metadata for vfork, inherited + process-control offset, and borrowed replay; +- fork-instrument tests proving a parameterized seed call preserves its mode + through unwind/replay; +- loud rejection of stale ABI 42 programs, packages, and VFS images; and +- rebuild/publish of every ABI-bound kernel, program, package archive, and VFS + artifact. + +As of this record, draft +[PR #1096](https://github.com/Automattic/kandelo/pull/1096) already owns the +ABI 43 activation-state-safe fork epoch, while draft +[PR #1098](https://github.com/Automattic/kandelo/pull/1098) also carries ABI 43 +host/kernel work. Brandon must choose the exact agreed base and whether vfork +joins that epoch or follows it. This branch must not independently claim ABI +43 or restack either draft. + +The linked continuation descriptor does not need a new serialized field for +borrowed replay, because it already carries `fixed_prefix_size`. The Worker +protocol does need separate addresses for the parent's continuation root and +the child's mutable prefix. The ABI bump is still mandatory: old host +semantics would consume the shared chain and old libc cannot express the mode. + +The current pre-copy admission change alters no guest-visible structure, +syscall number, import signature, frame encoding, or host/kernel protocol. Its +`EAGAIN` behavior uses the already-defined fork launch failure path. The ABI +snapshot check must nevertheless run for this series. + +## Rejected or deferred alternatives + +### Treat every fork as vfork + +Rejected. The host cannot know at fork time that a child will exec, and +ordinary child writes must be isolated even when an application later execs. + +### Same-Worker sequential child + +Rejected in favor of a separate Worker. It entangles channel closures, +instance globals, pthread entry, dynamic-link state, crash containment, and +Worker retirement across two Process identities. + +### Sparse exact clone + +Deferred. Synthetic RSS improved, but scan time regressed and no real +Homebrew, Node/browser, or cross-engine application result exists. + +### Worker or module rotation + +Rejected as the primary fix. The measured Worker/module increment is small +beside full clone commitment, current workers already have exact teardown, and +July 30 removed the separate `workerData` retention path. Rotation cannot +provide copy-on-write semantics or remove the eager copy. + +### Broader package-specific posix_spawn rewrites + +Rejected. Upstream CRuby already exposes the correct vfork selection hook. +Expanding #1166 would move platform policy into a package and still leave +other upstream fork/exec users exposed. + +### Software dirty-page tracking + +Deferred. Exact dirty tracking would require instrumenting every guest store, +bulk-memory operation, memory growth, and side module consistently, then +proving that a missed write cannot corrupt fork isolation. Sparse scanning is +already costly without that instrumentation. + +### Move the active-frame cursor into a new Wasm global + +Not required for the selected design. A per-instance cursor would remove the +generated store at offset zero of the module prefix, but it would add another +fork-instrument semantic change during an already active ABI batch. The +module's exact prefix is small relative to its address space, its size is +already declared, and a private copy has passed real separate-Worker replay. +The active ABI work may still choose a global if it simplifies its new frame +model, but vfork must not depend on that larger rewrite. + +### Future Wasm memory-control or page-mapping features + +Monitor, but do not design current correctness around them. Current browser +WebAssembly has no portable clone or copy-on-write primitive. A future +standardized mapping facility could optimize ordinary fork behind the same +ownership contract. + +### Explicit garbage collection or renderer reset + +Rejected. Collection timing is not a portable API or correctness boundary. +Resetting a renderer/kernel/Worker would discard legitimate machine state and +would only mask ownership or admission defects. + +## PR #1166 removal and proof plan + +After the coordinated vfork series is complete: + +1. Delete `packages/registry/ruby/patches/kandelo-posix-spawn.patch` and its + application block from `build-ruby.sh`; do not replace it with another + Ruby command classifier. +2. Remove tests that assert the Kandelo-only Ruby patch and retain/rewrite + their useful process semantics as platform vfork tests. +3. Remove the recipe's `ac_cv_func_vfork=no` override so the SDK's truthful + working-vfork probe enables upstream `HAVE_WORKING_VFORK`. +4. Build from the checksum-pinned upstream 4.0.5 source through the normal + Kandelo SDK, libc, fork-instrument, package, and ABI path. Verify the source + tree has no #1166 patch residue. +5. Add an upstream-selection fixture that runs as uid 1000 and proves + `retry_fork_async_signal_safe()` reaches `SYS_VFORK` with no full memory + allocation/copy. Run the matched root/privileged fixture and prove it + reaches ordinary `SYS_FORK` and still clones independently. +6. Exercise failed exec, successful exec, `_exit`, trap/crash, descriptors, + signals, cwd/credentials, process groups, main/pthread callers, sequential + repetition, rejected nesting, and dynamic side modules through platform + tests before using Ruby as application evidence. +7. Rebuild the Ruby package, increment its publish revision/cache key, publish + it to the ABI-specific binary index, and anonymously resolve the published + archive. No local unpublished artifact may satisfy the proof. +8. Rebuild the Homebrew VFS/image inputs against that exact Ruby archive and + verify their ABI metadata and source provenance. +9. Repeat the exact first-party lifecycle: boot the stock mostly-lazy main + shell as uid 1000, tap the immutable public core revision, verify trust and + revision, remove the directly composed Bzip2 receipt, install Bzip2 through + stock `brew`, execute it, and recheck tap/trust state. +10. Record renderer/process-tree RSS throughout the lifecycle in Node and + Chromium, require completion without renderer loss or history-proportional + growth, and run the applicable Firefox/WebKit platform lifecycle even + where the Chromium product proof remains authoritative. +11. Repeat the intentional root/privileged Ruby fallback with enough memory + headroom to prove ordinary fork behavior independently; do not expect that + fallback to have vfork's memory profile. + +Only after that evidence should #1166's migration exception and documentation +be removed. Until then, this record supports a design, one generic admission +guardrail, and unwired host foundations, not the claim that Homebrew's +fork-then-exec problem is fully resolved. + +## Validation recorded for this change + +Completed: + +- full repository build through `scripts/dev-shell.sh`; +- focused allocator, kernel rollback, Node/browser host-parity, fork-clone, + and retirement Vitest tests (60 tests), including proof that retirement + rejection reaches fork as `EAGAIN`; +- 60 current-tree focused host tests covering refcounted backing aliases, + borrowed linked-frame traversal, transactional prefix validation, real + instrumented replay in a separate Node Worker, unchanged parent bytes, and + successful later parent replay; +- 29 focused host lifecycle tests, including 13 shared-vfork coordinator tests + plus the existing exact Worker-quiescence and process-generation detach + ledgers; +- the complete CI-shaped `fork-instrument` host-target suite (210 tests), plus + host declaration generation/typechecking and the VitePress documentation + build; +- ABI snapshot, C header, and generated TypeScript binding consistency check; +- Node process-lifecycle benchmark, three rounds; +- Node exact-retirement RSS fixture, 48 measured children after warm-up; +- component measurement harness, two independent runs; +- real instrumented main and active-side continuations plus a current-SDK + wasm-ld side-module borrowed-replay component test in separate module + Workers on Chromium, Firefox, and WebKit; +- Chromium, Firefox, and WebKit 100-iteration fork/exec retirement test; and +- the current P-08 fork-instrument alias fixture after generating the stale + baseline package projection for the test only. + +A broad host Vitest sweep was also attempted. It completed with 197 of 270 +test files passing and 2,288 of 2,314 tests passing, but it was not green: +69 files and 20 tests failed. Failures included the baseline stale +`packages/registry/program-packages.json` source projection and the absent +`sysroot64` fixture prerequisite. That run is recorded as a limitation, not +as completion evidence for this change. + +### ABI 43 integration update — 2026-08-01 + +The borrowed replay foundations were forward-ported onto ABI 43's +process-wide activation journal rather than retaining the earlier per-side +continuation/archive design. Validation through `scripts/dev-shell.sh` +recorded for that forward-port is: + +- host declaration generation, typechecking, and the VitePress documentation + build completed; +- 77 focused dynamic-linker tests passed, including current-SDK wasm-ld + reconstruction over the parent's shared Memory; +- the ABI 43 Node Worker borrowed-replay test, 37 linked-continuation tests, + 36 module-state tests, 10 activation-registry tests, 9 process-coordinator + tests, 13 lifetime tests, and 12 dynamic-linker archive tests passed; and +- nine Playwright cases passed: the ABI 43 continuation/manifest proof, the + wasm-ld no-write proof, and the active-side-continuation proof in Chromium, + Firefox, and WebKit. + +A wider ten-file host run recorded 196 passed tests and one existing +capability skip, but it was not green: six ordinary copied-fork dlopen +end-to-end cases timed out at 30 seconds. A serial rerun of the two cases in +`fork-dlopen-replay-e2e.test.ts` timed out the same way. The same two tests and +the four `fork-from-dlopen-side-module-e2e.test.ts` cases were then run at the +pre-vfork ABI 43 integration commit `992369868`, using the same generated +kernel and sysroots whose source inputs this host-only stack does not change. +The baseline reproduced all six 30-second timeouts; its remaining dlopen test +passed and its C++ capability case was skipped. This rules out the borrowed +replay stack as the source of the observed ordinary-fork failures, but the ABI +43 baseline defect itself remains unresolved. These cases are not completion +evidence for either path. + +Still required before a broad vfork or Homebrew completion claim: + +- the coordinated ABI snapshot/bump and complete vfork implementation; +- libc, POSIX, Sortix, kernel, host, fork-instrument, ABI, Node, and browser + conformance suites selected for that implementation; +- vfork-specific failure/rollback and cross-engine tests listed above; +- pristine upstream-selection tests at uid 1000 and privileged uid 0; +- rebuilt and anonymously published Ruby/VFS artifacts; and +- the exact real in-guest Homebrew lifecycle and RSS proof. From b294046e3bfe540de7e672052dddf2486c892ed1 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Fri, 3 Jul 2026 12:22:11 -0400 Subject: [PATCH 18/82] Host: Call fpcast-emulated pthread entries correctly Binaryen --fpcast-emu rewrites indirectly called functions to a uniform i64 trampoline signature. Host pthread entry calls were still passing a plain JavaScript number, which traps when the wrapper expects BigInt arguments. Build the call arguments from the WebAssembly function parameter count: preserve the plain pointer ABI for ordinary entries and use a BigInt pointer plus zero-filled i64 slots for fpcast trampolines. ABI 43 forward-port: fork-from-thread children now enter through the exported wpk_fork_resume_thread helper. Retain that plain replay ABI and apply fpcast-expanded arguments only where the host calls the table entry directly. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit a2b70da5ddfa9bee28c5dbaeaf846c66919cc87b) --- host/src/worker-main.ts | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/host/src/worker-main.ts b/host/src/worker-main.ts index 5337694746..691652b902 100644 --- a/host/src/worker-main.ts +++ b/host/src/worker-main.ts @@ -4893,6 +4893,42 @@ export function patchWasmForThread(bytes: ArrayBuffer): ArrayBuffer { * but rooted at the pthread function and this thread's channel-local fork * buffer. */ +/** + * Build the JS argument list for calling a wasm pthread entry function through + * the indirect function table. + * + * Kandelo user programs are post-processed with binaryen's `--fpcast-emu` (see + * optimize_wasm in scripts/ports/*), which rewrites every indirectly-called + * function — including pthread entry points, which the host reaches via + * `table.get(fnPtr)` — to a single uniform trampoline signature with N i64 + * parameters and an i64 result. Calling such a trampoline with the plain C ABI + * (`fn(argPtr)` where argPtr is a JS number) throws + * `TypeError: Cannot convert to a BigInt`, because the first parameter is + * i64. This surfaced as the first fork-instrumented *and* threaded program + * (pcmanfm) crashing on its first worker thread. + * + * A plain (un-emulated) entry has exactly one pointer parameter, so the + * function wrapper's parameter count distinguishes the two: `length <= 1` is the + * plain C ABI (i32 pointer on wasm32, i64 on wasm64); `length > 1` is the + * fpcast-emu trampoline, whose parameters are all i64 — pass the pointer arg + * first as a BigInt and zero-fill the remaining slots. + */ +function buildThreadEntryArgs( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + threadFn: (...args: any[]) => unknown, + argPtr: number, + ptrWidth: number, +): (number | bigint)[] { + const argc = threadFn.length; + if (argc <= 1) { + const arg = ptrWidth === 8 ? BigInt(argPtr) : argPtr; + return argc === 0 ? [] : [arg]; + } + const args: (number | bigint)[] = new Array(argc).fill(0n); + args[0] = BigInt(argPtr); + return args; +} + export async function centralizedThreadWorkerMain( port: MessagePort, initData: CentralizedThreadInitMessage, @@ -5553,6 +5589,7 @@ export async function centralizedThreadWorkerMain( } const threadArg = ptrWidth === 8 ? BigInt(argPtr) : argPtr; + const threadArgs = buildThreadEntryArgs(threadFn, argPtr, ptrWidth); const resumeThread = hasForkInstrumentation ? (instance.exports.wpk_fork_resume_thread as | ((tableIndex: number, arg: number | bigint) => number | bigint) @@ -5571,7 +5608,7 @@ export async function centralizedThreadWorkerMain( try { const raw = threadProcessContinuation.phaseName() === "idle" - ? threadFn(threadArg) + ? threadFn(...threadArgs) : resumeThread!(fnPtr, threadArg); result = Number(raw); } catch (e) { @@ -5617,7 +5654,7 @@ export async function centralizedThreadWorkerMain( } } else { try { - const raw = threadFn(threadArg); + const raw = threadFn(...threadArgs); result = Number(raw); } catch (e) { if ( From 8863bc36e830278e7029e5a979b7305fcd042657 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sat, 25 Jul 2026 16:53:06 -0400 Subject: [PATCH 19/82] POSIX: Distinguish terminals from character devices Character-special mode is broader than terminal identity. Kandelo encoded host terminal stdio and virtual devices such as /dev/null, framebuffer, audio, and DRM as CharDevice, so terminal probes could mistake any virtual character device for an interactive terminal. Derive terminal identity from the open file description. Dedicated PTY master and slave types are terminals; legacy host stdio is a terminal only when its stable canonical path and host handle agree. Reuse that result for isatty, termios, ioctl namespace gating, and fpathconf. The ABI 43 forward-port retains the current exact 60-byte termios layout. This changes no syscall number, marshalling rule, exported signature, structure layout, generated binding, or VFS ABI metadata. Validation: - native kernel unit suite: 1,491 passed - ABI snapshot and generated bindings check - git diff --check (cherry picked from commit 3b470d3b26059e998d18072ced4d331977ca6255) --- crates/kernel/src/ofd.rs | 22 ++++ crates/kernel/src/syscalls.rs | 211 +++++++++++++++++++++++++++++----- docs/posix-status.md | 8 +- 3 files changed, 207 insertions(+), 34 deletions(-) diff --git a/crates/kernel/src/ofd.rs b/crates/kernel/src/ofd.rs index df4eaed6dc..4660e7c3ac 100644 --- a/crates/kernel/src/ofd.rs +++ b/crates/kernel/src/ofd.rs @@ -312,6 +312,28 @@ pub(crate) struct PendingDirEntry { } impl OpenFileDesc { + /// Whether this open description denotes a terminal endpoint. + /// + /// Host-backed standard streams predate the dedicated PTY file types, so + /// they remain encoded as `CharDevice` OFDs with their canonical stdio + /// paths and non-negative host stream handles. Kernel-owned character + /// devices use negative handles instead (`/dev/null`, framebuffer, DRM, + /// and so on) and must not acquire terminal semantics merely because + /// `stat(2)` reports `S_IFCHR`. + pub(crate) fn is_terminal(&self) -> bool { + match self.file_type { + FileType::PtyMaster | FileType::PtySlave => true, + FileType::CharDevice => { + self.host_handle >= 0 + && matches!( + self.path.as_slice(), + b"/dev/stdin" | b"/dev/stdout" | b"/dev/stderr" + ) + } + _ => false, + } + } + /// Drop process-local directory-iterator state while preserving the /// guest-visible position at which a newly inherited or transferred /// descriptor must resume. diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 1c9a388864..ffc7aade4b 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -8783,16 +8783,13 @@ pub fn sys_mremap( } /// Check if a file descriptor refers to a terminal. -/// Returns 1 if it's a terminal (CharDevice, PtyMaster, or PtySlave), Err(ENOTTY) otherwise. +/// Returns 1 if it is a host terminal or PTY, Err(ENOTTY) otherwise. pub fn sys_isatty(proc: &Process, fd: i32) -> Result { let entry = proc.fd_table.get(fd)?; let ofd_idx = entry.ofd_ref.0; let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; - if matches!( - ofd.file_type, - FileType::CharDevice | FileType::PtyMaster | FileType::PtySlave - ) { + if ofd.is_terminal() { Ok(1) } else { Err(Errno::ENOTTY) @@ -13550,10 +13547,7 @@ pub fn sys_tcgetattr(proc: &mut Process, fd: i32, buf: &mut [u8]) -> Result<(), let entry = proc.fd_table.get(fd)?; let ofd_idx = entry.ofd_ref.0; let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; - if !matches!( - ofd.file_type, - FileType::CharDevice | FileType::PtyMaster | FileType::PtySlave - ) { + if !ofd.is_terminal() { return Err(Errno::ENOTTY); } if buf.len() != crate::terminal::TERMIOS_SIZE { @@ -13580,10 +13574,7 @@ pub fn sys_tcsetattr(proc: &mut Process, fd: i32, action: u32, buf: &[u8]) -> Re let entry = proc.fd_table.get(fd)?; let ofd_idx = entry.ofd_ref.0; let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; - if !matches!( - ofd.file_type, - FileType::CharDevice | FileType::PtyMaster | FileType::PtySlave - ) { + if !ofd.is_terminal() { return Err(Errno::ENOTTY); } if buf.len() != crate::terminal::TERMIOS_SIZE { @@ -13613,9 +13604,17 @@ pub fn sys_tcsetattr(proc: &mut Process, fd: i32, action: u32, buf: &[u8]) -> Re Ok(()) } +fn is_terminal_ioctl_request(request: u32) -> bool { + // Linux reserves ioctl type 'T' for tty/termios and 'K' for VT keyboard + // controls. Classify the namespaces instead of duplicating today's + // request list, so a newly implemented terminal request cannot bypass + // non-terminal gating merely because this helper was not updated. + matches!((request >> 8) & 0xff, 0x54 | 0x4B) +} + /// ioctl -- device control. /// Supports generic ioctls (FIONREAD, FIONBIO, FIOCLEX, FIONCLEX) on any fd type, -/// plus terminal ioctls (TIOCGWINSZ, TIOCSWINSZ) on CharDevice fds only. +/// plus terminal ioctls (TIOCGWINSZ, TIOCSWINSZ) on host terminals and PTYs. pub fn sys_ioctl( proc: &mut Process, host: &mut dyn HostIO, @@ -13646,6 +13645,7 @@ pub fn sys_ioctl( if ofd.is_path_only() { return Err(Errno::EBADF); } + let is_terminal = ofd.is_terminal(); // FIONBIO — toggle O_NONBLOCK on the OFD status_flags if request == 0x5421 { @@ -13758,6 +13758,14 @@ pub fn sys_ioctl( return Ok(()); } + // Device-specific handlers intentionally own unknown-ioctl errno policy, + // but a terminal request on a non-terminal must consistently be ENOTTY. + // Gate that shared namespace before framebuffer/audio/DRM dispatch so a + // broad device handler cannot reinterpret TCGETS or a VT probe. + if is_terminal_ioctl_request(request) && !is_terminal { + return Err(Errno::ENOTTY); + } + // --- PTY-specific ioctls (work on PtyMaster only) --- { let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; @@ -13843,6 +13851,9 @@ pub fn sys_ioctl( match request { // KDGKBTYPE — return KB_101 (0x02) as a single byte. 0x4B33 => { + if !is_terminal { + return Err(Errno::ENOTTY); + } if buf.is_empty() { return Err(Errno::EINVAL); } @@ -13851,6 +13862,9 @@ pub fn sys_ioctl( } // KDGKBMODE — return K_XLATE (1) as i32. 0x4B44 => { + if !is_terminal { + return Err(Errno::ENOTTY); + } if buf.len() < 4 { return Err(Errno::EINVAL); } @@ -13858,25 +13872,26 @@ pub fn sys_ioctl( return Ok(()); } // KDSKBMODE — accept any mode, no-op success. - 0x4B45 => return Ok(()), + 0x4B45 => { + if !is_terminal { + return Err(Errno::ENOTTY); + } + return Ok(()); + } _ => {} } - // --- Terminal ioctls (work on CharDevice, PtyMaster, PtySlave) --- + // --- Terminal ioctls (work on host terminals and PTYs) --- let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; let file_type = ofd.file_type; let host_handle = ofd.host_handle; - let is_terminal = matches!( - file_type, - FileType::CharDevice | FileType::PtyMaster | FileType::PtySlave - ); if !is_terminal { return Err(Errno::ENOTTY); } // Helper: get mutable reference to the appropriate TerminalState. - // For PTY fds → PTY pair's terminal state; for CharDevice → process terminal state. + // For PTY fds → PTY pair's terminal state; for host stdio → process terminal state. // We handle this by dispatching per-request below. use crate::terminal::*; @@ -15128,6 +15143,7 @@ pub fn sys_fpathconf( validate_pathconf_name(name)?; let entry = proc.fd_table.get(fd)?; let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + let is_terminal = ofd.is_terminal(); let file_type = ofd.file_type; let host_handle = ofd.host_handle; let path = ofd.path.clone(); @@ -15164,12 +15180,7 @@ pub fn sys_fpathconf( } FileType::MemFd => filesystem_pathconf_value(name, false, None), FileType::Regular | FileType::Directory | FileType::CharDevice => { - if file_type == FileType::CharDevice - && matches!( - path.as_slice(), - b"/dev/stdin" | b"/dev/stdout" | b"/dev/stderr" - ) - { + if is_terminal { terminal_pathconf_value(name) } else if is_procfs_namespace_path(&path) || (is_devfs_namespace_path(&path) && !is_host_backed_devfs_path(&path)) @@ -24615,9 +24626,149 @@ mod tests { } #[test] - fn test_isatty_stdin() { - let proc = terminal_process(1); - assert_eq!(sys_isatty(&proc, 0), Ok(1)); + fn test_isatty_distinguishes_host_terminal_from_captured_stdio() { + let terminal = terminal_process(1); + let captured = Process::new(2); + + for fd in 0..=2 { + assert_eq!(sys_isatty(&terminal, fd), Ok(1)); + assert_eq!(sys_isatty(&captured, fd), Err(Errno::ENOTTY)); + } + } + + #[test] + fn test_host_terminal_and_both_pty_endpoints_accept_terminal_operations() { + fn assert_terminal_surface(proc: &mut Process, host: &mut MockHostIO, fd: i32) { + assert_eq!(sys_isatty(proc, fd), Ok(1)); + + let mut attrs = [0; crate::terminal::TERMIOS_SIZE]; + assert_eq!(sys_tcgetattr(proc, fd, &mut attrs), Ok(())); + assert_eq!( + sys_tcsetattr(proc, fd, crate::terminal::TCSANOW, &attrs), + Ok(()), + ); + + let mut ioctl_attrs = [0; crate::terminal::TERMIOS_SIZE]; + assert_eq!( + sys_ioctl( + proc, + host, + fd, + crate::terminal::TCGETS, + &mut ioctl_attrs, + ), + Ok(()), + ); + let mut keyboard_type = [0]; + assert_eq!( + sys_ioctl(proc, host, fd, 0x4B33, &mut keyboard_type), + Ok(()), + ); + assert_eq!(keyboard_type, [0x02]); + } + + let mut terminal = terminal_process(1); + let mut terminal_host = MockHostIO::new(); + for fd in 0..=2 { + assert_terminal_surface(&mut terminal, &mut terminal_host, fd); + } + + let mut fixture = PtyFixture::new(); + for fd in [fixture.master_fd, fixture.slave_fd] { + assert_terminal_surface(&mut fixture.proc, &mut fixture.host, fd); + } + } + + #[test] + fn test_virtual_character_devices_reject_terminal_operations() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let devices: &[(&[u8], i64)] = &[ + (b"/dev/null", VirtualDevice::Null.host_handle()), + (b"/dev/console", VirtualDevice::Null.host_handle()), + (b"/dev/zero", VirtualDevice::Zero.host_handle()), + (b"/dev/urandom", VirtualDevice::Urandom.host_handle()), + (b"/dev/random", VirtualDevice::Urandom.host_handle()), + (b"/dev/full", VirtualDevice::Full.host_handle()), + (b"/dev/fb0", VirtualDevice::Fb0.host_handle()), + (b"/dev/input/mice", VirtualDevice::Mice.host_handle()), + (b"/dev/dsp", VirtualDevice::Dsp.host_handle()), + ( + b"/dev/dri/renderD128", + VirtualDevice::DriRenderD128.host_handle(), + ), + (b"/dev/dri/card0", VirtualDevice::DriCard0.host_handle()), + // Prime fds are kernel-owned CharDevices outside the named + // VirtualDevice range and obey the same non-terminal contract. + (b"/dev/dri/prime-test", -200), + // A future host-backed CharDevice must opt into terminal identity + // rather than inheriting it from a non-negative handle. + (b"/dev/other-char-device", 77), + ]; + + for &(path, host_handle) in devices { + let ofd_idx = + proc.ofd_table + .create(FileType::CharDevice, O_RDWR, host_handle, path.to_vec()); + let fd = proc + .fd_table + .alloc(crate::fd::OpenFileDescRef(ofd_idx), 0) + .unwrap(); + + assert_eq!(sys_isatty(&proc, fd), Err(Errno::ENOTTY), "{path:?}"); + assert_eq!( + sys_tcgetattr(&mut proc, fd, &mut [0; 48]), + Err(Errno::ENOTTY), + "{path:?}", + ); + assert_eq!( + sys_tcsetattr(&mut proc, fd, crate::terminal::TCSANOW, &[0; 48]), + Err(Errno::ENOTTY), + "{path:?}", + ); + assert_eq!( + sys_ioctl( + &mut proc, + &mut host, + fd, + crate::terminal::TCGETS, + &mut [0; crate::terminal::TERMIOS_SIZE], + ), + Err(Errno::ENOTTY), + "{path:?}", + ); + // WHY: musl implements isatty() with TIOCGWINSZ rather than + // Kandelo's legacy direct isatty syscall. Keep the public libc + // path in this matrix so a future ioctl refactor cannot restore + // false terminal identity for character devices. + assert_eq!( + sys_ioctl( + &mut proc, + &mut host, + fd, + crate::terminal::TIOCGWINSZ, + &mut [0; 8], + ), + Err(Errno::ENOTTY), + "{path:?}", + ); + assert_eq!( + sys_ioctl(&mut proc, &mut host, fd, 0x4B33, &mut [0; 1]), + Err(Errno::ENOTTY), + "{path:?}", + ); + } + } + + #[test] + fn test_redirecting_terminal_stdout_to_dev_null_clears_terminal_identity() { + let mut proc = terminal_process(1); + let mut host = MockHostIO::new(); + let null_fd = sys_open(&mut proc, &mut host, b"/dev/null", O_WRONLY, 0).unwrap(); + + assert_eq!(sys_isatty(&proc, 1), Ok(1)); + assert_eq!(sys_dup2(&mut proc, &mut host, null_fd, 1), Ok(1)); + assert_eq!(sys_isatty(&proc, 1), Err(Errno::ENOTTY)); } #[test] diff --git a/docs/posix-status.md b/docs/posix-status.md index 5f6bc5dccf..3b6d9b60de 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -373,9 +373,9 @@ proves and reserves only the 128-byte prefix the kernel can write. | Function | Status | Notes | |----------|--------|-------| -| `isatty()` | Full | Returns 1 for CharDevice, PtyMaster, and PtySlave fds; ENOTTY for others. | -| `tcgetattr()` / `tcsetattr()` | Partial | CharDevice and PTY fds round-trip musl's exact 60-byte termios layout, including all four flag words, `c_line`, `c_cc`, and input/output speeds; custom syscalls 70/71 use that same layout and no longer expose a second shortened format. `TCSANOW` and `TCSADRAIN` preserve unread input across `ICANON` transitions: completed lines and the current edited partial line become raw-readable in byte order, while unread raw bytes become immediately readable if the mode changes back, matching Linux EOF-push behavior. `TCSAFLUSH` discards unread input before applying the change. PTY writes synchronously enter the output queue, so there is no deferred device transmission to await. Implemented line discipline includes `VERASE`, `VKILL`, non-empty-line `VEOF`, ICRNL/INLCR/IGNCR, and ECHO/ECHOE/ECHOK/ECHONL. Remaining gaps: `VMIN`/`VTIME` values round-trip but raw-read timing is approximated, an empty canonical `VEOF` does not create a queued EOF event, a canonical `read()` can return bytes from multiple completed lines instead of stopping after one line, `VWERASE` is not implemented, and exposed input/output flags outside the listed subset do not all have data-path semantics. | -| `ioctl()` | Full | 16 terminal ioctls: TCGETS/TCSETS/TCSETSW/TCSETSF (termios), TIOCGPTN (PTY number), TIOCSPTLCK (unlock PTY), TIOCGPGRP/TIOCSPGRP (foreground pgid), TIOCGWINSZ/TIOCSWINSZ (window size + SIGWINCH), TCSBRK/TCXONC/TCFLSH, TIOCGSID/TIOCSCTTY/TIOCNOTTY (session/controlling terminal). Generic: FIONREAD, FIONBIO, FIOCLEX/FIONCLEX, FIOASYNC. Works on CharDevice, PtyMaster, and PtySlave fds. | +| `isatty()` | Full | Returns 1 for host terminal stdio and PTY master/slave fds; returns ENOTTY for pipes, files, and non-terminal character devices such as `/dev/null`, `/dev/zero`, framebuffer, audio, and DRM nodes. | +| `tcgetattr()` / `tcsetattr()` | Partial | Host terminal and PTY fds round-trip musl's exact 60-byte termios layout, including all four flag words, `c_line`, `c_cc`, and input/output speeds; custom syscalls 70/71 use that same layout and no longer expose a second shortened format. Non-terminal character devices return ENOTTY. `TCSANOW` and `TCSADRAIN` preserve unread input across `ICANON` transitions: completed lines and the current edited partial line become raw-readable in byte order, while unread raw bytes become immediately readable if the mode changes back, matching Linux EOF-push behavior. `TCSAFLUSH` discards unread input before applying the change. PTY writes synchronously enter the output queue, so there is no deferred device transmission to await. Implemented line discipline includes `VERASE`, `VKILL`, non-empty-line `VEOF`, ICRNL/INLCR/IGNCR, and ECHO/ECHOE/ECHOK/ECHONL. Remaining gaps: `VMIN`/`VTIME` values round-trip but raw-read timing is approximated, an empty canonical `VEOF` does not create a queued EOF event, a canonical `read()` can return bytes from multiple completed lines instead of stopping after one line, `VWERASE` is not implemented, and exposed input/output flags outside the listed subset do not all have data-path semantics. | +| `ioctl()` | Full | 16 terminal ioctls: TCGETS/TCSETS/TCSETSW/TCSETSF (termios), TIOCGPTN (PTY number), TIOCSPTLCK (unlock PTY), TIOCGPGRP/TIOCSPGRP (foreground pgid), TIOCGWINSZ/TIOCSWINSZ (window size + SIGWINCH), TCSBRK/TCXONC/TCFLSH, TIOCGSID/TIOCSCTTY/TIOCNOTTY (session/controlling terminal). Generic: FIONREAD, FIONBIO, FIOCLEX/FIONCLEX, FIOASYNC. Terminal and Linux-VT requests work on host terminals and PTYs and return ENOTTY on other character devices. | | `posix_openpt()` | Full | Opens `/dev/ptmx`, allocates PTY pair, returns master fd. | | `grantpt()` / `unlockpt()` | Full | `grantpt()` is a no-op (no permissions to set). `unlockpt()` clears the lock flag on the PTY pair. | | `ptsname()` | Full | Returns `/dev/pts/N` path for the slave side. | @@ -398,7 +398,7 @@ proves and reserves only the 128-byte prefix the kernel can write. | `/dev/tty` | Partial | Uses the first open PTY-slave OFD as the current controlling-terminal heuristic. When none is open, it currently falls back to fd 0 rather than returning ENXIO; `pathconf()` follows that same OFD selection and therefore does not advertise terminal variables for the captured, pipe-backed case. | | `/dev/ptmx` | Full | PTY master multiplexer. `open()` allocates a new PTY pair, returns master fd. | | `/dev/pts/*` | Full | PTY slave devices. `posix_openpt()` + `grantpt()` + `unlockpt()` + `ptsname()`. Full line discipline, canonical/raw mode, OPOST/ONLCR, 16 terminal ioctls. | -| `/dev/fb0` | Full | Linux fbdev framebuffer. Single-open (`EBUSY` for second opener). 640×400 BGRA32 packed-pixel. ioctls: `FBIOGET_VSCREENINFO`, `FBIOGET_FSCREENINFO`, `FBIOPAN_DISPLAY` (no-op success), `FBIOPUT_VSCREENINFO` (validates geometry). `mmap` returns a region in process memory and notifies the host (`bind_framebuffer` callback) so the browser canvas can mirror pixels. `munmap`/`exit`/`exec` discard the image mapping; a surviving fd retains device ownership across exec. Ownership is released after both the final fd and any live mapping are gone, since a mapping remains valid after `close()`. Linux-VT keyboard ioctls (`KDGKBTYPE`/`KDGKBMODE`/`KDSKBMODE`) accepted with sensible defaults so fbDOOM-style software works unmodified. | +| `/dev/fb0` | Full | Linux fbdev framebuffer. Single-open (`EBUSY` for second opener). 640×400 BGRA32 packed-pixel. ioctls: `FBIOGET_VSCREENINFO`, `FBIOGET_FSCREENINFO`, `FBIOPAN_DISPLAY` (no-op success), `FBIOPUT_VSCREENINFO` (validates geometry). `mmap` returns a region in process memory and notifies the host (`bind_framebuffer` callback) so the browser canvas can mirror pixels. `munmap`/`exit`/`exec` discard the image mapping; a surviving fd retains device ownership across exec. Ownership is released after both the final fd and any live mapping are gone, since a mapping remains valid after `close()`. Linux-VT keyboard ioctls (`KDGKBTYPE`/`KDGKBMODE`/`KDSKBMODE`) are accepted on the process's terminal fd so fbDOOM-style software works unmodified; `/dev/fb0` itself is not a terminal. | | `/dev/input/mice` | Full | Linux `mousedev` PS/2 mouse stream. Single-open (`EBUSY` for second pid). 3-byte packets: byte0 button bits + sign/overflow flags, bytes 1..2 signed dx/dy with positive-up dy. Host pushes events via `kernel_inject_mouse_event(dx, dy, buttons)`; the kernel buffers up to 4096 packets (whole-packet drop on overflow). `read()` drains queued bytes; returns `EAGAIN` when empty. `poll()` reports `POLLIN` only when bytes are queued. Ownership and queued packets survive exec with a non-CLOEXEC fd; last close or exit releases and clears them. No IMPS/2 wheel protocol, no `evdev`/`/dev/input/eventN`. | | `/dev/dsp` | Full (write-only) | OSS-style PCM audio sink. Single-open (`EBUSY` for second pid). `write()` accepts interleaved 16-bit-LE PCM and buffers it in a 256 KiB ring; the host drains via the `kernel_drain_audio` wasm export and feeds a Web Audio `AudioContext`. ioctls: `SNDCTL_DSP_RESET`, `SNDCTL_DSP_SYNC`, `SNDCTL_DSP_SPEED` (clamp 4000–192000 Hz), `SNDCTL_DSP_STEREO` / `SNDCTL_DSP_CHANNELS` (1 or 2), `SNDCTL_DSP_SETFMT` (only `AFMT_S16_LE`), `SNDCTL_DSP_GETFMTS`, `SNDCTL_DSP_SETFRAGMENT` (accept-and-acknowledge). On overflow drops the *oldest whole frame* — never tears L/R alignment. Ownership and queued samples survive exec with a non-CLOEXEC fd; last close or exit releases and flushes them. `read()` returns 0 (EOF-like). `poll()` reports `POLLOUT` always, never `POLLIN`. No record path, no `mmap`-based zero-copy; DOOM's mixer is in user space. | | `/dev/shm/*` | Partial | POSIX shm objects are regular files used by `shm_open()`. Stable-identity backends support host-coordinated `MAP_SHARED` across processes at syscall boundaries; this is not immediate shared linear memory and does not make process-shared futexes work. | From fa327d8d6bdeb0c84235a97d9e7a76a7161f0c94 Mon Sep 17 00:00:00 2001 From: mho22 Date: Wed, 15 Jul 2026 15:49:13 +0200 Subject: [PATCH 20/82] Host: Synthesize POSIX permissions for Windows mounts Windows does not expose a POSIX permission model through Node. Native entries carry no owner/group/other split or directory search bit, so a guest that drops privileges cannot traverse or write the host-backed sandbox it was given. On Windows only, represent writable host directories as 0777, read-only directories as 0555, writable files as 0666, and read-only files as 0444. Preserve native type bits and let guest chmod and chown metadata override the synthesized permissions. POSIX hosts keep their native modes unchanged. The ABI 43 forward-port performs synthesis only after exact conversion of the native BigInt mode. Existing EOVERFLOW checks for modes, links, sizes, and timestamps remain intact. Validation: - host declaration generation and typechecking - native-metadata and Node uid/gid suites: 15 passed - Windows host execution was not available on this macOS worktree - git diff --check Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit aa121862c5c093ec11e8b0601ca70b65a2745926) --- host/src/platform/native-metadata.ts | 53 +++++++++++++++++++++- host/test/platform/native-metadata.test.ts | 48 ++++++++++++++++++++ 2 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 host/test/platform/native-metadata.test.ts diff --git a/host/src/platform/native-metadata.ts b/host/src/platform/native-metadata.ts index 90f5f592e2..75d596df8d 100644 --- a/host/src/platform/native-metadata.ts +++ b/host/src/platform/native-metadata.ts @@ -39,6 +39,48 @@ function checkedMilliseconds(valueNs: bigint, field: string): number { return Number(wholeMilliseconds) + Number(fractionalMilliseconds) / 1_000_000; } +const S_IFMT = 0o170000; +const S_IFDIR = 0o040000; +const S_IFLNK = 0o120000; + +/** + * Windows has no POSIX permission model: `fs.statSync` reports every entry as + * `0o666` (writable) or `0o444` (read-only), with no owner/group/other split + * and no execute/search bit on directories, and `chmod` cannot set an execute + * bit on a directory or otherwise express POSIX bits. Two things break for a + * guest process that drops privileges (e.g. a php-fpm worker running as a + * non-root uid): + * + * - Directory lookup enforces `X_OK` on every path component, so with no + * search bit the worker can't traverse any host-backed directory. + * - The worker often has to *write* into the mount (WordPress writes its + * SQLite database, uploads, and cache under the mounted tree). Hosts grant + * this by `chmod`-ing those directories world-writable — a no-op on Windows + * that also never reaches this overlay, so the intent is invisible here. + * + * This is a host-platform boundary, not a POSIX gap in the kernel: Windows ACLs + * don't map to POSIX bits, so the kernel can't enforce them anyway. Represent + * host-backed entries as world-accessible — `0o777` directories, `0o666` files + * — so a privilege-dropped guest can both traverse and write the sandbox it was + * given, while still honoring the one attribute Windows does expose by mapping + * read-only entries to `0o555`/`0o444`. Type bits come from the native mode, and + * a genuine host-level read-only file still fails its write at the native fs + * layer. Guest `chmod`/`chown` continue to override through the overlay. + */ +const SYNTHESIZE_POSIX_MODE = process.platform === "win32"; + +export function synthesizePosixMode(nativeMode: number): number { + const type = nativeMode & S_IFMT; + // Node sets the owner-write bit (0o200) only when the entry is not + // read-only; use it to carry the read-only attribute across. + const writable = (nativeMode & 0o200) !== 0; + let perms: number; + if (type === S_IFDIR) perms = writable ? 0o777 : 0o555; + else if (type === S_IFLNK) perms = 0o777; + else perms = writable ? 0o666 : 0o444; + return type | perms; +} + interface VirtualMetadata { mode?: number; uid?: number; @@ -80,12 +122,19 @@ export class NativeMetadataOverlay { ); } const nativeMode = checkedNumber(s.mode, "st_mode"); + // On hosts that don't expose POSIX permission bits (Windows), replace the + // native permission bits with synthesized ones so a privilege-dropped guest + // can traverse and write host-backed mounts. Type bits are untouched, and a + // guest chmod (metadata.mode) still wins. + const baseMode = SYNTHESIZE_POSIX_MODE + ? synthesizePosixMode(nativeMode) + : nativeMode; return { dev: s.dev, ino: s.ino, mode: metadata?.mode === undefined - ? nativeMode - : (nativeMode & ~MODE_CHANGE_MASK) | (metadata.mode & MODE_CHANGE_MASK), + ? baseMode + : (baseMode & ~MODE_CHANGE_MASK) | (metadata.mode & MODE_CHANGE_MASK), nlink: checkedNumber(s.nlink, "st_nlink"), uid: metadata?.uid ?? this.defaultUid, gid: metadata?.gid ?? this.defaultGid, diff --git a/host/test/platform/native-metadata.test.ts b/host/test/platform/native-metadata.test.ts new file mode 100644 index 0000000000..c1b146d83a --- /dev/null +++ b/host/test/platform/native-metadata.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import { synthesizePosixMode } from "../../src/platform/native-metadata"; + +const S_IFDIR = 0o040000; +const S_IFREG = 0o100000; +const S_IFLNK = 0o120000; + +// Windows has no POSIX permission model: Node's `fs.statSync` reports every +// entry as 0o666 (writable) or 0o444 (read-only), with no execute/search bit +// on directories, and `chmod` can't express POSIX bits. `synthesizePosixMode` +// represents host-backed entries as world-accessible so a privilege-dropped +// guest process can both traverse and write the mounted sandbox (WordPress +// writes its SQLite database, uploads, and cache into it), honoring only the +// read-only attribute Windows does expose. These inputs are exactly what Node +// reports on Windows. +describe("synthesizePosixMode", () => { + it("gives writable directories full rwx for owner, group, and other", () => { + expect(synthesizePosixMode(S_IFDIR | 0o666)).toBe(S_IFDIR | 0o777); + }); + + it("keeps read-only directories traversable but not writable", () => { + expect(synthesizePosixMode(S_IFDIR | 0o444)).toBe(S_IFDIR | 0o555); + }); + + it("maps writable regular files to world read/write (0o666)", () => { + expect(synthesizePosixMode(S_IFREG | 0o666)).toBe(S_IFREG | 0o666); + }); + + it("maps read-only regular files to 0o444", () => { + expect(synthesizePosixMode(S_IFREG | 0o444)).toBe(S_IFREG | 0o444); + }); + + it("reports symlinks as 0o777 regardless of the read-only attribute", () => { + expect(synthesizePosixMode(S_IFLNK | 0o666)).toBe(S_IFLNK | 0o777); + expect(synthesizePosixMode(S_IFLNK | 0o444)).toBe(S_IFLNK | 0o777); + }); + + it("preserves the file-type bits", () => { + expect(synthesizePosixMode(S_IFDIR | 0o666) & 0o170000).toBe(S_IFDIR); + expect(synthesizePosixMode(S_IFREG | 0o666) & 0o170000).toBe(S_IFREG); + }); + + it("gives writable directories other search+write, so lookup and writes succeed", () => { + // Windows reports writable dirs as 0o666; the uid-dropped worker needs + // both the search (0o1) and write (0o2) bit as "other". + expect(synthesizePosixMode(S_IFDIR | 0o666) & 0o003).toBe(0o003); + }); +}); From 415e40134d99beb9cc9feb0cfc9304e1d3b5a6b8 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Thu, 9 Jul 2026 01:51:08 -0400 Subject: [PATCH 21/82] Docs: Document fresh-worktree validation prerequisites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new git worktree (and a fresh checkout) does not inherit git submodules, a musl sysroot, node_modules, or fetched test binaries, so Vitest and the conformance/browser suites cannot run until those are built or fetched. Agents were treating this as "cannot validate" rather than a setup step. Document the exact sequence in validation.md and build-docs-and-prs.md: submodule init (with the stray-libc/musl recovery), build-musl.sh (sysroot), build.sh (kernel wasm → local-binaries/kernel.wasm + rootfs), root + host `npm ci` (root provides tsx for the conformance runners), and fetch-binaries.sh. Add the explicit instruction not to report "I can't run Vitest/conformance/browser" because a fresh worktree lacks artifacts — build or fetch them and report the real result, or name the exact step that failed. Verified by running the sequence end-to-end in a fresh worktree: kernel wasm + sysroot + rootfs build, `vitest` (778 pass; the only failures were a missing fetched `programs/wasm64/hello64.wasm`), and Sortix conformance (10/10) all ran. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit a3739a27a73ca5234186fc0e7fb05103fd72fa59) This conceptual commit combines recovery commits cda80003f and 050a7875e. --- docs/agent-guidance/build-docs-and-prs.md | 19 +++++++ docs/agent-guidance/validation.md | 65 +++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/docs/agent-guidance/build-docs-and-prs.md b/docs/agent-guidance/build-docs-and-prs.md index 4ab7dfaf44..ac3d567656 100644 --- a/docs/agent-guidance/build-docs-and-prs.md +++ b/docs/agent-guidance/build-docs-and-prs.md @@ -131,6 +131,25 @@ scripts/build-programs.sh # Rebuild test/example C programs against a stale `sysroot/lib/libc.a`, hiding or inventing syscall, ABI, and libc behavior. +### First build in a fresh checkout or worktree + +A new `git worktree` does not inherit submodules, a musl sysroot, `node_modules`, +or fetched binaries — so Vitest and the conformance/browser suites cannot run +until you build them. This is a setup step, not a reason to say "I can't +validate." The full sequence (see `validation.md` for detail): + +```bash +git submodule update --init --recursive # musl, libc-test, os-test +# if libc/musl exists but is a stray partial dir: rm -rf libc/musl && git submodule update --init libc/musl +scripts/dev-shell.sh bash scripts/build-musl.sh # sysroot (~20s) +scripts/dev-shell.sh bash build.sh # kernel wasm → local-binaries/, host, rootfs (~1.5min) +npm ci && (cd host && npm ci) # root deps (tsx for conformance runners) + host deps +scripts/dev-shell.sh bash scripts/fetch-binaries.sh # prebuilt test binaries build.sh does not produce +``` + +A stale `local-binaries/kernel.wasm` silently runs OLD kernel code in +Vitest/conformance, so rebuild with `bash build.sh` after any kernel Rust edit. + ## Documentation And PRs PR titles, PR descriptions, and commit messages should lead with the purpose of diff --git a/docs/agent-guidance/validation.md b/docs/agent-guidance/validation.md index 9c092fa2e9..204f508639 100644 --- a/docs/agent-guidance/validation.md +++ b/docs/agent-guidance/validation.md @@ -57,6 +57,71 @@ rustc -vV | awk '/^host/ {print $2}' `scripts/ci-run-test-suite.sh` does not currently expose an `abi` suite; run `bash scripts/check-abi-version.sh` separately for ABI-adjacent changes. +## Preparing a fresh checkout or worktree to run the suites + +The Vitest, browser, libc, posix, and sortix suites need built artifacts and +submodules that a fresh checkout — and every new `git worktree` — does **not** +inherit. Missing artifacts surface as `Binary not found: …/kernel.wasm` (or a +program `.wasm`), `sysroot not found`, or `libc/musl/src: No such file`. These +are not "cannot validate" conditions. Build or fetch what is missing: + +1. **Submodules** (musl, libc-test, os-test) — worktrees do not check them out: + ```bash + git submodule update --init --recursive + ``` + If `libc/musl` exists but is not a valid checkout (a stray dir from a partial + build blocks the clone), reset it: `rm -rf libc/musl && git submodule update + --init libc/musl`. +2. **musl sysroot** — one-time, ~20s; required before `build.sh` can compile the + user programs and rootfs: + ```bash + scripts/dev-shell.sh bash scripts/build-musl.sh + ``` +3. **Kernel wasm + host + rootfs** — ~1.5min; produces `local-binaries/kernel.wasm` + (the binary resolver prefers it over `binaries/`) and `host/wasm/rootfs.vfs`: + ```bash + scripts/dev-shell.sh bash build.sh + ``` +4. **Node dependencies** — `node_modules` are per-checkout, and both the repo + root (the conformance runners load `tsx` from root) and `host/` are needed: + ```bash + npm ci # root — provides tsx used by run-sortix/posix/libc-tests.sh + (cd host && npm ci) + ``` +5. **Prebuilt test binaries** the source build does not produce, e.g. the + MariaDB/Perl VFS images a few Vitest cases load: + ```bash + scripts/dev-shell.sh bash scripts/fetch-binaries.sh + ``` +6. **wasm64 sysroot** (only for the `wasm64` Vitest cases, which need an LP64 + `hello64.wasm` that `fetch-binaries.sh` does not carry): + ```bash + scripts/dev-shell.sh bash scripts/build-musl.sh --arch wasm64posix + scripts/dev-shell.sh bash scripts/build-programs.sh + ``` + +After that the full suites run. Do **not** report "I can't run Vitest / the +conformance suites / the browser" because a fresh worktree lacks artifacts — +build or fetch them with the steps above, then run the suite and report the real +result. If a suite genuinely cannot run (no network for `fetch-binaries.sh`, no +display for browser tests, etc.), name the exact step that failed and why; that +is different from validation being impossible. + +Before blaming a suite failure on your change, confirm it actually is your +change: a few package/demo tests (e.g. the Erlang `ring` benchmark) can fail for +environment or artifact reasons unrelated to a given diff. Reproduce the failure +on a pristine `origin/main` build of the same artifact before attributing it — +rebuild just the kernel wasm (`cargo build --release -p kandelo -Z +build-std=core,alloc && cp target/wasm32-unknown-unknown/release/kandelo_kernel.wasm +local-binaries/kernel.wasm`) at `origin/main` and re-run the one test. Report a +pre-existing failure as pre-existing, not as your regression. + +After editing kernel Rust, rebuild the kernel wasm (`bash build.sh`) before the +Vitest/conformance suites — they load `local-binaries/kernel.wasm`, so a stale +wasm silently runs your OLD kernel code. `bash build.sh` does not rebuild musl; +after editing `libc/musl-overlay/` or `libc/glue/channel_syscall.c`, run +`scripts/build-musl.sh` first. + The table names primary evidence, not a universal checklist. Choose the suites that support the claim you will make, broaden coverage when a change crosses contract boundaries, and report anything relevant that was not run. From 5d1cf8f4e099d8355792a3b5cc3d64e1efd7fc3d Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Mon, 20 Jul 2026 10:25:49 -0400 Subject: [PATCH 22/82] Docs: Require purpose-prefixed PRs and commits Make Area: Purpose the repository convention for PR titles and commit subjects. Add contract-oriented examples and guidance for choosing one primary prefix for cross-cutting changes. Validation: - VitePress documentation build - git diff --check (cherry picked from commit 60fb395ecea7324ef833bbcb5c83e4a843b530a0) --- CLAUDE.md | 10 +++++-- docs/agent-guidance/build-docs-and-prs.md | 36 +++++++++++++++++++---- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 253b4bd8be..be9db313b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -285,9 +285,13 @@ pins current. `libc/glue/channel_syscall.c`, run `scripts/build-musl.sh` before relying on `build.sh`, Vitest, or conformance tests. -PR titles, PR descriptions, and commit messages should lead with the purpose of -the work: the platform contract, user-visible behavior, system invariant, or -project capability being changed or protected. Every PR description must put a +PR titles and commit subjects must begin with a concise purpose prefix in the +form `Area: Purpose`, such as `Homebrew:`, `Kernel:`, `POSIX:`, `CI:`, +`Browser:`, or `Docs:`. Choose the area that best identifies the contract or +capability a reader should notice; the prefix is a routing hint, not a substitute +for a purpose-led title. PR descriptions and commit messages should then lead +with the platform contract, user-visible behavior, system invariant, or project +capability being changed or protected. Every PR description must put a plain-language `## Why` section before `## What changed`, implementation details, or validation. Explain the current problem, who or what it affects, and why fixing it matters before describing the mechanics. diff --git a/docs/agent-guidance/build-docs-and-prs.md b/docs/agent-guidance/build-docs-and-prs.md index ac3d567656..4de6ff302c 100644 --- a/docs/agent-guidance/build-docs-and-prs.md +++ b/docs/agent-guidance/build-docs-and-prs.md @@ -152,12 +152,36 @@ Vitest/conformance, so rebuild with `bash build.sh` after any kernel Rust edit. ## Documentation And PRs -PR titles, PR descriptions, and commit messages should lead with the purpose of -the work: the platform contract, user-visible behavior, system invariant, or -project capability being changed or protected. Every PR description must begin -its substance with a plain-language `## Why` section. Put `## What changed`, -implementation details, validation, and rollout information after it. The Why -section must explain: +PR titles and commit subjects must begin with a concise purpose prefix in the +form `Area: Purpose`. Use the primary contract or capability affected, not a +mechanical verb or team name. Common prefixes include: + +- `ABI:` for the host/kernel binary contract and versioning. +- `Browser:` for browser-only product or presentation behavior. +- `CI:` for repository validation, release, and automation infrastructure. +- `Docs:` for documentation-only changes. +- `Homebrew:` for tap, bottle, publisher, and Homebrew VFS work. +- `Host:` for shared Node.js/browser host-runtime behavior. +- `Kernel:` for kernel implementation and internal process state. +- `Libc:` for musl and libc glue. +- `Packages:` for the general package system and package recipes. +- `Performance:` for measured performance work. +- `POSIX:` for externally observable POSIX semantics and conformance. +- `SDK:` for cross-compilation and SDK behavior. + +Choose the prefix that gives a reviewer the most useful first routing signal. +For cross-cutting work, prefer the primary user-visible purpose instead of +stacking several prefixes. Use another clear area when the examples do not fit. +Keep the same semantic prefix when a PR is squash-merged so the resulting +commit remains identifiable in history; purpose-prefix substantive intermediate +commits as well. + +The prefix does not replace a purpose-led title. PR titles, PR descriptions, +and commit messages should lead with the platform contract, user-visible +behavior, system invariant, or project capability being changed or protected. +Every PR description must begin its substance with a plain-language `## Why` +section. Put `## What changed`, implementation details, validation, and rollout +information after it. The Why section must explain: - what currently fails, is risky, or is unnecessarily difficult; - who or what is affected; and From d3736eb5246d2a7572c3a17d73a94d113598eae5 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Thu, 11 Jun 2026 09:53:07 -0400 Subject: [PATCH 23/82] Browser: Restrict reclaim handling to WebKit Chromium-based user agents also contain AppleWebKit. Treating that token as sufficient enabled WebKit-only post-destroy memory handling in Chrome, Edge, Opera, and Chromium on iOS. Forward-port the predicate into the current shared kernel-owned boot helper and keep a pure user-agent classifier for regression coverage. Exclude Chromium, Chrome on iOS, Edge, Opera, Firefox, and Firefox on iOS while retaining Safari and WebKit. Validation: - browser engine detection test: 1 passed - git diff --check (cherry picked from commit 4cbaf2b3a27f7cabd052bf9a7c0bfe27a9dc3495) --- apps/browser-demos/lib/browser-engine.ts | 13 +++++++++++ apps/browser-demos/lib/kernel-owned-boot.ts | 8 ++----- host/test/browser-engine-detection.test.ts | 24 +++++++++++++++++++++ 3 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 apps/browser-demos/lib/browser-engine.ts create mode 100644 host/test/browser-engine-detection.test.ts diff --git a/apps/browser-demos/lib/browser-engine.ts b/apps/browser-demos/lib/browser-engine.ts new file mode 100644 index 0000000000..5b2fdc3128 --- /dev/null +++ b/apps/browser-demos/lib/browser-engine.ts @@ -0,0 +1,13 @@ +/** + * Whether a user-agent identifies WebKit without another browser engine's + * compatibility token. Chromium-based browsers also include `AppleWebKit`, + * so that token alone would incorrectly enable WebKit-only memory handling. + */ +export function isWebKitLikeUserAgent(userAgent: string): boolean { + return /AppleWebKit/i.test(userAgent) + && !/(Chrome|Chromium|CriOS|Edg|OPR|Firefox|FxiOS)/i.test(userAgent); +} + +export function isWebKitLikeBrowser(): boolean { + return isWebKitLikeUserAgent(navigator.userAgent); +} diff --git a/apps/browser-demos/lib/kernel-owned-boot.ts b/apps/browser-demos/lib/kernel-owned-boot.ts index d8352b30a4..99be8c9746 100644 --- a/apps/browser-demos/lib/kernel-owned-boot.ts +++ b/apps/browser-demos/lib/kernel-owned-boot.ts @@ -10,10 +10,12 @@ // and nudge WebKit's collector to reclaim it between boots. import { MemoryFileSystem } from "@host/vfs/memory-fs"; import { overlayEtcFromRootfs } from "@host/vfs/rootfs-overlay"; +import { isWebKitLikeBrowser } from "./browser-engine"; // @ts-expect-error — vite ?url virtual module (resolved by the kernel-artifacts plugin) import rootfsVfsUrl from "@rootfs-vfs?url"; export { overlayEtcFromRootfs }; +export { isWebKitLikeBrowser, isWebKitLikeUserAgent } from "./browser-engine"; const WEBKIT_RECLAIM_TIMEOUT_MS = 1_500; const WEBKIT_RECLAIM_STEP_MS = 150; @@ -28,12 +30,6 @@ const imageBufferRegistry = }) : null; -export function isWebKitLikeBrowser(): boolean { - const ua = navigator.userAgent; - return /AppleWebKit/i.test(ua) - && !/(Chrome|Chromium|CriOS|Edg|OPR|Firefox|FxiOS)/i.test(ua); -} - /** * Track a transient image-build buffer so {@link settleWebKitReclaim} can wait * for its reclamation instead of guessing with a fixed delay. The registry diff --git a/host/test/browser-engine-detection.test.ts b/host/test/browser-engine-detection.test.ts new file mode 100644 index 0000000000..97a5e460d0 --- /dev/null +++ b/host/test/browser-engine-detection.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { isWebKitLikeUserAgent } from "../../apps/browser-demos/lib/browser-engine"; + +describe("browser engine detection", () => { + it("selects Safari and WebKit without selecting compatibility tokens", () => { + const safari = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/605.1.15 Version/17.4 Safari/605.1.15"; + const alternatives = [ + "AppleWebKit/537.36 Chrome/126.0.0.0 Safari/537.36", + "AppleWebKit/605.1.15 CriOS/126.0 Mobile/15E148 Safari/604.1", + "AppleWebKit/537.36 Chromium/126.0 Safari/537.36", + "AppleWebKit/537.36 Chrome/126.0 Safari/537.36 Edg/126.0", + "AppleWebKit/537.36 Chrome/126.0 Safari/537.36 OPR/112.0", + "Gecko/20100101 Firefox/128.0", + "AppleWebKit/605.1.15 FxiOS/128.0 Mobile/15E148 Safari/605.1.15", + ]; + + expect(isWebKitLikeUserAgent(safari)).toBe(true); + for (const userAgent of alternatives) { + expect(isWebKitLikeUserAgent(userAgent)).toBe(false); + } + }); +}); From ce71445c733b84ddb8311fe3419e0d13ec3b72be Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sat, 20 Jun 2026 12:25:29 -0400 Subject: [PATCH 24/82] Performance: Avoid munmap mapping-vector churn Mutate the sorted mapping vector in place instead of draining and rebuilding it for every munmap call. Preserve the current ABI 43 page-rounding behavior and retain the vector allocation when entries are removed. Keep the original kernel-trap syscall-ring diagnostic while adapting it to the current fatal-entry and blocking-retry cleanup paths. Validated with: scripts/dev-shell.sh -- scripts/ci-run-test-suite.sh cargo-kernel scripts/dev-shell.sh -- npm --prefix host run typecheck End-to-end performance has not been measured yet; that remains part of the batched Node and browser validation. (cherry picked from commit 18a5aa3f7c925ef293aa36ec47033ebe75340f98) --- crates/kernel/src/memory.rs | 92 +++++++++++++++++++++++++++---------- host/src/kernel-worker.ts | 14 +++++- 2 files changed, 81 insertions(+), 25 deletions(-) diff --git a/crates/kernel/src/memory.rs b/crates/kernel/src/memory.rs index dc13ec36ec..b9b4f090e9 100644 --- a/crates/kernel/src/memory.rs +++ b/crates/kernel/src/memory.rs @@ -321,41 +321,53 @@ impl MemoryManager { }; let unmap_end = addr.saturating_add(aligned_len); let mut found = false; - let mut new_mappings: Vec = Vec::new(); - for m in self.mappings.drain(..) { - let m_end = m.addr.saturating_add(m.len); + let mut i = 0; + while i < self.mappings.len() { + let m_addr = self.mappings[i].addr; + let m_len = self.mappings[i].len; + let m_end = m_addr.saturating_add(m_len); - // No overlap — keep as is - if m_end <= addr || m.addr >= unmap_end { - new_mappings.push(m); + if m_end <= addr { + i += 1; continue; } + if m_addr >= unmap_end { + break; + } found = true; - // Left remnant: mapping starts before unmap region - if m.addr < addr { - new_mappings.push(MappedRegion { - addr: m.addr, - len: addr - m.addr, - prot: m.prot, - flags: m.flags, - }); - } + let has_left = m_addr < addr; + let has_right = m_end > unmap_end; - // Right remnant: mapping extends past unmap region - if m_end > unmap_end { - new_mappings.push(MappedRegion { - addr: unmap_end, - len: m_end - unmap_end, - prot: m.prot, - flags: m.flags, - }); + match (has_left, has_right) { + (false, false) => { + self.mappings.remove(i); + } + (true, false) => { + self.mappings[i].len = addr - m_addr; + i += 1; + } + (false, true) => { + self.mappings[i].addr = unmap_end; + self.mappings[i].len = m_end - unmap_end; + break; + } + (true, true) => { + let right = MappedRegion { + addr: unmap_end, + len: m_end - unmap_end, + prot: self.mappings[i].prot, + flags: self.mappings[i].flags, + }; + self.mappings[i].len = addr - m_addr; + self.mappings.insert(i + 1, right); + break; + } } } - self.mappings = new_mappings; found } @@ -893,6 +905,38 @@ mod tests { assert_eq!(mm.mmap_anonymous(0, 0x20000, rw, anon), addr); } + #[test] + fn test_munmap_does_not_rebuild_mapping_vec_for_middle_removal() { + let mut mm = MemoryManager::new(); + mm.mappings.reserve(16); + let first = mm.mmap_anonymous( + 0, + 0x10000, + PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, + ); + let second = mm.mmap_anonymous( + 0, + 0x10000, + PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, + ); + let third = mm.mmap_anonymous( + 0, + 0x10000, + PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, + ); + let capacity_before = mm.mappings.capacity(); + + assert!(mm.munmap(second, 0x10000)); + + assert_eq!(mm.mappings.capacity(), capacity_before); + assert_eq!(mm.mappings.len(), 2); + assert_eq!(mm.mappings[0].addr, first); + assert_eq!(mm.mappings[1].addr, third); + } + #[test] fn test_brk() { let mut mm = MemoryManager::new(); diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index 8d1c434093..9fdc21e112 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -12247,7 +12247,19 @@ export class CentralizedKernelWorker { } // If the kernel throws (e.g., invalid memory access), complete the // channel with -EIO to unblock the process rather than deadlocking. - if (logging) console.error(logEntry + " = KERNEL THROW"); + const recentSyscalls = this.dumpLastSyscalls(channel.pid); + const throwEntry = logEntry || this.formatSyscallEntry( + channel, + syscallNr, + origArgs, + diagnosticArgs, + ); + console.error(throwEntry + " = KERNEL THROW"); + if (recentSyscalls) { + console.error( + `[handleSyscall] recent syscalls for pid=${channel.pid}:\n${recentSyscalls}`, + ); + } console.error( `[handleSyscall] kernel threw for pid=${channel.pid} syscall=${syscallNr} args=[${diagnosticArgs}]:`, err, From fa7d554afc26f15c17741bcd8350c42dd78374eb Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sun, 21 Jun 2026 20:03:47 -0400 Subject: [PATCH 25/82] Kernel: Reject late reaped-process syscalls (cherry picked from commit 076362bb6deaee6a27f635a30ad8f53e426b57f3) --- host/test/kernel-late-channel.test.ts | 102 ++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 host/test/kernel-late-channel.test.ts diff --git a/host/test/kernel-late-channel.test.ts b/host/test/kernel-late-channel.test.ts new file mode 100644 index 0000000000..578e811002 --- /dev/null +++ b/host/test/kernel-late-channel.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolveBinary } from "../src/binary-resolver"; +import { detectPtrWidth } from "../src/constants"; +import { + ABI_SYSCALLS, + CH_ARGS, + CH_ARG_SIZE, + CH_ERRNO, + CH_RETURN, + CH_SYSCALL, + CH_TOTAL_SIZE, +} from "../src/generated/abi"; + +const ESRCH = 3; + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); +} + +async function instantiateKernelOnly(bytes: Uint8Array): Promise { + const ptrWidth = detectPtrWidth(toArrayBuffer(bytes)); + const memory = ptrWidth === 8 + ? new WebAssembly.Memory({ + initial: 24n, + maximum: 16384n, + shared: true, + address: "i64", + } as unknown as WebAssembly.MemoryDescriptor) + : new WebAssembly.Memory({ + initial: 24, + maximum: 16384, + shared: true, + }); + const module = await WebAssembly.compile(bytes as BufferSource); + const importObject: WebAssembly.Imports = { env: { memory } }; + const envImports = importObject.env as Record; + for (const imp of WebAssembly.Module.imports(module)) { + if (imp.module !== "env" || imp.name === "memory") continue; + envImports[imp.name] ??= + imp.kind === "function" + ? (..._args: unknown[]) => 0 + : imp.kind === "global" + ? new WebAssembly.Global({ value: "i32", mutable: true }, 0) + : undefined; + } + return await WebAssembly.instantiate(module, importObject); +} + +describe("kernel_handle_channel", () => { + it("returns ESRCH instead of trapping for a late syscall from a reaped process", async () => { + const instance = await instantiateKernelOnly(readFileSync(resolveBinary("kernel.wasm"))); + const memory = instance.exports.memory as WebAssembly.Memory; + const allocScratch = instance.exports.kernel_alloc_scratch as (size: number) => number; + const createProcess = instance.exports.kernel_create_process as () => number; + const forkProcess = instance.exports.kernel_fork_process as ( + parentPid: number, + callerTid: number, + ) => number; + const markProcessSignaled = instance.exports.kernel_mark_process_signaled as ( + pid: number, + signum: number, + ) => number; + const reapExitedChild = instance.exports.kernel_reap_exited_child as ( + parentPid: number, + childPid: number, + ) => number; + const handleChannel = instance.exports.kernel_handle_channel as ( + offset: number, + capacity: number, + pid: number, + retryToken: bigint, + ) => number; + + const parentPid = createProcess(); + const childPid = forkProcess(parentPid, parentPid); + const channelOffset = allocScratch(CH_TOTAL_SIZE); + const channel = new Uint8Array(memory.buffer, channelOffset, CH_TOTAL_SIZE); + channel.fill(0); + const view = new DataView(memory.buffer, channelOffset, CH_TOTAL_SIZE); + + expect(parentPid).toBeGreaterThan(0); + expect(childPid).toBeGreaterThan(0); + expect(markProcessSignaled(childPid, 11)).toBe(0); + expect(reapExitedChild(parentPid, childPid)).toBe(0); + + channel.fill(0); + view.setUint32(CH_SYSCALL, ABI_SYSCALLS.Mmap, true); + view.setBigInt64(CH_ARGS + CH_ARG_SIZE * 0, 0n, true); + view.setBigInt64(CH_ARGS + CH_ARG_SIZE * 1, 4096n, true); + view.setBigInt64(CH_ARGS + CH_ARG_SIZE * 2, 0n, true); + view.setBigInt64(CH_ARGS + CH_ARG_SIZE * 3, 0x22n, true); + view.setBigInt64(CH_ARGS + CH_ARG_SIZE * 4, -1n, true); + view.setBigInt64(CH_ARGS + CH_ARG_SIZE * 5, 0n, true); + + expect(handleChannel(channelOffset, CH_TOTAL_SIZE, childPid, 0n)).toBe( + -ESRCH, + ); + expect(view.getBigInt64(CH_RETURN, true)).toBe(-1n); + expect(view.getUint32(CH_ERRNO, true)).toBe(ESRCH); + }); +}); From 4caaa437cc19ececdf02a3ccfe6514e9a20107f6 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Wed, 8 Jul 2026 23:23:04 -0400 Subject: [PATCH 26/82] Network: Deliver loopback UDP across processes POSIX loopback is machine-scoped. Kandelo previously searched only the sender process socket arena, so an unconnected sendto could report success while dropping the datagram and a connected socket could retain a false ECONNREFUSED. Capture an owned route after the process-local send, release the direct process-table borrows, and then deliver to one accepting foreign endpoint. This preserves sender metadata, avoids duplicate delivery across inherited or SO_REUSEADDR bindings, and clears the provisional connected-socket error only after successful foreign delivery. This changes no ABI exports, imports, or repr(C) layouts. Validated with: scripts/dev-shell.sh -- scripts/ci-run-test-suite.sh cargo-kernel scripts/dev-shell.sh -- bash scripts/check-abi-version.sh scripts/dev-shell.sh -- npm run docs:build (cherry picked from commit c413bd85e8bd0bd7a84f482a926f6ea765f168bc) Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/kernel/src/syscalls.rs | 287 ++++++++++++++++++++++++++++++++++ crates/kernel/src/wasm_api.rs | 36 +++++ docs/posix-status.md | 2 +- 3 files changed, 324 insertions(+), 1 deletion(-) diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index ffc7aade4b..7a55ce54c5 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -10527,6 +10527,158 @@ pub fn inject_udp_datagram_into( -(Errno::ECONNREFUSED as i32) } +/// Owned routing facts captured after a successful process-local UDP send. +/// +/// The exported Wasm wrappers finish using their borrowed [`Process`] before +/// re-entering the machine-wide process table to complete cross-process +/// loopback delivery. Keeping only scalar identity and address data across +/// that boundary prevents two mutable references to the same process state. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct CrossProcessLoopbackUdpRoute { + sender_pid: u32, + sender_sock_idx: usize, + sender_uid: u32, + sender_gid: u32, + connected: bool, + dst_addr: [u8; 4], + dst_port: u16, + src_addr: [u8; 4], + src_port: u16, +} + +/// Capture an IPv4 loopback route after `send`, `sendto`, or `sendmsg` has +/// completed its process-local work. +pub(crate) fn cross_process_loopback_udp_route( + proc: &Process, + fd: i32, + addr: Option<&[u8]>, +) -> Option { + use crate::socket::{SocketDomain, SocketState, SocketType}; + + let ofd_idx = resolve_io_ofd(proc, fd).ok()?; + let ofd = proc.ofd_table.get(ofd_idx)?; + if ofd.file_type != FileType::Socket { + return None; + } + let sender_sock_idx = (-(ofd.host_handle + 1)) as usize; + let sock = proc.sockets.get(sender_sock_idx)?; + if sock.domain != SocketDomain::Inet || sock.sock_type != SocketType::Dgram { + return None; + } + + let (dst_addr, dst_port) = match addr.filter(|addr| !addr.is_empty()) { + Some(addr) => parse_sockaddr_in(addr).ok()?, + None if sock.state == SocketState::Connected => (sock.peer_addr, sock.peer_port), + None => return None, + }; + let dst_addr = udp_canonical_dst_addr(dst_addr); + if !is_loopback_addr(dst_addr) { + return None; + } + let src_addr = if sock.bind_addr == [0; 4] { + udp_route_local_addr(dst_addr) + } else { + sock.bind_addr + }; + + Some(CrossProcessLoopbackUdpRoute { + sender_pid: proc.pid, + sender_sock_idx, + sender_uid: proc.uid, + sender_gid: proc.gid, + connected: sock.state == SocketState::Connected, + dst_addr, + dst_port, + src_addr, + src_port: sock.bind_port, + }) +} + +/// Deliver one successful IPv4 loopback send to an accepting socket owned by +/// another process on this machine. +/// +/// Process-local UDP delivery already chooses one accepting endpoint. If the +/// sender owns such an endpoint, it has already received the datagram and this +/// function must not duplicate it into an inherited or `SO_REUSEADDR` peer. +/// Otherwise, choose the first accepting foreign endpoint by the same registry +/// order. A full UDP queue still counts as delivery because UDP drops incoming +/// datagrams rather than reporting receiver backpressure to the sender. +pub(crate) fn deliver_cross_process_loopback_udp( + table: &mut crate::process_table::ProcessTable, + route: CrossProcessLoopbackUdpRoute, + data: &[u8], +) -> bool { + use crate::socket::Datagram; + + let endpoints = crate::socket::udp_lookup(route.dst_addr, route.dst_port); + let sender_already_received = table.get(route.sender_pid).is_some_and(|sender| { + endpoints.iter().any(|endpoint| { + endpoint.pid == route.sender_pid + && sender + .sockets + .get(endpoint.sock_idx) + .is_some_and(|sock| { + udp_socket_accepts_datagram(sock, route.src_addr, route.src_port) + }) + }) + }); + if sender_already_received { + return false; + } + + let mut delivered = false; + for endpoint in endpoints { + if endpoint.pid == route.sender_pid { + continue; + } + let Some(target) = table.get_mut(endpoint.pid) else { + continue; + }; + let accepts = target + .sockets + .get(endpoint.sock_idx) + .is_some_and(|sock| { + udp_socket_accepts_datagram(sock, route.src_addr, route.src_port) + }); + if !accepts { + continue; + } + let Some(socket) = target.sockets.get_mut(endpoint.sock_idx) else { + continue; + }; + udp_queue_datagram(socket, || Datagram { + data: data.to_vec(), + src_addr: route.src_addr, + src_addr6: [0; 16], + dst_addr: route.dst_addr, + dst_addr6: [0; 16], + src_port: route.src_port, + // Socket indices are process-local and cannot name the sender from + // the receiving process. + src_sock_idx: None, + ipv6_tclass: 0, + src_pid: route.sender_pid, + src_uid: route.sender_uid, + src_gid: route.sender_gid, + ancillary_fds: Vec::new(), + }); + delivered = true; + break; + } + + if delivered && route.connected { + // The process-local send records ECONNREFUSED when it cannot see a + // receiver in its own socket arena. Cross-process delivery proves that + // provisional error false for this send. + if let Some(sender) = table.get_mut(route.sender_pid) { + if let Some(socket) = sender.sockets.get_mut(route.sender_sock_idx) { + socket.connect_error = 0; + } + } + } + delivered +} + /// getsockname -- get local socket address. /// /// For AF_INET sockets, writes a full 16-byte sockaddr_in: @@ -33205,6 +33357,141 @@ mod tests { assert_eq!(from_addr[0], 2); // AF_INET } + #[test] + fn test_udp_loopback_cross_process() { + use wasm_posix_shared::socket::*; + + struct UdpProcessCleanup([u32; 2]); + impl Drop for UdpProcessCleanup { + fn drop(&mut self) { + for pid in self.0 { + crate::socket::udp_cleanup_process(pid); + } + } + } + + let mut table = crate::process_table::ProcessTable::new(); + let sender_pid = table.create_process().unwrap(); + let receiver_pid = table.create_process().unwrap(); + let _cleanup = UdpProcessCleanup([sender_pid, receiver_pid]); + let mut host = MockHostIO::new(); + + let (recv_fd, destination) = { + let receiver = table.get_mut(receiver_pid).unwrap(); + let recv_fd = sys_socket(receiver, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); + let mut bind_addr = [0u8; 16]; + bind_addr[0] = AF_INET as u8; + sys_bind(receiver, &mut host, recv_fd, &bind_addr).unwrap(); + + let mut destination = [0u8; 16]; + sys_getsockname(receiver, recv_fd, &mut destination).unwrap(); + destination[4..8].copy_from_slice(&[127, 0, 0, 1]); + (recv_fd, destination) + }; + let send_fd = { + let sender = table.get_mut(sender_pid).unwrap(); + sys_socket(sender, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap() + }; + + let route = { + let sender = table.get_mut(sender_pid).unwrap(); + assert_eq!( + sys_sendto( + sender, + &mut host, + send_fd, + b"cross-process sendto", + 0, + &destination, + ) + .unwrap(), + 20, + ); + cross_process_loopback_udp_route(sender, send_fd, Some(&destination)).unwrap() + }; + assert!(deliver_cross_process_loopback_udp( + &mut table, + route, + b"cross-process sendto", + )); + + let mut payload = [0u8; 64]; + let mut source = [0u8; 16]; + { + let receiver = table.get_mut(receiver_pid).unwrap(); + let (len, addr_len) = sys_recvfrom( + receiver, + &mut host, + recv_fd, + &mut payload, + 0, + &mut source, + ) + .unwrap(); + assert_eq!(&payload[..len], b"cross-process sendto"); + assert_eq!(addr_len, 16); + assert_eq!(&source[4..8], &[127, 0, 0, 1]); + assert_ne!(u16::from_be_bytes([source[2], source[3]]), 0); + } + + // Connected UDP initially records ECONNREFUSED when its process-local + // arena has no receiver. Successful machine-wide delivery must clear + // that provisional error so the next send does not fail spuriously. + let route = { + let sender = table.get_mut(sender_pid).unwrap(); + sys_connect(sender, &mut host, send_fd, &destination).unwrap(); + assert_eq!( + sys_send(sender, &mut host, send_fd, b"connected one", 0).unwrap(), + 13, + ); + cross_process_loopback_udp_route(sender, send_fd, None).unwrap() + }; + assert!(deliver_cross_process_loopback_udp( + &mut table, + route, + b"connected one", + )); + { + let sender = table.get_mut(sender_pid).unwrap(); + assert_eq!( + sys_getsockopt(sender, send_fd, SOL_SOCKET, SO_ERROR).unwrap(), + 0, + ); + assert_eq!( + sys_send(sender, &mut host, send_fd, b"connected two", 0).unwrap(), + 13, + ); + } + + let route = { + let sender = table.get(sender_pid).unwrap(); + cross_process_loopback_udp_route(sender, send_fd, None).unwrap() + }; + assert!(deliver_cross_process_loopback_udp( + &mut table, + route, + b"connected two", + )); + { + let receiver = table.get_mut(receiver_pid).unwrap(); + for expected in [b"connected one".as_slice(), b"connected two".as_slice()] { + let (len, _) = sys_recvfrom( + receiver, + &mut host, + recv_fd, + &mut payload, + 0, + &mut source, + ) + .unwrap(); + assert_eq!(&payload[..len], expected); + } + sys_close(receiver, &mut host, recv_fd).unwrap(); + } + let sender = table.get_mut(sender_pid).unwrap(); + sys_close(sender, &mut host, send_fd).unwrap(); + } + #[test] fn test_ipv4_limited_broadcast_requires_so_broadcast() { let mut proc = Process::new(9060); diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index b995febd3e..7c4410daf5 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -8541,6 +8541,9 @@ pub extern "C" fn kernel_sendmsg(fd: i32, msg_ptr: *const u8, flags: u32, retry_ Ok(n) => n as i32, Err(e) => -(e as i32), }; + let cross_process_udp = (result >= 0) + .then(|| syscalls::cross_process_loopback_udp_route(proc, fd, addr)) + .flatten(); if result == -(Errno::EAGAIN as i32) { if let Err(error) = syscalls::ensure_blocking_retry_ofd_binding( proc, @@ -8558,6 +8561,7 @@ pub extern "C" fn kernel_sendmsg(fd: i32, msg_ptr: *const u8, flags: u32, retry_ syscalls::drain_deferred_scm_rights_releases(advisory_locks, &mut host); deliver_pending_signals_for_known_tid(proc, advisory_locks, &mut host, tid); + complete_cross_process_loopback_udp(cross_process_udp, buf); result } @@ -9690,6 +9694,24 @@ fn cross_process_unix_connect( Ok(()) } +/// Finish a machine-local IPv4 UDP send after all direct references into the +/// process table have reached their last use. +/// +/// WHY: `get_process*()` returns mutable references backed by the global +/// `UnsafeCell`. Re-entering `PROCESS_TABLE` while one of those references is +/// still live could alias the sender. Callers therefore capture only an owned +/// route, finish retry/signal cleanup, and invoke this helper last. +fn complete_cross_process_loopback_udp( + route: Option, + data: &[u8], +) { + let Some(route) = route else { + return; + }; + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + syscalls::deliver_cross_process_loopback_udp(table, route, data); +} + #[cfg(test)] mod socket_wrapper_tests { use super::{cross_process_unix_connect, write_getsockopt_bytes, WasmHostIO}; @@ -9769,7 +9791,11 @@ pub extern "C" fn kernel_send(fd: i32, buf_ptr: *const u8, buf_len: u32, flags: Ok(n) => n as i32, Err(e) => -(e as i32), }; + let cross_process_udp = (result >= 0) + .then(|| syscalls::cross_process_loopback_udp_route(proc, fd, None)) + .flatten(); deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); + complete_cross_process_loopback_udp(cross_process_udp, buf); result } @@ -10236,7 +10262,17 @@ pub extern "C" fn kernel_sendto( Ok(n) => n as i32, Err(e) => -(e as i32), }; + let cross_process_udp = (result >= 0) + .then(|| { + syscalls::cross_process_loopback_udp_route( + proc, + fd, + (!addr.is_empty()).then_some(addr), + ) + }) + .flatten(); deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); + complete_cross_process_loopback_udp(cross_process_udp, buf); result } diff --git a/docs/posix-status.md b/docs/posix-status.md index 3b6d9b60de..a80b345d92 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -277,7 +277,7 @@ shortcuts. | `accept()` / `accept4()` | Partial | AF_INET TCP delegates to the active HostIO networking backend; AF_UNIX and AF_INET6 loopback streams return connected sockets from the shared kernel queue. A dual-stack IPv6 listener reports IPv4 peers as IPv4-mapped `sockaddr_in6`. Linux-style accept does not inherit O_NONBLOCK; accept4 applies SOCK_NONBLOCK and SOCK_CLOEXEC explicitly and rejects other flags before consuming a pending connection. Datagram accept rejects as unsupported. | | `connect()` | Partial | AF_UNIX streams support same- and cross-process pathname or abstract-namespace listeners; pathname lookup uses the same canonical component walker as bind, including cross-process retries. AF_UNIX datagrams deliver to a registered peer only within the same process; a missing, wrong-type, or cross-process peer returns ECONNREFUSED until machine-wide datagram routing exists. AF_INET TCP is host-backed and works over Node external TCP or the browser local virtual-network backend. For an external non-blocking TCP handshake, the first pending call reports EINPROGRESS, a repeat while it remains pending reports EALREADY, and poll reports writable when completion or failure can be collected through SO_ERROR; blocking callers wait through the same host connection. AF_INET UDP connect stores the peer, auto-binds an ephemeral local port when needed, filters receives to the connected peer, and supports AF_UNSPEC unconnect. AF_INET6 streams support same- and cross-process `::1`; AF_INET6 datagrams are process-local and report `IPV6_V6ONLY=1` because dual-stack datagram routing is not implemented. Non-loopback IPv6 fails with EADDRNOTAVAIL for streams and ENETUNREACH for datagrams. External raw UDP also returns ENETUNREACH without another HostIO transport. | | `send()` / `recv()` | Partial | Unix domain streams and datagrams, AF_INET/AF_INET6 TCP streams, and connected AF_INET/AF_INET6 UDP preserve their socket-family addressing and datagram boundaries. TCP send/recv works over Node external TCP and the local virtual-network backend. Datagram MSG_PEEK and MSG_DONTWAIT are handled through recvfrom. Normal TCP close drains queued bytes before FIN and EOF; no transport invents a fixed post-FIN write count. A send rejected by a closed/reset stream returns EPIPE and raises SIGPIPE, while direct host/virtual handles may preserve ECONNRESET; accepted pipe-bridged resets currently surface as EOF/EPIPE. MSG_NOSIGNAL suppresses SIGPIPE without changing the errno. | -| `sendto()` / `recvfrom()` | Partial | AF_INET, AF_INET6, and AF_UNIX datagrams support connected and unconnected send, receive queues, and connected-peer filtering. IPv4/IPv6 return sender addresses; AF_UNIX currently returns only the family. IPv4 limited-broadcast sends to `255.255.255.255` require `SO_BROADCAST` and fail with `EACCES` without it; enabling the option passes that permission gate, after which the send reaches the active routing/backend boundary. Kandelo does not itself model broadcast delivery. On AF_INET, AF_INET6, and AF_UNIX datagrams, Linux's input `MSG_TRUNC` extension returns the full datagram length while copying at most the caller's buffer; ordinary consume/`MSG_PEEK` behavior is unchanged. IPv4/IPv6 UDP receive queues hold 128 datagrams and drop a new arrival once full, preserving the accepted queue's order; `SO_RCVBUF` requests do not size that fixed queue, and `getsockopt` reports the fixed default capacity. AF_UNIX uses the same bound but preserves reliable delivery: a full queue blocks a blocking send through host retry and returns EAGAIN for `O_NONBLOCK`/`MSG_DONTWAIT`; capacity, association, shutdown, close, and pathname changes wake blocked sends and writable readiness waits to observe capacity or the new immediate error. In-kernel IPv4/IPv6 loopback, AF_UNIX datagram, and IPv4 multicast delivery currently reaches sockets in the sender's process only; machine-wide cross-process datagram routing remains unimplemented. Fork preserves kernel-local bind reservations and lookup ownership, but it does not yet share or transfer a host-backed UDP registration. The `10.88.*` LocalVirtualNetwork path can route IPv4 datagrams between attached Kandelo machines through HostIO for the process that registered the endpoint. IPv4 multicast supports interface selection, loop suppression, membership, and source filtering only; IPv6 multicast and external raw UDP are not implemented. | +| `sendto()` / `recvfrom()` | Partial | AF_INET, AF_INET6, and AF_UNIX datagrams support connected and unconnected send, receive queues, and connected-peer filtering. IPv4/IPv6 return sender addresses; AF_UNIX currently returns only the family. IPv4 limited-broadcast sends to `255.255.255.255` require `SO_BROADCAST` and fail with `EACCES` without it; enabling the option passes that permission gate, after which the send reaches the active routing/backend boundary. Kandelo does not itself model broadcast delivery. On AF_INET, AF_INET6, and AF_UNIX datagrams, Linux's input `MSG_TRUNC` extension returns the full datagram length while copying at most the caller's buffer; ordinary consume/`MSG_PEEK` behavior is unchanged. IPv4/IPv6 UDP receive queues hold 128 datagrams and drop a new arrival once full, preserving the accepted queue's order; `SO_RCVBUF` requests do not size that fixed queue, and `getsockopt` reports the fixed default capacity. AF_UNIX uses the same bound but preserves reliable delivery: a full queue blocks a blocking send through host retry and returns EAGAIN for `O_NONBLOCK`/`MSG_DONTWAIT`; capacity, association, shutdown, close, and pathname changes wake blocked sends and writable readiness waits to observe capacity or the new immediate error. In-kernel IPv4 loopback unicast reaches one accepting socket across processes on the same machine. IPv6 loopback, AF_UNIX datagrams, and IPv4 multicast remain process-local; generic machine-wide cross-process datagram routing is not implemented. Fork preserves kernel-local bind reservations and lookup ownership, but it does not yet share or transfer a host-backed UDP registration. The `10.88.*` LocalVirtualNetwork path can route IPv4 datagrams between attached Kandelo machines through HostIO for the process that registered the endpoint. IPv4 multicast supports interface selection, loop suppression, membership, and source filtering only; IPv6 multicast and external raw UDP are not implemented. | | `sendmsg()` / `recvmsg()` | Partial | The host validates every native wasm32/wasm64 iovec and enforces the generated `IOV_MAX` of 1,024. It flattens the complete send list into one fixed-wire kernel buffer and scatters a received prefix across the complete caller list; zero-length entries remain valid. The complete aligned header, optional name, translated control records, one canonical iovec, and payload are capacity-checked as one owned layout: the ordinary channel allocation is used when it fits, and a fresh Rust-owned token reservation is used otherwise. The operation is never shortened merely to fit scratch. Native `cmsghdr` records are translated between the generated wasm32/wasm64 layouts and a fixed kernel wire, so receive capacity reflects the descriptors the caller layout can actually represent. `SCM_RIGHTS` preserves owned, receiver-reconstructible non-socket descriptions while they are queued. A batch containing a socket, epoll instance, stale backing, or other process-owned description that cannot be reconstructed fails atomically with `EOPNOTSUPP` before carrier bytes are published; a copied socket snapshot is never reported as successful transfer. AF_UNIX stream rights remain associated with their carrier-byte positions, `MSG_WAITALL` stops at a rights boundary, ordinary reads discard only rights whose bytes they consume, and repeated `MSG_PEEK` does not consume bytes or rights. AF_UNIX datagrams queue payload/address/rights atomically for connected and addressed same-process sends, including zero-byte messages received with `msg_iovlen == 0`; ordinary `read(..., 0)` remains a no-op. Closing the sender's fd cannot invalidate a supported in-flight or received reference. `recvmsg()` installs the descriptor prefix that fits the caller's control buffer, releases the excess, reports `MSG_CTRUNC`, applies `MSG_CMSG_CLOEXEC` atomically, and reports output `MSG_TRUNC` independently of the input flag that selects full-length return behavior. Cross-process AF_UNIX datagram routing, socket-descriptor transfer, and other socket-family ancillary messages remain unsupported, so this surface is still partial. | | `setsockopt()` / `getsockopt()` | Partial | SOL_SOCKET exposes SO_TYPE, SO_DOMAIN, SO_ERROR, SO_ACCEPTCONN, SO_RCVBUF, and SO_SNDBUF; SO_REUSEADDR affects UDP bind conflicts. The public host scalar `setsockopt` wrapper stages exactly four value bytes in allocator-owned scratch and passes the lease-derived pointer plus length to Rust; the scalar is never interpreted as a kernel address. `SO_RCVTIMEO`/`SO_SNDTIMEO` accept musl's wasm32 time64 option numbers (66/67) and wasm64 long64 numbers (20/21), canonicalizing both to the same stored timeout state; `struct timeval` is 16 bytes on both ABIs. `SO_RCVBUF`/`SO_SNDBUF` requests are accepted and stored but do not resize kernel queues or pipe buffers; `getsockopt()` reports the fixed default. `SO_BROADCAST` controls only the IPv4 limited-broadcast permission gate and does not provide broadcast delivery. SO_LINGER uses `struct linger`; its disabled form is stored, while enabling timed or reset-style linger returns EOPNOTSUPP until every transport supports the close mode. SO_BINDTODEVICE validates `lo`/`eth0`, supports empty-name unbind, and constrains bind/connect/send routing. TCP_CONGESTION uses a string layout and accepts only the modeled `cubic` policy; selecting unimplemented algorithms fails. IPv4 multicast membership/source-filter options drive process-local loopback delivery. IPV6_V6ONLY controls pre-bind stream dual-stack behavior; AF_INET6 datagrams truthfully remain V6-only. Other accepted IPv6 multicast options are stored but do not provide IPv6 multicast transport. | | `shutdown()` | Partial | SHUT_RD, SHUT_WR, and SHUT_RDWR transitions are idempotent within a process and release each owned pipe/host reference once. UDP write shutdown returns EPIPE on datagram send; read shutdown is EOF-like for recv/poll. Sending to a read-shut AF_UNIX datagram peer returns EPIPE (and SIGPIPE unless MSG_NOSIGNAL is used), and the transition wakes blocked sends/readiness waits. Fork-inherited sockets still clone shutdown flags per process instead of sharing one socket-wide shutdown state, and the external host ABI has no half-shutdown operation. | From f54ca70e8e63246a00eb6819c1d5260dad924a7b Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Fri, 10 Jul 2026 23:30:25 -0400 Subject: [PATCH 27/82] Kernel: Preserve descriptor identity through devfs aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following stat and fstatat calls on /dev/fd/N and the standard-stream aliases now return the referenced descriptor’s authoritative metadata. No-follow calls report stable devfs symlink metadata, readlink exposes the alias target, and O_NOFOLLOW rejects the final symlink. Keep devfs directory, getdents, and lstat device/inode metadata coherent. Add a guest regression exercised through NodeKernelHost and BrowserKernel. The browser test uses the current minimal runner so it does not activate unrelated shell-package generations. This fixes GNU coreutils mistaking /dev/null for stdin without changing the ABI. (cherry picked from commit 35f04550738e8d429762cc2c73cac45aa9429fcf) --- apps/browser-demos/test/dev-fd-stat.spec.ts | 25 +++ crates/kernel/src/devfs.rs | 55 ++++- crates/kernel/src/syscalls.rs | 214 ++++++++++++++------ docs/posix-status.md | 10 +- host/test/dev-fd-stat.test.ts | 22 ++ programs/dev-fd-stat.c | 97 +++++++++ 6 files changed, 357 insertions(+), 66 deletions(-) create mode 100644 apps/browser-demos/test/dev-fd-stat.spec.ts create mode 100644 host/test/dev-fd-stat.test.ts create mode 100644 programs/dev-fd-stat.c diff --git a/apps/browser-demos/test/dev-fd-stat.spec.ts b/apps/browser-demos/test/dev-fd-stat.spec.ts new file mode 100644 index 0000000000..dd45f14d98 --- /dev/null +++ b/apps/browser-demos/test/dev-fd-stat.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { tryResolveBinary } from "../../../host/src/binary-resolver"; + +const devFdStatBinary = tryResolveBinary("programs/dev-fd-stat.wasm"); + +test.skip(!devFdStatBinary, "dev-fd-stat.wasm was not built"); + +test("devfs descriptor aliases preserve stat identity in BrowserKernel", async ({ + page, + baseURL, +}) => { + await page.goto(new URL("/pages/test-runner/?minimal=1", baseURL).href); + await page.waitForFunction(() => (window as any).__testRunnerReady === true); + + const bytes = Array.from(readFileSync(devFdStatBinary!)); + const result = await page.evaluate(async (wasmBytes) => { + const wasm = new Uint8Array(wasmBytes).buffer; + return (window as any).__runTest(wasm, ["dev-fd-stat"]); + }, bytes); + + expect(result.exitCode, JSON.stringify(result, null, 2)).toBe(0); + expect(result.stdout).toBe("PASS\n"); + expect(result.stderr).toBe(""); +}); diff --git a/crates/kernel/src/devfs.rs b/crates/kernel/src/devfs.rs index e7d5ea3f1f..f6b862331c 100644 --- a/crates/kernel/src/devfs.rs +++ b/crates/kernel/src/devfs.rs @@ -8,7 +8,7 @@ extern crate alloc; use alloc::vec::Vec; -use wasm_posix_shared::mode::S_IFDIR; +use wasm_posix_shared::mode::{S_IFDIR, S_IFLNK}; use wasm_posix_shared::{Errno, WasmStat}; /// Sentinel host_handle for devfs directory OFDs. @@ -55,7 +55,7 @@ pub fn match_devfs_dir(path: &[u8]) -> Option { pub fn match_devfs_stat(path: &[u8], uid: u32, gid: u32) -> Option { if let Some(_entry) = match_devfs_dir(path) { return Some(WasmStat { - st_dev: 6, + st_dev: 5, st_ino: devfs_ino(path), st_mode: S_IFDIR | 0o755, st_nlink: 2, @@ -74,6 +74,29 @@ pub fn match_devfs_stat(path: &[u8], uid: u32, gid: u32) -> Option { None } +/// Build metadata for a synthetic symlink owned by devfs. +/// +/// The inode comes from the same path-based namespace used by directory +/// entries, so repeated lstat calls and getdents report the same identity. +pub fn devfs_symlink_stat(path: &[u8], target_len: usize, uid: u32, gid: u32) -> WasmStat { + WasmStat { + st_dev: 5, + st_ino: devfs_ino(path), + st_mode: S_IFLNK | 0o777, + st_nlink: 1, + st_uid: uid, + st_gid: gid, + st_size: target_len as u64, + st_atime_sec: 0, + st_atime_nsec: 0, + st_mtime_sec: 0, + st_mtime_nsec: 0, + st_ctime_sec: 0, + st_ctime_nsec: 0, + _pad: 0, + } +} + /// Open a devfs directory, creating an OFD with the sentinel handle. /// Returns the new fd number. pub fn devfs_open_dir( @@ -172,7 +195,8 @@ fn dir_entries(proc: &crate::process::Process, entry: &DevfsEntry) -> Vec<(Vec Option { None } +/// Return the target text exposed by readlink for a devfs descriptor alias. +fn dev_fd_link_target<'a>( + proc: &'a Process, + path: &[u8], + target_fd: i32, +) -> Result<&'a [u8], Errno> { + match path { + b"/dev/stdin" => Ok(b"/dev/fd/0"), + b"/dev/stdout" => Ok(b"/dev/fd/1"), + b"/dev/stderr" => Ok(b"/dev/fd/2"), + _ => { + let entry = proc.fd_table.get(target_fd).map_err(|_| Errno::ENOENT)?; + let ofd = proc + .ofd_table + .get(entry.ofd_ref.0) + .ok_or(Errno::ENOENT)?; + Ok(&ofd.path) + } + } +} + +fn dev_fd_lstat(proc: &Process, path: &[u8], target_fd: i32) -> Result { + let target = dev_fd_link_target(proc, path, target_fd)?; + Ok(crate::devfs::devfs_symlink_stat( + path, + target.len(), + proc.euid, + proc.egid, + )) +} + +fn stat_dev_fd( + proc: &mut Process, + host: &mut dyn HostIO, + target_fd: i32, +) -> Result { + sys_fstat(proc, host, target_fd).map_err(|err| { + if err == Errno::EBADF { + Errno::ENOENT + } else { + err + } + }) +} + /// Try to claim `/dev/fb0` for the calling process. /// /// `/dev/fb0` is single-owner: at most one process at a time can have an @@ -2751,6 +2796,9 @@ pub fn sys_open( // /dev/fd/N and /dev/stdin|stdout|stderr — dup an existing fd if let Some(target_fd) = match_dev_fd(&resolved) { + if oflags & O_NOFOLLOW != 0 { + return Err(Errno::ELOOP); + } let entry = proc.fd_table.get(target_fd)?; let ofd_ref = entry.ofd_ref; proc.ofd_table.inc_ref(ofd_ref.0); @@ -6652,24 +6700,8 @@ pub fn sys_stat(proc: &mut Process, host: &mut dyn HostIO, path: &[u8]) -> Resul if let Some(st) = match_pty_stat(&resolved, proc.euid, proc.egid) { return Ok(st); } - if match_dev_fd(&resolved).is_some() { - use wasm_posix_shared::mode::S_IFCHR; - return Ok(WasmStat { - st_dev: 5, - st_ino: 0, - st_mode: S_IFCHR | 0o666, - st_nlink: 1, - st_uid: proc.euid, - st_gid: proc.egid, - st_size: 0, - st_atime_sec: 0, - st_atime_nsec: 0, - st_mtime_sec: 0, - st_mtime_nsec: 0, - st_ctime_sec: 0, - st_ctime_nsec: 0, - _pad: 0, - }); + if let Some(target_fd) = match_dev_fd(&resolved) { + return stat_dev_fd(proc, host, target_fd); } if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { return procfs_entry_stat(proc, host, &entry, true); @@ -6706,24 +6738,8 @@ pub fn sys_lstat( if let Some(st) = match_pty_stat(&resolved, proc.euid, proc.egid) { return Ok(st); } - if match_dev_fd(&resolved).is_some() { - use wasm_posix_shared::mode::S_IFCHR; - return Ok(WasmStat { - st_dev: 5, - st_ino: 0, - st_mode: S_IFCHR | 0o666, - st_nlink: 1, - st_uid: proc.euid, - st_gid: proc.egid, - st_size: 0, - st_atime_sec: 0, - st_atime_nsec: 0, - st_mtime_sec: 0, - st_mtime_nsec: 0, - st_ctime_sec: 0, - st_ctime_nsec: 0, - _pad: 0, - }); + if let Some(target_fd) = match_dev_fd(&resolved) { + return dev_fd_lstat(proc, &resolved, target_fd); } if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { return procfs_entry_stat(proc, host, &entry, false); @@ -7045,6 +7061,13 @@ pub fn sys_readlink( return Err(Errno::EINVAL); } + if let Some(target_fd) = match_dev_fd(&resolved) { + let target = dev_fd_link_target(proc, &resolved, target_fd)?; + let n = buf.len().min(target.len()); + buf[..n].copy_from_slice(&target[..n]); + return Ok(n); + } + check_search_path(proc, host, &resolved)?; host.host_readlink(&resolved, buf) } @@ -13325,6 +13348,9 @@ pub fn sys_openat( // /dev/fd/N and /dev/stdin|stdout|stderr — dup an existing fd if let Some(target_fd) = match_dev_fd(&resolved) { + if oflags & O_NOFOLLOW != 0 { + return Err(Errno::ELOOP); + } let entry = proc.fd_table.get(target_fd)?; let ofd_ref = entry.ofd_ref; proc.ofd_table.inc_ref(ofd_ref.0); @@ -13543,24 +13569,11 @@ pub fn sys_fstatat( if let Some(dev) = match_virtual_device(&resolved) { return Ok(virtual_device_stat(dev, proc.euid, proc.egid)); } - if match_dev_fd(&resolved).is_some() { - use wasm_posix_shared::mode::S_IFCHR; - return Ok(WasmStat { - st_dev: 5, - st_ino: 0, - st_mode: S_IFCHR | 0o666, - st_nlink: 1, - st_uid: proc.euid, - st_gid: proc.egid, - st_size: 0, - st_atime_sec: 0, - st_atime_nsec: 0, - st_mtime_sec: 0, - st_mtime_nsec: 0, - st_ctime_sec: 0, - st_ctime_nsec: 0, - _pad: 0, - }); + if let Some(target_fd) = match_dev_fd(&resolved) { + if flags & AT_SYMLINK_NOFOLLOW != 0 { + return dev_fd_lstat(proc, &resolved, target_fd); + } + return stat_dev_fd(proc, host, target_fd); } if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { let follow = flags & AT_SYMLINK_NOFOLLOW == 0; @@ -15827,6 +15840,13 @@ pub fn sys_readlinkat( return Err(Errno::EINVAL); } + if let Some(target_fd) = match_dev_fd(&resolved) { + let target = dev_fd_link_target(proc, &resolved, target_fd)?; + let n = buf.len().min(target.len()); + buf[..n].copy_from_slice(&target[..n]); + return Ok(n); + } + check_search_path(proc, host, &resolved)?; host.host_readlink(&resolved, buf) } @@ -16173,7 +16193,7 @@ fn virtual_statfs_for_path(resolved: &[u8], pid: u32) -> Option { || resolved == b"/dev/ptmx" || resolved == b"/dev/tty" || resolved.starts_with(b"/dev/pts/") - || resolved.starts_with(b"/dev/fd/") + || match_dev_fd(resolved).is_some() { return Some(devfs_statfs()); } @@ -32872,11 +32892,62 @@ mod tests { } #[test] - fn test_stat_dev_fd_path() { + fn test_dev_fd_stat_follows_descriptor_but_lstat_reports_symlink() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let st = sys_stat(&mut proc, &mut host, b"/dev/fd/0").unwrap(); - assert_eq!(st.st_mode & 0xF000, wasm_posix_shared::mode::S_IFCHR); + let fd_stat = sys_fstat(&mut proc, &mut host, 0).unwrap(); + + for path in [b"/dev/stdin".as_slice(), b"/dev/fd/0".as_slice()] { + let path_stat = sys_stat(&mut proc, &mut host, path).unwrap(); + assert_eq!(path_stat.st_dev, fd_stat.st_dev); + assert_eq!(path_stat.st_ino, fd_stat.st_ino); + assert_eq!(path_stat.st_mode, fd_stat.st_mode); + + let at_stat = sys_fstatat(&mut proc, &mut host, AT_FDCWD, path, 0).unwrap(); + assert_eq!(at_stat.st_dev, fd_stat.st_dev); + assert_eq!(at_stat.st_ino, fd_stat.st_ino); + assert_eq!(at_stat.st_mode, fd_stat.st_mode); + + let link_stat = sys_lstat(&mut proc, &mut host, path).unwrap(); + assert_eq!(link_stat.st_mode & S_IFMT, S_IFLNK); + let nofollow_stat = sys_fstatat( + &mut proc, + &mut host, + AT_FDCWD, + path, + AT_SYMLINK_NOFOLLOW, + ) + .unwrap(); + assert_eq!(nofollow_stat.st_ino, link_stat.st_ino); + assert_eq!(nofollow_stat.st_mode, link_stat.st_mode); + } + } + + #[test] + fn test_dev_fd_alias_follows_arbitrary_open_descriptor() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/null", O_RDONLY, 0).unwrap(); + let path = alloc::format!("/dev/fd/{fd}").into_bytes(); + + let fd_stat = sys_fstat(&mut proc, &mut host, fd).unwrap(); + let path_stat = sys_stat(&mut proc, &mut host, &path).unwrap(); + assert_eq!(path_stat.st_dev, fd_stat.st_dev); + assert_eq!(path_stat.st_ino, fd_stat.st_ino); + assert_eq!(path_stat.st_mode, fd_stat.st_mode); + + let mut buf = [0u8; 32]; + let n = sys_readlink(&mut proc, &mut host, &path, &mut buf).unwrap(); + assert_eq!(&buf[..n], b"/dev/null"); + } + + #[test] + fn test_dev_stdio_readlink_targets_dev_fd() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let mut buf = [0u8; 32]; + let n = sys_readlink(&mut proc, &mut host, b"/dev/stdin", &mut buf).unwrap(); + assert_eq!(&buf[..n], b"/dev/fd/0"); } #[test] @@ -33037,6 +33108,33 @@ mod tests { assert_eq!(ofd_ref_0, ofd_ref_new); } + #[test] + fn test_open_dev_fd_nofollow_rejects_symlink() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + assert_eq!( + sys_open( + &mut proc, + &mut host, + b"/dev/stdin", + O_RDONLY | O_NOFOLLOW, + 0, + ), + Err(Errno::ELOOP), + ); + assert_eq!( + sys_openat( + &mut proc, + &mut host, + AT_FDCWD, + b"/dev/fd/0", + O_RDONLY | O_NOFOLLOW, + 0, + ), + Err(Errno::ELOOP), + ); + } + #[test] fn test_open_dev_fd_nonexistent() { let mut proc = Process::new(1); diff --git a/docs/posix-status.md b/docs/posix-status.md index a80b345d92..cba84bbd95 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -391,10 +391,10 @@ proves and reserves only the 128-byte prefix the kernel can write. | `/dev/zero` | Full | Read fills buffer with zeros. Write discards data (returns count). | | `/dev/urandom` / `/dev/random` | Full | Read delegates to `host_getrandom()` (crypto.getRandomValues on host). Write discards. | | `/dev/full` | Full | Read fills buffer with zeros. Write returns ENOSPC. | -| `/dev/fd/N` | Full | Open-time dup of fd N. Validates target fd exists (EBADF if not). | -| `/dev/stdin` | Full | Alias for `/dev/fd/0`. | -| `/dev/stdout` | Full | Alias for `/dev/fd/1`. | -| `/dev/stderr` | Full | Alias for `/dev/fd/2`. | +| `/dev/fd/N` | Full | Symlink-like descriptor alias. `open()` duplicates fd N; following `stat()`/`fstatat()` returns the same metadata as `fstat(N)`, while `lstat()`/`AT_SYMLINK_NOFOLLOW` reports the devfs symlink. `readlink()` returns the open file description's path. Opening validates the target fd exists (EBADF if not). | +| `/dev/stdin` | Full | Symlink alias for `/dev/fd/0`; following metadata is fd 0 metadata. | +| `/dev/stdout` | Full | Symlink alias for `/dev/fd/1`; following metadata is fd 1 metadata. | +| `/dev/stderr` | Full | Symlink alias for `/dev/fd/2`; following metadata is fd 2 metadata. | | `/dev/tty` | Partial | Uses the first open PTY-slave OFD as the current controlling-terminal heuristic. When none is open, it currently falls back to fd 0 rather than returning ENXIO; `pathconf()` follows that same OFD selection and therefore does not advertise terminal variables for the captured, pipe-backed case. | | `/dev/ptmx` | Full | PTY master multiplexer. `open()` allocates a new PTY pair, returns master fd. | | `/dev/pts/*` | Full | PTY slave devices. `posix_openpt()` + `grantpt()` + `unlockpt()` + `ptsname()`. Full line discipline, canonical/raw mode, OPOST/ONLCR, 16 terminal ioctls. | @@ -403,7 +403,7 @@ proves and reserves only the 128-byte prefix the kernel can write. | `/dev/dsp` | Full (write-only) | OSS-style PCM audio sink. Single-open (`EBUSY` for second pid). `write()` accepts interleaved 16-bit-LE PCM and buffers it in a 256 KiB ring; the host drains via the `kernel_drain_audio` wasm export and feeds a Web Audio `AudioContext`. ioctls: `SNDCTL_DSP_RESET`, `SNDCTL_DSP_SYNC`, `SNDCTL_DSP_SPEED` (clamp 4000–192000 Hz), `SNDCTL_DSP_STEREO` / `SNDCTL_DSP_CHANNELS` (1 or 2), `SNDCTL_DSP_SETFMT` (only `AFMT_S16_LE`), `SNDCTL_DSP_GETFMTS`, `SNDCTL_DSP_SETFRAGMENT` (accept-and-acknowledge). On overflow drops the *oldest whole frame* — never tears L/R alignment. Ownership and queued samples survive exec with a non-CLOEXEC fd; last close or exit releases and flushes them. `read()` returns 0 (EOF-like). `poll()` reports `POLLOUT` always, never `POLLIN`. No record path, no `mmap`-based zero-copy; DOOM's mixer is in user space. | | `/dev/shm/*` | Partial | POSIX shm objects are regular files used by `shm_open()`. Stable-identity backends support host-coordinated `MAP_SHARED` across processes at syscall boundaries; this is not immediate shared linear memory and does not make process-shared futexes work. | -All virtual devices return synthetic `stat()` with `S_IFCHR | 0666`, deterministic inode numbers, and `st_dev=5`. Path interception in kernel before host delegation — no host filesystem changes needed. `access()` returns OK for all virtual devices. +Character-device entries return synthetic `stat()` with deterministic inode numbers and `st_dev=5`. Descriptor aliases are devfs symlinks: following metadata comes from the referenced descriptor, and no-follow metadata uses a deterministic devfs inode. Path interception happens in the kernel before host delegation, so Node.js and browser hosts share the same behavior without host filesystem changes. `access()` returns OK for all virtual devices. ## Environment diff --git a/host/test/dev-fd-stat.test.ts b/host/test/dev-fd-stat.test.ts new file mode 100644 index 0000000000..96ce081234 --- /dev/null +++ b/host/test/dev-fd-stat.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { tryResolveBinary } from "../src/binary-resolver"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const devFdStatBinary = tryResolveBinary("programs/dev-fd-stat.wasm"); + +describe("devfs descriptor aliases", () => { + it.skipIf(!devFdStatBinary)( + "stat follows /dev/std{in,out,err} and /dev/fd/N while lstat reports symlinks", + async () => { + const result = await runCentralizedProgram({ + programPath: devFdStatBinary!, + argv: ["dev-fd-stat"], + useDefaultRootfs: false, + }); + + expect(result.exitCode, `stderr=${result.stderr}`).toBe(0); + expect(result.stdout).toBe("PASS\n"); + expect(result.stderr).toBe(""); + }, + ); +}); diff --git a/programs/dev-fd-stat.c b/programs/dev-fd-stat.c new file mode 100644 index 0000000000..f7406f2cfa --- /dev/null +++ b/programs/dev-fd-stat.c @@ -0,0 +1,97 @@ +#include +#include +#include +#include + +static int same_file_identity(const struct stat *left, const struct stat *right) { + return left->st_dev == right->st_dev && + left->st_ino == right->st_ino && + left->st_mode == right->st_mode; +} + +static int check_fd_alias(const char *path, int fd) { + struct stat fd_stat; + struct stat path_stat; + struct stat at_stat; + struct stat link_stat; + struct stat nofollow_stat; + char link_target[128]; + + if (fstat(fd, &fd_stat) != 0) { + perror("fstat"); + return 1; + } + if (stat(path, &path_stat) != 0) { + perror(path); + return 1; + } + if (!same_file_identity(&path_stat, &fd_stat)) { + fprintf(stderr, + "%s stat mismatch: path=(%llu,%llu,%o) fd=(%llu,%llu,%o)\n", + path, + (unsigned long long)path_stat.st_dev, + (unsigned long long)path_stat.st_ino, + path_stat.st_mode, + (unsigned long long)fd_stat.st_dev, + (unsigned long long)fd_stat.st_ino, + fd_stat.st_mode); + return 1; + } + if (fstatat(AT_FDCWD, path, &at_stat, 0) != 0) { + perror("fstatat"); + return 1; + } + if (!same_file_identity(&at_stat, &fd_stat)) { + fprintf(stderr, "%s fstatat did not follow the descriptor alias\n", path); + return 1; + } + if (lstat(path, &link_stat) != 0) { + perror("lstat"); + return 1; + } + if (!S_ISLNK(link_stat.st_mode)) { + fprintf(stderr, "%s lstat mode is %o, expected a symlink\n", path, + link_stat.st_mode); + return 1; + } + if (fstatat(AT_FDCWD, path, &nofollow_stat, AT_SYMLINK_NOFOLLOW) != 0) { + perror("fstatat nofollow"); + return 1; + } + if (!S_ISLNK(nofollow_stat.st_mode) || + nofollow_stat.st_ino != link_stat.st_ino) { + fprintf(stderr, "%s fstatat nofollow did not report the symlink\n", path); + return 1; + } + ssize_t link_len = readlink(path, link_target, sizeof(link_target)); + if (link_len <= 0 || link_stat.st_size != link_len) { + fprintf(stderr, "%s readlink disagrees with lstat size\n", path); + return 1; + } + return 0; +} + +int main(void) { + if (check_fd_alias("/dev/stdin", STDIN_FILENO) != 0 || + check_fd_alias("/dev/stdout", STDOUT_FILENO) != 0 || + check_fd_alias("/dev/stderr", STDERR_FILENO) != 0) { + return 1; + } + + int null_fd = open("/dev/null", O_RDWR); + if (null_fd < 0) { + perror("open /dev/null"); + return 1; + } + + char fd_path[32]; + snprintf(fd_path, sizeof(fd_path), "/dev/fd/%d", null_fd); + int result = check_fd_alias(fd_path, null_fd); + close(null_fd); + if (result != 0) { + return result; + } + + puts("PASS"); + return 0; +} From 5220e0bbf5cf18107a833d3df9dbf07a87ad0e1f Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sun, 12 Jul 2026 09:38:50 -0400 Subject: [PATCH 28/82] SDK: Preserve executable linker input order Place Kandelo syscall glue and the C runtime startup object before user link inputs, preserve the caller source, archive, and library order, and keep the final musl archive last. This prevents an explicit -lc from resolving syscall definitions before the platform overrides and avoids scanning dependency libraries before their consumers. Adapt the original change to the current prepared-linker and reproducible path logic. The Kandelo-native driver already uses the same order. (cherry picked from commit 915a84b6d5949af07e05f9ea0983cf84bfd4bcfb) --- docs/sdk-guide.md | 6 ++++++ sdk/src/bin/cc.ts | 22 ++++++++++++++-------- sdk/test/cc.test.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/docs/sdk-guide.md b/docs/sdk-guide.md index 24a6ea02f2..0ee3f7c5dc 100644 --- a/docs/sdk-guide.md +++ b/docs/sdk-guide.md @@ -324,6 +324,12 @@ When linking an executable (not compile-only), the SDK adds: - `sysroot/lib/crt1.o` — C runtime startup - `sysroot/lib/libc.a` — musl libc +The injected glue and startup objects precede user linker inputs, and the final +musl archive follows them. The SDK preserves user source, object, archive, and +`-l` ordering between those boundaries. This also makes an explicit `-lc`, as +emitted by some Autoconf projects, equivalent to the automatically linked libc +without pulling musl's syscall definitions ahead of Kandelo's glue overrides. + ### Sysroot platform libraries `scripts/build-musl.sh` also builds Kandelo's platform graphics shims into the diff --git a/sdk/src/bin/cc.ts b/sdk/src/bin/cc.ts index 6db0f1af5d..b9f06923e2 100755 --- a/sdk/src/bin/cc.ts +++ b/sdk/src/bin/cc.ts @@ -239,29 +239,31 @@ function buildClangArgsInternal( if (parsed.preprocessOnly) args.push('-E'); if (parsed.assemblyOnly) args.push('-S'); if (parsed.outputFile) args.push('-o', parsed.outputFile); + const deferExecutableInputs = linking && !classifyLink && !parsed.shared; // Static link semantics depend on the caller's exact ordering of objects, // archives, -l flags, and linker group controls. Parsed classifications are // for SDK decisions only; forwarding must never rebuild the command in - // type-based buckets. - args.push(...parsed.forwardedArgs); + // type-based buckets. Executable links defer this sequence until after the + // platform glue and CRT so an explicit -lc cannot resolve musl's syscall + // definitions before Kandelo's overrides. + if (!deferExecutableInputs) args.push(...parsed.forwardedArgs); // The SDK compiles its glue sources during each executable link. Keep those // files and sysroot headers independent of the checkout used for the build. // Append these after caller flags so a broader caller-owned mapping cannot // retain a less-specific host path in DWARF. - if ( + const sdkCompileArgs = ( hasSourceFiles || parsed.compileOnly || parsed.preprocessOnly || parsed.assemblyOnly || linking || executableLinker?.kind === 'no-link' - ) { - args.push(...sdkSourcePrefixMapFlags(toolchain, arch)); - } + ) ? sdkSourcePrefixMapFlags(toolchain, arch) : []; // -fPIC is consumed by parseArgs (so the linker can see `parsed.pic`), // but it must also reach clang at compile time so the resulting object // uses PIC relocations. Without this a TU later linked into a shared // library produces non-PIC objects and `wasm-ld --shared` rejects them // with "R_WASM_MEMORY_ADDR_LEB cannot be used; recompile with -fPIC". - if (parsed.pic) args.push('-fPIC'); + if (parsed.pic) sdkCompileArgs.push('-fPIC'); + if (!deferExecutableInputs) args.push(...sdkCompileArgs); if (linking) { // Keep clang and lld in the same resolved LLVM tree. Without an explicit @@ -294,7 +296,9 @@ function buildClangArgsInternal( `through ${MAX_EXECUTABLE_MEMORY_SIZE} bytes`, ); } - // Executable build: link CRT, libc, and syscall glue + // Executable build: place platform definitions before caller inputs and + // leave the final libc archive available for everything still + // unresolved. This order is also used by the Kandelo-native SDK driver. const threadSlots = inferThreadSlotDeclaration(parsed, userArgs, { readFile: (path) => { try { @@ -317,6 +321,8 @@ function buildClangArgsInternal( } args.push( join(toolchain.sysroot, 'lib', 'crt1.o'), + ...parsed.forwardedArgs, + ...sdkCompileArgs, join(toolchain.sysroot, 'lib', 'libc.a'), // LLD 22 made --stack-first the default; LLD 21 neither defaults to // it nor accepts --no-stack-first. Preserve Kandelo's established diff --git a/sdk/test/cc.test.ts b/sdk/test/cc.test.ts index 6882c046af..8705983a38 100644 --- a/sdk/test/cc.test.ts +++ b/sdk/test/cc.test.ts @@ -135,6 +135,33 @@ describe('buildClangArgs', () => { expect(forwarded).toEqual(userLinkArgs); }); + it('orders explicit libc and user libraries after syscall glue', () => { + const args = build([ + 'main.o', + '-L', '/deps/lib', + '-lxml2', + 'support.a', + '-lc', + '-o', 'out.wasm', + ]); + const channelGlue = args.indexOf('/tmp/glue/channel_syscall.c'); + const crt = args.indexOf('/tmp/sysroot/lib/crt1.o'); + const main = args.indexOf('main.o'); + const libraryPath = args.indexOf('-L'); + const xml = args.indexOf('-lxml2'); + const support = args.indexOf('support.a'); + const explicitLibc = args.indexOf('-lc'); + const finalLibc = args.indexOf('/tmp/sysroot/lib/libc.a'); + + expect(channelGlue).toBeLessThan(crt); + expect(crt).toBeLessThan(main); + expect(main).toBeLessThan(libraryPath); + expect(libraryPath).toBeLessThan(xml); + expect(xml).toBeLessThan(support); + expect(support).toBeLessThan(explicitLibc); + expect(explicitLibc).toBeLessThan(finalLibc); + }); + it('maps SDK-owned glue and sysroot paths to stable debug identities', () => { const args = build(['-ffile-prefix-map=/tmp=/caller-source', 'foo.c', '-o', 'foo.wasm']); From 9995125c4af8fba82862433111547e7b33fa4bc3 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Wed, 22 Jul 2026 15:00:56 -0400 Subject: [PATCH 29/82] POSIX: Preserve directory streams when rewind fails Open the replacement iterator before retiring the active stream, and keep the old state authoritative if opening or closing the replacement sequence fails. Add kernel failure coverage and shared host-bridge coverage for throwing close calls and numeric handle reuse. Adapt the host tests to the current Rust-lent destination capability instead of bypassing private state. (cherry picked from commit e96c38127fef119880a53eb9f07ac68927547121) --- crates/kernel/src/syscalls.rs | 79 ++++++++++++++++++++++++++++- docs/posix-status.md | 2 +- host/src/kernel.ts | 6 +++ host/src/types.ts | 4 ++ host/test/readdir-atomicity.test.ts | 62 ++++++++++++++++++++++ 5 files changed, 151 insertions(+), 2 deletions(-) diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 805a13f2fe..87ce8cc58e 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -7396,9 +7396,18 @@ pub fn sys_rewinddir( let path = stream.path.clone(); let old_handle = stream.host_handle; - host.host_closedir(old_handle)?; + // Construct the replacement before retiring the current iterator. A + // transient reopen failure must not leave the live DirStream pointing at + // a handle that we already closed. let new_handle = host.host_opendir(&path)?; + if let Err(err) = host.host_closedir(old_handle) { + // The old iterator remains authoritative when its close fails. Do not + // leak the replacement that never became visible to the stream. + let _ = host.host_closedir(new_handle); + return Err(err); + } + let stream = proc .dir_streams .get_mut(idx) @@ -16980,6 +16989,7 @@ mod tests { dir_entry_count: usize, // total number of mock entries dir_entry_names: Option>>, dir_opendir_error: Option, + dir_closedir_error_once: Option, dir_readdir_error: Option<(usize, Errno)>, dir_readdir_reported_len: Option, sigsuspend_signal: u32, @@ -17068,6 +17078,7 @@ mod tests { dir_entry_count: 1, dir_entry_names: None, dir_opendir_error: None, + dir_closedir_error_once: None, dir_readdir_error: None, dir_readdir_reported_len: None, sigsuspend_signal: 0, @@ -17615,6 +17626,9 @@ mod tests { } fn host_closedir(&mut self, handle: i64) -> Result<(), Errno> { + if let Some(err) = self.dir_closedir_error_once.take() { + return Err(err); + } self.dir_entry_indices.remove(&handle); self.closed_dir_handles.push(handle); Ok(()) @@ -21153,6 +21167,69 @@ mod tests { sys_closedir(&mut proc, &mut host, dh).unwrap(); } + #[test] + fn rewinddir_reopen_failure_preserves_the_live_iterator() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let dh = sys_opendir(&mut proc, &mut host, b"/tmp").unwrap(); + let original = proc.dir_streams[dh as usize].as_mut().unwrap(); + original.position = 7; + original.synth_dot_state = 2; + assert_eq!(original.host_handle, 200); + + host.dir_opendir_error = Some(Errno::EACCES); + assert_eq!( + sys_rewinddir(&mut proc, &mut host, dh), + Err(Errno::EACCES), + ); + + let preserved = proc.dir_streams[dh as usize].as_ref().unwrap(); + assert_eq!(preserved.host_handle, 200); + assert_eq!(preserved.position, 7); + assert_eq!(preserved.synth_dot_state, 2); + assert!(host.closed_dir_handles.is_empty()); + + host.dir_opendir_error = None; + sys_rewinddir(&mut proc, &mut host, dh).unwrap(); + let rewound = proc.dir_streams[dh as usize].as_ref().unwrap(); + assert_eq!(rewound.host_handle, 201); + assert_eq!(rewound.position, 0); + assert_eq!(rewound.synth_dot_state, 0); + assert_eq!(host.closed_dir_handles, [200]); + + sys_closedir(&mut proc, &mut host, dh).unwrap(); + assert_eq!(host.closed_dir_handles, [200, 201]); + } + + #[test] + fn rewinddir_close_failure_discards_the_replacement() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let dh = sys_opendir(&mut proc, &mut host, b"/tmp").unwrap(); + let original = proc.dir_streams[dh as usize].as_mut().unwrap(); + original.position = 7; + original.synth_dot_state = 2; + + host.dir_closedir_error_once = Some(Errno::EIO); + assert_eq!(sys_rewinddir(&mut proc, &mut host, dh), Err(Errno::EIO)); + + let preserved = proc.dir_streams[dh as usize].as_ref().unwrap(); + assert_eq!(preserved.host_handle, 200); + assert_eq!(preserved.position, 7); + assert_eq!(preserved.synth_dot_state, 2); + assert_eq!(host.closed_dir_handles, [201]); + + sys_rewinddir(&mut proc, &mut host, dh).unwrap(); + let rewound = proc.dir_streams[dh as usize].as_ref().unwrap(); + assert_eq!(rewound.host_handle, 202); + assert_eq!(rewound.position, 0); + assert_eq!(rewound.synth_dot_state, 0); + assert_eq!(host.closed_dir_handles, [201, 200]); + + sys_closedir(&mut proc, &mut host, dh).unwrap(); + assert_eq!(host.closed_dir_handles, [201, 200, 202]); + } + #[test] fn test_telldir_returns_position() { let mut proc = Process::new(1); diff --git a/docs/posix-status.md b/docs/posix-status.md index cba84bbd95..966fe00914 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -227,7 +227,7 @@ to a different directory than the original OFD. | `opendir()` | Partial | Host-delegated via DirStream table. Entry-at-a-time iteration. Stores resolved path for rewinddir. | | `readdir()` | Full | Returns WasmDirent (d_ino, d_type, d_namlen) + name buffer. Synthesizes "." and ".." entries before host entries. Tracks position for telldir/seekdir. | | `closedir()` | Full | Frees DirStream slot, delegates to host. | -| `rewinddir()` | Full | Closes and reopens directory via stored path. Resets position to 0. | +| `rewinddir()` | Full | Reopens the directory via its stored path and resets the position to zero. The replacement is opened before the live iterator is retired, so a failed reopen leaves the previous stream and position intact. | | `telldir()` | Full | Returns current position counter from DirStream. | | `seekdir()` | Full | Rewinds and skips entries to reach target position. | | `mkdir()` | Partial | Host-delegated. Relative paths resolved via kernel cwd. umask applied to mode. | diff --git a/host/src/kernel.ts b/host/src/kernel.ts index b69aa0b6fa..41b7d57bfe 100644 --- a/host/src/kernel.ts +++ b/host/src/kernel.ts @@ -215,6 +215,7 @@ interface WasmPosixKernelTestAuthority { bytes: Uint8Array, ): void; hostFstat(handle: bigint, statPointer: KernelPointer): number; + hostOpendir(pathPointer: KernelPointer, pathLength: number): bigint; hostReaddir( handle: bigint, direntPointer: KernelPointer, @@ -1059,6 +1060,11 @@ export class WasmPosixKernel { } }, ); + defineMethod( + "hostOpendir", + (pathPointer: KernelPointer, pathLength: number) => + this.#hostOpendir(pathPointer, pathLength), + ); defineMethod( "hostReaddir", ( diff --git a/host/src/types.ts b/host/src/types.ts index e39379b50f..a4b068bc6f 100644 --- a/host/src/types.ts +++ b/host/src/types.ts @@ -141,6 +141,10 @@ export interface PlatformIO { utimensat(path: string, atimeSec: number, atimeNsec: number, mtimeSec: number, mtimeNsec: number): void; // Directory iteration + /** + * Open a directory and return an opaque handle. A handle must not be reused + * while its previous directory iterator is still live. + */ opendir(path: string): number; /** * Return and consume the next entry. If this throws, the iterator must stay diff --git a/host/test/readdir-atomicity.test.ts b/host/test/readdir-atomicity.test.ts index d9f99cb208..ac3823f68a 100644 --- a/host/test/readdir-atomicity.test.ts +++ b/host/test/readdir-atomicity.test.ts @@ -117,4 +117,66 @@ describe("host readdir retry atomicity", () => { ), ).toBe("new-iterator"); }); + + it("clears a staged entry even when the backend close fails", () => { + let failFirstNameRead = true; + const { io, kernel } = createKernelBridge([ + { + get name(): string { + if (failFirstNameRead) { + failFirstNameRead = false; + throw new Error("malformed old iterator entry"); + } + return "old-iterator"; + }, + type: 8, + ino: 1, + }, + ]); + io.closedir.mockImplementationOnce(() => { + throw new Error("injected close failure"); + }); + const bridge = kernel.testAuthority; + + expect(bridge.hostReaddir(7n, 16, 128, 64)).toBeLessThan(0); + expect(bridge.hostClosedir(7n)).toBeLessThan(0); + expect(bridge.hostReaddir(7n, 16, 128, 64)).toBe(0); + expect(io.closedir).toHaveBeenCalledWith(7); + expect(io.readdir).toHaveBeenCalledTimes(2); + }); + + it("drops stale transport state when opendir returns a reused handle", () => { + let failFirstNameRead = true; + const { io, kernel, memory } = createKernelBridge([ + { + get name(): string { + if (failFirstNameRead) { + failFirstNameRead = false; + throw new Error("malformed old iterator entry"); + } + return "old-iterator"; + }, + type: 8, + ino: 1, + }, + { name: "new-iterator", type: 4, ino: 2 }, + ]); + const bridge = kernel.testAuthority; + + expect(bridge.hostReaddir(7n, 16, 128, 64)).toBeLessThan(0); + + new Uint8Array(memory.buffer, 256, 4).set( + new TextEncoder().encode("/tmp"), + ); + expect(bridge.hostOpendir(256, 4)).toBe(7n); + expect(bridge.hostReaddir(7n, 16, 128, 64)).toBe(1); + + expect(io.opendir).toHaveBeenCalledWith("/tmp"); + expect(io.readdir).toHaveBeenCalledTimes(2); + expect( + new TextDecoder().decode( + new Uint8Array(memory.buffer, 128, "new-iterator".length), + ), + ).toBe("new-iterator"); + }); }); From 82da96da5d9c6dba7de74443cfabb1a88aa7354d Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sat, 1 Aug 2026 02:13:50 -0400 Subject: [PATCH 30/82] Host: Reflect ABI 43 modules from admitted bytes WebKit can compile valid ABI 43 fork artifacts while failing to return their import descriptors. That prevents otherwise portable programs from reaching the normal host path. Bind Kandelo-created modules to descriptors parsed from the exact bytes that passed artifact admission, and keep native engine reflection for externally supplied modules. Cover the fallback with host tests and an exact Chromium, Firefox, and WebKit artifact test. Validation: - scripts/dev-shell.sh -- npm --prefix host run typecheck - scripts/dev-shell.sh -- npm run docs:build - 114 focused host tests - Playwright reflection test on Chromium, Firefox, and WebKit --- .../test/wasm-module-reflection.spec.ts | 68 ++++++++++++++ docs/browser-support.md | 8 ++ host/src/constants.ts | 94 ++++++++++++++++--- host/src/dylink.ts | 20 +++- host/src/fork-imported-globals.ts | 5 +- host/src/wasi-detect.ts | 10 +- host/src/wasm-module-reflection.ts | 66 +++++++++++++ host/src/worker-main.ts | 47 ++++++---- host/test/fork-artifact-gc-types.test.ts | 7 ++ host/test/wasm-module-reflection.test.ts | 64 +++++++++++++ 10 files changed, 348 insertions(+), 41 deletions(-) create mode 100644 apps/browser-demos/test/wasm-module-reflection.spec.ts create mode 100644 host/src/wasm-module-reflection.ts create mode 100644 host/test/wasm-module-reflection.test.ts diff --git a/apps/browser-demos/test/wasm-module-reflection.spec.ts b/apps/browser-demos/test/wasm-module-reflection.spec.ts new file mode 100644 index 0000000000..de15495883 --- /dev/null +++ b/apps/browser-demos/test/wasm-module-reflection.spec.ts @@ -0,0 +1,68 @@ +import { expect, test } from "@playwright/test"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const reflectionModulePath = resolve( + __dirname, + "../../../host/src/wasm-module-reflection.ts", +); +const forkArtifactPath = resolve( + __dirname, + "../../../local-binaries/programs/wasm32/p_01_fork_main_thread.wasm", +); + +test("ABI 43 import reflection is identical across browser engines", async ({ + page, + baseURL, +}) => { + expect(baseURL).toBeTruthy(); + await page.goto(new URL("/trap-signal-test.html", baseURL!).href); + + const result = await page.evaluate(async ({ reflectionUrl, artifactUrl }) => { + const response = await fetch(artifactUrl); + if (!response.ok) { + throw new Error(`artifact fetch failed: ${response.status}`); + } + const bytes = await response.arrayBuffer(); + const module = await WebAssembly.compile(bytes); + let nativeImportError: string | null = null; + try { + WebAssembly.Module.imports(module); + } catch (error) { + nativeImportError = error instanceof Error ? error.message : String(error); + } + + const reflection = await import(/* @vite-ignore */ reflectionUrl); + reflection.registerWasmModuleReflection(module, bytes); + const imports = reflection.wasmModuleImports(module); + const exports = reflection.wasmModuleExports(module); + return { + nativeImportError, + hasKernelFork: imports.some( + (entry: { module: string; name: string; kind: string }) => + entry.module === "kernel" + && entry.name === "kernel_fork" + && entry.kind === "function", + ), + hasForkUnwindTag: imports.some( + (entry: { module: string; name: string; kind: string }) => + entry.module === "env" + && entry.name === "__wpk_fork_unwind" + && entry.kind === "tag", + ), + hasForkResumeExport: exports.some( + (entry: { name: string; kind: string }) => + entry.name === "wpk_fork_resume_start" + && entry.kind === "function", + ), + }; + }, { + reflectionUrl: new URL(`/@fs/${reflectionModulePath}`, baseURL!).href, + artifactUrl: new URL(`/@fs/${forkArtifactPath}`, baseURL!).href, + }); + + expect(result.hasKernelFork).toBe(true); + expect(result.hasForkUnwindTag).toBe(true); + expect(result.hasForkResumeExport).toBe(true); +}); diff --git a/docs/browser-support.md b/docs/browser-support.md index 62bd64512d..8ff147d532 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -64,6 +64,14 @@ Service Worker ──MessagePort──> Kernel Worker │ device, and shared-memory mounts are boot-local and are recreated when those bytes start another machine. - **Legacy shared VFS** (`memfs:` constructor option + `kernel.spawn()`): main thread holds a `MemoryFileSystem` and shares the SAB with the kernel worker. Used by demos that fetch transient binaries at runtime (test runners, REPLs that load arbitrary user code, benchmark suites). The main thread transfers each program's bytes, but the Rust `ProcessTable` allocates the PID and the worker returns it. Top-level creation, guest fork/spawn, and thread clone all draw from that one authoritative task-ID sequence; no browser or host-side allocator exists. +- **Exact module reflection**: each process worker binds a compiled module to + the exact Wasm bytes that passed artifact admission. Import and export names, + kinds, and declaration order come from Kandelo's binary contract parser. + This keeps Node.js, Chromium, Firefox, and WebKit on one path; in particular, + WebKit can compile ABI 43 exception-reference imports even when its + `WebAssembly.Module.imports()` API cannot produce descriptors for them. + Modules created by an external embedder without registered bytes retain the + native reflection fallback. - **Exec reads from filesystem**: Like a real OS, `exec()` reads binaries from the kernel-side `MemoryFileSystem`. Programs are baked into the VFS image at build time (or written by the page in the legacy path before spawning). Symlinks are used for multicall binaries (e.g., coreutils). - **dinit for service supervision**: Multi-process demos (nginx, redis, mariadb, nginx-php, wordpress, lamp, mariadb-test) bake `/sbin/dinit` and per-service files under `/etc/dinit.d/` into the VFS image via `addDinitInit()` (`images/vfs/scripts/dinit-image-helpers.ts`). dinit is the first user process, not PID 1. It reaps its directly supervised children and handles `depends-on` ordering and bootstrap-then-daemon chains. Synthetic PID 1 has no wait loop, so Kandelo does not yet reap children reparented to it. Page code waits for service-ready via `onListenTcp` (port-bind) callbacks, then starts driving the demo over kernel-loopback TCP or the HTTP bridge. - **Connection pump in kernel worker**: HTTP↔TCP bridge runs inside the kernel worker with synchronous pipe I/O (direct Wasm export calls). Service worker transfers a MessagePort to the kernel worker for HTTP request delivery. diff --git a/host/src/constants.ts b/host/src/constants.ts index 0936113a66..0b253d020f 100644 --- a/host/src/constants.ts +++ b/host/src/constants.ts @@ -2153,17 +2153,61 @@ export function readWasmFunctionImports( ); } +export type DecodedWasmExternalKind = + | "function" + | "table" + | "memory" + | "global" + | "tag"; + +export interface DecodedWasmImportDescriptor { + readonly module: string; + readonly name: string; + readonly kind: DecodedWasmExternalKind; +} + +export interface DecodedWasmExportDescriptor { + readonly name: string; + readonly kind: DecodedWasmExternalKind; +} + +function decodedExternalKind( + kind: number, + context: string, +): DecodedWasmExternalKind { + switch (kind) { + case 0: + return "function"; + case 1: + return "table"; + case 2: + return "memory"; + case 3: + return "global"; + case 4: + return "tag"; + default: + throw new Error(`${context} has unsupported external kind ${kind}`); + } +} + /** - * Return import names in `module.field` form. This is intentionally a small - * section parser rather than `new WebAssembly.Module(...)` so release/resolver - * guards can inspect binaries built with newer wasm features than the current - * JS engine can instantiate. + * Decode every import name and kind in declaration order. + * + * This is intentionally a binary-section parser rather than + * `WebAssembly.Module.imports()`: release/resolver guards can inspect binaries + * built with newer Wasm features than the current JS engine can reflect, and + * WebKit cannot currently produce descriptors for some valid exception- + * reference imports. The same parser already validates the richer ABI 43 + * function/global/table contract above. */ -export function readWasmImportNames(programBytes: ArrayBuffer): string[] { +export function readWasmImportDescriptors( + programBytes: ArrayBuffer, +): readonly DecodedWasmImportDescriptor[] { const src = new Uint8Array(programBytes); if (!hasWasmMagic(src)) return []; - const names: string[] = []; + const imports: DecodedWasmImportDescriptor[] = []; let offset = 8; while (offset < src.length) { const sectionId = src[offset]; @@ -2177,10 +2221,14 @@ export function readWasmImportNames(programBytes: ArrayBuffer): string[] { for (let i = 0; i < importCount; i++) { const [moduleName, afterModule] = readName(src, pos); const [fieldName, afterField] = readName(src, afterModule); - names.push(`${moduleName}.${fieldName}`); pos = afterField; const kind = src[pos++]; + imports.push({ + module: moduleName, + name: fieldName, + kind: decodedExternalKind(kind, `import ${moduleName}.${fieldName}`), + }); if (kind === 0) { const [, n] = readULEB128(src, pos); pos += n; } else if (kind === 1) { @@ -2201,15 +2249,24 @@ export function readWasmImportNames(programBytes: ArrayBuffer): string[] { offset = contentOffset + sectionSize; } - return names; + return imports; } -/** Return all export names from a wasm module. */ -export function readWasmExportNames(programBytes: ArrayBuffer): string[] { +/** Return import names in `module.field` form. */ +export function readWasmImportNames(programBytes: ArrayBuffer): string[] { + return readWasmImportDescriptors(programBytes).map( + ({ module, name }) => `${module}.${name}`, + ); +} + +/** Decode every export name and kind in declaration order. */ +export function readWasmExportDescriptors( + programBytes: ArrayBuffer, +): readonly DecodedWasmExportDescriptor[] { const src = new Uint8Array(programBytes); if (!hasWasmMagic(src)) return []; - const names: string[] = []; + const exports: DecodedWasmExportDescriptor[] = []; let offset = 8; while (offset < src.length) { const sectionId = src[offset]; @@ -2222,8 +2279,12 @@ export function readWasmExportNames(programBytes: ArrayBuffer): string[] { pos += countBytes; for (let i = 0; i < exportCount; i++) { const [name, afterName] = readName(src, pos); - names.push(name); - pos = afterName + 1; + pos = afterName; + const kind = src[pos++]; + exports.push({ + name, + kind: decodedExternalKind(kind, `export ${name}`), + }); const [, indexBytes] = readULEB128(src, pos); pos += indexBytes; } @@ -2232,7 +2293,12 @@ export function readWasmExportNames(programBytes: ArrayBuffer): string[] { offset = contentOffset + sectionSize; } - return names; + return exports; +} + +/** Return all export names from a wasm module. */ +export function readWasmExportNames(programBytes: ArrayBuffer): string[] { + return readWasmExportDescriptors(programBytes).map(({ name }) => name); } /** Return all custom-section names from a wasm module. */ diff --git a/host/src/dylink.ts b/host/src/dylink.ts index 5e7f723025..787b55b1e6 100644 --- a/host/src/dylink.ts +++ b/host/src/dylink.ts @@ -28,6 +28,11 @@ import { FORK_UNWIND_TAG_IMPORT_NAME, requireForkUnwindTag, } from "./fork-unwind-transport"; +import { + registerWasmModuleReflection, + wasmModuleExports, + wasmModuleImports, +} from "./wasm-module-reflection"; // dylink.0 sub-section types const WASM_DYLINK_MEM_INFO = 1; @@ -1151,6 +1156,7 @@ function* instantiateSharedLibrarySteps( const sourceModule = new WebAssembly.Module( wasmBytes as unknown as BufferSource, ); + registerWasmModuleReflection(sourceModule, wasmBytes); const borrowsMemory = replay?.memoryOwnership === "borrowed"; if (borrowsMemory) { if (!(options.memory.buffer instanceof SharedArrayBuffer)) { @@ -1163,13 +1169,19 @@ function* instantiateSharedLibrarySteps( } requirePassiveDataSegmentsForBorrowedReplay(wasmBytes, name); } - const module = borrowsMemory + const borrowedModuleBytes = borrowsMemory + ? withoutBorrowedReplayStart(wasmBytes, name) + : undefined; + const module = borrowedModuleBytes ? new WebAssembly.Module( - withoutBorrowedReplayStart(wasmBytes, name) as unknown as BufferSource, + borrowedModuleBytes as unknown as BufferSource, ) : sourceModule; - const moduleImports = WebAssembly.Module.imports(module); - const moduleExports = WebAssembly.Module.exports(module); + if (borrowedModuleBytes) { + registerWasmModuleReflection(module, borrowedModuleBytes); + } + const moduleImports = wasmModuleImports(module); + const moduleExports = wasmModuleExports(module); const moduleExportKinds = new Map( moduleExports.map((moduleExport) => [ moduleExport.name, diff --git a/host/src/fork-imported-globals.ts b/host/src/fork-imported-globals.ts index 06991b54a5..6504c5160a 100644 --- a/host/src/fork-imported-globals.ts +++ b/host/src/fork-imported-globals.ts @@ -23,6 +23,7 @@ import { readForkImportedGlobals, readForkImportedTables, } from "./fork-module-state"; +import { wasmModuleImports } from "./wasm-module-reflection"; export type ForkWasmImports = Readonly< Record>> @@ -204,8 +205,8 @@ function validateImportedDescriptors( globalDescriptors: readonly ForkImportedGlobalState[], tableDescriptors: readonly ForkImportedTableState[], context: string, -): readonly WebAssembly.ModuleImportDescriptor[] { - const imports = WebAssembly.Module.imports(module); +): ReturnType { + const imports = wasmModuleImports(module); const ordinals = new Set(); for (const descriptor of globalDescriptors) { const declaration = imports[descriptor.importOrdinal]; diff --git a/host/src/wasi-detect.ts b/host/src/wasi-detect.ts index 8d1c2525e9..e10bcc3904 100644 --- a/host/src/wasi-detect.ts +++ b/host/src/wasi-detect.ts @@ -13,6 +13,10 @@ * true. For non-WASI workloads (the common case in this repo) it * never enters the worker. */ +import { + wasmModuleExports, + wasmModuleImports, +} from "./wasm-module-reflection"; /** * Detect whether a compiled WebAssembly module is a WASI module. @@ -21,7 +25,7 @@ * supports; older `wasi_unstable` modules aren't recognized. */ export function isWasiModule(module: WebAssembly.Module): boolean { - return WebAssembly.Module.imports(module).some( + return wasmModuleImports(module).some( imp => imp.module === "wasi_snapshot_preview1", ); } @@ -30,7 +34,7 @@ export function isWasiModule(module: WebAssembly.Module): boolean { * Check if a WASI module imports memory (required for shared memory channel). */ export function wasiModuleImportsMemory(module: WebAssembly.Module): boolean { - return WebAssembly.Module.imports(module).some( + return wasmModuleImports(module).some( imp => imp.module === "env" && imp.name === "memory" && imp.kind === "memory", ); } @@ -39,7 +43,7 @@ export function wasiModuleImportsMemory(module: WebAssembly.Module): boolean { * Check if a WASI module defines its own memory (not supported). */ export function wasiModuleDefinesMemory(module: WebAssembly.Module): boolean { - return WebAssembly.Module.exports(module).some( + return wasmModuleExports(module).some( exp => exp.name === "memory" && exp.kind === "memory", ); } diff --git a/host/src/wasm-module-reflection.ts b/host/src/wasm-module-reflection.ts new file mode 100644 index 0000000000..b53889c9a9 --- /dev/null +++ b/host/src/wasm-module-reflection.ts @@ -0,0 +1,66 @@ +import { + type DecodedWasmExportDescriptor, + type DecodedWasmImportDescriptor, + readWasmExportDescriptors, + readWasmImportDescriptors, +} from "./constants"; + +interface RegisteredModuleReflection { + readonly imports: readonly DecodedWasmImportDescriptor[]; + readonly exports: readonly DecodedWasmExportDescriptor[]; +} + +const registeredReflection = new WeakMap< + WebAssembly.Module, + RegisteredModuleReflection +>(); + +/** + * Bind one compiled module to the exact bytes from which the host created it. + * + * WHY: WebKit can compile ABI 43 fork artifacts containing exception-reference + * imports while `WebAssembly.Module.imports()` throws instead of returning + * their name/kind descriptors. Kandelo already parses and validates these + * bytes before admission, so retain that exact ordered reflection alongside + * the module rather than making browser behavior depend on a weaker engine + * reflection surface. + */ +export function registerWasmModuleReflection( + module: WebAssembly.Module, + bytes: ArrayBuffer | Uint8Array, +): void { + const exactBytes = bytes instanceof Uint8Array + ? bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer + : bytes; + registeredReflection.set(module, { + imports: Object.freeze( + readWasmImportDescriptors(exactBytes).map((descriptor) => + Object.freeze({ ...descriptor }) + ), + ), + exports: Object.freeze( + readWasmExportDescriptors(exactBytes).map((descriptor) => + Object.freeze({ ...descriptor }) + ), + ), + }); +} + +export function wasmModuleImports( + module: WebAssembly.Module, +): readonly DecodedWasmImportDescriptor[] { + return registeredReflection.get(module)?.imports + ?? (WebAssembly.Module.imports(module) as + readonly DecodedWasmImportDescriptor[]); +} + +export function wasmModuleExports( + module: WebAssembly.Module, +): readonly DecodedWasmExportDescriptor[] { + return registeredReflection.get(module)?.exports + ?? (WebAssembly.Module.exports(module) as + readonly DecodedWasmExportDescriptor[]); +} diff --git a/host/src/worker-main.ts b/host/src/worker-main.ts index 691652b902..36e4344993 100644 --- a/host/src/worker-main.ts +++ b/host/src/worker-main.ts @@ -32,6 +32,7 @@ import { import { describeWasmArtifactPolicyFailures, extractAbiVersion, + readWasmImportDescriptors, WASM_PAGE_SIZE, } from "./constants"; import { @@ -141,6 +142,11 @@ import { // everything compiled by wasm32-posix) never trigger. import { isWasiModule, wasiModuleDefinesMemory } from "./wasi-detect"; import { synchronizeReceivedSharedWasmMemory } from "./shared-wasm-memory-growth"; +import { + registerWasmModuleReflection, + wasmModuleExports, + wasmModuleImports, +} from "./wasm-module-reflection"; export interface MessagePort { postMessage(msg: unknown, transferList?: unknown[]): void; on(event: string, handler: (...args: unknown[]) => void): void; @@ -2216,7 +2222,7 @@ export function assertSupportedKernelFunctionImports( module: WebAssembly.Module, kernelImports: Record, ): void { - for (const imp of WebAssembly.Module.imports(module)) { + for (const imp of wasmModuleImports(module)) { if ( imp.kind === "function" && imp.module === "kernel" @@ -2267,7 +2273,7 @@ function buildImportObject( // Provide __channel_base as a mutable wasm global if the module imports it. // Each instance gets its own global, immune to cross-thread shared memory corruption. // On wasm64, __channel_base is i64 (BigInt); on wasm32 it's i32 (number). - const moduleImports = WebAssembly.Module.imports(module); + const moduleImports = wasmModuleImports(module); const importsFunction = (name: string): boolean => moduleImports.some( (i) => i.module === "env" && i.name === name && i.kind === "function", @@ -2625,7 +2631,7 @@ function buildImportObject( // Environment integrations fail at the point of use when the host does not // implement them. Kernel imports were validated above and are never faked. - for (const imp of WebAssembly.Module.imports(module)) { + for (const imp of wasmModuleImports(module)) { if (imp.kind !== "function") continue; if (imp.module === "env") { if (!Object.hasOwn(envImports, imp.name)) { @@ -2964,7 +2970,7 @@ function hasCompleteForkInstrumentation( module: WebAssembly.Module, pid: number, ): boolean { - const moduleExports = WebAssembly.Module.exports(module); + const moduleExports = wasmModuleExports(module); const exportNames = new Set(moduleExports.map((e) => e.name)); const legacyAsyncifyExports = [...exportNames].filter((name) => name.startsWith("asyncify_"), @@ -3087,6 +3093,7 @@ export async function centralizedWorkerMain( const module = initData.programModule ? initData.programModule : await WebAssembly.compile(programBytes); + registerWasmModuleReflection(module, programBytes); // --- WASI module detection and handling --- if (isWasiModule(module)) { if (wasiModuleDefinesMemory(module)) { @@ -3119,7 +3126,7 @@ export async function centralizedWorkerMain( }; // Stub any additional env imports the module needs - const moduleImports = WebAssembly.Module.imports(module); + const moduleImports = wasmModuleImports(module); for (const imp of moduleImports) { if (imp.module === "env" && imp.name !== "memory") { if (!(importObject.env as Record)[imp.name]) { @@ -3658,12 +3665,14 @@ export async function centralizedWorkerMain( "is duplicated or aliases the main activation", ); } - modules.set( - library.activationId, - new WebAssembly.Module( - library.moduleBytes as unknown as BufferSource, - ), + const activationModule = new WebAssembly.Module( + library.moduleBytes as unknown as BufferSource, + ); + registerWasmModuleReflection( + activationModule, + library.moduleBytes, ); + modules.set(library.activationId, activationModule); } const declarations = [...modules] .sort(([left], [right]) => left - right) @@ -4431,7 +4440,7 @@ function setupChannelBase( ): void { // If the module imports env.__channel_base as a global, the channel offset was // already set at instantiation via WebAssembly.Global in buildImportObject. - const moduleImports = WebAssembly.Module.imports(module); + const moduleImports = wasmModuleImports(module); if ( moduleImports.some( (i) => @@ -4580,13 +4589,11 @@ export function patchWasmForThread(bytes: ArrayBuffer): ArrayBuffer { if (!hasStartSection) return bytes; // WHY: import descriptors can contain recursive GC types, multi-byte - // concrete references, table64 limits, tags, and future standardized - // imports. The engine has already validated and decoded that grammar; using - // its reflection avoids a second partial parser shifting every function - // index when a non-function import is not one byte wide. - numFuncImports = WebAssembly.Module.imports( - new WebAssembly.Module(bytes), - ).filter((entry) => entry.kind === "function").length; + // concrete references, table64 limits, and tags. Use the same exact binary + // parser as ABI admission: WebKit can compile these modules while refusing + // to expose their import descriptors through engine reflection. + numFuncImports = readWasmImportDescriptors(bytes) + .filter((entry) => entry.kind === "function").length; // Find the constructor function by looking at the exported helper wrappers. // Plain lld output puts `call $__wasm_call_ctors` first. After @@ -5032,6 +5039,10 @@ export async function centralizedThreadWorkerMain( const module = initData.programModule ? initData.programModule : new WebAssembly.Module(programBytes!); + registerWasmModuleReflection( + module, + programBytes ?? initData.programBytes, + ); const hasForkInstrumentation = hasCompleteForkInstrumentation(module, pid); if (hasForkInstrumentation) { diff --git a/host/test/fork-artifact-gc-types.test.ts b/host/test/fork-artifact-gc-types.test.ts index 0923fed44f..0fa1d06aa4 100644 --- a/host/test/fork-artifact-gc-types.test.ts +++ b/host/test/fork-artifact-gc-types.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { detectPtrWidth, describeWasmArtifactPolicyFailures, + readWasmImportDescriptors, readWasmImportNames, wasmImportsKernelFork, } from "../src/constants"; @@ -75,6 +76,12 @@ describe("fork artifact parsing with recursive GC types", () => { "env.gc_global", "env.memory", ]); + expect(readWasmImportDescriptors(wasm)).toEqual([ + { module: "kernel", name: "kernel_fork", kind: "function" }, + { module: "env", name: "gc_table", kind: "table" }, + { module: "env", name: "gc_global", kind: "global" }, + { module: "env", name: "memory", kind: "memory" }, + ]); expect(wasmImportsKernelFork(wasm)).toBe(true); expect(detectPtrWidth(wasm)).toBe(8); diff --git a/host/test/wasm-module-reflection.test.ts b/host/test/wasm-module-reflection.test.ts new file mode 100644 index 0000000000..3eab972da7 --- /dev/null +++ b/host/test/wasm-module-reflection.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + registerWasmModuleReflection, + wasmModuleExports, + wasmModuleImports, +} from "../src/wasm-module-reflection"; + +const fixture = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, + 0x01, 0x00, 0x00, 0x00, + // type: () -> () + 0x01, 0x04, 0x01, 0x60, 0x00, 0x00, + // import env.callback as function type 0 + 0x02, 0x10, 0x01, + 0x03, 0x65, 0x6e, 0x76, + 0x08, 0x63, 0x61, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, + 0x00, 0x00, + // export the imported function as callback + 0x07, 0x0c, 0x01, + 0x08, 0x63, 0x61, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, + 0x00, 0x00, +]).buffer; + +describe("exact Wasm module reflection", () => { + it("uses registered artifact descriptors when engine reflection is unavailable", () => { + const module = new WebAssembly.Module(fixture); + registerWasmModuleReflection(module, fixture); + const imports = vi.spyOn(WebAssembly.Module, "imports").mockImplementation( + () => { + throw new TypeError("engine cannot reflect this module"); + }, + ); + const exports = vi.spyOn(WebAssembly.Module, "exports").mockImplementation( + () => { + throw new TypeError("engine cannot reflect this module"); + }, + ); + try { + expect(wasmModuleImports(module)).toEqual([ + { module: "env", name: "callback", kind: "function" }, + ]); + expect(wasmModuleExports(module)).toEqual([ + { name: "callback", kind: "function" }, + ]); + expect(imports).not.toHaveBeenCalled(); + expect(exports).not.toHaveBeenCalled(); + } finally { + imports.mockRestore(); + exports.mockRestore(); + } + }); + + it("retains native reflection for modules without registered bytes", () => { + const module = new WebAssembly.Module(fixture); + + expect(wasmModuleImports(module)).toEqual( + WebAssembly.Module.imports(module), + ); + expect(wasmModuleExports(module)).toEqual( + WebAssembly.Module.exports(module), + ); + }); +}); From d7e23a71dc6034f08d5fcee8e1e999756347da1a Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Wed, 29 Jul 2026 00:13:07 -0400 Subject: [PATCH 31/82] Host: Deliver caught signals before retrying waits Treat CH_SIG as channel-owned state after the kernel dequeues a caught signal. A blocked host retry now completes with EINTR before it can re-park, so libc can run the handler without losing the only signal record. Forward-port #1129 onto ABI 43 exact retry ownership. Host-generated events now enter through an additive kernel export instead of borrowing the target task's occupied syscall channel. Preserve public nonblocking EAGAIN, SA_RESTART policy, and Node/browser parity. Validation: - 1,500 native kernel tests - 177 focused blocking-retry and signal tests - real accept/SIGCHLD guest in Node.js - real accept/SIGCHLD guest in Chromium, Firefox, and WebKit - ABI snapshot, host typecheck, and documentation build (cherry picked from commit 997fc7ba184bdbd2464d695e71c47eab753d91a5) --- abi/snapshot.json | 5 + apps/browser-demos/test/accept-signal.spec.ts | 38 ++++ crates/kernel/src/wasm_api.rs | 44 +++++ docs/architecture.md | 17 ++ examples/accept_signal_test.c | 168 ++++++++++++++++++ host/src/kernel-worker.ts | 87 ++++----- host/test/accept-signal-guest.test.ts | 25 +++ host/test/connect-pending-retry.test.ts | 38 +++- .../kernel-blocking-retry-snapshot.test.ts | 131 ++++++++++++++ host/test/signal-accept-livelock.test.ts | 72 ++++++-- host/test/support/kernel-scratch-instance.ts | 4 + 11 files changed, 558 insertions(+), 71 deletions(-) create mode 100644 apps/browser-demos/test/accept-signal.spec.ts create mode 100644 examples/accept_signal_test.c create mode 100644 host/test/accept-signal-guest.test.ts diff --git a/abi/snapshot.json b/abi/snapshot.json index 628a1ffb10..deb65dc1bf 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -1898,6 +1898,11 @@ "name": "kernel_futex", "signature": "(i32,i32,i32,i32,i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_generate_host_signal", + "signature": "(i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_get_argc", diff --git a/apps/browser-demos/test/accept-signal.spec.ts b/apps/browser-demos/test/accept-signal.spec.ts new file mode 100644 index 0000000000..9143ba8bdc --- /dev/null +++ b/apps/browser-demos/test/accept-signal.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from "@playwright/test"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const programPath = resolve( + __dirname, + "../../../examples/accept_signal_test.wasm", +); + +test("caught SIGCHLD interrupts and restarts accept coherently", async ({ + page, + baseURL, +}) => { + expect(baseURL).toBeTruthy(); + + await page.goto(new URL("/pages/test-runner/?minimal=1", baseURL).href); + await page.waitForFunction(() => (window as any).__testRunnerReady === true); + + const programUrl = new URL(`/@fs/${programPath}`, baseURL).href; + const result = await page.evaluate(async ({ programUrl }) => { + const response = await fetch(programUrl); + if (!response.ok) { + throw new Error(`program fetch failed: ${response.status}`); + } + return (window as any).__runTest( + await response.arrayBuffer(), + ["accept_signal_test"], + 15_000, + ); + }, { programUrl }); + + expect(result.exitCode, JSON.stringify(result, null, 2)).toBe(0); + expect(result.stdout).toContain( + "PASS accept signal interruption and SA_RESTART", + ); + expect(result.stderr).toBe(""); +}); diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index 7c4410daf5..eb828398df 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -2175,6 +2175,50 @@ pub extern "C" fn kernel_thread_has_deliverable(pid: u32, tid: u32) -> i32 { } } +/// Generate one process-directed signal from a host-owned asynchronous event. +/// +/// WHY: alarm expiry and child lifecycle notification occur after the guest +/// syscall that armed or caused them has returned. Re-entering the target's +/// syscall channel as a synthetic `kill()` would compete with an exact blocked +/// retry owned by that task and can truthfully fail with EBUSY. This boundary +/// names the target explicitly, creates no caller task authority, and retains +/// the historical self-`kill()` SI_USER metadata until richer event-specific +/// siginfo is represented by the ABI. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_generate_host_signal(pid: u32, signum: u32) -> i32 { + use wasm_posix_shared::signal::NSIG; + + if signum >= NSIG && signum != 0 { + return -(Errno::EINVAL as i32); + } + + let _gkl = GklGuard::acquire(); + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let Some((proc, advisory_locks)) = table.process_and_advisory_locks(pid) + else { + return -(Errno::ESRCH as i32); + }; + if !proc.is_live_explicit_tid(proc.pid) { + return -(Errno::ESRCH as i32); + } + if signum == 0 { + return 0; + } + + let sender_uid = proc.uid; + proc.raise_signal_with_metadata(signum, 0, 0, pid, sender_uid); + if let Some(target_tid) = proc.pick_thread_for_shared_signal(signum) { + let mut host = WasmHostIO; + let _ = deliver_pending_signals_for_tid_with_locks( + proc, + advisory_locks, + &mut host, + target_tid, + ); + } + 0 +} + /// Get fork exec path for a specific process. /// Writes path to buf, returns bytes written, 0 if no exec path, -ESRCH if not found. #[unsafe(no_mangle)] diff --git a/docs/architecture.md b/docs/architecture.md index ba16c6ab07..a523e4e117 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -842,6 +842,17 @@ timeout) cannot complete immediately. The process worker remains blocked in `Atomics.wait` while the host parks and wakes its pending channel through `Atomics.waitAsync`. +The retry boundary also owns caught-signal delivery. Once Rust dequeues a +caught signal into `CH_SIG`, that channel is the signal record's sole owner +until libc runs the handler and clears it. If the syscall would otherwise +remain blocked, the host captures and releases its exact retry authority, then +completes the channel with `EINTR` before it can park again. Public nonblocking +`EAGAIN` outcomes remain `EAGAIN`. After the handler, libc resubmits only its +reviewed zero-progress `SA_RESTART` allowlist, including `accept` and +`accept4`; timeout-bearing operations suppress restart when a new submission +would reset their deadline. The shared `CentralizedKernelWorker` state machine +provides the same behavior in Node.js and browser hosts. + For a represented retry, the initial call uses token zero. Before returning `EAGAIN`, Rust pins any exact target required by that operation. The host detaches the complete request, queries the authoritative token, and either @@ -2026,6 +2037,12 @@ Signals are delivered at syscall boundaries. When a process has a pending signal 4. After the handler returns, the glue calls `SYS_RT_SIGRETURN` to restore the signal mask 5. If the signal interrupted a blocking syscall, EINTR is returned +The host distinguishes the kernel's internal `EAGAIN` retry sentinel from a +completed nonblocking `EAGAIN`. When a caught signal is prepared while an +internal retry is still blocked, the host publishes `EINTR` without discarding +the prepared `CH_SIG` record. Libc runs the handler before deciding whether +`SA_RESTART` permits resubmitting that syscall. + Features: RT signal queuing with `si_value`, cross-process `kill`/`killpg`, `sigaltstack` with shadow stack swap, `sigsuspend`, `sigtimedwait`, `setitimer`/`alarm` via host timers. The exception is a channel request whose completion is owned by diff --git a/examples/accept_signal_test.c b/examples/accept_signal_test.c new file mode 100644 index 0000000000..630b948732 --- /dev/null +++ b/examples/accept_signal_test.c @@ -0,0 +1,168 @@ +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static volatile sig_atomic_t sigchld_count; + +static void on_sigchld(int signum) +{ + (void)signum; + sigchld_count++; +} + +static void sleep_ms(long milliseconds) +{ + struct timespec delay = { + .tv_sec = milliseconds / 1000, + .tv_nsec = (milliseconds % 1000) * 1000000, + }; + while (nanosleep(&delay, &delay) != 0 && errno == EINTR) + ; +} + +static int connect_after_delay(uint16_t port) +{ + sleep_ms(400); + + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) + return 20; + struct sockaddr_in address = { + .sin_family = AF_INET, + .sin_port = htons(port), + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + }; + if (connect(fd, (struct sockaddr *)&address, sizeof(address)) != 0) + return 21; + + /* + * WHY: keep this child alive until after the parent inspects the handler + * count. Otherwise the connector's own SIGCHLD could hide a lost signal + * from the child that was meant to interrupt accept(). + */ + sleep_ms(100); + close(fd); + return 0; +} + +static int run_case(uint16_t port, int restart) +{ + struct sigaction action; + memset(&action, 0, sizeof(action)); + action.sa_handler = on_sigchld; + action.sa_flags = restart ? SA_RESTART : 0; + sigemptyset(&action.sa_mask); + if (sigaction(SIGCHLD, &action, NULL) != 0) + return 2; + sigchld_count = 0; + + int listener = socket(AF_INET, SOCK_STREAM, 0); + if (listener < 0) + return 3; + int reuse = 1; + if (setsockopt( + listener, + SOL_SOCKET, + SO_REUSEADDR, + &reuse, + sizeof(reuse) + ) != 0) + return 4; + struct sockaddr_in address = { + .sin_family = AF_INET, + .sin_port = htons(port), + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + }; + if (bind(listener, (struct sockaddr *)&address, sizeof(address)) != 0) + return 5; + if (listen(listener, 4) != 0) + return 6; + + pid_t exiting_child = fork(); + if (exiting_child < 0) + return 7; + if (exiting_child == 0) { + close(listener); + sleep_ms(100); + _exit(0); + } + + pid_t connector = fork(); + if (connector < 0) + return 8; + if (connector == 0) { + close(listener); + _exit(connect_after_delay(port)); + } + + errno = 0; + int accepted = accept(listener, NULL, NULL); + int accept_errno = errno; + if (!restart) { + if (accepted >= 0 || accept_errno != EINTR) { + fprintf( + stderr, + "accept without SA_RESTART returned %d, errno=%d\n", + accepted, + accept_errno + ); + return 9; + } + accepted = accept(listener, NULL, NULL); + accept_errno = errno; + } + + if (accepted < 0) { + fprintf( + stderr, + "accept with restart=%d returned errno=%d\n", + restart, + accept_errno + ); + return 10; + } + if (sigchld_count != 1) { + fprintf( + stderr, + "accept with restart=%d observed %d handlers, expected 1\n", + restart, + (int)sigchld_count + ); + return 11; + } + + close(accepted); + close(listener); + + int status; + if (waitpid(exiting_child, &status, 0) != exiting_child || + !WIFEXITED(status) || WEXITSTATUS(status) != 0) + return 12; + if (waitpid(connector, &status, 0) != connector || + !WIFEXITED(status) || WEXITSTATUS(status) != 0) + return 13; + return 0; +} + +int main(void) +{ + int result = run_case(25254, 0); + if (result != 0) + return result; + result = run_case(25255, 1); + if (result != 0) + return result; + + puts("PASS accept signal interruption and SA_RESTART"); + return 0; +} diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index 9fdc21e112..ceb27b334d 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -24833,10 +24833,9 @@ export class CentralizedKernelWorker { } /** - * Queue a signal on a target process in the kernel by invoking SYS_KILL - * through kernel_handle_channel. The signal is queued in the kernel's - * ProcessTable and will be delivered via dequeueSignalForDelivery on the - * target process's next syscall completion. + * Queue a signal on a target process through the host-owned kernel boundary. + * The signal is recorded in the kernel's ProcessTable and will be delivered + * via dequeueSignalForDelivery on the target's next guest checkpoint. */ private sendSignalToProcess( targetPid: number, @@ -24872,51 +24871,29 @@ export class CentralizedKernelWorker { // that expires in that handoff window from being lost. if (queueSignal) { - // Host-originated process signals are shared deliveries. Bind the exact - // kernel-owned leader rather than relying on an implicit main-thread - // sentinel or state left over from a prior dispatch. + const generateHostSignal = this.#kernelInstanceForEntry(entry).exports + .kernel_generate_host_signal as + ((pid: number, signal: number) => number) | undefined; + if (typeof generateHostSignal !== "function") { + this.#failBlockingRetryProtocol( + "kernel host-signal generation export is unavailable", + ); + } + let result: number; try { - this.#bindKernelTid(targetPid, targetPid, entry); + result = generateHostSignal(targetPid, signum); } catch (error) { this.#rethrowKernelEntryFatal(error); - return; + this.#failBlockingRetryProtocol( + `kernel host-signal generation trapped for pid ${targetPid}`, + error, + ); } - this.currentHandlePid = targetPid; - try { - this.#requireMainScratchRegion().withLease((lease) => { - const kernelView = lease.dataView(0, CH_TOTAL_SIZE); - // Write SYS_KILL into scratch: kill(targetPid, signum) - kernelView.setUint32(CH_SYSCALL, SYS_KILL, true); - kernelView.setBigInt64(CH_ARGS, BigInt(targetPid), true); - kernelView.setBigInt64( - CH_ARGS + CH_ARG_SIZE, - BigInt(signum), - true, - ); - for (let i = 2; i < CH_ARGS_COUNT; i++) { - kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); - } - this.#invokeEntryScratchExport( - entry, - lease, - "kernel_handle_channel", - [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - targetPid, - 0n, - ], - ); - }); - } catch (err) { - this.#rethrowKernelEntryFatal(err); - // Non-fatal — signal delivery is best-effort from the host side - console.error( - `[sendSignalToProcess] kernel threw for pid=${targetPid} sig=${signum}: ${err}`, + if (result === -ESRCH) return; + if (!Number.isSafeInteger(result) || result !== 0) { + this.#failBlockingRetryProtocol( + `kernel rejected host signal ${signum} for pid ${targetPid}: ${result}`, ); - return; - } finally { - this.currentHandlePid = 0; } } @@ -24927,7 +24904,8 @@ export class CentralizedKernelWorker { // query or blocked-syscall retry observes the target. this.#drainAndProcessWakeupEventsWithinKernelEntry(entry); - // Default terminating actions are applied inside kernel_handle_channel. + // Default terminating actions are applied inside the kernel generation + // boundary. // Retire a newly exited worker before considering any blocking-channel // wakeup; guest code must not resume after signal death. this.reapKilledProcessesAfterSyscall(entry); @@ -24952,9 +24930,7 @@ export class CentralizedKernelWorker { // Ignored and default-ignore signals are consumed inside the kernel. Do // not shorten a sleep merely because its mask would have accepted a // signal that is no longer pending. - if ( - !this.#kernelThreadHasDeliverable(targetPid, targetTid, entry) - ) return; + if (!this.#kernelThreadHasDeliverable(targetPid, targetTid, entry)) return; if ( this.interruptPendingFutexForCaughtSignal( @@ -24986,15 +24962,12 @@ export class CentralizedKernelWorker { // 2. Pending ppoll/poll retry — wake ALL threads for this pid. // Snapshot-and-skip-if-replaced: retrySyscall runs handleSyscall - // synchronously, and a non-interruptible blocking wait (notably - // accept(), which has no EINTR path) re-inserts the SAME - // exact-channel key via pendingPollRetries.set when it re-parks on - // EAGAIN. JS Map iterators are not snapshots — a deleted-then- - // reinserted key reappears at the tail and the raw for..of would - // revisit it forever, livelocking the whole kernel worker thread. - // Mirror wakeBlockedPoll / wakeAllBlockedRetries. (Regression: - // SIGCHLD to a forking daemon's master parked in accept() — - // e.g. msmtpd delivering WordPress mail — wedged the kernel.) + // synchronously, and a wait that remains blocked can reinsert the SAME + // exact-channel key via pendingPollRetries.set. JS Map iterators are not + // snapshots — a deleted-then-reinserted key reappears at the tail and + // the raw for..of would revisit it forever, livelocking the whole + // kernel worker thread. Mirror wakeBlockedPoll / + // wakeAllBlockedRetries. const pollMatches = Array.from(this.pendingPollRetries.entries()).filter( ([, e]) => e.channel.pid === targetPid, ); diff --git a/host/test/accept-signal-guest.test.ts b/host/test/accept-signal-guest.test.ts new file mode 100644 index 0000000000..9ab63f3372 --- /dev/null +++ b/host/test/accept-signal-guest.test.ts @@ -0,0 +1,25 @@ +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const program = join(repoRoot, "examples/accept_signal_test.wasm"); + +describe.skipIf(!existsSync(program))("accept signal guest", () => { + it("delivers SIGCHLD before restarting a blocked accept", async () => { + const result = await runCentralizedProgram({ + programPath: program, + argv: ["accept_signal_test"], + useDefaultRootfs: false, + timeout: 10_000, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain( + "PASS accept signal interruption and SA_RESTART", + ); + expect(result.stderr).toBe(""); + }); +}); diff --git a/host/test/connect-pending-retry.test.ts b/host/test/connect-pending-retry.test.ts index 045ba4b94f..2a7ac5dd89 100644 --- a/host/test/connect-pending-retry.test.ts +++ b/host/test/connect-pending-retry.test.ts @@ -6,6 +6,8 @@ import { CH_ARGS, CH_ERRNO, CH_RETURN, + CH_SIG_BASE, + CH_SIG_SIGNUM, CH_STATUS, CH_SYSCALL, } from "../src/generated/abi"; @@ -17,6 +19,7 @@ import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; const EINPROGRESS = 115; const EALREADY = 114; const ECONNREFUSED = 111; +const EINTR = 4; type KernelResult = { retVal: number; errVal: number }; @@ -26,7 +29,11 @@ function createSharedMemory(pages = 2): WebAssembly.Memory { function createConnectHarness( results: KernelResult[], - options: { nonblock?: boolean; family?: number } = {}, + options: { + nonblock?: boolean; + family?: number; + handlerSignal?: number; + } = {}, ) { const kernelMemory = createSharedMemory(); const processMemory = createSharedMemory(); @@ -58,7 +65,20 @@ function createConnectHarness( kernelExports: { kernel_blocking_retry_release: () => 0, kernel_blocking_retry_token: () => 1n, - kernel_dequeue_signal: () => 0, + kernel_dequeue_signal: ( + _pid: number, + _tid: number, + rawPointer: number | bigint, + ) => { + const signal = options.handlerSignal ?? 0; + if (signal <= 0) return 0; + new DataView(kernelMemory.buffer).setUint32( + Number(rawPointer) + CH_SIG_SIGNUM - CH_SIG_BASE, + signal, + true, + ); + return signal; + }, kernel_get_process_exit_signal: () => -1, kernel_get_socket_timeout_ms: getSocketTimeout, kernel_handle_channel: handleChannel, @@ -147,6 +167,20 @@ describe("pending AF_INET connect routing", () => { expect(harness.getSocketTimeout).toHaveBeenCalledOnce(); }); + it("interrupts a blocking pending connect for a caught signal", () => { + const harness = createConnectHarness( + [{ retVal: -1, errVal: EINPROGRESS }], + { handlerSignal: 10 }, + ); + + harness.worker.handleSyscall(harness.channel); + + expect(harness.completeChannel).toHaveBeenCalledOnce(); + expect(harness.completeChannel.mock.calls[0].slice(4, 6)) + .toEqual([-1, EINTR]); + expect(harness.worker.pendingPollRetries.size).toBe(0); + }); + it("keeps a blocking EALREADY retry parked and then returns the failure", () => { vi.useFakeTimers(); const harness = createConnectHarness([ diff --git a/host/test/kernel-blocking-retry-snapshot.test.ts b/host/test/kernel-blocking-retry-snapshot.test.ts index 19940bb2eb..5fa36f7d51 100644 --- a/host/test/kernel-blocking-retry-snapshot.test.ts +++ b/host/test/kernel-blocking-retry-snapshot.test.ts @@ -207,6 +207,7 @@ function createRetryHarness( kernel_get_process_exit_signal: () => -1, kernel_get_process_state: () => 0, kernel_get_socket_timeout_ms: vi.fn(() => 0n), + kernel_generate_host_signal: () => 0, kernel_handle_channel: () => 0, kernel_is_fd_nonblock: () => 0, kernel_mq_descriptor_msgsize: () => 4, @@ -4176,6 +4177,136 @@ describe("remaining pointer-bearing blocking retry snapshots", () => { }, ); + it.each(WIDTHS)( + "%s publishes EINTR instead of re-parking a caught signal on accept", + (_name, pointerWidth) => { + const harness = createRetryHarness(pointerWidth); + writeRequest(harness, ABI_SYSCALLS.Accept, [7n, 0n, 0n]); + harness.kernelExports.kernel_blocking_retry_token = vi.fn(() => 74n); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + publishKernelResult( + kernelView(harness, rawPointer), + -1, + EAGAIN, + ); + return 0; + }, + ); + harness.kernelExports.kernel_dequeue_signal = vi.fn( + ( + _pid: number, + _tid: number, + rawPointer: number | bigint, + ) => writeKernelCaughtSignal(harness, rawPointer), + ); + + harness.worker.handleSyscall(harness.channel); + + expect(requestResult(harness)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: -1, + errno: EINTR, + }); + const channelView = new DataView( + harness.processMemory.buffer, + harness.channel.channelOffset, + ); + expect(channelView.getUint32(CH_SIG_SIGNUM, true)).toBe(SIGUSR1); + expect(channelView.getUint32(CH_SIG_FLAGS, true)).toBe(SA_RESTART); + expect( + harness.kernelExports.kernel_blocking_retry_token, + ).toHaveBeenCalledWith( + harness.channel.pid, + harness.channel.pid, + ABI_SYSCALLS.Accept, + ); + expect( + harness.kernelExports.kernel_handle_channel.mock.calls.map( + (call) => call[3], + ), + ).toEqual([0n]); + expect( + harness.kernelExports.kernel_blocking_retry_release, + ).toHaveBeenCalledWith( + harness.channel.pid, + harness.channel.pid, + 74n, + ); + expect(harness.worker.pendingPollRetries.has(harness.channel)).toBe( + false, + ); + }, + ); + + it.each(WIDTHS)( + "%s preserves public accept EAGAIN while attaching a caught signal", + (_name, pointerWidth) => { + const harness = createRetryHarness(pointerWidth); + writeRequest(harness, ABI_SYSCALLS.Accept, [7n, 0n, 0n]); + const isFdNonblock = vi.fn(() => 1); + harness.kernelExports.kernel_is_fd_nonblock = isFdNonblock; + harness.kernelExports.kernel_blocking_retry_token = vi.fn(() => 75n); + harness.kernelExports.kernel_handle_channel = vi.fn( + (rawPointer: number | bigint) => { + publishKernelResult( + kernelView(harness, rawPointer), + -1, + EAGAIN, + ); + return 0; + }, + ); + harness.kernelExports.kernel_dequeue_signal = vi.fn( + ( + _pid: number, + _tid: number, + rawPointer: number | bigint, + ) => writeKernelCaughtSignal(harness, rawPointer), + ); + + harness.worker.handleSyscall(harness.channel); + + expect(requestResult(harness)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + returnValue: -1, + errno: EAGAIN, + }); + expect(isFdNonblock).toHaveBeenCalledWith( + harness.channel.pid, + 7, + ); + const channelView = new DataView( + harness.processMemory.buffer, + harness.channel.channelOffset, + ); + expect(channelView.getUint32(CH_SIG_SIGNUM, true)).toBe(SIGUSR1); + expect(channelView.getUint32(CH_SIG_FLAGS, true)).toBe(SA_RESTART); + expect( + harness.kernelExports.kernel_blocking_retry_token, + ).toHaveBeenCalledWith( + harness.channel.pid, + harness.channel.pid, + ABI_SYSCALLS.Accept, + ); + expect( + harness.kernelExports.kernel_handle_channel.mock.calls.map( + (call) => call[3], + ), + ).toEqual([0n]); + expect( + harness.kernelExports.kernel_blocking_retry_release, + ).toHaveBeenCalledWith( + harness.channel.pid, + harness.channel.pid, + 75n, + ); + expect(harness.worker.pendingPollRetries.has(harness.channel)).toBe( + false, + ); + }, + ); + it.each(WIDTHS)( "%s releases exact retry authority before a first-attempt caught-signal EINTR", (_name, pointerWidth) => { diff --git a/host/test/signal-accept-livelock.test.ts b/host/test/signal-accept-livelock.test.ts index 2b3955c7b2..97e97ee686 100644 --- a/host/test/signal-accept-livelock.test.ts +++ b/host/test/signal-accept-livelock.test.ts @@ -42,6 +42,7 @@ import { const SIGCHLD = 17; const SIGTERM = 15; const SYS_TKILL = 204; +const ESRCH = 3; const SCRATCH_OFFSET = 4096; interface TestChannel { @@ -91,7 +92,9 @@ function createChannel( return channel; } -function createWorkerHarness(): SignalHarness { +function createWorkerHarness( + options: { readonly excludedExports?: readonly string[] } = {}, +): SignalHarness { const kernelMemory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); const completeChannel = vi.fn(); const onExit = vi.fn(); @@ -106,6 +109,7 @@ function createWorkerHarness(): SignalHarness { view.setUint32(CH_ERRNO, 0, true); return 0; }; + implementations.kernel_generate_host_signal = () => 0; implementations.kernel_pick_signal_target_tid = (pid: number) => pid; implementations.kernel_thread_has_deliverable = () => 1; implementations.kernel_get_process_exit_signal = () => -1; @@ -120,6 +124,9 @@ function createWorkerHarness(): SignalHarness { kernelMemory, () => implementations, () => SCRATCH_OFFSET, + 4, + undefined, + options.excludedExports, ); const gatedInstance = createKernelEntryGatedInstance(rawInstance, gate); const scratch = allocateKernelScratchRegion( @@ -528,12 +535,7 @@ describe("signal delivery to a process blocked in accept()", () => { registerProcess(harness, pid, [channel]); const pickSignalTarget = vi.fn(() => pid); let exited = false; - harness.implementations.kernel_handle_channel = ( - pointer: number | bigint, - ) => { - const view = new DataView(harness.kernelMemory.buffer, Number(pointer)); - view.setBigInt64(CH_RETURN, 0n, true); - view.setUint32(CH_ERRNO, 0, true); + harness.implementations.kernel_generate_host_signal = () => { exited = true; return 0; }; @@ -554,7 +556,7 @@ describe("signal delivery to a process blocked in accept()", () => { const channel = createChannel(pid); registerProcess(harness, pid, [channel]); const pickSignalTarget = vi.fn(() => pid); - harness.implementations.kernel_handle_channel = () => { + harness.implementations.kernel_generate_host_signal = () => { throw new Error("synthetic kernel trap"); }; harness.implementations.kernel_pick_signal_target_tid = pickSignalTarget; @@ -562,7 +564,7 @@ describe("signal delivery to a process blocked in accept()", () => { try { expect(() => { harness.worker.testAuthority.sendSignalForTest(pid, SIGTERM); - }).toThrow(/kernel_handle_channel failed/); + }).toThrow(/kernel_generate_host_signal failed/); await Promise.resolve(); expect(harness.onExit).not.toHaveBeenCalled(); @@ -573,20 +575,66 @@ describe("signal delivery to a process blocked in accept()", () => { } }); - it("preserves the ambient host PID when signal TID binding is rejected", () => { + it("fails the kernel generation when host-signal generation is absent", async () => { + const harness = createWorkerHarness({ + excludedExports: ["kernel_generate_host_signal"], + }); + const pickSignalTarget = vi.fn(() => 53); + harness.implementations.kernel_pick_signal_target_tid = pickSignalTarget; + + expect(() => { + harness.worker.testAuthority.sendSignalForTest(53, SIGTERM); + }).toThrow(/kernel host-signal generation export is unavailable/); + await Promise.resolve(); + + expect(pickSignalTarget).not.toHaveBeenCalled(); + expect(harness.onKernelFatal).toHaveBeenCalledOnce(); + }); + + it("fails the kernel generation when host-signal generation is rejected", async () => { + const harness = createWorkerHarness(); + const pickSignalTarget = vi.fn(() => 54); + harness.implementations.kernel_generate_host_signal = () => -16; + harness.implementations.kernel_pick_signal_target_tid = pickSignalTarget; + + expect(() => { + harness.worker.testAuthority.sendSignalForTest(54, SIGTERM); + }).toThrow(/kernel rejected host signal 15 for pid 54: -16/); + await Promise.resolve(); + + expect(pickSignalTarget).not.toHaveBeenCalled(); + expect(harness.onKernelFatal).toHaveBeenCalledOnce(); + }); + + it("treats ESRCH from host-signal generation as retired target proof", () => { + const harness = createWorkerHarness(); + const pickSignalTarget = vi.fn(() => 55); + harness.implementations.kernel_generate_host_signal = () => -ESRCH; + harness.implementations.kernel_pick_signal_target_tid = pickSignalTarget; + + harness.worker.testAuthority.sendSignalForTest(55, SIGTERM); + + expect(pickSignalTarget).not.toHaveBeenCalled(); + expect(harness.onKernelFatal).not.toHaveBeenCalled(); + }); + + it("generates a host signal without consuming task-channel authority", () => { const harness = createWorkerHarness(); const state = mutableState(harness); const targetPid = 54; const priorPid = 91; - const setCurrentTid = vi.fn(() => -3); + const generateHostSignal = vi.fn(() => 0); + const setCurrentTid = vi.fn(() => 0); const handleChannel = vi.fn(); state.currentHandlePid = priorPid; + harness.implementations.kernel_generate_host_signal = generateHostSignal; harness.implementations.kernel_set_current_tid = setCurrentTid; harness.implementations.kernel_handle_channel = handleChannel; harness.worker.testAuthority.sendSignalForTest(targetPid, SIGTERM); - expect(setCurrentTid).toHaveBeenCalledWith(targetPid, targetPid); + expect(generateHostSignal).toHaveBeenCalledWith(targetPid, SIGTERM); + expect(setCurrentTid).not.toHaveBeenCalled(); expect(handleChannel).not.toHaveBeenCalled(); expect(state.currentHandlePid).toBe(priorPid); }); diff --git a/host/test/support/kernel-scratch-instance.ts b/host/test/support/kernel-scratch-instance.ts index 171e0e33af..edbd5d0797 100644 --- a/host/test/support/kernel-scratch-instance.ts +++ b/host/test/support/kernel-scratch-instance.ts @@ -140,6 +140,10 @@ function signatures( parameters: [i32], result: i32, }, + kernel_generate_host_signal: { + parameters: [i32, i32], + result: i32, + }, kernel_has_sa_nocldstop: { parameters: [i32], result: i32, From 3a08f141a5f1d149c593548bd7143346f7a6337d Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sat, 11 Jul 2026 19:48:27 -0400 Subject: [PATCH 32/82] Host: Ignore debug names while patching thread modules Custom name sections are optional, unauthenticated debug metadata. They cannot authorize rewriting a function body while preparing a pthread module. Forward-port #892 onto ABI 43's linker-wrapper scan. Remove the arbitrary exported-call fallback, accept only executable constructor evidence, and require the selected function to have type () -> (). Validation: - 48 focused binary-parser and patch tests, with one skipped - 27 focused pthread and thread-lifecycle tests - host TypeScript declaration build - exact spoof fixture in Chromium, Firefox, and WebKit (cherry picked from commit f678e098e10b2c563ae0eecd3e3088e862695086) --- .../test/thread-wasm-patch.spec.ts | 94 ++++++++++ host/src/constants.ts | 21 +++ host/src/worker-main.ts | 66 +++++-- host/test/thread-wasm-patch.test.ts | 164 ++++++++++++++++++ 4 files changed, 329 insertions(+), 16 deletions(-) create mode 100644 apps/browser-demos/test/thread-wasm-patch.spec.ts create mode 100644 host/test/thread-wasm-patch.test.ts diff --git a/apps/browser-demos/test/thread-wasm-patch.spec.ts b/apps/browser-demos/test/thread-wasm-patch.spec.ts new file mode 100644 index 0000000000..5eb52cd891 --- /dev/null +++ b/apps/browser-demos/test/thread-wasm-patch.spec.ts @@ -0,0 +1,94 @@ +import { expect, test } from "@playwright/test"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const workerMainPath = resolve( + __dirname, + "../../../host/src/worker-main.ts", +); + +function uleb(value: number): number[] { + const bytes: number[] = []; + do { + let byte = value & 0x7f; + value >>>= 7; + if (value !== 0) byte |= 0x80; + bytes.push(byte); + } while (value !== 0); + return bytes; +} + +function name(value: string): number[] { + const bytes = new TextEncoder().encode(value); + return [...uleb(bytes.byteLength), ...bytes]; +} + +function section(id: number, contents: number[]): number[] { + return [id, ...uleb(contents.length), ...contents]; +} + +function spoofedConstructorFixture(): number[] { + const bytes = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]; + bytes.push(...section(1, [ + 0x02, + 0x60, 0x00, 0x00, + 0x60, 0x00, 0x01, 0x7f, + ])); + bytes.push(...section(3, [0x03, 0x00, 0x00, 0x01])); + bytes.push(...section(7, [ + 0x01, + ...name("invoke_spoof"), + 0x00, 0x02, + ])); + bytes.push(...section(8, [0x00])); + bytes.push(...section(10, [ + 0x03, + 0x02, 0x00, 0x0b, + 0x03, 0x00, 0x00, 0x0b, + 0x06, 0x00, 0x10, 0x01, 0x41, 0x12, 0x0b, + ])); + const functionNameMap = [ + 0x01, + 0x01, + ...name("__wasm_call_ctors"), + ]; + bytes.push(...section(0, [ + ...name("name"), + 0x01, + ...uleb(functionNameMap.length), + ...functionNameMap, + ])); + return bytes; +} + +test("thread patching ignores spoofed debug names in every browser", async ({ + page, + baseURL, +}) => { + expect(baseURL).toBeTruthy(); + await page.goto(new URL("/trap-signal-test.html", baseURL!).href); + + const result = await page.evaluate(async ({ workerUrl, fixture }) => { + const { patchWasmForThread } = await import( + /* @vite-ignore */ workerUrl + ); + const source = new Uint8Array(fixture).buffer; + const patched = patchWasmForThread(source); + const valid = WebAssembly.validate(patched); + const module = await WebAssembly.compile(patched); + const instance = await WebAssembly.instantiate(module); + let trapped = false; + try { + (instance.exports.invoke_spoof as () => number)(); + } catch (error) { + trapped = error instanceof WebAssembly.RuntimeError; + } + return { valid, trapped }; + }, { + workerUrl: new URL(`/@fs/${workerMainPath}`, baseURL!).href, + fixture: spoofedConstructorFixture(), + }); + + expect(result).toEqual({ valid: true, trapped: true }); +}); diff --git a/host/src/constants.ts b/host/src/constants.ts index 0b253d020f..4960727dac 100644 --- a/host/src/constants.ts +++ b/host/src/constants.ts @@ -654,6 +654,8 @@ interface WasmExportEntry { } interface WasmForkArtifactFacts { + functionTypes: readonly (WasmFunctionSignature | undefined)[]; + functionTypeIndices: readonly number[]; functionImports: Map; functionImportEntries: WasmFunctionImportType[]; globalImports: Map; @@ -795,6 +797,8 @@ function readWasmForkArtifactFacts(programBytes: ArrayBuffer): WasmForkArtifactF const functionTypeIndices: number[] = []; const pendingFunctionExports: Array<{ name: string; index: number }> = []; const facts: WasmForkArtifactFacts = { + functionTypes, + functionTypeIndices, functionImports: new Map(), functionImportEntries: [], globalImports: new Map(), @@ -2153,6 +2157,23 @@ export function readWasmFunctionImports( ); } +/** Return the exact parameter/result arity for one core function index. */ +export function readWasmFunctionArity( + programBytes: ArrayBuffer, + functionIndex: number, +): Readonly<{ parameters: number; results: number }> | null { + if (!Number.isSafeInteger(functionIndex) || functionIndex < 0) return null; + const facts = readWasmForkArtifactFacts(programBytes); + const typeIndex = facts.functionTypeIndices[functionIndex]; + if (typeIndex === undefined) return null; + const signature = facts.functionTypes[typeIndex]; + if (signature === undefined) return null; + return Object.freeze({ + parameters: signature.params.length, + results: signature.results.length, + }); +} + export type DecodedWasmExternalKind = | "function" | "table" diff --git a/host/src/worker-main.ts b/host/src/worker-main.ts index 36e4344993..0e4d2ef916 100644 --- a/host/src/worker-main.ts +++ b/host/src/worker-main.ts @@ -32,6 +32,7 @@ import { import { describeWasmArtifactPolicyFailures, extractAbiVersion, + readWasmFunctionArity, readWasmImportDescriptors, WASM_PAGE_SIZE, } from "./constants"; @@ -4595,7 +4596,9 @@ export function patchWasmForThread(bytes: ArrayBuffer): ArrayBuffer { numFuncImports = readWasmImportDescriptors(bytes) .filter((entry) => entry.kind === "function").length; - // Find the constructor function by looking at the exported helper wrappers. + // Find the constructor function from executable linker evidence. Custom + // name sections are optional debug metadata and cannot authorize a body + // rewrite. // Plain lld output puts `call $__wasm_call_ctors` first. After // wasm-fork-instrument, wrappers have a rewind prolog before the original // body, so scan instructions and choose the call target shared by the known @@ -4757,7 +4760,21 @@ export function patchWasmForThread(bytes: ArrayBuffer): ArrayBuffer { return calls; } - // Find the Code section and identify a call target shared by LLVM helper exports. + const ctorCandidates = new Map(); + const addCtorCandidate = (index: number | undefined, source: string): void => { + if (index === undefined) return; + const sources = ctorCandidates.get(index) ?? []; + sources.push(source); + ctorCandidates.set(index, sources); + }; + addCtorCandidate( + exportFuncIndicesByName.get("__wasm_call_ctors"), + "function export", + ); + + // Find the Code section and identify a call target shared by LLVM helper + // exports. Instrumented wrappers can have a rewind prolog, so a shared + // executable target is stronger evidence than a fixed instruction offset. for (const sec of sections) { if (sec.id === 10 && exportedFuncIndices.length > 0) { const helperNames = [ @@ -4800,29 +4817,46 @@ export function patchWasmForThread(bytes: ArrayBuffer): ArrayBuffer { } } - if (best) { - ctorFuncIndex = best.target; - } else { - // Fallback for very small legacy binaries: use the first call in an - // exported function whose body starts with that call. - for (const funcIndex of exportedFuncIndices) { - const bounds = getInstructionStartAndEnd(sec, funcIndex); - if (!bounds || src[bounds.start] !== 0x10) continue; + if (best) addCtorCandidate(best.target, "shared linker wrappers"); + + // A validated ABI marker is itself a linker wrapper in small legacy + // modules. Its leading direct call is authoritative even when there is + // no second helper export with which to intersect it. + const abiMarkerIndex = exportFuncIndicesByName.get("__abi_version"); + if (abiMarkerIndex !== undefined && extractAbiVersion(bytes) !== null) { + const bounds = getInstructionStartAndEnd(sec, abiMarkerIndex); + if (bounds && src[bounds.start] === 0x10) { const [target] = readLEB128(src, bounds.start + 1); - if (target >= numFuncImports) { - ctorFuncIndex = target; - break; - } + addCtorCandidate(target, "__abi_version linker wrapper"); } } break; } } + if (ctorCandidates.size > 1) { + const evidence = [...ctorCandidates] + .map(([index, sources]) => `${index} (${sources.join(", ")})`) + .join("; "); + throw new Error(`Conflicting __wasm_call_ctors evidence: ${evidence}`); + } + ctorFuncIndex = ctorCandidates.keys().next().value ?? -1; + const ctorCodeEntry = ctorFuncIndex >= 0 ? ctorFuncIndex - numFuncImports : -1; - if (ctorFuncIndex < 0) { - // No ctor found — still strip start section but can't neuter the ctor body + if (ctorFuncIndex >= 0) { + const arity = readWasmFunctionArity(bytes, ctorFuncIndex); + if (ctorCodeEntry < 0 || arity === null) { + throw new Error( + `__wasm_call_ctors function ${ctorFuncIndex} has no defined function body`, + ); + } + if (arity.parameters !== 0 || arity.results !== 0) { + throw new Error( + `__wasm_call_ctors function ${ctorFuncIndex} must have type () -> (), ` + + `found ${arity.parameters} parameter(s) and ${arity.results} result(s)`, + ); + } } // Build output: always skip Start section; optionally neuter constructor function diff --git a/host/test/thread-wasm-patch.test.ts b/host/test/thread-wasm-patch.test.ts new file mode 100644 index 0000000000..24202048d7 --- /dev/null +++ b/host/test/thread-wasm-patch.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vitest"; +import { patchWasmForThread } from "../src/worker-main"; + +function uleb(value: number): number[] { + const encoded: number[] = []; + do { + let byte = value & 0x7f; + value >>>= 7; + if (value !== 0) byte |= 0x80; + encoded.push(byte); + } while (value !== 0); + return encoded; +} + +function section(id: number, content: number[]): number[] { + return [id, ...uleb(content.length), ...content]; +} + +function name(value: string): number[] { + const bytes = new TextEncoder().encode(value); + return [...uleb(bytes.length), ...bytes]; +} + +function moduleBytes(options: { + types: Array<{ params: number[]; results: number[] }>; + functionTypes: number[]; + bodies: number[][]; + exports: Array<{ name: string; index: number }>; + functionNames?: Array<{ name: string; index: number }>; + start?: number; +}): ArrayBuffer { + const bytes = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]; + const typeContent = [...uleb(options.types.length)]; + for (const type of options.types) { + typeContent.push( + 0x60, + ...uleb(type.params.length), + ...type.params, + ...uleb(type.results.length), + ...type.results, + ); + } + bytes.push(...section(1, typeContent)); + bytes.push(...section(3, [ + ...uleb(options.functionTypes.length), + ...options.functionTypes.flatMap(uleb), + ])); + bytes.push(...section(7, [ + ...uleb(options.exports.length), + ...options.exports.flatMap((entry) => [ + ...name(entry.name), + 0x00, + ...uleb(entry.index), + ]), + ])); + if (options.start !== undefined) { + bytes.push(...section(8, uleb(options.start))); + } + const codeContent = [...uleb(options.bodies.length)]; + for (const instructions of options.bodies) { + const body = [0x00, ...instructions, 0x0b]; + codeContent.push(...uleb(body.length), ...body); + } + bytes.push(...section(10, codeContent)); + if (options.functionNames) { + const functionNameMap = [ + ...uleb(options.functionNames.length), + ...options.functionNames.flatMap((entry) => [ + ...uleb(entry.index), + ...name(entry.name), + ]), + ]; + bytes.push(...section(0, [ + ...name("name"), + 0x01, + ...uleb(functionNameMap.length), + ...functionNameMap, + ])); + } + return new Uint8Array(bytes).buffer; +} + +const VOID = { params: [], results: [] }; +const I32_RESULT = { params: [], results: [0x7f] }; + +describe("patchWasmForThread", () => { + it("does not rewrite an unrelated exported call target when no constructors exist", async () => { + const original = moduleBytes({ + types: [VOID, I32_RESULT], + functionTypes: [0, 1, 1, 1], + bodies: [ + [], + [0x41, 0x07], + [0x10, 0x01], + [0x41, 0x12], + ], + exports: [ + { name: "p10_errno_address", index: 2 }, + { name: "__abi_version", index: 3 }, + ], + start: 0, + }); + + const patched = patchWasmForThread(original); + expect(WebAssembly.validate(patched)).toBe(true); + const { instance } = await WebAssembly.instantiate(patched); + expect((instance.exports.p10_errno_address as () => number)()).toBe(7); + }); + + it("neuters the constructor identified by the ABI linker wrapper", async () => { + const original = moduleBytes({ + types: [VOID, I32_RESULT], + functionTypes: [0, 0, 1], + bodies: [ + [], + [0x00], + [0x10, 0x01, 0x41, 0x12], + ], + exports: [{ name: "__abi_version", index: 2 }], + start: 0, + }); + + const patched = patchWasmForThread(original); + expect(WebAssembly.validate(patched)).toBe(true); + const { instance } = await WebAssembly.instantiate(patched); + expect((instance.exports.__abi_version as () => number)()).toBe(18); + }); + + it("does not rewrite a function identified only by spoofed name metadata", async () => { + const original = moduleBytes({ + types: [VOID, I32_RESULT], + functionTypes: [0, 0, 1], + bodies: [ + [], + [0x00], + [0x10, 0x01, 0x41, 0x12], + ], + exports: [{ name: "invoke_spoof", index: 2 }], + functionNames: [{ name: "__wasm_call_ctors", index: 1 }], + start: 0, + }); + + const patched = patchWasmForThread(original); + expect(WebAssembly.validate(patched)).toBe(true); + const { instance } = await WebAssembly.instantiate(patched); + expect(() => (instance.exports.invoke_spoof as () => number)()).toThrow( + /unreachable/, + ); + }); + + it("rejects constructor evidence that does not point to a () -> () function", () => { + const original = moduleBytes({ + types: [VOID, I32_RESULT], + functionTypes: [0, 1], + bodies: [[], [0x41, 0x01]], + exports: [{ name: "__wasm_call_ctors", index: 1 }], + start: 0, + }); + + expect(() => patchWasmForThread(original)).toThrow( + /must have type \(\) -> \(\).*1 result/, + ); + }); +}); From 9af930df3966f6bb8c85e0658147be49975c23c4 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sat, 11 Jul 2026 17:40:15 -0400 Subject: [PATCH 33/82] Host: Finalize readiness timeouts through the kernel Preserve one absolute deadline across poll and select retries, and finish timeout expiry with a zero-time kernel pass that clears outputs and restores temporary signal masks. The ABI 43 retry stack already supersedes the original host mutation approach with immutable request snapshots. Port the missing kernel, broad-wake, targeted-wake, and documentation evidence without regressing that stronger ownership model. Validation: - 1,501 native kernel unit tests - 162 focused host retry tests - host type generation - VitePress documentation build (cherry picked from commit b671aa68734a2101d6015c174ea94cd941546ea6) --- crates/kernel/src/syscalls.rs | 32 ++++++++++ docs/architecture.md | 9 +++ docs/posix-status.md | 8 +-- host/test/readiness-deadline.test.ts | 94 +++++++++++++++++++++++++++- 4 files changed, 138 insertions(+), 5 deletions(-) diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 87ce8cc58e..0819aee7df 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -27076,6 +27076,38 @@ mod tests { assert_eq!(pollfd.revents, 0); } + #[test] + fn test_ppoll_zero_timeout_clears_revents_and_restores_mask() { + use wasm_posix_shared::poll::POLLIN; + + let mut proc = Process::new(81_006); + let mut host = MockHostIO::new(); + let tid = proc.pid; + proc.blocked_retries.bind_task(tid); + let original_mask = crate::signal::sig_bit(2); + let temporary_mask = crate::signal::sig_bit(3); + assert!(proc.set_blocked_for(tid, original_mask)); + let mut fds = [WasmPollFd { + fd: -1, + events: POLLIN, + revents: POLLIN, + }]; + + assert_eq!( + sys_ppoll( + &mut proc, + &mut host, + &mut fds, + 0, + Some(temporary_mask), + ), + Ok(0), + ); + assert_eq!(fds[0].revents, 0); + assert_eq!(proc.blocked_for(tid), original_mask); + assert_eq!(proc.sigsuspend_saved_mask_for(tid), None); + } + #[test] fn test_poll_socket_pair() { let mut proc = Process::new(1); diff --git a/docs/architecture.md b/docs/architecture.md index a523e4e117..42666a6308 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -865,6 +865,15 @@ This mechanism is critical: asynchronous scheduling never owns a live scratch view, while Rust retains the resource identity and lifetime needed by the next synchronous entry. +Finite `poll()`/`ppoll()` and `select()`/`pselect6()` waits retain one absolute +deadline from their first attempt. Targeted readiness events, broad wakeups, +and safety retries use the remaining duration instead of restarting the +caller's timeout. Except for descriptor-free `select()` used only as a sleep, +expiry performs one zero-time kernel pass. That pass makes the final readiness +decision, clears readiness outputs, and restores any temporary `ppoll()` or +`pselect6()` signal mask. The host rebuilds the pass from its immutable request +snapshot; it does not overwrite the caller's original timeout or arguments. + `F_SETLKW` uses the same parking mechanism with a narrower wake contract. A conflict returns the internal retry result, and the host parks only that lock request. Unlock, conversion, close, exit, and other Rust-side changes that may diff --git a/docs/posix-status.md b/docs/posix-status.md index 966fe00914..3d9d2dbe6d 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -281,10 +281,10 @@ shortcuts. | `sendmsg()` / `recvmsg()` | Partial | The host validates every native wasm32/wasm64 iovec and enforces the generated `IOV_MAX` of 1,024. It flattens the complete send list into one fixed-wire kernel buffer and scatters a received prefix across the complete caller list; zero-length entries remain valid. The complete aligned header, optional name, translated control records, one canonical iovec, and payload are capacity-checked as one owned layout: the ordinary channel allocation is used when it fits, and a fresh Rust-owned token reservation is used otherwise. The operation is never shortened merely to fit scratch. Native `cmsghdr` records are translated between the generated wasm32/wasm64 layouts and a fixed kernel wire, so receive capacity reflects the descriptors the caller layout can actually represent. `SCM_RIGHTS` preserves owned, receiver-reconstructible non-socket descriptions while they are queued. A batch containing a socket, epoll instance, stale backing, or other process-owned description that cannot be reconstructed fails atomically with `EOPNOTSUPP` before carrier bytes are published; a copied socket snapshot is never reported as successful transfer. AF_UNIX stream rights remain associated with their carrier-byte positions, `MSG_WAITALL` stops at a rights boundary, ordinary reads discard only rights whose bytes they consume, and repeated `MSG_PEEK` does not consume bytes or rights. AF_UNIX datagrams queue payload/address/rights atomically for connected and addressed same-process sends, including zero-byte messages received with `msg_iovlen == 0`; ordinary `read(..., 0)` remains a no-op. Closing the sender's fd cannot invalidate a supported in-flight or received reference. `recvmsg()` installs the descriptor prefix that fits the caller's control buffer, releases the excess, reports `MSG_CTRUNC`, applies `MSG_CMSG_CLOEXEC` atomically, and reports output `MSG_TRUNC` independently of the input flag that selects full-length return behavior. Cross-process AF_UNIX datagram routing, socket-descriptor transfer, and other socket-family ancillary messages remain unsupported, so this surface is still partial. | | `setsockopt()` / `getsockopt()` | Partial | SOL_SOCKET exposes SO_TYPE, SO_DOMAIN, SO_ERROR, SO_ACCEPTCONN, SO_RCVBUF, and SO_SNDBUF; SO_REUSEADDR affects UDP bind conflicts. The public host scalar `setsockopt` wrapper stages exactly four value bytes in allocator-owned scratch and passes the lease-derived pointer plus length to Rust; the scalar is never interpreted as a kernel address. `SO_RCVTIMEO`/`SO_SNDTIMEO` accept musl's wasm32 time64 option numbers (66/67) and wasm64 long64 numbers (20/21), canonicalizing both to the same stored timeout state; `struct timeval` is 16 bytes on both ABIs. `SO_RCVBUF`/`SO_SNDBUF` requests are accepted and stored but do not resize kernel queues or pipe buffers; `getsockopt()` reports the fixed default. `SO_BROADCAST` controls only the IPv4 limited-broadcast permission gate and does not provide broadcast delivery. SO_LINGER uses `struct linger`; its disabled form is stored, while enabling timed or reset-style linger returns EOPNOTSUPP until every transport supports the close mode. SO_BINDTODEVICE validates `lo`/`eth0`, supports empty-name unbind, and constrains bind/connect/send routing. TCP_CONGESTION uses a string layout and accepts only the modeled `cubic` policy; selecting unimplemented algorithms fails. IPv4 multicast membership/source-filter options drive process-local loopback delivery. IPV6_V6ONLY controls pre-bind stream dual-stack behavior; AF_INET6 datagrams truthfully remain V6-only. Other accepted IPv6 multicast options are stored but do not provide IPv6 multicast transport. | | `shutdown()` | Partial | SHUT_RD, SHUT_WR, and SHUT_RDWR transitions are idempotent within a process and release each owned pipe/host reference once. UDP write shutdown returns EPIPE on datagram send; read shutdown is EOF-like for recv/poll. Sending to a read-shut AF_UNIX datagram peer returns EPIPE (and SIGPIPE unless MSG_NOSIGNAL is used), and the transition wakes blocked sends/readiness waits. Fork-inherited sockets still clone shutdown flags per process instead of sharing one socket-wide shutdown state, and the external host ABI has no half-shutdown operation. | -| `select()` | Partial | Wrapper around poll(). Converts fd_set bitmasks to pollfd array. Timeout supported via a host retry loop. A caught signal interrupts a would-block retry, including the no-fd sleep path, with EINTR; ignored signals leave it parked and a concurrently ready result is preserved. | -| `poll()` | Partial | Checks readiness for regular files, pipes, and sockets. UDP poll reports queued datagrams, connected-peer filtering, EOF-like read shutdown, write-shutdown hangup, and pending socket errors. Timeout supported via polling loop with 1ms sleep intervals. Returns EINTR on pending signals. | -| `ppoll()` | Full | Wraps poll() with atomic signal mask swap: save → set → poll → restore. Timespec converted to timeout_ms in glue layer. | -| `pselect6()` | Partial | Wraps select() with an atomic signal-mask swap across the host retry loop. The pselect6-style `{sigset_t *, size_t}` argument supplies the mask; timeout precision is rounded to host milliseconds. Caught signals interrupt a would-block retry with EINTR after the temporary mask is restored. | +| `select()` | Partial | Wrapper around poll(). Converts fd_set bitmasks to pollfd array. A finite wait keeps one absolute deadline across host retries and finishes with a zero-time kernel pass, except for the descriptor-free sleep form. A caught signal interrupts a would-block retry, including the no-fd sleep path, with EINTR; ignored signals leave it parked and a concurrently ready result is preserved. | +| `poll()` | Partial | Checks readiness for regular files, pipes, and sockets. UDP poll reports queued datagrams, connected-peer filtering, EOF-like read shutdown, write-shutdown hangup, and pending socket errors. A finite wait keeps one absolute deadline across targeted wakeups and safety retries, then finishes with a zero-time kernel pass that clears `revents`. Returns EINTR on pending signals. | +| `ppoll()` | Full | Wraps poll() with atomic signal mask swap: save → set → poll → restore. The glue layer converts the timespec to milliseconds. A finite wait preserves its deadline across host retries and expires through the kernel so `revents` is copied back and the temporary mask is restored. | +| `pselect6()` | Partial | Wraps select() with an atomic signal-mask swap across the host retry loop. The pselect6-style `{sigset_t *, size_t}` argument supplies the mask; timeout precision is rounded to host milliseconds. A finite wait preserves its deadline and expires through a zero-time kernel pass. Caught signals interrupt a would-block retry with EINTR after the temporary mask is restored. | | `epoll_create1()` | Full | Creates epoll instance with per-process interest list. EPOLL_CLOEXEC flag supported. | | `epoll_ctl()` | Full | EPOLL_CTL_ADD, EPOLL_CTL_MOD, EPOLL_CTL_DEL. Stores interest set with events + data. | | `epoll_pwait()` | Full | Builds pollfd from interest set, delegates to poll, maps results back to epoll_event structs. Optional signal mask swap. | diff --git a/host/test/readiness-deadline.test.ts b/host/test/readiness-deadline.test.ts index 95b86fd0d1..352c42adbe 100644 --- a/host/test/readiness-deadline.test.ts +++ b/host/test/readiness-deadline.test.ts @@ -54,7 +54,9 @@ interface ReadinessState { }; }>; readonly hostReaped: Set; - readonly pendingPollRetries: Map; + readonly pendingPollRetries: Map; readonly pendingSelectRetries: Map 0), kernel_get_process_exit_signal: vi.fn(() => exitSignal), + kernel_get_fd_pipe_idx: vi.fn((_pid: number, fd: number) => + fd === 7 ? 99 : -1 + ), kernel_handle_channel: handleChannel, kernel_set_current_tid: setCurrentTid, }; @@ -208,6 +213,7 @@ function createHarness( "kernel_drain_wakeup_events", "kernel_get_parent_pid", "kernel_get_process_exit_signal", + "kernel_get_fd_pipe_idx", "kernel_handle_channel", "kernel_set_current_tid", ], @@ -388,6 +394,92 @@ describe("finite readiness deadlines", () => { expect(retainedSnapshot?.dispatch.readinessTimeoutMs).toBe(120); }); + it("does not extend an nfds=0 poll deadline across broad wakes", async () => { + vi.useFakeTimers(); + vi.setSystemTime(2_000); + + const observedKernelTimeouts: number[] = []; + const harness = createHarness((_syscallNr, scratch) => { + const timeout = Number( + scratch.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true), + ); + observedKernelTimeouts.push(timeout); + return timeout === 0 + ? { retVal: 0, errVal: 0 } + : { retVal: -1, errVal: EAGAIN }; + }); + const args = syscallArgs(0, 0, 30); + + dispatchSyscall(harness, ABI_SYSCALLS.Poll, args); + expect(harness.state.pendingPollRetries.get(harness.channel)?.deadline) + .toBe(2_030); + + await vi.advanceTimersByTimeAsync(5); + harness.queueReadableWake(); + harness.worker.testAuthority.drainWakeupEventsForTest(); + await vi.advanceTimersByTimeAsync(0); + expect(harness.state.pendingPollRetries.get(harness.channel)?.deadline) + .toBe(2_030); + + await vi.advanceTimersByTimeAsync(7); + harness.queueReadableWake(); + harness.worker.testAuthority.drainWakeupEventsForTest(); + await vi.advanceTimersByTimeAsync(0); + expect(harness.state.pendingPollRetries.get(harness.channel)?.deadline) + .toBe(2_030); + + await vi.advanceTimersByTimeAsync(17); + expect(harness.completeChannel).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + await Promise.resolve(); + + expect(observedKernelTimeouts.at(-1)).toBe(0); + expect(harness.completeChannel).toHaveBeenCalledOnce(); + expect(harness.state.pendingPollRetries.size).toBe(0); + }); + + it("cancels the poll deadline after targeted readiness", async () => { + vi.useFakeTimers(); + vi.setSystemTime(3_000); + + let attempts = 0; + const harness = createHarness((syscallNr) => { + expect(syscallNr).toBe(ABI_SYSCALLS.Poll); + attempts++; + return attempts === 1 + ? { retVal: -1, errVal: EAGAIN } + : { retVal: 1, errVal: 0 }; + }); + const pollPointer = 1024; + const pollfd = new DataView( + harness.processMemory.buffer, + pollPointer, + STRUCT_SIZE_WASM_POLL_FD, + ); + pollfd.setInt32(0, 7, true); + pollfd.setInt16(4, 0x001, true); + const args = syscallArgs(pollPointer, 1, 40); + + dispatchSyscall(harness, ABI_SYSCALLS.Poll, args); + expect(harness.state.pendingPollRetries.get(harness.channel)?.deadline) + .toBe(3_040); + + await vi.advanceTimersByTimeAsync(7); + harness.worker.notifyPipeReadable(99); + await Promise.resolve(); + + expect(attempts).toBe(2); + expect(harness.completeChannel).toHaveBeenCalledOnce(); + expect(harness.completeChannel.mock.calls[0]!.slice(4, 6)).toEqual([ + 1, + 0, + ]); + expect(harness.state.pendingPollRetries.size).toBe(0); + + await vi.advanceTimersByTimeAsync(100); + expect(harness.completeChannel).toHaveBeenCalledOnce(); + }); + it("treats a final zero-time ppoll EAGAIN as timeout after mask cleanup", async () => { vi.useFakeTimers(); vi.setSystemTime(1_500); From 0b9e50219ee6540d20a91bb5cf24f02faf22f361 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Mon, 15 Jun 2026 23:02:52 -0400 Subject: [PATCH 34/82] Host: Run Node service demos from shared dinit images Node and browser hosts must exercise the same service topology. The Node demos previously reconstructed browser service graphs from loose host binaries, which let the two platform paths drift. Forward-port PR #707 so nginx, Redis, MariaDB, WordPress, and LAMP restore the browser service VFS images and launch image-owned dinit. Preserve the current lazy-tree seal verification and spawnFromVfs executable-lookup contracts. Route local image builds through run.sh. Validation: - Host declaration/type build passed. - A direct strict TypeScript check passed for the shared launcher and all service entry points. - All modified shell entry points passed bash -n. - VitePress documentation build passed. - Playwright discovered all five counterpart checks under Chromium, Firefox, and WebKit. The production browser build and runtime checks stop at the existing stale program-packages.json guard, before service startup. Regenerate that projection with the final ABI 43 package artifacts before the full browser and service lifecycle validation. (cherry picked from commit 159fb89d308c00abeb62b7ba833404f9e1d6f7ca) --- .../test/node-host-counterparts.spec.ts | 294 ++++++++++++++++++ docs/architecture.md | 7 + docs/browser-support.md | 13 +- packages/registry/lamp/demo/run.sh | 39 +-- packages/registry/lamp/demo/serve.ts | 260 +++------------- packages/registry/mariadb/demo/run.sh | 21 +- packages/registry/mariadb/demo/serve.ts | 193 +++--------- packages/registry/nginx/demo/run-php.sh | 20 +- packages/registry/nginx/demo/run.sh | 10 +- packages/registry/nginx/demo/serve-php.ts | 127 +++----- packages/registry/nginx/demo/serve.ts | 124 +++----- packages/registry/redis/demo/serve.ts | 113 +++---- packages/registry/service-vfs-demo.ts | 273 ++++++++++++++++ packages/registry/wordpress/demo/run-nginx.sh | 2 +- packages/registry/wordpress/demo/run.sh | 23 +- .../registry/wordpress/demo/serve-nginx.ts | 173 ++--------- packages/registry/wordpress/demo/serve.ts | 136 ++++---- .../test/wordpress-site-editor.test.ts | 123 ++------ run.sh | 42 +-- 19 files changed, 994 insertions(+), 999 deletions(-) create mode 100644 apps/browser-demos/test/node-host-counterparts.spec.ts create mode 100644 packages/registry/service-vfs-demo.ts diff --git a/apps/browser-demos/test/node-host-counterparts.spec.ts b/apps/browser-demos/test/node-host-counterparts.spec.ts new file mode 100644 index 0000000000..f33f8be74f --- /dev/null +++ b/apps/browser-demos/test/node-host-counterparts.spec.ts @@ -0,0 +1,294 @@ +import { expect, test } from "@playwright/test"; +import { execFileSync, spawn, type ChildProcess } from "node:child_process"; +import { existsSync } from "node:fs"; +import { createServer } from "node:net"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { tryResolveBinary } from "../../../host/src/binary-resolver"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(__dirname, "../../.."); +const publicDir = join(repoRoot, "apps", "browser-demos", "public"); + +interface ServiceImageRef { + relPath: string; + publicFile: string; + buildHint: string; +} + +interface RunningDemo { + proc: ChildProcess; + output: () => string; +} + +const serviceImages = { + nginx: { + relPath: "programs/nginx-vfs.vfs.zst", + publicFile: "nginx.vfs.zst", + buildHint: "./run.sh build nginx-vfs", + }, + nginxPhp: { + relPath: "programs/nginx-php-vfs.vfs.zst", + publicFile: "nginx-php.vfs.zst", + buildHint: "./run.sh build nginx-php-vfs", + }, + wordpress: { + relPath: "programs/wordpress.vfs.zst", + publicFile: "wordpress.vfs.zst", + buildHint: "./run.sh build wp-vfs", + }, + lamp: { + relPath: "programs/lamp.vfs.zst", + publicFile: "lamp.vfs.zst", + buildHint: "./run.sh build lamp-vfs", + }, +} satisfies Record; + +function hasServiceImage(image: ServiceImageRef): boolean { + return !!tryResolveBinary(image.relPath) || existsSync(join(publicDir, image.publicFile)); +} + +function skipUnlessRunnable(label: string, image: ServiceImageRef): void { + test.skip(!tryResolveBinary("kernel.wasm"), `${label}: kernel.wasm is not built`); + test.skip( + !hasServiceImage(image), + `${label}: service VFS image is not built (${image.buildHint})`, + ); +} + +function skipUnlessProgram(label: string, relPath: string): void { + test.skip(!tryResolveBinary("kernel.wasm"), `${label}: kernel.wasm is not built`); + test.skip(!tryResolveBinary(relPath), `${label}: ${relPath} is not built`); +} + +async function getFreePort(host = "127.0.0.1"): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.listen(0, host, () => { + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + server.close(() => resolve(port)); + }); + server.on("error", reject); + }); +} + +async function isPortAvailable(port: number, host = "127.0.0.1"): Promise { + return new Promise((resolve) => { + const server = createServer(); + server.once("error", () => resolve(false)); + server.listen(port, host, () => server.close(() => resolve(true))); + }); +} + +async function startNodeHostDemo( + script: string, + args: string[], + readyPattern: RegExp, + timeoutMs: number, +): Promise { + const proc = spawn("npx", ["tsx", script, ...args], { + cwd: repoRoot, + detached: process.platform !== "win32", + env: { ...process.env, FORCE_COLOR: "0" }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let output = ""; + let settled = false; + + const append = (chunk: Buffer) => { + output += chunk.toString(); + }; + proc.stdout?.on("data", append); + proc.stderr?.on("data", append); + + try { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Timed out waiting for ${script}.\n${output.slice(-4000)}`)); + }, timeoutMs); + + const check = () => { + if (readyPattern.test(output)) { + clearTimeout(timeout); + settled = true; + resolve(); + } + }; + + proc.stdout?.on("data", check); + proc.stderr?.on("data", check); + proc.once("exit", (code, signal) => { + clearTimeout(timeout); + if (!settled) { + reject( + new Error( + `${script} exited before readiness (code=${code}, signal=${signal}).\n` + + output.slice(-4000), + ), + ); + } + }); + }); + } catch (e) { + await stopNodeHostDemo(proc); + throw e; + } + + return { proc, output: () => output }; +} + +async function stopNodeHostDemo(proc: ChildProcess): Promise { + if (!proc.pid || proc.exitCode !== null || proc.signalCode !== null) return; + + const waitForExit = new Promise((resolve) => { + proc.once("exit", () => resolve()); + }); + + signalProcess(proc, "SIGTERM"); + const exited = await Promise.race([ + waitForExit.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 5_000)), + ]); + + if (!exited) { + signalProcess(proc, "SIGKILL"); + await waitForExit.catch(() => {}); + } +} + +function signalProcess(proc: ChildProcess, signal: NodeJS.Signals): void { + if (!proc.pid) return; + try { + if (process.platform !== "win32") process.kill(-proc.pid, signal); + else proc.kill(signal); + } catch { + try { + proc.kill(signal); + } catch { + // Already exited. + } + } +} + +async function fetchText(url: string, timeoutMs = 30_000): Promise { + const resp = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + const text = await resp.text(); + expect(resp.status, text.slice(0, 1000)).toBeLessThan(500); + return text; +} + +test.describe.configure({ mode: "serial" }); + +test.describe("Node-host counterparts for Kandelo browser demos", () => { + test("shell command runner executes dash like the browser shell demo", () => { + test.setTimeout(60_000); + skipUnlessProgram("shell Node-host demo", "programs/dash.wasm"); + + const output = execFileSync( + "npx", + [ + "tsx", + "packages/registry/shell/demo/serve.ts", + "-c", + "echo KANDELO_NODE_HOST_SHELL_OK", + ], + { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 45_000, + }, + ); + + expect(output).toContain("KANDELO_NODE_HOST_SHELL_OK"); + }); + + test("nginx serves the same static page as the browser nginx demo", async () => { + test.setTimeout(180_000); + skipUnlessRunnable("nginx Node-host demo", serviceImages.nginx); + + const port = await getFreePort(); + const demo = await startNodeHostDemo( + "packages/registry/nginx/demo/serve.ts", + [String(port)], + /nginx running under dinit/i, + 120_000, + ); + + try { + const html = await fetchText(`http://127.0.0.1:${port}/`); + expect(html).toContain("Hello from nginx on WebAssembly!"); + expect(html).toContain("/sbin/dinit"); + } finally { + await stopNodeHostDemo(demo.proc); + } + }); + + test("nginx + PHP serves dynamic PHP like the browser nginx + PHP demo", async () => { + test.setTimeout(240_000); + skipUnlessRunnable("nginx + PHP Node-host demo", serviceImages.nginxPhp); + + const port = await getFreePort(); + const demo = await startNodeHostDemo( + "packages/registry/nginx/demo/serve-php.ts", + [String(port)], + /nginx \+ PHP-FPM running under dinit/i, + 180_000, + ); + + try { + const html = await fetchText(`http://127.0.0.1:${port}/info.php`); + expect(html).toContain("PHP-FPM on WebAssembly"); + expect(html).toMatch(/REQUEST_URI|SERVER_SOFTWARE|PHP/); + } finally { + await stopNodeHostDemo(demo.proc); + } + }); + + test("WordPress SQLite reaches the installer like the browser WordPress SQLite demo", async () => { + test.setTimeout(300_000); + skipUnlessRunnable("WordPress SQLite Node-host demo", serviceImages.wordpress); + + const port = await getFreePort(); + const demo = await startNodeHostDemo( + "packages/registry/wordpress/demo/serve.ts", + [String(port)], + /WordPress running behind nginx \+ php-fpm/i, + 180_000, + ); + + try { + const html = await fetchText(`http://127.0.0.1:${port}/`); + expect(html).toMatch(/WordPress|wp-admin|install/i); + } finally { + await stopNodeHostDemo(demo.proc); + } + }); + + test("WordPress MariaDB reaches the installer like the browser WordPress MariaDB demo", async () => { + test.setTimeout(420_000); + skipUnlessRunnable("WordPress MariaDB Node-host demo", serviceImages.lamp); + test.skip( + !(await isPortAvailable(3306)), + "WordPress MariaDB Node-host demo needs host port 3306", + ); + + const port = await getFreePort(); + const demo = await startNodeHostDemo( + "packages/registry/lamp/demo/serve.ts", + [String(port)], + /LAMP stack running under dinit/i, + 300_000, + ); + + try { + const html = await fetchText(`http://127.0.0.1:${port}/`); + expect(html).toMatch(/WordPress|wp-admin|install/i); + } finally { + await stopNodeHostDemo(demo.proc); + } + }); +}); diff --git a/docs/architecture.md b/docs/architecture.md index 42666a6308..4765103b54 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1842,6 +1842,13 @@ There are two consumption patterns for VFS images, depending on whether the demo Build scripts are in `images/vfs/scripts/` and share common helpers (`vfs-image-helpers.ts` for VFS write primitives, `dinit-image-helpers.ts` for the dinit binary + standard rootfs files + service-file rendering). To build all VFS images, use the per-demo scripts above or the convenience targets in `run.sh` (e.g., `./run.sh build python-vfs`). The repaired Python and Erlang recipes remain disabled legacy compatibility paths: staging does not publish them, and they are not Homebrew distribution units. +The Node counterparts for the service-supervised demos consume these same +images. They authenticate imported lazy-tree seals, apply transient runtime +configuration to a private restored image, give the resulting root filesystem +to `NodeKernelHost`, and start `/sbin/dinit` with `spawnFromVfs()`. They do not +reconstruct the browser service graph by launching loose package binaries from +the host filesystem. + **Binary format:** `MemoryFileSystem.saveImage()` returns the raw VFS image below. The image diff --git a/docs/browser-support.md b/docs/browser-support.md index 8ff147d532..f7c6a9fb31 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -73,7 +73,18 @@ Service Worker ──MessagePort──> Kernel Worker │ Modules created by an external embedder without registered bytes retain the native reflection fallback. - **Exec reads from filesystem**: Like a real OS, `exec()` reads binaries from the kernel-side `MemoryFileSystem`. Programs are baked into the VFS image at build time (or written by the page in the legacy path before spawning). Symlinks are used for multicall binaries (e.g., coreutils). -- **dinit for service supervision**: Multi-process demos (nginx, redis, mariadb, nginx-php, wordpress, lamp, mariadb-test) bake `/sbin/dinit` and per-service files under `/etc/dinit.d/` into the VFS image via `addDinitInit()` (`images/vfs/scripts/dinit-image-helpers.ts`). dinit is the first user process, not PID 1. It reaps its directly supervised children and handles `depends-on` ordering and bootstrap-then-daemon chains. Synthetic PID 1 has no wait loop, so Kandelo does not yet reap children reparented to it. Page code waits for service-ready via `onListenTcp` (port-bind) callbacks, then starts driving the demo over kernel-loopback TCP or the HTTP bridge. +- **dinit for service supervision**: Multi-process demos (nginx, redis, + mariadb, nginx-php, wordpress, lamp, mariadb-test) bake `/sbin/dinit` and + per-service files under `/etc/dinit.d/` into the VFS image via + `addDinitInit()` (`images/vfs/scripts/dinit-image-helpers.ts`). dinit is the + first user process, not PID 1. It reaps its directly supervised children and + handles `depends-on` ordering and bootstrap-then-daemon chains. Synthetic + PID 1 has no wait loop, so Kandelo does not yet reap children reparented to + it. Page code waits for service-ready via `onListenTcp` (port-bind) + callbacks, then starts driving the demo over kernel-loopback TCP or the HTTP + bridge. The corresponding Node demo commands resolve and authenticate the + same VFS artifacts, apply only per-run configuration such as a listen port, + and start image-owned dinit through `NodeKernelHost.spawnFromVfs()`. - **Connection pump in kernel worker**: HTTP↔TCP bridge runs inside the kernel worker with synchronous pipe I/O (direct Wasm export calls). Service worker transfers a MessagePort to the kernel worker for HTTP request delivery. - **App clients on main thread**: MySQL and Redis wire protocol clients stay on the main thread and use async pipe operations via the message protocol. - **Rust-owned advisory locks**: the browser host does not hold advisory-lock diff --git a/packages/registry/lamp/demo/run.sh b/packages/registry/lamp/demo/run.sh index 5dee7ae7e5..ecfb89225a 100755 --- a/packages/registry/lamp/demo/run.sh +++ b/packages/registry/lamp/demo/run.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # # Build (if needed) and run the full LAMP stack on kandelo. -# MariaDB + PHP-FPM + nginx + WordPress, all as Wasm processes. +# MariaDB + PHP-FPM + nginx + WordPress, supervised by dinit in a VFS image. # # Usage: # bash packages/registry/lamp/demo/run.sh [port] @@ -30,40 +30,15 @@ else echo "--- SDK tools: OK ---" fi -# Step 3: MariaDB -if ! "$REPO_ROOT/scripts/resolve-binary.sh" programs/mariadb/mariadbd.wasm >/dev/null 2>&1 \ - && [ ! -f "$REPO_ROOT/packages/registry/mariadb/mariadb-install/bin/mariadbd.wasm" ]; then - echo "--- Building MariaDB ---" - bash "$REPO_ROOT/packages/registry/mariadb/build-mariadb.sh" +# Step 3: LAMP service VFS image +if ! "$REPO_ROOT/scripts/resolve-binary.sh" programs/lamp.vfs.zst >/dev/null 2>&1; then + echo "--- Building LAMP VFS image ---" + bash "$REPO_ROOT/run.sh" build lamp-vfs else - echo "--- MariaDB: OK ---" + echo "--- LAMP VFS image: OK ---" fi -# Step 4: nginx -if ! "$REPO_ROOT/scripts/resolve-binary.sh" programs/nginx.wasm >/dev/null 2>&1; then - echo "--- Building nginx ---" - bash "$REPO_ROOT/packages/registry/nginx/build-nginx-local.sh" -else - echo "--- nginx.wasm: OK ---" -fi - -# Step 5: PHP-FPM (builds sqlite, zlib, openssl, libxml2 as needed) -if ! "$REPO_ROOT/scripts/resolve-binary.sh" programs/php/php-fpm.wasm >/dev/null 2>&1; then - echo "--- Building PHP-FPM + dependencies ---" - bash "$REPO_ROOT/packages/registry/php/build-php.sh" -else - echo "--- php-fpm.wasm: OK ---" -fi - -# Step 6: WordPress + wp-config.php for MySQL -if [ ! -f "$SCRIPT_DIR/wordpress/wp-settings.php" ]; then - echo "--- Setting up WordPress ---" - bash "$SCRIPT_DIR/setup.sh" -else - echo "--- WordPress: OK ---" -fi - -# Step 7: Host dependencies +# Step 4: Host dependencies if [ ! -d "$REPO_ROOT/node_modules" ]; then echo "--- Installing host dependencies ---" cd "$REPO_ROOT" && npm install && cd "$REPO_ROOT" diff --git a/packages/registry/lamp/demo/serve.ts b/packages/registry/lamp/demo/serve.ts index 8874845238..ee99a7fb54 100644 --- a/packages/registry/lamp/demo/serve.ts +++ b/packages/registry/lamp/demo/serve.ts @@ -1,231 +1,71 @@ /** - * serve.ts — Full LAMP stack on kandelo. + * serve.ts — Run the full LAMP service VFS on the Node host. * - * Runs MariaDB + PHP-FPM + nginx as separate Wasm processes in one kernel: - * - MariaDB (threads for signal handler + timer) - * - PHP-FPM (master + 6 worker processes) - * - nginx (master + 2 worker processes) - * - * All inter-process communication (FastCGI, MySQL protocol) flows through - * the kernel's cross-process loopback TCP. + * dinit starts MariaDB bootstrap, MariaDB, SMTP capture, PHP-FPM, and + * nginx from the baked /etc/dinit.d service tree. * * Usage: * npx tsx packages/registry/lamp/demo/serve.ts [port] - * - * Requires: - * 1. MariaDB: programs/mariadb/mariadbd.wasm - * 2. PHP-FPM: programs/php/php-fpm.wasm - * 3. nginx: programs/nginx.wasm - * 4. WordPress: packages/registry/lamp/demo/wordpress/ - * (download with: bash packages/registry/lamp/demo/setup.sh) */ -import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync } from "fs"; -import { resolve, dirname, join } from "path"; -import { NodeKernelHost } from "../../../../host/src/node-kernel-host"; -import { resolveBinary, tryResolveBinary } from "../../../../host/src/binary-resolver"; - -const scriptDir = dirname(new URL(import.meta.url).pathname); -const repoRoot = resolve(scriptDir, "../../../.."); - -// Binary paths -const mariadbInstall = resolve(repoRoot, "packages/registry/mariadb/mariadb-install"); -const mysqldPath = tryResolveBinary("programs/mariadb/mariadbd.wasm") - ?? resolve(mariadbInstall, "bin/mariadbd.wasm"); -const phpFpmWasmPath = resolveBinary("programs/php/php-fpm.wasm"); -const nginxWasmPath = resolveBinary("programs/nginx.wasm"); - -// WordPress and config paths -const wpDir = resolve(scriptDir, "wordpress"); -const confTemplate = resolve(scriptDir, "nginx.conf"); -const phpFpmConf = resolve(scriptDir, "php-fpm.conf"); -const routerScript = resolve(scriptDir, "fpm-router.php"); - -// MariaDB data directory -const dataDir = resolve(scriptDir, "data"); - -const port = parseInt(process.argv[2] || "8080", 10); - -// Validate prerequisites -for (const [name, path, hint] of [ - ["mariadbd.wasm", mysqldPath, "scripts/fetch-binaries.sh or bash packages/registry/mariadb/build-mariadb.sh"], - ["php-fpm.wasm", phpFpmWasmPath, "bash packages/registry/php/build-php.sh"], - ["nginx.wasm", nginxWasmPath, "bash packages/registry/nginx/build-nginx-local.sh"], -] as const) { - if (!existsSync(path)) { - console.error(`Error: ${name} not found. Run: ${hint}`); - process.exit(1); - } -} -if (!existsSync(join(wpDir, "wp-settings.php"))) { - console.error("Error: WordPress not found. Run: bash packages/registry/lamp/demo/setup.sh"); - process.exit(1); -} - -// Create directories -mkdirSync(resolve(dataDir, "mysql"), { recursive: true }); -mkdirSync(resolve(dataDir, "tmp"), { recursive: true }); -for (const dir of ["client_body_temp", "fastcgi_temp", "logs"]) { - mkdirSync(join("/tmp/nginx-wasm", dir), { recursive: true }); -} - -function loadBytes(path: string): ArrayBuffer { - const buf = readFileSync(path); - return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); -} - -function generateNginxConf(): string { - let conf = readFileSync(confTemplate, "utf-8"); - conf = conf.replace(/WORDPRESS_ROOT/g, wpDir); - conf = conf.replace(/ROUTER_SCRIPT/g, routerScript); - conf = conf.replace("listen 8080", `listen ${port}`); - const outPath = "/tmp/nginx-wasm/lamp-nginx.conf"; - writeFileSync(outPath, conf); - return outPath; -} +import { + bootDinitServiceVfs, + configureWordPressRuntime, + finishWhenDinitExits, + installSignalHandlers, + trackDinitExit, + waitForHttp, +} from "../../service-vfs-demo"; async function main() { - const mysqldBytes = loadBytes(mysqldPath); - const phpFpmBytes = loadBytes(phpFpmWasmPath); - const nginxBytes = loadBytes(nginxWasmPath); - - // Check if bootstrap is needed - const mysqlDir = resolve(dataDir, "mysql"); - const needsBootstrap = readdirSync(mysqlDir).length === 0; - - if (needsBootstrap) { - // ========================================================================= - // Bootstrap Phase: initialize MariaDB system tables - // ========================================================================= - console.log("Bootstrapping MariaDB system tables..."); - const shareDir = resolve(mariadbInstall, "share/mysql"); - const systemTables = readFileSync(resolve(shareDir, "mysql_system_tables.sql"), "utf-8"); - const systemData = readFileSync(resolve(shareDir, "mysql_system_tables_data.sql"), "utf-8"); - const bootstrapSql = `use mysql;\n${systemTables}\n${systemData}\n`; - - const bootstrapHost = new NodeKernelHost({ - maxWorkers: 12, - onStdout: (_pid, data) => process.stdout.write(new TextDecoder().decode(data)), - onStderr: (_pid, data) => process.stderr.write(new TextDecoder().decode(data)), - }); - await bootstrapHost.init(); - - const bootstrapExit = bootstrapHost.spawn(mysqldBytes, [ - "mariadbd", "--no-defaults", - `--datadir=${dataDir}`, `--tmpdir=${resolve(dataDir, "tmp")}`, - "--default-storage-engine=Aria", "--skip-grant-tables", - "--key-buffer-size=1048576", "--table-open-cache=10", - "--sort-buffer-size=262144", "--bootstrap", "--log-warnings=0", - ], { - env: ["HOME=/tmp", "PATH=/usr/local/bin:/usr/bin:/bin", "TMPDIR=/tmp"], - cwd: dataDir, - stdin: new TextEncoder().encode(bootstrapSql), - }); - - const bootstrapTimeout = new Promise((r) => setTimeout(() => { - console.log("Bootstrap complete (timeout). Proceeding..."); - r(0); - }, 30000)); - await Promise.race([bootstrapExit, bootstrapTimeout]); - await bootstrapHost.destroy().catch(() => {}); - - // Create WordPress database - console.log("Creating wordpress database..."); - const createDbHost = new NodeKernelHost({ - maxWorkers: 12, - onStdout: (_pid, data) => process.stdout.write(new TextDecoder().decode(data)), - onStderr: (_pid, data) => process.stderr.write(new TextDecoder().decode(data)), - }); - await createDbHost.init(); - - const createDbExit = createDbHost.spawn(mysqldBytes, [ - "mariadbd", "--no-defaults", - `--datadir=${dataDir}`, `--tmpdir=${resolve(dataDir, "tmp")}`, - "--default-storage-engine=Aria", "--skip-grant-tables", - "--key-buffer-size=1048576", "--table-open-cache=10", - "--sort-buffer-size=262144", "--bootstrap", "--log-warnings=0", - ], { - env: ["HOME=/tmp", "PATH=/usr/local/bin:/usr/bin:/bin", "TMPDIR=/tmp"], - cwd: dataDir, - stdin: new TextEncoder().encode("CREATE DATABASE IF NOT EXISTS wordpress;\n"), - }); - - const createDbTimeout = new Promise((r) => setTimeout(() => r(0), 15000)); - await Promise.race([createDbExit, createDbTimeout]); - await createDbHost.destroy().catch(() => {}); - console.log("Database bootstrap complete.\n"); - } - - // ========================================================================= - // Main Phase: start all services - // ========================================================================= - - const host = new NodeKernelHost({ + const port = parsePort(process.argv[2] ?? "8080"); + + console.log("Booting LAMP VFS with dinit..."); + const { host, exitPromise } = await bootDinitServiceVfs({ + image: { + relPath: "programs/lamp.vfs.zst", + publicFile: "lamp.vfs.zst", + buildHint: "./run.sh build lamp-vfs", + }, + target: "nginx", maxWorkers: 16, - onStdout: (_pid, data) => process.stdout.write(new TextDecoder().decode(data)), - onStderr: (_pid, data) => process.stderr.write(new TextDecoder().decode(data)), + maxPages: 4096, + configure: (fs) => configureWordPressRuntime(fs, { + port, + freshSqliteDatabase: false, + phpFpmWorkers: 6, + }), + env: [ + "HOME=/root", + "TERM=xterm-256color", + "PATH=/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin", + "WP_APP_PATH=/", + "WP_PROTO=http", + ], }); - await host.init(); - // Phase 1: MariaDB - console.log("Starting MariaDB on 127.0.0.1:3306..."); - host.spawn(mysqldBytes, [ - "mariadbd", "--no-defaults", - `--datadir=${dataDir}`, `--tmpdir=${resolve(dataDir, "tmp")}`, - "--default-storage-engine=Aria", "--skip-grant-tables", - "--key-buffer-size=1048576", "--table-open-cache=10", - "--sort-buffer-size=262144", - "--skip-networking=0", "--port=3306", - "--bind-address=0.0.0.0", "--socket=", - "--max-connections=10", - ], { - env: ["HOME=/tmp", "PATH=/usr/local/bin:/usr/bin:/bin", "TMPDIR=/tmp"], - cwd: dataDir, - }); - - // Wait for MariaDB to be ready - console.log("Waiting for MariaDB to initialize..."); - await new Promise((r) => setTimeout(r, 5000)); + installSignalHandlers(host); + const dinitExited = trackDinitExit(exitPromise); - // Phase 2: PHP-FPM - console.log("Starting PHP-FPM on 127.0.0.1:9000..."); - host.spawn(phpFpmBytes, [ - "php-fpm", "-R", "-y", phpFpmConf, "-c", "/dev/null", "--nodaemonize", - ], { - env: ["HOME=/tmp", "PATH=/usr/local/bin:/usr/bin:/bin"], - cwd: wpDir, - }); + console.log(`Waiting for LAMP stack on http://localhost:${port}/...`); + await waitForHttp(`http://localhost:${port}/`, 300_000, dinitExited); - // Wait for PHP-FPM to start listening - console.log("Waiting for PHP-FPM to initialize..."); - await new Promise((r) => setTimeout(r, 2000)); - - // Phase 3: nginx - const confPath = generateNginxConf(); - console.log(`Starting nginx on http://localhost:${port}/...`); - console.log(" nginx will fork 2 worker processes\n"); - host.spawn(nginxBytes, [ - "nginx", "-p", scriptDir + "/", "-c", confPath, - ], { - env: ["HOME=/tmp", "PATH=/usr/local/bin:/usr/bin:/bin"], - cwd: scriptDir, - }); - - // Ready - console.log("=== LAMP stack running on kandelo ==="); - console.log(` MariaDB: 127.0.0.1:3306`); - console.log(` PHP-FPM: 127.0.0.1:9000 (master + 6 workers)`); - console.log(` nginx: http://localhost:${port}/ (master + 2 workers)`); + console.log("\nLAMP stack running under dinit."); + console.log(" MariaDB: 127.0.0.1:3306"); + console.log(" PHP-FPM: 127.0.0.1:9000"); + console.log(` nginx: http://localhost:${port}/`); console.log(` WordPress: http://localhost:${port}/`); console.log("\nPress Ctrl+C to stop."); - process.on("SIGINT", async () => { - console.log("\nShutting down..."); - await host.destroy().catch(() => {}); - process.exit(0); - }); + await finishWhenDinitExits(host, exitPromise); +} - await new Promise(() => {}); +function parsePort(value: string): number { + const port = Number(value); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`Invalid port: ${value}`); + } + return port; } main().catch((e) => { diff --git a/packages/registry/mariadb/demo/run.sh b/packages/registry/mariadb/demo/run.sh index 369900c65d..95a429427e 100755 --- a/packages/registry/mariadb/demo/run.sh +++ b/packages/registry/mariadb/demo/run.sh @@ -3,7 +3,7 @@ # Build (if needed) and run MariaDB on kandelo. # # Usage: -# bash packages/registry/mariadb/demo/run.sh [port] +# bash packages/registry/mariadb/demo/run.sh [--innodb] [--wasm64] # set -euo pipefail @@ -29,13 +29,20 @@ else echo "--- SDK tools: OK ---" fi -# Step 3: MariaDB binary -if ! "$REPO_ROOT/scripts/resolve-binary.sh" programs/mariadb/mariadbd.wasm >/dev/null 2>&1 \ - && [ ! -f "$REPO_ROOT/packages/registry/mariadb/mariadb-install/bin/mariadbd.wasm" ]; then - echo "--- Building MariaDB ---" - bash "$REPO_ROOT/packages/registry/mariadb/build-mariadb.sh" +# Step 3: MariaDB service VFS image +if printf '%s\n' "$@" | grep -qx -- "--wasm64"; then + vfs_target="mariadb64-vfs" + vfs_rel="programs/wasm64/mariadb-vfs.vfs.zst" else - echo "--- MariaDB: OK ---" + vfs_target="mariadb-vfs" + vfs_rel="programs/mariadb-vfs.vfs.zst" +fi + +if ! "$REPO_ROOT/scripts/resolve-binary.sh" "$vfs_rel" >/dev/null 2>&1; then + echo "--- Building MariaDB VFS image ---" + bash "$REPO_ROOT/run.sh" build "$vfs_target" +else + echo "--- MariaDB VFS image: OK ---" fi # Step 4: Host dependencies diff --git a/packages/registry/mariadb/demo/serve.ts b/packages/registry/mariadb/demo/serve.ts index 14abf33c57..8292d572dd 100644 --- a/packages/registry/mariadb/demo/serve.ts +++ b/packages/registry/mariadb/demo/serve.ts @@ -1,167 +1,64 @@ /** - * serve.ts — Run MariaDB 10.5 (mysqld) on the kandelo. + * serve.ts — Run the MariaDB service VFS on the Node host. * - * Starts mysqld in single-thread mode with Aria (default) or InnoDB engine. - * The kernel's PlatformIO layer provides access to the host filesystem - * for data directory and temp files. + * The VFS image contains dinit plus both Aria and InnoDB service trees. + * dinit runs the selected bootstrap service and then starts mariadbd. * * Usage: * npx tsx packages/registry/mariadb/demo/serve.ts * npx tsx packages/registry/mariadb/demo/serve.ts --innodb + * npx tsx packages/registry/mariadb/demo/serve.ts --wasm64 * * Then: mysql -h 127.0.0.1 -P 3306 -u root */ -import { readFileSync, existsSync, mkdirSync, readdirSync } from "fs"; -import { resolve, dirname } from "path"; -import { NodeKernelHost } from "../../../../host/src/node-kernel-host"; -import { tryResolveBinary } from "../../../../host/src/binary-resolver"; - -const scriptDir = dirname(new URL(import.meta.url).pathname); -const repoRoot = resolve(scriptDir, "../../../.."); +import { + bootDinitServiceVfs, + finishWhenDinitExits, + installSignalHandlers, + trackDinitExit, + waitForTcp, +} from "../../service-vfs-demo"; const useWasm64 = process.argv.includes("--wasm64"); const useInnoDB = process.argv.includes("--innodb"); -const mariadbLibDir = resolve(repoRoot, "packages/registry/mariadb"); -const installDir = resolve(mariadbLibDir, useWasm64 ? "mariadb-install-64" : "mariadb-install"); - -// Create data directory on host (arch-specific so wasm32 and wasm64 don't -// share bootstrap state — the two builds may initialise system tables with -// slightly different layouts) -const dataDir = resolve(scriptDir, useWasm64 ? "data-64" : "data"); -mkdirSync(resolve(dataDir, "mysql"), { recursive: true }); -mkdirSync(resolve(dataDir, "tmp"), { recursive: true }); - -function loadBytes(path: string): ArrayBuffer { - const buf = readFileSync(path); - return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); -} +const unsupported = process.argv.find((arg) => arg === "--bootstrap" || arg === "--debug-help"); async function main() { - const resolverPath = useWasm64 - ? "programs/wasm64/mariadb/mariadbd.wasm" - : "programs/mariadb/mariadbd.wasm"; - const mysqldWasm = tryResolveBinary(resolverPath) - ?? resolve(installDir, "bin/mariadbd.wasm"); - if (!existsSync(mysqldWasm)) { - console.error("mariadbd.wasm not found. Run: scripts/fetch-binaries.sh or bash packages/registry/mariadb/build-mariadb.sh"); - process.exit(1); - } - - const mysqldBytes = loadBytes(mysqldWasm); - - // Check if data directory needs bootstrapping - const mysqlDir = resolve(dataDir, "mysql"); - const needsBootstrap = readdirSync(mysqlDir).length === 0; - - const debugMode = process.argv.includes("--debug-help"); - const bootstrapMode = needsBootstrap || process.argv.includes("--bootstrap"); - - const engine = useInnoDB ? "InnoDB" : "Aria"; - const commonArgs = [ - "mariadbd", - "--no-defaults", - `--datadir=${dataDir}`, - `--tmpdir=${resolve(dataDir, "tmp")}`, - `--default-storage-engine=${engine}`, - "--skip-grant-tables", - "--key-buffer-size=1048576", - "--table-open-cache=10", - "--sort-buffer-size=262144", - ...(useInnoDB ? [ - "--innodb-buffer-pool-size=8M", - "--innodb-log-file-size=4M", - "--innodb-log-buffer-size=1M", - "--innodb-flush-log-at-trx-commit=2", - "--innodb-buffer-pool-load-at-startup=OFF", - "--innodb-buffer-pool-dump-at-shutdown=OFF", - ] : []), - ]; - - const serverArgs = debugMode ? [ - "mariadbd", - "--no-defaults", - "--help", - "--verbose", - ] : bootstrapMode ? [ - ...commonArgs, - "--bootstrap", - "--log-warnings=0", - ] : [ - ...commonArgs, - "--skip-networking=0", - "--port=3306", - "--bind-address=0.0.0.0", - "--socket=", - "--max-connections=10", - ]; - - const host = new NodeKernelHost({ - maxWorkers: 8, - // InnoDB writes log files in 1MB chunks; increase data buffer from 64KB default - dataBufferSize: useInnoDB ? 2 * 1024 * 1024 : undefined, - onStdout: (_pid, data) => process.stdout.write(new TextDecoder().decode(data)), - onStderr: (_pid, data) => process.stderr.write(new TextDecoder().decode(data)), - }); - - await host.init(); - - // For bootstrap mode, prepare SQL data for stdin - let stdinData: Uint8Array | undefined; - if (bootstrapMode) { - console.log("Bootstrapping MariaDB system tables..."); - const shareDir = resolve(installDir, "share/mysql"); - const systemTables = readFileSync(resolve(shareDir, "mysql_system_tables.sql"), "utf-8"); - const systemData = readFileSync(resolve(shareDir, "mysql_system_tables_data.sql"), "utf-8"); - const bootstrapSql = `use mysql;\n${systemTables}\n${systemData}\n`; - stdinData = new TextEncoder().encode(bootstrapSql); - } else { - console.log("Starting MariaDB 10.5 on kandelo..."); - console.log(`Data directory: ${dataDir}`); - console.log("Connect with: mysql -h 127.0.0.1 -P 3306 -u root"); - } - console.log(""); - - const exitPromise = host.spawn(mysqldBytes, serverArgs, { - env: [ - "HOME=/tmp", - "PATH=/usr/local/bin:/usr/bin:/bin", - "TMPDIR=/tmp", - ], - cwd: dataDir, - stdin: stdinData, - }); - - process.on("SIGINT", async () => { - console.log("\nShutting down..."); - await host.destroy().catch(() => {}); - process.exit(0); - }); - - let status: number; - if (bootstrapMode) { - // Bootstrap mode: after stdin is consumed, mysqld should exit. - // Background threads may hang during shutdown — race with a timeout. - const timeoutPromise = new Promise((resolveP) => { - setTimeout(() => { - console.log("\nBootstrap appears complete (timeout). Forcing shutdown."); - resolveP(0); - }, 60000); - }); - status = await Promise.race([exitPromise, timeoutPromise]); - } else { - status = await exitPromise; - } - - await host.destroy().catch(() => {}); - - if (bootstrapMode && status === 0 && !process.argv.includes("--bootstrap")) { - console.log("\nBootstrap complete! Restart to run the server.\n"); - } - process.exit(status); + if (unsupported) { + throw new Error(`${unsupported} is not supported by the VFS-backed service demo; dinit runs bootstrap automatically.`); + } + + const target = useInnoDB ? "innodb-mariadb" : "aria-mariadb"; + console.log(`Booting MariaDB VFS with dinit (${useInnoDB ? "InnoDB" : "Aria"}, ${useWasm64 ? "wasm64" : "wasm32"})...`); + const { host, exitPromise } = await bootDinitServiceVfs({ + image: { + relPath: useWasm64 + ? "programs/wasm64/mariadb-vfs.vfs.zst" + : "programs/mariadb-vfs.vfs.zst", + publicFile: useWasm64 ? "mariadb-64.vfs.zst" : "mariadb.vfs.zst", + buildHint: useWasm64 + ? "./run.sh build mariadb64-vfs" + : "./run.sh build mariadb-vfs", + }, + target, + maxWorkers: 12, + }); + + installSignalHandlers(host); + const dinitExited = trackDinitExit(exitPromise); + + console.log("Waiting for MariaDB on 127.0.0.1:3306..."); + await waitForTcp(3306, 180_000, dinitExited); + + console.log("\nMariaDB running under dinit."); + console.log(" Connect with: mysql -h 127.0.0.1 -P 3306 -u root"); + console.log("\nPress Ctrl+C to stop."); + + await finishWhenDinitExits(host, exitPromise); } main().catch((e) => { - console.error(e); - process.exit(1); + console.error(e); + process.exit(1); }); diff --git a/packages/registry/nginx/demo/run-php.sh b/packages/registry/nginx/demo/run-php.sh index cb19406fb0..2746d05108 100755 --- a/packages/registry/nginx/demo/run-php.sh +++ b/packages/registry/nginx/demo/run-php.sh @@ -29,23 +29,15 @@ else echo "--- SDK tools: OK ---" fi -# Step 3: nginx binary -if ! "$REPO_ROOT/scripts/resolve-binary.sh" programs/nginx.wasm >/dev/null 2>&1; then - echo "--- Building nginx ---" - bash "$REPO_ROOT/packages/registry/nginx/build-nginx-local.sh" +# Step 3: nginx + PHP-FPM service VFS image +if ! "$REPO_ROOT/scripts/resolve-binary.sh" programs/nginx-php-vfs.vfs.zst >/dev/null 2>&1; then + echo "--- Building nginx + PHP-FPM VFS image ---" + bash "$REPO_ROOT/run.sh" build nginx-php-vfs else - echo "--- nginx.wasm: OK ---" + echo "--- nginx + PHP-FPM VFS image: OK ---" fi -# Step 4: PHP-FPM binary (builds sqlite, zlib, openssl, libxml2 as needed) -if ! "$REPO_ROOT/scripts/resolve-binary.sh" programs/php/php-fpm.wasm >/dev/null 2>&1; then - echo "--- Building PHP-FPM + dependencies ---" - bash "$REPO_ROOT/packages/registry/php/build-php.sh" -else - echo "--- php-fpm.wasm: OK ---" -fi - -# Step 5: Host dependencies +# Step 4: Host dependencies if [ ! -d "$REPO_ROOT/node_modules" ]; then echo "--- Installing host dependencies ---" cd "$REPO_ROOT" && npm install && cd "$REPO_ROOT" diff --git a/packages/registry/nginx/demo/run.sh b/packages/registry/nginx/demo/run.sh index d7a8114615..84b79515fe 100755 --- a/packages/registry/nginx/demo/run.sh +++ b/packages/registry/nginx/demo/run.sh @@ -29,12 +29,12 @@ else echo "--- SDK tools: OK ---" fi -# Step 3: nginx binary -if ! "$REPO_ROOT/scripts/resolve-binary.sh" programs/nginx.wasm >/dev/null 2>&1; then - echo "--- Building nginx ---" - bash "$REPO_ROOT/packages/registry/nginx/build-nginx-local.sh" +# Step 3: nginx service VFS image +if ! "$REPO_ROOT/scripts/resolve-binary.sh" programs/nginx-vfs.vfs.zst >/dev/null 2>&1; then + echo "--- Building nginx VFS image ---" + bash "$REPO_ROOT/run.sh" build nginx-vfs else - echo "--- nginx.wasm: OK ---" + echo "--- nginx VFS image: OK ---" fi # Step 4: Host dependencies diff --git a/packages/registry/nginx/demo/serve-php.ts b/packages/registry/nginx/demo/serve-php.ts index 2f03c993f6..bb03438799 100644 --- a/packages/registry/nginx/demo/serve-php.ts +++ b/packages/registry/nginx/demo/serve-php.ts @@ -1,107 +1,64 @@ /** - * serve-php.ts — Run nginx (multi-worker) + php-fpm on kandelo. + * serve-php.ts — Run the nginx + PHP-FPM service VFS on the Node host. * - * Starts multiple Wasm processes in the same kernel: - * - php-fpm master → forks 6 worker processes - * - nginx master → forks 2 worker processes - * - * The kernel's cross-process loopback routes nginx → php-fpm traffic - * through in-kernel pipes. + * dinit starts PHP-FPM first, then nginx through the dependency graph + * baked into /etc/dinit.d. * * Usage: - * npx tsx packages/registry/nginx/demo/serve-php.ts + * npx tsx packages/registry/nginx/demo/serve-php.ts [port] * * Then: curl http://localhost:8080/info.php */ -import { readFileSync, existsSync, mkdirSync } from "fs"; -import { resolve, dirname, join } from "path"; -import { NodeKernelHost } from "../../../../host/src/node-kernel-host"; -import { resolveBinary } from "../../../../host/src/binary-resolver"; - -const scriptDir = dirname(new URL(import.meta.url).pathname); -const prefix = resolve(scriptDir); - -// Create temp directories -for (const dir of ["client_body_temp", "fastcgi_temp"]) { - mkdirSync(join("/tmp/nginx-wasm", dir), { recursive: true }); -} -mkdirSync("/tmp/nginx-wasm/logs", { recursive: true }); - -function loadBytes(path: string): ArrayBuffer { - const buf = readFileSync(path); - return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); -} +import { + bootDinitServiceVfs, + finishWhenDinitExits, + installSignalHandlers, + removeServiceLogfiles, + rewriteNginxListenPort, + trackDinitExit, + waitForHttp, +} from "../../service-vfs-demo"; async function main() { - const phpFpmWasm = resolveBinary("programs/php/php-fpm.wasm"); - const nginxWasm = resolveBinary("programs/nginx.wasm"); - const confPath = resolve(scriptDir, "nginx.conf"); - const phpFpmConf = resolve(scriptDir, "php-fpm.conf"); - - for (const [name, path] of [["php-fpm.wasm", phpFpmWasm], ["nginx.wasm", nginxWasm]] as const) { - if (!existsSync(path)) { - console.error(`${name} not found. Run the appropriate build script first.`); - process.exit(1); - } - } - - const phpFpmBytes = loadBytes(phpFpmWasm); - const nginxBytes = loadBytes(nginxWasm); - - const host = new NodeKernelHost({ + const port = parsePort(process.argv[2] ?? "8080"); + + console.log("Booting nginx + PHP-FPM VFS with dinit..."); + const { host, exitPromise } = await bootDinitServiceVfs({ + image: { + relPath: "programs/nginx-php-vfs.vfs.zst", + publicFile: "nginx-php.vfs.zst", + buildHint: "./run.sh build nginx-php-vfs", + }, + target: "nginx", maxWorkers: 12, maxPages: 4096, - onStdout: (_pid, data) => process.stdout.write(data), - onStderr: (_pid, data) => process.stderr.write(data), - }); - - await host.init(); - - // --- php-fpm master + static worker pool --- - console.log("Starting php-fpm master on 127.0.0.1:9000..."); - const fpmExit = host.spawn(phpFpmBytes, [ - "php-fpm", - "-R", - "-y", phpFpmConf, - "-c", "/dev/null", - "--nodaemonize", - ], { - env: ["HOME=/tmp", "PATH=/usr/local/bin:/usr/bin:/bin"], - cwd: prefix, + configure: (fs) => { + rewriteNginxListenPort(fs, port); + removeServiceLogfiles(fs, ["php-fpm", "nginx"]); + }, }); - // Wait for php-fpm to start listening before starting nginx - console.log("Waiting for php-fpm to initialize..."); - await new Promise((r) => setTimeout(r, 2000)); + installSignalHandlers(host); + const dinitExited = trackDinitExit(exitPromise); - // --- nginx master --- - console.log("Starting nginx master on http://localhost:8080/..."); - console.log(" nginx will fork 2 worker processes"); - host.spawn(nginxBytes, [ - "nginx", - "-p", prefix + "/", - "-c", confPath, - ], { - env: ["HOME=/tmp", "PATH=/usr/local/bin:/usr/bin:/bin"], - cwd: prefix, - }); + console.log(`Waiting for nginx + PHP-FPM on http://localhost:${port}/...`); + await waitForHttp(`http://localhost:${port}/info.php`, 180_000, dinitExited); - console.log("\nnginx (multi-worker) + php-fpm running!"); - console.log(" Static files: curl http://localhost:8080/"); - console.log(" PHP: curl http://localhost:8080/info.php"); + console.log("\nnginx + PHP-FPM running under dinit."); + console.log(` Static files: curl http://localhost:${port}/`); + console.log(` PHP: curl http://localhost:${port}/info.php`); console.log("\nPress Ctrl+C to stop."); - // Handle Ctrl+C - process.on("SIGINT", async () => { - console.log("\nShutting down..."); - await host.destroy().catch(() => {}); - process.exit(0); - }); + await finishWhenDinitExits(host, exitPromise); +} - // Wait for php-fpm to exit (it shouldn't normally) - await fpmExit; - await host.destroy().catch(() => {}); +function parsePort(value: string): number { + const port = Number(value); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`Invalid port: ${value}`); + } + return port; } main().catch((e) => { diff --git a/packages/registry/nginx/demo/serve.ts b/packages/registry/nginx/demo/serve.ts index 82c5572bf2..06c8befc12 100644 --- a/packages/registry/nginx/demo/serve.ts +++ b/packages/registry/nginx/demo/serve.ts @@ -1,92 +1,62 @@ /** - * serve.ts — Run nginx.wasm serving static files on the kernel. + * serve.ts — Run the nginx service VFS on the Node host. * - * Starts nginx with master_process on and 2 worker processes. - * The kernel-assigned master forks 2 workers that handle connections. - * - * Uses NodeKernelHost which runs the kernel in a dedicated worker_thread - * for optimal syscall throughput. TCP bridging is automatic. + * The image boots dinit as the first user process, and dinit starts nginx from + * /etc/dinit.d/nginx. This mirrors the browser demo instead of staging + * nginx manually on the Node host filesystem. * * Usage: - * npx tsx packages/registry/nginx/demo/serve.ts + * npx tsx packages/registry/nginx/demo/serve.ts [port] * * Then: curl http://localhost:8080/ */ -import { readFileSync, mkdirSync } from "fs"; -import { resolve, dirname, join } from "path"; -import { NodeKernelHost } from "../../../../host/src/node-kernel-host"; -import { tryResolveBinary } from "../../../../host/src/binary-resolver"; - -const scriptDir = dirname(new URL(import.meta.url).pathname); -const repoRoot = resolve(scriptDir, "../../../.."); - -// Set up filesystem layout for nginx -const prefix = resolve(scriptDir); -const tmpDir = "/tmp/nginx-wasm"; - -// Create temp directories nginx needs -for (const dir of ["client_body_temp", "proxy_temp", "fastcgi_temp"]) { - mkdirSync(join(tmpDir, dir), { recursive: true }); -} -mkdirSync(join(tmpDir, "logs"), { recursive: true }); - -function loadBytes(path: string): ArrayBuffer { - const buf = readFileSync(path); - return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); -} +import { + bootDinitServiceVfs, + finishWhenDinitExits, + installSignalHandlers, + rewriteNginxListenPort, + trackDinitExit, + waitForHttp, +} from "../../service-vfs-demo"; async function main() { - const nginxWasm = tryResolveBinary("programs/nginx.wasm"); - if (!nginxWasm) { - console.error( - "nginx.wasm not found. Run: scripts/fetch-binaries.sh " + - "(or bash packages/registry/nginx/build-nginx-local.sh to build locally).", - ); - process.exit(1); - } - - const nginxBytes = loadBytes(nginxWasm); - const confPath = resolve(scriptDir, "nginx.conf"); - - const host = new NodeKernelHost({ - maxWorkers: 8, - onStdout: (_pid, data) => process.stdout.write(data), - onStderr: (_pid, data) => process.stderr.write(data), - }); - - await host.init(); - - console.log(`Starting nginx multi-worker (prefix=${prefix})...`); - console.log(" master + 2 worker processes (kernel-assigned PIDs)"); - console.log("Listening on http://localhost:8080/"); - console.log("Press Ctrl+C to stop."); - - const exitPromise = host.spawn(nginxBytes, [ - "nginx", - "-p", prefix + "/", - "-c", confPath, - ], { - env: [ - "HOME=/tmp", - "PATH=/usr/local/bin:/usr/bin:/bin", - ], - cwd: prefix, - }); - - // Handle Ctrl+C gracefully - process.on("SIGINT", async () => { - console.log("\nShutting down..."); - await host.destroy().catch(() => {}); - process.exit(0); - }); + const port = parsePort(process.argv[2] ?? "8080"); + + console.log("Booting nginx VFS with dinit..."); + const { host, exitPromise } = await bootDinitServiceVfs({ + image: { + relPath: "programs/nginx-vfs.vfs.zst", + publicFile: "nginx.vfs.zst", + buildHint: "./run.sh build nginx-vfs", + }, + target: "nginx", + maxWorkers: 8, + configure: (fs) => rewriteNginxListenPort(fs, port), + }); + + installSignalHandlers(host); + const dinitExited = trackDinitExit(exitPromise); + + console.log(`Waiting for nginx on http://localhost:${port}/...`); + await waitForHttp(`http://localhost:${port}/`, 120_000, dinitExited); + + console.log("\nnginx running under dinit."); + console.log(` Static files: curl http://localhost:${port}/`); + console.log("\nPress Ctrl+C to stop."); + + await finishWhenDinitExits(host, exitPromise); +} - const status = await exitPromise; - await host.destroy().catch(() => {}); - process.exit(status); +function parsePort(value: string): number { + const port = Number(value); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`Invalid port: ${value}`); + } + return port; } main().catch((e) => { - console.error(e); - process.exit(1); + console.error(e); + process.exit(1); }); diff --git a/packages/registry/redis/demo/serve.ts b/packages/registry/redis/demo/serve.ts index d28df00dce..05cb576f8f 100644 --- a/packages/registry/redis/demo/serve.ts +++ b/packages/registry/redis/demo/serve.ts @@ -1,9 +1,8 @@ /** - * serve.ts — Run redis-server.wasm on the kandelo. + * serve.ts — Run the Redis service VFS on the Node host. * - * Starts Redis 7.2 with 3 background threads (close_file, aof_fsync, lazy_free). - * The kernel automatically bridges real TCP connections into the - * kernel's pipe-backed sockets when redis calls listen(). + * dinit starts redis-server from /etc/dinit.d/redis, matching the + * browser service demo. * * Usage: * npx tsx packages/registry/redis/demo/serve.ts [port] @@ -11,79 +10,57 @@ * Then: redis-cli -p 6379 SET hello world */ -import { readFileSync, existsSync, mkdirSync } from "fs"; -import { resolve, dirname } from "path"; -import { NodeKernelHost } from "../../../../host/src/node-kernel-host"; - -const scriptDir = dirname(new URL(import.meta.url).pathname); -const repoRoot = resolve(scriptDir, "../../../.."); - -function loadBytes(path: string): ArrayBuffer { - const buf = readFileSync(path); - return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); -} +import { + bootDinitServiceVfs, + finishWhenDinitExits, + installSignalHandlers, + rewriteDinitServiceCommand, + trackDinitExit, + waitForTcp, +} from "../../service-vfs-demo"; async function main() { - const port = process.argv[2] || "6379"; - - const redisWasm = resolve(repoRoot, "packages/registry/redis/bin/redis-server.wasm"); - if (!existsSync(redisWasm)) { - console.error("redis-server.wasm not found. Run: bash packages/registry/redis/build-redis.sh"); - process.exit(1); - } + const port = parsePort(process.argv[2] ?? "6379"); - const redisBytes = loadBytes(redisWasm); + console.log("Booting Redis VFS with dinit..."); + const { host, exitPromise } = await bootDinitServiceVfs({ + image: { + relPath: "programs/redis-vfs.vfs.zst", + publicFile: "redis.vfs.zst", + buildHint: "./run.sh build redis-vfs", + }, + target: "redis", + maxWorkers: 8, + configure: (fs) => { + rewriteDinitServiceCommand(fs, "redis", (command) => + command.replace(/--port\s+\d+/, `--port ${port}`), + ); + }, + }); - // Create data directory for Redis persistence - const dataDir = resolve(scriptDir, "data"); - mkdirSync(dataDir, { recursive: true }); + installSignalHandlers(host); + const dinitExited = trackDinitExit(exitPromise); - const host = new NodeKernelHost({ - maxWorkers: 8, - onStdout: (_pid, data) => process.stdout.write(data), - onStderr: (_pid, data) => process.stderr.write(data), - }); + console.log(`Waiting for Redis on 127.0.0.1:${port}...`); + await waitForTcp(port, 120_000, dinitExited); - await host.init(); + console.log("\nRedis running under dinit."); + console.log(` redis-cli -p ${port} SET hello world`); + console.log(` redis-cli -p ${port} GET hello`); + console.log("\nPress Ctrl+C to stop."); - console.log(`Starting Redis 7.2 on port ${port}...`); - console.log(`Data directory: ${dataDir}`); - console.log(` redis-cli -p ${port} SET hello world`); - console.log(` redis-cli -p ${port} GET hello`); - console.log("Press Ctrl+C to stop.\n"); - - const exitPromise = host.spawn(redisBytes, [ - "redis-server", - "--port", port, - "--bind", "0.0.0.0", - "--dir", dataDir, - "--save", "", // Disable RDB snapshots - "--appendonly", "no", // Disable AOF - "--loglevel", "notice", - "--daemonize", "no", - "--databases", "16", - "--io-threads", "1", // Single I/O thread - ], { - env: [ - "HOME=/tmp", - "PATH=/usr/local/bin:/usr/bin:/bin", - ], - cwd: dataDir, - }); - - // Handle Ctrl+C gracefully - process.on("SIGINT", async () => { - console.log("\nShutting down Redis..."); - await host.destroy().catch(() => {}); - process.exit(0); - }); + await finishWhenDinitExits(host, exitPromise); +} - const status = await exitPromise; - await host.destroy().catch(() => {}); - process.exit(status); +function parsePort(value: string): number { + const port = Number(value); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`Invalid port: ${value}`); + } + return port; } main().catch((e) => { - console.error(e); - process.exit(1); + console.error(e); + process.exit(1); }); diff --git a/packages/registry/service-vfs-demo.ts b/packages/registry/service-vfs-demo.ts new file mode 100644 index 0000000000..2433d55448 --- /dev/null +++ b/packages/registry/service-vfs-demo.ts @@ -0,0 +1,273 @@ +import { existsSync, readFileSync } from "node:fs"; +import { Socket } from "node:net"; +import { join } from "node:path"; +import { NodeKernelHost } from "../../host/src/node-kernel-host"; +import { findRepoRoot, tryResolveBinary } from "../../host/src/binary-resolver"; +import type { MemoryFileSystem } from "../../host/src/vfs/memory-fs"; +import { ensureDirRecursive, writeVfsFile } from "../../host/src/vfs/image-helpers"; +import { restoreVerifiedVfsImage } from "../../host/src/vfs/load-image"; + +export const SERVICE_DEMO_ENV = [ + "HOME=/root", + "TERM=xterm-256color", + "PATH=/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin", + "TMPDIR=/tmp", +]; + +export interface ServiceVfsImageRef { + /** Resolver path, for example `programs/nginx-vfs.vfs.zst`. */ + relPath: string; + /** Fallback filename under apps/browser-demos/public for local builds. */ + publicFile: string; + /** Human-readable build command included in errors. */ + buildHint: string; +} + +export interface BootDinitServiceOptions { + image: ServiceVfsImageRef; + target?: string; + maxWorkers?: number; + maxPages?: number; + configure?: (fs: MemoryFileSystem) => void | Promise; + env?: string[]; + cwd?: string; +} + +export interface BootedDinitService { + host: NodeKernelHost; + exitPromise: Promise; +} + +export async function bootDinitServiceVfs(options: BootDinitServiceOptions): Promise { + const imagePath = resolveServiceVfsImage(options.image); + const image = readFileSync(imagePath); + // WHY: configuration rewrites are host-side effects derived from imported + // image state, so reject forged lazy-tree seals before reading or writing it. + const fs = await restoreVerifiedVfsImage(image, { + maxByteLength: 1024 * 1024 * 1024, + }); + await options.configure?.(fs); + + const rootfsImage = await fs.saveImage(); + + const host = new NodeKernelHost({ + maxWorkers: options.maxWorkers ?? 12, + maxPages: options.maxPages, + rootfsImage, + onStdout: (_pid, data) => process.stdout.write(data), + onStderr: (_pid, data) => process.stderr.write(data), + }); + await host.init(); + + const argv = ["/sbin/dinit", "--container", "-p", "/tmp/dinitctl"]; + if (options.target) argv.push(options.target); + + // WHY: dinit belongs to the imported image. Resolve it through the + // worker-owned VFS so the Node demo exercises the same executable lookup + // path as every service dinit starts, without a second host-side byte copy. + const { exit: exitPromise } = await host.spawnFromVfs("/sbin/dinit", argv, { + env: options.env ?? SERVICE_DEMO_ENV, + cwd: options.cwd ?? "/", + }); + + return { host, exitPromise }; +} + +export function resolveServiceVfsImage(image: ServiceVfsImageRef): string { + const resolved = tryResolveBinary(image.relPath); + if (resolved) return resolved; + + const publicPath = join(findRepoRoot(), "apps", "browser-demos", "public", image.publicFile); + if (existsSync(publicPath)) return publicPath; + + throw new Error( + `Service VFS image not found: ${image.relPath}\n` + + ` checked resolver/local-binaries path for ${image.relPath}\n` + + ` checked: ${publicPath}\n` + + ` Build it with: ${image.buildHint}`, + ); +} + +export function readVfsBytes(fs: MemoryFileSystem, path: string): ArrayBuffer { + const stat = fs.stat(path); + const fd = fs.open(path, 0, 0); + try { + const out = new Uint8Array(stat.size); + let offset = 0; + while (offset < out.byteLength) { + const n = fs.read(fd, out.subarray(offset), null, out.byteLength - offset); + if (n <= 0) break; + offset += n; + } + return out.buffer.slice(out.byteOffset, out.byteOffset + offset); + } finally { + fs.close(fd); + } +} + +export function readVfsText(fs: MemoryFileSystem, path: string): string { + return new TextDecoder().decode(readVfsBytes(fs, path)); +} + +export function rewriteNginxListenPort(fs: MemoryFileSystem, port: number): void { + const path = "/etc/nginx/nginx.conf"; + const conf = readVfsText(fs, path); + const updated = conf.replace(/listen\s+8080\b/g, `listen ${port}`); + writeVfsFile(fs, path, updated); +} + +export function rewriteDinitServiceCommand( + fs: MemoryFileSystem, + service: string, + rewrite: (command: string) => string, +): void { + const path = `/etc/dinit.d/${service}`; + const conf = readVfsText(fs, path); + const updated = conf.replace(/^command\s*=\s*(.*)$/m, (_line, command: string) => { + return `command = ${rewrite(command)}`; + }); + writeVfsFile(fs, path, updated); +} + +export function removeServiceLogfiles(fs: MemoryFileSystem, services: string[]): void { + for (const service of services) { + const path = `/etc/dinit.d/${service}`; + try { + const conf = readVfsText(fs, path).replace(/^logfile\s*=.*\n/gm, ""); + writeVfsFile(fs, path, conf); + } catch { + // Some service images do not include every optional service. + } + } +} + +export function configureWordPressRuntime( + fs: MemoryFileSystem, + options: { port: number; freshSqliteDatabase?: boolean; phpFpmWorkers?: number }, +): void { + rewriteNginxListenPort(fs, options.port); + + try { + const phpFpmConf = readVfsText(fs, "/etc/php-fpm.conf") + .replace(/pm\.max_children\s*=\s*\d+/, `pm.max_children = ${options.phpFpmWorkers ?? 6}`); + writeVfsFile(fs, "/etc/php-fpm.conf", phpFpmConf); + } catch { + // Not every service image has PHP-FPM. + } + + try { + const wpConfig = readVfsText(fs, "/etc/wp-config-template.php") + .replaceAll("@@APP_PATH@@", "/") + .replaceAll("@@PROTO@@", "http"); + writeVfsFile(fs, "/var/www/html/wp-config.php", wpConfig); + } catch { + // Not every web service image has WordPress. + } + + try { + writeVfsFile( + fs, + "/etc/wp-config-init.sh", + "echo \"wp-config-init: APP_PATH=${WP_APP_PATH:-/} PROTO=${WP_PROTO:-http}\"\n", + ); + } catch { + // Not every web service image has wp-config-init. + } + + if (options.freshSqliteDatabase) { + try { + fs.unlink("/var/www/html/wp-content/database/wordpress.db"); + } catch { + // Fresh release images do not contain an installed database. + } + } + + ensureDirRecursive(fs, "/var/cache/opcache"); + removeServiceLogfiles(fs, [ + "wp-config-init", + "smtp-capture", + "mariadb-bootstrap", + "mariadb", + "php-fpm", + "nginx", + ]); +} + +export async function waitForHttp(url: string, timeoutMs: number, shouldAbort?: () => boolean): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (shouldAbort?.()) throw new Error("service exited before HTTP readiness"); + try { + const resp = await fetch(url, { signal: AbortSignal.timeout(5_000) }); + await resp.body?.cancel(); + return; + } catch { + await sleep(250); + } + } + throw new Error(`Timed out waiting for HTTP readiness: ${url}`); +} + +export async function waitForTcp(port: number, timeoutMs: number, shouldAbort?: () => boolean): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (shouldAbort?.()) throw new Error("service exited before TCP readiness"); + try { + await connectOnce(port, Math.min(2_000, Math.max(250, deadline - Date.now()))); + return; + } catch { + await sleep(250); + } + } + throw new Error(`Timed out waiting for TCP readiness on 127.0.0.1:${port}`); +} + +export function installSignalHandlers(host: NodeKernelHost): void { + process.on("SIGINT", async () => { + console.log("\nShutting down..."); + await host.destroy().catch(() => {}); + process.exit(0); + }); +} + +export function trackDinitExit(exitPromise: Promise): () => boolean { + let exited = false; + exitPromise.then( + (code) => { + exited = true; + console.error(`dinit exited with code ${code}`); + }, + () => { + exited = true; + }, + ); + return () => exited; +} + +export async function finishWhenDinitExits(host: NodeKernelHost, exitPromise: Promise): Promise { + const status = await exitPromise; + await host.destroy().catch(() => {}); + process.exit(status); +} + +function connectOnce(port: number, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const socket = new Socket(); + let settled = false; + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + socket.destroy(); + fn(); + }; + socket.setTimeout(timeoutMs); + socket.once("connect", () => finish(resolve)); + socket.once("timeout", () => finish(() => reject(new Error("timeout")))); + socket.once("error", (err) => finish(() => reject(err))); + socket.connect(port, "127.0.0.1"); + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/registry/wordpress/demo/run-nginx.sh b/packages/registry/wordpress/demo/run-nginx.sh index 705844b93e..791fa279d3 100755 --- a/packages/registry/wordpress/demo/run-nginx.sh +++ b/packages/registry/wordpress/demo/run-nginx.sh @@ -33,7 +33,7 @@ fi # Step 3: WordPress VFS image if ! "$REPO_ROOT/scripts/resolve-binary.sh" programs/wordpress.vfs.zst >/dev/null 2>&1; then echo "--- Building WordPress VFS image ---" - bash "$REPO_ROOT/packages/registry/wordpress/build-wordpress.sh" + bash "$REPO_ROOT/run.sh" build wp-vfs else echo "--- WordPress VFS image: OK ---" fi diff --git a/packages/registry/wordpress/demo/run.sh b/packages/registry/wordpress/demo/run.sh index a7120e5f8e..4e79f2dcfb 100755 --- a/packages/registry/wordpress/demo/run.sh +++ b/packages/registry/wordpress/demo/run.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # # Build (if needed) and run WordPress on kandelo. -# Uses PHP's built-in web server + SQLite for storage. +# Uses the WordPress service VFS (dinit + nginx + PHP-FPM + SQLite). # # Usage: # bash packages/registry/wordpress/demo/run.sh [port] @@ -30,24 +30,15 @@ else echo "--- SDK tools: OK ---" fi -# Step 3: PHP CLI binary (builds sqlite, zlib as needed) -PHP_BINARY="$REPO_ROOT/packages/registry/php/php-src/sapi/cli/php" -if [ ! -f "$PHP_BINARY" ]; then - echo "--- Building PHP CLI + dependencies ---" - bash "$REPO_ROOT/packages/registry/php/build-php.sh" +# Step 3: WordPress service VFS image +if ! "$REPO_ROOT/scripts/resolve-binary.sh" programs/wordpress.vfs.zst >/dev/null 2>&1; then + echo "--- Building WordPress VFS image ---" + bash "$REPO_ROOT/run.sh" build wp-vfs else - echo "--- PHP CLI: OK ---" + echo "--- WordPress VFS image: OK ---" fi -# Step 4: WordPress + SQLite plugin -if [ ! -f "$SCRIPT_DIR/../wordpress/wp-settings.php" ]; then - echo "--- Downloading WordPress ---" - bash "$SCRIPT_DIR/../setup.sh" -else - echo "--- WordPress: OK ---" -fi - -# Step 5: Host dependencies +# Step 4: Host dependencies if [ ! -d "$REPO_ROOT/node_modules" ]; then echo "--- Installing host dependencies ---" cd "$REPO_ROOT" && npm install && cd "$REPO_ROOT" diff --git a/packages/registry/wordpress/demo/serve-nginx.ts b/packages/registry/wordpress/demo/serve-nginx.ts index 51566432dd..d254376bd1 100644 --- a/packages/registry/wordpress/demo/serve-nginx.ts +++ b/packages/registry/wordpress/demo/serve-nginx.ts @@ -1,64 +1,41 @@ /** - * serve-nginx.ts — WordPress behind nginx + PHP-FPM on kandelo. + * serve-nginx.ts — WordPress behind nginx + PHP-FPM on the Node host. * * Boots the same fully virtualized dinit/nginx/PHP-FPM/WordPress VFS image - * used by the browser demo. dinit starts: - * - wp-config-init - * - php-fpm master → static worker pool - * - nginx master → 2 workers - * - * nginx handles HTTP connections (multi-worker) and proxies all requests - * to PHP-FPM via FastCGI over the kernel's loopback TCP. + * used by the browser demo. dinit starts wp-config-init, SMTP capture, + * PHP-FPM, and nginx from /etc/dinit.d. * * Usage: * npx tsx packages/registry/wordpress/demo/serve-nginx.ts [port] - * - * Requires: - * 1. WordPress VFS image: programs/wordpress.vfs.zst - * (build with: bash images/vfs/scripts/build-wp-vfs-image.sh) - * 2. dinit binary: programs/dinit/dinit.wasm */ -import { readFileSync } from "fs"; -import { NodeKernelHost } from "../../../../host/src/node-kernel-host"; -import { resolveBinary } from "../../../../host/src/binary-resolver"; -import { MemoryFileSystem } from "../../../../host/src/vfs/memory-fs"; -import { ensureDirRecursive, writeVfsFile } from "../../../../host/src/vfs/image-helpers"; -import { restoreVerifiedVfsImage } from "../../../../host/src/vfs/load-image"; - -const wordpressVfsPath = resolveBinary("programs/wordpress.vfs.zst"); -const dinitWasmPath = resolveBinary("programs/dinit/dinit.wasm"); -const PHP_FPM_WORKERS = 6; - -const port = parseInt(process.argv[2] || "8080", 10); - -function loadBytes(path: string): ArrayBuffer { - const buf = readFileSync(path); - return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); -} +import { + bootDinitServiceVfs, + configureWordPressRuntime, + finishWhenDinitExits, + installSignalHandlers, + trackDinitExit, + waitForHttp, +} from "../../service-vfs-demo"; async function main() { - const wordpressVfs = await configureWordPressVfs(loadBytes(wordpressVfsPath)); - const dinitBytes = loadBytes(dinitWasmPath); + const port = parsePort(process.argv[2] ?? "8080"); - const host = new NodeKernelHost({ + console.log("Booting WordPress VFS with dinit..."); + const { host, exitPromise } = await bootDinitServiceVfs({ + image: { + relPath: "programs/wordpress.vfs.zst", + publicFile: "wordpress.vfs.zst", + buildHint: "./run.sh build wp-vfs", + }, + target: "nginx", maxWorkers: 12, maxPages: 4096, - rootfsImage: wordpressVfs, - onStdout: (_pid, data) => process.stdout.write(data), - onStderr: (_pid, data) => process.stderr.write(data), - }); - - await host.init(); - - console.log("Booting WordPress VFS with dinit..."); - const dinitExit = host.spawn(dinitBytes, [ - "/sbin/dinit", - "--container", - "-p", - "/tmp/dinitctl", - "nginx", - ], { + configure: (fs) => configureWordPressRuntime(fs, { + port, + freshSqliteDatabase: true, + phpFpmWorkers: 6, + }), env: [ "HOME=/root", "TERM=xterm-256color", @@ -66,110 +43,28 @@ async function main() { "WP_APP_PATH=/", "WP_PROTO=http", ], - cwd: "/", }); - let dinitExited = false; - dinitExit.then((code) => { - dinitExited = true; - console.error(`dinit exited with code ${code}`); - }).catch(() => { - dinitExited = true; - }); + installSignalHandlers(host); + const dinitExited = trackDinitExit(exitPromise); console.log(`Waiting for nginx on http://localhost:${port}/...`); - await waitForHttp(`http://localhost:${port}/`, 180_000, () => dinitExited); + await waitForHttp(`http://localhost:${port}/`, 180_000, dinitExited); console.log("\nWordPress running behind nginx + php-fpm!"); console.log(` Homepage: curl http://localhost:${port}/`); console.log(` Admin: http://localhost:${port}/wp-admin/`); console.log("\nPress Ctrl+C to stop."); - process.on("SIGINT", async () => { - console.log("\nShutting down..."); - await host.destroy().catch(() => {}); - process.exit(0); - }); - - await dinitExit; - await host.destroy().catch(() => {}); -} - -async function configureWordPressVfs(image: ArrayBuffer): Promise { - // WHY: configuration rewrites are host-side effects derived from imported - // image state, so reject forged lazy-tree seals before reading or writing it. - const fs = await restoreVerifiedVfsImage(new Uint8Array(image), { - maxByteLength: 1024 * 1024 * 1024, - }); - const nginxConf = readVfsText(fs, "/etc/nginx/nginx.conf") - .replace(/listen\s+8080;/, `listen ${port};`); - writeVfsFile(fs, "/etc/nginx/nginx.conf", nginxConf); - const phpFpmConf = readVfsText(fs, "/etc/php-fpm.conf") - .replace(/pm\.max_children\s*=\s*\d+/, `pm.max_children = ${PHP_FPM_WORKERS}`); - writeVfsFile(fs, "/etc/php-fpm.conf", phpFpmConf); - ensureDirRecursive(fs, "/var/cache/opcache"); - writeVfsFile( - fs, - "/var/www/html/wp-config.php", - readVfsText(fs, "/etc/wp-config-template.php") - .replaceAll("@@APP_PATH@@", "/") - .replaceAll("@@PROTO@@", "http"), - ); - writeVfsFile( - fs, - "/etc/wp-config-init.sh", - "echo \"wp-config-init: APP_PATH=${WP_APP_PATH:-/} PROTO=${WP_PROTO:-http}\"\n", - ); - for (const service of ["wp-config-init", "php-fpm", "nginx"]) { - const path = `/etc/dinit.d/${service}`; - const conf = readVfsText(fs, path).replace(/^logfile\s*=.*\n/gm, ""); - writeVfsFile(fs, path, conf); - } - try { - fs.unlink("/var/www/html/wp-content/database/wordpress.db"); - } catch { - // Fresh release images do not contain an installed database. - } - const saved = await fs.saveImage(); - return saved.buffer.slice(saved.byteOffset, saved.byteOffset + saved.byteLength); -} - -function readVfsText(fs: MemoryFileSystem, path: string): string { - const st = fs.stat(path); - const fd = fs.open(path, 0, 0); - try { - const bytes = new Uint8Array(st.size); - let offset = 0; - while (offset < bytes.byteLength) { - const n = fs.read(fd, bytes.subarray(offset), null, bytes.byteLength - offset); - if (n <= 0) break; - offset += n; - } - return new TextDecoder().decode(bytes.subarray(0, offset)); - } finally { - fs.close(fd); - } + await finishWhenDinitExits(host, exitPromise); } -async function waitForHttp( - url: string, - timeoutMs: number, - didExit: () => boolean, -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (didExit()) { - throw new Error("dinit exited before nginx responded to HTTP"); - } - try { - const resp = await fetch(url, { signal: AbortSignal.timeout(5000) }); - await resp.body?.cancel(); - return; - } catch { - await new Promise((r) => setTimeout(r, 500)); - } +function parsePort(value: string): number { + const port = Number(value); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`Invalid port: ${value}`); } - throw new Error(`nginx did not respond to HTTP within ${timeoutMs}ms`); + return port; } main().catch((e) => { diff --git a/packages/registry/wordpress/demo/serve.ts b/packages/registry/wordpress/demo/serve.ts index 216b0fc1ed..f0075d87bc 100644 --- a/packages/registry/wordpress/demo/serve.ts +++ b/packages/registry/wordpress/demo/serve.ts @@ -1,101 +1,71 @@ #!/usr/bin/env node --experimental-strip-types /** - * WordPress HTTP Server — serves WordPress via PHP's built-in server - * running on kandelo with real TCP connections bridged in. + * WordPress service demo — boots the WordPress VFS on the Node host. * - * Usage: - * node --experimental-strip-types packages/registry/wordpress/demo/serve.ts [port] + * dinit starts wp-config-init, SMTP capture, PHP-FPM, and nginx from the + * baked /etc/dinit.d service tree. This intentionally mirrors the browser + * demo instead of using PHP's built-in development server. * - * Requires: - * 1. PHP binary: packages/registry/php/php-src/sapi/cli/php - * (build with: cd packages/registry/php && bash build.sh) - * 2. WordPress files: packages/registry/wordpress/wordpress/ - * (download with: bash packages/registry/wordpress/setup.sh) + * Usage: + * npx tsx packages/registry/wordpress/demo/serve.ts [port] */ -import { readFileSync, existsSync } from "node:fs"; -import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { NodeKernelHost } from "../../../../host/src/node-kernel-host.ts"; -import { tryResolveBinary } from "../../../../host/src/binary-resolver.ts"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const repoRoot = join(__dirname, "../../../.."); -const phpBinaryPath = - tryResolveBinary("programs/php/php.wasm") ?? - join(repoRoot, "packages/registry/php/php-src/sapi/cli/php"); -const wpDir = join(__dirname, "..", "wordpress"); -const routerScript = join(__dirname, "router.php"); - -const port = parseInt(process.argv[2] || "3000", 10); - -// Validate prerequisites -if (!existsSync(phpBinaryPath)) { - console.error("Error: PHP binary not found. Build with: cd packages/registry/php && bash build.sh"); - process.exit(1); -} -if (!existsSync(join(wpDir, "wp-settings.php"))) { - console.error("Error: WordPress not found. Run: bash packages/registry/wordpress/setup.sh"); - process.exit(1); -} - -function loadFile(path: string): ArrayBuffer { - const buf = readFileSync(path); - return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); -} +import { + bootDinitServiceVfs, + configureWordPressRuntime, + finishWhenDinitExits, + installSignalHandlers, + trackDinitExit, + waitForHttp, +} from "../../service-vfs-demo"; async function main() { - const programBytes = loadFile(phpBinaryPath); + const port = parsePort(process.argv[2] ?? "3000"); - const host = new NodeKernelHost({ - maxWorkers: 4, - onStdout: (_pid, data) => process.stdout.write(data), - onStderr: (_pid, data) => process.stderr.write(data), + console.log("Booting WordPress VFS with dinit..."); + const { host, exitPromise } = await bootDinitServiceVfs({ + image: { + relPath: "programs/wordpress.vfs.zst", + publicFile: "wordpress.vfs.zst", + buildHint: "./run.sh build wp-vfs", + }, + target: "nginx", + maxWorkers: 12, + maxPages: 4096, + configure: (fs) => configureWordPressRuntime(fs, { + port, + freshSqliteDatabase: true, + phpFpmWorkers: 6, + }), + env: [ + "HOME=/root", + "TERM=xterm-256color", + "PATH=/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin", + "WP_APP_PATH=/", + "WP_PROTO=http", + ], }); - await host.init(); + installSignalHandlers(host); + const dinitExited = trackDinitExit(exitPromise); - console.log(`WordPress server starting on http://localhost:${port}`); - console.log("Waiting for PHP built-in server to initialize..."); + console.log(`Waiting for WordPress on http://localhost:${port}/...`); + await waitForHttp(`http://localhost:${port}/`, 180_000, dinitExited); - // Load opcache as a Zend extension so the cli-server SAPI caches - // parsed bytecode across requests instead of re-parsing every .php - // file on every hit (~50 files for WordPress install per page). - // opcache.so is a third [[outputs]] entry in packages/registry/php/ - // package.toml; the resolver places it at programs/php/opcache.so - // alongside php.wasm. Pass that directory as `extension_dir`. - const opcachePath = tryResolveBinary("programs/php/opcache.so"); - const enableOpcache = process.env.NO_OPCACHE !== "1" && opcachePath !== null; - const opcacheArgs = enableOpcache ? [ - "-d", `extension_dir=${dirname(opcachePath!)}`, - "-d", "zend_extension=opcache", - "-d", "opcache.enable=1", - "-d", "opcache.enable_cli=1", - "-d", "opcache.file_cache=/tmp", - "-d", "opcache.file_cache_only=1", - "-d", "opcache.memory_consumption=128", - "-d", "opcache.validate_timestamps=0", - ] : []; - if (!enableOpcache && process.env.NO_OPCACHE !== "1") { - console.warn("WARN: opcache.so not found via resolver — running without opcache"); - } - const exitPromise = host.spawn(programBytes, [ - "php", - ...opcacheArgs, - "-S", `0.0.0.0:${port}`, "-t", wpDir, routerScript, - ], { - env: ["HOME=/tmp", "TMPDIR=/tmp"], - }); + console.log("\nWordPress running behind nginx + php-fpm!"); + console.log(` Homepage: curl http://localhost:${port}/`); + console.log(` Admin: http://localhost:${port}/wp-admin/`); + console.log("\nPress Ctrl+C to stop."); - process.on("SIGINT", async () => { - console.log("\nShutting down..."); - await host.destroy().catch(() => {}); - process.exit(0); - }); + await finishWhenDinitExits(host, exitPromise); +} - const status = await exitPromise; - await host.destroy().catch(() => {}); - process.exit(status); +function parsePort(value: string): number { + const port = Number(value); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`Invalid port: ${value}`); + } + return port; } main().catch((err) => { diff --git a/packages/registry/wordpress/test/wordpress-site-editor.test.ts b/packages/registry/wordpress/test/wordpress-site-editor.test.ts index 02c885571b..bcf078beef 100644 --- a/packages/registry/wordpress/test/wordpress-site-editor.test.ts +++ b/packages/registry/wordpress/test/wordpress-site-editor.test.ts @@ -8,15 +8,13 @@ * * Requires: * 0. KANDELO_WORDPRESS_SITE_EDITOR_E2E=1 - * 1. PHP binary: packages/registry/php/php-src/sapi/cli/php - * 2. WordPress files: packages/registry/wordpress/wordpress/ - * 3. Kernel wasm: host/wasm/kandelo-kernel.wasm - * 4. Playwright browsers: npx playwright install chromium + * 1. WordPress service VFS image: programs/wordpress.vfs.zst + * 2. Kernel wasm: host/wasm/kandelo-kernel.wasm + * 3. Playwright browsers: npx playwright install chromium */ import { describe, it, expect, afterAll } from "vitest"; -import { existsSync, unlinkSync, mkdirSync, writeFileSync } from "node:fs"; -import { execSync } from "node:child_process"; +import { existsSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { spawn, type ChildProcess } from "node:child_process"; @@ -26,25 +24,23 @@ import { tryResolveBinary } from "../../../../host/src/binary-resolver"; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(__dirname, "../../../.."); -const phpBinaryPath = tryResolveBinary("programs/php/php.wasm"); const kernelWasmPath = tryResolveBinary("kernel.wasm"); -const wpDir = join(repoRoot, "packages/registry/wordpress/wordpress"); -const dbPath = join(wpDir, "wp-content/database/wordpress.db"); +const wpVfsPath = tryResolveBinary("programs/wordpress.vfs.zst") + ?? (existsSync(join(repoRoot, "apps/browser-demos/public/wordpress.vfs.zst")) + ? join(repoRoot, "apps/browser-demos/public/wordpress.vfs.zst") + : null); -const PHP_AVAILABLE = !!phpBinaryPath; -const WP_AVAILABLE = existsSync(join(wpDir, "wp-settings.php")); const KERNEL_AVAILABLE = !!kernelWasmPath; +const WP_VFS_AVAILABLE = !!wpVfsPath; const E2E_ENABLED = process.env.KANDELO_WORDPRESS_SITE_EDITOR_E2E === "1"; const SKIP_REASON = !E2E_ENABLED ? "set KANDELO_WORDPRESS_SITE_EDITOR_E2E=1 to run the heavyweight browser E2E" - : !PHP_AVAILABLE - ? "PHP binary not built" - : !WP_AVAILABLE - ? "WordPress not downloaded (run packages/registry/wordpress/setup.sh)" - : !KERNEL_AVAILABLE - ? "Kernel wasm not built (run bash build.sh)" - : ""; + : !WP_VFS_AVAILABLE + ? "WordPress VFS image not built (run ./run.sh build wp-vfs)" + : !KERNEL_AVAILABLE + ? "Kernel wasm not built (run bash build.sh)" + : ""; const ADMIN_USER = "admin"; const ADMIN_PASS = "X9#kQ2!vLm@pR7$w"; @@ -81,7 +77,7 @@ async function startServer(port: number): Promise { proc.stderr?.on("data", (d) => { output += d.toString(); }); proc.stdout?.on("data", (d) => { output += d.toString(); }); - // Wait for PHP's built-in server startup message + // Wait for the dinit/nginx/PHP-FPM service stack to be ready. await new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error( @@ -90,7 +86,7 @@ async function startServer(port: number): Promise { }, 120_000); const check = (data: Buffer) => { - if (/Development Server.*started/i.test(data.toString())) { + if (/WordPress running behind nginx \+ php-fpm/i.test(data.toString())) { clearTimeout(timeout); resolve(); } @@ -129,12 +125,9 @@ function killServer(proc: ChildProcess): void { } /** - * Install WordPress by sending a POST and monitoring the database file. - * - * PHP's built-in server is single-threaded. The install POST blocks the - * server while creating database tables. We can't wait for the response - * to complete (PHP hangs after wp_install), so we monitor the DB file - * directly and abort once install is confirmed. + * Install WordPress by sending the normal install POST. The VFS-backed + * service demo uses nginx + PHP-FPM workers, so the request can complete + * normally; no host-side database polling or server restart is needed. */ async function installWordPress(baseUrl: string): Promise { const body = new URLSearchParams({ @@ -147,51 +140,16 @@ async function installWordPress(baseUrl: string): Promise { Submit: "Install WordPress", }); - // Fire the install POST (don't wait for response — it hangs) - const controller = new AbortController(); - fetch(`${baseUrl}/wp-admin/install.php?step=2`, { + const resp = await fetch(`${baseUrl}/wp-admin/install.php?step=2`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString(), - signal: controller.signal, - }).catch(() => {}); - - // Monitor the database file until WordPress tables + admin user exist - const requiredTables = [ - "wp_options", "wp_users", "wp_posts", "wp_comments", - "wp_terms", "wp_term_taxonomy", "wp_term_relationships", - ]; - - const deadline = Date.now() + 600_000; // 10 minutes - while (Date.now() < deadline) { - if (existsSync(dbPath)) { - try { - const tables = execSync( - `sqlite3 "${dbPath}" ".tables"`, - { encoding: "utf-8", timeout: 5000 }, - ); - const hasAll = requiredTables.every((t) => tables.includes(t)); - if (hasAll) { - const users = execSync( - `sqlite3 "${dbPath}" "SELECT user_login FROM wp_users LIMIT 1;"`, - { encoding: "utf-8", timeout: 5000 }, - ); - if (users.includes(ADMIN_USER)) { - // Update siteurl/home to match current server URL - execSync( - `sqlite3 "${dbPath}" "UPDATE wp_options SET option_value='${baseUrl}' WHERE option_name IN ('siteurl','home');"`, - { timeout: 5000 }, - ); - controller.abort(); - return; - } - } - } catch { /* DB might be locked */ } - } - await new Promise((r) => setTimeout(r, 2000)); + signal: AbortSignal.timeout(600_000), + }); + const text = await resp.text(); + if (resp.status < 200 || resp.status >= 400) { + throw new Error(`WordPress install failed with HTTP ${resp.status}: ${text.slice(0, 1000)}`); } - controller.abort(); - throw new Error("WordPress install did not complete within 10 minutes"); } /** Dismiss the WP 6.7+ welcome guide modal if it appears. */ @@ -245,40 +203,15 @@ describe.skipIf(!!SKIP_REASON)("WordPress Site Editor E2E", () => { const port = await getRandomPort(); const baseUrl = `http://127.0.0.1:${port}`; - // Fresh database - if (existsSync(dbPath)) { - unlinkSync(dbPath); - } - - // Create mu-plugin to disable operations that hang in Wasm - const muPluginsDir = join(wpDir, "wp-content/mu-plugins"); - mkdirSync(muPluginsDir, { recursive: true }); - writeFileSync( - join(muPluginsDir, "wasm-optimizations.php"), - " setTimeout(r, 2000)); - serverProc = await startServer(port); - // Login via fetch to get auth cookies. We avoid using Playwright for - // login because the browser's dashboard subrequests (CSS, JS, AJAX) - // would block PHP's single-threaded built-in server, preventing - // subsequent page loads until all subrequests complete. + // login so the test can inject cookies and navigate straight to the + // editor page. const loginResp = await fetch(`${baseUrl}/wp-login.php`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, diff --git a/run.sh b/run.sh index e423987e93..c5e5117ace 100755 --- a/run.sh +++ b/run.sh @@ -2523,11 +2523,14 @@ build_all() { build_nethack build_git build_nginx + build_nginx_vfs build_php build_php_fpm + build_nginx_php_vfs build_mariadb build_mariadb_vfs build_redis + build_redis_vfs build_dinit build_msmtpd build_cpython @@ -2913,42 +2916,42 @@ cmd_run() { case "$example" in nginx) - build_nginx + build_nginx_vfs step "Starting nginx" exec npx tsx "$REPO_ROOT/packages/registry/nginx/demo/serve.ts" "$@" ;; mariadb) - build_mariadb + local use_wasm64=false + for arg in "$@"; do + if [ "$arg" = "--wasm64" ]; then + use_wasm64=true + fi + done + if [ "$use_wasm64" = true ]; then + build_mariadb64_vfs + else + build_mariadb_vfs + fi step "Starting MariaDB" exec npx tsx "$REPO_ROOT/packages/registry/mariadb/demo/serve.ts" "$@" ;; redis) - build_redis + build_redis_vfs step "Starting Redis" exec npx tsx "$REPO_ROOT/packages/registry/redis/demo/serve.ts" "$@" ;; wordpress) - build_php - build_wordpress - step "Starting WordPress (PHP built-in server + SQLite)" + build_wp_vfs + step "Starting WordPress (nginx + PHP-FPM + SQLite)" exec npx tsx "$REPO_ROOT/packages/registry/wordpress/demo/serve.ts" "$@" ;; wordpress-nginx) - build_nginx - build_php_fpm - build_wordpress + build_wp_vfs step "Starting WordPress (nginx + PHP-FPM + SQLite)" exec npx tsx "$REPO_ROOT/packages/registry/wordpress/demo/serve-nginx.ts" "$@" ;; lamp) - build_mariadb - build_nginx - build_php_fpm - # LAMP uses its own WordPress setup (MySQL mode) - if [ ! -f "$REPO_ROOT/packages/registry/lamp/demo/wordpress/wp-settings.php" ]; then - step "Setting up LAMP WordPress" - bash "$REPO_ROOT/packages/registry/lamp/demo/setup.sh" - fi + build_lamp_vfs step "Starting LAMP stack (MariaDB + PHP-FPM + nginx + WordPress)" exec npx tsx "$REPO_ROOT/packages/registry/lamp/demo/serve.ts" "$@" ;; @@ -3197,11 +3200,14 @@ cmd_list() { echo " fbdoom fbDOOM (framebuffer DOOM via /dev/fb0) $(has_fbdoom && echo "${GREEN}✓${RESET}" || echo "${YELLOW}○${RESET}")" echo " git Git 2.47.1 $(has_git && echo "${GREEN}✓${RESET}" || echo "${YELLOW}○${RESET}")" echo " nginx nginx 1.24 Wasm binary $(has_nginx && echo "${GREEN}✓${RESET}" || echo "${YELLOW}○${RESET}")" + echo " nginx-vfs nginx service VFS image $(has_nginx_vfs && echo "${GREEN}✓${RESET}" || echo "${YELLOW}○${RESET}")" echo " php PHP 8.3 CLI binary $(has_php && echo "${GREEN}✓${RESET}" || echo "${YELLOW}○${RESET}")" echo " php-fpm PHP-FPM Wasm binary $(has_php_fpm && echo "${GREEN}✓${RESET}" || echo "${YELLOW}○${RESET}")" + echo " nginx-php-vfs nginx + PHP-FPM VFS image $(has_nginx_php_vfs && echo "${GREEN}✓${RESET}" || echo "${YELLOW}○${RESET}")" echo " mariadb MariaDB 10.5 Wasm binary (wasm32) $(has_mariadb && echo "${GREEN}✓${RESET}" || echo "${YELLOW}○${RESET}")" echo " mariadb64 MariaDB 10.5 Wasm binary (wasm64) $(has_mariadb64 && echo "${GREEN}✓${RESET}" || echo "${YELLOW}○${RESET}")" echo " redis Redis 7.2 Wasm binary $(has_redis && echo "${GREEN}✓${RESET}" || echo "${YELLOW}○${RESET}")" + echo " redis-vfs Redis service VFS image $(has_redis_vfs && echo "${GREEN}✓${RESET}" || echo "${YELLOW}○${RESET}")" echo " dinit dinit service supervisor $(has_dinit && echo "${GREEN}✓${RESET}" || echo "${YELLOW}○${RESET}")" echo " msmtpd Local SMTP capture server $(has_msmtpd && echo "${GREEN}✓${RESET}" || echo "${YELLOW}○${RESET}")" echo " cpython CPython 3.13 Wasm binary $(has_cpython && echo "${GREEN}✓${RESET}" || echo "${YELLOW}○${RESET}")" @@ -3252,7 +3258,7 @@ cmd_list() { echo " ./run.sh run nginx [port] nginx HTTP server" echo " ./run.sh run redis [port] Redis key-value store" echo " ./run.sh run mariadb MariaDB standalone" - echo " ./run.sh run wordpress [port] WordPress (PHP built-in + SQLite)" + echo " ./run.sh run wordpress [port] WordPress (nginx + PHP-FPM + SQLite)" echo " ./run.sh run wordpress-nginx [port] WordPress (nginx + PHP-FPM + SQLite)" echo " ./run.sh run lamp [port] Full LAMP stack (MariaDB + nginx + PHP-FPM)" echo " ./run.sh run erlang [-eval 'Expr'] Erlang BEAM VM" From debf7f4563817f38df27ba949ffd01e24e16f731 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Thu, 2 Jul 2026 09:35:45 -0400 Subject: [PATCH 35/82] CI: Repair xtask fixtures and gate xtask tests Seven xtask unit tests failed after PR #605 (`3430c5bbc`) added program output/cache artifact validation and, for one test, after ABI_VERSION moved past 4. They are stale test defects, not product regressions: their fixtures emit artifacts that correct validation rejects. - Class A (five `cmd_resolve_*` tests): build scripts `touch`ed an empty `.wasm`, rejected as "is not a wasm binary". They now emit a valid minimal module through `emit_wasm_build_script` and `minimal_executable_wasm`. The kernel fixture derives the complete `HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS` set from the shared constant. - Class B (`binaries_dir_program_fetch_does_not_require_built_deps`): the fetched archive fixture had only a Wasm header, so cache validation forced the source-build fallback that this test excludes. It now uses valid Wasm and exercises the intended remote-first path. - Class C (`cli_produces_archive_with_canonical_filename`): the assertion hardcoded `abi4`; it now derives the segment from `shared::ABI_VERSION`. Production validation is unchanged. The negative `wasm_artifact_policy_rejects_empty_and_exportless_when_exports_required` test locks in empty/non-Wasm and missing-export rejection, so fixtures cannot be fixed by weakening validation. xtask's unit tests were not gated in CI, which allowed them to rot. Add `cargo-xtask` to `scripts/ci-run-test-suite.sh` and wire it into prepare-merge, staging-build, and force-rebuild without kernel, toolchain, or workspace requirements. Document the suite in validation and repository guidance. Historical source-branch validation: `cargo test -p xtask`: 290 passed / 7 failed -> 298 passed / 0 failed. Implements kd-xc19 triage design: docs/plans/2026-07-02-xtask-stale-wasm-fixture-test-failures-triage.md ABI 43 forward-port: The current stack already includes the fixture-byte repairs, so this commit retains one copy and strengthens the non-Wasm negative guard. The source-only suites now live in an early matrix; cargo-xtask joins it without kernel gating or a prepared workspace. The targeted tests and runner/workflow contract pass. The full suite is 628 passed and 3 unrelated failures: the deferred program projection and two Homebrew sidecar fixtures. Co-Authored-By: Claude Opus 4.8 (cherry picked from commit 89d5632068c334106360d91b775845ecf3bb5d4f) This conceptual commit combines recovery commits 4e825b5bb and b453a5992. --- .github/workflows/force-rebuild.yml | 6 + .github/workflows/prepare-merge.yml | 10 +- .github/workflows/staging-build.yml | 10 +- docs/agent-guidance/validation.md | 3 +- docs/repository-organization.md | 2 +- scripts/ci-run-test-suite.sh | 9 +- test-runs/kd-872c/SUMMARY.md | 85 +++ test-runs/kd-872c/after-failed.txt | 0 test-runs/kd-872c/after-passed.txt | 298 +++++++++++ test-runs/kd-872c/after-rebase-abi16.log | 333 ++++++++++++ test-runs/kd-872c/after-serial.log | 311 +++++++++++ test-runs/kd-872c/after-skipped.txt | 0 test-runs/kd-872c/before-failed.txt | 7 + test-runs/kd-872c/before-passed.txt | 290 ++++++++++ test-runs/kd-872c/before-skipped.txt | 0 test-runs/kd-872c/repro-before-serial.log | 505 ++++++++++++++++++ .../scripts/ci-run-test-suite-groups.test.sh | 70 +-- tools/xtask/src/archive_stage_cli.rs | 5 + tools/xtask/src/build_deps.rs | 52 +- 19 files changed, 1939 insertions(+), 57 deletions(-) create mode 100644 test-runs/kd-872c/SUMMARY.md create mode 100644 test-runs/kd-872c/after-failed.txt create mode 100644 test-runs/kd-872c/after-passed.txt create mode 100644 test-runs/kd-872c/after-rebase-abi16.log create mode 100644 test-runs/kd-872c/after-serial.log create mode 100644 test-runs/kd-872c/after-skipped.txt create mode 100644 test-runs/kd-872c/before-failed.txt create mode 100644 test-runs/kd-872c/before-passed.txt create mode 100644 test-runs/kd-872c/before-skipped.txt create mode 100644 test-runs/kd-872c/repro-before-serial.log diff --git a/.github/workflows/force-rebuild.yml b/.github/workflows/force-rebuild.yml index d9fc9320b7..e754671a28 100644 --- a/.github/workflows/force-rebuild.yml +++ b/.github/workflows/force-rebuild.yml @@ -830,6 +830,12 @@ jobs: needs_submodules: false needs_toolchain: false needs_workspace: false + - suite: cargo-xtask + label: cargo-xtask + group: all + needs_submodules: false + needs_toolchain: false + needs_workspace: false - suite: vitest label: vitest (1/2) group: 1/2 diff --git a/.github/workflows/prepare-merge.yml b/.github/workflows/prepare-merge.yml index c45ff3ecfd..3723f5414f 100644 --- a/.github/workflows/prepare-merge.yml +++ b/.github/workflows/prepare-merge.yml @@ -1609,9 +1609,17 @@ jobs: include: - suite: cargo-kernel label: cargo-kernel + kernel_only: true - suite: fork-instrument label: fork-instrument + kernel_only: true + - suite: cargo-xtask + label: cargo-xtask + # Package-system automation is independent of kernel changes and + # needs neither the toolchain nor the prepared test workspace. + kernel_only: false env: + KERNEL_ONLY: ${{ matrix.kernel_only }} KERNEL_CHANGED: ${{ needs.change-scope.outputs.kernel }} SUITE: ${{ matrix.suite }} steps: @@ -1619,7 +1627,7 @@ jobs: id: suite run: | set -euo pipefail - if [ "$KERNEL_CHANGED" != "true" ]; then + if [ "$KERNEL_ONLY" = "true" ] && [ "$KERNEL_CHANGED" != "true" ]; then echo "skip=true" >> "$GITHUB_OUTPUT" echo "$SUITE is kernel-only and this diff does not require kernel suites." exit 0 diff --git a/.github/workflows/staging-build.yml b/.github/workflows/staging-build.yml index 7013ee6122..8ca846d31d 100644 --- a/.github/workflows/staging-build.yml +++ b/.github/workflows/staging-build.yml @@ -838,9 +838,17 @@ jobs: include: - suite: cargo-kernel label: cargo-kernel + kernel_only: true - suite: fork-instrument label: fork-instrument + kernel_only: true + - suite: cargo-xtask + label: cargo-xtask + # Package-system automation is independent of kernel changes and + # needs neither the toolchain nor the prepared test workspace. + kernel_only: false env: + KERNEL_ONLY: ${{ matrix.kernel_only }} KERNEL_CHANGED: ${{ needs.change-scope.outputs.kernel }} SKIP_STAGING_TESTS: ${{ needs.change-scope.outputs.skip_staging_tests }} SUITE: ${{ matrix.suite }} @@ -854,7 +862,7 @@ jobs: echo "skip-staging-tests label is present; $SUITE is deferred to prepare-merge." exit 0 fi - if [ "$KERNEL_CHANGED" != "true" ]; then + if [ "$KERNEL_ONLY" = "true" ] && [ "$KERNEL_CHANGED" != "true" ]; then echo "skip=true" >> "$GITHUB_OUTPUT" echo "$SUITE is kernel-only and this diff does not require kernel suites." exit 0 diff --git a/docs/agent-guidance/validation.md b/docs/agent-guidance/validation.md index 204f508639..46e6466384 100644 --- a/docs/agent-guidance/validation.md +++ b/docs/agent-guidance/validation.md @@ -22,6 +22,7 @@ Core validation surface: |---|---|---| | Kernel unit tests | `cargo test -p kandelo --target --lib` | Kernel logic changes | | Fork instrument tests | `cargo test -p fork-instrument --target ` | Fork instrumentation/tooling changes | +| Package-system automation tests | `cargo test -p xtask --target ` | `tools/xtask/**` changes: package resolver, binaries-dir placement, cache/output artifact validation, archive staging + canonical filename | | Host integration tests | `cd host && npx vitest run` | Host/runtime behavior | | Browser app/runtime tests | `cd apps/browser-demos && npx playwright test --grep-invert "@slow" --project=chromium` | Browser host, UI, demo, service worker, VFS image behavior | | Browser package-tree contract | `cd apps/browser-demos && npx playwright test test/package-deferred-tree-browser.spec.ts --project=chromium --project=firefox --project=webkit` | Browser lazy/eager package-tree parity, including Safari/WebKit | @@ -34,7 +35,7 @@ Core validation surface: For CI-shaped local runs, prefer: ```bash -bash scripts/dev-shell.sh bash scripts/ci-run-test-suite.sh [group] +bash scripts/dev-shell.sh bash scripts/ci-run-test-suite.sh [group] ``` The optional group reproduces CI's deterministic suite partitions. Vitest diff --git a/docs/repository-organization.md b/docs/repository-organization.md index b81bd952b7..9179f8ca12 100644 --- a/docs/repository-organization.md +++ b/docs/repository-organization.md @@ -80,7 +80,7 @@ The layout is designed so later CI path filters can make conservative, explainab | `host/src/browser-*.ts`, `host/src/worker-adapter-browser.ts` | Browser host checks, browser UI/tests, host parity tests | | `host/src/vfs/**`, `host/src/networking/**`, `host/src/framebuffer/**` | Shared host/runtime checks plus affected package/browser checks | | `packages/registry//**` | That package build and `packages/registry//test/**` | -| `packages/sets/**`, `tools/xtask/**`, `docs/package-management*.md` | Package-system automation checks | +| `packages/sets/**`, `tools/xtask/**`, `docs/package-management*.md` | Package-system automation checks, including the `cargo test -p xtask` (`cargo-xtask`) unit-test suite | | `apps/browser-demos/**`, `web-libs/**` | Browser app build/tests and relevant package browser specs | | `images/**`, `tools/mkrootfs/**` | Rootfs/VFS image checks and consumers of those images | diff --git a/scripts/ci-run-test-suite.sh b/scripts/ci-run-test-suite.sh index f4f5eea724..f87edf081e 100755 --- a/scripts/ci-run-test-suite.sh +++ b/scripts/ci-run-test-suite.sh @@ -20,7 +20,7 @@ host_target() { suite="${1:-}" if [ -z "$suite" ]; then - echo "usage: $0 [group]" >&2 + echo "usage: $0 [group]" >&2 exit 2 fi group="${2:-${TEST_GROUP:-all}}" @@ -313,6 +313,13 @@ case "$suite" in HOST_TARGET="$(host_target)" cargo test -p fork-instrument --target "$HOST_TARGET" ;; + cargo-xtask) + # Package-system automation unit tests (tools/xtask/**): package + # resolver, binaries-dir placement, and archive staging/naming. Pure + # host-target cargo tests; no wasm sysroots or prepared workspace needed. + HOST_TARGET="$(host_target)" + cargo test -p xtask --target "$HOST_TARGET" + ;; vitest) resource_cases="$REPO_ROOT/scripts/ci-vitest-resource-isolated-cases.tsv" resource_files=() diff --git a/test-runs/kd-872c/SUMMARY.md b/test-runs/kd-872c/SUMMARY.md new file mode 100644 index 0000000000..75b5ed3e6e --- /dev/null +++ b/test-runs/kd-872c/SUMMARY.md @@ -0,0 +1,85 @@ +# kd-872c — xtask stale-fixture repair + gate: test outcome summary + +Command (host target, inside `scripts/dev-shell.sh`): + +``` +scripts/dev-shell.sh cargo test -p xtask --target aarch64-apple-darwin -- --test-threads=1 +``` + +Base: `53fb842e8` (kd-u7f validation-gates convoy base, ABI_VERSION 15). + +## Counts + +| Run | Base | ABI | Total | Passed | Failed | Ignored | Exit | +|---|---|---|---|---|---|---|---| +| Before (`repro-before-serial.log`) | `53fb842e8` (convoy) | 15 | 297 | 290 | 7 | 0 | 101 | +| After (`after-serial.log`) | `53fb842e8` (convoy) | 15 | 298 | 298 | 0 | 0 | 0 | +| After, rebased (`after-rebase-abi16.log`) | `origin/main` (PR base) | 16 | 315 | 315 | 0 | 0 | 0 | + +The PR is rebased on `origin/main` (ABI 16), which carries 17 more xtask tests +than the convoy base, so the green total there is 315 rather than 298 — all pass, +including the version-relative Class C assertion, confirming the fix is +base-independent. Before/after on the convoy base (ABI 15) is the primary +7-failure repair evidence; the design doc independently confirmed the same 7 +failures at ABI 16 (kd-1mr base `f4339836`). + +Delta (convoy base): the 7 stale failures now pass, plus 1 new negative +regression-guard test +(`wasm_artifact_policy_rejects_empty_and_exportless_when_exports_required`). +No previously-passing test regressed. + +Outcome lists: `before-{passed,failed,skipped}.txt`, +`after-{passed,failed,skipped}.txt`. Skipped is empty by construction — the +xtask suite declares no `#[ignore]` tests. + +Note on the lists: two always-passing tests +(`build_into_cache_stderr_dup_pattern_does_not_panic`, +`ensure_built_fails_when_script_exits_nonzero`) interleave captured subprocess +stdout onto their inline result line under `--test-threads=1`, so their `... ok` +is recovered by name rather than by the raw grep. The authoritative counts are +the `test result:` summary lines above. + +## The 7 repaired tests (were failing → now pass) + +Class A (5) — build-script fixtures `touch`ed an empty `.wasm`, rejected by the +PR #605 output validation as "is not a wasm binary". Now emit valid wasm via +`emit_wasm_build_script` + `minimal_executable_wasm` (the kernel variant emits +the full `HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS` set from the shared const): + +- build_deps::tests::cmd_resolve_with_binaries_dir_places_single_output_symlink +- build_deps::tests::cmd_resolve_with_binaries_dir_places_kernel_at_root +- build_deps::tests::cmd_resolve_with_binaries_dir_places_multi_output_symlinks +- build_deps::tests::cmd_resolve_with_binaries_dir_replaces_existing_link +- build_deps::tests::cmd_resolve_without_binaries_dir_places_no_symlinks + +Class B (1) — fetched-archive fixture was a wasm header only (no exports), so +`validate_cache_artifacts` reported "missing required exports" and the fetch fell +back to a source build (baddep `exit 42` → panic). Now uses a valid +`minimal_executable_wasm()`: + +- build_deps::tests::binaries_dir_program_fetch_does_not_require_built_deps + +Class C (1) — assertion hardcoded `abi4`; the canonical filename correctly +encodes the real ABI. Now version-relative via `shared::ABI_VERSION`: + +- archive_stage_cli::tests::cli_produces_archive_with_canonical_filename + +## Production validation unchanged + +No product code changed. Only test fixtures/assertions plus a new negative +regression guard. The guard asserts empty/non-wasm → "is not a wasm binary" and +export-less wasm → "missing required exports", so a future change cannot weaken +validation to accommodate an empty fixture without turning this test red. + +## CI gate + ABI verification + +- `scripts/dev-shell.sh bash scripts/ci-run-test-suite.sh cargo-xtask` runs the + new suite through the exact CI entrypoint: exit 0, 0 failed. This is the path + wired into the `prepare-merge`, `staging-build`, and `force-rebuild` matrices. +- `scripts/dev-shell.sh bash scripts/check-abi-version.sh`: exit 0, "ABI_VERSION + and snapshot are consistent" — this change does not perturb the ABI. +- Shell (`bash -n`) and YAML (all three workflows) validated. + +Not run (this change touches none of these surfaces): vitest, musl libc-test, +Open POSIX, browser demos, kernel `--lib`. Reason recorded per the validation +contract: the diff is xtask test fixtures + CI suite wiring + docs only. diff --git a/test-runs/kd-872c/after-failed.txt b/test-runs/kd-872c/after-failed.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test-runs/kd-872c/after-passed.txt b/test-runs/kd-872c/after-passed.txt new file mode 100644 index 0000000000..ee140603dc --- /dev/null +++ b/test-runs/kd-872c/after-passed.txt @@ -0,0 +1,298 @@ +archive_stage::tests::embedded_manifest_round_trips_through_parse_archived +archive_stage::tests::produces_archive_consumable_by_remote_fetch +archive_stage::tests::produces_byte_identical_archive_on_repeat_invocation +archive_stage::tests::rejects_empty_cache_dir +archive_stage::tests::rejects_source_kind +archive_stage::tests::rejects_when_cache_entry_is_missing +archive_stage_cli::tests::cli_archive_filename_uses_build_toml_revision +archive_stage_cli::tests::cli_binaries_dir_materializes_program_dependency_symlink +archive_stage_cli::tests::cli_is_byte_deterministic_across_invocations +archive_stage_cli::tests::cli_produces_archive_with_canonical_filename +archive_stage_cli::tests::cli_rejects_arch_not_in_target_arches +archive_stage_cli::tests::cli_rejects_cache_key_input_mutation_during_build +archive_stage_cli::tests::cli_rejects_expected_cache_key_mismatch_before_build +archive_stage_cli::tests::cli_rejects_source_kind_with_clear_error +archive_stage_cli::tests::cli_requires_all_mandatory_flags +build_deps::tests::binaries_dir_program_fetch_does_not_require_built_deps +build_deps::tests::build_deps_check_flags_inconsistent_constraint +build_deps::tests::build_deps_check_flags_inconsistent_probe +build_deps::tests::build_deps_check_passes_on_consistent_registry +build_deps::tests::build_fails_when_program_wasm_output_missing +build_deps::tests::build_into_cache_stderr_dup_pattern_does_not_panic +build_deps::tests::build_script_sees_target_arch_env +build_deps::tests::build_script_stdout_redirect_to_owned_fd_works +build_deps::tests::build_validates_program_wasm_outputs_present +build_deps::tests::cache_key_sha_changes_when_library_output_header_added +build_deps::tests::cache_key_sha_changes_when_library_output_lib_filename_changes +build_deps::tests::cache_key_sha_changes_when_library_output_pkgconfig_added +build_deps::tests::cache_key_sha_changes_when_program_output_added +build_deps::tests::cache_key_sha_changes_when_program_output_fork_policy_changes +build_deps::tests::cache_key_sha_changes_when_program_output_name_changes +build_deps::tests::cache_key_sha_changes_when_program_output_wasm_filename_changes +build_deps::tests::cache_key_sha_changes_when_program_outputs_reordered +build_deps::tests::cache_key_sha_changes_with_abi_version +build_deps::tests::cache_key_sha_changes_with_target_arch +build_deps::tests::canonical_path_layout +build_deps::tests::canonical_path_uses_programs_subdir_for_program_kind +build_deps::tests::cmd_resolve_with_binaries_dir_places_kernel_at_root +build_deps::tests::cmd_resolve_with_binaries_dir_places_multi_output_symlinks +build_deps::tests::cmd_resolve_with_binaries_dir_places_single_output_symlink +build_deps::tests::cmd_resolve_with_binaries_dir_replaces_existing_link +build_deps::tests::cmd_resolve_without_binaries_dir_places_no_symlinks +build_deps::tests::compute_cache_key_sha_args_parse_equals_form +build_deps::tests::compute_cache_key_sha_args_parse_long_form +build_deps::tests::compute_cache_key_sha_args_reject_missing_arch +build_deps::tests::compute_cache_key_sha_args_reject_missing_package +build_deps::tests::compute_cache_key_sha_args_reject_unknown_flag +build_deps::tests::compute_cache_key_sha_changes_on_input_change +build_deps::tests::compute_cache_key_sha_is_deterministic_across_invocations +build_deps::tests::compute_cache_key_sha_rejects_missing_build_toml_input +build_deps::tests::compute_cache_key_sha_subcommand_prints_64_hex_for_real_package +build_deps::tests::compute_cache_key_sha_uses_build_toml_inputs +build_deps::tests::compute_cache_key_sha_uses_build_toml_revision +build_deps::tests::compute_sha_detects_cycle +build_deps::tests::compute_sha_is_deterministic +build_deps::tests::compute_sha_rejects_version_mismatch +build_deps::tests::current_abi_version_matches_shared_crate +build_deps::tests::direct_pr_overlay_fetch_installs_archive_before_build_toml_index +build_deps::tests::ensure_built_cache_hit_skips_host_tool_probes +build_deps::tests::ensure_built_cache_miss_aborts_when_host_tool_missing +build_deps::tests::ensure_built_fails_when_declared_output_missing +build_deps::tests::ensure_built_fails_when_script_exits_nonzero +build_deps::tests::ensure_built_is_idempotent_on_cache_hit +build_deps::tests::ensure_built_runs_script_on_cache_miss +build_deps::tests::ensure_built_source_kind_fetches_and_extracts_via_file_url +build_deps::tests::ensure_built_source_kind_script_must_populate_out_dir +build_deps::tests::ensure_built_source_kind_with_build_script_runs_it +build_deps::tests::env_key_canonicalises_hyphens_and_case +build_deps::tests::extract_binaries_dir_flag_absent +build_deps::tests::extract_binaries_dir_flag_equals_form +build_deps::tests::extract_binaries_dir_flag_rejects_duplicate +build_deps::tests::extract_binaries_dir_flag_separated_form +build_deps::tests::extract_fetch_only_flag_removes_flag +build_deps::tests::fetch_only_rejects_missing_index_entry_without_source_build +build_deps::tests::force_rebuild_bypasses_index_fetch +build_deps::tests::force_rebuild_only_affects_named_packages +build_deps::tests::force_rebuild_runs_build_script_on_cache_hit +build_deps::tests::fork_instrument_cargo_dependency_digest_ignores_unrelated_lockfile_entries +build_deps::tests::fork_instrument_tool_inputs_apply_only_to_programs_that_use_them +build_deps::tests::fork_instrument_tool_inputs_hash_dependency_closure_instead_of_whole_lockfile +build_deps::tests::global_package_build_input_digests_change_with_content +build_deps::tests::global_package_build_input_digests_reject_missing_input +build_deps::tests::global_package_toolchain_inputs_include_package_build_actions +build_deps::tests::index_fetch_falls_through_on_abi_mismatch +build_deps::tests::index_fetch_falls_through_on_archive_sha_mismatch +build_deps::tests::index_fetch_falls_through_on_cache_key_mismatch +build_deps::tests::index_fetch_falls_through_on_index_toml_abi_mismatch +build_deps::tests::index_fetch_falls_through_on_target_arch_mismatch +build_deps::tests::index_fetch_installs_archive_when_sha_arch_abi_cachekey_all_match +build_deps::tests::libtool_archive_libdir_is_rewritten_to_canonical_path +build_deps::tests::local_libs_override_wins +build_deps::tests::output_fork_instrumentation_for_rel_is_arch_neutral +build_deps::tests::parse_target_arch_accepts_known_values +build_deps::tests::parse_target_arch_rejects_unknown_values +build_deps::tests::pkg_config_path_includes_transitive_lib_pkgconfig +build_deps::tests::pkg_config_path_skips_libs_without_pkgconfig_dir +build_deps::tests::pkgconfig_prefix_is_rewritten_to_canonical_path +build_deps::tests::pkgconfig_symlinks_survive_the_rewrite +build_deps::tests::program_output_validation_accepts_disabled_fork_instrumentation_policy +build_deps::tests::program_output_validation_accepts_kernel_host_adapter_exports +build_deps::tests::program_output_validation_accepts_relocatable_fork_objects +build_deps::tests::program_output_validation_rejects_executable_without_entrypoint_exports +build_deps::tests::program_output_validation_rejects_fork_without_wpk_exports +build_deps::tests::program_output_validation_rejects_kernel_missing_host_adapter_exports +build_deps::tests::program_output_validation_rejects_legacy_asyncify_wasm +build_deps::tests::program_output_validation_rejects_wpk_exports_when_policy_disabled +build_deps::tests::programs_by_name_filters_to_program_kind +build_deps::tests::registry_find_falls_through_to_second_root +build_deps::tests::registry_find_returns_first_hit +build_deps::tests::render_probe_failures_uses_darwin_alias_for_macos +build_deps::tests::resolve_with_arch_wasm64_uses_different_cache_path +build_deps::tests::source_kind_canonical_path_omits_arch +build_deps::tests::source_kind_direct_dep_exports_src_dir_env_var +build_deps::tests::source_kind_sha_omits_arch_and_abi_inputs +build_deps::tests::source_kind_sha_uses_distinct_domain +build_deps::tests::transitive_deps_are_built_and_exposed_via_env +build_deps::tests::walk_all_finds_libraries_and_programs +build_deps::tests::walk_all_first_root_wins_for_duplicate_names +build_deps::tests::walk_all_handles_missing_registry_root +build_deps::tests::wasm_artifact_policy_rejects_empty_and_exportless_when_exports_required +build_index::tests::abi_mismatch_in_filename_is_rejected +build_index::tests::determinism_byte_identical_on_repeat_invocation +build_index::tests::divergent_version_across_arches_is_rejected +build_index::tests::empty_input_produces_valid_header_only_toml +build_index::tests::filename_parser_handles_multi_segment_names +build_index::tests::filename_parser_rejects_malformed_inputs +build_index::tests::missing_arch_only_emits_present_block +build_index::tests::smoke_two_packages_two_arches +dump_abi::tests::adding_host_adapter_section_is_compatible +dump_abi::tests::adding_syscall_arg_descriptor_section_is_compatible +dump_abi::tests::additive_syscall_export_and_struct_are_compatible +dump_abi::tests::changed_channel_layout_is_breaking +dump_abi::tests::changed_existing_export_is_breaking +dump_abi::tests::changed_syscall_arg_descriptor_is_breaking +dump_abi::tests::renamed_syscall_number_is_breaking +dump_abi::tests::syscall_log_names_match_existing_trace_spelling +host_tool_probe::tests::probe_compares_numerically_3_20_satisfies_3_9 +host_tool_probe::tests::probe_passes_when_version_meets_constraint +host_tool_probe::tests::probe_rejects_old_version +host_tool_probe::tests::probe_reports_bad_output_when_regex_does_not_match +host_tool_probe::tests::probe_reports_missing_when_not_in_path +index_toml::tests::archive_filename_abi_extracts_abi_segment +index_toml::tests::fetch_index_errors_when_no_cache_and_offline +index_toml::tests::fetch_index_falls_back_to_cache_when_offline +index_toml::tests::fetch_index_reads_file_url_and_writes_cache +index_toml::tests::index_cache_path_distinguishes_urls +index_toml::tests::index_toml_prunes_archive_entries_for_other_abis +index_toml::tests::index_toml_round_trips_semantic_equality +index_toml::tests::index_toml_validate_archive_abi_versions_rejects_current_mismatch +index_toml::tests::index_toml_validate_archive_abi_versions_rejects_fallback_mismatch +index_toml::tests::index_toml_write_omits_none_fields +index_toml::tests::index_toml_write_sorts_packages_alphabetically +index_toml::tests::parses_index_toml_with_failed_entry_and_fallback +index_toml::tests::parses_index_toml_with_success_entry +index_toml::tests::update_entry_failed_moves_current_to_fallback +index_toml::tests::update_entry_failed_preserves_existing_fallback +index_toml::tests::update_entry_failed_with_no_prior_success_has_no_fallback +index_toml::tests::update_entry_success_after_failed_clears_fallback +index_toml::tests::update_entry_success_overwrites_current_and_clears_fallback +index_toml::tests::update_entry_success_refreshes_existing_package_revision +index_update::tests::index_update_failed_moves_existing_success_to_fallback +index_update::tests::index_update_preserves_durable_index_toml_abi_from_expected_tag +index_update::tests::index_update_rejects_archive_cache_key_mismatch +index_update::tests::index_update_rejects_archive_path_name_mismatch +index_update::tests::index_update_rejects_missing_required_flag +index_update::tests::index_update_rejects_mixed_index_toml_archive_abis +index_update::tests::index_update_rejects_unknown_flag +index_update::tests::index_update_repair_rewrites_stale_index_toml_abi +index_update::tests::index_update_rewrites_stale_index_toml_abi_and_prunes_old_entries +index_update::tests::index_update_success_writes_entry_to_index +package_matrix::tests::dependency_artifacts_reports_only_selected_direct_dependencies +package_matrix::tests::sort_matrix_orders_selected_program_dependencies_first +pkg_manifest::tests::archived_parse_accepts_missing_kernel_abi +pkg_manifest::tests::archived_parses_bare_binary_block_as_wasm32 +pkg_manifest::tests::archived_parses_per_arch_binary_block +pkg_manifest::tests::archived_rejects_invalid_binary_archive_sha +pkg_manifest::tests::archived_rejects_long_binary_archive_sha +pkg_manifest::tests::archived_rejects_mixed_binary_shape +pkg_manifest::tests::archived_rejects_short_binary_archive_sha +pkg_manifest::tests::archived_rejects_unknown_binary_key +pkg_manifest::tests::archived_rejects_uppercase_binary_archive_sha +pkg_manifest::tests::build_rejects_legacy_script_field +pkg_manifest::tests::build_script_override_is_repo_root_relative +pkg_manifest::tests::depends_on_parsed_into_deprefs +pkg_manifest::tests::depref_parse_basic +pkg_manifest::tests::depref_rejects_empty_fields +pkg_manifest::tests::depref_rejects_missing_at +pkg_manifest::tests::host_tools_allowed_on_source_kind +pkg_manifest::tests::host_tools_reject_duplicate_names_in_same_manifest +pkg_manifest::tests::host_tools_reject_empty_probe_args +pkg_manifest::tests::host_tools_reject_invalid_probe_regex +pkg_manifest::tests::kernel_abi_is_optional +pkg_manifest::tests::output_dest_rel_library_kind_errors +pkg_manifest::tests::output_dest_rel_multi_output_uses_program_subdir +pkg_manifest::tests::output_dest_rel_single_output_program_name_matches_output_name +pkg_manifest::tests::output_dest_rel_single_output_with_diverging_name_uses_output_name +pkg_manifest::tests::output_dest_rel_unknown_basename_errors +pkg_manifest::tests::output_fork_instrumentation_can_be_disabled +pkg_manifest::tests::output_fork_instrumentation_defaults_to_auto +pkg_manifest::tests::overlay_absent_uses_base +pkg_manifest::tests::overlay_merges_binary_block_over_base +pkg_manifest::tests::overlay_merges_multiple_arches_from_overlay +pkg_manifest::tests::overlay_with_non_binary_field_is_rejected +pkg_manifest::tests::parse_accepts_no_binary_block +pkg_manifest::tests::parse_archived_accepts_full_compatibility_block +pkg_manifest::tests::parse_archived_accepts_legacy_script_field +pkg_manifest::tests::parse_archived_accepts_repo_url_and_commit +pkg_manifest::tests::parse_archived_accepts_script_path_field +pkg_manifest::tests::parse_archived_rejects_empty_abi_versions +pkg_manifest::tests::parse_archived_rejects_short_cache_key_sha +pkg_manifest::tests::parse_archived_rejects_uppercase_cache_key_sha +pkg_manifest::tests::parse_archived_rejects_zero_revision +pkg_manifest::tests::parse_archived_requires_compatibility_block +pkg_manifest::tests::parses_build_toml_with_direct_url +pkg_manifest::tests::parses_build_toml_with_indexed_binary +pkg_manifest::tests::parses_host_tools_with_defaults +pkg_manifest::tests::parses_manifest_with_kind_library +pkg_manifest::tests::parses_minimal_manifest +pkg_manifest::tests::parses_minimal_program_manifest +pkg_manifest::tests::parses_multi_output_program_manifest +pkg_manifest::tests::parses_top_level_kernel_abi +pkg_manifest::tests::rejects_build_toml_direct_url_without_sha +pkg_manifest::tests::rejects_build_toml_with_absolute_input +pkg_manifest::tests::rejects_build_toml_with_both_indexed_and_direct +pkg_manifest::tests::rejects_build_toml_with_empty_binary +pkg_manifest::tests::rejects_build_toml_with_parent_dir_input +pkg_manifest::tests::rejects_build_toml_with_unknown_binary_field +pkg_manifest::tests::rejects_build_toml_with_unknown_top_level_field +pkg_manifest::tests::rejects_compatibility_in_source_mode +pkg_manifest::tests::rejects_duplicate_depends_on +pkg_manifest::tests::rejects_empty_spdx +pkg_manifest::tests::rejects_library_with_array_outputs +pkg_manifest::tests::rejects_manifest_without_kind +pkg_manifest::tests::rejects_program_output_with_empty_wasm +pkg_manifest::tests::rejects_program_with_no_outputs +pkg_manifest::tests::rejects_program_with_table_outputs +pkg_manifest::tests::rejects_uppercase_or_short_sha +pkg_manifest::tests::resolve_index_url_passes_through_template_without_abi_token +pkg_manifest::tests::resolve_index_url_returns_none_for_direct_source +pkg_manifest::tests::resolve_index_url_substitutes_abi_in_indexed_form +pkg_manifest::tests::source_kind_minimal_manifest_parses +pkg_manifest::tests::source_kind_rejects_binary_block +pkg_manifest::tests::source_package_toml_accepts_minimal_new_format +pkg_manifest::tests::source_package_toml_rejects_legacy_binary_block +pkg_manifest::tests::source_package_toml_rejects_legacy_build_commit +pkg_manifest::tests::source_package_toml_rejects_legacy_build_repo_url +pkg_manifest::tests::source_package_toml_rejects_legacy_revision_field +pkg_manifest::tests::source_parse_accepts_kernel_abi_absent_when_no_build_block +pkg_manifest::tests::source_parse_rejects_missing_kernel_abi_when_build_block_present +pkg_manifest::tests::target_arch_as_str_is_stable +pkg_manifest::tests::version_constraint_accepts_two_and_three_component +pkg_manifest::tests::version_constraint_compares_numerically_not_lexicographically +pkg_manifest::tests::version_constraint_rejects_compound +pkg_manifest::tests::version_constraint_rejects_other_operators +pkg_manifest::tests::version_constraint_rejects_prerelease_suffix +pkg_manifest::tests::version_eq_and_ord_agree_on_patch_none_zero +remote_fetch::tests::extract_tar_zst_round_trips +remote_fetch::tests::fetch_archive_reads_file_scheme_to_temp_file +remote_fetch::tests::fetch_archive_restarts_when_range_is_ignored +remote_fetch::tests::fetch_archive_resumes_after_midstream_failure_with_ranges +remote_fetch::tests::fetch_archive_sha_mismatch_fails_and_removes_temp_file +remote_fetch::tests::fetch_archive_streams_http_to_temp_file +remote_fetch::tests::fetch_http_url_retries_transient_5xx +remote_fetch::tests::fetch_url_reads_file_scheme +remote_fetch::tests::fetch_url_rejects_unsupported_scheme +remote_fetch::tests::fetch_url_returns_error_for_missing_file +remote_fetch::tests::flatten_archive_layout_hoists_artifacts +remote_fetch::tests::offline_env_var_blocks_fetch +remote_fetch::tests::verify_sha_accepts_matching_digest +remote_fetch::tests::verify_sha_rejects_mismatched_digest +source_extract::tests::extract_preserves_multiple_top_level_entries +source_extract::tests::extract_tar_gz_strips_single_top_level_dir +source_extract::tests::extract_tar_zst_round_trips +source_extract::tests::fetch_and_extract_via_file_url_succeeds +source_extract::tests::from_url_detects_known_extensions +source_extract::tests::from_url_handles_query_string_and_fragment +source_extract::tests::from_url_rejects_unknown_extension +update_pkg_manifest::tests::idempotent_no_op_when_commit_already_matches +update_pkg_manifest::tests::idempotent_when_values_already_match +update_pkg_manifest::tests::overwrites_existing_commit_on_rebase +update_pkg_manifest::tests::rejects_bad_arch +update_pkg_manifest::tests::rejects_bad_sha +update_pkg_manifest::tests::rejects_empty_commit +update_pkg_manifest::tests::rejects_empty_url +update_pkg_manifest::tests::rejects_mixed_shape_binary +update_pkg_manifest::tests::rejects_wasm64_against_bare_binary +update_pkg_manifest::tests::rejects_wasm64_against_bare_binary_even_when_arches_present +update_pkg_manifest::tests::rejects_whitespace_in_commit +update_pkg_manifest::tests::shape_from_bare_binary_overrides_arches_present +update_pkg_manifest::tests::shape_from_bare_binary_with_single_arches_entry +update_pkg_manifest::tests::shape_from_per_arch_binary_overrides_arches_absent +update_pkg_manifest::tests::skips_silently_when_build_block_absent +update_pkg_manifest::tests::updates_both_arches_independently +update_pkg_manifest::tests::updates_one_arch_in_multi_arch_block +update_pkg_manifest::tests::updates_single_arch_bare_binary_block +update_pkg_manifest::tests::writes_commit_when_build_block_present +util::tests::hex_empty_is_empty +util::tests::hex_encodes_known_bytes +util::tests::hex_length_is_double_input diff --git a/test-runs/kd-872c/after-rebase-abi16.log b/test-runs/kd-872c/after-rebase-abi16.log new file mode 100644 index 0000000000..acc81f84f5 --- /dev/null +++ b/test-runs/kd-872c/after-rebase-abi16.log @@ -0,0 +1,333 @@ +kandelo dev shell — LLVM 21.1.7, Rust (pinned via rust-toolchain.toml), Node 24, Erlang 28 (minimal), SDK on PATH + Compiling bzip2-sys v0.1.13+1.0.8 + Compiling lzma-sys v0.1.20 + Compiling tar v0.4.46 + Compiling wasm-posix-shared v0.1.0 (/Users/brandon/src/kandelo-gascity/worktrees/kandelo/kd-872c-fix-7-stale-xtask-test-fixtures-assertions-and-gate-carg/crates/shared) + Compiling bzip2 v0.4.4 + Compiling xz2 v0.1.7 + Compiling xtask v0.1.0 (/Users/brandon/src/kandelo-gascity/worktrees/kandelo/kd-872c-fix-7-stale-xtask-test-fixtures-assertions-and-gate-carg/tools/xtask) + Finished `test` profile [unoptimized + debuginfo] target(s) in 5.71s + Running unittests src/main.rs (target/aarch64-apple-darwin/debug/deps/xtask-e46919a9d0f50bd0) + +running 315 tests +test archive_stage::tests::embedded_manifest_round_trips_through_parse_archived ... ok +test archive_stage::tests::produces_archive_consumable_by_remote_fetch ... ok +test archive_stage::tests::produces_byte_identical_archive_on_repeat_invocation ... ok +test archive_stage::tests::rejects_empty_cache_dir ... ok +test archive_stage::tests::rejects_source_kind ... ok +test archive_stage::tests::rejects_when_cache_entry_is_missing ... ok +test archive_stage_cli::tests::cli_archive_filename_uses_build_toml_revision ... ok +test archive_stage_cli::tests::cli_binaries_dir_materializes_program_dependency_symlink ... ok +test archive_stage_cli::tests::cli_is_byte_deterministic_across_invocations ... ok +test archive_stage_cli::tests::cli_produces_archive_with_canonical_filename ... ok +test archive_stage_cli::tests::cli_rejects_arch_not_in_target_arches ... ok +test archive_stage_cli::tests::cli_rejects_cache_key_input_mutation_during_build ... ok +test archive_stage_cli::tests::cli_rejects_expected_cache_key_mismatch_before_build ... ok +test archive_stage_cli::tests::cli_rejects_source_kind_with_clear_error ... ok +test archive_stage_cli::tests::cli_requires_all_mandatory_flags ... ok +test build_deps::tests::binaries_dir_program_fetch_does_not_require_built_deps ... ok +test build_deps::tests::build_deps_check_flags_inconsistent_constraint ... ok +test build_deps::tests::build_deps_check_flags_inconsistent_probe ... ok +test build_deps::tests::build_deps_check_passes_on_consistent_registry ... ok +test build_deps::tests::build_fails_when_program_wasm_output_missing ... ok +test build_deps::tests::build_into_cache_stderr_dup_pattern_does_not_panic ... running +ok +test build_deps::tests::build_script_sees_target_arch_env ... ok +test build_deps::tests::build_script_stdout_redirect_to_owned_fd_works ... ok +test build_deps::tests::build_validates_program_wasm_outputs_present ... ok +test build_deps::tests::cache_key_sha_changes_when_library_output_header_added ... ok +test build_deps::tests::cache_key_sha_changes_when_library_output_lib_filename_changes ... ok +test build_deps::tests::cache_key_sha_changes_when_library_output_pkgconfig_added ... ok +test build_deps::tests::cache_key_sha_changes_when_program_output_added ... ok +test build_deps::tests::cache_key_sha_changes_when_program_output_fork_policy_changes ... ok +test build_deps::tests::cache_key_sha_changes_when_program_output_name_changes ... ok +test build_deps::tests::cache_key_sha_changes_when_program_output_wasm_filename_changes ... ok +test build_deps::tests::cache_key_sha_changes_when_program_outputs_reordered ... ok +test build_deps::tests::cache_key_sha_changes_with_abi_version ... ok +test build_deps::tests::cache_key_sha_changes_with_target_arch ... ok +test build_deps::tests::canonical_path_layout ... ok +test build_deps::tests::canonical_path_uses_programs_subdir_for_program_kind ... ok +test build_deps::tests::cmd_resolve_with_binaries_dir_places_kernel_at_root ... ok +test build_deps::tests::cmd_resolve_with_binaries_dir_places_multi_output_symlinks ... ok +test build_deps::tests::cmd_resolve_with_binaries_dir_places_single_output_symlink ... ok +test build_deps::tests::cmd_resolve_with_binaries_dir_replaces_existing_link ... ok +test build_deps::tests::cmd_resolve_without_binaries_dir_places_no_symlinks ... ok +test build_deps::tests::compute_cache_key_sha_args_parse_equals_form ... ok +test build_deps::tests::compute_cache_key_sha_args_parse_long_form ... ok +test build_deps::tests::compute_cache_key_sha_args_reject_missing_arch ... ok +test build_deps::tests::compute_cache_key_sha_args_reject_missing_package ... ok +test build_deps::tests::compute_cache_key_sha_args_reject_unknown_flag ... ok +test build_deps::tests::compute_cache_key_sha_changes_on_input_change ... ok +test build_deps::tests::compute_cache_key_sha_is_deterministic_across_invocations ... ok +test build_deps::tests::compute_cache_key_sha_rejects_missing_build_toml_input ... ok +test build_deps::tests::compute_cache_key_sha_subcommand_prints_64_hex_for_real_package ... ok +test build_deps::tests::compute_cache_key_sha_uses_build_toml_inputs ... ok +test build_deps::tests::compute_cache_key_sha_uses_build_toml_revision ... ok +test build_deps::tests::compute_sha_detects_cycle ... ok +test build_deps::tests::compute_sha_is_deterministic ... ok +test build_deps::tests::compute_sha_rejects_version_mismatch ... ok +test build_deps::tests::current_abi_version_matches_shared_crate ... ok +test build_deps::tests::direct_pr_overlay_fetch_installs_archive_before_build_toml_index ... ok +test build_deps::tests::ensure_built_cache_hit_skips_host_tool_probes ... ok +test build_deps::tests::ensure_built_cache_miss_aborts_when_host_tool_missing ... ok +test build_deps::tests::ensure_built_fails_when_declared_output_missing ... ok +test build_deps::tests::ensure_built_fails_when_script_exits_nonzero ... boom +ok +test build_deps::tests::ensure_built_is_idempotent_on_cache_hit ... ok +test build_deps::tests::ensure_built_runs_script_on_cache_miss ... ok +test build_deps::tests::ensure_built_source_kind_fetches_and_extracts_via_file_url ... ok +test build_deps::tests::ensure_built_source_kind_script_must_populate_out_dir ... ok +test build_deps::tests::ensure_built_source_kind_with_build_script_runs_it ... ok +test build_deps::tests::env_key_canonicalises_hyphens_and_case ... ok +test build_deps::tests::extract_binaries_dir_flag_absent ... ok +test build_deps::tests::extract_binaries_dir_flag_equals_form ... ok +test build_deps::tests::extract_binaries_dir_flag_rejects_duplicate ... ok +test build_deps::tests::extract_binaries_dir_flag_separated_form ... ok +test build_deps::tests::extract_fetch_only_flag_removes_flag ... ok +test build_deps::tests::fetch_only_rejects_missing_index_entry_without_source_build ... ok +test build_deps::tests::force_rebuild_bypasses_index_fetch ... ok +test build_deps::tests::force_rebuild_only_affects_named_packages ... ok +test build_deps::tests::force_rebuild_runs_build_script_on_cache_hit ... ok +test build_deps::tests::fork_instrument_cargo_dependency_digest_ignores_unrelated_lockfile_entries ... ok +test build_deps::tests::fork_instrument_tool_inputs_apply_only_to_programs_that_use_them ... ok +test build_deps::tests::fork_instrument_tool_inputs_hash_dependency_closure_instead_of_whole_lockfile ... ok +test build_deps::tests::global_package_build_input_digests_change_with_content ... ok +test build_deps::tests::global_package_build_input_digests_reject_missing_input ... ok +test build_deps::tests::global_package_toolchain_inputs_include_package_build_actions ... ok +test build_deps::tests::index_fetch_falls_through_on_abi_mismatch ... ok +test build_deps::tests::index_fetch_falls_through_on_archive_sha_mismatch ... ok +test build_deps::tests::index_fetch_falls_through_on_cache_key_mismatch ... ok +test build_deps::tests::index_fetch_falls_through_on_index_toml_abi_mismatch ... ok +test build_deps::tests::index_fetch_falls_through_on_target_arch_mismatch ... ok +test build_deps::tests::index_fetch_installs_archive_when_sha_arch_abi_cachekey_all_match ... ok +test build_deps::tests::libtool_archive_libdir_is_rewritten_to_canonical_path ... ok +test build_deps::tests::local_libs_override_wins ... ok +test build_deps::tests::output_fork_instrumentation_for_rel_is_arch_neutral ... ok +test build_deps::tests::parse_target_arch_accepts_known_values ... ok +test build_deps::tests::parse_target_arch_rejects_unknown_values ... ok +test build_deps::tests::pkg_config_path_includes_transitive_lib_pkgconfig ... ok +test build_deps::tests::pkg_config_path_skips_libs_without_pkgconfig_dir ... ok +test build_deps::tests::pkgconfig_prefix_is_rewritten_to_canonical_path ... ok +test build_deps::tests::pkgconfig_symlinks_survive_the_rewrite ... ok +test build_deps::tests::program_output_validation_accepts_disabled_fork_instrumentation_policy ... ok +test build_deps::tests::program_output_validation_accepts_kernel_host_adapter_exports ... ok +test build_deps::tests::program_output_validation_accepts_relocatable_fork_objects ... ok +test build_deps::tests::program_output_validation_rejects_executable_without_entrypoint_exports ... ok +test build_deps::tests::program_output_validation_rejects_fork_without_wpk_exports ... ok +test build_deps::tests::program_output_validation_rejects_kernel_missing_host_adapter_exports ... ok +test build_deps::tests::program_output_validation_rejects_legacy_asyncify_wasm ... ok +test build_deps::tests::program_output_validation_rejects_wpk_exports_when_policy_disabled ... ok +test build_deps::tests::programs_by_name_filters_to_program_kind ... ok +test build_deps::tests::registry_find_falls_through_to_second_root ... ok +test build_deps::tests::registry_find_returns_first_hit ... ok +test build_deps::tests::render_probe_failures_uses_darwin_alias_for_macos ... ok +test build_deps::tests::resolve_with_arch_wasm64_uses_different_cache_path ... ok +test build_deps::tests::source_kind_canonical_path_omits_arch ... ok +test build_deps::tests::source_kind_direct_dep_exports_src_dir_env_var ... ok +test build_deps::tests::source_kind_sha_omits_arch_and_abi_inputs ... ok +test build_deps::tests::source_kind_sha_uses_distinct_domain ... ok +test build_deps::tests::transitive_deps_are_built_and_exposed_via_env ... ok +test build_deps::tests::walk_all_finds_libraries_and_programs ... ok +test build_deps::tests::walk_all_first_root_wins_for_duplicate_names ... ok +test build_deps::tests::walk_all_handles_missing_registry_root ... ok +test build_deps::tests::wasm_artifact_policy_rejects_empty_and_exportless_when_exports_required ... ok +test build_index::tests::abi_mismatch_in_filename_is_rejected ... ok +test build_index::tests::determinism_byte_identical_on_repeat_invocation ... ok +test build_index::tests::divergent_version_across_arches_is_rejected ... ok +test build_index::tests::empty_input_produces_valid_header_only_toml ... ok +test build_index::tests::filename_parser_handles_multi_segment_names ... ok +test build_index::tests::filename_parser_rejects_malformed_inputs ... ok +test build_index::tests::missing_arch_only_emits_present_block ... ok +test build_index::tests::smoke_two_packages_two_arches ... ok +test dump_abi::tests::adding_host_adapter_section_is_compatible ... ok +test dump_abi::tests::adding_syscall_arg_descriptor_section_is_compatible ... ok +test dump_abi::tests::additive_syscall_export_and_struct_are_compatible ... ok +test dump_abi::tests::changed_channel_layout_is_breaking ... ok +test dump_abi::tests::changed_existing_export_is_breaking ... ok +test dump_abi::tests::changed_syscall_arg_descriptor_is_breaking ... ok +test dump_abi::tests::renamed_syscall_number_is_breaking ... ok +test dump_abi::tests::syscall_log_names_match_existing_trace_spelling ... ok +test homebrew_schema::tests::homebrew_examples_validate_against_schemas ... ok +test homebrew_schema::tests::homebrew_metadata_rejects_arch_tag_mismatch ... ok +test homebrew_schema::tests::homebrew_metadata_rejects_browser_claim_without_browser_runtime ... ok +test homebrew_schema::tests::link_manifest_rejects_absolute_link_targets ... ok +test homebrew_schema::tests::link_manifest_rejects_malformed_bottle_sha ... ok +test homebrew_schema::tests::scaffold_paths_exist_for_semantic_validator_handoff ... ok +test homebrew_sidecars::tests::failed_generation_carries_last_green_fallback ... ok +test homebrew_sidecars::tests::success_generation_hashes_bottle_bytes_and_sidecars ... ok +test homebrew_validate::tests::command_entrypoint_validates_live_tap_fixture ... ok +test homebrew_validate::tests::rejects_dotdot_link_path ... ok +test homebrew_validate::tests::rejects_duplicate_link_targets ... ok +test homebrew_validate::tests::rejects_formula_sha_mismatch ... ok +test homebrew_validate::tests::rejects_formula_sidecar_drift ... ok +test homebrew_validate::tests::rejects_link_manifest_bottle_sha_drift ... ok +test homebrew_validate::tests::rejects_missing_dependency_closure ... ok +test homebrew_validate::tests::rejects_release_abi_mismatch ... ok +test homebrew_validate::tests::validates_live_tap_fixture ... ok +test host_tool_probe::tests::probe_compares_numerically_3_20_satisfies_3_9 ... ok +test host_tool_probe::tests::probe_passes_when_version_meets_constraint ... ok +test host_tool_probe::tests::probe_rejects_old_version ... ok +test host_tool_probe::tests::probe_reports_bad_output_when_regex_does_not_match ... ok +test host_tool_probe::tests::probe_reports_missing_when_not_in_path ... ok +test index_toml::tests::archive_filename_abi_extracts_abi_segment ... ok +test index_toml::tests::fetch_index_errors_when_no_cache_and_offline ... ok +test index_toml::tests::fetch_index_falls_back_to_cache_when_offline ... ok +test index_toml::tests::fetch_index_reads_file_url_and_writes_cache ... ok +test index_toml::tests::index_cache_path_distinguishes_urls ... ok +test index_toml::tests::index_toml_prunes_archive_entries_for_other_abis ... ok +test index_toml::tests::index_toml_round_trips_semantic_equality ... ok +test index_toml::tests::index_toml_validate_archive_abi_versions_rejects_current_mismatch ... ok +test index_toml::tests::index_toml_validate_archive_abi_versions_rejects_fallback_mismatch ... ok +test index_toml::tests::index_toml_write_omits_none_fields ... ok +test index_toml::tests::index_toml_write_sorts_packages_alphabetically ... ok +test index_toml::tests::parses_index_toml_with_failed_entry_and_fallback ... ok +test index_toml::tests::parses_index_toml_with_success_entry ... ok +test index_toml::tests::update_entry_failed_moves_current_to_fallback ... ok +test index_toml::tests::update_entry_failed_preserves_existing_fallback ... ok +test index_toml::tests::update_entry_failed_with_no_prior_success_has_no_fallback ... ok +test index_toml::tests::update_entry_success_after_failed_clears_fallback ... ok +test index_toml::tests::update_entry_success_overwrites_current_and_clears_fallback ... ok +test index_toml::tests::update_entry_success_refreshes_existing_package_revision ... ok +test index_update::tests::index_update_failed_moves_existing_success_to_fallback ... ok +test index_update::tests::index_update_preserves_durable_index_toml_abi_from_expected_tag ... ok +test index_update::tests::index_update_rejects_archive_cache_key_mismatch ... ok +test index_update::tests::index_update_rejects_archive_path_name_mismatch ... ok +test index_update::tests::index_update_rejects_missing_required_flag ... ok +test index_update::tests::index_update_rejects_mixed_index_toml_archive_abis ... ok +test index_update::tests::index_update_rejects_unknown_flag ... ok +test index_update::tests::index_update_repair_rewrites_stale_index_toml_abi ... ok +test index_update::tests::index_update_rewrites_stale_index_toml_abi_and_prunes_old_entries ... ok +test index_update::tests::index_update_success_writes_entry_to_index ... ok +test package_matrix::tests::dependency_artifacts_reports_only_selected_direct_dependencies ... ok +test package_matrix::tests::sort_matrix_orders_selected_program_dependencies_first ... ok +test pkg_manifest::tests::archived_parse_accepts_missing_kernel_abi ... ok +test pkg_manifest::tests::archived_parses_bare_binary_block_as_wasm32 ... ok +test pkg_manifest::tests::archived_parses_per_arch_binary_block ... ok +test pkg_manifest::tests::archived_rejects_invalid_binary_archive_sha ... ok +test pkg_manifest::tests::archived_rejects_long_binary_archive_sha ... ok +test pkg_manifest::tests::archived_rejects_mixed_binary_shape ... ok +test pkg_manifest::tests::archived_rejects_short_binary_archive_sha ... ok +test pkg_manifest::tests::archived_rejects_unknown_binary_key ... ok +test pkg_manifest::tests::archived_rejects_uppercase_binary_archive_sha ... ok +test pkg_manifest::tests::build_rejects_legacy_script_field ... ok +test pkg_manifest::tests::build_script_override_is_repo_root_relative ... ok +test pkg_manifest::tests::depends_on_parsed_into_deprefs ... ok +test pkg_manifest::tests::depref_parse_basic ... ok +test pkg_manifest::tests::depref_rejects_empty_fields ... ok +test pkg_manifest::tests::depref_rejects_missing_at ... ok +test pkg_manifest::tests::host_tools_allowed_on_source_kind ... ok +test pkg_manifest::tests::host_tools_reject_duplicate_names_in_same_manifest ... ok +test pkg_manifest::tests::host_tools_reject_empty_probe_args ... ok +test pkg_manifest::tests::host_tools_reject_invalid_probe_regex ... ok +test pkg_manifest::tests::kernel_abi_is_optional ... ok +test pkg_manifest::tests::output_dest_rel_library_kind_errors ... ok +test pkg_manifest::tests::output_dest_rel_multi_output_uses_program_subdir ... ok +test pkg_manifest::tests::output_dest_rel_single_output_program_name_matches_output_name ... ok +test pkg_manifest::tests::output_dest_rel_single_output_with_diverging_name_uses_output_name ... ok +test pkg_manifest::tests::output_dest_rel_unknown_basename_errors ... ok +test pkg_manifest::tests::output_fork_instrumentation_can_be_disabled ... ok +test pkg_manifest::tests::output_fork_instrumentation_defaults_to_auto ... ok +test pkg_manifest::tests::overlay_absent_uses_base ... ok +test pkg_manifest::tests::overlay_merges_binary_block_over_base ... ok +test pkg_manifest::tests::overlay_merges_multiple_arches_from_overlay ... ok +test pkg_manifest::tests::overlay_with_non_binary_field_is_rejected ... ok +test pkg_manifest::tests::parse_accepts_no_binary_block ... ok +test pkg_manifest::tests::parse_archived_accepts_full_compatibility_block ... ok +test pkg_manifest::tests::parse_archived_accepts_legacy_script_field ... ok +test pkg_manifest::tests::parse_archived_accepts_repo_url_and_commit ... ok +test pkg_manifest::tests::parse_archived_accepts_script_path_field ... ok +test pkg_manifest::tests::parse_archived_rejects_empty_abi_versions ... ok +test pkg_manifest::tests::parse_archived_rejects_short_cache_key_sha ... ok +test pkg_manifest::tests::parse_archived_rejects_uppercase_cache_key_sha ... ok +test pkg_manifest::tests::parse_archived_rejects_zero_revision ... ok +test pkg_manifest::tests::parse_archived_requires_compatibility_block ... ok +test pkg_manifest::tests::parses_build_toml_with_direct_url ... ok +test pkg_manifest::tests::parses_build_toml_with_indexed_binary ... ok +test pkg_manifest::tests::parses_host_tools_with_defaults ... ok +test pkg_manifest::tests::parses_manifest_with_kind_library ... ok +test pkg_manifest::tests::parses_minimal_manifest ... ok +test pkg_manifest::tests::parses_minimal_program_manifest ... ok +test pkg_manifest::tests::parses_multi_output_program_manifest ... ok +test pkg_manifest::tests::parses_top_level_kernel_abi ... ok +test pkg_manifest::tests::rejects_build_toml_direct_url_without_sha ... ok +test pkg_manifest::tests::rejects_build_toml_with_absolute_input ... ok +test pkg_manifest::tests::rejects_build_toml_with_both_indexed_and_direct ... ok +test pkg_manifest::tests::rejects_build_toml_with_empty_binary ... ok +test pkg_manifest::tests::rejects_build_toml_with_parent_dir_input ... ok +test pkg_manifest::tests::rejects_build_toml_with_unknown_binary_field ... ok +test pkg_manifest::tests::rejects_build_toml_with_unknown_top_level_field ... ok +test pkg_manifest::tests::rejects_compatibility_in_source_mode ... ok +test pkg_manifest::tests::rejects_duplicate_depends_on ... ok +test pkg_manifest::tests::rejects_empty_spdx ... ok +test pkg_manifest::tests::rejects_library_with_array_outputs ... ok +test pkg_manifest::tests::rejects_manifest_without_kind ... ok +test pkg_manifest::tests::rejects_program_output_with_empty_wasm ... ok +test pkg_manifest::tests::rejects_program_with_no_outputs ... ok +test pkg_manifest::tests::rejects_program_with_table_outputs ... ok +test pkg_manifest::tests::rejects_uppercase_or_short_sha ... ok +test pkg_manifest::tests::resolve_index_url_passes_through_template_without_abi_token ... ok +test pkg_manifest::tests::resolve_index_url_returns_none_for_direct_source ... ok +test pkg_manifest::tests::resolve_index_url_substitutes_abi_in_indexed_form ... ok +test pkg_manifest::tests::source_kind_minimal_manifest_parses ... ok +test pkg_manifest::tests::source_kind_rejects_binary_block ... ok +test pkg_manifest::tests::source_package_toml_accepts_minimal_new_format ... ok +test pkg_manifest::tests::source_package_toml_rejects_legacy_binary_block ... ok +test pkg_manifest::tests::source_package_toml_rejects_legacy_build_commit ... ok +test pkg_manifest::tests::source_package_toml_rejects_legacy_build_repo_url ... ok +test pkg_manifest::tests::source_package_toml_rejects_legacy_revision_field ... ok +test pkg_manifest::tests::source_parse_accepts_kernel_abi_absent_when_no_build_block ... ok +test pkg_manifest::tests::source_parse_rejects_missing_kernel_abi_when_build_block_present ... ok +test pkg_manifest::tests::target_arch_as_str_is_stable ... ok +test pkg_manifest::tests::version_constraint_accepts_two_and_three_component ... ok +test pkg_manifest::tests::version_constraint_compares_numerically_not_lexicographically ... ok +test pkg_manifest::tests::version_constraint_rejects_compound ... ok +test pkg_manifest::tests::version_constraint_rejects_other_operators ... ok +test pkg_manifest::tests::version_constraint_rejects_prerelease_suffix ... ok +test pkg_manifest::tests::version_eq_and_ord_agree_on_patch_none_zero ... ok +test remote_fetch::tests::extract_tar_zst_round_trips ... ok +test remote_fetch::tests::fetch_archive_reads_file_scheme_to_temp_file ... ok +test remote_fetch::tests::fetch_archive_restarts_when_range_is_ignored ... ok +test remote_fetch::tests::fetch_archive_resumes_after_midstream_failure_with_ranges ... ok +test remote_fetch::tests::fetch_archive_sha_mismatch_fails_and_removes_temp_file ... ok +test remote_fetch::tests::fetch_archive_streams_http_to_temp_file ... ok +test remote_fetch::tests::fetch_http_url_retries_transient_5xx ... ok +test remote_fetch::tests::fetch_url_reads_file_scheme ... ok +test remote_fetch::tests::fetch_url_rejects_unsupported_scheme ... ok +test remote_fetch::tests::fetch_url_returns_error_for_missing_file ... ok +test remote_fetch::tests::flatten_archive_layout_hoists_artifacts ... ok +test remote_fetch::tests::offline_env_var_blocks_fetch ... ok +test remote_fetch::tests::verify_sha_accepts_matching_digest ... ok +test remote_fetch::tests::verify_sha_rejects_mismatched_digest ... ok +test source_extract::tests::extract_preserves_multiple_top_level_entries ... ok +test source_extract::tests::extract_tar_gz_strips_single_top_level_dir ... ok +test source_extract::tests::extract_tar_zst_round_trips ... ok +test source_extract::tests::fetch_and_extract_via_file_url_succeeds ... ok +test source_extract::tests::from_url_detects_known_extensions ... ok +test source_extract::tests::from_url_handles_query_string_and_fragment ... ok +test source_extract::tests::from_url_rejects_unknown_extension ... ok +test update_pkg_manifest::tests::idempotent_no_op_when_commit_already_matches ... ok +test update_pkg_manifest::tests::idempotent_when_values_already_match ... ok +test update_pkg_manifest::tests::overwrites_existing_commit_on_rebase ... ok +test update_pkg_manifest::tests::rejects_bad_arch ... ok +test update_pkg_manifest::tests::rejects_bad_sha ... ok +test update_pkg_manifest::tests::rejects_empty_commit ... ok +test update_pkg_manifest::tests::rejects_empty_url ... ok +test update_pkg_manifest::tests::rejects_mixed_shape_binary ... ok +test update_pkg_manifest::tests::rejects_wasm64_against_bare_binary ... ok +test update_pkg_manifest::tests::rejects_wasm64_against_bare_binary_even_when_arches_present ... ok +test update_pkg_manifest::tests::rejects_whitespace_in_commit ... ok +test update_pkg_manifest::tests::shape_from_bare_binary_overrides_arches_present ... ok +test update_pkg_manifest::tests::shape_from_bare_binary_with_single_arches_entry ... ok +test update_pkg_manifest::tests::shape_from_per_arch_binary_overrides_arches_absent ... ok +test update_pkg_manifest::tests::skips_silently_when_build_block_absent ... ok +test update_pkg_manifest::tests::updates_both_arches_independently ... ok +test update_pkg_manifest::tests::updates_one_arch_in_multi_arch_block ... ok +test update_pkg_manifest::tests::updates_single_arch_bare_binary_block ... ok +test update_pkg_manifest::tests::writes_commit_when_build_block_present ... ok +test util::tests::hex_empty_is_empty ... ok +test util::tests::hex_encodes_known_bytes ... ok +test util::tests::hex_length_is_double_input ... ok + +test result: ok. 315 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 16.28s + +EXIT=0 diff --git a/test-runs/kd-872c/after-serial.log b/test-runs/kd-872c/after-serial.log new file mode 100644 index 0000000000..9dc0f27f5f --- /dev/null +++ b/test-runs/kd-872c/after-serial.log @@ -0,0 +1,311 @@ +warning: Git tree '/Users/brandon/src/kandelo-gascity/worktrees/kandelo/kd-872c-fix-7-stale-xtask-test-fixtures-assertions-and-gate-carg' has uncommitted changes +kandelo dev shell — LLVM 21.1.7, Rust (pinned via rust-toolchain.toml), Node 24, Erlang 28 (minimal), SDK on PATH + Compiling xtask v0.1.0 (/Users/brandon/src/kandelo-gascity/worktrees/kandelo/kd-872c-fix-7-stale-xtask-test-fixtures-assertions-and-gate-carg/tools/xtask) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.95s + Running unittests src/main.rs (target/aarch64-apple-darwin/debug/deps/xtask-f88e9604e66db803) + +running 298 tests +test archive_stage::tests::embedded_manifest_round_trips_through_parse_archived ... ok +test archive_stage::tests::produces_archive_consumable_by_remote_fetch ... ok +test archive_stage::tests::produces_byte_identical_archive_on_repeat_invocation ... ok +test archive_stage::tests::rejects_empty_cache_dir ... ok +test archive_stage::tests::rejects_source_kind ... ok +test archive_stage::tests::rejects_when_cache_entry_is_missing ... ok +test archive_stage_cli::tests::cli_archive_filename_uses_build_toml_revision ... ok +test archive_stage_cli::tests::cli_binaries_dir_materializes_program_dependency_symlink ... ok +test archive_stage_cli::tests::cli_is_byte_deterministic_across_invocations ... ok +test archive_stage_cli::tests::cli_produces_archive_with_canonical_filename ... ok +test archive_stage_cli::tests::cli_rejects_arch_not_in_target_arches ... ok +test archive_stage_cli::tests::cli_rejects_cache_key_input_mutation_during_build ... ok +test archive_stage_cli::tests::cli_rejects_expected_cache_key_mismatch_before_build ... ok +test archive_stage_cli::tests::cli_rejects_source_kind_with_clear_error ... ok +test archive_stage_cli::tests::cli_requires_all_mandatory_flags ... ok +test build_deps::tests::binaries_dir_program_fetch_does_not_require_built_deps ... ok +test build_deps::tests::build_deps_check_flags_inconsistent_constraint ... ok +test build_deps::tests::build_deps_check_flags_inconsistent_probe ... ok +test build_deps::tests::build_deps_check_passes_on_consistent_registry ... ok +test build_deps::tests::build_fails_when_program_wasm_output_missing ... ok +test build_deps::tests::build_into_cache_stderr_dup_pattern_does_not_panic ... running +ok +test build_deps::tests::build_script_sees_target_arch_env ... ok +test build_deps::tests::build_script_stdout_redirect_to_owned_fd_works ... ok +test build_deps::tests::build_validates_program_wasm_outputs_present ... ok +test build_deps::tests::cache_key_sha_changes_when_library_output_header_added ... ok +test build_deps::tests::cache_key_sha_changes_when_library_output_lib_filename_changes ... ok +test build_deps::tests::cache_key_sha_changes_when_library_output_pkgconfig_added ... ok +test build_deps::tests::cache_key_sha_changes_when_program_output_added ... ok +test build_deps::tests::cache_key_sha_changes_when_program_output_fork_policy_changes ... ok +test build_deps::tests::cache_key_sha_changes_when_program_output_name_changes ... ok +test build_deps::tests::cache_key_sha_changes_when_program_output_wasm_filename_changes ... ok +test build_deps::tests::cache_key_sha_changes_when_program_outputs_reordered ... ok +test build_deps::tests::cache_key_sha_changes_with_abi_version ... ok +test build_deps::tests::cache_key_sha_changes_with_target_arch ... ok +test build_deps::tests::canonical_path_layout ... ok +test build_deps::tests::canonical_path_uses_programs_subdir_for_program_kind ... ok +test build_deps::tests::cmd_resolve_with_binaries_dir_places_kernel_at_root ... ok +test build_deps::tests::cmd_resolve_with_binaries_dir_places_multi_output_symlinks ... ok +test build_deps::tests::cmd_resolve_with_binaries_dir_places_single_output_symlink ... ok +test build_deps::tests::cmd_resolve_with_binaries_dir_replaces_existing_link ... ok +test build_deps::tests::cmd_resolve_without_binaries_dir_places_no_symlinks ... ok +test build_deps::tests::compute_cache_key_sha_args_parse_equals_form ... ok +test build_deps::tests::compute_cache_key_sha_args_parse_long_form ... ok +test build_deps::tests::compute_cache_key_sha_args_reject_missing_arch ... ok +test build_deps::tests::compute_cache_key_sha_args_reject_missing_package ... ok +test build_deps::tests::compute_cache_key_sha_args_reject_unknown_flag ... ok +test build_deps::tests::compute_cache_key_sha_changes_on_input_change ... ok +test build_deps::tests::compute_cache_key_sha_is_deterministic_across_invocations ... ok +test build_deps::tests::compute_cache_key_sha_rejects_missing_build_toml_input ... ok +test build_deps::tests::compute_cache_key_sha_subcommand_prints_64_hex_for_real_package ... ok +test build_deps::tests::compute_cache_key_sha_uses_build_toml_inputs ... ok +test build_deps::tests::compute_cache_key_sha_uses_build_toml_revision ... ok +test build_deps::tests::compute_sha_detects_cycle ... ok +test build_deps::tests::compute_sha_is_deterministic ... ok +test build_deps::tests::compute_sha_rejects_version_mismatch ... ok +test build_deps::tests::current_abi_version_matches_shared_crate ... ok +test build_deps::tests::direct_pr_overlay_fetch_installs_archive_before_build_toml_index ... ok +test build_deps::tests::ensure_built_cache_hit_skips_host_tool_probes ... ok +test build_deps::tests::ensure_built_cache_miss_aborts_when_host_tool_missing ... ok +test build_deps::tests::ensure_built_fails_when_declared_output_missing ... ok +test build_deps::tests::ensure_built_fails_when_script_exits_nonzero ... boom +ok +test build_deps::tests::ensure_built_is_idempotent_on_cache_hit ... ok +test build_deps::tests::ensure_built_runs_script_on_cache_miss ... ok +test build_deps::tests::ensure_built_source_kind_fetches_and_extracts_via_file_url ... ok +test build_deps::tests::ensure_built_source_kind_script_must_populate_out_dir ... ok +test build_deps::tests::ensure_built_source_kind_with_build_script_runs_it ... ok +test build_deps::tests::env_key_canonicalises_hyphens_and_case ... ok +test build_deps::tests::extract_binaries_dir_flag_absent ... ok +test build_deps::tests::extract_binaries_dir_flag_equals_form ... ok +test build_deps::tests::extract_binaries_dir_flag_rejects_duplicate ... ok +test build_deps::tests::extract_binaries_dir_flag_separated_form ... ok +test build_deps::tests::extract_fetch_only_flag_removes_flag ... ok +test build_deps::tests::fetch_only_rejects_missing_index_entry_without_source_build ... ok +test build_deps::tests::force_rebuild_bypasses_index_fetch ... ok +test build_deps::tests::force_rebuild_only_affects_named_packages ... ok +test build_deps::tests::force_rebuild_runs_build_script_on_cache_hit ... ok +test build_deps::tests::fork_instrument_cargo_dependency_digest_ignores_unrelated_lockfile_entries ... ok +test build_deps::tests::fork_instrument_tool_inputs_apply_only_to_programs_that_use_them ... ok +test build_deps::tests::fork_instrument_tool_inputs_hash_dependency_closure_instead_of_whole_lockfile ... ok +test build_deps::tests::global_package_build_input_digests_change_with_content ... ok +test build_deps::tests::global_package_build_input_digests_reject_missing_input ... ok +test build_deps::tests::global_package_toolchain_inputs_include_package_build_actions ... ok +test build_deps::tests::index_fetch_falls_through_on_abi_mismatch ... ok +test build_deps::tests::index_fetch_falls_through_on_archive_sha_mismatch ... ok +test build_deps::tests::index_fetch_falls_through_on_cache_key_mismatch ... ok +test build_deps::tests::index_fetch_falls_through_on_index_toml_abi_mismatch ... ok +test build_deps::tests::index_fetch_falls_through_on_target_arch_mismatch ... ok +test build_deps::tests::index_fetch_installs_archive_when_sha_arch_abi_cachekey_all_match ... ok +test build_deps::tests::libtool_archive_libdir_is_rewritten_to_canonical_path ... ok +test build_deps::tests::local_libs_override_wins ... ok +test build_deps::tests::output_fork_instrumentation_for_rel_is_arch_neutral ... ok +test build_deps::tests::parse_target_arch_accepts_known_values ... ok +test build_deps::tests::parse_target_arch_rejects_unknown_values ... ok +test build_deps::tests::pkg_config_path_includes_transitive_lib_pkgconfig ... ok +test build_deps::tests::pkg_config_path_skips_libs_without_pkgconfig_dir ... ok +test build_deps::tests::pkgconfig_prefix_is_rewritten_to_canonical_path ... ok +test build_deps::tests::pkgconfig_symlinks_survive_the_rewrite ... ok +test build_deps::tests::program_output_validation_accepts_disabled_fork_instrumentation_policy ... ok +test build_deps::tests::program_output_validation_accepts_kernel_host_adapter_exports ... ok +test build_deps::tests::program_output_validation_accepts_relocatable_fork_objects ... ok +test build_deps::tests::program_output_validation_rejects_executable_without_entrypoint_exports ... ok +test build_deps::tests::program_output_validation_rejects_fork_without_wpk_exports ... ok +test build_deps::tests::program_output_validation_rejects_kernel_missing_host_adapter_exports ... ok +test build_deps::tests::program_output_validation_rejects_legacy_asyncify_wasm ... ok +test build_deps::tests::program_output_validation_rejects_wpk_exports_when_policy_disabled ... ok +test build_deps::tests::programs_by_name_filters_to_program_kind ... ok +test build_deps::tests::registry_find_falls_through_to_second_root ... ok +test build_deps::tests::registry_find_returns_first_hit ... ok +test build_deps::tests::render_probe_failures_uses_darwin_alias_for_macos ... ok +test build_deps::tests::resolve_with_arch_wasm64_uses_different_cache_path ... ok +test build_deps::tests::source_kind_canonical_path_omits_arch ... ok +test build_deps::tests::source_kind_direct_dep_exports_src_dir_env_var ... ok +test build_deps::tests::source_kind_sha_omits_arch_and_abi_inputs ... ok +test build_deps::tests::source_kind_sha_uses_distinct_domain ... ok +test build_deps::tests::transitive_deps_are_built_and_exposed_via_env ... ok +test build_deps::tests::walk_all_finds_libraries_and_programs ... ok +test build_deps::tests::walk_all_first_root_wins_for_duplicate_names ... ok +test build_deps::tests::walk_all_handles_missing_registry_root ... ok +test build_deps::tests::wasm_artifact_policy_rejects_empty_and_exportless_when_exports_required ... ok +test build_index::tests::abi_mismatch_in_filename_is_rejected ... ok +test build_index::tests::determinism_byte_identical_on_repeat_invocation ... ok +test build_index::tests::divergent_version_across_arches_is_rejected ... ok +test build_index::tests::empty_input_produces_valid_header_only_toml ... ok +test build_index::tests::filename_parser_handles_multi_segment_names ... ok +test build_index::tests::filename_parser_rejects_malformed_inputs ... ok +test build_index::tests::missing_arch_only_emits_present_block ... ok +test build_index::tests::smoke_two_packages_two_arches ... ok +test dump_abi::tests::adding_host_adapter_section_is_compatible ... ok +test dump_abi::tests::adding_syscall_arg_descriptor_section_is_compatible ... ok +test dump_abi::tests::additive_syscall_export_and_struct_are_compatible ... ok +test dump_abi::tests::changed_channel_layout_is_breaking ... ok +test dump_abi::tests::changed_existing_export_is_breaking ... ok +test dump_abi::tests::changed_syscall_arg_descriptor_is_breaking ... ok +test dump_abi::tests::renamed_syscall_number_is_breaking ... ok +test dump_abi::tests::syscall_log_names_match_existing_trace_spelling ... ok +test host_tool_probe::tests::probe_compares_numerically_3_20_satisfies_3_9 ... ok +test host_tool_probe::tests::probe_passes_when_version_meets_constraint ... ok +test host_tool_probe::tests::probe_rejects_old_version ... ok +test host_tool_probe::tests::probe_reports_bad_output_when_regex_does_not_match ... ok +test host_tool_probe::tests::probe_reports_missing_when_not_in_path ... ok +test index_toml::tests::archive_filename_abi_extracts_abi_segment ... ok +test index_toml::tests::fetch_index_errors_when_no_cache_and_offline ... ok +test index_toml::tests::fetch_index_falls_back_to_cache_when_offline ... ok +test index_toml::tests::fetch_index_reads_file_url_and_writes_cache ... ok +test index_toml::tests::index_cache_path_distinguishes_urls ... ok +test index_toml::tests::index_toml_prunes_archive_entries_for_other_abis ... ok +test index_toml::tests::index_toml_round_trips_semantic_equality ... ok +test index_toml::tests::index_toml_validate_archive_abi_versions_rejects_current_mismatch ... ok +test index_toml::tests::index_toml_validate_archive_abi_versions_rejects_fallback_mismatch ... ok +test index_toml::tests::index_toml_write_omits_none_fields ... ok +test index_toml::tests::index_toml_write_sorts_packages_alphabetically ... ok +test index_toml::tests::parses_index_toml_with_failed_entry_and_fallback ... ok +test index_toml::tests::parses_index_toml_with_success_entry ... ok +test index_toml::tests::update_entry_failed_moves_current_to_fallback ... ok +test index_toml::tests::update_entry_failed_preserves_existing_fallback ... ok +test index_toml::tests::update_entry_failed_with_no_prior_success_has_no_fallback ... ok +test index_toml::tests::update_entry_success_after_failed_clears_fallback ... ok +test index_toml::tests::update_entry_success_overwrites_current_and_clears_fallback ... ok +test index_toml::tests::update_entry_success_refreshes_existing_package_revision ... ok +test index_update::tests::index_update_failed_moves_existing_success_to_fallback ... ok +test index_update::tests::index_update_preserves_durable_index_toml_abi_from_expected_tag ... ok +test index_update::tests::index_update_rejects_archive_cache_key_mismatch ... ok +test index_update::tests::index_update_rejects_archive_path_name_mismatch ... ok +test index_update::tests::index_update_rejects_missing_required_flag ... ok +test index_update::tests::index_update_rejects_mixed_index_toml_archive_abis ... ok +test index_update::tests::index_update_rejects_unknown_flag ... ok +test index_update::tests::index_update_repair_rewrites_stale_index_toml_abi ... ok +test index_update::tests::index_update_rewrites_stale_index_toml_abi_and_prunes_old_entries ... ok +test index_update::tests::index_update_success_writes_entry_to_index ... ok +test package_matrix::tests::dependency_artifacts_reports_only_selected_direct_dependencies ... ok +test package_matrix::tests::sort_matrix_orders_selected_program_dependencies_first ... ok +test pkg_manifest::tests::archived_parse_accepts_missing_kernel_abi ... ok +test pkg_manifest::tests::archived_parses_bare_binary_block_as_wasm32 ... ok +test pkg_manifest::tests::archived_parses_per_arch_binary_block ... ok +test pkg_manifest::tests::archived_rejects_invalid_binary_archive_sha ... ok +test pkg_manifest::tests::archived_rejects_long_binary_archive_sha ... ok +test pkg_manifest::tests::archived_rejects_mixed_binary_shape ... ok +test pkg_manifest::tests::archived_rejects_short_binary_archive_sha ... ok +test pkg_manifest::tests::archived_rejects_unknown_binary_key ... ok +test pkg_manifest::tests::archived_rejects_uppercase_binary_archive_sha ... ok +test pkg_manifest::tests::build_rejects_legacy_script_field ... ok +test pkg_manifest::tests::build_script_override_is_repo_root_relative ... ok +test pkg_manifest::tests::depends_on_parsed_into_deprefs ... ok +test pkg_manifest::tests::depref_parse_basic ... ok +test pkg_manifest::tests::depref_rejects_empty_fields ... ok +test pkg_manifest::tests::depref_rejects_missing_at ... ok +test pkg_manifest::tests::host_tools_allowed_on_source_kind ... ok +test pkg_manifest::tests::host_tools_reject_duplicate_names_in_same_manifest ... ok +test pkg_manifest::tests::host_tools_reject_empty_probe_args ... ok +test pkg_manifest::tests::host_tools_reject_invalid_probe_regex ... ok +test pkg_manifest::tests::kernel_abi_is_optional ... ok +test pkg_manifest::tests::output_dest_rel_library_kind_errors ... ok +test pkg_manifest::tests::output_dest_rel_multi_output_uses_program_subdir ... ok +test pkg_manifest::tests::output_dest_rel_single_output_program_name_matches_output_name ... ok +test pkg_manifest::tests::output_dest_rel_single_output_with_diverging_name_uses_output_name ... ok +test pkg_manifest::tests::output_dest_rel_unknown_basename_errors ... ok +test pkg_manifest::tests::output_fork_instrumentation_can_be_disabled ... ok +test pkg_manifest::tests::output_fork_instrumentation_defaults_to_auto ... ok +test pkg_manifest::tests::overlay_absent_uses_base ... ok +test pkg_manifest::tests::overlay_merges_binary_block_over_base ... ok +test pkg_manifest::tests::overlay_merges_multiple_arches_from_overlay ... ok +test pkg_manifest::tests::overlay_with_non_binary_field_is_rejected ... ok +test pkg_manifest::tests::parse_accepts_no_binary_block ... ok +test pkg_manifest::tests::parse_archived_accepts_full_compatibility_block ... ok +test pkg_manifest::tests::parse_archived_accepts_legacy_script_field ... ok +test pkg_manifest::tests::parse_archived_accepts_repo_url_and_commit ... ok +test pkg_manifest::tests::parse_archived_accepts_script_path_field ... ok +test pkg_manifest::tests::parse_archived_rejects_empty_abi_versions ... ok +test pkg_manifest::tests::parse_archived_rejects_short_cache_key_sha ... ok +test pkg_manifest::tests::parse_archived_rejects_uppercase_cache_key_sha ... ok +test pkg_manifest::tests::parse_archived_rejects_zero_revision ... ok +test pkg_manifest::tests::parse_archived_requires_compatibility_block ... ok +test pkg_manifest::tests::parses_build_toml_with_direct_url ... ok +test pkg_manifest::tests::parses_build_toml_with_indexed_binary ... ok +test pkg_manifest::tests::parses_host_tools_with_defaults ... ok +test pkg_manifest::tests::parses_manifest_with_kind_library ... ok +test pkg_manifest::tests::parses_minimal_manifest ... ok +test pkg_manifest::tests::parses_minimal_program_manifest ... ok +test pkg_manifest::tests::parses_multi_output_program_manifest ... ok +test pkg_manifest::tests::parses_top_level_kernel_abi ... ok +test pkg_manifest::tests::rejects_build_toml_direct_url_without_sha ... ok +test pkg_manifest::tests::rejects_build_toml_with_absolute_input ... ok +test pkg_manifest::tests::rejects_build_toml_with_both_indexed_and_direct ... ok +test pkg_manifest::tests::rejects_build_toml_with_empty_binary ... ok +test pkg_manifest::tests::rejects_build_toml_with_parent_dir_input ... ok +test pkg_manifest::tests::rejects_build_toml_with_unknown_binary_field ... ok +test pkg_manifest::tests::rejects_build_toml_with_unknown_top_level_field ... ok +test pkg_manifest::tests::rejects_compatibility_in_source_mode ... ok +test pkg_manifest::tests::rejects_duplicate_depends_on ... ok +test pkg_manifest::tests::rejects_empty_spdx ... ok +test pkg_manifest::tests::rejects_library_with_array_outputs ... ok +test pkg_manifest::tests::rejects_manifest_without_kind ... ok +test pkg_manifest::tests::rejects_program_output_with_empty_wasm ... ok +test pkg_manifest::tests::rejects_program_with_no_outputs ... ok +test pkg_manifest::tests::rejects_program_with_table_outputs ... ok +test pkg_manifest::tests::rejects_uppercase_or_short_sha ... ok +test pkg_manifest::tests::resolve_index_url_passes_through_template_without_abi_token ... ok +test pkg_manifest::tests::resolve_index_url_returns_none_for_direct_source ... ok +test pkg_manifest::tests::resolve_index_url_substitutes_abi_in_indexed_form ... ok +test pkg_manifest::tests::source_kind_minimal_manifest_parses ... ok +test pkg_manifest::tests::source_kind_rejects_binary_block ... ok +test pkg_manifest::tests::source_package_toml_accepts_minimal_new_format ... ok +test pkg_manifest::tests::source_package_toml_rejects_legacy_binary_block ... ok +test pkg_manifest::tests::source_package_toml_rejects_legacy_build_commit ... ok +test pkg_manifest::tests::source_package_toml_rejects_legacy_build_repo_url ... ok +test pkg_manifest::tests::source_package_toml_rejects_legacy_revision_field ... ok +test pkg_manifest::tests::source_parse_accepts_kernel_abi_absent_when_no_build_block ... ok +test pkg_manifest::tests::source_parse_rejects_missing_kernel_abi_when_build_block_present ... ok +test pkg_manifest::tests::target_arch_as_str_is_stable ... ok +test pkg_manifest::tests::version_constraint_accepts_two_and_three_component ... ok +test pkg_manifest::tests::version_constraint_compares_numerically_not_lexicographically ... ok +test pkg_manifest::tests::version_constraint_rejects_compound ... ok +test pkg_manifest::tests::version_constraint_rejects_other_operators ... ok +test pkg_manifest::tests::version_constraint_rejects_prerelease_suffix ... ok +test pkg_manifest::tests::version_eq_and_ord_agree_on_patch_none_zero ... ok +test remote_fetch::tests::extract_tar_zst_round_trips ... ok +test remote_fetch::tests::fetch_archive_reads_file_scheme_to_temp_file ... ok +test remote_fetch::tests::fetch_archive_restarts_when_range_is_ignored ... ok +test remote_fetch::tests::fetch_archive_resumes_after_midstream_failure_with_ranges ... ok +test remote_fetch::tests::fetch_archive_sha_mismatch_fails_and_removes_temp_file ... ok +test remote_fetch::tests::fetch_archive_streams_http_to_temp_file ... ok +test remote_fetch::tests::fetch_http_url_retries_transient_5xx ... ok +test remote_fetch::tests::fetch_url_reads_file_scheme ... ok +test remote_fetch::tests::fetch_url_rejects_unsupported_scheme ... ok +test remote_fetch::tests::fetch_url_returns_error_for_missing_file ... ok +test remote_fetch::tests::flatten_archive_layout_hoists_artifacts ... ok +test remote_fetch::tests::offline_env_var_blocks_fetch ... ok +test remote_fetch::tests::verify_sha_accepts_matching_digest ... ok +test remote_fetch::tests::verify_sha_rejects_mismatched_digest ... ok +test source_extract::tests::extract_preserves_multiple_top_level_entries ... ok +test source_extract::tests::extract_tar_gz_strips_single_top_level_dir ... ok +test source_extract::tests::extract_tar_zst_round_trips ... ok +test source_extract::tests::fetch_and_extract_via_file_url_succeeds ... ok +test source_extract::tests::from_url_detects_known_extensions ... ok +test source_extract::tests::from_url_handles_query_string_and_fragment ... ok +test source_extract::tests::from_url_rejects_unknown_extension ... ok +test update_pkg_manifest::tests::idempotent_no_op_when_commit_already_matches ... ok +test update_pkg_manifest::tests::idempotent_when_values_already_match ... ok +test update_pkg_manifest::tests::overwrites_existing_commit_on_rebase ... ok +test update_pkg_manifest::tests::rejects_bad_arch ... ok +test update_pkg_manifest::tests::rejects_bad_sha ... ok +test update_pkg_manifest::tests::rejects_empty_commit ... ok +test update_pkg_manifest::tests::rejects_empty_url ... ok +test update_pkg_manifest::tests::rejects_mixed_shape_binary ... ok +test update_pkg_manifest::tests::rejects_wasm64_against_bare_binary ... ok +test update_pkg_manifest::tests::rejects_wasm64_against_bare_binary_even_when_arches_present ... ok +test update_pkg_manifest::tests::rejects_whitespace_in_commit ... ok +test update_pkg_manifest::tests::shape_from_bare_binary_overrides_arches_present ... ok +test update_pkg_manifest::tests::shape_from_bare_binary_with_single_arches_entry ... ok +test update_pkg_manifest::tests::shape_from_per_arch_binary_overrides_arches_absent ... ok +test update_pkg_manifest::tests::skips_silently_when_build_block_absent ... ok +test update_pkg_manifest::tests::updates_both_arches_independently ... ok +test update_pkg_manifest::tests::updates_one_arch_in_multi_arch_block ... ok +test update_pkg_manifest::tests::updates_single_arch_bare_binary_block ... ok +test update_pkg_manifest::tests::writes_commit_when_build_block_present ... ok +test util::tests::hex_empty_is_empty ... ok +test util::tests::hex_encodes_known_bytes ... ok +test util::tests::hex_length_is_double_input ... ok + +test result: ok. 298 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 15.84s + +EXIT=0 diff --git a/test-runs/kd-872c/after-skipped.txt b/test-runs/kd-872c/after-skipped.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test-runs/kd-872c/before-failed.txt b/test-runs/kd-872c/before-failed.txt new file mode 100644 index 0000000000..6400408e79 --- /dev/null +++ b/test-runs/kd-872c/before-failed.txt @@ -0,0 +1,7 @@ +archive_stage_cli::tests::cli_produces_archive_with_canonical_filename +build_deps::tests::binaries_dir_program_fetch_does_not_require_built_deps +build_deps::tests::cmd_resolve_with_binaries_dir_places_kernel_at_root +build_deps::tests::cmd_resolve_with_binaries_dir_places_multi_output_symlinks +build_deps::tests::cmd_resolve_with_binaries_dir_places_single_output_symlink +build_deps::tests::cmd_resolve_with_binaries_dir_replaces_existing_link +build_deps::tests::cmd_resolve_without_binaries_dir_places_no_symlinks diff --git a/test-runs/kd-872c/before-passed.txt b/test-runs/kd-872c/before-passed.txt new file mode 100644 index 0000000000..6a65e7e337 --- /dev/null +++ b/test-runs/kd-872c/before-passed.txt @@ -0,0 +1,290 @@ +archive_stage::tests::embedded_manifest_round_trips_through_parse_archived +archive_stage::tests::produces_archive_consumable_by_remote_fetch +archive_stage::tests::produces_byte_identical_archive_on_repeat_invocation +archive_stage::tests::rejects_empty_cache_dir +archive_stage::tests::rejects_source_kind +archive_stage::tests::rejects_when_cache_entry_is_missing +archive_stage_cli::tests::cli_archive_filename_uses_build_toml_revision +archive_stage_cli::tests::cli_binaries_dir_materializes_program_dependency_symlink +archive_stage_cli::tests::cli_is_byte_deterministic_across_invocations +archive_stage_cli::tests::cli_rejects_arch_not_in_target_arches +archive_stage_cli::tests::cli_rejects_cache_key_input_mutation_during_build +archive_stage_cli::tests::cli_rejects_expected_cache_key_mismatch_before_build +archive_stage_cli::tests::cli_rejects_source_kind_with_clear_error +archive_stage_cli::tests::cli_requires_all_mandatory_flags +build_deps::tests::build_deps_check_flags_inconsistent_constraint +build_deps::tests::build_deps_check_flags_inconsistent_probe +build_deps::tests::build_deps_check_passes_on_consistent_registry +build_deps::tests::build_fails_when_program_wasm_output_missing +build_deps::tests::build_into_cache_stderr_dup_pattern_does_not_panic +build_deps::tests::build_script_sees_target_arch_env +build_deps::tests::build_script_stdout_redirect_to_owned_fd_works +build_deps::tests::build_validates_program_wasm_outputs_present +build_deps::tests::cache_key_sha_changes_when_library_output_header_added +build_deps::tests::cache_key_sha_changes_when_library_output_lib_filename_changes +build_deps::tests::cache_key_sha_changes_when_library_output_pkgconfig_added +build_deps::tests::cache_key_sha_changes_when_program_output_added +build_deps::tests::cache_key_sha_changes_when_program_output_fork_policy_changes +build_deps::tests::cache_key_sha_changes_when_program_output_name_changes +build_deps::tests::cache_key_sha_changes_when_program_output_wasm_filename_changes +build_deps::tests::cache_key_sha_changes_when_program_outputs_reordered +build_deps::tests::cache_key_sha_changes_with_abi_version +build_deps::tests::cache_key_sha_changes_with_target_arch +build_deps::tests::canonical_path_layout +build_deps::tests::canonical_path_uses_programs_subdir_for_program_kind +build_deps::tests::compute_cache_key_sha_args_parse_equals_form +build_deps::tests::compute_cache_key_sha_args_parse_long_form +build_deps::tests::compute_cache_key_sha_args_reject_missing_arch +build_deps::tests::compute_cache_key_sha_args_reject_missing_package +build_deps::tests::compute_cache_key_sha_args_reject_unknown_flag +build_deps::tests::compute_cache_key_sha_changes_on_input_change +build_deps::tests::compute_cache_key_sha_is_deterministic_across_invocations +build_deps::tests::compute_cache_key_sha_rejects_missing_build_toml_input +build_deps::tests::compute_cache_key_sha_subcommand_prints_64_hex_for_real_package +build_deps::tests::compute_cache_key_sha_uses_build_toml_inputs +build_deps::tests::compute_cache_key_sha_uses_build_toml_revision +build_deps::tests::compute_sha_detects_cycle +build_deps::tests::compute_sha_is_deterministic +build_deps::tests::compute_sha_rejects_version_mismatch +build_deps::tests::current_abi_version_matches_shared_crate +build_deps::tests::direct_pr_overlay_fetch_installs_archive_before_build_toml_index +build_deps::tests::ensure_built_cache_hit_skips_host_tool_probes +build_deps::tests::ensure_built_cache_miss_aborts_when_host_tool_missing +build_deps::tests::ensure_built_fails_when_declared_output_missing +build_deps::tests::ensure_built_fails_when_script_exits_nonzero +build_deps::tests::ensure_built_is_idempotent_on_cache_hit +build_deps::tests::ensure_built_runs_script_on_cache_miss +build_deps::tests::ensure_built_source_kind_fetches_and_extracts_via_file_url +build_deps::tests::ensure_built_source_kind_script_must_populate_out_dir +build_deps::tests::ensure_built_source_kind_with_build_script_runs_it +build_deps::tests::env_key_canonicalises_hyphens_and_case +build_deps::tests::extract_binaries_dir_flag_absent +build_deps::tests::extract_binaries_dir_flag_equals_form +build_deps::tests::extract_binaries_dir_flag_rejects_duplicate +build_deps::tests::extract_binaries_dir_flag_separated_form +build_deps::tests::extract_fetch_only_flag_removes_flag +build_deps::tests::fetch_only_rejects_missing_index_entry_without_source_build +build_deps::tests::force_rebuild_bypasses_index_fetch +build_deps::tests::force_rebuild_only_affects_named_packages +build_deps::tests::force_rebuild_runs_build_script_on_cache_hit +build_deps::tests::fork_instrument_cargo_dependency_digest_ignores_unrelated_lockfile_entries +build_deps::tests::fork_instrument_tool_inputs_apply_only_to_programs_that_use_them +build_deps::tests::fork_instrument_tool_inputs_hash_dependency_closure_instead_of_whole_lockfile +build_deps::tests::global_package_build_input_digests_change_with_content +build_deps::tests::global_package_build_input_digests_reject_missing_input +build_deps::tests::global_package_toolchain_inputs_include_package_build_actions +build_deps::tests::index_fetch_falls_through_on_abi_mismatch +build_deps::tests::index_fetch_falls_through_on_archive_sha_mismatch +build_deps::tests::index_fetch_falls_through_on_cache_key_mismatch +build_deps::tests::index_fetch_falls_through_on_index_toml_abi_mismatch +build_deps::tests::index_fetch_falls_through_on_target_arch_mismatch +build_deps::tests::index_fetch_installs_archive_when_sha_arch_abi_cachekey_all_match +build_deps::tests::libtool_archive_libdir_is_rewritten_to_canonical_path +build_deps::tests::local_libs_override_wins +build_deps::tests::output_fork_instrumentation_for_rel_is_arch_neutral +build_deps::tests::parse_target_arch_accepts_known_values +build_deps::tests::parse_target_arch_rejects_unknown_values +build_deps::tests::pkg_config_path_includes_transitive_lib_pkgconfig +build_deps::tests::pkg_config_path_skips_libs_without_pkgconfig_dir +build_deps::tests::pkgconfig_prefix_is_rewritten_to_canonical_path +build_deps::tests::pkgconfig_symlinks_survive_the_rewrite +build_deps::tests::program_output_validation_accepts_disabled_fork_instrumentation_policy +build_deps::tests::program_output_validation_accepts_kernel_host_adapter_exports +build_deps::tests::program_output_validation_accepts_relocatable_fork_objects +build_deps::tests::program_output_validation_rejects_executable_without_entrypoint_exports +build_deps::tests::program_output_validation_rejects_fork_without_wpk_exports +build_deps::tests::program_output_validation_rejects_kernel_missing_host_adapter_exports +build_deps::tests::program_output_validation_rejects_legacy_asyncify_wasm +build_deps::tests::program_output_validation_rejects_wpk_exports_when_policy_disabled +build_deps::tests::programs_by_name_filters_to_program_kind +build_deps::tests::registry_find_falls_through_to_second_root +build_deps::tests::registry_find_returns_first_hit +build_deps::tests::render_probe_failures_uses_darwin_alias_for_macos +build_deps::tests::resolve_with_arch_wasm64_uses_different_cache_path +build_deps::tests::source_kind_canonical_path_omits_arch +build_deps::tests::source_kind_direct_dep_exports_src_dir_env_var +build_deps::tests::source_kind_sha_omits_arch_and_abi_inputs +build_deps::tests::source_kind_sha_uses_distinct_domain +build_deps::tests::transitive_deps_are_built_and_exposed_via_env +build_deps::tests::walk_all_finds_libraries_and_programs +build_deps::tests::walk_all_first_root_wins_for_duplicate_names +build_deps::tests::walk_all_handles_missing_registry_root +build_index::tests::abi_mismatch_in_filename_is_rejected +build_index::tests::determinism_byte_identical_on_repeat_invocation +build_index::tests::divergent_version_across_arches_is_rejected +build_index::tests::empty_input_produces_valid_header_only_toml +build_index::tests::filename_parser_handles_multi_segment_names +build_index::tests::filename_parser_rejects_malformed_inputs +build_index::tests::missing_arch_only_emits_present_block +build_index::tests::smoke_two_packages_two_arches +dump_abi::tests::adding_host_adapter_section_is_compatible +dump_abi::tests::adding_syscall_arg_descriptor_section_is_compatible +dump_abi::tests::additive_syscall_export_and_struct_are_compatible +dump_abi::tests::changed_channel_layout_is_breaking +dump_abi::tests::changed_existing_export_is_breaking +dump_abi::tests::changed_syscall_arg_descriptor_is_breaking +dump_abi::tests::renamed_syscall_number_is_breaking +dump_abi::tests::syscall_log_names_match_existing_trace_spelling +host_tool_probe::tests::probe_compares_numerically_3_20_satisfies_3_9 +host_tool_probe::tests::probe_passes_when_version_meets_constraint +host_tool_probe::tests::probe_rejects_old_version +host_tool_probe::tests::probe_reports_bad_output_when_regex_does_not_match +host_tool_probe::tests::probe_reports_missing_when_not_in_path +index_toml::tests::archive_filename_abi_extracts_abi_segment +index_toml::tests::fetch_index_errors_when_no_cache_and_offline +index_toml::tests::fetch_index_falls_back_to_cache_when_offline +index_toml::tests::fetch_index_reads_file_url_and_writes_cache +index_toml::tests::index_cache_path_distinguishes_urls +index_toml::tests::index_toml_prunes_archive_entries_for_other_abis +index_toml::tests::index_toml_round_trips_semantic_equality +index_toml::tests::index_toml_validate_archive_abi_versions_rejects_current_mismatch +index_toml::tests::index_toml_validate_archive_abi_versions_rejects_fallback_mismatch +index_toml::tests::index_toml_write_omits_none_fields +index_toml::tests::index_toml_write_sorts_packages_alphabetically +index_toml::tests::parses_index_toml_with_failed_entry_and_fallback +index_toml::tests::parses_index_toml_with_success_entry +index_toml::tests::update_entry_failed_moves_current_to_fallback +index_toml::tests::update_entry_failed_preserves_existing_fallback +index_toml::tests::update_entry_failed_with_no_prior_success_has_no_fallback +index_toml::tests::update_entry_success_after_failed_clears_fallback +index_toml::tests::update_entry_success_overwrites_current_and_clears_fallback +index_toml::tests::update_entry_success_refreshes_existing_package_revision +index_update::tests::index_update_failed_moves_existing_success_to_fallback +index_update::tests::index_update_preserves_durable_index_toml_abi_from_expected_tag +index_update::tests::index_update_rejects_archive_cache_key_mismatch +index_update::tests::index_update_rejects_archive_path_name_mismatch +index_update::tests::index_update_rejects_missing_required_flag +index_update::tests::index_update_rejects_mixed_index_toml_archive_abis +index_update::tests::index_update_rejects_unknown_flag +index_update::tests::index_update_repair_rewrites_stale_index_toml_abi +index_update::tests::index_update_rewrites_stale_index_toml_abi_and_prunes_old_entries +index_update::tests::index_update_success_writes_entry_to_index +package_matrix::tests::dependency_artifacts_reports_only_selected_direct_dependencies +package_matrix::tests::sort_matrix_orders_selected_program_dependencies_first +pkg_manifest::tests::archived_parse_accepts_missing_kernel_abi +pkg_manifest::tests::archived_parses_bare_binary_block_as_wasm32 +pkg_manifest::tests::archived_parses_per_arch_binary_block +pkg_manifest::tests::archived_rejects_invalid_binary_archive_sha +pkg_manifest::tests::archived_rejects_long_binary_archive_sha +pkg_manifest::tests::archived_rejects_mixed_binary_shape +pkg_manifest::tests::archived_rejects_short_binary_archive_sha +pkg_manifest::tests::archived_rejects_unknown_binary_key +pkg_manifest::tests::archived_rejects_uppercase_binary_archive_sha +pkg_manifest::tests::build_rejects_legacy_script_field +pkg_manifest::tests::build_script_override_is_repo_root_relative +pkg_manifest::tests::depends_on_parsed_into_deprefs +pkg_manifest::tests::depref_parse_basic +pkg_manifest::tests::depref_rejects_empty_fields +pkg_manifest::tests::depref_rejects_missing_at +pkg_manifest::tests::host_tools_allowed_on_source_kind +pkg_manifest::tests::host_tools_reject_duplicate_names_in_same_manifest +pkg_manifest::tests::host_tools_reject_empty_probe_args +pkg_manifest::tests::host_tools_reject_invalid_probe_regex +pkg_manifest::tests::kernel_abi_is_optional +pkg_manifest::tests::output_dest_rel_library_kind_errors +pkg_manifest::tests::output_dest_rel_multi_output_uses_program_subdir +pkg_manifest::tests::output_dest_rel_single_output_program_name_matches_output_name +pkg_manifest::tests::output_dest_rel_single_output_with_diverging_name_uses_output_name +pkg_manifest::tests::output_dest_rel_unknown_basename_errors +pkg_manifest::tests::output_fork_instrumentation_can_be_disabled +pkg_manifest::tests::output_fork_instrumentation_defaults_to_auto +pkg_manifest::tests::overlay_absent_uses_base +pkg_manifest::tests::overlay_merges_binary_block_over_base +pkg_manifest::tests::overlay_merges_multiple_arches_from_overlay +pkg_manifest::tests::overlay_with_non_binary_field_is_rejected +pkg_manifest::tests::parse_accepts_no_binary_block +pkg_manifest::tests::parse_archived_accepts_full_compatibility_block +pkg_manifest::tests::parse_archived_accepts_legacy_script_field +pkg_manifest::tests::parse_archived_accepts_repo_url_and_commit +pkg_manifest::tests::parse_archived_accepts_script_path_field +pkg_manifest::tests::parse_archived_rejects_empty_abi_versions +pkg_manifest::tests::parse_archived_rejects_short_cache_key_sha +pkg_manifest::tests::parse_archived_rejects_uppercase_cache_key_sha +pkg_manifest::tests::parse_archived_rejects_zero_revision +pkg_manifest::tests::parse_archived_requires_compatibility_block +pkg_manifest::tests::parses_build_toml_with_direct_url +pkg_manifest::tests::parses_build_toml_with_indexed_binary +pkg_manifest::tests::parses_host_tools_with_defaults +pkg_manifest::tests::parses_manifest_with_kind_library +pkg_manifest::tests::parses_minimal_manifest +pkg_manifest::tests::parses_minimal_program_manifest +pkg_manifest::tests::parses_multi_output_program_manifest +pkg_manifest::tests::parses_top_level_kernel_abi +pkg_manifest::tests::rejects_build_toml_direct_url_without_sha +pkg_manifest::tests::rejects_build_toml_with_absolute_input +pkg_manifest::tests::rejects_build_toml_with_both_indexed_and_direct +pkg_manifest::tests::rejects_build_toml_with_empty_binary +pkg_manifest::tests::rejects_build_toml_with_parent_dir_input +pkg_manifest::tests::rejects_build_toml_with_unknown_binary_field +pkg_manifest::tests::rejects_build_toml_with_unknown_top_level_field +pkg_manifest::tests::rejects_compatibility_in_source_mode +pkg_manifest::tests::rejects_duplicate_depends_on +pkg_manifest::tests::rejects_empty_spdx +pkg_manifest::tests::rejects_library_with_array_outputs +pkg_manifest::tests::rejects_manifest_without_kind +pkg_manifest::tests::rejects_program_output_with_empty_wasm +pkg_manifest::tests::rejects_program_with_no_outputs +pkg_manifest::tests::rejects_program_with_table_outputs +pkg_manifest::tests::rejects_uppercase_or_short_sha +pkg_manifest::tests::resolve_index_url_passes_through_template_without_abi_token +pkg_manifest::tests::resolve_index_url_returns_none_for_direct_source +pkg_manifest::tests::resolve_index_url_substitutes_abi_in_indexed_form +pkg_manifest::tests::source_kind_minimal_manifest_parses +pkg_manifest::tests::source_kind_rejects_binary_block +pkg_manifest::tests::source_package_toml_accepts_minimal_new_format +pkg_manifest::tests::source_package_toml_rejects_legacy_binary_block +pkg_manifest::tests::source_package_toml_rejects_legacy_build_commit +pkg_manifest::tests::source_package_toml_rejects_legacy_build_repo_url +pkg_manifest::tests::source_package_toml_rejects_legacy_revision_field +pkg_manifest::tests::source_parse_accepts_kernel_abi_absent_when_no_build_block +pkg_manifest::tests::source_parse_rejects_missing_kernel_abi_when_build_block_present +pkg_manifest::tests::target_arch_as_str_is_stable +pkg_manifest::tests::version_constraint_accepts_two_and_three_component +pkg_manifest::tests::version_constraint_compares_numerically_not_lexicographically +pkg_manifest::tests::version_constraint_rejects_compound +pkg_manifest::tests::version_constraint_rejects_other_operators +pkg_manifest::tests::version_constraint_rejects_prerelease_suffix +pkg_manifest::tests::version_eq_and_ord_agree_on_patch_none_zero +remote_fetch::tests::extract_tar_zst_round_trips +remote_fetch::tests::fetch_archive_reads_file_scheme_to_temp_file +remote_fetch::tests::fetch_archive_restarts_when_range_is_ignored +remote_fetch::tests::fetch_archive_resumes_after_midstream_failure_with_ranges +remote_fetch::tests::fetch_archive_sha_mismatch_fails_and_removes_temp_file +remote_fetch::tests::fetch_archive_streams_http_to_temp_file +remote_fetch::tests::fetch_http_url_retries_transient_5xx +remote_fetch::tests::fetch_url_reads_file_scheme +remote_fetch::tests::fetch_url_rejects_unsupported_scheme +remote_fetch::tests::fetch_url_returns_error_for_missing_file +remote_fetch::tests::flatten_archive_layout_hoists_artifacts +remote_fetch::tests::offline_env_var_blocks_fetch +remote_fetch::tests::verify_sha_accepts_matching_digest +remote_fetch::tests::verify_sha_rejects_mismatched_digest +source_extract::tests::extract_preserves_multiple_top_level_entries +source_extract::tests::extract_tar_gz_strips_single_top_level_dir +source_extract::tests::extract_tar_zst_round_trips +source_extract::tests::fetch_and_extract_via_file_url_succeeds +source_extract::tests::from_url_detects_known_extensions +source_extract::tests::from_url_handles_query_string_and_fragment +source_extract::tests::from_url_rejects_unknown_extension +update_pkg_manifest::tests::idempotent_no_op_when_commit_already_matches +update_pkg_manifest::tests::idempotent_when_values_already_match +update_pkg_manifest::tests::overwrites_existing_commit_on_rebase +update_pkg_manifest::tests::rejects_bad_arch +update_pkg_manifest::tests::rejects_bad_sha +update_pkg_manifest::tests::rejects_empty_commit +update_pkg_manifest::tests::rejects_empty_url +update_pkg_manifest::tests::rejects_mixed_shape_binary +update_pkg_manifest::tests::rejects_wasm64_against_bare_binary +update_pkg_manifest::tests::rejects_wasm64_against_bare_binary_even_when_arches_present +update_pkg_manifest::tests::rejects_whitespace_in_commit +update_pkg_manifest::tests::shape_from_bare_binary_overrides_arches_present +update_pkg_manifest::tests::shape_from_bare_binary_with_single_arches_entry +update_pkg_manifest::tests::shape_from_per_arch_binary_overrides_arches_absent +update_pkg_manifest::tests::skips_silently_when_build_block_absent +update_pkg_manifest::tests::updates_both_arches_independently +update_pkg_manifest::tests::updates_one_arch_in_multi_arch_block +update_pkg_manifest::tests::updates_single_arch_bare_binary_block +update_pkg_manifest::tests::writes_commit_when_build_block_present +util::tests::hex_empty_is_empty +util::tests::hex_encodes_known_bytes +util::tests::hex_length_is_double_input diff --git a/test-runs/kd-872c/before-skipped.txt b/test-runs/kd-872c/before-skipped.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test-runs/kd-872c/repro-before-serial.log b/test-runs/kd-872c/repro-before-serial.log new file mode 100644 index 0000000000..5ae87b5b4b --- /dev/null +++ b/test-runs/kd-872c/repro-before-serial.log @@ -0,0 +1,505 @@ +kandelo dev shell — LLVM 21.1.7, Rust (pinned via rust-toolchain.toml), Node 24, Erlang 28 (minimal), SDK on PATH + Compiling libc v0.2.186 + Compiling proc-macro2 v1.0.106 + Compiling quote v1.0.45 + Compiling unicode-ident v1.0.24 + Compiling find-msvc-tools v0.1.9 + Compiling shlex v1.3.0 + Compiling cfg-if v1.0.4 + Compiling stable_deref_trait v1.2.1 + Compiling pkg-config v0.3.33 + Compiling serde_core v1.0.228 + Compiling version_check v0.9.5 + Compiling autocfg v1.5.0 + Compiling serde v1.0.228 + Compiling memchr v2.8.0 + Compiling writeable v0.6.3 + Compiling litemap v0.8.2 + Compiling smallvec v1.15.1 + Compiling icu_normalizer_data v2.2.0 + Compiling utf8_iter v1.0.4 + Compiling icu_properties_data v2.2.0 + Compiling zeroize v1.8.2 + Compiling foldhash v0.2.0 + Compiling crc32fast v1.5.0 + Compiling once_cell v1.21.4 + Compiling equivalent v1.0.2 + Compiling bitflags v2.11.1 + Compiling generic-array v0.14.7 + Compiling typenum v1.20.0 + Compiling rustix v1.1.4 + Compiling rustls-pki-types v1.14.1 + Compiling aho-corasick v1.1.4 + Compiling zerocopy v0.8.48 + Compiling getrandom v0.3.4 + Compiling percent-encoding v2.3.2 + Compiling num-traits v0.2.19 + Compiling regex-syntax v0.8.10 + Compiling untrusted v0.9.0 + Compiling simd-adler32 v0.3.9 + Compiling zmij v1.0.21 + Compiling parking_lot_core v0.9.12 + Compiling log v0.4.29 + Compiling form_urlencoded v1.2.2 + Compiling ahash v0.8.12 + Compiling bit-vec v0.6.3 + Compiling powerfmt v0.2.0 + Compiling regex-automata v0.4.14 + Compiling getrandom v0.4.2 + Compiling time-core v0.1.8 + Compiling serde_json v1.0.149 + Compiling rustls v0.23.40 + Compiling thiserror v2.0.18 + Compiling syn v2.0.117 + Compiling adler2 v2.0.1 + Compiling anyhow v1.0.102 + Compiling num-conv v0.2.1 + Compiling scopeguard v1.2.0 + Compiling zstd-safe v7.2.4 + Compiling miniz_oxide v0.8.9 + Compiling lock_api v0.4.14 + Compiling time-macros v0.2.27 + Compiling jobserver v0.1.34 + Compiling getrandom v0.2.17 + Compiling errno v0.3.14 + Compiling bit-set v0.5.3 + Compiling deranged v0.5.8 + Compiling cc v1.2.61 + Compiling block-buffer v0.10.4 + Compiling crypto-common v0.1.7 + Compiling synstructure v0.13.2 + Compiling hashbrown v0.17.0 + Compiling webpki-roots v1.0.7 + Compiling nom v8.0.0 + Compiling itoa v1.0.18 + Compiling subtle v2.6.1 + Compiling bumpalo v3.20.2 + Compiling num-integer v0.1.46 + Compiling num-complex v0.4.6 + Compiling toml_write v0.1.2 + Compiling zip v2.4.2 + Compiling base64 v0.22.1 + Compiling winnow v0.7.15 + Compiling indexmap v2.14.0 + Compiling num-bigint v0.4.6 + Compiling num-iter v0.1.45 + Compiling lazy_static v1.5.0 + Compiling time v0.3.47 + Compiling zopfli v0.8.3 + Compiling parking_lot v0.12.5 + Compiling xattr v1.6.1 + Compiling webpki-roots v0.26.11 + Compiling digest v0.10.7 + Compiling fancy-regex v0.13.0 + Compiling regex v1.12.3 + Compiling cpufeatures v0.2.17 + Compiling filetime v0.2.27 + Compiling flate2 v1.1.9 + Compiling zerofrom-derive v0.1.7 + Compiling yoke-derive v0.8.2 + Compiling zerovec-derive v0.11.3 + Compiling displaydoc v0.2.5 + Compiling serde_derive v1.0.228 + Compiling thiserror-impl v2.0.18 + Compiling ring v0.17.14 + Compiling zstd-sys v2.0.16+zstd.1.5.7 + Compiling num-rational v0.4.2 + Compiling lzma-sys v0.1.20 + Compiling bzip2-sys v0.1.13+1.0.8 + Compiling num v0.4.3 + Compiling uuid v1.23.1 + Compiling fraction v0.15.4 + Compiling fastrand v2.4.1 + Compiling bytecount v0.6.9 + Compiling num-cmp v0.1.0 + Compiling semver v1.0.28 + Compiling tempfile v3.27.0 + Compiling tar v0.4.45 + Compiling iso8601 v0.6.3 + Compiling sha2 v0.10.9 + Compiling wasm-posix-shared v0.1.0 (/Users/brandon/src/kandelo-gascity/worktrees/kandelo/kd-872c-fix-7-stale-xtask-test-fixtures-assertions-and-gate-carg/crates/shared) + Compiling zerofrom v0.1.7 + Compiling yoke v0.8.2 + Compiling xz2 v0.1.7 + Compiling zerovec v0.11.6 + Compiling zerotrie v0.2.4 + Compiling serde_spanned v0.6.9 + Compiling toml_datetime v0.6.11 + Compiling wasmparser v0.247.0 + Compiling toml_edit v0.22.27 + Compiling tinystr v0.8.3 + Compiling potential_utf v0.1.5 + Compiling bzip2 v0.4.4 + Compiling icu_collections v2.2.0 + Compiling icu_locale_core v2.2.0 + Compiling icu_provider v2.2.0 + Compiling toml v0.8.23 + Compiling icu_properties v2.2.0 + Compiling icu_normalizer v2.2.0 + Compiling idna_adapter v1.2.2 + Compiling idna v1.1.0 + Compiling rustls-webpki v0.103.13 + Compiling url v2.5.8 + Compiling jsonschema v0.18.3 + Compiling ureq v2.12.1 + Compiling zstd v0.13.3 + Compiling xtask v0.1.0 (/Users/brandon/src/kandelo-gascity/worktrees/kandelo/kd-872c-fix-7-stale-xtask-test-fixtures-assertions-and-gate-carg/tools/xtask) + Finished `test` profile [unoptimized + debuginfo] target(s) in 18.49s + Running unittests src/main.rs (target/aarch64-apple-darwin/debug/deps/xtask-f88e9604e66db803) + +running 297 tests +test archive_stage::tests::embedded_manifest_round_trips_through_parse_archived ... ok +test archive_stage::tests::produces_archive_consumable_by_remote_fetch ... ok +test archive_stage::tests::produces_byte_identical_archive_on_repeat_invocation ... ok +test archive_stage::tests::rejects_empty_cache_dir ... ok +test archive_stage::tests::rejects_source_kind ... ok +test archive_stage::tests::rejects_when_cache_entry_is_missing ... ok +test archive_stage_cli::tests::cli_archive_filename_uses_build_toml_revision ... ok +test archive_stage_cli::tests::cli_binaries_dir_materializes_program_dependency_symlink ... ok +test archive_stage_cli::tests::cli_is_byte_deterministic_across_invocations ... ok +test archive_stage_cli::tests::cli_produces_archive_with_canonical_filename ... FAILED +test archive_stage_cli::tests::cli_rejects_arch_not_in_target_arches ... ok +test archive_stage_cli::tests::cli_rejects_cache_key_input_mutation_during_build ... ok +test archive_stage_cli::tests::cli_rejects_expected_cache_key_mismatch_before_build ... ok +test archive_stage_cli::tests::cli_rejects_source_kind_with_clear_error ... ok +test archive_stage_cli::tests::cli_requires_all_mandatory_flags ... ok +test build_deps::tests::binaries_dir_program_fetch_does_not_require_built_deps ... baddep source build should not run +FAILED +test build_deps::tests::build_deps_check_flags_inconsistent_constraint ... ok +test build_deps::tests::build_deps_check_flags_inconsistent_probe ... ok +test build_deps::tests::build_deps_check_passes_on_consistent_registry ... ok +test build_deps::tests::build_fails_when_program_wasm_output_missing ... ok +test build_deps::tests::build_into_cache_stderr_dup_pattern_does_not_panic ... running +ok +test build_deps::tests::build_script_sees_target_arch_env ... ok +test build_deps::tests::build_script_stdout_redirect_to_owned_fd_works ... ok +test build_deps::tests::build_validates_program_wasm_outputs_present ... ok +test build_deps::tests::cache_key_sha_changes_when_library_output_header_added ... ok +test build_deps::tests::cache_key_sha_changes_when_library_output_lib_filename_changes ... ok +test build_deps::tests::cache_key_sha_changes_when_library_output_pkgconfig_added ... ok +test build_deps::tests::cache_key_sha_changes_when_program_output_added ... ok +test build_deps::tests::cache_key_sha_changes_when_program_output_fork_policy_changes ... ok +test build_deps::tests::cache_key_sha_changes_when_program_output_name_changes ... ok +test build_deps::tests::cache_key_sha_changes_when_program_output_wasm_filename_changes ... ok +test build_deps::tests::cache_key_sha_changes_when_program_outputs_reordered ... ok +test build_deps::tests::cache_key_sha_changes_with_abi_version ... ok +test build_deps::tests::cache_key_sha_changes_with_target_arch ... ok +test build_deps::tests::canonical_path_layout ... ok +test build_deps::tests::canonical_path_uses_programs_subdir_for_program_kind ... ok +test build_deps::tests::cmd_resolve_with_binaries_dir_places_kernel_at_root ... FAILED +test build_deps::tests::cmd_resolve_with_binaries_dir_places_multi_output_symlinks ... FAILED +test build_deps::tests::cmd_resolve_with_binaries_dir_places_single_output_symlink ... FAILED +test build_deps::tests::cmd_resolve_with_binaries_dir_replaces_existing_link ... FAILED +test build_deps::tests::cmd_resolve_without_binaries_dir_places_no_symlinks ... FAILED +test build_deps::tests::compute_cache_key_sha_args_parse_equals_form ... ok +test build_deps::tests::compute_cache_key_sha_args_parse_long_form ... ok +test build_deps::tests::compute_cache_key_sha_args_reject_missing_arch ... ok +test build_deps::tests::compute_cache_key_sha_args_reject_missing_package ... ok +test build_deps::tests::compute_cache_key_sha_args_reject_unknown_flag ... ok +test build_deps::tests::compute_cache_key_sha_changes_on_input_change ... ok +test build_deps::tests::compute_cache_key_sha_is_deterministic_across_invocations ... ok +test build_deps::tests::compute_cache_key_sha_rejects_missing_build_toml_input ... ok +test build_deps::tests::compute_cache_key_sha_subcommand_prints_64_hex_for_real_package ... ok +test build_deps::tests::compute_cache_key_sha_uses_build_toml_inputs ... ok +test build_deps::tests::compute_cache_key_sha_uses_build_toml_revision ... ok +test build_deps::tests::compute_sha_detects_cycle ... ok +test build_deps::tests::compute_sha_is_deterministic ... ok +test build_deps::tests::compute_sha_rejects_version_mismatch ... ok +test build_deps::tests::current_abi_version_matches_shared_crate ... ok +test build_deps::tests::direct_pr_overlay_fetch_installs_archive_before_build_toml_index ... ok +test build_deps::tests::ensure_built_cache_hit_skips_host_tool_probes ... ok +test build_deps::tests::ensure_built_cache_miss_aborts_when_host_tool_missing ... ok +test build_deps::tests::ensure_built_fails_when_declared_output_missing ... ok +test build_deps::tests::ensure_built_fails_when_script_exits_nonzero ... boom +ok +test build_deps::tests::ensure_built_is_idempotent_on_cache_hit ... ok +test build_deps::tests::ensure_built_runs_script_on_cache_miss ... ok +test build_deps::tests::ensure_built_source_kind_fetches_and_extracts_via_file_url ... ok +test build_deps::tests::ensure_built_source_kind_script_must_populate_out_dir ... ok +test build_deps::tests::ensure_built_source_kind_with_build_script_runs_it ... ok +test build_deps::tests::env_key_canonicalises_hyphens_and_case ... ok +test build_deps::tests::extract_binaries_dir_flag_absent ... ok +test build_deps::tests::extract_binaries_dir_flag_equals_form ... ok +test build_deps::tests::extract_binaries_dir_flag_rejects_duplicate ... ok +test build_deps::tests::extract_binaries_dir_flag_separated_form ... ok +test build_deps::tests::extract_fetch_only_flag_removes_flag ... ok +test build_deps::tests::fetch_only_rejects_missing_index_entry_without_source_build ... ok +test build_deps::tests::force_rebuild_bypasses_index_fetch ... ok +test build_deps::tests::force_rebuild_only_affects_named_packages ... ok +test build_deps::tests::force_rebuild_runs_build_script_on_cache_hit ... ok +test build_deps::tests::fork_instrument_cargo_dependency_digest_ignores_unrelated_lockfile_entries ... ok +test build_deps::tests::fork_instrument_tool_inputs_apply_only_to_programs_that_use_them ... ok +test build_deps::tests::fork_instrument_tool_inputs_hash_dependency_closure_instead_of_whole_lockfile ... ok +test build_deps::tests::global_package_build_input_digests_change_with_content ... ok +test build_deps::tests::global_package_build_input_digests_reject_missing_input ... ok +test build_deps::tests::global_package_toolchain_inputs_include_package_build_actions ... ok +test build_deps::tests::index_fetch_falls_through_on_abi_mismatch ... ok +test build_deps::tests::index_fetch_falls_through_on_archive_sha_mismatch ... ok +test build_deps::tests::index_fetch_falls_through_on_cache_key_mismatch ... ok +test build_deps::tests::index_fetch_falls_through_on_index_toml_abi_mismatch ... ok +test build_deps::tests::index_fetch_falls_through_on_target_arch_mismatch ... ok +test build_deps::tests::index_fetch_installs_archive_when_sha_arch_abi_cachekey_all_match ... ok +test build_deps::tests::libtool_archive_libdir_is_rewritten_to_canonical_path ... ok +test build_deps::tests::local_libs_override_wins ... ok +test build_deps::tests::output_fork_instrumentation_for_rel_is_arch_neutral ... ok +test build_deps::tests::parse_target_arch_accepts_known_values ... ok +test build_deps::tests::parse_target_arch_rejects_unknown_values ... ok +test build_deps::tests::pkg_config_path_includes_transitive_lib_pkgconfig ... ok +test build_deps::tests::pkg_config_path_skips_libs_without_pkgconfig_dir ... ok +test build_deps::tests::pkgconfig_prefix_is_rewritten_to_canonical_path ... ok +test build_deps::tests::pkgconfig_symlinks_survive_the_rewrite ... ok +test build_deps::tests::program_output_validation_accepts_disabled_fork_instrumentation_policy ... ok +test build_deps::tests::program_output_validation_accepts_kernel_host_adapter_exports ... ok +test build_deps::tests::program_output_validation_accepts_relocatable_fork_objects ... ok +test build_deps::tests::program_output_validation_rejects_executable_without_entrypoint_exports ... ok +test build_deps::tests::program_output_validation_rejects_fork_without_wpk_exports ... ok +test build_deps::tests::program_output_validation_rejects_kernel_missing_host_adapter_exports ... ok +test build_deps::tests::program_output_validation_rejects_legacy_asyncify_wasm ... ok +test build_deps::tests::program_output_validation_rejects_wpk_exports_when_policy_disabled ... ok +test build_deps::tests::programs_by_name_filters_to_program_kind ... ok +test build_deps::tests::registry_find_falls_through_to_second_root ... ok +test build_deps::tests::registry_find_returns_first_hit ... ok +test build_deps::tests::render_probe_failures_uses_darwin_alias_for_macos ... ok +test build_deps::tests::resolve_with_arch_wasm64_uses_different_cache_path ... ok +test build_deps::tests::source_kind_canonical_path_omits_arch ... ok +test build_deps::tests::source_kind_direct_dep_exports_src_dir_env_var ... ok +test build_deps::tests::source_kind_sha_omits_arch_and_abi_inputs ... ok +test build_deps::tests::source_kind_sha_uses_distinct_domain ... ok +test build_deps::tests::transitive_deps_are_built_and_exposed_via_env ... ok +test build_deps::tests::walk_all_finds_libraries_and_programs ... ok +test build_deps::tests::walk_all_first_root_wins_for_duplicate_names ... ok +test build_deps::tests::walk_all_handles_missing_registry_root ... ok +test build_index::tests::abi_mismatch_in_filename_is_rejected ... ok +test build_index::tests::determinism_byte_identical_on_repeat_invocation ... ok +test build_index::tests::divergent_version_across_arches_is_rejected ... ok +test build_index::tests::empty_input_produces_valid_header_only_toml ... ok +test build_index::tests::filename_parser_handles_multi_segment_names ... ok +test build_index::tests::filename_parser_rejects_malformed_inputs ... ok +test build_index::tests::missing_arch_only_emits_present_block ... ok +test build_index::tests::smoke_two_packages_two_arches ... ok +test dump_abi::tests::adding_host_adapter_section_is_compatible ... ok +test dump_abi::tests::adding_syscall_arg_descriptor_section_is_compatible ... ok +test dump_abi::tests::additive_syscall_export_and_struct_are_compatible ... ok +test dump_abi::tests::changed_channel_layout_is_breaking ... ok +test dump_abi::tests::changed_existing_export_is_breaking ... ok +test dump_abi::tests::changed_syscall_arg_descriptor_is_breaking ... ok +test dump_abi::tests::renamed_syscall_number_is_breaking ... ok +test dump_abi::tests::syscall_log_names_match_existing_trace_spelling ... ok +test host_tool_probe::tests::probe_compares_numerically_3_20_satisfies_3_9 ... ok +test host_tool_probe::tests::probe_passes_when_version_meets_constraint ... ok +test host_tool_probe::tests::probe_rejects_old_version ... ok +test host_tool_probe::tests::probe_reports_bad_output_when_regex_does_not_match ... ok +test host_tool_probe::tests::probe_reports_missing_when_not_in_path ... ok +test index_toml::tests::archive_filename_abi_extracts_abi_segment ... ok +test index_toml::tests::fetch_index_errors_when_no_cache_and_offline ... ok +test index_toml::tests::fetch_index_falls_back_to_cache_when_offline ... ok +test index_toml::tests::fetch_index_reads_file_url_and_writes_cache ... ok +test index_toml::tests::index_cache_path_distinguishes_urls ... ok +test index_toml::tests::index_toml_prunes_archive_entries_for_other_abis ... ok +test index_toml::tests::index_toml_round_trips_semantic_equality ... ok +test index_toml::tests::index_toml_validate_archive_abi_versions_rejects_current_mismatch ... ok +test index_toml::tests::index_toml_validate_archive_abi_versions_rejects_fallback_mismatch ... ok +test index_toml::tests::index_toml_write_omits_none_fields ... ok +test index_toml::tests::index_toml_write_sorts_packages_alphabetically ... ok +test index_toml::tests::parses_index_toml_with_failed_entry_and_fallback ... ok +test index_toml::tests::parses_index_toml_with_success_entry ... ok +test index_toml::tests::update_entry_failed_moves_current_to_fallback ... ok +test index_toml::tests::update_entry_failed_preserves_existing_fallback ... ok +test index_toml::tests::update_entry_failed_with_no_prior_success_has_no_fallback ... ok +test index_toml::tests::update_entry_success_after_failed_clears_fallback ... ok +test index_toml::tests::update_entry_success_overwrites_current_and_clears_fallback ... ok +test index_toml::tests::update_entry_success_refreshes_existing_package_revision ... ok +test index_update::tests::index_update_failed_moves_existing_success_to_fallback ... ok +test index_update::tests::index_update_preserves_durable_index_toml_abi_from_expected_tag ... ok +test index_update::tests::index_update_rejects_archive_cache_key_mismatch ... ok +test index_update::tests::index_update_rejects_archive_path_name_mismatch ... ok +test index_update::tests::index_update_rejects_missing_required_flag ... ok +test index_update::tests::index_update_rejects_mixed_index_toml_archive_abis ... ok +test index_update::tests::index_update_rejects_unknown_flag ... ok +test index_update::tests::index_update_repair_rewrites_stale_index_toml_abi ... ok +test index_update::tests::index_update_rewrites_stale_index_toml_abi_and_prunes_old_entries ... ok +test index_update::tests::index_update_success_writes_entry_to_index ... ok +test package_matrix::tests::dependency_artifacts_reports_only_selected_direct_dependencies ... ok +test package_matrix::tests::sort_matrix_orders_selected_program_dependencies_first ... ok +test pkg_manifest::tests::archived_parse_accepts_missing_kernel_abi ... ok +test pkg_manifest::tests::archived_parses_bare_binary_block_as_wasm32 ... ok +test pkg_manifest::tests::archived_parses_per_arch_binary_block ... ok +test pkg_manifest::tests::archived_rejects_invalid_binary_archive_sha ... ok +test pkg_manifest::tests::archived_rejects_long_binary_archive_sha ... ok +test pkg_manifest::tests::archived_rejects_mixed_binary_shape ... ok +test pkg_manifest::tests::archived_rejects_short_binary_archive_sha ... ok +test pkg_manifest::tests::archived_rejects_unknown_binary_key ... ok +test pkg_manifest::tests::archived_rejects_uppercase_binary_archive_sha ... ok +test pkg_manifest::tests::build_rejects_legacy_script_field ... ok +test pkg_manifest::tests::build_script_override_is_repo_root_relative ... ok +test pkg_manifest::tests::depends_on_parsed_into_deprefs ... ok +test pkg_manifest::tests::depref_parse_basic ... ok +test pkg_manifest::tests::depref_rejects_empty_fields ... ok +test pkg_manifest::tests::depref_rejects_missing_at ... ok +test pkg_manifest::tests::host_tools_allowed_on_source_kind ... ok +test pkg_manifest::tests::host_tools_reject_duplicate_names_in_same_manifest ... ok +test pkg_manifest::tests::host_tools_reject_empty_probe_args ... ok +test pkg_manifest::tests::host_tools_reject_invalid_probe_regex ... ok +test pkg_manifest::tests::kernel_abi_is_optional ... ok +test pkg_manifest::tests::output_dest_rel_library_kind_errors ... ok +test pkg_manifest::tests::output_dest_rel_multi_output_uses_program_subdir ... ok +test pkg_manifest::tests::output_dest_rel_single_output_program_name_matches_output_name ... ok +test pkg_manifest::tests::output_dest_rel_single_output_with_diverging_name_uses_output_name ... ok +test pkg_manifest::tests::output_dest_rel_unknown_basename_errors ... ok +test pkg_manifest::tests::output_fork_instrumentation_can_be_disabled ... ok +test pkg_manifest::tests::output_fork_instrumentation_defaults_to_auto ... ok +test pkg_manifest::tests::overlay_absent_uses_base ... ok +test pkg_manifest::tests::overlay_merges_binary_block_over_base ... ok +test pkg_manifest::tests::overlay_merges_multiple_arches_from_overlay ... ok +test pkg_manifest::tests::overlay_with_non_binary_field_is_rejected ... ok +test pkg_manifest::tests::parse_accepts_no_binary_block ... ok +test pkg_manifest::tests::parse_archived_accepts_full_compatibility_block ... ok +test pkg_manifest::tests::parse_archived_accepts_legacy_script_field ... ok +test pkg_manifest::tests::parse_archived_accepts_repo_url_and_commit ... ok +test pkg_manifest::tests::parse_archived_accepts_script_path_field ... ok +test pkg_manifest::tests::parse_archived_rejects_empty_abi_versions ... ok +test pkg_manifest::tests::parse_archived_rejects_short_cache_key_sha ... ok +test pkg_manifest::tests::parse_archived_rejects_uppercase_cache_key_sha ... ok +test pkg_manifest::tests::parse_archived_rejects_zero_revision ... ok +test pkg_manifest::tests::parse_archived_requires_compatibility_block ... ok +test pkg_manifest::tests::parses_build_toml_with_direct_url ... ok +test pkg_manifest::tests::parses_build_toml_with_indexed_binary ... ok +test pkg_manifest::tests::parses_host_tools_with_defaults ... ok +test pkg_manifest::tests::parses_manifest_with_kind_library ... ok +test pkg_manifest::tests::parses_minimal_manifest ... ok +test pkg_manifest::tests::parses_minimal_program_manifest ... ok +test pkg_manifest::tests::parses_multi_output_program_manifest ... ok +test pkg_manifest::tests::parses_top_level_kernel_abi ... ok +test pkg_manifest::tests::rejects_build_toml_direct_url_without_sha ... ok +test pkg_manifest::tests::rejects_build_toml_with_absolute_input ... ok +test pkg_manifest::tests::rejects_build_toml_with_both_indexed_and_direct ... ok +test pkg_manifest::tests::rejects_build_toml_with_empty_binary ... ok +test pkg_manifest::tests::rejects_build_toml_with_parent_dir_input ... ok +test pkg_manifest::tests::rejects_build_toml_with_unknown_binary_field ... ok +test pkg_manifest::tests::rejects_build_toml_with_unknown_top_level_field ... ok +test pkg_manifest::tests::rejects_compatibility_in_source_mode ... ok +test pkg_manifest::tests::rejects_duplicate_depends_on ... ok +test pkg_manifest::tests::rejects_empty_spdx ... ok +test pkg_manifest::tests::rejects_library_with_array_outputs ... ok +test pkg_manifest::tests::rejects_manifest_without_kind ... ok +test pkg_manifest::tests::rejects_program_output_with_empty_wasm ... ok +test pkg_manifest::tests::rejects_program_with_no_outputs ... ok +test pkg_manifest::tests::rejects_program_with_table_outputs ... ok +test pkg_manifest::tests::rejects_uppercase_or_short_sha ... ok +test pkg_manifest::tests::resolve_index_url_passes_through_template_without_abi_token ... ok +test pkg_manifest::tests::resolve_index_url_returns_none_for_direct_source ... ok +test pkg_manifest::tests::resolve_index_url_substitutes_abi_in_indexed_form ... ok +test pkg_manifest::tests::source_kind_minimal_manifest_parses ... ok +test pkg_manifest::tests::source_kind_rejects_binary_block ... ok +test pkg_manifest::tests::source_package_toml_accepts_minimal_new_format ... ok +test pkg_manifest::tests::source_package_toml_rejects_legacy_binary_block ... ok +test pkg_manifest::tests::source_package_toml_rejects_legacy_build_commit ... ok +test pkg_manifest::tests::source_package_toml_rejects_legacy_build_repo_url ... ok +test pkg_manifest::tests::source_package_toml_rejects_legacy_revision_field ... ok +test pkg_manifest::tests::source_parse_accepts_kernel_abi_absent_when_no_build_block ... ok +test pkg_manifest::tests::source_parse_rejects_missing_kernel_abi_when_build_block_present ... ok +test pkg_manifest::tests::target_arch_as_str_is_stable ... ok +test pkg_manifest::tests::version_constraint_accepts_two_and_three_component ... ok +test pkg_manifest::tests::version_constraint_compares_numerically_not_lexicographically ... ok +test pkg_manifest::tests::version_constraint_rejects_compound ... ok +test pkg_manifest::tests::version_constraint_rejects_other_operators ... ok +test pkg_manifest::tests::version_constraint_rejects_prerelease_suffix ... ok +test pkg_manifest::tests::version_eq_and_ord_agree_on_patch_none_zero ... ok +test remote_fetch::tests::extract_tar_zst_round_trips ... ok +test remote_fetch::tests::fetch_archive_reads_file_scheme_to_temp_file ... ok +test remote_fetch::tests::fetch_archive_restarts_when_range_is_ignored ... ok +test remote_fetch::tests::fetch_archive_resumes_after_midstream_failure_with_ranges ... ok +test remote_fetch::tests::fetch_archive_sha_mismatch_fails_and_removes_temp_file ... ok +test remote_fetch::tests::fetch_archive_streams_http_to_temp_file ... ok +test remote_fetch::tests::fetch_http_url_retries_transient_5xx ... ok +test remote_fetch::tests::fetch_url_reads_file_scheme ... ok +test remote_fetch::tests::fetch_url_rejects_unsupported_scheme ... ok +test remote_fetch::tests::fetch_url_returns_error_for_missing_file ... ok +test remote_fetch::tests::flatten_archive_layout_hoists_artifacts ... ok +test remote_fetch::tests::offline_env_var_blocks_fetch ... ok +test remote_fetch::tests::verify_sha_accepts_matching_digest ... ok +test remote_fetch::tests::verify_sha_rejects_mismatched_digest ... ok +test source_extract::tests::extract_preserves_multiple_top_level_entries ... ok +test source_extract::tests::extract_tar_gz_strips_single_top_level_dir ... ok +test source_extract::tests::extract_tar_zst_round_trips ... ok +test source_extract::tests::fetch_and_extract_via_file_url_succeeds ... ok +test source_extract::tests::from_url_detects_known_extensions ... ok +test source_extract::tests::from_url_handles_query_string_and_fragment ... ok +test source_extract::tests::from_url_rejects_unknown_extension ... ok +test update_pkg_manifest::tests::idempotent_no_op_when_commit_already_matches ... ok +test update_pkg_manifest::tests::idempotent_when_values_already_match ... ok +test update_pkg_manifest::tests::overwrites_existing_commit_on_rebase ... ok +test update_pkg_manifest::tests::rejects_bad_arch ... ok +test update_pkg_manifest::tests::rejects_bad_sha ... ok +test update_pkg_manifest::tests::rejects_empty_commit ... ok +test update_pkg_manifest::tests::rejects_empty_url ... ok +test update_pkg_manifest::tests::rejects_mixed_shape_binary ... ok +test update_pkg_manifest::tests::rejects_wasm64_against_bare_binary ... ok +test update_pkg_manifest::tests::rejects_wasm64_against_bare_binary_even_when_arches_present ... ok +test update_pkg_manifest::tests::rejects_whitespace_in_commit ... ok +test update_pkg_manifest::tests::shape_from_bare_binary_overrides_arches_present ... ok +test update_pkg_manifest::tests::shape_from_bare_binary_with_single_arches_entry ... ok +test update_pkg_manifest::tests::shape_from_per_arch_binary_overrides_arches_absent ... ok +test update_pkg_manifest::tests::skips_silently_when_build_block_absent ... ok +test update_pkg_manifest::tests::updates_both_arches_independently ... ok +test update_pkg_manifest::tests::updates_one_arch_in_multi_arch_block ... ok +test update_pkg_manifest::tests::updates_single_arch_bare_binary_block ... ok +test update_pkg_manifest::tests::writes_commit_when_build_block_present ... ok +test util::tests::hex_empty_is_empty ... ok +test util::tests::hex_encodes_known_bytes ... ok +test util::tests::hex_length_is_double_input ... ok + +failures: + +---- archive_stage_cli::tests::cli_produces_archive_with_canonical_filename stdout ---- +/tmp/nix-shell.EmRHqr/wpk-xtask-archive-stage-cli/e2-smoke-49060/out/z-1.0.0-rev1-abi15-wasm32-a8df5ebe.tar.zst + +thread 'archive_stage_cli::tests::cli_produces_archive_with_canonical_filename' (570566158) panicked at tools/xtask/src/archive_stage_cli.rs:619:9: +got: z-1.0.0-rev1-abi15-wasm32-a8df5ebe.tar.zst +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + +---- build_deps::tests::binaries_dir_program_fetch_does_not_require_built_deps stdout ---- +warning: index-based fetch for progIdx@1.0.0 from file:///tmp/nix-shell.EmRHqr/wpk-xtask-test/prog-bdir-remote-first-archive-49060/progIdx-1.0.0.tar.zst produced a stale artifact (/tmp/nix-shell.EmRHqr/wpk-xtask-test/prog-bdir-remote-first-cache-49060/programs/progIdx-1.0.0-rev1-wasm32-831b231c/progIdx.wasm: missing required exports: __abi_version, _start); falling back to source build + +thread 'build_deps::tests::binaries_dir_program_fetch_does_not_require_built_deps' (570566403) panicked at tools/xtask/src/build_deps.rs:5642:71: +called `Result::unwrap()` on an `Err` value: "baddep@1.0.0: build script /tmp/nix-shell.EmRHqr/wpk-xtask-test/prog-bdir-remote-first-reg-49060/baddep/build-baddep.sh exited with exit status: 42" + +---- build_deps::tests::cmd_resolve_with_binaries_dir_places_kernel_at_root stdout ---- + +thread 'build_deps::tests::cmd_resolve_with_binaries_dir_places_kernel_at_root' (570566871) panicked at tools/xtask/src/build_deps.rs:7312:14: +called `Result::unwrap()` on an `Err` value: "/tmp/nix-shell.EmRHqr/wpk-xtask-test/resolve-bdir-kernel-cache-49060/programs/kernel-0.1.0-rev1-wasm64-4b872689.tmp-49060/kandelo-kernel.wasm: is not a wasm binary" + +---- build_deps::tests::cmd_resolve_with_binaries_dir_places_multi_output_symlinks stdout ---- + +thread 'build_deps::tests::cmd_resolve_with_binaries_dir_places_multi_output_symlinks' (570566966) panicked at tools/xtask/src/build_deps.rs:7349:14: +called `Result::unwrap()` on an `Err` value: "/tmp/nix-shell.EmRHqr/wpk-xtask-test/resolve-bdir-multi-cache-49060/programs/twobin-0.1.0-rev1-wasm32-01c06ad6.tmp-49060/alpha.wasm: is not a wasm binary" + +---- build_deps::tests::cmd_resolve_with_binaries_dir_places_single_output_symlink stdout ---- + +thread 'build_deps::tests::cmd_resolve_with_binaries_dir_places_single_output_symlink' (570567054) panicked at tools/xtask/src/build_deps.rs:7271:14: +called `Result::unwrap()` on an `Err` value: "/tmp/nix-shell.EmRHqr/wpk-xtask-test/resolve-bdir-single-cache-49060/programs/tinybin-0.1.0-rev1-wasm32-83907833.tmp-49060/tinybin.wasm: is not a wasm binary" + +---- build_deps::tests::cmd_resolve_with_binaries_dir_replaces_existing_link stdout ---- + +thread 'build_deps::tests::cmd_resolve_with_binaries_dir_replaces_existing_link' (570567163) panicked at tools/xtask/src/build_deps.rs:7414:14: +called `Result::unwrap()` on an `Err` value: "/tmp/nix-shell.EmRHqr/wpk-xtask-test/resolve-bdir-replace-cache-49060/programs/rep-0.1.0-rev1-wasm32-b2ed7cc6.tmp-49060/rep.wasm: is not a wasm binary" + +---- build_deps::tests::cmd_resolve_without_binaries_dir_places_no_symlinks stdout ---- + +thread 'build_deps::tests::cmd_resolve_without_binaries_dir_places_no_symlinks' (570567280) panicked at tools/xtask/src/build_deps.rs:7377:88: +called `Result::unwrap()` on an `Err` value: "/tmp/nix-shell.EmRHqr/wpk-xtask-test/resolve-bdir-none-cache-49060/programs/noflag-0.1.0-rev1-wasm32-b0eb2736.tmp-49060/noflag.wasm: is not a wasm binary" + + +failures: + archive_stage_cli::tests::cli_produces_archive_with_canonical_filename + build_deps::tests::binaries_dir_program_fetch_does_not_require_built_deps + build_deps::tests::cmd_resolve_with_binaries_dir_places_kernel_at_root + build_deps::tests::cmd_resolve_with_binaries_dir_places_multi_output_symlinks + build_deps::tests::cmd_resolve_with_binaries_dir_places_single_output_symlink + build_deps::tests::cmd_resolve_with_binaries_dir_replaces_existing_link + build_deps::tests::cmd_resolve_without_binaries_dir_places_no_symlinks + +test result: FAILED. 290 passed; 7 failed; 0 ignored; 0 measured; 0 filtered out; finished in 16.22s + +error: test failed, to rerun pass `-p xtask --bin xtask` +EXIT=101 diff --git a/tests/scripts/ci-run-test-suite-groups.test.sh b/tests/scripts/ci-run-test-suite-groups.test.sh index a2b648a8fb..5a6f34aaf0 100755 --- a/tests/scripts/ci-run-test-suite-groups.test.sh +++ b/tests/scripts/ci-run-test-suite-groups.test.sh @@ -1179,6 +1179,15 @@ fi exit 1 } +: > "$CARGO_CAPTURE" +PATH="$FIXTURE/bin:$PATH" \ + bash "$FIXTURE/scripts/ci-run-test-suite.sh" cargo-xtask all +grep -Fxq "test -p xtask --target fixture-host" "$CARGO_CAPTURE" || { + echo "ci-run-test-suite.sh did not dispatch the cargo-xtask suite" >&2 + cat "$CARGO_CAPTURE" >&2 + exit 1 +} + for workflow in \ "$REPO_ROOT/.github/workflows/staging-build.yml" \ "$REPO_ROOT/.github/workflows/prepare-merge.yml"; do @@ -1209,52 +1218,27 @@ for workflow in \ exit 1 fi - case "$(basename "$workflow")" in - staging-build.yml) - node_acceptance_name="Run exact staged Node npm acceptance" - ;; - prepare-merge.yml) - node_acceptance_name="Build and run exact candidate Node npm acceptance" - ;; - esac - node_acceptance_block="$TMP_DIR/$(basename "$workflow").node-acceptance" - awk -v expected=" - name: $node_acceptance_name" ' - $0 == expected { - inside = 1 - print - next + early_rows=$(sed -n '/^ test-suite-early:/,/^ env:/p' "$workflow" | awk ' + /^ - suite: / { + suite = $0 + sub(/^ - suite: /, "", suite) } - inside && /^ - name: / { exit } - inside { print } - ' "$workflow" > "$node_acceptance_block" - dev_shell_line="$(awk ' - /scripts\/dev-shell\.sh/ { print NR; exit } - ' "$node_acceptance_block")" - activation_line="$(awk ' - /activate-ci-test-workspace\.sh/ { print NR; exit } - ' "$node_acceptance_block")" - consumer_line="$(awk ' - /resolve-binary\.sh|npm run build|npx playwright test/ { - print NR - exit + /^ kernel_only: / { + kernel_only = $0 + sub(/^ kernel_only: /, "", kernel_only) + print suite ":" kernel_only } - ' "$node_acceptance_block")" - if ! [[ "$dev_shell_line" =~ ^[1-9][0-9]*$ ]] || - ! [[ "$activation_line" =~ ^[1-9][0-9]*$ ]] || - ! [[ "$consumer_line" =~ ^[1-9][0-9]*$ ]] || - [ "$dev_shell_line" -ge "$activation_line" ] || - [ "$activation_line" -ge "$consumer_line" ]; then - echo "$(basename "$workflow"): direct Node acceptance does not activate its transported cache identity inside the dev shell before consumption" >&2 + ') + expected_early_rows=$'cargo-kernel:true\nfork-instrument:true\ncargo-xtask:false' + if [ "$early_rows" != "$expected_early_rows" ]; then + echo "$(basename "$workflow"): unexpected early Cargo suite matrix:" >&2 + printf '%s\n' "$early_rows" >&2 exit 1 fi - if [ "$(basename "$workflow")" = prepare-merge.yml ]; then - dev_shell_count="$(grep -Fc 'scripts/dev-shell.sh' "$node_acceptance_block")" - if [ "$dev_shell_count" -ne 1 ] || - ! grep -Fq "bash <<'NODE_ACCEPTANCE'" "$node_acceptance_block"; then - echo "prepare-merge.yml: candidate Node resolve, build, and acceptance do not share one activated dev-shell process" >&2 - exit 1 - fi - fi + grep -Fq 'KERNEL_ONLY: ${{ matrix.kernel_only }}' "$workflow" || { + echo "$(basename "$workflow"): early Cargo suites lack kernel scope" >&2 + exit 1 + } done grep -Fq \ @@ -1303,7 +1287,7 @@ force_rebuild_rows=$(sed -n \ print suite ":" group } ') -expected_force_rebuild_rows=$'cargo-kernel:all\nfork-instrument:all\nvitest:1/2\nvitest:2/2\nvitest:resource-isolated\nlibc:functional-regression\nlibc:math\nposix:all\nsortix:include\nsortix:basic\nsortix:runtime' +expected_force_rebuild_rows=$'cargo-kernel:all\nfork-instrument:all\ncargo-xtask:all\nvitest:1/2\nvitest:2/2\nvitest:resource-isolated\nlibc:functional-regression\nlibc:math\nposix:all\nsortix:include\nsortix:basic\nsortix:runtime' if [ "$force_rebuild_rows" != "$expected_force_rebuild_rows" ]; then echo "force-rebuild.yml: unexpected test-suite matrix:" >&2 printf '%s\n' "$force_rebuild_rows" >&2 diff --git a/tools/xtask/src/archive_stage_cli.rs b/tools/xtask/src/archive_stage_cli.rs index 3769680fd2..61e7d0d82a 100644 --- a/tools/xtask/src/archive_stage_cli.rs +++ b/tools/xtask/src/archive_stage_cli.rs @@ -898,6 +898,11 @@ built_by = "test" ); let name = &entries[0]; // --rev-abi--.tar.zst + // Derive the abi segment from the shared const so the assertion + // tracks ABI_VERSION instead of drifting on every bump, and doubles as a + // guard that the canonical filename encodes the real ABI. (The sibling + // `cli_archive_filename_uses_build_toml_revision` stays abi-pinned on + // purpose: it passes `--abi "4"` to test the revision field.) let prefix = format!("z-1.0.0-rev1-abi{}-wasm32-", shared::ABI_VERSION); assert!(name.starts_with(&prefix), "got: {name}"); assert!(name.ends_with(".tar.zst"), "got: {name}"); diff --git a/tools/xtask/src/build_deps.rs b/tools/xtask/src/build_deps.rs index bd083ae294..4d0a04b972 100644 --- a/tools/xtask/src/build_deps.rs +++ b/tools/xtask/src/build_deps.rs @@ -11814,10 +11814,19 @@ wasm = "second.wasm" complete_wasm_fork_artifact(4) } + /// A minimal, valid executable wasm module that exports exactly the required + /// program entrypoint exports (`__abi_version`, `_start`) -- the canonical + /// fixture shape for a non-kernel program output. + /// + /// Program-build fixtures must emit real wasm, never `touch` an empty file: + /// output/cache validation correctly rejects empty and export-less outputs. + /// Kernel fixtures must instead use the complete shared kernel export set. fn minimal_executable_wasm() -> Vec { wasm_exporting_names(&EXECUTABLE_PROGRAM_REQUIRED_EXPORTS) } + /// Render a build script that writes valid fixture bytes to a declared + /// program output instead of creating an empty file rejected by validation. fn emit_wasm_build_script(rel: &str, bytes: &[u8]) -> String { let escaped = bytes .iter() @@ -15749,6 +15758,10 @@ cache_key_sha = "{cache_key_hex}" &[TEST_ABI], &cache_key_hex, ); + // Valid wasm so the fetched artifact passes cache validation and the + // remote-first path is actually exercised (a header-only fixture is + // rejected as "missing required exports", forcing the source-build + // fallback this test is meant to prove does NOT happen). let prog_wasm = minimal_executable_wasm(); let archive_bytes = crate::remote_fetch::build_test_archive( &manifest_text, @@ -18707,34 +18720,55 @@ wasm = "bad.wasm" } #[test] - fn wasm_artifact_policy_rejects_empty_and_exportless_executables() { + fn wasm_artifact_policy_rejects_empty_and_exportless_when_exports_required() { + // Regression guard for the stale-fixture class (kd-872c / kd-xc19): the + // production artifact validation MUST keep rejecting empty + // (`touch`-style, 0-byte / non-wasm) outputs and wasm missing required + // exports. The 7 stale tests failed precisely because they emitted such + // fixtures; "fixing" them by weakening validation would trip these + // assertions. Fix the fixture (emit real wasm) instead. let required = &EXECUTABLE_PROGRAM_REQUIRED_EXPORTS; + // Empty and non-wasm bytes -> "is not a wasm binary". assert_eq!( wasm_artifact_policy_failures_for(&[], ForkInstrumentationPolicy::Auto, required), - ["is not a wasm binary"] + vec!["is not a wasm binary".to_string()], + "empty output must be rejected", + ); + assert_eq!( + wasm_artifact_policy_failures_for( + b"not wasm", + ForkInstrumentationPolicy::Auto, + required + ), + vec!["is not a wasm binary".to_string()], + "non-wasm output must be rejected", ); - let failures = wasm_artifact_policy_failures_for( + // Header-only wasm with no export section -> "missing required exports". + let exportless = wasm_artifact_policy_failures_for( b"\0asm\x01\0\0\0", ForkInstrumentationPolicy::Auto, required, ); - assert_eq!(failures.len(), 1, "got: {failures:?}"); + assert_eq!(exportless.len(), 1, "got: {exportless:?}"); assert!( - failures[0].contains("missing required exports") - && failures[0].contains("__abi_version") - && failures[0].contains("_start"), - "got: {failures:?}" + exportless[0].contains("missing required exports") + && exportless[0].contains("__abi_version") + && exportless[0].contains("_start"), + "got: {exportless:?}", ); + // Positive control: the shared minimal fixture passes cleanly, so the + // rejections above are about the fixture, not an over-strict policy. assert!( wasm_artifact_policy_failures_for( &minimal_executable_wasm(), ForkInstrumentationPolicy::Auto, required, ) - .is_empty() + .is_empty(), + "minimal executable wasm must satisfy validation", ); } From 220e8ac7f114cae55ab8a5cd87ddff7c7789c73e Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Mon, 6 Jul 2026 07:04:46 -0400 Subject: [PATCH 36/82] CI: Gate the Rust workspace as one contract Replace the `cargo-kernel` and `fork-instrument` CI entries with one `cargo-workspace` suite: cargo test --workspace --exclude xtask --target This is closed by default: new workspace crates enter the gate without another matrix edit, and integration tests run without a misleading `--lib` restriction. The source audit found that `wasm-posix-shared` and `wasm-local-root-spill` had not been covered by the old allow list. Keep xtask in its independent always-run `cargo-xtask` suite. It lives under `tools/`, outside kernel change scope, and its regressions are kernel-independent. The two gates complement rather than supersede one another. Document the audit and the complement-versus-supersede decision in: docs/plans/2026-07-05-workspace-unit-test-gating-audit.md Historical source-branch validation passed 1,172 tests across 22 test binaries with no failures. ABI 43 forward-port: Move `cargo-workspace` into the current early source-only matrix while retaining `cargo-xtask` as non-kernel-gated. On this stack, the complete workspace command passed, including 1,501 kernel tests, all current fork-instrument tests, 46 shared tests, 13 root-spill integration tests, and doc tests. The runner/workflow contract, workflow YAML parsing, and documentation build also passed. Bead: kd-i9oc (initiative validation-gates, umbrella kd-u7f) Co-Authored-By: Claude Opus 4.8 (cherry picked from commit 075e74ac2a51cd626ba431a7d17809d666335d3f) --- .github/workflows/force-rebuild.yml | 12 +- .github/workflows/prepare-merge.yml | 9 +- .github/workflows/staging-build.yml | 9 +- docs/agent-guidance/validation.md | 5 +- ...-07-05-workspace-unit-test-gating-audit.md | 362 ++++++++++++++++++ scripts/ci-run-test-suite.sh | 22 +- .../scripts/ci-run-test-suite-groups.test.sh | 14 +- 7 files changed, 403 insertions(+), 30 deletions(-) create mode 100644 docs/plans/2026-07-05-workspace-unit-test-gating-audit.md diff --git a/.github/workflows/force-rebuild.yml b/.github/workflows/force-rebuild.yml index e754671a28..16c4228ff5 100644 --- a/.github/workflows/force-rebuild.yml +++ b/.github/workflows/force-rebuild.yml @@ -818,15 +818,11 @@ jobs: # remove serial work while `test-gate` still aggregates every cell. matrix: include: - - suite: cargo-kernel - label: cargo-kernel - group: all - needs_submodules: false - needs_toolchain: false - needs_workspace: false - - suite: fork-instrument - label: fork-instrument + - suite: cargo-workspace + label: cargo-workspace group: all + # Cover every workspace crate except independently gated xtask. + # New crates are included without another matrix allow-list edit. needs_submodules: false needs_toolchain: false needs_workspace: false diff --git a/.github/workflows/prepare-merge.yml b/.github/workflows/prepare-merge.yml index 3723f5414f..a0fb09a1a8 100644 --- a/.github/workflows/prepare-merge.yml +++ b/.github/workflows/prepare-merge.yml @@ -1607,11 +1607,10 @@ jobs: fail-fast: false matrix: include: - - suite: cargo-kernel - label: cargo-kernel - kernel_only: true - - suite: fork-instrument - label: fork-instrument + - suite: cargo-workspace + label: cargo-workspace + # Closed-by-default coverage for every workspace crate except + # independently gated xtask, including integration-test crates. kernel_only: true - suite: cargo-xtask label: cargo-xtask diff --git a/.github/workflows/staging-build.yml b/.github/workflows/staging-build.yml index 8ca846d31d..403f0f6a8e 100644 --- a/.github/workflows/staging-build.yml +++ b/.github/workflows/staging-build.yml @@ -836,11 +836,10 @@ jobs: fail-fast: false matrix: include: - - suite: cargo-kernel - label: cargo-kernel - kernel_only: true - - suite: fork-instrument - label: fork-instrument + - suite: cargo-workspace + label: cargo-workspace + # Closed-by-default coverage for every workspace crate except + # independently gated xtask, including integration-test crates. kernel_only: true - suite: cargo-xtask label: cargo-xtask diff --git a/docs/agent-guidance/validation.md b/docs/agent-guidance/validation.md index 46e6466384..e9f027a4a3 100644 --- a/docs/agent-guidance/validation.md +++ b/docs/agent-guidance/validation.md @@ -20,8 +20,7 @@ Core validation surface: | Suite | Command | Primary evidence for | |---|---|---| -| Kernel unit tests | `cargo test -p kandelo --target --lib` | Kernel logic changes | -| Fork instrument tests | `cargo test -p fork-instrument --target ` | Fork instrumentation/tooling changes | +| Workspace Rust tests | `cargo test --workspace --exclude xtask --target ` | Any change under `crates/`: kernel, fork-instrument, shared, userspace, wasm-local-root-spill, and future workspace crates. `--target` is required because the default wasm32 target has no host runner; xtask has its own always-run suite. | | Package-system automation tests | `cargo test -p xtask --target ` | `tools/xtask/**` changes: package resolver, binaries-dir placement, cache/output artifact validation, archive staging + canonical filename | | Host integration tests | `cd host && npx vitest run` | Host/runtime behavior | | Browser app/runtime tests | `cd apps/browser-demos && npx playwright test --grep-invert "@slow" --project=chromium` | Browser host, UI, demo, service worker, VFS image behavior | @@ -35,7 +34,7 @@ Core validation surface: For CI-shaped local runs, prefer: ```bash -bash scripts/dev-shell.sh bash scripts/ci-run-test-suite.sh [group] +bash scripts/dev-shell.sh bash scripts/ci-run-test-suite.sh [group] ``` The optional group reproduces CI's deterministic suite partitions. Vitest diff --git a/docs/plans/2026-07-05-workspace-unit-test-gating-audit.md b/docs/plans/2026-07-05-workspace-unit-test-gating-audit.md new file mode 100644 index 0000000000..ea257e9aab --- /dev/null +++ b/docs/plans/2026-07-05-workspace-unit-test-gating-audit.md @@ -0,0 +1,362 @@ +# Workspace unit-test gating audit — broaden the cargo gate to `--workspace` + +- **Bead:** kd-7yjx (initiative `validation-gates`, umbrella convoy kd-u7f) +- **Discovered from:** kd-872c (which gated `cargo test -p xtask`) → open question from kd-xc19 +- **Author:** designer-adhoc-f8a9bbfb5b, 2026-07-05 +- **Status:** design + audit complete; implementation deferred to a follow-up bead +- **Audited base:** `origin/main` @ `24668156b` (ABI 16), under `scripts/dev-shell.sh` +- **Evidence:** `test-runs/kd-7yjx/` (per-crate logs, workspace run, control run, outcome lists, SUMMARY.md) + +## 1. Problem statement + +The `validation-gates` initiative (kd-u7f) exists because CI test gates rotted +silently. kd-xc19 traced 7 stale `xtask` fixtures that went unnoticed for a month +because **CI never ran `xtask`'s unit tests**: `scripts/ci-run-test-suite.sh` runs +cargo tests for only two packages, and `prepare-merge` builds `xtask` but never runs +its unit tests. kd-872c repairs those fixtures and gates `cargo test -p xtask`. + +kd-872c fixes *one* crate. The structural question this bead answers: **which other +workspace crates' unit tests does CI never run, are any of them silently rotting, and +should the gate be broadened to `cargo test --workspace` instead of accreting one +per-crate suite at a time?** + +The deeper defect is not "xtask was un-gated." It is that **the gate is an explicit +allow-list of packages**, so every new crate — and every crate that already existed +when the allow-list was written — is un-gated *by default* until a human remembers to +add it. That is the exact failure mode that let the xtask fixtures rot. A per-crate +allow-list cannot fix the class of bug it is an instance of. + +### Non-goals + +- **Not** repairing the 7 xtask fixtures or gating xtask per-crate — that is kd-872c + (PR #836), already done and depended upon here. +- **Not** changing what the kernel/fork-instrument suites assert. `--workspace` is a + strict superset; those crates keep running exactly as today. +- **Not** adding new tests to under-tested crates (e.g. `userspace` has 0 tests). Test + authorship is separate work; this bead is about *gating what already exists*. +- **Not** touching the non-cargo suites (vitest/browser/libc/posix/sortix). Those gate + runtime/host/conformance behavior and are orthogonal. +- **Not** gating wasm-target builds. The kernel's *product* build targets wasm32; this + is only about **host-run unit tests**. + +## 2. Audit findings (evidence) + +The workspace has **6 members** (`Cargo.toml`). Default build target is +`wasm32-unknown-unknown` (`.cargo/config.toml`), which has **no host test runner** — +so every cargo *test* invocation must pass an explicit `--target `. + +CI's cargo surface (`scripts/ci-run-test-suite.sh`, matrix in +`.github/workflows/prepare-merge.yml` lines 1378–1414) is exactly two suites: + +- `cargo-kernel` → `cargo test -p kandelo --target --lib` +- `fork-instrument` → `cargo test -p fork-instrument --target ` + +`docs/agent-guidance/validation.md` documents only those two. + +| Package | Member | Host tests | Result | CI-gated? | Notes | +|---|---|---|---|---|---| +| `kandelo` | `crates/kernel` | 963 (lib) | pass | **yes** | `--lib`; crate has no `tests/` dir | +| `fork-instrument` | `crates/fork-instrument` | 177 (25 lib + 152 integ) | pass | **yes** | includes `tests/` (no `--lib`) | +| `wasm-posix-shared` | `crates/shared` | 19 (lib) | pass | **no** | un-gated, clean | +| `wasm-posix-userspace` | `crates/userspace` | 0 | n/a | no | no tests to gate | +| `wasm-local-root-spill` | `crates/wasm-local-root-spill` | 13 (integration) | pass | **no** | un-gated, clean; see `--lib` trap below | +| `xtask` | `tools/xtask` | 314 (307 pass / **7 fail**) | fail | **no** | the kd-xc19/kd-872c fixtures; PR #836 pending | + +**Latent rot found: none beyond xtask.** `wasm-posix-shared` (19) and +`wasm-local-root-spill` (13) are un-gated but green. `xtask`'s 7 failures are the +already-triaged (kd-xc19) and already-fixed (kd-872c/PR #836) stale fixtures — not a +new discovery, so no new failure-triage bead is warranted. + +**The `--lib` trap (concrete reason to prefer `--workspace` over hand-written +per-crate lines):** `wasm-local-root-spill`'s 13 tests live in `tests/root_spill.rs` +and the crate also has a `main.rs` bin target. A gate written by pattern-matching the +existing `cargo-kernel` line (`-p --lib`) would compile the crate and run +**zero** tests while looking green. `--workspace` runs lib + bin + integration test +binaries uniformly, so it cannot fall into this trap. + +### Two decisive experiments + +1. **`cargo test --workspace --target aarch64-apple-darwin`** → builds clean in 22.5s; + runs all 18 test binaries; **1479 passed, 7 failed (all xtask), 0 ignored**. It is a + strict superset of today's two suites (same 963 kernel + 177 fork-instrument tests) + plus the three un-gated crates. Once PR #836 lands, this run is fully green. + +2. **Naive `cargo test --workspace` (default target `wasm32-unknown-unknown`)** → + **fails to compile** (exit 101). `getrandom` hard-errors on wasm32-unknown-unknown; + `xtask`'s host-only native deps (`ring`, `zstd-sys`) cannot cross-compile with host + `cc` flags. **A workspace gate must therefore pass `--target `** — you cannot + drop `cargo test --workspace` into CI without it. + +## 3. Users and operator workflows + +- **PR author / reviewer:** wants merge-blocking CI to actually run the tests that + exist. Today they can add a crate with tests and CI silently ignores it. After this + change, any workspace crate's host tests block merges automatically. +- **Future crate author:** adds `crates/foo` to the workspace; its tests are gated with + zero CI edits. This is the durable win — no allow-list to remember. +- **Local pre-merge:** one command mirrors the cargo gate: + `bash scripts/dev-shell.sh cargo test --workspace --target "$(rustc -vV | awk '/^host/{print $2}')"`. +- **Debugger triaging a red gate:** the failing binary is named in the `test result:` + line and the `failures:` block, per-package, exactly as today. + +## 4. Architecture / control flow of the change + +``` +prepare-merge.yml (test-suite matrix) + └─ suite: cargo-workspace # replaces suites cargo-kernel + fork-instrument + └─ scripts/ci-run-test-suite.sh cargo-workspace + └─ cargo test --workspace --target "$(host_target)" + ├─ kandelo (963) ┐ + ├─ fork-instrument (177)│ superset of today + ├─ wasm-posix-shared(19)│ newly gated + ├─ wasm-local-root-spill(13) + ├─ wasm-posix-userspace (0) + └─ xtask (314) ┘ requires PR #836 green first +``` + +`.cargo/config.toml` already sets `RUST_TEST_THREADS=1` (kernel globals assume +single-threaded access); this applies to every test binary, and cargo runs test +binaries sequentially by default, so `--workspace` preserves the serialization the +kernel tests rely on. + +## 5. Decision + +**Adopt a single `cargo-workspace` suite running +`cargo test --workspace --target `, replacing the `cargo-kernel` and +`fork-instrument` suites, sequenced *after* kd-872c/PR #836 makes `xtask` green.** + +Rationale: + +1. **Root-cause fix, not another instance.** `--workspace` is closed-by-default: + present and future crates are gated without anyone editing an allow-list. Per-crate + gates re-implement the very "someone forgot to add it" mechanism that caused kd-xc19. +2. **No coverage regression.** It runs the same kernel + fork-instrument tests plus the + currently-invisible `shared` and `wasm-local-root-spill` tests. +3. **Avoids the `--lib` trap** that a hand-written per-crate line for + `wasm-local-root-spill` would fall into (0 tests run, looks green). +4. **Cheap and fast.** ~22.5s cold build; run dominated by xtask (~17s), kernel 963 + tests in 0.10s. Net CI cost is *lower* than today's two separate suites (one job, + one compile of the shared dep graph instead of two). +5. **Simpler to maintain.** One suite line and one `validation.md` row, versus a growing + list that must be hand-extended per crate (kd-872c would add a third; this collapses + them). + +## 6. Alternatives considered + +- **A: Per-crate gates (add `cargo test -p wasm-posix-shared`, `-p wasm-local-root-spill`, + keep xtask separate).** Rejected as the primary path: it perpetuates the allow-list + failure mode, needs a new line per future crate, and invites the `--lib` trap. Kept as + a **fallback** if `--workspace` proves undesirable in CI packaging (see risks) — the + per-crate commands are known-good and green (except xtask/#836). +- **B: Do nothing; document that only kernel + fork-instrument are gated.** Rejected: the + bead's parent initiative is *about* closing this gap; leaving `shared`/`spill` un-gated + keeps the silent-rot door open. +- **C: `cargo test --workspace` without `--target`.** Rejected by experiment — it does + not compile (wasm32 default target; host-only deps). Not viable. +- **D: Fold the workspace gate into kd-872c/PR #836.** Attractive if #836 is unmerged, + but #836 is closed and scoped to xtask; retargeting it risks scope creep and re-review. + Preferred: a small follow-up that supersedes #836's per-crate xtask suite once #836 is + green. If #836 is still open when the follow-up starts, folding in is acceptable. +- **E: Keep `-p kandelo --lib` semantics (exclude integration tests) for consistency.** + Rejected: `--workspace` should run integration tests where they exist + (`fork-instrument`, `wasm-local-root-spill`); `--lib` would silence real coverage. + +## 7. Risks and mitigations + +- **R1 — Gate turns red on merge if landed before #836.** The workspace run is red today + purely because of xtask. *Mitigation:* hard ordering — land the `cargo-workspace` suite + only after PR #836 (xtask green) merges to `origin/main`. Same green-before-gate + constraint kd-872c already operates under. The follow-up bead carries a `depends-on` + edge to kd-872c. +- **R2 — Interaction with #836's per-crate xtask suite.** If #836 adds a `cargo-xtask` + suite and this adds `cargo-workspace`, xtask would run twice. *Mitigation:* the + follow-up **replaces** cargo-kernel + fork-instrument (+ xtask if #836 added it) with + the single workspace suite; net suite count drops. +- **R3 — A future crate legitimately can't build/run on host** (e.g. a wasm-only crate + with no host-runnable tests). `--workspace` would try to compile it. *Mitigation:* + such a crate should carry `#![cfg(...)]`/`#[cfg(not(target_arch="wasm32"))]` test + guards or be `default-members`-excluded; document the expectation in `validation.md`. + Today all 6 members compile on host, so this is a forward-looking guard, not a current + blocker. +- **R4 — CI caching / build-graph differences.** Merging two jobs into one changes the + cache key and parallelism. *Mitigation:* the two cargo suites already share the same + dep graph and toolchain; one job compiles it once instead of twice. Verify wall-clock + on the impl PR; fall back to Alternative A if a packaging constraint surfaces. +- **R5 — Slower feedback: one red crate fails the whole suite.** True of any aggregated + gate. *Mitigation:* `cargo test` reports per-binary results and the failing package is + named; `--no-fail-fast` can be added if per-crate isolation is wanted without splitting + suites. +- **R6 — Doctests.** The host `--workspace` run executed 0 doctests (every crate's + doctest harness is empty; confirmed 0 in per-crate runs). No regression vs + `-p kandelo --lib` (which also excludes doctests). If doctests are later added, decide + explicitly whether the gate should include them (`--doc`). + +## 8. Implementation sequence (for the follow-up bead) + +1. **Precondition:** kd-872c/PR #836 merged to `origin/main`; confirm + `cargo test -p xtask --target ` is green on main. +2. Add a `cargo-workspace` case to `scripts/ci-run-test-suite.sh`: + `cargo test --workspace --target "$(host_target)"`. +3. In `.github/workflows/prepare-merge.yml` (and `staging-build.yml`, + `force-rebuild.yml` which share the runner), replace the `cargo-kernel` and + `fork-instrument` matrix entries (and any `cargo-xtask` #836 added) with a single + `cargo-workspace` entry (`kernel_only: true`, no submodules/toolchain/workspace). + Check `.github/actions/detect-change-scope` path rules so crate/tooling changes + trigger the new suite. +4. Update `docs/agent-guidance/validation.md`: collapse the two cargo rows into one + `Workspace unit tests | cargo test --workspace --target | Any Rust crate change` + row; keep the `` computation note. +5. Run `bash scripts/dev-shell.sh bash scripts/ci-run-test-suite.sh cargo-workspace` + locally; publish before/after outcome lists on the bead. +6. Open a PR to `Automattic/kandelo` (origin); record `github_pr`; run + `kandelo_pr_remote_policy.sh patrol`. + +## 9. Test and documentation plan + +- **Test:** the change *is* a test-gate change; validate by running the new suite + locally under dev-shell (green expected once #836 lands) and by observing the suite + execute in the PR's own prepare-merge run. Outcome lists (passed/failed/skipped) + published per the convoy artifact rule. +- **Docs:** `validation.md` suite table (step 4 above). No ABI, package, browser, or + host-runtime docs are affected (no product behavior changes). + +## 10. Open questions + +1. **Fold-in vs supersede #836?** If PR #836 is still open when the follow-up begins, + fold the workspace suite into it (single review); otherwise supersede. Coordinator to + confirm which, based on #836's merge status. +2. **`--no-fail-fast`?** Should the workspace suite continue past the first failing crate + to surface all failures in one run? Low-cost; recommend yes unless CI log volume is a + concern. +3. **Forward guard for host-unbuildable crates (R3).** Do we want a documented convention + now (cfg-guard test code / exclude from default-members) so the first wasm-only crate + doesn't break the gate, or defer until such a crate appears? Recommend a one-line note + in `validation.md` now. +4. **`default-members`.** The workspace sets no `default-members`; `--workspace` is + explicit and unaffected, but if `default-members` is later narrowed, the gate must + keep `--workspace` (not bare `cargo test`) to stay exhaustive. + +--- + +## 11. Addendum — implementation (kd-i9oc, 2026-07-06) + +Implementer: `designer-adhoc-f8a9bbfb5b`. Re-verified against `origin/main` @ +`24668156b` (unchanged since the audit) under `scripts/dev-shell.sh`. This +addendum records a **gating asymmetry** that the body of the design did not +fully reckon with, the two coherent strategies it produces, and the resolution +of §10's open questions. **It changes the recommended implementation shape** and +requests one coordinator decision. + +### 11.1 State of PR #836 at implementation time + +`gh pr view 836`: `state=OPEN`, `mergeable=MERGEABLE`, `mergeStateStatus=BLOCKED`, +`reviewDecision=REVIEW_REQUIRED` — CI green (cargo-kernel, fork-instrument +SUCCESS) but **not merged** (needs review approval). The dependency bead kd-872c +is *closed*, but per the Kandelo PR convention (close on reviewable-PR-opened, not +on merge) closed ≠ merged. So `origin/main` still contains the 7 failing xtask +fixtures. The body's §8.1 precondition ("#836 merged to origin/main") is therefore +**not met today**. + +### 11.2 The gating asymmetry the body missed + +The body treated the two cargo suites as interchangeable (both `kernel_only: +true`). Inspecting what #836 actually did reveals they are not: + +- #836 adds its `cargo-xtask` suite as **`kernel_only: false` (always-run)**, with + the rationale "xtask regressions are independent of kernel changes." It does + **not** touch `detect-change-scope`. +- The reason it must be always-run: `xtask` lives under `tools/xtask/`, which is + **outside** the `kernel` change-scope. `kernel_runtime_changed_files()` fires + `kernel=true` for `^(crates|libc|…)/` — i.e. all *five* non-xtask members + (`crates/{kernel,fork-instrument,shared,userspace,wasm-local-root-spill}`) but + **not** `tools/xtask/`. + +So a single `cargo test --workspace` suite (which necessarily runs all six +members in one invocation) faces a **trilemma**, because one gate cannot be two +trigger-classes at once: + +| Option | xtask-only change | non-crates/ PR (docs, packages) | verdict | +|---|---|---|---| +| `kernel_only: true` | **skips xtask** (silent-skip regression vs #836) | cheap (skipped) | ✗ reintroduces the class of bug | +| `kernel_only: false` (always run) | runs | **compiles the whole kernel on every PR** | ✗ recurring cost | +| `kernel_only: true` + add `tools/xtask` to kernel scope | runs | cheap | ✗ **over-triggers** libc/posix/sortix on xtask-only changes | + +None is clean. The body's §4 diagram (xtask *inside* the workspace suite) implies +Option 2 (Strategy SUPERSEDE below) but did not price its always-run cost or the +#836 sequencing that Option 2 forces. + +### 11.3 Two coherent strategies + +**Strategy COMPLEMENT (recommended, and what this bead implements).** Replace +`cargo-kernel` + `fork-instrument` with a single `kernel_only: true` +`cargo-workspace` suite running `cargo test --workspace --exclude xtask --target +`, and **keep #836's always-run `cargo-xtask` suite** untouched. + +- Covers the five `crates/` members (adds the previously-invisible `shared` (19) + and `wasm-local-root-spill` (13) — exactly the audit's finding) and remains + closed-by-default for any *future* crate under `crates/`. +- `--exclude xtask` is a one-item, *justified* exclusion (xtask is separately and + correctly gated), not the open-ended per-crate allow-list §5 warned against. +- **Green on `origin/main` today → no #836 dependency.** Measured + `cargo test --workspace --exclude xtask --target aarch64-apple-darwin` = + **1172 passed / 0 failed / 0 ignored** across 22 binaries (963 kernel + 177 + fork-instrument + 19 shared + 13 spill + 0 userspace = 1479 − 307 xtask-pass). + Verified both directly and through `ci-run-test-suite.sh cargo-workspace` + (evidence: `test-runs/kd-i9oc/`). It ran `spill`'s integration binary + (`root_spill.rs`, 13) — confirming the `--lib` trap is avoided. +- Cost profile ≈ post-#836 baseline + shared/spill; non-`crates/` PRs still skip + the kernel compile. Order-independent w.r.t. #836 (both edit adjacent matrix + lines; second-to-merge rebases trivially). + +**Strategy SUPERSEDE (the body's literal §5 decision).** One `kernel_only: false` +`cargo test --workspace` suite covering all six members, replacing +cargo-kernel + fork-instrument **and** #836's cargo-xtask. + +- Purest closed-by-default (one suite, zero exclusions), unifies xtask in. +- Costs: (a) **blocked on #836 merging** (red on main until then — the 7 xtask + failures); (b) compiles the full workspace incl. the kernel crate on **every** + PR; (c) removes another lineage's just-added suite → needs a fold-vs-supersede + ruling and touches #836's territory. + +### 11.4 Resolution of §10 open questions + +1. **Fold-in vs supersede #836 → resolved as COMPLEMENT (neither).** With #836 + *open*, the body offered "folding in is acceptable." But folding xtask into a + single workspace suite forces the §11.2 trilemma. COMPLEMENT sidesteps it: + don't fold, don't supersede — **complement**. kd-i9oc gates the `crates/` + workspace; #836 keeps xtask. Orthogonal, both unblocked. *(Coordinator: confirm + COMPLEMENT, or elect SUPERSEDE and accept its cost + #836 sequencing — see + §11.5.)* +2. **`--no-fail-fast`? → No, for now.** The suite is one `cargo test` invocation; + cargo already reports per-binary results and names the failing package, and the + host run is <1s after compile, so a second failing crate is cheap to surface on + the next push. Adding `--no-fail-fast` is a one-word change if a reviewer wants + all-failures-in-one-run; not worth the (tiny) risk of masking an early hard + error today. Documented so it's a conscious default, not an omission. +3. **Forward guard for host-unbuildable crates (R3) → documented convention.** A + future wasm-only member must carry `#[cfg(not(target_arch = "wasm32"))]` test + guards or be excluded from the suite (like xtask is). Captured here and in the + `cargo-workspace` case comment; deferred a `validation.md` prose paragraph to + avoid over-documenting a hypothetical (all six members build on host today). +4. **`default-members` → keep `--workspace` explicit.** Unchanged from the body; + COMPLEMENT's `--workspace --exclude xtask` stays exhaustive-minus-one + regardless of any future `default-members` narrowing. + +### 11.5 Coordinator decision requested + +**One decision:** confirm **COMPLEMENT** (recommended: unblocked, low-cost, +low-regret, gates the audit's actual finding now) or elect **SUPERSEDE** (single +always-run suite; accept every-PR kernel compile + wait for #836 + a +fold/supersede ruling on #836's suite). This bead implements COMPLEMENT; a switch +to SUPERSEDE is a small, documented pivot (drop `--exclude xtask`, set +`kernel_only: false`, and remove #836's `cargo-xtask` after #836 merges). + +### 11.6 What shipped + +`scripts/ci-run-test-suite.sh` (`cargo-workspace` case), the `test-suite` matrices +in `prepare-merge.yml` / `staging-build.yml` / `force-rebuild.yml` (one +`cargo-workspace` entry replacing the two cargo entries), and +`docs/agent-guidance/validation.md` (two cargo rows collapsed to one). No +`detect-change-scope` edit is needed under COMPLEMENT — all five gated members are +under `crates/`, already in the `kernel` scope; editing `ci-run-test-suite.sh` +itself also sets `kernel=true`, so the new suite self-exercises on this PR. diff --git a/scripts/ci-run-test-suite.sh b/scripts/ci-run-test-suite.sh index f87edf081e..88efbdec3d 100755 --- a/scripts/ci-run-test-suite.sh +++ b/scripts/ci-run-test-suite.sh @@ -20,7 +20,7 @@ host_target() { suite="${1:-}" if [ -z "$suite" ]; then - echo "usage: $0 [group]" >&2 + echo "usage: $0 [group]" >&2 exit 2 fi group="${2:-${TEST_GROUP:-all}}" @@ -305,13 +305,21 @@ run_pages_shaped_browser_build() { } case "$suite" in - cargo-kernel) + cargo-workspace) + # Host-run unit + integration tests for every workspace crate EXCEPT + # xtask: kandelo (kernel), fork-instrument, wasm-posix-shared, + # wasm-posix-userspace, wasm-local-root-spill. `--workspace` is + # closed-by-default: a new crate under crates/ is gated with no + # allow-list edit, and each crate's integration tests run too (no + # `--lib`, which would silently run 0 tests for a bin-only crate such + # as wasm-local-root-spill). xtask is excluded because it is gated + # separately as the always-run `cargo-xtask` suite -- it lives under + # tools/ (outside the kernel change-scope) and its regressions are + # independent of kernel changes. `--target ` is REQUIRED: the + # default wasm32-unknown-unknown target has no host test runner, and + # host-only deps (getrandom; xtask's ring/zstd) do not cross-compile. HOST_TARGET="$(host_target)" - cargo test -p kandelo --target "$HOST_TARGET" --lib - ;; - fork-instrument) - HOST_TARGET="$(host_target)" - cargo test -p fork-instrument --target "$HOST_TARGET" + cargo test --workspace --exclude xtask --target "$HOST_TARGET" ;; cargo-xtask) # Package-system automation unit tests (tools/xtask/**): package diff --git a/tests/scripts/ci-run-test-suite-groups.test.sh b/tests/scripts/ci-run-test-suite-groups.test.sh index 5a6f34aaf0..b825a419ac 100755 --- a/tests/scripts/ci-run-test-suite-groups.test.sh +++ b/tests/scripts/ci-run-test-suite-groups.test.sh @@ -1187,6 +1187,16 @@ grep -Fxq "test -p xtask --target fixture-host" "$CARGO_CAPTURE" || { cat "$CARGO_CAPTURE" >&2 exit 1 } +: > "$CARGO_CAPTURE" +PATH="$FIXTURE/bin:$PATH" \ + bash "$FIXTURE/scripts/ci-run-test-suite.sh" cargo-workspace all +grep -Fxq \ + "test --workspace --exclude xtask --target fixture-host" \ + "$CARGO_CAPTURE" || { + echo "ci-run-test-suite.sh did not dispatch cargo-workspace" >&2 + cat "$CARGO_CAPTURE" >&2 + exit 1 +} for workflow in \ "$REPO_ROOT/.github/workflows/staging-build.yml" \ @@ -1229,7 +1239,7 @@ for workflow in \ print suite ":" kernel_only } ') - expected_early_rows=$'cargo-kernel:true\nfork-instrument:true\ncargo-xtask:false' + expected_early_rows=$'cargo-workspace:true\ncargo-xtask:false' if [ "$early_rows" != "$expected_early_rows" ]; then echo "$(basename "$workflow"): unexpected early Cargo suite matrix:" >&2 printf '%s\n' "$early_rows" >&2 @@ -1287,7 +1297,7 @@ force_rebuild_rows=$(sed -n \ print suite ":" group } ') -expected_force_rebuild_rows=$'cargo-kernel:all\nfork-instrument:all\ncargo-xtask:all\nvitest:1/2\nvitest:2/2\nvitest:resource-isolated\nlibc:functional-regression\nlibc:math\nposix:all\nsortix:include\nsortix:basic\nsortix:runtime' +expected_force_rebuild_rows=$'cargo-workspace:all\ncargo-xtask:all\nvitest:1/2\nvitest:2/2\nvitest:resource-isolated\nlibc:functional-regression\nlibc:math\nposix:all\nsortix:include\nsortix:basic\nsortix:runtime' if [ "$force_rebuild_rows" != "$expected_force_rebuild_rows" ]; then echo "force-rebuild.yml: unexpected test-suite matrix:" >&2 printf '%s\n' "$force_rebuild_rows" >&2 From cf8b01953850e2a0734f6fe41b59dc522e79f250 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Thu, 25 Jun 2026 00:55:45 -0400 Subject: [PATCH 37/82] Host: Reuse one bundled source worker entry Source checkouts without host/dist previously started a tsx loader for every process worker. Bundle the TypeScript entry once with esbuild and reuse it while retaining the compiled-entry preference and truthful tsx fallback. Preserve the current one-shot process initialization and worker stack policy. Remove partial bundle output on failure, synchronize the host lockfile, and document and benchmark this Node-only source path. Browser workers continue to use their build-time bundle. (cherry picked from commit 07c89462132616e5fcb6e976749f43d1edc8a898) Co-authored-by: Claude Opus 4.8 (1M context) --- benchmarks/node-source-worker-startup.ts | 149 ++++++++++++++++++ docs/architecture.md | 7 + .../2026-08-01-node-source-worker-startup.md | 50 ++++++ host/package-lock.json | 1 + host/package.json | 1 + host/src/worker-adapter.ts | 60 ++++++- host/test/node-worker-adapter.test.ts | 111 +++++++++++++ 7 files changed, 376 insertions(+), 3 deletions(-) create mode 100644 benchmarks/node-source-worker-startup.ts create mode 100644 docs/measurements/2026-08-01-node-source-worker-startup.md create mode 100644 host/test/node-worker-adapter.test.ts diff --git a/benchmarks/node-source-worker-startup.ts b/benchmarks/node-source-worker-startup.ts new file mode 100644 index 0000000000..445d18b50f --- /dev/null +++ b/benchmarks/node-source-worker-startup.ts @@ -0,0 +1,149 @@ +#!/usr/bin/env npx tsx +/** + * Compare Node's source-checkout worker bundle with the prior per-worker tsx + * loader. This benchmark intentionally bypasses the bundle resolver for its + * baseline so both paths run from the same checkout and worker source. + */ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { cpus, tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { performance } from "node:perf_hooks"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { + NodeWorkerAdapter, + type WorkerHandle, +} from "../host/src/worker-adapter"; + +type Mode = "bundle" | "tsx"; + +function positiveInteger(raw: string | undefined, fallback: number): number { + if (raw === undefined) return fallback; + const parsed = Number(raw); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error(`expected a positive integer, received ${raw}`); + } + return parsed; +} + +function option(name: string): string | undefined { + const prefix = `--${name}=`; + return process.argv.find((argument) => argument.startsWith(prefix)) + ?.slice(prefix.length); +} + +function median(values: number[]): number { + const sorted = [...values].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[middle - 1] + sorted[middle]) / 2 + : sorted[middle]; +} + +function waitForMessage(handle: WorkerHandle): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("worker did not reply within five seconds")), + 5_000, + ); + handle.on("message", () => { + clearTimeout(timeout); + resolve(); + }); + handle.on("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + handle.on("exit", (code) => { + if (code !== 0) { + clearTimeout(timeout); + reject(new Error(`worker exited ${code}`)); + } + }); + }); +} + +async function runMode( + mode: Mode, + entry: URL, + workers: number, +): Promise { + const adapter = new NodeWorkerAdapter(entry); + if (mode === "tsx") { + // WHY: returning null reproduces the source-checkout load order before the + // bundle path existed while leaving every other Worker option identical. + ( + adapter as unknown as { + resolveBundledSourceEntry: () => null; + } + ).resolveBundledSourceEntry = () => null; + } + + try { + const start = performance.now(); + for (let index = 0; index < workers; index++) { + const handle = adapter.createWorker({ index }); + try { + await waitForMessage(handle); + } finally { + await handle.terminate().catch(() => undefined); + } + } + return performance.now() - start; + } finally { + const bundledEntry = ( + adapter as unknown as { _bundledSourceEntry?: URL | false } + )._bundledSourceEntry; + if (bundledEntry instanceof URL) { + rmSync(dirname(fileURLToPath(bundledEntry)), { + recursive: true, + force: true, + }); + } + } +} + +const trials = positiveInteger(option("trials"), 3); +const workers = positiveInteger(option("workers"), 8); +const sourceDir = mkdtempSync(join(tmpdir(), "kandelo-worker-benchmark-")); +const entryPath = join(sourceDir, "ready.ts"); +writeFileSync( + entryPath, + [ + 'import { parentPort, workerData } from "node:worker_threads";', + "parentPort?.postMessage(workerData);", + ].join("\n"), +); + +const measurements: Record = { bundle: [], tsx: [] }; +try { + const entry = pathToFileURL(entryPath); + for (let trial = 0; trial < trials; trial++) { + const order: readonly Mode[] = trial % 2 === 0 + ? ["tsx", "bundle"] + : ["bundle", "tsx"]; + for (const mode of order) { + measurements[mode].push(await runMode(mode, entry, workers)); + } + } +} finally { + rmSync(sourceDir, { recursive: true, force: true }); +} + +const tsxMedianMs = median(measurements.tsx); +const bundleMedianMs = median(measurements.bundle); +console.log(JSON.stringify({ + schema: 1, + measuredAt: new Date().toISOString(), + node: process.version, + platform: process.platform, + arch: process.arch, + cpu: cpus()[0]?.model ?? "unknown", + trials, + workersPerTrial: workers, + includesOneTimeBundleCost: true, + tsxMs: measurements.tsx.map((value) => Number(value.toFixed(1))), + bundleMs: measurements.bundle.map((value) => Number(value.toFixed(1))), + tsxMedianMs: Number(tsxMedianMs.toFixed(1)), + bundleMedianMs: Number(bundleMedianMs.toFixed(1)), + medianRatio: Number((tsxMedianMs / bundleMedianMs).toFixed(2)), +}, null, 2)); diff --git a/docs/architecture.md b/docs/architecture.md index 4765103b54..d70887d011 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -173,6 +173,13 @@ Key host components: | NodeWorkerAdapter | `worker-adapter.ts` | Creates Node.js worker_threads | | BrowserWorkerAdapter | `worker-adapter-browser.ts` | Creates Web Workers | +`NodeWorkerAdapter` prefers the compiled worker entry distributed with the +host package. In a source checkout without `host/dist`, it bundles the +TypeScript entry once per adapter and reuses that temporary module for later +process workers; if bundling is unavailable, it falls back to the `tsx` +loader. Browser worker entries are bundled by the browser build and do not use +this Node-only source fallback. + Kernel module instantiation snapshots the caller's exact intrinsic `ArrayBuffer`, typed-array, or `DataView` byte window before inspecting it. Pointer-width detection and `WebAssembly.compile` consume that same detached diff --git a/docs/measurements/2026-08-01-node-source-worker-startup.md b/docs/measurements/2026-08-01-node-source-worker-startup.md new file mode 100644 index 0000000000..dedb24c789 --- /dev/null +++ b/docs/measurements/2026-08-01-node-source-worker-startup.md @@ -0,0 +1,50 @@ +# Node source-worker startup — 2026-08-01 + +## Conclusion + +When Kandelo runs directly from a source checkout without a compiled worker +entry, bundling the TypeScript entry once and reusing it materially reduces +Node process-worker startup time compared with starting a new `tsx` loader for +each worker. + +This is a Node source-mode result, not a claim about production packages or +browsers. Published Node packages prefer their compiled JavaScript entry. +Browser workers already pass through the browser build's bundler and do not +use either source fallback measured here. + +## Method + +The benchmark creates a minimal TypeScript worker that replies once with its +initialization value. Each trial creates a fresh `NodeWorkerAdapter`, launches +and terminates eight workers sequentially, and includes the one-time esbuild +cost in the bundled result. The baseline disables only the new bundle resolver +so the adapter follows its former per-worker `tsx` path with the same worker +source and Worker options. Trial order alternates to reduce ordering bias. + +Run from the declared development shell: + +```bash +scripts/dev-shell.sh npx tsx \ + benchmarks/node-source-worker-startup.ts --trials=3 --workers=8 +``` + +Environment: + +- Apple M5 Max with 48 GiB memory; +- macOS 26.6 (`Darwin 25.6.0`); and +- Node.js `v24.15.0` from Kandelo's development shell. + +## Results + +| Trial | Eight workers through `tsx` | Eight workers through cached bundle | +|---:|---:|---:| +| 1 | 632.6 ms | 228.2 ms | +| 2 | 654.0 ms | 212.3 ms | +| 3 | 649.2 ms | 211.8 ms | +| Median | 649.2 ms | 212.3 ms | + +For this narrow workload, the prior path took 3.06 times as long at the +median. The result demonstrates the source-startup effect only. It does not +measure a complete Homebrew lifecycle, resident memory, browser performance, +or the compiled distribution path. The batch-level Node and browser benchmark +suites and the exact Homebrew lifecycle remain separate completion gates. diff --git a/host/package-lock.json b/host/package-lock.json index 74cf6ca731..3ca9d768a6 100644 --- a/host/package-lock.json +++ b/host/package-lock.json @@ -15,6 +15,7 @@ "devDependencies": { "@playwright/test": "^1.61.0", "@types/node": "^25.9.3", + "esbuild": "^0.27.0", "tsup": "^8.0.0", "tsx": "^4.22.4", "typedoc": "^0.28.19", diff --git a/host/package.json b/host/package.json index 13f0602d7d..6287953a09 100644 --- a/host/package.json +++ b/host/package.json @@ -122,6 +122,7 @@ "devDependencies": { "@playwright/test": "^1.61.0", "@types/node": "^25.9.3", + "esbuild": "^0.27.0", "tsup": "^8.0.0", "tsx": "^4.22.4", "typedoc": "^0.28.19", diff --git a/host/src/worker-adapter.ts b/host/src/worker-adapter.ts index 4964e8d6a6..7fa68e28aa 100644 --- a/host/src/worker-adapter.ts +++ b/host/src/worker-adapter.ts @@ -98,9 +98,11 @@ export class MockWorkerAdapter implements WorkerAdapter { // --- Node.js implementation --- import { Worker, type WorkerOptions } from "node:worker_threads"; -import { pathToFileURL } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { createRequire } from "node:module"; -import { existsSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { NODE_WORKER_INIT_BY_MESSAGE } from "./node-worker-initialization"; // Wasm guest stacks consume the embedding worker's native stack when engines @@ -176,6 +178,7 @@ export function nodeWorkerInitialization( export class NodeWorkerAdapter implements WorkerAdapter { private entryUrl: URL; private _compiledEntry: URL | false | undefined; + private _bundledSourceEntry: URL | false | undefined; private readonly initializeByMessage: boolean; constructor(entryUrl?: URL) { @@ -237,6 +240,56 @@ export class NodeWorkerAdapter implements WorkerAdapter { return new NodeWorkerHandle(worker); } + /** + * Source checkouts often run without host/dist/*.js. Avoid spawning a fresh + * tsx loader for every guest process; Homebrew can launch hundreds of short + * lived workers and the per-worker loader path can stall under that churn. + * Browser workers already pass through the browser build's bundling path; + * this fallback is specific to Node.js running directly from source. + */ + private resolveBundledSourceEntry(): URL | null { + if (this._bundledSourceEntry !== undefined) { + return this._bundledSourceEntry || null; + } + if ( + this.entryUrl.protocol !== "file:" || + !this.entryUrl.pathname.endsWith(".ts") + ) { + this._bundledSourceEntry = false; + return null; + } + + let outdir: string | undefined; + try { + const require = createRequire(currentModuleUrl()); + const esbuild = require("esbuild") as { + buildSync: (options: Record) => void; + }; + outdir = mkdtempSync(join(tmpdir(), "kandelo-worker-entry-")); + const outfile = join(outdir, "worker-entry.mjs"); + esbuild.buildSync({ + entryPoints: [fileURLToPath(this.entryUrl)], + bundle: true, + platform: "node", + format: "esm", + target: "es2022", + outfile, + sourcemap: "inline", + logLevel: "silent", + }); + this._bundledSourceEntry = pathToFileURL(outfile); + return this._bundledSourceEntry; + } catch { + // WHY: a failed source bundle intentionally falls back to tsx, but the + // abandoned output directory otherwise accumulates across host retries. + if (outdir !== undefined) { + rmSync(outdir, { recursive: true, force: true }); + } + this._bundledSourceEntry = false; + return null; + } + } + createWorker(workerData: unknown): WorkerHandle { const initialization = nodeWorkerInitialization( workerData, @@ -244,7 +297,8 @@ export class NodeWorkerAdapter implements WorkerAdapter { ); // Try the compiled JS entry first (much faster startup — avoids tsx // bootstrap which takes >500ms with 10+ concurrent workers). - const compiledEntry = this.resolveCompiledEntry(); + const compiledEntry = + this.resolveCompiledEntry() ?? this.resolveBundledSourceEntry(); if (compiledEntry) { const worker = new Worker( compiledEntry, diff --git a/host/test/node-worker-adapter.test.ts b/host/test/node-worker-adapter.test.ts new file mode 100644 index 0000000000..9cc16e9d79 --- /dev/null +++ b/host/test/node-worker-adapter.test.ts @@ -0,0 +1,111 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { describe, expect, it } from "vitest"; +import { NodeWorkerAdapter, type WorkerHandle } from "../src/worker-adapter"; + +function waitForMessage(handle: WorkerHandle): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("worker timed out")), 5_000); + handle.on("message", (message) => { + clearTimeout(timeout); + resolve(message); + }); + handle.on("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + handle.on("exit", (code) => { + if (code !== 0) { + clearTimeout(timeout); + reject(new Error(`worker exited before message: ${code}`)); + } + }); + }); +} + +describe("NodeWorkerAdapter", () => { + it("bundles a TypeScript source worker when no compiled entry exists", async () => { + const dir = mkdtempSync(join(tmpdir(), "kandelo-worker-adapter-test-")); + const entryPath = join(dir, "worker-entry.ts"); + writeFileSync( + entryPath, + [ + 'import { parentPort, workerData } from "node:worker_threads";', + 'parentPort?.postMessage({ type: "ready", pid: workerData.pid });', + ].join("\n"), + ); + + const adapter = new NodeWorkerAdapter(pathToFileURL(entryPath)); + const handles: WorkerHandle[] = []; + let bundledDir: string | undefined; + try { + const first = adapter.createWorker({ pid: 42 }); + handles.push(first); + await expect(waitForMessage(first)).resolves.toEqual({ + type: "ready", + pid: 42, + }); + + const bundledEntry = ( + adapter as unknown as { _bundledSourceEntry?: URL | false } + )._bundledSourceEntry; + expect(bundledEntry).toBeInstanceOf(URL); + expect(existsSync(fileURLToPath(bundledEntry as URL))).toBe(true); + bundledDir = dirname(fileURLToPath(bundledEntry as URL)); + + const second = adapter.createWorker({ pid: 43 }); + handles.push(second); + await expect(waitForMessage(second)).resolves.toEqual({ + type: "ready", + pid: 43, + }); + expect( + (adapter as unknown as { _bundledSourceEntry?: URL | false }) + ._bundledSourceEntry, + ).toBe(bundledEntry); + } finally { + await Promise.all( + handles.map((handle) => handle.terminate().catch(() => undefined)), + ); + rmSync(dir, { recursive: true, force: true }); + if (bundledDir !== undefined) { + rmSync(bundledDir, { recursive: true, force: true }); + } + } + }); + + it("removes a partial bundle before selecting the tsx fallback", () => { + const root = mkdtempSync(join(tmpdir(), "kandelo-worker-failure-test-")); + const sourceDir = join(root, "source"); + const entryPath = join(sourceDir, "worker-entry.ts"); + mkdirSync(sourceDir); + writeFileSync(entryPath, 'import "./missing-module";'); + + const previousTmpdir = process.env.TMPDIR; + process.env.TMPDIR = root; + try { + const adapter = new NodeWorkerAdapter(pathToFileURL(entryPath)); + const resolved = ( + adapter as unknown as { resolveBundledSourceEntry: () => URL | null } + ).resolveBundledSourceEntry(); + expect(resolved).toBeNull(); + expect(readdirSync(root)).toEqual(["source"]); + } finally { + if (previousTmpdir === undefined) { + delete process.env.TMPDIR; + } else { + process.env.TMPDIR = previousTmpdir; + } + rmSync(root, { recursive: true, force: true }); + } + }); +}); From 1234934c6cb1c730ba63aee869047648fc885c10 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Fri, 10 Jul 2026 13:30:21 -0400 Subject: [PATCH 38/82] Browser: Add image-owned file ingest Let a VFS image declare one bounded, fixed-path upload in /etc/kandelo/demo.json. The framebuffer surface exposes the declaration as a file picker and drop target without inferring behavior from a package or profile name. Validate every profile eagerly and cap declarations at 64 MiB. Check both the file metadata and the bytes returned by the browser. Write through the VFS-owning worker before stopping the current device owner, so validation or write failure leaves the running program intact. Deliver host signals through the authoritative Rust ProcessTable. This keeps ESRCH truthful during exec handoff and preserves normal signal disposition, exit cleanup, and reaping. Wait boundedly for process exit and framebuffer release, then await PTY command dispatch without waiting for the long-lived replacement to return to a prompt. Expose the same signal and worker-owned VFS mutation primitives in Node and browser hosts. Bind the first consumer in canonical image metadata, retire the stale shell image identity, and leave revision 23 pending until rebuilt and sealed. This extends only lockstep host-worker protocols and consumes the existing kernel host-signal export. It does not change the guest/kernel ABI shape. Validation: - host typecheck - 47 focused Node and browser host tests - 104 kandelo-session tests - targeted browser/session TypeScript check - documentation build - main-shell artifact-lock and closure contract tests Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit a27c1ff516fe5f0821c143cd8a8d470a78e6581b) --- .../pages/kandelo/kernel-host/live-setup.ts | 6 + .../pages/kandelo/kernel-host/react.tsx | 12 +- .../pages/kandelo/panes/Framebuffer.tsx | 229 +++++++++++++- apps/browser-demos/pages/kandelo/styles.css | 86 ++++++ .../test/kandelo-doom-ingest.spec.ts | 180 +++++++++++ docs-site/guide/vfs-images.md | 11 +- docs/browser-support.md | 28 ++ examples/signal-wait.c | 18 ++ homebrew/main-shell-demo.json | 11 +- homebrew/main-shell-lazy-artifact-lock.json | 2 +- .../source-rootfs-shell-demo-profiles.json | 11 +- host/src/browser-kernel-host.ts | 22 +- host/src/browser-kernel-protocol.ts | 10 + host/src/browser-kernel-worker-entry.ts | 14 + host/src/kernel-worker.ts | 117 ++++++-- host/src/node-kernel-host.ts | 64 +++- host/src/node-kernel-protocol.ts | 19 ++ host/src/node-kernel-worker-entry.ts | 66 +++++ host/test/browser-kernel.test.ts | 24 ++ host/test/global-setup.ts | 1 + host/test/node-rootfs-export.test.ts | 15 + host/test/signal-process.test.ts | 76 +++++ packages/registry/shell/build.toml | 9 +- scripts/test-homebrew-main-shell-closure.sh | 5 +- web-libs/kandelo-session/src/demo-config.ts | 117 ++++++++ web-libs/kandelo-session/src/demo-ingest.ts | 280 ++++++++++++++++++ web-libs/kandelo-session/src/index.ts | 1 + web-libs/kandelo-session/src/kernel-host.ts | 127 ++++++-- .../kandelo-session/test/demo-ingest.test.ts | 213 +++++++++++++ 29 files changed, 1705 insertions(+), 69 deletions(-) create mode 100644 apps/browser-demos/test/kandelo-doom-ingest.spec.ts create mode 100644 examples/signal-wait.c create mode 100644 host/test/signal-process.test.ts create mode 100644 web-libs/kandelo-session/src/demo-ingest.ts create mode 100644 web-libs/kandelo-session/test/demo-ingest.test.ts diff --git a/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts b/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts index f03a37e2cf..15819f6da5 100644 --- a/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts +++ b/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts @@ -61,6 +61,7 @@ import { genericDemoPresentation, resolveDemoAssets, resolveDemoGuide, + resolveDemoIngest, resolveDemoPresentation, type DemoAssetConfig, type KandeloDemoConfig, @@ -1530,6 +1531,11 @@ async function bootProfile( (imageConfig ? resolveDemoGuide(imageConfig, profile.id) : null) ?? builtinDemoGuide(profile.id); host.setDemoGuide(demoGuide); + // Ingest is an image-owned capability. Absence is valid and must not be + // replaced with a package- or profile-name-specific UI promise. + host.setDemoIngest( + imageConfig ? resolveDemoIngest(imageConfig, profile.id) : null, + ); const imageAssets = imageConfig ? resolveDemoAssets(imageConfig, profile.id) : []; diff --git a/apps/browser-demos/pages/kandelo/kernel-host/react.tsx b/apps/browser-demos/pages/kandelo/kernel-host/react.tsx index 0366c56ecd..6fab68f0a7 100644 --- a/apps/browser-demos/pages/kandelo/kernel-host/react.tsx +++ b/apps/browser-demos/pages/kandelo/kernel-host/react.tsx @@ -12,7 +12,7 @@ import type { SurfaceAvailability, GalleryItem, GalleryTab, LazyDownloadEvent, LazyDownloadSummary, } from "../../../../../web-libs/kandelo-session/src/kernel-host"; import { activeLazyDownloadSummaries } from "../../../../../web-libs/kandelo-session/src/lazy-download"; -import type { DemoGuideConfig } from "../../../../../web-libs/kandelo-session/src/demo-config"; +import type { DemoGuideConfig, DemoIngestConfig } from "../../../../../web-libs/kandelo-session/src/demo-config"; const KernelHostContext = React.createContext(null); const LAZY_DOWNLOAD_COMPLETE_VISIBLE_MS = 2400; @@ -170,6 +170,16 @@ export function useDemoGuide(): DemoGuideConfig | null { return state; } +export function useDemoIngest(): DemoIngestConfig | null { + const host = useKernelHost(); + const [state, setState] = React.useState(() => host.getDemoIngest()); + React.useEffect(() => { + setState(host.getDemoIngest()); + return host.subscribeDemoIngest(setState); + }, [host]); + return state; +} + export function useGalleryItems(tab: GalleryTab = "presets"): { items: GalleryItem[]; loading: boolean; diff --git a/apps/browser-demos/pages/kandelo/panes/Framebuffer.tsx b/apps/browser-demos/pages/kandelo/panes/Framebuffer.tsx index 7e4806ce49..cc97108b57 100644 --- a/apps/browser-demos/pages/kandelo/panes/Framebuffer.tsx +++ b/apps/browser-demos/pages/kandelo/panes/Framebuffer.tsx @@ -15,7 +15,7 @@ // or press Ctrl+Shift+Esc to move focus back to the UI. import * as React from "react"; -import { useKernelHost, useStatus } from "../kernel-host/react"; +import { useDemoIngest, useKernelHost, useStatus } from "../kernel-host/react"; import { attachLinuxMediumRawKeyboard, attachPointerLockMouse, @@ -25,8 +25,16 @@ import type { AudioOutputHandle, FramebufferHandle, } from "../../../../../web-libs/kandelo-session/src/kernel-host"; +import { + IngestError, + runDemoIngest, + waitForProcessExit, + type IngestPhase, +} from "../../../../../web-libs/kandelo-session/src/demo-ingest"; import { useFittedCanvasStyle } from "./canvasFit"; +const FRAMEBUFFER_REBIND_TIMEOUT_MS = 10_000; + export interface FramebufferProps { dragProps?: import("./PaneHead").PaneHeadDragProps; onCollapse?: () => void; @@ -39,6 +47,7 @@ export interface FramebufferProps { export const Framebuffer: React.FC = ({ autoFocus = false, onDockControlsChange }) => { const host = useKernelHost(); const status = useStatus(); + const ingest = useDemoIngest(); const stageRef = React.useRef(null); const canvasRef = React.useRef(null); const handleRef = React.useRef(null); @@ -48,6 +57,10 @@ export const Framebuffer: React.FC = ({ autoFocus = false, onD const [boundPid, setBoundPid] = React.useState(null); const [focused, setFocused] = React.useState(false); const [mouseCaptured, setMouseCaptured] = React.useState(false); + const [ingestPhase, setIngestPhase] = React.useState(null); + const [ingestName, setIngestName] = React.useState(null); + const [ingestError, setIngestError] = React.useState(null); + const [dragActive, setDragActive] = React.useState(false); React.useEffect(() => { if (status !== "running") return; @@ -155,6 +168,106 @@ export const Framebuffer: React.FC = ({ autoFocus = false, onD mouseRef.current?.requestCapture(); }; + // /dev/fb0 is single-owner: the kernel returns EBUSY on a second open. The + // replacement emulator therefore cannot start until the outgoing one has + // both exited *and* had its binding torn down by the kernel's exit path. + // Those are two separate observations, so wait for both before relaunching. + const waitForFbRelease = React.useCallback(( + pid: number, + signal: AbortSignal, + ): Promise => { + const handle = handleRef.current; + const unbound = new Promise((resolve, reject) => { + let settled = false; + let off = () => {}; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + off(); + signal.removeEventListener("abort", onAbort); + if (error) reject(error); + else resolve(); + }; + const onAbort = () => finish( + new Error(`wait for /dev/fb0 release by pid ${pid} was cancelled`), + ); + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + if (!handle || handle.getBoundPid() !== pid) { + finish(); + return; + } + off = handle.onBoundPidChange((next) => { + if (next !== pid) finish(); + }); + if (settled) off(); + }); + return Promise.all([ + waitForProcessExit(host, pid, { signal }), + unbound, + ]).then(() => {}); + }, [host]); + + /** Resolve boundedly once some process has bound /dev/fb0 again. */ + const waitForFbBind = React.useCallback((): Promise => { + const handle = handleRef.current; + return new Promise((resolve, reject) => { + if (!handle) { + reject(new Error("framebuffer handle disappeared during restart")); + return; + } + let settled = false; + let off = () => {}; + const timer = window.setTimeout(() => { + finish(new Error( + `replacement did not bind /dev/fb0 within ` + + `${FRAMEBUFFER_REBIND_TIMEOUT_MS}ms`, + )); + }, FRAMEBUFFER_REBIND_TIMEOUT_MS); + const finish = (error?: Error) => { + if (settled) return; + settled = true; + window.clearTimeout(timer); + off(); + if (error) reject(error); + else resolve(); + }; + off = handle.onBoundPidChange((next) => { + if (next !== null) finish(); + }); + if (handle.getBoundPid() !== null) finish(); + if (settled) off(); + }); + }, []); + + const ingestFile = React.useCallback(async (file: File) => { + if (!ingest || ingestPhase !== null) return; + setIngestError(null); + setIngestName(file.name); + try { + await runDemoIngest(host, ingest, file, { + targetPid: handleRef.current?.getBoundPid() ?? null, + waitForRelease: waitForFbRelease, + onPhase: setIngestPhase, + }); + // runDemoIngest returns as soon as the relaunch is dispatched; keep the + // indicator up until the new process actually owns the framebuffer. + await waitForFbBind(); + } catch (err) { + setIngestError( + err instanceof IngestError ? err.message + : err instanceof Error ? err.message + : String(err), + ); + } finally { + setIngestPhase(null); + setIngestName(null); + } + }, [host, ingest, ingestPhase, waitForFbBind, waitForFbRelease]); + const showCanvas = status === "running" && !error; const showHint = showCanvas && boundPid === null; const captureLabel = mouseCaptured @@ -163,13 +276,24 @@ export const Framebuffer: React.FC = ({ autoFocus = false, onD ? "captured · click locks mouse" : boundPid !== null ? "click to play" : "waiting for /dev/fb0"; const canvasStyle = useFittedCanvasStyle(stageRef, canvasRef, 16 / 10); + const busy = ingestPhase !== null; const dockControls = React.useMemo(() => ( - ), [boundPid, captureLabel, focused, mouseCaptured]); + > + {ingest && status === "running" && ( + + )} + + ), [boundPid, busy, captureLabel, focused, ingest, ingestFile, ingestName, mouseCaptured, status]); React.useEffect(() => { if (!onDockControlsChange) return; @@ -177,8 +301,32 @@ export const Framebuffer: React.FC = ({ autoFocus = false, onD return () => onDockControlsChange(null); }, [dockControls, onDockControlsChange]); + // Drag-and-drop is an enhancement over the always-present dock button, so it + // is wired only when the image declares an ingest capability. + const dropHandlers = ingest && status === "running" ? { + onDragOver: (e: React.DragEvent) => { + e.preventDefault(); + if (!busy) setDragActive(true); + }, + onDragLeave: (e: React.DragEvent) => { + if (e.currentTarget.contains(e.relatedTarget as Node | null)) return; + setDragActive(false); + }, + onDrop: (e: React.DragEvent) => { + e.preventDefault(); + setDragActive(false); + const file = e.dataTransfer.files?.[0]; + if (file) void ingestFile(file); + }, + } : {}; + return ( -
+
= ({ autoFocus = false, onD Waiting for a process to bind /dev/fb0.
)} + {dragActive && !busy && ( +
+ Drop {ingest?.accept.join(" / ")} to load +
+ )} + {busy && ( +
+ {ingestName ? `loading ${ingestName}…` : "loading…"} +
+ )} + {ingestError && !busy && ( +
+ {ingestError} + +
+ )} {(error || status !== "running") && (
= ({ title, status, active = false }) => ( + children?: React.ReactNode; +}> = ({ title, status, active = false, children }) => (
{title} + + {children} {status}
); + +/** + * The primary ingest path: a real , so it works on every + * platform including touch, where drag-and-drop does not exist. + */ +const IngestControl: React.FC<{ + accept: string[]; + label: string; + busy: boolean; + busyLabel: string; + onFile: (file: File) => void; +}> = ({ accept, label, busy, busyLabel, onFile }) => { + const inputRef = React.useRef(null); + return ( + <> + { + const file = e.target.files?.[0]; + // Reset so re-picking the same file fires change again. + e.target.value = ""; + if (file) onFile(file); + }} + /> + + + ); +}; diff --git a/apps/browser-demos/pages/kandelo/styles.css b/apps/browser-demos/pages/kandelo/styles.css index 4d0f45c938..8f3523e98a 100644 --- a/apps/browser-demos/pages/kandelo/styles.css +++ b/apps/browser-demos/pages/kandelo/styles.css @@ -852,6 +852,92 @@ color: var(--kdock-active-text); } +/* Pushes everything after the title to the right edge of the dock. */ +.kdemo-surface-spacer { + flex: 1 1 auto; +} + +.kdemo-surface-action { + flex: 0 0 auto; + padding: 3px 8px; + border: 1px solid color-mix(in oklch, var(--kdock-active-bg) 72%, var(--kdock-border)); + border-radius: 5px; + background: color-mix(in oklch, var(--kdock-active-bg) 24%, transparent); + color: var(--kdock-active-text); + font: inherit; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + cursor: pointer; +} + +.kdemo-surface-action:hover:not(:disabled) { + background: color-mix(in oklch, var(--kdock-active-bg) 40%, transparent); +} + +.kdemo-surface-action:disabled { + cursor: progress; + opacity: 0.7; +} + +/* Drag-and-drop affordance + ingest status, overlaid on the fb stage. */ +.kframebuffer-surface[data-drag-active="true"] { + outline: 2px dashed var(--k-accent); + outline-offset: -6px; +} + +.kframebuffer-dropzone { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in oklch, var(--k-accent) 12%, transparent); + color: var(--k-fb-text); + font-family: var(--k-font-mono); + font-size: 12px; + pointer-events: none; +} + +/* Anchored to the top of the stage: the surface dock overlays the bottom, so a + bottom-anchored toast is hidden behind it. */ +.kframebuffer-toast { + position: absolute; + left: 50%; + top: 12px; + transform: translateX(-50%); + z-index: 2; + display: flex; + align-items: center; + gap: 8px; + max-width: min(90%, 520px); + padding: 6px 10px; + border: 1px solid var(--kdock-border); + border-radius: 6px; + background: var(--kdock-bg-sunk); + color: var(--k-fb-text); + font-family: var(--k-font-mono); + font-size: 11px; + line-height: 1.35; +} + +.kframebuffer-toast[data-error="true"] { + border-color: color-mix(in oklch, red 55%, var(--kdock-border)); + background: color-mix(in oklch, red 18%, var(--kdock-bg-sunk)); +} + +.kframebuffer-toast-dismiss { + flex: 0 0 auto; + border: 0; + background: none; + color: inherit; + font: inherit; + font-size: 14px; + line-height: 1; + cursor: pointer; +} + .kdock-popover-dismiss-layer { position: fixed; inset: 0; diff --git a/apps/browser-demos/test/kandelo-doom-ingest.spec.ts b/apps/browser-demos/test/kandelo-doom-ingest.spec.ts new file mode 100644 index 0000000000..7949923e9d --- /dev/null +++ b/apps/browser-demos/test/kandelo-doom-ingest.spec.ts @@ -0,0 +1,180 @@ +// "Bring your own WAD" ingest for the fbDOOM demo — the on-main consumer that +// exercises the reusable file-ingest capability end to end. +// +// The gate that matters is the /dev/fb0 handoff. fb0 is single-owner (EBUSY on +// a second open), so a successful reload requires the running fbdoom to exit +// and release the binding before the replacement can start. We prove that by +// watching the bound pid change AND fbDOOM re-render — the new process was +// launched by the author's restart command (`fbdoom -iwad /user.wad`), so a +// fresh pid painting DOOM is evidence the upload landed and was loaded. +// +// The demo boots by fetching the shareware doom1.wad; this spec uploads a WAD +// obtained the same way. If that fetch is unavailable the whole demo can't run, +// so the suite skips rather than reporting a false failure. + +import { expect, test, type Locator, type Page } from "@playwright/test"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const DOOM_WAD_URL = "https://cdn.jsdelivr.net/gh/gaborbata/vanilla-mocha-doom@15825a07a48806bcfb242a42afd5ee7cb3c9a3a4/wads/doom1.wad"; + +const appUrl = (path: string): string => { + const baseUrl = process.env.KANDELO_TEST_BASE_URL; + return baseUrl ? new URL(path, baseUrl).href : path; +}; + +let wadPath = ""; +let wadDir = ""; + +test.beforeAll(async () => { + wadDir = mkdtempSync(join(tmpdir(), "kandelo-doom-ingest-")); + wadPath = join(wadDir, "doom1.wad"); + try { + const res = await fetch(DOOM_WAD_URL); + if (!res.ok) return; + const buf = Buffer.from(await res.arrayBuffer()); + // A valid IWAD begins with the ASCII magic "IWAD". + if (buf.length < 12 || buf.toString("ascii", 0, 4) !== "IWAD") return; + writeFileSync(wadPath, buf); + } catch { + // Left unwritten → tests skip below. + } +}); + +test.afterAll(() => { + if (wadDir) rmSync(wadDir, { recursive: true, force: true }); +}); + +async function bootDoomOrSkip(page: Page): Promise { + test.skip(!existsSync(wadPath), "doom1.wad unavailable (offline) — demo can't run"); + await page.goto(appUrl("/?demo=doom"), { waitUntil: "domcontentloaded" }); + if (await page.locator("vite-error-overlay").count()) { + test.skip(true, "Required binary not built - Vite import error"); + } + const canvas = page.locator("canvas.kframebuffer-canvas").first(); + await expect(canvas).toBeVisible({ timeout: 180_000 }); + // Wait until fbDOOM has fetched its IWAD and painted the title screen. + await expect.poll(() => distinctColors(canvas), { + timeout: 120_000, + intervals: [1_000, 2_000, 3_000], + }).toBeGreaterThan(4); + return canvas; +} + +function distinctColors(canvas: Locator): Promise { + return canvas.evaluate((el: HTMLCanvasElement) => { + const ctx = el.getContext("2d"); + if (!ctx) return 0; + const { data } = ctx.getImageData(0, 0, el.width, el.height); + const seen = new Set(); + for (let i = 0; i < data.length; i += 4) { + seen.add((data[i] << 16) | (data[i + 1] << 8) | data[i + 2]); + if (seen.size > 8) break; + } + return seen.size; + }); +} + +async function boundPid(page: Page): Promise { + const title = ( + await page + .locator(".kdemo-surface-title", { hasText: "/DEV/FB0" }) + .first() + .textContent() + ) ?? ""; + const m = /pid (\d+)/i.exec(title); + return m ? Number(m[1]) : null; +} + +async function awaitHandoffAndRender(page: Page, canvas: Locator): Promise { + await page.getByTestId("fb-ingest-busy") + .waitFor({ state: "detached", timeout: 90_000 }) + .catch(() => { /* handoff may finish before we look */ }); + await expect.poll(() => distinctColors(canvas), { + timeout: 90_000, + intervals: [1_000, 2_000, 3_000], + }).toBeGreaterThan(4); +} + +test("Load WAD button swaps the running IWAD and hands /dev/fb0 over", async ({ page }) => { + test.setTimeout(300_000); + const canvas = await bootDoomOrSkip(page); + const pidBefore = await boundPid(page); + expect(pidBefore).not.toBeNull(); + + const button = page.getByTestId("fb-ingest-button"); + await expect(button).toBeVisible(); + await expect(button).toHaveText(/load wad/i); + + await page.getByTestId("fb-ingest-input").setInputFiles(wadPath); + await awaitHandoffAndRender(page, canvas); + + // A new process owns fb0 and is rendering. fbdoom only starts via the restart + // command (`-iwad /user.wad`), so the old instance must have exited and + // released fb0 — otherwise the relaunch would have hit EBUSY. + const pidAfter = await boundPid(page); + expect(pidAfter).not.toBeNull(); + expect(pidAfter).not.toBe(pidBefore); + await expect(page.getByTestId("fb-ingest-error")).toHaveCount(0); +}); + +test("dropping a WAD on the framebuffer loads it", async ({ page }) => { + test.setTimeout(300_000); + const canvas = await bootDoomOrSkip(page); + const pidBefore = await boundPid(page); + + // Hand the bytes to the page as base64: the app's service worker intercepts + // in-page fetch(), and a multi-MB numeric array is too heavy for evaluate. + const wadBase64 = readFileSync(wadPath).toString("base64"); + const dataTransfer = await page.evaluateHandle((b64) => { + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + const dt = new DataTransfer(); + dt.items.add(new File([bytes], "custom.wad", { type: "application/octet-stream" })); + return dt; + }, wadBase64); + + const stage = page.locator(".kframebuffer-surface").first(); + await stage.dispatchEvent("dragover", { dataTransfer }); + await expect(page.getByTestId("fb-dropzone")).toBeVisible(); + + await stage.dispatchEvent("drop", { dataTransfer }); + await awaitHandoffAndRender(page, canvas); + expect(await boundPid(page)).not.toBe(pidBefore); +}); + +test("a rejected file fails visibly and leaves the running WAD alone", async ({ page }) => { + test.setTimeout(300_000); + await bootDoomOrSkip(page); + const pidBefore = await boundPid(page); + const error = page.getByTestId("fb-ingest-error"); + + // Wrong extension — rejected before anything is written. + await page.getByTestId("fb-ingest-input").setInputFiles({ + name: "notes.txt", + mimeType: "text/plain", + buffer: Buffer.from("not a wad"), + }); + await expect(error).toBeVisible(); + await expect(error).toContainText(".wad"); + + // Over the 32 MiB cap — rejected before anything is written. + await page.getByTestId("fb-ingest-input").setInputFiles({ + name: "huge.wad", + mimeType: "application/octet-stream", + buffer: Buffer.alloc(33 * 1024 * 1024, 1), + }); + await expect(error).toBeVisible(); + await expect(error).toContainText(/exceeds/i); + + // The demo was never signalled: same pid, still up. + expect(await boundPid(page)).toBe(pidBefore); +}); diff --git a/docs-site/guide/vfs-images.md b/docs-site/guide/vfs-images.md index 605b98c2e0..9c332f3de3 100644 --- a/docs-site/guide/vfs-images.md +++ b/docs-site/guide/vfs-images.md @@ -152,7 +152,9 @@ Images consumed by the Kandelo UI can include: /etc/kandelo/demo.json ``` -This file lets the image declare presentation preferences, guide actions, companion HTML, assets, and automatic commands. Build scripts in this repo write it with: +This file lets the image declare presentation preferences, guide actions, +companion HTML, assets, automatic commands, and an optional fixed-path file +ingest. Build scripts in this repo write it with: ```ts writeKandeloDemoConfig(fs, { @@ -181,6 +183,13 @@ writeKandeloDemoConfig(fs, { }, ], }, + ingest: { + accept: [".rom"], + targetPath: "/inputs/game.rom", + maxBytes: 8 * 1024 * 1024, + label: "Load ROM", + onLoad: { restart: "emulator /inputs/game.rom" }, + }, }, }, }); diff --git a/docs/browser-support.md b/docs/browser-support.md index f7c6a9fb31..2609d905f7 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -517,6 +517,34 @@ Any extra files needed by an image-declared `autoCommand` can be declared in `assets`; the loader stages those paths generically and hash-verifies them when `sha256` is provided. +A profile may also declare one fixed-path file-ingest capability. The current +Kandelo browser UI presents it on the framebuffer surface as a file picker and +drop target: + +```json +{ + "ingest": { + "accept": [".wad"], + "targetPath": "/user.wad", + "maxBytes": 33554432, + "label": "Load WAD", + "onLoad": { + "restart": "/usr/local/bin/fbdoom -iwad /user.wad" + } + } +} +``` + +The image, not the uploaded filename or profile name, owns `targetPath` and the +optional restart command. The path must be absolute and normalized, its parent +must already exist, extensions are matched case-insensitively, and `maxBytes` +cannot exceed 64 MiB. The browser checks both the file's declared size and the +actual buffer length. It writes before stopping a current device owner, then +uses the kernel signal path and bounded process/device waits before dispatching +the image-owned command. Write, signal, timeout, and command-dispatch failures +remain visible. An absent `ingest` block means the image exposes no upload +capability; the loader does not infer one from a package or profile name. + The runtime treats this file as untrusted image input. It must be a regular file no larger than 256 KiB, contain valid UTF-8 and JSON, and use a supported version. The loader validates every profile before using any of them, so a diff --git a/examples/signal-wait.c b/examples/signal-wait.c new file mode 100644 index 0000000000..03b1c805dd --- /dev/null +++ b/examples/signal-wait.c @@ -0,0 +1,18 @@ +/* + * signal-wait.c — blocks forever until a signal arrives. + * + * Test fixture for host-initiated signal delivery + * (`CentralizedKernelWorker.signalProcess`, exposed as + * `NodeKernelHost.signalProcess` / `BrowserKernel.signalProcess`). The process + * installs no handlers, so a SIGTERM takes its default disposition and + * terminates it. Never exits on its own, which makes "the process went away" + * unambiguous evidence that the signal was delivered. + */ + +#include + +int main(void) { + for (;;) { + pause(); + } +} diff --git a/homebrew/main-shell-demo.json b/homebrew/main-shell-demo.json index 712f706d6c..e541282041 100644 --- a/homebrew/main-shell-demo.json +++ b/homebrew/main-shell-demo.json @@ -78,7 +78,16 @@ "mode": 420, "devCorsProxy": true } - ] + ], + "ingest": { + "accept": [".wad"], + "targetPath": "/user.wad", + "maxBytes": 33554432, + "label": "Load WAD", + "onLoad": { + "restart": "/usr/local/bin/fbdoom -iwad /user.wad" + } + } }, "modeset": { "presentation": { diff --git a/homebrew/main-shell-lazy-artifact-lock.json b/homebrew/main-shell-lazy-artifact-lock.json index 0933dd6671..11968976a4 100644 --- a/homebrew/main-shell-lazy-artifact-lock.json +++ b/homebrew/main-shell-lazy-artifact-lock.json @@ -6,7 +6,7 @@ "inputs": { "bootstrap_tree_spec_sha256": "7160094ad36d0684210a46331ec73bd8fe938222358f045ca60d3d6b210a04c4", "brewfile_sha256": "6f59fe83d93548bd2521a0fe9af1942ab321b2fb249939bf9e6a37f7de0f4722", - "demo_config_sha256": "6a09a53f0b169400948aa4f5c9c7a4b904ed7007ad0912d9cd73a66f7b57e455", + "demo_config_sha256": "ef689e4eed5d9b59874e5c46fb29c8fe6e1792dfd8252a7e2be4292a4e3747ef", "materialization_policy_sha256": "fb170c25f71e6e7fdd3470901b3ab3a42e7765648d55f62fad38230e30461fe0", "migration_lock_sha256": "92b6c3946e40e9384de0ed98c13b6f9ec595e487a5112cc8a450bc2ac3ea524e", "runtime_support_sha256": "4ee373429d0f26e459cd9fe5ea16901d1769f9947f89cffeaee262b94e403616", diff --git a/homebrew/source-rootfs-shell-demo-profiles.json b/homebrew/source-rootfs-shell-demo-profiles.json index e9046cfec0..2ae46ccb10 100644 --- a/homebrew/source-rootfs-shell-demo-profiles.json +++ b/homebrew/source-rootfs-shell-demo-profiles.json @@ -17,7 +17,16 @@ "mode": 420, "devCorsProxy": true } - ] + ], + "ingest": { + "accept": [".wad"], + "targetPath": "/user.wad", + "maxBytes": 33554432, + "label": "Load WAD", + "onLoad": { + "restart": "/usr/local/bin/fbdoom -iwad /user.wad" + } + } }, "modeset": { "presentation": { diff --git a/host/src/browser-kernel-host.ts b/host/src/browser-kernel-host.ts index 5d17cd2364..744d9adf65 100644 --- a/host/src/browser-kernel-host.ts +++ b/host/src/browser-kernel-host.ts @@ -957,6 +957,22 @@ export class BrowserKernel { }) as Promise; } + /** + * Deliver a POSIX signal to `pid`. Resolves false when the process is gone + * (ESRCH). Unlike {@link terminateProcess}, which tears down the wasm worker + * from the host, this runs the kernel's signal path, so the target's + * disposition and the kernel's exit cleanup both apply. + */ + async signalProcess(pid: number, signum: number): Promise { + const requestId = this.nextRequestId++; + return this.request(requestId, { + type: "signal_process", + requestId, + pid, + signum, + }) as Promise; + } + /** * Push a mouse event into the kernel's `/dev/input/mice` queue. Pass * deltas in PS/2 sign convention (positive-right, positive-up — invert @@ -1117,9 +1133,9 @@ export class BrowserKernel { /** * Create or replace a regular file in the worker-owned VFS. The mutation is - * performed by the kernel worker, preserving exclusive VFS ownership; call - * this only while guest processes that could access the path are stopped. - * The parent directory must already exist. + * performed by the kernel worker, preserving exclusive VFS ownership. The + * parent directory must already exist, and callers must coordinate access + * with guest processes that may use the same path. */ async writeFileToVfs( path: string, diff --git a/host/src/browser-kernel-protocol.ts b/host/src/browser-kernel-protocol.ts index 2b95efbdc5..6257171a52 100644 --- a/host/src/browser-kernel-protocol.ts +++ b/host/src/browser-kernel-protocol.ts @@ -112,6 +112,7 @@ export interface ReadVfsFileMessage { export interface WriteVfsFileMessage { type: "write_vfs_file"; requestId: number; + /** Normalized absolute guest path whose parent already exists. */ path: string; data: Uint8Array; mode: number; @@ -218,6 +219,14 @@ export interface IsStdinConsumedMessage { pid: number; } +/** Deliver `signum` to `pid`. Responds `true` when the process existed. */ +export interface SignalProcessMessage { + type: "signal_process"; + requestId: number; + pid: number; + signum: number; +} + export interface PickListenerTargetMessage { type: "pick_listener_target"; requestId: number; @@ -395,6 +404,7 @@ export type MainToKernelMessage = | WakeBlockedReadersMessage | WakeBlockedWritersMessage | IsStdinConsumedMessage + | SignalProcessMessage | PickListenerTargetMessage | DestroyMessage | RegisterPtyOutputMessage diff --git a/host/src/browser-kernel-worker-entry.ts b/host/src/browser-kernel-worker-entry.ts index 5e0939e79d..6e938a7946 100644 --- a/host/src/browser-kernel-worker-entry.ts +++ b/host/src/browser-kernel-worker-entry.ts @@ -3033,6 +3033,19 @@ function handleIsStdinConsumed(msg: Extract, +) { + try { + respond( + msg.requestId, + kernelWorker.signalProcess(msg.pid, msg.signum), + ); + } catch (error) { + respondError(msg.requestId, formatError(error)); + } +} + function handlePickListenerTarget( msg: Extract, ) { @@ -3404,6 +3417,7 @@ sw.onmessage = (e: MessageEvent) => { case "wake_blocked_readers": handleWakeBlockedReaders(msg); break; case "wake_blocked_writers": handleWakeBlockedWriters(msg); break; case "is_stdin_consumed": handleIsStdinConsumed(msg); break; + case "signal_process": handleSignalProcess(msg); break; case "pick_listener_target": handlePickListenerTarget(msg); break; case "http_request": handleHttpRequestMessage(msg); break; case "destroy": void handleDestroy(msg); break; diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index ceb27b334d..20c8a3b578 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -24786,6 +24786,50 @@ export class CentralizedKernelWorker { } } + /** + * Deliver one host-originated signal through the authoritative kernel + * ProcessTable. A temporarily detached host worker during exec does not make + * the process disappear, so the host registration map cannot answer this + * existence question truthfully. + */ + signalProcess(pid: number, signum: number): boolean { + if (!this.#initialized) throw new Error("Kernel not initialized"); + if (!Number.isSafeInteger(pid) || pid <= 0 || pid > MAX_KERNEL_TASK_ID) { + throw new RangeError(`Invalid kernel process ID ${pid}`); + } + if (!Number.isSafeInteger(signum) || signum < 0 || signum > 64) { + throw new RangeError(`Invalid POSIX signal number ${signum}`); + } + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError( + `host signal delivery pid=${pid}`, + ); + } + + let accepted: boolean | undefined; + const deferred = this.#runOrDeferKernelEntry( + `host signal delivery pid=${pid}`, + (entry) => { + accepted = this.#generateHostSignalWithinKernelEntry( + pid, + signum, + entry, + ); + if (accepted && signum !== 0) { + this.sendSignalToProcess(pid, signum, false, entry); + } + return undefined; + }, + ); + if (deferred || accepted === undefined) { + throw new KernelReentrantEntryError( + `host signal delivery pid=${pid}`, + ); + } + return accepted; + } + /** * Interrupt the exact host-owned futex selected for one caught signal. * @@ -24869,33 +24913,14 @@ export class CentralizedKernelWorker { // old worker registration while the same kernel Process (and its alarm) // remains alive. Queuing directly in the ProcessTable prevents a timer // that expires in that handoff window from being lost. - - if (queueSignal) { - const generateHostSignal = this.#kernelInstanceForEntry(entry).exports - .kernel_generate_host_signal as - ((pid: number, signal: number) => number) | undefined; - if (typeof generateHostSignal !== "function") { - this.#failBlockingRetryProtocol( - "kernel host-signal generation export is unavailable", - ); - } - let result: number; - try { - result = generateHostSignal(targetPid, signum); - } catch (error) { - this.#rethrowKernelEntryFatal(error); - this.#failBlockingRetryProtocol( - `kernel host-signal generation trapped for pid ${targetPid}`, - error, - ); - } - if (result === -ESRCH) return; - if (!Number.isSafeInteger(result) || result !== 0) { - this.#failBlockingRetryProtocol( - `kernel rejected host signal ${signum} for pid ${targetPid}: ${result}`, - ); - } - } + if ( + queueSignal + && !this.#generateHostSignalWithinKernelEntry( + targetPid, + signum, + entry, + ) + ) return; if (queueSignal) this.wakePendingSignalWaits(targetPid, signum); @@ -25013,6 +25038,44 @@ export class CentralizedKernelWorker { } } + /** + * Generate a host-owned signal while exact kernel-entry authority is live. + * The boolean preserves ESRCH as an ordinary, truthful race outcome; every + * other nonzero result is a host/kernel protocol failure after callers have + * validated the PID and signal ranges. + */ + #generateHostSignalWithinKernelEntry( + targetPid: number, + signum: number, + entry: KernelWorkerEntryContext, + ): boolean { + const generateHostSignal = this.#kernelInstanceForEntry(entry).exports + .kernel_generate_host_signal as + ((pid: number, signal: number) => number) | undefined; + if (typeof generateHostSignal !== "function") { + this.#failBlockingRetryProtocol( + "kernel host-signal generation export is unavailable", + ); + } + let result: number; + try { + result = generateHostSignal(targetPid, signum); + } catch (error) { + this.#rethrowKernelEntryFatal(error); + this.#failBlockingRetryProtocol( + `kernel host-signal generation trapped for pid ${targetPid}`, + error, + ); + } + if (result === -ESRCH) return false; + if (!Number.isSafeInteger(result) || result !== 0) { + this.#failBlockingRetryProtocol( + `kernel rejected host signal ${signum} for pid ${targetPid}: ${result}`, + ); + } + return true; + } + // ----------------------------------------------------------------------- // Process memory management // diff --git a/host/src/node-kernel-host.ts b/host/src/node-kernel-host.ts index 028a04f6da..4b9a8a1a56 100644 --- a/host/src/node-kernel-host.ts +++ b/host/src/node-kernel-host.ts @@ -17,7 +17,10 @@ import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { pathToFileURL } from "node:url"; import { createRequire } from "node:module"; -import { Worker as NodeThreadWorker } from "node:worker_threads"; +import { + Worker as NodeThreadWorker, + type Transferable, +} from "node:worker_threads"; import { resolveBinary } from "./binary-resolver"; import type { HostDiagnostic, @@ -723,6 +726,22 @@ export class NodeKernelHost { return result; } + /** + * Deliver a POSIX signal to `pid`. Resolves false when the process is gone + * (ESRCH). Mirrors `BrowserKernel.signalProcess`: unlike `terminateProcess` + * this goes through the kernel's signal path, so disposition and exit + * cleanup apply. + */ + async signalProcess(pid: number, signum: number): Promise { + const requestId = this._nextRequestId++; + return await this.request(requestId, { + type: "signal_process", + requestId, + pid, + signum, + }) as boolean; + } + /** * Snapshot the kernel's process table — one row per live process. Used * by Kandelo's Inspector → Procs tab. Mirrors `BrowserKernel.enumProcs`. @@ -850,6 +869,34 @@ export class NodeKernelHost { return result; } + /** + * Create or replace a regular file in the worker-owned VFS. The parent + * directory must already exist, matching the browser host's raw mutation + * capability. + */ + async writeFileToVfs( + path: string, + data: Uint8Array, + mode = 0o644, + ): Promise { + if (!this.initialized) { + throw new Error("VFS write requires an initialized kernel"); + } + const requestId = this._nextRequestId++; + const owned = data.slice(); + await this.request( + requestId, + { + type: "write_vfs_file", + requestId, + path, + data: owned, + mode: mode & 0o7777, + }, + [owned.buffer], + ); + } + /** * Serialize the quiescent worker-owned root filesystem for a later boot. * The root image is durable; boot-scoped scratch and device mounts are not. @@ -926,19 +973,26 @@ export class NodeKernelHost { // ── Private ── - private sendToWorker(msg: MainToKernelMessage): void { + private sendToWorker( + msg: MainToKernelMessage, + transfer?: readonly Transferable[], + ): void { if (this.kernelFatalError !== null) throw this.kernelFatalError; - this.worker.postMessage(msg); + this.worker.postMessage(msg, transfer); } - private request(requestId: number, msg: MainToKernelMessage): Promise { + private request( + requestId: number, + msg: MainToKernelMessage, + transfer?: readonly Transferable[], + ): Promise { return new Promise((resolve, reject) => { if (this.kernelFatalError !== null) { reject(this.kernelFatalError); return; } this.pendingRequests.set(requestId, { resolve, reject }); - this.sendToWorker(msg); + this.sendToWorker(msg, transfer); }); } diff --git a/host/src/node-kernel-protocol.ts b/host/src/node-kernel-protocol.ts index cf2ea46859..b5613b2d87 100644 --- a/host/src/node-kernel-protocol.ts +++ b/host/src/node-kernel-protocol.ts @@ -216,6 +216,15 @@ export interface ReadVfsFileMessage { path: string; } +/** Create or replace one regular file through the worker-owned VFS. */ +export interface WriteVfsFileMessage { + type: "write_vfs_file"; + requestId: number; + path: string; + data: Uint8Array; + mode: number; +} + /** Request the kernel's per-process fork counter. The kernel-worker entry * forwards this to `kernel_get_fork_count` and posts a `response` message * with `result` set to a `bigint` (u64 as BigInt). Used by the spawn @@ -238,6 +247,14 @@ export interface GetSpawnScratchCapacityRequestMessage { requestId: number; } +/** Deliver `signum` to `pid`. Responds `true` when the process existed. */ +export interface SignalProcessMessage { + type: "signal_process"; + requestId: number; + pid: number; + signum: number; +} + export interface ResolveExecResponseMessage { type: "resolve_exec_response"; requestId: number; @@ -326,9 +343,11 @@ export type MainToKernelMessage = | DestroyMessage | ExportRootfsImageMessage | ReadVfsFileMessage + | WriteVfsFileMessage | GetForkCountRequestMessage | GetKernelMemoryPagesRequestMessage | GetSpawnScratchCapacityRequestMessage + | SignalProcessMessage | ResolveExecResponseMessage | EnumProcsRequestMessage | ReadProcMapsRequestMessage diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts index eeb0246189..84baf905ec 100644 --- a/host/src/node-kernel-worker-entry.ts +++ b/host/src/node-kernel-worker-entry.ts @@ -2749,6 +2749,61 @@ async function handleReadVfsFile( } } +function handleWriteVfsFile( + msg: Extract, +) { + const io = vfsExecIO; + if (!io) { + respondError(msg.requestId, "VFS is not initialized"); + return; + } + let releaseMutation: (() => void) | undefined; + let fd: number | null = null; + try { + releaseMutation = rootfsSnapshotGate.beginMutation( + "write a rootfs file", + ); + fd = io.open( + msg.path, + 0o1101 /* O_WRONLY | O_CREAT | O_TRUNC */, + msg.mode & 0o7777, + ); + let offset = 0; + while (offset < msg.data.byteLength) { + const written = io.write( + fd, + msg.data.subarray(offset), + null, + msg.data.byteLength - offset, + ); + if (written <= 0) { + throw new Error(`Short write while staging ${msg.path}`); + } + offset += written; + } + io.close(fd); + fd = null; + // open(O_CREAT) preserves an existing file's mode. Apply the caller's + // requested mode explicitly so replacement and creation behave alike. + io.chmod(msg.path, msg.mode & 0o7777); + respond(msg.requestId, true); + } catch (error) { + if (fd !== null) { + try { + io.close(fd); + } catch { + // Preserve the write failure as the useful error. + } + } + respondError( + msg.requestId, + error instanceof Error ? error.message : String(error), + ); + } finally { + releaseMutation?.(); + } +} + // --- Message dispatch --- port.on("message", (msg: MainToKernelMessage) => { @@ -2834,6 +2889,17 @@ port.on("message", (msg: MainToKernelMessage) => { case "read_vfs_file": void handleReadVfsFile(msg); break; + case "write_vfs_file": + handleWriteVfsFile(msg); + break; + case "signal_process": { + try { + respond(msg.requestId, kernelWorker.signalProcess(msg.pid, msg.signum)); + } catch (err) { + respondError(msg.requestId, (err as Error)?.message ?? String(err)); + } + break; + } case "get_fork_count": { // Round-trip access to the kernel's per-process fork counter for // tests asserting SYS_SPAWN didn't fall back to fork. Result is a diff --git a/host/test/browser-kernel.test.ts b/host/test/browser-kernel.test.ts index e8938f1dd8..d33b7942e3 100644 --- a/host/test/browser-kernel.test.ts +++ b/host/test/browser-kernel.test.ts @@ -677,6 +677,30 @@ describe("BrowserKernel", () => { expect(await readPromise).toEqual(bytes); }); + it("signalProcess round-trips through the browser kernel worker", async () => { + const BrowserKernel = await loadBrowserKernel(); + const kernel = new BrowserKernel({ kernelOwnedFs: true }); + const initPromise = kernel.initFromImage({ + kernelWasm: new ArrayBuffer(8), + vfsImage: new Uint8Array(0), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const worker = MockWorker.instances[0]!; + worker.simulateMessage({ type: "ready" }); + await initPromise; + + const signalPromise = kernel.signalProcess(41, 15); + await new Promise((resolve) => setTimeout(resolve, 0)); + const signal = worker.lastMessage("signal_process"); + expect(signal).toMatchObject({ pid: 41, signum: 15 }); + worker.simulateMessage({ + type: "response", + requestId: signal.requestId, + result: true, + }); + await expect(signalPromise).resolves.toBe(true); + }); + it("mutates files through the VFS-owning worker with lossless snapshots", async () => { const BrowserKernel = await loadBrowserKernel(); const kernel = new BrowserKernel({ kernelOwnedFs: true }); diff --git a/host/test/global-setup.ts b/host/test/global-setup.ts index e7d052e35a..e15e6ce592 100644 --- a/host/test/global-setup.ts +++ b/host/test/global-setup.ts @@ -115,6 +115,7 @@ const TEST_PROGRAMS = [ "spawn-coverage.c", "spawn-pause.c", "block-forever.c", + "signal-wait.c", "mount_probe_test.c", "getpwent_smoke.c", "initial-credentials-test.c", diff --git a/host/test/node-rootfs-export.test.ts b/host/test/node-rootfs-export.test.ts index b7b41bd4c4..890e3b0cc8 100644 --- a/host/test/node-rootfs-export.test.ts +++ b/host/test/node-rootfs-export.test.ts @@ -129,6 +129,9 @@ describe("NodeKernelHost rootfs export contract", () => { await expect(host.readFileFromVfs("/missing")).rejects.toThrow( "VFS read requires an initialized kernel", ); + await expect( + host.writeFileToVfs("/tmp/file", new Uint8Array([1])), + ).rejects.toThrow("VFS write requires an initialized kernel"); await expect(host.exportRootfsImage()).rejects.toThrow( "rootfs export requires an initialized kernel", ); @@ -142,6 +145,9 @@ describe("NodeKernelHost rootfs export contract", () => { try { await host.init(asArrayBuffer(new Uint8Array(readFileSync(kernelPath!)))); await expect(host.readFileFromVfs("/missing")).resolves.toBeNull(); + await expect( + host.writeFileToVfs("/tmp/file", new Uint8Array([1])), + ).rejects.toThrow("VFS is not initialized"); await expect(host.exportRootfsImage()).rejects.toThrow( "rootfs export requires a VFS-backed kernel", ); @@ -160,6 +166,11 @@ describe("NodeKernelHost rootfs export contract", () => { let exported: Uint8Array; try { await first.init(asArrayBuffer(kernel)); + const staged = new Uint8Array([9, 8, 7, 6]); + await first.writeFileToVfs("/var/lib/ingested", staged, 0o620); + await expect( + first.readFileFromVfs("/var/lib/ingested"), + ).resolves.toEqual(staged); exported = await first.exportRootfsImage(); } finally { await first.destroy(); @@ -171,6 +182,10 @@ describe("NodeKernelHost rootfs export contract", () => { readFile(restored, "/var/lib/persisted-state"), )).toBe("survives reboot\n"); expect(restored.stat("/var/lib/persisted-state").mode & 0o7777).toBe(0o640); + expect(readFile(restored, "/var/lib/ingested")).toEqual( + new Uint8Array([9, 8, 7, 6]), + ); + expect(restored.stat("/var/lib/ingested").mode & 0o7777).toBe(0o620); expect(restored.exportLazyEntries()).toEqual([expect.objectContaining({ path: "/opt/lazy-tool", url: "https://packages.example.test/lazy-tool.wasm", diff --git a/host/test/signal-process.test.ts b/host/test/signal-process.test.ts new file mode 100644 index 0000000000..7f4b86ab78 --- /dev/null +++ b/host/test/signal-process.test.ts @@ -0,0 +1,76 @@ +/** + * Host-initiated signal delivery: `NodeKernelHost.signalProcess`. + * + * This is the Node half of a capability the browser host exposes identically + * (`BrowserKernel.signalProcess`), used by the browser demos' file-ingest flow + * to stop the process holding a single-owner device before relaunching it. + * + * Unlike `terminateProcess`, which tears the wasm worker down from the host, + * this routes through the kernel's SYS_KILL path, so the target's signal + * disposition applies and the kernel's own exit cleanup runs. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { NodeKernelHost } from "../src/node-kernel-host"; +import { findRepoRoot } from "../src/binary-resolver"; + +const SIGTERM = 15; + +function programBytes(name: string): ArrayBuffer { + const bytes = readFileSync(join(findRepoRoot(), "examples", name)); + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; +} + +describe("host-initiated signal delivery", () => { + it("SIGTERM terminates a process blocked in pause()", { timeout: 30_000 }, async () => { + const exited = new Map(); + const host = new NodeKernelHost({ + onProcessEvent: (event) => { + if (event.kind === "exit") exited.set(event.pid, event.exitStatus); + }, + }); + await host.init(); + try { + // spawn() resolves with the *exit status*, so don't await it here: + // signal-wait never exits on its own. Take the pid from onStarted. + let pid = -1; + const started = new Promise((resolve) => { + void host.spawn(programBytes("signal-wait.wasm"), ["signal-wait"], { + onStarted: (p) => { pid = p; resolve(); }, + }); + }); + await started; + expect(pid).toBeGreaterThan(0); + + // Give the guest time to reach pause(). + await new Promise((r) => setTimeout(r, 300)); + expect(exited.has(pid)).toBe(false); + + // Signal 0 is the POSIX existence probe. It must consult kernel state + // without applying a disposition or waking the blocked process. + expect(await host.signalProcess(pid, 0)).toBe(true); + expect(exited.has(pid)).toBe(false); + + expect(await host.signalProcess(pid, SIGTERM)).toBe(true); + + await expect.poll(() => exited.has(pid), { timeout: 10_000 }).toBe(true); + } finally { + await host.destroy(); + } + }); + + it("signalling an unknown pid reports ESRCH rather than pretending", async () => { + const host = new NodeKernelHost(); + await host.init(); + try { + expect(await host.signalProcess(999_999, SIGTERM)).toBe(false); + expect(await host.signalProcess(999_999, 0)).toBe(false); + await expect(host.signalProcess(999_999, 65)).rejects.toThrow( + "Invalid POSIX signal number 65", + ); + } finally { + await host.destroy(); + } + }); +}); diff --git a/packages/registry/shell/build.toml b/packages/registry/shell/build.toml index bcfeaf2d7c..4d3c90477b 100644 --- a/packages/registry/shell/build.toml +++ b/packages/registry/shell/build.toml @@ -49,10 +49,11 @@ inputs = [ repo_url = "https://github.com/Automattic/kandelo.git" commit = "UNPUBLISHED" revision = 23 -# WHY: this recipe is bound to the admitted ABI-42 flat selection and emits the -# self-contained product directly. It no longer depends on the retired lazy -# shell campaign or its unpublished derived artifact lock. -publication_state = "ready" +# WHY: the guest Homebrew source and image-owned demo metadata both changed +# shell inputs, so the previous sealed VFS identity is no longer valid. Keep +# normal resolution closed until this exact revision 23 input set is rebuilt, +# reviewed, and sealed through the package workflow. +publication_state = "pending" [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/scripts/test-homebrew-main-shell-closure.sh b/scripts/test-homebrew-main-shell-closure.sh index 5ba89495fe..7860ff0dbc 100755 --- a/scripts/test-homebrew-main-shell-closure.sh +++ b/scripts/test-homebrew-main-shell-closure.sh @@ -2995,9 +2995,8 @@ jq --arg sha "$artifact_sha" --argjson bytes "$artifact_bytes" \ '.state = "sealed" | .image = {sha256: $sha, bytes: $bytes}' \ "$LAZY_ARTIFACT_LOCK" >"$fixture_lock" pending_fixture_lock="$TMP_ROOT/lazy-shell-pending-artifact-lock.json" -# WHY: the checked-in lock advances from pending to sealed after a reviewed -# artifact is reproduced. Keep testing the pre-publication fail-closed state -# explicitly instead of making this test depend on that release phase. +# WHY: test the pre-publication fail-closed state explicitly without making +# this fixture depend on whether the checked-in release is pending or sealed. jq '.state = "pending" | .image = null' \ "$LAZY_ARTIFACT_LOCK" >"$pending_fixture_lock" bash "$LAZY_ARTIFACT_CHECKER" \ diff --git a/web-libs/kandelo-session/src/demo-config.ts b/web-libs/kandelo-session/src/demo-config.ts index a8e384fe8f..7bcc1107ef 100644 --- a/web-libs/kandelo-session/src/demo-config.ts +++ b/web-libs/kandelo-session/src/demo-config.ts @@ -48,6 +48,34 @@ export interface DemoCompanionConfig { srcDoc: string; } +/** + * What to do once an ingested file has landed at `targetPath`. + * + * `restart` is an author-provided shell command from the VFS image, never + * user input. The uploaded file's name never reaches it: the payload is + * always written to the fixed `targetPath`. + */ +export interface DemoIngestOnLoadConfig { + restart: string; +} + +/** + * Declarative "bring your own file" capability for a demo — e.g. a NES ROM or + * a DOOM WAD. Content-neutral: the schema knows about extensions, a size cap, + * and one fixed destination, not about what the bytes mean. + */ +export interface DemoIngestConfig { + /** Lowercase extension allow-list, each including the dot (".nes"). */ + accept: string[]; + /** Fixed absolute destination. Never derived from the uploaded filename. */ + targetPath: string; + /** Hard cap, enforced before any byte is written. */ + maxBytes: number; + /** Human-facing control label, e.g. "Load ROM". */ + label?: string; + onLoad?: DemoIngestOnLoadConfig; +} + export interface DemoGuideConfig { title: string; summary?: string; @@ -60,6 +88,7 @@ export interface KandeloDemoProfileConfig { presentation?: DemoPresentationConfig; assets?: DemoAssetConfig[]; guide?: DemoGuideConfig; + ingest?: DemoIngestConfig; } export interface KandeloDemoConfig { @@ -67,6 +96,7 @@ export interface KandeloDemoConfig { presentation?: DemoPresentationConfig; assets?: DemoAssetConfig[]; guide?: DemoGuideConfig; + ingest?: DemoIngestConfig; profiles?: Record; } @@ -190,6 +220,90 @@ export function resolveDemoGuide( : normalizeGuide(config.guide, "guide"); } +export function resolveDemoIngest( + config: KandeloDemoConfig, + profileId: string, +): DemoIngestConfig | null { + const profile = profileConfig(config, profileId); + if (isRecord(profile) && profile.ingest !== undefined) { + return normalizeIngest(profile.ingest, `profiles.${profileId}.ingest`); + } + return config.ingest === undefined + ? null + : normalizeIngest(config.ingest, "ingest"); +} + +/** Upper bound on any image-declared cap, so a bad image can't ask the browser + * to buffer an unbounded upload into the VFS. */ +const INGEST_MAX_BYTES_CEILING = 64 * 1024 * 1024; + +function normalizeIngest(value: unknown, field: string): DemoIngestConfig { + if (!isRecord(value)) { + throw new Error(`${field} must be an object`); + } + + if (!Array.isArray(value.accept) || value.accept.length === 0) { + throw new Error(`${field}.accept must be a non-empty array`); + } + const accept = value.accept.map((ext, index) => { + const raw = requiredString(ext, `${field}.accept[${index}]`); + if ( + !raw.startsWith(".") + || raw.length < 2 + || raw.length > 32 + || raw.includes("/") + || /\s/.test(raw) + ) { + throw new Error(`${field}.accept[${index}] must be an extension like ".nes"`); + } + return raw.toLowerCase(); + }); + if (new Set(accept).size !== accept.length) { + throw new Error(`${field}.accept must not contain duplicate extensions`); + } + + const targetPath = requiredString(value.targetPath, `${field}.targetPath`); + if (!targetPath.startsWith("/")) { + throw new Error(`${field}.targetPath must be absolute`); + } + // The write goes to this exact path, so a traversal here would escape the + // author's intended destination even though no user input reaches it. + const pathSegments = targetPath.split("/").slice(1); + if ( + pathSegments.length === 0 + || pathSegments.some( + (segment) => segment === "" || segment === "." || segment === "..", + ) + || targetPath.includes("\0") + ) { + throw new Error(`${field}.targetPath must be a normalized file path`); + } + + const maxBytes = value.maxBytes; + if (typeof maxBytes !== "number" || !Number.isInteger(maxBytes) || maxBytes <= 0) { + throw new Error(`${field}.maxBytes must be a positive integer`); + } + if (maxBytes > INGEST_MAX_BYTES_CEILING) { + throw new Error( + `${field}.maxBytes exceeds the ${INGEST_MAX_BYTES_CEILING}-byte ceiling`, + ); + } + + const ingest: DemoIngestConfig = { accept, targetPath, maxBytes }; + if (typeof value.label === "string" && value.label.length > 0) { + ingest.label = value.label; + } + if (value.onLoad !== undefined) { + if (!isRecord(value.onLoad)) { + throw new Error(`${field}.onLoad must be an object`); + } + ingest.onLoad = { + restart: requiredString(value.onLoad.restart, `${field}.onLoad.restart`), + }; + } + return ingest; +} + function profileConfig( config: KandeloDemoConfig, profileId: string, @@ -208,6 +322,9 @@ function validateProfileFields( if (value.guide !== undefined) { normalizeGuide(value.guide, `${field}.guide`); } + if (value.ingest !== undefined) { + normalizeIngest(value.ingest, `${field}.ingest`); + } } function normalizePresentationConfig(config: unknown): DemoPresentation { diff --git a/web-libs/kandelo-session/src/demo-ingest.ts b/web-libs/kandelo-session/src/demo-ingest.ts new file mode 100644 index 0000000000..1b56639a46 --- /dev/null +++ b/web-libs/kandelo-session/src/demo-ingest.ts @@ -0,0 +1,280 @@ +// Reusable "bring your own file" ingest for demos that consume a single +// author-declared input file — a NES ROM, a DOOM WAD, a disk image. +// +// The capability is declared in the VFS image (`/etc/kandelo/demo.json` → +// `ingest`) and executed here. Nothing in this module knows what the bytes +// mean, and no part of the uploaded file other than its bytes influences what +// happens: the destination is the config's fixed `targetPath`, and the +// relaunch command is the config's author-provided `onLoad.restart`. +// +// Restarting matters because the interesting consumers hold a single-owner +// device. `/dev/fb0` returns EBUSY on a second open (kernel +// `acquire_fb0_or_busy`), so a new instance cannot start until the old one has +// exited and the kernel's exit path has released the binding. + +import type { DemoIngestConfig } from "./demo-config"; +import type { KernelHost } from "./kernel-host"; + +/** POSIX SIGTERM. Default disposition terminates a process with no handler. */ +export const SIGTERM = 15; + +export type IngestRejection = + | "extension" + | "too-large" + | "empty" + | "read-failed" + | "write-failed" + | "restart-failed"; + +/** A rejection the UI is expected to show the user verbatim. */ +export class IngestError extends Error { + readonly reason: IngestRejection; + constructor(reason: IngestRejection, message: string) { + super(message); + this.name = "IngestError"; + this.reason = reason; + } +} + +export type IngestPhase = "validating" | "writing" | "stopping" | "starting" | "done"; + +/** Minimal file shape — a DOM `File` satisfies it; tests can pass a literal. */ +export interface IngestFileLike { + readonly name: string; + readonly size: number; + arrayBuffer(): Promise; +} + +export interface RunDemoIngestOptions { + /** + * The process to stop before relaunching, or null if nothing is running. + * Callers resolve this from whatever owns the resource — the framebuffer + * pane passes the current /dev/fb0 holder. + */ + targetPid?: number | null; + /** + * Resolves once the stopped process has released what the replacement needs. + * Defaults to "the pid emitted an exit event". The framebuffer pane passes a + * stricter wait that also observes the /dev/fb0 unbind, because the exit + * event and the device release are two separate observations and relaunching + * between them would hit EBUSY. + */ + waitForRelease?: (pid: number, signal: AbortSignal) => Promise; + onPhase?: (phase: IngestPhase) => void; + /** How long to wait for the old process to go away. */ + stopTimeoutMs?: number; +} + +/** Lowercase extension of a filename, including the dot. "" when none. */ +function extensionOf(name: string): string { + const dot = name.lastIndexOf("."); + return dot === -1 ? "" : name.slice(dot).toLowerCase(); +} + +/** + * Check a candidate file against the declared policy. Pure and synchronous, so + * the UI can reject before reading a single byte off disk. + */ +export function validateIngestFile(ingest: DemoIngestConfig, file: IngestFileLike): void { + const ext = extensionOf(file.name); + if (!ingest.accept.includes(ext)) { + throw new IngestError( + "extension", + `${file.name || "file"}: expected ${ingest.accept.join(" or ")}`, + ); + } + if (file.size <= 0) { + throw new IngestError("empty", `${file.name}: file is empty`); + } + if (file.size > ingest.maxBytes) { + throw new IngestError( + "too-large", + `${file.name}: ${formatBytes(file.size)} exceeds the ` + + `${formatBytes(ingest.maxBytes)} limit`, + ); + } +} + +/** + * Validate, write to the declared path, then hand off to a fresh process. + * + * Ordering is deliberate: the write happens *before* the running process is + * signalled. A rejected or failed write then leaves the current program + * untouched and on screen, rather than killing it and leaving a blank pane. + */ +export async function runDemoIngest( + host: KernelHost, + ingest: DemoIngestConfig, + file: IngestFileLike, + options: RunDemoIngestOptions = {}, +): Promise { + const { + targetPid = null, + waitForRelease, + onPhase = () => {}, + stopTimeoutMs = 10_000, + } = options; + + onPhase("validating"); + validateIngestFile(ingest, file); + let bytes: Uint8Array; + try { + bytes = new Uint8Array(await file.arrayBuffer()); + } catch (err) { + throw new IngestError( + "read-failed", + `could not read ${file.name || "file"}: ${errorText(err)}`, + ); + } + // File.size is useful for rejecting before allocation, but the returned + // buffer is the authority for what crosses the worker boundary. Recheck it + // so a malformed File-like object cannot bypass the cap. + validateIngestByteLength(ingest, file.name, bytes.byteLength); + + onPhase("writing"); + try { + await host.writeFile(ingest.targetPath, bytes, 0o644); + } catch (err) { + throw new IngestError( + "write-failed", + `could not write ${ingest.targetPath}: ${errorText(err)}`, + ); + } + + if (!ingest.onLoad) { + onPhase("done"); + return; + } + + if (targetPid !== null) { + onPhase("stopping"); + // Start watching before signalling, or a fast exit lands before we listen. + // The abort signal also guarantees bounded listener lifetime on timeout. + const releaseAbort = new AbortController(); + let releasePromise: Promise; + try { + releasePromise = waitForRelease + ? waitForRelease(targetPid, releaseAbort.signal) + : waitForProcessExit(host, targetPid, { + signal: releaseAbort.signal, + }); + } catch (error) { + releasePromise = Promise.reject(error); + } + const released = waitUntil( + releasePromise, + stopTimeoutMs, + `process ${targetPid} did not exit within ${stopTimeoutMs}ms`, + ); + // If signalProcess throws we never await `released`; keep its eventual + // timeout rejection from surfacing as an unhandled rejection. + released.catch(() => {}); + try { + // An already-dead pid resolves false. The subscribed-then-enumerated + // release wait still settles truthfully without requiring a future event. + await host.signalProcess(targetPid, SIGTERM); + await released; + } catch (err) { + throw new IngestError( + "restart-failed", + `wrote ${ingest.targetPath} but could not stop pid ${targetPid}: ${errorText(err)}`, + ); + } finally { + releaseAbort.abort(); + } + } + + onPhase("starting"); + try { + // The command is a long-lived foreground program. Wait for the PTY write, + // not for a new shell prompt, and propagate any dispatch failure. + await host.dispatchShellCommand(ingest.onLoad.restart); + } catch (err) { + throw new IngestError( + "restart-failed", + `wrote ${ingest.targetPath} but could not start replacement: ${errorText(err)}`, + ); + } + onPhase("done"); +} + +/** + * Resolve once `pid` has exited. Subscribe before enumerating so neither an + * already-finished process nor an exit racing the initial query can wedge the + * caller. + */ +export function waitForProcessExit( + host: KernelHost, + pid: number, + options: { signal?: AbortSignal } = {}, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + let off = () => {}; + const finish = (error?: unknown) => { + if (settled) return; + settled = true; + off(); + options.signal?.removeEventListener("abort", onAbort); + if (error === undefined) resolve(); + else reject(error); + }; + const onAbort = () => finish(new Error(`wait for pid ${pid} was cancelled`)); + if (options.signal?.aborted) { + onAbort(); + return; + } + options.signal?.addEventListener("abort", onAbort, { once: true }); + off = host.subscribeProcessEvents((event) => { + if (event.kind === "exit" && event.pid === pid) finish(); + }); + if (settled) off(); + + void host.enumProcs().then( + (processes) => { + const running = processes.some( + (process) => process.pid === pid && process.state !== "Z", + ); + if (!running) finish(); + }, + (error) => finish(error), + ); + }); +} + +function waitUntil(promise: Promise, ms: number, message: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), ms); + promise.then( + (value) => { clearTimeout(timer); resolve(value); }, + (err) => { clearTimeout(timer); reject(err); }, + ); + }); +} + +function errorText(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function validateIngestByteLength( + ingest: DemoIngestConfig, + name: string, + byteLength: number, +): void { + if (byteLength <= 0) { + throw new IngestError("empty", `${name || "file"}: file is empty`); + } + if (byteLength > ingest.maxBytes) { + throw new IngestError( + "too-large", + `${name || "file"}: ${formatBytes(byteLength)} exceeds the ` + + `${formatBytes(ingest.maxBytes)} limit`, + ); + } +} + +function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KiB`; + return `${(n / (1024 * 1024)).toFixed(1)} MiB`; +} diff --git a/web-libs/kandelo-session/src/index.ts b/web-libs/kandelo-session/src/index.ts index a7354f8c33..c5a4c21178 100644 --- a/web-libs/kandelo-session/src/index.ts +++ b/web-libs/kandelo-session/src/index.ts @@ -6,3 +6,4 @@ export * from "./demo-config"; export * from "./demo-config-vfs"; export * from "./demo-guides"; export * from "./shell-config"; +export * from "./demo-ingest"; diff --git a/web-libs/kandelo-session/src/kernel-host.ts b/web-libs/kandelo-session/src/kernel-host.ts index 221fd9be5a..2784eae121 100644 --- a/web-libs/kandelo-session/src/kernel-host.ts +++ b/web-libs/kandelo-session/src/kernel-host.ts @@ -1,4 +1,4 @@ -import type { DemoGuideConfig } from "./demo-config"; +import type { DemoGuideConfig, DemoIngestConfig } from "./demo-config"; import { advanceLazyDownloadSummary } from "./lazy-download"; // KernelHost — the contract between Kandelo session UI and the kernel/host runtime. @@ -126,8 +126,8 @@ export interface LazyDownloadSummary extends LazyDownloadEvent { } export interface KernelLike { - /** Synchronous VFS the kernel-worker sees. */ - readonly fs: FileSystemLike; + /** Legacy synchronous VFS surface; worker-owned hosts intentionally omit it. */ + readonly fs?: FileSystemLike; /** /dev/fb0 binding registry. Used by attachFramebuffer. */ readonly framebuffers?: FramebufferRegistryLike; /** @@ -135,6 +135,19 @@ export interface KernelLike { * bindings; write-based bindings (fbDOOM) don't reach into this. */ getProcessMemory?(pid: number): WebAssembly.Memory | undefined; + /** + * Deliver a POSIX signal to `pid` through the kernel's signal path (not a + * host-side worker teardown). Resolves false when the process is already + * gone. Used to stop a process that owns a single-owner device — e.g. the + * /dev/fb0 holder — before launching its replacement. + */ + signalProcess?(pid: number, signum: number): Promise; + /** + * Write `bytes` to `path` in the kernel-owned VFS. Its parent must already + * exist. The kernel worker owns the filesystem, so this is an async + * round-trip (unlike the deprecated synchronous {@link fs}). + */ + writeFileToVfs?(path: string, bytes: Uint8Array, mode?: number): Promise; /** * Append bytes to a process's stdin buffer. Used by the framebuffer * input path so DOM key events on the canvas reach the fb-bound @@ -581,6 +594,8 @@ export interface KernelHost { // shell / pty attachPty(path?: string, opts?: { cols: number; rows: number }): Promise; + /** Resolve after a command has been written, without waiting for a prompt. */ + dispatchShellCommand(command: string): Promise; runShellCommand(command: string): Promise; // VFS / procfs @@ -588,6 +603,19 @@ export interface KernelHost { readFileText(path: string): Promise; readDir(path: string): Promise; stat(path: string): Promise; + /** + * Write `bytes` to `path` in the live guest VFS. The parent directory must + * already exist. Callers are responsible for validating both the path and + * the payload — this is a raw capability, not a policy layer. + */ + writeFile(path: string, bytes: Uint8Array, mode?: number): Promise; + + // process control + /** + * Deliver a POSIX signal to `pid`. Resolves false when the process no longer + * exists. Rejects when the attached kernel cannot signal. + */ + signalProcess(pid: number, signum: number): Promise; // inspector enumProcs(): Promise; @@ -627,6 +655,9 @@ export interface KernelHost { subscribeSurfaceAvailability(cb: (state: SurfaceAvailability) => void): () => void; getDemoGuide(): DemoGuideConfig | null; subscribeDemoGuide(cb: (state: DemoGuideConfig | null) => void): () => void; + /** File-ingest capability declared by the current VFS image, if any. */ + getDemoIngest(): DemoIngestConfig | null; + subscribeDemoIngest(cb: (state: DemoIngestConfig | null) => void): () => void; // sharing snapshot(opts?: SnapshotOptions): Promise; @@ -832,6 +863,7 @@ export class LiveKernelHost implements KernelHost { private surfaceListeners = new ListenerSet(); private galleryListeners = new ListenerSet(); private demoGuideListeners = new ListenerSet(); + private demoIngestListeners = new ListenerSet(); private _descriptor: BootDescriptor; private presentation: DemoPresentation; @@ -839,6 +871,7 @@ export class LiveKernelHost implements KernelHost { private galleryItems: GalleryItem[]; private webPreview: WebPreviewState | null = null; private demoGuide: DemoGuideConfig | null = null; + private demoIngest: DemoIngestConfig | null = null; private surfaceAvailability: SurfaceAvailability = { ...DEFAULT_SURFACE_AVAILABILITY }; private offFramebufferAvailability: (() => void) | null = null; private offLazyDownloads: (() => void) | null = null; @@ -928,6 +961,7 @@ export class LiveKernelHost implements KernelHost { this.refreshFramebufferAvailability(); this.setSurfaceAvailability({ web: false, kms: false }); this.setDemoGuide(null); + this.setDemoIngest(null); } /** Configure the program attachPty spawns by default. */ @@ -957,14 +991,18 @@ export class LiveKernelHost implements KernelHost { this.demoGuideListeners.emit(this.getDemoGuide()); } - /** - * Write a command into the persistent PTY-backed shell. Owner code uses - * this for demos like Doom where the app should visibly originate from a - * real terminal command even when the terminal drawer starts closed. - */ - async runShellCommand(command: string): Promise { + /** Update the optional file-ingest capability exposed by the current image. */ + setDemoIngest(ingest: DemoIngestConfig | null): void { + this.demoIngest = ingest ? structuredClone(ingest) : null; + this.demoIngestListeners.emit(this.getDemoIngest()); + } + + private async startShellCommand( + command: string, + ): Promise<{ completion: Promise }> { const sessionKey = "/dev/pts/0"; - const previousCommandDone = this.ptyCommandQueues.get(sessionKey) ?? Promise.resolve(); + const previousCommandDone = + this.ptyCommandQueues.get(sessionKey) ?? Promise.resolve(); let resolveCommandDone!: () => void; let rejectCommandDone!: (err: unknown) => void; const commandDone = new Promise((resolve, reject) => { @@ -983,7 +1021,11 @@ export class LiveKernelHost implements KernelHost { await previousCommandDone.catch(() => {}); const pty = await this.attachPty(sessionKey, { cols: 100, rows: 30 }); const prompt = this.shell ? shellPrompt(this.shell) : null; - await waitForPtyReadiness(pty, { includeHistory: true, timeoutMs: 1200, prompt }).catch(() => {}); + await waitForPtyReadiness(pty, { + includeHistory: true, + timeoutMs: 1200, + prompt, + }).catch(() => {}); const completion = waitForPtyReadiness(pty, { includeHistory: false, timeoutMs: 300_000, @@ -991,13 +1033,28 @@ export class LiveKernelHost implements KernelHost { }); void completion.then(resolveCommandDone, rejectCommandDone); pty.write(command.endsWith("\n") ? command : `${command}\n`); - await commandDone; + return { completion: commandDone }; } catch (err) { rejectCommandDone(err); throw err; } } + /** + * Write a command into the persistent PTY-backed shell and resolve once the + * write has succeeded. This is the truthful dispatch surface for long-lived + * foreground programs that intentionally do not return to a shell prompt. + */ + async dispatchShellCommand(command: string): Promise { + await this.startShellCommand(command); + } + + /** Write a command and wait until the shell presents its next prompt. */ + async runShellCommand(command: string): Promise { + const { completion } = await this.startShellCommand(command); + await completion; + } + /** Update the status and fan out to subscribers. */ setStatus(s: MachineStatus): void { if (s === this._status) return; @@ -1180,6 +1237,7 @@ export class LiveKernelHost implements KernelHost { this.offLazyDownloads = null; this.setSurfaceAvailability({ terminal: false, framebuffer: false, web: false, kms: false }); this.setDemoGuide(null); + this.setDemoIngest(null); await this.kernel?.destroy?.(); } @@ -1412,6 +1470,32 @@ export class LiveKernelHost implements KernelHost { return new TextDecoder().decode(await this.readFile(path)); } + /** + * Create or replace one live guest file through the VFS-owning worker. The + * parent must already exist. Completion proves that the worker closed the + * file and applied its requested mode; no reboot or image rebuild is needed. + */ + async writeFile(path: string, bytes: Uint8Array, mode = 0o644): Promise { + if (!this.kernel?.writeFileToVfs) { + throw new Error( + `LiveKernelHost.writeFile(${path}): the attached kernel cannot write ` + + `to the VFS (no writeFileToVfs).`, + ); + } + await this.kernel.writeFileToVfs(path, bytes, mode); + } + + // ── KernelHost: process control ───────────────────────────────────────── + + async signalProcess(pid: number, signum: number): Promise { + if (!this.kernel?.signalProcess) { + throw new Error( + "LiveKernelHost.signalProcess: the attached kernel cannot deliver signals.", + ); + } + return this.kernel.signalProcess(pid, signum); + } + async readDir(path: string): Promise { const fs = this.requireFs(); const names = loadIdNameMaps(fs); @@ -1481,10 +1565,9 @@ export class LiveKernelHost implements KernelHost { } private requireFs(): FileSystemLike { - if (!this.kernel) { + if (!this.kernel?.fs) { throw new Error( - "LiveKernelHost: no kernel attached. " + - "Call attachKernel() before reading the VFS.", + "LiveKernelHost: the attached kernel has no synchronous VFS surface.", ); } return this.kernel.fs; @@ -1500,8 +1583,10 @@ export class LiveKernelHost implements KernelHost { // version and the kernel ship together (ABI ≥ 9). if (this.kernel?.enumProcs) { const snaps = await this.kernel.enumProcs(); - const names = loadIdNameMaps(this.kernel.fs); - return snaps.map((s) => toProcessInfo(s, names.users)); + const users = this.kernel.fs + ? loadIdNameMaps(this.kernel.fs).users + : new Map([[0, "root"]]); + return snaps.map((s) => toProcessInfo(s, users)); } const fs = this.requireFs(); const names = loadIdNameMaps(fs); @@ -1860,6 +1945,14 @@ export class LiveKernelHost implements KernelHost { return this.demoGuide ? structuredClone(this.demoGuide) : null; } + getDemoIngest(): DemoIngestConfig | null { + return this.demoIngest ? structuredClone(this.demoIngest) : null; + } + + subscribeDemoIngest(cb: (state: DemoIngestConfig | null) => void): () => void { + return this.demoIngestListeners.add(cb); + } + subscribeDemoGuide(cb: (state: DemoGuideConfig | null) => void): () => void { return this.demoGuideListeners.add(cb); } diff --git a/web-libs/kandelo-session/test/demo-ingest.test.ts b/web-libs/kandelo-session/test/demo-ingest.test.ts new file mode 100644 index 0000000000..2e6cdbc701 --- /dev/null +++ b/web-libs/kandelo-session/test/demo-ingest.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it, vi } from "vitest"; +import { + parseKandeloDemoConfig, + resolveDemoIngest, + validateKandeloDemoConfig, + type DemoIngestConfig, +} from "../src/demo-config"; +import { + IngestError, + runDemoIngest, + waitForProcessExit, + type IngestFileLike, +} from "../src/demo-ingest"; +import type { + KernelHost, + ProcessEvent, + ProcessInfo, +} from "../src/kernel-host"; + +const INGEST: DemoIngestConfig = { + accept: [".wad"], + targetPath: "/user.wad", + maxBytes: 8, + onLoad: { restart: "fbdoom -iwad /user.wad" }, +}; + +function file( + name: string, + declaredSize: number, + bytes: readonly number[], +): IngestFileLike { + return { + name, + size: declaredSize, + async arrayBuffer() { + return Uint8Array.from(bytes).buffer; + }, + }; +} + +function ingestHost(overrides: Partial = {}): KernelHost { + return { + writeFile: vi.fn(async () => {}), + signalProcess: vi.fn(async () => true), + dispatchShellCommand: vi.fn(async () => {}), + ...overrides, + } as unknown as KernelHost; +} + +describe("image-owned demo ingest metadata", () => { + it("resolves a validated fixed-path capability", () => { + const config = parseKandeloDemoConfig(JSON.stringify({ + version: 1, + profiles: { + emulator: { + ingest: { + accept: [".ROM"], + targetPath: "/inputs/game.rom", + maxBytes: 1024, + onLoad: { restart: "emulator /inputs/game.rom" }, + }, + }, + }, + })); + expect(config).not.toBeNull(); + validateKandeloDemoConfig(config!); + expect(resolveDemoIngest(config!, "emulator")).toEqual({ + accept: [".rom"], + targetPath: "/inputs/game.rom", + maxBytes: 1024, + onLoad: { restart: "emulator /inputs/game.rom" }, + }); + }); + + it("eagerly rejects an unsafe capability in an unselected profile", () => { + const config = parseKandeloDemoConfig(JSON.stringify({ + version: 1, + profiles: { + selected: {}, + unselected: { + ingest: { + accept: [".rom"], + targetPath: "/inputs/../escape.rom", + maxBytes: 1024, + }, + }, + }, + })); + expect(config).not.toBeNull(); + expect(() => validateKandeloDemoConfig(config!)).toThrow( + "profiles.unselected.ingest.targetPath must be a normalized file path", + ); + }); +}); + +describe("demo ingest transaction", () => { + it("writes before stopping and dispatches only after release", async () => { + const events: string[] = []; + const host = ingestHost({ + writeFile: vi.fn(async (path, bytes, mode) => { + events.push(`write:${path}:${bytes.byteLength}:${mode}`); + }), + signalProcess: vi.fn(async (pid, signum) => { + events.push(`signal:${pid}:${signum}`); + return true; + }), + dispatchShellCommand: vi.fn(async (command) => { + events.push(`dispatch:${command}`); + }), + }); + + await runDemoIngest(host, INGEST, file("custom.wad", 4, [1, 2, 3, 4]), { + targetPid: 41, + waitForRelease: async (pid) => { + events.push(`watch:${pid}`); + }, + }); + + expect(events).toEqual([ + "write:/user.wad:4:420", + "watch:41", + "signal:41:15", + "dispatch:fbdoom -iwad /user.wad", + ]); + }); + + it("rechecks actual bytes before writing", async () => { + const host = ingestHost(); + await expect( + runDemoIngest(host, INGEST, file("lying.wad", 1, new Array(9).fill(1))), + ).rejects.toMatchObject>({ reason: "too-large" }); + expect(host.writeFile).not.toHaveBeenCalled(); + expect(host.signalProcess).not.toHaveBeenCalled(); + }); + + it("does not stop the current process when the VFS write fails", async () => { + const host = ingestHost({ + writeFile: vi.fn(async () => { + throw new Error("read-only mount"); + }), + }); + await expect( + runDemoIngest(host, INGEST, file("custom.wad", 1, [1]), { + targetPid: 41, + }), + ).rejects.toMatchObject>({ reason: "write-failed" }); + expect(host.signalProcess).not.toHaveBeenCalled(); + expect(host.dispatchShellCommand).not.toHaveBeenCalled(); + }); + + it("surfaces restart dispatch failure", async () => { + const host = ingestHost({ + dispatchShellCommand: vi.fn(async () => { + throw new Error("PTY closed"); + }), + }); + await expect( + runDemoIngest(host, INGEST, file("custom.wad", 1, [1])), + ).rejects.toMatchObject>({ reason: "restart-failed" }); + }); + + it("aborts a release observer after the bounded timeout", async () => { + let releaseSignal: AbortSignal | undefined; + const host = ingestHost(); + await expect( + runDemoIngest(host, INGEST, file("custom.wad", 1, [1]), { + targetPid: 41, + stopTimeoutMs: 5, + waitForRelease: (_pid, signal) => { + releaseSignal = signal; + return new Promise(() => {}); + }, + }), + ).rejects.toMatchObject>({ reason: "restart-failed" }); + expect(releaseSignal?.aborted).toBe(true); + expect(host.dispatchShellCommand).not.toHaveBeenCalled(); + }); +}); + +describe("process-exit observation", () => { + it("subscribes before proving that an already-gone pid is absent", async () => { + const unsubscribe = vi.fn(); + const host = ingestHost({ + subscribeProcessEvents: vi.fn(() => unsubscribe), + enumProcs: vi.fn(async () => []), + }); + await waitForProcessExit(host, 41); + expect(host.subscribeProcessEvents).toHaveBeenCalledOnce(); + expect(host.enumProcs).toHaveBeenCalledOnce(); + expect(unsubscribe).toHaveBeenCalledOnce(); + }); + + it("does not miss an exit racing the initial process-table query", async () => { + let listener: ((event: ProcessEvent) => void) | undefined; + let resolveProcesses!: (processes: ProcessInfo[]) => void; + const unsubscribe = vi.fn(); + const host = ingestHost({ + subscribeProcessEvents: vi.fn((next) => { + listener = next; + return unsubscribe; + }), + enumProcs: vi.fn(() => new Promise((resolve) => { + resolveProcesses = resolve; + })), + }); + + const exited = waitForProcessExit(host, 41); + listener?.({ kind: "exit", pid: 41, exitStatus: 143 }); + resolveProcesses([]); + await exited; + expect(unsubscribe).toHaveBeenCalledOnce(); + }); +}); From 3490937edb55a68ab8158ca094042d2774c1508b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:45:51 +0000 Subject: [PATCH 39/82] Build: Update mkrootfs esbuild security release Refresh the mkrootfs lockfile from esbuild 0.27.7 to 0.28.1. This takes the upstream development-server path validation fix and keeps every platform-specific esbuild package on one release. Validation: - npm ci --dry-run --ignore-scripts - mkrootfs typecheck - 180 mkrootfs tests Signed-off-by: dependabot[bot] (cherry picked from commit 40cad977d587000e5292d21273f7768a4ea85336) --- tools/mkrootfs/package-lock.json | 218 ++++++++++++++++--------------- 1 file changed, 111 insertions(+), 107 deletions(-) diff --git a/tools/mkrootfs/package-lock.json b/tools/mkrootfs/package-lock.json index 08bfb3b7f5..77ebb9b049 100644 --- a/tools/mkrootfs/package-lock.json +++ b/tools/mkrootfs/package-lock.json @@ -20,9 +20,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -37,9 +37,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -54,9 +54,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -71,9 +71,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -88,7 +88,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -103,9 +105,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -120,9 +122,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -137,9 +139,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -154,9 +156,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -171,9 +173,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -188,9 +190,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -205,9 +207,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -222,9 +224,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -239,9 +241,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -256,9 +258,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -273,9 +275,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -290,9 +292,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -307,9 +309,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -324,9 +326,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -341,9 +343,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -358,9 +360,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -375,9 +377,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -392,9 +394,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -409,9 +411,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -426,9 +428,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -443,9 +445,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -1050,7 +1052,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.27.7", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1061,32 +1065,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/estree-walker": { @@ -1384,13 +1388,13 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", - "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", From b31aede2aa0787b8ea6581cb02c5eb74ea101779 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:47:47 +0000 Subject: [PATCH 40/82] Build: Refresh minor and patch npm dependencies Update the accepted minor and patch dependency group across the browser demos, host, root tooling, OpenSSL tests, WordPress demo, and SDK. This refreshes exact locks for Vite, Playwright, CodeMirror, tsx, TypeDoc, Terser, and Vitest while preserving their intended semver ranges. Validation: - exact npm installs in all six affected package roots - host typecheck and production build - 113 SDK tests - OpenSSL test suite load; its artifact-dependent test remains skipped - documentation build - Vite 8.1.5, Playwright 1.61.1, and tsx 4.23.1 launch checks - program-package projection freshness check Browser production bundling reaches the expected ABI 43 artifact gate; no provenance tier yet contains a complete ABI 43 package closure. Broad host validation ran 3,823 tests: 3,778 passed, 5 skipped, and 40 failed, with 57 additional suite-load failures. Those failures remain explicit batch-integration work and are not represented as a passing full suite. Signed-off-by: dependabot[bot] (cherry picked from commit 7deb360be42d455e65a20721084e304a080aa78b) --- apps/browser-demos/package-lock.json | 312 ++++---- apps/browser-demos/package.json | 16 +- host/package-lock.json | 678 +++++++++--------- host/package.json | 8 +- package-lock.json | 242 +++---- package.json | 6 +- packages/registry/openssl/package-lock.json | 392 +++++----- packages/registry/openssl/package.json | 2 +- .../registry/wordpress/demo/package-lock.json | 32 +- packages/registry/wordpress/demo/package.json | 4 +- sdk/package-lock.json | 392 +++++----- sdk/package.json | 2 +- 12 files changed, 1052 insertions(+), 1034 deletions(-) diff --git a/apps/browser-demos/package-lock.json b/apps/browser-demos/package-lock.json index d927402211..b27ab7b813 100644 --- a/apps/browser-demos/package-lock.json +++ b/apps/browser-demos/package-lock.json @@ -8,13 +8,13 @@ "name": "wasm-posix-browser-example", "version": "0.1.0", "dependencies": { - "@codemirror/commands": "^6.10.3", - "@codemirror/language": "^6.12.3", + "@codemirror/commands": "^6.10.4", + "@codemirror/language": "^6.12.4", "@codemirror/legacy-modes": "^6.5.3", - "@codemirror/search": "^6.7.0", + "@codemirror/search": "^6.7.1", "@codemirror/state": "^6.6.0", "@codemirror/theme-one-dark": "^6.1.3", - "@codemirror/view": "^6.43.1", + "@codemirror/view": "^6.43.6", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "codemirror": "^6.0.2", @@ -25,13 +25,13 @@ }, "devDependencies": { "@babel/parser": "^7.29.7", - "@playwright/test": "^1.61.0", + "@playwright/test": "^1.61.1", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.2", - "terser": "^5.48.0", + "@vitejs/plugin-react": "^6.0.3", + "terser": "^5.49.0", "typescript": "^6.0.3", - "vite": "^8.0.16" + "vite": "^8.1.5" } }, "node_modules/@babel/helper-string-parser": { @@ -97,21 +97,21 @@ } }, "node_modules/@codemirror/commands": { - "version": "6.10.3", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", - "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz", + "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==", "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.6.0", + "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "node_modules/@codemirror/language": { - "version": "6.12.3", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", - "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", @@ -143,9 +143,9 @@ } }, "node_modules/@codemirror/search": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz", - "integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz", + "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", @@ -154,9 +154,9 @@ } }, "node_modules/@codemirror/state": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", - "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", + "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", "license": "MIT", "dependencies": { "@marijn/find-cluster-break": "^1.0.0" @@ -175,33 +175,33 @@ } }, "node_modules/@codemirror/view": { - "version": "6.43.1", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.1.tgz", - "integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==", + "version": "6.43.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz", + "integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==", "license": "MIT", "dependencies": { - "@codemirror/state": "^6.6.0", + "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -210,9 +210,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -301,14 +301,14 @@ "license": "MIT" }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -320,9 +320,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "dev": true, "license": "MIT", "funding": { @@ -330,13 +330,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", - "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.61.0" + "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -346,9 +346,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "cpu": [ "arm64" ], @@ -363,9 +363,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "cpu": [ "arm64" ], @@ -380,9 +380,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "cpu": [ "x64" ], @@ -397,9 +397,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "cpu": [ "x64" ], @@ -414,9 +414,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "cpu": [ "arm" ], @@ -431,13 +431,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -448,13 +451,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -465,13 +471,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -482,13 +491,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -499,13 +511,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -516,13 +531,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -533,9 +551,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "cpu": [ "arm64" ], @@ -550,9 +568,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", "cpu": [ "wasm32" ], @@ -560,18 +578,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ "arm64" ], @@ -586,9 +604,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "cpu": [ "x64" ], @@ -610,9 +628,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -641,13 +659,13 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", - "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "^1.0.0" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -1053,9 +1071,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -1079,9 +1097,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -1092,13 +1110,13 @@ } }, "node_modules/playwright": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", - "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.61.0" + "playwright-core": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -1111,9 +1129,9 @@ } }, "node_modules/playwright-core": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", - "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1139,9 +1157,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -1159,7 +1177,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1189,13 +1207,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -1205,21 +1223,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, "node_modules/scheduler": { @@ -1266,9 +1284,9 @@ "license": "MIT" }, "node_modules/terser": { - "version": "5.48.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", - "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", + "version": "5.49.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz", + "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -1324,16 +1342,16 @@ } }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "bin": { @@ -1350,7 +1368,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/apps/browser-demos/package.json b/apps/browser-demos/package.json index 76600edb4b..b2bfabfe2d 100644 --- a/apps/browser-demos/package.json +++ b/apps/browser-demos/package.json @@ -12,22 +12,22 @@ }, "devDependencies": { "@babel/parser": "^7.29.7", - "@playwright/test": "^1.61.0", + "@playwright/test": "^1.61.1", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.2", - "terser": "^5.48.0", + "@vitejs/plugin-react": "^6.0.3", + "terser": "^5.49.0", "typescript": "^6.0.3", - "vite": "^8.0.16" + "vite": "^8.1.5" }, "dependencies": { - "@codemirror/commands": "^6.10.3", - "@codemirror/language": "^6.12.3", + "@codemirror/commands": "^6.10.4", + "@codemirror/language": "^6.12.4", "@codemirror/legacy-modes": "^6.5.3", - "@codemirror/search": "^6.7.0", + "@codemirror/search": "^6.7.1", "@codemirror/state": "^6.6.0", "@codemirror/theme-one-dark": "^6.1.3", - "@codemirror/view": "^6.43.1", + "@codemirror/view": "^6.43.6", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "codemirror": "^6.0.2", diff --git a/host/package-lock.json b/host/package-lock.json index 3ca9d768a6..cf394a3c04 100644 --- a/host/package-lock.json +++ b/host/package-lock.json @@ -13,32 +13,32 @@ "fzstd": "^0.1.1" }, "devDependencies": { - "@playwright/test": "^1.61.0", + "@playwright/test": "^1.61.1", "@types/node": "^25.9.3", "esbuild": "^0.27.0", "tsup": "^8.0.0", - "tsx": "^4.22.4", - "typedoc": "^0.28.19", + "tsx": "^4.23.1", + "typedoc": "^0.28.20", "typescript": "^6.0.3", - "vitest": "^4.1.9" + "vitest": "^4.1.10" } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -47,9 +47,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -58,9 +58,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", "cpu": [ "ppc64" ], @@ -75,9 +75,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "cpu": [ "arm" ], @@ -92,9 +92,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "cpu": [ "arm64" ], @@ -109,9 +109,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", "cpu": [ "x64" ], @@ -126,9 +126,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "cpu": [ "arm64" ], @@ -143,9 +143,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "cpu": [ "x64" ], @@ -160,9 +160,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "cpu": [ "arm64" ], @@ -177,9 +177,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "cpu": [ "x64" ], @@ -194,9 +194,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", "cpu": [ "arm" ], @@ -211,9 +211,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", "cpu": [ "arm64" ], @@ -228,9 +228,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", "cpu": [ "ia32" ], @@ -245,9 +245,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", "cpu": [ "loong64" ], @@ -262,9 +262,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", "cpu": [ "mips64el" ], @@ -279,9 +279,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "cpu": [ "ppc64" ], @@ -296,9 +296,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "cpu": [ "riscv64" ], @@ -313,9 +313,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "cpu": [ "s390x" ], @@ -330,9 +330,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" ], @@ -347,9 +347,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", "cpu": [ "arm64" ], @@ -364,9 +364,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "cpu": [ "x64" ], @@ -381,9 +381,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", "cpu": [ "arm64" ], @@ -398,9 +398,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "cpu": [ "x64" ], @@ -415,9 +415,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", "cpu": [ "arm64" ], @@ -432,9 +432,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "cpu": [ "x64" ], @@ -449,9 +449,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "cpu": [ "arm64" ], @@ -466,9 +466,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "cpu": [ "ia32" ], @@ -483,9 +483,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "cpu": [ "x64" ], @@ -553,14 +553,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.2" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -572,9 +572,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "dev": true, "license": "MIT", "funding": { @@ -582,13 +582,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", - "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.61.0" + "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -598,9 +598,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "cpu": [ "arm64" ], @@ -615,9 +615,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "cpu": [ "arm64" ], @@ -632,9 +632,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "cpu": [ "x64" ], @@ -649,9 +649,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "cpu": [ "x64" ], @@ -666,9 +666,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "cpu": [ "arm" ], @@ -683,9 +683,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "cpu": [ "arm64" ], @@ -703,9 +703,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "cpu": [ "arm64" ], @@ -723,9 +723,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "cpu": [ "ppc64" ], @@ -743,9 +743,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "cpu": [ "s390x" ], @@ -763,9 +763,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "cpu": [ "x64" ], @@ -783,9 +783,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "cpu": [ "x64" ], @@ -803,9 +803,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "cpu": [ "arm64" ], @@ -820,9 +820,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", "cpu": [ "wasm32" ], @@ -830,18 +830,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ "arm64" ], @@ -856,9 +856,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "cpu": [ "x64" ], @@ -1286,9 +1286,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -1349,16 +1349,16 @@ "license": "MIT" }, "node_modules/@vitest/expect": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", - "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1367,13 +1367,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", - "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.9", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1394,9 +1394,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", - "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1407,13 +1407,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", - "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.9", + "@vitest/utils": "4.1.10", "pathe": "^2.0.3" }, "funding": { @@ -1421,14 +1421,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", - "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1437,9 +1437,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", - "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", "funding": { @@ -1447,13 +1447,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", - "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", + "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1656,9 +1656,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1669,32 +1669,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, "node_modules/estree-walker": { @@ -1785,9 +1785,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -1801,23 +1801,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -1836,9 +1836,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -1857,9 +1857,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -1878,9 +1878,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -1899,9 +1899,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -1920,9 +1920,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], @@ -1944,9 +1944,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], @@ -1968,9 +1968,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], @@ -1992,9 +1992,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], @@ -2016,9 +2016,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -2037,9 +2037,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -2078,9 +2078,9 @@ "license": "MIT" }, "node_modules/linkify-it": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", - "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", "dev": true, "funding": [ { @@ -2125,9 +2125,9 @@ } }, "node_modules/markdown-it": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", - "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", "dev": true, "funding": [ { @@ -2142,8 +2142,8 @@ "license": "MIT", "dependencies": { "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" @@ -2153,9 +2153,9 @@ } }, "node_modules/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", "dev": true, "license": "MIT" }, @@ -2208,9 +2208,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -2265,9 +2265,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -2300,13 +2300,13 @@ } }, "node_modules/playwright": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", - "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.61.0" + "playwright-core": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -2319,9 +2319,9 @@ } }, "node_modules/playwright-core": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", - "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2347,9 +2347,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -2367,7 +2367,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2453,13 +2453,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -2469,21 +2469,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, "node_modules/rollup": { @@ -2738,9 +2738,9 @@ } }, "node_modules/tsx": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", - "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3241,17 +3241,17 @@ } }, "node_modules/typedoc": { - "version": "0.28.19", - "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.19.tgz", - "integrity": "sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw==", + "version": "0.28.20", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.20.tgz", + "integrity": "sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg==", "dev": true, "license": "Apache-2.0", "dependencies": { "@gerrit0/mini-shiki": "^3.23.0", "lunr": "^2.3.9", - "markdown-it": "^14.1.1", + "markdown-it": "^14.3.0", "minimatch": "^10.2.5", - "yaml": "^2.8.3" + "yaml": "^2.9.0" }, "bin": { "typedoc": "bin/typedoc" @@ -3300,16 +3300,16 @@ "license": "MIT" }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "bin": { @@ -3326,7 +3326,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -3378,19 +3378,19 @@ } }, "node_modules/vitest": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", - "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.9", - "@vitest/mocker": "4.1.9", - "@vitest/pretty-format": "4.1.9", - "@vitest/runner": "4.1.9", - "@vitest/snapshot": "4.1.9", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -3418,12 +3418,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.9", - "@vitest/browser-preview": "4.1.9", - "@vitest/browser-webdriverio": "4.1.9", - "@vitest/coverage-istanbul": "4.1.9", - "@vitest/coverage-v8": "4.1.9", - "@vitest/ui": "4.1.9", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -3495,9 +3495,9 @@ } }, "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "dev": true, "license": "ISC", "bin": { diff --git a/host/package.json b/host/package.json index 6287953a09..b22de76454 100644 --- a/host/package.json +++ b/host/package.json @@ -120,14 +120,14 @@ "prepack": "npm run build && bash ../scripts/prepare-host-package.sh" }, "devDependencies": { - "@playwright/test": "^1.61.0", + "@playwright/test": "^1.61.1", "@types/node": "^25.9.3", "esbuild": "^0.27.0", "tsup": "^8.0.0", - "tsx": "^4.22.4", - "typedoc": "^0.28.19", + "tsx": "^4.23.1", + "typedoc": "^0.28.20", "typescript": "^6.0.3", - "vitest": "^4.1.9" + "vitest": "^4.1.10" }, "dependencies": { "fflate": "^0.8.3", diff --git a/package-lock.json b/package-lock.json index bfac3c020f..10ae04d3c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,14 +8,14 @@ "dependencies": { "fflate": "^0.8.3", "fzstd": "^0.1.1", - "vite": "^8.0.3" + "vite": "^8.1.5" }, "devDependencies": { "@babel/parser": "^7.29.7", - "@playwright/test": "^1.61.0", + "@playwright/test": "^1.61.1", "esbuild": "^0.28.1", "playwright": "^1.59.1", - "tsx": "^4.22.4", + "tsx": "^4.23.1", "vitepress": "^1.6.4" } }, @@ -379,20 +379,20 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "license": "MIT", "optional": true, "dependencies": { @@ -400,9 +400,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "license": "MIT", "optional": true, "dependencies": { @@ -850,13 +850,13 @@ "license": "MIT" }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.2" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -868,22 +868,22 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" } }, "node_modules/@playwright/test": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", - "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.61.0" + "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -893,9 +893,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "cpu": [ "arm64" ], @@ -909,9 +909,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "cpu": [ "arm64" ], @@ -925,9 +925,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "cpu": [ "x64" ], @@ -941,9 +941,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "cpu": [ "x64" ], @@ -957,9 +957,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "cpu": [ "arm" ], @@ -973,9 +973,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "cpu": [ "arm64" ], @@ -992,9 +992,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "cpu": [ "arm64" ], @@ -1011,9 +1011,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "cpu": [ "ppc64" ], @@ -1030,9 +1030,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "cpu": [ "s390x" ], @@ -1049,9 +1049,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "cpu": [ "x64" ], @@ -1068,9 +1068,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "cpu": [ "x64" ], @@ -1087,9 +1087,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "cpu": [ "arm64" ], @@ -1103,27 +1103,27 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", "cpu": [ "wasm32" ], "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ "arm64" ], @@ -1137,9 +1137,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "cpu": [ "x64" ], @@ -1635,9 +1635,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "license": "MIT", "optional": true, "dependencies": { @@ -2693,9 +2693,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", @@ -2736,9 +2736,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", "engines": { "node": ">=12" @@ -2748,13 +2748,13 @@ } }, "node_modules/playwright": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", - "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.61.0" + "playwright-core": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -2767,9 +2767,9 @@ } }, "node_modules/playwright-core": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", - "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2780,9 +2780,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "funding": [ { "type": "opencollective", @@ -2799,7 +2799,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2864,12 +2864,12 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -2879,21 +2879,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, "node_modules/rollup": { @@ -3066,9 +3066,9 @@ "optional": true }, "node_modules/tsx": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", - "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3202,15 +3202,15 @@ } }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "bin": { @@ -3227,7 +3227,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/package.json b/package.json index c9357b57e0..c7d4534d9b 100644 --- a/package.json +++ b/package.json @@ -19,15 +19,15 @@ }, "devDependencies": { "@babel/parser": "^7.29.7", - "@playwright/test": "^1.61.0", + "@playwright/test": "^1.61.1", "esbuild": "^0.28.1", "playwright": "^1.59.1", - "tsx": "^4.22.4", + "tsx": "^4.23.1", "vitepress": "^1.6.4" }, "dependencies": { "fflate": "^0.8.3", "fzstd": "^0.1.1", - "vite": "^8.0.3" + "vite": "^8.1.5" } } diff --git a/packages/registry/openssl/package-lock.json b/packages/registry/openssl/package-lock.json index fc61e71859..4486f2d168 100644 --- a/packages/registry/openssl/package-lock.json +++ b/packages/registry/openssl/package-lock.json @@ -8,25 +8,25 @@ "name": "wasm-posix-openssl-example", "version": "0.0.1", "devDependencies": { - "vitest": "^4.1.9" + "vitest": "^4.1.10" } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -35,9 +35,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -53,14 +53,14 @@ "license": "MIT" }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.2" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -72,9 +72,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "dev": true, "license": "MIT", "funding": { @@ -82,9 +82,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "cpu": [ "arm64" ], @@ -99,9 +99,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "cpu": [ "arm64" ], @@ -116,9 +116,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "cpu": [ "x64" ], @@ -133,9 +133,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "cpu": [ "x64" ], @@ -150,9 +150,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "cpu": [ "arm" ], @@ -167,9 +167,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "cpu": [ "arm64" ], @@ -187,9 +187,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "cpu": [ "arm64" ], @@ -207,9 +207,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "cpu": [ "ppc64" ], @@ -227,9 +227,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "cpu": [ "s390x" ], @@ -247,9 +247,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "cpu": [ "x64" ], @@ -267,9 +267,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "cpu": [ "x64" ], @@ -287,9 +287,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "cpu": [ "arm64" ], @@ -304,9 +304,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", "cpu": [ "wasm32" ], @@ -314,18 +314,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ "arm64" ], @@ -340,9 +340,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "cpu": [ "x64" ], @@ -371,9 +371,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -407,16 +407,16 @@ "license": "MIT" }, "node_modules/@vitest/expect": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", - "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -425,13 +425,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", - "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.9", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -452,9 +452,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", - "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -465,13 +465,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", - "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.9", + "@vitest/utils": "4.1.10", "pathe": "^2.0.3" }, "funding": { @@ -479,14 +479,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", - "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -495,9 +495,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", - "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", "funding": { @@ -505,13 +505,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", - "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", + "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -617,9 +617,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -633,23 +633,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -668,9 +668,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -689,9 +689,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -710,9 +710,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -731,9 +731,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -752,9 +752,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], @@ -776,9 +776,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], @@ -800,9 +800,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], @@ -824,9 +824,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], @@ -848,9 +848,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -869,9 +869,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -900,9 +900,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -947,9 +947,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -960,9 +960,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -980,7 +980,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -989,13 +989,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -1005,21 +1005,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, "node_modules/siginfo": { @@ -1106,16 +1106,16 @@ "optional": true }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "bin": { @@ -1132,7 +1132,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -1184,19 +1184,19 @@ } }, "node_modules/vitest": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", - "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.9", - "@vitest/mocker": "4.1.9", - "@vitest/pretty-format": "4.1.9", - "@vitest/runner": "4.1.9", - "@vitest/snapshot": "4.1.9", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -1224,12 +1224,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.9", - "@vitest/browser-preview": "4.1.9", - "@vitest/browser-webdriverio": "4.1.9", - "@vitest/coverage-istanbul": "4.1.9", - "@vitest/coverage-v8": "4.1.9", - "@vitest/ui": "4.1.9", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/packages/registry/openssl/package.json b/packages/registry/openssl/package.json index 3325b679bc..fb007b4f28 100644 --- a/packages/registry/openssl/package.json +++ b/packages/registry/openssl/package.json @@ -8,6 +8,6 @@ "test": "vitest run" }, "devDependencies": { - "vitest": "^4.1.9" + "vitest": "^4.1.10" } } diff --git a/packages/registry/wordpress/demo/package-lock.json b/packages/registry/wordpress/demo/package-lock.json index bc708bef49..199d20800e 100644 --- a/packages/registry/wordpress/demo/package-lock.json +++ b/packages/registry/wordpress/demo/package-lock.json @@ -6,8 +6,8 @@ "": { "name": "kandelo-wordpress-demo", "dependencies": { - "@playwright/test": "^1.61.0", - "tsx": "^4.22.4" + "@playwright/test": "^1.61.1", + "tsx": "^4.23.1" } }, "node_modules/@esbuild/aix-ppc64": { @@ -427,12 +427,12 @@ } }, "node_modules/@playwright/test": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", - "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", "license": "Apache-2.0", "dependencies": { - "playwright": "1.61.0" + "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -497,12 +497,12 @@ } }, "node_modules/playwright": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", - "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.61.0" + "playwright-core": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -515,9 +515,9 @@ } }, "node_modules/playwright-core": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", - "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -527,9 +527,9 @@ } }, "node_modules/tsx": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", - "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", "license": "MIT", "dependencies": { "esbuild": "~0.28.0" diff --git a/packages/registry/wordpress/demo/package.json b/packages/registry/wordpress/demo/package.json index 3d9eaf0cb3..ceae70ec90 100644 --- a/packages/registry/wordpress/demo/package.json +++ b/packages/registry/wordpress/demo/package.json @@ -7,7 +7,7 @@ "serve": "npx tsx serve.ts" }, "dependencies": { - "@playwright/test": "^1.61.0", - "tsx": "^4.22.4" + "@playwright/test": "^1.61.1", + "tsx": "^4.23.1" } } diff --git a/sdk/package-lock.json b/sdk/package-lock.json index 7dd35469c4..d8d44c07c9 100644 --- a/sdk/package-lock.json +++ b/sdk/package-lock.json @@ -28,25 +28,25 @@ }, "devDependencies": { "@types/node": "^25.9.3", - "vitest": "^4.1.9" + "vitest": "^4.1.10" } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -55,9 +55,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -73,14 +73,14 @@ "license": "MIT" }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.2" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -92,9 +92,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "dev": true, "license": "MIT", "funding": { @@ -102,9 +102,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "cpu": [ "arm64" ], @@ -119,9 +119,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "cpu": [ "arm64" ], @@ -136,9 +136,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "cpu": [ "x64" ], @@ -153,9 +153,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "cpu": [ "x64" ], @@ -170,9 +170,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "cpu": [ "arm" ], @@ -187,9 +187,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "cpu": [ "arm64" ], @@ -207,9 +207,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "cpu": [ "arm64" ], @@ -227,9 +227,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "cpu": [ "ppc64" ], @@ -247,9 +247,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "cpu": [ "s390x" ], @@ -267,9 +267,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "cpu": [ "x64" ], @@ -287,9 +287,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "cpu": [ "x64" ], @@ -307,9 +307,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "cpu": [ "arm64" ], @@ -324,9 +324,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", "cpu": [ "wasm32" ], @@ -334,18 +334,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ "arm64" ], @@ -360,9 +360,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "cpu": [ "x64" ], @@ -391,9 +391,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -437,16 +437,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", - "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -455,13 +455,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", - "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.9", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -482,9 +482,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", - "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -495,13 +495,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", - "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.9", + "@vitest/utils": "4.1.10", "pathe": "^2.0.3" }, "funding": { @@ -509,14 +509,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", - "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -525,9 +525,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", - "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", "funding": { @@ -535,13 +535,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", - "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", + "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -647,9 +647,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -663,23 +663,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -698,9 +698,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -719,9 +719,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -740,9 +740,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -761,9 +761,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -782,9 +782,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], @@ -806,9 +806,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], @@ -830,9 +830,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], @@ -854,9 +854,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], @@ -878,9 +878,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -899,9 +899,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -930,9 +930,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -977,9 +977,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -990,9 +990,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -1010,7 +1010,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1019,13 +1019,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -1035,21 +1035,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, "node_modules/siginfo": { @@ -1143,16 +1143,16 @@ "license": "MIT" }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "bin": { @@ -1169,7 +1169,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -1221,19 +1221,19 @@ } }, "node_modules/vitest": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", - "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.9", - "@vitest/mocker": "4.1.9", - "@vitest/pretty-format": "4.1.9", - "@vitest/runner": "4.1.9", - "@vitest/snapshot": "4.1.9", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -1261,12 +1261,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.9", - "@vitest/browser-preview": "4.1.9", - "@vitest/browser-webdriverio": "4.1.9", - "@vitest/coverage-istanbul": "4.1.9", - "@vitest/coverage-v8": "4.1.9", - "@vitest/ui": "4.1.9", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/sdk/package.json b/sdk/package.json index 054bbed789..2914e600e6 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -36,6 +36,6 @@ }, "devDependencies": { "@types/node": "^25.9.3", - "vitest": "^4.1.9" + "vitest": "^4.1.10" } } From a961a45f14586a8a5f707162b52e7fb258a67ec7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:46:51 +0000 Subject: [PATCH 41/82] Build: Adopt Node 26 type definitions Update Node type definitions across the host, SDK, and the libxml2, SQLite, and zlib build-test projects. Node 26 replaces the removed worker_threads TransferListItem export with Transferable. Narrow the host-neutral browser vocabulary only inside the Node adapter so shared and browser paths retain the same contract. Dependabot grouped TypeScript 7 into this update, but do not take it. The latest released TypeDoc, 0.28.20, declares support only through TypeScript 6.0.x, and npm rejects the TypeScript 7 combination during a normal install. Keep TypeScript 6 until the documentation tool supports the new compiler. Validation: - normal dependency installs in all five affected package roots - host typecheck and production build - 54 Node and browser worker-adapter tests - 113 SDK tests - all three package Vitest configurations compile with Node 26 types The three package test scripts contain no TypeScript tests and report no test files. Host TypeDoc no longer reports a Node 26 adapter error, but six pre-existing documentation errors remain; this is not a docs pass. Signed-off-by: dependabot[bot] (cherry picked from commit abdc9f21a2080dc20e61b029a83b656f7821365d) --- host/package-lock.json | 16 ++++++++-------- host/package.json | 2 +- host/src/worker-adapter.ts | 5 ++++- packages/registry/libxml2/package.json | 2 +- packages/registry/sqlite/package.json | 2 +- packages/registry/zlib/package.json | 2 +- sdk/package-lock.json | 16 ++++++++-------- sdk/package.json | 2 +- 8 files changed, 25 insertions(+), 22 deletions(-) diff --git a/host/package-lock.json b/host/package-lock.json index cf394a3c04..527013277b 100644 --- a/host/package-lock.json +++ b/host/package-lock.json @@ -14,7 +14,7 @@ }, "devDependencies": { "@playwright/test": "^1.61.1", - "@types/node": "^25.9.3", + "@types/node": "^26.1.1", "esbuild": "^0.27.0", "tsup": "^8.0.0", "tsx": "^4.23.1", @@ -1332,13 +1332,13 @@ } }, "node_modules/@types/node": { - "version": "25.9.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", - "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" + "undici-types": "~8.3.0" } }, "node_modules/@types/unist": { @@ -3293,9 +3293,9 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "dev": true, "license": "MIT" }, diff --git a/host/package.json b/host/package.json index b22de76454..10ffb89855 100644 --- a/host/package.json +++ b/host/package.json @@ -121,7 +121,7 @@ }, "devDependencies": { "@playwright/test": "^1.61.1", - "@types/node": "^25.9.3", + "@types/node": "^26.1.1", "esbuild": "^0.27.0", "tsup": "^8.0.0", "tsx": "^4.23.1", diff --git a/host/src/worker-adapter.ts b/host/src/worker-adapter.ts index 7fa68e28aa..bc9d7b1987 100644 --- a/host/src/worker-adapter.ts +++ b/host/src/worker-adapter.ts @@ -332,9 +332,12 @@ class NodeWorkerHandle implements WorkerHandle { postMessage(message: unknown, transfer?: Transferable[]): void { if (transfer) { + // WHY: WorkerHandle exposes the browser transfer vocabulary so shared + // callers use one host-neutral contract. Narrow it only at the Node + // adapter boundary, where worker_threads validates the actual values. this.worker.postMessage( message, - transfer as import("node:worker_threads").TransferListItem[], + transfer as import("node:worker_threads").Transferable[], ); } else { this.worker.postMessage(message); diff --git a/packages/registry/libxml2/package.json b/packages/registry/libxml2/package.json index 764d1ba603..4cd060f32e 100644 --- a/packages/registry/libxml2/package.json +++ b/packages/registry/libxml2/package.json @@ -8,7 +8,7 @@ "test": "vitest run" }, "devDependencies": { - "@types/node": "^25.5.0", + "@types/node": "^26.1.1", "typescript": "^6.0.3", "vitest": "^4.1.9" } diff --git a/packages/registry/sqlite/package.json b/packages/registry/sqlite/package.json index 3a0e6c74cd..409fc57225 100644 --- a/packages/registry/sqlite/package.json +++ b/packages/registry/sqlite/package.json @@ -8,7 +8,7 @@ "test": "vitest run" }, "devDependencies": { - "@types/node": "^25.5.0", + "@types/node": "^26.1.1", "typescript": "^6.0.3", "vitest": "^4.1.9" } diff --git a/packages/registry/zlib/package.json b/packages/registry/zlib/package.json index 2630071eb5..891e840cd8 100644 --- a/packages/registry/zlib/package.json +++ b/packages/registry/zlib/package.json @@ -8,7 +8,7 @@ "test": "vitest run" }, "devDependencies": { - "@types/node": "^25.5.0", + "@types/node": "^26.1.1", "typescript": "^6.0.3", "vitest": "^4.1.9" } diff --git a/sdk/package-lock.json b/sdk/package-lock.json index d8d44c07c9..af3ecb40cb 100644 --- a/sdk/package-lock.json +++ b/sdk/package-lock.json @@ -27,7 +27,7 @@ "wasm64posix-strip": "bin/wasm64posix-strip" }, "devDependencies": { - "@types/node": "^25.9.3", + "@types/node": "^26.1.1", "vitest": "^4.1.10" } }, @@ -427,13 +427,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.9.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", - "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" + "undici-types": "~8.3.0" } }, "node_modules/@vitest/expect": { @@ -1136,9 +1136,9 @@ "optional": true }, "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "dev": true, "license": "MIT" }, diff --git a/sdk/package.json b/sdk/package.json index 2914e600e6..b7aac0a038 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -35,7 +35,7 @@ "prepack": "bash ../scripts/prepare-sdk-package.sh" }, "devDependencies": { - "@types/node": "^25.9.3", + "@types/node": "^26.1.1", "vitest": "^4.1.10" } } From 2b0b0633ddb5d6aff99fd8b927bf1347b8425605 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Thu, 28 May 2026 13:56:23 +0100 Subject: [PATCH 42/82] Threads: Move clear-TID metadata into Rust Rust now consumes the authoritative ThreadState on exit and returns its CLONE_CHILD_CLEARTID pointer to the shared host runtime. The host keeps the process-memory clear and futex wake because it owns guest memory. Use an i64 export result so every wasm32 pointer remains nonnegative and negative values truthfully carry errno. This removes the duplicate TypeScript pointer map without weakening exact task or retry cleanup. Validation: - ABI snapshot and compatibility check - kernel wasm release build - 2 focused kernel thread-exit tests - host type and bundle builds - 86 clone, exec, entry-gate, and lifecycle tests - 3 focused process-retirement tests - 10 generated-ABI tests --- abi/snapshot.json | 2 +- crates/kernel/src/syscalls.rs | 34 ++++++---- crates/kernel/src/wasm_api.rs | 23 ++++--- .../2026-05-20-rust-owned-host-logic-plan.md | 4 +- host/src/kernel-worker.ts | 63 ++++++++----------- host/test/advisory-lock-retry.test.ts | 2 - host/test/clone-tid-authority.test.ts | 4 +- host/test/exec-state-tracking.test.ts | 6 -- host/test/kernel-clone-exit-entry.test.ts | 1 - host/test/multi-worker.test.ts | 18 +++--- host/test/process-wait-lifecycle.test.ts | 39 +++++++++--- host/test/support/kernel-scratch-instance.ts | 2 +- 12 files changed, 107 insertions(+), 91 deletions(-) diff --git a/abi/snapshot.json b/abi/snapshot.json index deb65dc1bf..030b650835 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -2911,7 +2911,7 @@ { "kind": "func", "name": "kernel_thread_exit", - "signature": "(i32,i32) -> (i32)" + "signature": "(i32,i32) -> (i64)" }, { "kind": "func", diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 0819aee7df..274ebd271e 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -3879,26 +3879,30 @@ pub(crate) fn release_blocking_retry_bindings_for_tid( } } -/// Consume every resource owned by one exiting task before removing its -/// process-table record. +/// Consume one exiting task and return its kernel-owned non-identity state. /// /// WHY: the host may still hold an immutable retry snapshot for this TID. /// Removing the thread first would make that snapshot unreachable while its -/// stable OFD/MQ/IPC target remained pinned for the process lifetime. -pub(crate) fn cleanup_exiting_thread( +/// stable OFD/MQ/IPC target remained pinned for the process lifetime. The host +/// must also clear and wake `CLONE_CHILD_CLEARTID` in process memory, but the +/// pointer itself belongs to the Rust ThreadInfo lifecycle. Returning the +/// consumed state keeps one authoritative owner without moving process-memory +/// mutation into kernel memory. +pub(crate) fn cleanup_exiting_thread_with_state( proc: &mut Process, locks: &mut AdvisoryLockManager, host: &mut dyn HostIO, tid: u32, -) -> Result<(), Errno> { +) -> Result { if proc.get_thread(tid).is_none() { return Err(Errno::ESRCH); } let release_result = release_blocking_retry_bindings_for_tid(proc, locks, host, tid); let owner = ((proc.pid as u64) << 32) | tid as u64; cancel_fifo_open_for_owner(proc, owner); - proc.remove_thread(tid).ok_or(Errno::ESRCH)?; - release_result + let thread = proc.remove_thread(tid).ok_or(Errno::ESRCH)?; + release_result?; + Ok(thread) } pub(crate) fn release_all_blocking_retry_bindings( @@ -19018,7 +19022,8 @@ mod tests { Err(Errno::EAGAIN), ); set_test_current_tid(0); - cleanup_exiting_thread(&mut opener, &mut locks, &mut host, worker_tid).unwrap(); + cleanup_exiting_thread_with_state(&mut opener, &mut locks, &mut host, worker_tid) + .unwrap(); let released_fd = opener.fd_table.reserve().unwrap(); assert_eq!(released_fd, 3); @@ -41887,7 +41892,8 @@ mod tests { fn thread_exit_consumes_its_blocked_retry_target() { let mut proc = Process::new(74); let tid = 75; - proc.add_thread(crate::process::ThreadInfo::new(tid, 0, 0, 0)); + let ctid_ptr = 0x2000; + proc.add_thread(crate::process::ThreadInfo::new(tid, ctid_ptr, 0, 0)); let mut host = MockHostIO::new(); let mut locks = AdvisoryLockManager::new(); let fd = sys_open(&mut proc, &mut host, b"/thread-blocked", O_RDONLY, 0).unwrap(); @@ -41898,13 +41904,15 @@ mod tests { assert!(proc.ofd_table.get(ofd_idx).is_some()); assert_eq!(proc.blocked_retries.binding_count(), 1); - cleanup_exiting_thread(&mut proc, &mut locks, &mut host, tid).unwrap(); + let thread = + cleanup_exiting_thread_with_state(&mut proc, &mut locks, &mut host, tid).unwrap(); + assert_eq!(thread.ctid_ptr, ctid_ptr); assert!(proc.ofd_table.get(ofd_idx).is_none()); assert_eq!(proc.blocked_retries.binding_count(), 0); assert!(proc.get_thread(tid).is_none()); - assert_eq!( - cleanup_exiting_thread(&mut proc, &mut locks, &mut host, tid), + assert!(matches!( + cleanup_exiting_thread_with_state(&mut proc, &mut locks, &mut host, tid), Err(Errno::ESRCH) - ); + )); } } diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index eb828398df..8d5faea93f 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -12528,14 +12528,20 @@ pub extern "C" fn kernel_get_robust_list(_pid: u32, _head_ptr: usize, _len_ptr: /// thread_exit — clean up thread state in the kernel. /// Called by the host when a thread Worker exits. -/// Removes the thread from the process's thread table. +/// Removes the thread from the process's thread table and returns the +/// CLONE_CHILD_CLEARTID pointer recorded in ThreadInfo, or 0 if no clear-tid +/// wake is needed. Errors are returned as negative errno values. +/// +/// WHY: an i64 keeps every wasm32 `usize` pointer nonnegative while reserving +/// negative results for errno; narrowing to i32 would make high pointers +/// indistinguishable from failures. #[unsafe(no_mangle)] -pub extern "C" fn kernel_thread_exit(pid: u32, tid: u32) -> i32 { +pub extern "C" fn kernel_thread_exit(pid: u32, tid: u32) -> i64 { let _gkl = GklGuard::acquire(); let pt = unsafe { &mut *PROCESS_TABLE.0.get() }; match kernel_thread_exit_in_table(pt, pid, tid) { - Ok(()) => 0, - Err(e) => -(e as i32), + Ok(ctid_ptr) => ctid_ptr as i64, + Err(e) => -(e as i64), } } @@ -12543,10 +12549,11 @@ fn kernel_thread_exit_in_table( pt: &mut crate::process_table::ProcessTable, pid: u32, tid: u32, -) -> Result<(), Errno> { +) -> Result { let (proc, locks) = pt.process_and_advisory_locks(pid).ok_or(Errno::ESRCH)?; let mut host = WasmHostIO; - syscalls::cleanup_exiting_thread(proc, locks, &mut host, tid) + syscalls::cleanup_exiting_thread_with_state(proc, locks, &mut host, tid) + .map(|thread| thread.ctid_ptr) } #[cfg(test)] @@ -12558,14 +12565,14 @@ mod thread_exit_tests { let mut pt = crate::process_table::ProcessTable::new(); let first = pt.create_process().unwrap(); let second = pt.create_process().unwrap(); - let tid = pt.create_thread(first, first, 0, 0, 0).unwrap(); + let tid = pt.create_thread(first, first, 0, 0, 0x2000).unwrap(); assert_eq!( kernel_thread_exit_in_table(&mut pt, second, tid), Err(Errno::ESRCH) ); assert!(pt.get(first).unwrap().get_thread(tid).is_some()); - assert_eq!(kernel_thread_exit_in_table(&mut pt, first, tid), Ok(())); + assert_eq!(kernel_thread_exit_in_table(&mut pt, first, tid), Ok(0x2000)); assert!(pt.get(first).unwrap().get_thread(tid).is_none()); assert_eq!( kernel_thread_exit_in_table(&mut pt, first, tid), diff --git a/docs/plans/2026-05-20-rust-owned-host-logic-plan.md b/docs/plans/2026-05-20-rust-owned-host-logic-plan.md index 4b271fcf80..30e9ed3153 100644 --- a/docs/plans/2026-05-20-rust-owned-host-logic-plan.md +++ b/docs/plans/2026-05-20-rust-owned-host-logic-plan.md @@ -101,7 +101,7 @@ Implement chunk 1 first. It removes hand-maintained TS constants from the host r ## Living Migration Backlog -Updated: 2026-05-24 +Updated: 2026-05-28 This section is the handoff list for follow-up work. Keep it current as each slice lands so the project does not lose track of what was intentionally left @@ -114,7 +114,7 @@ path. | Done / PR #534 | Rust-owned syscall marshalling descriptors | `crates/shared::host_abi` owns simple pointer-argument descriptors; `dump-abi` generates `SYSCALL_ARGS`; TS host keeps memory copies but reads generated descriptors. | The old TS `SYSCALL_ARGS` table and syscall-number size switches are gone. `poll`/`ppoll`, SysV message prefix, `semop`, and `msgrcv` copy-back adjustments are metadata fields. Nested-pointer syscalls (`readv`/`writev`/preadv/pwritev) stay on dedicated TS paths. | Shared unit tests for descriptor ordering/high-risk sizes/nested-pointer exclusion; xtask ABI tests; `bash scripts/check-abi-version.sh`; generated ABI vitest; host build; kernel lib tests. | | Done / PR #534 follow-up | Extended host-visible syscall numbers and names | Add Rust/shared metadata for ABI-visible syscall numbers still hardcoded in host TS but not currently in `shared::Syscall`, such as `getrandom`, `clone`, `futex`, `ppoll`, `pselect6`, epoll, `exit_group`, `waitid`, `msync`, preadv/pwritev, mqueue, SysV IPC, `sched_yield`, `fallocate`, timers, and `thread_cancel`. Generate TS bindings, logging names, and snapshot coverage. | Host TS no longer defines literal syscall numbers for this set, and syscall trace names are generated from Rust-owned metadata. Existing `HOST_INTERCEPTED_SYSCALLS` remains separate for fork/exec/spawn because those are caught before normal dispatch. Public behavior unchanged. | Rust metadata uniqueness tests; xtask compatibility tests; `bash scripts/check-abi-version.sh update` + check; generated ABI vitest; host build; kernel lib tests. | | Done / stacked PR | Rust-defined host adapter manifest | Add a compact Rust-defined manifest describing ABI version, required host adapter protocol version, required/optional exports, worker protocol features, and channel metadata. JS validates it during kernel boot. | Boot fails earlier with clear errors when the host/kernel contract is incompatible. No Worker creation or Wasm instantiation moves out of JS. | Rust manifest serialization tests; ABI snapshot check; vitest boot validation cases; Node/browser worker-entry smoke if boot code changes. | -| In progress / stacked PR | Process lifecycle cleanup consolidation | Rust `ProcessTable` now owns parent lookup, wait-target matching, wait-status derivation, host-crash zombie marking, and authorized child reaping. TS keeps only blocked waiter queues plus Worker/memory cleanup. Remaining audit: thread-channel lifecycle, host timer cancellation, TCP listener target policy, and shared-memory mapping cleanup. | Kernel owns process lifecycle invariants that do not require Worker identity; JS owns Worker termination, memory objects, crash observation, and platform callbacks. | ProcessTable unit tests; fork/exec/spawn/clone/wait tests; crash/trap tests; browser parity smoke when worker entries change. | +| In progress / stacked PR | Process lifecycle cleanup consolidation | Rust `ProcessTable` now owns parent lookup, wait-target matching, wait-status derivation, host-crash zombie marking, authorized child reaping, and thread-exit clear-tid metadata. TS keeps blocked waiter queues, Worker/memory cleanup, and the actual clear-tid memory write/futex wake because the ctid pointer names process memory. Remaining audit: thread channel/Worker allocation and free-list lifecycle, host timer cancellation, TCP listener target policy, and shared-memory mapping cleanup. | Kernel owns process lifecycle invariants that do not require Worker identity; JS owns Worker termination, memory objects, crash observation, and platform callbacks. | ProcessTable unit tests; fork/exec/spawn/clone/wait tests; crash/trap tests; browser parity smoke when worker entries change. | | Planned | IPC/resource cleanup in Rust | Move remaining pure SysV IPC and POSIX mqueue lifetime/cleanup state into Rust-owned process cleanup paths. | `remove_process()` owns IPC cleanup; JS only wakes or schedules blocked channels when host primitives are involved. | SysV IPC and mqueue Rust tests plus host integration/e2e coverage for blocking and cleanup. | | Planned | Readiness metadata improvements | Replace broad host inference with kernel-emitted readiness events for pipe/socket/poll/select cases where the kernel already knows state changes. | JS still owns timers/retry queues/`Atomics.waitAsync`, but readiness decisions are less inferred from syscall numbers. No extra Wasm round trip per syscall. | Pipe/socket/poll/select/ppoll/pselect tests; browser bridge smoke for affected wake paths; performance comparison before removing broad fallback logic. | | Planned | VFS policy split | Keep backend I/O, OPFS/IndexedDB/fetch, Node `fs`, and lazy archive materialization in JS. Move permission and policy decisions into Rust where process uid/gid/umask/fd context is authoritative. | Guest-visible policy is enforced in Rust; host adapters only perform platform operations requested through a checked contract. | VFS unit tests, uid/gid/permission tests, host-fs metadata tests, default mount tests, Node/browser parity tests. | diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index 20c8a3b578..dee13683d2 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -2525,7 +2525,6 @@ interface WaitableChildCapacityProbeTestOptions { interface ThreadTransportStateTestResult { readonly channelTidEntries: number; readonly forkContextEntries: number; - readonly clearTidEntries: number; readonly activeThreadChannels: number; } @@ -2942,8 +2941,6 @@ export class CentralizedKernelWorker { string, { pid: number; deadline: number } >(); - /** Maps "pid:tid" to ctidPtr for CLONE_CHILD_CLEARTID on thread exit */ - private threadCtidPtrs = new Map(); /** TCP listeners: "pid:fd" → { server, pid, port, connections } */ private tcpListeners = new Map(); /** TCP listener targets: port → listener aliases for round-robin dispatch. @@ -4354,10 +4351,11 @@ export class CentralizedKernelWorker { inputError?: TypeError; value?: ThreadTransportStateTestResult; } = {}; - // WHY: unregister cleanup has no public observer for its three + // WHY: unregister cleanup has no public observer for its two // host-only pthread ownership indexes. Return only their per-PID // aggregate counts so the test can prove retirement without learning - // or mutating any channel, continuation, clear-TID pointer, or Map. + // or mutating any channel, continuation, or Map. Rust owns clear-TID + // pointers, so they are intentionally absent from this host observer. this.#runImmediateKernelEntry( "pthread transport state lifecycle inspection", () => { @@ -4374,7 +4372,6 @@ export class CentralizedKernelWorker { const prefix = `${pid}:`; let channelTidEntries = 0; let forkContextEntries = 0; - let clearTidEntries = 0; let activeThreadChannels = 0; for (const [key, tid] of this.channelTids) { if (key.startsWith(prefix) && tid !== pid) { @@ -4384,9 +4381,6 @@ export class CentralizedKernelWorker { for (const key of this.threadForkContexts.keys()) { if (key.startsWith(prefix)) forkContextEntries++; } - for (const key of this.threadCtidPtrs.keys()) { - if (key.startsWith(prefix)) clearTidEntries++; - } for (const channel of this.activeChannels) { if (channel.pid !== pid) continue; const tid = this.channelTids.get( @@ -4399,7 +4393,6 @@ export class CentralizedKernelWorker { outcome.value = kernelEntryIntrinsicObjectFreeze({ channelTidEntries, forkContextEntries, - clearTidEntries, activeThreadChannels, }); return undefined; @@ -8611,8 +8604,8 @@ export class CentralizedKernelWorker { if (channel.pid === pid) this.activeChannelRequests.delete(channel); } - // Thread mailbox identity and fork/clear-TID metadata belong to the old - // image even though exec preserves the process id. + // Thread mailbox identity belongs to the old image even though exec + // preserves the process id. Rust retires its own clear-TID metadata. this.clearProcessThreadTransportState(pid); for (const [key, entry] of this.posixTimers) { @@ -8653,9 +8646,6 @@ export class CentralizedKernelWorker { for (const key of this.threadForkContexts.keys()) { if (key.startsWith(prefix)) this.threadForkContexts.delete(key); } - for (const key of this.threadCtidPtrs.keys()) { - if (key.startsWith(prefix)) this.threadCtidPtrs.delete(key); - } } /** Release the exec guard only after the outer worker generation is installed. */ @@ -22824,12 +22814,6 @@ export class CentralizedKernelWorker { const stackPtr = origArgs[1]; const tlsPtr = origArgs[3]; - // Register only the effective CLONE_CHILD_CLEARTID pointer before the - // Worker starts. A short-lived pthread can reach SYS_EXIT immediately. - if (ctidPtr !== 0) { - this.threadCtidPtrs.set(`${channel.pid}:${tid}`, ctidPtr); - } - const createdAttachment = createThreadChannelAttachment( this, channel.pid, @@ -23072,7 +23056,6 @@ export class CentralizedKernelWorker { } state.pendingAttachment!.attachedChannelOffset = undefined; } - this.threadCtidPtrs.delete(`${channel.pid}:${tid}`); if (state.parentTidWritten) { new DataView(channel.memory.buffer).setInt32(ptidPtr, 0, true); state.parentTidWritten = false; @@ -24572,17 +24555,16 @@ export class CentralizedKernelWorker { /** * Notify the kernel that a thread has exited. - * Removes thread state from the process's thread table. + * + * WHY: whole-process forced teardown retires the process memory, so it does + * not need to publish a clear-TID wake. A surviving process must instead use + * `finalizeThreadExit`, which clears and wakes the pointer returned by Rust. */ notifyThreadExit(pid: number, tid: number): void { this.#runOrDeferKernelEntry( "thread exit", (entry) => { - this.#notifyThreadExitWithinKernelEntry( - pid, - tid, - entry, - ); + this.#notifyThreadExitWithinKernelEntry(pid, tid, entry); return undefined; }, ); @@ -24592,16 +24574,16 @@ export class CentralizedKernelWorker { pid: number, tid: number, entry?: KernelWorkerEntryContext, - ): void { + ): number { const threadExit = this.#kernelInstanceForEntry(entry).exports .kernel_thread_exit as - ((pid: number, tid: number) => number) | undefined; + ((pid: number, tid: number) => bigint) | undefined; if (!threadExit) { throw new Error("Kernel missing required kernel_thread_exit export"); } const result = threadExit(pid, tid); - if (result !== 0) { - const errno = result < 0 ? -result : EIO; + if (result < 0n) { + const errno = Number(-result); throw new KernelTaskBindingError( pid, tid, @@ -24609,6 +24591,16 @@ export class CentralizedKernelWorker { `Kernel could not remove tid ${tid} from process ${pid}: errno ${errno}`, ); } + const ctidPtr = Number(result); + if (!Number.isSafeInteger(ctidPtr)) { + throw new KernelTaskBindingError( + pid, + tid, + EIO, + `Kernel returned an invalid clear-TID pointer for tid ${tid} in process ${pid}`, + ); + } + return ctidPtr; } /** @@ -24663,8 +24655,6 @@ export class CentralizedKernelWorker { channelOffset: number, entry: KernelWorkerEntryContext, ): void { - const ctidKey = `${pid}:${tid}`; - const ctidPtr = this.threadCtidPtrs.get(ctidKey); const channel = this.activeChannels.find( (ch) => ch.pid === pid && ch.channelOffset === channelOffset, ); @@ -24673,14 +24663,14 @@ export class CentralizedKernelWorker { // Remove authoritative ThreadInfo before any host-memory bookkeeping can // fail. The clone path prevalidates ctid, but this check also rejects stale // or externally-constructed registrations without stranding a kernel TID. - this.#notifyThreadExitWithinKernelEntry(pid, tid, entry); + const ctidPtr = this.#notifyThreadExitWithinKernelEntry(pid, tid, entry); if (channel) { // kernel_thread_exit consumed this TID's exact retry binding before it // removed task authority. Host retirement must not release it twice. this.#forgetBlockingRetrySnapshotAfterKernelLifecycle(channel); } try { - if (ctidPtr && ctidPtr !== 0) { + if (ctidPtr !== 0) { if (!memory) { throw new KernelTaskBindingError( pid, @@ -24704,7 +24694,6 @@ export class CentralizedKernelWorker { Atomics.notify(i32View, ctidPtr / 4, 1); } } finally { - this.threadCtidPtrs.delete(ctidKey); this.#removeChannelWithinKernelEntry(pid, channelOffset, entry); } } diff --git a/host/test/advisory-lock-retry.test.ts b/host/test/advisory-lock-retry.test.ts index 6596bcd6bd..8191935b72 100644 --- a/host/test/advisory-lock-retry.test.ts +++ b/host/test/advisory-lock-retry.test.ts @@ -291,7 +291,6 @@ describe("Rust-owned advisory-lock retry scheduling", () => { state.pendingFutexWaits = new Map(); state.pendingCancels = new Set(); state.threadForkContexts = new Map(); - state.threadCtidPtrs = new Map(); state.posixTimers = new Map(); state.socketTimeoutTimers = new Map(); @@ -621,7 +620,6 @@ interface MutableWorkerState { pendingPipeWriters: Map; pendingCancels: Set; threadForkContexts: Map; - threadCtidPtrs: Map; posixTimers: Map; socketTimeoutTimers: Map>; sharedMappings: Map>; diff --git a/host/test/clone-tid-authority.test.ts b/host/test/clone-tid-authority.test.ts index b16f9d1233..724302616c 100644 --- a/host/test/clone-tid-authority.test.ts +++ b/host/test/clone-tid-authority.test.ts @@ -108,7 +108,6 @@ function makeCloneHarness( memory, explicitMaxAddr: true, }]]), - threadCtidPtrs: new Map(), threadForkContexts: new Map(), usePolling: true, }); @@ -190,7 +189,6 @@ function makeChannelOwnershipHarness() { explicitMaxAddr: true, }], ]), - threadCtidPtrs: new Map(), threadForkContexts: new Map(), usePolling: true, }); @@ -342,7 +340,7 @@ describe("kernel TID authority", () => { await flushCloneContinuation(); expect(onClone.mock.calls[0][0]).toMatchObject({ ctidPtr: 0 }); - expect((harness.worker as any).threadCtidPtrs.size).toBe(0); + expect(harness.notifyThreadExit).not.toHaveBeenCalled(); }); it("rejects zero before a host callback can attach an unallocated task", () => { diff --git a/host/test/exec-state-tracking.test.ts b/host/test/exec-state-tracking.test.ts index 6c01ecf831..6c4d1a81bb 100644 --- a/host/test/exec-state-tracking.test.ts +++ b/host/test/exec-state-tracking.test.ts @@ -169,10 +169,6 @@ describe("exec host-state transition", () => { ["7:256", { fnPtr: 1, argPtr: 2 }], ["8:0", { fnPtr: 3, argPtr: 4 }], ]), - threadCtidPtrs: new Map([ - ["7:11", 0x1000], - ["8:8", 0x2000], - ]), }); const notify = vi.spyOn(Atomics, "notify"); const parkedMain = worker.parkedChannelCompletions.get(mainChannel); @@ -219,8 +215,6 @@ describe("exec host-state transition", () => { expect(worker.channelTids.get("8:0")).toBe(8); expect(worker.threadForkContexts.has("7:256")).toBe(false); expect(worker.threadForkContexts.has("8:0")).toBe(true); - expect(worker.threadCtidPtrs.has("7:11")).toBe(false); - expect(worker.threadCtidPtrs.get("8:8")).toBe(0x2000); expect(notify).toHaveBeenCalledWith( expect.any(Int32Array), 4, diff --git a/host/test/kernel-clone-exit-entry.test.ts b/host/test/kernel-clone-exit-entry.test.ts index 515cc3a70b..ffdae3faf9 100644 --- a/host/test/kernel-clone-exit-entry.test.ts +++ b/host/test/kernel-clone-exit-entry.test.ts @@ -151,7 +151,6 @@ function makeHarness( syscallTraceCap: 64, syscallTraceEnabled: false, syscallTraceRing: [], - threadCtidPtrs: new Map(), threadForkContexts: new Map(), usePolling: true, }); diff --git a/host/test/multi-worker.test.ts b/host/test/multi-worker.test.ts index a3d7acac26..3985e7acdf 100644 --- a/host/test/multi-worker.test.ts +++ b/host/test/multi-worker.test.ts @@ -925,7 +925,7 @@ describe("CentralizedKernelWorker Process Management", () => { view.setUint32(CH_ERRNO, 0, true); return 0; }); - const threadExit = vi.fn(() => 0); + const threadExit = vi.fn(() => 0n); harness = createGatedLifecycleHarness({ callbacks: { onClone, onThreadExit }, kernelExports: { @@ -997,7 +997,7 @@ describe("CentralizedKernelWorker Process Management", () => { view.setUint32(CH_ERRNO, 0, true); return 0; }); - const threadExit = vi.fn(() => 0); + const threadExit = vi.fn(() => 0n); harness = createGatedLifecycleHarness({ callbacks: { onClone }, kernelExports: { @@ -1045,7 +1045,7 @@ describe("CentralizedKernelWorker Process Management", () => { maximum: 4, shared: true, }); - const threadExit = vi.fn(() => 0); + const threadExit = vi.fn(() => 0n); const onThreadExit = vi.fn(); const harness = createGatedLifecycleHarness({ callbacks: { onThreadExit }, @@ -1217,7 +1217,9 @@ describe("CentralizedKernelWorker Process Management", () => { view.setUint32(CH_ERRNO, 0, true); return 0; }); - const threadExit = vi.fn(() => 0); + const threadExit = vi.fn() + .mockReturnValueOnce(BigInt(ctidPtr)) + .mockReturnValueOnce(0n); harness = createGatedLifecycleHarness({ callbacks: { onClone }, kernelExports: { @@ -1300,7 +1302,7 @@ describe("CentralizedKernelWorker Process Management", () => { view.setUint32(CH_ERRNO, 0, true); return 0; }); - const threadExit = vi.fn(() => 0); + const threadExit = vi.fn(() => BigInt(ctidPtr)); harness = createGatedLifecycleHarness({ callbacks: { onClone }, kernelExports: { @@ -1388,7 +1390,7 @@ describe("CentralizedKernelWorker Process Management", () => { view.setUint32(CH_ERRNO, 0, true); return 0; }); - const threadExit = vi.fn(() => 0); + const threadExit = vi.fn(() => BigInt(newCtidPtr)); harness = createGatedLifecycleHarness({ callbacks: { onClone }, kernelExports: { @@ -1837,7 +1839,6 @@ describe("CentralizedKernelWorker Process Management", () => { ).toEqual({ channelTidEntries: 1, forkContextEntries: 1, - clearTidEntries: 1, activeThreadChannels: 1, }); } @@ -1850,7 +1851,6 @@ describe("CentralizedKernelWorker Process Management", () => { ).toEqual({ channelTidEntries: 0, forkContextEntries: 0, - clearTidEntries: 0, activeThreadChannels: 0, }); expect( @@ -1859,7 +1859,6 @@ describe("CentralizedKernelWorker Process Management", () => { ).toEqual({ channelTidEntries: 1, forkContextEntries: 1, - clearTidEntries: 1, activeThreadChannels: 1, }); expect(kw.getProcessMemory(firstPid)).toBeUndefined(); @@ -1872,7 +1871,6 @@ describe("CentralizedKernelWorker Process Management", () => { ).toEqual({ channelTidEntries: 0, forkContextEntries: 0, - clearTidEntries: 0, activeThreadChannels: 0, }); expect(kw.getProcessMemory(secondPid)).toBeUndefined(); diff --git a/host/test/process-wait-lifecycle.test.ts b/host/test/process-wait-lifecycle.test.ts index d2c07a85fb..b07433103a 100644 --- a/host/test/process-wait-lifecycle.test.ts +++ b/host/test/process-wait-lifecycle.test.ts @@ -2416,10 +2416,6 @@ describe("Rust-owned process wait lifecycle", () => { [`${pid}:0`, { fnPtr: 1, argPtr: 2 }], [`${otherPid}:0`, { fnPtr: 3, argPtr: 4 }], ]); - worker.threadCtidPtrs = new Map([ - [`${pid}:1001`, 3000], - [`${otherPid}:2001`, 4000], - ]); worker.processes = new Map([ [pid, { channels: [channel], memory }], [otherPid, { channels: [otherChannel], memory: otherMemory }], @@ -2437,9 +2433,6 @@ describe("Rust-owned process wait lifecycle", () => { expect(Array.from(worker.threadForkContexts.entries())).toEqual([ [`${otherPid}:0`, { fnPtr: 3, argPtr: 4 }], ]); - expect(Array.from(worker.threadCtidPtrs.entries())).toEqual([ - [`${otherPid}:2001`, 4000], - ]); expect(worker.processes.has(pid)).toBe(false); expect(worker.activeChannels).toEqual([otherChannel]); }); @@ -2689,6 +2682,38 @@ describe("Rust-owned process wait lifecycle", () => { 10, ); }); + + it("thread exit uses Rust-owned ctid metadata for clear-tid wakeup", () => { + const memory = createSharedMemory(); + const ctidPtr = 2048; + new DataView(memory.buffer).setInt32(ctidPtr, 123, true); + + const mainChannel = createChannel(10, memory, 0); + const threadChannel = createChannel(10, memory, 1024); + const kernelThreadExit = vi.fn(() => BigInt(ctidPtr)); + const worker = createWorkerHarness({ + kernel_thread_exit: kernelThreadExit, + }); + worker.processes = new Map([ + [10, { + pid: 10, + memory, + channels: [mainChannel, threadChannel], + ptrWidth: 4, + }], + ]); + worker.activeChannels = [mainChannel, threadChannel]; + worker.channelTids = new Map([["10:1024", 77]]); + worker.threadForkContexts = new Map([["10:1024", { fnPtr: 1, argPtr: 2 }]]); + worker.finalizeThreadExit(10, 77, threadChannel.channelOffset); + + expect(kernelThreadExit).toHaveBeenCalledWith(10, 77); + expect(new DataView(memory.buffer).getInt32(ctidPtr, true)).toBe(0); + expect(worker.processes.get(10).channels).toEqual([mainChannel]); + expect(worker.activeChannels).toEqual([mainChannel]); + expect(worker.channelTids.has("10:1024")).toBe(false); + expect(worker.threadForkContexts.has("10:1024")).toBe(false); + }); }); function createWorkerHarness( diff --git a/host/test/support/kernel-scratch-instance.ts b/host/test/support/kernel-scratch-instance.ts index edbd5d0797..0c7f5ca7c1 100644 --- a/host/test/support/kernel-scratch-instance.ts +++ b/host/test/support/kernel-scratch-instance.ts @@ -435,7 +435,7 @@ function signatures( }, kernel_thread_exit: { parameters: [i32, i32], - result: i32, + result: i64, }, kernel_thread_has_deliverable: { parameters: [i32, i32], From d1c74b58da6dd26ecd7c5ae783e319d2746021b8 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Thu, 28 May 2026 14:07:10 +0100 Subject: [PATCH 43/82] IPC: Move shared-memory attachment identity into Rust Make the Rust kernel authoritative for System V shared-memory attachment identity and inheritance. Generate the ABI metadata consumed by the host, remove host-maintained attachment truth, and preserve exact detach and exec behavior across process transitions. Cover address-owned detach, inherited mappings, pointer widths, and shared-memory coherence through the production kernel entry path. --- abi/snapshot.json | 35 +++ crates/kernel/src/channel_scratch.rs | 4 +- crates/kernel/src/fork.rs | 6 + crates/kernel/src/process.rs | 97 ++++++ crates/kernel/src/process_table.rs | 7 + crates/kernel/src/syscalls.rs | 24 ++ crates/kernel/src/wasm_api.rs | 159 +++++++++- crates/shared/src/lib.rs | 5 + docs/abi-versioning.md | 25 ++ docs/architecture.md | 21 +- .../2026-05-20-rust-owned-host-logic-plan.md | 4 +- host/src/generated/abi.ts | 5 + host/src/kernel-worker.ts | 278 ++++++++++++++---- host/test/exec-state-tracking.test.ts | 4 +- host/test/kernel-ipc-shmat-entry.test.ts | 203 ++++++++++++- host/test/kernel-scratch-contract.test.ts | 22 +- ...el-shared-memory-inheritance-entry.test.ts | 73 ++++- host/test/shared-memory-coherence.test.ts | 145 ++++++++- host/test/support/kernel-scratch-instance.ts | 24 ++ 19 files changed, 1047 insertions(+), 94 deletions(-) diff --git a/abi/snapshot.json b/abi/snapshot.json index 030b650835..949518b981 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -1064,8 +1064,13 @@ "kernel_has_sa_nocldstop", "kernel_host_adapter_manifest_len", "kernel_host_adapter_manifest_ptr", + "kernel_ipc_shm_lookup_mapping_for_task", + "kernel_ipc_shm_record_mapping_for_process", + "kernel_ipc_shm_record_mapping_for_task", "kernel_ipc_shmat_for_process", "kernel_ipc_shmat_for_task", + "kernel_ipc_shmdt_addr_for_process", + "kernel_ipc_shmdt_addr_for_task", "kernel_ipc_shmdt_for_process", "kernel_ipc_shmdt_for_task", "kernel_is_fd_nonblock", @@ -2193,11 +2198,26 @@ "name": "kernel_ioctl", "signature": "(i32,i32,i32,i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_ipc_shm_lookup_mapping_for_task", + "signature": "(i32,i32,i32) -> (i64)" + }, { "kind": "func", "name": "kernel_ipc_shm_read_chunk", "signature": "(i32,i32,i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_ipc_shm_record_mapping_for_process", + "signature": "(i32,i32,i32,i32) -> (i32)" + }, + { + "kind": "func", + "name": "kernel_ipc_shm_record_mapping_for_task", + "signature": "(i32,i32,i32,i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_ipc_shm_write_chunk", @@ -2223,6 +2243,21 @@ "name": "kernel_ipc_shmdt", "signature": "(i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_ipc_shmdt_addr", + "signature": "(i32) -> (i32)" + }, + { + "kind": "func", + "name": "kernel_ipc_shmdt_addr_for_process", + "signature": "(i32,i32) -> (i32)" + }, + { + "kind": "func", + "name": "kernel_ipc_shmdt_addr_for_task", + "signature": "(i32,i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_ipc_shmdt_for_process", diff --git a/crates/kernel/src/channel_scratch.rs b/crates/kernel/src/channel_scratch.rs index 7571f20bfc..73afb85a65 100644 --- a/crates/kernel/src/channel_scratch.rs +++ b/crates/kernel/src/channel_scratch.rs @@ -1068,8 +1068,8 @@ mod tests { 1, ), ( - r#"let _shmaddr = conditional_process_address!(0); - kernel_ipc_shmdt(a1)"#, + r#"let shmaddr = conditional_process_address!(0); + kernel_ipc_shmdt_addr(shmaddr)"#, 1, ), ( diff --git a/crates/kernel/src/fork.rs b/crates/kernel/src/fork.rs index cbba2dbfd7..6327f03544 100644 --- a/crates/kernel/src/fork.rs +++ b/crates/kernel/src/fork.rs @@ -2058,6 +2058,7 @@ mod tests { fn test_roundtrip_default_process() { let mut proc = Process::new(1); proc.terminal.foreground_pgid = 313; + proc.record_shm_mapping(0x20000, 17, 4096).unwrap(); let mut buf = vec![0u8; 64 * 1024]; let written = serialize_fork_state(&proc, &mut buf).unwrap(); assert!(written > 12); @@ -2075,6 +2076,9 @@ mod tests { assert_eq!(child.signals.pending, 0); assert_eq!(child.main_thread_signals.pending, 0); assert_eq!(child.terminal.foreground_pgid, 313); + // The host fork transaction records child attachments only after the + // corresponding shmat and byte materialization have both succeeded. + assert!(child.shm_mappings.is_empty()); } #[test] @@ -2319,6 +2323,7 @@ mod tests { fn test_exec_roundtrip_default_process() { let mut proc = Process::new(1); proc.terminal.foreground_pgid = 919; + proc.record_shm_mapping(0x20000, 17, 4096).unwrap(); let mut buf = vec![0u8; 64 * 1024]; let written = serialize_exec_state(&proc, &mut buf).unwrap(); assert!(written > 12); @@ -2330,6 +2335,7 @@ mod tests { assert_eq!(restored.signals.pending, 0); assert_eq!(restored.main_thread_signals.pending, 0); assert_eq!(restored.terminal.foreground_pgid, 919); + assert!(restored.shm_mappings.is_empty()); } #[test] diff --git a/crates/kernel/src/process.rs b/crates/kernel/src/process.rs index 722c74aa7a..c59160c704 100644 --- a/crates/kernel/src/process.rs +++ b/crates/kernel/src/process.rs @@ -431,6 +431,19 @@ pub struct DriBoBinding { pub bo_id: crate::dri::BoId, } +/// Kernel-owned identity for one System V shared-memory attachment. +/// +/// The host retains byte-coherence snapshots because it owns guest Memory, +/// but attachment identity and lifetime belong to the process table. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ShmMapping { + pub addr: usize, + pub shmid: i32, + pub size: usize, +} + +const MAX_SYSV_SHM_MAPPINGS_PER_PROCESS: usize = 4096; + /// Read-only identity of a thread owned by [`crate::process_table::ProcessTable`]. /// /// [`ThreadInfo`] dereferences to this view so existing `thread.tid` reads stay @@ -806,6 +819,9 @@ pub struct Process { /// memory region with the bo currently bound there so `sys_munmap` /// can issue the matching [`HostIO::gbm_bo_unbind`]. pub dri_bindings: Vec, + /// SysV shared-memory attachments keyed by the process virtual address + /// returned from `shmat`. + pub shm_mappings: Vec, /// Counts how many times this process has called fork() (parent side, on success). /// Read-only from outside the kernel via `kernel_get_fork_count`. /// Used as a regression guardrail by the spawn test suite to confirm @@ -1077,6 +1093,7 @@ impl Process { has_exec: false, fb_binding: None, dri_bindings: Vec::new(), + shm_mappings: Vec::new(), fork_count: 0, } } @@ -1329,6 +1346,52 @@ impl Process { self.identity.threads.clear(); } + /// Record one SysV shared-memory attachment after the host commits mmap. + pub fn record_shm_mapping( + &mut self, + addr: usize, + shmid: i32, + size: usize, + ) -> Result<(), Errno> { + if addr == 0 || shmid < 0 || size == 0 { + return Err(Errno::EINVAL); + } + if size > i32::MAX as usize { + return Err(Errno::EOVERFLOW); + } + if let Some(mapping) = self.shm_mapping_at(addr) { + return if mapping.shmid == shmid && mapping.size == size { + Ok(()) + } else { + Err(Errno::EINVAL) + }; + } + if self.shm_mappings.len() >= MAX_SYSV_SHM_MAPPINGS_PER_PROCESS { + return Err(Errno::ENOMEM); + } + self.shm_mappings + .try_reserve_exact(1) + .map_err(|_| Errno::ENOMEM)?; + self.shm_mappings.push(ShmMapping { addr, shmid, size }); + Ok(()) + } + + /// Find a SysV shared-memory attachment by its process address. + pub fn shm_mapping_at(&self, addr: usize) -> Option { + self.shm_mappings.iter().copied().find(|m| m.addr == addr) + } + + /// Remove and return a SysV shared-memory attachment by its process address. + pub fn remove_shm_mapping(&mut self, addr: usize) -> Option { + let idx = self.shm_mappings.iter().position(|m| m.addr == addr)?; + Some(self.shm_mappings.swap_remove(idx)) + } + + /// Drain every SysV attachment owned by the discarded address space. + pub(crate) fn take_shm_mappings(&mut self) -> Vec { + core::mem::take(&mut self.shm_mappings) + } + /// True if `tid` names the process's main thread. The main thread's TID /// equals the process PID (Linux convention) and is not tracked in /// [`Process::threads`]; its blocked mask lives in [`Process::signals`] @@ -2526,6 +2589,40 @@ mod tests { assert_eq!(proc.terminal.foreground_pgid, 1); } + #[test] + fn shm_mapping_bookkeeping_is_keyed_by_process_addr() { + let mut proc = Process::new(1); + + proc.record_shm_mapping(0x20000, 7, 4096).unwrap(); + assert_eq!( + proc.shm_mapping_at(0x20000), + Some(ShmMapping { + addr: 0x20000, + shmid: 7, + size: 4096, + }) + ); + + assert_eq!( + proc.record_shm_mapping(0x20000, 8, 8192), + Err(Errno::EINVAL) + ); + assert_eq!( + proc.record_shm_mapping(0x30000, 8, i32::MAX as usize + 1), + Err(Errno::EOVERFLOW) + ); + assert_eq!(proc.shm_mappings.len(), 1); + assert_eq!( + proc.remove_shm_mapping(0x20000), + Some(ShmMapping { + addr: 0x20000, + shmid: 7, + size: 4096, + }) + ); + assert_eq!(proc.shm_mapping_at(0x20000), None); + } + #[test] fn spawn_child_basic_inherits_cwd_and_returns_pid() { use crate::process_table::ProcessTable; diff --git a/crates/kernel/src/process_table.rs b/crates/kernel/src/process_table.rs index 3bab10241f..6e1f5b40f4 100644 --- a/crates/kernel/src/process_table.rs +++ b/crates/kernel/src/process_table.rs @@ -757,6 +757,13 @@ impl ProcessTable { crate::wakeup::push_advisory_lock(); } + // Drop SysV shared-memory attachments that were still live when the + // process exited or was reaped. + let ipc = unsafe { crate::ipc::global_ipc_table() }; + for mapping in &proc.shm_mappings { + let _ = ipc.shmdt(mapping.shmid, pid); + } + if retain_limbo_leader && proc.pgid == pid && self.group_has_member(pid) { self.processes.insert(pid, Self::limbo_process_from(&proc)); } diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 274ebd271e..4c9222154d 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -865,6 +865,14 @@ fn commit_exec_state_impl( } release_exec_image_state(proc, host); + // SysV attachments describe the old address space, not the persistent + // process identity. The host preflight has already published dirty bytes; + // drain Rust's authoritative attachment records at the exec commit so a + // failed preflight keeps them and a successful exec cannot leak nattch. + let ipc = unsafe { crate::ipc::global_ipc_table() }; + for mapping in proc.take_shm_mappings() { + let _ = ipc.shmdt(mapping.shmid, proc.pid); + } proc.signals.reset_dispositions_for_exec(); // Kernel-backed process-shared primitives outlive the process address @@ -25411,6 +25419,22 @@ mod tests { sys_close(&mut proc, &mut host, reader).unwrap(); } + #[test] + fn exec_discards_sysv_attachment_identity_at_the_commit() { + let mut proc = Process::new(0x6eec_0043); + let mut host = MockHostIO::new(); + // No real segment can reach this id in the test run. The commit still + // proves that image-owned metadata is drained when detach reports a + // segment already removed by IPC_RMID. + proc.record_shm_mapping(0x20000, i32::MAX, 4096) + .unwrap(); + let pid = proc.pid; + + commit_exec_state(&mut proc, &mut host, pid).unwrap(); + + assert!(proc.shm_mappings.is_empty()); + } + #[test] fn exec_preserves_stopped_state_and_parent_visible_status_record() { use wasm_posix_shared::signal::SIGTSTP; diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index 8d5faea93f..e548dc1abc 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -4646,8 +4646,8 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr kernel_ipc_shmat(a1, a2, a3) } 346 => { - let _shmaddr = conditional_process_address!(0); - kernel_ipc_shmdt(a1) + let shmaddr = conditional_process_address!(0); + kernel_ipc_shmdt_addr(shmaddr) } 347 => { // SYS_SHMCTL: (shmid, cmd, buf_ptr) @@ -5977,7 +5977,13 @@ pub extern "C" fn kernel_ipc_shmat_for_process( }; let ipc = unsafe { crate::ipc::global_ipc_table() }; match ipc.shmat(shmid, pid, flags as u32, uid, gid) { - Ok(size) => size as i32, + Ok(size) => match i32::try_from(size) { + Ok(size) => size, + Err(_) => { + let _ = ipc.shmdt(shmid, pid); + -(Errno::EOVERFLOW as i32) + } + }, Err(e) => -(e as i32), } } @@ -5998,6 +6004,153 @@ pub extern "C" fn kernel_ipc_shmat_for_task( kernel_ipc_shmat_for_process(pid, shmid, shmaddr, flags) } +fn ipc_record_shm_mapping( + pid: u32, + addr: usize, + shmid: i32, + size: u32, + require_live_process: bool, +) -> Result<(), Errno> { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let proc = table.get_mut(pid).ok_or(Errno::ESRCH)?; + if proc.state == ProcessState::Limbo + || (require_live_process + && !matches!(proc.state, ProcessState::Running | ProcessState::Stopped)) + { + return Err(Errno::ESRCH); + } + proc.record_shm_mapping(addr, shmid, size as usize) +} + +/// Record one host-materialized attachment for a retained process. +/// +/// This process form is used while a fork child exists in Rust but has no +/// running guest task yet. The host byte mirror is not authoritative for +/// attachment identity or lifetime. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_ipc_shm_record_mapping_for_process( + pid: u32, + addr: usize, + shmid: i32, + size: u32, +) -> i32 { + let _gkl = GklGuard::acquire(); + match ipc_record_shm_mapping(pid, addr, shmid, size, true) { + Ok(()) => 0, + Err(e) => -(e as i32), + } +} + +/// Record one host-materialized attachment for an exact live calling task. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_ipc_shm_record_mapping_for_task( + pid: u32, + tid: u32, + addr: usize, + shmid: i32, + size: u32, +) -> i32 { + let _gkl = GklGuard::acquire(); + let table = unsafe { &*PROCESS_TABLE.0.get() }; + if let Err(e) = table.validate_task(pid, tid) { + return -(e as i32); + } + match ipc_record_shm_mapping(pid, addr, shmid, size, true) { + Ok(()) => 0, + Err(e) => -(e as i32), + } +} + +/// Look up an attachment owned by an exact live task. +/// +/// The nonnegative result packs the byte size in the upper 32 bits and the +/// shmid in the lower 32 bits. Sizes are capped when recorded so every valid +/// result remains distinguishable from a negative errno without borrowing the +/// shared scratch channel. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_ipc_shm_lookup_mapping_for_task( + pid: u32, + tid: u32, + addr: usize, +) -> i64 { + let _gkl = GklGuard::acquire(); + let table = unsafe { &*PROCESS_TABLE.0.get() }; + if let Err(e) = table.validate_task(pid, tid) { + return -(e as i64); + } + let mapping = match table.get(pid).and_then(|proc| proc.shm_mapping_at(addr)) { + Some(mapping) => mapping, + None => return -(Errno::EINVAL as i64), + }; + let size = match u32::try_from(mapping.size) { + Ok(size) if size <= i32::MAX as u32 => size, + _ => return -(Errno::EOVERFLOW as i64), + }; + ((size as i64) << 32) | i64::from(mapping.shmid as u32) +} + +fn ipc_shmdt_addr(pid: u32, addr: usize, require_live_process: bool) -> Result<(), Errno> { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let mapping = { + let proc = table.get(pid).ok_or(Errno::ESRCH)?; + if pid == crate::process_table::SYNTHETIC_INIT_PID + || proc.state == ProcessState::Limbo + || (require_live_process + && !matches!(proc.state, ProcessState::Running | ProcessState::Stopped)) + { + return Err(Errno::ESRCH); + } + proc.shm_mapping_at(addr).ok_or(Errno::EINVAL)? + }; + + // Remove metadata only after nattch was released. A failed detach leaves + // the exact record available for teardown or a truthful retry. + unsafe { crate::ipc::global_ipc_table() }.shmdt(mapping.shmid, pid)?; + match table.get_mut(pid).and_then(|proc| proc.remove_shm_mapping(addr)) { + Some(removed) if removed == mapping => Ok(()), + _ => Err(Errno::EIO), + } +} + +/// Detach the attachment at an exact address for a retained process. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_ipc_shmdt_addr_for_process(pid: u32, addr: usize) -> i32 { + let _gkl = GklGuard::acquire(); + match ipc_shmdt_addr(pid, addr, false) { + Ok(()) => 0, + Err(e) => -(e as i32), + } +} + +/// Detach the attachment at an exact address for a live calling task. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_ipc_shmdt_addr_for_task(pid: u32, tid: u32, addr: usize) -> i32 { + let _gkl = GklGuard::acquire(); + let table = unsafe { &*PROCESS_TABLE.0.get() }; + if let Err(e) = table.validate_task(pid, tid) { + return -(e as i32); + } + match ipc_shmdt_addr(pid, addr, true) { + Ok(()) => 0, + Err(e) => -(e as i32), + } +} + +/// Dispatch-bound shmdt wrapper used by the scalar syscall path. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_ipc_shmdt_addr(addr: usize) -> i32 { + let table = unsafe { &*PROCESS_TABLE.0.get() }; + let pid = table.current_pid(); + let tid = table.current_tid(); + if pid == 0 || tid == 0 || table.validate_task(pid, tid).is_err() { + return -(Errno::ESRCH as i32); + } + match ipc_shmdt_addr(pid, addr, true) { + Ok(()) => 0, + Err(e) => -(e as i32), + } +} + /// Detach from shared memory segment. /// Host should call kernel_ipc_shm_write_chunk first to sync data back. #[unsafe(no_mangle)] diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index c936714e6e..1d02223675 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -2894,8 +2894,13 @@ pub mod abi { "kernel_has_sa_nocldstop", "kernel_host_adapter_manifest_len", "kernel_host_adapter_manifest_ptr", + "kernel_ipc_shm_lookup_mapping_for_task", + "kernel_ipc_shm_record_mapping_for_process", + "kernel_ipc_shm_record_mapping_for_task", "kernel_ipc_shmat_for_process", "kernel_ipc_shmat_for_task", + "kernel_ipc_shmdt_addr_for_process", + "kernel_ipc_shmdt_addr_for_task", "kernel_ipc_shmdt_for_process", "kernel_ipc_shmdt_for_task", "kernel_is_fd_nonblock", diff --git a/docs/abi-versioning.md b/docs/abi-versioning.md index 636347606b..04f8615249 100644 --- a/docs/abi-versioning.md +++ b/docs/abi-versioning.md @@ -265,6 +265,31 @@ rollback, and teardown use the separate explicit-process `kernel_ipc_shmat_for_process` and `kernel_ipc_shmdt_for_process` exports. The former `kernel_set_current_pid` export is removed. +The pending ABI 43 contract additionally makes the Rust `Process` authoritative +for each System V shared-memory attachment's process address, segment id, and +size. After the host has materialized an attachment and its byte-coherence +mirror, it commits that identity through +`kernel_ipc_shm_record_mapping_for_task(pid, tid, addr, shmid, size)`. Fork +materialization uses the corresponding `for_process` form because the child +does not have a running guest task yet. `shmdt` first calls +`kernel_ipc_shm_lookup_mapping_for_task(pid, tid, addr)`, whose nonnegative +`i64` result packs the size in the upper 32 bits and shmid in the lower 32 +bits; negative values are negated errno values. After publishing dirty bytes, +the host calls `kernel_ipc_shmdt_addr_for_task`; lifecycle rollback and teardown +use `kernel_ipc_shmdt_addr_for_process`. The older segment-id detach export is +used only to roll back a `shmat` that acquired `nattch` but failed before an +address record could be committed. + +These address records are not serialized in the ordinary fork wire image. +The existing host inheritance transaction records each child attachment only +after `shmat` succeeds and rolls back by exact address before publishing child +bytes. Successful exec drains the records and decrements `nattch` in Rust at +the irreversible image commit; failed exec leaves both records and host byte +mirrors intact. Process removal provides the final Rust-owned cleanup path. +The host mirror remains necessary because separate WebAssembly memories do not +share physical bytes, but it no longer determines attachment identity or +lifetime. + The Rust kernel Wasm's obsolete direct `kernel_fork` export and its host-supplied `host_fork` and `host_clone` imports are also removed. Guest libc still imports `kernel_fork` from its process-worker adapter; that adapter routes diff --git a/docs/architecture.md b/docs/architecture.md index d70887d011..6b5f521582 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,9 +86,12 @@ kernel_commit_process_exit(status) → committed_low_8_bits kernel_dequeue_signal(pid, tid, out_ptr, out_capacity) → 0 | signum | -errno kernel_wait_child_poll(parent_pid, caller_tid, target_pid, event_mask, flags, out_ptr, out_capacity) → child_pid | 0 | -errno kernel_ipc_shmat_for_task(pid, tid, shmid, addr, flags) → segment_size | -errno -kernel_ipc_shmdt_for_task(pid, tid, shmid) → 0 | -errno +kernel_ipc_shm_record_mapping_for_task(pid, tid, addr, shmid, size) → 0 | -errno +kernel_ipc_shm_lookup_mapping_for_task(pid, tid, addr) → packed_size_and_shmid | -errno +kernel_ipc_shmdt_addr_for_task(pid, tid, addr) → 0 | -errno kernel_ipc_shmat_for_process(pid, shmid, addr, flags) → segment_size | -errno -kernel_ipc_shmdt_for_process(pid, shmid) → 0 | -errno +kernel_ipc_shm_record_mapping_for_process(pid, addr, shmid, size) → 0 | -errno +kernel_ipc_shmdt_addr_for_process(pid, addr) → 0 | -errno kernel_alloc_scratch(size) → kernel_owned_pointer | 0 kernel_transfer_scratch_begin(minimum_capacity) → reservation_token | -errno kernel_transfer_scratch_pointer(reservation_token) → kernel_owned_pointer | 0 @@ -943,8 +946,11 @@ exit trap from an older ABI 42 kernel, then applies the same authoritative `Exited` state check. The compatibility path does not treat a trap alone as successful exit. Signal dequeue, child-wait polling, write-limit preparation, and guest SysV -shared-memory attachment also carry the exact live caller TID explicitly; -lifecycle cleanup uses separately named process-level SysV exports. +shared-memory attachment also carry the exact live caller TID explicitly. +Rust owns each attachment's address, segment id, size, and lifetime; the shared +host retains versioned snapshots only to reconcile bytes between distinct +process memories. Fork-child materialization and lifecycle cleanup use +separately named process-level SysV exports. Fork and spawn carry the channel's caller TID to the kernel, which validates it as a live task belonging to the parent. That value selects caller-specific state; it is never a candidate child identity. Clone validates the bound caller @@ -1169,9 +1175,10 @@ remaining POSIX gap is tracked in [posix-status.md](posix-status.md) and behind surviving descriptors are never fork-cloned or reconstructed: socket queues, eventfd/epoll/timerfd/signalfd state, memfd contents, procfs snapshots, terminal input, and OFD identity therefore survive without - refcount churn. After that commit the host forgets the old mapping trackers - and detaches SysV segments. The calling pthread's signal mask and directed - queue become the process state; sibling workers terminate. + refcount churn. At that same commit Rust detaches the old address space's + SysV segments; the host then forgets its non-authoritative byte mirrors and + the other old mapping trackers. The calling pthread's signal mask and + directed queue become the process state; sibling workers terminate. `alarm()`/`ITIMER_REAL` survives, while `timer_create()` timers are deleted. The conformance gaps in [posix-status.md](posix-status.md) still apply, notably numeric-fd epoll tracking and main-thread-directed signal diff --git a/docs/plans/2026-05-20-rust-owned-host-logic-plan.md b/docs/plans/2026-05-20-rust-owned-host-logic-plan.md index 30e9ed3153..2c6041dd9e 100644 --- a/docs/plans/2026-05-20-rust-owned-host-logic-plan.md +++ b/docs/plans/2026-05-20-rust-owned-host-logic-plan.md @@ -114,8 +114,8 @@ path. | Done / PR #534 | Rust-owned syscall marshalling descriptors | `crates/shared::host_abi` owns simple pointer-argument descriptors; `dump-abi` generates `SYSCALL_ARGS`; TS host keeps memory copies but reads generated descriptors. | The old TS `SYSCALL_ARGS` table and syscall-number size switches are gone. `poll`/`ppoll`, SysV message prefix, `semop`, and `msgrcv` copy-back adjustments are metadata fields. Nested-pointer syscalls (`readv`/`writev`/preadv/pwritev) stay on dedicated TS paths. | Shared unit tests for descriptor ordering/high-risk sizes/nested-pointer exclusion; xtask ABI tests; `bash scripts/check-abi-version.sh`; generated ABI vitest; host build; kernel lib tests. | | Done / PR #534 follow-up | Extended host-visible syscall numbers and names | Add Rust/shared metadata for ABI-visible syscall numbers still hardcoded in host TS but not currently in `shared::Syscall`, such as `getrandom`, `clone`, `futex`, `ppoll`, `pselect6`, epoll, `exit_group`, `waitid`, `msync`, preadv/pwritev, mqueue, SysV IPC, `sched_yield`, `fallocate`, timers, and `thread_cancel`. Generate TS bindings, logging names, and snapshot coverage. | Host TS no longer defines literal syscall numbers for this set, and syscall trace names are generated from Rust-owned metadata. Existing `HOST_INTERCEPTED_SYSCALLS` remains separate for fork/exec/spawn because those are caught before normal dispatch. Public behavior unchanged. | Rust metadata uniqueness tests; xtask compatibility tests; `bash scripts/check-abi-version.sh update` + check; generated ABI vitest; host build; kernel lib tests. | | Done / stacked PR | Rust-defined host adapter manifest | Add a compact Rust-defined manifest describing ABI version, required host adapter protocol version, required/optional exports, worker protocol features, and channel metadata. JS validates it during kernel boot. | Boot fails earlier with clear errors when the host/kernel contract is incompatible. No Worker creation or Wasm instantiation moves out of JS. | Rust manifest serialization tests; ABI snapshot check; vitest boot validation cases; Node/browser worker-entry smoke if boot code changes. | -| In progress / stacked PR | Process lifecycle cleanup consolidation | Rust `ProcessTable` now owns parent lookup, wait-target matching, wait-status derivation, host-crash zombie marking, authorized child reaping, and thread-exit clear-tid metadata. TS keeps blocked waiter queues, Worker/memory cleanup, and the actual clear-tid memory write/futex wake because the ctid pointer names process memory. Remaining audit: thread channel/Worker allocation and free-list lifecycle, host timer cancellation, TCP listener target policy, and shared-memory mapping cleanup. | Kernel owns process lifecycle invariants that do not require Worker identity; JS owns Worker termination, memory objects, crash observation, and platform callbacks. | ProcessTable unit tests; fork/exec/spawn/clone/wait tests; crash/trap tests; browser parity smoke when worker entries change. | -| Planned | IPC/resource cleanup in Rust | Move remaining pure SysV IPC and POSIX mqueue lifetime/cleanup state into Rust-owned process cleanup paths. | `remove_process()` owns IPC cleanup; JS only wakes or schedules blocked channels when host primitives are involved. | SysV IPC and mqueue Rust tests plus host integration/e2e coverage for blocking and cleanup. | +| In progress / stacked PR | Process lifecycle cleanup consolidation | Rust `ProcessTable` now owns parent lookup, wait-target matching, wait-status derivation, host-crash zombie marking, authorized child reaping, thread-exit clear-tid metadata, and SysV shared-memory attachment metadata. TS keeps blocked waiter queues, Worker/memory cleanup, and process-memory writes/futex wakeups because those pointers name guest memory. Remaining audit: thread channel/Worker allocation and free-list lifecycle, host timer cancellation, and TCP listener target policy. | Kernel owns process lifecycle invariants that do not require Worker identity; JS owns Worker termination, memory objects, crash observation, and platform callbacks. | ProcessTable unit tests; fork/exec/spawn/clone/wait tests; crash/trap tests; browser parity smoke when worker entries change. | +| In progress / stacked PR | IPC/resource cleanup in Rust | Rust `Process` now records `shmat` address -> segment metadata, records child attachments during transactional fork materialization, clears them across exec setup, and detaches live mappings from `remove_process()`. TS still copies bytes between guest memory and kernel SysV segments because only the host can address guest `Memory`. | `remove_process()` owns IPC attachment cleanup; JS only handles guest-memory transfer and host primitive wake/schedule work. | SysV IPC and mqueue Rust tests plus host integration/e2e coverage for blocking and cleanup. | | Planned | Readiness metadata improvements | Replace broad host inference with kernel-emitted readiness events for pipe/socket/poll/select cases where the kernel already knows state changes. | JS still owns timers/retry queues/`Atomics.waitAsync`, but readiness decisions are less inferred from syscall numbers. No extra Wasm round trip per syscall. | Pipe/socket/poll/select/ppoll/pselect tests; browser bridge smoke for affected wake paths; performance comparison before removing broad fallback logic. | | Planned | VFS policy split | Keep backend I/O, OPFS/IndexedDB/fetch, Node `fs`, and lazy archive materialization in JS. Move permission and policy decisions into Rust where process uid/gid/umask/fd context is authoritative. | Guest-visible policy is enforced in Rust; host adapters only perform platform operations requested through a checked contract. | VFS unit tests, uid/gid/permission tests, host-fs metadata tests, default mount tests, Node/browser parity tests. | | Planned | Procfs/process snapshot schema metadata | Generate binary process snapshot schema/constants consumed by TS UI decoding, or replace TS decoding with a Rust-exported stable formatter if that does not add hot-path cost. | TS no longer hand-decodes undocumented offsets for kernel process snapshot data. Procfs text formatting remains Rust-owned. | Rust procfs/process snapshot tests; generated ABI vitest; UI/kernel-host tests that consume snapshots. | diff --git a/host/src/generated/abi.ts b/host/src/generated/abi.ts index 67b3591121..0b5fb82a94 100644 --- a/host/src/generated/abi.ts +++ b/host/src/generated/abi.ts @@ -519,8 +519,13 @@ export const HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS = [ "kernel_has_sa_nocldstop", "kernel_host_adapter_manifest_len", "kernel_host_adapter_manifest_ptr", + "kernel_ipc_shm_lookup_mapping_for_task", + "kernel_ipc_shm_record_mapping_for_process", + "kernel_ipc_shm_record_mapping_for_task", "kernel_ipc_shmat_for_process", "kernel_ipc_shmat_for_task", + "kernel_ipc_shmdt_addr_for_process", + "kernel_ipc_shmdt_addr_for_task", "kernel_ipc_shmdt_for_process", "kernel_ipc_shmdt_for_task", "kernel_is_fd_nonblock", diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index dee13683d2..cee285975a 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -3172,7 +3172,13 @@ export class CentralizedKernelWorker { * to convert epoll_pwait to poll without calling kernel_handle_channel * (which crashes in Chrome for epoll_pwait due to a suspected V8 bug). */ private epollInterests = new Map>(); - /** Per-process SysV shared-memory attachments. */ + /** + * Byte-coherence mirrors for Rust-owned SysV shared-memory attachments. + * + * WHY: separate WebAssembly memories cannot directly share segment bytes. + * Rust owns attachment identity and lifetime; the shared host still needs + * snapshots and versions to reconcile bytes across those memories. + */ private shmMappings = new Map>(); /** Authoritative segment version, incremented after each merged publication. */ private shmSegmentVersions = new Map(); @@ -8467,11 +8473,7 @@ export class CentralizedKernelWorker { } } - /** - * Forget mappings and detach SysV segments after the irreversible kernel - * exec commit. A failure here is post-commit and must be treated as fatal by - * the caller; returning to the discarded image is no longer possible. - */ + /** Forget host byte mirrors after Rust commits the exec address-space drop. */ finalizeAddressSpaceForExec(pid: number): number { if (this.#kernelFatalError !== null) throw this.#kernelFatalError; if (this.#kernelEntryGate.shouldDeferVoidIngress) { @@ -8504,23 +8506,11 @@ export class CentralizedKernelWorker { } this.invalidateSharedMmapFdCacheForPid(pid); - const sysv = this.shmMappings.get(pid); - if (!sysv) return 0; - const detach = this.#kernelInstanceForEntry(entry).exports.kernel_ipc_shmdt_for_process as - ((pid: number, shmid: number) => number) | undefined; - let result = 0; - try { - if (!detach) return -EIO; - for (const mapping of sysv.values()) { - if (detach(pid, mapping.segId) < 0) result = -EIO; - } - } catch (error) { - this.#rethrowKernelEntryFatal(error); - result = -EIO; - } finally { - this.shmMappings.delete(pid); - } - return result; + // kernelExecSetup is the irreversible Rust commit. It has already drained + // the authoritative attachment records and decremented nattch; repeating + // detach here would release a different same-segment attachment. + this.shmMappings.delete(pid); + return 0; } /** @@ -28049,41 +28039,111 @@ export class CentralizedKernelWorker { const kernelShmdt = this.#kernelInstanceForEntry(entry).exports .kernel_ipc_shmdt_for_process as ((pid: number, shmid: number) => number) | undefined; + const recordMapping = this.#kernelInstanceForEntry(entry).exports + .kernel_ipc_shm_record_mapping_for_process as + (( + pid: number, + addr: KernelPointer, + shmid: number, + size: number, + ) => number) | undefined; + const kernelShmdtAddr = this.#kernelInstanceForEntry(entry).exports + .kernel_ipc_shmdt_addr_for_process as + ((pid: number, addr: KernelPointer) => number) | undefined; if ( prepared.sysvMappings.length > 0 - && (!kernelShmat || !kernelShmdt) + && (!kernelShmat || !kernelShmdt || !recordMapping || !kernelShmdtAddr) ) { return new Error("Kernel lacks SysV SHM inheritance exports"); } - const attachedSegments: number[] = []; + let kernelMapAddrs: KernelPointer[]; + try { + // Validate the complete child set before the first shmat. A mixed-model + // guest address that the kernel usize cannot represent must not acquire + // an attachment that Rust is then unable to identify for rollback. + kernelMapAddrs = prepared.sysvMappings.map((mapping) => + this.toKernelPtr(mapping.mapAddr)); + } catch (cause) { + return new Error( + "Cannot represent inherited SysV mapping in the kernel address model", + { cause }, + ); + } + + const attachedMappings: Array<{ mapAddr: number; segId: number }> = []; const materializedSysv: MaterializedInheritedSysvMapping[] = []; - for (const mapping of prepared.sysvMappings) { + for (const [mappingIndex, mapping] of prepared.sysvMappings.entries()) { const result = kernelShmat!( prepared.childPid, mapping.segId, mapping.mapAddr, mapping.readOnly ? SHM_RDONLY : 0, ); - // Every non-negative return represents a completed attachment, even if - // an incompatible kernel reports an unexpected size. - if (Number.isSafeInteger(result) && result >= 0) { - attachedSegments.push(mapping.segId); - } if ( !Number.isSafeInteger(result) || result < 0 || result !== mapping.size ) { + // Every nonnegative shmat result already incremented nattch, even when + // an incompatible kernel reports an unexpected size. It has no Rust + // address record yet, so release this one by segment identity. + if (Number.isSafeInteger(result) && result >= 0) { + const detachResult = kernelShmdt!( + prepared.childPid, + mapping.segId, + ); + if (!Number.isSafeInteger(detachResult) || detachResult !== 0) { + throw new Error( + `SysV shmdt rollback failed for segment ${mapping.segId}`, + ); + } + } this.#rollbackInheritedSysvAttachmentsWithinKernelEntry( prepared.childPid, - attachedSegments, + attachedMappings, entry, ); return new Error( `SysV shmat inheritance failed for segment ${mapping.segId}`, ); } + const recordResult = recordMapping!( + prepared.childPid, + kernelMapAddrs[mappingIndex]!, + mapping.segId, + mapping.size, + ); + if (!Number.isSafeInteger(recordResult) || recordResult !== 0) { + if (Number.isSafeInteger(recordResult) && recordResult < 0) { + const detachResult = kernelShmdt!( + prepared.childPid, + mapping.segId, + ); + if (!Number.isSafeInteger(detachResult) || detachResult !== 0) { + throw new Error( + `SysV shmdt rollback failed for segment ${mapping.segId}`, + ); + } + this.#rollbackInheritedSysvAttachmentsWithinKernelEntry( + prepared.childPid, + attachedMappings, + entry, + ); + return new Error( + `Cannot record inherited SysV segment ${mapping.segId}`, + ); + } + // A positive or imprecise response violates the additive ABI's + // 0/-errno contract, so attachment state is no longer provable. + throw new Error( + `Invalid SysV mapping record result for segment ${mapping.segId}`, + ); + } + attachedMappings.push({ + mapAddr: mapping.mapAddr, + segId: mapping.segId, + }); const latest = this.readSysvShmRange( mapping.segId, 0, @@ -28093,7 +28153,7 @@ export class CentralizedKernelWorker { if (!latest) { this.#rollbackInheritedSysvAttachmentsWithinKernelEntry( prepared.childPid, - attachedSegments, + attachedMappings, entry, ); return new Error( @@ -28114,7 +28174,7 @@ export class CentralizedKernelWorker { if (postExportValidation !== null) { this.#rollbackInheritedSysvAttachmentsWithinKernelEntry( prepared.childPid, - attachedSegments, + attachedMappings, entry, ); return postExportValidation; @@ -28136,21 +28196,24 @@ export class CentralizedKernelWorker { #rollbackInheritedSysvAttachmentsWithinKernelEntry( childPid: number, - attachedSegments: readonly number[], + attachedMappings: readonly { mapAddr: number; segId: number }[], entry: KernelWorkerEntryContext, ): void { - const kernelShmdt = this.#kernelInstanceForEntry(entry).exports - .kernel_ipc_shmdt_for_process as - ((pid: number, shmid: number) => number) | undefined; - if (!kernelShmdt) { + const kernelShmdtAddr = this.#kernelInstanceForEntry(entry).exports + .kernel_ipc_shmdt_addr_for_process as + ((pid: number, addr: KernelPointer) => number) | undefined; + if (!kernelShmdtAddr) { throw new Error("Kernel lost required SysV SHM rollback export"); } - for (let index = attachedSegments.length - 1; index >= 0; index--) { - const segId = attachedSegments[index]!; - const result = kernelShmdt(childPid, segId); + for (let index = attachedMappings.length - 1; index >= 0; index--) { + const mapping = attachedMappings[index]!; + const result = kernelShmdtAddr( + childPid, + this.toKernelPtr(mapping.mapAddr), + ); if (!Number.isSafeInteger(result) || result < 0) { throw new Error( - `SysV shmdt rollback failed for inherited segment ${segId}`, + `SysV shmdt rollback failed for inherited segment ${mapping.segId}`, ); } } @@ -28276,10 +28339,19 @@ export class CentralizedKernelWorker { entry, ); } - const kernelShmdt = this.#kernelInstanceForEntry(entry).exports.kernel_ipc_shmdt_for_process as - ((pid: number, shmid: number) => number) | undefined; - if (kernelShmdt) { - for (const mapping of pidMap.values()) kernelShmdt(pid, mapping.segId); + const kernelShmdtAddr = this.#kernelInstanceForEntry(entry).exports + .kernel_ipc_shmdt_addr_for_process as + ((pid: number, addr: KernelPointer) => number) | undefined; + if (!kernelShmdtAddr) { + throw new Error("Kernel lacks address-owned SysV SHM teardown export"); + } + for (const [addr, mapping] of pidMap) { + const result = kernelShmdtAddr(pid, this.toKernelPtr(addr)); + if (!Number.isSafeInteger(result) || result !== 0) { + throw new Error( + `Cannot detach SysV segment ${mapping.segId} at ${addr} for pid=${pid}`, + ); + } } this.shmMappings.delete(pid); } @@ -31663,6 +31735,12 @@ export class CentralizedKernelWorker { return; } + // Validate the kernel-side identity before materializing either bytes or + // a host mirror. If a memory64 address cannot fit the kernel's pointer + // model, the catch path can still roll back the raw nattch and mmap + // without leaving non-authoritative state behind. + const kernelAllocatedAddr = this.toKernelPtr(allocatedAddr); + this.ensureProcessMemoryCovers( channel.pid, channel.memory, @@ -31702,6 +31780,18 @@ export class CentralizedKernelWorker { pidMappings = new Map(); this.shmMappings.set(channel.pid, pidMappings); } + if (pidMappings.has(allocatedAddr)) { + this.#rollbackIpcShmatWithinKernelEntry( + channel, + shmid, + size, + allocatedAddr, + entry, + ); + if (this.hostReaped.has(channel.pid)) return; + this.completeChannelRawAndRelisten(channel, -EIO, EIO, entry); + return; + } pidMappings.set(allocatedAddr, { segId: shmid, size, @@ -31709,6 +31799,41 @@ export class CentralizedKernelWorker { snapshot, seenVersion: this.shmSegmentVersions.get(shmid) ?? 0, }); + const recordMapping = this.#kernelInstanceForEntry(entry).exports + .kernel_ipc_shm_record_mapping_for_task as + (( + pid: number, + tid: number, + addr: KernelPointer, + shmid: number, + size: number, + ) => number) | undefined; + const recordResult = recordMapping + ? recordMapping( + channel.pid, + callerTid, + kernelAllocatedAddr, + shmid, + size, + ) + : -EIO; + if (!Number.isSafeInteger(recordResult) || recordResult !== 0) { + pidMappings.delete(allocatedAddr); + if (pidMappings.size === 0) this.shmMappings.delete(channel.pid); + this.#rollbackIpcShmatWithinKernelEntry( + channel, + shmid, + size, + allocatedAddr, + entry, + ); + if (this.hostReaped.has(channel.pid)) return; + const errno = Number.isSafeInteger(recordResult) && recordResult < 0 + ? -recordResult + : EIO; + this.completeChannelRawAndRelisten(channel, -errno, errno, entry); + return; + } } catch (err) { this.#rethrowKernelEntryFatal(err); if (err instanceof KernelIpcShmatRollbackError) throw err; @@ -31749,16 +31874,56 @@ export class CentralizedKernelWorker { this.#rejectScratchTransfer(channel, error, entry); return; } + let kernelAddr: KernelPointer; + try { + kernelAddr = this.toKernelPtr(addr); + } catch { + // A valid wasm64 pointer can still exceed the kernel's address model. + // No successful shmat can have recorded that address, so POSIX shmdt + // reports an invalid attachment without truncating to a low mapping. + this.completeChannelRawAndRelisten(channel, -EINVAL, EINVAL, entry); + return; + } const callerTid = this.guestTidForChannel(channel); this.validateKernelTid(channel.pid, callerTid, entry); + const lookupMapping = this.#kernelInstanceForEntry(entry).exports + .kernel_ipc_shm_lookup_mapping_for_task as + ((pid: number, tid: number, addr: KernelPointer) => bigint) | undefined; + if (!lookupMapping) { + this.completeChannelRawAndRelisten(channel, -EIO, EIO, entry); + return; + } + const packed = lookupMapping( + channel.pid, + callerTid, + kernelAddr, + ); + if (packed < 0n) { + const errno = Number(-packed); + this.completeChannelRawAndRelisten(channel, -errno, errno, entry); + return; + } + const kernelMapping = { + segId: Number(BigInt.asIntN(32, packed)), + size: Number((packed >> 32n) & 0xffff_ffffn), + }; const pidMappings = this.shmMappings.get(channel.pid); if (!pidMappings) { - this.completeChannelRawAndRelisten(channel, -22, 22, entry); // EINVAL + this.completeChannelRawAndRelisten(channel, -EIO, EIO, entry); return; } const mapping = pidMappings.get(addr); - if (!mapping) { - this.completeChannelRawAndRelisten(channel, -22, 22, entry); // EINVAL + // Rust is authoritative. A missing or divergent byte mirror means the + // host cannot publish the attachment safely, so retain the Rust record + // for teardown and report the internal coherence failure truthfully. + if ( + !mapping + || kernelMapping.segId < 0 + || kernelMapping.size <= 0 + || mapping.segId !== kernelMapping.segId + || mapping.size !== kernelMapping.size + ) { + this.completeChannelRawAndRelisten(channel, -EIO, EIO, entry); return; } @@ -31774,13 +31939,12 @@ export class CentralizedKernelWorker { return; } - const kernelShmdt = this.#kernelInstanceForEntry(entry).exports.kernel_ipc_shmdt_for_task as - (pid: number, tid: number, shmid: number) => number; - const result = kernelShmdt( - channel.pid, - callerTid, - mapping.segId, - ); + const kernelShmdt = this.#kernelInstanceForEntry(entry).exports + .kernel_ipc_shmdt_addr_for_task as + ((pid: number, tid: number, addr: KernelPointer) => number) | undefined; + const result = kernelShmdt + ? kernelShmdt(channel.pid, callerTid, kernelAddr) + : -EIO; if (result < 0) { this.completeChannelRawAndRelisten(channel, result, -result, entry); diff --git a/host/test/exec-state-tracking.test.ts b/host/test/exec-state-tracking.test.ts index 6c4d1a81bb..c39509ee6c 100644 --- a/host/test/exec-state-tracking.test.ts +++ b/host/test/exec-state-tracking.test.ts @@ -1074,7 +1074,7 @@ describe("exec host-state transition", () => { expect(worker.currentHandlePid).toBe(0); }); - it("copies SysV mappings before commit and detaches them afterward", () => { + it("copies SysV mappings before commit and forgets mirrors after Rust detaches", () => { const memory = new WebAssembly.Memory({ initial: 1 }); new Uint8Array(memory.buffer, 0x1000, 4).set([1, 2, 3, 4]); const kernelMemory = new WebAssembly.Memory({ initial: 2 }); @@ -1120,7 +1120,7 @@ describe("exec host-state transition", () => { expect(worker.shmMappings.has(7)).toBe(true); expect(worker.finalizeAddressSpaceForExec(7)).toBe(0); - expect(detach).toHaveBeenCalledWith(7, 3); + expect(detach).not.toHaveBeenCalled(); expect(worker.shmMappings.has(7)).toBe(false); }); diff --git a/host/test/kernel-ipc-shmat-entry.test.ts b/host/test/kernel-ipc-shmat-entry.test.ts index f1795d3d7f..098a13fea5 100644 --- a/host/test/kernel-ipc-shmat-entry.test.ts +++ b/host/test/kernel-ipc-shmat-entry.test.ts @@ -29,6 +29,8 @@ const KERNEL_EXPORT_NAMES = [ "kernel_get_memory_pages", "kernel_get_process_exit_signal", "kernel_handle_channel", + "kernel_ipc_shm_read_chunk", + "kernel_ipc_shm_record_mapping_for_task", "kernel_ipc_shmat_for_task", "kernel_ipc_shmdt_for_process", "kernel_set_current_tid", @@ -58,6 +60,7 @@ function makeHarness( worker: Record, kernelMemory: WebAssembly.Memory, ) => Record, + kernelPointerWidth: 4 | 8 = pointerWidth, ): { readonly worker: Record; readonly channel: TestChannel; @@ -86,10 +89,10 @@ function makeHarness( let worker!: Record; let mutableImplementations!: Record; const rawInstance = createKernelScratchTestInstance( - pointerWidth, + kernelPointerWidth, kernelMemory, () => mutableImplementations, - () => kernelPointer(pointerWidth, 4_096), + () => kernelPointer(kernelPointerWidth, 4_096), 4, KERNEL_EXPORT_NAMES, ); @@ -99,7 +102,7 @@ function makeHarness( gatedInstance.exports.kernel_alloc_scratch as (capacity: number) => number | bigint, CH_TOTAL_SIZE, - pointerWidth, + kernelPointerWidth, "IPC shmat entry test scratch", gatedInstance, ); @@ -171,6 +174,200 @@ function readResult(channel: TestChannel): { } describe("IPC shmat rollback entry authority", () => { + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s records Rust ownership only after bytes and the host mirror exist", + (_name, pointerWidth) => { + const address = 0x7_000; + const segment = new Uint8Array([3, 1, 4, 1]); + const recordMapping = vi.fn(() => 0); + const shmdt = vi.fn(() => 0); + const harness = makeHarness( + pointerWidth, + {}, + (_worker, kernelMemory) => ({ + kernel_drain_wakeup_events: () => 0, + kernel_get_memory_pages: () => 256, + kernel_get_process_exit_signal: () => 0, + kernel_handle_channel: (rawPointer: number | bigint) => { + const view = new DataView( + kernelMemory.buffer, + Number(rawPointer), + CH_TOTAL_SIZE, + ); + view.setBigInt64(CH_RETURN, BigInt(address), true); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }, + kernel_ipc_shm_read_chunk: ( + _shmid: number, + _offset: number, + outPointer: number | bigint, + length: number, + ) => { + new Uint8Array(kernelMemory.buffer).set( + segment.subarray(0, length), + Number(outPointer), + ); + return length; + }, + kernel_ipc_shm_record_mapping_for_task: recordMapping, + kernel_ipc_shmat_for_task: () => segment.byteLength, + kernel_ipc_shmdt_for_process: shmdt, + kernel_set_current_tid: () => 0, + kernel_validate_task: () => 0, + }), + ); + writeShmat(harness.channel, 17, address); + + harness.worker.handleSyscall(harness.channel); + + expect(recordMapping).toHaveBeenCalledExactlyOnceWith( + 41, + 41, + kernelPointer(pointerWidth, address), + 17, + segment.byteLength, + ); + expect(shmdt).not.toHaveBeenCalled(); + expect( + Array.from( + new Uint8Array( + harness.channel.memory.buffer, + address, + segment.byteLength, + ), + ), + ).toEqual(Array.from(segment)); + expect(harness.worker.shmMappings.get(41)?.get(address)).toMatchObject({ + segId: 17, + size: segment.byteLength, + }); + expect(readResult(harness.channel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + retVal: address, + errno: 0, + }); + }, + ); + + it("rolls back mmap, the byte mirror, and nattch when Rust rejects the record", () => { + const address = 0x7_000; + const segmentSize = 4; + const syscalls: number[] = []; + const shmdt = vi.fn(() => 0); + const harness = makeHarness(4, {}, (_worker, kernelMemory) => ({ + kernel_drain_wakeup_events: () => 0, + kernel_get_memory_pages: () => 256, + kernel_get_process_exit_signal: () => 0, + kernel_handle_channel: (rawPointer: number) => { + const view = new DataView( + kernelMemory.buffer, + rawPointer, + CH_TOTAL_SIZE, + ); + const syscall = view.getUint32(CH_SYSCALL, true); + syscalls.push(syscall); + view.setBigInt64( + CH_RETURN, + BigInt(syscall === ABI_SYSCALLS.Mmap ? address : 0), + true, + ); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }, + kernel_ipc_shm_read_chunk: ( + _shmid: number, + _offset: number, + outPointer: number, + length: number, + ) => { + new Uint8Array(kernelMemory.buffer, outPointer, length).fill(0x55); + return length; + }, + kernel_ipc_shm_record_mapping_for_task: () => -12, + kernel_ipc_shmat_for_task: () => segmentSize, + kernel_ipc_shmdt_for_process: shmdt, + kernel_set_current_tid: () => 0, + kernel_validate_task: () => 0, + })); + writeShmat(harness.channel, 19, address); + + harness.worker.handleSyscall(harness.channel); + + expect(syscalls).toEqual([ + ABI_SYSCALLS.Mmap, + ABI_SYSCALLS.Munmap, + ]); + expect(shmdt).toHaveBeenCalledExactlyOnceWith(41, 19); + expect(harness.worker.shmMappings.has(41)).toBe(false); + expect(readResult(harness.channel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + retVal: -12, + errno: 12, + }); + }); + + it("rolls back before creating a mirror when the kernel cannot name a wasm64 mapping", () => { + const address = 0x1_0000_7000; + const segmentSize = 4; + const syscalls: number[] = []; + const readChunk = vi.fn(() => segmentSize); + const recordMapping = vi.fn(() => 0); + const shmdt = vi.fn(() => 0); + const harness = makeHarness( + 8, + {}, + (_worker, kernelMemory) => ({ + kernel_drain_wakeup_events: () => 0, + kernel_get_memory_pages: () => 256, + kernel_get_process_exit_signal: () => 0, + kernel_handle_channel: (rawPointer: number) => { + const view = new DataView( + kernelMemory.buffer, + rawPointer, + CH_TOTAL_SIZE, + ); + const syscall = view.getUint32(CH_SYSCALL, true); + syscalls.push(syscall); + view.setBigInt64( + CH_RETURN, + BigInt(syscall === ABI_SYSCALLS.Mmap ? address : 0), + true, + ); + view.setUint32(CH_ERRNO, 0, true); + return 0; + }, + kernel_ipc_shm_read_chunk: readChunk, + kernel_ipc_shm_record_mapping_for_task: recordMapping, + kernel_ipc_shmat_for_task: () => segmentSize, + kernel_ipc_shmdt_for_process: shmdt, + kernel_set_current_tid: () => 0, + kernel_validate_task: () => 0, + }), + 4, + ); + writeShmat(harness.channel, 19, address); + + harness.worker.handleSyscall(harness.channel); + + expect(syscalls).toEqual([ + ABI_SYSCALLS.Mmap, + ABI_SYSCALLS.Munmap, + ]); + expect(readChunk).not.toHaveBeenCalled(); + expect(recordMapping).not.toHaveBeenCalled(); + expect(shmdt).toHaveBeenCalledExactlyOnceWith(41, 19); + expect(harness.worker.shmMappings.has(41)).toBe(false); + expect(readResult(harness.channel)).toEqual({ + status: CHANNEL_STATUS_COMPLETE, + retVal: -5, + errno: 5, + }); + }); + it.each([ ["wasm32", 4], ["wasm64", 8], diff --git a/host/test/kernel-scratch-contract.test.ts b/host/test/kernel-scratch-contract.test.ts index a3260e3057..9f09c9e34a 100644 --- a/host/test/kernel-scratch-contract.test.ts +++ b/host/test/kernel-scratch-contract.test.ts @@ -585,9 +585,6 @@ const reviewedScalarKernelExportCalls: AuditAllowance[] = [ reviewedScalarKernelExportCall( "host/src/kernel-worker.ts::CentralizedKernelWorker.#createTestAuthority::kernel-export-direct-use::forkProcess(parentPid, callerTid)", ), - reviewedScalarKernelExportCall( - "host/src/kernel-worker.ts::CentralizedKernelWorker.#finalizeAddressSpaceForExecWithinKernelEntry::kernel-export-direct-use::detach(pid, mapping.segId)", - ), reviewedScalarKernelExportCall( "host/src/kernel-worker.ts::CentralizedKernelWorker.firePosixTimer::kernel-export-direct-use::fire(pid, timerId)", ), @@ -600,6 +597,13 @@ const reviewedScalarKernelExportCalls: AuditAllowance[] = [ reviewedScalarKernelExportCall( "host/src/kernel-worker.ts::CentralizedKernelWorker.#inheritPreparedSharedMappingsWithinKernelEntry::kernel-export-direct-use::kernelShmat!( prepared.childPid, mapping.segId, mapping.mapAddr, mapping.readOnly ? SHM_RDONLY : 0, )", ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#inheritPreparedSharedMappingsWithinKernelEntry::kernel-export-direct-use::kernelShmdt!( prepared.childPid, mapping.segId, )", + 2, + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#inheritPreparedSharedMappingsWithinKernelEntry::kernel-export-direct-use::recordMapping!( prepared.childPid, kernelMapAddrs[mappingIndex]!, mapping.segId, mapping.size, )", + ), reviewedScalarKernelExportCall( "host/src/kernel-worker.ts::CentralizedKernelWorker.#injectIncomingVirtualTcpConnection::kernel-export-direct-use::( this.#kernelInstanceForEntry(entry).exports.kernel_inject_connection as ( pid: number, listenerFd: number, a: number, b: number, c: number, d: number, port: number, ) => number )( target.pid, target.fd, remoteAddr[0], remoteAddr[1], remoteAddr[2], remoteAddr[3], remotePort, )", ), @@ -664,7 +668,7 @@ const reviewedScalarKernelExportCalls: AuditAllowance[] = [ "host/src/kernel-worker.ts::CentralizedKernelWorker.#retireBlockingRetryCaptureAfterExitedProcess::kernel-export-direct-use::getState(channel.pid)", ), reviewedScalarKernelExportCall( - "host/src/kernel-worker.ts::CentralizedKernelWorker.#rollbackInheritedSysvAttachmentsWithinKernelEntry::kernel-export-direct-use::kernelShmdt(childPid, segId)", + "host/src/kernel-worker.ts::CentralizedKernelWorker.#rollbackInheritedSysvAttachmentsWithinKernelEntry::kernel-export-direct-use::kernelShmdtAddr( childPid, this.toKernelPtr(mapping.mapAddr), )", ), reviewedScalarKernelExportCall( "host/src/kernel-worker.ts::CentralizedKernelWorker.#rollbackIpcShmatWithinKernelEntry::kernel-export-direct-use::kernelShmdt(channel.pid, shmid)", @@ -746,7 +750,13 @@ const reviewedScalarKernelExportCalls: AuditAllowance[] = [ "host/src/kernel-worker.ts::CentralizedKernelWorker.handleIpcShmat::kernel-export-direct-use::kernelShmat( channel.pid, callerTid, shmid, // The kernel owns attachment accounting but not the process mapping // address; this legacy ABI slot is intentionally ignored by Rust. 0, flags, )", ), reviewedScalarKernelExportCall( - "host/src/kernel-worker.ts::CentralizedKernelWorker.handleIpcShmdt::kernel-export-direct-use::kernelShmdt( channel.pid, callerTid, mapping.segId, )", + "host/src/kernel-worker.ts::CentralizedKernelWorker.handleIpcShmat::kernel-export-direct-use::recordMapping( channel.pid, callerTid, kernelAllocatedAddr, shmid, size, )", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.handleIpcShmdt::kernel-export-direct-use::lookupMapping( channel.pid, callerTid, kernelAddr, )", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.handleIpcShmdt::kernel-export-direct-use::kernelShmdt(channel.pid, callerTid, kernelAddr)", ), reviewedScalarKernelExportCall( "host/src/kernel-worker.ts::CentralizedKernelWorker.handleSemctl::kernel-export-direct-use::arrayBytes( channel.pid, this.guestTidForChannel(channel), semid, rawCmd, )", @@ -779,7 +789,7 @@ const reviewedScalarKernelExportCalls: AuditAllowance[] = [ "host/src/kernel-worker.ts::CentralizedKernelWorker.registerProcess::kernel-export-direct-use::getProcessState?.(pid)", ), reviewedScalarKernelExportCall( - "host/src/kernel-worker.ts::CentralizedKernelWorker.releaseAllSysvShmMappingsForProcess::kernel-export-direct-use::kernelShmdt(pid, mapping.segId)", + "host/src/kernel-worker.ts::CentralizedKernelWorker.releaseAllSysvShmMappingsForProcess::kernel-export-direct-use::kernelShmdtAddr(pid, this.toKernelPtr(addr))", ), reviewedScalarKernelExportCall( "host/src/kernel-worker.ts::CentralizedKernelWorker.resolveEpollReadinessIndices::kernel-export-direct-use::getAcceptWakeIdx(pid, interest.fd)", diff --git a/host/test/kernel-shared-memory-inheritance-entry.test.ts b/host/test/kernel-shared-memory-inheritance-entry.test.ts index 8020b773bf..d99b148311 100644 --- a/host/test/kernel-shared-memory-inheritance-entry.test.ts +++ b/host/test/kernel-shared-memory-inheritance-entry.test.ts @@ -10,7 +10,9 @@ import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; const KERNEL_EXPORT_NAMES = [ "kernel_ipc_shm_read_chunk", + "kernel_ipc_shm_record_mapping_for_process", "kernel_ipc_shmat_for_process", + "kernel_ipc_shmdt_addr_for_process", "kernel_ipc_shmdt_for_process", ] as const; @@ -116,7 +118,9 @@ function makeHarness( ): InheritanceHarness { const implementations: Record = { kernel_ipc_shm_read_chunk: () => 0, + kernel_ipc_shm_record_mapping_for_process: () => 0, kernel_ipc_shmat_for_process: () => -1, + kernel_ipc_shmdt_addr_for_process: () => 0, kernel_ipc_shmdt_for_process: () => 0, ...options.implementations, }; @@ -477,6 +481,8 @@ describe("shared-memory inheritance entry authority", () => { const shmat = vi.fn((_pid: number, segId: number) => segId === 11 ? size : -12); const shmdt = vi.fn(() => 0); + const recordMapping = vi.fn(() => 0); + const shmdtAddr = vi.fn(() => 0); const readChunk = vi.fn(( segId: number, offset: number, @@ -494,7 +500,9 @@ describe("shared-memory inheritance entry authority", () => { harness = makeHarness({ implementations: { kernel_ipc_shm_read_chunk: readChunk, + kernel_ipc_shm_record_mapping_for_process: recordMapping, kernel_ipc_shmat_for_process: shmat, + kernel_ipc_shmdt_addr_for_process: shmdtAddr, kernel_ipc_shmdt_for_process: shmdt, }, }); @@ -542,7 +550,17 @@ describe("shared-memory inheritance entry authority", () => { [childPid, 12, secondSysvAddr, 0], ]); expect(readChunk).toHaveBeenCalledOnce(); - expect(shmdt).toHaveBeenCalledExactlyOnceWith(childPid, 11); + expect(recordMapping).toHaveBeenCalledExactlyOnceWith( + childPid, + firstSysvAddr, + 11, + size, + ); + expect(shmdtAddr).toHaveBeenCalledExactlyOnceWith( + childPid, + firstSysvAddr, + ); + expect(shmdt).not.toHaveBeenCalled(); expect(backing.refCount).toBe(1); expect(harness.state.sharedMappings.has(childPid)).toBe(false); expect(harness.state.shmMappings.has(childPid)).toBe(false); @@ -572,6 +590,59 @@ describe("shared-memory inheritance entry authority", () => { ).toBe(0x42); }); + it("releases an unrecorded child attachment when Rust rejects its address", () => { + const parentPid = 53; + const childPid = 54; + const mapAddr = 0x2000; + const size = 16; + const childMemory = processMemory(); + new Uint8Array(childMemory.buffer).fill(0x77); + const shmat = vi.fn(() => size); + const recordMapping = vi.fn(() => -12); + const shmdt = vi.fn(() => 0); + const shmdtAddr = vi.fn(() => 0); + const readChunk = vi.fn(() => size); + const harness = makeHarness({ + implementations: { + kernel_ipc_shm_read_chunk: readChunk, + kernel_ipc_shm_record_mapping_for_process: recordMapping, + kernel_ipc_shmat_for_process: shmat, + kernel_ipc_shmdt_addr_for_process: shmdtAddr, + kernel_ipc_shmdt_for_process: shmdt, + }, + }); + harness.state.processes.set( + childPid, + processRegistration(childPid, childMemory), + ); + harness.state.shmMappings.set(parentPid, new Map([ + [mapAddr, { + segId: 11, + size, + readOnly: false, + snapshot: new Uint8Array(size), + seenVersion: 0, + }], + ])); + + expect(() => { + harness.worker.inheritProcessSharedMappings(parentPid, childPid); + }).toThrow(/Cannot record inherited SysV segment 11/); + + expect(shmat).toHaveBeenCalledExactlyOnceWith(childPid, 11, mapAddr, 0); + expect(recordMapping).toHaveBeenCalledExactlyOnceWith( + childPid, + mapAddr, + 11, + size, + ); + expect(shmdt).toHaveBeenCalledExactlyOnceWith(childPid, 11); + expect(shmdtAddr).not.toHaveBeenCalled(); + expect(readChunk).not.toHaveBeenCalled(); + expect(harness.state.shmMappings.has(childPid)).toBe(false); + expect(new Uint8Array(childMemory.buffer)[mapAddr]).toBe(0x77); + }); + it("restores bytes and prior refcounts if host publication fails mid-retain", () => { const parentPid = 61; const childPid = 62; diff --git a/host/test/shared-memory-coherence.test.ts b/host/test/shared-memory-coherence.test.ts index 334b71416d..3e6469fe52 100644 --- a/host/test/shared-memory-coherence.test.ts +++ b/host/test/shared-memory-coherence.test.ts @@ -281,7 +281,17 @@ function sysvHarness() { const shmat = vi.fn(() => size); const shmdt = vi.fn(() => 0); const shmatForTask = vi.fn(() => size); - const shmdtForTask = vi.fn(() => 0); + const recordForProcess = vi.fn(() => 0); + const recordForTask = vi.fn(() => 0); + const lookupForTask = vi.fn(( + _pid: number, + _tid: number, + addr: number, + ) => addr === mapAddr + ? (BigInt(size) << 32n) | BigInt(segId) + : -22n); + const shmdtAddrForProcess = vi.fn(() => 0); + const shmdtAddrForTask = vi.fn(() => 0); const validateTask = vi.fn(() => 0); const handleChannel = vi.fn((channelPtr: number | bigint) => { const view = new DataView( @@ -297,8 +307,17 @@ function sysvHarness() { view.getBigInt64(CH_ARGS + index * CH_ARG_SIZE, true), ), }); - view.setBigInt64(CH_RETURN, -1n, true); - view.setUint32(CH_ERRNO, 12, true); + const syscallNr = view.getUint32(CH_SYSCALL, true); + view.setBigInt64( + CH_RETURN, + syscallNr === ABI_SYSCALLS.Mmap ? -1n : 0n, + true, + ); + view.setUint32( + CH_ERRNO, + syscallNr === ABI_SYSCALLS.Mmap ? 12 : 0, + true, + ); return 0; }); const readChunk = vi.fn((id: number, offset: number, outPtr: number, maxLen: number) => { @@ -344,10 +363,14 @@ function sysvHarness() { kernelExports: { kernel_get_process_exit_signal: () => 0, kernel_handle_channel: handleChannel, + kernel_ipc_shm_lookup_mapping_for_task: lookupForTask, + kernel_ipc_shm_record_mapping_for_process: recordForProcess, + kernel_ipc_shm_record_mapping_for_task: recordForTask, kernel_ipc_shmat_for_process: shmat, kernel_ipc_shmat_for_task: shmatForTask, + kernel_ipc_shmdt_addr_for_process: shmdtAddrForProcess, + kernel_ipc_shmdt_addr_for_task: shmdtAddrForTask, kernel_ipc_shmdt_for_process: shmdt, - kernel_ipc_shmdt_for_task: shmdtForTask, kernel_ipc_shm_read_chunk: readChunk, kernel_ipc_shm_write_chunk: writeChunk, kernel_set_current_tid: () => 0, @@ -356,10 +379,14 @@ function sysvHarness() { kernelExportNames: [ "kernel_get_process_exit_signal", "kernel_handle_channel", + "kernel_ipc_shm_lookup_mapping_for_task", + "kernel_ipc_shm_record_mapping_for_process", + "kernel_ipc_shm_record_mapping_for_task", "kernel_ipc_shmat_for_process", "kernel_ipc_shmat_for_task", + "kernel_ipc_shmdt_addr_for_process", + "kernel_ipc_shmdt_addr_for_task", "kernel_ipc_shmdt_for_process", - "kernel_ipc_shmdt_for_task", "kernel_ipc_shm_read_chunk", "kernel_ipc_shm_write_chunk", "kernel_set_current_tid", @@ -370,16 +397,20 @@ function sysvHarness() { return { kw, handleChannel, + lookupForTask, mapAddr, memories, pids, readChunk, + recordForProcess, + recordForTask, segment, segId, shmat, shmatForTask, shmdt, - shmdtForTask, + shmdtAddrForProcess, + shmdtAddrForTask, size, syntheticMemorySyscalls, validateTask, @@ -433,13 +464,35 @@ describe("SysV SHM coherence and lifecycle", () => { const h = sysvHarness(); h.kw.inheritProcessSharedMappings(h.pids[0], h.pids[2]); expect(h.shmat).toHaveBeenCalledWith(h.pids[2], h.segId, h.mapAddr, 0); + expect(h.recordForProcess).toHaveBeenCalledWith( + h.pids[2], + h.mapAddr, + h.segId, + h.size, + ); expect((h.kw as any).shmMappings.get(h.pids[2]).size).toBe(1); (h.kw as any).releaseAllSharedMemoryForProcess(h.pids[2]); - expect(h.shmdt).toHaveBeenCalledTimes(1); - expect(h.shmdt).toHaveBeenCalledWith(h.pids[2], h.segId); + expect(h.shmdtAddrForProcess).toHaveBeenCalledTimes(1); + expect(h.shmdtAddrForProcess).toHaveBeenCalledWith( + h.pids[2], + h.mapAddr, + ); (h.kw as any).releaseAllSharedMemoryForProcess(h.pids[2]); - expect(h.shmdt).toHaveBeenCalledTimes(1); + expect(h.shmdtAddrForProcess).toHaveBeenCalledTimes(1); + expect(h.shmdt).not.toHaveBeenCalled(); + }); + + it("retains the byte mirror when Rust cannot prove lifecycle detach", () => { + const h = sysvHarness(); + h.shmdtAddrForProcess.mockReturnValue(-5); + + expect(() => { + (h.kw as any).releaseAllSharedMemoryForProcess(h.pids[0]); + }).toThrow(/Cannot detach SysV segment/); + + expect((h.kw as any).shmMappings.get(h.pids[0])?.has(h.mapAddr)) + .toBe(true); }); it("rolls back attachments when inherited SysV setup fails", () => { @@ -455,7 +508,12 @@ describe("SysV SHM coherence and lifecycle", () => { h.shmat.mockImplementationOnce(() => h.size).mockImplementationOnce(() => -12); expect(() => h.kw.inheritProcessSharedMappings(h.pids[0], h.pids[2])).toThrow(); - expect(h.shmdt).toHaveBeenCalledTimes(1); + expect(h.shmdtAddrForProcess).toHaveBeenCalledTimes(1); + expect(h.shmdtAddrForProcess).toHaveBeenCalledWith( + h.pids[2], + h.mapAddr, + ); + expect(h.shmdt).not.toHaveBeenCalled(); expect((h.kw as any).shmMappings.has(h.pids[2])).toBe(false); }); @@ -587,6 +645,70 @@ describe("SysV SHM coherence and lifecycle", () => { expect(relisten).toHaveBeenCalledWith(channel); }); + it("resolves shmdt identity in Rust before publishing and detaching", () => { + const h = sysvHarness(); + const pid = h.pids[0]; + const process = (h.kw as any).processes.get(pid); + const channel = process.channels[0]; + const relisten = vi.fn(); + h.kw.testAuthority.configureScratchBoundaryHooksForTest({ + relistenChannel: relisten, + }); + new Uint8Array(process.memory.buffer)[h.mapAddr + 7] = 0x7d; + + writeChannelSyscall( + channel, + ABI_SYSCALLS.Shmdt, + [BigInt(h.mapAddr)], + ); + h.kw.testAuthority.dispatchScratchBoundarySyscallForTest(channel); + + expect(h.lookupForTask).toHaveBeenCalledWith( + pid, + pid, + h.mapAddr, + ); + expect(h.segment[7]).toBe(0x7d); + expect(h.shmdtAddrForTask).toHaveBeenCalledExactlyOnceWith( + pid, + pid, + h.mapAddr, + ); + expect((h.kw as any).shmMappings.has(pid)).toBe(false); + expect(h.syntheticMemorySyscalls.at(-1)).toMatchObject({ + syscallNr: ABI_SYSCALLS.Munmap, + }); + const view = new DataView(channel.memory.buffer, channel.channelOffset); + expect(Number(view.getBigInt64(CH_RETURN, true))).toBe(0); + expect(view.getUint32(CH_ERRNO, true)).toBe(0); + expect(view.getUint32(CH_STATUS, true)).toBe(CHANNEL_STATUS_COMPLETE); + expect(relisten).toHaveBeenCalledWith(channel); + }); + + it("keeps the host mirror when address-owned shmdt fails", () => { + const h = sysvHarness(); + const pid = h.pids[0]; + const process = (h.kw as any).processes.get(pid); + const channel = process.channels[0]; + h.shmdtAddrForTask.mockReturnValue(-5); + h.kw.testAuthority.configureScratchBoundaryHooksForTest({ + relistenChannel: vi.fn(), + }); + + writeChannelSyscall( + channel, + ABI_SYSCALLS.Shmdt, + [BigInt(h.mapAddr)], + ); + h.kw.testAuthority.dispatchScratchBoundarySyscallForTest(channel); + + expect((h.kw as any).shmMappings.get(pid)?.has(h.mapAddr)).toBe(true); + expect(h.syntheticMemorySyscalls).toHaveLength(0); + const view = new DataView(channel.memory.buffer, channel.channelOffset); + expect(Number(view.getBigInt64(CH_RETURN, true))).toBe(-5); + expect(view.getUint32(CH_ERRNO, true)).toBe(5); + }); + it("does not alias a wasm64 shmdt address to an existing low mapping", () => { const h = sysvHarness(); const process = (h.kw as any).processes.get(h.pids[0]); @@ -606,7 +728,8 @@ describe("SysV SHM coherence and lifecycle", () => { h.kw.testAuthority.dispatchScratchBoundarySyscallForTest(channel); expect((h.kw as any).shmMappings.get(h.pids[0]).has(h.mapAddr)).toBe(true); - expect(h.shmdtForTask).not.toHaveBeenCalled(); + expect(h.lookupForTask).not.toHaveBeenCalled(); + expect(h.shmdtAddrForTask).not.toHaveBeenCalled(); expect(h.handleChannel).not.toHaveBeenCalled(); const view = new DataView(channel.memory.buffer, channel.channelOffset); expect(Number(view.getBigInt64(CH_RETURN, true))).toBe(-22); diff --git a/host/test/support/kernel-scratch-instance.ts b/host/test/support/kernel-scratch-instance.ts index 0c7f5ca7c1..957fb68326 100644 --- a/host/test/support/kernel-scratch-instance.ts +++ b/host/test/support/kernel-scratch-instance.ts @@ -207,6 +207,18 @@ function signatures( parameters: [i32, i32, pointer, i32], result: i32, }, + kernel_ipc_shm_lookup_mapping_for_task: { + parameters: [i32, i32, pointer], + result: i64, + }, + kernel_ipc_shm_record_mapping_for_process: { + parameters: [i32, pointer, i32, i32], + result: i32, + }, + kernel_ipc_shm_record_mapping_for_task: { + parameters: [i32, i32, pointer, i32, i32], + result: i32, + }, kernel_ipc_shmat_for_process: { parameters: [i32, i32, i32, i32], result: i32, @@ -219,6 +231,18 @@ function signatures( parameters: [i32, i32], result: i32, }, + kernel_ipc_shmdt_addr: { + parameters: [pointer], + result: i32, + }, + kernel_ipc_shmdt_addr_for_process: { + parameters: [i32, pointer], + result: i32, + }, + kernel_ipc_shmdt_addr_for_task: { + parameters: [i32, i32, pointer], + result: i32, + }, kernel_ipc_shmdt_for_task: { parameters: [i32, i32, i32], result: i32, From 2ace7285aed343edb08b123b11ab6e4d7ea5361f Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Thu, 28 May 2026 14:12:38 +0100 Subject: [PATCH 44/82] Network: Move TCP listener selection into Rust Move TCP listener lookup and target selection into the Rust process table. Export only generated ABI metadata needed to deliver the result, so the host no longer maintains a competing listener registry. Exercise selection, wait lifecycle, and multi-worker behavior through the shared host adapter. --- abi/snapshot.json | 6 + crates/kernel/src/process_table.rs | 137 ++++++++++++++++++ crates/kernel/src/wasm_api.rs | 35 +++++ crates/shared/src/lib.rs | 1 + docs/abi-versioning.md | 10 ++ docs/architecture.md | 1 + .../2026-05-20-rust-owned-host-logic-plan.md | 2 +- host/src/generated/abi.ts | 1 + host/src/kernel-scratch.ts | 4 + host/src/kernel-worker.ts | 67 +++++---- host/test/exec-state-tracking.test.ts | 17 ++- host/test/multi-worker.test.ts | 18 +++ host/test/process-wait-lifecycle.test.ts | 36 +++++ host/test/support/kernel-scratch-instance.ts | 4 + 14 files changed, 311 insertions(+), 28 deletions(-) diff --git a/abi/snapshot.json b/abi/snapshot.json index 949518b981..106c631fe0 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -1077,6 +1077,7 @@ "kernel_mark_process_signaled", "kernel_mq_descriptor_msgsize", "kernel_msqid_ds_bytes", + "kernel_pick_tcp_listener_target", "kernel_pick_signal_target_tid", "kernel_pipe_has_readers", "kernel_posix_timer_fire", @@ -2428,6 +2429,11 @@ "name": "kernel_pick_signal_target_tid", "signature": "(i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_pick_tcp_listener_target", + "signature": "(i32,i32,i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_pipe", diff --git a/crates/kernel/src/process_table.rs b/crates/kernel/src/process_table.rs index 6e1f5b40f4..2d5750896d 100644 --- a/crates/kernel/src/process_table.rs +++ b/crates/kernel/src/process_table.rs @@ -25,6 +25,7 @@ use crate::ofd::FileType; #[cfg(test)] use crate::process::ThreadInfo; use crate::process::{ChildWaitEvent, Process, ProcessState, StdioConfig}; +use crate::socket::SocketState; const INITIAL_FORK_STATE_BUFFER_LEN: usize = 64 * 1024; const MAX_FORK_STATE_BUFFER_LEN: usize = 4 * 1024 * 1024; @@ -101,6 +102,8 @@ pub struct ProcessTable { /// Keeping the pair prevents a stale or misrouted host dispatch from /// applying one process's valid TID to another process. current_tid_pid: u32, + /// Round-robin cursor for host-bridged TCP listener target selection. + tcp_listener_rr: BTreeMap, } /// Outcome of `ProcessTable::remove_process`. Bundles the side effects the @@ -415,6 +418,7 @@ impl ProcessTable { next_task_id: FIRST_TASK_ID, current_tid: 0, current_tid_pid: 0, + tcp_listener_rr: BTreeMap::new(), } } @@ -1483,6 +1487,70 @@ impl ProcessTable { self.processes.get(&pid).map(|proc| proc.ppid) } + /// Pick the process/fd that should receive the next host-bridged TCP + /// connection for `port`. + /// + /// JS still owns the actual `net.Server`/service-worker bridge, but the + /// process table owns which live process currently has an inherited + /// listening socket. When children inherited a listener through fork, + /// prefer them over the original parent just as the previous host-side + /// policy did. + pub fn pick_tcp_listener_target( + &mut self, + port: u16, + exclude_pid: u32, + ) -> Option<(u32, i32)> { + let mut targets = self.tcp_listener_targets(port, exclude_pid); + if targets.len() > 1 { + let children: Vec<(u32, i32)> = targets + .iter() + .copied() + .filter(|(pid, _fd)| { + self.processes + .get(pid) + .is_some_and(|proc| proc.ppid > 0) + }) + .collect(); + if !children.is_empty() { + targets = children; + } + } + + if targets.is_empty() { + self.tcp_listener_rr.remove(&port); + return None; + } + + let idx = self.tcp_listener_rr.get(&port).copied().unwrap_or(0) % targets.len(); + self.tcp_listener_rr.insert(port, idx + 1); + Some(targets[idx]) + } + + fn tcp_listener_targets(&self, port: u16, exclude_pid: u32) -> Vec<(u32, i32)> { + let mut targets = Vec::new(); + for (&pid, proc) in &self.processes { + if pid == exclude_pid || proc.state != ProcessState::Running { + continue; + } + for (fd, entry) in proc.fd_table.iter() { + let Some(ofd) = proc.ofd_table.get(entry.ofd_ref.0) else { + continue; + }; + if ofd.file_type != FileType::Socket || ofd.host_handle >= 0 { + continue; + } + let sock_idx = (-(ofd.host_handle + 1)) as usize; + let Some(sock) = proc.sockets.get(sock_idx) else { + continue; + }; + if sock.state == SocketState::Listening && sock.bind_port == port { + targets.push((pid, fd)); + } + } + } + targets + } + /// Select the latest status-information record for a direct child. /// Nonmatching masks and WNOWAIT leave that single record untouched. pub fn poll_wait_event( @@ -3369,4 +3437,73 @@ mod tests { assert!(table.get_process_containing_task(9999).is_none()); } + + #[test] + fn tcp_listener_target_policy_prefers_fork_children() { + let mut table = ProcessTable::new(); + let parent = table.create_process().unwrap(); + let first_child = table.create_process().unwrap(); + let second_child = table.create_process().unwrap(); + table.processes.get_mut(&first_child).unwrap().ppid = parent; + table.processes.get_mut(&second_child).unwrap().ppid = parent; + + add_listening_socket(&mut table, parent, 8080, 3); + add_listening_socket(&mut table, first_child, 8080, 3); + add_listening_socket(&mut table, second_child, 8080, 3); + + assert_eq!( + table.pick_tcp_listener_target(8080, 0), + Some((first_child, 3)) + ); + assert_eq!( + table.pick_tcp_listener_target(8080, 0), + Some((second_child, 3)) + ); + assert_eq!( + table.pick_tcp_listener_target(8080, 0), + Some((first_child, 3)) + ); + } + + #[test] + fn tcp_listener_target_policy_can_exclude_a_process_during_cleanup() { + let mut table = ProcessTable::new(); + let parent = table.create_process().unwrap(); + let child = table.create_process().unwrap(); + table.processes.get_mut(&child).unwrap().ppid = parent; + + add_listening_socket(&mut table, parent, 8080, 3); + add_listening_socket(&mut table, child, 8080, 3); + + assert_eq!( + table.pick_tcp_listener_target(8080, parent), + Some((child, 3)) + ); + assert_eq!( + table.pick_tcp_listener_target(8080, child), + Some((parent, 3)) + ); + assert_eq!(table.pick_tcp_listener_target(9999, 0), None); + } + + fn add_listening_socket(table: &mut ProcessTable, pid: u32, port: u16, fd: i32) { + use crate::fd::OpenFileDescRef; + use crate::socket::{SocketDomain, SocketInfo, SocketState, SocketType}; + use wasm_posix_shared::flags::O_RDWR; + + let proc = table.processes.get_mut(&pid).unwrap(); + let mut sock = SocketInfo::new(SocketDomain::Inet, SocketType::Stream, 0); + sock.state = SocketState::Listening; + sock.bind_port = port; + let sock_idx = proc.sockets.alloc(sock); + let ofd_idx = proc.ofd_table.create( + FileType::Socket, + O_RDWR, + -((sock_idx as i64) + 1), + b"socket".to_vec(), + ); + proc.fd_table + .alloc_at_min(OpenFileDescRef(ofd_idx), 0, fd) + .unwrap(); + } } diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index e548dc1abc..bf25c91b48 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -1983,6 +1983,41 @@ pub extern "C" fn kernel_get_process_state(pid: u32) -> i32 { } } +/// Pick the next live process/fd that should receive a host-bridged TCP +/// connection for `port`. +/// +/// Writes `{ u32 pid, i32 fd }` to `out_ptr`; returns 1 if a target was +/// written, 0 if none exists, or negative errno. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_pick_tcp_listener_target( + port: u32, + exclude_pid: u32, + out_ptr: *mut u8, + out_capacity: u32, +) -> i32 { + if out_ptr.is_null() { + return -(Errno::EFAULT as i32); + } + if out_capacity != 8 { + return -(Errno::EINVAL as i32); + } + if port > u16::MAX as u32 { + return -(Errno::EINVAL as i32); + } + + let _gkl = GklGuard::acquire(); + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + match table.pick_tcp_listener_target(port as u16, exclude_pid) { + Some((pid, fd)) => { + let out = unsafe { core::slice::from_raw_parts_mut(out_ptr, 8) }; + out[0..4].copy_from_slice(&pid.to_le_bytes()); + out[4..8].copy_from_slice(&fd.to_le_bytes()); + 1 + } + None => 0, + } +} + /// Mark a process as signal-terminated without removing it from the table. /// /// Used by the host when the Worker dies before the guest reaches SYS_EXIT. diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 1d02223675..09157bae66 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -2907,6 +2907,7 @@ pub mod abi { "kernel_mark_process_signaled", "kernel_mq_descriptor_msgsize", "kernel_msqid_ds_bytes", + "kernel_pick_tcp_listener_target", "kernel_pick_signal_target_tid", "kernel_pipe_has_readers", "kernel_posix_timer_fire", diff --git a/docs/abi-versioning.md b/docs/abi-versioning.md index 04f8615249..1b0b9f15e0 100644 --- a/docs/abi-versioning.md +++ b/docs/abi-versioning.md @@ -265,6 +265,16 @@ rollback, and teardown use the separate explicit-process `kernel_ipc_shmat_for_process` and `kernel_ipc_shmdt_for_process` exports. The former `kernel_set_current_pid` export is removed. +ABI 43 also moves host-bridged TCP listener selection into the process table. +`kernel_pick_tcp_listener_target(port, exclude_pid, out_ptr, out_capacity)` +writes one little-endian `{ u32 pid, i32 fd }` record into eight bytes of kernel +scratch when `out_capacity` is exactly eight and returns `1`, returns `0` when +no live listener exists, or returns a negative errno. Rust filters +authoritative process, descriptor, open-file-description, and socket state and +owns the per-port round-robin cursor. The +shared Node/browser host retains only the platform listener objects, stable +accept-wakeup identities, and their lifecycle mirrors. + The pending ABI 43 contract additionally makes the Rust `Process` authoritative for each System V shared-memory attachment's process address, segment id, and size. After the host has materialized an attachment and its byte-coherence diff --git a/docs/architecture.md b/docs/architecture.md index 6b5f521582..1bc7428ec3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -85,6 +85,7 @@ kernel_thread_exit(pid, tid) → 0 | -errno kernel_commit_process_exit(status) → committed_low_8_bits kernel_dequeue_signal(pid, tid, out_ptr, out_capacity) → 0 | signum | -errno kernel_wait_child_poll(parent_pid, caller_tid, target_pid, event_mask, flags, out_ptr, out_capacity) → child_pid | 0 | -errno +kernel_pick_tcp_listener_target(port, exclude_pid, out_ptr, out_capacity) → 1 | 0 | -errno kernel_ipc_shmat_for_task(pid, tid, shmid, addr, flags) → segment_size | -errno kernel_ipc_shm_record_mapping_for_task(pid, tid, addr, shmid, size) → 0 | -errno kernel_ipc_shm_lookup_mapping_for_task(pid, tid, addr) → packed_size_and_shmid | -errno diff --git a/docs/plans/2026-05-20-rust-owned-host-logic-plan.md b/docs/plans/2026-05-20-rust-owned-host-logic-plan.md index 2c6041dd9e..4786ae2235 100644 --- a/docs/plans/2026-05-20-rust-owned-host-logic-plan.md +++ b/docs/plans/2026-05-20-rust-owned-host-logic-plan.md @@ -114,7 +114,7 @@ path. | Done / PR #534 | Rust-owned syscall marshalling descriptors | `crates/shared::host_abi` owns simple pointer-argument descriptors; `dump-abi` generates `SYSCALL_ARGS`; TS host keeps memory copies but reads generated descriptors. | The old TS `SYSCALL_ARGS` table and syscall-number size switches are gone. `poll`/`ppoll`, SysV message prefix, `semop`, and `msgrcv` copy-back adjustments are metadata fields. Nested-pointer syscalls (`readv`/`writev`/preadv/pwritev) stay on dedicated TS paths. | Shared unit tests for descriptor ordering/high-risk sizes/nested-pointer exclusion; xtask ABI tests; `bash scripts/check-abi-version.sh`; generated ABI vitest; host build; kernel lib tests. | | Done / PR #534 follow-up | Extended host-visible syscall numbers and names | Add Rust/shared metadata for ABI-visible syscall numbers still hardcoded in host TS but not currently in `shared::Syscall`, such as `getrandom`, `clone`, `futex`, `ppoll`, `pselect6`, epoll, `exit_group`, `waitid`, `msync`, preadv/pwritev, mqueue, SysV IPC, `sched_yield`, `fallocate`, timers, and `thread_cancel`. Generate TS bindings, logging names, and snapshot coverage. | Host TS no longer defines literal syscall numbers for this set, and syscall trace names are generated from Rust-owned metadata. Existing `HOST_INTERCEPTED_SYSCALLS` remains separate for fork/exec/spawn because those are caught before normal dispatch. Public behavior unchanged. | Rust metadata uniqueness tests; xtask compatibility tests; `bash scripts/check-abi-version.sh update` + check; generated ABI vitest; host build; kernel lib tests. | | Done / stacked PR | Rust-defined host adapter manifest | Add a compact Rust-defined manifest describing ABI version, required host adapter protocol version, required/optional exports, worker protocol features, and channel metadata. JS validates it during kernel boot. | Boot fails earlier with clear errors when the host/kernel contract is incompatible. No Worker creation or Wasm instantiation moves out of JS. | Rust manifest serialization tests; ABI snapshot check; vitest boot validation cases; Node/browser worker-entry smoke if boot code changes. | -| In progress / stacked PR | Process lifecycle cleanup consolidation | Rust `ProcessTable` now owns parent lookup, wait-target matching, wait-status derivation, host-crash zombie marking, authorized child reaping, thread-exit clear-tid metadata, and SysV shared-memory attachment metadata. TS keeps blocked waiter queues, Worker/memory cleanup, and process-memory writes/futex wakeups because those pointers name guest memory. Remaining audit: thread channel/Worker allocation and free-list lifecycle, host timer cancellation, and TCP listener target policy. | Kernel owns process lifecycle invariants that do not require Worker identity; JS owns Worker termination, memory objects, crash observation, and platform callbacks. | ProcessTable unit tests; fork/exec/spawn/clone/wait tests; crash/trap tests; browser parity smoke when worker entries change. | +| In progress / stacked PR | Process lifecycle cleanup consolidation | Rust `ProcessTable` now owns parent lookup, wait-target matching, wait-status derivation, host-crash zombie marking, authorized child reaping, thread-exit clear-tid metadata, SysV shared-memory attachment metadata, and host-bridged TCP listener target policy. TS keeps blocked waiter queues, Worker/memory cleanup, platform timers, process-memory writes/futex wakeups, and the actual TCP server objects because those are host primitives. Remaining audit: thread channel/Worker allocation and free-list lifecycle plus host timer cancellation. | Kernel owns process lifecycle invariants that do not require Worker identity; JS owns Worker termination, memory objects, crash observation, and platform callbacks. | ProcessTable unit tests; fork/exec/spawn/clone/wait tests; crash/trap tests; browser parity smoke when worker entries change. | | In progress / stacked PR | IPC/resource cleanup in Rust | Rust `Process` now records `shmat` address -> segment metadata, records child attachments during transactional fork materialization, clears them across exec setup, and detaches live mappings from `remove_process()`. TS still copies bytes between guest memory and kernel SysV segments because only the host can address guest `Memory`. | `remove_process()` owns IPC attachment cleanup; JS only handles guest-memory transfer and host primitive wake/schedule work. | SysV IPC and mqueue Rust tests plus host integration/e2e coverage for blocking and cleanup. | | Planned | Readiness metadata improvements | Replace broad host inference with kernel-emitted readiness events for pipe/socket/poll/select cases where the kernel already knows state changes. | JS still owns timers/retry queues/`Atomics.waitAsync`, but readiness decisions are less inferred from syscall numbers. No extra Wasm round trip per syscall. | Pipe/socket/poll/select/ppoll/pselect tests; browser bridge smoke for affected wake paths; performance comparison before removing broad fallback logic. | | Planned | VFS policy split | Keep backend I/O, OPFS/IndexedDB/fetch, Node `fs`, and lazy archive materialization in JS. Move permission and policy decisions into Rust where process uid/gid/umask/fd context is authoritative. | Guest-visible policy is enforced in Rust; host adapters only perform platform operations requested through a checked contract. | VFS unit tests, uid/gid/permission tests, host-fs metadata tests, default mount tests, Node/browser parity tests. | diff --git a/host/src/generated/abi.ts b/host/src/generated/abi.ts index 0b5fb82a94..3dceb041b6 100644 --- a/host/src/generated/abi.ts +++ b/host/src/generated/abi.ts @@ -532,6 +532,7 @@ export const HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS = [ "kernel_mark_process_signaled", "kernel_mq_descriptor_msgsize", "kernel_msqid_ds_bytes", + "kernel_pick_tcp_listener_target", "kernel_pick_signal_target_tid", "kernel_pipe_has_readers", "kernel_posix_timer_fire", diff --git a/host/src/kernel-scratch.ts b/host/src/kernel-scratch.ts index 1191e04792..ec3a5b89e5 100644 --- a/host/src/kernel-scratch.ts +++ b/host/src/kernel-scratch.ts @@ -144,6 +144,7 @@ export const KERNEL_SCRATCH_EXPORT_NAMES = intrinsicObjectFreeze([ "kernel_pipe2", "kernel_pipe_read", "kernel_pipe_write", + "kernel_pick_tcp_listener_target", "kernel_poll", "kernel_process_metadata_stage", "kernel_pty_master_read", @@ -241,6 +242,7 @@ export function kernelScratchRequiredPointerArguments( case "kernel_ipc_shm_write_chunk": case "kernel_pipe_read": case "kernel_pipe_write": + case "kernel_pick_tcp_listener_target": case "kernel_spawn_process": case "kernel_tcsetattr": return REQUIRED_POINTER_2; @@ -274,6 +276,7 @@ function kernelScratchPointerAlignment( ): number { if ( (name === "kernel_pipe2" && pointerIndex === 1) + || (name === "kernel_pick_tcp_listener_target" && pointerIndex === 2) || (name === "kernel_poll" && pointerIndex === 0) || (name === "kernel_socketpair" && pointerIndex === 3) ) { @@ -304,6 +307,7 @@ function isKernelScratchExportName( case "kernel_pipe2": case "kernel_pipe_read": case "kernel_pipe_write": + case "kernel_pick_tcp_listener_target": case "kernel_poll": case "kernel_process_metadata_stage": case "kernel_pty_master_read": diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index cee285975a..d84e2c2281 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -21138,8 +21138,8 @@ export class CentralizedKernelWorker { // The kernel child is real before its host Worker launches. Install its // host-only fd mirrors synchronously so a sibling exec cannot remove the // parent's last listener and close the shared backend during onFork's - // async worker setup. Exact listener selection still ignores the child - // until onFork registers its process memory. + // async worker setup. Rust owns target selection; these mirrors retain the + // host backend and stable wake identity across that launch window. try { this.inheritHostFdMirrors(parentPid, childPid, entry); } catch (err) { @@ -29237,8 +29237,9 @@ export class CentralizedKernelWorker { ); } - // Register this pid:fd as a target for this port (needed for both - // Node.js TCP bridging and browser service-worker connection injection). + // Register this pid:fd as a fallback/readiness target for this port. + // Runtime target selection is Rust-owned; Node and browser still need this + // mirror to retain bridge resources and stable accept-wakeup identities. if (!this.tcpListenerTargets.has(port)) { this.tcpListenerTargets.set(port, []); this.tcpListenerRRIndex.set(port, 0); @@ -29313,6 +29314,7 @@ export class CentralizedKernelWorker { this.tcpListeners.set(key, { server, pid, port, connections }); } + /** Select the Rust-authoritative listener target for host-side injection. */ pickListenerTarget(port: number): { pid: number; fd: number } | null { if (!Number.isSafeInteger(port) || port < 0 || port > 0xffff) { throw new RangeError(`invalid listener port ${String(port)}`); @@ -29337,31 +29339,46 @@ export class CentralizedKernelWorker { #pickListenerTargetWithinKernelEntry( port: number, entry: KernelWorkerEntryContext, + excludePid = 0, ): TcpListenerTarget | null { - const targets = this.tcpListenerTargets.get(port); - if (!targets || targets.length === 0) return null; - const alive = targets.filter((target) => this.processes.has(target.pid)); - if (alive.length === 0) return null; - - // Do not prune unregistered targets here: a fork/spawn child owns its - // kernel listener before async Worker registration completes. Explicit - // process teardown removes truly dead targets. - - // If there are fork children among targets, prefer them over the original - // listener (the master doesn't accept connections, workers do). - let candidates = alive; - if (alive.length > 1) { - const children = alive.filter( - (target) => this.getParentPid(target.pid, entry) !== undefined, + const scratch = this.#requireMainScratchRegion(); + return scratch.withLease((lease) => { + const result = this.#invokeEntryScratchExport( + entry, + lease, + "kernel_pick_tcp_listener_target", + [port, excludePid, lease.exportPointer(0, 8), 8], ); - if (children.length > 0) { - candidates = children; + if (result < 0) { + throw new KernelScratchError( + `kernel listener target selection failed: ${result}`, + -result, + ); + } + if (result === 0) return null; + if (result !== 1) { + throw new KernelScratchError( + `kernel returned invalid listener target count ${result}`, + EIO, + ); } - } - const idx = (this.tcpListenerRRIndex.get(port) ?? 0) % candidates.length; - this.tcpListenerRRIndex.set(port, idx + 1); - return candidates[idx]!; + const bytes = lease.copyOut(0, 8); + const view = new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ); + const pid = view.getUint32(0, true); + const fd = view.getInt32(4, true); + if (pid === 0 || fd < 0) { + throw new KernelScratchError( + `kernel returned invalid listener target pid=${pid} fd=${fd}`, + EIO, + ); + } + return { pid, fd }; + }); } /** diff --git a/host/test/exec-state-tracking.test.ts b/host/test/exec-state-tracking.test.ts index c39509ee6c..621c1520e6 100644 --- a/host/test/exec-state-tracking.test.ts +++ b/host/test/exec-state-tracking.test.ts @@ -602,13 +602,14 @@ describe("exec host-state transition", () => { port: 8080, connections: new Set(), }; + const kernelMemory = new WebAssembly.Memory({ initial: 2 }); const worker = createWorker({ processes: new Map([[7, { channels: [channel], memory }]]), callbacks: { onResolveSpawn: vi.fn(async () => resolvedProgram()), onSpawn: vi.fn(() => spawned), }, - kernelMemory: new WebAssembly.Memory({ initial: 2 }), + kernelMemory, kernelInstance: { exports: { kernel_spawn_process: () => 100, @@ -617,6 +618,18 @@ describe("exec host-state transition", () => { fd === 4 ? 41 : -1, kernel_find_listener_fd_by_accept_wake: (_pid: number, wakeIdx: number) => wakeIdx === 41 ? 4 : -1, + kernel_pick_tcp_listener_target: ( + _port: number, + _excludePid: number, + outPtr: number, + outCapacity: number, + ) => { + if (outCapacity !== 8) return -22; + const view = new DataView(kernelMemory.buffer, outPtr, outCapacity); + view.setUint32(0, 100, true); + view.setInt32(4, 4, true); + return 1; + }, }, }, tcpListenerTargets: new Map([[8080, [{ @@ -648,7 +661,7 @@ describe("exec host-state transition", () => { }); worker.cleanupTcpListeners(7); expect(close).not.toHaveBeenCalled(); - expect(worker.pickListenerTarget(8080)).toBeNull(); + expect(worker.pickListenerTarget(8080)).toEqual({ pid: 100, fd: 4 }); const childMemory = new WebAssembly.Memory({ initial: 1, diff --git a/host/test/multi-worker.test.ts b/host/test/multi-worker.test.ts index 3985e7acdf..010f634f70 100644 --- a/host/test/multi-worker.test.ts +++ b/host/test/multi-worker.test.ts @@ -662,6 +662,7 @@ describe("CentralizedKernelWorker Process Management", () => { shared: true, }); publishMainForkContinuation(parentMemory, oldChannelOffset); + let selectedListenerPid = parentPid; let finishFork!: (offsets: number[]) => void; const forkLaunch = new Promise((resolve) => { finishFork = resolve; @@ -675,6 +676,22 @@ describe("CentralizedKernelWorker Process Management", () => { _pid: number, fd: number, ) => fd === listenerFd ? 41 : -1, + kernel_pick_tcp_listener_target: ( + _port: number, + _excludePid: number, + outPtr: number, + outCapacity: number, + ) => { + if (outCapacity !== 8) return -22; + const view = new DataView( + harness.kernelMemory.buffer, + outPtr, + outCapacity, + ); + view.setUint32(0, selectedListenerPid, true); + view.setInt32(4, listenerFd, true); + return 1; + }, }, }); const [oldChannel] = harness.worker.testAuthority @@ -707,6 +724,7 @@ describe("CentralizedKernelWorker Process Management", () => { () => onFork.mock.calls.length === 1, "fork worker launch", ); + selectedListenerPid = childPid; // The fork path must install child mirrors before the async worker launch. // Replace the parent generation while that launch is pending, then remove diff --git a/host/test/process-wait-lifecycle.test.ts b/host/test/process-wait-lifecycle.test.ts index b07433103a..3c4e11b55b 100644 --- a/host/test/process-wait-lifecycle.test.ts +++ b/host/test/process-wait-lifecycle.test.ts @@ -2714,6 +2714,42 @@ describe("Rust-owned process wait lifecycle", () => { expect(worker.channelTids.has("10:1024")).toBe(false); expect(worker.threadForkContexts.has("10:1024")).toBe(false); }); + + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s TCP listener target selection uses Rust-owned process policy", + (_name, kernelPtrWidth) => { + const kernelMemory = createSharedMemory(); + const pickTarget = vi.fn(( + _port: number, + _excludePid: number, + outPtr: number | bigint, + outCapacity: number, + ) => { + if (outCapacity !== 8) return -22; + const view = new DataView(kernelMemory.buffer); + view.setUint32(Number(outPtr), 44, true); + view.setInt32(Number(outPtr) + 4, 7, true); + return 1; + }); + const worker = createWorkerHarness({ + kernel_pick_tcp_listener_target: pickTarget, + }, kernelPtrWidth, kernelMemory); + // The host mirror is deliberately contradictory. Process/fd selection + // comes from Rust; JS retains this map only for bridge resources. + worker.tcpListenerTargets = new Map([[8080, [{ pid: 1, fd: 3 }]]]); + + expect(worker.pickListenerTarget(8080)).toEqual({ pid: 44, fd: 7 }); + expect(pickTarget).toHaveBeenCalledWith( + 8080, + 0, + kernelPtrWidth === 8 ? 128n : 128, + 8, + ); + }, + ); }); function createWorkerHarness( diff --git a/host/test/support/kernel-scratch-instance.ts b/host/test/support/kernel-scratch-instance.ts index 957fb68326..f4b0a5e170 100644 --- a/host/test/support/kernel-scratch-instance.ts +++ b/host/test/support/kernel-scratch-instance.ts @@ -465,6 +465,10 @@ function signatures( parameters: [i32, i32], result: i32, }, + kernel_pick_tcp_listener_target: { + parameters: [i32, i32, pointer, i32], + result: i32, + }, kernel_transfer_scratch_begin: { parameters: [pointer], result: i64, From 3604a7c15d95efd1e52d7f1715b58d3c0ac2d7a1 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Thu, 28 May 2026 14:16:41 +0100 Subject: [PATCH 45/82] Timers: Move cleanup identity into Rust Rust now owns the exact alarm and POSIX timer identities consumed during process teardown. The host retires platform handles only after a bounded, transactional handoff. Return ERANGE without partial mutation when scratch cannot hold the full list. Keep an explicit ESRCH fallback for asynchronous post-reap Worker detachment. Forward-ports 5d249bb6d from PR #592. --- abi/snapshot.json | 6 + crates/kernel/src/process.rs | 100 +++++++++ crates/kernel/src/wasm_api.rs | 47 ++++ crates/shared/src/lib.rs | 1 + docs/abi-versioning.md | 11 + docs/architecture.md | 1 + .../2026-05-20-rust-owned-host-logic-plan.md | 2 +- host/src/generated/abi.ts | 1 + host/src/kernel-scratch.ts | 4 + host/src/kernel-worker.ts | 200 ++++++++++++++++-- host/test/kernel-worker-test-scratch.ts | 22 +- host/test/multi-worker.test.ts | 6 +- host/test/process-wait-lifecycle.test.ts | 121 +++++++++++ host/test/readiness-deadline.test.ts | 7 +- host/test/select-signal-outcome.test.ts | 7 +- host/test/signal-accept-livelock.test.ts | 3 + host/test/support/kernel-scratch-instance.ts | 4 + 17 files changed, 526 insertions(+), 17 deletions(-) diff --git a/abi/snapshot.json b/abi/snapshot.json index 106c631fe0..e0f8a5057b 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -1099,6 +1099,7 @@ "kernel_spawn_scratch_capacity", "kernel_spawn_scratch_pointer", "kernel_spawn_scratch_retained_capacity", + "kernel_take_process_timer_cleanup", "kernel_thread_exit", "kernel_thread_has_deliverable", "kernel_transfer_channel_execute", @@ -2929,6 +2930,11 @@ "name": "kernel_sysconf", "signature": "(i32) -> (i64)" }, + { + "kind": "func", + "name": "kernel_take_process_timer_cleanup", + "signature": "(i32,i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_tcgetattr", diff --git a/crates/kernel/src/process.rs b/crates/kernel/src/process.rs index c59160c704..12ed2670ed 100644 --- a/crates/kernel/src/process.rs +++ b/crates/kernel/src/process.rs @@ -442,6 +442,13 @@ pub struct ShmMapping { pub size: usize, } +/// Exact Rust-owned timer identities whose platform handles must be retired. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostTimerCleanup { + pub cancel_alarm: bool, + pub posix_timer_ids: Vec, +} + const MAX_SYSV_SHM_MAPPINGS_PER_PROCESS: usize = 4096; /// Read-only identity of a thread owned by [`crate::process_table::ProcessTable`]. @@ -1392,6 +1399,54 @@ impl Process { core::mem::take(&mut self.shm_mappings) } + /// Drain the complete process-owned host timer identity list when it fits. + /// + /// Timer handles themselves are host primitives, but Rust owns whether an + /// alarm or POSIX timer remains attached to this process. Build the exact + /// batch before mutating either side so allocation or ID conversion failure + /// cannot leave a partially drained cleanup transaction. + pub fn take_host_timer_cleanup( + &mut self, + max_posix_timer_ids: usize, + ) -> Result { + let timer_count = self + .posix_timers + .iter() + .filter(|slot| slot.is_some()) + .count(); + if timer_count > max_posix_timer_ids { + return Err(Errno::ERANGE); + } + let mut posix_timer_ids = Vec::new(); + posix_timer_ids + .try_reserve_exact(timer_count) + .map_err(|_| Errno::ENOMEM)?; + if timer_count != 0 { + for (timer_id, slot) in self.posix_timers.iter().enumerate() { + if slot.is_none() { + continue; + } + posix_timer_ids.push(u32::try_from(timer_id).map_err(|_| Errno::EOVERFLOW)?); + if posix_timer_ids.len() == timer_count { + break; + } + } + } + + let cancel_alarm = self.alarm_deadline_ns != 0 || self.alarm_interval_ns != 0; + self.alarm_deadline_ns = 0; + self.alarm_interval_ns = 0; + for timer_id in &posix_timer_ids { + self.remove_posix_timer_notification(*timer_id); + self.posix_timers[*timer_id as usize] = None; + } + + Ok(HostTimerCleanup { + cancel_alarm, + posix_timer_ids, + }) + } + /// True if `tid` names the process's main thread. The main thread's TID /// equals the process PID (Linux convention) and is not tracked in /// [`Process::threads`]; its blocked mask lives in [`Process::signals`] @@ -2623,6 +2678,51 @@ mod tests { assert_eq!(proc.shm_mapping_at(0x20000), None); } + #[test] + fn host_timer_cleanup_is_bounded_and_transactional() { + let timer = |signo| PosixTimerState { + clock_id: 1, + sigev_signo: signo, + sigev_value_bits: 0, + sigev_notify: 0, + sigev_tid: 0, + interval_sec: 0, + interval_nsec: 0, + value_sec: 1, + value_nsec: 0, + notification_pending: false, + overrun_current: 0, + overrun_last: 0, + }; + let mut proc = Process::new(1); + proc.alarm_deadline_ns = 10; + proc.alarm_interval_ns = 5; + proc.posix_timers.push(Some(timer(14))); + proc.posix_timers.push(None); + proc.posix_timers.push(Some(timer(15))); + + assert_eq!(proc.take_host_timer_cleanup(1), Err(Errno::ERANGE)); + assert_eq!(proc.alarm_deadline_ns, 10); + assert_eq!(proc.alarm_interval_ns, 5); + assert!(proc.posix_timers[0].is_some()); + assert!(proc.posix_timers[2].is_some()); + + let cleanup = proc.take_host_timer_cleanup(2).unwrap(); + assert!(cleanup.cancel_alarm); + assert_eq!(cleanup.posix_timer_ids, alloc::vec![0, 2]); + assert_eq!(proc.alarm_deadline_ns, 0); + assert_eq!(proc.alarm_interval_ns, 0); + assert!(proc.posix_timers.iter().all(Option::is_none)); + + assert_eq!( + proc.take_host_timer_cleanup(1).unwrap(), + HostTimerCleanup { + cancel_alarm: false, + posix_timer_ids: alloc::vec![], + } + ); + } + #[test] fn spawn_child_basic_inherits_cwd_and_returns_pid() { use crate::process_table::ProcessTable; diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index bf25c91b48..0b96058452 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -2018,6 +2018,53 @@ pub extern "C" fn kernel_pick_tcp_listener_target( } } +/// Drain the complete bounded Rust-owned timer identity list for host teardown. +/// +/// Writes `{ u32 cancel_alarm, u32 posix_count, u32 timer_ids[posix_count] }` +/// into the caller's exact scratch capacity. The return value is +/// `posix_count`. If the complete list does not fit, returns `ERANGE` without +/// consuming any timer state. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_take_process_timer_cleanup( + pid: u32, + out_ptr: *mut u8, + out_capacity: u32, +) -> i32 { + const HEADER_BYTES: usize = 8; + const TIMER_ID_BYTES: usize = 4; + + if out_ptr.is_null() { + return -(Errno::EFAULT as i32); + } + let out_capacity = out_capacity as usize; + if out_capacity < HEADER_BYTES + TIMER_ID_BYTES + || (out_capacity - HEADER_BYTES) % TIMER_ID_BYTES != 0 + { + return -(Errno::EINVAL as i32); + } + let max_timer_ids = (out_capacity - HEADER_BYTES) / TIMER_ID_BYTES; + + let _gkl = GklGuard::acquire(); + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let Some(proc) = table.get_mut(pid) else { + return -(Errno::ESRCH as i32); + }; + let cleanup = match proc.take_host_timer_cleanup(max_timer_ids) { + Ok(cleanup) => cleanup, + Err(error) => return -(error as i32), + }; + + let out_len = HEADER_BYTES + cleanup.posix_timer_ids.len() * TIMER_ID_BYTES; + let out = unsafe { core::slice::from_raw_parts_mut(out_ptr, out_len) }; + out[0..4].copy_from_slice(&(cleanup.cancel_alarm as u32).to_le_bytes()); + out[4..8].copy_from_slice(&(cleanup.posix_timer_ids.len() as u32).to_le_bytes()); + for (index, timer_id) in cleanup.posix_timer_ids.iter().enumerate() { + let offset = HEADER_BYTES + index * TIMER_ID_BYTES; + out[offset..offset + TIMER_ID_BYTES].copy_from_slice(&timer_id.to_le_bytes()); + } + cleanup.posix_timer_ids.len() as i32 +} + /// Mark a process as signal-terminated without removing it from the table. /// /// Used by the host when the Worker dies before the guest reaches SYS_EXIT. diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 09157bae66..e9ee3b3a34 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -2929,6 +2929,7 @@ pub mod abi { "kernel_spawn_scratch_capacity", "kernel_spawn_scratch_pointer", "kernel_spawn_scratch_retained_capacity", + "kernel_take_process_timer_cleanup", "kernel_thread_exit", "kernel_thread_has_deliverable", "kernel_transfer_channel_execute", diff --git a/docs/abi-versioning.md b/docs/abi-versioning.md index 1b0b9f15e0..fd35f7692b 100644 --- a/docs/abi-versioning.md +++ b/docs/abi-versioning.md @@ -275,6 +275,17 @@ owns the per-port round-robin cursor. The shared Node/browser host retains only the platform listener objects, stable accept-wakeup identities, and their lifecycle mirrors. +Process teardown in ABI 43 also consumes platform-timer cleanup from Rust via +`kernel_take_process_timer_cleanup(pid, out_ptr, out_capacity)`. Each bounded +little-endian list begins with `{ u32 cancel_alarm, u32 posix_count }` and is +followed by `posix_count` timer IDs. Rust clears exactly those process-owned +identities before a parent can reap the zombie; the shared Node/browser host +uses the detached list only to cancel its `setTimeout`/`setInterval` handles. +An oversized list returns `ERANGE` without consuming any Rust state. The host +may use its remaining handle maps only at that bounded-output fallback or after +Rust reports `ESRCH`, which is the explicit post-reap worker-detachment +boundary. + The pending ABI 43 contract additionally makes the Rust `Process` authoritative for each System V shared-memory attachment's process address, segment id, and size. After the host has materialized an attachment and its byte-coherence diff --git a/docs/architecture.md b/docs/architecture.md index 1bc7428ec3..e70dbe08ce 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,6 +86,7 @@ kernel_commit_process_exit(status) → committed_low_8_bits kernel_dequeue_signal(pid, tid, out_ptr, out_capacity) → 0 | signum | -errno kernel_wait_child_poll(parent_pid, caller_tid, target_pid, event_mask, flags, out_ptr, out_capacity) → child_pid | 0 | -errno kernel_pick_tcp_listener_target(port, exclude_pid, out_ptr, out_capacity) → 1 | 0 | -errno +kernel_take_process_timer_cleanup(pid, out_ptr, out_capacity) → posix_count | -errno kernel_ipc_shmat_for_task(pid, tid, shmid, addr, flags) → segment_size | -errno kernel_ipc_shm_record_mapping_for_task(pid, tid, addr, shmid, size) → 0 | -errno kernel_ipc_shm_lookup_mapping_for_task(pid, tid, addr) → packed_size_and_shmid | -errno diff --git a/docs/plans/2026-05-20-rust-owned-host-logic-plan.md b/docs/plans/2026-05-20-rust-owned-host-logic-plan.md index 4786ae2235..be11071ef3 100644 --- a/docs/plans/2026-05-20-rust-owned-host-logic-plan.md +++ b/docs/plans/2026-05-20-rust-owned-host-logic-plan.md @@ -114,7 +114,7 @@ path. | Done / PR #534 | Rust-owned syscall marshalling descriptors | `crates/shared::host_abi` owns simple pointer-argument descriptors; `dump-abi` generates `SYSCALL_ARGS`; TS host keeps memory copies but reads generated descriptors. | The old TS `SYSCALL_ARGS` table and syscall-number size switches are gone. `poll`/`ppoll`, SysV message prefix, `semop`, and `msgrcv` copy-back adjustments are metadata fields. Nested-pointer syscalls (`readv`/`writev`/preadv/pwritev) stay on dedicated TS paths. | Shared unit tests for descriptor ordering/high-risk sizes/nested-pointer exclusion; xtask ABI tests; `bash scripts/check-abi-version.sh`; generated ABI vitest; host build; kernel lib tests. | | Done / PR #534 follow-up | Extended host-visible syscall numbers and names | Add Rust/shared metadata for ABI-visible syscall numbers still hardcoded in host TS but not currently in `shared::Syscall`, such as `getrandom`, `clone`, `futex`, `ppoll`, `pselect6`, epoll, `exit_group`, `waitid`, `msync`, preadv/pwritev, mqueue, SysV IPC, `sched_yield`, `fallocate`, timers, and `thread_cancel`. Generate TS bindings, logging names, and snapshot coverage. | Host TS no longer defines literal syscall numbers for this set, and syscall trace names are generated from Rust-owned metadata. Existing `HOST_INTERCEPTED_SYSCALLS` remains separate for fork/exec/spawn because those are caught before normal dispatch. Public behavior unchanged. | Rust metadata uniqueness tests; xtask compatibility tests; `bash scripts/check-abi-version.sh update` + check; generated ABI vitest; host build; kernel lib tests. | | Done / stacked PR | Rust-defined host adapter manifest | Add a compact Rust-defined manifest describing ABI version, required host adapter protocol version, required/optional exports, worker protocol features, and channel metadata. JS validates it during kernel boot. | Boot fails earlier with clear errors when the host/kernel contract is incompatible. No Worker creation or Wasm instantiation moves out of JS. | Rust manifest serialization tests; ABI snapshot check; vitest boot validation cases; Node/browser worker-entry smoke if boot code changes. | -| In progress / stacked PR | Process lifecycle cleanup consolidation | Rust `ProcessTable` now owns parent lookup, wait-target matching, wait-status derivation, host-crash zombie marking, authorized child reaping, thread-exit clear-tid metadata, SysV shared-memory attachment metadata, and host-bridged TCP listener target policy. TS keeps blocked waiter queues, Worker/memory cleanup, platform timers, process-memory writes/futex wakeups, and the actual TCP server objects because those are host primitives. Remaining audit: thread channel/Worker allocation and free-list lifecycle plus host timer cancellation. | Kernel owns process lifecycle invariants that do not require Worker identity; JS owns Worker termination, memory objects, crash observation, and platform callbacks. | ProcessTable unit tests; fork/exec/spawn/clone/wait tests; crash/trap tests; browser parity smoke when worker entries change. | +| In progress / stacked PR | Process lifecycle cleanup consolidation | Rust `ProcessTable` now owns parent lookup, wait-target matching, wait-status derivation, host-crash zombie marking, authorized child reaping, thread-exit clear-tid metadata, SysV shared-memory attachment metadata, host-bridged TCP listener target policy, and process-owned host timer cleanup metadata. TS keeps blocked waiter queues, Worker/memory cleanup, platform timer handles, process-memory writes/futex wakeups, and the actual TCP server objects because those are host primitives. Remaining audit: thread channel/Worker allocation and free-list lifecycle. | Kernel owns process lifecycle invariants that do not require Worker identity; JS owns Worker termination, memory objects, crash observation, and platform callbacks. | ProcessTable unit tests; fork/exec/spawn/clone/wait tests; crash/trap tests; browser parity smoke when worker entries change. | | In progress / stacked PR | IPC/resource cleanup in Rust | Rust `Process` now records `shmat` address -> segment metadata, records child attachments during transactional fork materialization, clears them across exec setup, and detaches live mappings from `remove_process()`. TS still copies bytes between guest memory and kernel SysV segments because only the host can address guest `Memory`. | `remove_process()` owns IPC attachment cleanup; JS only handles guest-memory transfer and host primitive wake/schedule work. | SysV IPC and mqueue Rust tests plus host integration/e2e coverage for blocking and cleanup. | | Planned | Readiness metadata improvements | Replace broad host inference with kernel-emitted readiness events for pipe/socket/poll/select cases where the kernel already knows state changes. | JS still owns timers/retry queues/`Atomics.waitAsync`, but readiness decisions are less inferred from syscall numbers. No extra Wasm round trip per syscall. | Pipe/socket/poll/select/ppoll/pselect tests; browser bridge smoke for affected wake paths; performance comparison before removing broad fallback logic. | | Planned | VFS policy split | Keep backend I/O, OPFS/IndexedDB/fetch, Node `fs`, and lazy archive materialization in JS. Move permission and policy decisions into Rust where process uid/gid/umask/fd context is authoritative. | Guest-visible policy is enforced in Rust; host adapters only perform platform operations requested through a checked contract. | VFS unit tests, uid/gid/permission tests, host-fs metadata tests, default mount tests, Node/browser parity tests. | diff --git a/host/src/generated/abi.ts b/host/src/generated/abi.ts index 3dceb041b6..bd099322da 100644 --- a/host/src/generated/abi.ts +++ b/host/src/generated/abi.ts @@ -554,6 +554,7 @@ export const HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS = [ "kernel_spawn_scratch_capacity", "kernel_spawn_scratch_pointer", "kernel_spawn_scratch_retained_capacity", + "kernel_take_process_timer_cleanup", "kernel_thread_exit", "kernel_thread_has_deliverable", "kernel_transfer_channel_execute", diff --git a/host/src/kernel-scratch.ts b/host/src/kernel-scratch.ts index ec3a5b89e5..513e8188a3 100644 --- a/host/src/kernel-scratch.ts +++ b/host/src/kernel-scratch.ts @@ -157,6 +157,7 @@ export const KERNEL_SCRATCH_EXPORT_NAMES = intrinsicObjectFreeze([ "kernel_setsockopt", "kernel_socketpair", "kernel_spawn_process", + "kernel_take_process_timer_cleanup", "kernel_tcgetattr", "kernel_tcsetattr", "kernel_transfer_channel_execute", @@ -232,6 +233,7 @@ export function kernelScratchRequiredPointerArguments( case "kernel_recv": case "kernel_send": case "kernel_set_cwd": + case "kernel_take_process_timer_cleanup": case "kernel_tcgetattr": return REQUIRED_POINTER_1; case "kernel_dequeue_signal": @@ -279,6 +281,7 @@ function kernelScratchPointerAlignment( || (name === "kernel_pick_tcp_listener_target" && pointerIndex === 2) || (name === "kernel_poll" && pointerIndex === 0) || (name === "kernel_socketpair" && pointerIndex === 3) + || (name === "kernel_take_process_timer_cleanup" && pointerIndex === 1) ) { return 4; } @@ -320,6 +323,7 @@ function isKernelScratchExportName( case "kernel_setsockopt": case "kernel_socketpair": case "kernel_spawn_process": + case "kernel_take_process_timer_cleanup": case "kernel_tcgetattr": case "kernel_tcsetattr": case "kernel_transfer_channel_execute": diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index d84e2c2281..fd489dfb6e 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -2379,6 +2379,13 @@ interface TcpListenerCleanupPlan { readonly listenerServersToClose: readonly import("net").Server[]; } +interface ProcessHostTimerCleanupPlan { + readonly cancelAlarm: boolean; + readonly posixTimerIds: readonly number[]; + /** Coherence failure to raise only after every named host handle is retired. */ + readonly mismatch: string | null; +} + /** * Module-private observation seams for the legacy scratch-boundary suite. * @@ -7715,7 +7722,7 @@ export class CentralizedKernelWorker { if (!registration) return true; if (expectedMemory && registration.memory !== expectedMemory) return false; - this.cancelAlarmTimerForProcess(pid); + this.#retireProcessHostTimersWithinKernelEntry(pid, entry, true); this.retireAsyncChannelsForProcess(pid, entry); this.discardStoppedChannelStateForProcess(pid); this.waitingForChild = (this.waitingForChild ?? []).filter( @@ -7822,6 +7829,180 @@ export class CentralizedKernelWorker { this.#cancelRegisteredTimeout(alarmTimer); } + private hostPosixTimerIdsForProcess(pid: number): number[] { + const prefix = `${pid}:`; + const timerIds: number[] = []; + for (const key of this.posixTimers.keys()) { + if (!key.startsWith(prefix)) continue; + const timerId = Number(key.slice(prefix.length)); + if (!Number.isSafeInteger(timerId) || timerId < 0 || timerId > 0xffff_ffff) { + throw new Error(`invalid host POSIX timer identity ${key}`); + } + timerIds.push(timerId); + } + return timerIds; + } + + /** Materialize Rust-owned timer identities before a process can be reaped. */ + #prepareProcessHostTimerCleanupWithinKernelEntry( + pid: number, + entry: KernelWorkerEntryContext, + allowMissingRustProcess: boolean, + ): ProcessHostTimerCleanupPlan { + const scratch = this.#requireMainScratchRegion(); + const headerBytes = 8; + const timerIdBytes = 4; + if (scratch.capacity < headerBytes + timerIdBytes) { + throw new KernelScratchError( + "kernel timer cleanup scratch cannot hold one timer identity", + EIO, + ); + } + const outCapacity = scratch.capacity + - ((scratch.capacity - headerBytes) % timerIdBytes); + const maxTimerIds = (outCapacity - headerBytes) / timerIdBytes; + const output = scratch.withLease((lease) => { + const result = this.#invokeEntryScratchExport( + entry, + lease, + "kernel_take_process_timer_cleanup", + [pid, lease.exportPointer(0, outCapacity), outCapacity], + ); + if (result < 0) return { result, bytes: null }; + if (!Number.isSafeInteger(result) || result > maxTimerIds) { + throw new KernelScratchError( + `kernel returned invalid timer cleanup count ${result}`, + EIO, + ); + } + return { + result, + bytes: lease.copyOut(0, headerBytes + result * timerIdBytes), + }; + }); + + if ( + output.result === -ERANGE + || (output.result === -ESRCH && allowMissingRustProcess) + ) { + // WHY: an oversized list remains wholly Rust-owned, while wait/reap may + // remove a zombie before asynchronous Worker detachment. In either + // explicit boundary, these maps are still exact platform-handle evidence + // and Rust has consumed no partial cleanup list. + return { + cancelAlarm: this.alarmTimers.has(pid), + posixTimerIds: this.hostPosixTimerIdsForProcess(pid), + mismatch: null, + }; + } + if (output.result < 0) { + throw new KernelScratchError( + `kernel process timer cleanup failed: ${output.result}`, + -output.result, + ); + } + const bytes = output.bytes; + if (bytes === null) { + throw new KernelScratchError( + "kernel timer cleanup omitted a successful output", + EIO, + ); + } + const view = new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ); + const headerCount = view.getUint32(4, true); + if (headerCount !== output.result) { + throw new KernelScratchError( + `kernel timer cleanup header count ${headerCount} did not match ` + + `result ${output.result}`, + EIO, + ); + } + const cancelAlarm = view.getUint32(0, true) !== 0; + const rustTimerIds: number[] = []; + const seenTimerIds = new Set(); + for (let index = 0; index < headerCount; index++) { + const timerId = view.getUint32( + headerBytes + index * timerIdBytes, + true, + ); + if (seenTimerIds.has(timerId)) { + throw new KernelScratchError( + `kernel repeated timer cleanup identity ${timerId}`, + EIO, + ); + } + seenTimerIds.add(timerId); + rustTimerIds.push(timerId); + } + + const hostAlarmExists = this.alarmTimers.has(pid); + const extraHostTimerIds = this.hostPosixTimerIdsForProcess(pid) + .filter((timerId) => !seenTimerIds.has(timerId)); + const mismatchParts: string[] = []; + if (hostAlarmExists && !cancelAlarm) { + mismatchParts.push("host alarm had no Rust owner"); + } + if (extraHostTimerIds.length !== 0) { + mismatchParts.push( + `${extraHostTimerIds.length} host POSIX timer(s) had no Rust owner`, + ); + } + return { + cancelAlarm: cancelAlarm || hostAlarmExists, + posixTimerIds: [...rustTimerIds, ...extraHostTimerIds], + mismatch: mismatchParts.length === 0 ? null : mismatchParts.join("; "), + }; + } + + private applyProcessHostTimerCleanup( + pid: number, + plan: ProcessHostTimerCleanupPlan, + ): void { + if (plan.cancelAlarm) this.cancelAlarmTimerForProcess(pid); + for (const timerId of plan.posixTimerIds) { + const key = `${pid}:${timerId}`; + const timer = this.posixTimers.get(key); + if (timer === undefined) continue; + // Delete first so an already-queued callback cannot act on a generation + // whose Rust timer identity has just been consumed. + this.posixTimers.delete(key); + this.#cancelRegisteredTimeout(timer.timeout); + if (timer.interval !== undefined) { + this.#cancelRegisteredInterval(timer.interval); + } + } + if (plan.mismatch !== null) { + throw new Error(`process ${pid} timer ownership mismatch: ${plan.mismatch}`); + } + } + + #retireProcessHostTimersWithinKernelEntry( + pid: number, + entry: KernelWorkerEntryContext, + allowMissingRustProcess = false, + ): void { + const plan = this.#prepareProcessHostTimerCleanupWithinKernelEntry( + pid, + entry, + allowMissingRustProcess, + ); + if ( + !plan.cancelAlarm + && plan.posixTimerIds.length === 0 + && plan.mismatch === null + ) { + return; + } + entry.deferProtocolEffect(() => { + this.applyProcessHostTimerCleanup(pid, plan); + return undefined; + }); + } + deactivateProcess( pid: number, expectedMemory?: WebAssembly.Memory, @@ -7845,16 +8026,7 @@ export class CentralizedKernelWorker { if (deferred) { throw new KernelReentrantEntryError(`process deactivation pid=${pid}`); } - if (!result) return false; - // Cancel any pending posix timers for this process - for (const [key, entry] of this.posixTimers) { - if (key.startsWith(`${pid}:`)) { - clearTimeout(entry.timeout); - if (entry.interval) clearInterval(entry.interval); - this.posixTimers.delete(key); - } - } - return true; + return result; } #deactivateProcessWithinKernelEntry( @@ -7865,7 +8037,7 @@ export class CentralizedKernelWorker { const registration = this.processes.get(pid); if (!registration) return true; if (expectedMemory && registration.memory !== expectedMemory) return false; - this.cancelAlarmTimerForProcess(pid); + this.#retireProcessHostTimersWithinKernelEntry(pid, entry, true); this.retireAsyncChannelsForProcess(pid, entry); this.discardStoppedChannelStateForProcess(pid); this.waitingForChild = (this.waitingForChild ?? []).filter( @@ -23187,6 +23359,7 @@ export class CentralizedKernelWorker { // produce two SIGCHLDs / two parent wake-ups. Cleared by // deactivateProcess and registerProcess. if (!this.hostReaped.has(exitingPid)) { + this.#retireProcessHostTimersWithinKernelEntry(exitingPid, entry); this.hostReaped.add(exitingPid); this.notifyParentOfExitedProcess(exitingPid, entry); } @@ -23249,6 +23422,7 @@ export class CentralizedKernelWorker { // consume and reap the zombie, after which the kernel query returns ESRCH. const signal = this.#getProcessExitSignal(exitingPid, entry); this.#forgetBlockingRetrySnapshotsAfterKernelLifecycle(exitingPid); + this.#retireProcessHostTimersWithinKernelEntry(exitingPid, entry); this.hostReaped.add(exitingPid); this.releaseAllSharedMemoryForProcess(exitingPid, true, entry); // Default signal delivery has already transitioned the Rust Process to @@ -23360,6 +23534,7 @@ export class CentralizedKernelWorker { ); } this.#forgetBlockingRetrySnapshotsAfterKernelLifecycle(pid); + this.#retireProcessHostTimersWithinKernelEntry(pid, entry); this.discardStoppedChannelStateForProcess(pid); this.hostReaped.add(pid); this.releaseAllSharedMemoryForProcess(pid, true, entry); @@ -23475,6 +23650,7 @@ export class CentralizedKernelWorker { if (this.hostReaped.has(pid)) return signal; this.#forgetBlockingRetrySnapshotsAfterKernelLifecycle(pid); + this.#retireProcessHostTimersWithinKernelEntry(pid, entry); this.hostReaped.add(pid); this.releaseAllSharedMemoryForProcess(pid, true, entry); this.notifyParentOfExitedProcess(pid, entry); diff --git a/host/test/kernel-worker-test-scratch.ts b/host/test/kernel-worker-test-scratch.ts index e29fa7c3a2..8a62ccb8f6 100644 --- a/host/test/kernel-worker-test-scratch.ts +++ b/host/test/kernel-worker-test-scratch.ts @@ -22,6 +22,19 @@ interface KernelWorkerTestScratchOptions { readonly kernelExportNames?: readonly string[]; } +/** Neutral Rust-owned timer teardown result for tests without platform timers. */ +export function emptyProcessTimerCleanup( + memory: WebAssembly.Memory, +): (_pid: number, outPointer: number | bigint, outCapacity: number) => number { + return (_pid, outPointer, outCapacity) => { + if (outCapacity < 12 || (outCapacity - 8) % 4 !== 0) return -22; + const output = new DataView(memory.buffer); + output.setUint32(Number(outPointer), 0, true); + output.setUint32(Number(outPointer) + 4, 0, true); + return 0; + }; +} + /** * Install the same gated, capacity-carrying main scratch contract that * worker.init() creates. @@ -58,10 +71,17 @@ export function installKernelWorkerTestScratch( } const gate = options.gate ?? new KernelEntryGate(); const gatedInstance = options.boundInstance ?? (() => { + const kernelExports = { + // Most worker tests do not model platform timers. An empty, bounded + // Rust-owned cleanup record is the neutral production result; timer + // ownership tests override this implementation explicitly. + kernel_take_process_timer_cleanup: emptyProcessTimerCleanup(memory), + ...options.kernelExports, + }; const rawInstance = createKernelScratchTestInstance( pointerWidth, memory, - () => options.kernelExports ?? {}, + () => kernelExports, () => pointerWidth === 8 ? BigInt(pointer) : pointer, 4, options.kernelExportNames, diff --git a/host/test/multi-worker.test.ts b/host/test/multi-worker.test.ts index 010f634f70..7dada4c28e 100644 --- a/host/test/multi-worker.test.ts +++ b/host/test/multi-worker.test.ts @@ -49,7 +49,10 @@ import { PROCESS_STATE_RUNNING, WPK_FORK_LINKED_FRAME_POINTER_WIDTHS, } from "../src/generated/abi"; -import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; +import { + emptyProcessTimerCleanup, + installKernelWorkerTestScratch, +} from "./kernel-worker-test-scratch"; const MAX_PAGES = 1024; // 64 MiB: enough to prove initial < maximum. const WASM32_CONTINUATION_HEADER_SIZE = @@ -152,6 +155,7 @@ function createGatedLifecycleHarness(options: { kernel_remove_process: vi.fn(() => 0), kernel_set_current_tid: vi.fn(() => 0), kernel_set_max_addr: vi.fn(() => 0), + kernel_take_process_timer_cleanup: emptyProcessTimerCleanup(kernelMemory), kernel_thread_exit: vi.fn(() => 0), kernel_validate_task: vi.fn(() => 0), ...(options.kernelExports ?? {}), diff --git a/host/test/process-wait-lifecycle.test.ts b/host/test/process-wait-lifecycle.test.ts index 3c4e11b55b..0a638bb134 100644 --- a/host/test/process-wait-lifecycle.test.ts +++ b/host/test/process-wait-lifecycle.test.ts @@ -11,6 +11,7 @@ import { CH_SIG_SIGNUM, CH_STATUS, CH_SYSCALL, + CH_TOTAL_SIZE, CHANNEL_STATUS_COMPLETE, CHANNEL_STATUS_PENDING, CHANNEL_REQUEST_FLAG_CANCELLATION_POINT, @@ -275,6 +276,43 @@ describe("Rust-owned process wait lifecycle", () => { }, ); + it.each([ + ["the bounded Rust handoff returns ERANGE", -34], + ["Rust has reaped the zombie", -3], + ] as const)("retires exact host timer handles when %s", (_reason, result) => { + const pid = 42; + const memory = createSharedMemory(); + const channel = createChannel(pid, memory); + const takeTimerCleanup = vi.fn(() => result); + const worker = createWorkerHarness({ + kernel_take_process_timer_cleanup: takeTimerCleanup, + }); + worker.waitingForChild = []; + worker.activeChannels = [channel]; + worker.processes = new Map([[pid, { channels: [channel], memory }]]); + worker.execHandoffPids = new Set(); + worker.stdinFinite = new Set(); + worker.stdinBuffers = new Map(); + worker.hostReaped = new Set([pid]); + const alarm = setTimeout(() => {}, 60_000); + const posixTimeout = setTimeout(() => {}, 60_000); + worker.alarmTimers = new Map([[pid, alarm]]); + worker.posixTimers = new Map([ + [`${pid}:7`, { timeout: posixTimeout, signo: 10 }], + ]); + + try { + expect(worker.deactivateProcess(pid)).toBe(true); + + expect(takeTimerCleanup).toHaveBeenCalledOnce(); + expect(worker.alarmTimers.has(pid)).toBe(false); + expect(worker.posixTimers.has(`${pid}:7`)).toBe(false); + } finally { + clearTimeout(alarm); + clearTimeout(posixTimeout); + } + }); + it.each([ ["wasm32", 4], ["wasm64", 8], @@ -2715,6 +2753,78 @@ describe("Rust-owned process wait lifecycle", () => { expect(worker.threadForkContexts.has("10:1024")).toBe(false); }); + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s captures Rust-owned timer cleanup before parent notification", + (_name, kernelPtrWidth) => { + const pid = 42; + const kernelMemory = createSharedMemory(); + const calls: string[] = []; + const takeTimerCleanup = vi.fn(( + _pid: number, + outPtr: number | bigint, + outCapacity: number, + ) => { + calls.push("take-timers"); + const view = new DataView(kernelMemory.buffer); + view.setUint32(Number(outPtr), 1, true); + view.setUint32(Number(outPtr) + 4, 2, true); + view.setUint32(Number(outPtr) + 8, 4, true); + view.setUint32(Number(outPtr) + 12, 9, true); + return outCapacity >= 16 ? 2 : -22; + }); + const worker = createWorkerHarness({ + kernel_get_parent_pid: vi.fn(() => 7), + kernel_has_sa_nocldwait: vi.fn(() => 0), + kernel_mark_process_signaled: vi.fn(() => 0), + kernel_take_process_timer_cleanup: takeTimerCleanup, + }, kernelPtrWidth, kernelMemory); + worker.hostReaped = new Set(); + worker.sharedMappings = new Map(); + const alarm = setTimeout(() => {}, 60_000); + const firstTimeout = setTimeout(() => {}, 60_000); + const secondTimeout = setTimeout(() => {}, 60_000); + const secondInterval = setInterval(() => {}, 60_000); + const otherTimeout = setTimeout(() => {}, 60_000); + worker.alarmTimers = new Map([[pid, alarm]]); + worker.posixTimers = new Map([ + [`${pid}:4`, { timeout: firstTimeout, signo: 10 }], + [`${pid}:9`, { + timeout: secondTimeout, + interval: secondInterval, + signo: 12, + }], + ["99:4", { timeout: otherTimeout, signo: 10 }], + ]); + configureBoundaryHooks(worker, { + sendSignalToProcess: vi.fn(() => calls.push("notify-parent")), + }); + + try { + worker.notifyHostProcessCrashed(pid, 11); + + expect(calls).toEqual(["take-timers", "notify-parent"]); + expect(takeTimerCleanup).toHaveBeenCalledWith( + pid, + kernelPtrWidth === 8 ? 128n : 128, + CH_TOTAL_SIZE, + ); + expect(worker.alarmTimers.has(pid)).toBe(false); + expect(worker.posixTimers.has(`${pid}:4`)).toBe(false); + expect(worker.posixTimers.has(`${pid}:9`)).toBe(false); + expect(worker.posixTimers.has("99:4")).toBe(true); + } finally { + clearTimeout(alarm); + clearTimeout(firstTimeout); + clearTimeout(secondTimeout); + clearInterval(secondInterval); + clearTimeout(otherTimeout); + } + }, + ); + it.each([ ["wasm32", 4], ["wasm64", 8], @@ -2767,6 +2877,17 @@ function createWorkerHarness( kernel_get_process_state: vi.fn(() => PROCESS_STATE_RUNNING), kernel_mark_process_signaled: vi.fn(() => 0), kernel_set_current_tid: vi.fn(() => 0), + kernel_take_process_timer_cleanup: vi.fn(( + _pid: number, + outPtr: number | bigint, + outCapacity: number, + ) => { + if (outCapacity < 12) return -22; + const view = new DataView(kernelMemory.buffer); + view.setUint32(Number(outPtr), 0, true); + view.setUint32(Number(outPtr) + 4, 0, true); + return 0; + }), ...exports, }; const rawInstance = createKernelScratchTestInstance( diff --git a/host/test/readiness-deadline.test.ts b/host/test/readiness-deadline.test.ts index 352c42adbe..0cce6eb74d 100644 --- a/host/test/readiness-deadline.test.ts +++ b/host/test/readiness-deadline.test.ts @@ -25,7 +25,10 @@ import { import { createCentralizedKernelWorkerTestDouble, } from "../src/kernel-worker"; -import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; +import { + emptyProcessTimerCleanup, + installKernelWorkerTestScratch, +} from "./kernel-worker-test-scratch"; import { createKernelScratchTestInstance, } from "./support/kernel-scratch-instance"; @@ -197,6 +200,7 @@ function createHarness( ), kernel_handle_channel: handleChannel, kernel_set_current_tid: setCurrentTid, + kernel_take_process_timer_cleanup: emptyProcessTimerCleanup(kernelMemory), }; const gate = new KernelEntryGate(); const kernelInstance = createKernelEntryGatedInstance( @@ -216,6 +220,7 @@ function createHarness( "kernel_get_fd_pipe_idx", "kernel_handle_channel", "kernel_set_current_tid", + "kernel_take_process_timer_cleanup", ], ), gate, diff --git a/host/test/select-signal-outcome.test.ts b/host/test/select-signal-outcome.test.ts index 7ba5b9db66..4d22f2b19d 100644 --- a/host/test/select-signal-outcome.test.ts +++ b/host/test/select-signal-outcome.test.ts @@ -18,7 +18,10 @@ import { import { createCentralizedKernelWorkerTestDouble, } from "../src/kernel-worker"; -import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; +import { + emptyProcessTimerCleanup, + installKernelWorkerTestScratch, +} from "./kernel-worker-test-scratch"; import { createKernelScratchTestInstance, } from "./support/kernel-scratch-instance"; @@ -123,6 +126,7 @@ function createHarness(options: { kernel_get_process_exit_signal: vi.fn(() => exitSignal), kernel_handle_channel: handleChannel, kernel_set_current_tid: setCurrentTid, + kernel_take_process_timer_cleanup: emptyProcessTimerCleanup(kernelMemory), }; const gate = new KernelEntryGate(); const kernelInstance = createKernelEntryGatedInstance( @@ -141,6 +145,7 @@ function createHarness(options: { "kernel_get_process_exit_signal", "kernel_handle_channel", "kernel_set_current_tid", + "kernel_take_process_timer_cleanup", ], ), gate, diff --git a/host/test/signal-accept-livelock.test.ts b/host/test/signal-accept-livelock.test.ts index 97e97ee686..89401194ee 100644 --- a/host/test/signal-accept-livelock.test.ts +++ b/host/test/signal-accept-livelock.test.ts @@ -38,6 +38,7 @@ import { import { createKernelScratchTestInstance, } from "./support/kernel-scratch-instance"; +import { emptyProcessTimerCleanup } from "./kernel-worker-test-scratch"; const SIGCHLD = 17; const SIGTERM = 15; @@ -117,6 +118,8 @@ function createWorkerHarness( implementations.kernel_get_parent_pid = () => 0; implementations.kernel_dequeue_signal = () => 0; implementations.kernel_drain_wakeup_events = () => 0; + implementations.kernel_take_process_timer_cleanup = + emptyProcessTimerCleanup(kernelMemory); const gate = new KernelEntryGate(); const rawInstance = createKernelScratchTestInstance( diff --git a/host/test/support/kernel-scratch-instance.ts b/host/test/support/kernel-scratch-instance.ts index f4b0a5e170..87a5ffbce1 100644 --- a/host/test/support/kernel-scratch-instance.ts +++ b/host/test/support/kernel-scratch-instance.ts @@ -469,6 +469,10 @@ function signatures( parameters: [i32, i32, pointer, i32], result: i32, }, + kernel_take_process_timer_cleanup: { + parameters: [i32, pointer, i32], + result: i32, + }, kernel_transfer_scratch_begin: { parameters: [pointer], result: i64, From 882eda042f3f8106441580a3d7a4de0376e538f9 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Thu, 28 May 2026 14:25:27 +0100 Subject: [PATCH 46/82] Procfs: Record generated process snapshot schema The accepted ABI 43 scratch-publication work already provides a stricter version of this contract: Rust owns the packed snapshot wire layout, generated bindings carry every offset, and the shared host parser rejects malformed records. Record the completed plan item without introducing a second schema or weakening the current compatibility classifier. Forward-ports the remaining plan update from 8a76a9afd in PR #592. --- docs/plans/2026-05-20-rust-owned-host-logic-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-05-20-rust-owned-host-logic-plan.md b/docs/plans/2026-05-20-rust-owned-host-logic-plan.md index be11071ef3..2b2cd6b142 100644 --- a/docs/plans/2026-05-20-rust-owned-host-logic-plan.md +++ b/docs/plans/2026-05-20-rust-owned-host-logic-plan.md @@ -118,7 +118,7 @@ path. | In progress / stacked PR | IPC/resource cleanup in Rust | Rust `Process` now records `shmat` address -> segment metadata, records child attachments during transactional fork materialization, clears them across exec setup, and detaches live mappings from `remove_process()`. TS still copies bytes between guest memory and kernel SysV segments because only the host can address guest `Memory`. | `remove_process()` owns IPC attachment cleanup; JS only handles guest-memory transfer and host primitive wake/schedule work. | SysV IPC and mqueue Rust tests plus host integration/e2e coverage for blocking and cleanup. | | Planned | Readiness metadata improvements | Replace broad host inference with kernel-emitted readiness events for pipe/socket/poll/select cases where the kernel already knows state changes. | JS still owns timers/retry queues/`Atomics.waitAsync`, but readiness decisions are less inferred from syscall numbers. No extra Wasm round trip per syscall. | Pipe/socket/poll/select/ppoll/pselect tests; browser bridge smoke for affected wake paths; performance comparison before removing broad fallback logic. | | Planned | VFS policy split | Keep backend I/O, OPFS/IndexedDB/fetch, Node `fs`, and lazy archive materialization in JS. Move permission and policy decisions into Rust where process uid/gid/umask/fd context is authoritative. | Guest-visible policy is enforced in Rust; host adapters only perform platform operations requested through a checked contract. | VFS unit tests, uid/gid/permission tests, host-fs metadata tests, default mount tests, Node/browser parity tests. | -| Planned | Procfs/process snapshot schema metadata | Generate binary process snapshot schema/constants consumed by TS UI decoding, or replace TS decoding with a Rust-exported stable formatter if that does not add hot-path cost. | TS no longer hand-decodes undocumented offsets for kernel process snapshot data. Procfs text formatting remains Rust-owned. | Rust procfs/process snapshot tests; generated ABI vitest; UI/kernel-host tests that consume snapshots. | +| Done / ABI 43 batch | Procfs/process snapshot schema metadata | `crates/shared::process_snapshot_wire` owns the packed binary layout, `dump-abi` publishes it in `abi/snapshot.json` and generated TypeScript constants, and `parseProcSnapshots` consumes those offsets with strict bounds checks. | TypeScript no longer hand-decodes undocumented offsets for kernel process snapshot data. Procfs text formatting remains Rust-owned. | Rust process-snapshot wire tests; generated ABI Vitest; host snapshot parsing tests. | Deferral rule: if a chunk would move browser/Node primitives, add runtime JS evaluation, or add a Wasm call to every syscall without removing meaningful From 3f7fbafcfc22b88c0eee450c2836425ad9aa8266 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Thu, 28 May 2026 14:32:25 +0100 Subject: [PATCH 47/82] Readiness: Generate wakeup and multiplexing metadata Make Rust/shared authoritative for the packed wake-event record and all seven readiness, lifecycle, datagram, and advisory-lock reason bits present in the ABI 43 stream. Generate the snapshot and shared Node/browser constants, remove the production TypeScript literals, and require a future ABI bump for any new top-level wake-event wire contract. Forward-ports da2fe3cac from PR #592. --- abi/snapshot.json | 97 +++++++ crates/kernel/src/syscalls.rs | 5 +- crates/kernel/src/wakeup.rs | 27 +- crates/shared/src/lib.rs | 35 ++- docs/abi-versioning.md | 10 + docs/architecture.md | 17 ++ .../2026-05-20-rust-owned-host-logic-plan.md | 2 +- host/src/generated/abi.ts | 31 ++ host/src/kernel-worker.ts | 153 +++++----- host/test/advisory-lock-retry.test.ts | 1 + host/test/generated-abi.test.ts | 54 ++++ host/test/process-wait-lifecycle.test.ts | 6 +- host/test/readiness-wakeup.test.ts | 183 ++++++++++++ tools/xtask/src/dump_abi.rs | 269 +++++++++++++++++- 14 files changed, 801 insertions(+), 89 deletions(-) create mode 100644 host/test/readiness-wakeup.test.ts diff --git a/abi/snapshot.json b/abi/snapshot.json index e0f8a5057b..caca8e742a 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -1150,6 +1150,56 @@ "number": 386 } ], + "io_multiplexing": { + "epoll_events": [ + { + "name": "EPOLLIN", + "value": 1 + }, + { + "name": "EPOLLOUT", + "value": 4 + }, + { + "name": "EPOLLERR", + "value": 8 + }, + { + "name": "EPOLLHUP", + "value": 16 + } + ], + "poll_events": [ + { + "name": "POLLIN", + "value": 1 + }, + { + "name": "POLLPRI", + "value": 2 + }, + { + "name": "POLLOUT", + "value": 4 + }, + { + "name": "POLLERR", + "value": 8 + }, + { + "name": "POLLHUP", + "value": 16 + }, + { + "name": "POLLNVAL", + "value": 32 + } + ], + "select": { + "fd_set_bytes": 128, + "fd_setsize": 1024 + } + }, "ioctl_request_contracts": { "1074025521": { "argKind": "pointer", @@ -9328,5 +9378,52 @@ "WAIT_WUNTRACED": 2, "WAKE_PROCESS_CONTINUED": 32, "WAKE_PROCESS_STOPPED": 16 + }, + "wakeup_event_wire": { + "fields": [ + { + "name": "idx", + "offset": 0, + "size": 4, + "type": "u32" + }, + { + "name": "wakeType", + "offset": 4, + "size": 1, + "type": "u8" + } + ], + "record_size": 5, + "types": [ + { + "bit": 1, + "name": "readable" + }, + { + "bit": 2, + "name": "writable" + }, + { + "bit": 4, + "name": "accept" + }, + { + "bit": 8, + "name": "datagramWritable" + }, + { + "bit": 16, + "name": "processStopped" + }, + { + "bit": 32, + "name": "processContinued" + }, + { + "bit": 64, + "name": "advisoryLock" + } + ] } } diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 4c9222154d..07c64f4ebc 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -14798,10 +14798,7 @@ pub fn sys_epoll_pwait( } // Map EPOLL events to poll events - const EPOLLIN: u32 = 0x001; - const EPOLLOUT: u32 = 0x004; - const EPOLLERR: u32 = 0x008; - const EPOLLHUP: u32 = 0x010; + use wasm_posix_shared::epoll::{EPOLLERR, EPOLLHUP, EPOLLIN, EPOLLOUT}; #[allow(dead_code)] const EPOLLRDHUP: u32 = 0x2000; diff --git a/crates/kernel/src/wakeup.rs b/crates/kernel/src/wakeup.rs index 6de89add77..6dd20d0210 100644 --- a/crates/kernel/src/wakeup.rs +++ b/crates/kernel/src/wakeup.rs @@ -7,15 +7,16 @@ use alloc::vec::Vec; use core::cell::UnsafeCell; +use wasm_posix_shared::wakeup_event_wire; /// Pipe became readable (data was written, or write-end closed). -pub const WAKE_READABLE: u8 = 1; +pub const WAKE_READABLE: u8 = wakeup_event_wire::TYPE_READABLE; /// Pipe became writable (data was read, or read-end closed). -pub const WAKE_WRITABLE: u8 = 2; +pub const WAKE_WRITABLE: u8 = wakeup_event_wire::TYPE_WRITABLE; /// Listener accept queue received a pending connection. -pub const WAKE_ACCEPT: u8 = 4; +pub const WAKE_ACCEPT: u8 = wakeup_event_wire::TYPE_ACCEPT; /// AF_UNIX datagram send readiness or its immediate result changed. /// @@ -23,11 +24,11 @@ pub const WAKE_ACCEPT: u8 = 4; /// host therefore retries untargeted blocked sends and issues a broad /// readiness wake for poll/select/epoll operations when capacity, /// associations, shutdown, close, or pathname state changes. -pub const WAKE_DATAGRAM_WRITABLE: u8 = 8; +pub const WAKE_DATAGRAM_WRITABLE: u8 = wakeup_event_wire::TYPE_DATAGRAM_WRITABLE; /// Advisory-lock state changed in a way that may unblock F_SETLKW waiters. /// The host only reschedules parked channels; lock state remains in Rust. -pub const WAKE_ADVISORY_LOCK: u8 = 64; +pub const WAKE_ADVISORY_LOCK: u8 = wakeup_event_wire::TYPE_ADVISORY_LOCK; /// A readiness change event. #[derive(Debug, Clone, Copy)] @@ -104,7 +105,7 @@ pub fn push_advisory_lock() { /// Drain all pending wakeup events, writing them to the output buffer. /// Returns the number of events written. /// -/// Each event is serialized as: idx (u32 LE) + wake_type (u8) = 5 bytes. +/// Each event uses [`wakeup_event_wire`]: idx (u32 LE) + wake_type (u8). pub fn drain(out: &mut [u8], max_events: u32) -> u32 { #[cfg(test)] { @@ -121,19 +122,17 @@ pub fn drain(out: &mut [u8], max_events: u32) -> u32 { fn drain_events(events: &mut Vec, out: &mut [u8], max_events: u32) -> u32 { let count = events.len().min(max_events as usize); - let bytes_per_event = 5; - let max_by_buf = out.len() / bytes_per_event; + let max_by_buf = out.len() / wakeup_event_wire::RECORD_BYTES; let count = count.min(max_by_buf); for i in 0..count { let ev = &events[i]; - let offset = i * bytes_per_event; + let offset = i * wakeup_event_wire::RECORD_BYTES; let idx_bytes = ev.idx.to_le_bytes(); - out[offset] = idx_bytes[0]; - out[offset + 1] = idx_bytes[1]; - out[offset + 2] = idx_bytes[2]; - out[offset + 3] = idx_bytes[3]; - out[offset + 4] = ev.wake_type; + out[offset + wakeup_event_wire::IDX_OFFSET + ..offset + wakeup_event_wire::IDX_OFFSET + wakeup_event_wire::IDX_BYTES] + .copy_from_slice(&idx_bytes); + out[offset + wakeup_event_wire::TYPE_OFFSET] = ev.wake_type; } // Preserve events that did not fit this host drain. Lifecycle wakeups diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index e9ee3b3a34..3c395a6118 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -190,6 +190,29 @@ pub mod process_snapshot_wire { pub const HEADER_BYTES: usize = CMDLINE_LEN_OFFSET + size_of::(); } +/// Packed kernel/host wire layout for one readiness or lifecycle wake event. +/// +/// The event type is a bitset because one kernel transition may make several +/// host-owned retry classes eligible at once. Keep both producers and the +/// shared Node/browser consumer on these generated values. +pub mod wakeup_event_wire { + use core::mem::size_of; + + pub const IDX_OFFSET: usize = 0; + pub const IDX_BYTES: usize = size_of::(); + pub const TYPE_OFFSET: usize = IDX_OFFSET + IDX_BYTES; + pub const TYPE_BYTES: usize = size_of::(); + pub const RECORD_BYTES: usize = TYPE_OFFSET + TYPE_BYTES; + + pub const TYPE_READABLE: u8 = 1; + pub const TYPE_WRITABLE: u8 = 2; + pub const TYPE_ACCEPT: u8 = 4; + pub const TYPE_DATAGRAM_WRITABLE: u8 = 8; + pub const TYPE_PROCESS_STOPPED: u8 = 16; + pub const TYPE_PROCESS_CONTINUED: u8 = 32; + pub const TYPE_ADVISORY_LOCK: u8 = 64; +} + /// Cross-layer layout values and defensive limits for the non-forking spawn /// protocol. /// @@ -1081,6 +1104,14 @@ pub mod poll { pub const POLLNVAL: i16 = 0x0020; } +/// Epoll event constants. +pub mod epoll { + pub const EPOLLIN: u32 = 0x0001; + pub const EPOLLOUT: u32 = 0x0004; + pub const EPOLLERR: u32 = 0x0008; + pub const EPOLLHUP: u32 = 0x0010; +} + /// Seek whence constants. pub mod seek { pub const SEEK_SET: u32 = 0; @@ -1447,9 +1478,9 @@ pub mod wait { pub const PROCESS_STATE_EXITED: i32 = 2; /// Host retry wake reason: the process entered a stopped state. - pub const WAKE_PROCESS_STOPPED: u8 = 16; + pub const WAKE_PROCESS_STOPPED: u8 = super::wakeup_event_wire::TYPE_PROCESS_STOPPED; /// Host retry wake reason: the process resumed from a stopped state. - pub const WAKE_PROCESS_CONTINUED: u8 = 32; + pub const WAKE_PROCESS_CONTINUED: u8 = super::wakeup_event_wire::TYPE_PROCESS_CONTINUED; } /// Fixed-width kernel/musl resource-usage wire record. diff --git a/docs/abi-versioning.md b/docs/abi-versioning.md index fd35f7692b..856cc109b9 100644 --- a/docs/abi-versioning.md +++ b/docs/abi-versioning.md @@ -286,6 +286,16 @@ may use its remaining handle maps only at that bounded-output fallback or after Rust reports `ESRCH`, which is the explicit post-reap worker-detachment boundary. +ABI 43 publishes the existing kernel wake stream as the generated +`wakeup_event_wire` contract. Each packed record is five bytes: a +little-endian `u32` identity followed by a one-byte reason bitset. Rust owns +the offsets and the readable, writable, accept, datagram-writable, +process-stopped, process-continued, and advisory-lock bits. The shared +Node/browser host decodes the stream only through generated constants before +rescheduling its platform-owned retry queues. This metadata adds no extra +host-to-kernel call; it makes the already-observable stream explicit in the +ABI snapshot. + The pending ABI 43 contract additionally makes the Rust `Process` authoritative for each System V shared-memory attachment's process address, segment id, and size. After the host has materialized an attachment and its byte-coherence diff --git a/docs/architecture.md b/docs/architecture.md index e70dbe08ce..35e53db7cb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -877,6 +877,23 @@ This mechanism is critical: asynchronous scheduling never owns a live scratch view, while Rust retains the resource identity and lifetime needed by the next synchronous entry. +Rust serializes readiness and lifecycle changes through one packed wake-event +stream. `crates/shared::wakeup_event_wire` owns its five-byte record layout and +every reason bit; generated bindings give the shared Node/browser host the +same offsets and values. The host owns a complete copied batch before acting +on any event, because process stop/continue handling can synchronously reenter +kernel operations that reuse scratch. The same generated ABI surface owns the +`poll` and `epoll` event bits and `fd_set` sizing used by host-side readiness +marshalling, so the host does not restate those values. + +For pipe-readable and pipe-writable records, the host first retries ordinary +`poll()` and `ppoll()` channels whose captured kernel pipe index matches the +event. A signal-mask-swapping `ppoll()` remains parked for the existing +signal-safe grace period, and the broad fallback still covers wait classes +without an exact pipe identity, including `select()` and `pselect6()`. +Host-originated pipe bridge notifications use the same target-before-fallback +order in the shared Node.js/browser runtime. + Finite `poll()`/`ppoll()` and `select()`/`pselect6()` waits retain one absolute deadline from their first attempt. Targeted readiness events, broad wakeups, and safety retries use the remaining duration instead of restarting the diff --git a/docs/plans/2026-05-20-rust-owned-host-logic-plan.md b/docs/plans/2026-05-20-rust-owned-host-logic-plan.md index 2b2cd6b142..126a1d5d50 100644 --- a/docs/plans/2026-05-20-rust-owned-host-logic-plan.md +++ b/docs/plans/2026-05-20-rust-owned-host-logic-plan.md @@ -116,7 +116,7 @@ path. | Done / stacked PR | Rust-defined host adapter manifest | Add a compact Rust-defined manifest describing ABI version, required host adapter protocol version, required/optional exports, worker protocol features, and channel metadata. JS validates it during kernel boot. | Boot fails earlier with clear errors when the host/kernel contract is incompatible. No Worker creation or Wasm instantiation moves out of JS. | Rust manifest serialization tests; ABI snapshot check; vitest boot validation cases; Node/browser worker-entry smoke if boot code changes. | | In progress / stacked PR | Process lifecycle cleanup consolidation | Rust `ProcessTable` now owns parent lookup, wait-target matching, wait-status derivation, host-crash zombie marking, authorized child reaping, thread-exit clear-tid metadata, SysV shared-memory attachment metadata, host-bridged TCP listener target policy, and process-owned host timer cleanup metadata. TS keeps blocked waiter queues, Worker/memory cleanup, platform timer handles, process-memory writes/futex wakeups, and the actual TCP server objects because those are host primitives. Remaining audit: thread channel/Worker allocation and free-list lifecycle. | Kernel owns process lifecycle invariants that do not require Worker identity; JS owns Worker termination, memory objects, crash observation, and platform callbacks. | ProcessTable unit tests; fork/exec/spawn/clone/wait tests; crash/trap tests; browser parity smoke when worker entries change. | | In progress / stacked PR | IPC/resource cleanup in Rust | Rust `Process` now records `shmat` address -> segment metadata, records child attachments during transactional fork materialization, clears them across exec setup, and detaches live mappings from `remove_process()`. TS still copies bytes between guest memory and kernel SysV segments because only the host can address guest `Memory`. | `remove_process()` owns IPC attachment cleanup; JS only handles guest-memory transfer and host primitive wake/schedule work. | SysV IPC and mqueue Rust tests plus host integration/e2e coverage for blocking and cleanup. | -| Planned | Readiness metadata improvements | Replace broad host inference with kernel-emitted readiness events for pipe/socket/poll/select cases where the kernel already knows state changes. | JS still owns timers/retry queues/`Atomics.waitAsync`, but readiness decisions are less inferred from syscall numbers. No extra Wasm round trip per syscall. | Pipe/socket/poll/select/ppoll/pselect tests; browser bridge smoke for affected wake paths; performance comparison before removing broad fallback logic. | +| In progress / ABI 43 batch | Readiness metadata improvements | Rust/shared now owns the packed kernel wake-event layout and reason bits, `poll`/`epoll` event bits, and `fd_set` sizing consumed through generated bindings. Pipe-readable and pipe-writable events target matching ordinary `poll`/`ppoll` retries by captured pipe index before the broad fallback; signal-safe `ppoll` remains deferred, and `select`/`pselect6` targeting remains JavaScript-owned. | JavaScript still owns timers, retry queues, `Atomics.waitAsync`, and the broad fallback for wait classes without exact pipe identities. Metadata consumption and targeting add no host-to-kernel call. | Rust metadata and wakeup serialization tests; generated ABI Vitest; pipe/socket/poll/select/ppoll/pselect tests; browser bridge smoke before broader readiness claims. | | Planned | VFS policy split | Keep backend I/O, OPFS/IndexedDB/fetch, Node `fs`, and lazy archive materialization in JS. Move permission and policy decisions into Rust where process uid/gid/umask/fd context is authoritative. | Guest-visible policy is enforced in Rust; host adapters only perform platform operations requested through a checked contract. | VFS unit tests, uid/gid/permission tests, host-fs metadata tests, default mount tests, Node/browser parity tests. | | Done / ABI 43 batch | Procfs/process snapshot schema metadata | `crates/shared::process_snapshot_wire` owns the packed binary layout, `dump-abi` publishes it in `abi/snapshot.json` and generated TypeScript constants, and `parseProcSnapshots` consumes those offsets with strict bounds checks. | TypeScript no longer hand-decodes undocumented offsets for kernel process snapshot data. Procfs text formatting remains Rust-owned. | Rust process-snapshot wire tests; generated ABI Vitest; host snapshot parsing tests. | diff --git a/host/src/generated/abi.ts b/host/src/generated/abi.ts index bd099322da..d077ead9e0 100644 --- a/host/src/generated/abi.ts +++ b/host/src/generated/abi.ts @@ -356,6 +356,37 @@ export const PROCESS_SNAPSHOT_STATE_OFFSET = 24 as const; export const PROCESS_SNAPSHOT_COMM_LEN_OFFSET = 28 as const; export const PROCESS_SNAPSHOT_CMDLINE_LEN_OFFSET = 32 as const; +export const WAKEUP_EVENT_RECORD_BYTES = 5 as const; +export const WAKEUP_EVENT_TYPES = { + readable: 1, + writable: 2, + accept: 4, + datagramWritable: 8, + processStopped: 16, + processContinued: 32, + advisoryLock: 64, +} as const; +export const WAKEUP_EVENT_FIELDS = { + idx: { offset: 0, size: 4, type: "u32" }, + wakeType: { offset: 4, size: 1, type: "u8" }, +} as const; + +export const POLL_EVENTS = { + POLLIN: 1, + POLLPRI: 2, + POLLOUT: 4, + POLLERR: 8, + POLLHUP: 16, + POLLNVAL: 32, +} as const; + +export const EPOLL_EVENTS = { + EPOLLIN: 1, + EPOLLOUT: 4, + EPOLLERR: 8, + EPOLLHUP: 16, +} as const; + export const KERNEL_SCRATCH_SIGNAL_DELIVERY_BYTES = 56 as const; export const KERNEL_SCRATCH_FD_PAIR_BYTES = 8 as const; export const KERNEL_SCRATCH_MQUEUE_NOTIFICATION_BYTES = 8 as const; diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index fd489dfb6e..7ca6d59f9d 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -102,11 +102,13 @@ import { CHANNEL_REQUEST_FLAG_CANCELLATION_POINT, CHANNEL_REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED, CHANNEL_REQUEST_FLAGS_KNOWN_MASK, + EPOLL_EVENTS, FCNTL_FLOCK_BYTES, HOST_INTERCEPTED_SYSCALLS, IOCTL_REQUESTS, PROCESS_MEMORY_PAGES_PER_THREAD_SLOT, PROCESS_MEMORY_THREAD_SLOT_CHANNEL_PRIMARY_PAGE, + POLL_EVENTS, KERNEL_WAIT_RESULT_CHILD_UID_OFFSET, KERNEL_WAIT_RESULT_RUSAGE_OFFSET, KERNEL_WAIT_RESULT_SI_CODE_OFFSET, @@ -252,8 +254,9 @@ import { WAIT_WNOWAIT, WAIT_WSTOPPED, WAIT_WUNTRACED, - WAKE_PROCESS_CONTINUED, - WAKE_PROCESS_STOPPED, + WAKEUP_EVENT_FIELDS, + WAKEUP_EVENT_RECORD_BYTES, + WAKEUP_EVENT_TYPES, type SyscallArgDesc, } from "./generated/abi"; import { validateKernelHostAdapterManifest } from "./host-adapter-manifest"; @@ -3892,13 +3895,13 @@ export class CentralizedKernelWorker { return result; }, drainWakeupEventsForTest: (): void => { - // WHY: advisory-lock tests must exercise the real Rust wake decoder + // WHY: wake-stream tests must exercise the real Rust event decoder // under one exact generation scope. Expose only this argument-free // operation; returning the instance, scratch, or a target-bearing // dispatcher would reopen the authority boundary that the sealed // worker is meant to protect. this.#runImmediateKernelEntry( - "advisory-lock wake drain test", + "kernel wake-event drain test", (entry) => { this.#drainAndProcessWakeupEventsWithinKernelEntry(entry); return undefined; @@ -14498,7 +14501,7 @@ export class CentralizedKernelWorker { pollfds.inputBytes.byteOffset, pollfds.inputBytes.byteLength, ); - const POLLIN = 0x001; + const { POLLIN } = POLL_EVENTS; for (let i = 0; i < nfds; i++) { const pollfdOffset = i * STRUCT_SIZE_WASM_POLL_FD; const fd = pollView.getInt32( @@ -14545,7 +14548,7 @@ export class CentralizedKernelWorker { const key = `${pid}:`; const indices: number[] = []; const acceptIndices: number[] = []; - const EPOLLIN = 0x001; + const { EPOLLIN } = EPOLL_EVENTS; for (const [k, interests] of this.epollInterests) { if (!k.startsWith(key)) continue; for (const interest of interests) { @@ -14582,18 +14585,29 @@ export class CentralizedKernelWorker { } } - private wakeBlockedPoll(pid: number, pipeIdx: number): void { + private wakeBlockedPollRetriesForPipe( + pipeIdx: number, + pidFilter?: number, + options: { deferSignalSafe?: boolean } = {}, + ): boolean { // retrySyscall runs handleSyscall synchronously, which can re-insert // the same key via pendingPollRetries.set when the kernel returns // EAGAIN. JS Map iterators are not snapshots — re-inserted entries // appear at the new tail and the iterator yields them, livelocking - // wakeBlockedPoll-hit / poll / poll-register inside one tick. Mirror + // wakeup-event / poll / poll-register inside one tick. Mirror // wakeAllBlockedRetries' snapshot-and-skip-if-replaced pattern. + let deferredSignalSafeWake = false; const matches = Array.from(this.pendingPollRetries.entries()).filter( - ([, e]) => e.channel.pid === pid && e.pipeIndices.includes(pipeIdx), + ([, entry]) => + (pidFilter === undefined || entry.channel.pid === pidFilter) + && entry.pipeIndices.includes(pipeIdx), ); for (const [key, entry] of matches) { if (this.pendingPollRetries.get(key) !== entry) continue; + if (options.deferSignalSafe && entry.needsSignalSafeWake) { + deferredSignalSafeWake = true; + continue; + } if (entry.timer !== null) { this.#cancelRegisteredTimeout(entry.timer); } @@ -14602,6 +14616,7 @@ export class CentralizedKernelWorker { this.retrySyscall(entry.channel); } } + return deferredSignalSafeWake; } /** @@ -14621,8 +14636,8 @@ export class CentralizedKernelWorker { * * Without step 2, blocked pollers wait for the fallback timer in * `handleBlockingRetry` to fire, which is the bug behind PR fixing - * the WordPress LAMP demo's slow install.php (see commit history). - */ + * the WordPress LAMP demo's slow install.php (see commit history). + */ public notifyPipeReadable(pipeIdx: number, pidFilter?: number): void { this.#runOrDeferKernelEntry( `pipe readable notification index=${pipeIdx}`, @@ -14652,23 +14667,8 @@ export class CentralizedKernelWorker { } } } - // 2. Blocked pollers watching this pipe. Snapshot-and-skip-if-replaced: - // retrySyscall runs synchronously and a re-parking wait re-inserts the - // same exact-channel key, which a raw for..of over the live Map would - // revisit forever (see wakeBlockedPoll / sendSignalToProcess). - const pollMatches = Array.from(this.pendingPollRetries.entries()).filter( - ([, e]) => - (pidFilter === undefined || e.channel.pid === pidFilter) && - e.pipeIndices.includes(pipeIdx), - ); - for (const [key, entry] of pollMatches) { - if (this.pendingPollRetries.get(key) !== entry) continue; - if (entry.timer !== null) this.#cancelRegisteredTimeout(entry.timer); - this.pendingPollRetries.delete(key); - if (this.isRegisteredChannel(entry.channel)) { - this.retrySyscall(entry.channel); - } - } + // 2. Blocked pollers watching this pipe. + this.wakeBlockedPollRetriesForPipe(pipeIdx, pidFilter); // 3. Broad wake for any other pending retries this.scheduleWakeBlockedRetries(entry); } @@ -14677,8 +14677,9 @@ export class CentralizedKernelWorker { * Public wake helper for host-side pipe reads (response pump in * the TCP/HTTP bridges). Call this AFTER directly reading data * from a pipe so any process blocked writing because the pipe was - * full can resume, plus a broad wake. - */ + * full, or polling that pipe for writability, can resume. A broad + * wake still runs for wait classes without pipe identities. + */ public notifyPipeWritable(pipeIdx: number): void { this.#runOrDeferKernelEntry( `pipe writable notification index=${pipeIdx}`, @@ -14702,6 +14703,7 @@ export class CentralizedKernelWorker { } } } + this.wakeBlockedPollRetriesForPipe(pipeIdx); this.scheduleWakeBlockedRetries(entry); } @@ -14755,8 +14757,7 @@ export class CentralizedKernelWorker { if (!drainFn) return; const MAX_EVENTS = 256; - const BYTES_PER_EVENT = 5; - const bufSize = MAX_EVENTS * BYTES_PER_EVENT; + const bufSize = MAX_EVENTS * WAKEUP_EVENT_RECORD_BYTES; // Own the complete batch before acting on any event. STOPPED/CONTINUED // processing can send SIGCHLD and complete a parent wait, both of which @@ -14786,50 +14787,56 @@ export class CentralizedKernelWorker { return { count, bytes: count > 0 - ? lease.copyOut(0, count * BYTES_PER_EVENT) + ? lease.copyOut(0, count * WAKEUP_EVENT_RECORD_BYTES) : new Uint8Array(0), }; }); const { count } = batch; if (count <= 0) break; for (let i = 0; i < count; i++) { - const off = i * BYTES_PER_EVENT; + const off = i * WAKEUP_EVENT_RECORD_BYTES; + const idxOffset = off + WAKEUP_EVENT_FIELDS.idx.offset; events.push({ wakeIdx: - (batch.bytes[off] | - (batch.bytes[off + 1] << 8) | - (batch.bytes[off + 2] << 16) | - (batch.bytes[off + 3] << 24)) >>> + (batch.bytes[idxOffset] | + (batch.bytes[idxOffset + 1] << 8) | + (batch.bytes[idxOffset + 2] << 16) | + (batch.bytes[idxOffset + 3] << 24)) >>> 0, - wakeType: batch.bytes[off + 4], + wakeType: batch.bytes[off + WAKEUP_EVENT_FIELDS.wakeType.offset], }); } if (count < MAX_EVENTS) break; } if (events.length === 0) return; - const WAKE_READABLE = 1; - const WAKE_WRITABLE = 2; - const WAKE_ACCEPT = 4; - const WAKE_DATAGRAM_WRITABLE = 8; - const WAKE_ADVISORY_LOCK = 64; let needBroadWake = false; let needDatagramWriterWake = false; let needAdvisoryLockWake = false; + let needSignalSafeDeferredWake = false; for (const { wakeIdx, wakeType } of events) { const lifecycleEvent = - wakeType & (WAKE_PROCESS_STOPPED | WAKE_PROCESS_CONTINUED); + wakeType & ( + WAKEUP_EVENT_TYPES.processStopped + | WAKEUP_EVENT_TYPES.processContinued + ); const lifecycleSupersededByExit = lifecycleEvent !== 0 && this.finalizeExitedProcessBeforeLifecycleNotification(wakeIdx, entry); - if (!lifecycleSupersededByExit && wakeType & WAKE_PROCESS_STOPPED) { + if ( + !lifecycleSupersededByExit + && wakeType & WAKEUP_EVENT_TYPES.processStopped + ) { (this.stoppedPids ??= new Set()).add(wakeIdx); this.notifyParentOfChildStateTransition(wakeIdx, entry); } - if (!lifecycleSupersededByExit && wakeType & WAKE_PROCESS_CONTINUED) { + if ( + !lifecycleSupersededByExit + && wakeType & WAKEUP_EVENT_TYPES.processContinued + ) { if (this.resumeStoppedProcess(wakeIdx, entry)) { this.notifyParentOfChildStateTransition(wakeIdx, entry); } else { @@ -14841,7 +14848,7 @@ export class CentralizedKernelWorker { } } - if (wakeType & WAKE_READABLE) { + if (wakeType & WAKEUP_EVENT_TYPES.readable) { // Pipe became readable — wake pending readers on this pipe const readers = this.pendingPipeReaders.get(wakeIdx); if (readers && readers.length > 0) { @@ -14854,7 +14861,7 @@ export class CentralizedKernelWorker { } } - if (wakeType & WAKE_WRITABLE) { + if (wakeType & WAKEUP_EVENT_TYPES.writable) { // Pipe became writable — wake pending writers on this pipe const writers = this.pendingPipeWriters.get(wakeIdx); if (writers && writers.length > 0) { @@ -14867,11 +14874,26 @@ export class CentralizedKernelWorker { } } - if (wakeType & WAKE_ACCEPT) { + if ( + wakeType + & (WAKEUP_EVENT_TYPES.readable | WAKEUP_EVENT_TYPES.writable) + ) { + if ( + this.wakeBlockedPollRetriesForPipe( + wakeIdx, + undefined, + { deferSignalSafe: true }, + ) + ) { + needSignalSafeDeferredWake = true; + } + } + + if (wakeType & WAKEUP_EVENT_TYPES.accept) { this.wakeBlockedAccept(wakeIdx); } - if (wakeType & WAKE_DATAGRAM_WRITABLE) { + if (wakeType & WAKEUP_EVENT_TYPES.datagramWritable) { // Datagram queues have no pipe token that identifies every blocked // sender. Retry generic blocked writes synchronously so a short // SO_SNDTIMEO cannot win after the send has become ready or acquired @@ -14881,13 +14903,16 @@ export class CentralizedKernelWorker { needDatagramWriterWake = true; } - if (wakeType & WAKE_ADVISORY_LOCK) { + if (wakeType & WAKEUP_EVENT_TYPES.advisoryLock) { needAdvisoryLockWake = true; } if ( wakeType & - (WAKE_READABLE | WAKE_WRITABLE | WAKE_ACCEPT | WAKE_DATAGRAM_WRITABLE) + (WAKEUP_EVENT_TYPES.readable + | WAKEUP_EVENT_TYPES.writable + | WAKEUP_EVENT_TYPES.accept + | WAKEUP_EVENT_TYPES.datagramWritable) ) { needBroadWake = true; } @@ -14913,10 +14938,9 @@ export class CentralizedKernelWorker { // time to land. Kill-triggered wakes (line ~2050) always use the // immediate setImmediate path — by the time kill has been processed // the signal is already queued, so there's no race. Pipe - // reader/writer wakes above run synchronously (not via this - // deferred path), so plain read/write throughput is unaffected. We - // only pay the delay when a pipe event happens to wake a ppoll or - // pselect6 caller. + // reader/writer and non-signal-safe poll wakes above run synchronously + // (not via this deferred path). Only matching signal-safe ppoll entries + // remain parked for the grace period. if (needDatagramWriterWake) { this.wakeBlockedFallbackWriters(); } @@ -14924,7 +14948,10 @@ export class CentralizedKernelWorker { this.wakeBlockedAdvisoryLockRetries(); } if (needBroadWake) { - if (this.anyPendingRetryNeedsSignalSafeWake()) { + if ( + needSignalSafeDeferredWake + || this.anyPendingRetryNeedsSignalSafeWake() + ) { this.scheduleWakeBlockedRetriesDeferred(entry); } else { this.scheduleWakeBlockedRetries(entry); @@ -18681,14 +18708,8 @@ export class CentralizedKernelWorker { } // EPOLL event flags → poll event flags - const EPOLLIN = 0x001; - const EPOLLOUT = 0x004; - const EPOLLERR = 0x008; - const EPOLLHUP = 0x010; - const POLLIN = 0x001; - const POLLOUT = 0x004; - const POLLERR = 0x008; - const POLLHUP = 0x010; + const { EPOLLIN, EPOLLOUT, EPOLLERR, EPOLLHUP } = EPOLL_EVENTS; + const { POLLIN, POLLOUT, POLLERR, POLLHUP } = POLL_EVENTS; // Build fixed pollfd records in kernel scratch data. const nfds = interests.length; diff --git a/host/test/advisory-lock-retry.test.ts b/host/test/advisory-lock-retry.test.ts index 8191935b72..b3e1c9ea76 100644 --- a/host/test/advisory-lock-retry.test.ts +++ b/host/test/advisory-lock-retry.test.ts @@ -643,6 +643,7 @@ function createWorker( kernel_blocking_retry_token: () => 1n, kernel_dequeue_signal: () => 0, kernel_drain_wakeup_events: () => 0, + kernel_generate_host_signal: () => 0, kernel_get_parent_pid: () => 0, kernel_get_process_exit_signal: () => -1, kernel_get_process_exit_status: () => -1, diff --git a/host/test/generated-abi.test.ts b/host/test/generated-abi.test.ts index bbd17c633e..fa68d13cff 100644 --- a/host/test/generated-abi.test.ts +++ b/host/test/generated-abi.test.ts @@ -35,6 +35,7 @@ import { CH_STATUS, CH_SYSCALL, CH_TOTAL_SIZE, + EPOLL_EVENTS, HOST_ADAPTER_MANIFEST_FIELDS, HOST_ADAPTER_MANIFEST_MAGIC, HOST_ADAPTER_MANIFEST_SIZE, @@ -67,6 +68,7 @@ import { PROCESS_MEMORY_WASM_PAGE_SIZE, PROCESS_METADATA_KIND_ARGV, PROCESS_METADATA_KIND_ENVIRONMENT, + POLL_EVENTS, PROCESS_SNAPSHOT_CMDLINE_LEN_OFFSET, PROCESS_SNAPSHOT_COMM_LEN_OFFSET, PROCESS_SNAPSHOT_COUNT_BYTES, @@ -98,6 +100,11 @@ import { STRUCT_SIZE_WASM_STATFS, STRUCT_SIZE_WASM_TIMESPEC, SYSCALL_ARGS, + SELECT_FD_SET_BYTES, + SELECT_FD_SETSIZE, + WAKEUP_EVENT_FIELDS, + WAKEUP_EVENT_RECORD_BYTES, + WAKEUP_EVENT_TYPES, WASM_DIRENT_INO_OFFSET, WASM_DIRENT_NAME_LENGTH_OFFSET, WASM_DIRENT_TYPE_OFFSET, @@ -199,6 +206,18 @@ function processSnapshotFieldOffset(fieldName: string): number { return field.offset; } +function wakeupEventField( + fieldName: string, +): { offset: number; size: number; type: string } { + const field = snapshot.wakeup_event_wire.fields.find( + (candidate: { name: string }) => candidate.name === fieldName, + ); + if (!field) { + throw new Error(`missing wakeup event field ${fieldName}`); + } + return { offset: field.offset, size: field.size, type: field.type }; +} + function statusNumber(name: string): number { const status = snapshot.channel_status_codes.find((s: { name: string }) => s.name === name); if (!status) throw new Error(`missing channel_status_codes entry ${name}`); @@ -223,6 +242,12 @@ function namedNumberMap(entries: NamedNumber[]): Record { return Object.fromEntries(entries.map(({ name, number }) => [name, number])); } +function namedValueMap( + entries: Array<{ name: string; value: number }>, +): Record { + return Object.fromEntries(entries.map(({ name, value }) => [name, value])); +} + function hostAdapterManifestField(name: string): { offset: number; size: number } { const field = snapshot.host_adapter.manifest_fields.find((f: { name: string }) => f.name === name); if (!field) throw new Error(`missing host_adapter manifest field ${name}`); @@ -465,6 +490,35 @@ describe("generated host ABI bindings", () => { .toBe(processSnapshotFieldOffset("cmdline_len")); }); + it("match the packed wakeup-event wire contract", () => { + expect(WAKEUP_EVENT_RECORD_BYTES) + .toBe(snapshot.wakeup_event_wire.record_size); + expect(WAKEUP_EVENT_FIELDS.idx).toEqual(wakeupEventField("idx")); + expect(WAKEUP_EVENT_FIELDS.wakeType) + .toEqual(wakeupEventField("wakeType")); + expect(Object.entries(WAKEUP_EVENT_TYPES)).toEqual( + snapshot.wakeup_event_wire.types.map( + (eventType: { name: string; bit: number }) => [ + eventType.name, + eventType.bit, + ], + ), + ); + }); + + it("match Rust-owned I/O multiplexing metadata", () => { + expect(POLL_EVENTS).toEqual( + namedValueMap(snapshot.io_multiplexing.poll_events), + ); + expect(EPOLL_EVENTS).toEqual( + namedValueMap(snapshot.io_multiplexing.epoll_events), + ); + expect(SELECT_FD_SETSIZE) + .toBe(snapshot.io_multiplexing.select.fd_setsize); + expect(SELECT_FD_SET_BYTES) + .toBe(snapshot.io_multiplexing.select.fd_set_bytes); + }); + it("match the atomic process-metadata transaction contract", () => { expect(PROCESS_METADATA_KIND_ARGV) .toBe(snapshot.process_metadata_contract.kind_argv); diff --git a/host/test/process-wait-lifecycle.test.ts b/host/test/process-wait-lifecycle.test.ts index 0a638bb134..e01cb6b63a 100644 --- a/host/test/process-wait-lifecycle.test.ts +++ b/host/test/process-wait-lifecycle.test.ts @@ -892,8 +892,10 @@ describe("Rust-owned process wait lifecycle", () => { ); return 1; }); + const generateHostSignal = vi.fn(() => 0); const worker = createWorkerHarness({ kernel_drain_wakeup_events: drain, + kernel_generate_host_signal: generateHostSignal, kernel_get_parent_pid: vi.fn((pid: number) => pid === 42 ? 7 : 0), kernel_get_process_state: vi.fn(() => PROCESS_STATE_RUNNING), kernel_handle_channel: handleChannel, @@ -933,7 +935,8 @@ describe("Rust-owned process wait lifecycle", () => { }); expect(worker.stoppedPids.has(42)).toBe(false); expect(relistenChannel).toHaveBeenCalledWith(parentChannel); - expect(handleChannel).toHaveBeenCalledTimes(2); + expect(handleChannel).toHaveBeenCalledOnce(); + expect(generateHostSignal).toHaveBeenCalledWith(7, SIGCHLD); }); it("does not report CONTINUED when resume preflight stops the process again", () => { @@ -2873,6 +2876,7 @@ function createWorkerHarness( kernel_dequeue_signal: vi.fn(() => 0), kernel_drain_wakeup_events: vi.fn(() => 0), kernel_get_parent_pid: vi.fn(() => 0), + kernel_generate_host_signal: vi.fn(() => 0), kernel_get_process_exit_signal: vi.fn(() => -1), kernel_get_process_state: vi.fn(() => PROCESS_STATE_RUNNING), kernel_mark_process_signaled: vi.fn(() => 0), diff --git a/host/test/readiness-wakeup.test.ts b/host/test/readiness-wakeup.test.ts new file mode 100644 index 0000000000..80553234c2 --- /dev/null +++ b/host/test/readiness-wakeup.test.ts @@ -0,0 +1,183 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + WAKEUP_EVENT_FIELDS, + WAKEUP_EVENT_TYPES, +} from "../src/generated/abi"; +import { + createCentralizedKernelWorkerTestDouble, +} from "../src/kernel-worker"; +import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; + +type TestWorker = ReturnType; +type TestChannel = ReturnType< + TestWorker["testAuthority"]["replaceProcessRegistrationForLifecycleTest"] +>[number]; + +interface PendingPollRetry { + timer: ReturnType | null; + channel: TestChannel; + pipeIndices: number[]; + needsSignalSafeWake?: boolean; + deadline?: number; +} + +interface MutableWorkerState { + pendingPollRetries: Map; +} + +function mutableState(worker: TestWorker): MutableWorkerState { + // Arrange existing inert retry state without exposing or replacing any + // authority-bearing worker method. + return worker as unknown as MutableWorkerState; +} + +function createSharedMemory(): WebAssembly.Memory { + return new WebAssembly.Memory({ + initial: 2, + maximum: 2, + shared: true, + }); +} + +function createWakeHarness( + wakeIdx: number, + wakeType: number, + pids: readonly number[], +): { + channels: TestChannel[]; + retrySyscall: ReturnType; + scheduleWakeBlockedRetries: ReturnType; + state: MutableWorkerState; + worker: TestWorker; +} { + const kernelMemory = new WebAssembly.Memory({ + initial: 2, + maximum: 2, + }); + let drained = false; + const drainWakeupEvents = vi.fn((outPointer: number | bigint): number => { + if (drained) return 0; + drained = true; + const output = new DataView(kernelMemory.buffer); + output.setUint32( + Number(outPointer) + WAKEUP_EVENT_FIELDS.idx.offset, + wakeIdx, + true, + ); + output.setUint8( + Number(outPointer) + WAKEUP_EVENT_FIELDS.wakeType.offset, + wakeType, + ); + return 1; + }); + const worker = createCentralizedKernelWorkerTestDouble(); + installKernelWorkerTestScratch(worker, kernelMemory, 128, 4, { + kernelExports: { + kernel_drain_wakeup_events: drainWakeupEvents, + }, + }); + const channels = pids.map((pid) => { + const [channel] = + worker.testAuthority.replaceProcessRegistrationForLifecycleTest({ + pid, + memory: createSharedMemory(), + channelOffsets: [0], + }); + return channel!; + }); + const retrySyscall = vi.fn(); + const scheduleWakeBlockedRetries = vi.fn(); + worker.testAuthority.configureScratchBoundaryHooksForTest({ + retrySyscall, + scheduleWakeBlockedRetries, + }); + return { + channels, + retrySyscall, + scheduleWakeBlockedRetries, + state: mutableState(worker), + worker, + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("readiness wakeup targeting", () => { + it("retries only poll waiters that watch the kernel-woken pipe", () => { + const harness = createWakeHarness( + 7, + WAKEUP_EVENT_TYPES.readable, + [11, 12], + ); + const [matching, unrelated] = harness.channels; + harness.state.pendingPollRetries.set(matching!, { + timer: null, + channel: matching!, + pipeIndices: [7], + }); + harness.state.pendingPollRetries.set(unrelated!, { + timer: null, + channel: unrelated!, + pipeIndices: [9], + }); + + harness.worker.testAuthority.drainWakeupEventsForTest(); + + expect(harness.retrySyscall).toHaveBeenCalledOnce(); + expect(harness.retrySyscall).toHaveBeenCalledWith(matching); + expect(harness.state.pendingPollRetries.has(matching!)).toBe(false); + expect(harness.state.pendingPollRetries.has(unrelated!)).toBe(true); + expect(harness.scheduleWakeBlockedRetries).toHaveBeenCalledOnce(); + }); + + it("keeps matching signal-safe ppoll deferred while retrying poll", () => { + vi.useFakeTimers(); + const harness = createWakeHarness( + 7, + WAKEUP_EVENT_TYPES.writable, + [11, 12], + ); + const [signalSafe, normal] = harness.channels; + const signalSafeEntry: PendingPollRetry = { + timer: null, + channel: signalSafe!, + pipeIndices: [7], + needsSignalSafeWake: true, + }; + harness.state.pendingPollRetries.set(signalSafe!, signalSafeEntry); + harness.state.pendingPollRetries.set(normal!, { + timer: null, + channel: normal!, + pipeIndices: [7], + }); + + harness.worker.testAuthority.drainWakeupEventsForTest(); + + expect(harness.retrySyscall).toHaveBeenCalledOnce(); + expect(harness.retrySyscall).toHaveBeenCalledWith(normal); + expect(harness.state.pendingPollRetries.get(signalSafe!)) + .toBe(signalSafeEntry); + expect(harness.state.pendingPollRetries.has(normal!)).toBe(false); + expect(signalSafeEntry.timer).not.toBeNull(); + expect(harness.scheduleWakeBlockedRetries).not.toHaveBeenCalled(); + }); + + it("targets writable pollers before a host bridge broad wake", () => { + const harness = createWakeHarness(0, 0, [11]); + const [channel] = harness.channels; + harness.state.pendingPollRetries.set(channel!, { + timer: null, + channel: channel!, + pipeIndices: [7], + }); + + harness.worker.notifyPipeWritable(7); + + expect(harness.retrySyscall).toHaveBeenCalledOnce(); + expect(harness.retrySyscall).toHaveBeenCalledWith(channel); + expect(harness.state.pendingPollRetries.has(channel!)).toBe(false); + expect(harness.scheduleWakeBlockedRetries).toHaveBeenCalledOnce(); + }); +}); diff --git a/tools/xtask/src/dump_abi.rs b/tools/xtask/src/dump_abi.rs index 7b697725e0..47648d55ea 100644 --- a/tools/xtask/src/dump_abi.rs +++ b/tools/xtask/src/dump_abi.rs @@ -14,6 +14,10 @@ //! adapter boot contract metadata //! * [`wasm_posix_shared::host_abi`] — host adapter syscall marshalling //! descriptors +//! * [`wasm_posix_shared::wakeup_event_wire`] — kernel wakeup-event layout +//! and retry/lifecycle reason bits consumed by shared hosts +//! * [`wasm_posix_shared::poll`], [`wasm_posix_shared::epoll`], and +//! [`wasm_posix_shared::select`] — I/O multiplexing event metadata //! //! When `--kernel-wasm ` is provided, the snapshot also covers //! every export in the built kernel `.wasm` (after filtering through @@ -1889,6 +1893,33 @@ fn render_ts_module() -> String { "export const PROCESS_SNAPSHOT_CMDLINE_LEN_OFFSET = {} as const;\n\n", shared::process_snapshot_wire::CMDLINE_LEN_OFFSET )); + out.push_str(&format!( + "export const WAKEUP_EVENT_RECORD_BYTES = {} as const;\n", + shared::wakeup_event_wire::RECORD_BYTES + )); + out.push_str("export const WAKEUP_EVENT_TYPES = {\n"); + for event_type in wakeup_event_types() { + out.push_str(&format!(" {}: {},\n", event_type.name, event_type.bit)); + } + out.push_str("} as const;\n"); + out.push_str("export const WAKEUP_EVENT_FIELDS = {\n"); + for field in wakeup_event_fields() { + out.push_str(&format!( + " {}: {{ offset: {}, size: {}, type: {:?} }},\n", + field.name, field.offset, field.size, field.ty + )); + } + out.push_str("} as const;\n\n"); + out.push_str("export const POLL_EVENTS = {\n"); + for (name, value) in poll_events() { + out.push_str(&format!(" {}: {},\n", name, value)); + } + out.push_str("} as const;\n\n"); + out.push_str("export const EPOLL_EVENTS = {\n"); + for (name, value) in epoll_events() { + out.push_str(&format!(" {}: {},\n", name, value)); + } + out.push_str("} as const;\n\n"); out.push_str(&format!( "export const KERNEL_SCRATCH_SIGNAL_DELIVERY_BYTES = {} as const;\n", shared::kernel_scratch_wire::SIGNAL_DELIVERY_BYTES @@ -3301,6 +3332,8 @@ fn build_snapshot(kernel_wasm: &std::path::Path) -> Result { "process_snapshot_wire".into(), process_snapshot_wire(), ); + root.insert("wakeup_event_wire".into(), wakeup_event_wire()); + root.insert("io_multiplexing".into(), io_multiplexing()); root.insert("spawn_contract".into(), spawn_contract()); root.insert("channel_header".into(), channel_header()); @@ -3391,6 +3424,145 @@ fn process_snapshot_wire() -> Value { }) } +struct WakeupEventField { + name: &'static str, + offset: usize, + size: usize, + ty: &'static str, +} + +fn wakeup_event_fields() -> [WakeupEventField; 2] { + use shared::wakeup_event_wire as wire; + + [ + WakeupEventField { + name: "idx", + offset: wire::IDX_OFFSET, + size: wire::IDX_BYTES, + ty: "u32", + }, + WakeupEventField { + name: "wakeType", + offset: wire::TYPE_OFFSET, + size: wire::TYPE_BYTES, + ty: "u8", + }, + ] +} + +struct WakeupEventType { + name: &'static str, + bit: u8, +} + +fn wakeup_event_types() -> [WakeupEventType; 7] { + use shared::wakeup_event_wire as wire; + + [ + WakeupEventType { + name: "readable", + bit: wire::TYPE_READABLE, + }, + WakeupEventType { + name: "writable", + bit: wire::TYPE_WRITABLE, + }, + WakeupEventType { + name: "accept", + bit: wire::TYPE_ACCEPT, + }, + WakeupEventType { + name: "datagramWritable", + bit: wire::TYPE_DATAGRAM_WRITABLE, + }, + WakeupEventType { + name: "processStopped", + bit: wire::TYPE_PROCESS_STOPPED, + }, + WakeupEventType { + name: "processContinued", + bit: wire::TYPE_PROCESS_CONTINUED, + }, + WakeupEventType { + name: "advisoryLock", + bit: wire::TYPE_ADVISORY_LOCK, + }, + ] +} + +fn wakeup_event_wire() -> Value { + let fields: Vec = wakeup_event_fields() + .iter() + .map(|field| { + json!({ + "name": field.name, + "offset": field.offset, + "size": field.size, + "type": field.ty, + }) + }) + .collect(); + let types: Vec = wakeup_event_types() + .iter() + .map(|event_type| { + json!({ + "name": event_type.name, + "bit": event_type.bit, + }) + }) + .collect(); + + json!({ + "record_size": shared::wakeup_event_wire::RECORD_BYTES, + "fields": fields, + "types": types, + }) +} + +fn poll_events() -> [(&'static str, i16); 6] { + use shared::poll::*; + + [ + ("POLLIN", POLLIN), + ("POLLPRI", POLLPRI), + ("POLLOUT", POLLOUT), + ("POLLERR", POLLERR), + ("POLLHUP", POLLHUP), + ("POLLNVAL", POLLNVAL), + ] +} + +fn epoll_events() -> [(&'static str, u32); 4] { + use shared::epoll::*; + + [ + ("EPOLLIN", EPOLLIN), + ("EPOLLOUT", EPOLLOUT), + ("EPOLLERR", EPOLLERR), + ("EPOLLHUP", EPOLLHUP), + ] +} + +fn io_multiplexing() -> Value { + let poll_events: Vec = poll_events() + .into_iter() + .map(|(name, value)| json!({ "name": name, "value": value })) + .collect(); + let epoll_events: Vec = epoll_events() + .into_iter() + .map(|(name, value)| json!({ "name": name, "value": value })) + .collect(); + + json!({ + "poll_events": poll_events, + "epoll_events": epoll_events, + "select": { + "fd_setsize": shared::select::FD_SETSIZE, + "fd_set_bytes": shared::select::FD_SET_BYTES, + }, + }) +} + fn channel_scalar_contract() -> Value { let syscalls: Vec = shared::channel_scalar::SYSCALLS .iter() @@ -6019,7 +6191,10 @@ fn classify_compat_change(old: &Value, new: &Value) -> Result bool { - matches!(section, "host_adapter" | "syscall_arg_descriptors") + matches!( + section, + "host_adapter" | "io_multiplexing" | "syscall_arg_descriptors" + ) } fn classify_host_adapter( @@ -6249,6 +6424,12 @@ mod tests { assert!( rendered.contains("export const PROCESS_SNAPSHOT_CMDLINE_LEN_OFFSET = 32 as const;") ); + assert!(rendered.contains("export const WAKEUP_EVENT_RECORD_BYTES = 5 as const;")); + assert!(rendered.contains(" processContinued: 32,")); + assert!(rendered.contains(" advisoryLock: 64,")); + assert!( + rendered.contains(" wakeType: { offset: 4, size: 1, type: \"u8\" },") + ); assert!(rendered.contains("export const PR_SET_NAME = 15 as const;")); assert!(rendered.contains("export const PR_GET_NAME = 16 as const;")); assert!(rendered.contains("export const PRCTL_NAME_BYTES = 16 as const;")); @@ -6340,6 +6521,64 @@ mod tests { ); } + #[test] + fn generated_wakeup_event_wire_is_packed_and_complete() { + let wire = wakeup_event_wire(); + assert_eq!(wire["record_size"], json!(5)); + assert_eq!( + wire["fields"], + json!([ + { "name": "idx", "offset": 0, "size": 4, "type": "u32" }, + { "name": "wakeType", "offset": 4, "size": 1, "type": "u8" }, + ]) + ); + assert_eq!( + wire["types"], + json!([ + { "name": "readable", "bit": 1 }, + { "name": "writable", "bit": 2 }, + { "name": "accept", "bit": 4 }, + { "name": "datagramWritable", "bit": 8 }, + { "name": "processStopped", "bit": 16 }, + { "name": "processContinued", "bit": 32 }, + { "name": "advisoryLock", "bit": 64 }, + ]) + ); + } + + #[test] + fn generated_io_multiplexing_metadata_is_complete() { + assert_eq!( + io_multiplexing(), + json!({ + "poll_events": [ + { "name": "POLLIN", "value": 1 }, + { "name": "POLLPRI", "value": 2 }, + { "name": "POLLOUT", "value": 4 }, + { "name": "POLLERR", "value": 8 }, + { "name": "POLLHUP", "value": 16 }, + { "name": "POLLNVAL", "value": 32 }, + ], + "epoll_events": [ + { "name": "EPOLLIN", "value": 1 }, + { "name": "EPOLLOUT", "value": 4 }, + { "name": "EPOLLERR", "value": 8 }, + { "name": "EPOLLHUP", "value": 16 }, + ], + "select": { + "fd_setsize": 1024, + "fd_set_bytes": 128, + }, + }), + ); + + let rendered = render_ts_module(); + assert!(rendered.contains("export const POLL_EVENTS = {")); + assert!(rendered.contains(" POLLNVAL: 32,")); + assert!(rendered.contains("export const EPOLL_EVENTS = {")); + assert!(rendered.contains(" EPOLLHUP: 16,")); + } + #[test] fn generated_process_metadata_contract_keeps_kinds_together() { assert_eq!( @@ -6984,6 +7223,7 @@ mod tests { json!({ "abi_version": 10, "channel_header": {"size": 64}, + "io_multiplexing": io_multiplexing(), "platform_limits": platform_limits(), "process_native_layouts": process_native_layouts(), "spawn_contract": spawn_contract(), @@ -7103,6 +7343,33 @@ mod tests { ); } + #[test] + fn adding_io_multiplexing_section_is_compatible() { + let mut old = base_snapshot(); + old.as_object_mut().unwrap().remove("io_multiplexing"); + let new = base_snapshot(); + + let report = classify_compat_change(&old, &new).unwrap(); + assert!(report.breaking.is_empty(), "{report:?}"); + assert_eq!( + report.additive, + vec!["added top-level section \"io_multiplexing\""] + ); + } + + #[test] + fn adding_wakeup_event_wire_section_requires_an_abi_bump() { + let old = base_snapshot(); + let mut new = old.clone(); + new["wakeup_event_wire"] = wakeup_event_wire(); + + let report = classify_compat_change(&old, &new).unwrap(); + assert_eq!( + report.breaking, + vec!["added top-level section \"wakeup_event_wire\""] + ); + } + #[test] fn adding_wait_contract_section_is_breaking() { let old = base_snapshot(); From 05f0f79013b30b3f0b45d1d6379be8a10a2b0e24 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Thu, 28 May 2026 15:02:27 +0100 Subject: [PATCH 48/82] VFS: Generate ABI-bound host metadata Generate VFS-visible constants from shared Rust sources into the ABI snapshot and TypeScript bindings so Node and browser adapters consume one contract. Forward-port 9c74c48db across the current host layout, including newer process-worker and VFS consumers. Keep standalone OPFS, vendored SharedFS, and Homebrew-specific adapters at documented boundaries. --- abi/snapshot.json | 322 ++++++++++++++++++ crates/kernel/src/syscalls.rs | 3 - crates/shared/src/lib.rs | 10 + docs/architecture.md | 9 + .../2026-05-20-rust-owned-host-logic-plan.md | 2 +- host/src/browser-kernel-host.ts | 4 +- host/src/browser-kernel-worker-entry.ts | 25 +- host/src/generated/abi.ts | 100 ++++++ host/src/kernel-worker.ts | 39 ++- host/src/native-positioned-write.ts | 17 +- host/src/node-kernel-host.ts | 3 +- host/src/node-kernel-worker-entry.ts | 13 +- host/src/pathconf.ts | 13 +- host/src/platform/native-metadata.ts | 42 ++- host/src/vfs/default-mounts.ts | 9 +- host/src/vfs/device-fs.ts | 23 +- host/src/vfs/host-fs.ts | 64 ++-- host/src/vfs/image-helpers.ts | 4 +- host/src/vfs/memory-fs.ts | 44 +-- host/src/vfs/package-deferred-tree.ts | 10 +- host/src/vfs/rootfs-overlay.ts | 11 +- host/src/vfs/tar.ts | 3 +- host/src/vfs/zip.ts | 5 +- host/src/wasi-shim.ts | 65 ++-- host/test/generated-abi.test.ts | 27 ++ host/test/vfs-image-wasm-policy.test.ts | 8 +- tools/xtask/src/dump_abi.rs | 277 ++++++++++++++- 27 files changed, 970 insertions(+), 182 deletions(-) diff --git a/abi/snapshot.json b/abi/snapshot.json index caca8e742a..f413bd0e2f 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -9359,6 +9359,328 @@ "number": 415 } ], + "vfs_metadata": { + "access_modes": [ + { + "name": "F_OK", + "value": 0 + }, + { + "name": "R_OK", + "value": 4 + }, + { + "name": "W_OK", + "value": 2 + }, + { + "name": "X_OK", + "value": 1 + } + ], + "at_flags": [ + { + "name": "AT_FDCWD", + "value": -100 + }, + { + "name": "AT_SYMLINK_NOFOLLOW", + "value": 256 + }, + { + "name": "AT_REMOVEDIR", + "value": 512 + }, + { + "name": "AT_EMPTY_PATH", + "value": 4096 + } + ], + "dirent_types": [ + { + "name": "DT_UNKNOWN", + "value": 0 + }, + { + "name": "DT_FIFO", + "value": 1 + }, + { + "name": "DT_CHR", + "value": 2 + }, + { + "name": "DT_DIR", + "value": 4 + }, + { + "name": "DT_BLK", + "value": 6 + }, + { + "name": "DT_REG", + "value": 8 + }, + { + "name": "DT_LNK", + "value": 10 + }, + { + "name": "DT_SOCK", + "value": 12 + } + ], + "fcntl_commands": [ + { + "name": "F_DUPFD", + "value": 0 + }, + { + "name": "F_GETFD", + "value": 1 + }, + { + "name": "F_SETFD", + "value": 2 + }, + { + "name": "F_GETFL", + "value": 3 + }, + { + "name": "F_SETFL", + "value": 4 + }, + { + "name": "F_GETLK", + "value": 12 + }, + { + "name": "F_SETLK", + "value": 13 + }, + { + "name": "F_SETLKW", + "value": 14 + }, + { + "name": "F_SETOWN", + "value": 8 + }, + { + "name": "F_GETOWN", + "value": 9 + }, + { + "name": "F_DUPFD_CLOEXEC", + "value": 1030 + }, + { + "name": "F_DUPFD_CLOFORK", + "value": 1028 + }, + { + "name": "F_OFD_GETLK", + "value": 36 + }, + { + "name": "F_OFD_SETLK", + "value": 37 + }, + { + "name": "F_OFD_SETLKW", + "value": 38 + } + ], + "fd_flags": [ + { + "name": "FD_CLOEXEC", + "value": 1 + }, + { + "name": "FD_CLOFORK", + "value": 2 + } + ], + "file_modes": [ + { + "name": "S_IFMT", + "value": 61440 + }, + { + "name": "S_IFSOCK", + "value": 49152 + }, + { + "name": "S_IFLNK", + "value": 40960 + }, + { + "name": "S_IFREG", + "value": 32768 + }, + { + "name": "S_IFBLK", + "value": 24576 + }, + { + "name": "S_IFDIR", + "value": 16384 + }, + { + "name": "S_IFCHR", + "value": 8192 + }, + { + "name": "S_IFIFO", + "value": 4096 + }, + { + "name": "S_ISUID", + "value": 2048 + }, + { + "name": "S_ISGID", + "value": 1024 + }, + { + "name": "S_ISVTX", + "value": 512 + }, + { + "name": "S_IRWXU", + "value": 448 + }, + { + "name": "S_IRUSR", + "value": 256 + }, + { + "name": "S_IWUSR", + "value": 128 + }, + { + "name": "S_IXUSR", + "value": 64 + }, + { + "name": "S_IRWXG", + "value": 56 + }, + { + "name": "S_IRGRP", + "value": 32 + }, + { + "name": "S_IWGRP", + "value": 16 + }, + { + "name": "S_IXGRP", + "value": 8 + }, + { + "name": "S_IRWXO", + "value": 7 + }, + { + "name": "S_IROTH", + "value": 4 + }, + { + "name": "S_IWOTH", + "value": 2 + }, + { + "name": "S_IXOTH", + "value": 1 + }, + { + "name": "S_MODE_BITS", + "value": 4095 + } + ], + "open_flags": [ + { + "name": "O_RDONLY", + "value": 0 + }, + { + "name": "O_WRONLY", + "value": 1 + }, + { + "name": "O_RDWR", + "value": 2 + }, + { + "name": "O_ACCMODE", + "value": 3 + }, + { + "name": "O_CREAT", + "value": 64 + }, + { + "name": "O_EXCL", + "value": 128 + }, + { + "name": "O_NOCTTY", + "value": 256 + }, + { + "name": "O_TRUNC", + "value": 512 + }, + { + "name": "O_APPEND", + "value": 1024 + }, + { + "name": "O_NONBLOCK", + "value": 2048 + }, + { + "name": "O_ASYNC", + "value": 8192 + }, + { + "name": "O_DIRECTORY", + "value": 65536 + }, + { + "name": "O_NOFOLLOW", + "value": 131072 + }, + { + "name": "O_CLOEXEC", + "value": 524288 + }, + { + "name": "O_PATH", + "value": 2097152 + }, + { + "name": "O_CLOFORK", + "value": 8388608 + } + ], + "seek_whence": [ + { + "name": "SEEK_SET", + "value": 0 + }, + { + "name": "SEEK_CUR", + "value": 1 + }, + { + "name": "SEEK_END", + "value": 2 + } + ] + }, "wait_contract": { "PROCESS_STATE_EXITED": 2, "PROCESS_STATE_RUNNING": 0, diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 07c64f4ebc..a72334752e 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -38,7 +38,6 @@ const CREATION_FLAGS: u32 = // Linux fstatat/statx flags mirrored by the guest's . const AT_NO_AUTOMOUNT: u32 = 0x800; -const AT_EMPTY_PATH: u32 = 0x1000; const AT_STATX_SYNC_TYPE: u32 = 0x6000; const FSTATAT_VALID_FLAGS: u32 = AT_SYMLINK_NOFOLLOW | AT_NO_AUTOMOUNT | AT_EMPTY_PATH; @@ -8021,8 +8020,6 @@ pub fn sys_execveat( path: &[u8], flags: u32, ) -> Result<(), Errno> { - const AT_EMPTY_PATH: u32 = 0x1000; - if flags & AT_EMPTY_PATH != 0 && path.is_empty() { // fexecve path: exec the file referenced by dirfd let entry = proc.fd_table.get(dirfd)?; diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 3c395a6118..9c2e4f738c 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -915,6 +915,7 @@ pub mod flags { pub const O_ACCMODE: u32 = 3; pub const O_CREAT: u32 = 0o100; pub const O_EXCL: u32 = 0o200; + pub const O_NOCTTY: u32 = 0o400; pub const O_TRUNC: u32 = 0o1000; pub const O_APPEND: u32 = 0o2000; pub const O_NONBLOCK: u32 = 0o4000; @@ -927,6 +928,7 @@ pub mod flags { pub const AT_FDCWD: i32 = -100; pub const AT_SYMLINK_NOFOLLOW: u32 = 0x100; pub const AT_REMOVEDIR: u32 = 0x200; + pub const AT_EMPTY_PATH: u32 = 0x1000; } /// File descriptor flags (FD_*). @@ -1151,6 +1153,11 @@ pub mod mode { pub const S_IFCHR: u32 = 0o020000; pub const S_IFIFO: u32 = 0o010000; + // Special permission bits + pub const S_ISUID: u32 = 0o4000; + pub const S_ISGID: u32 = 0o2000; + pub const S_ISVTX: u32 = 0o1000; + // Owner permissions pub const S_IRWXU: u32 = 0o700; pub const S_IRUSR: u32 = 0o400; @@ -1168,6 +1175,9 @@ pub mod mode { pub const S_IROTH: u32 = 0o004; pub const S_IWOTH: u32 = 0o002; pub const S_IXOTH: u32 = 0o001; + + pub const S_MODE_BITS: u32 = + S_ISUID | S_ISGID | S_ISVTX | S_IRWXU | S_IRWXG | S_IRWXO; } /// Shared-memory channel layout offsets and sizes. diff --git a/docs/architecture.md b/docs/architecture.md index 35e53db7cb..cc5846847a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1537,6 +1537,15 @@ The kernel's hardcoded `INITIAL_BRK` (16MB) is a fallback for binaries that don' `FileSystemBackend` (`host/src/vfs/types.ts`) is the per-mount interface (open/read/write/stat/readdir/symlink/...). Two backends are in use today: +Guest-visible VFS numbers come from `crates/shared` and are recorded under +`vfs_metadata` in `abi/snapshot.json`. The generated +`host/src/generated/abi.ts` bindings supply open and `*at` flags, descriptor +and `fcntl` values, access modes, file modes, directory-entry types, and seek +constants to shared Node/browser host adapters. This records Kandelo's existing +guest ABI; it does not establish a general Linux-compatibility contract. The +standalone OPFS worker and the vendored SharedFS implementation retain local +copies at their explicit entry-point and vendor boundaries. + - **`MemoryFileSystem`** (`vfs/memory-fs.ts`) — SAB-backed in-memory FS. Used for the rootfs image mount and for browser scratch mounts. Honours uid/gid/mode stored on each inode. - **`HostFileSystem`** (`vfs/host-fs.ts`) — proxies a Node host directory. Used for Node scratch mounts. Normalises stat uid/gid to `0/0` so the user's macOS/Linux uid does not leak into the kernel. Native creation receives the requested file/directory mode, but later guest `chmod`/`chown` updates are held in VFS metadata only; the Node host never applies native ownership changes. - **`OpfsFileSystem`** (`vfs/opfs.ts`) — browser-persistent Origin Private File diff --git a/docs/plans/2026-05-20-rust-owned-host-logic-plan.md b/docs/plans/2026-05-20-rust-owned-host-logic-plan.md index 126a1d5d50..f24b40b722 100644 --- a/docs/plans/2026-05-20-rust-owned-host-logic-plan.md +++ b/docs/plans/2026-05-20-rust-owned-host-logic-plan.md @@ -117,7 +117,7 @@ path. | In progress / stacked PR | Process lifecycle cleanup consolidation | Rust `ProcessTable` now owns parent lookup, wait-target matching, wait-status derivation, host-crash zombie marking, authorized child reaping, thread-exit clear-tid metadata, SysV shared-memory attachment metadata, host-bridged TCP listener target policy, and process-owned host timer cleanup metadata. TS keeps blocked waiter queues, Worker/memory cleanup, platform timer handles, process-memory writes/futex wakeups, and the actual TCP server objects because those are host primitives. Remaining audit: thread channel/Worker allocation and free-list lifecycle. | Kernel owns process lifecycle invariants that do not require Worker identity; JS owns Worker termination, memory objects, crash observation, and platform callbacks. | ProcessTable unit tests; fork/exec/spawn/clone/wait tests; crash/trap tests; browser parity smoke when worker entries change. | | In progress / stacked PR | IPC/resource cleanup in Rust | Rust `Process` now records `shmat` address -> segment metadata, records child attachments during transactional fork materialization, clears them across exec setup, and detaches live mappings from `remove_process()`. TS still copies bytes between guest memory and kernel SysV segments because only the host can address guest `Memory`. | `remove_process()` owns IPC attachment cleanup; JS only handles guest-memory transfer and host primitive wake/schedule work. | SysV IPC and mqueue Rust tests plus host integration/e2e coverage for blocking and cleanup. | | In progress / ABI 43 batch | Readiness metadata improvements | Rust/shared now owns the packed kernel wake-event layout and reason bits, `poll`/`epoll` event bits, and `fd_set` sizing consumed through generated bindings. Pipe-readable and pipe-writable events target matching ordinary `poll`/`ppoll` retries by captured pipe index before the broad fallback; signal-safe `ppoll` remains deferred, and `select`/`pselect6` targeting remains JavaScript-owned. | JavaScript still owns timers, retry queues, `Atomics.waitAsync`, and the broad fallback for wait classes without exact pipe identities. Metadata consumption and targeting add no host-to-kernel call. | Rust metadata and wakeup serialization tests; generated ABI Vitest; pipe/socket/poll/select/ppoll/pselect tests; browser bridge smoke before broader readiness claims. | -| Planned | VFS policy split | Keep backend I/O, OPFS/IndexedDB/fetch, Node `fs`, and lazy archive materialization in JS. Move permission and policy decisions into Rust where process uid/gid/umask/fd context is authoritative. | Guest-visible policy is enforced in Rust; host adapters only perform platform operations requested through a checked contract. | VFS unit tests, uid/gid/permission tests, host-fs metadata tests, default mount tests, Node/browser parity tests. | +| In progress / ABI 43 batch | VFS policy split | Rust/shared owns the VFS-visible open and `*at` flags, descriptor and `fcntl` values, access modes, file modes, directory-entry types, and seek constants generated for shared Node/browser host consumers and the WebAssembly System Interface (WASI) shim. Backend I/O, Origin Private File System (OPFS), Node `fs`, and lazy archive materialization remain JavaScript-owned. The standalone OPFS worker and vendored SharedFS keep entry-point/vendor-local copies; Homebrew-specific adapters remain deferred to their dedicated batch. | Generated metadata centralizes Kandelo's existing guest ABI without making a broader Linux-compatibility promise. Kernel uid/gid/umask checks remain authoritative; mount/read-only enforcement still needs an explicit checked contract. | Generated ABI tests and snapshot check; VFS, uid/gid/permission, host-fs metadata, default mount, Node worker, and browser worker tests before broader policy claims. | | Done / ABI 43 batch | Procfs/process snapshot schema metadata | `crates/shared::process_snapshot_wire` owns the packed binary layout, `dump-abi` publishes it in `abi/snapshot.json` and generated TypeScript constants, and `parseProcSnapshots` consumes those offsets with strict bounds checks. | TypeScript no longer hand-decodes undocumented offsets for kernel process snapshot data. Procfs text formatting remains Rust-owned. | Rust process-snapshot wire tests; generated ABI Vitest; host snapshot parsing tests. | Deferral rule: if a chunk would move browser/Node primitives, add runtime JS diff --git a/host/src/browser-kernel-host.ts b/host/src/browser-kernel-host.ts index 744d9adf65..e03addd774 100644 --- a/host/src/browser-kernel-host.ts +++ b/host/src/browser-kernel-host.ts @@ -34,7 +34,7 @@ import { type ClosedLazyAsset, } from "./vfs/closed-lazy-assets"; import { awaitGracefulKernelRealmDestroy } from "./kernel-realm-destroy"; -import type { MountSpec } from "./vfs/default-mounts"; +import { FILE_MODES } from "./generated/abi"; const DESTROY_REQUEST_TIMEOUT_MS = 2_000; const MAX_PENDING_PTY_OUTPUT_BYTES = 64 * 1024; @@ -1149,7 +1149,7 @@ export class BrowserKernel { requestId, path, data: owned, - mode: mode & 0o7777, + mode: mode & FILE_MODES.S_MODE_BITS, }, [owned.buffer]); } diff --git a/host/src/browser-kernel-worker-entry.ts b/host/src/browser-kernel-worker-entry.ts index 6e938a7946..dd48e20f52 100644 --- a/host/src/browser-kernel-worker-entry.ts +++ b/host/src/browser-kernel-worker-entry.ts @@ -81,6 +81,7 @@ import type { } from "./worker-protocol"; import { ThreadPageAllocator } from "./thread-allocator"; import { CH_TOTAL_SIZE, DEFAULT_MAX_PAGES, PAGES_PER_THREAD } from "./constants"; +import { FILE_MODES, OPEN_FLAGS } from "./generated/abi"; import { acquireForkMemoryClone, computeProcessMemoryLayout, @@ -106,6 +107,8 @@ import type { import { kernelRealmDestroyResult } from "./kernel-realm-destroy"; const PAGE_SIZE = 65536; +const O_WRONLY_CREAT_TRUNC = + OPEN_FLAGS.O_WRONLY | OPEN_FLAGS.O_CREAT | OPEN_FLAGS.O_TRUNC; // State let kernelWorker: CentralizedKernelWorker; let workerAdapter: BrowserWorkerAdapter; @@ -1020,7 +1023,11 @@ async function handleInit(msg: Extract) { try { memfs.mkdir(dir, 0o755); } catch { /* exists */ } } const certBytes = new TextEncoder().encode(caCertPem); - const certFd = memfs.open("/etc/ssl/certs/ca-certificates.crt", 0o1101, 0o644); + const certFd = memfs.open( + "/etc/ssl/certs/ca-certificates.crt", + O_WRONLY_CREAT_TRUNC, + 0o644, + ); memfs.write(certFd, certBytes, 0, certBytes.length); memfs.close(certFd); } catch (e) { @@ -2771,7 +2778,7 @@ async function handleReadVfsFile( "read or materialize a rootfs file", ); const { data, stat } = await readPreparedPlatformFile(io, msg.path); - if ((stat.mode & 0o170000) !== 0o100000) { + if ((stat.mode & FILE_MODES.S_IFMT) !== FILE_MODES.S_IFREG) { respond(msg.requestId, null); return; } @@ -2779,7 +2786,9 @@ async function handleReadVfsFile( const result = data.slice(); respond( msg.requestId, - msg.includeMode ? { data: result, mode: stat.mode & 0o7777 } : result, + msg.includeMode + ? { data: result, mode: stat.mode & FILE_MODES.S_MODE_BITS } + : result, ); } catch (error) { if (isMissingPathError(error)) respond(msg.requestId, null); @@ -2798,7 +2807,11 @@ function handleWriteVfsFile(msg: Extract { if (!io) return null; try { const { data, stat } = await readPreparedPlatformFile(io, path); - if ((stat.mode & 0o170000) === 0o040000) return null; + if ((stat.mode & FILE_MODES.S_IFMT) === FILE_MODES.S_IFDIR) return null; return bufferToArrayBuffer(data); } catch (error) { if (isMissingPathError(error)) return null; @@ -2731,7 +2734,7 @@ async function handleReadVfsFile( "read or materialize a rootfs file", ); const { data, stat } = await readPreparedPlatformFile(io, msg.path); - if ((stat.mode & 0o170000) !== 0o100000) { + if ((stat.mode & FILE_MODES.S_IFMT) !== FILE_MODES.S_IFREG) { respond(msg.requestId, null); return; } @@ -2765,8 +2768,8 @@ function handleWriteVfsFile( ); fd = io.open( msg.path, - 0o1101 /* O_WRONLY | O_CREAT | O_TRUNC */, - msg.mode & 0o7777, + O_WRONLY_CREAT_TRUNC, + msg.mode & FILE_MODES.S_MODE_BITS, ); let offset = 0; while (offset < msg.data.byteLength) { @@ -2785,7 +2788,7 @@ function handleWriteVfsFile( fd = null; // open(O_CREAT) preserves an existing file's mode. Apply the caller's // requested mode explicitly so replacement and creation behave alike. - io.chmod(msg.path, msg.mode & 0o7777); + io.chmod(msg.path, msg.mode & FILE_MODES.S_MODE_BITS); respond(msg.requestId, true); } catch (error) { if (fd !== null) { diff --git a/host/src/pathconf.ts b/host/src/pathconf.ts index 875dedfc7f..61aaf580fd 100644 --- a/host/src/pathconf.ts +++ b/host/src/pathconf.ts @@ -1,4 +1,5 @@ import { + FILE_MODES, PATHCONF_NAMES, POSIX_PATH_MAX_BYTES, } from "./generated/abi"; @@ -42,7 +43,7 @@ export function filesystemPathconf( return 1; // the common resolver rejects overlong byte components case PATHCONF_NAMES.ASYNC_IO: // musl implements AIO with guest pthreads over pread/pwrite/fsync. - return (stat.mode & 0o170000) === 0o100000 + return (stat.mode & FILE_MODES.S_IFMT) === FILE_MODES.S_IFREG ? 1 : invalidAssociation(name); case PATHCONF_NAMES.SYNC_IO: @@ -63,13 +64,15 @@ export function filesystemPathconf( case PATHCONF_NAMES.TIMESTAMP_RESOLUTION: return profile.timestampResolutionNs; case PATHCONF_NAMES.PIPE_BUF: { - const fileType = stat.mode & 0o170000; + const fileType = stat.mode & FILE_MODES.S_IFMT; // Named FIFO support and host atomicity are not uniform yet. Preserve // the valid association without fabricating a numeric guarantee. For a // directory the value applies to FIFOs created within that directory. - return fileType === 0o010000 || fileType === 0o040000 - ? null - : invalidAssociation(name); + if ( + fileType === FILE_MODES.S_IFIFO || + fileType === FILE_MODES.S_IFDIR + ) return null; + return invalidAssociation(name); } case PATHCONF_NAMES.MAX_CANON: case PATHCONF_NAMES.MAX_INPUT: diff --git a/host/src/platform/native-metadata.ts b/host/src/platform/native-metadata.ts index 75d596df8d..badf313be1 100644 --- a/host/src/platform/native-metadata.ts +++ b/host/src/platform/native-metadata.ts @@ -1,13 +1,16 @@ import type { BigIntStats } from "node:fs"; +import { ACCESS_MODES, FILE_MODES } from "../generated/abi"; import type { StatResult } from "../types"; -const MODE_CHANGE_MASK = 0o7777; +const MODE_CHANGE_MASK = FILE_MODES.S_MODE_BITS; const UID_GID_UNCHANGED = 0xffffffff; -const SET_ID_BITS = 0o6000; -const EXECUTE_BITS = 0o111; -const X_OK = 0o1; -const W_OK = 0o2; -const R_OK = 0o4; +const SET_ID_BITS = FILE_MODES.S_ISUID | FILE_MODES.S_ISGID; +const EXECUTE_BITS = + FILE_MODES.S_IXUSR | FILE_MODES.S_IXGRP | FILE_MODES.S_IXOTH; +const READABLE_BITS = + FILE_MODES.S_IRUSR | FILE_MODES.S_IRGRP | FILE_MODES.S_IROTH; +const WRITABLE_BITS = + FILE_MODES.S_IWUSR | FILE_MODES.S_IWGRP | FILE_MODES.S_IWOTH; const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER); const MIN_SAFE_INTEGER = BigInt(Number.MIN_SAFE_INTEGER); const NANOSECONDS_PER_MILLISECOND = 1_000_000n; @@ -39,9 +42,9 @@ function checkedMilliseconds(valueNs: bigint, field: string): number { return Number(wholeMilliseconds) + Number(fractionalMilliseconds) / 1_000_000; } -const S_IFMT = 0o170000; -const S_IFDIR = 0o040000; -const S_IFLNK = 0o120000; +const S_IFMT = FILE_MODES.S_IFMT; +const S_IFDIR = FILE_MODES.S_IFDIR; +const S_IFLNK = FILE_MODES.S_IFLNK; /** * Windows has no POSIX permission model: `fs.statSync` reports every entry as @@ -194,9 +197,24 @@ export class NativeMetadataOverlay { access(s: BigIntStats, amode: number): void { const mode = this.toStatResult(s).mode; - if ((amode & R_OK) !== 0 && (mode & 0o444) === 0) throw new Error("EACCES"); - if ((amode & W_OK) !== 0 && (mode & 0o222) === 0) throw new Error("EACCES"); - if ((amode & X_OK) !== 0 && (mode & 0o111) === 0) throw new Error("EACCES"); + if ( + (amode & ACCESS_MODES.R_OK) !== 0 && + (mode & READABLE_BITS) === 0 + ) { + throw new Error("EACCES"); + } + if ( + (amode & ACCESS_MODES.W_OK) !== 0 && + (mode & WRITABLE_BITS) === 0 + ) { + throw new Error("EACCES"); + } + if ( + (amode & ACCESS_MODES.X_OK) !== 0 && + (mode & EXECUTE_BITS) === 0 + ) { + throw new Error("EACCES"); + } } private metadataFor(s: BigIntStats): VirtualMetadata { diff --git a/host/src/vfs/default-mounts.ts b/host/src/vfs/default-mounts.ts index 052eef4e75..a41263ba02 100644 --- a/host/src/vfs/default-mounts.ts +++ b/host/src/vfs/default-mounts.ts @@ -12,11 +12,12 @@ */ import type { MountConfig } from "./types"; +import { FILE_MODES, OPEN_FLAGS } from "../generated/abi"; import { MemoryFileSystem } from "./memory-fs"; import { restoreVerifiedVfsImage } from "./load-image"; -const S_IFMT = 0xf000; -const S_IFDIR = 0x4000; +const O_WRONLY_CREAT_TRUNC = + OPEN_FLAGS.O_WRONLY | OPEN_FLAGS.O_CREAT | OPEN_FLAGS.O_TRUNC; export interface MountSpec { /** Absolute VFS mount point (e.g., "/etc"). No trailing slash except "/". */ @@ -98,7 +99,7 @@ function readTextFile(fs: MemoryFileSystem, path: string): string | null { function writeTextFile(fs: MemoryFileSystem, path: string, text: string): void { const bytes = new TextEncoder().encode(text); - const fd = fs.open(path, 0o1101, 0o644); // O_WRONLY | O_CREAT | O_TRUNC + const fd = fs.open(path, O_WRONLY_CREAT_TRUNC, 0o644); try { if (bytes.byteLength > 0) fs.write(fd, bytes, null, bytes.byteLength); } finally { @@ -121,7 +122,7 @@ function normalizeMountPoint(path: string): string { } function isDirectoryMode(mode: number): boolean { - return (mode & S_IFMT) === S_IFDIR; + return (mode & FILE_MODES.S_IFMT) === FILE_MODES.S_IFDIR; } /** diff --git a/host/src/vfs/device-fs.ts b/host/src/vfs/device-fs.ts index 66d6114212..fe5d22a8ca 100644 --- a/host/src/vfs/device-fs.ts +++ b/host/src/vfs/device-fs.ts @@ -7,11 +7,12 @@ import type { } from "../types"; import { checkedHostFileOffset } from "../file-offset"; import { filesystemPathconf } from "../pathconf"; +import { DIRENT_TYPES, FILE_MODES } from "../generated/abi"; import type { FileSystemBackend, DirEntry } from "./types"; import { DEVFS_SUPER_MAGIC, zeroCapacityStatfs } from "../statfs"; -const S_IFCHR = 0o020000; -const S_IFDIR = 0o040000; +const { DT_CHR, DT_DIR, DT_LNK } = DIRENT_TYPES; +const { S_IFCHR, S_IFDIR } = FILE_MODES; type DeviceReader = (buffer: Uint8Array, length: number) => number; type DeviceWriter = (buffer: Uint8Array, length: number) => number; @@ -72,12 +73,12 @@ const SUBDIRS = ["pts", "shm", "mqueue"]; /** Extra entries to list in /dev readdir (kernel-managed, not in devices map). */ const EXTRA_ENTRIES: DirEntry[] = [ - { name: "ptmx", type: 2 /* DT_CHR */, ino: 0x100 }, - { name: "pts", type: 4 /* DT_DIR */, ino: 0x101 }, - { name: "fd", type: 10 /* DT_LNK */, ino: 0x102 }, - { name: "stdin", type: 10 /* DT_LNK */, ino: 0x103 }, - { name: "stdout", type: 10 /* DT_LNK */, ino: 0x104 }, - { name: "stderr", type: 10 /* DT_LNK */, ino: 0x105 }, + { name: "ptmx", type: DT_CHR, ino: 0x100 }, + { name: "pts", type: DT_DIR, ino: 0x101 }, + { name: "fd", type: DT_LNK, ino: 0x102 }, + { name: "stdin", type: DT_LNK, ino: 0x103 }, + { name: "stdout", type: DT_LNK, ino: 0x104 }, + { name: "stderr", type: DT_LNK, ino: 0x105 }, ]; function isRootPath(path: string): boolean { @@ -305,7 +306,11 @@ export class DeviceFileSystem implements FileSystemBackend { if (isRootPath(path)) { // /dev root: device nodes + extra kernel-managed entries entries = [ - ...this.deviceNames.map((n, i) => ({ name: n, type: 2 /* DT_CHR */, ino: i + 1 })), + ...this.deviceNames.map((n, i) => ({ + name: n, + type: DT_CHR, + ino: i + 1, + })), ...EXTRA_ENTRIES.filter(e => !this.devices.has(e.name)), ]; } else if (SUBDIRS.includes(name)) { diff --git a/host/src/vfs/host-fs.ts b/host/src/vfs/host-fs.ts index 03c89b4275..6a6d911c1c 100644 --- a/host/src/vfs/host-fs.ts +++ b/host/src/vfs/host-fs.ts @@ -32,6 +32,11 @@ import { } from "../native-positioned-write"; import { NativeMetadataOverlay } from "../platform/native-metadata"; import { filesystemPathconf } from "../pathconf"; +import { + DIRENT_TYPES, + OPEN_FLAGS, + SEEK_WHENCE, +} from "../generated/abi"; import type { FileSystemBackend, DirEntry } from "./types"; import { DEFAULT_STATFS_BLOCK_SIZE, DEFAULT_STATFS_NAMELEN } from "../statfs"; @@ -77,35 +82,23 @@ export function createSessionOwnedHostFileSystem( * The numeric values differ between Linux and macOS/BSD. */ export function translateOpenFlags(linuxFlags: number): number { - // Linux flag constants (octal) - const L_O_WRONLY = 0o1; - const L_O_RDWR = 0o2; - const L_O_CREAT = 0o100; - const L_O_EXCL = 0o200; - const L_O_NOCTTY = 0o400; - const L_O_TRUNC = 0o1000; - const L_O_APPEND = 0o2000; - const L_O_NONBLOCK = 0o4000; - const L_O_DIRECTORY = 0o200000; - const L_O_NOFOLLOW = 0o400000; - let native = 0; // Access mode (bottom 2 bits) - if (linuxFlags & L_O_RDWR) native |= fs.constants.O_RDWR; - else if (linuxFlags & L_O_WRONLY) native |= fs.constants.O_WRONLY; + if (linuxFlags & OPEN_FLAGS.O_RDWR) native |= fs.constants.O_RDWR; + else if (linuxFlags & OPEN_FLAGS.O_WRONLY) native |= fs.constants.O_WRONLY; // else O_RDONLY = 0 - if (linuxFlags & L_O_CREAT) native |= fs.constants.O_CREAT; - if (linuxFlags & L_O_EXCL) native |= fs.constants.O_EXCL; - if (linuxFlags & L_O_TRUNC) native |= fs.constants.O_TRUNC; - if (linuxFlags & L_O_APPEND) native |= fs.constants.O_APPEND; - if (linuxFlags & L_O_NONBLOCK) native |= fs.constants.O_NONBLOCK; - if (linuxFlags & L_O_DIRECTORY && fs.constants.O_DIRECTORY) + if (linuxFlags & OPEN_FLAGS.O_CREAT) native |= fs.constants.O_CREAT; + if (linuxFlags & OPEN_FLAGS.O_EXCL) native |= fs.constants.O_EXCL; + if (linuxFlags & OPEN_FLAGS.O_TRUNC) native |= fs.constants.O_TRUNC; + if (linuxFlags & OPEN_FLAGS.O_APPEND) native |= fs.constants.O_APPEND; + if (linuxFlags & OPEN_FLAGS.O_NONBLOCK) native |= fs.constants.O_NONBLOCK; + if (linuxFlags & OPEN_FLAGS.O_DIRECTORY && fs.constants.O_DIRECTORY) native |= fs.constants.O_DIRECTORY; - if (linuxFlags & L_O_NOFOLLOW && fs.constants.O_NOFOLLOW) + if (linuxFlags & OPEN_FLAGS.O_NOFOLLOW && fs.constants.O_NOFOLLOW) native |= fs.constants.O_NOFOLLOW; - if (linuxFlags & L_O_NOCTTY && fs.constants.O_NOCTTY) + if (linuxFlags & OPEN_FLAGS.O_NOCTTY && fs.constants.O_NOCTTY) native |= fs.constants.O_NOCTTY; // O_LARGEFILE and O_CLOEXEC have no Node.js equivalent; ignored. @@ -312,8 +305,9 @@ export class HostFileSystem implements FileSystemBackend { open(path: string, flags: number, mode: number): number { const noFollowFinal = - (flags & 0o400000) !== 0 || - ((flags & 0o100) !== 0 && (flags & 0o200) !== 0); + (flags & OPEN_FLAGS.O_NOFOLLOW) !== 0 || + ((flags & OPEN_FLAGS.O_CREAT) !== 0 && + (flags & OPEN_FLAGS.O_EXCL) !== 0); const nativePath = this.safePath(path, !noFollowFinal); const { fd, created } = openNativeBackingFile( nativePath, @@ -507,13 +501,13 @@ export class HostFileSystem implements FileSystemBackend { ): HostFileOffset { let newPos: HostFileOffset; switch (whence) { - case 0: // SEEK_SET + case SEEK_WHENCE.SEEK_SET: newPos = checkedSeekPosition(0, offset); break; - case 1: // SEEK_CUR + case SEEK_WHENCE.SEEK_CUR: newPos = checkedSeekPosition(this.fdPositions.get(handle) ?? 0, offset); break; - case 2: // SEEK_END + case SEEK_WHENCE.SEEK_END: newPos = checkedSeekPosition( hostFileOffsetFromBigInt( fs.fstatSync(handle, { bigint: true }).size, @@ -725,20 +719,20 @@ export class HostFileSystem implements FileSystemBackend { const entry = dir.readSync(); if (!entry) return null; - let dtype = 0; // DT_UNKNOWN + let dtype: number = DIRENT_TYPES.DT_UNKNOWN; if (entry.isFile()) - dtype = 8; // DT_REG + dtype = DIRENT_TYPES.DT_REG; else if (entry.isDirectory()) - dtype = 4; // DT_DIR + dtype = DIRENT_TYPES.DT_DIR; else if (entry.isSymbolicLink()) - dtype = 10; // DT_LNK + dtype = DIRENT_TYPES.DT_LNK; else if (entry.isFIFO()) - dtype = 1; // DT_FIFO + dtype = DIRENT_TYPES.DT_FIFO; else if (entry.isSocket()) - dtype = 12; // DT_SOCK + dtype = DIRENT_TYPES.DT_SOCK; else if (entry.isCharacterDevice()) - dtype = 2; // DT_CHR - else if (entry.isBlockDevice()) dtype = 6; // DT_BLK + dtype = DIRENT_TYPES.DT_CHR; + else if (entry.isBlockDevice()) dtype = DIRENT_TYPES.DT_BLK; return { name: entry.name, type: dtype, ino: 0 }; } diff --git a/host/src/vfs/image-helpers.ts b/host/src/vfs/image-helpers.ts index 672405a33e..7c68262eb6 100644 --- a/host/src/vfs/image-helpers.ts +++ b/host/src/vfs/image-helpers.ts @@ -6,10 +6,12 @@ * For host-disk-aware utilities (walking a directory, saving to a file), * see scripts-side helpers. */ +import { OPEN_FLAGS } from "../generated/abi"; import type { MemoryFileSystem } from "./memory-fs"; import { EEXIST } from "./sharedfs-vendor"; -const O_WRONLY_CREAT_TRUNC = 0o1101; +const O_WRONLY_CREAT_TRUNC = + OPEN_FLAGS.O_WRONLY | OPEN_FLAGS.O_CREAT | OPEN_FLAGS.O_TRUNC; /** Write text content to a path in the memfs. Creates parent dirs implicitly via writeVfsBinary. */ export function writeVfsFile( diff --git a/host/src/vfs/memory-fs.ts b/host/src/vfs/memory-fs.ts index 6553b4ce5d..e921b36ed8 100644 --- a/host/src/vfs/memory-fs.ts +++ b/host/src/vfs/memory-fs.ts @@ -13,6 +13,7 @@ import { } from "../file-offset"; import { filesystemPathconf } from "../pathconf"; import { SFFS_SUPER_MAGIC } from "../statfs"; +import { DIRENT_TYPES, FILE_MODES, OPEN_FLAGS } from "../generated/abi"; import type { FileSystemBackend, DirEntry } from "./types"; import { O_CREAT, @@ -402,12 +403,10 @@ const VFS_IMAGE_FLAG_HAS_LAZY_ARCHIVES = 1 << 1; const VFS_IMAGE_FLAG_HAS_METADATA = 1 << 2; const VFS_IMAGE_FLAG_HAS_TYPED_LAZY_ARCHIVES = 1 << 3; const VFS_IMAGE_HEADER_SIZE = 16; // magic(4) + version(4) + flags(4) + sabLen(4) -const S_IFMT = 0xf000; -const S_IFREG = 0x8000; -const S_IFDIR = 0x4000; -const S_IFLNK = 0xa000; -const O_RDONLY = 0x0000; -const O_WRONLY_CREAT_TRUNC = 0o1101; +const { S_IFMT, S_IFREG, S_IFDIR, S_IFLNK } = FILE_MODES; +const O_RDONLY = OPEN_FLAGS.O_RDONLY; +const O_WRONLY_CREAT_TRUNC = + OPEN_FLAGS.O_WRONLY | OPEN_FLAGS.O_CREAT | OPEN_FLAGS.O_TRUNC; const COPY_CHUNK_BYTES = 1024 * 1024; const MIN_REBASE_INITIAL_BYTES = 16 * 1024 * 1024; const VFS_IMAGE_MAX_METADATA_BYTES = 64 * 1024; @@ -1416,7 +1415,7 @@ function validateLazyTreeSourceInventory( entry.mode, `Lazy tree source entry ${sourcePath} mode`, 0, - 0o7777, + FILE_MODES.S_MODE_BITS, ); const size = requireLazyTreeInteger( entry.size, @@ -1794,7 +1793,7 @@ function validateLazyTreeDefinition( record.mode, `Lazy tree entry ${vfsPath} mode`, 0, - 0o7777, + FILE_MODES.S_MODE_BITS, ); const size = requireLazyTreeInteger( record.size, @@ -3152,7 +3151,7 @@ export class MemoryFileSystem implements FileSystemBackend { : S_IFREG; if ( (identity.mode & S_IFMT) !== expectedType || - (identity.mode & 0o7777) !== inventoryEntry.mode + (identity.mode & FILE_MODES.S_MODE_BITS) !== inventoryEntry.mode ) { throw new Error( `Lazy tree namespace entry ${inventoryEntry.vfsPath} ` + @@ -4673,7 +4672,7 @@ export class MemoryFileSystem implements FileSystemBackend { inventoryAtPath; if ( !inventoryEntry || (st.mode & S_IFMT) !== S_IFREG || st.size !== 0 || - (st.mode & 0o7777) !== inventoryEntry.mode || + (st.mode & FILE_MODES.S_MODE_BITS) !== inventoryEntry.mode || (inventoryAtPath?.inodeGroup !== undefined && inventoryAtPath.inodeGroup !== inventoryEntry.inodeGroup) ) { @@ -4742,7 +4741,7 @@ export class MemoryFileSystem implements FileSystemBackend { : S_IFLNK; if ( (st.mode & S_IFMT) !== expectedType || - (st.mode & 0o7777) !== inventoryEntry.mode || + (st.mode & FILE_MODES.S_MODE_BITS) !== inventoryEntry.mode || ( inventoryEntry.type === "symlink" && ( @@ -5370,7 +5369,7 @@ export class MemoryFileSystem implements FileSystemBackend { : (entry.mode & 0o111) !== 0 ? 0o755 : 0o644 - : entry.mode & 0o7777; + : entry.mode & FILE_MODES.S_MODE_BITS; if ( actualType !== expected.type || actualMode !== expected.mode @@ -5455,7 +5454,7 @@ export class MemoryFileSystem implements FileSystemBackend { `Lazy tree member ${sourcePath} is ${actual.type}, expected ${expectedType}`, ); } - if ((actual.mode & 0o7777) !== expected.mode) { + if ((actual.mode & FILE_MODES.S_MODE_BITS) !== expected.mode) { throw new Error(`Lazy tree member ${sourcePath} mode differs from inventory`); } if (expectedType === "file" && actual.data?.byteLength !== expected.size) { @@ -5903,7 +5902,7 @@ export class MemoryFileSystem implements FileSystemBackend { : S_IFREG; if ( (st.mode & S_IFMT) !== expectedType || - (st.mode & 0o7777) !== inventoryEntry.mode + (st.mode & FILE_MODES.S_MODE_BITS) !== inventoryEntry.mode ) { throw new Error( `Lazy atomic tree changed at ${inventoryEntry.vfsPath}`, @@ -7094,7 +7093,7 @@ export class MemoryFileSystem implements FileSystemBackend { gid: number, content: Uint8Array, ): void { - const fd = this.open(path, 0o1101, mode); // O_WRONLY | O_CREAT | O_TRUNC + const fd = this.open(path, O_WRONLY_CREAT_TRUNC, mode); if (content.length > 0) this.write(fd, content, null, content.length); this.close(fd); this.chown(path, uid, gid); @@ -7126,7 +7125,7 @@ export class MemoryFileSystem implements FileSystemBackend { ): void { const st = this.lstat(path); const kind = st.mode & S_IFMT; - const mode = st.mode & 0o7777; + const mode = st.mode & FILE_MODES.S_MODE_BITS; if (kind === S_IFDIR) { if (path === "/") { @@ -7267,12 +7266,13 @@ export class MemoryFileSystem implements FileSystemBackend { if (!entry) return null; // Determine d_type from mode const mode = entry.stat.mode; - let dtype = 0; // DT_UNKNOWN - if ((mode & 0xf000) === 0x8000) - dtype = 8; // DT_REG - else if ((mode & 0xf000) === 0x4000) - dtype = 4; // DT_DIR - else if ((mode & 0xf000) === 0xa000) dtype = 10; // DT_LNK + let dtype: number = DIRENT_TYPES.DT_UNKNOWN; + if ((mode & FILE_MODES.S_IFMT) === FILE_MODES.S_IFREG) + dtype = DIRENT_TYPES.DT_REG; + else if ((mode & FILE_MODES.S_IFMT) === FILE_MODES.S_IFDIR) + dtype = DIRENT_TYPES.DT_DIR; + else if ((mode & FILE_MODES.S_IFMT) === FILE_MODES.S_IFLNK) + dtype = DIRENT_TYPES.DT_LNK; return { name: entry.name, type: dtype, ino: entry.stat.ino }; } diff --git a/host/src/vfs/package-deferred-tree.ts b/host/src/vfs/package-deferred-tree.ts index 20d72ca867..a98949c9d2 100644 --- a/host/src/vfs/package-deferred-tree.ts +++ b/host/src/vfs/package-deferred-tree.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { FILE_MODES } from "../generated/abi"; import { MemoryFileSystem, @@ -23,10 +24,7 @@ export { type PackageDeferredZipTreeSpec, } from "./package-deferred-tree-contract"; -const S_IFMT = 0xf000; -const S_IFREG = 0x8000; -const S_IFDIR = 0x4000; -const S_IFLNK = 0xa000; +const { S_IFMT, S_IFREG, S_IFDIR, S_IFLNK } = FILE_MODES; const textEncoder = new TextEncoder(); export interface PackageDeferredZipTreeDescriptor { @@ -541,7 +539,7 @@ export function assertPackageDeferredZipTreeState( : S_IFREG; if ( (stat.mode & S_IFMT) !== expectedType || - (stat.mode & 0o7777) !== entry.mode || + (stat.mode & FILE_MODES.S_MODE_BITS) !== entry.mode || stat.uid !== derived.descriptor.owner.uid || stat.gid !== derived.descriptor.owner.gid || (entry.type !== "directory" && stat.size !== entry.size) || @@ -723,7 +721,7 @@ function preflightNamespace( if ( entry.type !== "directory" || (existing.mode & S_IFMT) !== S_IFDIR || - (existing.mode & 0o7777) !== entry.mode || + (existing.mode & FILE_MODES.S_MODE_BITS) !== entry.mode || existing.uid !== descriptor.owner.uid || existing.gid !== descriptor.owner.gid ) { diff --git a/host/src/vfs/rootfs-overlay.ts b/host/src/vfs/rootfs-overlay.ts index 84061d7c53..bc4f8f05dd 100644 --- a/host/src/vfs/rootfs-overlay.ts +++ b/host/src/vfs/rootfs-overlay.ts @@ -1,4 +1,5 @@ import { MemoryFileSystem } from "./memory-fs"; +import { FILE_MODES } from "../generated/abi"; import { ENOENT, ENOSPC, @@ -6,13 +7,11 @@ import { O_RDONLY, O_TRUNC, O_WRONLY, - S_IFDIR, - S_IFLNK, - S_IFMT, - S_IFREG, SFSError, } from "./sharedfs-vendor"; +const { S_IFDIR, S_IFLNK, S_IFMT, S_IFREG } = FILE_MODES; + function lstatIfPresent(fs: MemoryFileSystem, path: string) { try { return fs.lstat(path); @@ -108,7 +107,7 @@ function copyMissingRootfsPath( } else { target.mkdirWithOwner( path, - sourceStat.mode & 0o7777, + sourceStat.mode & FILE_MODES.S_MODE_BITS, sourceStat.uid, sourceStat.gid, ); @@ -150,7 +149,7 @@ function copyMissingRootfsPath( target, path, readFile(source, path, sourceStat.size), - sourceStat.mode & 0o7777, + sourceStat.mode & FILE_MODES.S_MODE_BITS, sourceStat.uid, sourceStat.gid, ); diff --git a/host/src/vfs/tar.ts b/host/src/vfs/tar.ts index 28e0d97010..0f4e9c7ed6 100644 --- a/host/src/vfs/tar.ts +++ b/host/src/vfs/tar.ts @@ -1,7 +1,8 @@ import { Gunzip } from "fflate"; +import { FILE_MODES } from "../generated/abi"; const TAR_BLOCK_BYTES = 512; -const MODE_BITS = 0o7777; +const MODE_BITS = FILE_MODES.S_MODE_BITS; const MEBIBYTE = 1024 * 1024; const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true, diff --git a/host/src/vfs/zip.ts b/host/src/vfs/zip.ts index 8a6c36e427..04ad0863c1 100644 --- a/host/src/vfs/zip.ts +++ b/host/src/vfs/zip.ts @@ -7,6 +7,7 @@ */ import { Inflate, inflateSync } from "fflate"; +import { FILE_MODES } from "../generated/abi"; // --- Zip format signatures --- @@ -29,9 +30,7 @@ const COMPRESSION_DEFLATE = 8; // Unix creator OS code const CREATOR_UNIX = 3; -// Unix file type mask for symlinks -const S_IFLNK = 0xa000; -const S_IFMT = 0xf000; +const { S_IFLNK, S_IFMT } = FILE_MODES; const fileNameDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true, diff --git a/host/src/wasi-shim.ts b/host/src/wasi-shim.ts index 9884114ad2..20a7cc5732 100644 --- a/host/src/wasi-shim.ts +++ b/host/src/wasi-shim.ts @@ -16,6 +16,7 @@ import { ABI_SYSCALLS, + AT_FLAGS, CHANNEL_STATUS_IDLE, CHANNEL_STATUS_PENDING, CH_ARG_SIZE, @@ -26,9 +27,13 @@ import { CH_RETURN, CH_STATUS, CH_SYSCALL, + FCNTL_COMMANDS, + FILE_MODES, + OPEN_FLAGS, PROCESS_IOVEC_WASM32_BASE_OFFSET, PROCESS_IOVEC_WASM32_LEN_OFFSET, PROCESS_IOVEC_WASM32_SIZE, + SEEK_WHENCE, STRUCT_SIZE_WASM_STAT, STRUCT_SIZE_WASM_POLL_FD, WASM_POLL_FD_EVENTS_OFFSET, @@ -81,28 +86,25 @@ const SYS_DUP2 = ABI_SYSCALLS.Dup2; const SYS_SHUTDOWN = ABI_SYSCALLS.Shutdown; // --- POSIX flags (from crates/shared/src/lib.rs) --- -const O_RDONLY = 0; -const O_WRONLY = 1; -const O_RDWR = 2; -const O_CREAT = 0o100; -const O_EXCL = 0o200; -const O_TRUNC = 0o1000; -const O_APPEND = 0o2000; -const O_NONBLOCK = 0o4000; -const O_DIRECTORY = 0o200000; -const O_NOFOLLOW = 0o400000; - -const AT_FDCWD = -100; -const AT_SYMLINK_NOFOLLOW = 0x100; -const AT_REMOVEDIR = 0x200; - -const F_GETFL = 3; -const F_SETFL = 4; - -// SEEK constants (POSIX) -const SEEK_SET = 0; -const SEEK_CUR = 1; -const SEEK_END = 2; +const O_RDONLY = OPEN_FLAGS.O_RDONLY; +const O_RDWR = OPEN_FLAGS.O_RDWR; +const O_ACCMODE = OPEN_FLAGS.O_ACCMODE; +const O_CREAT = OPEN_FLAGS.O_CREAT; +const O_EXCL = OPEN_FLAGS.O_EXCL; +const O_TRUNC = OPEN_FLAGS.O_TRUNC; +const O_APPEND = OPEN_FLAGS.O_APPEND; +const O_NONBLOCK = OPEN_FLAGS.O_NONBLOCK; +const O_DIRECTORY = OPEN_FLAGS.O_DIRECTORY; + +const AT_FDCWD = AT_FLAGS.AT_FDCWD; +const AT_REMOVEDIR = AT_FLAGS.AT_REMOVEDIR; + +const F_GETFL = FCNTL_COMMANDS.F_GETFL; +const F_SETFL = FCNTL_COMMANDS.F_SETFL; + +const SEEK_SET = SEEK_WHENCE.SEEK_SET; +const SEEK_CUR = SEEK_WHENCE.SEEK_CUR; +const SEEK_END = SEEK_WHENCE.SEEK_END; type SyscallScalar = number | bigint; const MIN_SIGNED_I64 = -(1n << 63n); @@ -132,15 +134,14 @@ function splitSignedI64Words(value: bigint): { }; } -// S_IFMT mode bits -const S_IFDIR = 0o040000; -const S_IFCHR = 0o020000; -const S_IFBLK = 0o060000; -const S_IFREG = 0o100000; -const S_IFIFO = 0o010000; -const S_IFLNK = 0o120000; -const S_IFSOCK = 0o140000; -const S_IFMT = 0o170000; +const S_IFDIR = FILE_MODES.S_IFDIR; +const S_IFCHR = FILE_MODES.S_IFCHR; +const S_IFBLK = FILE_MODES.S_IFBLK; +const S_IFREG = FILE_MODES.S_IFREG; +const S_IFIFO = FILE_MODES.S_IFIFO; +const S_IFLNK = FILE_MODES.S_IFLNK; +const S_IFSOCK = FILE_MODES.S_IFSOCK; +const S_IFMT = FILE_MODES.S_IFMT; // Stat struct size written by kernel const WASM_STAT_SIZE = STRUCT_SIZE_WASM_STAT; @@ -1234,7 +1235,7 @@ export class WasiShim { if (errno) { // If O_RDWR fails with EISDIR or EACCES, retry with O_RDONLY if ((errno === 21 || errno === 13) && !(posixFlags & O_CREAT)) { - posixFlags = (posixFlags & ~3) | O_RDONLY; + posixFlags = (posixFlags & ~O_ACCMODE) | O_RDONLY; const retry = this.doSyscall(SYS_OPENAT, kernelDirfd, pathAddr, posixFlags, 0o666); if (retry.errno) return translateLinuxErrno(retry.errno); new DataView(this.memory.buffer).setUint32( diff --git a/host/test/generated-abi.test.ts b/host/test/generated-abi.test.ts index fa68d13cff..0543ea3022 100644 --- a/host/test/generated-abi.test.ts +++ b/host/test/generated-abi.test.ts @@ -8,6 +8,8 @@ import { ABI_SYSCALL_NAMES, ABI_SYSCALLS, ABI_VERSION, + ACCESS_MODES, + AT_FLAGS, CHANNEL_STATUS, CH_ARG_SIZE, CH_ARGS, @@ -35,7 +37,11 @@ import { CH_STATUS, CH_SYSCALL, CH_TOTAL_SIZE, + DIRENT_TYPES, EPOLL_EVENTS, + FCNTL_COMMANDS, + FD_FLAGS, + FILE_MODES, HOST_ADAPTER_MANIFEST_FIELDS, HOST_ADAPTER_MANIFEST_MAGIC, HOST_ADAPTER_MANIFEST_SIZE, @@ -47,6 +53,7 @@ import { HOST_ADAPTER_VERSION, HOST_ADAPTER_WORKER_FEATURES, HOST_INTERCEPTED_SYSCALLS, + OPEN_FLAGS, PROCESS_MEMORY_DEFAULT_INITIAL_PAGES, PROCESS_MEMORY_DEFAULT_MAX_PAGES, PROCESS_MEMORY_DEFAULT_THREAD_SLOTS, @@ -102,6 +109,7 @@ import { SYSCALL_ARGS, SELECT_FD_SET_BYTES, SELECT_FD_SETSIZE, + SEEK_WHENCE, WAKEUP_EVENT_FIELDS, WAKEUP_EVENT_RECORD_BYTES, WAKEUP_EVENT_TYPES, @@ -519,6 +527,25 @@ describe("generated host ABI bindings", () => { .toBe(snapshot.io_multiplexing.select.fd_set_bytes); }); + it("match Rust-owned VFS metadata", () => { + expect(OPEN_FLAGS).toEqual(namedValueMap(snapshot.vfs_metadata.open_flags)); + expect(AT_FLAGS).toEqual(namedValueMap(snapshot.vfs_metadata.at_flags)); + expect(FD_FLAGS).toEqual(namedValueMap(snapshot.vfs_metadata.fd_flags)); + expect(FCNTL_COMMANDS).toEqual( + namedValueMap(snapshot.vfs_metadata.fcntl_commands), + ); + expect(ACCESS_MODES).toEqual( + namedValueMap(snapshot.vfs_metadata.access_modes), + ); + expect(FILE_MODES).toEqual(namedValueMap(snapshot.vfs_metadata.file_modes)); + expect(DIRENT_TYPES).toEqual( + namedValueMap(snapshot.vfs_metadata.dirent_types), + ); + expect(SEEK_WHENCE).toEqual( + namedValueMap(snapshot.vfs_metadata.seek_whence), + ); + }); + it("match the atomic process-metadata transaction contract", () => { expect(PROCESS_METADATA_KIND_ARGV) .toBe(snapshot.process_metadata_contract.kind_argv); diff --git a/host/test/vfs-image-wasm-policy.test.ts b/host/test/vfs-image-wasm-policy.test.ts index d4803327f5..2b4d52c5cc 100644 --- a/host/test/vfs-image-wasm-policy.test.ts +++ b/host/test/vfs-image-wasm-policy.test.ts @@ -8,6 +8,7 @@ import { } from "../../images/vfs/scripts/vfs-image-helpers"; import { ensureDirRecursive, writeVfsBinary } from "../src/vfs/image-helpers"; import { MemoryFileSystem } from "../src/vfs/memory-fs"; +import { ABI_VERSION } from "../src/generated/abi"; const NODE_PATH = "/usr/bin/node"; const DISABLED_NODE_POLICY = { @@ -49,7 +50,7 @@ describe("VFS image path-scoped Wasm artifact policy", () => { wasmArtifactPolicies: [DISABLED_NODE_POLICY], }), ).rejects.toThrow( - /\/usr\/bin\/undeclared-stale: incomplete wasm-fork-instrument exports/, + /\/usr\/bin\/undeclared-stale:[\s\S]*incomplete wasm-fork-instrument exports/, ); }); @@ -67,7 +68,10 @@ describe("VFS image path-scoped Wasm artifact policy", () => { wasmArtifactPolicies: [DISABLED_NODE_POLICY], }), ).rejects.toThrow( - /\/usr\/bin\/node: contains ABI 42 wasm-fork-instrument metadata, imports, or exports/, + new RegExp( + String.raw`/usr/bin/node:[\s\S]*contains ABI ${ABI_VERSION} ` + + "wasm-fork-instrument metadata, imports, or exports", + ), ); }); diff --git a/tools/xtask/src/dump_abi.rs b/tools/xtask/src/dump_abi.rs index 47648d55ea..f36296716e 100644 --- a/tools/xtask/src/dump_abi.rs +++ b/tools/xtask/src/dump_abi.rs @@ -18,6 +18,10 @@ //! and retry/lifecycle reason bits consumed by shared hosts //! * [`wasm_posix_shared::poll`], [`wasm_posix_shared::epoll`], and //! [`wasm_posix_shared::select`] — I/O multiplexing event metadata +//! * [`wasm_posix_shared::flags`], [`wasm_posix_shared::access`], +//! [`wasm_posix_shared::mode`], [`wasm_posix_shared::dirent`], and +//! [`wasm_posix_shared::seek`] — VFS-visible constants consumed by host +//! adapters //! //! When `--kernel-wasm ` is provided, the snapshot also covers //! every export in the built kernel `.wasm` (after filtering through @@ -1920,6 +1924,46 @@ fn render_ts_module() -> String { out.push_str(&format!(" {}: {},\n", name, value)); } out.push_str("} as const;\n\n"); + out.push_str("export const OPEN_FLAGS = {\n"); + for (name, value) in open_flags() { + out.push_str(&format!(" {}: {},\n", name, value)); + } + out.push_str("} as const;\n\n"); + out.push_str("export const AT_FLAGS = {\n"); + for (name, value) in at_flags() { + out.push_str(&format!(" {}: {},\n", name, value)); + } + out.push_str("} as const;\n\n"); + out.push_str("export const FD_FLAGS = {\n"); + for (name, value) in fd_flags() { + out.push_str(&format!(" {}: {},\n", name, value)); + } + out.push_str("} as const;\n\n"); + out.push_str("export const FCNTL_COMMANDS = {\n"); + for (name, value) in fcntl_commands() { + out.push_str(&format!(" {}: {},\n", name, value)); + } + out.push_str("} as const;\n\n"); + out.push_str("export const ACCESS_MODES = {\n"); + for (name, value) in access_modes() { + out.push_str(&format!(" {}: {},\n", name, value)); + } + out.push_str("} as const;\n\n"); + out.push_str("export const FILE_MODES = {\n"); + for (name, value) in file_modes() { + out.push_str(&format!(" {}: {},\n", name, value)); + } + out.push_str("} as const;\n\n"); + out.push_str("export const DIRENT_TYPES = {\n"); + for (name, value) in dirent_types() { + out.push_str(&format!(" {}: {},\n", name, value)); + } + out.push_str("} as const;\n\n"); + out.push_str("export const SEEK_WHENCE = {\n"); + for (name, value) in seek_whence() { + out.push_str(&format!(" {}: {},\n", name, value)); + } + out.push_str("} as const;\n\n"); out.push_str(&format!( "export const KERNEL_SCRATCH_SIGNAL_DELIVERY_BYTES = {} as const;\n", shared::kernel_scratch_wire::SIGNAL_DELIVERY_BYTES @@ -3334,6 +3378,7 @@ fn build_snapshot(kernel_wasm: &std::path::Path) -> Result { ); root.insert("wakeup_event_wire".into(), wakeup_event_wire()); root.insert("io_multiplexing".into(), io_multiplexing()); + root.insert("vfs_metadata".into(), vfs_metadata()); root.insert("spawn_contract".into(), spawn_contract()); root.insert("channel_header".into(), channel_header()); @@ -3563,6 +3608,166 @@ fn io_multiplexing() -> Value { }) } +fn open_flags() -> [(&'static str, u32); 16] { + use shared::flags::*; + + [ + ("O_RDONLY", O_RDONLY), + ("O_WRONLY", O_WRONLY), + ("O_RDWR", O_RDWR), + ("O_ACCMODE", O_ACCMODE), + ("O_CREAT", O_CREAT), + ("O_EXCL", O_EXCL), + ("O_NOCTTY", O_NOCTTY), + ("O_TRUNC", O_TRUNC), + ("O_APPEND", O_APPEND), + ("O_NONBLOCK", O_NONBLOCK), + ("O_ASYNC", O_ASYNC), + ("O_DIRECTORY", O_DIRECTORY), + ("O_NOFOLLOW", O_NOFOLLOW), + ("O_CLOEXEC", O_CLOEXEC), + ("O_PATH", O_PATH), + ("O_CLOFORK", O_CLOFORK), + ] +} + +fn at_flags() -> [(&'static str, i32); 4] { + use shared::flags::*; + + [ + ("AT_FDCWD", AT_FDCWD), + ("AT_SYMLINK_NOFOLLOW", AT_SYMLINK_NOFOLLOW as i32), + ("AT_REMOVEDIR", AT_REMOVEDIR as i32), + ("AT_EMPTY_PATH", AT_EMPTY_PATH as i32), + ] +} + +fn fd_flags() -> [(&'static str, u32); 2] { + use shared::fd_flags::*; + + [("FD_CLOEXEC", FD_CLOEXEC), ("FD_CLOFORK", FD_CLOFORK)] +} + +fn fcntl_commands() -> [(&'static str, u32); 15] { + use shared::fcntl_cmd::*; + + [ + ("F_DUPFD", F_DUPFD), + ("F_GETFD", F_GETFD), + ("F_SETFD", F_SETFD), + ("F_GETFL", F_GETFL), + ("F_SETFL", F_SETFL), + ("F_GETLK", F_GETLK), + ("F_SETLK", F_SETLK), + ("F_SETLKW", F_SETLKW), + ("F_SETOWN", F_SETOWN), + ("F_GETOWN", F_GETOWN), + ("F_DUPFD_CLOEXEC", F_DUPFD_CLOEXEC), + ("F_DUPFD_CLOFORK", F_DUPFD_CLOFORK), + ("F_OFD_GETLK", F_OFD_GETLK), + ("F_OFD_SETLK", F_OFD_SETLK), + ("F_OFD_SETLKW", F_OFD_SETLKW), + ] +} + +fn access_modes() -> [(&'static str, u32); 4] { + use shared::access::*; + + [ + ("F_OK", F_OK), + ("R_OK", R_OK), + ("W_OK", W_OK), + ("X_OK", X_OK), + ] +} + +fn file_modes() -> [(&'static str, u32); 24] { + use shared::mode::*; + + [ + ("S_IFMT", S_IFMT), + ("S_IFSOCK", S_IFSOCK), + ("S_IFLNK", S_IFLNK), + ("S_IFREG", S_IFREG), + ("S_IFBLK", S_IFBLK), + ("S_IFDIR", S_IFDIR), + ("S_IFCHR", S_IFCHR), + ("S_IFIFO", S_IFIFO), + ("S_ISUID", S_ISUID), + ("S_ISGID", S_ISGID), + ("S_ISVTX", S_ISVTX), + ("S_IRWXU", S_IRWXU), + ("S_IRUSR", S_IRUSR), + ("S_IWUSR", S_IWUSR), + ("S_IXUSR", S_IXUSR), + ("S_IRWXG", S_IRWXG), + ("S_IRGRP", S_IRGRP), + ("S_IWGRP", S_IWGRP), + ("S_IXGRP", S_IXGRP), + ("S_IRWXO", S_IRWXO), + ("S_IROTH", S_IROTH), + ("S_IWOTH", S_IWOTH), + ("S_IXOTH", S_IXOTH), + ("S_MODE_BITS", S_MODE_BITS), + ] +} + +fn dirent_types() -> [(&'static str, u32); 8] { + use shared::dirent::*; + + [ + ("DT_UNKNOWN", DT_UNKNOWN), + ("DT_FIFO", DT_FIFO), + ("DT_CHR", DT_CHR), + ("DT_DIR", DT_DIR), + ("DT_BLK", DT_BLK), + ("DT_REG", DT_REG), + ("DT_LNK", DT_LNK), + ("DT_SOCK", DT_SOCK), + ] +} + +fn seek_whence() -> [(&'static str, u32); 3] { + use shared::seek::*; + + [ + ("SEEK_SET", SEEK_SET), + ("SEEK_CUR", SEEK_CUR), + ("SEEK_END", SEEK_END), + ] +} + +fn named_values(entries: [(&'static str, u32); N]) -> Value { + Value::Array( + entries + .into_iter() + .map(|(name, value)| json!({ "name": name, "value": value })) + .collect(), + ) +} + +fn named_signed_values(entries: [(&'static str, i32); N]) -> Value { + Value::Array( + entries + .into_iter() + .map(|(name, value)| json!({ "name": name, "value": value })) + .collect(), + ) +} + +fn vfs_metadata() -> Value { + json!({ + "open_flags": named_values(open_flags()), + "at_flags": named_signed_values(at_flags()), + "fd_flags": named_values(fd_flags()), + "fcntl_commands": named_values(fcntl_commands()), + "access_modes": named_values(access_modes()), + "file_modes": named_values(file_modes()), + "dirent_types": named_values(dirent_types()), + "seek_whence": named_values(seek_whence()), + }) +} + fn channel_scalar_contract() -> Value { let syscalls: Vec = shared::channel_scalar::SYSCALLS .iter() @@ -6193,7 +6398,7 @@ fn classify_compat_change(old: &Value, new: &Value) -> Result bool { matches!( section, - "host_adapter" | "io_multiplexing" | "syscall_arg_descriptors" + "host_adapter" | "io_multiplexing" | "syscall_arg_descriptors" | "vfs_metadata" ) } @@ -6579,6 +6784,59 @@ mod tests { assert!(rendered.contains(" EPOLLHUP: 16,")); } + #[test] + fn generated_vfs_metadata_is_complete() { + let metadata = vfs_metadata(); + assert_eq!(metadata["open_flags"].as_array().unwrap().len(), 16); + assert_eq!(metadata["at_flags"].as_array().unwrap().len(), 4); + assert_eq!(metadata["fd_flags"].as_array().unwrap().len(), 2); + assert_eq!(metadata["fcntl_commands"].as_array().unwrap().len(), 15); + assert_eq!(metadata["access_modes"].as_array().unwrap().len(), 4); + assert_eq!(metadata["file_modes"].as_array().unwrap().len(), 24); + assert_eq!(metadata["dirent_types"].as_array().unwrap().len(), 8); + assert_eq!(metadata["seek_whence"].as_array().unwrap().len(), 3); + + for (section, name, value) in [ + ("open_flags", "O_NOCTTY", json!(0o400)), + ("open_flags", "O_ASYNC", json!(0o20000)), + ("open_flags", "O_PATH", json!(0o10000000)), + ("at_flags", "AT_FDCWD", json!(-100)), + ("at_flags", "AT_EMPTY_PATH", json!(0x1000)), + ("fd_flags", "FD_CLOFORK", json!(2)), + ("fcntl_commands", "F_DUPFD_CLOFORK", json!(1028)), + ("access_modes", "X_OK", json!(1)), + ("file_modes", "S_IFREG", json!(0o100000)), + ("file_modes", "S_MODE_BITS", json!(0o7777)), + ("dirent_types", "DT_SOCK", json!(12)), + ("seek_whence", "SEEK_END", json!(2)), + ] { + assert!( + metadata[section] + .as_array() + .unwrap() + .contains(&json!({ "name": name, "value": value })), + "missing {section}.{name}", + ); + } + + let rendered = render_ts_module(); + for expected in [ + "export const OPEN_FLAGS = {", + " O_PATH: 2097152,", + "export const AT_FLAGS = {", + " AT_FDCWD: -100,", + "export const FD_FLAGS = {", + "export const FCNTL_COMMANDS = {", + "export const ACCESS_MODES = {", + "export const FILE_MODES = {", + " S_MODE_BITS: 4095,", + "export const DIRENT_TYPES = {", + "export const SEEK_WHENCE = {", + ] { + assert!(rendered.contains(expected), "missing generated TS: {expected}"); + } + } + #[test] fn generated_process_metadata_contract_keeps_kinds_together() { assert_eq!( @@ -7275,7 +7533,8 @@ mod tests { "size": {"type": "cstring"} } ] - } + }, + "vfs_metadata": vfs_metadata() }) } @@ -7357,6 +7616,20 @@ mod tests { ); } + #[test] + fn adding_vfs_metadata_section_is_compatible() { + let mut old = base_snapshot(); + old.as_object_mut().unwrap().remove("vfs_metadata"); + let new = base_snapshot(); + + let report = classify_compat_change(&old, &new).unwrap(); + assert!(report.breaking.is_empty(), "{report:?}"); + assert_eq!( + report.additive, + vec!["added top-level section \"vfs_metadata\""] + ); + } + #[test] fn adding_wakeup_event_wire_section_requires_an_abi_bump() { let old = base_snapshot(); From 4a44431ed4ec669ba02f8bae948175eb91fc9a58 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Thu, 28 May 2026 15:04:15 +0100 Subject: [PATCH 49/82] Docs: Record the Rust and host ownership boundary Record which lifecycle, IPC, readiness, and VFS responsibilities are now Rust-owned and which must remain with Node and browser host primitives. Forward-port f927c6d6d while keeping current ABI 43 validation and design gates explicit. --- .../2026-05-20-rust-owned-host-logic-plan.md | 51 ++++- ...d-advisory-file-lock-native-bridge-plan.md | 190 ++++++++++++++++++ 2 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 docs/plans/2026-06-01-centralized-advisory-file-lock-native-bridge-plan.md diff --git a/docs/plans/2026-05-20-rust-owned-host-logic-plan.md b/docs/plans/2026-05-20-rust-owned-host-logic-plan.md index f24b40b722..013b82ba63 100644 --- a/docs/plans/2026-05-20-rust-owned-host-logic-plan.md +++ b/docs/plans/2026-05-20-rust-owned-host-logic-plan.md @@ -35,7 +35,7 @@ The host must stay responsible for browser and Node platform primitives: Workers 1. **Generated TS ABI constants from `crates/shared`/`xtask dump-abi`.** - Generate `host/src/generated/abi.ts` with ABI version, channel offsets/sizes, status codes, host-intercepted syscall numbers, syscall numbers already in `shared::Syscall`, and marshalled struct sizes. - Use it in `constants.ts`, `kernel-worker.ts`, `kernel.ts`, and the simplest worker-channel writers. - - Keep legacy constants that are not yet in `shared::Syscall` local for now. + - Keep constants that are not yet in `shared::Syscall` local for now. 2. **Expand shared syscall metadata coverage.** - Move currently untracked syscall numbers used by TS (`clone`, `futex`, `epoll`, `mq`, SysV IPC, thread cancel, exit_group, etc.) into Rust/shared metadata. @@ -74,7 +74,7 @@ The host must stay responsible for browser and Node platform primitives: Workers | Browser/Node parity | Host lifecycle changes often break one side only. | Migrate shared files first, then update both worker entries in the same PR for lifecycle changes. | | Snapshot churn | Adding snapshot coverage can look like ABI change even when runtime bytes do not change. | Keep first slice generated from existing snapshot/shared data. For new coverage, follow `docs/abi-versioning.md` and classify whether an ABI bump is required. | | V8/browser workarounds | Epoll and wake scheduling have browser-specific failure modes. | Do not remove TS workarounds until browser smoke/Playwright evidence exists. | -| Legacy binaries | Older images depend on stable ABI pins and first-party host adapters. | Keep strict `__abi_version` checks; use additive manifests/bindings without weakening compatibility checks. | +| Version-pinned binaries | Images can depend on stable ABI pins and first-party host adapters. | Keep strict `__abi_version` checks; use additive manifests/bindings without weakening compatibility checks. | ## ABI And Versioning Implications @@ -114,12 +114,51 @@ path. | Done / PR #534 | Rust-owned syscall marshalling descriptors | `crates/shared::host_abi` owns simple pointer-argument descriptors; `dump-abi` generates `SYSCALL_ARGS`; TS host keeps memory copies but reads generated descriptors. | The old TS `SYSCALL_ARGS` table and syscall-number size switches are gone. `poll`/`ppoll`, SysV message prefix, `semop`, and `msgrcv` copy-back adjustments are metadata fields. Nested-pointer syscalls (`readv`/`writev`/preadv/pwritev) stay on dedicated TS paths. | Shared unit tests for descriptor ordering/high-risk sizes/nested-pointer exclusion; xtask ABI tests; `bash scripts/check-abi-version.sh`; generated ABI vitest; host build; kernel lib tests. | | Done / PR #534 follow-up | Extended host-visible syscall numbers and names | Add Rust/shared metadata for ABI-visible syscall numbers still hardcoded in host TS but not currently in `shared::Syscall`, such as `getrandom`, `clone`, `futex`, `ppoll`, `pselect6`, epoll, `exit_group`, `waitid`, `msync`, preadv/pwritev, mqueue, SysV IPC, `sched_yield`, `fallocate`, timers, and `thread_cancel`. Generate TS bindings, logging names, and snapshot coverage. | Host TS no longer defines literal syscall numbers for this set, and syscall trace names are generated from Rust-owned metadata. Existing `HOST_INTERCEPTED_SYSCALLS` remains separate for fork/exec/spawn because those are caught before normal dispatch. Public behavior unchanged. | Rust metadata uniqueness tests; xtask compatibility tests; `bash scripts/check-abi-version.sh update` + check; generated ABI vitest; host build; kernel lib tests. | | Done / stacked PR | Rust-defined host adapter manifest | Add a compact Rust-defined manifest describing ABI version, required host adapter protocol version, required/optional exports, worker protocol features, and channel metadata. JS validates it during kernel boot. | Boot fails earlier with clear errors when the host/kernel contract is incompatible. No Worker creation or Wasm instantiation moves out of JS. | Rust manifest serialization tests; ABI snapshot check; vitest boot validation cases; Node/browser worker-entry smoke if boot code changes. | -| In progress / stacked PR | Process lifecycle cleanup consolidation | Rust `ProcessTable` now owns parent lookup, wait-target matching, wait-status derivation, host-crash zombie marking, authorized child reaping, thread-exit clear-tid metadata, SysV shared-memory attachment metadata, host-bridged TCP listener target policy, and process-owned host timer cleanup metadata. TS keeps blocked waiter queues, Worker/memory cleanup, platform timer handles, process-memory writes/futex wakeups, and the actual TCP server objects because those are host primitives. Remaining audit: thread channel/Worker allocation and free-list lifecycle. | Kernel owns process lifecycle invariants that do not require Worker identity; JS owns Worker termination, memory objects, crash observation, and platform callbacks. | ProcessTable unit tests; fork/exec/spawn/clone/wait tests; crash/trap tests; browser parity smoke when worker entries change. | -| In progress / stacked PR | IPC/resource cleanup in Rust | Rust `Process` now records `shmat` address -> segment metadata, records child attachments during transactional fork materialization, clears them across exec setup, and detaches live mappings from `remove_process()`. TS still copies bytes between guest memory and kernel SysV segments because only the host can address guest `Memory`. | `remove_process()` owns IPC attachment cleanup; JS only handles guest-memory transfer and host primitive wake/schedule work. | SysV IPC and mqueue Rust tests plus host integration/e2e coverage for blocking and cleanup. | -| In progress / ABI 43 batch | Readiness metadata improvements | Rust/shared now owns the packed kernel wake-event layout and reason bits, `poll`/`epoll` event bits, and `fd_set` sizing consumed through generated bindings. Pipe-readable and pipe-writable events target matching ordinary `poll`/`ppoll` retries by captured pipe index before the broad fallback; signal-safe `ppoll` remains deferred, and `select`/`pselect6` targeting remains JavaScript-owned. | JavaScript still owns timers, retry queues, `Atomics.waitAsync`, and the broad fallback for wait classes without exact pipe identities. Metadata consumption and targeting add no host-to-kernel call. | Rust metadata and wakeup serialization tests; generated ABI Vitest; pipe/socket/poll/select/ppoll/pselect tests; browser bridge smoke before broader readiness claims. | -| In progress / ABI 43 batch | VFS policy split | Rust/shared owns the VFS-visible open and `*at` flags, descriptor and `fcntl` values, access modes, file modes, directory-entry types, and seek constants generated for shared Node/browser host consumers and the WebAssembly System Interface (WASI) shim. Backend I/O, Origin Private File System (OPFS), Node `fs`, and lazy archive materialization remain JavaScript-owned. The standalone OPFS worker and vendored SharedFS keep entry-point/vendor-local copies; Homebrew-specific adapters remain deferred to their dedicated batch. | Generated metadata centralizes Kandelo's existing guest ABI without making a broader Linux-compatibility promise. Kernel uid/gid/umask checks remain authoritative; mount/read-only enforcement still needs an explicit checked contract. | Generated ABI tests and snapshot check; VFS, uid/gid/permission, host-fs metadata, default mount, Node worker, and browser worker tests before broader policy claims. | +| Implementation complete / ABI 43 batch; validation pending | Process lifecycle cleanup consolidation | Rust `ProcessTable` owns parent lookup, wait-target matching, wait-status derivation, host-crash zombie marking, authorized child reaping, thread-exit clear-TID metadata, SysV shared-memory attachment metadata, host-bridged TCP listener target policy, and process-owned host timer cleanup metadata. TypeScript keeps blocked waiter queues, Worker/memory cleanup, platform timer handles, process-memory writes and futex wakeups, thread-channel allocation/free-list state, and actual TCP server objects because those are host primitives. | Kernel owns lifecycle invariants that do not require Worker identity; JavaScript owns Worker termination, memory objects, crash observation, channel allocation, and platform callbacks. | ProcessTable unit tests; fork/exec/spawn/clone/wait tests; crash/trap tests; Node and browser worker smoke before a broad completion claim. | +| Implementation complete / ABI 43 batch; validation pending | IPC/resource cleanup in Rust | Rust `Process` records `shmat` address-to-segment metadata, records child attachments during transactional fork materialization, clears them across exec setup, and detaches live mappings from `remove_process()`. Rust owns mqueue tables and process cleanup. TypeScript still copies bytes between guest memory and kernel SysV segments and drains mqueue notifications because only the host can address guest `Memory` and wake host channels. | `remove_process()` owns IPC attachment cleanup; JavaScript handles guest-memory transfer, signal/notification delivery, and host primitive wake/schedule work. | SysV IPC and mqueue Rust tests plus host integration coverage for blocking, fork inheritance, exec, and cleanup. | +| Metadata slice complete / ABI 43 batch; browser and performance evidence deferred | Readiness metadata improvements | Rust/shared owns the packed kernel wake-event layout and reason bits, `poll`/`epoll` event bits, and `fd_set` sizing consumed through generated bindings. Pipe-readable and pipe-writable events target matching ordinary `poll`/`ppoll` retries by captured pipe index before the broad fallback; signal-safe `ppoll`, `select`/`pselect6` targeting, and epoll mirror removal remain deferred. | JavaScript owns timers, retry queues, `Atomics.waitAsync`, and the broad fallback for wait classes without exact identities. Metadata consumption and targeting add no host-to-kernel call. | Rust metadata and wakeup serialization tests; generated ABI Vitest; pipe/socket/poll/select/ppoll/pselect tests; browser bridge smoke and performance comparison before removing fallback logic. | +| Metadata slice complete / ABI 43 batch; policy design deferred | VFS policy split | Rust/shared owns the VFS-visible open and `*at` flags, descriptor and `fcntl` values, access modes, file modes, directory-entry types, and seek constants generated for shared Node/browser host consumers and the WebAssembly System Interface (WASI) shim. Backend I/O, Origin Private File System (OPFS), Node `fs`, and lazy archive materialization remain JavaScript-owned. The standalone OPFS worker and vendored SharedFS keep entry-point/vendor-local copies; Homebrew-specific adapters remain deferred to their dedicated batch. | Generated metadata centralizes Kandelo's existing guest ABI without making a broader Linux-compatibility promise. Kernel uid/gid/umask checks remain authoritative; mount/read-only enforcement needs an explicit checked contract. | Generated ABI tests and snapshot check; VFS, uid/gid/permission, host-fs metadata, default mount, Node worker, and browser worker tests before broader policy claims. | | Done / ABI 43 batch | Procfs/process snapshot schema metadata | `crates/shared::process_snapshot_wire` owns the packed binary layout, `dump-abi` publishes it in `abi/snapshot.json` and generated TypeScript constants, and `parseProcSnapshots` consumes those offsets with strict bounds checks. | TypeScript no longer hand-decodes undocumented offsets for kernel process snapshot data. Procfs text formatting remains Rust-owned. | Rust process-snapshot wire tests; generated ABI Vitest; host snapshot parsing tests. | Deferral rule: if a chunk would move browser/Node primitives, add runtime JS evaluation, or add a Wasm call to every syscall without removing meaningful ABI/security complexity, leave it in JS and document the reason here. + +## Remaining Rust/host boundary + +The metadata batch exhausts the changes that are safe to treat as mechanical +Rust ownership moves. Remaining candidates need a specific design or evidence +gate: + +- Worker creation, `WebAssembly.Memory`, thread-channel allocation, and channel + free-list ownership are host primitives. +- SysV shared-memory copies and mqueue notification delivery require JavaScript + access to process memory and channel wakeups. +- More precise `select`/`pselect6` targeting and epoll mirror removal require + browser smoke coverage and performance data because the broad fallback also + protects signal-mask-swapping waits and engine-specific behavior. +- Mount/read-only enforcement needs an explicit mount-table contract shared by + Rust and the Node/browser VFS backends before adapters can act as pure + platform executors. + +Resume with design documentation and tests for one of those contracts before +moving more logic across the Rust/TypeScript boundary. + +## Additional candidate work + +These are not approved migration chunks. Each needs a focused design, tests, +and explicit host/API-surface tradeoffs before implementation. + +| Candidate | Why consider it | Boundary notes | +|---|---|---| +| Centralized advisory file-lock ownership with native lock bridge | Rust now owns Kandelo's `fcntl`/`flock` state, stable file and open-file-description identity, conflict semantics, and lifecycle cleanup. A native bridge remains an explicitly separate design question for coordinating host-backed files with programs outside Kandelo. See `docs/plans/2026-06-01-centralized-advisory-file-lock-native-bridge-plan.md`. | Kandelo has one centralized kernel target; do not preserve earlier decentralized research behavior as a compatibility concern. Browser/memfs hosts have no native-lock surface. Any Node/native bridge must preserve Rust as the sole Kandelo lock authority and account for host operating-system ownership rules. | +| Nested syscall marshalling descriptors | TypeScript special-cases nested process-memory layouts such as `readv`/`writev`, `sendmsg`/`recvmsg`, `fcntl` lock structs, `semctl`, `select`/`pselect6`, `ppoll`, and selected `ioctl` payloads. Shared metadata could keep future adapters from reimplementing that ABI knowledge. | Copies stay in the host adapter because they access process `WebAssembly.Memory`. Prefer generated tables or a reusable Rust host-adapter crate over a runtime kernel call for every syscall. | +| WebAssembly System Interface Preview 1 translation | `host/src/wasi-shim.ts` maps the WebAssembly System Interface (WASI) fd/path/poll/socket/errno surfaces onto Kandelo's POSIX syscall ABI. Non-JavaScript integrations would otherwise need to port it. | This likely belongs in a Rust host-adapter crate rather than the process-table kernel. The host still supplies module-memory access and syscall submission. | +| Exec/spawn launch planning | TypeScript reads argv/envp from process memory, resolves relative exec paths, handles `execveat(AT_EMPTY_PATH)`, performs spawn preflight, and handles shebang recursion in both worker entries. Rust could own a launch-plan descriptor so hosts only resolve/load bytes and instantiate. | Worker creation, module compilation, and byte loading remain host primitives. A design must preserve `posix_spawnp` file actions exactly once and `execvpe`/`PATH` retry semantics. | +| Mount/read-only VFS policy contract | The mount spec carries `readonly`, but enforcement and mount routing are host-side. Rust owns process uid/gid/umask and permission checks from host stat metadata. | Host VFS backends remain platform executors. Rust needs a versioned Node/browser contract for read-only enforcement, mount flags, path-to-mount identity, and `/proc/mounts` parity. | +| File-backed `mmap`/`msync` descriptors | TypeScript populates file-backed mappings after `mmap`, tracks `MAP_SHARED` regions, flushes `msync`, and cleans tracking on `munmap`. Rust owns virtual-address allocation and descriptor state, so it could own mapping descriptors and writeback policy. | Guest-memory copies and host file I/O remain host-side. The kernel would need to emit enough mapping/writeback commands for adapters to copy without duplicating policy. | +| Readiness, `select`/`pselect6`, and `epoll` descriptors | Rust emits targeted wakeup metadata, but TypeScript owns retry queues, timeout policy, signal-safe wake grace, and an epoll interest mirror. | Timers and `Atomics.waitAsync` remain host-side. More precise descriptors need browser smoke and performance data before removing broad fallbacks or the epoll mirror. | +| Signal-delivery event ABI | Rust owns signal state, but TypeScript copies delivery records into process channels, wakes blocked peers after `kill`, handles signal-death follow-up, and drains mqueue notification signals. | The host must wake channels and touch process memory. Rust/shared could define a compact event/command ABI so adapters run one generic delivery loop instead of copying TypeScript control flow. | +| Futex and cancellation wait descriptors | Futex waits and deferred `pthread_cancel` alter host wait state (`Atomics.waitAsync`, timers, pipe-reader registrations, and poll/select retries), while validation and wake/cancel targets are kernel semantics. | Actual waits stay host-side because futex addresses are in process memory. A Rust-owned descriptor/event contract is useful only if it reduces adapter-specific logic without adding hot-path round trips. | +| Virtual network-interface `ioctl` metadata | TypeScript currently supplies Linux-specific `SIOCGIFCONF`, `SIOCGIFHWADDR`, and `SIOCGIFADDR` layouts. | This is not an active migration goal: these interfaces are outside POSIX, and broad Linux compatibility is out of scope. Keep that boundary explicit; reconsider only if a package-independent Kandelo contract requires a portable interface inventory. | +| Device queue/surface contracts | Rust owns much of `/dev/fb0`, `/dev/input/mice`, and `/dev/dsp` semantics and their bounded queues. Remaining TypeScript surfaces can be audited for presentation-only code versus kernel policy. | DOM, canvas, Web Audio, and input-event collection stay host-side. More kernel-owned device contracts are useful only where they shrink adapter behavior without moving presentation into Rust. | diff --git a/docs/plans/2026-06-01-centralized-advisory-file-lock-native-bridge-plan.md b/docs/plans/2026-06-01-centralized-advisory-file-lock-native-bridge-plan.md new file mode 100644 index 0000000000..d392f1e9e8 --- /dev/null +++ b/docs/plans/2026-06-01-centralized-advisory-file-lock-native-bridge-plan.md @@ -0,0 +1,190 @@ +# Centralized Advisory File Lock And Native Bridge Plan + +Date: 2026-06-01 + +Status: Kandelo-owned lock authority implemented in Rust on 2026-07-15; +native coordination with programs outside Kandelo remains unimplemented and is +not implied by the current POSIX support claim. + +## Context + +Kandelo has one supported kernel architecture: a centralized Rust kernel +coordinates process state while JavaScript host adapters provide platform +primitives. Earlier decentralized research paths are not compatibility +targets. + +At the time of this design, advisory file locking was split across Rust and +TypeScript: + +- Rust parsed `fcntl`/`flock` requests, validated access mode, resolved + `SEEK_SET`/`SEEK_CUR`/`SEEK_END`, owned process and open-file-description + context, and released some locks during close/exit cleanup. +- TypeScript owned a `SharedLockTable` for host-backed files, keyed by a path + hash, so locks were visible to Kandelo workers. +- The kernel called `host_fcntl_lock` for host-backed files. + +That split kept platform policy outside the process table and required future +non-JavaScript hosts to reproduce lock semantics. A distinct host hook may +still be valuable: Node can mount native files, and those files may need to +coordinate with native operating-system programs. That external bridge is not +part of Kandelo's current internal lock authority. + +## Goals + +- Move Kandelo-owned advisory lock state into the centralized Rust kernel. +- Preserve POSIX byte-range `fcntl`, open-file-description (OFD) locks, and BSD + `flock` mappings already handled by Rust. +- Replace path-hash identity with stable VFS file identity. +- Keep any future host/native lock bridge separate from Kandelo conflict + decisions. +- Preserve correct browser/memfs behavior without native lock support. +- Keep host hooks out of the syscall hot path except for lock requests that + genuinely require a native bridge. + +## Non-goals + +- Do not preserve decentralized research behavior. +- Do not move native operating-system file APIs into Rust WebAssembly. +- Do not require native locking support in browsers. +- Do not implement mandatory locking. +- Do not use string paths as final identity across hard links or renames. +- Do not treat Linux-specific native locking behavior as a general Kandelo + compatibility goal. + +## Current implementation + +The July implementation completed the Kandelo-owned portion: + +- `ProcessTable` owns the machine-wide `AdvisoryLockManager`. +- Each host-backed file uses exact `(st_dev, st_ino)` identity when available; + in-kernel files use typed kernel object identity. +- Each open file description has a stable `OfdId` carried across descriptor + duplication, fork, exec-surviving descriptors, and descriptor transfer. +- Rust owns conflict detection, range replacement/splitting, `F_GETLK`, + `F_SETLK`, blocking retry state, OFD/flock namespaces, bounded capacity, + wakeups, and close/process cleanup. +- Node and browser hosts no longer provide a `SharedLockTable` authority. + +This solves locking among Kandelo processes. It does not prove that a Kandelo +lock conflicts with a separate native program holding a lock on the same +host-backed inode. + +## Design rationale + +### Rust-owned Kandelo lock table + +The kernel-wide table tracks: + +- Stable file identity. +- Lock owner: POSIX process, OFD, or `flock` owner. +- Lock kind: read, write, or unlock operation. +- Normalized byte range after `l_whence` resolution. +- Independent POSIX/OFD and `flock` conflict namespaces where required. + +It implements conflict detection, lock replacement, partial unlock, +`F_GETLK`, final-OFD cleanup, and process cleanup. Rust remains the sole source +of truth even if a native bridge is added later. + +### Stable VFS file identity + +The implemented identity is carried on the open file description: + +- Native-backed files use `(st_dev, st_ino)` from the live host handle. +- In-kernel regular-file-like objects use a typed kernel object identifier. +- The identity follows the OFD instead of being recomputed from a pathname. + +This is hard-link aware and rename stable where the backend supplies real +device/inode identity. Backends that cannot prove stable identity must expose +that limitation rather than substitute a path hash silently. + +### Optional native lock bridge + +Any future bridge should be a narrow, versioned host capability called only +for native-lock-capable file identities. It must answer: + +- Whether native locking is supported for this file. +- Whether the requested byte-range lock conflicts with an external process. +- Whether the host acquired or mirrored the native state needed for Kandelo's + aggregate internal state. +- Whether unlock/exit reconciliation completed. + +Browser and in-memory hosts would report that no native surface exists; Rust +would continue using its internal table. + +### Transaction boundary + +For `F_SETLK`, a bridge-enabled future implementation should: + +1. Resolve and validate the request. +2. Check Kandelo-internal conflicts. +3. Ask the native bridge to acquire or probe when the file requires it. +4. Commit the Rust table only after native success. +5. Reconcile native state if a later step fails. + +For unlock, Rust should update internal state and ask the bridge to reconcile +the aggregate native state for the file. + +`F_SETLKW` requires asynchronous retry. Blocking the centralized kernel or a +JavaScript event loop on a native lock is not acceptable. A bridge would need +nonblocking attempts plus Kandelo's blocked-syscall retry/wakeup machinery, or +a dedicated native-lock worker that completes through the same contract. + +### Native backend constraints + +Node does not expose portable native byte-range locking in its standard file +API. Candidate backends need a separate, focused evaluation: + +- POSIX `fcntl` or OFD locks through a native addon or helper process. +- `flock` only where whole-file locking is sufficient. +- Platform-specific Windows locking if that platform becomes in scope. +- An explicit unsupported capability for browser/memfs. + +Native POSIX lock ownership is often per native process rather than per file +descriptor; closing one descriptor can release locks held through another. +Because one Node host represents many Kandelo processes, operating-system lock +state cannot replace the Rust conflict table. A bridge must mirror aggregate +native state per stable file identity. + +## Migration record + +1. **Rust lock-table semantics — implemented.** + - Covers read/read compatibility, write conflicts, replacement, partial + unlock, `F_GETLK`, `SEEK_END`, OFD ownership, cleanup, and capacity. +2. **Stable VFS file identity — implemented for current backends.** + - Uses host device/inode identity and typed kernel object identity. +3. **Kandelo lock authority in Rust — implemented.** + - Removed TypeScript conflict decisions and path-hash ownership. +4. **Native bridge capability negotiation — not implemented.** + - Requires an explicit ABI/capability design and Node/browser parity + treatment before code changes. +5. **Node native locking backend — not implemented.** + - Requires a dependency and CI strategy plus external-process tests. +6. **Obsolete TypeScript table removal — implemented.** + - Hosts retain only the platform work still required for ordinary VFS I/O. + +## Required evidence for a native bridge + +- Existing Rust lock-manager and syscall suites remain green. +- Multiple Kandelo processes coordinate on one file across fork, exec, + descriptor transfer, rename, and final close. +- Browser/memfs retains correct internal advisory locking without a native + capability. +- Node tests use an external native process or helper to hold a conflicting + lock. +- Acquisition failure leaves the Rust table unchanged. +- Commit failure reconciles any native lock already acquired. +- Unlock, process exit, exec, host failure, and kernel teardown do not leak + native state. +- Performance measurement shows no extra host crossing on non-lock syscalls. + +## Open questions + +- Should a native bridge receive individual operations or the complete desired + aggregate lock state for one file identity? +- Should native `F_SETLKW` reuse existing blocked-syscall retry machinery or a + dedicated native-lock waiter? +- Which Node native-lock mechanism is acceptable for dependencies, supported + operating systems, and continuous integration? +- What truthful failure should a host return when external native coordination + is requested but unavailable? +- Should lock state be exposed in diagnostics or procfs? From 523a948fa8493ca91a1f66fbd8fd6196f59a7600 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Mon, 13 Jul 2026 19:29:38 -0400 Subject: [PATCH 50/82] Audio: Provide process-safe OSS PCM across both hosts Forward-port PR #947 core commit d77e5a936 onto the combined ABI 43 contract. Keep PCM ownership on refcounted open file descriptions and expose the bounded shared-clock transport without dropping later ABI exports or regenerating package cache keys prematurely. --- abi/snapshot.json | 482 ++++++ crates/kernel/src/audio.rs | 1472 ++++++++++++++--- crates/kernel/src/descriptor_backing.rs | 31 +- crates/kernel/src/fork.rs | 5 +- crates/kernel/src/ofd.rs | 2 + crates/kernel/src/syscalls.rs | 1119 ++++++++++--- crates/kernel/src/wasm_api.rs | 93 +- crates/shared/src/ioctl_contract.rs | 22 + crates/shared/src/lib.rs | 314 +++- examples/dsp_signal_test.c | 215 +++ host/package.json | 1 + host/src/audio/browser-pcm-driver.ts | 380 +++++ host/src/audio/node-pcm-driver.ts | 318 ++++ host/src/audio/pcm-audio-worklet.js | 263 +++ host/src/audio/pcm-driver.ts | 19 + host/src/audio/pcm-transport.ts | 370 +++++ host/src/browser-kernel-host.ts | 85 +- host/src/browser-kernel-protocol.ts | 4 +- host/src/browser-kernel-worker-entry.ts | 15 +- host/src/generated/abi.ts | 76 + host/src/kernel-worker.ts | 351 +++- host/src/node-kernel-host.ts | 4 +- host/src/node-kernel-worker-entry.ts | 27 + host/test/audio-signal-interruption.test.ts | 48 + host/test/browser-kernel.test.ts | 162 ++ host/test/browser-pcm-driver.test.ts | 417 +++++ host/test/centralized-test-helper.ts | 16 +- host/test/global-setup.ts | 1 + host/test/ioctl-arg-size.test.ts | 45 + .../kernel-blocking-retry-snapshot.test.ts | 1 + host/test/kernel-host-destroy.test.ts | 4 +- host/test/node-pcm-driver.test.ts | 282 ++++ host/test/pcm-audio-worklet.test.ts | 356 ++++ host/test/pcm-test-helpers.ts | 221 +++ host/test/pcm-transport.test.ts | 57 + host/test/pcm-wake-observer.test.ts | 96 ++ host/test/support/kernel-scratch-instance.ts | 20 + host/tsup.config.ts | 11 +- libc/glue/abi_constants.h | 88 + libc/glue/channel_syscall.c | 14 +- libc/musl-overlay/include/sys/soundcard.h | 269 +++ libc/musl-overlay/src/unistd/close.c | 23 + programs/audiotest.c | 33 +- run.sh | 5 + scripts/prepare-host-package.sh | 8 + tools/xtask/src/dump_abi.rs | 573 +++++++ 46 files changed, 7837 insertions(+), 581 deletions(-) create mode 100644 examples/dsp_signal_test.c create mode 100644 host/src/audio/browser-pcm-driver.ts create mode 100644 host/src/audio/node-pcm-driver.ts create mode 100644 host/src/audio/pcm-audio-worklet.js create mode 100644 host/src/audio/pcm-driver.ts create mode 100644 host/src/audio/pcm-transport.ts create mode 100644 host/test/audio-signal-interruption.test.ts create mode 100644 host/test/browser-pcm-driver.test.ts create mode 100644 host/test/ioctl-arg-size.test.ts create mode 100644 host/test/node-pcm-driver.test.ts create mode 100644 host/test/pcm-audio-worklet.test.ts create mode 100644 host/test/pcm-test-helpers.ts create mode 100644 host/test/pcm-transport.test.ts create mode 100644 host/test/pcm-wake-observer.test.ts create mode 100644 libc/musl-overlay/include/sys/soundcard.h create mode 100644 libc/musl-overlay/src/unistd/close.c diff --git a/abi/snapshot.json b/abi/snapshot.json index f413bd0e2f..9bd7b174e9 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -1078,6 +1078,11 @@ "kernel_mq_descriptor_msgsize", "kernel_msqid_ds_bytes", "kernel_pick_tcp_listener_target", + "kernel_pcm_claim_transport", + "kernel_pcm_clock_update", + "kernel_pcm_reconcile", + "kernel_pcm_transport_len", + "kernel_pcm_transport_ptr", "kernel_pick_signal_target_tid", "kernel_pipe_has_readers", "kernel_posix_timer_fire", @@ -1201,6 +1206,18 @@ } }, "ioctl_request_contracts": { + "1074024452": { + "argKind": "pointer", + "direction": "in", + "wasm32Size": 4, + "wasm64Size": 4 + }, + "1074024464": { + "argKind": "pointer", + "direction": "in", + "wasm32Size": 4, + "wasm64Size": 4 + }, "1074025521": { "argKind": "pointer", "direction": "in", @@ -1267,18 +1284,120 @@ "wasm32Size": 0, "wasm64Size": 0 }, + "20488": { + "argKind": "none", + "direction": "none", + "wasm32Size": 0, + "wasm64Size": 0 + }, + "20494": { + "argKind": "none", + "direction": "none", + "wasm32Size": 0, + "wasm64Size": 0 + }, + "20501": { + "argKind": "none", + "direction": "none", + "wasm32Size": 0, + "wasm64Size": 0 + }, + "20502": { + "argKind": "none", + "direction": "none", + "wasm32Size": 0, + "wasm64Size": 0 + }, + "2147766274": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 4, + "wasm64Size": 4 + }, + "2147766277": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 4, + "wasm64Size": 4 + }, + "2147766278": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 4, + "wasm64Size": 4 + }, + "2147766279": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 4, + "wasm64Size": 4 + }, "2147766283": { "argKind": "pointer", "direction": "out", "wasm32Size": 4, "wasm64Size": 4 }, + "2147766287": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 4, + "wasm64Size": 4 + }, + "2147766288": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 4, + "wasm64Size": 4 + }, + "2147766295": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 4, + "wasm64Size": 4 + }, "2147767344": { "argKind": "pointer", "direction": "out", "wasm32Size": 4, "wasm64Size": 4 }, + "2148028435": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 8, + "wasm64Size": 8 + }, + "2148028436": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 8, + "wasm64Size": 8 + }, + "2148290577": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 12, + "wasm64Size": 12 + }, + "2148290578": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 12, + "wasm64Size": 12 + }, + "2148552716": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 16, + "wasm64Size": 16 + }, + "2148552717": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 16, + "wasm64Size": 16 + }, "21505": { "argKind": "pointer", "direction": "out", @@ -1417,6 +1536,12 @@ "wasm32Size": 4, "wasm64Size": 4 }, + "3221508100": { + "argKind": "pointer", + "direction": "out", + "wasm32Size": 4, + "wasm64Size": 4 + }, "3221508101": { "argKind": "pointer", "direction": "inout", @@ -1429,6 +1554,18 @@ "wasm32Size": 4, "wasm64Size": 4 }, + "3221508103": { + "argKind": "pointer", + "direction": "inout", + "wasm32Size": 4, + "wasm64Size": 4 + }, + "3221508105": { + "argKind": "pointer", + "direction": "inout", + "wasm32Size": 4, + "wasm64Size": 4 + }, "3221508106": { "argKind": "pointer", "direction": "inout", @@ -2475,6 +2612,31 @@ "name": "kernel_pause", "signature": "() -> (i32)" }, + { + "kind": "func", + "name": "kernel_pcm_claim_transport", + "signature": "(i32) -> (i32)" + }, + { + "kind": "func", + "name": "kernel_pcm_clock_update", + "signature": "(i32) -> (i32)" + }, + { + "kind": "func", + "name": "kernel_pcm_reconcile", + "signature": "() -> (i32)" + }, + { + "kind": "func", + "name": "kernel_pcm_transport_len", + "signature": "() -> (i32)" + }, + { + "kind": "func", + "name": "kernel_pcm_transport_ptr", + "signature": "() -> (i32)" + }, { "kind": "func", "name": "kernel_pick_signal_target_tid", @@ -3161,6 +3323,60 @@ } ], "marshalled_structs": { + "AudioBufInfo": { + "align": 4, + "fields": [ + { + "name": "fragments", + "offset": 0, + "span": 4, + "type": "i32" + }, + { + "name": "fragstotal", + "offset": 4, + "span": 4, + "type": "i32" + }, + { + "name": "fragsize", + "offset": 8, + "span": 4, + "type": "i32" + }, + { + "name": "bytes", + "offset": 12, + "span": 4, + "type": "i32" + } + ], + "size": 16 + }, + "CountInfo": { + "align": 4, + "fields": [ + { + "name": "bytes", + "offset": 0, + "span": 4, + "type": "i32" + }, + { + "name": "blocks", + "offset": 4, + "span": 4, + "type": "i32" + }, + { + "name": "ptr", + "offset": 8, + "span": 4, + "type": "i32" + } + ], + "size": 12 + }, "FbBitfield": { "fields": [ { @@ -3621,6 +3837,174 @@ ], "size": 160 }, + "PcmSharedControl": { + "align": 4, + "fields": [ + { + "name": "magic", + "offset": 0, + "span": 4, + "type": "u32" + }, + { + "name": "version", + "offset": 4, + "span": 4, + "type": "u32" + }, + { + "name": "header_bytes", + "offset": 8, + "span": 4, + "type": "u32" + }, + { + "name": "physical_capacity_bytes", + "offset": 12, + "span": 4, + "type": "u32" + }, + { + "name": "active_capacity_bytes", + "offset": 16, + "span": 4, + "type": "u32" + }, + { + "name": "format", + "offset": 20, + "span": 4, + "type": "u32" + }, + { + "name": "rate", + "offset": 24, + "span": 4, + "type": "u32" + }, + { + "name": "channels", + "offset": 28, + "span": 4, + "type": "u32" + }, + { + "name": "frame_bytes", + "offset": 32, + "span": 4, + "type": "u32" + }, + { + "name": "fragment_bytes", + "offset": 36, + "span": 4, + "type": "u32" + }, + { + "name": "fragment_count", + "offset": 40, + "span": 4, + "type": "u32" + }, + { + "name": "state", + "offset": 44, + "span": 4, + "type": "u32" + }, + { + "name": "generation", + "offset": 48, + "span": 4, + "type": "u32" + }, + { + "name": "flags", + "offset": 52, + "span": 4, + "type": "u32" + }, + { + "name": "transport_mode", + "offset": 56, + "span": 4, + "type": "u32" + }, + { + "name": "producer_seq", + "offset": 60, + "span": 4, + "type": "u32" + }, + { + "name": "producer_lo", + "offset": 64, + "span": 4, + "type": "u32" + }, + { + "name": "producer_hi", + "offset": 68, + "span": 4, + "type": "u32" + }, + { + "name": "consumer_seq", + "offset": 72, + "span": 4, + "type": "u32" + }, + { + "name": "consumer_lo", + "offset": 76, + "span": 4, + "type": "u32" + }, + { + "name": "consumer_hi", + "offset": 80, + "span": 4, + "type": "u32" + }, + { + "name": "discard_seq", + "offset": 84, + "span": 4, + "type": "u32" + }, + { + "name": "discard_lo", + "offset": 88, + "span": 4, + "type": "u32" + }, + { + "name": "discard_hi", + "offset": 92, + "span": 4, + "type": "u32" + }, + { + "name": "underruns", + "offset": 96, + "span": 4, + "type": "u32" + }, + { + "name": "wake_seq", + "offset": 100, + "span": 4, + "type": "u32" + }, + { + "name": "reserved", + "offset": 104, + "span": 24, + "type": "[u32; 6]" + } + ], + "size": 128 + }, "WasmDirent": { "fields": [ { @@ -4677,6 +5061,83 @@ "size": 16 } }, + "oss_source_abi": { + "capabilities": { + "PCM_CAP_BATCH": 1024, + "PCM_CAP_BIND": 32768, + "PCM_CAP_COPROC": 2048, + "PCM_CAP_DEFAULT": 1073741824, + "PCM_CAP_DUPLEX": 256, + "PCM_CAP_INPUT": 65536, + "PCM_CAP_MMAP": 8192, + "PCM_CAP_MULTI": 16384, + "PCM_CAP_OUTPUT": 131072, + "PCM_CAP_REALTIME": 512, + "PCM_CAP_REVISION": 255, + "PCM_CAP_TRIGGER": 4096, + "PCM_CAP_VIRTUAL": 262144 + }, + "formats": { + "AFMT_AC3": 1024, + "AFMT_A_LAW": 2, + "AFMT_F32_BE": 536870912, + "AFMT_F32_LE": 268435456, + "AFMT_IMA_ADPCM": 4, + "AFMT_MPEG": 512, + "AFMT_MU_LAW": 1, + "AFMT_QUERY": 0, + "AFMT_S16_BE": 32, + "AFMT_S16_LE": 16, + "AFMT_S24_BE": 131072, + "AFMT_S24_LE": 65536, + "AFMT_S32_BE": 8192, + "AFMT_S32_LE": 4096, + "AFMT_S8": 64, + "AFMT_U16_BE": 256, + "AFMT_U16_LE": 128, + "AFMT_U24_BE": 524288, + "AFMT_U24_LE": 262144, + "AFMT_U32_BE": 32768, + "AFMT_U32_LE": 16384, + "AFMT_U8": 8 + }, + "ioctls": { + "SNDCTL_DSP_CHANNELS": 3221508102, + "SNDCTL_DSP_GETBLKSIZE": 3221508100, + "SNDCTL_DSP_GETCAPS": 2147766287, + "SNDCTL_DSP_GETFMTS": 2147766283, + "SNDCTL_DSP_GETIPTR": 2148290577, + "SNDCTL_DSP_GETISPACE": 2148552717, + "SNDCTL_DSP_GETODELAY": 2147766295, + "SNDCTL_DSP_GETOPTR": 2148290578, + "SNDCTL_DSP_GETOSPACE": 2148552716, + "SNDCTL_DSP_GETTRIGGER": 2147766288, + "SNDCTL_DSP_MAPINBUF": 2148028435, + "SNDCTL_DSP_MAPOUTBUF": 2148028436, + "SNDCTL_DSP_NONBLOCK": 20494, + "SNDCTL_DSP_POST": 20488, + "SNDCTL_DSP_RESET": 20480, + "SNDCTL_DSP_SETBLKSIZE": 1074024452, + "SNDCTL_DSP_SETDUPLEX": 20502, + "SNDCTL_DSP_SETFMT": 3221508101, + "SNDCTL_DSP_SETFRAGMENT": 3221508106, + "SNDCTL_DSP_SETSYNCRO": 20501, + "SNDCTL_DSP_SETTRIGGER": 1074024464, + "SNDCTL_DSP_SPEED": 3221508098, + "SNDCTL_DSP_STEREO": 3221508099, + "SNDCTL_DSP_SUBDIVIDE": 3221508105, + "SNDCTL_DSP_SYNC": 20481, + "SOUND_PCM_READ_BITS": 2147766277, + "SOUND_PCM_READ_CHANNELS": 2147766278, + "SOUND_PCM_READ_FILTER": 2147766279, + "SOUND_PCM_READ_RATE": 2147766274, + "SOUND_PCM_WRITE_FILTER": 3221508103 + }, + "trigger_values": { + "PCM_ENABLE_INPUT": 1, + "PCM_ENABLE_OUTPUT": 2 + } + }, "pathconf_names": { "ALLOC_SIZE_MIN": 18, "ASYNC_IO": 10, @@ -4703,6 +5164,27 @@ "TIMESTAMP_RESOLUTION": 23, "VDISABLE": 8 }, + "pcm_transport_abi": { + "flag_configuring": 1, + "flag_fatal_error": 4, + "flag_underrun_active": 2, + "format_s16_be": 3, + "format_s16_le": 2, + "format_u8": 1, + "format_unknown": 0, + "header_bytes": 128, + "magic": 827147088, + "ring_bytes": 65536, + "state_closed": 0, + "state_draining": 3, + "state_running": 2, + "state_stopped": 1, + "total_bytes": 65664, + "transport_legacy_pull": 1, + "transport_shared_clock": 2, + "transport_unclaimed": 0, + "version": 1 + }, "platform_limits": { "arg_max_bytes": 4194304, "fd_set_bytes": 128, diff --git a/crates/kernel/src/audio.rs b/crates/kernel/src/audio.rs index 86aea81e81..90c9640052 100644 --- a/crates/kernel/src/audio.rs +++ b/crates/kernel/src/audio.rs @@ -1,300 +1,1310 @@ -//! `/dev/dsp` — OSS-style PCM audio sink. +//! Implementation-neutral, playback-only PCM stream core. //! -//! Surface mirrors what the Linux Open Sound System (OSS) `dsp` device -//! exposes: a character device that user-space writes raw PCM frames -//! into, with a handful of `ioctl`s for sample rate / format / channel -//! count. The kernel does **not** synthesize or mix audio — DOOM's own -//! mixer fills its 16-bit-stereo buffer and `write()`s it here. The host -//! periodically drains the resulting byte stream via -//! [`drain_into`] (exposed as the `kernel_drain_audio` wasm export) and -//! feeds it to a Web Audio AudioContext. -//! -//! ## Format -//! -//! We accept exactly the format fbDOOM (and most OSS clients) configure -//! by default: signed-16-bit little-endian, stereo, ~11025–48000 Hz. The -//! `ioctl` handler validates each request and stores the chosen rate / -//! channel count so the host can pick them up via -//! [`current_config`]; anything else is `EINVAL`. -//! -//! ## Single-owner -//! -//! Like `/dev/fb0` and `/dev/input/mice`, `/dev/dsp` is single-open. A -//! second `open` from a different pid is `EBUSY`. Re-opens by the -//! current owner are accepted (matches the typical OSS exclusive-grab -//! model). A non-CLOEXEC fd retains ownership and queued samples across -//! `execve`; last close or process exit releases ownership and clears the -//! ring so a successor open starts from silence. -//! -//! ## Backpressure -//! -//! The ring is bounded ([`MAX_QUEUED_BYTES`]) so a misbehaving program -//! that writes faster than the host drains can't OOM the kernel. When -//! the ring fills up, the oldest **whole frame** is dropped to make -//! room — never a partial frame, since downstream tooling assumes -//! interleaved L/R samples are paired. This is the same trade-off OSS -//! made on overrun: drop now, keep recent audio. +//! OSS `/dev/dsp` is a frontend in `syscalls.rs`. The authoritative playback +//! state is a refcounted open-file-description backing, while one fixed, +//! versioned transport exposes the default physical device to browser and +//! Node audio clocks. No mixer, routing policy, capture, or Web Audio concept +//! is part of this module's guest-facing model. extern crate alloc; -use alloc::collections::VecDeque; use core::cell::UnsafeCell; -use core::sync::atomic::{AtomicI32, AtomicU32, Ordering}; - -/// Owning pid of `/dev/dsp`, or `-1` if free. -pub(crate) static DSP_OWNER: AtomicI32 = AtomicI32::new(-1); - -/// Currently configured sample rate (Hz). Defaults to fbDOOM's preferred -/// 11025 Hz so a process that opens the device without ever calling -/// `SNDCTL_DSP_SPEED` still produces something playable. -pub(crate) static SAMPLE_RATE: AtomicU32 = AtomicU32::new(11025); - -/// Currently configured channel count (1 = mono, 2 = stereo). Defaults -/// to stereo to match fbDOOM's default. -pub(crate) static CHANNELS: AtomicU32 = AtomicU32::new(2); - -/// Bytes per S16_LE sample times channel count. Used to align ring -/// drops and reads to whole frames. -fn frame_bytes() -> usize { - 2 * (CHANNELS.load(Ordering::Relaxed) as usize).max(1) -} - -/// Ring capacity in bytes. ~256 KiB → ~1.5 s of stereo S16 at 44100 Hz, -/// or ~6 s at 11025 Hz. Generous enough that the host can fall behind a -/// few RAFs without dropping audio, small enough that kernel memory -/// pressure stays bounded. -const MAX_QUEUED_BYTES: usize = 256 * 1024; - -struct GlobalRing(UnsafeCell>); -unsafe impl Sync for GlobalRing {} - -static RING: GlobalRing = GlobalRing(UnsafeCell::new(VecDeque::new())); - -fn ring() -> &'static mut VecDeque { - unsafe { &mut *RING.0.get() } -} - -/// PCM format the device accepts. Matches OSS `AFMT_S16_LE` — signed -/// 16-bit little-endian. We don't allow other formats; `set_format` -/// rejects anything else with `EINVAL`. -pub(crate) const AFMT_S16_LE: u32 = 0x10; - -/// Append `data` to the ring as raw bytes. Drops oldest *whole frames* -/// to fit `data.len()` if the ring is near capacity. Returns the number -/// of bytes actually buffered (always `data.len()` — overflow is -/// silent, mirroring what a real OSS device does on hardware overrun). -pub fn write_pcm(data: &[u8]) { - let r = ring(); - let frame = frame_bytes(); - while r.len() + data.len() > MAX_QUEUED_BYTES { - // Drop one frame from the front. A frame is 2-channel S16 by - // default = 4 bytes. Tearing a frame would shift L/R alignment - // for every subsequent drain, producing inverted-channel hiss. - let drop = frame.min(r.len()); - if drop == 0 { - break; +use core::sync::atomic::{AtomicBool, AtomicI32, AtomicU32, Ordering}; + +use wasm_posix_shared::{Errno, pcm}; + +const DEFAULT_RATE: u32 = 48_000; +const DEFAULT_CHANNELS: u32 = 2; +const DEFAULT_FORMAT: u32 = pcm::PCM_FORMAT_S16_LE; +const DEFAULT_FRAGMENT_BYTES: u32 = 1024; +const DEFAULT_FRAGMENT_COUNT: u32 = 4; +const MIN_RATE: u32 = 8_000; +const MAX_RATE: u32 = 192_000; +const MIN_FRAGMENT_EXP: u32 = 4; +const MAX_FRAGMENT_EXP: u32 = 16; +const PCM_WAKE_BASE: u32 = 0x2000_0000; + +/// State owned by one logical PCM open file description. Forked process OFD +/// copies refer to the same backing table entry; `dup` aliases the local OFD. +#[derive(Debug)] +pub struct PcmStream { + pub requested_format: u32, + pub actual_format: u32, + pub requested_rate: u32, + pub actual_rate: u32, + pub requested_channels: u32, + pub actual_channels: u32, + pub fragment_bytes: u32, + pub fragment_count: u32, + pub optr_played_base: u64, + pub optr_consumer_base: u64, + pub last_optr_blocks: u64, + pub nonblock: bool, +} + +/// Implementation-neutral output-buffer availability returned to frontends. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PcmOutputSpace { + pub available_fragments: u32, + pub total_fragments: u32, + pub fragment_bytes: u32, + pub available_bytes: u32, +} + +/// Implementation-neutral audio-clock playback position returned to frontends. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PcmPosition { + pub played_bytes: u64, + pub completed_fragments: u32, + pub ring_offset: u32, +} + +impl PcmStream { + fn new() -> Self { + Self { + requested_format: DEFAULT_FORMAT, + actual_format: DEFAULT_FORMAT, + requested_rate: DEFAULT_RATE, + actual_rate: DEFAULT_RATE, + requested_channels: DEFAULT_CHANNELS, + actual_channels: DEFAULT_CHANNELS, + fragment_bytes: DEFAULT_FRAGMENT_BYTES, + fragment_count: DEFAULT_FRAGMENT_COUNT, + optr_played_base: 0, + optr_consumer_base: 0, + last_optr_blocks: 0, + nonblock: false, } - for _ in 0..drop { - r.pop_front(); + } + + fn frame_bytes(&self) -> u32 { + sample_bytes(self.actual_format) * self.actual_channels + } + + fn capacity(&self) -> u32 { + self.fragment_bytes * self.fragment_count + } +} + +#[repr(C)] +struct AtomicPcmControl { + magic: AtomicU32, + version: AtomicU32, + header_bytes: AtomicU32, + physical_capacity_bytes: AtomicU32, + active_capacity_bytes: AtomicU32, + format: AtomicU32, + rate: AtomicU32, + channels: AtomicU32, + frame_bytes: AtomicU32, + fragment_bytes: AtomicU32, + fragment_count: AtomicU32, + state: AtomicU32, + generation: AtomicU32, + flags: AtomicU32, + transport_mode: AtomicU32, + producer_seq: AtomicU32, + producer_lo: AtomicU32, + producer_hi: AtomicU32, + consumer_seq: AtomicU32, + consumer_lo: AtomicU32, + consumer_hi: AtomicU32, + discard_seq: AtomicU32, + discard_lo: AtomicU32, + discard_hi: AtomicU32, + underruns: AtomicU32, + wake_seq: AtomicU32, + reserved: [AtomicU32; 6], +} + +impl AtomicPcmControl { + const fn new() -> Self { + Self { + magic: AtomicU32::new(pcm::PCM_TRANSPORT_MAGIC), + version: AtomicU32::new(pcm::PCM_TRANSPORT_VERSION), + header_bytes: AtomicU32::new(pcm::PCM_TRANSPORT_HEADER_BYTES), + physical_capacity_bytes: AtomicU32::new(pcm::PCM_TRANSPORT_RING_BYTES), + active_capacity_bytes: AtomicU32::new(DEFAULT_FRAGMENT_BYTES * DEFAULT_FRAGMENT_COUNT), + format: AtomicU32::new(DEFAULT_FORMAT), + rate: AtomicU32::new(DEFAULT_RATE), + channels: AtomicU32::new(DEFAULT_CHANNELS), + frame_bytes: AtomicU32::new(4), + fragment_bytes: AtomicU32::new(DEFAULT_FRAGMENT_BYTES), + fragment_count: AtomicU32::new(DEFAULT_FRAGMENT_COUNT), + state: AtomicU32::new(pcm::PCM_STATE_CLOSED), + generation: AtomicU32::new(0), + flags: AtomicU32::new(0), + transport_mode: AtomicU32::new(pcm::PCM_TRANSPORT_UNCLAIMED), + producer_seq: AtomicU32::new(0), + producer_lo: AtomicU32::new(0), + producer_hi: AtomicU32::new(0), + consumer_seq: AtomicU32::new(0), + consumer_lo: AtomicU32::new(0), + consumer_hi: AtomicU32::new(0), + discard_seq: AtomicU32::new(0), + discard_lo: AtomicU32::new(0), + discard_hi: AtomicU32::new(0), + underruns: AtomicU32::new(0), + wake_seq: AtomicU32::new(0), + reserved: [const { AtomicU32::new(0) }; 6], } } - for &b in data { - r.push_back(b); +} + +#[repr(C, align(64))] +struct SharedTransport { + control: AtomicPcmControl, + ring: UnsafeCell<[u8; pcm::PCM_TRANSPORT_RING_BYTES as usize]>, +} + +// SAFETY: ring bytes have one kernel producer and one host audio-clock +// consumer. Release/acquire publication of the cursors synchronizes access. +unsafe impl Sync for SharedTransport {} + +static TRANSPORT: SharedTransport = SharedTransport { + control: AtomicPcmControl::new(), + ring: UnsafeCell::new([0; pcm::PCM_TRANSPORT_RING_BYTES as usize]), +}; + +static ACTIVE_STREAM: AtomicI32 = AtomicI32::new(-1); +static ORPHAN_DRAINING: AtomicBool = AtomicBool::new(false); + +struct KernelCursor(UnsafeCell); +unsafe impl Sync for KernelCursor {} +static LAST_RECONCILED_CONSUMER: KernelCursor = KernelCursor(UnsafeCell::new(0)); +static PLAYED_BYTES: KernelCursor = KernelCursor(UnsafeCell::new(0)); + +fn sample_bytes(format: u32) -> u32 { + match format { + pcm::PCM_FORMAT_U8 => 1, + pcm::PCM_FORMAT_S16_LE | pcm::PCM_FORMAT_S16_BE => 2, + _ => 0, + } +} + +fn read_cursor(seq: &AtomicU32, lo: &AtomicU32, hi: &AtomicU32) -> u64 { + loop { + let before = seq.load(Ordering::Acquire); + if before & 1 != 0 { + core::hint::spin_loop(); + continue; + } + let low = lo.load(Ordering::Relaxed); + let high = hi.load(Ordering::Relaxed); + let after = seq.load(Ordering::Acquire); + if before == after { + return ((high as u64) << 32) | low as u64; + } } } -/// Drain up to `out.len()` bytes from the ring into `out`. Returns the -/// number of bytes copied. Stops at whole-frame boundaries — never -/// returns a torn frame. -pub fn drain_into(out: &mut [u8]) -> usize { - let r = ring(); - let frame = frame_bytes(); - let avail = r.len(); - let want = out.len(); - // Round both ends down to a whole frame so the host always receives - // L/R pairs (when stereo) — feeding a torn frame to AudioContext - // would swap channels for the rest of the stream. - let n = core::cmp::min(want, avail); - let n = (n / frame) * frame; - for i in 0..n { - out[i] = r.pop_front().unwrap_or(0); +fn write_cursor(seq: &AtomicU32, lo: &AtomicU32, hi: &AtomicU32, value: u64) { + seq.fetch_add(1, Ordering::AcqRel); + lo.store(value as u32, Ordering::Relaxed); + hi.store((value >> 32) as u32, Ordering::Relaxed); + seq.fetch_add(1, Ordering::Release); +} + +fn producer() -> u64 { + let c = &TRANSPORT.control; + read_cursor(&c.producer_seq, &c.producer_lo, &c.producer_hi) +} + +fn consumer() -> u64 { + let c = &TRANSPORT.control; + read_cursor(&c.consumer_seq, &c.consumer_lo, &c.consumer_hi) +} + +fn discard() -> u64 { + let c = &TRANSPORT.control; + read_cursor(&c.discard_seq, &c.discard_lo, &c.discard_hi) +} + +fn effective_consumer() -> u64 { + consumer().max(discard()).min(producer()) +} + +fn queued_bytes() -> u64 { + producer().saturating_sub(effective_consumer()) +} + +fn publish_producer(value: u64) { + let c = &TRANSPORT.control; + write_cursor(&c.producer_seq, &c.producer_lo, &c.producer_hi, value); +} + +fn publish_consumer(value: u64) { + let c = &TRANSPORT.control; + write_cursor(&c.consumer_seq, &c.consumer_lo, &c.consumer_hi, value); +} + +fn publish_discard(value: u64) { + let c = &TRANSPORT.control; + write_cursor(&c.discard_seq, &c.discard_lo, &c.discard_hi, value); +} + +pub fn stream_handle(idx: usize) -> i64 { + -(idx as i64) - 1 +} + +pub fn stream_index(handle: i64) -> Result { + if handle >= 0 { + return Err(Errno::EBADF); } - n + usize::try_from(-(handle + 1)).map_err(|_| Errno::EBADF) } -/// Bytes currently buffered. -pub fn pending_bytes() -> usize { - ring().len() +fn wake_token(idx: usize) -> u32 { + PCM_WAKE_BASE.saturating_add(idx as u32) +} + +pub fn wake_token_for_handle(handle: i64) -> Result { + Ok(wake_token(stream_index(handle)?)) } -/// Drop all buffered samples. Called when the owner exits or closes its last -/// fd, and by `SNDCTL_DSP_RESET`. -pub fn reset() { - ring().clear(); +fn configure_transport(stream: &PcmStream) { + let c = &TRANSPORT.control; + c.active_capacity_bytes + .store(stream.capacity(), Ordering::Release); + c.format.store(stream.actual_format, Ordering::Release); + c.rate.store(stream.actual_rate, Ordering::Release); + c.channels.store(stream.actual_channels, Ordering::Release); + c.frame_bytes.store(stream.frame_bytes(), Ordering::Release); + c.fragment_bytes + .store(stream.fragment_bytes, Ordering::Release); + c.fragment_count + .store(stream.fragment_count, Ordering::Release); } -/// Set the sample rate (Hz). Returns the rate actually stored — OSS -/// behavior: clamp to a hardware-sensible range and report back what we -/// landed on. We accept the full range fbDOOM and similar consumers -/// emit (11025, 22050, 44100, 48000) plus reasonable extremes. -pub fn set_sample_rate(hz: u32) -> u32 { - let clamped = hz.clamp(4000, 192000); - SAMPLE_RATE.store(clamped, Ordering::Relaxed); - clamped +/// Publish configuration as one host-observable generation. The configuring +/// bit closes the interval in which the individual atomic fields would +/// otherwise form a torn snapshot for a concurrent host audio clock. +fn publish_configuration(stream: &PcmStream) { + let c = &TRANSPORT.control; + c.flags + .fetch_or(pcm::PCM_FLAG_CONFIGURING, Ordering::AcqRel); + configure_transport(stream); + c.generation.fetch_add(1, Ordering::AcqRel); + c.flags.fetch_and( + !(pcm::PCM_FLAG_CONFIGURING | pcm::PCM_FLAG_UNDERRUN_ACTIVE), + Ordering::Release, + ); } -/// Set channel count. Returns the count actually stored. Accepts 1 or -/// 2; anything else clamps to 2 (matches what real OSS drivers tend to -/// do — most cards don't support 3+ channels in dsp mode). -pub fn set_channels(n: u32) -> u32 { - let n = if n == 1 { 1 } else { 2 }; - CHANNELS.store(n, Ordering::Relaxed); +fn clear_underrun_episode() { + TRANSPORT + .control + .flags + .fetch_and(!pcm::PCM_FLAG_UNDERRUN_ACTIVE, Ordering::AcqRel); +} + +fn note_underrun_episode() { + let was_active = TRANSPORT + .control + .flags + .fetch_or(pcm::PCM_FLAG_UNDERRUN_ACTIVE, Ordering::AcqRel) + & pcm::PCM_FLAG_UNDERRUN_ACTIVE + != 0; + if !was_active { + TRANSPORT.control.underruns.fetch_add(1, Ordering::AcqRel); + } +} + +fn enter_stopped_generation() { + clear_underrun_episode(); + TRANSPORT + .control + .state + .store(pcm::PCM_STATE_STOPPED, Ordering::Release); + TRANSPORT.control.generation.fetch_add(1, Ordering::AcqRel); +} + +pub fn has_fatal_error(handle: i64) -> Result { + with_stream(handle, |_| { + TRANSPORT.control.flags.load(Ordering::Acquire) & pcm::PCM_FLAG_FATAL_ERROR != 0 + }) +} + +fn reset_transport_for_open(stream: &mut PcmStream) { + let c = &TRANSPORT.control; + c.state.store(pcm::PCM_STATE_CLOSED, Ordering::Release); + + // The host audio clock can still be finishing a quantum from the previous + // generation while RESET + close + reopen runs in the kernel worker. Keep + // the live transport cursors monotonic across opens so such a stale + // consumer publication can never jump ahead of the new stream. The new + // discard floor makes every byte from the prior generation unreachable; + // subsequent writes continue from the same absolute producer position. + let base = producer(); + publish_discard(base); + unsafe { *LAST_RECONCILED_CONSUMER.0.get() = base }; + unsafe { *PLAYED_BYTES.0.get() = 0 }; + stream.optr_played_base = 0; + stream.optr_consumer_base = base; + stream.last_optr_blocks = 0; + c.underruns.store(0, Ordering::Release); + publish_configuration(stream); + c.state.store(pcm::PCM_STATE_STOPPED, Ordering::Release); + ORPHAN_DRAINING.store(false, Ordering::Release); +} + +/// Allocate and exclusively claim the one default playback stream. +pub fn open_stream() -> Result { + if ACTIVE_STREAM.load(Ordering::Acquire) != -1 { + return Err(Errno::EBUSY); + } + let stream = PcmStream::new(); + let idx = crate::descriptor_backing::with_pcm_streams(|table| table.alloc(stream)); + if ACTIVE_STREAM + .compare_exchange(-1, idx as i32, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + crate::descriptor_backing::with_pcm_streams(|table| { + table.release(idx); + }); + return Err(Errno::EBUSY); + } + crate::descriptor_backing::with_pcm_streams(|table| { + let stream = table.get_mut(idx).expect("new PCM backing"); + reset_transport_for_open(stream); + }); + Ok(stream_handle(idx)) +} + +pub fn rollback_open(handle: i64) { + if let Ok(idx) = stream_index(handle) { + crate::descriptor_backing::with_pcm_streams(|table| { + table.release(idx); + }); + finish_closed(idx); + } +} + +fn finish_closed(idx: usize) { + if ACTIVE_STREAM + .compare_exchange(idx as i32, -1, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + ORPHAN_DRAINING.store(false, Ordering::Release); + clear_underrun_episode(); + TRANSPORT + .control + .state + .store(pcm::PCM_STATE_CLOSED, Ordering::Release); + } +} + +/// Called after the final cross-process OFD reference disappears. A queued +/// tail becomes an orphan drain and keeps exclusive ownership until the audio +/// clock reaches the producer; empty streams release immediately. +pub fn on_last_ofd_released(idx: usize) { + if ACTIVE_STREAM.load(Ordering::Acquire) != idx as i32 { + return; + } + if TRANSPORT.control.flags.load(Ordering::Acquire) & pcm::PCM_FLAG_FATAL_ERROR != 0 { + publish_discard(producer()); + finish_closed(idx); + return; + } + pad_terminal_frame(); + if queued_bytes() == 0 { + finish_closed(idx); + } else { + ORPHAN_DRAINING.store(true, Ordering::Release); + clear_underrun_episode(); + TRANSPORT + .control + .state + .store(pcm::PCM_STATE_DRAINING, Ordering::Release); + } +} + +fn with_stream(handle: i64, f: impl FnOnce(&mut PcmStream) -> R) -> Result { + let idx = stream_index(handle)?; + crate::descriptor_backing::with_pcm_streams(|table| { + table.get_mut(idx).map(f).ok_or(Errno::EBADF) + }) +} + +fn ensure_configurable() -> Result<(), Errno> { + match TRANSPORT.control.state.load(Ordering::Acquire) { + pcm::PCM_STATE_RUNNING | pcm::PCM_STATE_DRAINING => Err(Errno::EBUSY), + _ => Ok(()), + } +} + +pub fn set_rate(handle: i64, requested: u32) -> Result { + if requested == 0 { + return with_stream(handle, |stream| stream.actual_rate); + } + ensure_configurable()?; + with_stream(handle, |stream| { + stream.requested_rate = requested; + stream.actual_rate = requested.clamp(MIN_RATE, MAX_RATE); + publish_configuration(stream); + stream.actual_rate + }) +} + +pub fn set_channels(handle: i64, requested: u32) -> Result { + if requested == 0 { + return with_stream(handle, |stream| stream.actual_channels); + } + ensure_configurable()?; + with_stream(handle, |stream| { + stream.requested_channels = requested; + stream.actual_channels = if requested == 1 { 1 } else { 2 }; + publish_configuration(stream); + stream.actual_channels + }) +} + +pub fn set_format(handle: i64, requested: u32) -> Result { + if requested == pcm::PCM_FORMAT_UNKNOWN { + return with_stream(handle, |stream| stream.actual_format); + } + if !matches!( + requested, + pcm::PCM_FORMAT_U8 | pcm::PCM_FORMAT_S16_LE | pcm::PCM_FORMAT_S16_BE + ) { + return Err(Errno::EINVAL); + } + ensure_configurable()?; + with_stream(handle, |stream| { + stream.requested_format = requested; + stream.actual_format = requested; + publish_configuration(stream); + stream.actual_format + }) +} + +pub fn set_fragment(handle: i64, encoded: u32) -> Result { + ensure_configurable()?; + with_stream(handle, |stream| { + let exp = (encoded & 0xffff).clamp(MIN_FRAGMENT_EXP, MAX_FRAGMENT_EXP); + let fragment_bytes = 1u32 << exp; + let requested_count = encoded >> 16; + let max_count = (pcm::PCM_TRANSPORT_RING_BYTES / fragment_bytes).max(1); + let fragment_count = if requested_count == 0 { + max_count + } else { + requested_count.clamp(max_count.min(2), max_count) + }; + stream.fragment_bytes = fragment_bytes; + stream.fragment_count = fragment_count; + let played = unsafe { *PLAYED_BYTES.0.get() }; + stream.last_optr_blocks = + played.saturating_sub(stream.optr_played_base) / fragment_bytes as u64; + publish_configuration(stream); + (fragment_count << 16) | exp + }) +} + +pub fn config(handle: i64) -> Result<(u32, u32, u32), Errno> { + with_stream(handle, |stream| { + ( + stream.actual_format, + stream.actual_rate, + stream.actual_channels, + ) + }) +} + +pub fn geometry(handle: i64) -> Result<(u32, u32), Errno> { + with_stream(handle, |stream| { + (stream.fragment_bytes, stream.fragment_count) + }) +} + +pub fn set_nonblock(handle: i64, enabled: bool) -> Result<(), Errno> { + with_stream(handle, |stream| stream.nonblock = enabled) +} + +pub fn is_nonblock(handle: i64) -> Result { + with_stream(handle, |stream| stream.nonblock) +} + +pub fn write(handle: i64, data: &[u8], nonblock: bool) -> Result { + if data.is_empty() { + return Ok(0); + } + with_stream(handle, |stream| { + if TRANSPORT.control.flags.load(Ordering::Acquire) & pcm::PCM_FLAG_FATAL_ERROR != 0 { + return Err(Errno::EIO); + } + let capacity = stream.capacity() as usize; + let queued = queued_bytes().min(capacity as u64) as usize; + let free = capacity.saturating_sub(queued); + if free == 0 { + return Err(Errno::EAGAIN); + } + if !nonblock && data.len() <= capacity && free < data.len() { + // A queued partial frame cannot be consumed by the audio clock. + // Permit a short write when it completes that frame; insisting on + // room for the entire request here would deadlock both sides. + let frame = stream.frame_bytes().max(1) as usize; + let remainder = queued % frame; + let completes_partial = remainder != 0 && free >= frame - remainder; + if !completes_partial { + return Err(Errno::EAGAIN); + } + } + let n = data.len().min(free); + if n == 0 { + return Err(Errno::EAGAIN); + } + let producer_before = producer(); + let start = (producer_before % capacity as u64) as usize; + let first = n.min(capacity - start); + unsafe { + let ring = (*TRANSPORT.ring.get()).as_mut_ptr(); + core::ptr::copy_nonoverlapping(data.as_ptr(), ring.add(start), first); + if first < n { + core::ptr::copy_nonoverlapping(data.as_ptr().add(first), ring, n - first); + } + } + publish_producer(producer_before + n as u64); + TRANSPORT + .control + .state + .store(pcm::PCM_STATE_RUNNING, Ordering::Release); + Ok(n) + })? +} + +/// Complete a final partial frame before a drain. OSS exposes `/dev/dsp` as a +/// byte stream, while the physical sink advances in whole frames. Padding is +/// therefore part of drain/close, not an alignment restriction on `write()`. +fn pad_terminal_frame() { + let frame = TRANSPORT.control.frame_bytes.load(Ordering::Acquire).max(1) as u64; + let queued = queued_bytes(); + let remainder = queued % frame; + if remainder == 0 { + return; + } + let padding = (frame - remainder) as usize; + let capacity = TRANSPORT + .control + .active_capacity_bytes + .load(Ordering::Acquire) as usize; + if capacity == 0 || queued.saturating_add(padding as u64) > capacity as u64 { + return; + } + let fill = if TRANSPORT.control.format.load(Ordering::Acquire) == pcm::PCM_FORMAT_U8 { + 0x80 + } else { + 0 + }; + let before = producer(); + let start = (before % capacity as u64) as usize; + unsafe { + let ring = (*TRANSPORT.ring.get()).as_mut_ptr(); + for offset in 0..padding { + ring.add((start + offset) % capacity).write(fill); + } + } + publish_producer(before + padding as u64); +} + +pub fn poll_writable(handle: i64) -> Result { + with_stream(handle, |stream| { + if TRANSPORT.control.flags.load(Ordering::Acquire) & pcm::PCM_FLAG_FATAL_ERROR != 0 { + return Err(Errno::EIO); + } + let free = stream.capacity() as u64 - queued_bytes().min(stream.capacity() as u64); + Ok(free >= stream.fragment_bytes as u64) + })? +} + +pub fn output_space(handle: i64) -> Result { + with_stream(handle, |stream| { + if TRANSPORT.control.flags.load(Ordering::Acquire) & pcm::PCM_FLAG_FATAL_ERROR != 0 { + return Err(Errno::EIO); + } + let free = stream.capacity() as u64 - queued_bytes().min(stream.capacity() as u64); + Ok(PcmOutputSpace { + available_fragments: (free / stream.fragment_bytes as u64) as u32, + total_fragments: stream.fragment_count, + fragment_bytes: stream.fragment_bytes, + available_bytes: free as u32, + }) + })? +} + +pub fn output_delay(handle: i64) -> Result { + with_stream(handle, |stream| { + queued_bytes().min(stream.capacity() as u64) as i32 + }) +} + +pub fn output_pointer(handle: i64) -> Result { + with_stream(handle, |_| ())?; + // GETOPTR observes the audio clock, so first account for a consumer + // publication that has not yet passed through the syscall path. + reconcile(); + with_stream(handle, |stream| { + let played = unsafe { *PLAYED_BYTES.0.get() }.saturating_sub(stream.optr_played_base); + let blocks = played / stream.fragment_bytes as u64; + let delta = blocks.saturating_sub(stream.last_optr_blocks); + stream.last_optr_blocks = blocks; + PcmPosition { + played_bytes: played, + completed_fragments: delta.min(u32::MAX as u64) as u32, + ring_offset: (effective_consumer().saturating_sub(stream.optr_consumer_base) + % stream.capacity() as u64) as u32, + } + }) +} + +pub fn post(handle: i64) -> Result<(), Errno> { + with_stream(handle, |_| ())?; + if has_fatal_error(handle)? { + return Err(Errno::EIO); + } + clear_underrun_episode(); + TRANSPORT + .control + .state + .store(pcm::PCM_STATE_RUNNING, Ordering::Release); + Ok(()) +} + +pub fn sync(handle: i64) -> Result<(), Errno> { + with_stream(handle, |_| ())?; + if has_fatal_error(handle)? { + return Err(Errno::EIO); + } + pad_terminal_frame(); + reconcile(); + if queued_bytes() == 0 { + enter_stopped_generation(); + Ok(()) + } else { + clear_underrun_episode(); + TRANSPORT + .control + .state + .store(pcm::PCM_STATE_DRAINING, Ordering::Release); + Err(Errno::EAGAIN) + } +} + +pub fn reset_stream(handle: i64) -> Result<(), Errno> { + with_stream(handle, |_| ())?; + // Account for a shared-clock consumer update before discard changes the + // effective-consumer floor; otherwise RESET could erase played position. + reconcile(); + let end = producer(); + publish_discard(end); + TRANSPORT.control.generation.fetch_add(1, Ordering::AcqRel); + TRANSPORT + .control + .state + .store(pcm::PCM_STATE_STOPPED, Ordering::Release); + clear_underrun_episode(); + reconcile(); + let played_base = unsafe { *PLAYED_BYTES.0.get() }; + let consumer_base = effective_consumer(); + with_stream(handle, |stream| { + stream.optr_played_base = played_base; + stream.optr_consumer_base = consumer_base; + stream.last_optr_blocks = 0; + })?; + Ok(()) +} + +/// Explicit final close drains before fd/OFD removal. The host turns this +/// internal EAGAIN into a blocking retry, leaving the descriptor valid. +pub fn preflight_close(handle: i64) -> Result<(), Errno> { + let idx = stream_index(handle)?; + let last = crate::descriptor_backing::with_pcm_streams(|table| table.ref_count(idx) == Some(1)); + if !last { + return Ok(()); + } + if has_fatal_error(handle)? { + publish_discard(producer()); + return Err(Errno::EIO); + } + pad_terminal_frame(); + reconcile(); + if queued_bytes() == 0 { + Ok(()) + } else { + clear_underrun_episode(); + TRANSPORT + .control + .state + .store(pcm::PCM_STATE_DRAINING, Ordering::Release); + Err(Errno::EAGAIN) + } +} + +pub fn reconcile() -> i32 { + let active = ACTIVE_STREAM.load(Ordering::Acquire); + if active < 0 { + return 0; + } + let now = effective_consumer(); + let discard_floor = discard(); + let previous = unsafe { &mut *LAST_RECONCILED_CONSUMER.0.get() }; + let advanced = now > *previous; + let played_delta = now.saturating_sub((*previous).max(discard_floor)); + if played_delta != 0 { + unsafe { + *PLAYED_BYTES.0.get() = (*PLAYED_BYTES.0.get()).saturating_add(played_delta); + } + } + *previous = (*previous).max(now); + if advanced { + crate::wakeup::push(wake_token(active as usize), crate::wakeup::WAKE_WRITABLE); + } + // There is no OFD left to observe EIO once an implicit close has turned a + // queued tail into an orphan drain. If the physical sink fails after that + // transition, it can never advance the consumer to empty the queue. Drop + // only the unplayed tail and release exclusive ownership; consumer bytes + // reconciled above remain the sole source of played-position accounting. + let fatal_orphan = ORPHAN_DRAINING.load(Ordering::Acquire) + && TRANSPORT.control.flags.load(Ordering::Acquire) & pcm::PCM_FLAG_FATAL_ERROR != 0; + if fatal_orphan { + publish_discard(producer()); + finish_closed(active as usize); + } else if queued_bytes() == 0 { + if ORPHAN_DRAINING.load(Ordering::Acquire) { + finish_closed(active as usize); + } else if TRANSPORT.control.state.load(Ordering::Acquire) == pcm::PCM_STATE_DRAINING { + enter_stopped_generation(); + } + } + if advanced { 1 } else { 0 } +} + +pub fn claim_transport(mode: u32) -> Result<(), Errno> { + if !matches!( + mode, + pcm::PCM_TRANSPORT_LEGACY_PULL | pcm::PCM_TRANSPORT_SHARED_CLOCK + ) { + return Err(Errno::EINVAL); + } + let slot = &TRANSPORT.control.transport_mode; + loop { + let current = slot.load(Ordering::Acquire); + if current == mode { + return Ok(()); + } + if current != pcm::PCM_TRANSPORT_UNCLAIMED { + return Err(Errno::EBUSY); + } + if slot + .compare_exchange(current, mode, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + return Ok(()); + } + } +} + +pub fn clock_update(requested_frames: u32) -> u32 { + if claim_transport(pcm::PCM_TRANSPORT_SHARED_CLOCK).is_err() { + return 0; + } + let frame = TRANSPORT.control.frame_bytes.load(Ordering::Acquire).max(1) as u64; + let available_frames = queued_bytes() / frame; + let consumed_frames = available_frames.min(requested_frames as u64); + let state = TRANSPORT.control.state.load(Ordering::Acquire); + if consumed_frames != 0 || state != pcm::PCM_STATE_RUNNING { + clear_underrun_episode(); + } + if requested_frames != 0 + && consumed_frames < requested_frames as u64 + && state == pcm::PCM_STATE_RUNNING + { + note_underrun_episode(); + } + if consumed_frames != 0 { + publish_consumer(effective_consumer() + consumed_frames * frame); + } + TRANSPORT.control.wake_seq.fetch_add(1, Ordering::AcqRel); + reconcile(); + consumed_frames as u32 +} + +/// Compatibility pull consumer retained for existing hosts. A claimed shared +/// clock wins exclusively, so this path can never race an AudioWorklet. +pub fn drain_into(out: &mut [u8]) -> usize { + if claim_transport(pcm::PCM_TRANSPORT_LEGACY_PULL).is_err() { + return 0; + } + let frame = TRANSPORT.control.frame_bytes.load(Ordering::Acquire).max(1) as usize; + let capacity = TRANSPORT + .control + .active_capacity_bytes + .load(Ordering::Acquire) + .max(1) as usize; + let available = queued_bytes().min(capacity as u64) as usize; + let n = out.len().min(available) / frame * frame; + if n == 0 { + return 0; + } + let before = effective_consumer(); + let start = (before % capacity as u64) as usize; + let first = n.min(capacity - start); + unsafe { + let ring = (*TRANSPORT.ring.get()).as_ptr(); + core::ptr::copy_nonoverlapping(ring.add(start), out.as_mut_ptr(), first); + if first < n { + core::ptr::copy_nonoverlapping(ring, out.as_mut_ptr().add(first), n - first); + } + } + publish_consumer(before + n as u64); + TRANSPORT.control.wake_seq.fetch_add(1, Ordering::AcqRel); + reconcile(); n } -/// Validate the format. Only `AFMT_S16_LE` is supported — return -/// `false` for anything else so the ioctl handler can map it to -/// `EINVAL`. -pub fn set_format(fmt: u32) -> bool { - fmt == AFMT_S16_LE +pub fn pending_bytes() -> usize { + queued_bytes() as usize } -/// Snapshot of the current device config. Returned to the host so it -/// can configure its AudioContext. `(sample_rate_hz, channels)`. pub fn current_config() -> (u32, u32) { ( - SAMPLE_RATE.load(Ordering::Relaxed), - CHANNELS.load(Ordering::Relaxed), + TRANSPORT.control.rate.load(Ordering::Acquire), + TRANSPORT.control.channels.load(Ordering::Acquire), ) } -/// Serializes tests that touch the global ring + atomics. Shared -/// across `audio::tests` and `syscalls::tests` because both touch the -/// same process-global state — using two separate mutexes would let -/// them race when cargo runs them concurrently. Public-in-test only. +pub fn transport_ptr() -> *const u8 { + core::ptr::addr_of!(TRANSPORT).cast() +} + +pub const fn transport_len() -> u32 { + pcm::PCM_TRANSPORT_BYTES +} + #[cfg(test)] -pub static TEST_RING_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +pub static TEST_AUDIO_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[cfg(test)] +pub fn reset_for_test() { + let active = ACTIVE_STREAM.swap(-1, Ordering::AcqRel); + if active >= 0 { + crate::descriptor_backing::with_pcm_streams(|table| { + while table.ref_count(active as usize).is_some() { + if table.release(active as usize) { + break; + } + } + }); + } + ORPHAN_DRAINING.store(false, Ordering::Release); + TRANSPORT + .control + .state + .store(pcm::PCM_STATE_CLOSED, Ordering::Release); + TRANSPORT + .control + .transport_mode + .store(pcm::PCM_TRANSPORT_UNCLAIMED, Ordering::Release); + TRANSPORT.control.flags.store(0, Ordering::Release); + TRANSPORT.control.underruns.store(0, Ordering::Release); + publish_producer(0); + publish_consumer(0); + publish_discard(0); + unsafe { *LAST_RECONCILED_CONSUMER.0.get() = 0 }; + unsafe { *PLAYED_BYTES.0.get() = 0 }; +} + +#[cfg(test)] +pub fn mark_fatal_error_for_test() { + TRANSPORT + .control + .flags + .fetch_or(pcm::PCM_FLAG_FATAL_ERROR, Ordering::AcqRel); +} #[cfg(test)] mod tests { use super::*; + use core::mem::{offset_of, size_of}; - fn fresh() -> std::sync::MutexGuard<'static, ()> { - // Tolerate poisoned locks from earlier failed assertions — the - // ring is reset before each test, so prior panics don't leave - // observable state behind. - let g = TEST_RING_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - reset(); - SAMPLE_RATE.store(11025, Ordering::Relaxed); - CHANNELS.store(2, Ordering::Relaxed); - g + fn fresh() -> (std::sync::MutexGuard<'static, ()>, i64) { + let guard = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + reset_for_test(); + let handle = open_stream().unwrap(); + (guard, handle) } #[test] - fn empty_ring_drains_zero() { - let _g = fresh(); - let mut buf = [0u8; 16]; - assert_eq!(drain_into(&mut buf), 0); - assert_eq!(pending_bytes(), 0); + fn atomic_transport_matches_shared_layout() { + assert_eq!( + size_of::(), + pcm::PCM_TRANSPORT_HEADER_BYTES as usize + ); + assert_eq!( + offset_of!(SharedTransport, ring), + pcm::PCM_TRANSPORT_HEADER_BYTES as usize + ); + assert_eq!( + size_of::(), + pcm::PCM_TRANSPORT_BYTES as usize + ); } #[test] - fn write_then_drain_roundtrip_preserves_bytes() { - let _g = fresh(); - let frame = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]; - write_pcm(&frame); - assert_eq!(pending_bytes(), 8); + fn ring_wraparound_preserves_pcm() { + let (_guard, handle) = fresh(); + set_fragment(handle, (2 << 16) | 4).unwrap(); + let first = [1u8; 28]; + assert_eq!(write(handle, &first, false).unwrap(), 28); + let mut drained = [0u8; 24]; + assert_eq!(drain_into(&mut drained), 24); + let second = [2u8; 24]; + assert_eq!(write(handle, &second, false).unwrap(), 24); + let mut all = [0u8; 28]; + assert_eq!(drain_into(&mut all), 28); + assert_eq!(&all[..4], &[1; 4]); + assert_eq!(&all[4..], &[2; 24]); + } + + #[test] + fn blocking_and_nonblocking_backpressure() { + let (_guard, handle) = fresh(); + set_fragment(handle, (2 << 16) | 4).unwrap(); + let full = [0u8; 32]; + assert_eq!(write(handle, &full, false).unwrap(), 32); + assert_eq!(write(handle, &[0; 4], false), Err(Errno::EAGAIN)); let mut out = [0u8; 8]; assert_eq!(drain_into(&mut out), 8); - assert_eq!(out, frame); + assert_eq!(write(handle, &[0; 12], false), Err(Errno::EAGAIN)); + assert_eq!(write(handle, &[0; 12], true).unwrap(), 8); + } + + #[test] + fn blocking_write_can_complete_a_queued_partial_frame() { + let (_guard, handle) = fresh(); + set_fragment(handle, (2 << 16) | 4).unwrap(); + assert_eq!(write(handle, &[1], false).unwrap(), 1); + + // Waiting for all 32 bytes would deadlock: the audio clock cannot + // consume the queued single byte until this write completes a frame. + assert_eq!(write(handle, &[2; 32], false).unwrap(), 31); + assert_eq!(pending_bytes(), 32); + assert_eq!(drain_into(&mut [0; 32]), 32); + } + + #[test] + fn reset_discards_without_rewinding_monotonic_producer() { + let (_guard, handle) = fresh(); + write(handle, &[0; 16], false).unwrap(); + let before = producer(); + reset_stream(handle).unwrap(); assert_eq!(pending_bytes(), 0); + assert_eq!(producer(), before); + assert_eq!(discard(), before); } #[test] - fn drain_rounds_down_to_whole_stereo_frame() { - let _g = fresh(); - // Stereo S16 → 4 bytes/frame. - let bytes: [u8; 12] = [0; 12]; - write_pcm(&bytes); - let mut out = [0u8; 7]; // 7 < 8, should drain 4 bytes (one frame) - assert_eq!(drain_into(&mut out), 4); - assert_eq!(pending_bytes(), 8); + fn fragment_encoding_clamps_and_zero_count_means_maximum() { + let (_guard, handle) = fresh(); + assert_eq!(set_fragment(handle, 3).unwrap(), (4096 << 16) | 4); + assert_eq!(geometry(handle).unwrap(), (16, 4096)); + assert_eq!( + set_fragment(handle, (1 << 16) | 10).unwrap(), + (2 << 16) | 10 + ); + reset_stream(handle).unwrap(); + assert_eq!( + set_fragment(handle, (7 << 16) | 20).unwrap(), + (1 << 16) | 16 + ); + assert_eq!(geometry(handle).unwrap(), (65_536, 1)); } #[test] - fn mono_drain_uses_2_byte_frames() { - let _g = fresh(); - set_channels(1); - let bytes: [u8; 6] = [1, 2, 3, 4, 5, 6]; - write_pcm(&bytes); - let mut out = [0u8; 3]; // 3 < 4, should round down to 2 (one mono frame) - assert_eq!(drain_into(&mut out), 2); - assert_eq!(out[0], 1); - assert_eq!(out[1], 2); + fn reset_discard_does_not_advance_played_position() { + let (_guard, handle) = fresh(); + write(handle, &[1; 8], false).unwrap(); + assert_eq!(drain_into(&mut [0; 8]), 8); + assert_eq!(output_pointer(handle).unwrap().played_bytes, 8); + write(handle, &[2; 8], false).unwrap(); + reset_stream(handle).unwrap(); + assert_eq!( + output_pointer(handle).unwrap(), + PcmPosition { + played_bytes: 0, + completed_fragments: 0, + ring_offset: 0, + } + ); } #[test] - fn set_sample_rate_clamps_to_supported_range() { - let _g = fresh(); - assert_eq!(set_sample_rate(44100), 44100); - assert_eq!(set_sample_rate(0), 4000); - assert_eq!(set_sample_rate(1_000_000), 192000); - assert_eq!(current_config().0, 192000); + fn reset_reconciles_shared_consumer_before_discarding_tail() { + let (_guard, handle) = fresh(); + write(handle, &[1; 16], false).unwrap(); + publish_consumer(8); + reset_stream(handle).unwrap(); + assert_eq!(output_pointer(handle).unwrap().played_bytes, 0); + assert_eq!(pending_bytes(), 0); } #[test] - fn set_channels_only_accepts_mono_or_stereo() { - let _g = fresh(); - assert_eq!(set_channels(1), 1); - assert_eq!(set_channels(2), 2); - // Anything weird normalizes to stereo — what real OSS drivers do. - assert_eq!(set_channels(7), 2); - assert_eq!(set_channels(0), 2); + fn getoptr_reconciles_and_reports_reset_relative_position() { + let (_guard, handle) = fresh(); + set_fragment(handle, (4 << 16) | 4).unwrap(); + write(handle, &[1; 64], false).unwrap(); + + // Model a shared-clock publication that has not reached reconcile(). + publish_consumer(48); + assert_eq!( + output_pointer(handle).unwrap(), + PcmPosition { + played_bytes: 48, + completed_fragments: 3, + ring_offset: 48, + } + ); + + reset_stream(handle).unwrap(); + assert_eq!( + output_pointer(handle).unwrap(), + PcmPosition { + played_bytes: 0, + completed_fragments: 0, + ring_offset: 0, + } + ); + + write(handle, &[2; 32], false).unwrap(); + publish_consumer(producer()); + assert_eq!( + output_pointer(handle).unwrap(), + PcmPosition { + played_bytes: 32, + completed_fragments: 2, + ring_offset: 32, + } + ); } #[test] - fn set_format_rejects_anything_but_s16_le() { - let _g = fresh(); - assert!(set_format(AFMT_S16_LE)); - assert!(!set_format(0x08)); // AFMT_U8 - assert!(!set_format(0x20)); // AFMT_S16_BE - assert!(!set_format(0)); + fn delay_space_position_sync_and_reconfiguration_follow_the_audio_clock() { + let (_guard, handle) = fresh(); + set_fragment(handle, (2 << 16) | 4).unwrap(); + write(handle, &[1; 32], false).unwrap(); + + assert_eq!(output_delay(handle).unwrap(), 32); + assert_eq!( + output_space(handle).unwrap(), + PcmOutputSpace { + available_fragments: 0, + total_fragments: 2, + fragment_bytes: 16, + available_bytes: 0, + } + ); + assert_eq!(set_rate(handle, 44_100), Err(Errno::EBUSY)); + assert_eq!(sync(handle), Err(Errno::EAGAIN)); + + assert_eq!(drain_into(&mut [0; 16]), 16); + assert_eq!(output_delay(handle).unwrap(), 16); + assert_eq!( + output_pointer(handle).unwrap(), + PcmPosition { + played_bytes: 16, + completed_fragments: 1, + ring_offset: 16, + } + ); + assert_eq!(output_pointer(handle).unwrap().completed_fragments, 0); + + assert_eq!(drain_into(&mut [0; 16]), 16); + let before_stop = TRANSPORT.control.generation.load(Ordering::Acquire); + assert_eq!(sync(handle), Ok(())); + let stopped = TRANSPORT.control.generation.load(Ordering::Acquire); + assert_eq!(stopped, before_stop.wrapping_add(1)); + assert_eq!(set_rate(handle, 44_100), Ok(44_100)); + assert_eq!( + TRANSPORT.control.generation.load(Ordering::Acquire), + stopped.wrapping_add(1) + ); + assert_eq!(TRANSPORT.control.rate.load(Ordering::Acquire), 44_100); + assert_eq!( + TRANSPORT.control.flags.load(Ordering::Acquire) & pcm::PCM_FLAG_CONFIGURING, + 0 + ); } #[test] - fn overflow_drops_oldest_whole_frame() { - let _g = fresh(); - // Stereo S16 → 4-byte frames. Fill exactly to capacity, then add - // one more frame: the head frame must drop. - let mut head_frame = [0u8; 4]; - head_frame.copy_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]); - write_pcm(&head_frame); - - // Pad up to capacity with a recognizable pattern. - let pad = [0x11u8; MAX_QUEUED_BYTES - 4]; - write_pcm(&pad); - assert_eq!(pending_bytes(), MAX_QUEUED_BYTES); - - // One more frame — head must drop. - let new_frame = [0x42, 0x43, 0x44, 0x45]; - write_pcm(&new_frame); - - // Drain everything: should NOT see the original head frame. - let mut all = vec![0u8; pending_bytes()]; - let n = drain_into(&mut all); - assert_eq!(n, MAX_QUEUED_BYTES); - // First 4 bytes are the start of the pad pattern, NOT the head_frame. - assert_eq!(&all[0..4], &[0x11, 0x11, 0x11, 0x11]); - // Tail is the freshest frame. - assert_eq!(&all[MAX_QUEUED_BYTES - 4..], &new_frame); + fn shared_clock_underrun_consumes_available_frames_and_records_silence_gap() { + let (_guard, handle) = fresh(); + write(handle, &[1; 4], false).unwrap(); + + assert_eq!(clock_update(2), 1); + assert_eq!(pending_bytes(), 0); + assert_eq!(TRANSPORT.control.underruns.load(Ordering::Acquire), 1); + assert_eq!(clock_update(2), 0); + assert_eq!(TRANSPORT.control.underruns.load(Ordering::Acquire), 1); + + write(handle, &[2; 4], false).unwrap(); + assert_eq!(clock_update(2), 1); + assert_eq!(TRANSPORT.control.underruns.load(Ordering::Acquire), 2); } #[test] - fn reset_drops_pending() { - let _g = fresh(); - write_pcm(&[1, 2, 3, 4]); - assert_eq!(pending_bytes(), 4); - reset(); + fn draining_a_short_tail_is_not_an_underrun() { + let (_guard, handle) = fresh(); + write(handle, &[1; 8], false).unwrap(); + assert_eq!(sync(handle), Err(Errno::EAGAIN)); + let before_stop = TRANSPORT.control.generation.load(Ordering::Acquire); + + assert_eq!(clock_update(128), 2); + assert_eq!(TRANSPORT.control.underruns.load(Ordering::Acquire), 0); + assert_eq!( + TRANSPORT.control.flags.load(Ordering::Acquire) & pcm::PCM_FLAG_UNDERRUN_ACTIVE, + 0 + ); + assert_eq!( + TRANSPORT.control.state.load(Ordering::Acquire), + pcm::PCM_STATE_STOPPED + ); + assert_eq!( + TRANSPORT.control.generation.load(Ordering::Acquire), + before_stop.wrapping_add(1) + ); + } + + #[test] + fn fatal_sink_error_fails_io_and_implicit_close_does_not_stall() { + let (_guard, handle) = fresh(); + write(handle, &[1; 8], false).unwrap(); + TRANSPORT + .control + .flags + .fetch_or(pcm::PCM_FLAG_FATAL_ERROR, Ordering::AcqRel); + + assert_eq!(write(handle, &[2; 4], false), Err(Errno::EIO)); + assert_eq!(poll_writable(handle), Err(Errno::EIO)); + assert_eq!(output_space(handle), Err(Errno::EIO)); + assert_eq!(sync(handle), Err(Errno::EIO)); + assert_eq!(preflight_close(handle), Err(Errno::EIO)); + + let idx = stream_index(handle).unwrap(); + let freed = crate::descriptor_backing::with_pcm_streams(|table| table.release(idx)); + assert!(freed); + on_last_ofd_released(idx); + assert_eq!(ACTIVE_STREAM.load(Ordering::Acquire), -1); + assert_eq!(pending_bytes(), 0); + } + + #[test] + fn fatal_sink_after_orphaning_tail_discards_without_counting_playback() { + let (_guard, handle) = fresh(); + write(handle, &[1; 16], false).unwrap(); + // Model consumer progress that the host published immediately before + // process teardown, but which no syscall has reconciled yet. + publish_consumer(8); + + let idx = stream_index(handle).unwrap(); + let freed = crate::descriptor_backing::with_pcm_streams(|table| table.release(idx)); + assert!(freed); + on_last_ofd_released(idx); + assert_eq!(ACTIVE_STREAM.load(Ordering::Acquire), idx as i32); + assert!(ORPHAN_DRAINING.load(Ordering::Acquire)); + assert_eq!(pending_bytes(), 8); + assert_eq!(open_stream(), Err(Errno::EBUSY)); + + mark_fatal_error_for_test(); + assert_eq!(reconcile(), 1); + assert_eq!(ACTIVE_STREAM.load(Ordering::Acquire), -1); + assert!(!ORPHAN_DRAINING.load(Ordering::Acquire)); + assert_eq!(pending_bytes(), 0); + assert_eq!(discard(), producer()); + assert_eq!(unsafe { *PLAYED_BYTES.0.get() }, 8); + + // The physical failure remains sticky, but it must not retain the old + // stream's exclusive-open claim. + let reopened = open_stream().unwrap(); + assert!(has_fatal_error(reopened).unwrap()); + rollback_open(reopened); + } + + #[test] + fn drain_pads_partial_u8_stereo_frame_with_silence() { + let (_guard, handle) = fresh(); + set_format(handle, pcm::PCM_FORMAT_U8).unwrap(); + set_channels(handle, 2).unwrap(); + write(handle, &[1, 2, 3], false).unwrap(); + assert_eq!(sync(handle), Err(Errno::EAGAIN)); + let mut out = [0; 4]; + assert_eq!(drain_into(&mut out), 4); + assert_eq!(out, [1, 2, 3, 0x80]); + } + + #[test] + fn transport_claim_prevents_competing_consumers() { + let (_guard, handle) = fresh(); + write(handle, &[0; 16], false).unwrap(); + claim_transport(pcm::PCM_TRANSPORT_SHARED_CLOCK).unwrap(); + assert_eq!(drain_into(&mut [0; 16]), 0); + assert_eq!(clock_update(4), 4); assert_eq!(pending_bytes(), 0); } + + #[test] + fn reset_close_reopen_keeps_live_cursors_monotonic() { + let (_guard, first) = fresh(); + set_format(first, pcm::PCM_FORMAT_U8).unwrap(); + set_channels(first, 1).unwrap(); + write(first, &[1, 2, 3, 4], false).unwrap(); + + let mut played = [0; 2]; + assert_eq!(drain_into(&mut played), 2); + assert_eq!(played, [1, 2]); + assert_eq!(output_pointer(first).unwrap().played_bytes, 2); + + // RESET permits the final close without waiting for the old tail. This + // is the lifecycle in which an already-running AudioWorklet quantum + // can otherwise publish an old absolute consumer after the reopen. + reset_stream(first).unwrap(); + let base = producer(); + let old_consumer = consumer(); + assert_eq!(base, 4); + assert_eq!(old_consumer, 2); + assert_eq!(discard(), base); + + let first_idx = stream_index(first).unwrap(); + let freed = crate::descriptor_backing::with_pcm_streams(|table| table.release(first_idx)); + assert!(freed); + on_last_ofd_released(first_idx); + + let second = open_stream().unwrap(); + assert_eq!(producer(), base, "producer must not rewind across opens"); + assert_eq!( + consumer(), + old_consumer, + "kernel must not race the host consumer writer" + ); + assert_eq!( + discard(), + base, + "the new generation starts at the old producer" + ); + assert_eq!(output_pointer(second).unwrap().played_bytes, 0); + + // Model a late publication from the old worklet generation. Because it + // cannot exceed that generation's producer, the new discard floor wins + // and no byte from the new stream is consumed or skipped. + publish_consumer(base - 1); + assert_eq!(pending_bytes(), 0); + + set_format(second, pcm::PCM_FORMAT_U8).unwrap(); + set_channels(second, 1).unwrap(); + assert_eq!(write(second, &[9, 10], false).unwrap(), 2); + assert_eq!(producer(), base + 2); + let mut next = [0; 2]; + assert_eq!(drain_into(&mut next), 2); + assert_eq!(next, [9, 10]); + assert_eq!(consumer(), base + 2); + assert_eq!(output_pointer(second).unwrap().played_bytes, 2); + } } diff --git a/crates/kernel/src/descriptor_backing.rs b/crates/kernel/src/descriptor_backing.rs index 94a9f23cb2..38fbe895e6 100644 --- a/crates/kernel/src/descriptor_backing.rs +++ b/crates/kernel/src/descriptor_backing.rs @@ -112,7 +112,6 @@ impl SharedBackingTable { true } - #[cfg(test)] pub fn ref_count(&self, idx: usize) -> Option { self.entries .get(idx) @@ -217,7 +216,9 @@ static TIMERFDS: GlobalBackingTable = GlobalBackingTable::new(); static SIGNALFDS: GlobalBackingTable = GlobalBackingTable::new(); static MEMFDS: GlobalBackingTable = GlobalBackingTable::new(); static PROCFS_BUFS: GlobalBackingTable = GlobalBackingTable::new(); -static SYNTHETIC_REGULARS: GlobalBackingTable = GlobalBackingTable::new(); +static SYNTHETIC_REGULARS: GlobalBackingTable = + GlobalBackingTable::new(); +static PCM_STREAMS: GlobalBackingTable = GlobalBackingTable::new(); // Keep synthetic backing handles disjoint from the small negative sentinels // used by pipes, devices, and procfs. @@ -277,6 +278,12 @@ fn synthetic_regular_idx(host_handle: i64) -> Result { .ok_or(Errno::EBADF) } +pub fn with_pcm_streams( + f: impl for<'a> FnOnce(&'a mut SharedBackingTable) -> R, +) -> R { + PCM_STREAMS.with(f) +} + fn negative_handle_idx(host_handle: i64) -> Result { if host_handle >= 0 { return Err(Errno::EBADF); @@ -295,7 +302,11 @@ fn negative_handle_idx(host_handle: i64) -> Result { pub fn manages_ofd(file_type: FileType, host_handle: i64) -> bool { matches!( file_type, - FileType::EventFd | FileType::TimerFd | FileType::SignalFd | FileType::MemFd + FileType::EventFd + | FileType::TimerFd + | FileType::SignalFd + | FileType::MemFd + | FileType::PcmPlayback ) || (file_type == FileType::Regular && (crate::procfs::is_procfs_buf_handle(host_handle) || is_synthetic_regular_handle(host_handle))) @@ -316,6 +327,8 @@ pub(crate) fn is_live_managed_ofd(file_type: FileType, host_handle: i64) -> bool .is_ok_and(|idx| with_signalfds(|table| table.get(idx).is_some())), FileType::MemFd => negative_handle_idx(host_handle) .is_ok_and(|idx| with_memfds(|table| table.get(idx).is_some())), + FileType::PcmPlayback => negative_handle_idx(host_handle) + .is_ok_and(|idx| with_pcm_streams(|table| table.get(idx).is_some())), FileType::Regular if crate::procfs::is_procfs_buf_handle(host_handle) => { with_procfs_bufs(|table| { table @@ -474,6 +487,9 @@ pub fn add_ref_for_ofd(file_type: FileType, host_handle: i64) -> Result with_memfds(|table| table.add_ref(negative_handle_idx(host_handle)?))?, + FileType::PcmPlayback => { + with_pcm_streams(|table| table.add_ref(negative_handle_idx(host_handle)?))? + } FileType::Regular if crate::procfs::is_procfs_buf_handle(host_handle) => { with_procfs_bufs(|table| table.add_ref(crate::procfs::procfs_buf_idx(host_handle)))? } @@ -498,6 +514,15 @@ pub fn release_for_ofd(file_type: FileType, host_handle: i64) -> bool { .is_ok_and(|idx| with_signalfds(|table| table.release(idx))), FileType::MemFd => negative_handle_idx(host_handle) .is_ok_and(|idx| with_memfds(|table| table.release(idx))), + FileType::PcmPlayback => { + negative_handle_idx(host_handle).is_ok_and(|idx| { + let freed = with_pcm_streams(|table| table.release(idx)); + if freed { + crate::audio::on_last_ofd_released(idx); + } + freed + }) + } FileType::Regular if crate::procfs::is_procfs_buf_handle(host_handle) => { with_procfs_bufs(|table| table.release(crate::procfs::procfs_buf_idx(host_handle))) } diff --git a/crates/kernel/src/fork.rs b/crates/kernel/src/fork.rs index 6327f03544..b35747281a 100644 --- a/crates/kernel/src/fork.rs +++ b/crates/kernel/src/fork.rs @@ -40,7 +40,8 @@ const EXEC_MAGIC: u32 = 0x45584543; // "EXEC" // This header version is also shared by the cfg(test) legacy exec-state // fixture. v14 widens that fixture's directed-signal metadata to complete raw // `union sigval` bits plus sender credentials. Production fork serialization -// still clears and omits every pending directed signal. +// still clears and omits every pending directed signal. The earlier v12 +// addition made PCM playback an OFD-owned backing retained by fork and exec. const FORK_VERSION: u32 = 14; // Bounds for deserialization to prevent OOM from malformed buffers. @@ -472,6 +473,7 @@ fn file_type_to_u32(ft: FileType) -> u32 { FileType::MemFd => 9, FileType::PtyMaster => 10, FileType::PtySlave => 11, + FileType::PcmPlayback => 12, } } @@ -489,6 +491,7 @@ fn u32_to_file_type(v: u32) -> Result { 9 => Ok(FileType::MemFd), 10 => Ok(FileType::PtyMaster), 11 => Ok(FileType::PtySlave), + 12 => Ok(FileType::PcmPlayback), _ => Err(Errno::EINVAL), } } diff --git a/crates/kernel/src/ofd.rs b/crates/kernel/src/ofd.rs index 4660e7c3ac..3760d0253d 100644 --- a/crates/kernel/src/ofd.rs +++ b/crates/kernel/src/ofd.rs @@ -157,6 +157,8 @@ pub enum FileType { MemFd, PtyMaster, PtySlave, + /// Playback-only PCM stream backing used by the OSS `/dev/dsp` frontend. + PcmPlayback, } /// Live cmdbuf mapping for a process's GLES2 fd. diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index a72334752e..8c1a729631 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -437,122 +437,178 @@ fn proc_has_mice_fd(proc: &Process) -> bool { proc_has_virtual_device_fd(proc, VirtualDevice::Mice) } -/// Try to claim `/dev/dsp` for the calling process. +/// Convert the PCM core's internal "park until the audio clock advances" +/// sentinel into a real signal interruption at the syscall boundary. /// -/// Single-owner like `/dev/fb0` and `/dev/input/mice` — second open from -/// a different pid is `EBUSY`. Re-opens by the current owner are -/// accepted (matches the typical OSS exclusive-grab model). -fn acquire_dsp_or_busy(pid: u32) -> Result<(), Errno> { - let pid = pid as i32; - let owner = crate::audio::DSP_OWNER.load(core::sync::atomic::Ordering::SeqCst); - if owner != -1 && owner != pid { - return Err(Errno::EBUSY); +/// The host owns the asynchronous retry loop for `EAGAIN`, so allowing a +/// caught signal to pass through as `EAGAIN` would leave the guest asleep and +/// could let a later retry overwrite the undelivered channel signal record. +/// Only an operation which made no progress reaches this helper: a partial +/// write remains a successful short write, as POSIX requires. +fn interrupt_pcm_wait(proc: &Process, result: Result) -> Result { + if !matches!(result, Err(Errno::EAGAIN)) { + return result; } - let _ = crate::audio::DSP_OWNER.compare_exchange( - -1, - pid, - core::sync::atomic::Ordering::SeqCst, - core::sync::atomic::Ordering::SeqCst, - ); - Ok(()) -} -/// Release `/dev/dsp` ownership held by `pid`, if any. Drops any -/// pending samples so the next opener starts from silence. Idempotent. -pub(crate) fn maybe_release_dsp(pid: u32) { - let prev = crate::audio::DSP_OWNER.compare_exchange( - pid as i32, - -1, - core::sync::atomic::Ordering::SeqCst, - core::sync::atomic::Ordering::SeqCst, - ); - if prev.is_ok() { - crate::audio::reset(); + let tid = current_tid_for_process(proc); + let caught = proc + .next_deliverable_signal(tid) + .is_some_and(|signum| { + matches!( + proc.signals.get_action(signum).handler, + SignalHandler::Handler(_) + ) + }); + if caught { + Err(Errno::EINTR) + } else { + result } } -/// True iff `proc` still has an open fd referencing `/dev/dsp`. -fn proc_has_dsp_fd(proc: &Process) -> bool { - proc_has_virtual_device_fd(proc, VirtualDevice::Dsp) -} - -/// Handle ioctl on `/dev/dsp`. -/// -/// Implements the OSS commands fbDOOM (and most OSS clients) actually -/// emit during init: -/// - `SNDCTL_DSP_RESET` — clear the ring (no-op besides side effect). -/// - `SNDCTL_DSP_SPEED` — set sample rate; in/out: i32 hz. -/// - `SNDCTL_DSP_STEREO` — set channel count; in/out: i32 (0=mono, 1=stereo). -/// - `SNDCTL_DSP_SETFMT` — set sample format; only `AFMT_S16_LE`. -/// - `SNDCTL_DSP_GETFMTS` — bitmask of supported formats; we report only `AFMT_S16_LE`. -/// - `SNDCTL_DSP_SETFRAGMENT` — accept and ignore (host buffering is RAF-paced). -/// - `SNDCTL_DSP_SYNC` — accept; the kernel ring is the boundary. -/// -/// Anything else returns `ENOTTY`. -fn handle_dsp_ioctl(request: u32, buf: &mut [u8]) -> Result<(), Errno> { +/// OSS `/dev/dsp` translation frontend. The PCM core deliberately exposes no +/// OSS ioctl numbers or ABI structs. +fn handle_dsp_ioctl( + proc: &mut Process, + ofd_idx: usize, + request: u32, + buf: &mut [u8], +) -> Result<(), Errno> { use wasm_posix_shared::oss::*; - match request { - SNDCTL_DSP_RESET => { - crate::audio::reset(); - Ok(()) + let handle = proc + .ofd_table + .get(ofd_idx) + .filter(|ofd| ofd.file_type == FileType::PcmPlayback) + .map(|ofd| ofd.host_handle) + .ok_or(Errno::EBADF)?; + + fn read_i32(buf: &[u8]) -> Result { + let bytes: [u8; 4] = buf.get(..4).ok_or(Errno::EINVAL)?.try_into().unwrap(); + Ok(i32::from_le_bytes(bytes)) + } + fn write_i32(buf: &mut [u8], value: i32) -> Result<(), Errno> { + buf.get_mut(..4) + .ok_or(Errno::EINVAL)? + .copy_from_slice(&value.to_le_bytes()); + Ok(()) + } + fn oss_to_pcm(format: u32) -> Option { + match format { + AFMT_QUERY => Some(wasm_posix_shared::pcm::PCM_FORMAT_UNKNOWN), + AFMT_U8 => Some(wasm_posix_shared::pcm::PCM_FORMAT_U8), + AFMT_S16_LE => Some(wasm_posix_shared::pcm::PCM_FORMAT_S16_LE), + AFMT_S16_BE => Some(wasm_posix_shared::pcm::PCM_FORMAT_S16_BE), + _ => None, + } + } + fn pcm_to_oss(format: u32) -> Result { + match format { + wasm_posix_shared::pcm::PCM_FORMAT_U8 => Ok(AFMT_U8), + wasm_posix_shared::pcm::PCM_FORMAT_S16_LE => Ok(AFMT_S16_LE), + wasm_posix_shared::pcm::PCM_FORMAT_S16_BE => Ok(AFMT_S16_BE), + _ => Err(Errno::EIO), } - SNDCTL_DSP_SYNC => Ok(()), + } + + match request { + SNDCTL_DSP_RESET => crate::audio::reset_stream(handle), + SNDCTL_DSP_SYNC => interrupt_pcm_wait(proc, crate::audio::sync(handle)), + SNDCTL_DSP_POST => crate::audio::post(handle), SNDCTL_DSP_SPEED => { - if buf.len() < 4 { + let requested = read_i32(buf)?; + if requested < 0 { return Err(Errno::EINVAL); } - let req = i32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]).max(0) as u32; - let actual = crate::audio::set_sample_rate(req); - buf[0..4].copy_from_slice(&(actual as i32).to_le_bytes()); - Ok(()) + write_i32(buf, crate::audio::set_rate(handle, requested as u32)? as i32) } SNDCTL_DSP_STEREO => { - if buf.len() < 4 { - return Err(Errno::EINVAL); - } - let req = i32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); - // OSS: arg = 0 means mono, anything else = stereo. - let chans = if req == 0 { 1 } else { 2 }; - let actual = crate::audio::set_channels(chans); - // Report back the boolean (1 = stereo, 0 = mono). - let report: i32 = if actual == 2 { 1 } else { 0 }; - buf[0..4].copy_from_slice(&report.to_le_bytes()); - Ok(()) + let requested = if read_i32(buf)? == 0 { 1 } else { 2 }; + let actual = crate::audio::set_channels(handle, requested)?; + write_i32(buf, i32::from(actual == 2)) } SNDCTL_DSP_CHANNELS => { - if buf.len() < 4 { + let requested = read_i32(buf)?; + if requested < 0 { return Err(Errno::EINVAL); } - let req = i32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]).max(0) as u32; - let actual = crate::audio::set_channels(req); - buf[0..4].copy_from_slice(&(actual as i32).to_le_bytes()); - Ok(()) + write_i32( + buf, + crate::audio::set_channels(handle, requested as u32)? as i32, + ) } SNDCTL_DSP_SETFMT => { - if buf.len() < 4 { + let requested = read_i32(buf)? as u32; + let pcm = oss_to_pcm(requested).ok_or(Errno::EINVAL)?; + let actual = pcm_to_oss(crate::audio::set_format(handle, pcm)?)?; + write_i32(buf, actual as i32) + } + SNDCTL_DSP_GETFMTS => write_i32(buf, SUPPORTED_FORMATS as i32), + SNDCTL_DSP_SETFRAGMENT => { + let actual = crate::audio::set_fragment(handle, read_i32(buf)? as u32)?; + write_i32(buf, actual as i32) + } + SNDCTL_DSP_GETOSPACE => { + let space = crate::audio::output_space(handle)?; + if buf.len() < 16 { return Err(Errno::EINVAL); } - let req = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); - if !crate::audio::set_format(req) { - return Err(Errno::EINVAL); + for (offset, value) in [ + space.available_fragments, + space.total_fragments, + space.fragment_bytes, + space.available_bytes, + ] + .into_iter() + .enumerate() + { + let start = offset * 4; + buf[start..start + 4].copy_from_slice(&(value as i32).to_le_bytes()); } - // Echo the format back. - buf[0..4].copy_from_slice(&req.to_le_bytes()); Ok(()) } - SNDCTL_DSP_GETFMTS => { - if buf.len() < 4 { + SNDCTL_DSP_GETBLKSIZE => { + let (fragment_bytes, _) = crate::audio::geometry(handle)?; + write_i32(buf, fragment_bytes as i32) + } + SNDCTL_DSP_GETODELAY => write_i32(buf, crate::audio::output_delay(handle)?), + SNDCTL_DSP_GETCAPS => write_i32(buf, SUPPORTED_CAPS as i32), + SNDCTL_DSP_GETOPTR => { + let position = crate::audio::output_pointer(handle)?; + if buf.len() < 12 { return Err(Errno::EINVAL); } - buf[0..4].copy_from_slice(&crate::audio::AFMT_S16_LE.to_le_bytes()); + let values = [ + position.played_bytes as u32 as i32, + position.completed_fragments as i32, + position.ring_offset as i32, + ]; + for (index, value) in values.into_iter().enumerate() { + let start = index * 4; + buf[start..start + 4].copy_from_slice(&value.to_le_bytes()); + } Ok(()) } - SNDCTL_DSP_SETFRAGMENT => { - // Accept silently — fragment hints don't apply to the - // host-side AudioContext path. Echo the value the caller - // provided so it doesn't second-guess us. + SNDCTL_DSP_NONBLOCK => { + crate::audio::set_nonblock(handle, true)?; + proc.ofd_table + .get_mut(ofd_idx) + .ok_or(Errno::EBADF)? + .status_flags |= O_NONBLOCK; Ok(()) } + SOUND_PCM_READ_RATE => { + let (_, rate, _) = crate::audio::config(handle)?; + write_i32(buf, rate as i32) + } + SOUND_PCM_READ_CHANNELS => { + let (_, _, channels) = crate::audio::config(handle)?; + write_i32(buf, channels as i32) + } + SOUND_PCM_READ_BITS => { + let (format, _, _) = crate::audio::config(handle)?; + let bits = if format == wasm_posix_shared::pcm::PCM_FORMAT_U8 { 8 } else { 16 }; + write_i32(buf, bits) + } _ => Err(Errno::ENOTTY), } } @@ -781,9 +837,8 @@ pub(crate) fn release_exec_image_state(proc: &mut Process, host: &mut dyn HostIO maybe_release_fb0(proc.pid); } } - // Mouse and DSP state belongs to their surviving open descriptions, not - // the discarded Wasm image. A CLOEXEC close (or the eventual last close) - // releases ownership and drains the corresponding queue. + // Device state belongs to surviving open descriptions, not the discarded + // Wasm image. A final PCM CLOEXEC close leaves its tail to the audio clock. release_process_dri_mappings(proc, host); } @@ -855,8 +910,8 @@ fn commit_exec_state_impl( .collect(); for fd in cloexec_fds { let _ = match locks.as_deref_mut() { - Some(locks) => sys_close_with_locks(proc, locks, host, fd), - None => sys_close(proc, host, fd), + Some(locks) => sys_close_implicit_with_locks(proc, locks, host, fd), + None => sys_close_implicit(proc, host, fd), }; } for stream in proc.dir_streams.iter_mut().filter_map(Option::take) { @@ -2810,8 +2865,13 @@ pub fn sys_open( let ofd_ref = entry.ofd_ref; proc.ofd_table.inc_ref(ofd_ref.0); let fd_flags = oflags_to_fd_flags(oflags); - let fd = proc.fd_table.alloc(ofd_ref, fd_flags)?; - return Ok(fd); + return match proc.fd_table.alloc(ofd_ref, fd_flags) { + Ok(fd) => Ok(fd), + Err(err) => { + proc.ofd_table.dec_ref(ofd_ref.0); + Err(err) + } + }; } // Virtual device nodes — handle in-kernel, no host call @@ -2822,10 +2882,29 @@ pub fn sys_open( if dev == VirtualDevice::Mice { acquire_mice_or_busy(proc.pid)?; } + let status_flags = oflags & !CREATION_FLAGS; if dev == VirtualDevice::Dsp { - acquire_dsp_or_busy(proc.pid)?; + if status_flags & O_ACCMODE != O_WRONLY { + return Err(Errno::EOPNOTSUPP); + } + let pcm_handle = crate::audio::open_stream()?; + crate::audio::set_nonblock(pcm_handle, status_flags & O_NONBLOCK != 0)?; + let ofd_idx = proc.ofd_table.create( + FileType::PcmPlayback, + status_flags, + pcm_handle, + resolved, + ); + let fd_flags = oflags_to_fd_flags(oflags); + return match proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags) { + Ok(fd) => Ok(fd), + Err(error) => { + proc.ofd_table.dec_ref(ofd_idx); + crate::audio::rollback_open(pcm_handle); + Err(error) + } + }; } - let status_flags = oflags & !CREATION_FLAGS; let ofd_idx = proc.ofd_table.create( FileType::CharDevice, status_flags, @@ -3072,7 +3151,11 @@ pub(crate) fn validate_scm_rights_transfer_metadata( _ => Err(Errno::EOPNOTSUPP), } } - FileType::EventFd | FileType::TimerFd | FileType::SignalFd | FileType::MemFd => { + FileType::EventFd + | FileType::TimerFd + | FileType::SignalFd + | FileType::MemFd + | FileType::PcmPlayback => { crate::descriptor_backing::is_live_managed_ofd(file_type, host_handle) .then_some(()) .ok_or(Errno::EOPNOTSUPP) @@ -3247,7 +3330,7 @@ fn release_final_ofd_locks(locks: Option<&mut AdvisoryLockManager>, ofd_id: OfdI /// kinds that cannot carry advisory locks. Machine execution uses /// [`sys_close_with_locks`]. pub fn sys_close(proc: &mut Process, host: &mut dyn HostIO, fd: i32) -> Result<(), Errno> { - sys_close_impl(proc, None, host, fd) + sys_close_impl(proc, None, host, fd, true) } /// Close a descriptor while applying process-lock and final-OFD cleanup. @@ -3257,7 +3340,34 @@ pub fn sys_close_with_locks( host: &mut dyn HostIO, fd: i32, ) -> Result<(), Errno> { - let result = sys_close_impl(proc, Some(&mut *locks), host, fd); + let result = sys_close_impl(proc, Some(&mut *locks), host, fd, true); + drain_deferred_scm_rights_releases(locks, host); + result +} + +/// Close without waiting for a PCM tail. Used by process teardown and other +/// implicit-close paths where POSIX does not permit delaying descriptor-table +/// mutation. The final PCM backing remains exclusively owned while it drains. +pub(crate) fn sys_close_implicit( + proc: &mut Process, + host: &mut dyn HostIO, + fd: i32, +) -> Result<(), Errno> { + sys_close_impl(proc, None, host, fd, false) +} + +/// Lock-aware counterpart of [`sys_close_implicit`]. +/// +/// WHY: exec, exit, and descriptor replacement cannot leave a descriptor +/// installed while PCM drains, but they must still release process and OFD +/// locks through the authoritative machine lock table. +pub(crate) fn sys_close_implicit_with_locks( + proc: &mut Process, + locks: &mut AdvisoryLockManager, + host: &mut dyn HostIO, + fd: i32, +) -> Result<(), Errno> { + let result = sys_close_impl(proc, Some(&mut *locks), host, fd, false); drain_deferred_scm_rights_releases(locks, host); result } @@ -3267,7 +3377,27 @@ fn sys_close_impl( mut locks: Option<&mut AdvisoryLockManager>, host: &mut dyn HostIO, fd: i32, + drain_pcm: bool, ) -> Result<(), Errno> { + let mut deferred_pcm_error = None; + if drain_pcm { + let entry = proc.fd_table.get(fd)?; + let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + // A dup-shared OFD is not being destroyed until its final local fd + // closes. Cross-process inherited OFDs are checked by the PCM table. + if ofd.file_type == FileType::PcmPlayback && ofd.ref_count == 1 { + match interrupt_pcm_wait(proc, crate::audio::preflight_close(ofd.host_handle)) { + Ok(()) => {} + // A fatal sink cannot drain. The PCM core has discarded its + // tail, so close must still release the fd/OFD and exclusive + // device ownership while reporting the sink failure. + Err(Errno::EIO) => deferred_pcm_error = Some(Errno::EIO), + // EAGAIN and its caught-signal EINTR translation leave the fd + // live so the explicit close can be retried deterministically. + Err(err) => return Err(err), + } + } + } let ofd_ref = proc.fd_table.free(fd)?; let idx = ofd_ref.0; @@ -3301,7 +3431,12 @@ fn sys_close_impl( publish_advisory_lock_mutation(locks.remove_process_file(proc.pid, file_id)); } - release_ofd_reference_impl(proc, locks, host, idx) + let release_result = release_ofd_reference_impl(proc, locks, host, idx); + match (release_result, deferred_pcm_error) { + (Err(err), _) => Err(err), + (Ok(()), Some(err)) => Err(err), + (Ok(()), None) => Ok(()), + } } /// Release one OFD reference and, when it is the final reference, perform the @@ -3489,6 +3624,9 @@ fn release_ofd_reference_impl( release_final_ofd_locks(locks.as_deref_mut(), ofd_id); } } + FileType::PcmPlayback => { + crate::descriptor_backing::release_for_ofd(file_type, host_handle); + } FileType::PtyMaster => { let pty_idx = host_handle as usize; if let Some(pty) = crate::pty::get_pty(pty_idx) { @@ -3566,17 +3704,6 @@ fn release_ofd_reference_impl( maybe_release_mice(proc.pid); } - // /dev/dsp ownership: same pattern — release once the last Dsp fd - // is gone and drop any unflushed PCM bytes so a successor open - // starts from silence. - if freed - && file_type == FileType::CharDevice - && VirtualDevice::from_host_handle(host_handle) == Some(VirtualDevice::Dsp) - && !proc_has_dsp_fd(proc) - { - maybe_release_dsp(proc.pid); - } - Ok(()) } @@ -4218,13 +4345,10 @@ pub fn sys_read( // /dev/fb0 doesn't support direct read — software is // expected to mmap. Return 0 (EOF-like) rather than // making up pixel bytes, matching the existing - // "no-op for unsupported access" pattern. /dev/dsp - // is write-only too: the host drains via the - // dedicated wasm export, not via user-space read(). - VirtualDevice::Null - | VirtualDevice::Fb0 - | VirtualDevice::Dsp - | VirtualDevice::DriRenderD128 => 0, + // "no-op for unsupported access" pattern. + VirtualDevice::Null | VirtualDevice::Fb0 | VirtualDevice::DriRenderD128 => 0, + // Real DSP descriptors use PcmPlayback and O_WRONLY. + VirtualDevice::Dsp => return Err(Errno::EBADF), VirtualDevice::DriCard0 => { // Drain queued DRM events (DRM_EVENT_FLIP_COMPLETE) // into the caller buffer, one byte at a time so a @@ -4414,6 +4538,15 @@ pub fn sys_write( } match file_type { + FileType::PcmPlayback => { + let nonblock = crate::audio::is_nonblock(host_handle)?; + let result = crate::audio::write(host_handle, buf, nonblock); + if nonblock { + result + } else { + interrupt_pcm_wait(proc, result) + } + } FileType::Pipe => { if host_handle >= 0 { // Host-delegated pipe (cross-process): use host_write @@ -4601,16 +4734,9 @@ pub fn sys_write( // when capping at smem_len; we mirror that. Ok(buf.len()) } - VirtualDevice::Dsp => { - // OSS write semantics: append PCM to the device - // queue. The kernel ring drops oldest whole - // frames on overflow (matches what real OSS - // drivers do under hardware overrun) but always - // reports `buf.len()` to the caller — same as - // /dev/null/zero/urandom: we never short-write. - crate::audio::write_pcm(buf); - Ok(buf.len()) - } + // `/dev/dsp` opens are represented by PcmPlayback, + // never by this legacy virtual-character path. + VirtualDevice::Dsp => Err(Errno::EBADF), _ => Ok(buf.len()), // Null, Zero, Urandom, Mice: discard }; } @@ -4730,6 +4856,7 @@ pub fn sys_lseek( | FileType::SignalFd | FileType::PtyMaster | FileType::PtySlave + | FileType::PcmPlayback ) { return Err(Errno::ESPIPE); } @@ -4978,6 +5105,7 @@ pub fn sys_pread( | FileType::SignalFd | FileType::PtyMaster | FileType::PtySlave + | FileType::PcmPlayback ) { return Err(Errno::ESPIPE); } @@ -5225,6 +5353,7 @@ fn validate_transfer_input(proc: &Process, fd: i32, offset: Option) -> Resu | FileType::SignalFd | FileType::PtyMaster | FileType::PtySlave + | FileType::PcmPlayback ) { return Err(Errno::ESPIPE); @@ -5397,6 +5526,7 @@ pub fn sys_pwrite( | FileType::SignalFd | FileType::PtyMaster | FileType::PtySlave + | FileType::PcmPlayback ) { return Err(Errno::ESPIPE); } @@ -5855,8 +5985,8 @@ fn sys_dup2_impl( // Close newfd if it's open (ignore errors). let _ = match locks.as_deref_mut() { - Some(locks) => sys_close_with_locks(proc, locks, host, newfd), - None => sys_close(proc, host, newfd), + Some(locks) => sys_close_implicit_with_locks(proc, locks, host, newfd), + None => sys_close_implicit(proc, host, newfd), }; // Re-read oldfd entry since sys_close may have mutated tables @@ -6058,6 +6188,16 @@ pub fn sys_fstat(proc: &Process, host: &mut dyn HostIO, fd: i32) -> Result Result { let entry = proc.fd_table.get(fd)?; @@ -6251,7 +6399,12 @@ pub fn sys_fcntl(proc: &mut Process, fd: i32, cmd: u32, arg: u32) -> Result { @@ -8388,8 +8541,8 @@ fn cleanup_process_for_exit( let open_fds: Vec = proc.fd_table.iter().map(|(fd, _)| fd).collect(); for fd in open_fds { let _ = match locks.as_deref_mut() { - Some(locks) => sys_close_with_locks(proc, locks, host, fd), - None => sys_close(proc, host, fd), + Some(locks) => sys_close_implicit_with_locks(proc, locks, host, fd), + None => sys_close_implicit(proc, host, fd), }; } @@ -8961,6 +9114,9 @@ pub fn sys_mmap( if ofd.is_path_only() { return Err(Errno::EBADF); } + if ofd.file_type == FileType::PcmPlayback { + return Err(Errno::ENODEV); + } if ofd.file_type == FileType::CharDevice && VirtualDevice::from_host_handle(ofd.host_handle) == Some(VirtualDevice::Fb0) { @@ -12962,6 +13118,22 @@ fn poll_check(proc: &mut Process, host: &mut dyn HostIO, fds: &mut [WasmPollFd]) let mut revents: i16 = 0; match ofd.file_type { + FileType::PcmPlayback => { + match crate::audio::has_fatal_error(ofd.host_handle) { + // Error readiness is reported regardless of the requested + // event mask, and a failed sink is never simultaneously + // advertised as writable. + Ok(true) => revents |= POLLERR, + Ok(false) + if pollfd.events & POLLOUT != 0 + && crate::audio::poll_writable(ofd.host_handle) + .unwrap_or(false) => + { + revents |= POLLOUT; + } + _ => {} + } + } FileType::EventFd => { let efd_idx = (-(ofd.host_handle + 1)) as usize; if let Some((counter, semaphore_room)) = @@ -13016,15 +13188,6 @@ fn poll_check(proc: &mut Process, host: &mut dyn HostIO, fds: &mut [WasmPollFd]) revents |= POLLIN; } // Mice doesn't accept writes — never report POLLOUT. - } else if ofd.file_type == FileType::CharDevice - && VirtualDevice::from_host_handle(ofd.host_handle) == Some(VirtualDevice::Dsp) - { - // /dev/dsp is write-only. POLLOUT is always ready — - // the ring drops oldest frames on overflow rather - // than blocking — and POLLIN never fires. - if pollfd.events & POLLOUT != 0 { - revents |= POLLOUT; - } } else { // Regular files and char devices are always ready if pollfd.events & POLLIN != 0 { @@ -13373,8 +13536,13 @@ pub fn sys_openat( let ofd_ref = entry.ofd_ref; proc.ofd_table.inc_ref(ofd_ref.0); let fd_flags = oflags_to_fd_flags(oflags); - let fd = proc.fd_table.alloc(ofd_ref, fd_flags)?; - return Ok(fd); + return match proc.fd_table.alloc(ofd_ref, fd_flags) { + Ok(fd) => Ok(fd), + Err(err) => { + proc.ofd_table.dec_ref(ofd_ref.0); + Err(err) + } + }; } // Virtual device nodes — handle in-kernel, no host call @@ -13385,10 +13553,29 @@ pub fn sys_openat( if dev == VirtualDevice::Mice { acquire_mice_or_busy(proc.pid)?; } + let status_flags = oflags & !CREATION_FLAGS; if dev == VirtualDevice::Dsp { - acquire_dsp_or_busy(proc.pid)?; + if status_flags & O_ACCMODE != O_WRONLY { + return Err(Errno::EOPNOTSUPP); + } + let pcm_handle = crate::audio::open_stream()?; + crate::audio::set_nonblock(pcm_handle, status_flags & O_NONBLOCK != 0)?; + let ofd_idx = proc.ofd_table.create( + FileType::PcmPlayback, + status_flags, + pcm_handle, + resolved, + ); + let fd_flags = oflags_to_fd_flags(oflags); + return match proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags) { + Ok(fd) => Ok(fd), + Err(error) => { + proc.ofd_table.dec_ref(ofd_idx); + crate::audio::rollback_open(pcm_handle); + Err(error) + } + }; } - let status_flags = oflags & !CREATION_FLAGS; let ofd_idx = proc.ofd_table.create( FileType::CharDevice, status_flags, @@ -13837,11 +14024,15 @@ pub fn sys_ioctl( } let val = i32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); let ofd = proc.ofd_table.get_mut(ofd_idx).ok_or(Errno::EBADF)?; + let pcm_handle = (ofd.file_type == FileType::PcmPlayback).then_some(ofd.host_handle); if val != 0 { ofd.status_flags |= wasm_posix_shared::flags::O_NONBLOCK; } else { ofd.status_flags &= !wasm_posix_shared::flags::O_NONBLOCK; } + if let Some(handle) = pcm_handle { + crate::audio::set_nonblock(handle, val != 0)?; + } return Ok(()); } @@ -13996,10 +14187,8 @@ pub fn sys_ioctl( // --- /dev/dsp ioctls — OSS surface --- { let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; - if ofd.file_type == FileType::CharDevice - && VirtualDevice::from_host_handle(ofd.host_handle) == Some(VirtualDevice::Dsp) - { - return handle_dsp_ioctl(request, buf); + if ofd.file_type == FileType::PcmPlayback { + return handle_dsp_ioctl(proc, ofd_idx, request, buf); } } @@ -15372,7 +15561,11 @@ pub fn sys_fpathconf( virtual_filesystem_pathconf_value(name) } } - FileType::EventFd | FileType::Epoll | FileType::TimerFd | FileType::SignalFd => { + FileType::EventFd + | FileType::Epoll + | FileType::TimerFd + | FileType::SignalFd + | FileType::PcmPlayback => { Err(Errno::EINVAL) } } @@ -38847,22 +39040,10 @@ mod tests { } // ----------------------------------------------------------------- - // /dev/dsp tests — mirror the fb0 / mice surface for OSS audio. + // /dev/dsp tests — OSS frontend over the OFD-owned PCM core. // ----------------------------------------------------------------- - /// Serializes tests that touch DSP_OWNER + the audio ring + the - /// audio config atomics. Shares the lock with `audio::tests` so - /// concurrent runs across the two modules don't race on the global - /// ring (single mutex, two test modules). - use crate::audio::TEST_RING_LOCK as DSP_OWNER_LOCK; - - fn reset_dsp_state() { - use core::sync::atomic::Ordering; - crate::audio::DSP_OWNER.store(-1, Ordering::SeqCst); - crate::audio::reset(); - crate::audio::SAMPLE_RATE.store(11025, Ordering::Relaxed); - crate::audio::CHANNELS.store(2, Ordering::Relaxed); - } + use crate::audio::TEST_AUDIO_LOCK; #[test] fn match_virtual_device_recognizes_dsp() { @@ -38881,196 +39062,596 @@ mod tests { } #[test] - fn open_dsp_acquires_ownership_and_second_open_from_other_pid_is_ebusy() { - use core::sync::atomic::Ordering; - let _g = DSP_OWNER_LOCK.lock().unwrap(); - reset_dsp_state(); + fn opened_dsp_fstat_is_chr() { + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::audio::reset_for_test(); + + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open( + &mut proc, + &mut host, + b"/dev/dsp", + O_WRONLY | O_NONBLOCK, + 0, + ) + .unwrap(); + let st = sys_fstat(&mut proc, &mut host, fd).unwrap(); + assert_eq!( + st.st_mode & wasm_posix_shared::mode::S_IFMT, + wasm_posix_shared::mode::S_IFCHR + ); + assert_eq!(st.st_ino, VirtualDevice::Dsp.ino()); + sys_close(&mut proc, &mut host, fd).unwrap(); + } + + #[test] + fn open_dsp_is_exclusive_per_open_description_and_rejects_capture() { + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::audio::reset_for_test(); let mut proc1 = Process::new(1); let mut proc2 = Process::new(2); let mut host = MockHostIO::new(); let fd1 = sys_open(&mut proc1, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + let err = sys_open(&mut proc2, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap_err(); + assert_eq!(err, Errno::EBUSY); assert_eq!( - crate::audio::DSP_OWNER.load(Ordering::SeqCst), - proc1.pid as i32 + sys_open(&mut proc1, &mut host, b"/dev/dsp", O_WRONLY, 0), + Err(Errno::EBUSY) + ); + assert_eq!( + sys_open(&mut proc2, &mut host, b"/dev/dsp", O_RDONLY, 0), + Err(Errno::EOPNOTSUPP) + ); + assert_eq!( + sys_open(&mut proc2, &mut host, b"/dev/dsp", O_RDWR, 0), + Err(Errno::EOPNOTSUPP) ); + sys_close(&mut proc1, &mut host, fd1).unwrap(); + } - let err = sys_open(&mut proc2, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap_err(); - assert_eq!(err, Errno::EBUSY); + #[test] + fn inherited_dsp_descriptor_shares_ownership_and_nonblock_state() { + use crate::process_table::ProcessTable; - // Re-open by SAME process is allowed. - let fd1b = sys_open(&mut proc1, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); - assert_ne!(fd1, fd1b); - sys_close(&mut proc1, &mut host, fd1).unwrap(); - sys_close(&mut proc1, &mut host, fd1b).unwrap(); + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::audio::reset_for_test(); + let mut table = ProcessTable::new(); + let mut host = MockHostIO::new(); + let parent = table.create_process().unwrap(); + let fd = sys_open( + table.get_mut(parent).unwrap(), + &mut host, + b"/dev/dsp", + O_WRONLY, + 0, + ) + .unwrap(); + let child = table.fork_process_for_caller(parent, parent).unwrap(); + + sys_fcntl(table.get_mut(parent).unwrap(), fd, F_SETFL, O_NONBLOCK).unwrap(); + assert_ne!( + sys_fcntl(table.get_mut(child).unwrap(), fd, F_GETFL, 0).unwrap() as u32 + & O_NONBLOCK, + 0 + ); + sys_fcntl(table.get_mut(child).unwrap(), fd, F_SETFL, 0).unwrap(); + assert_eq!( + sys_fcntl(table.get_mut(parent).unwrap(), fd, F_GETFL, 0).unwrap() as u32 + & O_NONBLOCK, + 0 + ); + + sys_close(table.get_mut(child).unwrap(), &mut host, fd).unwrap(); + let mut contender = Process::new(980_102); + assert_eq!( + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0), + Err(Errno::EBUSY) + ); + sys_close(table.get_mut(parent).unwrap(), &mut host, fd).unwrap(); + let contender_fd = + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + sys_close(&mut contender, &mut host, contender_fd).unwrap(); } #[test] - fn write_dsp_buffers_pcm_into_ring() { - let _g = DSP_OWNER_LOCK.lock().unwrap(); - reset_dsp_state(); + fn duplicated_dsp_descriptor_keeps_one_ofd_and_exclusive_owner() { + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::audio::reset_for_test(); + let mut owner = Process::new(1); + let mut contender = Process::new(2); + let mut host = MockHostIO::new(); - let mut proc = Process::new(1); + let fd = sys_open(&mut owner, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + let alias = sys_dup(&mut owner, fd).unwrap(); + sys_close(&mut owner, &mut host, fd).unwrap(); + assert_eq!( + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0), + Err(Errno::EBUSY) + ); + sys_close(&mut owner, &mut host, alias).unwrap(); + + let contender_fd = + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + sys_close(&mut contender, &mut host, contender_fd).unwrap(); + } + + #[test] + fn dsp_dup2_replacement_keeps_owner_until_the_final_alias_closes() { + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::audio::reset_for_test(); + let mut owner = Process::new(1); + let mut contender = Process::new(2); let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); - let pcm: [u8; 8] = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]; - let n = sys_write(&mut proc, &mut host, fd, &pcm).unwrap(); - assert_eq!(n, 8); - assert_eq!(crate::audio::pending_bytes(), 8); + let dsp_fd = sys_open(&mut owner, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + let replaced_fd = sys_open(&mut owner, &mut host, b"/dev/null", O_WRONLY, 0).unwrap(); + assert_eq!(sys_dup2(&mut owner, &mut host, dsp_fd, replaced_fd), Ok(replaced_fd)); + assert_eq!(sys_dup2(&mut owner, &mut host, replaced_fd, replaced_fd), Ok(replaced_fd)); - let mut out = [0u8; 8]; - let drained = crate::audio::drain_into(&mut out); - assert_eq!(drained, 8); - assert_eq!(out, pcm); + sys_close(&mut owner, &mut host, dsp_fd).unwrap(); + assert_eq!( + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0), + Err(Errno::EBUSY) + ); + sys_close(&mut owner, &mut host, replaced_fd).unwrap(); - sys_close(&mut proc, &mut host, fd).unwrap(); + let contender_fd = + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + sys_close(&mut contender, &mut host, contender_fd).unwrap(); } #[test] - fn read_dsp_returns_zero() { - // OSS write-only: read returns 0 (EOF-like), not EAGAIN — same - // policy as /dev/null. Stops fbDOOM from spinning if it ever - // tries to read back the ring. - let _g = DSP_OWNER_LOCK.lock().unwrap(); - reset_dsp_state(); + fn non_cloexec_dsp_descriptor_survives_exec_with_the_same_owner() { + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::audio::reset_for_test(); + let mut owner = Process::new(1); + let mut contender = Process::new(2); + let mut host = MockHostIO::new(); - let mut proc = Process::new(1); + let fd = sys_open(&mut owner, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + let pid = owner.pid; + commit_exec_state(&mut owner, &mut host, pid).unwrap(); + assert!(owner.fd_table.get(fd).is_ok()); + assert_eq!(sys_write(&mut owner, &mut host, fd, &[1; 4]), Ok(4)); + assert_eq!( + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0), + Err(Errno::EBUSY) + ); + + crate::audio::reset_stream( + owner + .ofd_table + .get(owner.fd_table.get(fd).unwrap().ofd_ref.0) + .unwrap() + .host_handle, + ) + .unwrap(); + sys_close(&mut owner, &mut host, fd).unwrap(); + let contender_fd = + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + sys_close(&mut contender, &mut host, contender_fd).unwrap(); + } + + #[test] + fn inherited_dsp_owner_survives_child_exit_until_parent_closes() { + use crate::process_table::ProcessTable; + + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::audio::reset_for_test(); + let mut table = ProcessTable::new(); let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/dev/dsp", O_RDONLY, 0).unwrap(); + let parent = table.create_process().unwrap(); + let fd = sys_open( + table.get_mut(parent).unwrap(), + &mut host, + b"/dev/dsp", + O_WRONLY, + 0, + ) + .unwrap(); + let child = table.fork_process_for_caller(parent, parent).unwrap(); - let mut buf = [0u8; 8]; - let n = sys_read(&mut proc, &mut host, fd, &mut buf).unwrap(); - assert_eq!(n, 0); - sys_close(&mut proc, &mut host, fd).unwrap(); + sys_exit(table.get_mut(child).unwrap(), &mut host, 0); + let mut contender = Process::new(980_202); + assert_eq!( + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0), + Err(Errno::EBUSY) + ); + assert_eq!( + sys_write(table.get_mut(parent).unwrap(), &mut host, fd, &[7; 4]), + Ok(4) + ); + crate::audio::reset_stream( + table + .get(parent) + .unwrap() + .ofd_table + .get( + table + .get(parent) + .unwrap() + .fd_table + .get(fd) + .unwrap() + .ofd_ref + .0, + ) + .unwrap() + .host_handle, + ) + .unwrap(); + sys_close(table.get_mut(parent).unwrap(), &mut host, fd).unwrap(); + + let contender_fd = + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + sys_close(&mut contender, &mut host, contender_fd).unwrap(); } #[test] - fn ioctl_dsp_speed_roundtrips_through_ring_config() { - use wasm_posix_shared::oss::*; - let _g = DSP_OWNER_LOCK.lock().unwrap(); - reset_dsp_state(); + fn caught_signal_interrupts_only_unprogressed_dsp_waits_and_keeps_close_live() { + use wasm_posix_shared::oss::{SNDCTL_DSP_RESET, SNDCTL_DSP_SETFRAGMENT, SNDCTL_DSP_SYNC}; + use wasm_posix_shared::signal::{SA_RESTART, SIGUSR1}; + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::audio::reset_for_test(); let mut proc = Process::new(1); let mut host = MockHostIO::new(); let fd = sys_open(&mut proc, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + let mut fragments = ((2i32 << 16) | 4).to_le_bytes(); + sys_ioctl( + &mut proc, + &mut host, + fd, + SNDCTL_DSP_SETFRAGMENT, + &mut fragments, + ) + .unwrap(); - let mut arg = 44100i32.to_le_bytes(); - sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_SPEED, &mut arg).unwrap(); - // The kernel echoes back the rate it actually configured. - assert_eq!(i32::from_le_bytes(arg), 44100); - assert_eq!(crate::audio::current_config().0, 44100); + assert_eq!(sys_write(&mut proc, &mut host, fd, &[1; 32]), Ok(32)); + sys_sigaction(&mut proc, SIGUSR1, 42, SA_RESTART, 0).unwrap(); + proc.signals.raise(SIGUSR1); + // A nonblocking no-progress write reports buffer pressure, not an + // interruption: it never slept. The caught signal stays pending for + // the next syscall boundary. + sys_fcntl(&mut proc, fd, F_SETFL, O_NONBLOCK).unwrap(); + assert_eq!(sys_write(&mut proc, &mut host, fd, &[2; 1]), Err(Errno::EAGAIN)); + assert!(proc.signals.is_pending(SIGUSR1)); + sys_fcntl(&mut proc, fd, F_SETFL, 0).unwrap(); + + // No bytes fit, so write, drain, and final close are interruptible. + // The kernel leaves the caught record queued for the host/glue to + // deliver, and close has not removed the descriptor. + assert_eq!(sys_write(&mut proc, &mut host, fd, &[2; 1]), Err(Errno::EINTR)); + assert!(proc.signals.is_pending(SIGUSR1)); + assert_eq!( + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_SYNC, &mut []), + Err(Errno::EINTR) + ); + assert_eq!(sys_close(&mut proc, &mut host, fd), Err(Errno::EINTR)); + assert!(proc.fd_table.get(fd).is_ok()); + + // A write larger than the configured capacity may make partial + // progress. That progress wins over EINTR and the signal stays queued + // for delivery at the successful syscall boundary. + assert_eq!(crate::audio::drain_into(&mut [0; 16]), 16); + assert_eq!(sys_write(&mut proc, &mut host, fd, &[3; 33]), Ok(16)); + assert!(proc.signals.is_pending(SIGUSR1)); + + // Changing the disposition to ignore discards the pending instance, + // allowing deterministic reset and close cleanup. + sys_sigaction(&mut proc, SIGUSR1, SIG_IGN, 0, 0).unwrap(); + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_RESET, &mut []).unwrap(); sys_close(&mut proc, &mut host, fd).unwrap(); } #[test] - fn ioctl_dsp_setfmt_rejects_non_s16_le() { + fn exec_cloexec_and_process_exit_leave_pcm_tail_to_the_audio_clock() { + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let mut host = MockHostIO::new(); + + crate::audio::reset_for_test(); + let mut exec_owner = Process::new(10); + let exec_fd = sys_open( + &mut exec_owner, + &mut host, + b"/dev/dsp", + O_WRONLY | O_CLOEXEC, + 0, + ) + .unwrap(); + sys_write(&mut exec_owner, &mut host, exec_fd, &[1; 8]).unwrap(); + let exec_pid = exec_owner.pid; + commit_exec_state(&mut exec_owner, &mut host, exec_pid).unwrap(); + assert!(exec_owner.fd_table.get(exec_fd).is_err()); + + let mut contender = Process::new(11); + assert_eq!( + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0), + Err(Errno::EBUSY) + ); + assert_eq!(crate::audio::drain_into(&mut [0; 8]), 8); + let reopened = + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + sys_close(&mut contender, &mut host, reopened).unwrap(); + + crate::audio::reset_for_test(); + let mut exit_owner = Process::new(20); + let exit_fd = + sys_open(&mut exit_owner, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + sys_write(&mut exit_owner, &mut host, exit_fd, &[2; 8]).unwrap(); + sys_exit(&mut exit_owner, &mut host, 0); + assert!(exit_owner.fd_table.get(exit_fd).is_err()); + + let mut exit_contender = Process::new(21); + assert_eq!( + sys_open( + &mut exit_contender, + &mut host, + b"/dev/dsp", + O_WRONLY, + 0, + ), + Err(Errno::EBUSY) + ); + assert_eq!(crate::audio::drain_into(&mut [0; 8]), 8); + let reopened = sys_open( + &mut exit_contender, + &mut host, + b"/dev/dsp", + O_WRONLY, + 0, + ) + .unwrap(); + sys_close(&mut exit_contender, &mut host, reopened).unwrap(); + } + + #[test] + fn dsp_reconfiguration_and_unsupported_operations_fail_truthfully() { use wasm_posix_shared::oss::*; - let _g = DSP_OWNER_LOCK.lock().unwrap(); - reset_dsp_state(); + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::audio::reset_for_test(); let mut proc = Process::new(1); let mut host = MockHostIO::new(); let fd = sys_open(&mut proc, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); - let mut arg = 0x08u32.to_le_bytes(); // AFMT_U8 — unsupported - let err = sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_SETFMT, &mut arg).unwrap_err(); - assert_eq!(err, Errno::EINVAL); + let mut query = (AFMT_QUERY as i32).to_le_bytes(); + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_SETFMT, &mut query).unwrap(); + assert_eq!(u32::from_le_bytes(query), AFMT_S16_LE); + + let mut unsupported_format = (AFMT_S32_LE as i32).to_le_bytes(); + assert_eq!( + sys_ioctl( + &mut proc, + &mut host, + fd, + SNDCTL_DSP_SETFMT, + &mut unsupported_format, + ), + Err(Errno::EINVAL) + ); - let mut arg = AFMT_S16_LE.to_le_bytes(); - sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_SETFMT, &mut arg).unwrap(); + let mut low_rate = 1i32.to_le_bytes(); + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_SPEED, &mut low_rate).unwrap(); + assert_eq!(i32::from_le_bytes(low_rate), 8_000); + let mut channels = 9i32.to_le_bytes(); + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_CHANNELS, &mut channels).unwrap(); + assert_eq!(i32::from_le_bytes(channels), 2); + + sys_write(&mut proc, &mut host, fd, &[0; 4]).unwrap(); + let mut rate = 44_100i32.to_le_bytes(); + assert_eq!( + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_SPEED, &mut rate), + Err(Errno::EBUSY) + ); + assert_eq!( + sys_mmap(&mut proc, &mut host, 0, 4096, 3, 0x02, fd, 0), + Err(Errno::ENODEV) + ); + + for (request, size) in [ + (SNDCTL_DSP_SETBLKSIZE, 4), + (SOUND_PCM_WRITE_FILTER, 4), + (SOUND_PCM_READ_FILTER, 4), + (SNDCTL_DSP_SUBDIVIDE, 4), + (SNDCTL_DSP_GETISPACE, 16), + (SNDCTL_DSP_SETTRIGGER, 4), + (SNDCTL_DSP_GETTRIGGER, 4), + (SNDCTL_DSP_GETIPTR, 12), + (SNDCTL_DSP_MAPINBUF, 8), + (SNDCTL_DSP_MAPOUTBUF, 8), + (SNDCTL_DSP_SETSYNCRO, 0), + (SNDCTL_DSP_SETDUPLEX, 0), + ] { + let mut argument = alloc::vec![0; size]; + assert_eq!( + sys_ioctl(&mut proc, &mut host, fd, request, &mut argument), + Err(Errno::ENOTTY), + "request {request:#x}" + ); + } + + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_RESET, &mut []).unwrap(); sys_close(&mut proc, &mut host, fd).unwrap(); } #[test] - fn ioctl_dsp_getfmts_reports_s16_le_only() { + fn dsp_ioctl_negotiation_geometry_and_queries_are_truthful() { use wasm_posix_shared::oss::*; - let _g = DSP_OWNER_LOCK.lock().unwrap(); - reset_dsp_state(); + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::audio::reset_for_test(); let mut proc = Process::new(1); let mut host = MockHostIO::new(); let fd = sys_open(&mut proc, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); - let mut arg = [0u8; 4]; - sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_GETFMTS, &mut arg).unwrap(); - assert_eq!(u32::from_le_bytes(arg), AFMT_S16_LE); + let mut arg = 44100i32.to_le_bytes(); + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_SPEED, &mut arg).unwrap(); + // The kernel echoes back the rate it actually configured. + assert_eq!(i32::from_le_bytes(arg), 44100); + let mut format = (AFMT_U8 as i32).to_le_bytes(); + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_SETFMT, &mut format).unwrap(); + assert_eq!(i32::from_le_bytes(format), AFMT_U8 as i32); + let mut fragments = ((2i32 << 16) | 4).to_le_bytes(); + sys_ioctl( + &mut proc, + &mut host, + fd, + SNDCTL_DSP_SETFRAGMENT, + &mut fragments, + ) + .unwrap(); + assert_eq!(i32::from_le_bytes(fragments), (2 << 16) | 4); + + let mut formats = [0; 4]; + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_GETFMTS, &mut formats).unwrap(); + assert_eq!(u32::from_le_bytes(formats), SUPPORTED_FORMATS); + let mut caps = [0; 4]; + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_GETCAPS, &mut caps).unwrap(); + assert_eq!(u32::from_le_bytes(caps), SUPPORTED_CAPS); + let mut space = [0; 16]; + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_GETOSPACE, &mut space).unwrap(); + assert_eq!(i32::from_le_bytes(space[0..4].try_into().unwrap()), 2); + assert_eq!(i32::from_le_bytes(space[8..12].try_into().unwrap()), 16); + assert_eq!(i32::from_le_bytes(space[12..16].try_into().unwrap()), 32); sys_close(&mut proc, &mut host, fd).unwrap(); } #[test] - fn ioctl_dsp_reset_drains_ring() { + fn dsp_backpressure_poll_reset_and_final_close_follow_audio_clock() { use wasm_posix_shared::oss::*; - let _g = DSP_OWNER_LOCK.lock().unwrap(); - reset_dsp_state(); - + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::audio::reset_for_test(); let mut proc = Process::new(1); let mut host = MockHostIO::new(); let fd = sys_open(&mut proc, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + let mut fragments = ((2i32 << 16) | 4).to_le_bytes(); + sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_SETFRAGMENT, &mut fragments).unwrap(); - sys_write(&mut proc, &mut host, fd, &[1, 2, 3, 4]).unwrap(); - assert_eq!(crate::audio::pending_bytes(), 4); - - let mut arg = [0u8; 0]; - sys_ioctl(&mut proc, &mut host, fd, SNDCTL_DSP_RESET, &mut arg).unwrap(); - assert_eq!(crate::audio::pending_bytes(), 0); + assert_eq!(sys_write(&mut proc, &mut host, fd, &[1; 32]), Ok(32)); + assert_eq!(sys_write(&mut proc, &mut host, fd, &[2; 1]), Err(Errno::EAGAIN)); + let mut pollfd = WasmPollFd { fd, events: POLLOUT, revents: 0 }; + assert_eq!( + sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0), + Ok(0) + ); + let mut drained = [0; 16]; + assert_eq!(crate::audio::drain_into(&mut drained), 16); + assert_eq!( + sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0), + Ok(1) + ); + assert_eq!(pollfd.revents, POLLOUT); + assert_eq!(sys_close(&mut proc, &mut host, fd), Err(Errno::EAGAIN)); + assert!(proc.fd_table.get(fd).is_ok(), "blocking close keeps fd live"); + let mut tail = [0; 16]; + assert_eq!(crate::audio::drain_into(&mut tail), 16); sys_close(&mut proc, &mut host, fd).unwrap(); + + let fd2 = sys_open(&mut proc, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + sys_write(&mut proc, &mut host, fd2, &[3; 7]).unwrap(); + sys_ioctl(&mut proc, &mut host, fd2, SNDCTL_DSP_RESET, &mut []).unwrap(); + assert_eq!(crate::audio::pending_bytes(), 0); + sys_close(&mut proc, &mut host, fd2).unwrap(); } #[test] - fn close_dsp_releases_owner_and_clears_ring() { - use core::sync::atomic::Ordering; - let _g = DSP_OWNER_LOCK.lock().unwrap(); - reset_dsp_state(); + fn dsp_fatal_sink_polls_error_and_final_close_releases_ownership() { + use wasm_posix_shared::oss::SNDCTL_DSP_GETOSPACE; + use wasm_posix_shared::poll::{POLLERR, POLLOUT}; - let mut proc = Process::new(1); + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::audio::reset_for_test(); + let mut owner = Process::new(1); + let mut contender = Process::new(2); let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + let fd = sys_open(&mut owner, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + sys_write(&mut owner, &mut host, fd, &[1; 8]).unwrap(); + crate::audio::mark_fatal_error_for_test(); + + let mut pollfd = WasmPollFd { + fd, + events: POLLOUT, + revents: 0, + }; assert_eq!( - crate::audio::DSP_OWNER.load(Ordering::SeqCst), - proc.pid as i32 + sys_poll( + &mut owner, + &mut host, + core::slice::from_mut(&mut pollfd), + 0, + ), + Ok(1) ); + assert_eq!(pollfd.revents, POLLERR); - sys_write(&mut proc, &mut host, fd, &[1, 2, 3, 4]).unwrap(); - sys_close(&mut proc, &mut host, fd).unwrap(); - assert_eq!(crate::audio::DSP_OWNER.load(Ordering::SeqCst), -1); + let mut output_space = + [0; core::mem::size_of::()]; assert_eq!( - crate::audio::pending_bytes(), - 0, - "close should drain the ring when releasing ownership" + sys_ioctl( + &mut owner, + &mut host, + fd, + SNDCTL_DSP_GETOSPACE, + &mut output_space, + ), + Err(Errno::EIO) ); - } - #[test] - fn exec_keeps_dsp_owner_and_ring_for_open_fd() { - use core::sync::atomic::Ordering; - let _g = DSP_OWNER_LOCK.lock().unwrap(); - reset_dsp_state(); + assert_eq!(sys_close(&mut owner, &mut host, fd), Err(Errno::EIO)); + assert!( + owner.fd_table.get(fd).is_err(), + "fatal close must remove the descriptor even while returning EIO" + ); + assert_eq!(crate::audio::pending_bytes(), 0); - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let _fd = sys_open(&mut proc, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + let contender_fd = + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); assert_eq!( - crate::audio::DSP_OWNER.load(Ordering::SeqCst), - proc.pid as i32 + sys_close(&mut contender, &mut host, contender_fd), + Err(Errno::EIO), + "a persistent host sink failure is reported without retaining ownership" ); + } + + #[test] + fn failed_dev_fd_alias_opens_do_not_leak_dsp_ofd_owner() { + let _g = TEST_AUDIO_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::audio::reset_for_test(); + let mut owner = Process::new(1); + let mut contender = Process::new(2); + let mut host = MockHostIO::new(); + + let fd = sys_open(&mut owner, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + assert_eq!(fd, 3); + sys_setrlimit(&mut owner, 7, 4, 4096).unwrap(); - sys_write(&mut proc, &mut host, _fd, &[5, 5, 5, 5]).unwrap(); - sys_execve(&mut proc, &mut host, b"/bin/sh").unwrap(); assert_eq!( - crate::audio::DSP_OWNER.load(Ordering::SeqCst), - proc.pid as i32 + sys_open(&mut owner, &mut host, b"/dev/fd/3", O_WRONLY, 0), + Err(Errno::EMFILE) ); assert_eq!( - crate::audio::pending_bytes(), - 4, - "exec should retain samples behind a surviving open fd" + sys_openat( + &mut owner, + &mut host, + AT_FDCWD, + b"/dev/fd/3", + O_WRONLY, + 0, + ), + Err(Errno::EMFILE) ); - sys_close(&mut proc, &mut host, _fd).unwrap(); + + sys_close(&mut owner, &mut host, fd).unwrap(); + let contender_fd = + sys_open(&mut contender, &mut host, b"/dev/dsp", O_WRONLY, 0).unwrap(); + sys_close(&mut contender, &mut host, contender_fd).unwrap(); } // ----------------------------------------------------------------- diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index 0b96058452..9908a29915 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -1728,10 +1728,6 @@ fn finish_removed_process(pid: u32, result: crate::process_table::RemoveProcessR // successor open starts clean. No host-side unbind — the device is // host→kernel only. crate::syscalls::maybe_release_mice(pid); - // /dev/dsp cleanup: drop ownership and flush the PCM ring. The host-side - // AudioContext keeps playing whatever is already scheduled; we just stop - // feeding it new samples from this dead pid. - crate::syscalls::maybe_release_dsp(pid); } fn remove_process_and_cleanup(pid: u32) -> i32 { @@ -2756,7 +2752,12 @@ fn prepare_exec_state(pid: u32, caller_tid: u32) -> Result<(), Errno> { syscalls::sys_dup2_with_locks(proc, advisory_locks, &mut host, old_fd, new_fd)?; } FdAction::Close { fd } => { - syscalls::sys_close_with_locks(proc, advisory_locks, &mut host, fd)?; + syscalls::sys_close_implicit_with_locks( + proc, + advisory_locks, + &mut host, + fd, + )?; } FdAction::Open { fd, @@ -2768,8 +2769,12 @@ fn prepare_exec_state(pid: u32, caller_tid: u32) -> Result<(), Errno> { syscalls::sys_open(proc, &mut host, path, flags as u32, mode as u32)?; if opened_fd != fd { syscalls::sys_dup2_with_locks(proc, advisory_locks, &mut host, opened_fd, fd)?; - let _ = - syscalls::sys_close_with_locks(proc, advisory_locks, &mut host, opened_fd); + let _ = syscalls::sys_close_implicit_with_locks( + proc, + advisory_locks, + &mut host, + opened_fd, + ); } } } @@ -6537,7 +6542,8 @@ fn kernel_mknod(path_ptr: *const u8, path_len: u32, mode: u32) -> i32 { let flags = O_CREAT | O_EXCL | O_WRONLY; match syscalls::sys_open(proc, &mut host, path, flags, mode) { Ok(fd) => { - let _ = syscalls::sys_close_with_locks(proc, advisory_locks, &mut host, fd); + let _ = + syscalls::sys_close_implicit_with_locks(proc, advisory_locks, &mut host, fd); 0 } Err(e) => -(e as i32), @@ -6575,7 +6581,8 @@ fn kernel_mknodat(dirfd: i32, path_ptr: *const u8, path_len: u32, mode: u32) -> let flags = O_CREAT | O_EXCL | O_WRONLY; match syscalls::sys_openat(proc, &mut host, dirfd, path, flags, mode) { Ok(fd) => { - let _ = syscalls::sys_close_with_locks(proc, advisory_locks, &mut host, fd); + let _ = + syscalls::sys_close_implicit_with_locks(proc, advisory_locks, &mut host, fd); 0 } Err(e) => -(e as i32), @@ -11956,8 +11963,12 @@ pub extern "C" fn kernel_apply_fork_fd_actions() -> i32 { } } crate::process::FdAction::Close { fd } => { - if let Err(e) = syscalls::sys_close_with_locks(proc, advisory_locks, &mut host, fd) - { + if let Err(e) = syscalls::sys_close_implicit_with_locks( + proc, + advisory_locks, + &mut host, + fd, + ) { return -(e as i32); } } @@ -11978,7 +11989,7 @@ pub extern "C" fn kernel_apply_fork_fd_actions() -> i32 { ) { return -(e as i32); } - let _ = syscalls::sys_close_with_locks( + let _ = syscalls::sys_close_implicit_with_locks( proc, advisory_locks, &mut host, @@ -13368,6 +13379,13 @@ pub extern "C" fn kernel_is_fd_nonblock(pid: u32, fd: i32) -> i32 { Some(o) => o, None => return -1, }; + if ofd.file_type == crate::ofd::FileType::PcmPlayback { + return match crate::audio::is_nonblock(ofd.host_handle) { + Ok(true) => 1, + Ok(false) => 0, + Err(_) => -1, + }; + } if ofd.status_flags & wasm_posix_shared::flags::O_NONBLOCK != 0 { 1 } else { @@ -13418,8 +13436,9 @@ pub extern "C" fn kernel_get_socket_timeout_ms(pid: u32, fd: i32, is_recv: i32) /// Look up the send pipe/buffer index for a fd (for writing). /// For pipe fds: returns the pipe index. -/// For socket fds: returns send_buf_idx. -/// Returns -1 if the fd is not a pipe or connected socket. +/// For socket fds: returns send_buf_idx. For PCM playback: returns the +/// stream's writable-capacity wake token. +/// Returns -1 if the fd has no targeted writable wake token. #[unsafe(no_mangle)] pub extern "C" fn kernel_get_fd_send_pipe_idx(pid: u32, fd: i32) -> i32 { use crate::ofd::FileType; @@ -13449,6 +13468,9 @@ pub extern "C" fn kernel_get_fd_send_pipe_idx(pid: u32, fd: i32) -> i32 { None => -1, } } + FileType::PcmPlayback => crate::audio::wake_token_for_handle(ofd.host_handle) + .map(|token| token as i32) + .unwrap_or(-1), _ => -1, } } @@ -13684,10 +13706,11 @@ pub extern "C" fn kernel_inject_mouse_event(dx: i32, dy: i32, buttons: u32) { /// Drain up to `out_len` bytes of PCM audio from the kernel-side ring /// into the host-provided buffer. Returns the number of bytes copied. /// -/// The host calls this from its audio scheduler (typically once per -/// audio block at ~11–48 ms cadence) and feeds the result to a Web -/// Audio AudioContext. Reads stop on whole-frame boundaries (2 bytes -/// for mono, 4 for stereo) so the host never receives a torn L/R pair. +/// This compatibility pull path is retained for hosts that do not claim the +/// shared-clock transport. Browser AudioWorklets and the Node clocked sink +/// consume the shared ring directly, and an exclusive transport claim prevents +/// this export from racing them. Reads stop on whole-frame boundaries so a +/// caller never receives a torn PCM frame. /// /// `out_ptr` points into kernel-wasm memory — same pattern as /// `kernel_drain_wakeup_events`. The host's scratch allocation is the @@ -13699,7 +13722,7 @@ pub extern "C" fn kernel_drain_audio(out_ptr: *mut u8, out_len: u32) -> u32 { } /// Read the currently configured `/dev/dsp` sample rate (Hz). Defaults -/// to 11025 Hz before the user program calls `SNDCTL_DSP_SPEED`. +/// to 48000 Hz before the user program calls `SNDCTL_DSP_SPEED`. #[unsafe(no_mangle)] pub extern "C" fn kernel_audio_sample_rate() -> u32 { crate::audio::current_config().0 @@ -13720,6 +13743,38 @@ pub extern "C" fn kernel_audio_pending() -> u32 { crate::audio::pending_bytes() as u32 } +/// Base pointer and length of the versioned PCM shared transport. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_pcm_transport_ptr() -> u32 { + crate::audio::transport_ptr() as usize as u32 +} + +#[unsafe(no_mangle)] +pub extern "C" fn kernel_pcm_transport_len() -> u32 { + crate::audio::transport_len() +} + +/// Claim the single PCM consumer mode (legacy pull or shared audio clock). +#[unsafe(no_mangle)] +pub extern "C" fn kernel_pcm_claim_transport(mode: u32) -> i32 { + match crate::audio::claim_transport(mode) { + Ok(()) => 0, + Err(error) => -(error as i32), + } +} + +/// Reconcile a host-written consumer cursor and publish writer wakeups. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_pcm_reconcile() -> i32 { + crate::audio::reconcile() +} + +/// Advance the Node/headless sink by an audio-clock frame budget. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_pcm_clock_update(frames: u32) -> u32 { + crate::audio::clock_update(frames) +} + // --------------------------------------------------------------------------- // Wakeup event drain // --------------------------------------------------------------------------- diff --git a/crates/shared/src/ioctl_contract.rs b/crates/shared/src/ioctl_contract.rs index 59545603fe..4ad93afc76 100644 --- a/crates/shared/src/ioctl_contract.rs +++ b/crates/shared/src/ioctl_contract.rs @@ -166,6 +166,10 @@ pub const IOCTL_REQUEST_CONTRACTS: &[IoctlRequestContract] = &[ scalar_i32!(KDSKBMODE), no_arg!(crate::oss::SNDCTL_DSP_RESET), no_arg!(crate::oss::SNDCTL_DSP_SYNC), + no_arg!(crate::oss::SNDCTL_DSP_POST), + no_arg!(crate::oss::SNDCTL_DSP_NONBLOCK), + no_arg!(crate::oss::SNDCTL_DSP_SETSYNCRO), + no_arg!(crate::oss::SNDCTL_DSP_SETDUPLEX), pointer!(TCGETS, Out, TERMIOS_SIZE), pointer!(TCSETS, In, TERMIOS_SIZE), pointer!(TCSETSW, In, TERMIOS_SIZE), @@ -188,14 +192,32 @@ pub const IOCTL_REQUEST_CONTRACTS: &[IoctlRequestContract] = &[ no_arg!(crate::dri::DRM_IOCTL_SET_MASTER), no_arg!(crate::dri::DRM_IOCTL_DROP_MASTER), pointer!(SIOCATMARK, Out, 4), + pointer!(crate::oss::SNDCTL_DSP_SETBLKSIZE, In, 4), + pointer!(crate::oss::SNDCTL_DSP_SETTRIGGER, In, 4), pointer!(TIOCSPTLCK, In, 4), pointer!(crate::dri::DRM_IOCTL_GEM_CLOSE, In, 8), + pointer!(crate::oss::SOUND_PCM_READ_RATE, Out, 4), + pointer!(crate::oss::SOUND_PCM_READ_BITS, Out, 4), + pointer!(crate::oss::SOUND_PCM_READ_CHANNELS, Out, 4), + pointer!(crate::oss::SOUND_PCM_READ_FILTER, Out, 4), pointer!(crate::oss::SNDCTL_DSP_GETFMTS, Out, 4), + pointer!(crate::oss::SNDCTL_DSP_GETCAPS, Out, 4), + pointer!(crate::oss::SNDCTL_DSP_GETTRIGGER, Out, 4), + pointer!(crate::oss::SNDCTL_DSP_GETODELAY, Out, 4), pointer!(TIOCGPTN, Out, 4), + pointer!(crate::oss::SNDCTL_DSP_MAPINBUF, Out, 8), + pointer!(crate::oss::SNDCTL_DSP_MAPOUTBUF, Out, 8), + pointer!(crate::oss::SNDCTL_DSP_GETIPTR, Out, 12), + pointer!(crate::oss::SNDCTL_DSP_GETOPTR, Out, 12), + pointer!(crate::oss::SNDCTL_DSP_GETOSPACE, Out, 16), + pointer!(crate::oss::SNDCTL_DSP_GETISPACE, Out, 16), pointer!(crate::oss::SNDCTL_DSP_SPEED, InOut, 4), pointer!(crate::oss::SNDCTL_DSP_STEREO, InOut, 4), + pointer!(crate::oss::SNDCTL_DSP_GETBLKSIZE, Out, 4), pointer!(crate::oss::SNDCTL_DSP_SETFMT, InOut, 4), pointer!(crate::oss::SNDCTL_DSP_CHANNELS, InOut, 4), + pointer!(crate::oss::SOUND_PCM_WRITE_FILTER, InOut, 4), + pointer!(crate::oss::SNDCTL_DSP_SUBDIVIDE, InOut, 4), pointer!(crate::oss::SNDCTL_DSP_SETFRAGMENT, InOut, 4), pointer!(crate::dri::DRM_IOCTL_MODE_RMFB, In, 4), pointer!(crate::dri::DRM_IOCTL_MODE_DESTROY_DUMB, In, 4), diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 9c2e4f738c..92cf654e71 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -107,7 +107,10 @@ pub mod process_layout; /// descriptor-mode, and socket-timeout query exports. Each channel request /// also carries generated, one-shot flags that distinguish `__syscall_cp` /// from a plain syscall with the same number and defer signal delivery for -/// completions consumed outside libc's post-syscall trampoline. +/// completions consumed outside libc's post-syscall trampoline. OSS PCM +/// ioctl transfers use request-sized arguments, `/dev/dsp` descriptors +/// share a refcounted stream across fork and exec, and the host consumes a +/// versioned bounded transport paced by the audio clock. pub const ABI_VERSION: u32 = 43; /// Byte width of Kandelo's Linux-compatible kernel CPU-affinity mask. @@ -2949,6 +2952,11 @@ pub mod abi { "kernel_mq_descriptor_msgsize", "kernel_msqid_ds_bytes", "kernel_pick_tcp_listener_target", + "kernel_pcm_claim_transport", + "kernel_pcm_clock_update", + "kernel_pcm_reconcile", + "kernel_pcm_transport_len", + "kernel_pcm_transport_ptr", "kernel_pick_signal_target_tid", "kernel_pipe_has_readers", "kernel_posix_timer_fire", @@ -3956,20 +3964,12 @@ mod fbdev_tests { /// OSS (Open Sound System) ABI constants. /// -/// These mirror what glibc / musl expose via `` to -/// programs that talk to `/dev/dsp`. We accept the subset fbDOOM (and -/// most real OSS clients) actually emit during init: speed, channel -/// count, format, and a couple of accept-and-acknowledge ops. -/// -/// The numeric values are the standard OSS encoding — the same numbers -/// real Linux kernels return — so user-space programs that hard-code -/// these constants (rather than `#include`-ing the header) work -/// unchanged. +/// This is Kandelo's owned wasm32 source ABI, informed by canonical OSS and +/// FreeBSD's PCM frontend. Every supported command has real state semantics; +/// unsupported capture, duplex, trigger, and mmap operations remain errors. pub mod oss { - // The values below come from the Linux `` IOC - // encoding — the same ones glibc, musl, and any OSS-targeted DOS - // port hard-code. Matching them exactly lets user programs that - // skip the header still talk to us. + // Pin canonical OSS ioctl encodings explicitly so the SDK header and Rust + // frontend cannot inherit or drift with a host operating system's ABI. /// `SNDCTL_DSP_RESET` — flush + stop. No argument. pub const SNDCTL_DSP_RESET: u32 = 0x00005000; @@ -3981,17 +3981,299 @@ pub mod oss { pub const SNDCTL_DSP_STEREO: u32 = 0xc0045003; /// `SNDCTL_DSP_GETBLKSIZE` — preferred fragment size. out: i32 bytes. pub const SNDCTL_DSP_GETBLKSIZE: u32 = 0xc0045004; + /// FreeBSD's distinct block-size setter; pinned for source ABI but unsupported. + pub const SNDCTL_DSP_SETBLKSIZE: u32 = 0x40045004; /// `SNDCTL_DSP_SETFMT` — get/set sample format. inout: i32 AFMT_*. pub const SNDCTL_DSP_SETFMT: u32 = 0xc0045005; /// `SNDCTL_DSP_CHANNELS` — get/set explicit channel count. inout: i32. pub const SNDCTL_DSP_CHANNELS: u32 = 0xc0045006; + /// Legacy PCM filter control; pinned for source ABI but unsupported. + pub const SOUND_PCM_WRITE_FILTER: u32 = 0xc0045007; + /// `SNDCTL_DSP_POST` — start playback of queued output. No argument. + pub const SNDCTL_DSP_POST: u32 = 0x00005008; + /// Legacy fragment subdivision control; pinned for source ABI but unsupported. + pub const SNDCTL_DSP_SUBDIVIDE: u32 = 0xc0045009; /// `SNDCTL_DSP_GETFMTS` — bitmask of supported formats. out: i32 AFMT_* mask. pub const SNDCTL_DSP_GETFMTS: u32 = 0x8004500b; /// `SNDCTL_DSP_SETFRAGMENT` — fragment-size hint. inout: i32. pub const SNDCTL_DSP_SETFRAGMENT: u32 = 0xc004500a; + /// `SNDCTL_DSP_GETOSPACE` — immediately writable output geometry. + pub const SNDCTL_DSP_GETOSPACE: u32 = 0x8010500c; + /// Canonical capture query; pinned for source ABI but unsupported. + pub const SNDCTL_DSP_GETISPACE: u32 = 0x8010500d; + /// `SNDCTL_DSP_NONBLOCK` — enable non-blocking mode on this OFD. + pub const SNDCTL_DSP_NONBLOCK: u32 = 0x0000500e; + /// `SNDCTL_DSP_GETCAPS` — query truthful PCM capabilities. + pub const SNDCTL_DSP_GETCAPS: u32 = 0x8004500f; + /// Canonical trigger controls; pinned for source ABI but unsupported. + pub const SNDCTL_DSP_SETTRIGGER: u32 = 0x40045010; + pub const SNDCTL_DSP_GETTRIGGER: u32 = 0x80045010; + /// Canonical capture position; pinned for source ABI but unsupported. + pub const SNDCTL_DSP_GETIPTR: u32 = 0x800c5011; + /// `SNDCTL_DSP_GETOPTR` — query monotonic output position. + pub const SNDCTL_DSP_GETOPTR: u32 = 0x800c5012; + /// Canonical mmap-buffer operations; pinned for source ABI but unsupported. + pub const SNDCTL_DSP_MAPINBUF: u32 = 0x80085013; + pub const SNDCTL_DSP_MAPOUTBUF: u32 = 0x80085014; + /// Canonical synchronization/duplex controls; pinned but unsupported. + pub const SNDCTL_DSP_SETSYNCRO: u32 = 0x00005015; + pub const SNDCTL_DSP_SETDUPLEX: u32 = 0x00005016; + /// `SNDCTL_DSP_GETODELAY` — queued, not-yet-played output bytes. + pub const SNDCTL_DSP_GETODELAY: u32 = 0x80045017; + /// Read-only aliases used by portable OSS clients. + pub const SOUND_PCM_READ_RATE: u32 = 0x80045002; + pub const SOUND_PCM_READ_BITS: u32 = 0x80045005; + pub const SOUND_PCM_READ_CHANNELS: u32 = 0x80045006; + pub const SOUND_PCM_READ_FILTER: u32 = 0x80045007; + + /// Values accepted by the canonical (currently unsupported) trigger ioctls. + pub const PCM_ENABLE_INPUT: u32 = 0x0000_0001; + pub const PCM_ENABLE_OUTPUT: u32 = 0x0000_0002; + + /// `AFMT_QUERY` — query the current format without changing it. + pub const AFMT_QUERY: u32 = 0; + // Keep the canonical OSS/FreeBSD format namespace available to source + // consumers even when the initial playback core does not implement a + // particular encoding. `GETFMTS` advertises only `SUPPORTED_FORMATS`, + // and `SETFMT` rejects every other value. + pub const AFMT_MU_LAW: u32 = 0x0000_0001; + pub const AFMT_A_LAW: u32 = 0x0000_0002; + pub const AFMT_IMA_ADPCM: u32 = 0x0000_0004; + /// `AFMT_U8` — unsigned 8-bit PCM. + pub const AFMT_U8: u32 = 0x0000_0008; + /// `AFMT_S16_LE` — signed 16-bit little-endian PCM. + pub const AFMT_S16_LE: u32 = 0x0000_0010; + /// `AFMT_S16_BE` — signed 16-bit big-endian PCM. + pub const AFMT_S16_BE: u32 = 0x0000_0020; + pub const AFMT_S8: u32 = 0x0000_0040; + pub const AFMT_U16_LE: u32 = 0x0000_0080; + pub const AFMT_U16_BE: u32 = 0x0000_0100; + pub const AFMT_MPEG: u32 = 0x0000_0200; + pub const AFMT_AC3: u32 = 0x0000_0400; + pub const AFMT_S32_LE: u32 = 0x0000_1000; + pub const AFMT_S32_BE: u32 = 0x0000_2000; + pub const AFMT_U32_LE: u32 = 0x0000_4000; + pub const AFMT_U32_BE: u32 = 0x0000_8000; + pub const AFMT_S24_LE: u32 = 0x0001_0000; + pub const AFMT_S24_BE: u32 = 0x0002_0000; + pub const AFMT_U24_LE: u32 = 0x0004_0000; + pub const AFMT_U24_BE: u32 = 0x0008_0000; + pub const AFMT_F32_LE: u32 = 0x1000_0000; + pub const AFMT_F32_BE: u32 = 0x2000_0000; + + pub const SUPPORTED_FORMATS: u32 = AFMT_U8 | AFMT_S16_LE | AFMT_S16_BE; + + // OSS4/FreeBSD core capability bits. The SDK exposes these names for + // source compatibility; only output/default/virtual are advertised. + pub const PCM_CAP_REVISION: u32 = 0x0000_00ff; + pub const PCM_CAP_DUPLEX: u32 = 0x0000_0100; + pub const PCM_CAP_REALTIME: u32 = 0x0000_0200; + pub const PCM_CAP_BATCH: u32 = 0x0000_0400; + pub const PCM_CAP_COPROC: u32 = 0x0000_0800; + pub const PCM_CAP_TRIGGER: u32 = 0x0000_1000; + pub const PCM_CAP_MMAP: u32 = 0x0000_2000; + pub const PCM_CAP_MULTI: u32 = 0x0000_4000; + pub const PCM_CAP_BIND: u32 = 0x0000_8000; + pub const PCM_CAP_INPUT: u32 = 0x0001_0000; + pub const PCM_CAP_OUTPUT: u32 = 0x0002_0000; + pub const PCM_CAP_VIRTUAL: u32 = 0x0004_0000; + pub const PCM_CAP_DEFAULT: u32 = 0x4000_0000; + pub const SUPPORTED_CAPS: u32 = PCM_CAP_OUTPUT | PCM_CAP_VIRTUAL | PCM_CAP_DEFAULT; + + /// Wasm32-owned layout of OSS `audio_buf_info`. + #[repr(C)] + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] + pub struct AudioBufInfo { + pub fragments: i32, + pub fragstotal: i32, + pub fragsize: i32, + pub bytes: i32, + } + + /// Wasm32-owned layout of OSS `count_info`. + #[repr(C)] + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] + pub struct CountInfo { + pub bytes: i32, + pub blocks: i32, + pub ptr: i32, + } - /// `AFMT_S16_LE` — signed 16-bit little-endian. The only format we accept. - pub const AFMT_S16_LE: u32 = 0x10; +} + +/// Implementation-neutral PCM host transport contract. +pub mod pcm { + pub const PCM_TRANSPORT_MAGIC: u32 = 0x314d_4350; // "PCM1" LE + pub const PCM_TRANSPORT_VERSION: u32 = 1; + pub const PCM_TRANSPORT_HEADER_BYTES: u32 = 128; + pub const PCM_TRANSPORT_RING_BYTES: u32 = 64 * 1024; + pub const PCM_TRANSPORT_BYTES: u32 = PCM_TRANSPORT_HEADER_BYTES + PCM_TRANSPORT_RING_BYTES; + + pub const PCM_STATE_CLOSED: u32 = 0; + pub const PCM_STATE_STOPPED: u32 = 1; + pub const PCM_STATE_RUNNING: u32 = 2; + pub const PCM_STATE_DRAINING: u32 = 3; + + pub const PCM_FORMAT_UNKNOWN: u32 = 0; + pub const PCM_FORMAT_U8: u32 = 1; + pub const PCM_FORMAT_S16_LE: u32 = 2; + pub const PCM_FORMAT_S16_BE: u32 = 3; + + pub const PCM_TRANSPORT_UNCLAIMED: u32 = 0; + pub const PCM_TRANSPORT_LEGACY_PULL: u32 = 1; + pub const PCM_TRANSPORT_SHARED_CLOCK: u32 = 2; + + /// Kernel is publishing a new multi-field stream configuration. Host + /// clocks must render silence and avoid cursor publication until clear. + pub const PCM_FLAG_CONFIGURING: u32 = 1 << 0; + /// Playback is currently in an underrun episode. The first transition + /// into an episode increments `PcmSharedControl::underruns`. + pub const PCM_FLAG_UNDERRUN_ACTIVE: u32 = 1 << 1; + /// The attached physical sink failed permanently. Suspension and browser + /// user-activation waits are recoverable and must not set this bit. + pub const PCM_FLAG_FATAL_ERROR: u32 = 1 << 2; + + /// Versioned PCM-only header shared with browser AudioWorklets and Node + /// sinks. Every field is a 32-bit word so JS can use `Atomics` directly. + /// The three u64 cursors use odd/even seqlocks around low/high words. + #[repr(C)] + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] + pub struct PcmSharedControl { + pub magic: u32, + pub version: u32, + pub header_bytes: u32, + pub physical_capacity_bytes: u32, + pub active_capacity_bytes: u32, + pub format: u32, + pub rate: u32, + pub channels: u32, + pub frame_bytes: u32, + pub fragment_bytes: u32, + pub fragment_count: u32, + pub state: u32, + pub generation: u32, + pub flags: u32, + pub transport_mode: u32, + pub producer_seq: u32, + pub producer_lo: u32, + pub producer_hi: u32, + pub consumer_seq: u32, + pub consumer_lo: u32, + pub consumer_hi: u32, + pub discard_seq: u32, + pub discard_lo: u32, + pub discard_hi: u32, + pub underruns: u32, + pub wake_seq: u32, + pub reserved: [u32; 6], + } +} + +#[cfg(test)] +mod oss_abi_tests { + use super::oss::*; + use super::pcm::*; + use core::mem::{align_of, offset_of, size_of}; + + #[test] + fn ioctl_values_and_struct_layouts_are_pinned() { + assert_eq!(SNDCTL_DSP_RESET, 0x0000_5000); + assert_eq!(SNDCTL_DSP_SYNC, 0x0000_5001); + assert_eq!(SNDCTL_DSP_SPEED, 0xc004_5002); + assert_eq!(SNDCTL_DSP_STEREO, 0xc004_5003); + assert_eq!(SNDCTL_DSP_GETBLKSIZE, 0xc004_5004); + assert_eq!(SNDCTL_DSP_SETBLKSIZE, 0x4004_5004); + assert_eq!(SNDCTL_DSP_SETFMT, 0xc004_5005); + assert_eq!(SNDCTL_DSP_CHANNELS, 0xc004_5006); + assert_eq!(SOUND_PCM_WRITE_FILTER, 0xc004_5007); + assert_eq!(SNDCTL_DSP_POST, 0x0000_5008); + assert_eq!(SNDCTL_DSP_SUBDIVIDE, 0xc004_5009); + assert_eq!(SNDCTL_DSP_SETFRAGMENT, 0xc004_500a); + assert_eq!(SNDCTL_DSP_GETFMTS, 0x8004_500b); + assert_eq!(SNDCTL_DSP_GETOSPACE, 0x8010_500c); + assert_eq!(SNDCTL_DSP_GETISPACE, 0x8010_500d); + assert_eq!(SNDCTL_DSP_NONBLOCK, 0x0000_500e); + assert_eq!(SNDCTL_DSP_GETCAPS, 0x8004_500f); + assert_eq!(SNDCTL_DSP_SETTRIGGER, 0x4004_5010); + assert_eq!(SNDCTL_DSP_GETTRIGGER, 0x8004_5010); + assert_eq!(SNDCTL_DSP_GETIPTR, 0x800c_5011); + assert_eq!(SNDCTL_DSP_GETOPTR, 0x800c_5012); + assert_eq!(SNDCTL_DSP_MAPINBUF, 0x8008_5013); + assert_eq!(SNDCTL_DSP_MAPOUTBUF, 0x8008_5014); + assert_eq!(SNDCTL_DSP_SETSYNCRO, 0x0000_5015); + assert_eq!(SNDCTL_DSP_SETDUPLEX, 0x0000_5016); + assert_eq!(SNDCTL_DSP_GETODELAY, 0x8004_5017); + assert_eq!(SOUND_PCM_READ_RATE, 0x8004_5002); + assert_eq!(SOUND_PCM_READ_BITS, 0x8004_5005); + assert_eq!(SOUND_PCM_READ_CHANNELS, 0x8004_5006); + assert_eq!(SOUND_PCM_READ_FILTER, 0x8004_5007); + assert_eq!(PCM_ENABLE_INPUT, 0x0000_0001); + assert_eq!(PCM_ENABLE_OUTPUT, 0x0000_0002); + assert_eq!(AFMT_QUERY, 0x0000_0000); + assert_eq!(AFMT_MU_LAW, 0x0000_0001); + assert_eq!(AFMT_A_LAW, 0x0000_0002); + assert_eq!(AFMT_IMA_ADPCM, 0x0000_0004); + assert_eq!(AFMT_U8, 0x0000_0008); + assert_eq!(AFMT_S16_LE, 0x0000_0010); + assert_eq!(AFMT_S16_BE, 0x0000_0020); + assert_eq!(AFMT_S8, 0x0000_0040); + assert_eq!(AFMT_U16_LE, 0x0000_0080); + assert_eq!(AFMT_U16_BE, 0x0000_0100); + assert_eq!(AFMT_MPEG, 0x0000_0200); + assert_eq!(AFMT_AC3, 0x0000_0400); + assert_eq!(AFMT_S32_LE, 0x0000_1000); + assert_eq!(AFMT_S32_BE, 0x0000_2000); + assert_eq!(AFMT_U32_LE, 0x0000_4000); + assert_eq!(AFMT_U32_BE, 0x0000_8000); + assert_eq!(AFMT_S24_LE, 0x0001_0000); + assert_eq!(AFMT_S24_BE, 0x0002_0000); + assert_eq!(AFMT_U24_LE, 0x0004_0000); + assert_eq!(AFMT_U24_BE, 0x0008_0000); + assert_eq!(AFMT_F32_LE, 0x1000_0000); + assert_eq!(AFMT_F32_BE, 0x2000_0000); + assert_eq!(SUPPORTED_FORMATS, 0x0000_0038); + assert_eq!(PCM_CAP_REVISION, 0x0000_00ff); + assert_eq!(PCM_CAP_DUPLEX, 0x0000_0100); + assert_eq!(PCM_CAP_REALTIME, 0x0000_0200); + assert_eq!(PCM_CAP_BATCH, 0x0000_0400); + assert_eq!(PCM_CAP_COPROC, 0x0000_0800); + assert_eq!(PCM_CAP_TRIGGER, 0x0000_1000); + assert_eq!(PCM_CAP_MMAP, 0x0000_2000); + assert_eq!(PCM_CAP_MULTI, 0x0000_4000); + assert_eq!(PCM_CAP_BIND, 0x0000_8000); + assert_eq!(PCM_CAP_INPUT, 0x0001_0000); + assert_eq!(PCM_CAP_OUTPUT, 0x0002_0000); + assert_eq!(PCM_CAP_VIRTUAL, 0x0004_0000); + assert_eq!(PCM_CAP_DEFAULT, 0x4000_0000); + assert_eq!(size_of::(), 16); + assert_eq!(align_of::(), 4); + assert_eq!(offset_of!(AudioBufInfo, fragments), 0); + assert_eq!(offset_of!(AudioBufInfo, fragstotal), 4); + assert_eq!(offset_of!(AudioBufInfo, fragsize), 8); + assert_eq!(offset_of!(AudioBufInfo, bytes), 12); + assert_eq!(size_of::(), 12); + assert_eq!(align_of::(), 4); + assert_eq!(offset_of!(CountInfo, bytes), 0); + assert_eq!(offset_of!(CountInfo, blocks), 4); + assert_eq!(offset_of!(CountInfo, ptr), 8); + } + + #[test] + fn pcm_transport_layout_is_fixed_for_js_atomics() { + assert_eq!(size_of::(), 128); + assert_eq!(align_of::(), 4); + assert_eq!(offset_of!(PcmSharedControl, active_capacity_bytes), 16); + assert_eq!(offset_of!(PcmSharedControl, state), 44); + assert_eq!(offset_of!(PcmSharedControl, producer_seq), 60); + assert_eq!(offset_of!(PcmSharedControl, consumer_seq), 72); + assert_eq!(offset_of!(PcmSharedControl, discard_seq), 84); + assert_eq!(offset_of!(PcmSharedControl, underruns), 96); + assert_eq!(offset_of!(PcmSharedControl, wake_seq), 100); + assert_eq!(PCM_FLAG_CONFIGURING, 1); + assert_eq!(PCM_FLAG_UNDERRUN_ACTIVE, 2); + assert_eq!(PCM_FLAG_FATAL_ERROR, 4); + assert_eq!(PCM_TRANSPORT_BYTES, 65_664); + } } /// GLES / EGL ABI: ioctl numbers, opcode tables, and marshalled argument diff --git a/examples/dsp_signal_test.c b/examples/dsp_signal_test.c new file mode 100644 index 0000000000..8c402cf28a --- /dev/null +++ b/examples/dsp_signal_test.c @@ -0,0 +1,215 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum { PCM_BYTES = 8192 }; + +static volatile sig_atomic_t alarm_count; +static unsigned char pcm[PCM_BYTES]; + +static void on_alarm(int signum) +{ + (void)signum; + alarm_count++; +} + +static int arm_alarm(long usec) +{ + const struct itimerval timer = { + .it_value = { .tv_sec = 0, .tv_usec = usec }, + }; + return setitimer(ITIMER_REAL, &timer, NULL); +} + +static long elapsed_ms(struct timespec start, struct timespec end) +{ + return (end.tv_sec - start.tv_sec) * 1000L + + (end.tv_nsec - start.tv_nsec) / 1000000L; +} + +static int configure_dsp(int fd) +{ + int format = AFMT_U8; + int channels = 1; + int rate = 8000; + int fragments = (2 << 16) | 12; + + if (ioctl(fd, SNDCTL_DSP_SETFMT, &format) != 0 || format != AFMT_U8) + return -1; + if (ioctl(fd, SNDCTL_DSP_CHANNELS, &channels) != 0 || channels != 1) + return -1; + if (ioctl(fd, SNDCTL_DSP_SPEED, &rate) != 0 || rate != 8000) + return -1; + if (ioctl(fd, SNDCTL_DSP_SETFRAGMENT, &fragments) != 0 || + fragments != ((2 << 16) | 12)) + return -1; + return 0; +} + +static int fill_dsp(int fd) +{ + ssize_t written = write(fd, pcm, sizeof(pcm)); + if (written != (ssize_t)sizeof(pcm)) { + fprintf(stderr, "fill write: result=%ld errno=%d\n", (long)written, errno); + return -1; + } + return 0; +} + +static int expect_interrupted_write(int fd) +{ + const int before = alarm_count; + if (fill_dsp(fd) != 0 || arm_alarm(20 * 1000) != 0) + return -1; + errno = 0; + ssize_t result = write(fd, pcm, sizeof(pcm)); + if (result != -1 || errno != EINTR || alarm_count != before + 1) { + fprintf(stderr, "write interruption: result=%ld errno=%d alarms=%d\n", + (long)result, errno, (int)alarm_count); + return -1; + } + return ioctl(fd, SNDCTL_DSP_RESET, 0); +} + +static int expect_interrupted_sync(int fd) +{ + const int before = alarm_count; + if (fill_dsp(fd) != 0 || arm_alarm(20 * 1000) != 0) + return -1; + errno = 0; + int result = ioctl(fd, SNDCTL_DSP_SYNC, 0); + if (result != -1 || errno != EINTR || alarm_count != before + 1) { + fprintf(stderr, "sync interruption: result=%d errno=%d alarms=%d\n", + result, errno, (int)alarm_count); + return -1; + } + return ioctl(fd, SNDCTL_DSP_RESET, 0); +} + +static int expect_interrupted_close(int fd) +{ + const int before = alarm_count; + if (fill_dsp(fd) != 0 || arm_alarm(20 * 1000) != 0) + return -1; + errno = 0; + int result = close(fd); + if (result != -1 || errno != EINTR || alarm_count != before + 1) { + fprintf(stderr, "close interruption: result=%d errno=%d alarms=%d\n", + result, errno, (int)alarm_count); + return -1; + } + if (fcntl(fd, F_GETFL) < 0) { + perror("interrupted close consumed fd"); + return -1; + } + return 0; +} + +static int expect_restarted_write(int fd) +{ + const int before = alarm_count; + struct timespec start; + struct timespec end; + if (fill_dsp(fd) != 0 || arm_alarm(20 * 1000) != 0 || + clock_gettime(CLOCK_MONOTONIC, &start) != 0) + return -1; + errno = 0; + ssize_t result = write(fd, pcm, sizeof(pcm)); + if (clock_gettime(CLOCK_MONOTONIC, &end) != 0) + return -1; + long duration = elapsed_ms(start, end); + if (result != (ssize_t)sizeof(pcm) || alarm_count != before + 1 || + duration < 500 || duration > 5000) { + fprintf(stderr, + "restarted write: result=%ld errno=%d alarms=%d elapsed=%ld\n", + (long)result, errno, (int)alarm_count, duration); + return -1; + } + return ioctl(fd, SNDCTL_DSP_RESET, 0); +} + +static int expect_restarted_sync(int fd) +{ + const int before = alarm_count; + struct timespec start; + struct timespec end; + if (fill_dsp(fd) != 0 || arm_alarm(20 * 1000) != 0 || + clock_gettime(CLOCK_MONOTONIC, &start) != 0) + return -1; + errno = 0; + int result = ioctl(fd, SNDCTL_DSP_SYNC, 0); + if (clock_gettime(CLOCK_MONOTONIC, &end) != 0) + return -1; + long duration = elapsed_ms(start, end); + if (result != 0 || alarm_count != before + 1 || + duration < 500 || duration > 5000) { + fprintf(stderr, + "restarted sync: result=%d errno=%d alarms=%d elapsed=%ld\n", + result, errno, (int)alarm_count, duration); + return -1; + } + return 0; +} + +int main(void) +{ + for (size_t i = 0; i < sizeof(pcm); i++) + pcm[i] = (unsigned char)i; + + struct sigaction action = { .sa_handler = on_alarm }; + sigemptyset(&action.sa_mask); + if (sigaction(SIGALRM, &action, NULL) != 0) { + perror("sigaction"); + return 2; + } + + int fd = open("/dev/dsp", O_WRONLY); + if (fd < 0 || configure_dsp(fd) != 0) { + perror("open/configure dsp"); + return 3; + } + if (expect_interrupted_write(fd) != 0 || + expect_interrupted_sync(fd) != 0 || + expect_interrupted_close(fd) != 0) { + return 4; + } + if (ioctl(fd, SNDCTL_DSP_RESET, 0) != 0 || close(fd) != 0) { + perror("cleanup after interrupted close"); + return 5; + } + + action.sa_flags = SA_RESTART; + if (sigaction(SIGALRM, &action, NULL) != 0) { + perror("sigaction(SA_RESTART)"); + return 6; + } + fd = open("/dev/dsp", O_WRONLY); + if (fd < 0 || configure_dsp(fd) != 0) { + perror("reopen/configure dsp"); + return 7; + } + if (expect_restarted_write(fd) != 0 || expect_restarted_sync(fd) != 0) + return 8; + + /* close is intentionally outside the SA_RESTART whitelist: an EINTR + * leaves the descriptor live, so policy remains with the caller. */ + if (expect_interrupted_close(fd) != 0) + return 9; + if (ioctl(fd, SNDCTL_DSP_RESET, 0) != 0 || close(fd) != 0) { + perror("final cleanup"); + return 10; + } + + printf("PASS dsp signal interruption alarms=%d\n", (int)alarm_count); + return 0; +} diff --git a/host/package.json b/host/package.json index 10ffb89855..01ce9a2b8b 100644 --- a/host/package.json +++ b/host/package.json @@ -61,6 +61,7 @@ "default": "./dist/framebuffer/index.cjs" } }, + "./audio/pcm-audio-worklet.js": "./dist/audio/pcm-audio-worklet.js", "./worker-entry": { "browser": { "types": "./dist/worker-entry-browser.d.ts", diff --git a/host/src/audio/browser-pcm-driver.ts b/host/src/audio/browser-pcm-driver.ts new file mode 100644 index 0000000000..f907536183 --- /dev/null +++ b/host/src/audio/browser-pcm-driver.ts @@ -0,0 +1,380 @@ +import type { PcmOutputDriver, PcmOutputState } from "./pcm-driver.js"; +import { + PCM_CONTROL, + PcmSampleFormat, + PcmStreamState, + PcmTransportFlag, + hasPcmFatalError, + markPcmFatalError, + pcmControlWords, + validatePcmTransport, + type PcmTransportDescriptor, +} from "./pcm-transport.js"; + +export interface BrowserPcmDriverOptions { + workletUrl: string | URL; + createContext?: () => AudioContext; + createNode?: ( + context: AudioContext, + name: string, + options: AudioWorkletNodeOptions, + ) => AudioWorkletNode; +} + +const DEFAULT_RENDER_QUANTUM_FRAMES = 128; +const UNREPORTED_OUTPUT_LATENCY_FALLBACK_MS = 100; +const MAX_OUTPUT_PIPELINE_SETTLE_MS = 1000; + +/** AudioWorklet-backed physical/default PCM sink for browser machines. */ +export class BrowserPcmDriver implements PcmOutputDriver { + private readonly listeners = new Set<(state: PcmOutputState) => void>(); + private context: AudioContext | null = null; + private node: AudioWorkletNode | null = null; + private transport: PcmTransportDescriptor | null = null; + private state: PcmOutputState = "unprepared"; + private preparing: Promise | null = null; + private readonly onContextError = () => this.fail(); + + constructor(private readonly options: BrowserPcmDriverOptions) {} + + prepare(transport: PcmTransportDescriptor): Promise { + if (this.state === "closed") { + return Promise.reject(new Error("PCM output is closed")); + } + validatePcmTransport(transport); + if (this.transport) { + if (!sameTransport(this.transport, transport)) { + return Promise.reject( + new Error("PCM output is already attached to another transport"), + ); + } + return this.preparing ?? Promise.resolve(); + } + this.transport = transport; + this.preparing = this.prepareInner(transport).finally(() => { + this.preparing = null; + }); + return this.preparing; + } + + async resume(): Promise { + if (this.preparing) await this.preparing; + if (this.state === "error") throw new Error("PCM output has failed"); + if (this.transport && hasPcmFatalError(pcmControlWords(this.transport))) { + this.setState("error"); + throw new Error("PCM output has failed"); + } + const context = this.context; + if (!context) throw new Error("PCM output has not been prepared"); + try { + await context.resume(); + this.syncContextState(); + if (context.state !== "running") { + throw new Error(`AudioContext remained ${context.state}`); + } + } catch (error) { + // A suspended context and a rejected resume before a user gesture are + // recoverable. The caller can retry resume from the next activation. + this.syncContextState(); + throw error; + } + } + + async suspend(): Promise { + if (this.preparing) await this.preparing; + if (this.transport && hasPcmFatalError(pcmControlWords(this.transport))) { + this.setState("error"); + } + if (!this.context || this.context.state === "closed") return; + await this.context.suspend(); + this.syncContextState(); + } + + /** + * Let samples already rendered by the worklet reach the physical output + * before machine teardown closes the AudioContext. + * + * The shared consumer cursor advances when the worklet fills a render + * quantum, before Web Audio's downstream buffers and the output device have + * necessarily emitted that quantum. BrowserKernel calls this only after the + * kernel worker has drained the shared PCM ring. Per Web Audio 1.1's + * AudioContext.suspend() contract, suspension lets already-processed blocks + * play to the destination and resolves once its frame buffer has been handed + * to the hardware; the latency wait then covers physical emission of the + * reported processing/device queues and the final quantum. + * + * This method never resumes a context, so it does not bypass browser user- + * activation policy. Both suspension and the final wait share a bounded + * teardown budget so a broken AudioContext cannot wedge machine destroy. + */ + async settleOutputPipeline(): Promise { + if (this.preparing) await this.preparing.catch(() => {}); + const context = this.context; + if (!context || !this.node || context.state !== "running") return; + + const startedAt = performance.now(); + await waitForPromiseWithin( + context.suspend(), + MAX_OUTPUT_PIPELINE_SETTLE_MS, + ); + this.syncContextState(); + + if ((context.state as string) === "closed" || context !== this.context) { + return; + } + const elapsedMs = Math.max(0, performance.now() - startedAt); + const remainingBudgetMs = Math.max( + 0, + MAX_OUTPUT_PIPELINE_SETTLE_MS - elapsedMs, + ); + const settleMs = Math.min( + outputPipelineSettleMs(context), + remainingBudgetMs, + ); + if (settleMs > 0) await delay(settleMs); + } + + async close(): Promise { + if (this.state === "closed") return; + if (this.preparing) await this.preparing.catch(() => {}); + const node = this.node; + const context = this.context; + this.node = null; + this.context = null; + this.transport = null; + if (node) { + node.port.onmessage = null; + node.onprocessorerror = null; + node.disconnect(); + node.port.close(); + } + if (context) { + context.onstatechange = null; + context.removeEventListener?.("error", this.onContextError); + } + if (context && context.state !== "closed") await context.close(); + this.setState("closed"); + this.listeners.clear(); + } + + getState(): PcmOutputState { + return this.state; + } + + subscribe(listener: (state: PcmOutputState) => void): () => void { + this.listeners.add(listener); + listener(this.state); + return () => this.listeners.delete(listener); + } + + private async prepareInner(transport: PcmTransportDescriptor): Promise { + const AudioContextCtor = + globalThis.AudioContext ?? + ( + globalThis as typeof globalThis & { + webkitAudioContext?: typeof AudioContext; + } + ).webkitAudioContext; + if (!this.options.createContext && !AudioContextCtor) { + this.markFatalError(); + this.setState("unavailable"); + this.transport = null; + throw new Error("Web Audio is unavailable"); + } + + let context: AudioContext; + try { + context = this.options.createContext + ? this.options.createContext() + : new (AudioContextCtor as typeof AudioContext)({ + latencyHint: "interactive", + }); + } catch (error) { + this.markFatalError(); + this.transport = null; + this.setState("error"); + throw error; + } + this.context = context; + context.onstatechange = () => this.syncContextState(); + // Web Audio 1.1 defines AudioContext's `error` event for audio-system + // resource failures. TypeScript's current DOM declarations do not yet + // include the event in AudioContextEventMap, so use the string overload. + context.addEventListener?.("error", this.onContextError); + + try { + await context.audioWorklet.addModule(String(this.options.workletUrl)); + const options: AudioWorkletNodeOptions = { + numberOfInputs: 0, + numberOfOutputs: 1, + outputChannelCount: [2], + processorOptions: { + ...transport, + layout: PCM_CONTROL, + formats: { + u8: PcmSampleFormat.U8, + s16le: PcmSampleFormat.S16Le, + s16be: PcmSampleFormat.S16Be, + }, + states: { + running: PcmStreamState.Running, + draining: PcmStreamState.Draining, + }, + flags: { + configuring: PcmTransportFlag.Configuring, + underrunActive: PcmTransportFlag.UnderrunActive, + fatalError: PcmTransportFlag.FatalError, + }, + outputSampleRate: context.sampleRate, + }, + }; + const node = this.options.createNode + ? this.options.createNode(context, "kandelo-pcm-output", options) + : new AudioWorkletNode(context, "kandelo-pcm-output", options); + node.port.onmessage = (event: MessageEvent) => { + const message = event.data as { type?: unknown; message?: unknown }; + if (message?.type !== "error") return; + this.fail(); + console.error( + `[BrowserPcmDriver] ${ + typeof message.message === "string" + ? message.message + : "AudioWorklet failed" + }`, + ); + }; + node.onprocessorerror = () => { + this.fail(); + }; + node.connect(context.destination); + this.node = node; + this.syncContextState(); + } catch (error) { + this.markFatalError(); + this.node = null; + this.context = null; + this.transport = null; + context.onstatechange = null; + context.removeEventListener?.("error", this.onContextError); + if (context.state !== "closed") await context.close().catch(() => {}); + this.setState("error"); + throw error; + } + } + + private syncContextState(): void { + if ( + this.state === "error" || + (this.transport && hasPcmFatalError(pcmControlWords(this.transport))) + ) { + this.setState("error"); + return; + } + const contextState = this.context?.state as string | undefined; + switch (contextState) { + case "running": + this.setState("running"); + break; + case "interrupted": + this.setState("interrupted"); + break; + case "closed": + this.fail(); + break; + default: + this.setState("suspended"); + break; + } + } + + private setState(state: PcmOutputState): void { + if (this.state === "error" && state !== "closed") return; + if (this.state === state) return; + this.state = state; + for (const listener of this.listeners) listener(state); + } + + private markFatalError(): void { + if (!this.transport) return; + markPcmFatalError(pcmControlWords(this.transport)); + } + + private fail(): void { + this.markFatalError(); + this.setState("error"); + } +} + +function sameTransport( + a: PcmTransportDescriptor, + b: PcmTransportDescriptor, +): boolean { + return ( + a.buffer === b.buffer && + a.controlOffset === b.controlOffset && + a.controlBytes === b.controlBytes && + a.dataOffset === b.dataOffset && + a.dataBytes === b.dataBytes + ); +} + +function outputPipelineSettleMs(context: AudioContext): number { + const extendedContext = context as AudioContext & { + outputLatency?: number; + renderQuantumSize?: number; + }; + const sampleRate = positiveFinite(context.sampleRate) ?? 48_000; + const renderFrames = + positiveFinite(extendedContext.renderQuantumSize) ?? + DEFAULT_RENDER_QUANTUM_FRAMES; + const quantumMs = (renderFrames * 1000) / sampleRate; + const baseLatencyMs = (nonnegativeFinite(context.baseLatency) ?? 0) * 1000; + const outputLatencySeconds = positiveFinite(extendedContext.outputLatency); + const outputLatencyMs = (outputLatencySeconds ?? 0) * 1000; + const unreportedFallbackMs = + outputLatencySeconds === undefined + ? UNREPORTED_OUTPUT_LATENCY_FALLBACK_MS + : 0; + return Math.ceil( + Math.min( + MAX_OUTPUT_PIPELINE_SETTLE_MS, + Math.max( + unreportedFallbackMs, + baseLatencyMs + outputLatencyMs + quantumMs, + ), + ), + ); +} + +function positiveFinite(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? value + : undefined; +} + +function nonnegativeFinite(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? value + : undefined; +} + +function delay(delayMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, delayMs)); +} + +function waitForPromiseWithin( + promise: Promise, + timeoutMs: number, +): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve(); + }; + const timeout = setTimeout(finish, timeoutMs); + void promise.then(finish, finish); + }); +} diff --git a/host/src/audio/node-pcm-driver.ts b/host/src/audio/node-pcm-driver.ts new file mode 100644 index 0000000000..bb947f7f3a --- /dev/null +++ b/host/src/audio/node-pcm-driver.ts @@ -0,0 +1,318 @@ +import type { PcmOutputDriver, PcmOutputState } from "./pcm-driver.js"; +import { + PcmSampleFormat, + PcmStreamState, + PcmTransportFlag, + hasPcmFatalError, + isPcmGenerationCurrent, + markPcmFatalError, + pcmControlWords, + pcmDataBytes, + readEffectiveConsumerPosition, + readPcmConfig, + readProducerPosition, + readRingBytes, + validatePcmTransport, + type PcmTransportConfig, + type PcmTransportDescriptor, +} from "./pcm-transport.js"; + +export interface PcmDriverClock { + now(): number; + setTimeout(callback: () => void, delayMs: number): unknown; + clearTimeout(handle: unknown): void; +} + +export interface NodePcmDriverOptions { + clockUpdate(requestedFrames: number): number; + /** Wake/retry guest syscalls after a fatal sink transition. */ + onFatal?: () => void; + clock?: PcmDriverClock; + onConsume?: (event: { + bytes: Uint8Array; + frames: number; + requestedFrames: number; + config: PcmTransportConfig; + }) => void; + idlePollMs?: number; +} + +const defaultClock: PcmDriverClock = { + now: () => performance.now(), + setTimeout: (callback, delayMs) => { + const handle = setTimeout(callback, delayMs); + if (typeof handle === "object" && "unref" in handle) handle.unref(); + return handle; + }, + clearTimeout: (handle) => + clearTimeout(handle as ReturnType), +}; + +/** Wall-clock-paced null sink for Node and headless hosts. */ +export class NodePcmDriver implements PcmOutputDriver { + private readonly clock: PcmDriverClock; + private readonly listeners = new Set<(state: PcmOutputState) => void>(); + private transport: PcmTransportDescriptor | null = null; + private timer: unknown = null; + private state: PcmOutputState = "unprepared"; + private enabled = true; + private generation = -1; + private fatalNotified = false; + private lastNow = 0; + private frameRemainder = 0; + + constructor(private readonly options: NodePcmDriverOptions) { + this.clock = options.clock ?? defaultClock; + } + + async prepare(transport: PcmTransportDescriptor): Promise { + if (this.state === "closed") throw new Error("PCM driver is closed"); + validatePcmTransport(transport); + if (this.transport) { + if (!sameTransport(this.transport, transport)) { + throw new Error("PCM driver is already attached to another transport"); + } + return; + } + this.transport = transport; + this.lastNow = this.clock.now(); + this.setState("running"); + this.schedule(0); + } + + async resume(): Promise { + if (this.state === "closed") throw new Error("PCM driver is closed"); + if (this.state === "error") throw new Error("PCM output has failed"); + if (this.transport && hasPcmFatalError(pcmControlWords(this.transport))) { + this.fail(); + throw new Error("PCM output has failed"); + } + this.enabled = true; + this.lastNow = this.clock.now(); + this.frameRemainder = 0; + if (this.transport) { + this.setState("running"); + this.schedule(0); + } + } + + async suspend(): Promise { + if (this.transport && hasPcmFatalError(pcmControlWords(this.transport))) { + this.fail(); + return; + } + this.enabled = false; + this.cancelTimer(); + if (this.state !== "closed" && this.state !== "error") { + this.setState("suspended"); + } + } + + async close(): Promise { + if (this.state === "closed") return; + this.enabled = false; + this.cancelTimer(); + this.transport = null; + this.setState("closed"); + this.listeners.clear(); + } + + getState(): PcmOutputState { + return this.state; + } + + subscribe(listener: (state: PcmOutputState) => void): () => void { + this.listeners.add(listener); + listener(this.state); + return () => this.listeners.delete(listener); + } + + /** Visible for deterministic tests and worker-owned scheduling. */ + tick(): void { + this.timer = null; + const transport = this.transport; + if (!transport || !this.enabled || this.state === "closed") return; + + const words = pcmControlWords(transport); + const config = readPcmConfig(words); + const now = this.clock.now(); + if ((config.flags & PcmTransportFlag.FatalError) !== 0) { + this.fail(); + return; + } + const sampleBytes = bytesPerSample(config.format); + if ( + config.sampleRate === 0 || + (config.channels !== 1 && config.channels !== 2) || + sampleBytes === 0 || + config.frameBytes !== sampleBytes * config.channels || + config.activeCapacityBytes < config.frameBytes || + config.activeCapacityBytes > transport.dataBytes || + config.activeCapacityBytes % config.frameBytes !== 0 + ) { + this.fail(true); + return; + } + if (config.generation !== this.generation) { + this.generation = config.generation; + this.frameRemainder = 0; + this.lastNow = now; + } + + if ( + (config.state !== PcmStreamState.Running && + config.state !== PcmStreamState.Draining) || + config.sampleRate === 0 || + config.frameBytes === 0 + ) { + this.lastNow = now; + this.frameRemainder = 0; + this.schedule(this.options.idlePollMs ?? 10); + return; + } + + const elapsedMs = Math.max(0, now - this.lastNow); + this.lastNow = now; + const due = this.frameRemainder + (elapsedMs * config.sampleRate) / 1000; + const requestedFrames = Math.floor(due); + this.frameRemainder = due - requestedFrames; + + if (requestedFrames > 0) { + const consumer = readEffectiveConsumerPosition(words); + const producer = readProducerPosition(words); + const queuedFrames = Number( + (producer > consumer ? producer - consumer : 0n) / + BigInt(config.frameBytes), + ); + const candidateFrames = Math.min(requestedFrames, queuedFrames); + const candidateBytes = + this.options.onConsume && candidateFrames > 0 + ? readRingBytes( + pcmDataBytes(transport).subarray( + 0, + Math.min( + transport.dataBytes, + Math.max(config.frameBytes, config.activeCapacityBytes), + ), + ), + consumer, + candidateFrames * config.frameBytes, + ) + : new Uint8Array(0); + if (!isPcmGenerationCurrent(words, config.generation)) { + this.lastNow = now; + this.frameRemainder = 0; + this.schedule(0); + return; + } + let consumedFrames: number; + try { + consumedFrames = Math.max( + 0, + Math.min(candidateFrames, this.options.clockUpdate(requestedFrames)), + ); + } catch { + this.fail(true); + return; + } + const completedDrain = + config.state === PcmStreamState.Draining && + consumedFrames > 0 && + consumedFrames === queuedFrames; + if ( + !isPcmGenerationCurrent(words, config.generation) && + !completedDrain + ) { + this.lastNow = now; + this.frameRemainder = 0; + this.schedule(0); + return; + } + if (consumedFrames > 0 && this.options.onConsume) { + try { + this.options.onConsume({ + bytes: candidateBytes.subarray( + 0, + consumedFrames * config.frameBytes, + ), + frames: consumedFrames, + requestedFrames, + config, + }); + } catch { + this.fail(true); + return; + } + } + } + + const periodFrames = + config.fragmentBytes > 0 + ? Math.max(1, Math.floor(config.fragmentBytes / config.frameBytes)) + : 128; + const periodMs = (periodFrames * 1000) / config.sampleRate; + const untilNextFrame = + ((1 - this.frameRemainder) * 1000) / config.sampleRate; + this.schedule(Math.max(1, Math.min(periodMs, untilNextFrame + periodMs))); + } + + private schedule(delayMs: number): void { + if (this.timer !== null || !this.enabled || !this.transport) return; + this.timer = this.clock.setTimeout(() => this.tick(), delayMs); + } + + private cancelTimer(): void { + if (this.timer === null) return; + this.clock.clearTimeout(this.timer); + this.timer = null; + } + + private fail(markFatal = false): void { + if (markFatal && this.transport) { + markPcmFatalError(pcmControlWords(this.transport)); + } + if (!this.fatalNotified) { + this.fatalNotified = true; + try { + this.options.onFatal?.(); + } catch (error) { + console.error("[NodePcmDriver] fatal wake callback failed", error); + } + } + this.enabled = false; + this.cancelTimer(); + this.setState("error"); + } + + private setState(state: PcmOutputState): void { + if (this.state === "error" && state !== "closed") return; + if (this.state === state) return; + this.state = state; + for (const listener of this.listeners) listener(state); + } +} + +function sameTransport( + a: PcmTransportDescriptor, + b: PcmTransportDescriptor, +): boolean { + return ( + a.buffer === b.buffer && + a.controlOffset === b.controlOffset && + a.controlBytes === b.controlBytes && + a.dataOffset === b.dataOffset && + a.dataBytes === b.dataBytes + ); +} + +function bytesPerSample(format: PcmSampleFormat): number { + switch (format) { + case PcmSampleFormat.U8: + return 1; + case PcmSampleFormat.S16Le: + case PcmSampleFormat.S16Be: + return 2; + default: + return 0; + } +} diff --git a/host/src/audio/pcm-audio-worklet.js b/host/src/audio/pcm-audio-worklet.js new file mode 100644 index 0000000000..c207df57aa --- /dev/null +++ b/host/src/audio/pcm-audio-worklet.js @@ -0,0 +1,263 @@ +/* + * Kandelo PCM AudioWorklet. The processor is deliberately self-contained so + * it can be emitted as a real browser asset and tested directly in Vitest. + * All layout offsets and enum values arrive in processorOptions; the worklet + * never imports OSS, WebAssembly, or kernel implementation details. + */ + +const WorkletBase = + globalThis.AudioWorkletProcessor ?? + class { + constructor() { + this.port = { onmessage: null, postMessage() {} }; + } + }; + +function loadU32(words, index) { + return Atomics.load(words, index) >>> 0; +} + +function readU64(words, seqIndex, loIndex, hiIndex) { + for (;;) { + const before = loadU32(words, seqIndex); + if (before & 1) continue; + const lo = loadU32(words, loIndex); + const hi = loadU32(words, hiIndex); + const after = loadU32(words, seqIndex); + if (before === after && !(after & 1)) { + return (BigInt(hi) << 32n) | BigInt(lo); + } + } +} + +function writeU64(words, seqIndex, loIndex, hiIndex, value) { + Atomics.add(words, seqIndex, 1); + Atomics.store(words, loIndex, Number(value & 0xffff_ffffn) | 0); + Atomics.store(words, hiIndex, Number((value >> 32n) & 0xffff_ffffn) | 0); + Atomics.add(words, seqIndex, 1); +} + +export class KandeloPcmProcessor extends WorkletBase { + constructor(options = {}) { + super(options); + const config = options.processorOptions ?? options; + this.layout = config.layout; + this.formats = config.formats; + this.states = config.states; + this.flags = config.flags; + this.outputRate = + config.outputSampleRate ?? globalThis.sampleRate ?? 48_000; + this.words = new Int32Array( + config.buffer, + config.controlOffset, + config.controlBytes / 4, + ); + this.ring = new Uint8Array( + config.buffer, + config.dataOffset, + config.dataBytes, + ); + this.generation = -1; + this.sourcePhase = 0; + this.lastError = ""; + } + + process(_inputs, outputs) { + const output = outputs[0] ?? []; + const outputFrames = output[0]?.length ?? 0; + for (const channel of output) channel.fill(0); + if (outputFrames === 0) return true; + + const l = this.layout; + const transportFlags = loadU32(this.words, l.flags); + if ( + (transportFlags & (this.flags.configuring | this.flags.fatalError)) !== + 0 + ) { + return true; + } + const generation = loadU32(this.words, l.generation); + if (generation !== this.generation) { + this.generation = generation; + this.sourcePhase = 0; + } + + const state = loadU32(this.words, l.state); + if (state !== this.states.running && state !== this.states.draining) { + this.clearUnderrun(); + return true; + } + + const format = loadU32(this.words, l.format); + const sourceRate = loadU32(this.words, l.sampleRate); + const channels = loadU32(this.words, l.channels); + const frameBytes = loadU32(this.words, l.frameBytes); + const activeCapacityBytes = loadU32(this.words, l.activeCapacityBytes); + const bytesPerSample = this.bytesPerSample(format); + const configGeneration = loadU32(this.words, l.generation); + const configFlags = loadU32(this.words, l.flags); + const confirmedConfigGeneration = loadU32(this.words, l.generation); + if ( + configGeneration !== generation || + confirmedConfigGeneration !== generation || + (configFlags & (this.flags.configuring | this.flags.fatalError)) !== 0 + ) { + this.generation = confirmedConfigGeneration; + this.sourcePhase = 0; + return true; + } + if ( + sourceRate === 0 || + (channels !== 1 && channels !== 2) || + bytesPerSample === 0 || + frameBytes !== bytesPerSample * channels || + activeCapacityBytes < frameBytes || + activeCapacityBytes > this.ring.byteLength || + activeCapacityBytes % frameBytes !== 0 + ) { + this.reportError("unsupported PCM configuration"); + return true; + } + + const producer = readU64( + this.words, + l.producerSeq, + l.producerLo, + l.producerHi, + ); + let consumer = readU64( + this.words, + l.consumerSeq, + l.consumerLo, + l.consumerHi, + ); + const discard = readU64(this.words, l.discardSeq, l.discardLo, l.discardHi); + if (discard > consumer) consumer = discard > producer ? producer : discard; + const initialConsumer = consumer; + this.activeCapacityBytes = activeCapacityBytes; + const step = sourceRate / this.outputRate; + let underrun = false; + let renderedPcm = false; + + for (let outFrame = 0; outFrame < outputFrames; outFrame++) { + const queuedBytes = producer > consumer ? producer - consumer : 0n; + const queuedFrames = Number(queuedBytes / BigInt(frameBytes)); + const sourceIndex = Math.floor(this.sourcePhase); + if (sourceIndex >= queuedFrames) { + underrun = true; + this.sourcePhase = 0; + break; + } + + renderedPcm = true; + const nextIndex = Math.min(sourceIndex + 1, queuedFrames - 1); + const fraction = this.sourcePhase - sourceIndex; + for (let outChannel = 0; outChannel < output.length; outChannel++) { + const sourceChannel = + channels === 1 ? 0 : Math.min(outChannel, channels - 1); + const a = this.readSample( + consumer + + BigInt(sourceIndex * frameBytes + sourceChannel * bytesPerSample), + format, + ); + const b = this.readSample( + consumer + + BigInt(nextIndex * frameBytes + sourceChannel * bytesPerSample), + format, + ); + output[outChannel][outFrame] = a + (b - a) * fraction; + } + + this.sourcePhase += step; + const advance = Math.min(Math.floor(this.sourcePhase), queuedFrames); + if (advance > 0) { + consumer += BigInt(advance * frameBytes); + this.sourcePhase -= advance; + } + } + + // RESET and reopen run in the kernel worker concurrently with this render + // quantum. Never publish an old generation's cursor or samples into the + // new stream. Transport cursors remain monotonic across generations, which + // also makes a generation change in the tiny interval after this check + // harmless to the new stream's queued bytes. + const finalGeneration = loadU32(this.words, l.generation); + const finalFlags = loadU32(this.words, l.flags); + const confirmedGeneration = loadU32(this.words, l.generation); + if ( + finalGeneration !== generation || + confirmedGeneration !== generation || + (finalFlags & (this.flags.configuring | this.flags.fatalError)) !== 0 + ) { + for (const channel of output) channel.fill(0); + this.generation = confirmedGeneration; + this.sourcePhase = 0; + return true; + } + + const finalState = loadU32(this.words, l.state); + if (renderedPcm || finalState !== this.states.running) { + this.clearUnderrun(); + } + if (underrun && finalState === this.states.running) this.noteUnderrun(); + if (consumer !== initialConsumer) { + writeU64(this.words, l.consumerSeq, l.consumerLo, l.consumerHi, consumer); + this.signalProgress(); + } + return true; + } + + bytesPerSample(format) { + if (format === this.formats.u8) return 1; + if (format === this.formats.s16le || format === this.formats.s16be) + return 2; + return 0; + } + + readSample(absoluteByte, format) { + const capacity = this.activeCapacityBytes; + const at = Number(absoluteByte % BigInt(capacity)); + if (format === this.formats.u8) return (this.ring[at] - 128) / 128; + const next = (at + 1) % capacity; + const lo = format === this.formats.s16le ? this.ring[at] : this.ring[next]; + const hi = format === this.formats.s16le ? this.ring[next] : this.ring[at]; + let sample = lo | (hi << 8); + if (sample & 0x8000) sample -= 0x1_0000; + return sample / 32768; + } + + noteUnderrun() { + const previous = Atomics.or( + this.words, + this.layout.flags, + this.flags.underrunActive, + ); + if ((previous & this.flags.underrunActive) !== 0) return; + Atomics.add(this.words, this.layout.underruns, 1); + this.signalProgress(); + } + + clearUnderrun() { + Atomics.and(this.words, this.layout.flags, ~this.flags.underrunActive); + } + + signalProgress() { + Atomics.add(this.words, this.layout.wakeSeq, 1); + // A kernel observer and teardown drain can wait concurrently. Final-tail + // consumption is a one-shot transition, so every waiter must see it. + Atomics.notify(this.words, this.layout.wakeSeq); + } + + reportError(message) { + if (message === this.lastError) return; + this.lastError = message; + this.clearUnderrun(); + Atomics.or(this.words, this.layout.flags, this.flags.fatalError); + this.signalProgress(); + this.port.postMessage({ type: "error", message }); + } +} + +if (typeof globalThis.registerProcessor === "function") { + globalThis.registerProcessor("kandelo-pcm-output", KandeloPcmProcessor); +} diff --git a/host/src/audio/pcm-driver.ts b/host/src/audio/pcm-driver.ts new file mode 100644 index 0000000000..eedefbcf2b --- /dev/null +++ b/host/src/audio/pcm-driver.ts @@ -0,0 +1,19 @@ +import type { PcmTransportDescriptor } from "./pcm-transport.js"; + +export type PcmOutputState = + | "unavailable" + | "unprepared" + | "suspended" + | "running" + | "interrupted" + | "closed" + | "error"; + +export interface PcmOutputDriver { + prepare(transport: PcmTransportDescriptor): Promise; + resume(): Promise; + suspend(): Promise; + close(): Promise; + getState(): PcmOutputState; + subscribe(listener: (state: PcmOutputState) => void): () => void; +} diff --git a/host/src/audio/pcm-transport.ts b/host/src/audio/pcm-transport.ts new file mode 100644 index 0000000000..bf9914ecce --- /dev/null +++ b/host/src/audio/pcm-transport.ts @@ -0,0 +1,370 @@ +import { + PCM_FORMAT_S16_BE, + PCM_FORMAT_S16_LE, + PCM_FORMAT_U8, + PCM_FORMAT_UNKNOWN, + PCM_FLAG_CONFIGURING, + PCM_FLAG_FATAL_ERROR, + PCM_FLAG_UNDERRUN_ACTIVE, + PCM_SHARED_CONTROL_FIELDS, + PCM_STATE_CLOSED, + PCM_STATE_DRAINING, + PCM_STATE_RUNNING, + PCM_STATE_STOPPED, + PCM_TRANSPORT_HEADER_BYTES, + PCM_TRANSPORT_LEGACY_PULL, + PCM_TRANSPORT_MAGIC, + PCM_TRANSPORT_RING_BYTES, + PCM_TRANSPORT_SHARED_CLOCK, + PCM_TRANSPORT_UNCLAIMED, + PCM_TRANSPORT_VERSION, +} from "../generated/abi.js"; + +/** + * Shared kernel/host PCM transport. + * + * The control header lives in the kernel's shared WebAssembly memory. Rust is + * the sole writer for stream configuration and the producer cursor; the host + * sink is the sole writer for the consumer cursor. Cursor halves are guarded + * by 32-bit sequence counters so AudioWorklet code never depends on 64-bit JS + * atomics. + */ + +export const PCM_CONTROL_MAGIC = PCM_TRANSPORT_MAGIC; +export const PCM_CONTROL_VERSION = PCM_TRANSPORT_VERSION; +export const PCM_CONTROL_BYTES = PCM_TRANSPORT_HEADER_BYTES; +export const PCM_PHYSICAL_CAPACITY_BYTES = PCM_TRANSPORT_RING_BYTES; + +/** Word indices within the fixed-width control header. */ +export const PCM_CONTROL = { + magic: PCM_SHARED_CONTROL_FIELDS.magic.offset / 4, + version: PCM_SHARED_CONTROL_FIELDS.version.offset / 4, + headerBytes: PCM_SHARED_CONTROL_FIELDS.headerBytes.offset / 4, + physicalCapacityBytes: + PCM_SHARED_CONTROL_FIELDS.physicalCapacityBytes.offset / 4, + activeCapacityBytes: PCM_SHARED_CONTROL_FIELDS.activeCapacityBytes.offset / 4, + format: PCM_SHARED_CONTROL_FIELDS.format.offset / 4, + sampleRate: PCM_SHARED_CONTROL_FIELDS.sampleRate.offset / 4, + channels: PCM_SHARED_CONTROL_FIELDS.channels.offset / 4, + frameBytes: PCM_SHARED_CONTROL_FIELDS.frameBytes.offset / 4, + fragmentBytes: PCM_SHARED_CONTROL_FIELDS.fragmentBytes.offset / 4, + fragments: PCM_SHARED_CONTROL_FIELDS.fragments.offset / 4, + state: PCM_SHARED_CONTROL_FIELDS.state.offset / 4, + generation: PCM_SHARED_CONTROL_FIELDS.generation.offset / 4, + flags: PCM_SHARED_CONTROL_FIELDS.flags.offset / 4, + transportMode: PCM_SHARED_CONTROL_FIELDS.transportMode.offset / 4, + producerSeq: PCM_SHARED_CONTROL_FIELDS.producerSeq.offset / 4, + producerLo: PCM_SHARED_CONTROL_FIELDS.producerLo.offset / 4, + producerHi: PCM_SHARED_CONTROL_FIELDS.producerHi.offset / 4, + consumerSeq: PCM_SHARED_CONTROL_FIELDS.consumerSeq.offset / 4, + consumerLo: PCM_SHARED_CONTROL_FIELDS.consumerLo.offset / 4, + consumerHi: PCM_SHARED_CONTROL_FIELDS.consumerHi.offset / 4, + discardSeq: PCM_SHARED_CONTROL_FIELDS.discardSeq.offset / 4, + discardLo: PCM_SHARED_CONTROL_FIELDS.discardLo.offset / 4, + discardHi: PCM_SHARED_CONTROL_FIELDS.discardHi.offset / 4, + underruns: PCM_SHARED_CONTROL_FIELDS.underruns.offset / 4, + wakeSeq: PCM_SHARED_CONTROL_FIELDS.wakeSeq.offset / 4, +} as const; + +/** Implementation-neutral formats presented to host sinks. */ +export const PcmSampleFormat = { + Unknown: PCM_FORMAT_UNKNOWN, + U8: PCM_FORMAT_U8, + S16Le: PCM_FORMAT_S16_LE, + S16Be: PCM_FORMAT_S16_BE, +} as const; +export type PcmSampleFormat = + (typeof PcmSampleFormat)[keyof typeof PcmSampleFormat]; + +export const PcmStreamState = { + Closed: PCM_STATE_CLOSED, + Stopped: PCM_STATE_STOPPED, + Running: PCM_STATE_RUNNING, + Draining: PCM_STATE_DRAINING, +} as const; +export type PcmStreamState = + (typeof PcmStreamState)[keyof typeof PcmStreamState]; + +export const PcmTransportMode = { + Unclaimed: PCM_TRANSPORT_UNCLAIMED, + LegacyPull: PCM_TRANSPORT_LEGACY_PULL, + SharedClock: PCM_TRANSPORT_SHARED_CLOCK, +} as const; +export type PcmTransportMode = + (typeof PcmTransportMode)[keyof typeof PcmTransportMode]; + +export const PcmTransportFlag = { + Configuring: PCM_FLAG_CONFIGURING, + UnderrunActive: PCM_FLAG_UNDERRUN_ACTIVE, + FatalError: PCM_FLAG_FATAL_ERROR, +} as const; + +export interface PcmTransportDescriptor { + buffer: SharedArrayBuffer; + controlOffset: number; + controlBytes: number; + dataOffset: number; + dataBytes: number; +} + +export interface PcmTransportConfig { + activeCapacityBytes: number; + format: PcmSampleFormat; + sampleRate: number; + channels: number; + frameBytes: number; + fragmentBytes: number; + fragments: number; + state: PcmStreamState; + generation: number; + flags: number; +} + +export function pcmControlWords( + descriptor: PcmTransportDescriptor, +): Int32Array { + validateDescriptorBounds(descriptor); + return new Int32Array( + descriptor.buffer, + descriptor.controlOffset, + descriptor.controlBytes / Int32Array.BYTES_PER_ELEMENT, + ); +} + +export function pcmDataBytes( + descriptor: PcmTransportDescriptor, +): Uint8Array { + validateDescriptorBounds(descriptor); + return new Uint8Array( + descriptor.buffer, + descriptor.dataOffset, + descriptor.dataBytes, + ); +} + +export function validatePcmTransport(descriptor: PcmTransportDescriptor): void { + const words = pcmControlWords(descriptor); + const magic = loadU32(words, PCM_CONTROL.magic); + const version = loadU32(words, PCM_CONTROL.version); + const headerBytes = loadU32(words, PCM_CONTROL.headerBytes); + const capacityBytes = loadU32(words, PCM_CONTROL.physicalCapacityBytes); + if (magic !== PCM_CONTROL_MAGIC) { + throw new Error(`PCM transport has invalid magic 0x${magic.toString(16)}`); + } + if (version !== PCM_CONTROL_VERSION) { + throw new Error( + `PCM transport version ${version} is not supported (expected ${PCM_CONTROL_VERSION})`, + ); + } + if ( + headerBytes !== PCM_CONTROL_BYTES || + headerBytes > descriptor.controlBytes + ) { + throw new Error( + `PCM transport header is ${headerBytes} bytes (expected ${PCM_CONTROL_BYTES})`, + ); + } + if (capacityBytes !== descriptor.dataBytes) { + throw new Error( + `PCM transport capacity ${capacityBytes} does not match descriptor ${descriptor.dataBytes}`, + ); + } +} + +export function readPcmConfig(words: Int32Array): PcmTransportConfig { + for (;;) { + const flagsBefore = loadU32(words, PCM_CONTROL.flags); + if ((flagsBefore & PcmTransportFlag.Configuring) !== 0) continue; + const generation = loadU32(words, PCM_CONTROL.generation); + const config = { + activeCapacityBytes: loadU32(words, PCM_CONTROL.activeCapacityBytes), + format: loadU32(words, PCM_CONTROL.format) as PcmSampleFormat, + sampleRate: loadU32(words, PCM_CONTROL.sampleRate), + channels: loadU32(words, PCM_CONTROL.channels), + frameBytes: loadU32(words, PCM_CONTROL.frameBytes), + fragmentBytes: loadU32(words, PCM_CONTROL.fragmentBytes), + fragments: loadU32(words, PCM_CONTROL.fragments), + state: loadU32(words, PCM_CONTROL.state) as PcmStreamState, + generation, + flags: flagsBefore, + }; + const generationAfter = loadU32(words, PCM_CONTROL.generation); + const flagsAfter = loadU32(words, PCM_CONTROL.flags); + if ( + generationAfter === generation && + (flagsAfter & PcmTransportFlag.Configuring) === 0 + ) { + config.flags = flagsAfter; + return config; + } + } +} + +export function isPcmGenerationCurrent( + words: Int32Array, + generation: number, +): boolean { + const before = loadU32(words, PCM_CONTROL.generation); + const flags = loadU32(words, PCM_CONTROL.flags); + const after = loadU32(words, PCM_CONTROL.generation); + return ( + before === generation && + after === generation && + (flags & PcmTransportFlag.Configuring) === 0 + ); +} + +export function markPcmFatalError(words: Int32Array): void { + Atomics.and(words, PCM_CONTROL.flags, ~PcmTransportFlag.UnderrunActive); + Atomics.or(words, PCM_CONTROL.flags, PcmTransportFlag.FatalError); + signalPcmConsumerProgress(words); +} + +export function hasPcmFatalError(words: Int32Array): boolean { + return ( + (loadU32(words, PCM_CONTROL.flags) & PcmTransportFlag.FatalError) !== 0 + ); +} + +export function readProducerPosition(words: Int32Array): bigint { + return readSeqlockedU64( + words, + PCM_CONTROL.producerSeq, + PCM_CONTROL.producerLo, + PCM_CONTROL.producerHi, + ); +} + +export function readConsumerPosition(words: Int32Array): bigint { + return readSeqlockedU64( + words, + PCM_CONTROL.consumerSeq, + PCM_CONTROL.consumerLo, + PCM_CONTROL.consumerHi, + ); +} + +export function readDiscardPosition(words: Int32Array): bigint { + return readSeqlockedU64( + words, + PCM_CONTROL.discardSeq, + PCM_CONTROL.discardLo, + PCM_CONTROL.discardHi, + ); +} + +export function readEffectiveConsumerPosition(words: Int32Array): bigint { + const consumer = readConsumerPosition(words); + const discard = readDiscardPosition(words); + const producer = readProducerPosition(words); + const effective = consumer > discard ? consumer : discard; + return effective > producer ? producer : effective; +} + +export function writeConsumerPosition(words: Int32Array, value: bigint): void { + writeSeqlockedU64( + words, + PCM_CONTROL.consumerSeq, + PCM_CONTROL.consumerLo, + PCM_CONTROL.consumerHi, + value, + ); +} + +export function signalPcmConsumerProgress(words: Int32Array): void { + Atomics.add(words, PCM_CONTROL.wakeSeq, 1); + // The persistent kernel observer and a bounded teardown drain may both be + // waiting on the same one-shot cursor transition. Wake every waiter: the + // sequence check below each wait decides which work remains relevant. + Atomics.notify(words, PCM_CONTROL.wakeSeq); +} + +export function readRingBytes( + ring: Uint8Array, + absoluteOffset: bigint, + length: number, +): Uint8Array { + const out = new Uint8Array(length); + if (length === 0 || ring.byteLength === 0) return out; + let offset = Number(absoluteOffset % BigInt(ring.byteLength)); + let copied = 0; + while (copied < length) { + const chunk = Math.min(length - copied, ring.byteLength - offset); + out.set(ring.subarray(offset, offset + chunk), copied); + copied += chunk; + offset = 0; + } + return out; +} + +export function loadU32(words: Int32Array, index: number): number { + return Atomics.load(words, index) >>> 0; +} + +export function storeU32( + words: Int32Array, + index: number, + value: number, +): void { + Atomics.store(words, index, value | 0); +} + +export function readSeqlockedU64( + words: Int32Array, + seqIndex: number, + loIndex: number, + hiIndex: number, +): bigint { + for (;;) { + const before = loadU32(words, seqIndex); + if ((before & 1) !== 0) continue; + const lo = loadU32(words, loIndex); + const hi = loadU32(words, hiIndex); + const after = loadU32(words, seqIndex); + if (before === after && (after & 1) === 0) { + return (BigInt(hi) << 32n) | BigInt(lo); + } + } +} + +export function writeSeqlockedU64( + words: Int32Array, + seqIndex: number, + loIndex: number, + hiIndex: number, + value: bigint, +): void { + Atomics.add(words, seqIndex, 1); + storeU32(words, loIndex, Number(value & 0xffff_ffffn)); + storeU32(words, hiIndex, Number((value >> 32n) & 0xffff_ffffn)); + Atomics.add(words, seqIndex, 1); +} + +function validateDescriptorBounds(descriptor: PcmTransportDescriptor): void { + if (!(descriptor.buffer instanceof SharedArrayBuffer)) { + throw new TypeError("PCM transport requires SharedArrayBuffer"); + } + for (const [name, value] of Object.entries({ + controlOffset: descriptor.controlOffset, + controlBytes: descriptor.controlBytes, + dataOffset: descriptor.dataOffset, + dataBytes: descriptor.dataBytes, + })) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError(`PCM transport ${name} is invalid: ${value}`); + } + } + if ( + (descriptor.controlOffset & 3) !== 0 || + (descriptor.controlBytes & 3) !== 0 + ) { + throw new RangeError("PCM control header must be 32-bit aligned"); + } + if ( + descriptor.controlOffset + descriptor.controlBytes > + descriptor.buffer.byteLength || + descriptor.dataOffset + descriptor.dataBytes > descriptor.buffer.byteLength + ) { + throw new RangeError("PCM transport lies outside shared kernel memory"); + } +} diff --git a/host/src/browser-kernel-host.ts b/host/src/browser-kernel-host.ts index e03addd774..c5b23f98fa 100644 --- a/host/src/browser-kernel-host.ts +++ b/host/src/browser-kernel-host.ts @@ -35,10 +35,15 @@ import { } from "./vfs/closed-lazy-assets"; import { awaitGracefulKernelRealmDestroy } from "./kernel-realm-destroy"; import { FILE_MODES } from "./generated/abi"; +import { BrowserPcmDriver } from "./audio/browser-pcm-driver"; +import type { PcmOutputState } from "./audio/pcm-driver"; +import type { PcmTransportDescriptor } from "./audio/pcm-transport"; const DESTROY_REQUEST_TIMEOUT_MS = 2_000; -const MAX_PENDING_PTY_OUTPUT_BYTES = 64 * 1024; -const MAX_PENDING_PTY_OUTPUT_CHUNKS = 4_096; +const defaultPcmWorkletUrl = new URL( + "./audio/pcm-audio-worklet.js", + import.meta.url, +); export interface BrowserKernelOptions { /** Maximum concurrent workers (default: 4) */ @@ -111,6 +116,8 @@ export interface BrowserKernelOptions { * use this to route guest HTTP(S) and external lazy VFS downloads through * a CORS-capable proxy. Same-origin lazy assets remain direct. */ corsProxyUrl?: string; + /** Override the packaged PCM AudioWorklet asset URL. */ + audioWorkletUrl?: string | URL; } /** Options for {@link BrowserKernel.boot}. */ @@ -239,6 +246,8 @@ export class BrowserKernel { private pendingPtyOutputChunks = 0; private pendingPtyOutputFailure: Error | undefined; private lazyDownloadListeners = new Set<(event: LazyDownloadEvent) => void>(); + private pcmTransport: PcmTransportDescriptor | null = null; + private pcmDriver: BrowserPcmDriver | null = null; constructor(options: BrowserKernelOptions = {}) { this.maxPages = options.maxMemoryPages ?? DEFAULT_MAX_PAGES; @@ -1020,10 +1029,9 @@ export class BrowserKernel { * rate / channel count so the caller can build a correctly-sized * `AudioBuffer`. Empty `Uint8Array` if the ring is empty. * - * The audio scheduler in `apps/browser-demos/pages/doom/main.ts` calls - * this every ~50 ms via setInterval, decodes S16 → Float32, and - * schedules the result on a chained `AudioBufferSourceNode` so DOOM - * SFX play continuously while the game is running. + * @deprecated BrowserKernel now claims the shared-clock PCM transport for + * its machine-level AudioWorklet. This compatibility method returns an + * empty buffer while that transport is active. */ async drainAudio(maxBytes: number): Promise<{ bytes: Uint8Array; @@ -1038,6 +1046,45 @@ export class BrowserKernel { }) as Promise<{ bytes: Uint8Array; sampleRate: number; channels: number }>; } + /** Preload the machine-level PCM sink without attempting user activation. */ + async prepareAudio(): Promise { + const transport = this.pcmTransport; + if (!transport) throw new Error("PCM transport is not available"); + const driver = this.pcmDriver ??= new BrowserPcmDriver({ + workletUrl: this.options.audioWorkletUrl ?? defaultPcmWorkletUrl, + }); + await driver.prepare(transport); + } + + /** Resume audible PCM output. Call directly from a trusted user gesture. */ + async resumeAudio(): Promise { + await this.prepareAudio(); + await this.pcmDriver!.resume(); + } + + /** Suspend the browser audio clock without discarding queued PCM. */ + async suspendAudio(): Promise { + await this.pcmDriver?.suspend(); + } + + getAudioState(): PcmOutputState { + return this.pcmDriver?.getState() ?? + (this.pcmTransport ? "unprepared" : "unavailable"); + } + + onAudioStateChange(listener: (state: PcmOutputState) => void): () => void { + if (!this.pcmDriver && this.pcmTransport) { + this.pcmDriver = new BrowserPcmDriver({ + workletUrl: this.options.audioWorkletUrl ?? defaultPcmWorkletUrl, + }); + } + if (!this.pcmDriver) { + listener("unavailable"); + return () => {}; + } + return this.pcmDriver.subscribe(listener); + } + // ── PTY methods ── /** Write data to the PTY master for a process. */ @@ -1203,6 +1250,10 @@ export class BrowserKernel { ); } this.initialized = false; + await this.pcmDriver?.settleOutputPipeline().catch(() => {}); + await this.pcmDriver?.close().catch(() => {}); + this.pcmDriver = null; + this.pcmTransport = null; // WHY: process/pthread Workers are owned beneath the kernel worker. After // the worker's bounded graceful attempt, terminating this outer realm is // the final release fence for aliases that could not be detached exactly. @@ -1343,11 +1394,27 @@ export class BrowserKernel { private handleWorkerMessage(msg: KernelToMainMessage): void { switch (msg.type) { - case "ready": + case "ready": { + if (msg.pcmTransport) { + this.pcmTransport = msg.pcmTransport; + if ( + typeof globalThis.AudioContext === "function" || + "webkitAudioContext" in globalThis + ) { + void this.prepareAudio().catch((error) => { + this.options.onHostDiagnostic?.({ + pid: 0, + source: "browser PCM output", + message: error instanceof Error ? error.message : String(error), + }); + }); + } + } + break; + } case "init_error": // The temporary boot listener resolves or rejects initialization. The - // permanent listener also receives these messages, so account for - // them explicitly rather than relying on an implicit fall-through. + // permanent listener also receives this message, so account for it. break; case "kernel_fatal": { const error = new Error(`Kernel worker failed: ${msg.error}`); diff --git a/host/src/browser-kernel-protocol.ts b/host/src/browser-kernel-protocol.ts index 6257171a52..fb7c0bddf8 100644 --- a/host/src/browser-kernel-protocol.ts +++ b/host/src/browser-kernel-protocol.ts @@ -16,7 +16,7 @@ import type { HostDiagnosticMessage, } from "./host-diagnostic"; import type { ClosedLazyAsset } from "./vfs/closed-lazy-assets"; -import type { MountSpec } from "./vfs/default-mounts"; +import type { PcmTransportDescriptor } from "./audio/pcm-transport"; export type { HttpRequest, HttpResponse }; export type { HostDiagnostic } from "./host-diagnostic"; @@ -428,6 +428,8 @@ export type MainToKernelMessage = export interface ReadyMessage { type: "ready"; + /** Versioned PCM-only shared transport claimed by the kernel worker. */ + pcmTransport?: PcmTransportDescriptor; } export interface InitErrorMessage { diff --git a/host/src/browser-kernel-worker-entry.ts b/host/src/browser-kernel-worker-entry.ts index dd48e20f52..f048d1d0b1 100644 --- a/host/src/browser-kernel-worker-entry.ts +++ b/host/src/browser-kernel-worker-entry.ts @@ -203,6 +203,7 @@ const FRAMEBUFFER_RELEASE_ACK_WAIT_MS = 2000; // their exit path and drain. See docs/jsc-terminate-atomics-wait-workaround.md. const DESTROY_KILL_DRAIN_TIMEOUT_MS = 1500; const DESTROY_KILL_DRAIN_POLL_MS = 15; +const PCM_DESTROY_DRAIN_TIMEOUT_MS = 2000; /** * Workers we deliberately terminated — exec, exit, top-level destroy. The @@ -1204,7 +1205,8 @@ async function handleInit(msg: Extract) { // cannot slip past both the pending queue and the serialized live path. initReady = true; - post({ type: "ready" }); + const pcmTransport = kernelWorker.claimPcmTransport(true); + post({ type: "ready", pcmTransport }); } // ── Spawn ── @@ -3165,6 +3167,17 @@ async function performDestroy() { threadWorkers.clear(); threadedProcessPids.clear(); ptyByPid.clear(); + await waitForProcessTeardowns(); + if (!(await kernelWorker.waitForPcmDrain(PCM_DESTROY_DRAIN_TIMEOUT_MS))) { + post({ + type: "host_diagnostic", + pid: 0, + source: "browser PCM output", + message: + "Audio clock did not consume the queued close tail before machine teardown; the remaining tail was discarded.", + }); + } + kernelWorker.shutdownPcmTransport(); initReady = false; initFailure = "kernel worker destroyed"; failPendingLazyRegistrations(initFailure); diff --git a/host/src/generated/abi.ts b/host/src/generated/abi.ts index ed8983e74c..b68c41c16f 100644 --- a/host/src/generated/abi.ts +++ b/host/src/generated/abi.ts @@ -664,6 +664,11 @@ export const HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS = [ "kernel_mq_descriptor_msgsize", "kernel_msqid_ds_bytes", "kernel_pick_tcp_listener_target", + "kernel_pcm_claim_transport", + "kernel_pcm_clock_update", + "kernel_pcm_reconcile", + "kernel_pcm_transport_len", + "kernel_pcm_transport_ptr", "kernel_pick_signal_target_tid", "kernel_pipe_has_readers", "kernel_posix_timer_fire", @@ -705,6 +710,55 @@ export const HOST_ADAPTER_OPTIONAL_KERNEL_EXPORTS = [ "kernel_set_mmap_base", ] as const; +export const PCM_TRANSPORT_MAGIC = 827147088 as const; +export const PCM_TRANSPORT_VERSION = 1 as const; +export const PCM_TRANSPORT_HEADER_BYTES = 128 as const; +export const PCM_TRANSPORT_RING_BYTES = 65536 as const; +export const PCM_TRANSPORT_BYTES = 65664 as const; +export const PCM_STATE_CLOSED = 0 as const; +export const PCM_STATE_STOPPED = 1 as const; +export const PCM_STATE_RUNNING = 2 as const; +export const PCM_STATE_DRAINING = 3 as const; +export const PCM_FORMAT_UNKNOWN = 0 as const; +export const PCM_FORMAT_U8 = 1 as const; +export const PCM_FORMAT_S16_LE = 2 as const; +export const PCM_FORMAT_S16_BE = 3 as const; +export const PCM_TRANSPORT_UNCLAIMED = 0 as const; +export const PCM_TRANSPORT_LEGACY_PULL = 1 as const; +export const PCM_TRANSPORT_SHARED_CLOCK = 2 as const; +export const PCM_FLAG_CONFIGURING = 1 as const; +export const PCM_FLAG_UNDERRUN_ACTIVE = 2 as const; +export const PCM_FLAG_FATAL_ERROR = 4 as const; + +export const PCM_SHARED_CONTROL_FIELDS = { + magic: { offset: 0, size: 4 }, + version: { offset: 4, size: 4 }, + headerBytes: { offset: 8, size: 4 }, + physicalCapacityBytes: { offset: 12, size: 4 }, + activeCapacityBytes: { offset: 16, size: 4 }, + format: { offset: 20, size: 4 }, + sampleRate: { offset: 24, size: 4 }, + channels: { offset: 28, size: 4 }, + frameBytes: { offset: 32, size: 4 }, + fragmentBytes: { offset: 36, size: 4 }, + fragments: { offset: 40, size: 4 }, + state: { offset: 44, size: 4 }, + generation: { offset: 48, size: 4 }, + flags: { offset: 52, size: 4 }, + transportMode: { offset: 56, size: 4 }, + producerSeq: { offset: 60, size: 4 }, + producerLo: { offset: 64, size: 4 }, + producerHi: { offset: 68, size: 4 }, + consumerSeq: { offset: 72, size: 4 }, + consumerLo: { offset: 76, size: 4 }, + consumerHi: { offset: 80, size: 4 }, + discardSeq: { offset: 84, size: 4 }, + discardLo: { offset: 88, size: 4 }, + discardHi: { offset: 92, size: 4 }, + underruns: { offset: 96, size: 4 }, + wakeSeq: { offset: 100, size: 4 }, +} as const; + export const HOST_ADAPTER_MANIFEST_FIELDS = { magic: { offset: 0, size: 4 }, manifestVersion: { offset: 4, size: 2 }, @@ -1491,6 +1545,10 @@ export const IOCTL_REQUESTS: Record = { 19269: { argKind: "scalar-i32", direction: "none", wasm32Size: 0, wasm64Size: 0 }, 20480: { argKind: "none", direction: "none", wasm32Size: 0, wasm64Size: 0 }, 20481: { argKind: "none", direction: "none", wasm32Size: 0, wasm64Size: 0 }, + 20488: { argKind: "none", direction: "none", wasm32Size: 0, wasm64Size: 0 }, + 20494: { argKind: "none", direction: "none", wasm32Size: 0, wasm64Size: 0 }, + 20501: { argKind: "none", direction: "none", wasm32Size: 0, wasm64Size: 0 }, + 20502: { argKind: "none", direction: "none", wasm32Size: 0, wasm64Size: 0 }, 21505: { argKind: "pointer", direction: "out", wasm32Size: 60, wasm64Size: 60 }, 21506: { argKind: "pointer", direction: "in", wasm32Size: 60, wasm64Size: 60 }, 21507: { argKind: "pointer", direction: "in", wasm32Size: 60, wasm64Size: 60 }, @@ -1513,14 +1571,32 @@ export const IOCTL_REQUESTS: Record = { 25630: { argKind: "none", direction: "none", wasm32Size: 0, wasm64Size: 0 }, 25631: { argKind: "none", direction: "none", wasm32Size: 0, wasm64Size: 0 }, 35077: { argKind: "pointer", direction: "out", wasm32Size: 4, wasm64Size: 4 }, + 1074024452: { argKind: "pointer", direction: "in", wasm32Size: 4, wasm64Size: 4 }, + 1074024464: { argKind: "pointer", direction: "in", wasm32Size: 4, wasm64Size: 4 }, 1074025521: { argKind: "pointer", direction: "in", wasm32Size: 4, wasm64Size: 4 }, 1074291721: { argKind: "pointer", direction: "in", wasm32Size: 8, wasm64Size: 8 }, + 2147766274: { argKind: "pointer", direction: "out", wasm32Size: 4, wasm64Size: 4 }, + 2147766277: { argKind: "pointer", direction: "out", wasm32Size: 4, wasm64Size: 4 }, + 2147766278: { argKind: "pointer", direction: "out", wasm32Size: 4, wasm64Size: 4 }, + 2147766279: { argKind: "pointer", direction: "out", wasm32Size: 4, wasm64Size: 4 }, 2147766283: { argKind: "pointer", direction: "out", wasm32Size: 4, wasm64Size: 4 }, + 2147766287: { argKind: "pointer", direction: "out", wasm32Size: 4, wasm64Size: 4 }, + 2147766288: { argKind: "pointer", direction: "out", wasm32Size: 4, wasm64Size: 4 }, + 2147766295: { argKind: "pointer", direction: "out", wasm32Size: 4, wasm64Size: 4 }, 2147767344: { argKind: "pointer", direction: "out", wasm32Size: 4, wasm64Size: 4 }, + 2148028435: { argKind: "pointer", direction: "out", wasm32Size: 8, wasm64Size: 8 }, + 2148028436: { argKind: "pointer", direction: "out", wasm32Size: 8, wasm64Size: 8 }, + 2148290577: { argKind: "pointer", direction: "out", wasm32Size: 12, wasm64Size: 12 }, + 2148290578: { argKind: "pointer", direction: "out", wasm32Size: 12, wasm64Size: 12 }, + 2148552716: { argKind: "pointer", direction: "out", wasm32Size: 16, wasm64Size: 16 }, + 2148552717: { argKind: "pointer", direction: "out", wasm32Size: 16, wasm64Size: 16 }, 3221508098: { argKind: "pointer", direction: "inout", wasm32Size: 4, wasm64Size: 4 }, 3221508099: { argKind: "pointer", direction: "inout", wasm32Size: 4, wasm64Size: 4 }, + 3221508100: { argKind: "pointer", direction: "out", wasm32Size: 4, wasm64Size: 4 }, 3221508101: { argKind: "pointer", direction: "inout", wasm32Size: 4, wasm64Size: 4 }, 3221508102: { argKind: "pointer", direction: "inout", wasm32Size: 4, wasm64Size: 4 }, + 3221508103: { argKind: "pointer", direction: "inout", wasm32Size: 4, wasm64Size: 4 }, + 3221508105: { argKind: "pointer", direction: "inout", wasm32Size: 4, wasm64Size: 4 }, 3221508106: { argKind: "pointer", direction: "inout", wasm32Size: 4, wasm64Size: 4 }, 3221513391: { argKind: "pointer", direction: "in", wasm32Size: 4, wasm64Size: 4 }, 3221513396: { argKind: "pointer", direction: "in", wasm32Size: 4, wasm64Size: 4 }, diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index d9e1ecf267..252550966a 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -273,6 +273,16 @@ import { } from "./process-memory"; import { readForkContinuationAnchor } from "./fork-continuation"; import { EXEC_RETIRE_SIGNAL_CODE } from "./worker-protocol"; +import { + PCM_CONTROL, + PCM_CONTROL_BYTES, + PcmTransportMode, + pcmControlWords, + readEffectiveConsumerPosition, + readProducerPosition, + validatePcmTransport, + type PcmTransportDescriptor, +} from "./audio/pcm-transport"; import type { KernelConfig, NetworkAddress, PlatformIO, TcpConnectionPeer, UdpDatagram } from "./types"; @@ -2765,6 +2775,8 @@ export class CentralizedKernelWorker { private execHandoffPids = new Set(); /** Capacity travels with the allocator-owned pointer. */ #scratchRegion: KernelScratchRegion | null = null; + #pcmTransportDescriptor: PcmTransportDescriptor | null = null; + #pcmWakeObserverGeneration = 0; /** * Host-side half of the Rust reservation state machine. * @@ -15296,6 +15308,42 @@ export class CentralizedKernelWorker { } } + /** + * Retry the exact write/writev mailbox selected for a caught signal. + * + * PCM backpressure uses the generic writable-token registry, but the signal + * path historically retried only poll-timer entries. Leaving a PCM writer + * here meant its handler could not run until the audio clock happened to + * make the original write succeed. Re-entering the kernel lets it return + * EINTR before progress, or preserve a short successful write if capacity + * became available concurrently. + */ + private retryPendingWriterForCaughtSignal(pid: number, tid: number): boolean { + for (const writers of this.pendingPipeWriters.values()) { + const writer = writers.find(({ channel }) => { + if ( + channel.pid !== pid || + this.guestTidForChannel(channel) !== tid + ) { + return false; + } + const syscallNr = new DataView( + channel.memory.buffer, + channel.channelOffset, + ).getUint32(CH_SYSCALL, true); + return syscallNr === SYS_WRITE || syscallNr === SYS_WRITEV; + }); + if (!writer) continue; + + this.removePendingPipeWriter(writer.channel); + if (this.isRegisteredChannel(writer.channel)) { + this.retrySyscall(writer.channel); + } + return true; + } + return false; + } + /** * SYS_THREAD_CANCEL — wake a thread that is blocked in a cancellation-point * syscall so its glue (__syscall_cp) can observe the pending cancel flag @@ -25147,6 +25195,10 @@ export class CentralizedKernelWorker { // Signal is deliverable — wake any blocking syscall for this process + // PCM and pipe write/writev waits live in the targeted writable registry, + // not the poll-timer map below. Retry only the signal-selected thread. + if (this.retryPendingWriterForCaughtSignal(targetPid, targetTid)) return; + // 1. Pending sleep (nanosleep, usleep, clock_nanosleep) const pendingSleepMatch = Array.from(this.pendingSleeps.entries()).find( ([channel]) => channel.pid === targetPid @@ -29240,11 +29292,9 @@ export class CentralizedKernelWorker { * a multiple of the active frame size (2 bytes mono / 4 bytes * stereo). * - * The host typically drives this from an `AudioWorkletNode` or - * `AudioBufferSourceNode` scheduler that pulls samples at the rate - * an `AudioContext` reports. The kernel ring drops oldest frames on - * overflow rather than blocking, so falling behind a few RAFs costs - * audio but never wedges DOOM. + * Retained only for compatibility with older hosts. The shared-clock + * transport is exclusive, so this pull path returns no data once an + * AudioWorklet or Node clock owns the sink. */ drainAudio(out: Uint8Array): number { return this.#kernel.drainAudio(out); @@ -29265,6 +29315,297 @@ export class CentralizedKernelWorker { return this.#kernel.audioPending(); } + /** + * Claim the single physical PCM sink and return its versioned shared ring. + * Browser workers observe AudioWorklet cursor updates; Node's clock export + * reconciles synchronously and therefore does not need a separate observer. + */ + claimPcmTransport(observeConsumerWake: boolean): PcmTransportDescriptor { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#pcmTransportDescriptor) return this.#pcmTransportDescriptor; + if ( + !this.#initialized || + this.#kernelInstance === null || + this.#kernelMemory === null + ) { + throw new Error("kernel is not initialized for PCM transport claim"); + } + + let descriptor: PcmTransportDescriptor | null = null; + let claimResult: number | null = null; + let exportMissing = false; + this.#runImmediateKernelEntry("PCM transport claim", (entry) => { + const exports = this.#kernelInstanceForEntry(entry).exports; + const ptrFn = exports.kernel_pcm_transport_ptr as + | (() => number | bigint) + | undefined; + const lenFn = exports.kernel_pcm_transport_len as + | (() => number) + | undefined; + const claimFn = exports.kernel_pcm_claim_transport as + | ((mode: number) => number) + | undefined; + if ( + typeof ptrFn !== "function" || + typeof lenFn !== "function" || + typeof claimFn !== "function" + ) { + exportMissing = true; + return undefined; + } + + const controlOffset = checkedKernelExportPointer( + ptrFn(), + this.#kernelPointerWidth, + "PCM transport pointer", + ); + const totalBytes = lenFn(); + if ( + !Number.isSafeInteger(totalBytes) || + totalBytes <= PCM_CONTROL_BYTES + ) { + throw new KernelScratchError( + `kernel returned invalid PCM transport length ${totalBytes}`, + EIO, + ); + } + const range = checkedMemoryRange( + this.#kernelMemory!, + controlOffset, + totalBytes, + this.#kernelPointerWidth, + "PCM transport", + ); + const buffer = kernelEntryMemoryBuffer(this.#kernelMemory!); + if (!(buffer instanceof SharedArrayBuffer)) { + throw new KernelScratchError( + "PCM transport is not backed by shared kernel memory", + EIO, + ); + } + + const candidate = kernelEntryIntrinsicObjectFreeze({ + buffer, + controlOffset: range.pointer, + controlBytes: PCM_CONTROL_BYTES, + dataOffset: range.pointer + PCM_CONTROL_BYTES, + dataBytes: range.length - PCM_CONTROL_BYTES, + }) as PcmTransportDescriptor; + validatePcmTransport(candidate); + descriptor = candidate; + claimResult = claimFn(PcmTransportMode.SharedClock); + return undefined; + }); + + if (exportMissing) { + throw new Error("kernel does not expose a shared PCM transport"); + } + if (descriptor === null || claimResult === null) { + throw new Error("kernel did not complete the PCM transport claim"); + } + if (!Number.isSafeInteger(claimResult) || claimResult > 0) { + throw new Error( + `kernel returned invalid PCM transport claim result ${claimResult}`, + ); + } + if (claimResult < 0) { + throw new Error( + `failed to claim PCM transport: errno ${-claimResult}`, + ); + } + this.#pcmTransportDescriptor = descriptor; + if (observeConsumerWake) this.startPcmWakeObserver(descriptor); + return descriptor; + } + + /** Advance Node's paced null sink and wake affected write/poll/drain calls. */ + pcmClockUpdate(requestedFrames: number): number { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if ( + !Number.isSafeInteger(requestedFrames) || + requestedFrames < 0 || + requestedFrames > 0xffff_ffff + ) { + throw new RangeError( + `PCM clock frame budget is invalid: ${requestedFrames}`, + ); + } + if (this.#pcmTransportDescriptor === null) { + throw new Error("PCM transport has not been claimed"); + } + + let consumed: number | null = null; + let exportMissing = false; + this.#runImmediateKernelEntry("PCM clock update", (entry) => { + const clockUpdate = this.#kernelInstanceForEntry(entry).exports + .kernel_pcm_clock_update as + | ((frames: number) => number) + | undefined; + if (typeof clockUpdate !== "function") { + exportMissing = true; + return undefined; + } + consumed = clockUpdate(requestedFrames) >>> 0; + this.#drainAndProcessWakeupEventsWithinKernelEntry(entry); + this.scheduleWakeBlockedRetries(entry); + return undefined; + }); + if (exportMissing) { + throw new Error("kernel_pcm_clock_update export is unavailable"); + } + if (consumed === null) { + throw new Error("kernel did not complete the PCM clock update"); + } + + // kernel_pcm_clock_update advances wakeSeq inside Wasm, but changing an + // atomic value does not by itself wake a JS Atomics.waitAsync waiter. + // Browser consumption calls Atomics.notify from the AudioWorklet; mirror + // that notification for Node so destroy-time orphan drains settle as soon + // as the paced null sink reaches their tail instead of sleeping until the + // bounded teardown timeout. + Atomics.notify( + pcmControlWords(this.#pcmTransportDescriptor), + PCM_CONTROL.wakeSeq, + ); + return consumed; + } + + /** Stop host-side observation before terminating a kernel worker. */ + shutdownPcmTransport(): void { + this.#pcmWakeObserverGeneration++; + const descriptor = this.#pcmTransportDescriptor; + this.#pcmTransportDescriptor = null; + if (descriptor) { + const words = pcmControlWords(descriptor); + // Pair the notification with a sequence change so an observer racing + // between its lifecycle check and waitAsync cannot miss shutdown. + Atomics.add(words, PCM_CONTROL.wakeSeq, 1); + Atomics.notify(words, PCM_CONTROL.wakeSeq); + } + } + + /** + * Give the physical audio clock a bounded chance to consume an orphaned + * close tail before machine teardown. A suspended browser context times out + * truthfully; ordinary descriptor close/SYNC remains unbounded in the guest. + */ + async waitForPcmDrain(timeoutMs: number): Promise { + const descriptor = this.#pcmTransportDescriptor; + if (!descriptor) return true; + const words = pcmControlWords(descriptor); + const deadline = performance.now() + Math.max(0, timeoutMs); + let observed = Atomics.load(words, PCM_CONTROL.wakeSeq); + while (true) { + // Reconcile before testing the cursor or arming a wait. In particular, + // the final AudioWorklet quantum may already have published its cursor + // and one-shot notification before this continuation gets to run. + this.#reconcilePcmTransport( + "PCM teardown-drain reconciliation", + descriptor, + ); + + if ( + readProducerPosition(words) <= readEffectiveConsumerPosition(words) + ) { + return true; + } + const remaining = deadline - performance.now(); + if (remaining <= 0) return false; + + const current = Atomics.load(words, PCM_CONTROL.wakeSeq); + if (current !== observed) { + observed = current; + continue; + } + + // waitAsync compares and arms atomically. A sequence change after the + // load therefore either returns "not-equal" or wakes this waiter. + const wait = Atomics.waitAsync( + words, + PCM_CONTROL.wakeSeq, + observed, + remaining, + ); + if (wait.async) await wait.value; + observed = Atomics.load(words, PCM_CONTROL.wakeSeq); + } + } + + private startPcmWakeObserver(descriptor: PcmTransportDescriptor): void { + const observerGeneration = ++this.#pcmWakeObserverGeneration; + const words = pcmControlWords(descriptor); + void (async () => { + let observed = Atomics.load(words, PCM_CONTROL.wakeSeq); + while ( + observerGeneration === this.#pcmWakeObserverGeneration && + descriptor === this.#pcmTransportDescriptor + ) { + // Reconcile first: the sequence may already reflect a final quantum + // whose notification ran before this observer continuation. + this.#reconcilePcmTransport( + "PCM consumer-wake reconciliation", + descriptor, + ); + + if ( + observerGeneration !== this.#pcmWakeObserverGeneration || + descriptor !== this.#pcmTransportDescriptor + ) { + return; + } + + const current = Atomics.load(words, PCM_CONTROL.wakeSeq); + if (current !== observed) { + observed = current; + continue; + } + + const wait = Atomics.waitAsync(words, PCM_CONTROL.wakeSeq, observed); + if (wait.async) await wait.value; + observed = Atomics.load(words, PCM_CONTROL.wakeSeq); + } + })().catch((error) => { + console.error("[kernel-worker] PCM wake observer failed", error); + }); + } + + /** + * Reconcile a host-published consumer cursor under the same serialized entry + * that drains its resulting writer wakeups. + * + * WHY: browser AudioWorklet progress arrives through an async host observer. + * The cursor lives in shared memory, but Rust's open-file-description and + * wake queues remain kernel-owned. Treating reconciliation as a bare export + * would let it interleave with scratch transfer or fork entry and split those + * two authoritative views. + */ + #reconcilePcmTransport( + label: string, + descriptor: PcmTransportDescriptor, + ): void { + this.#runOrDeferKernelEntry( + label, + (entry) => { + if (descriptor !== this.#pcmTransportDescriptor) return undefined; + const reconcile = this.#kernelInstanceForEntry(entry).exports + .kernel_pcm_reconcile as (() => number) | undefined; + if (typeof reconcile !== "function") { + throw new Error("kernel_pcm_reconcile export is unavailable"); + } + const result = reconcile(); + if (result !== 0 && result !== 1) { + throw new Error( + `kernel returned invalid PCM reconciliation result ${result}`, + ); + } + this.#drainAndProcessWakeupEventsWithinKernelEntry(entry); + this.scheduleWakeBlockedRetries(entry); + return undefined; + }, + descriptor, + ); + } + /** * ABI version the kernel advertised at startup via its * `__abi_version` export. Worker processes compare against this diff --git a/host/src/node-kernel-host.ts b/host/src/node-kernel-host.ts index a7b1693799..497dfb5273 100644 --- a/host/src/node-kernel-host.ts +++ b/host/src/node-kernel-host.ts @@ -55,7 +55,9 @@ function currentModuleDir(): string { } const MODULE_DIR = currentModuleDir(); -const DESTROY_REQUEST_TIMEOUT_MS = 2_000; +// Worker teardown may spend 1.5s waking blocked guests and then give a +// suspended/slow PCM clock 2s to finish an orphaned close tail. +const DESTROY_REQUEST_TIMEOUT_MS = 5_000; const DEFAULT_SSL_ENV = [ "SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt", "SSL_CERT_DIR=/etc/ssl/certs", diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts index a0cc4b901f..b855b60e92 100644 --- a/host/src/node-kernel-worker-entry.ts +++ b/host/src/node-kernel-worker-entry.ts @@ -126,6 +126,7 @@ import type { HttpRequestMessage, } from "./node-kernel-protocol"; import { kernelRealmDestroyResult } from "./kernel-realm-destroy"; +import { NodePcmDriver } from "./audio/node-pcm-driver"; if (!parentPort) { throw new Error("node-kernel-worker-entry must run in a worker_thread"); @@ -138,6 +139,7 @@ const O_WRONLY_CREAT_TRUNC = // --- State --- let kernelWorker: CentralizedKernelWorker; +let pcmDriver: NodePcmDriver | null = null; let workerAdapter: NodeWorkerAdapter; let maxPages: number = DEFAULT_MAX_PAGES; let defaultThreadSlots: number = DEFAULT_PROCESS_THREAD_SLOTS; @@ -184,6 +186,7 @@ const DESTROY_KILL_DRAIN_TIMEOUT_MS = 1500; const DESTROY_KILL_DRAIN_POLL_MS = 15; const PROCESS_WORKER_QUIESCENCE_WAIT_MS = 100; const EXEC_WORKER_RETIREMENT_WAIT_MS = 5_000; +const PCM_DESTROY_DRAIN_TIMEOUT_MS = 2000; // Process tracking interface ForkReplayContext { @@ -1086,6 +1089,18 @@ async function handleInit(msg: InitMessage) { await kernelWorker.init(msg.kernelWasmBytes); + const pcmTransport = kernelWorker.claimPcmTransport(false); + pcmDriver = new NodePcmDriver({ + clockUpdate: (frames) => kernelWorker.pcmClockUpdate(frames), + // Node does not run the browser's shared-wake observer. Force one kernel + // reconciliation/retry pass so blocked write, poll, drain, and close calls + // observe EIO immediately when the null/physical sink fails. + onFatal: () => { + kernelWorker.pcmClockUpdate(0); + }, + }); + await pcmDriver.prepare(pcmTransport); + initReady = true; post({ type: "ready" }); } @@ -2581,6 +2596,18 @@ async function performDestroy() { threadModuleCache.clear(); threadWorkers.clear(); ptyByPid.clear(); + if (!(await kernelWorker.waitForPcmDrain(PCM_DESTROY_DRAIN_TIMEOUT_MS))) { + post({ + type: "host_diagnostic", + pid: 0, + source: "Node PCM output", + message: + "Audio clock did not consume the queued close tail before machine teardown; the remaining tail was discarded.", + }); + } + await pcmDriver?.close(); + pcmDriver = null; + kernelWorker.shutdownPcmTransport(); if (gracefulDetachComplete) { try { processMemoryAllocator.clear(); diff --git a/host/test/audio-signal-interruption.test.ts b/host/test/audio-signal-interruption.test.ts new file mode 100644 index 0000000000..810e7925c4 --- /dev/null +++ b/host/test/audio-signal-interruption.test.ts @@ -0,0 +1,48 @@ +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { NodePcmDriver } from "../src/audio/node-pcm-driver"; +import { NodePlatformIO } from "../src/platform/node"; +import type { CentralizedKernelWorker } from "../src/kernel-worker"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const program = join(repoRoot, "examples/dsp_signal_test.wasm"); + +describe.skipIf(!existsSync(program))("/dev/dsp signal interruption", () => { + it("delivers caught signals, applies narrow SA_RESTART, and preserves an interrupted close fd", async () => { + let kernel: CentralizedKernelWorker | null = null; + let driver: NodePcmDriver | null = null; + let consumedBytes = 0; + try { + const result = await runCentralizedProgram({ + programPath: program, + argv: ["dsp_signal_test"], + useDefaultRootfs: false, + timeout: 15_000, + io: new NodePlatformIO(), + onKernelReady: async (readyKernel) => { + kernel = readyKernel; + driver = new NodePcmDriver({ + clockUpdate: (frames) => readyKernel.pcmClockUpdate(frames), + onConsume: ({ bytes }) => { + consumedBytes += bytes.byteLength; + }, + }); + await driver.prepare(readyKernel.claimPcmTransport(false)); + }, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("PASS dsp signal interruption alarms=6"); + expect(consumedBytes).toBeGreaterThan(0); + expect(kernel).not.toBeNull(); + expect(await kernel!.waitForPcmDrain(1000)).toBe(true); + } finally { + await driver?.close().catch(() => {}); + kernel?.shutdownPcmTransport(); + } + }, 20_000); +}); diff --git a/host/test/browser-kernel.test.ts b/host/test/browser-kernel.test.ts index d33b7942e3..708301e5e5 100644 --- a/host/test/browser-kernel.test.ts +++ b/host/test/browser-kernel.test.ts @@ -16,6 +16,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { createHash } from "node:crypto"; import { WASM_PAGE_SIZE } from "../src/constants"; import type { HttpResponse } from "../src/networking"; +import { createPcmTransport } from "./pcm-test-helpers"; const defaultArtifactModuleState = vi.hoisted(() => ({ loads: 0 })); vi.mock("../src/browser-kernel-default-artifacts", () => { @@ -98,6 +99,64 @@ async function loadBrowserKernel() { return mod.BrowserKernel as typeof import("../src/browser-kernel-host").BrowserKernel; } +function browserAudioGlobals() { + const addModule = vi.fn(async () => {}); + const context = { + state: "suspended" as AudioContextState, + sampleRate: 48_000, + baseLatency: 0.01, + outputLatency: 0.02, + renderQuantumSize: 128, + destination: {}, + audioWorklet: { addModule }, + onstatechange: null as (() => void) | null, + resume: vi.fn(async function (this: typeof context) { + this.state = "running"; + this.onstatechange?.(); + }), + suspend: vi.fn(async function (this: typeof context) { + this.state = "suspended"; + this.onstatechange?.(); + }), + close: vi.fn(async function (this: typeof context) { + this.state = "closed"; + this.onstatechange?.(); + }), + }; + const port = { + onmessage: null as ((event: MessageEvent) => void) | null, + close: vi.fn(), + }; + const node = { + port, + onprocessorerror: null as (() => void) | null, + connect: vi.fn(), + disconnect: vi.fn(), + }; + let nodeOptions: AudioWorkletNodeOptions | undefined; + const AudioContextCtor = vi.fn(function () { + return context; + }); + const AudioWorkletNodeCtor = vi.fn(function ( + _context: AudioContext, + _name: string, + options: AudioWorkletNodeOptions, + ) { + nodeOptions = options; + return node; + }); + vi.stubGlobal("AudioContext", AudioContextCtor); + vi.stubGlobal("AudioWorkletNode", AudioWorkletNodeCtor); + return { + addModule, + context, + node, + AudioContextCtor, + AudioWorkletNodeCtor, + get nodeOptions() { return nodeOptions; }, + }; +} + describe("BrowserKernel", () => { beforeEach(() => { @@ -1460,4 +1519,107 @@ describe("BrowserKernel", () => { bind(); expect(kernel.getProcessMemory(pid)).toBe(memory); }); + + it("prepares the browser PCM sink from the worker ready transport", async () => { + const audio = browserAudioGlobals(); + const BrowserKernel = await loadBrowserKernel(); + const kernel = new BrowserKernel({ + kernelOwnedFs: true, + audioWorkletUrl: "/assets/kandelo-pcm-output.js", + }); + const initPromise = kernel.initFromImage({ + kernelWasm: new ArrayBuffer(8), + vfsImage: new Uint8Array(0), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const worker = MockWorker.instances[0]!; + const transport = createPcmTransport(); + + worker.simulateMessage({ type: "ready", pcmTransport: transport }); + await initPromise; + await vi.waitFor(() => { + expect(kernel.getAudioState()).toBe("suspended"); + }); + + expect(audio.AudioContextCtor).toHaveBeenCalledOnce(); + expect(audio.addModule).toHaveBeenCalledWith( + "/assets/kandelo-pcm-output.js", + ); + expect(audio.AudioWorkletNodeCtor).toHaveBeenCalledWith( + audio.context, + "kandelo-pcm-output", + expect.any(Object), + ); + expect(audio.nodeOptions?.processorOptions).toMatchObject(transport); + expect(audio.node.connect).toHaveBeenCalledWith(audio.context.destination); + }); + + it("settles and closes browser PCM before terminating the kernel worker", async () => { + const audio = browserAudioGlobals(); + const BrowserKernel = await loadBrowserKernel(); + const kernel = new BrowserKernel({ kernelOwnedFs: true }); + const initPromise = kernel.initFromImage({ + kernelWasm: new ArrayBuffer(8), + vfsImage: new Uint8Array(0), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const worker = MockWorker.instances[0]!; + worker.simulateMessage({ + type: "ready", + pcmTransport: createPcmTransport(), + }); + await initPromise; + await vi.waitFor(() => { + expect(kernel.getAudioState()).toBe("suspended"); + }); + await kernel.resumeAudio(); + + const teardownOrder: string[] = []; + let finishPcmSettlement!: () => void; + audio.context.suspend.mockImplementationOnce( + () => + new Promise((resolve) => { + teardownOrder.push("pcm-pipeline-settle"); + finishPcmSettlement = () => { + audio.context.state = "suspended"; + audio.context.onstatechange?.(); + resolve(); + }; + }), + ); + audio.context.close.mockImplementationOnce(async () => { + teardownOrder.push("pcm-context-close"); + audio.context.state = "closed"; + audio.context.onstatechange?.(); + }); + vi.spyOn(worker, "terminate").mockImplementationOnce(() => { + teardownOrder.push("kernel-worker-terminate"); + worker.terminated = true; + }); + + const destroyPromise = kernel.destroy(); + await new Promise((resolve) => setTimeout(resolve, 0)); + const destroy = worker.lastMessage("destroy"); + worker.simulateMessage({ + type: "response", + requestId: destroy.requestId, + result: { gracefulDetachComplete: true }, + }); + await vi.waitFor(() => { + expect(teardownOrder).toEqual(["pcm-pipeline-settle"]); + }); + expect(audio.context.close).not.toHaveBeenCalled(); + expect(worker.terminated).toBe(false); + finishPcmSettlement(); + await destroyPromise; + + expect(teardownOrder).toEqual([ + "pcm-pipeline-settle", + "pcm-context-close", + "kernel-worker-terminate", + ]); + expect(audio.node.disconnect).toHaveBeenCalledOnce(); + expect(audio.node.port.close).toHaveBeenCalledOnce(); + expect(kernel.getAudioState()).toBe("unavailable"); + }); }); diff --git a/host/test/browser-pcm-driver.test.ts b/host/test/browser-pcm-driver.test.ts new file mode 100644 index 0000000000..df8c2616e7 --- /dev/null +++ b/host/test/browser-pcm-driver.test.ts @@ -0,0 +1,417 @@ +import { describe, expect, it, vi } from "vitest"; +import { BrowserPcmDriver } from "../src/audio/browser-pcm-driver"; +import { + PCM_CONTROL, + PcmSampleFormat, + PcmStreamState, + PcmTransportFlag, + pcmControlWords, + readEffectiveConsumerPosition, + readProducerPosition, +} from "../src/audio/pcm-transport"; +import { createPcmTransport, writeProducer } from "./pcm-test-helpers"; + +function browserAudioMocks() { + const addModule = vi.fn(async () => {}); + const eventListeners = new Map< + string, + Set + >(); + const context = { + state: "suspended" as AudioContextState, + sampleRate: 48_000, + baseLatency: 0.01, + outputLatency: 0.02, + renderQuantumSize: 128, + destination: {}, + audioWorklet: { addModule }, + onstatechange: null as (() => void) | null, + addEventListener: vi.fn( + (type: string, listener: EventListenerOrEventListenerObject | null) => { + if (!listener) return; + const listeners = eventListeners.get(type) ?? new Set(); + listeners.add(listener); + eventListeners.set(type, listeners); + }, + ), + removeEventListener: vi.fn( + (type: string, listener: EventListenerOrEventListenerObject | null) => { + if (listener) eventListeners.get(type)?.delete(listener); + }, + ), + dispatchEvent: vi.fn((event: Event) => { + for (const listener of eventListeners.get(event.type) ?? []) { + if (typeof listener === "function") listener.call(context, event); + else listener.handleEvent(event); + } + return true; + }), + resume: vi.fn(async function (this: typeof context) { + this.state = "running"; + this.onstatechange?.(); + }), + suspend: vi.fn(async function (this: typeof context) { + this.state = "suspended"; + this.onstatechange?.(); + }), + close: vi.fn(async function (this: typeof context) { + this.state = "closed"; + this.onstatechange?.(); + }), + }; + const port = { + onmessage: null as ((event: MessageEvent) => void) | null, + close: vi.fn(), + }; + const node = { + port, + onprocessorerror: null as (() => void) | null, + connect: vi.fn(), + disconnect: vi.fn(), + }; + let nodeOptions: AudioWorkletNodeOptions | undefined; + const createNode = vi.fn( + ( + _context: AudioContext, + _name: string, + options: AudioWorkletNodeOptions, + ) => { + nodeOptions = options; + return node as unknown as AudioWorkletNode; + }, + ); + return { + addModule, + context, + node, + createNode, + get nodeOptions() { + return nodeOptions; + }, + }; +} + +describe("BrowserPcmDriver", () => { + it("loads the packaged worklet with the generated PCM-only transport contract", async () => { + const mocks = browserAudioMocks(); + const driver = new BrowserPcmDriver({ + workletUrl: "/assets/kandelo-pcm.js", + createContext: () => mocks.context as unknown as AudioContext, + createNode: mocks.createNode, + }); + const states: string[] = []; + driver.subscribe((state) => states.push(state)); + + await driver.prepare(createPcmTransport()); + expect(mocks.addModule).toHaveBeenCalledWith("/assets/kandelo-pcm.js"); + expect(mocks.createNode).toHaveBeenCalledWith( + mocks.context, + "kandelo-pcm-output", + expect.any(Object), + ); + expect(mocks.nodeOptions?.processorOptions).toMatchObject({ + layout: PCM_CONTROL, + formats: { + u8: PcmSampleFormat.U8, + s16le: PcmSampleFormat.S16Le, + s16be: PcmSampleFormat.S16Be, + }, + states: { + running: PcmStreamState.Running, + draining: PcmStreamState.Draining, + }, + flags: { + configuring: PcmTransportFlag.Configuring, + underrunActive: PcmTransportFlag.UnderrunActive, + fatalError: PcmTransportFlag.FatalError, + }, + outputSampleRate: 48_000, + }); + expect(driver.getState()).toBe("suspended"); + + await driver.resume(); + expect(driver.getState()).toBe("running"); + await driver.suspend(); + expect(driver.getState()).toBe("suspended"); + await driver.close(); + expect(mocks.node.disconnect).toHaveBeenCalledOnce(); + expect(mocks.node.port.close).toHaveBeenCalledOnce(); + expect(mocks.context.close).toHaveBeenCalledOnce(); + expect(states).toContain("running"); + expect(states.at(-1)).toBe("closed"); + }); + + it("surfaces AudioContext activation failure instead of pretending to run", async () => { + const mocks = browserAudioMocks(); + mocks.context.resume.mockImplementationOnce(async () => { + throw new Error("user activation required"); + }); + const driver = new BrowserPcmDriver({ + workletUrl: "/worklet.js", + createContext: () => mocks.context as unknown as AudioContext, + createNode: mocks.createNode, + }); + const descriptor = createPcmTransport(); + await driver.prepare(descriptor); + + await expect(driver.resume()).rejects.toThrow("user activation required"); + expect(driver.getState()).toBe("suspended"); + expect( + Atomics.load(pcmControlWords(descriptor), PCM_CONTROL.flags) & + PcmTransportFlag.FatalError, + ).toBe(0); + await driver.close(); + }); + + it("settles the rendered Web Audio tail before context close", async () => { + vi.useFakeTimers(); + try { + const mocks = browserAudioMocks(); + const driver = new BrowserPcmDriver({ + workletUrl: "/worklet.js", + createContext: () => mocks.context as unknown as AudioContext, + createNode: mocks.createNode, + }); + await driver.prepare(createPcmTransport()); + await driver.resume(); + + let settled = false; + const settlement = driver.settleOutputPipeline().then(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(0); + + expect(mocks.context.suspend).toHaveBeenCalledOnce(); + expect(mocks.context.resume).toHaveBeenCalledOnce(); + expect(driver.getState()).toBe("suspended"); + expect(settled).toBe(false); + + // 10 ms base latency + 20 ms device latency + one 128-frame + // render quantum at 48 kHz rounds up to a 33 ms settlement wait. + await vi.advanceTimersByTimeAsync(32); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await settlement; + expect(settled).toBe(true); + + await driver.close(); + expect(mocks.context.close).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it("does not resume suspended audio while settling teardown", async () => { + const mocks = browserAudioMocks(); + const driver = new BrowserPcmDriver({ + workletUrl: "/worklet.js", + createContext: () => mocks.context as unknown as AudioContext, + createNode: mocks.createNode, + }); + await driver.prepare(createPcmTransport()); + + await driver.settleOutputPipeline(); + + expect(mocks.context.resume).not.toHaveBeenCalled(); + expect(mocks.context.suspend).not.toHaveBeenCalled(); + expect(driver.getState()).toBe("suspended"); + await driver.close(); + }); + + it("bounds settlement when AudioContext suspension never resolves", async () => { + vi.useFakeTimers(); + try { + const mocks = browserAudioMocks(); + mocks.context.suspend.mockImplementationOnce( + () => new Promise(() => {}), + ); + const driver = new BrowserPcmDriver({ + workletUrl: "/worklet.js", + createContext: () => mocks.context as unknown as AudioContext, + createNode: mocks.createNode, + }); + await driver.prepare(createPcmTransport()); + await driver.resume(); + + let settled = false; + const settlement = driver.settleOutputPipeline().then(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(999); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await settlement; + expect(settled).toBe(true); + + await driver.close(); + } finally { + vi.useRealTimers(); + } + }); + + it("surfaces an explicit AudioWorklet error message", async () => { + const mocks = browserAudioMocks(); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const driver = new BrowserPcmDriver({ + workletUrl: "/worklet.js", + createContext: () => mocks.context as unknown as AudioContext, + createNode: mocks.createNode, + }); + const states: string[] = []; + driver.subscribe((state) => states.push(state)); + const descriptor = createPcmTransport(); + await driver.prepare(descriptor); + + mocks.node.port.onmessage?.({ + data: { type: "error", message: "PCM generation changed mid-quantum" }, + } as MessageEvent); + + expect(driver.getState()).toBe("error"); + expect(states.at(-1)).toBe("error"); + expect(consoleError).toHaveBeenCalledWith( + "[BrowserPcmDriver] PCM generation changed mid-quantum", + ); + expect( + Atomics.load(pcmControlWords(descriptor), PCM_CONTROL.flags) & + PcmTransportFlag.FatalError, + ).toBe(PcmTransportFlag.FatalError); + await expect(driver.resume()).rejects.toThrow("PCM output has failed"); + await driver.close(); + }); + + it("latches the standard AudioContext output-error event across its suspended statechange", async () => { + const mocks = browserAudioMocks(); + const driver = new BrowserPcmDriver({ + workletUrl: "/worklet.js", + createContext: () => mocks.context as unknown as AudioContext, + createNode: mocks.createNode, + }); + const descriptor = createPcmTransport(); + const words = pcmControlWords(descriptor); + await driver.prepare(descriptor); + await driver.resume(); + + mocks.context.dispatchEvent(new Event("error")); + expect(driver.getState()).toBe("error"); + expect( + Atomics.load(words, PCM_CONTROL.flags) & PcmTransportFlag.FatalError, + ).toBe(PcmTransportFlag.FatalError); + expect(Atomics.load(words, PCM_CONTROL.wakeSeq)).toBe(1); + + // Web Audio 1.1 dispatches `error` before moving a resource-failed + // context to suspended and dispatching statechange. That follow-up state + // must not make a permanently failed sink look recoverable. + mocks.context.state = "suspended"; + mocks.context.onstatechange?.(); + await driver.suspend(); + expect(driver.getState()).toBe("error"); + await expect(driver.resume()).rejects.toThrow("PCM output has failed"); + + await driver.close(); + expect(mocks.context.removeEventListener).toHaveBeenCalledWith( + "error", + expect.any(Function), + ); + }); + + it("keeps ordinary interruption and suspension recoverable", async () => { + const mocks = browserAudioMocks(); + const driver = new BrowserPcmDriver({ + workletUrl: "/worklet.js", + createContext: () => mocks.context as unknown as AudioContext, + createNode: mocks.createNode, + }); + const descriptor = createPcmTransport(); + const words = pcmControlWords(descriptor); + await driver.prepare(descriptor); + + mocks.context.state = "interrupted" as AudioContextState; + mocks.context.onstatechange?.(); + expect(driver.getState()).toBe("interrupted"); + expect( + Atomics.load(words, PCM_CONTROL.flags) & PcmTransportFlag.FatalError, + ).toBe(0); + + mocks.context.state = "suspended"; + mocks.context.onstatechange?.(); + expect(driver.getState()).toBe("suspended"); + await driver.resume(); + expect(driver.getState()).toBe("running"); + await driver.close(); + }); + + it("treats an unexpectedly closed AudioContext as a latched sink failure", async () => { + const mocks = browserAudioMocks(); + const driver = new BrowserPcmDriver({ + workletUrl: "/worklet.js", + createContext: () => mocks.context as unknown as AudioContext, + createNode: mocks.createNode, + }); + const descriptor = createPcmTransport(); + const words = pcmControlWords(descriptor); + await driver.prepare(descriptor); + + mocks.context.state = "closed"; + mocks.context.onstatechange?.(); + expect(driver.getState()).toBe("error"); + expect( + Atomics.load(words, PCM_CONTROL.flags) & PcmTransportFlag.FatalError, + ).toBe(PcmTransportFlag.FatalError); + expect(Atomics.load(words, PCM_CONTROL.wakeSeq)).toBe(1); + + mocks.context.state = "suspended"; + mocks.context.onstatechange?.(); + expect(driver.getState()).toBe("error"); + await driver.close(); + }); + + it("wakes orphan-drain reconciliation when the AudioWorklet processor fails", async () => { + const mocks = browserAudioMocks(); + const driver = new BrowserPcmDriver({ + workletUrl: "/worklet.js", + createContext: () => mocks.context as unknown as AudioContext, + createNode: mocks.createNode, + }); + const states: string[] = []; + driver.subscribe((state) => states.push(state)); + const descriptor = createPcmTransport({ + state: PcmStreamState.Draining, + }); + writeProducer(descriptor, 64n); + const words = pcmControlWords(descriptor); + await driver.prepare(descriptor); + + mocks.node.onprocessorerror?.(); + + expect(driver.getState()).toBe("error"); + expect(states.at(-1)).toBe("error"); + expect( + Atomics.load(words, PCM_CONTROL.flags) & PcmTransportFlag.FatalError, + ).toBe(PcmTransportFlag.FatalError); + expect(Atomics.load(words, PCM_CONTROL.wakeSeq)).toBe(1); + expect(readEffectiveConsumerPosition(words)).toBe(0n); + expect(readProducerPosition(words)).toBe(64n); + await driver.close(); + }); + + it("closes a partially-created context when worklet loading fails", async () => { + const mocks = browserAudioMocks(); + mocks.addModule.mockRejectedValueOnce(new Error("asset missing")); + const driver = new BrowserPcmDriver({ + workletUrl: "/missing.js", + createContext: () => mocks.context as unknown as AudioContext, + createNode: mocks.createNode, + }); + + const descriptor = createPcmTransport(); + await expect(driver.prepare(descriptor)).rejects.toThrow("asset missing"); + expect(driver.getState()).toBe("error"); + expect(mocks.context.close).toHaveBeenCalledOnce(); + expect( + Atomics.load(pcmControlWords(descriptor), PCM_CONTROL.flags) & + PcmTransportFlag.FatalError, + ).toBe(PcmTransportFlag.FatalError); + }); +}); diff --git a/host/test/centralized-test-helper.ts b/host/test/centralized-test-helper.ts index 04cab0c84a..b67ccfebe3 100644 --- a/host/test/centralized-test-helper.ts +++ b/host/test/centralized-test-helper.ts @@ -144,6 +144,16 @@ export interface RunProgramOptions { /** Callback invoked after the process starts. * Use this to call appendStdinData() for interactive stdin testing. */ onStarted?: (kernelProxy: KernelStdinProxy, pid: number) => void | Promise; + /** + * Main-thread harness hook invoked after process registration but before its + * Worker starts. Supplying this forces main-thread mode so tests can attach + * host devices that need direct access to `CentralizedKernelWorker` while + * retaining the production-equivalent pthread/fork/exec worker wiring. + */ + onKernelReady?: ( + kernelWorker: CentralizedKernelWorker, + pid: number, + ) => void | Promise; /** If `true`, the helper queries `kernel_get_fork_count(pid)` whenever * the running program creates a guest child and surfaces those live-parent * snapshots on `RunProgramResult.forkCountSamples`. Used by the @@ -196,7 +206,7 @@ export interface RunProgramResult { export async function runCentralizedProgram( options: RunProgramOptions, ): Promise { - if (options.io) { + if (options.io || options.onKernelReady) { return runOnMainThread(options); } return runInWorkerThread(options); @@ -913,6 +923,10 @@ async function runOnMainThread(options: RunProgramOptions): Promise; diff --git a/host/test/global-setup.ts b/host/test/global-setup.ts index e15e6ce592..5022e52b0d 100644 --- a/host/test/global-setup.ts +++ b/host/test/global-setup.ts @@ -83,6 +83,7 @@ const TEST_PROGRAMS = [ "clock_getcpuclockid_test.c", "syscall_cp_offset_test.c", "select_signal_test.c", + "dsp_signal_test.c", "lseek_invalid_test.c", "environment_lifecycle_test.c", "chown_sentinel_test.c", diff --git a/host/test/ioctl-arg-size.test.ts b/host/test/ioctl-arg-size.test.ts new file mode 100644 index 0000000000..909a915fd7 --- /dev/null +++ b/host/test/ioctl-arg-size.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { IOCTL_REQUESTS } from "../src/generated/abi"; + +describe("ioctl argument sizing", () => { + it("generates Kandelo's supported OSS argument contracts", () => { + for (const request of [0x5000, 0x5001, 0x5008, 0x500e]) { + expect(IOCTL_REQUESTS[request], request.toString(16)).toEqual({ + argKind: "none", + direction: "none", + wasm32Size: 0, + wasm64Size: 0, + }); + } + for (const request of [ + 0xc0045002, // SPEED + 0xc0045003, // STEREO + 0xc0045004, // GETBLKSIZE + 0xc0045005, // SETFMT + 0xc0045006, // CHANNELS + 0xc004500a, // SETFRAGMENT + 0x8004500b, // GETFMTS + 0x8004500f, // GETCAPS + 0x80045017, // GETODELAY + 0x80045002, // READ_RATE + 0x80045005, // READ_BITS + 0x80045006, // READ_CHANNELS + ]) { + expect( + IOCTL_REQUESTS[request]?.wasm32Size, + request.toString(16), + ).toBe(4); + expect( + IOCTL_REQUESTS[request]?.argKind, + request.toString(16), + ).toBe("pointer"); + } + expect(IOCTL_REQUESTS[0x8010500c]?.wasm32Size).toBe(16); // GETOSPACE/audio_buf_info + expect(IOCTL_REQUESTS[0x800c5012]?.wasm32Size).toBe(12); // GETOPTR/count_info + expect(IOCTL_REQUESTS[0x540b]).toMatchObject({ + argKind: "scalar-i32", + wasm32Size: 0, + }); // TCFLSH has an immediate selector + }); +}); diff --git a/host/test/kernel-blocking-retry-snapshot.test.ts b/host/test/kernel-blocking-retry-snapshot.test.ts index 5fa36f7d51..fa02296dee 100644 --- a/host/test/kernel-blocking-retry-snapshot.test.ts +++ b/host/test/kernel-blocking-retry-snapshot.test.ts @@ -732,6 +732,7 @@ describe("blocking retry snapshot contract", () => { "fcntl", "flock", "futex", + "ioctl", "mq_timedreceive", "mq_timedsend", "open", diff --git a/host/test/kernel-host-destroy.test.ts b/host/test/kernel-host-destroy.test.ts index 628c67bc78..d0948ac87d 100644 --- a/host/test/kernel-host-destroy.test.ts +++ b/host/test/kernel-host-destroy.test.ts @@ -67,13 +67,13 @@ describe("kernel host destroy containment boundary", () => { testable.request = vi.fn(() => new Promise(() => {})); const destroyPromise = host.destroy(); - await vi.advanceTimersByTimeAsync(2_000); + await vi.advanceTimersByTimeAsync(5_000); await destroyPromise; expect(terminate).toHaveBeenCalledOnce(); expect(testable.pendingRequests.size).toBe(0); expect(diagnostics).toEqual([ - expect.stringContaining("timed out after 2000ms"), + expect.stringContaining("timed out after 5000ms"), ]); } finally { vi.useRealTimers(); diff --git a/host/test/node-pcm-driver.test.ts b/host/test/node-pcm-driver.test.ts new file mode 100644 index 0000000000..ffa7f2e6aa --- /dev/null +++ b/host/test/node-pcm-driver.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it, vi } from "vitest"; +import { + NodePcmDriver, + type PcmDriverClock, +} from "../src/audio/node-pcm-driver"; +import { + PCM_CONTROL, + PcmStreamState, + PcmTransportFlag, + pcmControlWords, + readConsumerPosition, + readEffectiveConsumerPosition, + readPcmConfig, + readProducerPosition, + storeU32, + writeConsumerPosition, +} from "../src/audio/pcm-transport"; +import { + createPcmKernelWorker, + createPcmTransport, + writeProducer, + writeRing, +} from "./pcm-test-helpers"; + +class FakeClock implements PcmDriverClock { + time = 0; + private nextId = 1; + private callbacks = new Map void>(); + + now(): number { + return this.time; + } + + setTimeout(callback: () => void, _delayMs: number): number { + const id = this.nextId++; + this.callbacks.set(id, callback); + return id; + } + + clearTimeout(handle: unknown): void { + this.callbacks.delete(handle as number); + } + + runNext(advanceMs = 0): void { + this.time += advanceMs; + const entry = this.callbacks.entries().next().value as + [number, () => void] | undefined; + if (!entry) throw new Error("no timer scheduled"); + this.callbacks.delete(entry[0]); + entry[1](); + } + + get pending(): number { + return this.callbacks.size; + } +} + +function kernelClockUpdate( + descriptor: ReturnType, + requests: number[], +) { + return (requestedFrames: number): number => { + requests.push(requestedFrames); + const words = pcmControlWords(descriptor); + const config = readPcmConfig(words); + const consumer = readEffectiveConsumerPosition(words); + const producer = readProducerPosition(words); + const queued = Number((producer - consumer) / BigInt(config.frameBytes)); + const actual = Math.min(requestedFrames, queued); + writeConsumerPosition(words, consumer + BigInt(actual * config.frameBytes)); + return actual; + }; +} + +describe("NodePcmDriver", () => { + it("advances playback from elapsed wall-clock time, not CPU speed", async () => { + const descriptor = createPcmTransport({ fragmentBytes: 1920 }); + const bytes = new Uint8Array(480 * 4).map((_, i) => i & 0xff); + writeRing(descriptor, 0n, bytes, 4096); + writeProducer(descriptor, BigInt(bytes.byteLength)); + const clock = new FakeClock(); + const requests: number[] = []; + const events: Array<{ frames: number; bytes: Uint8Array }> = []; + const driver = new NodePcmDriver({ + clock, + clockUpdate: kernelClockUpdate(descriptor, requests), + onConsume: (event) => + events.push({ frames: event.frames, bytes: event.bytes }), + }); + + await driver.prepare(descriptor); + clock.runNext(0); + expect(requests).toEqual([]); + clock.runNext(10); + expect(requests).toEqual([480]); + expect(events[0]?.frames).toBe(480); + expect([...events[0]!.bytes]).toEqual([...bytes]); + expect(readConsumerPosition(pcmControlWords(descriptor))).toBe(1920n); + await driver.close(); + }); + + it("asks the kernel to account for underrun time but reports only queued data", async () => { + const descriptor = createPcmTransport(); + writeProducer(descriptor, 100n * 4n); + const clock = new FakeClock(); + const requests: number[] = []; + const consumed: number[] = []; + const driver = new NodePcmDriver({ + clock, + clockUpdate: kernelClockUpdate(descriptor, requests), + onConsume: (event) => consumed.push(event.frames), + }); + + await driver.prepare(descriptor); + clock.runNext(); + clock.runNext(10); + expect(requests).toEqual([480]); + expect(consumed).toEqual([100]); + expect(readConsumerPosition(pcmControlWords(descriptor))).toBe(400n); + await driver.close(); + }); + + it("preserves fractional frames so repeated short ticks do not drift", async () => { + const descriptor = createPcmTransport({ sampleRate: 44_100 }); + writeProducer(descriptor, 10_000n * 4n); + const clock = new FakeClock(); + const requests: number[] = []; + const driver = new NodePcmDriver({ + clock, + clockUpdate: kernelClockUpdate(descriptor, requests), + }); + + await driver.prepare(descriptor); + clock.runNext(); + for (let i = 0; i < 10; i++) clock.runNext(1); + expect(requests.reduce((sum, value) => sum + value, 0)).toBe(441); + await driver.close(); + }); + + it("does not report bytes from a tick whose generation changed in flight", async () => { + const descriptor = createPcmTransport(); + writeProducer(descriptor, 480n * 4n); + const words = pcmControlWords(descriptor); + const clock = new FakeClock(); + const consumed: number[] = []; + const driver = new NodePcmDriver({ + clock, + clockUpdate: (requestedFrames) => { + writeConsumerPosition(words, BigInt(requestedFrames * 4)); + storeU32(words, PCM_CONTROL.generation, 2); + return requestedFrames; + }, + onConsume: (event) => consumed.push(event.frames), + }); + + await driver.prepare(descriptor); + clock.runNext(); + clock.runNext(10); + expect(consumed).toEqual([]); + expect(driver.getState()).toBe("running"); + await driver.close(); + }); + + it("paces a draining tail without turning the expected silence into an error", async () => { + const descriptor = createPcmTransport({ state: PcmStreamState.Draining }); + writeProducer(descriptor, 100n * 4n); + const clock = new FakeClock(); + const requests: number[] = []; + const consumed: number[] = []; + const words = pcmControlWords(descriptor); + const driver = new NodePcmDriver({ + clock, + clockUpdate: (requestedFrames) => { + requests.push(requestedFrames); + writeConsumerPosition(words, 100n * 4n); + storeU32(words, PCM_CONTROL.state, PcmStreamState.Stopped); + storeU32(words, PCM_CONTROL.generation, 2); + return 100; + }, + onConsume: (event) => consumed.push(event.frames), + }); + + await driver.prepare(descriptor); + clock.runNext(); + clock.runNext(10); + expect(requests).toEqual([480]); + expect(consumed).toEqual([100]); + expect(driver.getState()).toBe("running"); + expect( + Atomics.load(words, PCM_CONTROL.flags) & PcmTransportFlag.FatalError, + ).toBe(0); + await driver.close(); + }); + + it("wakes orphan-drain reconciliation when the Node sink fails with a queued tail", async () => { + const descriptor = createPcmTransport({ state: PcmStreamState.Draining }); + writeProducer(descriptor, 960n * 4n); + const words = pcmControlWords(descriptor); + const clock = new FakeClock(); + let queuedAtFatal = 0n; + const onFatal = vi.fn(() => { + queuedAtFatal = + readProducerPosition(words) - readEffectiveConsumerPosition(words); + }); + const driver = new NodePcmDriver({ + clock, + clockUpdate: kernelClockUpdate(descriptor, []), + onFatal, + onConsume: () => { + throw new Error("sink failed"); + }, + }); + + await driver.prepare(descriptor); + clock.runNext(); + clock.runNext(10); + expect(driver.getState()).toBe("error"); + expect(clock.pending).toBe(0); + expect( + Atomics.load(words, PCM_CONTROL.flags) & PcmTransportFlag.FatalError, + ).toBe(PcmTransportFlag.FatalError); + expect(Atomics.load(words, PCM_CONTROL.wakeSeq)).toBe(1); + expect(onFatal).toHaveBeenCalledOnce(); + expect(queuedAtFatal).toBe(480n * 4n); + await driver.suspend(); + expect(driver.getState()).toBe("error"); + await expect(driver.resume()).rejects.toThrow("PCM output has failed"); + expect(onFatal).toHaveBeenCalledOnce(); + expect(clock.pending).toBe(0); + await driver.close(); + }); + + it("suspends and tears down its worker-owned timer deterministically", async () => { + const descriptor = createPcmTransport(); + const clock = new FakeClock(); + const driver = new NodePcmDriver({ + clock, + clockUpdate: () => 0, + }); + + await driver.prepare(descriptor); + expect(clock.pending).toBe(1); + await driver.suspend(); + expect(driver.getState()).toBe("suspended"); + expect(clock.pending).toBe(0); + await driver.resume(); + expect(clock.pending).toBe(1); + await driver.close(); + expect(driver.getState()).toBe("closed"); + expect(clock.pending).toBe(0); + }); + + it("wakes a pending machine-teardown drain as soon as the Node clock consumes its tail", async () => { + const { worker, descriptor } = createPcmKernelWorker({ + transport: { state: PcmStreamState.Draining }, + clockUpdate: (requestedFrames, transport) => { + const frames = Math.min(requestedFrames, 1); + const transportWords = pcmControlWords(transport); + writeConsumerPosition(transportWords, BigInt(frames * 4)); + Atomics.add(transportWords, PCM_CONTROL.wakeSeq, 1); + return frames; + }, + }); + const words = pcmControlWords(descriptor); + writeProducer(descriptor, 4n); + + // Model the Wasm clock export precisely: it advances the shared cursor and + // wake sequence, but only the host can notify a JS Atomics.waitAsync waiter. + const drain = worker.waitForPcmDrain(2_000); + worker.pcmClockUpdate(1); + + await expect( + Promise.race([ + drain.then((drained) => ({ kind: "drain", drained })), + new Promise<{ kind: "timeout" }>((resolve) => + setTimeout(() => resolve({ kind: "timeout" }), 250), + ), + ]), + ).resolves.toEqual({ kind: "drain", drained: true }); + expect(readConsumerPosition(words)).toBe(4n); + }); +}); diff --git a/host/test/pcm-audio-worklet.test.ts b/host/test/pcm-audio-worklet.test.ts new file mode 100644 index 0000000000..6c3be1f975 --- /dev/null +++ b/host/test/pcm-audio-worklet.test.ts @@ -0,0 +1,356 @@ +import { describe, expect, it } from "vitest"; +import { + PCM_CONTROL, + PcmSampleFormat, + PcmStreamState, + PcmTransportFlag, + pcmControlWords, + readConsumerPosition, + readEffectiveConsumerPosition, + storeU32, +} from "../src/audio/pcm-transport"; +// The production worklet is deliberately a self-contained JavaScript asset. +// @ts-expect-error JavaScript worklet asset has no declaration file. +import { KandeloPcmProcessor } from "../src/audio/pcm-audio-worklet.js"; +import { + createPcmTransport, + writeConsumer, + writeDiscard, + writeProducer, + writeRing, +} from "./pcm-test-helpers"; + +function processorOptions(descriptor: ReturnType) { + return { + ...descriptor, + layout: PCM_CONTROL, + formats: { + u8: PcmSampleFormat.U8, + s16le: PcmSampleFormat.S16Le, + s16be: PcmSampleFormat.S16Be, + }, + states: { + running: PcmStreamState.Running, + draining: PcmStreamState.Draining, + }, + flags: { + configuring: PcmTransportFlag.Configuring, + underrunActive: PcmTransportFlag.UnderrunActive, + fatalError: PcmTransportFlag.FatalError, + }, + outputSampleRate: 48_000, + }; +} + +function render( + processor: InstanceType, + frames: number, + channels = 2, +): Float32Array[] { + const output = Array.from( + { length: channels }, + () => new Float32Array(frames), + ); + expect(processor.process([], [output])).toBe(true); + return output; +} + +describe("Kandelo PCM AudioWorklet", () => { + it("renders idle silence without recording false underruns", () => { + const descriptor = createPcmTransport({ state: PcmStreamState.Stopped }); + const processor = new KandeloPcmProcessor({ + processorOptions: processorOptions(descriptor), + }); + expect([...render(processor, 128)[0]]).toEqual(new Array(128).fill(0)); + expect( + Atomics.load(pcmControlWords(descriptor), PCM_CONTROL.underruns), + ).toBe(0); + }); + + it("converts unsigned 8-bit mono and advances the byte cursor", () => { + const descriptor = createPcmTransport({ + activeCapacityBytes: 16, + format: PcmSampleFormat.U8, + channels: 1, + frameBytes: 1, + }); + writeRing(descriptor, 0n, Uint8Array.from([0, 64, 128, 255]), 16); + writeProducer(descriptor, 4n); + const processor = new KandeloPcmProcessor({ + processorOptions: processorOptions(descriptor), + }); + + const [left, right] = render(processor, 4); + expect([...left]).toEqual([-1, -0.5, 0, 127 / 128]); + expect([...right]).toEqual([...left]); + expect(readConsumerPosition(pcmControlWords(descriptor))).toBe(4n); + }); + + it("decodes S16LE stereo across ring wrap", () => { + const descriptor = createPcmTransport({ activeCapacityBytes: 16 }); + writeConsumer(descriptor, 12n); + writeRing( + descriptor, + 12n, + Uint8Array.from([0x00, 0x40, 0x00, 0xc0, 0xff, 0x7f, 0x00, 0x80]), + 16, + ); + writeProducer(descriptor, 20n); + const processor = new KandeloPcmProcessor({ + processorOptions: processorOptions(descriptor), + }); + + const [left, right] = render(processor, 2); + expect([...left]).toEqual([0.5, 32767 / 32768]); + expect([...right]).toEqual([-0.5, -1]); + expect(readConsumerPosition(pcmControlWords(descriptor))).toBe(20n); + }); + + it("decodes S16BE mono across ring wrap and duplicates it to stereo", () => { + const descriptor = createPcmTransport({ + activeCapacityBytes: 8, + format: PcmSampleFormat.S16Be, + channels: 1, + frameBytes: 2, + }); + writeConsumer(descriptor, 6n); + writeRing(descriptor, 6n, Uint8Array.from([0x40, 0x00, 0xc0, 0x00]), 8); + writeProducer(descriptor, 10n); + const processor = new KandeloPcmProcessor({ + processorOptions: processorOptions(descriptor), + }); + + const [left, right] = render(processor, 2); + expect([...left]).toEqual([0.5, -0.5]); + expect([...right]).toEqual([...left]); + expect(readConsumerPosition(pcmControlWords(descriptor))).toBe(10n); + }); + + it("honors reset discard positions without replaying stale bytes", () => { + const descriptor = createPcmTransport({ + activeCapacityBytes: 8, + format: PcmSampleFormat.U8, + channels: 1, + frameBytes: 1, + }); + writeRing(descriptor, 4n, Uint8Array.from([128, 255]), 8); + writeConsumer(descriptor, 0n); + writeDiscard(descriptor, 4n); + writeProducer(descriptor, 6n); + const processor = new KandeloPcmProcessor({ + processorOptions: processorOptions(descriptor), + }); + + const [left] = render(processor, 1); + expect(left[0]).toBe(0); + expect(readConsumerPosition(pcmControlWords(descriptor))).toBe(5n); + }); + + it("emits silence on underrun and counts one transition, not one quantum", () => { + const descriptor = createPcmTransport({ + activeCapacityBytes: 8, + format: PcmSampleFormat.U8, + channels: 1, + frameBytes: 1, + }); + const processor = new KandeloPcmProcessor({ + processorOptions: processorOptions(descriptor), + }); + const words = pcmControlWords(descriptor); + + expect([...render(processor, 4)[0]]).toEqual([0, 0, 0, 0]); + render(processor, 4); + expect(Atomics.load(words, PCM_CONTROL.underruns)).toBe(1); + + writeRing(descriptor, 0n, Uint8Array.from([128]), 8); + writeProducer(descriptor, 1n); + render(processor, 2); + expect(Atomics.load(words, PCM_CONTROL.underruns)).toBe(2); + }); + + it("drains a short queued tail into silence without counting an underrun", () => { + const descriptor = createPcmTransport({ + activeCapacityBytes: 8, + format: PcmSampleFormat.U8, + channels: 1, + frameBytes: 1, + state: PcmStreamState.Draining, + }); + writeRing(descriptor, 0n, Uint8Array.from([128, 255]), 8); + writeProducer(descriptor, 2n); + const processor = new KandeloPcmProcessor({ + processorOptions: processorOptions(descriptor), + }); + const words = pcmControlWords(descriptor); + + const [left] = render(processor, 8); + expect([...left.slice(0, 2)]).toEqual([0, 127 / 128]); + expect([...left.slice(2)]).toEqual(new Array(6).fill(0)); + expect(readConsumerPosition(words)).toBe(2n); + expect(Atomics.load(words, PCM_CONTROL.underruns)).toBe(0); + expect( + Atomics.load(words, PCM_CONTROL.flags) & PcmTransportFlag.UnderrunActive, + ).toBe(0); + }); + + it("resamples from the guest rate to the audio clock", () => { + const descriptor = createPcmTransport({ + activeCapacityBytes: 8, + format: PcmSampleFormat.U8, + sampleRate: 24_000, + channels: 1, + frameBytes: 1, + }); + writeRing(descriptor, 0n, Uint8Array.from([128, 255]), 8); + writeProducer(descriptor, 2n); + const processor = new KandeloPcmProcessor({ + processorOptions: processorOptions(descriptor), + }); + + const [left] = render(processor, 4); + expect(left[0]).toBe(0); + expect(left[1]).toBeCloseTo(127 / 256); + expect(left[2]).toBeCloseTo(127 / 128); + expect(readConsumerPosition(pcmControlWords(descriptor))).toBe(2n); + }); + + it("drops an in-flight quantum when reset and reopen changes generation", () => { + const descriptor = createPcmTransport({ + activeCapacityBytes: 8, + format: PcmSampleFormat.U8, + channels: 1, + frameBytes: 1, + generation: 7, + }); + writeConsumer(descriptor, 8n); + writeRing(descriptor, 8n, Uint8Array.from([0, 255]), 8); + writeProducer(descriptor, 10n); + const processor = new KandeloPcmProcessor({ + processorOptions: processorOptions(descriptor), + }); + const words = pcmControlWords(descriptor); + + // Inject RESET + final close + reopen after the old quantum has begun + // reading. The new generation keeps absolute cursors monotonic, advances + // discard to the old producer, then queues one new byte at that base. + const originalReadSample = processor.readSample.bind(processor); + let reopened = false; + processor.readSample = (absoluteByte: bigint, format: number) => { + const sample = originalReadSample(absoluteByte, format); + if (!reopened) { + reopened = true; + storeU32(words, PCM_CONTROL.state, PcmStreamState.Closed); + writeDiscard(descriptor, 10n); + storeU32(words, PCM_CONTROL.generation, 8); + writeRing(descriptor, 10n, Uint8Array.from([255]), 8); + writeProducer(descriptor, 11n); + storeU32(words, PCM_CONTROL.state, PcmStreamState.Running); + } + return sample; + }; + + const [staleLeft, staleRight] = render(processor, 4); + expect([...staleLeft]).toEqual([0, 0, 0, 0]); + expect([...staleRight]).toEqual([0, 0, 0, 0]); + expect(readConsumerPosition(words)).toBe(8n); + expect(readEffectiveConsumerPosition(words)).toBe(10n); + expect(Atomics.load(words, PCM_CONTROL.underruns)).toBe(0); + + const [freshLeft] = render(processor, 1); + expect(freshLeft[0]).toBe(127 / 128); + expect(readConsumerPosition(words)).toBe(11n); + }); + + it("rejects a quantum that overlaps a multi-field configuration update", () => { + const descriptor = createPcmTransport({ + activeCapacityBytes: 8, + format: PcmSampleFormat.U8, + channels: 1, + frameBytes: 1, + generation: 11, + }); + writeRing(descriptor, 0n, Uint8Array.from([255, 255]), 8); + writeProducer(descriptor, 2n); + const processor = new KandeloPcmProcessor({ + processorOptions: processorOptions(descriptor), + }); + const words = pcmControlWords(descriptor); + + const originalReadSample = processor.readSample.bind(processor); + let beganUpdate = false; + processor.readSample = (absoluteByte: bigint, format: number) => { + const sample = originalReadSample(absoluteByte, format); + if (!beganUpdate) { + beganUpdate = true; + Atomics.or(words, PCM_CONTROL.flags, PcmTransportFlag.Configuring); + // A torn snapshot is temporarily invalid. It must be rejected as a + // generation race, not latched as a physical-sink failure. + storeU32(words, PCM_CONTROL.channels, 3); + } + return sample; + }; + + expect([...render(processor, 2)[0]]).toEqual([0, 0]); + expect(readConsumerPosition(words)).toBe(0n); + expect( + Atomics.load(words, PCM_CONTROL.flags) & PcmTransportFlag.FatalError, + ).toBe(0); + + storeU32(words, PCM_CONTROL.channels, 1); + storeU32(words, PCM_CONTROL.generation, 12); + Atomics.and(words, PCM_CONTROL.flags, ~PcmTransportFlag.Configuring); + processor.readSample = originalReadSample; + expect(render(processor, 1)[0][0]).toBe(127 / 128); + }); + + it("latches and wakes a fatal transport error", () => { + const descriptor = createPcmTransport({ channels: 3 }); + const processor = new KandeloPcmProcessor({ + processorOptions: processorOptions(descriptor), + }); + const words = pcmControlWords(descriptor); + + render(processor, 1); + expect( + Atomics.load(words, PCM_CONTROL.flags) & PcmTransportFlag.FatalError, + ).toBe(PcmTransportFlag.FatalError); + expect(Atomics.load(words, PCM_CONTROL.wakeSeq)).toBe(1); + }); + + it("wakes both the kernel observer and teardown drain on final progress", async () => { + const descriptor = createPcmTransport({ + activeCapacityBytes: 8, + format: PcmSampleFormat.U8, + channels: 1, + frameBytes: 1, + state: PcmStreamState.Draining, + }); + writeRing(descriptor, 0n, Uint8Array.from([128]), 8); + writeProducer(descriptor, 1n); + const processor = new KandeloPcmProcessor({ + processorOptions: processorOptions(descriptor), + }); + const words = pcmControlWords(descriptor); + const observed = Atomics.load(words, PCM_CONTROL.wakeSeq); + const waiters = [ + Atomics.waitAsync(words, PCM_CONTROL.wakeSeq, observed), + Atomics.waitAsync(words, PCM_CONTROL.wakeSeq, observed), + ]; + expect(waiters.every((waiter) => waiter.async)).toBe(true); + + render(processor, 1); + let timer: ReturnType | undefined; + const wokeAll = await Promise.race([ + Promise.all(waiters.map((waiter) => Promise.resolve(waiter.value))).then( + () => true, + ), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), 100); + }), + ]); + if (timer !== undefined) clearTimeout(timer); + Atomics.notify(words, PCM_CONTROL.wakeSeq); + + expect(wokeAll).toBe(true); + }); +}); diff --git a/host/test/pcm-test-helpers.ts b/host/test/pcm-test-helpers.ts new file mode 100644 index 0000000000..6ac55e7516 --- /dev/null +++ b/host/test/pcm-test-helpers.ts @@ -0,0 +1,221 @@ +import { + PCM_CONTROL, + PCM_CONTROL_BYTES, + PCM_CONTROL_MAGIC, + PCM_CONTROL_VERSION, + PCM_PHYSICAL_CAPACITY_BYTES, + PcmSampleFormat, + PcmStreamState, + pcmControlWords, + pcmDataBytes, + storeU32, + writeConsumerPosition, + writeSeqlockedU64, + type PcmTransportDescriptor, +} from "../src/audio/pcm-transport"; +import { createCentralizedKernelWorkerTestDouble } from "../src/kernel-worker"; +import { + createKernelEntryGatedInstance, + KernelEntryGate, +} from "../src/kernel-entry-gate"; +import { allocateKernelScratchRegion } from "../src/kernel-scratch"; +import { createKernelScratchTestInstance } from "./support/kernel-scratch-instance"; + +export interface PcmTransportTestOptions { + activeCapacityBytes?: number; + format?: PcmSampleFormat; + sampleRate?: number; + channels?: number; + frameBytes?: number; + fragmentBytes?: number; + fragments?: number; + state?: PcmStreamState; + generation?: number; + flags?: number; +} + +export function createPcmTransport( + options: PcmTransportTestOptions = {}, +): PcmTransportDescriptor { + const buffer = new SharedArrayBuffer( + PCM_CONTROL_BYTES + PCM_PHYSICAL_CAPACITY_BYTES, + ); + const descriptor: PcmTransportDescriptor = { + buffer, + controlOffset: 0, + controlBytes: PCM_CONTROL_BYTES, + dataOffset: PCM_CONTROL_BYTES, + dataBytes: PCM_PHYSICAL_CAPACITY_BYTES, + }; + const words = pcmControlWords(descriptor); + storeU32(words, PCM_CONTROL.magic, PCM_CONTROL_MAGIC); + storeU32(words, PCM_CONTROL.version, PCM_CONTROL_VERSION); + storeU32(words, PCM_CONTROL.headerBytes, PCM_CONTROL_BYTES); + storeU32( + words, + PCM_CONTROL.physicalCapacityBytes, + PCM_PHYSICAL_CAPACITY_BYTES, + ); + storeU32( + words, + PCM_CONTROL.activeCapacityBytes, + options.activeCapacityBytes ?? 4096, + ); + storeU32(words, PCM_CONTROL.format, options.format ?? PcmSampleFormat.S16Le); + storeU32(words, PCM_CONTROL.sampleRate, options.sampleRate ?? 48_000); + storeU32(words, PCM_CONTROL.channels, options.channels ?? 2); + storeU32(words, PCM_CONTROL.frameBytes, options.frameBytes ?? 4); + storeU32(words, PCM_CONTROL.fragmentBytes, options.fragmentBytes ?? 512); + storeU32(words, PCM_CONTROL.fragments, options.fragments ?? 8); + storeU32(words, PCM_CONTROL.state, options.state ?? PcmStreamState.Running); + storeU32(words, PCM_CONTROL.generation, options.generation ?? 1); + storeU32(words, PCM_CONTROL.flags, options.flags ?? 0); + return descriptor; +} + +const PCM_TEST_CONTROL_OFFSET = 4096; +const PCM_TEST_SCRATCH_OFFSET = 2 * 65_536; +const PCM_TEST_SCRATCH_CAPACITY = 65_536; + +/** + * Build a genuine gated Wasm generation around the PCM test transport. + * + * WHY: the integration worker deliberately seals its entry surface and keeps + * the kernel/gate as JavaScript private fields. PCM tests must exercise that + * production authority boundary instead of manufacturing an unbranded object + * with replaceable pseudo-kernel methods. + */ +export function createPcmKernelWorker(options: { + transport?: PcmTransportTestOptions; + observeConsumerWake?: boolean; + beforeClaim?: (descriptor: PcmTransportDescriptor) => void; + claimTransport?: ( + mode: number, + descriptor: PcmTransportDescriptor, + ) => number; + reconcile?: (descriptor: PcmTransportDescriptor) => number; + clockUpdate?: ( + requestedFrames: number, + descriptor: PcmTransportDescriptor, + ) => number; +} = {}): { + readonly worker: ReturnType; + readonly descriptor: PcmTransportDescriptor; + readonly gate: KernelEntryGate; +} { + const memory = new WebAssembly.Memory({ + initial: 3, + maximum: 3, + shared: true, + }); + const buffer = memory.buffer as SharedArrayBuffer; + const template = createPcmTransport(options.transport); + const totalBytes = PCM_CONTROL_BYTES + PCM_PHYSICAL_CAPACITY_BYTES; + new Uint8Array(buffer, PCM_TEST_CONTROL_OFFSET, totalBytes).set( + new Uint8Array(template.buffer), + ); + const transport: PcmTransportDescriptor = { + buffer, + controlOffset: PCM_TEST_CONTROL_OFFSET, + controlBytes: PCM_CONTROL_BYTES, + dataOffset: PCM_TEST_CONTROL_OFFSET + PCM_CONTROL_BYTES, + dataBytes: PCM_PHYSICAL_CAPACITY_BYTES, + }; + options.beforeClaim?.(transport); + + const implementations: Record = { + kernel_drain_wakeup_events: () => 0, + kernel_pcm_transport_ptr: () => PCM_TEST_CONTROL_OFFSET, + kernel_pcm_transport_len: () => totalBytes, + kernel_pcm_claim_transport: (mode: number) => + options.claimTransport?.(mode, transport) ?? 0, + kernel_pcm_reconcile: () => options.reconcile?.(transport) ?? 0, + kernel_pcm_clock_update: (requestedFrames: number) => + options.clockUpdate?.(requestedFrames >>> 0, transport) ?? 0, + }; + const gate = new KernelEntryGate(); + const rawInstance = createKernelScratchTestInstance( + 4, + memory, + () => implementations, + () => PCM_TEST_SCRATCH_OFFSET, + 4, + [ + "kernel_drain_wakeup_events", + "kernel_pcm_claim_transport", + "kernel_pcm_clock_update", + "kernel_pcm_reconcile", + "kernel_pcm_transport_len", + "kernel_pcm_transport_ptr", + ], + ); + const instance = createKernelEntryGatedInstance(rawInstance, gate); + const scratch = allocateKernelScratchRegion( + memory, + instance.exports.kernel_alloc_scratch as (capacity: number) => number, + PCM_TEST_SCRATCH_CAPACITY, + 4, + "PCM worker test scratch", + instance, + ); + const worker = createCentralizedKernelWorkerTestDouble(); + worker.testAuthority.initializeKernelForTest({ + instance, + gate, + mainScratch: scratch, + tcpScratch: scratch, + }); + const descriptor = worker.claimPcmTransport( + options.observeConsumerWake ?? false, + ); + return { worker, descriptor, gate }; +} + +export function writeProducer( + descriptor: PcmTransportDescriptor, + value: bigint, +): void { + const words = pcmControlWords(descriptor); + writeSeqlockedU64( + words, + PCM_CONTROL.producerSeq, + PCM_CONTROL.producerLo, + PCM_CONTROL.producerHi, + value, + ); +} + +export function writeConsumer( + descriptor: PcmTransportDescriptor, + value: bigint, +): void { + writeConsumerPosition(pcmControlWords(descriptor), value); +} + +export function writeDiscard( + descriptor: PcmTransportDescriptor, + value: bigint, +): void { + const words = pcmControlWords(descriptor); + writeSeqlockedU64( + words, + PCM_CONTROL.discardSeq, + PCM_CONTROL.discardLo, + PCM_CONTROL.discardHi, + value, + ); +} + +export function writeRing( + descriptor: PcmTransportDescriptor, + absoluteOffset: bigint, + bytes: Uint8Array, + activeCapacityBytes: number, +): void { + const ring = pcmDataBytes(descriptor); + let at = Number(absoluteOffset % BigInt(activeCapacityBytes)); + for (const byte of bytes) { + ring[at] = byte; + at = (at + 1) % activeCapacityBytes; + } +} diff --git a/host/test/pcm-transport.test.ts b/host/test/pcm-transport.test.ts new file mode 100644 index 0000000000..52b16cdc3d --- /dev/null +++ b/host/test/pcm-transport.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { PCM_SHARED_CONTROL_FIELDS } from "../src/generated/abi"; +import { + PCM_CONTROL, + readRingBytes, + readSeqlockedU64, + signalPcmConsumerProgress, + writeSeqlockedU64, +} from "../src/audio/pcm-transport"; + +describe("PCM shared transport", () => { + it("derives every AudioWorklet word index from generated ABI offsets", () => { + for (const key of Object.keys(PCM_CONTROL) as Array) { + expect(PCM_CONTROL[key]).toBe(PCM_SHARED_CONTROL_FIELDS[key].offset / 4); + expect(PCM_SHARED_CONTROL_FIELDS[key].size).toBe(4); + } + }); + + it("reads and writes 64-bit cursors through 32-bit seqlocks", () => { + const words = new Int32Array(new SharedArrayBuffer(16)); + const value = 0x1234_5678_abcd_ef01n; + writeSeqlockedU64(words, 0, 1, 2, value); + expect(readSeqlockedU64(words, 0, 1, 2)).toBe(value); + expect(Atomics.load(words, 0) & 1).toBe(0); + }); + + it("copies bytes across a bounded ring wrap", () => { + const ring = Uint8Array.from([0, 1, 2, 3, 4, 5, 6, 7]); + expect([...readRingBytes(ring, 6n, 5)]).toEqual([6, 7, 0, 1, 2]); + }); + + it("wakes every waiter on a one-shot consumer transition", async () => { + const words = new Int32Array(new SharedArrayBuffer(256)); + const observed = Atomics.load(words, PCM_CONTROL.wakeSeq); + const waiters = [ + Atomics.waitAsync(words, PCM_CONTROL.wakeSeq, observed), + Atomics.waitAsync(words, PCM_CONTROL.wakeSeq, observed), + ]; + expect(waiters.every((waiter) => waiter.async)).toBe(true); + + signalPcmConsumerProgress(words); + let timer: ReturnType | undefined; + const wokeAll = await Promise.race([ + Promise.all(waiters.map((waiter) => Promise.resolve(waiter.value))).then( + () => true, + ), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), 100); + }), + ]); + if (timer !== undefined) clearTimeout(timer); + // Clean up the remaining waiter if this assertion ever regresses. + Atomics.notify(words, PCM_CONTROL.wakeSeq); + + expect(wokeAll).toBe(true); + }); +}); diff --git a/host/test/pcm-wake-observer.test.ts b/host/test/pcm-wake-observer.test.ts new file mode 100644 index 0000000000..de8b7e114b --- /dev/null +++ b/host/test/pcm-wake-observer.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from "vitest"; +import { + PCM_CONTROL, + PcmStreamState, + pcmControlWords, + signalPcmConsumerProgress, + writeConsumerPosition, + type PcmTransportDescriptor, +} from "../src/audio/pcm-transport"; +import { + createPcmKernelWorker, + writeProducer, +} from "./pcm-test-helpers"; + +describe("PCM wake observation", () => { + it("reconciles final-quantum progress without requiring a later wake", async () => { + const reconcile = vi.fn((descriptor: PcmTransportDescriptor) => { + const words = pcmControlWords(descriptor); + if (reconcile.mock.calls.length === 1) { + // Model the worklet publishing the final cursor while the preceding + // reconciliation is in flight. Its notification may run before the + // observer arms again, so the remembered sequence must cause a retry. + writeConsumerPosition(words, 4n); + signalPcmConsumerProgress(words); + } + return 0; + }); + const { worker, descriptor } = createPcmKernelWorker({ + transport: { state: PcmStreamState.Draining }, + beforeClaim: (transport) => writeProducer(transport, 4n), + reconcile, + observeConsumerWake: true, + }); + const words = pcmControlWords(descriptor); + + // The first pass observes the progress race; the sequence recheck drives + // the second reconciliation synchronously without another notification. + expect(reconcile).toHaveBeenCalledTimes(2); + expect(Atomics.load(words, PCM_CONTROL.wakeSeq)).toBe(1); + + worker.shutdownPcmTransport(); + await Promise.resolve(); + }); + + it("does not sleep when progress lands between the drain check and wait", async () => { + const { worker, descriptor } = createPcmKernelWorker({ + transport: { state: PcmStreamState.Draining }, + }); + const words = pcmControlWords(descriptor); + writeProducer(descriptor, 4n); + const originalLoad = Atomics.load.bind(Atomics); + let wakeSequenceLoads = 0; + const loadSpy = vi.spyOn(Atomics, "load").mockImplementation( + ((array: Int32Array, index: number) => { + if ( + array.buffer === words.buffer && + array.byteOffset === words.byteOffset && + index === PCM_CONTROL.wakeSeq + ) { + wakeSequenceLoads++; + if (wakeSequenceLoads === 2) { + // The first load remembers the prior sequence. Inject the final + // cursor at the explicit pre-wait recheck to exercise the window + // that used to lose this one-shot notification. + writeConsumerPosition(words, 4n); + signalPcmConsumerProgress(words); + } + } + return originalLoad(array, index); + }) as typeof Atomics.load, + ); + + let settledWithoutAnotherWake: boolean | undefined; + try { + let settled: boolean | undefined; + const drain = worker.waitForPcmDrain(1_000).then((value) => { + settled = value; + return value; + }); + await Promise.resolve(); + settledWithoutAnotherWake = settled; + + // Keep a failing implementation from leaving a live waiter behind. + if (settled === undefined) { + writeConsumerPosition(words, 4n); + signalPcmConsumerProgress(words); + await drain; + } + } finally { + loadSpy.mockRestore(); + } + + expect(wakeSequenceLoads).toBeGreaterThanOrEqual(2); + expect(settledWithoutAnotherWake).toBe(true); + }); +}); diff --git a/host/test/support/kernel-scratch-instance.ts b/host/test/support/kernel-scratch-instance.ts index 87a5ffbce1..59794290ed 100644 --- a/host/test/support/kernel-scratch-instance.ts +++ b/host/test/support/kernel-scratch-instance.ts @@ -303,6 +303,26 @@ function signatures( parameters: [i32, i32, pointer, i32], result: i32, }, + kernel_pcm_claim_transport: { + parameters: [i32], + result: i32, + }, + kernel_pcm_clock_update: { + parameters: [i32], + result: i32, + }, + kernel_pcm_reconcile: { + parameters: [], + result: i32, + }, + kernel_pcm_transport_len: { + parameters: [], + result: i32, + }, + kernel_pcm_transport_ptr: { + parameters: [], + result: i32, + }, kernel_poll: { parameters: [pointer, i32, i32, i32], result: i32, diff --git a/host/tsup.config.ts b/host/tsup.config.ts index 05033040f8..aeb6ecb8b4 100644 --- a/host/tsup.config.ts +++ b/host/tsup.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from "tsup"; -import { dirname } from "node:path"; +import { copyFileSync, mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { hostBuildFingerprintBanner } from "./src/compiled-worker-entry"; @@ -28,4 +29,12 @@ export default defineConfig({ banner: { js: hostBuildFingerprintBanner(hostRoot), }, + onSuccess: async () => { + const outputDir = resolve(hostRoot, "dist/audio"); + mkdirSync(outputDir, { recursive: true }); + copyFileSync( + resolve(hostRoot, "src/audio/pcm-audio-worklet.js"), + resolve(outputDir, "pcm-audio-worklet.js"), + ); + }, }); diff --git a/libc/glue/abi_constants.h b/libc/glue/abi_constants.h index 4e59950c40..472aa96a4b 100644 --- a/libc/glue/abi_constants.h +++ b/libc/glue/abi_constants.h @@ -151,6 +151,22 @@ WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; case 0x00005001u: return pointer_width == 4u ? 0u : pointer_width == 8u ? 0u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00005008u: +return pointer_width == 4u ? 0u : +pointer_width == 8u ? 0u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x0000500eu: +return pointer_width == 4u ? 0u : +pointer_width == 8u ? 0u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00005015u: +return pointer_width == 4u ? 0u : +pointer_width == 8u ? 0u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x00005016u: +return pointer_width == 4u ? 0u : +pointer_width == 8u ? 0u : WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; case 0x00005401u: return pointer_width == 4u ? 60u : @@ -239,6 +255,14 @@ WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; case 0x00008905u: return pointer_width == 4u ? 4u : pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x40045004u: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x40045010u: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; case 0x40045431u: return pointer_width == 4u ? 4u : @@ -247,14 +271,66 @@ WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; case 0x40086409u: return pointer_width == 4u ? 8u : pointer_width == 8u ? 8u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x80045002u: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x80045005u: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x80045006u: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x80045007u: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; case 0x8004500bu: return pointer_width == 4u ? 4u : pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x8004500fu: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x80045010u: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x80045017u: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; case 0x80045430u: return pointer_width == 4u ? 4u : pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x80085013u: +return pointer_width == 4u ? 8u : +pointer_width == 8u ? 8u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x80085014u: +return pointer_width == 4u ? 8u : +pointer_width == 8u ? 8u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x800c5011u: +return pointer_width == 4u ? 12u : +pointer_width == 8u ? 12u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x800c5012u: +return pointer_width == 4u ? 12u : +pointer_width == 8u ? 12u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x8010500cu: +return pointer_width == 4u ? 16u : +pointer_width == 8u ? 16u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0x8010500du: +return pointer_width == 4u ? 16u : +pointer_width == 8u ? 16u : WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; case 0xc0045002u: return pointer_width == 4u ? 4u : @@ -263,6 +339,10 @@ WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; case 0xc0045003u: return pointer_width == 4u ? 4u : pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0xc0045004u: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; case 0xc0045005u: return pointer_width == 4u ? 4u : @@ -271,6 +351,14 @@ WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; case 0xc0045006u: return pointer_width == 4u ? 4u : pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0xc0045007u: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : +WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; + case 0xc0045009u: +return pointer_width == 4u ? 4u : +pointer_width == 8u ? 4u : WASM_POSIX_IOCTL_UNSUPPORTED_SIZE; case 0xc004500au: return pointer_width == 4u ? 4u : diff --git a/libc/glue/channel_syscall.c b/libc/glue/channel_syscall.c index 04204dceda..7144fb6bbd 100644 --- a/libc/glue/channel_syscall.c +++ b/libc/glue/channel_syscall.c @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include "abi_constants.h" @@ -186,6 +187,9 @@ static int kandelo_should_restart_after_handler( case __NR_mq_timedsend: case __NR_mq_timedreceive: return 1; + case __NR_ioctl: + /* OSS output drain is a zero-progress slow-device wait. */ + return (uint32_t)a2 == SNDCTL_DSP_SYNC; case __NR_fcntl: /* * musl aliases the feature-gated F_SETLKW64 spelling to this same @@ -758,10 +762,12 @@ static long __do_syscall_impl(long n, long long a1, long long a2, long long a3, &delivered_signal ); - /* A host-deferred blocking operation completes the channel with EINTR so - * the caught handler runs at the real interruption boundary. SA_RESTART - * resubmits only the explicitly classified zero-progress operations after - * handler mask restoration and cancellation preflight. */ + /* A host-deferred blocking operation or slow PCM drain completes the + * channel with EINTR so the caught handler runs at the real interruption + * boundary. SA_RESTART resubmits only explicitly classified zero-progress + * operations after handler mask restoration and cancellation preflight. + * An interrupted final /dev/dsp close deliberately remains non-restarted: + * its fd stays valid for an explicit caller retry. */ if (err == EINTR && delivered_signal && (delivered_flags & SA_RESTART) != 0 && kandelo_should_restart_after_handler(n, a1, a2, a3, a4, a5, a6)) { diff --git a/libc/musl-overlay/include/sys/soundcard.h b/libc/musl-overlay/include/sys/soundcard.h new file mode 100644 index 0000000000..efc66e5b94 --- /dev/null +++ b/libc/musl-overlay/include/sys/soundcard.h @@ -0,0 +1,269 @@ +/* GENERATED by `cargo xtask dump-abi`. Do not edit by hand. */ +/* Kandelo-owned wasm32 OSS source ABI; independent of the host UAPI. */ +#ifndef KANDELO_SYS_SOUNDCARD_H +#define KANDELO_SYS_SOUNDCARD_H + +#include +#include + +#define AFMT_QUERY 0x00000000u +#define AFMT_MU_LAW 0x00000001u +#define AFMT_A_LAW 0x00000002u +#define AFMT_IMA_ADPCM 0x00000004u +#define AFMT_U8 0x00000008u +#define AFMT_S16_LE 0x00000010u +#define AFMT_S16_BE 0x00000020u +#define AFMT_S8 0x00000040u +#define AFMT_U16_LE 0x00000080u +#define AFMT_U16_BE 0x00000100u +#define AFMT_MPEG 0x00000200u +#define AFMT_AC3 0x00000400u +#define AFMT_S32_LE 0x00001000u +#define AFMT_S32_BE 0x00002000u +#define AFMT_U32_LE 0x00004000u +#define AFMT_U32_BE 0x00008000u +#define AFMT_S24_LE 0x00010000u +#define AFMT_S24_BE 0x00020000u +#define AFMT_U24_LE 0x00040000u +#define AFMT_U24_BE 0x00080000u +#define AFMT_F32_LE 0x10000000u +#define AFMT_F32_BE 0x20000000u +#define AFMT_S16_NE AFMT_S16_LE +#define AFMT_S16_OE AFMT_S16_BE +#define AFMT_S24_NE AFMT_S24_LE +#define AFMT_S24_OE AFMT_S24_BE +#define AFMT_S32_NE AFMT_S32_LE +#define AFMT_S32_OE AFMT_S32_BE +#define AFMT_U16_NE AFMT_U16_LE +#define AFMT_U16_OE AFMT_U16_BE +#define AFMT_U24_NE AFMT_U24_LE +#define AFMT_U24_OE AFMT_U24_BE +#define AFMT_U32_NE AFMT_U32_LE +#define AFMT_U32_OE AFMT_U32_BE +#define AFMT_F32_NE AFMT_F32_LE +#define AFMT_F32_OE AFMT_F32_BE +#define AFMT_FLOAT AFMT_F32_NE + +typedef struct audio_buf_info { + int32_t fragments; + int32_t fragstotal; + int32_t fragsize; + int32_t bytes; +} audio_buf_info; + +typedef struct count_info { + int32_t bytes; + int32_t blocks; + int32_t ptr; +} count_info; + +/* Declared for source compatibility; Kandelo does not support DSP mmap. */ +typedef struct buffmem_desc { + void *buffer; + int32_t size; +} buffmem_desc; + +#define SNDCTL_DSP_RESET 0x00005000u +#define SNDCTL_DSP_SYNC 0x00005001u +#define SNDCTL_DSP_SPEED 0xc0045002u +#define SNDCTL_DSP_STEREO 0xc0045003u +#define SNDCTL_DSP_GETBLKSIZE 0xc0045004u +#define SNDCTL_DSP_SETBLKSIZE 0x40045004u +#define SNDCTL_DSP_SETFMT 0xc0045005u +#define SNDCTL_DSP_CHANNELS 0xc0045006u +#define SOUND_PCM_WRITE_FILTER 0xc0045007u +#define SNDCTL_DSP_POST 0x00005008u +#define SNDCTL_DSP_SUBDIVIDE 0xc0045009u +#define SNDCTL_DSP_SETFRAGMENT 0xc004500au +#define SNDCTL_DSP_GETFMTS 0x8004500bu +#define SNDCTL_DSP_GETOSPACE 0x8010500cu +#define SNDCTL_DSP_GETISPACE 0x8010500du +#define SNDCTL_DSP_NONBLOCK 0x0000500eu +#define SNDCTL_DSP_GETCAPS 0x8004500fu +#define SNDCTL_DSP_SETTRIGGER 0x40045010u +#define SNDCTL_DSP_GETTRIGGER 0x80045010u +#define SNDCTL_DSP_GETIPTR 0x800c5011u +#define SNDCTL_DSP_GETOPTR 0x800c5012u +#define SNDCTL_DSP_MAPINBUF 0x80085013u +#define SNDCTL_DSP_MAPOUTBUF 0x80085014u +#define SNDCTL_DSP_SETSYNCRO 0x00005015u +#define SNDCTL_DSP_SETDUPLEX 0x00005016u +#define SNDCTL_DSP_GETODELAY 0x80045017u +#define SOUND_PCM_READ_RATE 0x80045002u +#define SOUND_PCM_READ_BITS 0x80045005u +#define SOUND_PCM_READ_CHANNELS 0x80045006u +#define SOUND_PCM_READ_FILTER 0x80045007u +#define SNDCTL_DSP_HALT SNDCTL_DSP_RESET +#define SNDCTL_DSP_SAMPLESIZE SNDCTL_DSP_SETFMT +#define SOUND_PCM_WRITE_RATE SNDCTL_DSP_SPEED +#define SOUND_PCM_WRITE_CHANNELS SNDCTL_DSP_CHANNELS +#define SOUND_PCM_WRITE_BITS SNDCTL_DSP_SETFMT +#define SOUND_PCM_SETFMT SNDCTL_DSP_SETFMT +#define SOUND_PCM_POST SNDCTL_DSP_POST +#define SOUND_PCM_RESET SNDCTL_DSP_RESET +#define SOUND_PCM_SYNC SNDCTL_DSP_SYNC +#define SOUND_PCM_SUBDIVIDE SNDCTL_DSP_SUBDIVIDE +#define SOUND_PCM_SETFRAGMENT SNDCTL_DSP_SETFRAGMENT +#define SOUND_PCM_GETFMTS SNDCTL_DSP_GETFMTS +#define SOUND_PCM_GETOSPACE SNDCTL_DSP_GETOSPACE +#define SOUND_PCM_GETISPACE SNDCTL_DSP_GETISPACE +#define SOUND_PCM_NONBLOCK SNDCTL_DSP_NONBLOCK +#define SOUND_PCM_GETCAPS SNDCTL_DSP_GETCAPS +#define SOUND_PCM_GETTRIGGER SNDCTL_DSP_GETTRIGGER +#define SOUND_PCM_SETTRIGGER SNDCTL_DSP_SETTRIGGER +#define SOUND_PCM_SETSYNCRO SNDCTL_DSP_SETSYNCRO +#define SOUND_PCM_GETIPTR SNDCTL_DSP_GETIPTR +#define SOUND_PCM_GETOPTR SNDCTL_DSP_GETOPTR +#define SOUND_PCM_MAPINBUF SNDCTL_DSP_MAPINBUF +#define SOUND_PCM_MAPOUTBUF SNDCTL_DSP_MAPOUTBUF + +#define PCM_ENABLE_INPUT 0x00000001u +#define PCM_ENABLE_OUTPUT 0x00000002u + +#define PCM_CAP_REVISION 0x000000ffu +#define PCM_CAP_DUPLEX 0x00000100u +#define PCM_CAP_REALTIME 0x00000200u +#define PCM_CAP_BATCH 0x00000400u +#define PCM_CAP_COPROC 0x00000800u +#define PCM_CAP_TRIGGER 0x00001000u +#define PCM_CAP_MMAP 0x00002000u +#define PCM_CAP_MULTI 0x00004000u +#define PCM_CAP_BIND 0x00008000u +#define PCM_CAP_INPUT 0x00010000u +#define PCM_CAP_OUTPUT 0x00020000u +#define PCM_CAP_VIRTUAL 0x00040000u +#define PCM_CAP_DEFAULT 0x40000000u +#define DSP_CAP_REVISION PCM_CAP_REVISION +#define DSP_CAP_DUPLEX PCM_CAP_DUPLEX +#define DSP_CAP_REALTIME PCM_CAP_REALTIME +#define DSP_CAP_BATCH PCM_CAP_BATCH +#define DSP_CAP_COPROC PCM_CAP_COPROC +#define DSP_CAP_TRIGGER PCM_CAP_TRIGGER +#define DSP_CAP_MMAP PCM_CAP_MMAP +#define DSP_CAP_MULTI PCM_CAP_MULTI +#define DSP_CAP_BIND PCM_CAP_BIND +#define DSP_CAP_INPUT PCM_CAP_INPUT +#define DSP_CAP_OUTPUT PCM_CAP_OUTPUT +#define DSP_CAP_VIRTUAL PCM_CAP_VIRTUAL +#define DSP_CAP_DEFAULT PCM_CAP_DEFAULT + +#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L +_Static_assert(AFMT_QUERY == 0x00000000u, "AFMT_QUERY ABI"); +_Static_assert(AFMT_MU_LAW == 0x00000001u, "AFMT_MU_LAW ABI"); +_Static_assert(AFMT_A_LAW == 0x00000002u, "AFMT_A_LAW ABI"); +_Static_assert(AFMT_IMA_ADPCM == 0x00000004u, "AFMT_IMA_ADPCM ABI"); +_Static_assert(AFMT_U8 == 0x00000008u, "AFMT_U8 ABI"); +_Static_assert(AFMT_S16_LE == 0x00000010u, "AFMT_S16_LE ABI"); +_Static_assert(AFMT_S16_BE == 0x00000020u, "AFMT_S16_BE ABI"); +_Static_assert(AFMT_S8 == 0x00000040u, "AFMT_S8 ABI"); +_Static_assert(AFMT_U16_LE == 0x00000080u, "AFMT_U16_LE ABI"); +_Static_assert(AFMT_U16_BE == 0x00000100u, "AFMT_U16_BE ABI"); +_Static_assert(AFMT_MPEG == 0x00000200u, "AFMT_MPEG ABI"); +_Static_assert(AFMT_AC3 == 0x00000400u, "AFMT_AC3 ABI"); +_Static_assert(AFMT_S32_LE == 0x00001000u, "AFMT_S32_LE ABI"); +_Static_assert(AFMT_S32_BE == 0x00002000u, "AFMT_S32_BE ABI"); +_Static_assert(AFMT_U32_LE == 0x00004000u, "AFMT_U32_LE ABI"); +_Static_assert(AFMT_U32_BE == 0x00008000u, "AFMT_U32_BE ABI"); +_Static_assert(AFMT_S24_LE == 0x00010000u, "AFMT_S24_LE ABI"); +_Static_assert(AFMT_S24_BE == 0x00020000u, "AFMT_S24_BE ABI"); +_Static_assert(AFMT_U24_LE == 0x00040000u, "AFMT_U24_LE ABI"); +_Static_assert(AFMT_U24_BE == 0x00080000u, "AFMT_U24_BE ABI"); +_Static_assert(AFMT_F32_LE == 0x10000000u, "AFMT_F32_LE ABI"); +_Static_assert(AFMT_F32_BE == 0x20000000u, "AFMT_F32_BE ABI"); +_Static_assert(SNDCTL_DSP_RESET == 0x00005000u, "SNDCTL_DSP_RESET ABI"); +_Static_assert(SNDCTL_DSP_SYNC == 0x00005001u, "SNDCTL_DSP_SYNC ABI"); +_Static_assert(SNDCTL_DSP_SPEED == 0xc0045002u, "SNDCTL_DSP_SPEED ABI"); +_Static_assert(SNDCTL_DSP_STEREO == 0xc0045003u, "SNDCTL_DSP_STEREO ABI"); +_Static_assert(SNDCTL_DSP_GETBLKSIZE == 0xc0045004u, "SNDCTL_DSP_GETBLKSIZE ABI"); +_Static_assert(SNDCTL_DSP_SETBLKSIZE == 0x40045004u, "SNDCTL_DSP_SETBLKSIZE ABI"); +_Static_assert(SNDCTL_DSP_SETFMT == 0xc0045005u, "SNDCTL_DSP_SETFMT ABI"); +_Static_assert(SNDCTL_DSP_CHANNELS == 0xc0045006u, "SNDCTL_DSP_CHANNELS ABI"); +_Static_assert(SOUND_PCM_WRITE_FILTER == 0xc0045007u, "SOUND_PCM_WRITE_FILTER ABI"); +_Static_assert(SNDCTL_DSP_POST == 0x00005008u, "SNDCTL_DSP_POST ABI"); +_Static_assert(SNDCTL_DSP_SUBDIVIDE == 0xc0045009u, "SNDCTL_DSP_SUBDIVIDE ABI"); +_Static_assert(SNDCTL_DSP_SETFRAGMENT == 0xc004500au, "SNDCTL_DSP_SETFRAGMENT ABI"); +_Static_assert(SNDCTL_DSP_GETFMTS == 0x8004500bu, "SNDCTL_DSP_GETFMTS ABI"); +_Static_assert(SNDCTL_DSP_GETOSPACE == 0x8010500cu, "SNDCTL_DSP_GETOSPACE ABI"); +_Static_assert(SNDCTL_DSP_GETISPACE == 0x8010500du, "SNDCTL_DSP_GETISPACE ABI"); +_Static_assert(SNDCTL_DSP_NONBLOCK == 0x0000500eu, "SNDCTL_DSP_NONBLOCK ABI"); +_Static_assert(SNDCTL_DSP_GETCAPS == 0x8004500fu, "SNDCTL_DSP_GETCAPS ABI"); +_Static_assert(SNDCTL_DSP_SETTRIGGER == 0x40045010u, "SNDCTL_DSP_SETTRIGGER ABI"); +_Static_assert(SNDCTL_DSP_GETTRIGGER == 0x80045010u, "SNDCTL_DSP_GETTRIGGER ABI"); +_Static_assert(SNDCTL_DSP_GETIPTR == 0x800c5011u, "SNDCTL_DSP_GETIPTR ABI"); +_Static_assert(SNDCTL_DSP_GETOPTR == 0x800c5012u, "SNDCTL_DSP_GETOPTR ABI"); +_Static_assert(SNDCTL_DSP_MAPINBUF == 0x80085013u, "SNDCTL_DSP_MAPINBUF ABI"); +_Static_assert(SNDCTL_DSP_MAPOUTBUF == 0x80085014u, "SNDCTL_DSP_MAPOUTBUF ABI"); +_Static_assert(SNDCTL_DSP_SETSYNCRO == 0x00005015u, "SNDCTL_DSP_SETSYNCRO ABI"); +_Static_assert(SNDCTL_DSP_SETDUPLEX == 0x00005016u, "SNDCTL_DSP_SETDUPLEX ABI"); +_Static_assert(SNDCTL_DSP_GETODELAY == 0x80045017u, "SNDCTL_DSP_GETODELAY ABI"); +_Static_assert(SOUND_PCM_READ_RATE == 0x80045002u, "SOUND_PCM_READ_RATE ABI"); +_Static_assert(SOUND_PCM_READ_BITS == 0x80045005u, "SOUND_PCM_READ_BITS ABI"); +_Static_assert(SOUND_PCM_READ_CHANNELS == 0x80045006u, "SOUND_PCM_READ_CHANNELS ABI"); +_Static_assert(SOUND_PCM_READ_FILTER == 0x80045007u, "SOUND_PCM_READ_FILTER ABI"); +_Static_assert(PCM_ENABLE_INPUT == 0x00000001u, "PCM_ENABLE_INPUT ABI"); +_Static_assert(PCM_ENABLE_OUTPUT == 0x00000002u, "PCM_ENABLE_OUTPUT ABI"); +_Static_assert(PCM_CAP_REVISION == 0x000000ffu, "PCM_CAP_REVISION ABI"); +_Static_assert(PCM_CAP_DUPLEX == 0x00000100u, "PCM_CAP_DUPLEX ABI"); +_Static_assert(PCM_CAP_REALTIME == 0x00000200u, "PCM_CAP_REALTIME ABI"); +_Static_assert(PCM_CAP_BATCH == 0x00000400u, "PCM_CAP_BATCH ABI"); +_Static_assert(PCM_CAP_COPROC == 0x00000800u, "PCM_CAP_COPROC ABI"); +_Static_assert(PCM_CAP_TRIGGER == 0x00001000u, "PCM_CAP_TRIGGER ABI"); +_Static_assert(PCM_CAP_MMAP == 0x00002000u, "PCM_CAP_MMAP ABI"); +_Static_assert(PCM_CAP_MULTI == 0x00004000u, "PCM_CAP_MULTI ABI"); +_Static_assert(PCM_CAP_BIND == 0x00008000u, "PCM_CAP_BIND ABI"); +_Static_assert(PCM_CAP_INPUT == 0x00010000u, "PCM_CAP_INPUT ABI"); +_Static_assert(PCM_CAP_OUTPUT == 0x00020000u, "PCM_CAP_OUTPUT ABI"); +_Static_assert(PCM_CAP_VIRTUAL == 0x00040000u, "PCM_CAP_VIRTUAL ABI"); +_Static_assert(PCM_CAP_DEFAULT == 0x40000000u, "PCM_CAP_DEFAULT ABI"); +_Static_assert(SNDCTL_DSP_HALT == SNDCTL_DSP_RESET, "SNDCTL_DSP_HALT source alias"); +_Static_assert(SNDCTL_DSP_SAMPLESIZE == SNDCTL_DSP_SETFMT, "SNDCTL_DSP_SAMPLESIZE source alias"); +_Static_assert(SOUND_PCM_WRITE_RATE == SNDCTL_DSP_SPEED, "SOUND_PCM_WRITE_RATE source alias"); +_Static_assert(SOUND_PCM_WRITE_CHANNELS == SNDCTL_DSP_CHANNELS, "SOUND_PCM_WRITE_CHANNELS source alias"); +_Static_assert(SOUND_PCM_WRITE_BITS == SNDCTL_DSP_SETFMT, "SOUND_PCM_WRITE_BITS source alias"); +_Static_assert(SOUND_PCM_SETFMT == SNDCTL_DSP_SETFMT, "SOUND_PCM_SETFMT source alias"); +_Static_assert(SOUND_PCM_POST == SNDCTL_DSP_POST, "SOUND_PCM_POST source alias"); +_Static_assert(SOUND_PCM_RESET == SNDCTL_DSP_RESET, "SOUND_PCM_RESET source alias"); +_Static_assert(SOUND_PCM_SYNC == SNDCTL_DSP_SYNC, "SOUND_PCM_SYNC source alias"); +_Static_assert(SOUND_PCM_SUBDIVIDE == SNDCTL_DSP_SUBDIVIDE, "SOUND_PCM_SUBDIVIDE source alias"); +_Static_assert(SOUND_PCM_SETFRAGMENT == SNDCTL_DSP_SETFRAGMENT, "SOUND_PCM_SETFRAGMENT source alias"); +_Static_assert(SOUND_PCM_GETFMTS == SNDCTL_DSP_GETFMTS, "SOUND_PCM_GETFMTS source alias"); +_Static_assert(SOUND_PCM_GETOSPACE == SNDCTL_DSP_GETOSPACE, "SOUND_PCM_GETOSPACE source alias"); +_Static_assert(SOUND_PCM_GETISPACE == SNDCTL_DSP_GETISPACE, "SOUND_PCM_GETISPACE source alias"); +_Static_assert(SOUND_PCM_NONBLOCK == SNDCTL_DSP_NONBLOCK, "SOUND_PCM_NONBLOCK source alias"); +_Static_assert(SOUND_PCM_GETCAPS == SNDCTL_DSP_GETCAPS, "SOUND_PCM_GETCAPS source alias"); +_Static_assert(SOUND_PCM_GETTRIGGER == SNDCTL_DSP_GETTRIGGER, "SOUND_PCM_GETTRIGGER source alias"); +_Static_assert(SOUND_PCM_SETTRIGGER == SNDCTL_DSP_SETTRIGGER, "SOUND_PCM_SETTRIGGER source alias"); +_Static_assert(SOUND_PCM_SETSYNCRO == SNDCTL_DSP_SETSYNCRO, "SOUND_PCM_SETSYNCRO source alias"); +_Static_assert(SOUND_PCM_GETIPTR == SNDCTL_DSP_GETIPTR, "SOUND_PCM_GETIPTR source alias"); +_Static_assert(SOUND_PCM_GETOPTR == SNDCTL_DSP_GETOPTR, "SOUND_PCM_GETOPTR source alias"); +_Static_assert(SOUND_PCM_MAPINBUF == SNDCTL_DSP_MAPINBUF, "SOUND_PCM_MAPINBUF source alias"); +_Static_assert(SOUND_PCM_MAPOUTBUF == SNDCTL_DSP_MAPOUTBUF, "SOUND_PCM_MAPOUTBUF source alias"); +_Static_assert(AFMT_S16_NE == AFMT_S16_LE, "AFMT_S16_NE source alias"); +_Static_assert(AFMT_S16_OE == AFMT_S16_BE, "AFMT_S16_OE source alias"); +_Static_assert(AFMT_S24_NE == AFMT_S24_LE, "AFMT_S24_NE source alias"); +_Static_assert(AFMT_S24_OE == AFMT_S24_BE, "AFMT_S24_OE source alias"); +_Static_assert(AFMT_S32_NE == AFMT_S32_LE, "AFMT_S32_NE source alias"); +_Static_assert(AFMT_S32_OE == AFMT_S32_BE, "AFMT_S32_OE source alias"); +_Static_assert(AFMT_U16_NE == AFMT_U16_LE, "AFMT_U16_NE source alias"); +_Static_assert(AFMT_U16_OE == AFMT_U16_BE, "AFMT_U16_OE source alias"); +_Static_assert(AFMT_U24_NE == AFMT_U24_LE, "AFMT_U24_NE source alias"); +_Static_assert(AFMT_U24_OE == AFMT_U24_BE, "AFMT_U24_OE source alias"); +_Static_assert(AFMT_U32_NE == AFMT_U32_LE, "AFMT_U32_NE source alias"); +_Static_assert(AFMT_U32_OE == AFMT_U32_BE, "AFMT_U32_OE source alias"); +_Static_assert(AFMT_F32_NE == AFMT_F32_LE, "AFMT_F32_NE source alias"); +_Static_assert(AFMT_F32_OE == AFMT_F32_BE, "AFMT_F32_OE source alias"); +_Static_assert(AFMT_FLOAT == AFMT_F32_NE, "AFMT_FLOAT source alias"); +_Static_assert(sizeof(audio_buf_info) == 16, "audio_buf_info ABI size"); +_Static_assert(_Alignof(audio_buf_info) == 4, "audio_buf_info ABI align"); +_Static_assert(offsetof(audio_buf_info, fragments) == 0, "audio_buf_info.fragments ABI offset"); +_Static_assert(offsetof(audio_buf_info, fragstotal) == 4, "audio_buf_info.fragstotal ABI offset"); +_Static_assert(offsetof(audio_buf_info, fragsize) == 8, "audio_buf_info.fragsize ABI offset"); +_Static_assert(offsetof(audio_buf_info, bytes) == 12, "audio_buf_info.bytes ABI offset"); +_Static_assert(sizeof(count_info) == 12, "count_info ABI size"); +_Static_assert(_Alignof(count_info) == 4, "count_info ABI align"); +_Static_assert(offsetof(count_info, bytes) == 0, "count_info.bytes ABI offset"); +_Static_assert(offsetof(count_info, blocks) == 4, "count_info.blocks ABI offset"); +_Static_assert(offsetof(count_info, ptr) == 8, "count_info.ptr ABI offset"); +#endif + +#endif /* KANDELO_SYS_SOUNDCARD_H */ diff --git a/libc/musl-overlay/src/unistd/close.c b/libc/musl-overlay/src/unistd/close.c new file mode 100644 index 0000000000..04503d60ec --- /dev/null +++ b/libc/musl-overlay/src/unistd/close.c @@ -0,0 +1,23 @@ +#include +#include "aio_impl.h" +#include "syscall.h" + +/* + * Upstream musl converts close(2)'s EINTR result into success for Linux, + * where the descriptor has already been consumed even when close reports an + * interruption. Kandelo's blocking /dev/dsp final close preflights its drain + * before mutating the descriptor table: EINTR therefore means the fd is still + * valid and the caller can retry or reset it. Preserve that truthful result. + */ +static int dummy(int fd) +{ + return fd; +} + +weak_alias(dummy, __aio_close); + +int close(int fd) +{ + fd = __aio_close(fd); + return __syscall_ret(__syscall_cp(SYS_close, fd)); +} diff --git a/programs/audiotest.c b/programs/audiotest.c index 269b709137..822b6ff3b6 100644 --- a/programs/audiotest.c +++ b/programs/audiotest.c @@ -5,19 +5,18 @@ * Used by host/test/audio-integration.test.ts to verify the kernel * - exposes /dev/dsp * - accepts OSS ioctls (SNDCTL_DSP_SPEED / STEREO / SETFMT / GETFMTS) - * - buffers `write()` bytes into the ring drained by - * `kernel_drain_audio` + * - compiles against Kandelo's installed OSS source-compatibility header + * - buffers `write()` bytes for the host PCM sink * - reports the configured sample rate / channel count via the - * dedicated wasm exports + * PCM transport descriptor * * On success the program prints: * * ready * wrote * - * and exits 0. The harness reads the rate/chans from the first line, - * then calls drainAudio() repeatedly until it has the same byte count - * back, asserting that the bytes match what we wrote. + * and exits 0 after the final close has drained. The host sink observes the + * same deterministic bytes while advancing the audio clock. */ #include #include @@ -25,18 +24,9 @@ #include #include #include +#include #include -/* OSS ioctls — same numeric values the kernel and Linux use. We - * hard-code rather than #include so the test program - * builds without pulling additional headers into the toolchain. */ -#define SNDCTL_DSP_RESET 0x00005000u -#define SNDCTL_DSP_SPEED 0xc0045002u -#define SNDCTL_DSP_STEREO 0xc0045003u -#define SNDCTL_DSP_SETFMT 0xc0045005u -#define SNDCTL_DSP_GETFMTS 0x8004500bu -#define AFMT_S16_LE 0x10 - int main(void) { int fd = open("/dev/dsp", O_WRONLY); if (fd < 0) { @@ -95,10 +85,19 @@ int main(void) { close(fd); return 1; } + if ((size_t)n != sizeof(pcm)) { + fprintf(stderr, "short blocking /dev/dsp write: %zd of %zu bytes\n", + n, sizeof(pcm)); + close(fd); + return 1; + } printf("wrote %zd\n", n); fflush(stdout); - close(fd); + if (close(fd) < 0) { + perror("close /dev/dsp"); + return 1; + } return 0; } diff --git a/run.sh b/run.sh index c5e5117ace..6a4cd4efea 100755 --- a/run.sh +++ b/run.sh @@ -295,6 +295,11 @@ KERNEL_REQUIRED_EXPORTS=( kernel_mq_descriptor_msgsize kernel_msqid_ds_bytes kernel_pick_signal_target_tid + kernel_pcm_claim_transport + kernel_pcm_clock_update + kernel_pcm_reconcile + kernel_pcm_transport_len + kernel_pcm_transport_ptr kernel_pipe_has_readers kernel_posix_timer_fire kernel_process_metadata_begin diff --git a/scripts/prepare-host-package.sh b/scripts/prepare-host-package.sh index 93b05d14a7..1c0ad000d9 100755 --- a/scripts/prepare-host-package.sh +++ b/scripts/prepare-host-package.sh @@ -18,6 +18,14 @@ cp \ "$REPO_ROOT/packages/registry/program-packages.json" \ "$HOST_WASM_DIR/program-packages.json" +# `npm run build` copies the self-contained browser PCM worklet through the +# tsup onSuccess hook. Keep prepack strict so a publish can never contain a +# BrowserKernel bundle whose relative worklet URL resolves to a missing asset. +test -f "$REPO_ROOT/host/dist/audio/pcm-audio-worklet.js" || { + echo "prepare-host-package: missing dist/audio/pcm-audio-worklet.js; run npm run build in host/" >&2 + exit 1 +} + copy_first_existing() { local dest="$1" shift diff --git a/tools/xtask/src/dump_abi.rs b/tools/xtask/src/dump_abi.rs index f36296716e..be6c7941f1 100644 --- a/tools/xtask/src/dump_abi.rs +++ b/tools/xtask/src/dump_abi.rs @@ -98,6 +98,7 @@ pub fn run(args: Vec) -> Result<(), String> { let channel_scalars_header = render_channel_scalars_header(); let thread_syscalls_header = render_thread_syscalls_header(); let spawn_header = render_spawn_contract_header(); + let soundcard_header = render_soundcard_header(); let ts_module = render_ts_module(); let out = out_path.unwrap_or_else(|| repo_root().join("abi/snapshot.json")); @@ -112,6 +113,7 @@ pub fn run(args: Vec) -> Result<(), String> { repo_root().join("libc/musl-overlay/include/bits/kandelo_thread_syscalls.h"); let spawn_header_out = repo_root().join("libc/musl-overlay/src/process/wasm32posix/spawn_contract.h"); + let soundcard_header_out = repo_root().join("libc/musl-overlay/include/sys/soundcard.h"); let ts_out = repo_root().join("host/src/generated/abi.ts"); if check { @@ -138,6 +140,11 @@ pub fn run(args: Vec) -> Result<(), String> { "musl Kandelo thread syscall header", )?; check_file(&spawn_header_out, &spawn_header, "musl spawn_contract.h")?; + check_file( + &soundcard_header_out, + &soundcard_header, + "libc/musl-overlay/include/sys/soundcard.h", + )?; check_file(&ts_out, &ts_module, "host/src/generated/abi.ts")?; println!("abi snapshot up-to-date: {}", out.display()); println!("abi header up-to-date: {}", header_out.display()); @@ -161,6 +168,10 @@ pub fn run(args: Vec) -> Result<(), String> { "spawn contract header up-to-date: {}", spawn_header_out.display(), ); + println!( + "OSS header up-to-date: {}", + soundcard_header_out.display() + ); println!("abi TS bindings up-to-date: {}", ts_out.display()); return Ok(()); } @@ -179,6 +190,8 @@ pub fn run(args: Vec) -> Result<(), String> { println!("wrote {}", thread_syscalls_header_out.display()); write_file(&spawn_header_out, &spawn_header)?; println!("wrote {}", spawn_header_out.display()); + write_file(&soundcard_header_out, &soundcard_header)?; + println!("wrote {}", soundcard_header_out.display()); write_file(&ts_out, &ts_module)?; println!("wrote {}", ts_out.display()); Ok(()) @@ -758,6 +771,265 @@ fn render_c_channel_contract() -> String { ) } +/// Kandelo-owned wasm32 OSS source ABI. Generate this SDK header from the +/// same Rust constants recorded in the ABI snapshot so C and Rust cannot +/// silently assign different request numbers. +fn render_soundcard_header() -> String { + let mut out = String::from( + "/* GENERATED by `cargo xtask dump-abi`. Do not edit by hand. */\n\ + /* Kandelo-owned wasm32 OSS source ABI; independent of the host UAPI. */\n\ + #ifndef KANDELO_SYS_SOUNDCARD_H\n\ + #define KANDELO_SYS_SOUNDCARD_H\n\ + \n\ + #include \n\ + #include \n\ + \n", + ); + + for (name, value) in oss_format_constants() { + out.push_str(&format!("#define {name} 0x{value:08x}u\n")); + } + for (alias, target) in oss_format_aliases() { + out.push_str(&format!("#define {alias} {target}\n")); + } + out.push_str( + "\n\ + typedef struct audio_buf_info {\n\ + \tint32_t fragments;\n\ + \tint32_t fragstotal;\n\ + \tint32_t fragsize;\n\ + \tint32_t bytes;\n\ + } audio_buf_info;\n\ + \n\ + typedef struct count_info {\n\ + \tint32_t bytes;\n\ + \tint32_t blocks;\n\ + \tint32_t ptr;\n\ + } count_info;\n\ + \n\ + /* Declared for source compatibility; Kandelo does not support DSP mmap. */\n\ + typedef struct buffmem_desc {\n\ + \tvoid *buffer;\n\ + \tint32_t size;\n\ + } buffmem_desc;\n\ + \n", + ); + for (name, value) in oss_ioctl_constants() { + out.push_str(&format!("#define {name} 0x{value:08x}u\n")); + } + for (alias, target) in oss_source_aliases() { + out.push_str(&format!("#define {alias} {target}\n")); + } + out.push('\n'); + for (name, value) in oss_trigger_constants() { + out.push_str(&format!("#define {name} 0x{value:08x}u\n")); + } + out.push('\n'); + for (name, value) in oss_capability_constants() { + out.push_str(&format!("#define {name} 0x{value:08x}u\n")); + } + out.push_str( + "#define DSP_CAP_REVISION PCM_CAP_REVISION\n\ + #define DSP_CAP_DUPLEX PCM_CAP_DUPLEX\n\ + #define DSP_CAP_REALTIME PCM_CAP_REALTIME\n\ + #define DSP_CAP_BATCH PCM_CAP_BATCH\n\ + #define DSP_CAP_COPROC PCM_CAP_COPROC\n\ + #define DSP_CAP_TRIGGER PCM_CAP_TRIGGER\n\ + #define DSP_CAP_MMAP PCM_CAP_MMAP\n\ + #define DSP_CAP_MULTI PCM_CAP_MULTI\n\ + #define DSP_CAP_BIND PCM_CAP_BIND\n\ + #define DSP_CAP_INPUT PCM_CAP_INPUT\n\ + #define DSP_CAP_OUTPUT PCM_CAP_OUTPUT\n\ + #define DSP_CAP_VIRTUAL PCM_CAP_VIRTUAL\n\ + #define DSP_CAP_DEFAULT PCM_CAP_DEFAULT\n\ + \n\ + #if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L\n", + ); + for (name, value) in oss_format_constants() + .into_iter() + .chain(oss_ioctl_constants()) + .chain(oss_trigger_constants()) + .chain(oss_capability_constants()) + { + out.push_str(&format!( + "_Static_assert({name} == 0x{value:08x}u, \"{name} ABI\");\n" + )); + } + for (alias, target) in oss_source_aliases() { + out.push_str(&format!( + "_Static_assert({alias} == {target}, \"{alias} source alias\");\n" + )); + } + for (alias, target) in oss_format_aliases() { + out.push_str(&format!( + "_Static_assert({alias} == {target}, \"{alias} source alias\");\n" + )); + } + out.push_str( + "_Static_assert(sizeof(audio_buf_info) == 16, \"audio_buf_info ABI size\");\n\ + _Static_assert(_Alignof(audio_buf_info) == 4, \"audio_buf_info ABI align\");\n\ + _Static_assert(offsetof(audio_buf_info, fragments) == 0, \"audio_buf_info.fragments ABI offset\");\n\ + _Static_assert(offsetof(audio_buf_info, fragstotal) == 4, \"audio_buf_info.fragstotal ABI offset\");\n\ + _Static_assert(offsetof(audio_buf_info, fragsize) == 8, \"audio_buf_info.fragsize ABI offset\");\n\ + _Static_assert(offsetof(audio_buf_info, bytes) == 12, \"audio_buf_info.bytes ABI offset\");\n\ + _Static_assert(sizeof(count_info) == 12, \"count_info ABI size\");\n\ + _Static_assert(_Alignof(count_info) == 4, \"count_info ABI align\");\n\ + _Static_assert(offsetof(count_info, bytes) == 0, \"count_info.bytes ABI offset\");\n\ + _Static_assert(offsetof(count_info, blocks) == 4, \"count_info.blocks ABI offset\");\n\ + _Static_assert(offsetof(count_info, ptr) == 8, \"count_info.ptr ABI offset\");\n\ + #endif\n\ + \n\ + #endif /* KANDELO_SYS_SOUNDCARD_H */\n", + ); + out +} + +fn oss_format_constants() -> Vec<(&'static str, u32)> { + use shared::oss; + vec![ + ("AFMT_QUERY", oss::AFMT_QUERY), + ("AFMT_MU_LAW", oss::AFMT_MU_LAW), + ("AFMT_A_LAW", oss::AFMT_A_LAW), + ("AFMT_IMA_ADPCM", oss::AFMT_IMA_ADPCM), + ("AFMT_U8", oss::AFMT_U8), + ("AFMT_S16_LE", oss::AFMT_S16_LE), + ("AFMT_S16_BE", oss::AFMT_S16_BE), + ("AFMT_S8", oss::AFMT_S8), + ("AFMT_U16_LE", oss::AFMT_U16_LE), + ("AFMT_U16_BE", oss::AFMT_U16_BE), + ("AFMT_MPEG", oss::AFMT_MPEG), + ("AFMT_AC3", oss::AFMT_AC3), + ("AFMT_S32_LE", oss::AFMT_S32_LE), + ("AFMT_S32_BE", oss::AFMT_S32_BE), + ("AFMT_U32_LE", oss::AFMT_U32_LE), + ("AFMT_U32_BE", oss::AFMT_U32_BE), + ("AFMT_S24_LE", oss::AFMT_S24_LE), + ("AFMT_S24_BE", oss::AFMT_S24_BE), + ("AFMT_U24_LE", oss::AFMT_U24_LE), + ("AFMT_U24_BE", oss::AFMT_U24_BE), + ("AFMT_F32_LE", oss::AFMT_F32_LE), + ("AFMT_F32_BE", oss::AFMT_F32_BE), + ] +} + +/// The Kandelo SDK targets little-endian wasm32. Publish FreeBSD's canonical +/// native/opposite-endian format spellings without duplicating numeric values +/// in the ABI snapshot. +fn oss_format_aliases() -> Vec<(&'static str, &'static str)> { + vec![ + ("AFMT_S16_NE", "AFMT_S16_LE"), + ("AFMT_S16_OE", "AFMT_S16_BE"), + ("AFMT_S24_NE", "AFMT_S24_LE"), + ("AFMT_S24_OE", "AFMT_S24_BE"), + ("AFMT_S32_NE", "AFMT_S32_LE"), + ("AFMT_S32_OE", "AFMT_S32_BE"), + ("AFMT_U16_NE", "AFMT_U16_LE"), + ("AFMT_U16_OE", "AFMT_U16_BE"), + ("AFMT_U24_NE", "AFMT_U24_LE"), + ("AFMT_U24_OE", "AFMT_U24_BE"), + ("AFMT_U32_NE", "AFMT_U32_LE"), + ("AFMT_U32_OE", "AFMT_U32_BE"), + ("AFMT_F32_NE", "AFMT_F32_LE"), + ("AFMT_F32_OE", "AFMT_F32_BE"), + ("AFMT_FLOAT", "AFMT_F32_NE"), + ] +} + +fn oss_ioctl_constants() -> Vec<(&'static str, u32)> { + use shared::oss; + vec![ + ("SNDCTL_DSP_RESET", oss::SNDCTL_DSP_RESET), + ("SNDCTL_DSP_SYNC", oss::SNDCTL_DSP_SYNC), + ("SNDCTL_DSP_SPEED", oss::SNDCTL_DSP_SPEED), + ("SNDCTL_DSP_STEREO", oss::SNDCTL_DSP_STEREO), + ("SNDCTL_DSP_GETBLKSIZE", oss::SNDCTL_DSP_GETBLKSIZE), + ("SNDCTL_DSP_SETBLKSIZE", oss::SNDCTL_DSP_SETBLKSIZE), + ("SNDCTL_DSP_SETFMT", oss::SNDCTL_DSP_SETFMT), + ("SNDCTL_DSP_CHANNELS", oss::SNDCTL_DSP_CHANNELS), + ("SOUND_PCM_WRITE_FILTER", oss::SOUND_PCM_WRITE_FILTER), + ("SNDCTL_DSP_POST", oss::SNDCTL_DSP_POST), + ("SNDCTL_DSP_SUBDIVIDE", oss::SNDCTL_DSP_SUBDIVIDE), + ("SNDCTL_DSP_SETFRAGMENT", oss::SNDCTL_DSP_SETFRAGMENT), + ("SNDCTL_DSP_GETFMTS", oss::SNDCTL_DSP_GETFMTS), + ("SNDCTL_DSP_GETOSPACE", oss::SNDCTL_DSP_GETOSPACE), + ("SNDCTL_DSP_GETISPACE", oss::SNDCTL_DSP_GETISPACE), + ("SNDCTL_DSP_NONBLOCK", oss::SNDCTL_DSP_NONBLOCK), + ("SNDCTL_DSP_GETCAPS", oss::SNDCTL_DSP_GETCAPS), + ("SNDCTL_DSP_SETTRIGGER", oss::SNDCTL_DSP_SETTRIGGER), + ("SNDCTL_DSP_GETTRIGGER", oss::SNDCTL_DSP_GETTRIGGER), + ("SNDCTL_DSP_GETIPTR", oss::SNDCTL_DSP_GETIPTR), + ("SNDCTL_DSP_GETOPTR", oss::SNDCTL_DSP_GETOPTR), + ("SNDCTL_DSP_MAPINBUF", oss::SNDCTL_DSP_MAPINBUF), + ("SNDCTL_DSP_MAPOUTBUF", oss::SNDCTL_DSP_MAPOUTBUF), + ("SNDCTL_DSP_SETSYNCRO", oss::SNDCTL_DSP_SETSYNCRO), + ("SNDCTL_DSP_SETDUPLEX", oss::SNDCTL_DSP_SETDUPLEX), + ("SNDCTL_DSP_GETODELAY", oss::SNDCTL_DSP_GETODELAY), + ("SOUND_PCM_READ_RATE", oss::SOUND_PCM_READ_RATE), + ("SOUND_PCM_READ_BITS", oss::SOUND_PCM_READ_BITS), + ("SOUND_PCM_READ_CHANNELS", oss::SOUND_PCM_READ_CHANNELS), + ("SOUND_PCM_READ_FILTER", oss::SOUND_PCM_READ_FILTER), + ] +} + +/// Canonical OSS compatibility spellings that do not introduce additional +/// ioctl values. Keep aliases out of the ABI snapshot's numeric map: their +/// targets are already pinned there, while the generated C assertions protect +/// the source-level contract. +fn oss_source_aliases() -> Vec<(&'static str, &'static str)> { + vec![ + ("SNDCTL_DSP_HALT", "SNDCTL_DSP_RESET"), + ("SNDCTL_DSP_SAMPLESIZE", "SNDCTL_DSP_SETFMT"), + ("SOUND_PCM_WRITE_RATE", "SNDCTL_DSP_SPEED"), + ("SOUND_PCM_WRITE_CHANNELS", "SNDCTL_DSP_CHANNELS"), + ("SOUND_PCM_WRITE_BITS", "SNDCTL_DSP_SETFMT"), + ("SOUND_PCM_SETFMT", "SNDCTL_DSP_SETFMT"), + ("SOUND_PCM_POST", "SNDCTL_DSP_POST"), + ("SOUND_PCM_RESET", "SNDCTL_DSP_RESET"), + ("SOUND_PCM_SYNC", "SNDCTL_DSP_SYNC"), + ("SOUND_PCM_SUBDIVIDE", "SNDCTL_DSP_SUBDIVIDE"), + ("SOUND_PCM_SETFRAGMENT", "SNDCTL_DSP_SETFRAGMENT"), + ("SOUND_PCM_GETFMTS", "SNDCTL_DSP_GETFMTS"), + ("SOUND_PCM_GETOSPACE", "SNDCTL_DSP_GETOSPACE"), + ("SOUND_PCM_GETISPACE", "SNDCTL_DSP_GETISPACE"), + ("SOUND_PCM_NONBLOCK", "SNDCTL_DSP_NONBLOCK"), + ("SOUND_PCM_GETCAPS", "SNDCTL_DSP_GETCAPS"), + ("SOUND_PCM_GETTRIGGER", "SNDCTL_DSP_GETTRIGGER"), + ("SOUND_PCM_SETTRIGGER", "SNDCTL_DSP_SETTRIGGER"), + ("SOUND_PCM_SETSYNCRO", "SNDCTL_DSP_SETSYNCRO"), + ("SOUND_PCM_GETIPTR", "SNDCTL_DSP_GETIPTR"), + ("SOUND_PCM_GETOPTR", "SNDCTL_DSP_GETOPTR"), + ("SOUND_PCM_MAPINBUF", "SNDCTL_DSP_MAPINBUF"), + ("SOUND_PCM_MAPOUTBUF", "SNDCTL_DSP_MAPOUTBUF"), + ] +} + +fn oss_trigger_constants() -> Vec<(&'static str, u32)> { + use shared::oss; + vec![ + ("PCM_ENABLE_INPUT", oss::PCM_ENABLE_INPUT), + ("PCM_ENABLE_OUTPUT", oss::PCM_ENABLE_OUTPUT), + ] +} + +fn oss_capability_constants() -> Vec<(&'static str, u32)> { + use shared::oss; + vec![ + ("PCM_CAP_REVISION", oss::PCM_CAP_REVISION), + ("PCM_CAP_DUPLEX", oss::PCM_CAP_DUPLEX), + ("PCM_CAP_REALTIME", oss::PCM_CAP_REALTIME), + ("PCM_CAP_BATCH", oss::PCM_CAP_BATCH), + ("PCM_CAP_COPROC", oss::PCM_CAP_COPROC), + ("PCM_CAP_TRIGGER", oss::PCM_CAP_TRIGGER), + ("PCM_CAP_MMAP", oss::PCM_CAP_MMAP), + ("PCM_CAP_MULTI", oss::PCM_CAP_MULTI), + ("PCM_CAP_BIND", oss::PCM_CAP_BIND), + ("PCM_CAP_INPUT", oss::PCM_CAP_INPUT), + ("PCM_CAP_OUTPUT", oss::PCM_CAP_OUTPUT), + ("PCM_CAP_VIRTUAL", oss::PCM_CAP_VIRTUAL), + ("PCM_CAP_DEFAULT", oss::PCM_CAP_DEFAULT), + ] +} + /// TypeScript bindings consumed by `host/src/*`. /// /// Keep this generated from the same Rust/shared source of truth as @@ -2508,6 +2780,8 @@ fn render_ts_module() -> String { } out.push_str("] as const;\n\n"); + render_pcm_ts_bindings(&mut out); + out.push_str("export const HOST_ADAPTER_MANIFEST_FIELDS = {\n"); for field in host_adapter_manifest_fields() { out.push_str(&format!( @@ -3141,6 +3415,68 @@ fn program_artifact_type_name(value: shared::abi::ProgramArtifactValueType) -> & } } +fn render_pcm_ts_bindings(out: &mut String) { + use shared::pcm; + + for (name, value) in [ + ("PCM_TRANSPORT_MAGIC", pcm::PCM_TRANSPORT_MAGIC), + ("PCM_TRANSPORT_VERSION", pcm::PCM_TRANSPORT_VERSION), + ("PCM_TRANSPORT_HEADER_BYTES", pcm::PCM_TRANSPORT_HEADER_BYTES), + ("PCM_TRANSPORT_RING_BYTES", pcm::PCM_TRANSPORT_RING_BYTES), + ("PCM_TRANSPORT_BYTES", pcm::PCM_TRANSPORT_BYTES), + ("PCM_STATE_CLOSED", pcm::PCM_STATE_CLOSED), + ("PCM_STATE_STOPPED", pcm::PCM_STATE_STOPPED), + ("PCM_STATE_RUNNING", pcm::PCM_STATE_RUNNING), + ("PCM_STATE_DRAINING", pcm::PCM_STATE_DRAINING), + ("PCM_FORMAT_UNKNOWN", pcm::PCM_FORMAT_UNKNOWN), + ("PCM_FORMAT_U8", pcm::PCM_FORMAT_U8), + ("PCM_FORMAT_S16_LE", pcm::PCM_FORMAT_S16_LE), + ("PCM_FORMAT_S16_BE", pcm::PCM_FORMAT_S16_BE), + ("PCM_TRANSPORT_UNCLAIMED", pcm::PCM_TRANSPORT_UNCLAIMED), + ("PCM_TRANSPORT_LEGACY_PULL", pcm::PCM_TRANSPORT_LEGACY_PULL), + ("PCM_TRANSPORT_SHARED_CLOCK", pcm::PCM_TRANSPORT_SHARED_CLOCK), + ("PCM_FLAG_CONFIGURING", pcm::PCM_FLAG_CONFIGURING), + ("PCM_FLAG_UNDERRUN_ACTIVE", pcm::PCM_FLAG_UNDERRUN_ACTIVE), + ("PCM_FLAG_FATAL_ERROR", pcm::PCM_FLAG_FATAL_ERROR), + ] { + out.push_str(&format!("export const {name} = {value} as const;\n")); + } + out.push('\n'); + + out.push_str("export const PCM_SHARED_CONTROL_FIELDS = {\n"); + for (name, offset) in [ + ("magic", offset_of!(pcm::PcmSharedControl, magic)), + ("version", offset_of!(pcm::PcmSharedControl, version)), + ("headerBytes", offset_of!(pcm::PcmSharedControl, header_bytes)), + ("physicalCapacityBytes", offset_of!(pcm::PcmSharedControl, physical_capacity_bytes)), + ("activeCapacityBytes", offset_of!(pcm::PcmSharedControl, active_capacity_bytes)), + ("format", offset_of!(pcm::PcmSharedControl, format)), + ("sampleRate", offset_of!(pcm::PcmSharedControl, rate)), + ("channels", offset_of!(pcm::PcmSharedControl, channels)), + ("frameBytes", offset_of!(pcm::PcmSharedControl, frame_bytes)), + ("fragmentBytes", offset_of!(pcm::PcmSharedControl, fragment_bytes)), + ("fragments", offset_of!(pcm::PcmSharedControl, fragment_count)), + ("state", offset_of!(pcm::PcmSharedControl, state)), + ("generation", offset_of!(pcm::PcmSharedControl, generation)), + ("flags", offset_of!(pcm::PcmSharedControl, flags)), + ("transportMode", offset_of!(pcm::PcmSharedControl, transport_mode)), + ("producerSeq", offset_of!(pcm::PcmSharedControl, producer_seq)), + ("producerLo", offset_of!(pcm::PcmSharedControl, producer_lo)), + ("producerHi", offset_of!(pcm::PcmSharedControl, producer_hi)), + ("consumerSeq", offset_of!(pcm::PcmSharedControl, consumer_seq)), + ("consumerLo", offset_of!(pcm::PcmSharedControl, consumer_lo)), + ("consumerHi", offset_of!(pcm::PcmSharedControl, consumer_hi)), + ("discardSeq", offset_of!(pcm::PcmSharedControl, discard_seq)), + ("discardLo", offset_of!(pcm::PcmSharedControl, discard_lo)), + ("discardHi", offset_of!(pcm::PcmSharedControl, discard_hi)), + ("underruns", offset_of!(pcm::PcmSharedControl, underruns)), + ("wakeSeq", offset_of!(pcm::PcmSharedControl, wake_seq)), + ] { + out.push_str(&format!(" {name}: {{ offset: {offset}, size: 4 }},\n")); + } + out.push_str("} as const;\n\n"); +} + fn ts_syscall_arg_desc(desc: &shared::host_abi::SyscallArgDesc) -> String { let mut s = format!( "{{ argIndex: {}, direction: {:?}, size: {}", @@ -3363,6 +3699,29 @@ fn build_struct_layout(total_size: usize, fields: Vec<(&'static str, usize)>) -> Value::Object(m.into_iter().collect()) } +fn build_typed_struct_layout( + total_size: usize, + alignment: usize, + fields: &[(&'static str, usize, usize, &'static str)], +) -> Value { + let fields = fields + .iter() + .map(|(name, offset, span, field_type)| { + let mut field: JsonMap = BTreeMap::new(); + field.insert("name".into(), json!(name)); + field.insert("offset".into(), json!(offset)); + field.insert("span".into(), json!(span)); + field.insert("type".into(), json!(field_type)); + Value::Object(field.into_iter().collect()) + }) + .collect(); + let mut layout: JsonMap = BTreeMap::new(); + layout.insert("size".into(), json!(total_size)); + layout.insert("align".into(), json!(alignment)); + layout.insert("fields".into(), Value::Array(fields)); + Value::Object(layout.into_iter().collect()) +} + fn build_snapshot(kernel_wasm: &std::path::Path) -> Result { let mut root: JsonMap = BTreeMap::new(); @@ -3388,6 +3747,8 @@ fn build_snapshot(kernel_wasm: &std::path::Path) -> Result { root.insert("channel_scalar_contract".into(), channel_scalar_contract()); root.insert("marshalled_structs".into(), marshalled_structs()); + root.insert("oss_source_abi".into(), oss_source_abi()); + root.insert("pcm_transport_abi".into(), pcm_transport_abi()); root.insert("syscalls".into(), syscalls()); root.insert("pathconf_names".into(), pathconf_names()); root.insert("wait_contract".into(), wait_contract()); @@ -4065,6 +4426,74 @@ fn channel_buffers() -> Value { Value::Object(m.into_iter().collect()) } +fn oss_source_abi() -> Value { + let mut ioctls: JsonMap = BTreeMap::new(); + for (name, value) in oss_ioctl_constants() { + ioctls.insert(name.into(), json!(value)); + } + let mut formats: JsonMap = BTreeMap::new(); + for (name, value) in oss_format_constants() { + formats.insert(name.into(), json!(value)); + } + let mut capabilities: JsonMap = BTreeMap::new(); + for (name, value) in oss_capability_constants() { + capabilities.insert(name.into(), json!(value)); + } + let mut trigger_values: JsonMap = BTreeMap::new(); + for (name, value) in oss_trigger_constants() { + trigger_values.insert(name.into(), json!(value)); + } + + let mut abi: JsonMap = BTreeMap::new(); + abi.insert( + "ioctls".into(), + Value::Object(ioctls.into_iter().collect()), + ); + abi.insert( + "formats".into(), + Value::Object(formats.into_iter().collect()), + ); + abi.insert( + "capabilities".into(), + Value::Object(capabilities.into_iter().collect()), + ); + abi.insert( + "trigger_values".into(), + Value::Object(trigger_values.into_iter().collect()), + ); + Value::Object(abi.into_iter().collect()) +} + +fn pcm_transport_abi() -> Value { + use shared::pcm; + + let mut constants: JsonMap = BTreeMap::new(); + for (name, value) in [ + ("magic", pcm::PCM_TRANSPORT_MAGIC), + ("version", pcm::PCM_TRANSPORT_VERSION), + ("header_bytes", pcm::PCM_TRANSPORT_HEADER_BYTES), + ("ring_bytes", pcm::PCM_TRANSPORT_RING_BYTES), + ("total_bytes", pcm::PCM_TRANSPORT_BYTES), + ("state_closed", pcm::PCM_STATE_CLOSED), + ("state_stopped", pcm::PCM_STATE_STOPPED), + ("state_running", pcm::PCM_STATE_RUNNING), + ("state_draining", pcm::PCM_STATE_DRAINING), + ("format_unknown", pcm::PCM_FORMAT_UNKNOWN), + ("format_u8", pcm::PCM_FORMAT_U8), + ("format_s16_le", pcm::PCM_FORMAT_S16_LE), + ("format_s16_be", pcm::PCM_FORMAT_S16_BE), + ("transport_unclaimed", pcm::PCM_TRANSPORT_UNCLAIMED), + ("transport_legacy_pull", pcm::PCM_TRANSPORT_LEGACY_PULL), + ("transport_shared_clock", pcm::PCM_TRANSPORT_SHARED_CLOCK), + ("flag_configuring", pcm::PCM_FLAG_CONFIGURING), + ("flag_underrun_active", pcm::PCM_FLAG_UNDERRUN_ACTIVE), + ("flag_fatal_error", pcm::PCM_FLAG_FATAL_ERROR), + ] { + constants.insert(name.into(), json!(value)); + } + Value::Object(constants.into_iter().collect()) +} + fn process_memory_layout() -> Value { use shared::process_memory as pm; @@ -4276,6 +4705,8 @@ fn marshalled_structs() -> Value { }; use shared::fbdev::{FbBitfield, FbFixScreenInfo, FbVarScreenInfo}; use shared::gl::{GlContextAttrs, GlQueryInfo, GlSubmitInfo, GlSurfaceAttrs}; + use shared::oss::{AudioBufInfo, CountInfo}; + use shared::pcm::PcmSharedControl; use shared::{ KernelCmsghdrWire, KernelIovecWire, KernelMsghdrWire, KernelWaitResult, WasmDirent, WasmEpollEvent, WasmFlock, WasmPollFd, WasmRusageWire, WasmStat, WasmStatfs, @@ -4283,6 +4714,67 @@ fn marshalled_structs() -> Value { }; let mut structs: JsonMap = BTreeMap::new(); + structs.insert( + "AudioBufInfo".into(), + build_typed_struct_layout( + size_of::(), + align_of::(), + &[ + ("fragments", offset_of!(AudioBufInfo, fragments), 4, "i32"), + ("fragstotal", offset_of!(AudioBufInfo, fragstotal), 4, "i32"), + ("fragsize", offset_of!(AudioBufInfo, fragsize), 4, "i32"), + ("bytes", offset_of!(AudioBufInfo, bytes), 4, "i32"), + ], + ), + ); + structs.insert( + "CountInfo".into(), + build_typed_struct_layout( + size_of::(), + align_of::(), + &[ + ("bytes", offset_of!(CountInfo, bytes), 4, "i32"), + ("blocks", offset_of!(CountInfo, blocks), 4, "i32"), + ("ptr", offset_of!(CountInfo, ptr), 4, "i32"), + ], + ), + ); + structs.insert( + "PcmSharedControl".into(), + build_typed_struct_layout( + size_of::(), + align_of::(), + &[ + ("magic", offset_of!(PcmSharedControl, magic), 4, "u32"), + ("version", offset_of!(PcmSharedControl, version), 4, "u32"), + ("header_bytes", offset_of!(PcmSharedControl, header_bytes), 4, "u32"), + ("physical_capacity_bytes", offset_of!(PcmSharedControl, physical_capacity_bytes), 4, "u32"), + ("active_capacity_bytes", offset_of!(PcmSharedControl, active_capacity_bytes), 4, "u32"), + ("format", offset_of!(PcmSharedControl, format), 4, "u32"), + ("rate", offset_of!(PcmSharedControl, rate), 4, "u32"), + ("channels", offset_of!(PcmSharedControl, channels), 4, "u32"), + ("frame_bytes", offset_of!(PcmSharedControl, frame_bytes), 4, "u32"), + ("fragment_bytes", offset_of!(PcmSharedControl, fragment_bytes), 4, "u32"), + ("fragment_count", offset_of!(PcmSharedControl, fragment_count), 4, "u32"), + ("state", offset_of!(PcmSharedControl, state), 4, "u32"), + ("generation", offset_of!(PcmSharedControl, generation), 4, "u32"), + ("flags", offset_of!(PcmSharedControl, flags), 4, "u32"), + ("transport_mode", offset_of!(PcmSharedControl, transport_mode), 4, "u32"), + ("producer_seq", offset_of!(PcmSharedControl, producer_seq), 4, "u32"), + ("producer_lo", offset_of!(PcmSharedControl, producer_lo), 4, "u32"), + ("producer_hi", offset_of!(PcmSharedControl, producer_hi), 4, "u32"), + ("consumer_seq", offset_of!(PcmSharedControl, consumer_seq), 4, "u32"), + ("consumer_lo", offset_of!(PcmSharedControl, consumer_lo), 4, "u32"), + ("consumer_hi", offset_of!(PcmSharedControl, consumer_hi), 4, "u32"), + ("discard_seq", offset_of!(PcmSharedControl, discard_seq), 4, "u32"), + ("discard_lo", offset_of!(PcmSharedControl, discard_lo), 4, "u32"), + ("discard_hi", offset_of!(PcmSharedControl, discard_hi), 4, "u32"), + ("underruns", offset_of!(PcmSharedControl, underruns), 4, "u32"), + ("wake_seq", offset_of!(PcmSharedControl, wake_seq), 4, "u32"), + ("reserved", offset_of!(PcmSharedControl, reserved), 24, "[u32; 6]"), + ], + ), + ); structs.insert( "WasmStat".into(), struct_layout!(WasmStat { @@ -6694,6 +7186,9 @@ mod tests { assert!(rendered.contains("export const PATHCONF_NAMES = {")); assert!(rendered.contains(" PATH_MAX: 4,")); assert!(rendered.contains(" TIMESTAMP_RESOLUTION: 23,")); + assert!(rendered.contains("export const PCM_FLAG_CONFIGURING = 1 as const;")); + assert!(rendered.contains("export const PCM_FLAG_UNDERRUN_ACTIVE = 2 as const;")); + assert!(rendered.contains("export const PCM_FLAG_FATAL_ERROR = 4 as const;")); assert!(rendered.contains( "{ argIndex: 2, direction: \"out\", size: { type: \"fixed\", size: 8 }, required: true }" )); @@ -6702,6 +7197,75 @@ mod tests { assert_eq!(names["LINK_MAX"], json!(0)); assert_eq!(names["TIMESTAMP_RESOLUTION"], json!(23)); assert_eq!(names.as_object().unwrap().len(), 24); + + let pcm = pcm_transport_abi(); + assert_eq!(pcm["flag_configuring"], json!(1)); + assert_eq!(pcm["flag_underrun_active"], json!(2)); + assert_eq!(pcm["flag_fatal_error"], json!(4)); + } + + #[test] + fn generated_soundcard_header_contains_canonical_source_aliases() { + let rendered = render_soundcard_header(); + let aliases: BTreeMap<_, _> = oss_source_aliases().into_iter().collect(); + for (alias, target) in [ + ("SOUND_PCM_SUBDIVIDE", "SNDCTL_DSP_SUBDIVIDE"), + ("SOUND_PCM_SETFRAGMENT", "SNDCTL_DSP_SETFRAGMENT"), + ("SOUND_PCM_GETFMTS", "SNDCTL_DSP_GETFMTS"), + ("SOUND_PCM_GETOSPACE", "SNDCTL_DSP_GETOSPACE"), + ("SOUND_PCM_GETISPACE", "SNDCTL_DSP_GETISPACE"), + ("SOUND_PCM_NONBLOCK", "SNDCTL_DSP_NONBLOCK"), + ("SOUND_PCM_GETCAPS", "SNDCTL_DSP_GETCAPS"), + ("SOUND_PCM_GETTRIGGER", "SNDCTL_DSP_GETTRIGGER"), + ("SOUND_PCM_SETTRIGGER", "SNDCTL_DSP_SETTRIGGER"), + ("SOUND_PCM_SETSYNCRO", "SNDCTL_DSP_SETSYNCRO"), + ("SOUND_PCM_GETIPTR", "SNDCTL_DSP_GETIPTR"), + ("SOUND_PCM_GETOPTR", "SNDCTL_DSP_GETOPTR"), + ("SOUND_PCM_MAPINBUF", "SNDCTL_DSP_MAPINBUF"), + ("SOUND_PCM_MAPOUTBUF", "SNDCTL_DSP_MAPOUTBUF"), + ] { + assert_eq!( + aliases.get(alias), + Some(&target), + "missing canonical OSS PCM alias {alias}" + ); + } + for (alias, target) in oss_source_aliases() + .into_iter() + .chain(oss_format_aliases()) + { + let definition = format!("#define {alias} {target}"); + assert!( + rendered.contains(&definition), + "missing generated alias: {definition}" + ); + + let assertion = + format!("_Static_assert({alias} == {target}, \"{alias} source alias\");"); + assert!( + rendered.contains(&assertion), + "missing generated alias assertion: {assertion}" + ); + } + + let snapshotted_ioctls = oss_source_abi()["ioctls"].as_object().unwrap().clone(); + for (alias, _) in oss_source_aliases() { + assert!( + !snapshotted_ioctls.contains_key(alias), + "source alias {alias} must not duplicate its target in the ABI snapshot" + ); + } + let snapshotted_formats = oss_source_abi()["formats"].as_object().unwrap().clone(); + for (alias, _) in oss_format_aliases() { + assert!( + !snapshotted_formats.contains_key(alias), + "format alias {alias} must not duplicate its target in the ABI snapshot" + ); + } + + let trigger_values = &oss_source_abi()["trigger_values"]; + assert_eq!(trigger_values["PCM_ENABLE_INPUT"], json!(1)); + assert_eq!(trigger_values["PCM_ENABLE_OUTPUT"], json!(2)); } #[test] @@ -7464,6 +8028,15 @@ mod tests { let structs = marshalled_structs(); assert_eq!(structs["WasmRusageWire"]["size"], json!(144)); assert_eq!(structs["KernelWaitResult"]["size"], json!(160)); + assert_eq!(structs["AudioBufInfo"]["size"], json!(16)); + assert_eq!(structs["AudioBufInfo"]["align"], json!(4)); + assert_eq!( + structs["AudioBufInfo"]["fields"][3], + json!({"name": "bytes", "offset": 12, "span": 4, "type": "i32"}) + ); + assert_eq!(structs["CountInfo"]["size"], json!(12)); + assert_eq!(structs["PcmSharedControl"]["size"], json!(128)); + assert_eq!(structs["PcmSharedControl"]["align"], json!(4)); assert_eq!( structs["KernelWaitResult"]["fields"][4], json!({"name": "rusage", "offset": 16, "span": 144}) From 3ae490e2bee79a9baf3232f2aa06688743422916 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Mon, 13 Jul 2026 19:30:11 -0400 Subject: [PATCH 51/82] Browser: Expose machine-level PCM activation Forward-port the accepted browser portion of PR #947 onto the ABI 43 integration branch. Keep the current lazy test-runner artifact loading while adding the real AudioWorklet lifecycle proof and machine-level resume controls. Source: c288f562146acf263f8ee28405fdd7d3bcc21caf --- apps/browser-demos/pages/kandelo/app/App.tsx | 68 +++++ .../pages/kandelo/panes/Framebuffer.tsx | 21 +- apps/browser-demos/pages/kandelo/styles.css | 19 ++ .../pages/kandelo/views/Config.tsx | 1 - .../pages/test-runner/index.html | 4 + apps/browser-demos/pages/test-runner/main.ts | 279 ++++++++++++++++++ .../test/dsp-audio-worklet.spec.ts | 160 ++++++++++ host/src/framebuffer/browser-controls.ts | 105 ++----- .../test/framebuffer-browser-controls.test.ts | 16 + web-libs/kandelo-session/src/kernel-host.ts | 118 +++++++- .../test/kandelo-session.test.ts | 77 +++++ 11 files changed, 756 insertions(+), 112 deletions(-) create mode 100644 apps/browser-demos/test/dsp-audio-worklet.spec.ts diff --git a/apps/browser-demos/pages/kandelo/app/App.tsx b/apps/browser-demos/pages/kandelo/app/App.tsx index 28004b546f..7e4b1a5092 100644 --- a/apps/browser-demos/pages/kandelo/app/App.tsx +++ b/apps/browser-demos/pages/kandelo/app/App.tsx @@ -15,6 +15,7 @@ import type { BootDescriptor, GalleryItem, LazyDownloadEvent, + MachineAudioState, } from "../../../../../web-libs/kandelo-session/src/kernel-host"; import { lazyDownloadAssetLabel } from "../../../../../web-libs/kandelo-session/src/lazy-download"; @@ -72,12 +73,41 @@ export const App: React.FC = () => { const [themeOpen, setThemeOpen] = React.useState(false); const [terminals, setTerminals] = React.useState(() => [createShellTerminal(1)]); const [activeTerminalId, setActiveTerminalId] = React.useState("tty-1"); + const [audioState, setAudioState] = React.useState(() => host.getAudioState()); + const [audioError, setAudioError] = React.useState(null); const nextTerminalIndex = React.useRef(2); const autoOpenedDemoGuideKey = React.useRef(null); const desc = host.getBootDescriptor(); const resolvedThemeMode = theme.mode === "auto" ? systemThemeMode : theme.mode; + React.useEffect( + () => host.subscribeAudioState((state) => { + setAudioState(state); + if (state === "running") setAudioError(null); + }), + [host], + ); + + const activateAudio = React.useCallback(() => { + if (host.getAudioState() === "running") return; + void host.resumeAudio().then( + () => setAudioError(null), + (error) => setAudioError(error instanceof Error ? error.message : String(error)), + ); + }, [host]); + + // Web Audio starts only after a trusted gesture. Keep activation at the + // machine shell so terminal-only SDL applications use the same PCM sink. + React.useEffect(() => { + window.addEventListener("pointerdown", activateAudio, { capture: true }); + window.addEventListener("keydown", activateAudio, { capture: true }); + return () => { + window.removeEventListener("pointerdown", activateAudio, { capture: true }); + window.removeEventListener("keydown", activateAudio, { capture: true }); + }; + }, [activateAudio]); + React.useEffect(() => { const query = window.matchMedia("(prefers-color-scheme: dark)"); const onChange = () => setSystemThemeMode(query.matches ? "dark" : "light"); @@ -291,6 +321,13 @@ export const App: React.FC = () => { )} + {surface.status === "running" && audioState !== "running" && ( + + )} { ); }; +const AudioStatusToast: React.FC<{ + state: MachineAudioState; + error: string | null; + onEnable: () => void; +}> = ({ state, error, onEnable }) => { + const detail = error ?? ( + state === "interrupted" + ? "Audio output was interrupted by the browser or operating system." + : state === "unavailable" + ? "This browser does not provide the required Web Audio output." + : state === "error" + ? "The browser audio sink could not be started." + : "Browser policy pauses audio until you interact with this machine." + ); + return ( + + ); +}; + const TerminalDockControls: React.FC<{ terminals: ShellTerminal[]; activeTerminalId: string; diff --git a/apps/browser-demos/pages/kandelo/panes/Framebuffer.tsx b/apps/browser-demos/pages/kandelo/panes/Framebuffer.tsx index cc97108b57..14c26ef5bc 100644 --- a/apps/browser-demos/pages/kandelo/panes/Framebuffer.tsx +++ b/apps/browser-demos/pages/kandelo/panes/Framebuffer.tsx @@ -1,6 +1,6 @@ // Framebuffer pane — paints whatever process is bound to /dev/fb0, forwards // focused keyboard input as Linux input keycodes encoded in MEDIUMRAW, forwards -// pointer-lock mouse input to /dev/input/mice, and drains /dev/dsp audio. +// pointer-lock mouse input to /dev/input/mice. PCM output is machine-level. // // Painting: host.attachFramebuffer(canvas) returns a FramebufferHandle; the // host owns the requestAnimationFrame loop and BGRA→RGBA swizzle (see @@ -21,10 +21,7 @@ import { attachPointerLockMouse, type PointerLockMouseHandle, } from "../../../../../host/src/framebuffer/browser-controls"; -import type { - AudioOutputHandle, - FramebufferHandle, -} from "../../../../../web-libs/kandelo-session/src/kernel-host"; +import type { FramebufferHandle } from "../../../../../web-libs/kandelo-session/src/kernel-host"; import { IngestError, runDemoIngest, @@ -52,7 +49,6 @@ export const Framebuffer: React.FC = ({ autoFocus = false, onD const canvasRef = React.useRef(null); const handleRef = React.useRef(null); const mouseRef = React.useRef(null); - const audioRef = React.useRef(null); const [error, setError] = React.useState(null); const [boundPid, setBoundPid] = React.useState(null); const [focused, setFocused] = React.useState(false); @@ -68,27 +64,16 @@ export const Framebuffer: React.FC = ({ autoFocus = false, onD let handle: FramebufferHandle | null = null; let offBound: (() => void) | null = null; - let cancelled = false; try { handle = host.attachFramebuffer(canvasRef.current); handleRef.current = handle; setBoundPid(handle.getBoundPid()); offBound = handle.onBoundPidChange(setBoundPid); setError(null); - void handle.startAudio().then((audio) => { - if (cancelled) { - audio?.close(); - return; - } - audioRef.current = audio; - }); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } return () => { - cancelled = true; - try { audioRef.current?.close(); } catch { /* noop */ } - audioRef.current = null; try { offBound?.(); } catch { /* noop */ } try { handle?.close(); } catch { /* noop */ } handleRef.current = null; @@ -164,7 +149,7 @@ export const Framebuffer: React.FC = ({ autoFocus = false, onD const onCanvasClick = () => { canvasRef.current?.focus(); - void audioRef.current?.resume(); + void host.resumeAudio().catch(() => {}); mouseRef.current?.requestCapture(); }; diff --git a/apps/browser-demos/pages/kandelo/styles.css b/apps/browser-demos/pages/kandelo/styles.css index 8f3523e98a..69395bac37 100644 --- a/apps/browser-demos/pages/kandelo/styles.css +++ b/apps/browser-demos/pages/kandelo/styles.css @@ -1314,6 +1314,25 @@ animation: kdownload-toast-fade-error 5s ease-out forwards; } +.kpcm-audio-status { + top: auto; + bottom: calc(var(--kdock-height, 0px) + 14px); +} + +.kpcm-audio-error { + border-color: color-mix(in oklch, var(--k-err) 52%, var(--k-border)); +} + +.kpcm-audio-enable { + margin-left: auto; + border: 1px solid color-mix(in oklch, var(--k-accent) 42%, var(--k-border)); + border-radius: 6px; + background: color-mix(in oklch, var(--k-accent) 12%, var(--k-surface)); + color: var(--k-accent); + cursor: pointer; + padding: 3px 8px; +} + .kdownload-toast-top { display: flex; align-items: center; diff --git a/apps/browser-demos/pages/kandelo/views/Config.tsx b/apps/browser-demos/pages/kandelo/views/Config.tsx index 22365a0874..d56b710a0e 100644 --- a/apps/browser-demos/pages/kandelo/views/Config.tsx +++ b/apps/browser-demos/pages/kandelo/views/Config.tsx @@ -367,7 +367,6 @@ const CAPS: ReadonlyArray
Loading kernel...
+
+ + Audio test idle +
diff --git a/apps/browser-demos/pages/test-runner/main.ts b/apps/browser-demos/pages/test-runner/main.ts index 07608c136f..05bf0c716f 100644 --- a/apps/browser-demos/pages/test-runner/main.ts +++ b/apps/browser-demos/pages/test-runner/main.ts @@ -7,6 +7,15 @@ */ import { BrowserKernel } from "@host/browser-kernel-host"; import type { HostDiagnostic } from "@host/host-diagnostic"; +import pcmAudioWorkletUrl from "@host/audio/pcm-audio-worklet.js?url"; +import { + pcmControlWords, + readConsumerPosition, + readDiscardPosition, + readPcmConfig, + readProducerPosition, + type PcmTransportDescriptor, +} from "@host/audio/pcm-transport"; import { createBuildFsWithEtc, finalizeKernelOwnedImage, @@ -26,6 +35,46 @@ interface PtyInput { readyMarker: string; } +interface AudioTestSnapshot { + audioState: ReturnType; + audioStates: ReturnType[]; + workletAssetUrl: string; + workletPrepared: boolean; + producerBytes: number; + consumerBytes: number; + discardBytes: number; + queuedBytes: number; + activeCapacityBytes: number; + settled: boolean; + resumeAttempts: number; + trustedResumeAttempts: number; + lastResumeError: string | null; + stdout: string; + stderr: string; + hostDiagnostics: string[]; +} + +interface AudioTestResult extends AudioTestSnapshot { + exitCode: number; + elapsedMs: number; +} + +interface AudioTestSession { + kernel: BrowserKernel; + transport: PcmTransportDescriptor; + stdout: string; + stderr: string; + hostDiagnostics: string[]; + audioStates: ReturnType[]; + workletPrepared: boolean; + settled: boolean; + resumeAttempts: number; + trustedResumeAttempts: number; + lastResumeError: string | null; + result?: Promise; + unsubscribeAudioState?: () => void; +} + declare global { interface Window { __testRunnerReady: boolean; @@ -47,17 +96,146 @@ declare global { hostDiagnostics: HostDiagnostic[]; }>; __testCount: number; + /** + * Start one real-browser `/dev/dsp` run with the AudioContext deliberately + * suspended. The guest is allowed to fill the bounded PCM ring and block + * in close; `#resume-audio` is the only path that resumes the audio clock. + */ + __prepareAudioTest: ( + wasmBytes: ArrayBuffer, + argv?: string[], + timeoutMs?: number, + ) => Promise; + __audioTestSnapshot: () => AudioTestSnapshot; + __waitForAudioTest: () => Promise; + __suspendAudioTest: () => Promise; + __finishAudioTest: () => Promise; } } let kernelWasmBytes: ArrayBuffer | null = null; let execBinarySupport: ExecBinarySupport | null = null; +let activeAudioTest: AudioTestSession | null = null; const corsProxyUrl = new URL( `${import.meta.env.BASE_URL}__kandelo_cors_proxy?url=`, window.location.href, ).href; +function audioTransportFor(kernel: BrowserKernel): PcmTransportDescriptor { + // The transport is intentionally not part of BrowserKernel's public app + // API. This test-only page inspects it to prove that the production + // AudioWorklet, rather than a main-thread timer or legacy pull drain, + // advances the consumer clock. + const transport = ( + kernel as unknown as { pcmTransport: PcmTransportDescriptor | null } + ).pcmTransport; + if (!transport) { + throw new Error("PCM transport was not published by the kernel worker"); + } + return transport; +} + +function safeCursorNumber(value: bigint, label: string): number { + const number = Number(value); + if (!Number.isSafeInteger(number)) { + throw new Error(`${label} PCM cursor is outside JavaScript's safe integer range`); + } + return number; +} + +function snapshotAudioTest(session = activeAudioTest): AudioTestSnapshot { + if (!session) throw new Error("No browser audio test is active"); + const words = pcmControlWords(session.transport); + const producer = readProducerPosition(words); + const consumer = readConsumerPosition(words); + const discard = readDiscardPosition(words); + const effectiveConsumer = consumer > discard ? consumer : discard; + const config = readPcmConfig(words); + return { + audioState: session.kernel.getAudioState(), + audioStates: session.audioStates.slice(), + workletAssetUrl: pcmAudioWorkletUrl, + workletPrepared: session.workletPrepared, + producerBytes: safeCursorNumber(producer, "producer"), + consumerBytes: safeCursorNumber(consumer, "consumer"), + discardBytes: safeCursorNumber(discard, "discard"), + queuedBytes: safeCursorNumber( + producer > effectiveConsumer ? producer - effectiveConsumer : 0n, + "queued", + ), + activeCapacityBytes: config.activeCapacityBytes, + settled: session.settled, + resumeAttempts: session.resumeAttempts, + trustedResumeAttempts: session.trustedResumeAttempts, + lastResumeError: session.lastResumeError, + stdout: session.stdout, + stderr: session.stderr, + hostDiagnostics: session.hostDiagnostics.slice(), + }; +} + +function withTimeout( + promise: Promise, + timeoutMs: number, + label: string, +): Promise { + return new Promise((resolve, reject) => { + const timer = window.setTimeout( + () => reject(new Error(`${label} timed out after ${timeoutMs} ms`)), + timeoutMs, + ); + promise.then( + (value) => { + window.clearTimeout(timer); + resolve(value); + }, + (error) => { + window.clearTimeout(timer); + reject(error); + }, + ); + }); +} + +async function finishAudioTest(): Promise { + const session = activeAudioTest; + activeAudioTest = null; + const resumeButton = document.getElementById("resume-audio") as HTMLButtonElement; + resumeButton.disabled = true; + document.getElementById("audio-status")!.textContent = "Audio test idle"; + if (!session) return; + session.unsubscribeAudioState?.(); + await session.kernel.destroy().catch(() => {}); + await settleWebKitReclaim(); +} + +function installAudioResumeButton(): void { + const resumeButton = document.getElementById("resume-audio") as HTMLButtonElement; + resumeButton.addEventListener("click", (event) => { + const session = activeAudioTest; + if (!session) return; + session.resumeAttempts++; + if (event.isTrusted && navigator.userActivation?.isActive) { + session.trustedResumeAttempts++; + } + session.lastResumeError = null; + resumeButton.disabled = true; + document.getElementById("audio-status")!.textContent = "Resuming audio..."; + void session.kernel.resumeAudio().then( + () => { + document.getElementById("audio-status")!.textContent = "Audio running"; + }, + (error) => { + session.lastResumeError = error instanceof Error ? error.message : String(error); + document.getElementById("audio-status")!.textContent = + `Audio resume failed: ${session.lastResumeError}`; + resumeButton.disabled = false; + }, + ); + }); +} + async function init() { const minimal = new URLSearchParams(window.location.search).get("minimal") === "1"; /* @@ -83,6 +261,107 @@ async function init() { window.__testCount = 0; + installAudioResumeButton(); + + window.__prepareAudioTest = async ( + wasmBytes: ArrayBuffer, + argv = ["audiotest"], + timeoutMs = 30_000, + ) => { + await finishAudioTest(); + + const buildFs = await createBuildFsWithEtc(); + const vfsImage = await finalizeKernelOwnedImage(buildFs); + let session: AudioTestSession | null = null; + const decoder = new TextDecoder(); + const hostDiagnostics: string[] = []; + const kernel = new BrowserKernel({ + kernelOwnedFs: true, + onStdout: (data: Uint8Array) => { + if (session) session.stdout += decoder.decode(data); + }, + onStderr: (data: Uint8Array) => { + if (session) session.stderr += decoder.decode(data); + }, + onHostDiagnostic: (diagnostic) => { + hostDiagnostics.push(`${diagnostic.source}: ${diagnostic.message}`); + }, + }); + + try { + await kernel.initFromImage({ kernelWasm: kernelWasmBytes!, vfsImage }); + session = { + kernel, + transport: audioTransportFor(kernel), + stdout: "", + stderr: "", + hostDiagnostics, + audioStates: [], + workletPrepared: false, + settled: false, + resumeAttempts: 0, + trustedResumeAttempts: 0, + lastResumeError: null, + }; + activeAudioTest = session; + session.unsubscribeAudioState = kernel.onAudioStateChange((state) => { + if (session && session.audioStates.at(-1) !== state) { + session.audioStates.push(state); + } + }); + + // Loading the default worklet URL is part of preparation. Force a + // suspended starting point even in browsers whose autoplay policy lets + // a newly-created context run, then queue guest PCM behind that clock. + await kernel.prepareAudio(); + session.workletPrepared = true; + await kernel.suspendAudio(); + const startedAt = performance.now(); + session.result = withTimeout( + kernel.spawn(wasmBytes, argv, { env: ["SDL_AUDIODRIVER=dsp"] }), + timeoutMs, + "browser /dev/dsp guest", + ).then((exitCode) => { + if (!session) throw new Error("Browser audio session disappeared"); + session.settled = true; + return { + ...snapshotAudioTest(session), + exitCode, + elapsedMs: performance.now() - startedAt, + }; + }); + + const resumeButton = document.getElementById( + "resume-audio", + ) as HTMLButtonElement; + resumeButton.disabled = false; + document.getElementById("audio-status")!.textContent = "Audio suspended; PCM may queue"; + return snapshotAudioTest(session); + } catch (error) { + if (activeAudioTest === session) activeAudioTest = null; + session?.unsubscribeAudioState?.(); + await kernel.destroy().catch(() => {}); + throw error; + } + }; + + window.__audioTestSnapshot = () => snapshotAudioTest(); + window.__waitForAudioTest = async () => { + const result = activeAudioTest?.result; + if (!result) throw new Error("Browser audio test has not started"); + return result; + }; + window.__suspendAudioTest = async () => { + const session = activeAudioTest; + if (!session) throw new Error("No browser audio test is active"); + await session.kernel.suspendAudio(); + const resumeButton = document.getElementById("resume-audio") as HTMLButtonElement; + resumeButton.disabled = false; + document.getElementById("audio-status")!.textContent = "Audio suspended"; + return snapshotAudioTest(session); + }; + window.__finishAudioTest = finishAudioTest; + window.__runTest = async ( wasmBytes: ArrayBuffer, argv?: string[], diff --git a/apps/browser-demos/test/dsp-audio-worklet.spec.ts b/apps/browser-demos/test/dsp-audio-worklet.spec.ts new file mode 100644 index 0000000000..1a4c00eddc --- /dev/null +++ b/apps/browser-demos/test/dsp-audio-worklet.spec.ts @@ -0,0 +1,160 @@ +import { expect, test } from "@playwright/test"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const programPath = resolve( + __dirname, + "../../../local-binaries/programs/wasm32/audiotest.wasm", +); + +type AudioSnapshot = { + audioState: string; + audioStates: string[]; + workletAssetUrl: string; + workletPrepared: boolean; + producerBytes: number; + consumerBytes: number; + discardBytes: number; + queuedBytes: number; + activeCapacityBytes: number; + settled: boolean; + resumeAttempts: number; + trustedResumeAttempts: number; + lastResumeError: string | null; + stdout: string; + stderr: string; + hostDiagnostics: string[]; +}; + +type AudioResult = AudioSnapshot & { + exitCode: number; + elapsedMs: number; +}; + +test("the production AudioWorklet drains /dev/dsp after a trusted resume", async ({ + page, + baseURL, + browserName, +}) => { + test.skip(browserName !== "chromium", "the aggregate browser gate uses Chromium"); + expect(baseURL).toBeTruthy(); + + const runtimeErrors: string[] = []; + page.on("pageerror", (error) => { + runtimeErrors.push(`pageerror: ${error.message}`); + }); + page.on("console", (message) => { + if (message.type() === "error") { + runtimeErrors.push(`console: ${message.text()}`); + } + }); + + await page.goto(new URL("/pages/test-runner/", baseURL).href); + await page.waitForFunction(() => (window as any).__testRunnerReady === true); + + try { + const programUrl = new URL(`/@fs/${programPath}`, baseURL).href; + const initial = await page.evaluate(async ({ programUrl }): Promise => { + const response = await fetch(programUrl); + if (!response.ok) { + throw new Error(`audiotest fetch failed: ${response.status} ${response.url}`); + } + return (window as any).__prepareAudioTest( + await response.arrayBuffer(), + ["audiotest"], + 30_000, + ); + }, { programUrl }); + + expect(initial.workletPrepared).toBe(true); + expect(initial.audioState).toBe("suspended"); + expect(initial.activeCapacityBytes).toBeGreaterThan(0); + const workletAsset = await page.evaluate(async (url) => { + const response = await fetch(url, { cache: "no-store" }); + return { + status: response.status, + url: response.url, + source: await response.text(), + }; + }, initial.workletAssetUrl); + expect(workletAsset.status, workletAsset.url).toBe(200); + expect(workletAsset.source).toContain("kandelo-pcm-output"); + expect(workletAsset.source).toContain("registerProcessor"); + + // The guest writes its deterministic buffer, then close(SYNC) must remain + // blocked while the AudioContext clock is suspended. No timer-based or + // instantaneous host drain is allowed to move the consumer cursor. + await expect.poll( + () => page.evaluate((): AudioSnapshot => (window as any).__audioTestSnapshot()), + { timeout: 15_000 }, + ).toMatchObject({ + audioState: "suspended", + settled: false, + stdout: expect.stringContaining("wrote 256"), + }); + const queued = await page.evaluate( + (): AudioSnapshot => (window as any).__audioTestSnapshot(), + ); + expect(queued.producerBytes).toBeGreaterThan(queued.consumerBytes); + expect(queued.queuedBytes).toBe(256); + + await page.waitForTimeout(250); + const held = await page.evaluate( + (): AudioSnapshot => (window as any).__audioTestSnapshot(), + ); + expect(held.consumerBytes).toBe(queued.consumerBytes); + expect(held.queuedBytes).toBe(queued.queuedBytes); + expect(held.settled).toBe(false); + + // Playwright's physical click dispatches a trusted event. The page's click + // handler calls BrowserKernel.resumeAudio() directly in that activation. + await page.getByRole("button", { name: "Resume audio" }).click(); + await expect.poll( + () => page.evaluate((): AudioSnapshot => (window as any).__audioTestSnapshot()), + { timeout: 10_000 }, + ).toMatchObject({ + audioState: "running", + resumeAttempts: 1, + trustedResumeAttempts: 1, + lastResumeError: null, + }); + + const result = await page.evaluate( + (): Promise => (window as any).__waitForAudioTest(), + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout.trim().split("\n")).toEqual([ + "ready 44100 2", + "wrote 256", + ]); + expect(result.stderr).toBe(""); + expect(result.hostDiagnostics).toEqual([]); + expect(result.producerBytes).toBeGreaterThan(0); + expect(result.consumerBytes).toBe(result.producerBytes); + expect(result.queuedBytes).toBe(0); + expect(result.settled).toBe(true); + + // Exercise the machine-level lifecycle once more after the clean drain: + // suspension is observable, and a second trusted gesture restores the + // same worklet-backed sink without rebuilding or replacing the transport. + const suspended = await page.evaluate( + (): Promise => (window as any).__suspendAudioTest(), + ); + expect(suspended.audioState).toBe("suspended"); + await page.getByRole("button", { name: "Resume audio" }).click(); + await expect.poll( + () => page.evaluate((): AudioSnapshot => (window as any).__audioTestSnapshot()), + { timeout: 10_000 }, + ).toMatchObject({ + audioState: "running", + resumeAttempts: 2, + trustedResumeAttempts: 2, + lastResumeError: null, + }); + + expect(runtimeErrors).toEqual([]); + } finally { + await page.evaluate(() => (window as any).__finishAudioTest?.()).catch(() => {}); + } +}); diff --git a/host/src/framebuffer/browser-controls.ts b/host/src/framebuffer/browser-controls.ts index 5bcbc4d7f4..c12a9ab8b9 100644 --- a/host/src/framebuffer/browser-controls.ts +++ b/host/src/framebuffer/browser-controls.ts @@ -1,11 +1,14 @@ /** - * Browser-side input/audio helpers for framebuffer demos. + * Browser-side input helpers for framebuffer demos. * * Rendering stays in `canvas-renderer.ts`; this module covers the two - * browser-only device bridges used by fbDOOM: + * browser-only device bridge used by fbDOOM: * * - Pointer Lock mouse deltas -> `/dev/input/mice` PS/2 packets. - * - `/dev/dsp` PCM ring drains -> Web Audio playback. + * + * The legacy PCM scheduler types remain exported below for source + * compatibility. Machine-level AudioWorklet playback supersedes that + * main-thread drain path. */ export interface MouseEventSink { @@ -80,6 +83,7 @@ export interface ScalePointerLockMouseDeltaOptions { clientHeight?: number; } +/** @deprecated Use the machine-level shared PCM transport. */ export interface AudioDrainSource { drainAudio(maxBytes: number): Promise<{ bytes: Uint8Array; @@ -88,6 +92,7 @@ export interface AudioDrainSource { }>; } +/** @deprecated The main-thread PCM scheduler has been retired. */ export interface PcmAudioSchedulerOptions { pollMs?: number; drainBytes?: number; @@ -95,6 +100,7 @@ export interface PcmAudioSchedulerOptions { maxLookaheadSeconds?: number; } +/** @deprecated Use the machine-level browser PCM driver lifecycle. */ export interface AudioOutputHandle { resume(): Promise; close(): void; @@ -103,8 +109,6 @@ export interface AudioOutputHandle { export const DEFAULT_POINTER_LOCK_MOUSE_SENSITIVITY = 4; -const AUDIO_POLL_MS = 50; -const AUDIO_DRAIN_BYTES = 32 * 1024; const MIN_MOUSE_DELTA = -128; const MAX_MOUSE_DELTA = 127; @@ -675,89 +679,28 @@ export function attachPointerLockMouse( }; } +/** + * Retained only so existing imports fail truthfully at runtime instead of + * silently reviving the retired timer-driven drain path. + * + * @deprecated Browser PCM playback now belongs to the machine-level + * AudioWorklet transport. Use `BrowserKernel.resumeAudio()` (or the matching + * `KernelHost` method) from a trusted user gesture. + */ export function createPcmAudioScheduler( source: AudioDrainSource, opts: PcmAudioSchedulerOptions = {}, ): AudioOutputHandle { - const AudioContextCtor = - globalThis.AudioContext ?? - (globalThis as typeof globalThis & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; - if (!AudioContextCtor) { - return { - resume: async () => {}, - close: () => {}, - getState: () => "unavailable", - }; - } - - const audioCtx = new AudioContextCtor(); - const pollMs = opts.pollMs ?? AUDIO_POLL_MS; - const drainBytes = opts.drainBytes ?? AUDIO_DRAIN_BYTES; - const lookaheadSeconds = opts.lookaheadSeconds ?? 0.04; - const maxLookaheadSeconds = opts.maxLookaheadSeconds ?? 0.15; - - let cursor = audioCtx.currentTime; - let sampleRate = 44100; - let channels = 2; - let stopped = false; - - const timer = globalThis.setInterval(async () => { - if (stopped || audioCtx.state !== "running") return; - - let drain; - try { - drain = await source.drainAudio(drainBytes); - } catch { - return; - } - - const bytes = drain.bytes; - if (bytes.byteLength === 0) return; - if (drain.sampleRate > 0) sampleRate = drain.sampleRate; - if (drain.channels > 0) channels = drain.channels; - - const bytesPerFrame = 2 * channels; - const frames = Math.floor(bytes.byteLength / bytesPerFrame); - if (frames === 0) return; - - const buffer = audioCtx.createBuffer(channels, frames, sampleRate); - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - for (let ch = 0; ch < channels; ch++) { - const dst = buffer.getChannelData(ch); - for (let i = 0; i < frames; i++) { - const sample = view.getInt16((i * channels + ch) * 2, true); - dst[i] = sample / 32768; - } - } - - const now = audioCtx.currentTime; - if (cursor < now + lookaheadSeconds) { - cursor = now + lookaheadSeconds; - } else if (cursor > now + maxLookaheadSeconds) { - cursor = now + lookaheadSeconds; - return; - } - - const node = audioCtx.createBufferSource(); - node.buffer = buffer; - node.connect(audioCtx.destination); - node.start(cursor); - cursor += frames / sampleRate; - }, pollMs); - + void source; + void opts; return { resume: async () => { - if (audioCtx.state === "suspended") { - await audioCtx.resume().catch(() => {}); - } - }, - close: () => { - if (stopped) return; - stopped = true; - globalThis.clearInterval(timer); - void audioCtx.close().catch(() => {}); + throw new Error( + "createPcmAudioScheduler is unavailable; use the machine-level AudioWorklet PCM driver", + ); }, - getState: () => audioCtx.state, + close: () => {}, + getState: () => "unavailable", }; } diff --git a/host/test/framebuffer-browser-controls.test.ts b/host/test/framebuffer-browser-controls.test.ts index e2ee0ac32f..688f3260a0 100644 --- a/host/test/framebuffer-browser-controls.test.ts +++ b/host/test/framebuffer-browser-controls.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { attachLinuxMediumRawKeyboard, + createPcmAudioScheduler, DEFAULT_POINTER_LOCK_MOUSE_SENSITIVITY, encodeKeyboardEventAsLinuxMediumRaw, encodeLinuxMediumRawKeyCode, @@ -141,4 +142,19 @@ describe("framebuffer browser controls", () => { { dx: 46, dy: -4, buttons: 0b101 }, ]); }); + + it("keeps the retired PCM scheduler export explicitly unavailable", async () => { + let drainCalls = 0; + const output = createPcmAudioScheduler({ + drainAudio: async () => { + drainCalls++; + return { bytes: new Uint8Array(), sampleRate: 48_000, channels: 2 }; + }, + }); + + expect(output.getState()).toBe("unavailable"); + await expect(output.resume()).rejects.toThrow("machine-level AudioWorklet PCM driver"); + expect(drainCalls).toBe(0); + expect(() => output.close()).not.toThrow(); + }); }); diff --git a/web-libs/kandelo-session/src/kernel-host.ts b/web-libs/kandelo-session/src/kernel-host.ts index 2784eae121..7253a486bb 100644 --- a/web-libs/kandelo-session/src/kernel-host.ts +++ b/web-libs/kandelo-session/src/kernel-host.ts @@ -183,13 +183,21 @@ export interface KernelLike { */ kmsAttachStats?(crtcId: number, stats: SharedArrayBuffer): void; /** - * Drain PCM bytes buffered in `/dev/dsp` for browser playback. + * Legacy main-thread PCM drain endpoint. + * + * @deprecated PCM playback is owned by the machine-level host and its + * AudioWorklet transport. New callers should use `resumeAudio`. */ drainAudio?(maxBytes: number): Promise<{ bytes: Uint8Array; sampleRate: number; channels: number; }>; + prepareAudio?(): Promise; + resumeAudio?(): Promise; + suspendAudio?(): Promise; + getAudioState?(): MachineAudioState; + onAudioStateChange?(cb: (state: MachineAudioState) => void): () => void; /** * Subscribe to the kernel-worker's live syscall trace. Each event * carries the raw syscall number + args + firing pid. The underlying @@ -334,12 +342,12 @@ export interface PtyHandle { /** * Handle returned by `attachFramebuffer`. The canvas is wired up to paint - * frames; this handle lets the embedder bridge input/audio for whichever + * frames; this handle lets the embedder bridge input for whichever * process is currently bound to `/dev/fb0` (fbDOOM, fbtest, etc.). * * Keyboard input is delivered as raw bytes to the bound process's stdin — * the same channel fbDOOM reads scancodes from. Mouse input goes through - * `/dev/input/mice`; audio drains from `/dev/dsp`. + * `/dev/input/mice`. PCM playback belongs to the machine-level host. */ export interface FramebufferHandle { /** Send raw bytes to the fb-bound process's stdin. No-op if nothing is bound. */ @@ -350,8 +358,11 @@ export interface FramebufferHandle { */ sendMouseEvent(dx: number, dy: number, buttons: number): void; /** - * Start draining `/dev/dsp` into Web Audio. Returns null when the wrapped - * kernel does not expose audio draining. + * Return a compatibility handle for the machine-level PCM output. + * + * @deprecated PCM is not framebuffer-scoped. Call `KernelHost.resumeAudio` + * from a trusted user gesture instead. Closing this compatibility handle + * does not stop machine audio. */ startAudio(): Promise; /** Pid currently bound to /dev/fb0, or null if no binding is live. */ @@ -362,12 +373,26 @@ export interface FramebufferHandle { close(): void; } +/** + * Legacy Web Audio-shaped output handle retained for source compatibility. + * + * @deprecated Use the machine-level audio methods on `KernelHost`. + */ export interface AudioOutputHandle { resume(): Promise; close(): void; getState(): AudioContextState | "unavailable"; } +export type MachineAudioState = + | "unavailable" + | "unprepared" + | "suspended" + | "running" + | "interrupted" + | "closed" + | "error"; + /** * Handle returned by `attachKmsDisplay`. The wrapped canvas is wired up * as the scanout target for a KMS CRTC; whatever wasm process holds @@ -625,9 +650,18 @@ export interface KernelHost { subscribeSyscalls(cb: (e: SyscallEvent) => void, filter?: SyscallFilter): () => void; syscallHistory(filter?: SyscallFilter): SyscallEvent[]; + // Machine-level physical/default PCM sink. Browser callers should invoke + // resumeAudio directly from a trusted user gesture. + prepareAudio(): Promise; + resumeAudio(): Promise; + suspendAudio(): Promise; + getAudioState(): MachineAudioState; + subscribeAudioState(cb: (state: MachineAudioState) => void): () => void; + // framebuffer — mirrors /dev/fb0 into a 2D canvas and returns a handle - // that the embedder uses to forward keyboard, mouse, and audio device - // traffic for the bound process. + // that the embedder uses to forward keyboard and mouse input for the bound + // process. PCM output is machine-level; startAudio remains as a deprecated + // compatibility adapter. attachFramebuffer(canvas: HTMLCanvasElement): FramebufferHandle; // KMS display — registers a canvas as the scanout target for a @@ -864,6 +898,7 @@ export class LiveKernelHost implements KernelHost { private galleryListeners = new ListenerSet(); private demoGuideListeners = new ListenerSet(); private demoIngestListeners = new ListenerSet(); + private audioStateListeners = new ListenerSet(); private _descriptor: BootDescriptor; private presentation: DemoPresentation; @@ -875,6 +910,7 @@ export class LiveKernelHost implements KernelHost { private surfaceAvailability: SurfaceAvailability = { ...DEFAULT_SURFACE_AVAILABILITY }; private offFramebufferAvailability: (() => void) | null = null; private offLazyDownloads: (() => void) | null = null; + private offAudioState: (() => void) | null = null; private kernel?: KernelLike; private shell?: NonNullable; @@ -924,6 +960,8 @@ export class LiveKernelHost implements KernelHost { this.offFramebufferAvailability = null; this.offLazyDownloads?.(); this.offLazyDownloads = null; + this.offAudioState?.(); + this.offAudioState = null; this.kernel = kernel; this.ptySessions.clear(); this.ptyAttachPromises.clear(); @@ -939,6 +977,12 @@ export class LiveKernelHost implements KernelHost { this.emitLazyDownloadEvent(event); }); } + if (kernel.onAudioStateChange) { + this.offAudioState = kernel.onAudioStateChange((state) => { + this.audioStateListeners.emit(state); + }); + } + this.audioStateListeners.emit(this.getAudioState()); this.refreshTerminalAvailability(); this.refreshFramebufferAvailability(); this.refreshKmsAvailability(); @@ -952,11 +996,14 @@ export class LiveKernelHost implements KernelHost { this.offFramebufferAvailability = null; this.offLazyDownloads?.(); this.offLazyDownloads = null; + this.offAudioState?.(); + this.offAudioState = null; this.kernel = undefined; this.ptySessions.clear(); this.ptyAttachPromises.clear(); this.ptyCommandQueues.clear(); this.shellPids.clear(); + this.audioStateListeners.emit("unavailable"); this.refreshTerminalAvailability(); this.refreshFramebufferAvailability(); this.setSurfaceAvailability({ web: false, kms: false }); @@ -1235,6 +1282,8 @@ export class LiveKernelHost implements KernelHost { this.offFramebufferAvailability = null; this.offLazyDownloads?.(); this.offLazyDownloads = null; + this.offAudioState?.(); + this.offAudioState = null; this.setSurfaceAvailability({ terminal: false, framebuffer: false, web: false, kms: false }); this.setDemoGuide(null); this.setDemoIngest(null); @@ -1711,6 +1760,34 @@ export class LiveKernelHost implements KernelHost { return history.slice(); } + async prepareAudio(): Promise { + if (!this.kernel?.prepareAudio) { + throw new Error("PCM output is unavailable"); + } + await this.kernel.prepareAudio(); + } + + async resumeAudio(): Promise { + if (!this.kernel?.resumeAudio) { + throw new Error("PCM output is unavailable"); + } + await this.kernel.resumeAudio(); + } + + async suspendAudio(): Promise { + await this.kernel?.suspendAudio?.(); + } + + getAudioState(): MachineAudioState { + return this.kernel?.getAudioState?.() ?? "unavailable"; + } + + subscribeAudioState(cb: (state: MachineAudioState) => void): () => void { + const off = this.audioStateListeners.add(cb); + cb(this.getAudioState()); + return off; + } + /** * Walk the parent chain of `pid` and return the shell pid it descends from * when it shares a terminal PTY for stdin. Used by attachFramebuffer to pick @@ -1860,11 +1937,28 @@ export class LiveKernelHost implements KernelHost { kernel.injectMouseEvent?.(dx, dy, buttons); }, startAudio: async () => { - if (!kernel.drainAudio) return null; - const { createPcmAudioScheduler } = await import("../../../host/src/framebuffer/browser-controls.js"); - return createPcmAudioScheduler({ - drainAudio: kernel.drainAudio.bind(kernel), - }); + if (!kernel.resumeAudio) return null; + return { + resume: () => kernel.resumeAudio!(), + // Audio is a machine resource shared by all Unix applications. A + // framebuffer-scoped compatibility handle must not tear it down. + close: () => {}, + getState: () => { + switch (kernel.getAudioState?.() ?? "unavailable") { + case "running": + return "running"; + case "closed": + return "closed"; + case "suspended": + case "interrupted": + case "unprepared": + return "suspended"; + case "unavailable": + case "error": + return "unavailable"; + } + }, + }; }, getBoundPid: () => attachedPid, onBoundPidChange: (cb) => boundPidListeners.add(cb), diff --git a/web-libs/kandelo-session/test/kandelo-session.test.ts b/web-libs/kandelo-session/test/kandelo-session.test.ts index f991df542d..14219c37bf 100644 --- a/web-libs/kandelo-session/test/kandelo-session.test.ts +++ b/web-libs/kandelo-session/test/kandelo-session.test.ts @@ -703,6 +703,83 @@ describe("LiveKernelHost: process listing", () => { }); }); +describe("LiveKernelHost: machine PCM lifecycle", () => { + it("forwards explicit activation and state changes independently of framebuffer", async () => { + let state: import("../src/kernel-host").MachineAudioState = "suspended"; + let emit: ((next: import("../src/kernel-host").MachineAudioState) => void) | null = null; + const prepareAudio = vi.fn(async () => {}); + const resumeAudio = vi.fn(async () => { + state = "running"; + emit?.(state); + }); + const suspendAudio = vi.fn(async () => { + state = "suspended"; + emit?.(state); + }); + const host = new LiveKernelHost({ + kernel: { + prepareAudio, + resumeAudio, + suspendAudio, + getAudioState: () => state, + onAudioStateChange: (cb: typeof emit) => { + emit = cb; + cb?.(state); + return () => { emit = null; }; + }, + } as never, + }); + const observed: string[] = []; + const off = host.subscribeAudioState((next) => observed.push(next)); + + await host.prepareAudio(); + await host.resumeAudio(); + expect(prepareAudio).toHaveBeenCalledOnce(); + expect(resumeAudio).toHaveBeenCalledOnce(); + expect(host.getAudioState()).toBe("running"); + await host.suspendAudio(); + expect(host.getAudioState()).toBe("suspended"); + expect(observed).toContain("running"); + expect(observed.at(-1)).toBe("suspended"); + + host.detachKernel(); + expect(observed.at(-1)).toBe("unavailable"); + off(); + }); + + it("retains framebuffer startAudio as a non-owning machine-audio adapter", async () => { + let state: import("../src/kernel-host").MachineAudioState = "suspended"; + const resumeAudio = vi.fn(async () => { state = "running"; }); + const suspendAudio = vi.fn(async () => { state = "suspended"; }); + const framebuffers = { + list: () => [], + onChange: () => () => {}, + }; + const host = new LiveKernelHost({ + kernel: { + framebuffers, + getProcessMemory: () => undefined, + resumeAudio, + suspendAudio, + getAudioState: () => state, + } as never, + }); + + const framebuffer = host.attachFramebuffer({} as HTMLCanvasElement); + const output = await framebuffer.startAudio(); + expect(output).not.toBeNull(); + expect(output!.getState()).toBe("suspended"); + await output!.resume(); + expect(resumeAudio).toHaveBeenCalledOnce(); + expect(output!.getState()).toBe("running"); + + output!.close(); + expect(suspendAudio).not.toHaveBeenCalled(); + expect(output!.getState()).toBe("running"); + framebuffer.close(); + }); +}); + describe("LiveKernelHost: shell command queue", () => { it("uses the worker-returned pid for a transferred shell binary", async () => { const outputPids: number[] = []; From 692b12b1a7cda6c001f66c827e419bbe8a3f6a5d Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Mon, 13 Jul 2026 19:30:32 -0400 Subject: [PATCH 52/82] Packages: Validate portable OSS consumers Forward-port the accepted SDL package and integration-test slice of PR #947 onto ABI 43. Build official SDL2 and SDL3 releases through the worktree SDK, keep their upstream OSS backends, and exercise paced PCM through the production Node path. The generated program-package projection remains reserved for the final artifact commit. Source: 66e8dcbe8dcb5da95de43b5b9da214a3879bbf4a --- apps/browser-demos/pages/kandelo/app/App.tsx | 2 +- apps/browser-demos/pages/kandelo/presets.ts | 2 +- .../test/kandelo-merge-gate.spec.ts | 14 +- docs/architecture.md | 166 ++++- docs/browser-support.md | 79 ++- docs/porting-guide.md | 65 ++ docs/posix-status.md | 109 ++- docs/sdk-guide.md | 7 + host/test/audio-integration.test.ts | 670 +++++++++++++----- host/test/sdl-dsp-fixtures.ts | 83 +++ packages/registry/fbdoom/build-fbdoom.sh | 16 +- packages/registry/fbdoom/build.toml | 6 +- packages/registry/fbdoom/package.toml | 8 +- .../patches/0003-add-sound-support.patch | 302 +++++--- .../0004-music-support-vendor-fixups.patch | 16 +- .../patches/0005-add-music-support.patch | 93 ++- .../sdl-dsp-test/build-sdl-dsp-test.sh | 60 ++ packages/registry/sdl-dsp-test/build.toml | 13 + packages/registry/sdl-dsp-test/package.toml | 25 + .../registry/sdl-dsp-test/src/sdl2-dsp-test.c | 144 ++++ .../registry/sdl-dsp-test/src/sdl3-dsp-test.c | 218 ++++++ .../build-sdl2-mixer-playwave.sh | 93 +++ .../registry/sdl2-mixer-playwave/build.toml | 11 + .../registry/sdl2-mixer-playwave/package.toml | 45 ++ packages/registry/sdl2/build-sdl2.sh | 133 ++++ packages/registry/sdl2/build.toml | 11 + packages/registry/sdl2/package.toml | 53 ++ .../0001-recognize-kandelo-as-unix.patch | 37 + packages/registry/sdl3/build-sdl3.sh | 112 +++ packages/registry/sdl3/build.toml | 13 + .../sdl3/cmake/Platform/Kandelo.cmake | 5 + .../sdl3/cmake/kandelo-toolchain.cmake | 23 + packages/registry/sdl3/package.toml | 60 ++ .../0001-recognize-kandelo-platform.patch | 24 + run.sh | 1 + sdk/src/lib/flags.ts | 7 + sdk/test/cc.test.ts | 2 + sdk/test/flags.test.ts | 2 + tests/package-system/sdl-dsp-packages.test.ts | 175 +++++ 39 files changed, 2529 insertions(+), 376 deletions(-) create mode 100644 host/test/sdl-dsp-fixtures.ts create mode 100644 packages/registry/sdl-dsp-test/build-sdl-dsp-test.sh create mode 100644 packages/registry/sdl-dsp-test/build.toml create mode 100644 packages/registry/sdl-dsp-test/package.toml create mode 100644 packages/registry/sdl-dsp-test/src/sdl2-dsp-test.c create mode 100644 packages/registry/sdl-dsp-test/src/sdl3-dsp-test.c create mode 100755 packages/registry/sdl2-mixer-playwave/build-sdl2-mixer-playwave.sh create mode 100644 packages/registry/sdl2-mixer-playwave/build.toml create mode 100644 packages/registry/sdl2-mixer-playwave/package.toml create mode 100644 packages/registry/sdl2/build-sdl2.sh create mode 100644 packages/registry/sdl2/build.toml create mode 100644 packages/registry/sdl2/package.toml create mode 100644 packages/registry/sdl2/patches/0001-recognize-kandelo-as-unix.patch create mode 100644 packages/registry/sdl3/build-sdl3.sh create mode 100644 packages/registry/sdl3/build.toml create mode 100644 packages/registry/sdl3/cmake/Platform/Kandelo.cmake create mode 100644 packages/registry/sdl3/cmake/kandelo-toolchain.cmake create mode 100644 packages/registry/sdl3/package.toml create mode 100644 packages/registry/sdl3/patches/0001-recognize-kandelo-platform.patch create mode 100644 tests/package-system/sdl-dsp-packages.test.ts diff --git a/apps/browser-demos/pages/kandelo/app/App.tsx b/apps/browser-demos/pages/kandelo/app/App.tsx index 7e4b1a5092..98c6d1482a 100644 --- a/apps/browser-demos/pages/kandelo/app/App.tsx +++ b/apps/browser-demos/pages/kandelo/app/App.tsx @@ -273,7 +273,7 @@ export const App: React.FC = () => { }, []); return ( -
+
{isEmpty ? ( { +test("Kandelo fbDOOM demo renders and starts the OSS audio sink", async ({ page }) => { test.setTimeout(240_000); await gotoOrSkip(page, "/?demo=doom"); - await expect(page.locator("canvas").first()).toBeVisible({ timeout: 180_000 }); + const canvas = page.locator("canvas").first(); + await expect(canvas).toBeVisible({ timeout: 180_000 }); + + await canvas.click({ position: { x: 8, y: 8 } }); + await expect(page.locator("[data-audio-state]").first()).toHaveAttribute( + "data-audio-state", + "running", + { timeout: 10_000 }, + ); await expect .poll(async () => { - return page.locator("canvas").first().evaluate((canvas: HTMLCanvasElement) => { + return canvas.evaluate((canvas: HTMLCanvasElement) => { if (canvas.width === 0 || canvas.height === 0) return false; const ctx = canvas.getContext("2d"); if (!ctx) return false; diff --git a/docs/architecture.md b/docs/architecture.md index cc5846847a..e44e358ee0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2061,29 +2061,153 @@ Single-open semantics match real Linux mousedev exclusive-grab. The host inverts ## Audio output (`/dev/dsp`) -The kernel exposes an OSS-style `/dev/dsp` character device so unmodified Linux audio software (fbDOOM, etc.) can play sound through a browser `AudioContext`. Direction is reversed vs. mouse: PCM samples flow **process → kernel → host**. - -``` - user process kernel-worker / kernel browser main thread - ──────────── ────────────────────── ─────────────────────── - open("/dev/dsp", O_WRONLY) - ioctl SNDCTL_DSP_SPEED ──► audio::set_sample_rate - ioctl SNDCTL_DSP_STEREO ──► audio::set_channels - ioctl SNDCTL_DSP_SETFMT ──► audio::set_format (must be S16_LE) - write(fd, pcm, len) ──► audio::write_pcm - push bytes to 256 KiB ring - (drop oldest whole frames on overflow) - ◄──────────── setInterval(50ms): drainAudio(maxBytes) - kernel_drain_audio(out_ptr, out_len) - drain whole-frame bytes from ring - ─────────────► decode S16 → Float32 - schedule AudioBufferSourceNode - on AudioContext clock +Kandelo separates the Unix compatibility API from its physical audio +transport. OSS is the first frontend; the state below it is an +implementation-neutral, playback-only PCM stream rather than an ALSA or Web +Audio model. + +```text + SDL2 / SDL3 / Unix application + open, ioctl, write, poll + | + OSS /dev/dsp frontend + | + Kandelo PCM stream core + format + geometry + monotonic cursors + | + shared bounded PCM transport + / \ + Browser AudioWorklet Node clocked sink ``` -The kernel does **not** mix or synthesize audio. The user program (DOOM's mixer in `i_kernel_sound.c` plus the OPL2 software synth in `i_oplmusic.c` + `opl/opl3.c` for music) does that work and writes interleaved S16_LE frames; the kernel ring is just transport. fbDOOM's mixer produces 1280 stereo frames per ~28 ms game tic — slightly more than the 1260 frames the AudioContext consumes per tic — so the ring stays full enough to hide drain jitter, and the drop-oldest-on-overflow policy keeps memory bounded. - -Single-open semantics match the typical OSS exclusive-grab model. Ownership is released on `close` of the last `/dev/dsp` fd or on process exit; a surviving non-CLOEXEC fd retains both ownership and queued samples across exec. The ring is flushed when ownership is released so a successor open hears silence rather than the previous owner's tail. ABI version bumped 7 → 8 to register the new `kernel_drain_audio(i64, i32) -> i32` export plus the three readouts `kernel_audio_sample_rate / channels / pending`. The OSS ioctl encodings live in `crates/shared/src/lib.rs::oss`. +The PCM core owns requested and actual format, sample rate and channel count; +fragment geometry; 64-bit monotonic producer, consumer, and discard positions; +started, stopped, and draining state; reset generation; underrun count; and +write/drain waiters. The OSS frontend translates fixed-width `soundcard.h` +arguments into that model. No Web Audio concept appears in the guest ABI. +Configuration fields are published under a configuring flag and become visible +as one generation. Finishing a drain also advances the generation, so host +resamplers reset their phase before the next logical playback stream. + +There is one physical/default playback device and, initially, one exclusive +stream. Ownership belongs to the open file description (OFD): `dup()` and +descriptors inherited through `fork()` share the stream, while a distinct +`open()` returns `EBUSY`. A surviving non-`CLOEXEC` descriptor keeps the same +stream across `exec`. There is no kernel mixer, routing policy, capture stream, +or concatenation of data from unrelated writers. + +The transport reserves one 64 KiB PCM ring plus a 128-byte fixed-width control +header in the kernel's shared Wasm memory: 65,664 bytes total. The active queue is latency-sized: +four 1024-byte fragments (4096 bytes) by default, and `SETFRAGMENT` may select +another geometry up to the 64 KiB physical bound. At the default 48 kHz, +stereo S16 configuration, 4096 bytes are about 21.3 ms of queued audio. The +host receives a descriptor for this same memory; it does not allocate a second +persistent PCM ring. Browser-engine Float32 render buffers are transient. + +Writes form one continuous byte stream and never discard previously queued +audio. Bytes from an incomplete PCM frame remain queued across later writes. +`SYNC` or final OFD close pads only a terminal incomplete frame with format +silence (`0x80` for U8 and zero for S16_LE/S16_BE) before draining; `POST` does +not fabricate padding. A frame-aligned blocking request no larger than the +active capacity waits until the entire request can be accepted, which covers +normal SDL periods. If an earlier unaligned write has left the ring ending in +an incomplete frame, a later blocking write may return the prefix that +completes that frame rather than deadlocking behind bytes the audio clock +cannot yet consume. Larger requests and nonblocking requests may likewise +report partial progress; a nonblocking write with no capacity returns +`EAGAIN`. +`poll(POLLOUT)` becomes ready only when at least one fragment is free. When the +host audio clock advances the consumer position, the kernel reconciles the +monotonic cursors and wakes writers, poll waiters, and drain waiters. Running +out of queued frames is an underrun and produces silence; it does not move the +producer or overwrite old frames. + +An explicit final `close()` drains to the audio clock before releasing the +exclusive device. `SNDCTL_DSP_RESET` is the explicit discard operation for an +application that does not want to drain. Exit, `CLOEXEC`, and forced teardown +cannot keep a syscall alive, so a queued tail becomes an orphan drain: the +device remains exclusive until the host consumes that tail, then releases +automatically. This prevents a final buffer from being truncated or joined to +the next opener's stream. Caught signals interrupt a blocked write, `SYNC`, or +explicit final close through the ordinary `EINTR` path. `SA_RESTART` restarts +write and `SYNC`; an interrupted close leaves the descriptor valid for an +explicit caller retry. + +In browsers an `AudioWorkletProcessor` consumes and converts PCM in render +quanta (normally 128 output frames), advances the shared consumer cursor, and +emits silence on underrun. The main thread only creates/resumes the +`AudioContext` and connects the node; it is not the audio clock and does not +drain through a timer. At 48 kHz one render quantum is about 2.7 ms; browser +device `baseLatency`/`outputLatency` is additional and platform-dependent. +After machine teardown drains the shared ring, a running browser context is +suspended to hand already-rendered blocks to the output device, then retained +for a bounded base/output-latency-plus-quantum settlement interval before it is +closed. Teardown never resumes a suspended or interrupted context, preserving +the browser's user-activation boundary. +Node uses the same cursor and wakeup contract with a wall-clock-paced null sink +for headless execution, so callbacks cannot run at CPU speed. Its running tick +follows the negotiated fragment duration, preserves fractional-frame drift, +and falls back to 10 ms polling while idle. + +A permanent sink failure is distinct from recoverable browser suspension. The +host latches a shared fatal flag and wakes the kernel: writes and drains fail +with `EIO`, polling reports `POLLERR`, and final close discards the unplayable +tail, releases ownership, and returns `EIO`. If the failure arrives after an +implicit close has orphaned a tail, reconciliation discards that unplayable +tail and releases ownership; no descriptor remains to receive `EIO`. A +suspended or interrupted `AudioContext` does not set that flag; it stops the +consumer clock and applies normal queue backpressure until resume. + +The AudioWorklet/shared-ring transport builds on the exploration in PR #698, +while deliberately omitting that experiment's ALSA state machine and +`/dev/snd` ABI. OSS command details are documented in +[POSIX status](posix-status.md#oss-playback-compatibility). + +### Measured PCM footprint + +The following historical measurements were recorded for the original PR #947 +implementation. They compare its clean starting commit +`92d5940f7e0107514ea12ab813d395257678377e` with that branch's ABI 40 result. +They establish the footprint of the audio architecture in that source branch; +they are not a current ABI 43 artifact-size claim. The ABI 43 integration must +be remeasured after its package artifacts are finalized. Compressed sizes use +`zstd -19`; JavaScript totals cover the same ten existing ESM entry files on +both sides, with the new worklet shown separately. + +| Artifact | Before raw / compressed | After raw / compressed | Delta | +|---|---:|---:|---:| +| Kernel Wasm | 532,311 / 143,233 B | 612,569 / 147,554 B | +80,258 / +4,321 B | +| Host ESM entries | 3,711,675 / 634,586 B | 3,771,682 / 644,709 B | +60,007 / +10,123 B | +| PCM AudioWorklet | absent | 8,863 / 2,462 B | new | +| `audiotest.wasm` | 30,180 / 13,092 B | 30,365 / 13,183 B | +185 / +91 B | +| `dsp_signal_test.wasm` | absent | 32,355 / 13,890 B | new | + +The upstream integration artifacts measure as follows: + +| Artifact | Raw / compressed (`zstd -19`) | +|---|---:| +| SDL2 2.32.10 `libSDL2.a` | 1,192,694 / 322,705 B | +| SDL3 3.4.10 `libSDL3.a` | 1,970,446 / 513,937 B | +| SDL2 DSP fixture Wasm | 1,573,606 / 391,329 B | +| SDL3 DSP fixture Wasm | 2,470,559 / 521,937 B | +| SDL_mixer 2.8.2 `playwave` Wasm | 1,800,045 / 434,029 B | + +The installed regular-file totals are 3,864,113 bytes for the SDL2 package, +5,545,678 bytes for SDL3, and approximately 4.04 MB for the combined fixture +package. Deterministically staged package archives measured approximately +0.66 MB, 0.95 MB, and 0.70 MB respectively after `zstd -19`; exact published +archives vary slightly with provenance strings. The test-only `playwave` +package contains one 1,800,045-byte regular file; its compressed size is the +434,029-byte value above. + +Steady-state transport memory is the fixed 65,664-byte allocation described +above, versus a growable old queue whose maximum occupancy was 262,144 PCM +bytes. The default active queue is 21.333 ms. A normal 128-frame browser +quantum adds 2.667 ms at 48 kHz, for 24 ms of configured software buffering +before platform-specific `baseLatency` and `outputLatency`. Node advances in +the default 256-frame (5.333 ms) fragment cadence against the same 21.333 ms +bounded queue. These are footprint and configured-buffer measurements, not a +throughput or performance claim. ## Signal Subsystem diff --git a/docs/browser-support.md b/docs/browser-support.md index 2609d905f7..df55748994 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -224,10 +224,67 @@ pipe pair. - Single-owner device (one process can hold `/dev/input/mice` open at a time; second open from another pid returns `EBUSY`). ### Audio output (`/dev/dsp`) -- The kernel exposes an OSS-style `/dev/dsp` character device. User programs `open(O_WRONLY)`, configure rate / channels / format via `SNDCTL_DSP_*` ioctls, and `write()` interleaved 16-bit-LE PCM. The kernel buffers samples in a 256 KiB ring (~1.5 s of stereo S16 @ 44.1 kHz). On overflow the *oldest* whole frame drops — same trade-off real OSS hardware makes under hardware overrun. -- Demo pages drive a `setInterval` loop (~50 ms cadence) that calls `BrowserKernel.drainAudio(maxBytes)`. The kernel-worker drains the ring via the `kernel_drain_audio` wasm export (which respects whole-frame boundaries so stereo L/R never tear) and posts the bytes back. Main thread converts S16 → Float32, builds an `AudioBuffer`, and schedules an `AudioBufferSourceNode` on the `AudioContext` clock with a small lookahead so brief drain hiccups don't underrun. -- Single-owner device. A non-CLOEXEC fd retains ownership and queued samples across `execve`; last close or process exit releases the owner and flushes the ring so a successor starts from silence. Format must be `AFMT_S16_LE`; other formats are `EINVAL`. -- **AudioContext gesture requirement.** `new AudioContext()` starts suspended in modern browsers and only resumes after a user gesture. The DOOM demo creates the context immediately after the user's "Start" click (which is itself a gesture), so `audioCtx.resume()` succeeds without a separate prompt. + +- The kernel exposes a playback-only OSS `/dev/dsp` frontend over its generic + PCM stream core. Applications may write U8, signed 16-bit little-endian, or + signed 16-bit big-endian mono/stereo PCM at 8–192 kHz. The worklet converts + those PCM concepts to the browser output format; Web Audio types are not + exposed to guests. +- The queue is bounded and backpressured. It defaults to four 1024-byte + fragments (4096 active bytes), within a fixed 65,664-byte transport + allocation (128-byte control header plus 65,536-byte ring). + The kernel never drops the oldest samples. Blocking writers sleep for + capacity, nonblocking writers receive partial progress or `EAGAIN`, and + `POLLOUT` requires at least one free fragment. Writes may end between PCM + frame boundaries: later writes continue the same byte stream, while a drain + pads only a terminal incomplete frame with format-appropriate silence. +- An `AudioWorkletProcessor`, running on the Web Audio render clock, reads the + shared PCM ring directly, advances the kernel-visible consumer cursor, and + outputs silence on underrun. There is no main-thread audio-drain timer and no + second persistent PCM queue; browser-provided Float32 output buffers are + transient. Consumption wakes blocked writes, `poll()`, and + `SNDCTL_DSP_SYNC`/close drain waiters in the kernel worker. +- The default 4096-byte queue is about 21.3 ms at 48 kHz stereo S16. A normal + 128-frame worklet render quantum adds about 2.7 ms at 48 kHz; the browser's + `AudioContext.baseLatency` and `outputLatency` are device-specific and must + be measured separately. Machine teardown first waits for the shared PCM ring + to drain. If the context is running, it then suspends the context so Web + Audio hands already-rendered blocks to the output device and waits a bounded + interval covering the reported base/output latency and final render quantum + before closing the context. A suspended or interrupted context is never + resumed implicitly during teardown. +- **A user gesture is required.** Preparing the PCM driver may leave its + `AudioContext` suspended. The application must call the session audio-resume + path from a click, keypress, or other browser-recognized activation. If the + context is suspended or interrupted, the consumer cursor intentionally + stops: the queue fills, writers apply backpressure, and drain/close stays + pending instead of pretending audio played. Resuming the context continues + from the queued position. +- Browser policy suspension and interruption are recoverable and do not poison + the stream. A permanent worklet, processor, or sink failure is latched into + the shared transport instead: blocked calls wake, `write()` and drain return + `EIO`, `poll()` reports `POLLERR`, and final close releases the exclusive + device after discarding only the tail that can no longer be played. A fatal + failure during an orphan drain likewise discards the unplayable tail and + releases ownership instead of wedging subsequent opens. +- The one physical device is exclusive by OFD. `dup()` and inherited + descriptors share it; another `open()` gets `EBUSY`. Explicit final close + drains. Exit or `CLOEXEC` leaves any queued tail as an orphan drain and keeps + the device busy until the worklet reaches it. `RESET` is the explicit way to + discard a tail. A caught signal can interrupt a blocked write, drain, or + explicit close; write and drain honor `SA_RESTART`, while interrupted close + leaves the fd valid for the caller to retry. +- Capture, duplex, `mmap`, OSS mixer devices, and kernel multi-client mixing + are not implemented. `open(O_RDONLY)` and `open(O_RDWR)` fail with + `ENOTSUP`, so browser software does not discover a fake recording device. + +Node uses the same transport and state transitions. Its default headless sink +advances the consumer position from elapsed wall-clock time at the configured +sample rate and emits consumed bytes to an optional observer; it never drains +the queue instantaneously or keeps a second PCM copy. Running ticks follow the +negotiated fragment duration, preserve fractional-frame drift, and use 10 ms +idle polling. Applications therefore see the same write and SDL callback +pacing even when no physical Node audio device is attached. ## Browser Demos @@ -263,6 +320,20 @@ The "Boot pattern" column reflects how the demo enters the kernel: Run the browser app: `cd apps/browser-demos && npm run dev`, then open `http://127.0.0.1:5401/`. +For a manual OSS playback check after changing the port, first run +`./run.sh clean fbdoom && ./run.sh build fbdoom` so an ignored local artifact +from an older package revision cannot be reused. Then run `./run.sh browser`, +select the fbDOOM demo, and click the framebuffer to satisfy the browser's +audio-activation requirement. The title-screen music checks the software OPL +path; starting a game and firing the pistol checks mixed sound effects. Quit +through DOOM's menu to exercise the normal `/dev/dsp` drain-and-close path. +This demo is a direct OSS consumer, not an SDL test; the `sdl-dsp-test` package +and host audio integration suite exercise the unmodified SDL2 and SDL3 `dsp` +backends. That suite also runs SDL_mixer 2.8.2's unmodified `playwave` example +against deterministic WAVs and compares the Node sink's consumed PCM exactly. +Browser output remains a manual audible check because the production +AudioWorklet intentionally exposes transport cursors, not rendered samples. + ### Kandelo session UI The Kandelo app at `/pages/kandelo/` keeps the running machine as the primary diff --git a/docs/porting-guide.md b/docs/porting-guide.md index 0d9631f411..99220504bd 100644 --- a/docs/porting-guide.md +++ b/docs/porting-guide.md @@ -63,6 +63,71 @@ headers from an arbitrary host LLVM install; the libcxx package generates and ships a version-matched header tree with its `libc++.a` and `libc++abi.a`. See `packages/registry/mariadb/build-mariadb.sh` for a complete example. +### OSS playback with `/dev/dsp` + +Kandelo's standard low-level audio API is OSS `/dev/dsp`, backed by the generic +PCM stream core. Do not enable ALSA or copy Linux `/dev/snd` headers into a +port. Direct users include ``, open `/dev/dsp` with +`O_WRONLY`, negotiate an advertised format/rate/channel count, and use +blocking `write()` or `poll(POLLOUT)` plus nonblocking writes. See the exact +[ioctl matrix](posix-status.md#oss-playback-compatibility). +Writes are a continuous byte stream, so an application may split a PCM frame +across calls. `SNDCTL_DSP_SYNC` and final close pad a terminal partial frame +with format silence and wait for it to reach the audio clock. + +Upstream SDL needs no DSP-backend source changes. The registry packages build +the official releases with only their OSS audio backend: + +```bash +cargo xtask build-deps resolve sdl2 +cargo xtask build-deps resolve sdl3 +``` + +For an external SDL2 build, retain the equivalent of: + +```bash +./configure --host=wasm32-unknown-none \ + --enable-static --disable-shared \ + --enable-audio --enable-oss \ + --disable-alsa --disable-pulseaudio --disable-pipewire \ + --disable-jack --disable-sndio +``` + +SDL2 2.32.x does not classify `wasm32-unknown-none` as a Unix target upstream, +so the Kandelo package carries a minimal platform-classification patch to its +generated configure logic. It does not change `src/audio/dsp/`. + +For SDL3, configure a truthful Kandelo CMake toolchain and retain: + +```bash +cmake -S SDL -B build \ + -DCMAKE_TOOLCHAIN_FILE=/path/to/kandelo-toolchain.cmake \ + -DSDL_STATIC=ON -DSDL_SHARED=OFF \ + -DSDL_UNIX_CONSOLE_BUILD=ON \ + -DSDL_AUDIO=ON -DSDL_OSS=ON \ + -DSDL_ALSA=OFF -DSDL_PULSEAUDIO=OFF -DSDL_PIPEWIRE=OFF \ + -DSDL_JACK=OFF -DSDL_SNDIO=OFF +``` + +The registry's SDL3 patch only teaches its platform detector that Kandelo is +Unix; its OSS backend is also unmodified. Do not claim Linux or Emscripten as +the target to make feature detection pass. + +At runtime the default path is already `/dev/dsp`. Set +`SDL_AUDIODRIVER=dsp` when a deterministic backend choice is needed (tests, +headless runners, or images that may gain another driver later). SDL also +honors its upstream device-path overrides (`AUDIODEV`, and `SDL_PATH_DSP` in +SDL2). Playback supports U8/S16_LE/S16_BE, mono/stereo, and 8–192 kHz. SDL2's +backend is paced primarily by blocking `write()`; SDL3 additionally waits on +truthful `SNDCTL_DSP_GETOSPACE` results. Capture/duplex opens fail with +`ENOTSUP`, so do not advertise recording support in application UI. + +The host audio integration suite also builds SDL_mixer 2.8.2's upstream +`playwave` example unchanged and runs it with `SDL_AUDIODRIVER=dsp`. It checks +44.1 kHz signed-16 stereo and 22.05 kHz unsigned-8 mono WAVs byte-for-byte at +the paced Node sink, including negotiated fragment geometry, the SDL2 OSS +ioctl sequence, complete drain, and zero discarded data. + ### Step 3: Test it ```bash diff --git a/docs/posix-status.md b/docs/posix-status.md index 3d9d2dbe6d..e083a9d549 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -400,11 +400,118 @@ proves and reserves only the 128-byte prefix the kernel can write. | `/dev/pts/*` | Full | PTY slave devices. `posix_openpt()` + `grantpt()` + `unlockpt()` + `ptsname()`. Full line discipline, canonical/raw mode, OPOST/ONLCR, 16 terminal ioctls. | | `/dev/fb0` | Full | Linux fbdev framebuffer. Single-open (`EBUSY` for second opener). 640×400 BGRA32 packed-pixel. ioctls: `FBIOGET_VSCREENINFO`, `FBIOGET_FSCREENINFO`, `FBIOPAN_DISPLAY` (no-op success), `FBIOPUT_VSCREENINFO` (validates geometry). `mmap` returns a region in process memory and notifies the host (`bind_framebuffer` callback) so the browser canvas can mirror pixels. `munmap`/`exit`/`exec` discard the image mapping; a surviving fd retains device ownership across exec. Ownership is released after both the final fd and any live mapping are gone, since a mapping remains valid after `close()`. Linux-VT keyboard ioctls (`KDGKBTYPE`/`KDGKBMODE`/`KDSKBMODE`) are accepted on the process's terminal fd so fbDOOM-style software works unmodified; `/dev/fb0` itself is not a terminal. | | `/dev/input/mice` | Full | Linux `mousedev` PS/2 mouse stream. Single-open (`EBUSY` for second pid). 3-byte packets: byte0 button bits + sign/overflow flags, bytes 1..2 signed dx/dy with positive-up dy. Host pushes events via `kernel_inject_mouse_event(dx, dy, buttons)`; the kernel buffers up to 4096 packets (whole-packet drop on overflow). `read()` drains queued bytes; returns `EAGAIN` when empty. `poll()` reports `POLLIN` only when bytes are queued. Ownership and queued packets survive exec with a non-CLOEXEC fd; last close or exit releases and clears them. No IMPS/2 wheel protocol, no `evdev`/`/dev/input/eventN`. | -| `/dev/dsp` | Full (write-only) | OSS-style PCM audio sink. Single-open (`EBUSY` for second pid). `write()` accepts interleaved 16-bit-LE PCM and buffers it in a 256 KiB ring; the host drains via the `kernel_drain_audio` wasm export and feeds a Web Audio `AudioContext`. ioctls: `SNDCTL_DSP_RESET`, `SNDCTL_DSP_SYNC`, `SNDCTL_DSP_SPEED` (clamp 4000–192000 Hz), `SNDCTL_DSP_STEREO` / `SNDCTL_DSP_CHANNELS` (1 or 2), `SNDCTL_DSP_SETFMT` (only `AFMT_S16_LE`), `SNDCTL_DSP_GETFMTS`, `SNDCTL_DSP_SETFRAGMENT` (accept-and-acknowledge). On overflow drops the *oldest whole frame* — never tears L/R alignment. Ownership and queued samples survive exec with a non-CLOEXEC fd; last close or exit releases and flushes them. `read()` returns 0 (EOF-like). `poll()` reports `POLLOUT` always, never `POLLIN`. No record path, no `mmap`-based zero-copy; DOOM's mixer is in user space. | +| `/dev/dsp` | Partial (playback only) | Source-compatible OSS PCM playback over the implementation-neutral Kandelo PCM core. U8/S16_LE/S16_BE, mono/stereo, 8–192 kHz; bounded fragment queue with blocking/nonblocking backpressure and audio-clock drain. Exclusive ownership is per OFD, not PID. See the matrix below. Capture, duplex, mmap, mixer controls, and multi-client mixing are unsupported. | | `/dev/shm/*` | Partial | POSIX shm objects are regular files used by `shm_open()`. Stable-identity backends support host-coordinated `MAP_SHARED` across processes at syscall boundaries; this is not immediate shared linear memory and does not make process-shared futexes work. | Character-device entries return synthetic `stat()` with deterministic inode numbers and `st_dev=5`. Descriptor aliases are devfs symlinks: following metadata comes from the referenced descriptor, and no-follow metadata uses a deterministic devfs inode. Path interception happens in the kernel before host delegation, so Node.js and browser hosts share the same behavior without host filesystem changes. `access()` returns OK for all virtual devices. +## OSS playback compatibility + +Kandelo owns a fixed wasm32 OSS source ABI in ``; it does not +import a Linux host UAPI. `audio_buf_info` is four signed 32-bit fields in the +canonical `fragments`, `fragstotal`, `fragsize`, `bytes` order (16 bytes, +4-byte alignment). `count_info` is three signed 32-bit fields (`bytes`, +`blocks`, `ptr`; 12 bytes, 4-byte alignment). The C header, Rust constants, +and ABI snapshot assert every numeric value and layout. Canonical source aliases +are emitted by the same Rust-owned generator and carry C equality assertions. +The command semantics follow +the established [FreeBSD PCM/OSS frontend](https://github.com/freebsd/freebsd-src/blob/main/sys/dev/sound/pcm/dsp.c) +and [canonical OSS definitions](https://github.com/torvalds/linux/blob/master/include/uapi/linux/soundcard.h), +while the numeric encodings below are specifically Kandelo's wasm32 ABI. + +| Command | wasm32 value | Support and observable behavior | +|---|---:|---| +| `SNDCTL_DSP_RESET` (`SNDCTL_DSP_HALT`, `SOUND_PCM_RESET`) | `0x00005000` | Discards queued output, stops the stream, advances the reset generation, and wakes capacity/drain waiters. This is the explicit non-draining shutdown operation. | +| `SNDCTL_DSP_SYNC` (`SOUND_PCM_SYNC`) | `0x00005001` | Pads a terminal incomplete PCM frame with format silence, then blocks until the host audio clock has consumed all queued frames. Existing signal interruption rules apply; it is not an acknowledge-only no-op. | +| `SNDCTL_DSP_SPEED` (`SOUND_PCM_WRITE_RATE`) | `0xc0045002` | Signed 32-bit in/out rate. Zero queries the current rate; nonzero requests clamp to 8000–192000 Hz and return the actual value. Reconfiguration while running or draining returns `EBUSY`. | +| `SOUND_PCM_READ_RATE` | `0x80045002` | Returns the current actual rate as a signed 32-bit value without changing the configuration. | +| `SNDCTL_DSP_STEREO` | `0xc0045003` | Signed 32-bit in/out legacy mono/stereo selector (`0`/`1`), returning the actual selection. | +| `SNDCTL_DSP_GETBLKSIZE` | `0xc0045004` | Returns the actual fragment size in bytes. | +| `SNDCTL_DSP_SETFMT` (`SNDCTL_DSP_SAMPLESIZE`, `SOUND_PCM_SETFMT`, `SOUND_PCM_WRITE_BITS`) | `0xc0045005` | Signed 32-bit in/out format. Supports `AFMT_U8`, `AFMT_S16_LE`, and `AFMT_S16_BE`; `AFMT_QUERY` returns the current format. Reconfiguration while running or draining returns `EBUSY`. | +| `SOUND_PCM_READ_BITS` | `0x80045005` | Returns the current actual sample width (`8` or `16`) as a signed 32-bit value without changing the configuration. | +| `SNDCTL_DSP_CHANNELS` (`SOUND_PCM_WRITE_CHANNELS`) | `0xc0045006` | Signed 32-bit in/out channel count. Zero queries; requests negotiate to mono or stereo and return the actual count. | +| `SOUND_PCM_READ_CHANNELS` | `0x80045006` | Returns the current actual channel count as a signed 32-bit value without changing the configuration. | +| `SNDCTL_DSP_POST` (`SOUND_PCM_POST`) | `0x00005008` | Starts output without fabricating data. An empty stream underruns to silence. | +| `SNDCTL_DSP_SETFRAGMENT` (`SOUND_PCM_SETFRAGMENT`) | `0xc004500a` | Signed 32-bit in/out geometry: high 16 bits request fragment count, low 16 bits request log2 fragment bytes. The exponent clamps to 4–16. A zero count selects the maximum whole-fragment count fitting 65536 bytes; a nonzero count clamps to at least two fragments when two fit, then to that maximum. Thus exponent 16 truthfully selects the only possible geometry: one 64 KiB fragment. Returns the encoded actual geometry. Reconfiguration while running or draining returns `EBUSY`. | +| `SNDCTL_DSP_GETFMTS` (`SOUND_PCM_GETFMTS`) | `0x8004500b` | Returns exactly the supported playback format mask: U8, S16_LE, and S16_BE. | +| `SNDCTL_DSP_GETOSPACE` (`SOUND_PCM_GETOSPACE`) | `0x8010500c` | Returns truthful `audio_buf_info`: whole immediately available fragments, total fragments, fragment bytes, and all immediately writable bytes (including a partial fragment). SDL3 uses `bytes` to pace its wait path. | +| `SNDCTL_DSP_NONBLOCK` (`SOUND_PCM_NONBLOCK`) | `0x0000500e` | Sets `O_NONBLOCK` on the shared OFD. `fcntl()`/`FIONBIO` may subsequently manage that status flag through the normal descriptor path. | +| `SNDCTL_DSP_GETCAPS` (`SOUND_PCM_GETCAPS`) | `0x8004500f` | Returns only `PCM_CAP_OUTPUT | PCM_CAP_VIRTUAL | PCM_CAP_DEFAULT`. It does not advertise capture, duplex, mmap, trigger, or multi-open capabilities. | +| `SNDCTL_DSP_GETOPTR` (`SOUND_PCM_GETOPTR`) | `0x800c5012` | Returns `count_info`: low 32 bits of audio-clock-consumed bytes, fragment transitions since the previous query on this stream, and the consumer byte offset modulo active capacity. `RESET` discards do not advance it; drain padding counts when played. | +| `SNDCTL_DSP_GETODELAY` | `0x80045017` | Returns all queued, not-yet-consumed output bytes, including terminal-frame padding once a drain begins. | + +The SDK header also defines the canonical FreeBSD OSS format identifiers for +mu-law, A-law, IMA ADPCM, signed 8-bit, unsigned 16/24/32-bit, signed +24/32-bit, MPEG, AC3, and 32-bit float formats, including native- and +opposite-endian aliases. This is source compatibility, not an advertisement: +`GETFMTS` contains only U8/S16_LE/S16_BE, and `SETFMT` returns `EINVAL` for the +other identifiers. + +The following canonical command numbers are pinned in the SDK header so source +and ioctl marshalling cannot drift, but the playback-only frontend rejects each +one with `ENOTTY`; none is accepted as a no-op or advertised by `GETCAPS`. + +| Unsupported command | wasm32 value | Boundary | +|---|---:|---| +| `SNDCTL_DSP_SETBLKSIZE` | `0x40045004` | FreeBSD's distinct block-size setter is source-visible, but direct block-size setting is not implemented. It does not collide with the supported `GETBLKSIZE` request. | +| `SOUND_PCM_WRITE_FILTER` | `0xc0045007` | Legacy PCM filter control is not implemented. | +| `SOUND_PCM_READ_FILTER` | `0x80045007` | Legacy PCM filter query is not implemented. | +| `SNDCTL_DSP_SUBDIVIDE` (`SOUND_PCM_SUBDIVIDE`) | `0xc0045009` | Legacy fragment subdivision is not implemented; use `SETFRAGMENT`. | +| `SNDCTL_DSP_GETISPACE` (`SOUND_PCM_GETISPACE`) | `0x8010500d` | Capture-space query; capture is not implemented. | +| `SNDCTL_DSP_SETTRIGGER` (`SOUND_PCM_SETTRIGGER`) | `0x40045010` | Trigger control is not implemented. The header defines canonical values `PCM_ENABLE_INPUT=1` and `PCM_ENABLE_OUTPUT=2` without advertising trigger capability. | +| `SNDCTL_DSP_GETTRIGGER` (`SOUND_PCM_GETTRIGGER`) | `0x80045010` | Trigger control is not implemented. | +| `SNDCTL_DSP_GETIPTR` (`SOUND_PCM_GETIPTR`) | `0x800c5011` | Capture-position accounting is not implemented. | +| `SNDCTL_DSP_MAPINBUF` (`SOUND_PCM_MAPINBUF`) | `0x80085013` | Direct mapped capture buffers are not implemented. | +| `SNDCTL_DSP_MAPOUTBUF` (`SOUND_PCM_MAPOUTBUF`) | `0x80085014` | Direct mapped playback buffers are not implemented. | +| `SNDCTL_DSP_SETSYNCRO` (`SOUND_PCM_SETSYNCRO`) | `0x00005015` | Synchronized input/output start is not implemented. | +| `SNDCTL_DSP_SETDUPLEX` | `0x00005016` | Duplex operation is not implemented. | + +The initial stream is 48 kHz stereo S16_LE with four 1024-byte fragments. +Configuration calls record both requested and returned actual values. Format, +rate, channel, and fragment changes return `EBUSY` while running or draining; +they are accepted again after a successful `SYNC` leaves the stream stopped, +or after `RESET`. Writes +accept arbitrary byte lengths and form one continuous PCM byte stream; a +partial frame remains queued across subsequent writes. `SYNC` and final OFD +close pad a terminal partial frame with `0x80` for U8 or `0x00` for either S16 +format before draining. `POST` does not pad. A frame-aligned blocking write no +larger than active capacity waits for the full request, including normal SDL +period writes. If a prior unaligned write has left an incomplete frame at a +full queue boundary, a later blocking call may return the prefix that completes +that frame so playback can resume; callers must handle that ordinary Unix +short-write result. Requests larger than capacity may also advance partially. +With `O_NONBLOCK`, available bytes are accepted immediately, and no capacity +returns `EAGAIN`. `poll(POLLOUT)` is reported only when at least one fragment +is free. Consumer progress wakes writers and poll/drain waiters. Underruns +output silence; queue overflow never discards older audio. +Underruns are counted once per continuous starvation episode, and a short, +successfully draining tail is not classified as an underrun. + +The one physical/default device is exclusively owned by its OFD. `dup()` and +fork inheritance share it; a separate `open()` returns `EBUSY`, including one +from the same PID. Explicit final `close()` drains before releasing ownership. +Exit, `CLOEXEC`, or forced teardown leaves a queued tail draining and keeps the +device busy until the audio clock reaches the producer. A non-`CLOEXEC` +descriptor and its queued state survive `exec`. Caught signals interrupt a +blocked write, `SYNC`, or final close with `EINTR`; `SA_RESTART` applies to +write and `SYNC`, while an interrupted close keeps the descriptor open for a +caller-directed retry. + +A permanent host sink failure is latched and wakes every affected waiter. +Further writes and drains fail with `EIO`, `poll()` exposes `POLLERR`, and final +close discards the now-unplayable tail, releases the fd/OFD and exclusive +ownership, and returns `EIO`. If the failure arrives during an orphan drain +after exit or `CLOEXEC`, reconciliation discards the unplayable orphan tail and +releases exclusive ownership; there is no fd left to report `EIO` through. +Browser user-activation suspension is recoverable backpressure and does not set +this fatal state. + +Capture opens (`O_RDONLY` and `O_RDWR`) fail with `ENOTSUP`; they do not expose +a device that returns EOF. Unsupported and unknown ioctls fail with `ENOTTY`, +`mmap()` fails with `ENODEV`, and seek-style operations fail with `ESPIPE`. `/dev/mixer`, +recording, duplex, trigger control, direct mapped playback, and kernel mixing +remain explicit gaps. + ## Environment | Function | Status | Notes | diff --git a/docs/sdk-guide.md b/docs/sdk-guide.md index 0ee3f7c5dc..e00332d01a 100644 --- a/docs/sdk-guide.md +++ b/docs/sdk-guide.md @@ -176,6 +176,7 @@ not currently supported. ``` --target=wasm32-unknown-unknown # Wasm target triple +-D__unix__=1 -D__unix=1 # Kandelo Unix source-environment identity -matomics # Enable atomics (SharedArrayBuffer) -mbulk-memory # Enable bulk memory operations -mexception-handling # Enable Wasm exception handling @@ -192,6 +193,12 @@ not currently supported. # , or /usr/src/kandelo-sdk/sysroot64 for the wasm64 . ``` +The generic LLVM Wasm triple does not imply an operating system. The SDK +defines the conventional reserved Unix macros because Kandelo supplies a Unix +and POSIX userspace; it deliberately does not define Linux, FreeBSD, WASI, or +Emscripten platform macros. This lets upstream source choose generic Unix +interfaces such as OSS without misrepresenting the kernel it will run on. + The file, debug, and macro prefix maps cover paths owned and injected by the SDK. Linked Wasm debug information and `__FILE__` strings therefore do not depend on the checkout containing `libc/glue` or the target sysroot. Build diff --git a/host/test/audio-integration.test.ts b/host/test/audio-integration.test.ts index e91808ea17..1bbfa70b84 100644 --- a/host/test/audio-integration.test.ts +++ b/host/test/audio-integration.test.ts @@ -1,211 +1,501 @@ /** - * Integration test for /dev/dsp. - * - * Spawns programs/audiotest.c which: - * - opens /dev/dsp with O_WRONLY - * - configures sample rate (44100), stereo, AFMT_S16_LE via OSS ioctls - * - prints "ready \n" - * - writes a 256-byte PCM frame sequence (byte i = i & 0xff) - * - prints "wrote \n" - * - exits 0 - * - * The test then asserts: - * - the program exited cleanly - * - audioSampleRate() / audioChannels() report what audiotest configured - * - drainAudio() returns the same 256 bytes the program wrote - * - * Runs with the shared kernel and one worker per process — same harness as - * mouse-integration.test.ts. + * End-to-end `/dev/dsp` playback through the same paced Node sink used by the + * worker-thread host. No legacy pull drain participates in these tests: the + * shared-clock transport is claimed before the guest starts, and descriptor + * close cannot complete until the null sink's wall clock consumes the tail. */ -import { describe, it, expect } from "vitest"; -import { existsSync, readFileSync } from "node:fs"; -import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { CAPTURED_STDIO, CentralizedKernelWorker } from "../src/kernel-worker"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { beforeAll, describe, expect, it } from "vitest"; +import { NodePcmDriver } from "../src/audio/node-pcm-driver"; +import { + pcmControlWords, + readConsumerPosition, + readDiscardPosition, + readPcmConfig, + readProducerPosition, + type PcmTransportDescriptor, +} from "../src/audio/pcm-transport"; import { NodePlatformIO } from "../src/platform/node"; -import { NodeWorkerAdapter } from "../src/worker-adapter"; -import { detectPtrWidth } from "../src/constants"; -import type { - CentralizedWorkerInitMessage, - WorkerToHostMessage, -} from "../src/worker-protocol"; -import { TestProcessReferenceOwners } from "./process-reference-owner-helper"; +import { resolveBinary } from "../src/binary-resolver"; +import type { CentralizedKernelWorker } from "../src/kernel-worker"; +import { ABI_SYSCALLS } from "../src/generated/abi"; +import { runCentralizedProgram } from "./centralized-test-helper"; +import { ensureSdlDspFixtures } from "./sdl-dsp-fixtures"; -const __dirname = dirname(fileURLToPath(import.meta.url)); +const SNDCTL_DSP_SPEED = 0xc004_5002; +const SNDCTL_DSP_SETFMT = 0xc004_5005; +const SNDCTL_DSP_CHANNELS = 0xc004_5006; +const SNDCTL_DSP_SETFRAGMENT = 0xc004_500a; +const SNDCTL_DSP_GETFMTS = 0x8004_500b; +const SNDCTL_DSP_GETOSPACE = 0x8010_500c; -const audiotestBinary = join(__dirname, "../wasm/audiotest.wasm"); -const kernelBinary = join(__dirname, "../wasm/kandelo-kernel.wasm"); +interface AudioProgramResult { + exitCode: number; + stdout: string; + stderr: string; + elapsedMs: number; + consumed: Uint8Array; + sampleRate: number; + channels: number; + frameBytes: number; + fragmentBytes: number; + fragments: number; + activeCapacityBytes: number; + drainedAtExit: boolean; + cleanTail: boolean; + producerBytes: number; + consumerBytes: number; + discardedBytes: number; + ioctlRequests: number[]; +} -const MAX_PAGES = 16384; -const CH_TOTAL_SIZE = 72 + 65536; +async function runAudioProgram( + relativePath: string, + argv: string[], + timeoutMs = 15_000, +): Promise { + const consumedChunks: Uint8Array[] = []; + let kernel: CentralizedKernelWorker | null = null; + let pcmDriver: NodePcmDriver | null = null; + let transport: PcmTransportDescriptor | null = null; + let initialProducer = 0n; + let initialConsumer = 0n; + let initialDiscard = 0n; + const start = performance.now(); + try { + const result = await runCentralizedProgram({ + programPath: resolveBinary(relativePath), + argv, + env: ["SDL_AUDIODRIVER=dsp"], + timeout: timeoutMs, + io: new NodePlatformIO(), + onKernelReady: async (readyKernel) => { + kernel = readyKernel; + // This opt-in trace is scoped to the fixture process and is drained + // after exit. It proves which unmodified upstream backend path ran + // without adding a production-only ioctl counter or weakening the ABI. + readyKernel.enableSyscallTrace(); + transport = readyKernel.claimPcmTransport(false); + const words = pcmControlWords(transport); + initialProducer = readProducerPosition(words); + initialConsumer = readConsumerPosition(words); + initialDiscard = readDiscardPosition(words); + pcmDriver = new NodePcmDriver({ + clockUpdate: (frames) => readyKernel.pcmClockUpdate(frames), + onConsume: ({ bytes }) => consumedChunks.push(bytes.slice()), + }); + await pcmDriver.prepare(transport); + }, + }); + const elapsedMs = performance.now() - start; + const activeKernel = kernel as CentralizedKernelWorker | null; + if (!activeKernel) throw new Error("PCM kernel hook did not run"); + const activeTransport = transport as PcmTransportDescriptor | null; + if (!activeTransport) throw new Error("PCM transport hook did not run"); + const words = pcmControlWords(activeTransport); + const config = readPcmConfig(words); + // Snapshot before the teardown helper gets any opportunity to finish an + // orphan drain. The fixture's explicit SDL device close must itself have + // reached the audio clock and released the OFD before process exit. + const drainedAtExit = + readProducerPosition(words) === readConsumerPosition(words) && + readDiscardPosition(words) === initialDiscard; + const cleanTail = await activeKernel.waitForPcmDrain(1000); + const producerBytes = Number(readProducerPosition(words) - initialProducer); + const consumerBytes = Number(readConsumerPosition(words) - initialConsumer); + const discardedBytes = Number(readDiscardPosition(words) - initialDiscard); + const ioctlRequests = activeKernel + .drainSyscallTrace() + .filter((event) => event.nr === ABI_SYSCALLS.Ioctl) + .map((event) => event.args[1] >>> 0); + const total = consumedChunks.reduce( + (sum, chunk) => sum + chunk.byteLength, + 0, + ); + const consumed = new Uint8Array(total); + let offset = 0; + for (const chunk of consumedChunks) { + consumed.set(chunk, offset); + offset += chunk.byteLength; + } + return { + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + elapsedMs, + consumed, + sampleRate: activeKernel.audioSampleRate(), + channels: activeKernel.audioChannels(), + frameBytes: config.frameBytes, + fragmentBytes: config.fragmentBytes, + fragments: config.fragments, + activeCapacityBytes: config.activeCapacityBytes, + drainedAtExit, + cleanTail, + producerBytes, + consumerBytes, + discardedBytes, + ioctlRequests, + }; + } finally { + await pcmDriver?.close().catch(() => {}); + kernel?.shutdownPcmTransport(); + } +} -function loadProgramWasm(path: string): ArrayBuffer { - const buf = readFileSync(path); - return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); +interface WaveFixture { + bytes: Uint8Array; + pcm: Uint8Array; + sampleRate: number; + channels: number; + bitsPerSample: 8 | 16; + frameBytes: number; + periodFrames: number; + periodBytes: number; + durationMs: number; + silenceByte: number; } -function createProcessMemory(initialPages: number): WebAssembly.Memory { - return new WebAssembly.Memory({ - initial: initialPages, - maximum: MAX_PAGES, - shared: true, - }); +function deterministicWave( + sampleRate: number, + channels: 1 | 2, + bitsPerSample: 8 | 16, + periods: number, +): WaveFixture { + const periodFrames = 4096; + const sampleBytes = bitsPerSample / 8; + const frameBytes = channels * sampleBytes; + const frames = periodFrames * periods; + const pcm = new Uint8Array(frames * frameBytes); + const pcmView = new DataView(pcm.buffer); + + for (let frame = 0; frame < frames; frame++) { + if (bitsPerSample === 8) { + // Stay away from unsigned 8-bit silence (0x80), making the exact + // beginning and end of playwave's sample observable in the sink. + for (let channel = 0; channel < channels; channel++) { + pcm[frame * frameBytes + channel] = + 32 + ((frame + channel * 17) % 64); + } + } else { + const left = ((frame % 257) - 128) * 123; + const offset = frame * frameBytes; + pcmView.setInt16(offset, left, true); + if (channels === 2) pcmView.setInt16(offset + 2, -left, true); + } + } + + const bytes = new Uint8Array(44 + pcm.byteLength); + const view = new DataView(bytes.buffer); + const ascii = (offset: number, text: string) => { + for (let i = 0; i < text.length; i++) bytes[offset + i] = text.charCodeAt(i); + }; + ascii(0, "RIFF"); + view.setUint32(4, 36 + pcm.byteLength, true); + ascii(8, "WAVE"); + ascii(12, "fmt "); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, channels, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * frameBytes, true); + view.setUint16(32, frameBytes, true); + view.setUint16(34, bitsPerSample, true); + ascii(36, "data"); + view.setUint32(40, pcm.byteLength, true); + bytes.set(pcm, 44); + + return { + bytes, + pcm, + sampleRate, + channels, + bitsPerSample, + frameBytes, + periodFrames, + periodBytes: periodFrames * frameBytes, + durationMs: (frames * 1000) / sampleRate, + silenceByte: bitsPerSample === 8 ? 0x80 : 0, + }; } -describe.skipIf(!existsSync(audiotestBinary))("audio integration", () => { - it("/dev/dsp buffers PCM written by a program; host drains it back verbatim", async () => { - const programBytes = loadProgramWasm(audiotestBinary); - const kernelWasmBytes = loadProgramWasm(kernelBinary); - const ptrWidth = detectPtrWidth(programBytes); - expect(ptrWidth).toBe(4); - - const io = new NodePlatformIO(); - const workerAdapter = new NodeWorkerAdapter(); - const referenceOwners = new TestProcessReferenceOwners(); - const workers = new Map< - number, - ReturnType - >(); - - let pid = 0; - - let stdout = ""; - let resolveExit: (status: number) => void; - let rejectExit: (reason: Error) => void; - const exitPromise = new Promise((resolve, reject) => { - resolveExit = resolve; - rejectExit = reject; - }); +function expectExactPlaywavePcm( + consumed: Uint8Array, + fixture: WaveFixture, +): void { + const start = consumed.findIndex((byte) => byte !== fixture.silenceByte); + expect( + start, + "playwave never produced non-silent sample data", + ).toBeGreaterThanOrEqual(0); + expect(start % fixture.periodBytes).toBe(0); + expect(consumed.byteLength % fixture.periodBytes).toBe(0); + expect( + (consumed.byteLength - fixture.pcm.byteLength) % fixture.periodBytes, + ).toBe(0); + expect(start + fixture.pcm.byteLength).toBeLessThanOrEqual( + consumed.byteLength, + ); - const kernel = new CentralizedKernelWorker( - { - maxWorkers: 4, - dataBufferSize: 65536, - useSharedMemory: true, - enableSyscallLog: false, - }, - io, - { - onExit: (exitPid, exitStatus) => { - if (exitPid === pid) { - referenceOwners.release(exitPid); - kernel.unregisterProcess(exitPid); - const w = workers.get(exitPid); - if (w) { - w.terminate().catch(() => {}); - workers.delete(exitPid); - } - resolveExit(exitStatus); - } - }, - }, - ); + expect(consumed.slice(0, start)).toEqual( + new Uint8Array(start).fill(fixture.silenceByte), + ); + expect(consumed.slice(start, start + fixture.pcm.byteLength)).toEqual( + fixture.pcm, + ); + expect(consumed.slice(start + fixture.pcm.byteLength)).toEqual( + new Uint8Array(consumed.byteLength - start - fixture.pcm.byteLength).fill( + fixture.silenceByte, + ), + ); - let stderr = ""; - kernel.setOutputCallbacks({ - onStdout: (data: Uint8Array) => { - stdout += new TextDecoder().decode(data); - }, - onStderr: (data: Uint8Array) => { - stderr += new TextDecoder().decode(data); - }, - }); + // SDL2 may prime its device or race one final callback while playwave polls + // Mix_Playing(). Bound that behavior while accepting only whole periods. + expect(consumed.byteLength - fixture.pcm.byteLength).toBeLessThanOrEqual( + fixture.periodBytes * 8, + ); +} - await kernel.init(kernelWasmBytes); - pid = kernel.createProcess(CAPTURED_STDIO); - - const memory = createProcessMemory(17); - const channelOffset = (MAX_PAGES - 2) * 65536; - memory.grow(MAX_PAGES - 17); - new Uint8Array(memory.buffer, channelOffset, CH_TOTAL_SIZE).fill(0); - - kernel.registerProcess(pid, memory, [channelOffset], { ptrWidth }); - const referenceInit = referenceOwners.start(pid); - - const initData: CentralizedWorkerInitMessage = { - type: "centralized_init", - pid, - programBytes, - memory, - channelOffset, - argv: ["audiotest"], - env: [], - ptrWidth, - ...referenceInit, - }; +describe("audio integration", () => { + beforeAll(() => ensureSdlDspFixtures(), 20 * 60_000); - const mainWorker = workerAdapter.createWorker(initData); - referenceOwners.attach(pid, mainWorker); - mainWorker.on("error", rejectExit); - mainWorker.on("message", (raw: unknown) => { - const message = raw as WorkerToHostMessage; - if (message.type === "error" && message.pid === pid) { - rejectExit(new Error(message.message)); + it("paces and consumes the deterministic OSS PCM fixture verbatim", async () => { + const result = await runAudioProgram("programs/audiotest.wasm", [ + "audiotest", + ]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout.trim().split("\n")).toEqual([ + "ready 44100 2", + "wrote 256", + ]); + expect(result.sampleRate).toBe(44_100); + expect(result.channels).toBe(2); + expect(result.drainedAtExit).toBe(true); + expect(result.cleanTail).toBe(true); + expect(result.consumed.byteLength).toBe(256); + expect(result.producerBytes).toBe(256); + expect(result.consumerBytes).toBe(256); + expect(result.discardedBytes).toBe(0); + for (let i = 0; i < result.consumed.byteLength; i++) { + expect(result.consumed[i]).toBe(i & 0xff); + } + }, 30_000); + + for (const fixture of [ + { + relativePath: "programs/sdl-dsp-test/sdl2-dsp-test.wasm", + argv0: "sdl2-dsp-test", + sdlMajor: 2, + rate: 22_050, + format: "U8", + channels: 1, + }, + { + relativePath: "programs/sdl-dsp-test/sdl3-dsp-test.wasm", + argv0: "sdl3-dsp-test", + sdlMajor: 3, + rate: 48_000, + format: "S16LE", + channels: 2, + }, + ]) { + it(`runs upstream SDL${fixture.sdlMajor}'s dsp backend at real-time pace`, async () => { + const result = await runAudioProgram( + fixture.relativePath, + [fixture.argv0], + 20_000, + ); + expect(result.exitCode, result.stderr).toBe(0); + const resultLines = result.stdout + .split("\n") + .filter((line) => line.startsWith("SDL_DSP_RESULT ")); + expect(resultLines, result.stdout).toHaveLength(1); + const report = JSON.parse( + resultLines[0]!.slice("SDL_DSP_RESULT ".length), + ) as { + sdl_major: number; + requested_rate: number; + requested_format: string; + requested_channels: number; + actual_rate: number; + actual_format: string; + actual_channels: number; + callbacks: number; + frames: number; + pcm_bytes: number; + period_frames?: number; + elapsed_ms: number; + close_ms: number; + paced: boolean; + }; + + expect(report).toMatchObject({ + sdl_major: fixture.sdlMajor, + requested_rate: fixture.rate, + requested_format: fixture.format, + requested_channels: fixture.channels, + actual_rate: fixture.rate, + actual_format: fixture.format, + actual_channels: fixture.channels, + paced: true, + }); + expect(report.callbacks).toBeGreaterThanOrEqual(2); + expect(report.frames).toBeGreaterThan(0); + expect(report.elapsed_ms).toBeGreaterThanOrEqual(750); + expect(report.elapsed_ms).toBeLessThan(2500); + expect(report.close_ms).toBeLessThan(2000); + expect(result.elapsedMs).toBeGreaterThanOrEqual(750); + expect(result.elapsedMs).toBeLessThan(5000); + expect(report.pcm_bytes).toBe( + report.frames * (fixture.sdlMajor === 2 ? 1 : 4), + ); + expect(result.producerBytes).toBeGreaterThan(0); + const startupSilenceBytes = + fixture.sdlMajor === 2 ? result.producerBytes - report.pcm_bytes : 0; + if (fixture.sdlMajor === 2) { + // SDL2 primes OSS playback with callback-sized silent periods before + // it starts delivering application callback data. The count depends + // on how many device periods elapse while its audio thread starts, + // but each period and every following pattern byte remain exact. + expect(report.period_frames).toBeGreaterThan(0); + expect(startupSilenceBytes).toBeGreaterThanOrEqual( + report.period_frames!, + ); + expect(startupSilenceBytes % report.period_frames!).toBe(0); + expect(startupSilenceBytes).toBeLessThanOrEqual( + report.period_frames! * 4, + ); + expect(result.producerBytes).toBe( + report.pcm_bytes + startupSilenceBytes, + ); + } else { + // SDL3 may retain a callback-produced suffix in its AudioStream when + // it destroys the stream; every byte that reached /dev/dsp is still + // verified below and drained according to the transport cursors. + expect(result.producerBytes).toBeLessThanOrEqual(report.pcm_bytes); } - }); - workers.set(pid, mainWorker); - - try { - const exitCode = await Promise.race([ - exitPromise, - new Promise((_, reject) => - setTimeout( - () => - reject( - new Error( - "audiotest didn't exit in 10s" + - (stderr ? `: ${stderr}` : ""), - ), - ), - 10_000, - ), - ), - ]); - expect(exitCode).toBe(0); - void stderr; - - // Stdout: "ready 44100 2\nwrote 256\n" - const lines = stdout.split("\n").filter((l) => l.length > 0); - expect(lines).toHaveLength(2); - const ready = lines[0].split(" "); - expect(ready[0]).toBe("ready"); - expect(parseInt(ready[1], 10)).toBe(44100); - expect(parseInt(ready[2], 10)).toBe(2); - const wrote = lines[1].split(" "); - expect(wrote[0]).toBe("wrote"); - expect(parseInt(wrote[1], 10)).toBe(256); - - // Kernel-side config readouts agree with what the program asked for. - expect(kernel.audioSampleRate()).toBe(44100); - expect(kernel.audioChannels()).toBe(2); - - // Drain the ring. audiotest writes 256 bytes that the kernel's - // 4-byte stereo frame alignment rounds down to 256 (256 / 4 * - // 4 = 256). The first call should pull all 256 bytes; we loop - // defensively in case the host returns fewer per call. - const drained = new Uint8Array(256); - let total = 0; - for (let attempt = 0; attempt < 8 && total < drained.length; attempt++) { - const chunk = new Uint8Array(drained.length - total); - const n = kernel.drainAudio(chunk); - if (n === 0) break; - drained.set(chunk.subarray(0, n), total); - total += n; + expect(result.consumerBytes).toBe(result.producerBytes); + expect(result.discardedBytes).toBe(0); + expect(result.consumed.byteLength).toBe(result.producerBytes); + const expectedPcm = new Uint8Array(result.consumed.byteLength); + for (let offset = 0; offset < result.consumed.byteLength; offset++) { + if (fixture.sdlMajor === 2) { + expectedPcm[offset] = + offset < startupSilenceBytes + ? 0x80 + : 32 + ((offset - startupSilenceBytes) % 192); + } else { + const phase = Math.floor(offset / 4) % 200; + const sample = (phase - 100) * 240; + const right = -sample; + expectedPcm[offset] = [ + sample & 0xff, + (sample >> 8) & 0xff, + right & 0xff, + (right >> 8) & 0xff, + ][offset % 4]!; + } } - expect(total).toBe(256); - // audiotest wrote pcm[i] = i & 0xff - for (let i = 0; i < 256; i++) { - expect(drained[i]).toBe(i & 0xff); + expect(result.consumed).toEqual(expectedPcm); + expect(result.drainedAtExit).toBe(true); + expect(result.cleanTail).toBe(true); + if (fixture.sdlMajor === 3) { + expect(result.ioctlRequests).toContain(SNDCTL_DSP_GETOSPACE); } + }, 30_000); + } - // Ring is empty after the drain. - expect(kernel.audioPending()).toBe(0); - const after = new Uint8Array(64); - expect(kernel.drainAudio(after)).toBe(0); - } finally { - for (const [, w] of workers) await w.terminate().catch(() => {}); - referenceOwners.close(); - void exitPromise.catch(() => {}); - } + for (const playwave of [ + { + name: "default S16 stereo", + fixture: deterministicWave(44_100, 2, 16, 5), + args: [] as string[], + opened: "Opened audio at 44100 Hz 16 bit stereo", + }, + { + name: "requested U8 mono", + fixture: deterministicWave(22_050, 1, 8, 3), + args: ["-8", "-m", "-r", "22050"], + opened: "Opened audio at 22050 Hz 8 bit mono", + }, + ]) { + it( + `plays upstream SDL_mixer playwave's ${playwave.name} WAV exactly`, + async () => { + const tempDir = mkdtempSync(join(tmpdir(), "kandelo-playwave-")); + const wavePath = join(tempDir, "deterministic.wav"); + writeFileSync(wavePath, playwave.fixture.bytes); + + let result: AudioProgramResult; + try { + result = await runAudioProgram( + "programs/playwave.wasm", + ["playwave", ...playwave.args, wavePath], + 20_000, + ); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + + expect(result.exitCode, result.stderr).toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain(playwave.opened); + expect(result.sampleRate).toBe(playwave.fixture.sampleRate); + expect(result.channels).toBe(playwave.fixture.channels); + expect(result.frameBytes).toBe(playwave.fixture.frameBytes); + expect(result.fragmentBytes).toBe(playwave.fixture.periodBytes); + expect(result.fragments).toBe(2); + expect(result.activeCapacityBytes).toBe( + playwave.fixture.periodBytes * 2, + ); + + // The sample itself must take approximately this much audio-clock + // time; startup periods and process setup may make the total longer. + expect(result.elapsedMs).toBeGreaterThanOrEqual( + playwave.fixture.durationMs * 0.75, + ); + expect(result.elapsedMs).toBeLessThan(5000); + expect(result.producerBytes).toBeGreaterThanOrEqual( + playwave.fixture.pcm.byteLength, + ); + expect(result.consumerBytes).toBe(result.producerBytes); + expect(result.consumed.byteLength).toBe(result.producerBytes); + expect(result.discardedBytes).toBe(0); + expect(result.drainedAtExit).toBe(true); + expect(result.cleanTail).toBe(true); + expectExactPlaywavePcm(result.consumed, playwave.fixture); + + for (const request of [ + SNDCTL_DSP_GETFMTS, + SNDCTL_DSP_SETFMT, + SNDCTL_DSP_CHANNELS, + SNDCTL_DSP_SPEED, + SNDCTL_DSP_SETFRAGMENT, + ]) { + expect(result.ioctlRequests).toContain(request); + } + }, + 30_000, + ); + } + + it("paces SDL through the production dedicated Node kernel worker", async () => { + const start = performance.now(); + const result = await runCentralizedProgram({ + programPath: resolveBinary("programs/sdl-dsp-test/sdl2-dsp-test.wasm"), + argv: ["sdl2-dsp-test"], + env: ["SDL_AUDIODRIVER=dsp"], + timeout: 20_000, + useDefaultRootfs: false, + }); + const elapsedMs = performance.now() - start; + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain('\"sdl_major\":2'); + expect(result.stdout).toContain('\"paced\":true'); + expect(elapsedMs).toBeGreaterThanOrEqual(750); + expect(elapsedMs).toBeLessThan(5000); + expect(result.hostDiagnostics).toEqual([]); }, 30_000); }); diff --git a/host/test/sdl-dsp-fixtures.ts b/host/test/sdl-dsp-fixtures.ts new file mode 100644 index 0000000000..be13d22475 --- /dev/null +++ b/host/test/sdl-dsp-fixtures.ts @@ -0,0 +1,83 @@ +import { execFileSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const testDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(testDir, "../.."); +const resolveBinaryScript = join(repoRoot, "scripts/resolve-binary.sh"); + +const SDL_DSP_FIXTURES = [ + "programs/sdl-dsp-test/sdl2-dsp-test.wasm", + "programs/sdl-dsp-test/sdl3-dsp-test.wasm", + "programs/playwave.wasm", +] as const; + +const SDL_DSP_PACKAGES = [ + "sdl-dsp-test", + "sdl2-mixer-playwave", +] as const; + +function binaryResolves(relativePath: string): boolean { + try { + execFileSync("bash", [resolveBinaryScript, relativePath], { + cwd: repoRoot, + stdio: "ignore", + }); + return true; + } catch { + return false; + } +} + +/** + * Materialize the upstream-SDL integration fixtures through the normal + * package resolver. This helper is imported only by audio-integration.test.ts + * so unrelated focused Vitest runs do not build SDL. + */ +export function ensureSdlDspFixtures(): void { + const rustcVersion = execFileSync("rustc", ["-vV"], { + cwd: repoRoot, + encoding: "utf8", + }); + const hostTarget = /^host:\s*(\S+)$/m.exec(rustcVersion)?.[1]; + if (!hostTarget) { + throw new Error( + "[audio-integration] rustc -vV did not report a host target", + ); + } + + // Always enter through the resolver. It owns dependency and source/cache + // invalidation; merely accepting an old output symlink could run stale SDL + // fixtures after package metadata, patches, or the cache key changes. + console.log("[audio-integration] Resolving SDL2/SDL3 /dev/dsp fixtures..."); + for (const packageName of SDL_DSP_PACKAGES) { + execFileSync( + "cargo", + [ + "run", + "-p", + "xtask", + "--target", + hostTarget, + "--quiet", + "--", + "build-deps", + "resolve", + packageName, + "--arch", + "wasm32", + "--binaries-dir", + join(repoRoot, "binaries"), + ], + { cwd: repoRoot, stdio: "inherit" }, + ); + } + + for (const fixture of SDL_DSP_FIXTURES) { + if (!binaryResolves(fixture)) { + throw new Error( + `[audio-integration] package resolver did not materialize ${fixture}`, + ); + } + } +} diff --git a/packages/registry/fbdoom/build-fbdoom.sh b/packages/registry/fbdoom/build-fbdoom.sh index 005a390dfa..22d46312ed 100755 --- a/packages/registry/fbdoom/build-fbdoom.sh +++ b/packages/registry/fbdoom/build-fbdoom.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Cross-compile maximevince/fbDOOM for Kandelo using wasm32posix-cc. The # fbdev frontend writes BGRA32 pixels into the framebuffer mmap; the canvas -# renderer consumes them. +# renderer consumes them, and the OSS frontend writes PCM to `/dev/dsp`. # # A direct build writes packages/registry/fbdoom/fbdoom.wasm. Resolver and # Formula builds instead write only below their declared work and output roots. @@ -119,9 +119,14 @@ echo "==> Cleaning previous build..." make clean || true echo "==> Cross-compiling fbdoom (wasm32, NOSDL=1)..." -# fbDOOM's Makefile wires NOSDL=1 to the framebuffer and null-audio frontend. -# Passing -lc explicitly would duplicate the SDK-injected channel syscall glue; -# retain -lm because the SDK does not inject libm. +# fbDOOM's own Makefile wires NOSDL=1 to the framebuffer frontend; the +# patch series adds a conventional OSS PCM module alongside it. We only +# override the toolchain here. +# +# LIBS="-lm" — wasm32posix-cc auto-injects channel_syscall.c plus the +# musl libc.a; passing -lc explicitly (the upstream Makefile default) +# would cause duplicate-symbol errors for fork / _Fork / __syscall_cp. +# We keep -lm because the SDK doesn't auto-link libm. make CC=wasm32posix-cc \ LD=wasm32posix-cc \ CFLAGS="-O2 -DNORMALUNIX -DLINUX -D_DEFAULT_SOURCE -Iopl" \ @@ -136,7 +141,8 @@ ls -la "$OUT_BIN" echo "==> fbdoom.wasm built." # No IWAD is bundled. The browser demo fetches the freely redistributable Doom -# shareware IWAD at page load and caches it via the Cache API. +# shareware IWAD at page load and caches it via the Cache API; see +# apps/browser-demos/pages/doom/main.ts. cd "$REPO_ROOT" source "$REPO_ROOT/scripts/install-local-binary.sh" install_local_binary fbdoom "$OUT_BIN" fbdoom.wasm diff --git a/packages/registry/fbdoom/build.toml b/packages/registry/fbdoom/build.toml index d4c2159798..d3d74ed609 100644 --- a/packages/registry/fbdoom/build.toml +++ b/packages/registry/fbdoom/build.toml @@ -4,9 +4,9 @@ inputs = [ "packages/registry/fbdoom/patches", "scripts/package-build-roots.sh", ] -repo_url = "https://github.com/brandonpayton/kandelo.git" -commit = "8c53383229fab78f97b098c3207a655159c03041" -revision = 5 +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "UNPUBLISHED" +revision = 6 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/fbdoom/package.toml b/packages/registry/fbdoom/package.toml index 4a1d2e64a7..d7e24374da 100644 --- a/packages/registry/fbdoom/package.toml +++ b/packages/registry/fbdoom/package.toml @@ -1,13 +1,15 @@ kind = "program" name = "fbdoom" version = "0.1.0" -# rev5: coalesce queued PS/2 mouse packets into one Doom mouse event per -# input poll so pointer-lock motion is not lost inside a game tic. rev4 fixed +# rev6: use the SDK OSS API, negotiate a latency-sized buffer, and complete +# short writes so the generic /dev/dsp backend remains correctly paced. rev5 +# coalesced queued PS/2 mouse packets into one Doom mouse event per input poll +# so pointer-lock motion is not lost inside a game tic. rev4 fixed # fbDOOM's wasm exit path by avoiding an invalid G_CheckDemoStatus -> # atexit_func_t cast. rev3 dropped the bundled IWAD; the browser demo fetches # the DOOM shareware `doom1.wad` at page load from a Linux-distro mirror and # caches it via the Cache API. The archive carries only `fbdoom.wasm`. -kernel_abi = 7 +kernel_abi = 43 depends_on = [] # Upstream has no release tarball, so this package pins an exact commit archive. diff --git a/packages/registry/fbdoom/patches/0003-add-sound-support.patch b/packages/registry/fbdoom/patches/0003-add-sound-support.patch index a89bcd93ca..9fdc6edc06 100644 --- a/packages/registry/fbdoom/patches/0003-add-sound-support.patch +++ b/packages/registry/fbdoom/patches/0003-add-sound-support.patch @@ -6,26 +6,24 @@ index fb32c7b..b566875 100644 #CFLAGS+=-fsanitize=address OBJS+=$(OBJDIR)/i_video_fbdev.o OBJS+=$(OBJDIR)/i_input_tty.o -+OBJS+=$(OBJDIR)/i_kernel_sound.o ++OBJS+=$(OBJDIR)/i_oss_sound.o CC=$(CROSS_COMPILE)gcc # gcc or g++ CFLAGS+=-ggdb3 -Os -diff --git a/fbdoom/i_kernel_sound.c b/fbdoom/i_kernel_sound.c +diff --git a/fbdoom/i_oss_sound.c b/fbdoom/i_oss_sound.c new file mode 100644 index 0000000..082b14b --- /dev/null -+++ b/fbdoom/i_kernel_sound.c -@@ -0,0 +1,285 @@ ++++ b/fbdoom/i_oss_sound.c +@@ -0,0 +1,410 @@ +/* -+ * i_kernel_sound.c — chocolate-doom sound module talking to /dev/dsp. ++ * i_oss_sound.c — chocolate-doom sound module using OSS PCM playback. + * -+ * The kandelo exposes an OSS-style /dev/dsp character -+ * device (kernel/audio.rs). This module fills the chocolate-doom -+ * `sound_module_t` shape, opens /dev/dsp once at init, mixes 8-bit -+ * mono SFX from WAD lumps into a 16-bit stereo @ 44.1 kHz mixbuffer, -+ * and `write()`s it to /dev/dsp every tic. The host (browser -+ * AudioContext) drains the kernel ring via the `kernel_drain_audio` -+ * export and feeds it back out as audio. ++ * This module fills the chocolate-doom `sound_module_t` shape, opens ++ * AUDIODEV (or /dev/dsp by default), mixes 8-bit mono SFX from WAD ++ * lumps into a 16-bit stereo 44.1 kHz buffer, and writes one buffer per ++ * game tic. The OSS device is only the PCM transport; mixing remains a ++ * userspace responsibility. + * + * No SDL / no ALSA / no resampler library — DOOM SFX sample rate + * (11025 Hz mono u8) is upmixed in-place with a 16.16 fixed-point @@ -33,12 +31,14 @@ index 0000000..082b14b + * SFX only, which is what the demo most needs. + */ + ++#include +#include +#include +#include +#include +#include +#include ++#include +#include + +#include "doomtype.h" @@ -48,31 +48,26 @@ index 0000000..082b14b +#include "w_wad.h" +#include "z_zone.h" + -+/* OSS ioctls — same numeric values the kernel and Linux use. We -+ * hard-code rather than #include so the build -+ * doesn't pull additional headers out of the wasm sysroot. */ -+#define SNDCTL_DSP_RESET 0x00005000u -+#define SNDCTL_DSP_SPEED 0xc0045002u -+#define SNDCTL_DSP_STEREO 0xc0045003u -+#define SNDCTL_DSP_SETFMT 0xc0045005u -+#define SNDCTL_DSP_GETFMTS 0x8004500bu -+#define AFMT_S16_LE 0x10 -+ -+/* Output config — must match what the kernel ring is configured for -+ * (kernel auto-rounds to whole frames, so the host AudioContext sees -+ * stereo S16 @ 44100 Hz). */ ++/* The mixer and OPL clock require the device to return this exact ++ * configuration. OSS negotiation is in/out, so init validates every ++ * returned value instead of assuming the request was accepted. */ +#define OUTPUT_RATE 44100 +#define OUTPUT_CHANNELS 2 +#define BYTES_PER_FRAME (2 * OUTPUT_CHANNELS) /* S16 * stereo */ + -+/* DOOM ticks run at 35 Hz. At 44100 Hz that's exactly 1260 frames -+ * per tic. Match exactly so the producer rate equals the consumer -+ * rate — any mismatch makes the AudioContext queue grow without -+ * bound, sounds desync, and the kernel ring drops fresh SFX on -+ * overflow (most visibly: the pistol shot vanishes after a few s). */ ++/* DOOM ticks run at 35 Hz. At 44100 Hz that is exactly 1260 frames ++ * per tic. Matching the device rate keeps SFX and music synchronized. */ +#define MIX_FRAMES 1260 +#define MIX_BYTES (MIX_FRAMES * BYTES_PER_FRAME) + ++/* Four 2048-byte fragments provide 8192 bytes (about 46 ms) of ++ * buffering, enough for one 5040-byte mix. Drivers may return a ++ * different geometry; the full-write loop below remains authoritative. */ ++#define OSS_FRAGMENT_SHIFT 11 ++#define OSS_FRAGMENT_COUNT 4 ++#define OSS_FRAGMENT_REQUEST \ ++ ((OSS_FRAGMENT_COUNT << 16) | OSS_FRAGMENT_SHIFT) ++ +#define NUM_CHANNELS 8 + +/* DOOM SFX in WAD lumps are 11025 Hz mono u8. The 8-byte header @@ -105,7 +100,57 @@ index 0000000..082b14b + * value back to I_StopSound / I_SoundIsPlaying. */ +static int handle_counter = 100; + -+static int I_KERNEL_GetSfxLumpNum(sfxinfo_t *sfx) ++static void I_OSS_ResetAndClose(void) ++{ ++ if (dsp_fd < 0) return; ++ ++ (void)ioctl(dsp_fd, SNDCTL_DSP_RESET, 0); ++ (void)close(dsp_fd); ++ dsp_fd = -1; ++} ++ ++static boolean I_OSS_SetFragmentGeometry(void) ++{ ++ int fragments = OSS_FRAGMENT_REQUEST; ++ unsigned int shift; ++ unsigned int count; ++ size_t fragment_bytes; ++ size_t capacity; ++ ++ if (ioctl(dsp_fd, SNDCTL_DSP_SETFRAGMENT, &fragments) < 0) { ++ fprintf(stderr, ++ "I_OSS_InitSound: SNDCTL_DSP_SETFRAGMENT failed: %s\n", ++ strerror(errno)); ++ return false; ++ } ++ ++ shift = (unsigned int)fragments & 0xffffu; ++ count = (unsigned int)fragments >> 16; ++ if (count == 0 || shift >= 31) { ++ fprintf(stderr, ++ "I_OSS_InitSound: invalid fragment geometry 0x%x\n", ++ (unsigned int)fragments); ++ return false; ++ } ++ ++ fragment_bytes = (size_t)1u << shift; ++ if (count > SIZE_MAX / fragment_bytes) { ++ fprintf(stderr, ++ "I_OSS_InitSound: fragment geometry overflows size_t\n"); ++ return false; ++ } ++ capacity = count * fragment_bytes; ++ if (capacity < MIX_BYTES) { ++ fprintf(stderr, ++ "I_OSS_InitSound: %zu-byte OSS buffer is smaller than " ++ "%u-byte mix; using blocking short writes\n", ++ capacity, (unsigned int)MIX_BYTES); ++ } ++ ++ return true; ++} ++ ++static int I_OSS_GetSfxLumpNum(sfxinfo_t *sfx) +{ + char namebuf[9]; + snprintf(namebuf, sizeof namebuf, "%s%s", @@ -113,57 +158,115 @@ index 0000000..082b14b + return W_GetNumForName(namebuf); +} + -+static boolean I_KERNEL_InitSound(boolean _use_sfx_prefix) ++static boolean I_OSS_InitSound(boolean _use_sfx_prefix) +{ ++ const char *device; ++ int formats; ++ int speed; ++ int stereo; ++ int fmt; ++ + use_sfx_prefix = _use_sfx_prefix; + -+ dsp_fd = open("/dev/dsp", O_WRONLY); ++ device = getenv("AUDIODEV"); ++ if (device == NULL || device[0] == '\0') device = "/dev/dsp"; ++ ++ dsp_fd = open(device, O_WRONLY); + if (dsp_fd < 0) { + fprintf(stderr, -+ "I_KERNEL_InitSound: /dev/dsp unavailable; sound disabled.\n"); ++ "I_OSS_InitSound: cannot open %s: %s; sound disabled.\n", ++ device, strerror(errno)); + return false; + } + -+ int speed = OUTPUT_RATE; -+ if (ioctl(dsp_fd, SNDCTL_DSP_SPEED, &speed) < 0) { ++ if (!I_OSS_SetFragmentGeometry()) goto fail; ++ ++ formats = 0; ++ if (ioctl(dsp_fd, SNDCTL_DSP_GETFMTS, &formats) < 0) { + fprintf(stderr, -+ "I_KERNEL_InitSound: SNDCTL_DSP_SPEED failed; sound disabled.\n"); -+ close(dsp_fd); -+ dsp_fd = -1; -+ return false; ++ "I_OSS_InitSound: SNDCTL_DSP_GETFMTS failed: %s\n", ++ strerror(errno)); ++ goto fail; ++ } ++ if ((formats & AFMT_S16_LE) == 0) { ++ fprintf(stderr, ++ "I_OSS_InitSound: signed 16-bit little-endian PCM unavailable\n"); ++ goto fail; + } + -+ int stereo = 1; /* 1 = stereo */ ++ fmt = AFMT_S16_LE; ++ if (ioctl(dsp_fd, SNDCTL_DSP_SETFMT, &fmt) < 0) { ++ fprintf(stderr, ++ "I_OSS_InitSound: SNDCTL_DSP_SETFMT failed: %s\n", ++ strerror(errno)); ++ goto fail; ++ } ++ if (fmt != AFMT_S16_LE) { ++ fprintf(stderr, ++ "I_OSS_InitSound: device returned PCM format 0x%x\n", ++ (unsigned int)fmt); ++ goto fail; ++ } ++ ++ stereo = 1; + if (ioctl(dsp_fd, SNDCTL_DSP_STEREO, &stereo) < 0) { + fprintf(stderr, -+ "I_KERNEL_InitSound: SNDCTL_DSP_STEREO failed; sound disabled.\n"); -+ close(dsp_fd); -+ dsp_fd = -1; -+ return false; ++ "I_OSS_InitSound: SNDCTL_DSP_STEREO failed: %s\n", ++ strerror(errno)); ++ goto fail; ++ } ++ if (stereo != 1) { ++ fprintf(stderr, ++ "I_OSS_InitSound: device did not accept stereo output\n"); ++ goto fail; + } + -+ int fmt = AFMT_S16_LE; -+ if (ioctl(dsp_fd, SNDCTL_DSP_SETFMT, &fmt) < 0) { ++ speed = OUTPUT_RATE; ++ if (ioctl(dsp_fd, SNDCTL_DSP_SPEED, &speed) < 0) { + fprintf(stderr, -+ "I_KERNEL_InitSound: SNDCTL_DSP_SETFMT(S16_LE) failed; sound disabled.\n"); -+ close(dsp_fd); -+ dsp_fd = -1; -+ return false; ++ "I_OSS_InitSound: SNDCTL_DSP_SPEED failed: %s\n", ++ strerror(errno)); ++ goto fail; ++ } ++ if (speed != OUTPUT_RATE) { ++ fprintf(stderr, ++ "I_OSS_InitSound: device returned %d Hz, need %d Hz\n", ++ speed, OUTPUT_RATE); ++ goto fail; + } + + memset(channels, 0, sizeof channels); + return true; ++ ++fail: ++ I_OSS_ResetAndClose(); ++ return false; +} + -+static void I_KERNEL_ShutdownSound(void) ++static void I_OSS_ShutdownSound(void) +{ -+ if (dsp_fd >= 0) { -+ close(dsp_fd); -+ dsp_fd = -1; ++ int result; ++ ++ if (dsp_fd < 0) return; ++ ++ do { ++ result = ioctl(dsp_fd, SNDCTL_DSP_SYNC, 0); ++ } while (result < 0 && errno == EINTR); ++ ++ if (result < 0) { ++ fprintf(stderr, "I_OSS_ShutdownSound: drain failed: %s\n", ++ strerror(errno)); ++ (void)ioctl(dsp_fd, SNDCTL_DSP_RESET, 0); ++ } ++ ++ if (close(dsp_fd) < 0) { ++ fprintf(stderr, "I_OSS_ShutdownSound: close failed: %s\n", ++ strerror(errno)); + } ++ dsp_fd = -1; +} + -+static void I_KERNEL_UpdateSoundParams(int handle, int vol, int sep) ++static void I_OSS_UpdateSoundParams(int handle, int vol, int sep) +{ + /* chocolate-doom passes the channel index here, not the handle — + * but the engine guards calls with SoundIsPlaying first, so a @@ -183,12 +286,12 @@ index 0000000..082b14b + } +} + -+static int I_KERNEL_StartSound(sfxinfo_t *sfxinfo, int channel, -+ int vol, int sep) ++static int I_OSS_StartSound(sfxinfo_t *sfxinfo, int channel, ++ int vol, int sep) +{ + if (dsp_fd < 0) return -1; + -+ int lump = I_KERNEL_GetSfxLumpNum(sfxinfo); ++ int lump = I_OSS_GetSfxLumpNum(sfxinfo); + if (lump < 0) return -1; + int size = W_LumpLength(lump); + if (size <= DOOM_SFX_HEADER_BYTES) return -1; @@ -216,11 +319,11 @@ index 0000000..082b14b + channels[slot].leftvol = vol > 127 ? 127 : (vol < 0 ? 0 : vol); + channels[slot].rightvol = channels[slot].leftvol; + -+ I_KERNEL_UpdateSoundParams(handle, vol, sep); ++ I_OSS_UpdateSoundParams(handle, vol, sep); + return handle; +} + -+static void I_KERNEL_StopSound(int handle) ++static void I_OSS_StopSound(int handle) +{ + for (int i = 0; i < NUM_CHANNELS; i++) { + if (channels[i].active && channels[i].handle == handle) { @@ -230,7 +333,7 @@ index 0000000..082b14b + } +} + -+static boolean I_KERNEL_SoundIsPlaying(int handle) ++static boolean I_OSS_SoundIsPlaying(int handle) +{ + for (int i = 0; i < NUM_CHANNELS; i++) { + if (channels[i].active && channels[i].handle == handle) return true; @@ -238,7 +341,27 @@ index 0000000..082b14b + return false; +} + -+static void I_KERNEL_PrecacheSounds(sfxinfo_t *sounds, int num_sounds) ++static boolean I_OSS_WriteAll(const void *data, size_t size) ++{ ++ const uint8_t *cursor = data; ++ ++ while (size != 0) { ++ ssize_t written = write(dsp_fd, cursor, size); ++ ++ if (written > 0) { ++ cursor += written; ++ size -= (size_t)written; ++ continue; ++ } ++ if (written < 0 && errno == EINTR) continue; ++ if (written == 0) errno = EIO; ++ return false; ++ } ++ ++ return true; ++} ++ ++static void I_OSS_PrecacheSounds(sfxinfo_t *sounds, int num_sounds) +{ + /* On-demand load via W_CacheLumpNum in StartSound — no-op here. */ + (void)sounds; (void)num_sounds; @@ -246,7 +369,7 @@ index 0000000..082b14b + +/* Mix MIX_FRAMES stereo S16 frames and write them to /dev/dsp. Called + * once per game tic from S_UpdateSounds → I_UpdateSound. */ -+static void I_KERNEL_UpdateSound(void) ++static void I_OSS_UpdateSound(void) +{ + if (dsp_fd < 0) return; + @@ -278,48 +401,49 @@ index 0000000..082b14b + mixbuffer[frame * 2 + 1] = (int16_t)dr; + } + -+ /* The kernel ring drops oldest frames on overflow rather than -+ * blocking, so a slow drain just costs audio — never wedges -+ * the game loop. We never short-write here in practice. */ -+ (void)write(dsp_fd, mixbuffer, sizeof mixbuffer); ++ if (!I_OSS_WriteAll(mixbuffer, sizeof mixbuffer)) { ++ int error = errno; ++ fprintf(stderr, "I_OSS_UpdateSound: PCM write failed: %s; " ++ "sound disabled.\n", strerror(error)); ++ I_OSS_ResetAndClose(); ++ } +} + -+static snddevice_t sound_kernel_devices[] = { ++static snddevice_t sound_oss_devices[] = { + SNDDEVICE_SB, +}; + -+sound_module_t sound_kernel_module = { -+ sound_kernel_devices, -+ sizeof(sound_kernel_devices) / sizeof(sound_kernel_devices[0]), -+ I_KERNEL_InitSound, -+ I_KERNEL_ShutdownSound, -+ I_KERNEL_GetSfxLumpNum, -+ I_KERNEL_UpdateSound, -+ I_KERNEL_UpdateSoundParams, -+ I_KERNEL_StartSound, -+ I_KERNEL_StopSound, -+ I_KERNEL_SoundIsPlaying, -+ I_KERNEL_PrecacheSounds, ++sound_module_t sound_oss_module = { ++ sound_oss_devices, ++ sizeof(sound_oss_devices) / sizeof(sound_oss_devices[0]), ++ I_OSS_InitSound, ++ I_OSS_ShutdownSound, ++ I_OSS_GetSfxLumpNum, ++ I_OSS_UpdateSound, ++ I_OSS_UpdateSoundParams, ++ I_OSS_StartSound, ++ I_OSS_StopSound, ++ I_OSS_SoundIsPlaying, ++ I_OSS_PrecacheSounds, +}; diff --git a/fbdoom/i_sound.c b/fbdoom/i_sound.c index 71947d2..d4b4017 100644 --- a/fbdoom/i_sound.c +++ b/fbdoom/i_sound.c -@@ -89,8 +89,16 @@ static int snd_mport = 0; +@@ -89,8 +89,15 @@ static int snd_mport = 0; // Compiled-in sound modules: -static sound_module_t *sound_modules[] = -+// /dev/dsp-backed sound module — defined in i_kernel_sound.c. We -+// register it unconditionally (no FEATURE_SOUND gate) because the -+// Kandelo always exposes /dev/dsp; if the open fails (no -+// AudioContext sink wired up host-side) the module's Init returns -+// false and the dispatcher falls through to silence. -+extern sound_module_t sound_kernel_module; ++// OSS sound module defined in i_oss_sound.c. Register it independently ++// of FEATURE_SOUND because fbDOOM's slim build omits the SDL and PC ++// speaker modules. If AUDIODEV or /dev/dsp cannot be opened, Init ++// returns false and the dispatcher falls through to silence. ++extern sound_module_t sound_oss_module; + +static sound_module_t *sound_modules[] = { -+ &sound_kernel_module, ++ &sound_oss_module, #ifdef FEATURE_SOUND &sound_sdl_module, &sound_pcsound_module, diff --git a/packages/registry/fbdoom/patches/0004-music-support-vendor-fixups.patch b/packages/registry/fbdoom/patches/0004-music-support-vendor-fixups.patch index 5163427d3e..fd648dbd87 100644 --- a/packages/registry/fbdoom/patches/0004-music-support-vendor-fixups.patch +++ b/packages/registry/fbdoom/patches/0004-music-support-vendor-fixups.patch @@ -5,9 +5,9 @@ // -#include "config.h" -+// kandelo build: no autotools config.h, no SDL. The only -+// driver shipped is opl_kernel.c, which is a pull-model driver suitable -+// for the single-threaded /dev/dsp mixing loop in i_kernel_sound.c. ++// fbDOOM's slim OSS build has no autotools config.h or SDL. Its only ++// driver is opl_pull.c, a pull model suitable for the single-threaded ++// /dev/dsp mixing loop in i_oss_sound.c. #include #include @@ -34,7 +34,7 @@ -#ifndef DISABLE_SDL2MIXER - &opl_sdl_driver, -#endif // DISABLE_SDL2MIXER -+ &opl_kernel_driver, ++ &opl_pull_driver, NULL }; @@ -142,10 +142,10 @@ -extern opl_driver_t opl_win32_driver; -#endif -extern opl_driver_t opl_sdl_driver; -+// The kandelo build only ships the kernel pull driver — all -+// SDL/Linux/Win32 hardware drivers are stripped (no SDL in the sysroot, -+// no real OPL chip behind a wasm sandbox). -+extern opl_driver_t opl_kernel_driver; ++// This slim OSS build ships only the pull driver. SDL and native ++// hardware drivers are omitted: neither exists in the wasm target, ++// and there is no physical OPL chip behind the sandbox. ++extern opl_driver_t opl_pull_driver; #endif /* #ifndef OPL_INTERNAL_H */ diff --git a/packages/registry/fbdoom/patches/0005-add-music-support.patch b/packages/registry/fbdoom/patches/0005-add-music-support.patch index ffd455588c..6b52b0b02e 100644 --- a/packages/registry/fbdoom/patches/0005-add-music-support.patch +++ b/packages/registry/fbdoom/patches/0005-add-music-support.patch @@ -1,5 +1,5 @@ ---- a/fbdoom/i_kernel_sound.c -+++ b/fbdoom/i_kernel_sound.c +--- a/fbdoom/i_oss_sound.c ++++ b/fbdoom/i_oss_sound.c @@ -11,8 +11,10 @@ * * No SDL / no ALSA / no resampler library — DOOM SFX sample rate @@ -7,12 +7,12 @@ - * step. Music (MUS / MIDI) is left to a future patch; this lands - * SFX only, which is what the demo most needs. + * step. Music (MUS/MIDI through GENMIDI -> OPL2 emulation) is mixed -+ * into the same per-tic mixbuffer below: OPL_Kernel_FillBuffer fills a ++ * into the same per-tic mixbuffer below: OPL_Pull_FillBuffer fills a + * stereo S16 buffer at OUTPUT_RATE which we accumulate alongside SFX + * before clamping. One write() per tic carries both. */ - #include + #include @@ -26,6 +28,7 @@ #include "doomtype.h" #include "deh_str.h" @@ -26,11 +26,11 @@ static boolean use_sfx_prefix; static int16_t mixbuffer[MIX_FRAMES * OUTPUT_CHANNELS]; +/* Music samples produced by the OPL emulator each tic (when active). -+ * Filled by OPL_Kernel_FillBuffer (opl/opl_kernel.c) and accumulated ++ * Filled by OPL_Pull_FillBuffer (opl/opl_pull.c) and accumulated + * into mixbuffer alongside SFX. When music is inactive the call + * returns silence so the mixer path is uniform. */ +static int16_t musicbuffer[MIX_FRAMES * OUTPUT_CHANNELS]; -+extern void OPL_Kernel_FillBuffer(int16_t *out, unsigned int frames); ++extern void OPL_Pull_FillBuffer(int16_t *out, unsigned int frames); static sound_channel_t channels[NUM_CHANNELS]; +/* S_UpdateSounds runs once per render frame, not once per tic. Gate on @@ -47,7 +47,7 @@ -/* Mix MIX_FRAMES stereo S16 frames and write them to /dev/dsp. Called - * once per game tic from S_UpdateSounds → I_UpdateSound. */ +/* Mix MIX_FRAMES stereo S16 frames and write them to /dev/dsp. */ - static void I_KERNEL_UpdateSound(void) + static void I_OSS_UpdateSound(void) { if (dsp_fd < 0) return; @@ -61,7 +61,7 @@ + * resampling — but it does fire MIDI register-write callbacks + * scheduled by i_oplmusic as time advances inside this call, + * which is how tempo / note events stay sample-precise. */ -+ OPL_Kernel_FillBuffer(musicbuffer, MIX_FRAMES); ++ OPL_Pull_FillBuffer(musicbuffer, MIX_FRAMES); + for (int frame = 0; frame < MIX_FRAMES; frame++) { - int dl = 0, dr = 0; @@ -79,7 +79,7 @@ + +// OPL music (i_oplmusic.c) — synthesizes MUS/MIDI through the +// chocolate-doom OPL2 emulator (opl/opl3.c) into PCM that -+// i_kernel_sound mixes into the same /dev/dsp write as SFX. We register ++// i_oss_sound mixes into the same /dev/dsp write as SFX. We register +// it unconditionally so the dispatcher picks it up for SNDDEVICE_SB / +// SNDDEVICE_ADLIB; if OPL_Init or GENMIDI-lump load fails the module's +// Init returns false and the dispatcher falls through to silence. @@ -98,7 +98,7 @@ +++ b/fbdoom/Makefile @@ -20,6 +20,15 @@ OBJS+=$(OBJDIR)/i_input_tty.o - OBJS+=$(OBJDIR)/i_kernel_sound.o + OBJS+=$(OBJDIR)/i_oss_sound.o +# Music: MUS->MIDI->OPL2 software synthesis, mixed into /dev/dsp. +OBJS+=$(OBJDIR)/i_oplmusic.o @@ -107,7 +107,7 @@ +OBJS+=$(OBJDIR)/opl/opl.o +OBJS+=$(OBJDIR)/opl/opl3.o +OBJS+=$(OBJDIR)/opl/opl_queue.o -+OBJS+=$(OBJDIR)/opl/opl_kernel.o ++OBJS+=$(OBJDIR)/opl/opl_pull.o + CC=$(CROSS_COMPILE)gcc # gcc or g++ CFLAGS+=-ggdb3 -Os @@ -120,12 +120,12 @@ @echo [Compiling $<] $(VB)$(CC) $(CFLAGS) -c $< -o $@ ---- a/fbdoom/opl/opl_kernel.c -+++ b/fbdoom/opl/opl_kernel.c -@@ -0,0 +1,354 @@ +--- a/fbdoom/opl/opl_pull.c ++++ b/fbdoom/opl/opl_pull.c +@@ -0,0 +1,353 @@ +// +// Copyright(C) 2005-2014 Simon Howard -+// Copyright(C) 2026 kandelo authors ++// Copyright(C) 2026 Kandelo authors +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License @@ -138,16 +138,16 @@ +// GNU General Public License for more details. +// +// DESCRIPTION: -+// OPL pull-model driver — kandelo /dev/dsp variant. ++// OPL pull-model driver for the OSS mixer. +// +// Chocolate-doom's OPL frontend (opl.c) drives an opl_driver_t +// impl. The stock SDL driver (opl_sdl.c) is push-based: SDL's audio +// thread invokes OPL_Mix_Callback, which advances time and calls +// OPL3_GenerateStream into SDL's mix buffer. We have no audio -+// thread — i_kernel_sound.c sits in the game-tic loop and pulls ++// thread — i_oss_sound.c sits in the game-tic loop and pulls +// audio synchronously every ~28 ms before write()-ing to /dev/dsp. +// -+// Hence "pull": OPL_Kernel_FillBuffer is the entry point. It ++// Hence "pull": OPL_Pull_FillBuffer is the entry point. It +// advances current_time as samples are produced, fires any due +// MIDI register-write callbacks queued by i_oplmusic, and writes +// stereo S16 frames into the caller-provided buffer at @@ -179,7 +179,7 @@ +static opl_callback_queue_t *callback_queue = NULL; + +// Current time in microseconds since OPL_Init. Advanced by AdvanceTime -+// as samples are produced (and by OPL_Kernel_Delay during OPL_Detect). ++// as samples are produced (and by OPL_Pull_Delay during OPL_Detect). +static uint64_t current_time; + +// If non-zero, time still advances but pause_offset accumulates so @@ -237,9 +237,8 @@ +// alongside, firing callbacks at sample-precise points so MIDI tempo +// stays exact regardless of how often the caller pulls. +// -+// Caller-facing entry point. Declared in opl_kernel.h-equivalent -+// extern in i_kernel_sound.c. -+void OPL_Kernel_FillBuffer(int16_t *out, unsigned int frames) ++// Caller-facing entry point. Declared in i_oss_sound.c. ++void OPL_Pull_FillBuffer(int16_t *out, unsigned int frames) +{ + unsigned int filled = 0; + @@ -297,7 +296,7 @@ + } +} + -+static int OPL_Kernel_Init(unsigned int port_base) ++static int OPL_Pull_Init(unsigned int port_base) +{ + paused = 0; + pause_offset = 0; @@ -309,7 +308,7 @@ + return 1; +} + -+static void OPL_Kernel_Shutdown(void) ++static void OPL_Pull_Shutdown(void) +{ + if (callback_queue != NULL) + { @@ -318,7 +317,7 @@ + } +} + -+static unsigned int OPL_Kernel_PortRead(opl_port_t port) ++static unsigned int OPL_Pull_PortRead(opl_port_t port) +{ + unsigned int result = 0; + @@ -384,7 +383,7 @@ + + if ((value & 0x20) == 0) + { -+ timer1.enabled = (value & 0x02) != 0; ++ timer2.enabled = (value & 0x02) != 0; + OPLTimer_CalculateEndTime(&timer2); + } + } @@ -396,7 +395,7 @@ + } +} + -+static void OPL_Kernel_PortWrite(opl_port_t port, unsigned int value) ++static void OPL_Pull_PortWrite(opl_port_t port, unsigned int value) +{ + if (port == OPL_REGISTER_PORT) + { @@ -412,7 +411,7 @@ + } +} + -+static void OPL_Kernel_SetCallback(uint64_t us, opl_callback_t callback, ++static void OPL_Pull_SetCallback(uint64_t us, opl_callback_t callback, + void *data) +{ + if (callback_queue != NULL) @@ -422,7 +421,7 @@ + } +} + -+static void OPL_Kernel_ClearCallbacks(void) ++static void OPL_Pull_ClearCallbacks(void) +{ + if (callback_queue != NULL) + { @@ -431,15 +430,15 @@ +} + +// Single-threaded — no concurrent producer to lock against. -+static void OPL_Kernel_Lock(void) { } -+static void OPL_Kernel_Unlock(void) { } ++static void OPL_Pull_Lock(void) { } ++static void OPL_Pull_Unlock(void) { } + -+static void OPL_Kernel_SetPaused(int p) ++static void OPL_Pull_SetPaused(int p) +{ + paused = p; +} + -+static void OPL_Kernel_AdjustCallbacks(float factor) ++static void OPL_Pull_AdjustCallbacks(float factor) +{ + if (callback_queue != NULL) + { @@ -450,7 +449,7 @@ +// Used only by OPL_Detect, which writes a timer value, "waits 1ms" and +// re-reads status to confirm the (emulated) chip is alive. We just step +// the clock; no audio output is needed for detection. -+static void OPL_Kernel_Delay(uint64_t us) ++static void OPL_Pull_Delay(uint64_t us) +{ + current_time += us; + @@ -462,18 +461,18 @@ + RunDueCallbacks(); +} + -+opl_driver_t opl_kernel_driver = ++opl_driver_t opl_pull_driver = +{ -+ "Kernel", -+ OPL_Kernel_Init, -+ OPL_Kernel_Shutdown, -+ OPL_Kernel_PortRead, -+ OPL_Kernel_PortWrite, -+ OPL_Kernel_SetCallback, -+ OPL_Kernel_ClearCallbacks, -+ OPL_Kernel_Lock, -+ OPL_Kernel_Unlock, -+ OPL_Kernel_SetPaused, -+ OPL_Kernel_AdjustCallbacks, -+ OPL_Kernel_Delay, ++ "Pull", ++ OPL_Pull_Init, ++ OPL_Pull_Shutdown, ++ OPL_Pull_PortRead, ++ OPL_Pull_PortWrite, ++ OPL_Pull_SetCallback, ++ OPL_Pull_ClearCallbacks, ++ OPL_Pull_Lock, ++ OPL_Pull_Unlock, ++ OPL_Pull_SetPaused, ++ OPL_Pull_AdjustCallbacks, ++ OPL_Pull_Delay, +}; diff --git a/packages/registry/sdl-dsp-test/build-sdl-dsp-test.sh b/packages/registry/sdl-dsp-test/build-sdl-dsp-test.sh new file mode 100644 index 0000000000..02f95fdd39 --- /dev/null +++ b/packages/registry/sdl-dsp-test/build-sdl-dsp-test.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Build deterministic SDL2 and SDL3 /dev/dsp pacing fixtures. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +# shellcheck source=/dev/null +source "$REPO_ROOT/sdk/activate.sh" + +INSTALL_DIR="${WASM_POSIX_DEP_OUT_DIR:?WASM_POSIX_DEP_OUT_DIR must name the resolver staging directory}" +TARGET_ARCH="${WASM_POSIX_DEP_TARGET_ARCH:-wasm32}" +SDL2_PREFIX="${WASM_POSIX_DEP_SDL2_DIR:?resolver did not provide the direct sdl2 dependency}" +SDL3_PREFIX="${WASM_POSIX_DEP_SDL3_DIR:?resolver did not provide the direct sdl3 dependency}" + +if [ "$TARGET_ARCH" != "wasm32" ]; then + echo "ERROR: SDL dsp fixtures currently support only wasm32, got $TARGET_ARCH" >&2 + exit 1 +fi + +export WASM_POSIX_SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" +CC=wasm32posix-cc +command -v "$CC" >/dev/null || { + echo "ERROR: $CC not found after sourcing sdk/activate.sh" >&2 + exit 1 +} + +test -f "$SDL2_PREFIX/lib/libSDL2.a" +test -f "$SDL3_PREFIX/lib/libSDL3.a" +mkdir -p "$INSTALL_DIR" +REPRO_FLAGS=( + "-ffile-prefix-map=$REPO_ROOT=/usr/src/kandelo" + "-fdebug-prefix-map=$REPO_ROOT=/usr/src/kandelo" + "-fmacro-prefix-map=$REPO_ROOT=/usr/src/kandelo" +) + +echo "==> Building the SDL2 blocking-write pacing fixture..." +"$CC" -O2 "${REPRO_FLAGS[@]}" -DSDL_MAIN_HANDLED \ + -I"$SDL2_PREFIX/include/SDL2" \ + "$SCRIPT_DIR/src/sdl2-dsp-test.c" \ + "$SDL2_PREFIX/lib/libSDL2.a" -lm \ + -o "$INSTALL_DIR/sdl2-dsp-test.wasm" + +echo "==> Building the SDL3 GETOSPACE pacing fixture..." +"$CC" -O2 "${REPRO_FLAGS[@]}" -DSDL_MAIN_HANDLED \ + -I"$SDL3_PREFIX/include" \ + "$SCRIPT_DIR/src/sdl3-dsp-test.c" \ + "$SDL3_PREFIX/lib/libSDL3.a" -lm \ + -o "$INSTALL_DIR/sdl3-dsp-test.wasm" + +for output in sdl2-dsp-test.wasm sdl3-dsp-test.wasm; do + "$REPO_ROOT/scripts/run-wasm-fork-instrument.sh" \ + "$INSTALL_DIR/$output" -o "$INSTALL_DIR/$output.instrumented" + mv "$INSTALL_DIR/$output.instrumented" "$INSTALL_DIR/$output" +done + +test -f "$INSTALL_DIR/sdl2-dsp-test.wasm" +test -f "$INSTALL_DIR/sdl3-dsp-test.wasm" +echo "==> SDL /dev/dsp fixtures complete" diff --git a/packages/registry/sdl-dsp-test/build.toml b/packages/registry/sdl-dsp-test/build.toml new file mode 100644 index 0000000000..b2bcd8d174 --- /dev/null +++ b/packages/registry/sdl-dsp-test/build.toml @@ -0,0 +1,13 @@ +script_path = "packages/registry/sdl-dsp-test/build-sdl-dsp-test.sh" +inputs = [ + "packages/registry/sdl-dsp-test/build-sdl-dsp-test.sh", + "packages/registry/sdl-dsp-test/src/sdl2-dsp-test.c", + "packages/registry/sdl-dsp-test/src/sdl3-dsp-test.c", + "scripts/run-wasm-fork-instrument.sh", +] +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "UNPUBLISHED" +revision = 1 + +[binary] +index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/sdl-dsp-test/package.toml b/packages/registry/sdl-dsp-test/package.toml new file mode 100644 index 0000000000..5333bd142f --- /dev/null +++ b/packages/registry/sdl-dsp-test/package.toml @@ -0,0 +1,25 @@ +kind = "program" +name = "sdl-dsp-test" +version = "0.1.0" +kernel_abi = 43 +depends_on = ["sdl2@2.32.10", "sdl3@3.4.10"] +arches = ["wasm32"] + +[source] +url = "https://github.com/Automattic/kandelo" +sha256 = "0000000000000000000000000000000000000000000000000000000000000000" + +[license] +spdx = "GPL-2.0-or-later" +url = "https://github.com/Automattic/kandelo/blob/main/COPYING" + +[build] +script_path = "packages/registry/sdl-dsp-test/build-sdl-dsp-test.sh" + +[[outputs]] +name = "sdl2-dsp-test" +wasm = "sdl2-dsp-test.wasm" + +[[outputs]] +name = "sdl3-dsp-test" +wasm = "sdl3-dsp-test.wasm" diff --git a/packages/registry/sdl-dsp-test/src/sdl2-dsp-test.c b/packages/registry/sdl-dsp-test/src/sdl2-dsp-test.c new file mode 100644 index 0000000000..c51ebeebbf --- /dev/null +++ b/packages/registry/sdl-dsp-test/src/sdl2-dsp-test.c @@ -0,0 +1,144 @@ +#include + +#include +#include +#include +#include +#include + +enum { RUN_MS = 900 }; + +struct playback_state { + uint64_t callbacks; + uint64_t frames; + uint32_t phase; +}; + +static uint64_t monotonic_ms(void) { + struct timespec now; + if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) { + return 0; + } + return (uint64_t)now.tv_sec * 1000u + (uint64_t)now.tv_nsec / 1000000u; +} + +static void SDLCALL fill_audio(void *userdata, Uint8 *stream, int len) { + struct playback_state *state = userdata; + int i; + + /* Deterministic unsigned 8-bit mono sawtooth. */ + for (i = 0; i < len; ++i) { + stream[i] = (Uint8)(32u + (state->phase++ % 192u)); + } + state->callbacks++; + state->frames += (uint64_t)len; +} + +static const char *format_name(SDL_AudioFormat format) { + return format == AUDIO_U8 ? "U8" : "OTHER"; +} + +int main(void) { + SDL_AudioSpec requested; + SDL_AudioSpec actual; + SDL_AudioDeviceID device; + struct playback_state state; + const char *driver; + uint64_t start_ms; + uint64_t end_ms; + uint64_t close_start_ms; + uint64_t close_end_ms; + uint64_t elapsed_ms; + uint64_t close_ms; + uint64_t expected_frames; + int paced; + + memset(&requested, 0, sizeof(requested)); + memset(&actual, 0, sizeof(actual)); + memset(&state, 0, sizeof(state)); + + if (setenv("SDL_AUDIODRIVER", "dsp", 1) != 0) { + perror("setenv SDL_AUDIODRIVER"); + return 1; + } + if (SDL_Init(SDL_INIT_AUDIO | SDL_INIT_TIMER) != 0) { + fprintf(stderr, "SDL2 init failed: %s\n", SDL_GetError()); + return 1; + } + + driver = SDL_GetCurrentAudioDriver(); + if (driver == NULL || strcmp(driver, "dsp") != 0) { + fprintf(stderr, "SDL2 selected unexpected audio driver: %s\n", + driver == NULL ? "(null)" : driver); + SDL_Quit(); + return 1; + } + + requested.freq = 22050; + requested.format = AUDIO_U8; + requested.channels = 1; + requested.samples = 512; + requested.callback = fill_audio; + requested.userdata = &state; + + device = SDL_OpenAudioDevice(NULL, 0, &requested, &actual, 0); + if (device == 0) { + fprintf(stderr, "SDL2 open failed: %s\n", SDL_GetError()); + SDL_Quit(); + return 1; + } + if (actual.freq != requested.freq || actual.format != requested.format || + actual.channels != requested.channels) { + fprintf(stderr, + "SDL2 changed an exactly supported spec: %d/%#x/%u -> %d/%#x/%u\n", + requested.freq, requested.format, (unsigned)requested.channels, + actual.freq, actual.format, (unsigned)actual.channels); + SDL_CloseAudioDevice(device); + SDL_Quit(); + return 1; + } + + start_ms = monotonic_ms(); + SDL_PauseAudioDevice(device, 0); + SDL_Delay(RUN_MS); + end_ms = monotonic_ms(); + close_start_ms = end_ms; + SDL_CloseAudioDevice(device); + close_end_ms = monotonic_ms(); + + SDL_Quit(); + + elapsed_ms = end_ms >= start_ms ? end_ms - start_ms : 0; + close_ms = close_end_ms >= close_start_ms ? close_end_ms - close_start_ms : 0; + expected_frames = actual.freq > 0 ? + ((uint64_t)actual.freq * elapsed_ms) / 1000u : 0; + paced = state.callbacks >= 2 && expected_frames > 0 && + state.frames >= expected_frames / 2u && + state.frames <= expected_frames * 2u + (uint64_t)actual.samples * 2u; + + if (!paced) { + fprintf(stderr, + "SDL2 callback pacing failed: frames=%llu expected=%llu elapsed=%llu ms\n", + (unsigned long long)state.frames, + (unsigned long long)expected_frames, + (unsigned long long)elapsed_ms); + return 1; + } + + printf("SDL_DSP_RESULT {\"sdl_major\":2,\"requested_rate\":22050," + "\"requested_format\":\"U8\",\"requested_channels\":1," + "\"actual_rate\":%d,\"actual_format\":\"%s\"," + "\"actual_channels\":%u,\"callbacks\":%llu,\"frames\":%llu," + "\"pcm_bytes\":%llu,\"period_frames\":%u," + "\"elapsed_ms\":%llu,\"close_ms\":%llu,\"paced\":%s}\n", + actual.freq, format_name(actual.format), (unsigned)actual.channels, + (unsigned long long)state.callbacks, + (unsigned long long)state.frames, + (unsigned long long)state.frames, + (unsigned)actual.samples, + (unsigned long long)elapsed_ms, + (unsigned long long)close_ms, + "true"); + fflush(stdout); + return 0; +} diff --git a/packages/registry/sdl-dsp-test/src/sdl3-dsp-test.c b/packages/registry/sdl-dsp-test/src/sdl3-dsp-test.c new file mode 100644 index 0000000000..4a26f2cb1f --- /dev/null +++ b/packages/registry/sdl-dsp-test/src/sdl3-dsp-test.c @@ -0,0 +1,218 @@ +#include + +#ifndef SDL_PLATFORM_UNIX +#error "Kandelo's SDK must expose its Unix platform identity to SDL3 consumers" +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum { RUN_MS = 900, FRAME_BYTES = 4, CHUNK_BYTES = 8192 }; + +struct playback_state { + uint64_t callbacks; + uint64_t frames; + uint32_t phase; + int failed; + Uint8 chunk[CHUNK_BYTES]; +}; + +static uint64_t monotonic_ms(void) { + struct timespec now; + if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) { + return 0; + } + return (uint64_t)now.tv_sec * 1000u + (uint64_t)now.tv_nsec / 1000000u; +} + +static void fill_s16le_stereo(struct playback_state *state, int bytes) { + int offset; + + for (offset = 0; offset < bytes; offset += FRAME_BYTES) { + int sample = ((int)(state->phase++ % 200u) - 100) * 240; + int right = -sample; + state->chunk[offset + 0] = (Uint8)(sample & 0xff); + state->chunk[offset + 1] = (Uint8)((sample >> 8) & 0xff); + state->chunk[offset + 2] = (Uint8)(right & 0xff); + state->chunk[offset + 3] = (Uint8)((right >> 8) & 0xff); + } +} + +static void SDLCALL provide_audio(void *userdata, SDL_AudioStream *stream, + int additional_amount, int total_amount) { + struct playback_state *state = userdata; + int remaining; + (void)total_amount; + + state->callbacks++; + remaining = additional_amount; + while (remaining > 0) { + int bytes = remaining < CHUNK_BYTES ? remaining : CHUNK_BYTES; + bytes = (bytes + FRAME_BYTES - 1) & ~(FRAME_BYTES - 1); + fill_s16le_stereo(state, bytes); + if (!SDL_PutAudioStreamData(stream, state->chunk, bytes)) { + state->failed = 1; + return; + } + state->frames += (uint64_t)bytes / FRAME_BYTES; + remaining -= bytes; + } +} + +static const char *format_name(SDL_AudioFormat format) { + return format == SDL_AUDIO_S16LE ? "S16LE" : "OTHER"; +} + +static void report_direct_dsp_probe(void) { + struct stat status; + int fd; + + errno = 0; + fd = open("/dev/dsp", O_WRONLY | O_NONBLOCK | O_CLOEXEC, 0); + if (fd < 0) { + fprintf(stderr, "SDL3 direct /dev/dsp probe: open failed: %s (%d)\n", + strerror(errno), errno); + return; + } + errno = 0; + if (fstat(fd, &status) != 0) { + fprintf(stderr, "SDL3 direct /dev/dsp probe: fstat failed: %s (%d)\n", + strerror(errno), errno); + } else { + fprintf(stderr, + "SDL3 direct /dev/dsp probe: mode=%#o character_device=%d\n", + (unsigned)status.st_mode, S_ISCHR(status.st_mode) ? 1 : 0); + } + if (close(fd) != 0) { + fprintf(stderr, "SDL3 direct /dev/dsp probe: close failed: %s (%d)\n", + strerror(errno), errno); + } +} + +int main(void) { + SDL_AudioSpec requested; + SDL_AudioSpec source_spec; + SDL_AudioSpec device_spec; + SDL_AudioStream *stream; + struct playback_state state; + const char *driver; + uint64_t start_ms; + uint64_t end_ms; + uint64_t close_start_ms; + uint64_t close_end_ms; + uint64_t elapsed_ms; + uint64_t close_ms; + uint64_t expected_frames; + int paced; + + SDL_zero(requested); + SDL_zero(source_spec); + SDL_zero(device_spec); + SDL_zero(state); + + if (setenv("SDL_AUDIODRIVER", "dsp", 1) != 0) { + perror("setenv SDL_AUDIODRIVER"); + return 1; + } + if (!SDL_Init(SDL_INIT_AUDIO)) { + fprintf(stderr, "SDL3 init failed: %s\n", SDL_GetError()); + report_direct_dsp_probe(); + return 1; + } + + driver = SDL_GetCurrentAudioDriver(); + if (driver == NULL || strcmp(driver, "dsp") != 0) { + fprintf(stderr, "SDL3 selected unexpected audio driver: %s\n", + driver == NULL ? "(null)" : driver); + SDL_Quit(); + return 1; + } + + requested.freq = 48000; + requested.format = SDL_AUDIO_S16LE; + requested.channels = 2; + stream = SDL_OpenAudioDeviceStream(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, + &requested, provide_audio, &state); + if (stream == NULL) { + fprintf(stderr, "SDL3 open failed: %s\n", SDL_GetError()); + SDL_Quit(); + return 1; + } + if (!SDL_GetAudioStreamFormat(stream, &source_spec, &device_spec)) { + fprintf(stderr, "SDL3 format query failed: %s\n", SDL_GetError()); + SDL_DestroyAudioStream(stream); + SDL_Quit(); + return 1; + } + if (source_spec.freq != requested.freq || + source_spec.format != requested.format || + source_spec.channels != requested.channels || + device_spec.freq != requested.freq || + device_spec.format != requested.format || + device_spec.channels != requested.channels) { + fprintf(stderr, + "SDL3 changed an exactly supported spec: %d/%#x/%u -> %d/%#x/%u\n", + requested.freq, requested.format, (unsigned)requested.channels, + device_spec.freq, device_spec.format, + (unsigned)device_spec.channels); + SDL_DestroyAudioStream(stream); + SDL_Quit(); + return 1; + } + + start_ms = monotonic_ms(); + if (!SDL_ResumeAudioStreamDevice(stream)) { + fprintf(stderr, "SDL3 resume failed: %s\n", SDL_GetError()); + SDL_DestroyAudioStream(stream); + SDL_Quit(); + return 1; + } + SDL_Delay(RUN_MS); + end_ms = monotonic_ms(); + close_start_ms = end_ms; + SDL_DestroyAudioStream(stream); + close_end_ms = monotonic_ms(); + + SDL_Quit(); + + elapsed_ms = end_ms >= start_ms ? end_ms - start_ms : 0; + close_ms = close_end_ms >= close_start_ms ? close_end_ms - close_start_ms : 0; + expected_frames = source_spec.freq > 0 ? + ((uint64_t)source_spec.freq * elapsed_ms) / 1000u : 0; + paced = !state.failed && state.callbacks >= 2 && expected_frames > 0 && + state.frames >= expected_frames / 2u && + state.frames <= expected_frames * 2u + 4096u; + + if (!paced) { + fprintf(stderr, + "SDL3 callback pacing failed: frames=%llu expected=%llu elapsed=%llu ms\n", + (unsigned long long)state.frames, + (unsigned long long)expected_frames, + (unsigned long long)elapsed_ms); + return 1; + } + + printf("SDL_DSP_RESULT {\"sdl_major\":3,\"requested_rate\":48000," + "\"requested_format\":\"S16LE\",\"requested_channels\":2," + "\"actual_rate\":%d,\"actual_format\":\"%s\"," + "\"actual_channels\":%u,\"callbacks\":%llu,\"frames\":%llu," + "\"pcm_bytes\":%llu,\"elapsed_ms\":%llu,\"close_ms\":%llu," + "\"paced\":%s}\n", + device_spec.freq, format_name(device_spec.format), + (unsigned)device_spec.channels, + (unsigned long long)state.callbacks, + (unsigned long long)state.frames, + (unsigned long long)state.frames * FRAME_BYTES, + (unsigned long long)elapsed_ms, + (unsigned long long)close_ms, + "true"); + fflush(stdout); + return 0; +} diff --git a/packages/registry/sdl2-mixer-playwave/build-sdl2-mixer-playwave.sh b/packages/registry/sdl2-mixer-playwave/build-sdl2-mixer-playwave.sh new file mode 100755 index 0000000000..2450c403ae --- /dev/null +++ b/packages/registry/sdl2-mixer-playwave/build-sdl2-mixer-playwave.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Build upstream SDL_mixer's unmodified playwave sample against Kandelo SDL2. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kandelo-sdl2-mixer-playwave.XXXXXX")" +trap 'rm -rf "$WORK_DIR"' EXIT + +# shellcheck source=/dev/null +source "$REPO_ROOT/sdk/activate.sh" + +MIXER_VERSION="${WASM_POSIX_DEP_VERSION:-2.8.2}" +SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://github.com/libsdl-org/SDL_mixer/releases/download/release-${MIXER_VERSION}/SDL2_mixer-${MIXER_VERSION}.tar.gz}" +SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-938dff531d00ace2296557a6599abe6f34599e2f34f0a4a08a397e2ccac8b8f7}" +INSTALL_DIR="${WASM_POSIX_DEP_OUT_DIR:?WASM_POSIX_DEP_OUT_DIR must name the resolver staging directory}" +TARGET_ARCH="${WASM_POSIX_DEP_TARGET_ARCH:-wasm32}" +SDL2_PREFIX="${WASM_POSIX_DEP_SDL2_DIR:?resolver did not provide the direct sdl2 dependency}" + +if [ "$TARGET_ARCH" != "wasm32" ]; then + echo "ERROR: SDL_mixer playwave currently supports only wasm32, got $TARGET_ARCH" >&2 + exit 1 +fi + +export WASM_POSIX_SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" +CC=wasm32posix-cc +CXX=wasm32posix-c++ +AR=wasm32posix-ar +RANLIB=wasm32posix-ranlib +NM=wasm32posix-nm +STRIP=wasm32posix-strip +for tool in "$CC" "$CXX" "$AR" "$RANLIB" "$NM" "$STRIP" \ + make curl tar shasum; do + command -v "$tool" >/dev/null || { + echo "ERROR: required build tool not found: $tool" >&2 + exit 1 + } +done + +test -f "$SDL2_PREFIX/lib/libSDL2.a" +test -f "$SDL2_PREFIX/include/SDL2/SDL.h" +test -f "$SDL2_PREFIX/lib/pkgconfig/sdl2.pc" + +TARBALL="$WORK_DIR/SDL2_mixer.tar.gz" +SRC_DIR="$WORK_DIR/source" +BUILD_DIR="$WORK_DIR/build" +REPRO_FLAGS="-ffile-prefix-map=$WORK_DIR=/usr/src/sdl2-mixer -fdebug-prefix-map=$WORK_DIR=/usr/src/sdl2-mixer -fmacro-prefix-map=$WORK_DIR=/usr/src/sdl2-mixer" + +echo "==> Downloading SDL_mixer $MIXER_VERSION..." +curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors \ + -fsSL "$SOURCE_URL" -o "$TARBALL" +echo "$SOURCE_SHA256 $TARBALL" | shasum -a 256 -c - +mkdir -p "$SRC_DIR" "$BUILD_DIR" "$INSTALL_DIR" +tar xzf "$TARBALL" -C "$SRC_DIR" --strip-components=1 + +echo "==> Configuring upstream playwave with only built-in WAVE support..." +( + cd "$BUILD_DIR" + export PKG_CONFIG_PATH="$SDL2_PREFIX/lib/pkgconfig" + export PKG_CONFIG_LIBDIR="$SDL2_PREFIX/lib/pkgconfig" + "$SRC_DIR/configure" \ + --host=wasm32-unknown-none \ + --prefix="$WORK_DIR/install-unused" \ + --enable-static \ + --disable-shared \ + --disable-sdltest \ + --disable-music-cmd \ + --enable-music-wave \ + --disable-music-mod \ + --disable-music-midi \ + --disable-music-gme \ + --disable-music-ogg \ + --disable-music-flac \ + --disable-music-mp3 \ + --disable-music-opus \ + --disable-music-wavpack \ + CC="$CC" CXX="$CXX" AR="$AR" RANLIB="$RANLIB" \ + NM="$NM" STRIP="$STRIP" \ + CFLAGS="-O2 -DSDL_MAIN_HANDLED $REPRO_FLAGS" + + make -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" build/playwave +) + +test -f "$BUILD_DIR/build/playwave" +cp "$BUILD_DIR/build/playwave" "$INSTALL_DIR/playwave.uninstrumented.wasm" +"$REPO_ROOT/scripts/run-wasm-fork-instrument.sh" \ + "$INSTALL_DIR/playwave.uninstrumented.wasm" \ + -o "$INSTALL_DIR/playwave.wasm" +rm -f "$INSTALL_DIR/playwave.uninstrumented.wasm" + +test -f "$INSTALL_DIR/playwave.wasm" +echo "==> SDL_mixer playwave fixture complete" diff --git a/packages/registry/sdl2-mixer-playwave/build.toml b/packages/registry/sdl2-mixer-playwave/build.toml new file mode 100644 index 0000000000..6873c8c473 --- /dev/null +++ b/packages/registry/sdl2-mixer-playwave/build.toml @@ -0,0 +1,11 @@ +script_path = "packages/registry/sdl2-mixer-playwave/build-sdl2-mixer-playwave.sh" +inputs = [ + "packages/registry/sdl2-mixer-playwave/build-sdl2-mixer-playwave.sh", + "scripts/run-wasm-fork-instrument.sh", +] +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "UNPUBLISHED" +revision = 1 + +[binary] +index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/sdl2-mixer-playwave/package.toml b/packages/registry/sdl2-mixer-playwave/package.toml new file mode 100644 index 0000000000..591de59dee --- /dev/null +++ b/packages/registry/sdl2-mixer-playwave/package.toml @@ -0,0 +1,45 @@ +kind = "program" +name = "sdl2-mixer-playwave" +version = "2.8.2" +kernel_abi = 43 +depends_on = ["sdl2@2.32.10"] +arches = ["wasm32"] + +[source] +url = "https://github.com/libsdl-org/SDL_mixer/releases/download/release-2.8.2/SDL2_mixer-2.8.2.tar.gz" +sha256 = "938dff531d00ace2296557a6599abe6f34599e2f34f0a4a08a397e2ccac8b8f7" + +[license] +spdx = "Zlib" +url = "https://github.com/libsdl-org/SDL_mixer/blob/release-2.8.2/LICENSE.txt" + +[build] +script_path = "packages/registry/sdl2-mixer-playwave/build-sdl2-mixer-playwave.sh" + +[[host_tools]] +name = "make" +version_constraint = ">=3.80" +probe = { args = ["--version"], version_regex = "GNU Make (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "curl" +version_constraint = ">=7.71.0" +probe = { args = ["--version"], version_regex = "curl (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "tar" +version_constraint = ">=1.30" +probe = { args = ["--version"], version_regex = "tar.*?(\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "shasum" +version_constraint = ">=6.0" +probe = { args = ["--version"], version_regex = "(\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[outputs]] +name = "playwave" +wasm = "playwave.wasm" diff --git a/packages/registry/sdl2/build-sdl2.sh b/packages/registry/sdl2/build-sdl2.sh new file mode 100644 index 0000000000..b5708e0d01 --- /dev/null +++ b/packages/registry/sdl2/build-sdl2.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# Build upstream SDL 2 with its unmodified OSS dsp backend for Kandelo. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kandelo-sdl2.XXXXXX")" +trap 'rm -rf "$WORK_DIR"' EXIT + +# shellcheck source=/dev/null +source "$REPO_ROOT/sdk/activate.sh" + +SDL_VERSION="${WASM_POSIX_DEP_VERSION:-2.32.10}" +SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://github.com/libsdl-org/SDL/releases/download/release-${SDL_VERSION}/SDL2-${SDL_VERSION}.tar.gz}" +SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-5f5993c530f084535c65a6879e9b26ad441169b3e25d789d83287040a9ca5165}" +INSTALL_DIR="${WASM_POSIX_DEP_OUT_DIR:?WASM_POSIX_DEP_OUT_DIR must name the resolver staging directory}" +TARGET_ARCH="${WASM_POSIX_DEP_TARGET_ARCH:-wasm32}" + +if [ "$TARGET_ARCH" != "wasm32" ]; then + echo "ERROR: SDL2 currently supports only wasm32, got $TARGET_ARCH" >&2 + exit 1 +fi + +export WASM_POSIX_SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" +CC=wasm32posix-cc +CXX=wasm32posix-c++ +AR=wasm32posix-ar +RANLIB=wasm32posix-ranlib +NM=wasm32posix-nm +STRIP=wasm32posix-strip +for tool in "$CC" "$CXX" "$AR" "$RANLIB" "$NM" "$STRIP" \ + make patch curl shasum; do + command -v "$tool" >/dev/null || { + echo "ERROR: required build tool not found: $tool" >&2 + exit 1 + } +done + +TARBALL="$WORK_DIR/SDL2.tar.gz" +SRC_DIR="$WORK_DIR/source" +BUILD_DIR="$WORK_DIR/build" +REPRO_FLAGS="-ffile-prefix-map=$WORK_DIR=/usr/src/sdl2 -fdebug-prefix-map=$WORK_DIR=/usr/src/sdl2 -fmacro-prefix-map=$WORK_DIR=/usr/src/sdl2" + +echo "==> Downloading SDL2 $SDL_VERSION..." +curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors \ + -fsSL "$SOURCE_URL" -o "$TARBALL" +echo "$SOURCE_SHA256 $TARBALL" | shasum -a 256 -c - +mkdir -p "$SRC_DIR" "$BUILD_DIR" +tar xzf "$TARBALL" -C "$SRC_DIR" --strip-components=1 + +echo "==> Applying the Kandelo platform-classification patch..." +patch -d "$SRC_DIR" -p1 < "$SCRIPT_DIR/patches/0001-recognize-kandelo-as-unix.patch" + +echo "==> Configuring SDL2 with only the OSS playback backend..." +# Kandelo exposes neither the non-POSIX sysctl header nor its matching API. +# Pin the cross-compile probe so SDL uses its portable sysconf path. +# Executable links intentionally permit unresolved host imports, so link-only +# Autoconf probes cannot prove optional functions. Pin only helpers absent from +# the Kandelo musl headers/library; SDL provides portable fallbacks for them. +( + cd "$BUILD_DIR" + "$SRC_DIR/configure" \ + --host=wasm32-unknown-none \ + --prefix="$INSTALL_DIR" \ + --enable-static \ + --disable-shared \ + --enable-audio \ + --enable-oss \ + --disable-alsa \ + --disable-pulseaudio \ + --disable-pipewire \ + --disable-jack \ + --disable-sndio \ + --disable-arts \ + --disable-esd \ + --disable-nas \ + --disable-fusionsound \ + --disable-libsamplerate \ + --disable-diskaudio \ + --disable-dummyaudio \ + --disable-video \ + --disable-render \ + --disable-joystick \ + --disable-haptic \ + --disable-hidapi \ + --disable-sensor \ + --disable-power \ + --disable-loadso \ + --disable-libudev \ + --disable-dbus \ + --disable-ime \ + --disable-ibus \ + --disable-fcitx \ + --disable-assembly \ + CC="$CC" CXX="$CXX" AR="$AR" RANLIB="$RANLIB" \ + NM="$NM" STRIP="$STRIP" \ + CFLAGS="-O2 $REPRO_FLAGS" \ + ac_cv_func_dlopen=no \ + ac_cv_func_sysctlbyname=no \ + ac_cv_func_elf_aux_info=no \ + ac_cv_func_pthread_set_name_np=no \ + ac_cv_func__wcsdup=no \ + ac_cv_func__wcsicmp=no \ + ac_cv_func__wcsnicmp=no \ + ac_cv_func__strrev=no \ + ac_cv_func__strupr=no \ + ac_cv_func__strlwr=no \ + ac_cv_func_itoa=no \ + ac_cv_func__ltoa=no \ + ac_cv_func__uitoa=no \ + ac_cv_func__ultoa=no \ + ac_cv_func__i64toa=no \ + ac_cv_func__ui64toa=no \ + ac_cv_func__stricmp=no \ + ac_cv_func__strnicmp=no + + make -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" + make install +) + +# The resolver atomically moves this staging tree, so generated metadata must +# locate the package relative to itself instead of retaining the temp prefix. +sed -i.bak 's|^prefix=.*|prefix=${pcfiledir}/../..|' \ + "$INSTALL_DIR/lib/pkgconfig/sdl2.pc" +rm -f "$INSTALL_DIR/lib/pkgconfig/sdl2.pc.bak" +rm -rf "$INSTALL_DIR/bin" "$INSTALL_DIR/share" "$INSTALL_DIR/lib/cmake" +rm -f "$INSTALL_DIR/lib/"*.la + +test -f "$INSTALL_DIR/lib/libSDL2.a" +test -f "$INSTALL_DIR/include/SDL2/SDL.h" +test -f "$INSTALL_DIR/lib/pkgconfig/sdl2.pc" +echo "==> SDL2 OSS-only static package complete" diff --git a/packages/registry/sdl2/build.toml b/packages/registry/sdl2/build.toml new file mode 100644 index 0000000000..24b5bed7d3 --- /dev/null +++ b/packages/registry/sdl2/build.toml @@ -0,0 +1,11 @@ +script_path = "packages/registry/sdl2/build-sdl2.sh" +inputs = [ + "packages/registry/sdl2/build-sdl2.sh", + "packages/registry/sdl2/patches/0001-recognize-kandelo-as-unix.patch", +] +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "UNPUBLISHED" +revision = 1 + +[binary] +index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/sdl2/package.toml b/packages/registry/sdl2/package.toml new file mode 100644 index 0000000000..37578cfa11 --- /dev/null +++ b/packages/registry/sdl2/package.toml @@ -0,0 +1,53 @@ +kind = "library" + +name = "sdl2" +version = "2.32.10" +kernel_abi = 43 +depends_on = [] +arches = ["wasm32"] + +[source] +url = "https://github.com/libsdl-org/SDL/releases/download/release-2.32.10/SDL2-2.32.10.tar.gz" +sha256 = "5f5993c530f084535c65a6879e9b26ad441169b3e25d789d83287040a9ca5165" + +[license] +spdx = "Zlib" +url = "https://github.com/libsdl-org/SDL/blob/release-2.32.10/LICENSE.txt" + +[build] +script_path = "packages/registry/sdl2/build-sdl2.sh" + +[[host_tools]] +name = "make" +version_constraint = ">=3.80" +probe = { args = ["--version"], version_regex = "GNU Make (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "curl" +version_constraint = ">=7.71.0" +probe = { args = ["--version"], version_regex = "curl (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "tar" +version_constraint = ">=1.30" +probe = { args = ["--version"], version_regex = "tar.*?(\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "patch" +version_constraint = ">=2.0" +probe = { args = ["--version"], version_regex = "patch (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "shasum" +version_constraint = ">=6.0" +probe = { args = ["--version"], version_regex = "(\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[outputs] +libs = ["lib/libSDL2.a"] +headers = ["include/SDL2"] +pkgconfig = ["lib/pkgconfig/sdl2.pc"] diff --git a/packages/registry/sdl2/patches/0001-recognize-kandelo-as-unix.patch b/packages/registry/sdl2/patches/0001-recognize-kandelo-as-unix.patch new file mode 100644 index 0000000000..f59dba776a --- /dev/null +++ b/packages/registry/sdl2/patches/0001-recognize-kandelo-as-unix.patch @@ -0,0 +1,37 @@ +diff --git a/configure.ac b/configure.ac +index 7e03714..6e35ee4 100644 +--- a/configure.ac ++++ b/configure.ac +@@ -3757,6 +3757,6 @@ have_locale=no + dnl Set up the configuration based on the host platform! + case "$host" in +- *-*-linux*|*-*-uclinux*|*-*-gnu*|*-*-k*bsd*-gnu|*-*-bsdi*|*-*-freebsd*|*-*-dragonfly*|*-*-netbsd*|*-*-openbsd*|*-*-sysv5*|*-*-solaris*|*-*-hpux*|*-*-aix*|*-*-minix*|*-*-nto*) ++ wasm32-*-none*|*-*-linux*|*-*-uclinux*|*-*-gnu*|*-*-k*bsd*-gnu|*-*-bsdi*|*-*-freebsd*|*-*-dragonfly*|*-*-netbsd*|*-*-openbsd*|*-*-sysv5*|*-*-solaris*|*-*-hpux*|*-*-aix*|*-*-minix*|*-*-nto*) + case "$host" in + *-*-android*) + # Android +@@ -3774,5 +3774,6 @@ case "$host" in + fi + ;; ++ wasm32-*-none*) ARCH=kandelo ;; + *-*-linux*) ARCH=linux ;; + *-*-uclinux*) ARCH=linux ;; + *-*-kfreebsd*-gnu) ARCH=kfreebsd-gnu ;; +diff --git a/configure b/configure +index d64ae91..42a8f9b 100755 +--- a/configure ++++ b/configure +@@ -28635,5 +28635,5 @@ have_locale=no + case "$host" in +- *-*-linux*|*-*-uclinux*|*-*-gnu*|*-*-k*bsd*-gnu|*-*-bsdi*|*-*-freebsd*|*-*-dragonfly*|*-*-netbsd*|*-*-openbsd*|*-*-sysv5*|*-*-solaris*|*-*-hpux*|*-*-aix*|*-*-minix*|*-*-nto*) ++ wasm32-*-none*|*-*-linux*|*-*-uclinux*|*-*-gnu*|*-*-k*bsd*-gnu|*-*-bsdi*|*-*-freebsd*|*-*-dragonfly*|*-*-netbsd*|*-*-openbsd*|*-*-sysv5*|*-*-solaris*|*-*-hpux*|*-*-aix*|*-*-minix*|*-*-nto*) + case "$host" in + *-*-android*) + # Android +@@ -28653,5 +28653,6 @@ case "$host" in + fi + ;; ++ wasm32-*-none*) ARCH=kandelo ;; + *-*-linux*) ARCH=linux ;; + *-*-uclinux*) ARCH=linux ;; + *-*-kfreebsd*-gnu) ARCH=kfreebsd-gnu ;; diff --git a/packages/registry/sdl3/build-sdl3.sh b/packages/registry/sdl3/build-sdl3.sh new file mode 100644 index 0000000000..26e7070141 --- /dev/null +++ b/packages/registry/sdl3/build-sdl3.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Build upstream SDL 3 with its unmodified OSS dsp backend for Kandelo. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kandelo-sdl3.XXXXXX")" +trap 'rm -rf "$WORK_DIR"' EXIT + +# shellcheck source=/dev/null +source "$REPO_ROOT/sdk/activate.sh" + +SDL_VERSION="${WASM_POSIX_DEP_VERSION:-3.4.10}" +SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://github.com/libsdl-org/SDL/releases/download/release-${SDL_VERSION}/SDL3-${SDL_VERSION}.tar.gz}" +SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-12b34280415ec8418c864408b93d008a20a6530687ee613d60bfbd20411f2785}" +INSTALL_DIR="${WASM_POSIX_DEP_OUT_DIR:?WASM_POSIX_DEP_OUT_DIR must name the resolver staging directory}" +TARGET_ARCH="${WASM_POSIX_DEP_TARGET_ARCH:-wasm32}" + +if [ "$TARGET_ARCH" != "wasm32" ]; then + echo "ERROR: SDL3 currently supports only wasm32, got $TARGET_ARCH" >&2 + exit 1 +fi + +export WASM_POSIX_SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" +for tool in wasm32posix-cc wasm32posix-c++ wasm32posix-ar \ + wasm32posix-ranlib wasm32posix-nm wasm32posix-strip \ + cmake patch curl shasum; do + command -v "$tool" >/dev/null || { + echo "ERROR: required build tool not found: $tool" >&2 + exit 1 + } +done + +TARBALL="$WORK_DIR/SDL3.tar.gz" +SRC_DIR="$WORK_DIR/source" +BUILD_DIR="$WORK_DIR/build" +REPRO_FLAGS="-ffile-prefix-map=$WORK_DIR=/usr/src/sdl3 -fdebug-prefix-map=$WORK_DIR=/usr/src/sdl3 -fmacro-prefix-map=$WORK_DIR=/usr/src/sdl3" + +echo "==> Downloading SDL3 $SDL_VERSION..." +curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors \ + -fsSL "$SOURCE_URL" -o "$TARBALL" +echo "$SOURCE_SHA256 $TARBALL" | shasum -a 256 -c - +mkdir -p "$SRC_DIR" +tar xzf "$TARBALL" -C "$SRC_DIR" --strip-components=1 + +echo "==> Applying the Kandelo platform-classification patch..." +patch -d "$SRC_DIR" -p1 < "$SCRIPT_DIR/patches/0001-recognize-kandelo-platform.patch" + +echo "==> Configuring SDL3 with only the OSS playback backend..." +cmake -S "$SRC_DIR" -B "$BUILD_DIR" \ + -DCMAKE_TOOLCHAIN_FILE="$SCRIPT_DIR/cmake/kandelo-toolchain.cmake" \ + -DCMAKE_INSTALL_PREFIX="$INSTALL_DIR" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_C_FLAGS_RELEASE="-O2 -DNDEBUG $REPRO_FLAGS" \ + -DCMAKE_CXX_FLAGS_RELEASE="-O2 -DNDEBUG $REPRO_FLAGS" \ + -DSDL_INSTALL=ON \ + -DSDL_INSTALL_DOCS=OFF \ + -DSDL_UNINSTALL=OFF \ + -DSDL_RELOCATABLE=ON \ + -DSDL_SHARED=OFF \ + -DSDL_STATIC=ON \ + -DSDL_TEST_LIBRARY=OFF \ + -DSDL_TESTS=OFF \ + -DSDL_EXAMPLES=OFF \ + -DSDL_AUDIO=ON \ + -DSDL_OSS=ON \ + -DSDL_UNIX_CONSOLE_BUILD=ON \ + -DSDL_ALSA=OFF \ + -DSDL_JACK=OFF \ + -DSDL_PIPEWIRE=OFF \ + -DSDL_PULSEAUDIO=OFF \ + -DSDL_SNDIO=OFF \ + -DSDL_DISKAUDIO=OFF \ + -DSDL_DUMMYAUDIO=OFF \ + -DSDL_VIDEO=OFF \ + -DSDL_GPU=OFF \ + -DSDL_RENDER=OFF \ + -DSDL_CAMERA=OFF \ + -DSDL_JOYSTICK=OFF \ + -DSDL_HAPTIC=OFF \ + -DSDL_HIDAPI=OFF \ + -DSDL_POWER=OFF \ + -DSDL_SENSOR=OFF \ + -DSDL_DIALOG=OFF \ + -DSDL_TRAY=OFF \ + -DSDL_DBUS=OFF \ + -DSDL_LIBURING=OFF \ + -DSDL_IBUS=OFF \ + -DSDL_LIBUDEV=OFF \ + -DSDL_ASSEMBLY=OFF \ + -DSDL_OFFSCREEN=OFF \ + -DSDL_RPATH=OFF + +cmake --build "$BUILD_DIR" --parallel +cmake --install "$BUILD_DIR" + +# The resolver atomically moves this staging tree. Keep pkg-config metadata +# relative, and reject CMake metadata that retained the temporary prefix. +sed -i.bak 's|^prefix=.*|prefix=${pcfiledir}/../..|' \ + "$INSTALL_DIR/lib/pkgconfig/sdl3.pc" +rm -f "$INSTALL_DIR/lib/pkgconfig/sdl3.pc.bak" +if grep -R -F "$INSTALL_DIR" "$INSTALL_DIR/lib/cmake/SDL3" >/dev/null; then + echo "ERROR: SDL3 CMake metadata retained its resolver staging prefix" >&2 + exit 1 +fi +rm -rf "$INSTALL_DIR/share" +test -f "$INSTALL_DIR/lib/libSDL3.a" +test -f "$INSTALL_DIR/include/SDL3/SDL.h" +test -f "$INSTALL_DIR/lib/pkgconfig/sdl3.pc" +test -d "$INSTALL_DIR/lib/cmake/SDL3" +echo "==> SDL3 OSS-only static package complete" diff --git a/packages/registry/sdl3/build.toml b/packages/registry/sdl3/build.toml new file mode 100644 index 0000000000..9152606177 --- /dev/null +++ b/packages/registry/sdl3/build.toml @@ -0,0 +1,13 @@ +script_path = "packages/registry/sdl3/build-sdl3.sh" +inputs = [ + "packages/registry/sdl3/build-sdl3.sh", + "packages/registry/sdl3/cmake/kandelo-toolchain.cmake", + "packages/registry/sdl3/cmake/Platform/Kandelo.cmake", + "packages/registry/sdl3/patches/0001-recognize-kandelo-platform.patch", +] +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "UNPUBLISHED" +revision = 1 + +[binary] +index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/sdl3/cmake/Platform/Kandelo.cmake b/packages/registry/sdl3/cmake/Platform/Kandelo.cmake new file mode 100644 index 0000000000..f4497742b7 --- /dev/null +++ b/packages/registry/sdl3/cmake/Platform/Kandelo.cmake @@ -0,0 +1,5 @@ +# Kandelo is a POSIX-compatible Unix platform targeting WebAssembly. +set(UNIX 1) +set(CMAKE_EXECUTABLE_SUFFIX ".wasm") +set(CMAKE_STATIC_LIBRARY_PREFIX "lib") +set(CMAKE_STATIC_LIBRARY_SUFFIX ".a") diff --git a/packages/registry/sdl3/cmake/kandelo-toolchain.cmake b/packages/registry/sdl3/cmake/kandelo-toolchain.cmake new file mode 100644 index 0000000000..c934103c91 --- /dev/null +++ b/packages/registry/sdl3/cmake/kandelo-toolchain.cmake @@ -0,0 +1,23 @@ +# CMake toolchain identity for Kandelo's wasm32 POSIX SDK. +set(CMAKE_SYSTEM_NAME Kandelo) +set(CMAKE_SYSTEM_PROCESSOR wasm32) + +set(CMAKE_C_COMPILER wasm32posix-cc) +set(CMAKE_CXX_COMPILER wasm32posix-c++) +set(CMAKE_AR wasm32posix-ar) +set(CMAKE_RANLIB wasm32posix-ranlib) +set(CMAKE_NM wasm32posix-nm) +set(CMAKE_STRIP wasm32posix-strip) + +set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) +list(PREPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}") + +if(DEFINED ENV{WASM_POSIX_SYSROOT}) + set(CMAKE_SYSROOT "$ENV{WASM_POSIX_SYSROOT}") + set(CMAKE_FIND_ROOT_PATH "$ENV{WASM_POSIX_SYSROOT}") +endif() + +set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) +set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) diff --git a/packages/registry/sdl3/package.toml b/packages/registry/sdl3/package.toml new file mode 100644 index 0000000000..036f3a0517 --- /dev/null +++ b/packages/registry/sdl3/package.toml @@ -0,0 +1,60 @@ +kind = "library" + +name = "sdl3" +version = "3.4.10" +kernel_abi = 43 +depends_on = [] +arches = ["wasm32"] + +[source] +url = "https://github.com/libsdl-org/SDL/releases/download/release-3.4.10/SDL3-3.4.10.tar.gz" +sha256 = "12b34280415ec8418c864408b93d008a20a6530687ee613d60bfbd20411f2785" + +[license] +spdx = "Zlib" +url = "https://github.com/libsdl-org/SDL/blob/release-3.4.10/LICENSE.txt" + +[build] +script_path = "packages/registry/sdl3/build-sdl3.sh" + +[[host_tools]] +name = "cmake" +version_constraint = ">=3.20" +probe = { args = ["--version"], version_regex = "cmake version (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "curl" +version_constraint = ">=7.71.0" +probe = { args = ["--version"], version_regex = "curl (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "tar" +version_constraint = ">=1.30" +probe = { args = ["--version"], version_regex = "tar.*?(\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "patch" +version_constraint = ">=2.0" +probe = { args = ["--version"], version_regex = "patch (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "shasum" +version_constraint = ">=6.0" +probe = { args = ["--version"], version_regex = "(\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[outputs] +libs = ["lib/libSDL3.a"] +headers = ["include/SDL3"] +pkgconfig = ["lib/pkgconfig/sdl3.pc"] +files = [ + "lib/cmake/SDL3/SDL3headersTargets.cmake", + "lib/cmake/SDL3/SDL3staticTargets.cmake", + "lib/cmake/SDL3/SDL3staticTargets-release.cmake", + "lib/cmake/SDL3/SDL3Config.cmake", + "lib/cmake/SDL3/SDL3ConfigVersion.cmake", +] diff --git a/packages/registry/sdl3/patches/0001-recognize-kandelo-platform.patch b/packages/registry/sdl3/patches/0001-recognize-kandelo-platform.patch new file mode 100644 index 0000000000..b74995c516 --- /dev/null +++ b/packages/registry/sdl3/patches/0001-recognize-kandelo-platform.patch @@ -0,0 +1,24 @@ +diff --git a/cmake/sdlplatform.cmake b/cmake/sdlplatform.cmake +index 3a013ab..29056a2 100644 +--- a/cmake/sdlplatform.cmake ++++ b/cmake/sdlplatform.cmake +@@ -30,6 +30,8 @@ function(SDL_DetectCMakePlatform) + set(sdl_cmake_platform RISCOS) + elseif(VITA) + set(sdl_cmake_platform Vita) ++ elseif(CMAKE_SYSTEM_NAME MATCHES "Kandelo.*") ++ set(sdl_cmake_platform Kandelo) + elseif(CMAKE_SYSTEM_NAME MATCHES ".*Linux") + set(sdl_cmake_platform Linux) + elseif(CMAKE_SYSTEM_NAME MATCHES "kFreeBSD.*") +diff --git a/CMakeLists.txt b/CMakeLists.txt +index e0d3bf1..64ad4cc 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -130,0 +131,6 @@ ++# Kandelo is a POSIX-compatible Unix kernel. CMake does not know this custom ++# system name itself, so preserve that platform fact for SDL's Unix checks. ++if(KANDELO) ++ set(UNIX TRUE) ++endif() ++ diff --git a/run.sh b/run.sh index 6a4cd4efea..d2da83e59f 100755 --- a/run.sh +++ b/run.sh @@ -2788,6 +2788,7 @@ clean_target() { warn "Cleaned NetHack (also invalidated nethack.zip and shell.vfs.zst; run '$0 build shell-vfs' to regenerate for browser demo)" ;; fbdoom) rm -rf "$REPO_ROOT/packages/registry/fbdoom/fbdoom-src" \ + "$REPO_ROOT/packages/registry/fbdoom/fbdoom-build" \ "$REPO_ROOT/local-binaries/programs/wasm32/fbdoom" rm -f "$REPO_ROOT/packages/registry/fbdoom/fbdoom.wasm" \ "$REPO_ROOT/local-binaries/programs/wasm32/fbdoom.wasm" \ diff --git a/sdk/src/lib/flags.ts b/sdk/src/lib/flags.ts index 7778c5fa26..c393655ad0 100644 --- a/sdk/src/lib/flags.ts +++ b/sdk/src/lib/flags.ts @@ -4,6 +4,13 @@ import { targetTriple, toolPrefix } from './arch.ts'; export function compileFlags(arch: WasmArch): string[] { return [ `--target=${targetTriple(arch)}`, + // LLVM's generic wasm target has no operating-system identity, but + // Kandelo is a Unix/POSIX userspace. Publish that source-environment + // contract through the reserved macros that Unix compilers conventionally + // predefine. This keeps upstream feature selection (including SDL's Unix + // platform headers) truthful without claiming Linux or another host OS. + '-D__unix__=1', + '-D__unix=1', '-matomics', '-mbulk-memory', '-mexception-handling', diff --git a/sdk/test/cc.test.ts b/sdk/test/cc.test.ts index 8705983a38..1cc7d84b2c 100644 --- a/sdk/test/cc.test.ts +++ b/sdk/test/cc.test.ts @@ -32,6 +32,8 @@ describe('buildClangArgs', () => { it('compile-only: adds compile flags, no link flags', () => { const args = build(['-c', 'foo.c', '-o', 'foo.o']); expect(args).toContain('--target=wasm32-unknown-unknown'); + expect(args).toContain('-D__unix__=1'); + expect(args).toContain('-D__unix=1'); expect(args).toContain('--sysroot=/tmp/sysroot'); expect(args).toContain('-c'); expect(args).toContain('foo.c'); diff --git a/sdk/test/flags.test.ts b/sdk/test/flags.test.ts index 7e7e4b9eee..2f574c1e56 100644 --- a/sdk/test/flags.test.ts +++ b/sdk/test/flags.test.ts @@ -195,6 +195,8 @@ describe('needsLinking', () => { describe('COMPILE_FLAGS', () => { it('includes target and wasm features', () => { expect(COMPILE_FLAGS).toContain('--target=wasm32-unknown-unknown'); + expect(COMPILE_FLAGS).toContain('-D__unix__=1'); + expect(COMPILE_FLAGS).toContain('-D__unix=1'); expect(COMPILE_FLAGS).toContain('-matomics'); expect(COMPILE_FLAGS).toContain('-mbulk-memory'); }); diff --git a/tests/package-system/sdl-dsp-packages.test.ts b/tests/package-system/sdl-dsp-packages.test.ts new file mode 100644 index 0000000000..8255724b9d --- /dev/null +++ b/tests/package-system/sdl-dsp-packages.test.ts @@ -0,0 +1,175 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +function source(path: string): string { + return readFileSync(join(repoRoot, path), "utf8"); +} + +describe("SDL OSS package recipes", () => { + it("pins the official SDL2 and SDL3 release archives", () => { + const sdl2 = source("packages/registry/sdl2/package.toml"); + const sdl3 = source("packages/registry/sdl3/package.toml"); + + expect(sdl2).toContain('version = "2.32.10"'); + expect(sdl2).toContain( + 'sha256 = "5f5993c530f084535c65a6879e9b26ad441169b3e25d789d83287040a9ca5165"', + ); + expect(sdl3).toContain('version = "3.4.10"'); + expect(sdl3).toContain( + 'sha256 = "12b34280415ec8418c864408b93d008a20a6530687ee613d60bfbd20411f2785"', + ); + }); + + it("limits upstream patches to truthful Kandelo platform detection", () => { + for (const patch of [ + "packages/registry/sdl2/patches/0001-recognize-kandelo-as-unix.patch", + "packages/registry/sdl3/patches/0001-recognize-kandelo-platform.patch", + ]) { + const text = source(patch); + expect(text).toMatch(/kandelo/i); + expect(text).not.toMatch(/^diff --git a\/src\/audio\/dsp\//m); + } + const sdl2Patch = source( + "packages/registry/sdl2/patches/0001-recognize-kandelo-as-unix.patch", + ); + expect(sdl2Patch).toContain("wasm32-*-none*"); + expect(sdl2Patch).not.toContain("+ *-*-none*"); + }); + + it("declares its versioned host build tools for resolver preflight", () => { + const sdl2 = source("packages/registry/sdl2/package.toml"); + const sdl3 = source("packages/registry/sdl3/package.toml"); + + for (const tool of ["curl", "tar", "patch", "shasum"]) { + expect(sdl2).toContain(`name = "${tool}"`); + expect(sdl3).toContain(`name = "${tool}"`); + } + expect(sdl2).toContain('name = "make"'); + expect(sdl3).toContain('name = "cmake"'); + // Both source recipes use curl's --retry-all-errors, introduced in 7.71.0. + expect(sdl2).toContain('version_constraint = ">=7.71.0"'); + expect(sdl3).toContain('version_constraint = ">=7.71.0"'); + }); + + it("builds static OSS-only libraries through the worktree SDK", () => { + const sdl2 = source("packages/registry/sdl2/build-sdl2.sh"); + const sdl3 = source("packages/registry/sdl3/build-sdl3.sh"); + + for (const script of [sdl2, sdl3]) { + expect(script).toContain('source "$REPO_ROOT/sdk/activate.sh"'); + expect(script).toContain("WASM_POSIX_DEP_OUT_DIR"); + expect(script).toContain("ffile-prefix-map"); + } + expect(sdl2).toContain("--enable-oss"); + expect(sdl2).toContain("--disable-alsa"); + expect(sdl2).toContain("--disable-pulseaudio"); + expect(sdl2).toContain("CXX=wasm32posix-c++"); + expect(sdl2).toContain("NM=wasm32posix-nm"); + expect(sdl2).toContain("STRIP=wasm32posix-strip"); + expect(sdl2).toContain("ac_cv_func_sysctlbyname=no"); + expect(sdl3).toContain("-DSDL_OSS=ON"); + expect(sdl3).toContain("-DSDL_UNIX_CONSOLE_BUILD=ON"); + expect(sdl3).toContain("-DSDL_ALSA=OFF"); + expect(sdl3).toContain("-DSDL_PULSEAUDIO=OFF"); + const sdl3Toolchain = source( + "packages/registry/sdl3/cmake/kandelo-toolchain.cmake", + ); + expect(sdl3Toolchain).toContain("set(CMAKE_NM wasm32posix-nm)"); + expect(sdl3Toolchain).toContain("set(CMAKE_STRIP wasm32posix-strip)"); + }); +}); + +describe("SDL /dev/dsp integration fixture", () => { + it("declares both upstream-version test executables", () => { + const manifest = source("packages/registry/sdl-dsp-test/package.toml"); + expect(manifest).toContain('depends_on = ["sdl2@2.32.10", "sdl3@3.4.10"]'); + expect(manifest).toContain('wasm = "sdl2-dsp-test.wasm"'); + expect(manifest).toContain('wasm = "sdl3-dsp-test.wasm"'); + }); + + it("forces dsp, reports JSON pacing data, and instruments final Wasm", () => { + const build = source( + "packages/registry/sdl-dsp-test/build-sdl-dsp-test.sh", + ); + const sdl2 = source("packages/registry/sdl-dsp-test/src/sdl2-dsp-test.c"); + const sdl3 = source("packages/registry/sdl-dsp-test/src/sdl3-dsp-test.c"); + + expect(build).toContain("scripts/run-wasm-fork-instrument.sh"); + for (const fixture of [sdl2, sdl3]) { + expect(fixture).toContain('setenv("SDL_AUDIODRIVER", "dsp", 1)'); + expect(fixture).toContain("SDL_DSP_RESULT "); + expect(fixture).toContain('\\"pcm_bytes\\":%llu'); + expect(fixture).toContain('\\"close_ms\\":%llu'); + } + expect(sdl2).toContain("requested.freq = 22050"); + expect(sdl2).toContain("requested.format = AUDIO_U8"); + expect(sdl3).toContain("requested.freq = 48000"); + expect(sdl3).toContain("requested.format = SDL_AUDIO_S16LE"); + expect(sdl3).toContain("#ifndef SDL_PLATFORM_UNIX"); + }); +}); + +describe("SDL_mixer playwave /dev/dsp integration fixture", () => { + it("pins the official SDL_mixer 2.8.2 source and SDL2 dependency", () => { + const manifest = source( + "packages/registry/sdl2-mixer-playwave/package.toml", + ); + + expect(manifest).toContain('version = "2.8.2"'); + expect(manifest).toContain( + 'url = "https://github.com/libsdl-org/SDL_mixer/releases/download/release-2.8.2/SDL2_mixer-2.8.2.tar.gz"', + ); + expect(manifest).toContain( + 'sha256 = "938dff531d00ace2296557a6599abe6f34599e2f34f0a4a08a397e2ccac8b8f7"', + ); + expect(manifest).toContain('depends_on = ["sdl2@2.32.10"]'); + expect(manifest).toContain('name = "playwave"'); + expect(manifest).toContain('wasm = "playwave.wasm"'); + }); + + it("declares every host tool used by the source build", () => { + const manifest = source( + "packages/registry/sdl2-mixer-playwave/package.toml", + ); + + for (const tool of ["make", "curl", "tar", "shasum"]) { + expect(manifest).toContain(`name = "${tool}"`); + } + expect(manifest).toContain('version_constraint = ">=7.71.0"'); + }); + + it("builds unmodified upstream playwave with only WAVE support", () => { + const build = source( + "packages/registry/sdl2-mixer-playwave/build-sdl2-mixer-playwave.sh", + ); + + expect( + existsSync(join(repoRoot, "packages/registry/sdl2-mixer-playwave/patches")), + ).toBe(false); + expect(build).not.toMatch(/\bpatch\b/); + expect(build).toContain('source "$REPO_ROOT/sdk/activate.sh"'); + expect(build).toContain("WASM_POSIX_DEP_OUT_DIR"); + expect(build).toContain("WASM_POSIX_DEP_SDL2_DIR"); + expect(build).toContain("--enable-music-wave"); + for (const decoder of [ + "cmd", + "mod", + "midi", + "gme", + "ogg", + "flac", + "mp3", + "opus", + "wavpack", + ]) { + expect(build).toContain(`--disable-music-${decoder}`); + } + expect(build).toContain("make -j"); + expect(build).toContain("build/playwave"); + expect(build).toContain("scripts/run-wasm-fork-instrument.sh"); + }); +}); From 716e30261c576be9ec4aaec5f1396b70ebe457f1 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sat, 1 Aug 2026 05:56:10 -0400 Subject: [PATCH 53/82] ABI: Establish the pre-vfork ABI 43 foundation Regenerate the Rust-owned package projection after composing the accepted ABI 43 adapter, host-ownership, and audio changes. Record the cache identities and SDL OSS fixture ownership before the later vfork transaction is added. This is a pre-vfork checkpoint, not the final ABI 43 artifact set. --- abi/snapshot.json | 2 +- .../reusable-kernel-export-stack-worker.ts | 18 +- crates/shared/src/lib.rs | 2 +- .../2026-07-31-affordable-fork-then-exec.md | 26 + docs/package-management.md | 7 + docs/plans/2026-08-01-abi-43-batch-plan.md | 180 ++++ host/src/generated/abi.ts | 2 +- host/src/kernel-worker.ts | 17 +- host/src/worker-main.ts | 20 +- host/test/centralized-test-helper.ts | 38 +- .../test/channel-listener-reclamation.test.ts | 7 +- host/test/dri-cube-pyramid.test.ts | 35 +- host/test/exec-retirement-marker.test.ts | 36 + host/test/exec-state-tracking.test.ts | 9 +- host/test/kernel-allocator-churn.test.ts | 5 +- host/test/kernel-authority-boundary.test.ts | 3 +- host/test/kernel-clone-exit-entry.test.ts | 3 + host/test/kernel-entry-context-audit.test.ts | 40 + .../test/kernel-network-cleanup-entry.test.ts | 18 +- host/test/kernel-scratch-contract.test.ts | 58 +- host/test/kernel-worker-test-scratch.ts | 20 +- host/test/package-runtime-file.test.ts | 80 ++ host/test/process-view-teardown.test.ts | 41 +- .../test/reusable-kernel-export-stack.test.ts | 7 +- .../support/kernel-entry-context-audit.ts | 1 + packages/registry/kernel/build-kernel.sh | 12 + packages/registry/program-packages.json | 788 ++++++++++-------- run.sh | 9 +- scripts/package-runtime-file.ts | 98 ++- scripts/resolve-binary.bundle.mjs | 12 +- .../build-input-import-closure.test.ts | 36 +- .../installed-host-package.test.ts | 37 +- tools/xtask/src/homebrew_sidecars.rs | 2 +- 33 files changed, 1182 insertions(+), 487 deletions(-) create mode 100644 docs/plans/2026-08-01-abi-43-batch-plan.md create mode 100644 host/test/exec-retirement-marker.test.ts diff --git a/abi/snapshot.json b/abi/snapshot.json index 9bd7b174e9..2a7bf88b97 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -1077,13 +1077,13 @@ "kernel_mark_process_signaled", "kernel_mq_descriptor_msgsize", "kernel_msqid_ds_bytes", - "kernel_pick_tcp_listener_target", "kernel_pcm_claim_transport", "kernel_pcm_clock_update", "kernel_pcm_reconcile", "kernel_pcm_transport_len", "kernel_pcm_transport_ptr", "kernel_pick_signal_target_tid", + "kernel_pick_tcp_listener_target", "kernel_pipe_has_readers", "kernel_posix_timer_fire", "kernel_process_metadata_begin", diff --git a/apps/browser-demos/test/fixtures/reusable-kernel-export-stack-worker.ts b/apps/browser-demos/test/fixtures/reusable-kernel-export-stack-worker.ts index 9a5c287194..988be7f4ef 100644 --- a/apps/browser-demos/test/fixtures/reusable-kernel-export-stack-worker.ts +++ b/apps/browser-demos/test/fixtures/reusable-kernel-export-stack-worker.ts @@ -4,8 +4,8 @@ import { BrowserTimeProvider } from "../../../../host/src/vfs/time"; import { VirtualPlatformIO } from "../../../../host/src/vfs/vfs"; interface ReusableKernelExports extends WebAssembly.Exports { + kernel_commit_process_exit(status: number): number; kernel_create_process(): number; - kernel_exit(status: number): void; kernel_get_stack_pointer(): number; kernel_reap_process(pid: number): number; kernel_set_current_tid(pid: number, tid: number): number; @@ -118,11 +118,17 @@ async function runProbe({ throw new Error(`bind process ${iteration} failed: ${bindResult}`); } - // A kernel instance is reusable for the lifetime of the machine. A - // successful process exit must return through Rust's Wasm epilogue; using - // a trap as success control flow leaks that activation's shadow-stack - // frame and exhausts the kernel after enough short-lived children. - exports.kernel_exit(0); + // A kernel instance is reusable for the lifetime of the machine. The + // host adapter must commit process exit through Rust's returning Wasm + // epilogue so repeated short-lived children cannot consume its shadow + // stack. The separate guest kernel_exit boundary intentionally traps to + // preserve _exit's non-returning contract. + const committedStatus = exports.kernel_commit_process_exit(0); + if (committedStatus !== 0) { + throw new Error( + `exit process ${iteration} returned ${committedStatus}`, + ); + } const stackPointer = exports.kernel_get_stack_pointer(); if (stackPointer !== baselineStackPointer) { throw new Error( diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 92cf654e71..ceed1614ca 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -2951,13 +2951,13 @@ pub mod abi { "kernel_mark_process_signaled", "kernel_mq_descriptor_msgsize", "kernel_msqid_ds_bytes", - "kernel_pick_tcp_listener_target", "kernel_pcm_claim_transport", "kernel_pcm_clock_update", "kernel_pcm_reconcile", "kernel_pcm_transport_len", "kernel_pcm_transport_ptr", "kernel_pick_signal_target_tid", + "kernel_pick_tcp_listener_target", "kernel_pipe_has_readers", "kernel_posix_timer_fire", "kernel_process_metadata_begin", diff --git a/docs/measurements/2026-07-31-affordable-fork-then-exec.md b/docs/measurements/2026-07-31-affordable-fork-then-exec.md index 978c3bb901..b26856704b 100644 --- a/docs/measurements/2026-07-31-affordable-fork-then-exec.md +++ b/docs/measurements/2026-07-31-affordable-fork-then-exec.md @@ -832,3 +832,29 @@ Still required before a broad vfork or Homebrew completion claim: - pristine upstream-selection tests at uid 1000 and privileged uid 0; - rebuilt and anonymously published Ruby/VFS artifacts; and - the exact real in-guest Homebrew lifecycle and RSS proof. + +### Selected ABI 43 batch validation — 2026-08-01 + +The selected non-Homebrew PR batch, PRs #1096 and #1098, the borrowed-replay +foundation, and PR #947 were composed on +`integration/abi43-batch-20260731`. The frozen sources, history rule, and next +implementation steps are recorded in +`docs/plans/2026-08-01-abi-43-batch-plan.md`. + +The latest broad host run through `scripts/dev-shell.sh` recorded 4,027 passed, +five failed, and 130 skipped tests out of 4,162. Every failure was an explicit +missing complete ABI 43 program-artifact closure in the run-example credential +or resolver fixtures. The exact 4,096-child `posix_spawn` churn passed in that +same concurrent run. + +An earlier concurrent run had terminated a churn child with signal 11. Added +diagnostics identified a test-only ownership race: the installed-host-package +test ran tsup in the shared checkout, whose clean step removed +`host/dist/worker-entry.js` while the churn machine was launching its next +Worker. Building that package in a private temporary source tree removed the +race; this was not evidence of kernel stack loss, renderer OOM, or incorrect +fork admission. + +Guest-visible vfork remains unimplemented after this batch-validation update. +No result above should be read as proof of parent suspension, zero-copy vfork +launch, failed-exec lifetime handling, or pristine upstream Ruby selection. diff --git a/docs/package-management.md b/docs/package-management.md index 25b15f3a7f..99004c891f 100644 --- a/docs/package-management.md +++ b/docs/package-management.md @@ -329,6 +329,13 @@ a complete fetched package, but local, fetched, and installed-package tiers are never combined. If artifacts exist but no tier has the complete accepted closure, resolution fails loudly. +The repo-side bridge incrementally builds the current release `xtask` once and +then executes that prepared binary for metadata queries. CI and other direct +callers may provide `WASM_POSIX_XTASK_BIN`; as with program-index checking, +that override attests that the regular file was prepared from the current +source. The bridge does not rebuild or silently substitute an explicitly +provided tool. + The host resolver applies the same rule automatically when any member of a program package with more than one total `[[outputs]]` plus `[[runtime_files]]` entry is requested. This includes a package with one diff --git a/docs/plans/2026-08-01-abi-43-batch-plan.md b/docs/plans/2026-08-01-abi-43-batch-plan.md new file mode 100644 index 0000000000..6a4cc5e9e5 --- /dev/null +++ b/docs/plans/2026-08-01-abi-43-batch-plan.md @@ -0,0 +1,180 @@ +# ABI 43 integration batch plan + +Recorded: 2026-08-01 (America/Indiana/Indianapolis) + +## Status and authority + +This document records the local ABI 43 integration branch after the selected +open pull requests were forward-ported and composed. It does not authorize a +push, merge, ABI release, package publication, Homebrew cutover, or removal of +the temporary CRuby patch in pull request (PR) #1166. + +The working branch is `integration/abi43-batch-20260731`. Kernel, ABI, libc, +host-runtime, and fork-instrument changes still require Brandon's explicit +approval before merge. + +## History contract + +The umbrella PR must preserve the purpose-scoped commits in this train. It +must be rebase-merged and must not be squash-merged. Forward-ported commits +retain original authorship; integration repairs remain separate commits with +their own purpose. + +The current local range has 145 commits above local `main` at +`c5a24dc148b2e69c0555d9e7802bee7cd48a18d7`. Its authors are: + +- 141 Brandon Payton commits, including 12 whose original GitHub committer is + retained in the source history; +- three Dependabot-authored dependency commits; and +- one `mho22`-authored Windows VFS commit. + +There is one temporary integration merge, `d850197a8`, used to absorb the +then-current `origin/main`. Before an umbrella PR, rebase the train once onto +the selected final mainline and remove that merge topology. Do not squash the +result. Verify the rewritten mapping with `git range-diff` and +`git log --format=fuller`. + +## Frozen selected sources + +The source refs below were fetched before integration. A fresh GitHub audit on +2026-08-01 found every head unchanged, open, and non-draft except the two +explicit ABI foundations, PRs #1096 and #1098, which remain drafts. + +| PR | Frozen head | Integrated purpose | +|---:|---|---| +| 841 | `a2b70da5d` | Correct fpcast-emulated pthread entry calls | +| 1104 | `3b470d3b2` | Distinguish terminals from character devices | +| 965 | `2e125f4c2` | Synthesize Windows host-mount POSIX permissions | +| 861 | `1e5d19ba0` | Record full-validation prerequisites | +| 1013 | `60fb395ec` | Require purpose-prefixed PR and commit text | +| 679 | `4cbaf2b3a` | Restrict reclaim handling to WebKit | +| 720 | `18a5aa3f7` | Avoid `munmap` mapping-vector churn | +| 761 | `076362bb6` | Reject late syscalls from reaped processes | +| 855 | `c413bd85e` | Deliver machine-local UDP between processes | +| 876 | `35f045507` | Preserve descriptor identity through devfs aliases | +| 899 | `915a84b6d` | Preserve executable linker input order | +| 1063 | `e96c38127` | Keep directory streams usable after rewind failure | +| 1129 | `997fc7ba1` | Deliver caught signals before retrying waits | +| 892 | `f678e098e` | Ignore debug names while patching thread modules | +| 886 | `b671aa687` | Finalize readiness timeouts through the kernel | +| 707 | `5af362833` | Use shared dinit images for Node service demos | +| 836 | `40f140846` | Repair xtask fixtures and gate xtask tests | +| 846 | `075e74ac2` | Gate the Rust workspace as one contract | +| 857 | `07c894621` | Reuse one bundled source Worker entry | +| 869 | `08c19c62c` | Add image-owned browser file ingest | +| 870 | `40cad977d` | Update the mkrootfs esbuild security release | +| 1031 | `7deb360be` | Refresh accepted minor and patch npm dependencies | +| 1030 | `abdc9f21a` | Adopt Node 26 type definitions | +| 592 | `0693b8479` | Move selected existing host metadata into Rust | +| 947 | `4fc2dd1cf` | Add OSS-compatible PCM audio across both hosts | +| 1096 | `320e2bc1b` | Make ABI 43 replay activation-state safe | +| 1098 | `74e761e35` | Bound and serialize kernel scratch transfers | + +The PR #592 forward-port moves ownership of already-exposed Kandelo state. Its +SysV shared-memory slice records existing attachment identity in Rust; it does +not add a Linux or general System V compatibility goal. No new SysV API was +selected merely for Linux compatibility. + +PR #947 remains seven authored commits in the train. PRs #1096 and #1098 also +retain their purpose ordering rather than becoming one ABI-shaped squash. +Generated projections and composition repairs follow the commits that make +them necessary. + +## Affordable fork work in the batch + +The train also carries the Kandelo-owned foundation developed in the dedicated +fork worktree: + +- ordinary fork admission rejects retired-memory saturation before allocating + or copying child memory; +- process-memory aliases retain exact backing ownership; +- a child can replay a borrowed parent continuation through private mutable + prefix storage without consuming the parent's frames; +- active side-module state can be reconstructed without writing parent memory; + and +- one exact-generation shared-memory lifetime coordinator prevents overlapping + borrowers and requires terminal evidence before parent resumption. + +These foundations remain intentionally disconnected from guest-visible +`vfork()`. Kandelo's libc still aliases `vfork()` to ordinary `fork()`, +`kernel_fork` still has no mode parameter, and the host still clones full +memory for `SYS_VFORK`. Documentation must continue to report that limitation +until the connected implementation and tests are complete. + +## Composition repairs completed + +The combined tree required additional reviewable repairs that did not belong +to any source PR in isolation: + +- regenerate ABI 43 program projections and the standalone resolver bundle; +- preserve the gated process-memory accessor after authority narrowing; +- route process exit through the returning ABI 43 adapter; +- update host fixtures for Rust-owned signal, timer, SysV, and audio state; +- execute runtime-file metadata through one prepared, source-attested `xtask`; + and +- build installed-host-package fixtures in private output trees so tsup cannot + delete a live machine's Worker entry during parallel tests. + +The last race had presented as a churn child terminated by signal 11. The +captured diagnostic proved that another test had removed +`host/dist/worker-entry.js`; it was not an out-of-memory event, fork admission, +or kernel stack loss. + +## Validation evidence on the composed tree + +All commands supporting claims below ran through `scripts/dev-shell.sh`. + +- ABI snapshot, generated C/TypeScript bindings, and version checks passed. +- Rust workspace validation recorded 1,519 kernel tests and 48 shared tests + passing; xtask recorded 638 unit tests plus its integration test passing. +- The focused authority and lifecycle cluster passed 13 files and 167 tests. +- PCM and audio interruption coverage passed seven files and 39 tests. +- Runtime-file metadata and PHP consumers passed 35 tests with five + intentional skips. +- The exact 4,096-child `posix_spawn`/`waitpid` churn passed alone and in the + broad concurrent suite after installed-package build isolation. +- The latest broad host run recorded 4,027 passed, five failed, and 130 + skipped tests out of 4,162. All five failures are missing complete ABI 43 + program-artifact closures in `run-example-credentials` and + `run-example-resolver`; no source/runtime failure remains in that run. + +The full repository build is not a pass. It reached external Bash source +fallback and stopped on a GNU mirror HTTP 502. Browser production assembly and +artifact-dependent runtime suites remain gated by the same missing complete +ABI 43 program generations. Performance, libc, POSIX, Sortix, and complete +browser claims must wait for their exact prerequisites and suites. + +## Next implementation series + +Keep the connected vfork work as small purpose-scoped commits above this +reviewed batch: + +1. Add the explicit ordinary/vfork mode to the ABI 43 `kernel_fork` import, + generated constants, snapshot, and fork-instrument propagation. +2. Make libc `_Fork()` and `fork()` pass ordinary mode and `vfork()` pass + vfork mode without running `pthread_atfork` handlers. +3. Add authoritative kernel Process state for a vfork child, caller + suspension, overlap/nesting rejection, and exact wait/reaping behavior. +4. Reserve a distinct child channel/control slot before launch and start a + separate child Worker that aliases the parent's shared Memory without a + `WebAssembly.Memory` construction or byte copy. +5. Connect borrowed main and active-side replay while keeping every child + continuation cursor and mutable prefix private. +6. Resume only the calling parent thread after successful exec or exact + `_exit`/signal/crash teardown. Failed exec must leave the lifetime coherent + and the parent blocked. +7. Prove descriptors/open file descriptions, cwd, credentials, signals, + process groups, main and pthread callers, runnable siblings, sequential + calls, overlap rejection, traps, and rollback on Node and applicable + browsers. +8. Rebase linearly, rerun attribution and ABI audits, then open an umbrella PR + only when Brandon authorizes that external action. + +## PR #1166 removal gate + +Do not alter or broaden PR #1166 during this series. Remove it only after +pristine upstream CRuby is rebuilt with working vfork enabled, uid 1000 proves +the upstream vfork path with no full-memory allocation/copy, privileged Ruby +proves its intentional ordinary-fork fallback, the exact artifacts are +published, and the real in-guest Homebrew tap/install lifecycle completes +without renderer loss or history-proportional memory growth. diff --git a/host/src/generated/abi.ts b/host/src/generated/abi.ts index b68c41c16f..4b76b30d96 100644 --- a/host/src/generated/abi.ts +++ b/host/src/generated/abi.ts @@ -663,13 +663,13 @@ export const HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS = [ "kernel_mark_process_signaled", "kernel_mq_descriptor_msgsize", "kernel_msqid_ds_bytes", - "kernel_pick_tcp_listener_target", "kernel_pcm_claim_transport", "kernel_pcm_clock_update", "kernel_pcm_reconcile", "kernel_pcm_transport_len", "kernel_pcm_transport_ptr", "kernel_pick_signal_target_tid", + "kernel_pick_tcp_listener_target", "kernel_pipe_has_readers", "kernel_posix_timer_fire", "kernel_process_metadata_begin", diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index 252550966a..5c039338e4 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -320,6 +320,9 @@ const kernelEntryIntrinsicAtomicsStore = Atomics.store; const kernelEntryIntrinsicAtomicsNotify = Atomics.notify; const KERNEL_ENTRY_I32_BYTES = 4; +// WHY: callers already hold the KernelEntryGate token for the exact process +// memory they are materializing. Use the captured intrinsic getter so guest +// hooks cannot replace WebAssembly.Memory.prototype.buffer mid-entry. function kernelEntryMemoryBuffer( memory: WebAssembly.Memory, ): ArrayBufferLike { @@ -29376,7 +29379,14 @@ export class CentralizedKernelWorker { this.#kernelPointerWidth, "PCM transport", ); - const buffer = kernelEntryMemoryBuffer(this.#kernelMemory!); + // WHY: SharedArrayBuffer cannot lend a bounded subrange. The trusted + // machine-level audio driver receives the backing plus checked offsets; + // its protocol exposes only this validated control-and-ring window. + const buffer = kernelEntryIntrinsicApply( + kernelEntryIntrinsicMemoryBuffer, + this.#kernelMemory!, + [], + ) as ArrayBufferLike; if (!(buffer instanceof SharedArrayBuffer)) { throw new KernelScratchError( "PCM transport is not backed by shared kernel memory", @@ -31445,8 +31455,9 @@ export class CentralizedKernelWorker { udpPlan: UdpBindingCleanupPlan, tcpPlan: TcpListenerCleanupPlan, ): void { - // Publish every replacement before the first callback. The gate keeps - // reentrant ingress behind the complete detached protocol-effect record. + // Publish every replacement before the first callback. Reentrant void + // ingress can join the gate after this complete record; roots that owe a + // synchronous result reject reentrancy rather than returning a fiction. this.udpBindings = udpPlan.udpBindings; this.tcpListenerTargets = tcpPlan.tcpListenerTargets; this.tcpListenerRRIndex = tcpPlan.tcpListenerRRIndex; diff --git a/host/src/worker-main.ts b/host/src/worker-main.ts index 0e4d2ef916..a9eb4300b9 100644 --- a/host/src/worker-main.ts +++ b/host/src/worker-main.ts @@ -47,7 +47,7 @@ import { CH_REQUEST_FLAGS, CH_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY, CH_RETURN, - CH_SIG_BASE, + CH_SIG_SI_CODE, CH_SIG_SIGNUM, CH_STATUS, CH_SYSCALL, @@ -160,10 +160,20 @@ function alignUp(value: number, align: number): number { const SYS_MMAP_NR = ABI_SYSCALLS.Mmap; const PROT_READ_WRITE = 3; const MAP_PRIVATE_ANONYMOUS = 0x22; -const CH_SIG_SI_CODE = CH_SIG_BASE + 24; +const SIGKILL = 9; class ExecRetirement extends Error {} +/** @internal Exported so ABI-generated retirement-marker decoding is tested. */ +export function isExecRetirementMarker( + view: DataView, + channelOffset: number, +): boolean { + return view.getUint32(channelOffset + CH_SIG_SIGNUM, true) === SIGKILL + && view.getUint32(channelOffset + CH_SIG_SI_CODE, true) + === EXEC_RETIRE_SIGNAL_CODE; +} + function markDeferredSignalDelivery( view: DataView, channelOffset: number, @@ -436,11 +446,7 @@ function buildKernelImports( kernel_exit: (status: number): void => { const view = new DataView(memory.buffer); const base = channelOffset; - if ( - view.getUint32(base + CH_SIG_SIGNUM, true) === 9 - && view.getUint32(base + CH_SIG_SI_CODE, true) - === EXEC_RETIRE_SIGNAL_CODE - ) { + if (isExecRetirementMarker(view, base)) { // Exec keeps the kernel Process alive. The old browser Worker must // unwind without publishing SYS_EXIT, then its wrapper emits the // exact-generation memory_quiescent ownership fence. diff --git a/host/test/centralized-test-helper.ts b/host/test/centralized-test-helper.ts index b67ccfebe3..cfa6bd400a 100644 --- a/host/test/centralized-test-helper.ts +++ b/host/test/centralized-test-helper.ts @@ -861,18 +861,32 @@ async function runOnMainThread(options: RunProgramOptions): Promise {}); - workers.delete(exitPid); - } + // WHY: onExit runs as a protocol-publication effect. Its kernel + // capability is revoked, but the entry gate is still draining that + // effect batch, so a nested export is correctly rejected as + // reentrant. Move child deactivation to the next fresh host turn, + // like the production Node and browser teardown paths do after + // their worker-quiescence await. + queueMicrotask(() => { + try { + kernelWorker.deactivateProcess(exitPid); + processProgramBytes.delete(exitPid); + processLayouts.delete(exitPid); + threadAllocators.delete(exitPid); + processPtrWidths.delete(exitPid); + forkReplayContexts.delete(exitPid); + releaseProcessReferenceOwner(exitPid); + const w = workers.get(exitPid); + if (w) { + w.terminate().catch(() => {}); + workers.delete(exitPid); + } + } catch (error) { + rejectExit( + error instanceof Error ? error : new Error(String(error)), + ); + } + }); } }, }, diff --git a/host/test/channel-listener-reclamation.test.ts b/host/test/channel-listener-reclamation.test.ts index bde75451b9..1bfacad070 100644 --- a/host/test/channel-listener-reclamation.test.ts +++ b/host/test/channel-listener-reclamation.test.ts @@ -1,7 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CH_STATUS } from "../src/generated/abi"; -import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { + CentralizedKernelWorker, + createCentralizedKernelWorkerTestDouble, +} from "../src/kernel-worker"; type ChannelFixture = { pid: number; @@ -84,7 +87,7 @@ function createHarness(channels: ChannelFixture[]): ListenerHarness { } } - return Object.assign(Object.create(CentralizedKernelWorker.prototype), { + return Object.assign(createCentralizedKernelWorkerTestDouble(), { processes, activeChannels: [...channels], retiredChannelListeners: new Set(), diff --git a/host/test/dri-cube-pyramid.test.ts b/host/test/dri-cube-pyramid.test.ts index 730b8ee4cb..4588b3f5da 100644 --- a/host/test/dri-cube-pyramid.test.ts +++ b/host/test/dri-cube-pyramid.test.ts @@ -235,18 +235,29 @@ describe.skipIf(!existsSync(programBinary) || !existsSync(kernelBinary))( } }, onExit: (exitPid, exitStatus) => { - referenceOwners.release(exitPid); - const w = workers.get(exitPid); - if (w) { - w.terminate().catch(() => {}); - workers.delete(exitPid); - } - if (exitPid === parentPid) { - kernel.unregisterProcess(exitPid); - resolveExit(exitStatus); - } else { - kernel.deactivateProcess(exitPid); - } + // WHY: lifecycle publication still runs inside the kernel entry + // gate. Retire the fixture's process from a fresh host turn so + // cleanup cannot reenter the export that published this exit. + queueMicrotask(() => { + try { + referenceOwners.release(exitPid); + const w = workers.get(exitPid); + if (w) { + w.terminate().catch(() => {}); + workers.delete(exitPid); + } + if (exitPid === parentPid) { + kernel.unregisterProcess(exitPid); + resolveExit(exitStatus); + } else { + kernel.deactivateProcess(exitPid); + } + } catch (error) { + rejectExit( + error instanceof Error ? error : new Error(String(error)), + ); + } + }); }, }, ); diff --git a/host/test/exec-retirement-marker.test.ts b/host/test/exec-retirement-marker.test.ts new file mode 100644 index 0000000000..f1156974a5 --- /dev/null +++ b/host/test/exec-retirement-marker.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { + CH_SIG_BASE, + CH_SIG_SIGNUM, + CH_SIG_SI_CODE, +} from "../src/generated/abi"; +import { isExecRetirementMarker } from "../src/worker-main"; +import { EXEC_RETIRE_SIGNAL_CODE } from "../src/worker-protocol"; + +describe("exec worker retirement marker", () => { + it("reads the marker from the generated ABI layout", () => { + const channelOffset = 64; + const view = new DataView( + new ArrayBuffer(channelOffset + CH_SIG_SI_CODE + 4), + ); + view.setUint32(channelOffset + CH_SIG_SIGNUM, 9, true); + view.setUint32( + channelOffset + CH_SIG_SI_CODE, + EXEC_RETIRE_SIGNAL_CODE, + true, + ); + + expect(isExecRetirementMarker(view, channelOffset)).toBe(true); + + view.setUint32(channelOffset + CH_SIG_SI_CODE, 0, true); + // ABI 42 placed si_code four bytes earlier. A stale derived offset must + // not accidentally become worker protocol after an ABI layout change. + view.setUint32( + channelOffset + CH_SIG_BASE + 24, + EXEC_RETIRE_SIGNAL_CODE, + true, + ); + expect(isExecRetirementMarker(view, channelOffset)).toBe(false); + }); +}); diff --git a/host/test/exec-state-tracking.test.ts b/host/test/exec-state-tracking.test.ts index 621c1520e6..b5661b36d6 100644 --- a/host/test/exec-state-tracking.test.ts +++ b/host/test/exec-state-tracking.test.ts @@ -1454,9 +1454,12 @@ function createWorker(overrides: Record): any { pointerWidth, { kernelExports: exports, - kernelExportNames: Object.entries(exports) - .filter(([, value]) => typeof value === "function") - .map(([name]) => name), + kernelExportNames: [ + "kernel_take_process_timer_cleanup", + ...Object.entries(exports) + .filter(([, value]) => typeof value === "function") + .map(([name]) => name), + ], }, ); workerKernelExports.set(worker, exports); diff --git a/host/test/kernel-allocator-churn.test.ts b/host/test/kernel-allocator-churn.test.ts index f044aeb928..4836e7f3fd 100644 --- a/host/test/kernel-allocator-churn.test.ts +++ b/host/test/kernel-allocator-churn.test.ts @@ -48,7 +48,10 @@ async function runChurn( readArrayBuffer(churnProgram), ["kernel_allocator_churn_test", mode, String(count)], ); - expect(exitCode, `${mode} churn stderr: ${stderr}`).toBe(0); + expect( + exitCode, + `${mode} churn stderr: ${stderr}\nhost diagnostics: ${diagnostics.join("\n")}`, + ).toBe(0); expect(stderr).toBe(""); expect(diagnostics).toEqual([]); expect(stdout).toContain( diff --git a/host/test/kernel-authority-boundary.test.ts b/host/test/kernel-authority-boundary.test.ts index 59ce2bad3a..a4fd869c2d 100644 --- a/host/test/kernel-authority-boundary.test.ts +++ b/host/test/kernel-authority-boundary.test.ts @@ -145,7 +145,7 @@ describe("kernel authority boundary", () => { ).toThrow(/subclass|exact CentralizedKernelWorker/i); }); - it("limits kernel test authority to one frozen six-method companion", () => { + it("limits kernel test authority to one frozen seven-method companion", () => { const production = new WasmPosixKernel({}, {}); const harness = createWasmPosixKernelTestHarness({}); const authority = harness.testAuthority; @@ -154,6 +154,7 @@ describe("kernel authority boundary", () => { "hostClose", "hostClosedir", "hostFstat", + "hostOpendir", "hostReaddir", "writeKernelBytes", ]; diff --git a/host/test/kernel-clone-exit-entry.test.ts b/host/test/kernel-clone-exit-entry.test.ts index ffdae3faf9..0f0547a3b8 100644 --- a/host/test/kernel-clone-exit-entry.test.ts +++ b/host/test/kernel-clone-exit-entry.test.ts @@ -25,6 +25,7 @@ import { PROCESS_STATE_EXITED, } from "../src/generated/abi"; import { createKernelScratchTestInstance } from "./support/kernel-scratch-instance"; +import { emptyProcessTimerCleanup } from "./kernel-worker-test-scratch"; const CLONE_PARENT_SETTID = 0x0010_0000; const ENOMEM = 12; @@ -39,6 +40,7 @@ const KERNEL_EXPORT_NAMES = [ "kernel_handle_channel", "kernel_inject_mouse_event", "kernel_set_current_tid", + "kernel_take_process_timer_cleanup", "kernel_thread_exit", ] as const; @@ -103,6 +105,7 @@ function makeHarness( kernel_handle_channel: () => 0, kernel_inject_mouse_event: () => 0, kernel_set_current_tid: () => 0, + kernel_take_process_timer_cleanup: emptyProcessTimerCleanup(kernelMemory), kernel_thread_exit: () => 0, ...implementations, }; diff --git a/host/test/kernel-entry-context-audit.test.ts b/host/test/kernel-entry-context-audit.test.ts index 6c03739ae5..861b40ff19 100644 --- a/host/test/kernel-entry-context-audit.test.ts +++ b/host/test/kernel-entry-context-audit.test.ts @@ -49,6 +49,46 @@ describe("kernel entry-context static audit", () => { expect(violations).toEqual([]); }); + it("audits immediate-result ingress as an exact lexical root", () => { + const violations = auditKernelEntryContext(` + interface KernelWorkerEntryContext { + instance: WebAssembly.Instance; + } + class CentralizedKernelWorker { + #runImmediateKernelEntry( + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + ): void {} + #kernelInstanceForEntry( + entry: KernelWorkerEntryContext, + ): WebAssembly.Instance { + return entry.instance; + } + #read(entry: KernelWorkerEntryContext): void { + const fn = this.#kernelInstanceForEntry(entry).exports.read as + () => void; + fn(); + } + claim(): void { + this.#runImmediateKernelEntry("claim", (entry) => { + this.#read(entry); + }); + } + badClaim(): void { + this.#runImmediateKernelEntry("bad claim", (_entry) => { + this.#read(undefined as unknown as KernelWorkerEntryContext); + }); + } + } + `); + + expect(violations).toHaveLength(1); + expect(violations[0]).toMatchObject({ + kind: "missing-explicit-entry", + owner: expect.stringContaining(" { const safe = auditKernelEntryContext(` interface KernelWorkerEntryContext { diff --git a/host/test/kernel-network-cleanup-entry.test.ts b/host/test/kernel-network-cleanup-entry.test.ts index e374e801e3..93f44a6f6a 100644 --- a/host/test/kernel-network-cleanup-entry.test.ts +++ b/host/test/kernel-network-cleanup-entry.test.ts @@ -11,12 +11,14 @@ import { import { allocateKernelScratchRegion } from "../src/kernel-scratch"; import { CH_TOTAL_SIZE } from "../src/generated/abi"; import { createKernelScratchTestInstance } from "./support/kernel-scratch-instance"; +import { emptyProcessTimerCleanup } from "./kernel-worker-test-scratch"; const KERNEL_EXPORT_NAMES = [ "kernel_drain_wakeup_events", "kernel_get_memory_pages", "kernel_inject_datagram", "kernel_remove_process", + "kernel_take_process_timer_cleanup", ] as const; function kernelPointer( @@ -55,6 +57,7 @@ function makeHarness( kernel_get_memory_pages: () => 256, kernel_inject_datagram: () => 0, kernel_remove_process: () => 0, + kernel_take_process_timer_cleanup: emptyProcessTimerCleanup(kernelMemory), ...options.implementations, }; const gate = options.gate ?? new KernelEntryGate(); @@ -116,7 +119,7 @@ describe("network cleanup entry authority", () => { const callbackSnapshots: ReturnType[] = []; const reentryErrors: unknown[] = []; const order: string[] = []; - let queuedSecondUnregister = false; + let nestedUnregisterError: unknown; let harness!: ReturnType; const observe = (label: string): void => { @@ -146,11 +149,12 @@ describe("network cleanup entry authority", () => { getaddrinfo: vi.fn(() => new Uint8Array([127, 0, 0, 1])), unbindUdp: vi.fn(() => { observe("UDP unbind"); - if (!queuedSecondUnregister) { - queuedSecondUnregister = true; - // Void ingress from a detached callback joins the FIFO. It must - // not overlap this publication or close the same resources twice. + try { + // This root returns a synchronous ownership result, so it cannot + // truthfully queue behind the detached publication in progress. harness.worker.unregisterProcess(41); + } catch (error) { + nestedUnregisterError = error; } }), closeTcpListener: vi.fn(() => observe("virtual listener close")), @@ -242,6 +246,10 @@ describe("network cleanup entry authority", () => { (error as KernelReentrantEntryError).activeExportName, ).toBe("detached host phase"); } + expect(nestedUnregisterError).toBeInstanceOf( + KernelReentrantEntryError, + ); + expect(harness.worker.unregisterProcess(41)).toBe(true); expect(removeProcess).toHaveBeenCalledExactlyOnceWith(41); expect(network.unbindUdp).toHaveBeenCalledExactlyOnceWith("41:7"); expect(network.closeTcpListener) diff --git a/host/test/kernel-scratch-contract.test.ts b/host/test/kernel-scratch-contract.test.ts index 9f09c9e34a..a0300882f5 100644 --- a/host/test/kernel-scratch-contract.test.ts +++ b/host/test/kernel-scratch-contract.test.ts @@ -558,7 +558,7 @@ const reviewedScalarKernelExportCalls: AuditAllowance[] = [ "apps/browser-demos/test/fixtures/reusable-kernel-export-stack-worker.ts::runProbe::kernel-export-direct-use::exports.kernel_create_process()", ), reviewedScalarKernelExportCall( - "apps/browser-demos/test/fixtures/reusable-kernel-export-stack-worker.ts::runProbe::kernel-export-direct-use::exports.kernel_exit(0)", + "apps/browser-demos/test/fixtures/reusable-kernel-export-stack-worker.ts::runProbe::kernel-export-direct-use::exports.kernel_commit_process_exit(0)", ), reviewedScalarKernelExportCall( "apps/browser-demos/test/fixtures/reusable-kernel-export-stack-worker.ts::runProbe::kernel-export-direct-use::exports.kernel_get_stack_pointer()", @@ -591,6 +591,9 @@ const reviewedScalarKernelExportCalls: AuditAllowance[] = [ reviewedScalarKernelExportCall( "host/src/kernel-worker.ts::CentralizedKernelWorker.#getProcessExitSignal::kernel-export-direct-use::getExitSignal(pid)", ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#generateHostSignalWithinKernelEntry::kernel-export-direct-use::generateHostSignal(targetPid, signum)", + ), reviewedScalarKernelExportCall( "host/src/kernel-worker.ts::CentralizedKernelWorker.#handleSyscallInner::kernel-export-direct-use::messageSizeForDescriptor( channel.pid, this.guestTidForChannel(channel), origArgs[0], )", ), @@ -631,6 +634,9 @@ const reviewedScalarKernelExportCalls: AuditAllowance[] = [ reviewedScalarKernelExportCall( "host/src/kernel-worker.ts::CentralizedKernelWorker.#releaseBlockingRetrySnapshot::kernel-export-direct-use::release( channel.pid, this.guestTidForChannel(channel), snapshot.retryToken, )", ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#reconcilePcmTransport::kernel-export-direct-use::reconcile()", + ), reviewedScalarKernelExportCall( "host/src/kernel-worker.ts::CentralizedKernelWorker.#rememberBlockingRetrySnapshot::kernel-export-direct-use::tokenForRetry( channel.pid, this.guestTidForChannel(channel), snapshot.syscallNr, )", ), @@ -704,6 +710,12 @@ const reviewedScalarKernelExportCalls: AuditAllowance[] = [ reviewedScalarKernelExportCall( "host/src/kernel-worker.ts::CentralizedKernelWorker.consumeExitedChild::kernel-export-direct-use::reapChild(parentPid, childPid)", ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.claimPcmTransport::kernel-export-direct-use::claimFn(PcmTransportMode.SharedClock)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.claimPcmTransport::kernel-export-direct-use::lenFn()", + ), reviewedScalarKernelExportCall( "host/src/kernel-worker.ts::CentralizedKernelWorker.createProcess::kernel-export-direct-use::createProcess(stdinKind, stdoutKind, stderrKind)", ), @@ -785,6 +797,9 @@ const reviewedScalarKernelExportCalls: AuditAllowance[] = [ reviewedScalarKernelExportCall( "host/src/kernel-worker.ts::CentralizedKernelWorker.notifyParentOfExitedProcess::kernel-export-direct-use::hasNoCldWait(parentPid)", ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.pcmClockUpdate::kernel-export-direct-use::clockUpdate(requestedFrames)", + ), reviewedScalarKernelExportCall( "host/src/kernel-worker.ts::CentralizedKernelWorker.registerProcess::kernel-export-direct-use::getProcessState?.(pid)", ), @@ -969,12 +984,43 @@ const auditAllowances: AuditAllowance[] = [ authorityOwner: "process-memory", why: "This browser epoll reproduction creates its test process memory, not the kernel's linear memory.", }, + { + key: "apps/browser-demos/test/fixtures/borrowed-active-side-replay-browser-worker.ts::::wasm-instance-authority::new WebAssembly.Instance(mainModule, { env: { memory, ...mainEnv }, kernel: { kernel_fork: finishBorrowedFork }, })", + disposition: "non-kernel", + authorityOwner: "process-memory", + why: "This isolated browser worker instantiates the user process's main fork-replay module against the request-owned process memory and wrapped continuation imports.", + }, + { + key: "apps/browser-demos/test/fixtures/borrowed-active-side-replay-browser-worker.ts::::wasm-instance-authority::new WebAssembly.Instance(sideModule, { env: { memory, ...sideEnv }, })", + disposition: "non-kernel", + authorityOwner: "process-memory", + why: "This isolated browser worker instantiates the user process's side module against the same request-owned process memory and activation-specific replay imports.", + }, + { + key: 'apps/browser-demos/test/fixtures/borrowed-fork-replay-browser-worker.ts::::wasm-instance-authority::new WebAssembly.Instance(module, { env: { memory, ...runtime.envImports, }, kernel: { kernel_fork: () => { if (runtime.coordinator.phaseName() !== "child-replay") { throw new Error( `borrowed browser child reached fork while ` + runtime.coordinator.phaseName(), ); } runtime.coordinator.finishReplay(); return 0; }, }, })', + disposition: "non-kernel", + authorityOwner: "process-memory", + why: "This isolated browser worker instantiates one user process replay activation against request-owned memory and the test runtime's wrapped fork imports.", + }, { key: "apps/browser-demos/test/fixtures/reusable-kernel-export-stack-worker.ts::runProbe::wasm-instance-authority::WebAssembly.instantiate(module, imports)", disposition: "kernel-control", authorityOwner: "kernel", why: "This exact dedicated test-worker site is injected through the module-secret kernel harness and retains the raw instance only long enough to verify returning kernel exports restore the Wasm shadow stack; production still publishes only the gated facade.", }, + { + key: "benchmarks/measure-fork-memory-components.mjs::measureSharedMemoryWorker::wasm-memory-authority::new WebAssembly.Memory({ initial: MEMORY_PAGES, maximum: MEMORY_PAGES, shared: true, })", + disposition: "non-kernel", + authorityOwner: "process-memory", + why: "This standalone measurement allocates one disposable process-sized shared memory to isolate Worker/module transfer RSS from fork cloning.", + }, + { + key: "benchmarks/measure-fork-memory-components.mjs::measureClone::wasm-memory-authority::new WebAssembly.Memory({ initial: MEMORY_PAGES, maximum: MEMORY_PAGES, shared: true, })", + disposition: "non-kernel", + authorityOwner: "process-memory", + count: 2, + why: "These two standalone measurement allocations are the disposable parent and child process memories used to compare full and sparse cloning RSS.", + }, { key: 'host/src/process-memory.ts::ProcessMemoryAllocator.createMemory::wasm-memory-authority::new WebAssembly.Memory({ initial: BigInt(request.initialPages) as any, maximum: BigInt(request.maximumPages) as any, shared: true, address: "i64", } as any)', disposition: "non-kernel", @@ -1104,6 +1150,16 @@ const auditAllowances: AuditAllowance[] = [ disposition: "kernel-control", why: "The dedicated worker retrieves the gated façade through the package-private authority; raw callable exports never leave kernel.ts.", }, + { + key: "host/src/kernel-worker.ts::CentralizedKernelWorker.claimPcmTransport::kernel-export-direct-use::ptrFn()", + disposition: "kernel-control", + why: "This exact no-argument export returns the start of the versioned PCM transport; the claim path immediately applies lossless pointer conversion plus a complete current-memory range check before publishing the descriptor.", + }, + { + key: "host/src/kernel-worker.ts::CentralizedKernelWorker.claimPcmTransport::kernel-memory-escape::kernelEntryIntrinsicApply( kernelEntryIntrinsicMemoryBuffer, this.#kernelMemory!, [], )", + disposition: "kernel-control", + why: "The trusted Node/browser audio driver must retain the shared backing for the checked PCM control-and-ring range; the exact claim entry validates the pointer, length, shared backing, and transport header before this machine-level descriptor is published.", + }, { key: "host/src/kernel-worker.ts::CentralizedKernelWorker.#createTestAuthority::scratch-address-contract::options.instance", disposition: "kernel-control", diff --git a/host/test/kernel-worker-test-scratch.ts b/host/test/kernel-worker-test-scratch.ts index 8a62ccb8f6..c5a577ad5f 100644 --- a/host/test/kernel-worker-test-scratch.ts +++ b/host/test/kernel-worker-test-scratch.ts @@ -71,17 +71,21 @@ export function installKernelWorkerTestScratch( } const gate = options.gate ?? new KernelEntryGate(); const gatedInstance = options.boundInstance ?? (() => { - const kernelExports = { - // Most worker tests do not model platform timers. An empty, bounded - // Rust-owned cleanup record is the neutral production result; timer - // ownership tests override this implementation explicitly. - kernel_take_process_timer_cleanup: emptyProcessTimerCleanup(memory), - ...options.kernelExports, - }; + // Most worker tests do not model platform timers. An empty, bounded + // Rust-owned cleanup record is the neutral production result; timer + // ownership tests override this implementation explicitly. + const defaultTimerCleanup = emptyProcessTimerCleanup(memory); const rawInstance = createKernelScratchTestInstance( pointerWidth, memory, - () => kernelExports, + // WHY: scratch fixtures intentionally resolve mutable test doubles at + // call time. Rebuilding this shallow view preserves that late binding + // while still supplying the neutral timer implementation; capturing a + // one-time spread would silently ignore later fault injection. + () => ({ + kernel_take_process_timer_cleanup: defaultTimerCleanup, + ...options.kernelExports, + }), () => pointerWidth === 8 ? BigInt(pointer) : pointer, 4, options.kernelExportNames, diff --git a/host/test/package-runtime-file.test.ts b/host/test/package-runtime-file.test.ts index 47dc490071..912becc02a 100644 --- a/host/test/package-runtime-file.test.ts +++ b/host/test/package-runtime-file.test.ts @@ -1,3 +1,6 @@ +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { parsePackageRuntimeFileContract, @@ -36,6 +39,83 @@ describe("package runtime-file closure metadata", () => { ]); }, 120_000); + it("uses an explicitly prepared xtask without ambient host tools", () => { + const root = mkdtempSync(join(tmpdir(), "kandelo-runtime-metadata-")); + const xtask = join(root, "xtask"); + writeFileSync( + xtask, + `#!/bin/sh +[ "$#" = 4 ] +[ "$1" = build-deps ] +[ "$2" = runtime-file-metadata ] +[ "$3" = php ] +[ "$4" = icu.dat ] +printf '%s\\n' '${metadata()}' +`, + ); + chmodSync(xtask, 0o755); + const savedXtask = process.env.WASM_POSIX_XTASK_BIN; + const hadSavedXtask = Object.prototype.hasOwnProperty.call( + process.env, + "WASM_POSIX_XTASK_BIN", + ); + const savedPath = process.env.PATH; + const hadSavedPath = Object.prototype.hasOwnProperty.call( + process.env, + "PATH", + ); + process.env.WASM_POSIX_XTASK_BIN = xtask; + process.env.PATH = ""; + try { + expect(readPackageRuntimeFileContract(findRepoRoot(), "php", "icu.dat")) + .toEqual({ + artifact: "icu.dat", + guestPath: "/usr/lib/php/icu.dat", + mode: 0o644, + mirrorPath: "php/icu.dat", + closureMirrorPaths: [ + "php/php.wasm", + "php/intl.so", + "php/icu.dat", + ], + }); + } finally { + if (hadSavedXtask) { + process.env.WASM_POSIX_XTASK_BIN = savedXtask ?? ""; + } else { + delete process.env.WASM_POSIX_XTASK_BIN; + } + if (hadSavedPath) { + process.env.PATH = savedPath ?? ""; + } else { + delete process.env.PATH; + } + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects a prepared xtask path that is not a regular file", () => { + const root = mkdtempSync(join(tmpdir(), "kandelo-runtime-metadata-")); + const savedXtask = process.env.WASM_POSIX_XTASK_BIN; + const hadSavedXtask = Object.prototype.hasOwnProperty.call( + process.env, + "WASM_POSIX_XTASK_BIN", + ); + process.env.WASM_POSIX_XTASK_BIN = root; + try { + expect(() => + readPackageRuntimeFileContract(findRepoRoot(), "php", "icu.dat") + ).toThrow(/Prepared xtask is not a regular file/); + } finally { + if (hadSavedXtask) { + process.env.WASM_POSIX_XTASK_BIN = savedXtask ?? ""; + } else { + delete process.env.WASM_POSIX_XTASK_BIN; + } + rmSync(root, { recursive: true, force: true }); + } + }); + it("rejects duplicate or incomplete closure path metadata", () => { expect(() => parsePackageRuntimeFileContract( metadata({ diff --git a/host/test/process-view-teardown.test.ts b/host/test/process-view-teardown.test.ts index a651e91d11..4e32acd652 100644 --- a/host/test/process-view-teardown.test.ts +++ b/host/test/process-view-teardown.test.ts @@ -1,8 +1,18 @@ import { describe, expect, it, vi } from "vitest"; -import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { + CentralizedKernelWorker, + createCentralizedKernelWorkerTestDouble, +} from "../src/kernel-worker"; import { WasmPosixKernel } from "../src/kernel"; +type TestableCentralizedKernelWorker = CentralizedKernelWorker & { + releaseProcessViews( + pid: number, + expectedMemory: WebAssembly.Memory, + ): boolean; +}; + describe("process-owned device view teardown", () => { it("drops every device alias that can retain an exiting process generation", () => { const removePid = vi.fn(); @@ -37,24 +47,23 @@ describe("process-owned device view teardown", () => { const oldMemory = new WebAssembly.Memory({ initial: 1 }); const newMemory = new WebAssembly.Memory({ initial: 1 }); const releaseProcessViews = vi.fn(); - const worker = Object.assign( - Object.create(CentralizedKernelWorker.prototype), - { - processes: new Map([ - [41, { pid: 41, memory: newMemory, channels: [] }], - ]), - kernel: { releaseProcessViews }, - }, - ) as CentralizedKernelWorker; + const worker = createCentralizedKernelWorkerTestDouble(); + Object.assign(worker, { + processes: new Map([ + [41, { pid: 41, memory: newMemory, channels: [] }], + ]), + }); + const kernel = new WasmPosixKernel({}, {}); + vi.spyOn(kernel, "releaseProcessViews").mockImplementation( + releaseProcessViews, + ); + worker.testAuthority.replaceKernelForScratchBoundaryTest(kernel); + const testable = worker as unknown as TestableCentralizedKernelWorker; - expect( - (worker as any).releaseProcessViews(41, oldMemory), - ).toBe(false); + expect(testable.releaseProcessViews(41, oldMemory)).toBe(false); expect(releaseProcessViews).not.toHaveBeenCalled(); - expect( - (worker as any).releaseProcessViews(41, newMemory), - ).toBe(true); + expect(testable.releaseProcessViews(41, newMemory)).toBe(true); expect(releaseProcessViews).toHaveBeenCalledWith(41); }); }); diff --git a/host/test/reusable-kernel-export-stack.test.ts b/host/test/reusable-kernel-export-stack.test.ts index fa9753b5d5..7f2a0f5d99 100644 --- a/host/test/reusable-kernel-export-stack.test.ts +++ b/host/test/reusable-kernel-export-stack.test.ts @@ -8,8 +8,8 @@ import { NodeTimeProvider } from "../src/vfs/time"; import { VirtualPlatformIO } from "../src/vfs/vfs"; interface ReusableKernelExports extends WebAssembly.Exports { + kernel_commit_process_exit(status: number): number; kernel_create_process(): number; - kernel_exit(status: number): void; kernel_get_stack_pointer(): number; kernel_reap_process(pid: number): number; kernel_set_current_tid(pid: number, tid: number): number; @@ -67,7 +67,10 @@ describe("reusable kernel export shadow-stack lifetime", () => { exports.kernel_set_current_tid(pid, pid), `exit bind ${iteration}`, ).toBe(0); - exports.kernel_exit(0); + expect( + exports.kernel_commit_process_exit(0), + `exit commit ${iteration}`, + ).toBe(0); expect( exports.kernel_get_stack_pointer(), `exit stack ${iteration}`, diff --git a/host/test/support/kernel-entry-context-audit.ts b/host/test/support/kernel-entry-context-audit.ts index c9351d0702..9bf7ccdad6 100644 --- a/host/test/support/kernel-entry-context-audit.ts +++ b/host/test/support/kernel-entry-context-audit.ts @@ -84,6 +84,7 @@ interface MethodInfo extends ScopeScan { } const ROOT_INGRESS_METHODS = new Map([ + ["#runImmediateKernelEntry", 1], ["#runOrDeferKernelEntry", 1], ["#runOrDeferChannelKernelEntry", 2], ]); diff --git a/packages/registry/kernel/build-kernel.sh b/packages/registry/kernel/build-kernel.sh index 8ca9a3bae2..a37794498c 100755 --- a/packages/registry/kernel/build-kernel.sh +++ b/packages/registry/kernel/build-kernel.sh @@ -47,15 +47,26 @@ wasm_require_exports "$OUT" \ kernel_has_sa_nocldstop \ kernel_host_adapter_manifest_len \ kernel_host_adapter_manifest_ptr \ + kernel_ipc_shm_lookup_mapping_for_task \ + kernel_ipc_shm_record_mapping_for_process \ + kernel_ipc_shm_record_mapping_for_task \ kernel_ipc_shmat_for_process \ kernel_ipc_shmat_for_task \ + kernel_ipc_shmdt_addr_for_process \ + kernel_ipc_shmdt_addr_for_task \ kernel_ipc_shmdt_for_process \ kernel_ipc_shmdt_for_task \ kernel_is_fd_nonblock \ kernel_mark_process_signaled \ kernel_mq_descriptor_msgsize \ kernel_msqid_ds_bytes \ + kernel_pcm_claim_transport \ + kernel_pcm_clock_update \ + kernel_pcm_reconcile \ + kernel_pcm_transport_len \ + kernel_pcm_transport_ptr \ kernel_pick_signal_target_tid \ + kernel_pick_tcp_listener_target \ kernel_pipe_has_readers \ kernel_posix_timer_fire \ kernel_process_metadata_begin \ @@ -76,6 +87,7 @@ wasm_require_exports "$OUT" \ kernel_spawn_scratch_capacity \ kernel_spawn_scratch_pointer \ kernel_spawn_scratch_retained_capacity \ + kernel_take_process_timer_cleanup \ kernel_thread_exit \ kernel_thread_has_deliverable \ kernel_transfer_channel_execute \ diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index f05ede03be..ce01547e3a 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -4,344 +4,344 @@ "bash": { "manifestSha256": "5dc4d97dc4f154f265ff57e9d972e7d5a64a2e15fadedfa246733b830dda3497", "cacheKeys": { - "wasm32": "3bb0b25c26be9fa2fac163fb9f5853958cb1a67d7a6bc6249b2af9e49ba5a63b", - "wasm64": "610dcc2f5af2c23cf666396a85ab4b4a455ab40e73cb38902fe68a3127679e30" + "wasm32": "f72620b103f7606cb3c9e2550ea68dc7c35c6bfbc69b217bb10d906901ec1c6c", + "wasm64": "ce13dbc6980023cf85367554177f62a4c64ea2d32eb9adafba82e4c630be147f" } }, "bc": { "manifestSha256": "43b61e92c29098720bc7f4871a3b611cc01ffd124024290d42967cebb6ffd459", "cacheKeys": { - "wasm32": "b088a88d58385a6cadd463fbbb94944c52790205c08bdf463fbfd5e496fa430e", - "wasm64": "05c026b8e9099ba9837228bba138f1638f63f8d893b504ffdd4d3ffc65be3389" + "wasm32": "43001c22570d4972d6143a04c7ba9103fc35a735c15070978f331353e1ed0108", + "wasm64": "41b9387e2bbcec8772f66f8c8aa6c4afc9e94de856e098392a3a05f7aed6a878" } }, "bzip2": { "manifestSha256": "59e1e53f5675e7c9148634933ed0f4c7192743727025cae4e843b6924c489abf", "cacheKeys": { - "wasm32": "9fa1eb2f8edc0ab1f0a983b603cf8c08c03867d429a73bc2994b9bc001646538", - "wasm64": "6c023377e8de285cd3879bdfdd95b417b98c690b23cf79a2304692a454f2ee88" + "wasm32": "aa390b7415063fe2316048755a2c515c82840564e6c9ef5d0186be27acdec409", + "wasm64": "dd2af354cc47621d6f9cacebcd45411aca86c93e162ea7498d8e5507ddacbd2a" } }, "coreutils": { "manifestSha256": "72fc9c3818fb11cebaa047a623743252395dc729163fa804a7272d59f87fe82d", "cacheKeys": { - "wasm32": "88e8aec625b11e1cf53112017c6b12206c3fb5d4c495bea02297ae4c3a45db1f", - "wasm64": "44ff689fd0db89fcec6c8006e243c7eef825775f93c054b2c116a31f49584088" + "wasm32": "4583435763207ddfd63478fc7b3a263fdc6fae110bcd38109e1e1c26e621ad7f", + "wasm64": "74f816ee53c308c9e94e737275bdb42a71ffd75b5331422f8c106333b7609dc5" } }, "cpython": { "manifestSha256": "7dd4f446697a73941ec940c2ccba4d53be73fe6947f1e701031a4d0aa964c4ff", "cacheKeys": { - "wasm32": "a0ef6affbcfb19fc83326cb35501026e63b93f7849329d6ce190cf197eddce41", - "wasm64": "987e3dff9ca4f3dab397214b10ed0436fe07cfddca0696f0ea592e2fef315a6a" + "wasm32": "a2e894f110205a0cf42bc02a56a31156a82fad0c291595a508bdb7bd09e7a6c7", + "wasm64": "3cf8ae7f6b6393df6e0c3ea8072bc2a6c7581d813d75b6ce48042aa2c1c92f3b" } }, "curl": { "manifestSha256": "55523d50261f46dc4aaaff458d1cd87c6f96eaecd687a7540ead35c96906366e", "cacheKeys": { - "wasm32": "3eb3d0113cc00ca53713eab7c2c68646a77484c332eb82ddd6389ac41d1568cd", - "wasm64": "c78baea32795627a879263af99ae70df02dd35e52fe46cb043d09ff427eeb2e4" + "wasm32": "2e4132091d535e4afe6ef5ca6e0952c3f459330736c0a7eb6125b28bd1f45369", + "wasm64": "195e130269e2854a233b5f163f50fbd26aa8bb31341ad3ac3bb342b955c61a9a" } }, "dash": { "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", "cacheKeys": { - "wasm32": "292795420bea85f6f35af3a2399696e790b7ae22af2385c440e79751d5e8e9ad", - "wasm64": "9de9e85d91e122625a02db27626c24ce1051077fd908bc6545177814c7a78d0e" + "wasm32": "c4b4345aa3f9011d0b214ad6adaa6c7fbe5eb2f6bbd0e5181b8f4be68f437bf3", + "wasm64": "f37e09c31002de4be5c25d4f789b81e2ced708016032756a6cafe010a4537811" } }, "diffutils": { "manifestSha256": "2286203eb762eca487939963e38ea1b901ac2dc9a830d3260c2fb291757df208", "cacheKeys": { - "wasm32": "0cfbd31c5b125cdc4ed2a0d4116ea69c94cd5eb3dabff3045b7db3b21feaec30", - "wasm64": "feca794225fd4c00c2006f2739f396bdbaf96799aa5cc513141c25bd03a4de5c" + "wasm32": "d48b0feda4af14e2d08a803611d0b825ac51e9a9379bdb8b49e1170eaf75e5d0", + "wasm64": "b574d2b123a1d927e46d99624745567d382dd3b81d99f4cc5beb2bfb7e9fad0b" } }, "dinit": { "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", "cacheKeys": { - "wasm32": "afabec4ac51bd35f27451be1ab27dbcd29abe2bdc93ee24d7ba39faf65c583c9", - "wasm64": "324b974aed89280168647e7c5cd771468d9ae5cc5878944539eed5af2b7e4a33" + "wasm32": "543e082567cc0502609bf0cdef6eb1ba431cef97d798136e739c915f8392615a", + "wasm64": "872d4ca7596d100e781612e7e659bc840c74fdab8c0769746c48ff1d5ec478fb" } }, "erlang": { "manifestSha256": "475d6037e73a0f1e2f4381067194b2422751206979ea17209e10efa5a2a93bbb", "cacheKeys": { - "wasm32": "2e4bcee403f1127d7a0b8eb59c95458e437acce60cdfdc3d3d44ca8d7c7d4f30", - "wasm64": "348df25cdc4695e5a74120f49de746aed4608ff7a1b43eb080f446e27244077c" + "wasm32": "9f4b0bfcf465fb5a83d04186672e5ca6b9e40f33db4f526e6438f0939bf9c8d2", + "wasm64": "4b36337b67b9911f085169937db021e7ec29b080bb9907ca232e7b96e5590808" } }, "erlang-vfs": { "manifestSha256": "15efb74f1825e2b85bedfeb8d98c19fa648c4be39cb9defb65330a8f21eb9141", "cacheKeys": { - "wasm32": "d01d2df6791faa1befb7f407585f436e908f8977b8f1fe24a1221927873d5ce7", - "wasm64": "bd35dc636e918356ec55b7d704bd16ce0ce32a26b1d3cf9504b847f75e673ca3" + "wasm32": "0b302ed848f5596a2c80569a6ee3b6426ae9778a5f0e15a824ee57c609960311", + "wasm64": "cdb9d3661305fa85850b6e4df581166fce5c7978565900f54efcb7c2217fae8b" } }, "fbdoom": { - "manifestSha256": "a00e0d9c84fcdbb3bd95f296cb3422d60b86dcff4c40734eea1bb0bec4c7d902", + "manifestSha256": "7ff2127ca940e41be90ba45204c89089a7a2093567b9bff581a9549b57138626", "cacheKeys": { - "wasm32": "92097da44dd8a57901cf771311b84d1bb395996c9b9f8abd5c144fc042a4ae40", - "wasm64": "7c546ebe90f363e9277188f557abf6080d811fd434b7cb41de929431fb459210" + "wasm32": "816074a21b019d5a52c7a676123b93ebbb0e841cab60a3c7553773520284733f", + "wasm64": "151fcd4ebdbadf44602ea10f2780fc1dbee281b2427829abe160db720e269028" } }, "file": { "manifestSha256": "7874c1affcbbf2c8ab8c8d4beb087f275af57b5caae6a8947bf5a63a181552b2", "cacheKeys": { - "wasm32": "7860fb07d0c2e45be9c06246b3545f408e3f5e18f3f721a052e84ab6fd5aab55", - "wasm64": "7127826b50f421c41d3d1090ae55b67873b32feb6a0735eac8c89c056c01fa7b" + "wasm32": "55d93c1fd59d890d6f78346197a6d474c654fc2a6687e1ff6e1ffb1ae09ce377", + "wasm64": "2ea8d818cf8ec473a4f77d812ea00c43615613a328e06cad6c5f523632a542c7" } }, "findutils": { "manifestSha256": "a9969cb102403cef105e758ef602692500fddd75ec96e4e3f168c50094b6c931", "cacheKeys": { - "wasm32": "f8bd60aa473359e98a1138a6329a8113f0d2e867fa427bb81d3cf8089ed1c5d5", - "wasm64": "8f5bee39b36df0b510799fc316190c1a9cdc170997b3aa48aa7b42ede9999610" + "wasm32": "b866449fe86205186c0537b6992df7747c852f0e07fde1da6f9d0a7b5808b152", + "wasm64": "095f02c50a616ef3983049ea209cb9464d85577d5fffd85fa13cb4aff0f78b7d" } }, "gawk": { "manifestSha256": "b788f3de724cd363ea68d08826586ddf544a75fb5dd9df1369ffafe06d8681b7", "cacheKeys": { - "wasm32": "e77e891fee5d2107b8326a0dd97f184d3d79faf0d87733c11514bd8889e9d30f", - "wasm64": "ffce14a7187436f4a273b99a9bde4988887296b473e52ba9136b11cdc9c5346d" + "wasm32": "13a6253127ab0250d8dc39d51979aae88a0004cb5a268493cd332eaf564546d6", + "wasm64": "a8cd27b75e88597d5f9fb83a3877f14e8951d3ed2ae543dd4fa44c4f0d073923" } }, "git": { "manifestSha256": "c2bc79e62c9a4e840ae94a16af0f41070460eaf3976a990dc60d2db977bee952", "cacheKeys": { - "wasm32": "b42be77b20c056922c3f8af6c8b40d84ec0e3b24eb9165f46dd7280f5e9d6de0", - "wasm64": "36568ecaa24dda8a6ea6d8f0707fbc769f4d3ce8fe24f75bb319716258443f0e" + "wasm32": "d96bb46b733a7cfb83b17b6d53f36c35b1617a75cb6c5b929237d35bff327b31", + "wasm64": "84999c3c616b868f93b1c9318f9d25e9d4d934a0a3142a2e38755cb13c0b15fb" } }, "grep": { "manifestSha256": "7c61b894807b831c7e8816cb1f39c78e3a69a4ced2fa3d18f0abdf00bf18334b", "cacheKeys": { - "wasm32": "a4ecf6c6a921fb6a7b6cf2e305637df1d6a23e86a3f05c8badcb26f6a9267a56", - "wasm64": "2e1cc9e3a26b9d9251e5ced660de01b17eeea3c0c30c9f71871e0f3e69d56b7d" + "wasm32": "e75c15a6f6114b40248b6de7c3057b99b108dde867cda73c111e47c3acbb326f", + "wasm64": "c4f3de09c2acc03edcf4e4b217bdae3e65de546e2543b4bf1ba7db89d59d2bb7" } }, "gzip": { "manifestSha256": "1485add843bbcd744244e707c353208fe11192b7b248feed1550875d3b75a917", "cacheKeys": { - "wasm32": "0f67d1d73c59bdae42cbbdb487e328a356909b7ff6b1316bb84824c62cce245e", - "wasm64": "2d00bc905cc74a21a424f742983c45a8c16c595ec19db9bc898a517b586ccdf1" + "wasm32": "0332b476d7ec3d6fcebec49aadf3facae32a69fc004cebfc6e5c7f3a195561b0", + "wasm64": "bcd0569a7bb38ead44161bb500fe6fcb60a1cb53982314beb8f34bb91061dbeb" } }, "homebrew-bootstrap": { "manifestSha256": "183004a8385ab5afc388cd43401341b82812c67f97b08ad349ebd7f4dbbebd0f", "cacheKeys": { - "wasm32": "9893ff14c81802d32de5f313e79c232c45e803f6e1eee3b751fe128b952cfe81", - "wasm64": "329410b5e4f92231855de007163870ffca6e42c744ce10cc1352a57ca342006f" + "wasm32": "ef0092c0b03589468a00976d8e5167f4a0a18260b8b61b2f7136263d480deaac", + "wasm64": "4bdc42eb5dfe7b8d9cb3e4a039a53816842097a538fd522e34f97d3390ea4b04" } }, "icu": { "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", "cacheKeys": { - "wasm32": "ed82530ca56a059fcb3ded9e7eba665878dd476a4e15fb0c67ec3fbab88ec961", - "wasm64": "2e6e5ec90f438b9949d61d5fb5a00110bb8c4a0ea7d8d81d934e6105fe0f7ba1" + "wasm32": "81677f4b7e2d40634555388d8d06757a052efcbb915bef1c6e57d444137848cd", + "wasm64": "91c5d7b785c8689058944da76ac2987993d90d5663a0003239feb1ec4050d934" } }, "kandelo-sdk": { "manifestSha256": "c081879f1becd855917cff96f17429bcf2b9fd61bf95211eca9ff8fb625bb2eb", "cacheKeys": { - "wasm32": "58a80626efbbee0e7271150ac3cd20cb54562bd9f2c96b2e5091e46a25b2306b", - "wasm64": "e47d59d838bfd451e68a3c59d78e8769d6bcda8a547586d3f51f6e48d4534616" + "wasm32": "ce884b1760130a2cb478fbe03f5ec2f407339e5631bebc516daf97a7d80fe096", + "wasm64": "0962794128f6c2b35277c561406bb5885d24618d5821a053fc70e909d89ea909" } }, "kernel": { "manifestSha256": "db1d66db8575562ac7b3e720dbaa06d7dd5eef577d80220ea4167dc2d69b863b", "cacheKeys": { - "wasm32": "223146b18c49321399b8008bdac9397c9af91f4bc3f1b60a936a8684bd9f233b", - "wasm64": "eb7ba1b16cc6abd55137760cd0a01ea683d97bfb356899324712e5b450b6d234" + "wasm32": "1272ecb067f8de873dec513e588c1f1c84186237ecdf69dd93fdb6849d8dea50", + "wasm64": "cc9c0326c42db84eef0a82f2202923b1cb7ee7ac24007dbbd684bbf8ab46736f" } }, "lamp": { "manifestSha256": "250b64635d64f8537178188fb5488dbfd2980d79a1c2ffbf346af67008088ba0", "cacheKeys": { - "wasm32": "77db0a7aaa0a78130e2689a24ac6250a67d358bc65cd1e2919ce95c5ca2e20ea", - "wasm64": "8d4775122fac5b2eb7e50f2debbe21e5c22ed6813e3e8353588b79474390797e" + "wasm32": "9eafe9e962c351bcfe4fceaa2fbace1ad8b9c9c4a74b46fa2d6a80e7e4f6c3d9", + "wasm64": "7bac32a5ae47d59e5dac0d392032282d6458bb674e09a5e3e4b91c4d216b6a8f" } }, "less": { "manifestSha256": "996d61545cfe83dcb663a33e8763e0edbd4db4a605fd69a91b243cf79b1f9b17", "cacheKeys": { - "wasm32": "bbae9262108e82a170c06628207705bb64d55794cdc97969b71e1f23c4c1da98", - "wasm64": "3c0ea075f180db83e6f6187c450fecf5ea8ffcda17866b0cd952548fc0ca37b1" + "wasm32": "180f11bd766f6c2482eb68dba1803d81bcfedc79ff0ff40a4fba1d11219d6dd9", + "wasm64": "9794e1db462de7da1334e3d67b1014c26e81fc1ff1e87cd7c118b0d7cb6b9c21" } }, "libcurl": { "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", "cacheKeys": { - "wasm32": "08ad5e096eb7909993a2aa2d75a3fc0282b5112818f9de92d7a21b9db3f7312b", - "wasm64": "b0e39ce50de688397c7acf2a5d492f2c737d31fc66d1dde7fe14b1c5863d2848" + "wasm32": "6ce2dd7a19b7f5af78edc02fc75d08bbeade7f41e968f8b8a3f6e7e5fe4b26ae", + "wasm64": "69dde320d13b2d22f9cc5a7884e9bbc3597ed36f3fa8be00513a3f144c207967" } }, "libcxx": { "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", "cacheKeys": { - "wasm32": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9", - "wasm64": "e6304f31d7a30e10501b57a82aae97e1bb5f00a00dd8196e9672ffe5b4704d6b" + "wasm32": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023", + "wasm64": "06a4c50ff62d8d2e638f341cf8ec0b1837b24a3d602a437759f0c2d0a8396821" } }, "libiconv": { "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", "cacheKeys": { - "wasm32": "8289b6195a813388b7673810c77749e055904b6ce1f80872c87f617e7c9fe26f", - "wasm64": "7470a5ec8cccedb719e250211b527040a4dfc3ae7c6e11a4ee18961a1855d955" + "wasm32": "8b132a257b9a02a3adccac9d5d6bedc00348a6ac92375401761312d7c46a2ecf", + "wasm64": "83ecaeabc5a95f57b5309bf957b3f9b0dde7bd39d8eef5cd978d9a15028ada59" } }, "libpng": { "manifestSha256": "79ed5c5c072a0267cce5c7a2fe37d8494571b17e44b952f619e04ec1c4a9db10", "cacheKeys": { - "wasm32": "ca264fd3613420d6d6545ce77fb9f3dd9e70c505bc8752cdfe7ff09cab8df4b8", - "wasm64": "831654519ecafb66d72a1638da17c59ef4d92728930dd2b6e19284c43fe055ee" + "wasm32": "5862e6edd08188b92bb7f84c064d147326ec65ba5024bb86e93a8efa93ff13d8", + "wasm64": "430d5136cd30c07465c73b9994fbfe799357a06edf712eb625a2b8d8274d7374" } }, "libxml2": { "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", "cacheKeys": { - "wasm32": "a16f2e59186fef56229883d91bd5b09c9fd24ec96137287ecec4fba963500822", - "wasm64": "4af3cc7d9e3fafea01ad834ae7b48c073fbb8277a22c591c724714e3fc166152" + "wasm32": "4873ada42645826762560ad1833364a60c630c6411f84a03456b171f279faf2b", + "wasm64": "6fe5c3313fb2ae976bc081a78ed82fa93736fa8797191db3f3f01fa2d9f6db46" } }, "libzip": { "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", "cacheKeys": { - "wasm32": "0f052ffc6c568fec083752495f6245511974745f30743d961cbed6296da68f32", - "wasm64": "5083a7fa8eb1204bee34fb9453cd411eec449e83acb1aa1faf560495a7e2044c" + "wasm32": "32c0e36842b5f47e79c66b925601f3f30c4ac982303cd159ade0b6c5755e5b61", + "wasm64": "52b48e294612fb9e5b9c0b83e3ac0007677ed58243d58601a5ae416556ef63f6" } }, "lsof": { "manifestSha256": "cfd199bbe082435f7a2e703e2738a368af1eeb5b76b6e93c376054f21ce94419", "cacheKeys": { - "wasm32": "80a6104542b21e23812feb4376257da964060f48f89b33ae23f0f5e4b02ea7de", - "wasm64": "de1cd85864201cc5981f0bc836c48083f691fd9f093eda6ee4a82c9d862a009a" + "wasm32": "6f7cbc2839c96bdc4569c4845fd0c4671e7596cfd36483deb4e4a05e5d48bccb", + "wasm64": "39892c4b2d66a8e2d0108856fbf8dee8857b06d9f4ccc9c01c6a3d39daa78f4b" } }, "m4": { "manifestSha256": "9b5838bdde8531079f23a89e135aa3832bbbbb7ad6099dd1503db877d52afcad", "cacheKeys": { - "wasm32": "b1f9a8a551e8240c6ceff2fd93866f1a459f2de4d35f7e2e796fafc1decf414f", - "wasm64": "ac3958dfd09aea7b5d1fdb80b5f29e2eacd93ccf5c2c27df28c47a315a4392f8" + "wasm32": "8eb7d929c608eecfe55cffe87513a1d8b7b58c3dbe80f350ab9e0eb9ad7416a8", + "wasm64": "1cdd52610d82f6c8cd191304fd4213b28caa155948d8464773a854df5c5dd1df" } }, "make": { "manifestSha256": "62e8b36a6df7e981d191061a5bb7f2db85b2deaf365352a7d590a6c6c06f2b1e", "cacheKeys": { - "wasm32": "7ffa37065290a8237c7c1aa53d14b19dd993f736628ec2f03372959d57f793e1", - "wasm64": "5636f67b39e72b393128bd128c7fcef9419fd38561ed729009bcdaa6a208cedf" + "wasm32": "dc7fce3e4dd128bbdd6a89356224db601bebd13df9c63548f5ab969a250dfcc5", + "wasm64": "23b0d807988b5c123df13094d172813c4e678017db558a5469e72929dfb9e8ea" } }, "mariadb": { "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", "cacheKeys": { - "wasm32": "a337766281607a68326c2c51b7b54e3a4bd1a71a0a1ea81afa52be09c1e0b2be", - "wasm64": "3a4f8b9c13bd52dc2db0f6945ba0ebf78d28806cf9e7b773e8224e2b7c0beb1f" + "wasm32": "a6fc124a4bc7562dd038f5e616c914837fc5882cd90f0199d469c0259d0a8b4c", + "wasm64": "8dca7f977ae361b01d67d1af6fa7e9847bb9499fd2fa746283dee757caf17837" } }, "mariadb-test": { "manifestSha256": "d4ccce161d659a5a87c80fa1117054bbccea870e0d4a46bd8a070d3930ad0e3f", "cacheKeys": { - "wasm32": "e5bffb7916977e035ce9cc59fe417aebe8b17f6df633a4309eb42015d45fac1a", - "wasm64": "c77517cb6f6ac94a314f67a79e93ae7b2660aab926762a9e9a49babbc685e229" + "wasm32": "1a48ab9b3bb0e378eef2d34d5dcab2084db47cac9da6e63ea77044f8fab184bf", + "wasm64": "b5874ac2d9bb3c1db5c6a4277e7efe9b24444eabfab55b61235ec4228ede972e" } }, "mariadb-vfs": { "manifestSha256": "26e74ac84d89b2a839ed72783437061a23022ef2f88ad0f52b6aacd0442ee1cf", "cacheKeys": { - "wasm32": "cdef3e378496ba28675f3810196078013dcf9bc50e440cb4aa95695866b5bdec", - "wasm64": "c006ddaad6e3eed8106d674e50db8906eb42aa267acaade1b7b07c0fde2c57ae" + "wasm32": "5cf6491aead72e3a179b3a052059d91022f88614a297a95cff25373742397bb7", + "wasm64": "d3797f03263fe43e7766405c957c37e15c877912546353f3f4d19913db9bc07b" } }, "modeset": { "manifestSha256": "36a8683c1c7309701d361c5f77e543a6c1531397b26c72dbb864528c59c42954", "cacheKeys": { - "wasm32": "965d4fca032e68491635b0aebe3498afa07a564d0525877142ac53170adf5193", - "wasm64": "3a31c841e8b235ca011c93d94082276ed7efd1b620f218d1be06046973dd3c5d" + "wasm32": "1d1f953607dc5796583d5284761f37e241bc860d9d874baa4eeb1493b908df6d", + "wasm64": "06f0007dcae2ebbc20b9a5a99235f1bbb7c0d73cac2efe5e39277b12ff5260e0" } }, "msmtpd": { "manifestSha256": "686ff665b5e7907e67618790b1a3eddce2ff47ed665595d17209bdcda1e0b274", "cacheKeys": { - "wasm32": "382f58897a42df23508ddc227f96d3229141399a510a350647f0ce7cc14e81ac", - "wasm64": "27df17d81b2ac9f200907686194920dfc3ea88c5e0f611bd86df1d45b2c839ba" + "wasm32": "554ed9b04dcac3ed56656590be2baadc303cd9494f8742fcd6ab3e9323faf62a", + "wasm64": "bb692d67407811d14ab3eab7cdb81c8c71205a24d79da3ed8fdc4ea34906e441" } }, "nano": { "manifestSha256": "c5a52f0c37e08b475bb815367b1f0d63d0d2b06649d99fe9f461b72d0b9cff51", "cacheKeys": { - "wasm32": "40f555a2b949327f54a4d779f19a955d7d3c9e7f76c8e21d090720bda5426e9d", - "wasm64": "b32e35f051ae1236d7b0bf255bb2a541d6f888ef5db7da7c9ba37a5400d3673b" + "wasm32": "25fe208fc4bfc8234d5145b76ddcdded2a1a17f16ff74886582a1c99b36807b2", + "wasm64": "8c5f3c178a45853409f1b5bf9794f98e3a99b1ff2af8a0835575ce8de5d9e366" } }, "ncurses": { "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", "cacheKeys": { - "wasm32": "70871736d22780051a9fcd7aa3b7fb9064f75d524d87d47ed3e1653eba5b2651", - "wasm64": "3b03426d42f4d0861c98fcc7fb9f0e6ce6b801c2b5c246210e4d9e72de6c291b" + "wasm32": "cf48512ef35d9852f274d38266ba81c4eb39033027e1628712bd4318557a1b79", + "wasm64": "edf239ce74a0d847642d957410642beb3536363a22301bd6bea7f470130a39e0" } }, "netcat": { "manifestSha256": "4cf33cd1ab768b3ad0108da8b68c2ff72e98469cd86a0bf2d0da54a7d4f6fa16", "cacheKeys": { - "wasm32": "e1d8c6bbe0bcee8911bf65f50ffd3db7b2a2c4cc06b012b688e2a490b717701d", - "wasm64": "376e440cd8c4c61178363f34ea157a6ed0ac7e3519ebf99108eb2788b97eb67b" + "wasm32": "baea699ba5ffc02066335a2eba35fab1f0208a111c40a67bcbc7841a7a83b543", + "wasm64": "358cae24ff7d41509e1e9d5500b5b8e30544e2caf8b90e1c687098c2bb196740" } }, "nethack": { "manifestSha256": "1a2f12ec2770bd8d40006d6c878a3493b97c71c2bb63ad08c7f6ad99bfd53504", "cacheKeys": { - "wasm32": "4a3754f3c1f16793ee5887577fd45d23eb9ae77c47bdc1d2f3985ed687003e3a", - "wasm64": "5a7c7bfbcbb550c27a400909b3826f7d59c94b0472dce6678b860b80a1ded6fa" + "wasm32": "8b16339b4630bf4fabc8507793973453fc8dc0d3e82db9f16f513465c066659b", + "wasm64": "65ccd517bed1b4b47cebec0b52f0f684d63ee6e1cd9a753637b2cc8924b32885" } }, "nethack-browser-bundle": { "manifestSha256": "b8294981da7ce4299dfb271d4aecb90ababa97a4d1acc9b716ae05bbe52beb53", "cacheKeys": { - "wasm32": "e549fa92d9ebd9c1bc8f42dbb3a7fc8a189142046ae339d17db38eee0f41bb93", - "wasm64": "3a6fd4676a0631692d7950b198e3cc6092b31ba49b0ef11832b79afcee363a6e" + "wasm32": "59ab4de06527df89271655257375163b3d571099fa077e419c967b4f653d732c", + "wasm64": "0f2157c456228c1a0e53098e2e7ca5b0b49e23c47395035b2035cc58aa29cb98" } }, "nginx": { "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", "cacheKeys": { - "wasm32": "313a503d66a7a8ad435c9aee08a4b44f332d84197caa6bc43e5d84336e3228ec", - "wasm64": "1d24f1d2790600c2c23789c6e93e73356e0a8fa7168b75a16a78df579ea6784c" + "wasm32": "a477b0911328bdd36d79e2339ccad2b67d23c69589fd622af20d8afa5af77df4", + "wasm64": "1ee6b0039a8ccc1ab919dd2d573ef9a5145c565f377d2d6427e9ffb75d208de3" } }, "nginx-php-vfs": { "manifestSha256": "97976410cb02f8ba710d856b4ac904bcf976d677b50eefdee39fb64176070d4b", "cacheKeys": { - "wasm32": "86f5a03d7a0e391e5e4b2ca4aef89768a5c5c34c69adb990ee9a9b4e6b70eb03", - "wasm64": "d0c6a5a4e1f76d8230ef33df341ccd61918db47ce803b782d2ea21a65e7546f5" + "wasm32": "79e5ef3f9123c515d69600d9293dfdba3394b72aabc39cd4a346e5163365f913", + "wasm64": "e7736b7d04bfcd4062dd68fd5ea4a495c18dccc37132bff07cccf4b5310b1497" } }, "nginx-vfs": { "manifestSha256": "46aa2d3250ac5cd0f102a85c3a36a086c7a50ba1f9e05fa1c2aae3d07ad16f10", "cacheKeys": { - "wasm32": "efa2faa3f5213e40ab5de3d87cd5398cfb8823c6ab8a43e614462360e209a4c5", - "wasm64": "a22b9dc96b9f68c0fd0f9a925c404f53ce7fa36e9b8eaae00b3b75965dd05c0b" + "wasm32": "ed0e8b6e71d96ca9ddd55e19983691efc56e50ba975e19147b2f4feef8067eee", + "wasm64": "3c7dcd2a932e0dbcd93e3d1f6643600ce0347b473fbb9463dc32a2e12892814f" } }, "node": { "manifestSha256": "2131a24dfda8587860e57fc65b573086477d376b065b3b9ca4e1dfe4d79f5790", "cacheKeys": { - "wasm32": "2aced04cced76dd3005469ac293751a4994e81de403d3fd06e102f8aa980f35f", - "wasm64": "f09b1db3e1e347b4ed10d08f574e7f599feaae43dacccbc32bf3fb86091d8fca" + "wasm32": "031b17aaa70fd9ad18f6b3414562564f33e88c53caa6dd71b9e85c2f2d326d4c", + "wasm64": "96bd073513e9f553f4736b7eb7919fd3bd67d4eea96d5cf88cebb1cc9f130f93" } }, "node-vfs": { "manifestSha256": "977f219defe57e18017ecc963159df32efdd21c53d6fea05943703d04739d8de", "cacheKeys": { - "wasm32": "b7bf43e041330e9b3759de3a4f3fb8ceb6408afdc19983004a011c320d01161d", - "wasm64": "9f6a7fe67d183d1320057d6f3ababacae20b89ee5d429a2505c05a9bc63c9754" + "wasm32": "705745bad6d92e50cddfc43b985d1095eee4d9069756ce75f65afdaa12b91e0b", + "wasm64": "92fc0ec544efabd9c8c9711e03befed7d283855077ef106eacdcfd1c1a594573" } }, "openssl": { "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", "cacheKeys": { - "wasm32": "23b54aad82b4105ad8f5ea2b66e5c15f30d69455dc4cd28a92a936e87e60914b", - "wasm64": "fb3097dfb43d45ce0c9b184d0701d5f9716a2a5f48ad9641b81d25891a519dba" + "wasm32": "005e9cd3956f26bf2c6e97610ed4c0ca4ee21eaa0d4e59a710f861a78bb7020d", + "wasm64": "b0f46897a88d766840c6b87c376892370a6b37fc80bc4bd8eaeb6a8f15db5c33" } }, "pcre2-source": { @@ -354,197 +354,225 @@ "perl": { "manifestSha256": "6cdc4dbc54d0e4008cff41f82ec8918c0e44b4aef23903539c1bc0f538fe3ad2", "cacheKeys": { - "wasm32": "f7862f341b5ccecda88730fc4ee4af950e7e488113b4701be0268c80f67e13a0", - "wasm64": "3dfbdf1e0a34a0025571dff4ff54d7bef7032fc4f34b6f00cab06d26675ac9a1" + "wasm32": "da7287e1ed6d2f9f39a2cc049a65f5fc76c00959ce7f8b9402f1a22ff8510276", + "wasm64": "9998b665864a44669f8710bba56a71d1a278036bad1e94aba6b8f56549b068b5" } }, "perl-vfs": { "manifestSha256": "1345cc102bc8c24a6bc276410c00b4fcc99ba5f6adc9b031f882aaa35d53c8bc", "cacheKeys": { - "wasm32": "1ede74eb16abfadc77060fb09c60ba8e84ce2e10bb07e2fcf9531a5b84d77922", - "wasm64": "c342faee5c8e5f6050cde9f981c5f5f4a06d39ae65d016eac87c892859a6ab6b" + "wasm32": "0a9e90fbfc9c3b971ad2b035752019674b5a15467737871015887a4787384ab8", + "wasm64": "3a9616e0830c2ed1cfdb984928cddb7e2a4903cdbcf8c0ad755032703065bb8a" } }, "php": { "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", "cacheKeys": { - "wasm32": "c069c011716dd51e586649af498a77fe7f9623c3f87d94517d504d65d59fc7ba", - "wasm64": "63e43ed24fcd90eeef9e671a8f3ccd912f16c48ab6ed24420975eb8dd8465a2a" + "wasm32": "98ecceaf50e4b0dbc10292b49b3c8ee2009e6ba965449123851818c123b9427b", + "wasm64": "64e753c28c8a134d66009f41b3e307d3c4342a1e5470ba30be074c50ed382de1" } }, "posix-utils-lite": { "manifestSha256": "8fd7190b2848ef80143adc7b9268c79e13cd4db3430f7105faf49a4b4407a06f", "cacheKeys": { - "wasm32": "cdb66e1b49ac036686b638f39724ec85da465e9eb43e2328d4ccc043a76f8d39", - "wasm64": "4cc2b148b9ba4335dffb29f0f2b4cc55b118c767cd9fdc224fa534c871a81951" + "wasm32": "a195d1db14bc727860c53d0cd87843aa52a3842fad036180c75c6e19173b7624", + "wasm64": "5157b1a7623120f3b7ccaa0fd31aa5032daab462a29f0ab3b438d8550aa3db98" } }, "python-vfs": { "manifestSha256": "d1aa99feae65f06c0ca8cf52c2176534b2723fd4ee6eba6e72c205245e1c9c26", "cacheKeys": { - "wasm32": "a375f2d91505ab06716eb34d7283c0e7ff89725c46b8e0d2b49aa127c7af45bb", - "wasm64": "1d65fa01870c6a2e342053d553b38af72e2dd17c75a88032a1130dfe19d3044d" + "wasm32": "a2382e09282fc05f23e94a83fbdcb0595a094ce09d7a93f9fa8e2ae1bb6b006f", + "wasm64": "6dc471b14bea588f938b7264e2be5e27ee70bbf0ad0c90ac2016db88b7453093" } }, "redis": { "manifestSha256": "151fb507de953ba2ba94b8f881bc7d66b4c741c1359a2f590cc07f9a4cd368a3", "cacheKeys": { - "wasm32": "53d782b84d5df6927c247fd042ab1099c8d70ac38056b6f16d3c81839626c478", - "wasm64": "d1fc566b4ce0b8f30440f179b900ef6a88676415b52a34693ab01abc6b3a1760" + "wasm32": "fdb4a0416b46db4d5999696fe921a12e434b951312e29530d8b03dda03152eb7", + "wasm64": "16a1a5a147f3b315430ec165af110b0a49a0fde68587d901f32645fb0d157188" } }, "redis-vfs": { "manifestSha256": "234c45c40f94e2a89f98295313b82cb9fce15a412ac829d9e87394e0dc731813", "cacheKeys": { - "wasm32": "535dc5d20e12a3f58533c644abc83b53a9e9bf4e2daea2a288910a3b24d909e3", - "wasm64": "a09d3635abb11f04902744c1ff443638e69bd2a6cdaf52fc34dd09f0aa7484ff" + "wasm32": "4b1a029a8da49d4b8ca43d63de06b10331ecdba4cfc1a16ed8a225824b54ce0f", + "wasm64": "fa0d8a5aed9ecf602d831fdbedf5c3768a22799eddb718fe2da515515f5bae88" } }, "rootfs": { "manifestSha256": "0420201025170f48c32949ac62707d4577cdc13a53ad1f01f59d318ec07c8d6e", "cacheKeys": { - "wasm32": "7f5e181f3f49fd31c5d6d5a51ee6da8b411760ad6d3bceef6388aeb8a0f3fbb6", - "wasm64": "eb962c76a8e47611c665b30d0746ba80ff0aaaa6e7fa6599e31e2aeed366ea8b" + "wasm32": "639d2ea627f402d23bde67e0c370130ec1dac0ddea55af87b01b540a81072c34", + "wasm64": "e0a756b37a30db6245d6abc720c86c992c349918ff6fded752b8406c47063afa" } }, "ruby": { "manifestSha256": "6ea67a246c29cd927a19decbb36ae4c4d478ff6642d2c77e08a6b2cfeb8251d2", "cacheKeys": { - "wasm32": "0f83557241b795469469b3b9547fdfd1f2b67b45cd35e3cef4aef5d3a30e8786", - "wasm64": "f2895d3b87df6065aa515a8d56f20ad611965bd337f8b0f13c5ca1c49a46c872" + "wasm32": "9c89b8e95447c8d17c4ca6c06b712f46b7fa9c4812bbcb637aa44b913092e80d", + "wasm64": "fe54cafbe963da1c1f41c6b80b62b3bfe1fca1abb2420f7c54aa115f2c39238b" + } + }, + "sdl-dsp-test": { + "manifestSha256": "a988bef0b27403846a675965d951a286245fa79e84c209657d70a9a1200e8037", + "cacheKeys": { + "wasm32": "422c6bfdf0bac96e006cd7d0f130cd1a4857be5b124a64965ede999f611c8542", + "wasm64": "5f4a100a587de94bcf503af58b4f4b1db40b6675eaa96eb089b162e26fb9c50a" + } + }, + "sdl2": { + "manifestSha256": "327ce0acca768f51e36ddab8ab58fd57b24cfd9e40722e818103c439f7263dd7", + "cacheKeys": { + "wasm32": "9cc6d3328ea4fb6848530e0e9a8cb5c30a8bd03a356fda29d6b46a91e37a58e8", + "wasm64": "6145d6354b9f03396cfb821b111c8f84ec4829d79cb5c50687299f7d7bcb3340" + } + }, + "sdl2-mixer-playwave": { + "manifestSha256": "5ff3863e9f83cb9ad62931e067ee6e417826e06391862d9d0a58d6cc6b4dc570", + "cacheKeys": { + "wasm32": "d9dbaaebde392a70f307fb54bdf7cbe069c42661f7b04cad8e9c58dfe89cbc1d", + "wasm64": "0c941862580abdeed64bb435c5f8c49d0c8d987739b75b1f1c259baa81eb396e" + } + }, + "sdl3": { + "manifestSha256": "3b7c64605b941e14d336b1a127d8219c50402dece009e8855297a0a0696bd67e", + "cacheKeys": { + "wasm32": "93bdeb1ecdb6e8d5e9e4bc0d14969ab3b4b6ece04481ef464f9050d08ef4036e", + "wasm64": "1afe335682a886843c5c532498ba255953a3beb7e7b69675830872c8275de969" } }, "sed": { "manifestSha256": "0c0d82d53907c19825941501f833252cf9adf35ebcebf6786baae28e5eb6958b", "cacheKeys": { - "wasm32": "e3c2e6258fc5d07bb75d188f2efc391db5c1f2c5474e80ff528075cdda67ae85", - "wasm64": "d235406b0fb236c7cf940b8486988c5543b23bef3d787566f07c4062e53606f6" + "wasm32": "2d8f0e8bafe76521572934b36ab8fc1eb327eac5492c1d96d18fee3b1df926a3", + "wasm64": "0ae1c38b1a3ff0877eaf2be24e9141f330acb24c6968bd145a01345221345b17" } }, "shell": { "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", "cacheKeys": { - "wasm32": "a017aa5de279b7f77c0f283db5fb4f89e43e495da413b4634776574e4040e71a", - "wasm64": "3b2f3ff64c91120325f1488266c32a93dd838e7ba8a9a4798843073494faaf6c" + "wasm32": "924040509cd02cedd551f4afa6568090fcd0bed488d44674c0c1802d33b3e6a4", + "wasm64": "c06b88d3049b3a6a3e122b191f7c9bb325464591267561e249f89c9526794cae" } }, "spidermonkey": { "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", "cacheKeys": { - "wasm32": "bb965948ea0c564fe96b832a9c819003eb7f0d6c3a9957e9c2e52845c9123f6e", - "wasm64": "f47364607db02da84c2be533396eff7b3baaf8e4c83fc2d5b2eac5dae262c7ae" + "wasm32": "997c17703209773768eee15f64449151904b0f89824ebec9649db902126a9573", + "wasm64": "91aa4f76c95a3bb36883858d68fabd2737589def3de6c1f07749ed0e1288756c" } }, "spidermonkey-node": { "manifestSha256": "f156c4c5044d2a9447324e7a2a35f8c3e6fa367b3e44f674dcc30ab6b946fae7", "cacheKeys": { - "wasm32": "137fb610878789d6a3fa9cf4c7dea22fca50171bde6bfcec426a47622e4af83d", - "wasm64": "1b451bb5a4e945212e4935e38791600efa97a1a17f12f13d6409415a5d8630e3" + "wasm32": "ffc7eecad0fb134fb01e8494621a16206d0b698828afe6c1c67cbdbbcfefbd7a", + "wasm64": "ce89980ddb57a50c6bf321341d97ed517472c59424ecbb0920bb5a8ac8221e7d" } }, "sqlite": { "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", "cacheKeys": { - "wasm32": "5b3fda5e074c97b803e622d48b5ab3e87b43230d3f68d668296fb64a27f1568b", - "wasm64": "d4e987f027dcbfdbd590f148d6b55db03bd7f5124c63a5c8d0cf24d2baa109f7" + "wasm32": "6e44f4ec127c258c4185142295492ab790cca757db35a284762a95668e0862e9", + "wasm64": "a8e0c763bdbedf8783b8c3a82942d73ea1e22234d23c2e95704c36b79862a81d" } }, "sqlite-cli": { "manifestSha256": "1cebb768f9473dc58fc6e72c9b24565760aedaec4cbdb29ad43b22a0a5fd7030", "cacheKeys": { - "wasm32": "f5dae689e9c9de8c8ad4fd72f90ee8b4eefd4b10fb2e19478451068b75f565ee", - "wasm64": "668a02a3d79ec15d704f00c1a70851b73a7eeee98d024fd28e2040093c04065c" + "wasm32": "8203b8f73e96deeefbe72c5eb9342cbd62b03fc31a4f3906cfbd4452b05c4c38", + "wasm64": "fa5ed8f2d10305310a4670d06fefcb10a00441379bb4faf016016bf7bdb3fe08" } }, "tar": { "manifestSha256": "4502355c246362a2035e52aa7b35d274882fad4b4404c03470458baee723ae68", "cacheKeys": { - "wasm32": "41dbda340b968a687157d02ec36f3a93c04dc62adcf406e5b388465966bcdefa", - "wasm64": "6180499ea90dbadd916c97a1d616678c7b4addd9f04d2dfb8877a75bb684765f" + "wasm32": "e0e058c4333c4c94b7ed1fb0da398e3e874998c8bf1b3249f84417047a312db7", + "wasm64": "efb9b87404f3c7cf720b67f3ef5bd5247abe746584e328cf594135e0869775b8" } }, "tcl": { "manifestSha256": "b98f237f741b11f5f5da55db75530b421755ba4c8a88d314553106faa1cca1ff", "cacheKeys": { - "wasm32": "48d99083556ccfb2c63736771c11b92e462a40b12d3850cb3ea26aae33826beb", - "wasm64": "ac2c62b8affcfba521f977ba37d466fdf6d0704c32e77f96dc439a7da4822d55" + "wasm32": "fe826385f0bd64f30c93c32bb93cbbb8948a74cf83fd137596c1f5df002ece70", + "wasm64": "f5a911b2527fb245cdfe5570020f3526e5dedabcced69e0e03ca6a003bfbc2f9" } }, "texlive": { "manifestSha256": "028ada023f8a8f9966c8a28575242a339aaaf0e8870fe4a221986133b381c3c0", "cacheKeys": { - "wasm32": "73cea7cd1849aca34f37ae2eb4c6c841328a17ae38e5c1abeeda4a83323a2361", - "wasm64": "7714c13b1cf7ad9306f7ac6289626e9d9b4cca3715e44dab3384524a8ecdfd10" + "wasm32": "86434df9283dee6425eb388b6229fd19a8f9624bef425d5a9b6e37082636cf2a", + "wasm64": "43f26b79acb71939b06a9b48a7cfe5ef3d1c870f84e92e26beeb3dbfa288abb7" } }, "unzip": { "manifestSha256": "ef4e5e34258827ab3cbc1930da3fd9514f08f2d7d56c7c3e0d5c495a4d3a20e9", "cacheKeys": { - "wasm32": "73d6a9db88d94927e2d5bc14d7f79c8eae82b902e890a9bee516cf4196b45046", - "wasm64": "1c98124ac54e8833e5fd324bda257247c304459b6ff7da28c3583983ec80e9e9" + "wasm32": "e6f3c139b81f8398c9083a88c64c541b2d50aa59bdf1eb3795dbc8100c477279", + "wasm64": "e995d0c6a7bd88b2eaf564ccdbe1322010e2cb4d2a56df0d31564d2a94787c7f" } }, "userspace": { "manifestSha256": "221176f2a096dee19bbefd89da5c7f50138ecd92d37ec9d7d6b35dbb25e86924", "cacheKeys": { - "wasm32": "c5a871390a64931e91a74d28998768a34469670bec9a60563b9eceab70108872", - "wasm64": "c78a43c17ffce7d63e756037f1aab417b0c1290a8562c25cf9ee432e7fa737df" + "wasm32": "e83868a1ddf56dd5c1a92f8439208407fba632a8738c1fffb73533cd4b6f36d9", + "wasm64": "9ea28d54c0efb58e1b60b16daf99176e8f843f651ec7d6bfba0e4b01285ced3b" } }, "vim": { "manifestSha256": "c211660ef41d01f95f3fbdf675b74891e897e3f304e04f1bf5586f326007382c", "cacheKeys": { - "wasm32": "f268b7542ce0d20e3e242dfd0bc33206208045fe35a2e9679a92f095afdee7fd", - "wasm64": "1281136757d461966e824e51c63a923420df5e64ed0a88d6660e6dcb5afaa9bf" + "wasm32": "a2ebb59c3b576c0eae37312c39711e3428cea60156614afbdb6c9de1540166eb", + "wasm64": "e922cd27cbd52e3bff06407d1a8c3eda10f439933911d0b5fff58de5bf4a0780" } }, "vim-browser-bundle": { "manifestSha256": "e31d84057db49c3534381604d0c8f3c7d765e33ede560a7ea7b01a5a8f1aa0d3", "cacheKeys": { - "wasm32": "5569fee9f1d9e9c5efcd112ee55b32774f208f1e000a68fdada86b0a5246684e", - "wasm64": "b4060be2180ae3abecde2eba0e41c229a51800f95edd7aa081a31882e4b750ee" + "wasm32": "b4d85b2f95d7c6ec98fa9efe64aafa5238d9fdbce7eede2c93ae16b4c042d1d6", + "wasm64": "222251531fb46c29631c5a6e047ef9ac0a584655ff14335a8628de4acba085bd" } }, "wget": { "manifestSha256": "61420b6cb1be12471f425b0aebf5aec58c49d0b43d939dd54508b305accbf54e", "cacheKeys": { - "wasm32": "dbf745ec60dc75ca96a470f6b07db9b239b3ae518ca75a897dff3346388d4358", - "wasm64": "aa368e415c9fb3c1f53e151c2aa33315c0d8746d047632a705d3bcfce67d8c5b" + "wasm32": "b9ca0d845774d9bc9421298065f0a0ac9c42bd5b31b52688d0ab35278c2adc68", + "wasm64": "89ecd15c2d8799d0cd695c0aadab8900d4ba90fac9fbe67bd22fbcc23c1d5541" } }, "wordpress": { "manifestSha256": "36465e0596a06e855a8524eecfd87da98643bc13b37ee12939f5aab44bf33418", "cacheKeys": { - "wasm32": "bfb506462d4179365315850df70adaaf957016f705b97675354e8ae9620dfaff", - "wasm64": "a2bba82adaf6b04c65250b66744339ebec68c190888318b3dd6c0cf6a9cbb452" + "wasm32": "4c18d0e78186915f6ea6e22e2ef769af3bb413a4b33220dd064aef10fc5a316a", + "wasm64": "5c4f162dbf381cf844642513cf9cbd6b57cbbb360788ff930f7e5aacf9f6ddfc" } }, "xz": { "manifestSha256": "8707d35b8647b1af8fa165dcb6ce7b2a6a007e19ab2daca2680c39395864a737", "cacheKeys": { - "wasm32": "2fbd8cf66a89cf6f38fff3f23ac0bcf5bed0358acfaf7440d29f9a5dfc68064d", - "wasm64": "ed0acf53fb6bc530d55cdb177432edfa626ac0fb71b12f24df8a16184a978c32" + "wasm32": "105b5d372e4e3d6f1cc9bf2871f5a6d082336a0fbfbd044c536d6466b28541f5", + "wasm64": "c59b2ba3e89288ee89d6849ee42eed006a26fbe9834ce92851fd85794b9b5b97" } }, "zip": { "manifestSha256": "9c9cfa8220aaeaea26736b55f7cd0a7517b8853c07f9f0d241ad67dd43592e36", "cacheKeys": { - "wasm32": "fea91b2310de8577c77761a062d1ba542223df19ca4bbc2ed6df8479a5e7837c", - "wasm64": "cf1b53d4fc9500e29db204a4275fd72da2abb7d893d766ea163b253a26007ba7" + "wasm32": "de42ac678212b2313fc54c1c583877e1f69573e850f5634fc296e1985375459a", + "wasm64": "0abc6bb4d733cb454f14d4723bbea16d54830edf516c861515ff4cfda52765f5" } }, "zlib": { "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", "cacheKeys": { - "wasm32": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2", - "wasm64": "f13fdf9a989ce6368e3ddef149979bb301f6fd4534db061e880f1091c39e7d94" + "wasm32": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b", + "wasm64": "68698763f0a75d77f9a6ed7b910cb84f424009940680e99718c83423c474751e" } }, "zstd": { "manifestSha256": "89f266938c2c253700a838e6352c9de66c976c84883325aaa979f72b732357e4", "cacheKeys": { - "wasm32": "a8ccfb874b8b263391a73976b9fa0592c0826a8ee6f0347bcf8ed2cdb9b2e9ed", - "wasm64": "ab5d9613da8725d9be28cac5720bd6a01af41d4af929055b8b142f30c8c1914d" + "wasm32": "b1f13d42e6ad8b54bb66c31ad8614bfd4f8f7989c4cd840131399dce1a15354f", + "wasm64": "28cd00493fe37461154d98877e576caa7d4de021edeba83eca9beafb493b0b86" } } }, @@ -555,14 +583,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "3bb0b25c26be9fa2fac163fb9f5853958cb1a67d7a6bc6249b2af9e49ba5a63b" + "wasm32": "f72620b103f7606cb3c9e2550ea68dc7c35c6bfbc69b217bb10d906901ec1c6c" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "70871736d22780051a9fcd7aa3b7fb9064f75d524d87d47ed3e1653eba5b2651" + "cacheKey": "cf48512ef35d9852f274d38266ba81c4eb39033027e1628712bd4318557a1b79" } ] }, @@ -582,7 +610,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b088a88d58385a6cadd463fbbb94944c52790205c08bdf463fbfd5e496fa430e" + "wasm32": "43001c22570d4972d6143a04c7ba9103fc35a735c15070978f331353e1ed0108" }, "dependencyClosures": { "wasm32": [] @@ -603,7 +631,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "9fa1eb2f8edc0ab1f0a983b603cf8c08c03867d429a73bc2994b9bc001646538" + "wasm32": "aa390b7415063fe2316048755a2c515c82840564e6c9ef5d0186be27acdec409" }, "dependencyClosures": { "wasm32": [] @@ -624,7 +652,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "88e8aec625b11e1cf53112017c6b12206c3fb5d4c495bea02297ae4c3a45db1f" + "wasm32": "4583435763207ddfd63478fc7b3a263fdc6fae110bcd38109e1e1c26e621ad7f" }, "dependencyClosures": { "wasm32": [] @@ -645,14 +673,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a0ef6affbcfb19fc83326cb35501026e63b93f7849329d6ce190cf197eddce41" + "wasm32": "a2e894f110205a0cf42bc02a56a31156a82fad0c291595a508bdb7bd09e7a6c7" }, "dependencyClosures": { "wasm32": [ { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2" + "cacheKey": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b" } ] }, @@ -679,19 +707,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "3eb3d0113cc00ca53713eab7c2c68646a77484c332eb82ddd6389ac41d1568cd" + "wasm32": "2e4132091d535e4afe6ef5ca6e0952c3f459330736c0a7eb6125b28bd1f45369" }, "dependencyClosures": { "wasm32": [ { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "23b54aad82b4105ad8f5ea2b66e5c15f30d69455dc4cd28a92a936e87e60914b" + "cacheKey": "005e9cd3956f26bf2c6e97610ed4c0ca4ee21eaa0d4e59a710f861a78bb7020d" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2" + "cacheKey": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b" } ] }, @@ -711,7 +739,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "292795420bea85f6f35af3a2399696e790b7ae22af2385c440e79751d5e8e9ad" + "wasm32": "c4b4345aa3f9011d0b214ad6adaa6c7fbe5eb2f6bbd0e5181b8f4be68f437bf3" }, "dependencyClosures": { "wasm32": [] @@ -732,7 +760,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0cfbd31c5b125cdc4ed2a0d4116ea69c94cd5eb3dabff3045b7db3b21feaec30" + "wasm32": "d48b0feda4af14e2d08a803611d0b825ac51e9a9379bdb8b49e1170eaf75e5d0" }, "dependencyClosures": { "wasm32": [] @@ -774,14 +802,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "afabec4ac51bd35f27451be1ab27dbcd29abe2bdc93ee24d7ba39faf65c583c9" + "wasm32": "543e082567cc0502609bf0cdef6eb1ba431cef97d798136e739c915f8392615a" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" + "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" } ] }, @@ -815,7 +843,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2e4bcee403f1127d7a0b8eb59c95458e437acce60cdfdc3d3d44ca8d7c7d4f30" + "wasm32": "9f4b0bfcf465fb5a83d04186672e5ca6b9e40f33db4f526e6438f0939bf9c8d2" }, "dependencyClosures": { "wasm32": [] @@ -843,14 +871,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "d01d2df6791faa1befb7f407585f436e908f8977b8f1fe24a1221927873d5ce7" + "wasm32": "0b302ed848f5596a2c80569a6ee3b6426ae9778a5f0e15a824ee57c609960311" }, "dependencyClosures": { "wasm32": [ { "packageName": "erlang", "manifestSha256": "475d6037e73a0f1e2f4381067194b2422751206979ea17209e10efa5a2a93bbb", - "cacheKey": "2e4bcee403f1127d7a0b8eb59c95458e437acce60cdfdc3d3d44ca8d7c7d4f30" + "cacheKey": "9f4b0bfcf465fb5a83d04186672e5ca6b9e40f33db4f526e6438f0939bf9c8d2" } ] }, @@ -865,12 +893,12 @@ ] }, "fbdoom": { - "manifestSha256": "a00e0d9c84fcdbb3bd95f296cb3422d60b86dcff4c40734eea1bb0bec4c7d902", + "manifestSha256": "7ff2127ca940e41be90ba45204c89089a7a2093567b9bff581a9549b57138626", "arches": [ "wasm32" ], "cacheKeys": { - "wasm32": "92097da44dd8a57901cf771311b84d1bb395996c9b9f8abd5c144fc042a4ae40" + "wasm32": "816074a21b019d5a52c7a676123b93ebbb0e841cab60a3c7553773520284733f" }, "dependencyClosures": { "wasm32": [] @@ -891,7 +919,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "7860fb07d0c2e45be9c06246b3545f408e3f5e18f3f721a052e84ab6fd5aab55" + "wasm32": "55d93c1fd59d890d6f78346197a6d474c654fc2a6687e1ff6e1ffb1ae09ce377" }, "dependencyClosures": { "wasm32": [] @@ -919,7 +947,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f8bd60aa473359e98a1138a6329a8113f0d2e867fa427bb81d3cf8089ed1c5d5" + "wasm32": "b866449fe86205186c0537b6992df7747c852f0e07fde1da6f9d0a7b5808b152" }, "dependencyClosures": { "wasm32": [] @@ -947,7 +975,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e77e891fee5d2107b8326a0dd97f184d3d79faf0d87733c11514bd8889e9d30f" + "wasm32": "13a6253127ab0250d8dc39d51979aae88a0004cb5a268493cd332eaf564546d6" }, "dependencyClosures": { "wasm32": [] @@ -968,7 +996,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b42be77b20c056922c3f8af6c8b40d84ec0e3b24eb9165f46dd7280f5e9d6de0" + "wasm32": "d96bb46b733a7cfb83b17b6d53f36c35b1617a75cb6c5b929237d35bff327b31" }, "dependencyClosures": { "wasm32": [] @@ -996,7 +1024,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a4ecf6c6a921fb6a7b6cf2e305637df1d6a23e86a3f05c8badcb26f6a9267a56" + "wasm32": "e75c15a6f6114b40248b6de7c3057b99b108dde867cda73c111e47c3acbb326f" }, "dependencyClosures": { "wasm32": [] @@ -1017,7 +1045,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0f67d1d73c59bdae42cbbdb487e328a356909b7ff6b1316bb84824c62cce245e" + "wasm32": "0332b476d7ec3d6fcebec49aadf3facae32a69fc004cebfc6e5c7f3a195561b0" }, "dependencyClosures": { "wasm32": [] @@ -1038,7 +1066,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "9893ff14c81802d32de5f313e79c232c45e803f6e1eee3b751fe128b952cfe81" + "wasm32": "ef0092c0b03589468a00976d8e5167f4a0a18260b8b61b2f7136263d480deaac" }, "dependencyClosures": { "wasm32": [] @@ -1066,14 +1094,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "58a80626efbbee0e7271150ac3cd20cb54562bd9f2c96b2e5091e46a25b2306b" + "wasm32": "ce884b1760130a2cb478fbe03f5ec2f407339e5631bebc516daf97a7d80fe096" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" + "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" } ] }, @@ -1093,64 +1121,64 @@ "wasm32" ], "cacheKeys": { - "wasm32": "77db0a7aaa0a78130e2689a24ac6250a67d358bc65cd1e2919ce95c5ca2e20ea" + "wasm32": "9eafe9e962c351bcfe4fceaa2fbace1ad8b9c9c4a74b46fa2d6a80e7e4f6c3d9" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "afabec4ac51bd35f27451be1ab27dbcd29abe2bdc93ee24d7ba39faf65c583c9" + "cacheKey": "543e082567cc0502609bf0cdef6eb1ba431cef97d798136e739c915f8392615a" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "ed82530ca56a059fcb3ded9e7eba665878dd476a4e15fb0c67ec3fbab88ec961" + "cacheKey": "81677f4b7e2d40634555388d8d06757a052efcbb915bef1c6e57d444137848cd" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "08ad5e096eb7909993a2aa2d75a3fc0282b5112818f9de92d7a21b9db3f7312b" + "cacheKey": "6ce2dd7a19b7f5af78edc02fc75d08bbeade7f41e968f8b8a3f6e7e5fe4b26ae" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" + "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "8289b6195a813388b7673810c77749e055904b6ce1f80872c87f617e7c9fe26f" + "cacheKey": "8b132a257b9a02a3adccac9d5d6bedc00348a6ac92375401761312d7c46a2ecf" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "a16f2e59186fef56229883d91bd5b09c9fd24ec96137287ecec4fba963500822" + "cacheKey": "4873ada42645826762560ad1833364a60c630c6411f84a03456b171f279faf2b" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "0f052ffc6c568fec083752495f6245511974745f30743d961cbed6296da68f32" + "cacheKey": "32c0e36842b5f47e79c66b925601f3f30c4ac982303cd159ade0b6c5755e5b61" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "a337766281607a68326c2c51b7b54e3a4bd1a71a0a1ea81afa52be09c1e0b2be" + "cacheKey": "a6fc124a4bc7562dd038f5e616c914837fc5882cd90f0199d469c0259d0a8b4c" }, { "packageName": "msmtpd", "manifestSha256": "09de04a422ddf631a29a259d816e081f8d317d1077808ede55d8c500baae748f", - "cacheKey": "382f58897a42df23508ddc227f96d3229141399a510a350647f0ce7cc14e81ac" + "cacheKey": "554ed9b04dcac3ed56656590be2baadc303cd9494f8742fcd6ab3e9323faf62a" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "313a503d66a7a8ad435c9aee08a4b44f332d84197caa6bc43e5d84336e3228ec" + "cacheKey": "a477b0911328bdd36d79e2339ccad2b67d23c69589fd622af20d8afa5af77df4" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "23b54aad82b4105ad8f5ea2b66e5c15f30d69455dc4cd28a92a936e87e60914b" + "cacheKey": "005e9cd3956f26bf2c6e97610ed4c0ca4ee21eaa0d4e59a710f861a78bb7020d" }, { "packageName": "pcre2-source", @@ -1160,22 +1188,22 @@ { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "c069c011716dd51e586649af498a77fe7f9623c3f87d94517d504d65d59fc7ba" + "cacheKey": "98ecceaf50e4b0dbc10292b49b3c8ee2009e6ba965449123851818c123b9427b" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "a017aa5de279b7f77c0f283db5fb4f89e43e495da413b4634776574e4040e71a" + "cacheKey": "924040509cd02cedd551f4afa6568090fcd0bed488d44674c0c1802d33b3e6a4" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "5b3fda5e074c97b803e622d48b5ab3e87b43230d3f68d668296fb64a27f1568b" + "cacheKey": "6e44f4ec127c258c4185142295492ab790cca757db35a284762a95668e0862e9" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2" + "cacheKey": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b" } ] }, @@ -1195,7 +1223,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "bbae9262108e82a170c06628207705bb64d55794cdc97969b71e1f23c4c1da98" + "wasm32": "180f11bd766f6c2482eb68dba1803d81bcfedc79ff0ff40a4fba1d11219d6dd9" }, "dependencyClosures": { "wasm32": [] @@ -1216,7 +1244,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "80a6104542b21e23812feb4376257da964060f48f89b33ae23f0f5e4b02ea7de" + "wasm32": "6f7cbc2839c96bdc4569c4845fd0c4671e7596cfd36483deb4e4a05e5d48bccb" }, "dependencyClosures": { "wasm32": [] @@ -1237,7 +1265,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b1f9a8a551e8240c6ceff2fd93866f1a459f2de4d35f7e2e796fafc1decf414f" + "wasm32": "8eb7d929c608eecfe55cffe87513a1d8b7b58c3dbe80f350ab9e0eb9ad7416a8" }, "dependencyClosures": { "wasm32": [] @@ -1258,7 +1286,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "7ffa37065290a8237c7c1aa53d14b19dd993f736628ec2f03372959d57f793e1" + "wasm32": "dc7fce3e4dd128bbdd6a89356224db601bebd13df9c63548f5ab969a250dfcc5" }, "dependencyClosures": { "wasm32": [] @@ -1280,15 +1308,15 @@ "wasm64" ], "cacheKeys": { - "wasm32": "a337766281607a68326c2c51b7b54e3a4bd1a71a0a1ea81afa52be09c1e0b2be", - "wasm64": "3a4f8b9c13bd52dc2db0f6945ba0ebf78d28806cf9e7b773e8224e2b7c0beb1f" + "wasm32": "a6fc124a4bc7562dd038f5e616c914837fc5882cd90f0199d469c0259d0a8b4c", + "wasm64": "8dca7f977ae361b01d67d1af6fa7e9847bb9499fd2fa746283dee757caf17837" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" + "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" }, { "packageName": "pcre2-source", @@ -1300,7 +1328,7 @@ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "e6304f31d7a30e10501b57a82aae97e1bb5f00a00dd8196e9672ffe5b4704d6b" + "cacheKey": "06a4c50ff62d8d2e638f341cf8ec0b1837b24a3d602a437759f0c2d0a8396821" }, { "packageName": "pcre2-source", @@ -1332,34 +1360,34 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e5bffb7916977e035ce9cc59fe417aebe8b17f6df633a4309eb42015d45fac1a" + "wasm32": "1a48ab9b3bb0e378eef2d34d5dcab2084db47cac9da6e63ea77044f8fab184bf" }, "dependencyClosures": { "wasm32": [ { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "88e8aec625b11e1cf53112017c6b12206c3fb5d4c495bea02297ae4c3a45db1f" + "cacheKey": "4583435763207ddfd63478fc7b3a263fdc6fae110bcd38109e1e1c26e621ad7f" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "292795420bea85f6f35af3a2399696e790b7ae22af2385c440e79751d5e8e9ad" + "cacheKey": "c4b4345aa3f9011d0b214ad6adaa6c7fbe5eb2f6bbd0e5181b8f4be68f437bf3" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "afabec4ac51bd35f27451be1ab27dbcd29abe2bdc93ee24d7ba39faf65c583c9" + "cacheKey": "543e082567cc0502609bf0cdef6eb1ba431cef97d798136e739c915f8392615a" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" + "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "a337766281607a68326c2c51b7b54e3a4bd1a71a0a1ea81afa52be09c1e0b2be" + "cacheKey": "a6fc124a4bc7562dd038f5e616c914837fc5882cd90f0199d469c0259d0a8b4c" }, { "packageName": "pcre2-source", @@ -1385,35 +1413,35 @@ "wasm64" ], "cacheKeys": { - "wasm32": "cdef3e378496ba28675f3810196078013dcf9bc50e440cb4aa95695866b5bdec", - "wasm64": "c006ddaad6e3eed8106d674e50db8906eb42aa267acaade1b7b07c0fde2c57ae" + "wasm32": "5cf6491aead72e3a179b3a052059d91022f88614a297a95cff25373742397bb7", + "wasm64": "d3797f03263fe43e7766405c957c37e15c877912546353f3f4d19913db9bc07b" }, "dependencyClosures": { "wasm32": [ { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "88e8aec625b11e1cf53112017c6b12206c3fb5d4c495bea02297ae4c3a45db1f" + "cacheKey": "4583435763207ddfd63478fc7b3a263fdc6fae110bcd38109e1e1c26e621ad7f" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "292795420bea85f6f35af3a2399696e790b7ae22af2385c440e79751d5e8e9ad" + "cacheKey": "c4b4345aa3f9011d0b214ad6adaa6c7fbe5eb2f6bbd0e5181b8f4be68f437bf3" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "afabec4ac51bd35f27451be1ab27dbcd29abe2bdc93ee24d7ba39faf65c583c9" + "cacheKey": "543e082567cc0502609bf0cdef6eb1ba431cef97d798136e739c915f8392615a" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" + "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "a337766281607a68326c2c51b7b54e3a4bd1a71a0a1ea81afa52be09c1e0b2be" + "cacheKey": "a6fc124a4bc7562dd038f5e616c914837fc5882cd90f0199d469c0259d0a8b4c" }, { "packageName": "pcre2-source", @@ -1425,27 +1453,27 @@ { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "44ff689fd0db89fcec6c8006e243c7eef825775f93c054b2c116a31f49584088" + "cacheKey": "74f816ee53c308c9e94e737275bdb42a71ffd75b5331422f8c106333b7609dc5" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "9de9e85d91e122625a02db27626c24ce1051077fd908bc6545177814c7a78d0e" + "cacheKey": "f37e09c31002de4be5c25d4f789b81e2ced708016032756a6cafe010a4537811" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "324b974aed89280168647e7c5cd771468d9ae5cc5878944539eed5af2b7e4a33" + "cacheKey": "872d4ca7596d100e781612e7e659bc840c74fdab8c0769746c48ff1d5ec478fb" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "e6304f31d7a30e10501b57a82aae97e1bb5f00a00dd8196e9672ffe5b4704d6b" + "cacheKey": "06a4c50ff62d8d2e638f341cf8ec0b1837b24a3d602a437759f0c2d0a8396821" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "3a4f8b9c13bd52dc2db0f6945ba0ebf78d28806cf9e7b773e8224e2b7c0beb1f" + "cacheKey": "8dca7f977ae361b01d67d1af6fa7e9847bb9499fd2fa746283dee757caf17837" }, { "packageName": "pcre2-source", @@ -1470,7 +1498,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "965d4fca032e68491635b0aebe3498afa07a564d0525877142ac53170adf5193" + "wasm32": "1d1f953607dc5796583d5284761f37e241bc860d9d874baa4eeb1493b908df6d" }, "dependencyClosures": { "wasm32": [] @@ -1491,7 +1519,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "382f58897a42df23508ddc227f96d3229141399a510a350647f0ce7cc14e81ac" + "wasm32": "554ed9b04dcac3ed56656590be2baadc303cd9494f8742fcd6ab3e9323faf62a" }, "dependencyClosures": { "wasm32": [] @@ -1512,7 +1540,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "40f555a2b949327f54a4d779f19a955d7d3c9e7f76c8e21d090720bda5426e9d" + "wasm32": "25fe208fc4bfc8234d5145b76ddcdded2a1a17f16ff74886582a1c99b36807b2" }, "dependencyClosures": { "wasm32": [] @@ -1533,7 +1561,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "70871736d22780051a9fcd7aa3b7fb9064f75d524d87d47ed3e1653eba5b2651" + "wasm32": "cf48512ef35d9852f274d38266ba81c4eb39033027e1628712bd4318557a1b79" }, "dependencyClosures": { "wasm32": [] @@ -1617,7 +1645,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e1d8c6bbe0bcee8911bf65f50ffd3db7b2a2c4cc06b012b688e2a490b717701d" + "wasm32": "baea699ba5ffc02066335a2eba35fab1f0208a111c40a67bcbc7841a7a83b543" }, "dependencyClosures": { "wasm32": [] @@ -1638,14 +1666,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "4a3754f3c1f16793ee5887577fd45d23eb9ae77c47bdc1d2f3985ed687003e3a" + "wasm32": "8b16339b4630bf4fabc8507793973453fc8dc0d3e82db9f16f513465c066659b" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "70871736d22780051a9fcd7aa3b7fb9064f75d524d87d47ed3e1653eba5b2651" + "cacheKey": "cf48512ef35d9852f274d38266ba81c4eb39033027e1628712bd4318557a1b79" } ] }, @@ -1665,19 +1693,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e549fa92d9ebd9c1bc8f42dbb3a7fc8a189142046ae339d17db38eee0f41bb93" + "wasm32": "59ab4de06527df89271655257375163b3d571099fa077e419c967b4f653d732c" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "70871736d22780051a9fcd7aa3b7fb9064f75d524d87d47ed3e1653eba5b2651" + "cacheKey": "cf48512ef35d9852f274d38266ba81c4eb39033027e1628712bd4318557a1b79" }, { "packageName": "nethack", "manifestSha256": "1a2f12ec2770bd8d40006d6c878a3493b97c71c2bb63ad08c7f6ad99bfd53504", - "cacheKey": "4a3754f3c1f16793ee5887577fd45d23eb9ae77c47bdc1d2f3985ed687003e3a" + "cacheKey": "8b16339b4630bf4fabc8507793973453fc8dc0d3e82db9f16f513465c066659b" } ] }, @@ -1697,7 +1725,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "313a503d66a7a8ad435c9aee08a4b44f332d84197caa6bc43e5d84336e3228ec" + "wasm32": "a477b0911328bdd36d79e2339ccad2b67d23c69589fd622af20d8afa5af77df4" }, "dependencyClosures": { "wasm32": [] @@ -1718,79 +1746,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "86f5a03d7a0e391e5e4b2ca4aef89768a5c5c34c69adb990ee9a9b4e6b70eb03" + "wasm32": "79e5ef3f9123c515d69600d9293dfdba3394b72aabc39cd4a346e5163365f913" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "afabec4ac51bd35f27451be1ab27dbcd29abe2bdc93ee24d7ba39faf65c583c9" + "cacheKey": "543e082567cc0502609bf0cdef6eb1ba431cef97d798136e739c915f8392615a" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "ed82530ca56a059fcb3ded9e7eba665878dd476a4e15fb0c67ec3fbab88ec961" + "cacheKey": "81677f4b7e2d40634555388d8d06757a052efcbb915bef1c6e57d444137848cd" }, { "packageName": "kernel", "manifestSha256": "db1d66db8575562ac7b3e720dbaa06d7dd5eef577d80220ea4167dc2d69b863b", - "cacheKey": "223146b18c49321399b8008bdac9397c9af91f4bc3f1b60a936a8684bd9f233b" + "cacheKey": "1272ecb067f8de873dec513e588c1f1c84186237ecdf69dd93fdb6849d8dea50" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "08ad5e096eb7909993a2aa2d75a3fc0282b5112818f9de92d7a21b9db3f7312b" + "cacheKey": "6ce2dd7a19b7f5af78edc02fc75d08bbeade7f41e968f8b8a3f6e7e5fe4b26ae" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" + "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "8289b6195a813388b7673810c77749e055904b6ce1f80872c87f617e7c9fe26f" + "cacheKey": "8b132a257b9a02a3adccac9d5d6bedc00348a6ac92375401761312d7c46a2ecf" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "a16f2e59186fef56229883d91bd5b09c9fd24ec96137287ecec4fba963500822" + "cacheKey": "4873ada42645826762560ad1833364a60c630c6411f84a03456b171f279faf2b" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "0f052ffc6c568fec083752495f6245511974745f30743d961cbed6296da68f32" + "cacheKey": "32c0e36842b5f47e79c66b925601f3f30c4ac982303cd159ade0b6c5755e5b61" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "313a503d66a7a8ad435c9aee08a4b44f332d84197caa6bc43e5d84336e3228ec" + "cacheKey": "a477b0911328bdd36d79e2339ccad2b67d23c69589fd622af20d8afa5af77df4" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "23b54aad82b4105ad8f5ea2b66e5c15f30d69455dc4cd28a92a936e87e60914b" + "cacheKey": "005e9cd3956f26bf2c6e97610ed4c0ca4ee21eaa0d4e59a710f861a78bb7020d" }, { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "c069c011716dd51e586649af498a77fe7f9623c3f87d94517d504d65d59fc7ba" + "cacheKey": "98ecceaf50e4b0dbc10292b49b3c8ee2009e6ba965449123851818c123b9427b" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "a017aa5de279b7f77c0f283db5fb4f89e43e495da413b4634776574e4040e71a" + "cacheKey": "924040509cd02cedd551f4afa6568090fcd0bed488d44674c0c1802d33b3e6a4" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "5b3fda5e074c97b803e622d48b5ab3e87b43230d3f68d668296fb64a27f1568b" + "cacheKey": "6e44f4ec127c258c4185142295492ab790cca757db35a284762a95668e0862e9" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2" + "cacheKey": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b" } ] }, @@ -1810,29 +1838,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "efa2faa3f5213e40ab5de3d87cd5398cfb8823c6ab8a43e614462360e209a4c5" + "wasm32": "ed0e8b6e71d96ca9ddd55e19983691efc56e50ba975e19147b2f4feef8067eee" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "afabec4ac51bd35f27451be1ab27dbcd29abe2bdc93ee24d7ba39faf65c583c9" + "cacheKey": "543e082567cc0502609bf0cdef6eb1ba431cef97d798136e739c915f8392615a" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" + "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "313a503d66a7a8ad435c9aee08a4b44f332d84197caa6bc43e5d84336e3228ec" + "cacheKey": "a477b0911328bdd36d79e2339ccad2b67d23c69589fd622af20d8afa5af77df4" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "a017aa5de279b7f77c0f283db5fb4f89e43e495da413b4634776574e4040e71a" + "cacheKey": "924040509cd02cedd551f4afa6568090fcd0bed488d44674c0c1802d33b3e6a4" } ] }, @@ -1852,29 +1880,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2aced04cced76dd3005469ac293751a4994e81de403d3fd06e102f8aa980f35f" + "wasm32": "031b17aaa70fd9ad18f6b3414562564f33e88c53caa6dd71b9e85c2f2d326d4c" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" + "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "23b54aad82b4105ad8f5ea2b66e5c15f30d69455dc4cd28a92a936e87e60914b" + "cacheKey": "005e9cd3956f26bf2c6e97610ed4c0ca4ee21eaa0d4e59a710f861a78bb7020d" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "bb965948ea0c564fe96b832a9c819003eb7f0d6c3a9957e9c2e52845c9123f6e" + "cacheKey": "997c17703209773768eee15f64449151904b0f89824ebec9649db902126a9573" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2" + "cacheKey": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b" } ] }, @@ -1894,39 +1922,39 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b7bf43e041330e9b3759de3a4f3fb8ceb6408afdc19983004a011c320d01161d" + "wasm32": "705745bad6d92e50cddfc43b985d1095eee4d9069756ce75f65afdaa12b91e0b" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" + "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" }, { "packageName": "node", "manifestSha256": "2131a24dfda8587860e57fc65b573086477d376b065b3b9ca4e1dfe4d79f5790", - "cacheKey": "2aced04cced76dd3005469ac293751a4994e81de403d3fd06e102f8aa980f35f" + "cacheKey": "031b17aaa70fd9ad18f6b3414562564f33e88c53caa6dd71b9e85c2f2d326d4c" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "23b54aad82b4105ad8f5ea2b66e5c15f30d69455dc4cd28a92a936e87e60914b" + "cacheKey": "005e9cd3956f26bf2c6e97610ed4c0ca4ee21eaa0d4e59a710f861a78bb7020d" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "a017aa5de279b7f77c0f283db5fb4f89e43e495da413b4634776574e4040e71a" + "cacheKey": "924040509cd02cedd551f4afa6568090fcd0bed488d44674c0c1802d33b3e6a4" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "bb965948ea0c564fe96b832a9c819003eb7f0d6c3a9957e9c2e52845c9123f6e" + "cacheKey": "997c17703209773768eee15f64449151904b0f89824ebec9649db902126a9573" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2" + "cacheKey": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b" } ] }, @@ -1946,7 +1974,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f7862f341b5ccecda88730fc4ee4af950e7e488113b4701be0268c80f67e13a0" + "wasm32": "da7287e1ed6d2f9f39a2cc049a65f5fc76c00959ce7f8b9402f1a22ff8510276" }, "dependencyClosures": { "wasm32": [] @@ -1967,14 +1995,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "1ede74eb16abfadc77060fb09c60ba8e84ce2e10bb07e2fcf9531a5b84d77922" + "wasm32": "0a9e90fbfc9c3b971ad2b035752019674b5a15467737871015887a4787384ab8" }, "dependencyClosures": { "wasm32": [ { "packageName": "perl", "manifestSha256": "6cdc4dbc54d0e4008cff41f82ec8918c0e44b4aef23903539c1bc0f538fe3ad2", - "cacheKey": "f7862f341b5ccecda88730fc4ee4af950e7e488113b4701be0268c80f67e13a0" + "cacheKey": "da7287e1ed6d2f9f39a2cc049a65f5fc76c00959ce7f8b9402f1a22ff8510276" } ] }, @@ -1994,54 +2022,54 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c069c011716dd51e586649af498a77fe7f9623c3f87d94517d504d65d59fc7ba" + "wasm32": "98ecceaf50e4b0dbc10292b49b3c8ee2009e6ba965449123851818c123b9427b" }, "dependencyClosures": { "wasm32": [ { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "ed82530ca56a059fcb3ded9e7eba665878dd476a4e15fb0c67ec3fbab88ec961" + "cacheKey": "81677f4b7e2d40634555388d8d06757a052efcbb915bef1c6e57d444137848cd" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "08ad5e096eb7909993a2aa2d75a3fc0282b5112818f9de92d7a21b9db3f7312b" + "cacheKey": "6ce2dd7a19b7f5af78edc02fc75d08bbeade7f41e968f8b8a3f6e7e5fe4b26ae" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" + "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "8289b6195a813388b7673810c77749e055904b6ce1f80872c87f617e7c9fe26f" + "cacheKey": "8b132a257b9a02a3adccac9d5d6bedc00348a6ac92375401761312d7c46a2ecf" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "a16f2e59186fef56229883d91bd5b09c9fd24ec96137287ecec4fba963500822" + "cacheKey": "4873ada42645826762560ad1833364a60c630c6411f84a03456b171f279faf2b" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "0f052ffc6c568fec083752495f6245511974745f30743d961cbed6296da68f32" + "cacheKey": "32c0e36842b5f47e79c66b925601f3f30c4ac982303cd159ade0b6c5755e5b61" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "23b54aad82b4105ad8f5ea2b66e5c15f30d69455dc4cd28a92a936e87e60914b" + "cacheKey": "005e9cd3956f26bf2c6e97610ed4c0ca4ee21eaa0d4e59a710f861a78bb7020d" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "5b3fda5e074c97b803e622d48b5ab3e87b43230d3f68d668296fb64a27f1568b" + "cacheKey": "6e44f4ec127c258c4185142295492ab790cca757db35a284762a95668e0862e9" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2" + "cacheKey": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b" } ] }, @@ -2117,7 +2145,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "cdb66e1b49ac036686b638f39724ec85da465e9eb43e2328d4ccc043a76f8d39" + "wasm32": "a195d1db14bc727860c53d0cd87843aa52a3842fad036180c75c6e19173b7624" }, "dependencyClosures": { "wasm32": [] @@ -2390,19 +2418,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a375f2d91505ab06716eb34d7283c0e7ff89725c46b8e0d2b49aa127c7af45bb" + "wasm32": "a2382e09282fc05f23e94a83fbdcb0595a094ce09d7a93f9fa8e2ae1bb6b006f" }, "dependencyClosures": { "wasm32": [ { "packageName": "cpython", "manifestSha256": "7dd4f446697a73941ec940c2ccba4d53be73fe6947f1e701031a4d0aa964c4ff", - "cacheKey": "a0ef6affbcfb19fc83326cb35501026e63b93f7849329d6ce190cf197eddce41" + "cacheKey": "a2e894f110205a0cf42bc02a56a31156a82fad0c291595a508bdb7bd09e7a6c7" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2" + "cacheKey": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b" } ] }, @@ -2422,7 +2450,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "53d782b84d5df6927c247fd042ab1099c8d70ac38056b6f16d3c81839626c478" + "wasm32": "fdb4a0416b46db4d5999696fe921a12e434b951312e29530d8b03dda03152eb7" }, "dependencyClosures": { "wasm32": [] @@ -2450,24 +2478,24 @@ "wasm32" ], "cacheKeys": { - "wasm32": "535dc5d20e12a3f58533c644abc83b53a9e9bf4e2daea2a288910a3b24d909e3" + "wasm32": "4b1a029a8da49d4b8ca43d63de06b10331ecdba4cfc1a16ed8a225824b54ce0f" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "afabec4ac51bd35f27451be1ab27dbcd29abe2bdc93ee24d7ba39faf65c583c9" + "cacheKey": "543e082567cc0502609bf0cdef6eb1ba431cef97d798136e739c915f8392615a" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" + "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" }, { "packageName": "redis", "manifestSha256": "151fb507de953ba2ba94b8f881bc7d66b4c741c1359a2f590cc07f9a4cd368a3", - "cacheKey": "53d782b84d5df6927c247fd042ab1099c8d70ac38056b6f16d3c81839626c478" + "cacheKey": "fdb4a0416b46db4d5999696fe921a12e434b951312e29530d8b03dda03152eb7" } ] }, @@ -2487,79 +2515,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "7f5e181f3f49fd31c5d6d5a51ee6da8b411760ad6d3bceef6388aeb8a0f3fbb6" + "wasm32": "639d2ea627f402d23bde67e0c370130ec1dac0ddea55af87b01b540a81072c34" }, "dependencyClosures": { "wasm32": [ { "packageName": "bash", "manifestSha256": "6478060f28d430d18a6ebe7c603392d35a41d9bcf6bc74322351d1450b9c5335", - "cacheKey": "3bb0b25c26be9fa2fac163fb9f5853958cb1a67d7a6bc6249b2af9e49ba5a63b" + "cacheKey": "f72620b103f7606cb3c9e2550ea68dc7c35c6bfbc69b217bb10d906901ec1c6c" }, { "packageName": "bc", "manifestSha256": "a65661463bb7047b91ff00153bd99fd962c96e571934ab4e923f5215fb6ecfbd", - "cacheKey": "b088a88d58385a6cadd463fbbb94944c52790205c08bdf463fbfd5e496fa430e" + "cacheKey": "43001c22570d4972d6143a04c7ba9103fc35a735c15070978f331353e1ed0108" }, { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "88e8aec625b11e1cf53112017c6b12206c3fb5d4c495bea02297ae4c3a45db1f" + "cacheKey": "4583435763207ddfd63478fc7b3a263fdc6fae110bcd38109e1e1c26e621ad7f" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "292795420bea85f6f35af3a2399696e790b7ae22af2385c440e79751d5e8e9ad" + "cacheKey": "c4b4345aa3f9011d0b214ad6adaa6c7fbe5eb2f6bbd0e5181b8f4be68f437bf3" }, { "packageName": "diffutils", "manifestSha256": "3a78f0a46bae43ce5ea235c6638c2cbd1d8559b0b62b1fb42443b35968c1aca3", - "cacheKey": "0cfbd31c5b125cdc4ed2a0d4116ea69c94cd5eb3dabff3045b7db3b21feaec30" + "cacheKey": "d48b0feda4af14e2d08a803611d0b825ac51e9a9379bdb8b49e1170eaf75e5d0" }, { "packageName": "file", "manifestSha256": "7874c1affcbbf2c8ab8c8d4beb087f275af57b5caae6a8947bf5a63a181552b2", - "cacheKey": "7860fb07d0c2e45be9c06246b3545f408e3f5e18f3f721a052e84ab6fd5aab55" + "cacheKey": "55d93c1fd59d890d6f78346197a6d474c654fc2a6687e1ff6e1ffb1ae09ce377" }, { "packageName": "findutils", "manifestSha256": "cceedf52aea67fbb0da03cfce6f2e9b1a7b5561fa1656c2762b43c651a625d02", - "cacheKey": "f8bd60aa473359e98a1138a6329a8113f0d2e867fa427bb81d3cf8089ed1c5d5" + "cacheKey": "b866449fe86205186c0537b6992df7747c852f0e07fde1da6f9d0a7b5808b152" }, { "packageName": "gawk", "manifestSha256": "a2567b8b0778805e385e1d3a41ac8b5d74aaf9d293b222001b17d09b11e5a0ba", - "cacheKey": "e77e891fee5d2107b8326a0dd97f184d3d79faf0d87733c11514bd8889e9d30f" + "cacheKey": "13a6253127ab0250d8dc39d51979aae88a0004cb5a268493cd332eaf564546d6" }, { "packageName": "grep", "manifestSha256": "59270d3bfb33167b32d13246bf855b49308f8c9c0a66d9635c804f702421fbc6", - "cacheKey": "a4ecf6c6a921fb6a7b6cf2e305637df1d6a23e86a3f05c8badcb26f6a9267a56" + "cacheKey": "e75c15a6f6114b40248b6de7c3057b99b108dde867cda73c111e47c3acbb326f" }, { "packageName": "m4", "manifestSha256": "2c6582d99d6eabfb9da52badf49b899e9fdcba76a728536b25bc3741f3331543", - "cacheKey": "b1f9a8a551e8240c6ceff2fd93866f1a459f2de4d35f7e2e796fafc1decf414f" + "cacheKey": "8eb7d929c608eecfe55cffe87513a1d8b7b58c3dbe80f350ab9e0eb9ad7416a8" }, { "packageName": "make", "manifestSha256": "f878d2d730f36a4c6dfe1fff1b4ccc704757ea22d8c4c8ccb95b11cd641e5fed", - "cacheKey": "7ffa37065290a8237c7c1aa53d14b19dd993f736628ec2f03372959d57f793e1" + "cacheKey": "dc7fce3e4dd128bbdd6a89356224db601bebd13df9c63548f5ab969a250dfcc5" }, { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "70871736d22780051a9fcd7aa3b7fb9064f75d524d87d47ed3e1653eba5b2651" + "cacheKey": "cf48512ef35d9852f274d38266ba81c4eb39033027e1628712bd4318557a1b79" }, { "packageName": "posix-utils-lite", "manifestSha256": "8fd7190b2848ef80143adc7b9268c79e13cd4db3430f7105faf49a4b4407a06f", - "cacheKey": "cdb66e1b49ac036686b638f39724ec85da465e9eb43e2328d4ccc043a76f8d39" + "cacheKey": "a195d1db14bc727860c53d0cd87843aa52a3842fad036180c75c6e19173b7624" }, { "packageName": "sed", "manifestSha256": "2a08e9c5dacc5facc8983c1ff35ff2a72db328405e9a1f464cc7ad155c4d08af", - "cacheKey": "e3c2e6258fc5d07bb75d188f2efc391db5c1f2c5474e80ff528075cdda67ae85" + "cacheKey": "2d8f0e8bafe76521572934b36ab8fc1eb327eac5492c1d96d18fee3b1df926a3" } ] }, @@ -2579,14 +2607,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0f83557241b795469469b3b9547fdfd1f2b67b45cd35e3cef4aef5d3a30e8786" + "wasm32": "9c89b8e95447c8d17c4ca6c06b712f46b7fa9c4812bbcb637aa44b913092e80d" }, "dependencyClosures": { "wasm32": [ { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2" + "cacheKey": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b" } ] }, @@ -2607,13 +2635,79 @@ } ] }, + "sdl-dsp-test": { + "manifestSha256": "a988bef0b27403846a675965d951a286245fa79e84c209657d70a9a1200e8037", + "arches": [ + "wasm32" + ], + "cacheKeys": { + "wasm32": "422c6bfdf0bac96e006cd7d0f130cd1a4857be5b124a64965ede999f611c8542" + }, + "dependencyClosures": { + "wasm32": [ + { + "packageName": "sdl2", + "manifestSha256": "327ce0acca768f51e36ddab8ab58fd57b24cfd9e40722e818103c439f7263dd7", + "cacheKey": "9cc6d3328ea4fb6848530e0e9a8cb5c30a8bd03a356fda29d6b46a91e37a58e8" + }, + { + "packageName": "sdl3", + "manifestSha256": "3b7c64605b941e14d336b1a127d8219c50402dece009e8855297a0a0696bd67e", + "cacheKey": "93bdeb1ecdb6e8d5e9e4bc0d14969ab3b4b6ece04481ef464f9050d08ef4036e" + } + ] + }, + "members": [ + { + "kind": "output", + "sourceArtifact": "sdl2-dsp-test.wasm", + "mirrorPath": "sdl-dsp-test/sdl2-dsp-test.wasm", + "outputName": "sdl2-dsp-test", + "forkInstrumentation": "auto" + }, + { + "kind": "output", + "sourceArtifact": "sdl3-dsp-test.wasm", + "mirrorPath": "sdl-dsp-test/sdl3-dsp-test.wasm", + "outputName": "sdl3-dsp-test", + "forkInstrumentation": "auto" + } + ] + }, + "sdl2-mixer-playwave": { + "manifestSha256": "5ff3863e9f83cb9ad62931e067ee6e417826e06391862d9d0a58d6cc6b4dc570", + "arches": [ + "wasm32" + ], + "cacheKeys": { + "wasm32": "d9dbaaebde392a70f307fb54bdf7cbe069c42661f7b04cad8e9c58dfe89cbc1d" + }, + "dependencyClosures": { + "wasm32": [ + { + "packageName": "sdl2", + "manifestSha256": "327ce0acca768f51e36ddab8ab58fd57b24cfd9e40722e818103c439f7263dd7", + "cacheKey": "9cc6d3328ea4fb6848530e0e9a8cb5c30a8bd03a356fda29d6b46a91e37a58e8" + } + ] + }, + "members": [ + { + "kind": "output", + "sourceArtifact": "playwave.wasm", + "mirrorPath": "playwave.wasm", + "outputName": "playwave", + "forkInstrumentation": "auto" + } + ] + }, "sed": { "manifestSha256": "0c0d82d53907c19825941501f833252cf9adf35ebcebf6786baae28e5eb6958b", "arches": [ "wasm32" ], "cacheKeys": { - "wasm32": "e3c2e6258fc5d07bb75d188f2efc391db5c1f2c5474e80ff528075cdda67ae85" + "wasm32": "2d8f0e8bafe76521572934b36ab8fc1eb327eac5492c1d96d18fee3b1df926a3" }, "dependencyClosures": { "wasm32": [] @@ -2634,7 +2728,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a017aa5de279b7f77c0f283db5fb4f89e43e495da413b4634776574e4040e71a" + "wasm32": "924040509cd02cedd551f4afa6568090fcd0bed488d44674c0c1802d33b3e6a4" }, "dependencyClosures": { "wasm32": [] @@ -2655,24 +2749,24 @@ "wasm32" ], "cacheKeys": { - "wasm32": "bb965948ea0c564fe96b832a9c819003eb7f0d6c3a9957e9c2e52845c9123f6e" + "wasm32": "997c17703209773768eee15f64449151904b0f89824ebec9649db902126a9573" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" + "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "23b54aad82b4105ad8f5ea2b66e5c15f30d69455dc4cd28a92a936e87e60914b" + "cacheKey": "005e9cd3956f26bf2c6e97610ed4c0ca4ee21eaa0d4e59a710f861a78bb7020d" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2" + "cacheKey": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b" } ] }, @@ -2692,29 +2786,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "137fb610878789d6a3fa9cf4c7dea22fca50171bde6bfcec426a47622e4af83d" + "wasm32": "ffc7eecad0fb134fb01e8494621a16206d0b698828afe6c1c67cbdbbcfefbd7a" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" + "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "23b54aad82b4105ad8f5ea2b66e5c15f30d69455dc4cd28a92a936e87e60914b" + "cacheKey": "005e9cd3956f26bf2c6e97610ed4c0ca4ee21eaa0d4e59a710f861a78bb7020d" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "bb965948ea0c564fe96b832a9c819003eb7f0d6c3a9957e9c2e52845c9123f6e" + "cacheKey": "997c17703209773768eee15f64449151904b0f89824ebec9649db902126a9573" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2" + "cacheKey": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b" } ] }, @@ -2734,7 +2828,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f5dae689e9c9de8c8ad4fd72f90ee8b4eefd4b10fb2e19478451068b75f565ee" + "wasm32": "8203b8f73e96deeefbe72c5eb9342cbd62b03fc31a4f3906cfbd4452b05c4c38" }, "dependencyClosures": { "wasm32": [] @@ -2755,7 +2849,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "41dbda340b968a687157d02ec36f3a93c04dc62adcf406e5b388465966bcdefa" + "wasm32": "e0e058c4333c4c94b7ed1fb0da398e3e874998c8bf1b3249f84417047a312db7" }, "dependencyClosures": { "wasm32": [] @@ -2776,7 +2870,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "48d99083556ccfb2c63736771c11b92e462a40b12d3850cb3ea26aae33826beb" + "wasm32": "fe826385f0bd64f30c93c32bb93cbbb8948a74cf83fd137596c1f5df002ece70" }, "dependencyClosures": { "wasm32": [] @@ -2797,19 +2891,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "73cea7cd1849aca34f37ae2eb4c6c841328a17ae38e5c1abeeda4a83323a2361" + "wasm32": "86434df9283dee6425eb388b6229fd19a8f9624bef425d5a9b6e37082636cf2a" }, "dependencyClosures": { "wasm32": [ { "packageName": "libpng", "manifestSha256": "79ed5c5c072a0267cce5c7a2fe37d8494571b17e44b952f619e04ec1c4a9db10", - "cacheKey": "ca264fd3613420d6d6545ce77fb9f3dd9e70c505bc8752cdfe7ff09cab8df4b8" + "cacheKey": "5862e6edd08188b92bb7f84c064d147326ec65ba5024bb86e93a8efa93ff13d8" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2" + "cacheKey": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b" } ] }, @@ -2836,7 +2930,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "73d6a9db88d94927e2d5bc14d7f79c8eae82b902e890a9bee516cf4196b45046" + "wasm32": "e6f3c139b81f8398c9083a88c64c541b2d50aa59bdf1eb3795dbc8100c477279" }, "dependencyClosures": { "wasm32": [] @@ -2857,7 +2951,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f268b7542ce0d20e3e242dfd0bc33206208045fe35a2e9679a92f095afdee7fd" + "wasm32": "a2ebb59c3b576c0eae37312c39711e3428cea60156614afbdb6c9de1540166eb" }, "dependencyClosures": { "wasm32": [] @@ -2878,14 +2972,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "5569fee9f1d9e9c5efcd112ee55b32774f208f1e000a68fdada86b0a5246684e" + "wasm32": "b4d85b2f95d7c6ec98fa9efe64aafa5238d9fdbce7eede2c93ae16b4c042d1d6" }, "dependencyClosures": { "wasm32": [ { "packageName": "vim", "manifestSha256": "c211660ef41d01f95f3fbdf675b74891e897e3f304e04f1bf5586f326007382c", - "cacheKey": "f268b7542ce0d20e3e242dfd0bc33206208045fe35a2e9679a92f095afdee7fd" + "cacheKey": "a2ebb59c3b576c0eae37312c39711e3428cea60156614afbdb6c9de1540166eb" } ] }, @@ -2905,7 +2999,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "dbf745ec60dc75ca96a470f6b07db9b239b3ae518ca75a897dff3346388d4358" + "wasm32": "b9ca0d845774d9bc9421298065f0a0ac9c42bd5b31b52688d0ab35278c2adc68" }, "dependencyClosures": { "wasm32": [] @@ -2926,79 +3020,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "bfb506462d4179365315850df70adaaf957016f705b97675354e8ae9620dfaff" + "wasm32": "4c18d0e78186915f6ea6e22e2ef769af3bb413a4b33220dd064aef10fc5a316a" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "afabec4ac51bd35f27451be1ab27dbcd29abe2bdc93ee24d7ba39faf65c583c9" + "cacheKey": "543e082567cc0502609bf0cdef6eb1ba431cef97d798136e739c915f8392615a" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "ed82530ca56a059fcb3ded9e7eba665878dd476a4e15fb0c67ec3fbab88ec961" + "cacheKey": "81677f4b7e2d40634555388d8d06757a052efcbb915bef1c6e57d444137848cd" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "08ad5e096eb7909993a2aa2d75a3fc0282b5112818f9de92d7a21b9db3f7312b" + "cacheKey": "6ce2dd7a19b7f5af78edc02fc75d08bbeade7f41e968f8b8a3f6e7e5fe4b26ae" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c7c50744bc51231bfc6e1c391755500abb4803062140db70c41a83a7c1df8cc9" + "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "8289b6195a813388b7673810c77749e055904b6ce1f80872c87f617e7c9fe26f" + "cacheKey": "8b132a257b9a02a3adccac9d5d6bedc00348a6ac92375401761312d7c46a2ecf" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "a16f2e59186fef56229883d91bd5b09c9fd24ec96137287ecec4fba963500822" + "cacheKey": "4873ada42645826762560ad1833364a60c630c6411f84a03456b171f279faf2b" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "0f052ffc6c568fec083752495f6245511974745f30743d961cbed6296da68f32" + "cacheKey": "32c0e36842b5f47e79c66b925601f3f30c4ac982303cd159ade0b6c5755e5b61" }, { "packageName": "msmtpd", "manifestSha256": "09de04a422ddf631a29a259d816e081f8d317d1077808ede55d8c500baae748f", - "cacheKey": "382f58897a42df23508ddc227f96d3229141399a510a350647f0ce7cc14e81ac" + "cacheKey": "554ed9b04dcac3ed56656590be2baadc303cd9494f8742fcd6ab3e9323faf62a" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "313a503d66a7a8ad435c9aee08a4b44f332d84197caa6bc43e5d84336e3228ec" + "cacheKey": "a477b0911328bdd36d79e2339ccad2b67d23c69589fd622af20d8afa5af77df4" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "23b54aad82b4105ad8f5ea2b66e5c15f30d69455dc4cd28a92a936e87e60914b" + "cacheKey": "005e9cd3956f26bf2c6e97610ed4c0ca4ee21eaa0d4e59a710f861a78bb7020d" }, { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "c069c011716dd51e586649af498a77fe7f9623c3f87d94517d504d65d59fc7ba" + "cacheKey": "98ecceaf50e4b0dbc10292b49b3c8ee2009e6ba965449123851818c123b9427b" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "a017aa5de279b7f77c0f283db5fb4f89e43e495da413b4634776574e4040e71a" + "cacheKey": "924040509cd02cedd551f4afa6568090fcd0bed488d44674c0c1802d33b3e6a4" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "5b3fda5e074c97b803e622d48b5ab3e87b43230d3f68d668296fb64a27f1568b" + "cacheKey": "6e44f4ec127c258c4185142295492ab790cca757db35a284762a95668e0862e9" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "d65b5419d1992beb7637232cd98603a809db9b573085b4aa4980176e5570e2b2" + "cacheKey": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b" } ] }, @@ -3018,7 +3112,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2fbd8cf66a89cf6f38fff3f23ac0bcf5bed0358acfaf7440d29f9a5dfc68064d" + "wasm32": "105b5d372e4e3d6f1cc9bf2871f5a6d082336a0fbfbd044c536d6466b28541f5" }, "dependencyClosures": { "wasm32": [] @@ -3039,7 +3133,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "fea91b2310de8577c77761a062d1ba542223df19ca4bbc2ed6df8479a5e7837c" + "wasm32": "de42ac678212b2313fc54c1c583877e1f69573e850f5634fc296e1985375459a" }, "dependencyClosures": { "wasm32": [] @@ -3060,7 +3154,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a8ccfb874b8b263391a73976b9fa0592c0826a8ee6f0347bcf8ed2cdb9b2e9ed" + "wasm32": "b1f13d42e6ad8b54bb66c31ad8614bfd4f8f7989c4cd840131399dce1a15354f" }, "dependencyClosures": { "wasm32": [] diff --git a/run.sh b/run.sh index d2da83e59f..0b7d5f507a 100755 --- a/run.sh +++ b/run.sh @@ -286,20 +286,26 @@ KERNEL_REQUIRED_EXPORTS=( kernel_has_sa_nocldstop kernel_host_adapter_manifest_len kernel_host_adapter_manifest_ptr + kernel_ipc_shm_lookup_mapping_for_task + kernel_ipc_shm_record_mapping_for_process + kernel_ipc_shm_record_mapping_for_task kernel_ipc_shmat_for_process kernel_ipc_shmat_for_task + kernel_ipc_shmdt_addr_for_process + kernel_ipc_shmdt_addr_for_task kernel_ipc_shmdt_for_process kernel_ipc_shmdt_for_task kernel_is_fd_nonblock kernel_mark_process_signaled kernel_mq_descriptor_msgsize kernel_msqid_ds_bytes - kernel_pick_signal_target_tid kernel_pcm_claim_transport kernel_pcm_clock_update kernel_pcm_reconcile kernel_pcm_transport_len kernel_pcm_transport_ptr + kernel_pick_signal_target_tid + kernel_pick_tcp_listener_target kernel_pipe_has_readers kernel_posix_timer_fire kernel_process_metadata_begin @@ -320,6 +326,7 @@ KERNEL_REQUIRED_EXPORTS=( kernel_spawn_scratch_capacity kernel_spawn_scratch_pointer kernel_spawn_scratch_retained_capacity + kernel_take_process_timer_cleanup kernel_thread_exit kernel_thread_has_deliverable kernel_transfer_channel_execute diff --git a/scripts/package-runtime-file.ts b/scripts/package-runtime-file.ts index 2045a36b44..8170f05213 100644 --- a/scripts/package-runtime-file.ts +++ b/scripts/package-runtime-file.ts @@ -7,7 +7,8 @@ * TypeScript fixtures. */ import { execFileSync } from "node:child_process"; -import { isAbsolute } from "node:path"; +import { lstatSync, realpathSync } from "node:fs"; +import { isAbsolute, join, resolve } from "node:path"; import { tryResolveBinarySet } from "../host/src/binary-resolver"; export interface PackageRuntimeFileContract { @@ -25,32 +26,78 @@ export interface ResolvedPackageRuntimeFile extends PackageRuntimeFileContract { closureHostPaths: ReadonlyMap; } -let cachedHostTarget: string | undefined; +let preparedRuntimeMetadataXtask: + | { repoRoot: string; xtaskPath: string } + | undefined; -function hostTarget(): string { - if (cachedHostTarget) return cachedHostTarget; - const output = execFileSync("rustc", ["-vV"], { encoding: "utf8" }); +function hostTarget(repoRoot: string): string { + const inDevShell = process.env.KANDELO_DEV_SHELL_TOOL_PATH !== undefined; + const command = inDevShell ? "rustc" : "bash"; + const args = inDevShell + ? ["-vV"] + : [join(repoRoot, "scripts", "dev-shell.sh"), "rustc", "-vV"]; + const output = execFileSync(command, args, { + cwd: repoRoot, + encoding: "utf8", + }); const target = output.match(/^host:\s*(\S+)$/m)?.[1]; if (!target) throw new Error("rustc -vV did not report a host target"); - cachedHostTarget = target; return target; } -function hostCargoEnv(): NodeJS.ProcessEnv { - const env = { ...process.env }; - for (const name of [ - "CC", - "CXX", - "AR", - "RANLIB", - "CFLAGS", - "CXXFLAGS", - "CPPFLAGS", - "LDFLAGS", - ]) { - delete env[name]; +function requireRegularXtask(path: string): string { + try { + if (lstatSync(path).isFile()) return realpathSync(path); + } catch { + // Report one stable preparation error below. } - return env; + throw new Error(`Prepared xtask is not a regular file: ${path}`); +} + +function prepareRuntimeMetadataXtask(repoRoot: string): string { + const explicit = process.env.WASM_POSIX_XTASK_BIN; + if (explicit !== undefined) { + const explicitPath = isAbsolute(explicit) + ? resolve(explicit) + : resolve(repoRoot, explicit); + return requireRegularXtask(explicitPath); + } + if (preparedRuntimeMetadataXtask?.repoRoot === repoRoot) { + return requireRegularXtask(preparedRuntimeMetadataXtask.xtaskPath); + } + + const target = hostTarget(repoRoot); + const xtaskPath = join( + repoRoot, + "target", + target, + "release", + process.platform === "win32" ? "xtask.exe" : "xtask", + ); + const cargoArgs = [ + "build", + "--release", + "-p", + "xtask", + "--target", + target, + "--quiet", + ]; + const inDevShell = process.env.KANDELO_DEV_SHELL_TOOL_PATH !== undefined; + const command = inDevShell ? "cargo" : "bash"; + const args = inDevShell + ? cargoArgs + : [join(repoRoot, "scripts", "dev-shell.sh"), "cargo", ...cargoArgs]; + // WHY: deleting compiler variables here also deletes the dev shell's + // declared host archiver and makes native Cargo build scripts fall back to + // ambient platform tools. Cargo's incremental build is the current-source + // attestation; CI can instead provide the exact prepared binary below. + execFileSync(command, args, { cwd: repoRoot, encoding: "utf8" }); + preparedRuntimeMetadataXtask = { + repoRoot, + xtaskPath: requireRegularXtask(xtaskPath), + }; + return preparedRuntimeMetadataXtask.xtaskPath; } export function readPackageRuntimeFileContract( @@ -59,21 +106,14 @@ export function readPackageRuntimeFileContract( artifact: string, ): PackageRuntimeFileContract { const raw = execFileSync( - "cargo", + prepareRuntimeMetadataXtask(repoRoot), [ - "run", - "-p", - "xtask", - "--target", - hostTarget(), - "--quiet", - "--", "build-deps", "runtime-file-metadata", packageName, artifact, ], - { cwd: repoRoot, encoding: "utf8", env: hostCargoEnv() }, + { cwd: repoRoot, encoding: "utf8" }, ).trim(); return parsePackageRuntimeFileContract(raw, packageName, artifact); } diff --git a/scripts/resolve-binary.bundle.mjs b/scripts/resolve-binary.bundle.mjs index 3fedc2a78b..bdb774d1df 100644 --- a/scripts/resolve-binary.bundle.mjs +++ b/scripts/resolve-binary.bundle.mjs @@ -1,13 +1,13 @@ // Generated by scripts/build-resolve-binary-bundle.sh; do not edit. Third-party notices: resolve-binary.bundle.LICENSES.txt -var ra=Object.defineProperty;var nn=(n,e,t)=>()=>{if(t)throw t[0];try{return n&&(e=n(n=0)),e}catch(r){throw t=[r],r}};var vi=(n,e)=>{for(var t in e)ra(n,t,{get:e[t],enumerable:!0})};import{createRequire as Oc}from"module";function Zo(n,e){return qo(n,{i:2},e&&e.out,e&&e.dictionary)}var Ic,xt,xc,Rc,oe,Ot,vc,Bo,$o,Tc,Uo,xt,Wo,Lc,Go,bc,Vu,Wn,ze,M,nr,ir,M,M,M,M,Ho,M,zc,kc,$n,Ae,Un,Vo,Kr,Pc,me,qo,Nc,Fc,It,Xo,Cc,Mc,Gn=nn(()=>{Ic=Oc("/");try{xt=Ic("worker_threads"),xc=xt.Worker,Rc=xt.isMarkedAsUntransferable}catch{}oe=Uint8Array,Ot=Uint16Array,vc=Int32Array,Bo=new oe([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),$o=new oe([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),Tc=new oe([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),Uo=function(n,e){for(var t=new Ot(31),r=0;r<31;++r)t[r]=e+=1<>1|(M&21845)<<1,ze=(ze&52428)>>2|(ze&13107)<<2,ze=(ze&61680)>>4|(ze&3855)<<4,Wn[M]=((ze&65280)>>8|(ze&255)<<8)>>1;nr=(function(n,e,t){for(var r=n.length,i=0,s=new Ot(e);i>c]=l}else for(a=new Ot(r),i=0;i>15-n[i]);return a}),ir=new oe(288);for(M=0;M<144;++M)ir[M]=8;for(M=144;M<256;++M)ir[M]=9;for(M=256;M<280;++M)ir[M]=7;for(M=280;M<288;++M)ir[M]=8;Ho=new oe(32);for(M=0;M<32;++M)Ho[M]=5;zc=nr(ir,9,1),kc=nr(Ho,5,1),$n=function(n){for(var e=n[0],t=1;te&&(e=n[t]);return e},Ae=function(n,e,t){var r=e/8|0;return(n[r]|n[r+1]<<8)>>(e&7)&t},Un=function(n,e){var t=e/8|0;return(n[t]|n[t+1]<<8|n[t+2]<<16)>>(e&7)},Vo=function(n){return(n+7)/8|0},Kr=function(n,e,t){return(e==null||e<0)&&(e=0),(t==null||t>n.length)&&(t=n.length),new oe(n.subarray(e,t))},Pc=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],me=function(n,e,t){var r=new Error(e||Pc[n]);if(r.code=n,Error.captureStackTrace&&Error.captureStackTrace(r,me),!t)throw r;return r},qo=function(n,e,t,r){var i=n.length,s=r?r.length:0;if(!i||e.f&&!e.l)return t||new oe(0);var o=!t,a=o||e.i!=2,c=e.i;o&&(t=new oe(i*3));var l=function(De){var Ke=t.length;if(De>Ke){var gr=new oe(Math.max(Ke*2,De));gr.set(t),t=gr}},d=e.f||0,u=e.p||0,p=e.b||0,m=e.l,f=e.d,h=e.m,g=e.n,_=i*8;do{if(!m){d=Ae(n,u,1);var y=Ae(n,u+1,3);if(u+=3,y)if(y==1)m=zc,f=kc,h=9,g=5;else if(y==2){var S=Ae(n,u,31)+257,O=Ae(n,u+10,15)+4,x=S+Ae(n,u+5,31)+1;u+=14;for(var R=new oe(x),T=new oe(19),L=0;L>4;if(E<16)R[L++]=E;else{var U=0,ue=0;for(E==16?(ue=3+Ae(n,u,3),u+=2,U=R[L-1]):E==17?(ue=3+Ae(n,u,7),u+=3):E==18&&(ue=11+Ae(n,u,127),u+=7);ue--;)R[L++]=U}}var C=R.subarray(0,S),H=R.subarray(S);h=$n(C),g=$n(H),m=nr(C,h,1),f=nr(H,g,1)}else me(1);else{var E=Vo(u)+4,A=n[E-4]|n[E-3]<<8,w=E+A;if(w>i){c&&me(0);break}a&&l(p+A),t.set(n.subarray(E,w),p),e.b=p+=A,e.p=u=w*8,e.f=d;continue}if(u>_){c&&me(0);break}}a&&l(p+131072);for(var bt=(1<>4;if(u+=U&15,u>_){c&&me(0);break}if(U||me(2),Re<256)t[p++]=Re;else if(Re==256){je=u,m=null;break}else{var zt=Re-254;if(Re>264){var L=Re-257,Ce=Bo[L];zt=Ae(n,u,(1<>4;lt||me(3),u+=lt&15;var H=bc[ge];if(ge>3){var Ce=$o[ge];H+=Un(n,u)&(1<_){c&&me(0);break}a&&l(p+131072);var Me=p+zt;if(p>3&1)+(e>>4&1);r>0;r-=!n[t++]);return t+(e&2)},It=(function(){function n(e,t){typeof e=="function"&&(t=e,e={}),this.ondata=t;var r=e&&e.dictionary&&e.dictionary.subarray(-32768);this.s={i:0,b:r?r.length:0},this.o=new oe(32768),this.p=new oe(0),r&&this.o.set(r)}return n.prototype.e=function(e){if(this.ondata||me(5),this.d&&me(4),!this.p.length)this.p=e;else if(e.length){var t=new oe(this.p.length+e.length);t.set(this.p),t.set(e,this.p.length),this.p=t}},n.prototype.c=function(e){this.s.i=+(this.d=e||!1);var t=this.s.b,r=qo(this.p,this.s,this.o);this.ondata(Kr(r,t,this.s.b),this.d),this.o=Kr(r,this.s.b-32768),this.s.b=this.o.length,this.p=Kr(this.p,this.s.p/8|0),this.s.p&=7},n.prototype.push=function(e,t){this.e(e),this.c(t)},n})();Xo=(function(){function n(e,t){this.v=1,this.r=0,It.call(this,e,t)}return n.prototype.push=function(e,t){if(It.prototype.e.call(this,e),this.r+=e.length,this.v){var r=this.p.subarray(this.v-1),i=r.length>3?Fc(r):4;if(i>r.length){if(!t)return}else this.v>1&&this.onmember&&this.onmember(this.r-r.length);this.p=r.subarray(i),this.v=0}It.prototype.c.call(this,0),this.s.f&&!this.s.l?(this.v=Vo(this.s.p)+9,this.s={i:0},this.o=new oe(0),this.push(new oe(0),t)):t&&It.prototype.c.call(this,t)},n})(),Cc=typeof TextDecoder<"u"&&new TextDecoder,Mc=0;try{Cc.decode(Nc,{stream:!0}),Mc=1}catch{}});var qn={};vi(qn,{extractZipEntry:()=>Hc,extractZipEntryBounded:()=>Vc,fetchZipCentralDirectory:()=>Zc,parseZipCentralDirectory:()=>or});function ts(n){let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=Math.max(0,n.length-Jo);for(let r=n.length-Bc;r>=t;r--)if(e.getUint32(r,!0)===Dc)return r;throw new Error("Zip EOCD record not found")}function or(n){let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=ts(n),r=e.getUint16(t+10,!0),i=e.getUint32(t+16,!0),s=[],o=i;for(let a=0;a>8,A;E===Yo?A=h>>16&65535:y.startsWith("bin/")||y.startsWith("sbin/")||y.includes("/bin/")||y.includes("/sbin/")?A=493:A=420;let w=y.endsWith("/"),S=E===Yo&&(A&Uc)===$c;s.push({fileName:y,fileNameBytes:_,compressedSize:d,uncompressedSize:u,compressionMethod:l,localHeaderOffset:g,mode:A,isDirectory:w,isSymlink:S,externalAttrs:h,creatorOS:E}),o+=Hn+p+m+f}return s}function rs(n,e){if(n.byteLength!==e.byteLength)return!1;for(let t=0;t{if(a.byteLength>t-s)throw new Error(`ZIP member ${e.fileName} expands beyond ${t} bytes`);i.set(a,s),s+=a.byteLength}).push(r,!0),s!==t)throw new Error(`ZIP member ${e.fileName} expanded ${s} bytes, expected ${t}`);return i}function qc(n,e){let t=new DataView(n.buffer,n.byteOffset,n.byteLength),r=e.localHeaderOffset;if(r<0||r>n.byteLength-Vn||t.getUint32(r,!0)!==jo)throw new Error(`Invalid local file header signature at offset ${r}`);let i=t.getUint16(r+8,!0),s=t.getUint16(r+26,!0),o=t.getUint16(r+28,!0),a=r+Vn,c=a+s+o,l=c+e.compressedSize;if(i!==e.compressionMethod||cn.byteLength||!rs(n.subarray(a,a+s),e.fileNameBytes))throw new Error(`ZIP member ${e.fileName} has inconsistent local metadata`);return n.subarray(c,l)}async function Zc(n){let e=await fetch(n,{method:"HEAD"});if(!e.ok)throw new Error(`HEAD request failed: ${e.status} ${e.statusText}`);let t=parseInt(e.headers.get("content-length")||"0",10),r=e.headers.get("accept-ranges");if(!t||r!=="bytes"){let _=await fetch(n);if(!_.ok)throw new Error(`Fetch failed: ${_.status} ${_.statusText}`);let y=new Uint8Array(await _.arrayBuffer());return{entries:or(y),totalSize:y.length}}let i=Math.min(t,Jo),s=t-i,o=await fetch(n,{headers:{Range:`bytes=${s}-${t-1}`}});if(o.status!==206){let _=await fetch(n);if(!_.ok)throw new Error(`Fetch failed: ${_.status} ${_.statusText}`);let y=new Uint8Array(await _.arrayBuffer());return{entries:or(y),totalSize:y.length}}let a=new Uint8Array(await o.arrayBuffer()),c=new DataView(a.buffer,a.byteOffset,a.byteLength),l=ts(a),d=c.getUint32(l+12,!0),u=c.getUint32(l+16,!0);if(u>=s){let _=t,y=new Uint8Array(_);return y.set(a,s),{entries:or(y),totalSize:_}}let p=u+d-1,m=await fetch(n,{headers:{Range:`bytes=${u}-${p}`}});if(m.status!==206)throw new Error(`Range request for CD failed: ${m.status}`);let f=new Uint8Array(await m.arrayBuffer()),h=t,g=new Uint8Array(h);return g.set(f,u),g.set(a,s),{entries:or(g),totalSize:h}}var Dc,Kc,jo,Jo,Bc,Hn,Vn,Qo,es,Yo,$c,Uc,Wc,Gc,Zn=nn(()=>{"use strict";Gn();Dc=101010256,Kc=33639248,jo=67324752,Jo=65557,Bc=22,Hn=46,Vn=30,Qo=0,es=8,Yo=3,$c=40960,Uc=61440,Wc=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),Gc=new TextEncoder});var ls={};vi(ls,{DEFAULT_TAR_GZIP_LIMITS:()=>cs,TarParseError:()=>b,parseTarGzip:()=>Jc});function Jc(n,e={}){let t=e.label??"TAR gzip archive",r=el(e.limits,t);if(n.byteLength===0||n.byteLength>r.maxCompressedBytes)throw new b(`${t}: compressed byte count ${n.byteLength} is outside 1..${r.maxCompressedBytes}`);let i=tl(n,t);if(i===0||i>r.maxUncompressedBytes)throw new b(`${t}: declared uncompressed byte count ${i} is outside 1..${r.maxUncompressedBytes}`);let s=rl(n,t,i);if(s.byteLength!==i)throw new b(`${t}: gzip expanded to ${s.byteLength} bytes, expected ${i}`);let o=new DataView(n.buffer,n.byteOffset,n.byteLength).getUint32(n.byteLength-8,!0);if(nl(s)!==o)throw new b(`${t}: gzip CRC32 mismatch`);return Qc(s,t,r)}function Qc(n,e,t){if(n.byteLength%ke!==0)throw new b(`${e}: TAR byte count is not block-aligned`);let r=[],i=0,s=0,o=0,a=null,c={},l=!1;for(;i+ke<=n.byteLength;){let d=n.subarray(i,i+ke);if(i+=ke,Yn(d)){if(i+ke>n.byteLength)throw new b(`${e}: TAR end marker is truncated`);let w=n.subarray(i,i+ke);if(!Yn(w))throw new b(`${e}: TAR has only one zero end block`);if(i+=ke,!Yn(n.subarray(i)))throw new b(`${e}: TAR has nonzero data after its end marker`);l=!0;break}al(d,e);let u=sr(d,156,1,e)||"0",p=Jn(d,124,12,`${e}: TAR entry size`),m=Jn(d,100,8,`${e}: TAR entry mode`)&Xc,f=cl(d,e,t.maxPathBytes),h=sr(d,157,100,e);if(u==="x"||u==="g"){if(o+=1,o>t.maxEntries+1)throw new b(`${e}: TAR extension header count exceeds ${t.maxEntries+1}`);let w=is(n,i,p,e);i=os(i,p,n.byteLength,e);let S=ol(w,e,t);u==="x"?a=S:c={...c,...S};continue}if(s+=1,s>t.maxEntries)throw new b(`${e}: TAR entry count exceeds ${t.maxEntries}`);let g={...c,...a??{}};a=null;let _=g.size===void 0?p:sl(g.size,`${e}: PAX entry size`),y=is(n,i,_,e);i=os(i,_,n.byteLength,e);let E=jn(g.path??f,e,t.maxPathBytes),A=g.linkpath??h;switch(u){case"0":case"\0":r.push({path:E,type:"file",mode:m,data:y});break;case"5":Xn(_,e,"directory",E),r.push({path:E,type:"directory",mode:m});break;case"2":Xn(_,e,"symlink",E),ss(A,e,E,t.maxLinkBytes,!1),r.push({path:E,type:"symlink",mode:m,linkName:A});break;case"1":Xn(_,e,"hardlink",E),ss(A,e,E,t.maxLinkBytes,!0),r.push({path:E,type:"hardlink",mode:m,linkName:jn(A,`${e}: hardlink target`,t.maxPathBytes)});break;case"3":case"4":case"6":throw new b(`${e}: unsupported TAR device/FIFO entry ${E}`);default:throw new b(`${e}: unsupported TAR entry type ${JSON.stringify(u)} for ${E}`)}}if(!l)throw new b(`${e}: TAR is missing its two-block end marker`);if(a!==null)throw new b(`${e}: local PAX header has no following entry`);return r}function el(n,e){let t={...cs,...n};for(let[r,i]of Object.entries(t))if(!Number.isSafeInteger(i)||i<=0)throw new b(`${e}: ${r} must be a positive safe integer`);return t}function tl(n,e){if(n.byteLength<18||n[0]!==31||n[1]!==139||n[2]!==8)throw new b(`${e}: invalid gzip header`);return new DataView(n.buffer,n.byteOffset,n.byteLength).getUint32(n.byteLength-4,!0)}function rl(n,e,t){let r=new Uint8Array(t),i=0,s=!1,o=new Xo(a=>{if(a.byteLength>t-i)throw new b(`${e}: gzip expansion exceeds its declared ${t} bytes`);r.set(a,i),i+=a.byteLength});o.onmember=()=>{throw s=!0,new b(`${e}: concatenated gzip members are unsupported`)};try{o.push(n,!0)}catch(a){throw a instanceof b?a:new b(`${e}: cannot gunzip archive: ${ul(a)}`)}if(s)throw new b(`${e}: concatenated gzip members are unsupported`);return r.subarray(0,i)}function nl(n){let e=4294967295;for(let t of n)e=jc[(e^t)&255]^e>>>8;return(e^4294967295)>>>0}function il(){let n=new Uint32Array(256);for(let e=0;e>>1^((t&1)===0?0:3988292384);n[e]=t>>>0}return n}function is(n,e,t,r){if(t>n.byteLength-e)throw new b(`${r}: TAR entry is truncated`);return n.subarray(e,e+t)}function os(n,e,t,r){let s=Math.ceil(e/ke)*ke;if(!Number.isSafeInteger(s)||s>t-n)throw new b(`${r}: TAR entry padding is truncated`);return n+s}function ol(n,e,t){let r={},i=0;for(;i9)throw new b(`${e}: invalid PAX record length`);if(o=o*10+h,!Number.isSafeInteger(o))throw new b(`${e}: invalid PAX record length`)}let a=i+o;if(o<=s-i+2||a>n.byteLength||n[a-1]!==10)throw new b(`${e}: truncated PAX record`);let c=s+1;for(;c=a-1)throw new b(`${e}: invalid PAX record`);let l=n.subarray(s+1,c);if(l.byteLength>256)throw new b(`${e}: PAX record key is too long`);let d=Qn(l,`${e}: PAX record key`),u=n.subarray(c+1,a-1),p=d==="path"?t.maxPathBytes:d==="linkpath"?t.maxLinkBytes:d==="size"?32:0;if(p===0){i=a;continue}if(u.byteLength>p)throw new b(`${e}: PAX ${d} value is too long`);let m=Qn(u,`${e}: PAX record value`);r[d]=m,i=a}return r}function sl(n,e){if(!/^(0|[1-9][0-9]*)$/.test(n))throw new b(`${e} is invalid`);let t=Number(n);if(!Number.isSafeInteger(t)||t<0)throw new b(`${e} is invalid`);return t}function al(n,e){let t=Jn(n,148,8,`${e}: TAR checksum`),r=0;for(let i=0;i=148&&i<156?32:n[i];if(t!==r)throw new b(`${e}: TAR checksum mismatch`)}function cl(n,e,t){let r=sr(n,0,100,e),i=sr(n,345,155,e);return jn(i?`${i}/${r}`:r,e,t)}function jn(n,e,t){let r=n;for(;r.startsWith("./");)r=r.slice(2);return r=r.replace(/\/+$/g,""),ll(r,`${e}: TAR path`,t),r}function sr(n,e,t,r){let i=e,s=e+t;for(;ir||n.includes("\0"))throw new b(`${e}: link target for ${t} is invalid`);if(i&&n.includes("\\"))throw new b(`${e}: hardlink target for ${t} is invalid`)}function ll(n,e,t){if(n.length===0||n.startsWith("/")||n.includes("\0")||n.includes("\\")||as.encode(n).byteLength>t)throw new b(`${e} ${JSON.stringify(n)} must be a bounded relative POSIX path`);for(let r of n.split("/"))if(r.length===0||r==="."||r==="..")throw new b(`${e} ${JSON.stringify(n)} contains an unsafe path segment`)}function Yn(n){for(let e of n)if(e!==0)return!1;return!0}function Qn(n,e){try{return Yc.decode(n)}catch{throw new b(`${e} contains non-UTF-8 text`)}}function ul(n){return n instanceof Error?n.message:String(n)}var ke,Xc,ns,Yc,as,jc,cs,b,us=nn(()=>{"use strict";Gn();ke=512,Xc=4095,ns=1024*1024,Yc=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),as=new TextEncoder,jc=il(),cs=Object.freeze({maxCompressedBytes:256*ns,maxUncompressedBytes:512*ns,maxEntries:1e5,maxPathBytes:4096,maxLinkBytes:65536}),b=class extends Error{constructor(e){super(e),this.name="TarParseError"}}});import{existsSync as pr,lstatSync as tn,readdirSync as Gs,readFileSync as st,realpathSync as Ie,statSync as Xe}from"node:fs";import{createHash as Hs}from"node:crypto";import{spawnSync as gi}from"node:child_process";import{basename as Xl,dirname as _r,isAbsolute as rn,join as $,relative as Yl,resolve as Oe,sep as jl}from"node:path";import{fileURLToPath as Jl}from"node:url";var kt="kandelo.wpk_fork.linked_frames";var Ti=[75,76,67,70],Pt=24,Li=8,on=3,bi=[{bytes:4,chunkHeaderSize:32,nodeHeaderSize:24},{bytes:8,chunkHeaderSize:56,nodeHeaderSize:32}],re="kandelo.wpk_fork.module_state",zi=1,ki=[75,70,77,68],Er=24,Pi=8;var sn=7;var Ni=1,Fi=1,Ci=1;var Mi=[{bytes:4,chunkHeaderSize:40},{bytes:8,chunkHeaderSize:56}];var Sr="__wpk_fork_global_";var wr="__wpk_fork_table_",an=1,cn=2,ln=3,un=4,dn=5,dt=6,Nt=7,Ft=8,Ct=9,ve="kandelo.wpk_fork.capabilities",Di=1;var Ki=7,Ar=4,Ee="kandelo.wpk_fork.exception_codec",Bi=1,Or=8,fn=16;var Ir="env",xr="__wpk_fork_unwind",ft="kandelo.wpk_fork.unwind_transport",Mt="__wpk_fork_static_root_catalog",Be="kandelo.wpk_fork.static_root_catalog";var hn=1,pn=0,$i=1,Rr=12,Ui=[75,70,83,82],Z="kandelo.wpk_fork.imported_globals";var Wi=[75,70,73,71],Gi=1,vr=16,Dt=24,Hi=1,Vi=2,qi=3,X="kandelo.wpk_fork.imported_tables",Zi=[75,70,73,84],Xi=1,Tr=16,Kt=24,Yi=1,ji=1,mn="env",_n="__wpk_fork_module_activation";var ht=[{module:"env",name:"__wpk_fork_frame_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_frame_next",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_peek",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_reserve",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_record_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_module_state_record_find",params:["i32","i32","i32","i32"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_record_reserve",params:["i32","i32","i32","ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_table_dirty_count",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_module_state_table_dirty_mark",params:["i32","i64","i64"],results:[]},{module:"env",name:"__wpk_fork_module_state_table_dirty_page",params:["i32","i32"],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_mutation_abort",params:[],results:[]},{module:"env",name:"__wpk_fork_module_state_table_mutation_begin",params:[],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_mutation_commit",params:["i32","i64","i64"],results:[]},{module:"env",name:"__wpk_fork_module_state_table_reconcile",params:[],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_state_owned",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_decode_funcref",params:["i32"],results:["funcref"]},{module:"env",name:"__wpk_fork_ref_encode_funcref",params:["funcref"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_broker_encode",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_broker_throw_recipe",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_cache_index",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_claim",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_define",params:["i32","i32","i32","i32","ptr","i32","ptr","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_ingress_throw",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_load",params:["i32","i32","i32","i32","ptr","i32","ptr","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_lookup",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_route",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_broker_encode",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_capture_layout",params:["i32","i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_claim",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_define",params:["i32","i32","i32","i32","i32","ptr","i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_i31",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_load",params:["i32","i32","i32","i32","i32","ptr","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_lookup",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_payload_len",params:["i32","i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_provenance_begin",params:["i32","i32","i32","i32","i64","i64","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_provenance_end",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_provenance_ref",params:["i32","i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_route",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_scratch_release",params:["ptr","ptr"],results:[]},{module:"env",name:"__wpk_fork_ref_scratch_reserve",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_ref_vector_append",params:["i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_vector_begin",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_vector_finish",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_vector_get",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_resume_peek",params:["i32"],results:["i32"]}],yn=[{module:"env",name:"__wpk_fork_ref_gc_transit",table64:!1,element:"anyref",minimum:1,maximum:null},{module:"env",name:"__wpk_fork_resume_table",table64:!1,element:"funcref",minimum:1,maximum:null}],Bt=[{name:"__wpk_fork_exception_materialize",params:["i32"],results:[]},{name:"__wpk_fork_ref_decode_exnref",params:["i32"],results:["exnref"]},{name:"__wpk_fork_ref_encode_exnref",params:["exnref"],results:["i32"]},{name:"__wpk_fork_ref_exn_abort",params:[],results:[]},{name:"__wpk_fork_ref_exn_clear",params:[],results:[]},{name:"__wpk_fork_ref_exn_encode_ingress",params:["i32"],results:["i32"]},{name:"__wpk_fork_ref_exn_throw_recipe",params:["i32"],results:[]},{name:"__wpk_fork_ref_exn_throw_slot",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_allocate",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_encode_slot",params:["i32"],results:["i32"]},{name:"__wpk_fork_ref_gc_fill",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_probe",params:["i32"],results:["i64"]},{name:"__wpk_fork_ref_gc_publish_externref",params:["i32","externref"],results:[]},{name:"__wpk_fork_static_root_harvest",params:[],results:[]},{name:"wpk_fork_abort_begin",params:["ptr"],results:[]},{name:"wpk_fork_abort_end",params:[],results:[]},{name:"wpk_fork_module_bootstrap",params:[],results:[]},{name:"wpk_fork_module_state_finish_restore",params:["i32"],results:[]},{name:"wpk_fork_module_state_restore",params:["i32"],results:[]},{name:"wpk_fork_module_state_save",params:["i32"],results:[]},{name:"wpk_fork_module_table_state_restore",params:["i32"],results:[]},{name:"wpk_fork_module_table_state_save",params:["i32"],results:[]},{name:"wpk_fork_module_thread_bootstrap",params:[],results:[]},{name:"wpk_fork_rewind_begin",params:["ptr"],results:[]},{name:"wpk_fork_rewind_end",params:[],results:[]},{name:"wpk_fork_state",params:[],results:["i32"]},{name:"wpk_fork_unwind_begin",params:["ptr"],results:[]},{name:"wpk_fork_unwind_end",params:[],results:[]}];var Ji=4096;var Qi=["__abi_version","kernel_alloc_scratch","kernel_blocking_retry_release","kernel_blocking_retry_token","kernel_commit_process_exit","kernel_create_process","kernel_create_process_with_stdio","kernel_dequeue_signal","kernel_exec_prepare","kernel_exec_setup_for_thread","kernel_fork_process","kernel_get_cwd","kernel_get_dirfd_path","kernel_get_fd_path","kernel_get_parent_pid","kernel_get_process_exit_signal","kernel_get_process_state","kernel_get_socket_timeout_ms","kernel_handle_channel","kernel_has_sa_nocldstop","kernel_host_adapter_manifest_len","kernel_host_adapter_manifest_ptr","kernel_ipc_shmat_for_process","kernel_ipc_shmat_for_task","kernel_ipc_shmdt_for_process","kernel_ipc_shmdt_for_task","kernel_is_fd_nonblock","kernel_mark_process_signaled","kernel_mq_descriptor_msgsize","kernel_msqid_ds_bytes","kernel_pick_signal_target_tid","kernel_pipe_has_readers","kernel_posix_timer_fire","kernel_process_metadata_begin","kernel_process_metadata_cancel","kernel_process_metadata_commit","kernel_process_metadata_stage","kernel_reap_exited_child","kernel_remove_process","kernel_semctl_array_bytes","kernel_semid_ds_bytes","kernel_set_current_tid","kernel_set_cwd","kernel_shmid_ds_bytes","kernel_spawn_process","kernel_spawn_reserved_process","kernel_spawn_scratch_begin","kernel_spawn_scratch_cancel","kernel_spawn_scratch_capacity","kernel_spawn_scratch_pointer","kernel_spawn_scratch_retained_capacity","kernel_thread_exit","kernel_thread_has_deliverable","kernel_transfer_channel_execute","kernel_transfer_io_execute","kernel_transfer_scratch_begin","kernel_transfer_scratch_cancel","kernel_transfer_scratch_capacity","kernel_transfer_scratch_pointer","kernel_validate_task","kernel_wait_child_poll"];var V={LINK_MAX:0,MAX_CANON:1,MAX_INPUT:2,NAME_MAX:3,PATH_MAX:4,PIPE_BUF:5,CHOWN_RESTRICTED:6,NO_TRUNC:7,VDISABLE:8,SYNC_IO:9,ASYNC_IO:10,PRIO_IO:11,SOCK_MAXBUF:12,FILESIZEBITS:13,REC_INCR_XFER_SIZE:14,REC_MAX_XFER_SIZE:15,REC_MIN_XFER_SIZE:16,REC_XFER_ALIGN:17,ALLOC_SIZE_MIN:18,SYMLINK_MAX:19,POSIX2_SYMLINKS:20,FALLOC:21,TEXTDOMAIN_MAX:22,TIMESTAMP_RESOLUTION:23};var ia=Uint8Array.from(Ui);function v(n,e){let t=0,r=0,i=e;for(;;){let s=n[i++];if(t|=(s&127)<=n.length)throw new Error(`${r} is truncated`);if((n[e++]&128)===0)return e}throw new Error(`${r} has an overlong LEB128 encoding`)}function Se(n,e,t){if(e>=n.length)throw new Error(`${t} is truncated`);let r=n[e++];switch(r){case 127:case 126:case 125:case 124:case 123:case 117:case 116:case 115:case 114:case 113:case 112:case 111:case 110:case 109:case 108:case 107:case 106:case 105:case 104:return{code:r,shared:!1,next:e};case 98:case 99:case 100:{let i=n[e]===101;i&&e++;let s=co(n,e,5,`${t} heap type`),[o]=ao(n,e);return{code:r,heapType:Number(o),shared:i,next:s}}default:throw new Error(`${t} has unknown value type 0x${r.toString(16)}`)}}function oa(n,e,t){return n[e]===120||n[e]===119?{code:n[e],shared:!1,next:e+1}:Se(n,e,t)}function sa(n,e){let t=n[e];if(t===64||t===127||t===126||t===125||t===124||t===123||t===112||t===111)return e+1;let[,r]=En(n,e);return e+r}function aa(n,e,t){let[r,i]=v(n,e);e+=i;let s=[],o=[];for(let u=0;u=n.length)throw new Error(`${t} mutability is truncated`);let i=n[e++];if(i!==0&&i!==1)throw new Error(`${t} has invalid mutability ${i}`);return e}function ca(n,e,t,r){if(e===101){if(t>=n.length)throw new Error(`${r} shared type is truncated`);e=n[t++]}for(let i of[76,77]){if(e!==i)continue;let[,s]=v(n,t);if(t+=s,t>=n.length)throw new Error(`${r} descriptor is truncated`);e=n[t++]}if(e===96)return aa(n,t,r);if(e===95){let[i,s]=v(n,t);t+=s;for(let o=0;o=n.length)throw new Error(`${t} is truncated`);let r=n[e++];if(r===79||r===80){let[i,s]=v(n,e);e+=s;for(let o=0;o=n.length)throw new Error(`${t} body is truncated`);r=n[e++]}return ca(n,r,e,t)}function la(n,e){let[t,r]=v(n,e);e+=r;let i=[];for(let s=0;s=21&&r<=34?Ut(e,t):r===84||r>=92&&r<=99||r>=112&&r<=123||r>=124&&r<=131||r>=156&&r<=159?t+1:t:n===254?r===0||r===1||r===2?Ut(e,t):r===3?t:r>=16&&r<=79?Ut(e,t):null:null}function da(n,e,t){let[r,i]=v(n,e);e+=i+r;let[s,o]=v(n,e);e+=o+s;let a=n[e++];if(a===0){t.funcImports++;let[,c]=v(n,e);e+=c}else if(a===1)e=Se(n,e,"table import type").next,e=Ue(n,e).next;else if(a===2)e=Ue(n,e).next;else if(a===3)t.globalImports++,e=Se(n,e,"global import type").next,e++;else if(a===4){e++;let[,c]=v(n,e);e+=c}return e}function Lr(n){return n.length>=8&&n[0]===0&&n[1]===97&&n[2]===115&&n[3]===109}function $e(n,e){let[t,r]=v(n,e);return e+=r,[new TextDecoder().decode(n.subarray(e,e+t)),e+t]}function fa(n,e){if(e.length===0)return!0;let t=new TextEncoder().encode(e);e:for(let r=0;r<=n.length-t.length;r++){for(let i=0;in);function ro(n,e){switch(n.code){case 127:return an;case 126:return cn;case 125:return ln;case 124:return un;case 123:return dn;case 112:case 115:return dt;case 111:case 114:return Nt;case 105:case 116:return Ft;case 104:case 106:case 107:case 108:case 109:case 110:case 113:case 117:return Ct;case 98:case 99:case 100:{let t=n.heapType;return t===void 0?null:t===-16||t===-13?dt:t===-17||t===-14?Nt:t===-23||t===-12?Ft:t>=0&&e[t]!==void 0?dt:Ct}default:return null}}function gn(n,e,t){if(!t)throw new Error(`function ${e} refers to an unknown type`);let r=n.get(e)??[];r.push(t),n.set(e,r)}function $t(n,e,t){let r=n.get(e)??[];r.push(t),n.set(e,r)}function Ue(n,e){let[t,r]=v(n,e);e+=r;let[i,s]=v(n,e);e+=s;let o=null;if((t&1)!==0){let[a,c]=v(n,e);e+=c,o=a}return{flags:t,minimum:i,maximum:o,next:e}}function pa(n){let e=new Uint8Array(n);if(!Lr(e))throw new Error("not a wasm binary");let t=[],r=[],i=[],s={functionImports:new Map,functionImportEntries:[],globalImports:new Map,tableImports:new Map,tables:[],tagImports:new Map,functionExports:new Map,globalExports:new Map,tableExports:new Map,exports:new Map,memoryPointerWidths:[],forkCapabilities:[],linkedFrameDescriptors:[],exceptionCodecDescriptors:[],importedGlobalsDescriptors:[],importedTablesDescriptors:[],moduleStateDescriptors:[],staticRootDescriptors:[],unwindTransportDescriptors:[],nativeStartCount:0,importsKernelFork:!1},o=0,a=0,c=8;for(;ce.length)throw new Error("wasm section exceeds file size");let f=p,h=!1;if(l===0){let[g,_]=$e(e,f);g===kt?s.linkedFrameDescriptors.push(e.slice(_,m)):g===ve?s.forkCapabilities.push(e.slice(_,m)):g===Ee?s.exceptionCodecDescriptors.push(e.slice(_,m)):g===Z?s.importedGlobalsDescriptors.push(e.slice(_,m)):g===X?s.importedTablesDescriptors.push(e.slice(_,m)):g===re?s.moduleStateDescriptors.push(e.slice(_,m)):g===Be?s.staticRootDescriptors.push(e.slice(_,m)):g===ft&&s.unwindTransportDescriptors.push(e.slice(_,m))}else if(l===1){h=!0;let g=la(e,f);t.push(...g.types),f=g.next}else if(l===2){h=!0;let[g,_]=v(e,f);f+=_;for(let y=0;y=e.length)throw new Error(`global import ${E}.${w} is truncated`);let R=e[f++];if((R&-4)!==0)throw new Error(`global import ${E}.${w} has invalid flags ${R}`);$t(s.globalImports,`${E}.${w}`,{module:E,name:w,importOrdinal:y,index:o++,valueType:x.code,recipeTypeCode:ro(x,t),mutable:(R&1)!==0,shared:(R&2)!==0})}else if(O===4){let x=e[f++];if(x!==0)throw new Error(`unsupported wasm tag attribute ${x}`);let[R,T]=v(e,f);f+=T,gn(s.tagImports,`${E}.${w}`,t[R])}else throw new Error(`unsupported wasm import kind ${O}`)}}else if(l===3){h=!0;let[g,_]=v(e,f);f+=_;for(let y=0;yn[c]===a))throw new Error("linked-frame descriptor has invalid magic");let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=e.getUint16(4,!0);if(t!==1)throw new Error(`linked-frame descriptor version ${t} is unsupported`);let r=e.getUint16(6,!0);if(r!==Pt)throw new Error(`linked-frame descriptor declares size ${r}, expected ${Pt}`);let i=e.getUint8(8),s=bi.find(({bytes:a})=>a===i);if(!s)throw new Error(`linked-frame descriptor pointer width ${i} is unsupported`);if(e.getUint8(9)!==Li)throw new Error(`linked-frame descriptor alignment ${e.getUint8(9)} is unsupported`);let o=e.getUint16(10,!0);if(o!==on)throw new Error(`linked-frame descriptor flags 0x${o.toString(16)} do not equal required flags 0x${on.toString(16)}`);if(e.getUint32(12,!0)!==s.chunkHeaderSize||e.getUint32(16,!0)!==s.nodeHeaderSize)throw new Error(`linked-frame descriptor header sizes do not match its ${i}-byte pointer width`);return s.bytes}function _a(n){if(n.length===0)return[`missing required ${ve} capability`];if(n.length!==1)return[`has ${n.length} ${ve} sections, expected exactly one`];let e=n[0];if(e.byteLength!==2)return[`${ve} has ${e.byteLength} bytes, expected 2`];if(e[0]!==Di)return[`${ve} version ${e[0]} is unsupported`];let t=e[1];return(t&~Ki)!==0?[`${ve} has unknown flags 0x${t.toString(16)}`]:(t&Ar)!==Ar?[`${ve} flags 0x${t.toString(16)} omit required activation-state safety flags 0x${Ar.toString(16)}`]:[]}function ya(n){let e=[],t=`${Ir}.${xr}`,r=n.tagImports.get(t);if(r?r.length!==1?e.push(`duplicate private fork-unwind tag import ${t}`):(r[0].params.length!==0||r[0].results.length!==0)&&e.push(`private fork-unwind tag ${t} must have an empty payload`):e.push(`missing required private fork-unwind tag import ${t}`),n.unwindTransportDescriptors.length===0)e.push(`missing required ${ft} descriptor`);else if(n.unwindTransportDescriptors.length!==1)e.push(`has ${n.unwindTransportDescriptors.length} ${ft} descriptors, expected exactly one`);else{let i=n.unwindTransportDescriptors[0];(i.length!==2||i[0]!==hn||i[1]!==pn)&&e.push(`${ft} must be [${hn}, ${pn}]`)}return e}function ga(n,e){if(n.length===0)return[`missing required ${re} descriptor`];if(n.length!==1)return[`has ${n.length} ${re} descriptors, expected exactly one`];let t=n[0];if(t.byteLength!==Er)return[`${re} has ${t.byteLength} bytes, expected ${Er}`];if(!ki.every((h,g)=>t[g]===h))return[`${re} has invalid magic`];let r=new DataView(t.buffer,t.byteOffset,t.byteLength),i=r.getUint16(4,!0),s=r.getUint16(6,!0),o=r.getUint8(8),a=Mi.find(({bytes:h})=>h===o),c=r.getUint8(9),l=r.getUint16(10,!0),d=r.getUint16(12,!0),u=r.getUint16(14,!0),p=r.getUint32(16,!0),m=r.getUint32(20,!0),f=[];return i!==zi&&f.push(`${re} version ${i} is unsupported`),s!==Er&&f.push(`${re} declares size ${s}`),a?e!==null&&o!==e&&f.push(`${re} pointer width ${o} does not match linked frames ${e}`):f.push(`${re} pointer width ${o} is unsupported`),c!==Pi&&f.push(`${re} alignment ${c} is unsupported`),l!==sn&&f.push(`${re} flags 0x${l.toString(16)} do not equal required flags 0x${sn.toString(16)}`),d!==Ni&&f.push(`${re} arena version ${d} is unsupported`),u!==Fi&&f.push(`${re} record version ${u} is unsupported`),p!==Ci&&f.push(`${re} root word ${p} is unsupported`),m!==0&&f.push(`${re} reserved field is nonzero`),f}function Ea(n){if(n.length===0)return[`missing required ${Ee} descriptor`];if(n.length!==1)return[`has ${n.length} ${Ee} descriptors, expected exactly one`];let e=n[0];if(e.byteLength2147483647||o.has(d))&&r.push(`${Ee} layout id ${d} is invalid or duplicated`),o.add(d)}return r}var Sa=new Set([an,cn,ln,un,dn,dt,Nt,Ft,Ct]);function no(n){return!(n.module===mn&&(n.name===_n||n.name==="__channel_base"||n.name==="__wpk_fork_module_state_table_generation_addr"))}function wa(n){let e=n.importedGlobalsDescriptors;if(e.length===0)return[`missing required ${Z} descriptor`];if(e.length!==1)return[`has ${e.length} ${Z} descriptors, expected exactly one`];let t=e[0];if(t.byteLengtht[g]===h)||i.push(`${Z} has invalid magic`),r.getUint16(4,!0)!==Gi&&i.push(`${Z} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==vr&&i.push(`${Z} declares an invalid header size`);let s=r.getUint32(8,!0);r.getUint32(12,!0)!==0&&i.push(`${Z} reserved field is nonzero`);let o=new Set,a=new Set,c=new TextDecoder("utf-8",{fatal:!0}),l=[],d=-1,u=vr;for(let h=0;ht.byteLength)return i.push(`${Z} record ${h} header is truncated`),i;let g=r.getUint32(u,!0),_=r.getUint32(u+4,!0),y=r.getUint8(u+8),E=r.getUint8(u+9),A=r.getUint32(u+12,!0),w=r.getUint32(u+16,!0),S=r.getUint32(u+20,!0),O=Dt+A+w;if(!Number.isSafeInteger(O)||g!==O||gt.byteLength)return i.push(`${Z} record ${h} has invalid bounds`),i;(_===0||o.has(_))&&i.push(`${Z} record ${h} has invalid or duplicated owner ${_}`),o.add(_),Sa.has(y)||i.push(`${Z} record ${h} has unknown value type ${y}`),(E&~qi)!==0&&i.push(`${Z} record ${h} has unknown flags 0x${E.toString(16)}`),r.getUint16(u+10,!0)!==0&&i.push(`${Z} record ${h} reserved fields are nonzero`),(a.has(S)||S<=d)&&i.push(`${Z} record ${h} has duplicated or unordered import ordinal`),a.add(S),d=S;let x=u+Dt;try{let R=c.decode(t.subarray(x,x+A)),T=c.decode(t.subarray(x+A,x+A+w));l.push({ownerId:_,typeCode:y,flags:E,importOrdinal:S,module:R,name:T})}catch{i.push(`${Z} record ${h} contains invalid UTF-8`)}u+=g}u!==t.byteLength&&i.push(`${Z} has trailing bytes`);let p=[...n.globalImports.values()].flat(),m=new Map(p.map(h=>[h.index,h])),f=new Set;for(let h of l){let g=`${Sr}${h.ownerId}`,_=n.exports.get(g);if(!_||_.length!==1||_[0].kind!==3){i.push(`${Z} owner ${h.ownerId} lacks exactly one global catalog export ${g}`);continue}let y=m.get(_[0].index);if(!y||!no(y)){i.push(`${Z} owner ${h.ownerId} does not identify a reconstructible imported global`);continue}if(y.module!==h.module||y.name!==h.name||y.importOrdinal!==h.importOrdinal||y.recipeTypeCode!==h.typeCode||y.mutable!==((h.flags&Hi)!==0)||y.shared!==((h.flags&Vi)!==0)){i.push(`${Z} owner ${h.ownerId} does not match its imported global declaration`);continue}if(f.has(y.index)){i.push(`${Z} repeats imported global index ${y.index}`);continue}f.add(y.index)}for(let h of p)no(h)&&!f.has(h.index)&&i.push(`${Z} omits imported global ${h.module}.${h.name} at index ${h.index}`);for(let[h,g]of n.exports){if(!h.startsWith(Sr))continue;let _=h.slice(Sr.length),y=Number(_);(!/^[1-9][0-9]*$/.test(_)||!Number.isSafeInteger(y)||y>4294967295||g.length!==1||g[0].kind!==3)&&i.push(`malformed reserved fork global catalog export ${h}`)}return i}var Aa=new Set([dt,Nt,Ft,Ct]);function io(n){return!yn.some(({module:e,name:t})=>n.module===e&&n.name===t)}function Oa(n){let e=n.importedTablesDescriptors;if(e.length===0)return[`missing required ${X} descriptor`];if(e.length!==1)return[`has ${e.length} ${X} descriptors, expected exactly one`];let t=e[0];if(t.byteLengtht[g]===h)||i.push(`${X} has invalid magic`),r.getUint16(4,!0)!==Xi&&i.push(`${X} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Tr&&i.push(`${X} declares an invalid header size`);let s=r.getUint32(8,!0);r.getUint32(12,!0)!==0&&i.push(`${X} reserved field is nonzero`);let o=new Set,a=new Set,c=new TextDecoder("utf-8",{fatal:!0}),l=[],d=-1,u=Tr;for(let h=0;ht.byteLength)return i.push(`${X} record ${h} header is truncated`),i;let g=r.getUint32(u,!0),_=r.getUint32(u+4,!0),y=r.getUint8(u+8),E=r.getUint8(u+9),A=r.getUint32(u+12,!0),w=r.getUint32(u+16,!0),S=r.getUint32(u+20,!0),O=Kt+A+w;if(!Number.isSafeInteger(O)||g!==O||gt.byteLength)return i.push(`${X} record ${h} has invalid bounds`),i;(_===0||o.has(_))&&i.push(`${X} record ${h} has invalid or duplicated owner ${_}`),o.add(_),Aa.has(y)||i.push(`${X} record ${h} has unknown element type ${y}`),(E&~ji)!==0&&i.push(`${X} record ${h} has unknown flags 0x${E.toString(16)}`),r.getUint16(u+10,!0)!==0&&i.push(`${X} record ${h} reserved fields are nonzero`),(a.has(S)||S<=d)&&i.push(`${X} record ${h} has duplicated or unordered import ordinal`),a.add(S),d=S;let x=u+Kt;try{let R=c.decode(t.subarray(x,x+A)),T=c.decode(t.subarray(x+A,x+A+w));l.push({ownerId:_,typeCode:y,flags:E,importOrdinal:S,module:R,name:T})}catch{i.push(`${X} record ${h} contains invalid UTF-8`)}u+=g}u!==t.byteLength&&i.push(`${X} has trailing bytes`);let p=[...n.tableImports.values()].flat(),m=new Map(p.map(h=>[h.index,h])),f=new Set;for(let h of l){let g=`${wr}${h.ownerId}`,_=n.exports.get(g);if(!_||_.length!==1||_[0].kind!==1){i.push(`${X} owner ${h.ownerId} lacks exactly one table catalog export ${g}`);continue}let y=m.get(_[0].index);if(!y||!io(y)){i.push(`${X} owner ${h.ownerId} does not identify a reconstructible imported table`);continue}if(y.module!==h.module||y.name!==h.name||y.importOrdinal!==h.importOrdinal||y.recipeTypeCode!==h.typeCode||y.table64!==((h.flags&Yi)!==0)){i.push(`${X} owner ${h.ownerId} does not match its imported table declaration`);continue}if(f.has(y.index)){i.push(`${X} repeats imported table index ${y.index}`);continue}f.add(y.index)}for(let h of p)io(h)&&!f.has(h.index)&&i.push(`${X} omits imported table ${h.module}.${h.name} at index ${h.index}`);for(let[h,g]of n.exports){if(!h.startsWith(wr))continue;let _=h.slice(wr.length),y=Number(_);(!/^[1-9][0-9]*$/.test(_)||!Number.isSafeInteger(y)||y>4294967295||g.length!==1||g[0].kind!==1)&&i.push(`malformed reserved fork table catalog export ${h}`)}return i}function Sn(n,e){switch(n){case"ptr":return e===8?126:127;case"i32":return 127;case"i64":return 126;case"anyref":return 110;case"exnref":return 105;case"externref":return 111;case"funcref":return 112}}function oo(n,e,t,r){return n.params.length===e.length&&n.results.length===t.length&&n.params.every((i,s)=>i===Sn(e[s],r))&&n.results.every((i,s)=>i===Sn(t[s],r))}function so(n,e,t){let r=i=>i==="ptr"?t===8?"i64":"i32":i;return`(${n.map(r).join(", ")}) -> (${e.map(r).join(", ")})`}function Ia(n){let e=`${mn}.${_n}`,t=n.globalImports.get(e);return t?t.length!==1?[`duplicate exception-codec activation import ${e}`]:t[0].valueType!==127||t[0].mutable?[`exception-codec activation import ${e} must be immutable i32`]:[]:[`missing required immutable exception-codec activation import ${e}`]}function xa(n){let e=[];for(let t of yn){let r=`${t.module}.${t.name}`,i=n.tableImports.get(r);if(!i){e.push(`missing required ABI 43 fork-runtime table import ${r}`);continue}if(i.length!==1){e.push(`duplicate ABI 43 fork-runtime table import ${r}`);continue}let s=i[0],o=Sn(t.element,4);(s.elementType!==o||s.table64!==t.table64||s.minimum!==t.minimum||s.maximum!==t.maximum)&&e.push(`ABI 43 fork-runtime table import ${r} has the wrong type or limits`)}return e}function Ra(n){if(n.staticRootDescriptors.length===0)return[`missing required ${Be} descriptor`];if(n.staticRootDescriptors.length!==1)return[`has ${n.staticRootDescriptors.length} ${Be} descriptors, expected exactly one`];let e=n.staticRootDescriptors[0];if(e.byteLength!==Rr)return[`${Be} has ${e.byteLength} bytes, expected ${Rr}`];let t=[];ia.some((l,d)=>e[d]!==l)&&t.push(`${Be} has invalid magic`);let r=new DataView(e.buffer,e.byteOffset,e.byteLength);r.getUint16(4,!0)!==$i&&t.push(`${Be} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Rr&&t.push(`${Be} declares an invalid header size`);let i=r.getUint32(8,!0),s=n.tableExports.get(Mt);if(!s||s.length!==1)return t.push(`missing exactly one table export ${Mt}`),t;let o=[...n.tableImports.values()].reduce((l,d)=>l+d.length,0),a=s[0],c=n.tables[a];return a!n.functionExports.has(c)).map(({name:c})=>c);t.length>0&&e.push(`incomplete wasm-fork-instrument exports; missing ${t.join(", ")}`);let r=null;if(n.linkedFrameDescriptors.length===0)e.push(`missing required ${kt} descriptor`);else if(n.linkedFrameDescriptors.length!==1)e.push(`has ${n.linkedFrameDescriptors.length} ${kt} descriptors, expected exactly one`);else try{r=ma(n.linkedFrameDescriptors[0])}catch(c){e.push(c instanceof Error?c.message:String(c))}e.push(...ga(n.moduleStateDescriptors,r));let i=ht.filter(({module:c,name:l})=>n.functionImports.has(`${c}.${l}`)),s=`${Ir}.${xr}`,o=n.importsKernelFork||i.length>0;if((o||n.tagImports.has(s)||n.unwindTransportDescriptors.length>0)&&e.push(...ya(n)),o){let c=ht.filter(({module:l,name:d})=>!n.functionImports.has(`${l}.${d}`)).map(({module:l,name:d})=>`${l}.${d}`);c.length>0&&e.push(`incomplete ABI 43 fork-runtime imports; missing ${c.join(", ")}`);for(let l of ht){let d=`${l.module}.${l.name}`,u=n.functionImports.get(d);u&&u.length!==1&&e.push(`duplicate ABI 43 fork-runtime import ${d}`)}}if(r!==null){if(n.memoryPointerWidths.length!==1)e.push(`ABI 43 fork instrumentation requires exactly one module memory, found ${n.memoryPointerWidths.length}`);else if(n.memoryPointerWidths[0]!==r){let c=r===8?"an":"a";e.push(`ABI 43 linked-frame descriptor declares ${c} ${r}-byte pointer but the module memory uses ${n.memoryPointerWidths[0]}-byte addresses`)}for(let c of Bt){let l=n.functionExports.get(c.name);l?.length===1&&!oo(l[0],c.params,c.results,r)&&e.push(`ABI 43 wasm-fork-instrument export ${c.name} has the wrong signature; expected ${so(c.params,c.results,r)}`)}if(o)for(let c of ht){let l=`${c.module}.${c.name}`,d=n.functionImports.get(l);d?.length===1&&!oo(d[0],c.params,c.results,r)&&e.push(`ABI 43 fork-runtime import ${l} has the wrong signature; expected ${so(c.params,c.results,r)}`)}}return e}function Ta(n){let e=new Uint8Array(n);if(!Lr(e))return[];let t=[],r=8;for(;rt.startsWith("reloc."))}function uo(n,e={}){let t=[],r=null;ba(n)&&t.push("contains asyncify_"),e.expectedAbi!==void 0&&e.expectedAbi!==null&&(r=Pa(n),r!==null&&r!==e.expectedAbi&&t.push(`ABI ${r}, expected ${e.expectedAbi}`));let i=new Set(La(n));if(e.requiredExports){let E=e.requiredExports.filter(A=>!i.has(A));E.length>0&&t.push(`missing required exports: ${E.join(", ")}`)}let s=ha.filter(E=>i.has(E)),o=Ta(n),a=lo(n),c=ht.filter(({module:E,name:A})=>o.includes(`${E}.${A}`)),l=a.filter(E=>E===kt).length,d=a.filter(E=>E===ve).length,u=a.filter(E=>E===re).length,p=a.filter(E=>E===Ee).length,m=a.filter(E=>E===Z).length,f=a.filter(E=>E===X).length,h=a.filter(E=>E===ft).length,g=o.includes(`${Ir}.${xr}`),_=s.length>0||c.length>0||l>0||d>0||u>0||p>0||m>0||f>0||h>0||g;if(e.expectedAbi!==void 0&&e.expectedAbi!==null&&_&&r===null&&t.push(`ABI ${e.expectedAbi} fork artifact is missing __abi_version; the activation-state capability epoch cannot be verified`),e.forbidForkInstrumentation&&_&&t.push("contains ABI 43 wasm-fork-instrument metadata, imports, or exports"),(e.requireForkInstrumentation??!za(n))&&(_||o.includes("kernel.kernel_fork")))try{t.push(...va(pa(n)))}catch(E){t.push(`cannot validate ABI 43 fork-artifact contract: ${E instanceof Error?E.message:String(E)}`)}return t}function ka(n,e){let t=new Uint8Array(n);if(t.length<8)return null;let r=0,i=null,s=null,o=8;for(;o=c)return null;let h=a;for(let y=0;y=f)return null;let[h,g]=v(t,m);m+=g;for(let _=0;_f)return null}return m}function p(m,f=0){if(f>4)return null;let h=d(m);if(!h)return null;let g=u(h.start,h.end);if(g===null)return null;let _=g,y=h.end;for(;_=32&&E<=38||E===208){let[,A]=v(t,_);_+=A}else if(E>=40&&E<=62)_=Ut(t,_);else if(E===63||E===64)_++;else if(E===66){let[,A]=ao(t,_);_+=A}else if(E===67)_+=4;else if(E===68)_+=8;else if(E===252||E===253||E===254){let A=ua(E,t,_);if(A===null)return null;_=A}}return null}return p(i)}function Pa(n){return ka(n,"__abi_version")}var Na=ArrayBuffer,j=Uint8Array,br=Uint16Array,Fa=Int16Array;var zr=Int32Array,wn=function(n,e,t){if(j.prototype.slice)return j.prototype.slice.call(n,e,t);(e==null||e<0)&&(e=0),(t==null||t>n.length)&&(t=n.length);var r=new j(t-e);return r.set(n.subarray(e,t)),r},Gt=function(n,e,t,r){if(j.prototype.fill)return j.prototype.fill.call(n,e,t,r);for((t==null||t<0)&&(t=0),(r==null||r>n.length)&&(r=n.length);tn.length)&&(r=n.length);t2046MB)","invalid block type","FSE accuracy too high","match distance too far back","unexpected EOF"],J=function(n,e,t){var r=new Error(e||Ma[n]);if(r.code=n,Error.captureStackTrace&&Error.captureStackTrace(r,J),!t)throw r;return r},fo=function(n,e,t){for(var r=0,i=0;r>>0},Ka=function(n,e){var t=n[0]|n[1]<<8|n[2]<<16;if(t==3126568&&n[3]==253){var r=n[4],i=r>>5&1,s=r>>2&1,o=r&3,a=r>>6;r&8&&J(0);var c=6-i,l=o==3?4:o,d=fo(n,c,l);c+=l;var u=a?1<>3);m=f+(f>>3)*(n[5]&7)}m>2145386496&&J(1);var h=new j((e==1?p||m:e?0:m)+12);return h[0]=1,h[4]=4,h[8]=8,{b:c+u,y:0,l:0,d,w:e&&e!=1?e:h.subarray(12),e:m,o:new zr(h.buffer,0,3),u:p,c:s,m:Math.min(131072,m)}}else if((t>>4|n[3]<<20)==25481893)return Da(n,4)+8;J(0)},Qe=function(n){for(var e=0;1<t&&J(3);for(var s=1<0;){var y=Qe(o+1),E=r>>3,A=(1<>(r&7)&A,S=(1<S&&(w-=O)),p[++a]=--w,w==-1?(o+=w,g[--d]=a):o-=w,!w)do{var R=r>>3;c=(n[R]|n[R+1]<<8)>>(r&7)&3,r+=2,a+=c}while(c==3)}(a>255||o)&&J(0);for(var T=0,L=(s>>1)+(s>>3)+3,D=s-1,q=0;q<=a;++q){var F=p[q];if(F<1){m[q]=-F;continue}for(l=0;l=d)}}for(T&&J(0),l=0;l>3,{b:i,s:g,n:_,t:f}]},Ba=function(n,e){var t=0,r=-1,i=new j(292),s=n[e],o=i.subarray(0,256),a=i.subarray(256,268),c=new br(i.buffer,268);if(s<128){var l=Ht(n,e+1,6),d=l[0],u=l[1];e+=s;var p=d<<3,m=n[e];m||J(0);for(var f=0,h=0,g=u.b,_=g,y=(++e<<3)-8+Qe(m);y-=g,!(y>3;if(f+=(n[E]|n[E+1]<<8)>>(y&7)&(1<>3,h+=(n[E]|n[E+1]<<8)>>(y&7)&(1<<_)-1,o[++r]=u.s[h],g=u.n[f],f=u.t[f],_=u.n[h],h=u.t[h]}++r>255&&J(0)}else{for(r=s-127;t>4,o[t+1]=A&15}++e}var w=0;for(t=0;t11&&J(0),w+=S&&1<0;--t){var q=c[t];Gt(D,t,q,c[t-1]=q+a[t]*(1<a&&u>3,m=(n[p]|n[p+1]<<8|n[p+2]<<16)>>(d&7);c=(c<>2,o=s<<1,a=s+o;Wt(n.subarray(r,r+=n[0]|n[1]<<8),e.subarray(0,s),t),Wt(n.subarray(r,r+=n[2]|n[3]<<8),e.subarray(s,o),t),Wt(n.subarray(r,r+=n[4]|n[5]<<8),e.subarray(o,a),t),Wt(n.subarray(r),e.subarray(a),t)},qa=function(n,e,t){var r,i=e.b,s=n[i],o=s>>1&3;e.l=s&1;var a=s>>3|n[i+1]<<5|n[i+2]<<13,c=(i+=3)+a;if(o==1)return i>=n.length?void 0:(e.b=i+1,t?(Gt(t,n[i],e.y,e.y+=a),t):Gt(new j(a),n[i]));if(!(c>n.length)){if(o==0)return e.b=c,t?(t.set(n.subarray(i,c),e.y),e.y+=a,t):wn(n,i,c);if(o==2){var l=n[i],d=l&3,u=l>>2&3,p=l>>4,m=0,f=0;d<2?u&1?p|=n[++i]<<4|(u&2&&n[++i]<<12):p=l>>3:(f=u,u<2?(p|=(n[++i]&63)<<4,m=n[i]>>6|n[++i]<<2):u==2?(p|=n[++i]<<4|(n[++i]&3)<<12,m=n[i]>>2|n[++i]<<6):(p|=n[++i]<<4|(n[++i]&63)<<12,m=n[i]>>6|n[++i]<<2|n[++i]<<10)),++i;var h=t?t.subarray(e.y,e.y+e.m):new j(e.m),g=h.length-p;if(d==0)h.set(n.subarray(i,i+=p),g);else if(d==1)Gt(h,n[i++],g);else{var _=e.h;if(d==2){var y=Ba(n,i);m+=i-(i=y[0]),e.h=_=y[1]}else _||J(0);(f?Va:Wt)(n.subarray(i,i+=m),h.subarray(g),_)}var E=n[i++];if(E){E==255?E=(n[i++]|n[i++]<<8)+32512:E>127&&(E=E-128<<8|n[i++]);var A=n[i++];A&3&&J(0);for(var w=[Ua,Wa,$a],S=2;S>-1;--S){var O=A>>(S<<1)+2&3;if(O==1){var x=new j([0,0,n[i++]]);w[S]={s:x.subarray(2,3),n:x.subarray(0,1),t:new br(x.buffer,0,1),b:0}}else O==2?(r=Ht(n,i,9-(S&1)),i=r[0],w[S]=r[1]):O==3&&(e.t||J(0),w[S]=e.t[S])}var R=e.t=w,T=R[0],L=R[1],D=R[2],q=n[c-1];q||J(0);var F=(c<<3)-8+Qe(q)-D.b,z=F>>3,U=0,ue=(n[z]|n[z+1]<<8)>>(F&7)&(1<>3;var C=(n[z]|n[z+1]<<8)>>(F&7)&(1<>3;var H=(n[z]|n[z+1]<<8)>>(F&7)&(1<>3;var lt=1<>>(F&7)<-1);z=(F-=On[je])>>3;var Me=Ha[je]+((n[z]|n[z+1]<<8|n[z+2]<<16)>>(F&7)&(1<>3;var Je=Ga[bt]+((n[z]|n[z+1]<<8|n[z+2]<<16)>>(F&7)&(1<>3,ue=D.t[ue]+((n[z]|n[z+1]<<8)>>(F&7)&(1<>3,H=T.t[H]+((n[z]|n[z+1]<<8)>>(F&7)&(1<>3,C=L.t[C]+((n[z]|n[z+1]<<8)>>(F&7)&(1<3)e.o[2]=e.o[1],e.o[1]=e.o[0],e.o[0]=ge-=3;else{var ut=ge-(Je!=0);ut?(ge=ut==3?e.o[0]-1:e.o[ut],ut>1&&(e.o[2]=e.o[1]),e.o[1]=e.o[0],e.o[0]=ge):ge=e.o[0]}for(var S=0;SMe&&(Ke=Me);for(var S=0;Sja)throw Vt("EOVERFLOW","file offset is outside signed i64");return n}function Qa(n){if(xn(n)<0n)throw Vt("EINVAL","negative positioned I/O offset");return n}function Rn(n){let e=xn(n);if(e_o)throw Vt("EOVERFLOW","backend cannot represent the file offset exactly");return mo(e)}function vn(n){let e=Qa(n);return Rn(e)}function yo(n){if(n===null)return null;let e=xn(n);if(e<0n)throw Vt("EINVAL","negative file-size limit");return e>_o?null:mo(e)}function Tn(n){let e=new Error(`EINVAL: pathconf name ${n} is not associated with this object`);throw e.code="EINVAL",e}function Ln(n,e,t){switch(e){case V.LINK_MAX:return null;case V.NAME_MAX:return 255;case V.PATH_MAX:return Ji;case V.CHOWN_RESTRICTED:return 1;case V.NO_TRUNC:return 1;case V.ASYNC_IO:return(n.mode&61440)===32768?1:Tn(e);case V.SYNC_IO:case V.PRIO_IO:case V.FILESIZEBITS:case V.REC_INCR_XFER_SIZE:case V.REC_MAX_XFER_SIZE:case V.REC_MIN_XFER_SIZE:case V.REC_XFER_ALIGN:case V.ALLOC_SIZE_MIN:case V.SYMLINK_MAX:case V.FALLOC:return null;case V.POSIX2_SYMLINKS:return t.supportsSymlinks?1:null;case V.TEXTDOMAIN_MAX:return 255;case V.TIMESTAMP_RESOLUTION:return t.timestampResolutionNs;case V.PIPE_BUF:{let r=n.mode&61440;return r===4096||r===16384?null:Tn(e)}case V.MAX_CANON:case V.MAX_INPUT:case V.VDISABLE:case V.SOCK_MAXBUF:return Tn(e);default:{let r=new Error(`EINVAL: invalid pathconf name ${e}`);throw r.code="EINVAL",r}}}var kr=Math.floor(160),bn=1397114451,zn=1,qt=32768,G=16384,pt=40960,W=61440,ec=2048,tc=1024,rc=73,go=4294967295,et=0,Eo=1;var er=64,Kn=128,rr=512,nc=1024,ic=65536,Zt=3,oc=0,sc=1,ac=2,P=8,cc=-1,we=-2,B=-5,te=-9,Cn=-16,St=-17,Le=-20,rt=-21,Y=-22,To=-24,nt=-27,ie=-28,Mn=-36,Dn=-39,Lo=-40,bo=-75,kn=0,Pn=4,Pr=8,mt=12,We=16,_t=20,Nr=24,tt=28,Fr=32,So=36,Cr=40,lc=44,uc=48,dc=52,Nn=56,Mr=60,Dr=64,Xt=68,wo=72,yt=0,N=8,K=12,k=16,de=24,Q=32,Yt=40,ne=48,jt=88,gt=92,Jt=96,Qt=100,ce=104,Ge=112,Ao=116,le=120,Oo=4,Te=8,Io=16,xo=20,Ro=-2147483648,fc=2147483647,hc=1034+1024*1024,He=hc*4096,pc={[we]:"No such file or directory",[B]:"I/O error",[te]:"Bad file descriptor",[Cn]:"Device or resource busy",[St]:"File exists",[Le]:"Not a directory",[rt]:"Is a directory",[Y]:"Invalid argument",[To]:"Too many open files",[nt]:"File too large",[ie]:"No space left on device",[Mn]:"File name too long",[Dn]:"Directory not empty",[Lo]:"Too many symbolic links",[bo]:"Value too large for data type"},I=class extends Error{constructor(t,r){super(r||pc[t]||`Error ${t}`);this.code=t;this.name="SFSError"}code},pe=new TextEncoder,tr=new TextDecoder,vo=pe.encode("..");function Fn(n){return n==="."||n===".."}function Et(n){return n.buffer instanceof SharedArrayBuffer?tr.decode(new Uint8Array(n)):tr.decode(n)}function Ve(n){return n+3&-4}var be=class n{constructor(e){this.buffer=e;this.view=new DataView(e),this.i32=new Int32Array(e),this.u8=new Uint8Array(e)}buffer;view;i32;u8;dirIndexes=new Map;blockAllocHint=0;inodeAllocHint=2;atomicsWaitAllowed;static DIR_INDEX_MIN_SIZE=64*1024;static mkfs(e,t){let r=e.byteLength;if(r<4096*16)throw new I(Y);let i=Math.floor(r/4096),s=t?Math.floor(t/4096):i*4,o=Math.floor(s/4);o<32&&(o=32),o=Math.ceil(o/32)*32;let a=Math.ceil(o/(4096*8)),c=Math.ceil(s/(4096*8)),l=Math.ceil(o*128/4096),d=1,u=d+a,p=u+c,m=p+l;if(m>=i){let x=(m+1)*4096;try{e.grow(x)}catch{throw new I(ie)}if(i=Math.floor(e.byteLength/4096),m>=i)throw new I(ie)}new Uint8Array(e).fill(0);let f=new n(e);f.w32(kn,bn),f.w32(Pn,zn),f.w32(Pr,4096),f.w32(mt,i),f.w32(We,o),f.w32(tt,d),f.w32(Fr,u),f.w32(So,p),f.w32(Cr,m),f.w32(lc,a),f.w32(uc,c),f.w32(dc,l),f.w32(Xt,s),f.w32(wo,256);let h=u*4096;for(let x=0;x>2)+(x>>5);f.i32[R]|=1<<(x&31)}let g=i-m;Atomics.store(f.i32,_t>>2,g),f.blockAllocHint=m;let _=d*4096;f.i32[_>>2]|=3,Atomics.store(f.i32,Nr>>2,o-2),f.inodeAllocHint=2;let y=f.inodeOffset(1);f.w32(y+N,G|493),f.w32(y+K,2),f.w64(y+ce,1);let E=f.blockAlloc();if(E<0)throw new I(ie);f.w32(y+ne,E);let A=E*4096,w=Ve(P+1),S=Ve(P+2);f.w32(A,1),f.view.setUint16(A+4,w,!0),f.view.setUint16(A+6,1,!0),f.u8[A+P]=46;let O=A+w;return f.w32(O,1),f.view.setUint16(O+4,S,!0),f.view.setUint16(O+6,2,!0),f.u8[O+P]=46,f.u8[O+P+1]=46,f.w64(y+k,w+S),Atomics.store(f.i32,Nn>>2,1),f}static inspectImageCapacity(e){if(e.byteLengththis.snapshotBytesUnlocked(e))}snapshotState(e){return this.withNamespaceLock(()=>({bytes:this.snapshotBytesUnlocked(e),identities:this.collectIdentityStateUnlocked()}))}identityState(){return this.withNamespaceLock(()=>this.collectIdentityStateUnlocked())}snapshotBytesUnlocked(e){let t=e?.normalizeTimestampsMs;if(t!==void 0&&(!Number.isSafeInteger(t)||t<0))throw new I(Y,"Snapshot timestamp must be a non-negative safe integer in milliseconds");let r=t===void 0?void 0:BigInt(t);for(let a=0;a>2)!==0)throw new I(Cn,"Cannot save a VFS image with open descriptors")}let i=this.r32(We);for(let a=0;a=1&&this.inodeIsAllocated(a)?r:0n;o.setBigUint64(c+Yt,l,!0),o.setBigUint64(c+de,l,!0),o.setBigUint64(c+Q,l,!0)}}return s}collectIdentityStateUnlocked(){let e=new Map,t=this.inodeOffset(1),r=this.r64(t+ce);e.set(`1:${r}`,{ino:1,generation:r,dataSequence:Atomics.load(this.i32,t+le>>2)>>>0,mode:this.r32(t+N),linkCount:this.r32(t+K),size:this.r64(t+k),uid:this.r32(t+Jt),gid:this.r32(t+Qt),paths:["/"]});let i=[{ino:1,path:"/"}],s=new Set;for(;i.length>0;){let o=i.pop();if(s.has(o.ino))throw new I(B);s.add(o.ino);let a=this.inodeOffset(o.ino);if((this.r32(a+N)&W)!==G)throw new I(B);let c=this.r64(a+k),l=0;for(;l>2)>>>0,mode:T,linkCount:this.r32(S+K),size:this.r64(S+k),uid:this.r32(S+Jt),gid:this.r32(S+Qt),...(T&W)===pt?{symlinkTarget:this.readSymlinkInodeUnlocked(_)}:{},paths:[]},e.set(x,R)}R.paths.push(w),(this.r32(S+N)&W)===G&&i.push({ino:_,path:w})}}h+=y}l+=f}}return e}statfs(){let e=this.r32(Pr),t=this.r32(mt),r=this.r32(Xt),i=typeof this.buffer.maxByteLength=="number"?this.buffer.maxByteLength:this.buffer.byteLength,s=Math.floor(i/e),o=Math.max(t,Math.min(r,s)),a=Atomics.load(this.i32,_t>>2),c=Math.max(0,o-t);return{blockSize:e,totalBlocks:o,freeBlocks:a+c,totalInodes:this.r32(We),freeInodes:Atomics.load(this.i32,Nr>>2),maxName:255}}r32(e){return this.view.getUint32(e,!0)}w32(e,t){this.view.setUint32(e,t,!0)}r64(e){return Number(this.view.getBigUint64(e,!0))}w64(e,t){this.view.setBigUint64(e,BigInt(t),!0)}waitForAtomicChange(e,t){if(this.atomicsWaitAllowed!==!1)try{Atomics.wait(this.i32,e,t),this.atomicsWaitAllowed=!0;return}catch(r){if(!(r instanceof TypeError))throw r;this.atomicsWaitAllowed=!1}for(;Atomics.load(this.i32,e)===t;);}resetAllocationHints(){this.blockAllocHint=this.findNextFreeBlockHint(),this.inodeAllocHint=this.findNextFreeInodeHint()}findNextFreeBlockHint(){let e=this.r32(mt),t=this.r32(Cr),r=this.r32(Fr)*4096;for(let i=t;i>2)+(i>>5),o=i&31;if((Atomics.load(this.i32,s)&1<>2)+(r>>5),s=r&31;if((Atomics.load(this.i32,i)&1<>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}sbUnlock(){let e=Mr>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}namespaceLock(){let e=Dr>>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}namespaceUnlock(){let e=Dr>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}withNamespaceLock(e){this.namespaceLock();try{return e()}finally{this.namespaceUnlock()}}resetRestoredRuntimeState(){Atomics.store(this.i32,Mr>>2,0),Atomics.store(this.i32,Dr>>2,0),this.u8.fill(0,256,4096);let e=this.r32(We),t=this.r32(tt)*4096;for(let r=0;r>5)*4)&1<<(r&31))===0||this.r32(i+K)!==0)continue;let o=this.r32(i+N),a=this.r64(i+k);(o&W)===pt&&a<=40?(this.u8.fill(0,i+ne,i+ne+40),this.w64(i+k,0)):this.inodeTruncate(r,0),this.inodeFree(r)}}blockAlloc(){let e=this.r32(mt),t=this.r32(Fr)*4096,r=this.r32(Cr),i=this.blockAllocHint>=r&&this.blockAllocHint>2)+(a>>5),l=a&31,d=Atomics.load(this.i32,c);if(d&1<>2,1),this.blockAllocHint=a+1>2)+(e>>5),i=e&31;for(;;){let s=Atomics.load(this.i32,r),o=s&~(1<>2,1),e>=this.r32(Cr)&&e>2)>0)return 0;let e=this.r32(mt),t=this.r32(Xt),r=this.r32(wo),i=e+r;if(i>t&&(i=t,r=i-e,r===0))return ie;let s=i*4096;if(this.buffer.byteLength>2,r),Atomics.add(this.i32,Nn>>2,1),this.blockAllocHint=e,0}finally{this.sbUnlock()}}inodeOffset(e){let r=this.r32(So)+Math.floor(e/32),i=e%32*128;return r*4096+i}inodeAlloc(){let e=this.r32(We),t=this.r32(tt)*4096,r=this.inodeAllocHint>=2&&this.inodeAllocHint>2)+(o>>5),c=o&31,l=Atomics.load(this.i32,a);if(l&1<>2,1),this.inodeAllocHint=o+1>2,1)+1}inodeFree(e){let r=(this.r32(tt)*4096>>2)+(e>>5),i=e&31;for(;;){let s=Atomics.load(this.i32,r);if((s&1<>2,1),e>=2&&e0&&this.w32(r+Ge,i-1),i<=1&&this.r32(r+K)===0&&(this.inodeTruncate(e,0),t=!0)}finally{this.inodeWriteUnlock(e)}t&&this.inodeFree(e)}inodeDropLinkRefLocked(e){let t=this.inodeOffset(e),r=this.r32(t+K);return r>1?(this.w32(t+K,r-1),this.w64(t+Q,Date.now()),!1):this.inodeOrphanLocked(e)}inodeOrphanLocked(e){let t=this.inodeOffset(e);if(this.w32(t+K,0),this.w64(t+Q,Date.now()),this.r32(t+Ge)>0)return!1;let r=this.r32(t+N),i=this.r64(t+k);return(r&W)===pt&&i<=40?(this.u8.fill(0,t+ne,t+ne+40),this.w64(t+k,0)):this.inodeTruncate(e,0),!0}inodeReadLock(e){let t=this.inodeOffset(e)+yt>>2;for(;;){let r=Atomics.load(this.i32,t);if(r&Ro){this.waitForAtomicChange(t,r);continue}if(Atomics.compareExchange(this.i32,t,r,r+1)===r)return}}inodeReadUnlock(e){let t=this.inodeOffset(e)+yt>>2;(Atomics.sub(this.i32,t,1)&fc)===1&&Atomics.notify(this.i32,t,1)}inodeWriteLock(e){let t=this.inodeOffset(e)+yt>>2;for(;;){let r=Atomics.load(this.i32,t);if(r!==0){this.waitForAtomicChange(t,r);continue}if(Atomics.compareExchange(this.i32,t,0,Ro)===0)return}}inodeWriteUnlock(e){let t=this.inodeOffset(e)+yt>>2;Atomics.store(this.i32,t,0),Atomics.notify(this.i32,t,1/0)}inodeBlockMap(e,t,r){let i=this.inodeOffset(e);if(t<10){let s=this.r32(i+ne+t*4);if(s!==0)return s;if(!r)return 0;let o=this.blockAllocWithGrow();return o<0||this.w32(i+ne+t*4,o),o}if(t-=10,t<1024){let s=this.r32(i+jt),o=!1;if(s===0){if(!r)return 0;if(s=this.blockAllocWithGrow(),s<0)return s;this.w32(i+jt,s),o=!0}let a=s*4096+t*4,c=this.r32(a);if(c!==0)return c;if(!r)return 0;let l=this.blockAllocWithGrow();return l<0?(o&&(this.w32(i+jt,0),this.blockFree(s)),l):(this.w32(a,l),l)}if(t-=1024,t<1024*1024){let s=Math.floor(t/1024),o=t%1024,a=this.r32(i+gt),c=!1;if(a===0){if(!r)return 0;if(a=this.blockAllocWithGrow(),a<0)return a;this.w32(i+gt,a),c=!0}let l=a*4096+s*4,d=this.r32(l),u=!1;if(d===0){if(!r)return 0;if(d=this.blockAllocWithGrow(),d<0)return c&&(this.w32(i+gt,0),this.blockFree(a)),d;this.w32(l,d),u=!0}let p=d*4096+o*4,m=this.r32(p);if(m!==0)return m;if(!r)return 0;let f=this.blockAllocWithGrow();return f<0?(u&&(this.w32(l,0),this.blockFree(d)),c&&(this.w32(i+gt,0),this.blockFree(a)),f):(this.w32(p,f),f)}return Y}inodeReadData(e,t,r,i){let s=this.inodeOffset(e),o=this.r64(s+k);if(t>=o)return 0;t+i>o&&(i=o-t);let a=0,c=0;for(;i>0;){let l=Math.floor(t/4096),d=t%4096,u=4096-d;u>i&&(u=i);let p=this.inodeBlockMap(e,l,!1);if(p<=0)r.fill(0,c,c+u);else{let m=p*4096+d;r.set(this.u8.subarray(m,m+u),c)}c+=u,t+=u,i-=u,a+=u}return a}inodeWriteData(e,t,r,i){let s=this.inodeOffset(e),o=this.r64(s+k);t>o&&this.zeroOldEofTail(e,o);let a=0,c=0;for(;i>0;){let l=Math.floor(t/4096),d=t%4096,u=4096-d;u>i&&(u=i);let p=this.inodeBlockMap(e,l,!0);if(p<0){if(a===0)return p;break}let m=p*4096+d;this.u8.set(r.subarray(c,c+u),m),c+=u,t+=u,i-=u,a+=u}if(a>0&&t>this.r64(s+k)&&this.w64(s+k,t),a>0){let l=Date.now();this.w64(s+de,l),this.w64(s+Q,l),Atomics.add(this.i32,s+le>>2,1)}return a}zeroInodeRange(e,t,r){for(;t0){let c=a*4096+s;this.u8.fill(0,c,c+o)}t+=o}}zeroOldEofTail(e,t){let r=t%4096;if(r===0)return;let i=Math.floor(t/4096),s=this.inodeBlockMap(e,i,!1);if(s<=0)return;let o=s*4096+r;this.u8.fill(0,o,s*4096+4096)}freeBlocksFrom(e,t){let r=this.inodeOffset(e);for(let o=t;o<10;o++){let a=this.r32(r+ne+o*4);a&&(this.blockFree(a),this.w32(r+ne+o*4,0))}let i=this.r32(r+jt);if(i){let o=t>10?t-10:0;for(let a=o;a<1024;a++){let c=i*4096+a*4,l=this.r32(c);l&&(this.blockFree(l),this.w32(c,0))}o===0&&(this.blockFree(i),this.w32(r+jt,0))}let s=this.r32(r+gt);if(s){let o=t>1034?t-10-1024:0,a=Math.floor(o/1024);for(let c=a;c<1024;c++){let l=s*4096+c*4,d=this.r32(l);if(!d)continue;let u=c===a?o%1024:0;for(let p=u;p<1024;p++){let m=d*4096+p*4,f=this.r32(m);f&&(this.blockFree(f),this.w32(m,0))}u===0&&(this.blockFree(d),this.w32(l,0))}a===0&&(this.blockFree(s),this.w32(r+gt,0))}}inodeTruncate(e,t,r=!1){let i=this.inodeOffset(e),s=this.r64(i+k),o=t!==s;if(t>=s){if(t>s&&this.zeroOldEofTail(e,s),this.w64(i+k,t),o||r){let c=Date.now();this.w64(i+de,c),this.w64(i+Q,c),Atomics.add(this.i32,i+le>>2,1)}return}t%4096!==0&&this.zeroInodeRange(e,t,Math.ceil(t/4096)*4096);let a=Math.ceil(t/4096);if(this.freeBlocksFrom(e,a),this.w64(i+k,t),o||r){let c=Date.now();this.w64(i+de,c),this.w64(i+Q,c),Atomics.add(this.i32,i+le>>2,1)}}validateFileSize(e){if(!Number.isSafeInteger(e)||e<0)throw new I(Y);if(e>He)throw new I(nt)}validateSeekPosition(e){if(!Number.isSafeInteger(e))throw new I(bo);if(e<0)throw new I(Y);if(e>He)throw new I(nt)}touchDirectoryMutation(e){let t=this.inodeOffset(e),r=Date.now();this.w64(t+de,r),this.w64(t+Q,r);let i=Atomics.add(this.i32,t+Ao>>2,1)+1>>>0,s=this.dirIndexes.get(e);s&&(s.mutationSequence=i,s.size=this.r64(t+k))}dirNameKey(e){return Et(e)}dirEntryNameMatches(e,t){if(this.view.getUint16(e+6,!0)!==t.length)return!1;for(let i=0;i=P&&r%4===0&&e+r<=t&&i<=r-P}inodeIsAllocated(e){let t=this.r32(We);if(e<=0||e>=t)return!1;let r=this.r32(tt)*4096;return(Atomics.load(this.i32,(r>>2)+(e>>5))&1<<(e&31))!==0}rebuildDirIndex(e,t,r,i){let s=new Map,o=[],a=0;for(;a4096-d&&(m=4096-d);let f=d;for(;f=P&&o.push({abs:h,recLen:_});f+=_}a+=m}let c={generation:t,mutationSequence:r,size:i,entries:s,free:o};return this.dirIndexes.set(e,c),c}getDirIndex(e){let t=this.inodeOffset(e),r=this.r64(t+k),i=this.r64(t+ce),s=Atomics.load(this.i32,t+Ao>>2)>>>0,o=this.dirIndexes.get(e);return o&&o.generation===i&&o.mutationSequence===s&&o.size===r?o:(o&&this.dirIndexes.delete(e),r=0;o--){let a=e.free[o];if(!(a.recLen4096-c&&(u=4096-c);let p=c;for(;pr)return-1;a=c,o+=l}return o===r?a:-1}dirAppendEntry(e,t,r,i=-1){let s=this.inodeOffset(e),o=this.r64(s+k),a=Ve(P+t.length),c=o,l=Math.floor(c/4096),d=c%4096,u=0;if(d!==0&&d+a>4096){let f=4096-d,h=0;if(f>=P){if(h=this.inodeBlockMap(e,l,!1),h<=0)return B}else if(i<0&&(i=this.findLastDirEntryInBlock(e,l,d)),i<0)return B;if(u=this.inodeBlockMap(e,l+1,!0),u<0)return u;if(f>=P){let g=h*4096+d;this.w32(g,0),this.view.setUint16(g+4,f,!0),this.view.setUint16(g+6,0,!0)}else{let _=this.view.getUint16(i+4,!0)+f;this.view.setUint16(i+4,_,!0),this.updateDirIndexRecLen(e,i,_)}c=(l+1)*4096,l++,d=0}let p;if(d===0){if(p=u||this.inodeBlockMap(e,l,!0),p<0)return p}else if(p=this.inodeBlockMap(e,l,!1),p<=0)return B;let m=p*4096+d;return this.w32(m,r),this.view.setUint16(m+4,a,!0),this.view.setUint16(m+6,t.length,!0),this.u8.set(t,m+P),this.w64(s+k,c+a),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,m,a),0}dirAddEntry(e,t,r){let i=this.getDirIndex(e);if(typeof i=="number")return i;if(i)return this.useDirIndexFreeSlot(i,e,t,r)?0:this.dirAppendEntry(e,t,r);let s=this.inodeOffset(e),o=this.r64(s+k),a=Ve(P+t.length),c=-1,l=0;for(;l4096-u&&(f=4096-u);let h=u;for(;hu+f||E>y-P)return B;if(_===0&&y>=a)return this.w32(g,r),this.view.setUint16(g+6,t.length,!0),this.u8.set(t,g+P),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,g,y),0;let A=Ve(P+E),w=y-A;if(_!==0&&w>=a){this.view.setUint16(g+4,A,!0);let S=g+A;return this.w32(S,r),this.view.setUint16(S+4,w,!0),this.view.setUint16(S+6,t.length,!0),this.u8.set(t,S+P),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,S,w),0}c=g,h+=y}l+=f}return this.dirAppendEntry(e,t,r,c)}dirRemoveEntry(e,t){let r=this.getDirIndex(e);if(typeof r=="number")return r;if(r){let a=this.dirNameKey(t),c=r.entries.get(a);if(!c)return we;if(this.r32(c.abs)===c.ino&&this.view.getUint16(c.abs+4,!0)===c.recLen&&this.view.getUint16(c.abs+6,!0)===c.nameLen&&this.dirEntryNameMatches(c.abs,t))return this.w32(c.abs,0),r.entries.delete(a),r.free.push({abs:c.abs,recLen:c.recLen}),this.touchDirectoryMutation(e),0;r.entries.delete(a)}let i=this.inodeOffset(e),s=this.r64(i+k),o=0;for(;o4096-c&&(u=4096-c);let p=c;for(;p4096-l&&(p=4096-l);let m=l;for(;m4096-o&&(l=4096-o);let d=o;for(;do+l||f>m-P)throw new I(B);if(p!==0){if(f===1&&this.u8[u+P]===46){d+=m;continue}if(f===2&&this.u8[u+P]===46&&this.u8[u+P+1]===46){d+=m;continue}return!1}d+=m}i+=l}return!0}dirIsAncestor(e,t){let r=t;for(let i=0;i<8*1024;i++){if(r===e)return!0;if(r===1)return!1;let s=this.dirLookup(r,vo);if(s<0||s===r)throw new I(B);r=s}throw new I(B)}pathResolve(e,t){if(!e.startsWith("/"))return we;let r=1,i=e.split("/").filter(o=>o.length>0),s=0;for(let o=0;o255)return Mn;let c=pe.encode(a),l;this.inodeReadLock(r);try{let p=this.inodeOffset(r);if((this.r32(p+N)&W)!==G)return Le;l=this.dirLookup(r,c)}finally{this.inodeReadUnlock(r)}if(l<0)return l;let d=this.inodeOffset(l);if((this.r32(d+N)&W)===pt&&(!(o===i.length-1)||t)){if(++s>8)return Lo;let m=this.r64(d+k),f;if(m<=40)f=Et(this.u8.subarray(d+ne,d+ne+m));else{let h=new Uint8Array(m);this.inodeReadData(l,0,h,m),f=tr.decode(h)}if(f.startsWith("/")){r=1;let h=f.split("/").filter(_=>_.length>0),g=i.slice(o+1);i.length=0,i.push(...h,...g),o=-1}else{let h=f.split("/").filter(_=>_.length>0),g=i.slice(o+1);i.length=o,i.push(...h,...g),o--}continue}r=l}return r}pathResolveParent(e){if(!e.startsWith("/"))throw new I(Y,"Path must be absolute");let t=e.split("/").filter(c=>c.length>0);if(t.length===0)throw new I(Y,"Cannot operate on /");let r=t.pop();if(r.length>255)throw new I(Mn);let i="/"+t.join("/"),s=this.pathResolve(i,!0);if(s<0)throw new I(s);let o=this.inodeOffset(s);if((this.r32(o+N)&W)!==G)throw new I(Le);return{parentIno:s,name:r}}fdAlloc(e,t,r){for(let i=0;i>2;if(Atomics.compareExchange(this.i32,o,0,1)===0)return this.w32(s+Oo,e),this.w64(s+Te,0),this.w32(s+Io,t),this.w32(s+xo,r?1:0),this.inodeAddOpenRef(e)?i:(Atomics.store(this.i32,o,0),we)}return To}fdGet(e){if(e<0||e>=kr)return null;let t=256+e*24;return Atomics.load(this.i32,t>>2)?{base:t,ino:this.r32(t+Oo),offset:this.r64(t+Te),flags:this.r32(t+Io),isDir:this.r32(t+xo)!==0}:null}fdFree(e){if(e>=0&&e>2,0)}}buildStat(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+ce),dataSequence:this.r32(t+le),mode:this.r32(t+N),linkCount:this.r32(t+K),size:this.r64(t+k),mtime:this.r64(t+de),ctime:this.r64(t+Q),atime:this.r64(t+Yt),uid:this.r32(t+Jt),gid:this.r32(t+Qt)}}namespaceEntryIdentity(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+ce),linkCount:this.r32(t+K),mode:this.r32(t+N)}}open(e,t,r=420){return this.withNamespaceLock(()=>this.openUnlocked(e,t,r))}createLazyStub(e,t){return this.withNamespaceLock(()=>{let r=this.openUnlocked(e,Eo|er,t);try{let i=this.fdGet(r);if(!i)throw new I(te);this.inodeWriteLock(i.ino);try{return this.inodeTruncate(i.ino,0,!0),this.buildStat(i.ino)}finally{this.inodeWriteUnlock(i.ino)}}finally{this.closeUnlocked(r)}})}replaceIfIdentity(e,t,r,i,s){return this.withNamespaceLock(()=>{let o=this.pathResolve(e,!0);if(o<0||o!==t)return!1;let a=this.inodeOffset(o);if(this.r64(a+ce)!==r||this.r32(a+le)!==i||(this.r32(a+N)&W)!==qt)return!1;this.validateFileSize(s.byteLength),this.inodeWriteLock(o);try{if(this.r64(a+ce)!==r||this.r32(a+le)!==i||this.r64(a+k)!==0)return!1;let c=this.r64(a+de),l=this.r64(a+Q);this.inodeTruncate(o,0,!0);let d=s.byteLength>0?this.inodeWriteData(o,0,s,s.byteLength):0;if(d!==s.byteLength)throw this.inodeTruncate(o,0,!0),Atomics.store(this.i32,a+le>>2,i),this.w64(a+de,c),this.w64(a+Q,l),new I(d<0?d:ie);return!0}finally{this.inodeWriteUnlock(o)}})}replaceManyIfIdentities(e,t=[]){return e.length===0&&t.length===0?!0:this.withNamespaceLock(()=>{let r=[],i=new Set,s=a=>{let c=this.pathResolve(a.path,!1);if(c<0||c!==a.expectedIno)return!1;let l=this.inodeOffset(c);return this.r64(l+ce)===a.expectedGeneration&&this.r32(l+le)===a.expectedDataSequence&&this.r32(l+N)===a.expectedMode&&this.r32(l+K)===a.expectedLinkCount&&this.r64(l+k)===a.expectedSize&&this.r32(l+Jt)===a.expectedUid&&this.r32(l+Qt)===a.expectedGid};for(let a of t)if(!s(a))return!1;for(let a of e){this.validateFileSize(a.data.byteLength);let c=-1;for(let l of a.paths){let d=this.pathResolve(l,!0);if(d!==a.expectedIno)continue;let u=this.inodeOffset(d);if(this.r64(u+ce)===a.expectedGeneration&&this.r32(u+le)===a.expectedDataSequence&&(this.r32(u+N)&W)===qt&&this.r64(u+k)===0){c=d;break}}if(c<0)return!1;if(i.has(c))throw new I(Y,"duplicate conditional replacement inode");i.add(c),r.push({...a,ino:c})}let o=[...i].sort((a,c)=>a-c);for(let a of o)this.inodeWriteLock(a);try{for(let l of r){let d=this.inodeOffset(l.ino);if(this.r64(d+ce)!==l.expectedGeneration||this.r32(d+le)!==l.expectedDataSequence||(this.r32(d+N)&W)!==qt||this.r64(d+k)!==0)return!1}for(let l of t)if(!s(l))return!1;let a=r.map(l=>{let d=this.inodeOffset(l.ino);return{ino:l.ino,dataSequence:this.r32(d+le),mtime:this.r64(d+de),ctime:this.r64(d+Q)}}),c=0;try{for(let l of r){c++,this.inodeTruncate(l.ino,0,!0);let d=l.data.byteLength>0?this.inodeWriteData(l.ino,0,l.data,l.data.byteLength):0;if(d!==l.data.byteLength)throw new I(d<0?d:ie)}}catch(l){for(let d=c-1;d>=0;d--){let u=a[d],p=this.inodeOffset(u.ino);this.inodeTruncate(u.ino,0,!0),Atomics.store(this.i32,p+le>>2,u.dataSequence),this.w64(p+de,u.mtime),this.w64(p+Q,u.ctime)}throw l}return!0}finally{for(let a=o.length-1;a>=0;a--)this.inodeWriteUnlock(o[a])}})}openUnlocked(e,t,r=420){let i=t&Zt,s=(t&er)!==0,o=(t&Kn)!==0;if(s&&o){let u=this.pathResolve(e,!1);if(u>=0)throw new I(St);if(u!==we)throw new I(u)}let a=this.pathResolve(e,!0);if(a<0&&a===we&&s){let{parentIno:u,name:p}=this.pathResolveParent(e);this.inodeWriteLock(u);try{let m=pe.encode(p),f=this.dirLookup(u,m);if(f>=0){if(o)throw new I(St);a=f}else{let h=this.inodeAlloc();if(h<0)throw new I(ie);let g=this.inodeOffset(h);this.w32(g+N,qt|r&4095),this.w32(g+K,1),this.w64(g+k,0);let _=Date.now();this.w64(g+Yt,_),this.w64(g+de,_),this.w64(g+Q,_);let y=this.dirAddEntry(u,m,h);if(y<0)throw this.inodeFree(h),new I(y);a=h}}finally{this.inodeWriteUnlock(u)}}if(a<0)throw new I(a);let c=this.inodeOffset(a),l=this.r32(c+N);if((l&W)===G&&i!==et)throw new I(rt);if(t&ic&&(l&W)!==G)throw new I(Le);if(t&rr){if((l&W)===G)throw new I(rt);this.inodeWriteLock(a),this.inodeTruncate(a,0,!0),this.inodeWriteUnlock(a)}let d=this.fdAlloc(a,t,!1);if(d<0)throw new I(d);return d}close(e){this.withNamespaceLock(()=>this.closeUnlocked(e))}closeUnlocked(e){let t=this.fdGet(e);if(!t)throw new I(te);this.fdFree(e),this.inodeDropOpenRef(t.ino)}read(e,t){let r=this.fdGet(e);if(!r)throw new I(te);let i=this.inodeOffset(r.ino);if((this.r32(i+N)&W)===G)throw new I(rt);this.inodeReadLock(r.ino);try{let o=this.inodeReadData(r.ino,r.offset,t,t.length),a=256+e*24;return this.w64(a+Te,r.offset+o),o}finally{this.inodeReadUnlock(r.ino)}}readAt(e,t,r){let i=this.fdGet(e);if(!i)throw new I(te);let s=this.inodeOffset(i.ino);if((this.r32(s+N)&W)===G)throw new I(rt);this.validateSeekPosition(r),this.inodeReadLock(i.ino);try{return this.inodeReadData(i.ino,r,t,t.length)}finally{this.inodeReadUnlock(i.ino)}}write(e,t){let r=this.fdGet(e);if(!r)throw new I(te);if((r.flags&Zt)===et)throw new I(te);this.inodeWriteLock(r.ino);try{let s=r.offset;if(r.flags&nc){let c=this.inodeOffset(r.ino);s=this.r64(c+k)}if(!Number.isSafeInteger(s)||s<0)throw new I(Y);if(s>He||t.length>He-s)throw new I(nt);let o=this.inodeWriteData(r.ino,s,t,t.length);if(o<0)return o;let a=256+e*24;return this.w64(a+Te,s+o),o}finally{this.inodeWriteUnlock(r.ino)}}append(e,t,r){let i=this.fdGet(e);if(!i)throw new I(te);if((i.flags&Zt)===et)throw new I(te);if(r!==null&&(!Number.isSafeInteger(r)||r<0))throw new I(Y);this.inodeWriteLock(i.ino);try{let o=this.inodeOffset(i.ino),a=this.r64(o+k);if(!Number.isSafeInteger(a)||a<0)throw new I(Y);if(a>He)throw new I(nt);if(r!==null&&a>=r){let f=256+e*24;return this.w64(f+Te,a),{written:0,end:a}}let c=r===null?t.length:Math.min(t.length,r-a),l=He-a;if(c>l)throw new I(nt);let d=t.subarray(0,c),u=this.inodeWriteData(i.ino,a,d,d.length);if(u<0)throw new I(u);let p=256+e*24,m=a+u;return this.w64(p+Te,m),{written:u,end:m}}finally{this.inodeWriteUnlock(i.ino)}}writeAt(e,t,r){let i=this.fdGet(e);if(!i)throw new I(te);if((i.flags&Zt)===et)throw new I(te);this.validateSeekPosition(r),this.inodeWriteLock(i.ino);try{if(r>He||t.length>He-r)throw new I(nt);return this.inodeWriteData(i.ino,r,t,t.length)}finally{this.inodeWriteUnlock(i.ino)}}lseek(e,t,r){let i=this.fdGet(e);if(!i)throw new I(te);let s;if(r===oc)s=t;else if(r===sc)s=i.offset+t;else if(r===ac){let a=this.inodeOffset(i.ino);s=this.r64(a+k)+t}else throw new I(Y);this.validateSeekPosition(s);let o=256+e*24;return this.w64(o+Te,s),s}ftruncate(e,t){let r=this.fdGet(e);if(!r)throw new I(te);if((r.flags&Zt)===et)throw new I(te);this.validateFileSize(t),this.inodeWriteLock(r.ino);try{this.inodeTruncate(r.ino,t,!0)}finally{this.inodeWriteUnlock(r.ino)}}fstat(e){let t=this.fdGet(e);if(!t)throw new I(te);this.inodeReadLock(t.ino);try{return this.buildStat(t.ino)}finally{this.inodeReadUnlock(t.ino)}}stat(e){return this.withNamespaceLock(()=>this.statUnlocked(e))}statUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new I(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}lstat(e){return this.withNamespaceLock(()=>this.lstatUnlocked(e))}lstatUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new I(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}unlink(e){return this.withNamespaceLock(()=>this.unlinkUnlocked(e))}unlinkUnlocked(e){let{parentIno:t,name:r}=this.pathResolveParent(e),i=pe.encode(r),s=e.length>1&&e.endsWith("/");this.inodeWriteLock(t);try{let o=this.dirLookup(t,i);if(o<0)throw new I(o);let a=this.inodeOffset(o),c=this.r32(a+N);if(s&&(c&W)!==G)throw new I(Le);if((c&W)===G)throw new I(rt);let l=this.namespaceEntryIdentity(o),d=this.dirRemoveEntry(t,i);if(d<0)throw new I(d);let u=!1;this.inodeWriteLock(o);try{u=this.inodeDropLinkRefLocked(o)}finally{this.inodeWriteUnlock(o)}return u&&this.inodeFree(o),l}finally{this.inodeWriteUnlock(t)}}rename(e,t){return this.withNamespaceLock(()=>this.renameUnlocked(e,t))}renameUnlocked(e,t){let{parentIno:r,name:i}=this.pathResolveParent(e),{parentIno:s,name:o}=this.pathResolveParent(t);if(Fn(i)||Fn(o))throw new I(Y);let a=pe.encode(i),c=pe.encode(o),l=e.length>1&&e.endsWith("/"),d=t.length>1&&t.endsWith("/"),u=Math.min(r,s),p=Math.max(r,s);this.inodeWriteLock(u),u!==p&&this.inodeWriteLock(p);try{let m=this.dirLookup(r,a);if(m<0)throw new I(m);let f=this.inodeOffset(m),g=this.r32(f+N)&W,_=this.namespaceEntryIdentity(m);if((l||d)&&g!==G)throw new I(Le);if(g===G&&this.dirIsAncestor(m,s))throw new I(Y);let y=this.dirLookup(s,c),E=!1,A;if(y>=0){if(y===m)return{source:_,replaced:_};A=this.namespaceEntryIdentity(y);let S=this.inodeOffset(y),x=this.r32(S+N)&W;if(g===G&&x!==G)throw new I(Le);if(g!==G&&x===G)throw new I(rt);let R=!1,T=y===r||y===s;T||this.inodeWriteLock(y);try{if(x===G&&!this.dirIsEmpty(y))throw new I(Dn);let L=this.dirReplaceEntryIno(s,c,m);if(L<0)throw new I(L);R=x===G?this.inodeOrphanLocked(y):this.inodeDropLinkRefLocked(y)}finally{T||this.inodeWriteUnlock(y)}R&&this.inodeFree(y),E=x===G}else{let S=this.dirAddEntry(s,c,m);if(S<0)throw new I(S)}let w=this.dirRemoveEntry(r,a);if(w<0)throw new I(w);if(g===G){if(r!==s){let S=this.inodeOffset(r);this.w32(S+K,this.r32(S+K)-1);let O=this.inodeOffset(s);this.w32(O+K,this.r32(O+K)+1),this.inodeWriteLock(m);try{let x=this.dirReplaceEntryIno(m,vo,s);if(x<0)throw new I(x);this.w64(f+Q,Date.now())}finally{this.inodeWriteUnlock(m)}}if(E){let S=this.inodeOffset(s);this.w32(S+K,this.r32(S+K)-1)}}else if(E){let S=this.inodeOffset(s);this.w32(S+K,this.r32(S+K)-1)}return{source:_,replaced:A}}finally{u!==p&&this.inodeWriteUnlock(p),this.inodeWriteUnlock(u)}}mkdir(e,t=493){this.withNamespaceLock(()=>this.mkdirUnlocked(e,t))}mkdirUnlocked(e,t=493){let{parentIno:r,name:i}=this.pathResolveParent(e),s=pe.encode(i);this.inodeWriteLock(r);try{if(this.dirLookup(r,s)>=0)throw new I(St);let a=this.inodeAlloc();if(a<0)throw new I(ie);let c=this.inodeOffset(a);this.w32(c+N,G|t),this.w32(c+K,2),this.w64(c+k,0);let l=Date.now();this.w64(c+Yt,l),this.w64(c+de,l),this.w64(c+Q,l);let d=this.blockAllocWithGrow();if(d<0)throw this.inodeFree(a),new I(ie);this.w32(c+ne,d);let u=d*4096,p=Ve(P+1),m=Ve(P+2);this.w32(u,a),this.view.setUint16(u+4,p,!0),this.view.setUint16(u+6,1,!0),this.u8[u+P]=46;let f=u+p;this.w32(f,r),this.view.setUint16(f+4,m,!0),this.view.setUint16(f+6,2,!0),this.u8[f+P]=46,this.u8[f+P+1]=46,this.w64(c+k,p+m);let h=this.dirAddEntry(r,s,a);if(h<0)throw this.blockFree(d),this.inodeFree(a),new I(h);let g=this.inodeOffset(r);this.w32(g+K,this.r32(g+K)+1)}finally{this.inodeWriteUnlock(r)}}rmdir(e){this.withNamespaceLock(()=>this.rmdirUnlocked(e))}rmdirUnlocked(e){let{parentIno:t,name:r}=this.pathResolveParent(e);if(Fn(r))throw new I(Y);let i=pe.encode(r);this.inodeWriteLock(t);try{let s=this.dirLookup(t,i);if(s<0)throw new I(s);let o=this.inodeOffset(s);if((this.r32(o+N)&W)!==G)throw new I(Le);let c=!1;this.inodeWriteLock(s);try{if(!this.dirIsEmpty(s))throw new I(Dn);let d=this.dirRemoveEntry(t,i);if(d<0)throw new I(d);c=this.inodeOrphanLocked(s)}finally{this.inodeWriteUnlock(s)}c&&this.inodeFree(s);let l=this.inodeOffset(t);this.w32(l+K,this.r32(l+K)-1)}finally{this.inodeWriteUnlock(t)}}symlink(e,t){this.withNamespaceLock(()=>this.symlinkUnlocked(e,t))}symlinkUnlocked(e,t){let{parentIno:r,name:i}=this.pathResolveParent(t),s=pe.encode(i),o=pe.encode(e);this.inodeWriteLock(r);try{if(this.dirLookup(r,s)>=0)throw new I(St);let c=this.inodeAlloc();if(c<0)throw new I(ie);let l=this.inodeOffset(c);if(this.w32(l+N,pt|511),this.w32(l+K,1),o.length<=40)this.u8.set(o,l+ne),this.w64(l+k,o.length);else{this.w64(l+k,0);let u=this.inodeWriteData(c,0,o,o.length);if(u!==o.length)throw u>0&&this.inodeTruncate(c,0),this.inodeFree(c),new I(u<0?u:ie)}let d=this.dirAddEntry(r,s,c);if(d<0)throw o.length<=40?(this.u8.fill(0,l+ne,l+ne+40),this.w64(l+k,0)):this.inodeTruncate(c,0),this.inodeFree(c),new I(d)}finally{this.inodeWriteUnlock(r)}}chmod(e,t){this.withNamespaceLock(()=>this.chmodUnlocked(e,t))}chmodUnlocked(e,t){let r=this.pathResolve(e,!0);if(r<0)throw new I(r);this.inodeWriteLock(r);try{let i=this.inodeOffset(r),s=this.r32(i+N);this.w32(i+N,s&W|t&4095),this.w64(i+Q,Date.now())}finally{this.inodeWriteUnlock(r)}}fchmod(e,t){let r=this.fdGet(e);if(!r)throw new I(te);this.inodeWriteLock(r.ino);try{let i=this.inodeOffset(r.ino),s=this.r32(i+N);this.w32(i+N,s&W|t&4095),this.w64(i+Q,Date.now())}finally{this.inodeWriteUnlock(r.ino)}}chown(e,t,r){this.withNamespaceLock(()=>this.chownUnlocked(e,t,r))}chownUnlocked(e,t,r){let i=this.pathResolve(e,!0);if(i<0)throw new I(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,r)}finally{this.inodeWriteUnlock(i)}}fchown(e,t,r){let i=this.fdGet(e);if(!i)throw new I(te);this.inodeWriteLock(i.ino);try{this.chownInodeUnlocked(i.ino,t,r)}finally{this.inodeWriteUnlock(i.ino)}}lchown(e,t,r){this.withNamespaceLock(()=>this.lchownUnlocked(e,t,r))}lchownUnlocked(e,t,r){let i=this.pathResolve(e,!1);if(i<0)throw new I(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,r)}finally{this.inodeWriteUnlock(i)}}chownInodeUnlocked(e,t,r){let i=this.inodeOffset(e);t!==go&&this.w32(i+Jt,t),r!==go&&this.w32(i+Qt,r);let s=this.r32(i+N);(s&W)===qt&&(s&rc)!==0&&this.w32(i+N,s&~(ec|tc)),this.w64(i+Q,Date.now())}utimens(e,t,r,i,s){this.withNamespaceLock(()=>this.utimensUnlocked(e,t,r,i,s))}utimensUnlocked(e,t,r,i,s){let o=this.pathResolve(e,!0);if(o<0)throw new I(o);this.inodeWriteLock(o);try{let a=this.inodeOffset(o),c=1073741823,l=1073741822,d=Date.now();if(r!==l){let u=r===c?d:t*1e3+Math.floor(r/1e6);this.w64(a+Yt,u)}if(s!==l){let u=s===c?d:i*1e3+Math.floor(s/1e6);this.w64(a+de,u)}this.w64(a+Q,d)}finally{this.inodeWriteUnlock(o)}}link(e,t){return this.withNamespaceLock(()=>this.linkUnlocked(e,t))}linkUnlocked(e,t){let r=this.pathResolve(e,!1);if(r<0)throw new I(r);let i=this.inodeOffset(r);if((this.r32(i+N)&W)===G)throw new I(cc);let{parentIno:o,name:a}=this.pathResolveParent(t),c=pe.encode(a);this.inodeWriteLock(o);try{if(this.dirLookup(o,c)>=0)throw new I(St);let d=this.dirAddEntry(o,c,r);if(d<0)throw new I(d);this.inodeWriteLock(r);try{let u=this.r32(i+K);this.w32(i+K,u+1),this.w64(i+Q,Date.now())}finally{this.inodeWriteUnlock(r)}return{...this.namespaceEntryIdentity(r),linkCount:this.r32(i+K)}}finally{this.inodeWriteUnlock(o)}}readlink(e){return this.withNamespaceLock(()=>this.readlinkUnlocked(e))}readlinkUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new I(t);return this.readSymlinkInodeUnlocked(t)}readSymlinkInodeUnlocked(e){let t=this.inodeOffset(e);if((this.r32(t+N)&W)!==pt)throw new I(Y);let i=this.r64(t+k);if(i<=40)return Et(this.u8.subarray(t+ne,t+ne+i));this.inodeReadLock(e);try{let s=new Uint8Array(i);return this.inodeReadData(e,0,s,i),tr.decode(s)}finally{this.inodeReadUnlock(e)}}opendir(e){return this.withNamespaceLock(()=>this.opendirUnlocked(e))}opendirUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new I(t);let r=this.inodeOffset(t);if((this.r32(r+N)&W)!==G)throw new I(Le);let s=this.fdAlloc(t,et,!0);if(s<0)throw new I(s);return s}readdirEntry(e){return this.withNamespaceLock(()=>this.readdirEntryUnlocked(e))}readdirEntryUnlocked(e){let t=this.fdGet(e);if(!t||!t.isDir)throw new I(te);let r=this.inodeOffset(t.ino),i=this.r64(r+k);for(;t.offset=this.r32(We))throw new I(B);let h=this.r32(tt)*4096;if((this.r32(h+(d>>5)*4)&1<<(d&31))===0)throw new I(B);let _=Et(this.u8.subarray(l+P,l+P+p)),y=this.buildStat(d);return this.w64(f+Te,m),t.offset=m,{name:_,stat:y}}return null}closedir(e){this.close(e)}readdir(e){let t=this.opendir(e),r=[];try{let i;for(;(i=this.readdirEntry(t))!==null;)i.name!=="."&&i.name!==".."&&r.push(i.name)}finally{this.closedir(t)}return r}writeFile(e,t){let r=typeof t=="string"?pe.encode(t):t,i=this.open(e,Eo|er|rr);try{this.write(i,r)}finally{this.close(i)}}readFile(e){let t=this.open(e,et);try{let r=this.fstat(t),i=new Uint8Array(r.size);return this.read(t,i),i}finally{this.close(t)}}readFileText(e){return tr.decode(this.readFile(e))}};function zo(n,e){let t=new Map,r=new Map;for(let o of n){if(t.has(o.path))throw new Error(`${e} duplicates path ${o.path}`);if(t.set(o.path,o),o.type==="file"){if(!o.inodeGroup)throw new Error(`${e} file ${o.path} has no inode group`);if(r.has(o.inodeGroup))throw new Error(`${e} inode group ${o.inodeGroup} has multiple files`);r.set(o.inodeGroup,o)}}let i=new Set,s=new Map;for(let o of n){if(o.type!=="hardlink"||s.has(o.path))continue;let a=[],c=o,l;for(;c.type==="hardlink";){let u=s.get(c.path);if(u){l=u;break}if(i.has(c.path))throw new Error(`${e} hardlink cycle reaches ${c.path}`);if(i.add(c.path),a.push(c),!c.target)throw new Error(`${e} hardlink ${c.path} has no target`);let p=t.get(c.target);if(!p)throw new Error(`${e} hardlink ${c.path} target ${c.target} is missing`);if(p.type!=="file"&&p.type!=="hardlink"||!c.inodeGroup||p.inodeGroup!==c.inodeGroup||p.size!==c.size||p.mode!==c.mode)throw new Error(`${e} hardlink ${c.path} has an invalid target`);c=p}l??=c.type==="file"?c:void 0;let d=r.get(o.inodeGroup??"");if(!l||l!==d)throw new Error(`${e} hardlink ${o.path} does not resolve to its inode`);for(let u=a.length-1;u>=0;u-=1){let p=a[u];if(r.get(p.inodeGroup??"")!==l)throw new Error(`${e} hardlink ${p.path} does not resolve to its inode`);i.delete(p.path),s.set(p.path,l)}}return{canonicalByGroup:r,canonicalTargetByPath:s}}var fe={maxArchiveBytes:268435456,maxExpandedBytes:268435456,maxPayloadBytes:268435456,maxEntries:1e5,maxPathBytes:4096,maxSymlinkTargetBytes:65536,maxStringBytes:8192,maxTransportsPerTree:8,maxActivationCapabilities:32,maxActivationRoots:64,maxActivationCapabilityBytes:255},xe={maxArchiveBytes:512*1024*1024,maxExpandedBytes:512*1024*1024,maxPayloadBytes:512*1024*1024,maxEntries:1e5,maxGroups:512};function ko(n,e="Deferred tree collection"){for(let[t,r]of Object.entries(n))if(!Number.isSafeInteger(r)||r<0)throw new Error(`${e} ${t} usage is invalid`);if(n.groups>xe.maxGroups)throw new Error(`${e} exceeds the ${xe.maxGroups}-group cap`);if(n.archiveBytes>xe.maxArchiveBytes)throw new Error(`${e} exceeds the archive-byte cap`);if(n.expandedBytes>xe.maxExpandedBytes)throw new Error(`${e} exceeds the expansion cap`);if(n.payloadBytes>xe.maxPayloadBytes)throw new Error(`${e} exceeds the payload-byte cap`);if(n.entries>xe.maxEntries)throw new Error(`${e} exceeds the entry-count cap`)}var Po=Object.freeze({prefix:"/opt/kandelo/homebrew",cellar:"/opt/kandelo/homebrew/Cellar",repository:"/opt/kandelo/homebrew",stableEntrypoint:"/usr/bin/brew"});var No=1e5,mc=4096;var wt=Po.prefix,Mo=[["@@HOMEBREW_PREFIX@@",wt],["@@HOMEBREW_CELLAR@@",`${wt}/Cellar`],["@@HOMEBREW_REPOSITORY@@",wt],["@@HOMEBREW_LIBRARY@@",`${wt}/Library`],["@@HOMEBREW_PERL@@",`${wt}/opt/perl/bin/perl`]],Bn="@@HOMEBREW_JAVA@@",_c=/^openjdk(?:@\d+(?:\.\d+)*)?/,At=new TextEncoder,yc=[...Mo.map(([n])=>n),Bn].map(n=>({placeholder:n,bytes:At.encode(n)}));function Do(n){let e=gc(n),t=e.changed_files;if(t!=null&&!Array.isArray(t))throw new Error("INSTALL_RECEIPT.json changed_files must be an array or null when present");let r=Array.isArray(t)?t:[];if(r.length>No)throw new Error(`INSTALL_RECEIPT.json declares ${r.length} changed files, limit ${No}`);let i=[],s=new Set;for(let[o,a]of r.entries()){if(typeof a!="string")throw new Error(`INSTALL_RECEIPT.json changed_files[${o}] is not a string`);if(Sc(a,"Homebrew changed file"),s.has(a))throw new Error(`INSTALL_RECEIPT.json repeats changed file ${a}`);s.add(a),i.push(a)}return{changedFiles:i,runtimeDependencies:e.runtime_dependencies}}function gc(n){let e;try{e=JSON.parse(new TextDecoder("utf-8",{fatal:!0}).decode(n))}catch(t){throw new Error("INSTALL_RECEIPT.json is not valid UTF-8 JSON: "+Ac(t))}if(typeof e!="object"||e===null||Array.isArray(e))throw new Error("INSTALL_RECEIPT.json must contain an object");return e}function Ko(n,e,t){let r=n;for(let[o,a]of Mo)r=Co(r,At.encode(o),At.encode(a));let i=At.encode(Bn);if(Fo(r,i)){let o=Ec(e.runtimeDependencies);if(o===void 0)throw new Error(`Homebrew changed file ${t} uses ${Bn} without exactly one OpenJDK runtime dependency`);r=Co(r,i,At.encode(o))}let s=yc.find(({bytes:o})=>Fo(r,o));if(s!==void 0)throw new Error(`Homebrew changed file ${t} retains ${s.placeholder}`);return r}function Ec(n){if(!Array.isArray(n))return;let e=[];for(let r of n){if(typeof r!="object"||r===null||Array.isArray(r))continue;let i=r,s=typeof i.full_name=="string"?i.full_name.split("/").at(-1):typeof i.name=="string"?i.name.split("/").at(-1):void 0,o=s===void 0?null:_c.exec(s);s!==void 0&&o?.[0]===s&&e.push(s)}let t=[...new Set(e)];return t.length===1?`${wt}/opt/${t[0]}/libexec`:void 0}function Sc(n,e){if(n.length===0||n.startsWith("/")||n.includes("\\")||n.includes("\0")||wc(n)||At.encode(n).byteLength>mc||n.split("/").some(t=>t===""||t==="."||t===".."))throw new Error(`${e} has an unsafe path segment: ${n}`)}function wc(n){for(let e=0;e57343)){if(t<=56319&&e+1=56320&&n.charCodeAt(e+1)<=57343){e+=1;continue}return!0}}return!1}function Fo(n,e){if(e.byteLength===0||e.byteLength>n.byteLength)return!1;e:for(let t=0;t<=n.byteLength-e.byteLength;t+=1){for(let r=0;rjr||n.includes("\0")||n.includes("\\"))throw new Error(`Lazy archive mount prefix must be an absolute POSIX path: ${JSON.stringify(n)}`);let e=n.replace(/\/+$/,"");if(e==="")return"/";if(e.slice(1).split("/").some(r=>r===""||r==="."||r===".."))throw new Error(`Lazy archive mount prefix is not canonical: ${JSON.stringify(n)}`);return e}function Ol(n,e,t,r){let i=fr(t),s=new Map,o=e.map(a=>{let c=a.fileName,l=`Lazy archive ${JSON.stringify(n)} member ${JSON.stringify(c)}`;if(c.length===0)throw new Error(`${l} has an empty path`);if(c.includes("\0"))throw new Error(`${l} contains a NUL byte`);if(c.includes("\\"))throw new Error(`${l} contains a backslash`);if(c.startsWith("/")||/^[A-Za-z]:\//.test(c))throw new Error(`${l} must be relative, not absolute`);if(a.isDirectory&&a.isSymlink)throw new Error(`${l} has conflicting directory and symlink types`);if(a.isDirectory!==c.endsWith("/"))throw new Error(`${l} has inconsistent directory metadata`);let d=a.isDirectory?c.slice(0,-1):c,u=d.split("/");if(d.length===0||u.some(p=>p===""||p==="."||p===".."))throw new Error(`${l} is not a canonical relative POSIX path`);if(s.has(d))throw new Error(`${l} collides with another member at ${JSON.stringify(d)}`);if(a.isSymlink&&!r?.has(c))throw new Error(`Lazy archive symlink target was not provided: ${c}`);return s.set(d,a),{entry:a,archivePath:d,vfsPath:i==="/"?`/${d}`:`${i}/${d}`}});for(let{archivePath:a}of o){let c=a.split("/");for(let l=1;lvt)throw new Error(`VFS image metadata exceeds ${vt} bytes`);let e;try{e=JSON.parse(new TextDecoder().decode(n))}catch(t){let r=t instanceof Error?t.message:String(t);throw new Error(`Invalid VFS image metadata JSON: ${r}`)}return hi(e)}function Rl(n){if(n===null)return new Uint8Array(0);let e=hi(n),t=new TextEncoder().encode(JSON.stringify(e));if(t.byteLength>vt)throw new Error(`VFS image metadata exceeds ${vt} bytes`);return t}function vl(n){return n.byteLength>=ar.length&&n[0]===ar[0]&&n[1]===ar[1]&&n[2]===ar[2]&&n[3]===ar[3]?Zl(n):n}function $r(n){let e=vl(n);if(e.byteLengthGr)throw new Error(`VFS image lazy metadata exceeds ${Gr} bytes`);if(n.byteLengthHr)throw new Error(`VFS image lazy archive metadata exceeds ${Hr} bytes`);if(n.byteLength=0?r:void 0}function bl(n){return n===408||n===429||n>=500&&n<=599}function zl(n,e=Date.now()){let t=n?.get("retry-after")?.trim();if(!t)return;let r;if(/^\d+$/.test(t))r=Number(t)*1e3;else{let i=Date.parse(t);if(!Number.isFinite(i))return;r=Math.max(0,i-e)}if(!(!Number.isSafeInteger(r)||r<0))return Math.min(r,xs)}function kl(n){if(!(typeof n!="object"||n===null||!("cause"in n)))return n.cause}function Rs(n){if(!(typeof n!="object"||n===null||!("name"in n)))return typeof n.name=="string"?n.name:void 0}function vs(n){if(!(typeof n!="object"||n===null||!("code"in n)))return typeof n.code=="string"?n.code:void 0}function Ts(n,e){let t=new Set,r=n;for(let i=0;r!==void 0&&i<8;i+=1){if(t.has(r))return!1;if(t.add(r),e(r))return!0;r=kl(r)}return!1}function Ls(n){return Ts(n,e=>Rs(e)==="AbortError"||vs(e)==="ABORT_ERR")}function Pl(n){return Ls(n)?!1:Ts(n,e=>{let t=Rs(e),r=vs(e);return e instanceof TypeError||t==="NetworkError"||t==="TimeoutError"||r!==void 0&&Al.has(r)})}function Nl(n,e){if(n instanceof qr){if(!bl(n.status))return null;if(n.retryAfterMs!==void 0)return n.retryAfterMs}else if(!Pl(n))return null;return Math.min(wl*2**e,xs)}function ee(n){if(n?.aborted)throw n.reason}function Fl(n,e){return ee(e),n===0?Promise.resolve():new Promise((t,r)=>{let i=setTimeout(()=>a(!1),n),s=()=>a(!0,e.reason),o=!1;function a(c,l){o||(o=!0,clearTimeout(i),e?.removeEventListener("abort",s),c?r(l):t())}e?.addEventListener("abort",s,{once:!0}),e?.aborted&&s()})}async function ni(n,e){try{await n.body?.cancel(e)}catch{}}function Cl(n,e){if(n.length===1)return n[0];let t=new Uint8Array(e),r=0;for(let i of n)t.set(i,r),r+=i.byteLength;return t}function hr(n){if(n===void 0)return;if(typeof n!="object"||n===null||Array.isArray(n))throw new Error("Lazy archive integrity must be an object");let e=n;if(Object.keys(e).length!==2||!("sha256"in e)||!("bytes"in e))throw new Error("Lazy archive integrity has unexpected fields");if(typeof e.sha256!="string"||!ai.test(e.sha256))throw new Error("Lazy archive integrity has an invalid SHA-256 digest");if(!Number.isSafeInteger(e.bytes)||Number(e.bytes)<=0||Number(e.bytes)>ds)throw new Error(`Lazy archive integrity byte count must be between 1 and ${ds}`);return{sha256:e.sha256,bytes:Number(e.bytes)}}function qe(n,e,t){if(typeof n!="object"||n===null||Array.isArray(n))throw new Error(`${t} must be an object`);let r=n;if(Object.keys(r).length!==e.length||e.some(s=>!Object.prototype.hasOwnProperty.call(r,s)))throw new Error(`${t} has unexpected or missing fields`);return r}function li(n,e,t,r){if(typeof n!="object"||n===null||Array.isArray(n))throw new Error(`${r} must be an object`);let i=n,s=new Set(e);if(Object.keys(i).some(o=>!s.has(o))||t.some(o=>!Object.prototype.hasOwnProperty.call(i,o)))throw new Error(`${r} has unexpected or missing fields`);return i}function Ne(n,e,t,r){if(!Array.isArray(n)||n.lengthr)throw new Error(`${e} must contain ${t} to ${r} items`);return n}function _e(n,e,t){if(typeof n!="string"||n.length===0||n.includes("\0")||new TextEncoder().encode(n).byteLength>t)throw new Error(`${e} is invalid or exceeds ${t} bytes`);return n}function se(n,e,t,r){if(!Number.isSafeInteger(n)||Number(n)r)throw new Error(`${e} must be an integer between ${t} and ${r}`);return Number(n)}function Zr(n,e=1){let t=n,r=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.source!==void 0,i=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.modePolicy!==void 0,s=qe(n,["decoder","mediaType","sha256","bytes","expandedBytes","sourceEntryCount","transports",...i?["modePolicy"]:[],...r?["source"]:[]],"Lazy tree content"),o=s.decoder==="zip-v1"?"application/zip":s.decoder==="homebrew-bottle-tar-gzip-v1"?"application/vnd.oci.image.layer.v1.tar+gzip":null;if(o===null||s.mediaType!==o)throw new Error("Lazy tree decoder and media type are inconsistent");let a=hr({sha256:s.sha256,bytes:s.bytes});if(!a)throw new Error("Lazy tree integrity is required");let c=Ne(s.transports,"Lazy tree transports",e,fe.maxTransportsPerTree).map((m,f)=>_e(m,`Lazy tree transport ${f}`,fi));if(new Set(c).size!==c.length)throw new Error("Lazy tree transports contain duplicates");let l=se(s.expandedBytes,"Lazy tree expanded byte count",0,_l),d=se(s.sourceEntryCount,"Lazy tree source entry count",1,Tt),u=r?Kl(s.source,s.decoder):void 0,p=i?s.modePolicy:void 0;if(p!==void 0&&(p!=="portable-posix-v1"||s.decoder!=="zip-v1"||r))throw new Error("Lazy tree mode policy is invalid for its decoder");if(u!==void 0&&u.entries.length!==d)throw new Error("Lazy tree source inventory count differs from its content");return{decoder:s.decoder,mediaType:o,sha256:a.sha256,bytes:a.bytes,expandedBytes:l,sourceEntryCount:d,transports:c,...p===void 0?{}:{modePolicy:p},...u===void 0?{}:{source:u}}}function bs(n){let e={groups:n.length,archiveBytes:0,expandedBytes:0,payloadBytes:0,entries:0};for(let t of n)t.content===void 0||t.inventory===void 0||(e.archiveBytes+=t.content.bytes,e.expandedBytes+=t.content.expandedBytes,e.payloadBytes+=t.inventory.filter(r=>r.type==="file").reduce((r,i)=>r+i.size,0),e.entries+=t.inventory.length+(t.content.source?.entries.length??0));return e}function ui(n){ko(n,"Serialized lazy tree collection")}function Ml(n){ui(bs(n))}function Dl(n){let e=new Map;for(let t of n){let r=t.activation?.atomicGroup;if(r===void 0)continue;if(!Rt(r))throw new Error(`Serialized lazy atomic activation group ${r.id} is unsealed`);let i=e.get(r.id);if(i===void 0)i={expectedCount:r.expectedCount,cohortSha256:r.cohortSha256,members:new Set,descriptors:new Set},e.set(r.id,i);else if(i.expectedCount!==r.expectedCount||i.cohortSha256!==r.cohortSha256)throw new Error(`Serialized lazy atomic activation group ${r.id} has inconsistent seals`);if(i.members.has(r.member)||i.descriptors.has(r.descriptorSha256))throw new Error(`Serialized lazy atomic activation group ${r.id} duplicates a member`);i.members.add(r.member),i.descriptors.add(r.descriptorSha256)}for(let[t,r]of e)if(r.members.size!==r.expectedCount)throw new Error(`Serialized lazy atomic activation group ${t} has ${r.members.size} of ${r.expectedCount} members`)}function ys(n){for(let[e,t]of n.entries())if(t.kind===dr||t.kind===ci||t.kind===ot)Ns(t,t.kind);else if(t.kind===ur)di(t,!1);else throw new Error(`Serialized lazy archive group ${e} has an unsupported kind`);Ml(n),Dl(n)}function Kl(n,e){if(e!=="homebrew-bottle-tar-gzip-v1")throw new Error("Lazy tree source inventory is valid only for original bottles");let t=qe(n,["schema","kind","entries"],"Lazy tree source inventory");if(t.schema!==1||t.kind!=="homebrew-bottle-tar-gzip-v1")throw new Error("Lazy tree source inventory has an unsupported identity");let r=new Map,i=Ne(t.entries,"Lazy tree source entries",1,Tt).map((o,a)=>{let c=o,l=typeof c=="object"&&c!==null&&!Array.isArray(c)?c.type:void 0,d=l==="directory"||l==="file"?["sourcePath","type","mode","size"]:l==="symlink"||l==="hardlink"?["sourcePath","type","mode","size","target"]:null;if(d===null)throw new Error(`Lazy tree source entry ${a} has invalid type`);let u=qe(o,d,`Lazy tree source entry ${a}`),p=ye(u.sourcePath,!1,`Lazy tree source entry ${a} path`);if(r.has(p))throw new Error(`Lazy tree source inventory duplicates ${p}`);let m=se(u.mode,`Lazy tree source entry ${p} mode`,0,4095),f=se(u.size,`Lazy tree source entry ${p} size`,0,Vr),h;if((l==="directory"||l==="symlink"||l==="hardlink")&&f!==0)throw new Error(`Lazy tree source ${p} has payload for ${String(l)}`);l==="symlink"?h=_e(u.target,`Lazy tree source symlink ${p} target`,Is):l==="hardlink"&&(h=ye(u.target,!1,`Lazy tree source hardlink ${p} target`));let g={sourcePath:p,type:l,mode:m,size:f,...h===void 0?{}:{target:h}};return r.set(p,g),g}),s=i.map(o=>o.sourcePath);if(s.some((o,a)=>a>0&&s[a-1]>=o))throw new Error("Lazy tree source inventory is not in canonical path order");return{schema:1,kind:"homebrew-bottle-tar-gzip-v1",entries:i}}function zs(n){let e=new Map(n.map(r=>[r.sourcePath,r])),t=new Map;for(let r of n){if(r.type!=="hardlink"||t.has(r.sourcePath))continue;let i=[],s=new Set,o=r,a;for(;o.type==="hardlink"&&(a=t.get(o.sourcePath),a===void 0);){if(s.has(o.sourcePath))throw new Error(`Lazy tree source hardlink cycle includes ${o.sourcePath}`);s.add(o.sourcePath),i.push(o);let c=e.get(o.target);if(c===void 0)throw new Error(`Lazy tree source hardlink ${o.sourcePath} target is absent`);if(c.type!=="file"&&c.type!=="hardlink")throw new Error(`Lazy tree source hardlink ${o.sourcePath} target is not regular`);o=c}a===void 0&&(a=o);for(let c of i)t.set(c.sourcePath,a)}return t}function ye(n,e,t,r=!1){if(typeof n!="string"||n.length===0||new TextEncoder().encode(n).byteLength>jr||n.includes("\0")||n.includes("\\")||n.startsWith("/")!==e)throw new Error(`${t} is not a canonical ${e?"absolute":"relative"} path`);if(r&&e&&n==="/")return n;if(n.slice(e?1:0).split("/").some(s=>s===""||s==="."||s===".."))throw new Error(`${t} has an unsafe path segment`);return n}function ks(n){let e=typeof n=="object"&&n!==null&&!Array.isArray(n)?n:null,t=e!==null&&(Object.hasOwn(e,"descriptorSha256")||Object.hasOwn(e,"expectedCount")||Object.hasOwn(e,"cohortSha256")),r=qe(n,t?["id","member","descriptorSha256","expectedCount","cohortSha256"]:["id","member"],"Lazy tree atomic activation membership"),i=_e(r.id,"Lazy tree atomic activation group",fs),s=_e(r.member,"Lazy tree atomic activation member",fs);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(i)||!/^[a-z0-9][a-z0-9:+._/-]*$/.test(s)||s.includes("//")||s.endsWith("/"))throw new Error("Lazy tree atomic activation membership is invalid");if(!t)return{id:i,member:s};let o=_e(r.descriptorSha256,"Lazy tree atomic member descriptor digest",64),a=_e(r.cohortSha256,"Lazy tree atomic cohort digest",64);if(!ai.test(o)||!ai.test(a))throw new Error("Lazy tree atomic activation digest is invalid");return{id:i,member:s,descriptorSha256:o,expectedCount:se(r.expectedCount,"Lazy tree atomic activation expected member count",1,Os),cohortSha256:a}}function Rt(n){return n.descriptorSha256!==void 0&&n.expectedCount!==void 0&&n.cohortSha256!==void 0}function Bl(n){let e=qe(n,["uid","gid"],"Lazy tree registration owner");return{uid:se(e.uid,"Lazy tree registration owner uid",0,hs),gid:se(e.gid,"Lazy tree registration owner gid",0,hs)}}function Ps(n,e,t,r,i=1){let s=Zr(n,i),o=fr(t),a=["mode","capabilities","roots",...typeof r=="object"&&r!==null&&!Array.isArray(r)&&Object.hasOwn(r,"atomicGroup")?["atomicGroup"]:[]],c=qe(r,a,"Lazy tree activation");if(c.mode!=="boot-prefetch"&&c.mode!=="first-use")throw new Error("Lazy tree activation mode is invalid");let l=Ne(c.capabilities,"Lazy tree activation capabilities",1,El).map((S,O)=>{let x=_e(S,`Lazy tree activation capability ${O}`,fe.maxActivationCapabilityBytes);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(x))throw new Error(`Lazy tree activation capability ${O} is invalid`);return x}),d=Ne(c.roots,"Lazy tree activation roots",1,Sl).map((S,O)=>ye(S,!0,`Lazy tree activation root ${O}`,!0));if(new Set(l).size!==l.length||new Set(d).size!==d.length)throw new Error("Lazy tree activation contains duplicates");let u=c.atomicGroup===void 0?void 0:ks(c.atomicGroup);if(u!==void 0&&c.mode!=="first-use")throw new Error("Lazy tree atomic activation group requires a valid first-use identity");let p={mode:c.mode,capabilities:l,roots:d,...u===void 0?{}:{atomicGroup:u}},m=Ne(e,"Lazy tree inventory",1,Tt),f=[],h=new Map,g=new Map,_=s.source===void 0?void 0:new Map(s.source.entries.map(S=>[S.sourcePath,S])),y=s.source===void 0?void 0:zs(s.source.entries),E=0;for(let[S,O]of m.entries()){if(typeof O!="object"||O===null||Array.isArray(O))throw new Error(`Lazy tree entry ${S} must be an object`);let x=O.type,R=x==="directory"?["vfsPath","sourcePath","type","mode","size"]:x==="file"?["vfsPath","sourcePath","type","mode","size","inodeGroup"]:x==="symlink"?["vfsPath","sourcePath","type","mode","size","target"]:x==="hardlink"?["vfsPath","sourcePath","type","mode","size","target","inodeGroup"]:null;if(!R)throw new Error(`Lazy tree entry ${S} has an invalid type`);let T=qe(O,[...R,..._===void 0?[]:["materialization"]],`Lazy tree entry ${S}`),L=ye(T.vfsPath,!0,`Lazy tree entry ${S} VFS path`),D=ye(T.sourcePath,!1,`Lazy tree entry ${S} source path`),q=_===void 0?void 0:T.materialization;if(_!==void 0&&q!=="archive"&&q!=="archive-homebrew-relocate"&&q!=="archive-copy"&&q!=="archive-copy-mode"&&q!=="descriptor")throw new Error(`Lazy tree entry ${L} has invalid materialization provenance`);if(o!=="/"&&L!==o&&!L.startsWith(`${o}/`))throw new Error(`Lazy tree entry ${L} escapes its mount prefix`);if(h.has(L))throw new Error(`Lazy tree duplicates VFS path ${L}`);let F=se(T.mode,`Lazy tree entry ${L} mode`,0,4095),z=se(T.size,`Lazy tree entry ${L} size`,0,Vr),U,ue;if(x==="directory"){if(z!==0)throw new Error(`Lazy tree directory ${L} has nonzero size`)}else if(x==="symlink"){if(U=_e(T.target,`Lazy tree symlink ${L} target`,Is),new TextEncoder().encode(U).byteLength!==z)throw new Error(`Lazy tree symlink ${L} size differs from its target`)}else ue=_e(T.inodeGroup,`Lazy tree entry ${L} inode group`,jr),x==="hardlink"&&(U=ye(T.target,!0,`Lazy tree hardlink ${L} target`));if(x!=="hardlink"&&(E+=z,E>Vr))throw new Error("Lazy tree inventory exceeds the expansion limit");let C={vfsPath:L,sourcePath:D,...q===void 0?{}:{materialization:q},type:x,mode:F,size:z,...U===void 0?{}:{target:U},...ue===void 0?{}:{inodeGroup:ue}};if(_===void 0){let H=g.get(D);if(H){if(s.decoder!=="zip-v1"||C.type!=="hardlink"||H.inodeGroup!==C.inodeGroup)throw new Error(`Lazy tree duplicates source path ${D}`)}else{if(s.decoder==="zip-v1"&&C.type==="hardlink")throw new Error(`Lazy ZIP hardlink ${L} does not reuse a canonical source path`);g.set(D,C)}}else if(C.materialization==="descriptor"){if(C.type!=="directory"&&C.type!=="symlink")throw new Error(`Lazy tree descriptor entry ${L} is not structural`);if(_.has(D))throw new Error(`Lazy tree descriptor entry ${L} impersonates a source member`)}else{let H=_.get(D);if(H===void 0)throw new Error(`Lazy tree entry ${L} names absent source ${D}`);if(C.materialization==="archive-copy"||C.materialization==="archive-copy-mode"){if(C.type!=="file"||H.type!=="file"||C.materialization==="archive-copy"&&C.mode!==H.mode)throw new Error(`Lazy tree archive copy ${L} differs from its source`)}else if(C.materialization==="archive-homebrew-relocate"){if(C.type!=="file"&&C.type!=="hardlink"||H.type!==C.type||C.type==="file"&&H.mode!==C.mode)throw new Error(`Lazy tree receipt-relocated entry ${L} differs from its source`)}else if(H.type!==C.type||C.type==="symlink"&&H.target!==C.target||C.type!=="hardlink"&&H.mode!==C.mode)throw new Error(`Lazy tree archive entry ${L} differs from its source`)}f.push(C),h.set(L,C)}for(let S of f){let O=S.vfsPath.split("/").filter(Boolean);for(let x=1;x({path:S.vfsPath,type:S.type,mode:S.mode,size:S.size,target:S.target,inodeGroup:S.inodeGroup})),"Lazy tree");if(_!==void 0){let S=new Set;for(let O of f){if(O.materialization!=="archive-homebrew-relocate")continue;let x=_.get(O.sourcePath),R=x.type==="file"?x:y.get(x.sourcePath);if(R?.type!=="file")throw new Error(`Lazy tree receipt-relocated entry ${O.vfsPath} is not regular`);S.add(R.sourcePath)}for(let O of f){if(O.materialization==="descriptor"||O.type!=="file"&&O.type!=="hardlink")continue;let x=_.get(O.sourcePath),R=x.type==="file"?x:y.get(x.sourcePath);if(R?.type!=="file"||!S.has(R.sourcePath)&&O.size!==R.size)throw new Error(`Lazy tree archive entry ${O.vfsPath} differs from its source`)}for(let O of f){if(O.type!=="hardlink"||O.materialization!=="archive"&&O.materialization!=="archive-homebrew-relocate")continue;let x=_.get(O.sourcePath),R=h.get(O.target),T=y.get(x.sourcePath);if(x.target!==R?.sourcePath||T?.type!=="file"||T.mode!==O.mode||R?.mode!==O.mode)throw new Error(`Lazy tree hardlink ${O.vfsPath} differs from its source`)}}if(s.sourceEntryCount!==(_===void 0?g.size:_.size))throw new Error("Lazy tree source entry count differs from its inventory");if(s.source===void 0&&s.expandedBytesO.vfsPath===S||O.vfsPath.startsWith(`${S}/`)))throw new Error(`Lazy tree activation root ${S} is not owned by its inventory`);let w=new Map;for(let S of f)S.type==="file"&&w.set(S.inodeGroup,S);if(w.size!==A.canonicalByGroup.size)throw new Error("Lazy tree regular inode inventory is inconsistent");return{content:s,entries:f,mountPrefix:o,activation:p,canonicalByGroup:w}}function Xr(n){return JSON.stringify([n.sourcePath,n.type,n.inodeGroup,n.target])}function di(n,e){let t=li(n,["kind","content","url","mountPrefix","integrity","materialized","entries"],["url","mountPrefix","materialized","entries"],"Serialized legacy lazy archive");if(t.kind===void 0){if(!e)throw new Error("Serialized lazy archive is missing its kind discriminator")}else if(t.kind!==ur)throw new Error("Serialized legacy lazy archive has an unsupported kind");let r=_e(t.url,"Serialized legacy lazy archive URL",fi),i=fr(t.mountPrefix),s=hr(t.integrity);if(t.content!==void 0){if(!e||t.kind!==void 0)throw new Error("Typed legacy lazy archives cannot carry generic content");let c=Zr(t.content);if(c.decoder!=="zip-v1"||c.transports.length!==1||c.transports[0]!==r||!s||c.sha256!==s.sha256||c.bytes!==s.bytes)throw new Error("Untagged legacy ZIP content identity is inconsistent")}if(t.materialized!==!1)throw new Error("Serialized legacy lazy archive must describe pending content");let o=new Set,a=Ne(t.entries,"Serialized legacy lazy archive entries",1,Tt).map((c,l)=>{let d=li(c,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","size","isSymlink","deleted"],`Serialized legacy lazy archive entry ${l}`),u=ye(d.vfsPath,!0,`Serialized legacy lazy archive entry ${l} VFS path`);if(o.has(u))throw new Error(`Serialized legacy lazy archive duplicates path ${u}`);o.add(u);let p=se(d.ino,`Serialized legacy lazy archive entry ${u} inode`,1,Number.MAX_SAFE_INTEGER),m=d.generation===void 0?void 0:se(d.generation,`Serialized legacy lazy archive entry ${u} generation`,0,Number.MAX_SAFE_INTEGER),f=d.dataSequence===void 0?void 0:se(d.dataSequence,`Serialized legacy lazy archive entry ${u} data sequence`,0,Number.MAX_SAFE_INTEGER),h=se(d.size,`Serialized legacy lazy archive entry ${u} size`,0,Vr);if(d.isSymlink!==!1||d.deleted!==!1||d.materialized!==void 0&&d.materialized!==!1)throw new Error(`Serialized legacy lazy archive entry ${u} is not pending`);if(d.type!==void 0&&d.type!=="file")throw new Error(`Serialized legacy lazy archive entry ${u} has an invalid type`);let g=d.archivePath===void 0?void 0:ye(d.archivePath,!1,`Serialized legacy lazy archive entry ${u} archive path`),_=d.sourcePath===void 0?void 0:ye(d.sourcePath,!1,`Serialized legacy lazy archive entry ${u} source path`),y=d.inodeGroup===void 0?void 0:_e(d.inodeGroup,`Serialized legacy lazy archive entry ${u} inode group`,jr);if(d.target!==void 0)throw new Error(`Serialized legacy lazy archive entry ${u} has a link target`);return{vfsPath:u,ino:p,...m===void 0?{}:{generation:m},...f===void 0?{}:{dataSequence:f},size:h,isSymlink:!1,deleted:!1,materialized:!1,...g===void 0?{}:{archivePath:g},..._===void 0?{}:{sourcePath:_},type:"file",...y===void 0?{}:{inodeGroup:y}}});return{kind:ur,url:r,mountPrefix:i,...s===void 0?{}:{integrity:s},materialized:!1,entries:a}}function Ns(n,e){let t=qe(n,["kind","content","inventory","activation","url","mountPrefix","integrity","materialized","entries"],"Serialized lazy tree");if(t.kind!==e)throw new Error("Serialized lazy tree has an unsupported kind");let r=Ps(t.content,t.inventory,t.mountPrefix,t.activation);if(e!==ot&&e===dr!=(r.content.source===void 0))throw new Error(e===dr?"Serialized deferred-tree-v1 cannot contain original-bottle source metadata":"Serialized deferred-tree-v2 requires original-bottle source metadata");let i=r.activation.atomicGroup;if(e===ot?i===void 0||!Rt(i):i!==void 0)throw new Error(e===ot?"Serialized deferred-tree-v3 requires a sealed atomic activation":"Atomic activation requires serialized deferred-tree-v3");let s=_e(t.url,"Serialized lazy tree URL",fi);if(s!==r.content.transports[0])throw new Error("Serialized lazy tree URL differs from its primary transport");let o=hr(t.integrity);if(!o||o.sha256!==r.content.sha256||o.bytes!==r.content.bytes)throw new Error("Serialized lazy tree integrity differs from its content");if(t.materialized!==!1)throw new Error("Serialized lazy tree must describe pending content");let a=new Map(r.entries.map(p=>[p.vfsPath,p])),c=new Map(r.entries.map(p=>[Xr(p),p])),l=Ne(t.entries,"Serialized lazy tree entries",0,Tt),d=new Set,u=l.map((p,m)=>{let f=li(p,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup"],`Serialized lazy tree entry ${m}`),h=ye(f.vfsPath,!0,`Serialized lazy tree entry ${m} VFS path`);if(d.has(h))throw new Error(`Serialized lazy tree duplicates pending path ${h}`);d.add(h);let g=ye(f.sourcePath,!1,`Serialized lazy tree entry ${m} source path`),_=ye(f.archivePath,!1,`Serialized lazy tree entry ${m} archive path`),y=a.get(h),E=c.get(Xr({sourcePath:g,type:typeof f.type=="string"?f.type:void 0,inodeGroup:typeof f.inodeGroup=="string"?f.inodeGroup:void 0,target:typeof f.target=="string"?f.target:void 0}))??y;if(!E||E.type!=="file"&&E.type!=="hardlink"||y?.inodeGroup!==void 0&&y.inodeGroup!==E.inodeGroup)throw new Error(`Serialized lazy tree entry ${h} is absent from its inventory`);let A=r.canonicalByGroup.get(E.inodeGroup);if(f.type!==E.type||f.inodeGroup!==E.inodeGroup||f.size!==E.size||_!==A?.sourcePath||f.target!==E.target||f.isSymlink!==!1||f.deleted!==!1||f.materialized!==!1)throw new Error(`Serialized lazy tree entry ${h} disagrees with its inventory`);let w=se(f.ino,`Serialized lazy tree entry ${h} inode`,1,Number.MAX_SAFE_INTEGER),S=se(f.generation,`Serialized lazy tree entry ${h} generation`,0,Number.MAX_SAFE_INTEGER),O=se(f.dataSequence,`Serialized lazy tree entry ${h} data sequence`,0,Number.MAX_SAFE_INTEGER);return{vfsPath:h,ino:w,generation:S,dataSequence:O,size:E.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:_,sourcePath:g,type:E.type,inodeGroup:E.inodeGroup,...E.target===void 0?{}:{target:E.target}}});for(let p of r.entries)if(r.activation.atomicGroup!==void 0&&(p.type==="file"||p.type==="hardlink")&&!d.has(p.vfsPath))throw new Error(`Serialized lazy tree omits pending path ${p.vfsPath}`);return{kind:e,content:r.content,inventory:r.entries,activation:r.activation,url:s,mountPrefix:r.mountPrefix,integrity:o,materialized:!1,entries:u}}async function lr(n,e){let t=globalThis.crypto?.subtle;if(!t)throw new Error(`${e} SHA-256 verification is unavailable`);let r=new Uint8Array(n.byteLength);r.set(n);let i=new Uint8Array(await t.digest("SHA-256",r));return Array.from(i,s=>s.toString(16).padStart(2,"0")).join("")}async function ii(n,e,t){if(t===void 0)return;if(n.byteLength!==t.bytes)throw new Error(`Lazy ${e} byte count ${n.byteLength} does not match expected ${t.bytes}`);let r=await lr(n,`Lazy ${e}`);if(r!==t.sha256)throw new Error(`Lazy ${e} SHA-256 ${r} does not match expected ${t.sha256}`)}function $l(n,e,t,r){let i=r.atomicGroup;if(i===void 0)throw new Error("Lazy atomic member is missing its typed tree descriptor");let s={schema:1,content:{decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...n.source===void 0?{}:{source:n.source}},mountPrefix:t,inventory:[...e].sort((o,a)=>o.vfsPatha.vfsPath?1:0),activation:{mode:r.mode,capabilities:r.capabilities,roots:r.roots,atomicGroup:{id:i.id,member:i.member}}};return new TextEncoder().encode(JSON.stringify(s))}function gs(n,e){let t=e.map(({member:r,descriptorSha256:i})=>({member:r,descriptorSha256:i}));return new TextEncoder().encode(JSON.stringify({schema:1,id:n,members:t.sort((r,i)=>r.memberi.member?1:0)}))}function Ul(n,e){if(n.byteLength!==e.byteLength)return!1;for(let t=0;tObject.freeze({...i}))};return r!==void 0&&(Object.freeze(r.entries),Object.freeze(r)),Object.freeze({decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,transports:t,...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...r===void 0?{}:{source:r}})}function Es(n){return{decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,transports:[...n.transports],...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...n.source===void 0?{}:{source:{schema:1,kind:"homebrew-bottle-tar-gzip-v1",entries:n.source.entries.map(e=>({...e}))}}}}function Wl(n){let e=n.map(t=>Object.freeze({...t}));return Object.freeze(e),e}function Gl(n,e,t){let r=[...n.capabilities],i=[...n.roots];return Object.freeze(r),Object.freeze(i),Object.freeze({mode:n.mode,capabilities:r,roots:i,atomicGroup:Object.freeze({id:e,member:t})})}function Hl(n){return{mode:n.activation.mode,capabilities:[...n.activation.capabilities],roots:[...n.activation.roots],atomicGroup:{id:n.id,member:n.member,descriptorSha256:n.descriptorSha256,expectedCount:n.expectedCount,cohortSha256:n.cohortSha256}}}function Vl(n,e){return n.ino===e.ino&&n.generation===e.generation&&n.dataSequence===e.dataSequence&&n.size===e.size&&n.isSymlink===e.isSymlink&&n.deleted===e.deleted&&n.materialized===e.materialized&&n.archivePath===e.archivePath&&n.sourcePath===e.sourcePath&&n.type===e.type&&n.inodeGroup===e.inodeGroup&&n.target===e.target}function Ur(n,e,t){let r=n.content,i=n.inventory,s=n.activation,o=n.integrity,a=n.entries,c=n.url,l=n.mountPrefix,d=n.materialized,u=s?.atomicGroup;if(r===void 0||i===void 0||s===void 0||u===void 0||s.mode!=="first-use"||u.id!==e||u.member!==t||d)throw new Error(`Lazy atomic activation member ${t} changed before snapshot`);if(o?.sha256!==r.sha256||o?.bytes!==r.bytes||c!==(r.transports[0]??""))throw new Error(`Lazy atomic activation member ${t} has inconsistent integrity`);let p=Fs(r),m=Wl(i),f=Gl(s,e,t),h=new Map;for(let A of m)A.type==="file"&&h.set(A.inodeGroup,A.sourcePath);let g=m.filter(A=>A.type!=="directory");if(a.size!==g.length)throw new Error(`Lazy atomic activation member ${t} has inconsistent runtime entries`);let _=g.map(A=>{let w=a.get(A.vfsPath),S=A.type==="symlink",O=S?A.sourcePath:h.get(A.inodeGroup),x=w!==void 0&&(w.sourcePath===A.sourcePath&&w.type===A.type&&w.target===A.target||A.type==="hardlink"&&w.sourcePath===O&&w.type==="file"&&w.target===void 0),R=w===void 0?["missing"]:[O===void 0?"archivePath source":void 0,w.generation===void 0?"generation":void 0,w.dataSequence===void 0?"dataSequence":void 0,w.size!==A.size?"size":void 0,w.isSymlink!==S?"symlink kind":void 0,w.deleted?"deletion state":void 0,w.materialized!==S?"materialization state":void 0,w.archivePath!==O?"archivePath":void 0,x?void 0:"descriptor mapping",w.inodeGroup!==A.inodeGroup?"inode group":void 0].filter(L=>L!==void 0);if(R.length>0)throw new Error(`Lazy atomic activation member ${t} has inconsistent mapping at ${A.vfsPath}: ${R.join(", ")}`);let T=w;return Object.freeze({vfsPath:A.vfsPath,ino:T.ino,generation:T.generation,dataSequence:T.dataSequence,size:T.size,isSymlink:T.isSymlink,deleted:!1,materialized:T.materialized,archivePath:O,sourcePath:A.sourcePath,type:A.type,...A.inodeGroup===void 0?{}:{inodeGroup:A.inodeGroup},...A.target===void 0?{}:{target:A.target}})});Object.freeze(_);let y=Object.freeze({sha256:p.sha256,bytes:p.bytes}),E=$l(p,m,l,f);return Object.freeze({id:e,member:t,descriptorBytes:E,content:p,inventory:m,activation:f,url:p.transports[0]??"",mountPrefix:l,integrity:y,entries:_})}function Ss(n,e,t,r){return Object.freeze({...n,descriptorSha256:e,expectedCount:t,cohortSha256:r})}function ws(n,e){return n.id!==e.id||n.member!==e.member||n.url!==e.url||n.mountPrefix!==e.mountPrefix||n.integrity.sha256!==e.integrity.sha256||n.integrity.bytes!==e.integrity.bytes||n.content.transports.length!==e.content.transports.length||n.content.transports.some((t,r)=>t!==e.content.transports[r])||!Ul(n.descriptorBytes,e.descriptorBytes)||n.entries.length!==e.entries.length?!1:n.entries.every((t,r)=>{let i=e.entries[r];return i!==void 0&&t.vfsPath===i.vfsPath&&Vl(t,i)})}function ql(n,e){let t=Fs(n.content,n.content.transports.map(e));return Object.freeze({...n,content:t,url:t.transports[0]??""})}function As(n){return{paths:Array.from(n.paths),expectedIno:n.ino,expectedGeneration:n.generation,expectedDataSequence:n.dataSequence,data:n.content}}var Yr=class n{fs;imageMetadata;lazyFiles=new Map;lazyArchiveGroups=[];deferredTreeMaterializationHandles=new WeakMap;lazyArchiveInodes=new Map;lazyAtomicGroups=new Map;lazyAtomicGroupByTree=new WeakMap;sealedLazyAtomicStates=new WeakMap;lazyDownloadListeners=new Set;lazyPreparations=new Map;lazyTransport={fetcher:(e,t)=>t===void 0?globalThis.fetch(e):globalThis.fetch(e,t)};constructor(e,t=null){this.fs=e,this.imageMetadata=t}static inodeKey(e,t){return`${e}:${t}`}static canAdoptLegacyLazyStub(e){return(e.mode&Pe)===cr&&e.size===0&&e.dataSequence<=1}reconcileLazyIdentityState(e){for(let[t,r]of this.lazyFiles){let i=e.get(t);if(!i||i.dataSequence!==r.dataSequence||i.paths.length===0){this.lazyFiles.delete(t);continue}r.paths=new Set(i.paths),r.paths.has(r.path)||(r.path=i.paths[0])}this.lazyArchiveInodes.clear();for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(!r?.committed)for(let c of i.snapshot.entries){if(c.isSymlink||c.materialized||c.generation===void 0)continue;let l=n.inodeKey(c.ino,c.generation),d=e.get(l);d!==void 0&&d.dataSequence===c.dataSequence&&d.paths.length>0&&this.lazyArchiveInodes.set(l,t)}continue}let s=t.content!==void 0&&t.inventory!==void 0&&!t.materialized,o=new Map;for(let c of t.entries.values()){if(c.deleted||c.materialized||c.generation===void 0)continue;let l=n.inodeKey(c.ino,c.generation);o.has(l)||o.set(l,c)}let a=new Map(Array.from(t.entries.entries()).filter(([,c])=>c.deleted||c.isSymlink&&!c.deleted));for(let[c,l]of o){let d=e.get(c);if(!(!d||d.dataSequence!==(l.dataSequence??0))){for(let u of d.paths)a.set(u,{...l,ino:d.ino,generation:d.generation,dataSequence:d.dataSequence,deleted:!1,materialized:!1});d.paths.length>0&&this.lazyArchiveInodes.set(c,t)}}t.entries=a,t.materialized=!Array.from(a.values()).some(c=>!c.isSymlink&&!c.materialized)&&!s&&(r===void 0||r.committed)}}validatePendingLazyTreeNamespaceState(e){let t=new Map;for(let r of e.values())for(let i of r.paths){if(t.has(i))throw new Error(`SharedFS namespace identity is ambiguous at ${i}`);t.set(i,r)}for(let r of this.lazyArchiveGroups){let i=this.lazyAtomicGroupByTree.get(r),o=this.sealedLazyAtomicStates.get(r)?.snapshot;if(i?.committed||o===void 0&&r.materialized||o===void 0&&(r.content===void 0||r.inventory===void 0))continue;let a=o?.inventory??r.inventory,c=o===void 0?r.entries:new Map(o.entries.map(m=>[m.vfsPath,m])),l=new Map,d=new Map,u=new Set;for(let m of c.values())m.deleted&&m.inodeGroup!==void 0&&u.add(m.inodeGroup);for(let m of a){if(m.type!=="file"&&m.type!=="hardlink")continue;l.set(m.inodeGroup,(l.get(m.inodeGroup)??0)+1);let f=d.get(m.inodeGroup)??[];f.push(m.vfsPath),d.set(m.inodeGroup,f)}let p=new Set([...u].filter(m=>d.get(m)?.every(f=>!t.has(f))));for(let m of a){let f=t.get(m.vfsPath);if(f===void 0){if(m.inodeGroup!==void 0&&p.has(m.inodeGroup))continue;throw new Error(`Lazy tree namespace entry ${m.vfsPath} is missing from the captured filesystem state`)}let h=m.type==="directory"?it:m.type==="symlink"?Br:cr;if((f.mode&Pe)!==h||(f.mode&4095)!==m.mode)throw new Error(`Lazy tree namespace entry ${m.vfsPath} disagrees with its captured type or mode`);if(m.type==="directory")continue;let g=c.get(m.vfsPath);if(g===void 0||g.ino!==f.ino||g.generation!==f.generation||g.dataSequence!==f.dataSequence)throw new Error(`Lazy tree namespace entry ${m.vfsPath} changed identity before serialization`);if(m.type==="symlink"){let _=new TextEncoder().encode(m.target).byteLength;if(f.linkCount!==1||f.size!==m.size||f.size!==_||f.symlinkTarget!==m.target)throw new Error(`Lazy tree symlink ${m.vfsPath} disagrees with its captured inventory`);continue}if(f.size!==0||f.linkCount!==l.get(m.inodeGroup))throw new Error(`Lazy tree stub ${m.vfsPath} has changed data or undeclared aliases`)}}}lazyFileForStat(e){let t=n.inodeKey(e.ino,e.generation),r=this.lazyFiles.get(t);if(r&&r.dataSequence!==e.dataSequence){this.lazyFiles.delete(t);return}return r}lazyArchiveEntriesForRead(e){let t=this.lazyAtomicGroupByTree.get(e),r=this.sealedLazyAtomicStates.get(e);return r!==void 0&&!t?.committed?r.snapshot.entries:Array.from(e.entries,([i,s])=>({vfsPath:i,...s}))}lazyArchiveForStat(e){let t=n.inodeKey(e.ino,e.generation),r=this.lazyArchiveInodes.get(t);if(!r)return;let i=this.lazyArchiveEntriesForRead(r).filter(o=>o.ino===e.ino&&o.generation===e.generation&&!o.deleted&&!o.materialized);if(i.some(o=>o.dataSequence===e.dataSequence))return r;if(this.lazyArchiveInodes.delete(t),this.sealedLazyAtomicStates.get(r)===void 0)for(let o of i)o.materialized=!0}lazyBackingForStat(e){let t=n.inodeKey(e.ino,e.generation),r=this.lazyFiles.get(t);if(r)return{token:r,path:r.path};let i=this.lazyArchiveInodes.get(t);if(!i)return null;let s=this.lazyArchiveEntriesForRead(i).find(a=>a.ino===e.ino&&a.generation===e.generation&&!a.deleted&&!a.materialized)?.vfsPath;if(s===void 0)return null;let o=this.lazyAtomicGroupByTree.get(i);return o===void 0?{token:i,path:s}:{token:o.token,path:s,atomicGroup:o}}lazyBackingForPath(e){let t=this.lazyArchiveGroups.find(r=>{let i=this.lazyAtomicGroupByTree.get(r),s=this.sealedLazyAtomicStates.get(r)?.snapshot,o=s===void 0?!r.materialized:!i?.committed,a=s?.content??r.content,c=s?.inventory??r.inventory,l=s?.activation??r.activation,d=s?.entries??Array.from(r.entries.values());return o&&a!==void 0&&c!==void 0&&l!==void 0&&d.every(u=>u.deleted||u.materialized||u.isSymlink)&&l.roots.some(u=>u==="/"||e===u||e.startsWith(`${u}/`))});if(t){let r=this.lazyAtomicGroupByTree.get(t);return{token:r?.token??t,path:e,directGroup:t,...r===void 0?{}:{atomicGroup:r}}}try{let r=this.fs.stat(e),i=this.lazyBackingForStat(r);return i?{...i,path:e}:null}catch{return null}}startLazyPreparation(e){let{path:t,token:r}=e,i={status:"pending",promise:Promise.resolve(!1)},s=e.atomicGroup?this.ensureAtomicLazyGroupMaterialized(e.atomicGroup).then(()=>!0):e.directGroup?this.ensureArchiveMaterialized(e.directGroup).then(()=>!0):this.materializePath(t);return i.promise=s.then(o=>(i.status="fulfilled",this.lazyPreparations.get(r)===i&&this.lazyPreparations.delete(r),o),o=>{throw i.status="rejected",i.error=o,o}),i.promise.catch(()=>{}),this.lazyPreparations.set(r,i),i}registerLazyAtomicGroupMembership(e,t=!1){let r=e.activation?.atomicGroup;if(r===void 0)return;let{id:i,member:s}=r;if(e.content===void 0||e.inventory===void 0||e.activation?.mode!=="first-use")throw new Error(`Lazy atomic activation group ${i} accepts only typed first-use trees`);let o=this.lazyAtomicGroups.get(i);if(o===void 0)o={id:i,token:Object.freeze({id:i}),groups:new Map,committed:!1},this.lazyAtomicGroups.set(i,o);else if(o.committed)throw new Error(`Lazy atomic activation group ${i} is already materialized`);if(o.groups.has(s))throw new Error(`Lazy atomic activation group ${i} duplicates member ${s}`);if(Rt(r)){if(o.expectedCount!==void 0&&(o.expectedCount!==r.expectedCount||o.cohortSha256!==r.cohortSha256))throw new Error(`Lazy atomic activation group ${i} has inconsistent seals`);o.expectedCount=r.expectedCount,o.cohortSha256=r.cohortSha256;let a=Ur(e,i,s);this.sealedLazyAtomicStates.set(e,{snapshot:Ss(a,r.descriptorSha256,r.expectedCount,r.cohortSha256),verified:t})}else if(o.expectedCount!==void 0)throw new Error(`Lazy atomic activation group ${i} mixes sealed and unsealed members`);o.groups.set(s,e),this.lazyAtomicGroupByTree.set(e,o)}async sealLazyAtomicGroup(e,t){let r=t.map(l=>ks({id:e,member:l}).member).sort();if(r.length===0||new Set(r).size!==r.length)throw new Error(`Lazy atomic activation group ${e} expected members are invalid`);let i=this.lazyAtomicGroups.get(e);if(i===void 0||i.committed)throw new Error(`Lazy atomic activation group ${e} is not pending`);let s=[...i.groups.keys()].sort();if(JSON.stringify(s)!==JSON.stringify(r))throw new Error(`Lazy atomic activation group ${e} members differ from its seal`);if(i.expectedCount!==void 0){if(i.expectedCount!==r.length||i.cohortSha256===void 0)throw new Error(`Lazy atomic activation group ${e} has an invalid existing seal`);await this.ensureLazyAtomicGroupSealValidated(i,r.map(l=>i.groups.get(l)),!0);return}let o=r.map(l=>Ur(i.groups.get(l),e,l)),a=[];for(let l of o)a.push({member:l.member,descriptorSha256:await lr(l.descriptorBytes,`Lazy atomic member ${l.member}`),source:l});let c=await lr(gs(e,a),`Lazy atomic activation group ${e}`);for(let l of a){let d=i.groups.get(l.member),u=Ur(d,e,l.member);if(!ws(l.source,u))throw new Error(`Lazy atomic activation member ${l.member} changed while sealing`)}for(let l of a){let d=i.groups.get(l.member);d.activation.atomicGroup={id:e,member:l.member,descriptorSha256:l.descriptorSha256,expectedCount:a.length,cohortSha256:c},this.sealedLazyAtomicStates.set(d,{snapshot:Ss(l.source,l.descriptorSha256,a.length,c),verified:!0})}i.expectedCount=a.length,i.cohortSha256=c}async verifyImportedLazyAtomicGroupSeals(){await this.validatePendingLazyAtomicGroupSeals(!0)}guardSynchronousLazyAccess(e){let t=this.lazyBackingForPath(e);if(!t)return;let r=this.lazyPreparations.get(t.token);if(r?.status==="fulfilled"){this.lazyPreparations.delete(t.token);let s=this.lazyBackingForPath(e);if(!s)return;r=this.lazyPreparations.get(s.token)??this.startLazyPreparation(s)}else if(r?.status==="rejected"){this.lazyPreparations.delete(t.token);let s=r.error instanceof Error?r.error.message:String(r.error),o=new Error(`EIO: lazy backing for ${e} failed: ${s}`);throw o.code="EIO",o.cause=r.error,o}else r||(r=this.startLazyPreparation(t));let i=new Error(`EAGAIN: lazy backing for ${e} is being prepared`);throw i.code="EAGAIN",i}invalidateLazyData(e){let t=n.inodeKey(e.ino,e.generation);this.lazyFiles.delete(t);let r=this.lazyArchiveInodes.get(t);if(r){this.lazyArchiveInodes.delete(t);for(let i of r.entries.values())i.ino===e.ino&&i.generation===e.generation&&(i.materialized=!0)}}rewriteLazyNamespacePaths(e,t,r){let i=t.length>1?t.replace(/\/+$/,""):t,s=r.length>1?r.replace(/\/+$/,""):r,o=`${i}/`,a=`${s}/`,c=n.inodeKey(e.ino,e.generation),l=(e.mode&Pe)===it,d=u=>u===i?s:l&&u.startsWith(o)?a+u.slice(o.length):u;for(let[u,p]of this.lazyFiles)!l&&u!==c||(p.paths=new Set(Array.from(p.paths,d)),p.path=d(p.path));for(let u of this.lazyArchiveGroups){let p=new Map;for(let[m,f]of u.entries){let h=f.generation===void 0?null:n.inodeKey(f.ino,f.generation);p.set(l||h===c?d(m):m,f)}u.entries=p,u.inventory&&(u.inventory=u.inventory.map(m=>({...m,vfsPath:d(m.vfsPath),...m.type==="hardlink"&&m.target!==void 0?{target:d(m.target)}:{}}))),u.activation&&(u.activation={...u.activation,roots:u.activation.roots.map(d)})}}get sharedBuffer(){return this.fs.buffer}static create(e,t){return new n(be.mkfs(e,t))}static fromExisting(e){return new n(be.mount(e))}rebaseToNewFileSystem(e){if(!Number.isSafeInteger(e)||e<=0)throw new Error(`Invalid MemoryFileSystem maxByteLength: ${e}`);let t=SharedArrayBuffer,{bytes:r,identities:i}=this.fs.snapshotState();this.reconcileLazyIdentityState(i);let s=this.serializeLazyEntries(),o=this.serializeValidatedLazyArchiveEntries(i),a=new t(r.byteLength);new Uint8Array(a).set(r);let c=new n(be.mount(a,{restoreImage:!0}),this.imageMetadata);c.importLazyEntries(s),c.importLazyArchiveEntriesInternal(o,!1,!0,"verified");let l=Math.min(e,Math.max(r.byteLength,ml)),d=new t(l,{maxByteLength:e}),u=n.create(d,e);u.setImageMetadata(this.imageMetadata);let p=new Set(s.flatMap(f=>f.paths??[f.path])),m=new Set;for(let f of o)if(!f.materialized)for(let h of f.entries)!h.deleted&&!h.isSymlink&&m.add(h.vfsPath);return c.copyPathToFreshFileSystem("/",u,p,m,new Map),u.importLazyEntries(s.map(f=>{let h=u.fs.lstat(f.path);return{...f,ino:h.ino,generation:h.generation,dataSequence:h.dataSequence}})),u.importLazyArchiveEntriesInternal(o.map(f=>({...f,entries:f.entries.map(h=>{if(h.deleted)return{...h,ino:0,generation:void 0};let g=u.fs.lstat(h.vfsPath);return{...h,ino:g.ino,generation:g.generation,dataSequence:g.dataSequence}})})),!1,!0,"verified"),u}getImageMetadata(){return Il(this.imageMetadata)}setImageMetadata(e){this.imageMetadata=e===null?null:hi(e)}subscribeLazyDownloads(e){return this.lazyDownloadListeners.add(e),()=>this.lazyDownloadListeners.delete(e)}setLazyFetcher(e,t={}){this.lazyTransport={fetcher:e,...t.signal===void 0?{}:{signal:t.signal}}}emitLazyDownload(e){if(this.lazyDownloadListeners.size===0)return;let t={...e,t:Tl()};for(let r of this.lazyDownloadListeners)try{r(t)}catch{}}async fetchLazyBytes(e,t){let r=0,i=e.integrity?.bytes??e.fallbackTotalBytes,s={id:e.id,kind:e.kind,url:e.url,path:e.path,mountPrefix:e.mountPrefix};for(let o=0;oe.integrity.bytes)throw new Error(`Lazy ${e.kind} exceeded expected byte count ${e.integrity.bytes}`);this.emitLazyDownload({...s,status:"progress",loadedBytes:r,totalBytes:i})}}}catch(u){try{await c.cancel(u)}catch{}throw u}}finally{c.releaseLock()}let d=Cl(l,r);return ee(t.signal),await ii(d,e.kind,e.integrity),ee(t.signal),this.emitLazyDownload({...s,status:"complete",loadedBytes:r,totalBytes:i??r}),d}catch(a){if(t.signal?.aborted){let d=t.signal.reason,u=d instanceof Error?d.message:String(d);throw this.emitLazyDownload({...s,status:"error",loadedBytes:r,totalBytes:i,error:u}),d}let c=o+1({...y})),activation:u,entries:new Map},g=y=>{let E=y.split("/").filter(Boolean),A="";for(let w=0;wE.vfsPath.split("/").length-A.vfsPath.split("/").length))if(y.type==="directory"){g(y.vfsPath);try{this.fs.mkdir(y.vfsPath,y.mode),this.fs.chmod(y.vfsPath,y.mode)}catch{if((this.fs.lstat(y.vfsPath).mode&Pe)!==it)throw new Error(`Lazy tree directory collides at ${y.vfsPath}`)}}for(let y of l){if(y.type!=="symlink")continue;g(y.vfsPath),this.fs.symlink(y.target,y.vfsPath);let E=this.fs.lstat(y.vfsPath);h.entries.set(y.vfsPath,{ino:E.ino,generation:E.generation,dataSequence:E.dataSequence,size:y.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:y.sourcePath,sourcePath:y.sourcePath,type:"symlink",target:y.target})}let _=new Map;for(let y of l){if(y.type!=="file")continue;g(y.vfsPath);let E=this.fs.createLazyStub(y.vfsPath,y.mode);this.invalidateLazyData(E),_.set(y.inodeGroup,E);let A={ino:E.ino,generation:E.generation,dataSequence:E.dataSequence,size:y.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:y.sourcePath,sourcePath:y.sourcePath,type:"file",inodeGroup:y.inodeGroup};h.entries.set(y.vfsPath,A)}for(let y of l){if(y.type!=="hardlink")continue;let E=p.get(y.inodeGroup);g(y.vfsPath),this.fs.link(E.vfsPath,y.vfsPath);let A=this.fs.lstat(y.vfsPath),w=_.get(y.inodeGroup);if(A.ino!==w.ino||A.generation!==w.generation)throw new Error(`Lazy tree hardlink ${y.vfsPath} did not share its inode`);h.entries.set(y.vfsPath,{ino:A.ino,generation:A.generation,dataSequence:A.dataSequence,size:y.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:E.sourcePath,sourcePath:y.sourcePath,type:"hardlink",inodeGroup:y.inodeGroup,target:y.target})}if(m!==void 0)for(let y of l)this.lchown(y.vfsPath,m.uid,m.gid);for(let y of h.entries.values())y.isSymlink||y.generation===void 0||this.lazyArchiveInodes.set(n.inodeKey(y.ino,y.generation),h);return this.lazyArchiveGroups.push(h),this.registerLazyAtomicGroupMembership(h),h}registerLazyTreeWithMaterializationHandle(e,t,r="/",i,s){let o=this.registerLazyTreeInternal(e,t,r,i,!0,s),a=Object.freeze({[dl]:!0});return this.deferredTreeMaterializationHandles.set(a,o),a}registerLazyArchiveFromEntries(e,t,r,i,s){let o=fr(r),a=Ol(e,t,o,i);a.some(({entry:l})=>!l.isDirectory&&!l.isSymlink)&&this.assertCanRegisterPendingLazyArchiveGroup();let c={...s?{content:Zr({decoder:"zip-v1",mediaType:"application/zip",sha256:s.sha256,bytes:s.bytes,expandedBytes:a.reduce((l,d)=>l+d.entry.uncompressedSize,0),sourceEntryCount:a.length,transports:[e]})}:{},url:e,mountPrefix:o,integrity:hr(s),materialized:!1,entries:new Map};for(let{entry:l,vfsPath:d}of a){if(l.isDirectory)continue;let u=d.split("/").filter(Boolean),p="";for(let m=0;ml.deleted||l.materialized),this.lazyArchiveGroups.push(c),c}importLazyArchiveEntries(e){this.importLazyArchiveEntriesInternal(e,!1,!0,"reject")}async importVerifiedLazyArchiveEntries(e){let t=structuredClone(e),r=this.exportLazyArchiveEntries(),i=n.fromExisting(this.sharedBuffer);i.importLazyArchiveEntriesInternal([...r,...t],!1,!0,"pending"),await i.verifyImportedLazyAtomicGroupSeals(),this.importLazyArchiveEntriesInternal(t,!1,!0,"verified")}importLazyArchiveEntriesInternal(e,t,r,i){let s=Ne(e,"Serialized lazy archive groups",0,Os).map((d,u)=>{if(typeof d!="object"||d===null||Array.isArray(d))throw new Error(`Serialized lazy archive group ${u} must be an object`);let p=d.kind;if(p===dr||p===ci||p===ot)return Ns(d,p);if(p===ur)return di(d,!1);if(p!==void 0)throw new Error(`Serialized lazy archive group ${u} has an unsupported kind`);if(r)throw new Error(`Serialized lazy archive group ${u} is missing its kind discriminator`);return di(d,!0)}),o=this.fs.identityState();this.reconcileLazyIdentityState(o);let a=[...this.serializeValidatedLazyArchiveEntries(o),...s];ys(a);let c=[],l=new Map;for(let d of s){let u=new Map,p=d.mountPrefix.replace(/\/+$/,""),m=d.content!==void 0&&d.inventory!==void 0&&d.activation!==void 0,f=m?new Map(d.inventory.map(w=>[w.vfsPath,w])):null,h=m?new Map(d.inventory.map(w=>[Xr(w),w])):null,g=new Map,_=new Map,y=new Map;for(let w of d.entries){let S=null,O=d.materialized||w.materialized===!0||w.isSymlink;if(!w.deleted&&!O){if((w.generation===void 0||w.dataSequence===void 0)&&!t)throw new Error("Live lazy-archive metadata requires inode generation and data sequence");try{S=this.fs.lstat(w.vfsPath)}catch{if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} is missing from the filesystem`);continue}if(S.ino!==w.ino){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} has a different inode`);continue}if(w.generation!==void 0&&S.generation!==w.generation){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} has a different generation`);continue}if(w.dataSequence===void 0){if(!n.canAdoptLegacyLazyStub(S)){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} is not pristine`);continue}}else if(S.dataSequence!==w.dataSequence){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} has a different data sequence`);continue}if(m){y.set(w.vfsPath,S);let R=f.get(w.vfsPath),T=h.get(Xr(w))??R;if(!T||(S.mode&Pe)!==cr||S.size!==0||(S.mode&4095)!==T.mode||R?.inodeGroup!==void 0&&R.inodeGroup!==T.inodeGroup)throw new Error(`Serialized lazy tree stub ${w.vfsPath} disagrees with its inventory`);let L=n.inodeKey(S.ino,S.generation),D=w.inodeGroup,q=g.get(D),F=_.get(L);if(q!==void 0&&q!==L||F!==void 0&&F!==D)throw new Error(`Serialized lazy tree inode group ${D} disagrees with the filesystem`);g.set(D,L),_.set(L,D)}}u.set(w.vfsPath,{ino:w.ino,generation:S?.generation??w.generation,dataSequence:S?.dataSequence??w.dataSequence,size:w.size,isSymlink:w.isSymlink,deleted:w.deleted,materialized:O,archivePath:w.archivePath??w.vfsPath.slice(p.length+1),sourcePath:w.sourcePath??w.archivePath??w.vfsPath.slice(p.length+1),type:w.type??(w.isSymlink?"symlink":"file"),inodeGroup:w.inodeGroup,target:w.target})}if(m){let w=new Map;for(let S of d.inventory){if(S.type==="file"||S.type==="hardlink"){w.set(S.inodeGroup,(w.get(S.inodeGroup)??0)+1);continue}let O;try{O=this.fs.lstat(S.vfsPath)}catch{throw new Error(`Serialized lazy tree namespace entry ${S.vfsPath} is missing from the filesystem`)}let x=S.type==="directory"?it:Br;if((O.mode&Pe)!==x||(O.mode&4095)!==S.mode||S.type==="symlink"&&(O.size!==new TextEncoder().encode(S.target).byteLength||this.fs.readlink(S.vfsPath)!==S.target))throw new Error(`Serialized lazy tree namespace entry ${S.vfsPath} disagrees with its inventory`);S.type==="symlink"&&u.set(S.vfsPath,{ino:O.ino,generation:O.generation,dataSequence:O.dataSequence,size:S.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:S.sourcePath,sourcePath:S.sourcePath,type:"symlink",target:S.target})}if(d.activation?.atomicGroup!==void 0)for(let S of d.inventory){if(S.type!=="file"&&S.type!=="hardlink")continue;if(y.get(S.vfsPath).linkCount!==w.get(S.inodeGroup))throw new Error(`Serialized lazy atomic tree inode group ${S.inodeGroup} has undeclared aliases`)}}let E=d.content===void 0?void 0:Zr(d.content),A={content:E,url:E?.transports[0]??d.url,mountPrefix:d.mountPrefix,integrity:E?{sha256:E.sha256,bytes:E.bytes}:hr(d.integrity),materialized:d.materialized||!(E&&d.inventory)&&Array.from(u.values()).every(w=>w.deleted||w.materialized),inventory:d.inventory?.map(w=>({...w})),activation:d.activation?{mode:d.activation.mode,capabilities:[...d.activation.capabilities],roots:[...d.activation.roots],...d.activation.atomicGroup===void 0?{}:{atomicGroup:{...d.activation.atomicGroup}}}:void 0,entries:u};if(c.push(A),!A.materialized){for(let[,w]of u)if(!w.deleted&&!w.materialized&&w.generation!==void 0){let S=n.inodeKey(w.ino,w.generation),O=l.get(S);if(O!==void 0&&O!==A)throw new Error(`Serialized lazy archive groups share pending inode ${S}`);if(this.lazyArchiveInodes.has(S))throw new Error(`Serialized lazy archive group collides with pending inode ${S}`);l.set(S,A)}}}for(let d of c){let u=d.activation?.atomicGroup;if(u!==void 0&&this.lazyAtomicGroups.get(u.id)?.committed)throw new Error(`Lazy atomic activation group ${u.id} is already materialized`)}if(i==="reject"&&c.some(d=>{let u=d.activation?.atomicGroup;return u!==void 0&&Rt(u)}))throw new Error("Sealed lazy archive registrations require importVerifiedLazyArchiveEntries()");this.lazyArchiveGroups.push(...c);for(let d of c)this.registerLazyAtomicGroupMembership(d,i==="verified");for(let[d,u]of l)this.lazyArchiveInodes.set(d,u)}rewriteLazyArchiveUrls(e){for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0&&!r?.committed){this.assertLazyAtomicSnapshotMatchesPublic(t);let s=ql(i.snapshot,e);t.content=Es(s.content),t.url=s.url,t.integrity={...s.integrity},i.snapshot=s;continue}t.content?(t.content={...t.content,transports:t.content.transports.map(e)},t.url=t.content.transports[0]):t.url=e(t.url)}}serializeLazyArchiveEntries(){let e=[];for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(r?.committed)continue;let c=i.snapshot;if(c.content.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");e.push({kind:ot,content:Es(c.content),inventory:c.inventory.map(l=>({...l})),activation:Hl(c),url:c.url,mountPrefix:c.mountPrefix,integrity:{...c.integrity},materialized:!1,entries:c.entries.filter(l=>!l.deleted&&!l.materialized).map(({vfsPath:l,...d})=>({vfsPath:l,...d}))});continue}let s=Array.from(t.entries,([c,l])=>({vfsPath:c,ino:l.ino,generation:l.generation,dataSequence:l.dataSequence,size:l.size,isSymlink:l.isSymlink,deleted:l.deleted,materialized:l.materialized,archivePath:l.archivePath,sourcePath:l.sourcePath,type:l.type,inodeGroup:l.inodeGroup,target:l.target})).filter(c=>!c.deleted&&!c.materialized);if(s.length===0&&!(t.content&&t.inventory&&!t.materialized))continue;let o=t.content!==void 0&&t.inventory!==void 0&&t.activation!==void 0;if(o&&t.content.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");let a=t.activation?.atomicGroup;if(a!==void 0&&!Rt(a))throw new Error(`Lazy atomic activation group ${a.id} must be sealed before serialization`);e.push(o?{kind:a!==void 0?ot:t.content.source===void 0?dr:ci,content:t.content,inventory:t.inventory,activation:t.activation,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:s}:{kind:ur,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:s})}return e}serializeValidatedLazyArchiveEntries(e){this.assertPendingLazyAtomicSnapshotsReadyForSerialization();let t=this.serializeLazyArchiveEntries();return ys(t),this.validatePendingLazyTreeNamespaceState(e),t}exportLazyArchiveEntries(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),this.serializeValidatedLazyArchiveEntries(e)}pendingDeferredTreeUsage(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),bs(this.serializeValidatedLazyArchiveEntries(e))}assertCanAppendDeferredTreeUsage(e){ui(e);let t=this.pendingDeferredTreeUsage();ui({groups:t.groups+e.groups,archiveBytes:t.archiveBytes+e.archiveBytes,expandedBytes:t.expandedBytes+e.expandedBytes,payloadBytes:t.payloadBytes+e.payloadBytes,entries:t.entries+e.entries})}assertCanRegisterPendingLazyArchiveGroup(){if(this.reconcileLazyIdentityState(this.fs.identityState()),this.lazyArchiveGroups.filter(t=>{let r=this.lazyAtomicGroupByTree.get(t);return this.sealedLazyAtomicStates.get(t)?.snapshot!==void 0?!r?.committed:!t.materialized&&(t.content!==void 0&&t.inventory!==void 0||Array.from(t.entries.values()).some(s=>!s.deleted&&!s.materialized))}).length>=xe.maxGroups)throw new Error(`Cannot register another lazy archive group: ${xe.maxGroups} pending groups already exist`)}async preparePath(e){let t=!1,r=Math.max(3,this.lazyArchiveGroups.length+1);for(let i=0;i!s.materialized&&s.activation?.mode==="boot-prefetch"),t=0,r,i=Array.from({length:Math.min(e.length,yl)},async()=>{for(;r===void 0;){let s=t;if(t+=1,s>=e.length)return;try{await this.prepareLazyTreeGroup(e[s])}catch(o){r??=o}}});if(await Promise.all(i),r!==void 0)throw r;return e.length}async materializeRegisteredDeferredTree(e,t){let r=this.deferredTreeMaterializationHandles.get(e);if(r===void 0)throw new Error("Deferred-tree handle was not issued by this filesystem");let i=this.lazyAtomicGroupByTree.get(r);if(i!==void 0)throw new Error(`Deferred tree belongs to atomic activation group ${i.id}; materialize the complete group instead`);if(r.materialized)return!1;let s=this.lazyPreparations.get(r);if(s!==void 0)return s.promise;let o=new Uint8Array(t.byteLength);o.set(t);let a={status:"pending",promise:Promise.resolve(!1)};a.promise=Promise.resolve().then(async()=>(await ii(o,"tree",r.integrity),await this.materializeArchiveBytes(r,o),!0)).then(c=>(a.status="fulfilled",c),c=>{throw a.status="rejected",a.error=c,c}),a.promise.catch(()=>{}),this.lazyPreparations.set(r,a);try{return await a.promise}finally{this.lazyPreparations.get(r)===a&&this.lazyPreparations.delete(r)}}async prepareLazyTreeGroup(e){let t=this.lazyAtomicGroupByTree.get(e);if(t?.committed||t===void 0&&e.materialized)return!1;let r=this.sealedLazyAtomicStates.get(e)?.snapshot,i={token:t?.token??e,path:r?.activation.roots[0]??e.activation?.roots[0]??e.mountPrefix,directGroup:e,...t===void 0?{}:{atomicGroup:t}},s=this.lazyPreparations.get(i.token)??this.startLazyPreparation(i);try{return await s.promise}finally{this.lazyPreparations.get(i.token)===s&&this.lazyPreparations.delete(i.token)}}async ensureMaterialized(e){return this.preparePath(e)}async materializePath(e){if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0)return!1;let t;try{t=this.fs.stat(e)}catch{return!1}let r=n.inodeKey(t.ino,t.generation),i=this.lazyFiles.get(r);if(i){let o=this.lazyTransport,a=await this.fetchLazyBytes({id:`file:${t.ino}`,kind:"file",url:i.url,path:i.path,fallbackTotalBytes:i.size},o);for(let c=0;c<3;c++){if(this.lazyFiles.get(r)!==i)return!1;for(let l of new Set([e,...i.paths]))if(ee(o.signal),this.fs.replaceIfIdentity(l,i.ino,i.generation,i.dataSequence,a))return i.path=l,this.lazyFiles.delete(r),!0;this.reconcileLazyIdentityState(this.fs.identityState())}throw new Error(`Lazy file kept changing names while materializing: ${e}`)}let s=this.lazyArchiveInodes.get(r);return s?(await this.ensureArchiveMaterialized(s,{path:e,ino:t.ino,generation:t.generation}),!this.lazyArchiveInodes.has(r)):!1}async decodeAndValidateLazyTree(e,t,r){let i=r?.content??e.content,s=r?.inventory??e.inventory;if(!i||!s)throw new Error("Lazy tree is missing its decoder or complete inventory");let o=new Map,a=new Map(s.map(p=>[p.vfsPath,p]));if(i.source!==void 0)for(let p of i.source.entries)o.set(p.sourcePath,p);else for(let p of s){if(p.type==="hardlink"){let f=a.get(p.target);if(!f)throw new Error(`Lazy tree hardlink target disappeared: ${p.target}`);if(p.sourcePath===f.sourcePath)continue}if(o.get(p.sourcePath))throw new Error(`Lazy tree inventory duplicates source member ${p.sourcePath}`);o.set(p.sourcePath,{sourcePath:p.sourcePath,type:p.type,mode:p.mode,size:p.size,...p.type==="symlink"?{target:p.target}:{},...p.type==="hardlink"?{target:a.get(p.target)?.sourcePath}:{}})}let c=new Map,l=0;if(i.decoder==="zip-v1"){let{parseZipCentralDirectory:p,extractZipEntryBounded:m}=await Promise.resolve().then(()=>(Zn(),qn)),f=p(t);if(f.length!==i.sourceEntryCount||f.length!==o.size)throw new Error("Lazy ZIP tree decoded inventory counts differ from its descriptor");for(let h of f){let g=h.isDirectory?h.fileName.replace(/\/$/,""):h.fileName;if(c.has(g))throw new Error(`Lazy ZIP tree duplicates source member ${g}`);let _=o.get(g);if(!_)throw new Error(`Lazy ZIP tree has undeclared source member ${g}`);if(l+=h.uncompressedSize,l>i.expandedBytes||h.uncompressedSize!==_.size)throw new Error(`Lazy ZIP tree member ${g} exceeds its inventory`);let y=h.isDirectory?"directory":h.isSymlink?"symlink":"file",E=i.modePolicy==="portable-posix-v1"?y==="directory"?493:y==="symlink"?511:(h.mode&73)!==0?493:420:h.mode&4095;if(y!==_.type||E!==_.mode)throw new Error(`Lazy ZIP tree member ${g} differs from inventory`);if(h.isDirectory)c.set(g,{type:"directory",mode:E});else{let A=m(t,h,_.size);if(h.isSymlink){let w;try{w=new TextDecoder("utf-8",{fatal:!0}).decode(A)}catch{throw new Error(`Lazy ZIP tree symlink ${g} is not UTF-8`)}c.set(g,{type:"symlink",mode:E,target:w})}else c.set(g,{type:"file",mode:E,data:A})}}}else{let{parseTarGzip:p}=await Promise.resolve().then(()=>(us(),ls)),m=p(t,{label:`Lazy tree ${i.sha256}`,limits:{maxCompressedBytes:i.bytes,maxUncompressedBytes:i.expandedBytes,maxEntries:i.sourceEntryCount}});l=new DataView(t.buffer,t.byteOffset,t.byteLength).getUint32(t.byteLength-4,!0);for(let f of m){if(c.has(f.path))throw new Error(`Lazy TAR tree duplicates source member ${f.path}`);f.type==="file"?c.set(f.path,{type:"file",mode:f.mode,data:f.data}):f.type==="directory"?c.set(f.path,{type:"directory",mode:f.mode}):c.set(f.path,{type:f.type,mode:f.mode,target:f.linkName})}}if(c.size!==i.sourceEntryCount||c.size!==o.size||l!==i.expandedBytes)throw new Error("Lazy tree decoded inventory counts differ from its descriptor");for(let[p,m]of o){let f=c.get(p);if(!f)throw new Error(`Lazy tree is missing source member ${p}`);let h=m.type;if(f.type!==h)throw new Error(`Lazy tree member ${p} is ${f.type}, expected ${h}`);if((f.mode&4095)!==m.mode)throw new Error(`Lazy tree member ${p} mode differs from inventory`);if(h==="file"&&f.data?.byteLength!==m.size)throw new Error(`Lazy tree member ${p} size differs from inventory`);if(h==="symlink"&&f.target!==m.target)throw new Error(`Lazy tree symlink ${p} target differs from inventory`);if(h==="hardlink"&&f.target!==m.target)throw new Error(`Lazy tree hardlink ${p} target differs from inventory`)}let d=new Set(s.flatMap(p=>p.materialization==="archive-homebrew-relocate"?[p.sourcePath]:[]));if(i.source!==void 0){let p=new Map(i.source.entries.map(h=>[h.sourcePath,h])),m=zs(i.source.entries),f=i.source.entries.filter(h=>h.sourcePath==="INSTALL_RECEIPT.json"||h.sourcePath.endsWith("/INSTALL_RECEIPT.json"));if(f.length>1)throw new Error(`Lazy Homebrew bottle has ${f.length} INSTALL_RECEIPT.json source members, expected at most one`);if(f.length===0){if(d.size>0)throw new Error("Lazy Homebrew bottle marks receipt relocation without INSTALL_RECEIPT.json")}else{let h=f[0],g=h.type==="file"?h:m.get(h.sourcePath),_=g===void 0?void 0:c.get(g.sourcePath);if(g?.type!=="file"||_?.type!=="file"||_.data===void 0)throw new Error("Lazy Homebrew bottle INSTALL_RECEIPT.json is not regular");let y=Do(_.data),E=h.sourcePath.lastIndexOf("/"),A=E<0?"":h.sourcePath.slice(0,E),w=new Set(y.changedFiles.map(O=>A.length===0?O:`${A}/${O}`));if(d.size!==w.size||[...d].some(O=>!w.has(O)))throw new Error("Lazy Homebrew bottle relocation markers differ from INSTALL_RECEIPT.json");let S=new Set;for(let O of w){let x=p.get(O),R=x?.type==="file"?x:x===void 0?void 0:m.get(x.sourcePath),T=R===void 0?void 0:c.get(R.sourcePath);if(R?.type!=="file"||T?.type!=="file"||T.data===void 0)throw new Error(`Lazy Homebrew bottle changed source ${O} is not regular`);S.has(R.sourcePath)||(T.data=Ko(T.data,y,O),S.add(R.sourcePath))}}}else if(d.size>0)throw new Error("Lazy tree receipt relocation requires original-bottle source truth");let u=new Map;for(let p of s){if(p.type!=="file"||p.materialization==="descriptor")continue;let m=c.get(p.sourcePath);if(m?.type!=="file"||!m.data)throw new Error(`Lazy tree has no file content for ${p.sourcePath}`);u.set(p.sourcePath,m.data)}return u}async ensureArchiveMaterialized(e,t){let r=this.lazyAtomicGroupByTree.get(e);if(r!==void 0){if(r.committed)return;let o=this.sealedLazyAtomicStates.get(e)?.snapshot,a=this.lazyPreparations.get(r.token)??this.startLazyPreparation({token:r.token,path:o?.activation.roots[0]??e.activation?.roots[0]??e.mountPrefix,atomicGroup:r});try{await a.promise}finally{this.lazyPreparations.get(r.token)===a&&this.lazyPreparations.delete(r.token)}return}if(e.materialized)return;let i=this.lazyTransport,s=await this.fetchLazyArchiveData(e,i);ee(i.signal),await this.materializeArchiveBytes(e,s,t,i.signal)}async fetchLazyArchiveData(e,t,r){let i=r?.content??e.content,s=r?.inventory??e.inventory,o=i!==void 0&&s!==void 0,a=r?.mountPrefix??e.mountPrefix,c=r?.integrity??e.integrity,l=o?i.transports:[r?.url??e.url],d=[],u=null;for(let[p,m]of l.entries())try{u=await this.fetchLazyBytes({id:`archive:${a}:${i?.sha256??m}:${p}`,kind:o?"tree":"archive",url:m,mountPrefix:a,integrity:c},t);break}catch(f){if(ee(t.signal),Ls(f))throw f;d.push(f instanceof Error?f.message:String(f))}if(ee(t.signal),u===null)throw new Error(`All ${l.length} lazy ${o?"tree":"archive"} transports failed: ${d.join("; ")}`);return u}async materializeArchiveBytes(e,t,r,i){if(ee(i),e.materialized)return;let s=await this.prepareLazyArchiveContents(e,t,i),o=r?n.inodeKey(r.ino,r.generation):null;for(let a=0;a<3;a++){let c=this.collectLazyArchiveReplacements(e,s,r);if(c.size>0&&(ee(i),!this.fs.replaceManyIfIdentities(Array.from(c.values(),As)))){if(this.reconcileLazyIdentityState(this.fs.identityState()),o&&!this.lazyArchiveInodes.has(o))return;continue}if(ee(i),this.publishLazyArchiveReplacements(e,c),e.materialized||(this.reconcileLazyIdentityState(this.fs.identityState()),o&&!this.lazyArchiveInodes.has(o)))return}if(o&&this.lazyArchiveInodes.has(o))throw new Error(`Lazy archive member kept changing names while materializing: ${r?.path}`)}async prepareLazyArchiveContents(e,t,r,i){ee(r);let s=i?.content??e.content,o=i?.inventory??e.inventory,c=s!==void 0&&o!==void 0?await this.decodeAndValidateLazyTree(e,t,i):null;ee(r);let{parseZipCentralDirectory:l,extractZipEntry:d}=await Promise.resolve().then(()=>(Zn(),qn));ee(r);let u=c?[]:l(t),p=new Map;for(let _ of u){if(p.has(_.fileName))throw new Error(`Lazy archive contains duplicate member: ${_.fileName}`);p.set(_.fileName,_)}let f=(i?.mountPrefix??e.mountPrefix).replace(/\/+$/,""),h=new Map,g=i===void 0?Array.from(e.entries):i.entries.map(_=>[_.vfsPath,_]);for(let[_,y]of g){if(y.deleted||y.materialized)continue;let E=y.archivePath??_.slice(f.length+1),A=c?void 0:p.get(E),w=c?.get(E);if(c){if(w===void 0||w.byteLength!==y.size)throw new Error(`Lazy tree member ${E} does not match its registered metadata`)}else if(A===void 0||A.isDirectory||A.isSymlink||A.uncompressedSize!==y.size)throw new Error(`Lazy archive member ${E} does not match its registered metadata`);if(y.generation===void 0)continue;let S=n.inodeKey(y.ino,y.generation),O=h.get(S);if(O&&O.archivePath!==E)throw new Error(`Lazy archive aliases for inode ${S} name different members`);if(!O){let x=w??d(t,A);if(x.byteLength!==y.size)throw new Error(`Lazy archive member ${E} extracted ${x.byteLength} bytes, expected ${y.size}`);h.set(S,{archivePath:E,content:x})}}return h}collectLazyArchiveReplacements(e,t,r,i){let s=new Map,o=i===void 0?Array.from(e.entries):i.entries.map(a=>[a.vfsPath,a]);for(let[a,c]of o){if(c.deleted||c.materialized||c.generation===void 0)continue;let l=n.inodeKey(c.ino,c.generation);if(this.lazyArchiveInodes.get(l)!==e)continue;let d=t.get(l);if(!d)throw new Error(`Lazy archive has no extracted content for inode ${l}`);let u=s.get(l);u||(u={ino:c.ino,generation:c.generation,dataSequence:c.dataSequence??0,paths:new Set,content:d.content},s.set(l,u)),u.paths.add(a),r&&r.ino===c.ino&&r.generation===c.generation&&u.paths.add(r.path)}return s}publishLazyArchiveReplacements(e,t){for(let[r,i]of t){this.lazyArchiveInodes.delete(r);for(let s of e.entries.values())s.ino===i.ino&&s.generation===i.generation&&(s.materialized=!0)}e.materialized=Array.from(e.entries.values()).every(r=>r.deleted||r.materialized)}collectAtomicTreeNamespace(e,t){let r=new Set,i=new Map,s=new Map,o=new Map(t.entries.map(c=>[c.vfsPath,c]));for(let c of t.inventory)(c.type==="file"||c.type==="hardlink")&&s.set(c.inodeGroup,(s.get(c.inodeGroup)??0)+1);let a=[];for(let c of t.inventory){let l;try{l=this.fs.lstat(c.vfsPath)}catch{throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`)}let d=c.type==="directory"?it:c.type==="symlink"?Br:cr;if((l.mode&Pe)!==d||(l.mode&4095)!==c.mode)throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`);if(c.type==="symlink"){let u=o.get(c.vfsPath);if(u===void 0||!u.isSymlink||u.deleted||u.ino!==l.ino||u.generation!==l.generation||u.dataSequence!==l.dataSequence||this.fs.readlink(c.vfsPath)!==c.target)throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`)}else if(c.type==="file"||c.type==="hardlink"){let u=o.get(c.vfsPath);if(u===void 0||u.deleted||u.materialized||u.isSymlink||u.generation===void 0||u.inodeGroup!==c.inodeGroup||u.ino!==l.ino||u.generation!==l.generation||u.dataSequence!==l.dataSequence||l.size!==0||l.linkCount!==s.get(c.inodeGroup))throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`);let p=n.inodeKey(u.ino,u.generation);if(this.lazyArchiveInodes.get(p)!==e)throw new Error(`Lazy atomic tree lost deferred ownership of ${c.vfsPath}`);let m=i.get(c.inodeGroup);if(m!==void 0&&m!==p)throw new Error(`Lazy atomic tree split hard links at ${c.vfsPath}`);i.set(c.inodeGroup,p),r.add(p)}a.push({path:c.vfsPath,expectedIno:l.ino,expectedGeneration:l.generation,expectedDataSequence:l.dataSequence,expectedMode:l.mode,expectedLinkCount:l.linkCount,expectedSize:l.size,expectedUid:l.uid,expectedGid:l.gid})}return{guards:a,pendingIdentities:r.size}}assertLazyAtomicSnapshotMatchesPublic(e){let t=this.sealedLazyAtomicStates.get(e),r=t?.snapshot,i=e.activation?.atomicGroup,s=r?.member??i?.member??"unknown",o;if(r!==void 0)try{o=Ur(e,r.id,r.member)}catch{o=void 0}if(t===void 0||r===void 0||i===void 0||!Rt(i)||i.id!==r.id||i.member!==r.member||i.descriptorSha256!==r.descriptorSha256||i.expectedCount!==r.expectedCount||i.cohortSha256!==r.cohortSha256||o===void 0||!ws(r,o))throw new Error(`Lazy atomic activation member ${s} changed after sealing`);return t}assertPendingLazyAtomicSnapshotsReadyForSerialization(){for(let e of this.lazyArchiveGroups){if(!this.sealedLazyAtomicStates.has(e)||this.lazyAtomicGroupByTree.get(e)?.committed)continue;let r=this.assertLazyAtomicSnapshotMatchesPublic(e);if(!r.verified)throw new Error(`Lazy atomic activation group ${r.snapshot.id} has not been cryptographically verified after import`)}}async validatePendingLazyAtomicGroupSeals(e){for(let t of this.lazyAtomicGroups.values()){if(t.committed||t.expectedCount===void 0||t.cohortSha256===void 0)continue;let r=[...t.groups.entries()].sort(([i],[s])=>is?1:0).map(([,i])=>i);await this.ensureLazyAtomicGroupSealValidated(t,r,e)}}async ensureLazyAtomicGroupSealValidated(e,t,r){if(this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r))return;let i=e.sealValidationFlight;if(i===void 0&&(i=this.validateLazyAtomicGroupSealOnce(e,t),e.sealValidationFlight=i,i.then(()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)},()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)})),await i,!this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r))throw new Error(`Lazy atomic activation group ${e.id} seal verification did not authenticate every member`)}assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r){if(e.committed)return!0;if(e.expectedCount===void 0||e.cohortSha256===void 0||t.length!==e.expectedCount)throw new Error(`Lazy atomic activation group ${e.id} is not completely sealed`);let i=!0,s=[];for(let o of t){let a=this.assertLazyAtomicSnapshotMatchesPublic(o),c=a.snapshot;if(c.id!==e.id||c.expectedCount!==e.expectedCount||c.cohortSha256!==e.cohortSha256||e.groups.get(c.member)!==o)throw new Error(`Lazy atomic activation group ${e.id} has an inconsistent member`);i&&=a.verified,s.push(a)}if(i&&r)for(let o=0;ofh?1:0).map(([,f])=>f);if(t.length===0)throw new Error(`Lazy atomic activation group ${e.id} has no trees`);if(await this.ensureLazyAtomicGroupSealValidated(e,t,!0),e.committed)return;let r=t.map(f=>this.sealedLazyAtomicStates.get(f).snapshot),i=t.map((f,h)=>({group:f,...this.collectAtomicTreeNamespace(f,r[h])})),s=this.lazyTransport,o=new Array(t.length),a=0,c=!1,l,d=Array.from({length:Math.min(gl,t.length)},async()=>{for(;!c;){let f=a++;if(f>=t.length)return;let h=t[f],g=r[f];try{let _=await this.fetchLazyArchiveData(h,s,g);ee(s.signal),o[f]={group:h,snapshot:g,contents:await this.prepareLazyArchiveContents(h,_,s.signal,g)}}catch(_){c||(c=!0,l=_)}}});if(await Promise.all(d),c)throw o.fill(void 0),l;ee(s.signal);for(let f of t)this.assertLazyAtomicSnapshotMatchesPublic(f);let u=[],p=[],m=[];for(let f=0;f{let a=this.lazyAtomicGroupByTree.get(o);return this.sealedLazyAtomicStates.get(o)?.snapshot===void 0?!o.materialized&&o.content!==void 0&&o.inventory!==void 0:!a?.committed});if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0&&r.length===0)return;let i=Array.from(this.lazyFiles.values(),o=>o.path);for(let o of i)await this.ensureMaterialized(o);let s=new Set(this.lazyArchiveInodes.values());for(let o of r)s.add(o);for(let o of s)await this.prepareLazyTreeGroup(o)}this.reconcileLazyIdentityState(this.fs.identityState());let e=this.lazyArchiveGroups.some(t=>{let r=this.lazyAtomicGroupByTree.get(t);return this.sealedLazyAtomicStates.get(t)?.snapshot===void 0?!t.materialized&&t.content!==void 0&&t.inventory!==void 0:!r?.committed});if(this.lazyFiles.size!==0||this.lazyArchiveInodes.size!==0||e)throw new Error("Cannot create a self-contained VFS image while lazy entries remain pending")}async saveImage(e){e?.materializeAll&&await this.materializeAllLazyEntries(),await this.validatePendingLazyAtomicGroupSeals(!1);let{bytes:t,identities:r}=this.fs.snapshotState({normalizeTimestampsMs:e?.normalizeTimestampsMs});this.reconcileLazyIdentityState(r);let i=this.serializeLazyEntries(),s=i.length>0,o=s?new TextEncoder().encode(JSON.stringify(i)):new Uint8Array(0);if(o.byteLength>Gr)throw new Error(`VFS image lazy metadata exceeds ${Gr} bytes`);let a=this.serializeValidatedLazyArchiveEntries(r),c=a.length>0,l=c?new TextEncoder().encode(JSON.stringify(a)):new Uint8Array(0);if(l.byteLength>Hr)throw new Error(`VFS image lazy archive metadata exceeds ${Hr} bytes`);let d=e?.metadata===void 0?this.imageMetadata:e.metadata,u=Rl(d),p=u.byteLength>0,m=c?4+l.byteLength:0,f=p?4+u.byteLength:0,h=he+t.byteLength+4+o.byteLength+m+f,g=new Uint8Array(h),_=new DataView(g.buffer);_.setUint32(0,oi,!0),_.setUint32(4,si,!0),_.setUint32(8,(s?ei:0)|(c?Wr:0)|(c?ri:0)|(p?ti:0),!0),_.setUint32(12,t.byteLength,!0),g.set(t,he);let y=he+t.byteLength;if(_.setUint32(y,o.byteLength,!0),o.byteLength>0&&g.set(o,y+4),c){let E=y+4+o.byteLength;_.setUint32(E,l.byteLength,!0),g.set(l,E+4)}if(p){let E=y+4+o.byteLength+m;_.setUint32(E,u.byteLength,!0),g.set(u,E+4)}return g}static readImageMetadata(e){let t=$r(e);if(!(t.flags&ti))return null;let{metadataOffset:r}=ms(t.image,t.view,t.flags,t.sabLen);if(t.image.byteLengthvt)throw new Error(`VFS image metadata exceeds ${vt} bytes`);if(t.image.byteLength0){let g=r.subarray(f+4,f+4+h),_=Ne(_s(g,"VFS image lazy metadata"),"VFS image lazy entries",0,Tt);m.importLazyEntriesInternal(_,!0)}if(s&Wr){let g=a.archiveOffset,_=i.getUint32(g,!0);if(_>0){let y=r.subarray(g+4,g+4+_),E=_s(y,"VFS image lazy archive metadata");m.importLazyArchiveEntriesInternal(E,!0,!!(s&ri),"pending")}}return m}adaptStat(e){return{dev:0,ino:e.ino,mode:e.mode,nlink:e.linkCount,uid:e.uid,gid:e.gid,size:e.size,atimeMs:e.atime,mtimeMs:e.mtime,ctimeMs:e.ctime}}adaptStatWithLazySize(e){let t=this.adaptStat(e),r=this.lazyFileForStat(e);if(r)return t.size=r.size,t;let i=this.lazyArchiveForStat(e);if(i){for(let s of this.lazyArchiveEntriesForRead(i))if(s.ino===e.ino&&s.generation===e.generation&&!s.deleted){t.size=s.size;break}}return t}open(e,t,r){(t&rr)===0&&!((t&er)!==0&&(t&Kn)!==0)&&this.guardSynchronousLazyAccess(e);let i=this.fs.open(e,t,r);return(t&rr)!==0&&this.invalidateLazyData(this.fs.fstat(i)),i}close(e){return this.fs.close(e),0}read(e,t,r,i){if(i>0){let s=this.lazyBackingForStat(this.fs.fstat(e));s&&(this.reconcileLazyIdentityState(this.fs.identityState()),s=this.lazyBackingForStat(this.fs.fstat(e)),s&&this.guardSynchronousLazyAccess(s.path))}return r!==null?this.fs.readAt(e,t.subarray(0,i),typeof r=="bigint"?vn(r):r):this.fs.read(e,t.subarray(0,i))}write(e,t,r,i){if(r!==null){let o=this.fs.writeAt(e,t.subarray(0,i),typeof r=="bigint"?vn(r):r);return o>0&&this.invalidateLazyData(this.fs.fstat(e)),o}let s=this.fs.write(e,t.subarray(0,i));return s>0&&this.invalidateLazyData(this.fs.fstat(e)),s}append(e,t,r,i){let s=this.fs.append(e,t.subarray(0,r),yo(i));return s.written>0&&this.invalidateLazyData(this.fs.fstat(e)),s}seek(e,t,r){return this.fs.lseek(e,typeof t=="bigint"?Rn(t):t,r)}fstat(e){return this.adaptStatWithLazySize(this.fs.fstat(e))}fpathconf(e,t){let r=this.fstat(e);return Ln(r,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}ftruncate(e,t){this.fs.ftruncate(e,t),this.invalidateLazyData(this.fs.fstat(e))}fsync(e){}fchmod(e,t){this.fs.fchmod(e,t)}fchown(e,t,r){this.fs.fchown(e,t,r)}stat(e){return this.adaptStatWithLazySize(this.fs.stat(e))}lstat(e){return this.adaptStatWithLazySize(this.fs.lstat(e))}statfs(e){this.fs.stat(e);let t=this.fs.statfs();return{type:1397114451,bsize:t.blockSize,blocks:t.totalBlocks,bfree:t.freeBlocks,bavail:t.freeBlocks,files:t.totalInodes,ffree:t.freeInodes,fsid:0,namelen:t.maxName,frsize:t.blockSize,flags:0}}pathconf(e,t){let r=this.stat(e);return Ln(r,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}mkdir(e,t){this.fs.mkdir(e,t)}rmdir(e){this.fs.rmdir(e)}unlink(e){let t=this.fs.unlink(e),r=n.inodeKey(t.ino,t.generation);if(t.linkCount>1&&(this.lazyFiles.has(r)||this.lazyArchiveInodes.has(r))){this.reconcileLazyIdentityState(this.fs.identityState());return}let i=this.lazyFiles.get(r);i&&(i.paths.delete(e),t.linkCount<=1?this.lazyFiles.delete(r):i.path===e&&(i.path=i.paths.values().next().value));let s=this.lazyArchiveInodes.get(r);if(s){let o=s.entries.get(e);if(t.linkCount<=1){for(let a of s.entries.values())a.ino===t.ino&&a.generation===t.generation&&(a.deleted=!0);this.lazyArchiveInodes.delete(r)}else o&&s.entries.delete(e)}}rename(e,t){let{source:r,replaced:i}=this.fs.rename(e,t);if(i&&i.ino===r.ino&&i.generation===r.generation)return;let s=!1;if(i){let o=n.inodeKey(i.ino,i.generation);i.linkCount>1&&(this.lazyFiles.has(o)||this.lazyArchiveInodes.has(o))&&(this.reconcileLazyIdentityState(this.fs.identityState()),s=!0);let a=this.lazyFiles.get(o);!s&&a&&(a.paths.delete(t),i.linkCount<=1?this.lazyFiles.delete(o):a.path===t&&(a.path=a.paths.values().next().value));let c=this.lazyArchiveInodes.get(o);if(!s&&c){let l=c.entries.get(t);i.linkCount<=1?(l&&(l.deleted=!0),this.lazyArchiveInodes.delete(o)):l&&c.entries.delete(t)}}s||this.rewriteLazyNamespacePaths(r,e,t)}link(e,t){let r=this.fs.link(e,t),i=n.inodeKey(r.ino,r.generation),s=this.lazyFiles.get(i);s&&s.paths.add(t);let o=this.lazyArchiveInodes.get(i);if(o){let a=Array.from(o.entries.values()).find(c=>c.ino===r.ino&&c.generation===r.generation);a&&o.entries.set(t,{...a})}}symlink(e,t){this.fs.symlink(e,t)}readlink(e){return this.fs.readlink(e)}chmod(e,t){this.fs.chmod(e,t)}chown(e,t,r){this.fs.chown(e,t,r)}lchown(e,t,r){this.fs.lchown(e,t,r)}createFileWithOwner(e,t,r,i,s){let o=this.open(e,577,t);s.length>0&&this.write(o,s,null,s.length),this.close(o),this.chown(e,r,i),this.chmod(e,t)}mkdirWithOwner(e,t,r,i){this.mkdir(e,t),this.chown(e,r,i),this.chmod(e,t)}symlinkWithOwner(e,t,r,i){this.symlink(e,t),this.lchown(t,r,i)}copyPathToFreshFileSystem(e,t,r,i,s){let o=this.lstat(e),a=o.mode&Pe,c=o.mode&4095;if(a===it){e==="/"?(t.chown(e,o.uid,o.gid),t.chmod(e,c)):t.mkdirWithOwner(e,c,o.uid,o.gid);let p=this.opendir(e);try{for(;;){let m=this.readdir(p);if(!m)break;m.name==="."||m.name===".."||this.copyPathToFreshFileSystem(e==="/"?`/${m.name}`:`${e}/${m.name}`,t,r,i,s)}}finally{this.closedir(p)}n.applyTimes(t,e,o);return}let l=o.nlink>1?`${o.dev}:${o.ino}`:null,d=l?s.get(l):void 0;if(d){t.link(d,e);return}if(a===Br){t.symlinkWithOwner(this.readlink(e),e,o.uid,o.gid),l&&s.set(l,e);return}if(a!==cr)throw new Error(`Unsupported file type while rebasing VFS: ${e}`);if(r.has(e)||i.has(e)){t.createFileWithOwner(e,c,o.uid,o.gid,new Uint8Array(0)),n.applyTimes(t,e,o),l&&s.set(l,e);return}this.copyRegularFileToFreshFileSystem(e,t,o,c),l&&s.set(l,e)}copyRegularFileToFreshFileSystem(e,t,r,i){let s=this.open(e,fl,0),o=null;try{o=t.open(e,hl,i);let a=new Uint8Array(Math.min(pl,Math.max(1,r.size))),c=r.size;for(;c>0;){let l=Math.min(a.byteLength,c),d=this.read(s,a,null,l);if(d<=0)throw new Error(`Unexpected EOF while rebasing VFS file: ${e}`);let u=0;for(;u!e||e==="."||e===".."))throw new Error(`Binary resolver path must be a normalized portable relative path: ${JSON.stringify(n)}`);return n}var ct=new Set(["wasm32","wasm64"]);function Ye(n){if(nu(n),!n.startsWith("programs/"))return n;let e=n.slice(9),t=e.split("/",1)[0];return ct.has(t)?n:`programs/wasm32/${e}`}function iu(n,e=$(Si(),"wasm")){let t=Ye(n),r=[$(e,t)];return n==="kernel.wasm"?r.push($(e,"kandelo-kernel.wasm")):n==="userspace.wasm"?r.push($(e,"wasm_posix_userspace.wasm")):n==="rootfs.vfs"&&r.push($(e,"rootfs.vfs")),r}var en=class extends Error{constructor(e){super(e),this.name="BinaryNotFoundError"}};function qs(){let n=[],e=!1;try{let r=at();e=!0;for(let[i,s]of[["local-binaries",$(r,"local-binaries")],["binaries",$(r,"binaries")]])n.push({label:i,root:s,identity:i==="local-binaries"?"local-generation":"program-cache",allowRegularFileClosure:!1,candidatesFor(o){return[$(s,Ye(o))]}})}catch{}let t=$(Si(),"wasm");return n.push({label:"installed package",root:t,identity:"installed-package",allowRegularFileClosure:!e,candidatesFor(r){return iu(r,t)}}),n}function Lt(n,e){return new Error(`Invalid package manifest ${n}: ${e}`)}function ae(n){try{return tn(n),!0}catch(e){if(e instanceof Error&&"code"in e&&e.code==="ENOENT")return!1;throw e}}function Ms(n,e,t){if(n.length===0||n.startsWith("/")||n.includes("\\")||n.includes("\0")||n.split("/").some(r=>!r||r==="."||r===".."))throw Lt(e,`${t} must be a normalized portable relative path`);return n}function Jr(n,e,t,r=!0){if(n.length===0||n==="."||n===".."||n.includes("/")||n.includes("\\")||n.includes("\0")||!r&&n.includes("@"))throw Lt(e,`${t} must be a safe single path component`);return n}var Ds="kandelo-program-packages-v2",Fe="program-packages.json",Ks=null,ou=null,Qr=null,mi=0;function wi(){return ou??$(Si(),"wasm",Fe)}function Zs(){if(Object.prototype.hasOwnProperty.call(process.env,"WASM_POSIX_DEPS_REGISTRY")){let t=null;return(process.env.WASM_POSIX_DEPS_REGISTRY??"").split(":").filter(Boolean).map(r=>r.startsWith("~/")&&process.env.HOME!==void 0?$(process.env.HOME,r.slice(2)):rn(r)?Oe(r):(t??=at(),Oe(t,r)))}let n;try{n=$(at(),"packages","registry")}catch{return null}let e=!1;if(ae(n)){if(!Xe(n).isDirectory())return[n];e=Gs(n,{withFileTypes:!0}).filter(t=>t.isDirectory()||t.isSymbolicLink()).some(t=>ae($(n,t.name,"package.toml")))}return!e&&Xs()===null&&ae(wi())?null:[n]}function Xs(){let n;try{n=at()}catch{return null}if(!pr($(n,"tools","xtask","Cargo.toml"))||!pr($(n,"scripts","dev-shell.sh")))return null;try{let e=Ie(Ei()),t=Ie(n);return[$(t,"host"),$(t,"scripts")].some(i=>pr(i)&&Ri(Ie(i),e))?t:null}catch{return null}}function Ai(n,e,t){let r=[typeof t.stderr=="string"?t.stderr.trim():"",typeof t.stdout=="string"?t.stdout.trim():"",t.error?.message??""].filter(Boolean).join(` +var ca=Object.defineProperty;var Ar=(n,e,t)=>()=>{if(t)throw t[0];try{return n&&(e=n(n=0)),e}catch(r){throw t=[r],r}};var bi=(n,e)=>{for(var t in e)ca(n,t,{get:e[t],enumerable:!0})};var Ft,Pi,Nt,ki,un,Fi,ne,Ni,Ci,Ir,Mi,dn,Di,Ki,Bi,$i,Rr,xr,ln,fn,hn,pn,mn,ht,Ct,Mt,Dt,ve,Ui,Wi,Tr,Se,Gi,vr,_n,Lr,zr,pt,Kt,$e,yn,gn,Hi,br,Vi,X,qi,Zi,Pr,Bt,Xi,Yi,ji,Y,Ji,Qi,kr,$t,eo,to,En,Sn,mt,wn,Ut,Wt,W,Gt,ro,no,q,et=Ar(()=>{"use strict";Ft="kandelo.wpk_fork.linked_frames",Pi=[75,76,67,70],Nt=24,ki=8,un=3,Fi=[{bytes:4,chunkHeaderSize:32,nodeHeaderSize:24},{bytes:8,chunkHeaderSize:56,nodeHeaderSize:32}],ne="kandelo.wpk_fork.module_state",Ni=1,Ci=[75,70,77,68],Ir=24,Mi=8,dn=7,Di=1,Ki=1,Bi=1,$i=[{bytes:4,chunkHeaderSize:40},{bytes:8,chunkHeaderSize:56}],Rr="__wpk_fork_global_",xr="__wpk_fork_table_",ln=1,fn=2,hn=3,pn=4,mn=5,ht=6,Ct=7,Mt=8,Dt=9,ve="kandelo.wpk_fork.capabilities",Ui=1,Wi=7,Tr=4,Se="kandelo.wpk_fork.exception_codec",Gi=1,vr=8,_n=16,Lr="env",zr="__wpk_fork_unwind",pt="kandelo.wpk_fork.unwind_transport",Kt="__wpk_fork_static_root_catalog",$e="kandelo.wpk_fork.static_root_catalog",yn=1,gn=0,Hi=1,br=12,Vi=[75,70,83,82],X="kandelo.wpk_fork.imported_globals",qi=[75,70,73,71],Zi=1,Pr=16,Bt=24,Xi=1,Yi=2,ji=3,Y="kandelo.wpk_fork.imported_tables",Ji=[75,70,73,84],Qi=1,kr=16,$t=24,eo=1,to=1,En="env",Sn="__wpk_fork_module_activation",mt=[{module:"env",name:"__wpk_fork_frame_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_frame_next",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_peek",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_reserve",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_record_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_module_state_record_find",params:["i32","i32","i32","i32"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_record_reserve",params:["i32","i32","i32","ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_table_dirty_count",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_module_state_table_dirty_mark",params:["i32","i64","i64"],results:[]},{module:"env",name:"__wpk_fork_module_state_table_dirty_page",params:["i32","i32"],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_mutation_abort",params:[],results:[]},{module:"env",name:"__wpk_fork_module_state_table_mutation_begin",params:[],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_mutation_commit",params:["i32","i64","i64"],results:[]},{module:"env",name:"__wpk_fork_module_state_table_reconcile",params:[],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_state_owned",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_decode_funcref",params:["i32"],results:["funcref"]},{module:"env",name:"__wpk_fork_ref_encode_funcref",params:["funcref"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_broker_encode",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_broker_throw_recipe",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_cache_index",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_claim",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_define",params:["i32","i32","i32","i32","ptr","i32","ptr","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_ingress_throw",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_load",params:["i32","i32","i32","i32","ptr","i32","ptr","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_lookup",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_route",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_broker_encode",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_capture_layout",params:["i32","i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_claim",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_define",params:["i32","i32","i32","i32","i32","ptr","i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_i31",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_load",params:["i32","i32","i32","i32","i32","ptr","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_lookup",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_payload_len",params:["i32","i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_provenance_begin",params:["i32","i32","i32","i32","i64","i64","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_provenance_end",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_provenance_ref",params:["i32","i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_route",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_scratch_release",params:["ptr","ptr"],results:[]},{module:"env",name:"__wpk_fork_ref_scratch_reserve",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_ref_vector_append",params:["i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_vector_begin",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_vector_finish",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_vector_get",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_resume_peek",params:["i32"],results:["i32"]}],wn=[{module:"env",name:"__wpk_fork_ref_gc_transit",table64:!1,element:"anyref",minimum:1,maximum:null},{module:"env",name:"__wpk_fork_resume_table",table64:!1,element:"funcref",minimum:1,maximum:null}],Ut=[{name:"__wpk_fork_exception_materialize",params:["i32"],results:[]},{name:"__wpk_fork_ref_decode_exnref",params:["i32"],results:["exnref"]},{name:"__wpk_fork_ref_encode_exnref",params:["exnref"],results:["i32"]},{name:"__wpk_fork_ref_exn_abort",params:[],results:[]},{name:"__wpk_fork_ref_exn_clear",params:[],results:[]},{name:"__wpk_fork_ref_exn_encode_ingress",params:["i32"],results:["i32"]},{name:"__wpk_fork_ref_exn_throw_recipe",params:["i32"],results:[]},{name:"__wpk_fork_ref_exn_throw_slot",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_allocate",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_encode_slot",params:["i32"],results:["i32"]},{name:"__wpk_fork_ref_gc_fill",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_probe",params:["i32"],results:["i64"]},{name:"__wpk_fork_ref_gc_publish_externref",params:["i32","externref"],results:[]},{name:"__wpk_fork_static_root_harvest",params:[],results:[]},{name:"wpk_fork_abort_begin",params:["ptr"],results:[]},{name:"wpk_fork_abort_end",params:[],results:[]},{name:"wpk_fork_module_bootstrap",params:[],results:[]},{name:"wpk_fork_module_state_finish_restore",params:["i32"],results:[]},{name:"wpk_fork_module_state_restore",params:["i32"],results:[]},{name:"wpk_fork_module_state_save",params:["i32"],results:[]},{name:"wpk_fork_module_table_state_restore",params:["i32"],results:[]},{name:"wpk_fork_module_table_state_save",params:["i32"],results:[]},{name:"wpk_fork_module_thread_bootstrap",params:[],results:[]},{name:"wpk_fork_rewind_begin",params:["ptr"],results:[]},{name:"wpk_fork_rewind_end",params:[],results:[]},{name:"wpk_fork_state",params:[],results:["i32"]},{name:"wpk_fork_unwind_begin",params:["ptr"],results:[]},{name:"wpk_fork_unwind_end",params:[],results:[]}],Wt={O_RDONLY:0,O_WRONLY:1,O_RDWR:2,O_ACCMODE:3,O_CREAT:64,O_EXCL:128,O_NOCTTY:256,O_TRUNC:512,O_APPEND:1024,O_NONBLOCK:2048,O_ASYNC:8192,O_DIRECTORY:65536,O_NOFOLLOW:131072,O_CLOEXEC:524288,O_PATH:2097152,O_CLOFORK:8388608},W={S_IFMT:61440,S_IFSOCK:49152,S_IFLNK:40960,S_IFREG:32768,S_IFBLK:24576,S_IFDIR:16384,S_IFCHR:8192,S_IFIFO:4096,S_ISUID:2048,S_ISGID:1024,S_ISVTX:512,S_IRWXU:448,S_IRUSR:256,S_IWUSR:128,S_IXUSR:64,S_IRWXG:56,S_IRGRP:32,S_IWGRP:16,S_IXGRP:8,S_IRWXO:7,S_IROTH:4,S_IWOTH:2,S_IXOTH:1,S_MODE_BITS:4095},Gt={DT_UNKNOWN:0,DT_FIFO:1,DT_CHR:2,DT_DIR:4,DT_BLK:6,DT_REG:8,DT_LNK:10,DT_SOCK:12},ro=4096,no=["__abi_version","kernel_alloc_scratch","kernel_blocking_retry_release","kernel_blocking_retry_token","kernel_commit_process_exit","kernel_create_process","kernel_create_process_with_stdio","kernel_dequeue_signal","kernel_exec_prepare","kernel_exec_setup_for_thread","kernel_fork_process","kernel_get_cwd","kernel_get_dirfd_path","kernel_get_fd_path","kernel_get_parent_pid","kernel_get_process_exit_signal","kernel_get_process_state","kernel_get_socket_timeout_ms","kernel_handle_channel","kernel_has_sa_nocldstop","kernel_host_adapter_manifest_len","kernel_host_adapter_manifest_ptr","kernel_ipc_shm_lookup_mapping_for_task","kernel_ipc_shm_record_mapping_for_process","kernel_ipc_shm_record_mapping_for_task","kernel_ipc_shmat_for_process","kernel_ipc_shmat_for_task","kernel_ipc_shmdt_addr_for_process","kernel_ipc_shmdt_addr_for_task","kernel_ipc_shmdt_for_process","kernel_ipc_shmdt_for_task","kernel_is_fd_nonblock","kernel_mark_process_signaled","kernel_mq_descriptor_msgsize","kernel_msqid_ds_bytes","kernel_pcm_claim_transport","kernel_pcm_clock_update","kernel_pcm_reconcile","kernel_pcm_transport_len","kernel_pcm_transport_ptr","kernel_pick_signal_target_tid","kernel_pick_tcp_listener_target","kernel_pipe_has_readers","kernel_posix_timer_fire","kernel_process_metadata_begin","kernel_process_metadata_cancel","kernel_process_metadata_commit","kernel_process_metadata_stage","kernel_reap_exited_child","kernel_remove_process","kernel_semctl_array_bytes","kernel_semid_ds_bytes","kernel_set_current_tid","kernel_set_cwd","kernel_shmid_ds_bytes","kernel_spawn_process","kernel_spawn_reserved_process","kernel_spawn_scratch_begin","kernel_spawn_scratch_cancel","kernel_spawn_scratch_capacity","kernel_spawn_scratch_pointer","kernel_spawn_scratch_retained_capacity","kernel_take_process_timer_cleanup","kernel_thread_exit","kernel_thread_has_deliverable","kernel_transfer_channel_execute","kernel_transfer_io_execute","kernel_transfer_scratch_begin","kernel_transfer_scratch_cancel","kernel_transfer_scratch_capacity","kernel_transfer_scratch_pointer","kernel_validate_task","kernel_wait_child_poll"],q={LINK_MAX:0,MAX_CANON:1,MAX_INPUT:2,NAME_MAX:3,PATH_MAX:4,PIPE_BUF:5,CHOWN_RESTRICTED:6,NO_TRUNC:7,VDISABLE:8,SYNC_IO:9,ASYNC_IO:10,PRIO_IO:11,SOCK_MAXBUF:12,FILESIZEBITS:13,REC_INCR_XFER_SIZE:14,REC_MAX_XFER_SIZE:15,REC_MIN_XFER_SIZE:16,REC_XFER_ALIGN:17,ALLOC_SIZE_MIN:18,SYMLINK_MAX:19,POSIX2_SYMLINKS:20,FALLOC:21,TEXTDOMAIN_MAX:22,TIMESTAMP_RESOLUTION:23}});import{createRequire as bc}from"module";function Qo(n,e){return Jo(n,{i:2},e&&e.out,e&&e.dictionary)}var Pc,Tt,kc,Fc,se,Rt,Nc,Ho,Vo,Cc,qo,Tt,Zo,Mc,Xo,Dc,Qd,qn,Pe,M,ar,cr,M,M,M,M,Yo,M,Kc,Bc,Hn,Ae,Vn,jo,Gr,$c,_e,Jo,Uc,Wc,xt,es,Gc,Hc,Zn=Ar(()=>{Pc=bc("/");try{Tt=Pc("worker_threads"),kc=Tt.Worker,Fc=Tt.isMarkedAsUntransferable}catch{}se=Uint8Array,Rt=Uint16Array,Nc=Int32Array,Ho=new se([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),Vo=new se([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),Cc=new se([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),qo=function(n,e){for(var t=new Rt(31),r=0;r<31;++r)t[r]=e+=1<>1|(M&21845)<<1,Pe=(Pe&52428)>>2|(Pe&13107)<<2,Pe=(Pe&61680)>>4|(Pe&3855)<<4,qn[M]=((Pe&65280)>>8|(Pe&255)<<8)>>1;ar=(function(n,e,t){for(var r=n.length,i=0,s=new Rt(e);i>c]=u}else for(a=new Rt(r),i=0;i>15-n[i]);return a}),cr=new se(288);for(M=0;M<144;++M)cr[M]=8;for(M=144;M<256;++M)cr[M]=9;for(M=256;M<280;++M)cr[M]=7;for(M=280;M<288;++M)cr[M]=8;Yo=new se(32);for(M=0;M<32;++M)Yo[M]=5;Kc=ar(cr,9,1),Bc=ar(Yo,5,1),Hn=function(n){for(var e=n[0],t=1;te&&(e=n[t]);return e},Ae=function(n,e,t){var r=e/8|0;return(n[r]|n[r+1]<<8)>>(e&7)&t},Vn=function(n,e){var t=e/8|0;return(n[t]|n[t+1]<<8|n[t+2]<<16)>>(e&7)},jo=function(n){return(n+7)/8|0},Gr=function(n,e,t){return(e==null||e<0)&&(e=0),(t==null||t>n.length)&&(t=n.length),new se(n.subarray(e,t))},$c=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],_e=function(n,e,t){var r=new Error(e||$c[n]);if(r.code=n,Error.captureStackTrace&&Error.captureStackTrace(r,_e),!t)throw r;return r},Jo=function(n,e,t,r){var i=n.length,s=r?r.length:0;if(!i||e.f&&!e.l)return t||new se(0);var o=!t,a=o||e.i!=2,c=e.i;o&&(t=new se(i*3));var u=function(Ke){var Be=t.length;if(Ke>Be){var Or=new se(Math.max(Be*2,Ke));Or.set(t),t=Or}},l=e.f||0,d=e.p||0,p=e.b||0,m=e.l,f=e.d,h=e.m,g=e.n,_=i*8;do{if(!m){l=Ae(n,d,1);var y=Ae(n,d+1,3);if(d+=3,y)if(y==1)m=Kc,f=Bc,h=9,g=5;else if(y==2){var S=Ae(n,d,31)+257,A=Ae(n,d+10,15)+4,R=S+Ae(n,d+5,31)+1;d+=14;for(var x=new se(R),v=new se(19),L=0;L>4;if(E<16)x[L++]=E;else{var U=0,le=0;for(E==16?(le=3+Ae(n,d,3),d+=2,U=x[L-1]):E==17?(le=3+Ae(n,d,7),d+=3):E==18&&(le=11+Ae(n,d,127),d+=7);le--;)x[L++]=U}}var C=x.subarray(0,S),V=x.subarray(S);h=Hn(C),g=Hn(V),m=ar(C,h,1),f=ar(V,g,1)}else _e(1);else{var E=jo(d)+4,O=n[E-4]|n[E-3]<<8,w=E+O;if(w>i){c&&_e(0);break}a&&u(p+O),t.set(n.subarray(E,w),p),e.b=p+=O,e.p=d=w*8,e.f=l;continue}if(d>_){c&&_e(0);break}}a&&u(p+131072);for(var Pt=(1<>4;if(d+=U&15,d>_){c&&_e(0);break}if(U||_e(2),Te<256)t[p++]=Te;else if(Te==256){Je=d,m=null;break}else{var kt=Te-254;if(Te>264){var L=Te-257,Me=Ho[L];kt=Ae(n,d,(1<>4;lt||_e(3),d+=lt&15;var V=Dc[Ee];if(Ee>3){var Me=Vo[Ee];V+=Vn(n,d)&(1<_){c&&_e(0);break}a&&u(p+131072);var De=p+kt;if(p>3&1)+(e>>4&1);r>0;r-=!n[t++]);return t+(e&2)},xt=(function(){function n(e,t){typeof e=="function"&&(t=e,e={}),this.ondata=t;var r=e&&e.dictionary&&e.dictionary.subarray(-32768);this.s={i:0,b:r?r.length:0},this.o=new se(32768),this.p=new se(0),r&&this.o.set(r)}return n.prototype.e=function(e){if(this.ondata||_e(5),this.d&&_e(4),!this.p.length)this.p=e;else if(e.length){var t=new se(this.p.length+e.length);t.set(this.p),t.set(e,this.p.length),this.p=t}},n.prototype.c=function(e){this.s.i=+(this.d=e||!1);var t=this.s.b,r=Jo(this.p,this.s,this.o);this.ondata(Gr(r,t,this.s.b),this.d),this.o=Gr(r,this.s.b-32768),this.s.b=this.o.length,this.p=Gr(this.p,this.s.p/8|0),this.s.p&=7},n.prototype.push=function(e,t){this.e(e),this.c(t)},n})();es=(function(){function n(e,t){this.v=1,this.r=0,xt.call(this,e,t)}return n.prototype.push=function(e,t){if(xt.prototype.e.call(this,e),this.r+=e.length,this.v){var r=this.p.subarray(this.v-1),i=r.length>3?Wc(r):4;if(i>r.length){if(!t)return}else this.v>1&&this.onmember&&this.onmember(this.r-r.length);this.p=r.subarray(i),this.v=0}xt.prototype.c.call(this,0),this.s.f&&!this.s.l?(this.v=jo(this.s.p)+9,this.s={i:0},this.o=new se(0),this.push(new se(0),t)):t&&xt.prototype.c.call(this,t)},n})(),Gc=typeof TextDecoder<"u"&&new TextDecoder,Hc=0;try{Gc.decode(Uc,{stream:!0}),Hc=1}catch{}});var jn={};bi(jn,{extractZipEntry:()=>Qc,extractZipEntryBounded:()=>eu,fetchZipCentralDirectory:()=>ru,parseZipCentralDirectory:()=>ur});function ss(n){let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=Math.max(0,n.length-ns);for(let r=n.length-Zc;r>=t;r--)if(e.getUint32(r,!0)===Vc)return r;throw new Error("Zip EOCD record not found")}function ur(n){let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=ss(n),r=e.getUint16(t+10,!0),i=e.getUint32(t+16,!0),s=[],o=i;for(let a=0;a>8,O;E===ts?O=h>>16&65535:y.startsWith("bin/")||y.startsWith("sbin/")||y.includes("/bin/")||y.includes("/sbin/")?O=493:O=420;let w=y.endsWith("/"),S=E===ts&&(O&Yc)===Xc;s.push({fileName:y,fileNameBytes:_,compressedSize:l,uncompressedSize:d,compressionMethod:u,localHeaderOffset:g,mode:O,isDirectory:w,isSymlink:S,externalAttrs:h,creatorOS:E}),o+=Xn+p+m+f}return s}function as(n,e){if(n.byteLength!==e.byteLength)return!1;for(let t=0;t{if(a.byteLength>t-s)throw new Error(`ZIP member ${e.fileName} expands beyond ${t} bytes`);i.set(a,s),s+=a.byteLength}).push(r,!0),s!==t)throw new Error(`ZIP member ${e.fileName} expanded ${s} bytes, expected ${t}`);return i}function tu(n,e){let t=new DataView(n.buffer,n.byteOffset,n.byteLength),r=e.localHeaderOffset;if(r<0||r>n.byteLength-Yn||t.getUint32(r,!0)!==rs)throw new Error(`Invalid local file header signature at offset ${r}`);let i=t.getUint16(r+8,!0),s=t.getUint16(r+26,!0),o=t.getUint16(r+28,!0),a=r+Yn,c=a+s+o,u=c+e.compressedSize;if(i!==e.compressionMethod||cn.byteLength||!as(n.subarray(a,a+s),e.fileNameBytes))throw new Error(`ZIP member ${e.fileName} has inconsistent local metadata`);return n.subarray(c,u)}async function ru(n){let e=await fetch(n,{method:"HEAD"});if(!e.ok)throw new Error(`HEAD request failed: ${e.status} ${e.statusText}`);let t=parseInt(e.headers.get("content-length")||"0",10),r=e.headers.get("accept-ranges");if(!t||r!=="bytes"){let _=await fetch(n);if(!_.ok)throw new Error(`Fetch failed: ${_.status} ${_.statusText}`);let y=new Uint8Array(await _.arrayBuffer());return{entries:ur(y),totalSize:y.length}}let i=Math.min(t,ns),s=t-i,o=await fetch(n,{headers:{Range:`bytes=${s}-${t-1}`}});if(o.status!==206){let _=await fetch(n);if(!_.ok)throw new Error(`Fetch failed: ${_.status} ${_.statusText}`);let y=new Uint8Array(await _.arrayBuffer());return{entries:ur(y),totalSize:y.length}}let a=new Uint8Array(await o.arrayBuffer()),c=new DataView(a.buffer,a.byteOffset,a.byteLength),u=ss(a),l=c.getUint32(u+12,!0),d=c.getUint32(u+16,!0);if(d>=s){let _=t,y=new Uint8Array(_);return y.set(a,s),{entries:ur(y),totalSize:_}}let p=d+l-1,m=await fetch(n,{headers:{Range:`bytes=${d}-${p}`}});if(m.status!==206)throw new Error(`Range request for CD failed: ${m.status}`);let f=new Uint8Array(await m.arrayBuffer()),h=t,g=new Uint8Array(h);return g.set(f,d),g.set(a,s),{entries:ur(g),totalSize:h}}var Vc,qc,rs,ns,Zc,Xn,Yn,is,os,ts,Xc,Yc,jc,Jc,Jn=Ar(()=>{"use strict";Zn();et();Vc=101010256,qc=33639248,rs=67324752,ns=65557,Zc=22,Xn=46,Yn=30,is=0,os=8,ts=3,{S_IFLNK:Xc,S_IFMT:Yc}=W,jc=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),Jc=new TextEncoder});var ps={};bi(ps,{DEFAULT_TAR_GZIP_LIMITS:()=>hs,TarParseError:()=>z,parseTarGzip:()=>su});function su(n,e={}){let t=e.label??"TAR gzip archive",r=cu(e.limits,t);if(n.byteLength===0||n.byteLength>r.maxCompressedBytes)throw new z(`${t}: compressed byte count ${n.byteLength} is outside 1..${r.maxCompressedBytes}`);let i=uu(n,t);if(i===0||i>r.maxUncompressedBytes)throw new z(`${t}: declared uncompressed byte count ${i} is outside 1..${r.maxUncompressedBytes}`);let s=du(n,t,i);if(s.byteLength!==i)throw new z(`${t}: gzip expanded to ${s.byteLength} bytes, expected ${i}`);let o=new DataView(n.buffer,n.byteOffset,n.byteLength).getUint32(n.byteLength-8,!0);if(lu(s)!==o)throw new z(`${t}: gzip CRC32 mismatch`);return au(s,t,r)}function au(n,e,t){if(n.byteLength%ke!==0)throw new z(`${e}: TAR byte count is not block-aligned`);let r=[],i=0,s=0,o=0,a=null,c={},u=!1;for(;i+ke<=n.byteLength;){let l=n.subarray(i,i+ke);if(i+=ke,ei(l)){if(i+ke>n.byteLength)throw new z(`${e}: TAR end marker is truncated`);let w=n.subarray(i,i+ke);if(!ei(w))throw new z(`${e}: TAR has only one zero end block`);if(i+=ke,!ei(n.subarray(i)))throw new z(`${e}: TAR has nonzero data after its end marker`);u=!0;break}mu(l,e);let d=dr(l,156,1,e)||"0",p=ri(l,124,12,`${e}: TAR entry size`),m=ri(l,100,8,`${e}: TAR entry mode`)&nu,f=_u(l,e,t.maxPathBytes),h=dr(l,157,100,e);if(d==="x"||d==="g"){if(o+=1,o>t.maxEntries+1)throw new z(`${e}: TAR extension header count exceeds ${t.maxEntries+1}`);let w=us(n,i,p,e);i=ds(i,p,n.byteLength,e);let S=hu(w,e,t);d==="x"?a=S:c={...c,...S};continue}if(s+=1,s>t.maxEntries)throw new z(`${e}: TAR entry count exceeds ${t.maxEntries}`);let g={...c,...a??{}};a=null;let _=g.size===void 0?p:pu(g.size,`${e}: PAX entry size`),y=us(n,i,_,e);i=ds(i,_,n.byteLength,e);let E=ti(g.path??f,e,t.maxPathBytes),O=g.linkpath??h;switch(d){case"0":case"\0":r.push({path:E,type:"file",mode:m,data:y});break;case"5":Qn(_,e,"directory",E),r.push({path:E,type:"directory",mode:m});break;case"2":Qn(_,e,"symlink",E),ls(O,e,E,t.maxLinkBytes,!1),r.push({path:E,type:"symlink",mode:m,linkName:O});break;case"1":Qn(_,e,"hardlink",E),ls(O,e,E,t.maxLinkBytes,!0),r.push({path:E,type:"hardlink",mode:m,linkName:ti(O,`${e}: hardlink target`,t.maxPathBytes)});break;case"3":case"4":case"6":throw new z(`${e}: unsupported TAR device/FIFO entry ${E}`);default:throw new z(`${e}: unsupported TAR entry type ${JSON.stringify(d)} for ${E}`)}}if(!u)throw new z(`${e}: TAR is missing its two-block end marker`);if(a!==null)throw new z(`${e}: local PAX header has no following entry`);return r}function cu(n,e){let t={...hs,...n};for(let[r,i]of Object.entries(t))if(!Number.isSafeInteger(i)||i<=0)throw new z(`${e}: ${r} must be a positive safe integer`);return t}function uu(n,e){if(n.byteLength<18||n[0]!==31||n[1]!==139||n[2]!==8)throw new z(`${e}: invalid gzip header`);return new DataView(n.buffer,n.byteOffset,n.byteLength).getUint32(n.byteLength-4,!0)}function du(n,e,t){let r=new Uint8Array(t),i=0,s=!1,o=new es(a=>{if(a.byteLength>t-i)throw new z(`${e}: gzip expansion exceeds its declared ${t} bytes`);r.set(a,i),i+=a.byteLength});o.onmember=()=>{throw s=!0,new z(`${e}: concatenated gzip members are unsupported`)};try{o.push(n,!0)}catch(a){throw a instanceof z?a:new z(`${e}: cannot gunzip archive: ${gu(a)}`)}if(s)throw new z(`${e}: concatenated gzip members are unsupported`);return r.subarray(0,i)}function lu(n){let e=4294967295;for(let t of n)e=ou[(e^t)&255]^e>>>8;return(e^4294967295)>>>0}function fu(){let n=new Uint32Array(256);for(let e=0;e>>1^((t&1)===0?0:3988292384);n[e]=t>>>0}return n}function us(n,e,t,r){if(t>n.byteLength-e)throw new z(`${r}: TAR entry is truncated`);return n.subarray(e,e+t)}function ds(n,e,t,r){let s=Math.ceil(e/ke)*ke;if(!Number.isSafeInteger(s)||s>t-n)throw new z(`${r}: TAR entry padding is truncated`);return n+s}function hu(n,e,t){let r={},i=0;for(;i9)throw new z(`${e}: invalid PAX record length`);if(o=o*10+h,!Number.isSafeInteger(o))throw new z(`${e}: invalid PAX record length`)}let a=i+o;if(o<=s-i+2||a>n.byteLength||n[a-1]!==10)throw new z(`${e}: truncated PAX record`);let c=s+1;for(;c=a-1)throw new z(`${e}: invalid PAX record`);let u=n.subarray(s+1,c);if(u.byteLength>256)throw new z(`${e}: PAX record key is too long`);let l=ni(u,`${e}: PAX record key`),d=n.subarray(c+1,a-1),p=l==="path"?t.maxPathBytes:l==="linkpath"?t.maxLinkBytes:l==="size"?32:0;if(p===0){i=a;continue}if(d.byteLength>p)throw new z(`${e}: PAX ${l} value is too long`);let m=ni(d,`${e}: PAX record value`);r[l]=m,i=a}return r}function pu(n,e){if(!/^(0|[1-9][0-9]*)$/.test(n))throw new z(`${e} is invalid`);let t=Number(n);if(!Number.isSafeInteger(t)||t<0)throw new z(`${e} is invalid`);return t}function mu(n,e){let t=ri(n,148,8,`${e}: TAR checksum`),r=0;for(let i=0;i=148&&i<156?32:n[i];if(t!==r)throw new z(`${e}: TAR checksum mismatch`)}function _u(n,e,t){let r=dr(n,0,100,e),i=dr(n,345,155,e);return ti(i?`${i}/${r}`:r,e,t)}function ti(n,e,t){let r=n;for(;r.startsWith("./");)r=r.slice(2);return r=r.replace(/\/+$/g,""),yu(r,`${e}: TAR path`,t),r}function dr(n,e,t,r){let i=e,s=e+t;for(;ir||n.includes("\0"))throw new z(`${e}: link target for ${t} is invalid`);if(i&&n.includes("\\"))throw new z(`${e}: hardlink target for ${t} is invalid`)}function yu(n,e,t){if(n.length===0||n.startsWith("/")||n.includes("\0")||n.includes("\\")||fs.encode(n).byteLength>t)throw new z(`${e} ${JSON.stringify(n)} must be a bounded relative POSIX path`);for(let r of n.split("/"))if(r.length===0||r==="."||r==="..")throw new z(`${e} ${JSON.stringify(n)} contains an unsafe path segment`)}function ei(n){for(let e of n)if(e!==0)return!1;return!0}function ni(n,e){try{return iu.decode(n)}catch{throw new z(`${e} contains non-UTF-8 text`)}}function gu(n){return n instanceof Error?n.message:String(n)}var ke,nu,cs,iu,fs,ou,hs,z,ms=Ar(()=>{"use strict";Zn();et();ke=512,nu=W.S_MODE_BITS,cs=1024*1024,iu=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),fs=new TextEncoder,ou=fu(),hs=Object.freeze({maxCompressedBytes:256*cs,maxUncompressedBytes:512*cs,maxEntries:1e5,maxPathBytes:4096,maxLinkBytes:65536}),z=class extends Error{constructor(e){super(e),this.name="TarParseError"}}});import{existsSync as gr,lstatSync as an,readdirSync as Ys,readFileSync as ct,realpathSync as Re,statSync as Ye}from"node:fs";import{createHash as js}from"node:crypto";import{spawnSync as Oi}from"node:child_process";import{basename as rd,dirname as Sr,isAbsolute as cn,join as $,relative as nd,resolve as Ie,sep as id}from"node:path";import{fileURLToPath as od}from"node:url";et();var da=Uint8Array.from(Vi);function T(n,e){let t=0,r=0,i=e;for(;;){let s=n[i++];if(t|=(s&127)<=n.length)throw new Error(`${r} is truncated`);if((n[e++]&128)===0)return e}throw new Error(`${r} has an overlong LEB128 encoding`)}function we(n,e,t){if(e>=n.length)throw new Error(`${t} is truncated`);let r=n[e++];switch(r){case 127:case 126:case 125:case 124:case 123:case 117:case 116:case 115:case 114:case 113:case 112:case 111:case 110:case 109:case 108:case 107:case 106:case 105:case 104:return{code:r,shared:!1,next:e};case 98:case 99:case 100:{let i=n[e]===101;i&&e++;let s=ho(n,e,5,`${t} heap type`),[o]=fo(n,e);return{code:r,heapType:Number(o),shared:i,next:s}}default:throw new Error(`${t} has unknown value type 0x${r.toString(16)}`)}}function la(n,e,t){return n[e]===120||n[e]===119?{code:n[e],shared:!1,next:e+1}:we(n,e,t)}function fa(n,e){let t=n[e];if(t===64||t===127||t===126||t===125||t===124||t===123||t===112||t===111)return e+1;let[,r]=An(n,e);return e+r}function ha(n,e,t){let[r,i]=T(n,e);e+=i;let s=[],o=[];for(let d=0;d=n.length)throw new Error(`${t} mutability is truncated`);let i=n[e++];if(i!==0&&i!==1)throw new Error(`${t} has invalid mutability ${i}`);return e}function pa(n,e,t,r){if(e===101){if(t>=n.length)throw new Error(`${r} shared type is truncated`);e=n[t++]}for(let i of[76,77]){if(e!==i)continue;let[,s]=T(n,t);if(t+=s,t>=n.length)throw new Error(`${r} descriptor is truncated`);e=n[t++]}if(e===96)return ha(n,t,r);if(e===95){let[i,s]=T(n,t);t+=s;for(let o=0;o=n.length)throw new Error(`${t} is truncated`);let r=n[e++];if(r===79||r===80){let[i,s]=T(n,e);e+=s;for(let o=0;o=n.length)throw new Error(`${t} body is truncated`);r=n[e++]}return pa(n,r,e,t)}function ma(n,e){let[t,r]=T(n,e);e+=r;let i=[];for(let s=0;s=21&&r<=34?Vt(e,t):r===84||r>=92&&r<=99||r>=112&&r<=123||r>=124&&r<=131||r>=156&&r<=159?t+1:t:n===254?r===0||r===1||r===2?Vt(e,t):r===3?t:r>=16&&r<=79?Vt(e,t):null:null}function ya(n,e,t){let[r,i]=T(n,e);e+=i+r;let[s,o]=T(n,e);e+=o+s;let a=n[e++];if(a===0){t.funcImports++;let[,c]=T(n,e);e+=c}else if(a===1)e=we(n,e,"table import type").next,e=We(n,e).next;else if(a===2)e=We(n,e).next;else if(a===3)t.globalImports++,e=we(n,e,"global import type").next,e++;else if(a===4){e++;let[,c]=T(n,e);e+=c}return e}function Fr(n){return n.length>=8&&n[0]===0&&n[1]===97&&n[2]===115&&n[3]===109}function Ue(n,e){let[t,r]=T(n,e);return e+=r,[new TextDecoder().decode(n.subarray(e,e+t)),e+t]}function ga(n,e){if(e.length===0)return!0;let t=new TextEncoder().encode(e);e:for(let r=0;r<=n.length-t.length;r++){for(let i=0;in);function so(n,e){switch(n.code){case 127:return ln;case 126:return fn;case 125:return hn;case 124:return pn;case 123:return mn;case 112:case 115:return ht;case 111:case 114:return Ct;case 105:case 116:return Mt;case 104:case 106:case 107:case 108:case 109:case 110:case 113:case 117:return Dt;case 98:case 99:case 100:{let t=n.heapType;return t===void 0?null:t===-16||t===-13?ht:t===-17||t===-14?Ct:t===-23||t===-12?Mt:t>=0&&e[t]!==void 0?ht:Dt}default:return null}}function On(n,e,t){if(!t)throw new Error(`function ${e} refers to an unknown type`);let r=n.get(e)??[];r.push(t),n.set(e,r)}function Ht(n,e,t){let r=n.get(e)??[];r.push(t),n.set(e,r)}function We(n,e){let[t,r]=T(n,e);e+=r;let[i,s]=T(n,e);e+=s;let o=null;if((t&1)!==0){let[a,c]=T(n,e);e+=c,o=a}return{flags:t,minimum:i,maximum:o,next:e}}function Sa(n){let e=new Uint8Array(n);if(!Fr(e))throw new Error("not a wasm binary");let t=[],r=[],i=[],s={functionTypes:t,functionTypeIndices:r,functionImports:new Map,functionImportEntries:[],globalImports:new Map,tableImports:new Map,tables:[],tagImports:new Map,functionExports:new Map,globalExports:new Map,tableExports:new Map,exports:new Map,memoryPointerWidths:[],forkCapabilities:[],linkedFrameDescriptors:[],exceptionCodecDescriptors:[],importedGlobalsDescriptors:[],importedTablesDescriptors:[],moduleStateDescriptors:[],staticRootDescriptors:[],unwindTransportDescriptors:[],nativeStartCount:0,importsKernelFork:!1},o=0,a=0,c=8;for(;ce.length)throw new Error("wasm section exceeds file size");let f=p,h=!1;if(u===0){let[g,_]=Ue(e,f);g===Ft?s.linkedFrameDescriptors.push(e.slice(_,m)):g===ve?s.forkCapabilities.push(e.slice(_,m)):g===Se?s.exceptionCodecDescriptors.push(e.slice(_,m)):g===X?s.importedGlobalsDescriptors.push(e.slice(_,m)):g===Y?s.importedTablesDescriptors.push(e.slice(_,m)):g===ne?s.moduleStateDescriptors.push(e.slice(_,m)):g===$e?s.staticRootDescriptors.push(e.slice(_,m)):g===pt&&s.unwindTransportDescriptors.push(e.slice(_,m))}else if(u===1){h=!0;let g=ma(e,f);t.push(...g.types),f=g.next}else if(u===2){h=!0;let[g,_]=T(e,f);f+=_;for(let y=0;y=e.length)throw new Error(`global import ${E}.${w} is truncated`);let x=e[f++];if((x&-4)!==0)throw new Error(`global import ${E}.${w} has invalid flags ${x}`);Ht(s.globalImports,`${E}.${w}`,{module:E,name:w,importOrdinal:y,index:o++,valueType:R.code,recipeTypeCode:so(R,t),mutable:(x&1)!==0,shared:(x&2)!==0})}else if(A===4){let R=e[f++];if(R!==0)throw new Error(`unsupported wasm tag attribute ${R}`);let[x,v]=T(e,f);f+=v,On(s.tagImports,`${E}.${w}`,t[x])}else throw new Error(`unsupported wasm import kind ${A}`)}}else if(u===3){h=!0;let[g,_]=T(e,f);f+=_;for(let y=0;yn[c]===a))throw new Error("linked-frame descriptor has invalid magic");let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=e.getUint16(4,!0);if(t!==1)throw new Error(`linked-frame descriptor version ${t} is unsupported`);let r=e.getUint16(6,!0);if(r!==Nt)throw new Error(`linked-frame descriptor declares size ${r}, expected ${Nt}`);let i=e.getUint8(8),s=Fi.find(({bytes:a})=>a===i);if(!s)throw new Error(`linked-frame descriptor pointer width ${i} is unsupported`);if(e.getUint8(9)!==ki)throw new Error(`linked-frame descriptor alignment ${e.getUint8(9)} is unsupported`);let o=e.getUint16(10,!0);if(o!==un)throw new Error(`linked-frame descriptor flags 0x${o.toString(16)} do not equal required flags 0x${un.toString(16)}`);if(e.getUint32(12,!0)!==s.chunkHeaderSize||e.getUint32(16,!0)!==s.nodeHeaderSize)throw new Error(`linked-frame descriptor header sizes do not match its ${i}-byte pointer width`);return s.bytes}function Oa(n){if(n.length===0)return[`missing required ${ve} capability`];if(n.length!==1)return[`has ${n.length} ${ve} sections, expected exactly one`];let e=n[0];if(e.byteLength!==2)return[`${ve} has ${e.byteLength} bytes, expected 2`];if(e[0]!==Ui)return[`${ve} version ${e[0]} is unsupported`];let t=e[1];return(t&~Wi)!==0?[`${ve} has unknown flags 0x${t.toString(16)}`]:(t&Tr)!==Tr?[`${ve} flags 0x${t.toString(16)} omit required activation-state safety flags 0x${Tr.toString(16)}`]:[]}function Aa(n){let e=[],t=`${Lr}.${zr}`,r=n.tagImports.get(t);if(r?r.length!==1?e.push(`duplicate private fork-unwind tag import ${t}`):(r[0].params.length!==0||r[0].results.length!==0)&&e.push(`private fork-unwind tag ${t} must have an empty payload`):e.push(`missing required private fork-unwind tag import ${t}`),n.unwindTransportDescriptors.length===0)e.push(`missing required ${pt} descriptor`);else if(n.unwindTransportDescriptors.length!==1)e.push(`has ${n.unwindTransportDescriptors.length} ${pt} descriptors, expected exactly one`);else{let i=n.unwindTransportDescriptors[0];(i.length!==2||i[0]!==yn||i[1]!==gn)&&e.push(`${pt} must be [${yn}, ${gn}]`)}return e}function Ia(n,e){if(n.length===0)return[`missing required ${ne} descriptor`];if(n.length!==1)return[`has ${n.length} ${ne} descriptors, expected exactly one`];let t=n[0];if(t.byteLength!==Ir)return[`${ne} has ${t.byteLength} bytes, expected ${Ir}`];if(!Ci.every((h,g)=>t[g]===h))return[`${ne} has invalid magic`];let r=new DataView(t.buffer,t.byteOffset,t.byteLength),i=r.getUint16(4,!0),s=r.getUint16(6,!0),o=r.getUint8(8),a=$i.find(({bytes:h})=>h===o),c=r.getUint8(9),u=r.getUint16(10,!0),l=r.getUint16(12,!0),d=r.getUint16(14,!0),p=r.getUint32(16,!0),m=r.getUint32(20,!0),f=[];return i!==Ni&&f.push(`${ne} version ${i} is unsupported`),s!==Ir&&f.push(`${ne} declares size ${s}`),a?e!==null&&o!==e&&f.push(`${ne} pointer width ${o} does not match linked frames ${e}`):f.push(`${ne} pointer width ${o} is unsupported`),c!==Mi&&f.push(`${ne} alignment ${c} is unsupported`),u!==dn&&f.push(`${ne} flags 0x${u.toString(16)} do not equal required flags 0x${dn.toString(16)}`),l!==Di&&f.push(`${ne} arena version ${l} is unsupported`),d!==Ki&&f.push(`${ne} record version ${d} is unsupported`),p!==Bi&&f.push(`${ne} root word ${p} is unsupported`),m!==0&&f.push(`${ne} reserved field is nonzero`),f}function Ra(n){if(n.length===0)return[`missing required ${Se} descriptor`];if(n.length!==1)return[`has ${n.length} ${Se} descriptors, expected exactly one`];let e=n[0];if(e.byteLength2147483647||o.has(l))&&r.push(`${Se} layout id ${l} is invalid or duplicated`),o.add(l)}return r}var xa=new Set([ln,fn,hn,pn,mn,ht,Ct,Mt,Dt]);function ao(n){return!(n.module===En&&(n.name===Sn||n.name==="__channel_base"||n.name==="__wpk_fork_module_state_table_generation_addr"))}function Ta(n){let e=n.importedGlobalsDescriptors;if(e.length===0)return[`missing required ${X} descriptor`];if(e.length!==1)return[`has ${e.length} ${X} descriptors, expected exactly one`];let t=e[0];if(t.byteLengtht[g]===h)||i.push(`${X} has invalid magic`),r.getUint16(4,!0)!==Zi&&i.push(`${X} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Pr&&i.push(`${X} declares an invalid header size`);let s=r.getUint32(8,!0);r.getUint32(12,!0)!==0&&i.push(`${X} reserved field is nonzero`);let o=new Set,a=new Set,c=new TextDecoder("utf-8",{fatal:!0}),u=[],l=-1,d=Pr;for(let h=0;ht.byteLength)return i.push(`${X} record ${h} header is truncated`),i;let g=r.getUint32(d,!0),_=r.getUint32(d+4,!0),y=r.getUint8(d+8),E=r.getUint8(d+9),O=r.getUint32(d+12,!0),w=r.getUint32(d+16,!0),S=r.getUint32(d+20,!0),A=Bt+O+w;if(!Number.isSafeInteger(A)||g!==A||gt.byteLength)return i.push(`${X} record ${h} has invalid bounds`),i;(_===0||o.has(_))&&i.push(`${X} record ${h} has invalid or duplicated owner ${_}`),o.add(_),xa.has(y)||i.push(`${X} record ${h} has unknown value type ${y}`),(E&~ji)!==0&&i.push(`${X} record ${h} has unknown flags 0x${E.toString(16)}`),r.getUint16(d+10,!0)!==0&&i.push(`${X} record ${h} reserved fields are nonzero`),(a.has(S)||S<=l)&&i.push(`${X} record ${h} has duplicated or unordered import ordinal`),a.add(S),l=S;let R=d+Bt;try{let x=c.decode(t.subarray(R,R+O)),v=c.decode(t.subarray(R+O,R+O+w));u.push({ownerId:_,typeCode:y,flags:E,importOrdinal:S,module:x,name:v})}catch{i.push(`${X} record ${h} contains invalid UTF-8`)}d+=g}d!==t.byteLength&&i.push(`${X} has trailing bytes`);let p=[...n.globalImports.values()].flat(),m=new Map(p.map(h=>[h.index,h])),f=new Set;for(let h of u){let g=`${Rr}${h.ownerId}`,_=n.exports.get(g);if(!_||_.length!==1||_[0].kind!==3){i.push(`${X} owner ${h.ownerId} lacks exactly one global catalog export ${g}`);continue}let y=m.get(_[0].index);if(!y||!ao(y)){i.push(`${X} owner ${h.ownerId} does not identify a reconstructible imported global`);continue}if(y.module!==h.module||y.name!==h.name||y.importOrdinal!==h.importOrdinal||y.recipeTypeCode!==h.typeCode||y.mutable!==((h.flags&Xi)!==0)||y.shared!==((h.flags&Yi)!==0)){i.push(`${X} owner ${h.ownerId} does not match its imported global declaration`);continue}if(f.has(y.index)){i.push(`${X} repeats imported global index ${y.index}`);continue}f.add(y.index)}for(let h of p)ao(h)&&!f.has(h.index)&&i.push(`${X} omits imported global ${h.module}.${h.name} at index ${h.index}`);for(let[h,g]of n.exports){if(!h.startsWith(Rr))continue;let _=h.slice(Rr.length),y=Number(_);(!/^[1-9][0-9]*$/.test(_)||!Number.isSafeInteger(y)||y>4294967295||g.length!==1||g[0].kind!==3)&&i.push(`malformed reserved fork global catalog export ${h}`)}return i}var va=new Set([ht,Ct,Mt,Dt]);function co(n){return!wn.some(({module:e,name:t})=>n.module===e&&n.name===t)}function La(n){let e=n.importedTablesDescriptors;if(e.length===0)return[`missing required ${Y} descriptor`];if(e.length!==1)return[`has ${e.length} ${Y} descriptors, expected exactly one`];let t=e[0];if(t.byteLengtht[g]===h)||i.push(`${Y} has invalid magic`),r.getUint16(4,!0)!==Qi&&i.push(`${Y} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==kr&&i.push(`${Y} declares an invalid header size`);let s=r.getUint32(8,!0);r.getUint32(12,!0)!==0&&i.push(`${Y} reserved field is nonzero`);let o=new Set,a=new Set,c=new TextDecoder("utf-8",{fatal:!0}),u=[],l=-1,d=kr;for(let h=0;ht.byteLength)return i.push(`${Y} record ${h} header is truncated`),i;let g=r.getUint32(d,!0),_=r.getUint32(d+4,!0),y=r.getUint8(d+8),E=r.getUint8(d+9),O=r.getUint32(d+12,!0),w=r.getUint32(d+16,!0),S=r.getUint32(d+20,!0),A=$t+O+w;if(!Number.isSafeInteger(A)||g!==A||g<$t||d+g>t.byteLength)return i.push(`${Y} record ${h} has invalid bounds`),i;(_===0||o.has(_))&&i.push(`${Y} record ${h} has invalid or duplicated owner ${_}`),o.add(_),va.has(y)||i.push(`${Y} record ${h} has unknown element type ${y}`),(E&~to)!==0&&i.push(`${Y} record ${h} has unknown flags 0x${E.toString(16)}`),r.getUint16(d+10,!0)!==0&&i.push(`${Y} record ${h} reserved fields are nonzero`),(a.has(S)||S<=l)&&i.push(`${Y} record ${h} has duplicated or unordered import ordinal`),a.add(S),l=S;let R=d+$t;try{let x=c.decode(t.subarray(R,R+O)),v=c.decode(t.subarray(R+O,R+O+w));u.push({ownerId:_,typeCode:y,flags:E,importOrdinal:S,module:x,name:v})}catch{i.push(`${Y} record ${h} contains invalid UTF-8`)}d+=g}d!==t.byteLength&&i.push(`${Y} has trailing bytes`);let p=[...n.tableImports.values()].flat(),m=new Map(p.map(h=>[h.index,h])),f=new Set;for(let h of u){let g=`${xr}${h.ownerId}`,_=n.exports.get(g);if(!_||_.length!==1||_[0].kind!==1){i.push(`${Y} owner ${h.ownerId} lacks exactly one table catalog export ${g}`);continue}let y=m.get(_[0].index);if(!y||!co(y)){i.push(`${Y} owner ${h.ownerId} does not identify a reconstructible imported table`);continue}if(y.module!==h.module||y.name!==h.name||y.importOrdinal!==h.importOrdinal||y.recipeTypeCode!==h.typeCode||y.table64!==((h.flags&eo)!==0)){i.push(`${Y} owner ${h.ownerId} does not match its imported table declaration`);continue}if(f.has(y.index)){i.push(`${Y} repeats imported table index ${y.index}`);continue}f.add(y.index)}for(let h of p)co(h)&&!f.has(h.index)&&i.push(`${Y} omits imported table ${h.module}.${h.name} at index ${h.index}`);for(let[h,g]of n.exports){if(!h.startsWith(xr))continue;let _=h.slice(xr.length),y=Number(_);(!/^[1-9][0-9]*$/.test(_)||!Number.isSafeInteger(y)||y>4294967295||g.length!==1||g[0].kind!==1)&&i.push(`malformed reserved fork table catalog export ${h}`)}return i}function In(n,e){switch(n){case"ptr":return e===8?126:127;case"i32":return 127;case"i64":return 126;case"anyref":return 110;case"exnref":return 105;case"externref":return 111;case"funcref":return 112}}function uo(n,e,t,r){return n.params.length===e.length&&n.results.length===t.length&&n.params.every((i,s)=>i===In(e[s],r))&&n.results.every((i,s)=>i===In(t[s],r))}function lo(n,e,t){let r=i=>i==="ptr"?t===8?"i64":"i32":i;return`(${n.map(r).join(", ")}) -> (${e.map(r).join(", ")})`}function za(n){let e=`${En}.${Sn}`,t=n.globalImports.get(e);return t?t.length!==1?[`duplicate exception-codec activation import ${e}`]:t[0].valueType!==127||t[0].mutable?[`exception-codec activation import ${e} must be immutable i32`]:[]:[`missing required immutable exception-codec activation import ${e}`]}function ba(n){let e=[];for(let t of wn){let r=`${t.module}.${t.name}`,i=n.tableImports.get(r);if(!i){e.push(`missing required ABI 43 fork-runtime table import ${r}`);continue}if(i.length!==1){e.push(`duplicate ABI 43 fork-runtime table import ${r}`);continue}let s=i[0],o=In(t.element,4);(s.elementType!==o||s.table64!==t.table64||s.minimum!==t.minimum||s.maximum!==t.maximum)&&e.push(`ABI 43 fork-runtime table import ${r} has the wrong type or limits`)}return e}function Pa(n){if(n.staticRootDescriptors.length===0)return[`missing required ${$e} descriptor`];if(n.staticRootDescriptors.length!==1)return[`has ${n.staticRootDescriptors.length} ${$e} descriptors, expected exactly one`];let e=n.staticRootDescriptors[0];if(e.byteLength!==br)return[`${$e} has ${e.byteLength} bytes, expected ${br}`];let t=[];da.some((u,l)=>e[l]!==u)&&t.push(`${$e} has invalid magic`);let r=new DataView(e.buffer,e.byteOffset,e.byteLength);r.getUint16(4,!0)!==Hi&&t.push(`${$e} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==br&&t.push(`${$e} declares an invalid header size`);let i=r.getUint32(8,!0),s=n.tableExports.get(Kt);if(!s||s.length!==1)return t.push(`missing exactly one table export ${Kt}`),t;let o=[...n.tableImports.values()].reduce((u,l)=>u+l.length,0),a=s[0],c=n.tables[a];return a!n.functionExports.has(c)).map(({name:c})=>c);t.length>0&&e.push(`incomplete wasm-fork-instrument exports; missing ${t.join(", ")}`);let r=null;if(n.linkedFrameDescriptors.length===0)e.push(`missing required ${Ft} descriptor`);else if(n.linkedFrameDescriptors.length!==1)e.push(`has ${n.linkedFrameDescriptors.length} ${Ft} descriptors, expected exactly one`);else try{r=wa(n.linkedFrameDescriptors[0])}catch(c){e.push(c instanceof Error?c.message:String(c))}e.push(...Ia(n.moduleStateDescriptors,r));let i=mt.filter(({module:c,name:u})=>n.functionImports.has(`${c}.${u}`)),s=`${Lr}.${zr}`,o=n.importsKernelFork||i.length>0;if((o||n.tagImports.has(s)||n.unwindTransportDescriptors.length>0)&&e.push(...Aa(n)),o){let c=mt.filter(({module:u,name:l})=>!n.functionImports.has(`${u}.${l}`)).map(({module:u,name:l})=>`${u}.${l}`);c.length>0&&e.push(`incomplete ABI 43 fork-runtime imports; missing ${c.join(", ")}`);for(let u of mt){let l=`${u.module}.${u.name}`,d=n.functionImports.get(l);d&&d.length!==1&&e.push(`duplicate ABI 43 fork-runtime import ${l}`)}}if(r!==null){if(n.memoryPointerWidths.length!==1)e.push(`ABI 43 fork instrumentation requires exactly one module memory, found ${n.memoryPointerWidths.length}`);else if(n.memoryPointerWidths[0]!==r){let c=r===8?"an":"a";e.push(`ABI 43 linked-frame descriptor declares ${c} ${r}-byte pointer but the module memory uses ${n.memoryPointerWidths[0]}-byte addresses`)}for(let c of Ut){let u=n.functionExports.get(c.name);u?.length===1&&!uo(u[0],c.params,c.results,r)&&e.push(`ABI 43 wasm-fork-instrument export ${c.name} has the wrong signature; expected ${lo(c.params,c.results,r)}`)}if(o)for(let c of mt){let u=`${c.module}.${c.name}`,l=n.functionImports.get(u);l?.length===1&&!uo(l[0],c.params,c.results,r)&&e.push(`ABI 43 fork-runtime import ${u} has the wrong signature; expected ${lo(c.params,c.results,r)}`)}}return e}function po(n,e){switch(n){case 0:return"function";case 1:return"table";case 2:return"memory";case 3:return"global";case 4:return"tag";default:throw new Error(`${e} has unsupported external kind ${n}`)}}function Fa(n){let e=new Uint8Array(n);if(!Fr(e))return[];let t=[],r=8;for(;r`${e}.${t}`)}function Ca(n){let e=new Uint8Array(n);if(!Fr(e))return[];let t=[],r=8;for(;re)}function mo(n){let e=new Uint8Array(n);if(!Fr(e))return[];let t=[],r=8;for(;rt.startsWith("reloc."))}function _o(n,e={}){let t=[],r=null;Da(n)&&t.push("contains asyncify_"),e.expectedAbi!==void 0&&e.expectedAbi!==null&&(r=$a(n),r!==null&&r!==e.expectedAbi&&t.push(`ABI ${r}, expected ${e.expectedAbi}`));let i=new Set(Ma(n));if(e.requiredExports){let E=e.requiredExports.filter(O=>!i.has(O));E.length>0&&t.push(`missing required exports: ${E.join(", ")}`)}let s=Ea.filter(E=>i.has(E)),o=Na(n),a=mo(n),c=mt.filter(({module:E,name:O})=>o.includes(`${E}.${O}`)),u=a.filter(E=>E===Ft).length,l=a.filter(E=>E===ve).length,d=a.filter(E=>E===ne).length,p=a.filter(E=>E===Se).length,m=a.filter(E=>E===X).length,f=a.filter(E=>E===Y).length,h=a.filter(E=>E===pt).length,g=o.includes(`${Lr}.${zr}`),_=s.length>0||c.length>0||u>0||l>0||d>0||p>0||m>0||f>0||h>0||g;if(e.expectedAbi!==void 0&&e.expectedAbi!==null&&_&&r===null&&t.push(`ABI ${e.expectedAbi} fork artifact is missing __abi_version; the activation-state capability epoch cannot be verified`),e.forbidForkInstrumentation&&_&&t.push("contains ABI 43 wasm-fork-instrument metadata, imports, or exports"),(e.requireForkInstrumentation??!Ka(n))&&(_||o.includes("kernel.kernel_fork")))try{t.push(...ka(Sa(n)))}catch(E){t.push(`cannot validate ABI 43 fork-artifact contract: ${E instanceof Error?E.message:String(E)}`)}return t}function Ba(n,e){let t=new Uint8Array(n);if(t.length<8)return null;let r=0,i=null,s=null,o=8;for(;o=c)return null;let h=a;for(let y=0;y=f)return null;let[h,g]=T(t,m);m+=g;for(let _=0;_f)return null}return m}function p(m,f=0){if(f>4)return null;let h=l(m);if(!h)return null;let g=d(h.start,h.end);if(g===null)return null;let _=g,y=h.end;for(;_=32&&E<=38||E===208){let[,O]=T(t,_);_+=O}else if(E>=40&&E<=62)_=Vt(t,_);else if(E===63||E===64)_++;else if(E===66){let[,O]=fo(t,_);_+=O}else if(E===67)_+=4;else if(E===68)_+=8;else if(E===252||E===253||E===254){let O=_a(E,t,_);if(O===null)return null;_=O}}return null}return p(i)}function $a(n){return Ba(n,"__abi_version")}et();var Ua=ArrayBuffer,J=Uint8Array,Nr=Uint16Array,Wa=Int16Array;var Cr=Int32Array,Rn=function(n,e,t){if(J.prototype.slice)return J.prototype.slice.call(n,e,t);(e==null||e<0)&&(e=0),(t==null||t>n.length)&&(t=n.length);var r=new J(t-e);return r.set(n.subarray(e,t)),r},Zt=function(n,e,t,r){if(J.prototype.fill)return J.prototype.fill.call(n,e,t,r);for((t==null||t<0)&&(t=0),(r==null||r>n.length)&&(r=n.length);tn.length)&&(r=n.length);t2046MB)","invalid block type","FSE accuracy too high","match distance too far back","unexpected EOF"],Q=function(n,e,t){var r=new Error(e||Ha[n]);if(r.code=n,Error.captureStackTrace&&Error.captureStackTrace(r,Q),!t)throw r;return r},yo=function(n,e,t){for(var r=0,i=0;r>>0},qa=function(n,e){var t=n[0]|n[1]<<8|n[2]<<16;if(t==3126568&&n[3]==253){var r=n[4],i=r>>5&1,s=r>>2&1,o=r&3,a=r>>6;r&8&&Q(0);var c=6-i,u=o==3?4:o,l=yo(n,c,u);c+=u;var d=a?1<>3);m=f+(f>>3)*(n[5]&7)}m>2145386496&&Q(1);var h=new J((e==1?p||m:e?0:m)+12);return h[0]=1,h[4]=4,h[8]=8,{b:c+d,y:0,l:0,d:l,w:e&&e!=1?e:h.subarray(12),e:m,o:new Cr(h.buffer,0,3),u:p,c:s,m:Math.min(131072,m)}}else if((t>>4|n[3]<<20)==25481893)return Va(n,4)+8;Q(0)},tt=function(n){for(var e=0;1<t&&Q(3);for(var s=1<0;){var y=tt(o+1),E=r>>3,O=(1<>(r&7)&O,S=(1<S&&(w-=A)),p[++a]=--w,w==-1?(o+=w,g[--l]=a):o-=w,!w)do{var x=r>>3;c=(n[x]|n[x+1]<<8)>>(r&7)&3,r+=2,a+=c}while(c==3)}(a>255||o)&&Q(0);for(var v=0,L=(s>>1)+(s>>3)+3,D=s-1,Z=0;Z<=a;++Z){var N=p[Z];if(N<1){m[Z]=-N;continue}for(u=0;u=l)}}for(v&&Q(0),u=0;u>3,{b:i,s:g,n:_,t:f}]},Za=function(n,e){var t=0,r=-1,i=new J(292),s=n[e],o=i.subarray(0,256),a=i.subarray(256,268),c=new Nr(i.buffer,268);if(s<128){var u=Xt(n,e+1,6),l=u[0],d=u[1];e+=s;var p=l<<3,m=n[e];m||Q(0);for(var f=0,h=0,g=d.b,_=g,y=(++e<<3)-8+tt(m);y-=g,!(y>3;if(f+=(n[E]|n[E+1]<<8)>>(y&7)&(1<>3,h+=(n[E]|n[E+1]<<8)>>(y&7)&(1<<_)-1,o[++r]=d.s[h],g=d.n[f],f=d.t[f],_=d.n[h],h=d.t[h]}++r>255&&Q(0)}else{for(r=s-127;t>4,o[t+1]=O&15}++e}var w=0;for(t=0;t11&&Q(0),w+=S&&1<0;--t){var Z=c[t];Zt(D,t,Z,c[t-1]=Z+a[t]*(1<a&&d>3,m=(n[p]|n[p+1]<<8|n[p+2]<<16)>>(l&7);c=(c<>2,o=s<<1,a=s+o;qt(n.subarray(r,r+=n[0]|n[1]<<8),e.subarray(0,s),t),qt(n.subarray(r,r+=n[2]|n[3]<<8),e.subarray(s,o),t),qt(n.subarray(r,r+=n[4]|n[5]<<8),e.subarray(o,a),t),qt(n.subarray(r),e.subarray(a),t)},tc=function(n,e,t){var r,i=e.b,s=n[i],o=s>>1&3;e.l=s&1;var a=s>>3|n[i+1]<<5|n[i+2]<<13,c=(i+=3)+a;if(o==1)return i>=n.length?void 0:(e.b=i+1,t?(Zt(t,n[i],e.y,e.y+=a),t):Zt(new J(a),n[i]));if(!(c>n.length)){if(o==0)return e.b=c,t?(t.set(n.subarray(i,c),e.y),e.y+=a,t):Rn(n,i,c);if(o==2){var u=n[i],l=u&3,d=u>>2&3,p=u>>4,m=0,f=0;l<2?d&1?p|=n[++i]<<4|(d&2&&n[++i]<<12):p=u>>3:(f=d,d<2?(p|=(n[++i]&63)<<4,m=n[i]>>6|n[++i]<<2):d==2?(p|=n[++i]<<4|(n[++i]&3)<<12,m=n[i]>>2|n[++i]<<6):(p|=n[++i]<<4|(n[++i]&63)<<12,m=n[i]>>6|n[++i]<<2|n[++i]<<10)),++i;var h=t?t.subarray(e.y,e.y+e.m):new J(e.m),g=h.length-p;if(l==0)h.set(n.subarray(i,i+=p),g);else if(l==1)Zt(h,n[i++],g);else{var _=e.h;if(l==2){var y=Za(n,i);m+=i-(i=y[0]),e.h=_=y[1]}else _||Q(0);(f?ec:qt)(n.subarray(i,i+=m),h.subarray(g),_)}var E=n[i++];if(E){E==255?E=(n[i++]|n[i++]<<8)+32512:E>127&&(E=E-128<<8|n[i++]);var O=n[i++];O&3&&Q(0);for(var w=[Ya,ja,Xa],S=2;S>-1;--S){var A=O>>(S<<1)+2&3;if(A==1){var R=new J([0,0,n[i++]]);w[S]={s:R.subarray(2,3),n:R.subarray(0,1),t:new Nr(R.buffer,0,1),b:0}}else A==2?(r=Xt(n,i,9-(S&1)),i=r[0],w[S]=r[1]):A==3&&(e.t||Q(0),w[S]=e.t[S])}var x=e.t=w,v=x[0],L=x[1],D=x[2],Z=n[c-1];Z||Q(0);var N=(c<<3)-8+tt(Z)-D.b,b=N>>3,U=0,le=(n[b]|n[b+1]<<8)>>(N&7)&(1<>3;var C=(n[b]|n[b+1]<<8)>>(N&7)&(1<>3;var V=(n[b]|n[b+1]<<8)>>(N&7)&(1<>3;var lt=1<>>(N&7)<-1);b=(N-=Tn[Je])>>3;var De=Qa[Je]+((n[b]|n[b+1]<<8|n[b+2]<<16)>>(N&7)&(1<>3;var Qe=Ja[Pt]+((n[b]|n[b+1]<<8|n[b+2]<<16)>>(N&7)&(1<>3,le=D.t[le]+((n[b]|n[b+1]<<8)>>(N&7)&(1<>3,V=v.t[V]+((n[b]|n[b+1]<<8)>>(N&7)&(1<>3,C=L.t[C]+((n[b]|n[b+1]<<8)>>(N&7)&(1<3)e.o[2]=e.o[1],e.o[1]=e.o[0],e.o[0]=Ee-=3;else{var ft=Ee-(Qe!=0);ft?(Ee=ft==3?e.o[0]-1:e.o[ft],ft>1&&(e.o[2]=e.o[1]),e.o[1]=e.o[0],e.o[0]=Ee):Ee=e.o[0]}for(var S=0;SDe&&(Be=De);for(var S=0;Soc)throw Yt("EOVERFLOW","file offset is outside signed i64");return n}function ac(n){if(Ln(n)<0n)throw Yt("EINVAL","negative positioned I/O offset");return n}function zn(n){let e=Ln(n);if(ewo)throw Yt("EOVERFLOW","backend cannot represent the file offset exactly");return So(e)}function bn(n){let e=ac(n);return zn(e)}function Oo(n){if(n===null)return null;let e=Ln(n);if(e<0n)throw Yt("EINVAL","negative file-size limit");return e>wo?null:So(e)}et();function Pn(n){let e=new Error(`EINVAL: pathconf name ${n} is not associated with this object`);throw e.code="EINVAL",e}function kn(n,e,t){switch(e){case q.LINK_MAX:return null;case q.NAME_MAX:return 255;case q.PATH_MAX:return ro;case q.CHOWN_RESTRICTED:return 1;case q.NO_TRUNC:return 1;case q.ASYNC_IO:return(n.mode&W.S_IFMT)===W.S_IFREG?1:Pn(e);case q.SYNC_IO:case q.PRIO_IO:case q.FILESIZEBITS:case q.REC_INCR_XFER_SIZE:case q.REC_MAX_XFER_SIZE:case q.REC_MIN_XFER_SIZE:case q.REC_XFER_ALIGN:case q.ALLOC_SIZE_MIN:case q.SYMLINK_MAX:case q.FALLOC:return null;case q.POSIX2_SYMLINKS:return t.supportsSymlinks?1:null;case q.TEXTDOMAIN_MAX:return 255;case q.TIMESTAMP_RESOLUTION:return t.timestampResolutionNs;case q.PIPE_BUF:{let r=n.mode&W.S_IFMT;return r===W.S_IFIFO||r===W.S_IFDIR?null:Pn(e)}case q.MAX_CANON:case q.MAX_INPUT:case q.VDISABLE:case q.SOCK_MAXBUF:return Pn(e);default:{let r=new Error(`EINVAL: invalid pathconf name ${e}`);throw r.code="EINVAL",r}}}et();var Mr=Math.floor(160),Fn=1397114451,Nn=1,jt=32768,H=16384,_t=40960,G=61440,cc=2048,uc=1024,dc=73,Ao=4294967295,rt=0,Io=1;var ir=64,Wn=128,sr=512,lc=1024,fc=65536,Jt=3,hc=0,pc=1,mc=2,k=8,_c=-1,Oe=-2,B=-5,re=-9,Bn=-16,Ot=-17,ze=-20,it=-21,j=-22,ko=-24,ot=-27,oe=-28,$n=-36,Un=-39,Fo=-40,No=-75,Cn=0,Mn=4,Dr=8,yt=12,Ge=16,gt=20,Kr=24,nt=28,Br=32,Ro=36,$r=40,yc=44,gc=48,Ec=52,Dn=56,Ur=60,Wr=64,Qt=68,xo=72,Et=0,F=8,K=12,P=16,fe=24,ee=32,er=40,ie=48,tr=88,St=92,rr=96,nr=100,ue=104,He=112,To=116,de=120,vo=4,Le=8,Lo=16,zo=20,bo=-2147483648,Sc=2147483647,wc=1034+1024*1024,Ve=wc*4096,Oc={[Oe]:"No such file or directory",[B]:"I/O error",[re]:"Bad file descriptor",[Bn]:"Device or resource busy",[Ot]:"File exists",[ze]:"Not a directory",[it]:"Is a directory",[j]:"Invalid argument",[ko]:"Too many open files",[ot]:"File too large",[oe]:"No space left on device",[$n]:"File name too long",[Un]:"Directory not empty",[Fo]:"Too many symbolic links",[No]:"Value too large for data type"},I=class extends Error{constructor(t,r){super(r||Oc[t]||`Error ${t}`);this.code=t;this.name="SFSError"}code},me=new TextEncoder,or=new TextDecoder,Po=me.encode("..");function Kn(n){return n==="."||n===".."}function wt(n){return n.buffer instanceof SharedArrayBuffer?or.decode(new Uint8Array(n)):or.decode(n)}function qe(n){return n+3&-4}var be=class n{constructor(e){this.buffer=e;this.view=new DataView(e),this.i32=new Int32Array(e),this.u8=new Uint8Array(e)}buffer;view;i32;u8;dirIndexes=new Map;blockAllocHint=0;inodeAllocHint=2;atomicsWaitAllowed;static DIR_INDEX_MIN_SIZE=64*1024;static mkfs(e,t){let r=e.byteLength;if(r<4096*16)throw new I(j);let i=Math.floor(r/4096),s=t?Math.floor(t/4096):i*4,o=Math.floor(s/4);o<32&&(o=32),o=Math.ceil(o/32)*32;let a=Math.ceil(o/(4096*8)),c=Math.ceil(s/(4096*8)),u=Math.ceil(o*128/4096),l=1,d=l+a,p=d+c,m=p+u;if(m>=i){let R=(m+1)*4096;try{e.grow(R)}catch{throw new I(oe)}if(i=Math.floor(e.byteLength/4096),m>=i)throw new I(oe)}new Uint8Array(e).fill(0);let f=new n(e);f.w32(Cn,Fn),f.w32(Mn,Nn),f.w32(Dr,4096),f.w32(yt,i),f.w32(Ge,o),f.w32(nt,l),f.w32(Br,d),f.w32(Ro,p),f.w32($r,m),f.w32(yc,a),f.w32(gc,c),f.w32(Ec,u),f.w32(Qt,s),f.w32(xo,256);let h=d*4096;for(let R=0;R>2)+(R>>5);f.i32[x]|=1<<(R&31)}let g=i-m;Atomics.store(f.i32,gt>>2,g),f.blockAllocHint=m;let _=l*4096;f.i32[_>>2]|=3,Atomics.store(f.i32,Kr>>2,o-2),f.inodeAllocHint=2;let y=f.inodeOffset(1);f.w32(y+F,H|493),f.w32(y+K,2),f.w64(y+ue,1);let E=f.blockAlloc();if(E<0)throw new I(oe);f.w32(y+ie,E);let O=E*4096,w=qe(k+1),S=qe(k+2);f.w32(O,1),f.view.setUint16(O+4,w,!0),f.view.setUint16(O+6,1,!0),f.u8[O+k]=46;let A=O+w;return f.w32(A,1),f.view.setUint16(A+4,S,!0),f.view.setUint16(A+6,2,!0),f.u8[A+k]=46,f.u8[A+k+1]=46,f.w64(y+P,w+S),Atomics.store(f.i32,Dn>>2,1),f}static inspectImageCapacity(e){if(e.byteLengththis.snapshotBytesUnlocked(e))}snapshotState(e){return this.withNamespaceLock(()=>({bytes:this.snapshotBytesUnlocked(e),identities:this.collectIdentityStateUnlocked()}))}identityState(){return this.withNamespaceLock(()=>this.collectIdentityStateUnlocked())}snapshotBytesUnlocked(e){let t=e?.normalizeTimestampsMs;if(t!==void 0&&(!Number.isSafeInteger(t)||t<0))throw new I(j,"Snapshot timestamp must be a non-negative safe integer in milliseconds");let r=t===void 0?void 0:BigInt(t);for(let a=0;a>2)!==0)throw new I(Bn,"Cannot save a VFS image with open descriptors")}let i=this.r32(Ge);for(let a=0;a=1&&this.inodeIsAllocated(a)?r:0n;o.setBigUint64(c+er,u,!0),o.setBigUint64(c+fe,u,!0),o.setBigUint64(c+ee,u,!0)}}return s}collectIdentityStateUnlocked(){let e=new Map,t=this.inodeOffset(1),r=this.r64(t+ue);e.set(`1:${r}`,{ino:1,generation:r,dataSequence:Atomics.load(this.i32,t+de>>2)>>>0,mode:this.r32(t+F),linkCount:this.r32(t+K),size:this.r64(t+P),uid:this.r32(t+rr),gid:this.r32(t+nr),paths:["/"]});let i=[{ino:1,path:"/"}],s=new Set;for(;i.length>0;){let o=i.pop();if(s.has(o.ino))throw new I(B);s.add(o.ino);let a=this.inodeOffset(o.ino);if((this.r32(a+F)&G)!==H)throw new I(B);let c=this.r64(a+P),u=0;for(;u>2)>>>0,mode:v,linkCount:this.r32(S+K),size:this.r64(S+P),uid:this.r32(S+rr),gid:this.r32(S+nr),...(v&G)===_t?{symlinkTarget:this.readSymlinkInodeUnlocked(_)}:{},paths:[]},e.set(R,x)}x.paths.push(w),(this.r32(S+F)&G)===H&&i.push({ino:_,path:w})}}h+=y}u+=f}}return e}statfs(){let e=this.r32(Dr),t=this.r32(yt),r=this.r32(Qt),i=typeof this.buffer.maxByteLength=="number"?this.buffer.maxByteLength:this.buffer.byteLength,s=Math.floor(i/e),o=Math.max(t,Math.min(r,s)),a=Atomics.load(this.i32,gt>>2),c=Math.max(0,o-t);return{blockSize:e,totalBlocks:o,freeBlocks:a+c,totalInodes:this.r32(Ge),freeInodes:Atomics.load(this.i32,Kr>>2),maxName:255}}r32(e){return this.view.getUint32(e,!0)}w32(e,t){this.view.setUint32(e,t,!0)}r64(e){return Number(this.view.getBigUint64(e,!0))}w64(e,t){this.view.setBigUint64(e,BigInt(t),!0)}waitForAtomicChange(e,t){if(this.atomicsWaitAllowed!==!1)try{Atomics.wait(this.i32,e,t),this.atomicsWaitAllowed=!0;return}catch(r){if(!(r instanceof TypeError))throw r;this.atomicsWaitAllowed=!1}for(;Atomics.load(this.i32,e)===t;);}resetAllocationHints(){this.blockAllocHint=this.findNextFreeBlockHint(),this.inodeAllocHint=this.findNextFreeInodeHint()}findNextFreeBlockHint(){let e=this.r32(yt),t=this.r32($r),r=this.r32(Br)*4096;for(let i=t;i>2)+(i>>5),o=i&31;if((Atomics.load(this.i32,s)&1<>2)+(r>>5),s=r&31;if((Atomics.load(this.i32,i)&1<>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}sbUnlock(){let e=Ur>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}namespaceLock(){let e=Wr>>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}namespaceUnlock(){let e=Wr>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}withNamespaceLock(e){this.namespaceLock();try{return e()}finally{this.namespaceUnlock()}}resetRestoredRuntimeState(){Atomics.store(this.i32,Ur>>2,0),Atomics.store(this.i32,Wr>>2,0),this.u8.fill(0,256,4096);let e=this.r32(Ge),t=this.r32(nt)*4096;for(let r=0;r>5)*4)&1<<(r&31))===0||this.r32(i+K)!==0)continue;let o=this.r32(i+F),a=this.r64(i+P);(o&G)===_t&&a<=40?(this.u8.fill(0,i+ie,i+ie+40),this.w64(i+P,0)):this.inodeTruncate(r,0),this.inodeFree(r)}}blockAlloc(){let e=this.r32(yt),t=this.r32(Br)*4096,r=this.r32($r),i=this.blockAllocHint>=r&&this.blockAllocHint>2)+(a>>5),u=a&31,l=Atomics.load(this.i32,c);if(l&1<>2,1),this.blockAllocHint=a+1>2)+(e>>5),i=e&31;for(;;){let s=Atomics.load(this.i32,r),o=s&~(1<>2,1),e>=this.r32($r)&&e>2)>0)return 0;let e=this.r32(yt),t=this.r32(Qt),r=this.r32(xo),i=e+r;if(i>t&&(i=t,r=i-e,r===0))return oe;let s=i*4096;if(this.buffer.byteLength>2,r),Atomics.add(this.i32,Dn>>2,1),this.blockAllocHint=e,0}finally{this.sbUnlock()}}inodeOffset(e){let r=this.r32(Ro)+Math.floor(e/32),i=e%32*128;return r*4096+i}inodeAlloc(){let e=this.r32(Ge),t=this.r32(nt)*4096,r=this.inodeAllocHint>=2&&this.inodeAllocHint>2)+(o>>5),c=o&31,u=Atomics.load(this.i32,a);if(u&1<>2,1),this.inodeAllocHint=o+1>2,1)+1}inodeFree(e){let r=(this.r32(nt)*4096>>2)+(e>>5),i=e&31;for(;;){let s=Atomics.load(this.i32,r);if((s&1<>2,1),e>=2&&e0&&this.w32(r+He,i-1),i<=1&&this.r32(r+K)===0&&(this.inodeTruncate(e,0),t=!0)}finally{this.inodeWriteUnlock(e)}t&&this.inodeFree(e)}inodeDropLinkRefLocked(e){let t=this.inodeOffset(e),r=this.r32(t+K);return r>1?(this.w32(t+K,r-1),this.w64(t+ee,Date.now()),!1):this.inodeOrphanLocked(e)}inodeOrphanLocked(e){let t=this.inodeOffset(e);if(this.w32(t+K,0),this.w64(t+ee,Date.now()),this.r32(t+He)>0)return!1;let r=this.r32(t+F),i=this.r64(t+P);return(r&G)===_t&&i<=40?(this.u8.fill(0,t+ie,t+ie+40),this.w64(t+P,0)):this.inodeTruncate(e,0),!0}inodeReadLock(e){let t=this.inodeOffset(e)+Et>>2;for(;;){let r=Atomics.load(this.i32,t);if(r&bo){this.waitForAtomicChange(t,r);continue}if(Atomics.compareExchange(this.i32,t,r,r+1)===r)return}}inodeReadUnlock(e){let t=this.inodeOffset(e)+Et>>2;(Atomics.sub(this.i32,t,1)&Sc)===1&&Atomics.notify(this.i32,t,1)}inodeWriteLock(e){let t=this.inodeOffset(e)+Et>>2;for(;;){let r=Atomics.load(this.i32,t);if(r!==0){this.waitForAtomicChange(t,r);continue}if(Atomics.compareExchange(this.i32,t,0,bo)===0)return}}inodeWriteUnlock(e){let t=this.inodeOffset(e)+Et>>2;Atomics.store(this.i32,t,0),Atomics.notify(this.i32,t,1/0)}inodeBlockMap(e,t,r){let i=this.inodeOffset(e);if(t<10){let s=this.r32(i+ie+t*4);if(s!==0)return s;if(!r)return 0;let o=this.blockAllocWithGrow();return o<0||this.w32(i+ie+t*4,o),o}if(t-=10,t<1024){let s=this.r32(i+tr),o=!1;if(s===0){if(!r)return 0;if(s=this.blockAllocWithGrow(),s<0)return s;this.w32(i+tr,s),o=!0}let a=s*4096+t*4,c=this.r32(a);if(c!==0)return c;if(!r)return 0;let u=this.blockAllocWithGrow();return u<0?(o&&(this.w32(i+tr,0),this.blockFree(s)),u):(this.w32(a,u),u)}if(t-=1024,t<1024*1024){let s=Math.floor(t/1024),o=t%1024,a=this.r32(i+St),c=!1;if(a===0){if(!r)return 0;if(a=this.blockAllocWithGrow(),a<0)return a;this.w32(i+St,a),c=!0}let u=a*4096+s*4,l=this.r32(u),d=!1;if(l===0){if(!r)return 0;if(l=this.blockAllocWithGrow(),l<0)return c&&(this.w32(i+St,0),this.blockFree(a)),l;this.w32(u,l),d=!0}let p=l*4096+o*4,m=this.r32(p);if(m!==0)return m;if(!r)return 0;let f=this.blockAllocWithGrow();return f<0?(d&&(this.w32(u,0),this.blockFree(l)),c&&(this.w32(i+St,0),this.blockFree(a)),f):(this.w32(p,f),f)}return j}inodeReadData(e,t,r,i){let s=this.inodeOffset(e),o=this.r64(s+P);if(t>=o)return 0;t+i>o&&(i=o-t);let a=0,c=0;for(;i>0;){let u=Math.floor(t/4096),l=t%4096,d=4096-l;d>i&&(d=i);let p=this.inodeBlockMap(e,u,!1);if(p<=0)r.fill(0,c,c+d);else{let m=p*4096+l;r.set(this.u8.subarray(m,m+d),c)}c+=d,t+=d,i-=d,a+=d}return a}inodeWriteData(e,t,r,i){let s=this.inodeOffset(e),o=this.r64(s+P);t>o&&this.zeroOldEofTail(e,o);let a=0,c=0;for(;i>0;){let u=Math.floor(t/4096),l=t%4096,d=4096-l;d>i&&(d=i);let p=this.inodeBlockMap(e,u,!0);if(p<0){if(a===0)return p;break}let m=p*4096+l;this.u8.set(r.subarray(c,c+d),m),c+=d,t+=d,i-=d,a+=d}if(a>0&&t>this.r64(s+P)&&this.w64(s+P,t),a>0){let u=Date.now();this.w64(s+fe,u),this.w64(s+ee,u),Atomics.add(this.i32,s+de>>2,1)}return a}zeroInodeRange(e,t,r){for(;t0){let c=a*4096+s;this.u8.fill(0,c,c+o)}t+=o}}zeroOldEofTail(e,t){let r=t%4096;if(r===0)return;let i=Math.floor(t/4096),s=this.inodeBlockMap(e,i,!1);if(s<=0)return;let o=s*4096+r;this.u8.fill(0,o,s*4096+4096)}freeBlocksFrom(e,t){let r=this.inodeOffset(e);for(let o=t;o<10;o++){let a=this.r32(r+ie+o*4);a&&(this.blockFree(a),this.w32(r+ie+o*4,0))}let i=this.r32(r+tr);if(i){let o=t>10?t-10:0;for(let a=o;a<1024;a++){let c=i*4096+a*4,u=this.r32(c);u&&(this.blockFree(u),this.w32(c,0))}o===0&&(this.blockFree(i),this.w32(r+tr,0))}let s=this.r32(r+St);if(s){let o=t>1034?t-10-1024:0,a=Math.floor(o/1024);for(let c=a;c<1024;c++){let u=s*4096+c*4,l=this.r32(u);if(!l)continue;let d=c===a?o%1024:0;for(let p=d;p<1024;p++){let m=l*4096+p*4,f=this.r32(m);f&&(this.blockFree(f),this.w32(m,0))}d===0&&(this.blockFree(l),this.w32(u,0))}a===0&&(this.blockFree(s),this.w32(r+St,0))}}inodeTruncate(e,t,r=!1){let i=this.inodeOffset(e),s=this.r64(i+P),o=t!==s;if(t>=s){if(t>s&&this.zeroOldEofTail(e,s),this.w64(i+P,t),o||r){let c=Date.now();this.w64(i+fe,c),this.w64(i+ee,c),Atomics.add(this.i32,i+de>>2,1)}return}t%4096!==0&&this.zeroInodeRange(e,t,Math.ceil(t/4096)*4096);let a=Math.ceil(t/4096);if(this.freeBlocksFrom(e,a),this.w64(i+P,t),o||r){let c=Date.now();this.w64(i+fe,c),this.w64(i+ee,c),Atomics.add(this.i32,i+de>>2,1)}}validateFileSize(e){if(!Number.isSafeInteger(e)||e<0)throw new I(j);if(e>Ve)throw new I(ot)}validateSeekPosition(e){if(!Number.isSafeInteger(e))throw new I(No);if(e<0)throw new I(j);if(e>Ve)throw new I(ot)}touchDirectoryMutation(e){let t=this.inodeOffset(e),r=Date.now();this.w64(t+fe,r),this.w64(t+ee,r);let i=Atomics.add(this.i32,t+To>>2,1)+1>>>0,s=this.dirIndexes.get(e);s&&(s.mutationSequence=i,s.size=this.r64(t+P))}dirNameKey(e){return wt(e)}dirEntryNameMatches(e,t){if(this.view.getUint16(e+6,!0)!==t.length)return!1;for(let i=0;i=k&&r%4===0&&e+r<=t&&i<=r-k}inodeIsAllocated(e){let t=this.r32(Ge);if(e<=0||e>=t)return!1;let r=this.r32(nt)*4096;return(Atomics.load(this.i32,(r>>2)+(e>>5))&1<<(e&31))!==0}rebuildDirIndex(e,t,r,i){let s=new Map,o=[],a=0;for(;a4096-l&&(m=4096-l);let f=l;for(;f=k&&o.push({abs:h,recLen:_});f+=_}a+=m}let c={generation:t,mutationSequence:r,size:i,entries:s,free:o};return this.dirIndexes.set(e,c),c}getDirIndex(e){let t=this.inodeOffset(e),r=this.r64(t+P),i=this.r64(t+ue),s=Atomics.load(this.i32,t+To>>2)>>>0,o=this.dirIndexes.get(e);return o&&o.generation===i&&o.mutationSequence===s&&o.size===r?o:(o&&this.dirIndexes.delete(e),r=0;o--){let a=e.free[o];if(!(a.recLen4096-c&&(d=4096-c);let p=c;for(;pr)return-1;a=c,o+=u}return o===r?a:-1}dirAppendEntry(e,t,r,i=-1){let s=this.inodeOffset(e),o=this.r64(s+P),a=qe(k+t.length),c=o,u=Math.floor(c/4096),l=c%4096,d=0;if(l!==0&&l+a>4096){let f=4096-l,h=0;if(f>=k){if(h=this.inodeBlockMap(e,u,!1),h<=0)return B}else if(i<0&&(i=this.findLastDirEntryInBlock(e,u,l)),i<0)return B;if(d=this.inodeBlockMap(e,u+1,!0),d<0)return d;if(f>=k){let g=h*4096+l;this.w32(g,0),this.view.setUint16(g+4,f,!0),this.view.setUint16(g+6,0,!0)}else{let _=this.view.getUint16(i+4,!0)+f;this.view.setUint16(i+4,_,!0),this.updateDirIndexRecLen(e,i,_)}c=(u+1)*4096,u++,l=0}let p;if(l===0){if(p=d||this.inodeBlockMap(e,u,!0),p<0)return p}else if(p=this.inodeBlockMap(e,u,!1),p<=0)return B;let m=p*4096+l;return this.w32(m,r),this.view.setUint16(m+4,a,!0),this.view.setUint16(m+6,t.length,!0),this.u8.set(t,m+k),this.w64(s+P,c+a),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,m,a),0}dirAddEntry(e,t,r){let i=this.getDirIndex(e);if(typeof i=="number")return i;if(i)return this.useDirIndexFreeSlot(i,e,t,r)?0:this.dirAppendEntry(e,t,r);let s=this.inodeOffset(e),o=this.r64(s+P),a=qe(k+t.length),c=-1,u=0;for(;u4096-d&&(f=4096-d);let h=d;for(;hd+f||E>y-k)return B;if(_===0&&y>=a)return this.w32(g,r),this.view.setUint16(g+6,t.length,!0),this.u8.set(t,g+k),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,g,y),0;let O=qe(k+E),w=y-O;if(_!==0&&w>=a){this.view.setUint16(g+4,O,!0);let S=g+O;return this.w32(S,r),this.view.setUint16(S+4,w,!0),this.view.setUint16(S+6,t.length,!0),this.u8.set(t,S+k),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,S,w),0}c=g,h+=y}u+=f}return this.dirAppendEntry(e,t,r,c)}dirRemoveEntry(e,t){let r=this.getDirIndex(e);if(typeof r=="number")return r;if(r){let a=this.dirNameKey(t),c=r.entries.get(a);if(!c)return Oe;if(this.r32(c.abs)===c.ino&&this.view.getUint16(c.abs+4,!0)===c.recLen&&this.view.getUint16(c.abs+6,!0)===c.nameLen&&this.dirEntryNameMatches(c.abs,t))return this.w32(c.abs,0),r.entries.delete(a),r.free.push({abs:c.abs,recLen:c.recLen}),this.touchDirectoryMutation(e),0;r.entries.delete(a)}let i=this.inodeOffset(e),s=this.r64(i+P),o=0;for(;o4096-c&&(d=4096-c);let p=c;for(;p4096-u&&(p=4096-u);let m=u;for(;m4096-o&&(u=4096-o);let l=o;for(;lo+u||f>m-k)throw new I(B);if(p!==0){if(f===1&&this.u8[d+k]===46){l+=m;continue}if(f===2&&this.u8[d+k]===46&&this.u8[d+k+1]===46){l+=m;continue}return!1}l+=m}i+=u}return!0}dirIsAncestor(e,t){let r=t;for(let i=0;i<8*1024;i++){if(r===e)return!0;if(r===1)return!1;let s=this.dirLookup(r,Po);if(s<0||s===r)throw new I(B);r=s}throw new I(B)}pathResolve(e,t){if(!e.startsWith("/"))return Oe;let r=1,i=e.split("/").filter(o=>o.length>0),s=0;for(let o=0;o255)return $n;let c=me.encode(a),u;this.inodeReadLock(r);try{let p=this.inodeOffset(r);if((this.r32(p+F)&G)!==H)return ze;u=this.dirLookup(r,c)}finally{this.inodeReadUnlock(r)}if(u<0)return u;let l=this.inodeOffset(u);if((this.r32(l+F)&G)===_t&&(!(o===i.length-1)||t)){if(++s>8)return Fo;let m=this.r64(l+P),f;if(m<=40)f=wt(this.u8.subarray(l+ie,l+ie+m));else{let h=new Uint8Array(m);this.inodeReadData(u,0,h,m),f=or.decode(h)}if(f.startsWith("/")){r=1;let h=f.split("/").filter(_=>_.length>0),g=i.slice(o+1);i.length=0,i.push(...h,...g),o=-1}else{let h=f.split("/").filter(_=>_.length>0),g=i.slice(o+1);i.length=o,i.push(...h,...g),o--}continue}r=u}return r}pathResolveParent(e){if(!e.startsWith("/"))throw new I(j,"Path must be absolute");let t=e.split("/").filter(c=>c.length>0);if(t.length===0)throw new I(j,"Cannot operate on /");let r=t.pop();if(r.length>255)throw new I($n);let i="/"+t.join("/"),s=this.pathResolve(i,!0);if(s<0)throw new I(s);let o=this.inodeOffset(s);if((this.r32(o+F)&G)!==H)throw new I(ze);return{parentIno:s,name:r}}fdAlloc(e,t,r){for(let i=0;i>2;if(Atomics.compareExchange(this.i32,o,0,1)===0)return this.w32(s+vo,e),this.w64(s+Le,0),this.w32(s+Lo,t),this.w32(s+zo,r?1:0),this.inodeAddOpenRef(e)?i:(Atomics.store(this.i32,o,0),Oe)}return ko}fdGet(e){if(e<0||e>=Mr)return null;let t=256+e*24;return Atomics.load(this.i32,t>>2)?{base:t,ino:this.r32(t+vo),offset:this.r64(t+Le),flags:this.r32(t+Lo),isDir:this.r32(t+zo)!==0}:null}fdFree(e){if(e>=0&&e>2,0)}}buildStat(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+ue),dataSequence:this.r32(t+de),mode:this.r32(t+F),linkCount:this.r32(t+K),size:this.r64(t+P),mtime:this.r64(t+fe),ctime:this.r64(t+ee),atime:this.r64(t+er),uid:this.r32(t+rr),gid:this.r32(t+nr)}}namespaceEntryIdentity(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+ue),linkCount:this.r32(t+K),mode:this.r32(t+F)}}open(e,t,r=420){return this.withNamespaceLock(()=>this.openUnlocked(e,t,r))}createLazyStub(e,t){return this.withNamespaceLock(()=>{let r=this.openUnlocked(e,Io|ir,t);try{let i=this.fdGet(r);if(!i)throw new I(re);this.inodeWriteLock(i.ino);try{return this.inodeTruncate(i.ino,0,!0),this.buildStat(i.ino)}finally{this.inodeWriteUnlock(i.ino)}}finally{this.closeUnlocked(r)}})}replaceIfIdentity(e,t,r,i,s){return this.withNamespaceLock(()=>{let o=this.pathResolve(e,!0);if(o<0||o!==t)return!1;let a=this.inodeOffset(o);if(this.r64(a+ue)!==r||this.r32(a+de)!==i||(this.r32(a+F)&G)!==jt)return!1;this.validateFileSize(s.byteLength),this.inodeWriteLock(o);try{if(this.r64(a+ue)!==r||this.r32(a+de)!==i||this.r64(a+P)!==0)return!1;let c=this.r64(a+fe),u=this.r64(a+ee);this.inodeTruncate(o,0,!0);let l=s.byteLength>0?this.inodeWriteData(o,0,s,s.byteLength):0;if(l!==s.byteLength)throw this.inodeTruncate(o,0,!0),Atomics.store(this.i32,a+de>>2,i),this.w64(a+fe,c),this.w64(a+ee,u),new I(l<0?l:oe);return!0}finally{this.inodeWriteUnlock(o)}})}replaceManyIfIdentities(e,t=[]){return e.length===0&&t.length===0?!0:this.withNamespaceLock(()=>{let r=[],i=new Set,s=a=>{let c=this.pathResolve(a.path,!1);if(c<0||c!==a.expectedIno)return!1;let u=this.inodeOffset(c);return this.r64(u+ue)===a.expectedGeneration&&this.r32(u+de)===a.expectedDataSequence&&this.r32(u+F)===a.expectedMode&&this.r32(u+K)===a.expectedLinkCount&&this.r64(u+P)===a.expectedSize&&this.r32(u+rr)===a.expectedUid&&this.r32(u+nr)===a.expectedGid};for(let a of t)if(!s(a))return!1;for(let a of e){this.validateFileSize(a.data.byteLength);let c=-1;for(let u of a.paths){let l=this.pathResolve(u,!0);if(l!==a.expectedIno)continue;let d=this.inodeOffset(l);if(this.r64(d+ue)===a.expectedGeneration&&this.r32(d+de)===a.expectedDataSequence&&(this.r32(d+F)&G)===jt&&this.r64(d+P)===0){c=l;break}}if(c<0)return!1;if(i.has(c))throw new I(j,"duplicate conditional replacement inode");i.add(c),r.push({...a,ino:c})}let o=[...i].sort((a,c)=>a-c);for(let a of o)this.inodeWriteLock(a);try{for(let u of r){let l=this.inodeOffset(u.ino);if(this.r64(l+ue)!==u.expectedGeneration||this.r32(l+de)!==u.expectedDataSequence||(this.r32(l+F)&G)!==jt||this.r64(l+P)!==0)return!1}for(let u of t)if(!s(u))return!1;let a=r.map(u=>{let l=this.inodeOffset(u.ino);return{ino:u.ino,dataSequence:this.r32(l+de),mtime:this.r64(l+fe),ctime:this.r64(l+ee)}}),c=0;try{for(let u of r){c++,this.inodeTruncate(u.ino,0,!0);let l=u.data.byteLength>0?this.inodeWriteData(u.ino,0,u.data,u.data.byteLength):0;if(l!==u.data.byteLength)throw new I(l<0?l:oe)}}catch(u){for(let l=c-1;l>=0;l--){let d=a[l],p=this.inodeOffset(d.ino);this.inodeTruncate(d.ino,0,!0),Atomics.store(this.i32,p+de>>2,d.dataSequence),this.w64(p+fe,d.mtime),this.w64(p+ee,d.ctime)}throw u}return!0}finally{for(let a=o.length-1;a>=0;a--)this.inodeWriteUnlock(o[a])}})}openUnlocked(e,t,r=420){let i=t&Jt,s=(t&ir)!==0,o=(t&Wn)!==0;if(s&&o){let d=this.pathResolve(e,!1);if(d>=0)throw new I(Ot);if(d!==Oe)throw new I(d)}let a=this.pathResolve(e,!0);if(a<0&&a===Oe&&s){let{parentIno:d,name:p}=this.pathResolveParent(e);this.inodeWriteLock(d);try{let m=me.encode(p),f=this.dirLookup(d,m);if(f>=0){if(o)throw new I(Ot);a=f}else{let h=this.inodeAlloc();if(h<0)throw new I(oe);let g=this.inodeOffset(h);this.w32(g+F,jt|r&4095),this.w32(g+K,1),this.w64(g+P,0);let _=Date.now();this.w64(g+er,_),this.w64(g+fe,_),this.w64(g+ee,_);let y=this.dirAddEntry(d,m,h);if(y<0)throw this.inodeFree(h),new I(y);a=h}}finally{this.inodeWriteUnlock(d)}}if(a<0)throw new I(a);let c=this.inodeOffset(a),u=this.r32(c+F);if((u&G)===H&&i!==rt)throw new I(it);if(t&fc&&(u&G)!==H)throw new I(ze);if(t&sr){if((u&G)===H)throw new I(it);this.inodeWriteLock(a),this.inodeTruncate(a,0,!0),this.inodeWriteUnlock(a)}let l=this.fdAlloc(a,t,!1);if(l<0)throw new I(l);return l}close(e){this.withNamespaceLock(()=>this.closeUnlocked(e))}closeUnlocked(e){let t=this.fdGet(e);if(!t)throw new I(re);this.fdFree(e),this.inodeDropOpenRef(t.ino)}read(e,t){let r=this.fdGet(e);if(!r)throw new I(re);let i=this.inodeOffset(r.ino);if((this.r32(i+F)&G)===H)throw new I(it);this.inodeReadLock(r.ino);try{let o=this.inodeReadData(r.ino,r.offset,t,t.length),a=256+e*24;return this.w64(a+Le,r.offset+o),o}finally{this.inodeReadUnlock(r.ino)}}readAt(e,t,r){let i=this.fdGet(e);if(!i)throw new I(re);let s=this.inodeOffset(i.ino);if((this.r32(s+F)&G)===H)throw new I(it);this.validateSeekPosition(r),this.inodeReadLock(i.ino);try{return this.inodeReadData(i.ino,r,t,t.length)}finally{this.inodeReadUnlock(i.ino)}}write(e,t){let r=this.fdGet(e);if(!r)throw new I(re);if((r.flags&Jt)===rt)throw new I(re);this.inodeWriteLock(r.ino);try{let s=r.offset;if(r.flags&lc){let c=this.inodeOffset(r.ino);s=this.r64(c+P)}if(!Number.isSafeInteger(s)||s<0)throw new I(j);if(s>Ve||t.length>Ve-s)throw new I(ot);let o=this.inodeWriteData(r.ino,s,t,t.length);if(o<0)return o;let a=256+e*24;return this.w64(a+Le,s+o),o}finally{this.inodeWriteUnlock(r.ino)}}append(e,t,r){let i=this.fdGet(e);if(!i)throw new I(re);if((i.flags&Jt)===rt)throw new I(re);if(r!==null&&(!Number.isSafeInteger(r)||r<0))throw new I(j);this.inodeWriteLock(i.ino);try{let o=this.inodeOffset(i.ino),a=this.r64(o+P);if(!Number.isSafeInteger(a)||a<0)throw new I(j);if(a>Ve)throw new I(ot);if(r!==null&&a>=r){let f=256+e*24;return this.w64(f+Le,a),{written:0,end:a}}let c=r===null?t.length:Math.min(t.length,r-a),u=Ve-a;if(c>u)throw new I(ot);let l=t.subarray(0,c),d=this.inodeWriteData(i.ino,a,l,l.length);if(d<0)throw new I(d);let p=256+e*24,m=a+d;return this.w64(p+Le,m),{written:d,end:m}}finally{this.inodeWriteUnlock(i.ino)}}writeAt(e,t,r){let i=this.fdGet(e);if(!i)throw new I(re);if((i.flags&Jt)===rt)throw new I(re);this.validateSeekPosition(r),this.inodeWriteLock(i.ino);try{if(r>Ve||t.length>Ve-r)throw new I(ot);return this.inodeWriteData(i.ino,r,t,t.length)}finally{this.inodeWriteUnlock(i.ino)}}lseek(e,t,r){let i=this.fdGet(e);if(!i)throw new I(re);let s;if(r===hc)s=t;else if(r===pc)s=i.offset+t;else if(r===mc){let a=this.inodeOffset(i.ino);s=this.r64(a+P)+t}else throw new I(j);this.validateSeekPosition(s);let o=256+e*24;return this.w64(o+Le,s),s}ftruncate(e,t){let r=this.fdGet(e);if(!r)throw new I(re);if((r.flags&Jt)===rt)throw new I(re);this.validateFileSize(t),this.inodeWriteLock(r.ino);try{this.inodeTruncate(r.ino,t,!0)}finally{this.inodeWriteUnlock(r.ino)}}fstat(e){let t=this.fdGet(e);if(!t)throw new I(re);this.inodeReadLock(t.ino);try{return this.buildStat(t.ino)}finally{this.inodeReadUnlock(t.ino)}}stat(e){return this.withNamespaceLock(()=>this.statUnlocked(e))}statUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new I(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}lstat(e){return this.withNamespaceLock(()=>this.lstatUnlocked(e))}lstatUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new I(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}unlink(e){return this.withNamespaceLock(()=>this.unlinkUnlocked(e))}unlinkUnlocked(e){let{parentIno:t,name:r}=this.pathResolveParent(e),i=me.encode(r),s=e.length>1&&e.endsWith("/");this.inodeWriteLock(t);try{let o=this.dirLookup(t,i);if(o<0)throw new I(o);let a=this.inodeOffset(o),c=this.r32(a+F);if(s&&(c&G)!==H)throw new I(ze);if((c&G)===H)throw new I(it);let u=this.namespaceEntryIdentity(o),l=this.dirRemoveEntry(t,i);if(l<0)throw new I(l);let d=!1;this.inodeWriteLock(o);try{d=this.inodeDropLinkRefLocked(o)}finally{this.inodeWriteUnlock(o)}return d&&this.inodeFree(o),u}finally{this.inodeWriteUnlock(t)}}rename(e,t){return this.withNamespaceLock(()=>this.renameUnlocked(e,t))}renameUnlocked(e,t){let{parentIno:r,name:i}=this.pathResolveParent(e),{parentIno:s,name:o}=this.pathResolveParent(t);if(Kn(i)||Kn(o))throw new I(j);let a=me.encode(i),c=me.encode(o),u=e.length>1&&e.endsWith("/"),l=t.length>1&&t.endsWith("/"),d=Math.min(r,s),p=Math.max(r,s);this.inodeWriteLock(d),d!==p&&this.inodeWriteLock(p);try{let m=this.dirLookup(r,a);if(m<0)throw new I(m);let f=this.inodeOffset(m),g=this.r32(f+F)&G,_=this.namespaceEntryIdentity(m);if((u||l)&&g!==H)throw new I(ze);if(g===H&&this.dirIsAncestor(m,s))throw new I(j);let y=this.dirLookup(s,c),E=!1,O;if(y>=0){if(y===m)return{source:_,replaced:_};O=this.namespaceEntryIdentity(y);let S=this.inodeOffset(y),R=this.r32(S+F)&G;if(g===H&&R!==H)throw new I(ze);if(g!==H&&R===H)throw new I(it);let x=!1,v=y===r||y===s;v||this.inodeWriteLock(y);try{if(R===H&&!this.dirIsEmpty(y))throw new I(Un);let L=this.dirReplaceEntryIno(s,c,m);if(L<0)throw new I(L);x=R===H?this.inodeOrphanLocked(y):this.inodeDropLinkRefLocked(y)}finally{v||this.inodeWriteUnlock(y)}x&&this.inodeFree(y),E=R===H}else{let S=this.dirAddEntry(s,c,m);if(S<0)throw new I(S)}let w=this.dirRemoveEntry(r,a);if(w<0)throw new I(w);if(g===H){if(r!==s){let S=this.inodeOffset(r);this.w32(S+K,this.r32(S+K)-1);let A=this.inodeOffset(s);this.w32(A+K,this.r32(A+K)+1),this.inodeWriteLock(m);try{let R=this.dirReplaceEntryIno(m,Po,s);if(R<0)throw new I(R);this.w64(f+ee,Date.now())}finally{this.inodeWriteUnlock(m)}}if(E){let S=this.inodeOffset(s);this.w32(S+K,this.r32(S+K)-1)}}else if(E){let S=this.inodeOffset(s);this.w32(S+K,this.r32(S+K)-1)}return{source:_,replaced:O}}finally{d!==p&&this.inodeWriteUnlock(p),this.inodeWriteUnlock(d)}}mkdir(e,t=493){this.withNamespaceLock(()=>this.mkdirUnlocked(e,t))}mkdirUnlocked(e,t=493){let{parentIno:r,name:i}=this.pathResolveParent(e),s=me.encode(i);this.inodeWriteLock(r);try{if(this.dirLookup(r,s)>=0)throw new I(Ot);let a=this.inodeAlloc();if(a<0)throw new I(oe);let c=this.inodeOffset(a);this.w32(c+F,H|t),this.w32(c+K,2),this.w64(c+P,0);let u=Date.now();this.w64(c+er,u),this.w64(c+fe,u),this.w64(c+ee,u);let l=this.blockAllocWithGrow();if(l<0)throw this.inodeFree(a),new I(oe);this.w32(c+ie,l);let d=l*4096,p=qe(k+1),m=qe(k+2);this.w32(d,a),this.view.setUint16(d+4,p,!0),this.view.setUint16(d+6,1,!0),this.u8[d+k]=46;let f=d+p;this.w32(f,r),this.view.setUint16(f+4,m,!0),this.view.setUint16(f+6,2,!0),this.u8[f+k]=46,this.u8[f+k+1]=46,this.w64(c+P,p+m);let h=this.dirAddEntry(r,s,a);if(h<0)throw this.blockFree(l),this.inodeFree(a),new I(h);let g=this.inodeOffset(r);this.w32(g+K,this.r32(g+K)+1)}finally{this.inodeWriteUnlock(r)}}rmdir(e){this.withNamespaceLock(()=>this.rmdirUnlocked(e))}rmdirUnlocked(e){let{parentIno:t,name:r}=this.pathResolveParent(e);if(Kn(r))throw new I(j);let i=me.encode(r);this.inodeWriteLock(t);try{let s=this.dirLookup(t,i);if(s<0)throw new I(s);let o=this.inodeOffset(s);if((this.r32(o+F)&G)!==H)throw new I(ze);let c=!1;this.inodeWriteLock(s);try{if(!this.dirIsEmpty(s))throw new I(Un);let l=this.dirRemoveEntry(t,i);if(l<0)throw new I(l);c=this.inodeOrphanLocked(s)}finally{this.inodeWriteUnlock(s)}c&&this.inodeFree(s);let u=this.inodeOffset(t);this.w32(u+K,this.r32(u+K)-1)}finally{this.inodeWriteUnlock(t)}}symlink(e,t){this.withNamespaceLock(()=>this.symlinkUnlocked(e,t))}symlinkUnlocked(e,t){let{parentIno:r,name:i}=this.pathResolveParent(t),s=me.encode(i),o=me.encode(e);this.inodeWriteLock(r);try{if(this.dirLookup(r,s)>=0)throw new I(Ot);let c=this.inodeAlloc();if(c<0)throw new I(oe);let u=this.inodeOffset(c);if(this.w32(u+F,_t|511),this.w32(u+K,1),o.length<=40)this.u8.set(o,u+ie),this.w64(u+P,o.length);else{this.w64(u+P,0);let d=this.inodeWriteData(c,0,o,o.length);if(d!==o.length)throw d>0&&this.inodeTruncate(c,0),this.inodeFree(c),new I(d<0?d:oe)}let l=this.dirAddEntry(r,s,c);if(l<0)throw o.length<=40?(this.u8.fill(0,u+ie,u+ie+40),this.w64(u+P,0)):this.inodeTruncate(c,0),this.inodeFree(c),new I(l)}finally{this.inodeWriteUnlock(r)}}chmod(e,t){this.withNamespaceLock(()=>this.chmodUnlocked(e,t))}chmodUnlocked(e,t){let r=this.pathResolve(e,!0);if(r<0)throw new I(r);this.inodeWriteLock(r);try{let i=this.inodeOffset(r),s=this.r32(i+F);this.w32(i+F,s&G|t&4095),this.w64(i+ee,Date.now())}finally{this.inodeWriteUnlock(r)}}fchmod(e,t){let r=this.fdGet(e);if(!r)throw new I(re);this.inodeWriteLock(r.ino);try{let i=this.inodeOffset(r.ino),s=this.r32(i+F);this.w32(i+F,s&G|t&4095),this.w64(i+ee,Date.now())}finally{this.inodeWriteUnlock(r.ino)}}chown(e,t,r){this.withNamespaceLock(()=>this.chownUnlocked(e,t,r))}chownUnlocked(e,t,r){let i=this.pathResolve(e,!0);if(i<0)throw new I(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,r)}finally{this.inodeWriteUnlock(i)}}fchown(e,t,r){let i=this.fdGet(e);if(!i)throw new I(re);this.inodeWriteLock(i.ino);try{this.chownInodeUnlocked(i.ino,t,r)}finally{this.inodeWriteUnlock(i.ino)}}lchown(e,t,r){this.withNamespaceLock(()=>this.lchownUnlocked(e,t,r))}lchownUnlocked(e,t,r){let i=this.pathResolve(e,!1);if(i<0)throw new I(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,r)}finally{this.inodeWriteUnlock(i)}}chownInodeUnlocked(e,t,r){let i=this.inodeOffset(e);t!==Ao&&this.w32(i+rr,t),r!==Ao&&this.w32(i+nr,r);let s=this.r32(i+F);(s&G)===jt&&(s&dc)!==0&&this.w32(i+F,s&~(cc|uc)),this.w64(i+ee,Date.now())}utimens(e,t,r,i,s){this.withNamespaceLock(()=>this.utimensUnlocked(e,t,r,i,s))}utimensUnlocked(e,t,r,i,s){let o=this.pathResolve(e,!0);if(o<0)throw new I(o);this.inodeWriteLock(o);try{let a=this.inodeOffset(o),c=1073741823,u=1073741822,l=Date.now();if(r!==u){let d=r===c?l:t*1e3+Math.floor(r/1e6);this.w64(a+er,d)}if(s!==u){let d=s===c?l:i*1e3+Math.floor(s/1e6);this.w64(a+fe,d)}this.w64(a+ee,l)}finally{this.inodeWriteUnlock(o)}}link(e,t){return this.withNamespaceLock(()=>this.linkUnlocked(e,t))}linkUnlocked(e,t){let r=this.pathResolve(e,!1);if(r<0)throw new I(r);let i=this.inodeOffset(r);if((this.r32(i+F)&G)===H)throw new I(_c);let{parentIno:o,name:a}=this.pathResolveParent(t),c=me.encode(a);this.inodeWriteLock(o);try{if(this.dirLookup(o,c)>=0)throw new I(Ot);let l=this.dirAddEntry(o,c,r);if(l<0)throw new I(l);this.inodeWriteLock(r);try{let d=this.r32(i+K);this.w32(i+K,d+1),this.w64(i+ee,Date.now())}finally{this.inodeWriteUnlock(r)}return{...this.namespaceEntryIdentity(r),linkCount:this.r32(i+K)}}finally{this.inodeWriteUnlock(o)}}readlink(e){return this.withNamespaceLock(()=>this.readlinkUnlocked(e))}readlinkUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new I(t);return this.readSymlinkInodeUnlocked(t)}readSymlinkInodeUnlocked(e){let t=this.inodeOffset(e);if((this.r32(t+F)&G)!==_t)throw new I(j);let i=this.r64(t+P);if(i<=40)return wt(this.u8.subarray(t+ie,t+ie+i));this.inodeReadLock(e);try{let s=new Uint8Array(i);return this.inodeReadData(e,0,s,i),or.decode(s)}finally{this.inodeReadUnlock(e)}}opendir(e){return this.withNamespaceLock(()=>this.opendirUnlocked(e))}opendirUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new I(t);let r=this.inodeOffset(t);if((this.r32(r+F)&G)!==H)throw new I(ze);let s=this.fdAlloc(t,rt,!0);if(s<0)throw new I(s);return s}readdirEntry(e){return this.withNamespaceLock(()=>this.readdirEntryUnlocked(e))}readdirEntryUnlocked(e){let t=this.fdGet(e);if(!t||!t.isDir)throw new I(re);let r=this.inodeOffset(t.ino),i=this.r64(r+P);for(;t.offset=this.r32(Ge))throw new I(B);let h=this.r32(nt)*4096;if((this.r32(h+(l>>5)*4)&1<<(l&31))===0)throw new I(B);let _=wt(this.u8.subarray(u+k,u+k+p)),y=this.buildStat(l);return this.w64(f+Le,m),t.offset=m,{name:_,stat:y}}return null}closedir(e){this.close(e)}readdir(e){let t=this.opendir(e),r=[];try{let i;for(;(i=this.readdirEntry(t))!==null;)i.name!=="."&&i.name!==".."&&r.push(i.name)}finally{this.closedir(t)}return r}writeFile(e,t){let r=typeof t=="string"?me.encode(t):t,i=this.open(e,Io|ir|sr);try{this.write(i,r)}finally{this.close(i)}}readFile(e){let t=this.open(e,rt);try{let r=this.fstat(t),i=new Uint8Array(r.size);return this.read(t,i),i}finally{this.close(t)}}readFileText(e){return or.decode(this.readFile(e))}};function Co(n,e){let t=new Map,r=new Map;for(let o of n){if(t.has(o.path))throw new Error(`${e} duplicates path ${o.path}`);if(t.set(o.path,o),o.type==="file"){if(!o.inodeGroup)throw new Error(`${e} file ${o.path} has no inode group`);if(r.has(o.inodeGroup))throw new Error(`${e} inode group ${o.inodeGroup} has multiple files`);r.set(o.inodeGroup,o)}}let i=new Set,s=new Map;for(let o of n){if(o.type!=="hardlink"||s.has(o.path))continue;let a=[],c=o,u;for(;c.type==="hardlink";){let d=s.get(c.path);if(d){u=d;break}if(i.has(c.path))throw new Error(`${e} hardlink cycle reaches ${c.path}`);if(i.add(c.path),a.push(c),!c.target)throw new Error(`${e} hardlink ${c.path} has no target`);let p=t.get(c.target);if(!p)throw new Error(`${e} hardlink ${c.path} target ${c.target} is missing`);if(p.type!=="file"&&p.type!=="hardlink"||!c.inodeGroup||p.inodeGroup!==c.inodeGroup||p.size!==c.size||p.mode!==c.mode)throw new Error(`${e} hardlink ${c.path} has an invalid target`);c=p}u??=c.type==="file"?c:void 0;let l=r.get(o.inodeGroup??"");if(!u||u!==l)throw new Error(`${e} hardlink ${o.path} does not resolve to its inode`);for(let d=a.length-1;d>=0;d-=1){let p=a[d];if(r.get(p.inodeGroup??"")!==u)throw new Error(`${e} hardlink ${p.path} does not resolve to its inode`);i.delete(p.path),s.set(p.path,u)}}return{canonicalByGroup:r,canonicalTargetByPath:s}}var he={maxArchiveBytes:268435456,maxExpandedBytes:268435456,maxPayloadBytes:268435456,maxEntries:1e5,maxPathBytes:4096,maxSymlinkTargetBytes:65536,maxStringBytes:8192,maxTransportsPerTree:8,maxActivationCapabilities:32,maxActivationRoots:64,maxActivationCapabilityBytes:255},xe={maxArchiveBytes:512*1024*1024,maxExpandedBytes:512*1024*1024,maxPayloadBytes:512*1024*1024,maxEntries:1e5,maxGroups:512};function Mo(n,e="Deferred tree collection"){for(let[t,r]of Object.entries(n))if(!Number.isSafeInteger(r)||r<0)throw new Error(`${e} ${t} usage is invalid`);if(n.groups>xe.maxGroups)throw new Error(`${e} exceeds the ${xe.maxGroups}-group cap`);if(n.archiveBytes>xe.maxArchiveBytes)throw new Error(`${e} exceeds the archive-byte cap`);if(n.expandedBytes>xe.maxExpandedBytes)throw new Error(`${e} exceeds the expansion cap`);if(n.payloadBytes>xe.maxPayloadBytes)throw new Error(`${e} exceeds the payload-byte cap`);if(n.entries>xe.maxEntries)throw new Error(`${e} exceeds the entry-count cap`)}var Do=Object.freeze({prefix:"/opt/kandelo/homebrew",cellar:"/opt/kandelo/homebrew/Cellar",repository:"/opt/kandelo/homebrew",stableEntrypoint:"/usr/bin/brew"});var Ko=1e5,Ac=4096;var At=Do.prefix,Uo=[["@@HOMEBREW_PREFIX@@",At],["@@HOMEBREW_CELLAR@@",`${At}/Cellar`],["@@HOMEBREW_REPOSITORY@@",At],["@@HOMEBREW_LIBRARY@@",`${At}/Library`],["@@HOMEBREW_PERL@@",`${At}/opt/perl/bin/perl`]],Gn="@@HOMEBREW_JAVA@@",Ic=/^openjdk(?:@\d+(?:\.\d+)*)?/,It=new TextEncoder,Rc=[...Uo.map(([n])=>n),Gn].map(n=>({placeholder:n,bytes:It.encode(n)}));function Wo(n){let e=xc(n),t=e.changed_files;if(t!=null&&!Array.isArray(t))throw new Error("INSTALL_RECEIPT.json changed_files must be an array or null when present");let r=Array.isArray(t)?t:[];if(r.length>Ko)throw new Error(`INSTALL_RECEIPT.json declares ${r.length} changed files, limit ${Ko}`);let i=[],s=new Set;for(let[o,a]of r.entries()){if(typeof a!="string")throw new Error(`INSTALL_RECEIPT.json changed_files[${o}] is not a string`);if(vc(a,"Homebrew changed file"),s.has(a))throw new Error(`INSTALL_RECEIPT.json repeats changed file ${a}`);s.add(a),i.push(a)}return{changedFiles:i,runtimeDependencies:e.runtime_dependencies}}function xc(n){let e;try{e=JSON.parse(new TextDecoder("utf-8",{fatal:!0}).decode(n))}catch(t){throw new Error("INSTALL_RECEIPT.json is not valid UTF-8 JSON: "+zc(t))}if(typeof e!="object"||e===null||Array.isArray(e))throw new Error("INSTALL_RECEIPT.json must contain an object");return e}function Go(n,e,t){let r=n;for(let[o,a]of Uo)r=$o(r,It.encode(o),It.encode(a));let i=It.encode(Gn);if(Bo(r,i)){let o=Tc(e.runtimeDependencies);if(o===void 0)throw new Error(`Homebrew changed file ${t} uses ${Gn} without exactly one OpenJDK runtime dependency`);r=$o(r,i,It.encode(o))}let s=Rc.find(({bytes:o})=>Bo(r,o));if(s!==void 0)throw new Error(`Homebrew changed file ${t} retains ${s.placeholder}`);return r}function Tc(n){if(!Array.isArray(n))return;let e=[];for(let r of n){if(typeof r!="object"||r===null||Array.isArray(r))continue;let i=r,s=typeof i.full_name=="string"?i.full_name.split("/").at(-1):typeof i.name=="string"?i.name.split("/").at(-1):void 0,o=s===void 0?null:Ic.exec(s);s!==void 0&&o?.[0]===s&&e.push(s)}let t=[...new Set(e)];return t.length===1?`${At}/opt/${t[0]}/libexec`:void 0}function vc(n,e){if(n.length===0||n.startsWith("/")||n.includes("\\")||n.includes("\0")||Lc(n)||It.encode(n).byteLength>Ac||n.split("/").some(t=>t===""||t==="."||t===".."))throw new Error(`${e} has an unsafe path segment: ${n}`)}function Lc(n){for(let e=0;e57343)){if(t<=56319&&e+1=56320&&n.charCodeAt(e+1)<=57343){e+=1;continue}return!0}}return!1}function Bo(n,e){if(e.byteLength===0||e.byteLength>n.byteLength)return!1;e:for(let t=0;t<=n.byteLength-e.byteLength;t+=1){for(let r=0;rrn||n.includes("\0")||n.includes("\\"))throw new Error(`Lazy archive mount prefix must be an absolute POSIX path: ${JSON.stringify(n)}`);let e=n.replace(/\/+$/,"");if(e==="")return"/";if(e.slice(1).split("/").some(r=>r===""||r==="."||r===".."))throw new Error(`Lazy archive mount prefix is not canonical: ${JSON.stringify(n)}`);return e}function zu(n,e,t,r){let i=_r(t),s=new Map,o=e.map(a=>{let c=a.fileName,u=`Lazy archive ${JSON.stringify(n)} member ${JSON.stringify(c)}`;if(c.length===0)throw new Error(`${u} has an empty path`);if(c.includes("\0"))throw new Error(`${u} contains a NUL byte`);if(c.includes("\\"))throw new Error(`${u} contains a backslash`);if(c.startsWith("/")||/^[A-Za-z]:\//.test(c))throw new Error(`${u} must be relative, not absolute`);if(a.isDirectory&&a.isSymlink)throw new Error(`${u} has conflicting directory and symlink types`);if(a.isDirectory!==c.endsWith("/"))throw new Error(`${u} has inconsistent directory metadata`);let l=a.isDirectory?c.slice(0,-1):c,d=l.split("/");if(l.length===0||d.some(p=>p===""||p==="."||p===".."))throw new Error(`${u} is not a canonical relative POSIX path`);if(s.has(l))throw new Error(`${u} collides with another member at ${JSON.stringify(l)}`);if(a.isSymlink&&!r?.has(c))throw new Error(`Lazy archive symlink target was not provided: ${c}`);return s.set(l,a),{entry:a,archivePath:l,vfsPath:i==="/"?`/${l}`:`${i}/${l}`}});for(let{archivePath:a}of o){let c=a.split("/");for(let u=1;uLt)throw new Error(`VFS image metadata exceeds ${Lt} bytes`);let e;try{e=JSON.parse(new TextDecoder().decode(n))}catch(t){let r=t instanceof Error?t.message:String(t);throw new Error(`Invalid VFS image metadata JSON: ${r}`)}return yi(e)}function ku(n){if(n===null)return new Uint8Array(0);let e=yi(n),t=new TextEncoder().encode(JSON.stringify(e));if(t.byteLength>Lt)throw new Error(`VFS image metadata exceeds ${Lt} bytes`);return t}function Fu(n){return n.byteLength>=lr.length&&n[0]===lr[0]&&n[1]===lr[1]&&n[2]===lr[2]&&n[3]===lr[3]?td(n):n}function Vr(n){let e=Fu(n);if(e.byteLengthXr)throw new Error(`VFS image lazy metadata exceeds ${Xr} bytes`);if(n.byteLengthYr)throw new Error(`VFS image lazy archive metadata exceeds ${Yr} bytes`);if(n.byteLength=0?r:void 0}function Mu(n){return n===408||n===429||n>=500&&n<=599}function Du(n,e=Date.now()){let t=n?.get("retry-after")?.trim();if(!t)return;let r;if(/^\d+$/.test(t))r=Number(t)*1e3;else{let i=Date.parse(t);if(!Number.isFinite(i))return;r=Math.max(0,i-e)}if(!(!Number.isSafeInteger(r)||r<0))return Math.min(r,bs)}function Ku(n){if(!(typeof n!="object"||n===null||!("cause"in n)))return n.cause}function Ps(n){if(!(typeof n!="object"||n===null||!("name"in n)))return typeof n.name=="string"?n.name:void 0}function ks(n){if(!(typeof n!="object"||n===null||!("code"in n)))return typeof n.code=="string"?n.code:void 0}function Fs(n,e){let t=new Set,r=n;for(let i=0;r!==void 0&&i<8;i+=1){if(t.has(r))return!1;if(t.add(r),e(r))return!0;r=Ku(r)}return!1}function Ns(n){return Fs(n,e=>Ps(e)==="AbortError"||ks(e)==="ABORT_ERR")}function Bu(n){return Ns(n)?!1:Fs(n,e=>{let t=Ps(e),r=ks(e);return e instanceof TypeError||t==="NetworkError"||t==="TimeoutError"||r!==void 0&&Lu.has(r)})}function $u(n,e){if(n instanceof Jr){if(!Mu(n.status))return null;if(n.retryAfterMs!==void 0)return n.retryAfterMs}else if(!Bu(n))return null;return Math.min(vu*2**e,bs)}function te(n){if(n?.aborted)throw n.reason}function Uu(n,e){return te(e),n===0?Promise.resolve():new Promise((t,r)=>{let i=setTimeout(()=>a(!1),n),s=()=>a(!0,e.reason),o=!1;function a(c,u){o||(o=!0,clearTimeout(i),e?.removeEventListener("abort",s),c?r(u):t())}e?.addEventListener("abort",s,{once:!0}),e?.aborted&&s()})}async function ai(n,e){try{await n.body?.cancel(e)}catch{}}function Wu(n,e){if(n.length===1)return n[0];let t=new Uint8Array(e),r=0;for(let i of n)t.set(i,r),r+=i.byteLength;return t}function yr(n){if(n===void 0)return;if(typeof n!="object"||n===null||Array.isArray(n))throw new Error("Lazy archive integrity must be an object");let e=n;if(Object.keys(e).length!==2||!("sha256"in e)||!("bytes"in e))throw new Error("Lazy archive integrity has unexpected fields");if(typeof e.sha256!="string"||!li.test(e.sha256))throw new Error("Lazy archive integrity has an invalid SHA-256 digest");if(!Number.isSafeInteger(e.bytes)||Number(e.bytes)<=0||Number(e.bytes)>ys)throw new Error(`Lazy archive integrity byte count must be between 1 and ${ys}`);return{sha256:e.sha256,bytes:Number(e.bytes)}}function Ze(n,e,t){if(typeof n!="object"||n===null||Array.isArray(n))throw new Error(`${t} must be an object`);let r=n;if(Object.keys(r).length!==e.length||e.some(s=>!Object.prototype.hasOwnProperty.call(r,s)))throw new Error(`${t} has unexpected or missing fields`);return r}function hi(n,e,t,r){if(typeof n!="object"||n===null||Array.isArray(n))throw new Error(`${r} must be an object`);let i=n,s=new Set(e);if(Object.keys(i).some(o=>!s.has(o))||t.some(o=>!Object.prototype.hasOwnProperty.call(i,o)))throw new Error(`${r} has unexpected or missing fields`);return i}function Ne(n,e,t,r){if(!Array.isArray(n)||n.lengthr)throw new Error(`${e} must contain ${t} to ${r} items`);return n}function ye(n,e,t){if(typeof n!="string"||n.length===0||n.includes("\0")||new TextEncoder().encode(n).byteLength>t)throw new Error(`${e} is invalid or exceeds ${t} bytes`);return n}function ae(n,e,t,r){if(!Number.isSafeInteger(n)||Number(n)r)throw new Error(`${e} must be an integer between ${t} and ${r}`);return Number(n)}function Qr(n,e=1){let t=n,r=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.source!==void 0,i=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.modePolicy!==void 0,s=Ze(n,["decoder","mediaType","sha256","bytes","expandedBytes","sourceEntryCount","transports",...i?["modePolicy"]:[],...r?["source"]:[]],"Lazy tree content"),o=s.decoder==="zip-v1"?"application/zip":s.decoder==="homebrew-bottle-tar-gzip-v1"?"application/vnd.oci.image.layer.v1.tar+gzip":null;if(o===null||s.mediaType!==o)throw new Error("Lazy tree decoder and media type are inconsistent");let a=yr({sha256:s.sha256,bytes:s.bytes});if(!a)throw new Error("Lazy tree integrity is required");let c=Ne(s.transports,"Lazy tree transports",e,he.maxTransportsPerTree).map((m,f)=>ye(m,`Lazy tree transport ${f}`,_i));if(new Set(c).size!==c.length)throw new Error("Lazy tree transports contain duplicates");let u=ae(s.expandedBytes,"Lazy tree expanded byte count",0,Au),l=ae(s.sourceEntryCount,"Lazy tree source entry count",1,zt),d=r?Vu(s.source,s.decoder):void 0,p=i?s.modePolicy:void 0;if(p!==void 0&&(p!=="portable-posix-v1"||s.decoder!=="zip-v1"||r))throw new Error("Lazy tree mode policy is invalid for its decoder");if(d!==void 0&&d.entries.length!==l)throw new Error("Lazy tree source inventory count differs from its content");return{decoder:s.decoder,mediaType:o,sha256:a.sha256,bytes:a.bytes,expandedBytes:u,sourceEntryCount:l,transports:c,...p===void 0?{}:{modePolicy:p},...d===void 0?{}:{source:d}}}function Cs(n){let e={groups:n.length,archiveBytes:0,expandedBytes:0,payloadBytes:0,entries:0};for(let t of n)t.content===void 0||t.inventory===void 0||(e.archiveBytes+=t.content.bytes,e.expandedBytes+=t.content.expandedBytes,e.payloadBytes+=t.inventory.filter(r=>r.type==="file").reduce((r,i)=>r+i.size,0),e.entries+=t.inventory.length+(t.content.source?.entries.length??0));return e}function pi(n){Mo(n,"Serialized lazy tree collection")}function Gu(n){pi(Cs(n))}function Hu(n){let e=new Map;for(let t of n){let r=t.activation?.atomicGroup;if(r===void 0)continue;if(!vt(r))throw new Error(`Serialized lazy atomic activation group ${r.id} is unsealed`);let i=e.get(r.id);if(i===void 0)i={expectedCount:r.expectedCount,cohortSha256:r.cohortSha256,members:new Set,descriptors:new Set},e.set(r.id,i);else if(i.expectedCount!==r.expectedCount||i.cohortSha256!==r.cohortSha256)throw new Error(`Serialized lazy atomic activation group ${r.id} has inconsistent seals`);if(i.members.has(r.member)||i.descriptors.has(r.descriptorSha256))throw new Error(`Serialized lazy atomic activation group ${r.id} duplicates a member`);i.members.add(r.member),i.descriptors.add(r.descriptorSha256)}for(let[t,r]of e)if(r.members.size!==r.expectedCount)throw new Error(`Serialized lazy atomic activation group ${t} has ${r.members.size} of ${r.expectedCount} members`)}function As(n){for(let[e,t]of n.entries())if(t.kind===mr||t.kind===fi||t.kind===at)Bs(t,t.kind);else if(t.kind===pr)mi(t,!1);else throw new Error(`Serialized lazy archive group ${e} has an unsupported kind`);Gu(n),Hu(n)}function Vu(n,e){if(e!=="homebrew-bottle-tar-gzip-v1")throw new Error("Lazy tree source inventory is valid only for original bottles");let t=Ze(n,["schema","kind","entries"],"Lazy tree source inventory");if(t.schema!==1||t.kind!=="homebrew-bottle-tar-gzip-v1")throw new Error("Lazy tree source inventory has an unsupported identity");let r=new Map,i=Ne(t.entries,"Lazy tree source entries",1,zt).map((o,a)=>{let c=o,u=typeof c=="object"&&c!==null&&!Array.isArray(c)?c.type:void 0,l=u==="directory"||u==="file"?["sourcePath","type","mode","size"]:u==="symlink"||u==="hardlink"?["sourcePath","type","mode","size","target"]:null;if(l===null)throw new Error(`Lazy tree source entry ${a} has invalid type`);let d=Ze(o,l,`Lazy tree source entry ${a}`),p=ge(d.sourcePath,!1,`Lazy tree source entry ${a} path`);if(r.has(p))throw new Error(`Lazy tree source inventory duplicates ${p}`);let m=ae(d.mode,`Lazy tree source entry ${p} mode`,0,W.S_MODE_BITS),f=ae(d.size,`Lazy tree source entry ${p} size`,0,jr),h;if((u==="directory"||u==="symlink"||u==="hardlink")&&f!==0)throw new Error(`Lazy tree source ${p} has payload for ${String(u)}`);u==="symlink"?h=ye(d.target,`Lazy tree source symlink ${p} target`,zs):u==="hardlink"&&(h=ge(d.target,!1,`Lazy tree source hardlink ${p} target`));let g={sourcePath:p,type:u,mode:m,size:f,...h===void 0?{}:{target:h}};return r.set(p,g),g}),s=i.map(o=>o.sourcePath);if(s.some((o,a)=>a>0&&s[a-1]>=o))throw new Error("Lazy tree source inventory is not in canonical path order");return{schema:1,kind:"homebrew-bottle-tar-gzip-v1",entries:i}}function Ms(n){let e=new Map(n.map(r=>[r.sourcePath,r])),t=new Map;for(let r of n){if(r.type!=="hardlink"||t.has(r.sourcePath))continue;let i=[],s=new Set,o=r,a;for(;o.type==="hardlink"&&(a=t.get(o.sourcePath),a===void 0);){if(s.has(o.sourcePath))throw new Error(`Lazy tree source hardlink cycle includes ${o.sourcePath}`);s.add(o.sourcePath),i.push(o);let c=e.get(o.target);if(c===void 0)throw new Error(`Lazy tree source hardlink ${o.sourcePath} target is absent`);if(c.type!=="file"&&c.type!=="hardlink")throw new Error(`Lazy tree source hardlink ${o.sourcePath} target is not regular`);o=c}a===void 0&&(a=o);for(let c of i)t.set(c.sourcePath,a)}return t}function ge(n,e,t,r=!1){if(typeof n!="string"||n.length===0||new TextEncoder().encode(n).byteLength>rn||n.includes("\0")||n.includes("\\")||n.startsWith("/")!==e)throw new Error(`${t} is not a canonical ${e?"absolute":"relative"} path`);if(r&&e&&n==="/")return n;if(n.slice(e?1:0).split("/").some(s=>s===""||s==="."||s===".."))throw new Error(`${t} has an unsafe path segment`);return n}function Ds(n){let e=typeof n=="object"&&n!==null&&!Array.isArray(n)?n:null,t=e!==null&&(Object.hasOwn(e,"descriptorSha256")||Object.hasOwn(e,"expectedCount")||Object.hasOwn(e,"cohortSha256")),r=Ze(n,t?["id","member","descriptorSha256","expectedCount","cohortSha256"]:["id","member"],"Lazy tree atomic activation membership"),i=ye(r.id,"Lazy tree atomic activation group",gs),s=ye(r.member,"Lazy tree atomic activation member",gs);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(i)||!/^[a-z0-9][a-z0-9:+._/-]*$/.test(s)||s.includes("//")||s.endsWith("/"))throw new Error("Lazy tree atomic activation membership is invalid");if(!t)return{id:i,member:s};let o=ye(r.descriptorSha256,"Lazy tree atomic member descriptor digest",64),a=ye(r.cohortSha256,"Lazy tree atomic cohort digest",64);if(!li.test(o)||!li.test(a))throw new Error("Lazy tree atomic activation digest is invalid");return{id:i,member:s,descriptorSha256:o,expectedCount:ae(r.expectedCount,"Lazy tree atomic activation expected member count",1,Ls),cohortSha256:a}}function vt(n){return n.descriptorSha256!==void 0&&n.expectedCount!==void 0&&n.cohortSha256!==void 0}function qu(n){let e=Ze(n,["uid","gid"],"Lazy tree registration owner");return{uid:ae(e.uid,"Lazy tree registration owner uid",0,Es),gid:ae(e.gid,"Lazy tree registration owner gid",0,Es)}}function Ks(n,e,t,r,i=1){let s=Qr(n,i),o=_r(t),a=["mode","capabilities","roots",...typeof r=="object"&&r!==null&&!Array.isArray(r)&&Object.hasOwn(r,"atomicGroup")?["atomicGroup"]:[]],c=Ze(r,a,"Lazy tree activation");if(c.mode!=="boot-prefetch"&&c.mode!=="first-use")throw new Error("Lazy tree activation mode is invalid");let u=Ne(c.capabilities,"Lazy tree activation capabilities",1,xu).map((S,A)=>{let R=ye(S,`Lazy tree activation capability ${A}`,he.maxActivationCapabilityBytes);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(R))throw new Error(`Lazy tree activation capability ${A} is invalid`);return R}),l=Ne(c.roots,"Lazy tree activation roots",1,Tu).map((S,A)=>ge(S,!0,`Lazy tree activation root ${A}`,!0));if(new Set(u).size!==u.length||new Set(l).size!==l.length)throw new Error("Lazy tree activation contains duplicates");let d=c.atomicGroup===void 0?void 0:Ds(c.atomicGroup);if(d!==void 0&&c.mode!=="first-use")throw new Error("Lazy tree atomic activation group requires a valid first-use identity");let p={mode:c.mode,capabilities:u,roots:l,...d===void 0?{}:{atomicGroup:d}},m=Ne(e,"Lazy tree inventory",1,zt),f=[],h=new Map,g=new Map,_=s.source===void 0?void 0:new Map(s.source.entries.map(S=>[S.sourcePath,S])),y=s.source===void 0?void 0:Ms(s.source.entries),E=0;for(let[S,A]of m.entries()){if(typeof A!="object"||A===null||Array.isArray(A))throw new Error(`Lazy tree entry ${S} must be an object`);let R=A.type,x=R==="directory"?["vfsPath","sourcePath","type","mode","size"]:R==="file"?["vfsPath","sourcePath","type","mode","size","inodeGroup"]:R==="symlink"?["vfsPath","sourcePath","type","mode","size","target"]:R==="hardlink"?["vfsPath","sourcePath","type","mode","size","target","inodeGroup"]:null;if(!x)throw new Error(`Lazy tree entry ${S} has an invalid type`);let v=Ze(A,[...x,..._===void 0?[]:["materialization"]],`Lazy tree entry ${S}`),L=ge(v.vfsPath,!0,`Lazy tree entry ${S} VFS path`),D=ge(v.sourcePath,!1,`Lazy tree entry ${S} source path`),Z=_===void 0?void 0:v.materialization;if(_!==void 0&&Z!=="archive"&&Z!=="archive-homebrew-relocate"&&Z!=="archive-copy"&&Z!=="archive-copy-mode"&&Z!=="descriptor")throw new Error(`Lazy tree entry ${L} has invalid materialization provenance`);if(o!=="/"&&L!==o&&!L.startsWith(`${o}/`))throw new Error(`Lazy tree entry ${L} escapes its mount prefix`);if(h.has(L))throw new Error(`Lazy tree duplicates VFS path ${L}`);let N=ae(v.mode,`Lazy tree entry ${L} mode`,0,W.S_MODE_BITS),b=ae(v.size,`Lazy tree entry ${L} size`,0,jr),U,le;if(R==="directory"){if(b!==0)throw new Error(`Lazy tree directory ${L} has nonzero size`)}else if(R==="symlink"){if(U=ye(v.target,`Lazy tree symlink ${L} target`,zs),new TextEncoder().encode(U).byteLength!==b)throw new Error(`Lazy tree symlink ${L} size differs from its target`)}else le=ye(v.inodeGroup,`Lazy tree entry ${L} inode group`,rn),R==="hardlink"&&(U=ge(v.target,!0,`Lazy tree hardlink ${L} target`));if(R!=="hardlink"&&(E+=b,E>jr))throw new Error("Lazy tree inventory exceeds the expansion limit");let C={vfsPath:L,sourcePath:D,...Z===void 0?{}:{materialization:Z},type:R,mode:N,size:b,...U===void 0?{}:{target:U},...le===void 0?{}:{inodeGroup:le}};if(_===void 0){let V=g.get(D);if(V){if(s.decoder!=="zip-v1"||C.type!=="hardlink"||V.inodeGroup!==C.inodeGroup)throw new Error(`Lazy tree duplicates source path ${D}`)}else{if(s.decoder==="zip-v1"&&C.type==="hardlink")throw new Error(`Lazy ZIP hardlink ${L} does not reuse a canonical source path`);g.set(D,C)}}else if(C.materialization==="descriptor"){if(C.type!=="directory"&&C.type!=="symlink")throw new Error(`Lazy tree descriptor entry ${L} is not structural`);if(_.has(D))throw new Error(`Lazy tree descriptor entry ${L} impersonates a source member`)}else{let V=_.get(D);if(V===void 0)throw new Error(`Lazy tree entry ${L} names absent source ${D}`);if(C.materialization==="archive-copy"||C.materialization==="archive-copy-mode"){if(C.type!=="file"||V.type!=="file"||C.materialization==="archive-copy"&&C.mode!==V.mode)throw new Error(`Lazy tree archive copy ${L} differs from its source`)}else if(C.materialization==="archive-homebrew-relocate"){if(C.type!=="file"&&C.type!=="hardlink"||V.type!==C.type||C.type==="file"&&V.mode!==C.mode)throw new Error(`Lazy tree receipt-relocated entry ${L} differs from its source`)}else if(V.type!==C.type||C.type==="symlink"&&V.target!==C.target||C.type!=="hardlink"&&V.mode!==C.mode)throw new Error(`Lazy tree archive entry ${L} differs from its source`)}f.push(C),h.set(L,C)}for(let S of f){let A=S.vfsPath.split("/").filter(Boolean);for(let R=1;R({path:S.vfsPath,type:S.type,mode:S.mode,size:S.size,target:S.target,inodeGroup:S.inodeGroup})),"Lazy tree");if(_!==void 0){let S=new Set;for(let A of f){if(A.materialization!=="archive-homebrew-relocate")continue;let R=_.get(A.sourcePath),x=R.type==="file"?R:y.get(R.sourcePath);if(x?.type!=="file")throw new Error(`Lazy tree receipt-relocated entry ${A.vfsPath} is not regular`);S.add(x.sourcePath)}for(let A of f){if(A.materialization==="descriptor"||A.type!=="file"&&A.type!=="hardlink")continue;let R=_.get(A.sourcePath),x=R.type==="file"?R:y.get(R.sourcePath);if(x?.type!=="file"||!S.has(x.sourcePath)&&A.size!==x.size)throw new Error(`Lazy tree archive entry ${A.vfsPath} differs from its source`)}for(let A of f){if(A.type!=="hardlink"||A.materialization!=="archive"&&A.materialization!=="archive-homebrew-relocate")continue;let R=_.get(A.sourcePath),x=h.get(A.target),v=y.get(R.sourcePath);if(R.target!==x?.sourcePath||v?.type!=="file"||v.mode!==A.mode||x?.mode!==A.mode)throw new Error(`Lazy tree hardlink ${A.vfsPath} differs from its source`)}}if(s.sourceEntryCount!==(_===void 0?g.size:_.size))throw new Error("Lazy tree source entry count differs from its inventory");if(s.source===void 0&&s.expandedBytesA.vfsPath===S||A.vfsPath.startsWith(`${S}/`)))throw new Error(`Lazy tree activation root ${S} is not owned by its inventory`);let w=new Map;for(let S of f)S.type==="file"&&w.set(S.inodeGroup,S);if(w.size!==O.canonicalByGroup.size)throw new Error("Lazy tree regular inode inventory is inconsistent");return{content:s,entries:f,mountPrefix:o,activation:p,canonicalByGroup:w}}function en(n){return JSON.stringify([n.sourcePath,n.type,n.inodeGroup,n.target])}function mi(n,e){let t=hi(n,["kind","content","url","mountPrefix","integrity","materialized","entries"],["url","mountPrefix","materialized","entries"],"Serialized legacy lazy archive");if(t.kind===void 0){if(!e)throw new Error("Serialized lazy archive is missing its kind discriminator")}else if(t.kind!==pr)throw new Error("Serialized legacy lazy archive has an unsupported kind");let r=ye(t.url,"Serialized legacy lazy archive URL",_i),i=_r(t.mountPrefix),s=yr(t.integrity);if(t.content!==void 0){if(!e||t.kind!==void 0)throw new Error("Typed legacy lazy archives cannot carry generic content");let c=Qr(t.content);if(c.decoder!=="zip-v1"||c.transports.length!==1||c.transports[0]!==r||!s||c.sha256!==s.sha256||c.bytes!==s.bytes)throw new Error("Untagged legacy ZIP content identity is inconsistent")}if(t.materialized!==!1)throw new Error("Serialized legacy lazy archive must describe pending content");let o=new Set,a=Ne(t.entries,"Serialized legacy lazy archive entries",1,zt).map((c,u)=>{let l=hi(c,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","size","isSymlink","deleted"],`Serialized legacy lazy archive entry ${u}`),d=ge(l.vfsPath,!0,`Serialized legacy lazy archive entry ${u} VFS path`);if(o.has(d))throw new Error(`Serialized legacy lazy archive duplicates path ${d}`);o.add(d);let p=ae(l.ino,`Serialized legacy lazy archive entry ${d} inode`,1,Number.MAX_SAFE_INTEGER),m=l.generation===void 0?void 0:ae(l.generation,`Serialized legacy lazy archive entry ${d} generation`,0,Number.MAX_SAFE_INTEGER),f=l.dataSequence===void 0?void 0:ae(l.dataSequence,`Serialized legacy lazy archive entry ${d} data sequence`,0,Number.MAX_SAFE_INTEGER),h=ae(l.size,`Serialized legacy lazy archive entry ${d} size`,0,jr);if(l.isSymlink!==!1||l.deleted!==!1||l.materialized!==void 0&&l.materialized!==!1)throw new Error(`Serialized legacy lazy archive entry ${d} is not pending`);if(l.type!==void 0&&l.type!=="file")throw new Error(`Serialized legacy lazy archive entry ${d} has an invalid type`);let g=l.archivePath===void 0?void 0:ge(l.archivePath,!1,`Serialized legacy lazy archive entry ${d} archive path`),_=l.sourcePath===void 0?void 0:ge(l.sourcePath,!1,`Serialized legacy lazy archive entry ${d} source path`),y=l.inodeGroup===void 0?void 0:ye(l.inodeGroup,`Serialized legacy lazy archive entry ${d} inode group`,rn);if(l.target!==void 0)throw new Error(`Serialized legacy lazy archive entry ${d} has a link target`);return{vfsPath:d,ino:p,...m===void 0?{}:{generation:m},...f===void 0?{}:{dataSequence:f},size:h,isSymlink:!1,deleted:!1,materialized:!1,...g===void 0?{}:{archivePath:g},..._===void 0?{}:{sourcePath:_},type:"file",...y===void 0?{}:{inodeGroup:y}}});return{kind:pr,url:r,mountPrefix:i,...s===void 0?{}:{integrity:s},materialized:!1,entries:a}}function Bs(n,e){let t=Ze(n,["kind","content","inventory","activation","url","mountPrefix","integrity","materialized","entries"],"Serialized lazy tree");if(t.kind!==e)throw new Error("Serialized lazy tree has an unsupported kind");let r=Ks(t.content,t.inventory,t.mountPrefix,t.activation);if(e!==at&&e===mr!=(r.content.source===void 0))throw new Error(e===mr?"Serialized deferred-tree-v1 cannot contain original-bottle source metadata":"Serialized deferred-tree-v2 requires original-bottle source metadata");let i=r.activation.atomicGroup;if(e===at?i===void 0||!vt(i):i!==void 0)throw new Error(e===at?"Serialized deferred-tree-v3 requires a sealed atomic activation":"Atomic activation requires serialized deferred-tree-v3");let s=ye(t.url,"Serialized lazy tree URL",_i);if(s!==r.content.transports[0])throw new Error("Serialized lazy tree URL differs from its primary transport");let o=yr(t.integrity);if(!o||o.sha256!==r.content.sha256||o.bytes!==r.content.bytes)throw new Error("Serialized lazy tree integrity differs from its content");if(t.materialized!==!1)throw new Error("Serialized lazy tree must describe pending content");let a=new Map(r.entries.map(p=>[p.vfsPath,p])),c=new Map(r.entries.map(p=>[en(p),p])),u=Ne(t.entries,"Serialized lazy tree entries",0,zt),l=new Set,d=u.map((p,m)=>{let f=hi(p,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup"],`Serialized lazy tree entry ${m}`),h=ge(f.vfsPath,!0,`Serialized lazy tree entry ${m} VFS path`);if(l.has(h))throw new Error(`Serialized lazy tree duplicates pending path ${h}`);l.add(h);let g=ge(f.sourcePath,!1,`Serialized lazy tree entry ${m} source path`),_=ge(f.archivePath,!1,`Serialized lazy tree entry ${m} archive path`),y=a.get(h),E=c.get(en({sourcePath:g,type:typeof f.type=="string"?f.type:void 0,inodeGroup:typeof f.inodeGroup=="string"?f.inodeGroup:void 0,target:typeof f.target=="string"?f.target:void 0}))??y;if(!E||E.type!=="file"&&E.type!=="hardlink"||y?.inodeGroup!==void 0&&y.inodeGroup!==E.inodeGroup)throw new Error(`Serialized lazy tree entry ${h} is absent from its inventory`);let O=r.canonicalByGroup.get(E.inodeGroup);if(f.type!==E.type||f.inodeGroup!==E.inodeGroup||f.size!==E.size||_!==O?.sourcePath||f.target!==E.target||f.isSymlink!==!1||f.deleted!==!1||f.materialized!==!1)throw new Error(`Serialized lazy tree entry ${h} disagrees with its inventory`);let w=ae(f.ino,`Serialized lazy tree entry ${h} inode`,1,Number.MAX_SAFE_INTEGER),S=ae(f.generation,`Serialized lazy tree entry ${h} generation`,0,Number.MAX_SAFE_INTEGER),A=ae(f.dataSequence,`Serialized lazy tree entry ${h} data sequence`,0,Number.MAX_SAFE_INTEGER);return{vfsPath:h,ino:w,generation:S,dataSequence:A,size:E.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:_,sourcePath:g,type:E.type,inodeGroup:E.inodeGroup,...E.target===void 0?{}:{target:E.target}}});for(let p of r.entries)if(r.activation.atomicGroup!==void 0&&(p.type==="file"||p.type==="hardlink")&&!l.has(p.vfsPath))throw new Error(`Serialized lazy tree omits pending path ${p.vfsPath}`);return{kind:e,content:r.content,inventory:r.entries,activation:r.activation,url:s,mountPrefix:r.mountPrefix,integrity:o,materialized:!1,entries:d}}async function hr(n,e){let t=globalThis.crypto?.subtle;if(!t)throw new Error(`${e} SHA-256 verification is unavailable`);let r=new Uint8Array(n.byteLength);r.set(n);let i=new Uint8Array(await t.digest("SHA-256",r));return Array.from(i,s=>s.toString(16).padStart(2,"0")).join("")}async function ci(n,e,t){if(t===void 0)return;if(n.byteLength!==t.bytes)throw new Error(`Lazy ${e} byte count ${n.byteLength} does not match expected ${t.bytes}`);let r=await hr(n,`Lazy ${e}`);if(r!==t.sha256)throw new Error(`Lazy ${e} SHA-256 ${r} does not match expected ${t.sha256}`)}function Zu(n,e,t,r){let i=r.atomicGroup;if(i===void 0)throw new Error("Lazy atomic member is missing its typed tree descriptor");let s={schema:1,content:{decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...n.source===void 0?{}:{source:n.source}},mountPrefix:t,inventory:[...e].sort((o,a)=>o.vfsPatha.vfsPath?1:0),activation:{mode:r.mode,capabilities:r.capabilities,roots:r.roots,atomicGroup:{id:i.id,member:i.member}}};return new TextEncoder().encode(JSON.stringify(s))}function Is(n,e){let t=e.map(({member:r,descriptorSha256:i})=>({member:r,descriptorSha256:i}));return new TextEncoder().encode(JSON.stringify({schema:1,id:n,members:t.sort((r,i)=>r.memberi.member?1:0)}))}function Xu(n,e){if(n.byteLength!==e.byteLength)return!1;for(let t=0;tObject.freeze({...i}))};return r!==void 0&&(Object.freeze(r.entries),Object.freeze(r)),Object.freeze({decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,transports:t,...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...r===void 0?{}:{source:r}})}function Rs(n){return{decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,transports:[...n.transports],...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...n.source===void 0?{}:{source:{schema:1,kind:"homebrew-bottle-tar-gzip-v1",entries:n.source.entries.map(e=>({...e}))}}}}function Yu(n){let e=n.map(t=>Object.freeze({...t}));return Object.freeze(e),e}function ju(n,e,t){let r=[...n.capabilities],i=[...n.roots];return Object.freeze(r),Object.freeze(i),Object.freeze({mode:n.mode,capabilities:r,roots:i,atomicGroup:Object.freeze({id:e,member:t})})}function Ju(n){return{mode:n.activation.mode,capabilities:[...n.activation.capabilities],roots:[...n.activation.roots],atomicGroup:{id:n.id,member:n.member,descriptorSha256:n.descriptorSha256,expectedCount:n.expectedCount,cohortSha256:n.cohortSha256}}}function Qu(n,e){return n.ino===e.ino&&n.generation===e.generation&&n.dataSequence===e.dataSequence&&n.size===e.size&&n.isSymlink===e.isSymlink&&n.deleted===e.deleted&&n.materialized===e.materialized&&n.archivePath===e.archivePath&&n.sourcePath===e.sourcePath&&n.type===e.type&&n.inodeGroup===e.inodeGroup&&n.target===e.target}function qr(n,e,t){let r=n.content,i=n.inventory,s=n.activation,o=n.integrity,a=n.entries,c=n.url,u=n.mountPrefix,l=n.materialized,d=s?.atomicGroup;if(r===void 0||i===void 0||s===void 0||d===void 0||s.mode!=="first-use"||d.id!==e||d.member!==t||l)throw new Error(`Lazy atomic activation member ${t} changed before snapshot`);if(o?.sha256!==r.sha256||o?.bytes!==r.bytes||c!==(r.transports[0]??""))throw new Error(`Lazy atomic activation member ${t} has inconsistent integrity`);let p=$s(r),m=Yu(i),f=ju(s,e,t),h=new Map;for(let O of m)O.type==="file"&&h.set(O.inodeGroup,O.sourcePath);let g=m.filter(O=>O.type!=="directory");if(a.size!==g.length)throw new Error(`Lazy atomic activation member ${t} has inconsistent runtime entries`);let _=g.map(O=>{let w=a.get(O.vfsPath),S=O.type==="symlink",A=S?O.sourcePath:h.get(O.inodeGroup),R=w!==void 0&&(w.sourcePath===O.sourcePath&&w.type===O.type&&w.target===O.target||O.type==="hardlink"&&w.sourcePath===A&&w.type==="file"&&w.target===void 0),x=w===void 0?["missing"]:[A===void 0?"archivePath source":void 0,w.generation===void 0?"generation":void 0,w.dataSequence===void 0?"dataSequence":void 0,w.size!==O.size?"size":void 0,w.isSymlink!==S?"symlink kind":void 0,w.deleted?"deletion state":void 0,w.materialized!==S?"materialization state":void 0,w.archivePath!==A?"archivePath":void 0,R?void 0:"descriptor mapping",w.inodeGroup!==O.inodeGroup?"inode group":void 0].filter(L=>L!==void 0);if(x.length>0)throw new Error(`Lazy atomic activation member ${t} has inconsistent mapping at ${O.vfsPath}: ${x.join(", ")}`);let v=w;return Object.freeze({vfsPath:O.vfsPath,ino:v.ino,generation:v.generation,dataSequence:v.dataSequence,size:v.size,isSymlink:v.isSymlink,deleted:!1,materialized:v.materialized,archivePath:A,sourcePath:O.sourcePath,type:O.type,...O.inodeGroup===void 0?{}:{inodeGroup:O.inodeGroup},...O.target===void 0?{}:{target:O.target}})});Object.freeze(_);let y=Object.freeze({sha256:p.sha256,bytes:p.bytes}),E=Zu(p,m,u,f);return Object.freeze({id:e,member:t,descriptorBytes:E,content:p,inventory:m,activation:f,url:p.transports[0]??"",mountPrefix:u,integrity:y,entries:_})}function xs(n,e,t,r){return Object.freeze({...n,descriptorSha256:e,expectedCount:t,cohortSha256:r})}function Ts(n,e){return n.id!==e.id||n.member!==e.member||n.url!==e.url||n.mountPrefix!==e.mountPrefix||n.integrity.sha256!==e.integrity.sha256||n.integrity.bytes!==e.integrity.bytes||n.content.transports.length!==e.content.transports.length||n.content.transports.some((t,r)=>t!==e.content.transports[r])||!Xu(n.descriptorBytes,e.descriptorBytes)||n.entries.length!==e.entries.length?!1:n.entries.every((t,r)=>{let i=e.entries[r];return i!==void 0&&t.vfsPath===i.vfsPath&&Qu(t,i)})}function ed(n,e){let t=$s(n.content,n.content.transports.map(e));return Object.freeze({...n,content:t,url:t.transports[0]??""})}function vs(n){return{paths:Array.from(n.paths),expectedIno:n.ino,expectedGeneration:n.generation,expectedDataSequence:n.dataSequence,data:n.content}}var tn=class n{fs;imageMetadata;lazyFiles=new Map;lazyArchiveGroups=[];deferredTreeMaterializationHandles=new WeakMap;lazyArchiveInodes=new Map;lazyAtomicGroups=new Map;lazyAtomicGroupByTree=new WeakMap;sealedLazyAtomicStates=new WeakMap;lazyDownloadListeners=new Set;lazyPreparations=new Map;lazyTransport={fetcher:(e,t)=>t===void 0?globalThis.fetch(e):globalThis.fetch(e,t)};constructor(e,t=null){this.fs=e,this.imageMetadata=t}static inodeKey(e,t){return`${e}:${t}`}static canAdoptLegacyLazyStub(e){return(e.mode&Fe)===fr&&e.size===0&&e.dataSequence<=1}reconcileLazyIdentityState(e){for(let[t,r]of this.lazyFiles){let i=e.get(t);if(!i||i.dataSequence!==r.dataSequence||i.paths.length===0){this.lazyFiles.delete(t);continue}r.paths=new Set(i.paths),r.paths.has(r.path)||(r.path=i.paths[0])}this.lazyArchiveInodes.clear();for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(!r?.committed)for(let c of i.snapshot.entries){if(c.isSymlink||c.materialized||c.generation===void 0)continue;let u=n.inodeKey(c.ino,c.generation),l=e.get(u);l!==void 0&&l.dataSequence===c.dataSequence&&l.paths.length>0&&this.lazyArchiveInodes.set(u,t)}continue}let s=t.content!==void 0&&t.inventory!==void 0&&!t.materialized,o=new Map;for(let c of t.entries.values()){if(c.deleted||c.materialized||c.generation===void 0)continue;let u=n.inodeKey(c.ino,c.generation);o.has(u)||o.set(u,c)}let a=new Map(Array.from(t.entries.entries()).filter(([,c])=>c.deleted||c.isSymlink&&!c.deleted));for(let[c,u]of o){let l=e.get(c);if(!(!l||l.dataSequence!==(u.dataSequence??0))){for(let d of l.paths)a.set(d,{...u,ino:l.ino,generation:l.generation,dataSequence:l.dataSequence,deleted:!1,materialized:!1});l.paths.length>0&&this.lazyArchiveInodes.set(c,t)}}t.entries=a,t.materialized=!Array.from(a.values()).some(c=>!c.isSymlink&&!c.materialized)&&!s&&(r===void 0||r.committed)}}validatePendingLazyTreeNamespaceState(e){let t=new Map;for(let r of e.values())for(let i of r.paths){if(t.has(i))throw new Error(`SharedFS namespace identity is ambiguous at ${i}`);t.set(i,r)}for(let r of this.lazyArchiveGroups){let i=this.lazyAtomicGroupByTree.get(r),o=this.sealedLazyAtomicStates.get(r)?.snapshot;if(i?.committed||o===void 0&&r.materialized||o===void 0&&(r.content===void 0||r.inventory===void 0))continue;let a=o?.inventory??r.inventory,c=o===void 0?r.entries:new Map(o.entries.map(m=>[m.vfsPath,m])),u=new Map,l=new Map,d=new Set;for(let m of c.values())m.deleted&&m.inodeGroup!==void 0&&d.add(m.inodeGroup);for(let m of a){if(m.type!=="file"&&m.type!=="hardlink")continue;u.set(m.inodeGroup,(u.get(m.inodeGroup)??0)+1);let f=l.get(m.inodeGroup)??[];f.push(m.vfsPath),l.set(m.inodeGroup,f)}let p=new Set([...d].filter(m=>l.get(m)?.every(f=>!t.has(f))));for(let m of a){let f=t.get(m.vfsPath);if(f===void 0){if(m.inodeGroup!==void 0&&p.has(m.inodeGroup))continue;throw new Error(`Lazy tree namespace entry ${m.vfsPath} is missing from the captured filesystem state`)}let h=m.type==="directory"?st:m.type==="symlink"?Hr:fr;if((f.mode&Fe)!==h||(f.mode&W.S_MODE_BITS)!==m.mode)throw new Error(`Lazy tree namespace entry ${m.vfsPath} disagrees with its captured type or mode`);if(m.type==="directory")continue;let g=c.get(m.vfsPath);if(g===void 0||g.ino!==f.ino||g.generation!==f.generation||g.dataSequence!==f.dataSequence)throw new Error(`Lazy tree namespace entry ${m.vfsPath} changed identity before serialization`);if(m.type==="symlink"){let _=new TextEncoder().encode(m.target).byteLength;if(f.linkCount!==1||f.size!==m.size||f.size!==_||f.symlinkTarget!==m.target)throw new Error(`Lazy tree symlink ${m.vfsPath} disagrees with its captured inventory`);continue}if(f.size!==0||f.linkCount!==u.get(m.inodeGroup))throw new Error(`Lazy tree stub ${m.vfsPath} has changed data or undeclared aliases`)}}}lazyFileForStat(e){let t=n.inodeKey(e.ino,e.generation),r=this.lazyFiles.get(t);if(r&&r.dataSequence!==e.dataSequence){this.lazyFiles.delete(t);return}return r}lazyArchiveEntriesForRead(e){let t=this.lazyAtomicGroupByTree.get(e),r=this.sealedLazyAtomicStates.get(e);return r!==void 0&&!t?.committed?r.snapshot.entries:Array.from(e.entries,([i,s])=>({vfsPath:i,...s}))}lazyArchiveForStat(e){let t=n.inodeKey(e.ino,e.generation),r=this.lazyArchiveInodes.get(t);if(!r)return;let i=this.lazyArchiveEntriesForRead(r).filter(o=>o.ino===e.ino&&o.generation===e.generation&&!o.deleted&&!o.materialized);if(i.some(o=>o.dataSequence===e.dataSequence))return r;if(this.lazyArchiveInodes.delete(t),this.sealedLazyAtomicStates.get(r)===void 0)for(let o of i)o.materialized=!0}lazyBackingForStat(e){let t=n.inodeKey(e.ino,e.generation),r=this.lazyFiles.get(t);if(r)return{token:r,path:r.path};let i=this.lazyArchiveInodes.get(t);if(!i)return null;let s=this.lazyArchiveEntriesForRead(i).find(a=>a.ino===e.ino&&a.generation===e.generation&&!a.deleted&&!a.materialized)?.vfsPath;if(s===void 0)return null;let o=this.lazyAtomicGroupByTree.get(i);return o===void 0?{token:i,path:s}:{token:o.token,path:s,atomicGroup:o}}lazyBackingForPath(e){let t=this.lazyArchiveGroups.find(r=>{let i=this.lazyAtomicGroupByTree.get(r),s=this.sealedLazyAtomicStates.get(r)?.snapshot,o=s===void 0?!r.materialized:!i?.committed,a=s?.content??r.content,c=s?.inventory??r.inventory,u=s?.activation??r.activation,l=s?.entries??Array.from(r.entries.values());return o&&a!==void 0&&c!==void 0&&u!==void 0&&l.every(d=>d.deleted||d.materialized||d.isSymlink)&&u.roots.some(d=>d==="/"||e===d||e.startsWith(`${d}/`))});if(t){let r=this.lazyAtomicGroupByTree.get(t);return{token:r?.token??t,path:e,directGroup:t,...r===void 0?{}:{atomicGroup:r}}}try{let r=this.fs.stat(e),i=this.lazyBackingForStat(r);return i?{...i,path:e}:null}catch{return null}}startLazyPreparation(e){let{path:t,token:r}=e,i={status:"pending",promise:Promise.resolve(!1)},s=e.atomicGroup?this.ensureAtomicLazyGroupMaterialized(e.atomicGroup).then(()=>!0):e.directGroup?this.ensureArchiveMaterialized(e.directGroup).then(()=>!0):this.materializePath(t);return i.promise=s.then(o=>(i.status="fulfilled",this.lazyPreparations.get(r)===i&&this.lazyPreparations.delete(r),o),o=>{throw i.status="rejected",i.error=o,o}),i.promise.catch(()=>{}),this.lazyPreparations.set(r,i),i}registerLazyAtomicGroupMembership(e,t=!1){let r=e.activation?.atomicGroup;if(r===void 0)return;let{id:i,member:s}=r;if(e.content===void 0||e.inventory===void 0||e.activation?.mode!=="first-use")throw new Error(`Lazy atomic activation group ${i} accepts only typed first-use trees`);let o=this.lazyAtomicGroups.get(i);if(o===void 0)o={id:i,token:Object.freeze({id:i}),groups:new Map,committed:!1},this.lazyAtomicGroups.set(i,o);else if(o.committed)throw new Error(`Lazy atomic activation group ${i} is already materialized`);if(o.groups.has(s))throw new Error(`Lazy atomic activation group ${i} duplicates member ${s}`);if(vt(r)){if(o.expectedCount!==void 0&&(o.expectedCount!==r.expectedCount||o.cohortSha256!==r.cohortSha256))throw new Error(`Lazy atomic activation group ${i} has inconsistent seals`);o.expectedCount=r.expectedCount,o.cohortSha256=r.cohortSha256;let a=qr(e,i,s);this.sealedLazyAtomicStates.set(e,{snapshot:xs(a,r.descriptorSha256,r.expectedCount,r.cohortSha256),verified:t})}else if(o.expectedCount!==void 0)throw new Error(`Lazy atomic activation group ${i} mixes sealed and unsealed members`);o.groups.set(s,e),this.lazyAtomicGroupByTree.set(e,o)}async sealLazyAtomicGroup(e,t){let r=t.map(u=>Ds({id:e,member:u}).member).sort();if(r.length===0||new Set(r).size!==r.length)throw new Error(`Lazy atomic activation group ${e} expected members are invalid`);let i=this.lazyAtomicGroups.get(e);if(i===void 0||i.committed)throw new Error(`Lazy atomic activation group ${e} is not pending`);let s=[...i.groups.keys()].sort();if(JSON.stringify(s)!==JSON.stringify(r))throw new Error(`Lazy atomic activation group ${e} members differ from its seal`);if(i.expectedCount!==void 0){if(i.expectedCount!==r.length||i.cohortSha256===void 0)throw new Error(`Lazy atomic activation group ${e} has an invalid existing seal`);await this.ensureLazyAtomicGroupSealValidated(i,r.map(u=>i.groups.get(u)),!0);return}let o=r.map(u=>qr(i.groups.get(u),e,u)),a=[];for(let u of o)a.push({member:u.member,descriptorSha256:await hr(u.descriptorBytes,`Lazy atomic member ${u.member}`),source:u});let c=await hr(Is(e,a),`Lazy atomic activation group ${e}`);for(let u of a){let l=i.groups.get(u.member),d=qr(l,e,u.member);if(!Ts(u.source,d))throw new Error(`Lazy atomic activation member ${u.member} changed while sealing`)}for(let u of a){let l=i.groups.get(u.member);l.activation.atomicGroup={id:e,member:u.member,descriptorSha256:u.descriptorSha256,expectedCount:a.length,cohortSha256:c},this.sealedLazyAtomicStates.set(l,{snapshot:xs(u.source,u.descriptorSha256,a.length,c),verified:!0})}i.expectedCount=a.length,i.cohortSha256=c}async verifyImportedLazyAtomicGroupSeals(){await this.validatePendingLazyAtomicGroupSeals(!0)}guardSynchronousLazyAccess(e){let t=this.lazyBackingForPath(e);if(!t)return;let r=this.lazyPreparations.get(t.token);if(r?.status==="fulfilled"){this.lazyPreparations.delete(t.token);let s=this.lazyBackingForPath(e);if(!s)return;r=this.lazyPreparations.get(s.token)??this.startLazyPreparation(s)}else if(r?.status==="rejected"){this.lazyPreparations.delete(t.token);let s=r.error instanceof Error?r.error.message:String(r.error),o=new Error(`EIO: lazy backing for ${e} failed: ${s}`);throw o.code="EIO",o.cause=r.error,o}else r||(r=this.startLazyPreparation(t));let i=new Error(`EAGAIN: lazy backing for ${e} is being prepared`);throw i.code="EAGAIN",i}invalidateLazyData(e){let t=n.inodeKey(e.ino,e.generation);this.lazyFiles.delete(t);let r=this.lazyArchiveInodes.get(t);if(r){this.lazyArchiveInodes.delete(t);for(let i of r.entries.values())i.ino===e.ino&&i.generation===e.generation&&(i.materialized=!0)}}rewriteLazyNamespacePaths(e,t,r){let i=t.length>1?t.replace(/\/+$/,""):t,s=r.length>1?r.replace(/\/+$/,""):r,o=`${i}/`,a=`${s}/`,c=n.inodeKey(e.ino,e.generation),u=(e.mode&Fe)===st,l=d=>d===i?s:u&&d.startsWith(o)?a+d.slice(o.length):d;for(let[d,p]of this.lazyFiles)!u&&d!==c||(p.paths=new Set(Array.from(p.paths,l)),p.path=l(p.path));for(let d of this.lazyArchiveGroups){let p=new Map;for(let[m,f]of d.entries){let h=f.generation===void 0?null:n.inodeKey(f.ino,f.generation);p.set(u||h===c?l(m):m,f)}d.entries=p,d.inventory&&(d.inventory=d.inventory.map(m=>({...m,vfsPath:l(m.vfsPath),...m.type==="hardlink"&&m.target!==void 0?{target:l(m.target)}:{}}))),d.activation&&(d.activation={...d.activation,roots:d.activation.roots.map(l)})}}get sharedBuffer(){return this.fs.buffer}static create(e,t){return new n(be.mkfs(e,t))}static fromExisting(e){return new n(be.mount(e))}rebaseToNewFileSystem(e){if(!Number.isSafeInteger(e)||e<=0)throw new Error(`Invalid MemoryFileSystem maxByteLength: ${e}`);let t=SharedArrayBuffer,{bytes:r,identities:i}=this.fs.snapshotState();this.reconcileLazyIdentityState(i);let s=this.serializeLazyEntries(),o=this.serializeValidatedLazyArchiveEntries(i),a=new t(r.byteLength);new Uint8Array(a).set(r);let c=new n(be.mount(a,{restoreImage:!0}),this.imageMetadata);c.importLazyEntries(s),c.importLazyArchiveEntriesInternal(o,!1,!0,"verified");let u=Math.min(e,Math.max(r.byteLength,Ou)),l=new t(u,{maxByteLength:e}),d=n.create(l,e);d.setImageMetadata(this.imageMetadata);let p=new Set(s.flatMap(f=>f.paths??[f.path])),m=new Set;for(let f of o)if(!f.materialized)for(let h of f.entries)!h.deleted&&!h.isSymlink&&m.add(h.vfsPath);return c.copyPathToFreshFileSystem("/",d,p,m,new Map),d.importLazyEntries(s.map(f=>{let h=d.fs.lstat(f.path);return{...f,ino:h.ino,generation:h.generation,dataSequence:h.dataSequence}})),d.importLazyArchiveEntriesInternal(o.map(f=>({...f,entries:f.entries.map(h=>{if(h.deleted)return{...h,ino:0,generation:void 0};let g=d.fs.lstat(h.vfsPath);return{...h,ino:g.ino,generation:g.generation,dataSequence:g.dataSequence}})})),!1,!0,"verified"),d}getImageMetadata(){return bu(this.imageMetadata)}setImageMetadata(e){this.imageMetadata=e===null?null:yi(e)}subscribeLazyDownloads(e){return this.lazyDownloadListeners.add(e),()=>this.lazyDownloadListeners.delete(e)}setLazyFetcher(e,t={}){this.lazyTransport={fetcher:e,...t.signal===void 0?{}:{signal:t.signal}}}emitLazyDownload(e){if(this.lazyDownloadListeners.size===0)return;let t={...e,t:Nu()};for(let r of this.lazyDownloadListeners)try{r(t)}catch{}}async fetchLazyBytes(e,t){let r=0,i=e.integrity?.bytes??e.fallbackTotalBytes,s={id:e.id,kind:e.kind,url:e.url,path:e.path,mountPrefix:e.mountPrefix};for(let o=0;oe.integrity.bytes)throw new Error(`Lazy ${e.kind} exceeded expected byte count ${e.integrity.bytes}`);this.emitLazyDownload({...s,status:"progress",loadedBytes:r,totalBytes:i})}}}catch(d){try{await c.cancel(d)}catch{}throw d}}finally{c.releaseLock()}let l=Wu(u,r);return te(t.signal),await ci(l,e.kind,e.integrity),te(t.signal),this.emitLazyDownload({...s,status:"complete",loadedBytes:r,totalBytes:i??r}),l}catch(a){if(t.signal?.aborted){let l=t.signal.reason,d=l instanceof Error?l.message:String(l);throw this.emitLazyDownload({...s,status:"error",loadedBytes:r,totalBytes:i,error:d}),l}let c=o+1({...y})),activation:d,entries:new Map},g=y=>{let E=y.split("/").filter(Boolean),O="";for(let w=0;wE.vfsPath.split("/").length-O.vfsPath.split("/").length))if(y.type==="directory"){g(y.vfsPath);try{this.fs.mkdir(y.vfsPath,y.mode),this.fs.chmod(y.vfsPath,y.mode)}catch{if((this.fs.lstat(y.vfsPath).mode&Fe)!==st)throw new Error(`Lazy tree directory collides at ${y.vfsPath}`)}}for(let y of u){if(y.type!=="symlink")continue;g(y.vfsPath),this.fs.symlink(y.target,y.vfsPath);let E=this.fs.lstat(y.vfsPath);h.entries.set(y.vfsPath,{ino:E.ino,generation:E.generation,dataSequence:E.dataSequence,size:y.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:y.sourcePath,sourcePath:y.sourcePath,type:"symlink",target:y.target})}let _=new Map;for(let y of u){if(y.type!=="file")continue;g(y.vfsPath);let E=this.fs.createLazyStub(y.vfsPath,y.mode);this.invalidateLazyData(E),_.set(y.inodeGroup,E);let O={ino:E.ino,generation:E.generation,dataSequence:E.dataSequence,size:y.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:y.sourcePath,sourcePath:y.sourcePath,type:"file",inodeGroup:y.inodeGroup};h.entries.set(y.vfsPath,O)}for(let y of u){if(y.type!=="hardlink")continue;let E=p.get(y.inodeGroup);g(y.vfsPath),this.fs.link(E.vfsPath,y.vfsPath);let O=this.fs.lstat(y.vfsPath),w=_.get(y.inodeGroup);if(O.ino!==w.ino||O.generation!==w.generation)throw new Error(`Lazy tree hardlink ${y.vfsPath} did not share its inode`);h.entries.set(y.vfsPath,{ino:O.ino,generation:O.generation,dataSequence:O.dataSequence,size:y.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:E.sourcePath,sourcePath:y.sourcePath,type:"hardlink",inodeGroup:y.inodeGroup,target:y.target})}if(m!==void 0)for(let y of u)this.lchown(y.vfsPath,m.uid,m.gid);for(let y of h.entries.values())y.isSymlink||y.generation===void 0||this.lazyArchiveInodes.set(n.inodeKey(y.ino,y.generation),h);return this.lazyArchiveGroups.push(h),this.registerLazyAtomicGroupMembership(h),h}registerLazyTreeWithMaterializationHandle(e,t,r="/",i,s){let o=this.registerLazyTreeInternal(e,t,r,i,!0,s),a=Object.freeze({[Eu]:!0});return this.deferredTreeMaterializationHandles.set(a,o),a}registerLazyArchiveFromEntries(e,t,r,i,s){let o=_r(r),a=zu(e,t,o,i);a.some(({entry:u})=>!u.isDirectory&&!u.isSymlink)&&this.assertCanRegisterPendingLazyArchiveGroup();let c={...s?{content:Qr({decoder:"zip-v1",mediaType:"application/zip",sha256:s.sha256,bytes:s.bytes,expandedBytes:a.reduce((u,l)=>u+l.entry.uncompressedSize,0),sourceEntryCount:a.length,transports:[e]})}:{},url:e,mountPrefix:o,integrity:yr(s),materialized:!1,entries:new Map};for(let{entry:u,vfsPath:l}of a){if(u.isDirectory)continue;let d=l.split("/").filter(Boolean),p="";for(let m=0;mu.deleted||u.materialized),this.lazyArchiveGroups.push(c),c}importLazyArchiveEntries(e){this.importLazyArchiveEntriesInternal(e,!1,!0,"reject")}async importVerifiedLazyArchiveEntries(e){let t=structuredClone(e),r=this.exportLazyArchiveEntries(),i=n.fromExisting(this.sharedBuffer);i.importLazyArchiveEntriesInternal([...r,...t],!1,!0,"pending"),await i.verifyImportedLazyAtomicGroupSeals(),this.importLazyArchiveEntriesInternal(t,!1,!0,"verified")}importLazyArchiveEntriesInternal(e,t,r,i){let s=Ne(e,"Serialized lazy archive groups",0,Ls).map((l,d)=>{if(typeof l!="object"||l===null||Array.isArray(l))throw new Error(`Serialized lazy archive group ${d} must be an object`);let p=l.kind;if(p===mr||p===fi||p===at)return Bs(l,p);if(p===pr)return mi(l,!1);if(p!==void 0)throw new Error(`Serialized lazy archive group ${d} has an unsupported kind`);if(r)throw new Error(`Serialized lazy archive group ${d} is missing its kind discriminator`);return mi(l,!0)}),o=this.fs.identityState();this.reconcileLazyIdentityState(o);let a=[...this.serializeValidatedLazyArchiveEntries(o),...s];As(a);let c=[],u=new Map;for(let l of s){let d=new Map,p=l.mountPrefix.replace(/\/+$/,""),m=l.content!==void 0&&l.inventory!==void 0&&l.activation!==void 0,f=m?new Map(l.inventory.map(w=>[w.vfsPath,w])):null,h=m?new Map(l.inventory.map(w=>[en(w),w])):null,g=new Map,_=new Map,y=new Map;for(let w of l.entries){let S=null,A=l.materialized||w.materialized===!0||w.isSymlink;if(!w.deleted&&!A){if((w.generation===void 0||w.dataSequence===void 0)&&!t)throw new Error("Live lazy-archive metadata requires inode generation and data sequence");try{S=this.fs.lstat(w.vfsPath)}catch{if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} is missing from the filesystem`);continue}if(S.ino!==w.ino){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} has a different inode`);continue}if(w.generation!==void 0&&S.generation!==w.generation){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} has a different generation`);continue}if(w.dataSequence===void 0){if(!n.canAdoptLegacyLazyStub(S)){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} is not pristine`);continue}}else if(S.dataSequence!==w.dataSequence){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} has a different data sequence`);continue}if(m){y.set(w.vfsPath,S);let x=f.get(w.vfsPath),v=h.get(en(w))??x;if(!v||(S.mode&Fe)!==fr||S.size!==0||(S.mode&W.S_MODE_BITS)!==v.mode||x?.inodeGroup!==void 0&&x.inodeGroup!==v.inodeGroup)throw new Error(`Serialized lazy tree stub ${w.vfsPath} disagrees with its inventory`);let L=n.inodeKey(S.ino,S.generation),D=w.inodeGroup,Z=g.get(D),N=_.get(L);if(Z!==void 0&&Z!==L||N!==void 0&&N!==D)throw new Error(`Serialized lazy tree inode group ${D} disagrees with the filesystem`);g.set(D,L),_.set(L,D)}}d.set(w.vfsPath,{ino:w.ino,generation:S?.generation??w.generation,dataSequence:S?.dataSequence??w.dataSequence,size:w.size,isSymlink:w.isSymlink,deleted:w.deleted,materialized:A,archivePath:w.archivePath??w.vfsPath.slice(p.length+1),sourcePath:w.sourcePath??w.archivePath??w.vfsPath.slice(p.length+1),type:w.type??(w.isSymlink?"symlink":"file"),inodeGroup:w.inodeGroup,target:w.target})}if(m){let w=new Map;for(let S of l.inventory){if(S.type==="file"||S.type==="hardlink"){w.set(S.inodeGroup,(w.get(S.inodeGroup)??0)+1);continue}let A;try{A=this.fs.lstat(S.vfsPath)}catch{throw new Error(`Serialized lazy tree namespace entry ${S.vfsPath} is missing from the filesystem`)}let R=S.type==="directory"?st:Hr;if((A.mode&Fe)!==R||(A.mode&W.S_MODE_BITS)!==S.mode||S.type==="symlink"&&(A.size!==new TextEncoder().encode(S.target).byteLength||this.fs.readlink(S.vfsPath)!==S.target))throw new Error(`Serialized lazy tree namespace entry ${S.vfsPath} disagrees with its inventory`);S.type==="symlink"&&d.set(S.vfsPath,{ino:A.ino,generation:A.generation,dataSequence:A.dataSequence,size:S.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:S.sourcePath,sourcePath:S.sourcePath,type:"symlink",target:S.target})}if(l.activation?.atomicGroup!==void 0)for(let S of l.inventory){if(S.type!=="file"&&S.type!=="hardlink")continue;if(y.get(S.vfsPath).linkCount!==w.get(S.inodeGroup))throw new Error(`Serialized lazy atomic tree inode group ${S.inodeGroup} has undeclared aliases`)}}let E=l.content===void 0?void 0:Qr(l.content),O={content:E,url:E?.transports[0]??l.url,mountPrefix:l.mountPrefix,integrity:E?{sha256:E.sha256,bytes:E.bytes}:yr(l.integrity),materialized:l.materialized||!(E&&l.inventory)&&Array.from(d.values()).every(w=>w.deleted||w.materialized),inventory:l.inventory?.map(w=>({...w})),activation:l.activation?{mode:l.activation.mode,capabilities:[...l.activation.capabilities],roots:[...l.activation.roots],...l.activation.atomicGroup===void 0?{}:{atomicGroup:{...l.activation.atomicGroup}}}:void 0,entries:d};if(c.push(O),!O.materialized){for(let[,w]of d)if(!w.deleted&&!w.materialized&&w.generation!==void 0){let S=n.inodeKey(w.ino,w.generation),A=u.get(S);if(A!==void 0&&A!==O)throw new Error(`Serialized lazy archive groups share pending inode ${S}`);if(this.lazyArchiveInodes.has(S))throw new Error(`Serialized lazy archive group collides with pending inode ${S}`);u.set(S,O)}}}for(let l of c){let d=l.activation?.atomicGroup;if(d!==void 0&&this.lazyAtomicGroups.get(d.id)?.committed)throw new Error(`Lazy atomic activation group ${d.id} is already materialized`)}if(i==="reject"&&c.some(l=>{let d=l.activation?.atomicGroup;return d!==void 0&&vt(d)}))throw new Error("Sealed lazy archive registrations require importVerifiedLazyArchiveEntries()");this.lazyArchiveGroups.push(...c);for(let l of c)this.registerLazyAtomicGroupMembership(l,i==="verified");for(let[l,d]of u)this.lazyArchiveInodes.set(l,d)}rewriteLazyArchiveUrls(e){for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0&&!r?.committed){this.assertLazyAtomicSnapshotMatchesPublic(t);let s=ed(i.snapshot,e);t.content=Rs(s.content),t.url=s.url,t.integrity={...s.integrity},i.snapshot=s;continue}t.content?(t.content={...t.content,transports:t.content.transports.map(e)},t.url=t.content.transports[0]):t.url=e(t.url)}}serializeLazyArchiveEntries(){let e=[];for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(r?.committed)continue;let c=i.snapshot;if(c.content.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");e.push({kind:at,content:Rs(c.content),inventory:c.inventory.map(u=>({...u})),activation:Ju(c),url:c.url,mountPrefix:c.mountPrefix,integrity:{...c.integrity},materialized:!1,entries:c.entries.filter(u=>!u.deleted&&!u.materialized).map(({vfsPath:u,...l})=>({vfsPath:u,...l}))});continue}let s=Array.from(t.entries,([c,u])=>({vfsPath:c,ino:u.ino,generation:u.generation,dataSequence:u.dataSequence,size:u.size,isSymlink:u.isSymlink,deleted:u.deleted,materialized:u.materialized,archivePath:u.archivePath,sourcePath:u.sourcePath,type:u.type,inodeGroup:u.inodeGroup,target:u.target})).filter(c=>!c.deleted&&!c.materialized);if(s.length===0&&!(t.content&&t.inventory&&!t.materialized))continue;let o=t.content!==void 0&&t.inventory!==void 0&&t.activation!==void 0;if(o&&t.content.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");let a=t.activation?.atomicGroup;if(a!==void 0&&!vt(a))throw new Error(`Lazy atomic activation group ${a.id} must be sealed before serialization`);e.push(o?{kind:a!==void 0?at:t.content.source===void 0?mr:fi,content:t.content,inventory:t.inventory,activation:t.activation,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:s}:{kind:pr,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:s})}return e}serializeValidatedLazyArchiveEntries(e){this.assertPendingLazyAtomicSnapshotsReadyForSerialization();let t=this.serializeLazyArchiveEntries();return As(t),this.validatePendingLazyTreeNamespaceState(e),t}exportLazyArchiveEntries(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),this.serializeValidatedLazyArchiveEntries(e)}pendingDeferredTreeUsage(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),Cs(this.serializeValidatedLazyArchiveEntries(e))}assertCanAppendDeferredTreeUsage(e){pi(e);let t=this.pendingDeferredTreeUsage();pi({groups:t.groups+e.groups,archiveBytes:t.archiveBytes+e.archiveBytes,expandedBytes:t.expandedBytes+e.expandedBytes,payloadBytes:t.payloadBytes+e.payloadBytes,entries:t.entries+e.entries})}assertCanRegisterPendingLazyArchiveGroup(){if(this.reconcileLazyIdentityState(this.fs.identityState()),this.lazyArchiveGroups.filter(t=>{let r=this.lazyAtomicGroupByTree.get(t);return this.sealedLazyAtomicStates.get(t)?.snapshot!==void 0?!r?.committed:!t.materialized&&(t.content!==void 0&&t.inventory!==void 0||Array.from(t.entries.values()).some(s=>!s.deleted&&!s.materialized))}).length>=xe.maxGroups)throw new Error(`Cannot register another lazy archive group: ${xe.maxGroups} pending groups already exist`)}async preparePath(e){let t=!1,r=Math.max(3,this.lazyArchiveGroups.length+1);for(let i=0;i!s.materialized&&s.activation?.mode==="boot-prefetch"),t=0,r,i=Array.from({length:Math.min(e.length,Iu)},async()=>{for(;r===void 0;){let s=t;if(t+=1,s>=e.length)return;try{await this.prepareLazyTreeGroup(e[s])}catch(o){r??=o}}});if(await Promise.all(i),r!==void 0)throw r;return e.length}async materializeRegisteredDeferredTree(e,t){let r=this.deferredTreeMaterializationHandles.get(e);if(r===void 0)throw new Error("Deferred-tree handle was not issued by this filesystem");let i=this.lazyAtomicGroupByTree.get(r);if(i!==void 0)throw new Error(`Deferred tree belongs to atomic activation group ${i.id}; materialize the complete group instead`);if(r.materialized)return!1;let s=this.lazyPreparations.get(r);if(s!==void 0)return s.promise;let o=new Uint8Array(t.byteLength);o.set(t);let a={status:"pending",promise:Promise.resolve(!1)};a.promise=Promise.resolve().then(async()=>(await ci(o,"tree",r.integrity),await this.materializeArchiveBytes(r,o),!0)).then(c=>(a.status="fulfilled",c),c=>{throw a.status="rejected",a.error=c,c}),a.promise.catch(()=>{}),this.lazyPreparations.set(r,a);try{return await a.promise}finally{this.lazyPreparations.get(r)===a&&this.lazyPreparations.delete(r)}}async prepareLazyTreeGroup(e){let t=this.lazyAtomicGroupByTree.get(e);if(t?.committed||t===void 0&&e.materialized)return!1;let r=this.sealedLazyAtomicStates.get(e)?.snapshot,i={token:t?.token??e,path:r?.activation.roots[0]??e.activation?.roots[0]??e.mountPrefix,directGroup:e,...t===void 0?{}:{atomicGroup:t}},s=this.lazyPreparations.get(i.token)??this.startLazyPreparation(i);try{return await s.promise}finally{this.lazyPreparations.get(i.token)===s&&this.lazyPreparations.delete(i.token)}}async ensureMaterialized(e){return this.preparePath(e)}async materializePath(e){if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0)return!1;let t;try{t=this.fs.stat(e)}catch{return!1}let r=n.inodeKey(t.ino,t.generation),i=this.lazyFiles.get(r);if(i){let o=this.lazyTransport,a=await this.fetchLazyBytes({id:`file:${t.ino}`,kind:"file",url:i.url,path:i.path,fallbackTotalBytes:i.size},o);for(let c=0;c<3;c++){if(this.lazyFiles.get(r)!==i)return!1;for(let u of new Set([e,...i.paths]))if(te(o.signal),this.fs.replaceIfIdentity(u,i.ino,i.generation,i.dataSequence,a))return i.path=u,this.lazyFiles.delete(r),!0;this.reconcileLazyIdentityState(this.fs.identityState())}throw new Error(`Lazy file kept changing names while materializing: ${e}`)}let s=this.lazyArchiveInodes.get(r);return s?(await this.ensureArchiveMaterialized(s,{path:e,ino:t.ino,generation:t.generation}),!this.lazyArchiveInodes.has(r)):!1}async decodeAndValidateLazyTree(e,t,r){let i=r?.content??e.content,s=r?.inventory??e.inventory;if(!i||!s)throw new Error("Lazy tree is missing its decoder or complete inventory");let o=new Map,a=new Map(s.map(p=>[p.vfsPath,p]));if(i.source!==void 0)for(let p of i.source.entries)o.set(p.sourcePath,p);else for(let p of s){if(p.type==="hardlink"){let f=a.get(p.target);if(!f)throw new Error(`Lazy tree hardlink target disappeared: ${p.target}`);if(p.sourcePath===f.sourcePath)continue}if(o.get(p.sourcePath))throw new Error(`Lazy tree inventory duplicates source member ${p.sourcePath}`);o.set(p.sourcePath,{sourcePath:p.sourcePath,type:p.type,mode:p.mode,size:p.size,...p.type==="symlink"?{target:p.target}:{},...p.type==="hardlink"?{target:a.get(p.target)?.sourcePath}:{}})}let c=new Map,u=0;if(i.decoder==="zip-v1"){let{parseZipCentralDirectory:p,extractZipEntryBounded:m}=await Promise.resolve().then(()=>(Jn(),jn)),f=p(t);if(f.length!==i.sourceEntryCount||f.length!==o.size)throw new Error("Lazy ZIP tree decoded inventory counts differ from its descriptor");for(let h of f){let g=h.isDirectory?h.fileName.replace(/\/$/,""):h.fileName;if(c.has(g))throw new Error(`Lazy ZIP tree duplicates source member ${g}`);let _=o.get(g);if(!_)throw new Error(`Lazy ZIP tree has undeclared source member ${g}`);if(u+=h.uncompressedSize,u>i.expandedBytes||h.uncompressedSize!==_.size)throw new Error(`Lazy ZIP tree member ${g} exceeds its inventory`);let y=h.isDirectory?"directory":h.isSymlink?"symlink":"file",E=i.modePolicy==="portable-posix-v1"?y==="directory"?493:y==="symlink"?511:(h.mode&73)!==0?493:420:h.mode&W.S_MODE_BITS;if(y!==_.type||E!==_.mode)throw new Error(`Lazy ZIP tree member ${g} differs from inventory`);if(h.isDirectory)c.set(g,{type:"directory",mode:E});else{let O=m(t,h,_.size);if(h.isSymlink){let w;try{w=new TextDecoder("utf-8",{fatal:!0}).decode(O)}catch{throw new Error(`Lazy ZIP tree symlink ${g} is not UTF-8`)}c.set(g,{type:"symlink",mode:E,target:w})}else c.set(g,{type:"file",mode:E,data:O})}}}else{let{parseTarGzip:p}=await Promise.resolve().then(()=>(ms(),ps)),m=p(t,{label:`Lazy tree ${i.sha256}`,limits:{maxCompressedBytes:i.bytes,maxUncompressedBytes:i.expandedBytes,maxEntries:i.sourceEntryCount}});u=new DataView(t.buffer,t.byteOffset,t.byteLength).getUint32(t.byteLength-4,!0);for(let f of m){if(c.has(f.path))throw new Error(`Lazy TAR tree duplicates source member ${f.path}`);f.type==="file"?c.set(f.path,{type:"file",mode:f.mode,data:f.data}):f.type==="directory"?c.set(f.path,{type:"directory",mode:f.mode}):c.set(f.path,{type:f.type,mode:f.mode,target:f.linkName})}}if(c.size!==i.sourceEntryCount||c.size!==o.size||u!==i.expandedBytes)throw new Error("Lazy tree decoded inventory counts differ from its descriptor");for(let[p,m]of o){let f=c.get(p);if(!f)throw new Error(`Lazy tree is missing source member ${p}`);let h=m.type;if(f.type!==h)throw new Error(`Lazy tree member ${p} is ${f.type}, expected ${h}`);if((f.mode&W.S_MODE_BITS)!==m.mode)throw new Error(`Lazy tree member ${p} mode differs from inventory`);if(h==="file"&&f.data?.byteLength!==m.size)throw new Error(`Lazy tree member ${p} size differs from inventory`);if(h==="symlink"&&f.target!==m.target)throw new Error(`Lazy tree symlink ${p} target differs from inventory`);if(h==="hardlink"&&f.target!==m.target)throw new Error(`Lazy tree hardlink ${p} target differs from inventory`)}let l=new Set(s.flatMap(p=>p.materialization==="archive-homebrew-relocate"?[p.sourcePath]:[]));if(i.source!==void 0){let p=new Map(i.source.entries.map(h=>[h.sourcePath,h])),m=Ms(i.source.entries),f=i.source.entries.filter(h=>h.sourcePath==="INSTALL_RECEIPT.json"||h.sourcePath.endsWith("/INSTALL_RECEIPT.json"));if(f.length>1)throw new Error(`Lazy Homebrew bottle has ${f.length} INSTALL_RECEIPT.json source members, expected at most one`);if(f.length===0){if(l.size>0)throw new Error("Lazy Homebrew bottle marks receipt relocation without INSTALL_RECEIPT.json")}else{let h=f[0],g=h.type==="file"?h:m.get(h.sourcePath),_=g===void 0?void 0:c.get(g.sourcePath);if(g?.type!=="file"||_?.type!=="file"||_.data===void 0)throw new Error("Lazy Homebrew bottle INSTALL_RECEIPT.json is not regular");let y=Wo(_.data),E=h.sourcePath.lastIndexOf("/"),O=E<0?"":h.sourcePath.slice(0,E),w=new Set(y.changedFiles.map(A=>O.length===0?A:`${O}/${A}`));if(l.size!==w.size||[...l].some(A=>!w.has(A)))throw new Error("Lazy Homebrew bottle relocation markers differ from INSTALL_RECEIPT.json");let S=new Set;for(let A of w){let R=p.get(A),x=R?.type==="file"?R:R===void 0?void 0:m.get(R.sourcePath),v=x===void 0?void 0:c.get(x.sourcePath);if(x?.type!=="file"||v?.type!=="file"||v.data===void 0)throw new Error(`Lazy Homebrew bottle changed source ${A} is not regular`);S.has(x.sourcePath)||(v.data=Go(v.data,y,A),S.add(x.sourcePath))}}}else if(l.size>0)throw new Error("Lazy tree receipt relocation requires original-bottle source truth");let d=new Map;for(let p of s){if(p.type!=="file"||p.materialization==="descriptor")continue;let m=c.get(p.sourcePath);if(m?.type!=="file"||!m.data)throw new Error(`Lazy tree has no file content for ${p.sourcePath}`);d.set(p.sourcePath,m.data)}return d}async ensureArchiveMaterialized(e,t){let r=this.lazyAtomicGroupByTree.get(e);if(r!==void 0){if(r.committed)return;let o=this.sealedLazyAtomicStates.get(e)?.snapshot,a=this.lazyPreparations.get(r.token)??this.startLazyPreparation({token:r.token,path:o?.activation.roots[0]??e.activation?.roots[0]??e.mountPrefix,atomicGroup:r});try{await a.promise}finally{this.lazyPreparations.get(r.token)===a&&this.lazyPreparations.delete(r.token)}return}if(e.materialized)return;let i=this.lazyTransport,s=await this.fetchLazyArchiveData(e,i);te(i.signal),await this.materializeArchiveBytes(e,s,t,i.signal)}async fetchLazyArchiveData(e,t,r){let i=r?.content??e.content,s=r?.inventory??e.inventory,o=i!==void 0&&s!==void 0,a=r?.mountPrefix??e.mountPrefix,c=r?.integrity??e.integrity,u=o?i.transports:[r?.url??e.url],l=[],d=null;for(let[p,m]of u.entries())try{d=await this.fetchLazyBytes({id:`archive:${a}:${i?.sha256??m}:${p}`,kind:o?"tree":"archive",url:m,mountPrefix:a,integrity:c},t);break}catch(f){if(te(t.signal),Ns(f))throw f;l.push(f instanceof Error?f.message:String(f))}if(te(t.signal),d===null)throw new Error(`All ${u.length} lazy ${o?"tree":"archive"} transports failed: ${l.join("; ")}`);return d}async materializeArchiveBytes(e,t,r,i){if(te(i),e.materialized)return;let s=await this.prepareLazyArchiveContents(e,t,i),o=r?n.inodeKey(r.ino,r.generation):null;for(let a=0;a<3;a++){let c=this.collectLazyArchiveReplacements(e,s,r);if(c.size>0&&(te(i),!this.fs.replaceManyIfIdentities(Array.from(c.values(),vs)))){if(this.reconcileLazyIdentityState(this.fs.identityState()),o&&!this.lazyArchiveInodes.has(o))return;continue}if(te(i),this.publishLazyArchiveReplacements(e,c),e.materialized||(this.reconcileLazyIdentityState(this.fs.identityState()),o&&!this.lazyArchiveInodes.has(o)))return}if(o&&this.lazyArchiveInodes.has(o))throw new Error(`Lazy archive member kept changing names while materializing: ${r?.path}`)}async prepareLazyArchiveContents(e,t,r,i){te(r);let s=i?.content??e.content,o=i?.inventory??e.inventory,c=s!==void 0&&o!==void 0?await this.decodeAndValidateLazyTree(e,t,i):null;te(r);let{parseZipCentralDirectory:u,extractZipEntry:l}=await Promise.resolve().then(()=>(Jn(),jn));te(r);let d=c?[]:u(t),p=new Map;for(let _ of d){if(p.has(_.fileName))throw new Error(`Lazy archive contains duplicate member: ${_.fileName}`);p.set(_.fileName,_)}let f=(i?.mountPrefix??e.mountPrefix).replace(/\/+$/,""),h=new Map,g=i===void 0?Array.from(e.entries):i.entries.map(_=>[_.vfsPath,_]);for(let[_,y]of g){if(y.deleted||y.materialized)continue;let E=y.archivePath??_.slice(f.length+1),O=c?void 0:p.get(E),w=c?.get(E);if(c){if(w===void 0||w.byteLength!==y.size)throw new Error(`Lazy tree member ${E} does not match its registered metadata`)}else if(O===void 0||O.isDirectory||O.isSymlink||O.uncompressedSize!==y.size)throw new Error(`Lazy archive member ${E} does not match its registered metadata`);if(y.generation===void 0)continue;let S=n.inodeKey(y.ino,y.generation),A=h.get(S);if(A&&A.archivePath!==E)throw new Error(`Lazy archive aliases for inode ${S} name different members`);if(!A){let R=w??l(t,O);if(R.byteLength!==y.size)throw new Error(`Lazy archive member ${E} extracted ${R.byteLength} bytes, expected ${y.size}`);h.set(S,{archivePath:E,content:R})}}return h}collectLazyArchiveReplacements(e,t,r,i){let s=new Map,o=i===void 0?Array.from(e.entries):i.entries.map(a=>[a.vfsPath,a]);for(let[a,c]of o){if(c.deleted||c.materialized||c.generation===void 0)continue;let u=n.inodeKey(c.ino,c.generation);if(this.lazyArchiveInodes.get(u)!==e)continue;let l=t.get(u);if(!l)throw new Error(`Lazy archive has no extracted content for inode ${u}`);let d=s.get(u);d||(d={ino:c.ino,generation:c.generation,dataSequence:c.dataSequence??0,paths:new Set,content:l.content},s.set(u,d)),d.paths.add(a),r&&r.ino===c.ino&&r.generation===c.generation&&d.paths.add(r.path)}return s}publishLazyArchiveReplacements(e,t){for(let[r,i]of t){this.lazyArchiveInodes.delete(r);for(let s of e.entries.values())s.ino===i.ino&&s.generation===i.generation&&(s.materialized=!0)}e.materialized=Array.from(e.entries.values()).every(r=>r.deleted||r.materialized)}collectAtomicTreeNamespace(e,t){let r=new Set,i=new Map,s=new Map,o=new Map(t.entries.map(c=>[c.vfsPath,c]));for(let c of t.inventory)(c.type==="file"||c.type==="hardlink")&&s.set(c.inodeGroup,(s.get(c.inodeGroup)??0)+1);let a=[];for(let c of t.inventory){let u;try{u=this.fs.lstat(c.vfsPath)}catch{throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`)}let l=c.type==="directory"?st:c.type==="symlink"?Hr:fr;if((u.mode&Fe)!==l||(u.mode&W.S_MODE_BITS)!==c.mode)throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`);if(c.type==="symlink"){let d=o.get(c.vfsPath);if(d===void 0||!d.isSymlink||d.deleted||d.ino!==u.ino||d.generation!==u.generation||d.dataSequence!==u.dataSequence||this.fs.readlink(c.vfsPath)!==c.target)throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`)}else if(c.type==="file"||c.type==="hardlink"){let d=o.get(c.vfsPath);if(d===void 0||d.deleted||d.materialized||d.isSymlink||d.generation===void 0||d.inodeGroup!==c.inodeGroup||d.ino!==u.ino||d.generation!==u.generation||d.dataSequence!==u.dataSequence||u.size!==0||u.linkCount!==s.get(c.inodeGroup))throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`);let p=n.inodeKey(d.ino,d.generation);if(this.lazyArchiveInodes.get(p)!==e)throw new Error(`Lazy atomic tree lost deferred ownership of ${c.vfsPath}`);let m=i.get(c.inodeGroup);if(m!==void 0&&m!==p)throw new Error(`Lazy atomic tree split hard links at ${c.vfsPath}`);i.set(c.inodeGroup,p),r.add(p)}a.push({path:c.vfsPath,expectedIno:u.ino,expectedGeneration:u.generation,expectedDataSequence:u.dataSequence,expectedMode:u.mode,expectedLinkCount:u.linkCount,expectedSize:u.size,expectedUid:u.uid,expectedGid:u.gid})}return{guards:a,pendingIdentities:r.size}}assertLazyAtomicSnapshotMatchesPublic(e){let t=this.sealedLazyAtomicStates.get(e),r=t?.snapshot,i=e.activation?.atomicGroup,s=r?.member??i?.member??"unknown",o;if(r!==void 0)try{o=qr(e,r.id,r.member)}catch{o=void 0}if(t===void 0||r===void 0||i===void 0||!vt(i)||i.id!==r.id||i.member!==r.member||i.descriptorSha256!==r.descriptorSha256||i.expectedCount!==r.expectedCount||i.cohortSha256!==r.cohortSha256||o===void 0||!Ts(r,o))throw new Error(`Lazy atomic activation member ${s} changed after sealing`);return t}assertPendingLazyAtomicSnapshotsReadyForSerialization(){for(let e of this.lazyArchiveGroups){if(!this.sealedLazyAtomicStates.has(e)||this.lazyAtomicGroupByTree.get(e)?.committed)continue;let r=this.assertLazyAtomicSnapshotMatchesPublic(e);if(!r.verified)throw new Error(`Lazy atomic activation group ${r.snapshot.id} has not been cryptographically verified after import`)}}async validatePendingLazyAtomicGroupSeals(e){for(let t of this.lazyAtomicGroups.values()){if(t.committed||t.expectedCount===void 0||t.cohortSha256===void 0)continue;let r=[...t.groups.entries()].sort(([i],[s])=>is?1:0).map(([,i])=>i);await this.ensureLazyAtomicGroupSealValidated(t,r,e)}}async ensureLazyAtomicGroupSealValidated(e,t,r){if(this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r))return;let i=e.sealValidationFlight;if(i===void 0&&(i=this.validateLazyAtomicGroupSealOnce(e,t),e.sealValidationFlight=i,i.then(()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)},()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)})),await i,!this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r))throw new Error(`Lazy atomic activation group ${e.id} seal verification did not authenticate every member`)}assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r){if(e.committed)return!0;if(e.expectedCount===void 0||e.cohortSha256===void 0||t.length!==e.expectedCount)throw new Error(`Lazy atomic activation group ${e.id} is not completely sealed`);let i=!0,s=[];for(let o of t){let a=this.assertLazyAtomicSnapshotMatchesPublic(o),c=a.snapshot;if(c.id!==e.id||c.expectedCount!==e.expectedCount||c.cohortSha256!==e.cohortSha256||e.groups.get(c.member)!==o)throw new Error(`Lazy atomic activation group ${e.id} has an inconsistent member`);i&&=a.verified,s.push(a)}if(i&&r)for(let o=0;ofh?1:0).map(([,f])=>f);if(t.length===0)throw new Error(`Lazy atomic activation group ${e.id} has no trees`);if(await this.ensureLazyAtomicGroupSealValidated(e,t,!0),e.committed)return;let r=t.map(f=>this.sealedLazyAtomicStates.get(f).snapshot),i=t.map((f,h)=>({group:f,...this.collectAtomicTreeNamespace(f,r[h])})),s=this.lazyTransport,o=new Array(t.length),a=0,c=!1,u,l=Array.from({length:Math.min(Ru,t.length)},async()=>{for(;!c;){let f=a++;if(f>=t.length)return;let h=t[f],g=r[f];try{let _=await this.fetchLazyArchiveData(h,s,g);te(s.signal),o[f]={group:h,snapshot:g,contents:await this.prepareLazyArchiveContents(h,_,s.signal,g)}}catch(_){c||(c=!0,u=_)}}});if(await Promise.all(l),c)throw o.fill(void 0),u;te(s.signal);for(let f of t)this.assertLazyAtomicSnapshotMatchesPublic(f);let d=[],p=[],m=[];for(let f=0;f{let a=this.lazyAtomicGroupByTree.get(o);return this.sealedLazyAtomicStates.get(o)?.snapshot===void 0?!o.materialized&&o.content!==void 0&&o.inventory!==void 0:!a?.committed});if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0&&r.length===0)return;let i=Array.from(this.lazyFiles.values(),o=>o.path);for(let o of i)await this.ensureMaterialized(o);let s=new Set(this.lazyArchiveInodes.values());for(let o of r)s.add(o);for(let o of s)await this.prepareLazyTreeGroup(o)}this.reconcileLazyIdentityState(this.fs.identityState());let e=this.lazyArchiveGroups.some(t=>{let r=this.lazyAtomicGroupByTree.get(t);return this.sealedLazyAtomicStates.get(t)?.snapshot===void 0?!t.materialized&&t.content!==void 0&&t.inventory!==void 0:!r?.committed});if(this.lazyFiles.size!==0||this.lazyArchiveInodes.size!==0||e)throw new Error("Cannot create a self-contained VFS image while lazy entries remain pending")}async saveImage(e){e?.materializeAll&&await this.materializeAllLazyEntries(),await this.validatePendingLazyAtomicGroupSeals(!1);let{bytes:t,identities:r}=this.fs.snapshotState({normalizeTimestampsMs:e?.normalizeTimestampsMs});this.reconcileLazyIdentityState(r);let i=this.serializeLazyEntries(),s=i.length>0,o=s?new TextEncoder().encode(JSON.stringify(i)):new Uint8Array(0);if(o.byteLength>Xr)throw new Error(`VFS image lazy metadata exceeds ${Xr} bytes`);let a=this.serializeValidatedLazyArchiveEntries(r),c=a.length>0,u=c?new TextEncoder().encode(JSON.stringify(a)):new Uint8Array(0);if(u.byteLength>Yr)throw new Error(`VFS image lazy archive metadata exceeds ${Yr} bytes`);let l=e?.metadata===void 0?this.imageMetadata:e.metadata,d=ku(l),p=d.byteLength>0,m=c?4+u.byteLength:0,f=p?4+d.byteLength:0,h=pe+t.byteLength+4+o.byteLength+m+f,g=new Uint8Array(h),_=new DataView(g.buffer);_.setUint32(0,ui,!0),_.setUint32(4,di,!0),_.setUint32(8,(s?ii:0)|(c?Zr:0)|(c?si:0)|(p?oi:0),!0),_.setUint32(12,t.byteLength,!0),g.set(t,pe);let y=pe+t.byteLength;if(_.setUint32(y,o.byteLength,!0),o.byteLength>0&&g.set(o,y+4),c){let E=y+4+o.byteLength;_.setUint32(E,u.byteLength,!0),g.set(u,E+4)}if(p){let E=y+4+o.byteLength+m;_.setUint32(E,d.byteLength,!0),g.set(d,E+4)}return g}static readImageMetadata(e){let t=Vr(e);if(!(t.flags&oi))return null;let{metadataOffset:r}=ws(t.image,t.view,t.flags,t.sabLen);if(t.image.byteLengthLt)throw new Error(`VFS image metadata exceeds ${Lt} bytes`);if(t.image.byteLength0){let g=r.subarray(f+4,f+4+h),_=Ne(Os(g,"VFS image lazy metadata"),"VFS image lazy entries",0,zt);m.importLazyEntriesInternal(_,!0)}if(s&Zr){let g=a.archiveOffset,_=i.getUint32(g,!0);if(_>0){let y=r.subarray(g+4,g+4+_),E=Os(y,"VFS image lazy archive metadata");m.importLazyArchiveEntriesInternal(E,!0,!!(s&si),"pending")}}return m}adaptStat(e){return{dev:0,ino:e.ino,mode:e.mode,nlink:e.linkCount,uid:e.uid,gid:e.gid,size:e.size,atimeMs:e.atime,mtimeMs:e.mtime,ctimeMs:e.ctime}}adaptStatWithLazySize(e){let t=this.adaptStat(e),r=this.lazyFileForStat(e);if(r)return t.size=r.size,t;let i=this.lazyArchiveForStat(e);if(i){for(let s of this.lazyArchiveEntriesForRead(i))if(s.ino===e.ino&&s.generation===e.generation&&!s.deleted){t.size=s.size;break}}return t}open(e,t,r){(t&sr)===0&&!((t&ir)!==0&&(t&Wn)!==0)&&this.guardSynchronousLazyAccess(e);let i=this.fs.open(e,t,r);return(t&sr)!==0&&this.invalidateLazyData(this.fs.fstat(i)),i}close(e){return this.fs.close(e),0}read(e,t,r,i){if(i>0){let s=this.lazyBackingForStat(this.fs.fstat(e));s&&(this.reconcileLazyIdentityState(this.fs.identityState()),s=this.lazyBackingForStat(this.fs.fstat(e)),s&&this.guardSynchronousLazyAccess(s.path))}return r!==null?this.fs.readAt(e,t.subarray(0,i),typeof r=="bigint"?bn(r):r):this.fs.read(e,t.subarray(0,i))}write(e,t,r,i){if(r!==null){let o=this.fs.writeAt(e,t.subarray(0,i),typeof r=="bigint"?bn(r):r);return o>0&&this.invalidateLazyData(this.fs.fstat(e)),o}let s=this.fs.write(e,t.subarray(0,i));return s>0&&this.invalidateLazyData(this.fs.fstat(e)),s}append(e,t,r,i){let s=this.fs.append(e,t.subarray(0,r),Oo(i));return s.written>0&&this.invalidateLazyData(this.fs.fstat(e)),s}seek(e,t,r){return this.fs.lseek(e,typeof t=="bigint"?zn(t):t,r)}fstat(e){return this.adaptStatWithLazySize(this.fs.fstat(e))}fpathconf(e,t){let r=this.fstat(e);return kn(r,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}ftruncate(e,t){this.fs.ftruncate(e,t),this.invalidateLazyData(this.fs.fstat(e))}fsync(e){}fchmod(e,t){this.fs.fchmod(e,t)}fchown(e,t,r){this.fs.fchown(e,t,r)}stat(e){return this.adaptStatWithLazySize(this.fs.stat(e))}lstat(e){return this.adaptStatWithLazySize(this.fs.lstat(e))}statfs(e){this.fs.stat(e);let t=this.fs.statfs();return{type:1397114451,bsize:t.blockSize,blocks:t.totalBlocks,bfree:t.freeBlocks,bavail:t.freeBlocks,files:t.totalInodes,ffree:t.freeInodes,fsid:0,namelen:t.maxName,frsize:t.blockSize,flags:0}}pathconf(e,t){let r=this.stat(e);return kn(r,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}mkdir(e,t){this.fs.mkdir(e,t)}rmdir(e){this.fs.rmdir(e)}unlink(e){let t=this.fs.unlink(e),r=n.inodeKey(t.ino,t.generation);if(t.linkCount>1&&(this.lazyFiles.has(r)||this.lazyArchiveInodes.has(r))){this.reconcileLazyIdentityState(this.fs.identityState());return}let i=this.lazyFiles.get(r);i&&(i.paths.delete(e),t.linkCount<=1?this.lazyFiles.delete(r):i.path===e&&(i.path=i.paths.values().next().value));let s=this.lazyArchiveInodes.get(r);if(s){let o=s.entries.get(e);if(t.linkCount<=1){for(let a of s.entries.values())a.ino===t.ino&&a.generation===t.generation&&(a.deleted=!0);this.lazyArchiveInodes.delete(r)}else o&&s.entries.delete(e)}}rename(e,t){let{source:r,replaced:i}=this.fs.rename(e,t);if(i&&i.ino===r.ino&&i.generation===r.generation)return;let s=!1;if(i){let o=n.inodeKey(i.ino,i.generation);i.linkCount>1&&(this.lazyFiles.has(o)||this.lazyArchiveInodes.has(o))&&(this.reconcileLazyIdentityState(this.fs.identityState()),s=!0);let a=this.lazyFiles.get(o);!s&&a&&(a.paths.delete(t),i.linkCount<=1?this.lazyFiles.delete(o):a.path===t&&(a.path=a.paths.values().next().value));let c=this.lazyArchiveInodes.get(o);if(!s&&c){let u=c.entries.get(t);i.linkCount<=1?(u&&(u.deleted=!0),this.lazyArchiveInodes.delete(o)):u&&c.entries.delete(t)}}s||this.rewriteLazyNamespacePaths(r,e,t)}link(e,t){let r=this.fs.link(e,t),i=n.inodeKey(r.ino,r.generation),s=this.lazyFiles.get(i);s&&s.paths.add(t);let o=this.lazyArchiveInodes.get(i);if(o){let a=Array.from(o.entries.values()).find(c=>c.ino===r.ino&&c.generation===r.generation);a&&o.entries.set(t,{...a})}}symlink(e,t){this.fs.symlink(e,t)}readlink(e){return this.fs.readlink(e)}chmod(e,t){this.fs.chmod(e,t)}chown(e,t,r){this.fs.chown(e,t,r)}lchown(e,t,r){this.fs.lchown(e,t,r)}createFileWithOwner(e,t,r,i,s){let o=this.open(e,_s,t);s.length>0&&this.write(o,s,null,s.length),this.close(o),this.chown(e,r,i),this.chmod(e,t)}mkdirWithOwner(e,t,r,i){this.mkdir(e,t),this.chown(e,r,i),this.chmod(e,t)}symlinkWithOwner(e,t,r,i){this.symlink(e,t),this.lchown(t,r,i)}copyPathToFreshFileSystem(e,t,r,i,s){let o=this.lstat(e),a=o.mode&Fe,c=o.mode&W.S_MODE_BITS;if(a===st){e==="/"?(t.chown(e,o.uid,o.gid),t.chmod(e,c)):t.mkdirWithOwner(e,c,o.uid,o.gid);let p=this.opendir(e);try{for(;;){let m=this.readdir(p);if(!m)break;m.name==="."||m.name===".."||this.copyPathToFreshFileSystem(e==="/"?`/${m.name}`:`${e}/${m.name}`,t,r,i,s)}}finally{this.closedir(p)}n.applyTimes(t,e,o);return}let u=o.nlink>1?`${o.dev}:${o.ino}`:null,l=u?s.get(u):void 0;if(l){t.link(l,e);return}if(a===Hr){t.symlinkWithOwner(this.readlink(e),e,o.uid,o.gid),u&&s.set(u,e);return}if(a!==fr)throw new Error(`Unsupported file type while rebasing VFS: ${e}`);if(r.has(e)||i.has(e)){t.createFileWithOwner(e,c,o.uid,o.gid,new Uint8Array(0)),n.applyTimes(t,e,o),u&&s.set(u,e);return}this.copyRegularFileToFreshFileSystem(e,t,o,c),u&&s.set(u,e)}copyRegularFileToFreshFileSystem(e,t,r,i){let s=this.open(e,Su,0),o=null;try{o=t.open(e,_s,i);let a=new Uint8Array(Math.min(wu,Math.max(1,r.size))),c=r.size;for(;c>0;){let u=Math.min(a.byteLength,c),l=this.read(s,a,null,u);if(l<=0)throw new Error(`Unexpected EOF while rebasing VFS file: ${e}`);let d=0;for(;d!e||e==="."||e===".."))throw new Error(`Binary resolver path must be a normalized portable relative path: ${JSON.stringify(n)}`);return n}var dt=new Set(["wasm32","wasm64"]);function je(n){if(dd(n),!n.startsWith("programs/"))return n;let e=n.slice(9),t=e.split("/",1)[0];return dt.has(t)?n:`programs/wasm32/${e}`}function ld(n,e=$(Ii(),"wasm")){let t=je(n),r=[$(e,t)];return n==="kernel.wasm"?r.push($(e,"kandelo-kernel.wasm")):n==="userspace.wasm"?r.push($(e,"wasm_posix_userspace.wasm")):n==="rootfs.vfs"&&r.push($(e,"rootfs.vfs")),r}var sn=class extends Error{constructor(e){super(e),this.name="BinaryNotFoundError"}};function Qs(){let n=[],e=!1;try{let r=ut();e=!0;for(let[i,s]of[["local-binaries",$(r,"local-binaries")],["binaries",$(r,"binaries")]])n.push({label:i,root:s,identity:i==="local-binaries"?"local-generation":"program-cache",allowRegularFileClosure:!1,candidatesFor(o){return[$(s,je(o))]}})}catch{}let t=$(Ii(),"wasm");return n.push({label:"installed package",root:t,identity:"installed-package",allowRegularFileClosure:!e,candidatesFor(r){return ld(r,t)}}),n}function bt(n,e){return new Error(`Invalid package manifest ${n}: ${e}`)}function ce(n){try{return an(n),!0}catch(e){if(e instanceof Error&&"code"in e&&e.code==="ENOENT")return!1;throw e}}function Ws(n,e,t){if(n.length===0||n.startsWith("/")||n.includes("\\")||n.includes("\0")||n.split("/").some(r=>!r||r==="."||r===".."))throw bt(e,`${t} must be a normalized portable relative path`);return n}function nn(n,e,t,r=!0){if(n.length===0||n==="."||n===".."||n.includes("/")||n.includes("\\")||n.includes("\0")||!r&&n.includes("@"))throw bt(e,`${t} must be a safe single path component`);return n}var Gs="kandelo-program-packages-v2",Ce="program-packages.json",Hs=null,fd=null,on=null,Ei=0;function Ri(){return fd??$(Ii(),"wasm",Ce)}function ea(){if(Object.prototype.hasOwnProperty.call(process.env,"WASM_POSIX_DEPS_REGISTRY")){let t=null;return(process.env.WASM_POSIX_DEPS_REGISTRY??"").split(":").filter(Boolean).map(r=>r.startsWith("~/")&&process.env.HOME!==void 0?$(process.env.HOME,r.slice(2)):cn(r)?Ie(r):(t??=ut(),Ie(t,r)))}let n;try{n=$(ut(),"packages","registry")}catch{return null}let e=!1;if(ce(n)){if(!Ye(n).isDirectory())return[n];e=Ys(n,{withFileTypes:!0}).filter(t=>t.isDirectory()||t.isSymbolicLink()).some(t=>ce($(n,t.name,"package.toml")))}return!e&&ta()===null&&ce(Ri())?null:[n]}function ta(){let n;try{n=ut()}catch{return null}if(!gr($(n,"tools","xtask","Cargo.toml"))||!gr($(n,"scripts","dev-shell.sh")))return null;try{let e=Re(Ai()),t=Re(n);return[$(t,"host"),$(t,"scripts")].some(i=>gr(i)&&zi(Re(i),e))?t:null}catch{return null}}function xi(n,e,t){let r=[typeof t.stderr=="string"?t.stderr.trim():"",typeof t.stdout=="string"?t.stdout.trim():"",t.error?.message??""].filter(Boolean).join(` `);return`${n} ${e.join(" ")} failed${t.status===null?"":` with status ${t.status}`}${r?`: -${r}`:""}`}function su(n){let e=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,t=e?"rustc":"bash",r=e?["-vV"]:[$(n,"scripts","dev-shell.sh"),"rustc","-vV"],i=gi(t,r,{cwd:n,encoding:"utf8"});if(i.status!==0)throw new Error(Ai(t,r,i));let s=i.stdout.split(/\r?\n/).find(o=>o.startsWith("host: "))?.slice(6).trim();if(!s)throw new Error(`Could not determine the Rust host target for ${n}`);return s}function _i(n){try{if(tn(n).isFile())return Ie(n)}catch{}throw new Error(`Prepared xtask is not a regular file: ${n}`)}function au(n){let e=process.env.WASM_POSIX_XTASK_BIN;if(e!==void 0){let l=rn(e)?Oe(e):Oe(n,e);return _i(l)}if(Qr?.sourceRepoRoot===n)return _i(Qr.xtaskPath);let t=su(n),r=$(n,"target",t,"release",process.platform==="win32"?"xtask.exe":"xtask"),i=["build","--release","-p","xtask","--target",t,"--quiet"],s=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,o=s?"cargo":"bash",a=s?i:[$(n,"scripts","dev-shell.sh"),"cargo",...i],c=gi(o,a,{cwd:n,encoding:"utf8"});if(c.status!==0)throw new Error(Ai(o,a,c));return Qr={sourceRepoRoot:n,xtaskPath:_i(r)},Qr.xtaskPath}function cu(){let n=Xs();if(n===null)return;let e=Zs();if(e===null)return;if(Ks){Ks(n,e);return}let t=au(n),r=["build-deps","program-index-context-check","--source-repo-root",n],i=gi(t,r,{cwd:n,encoding:"utf8",env:{...process.env,WASM_POSIX_DEPS_REGISTRY:e.join(":")}});if(i.status!==0)throw new Error(`Program package source projection is not current: -${Ai(t,r,i)}`)}function lu(n,e){if(mi>0||!n.some(t=>t.startsWith("programs/")))return e();mi+=1;try{return cu(),e()}finally{mi-=1}}function Ze(n,e){let t=Object.keys(n).sort(),r=[...e].sort();return t.length===r.length&&t.every((i,s)=>i===r[s])}function yi(n){let e;try{e=JSON.parse(st(n,"utf8"))}catch(o){throw new Error(`Invalid program package index ${n}: ${o instanceof Error?o.message:String(o)}`)}if(typeof e!="object"||e===null||!Ze(e,["format","identities","packages"])||e.format!==Ds||typeof e.identities!="object"||e.identities===null||Array.isArray(e.identities)||typeof e.packages!="object"||e.packages===null||Array.isArray(e.packages))throw new Error(`Invalid program package index ${n}: expected ${Ds}`);let t=new Map,r=e.identities;for(let[o,a]of Object.entries(r)){if(Jr(o,n,"identity package name",!1),typeof a!="object"||a===null||!Ze(a,["manifestSha256","cacheKeys"])||typeof a.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(a.manifestSha256)||typeof a.cacheKeys!="object"||a.cacheKeys===null||Array.isArray(a.cacheKeys))throw new Error(`Invalid program package index ${n}: malformed identity ${JSON.stringify(o)}`);let c=a.cacheKeys;if(!Ze(c,["wasm32","wasm64"])||Object.values(c).some(l=>typeof l!="string"||!/^[a-f0-9]{64}$/.test(l)))throw new Error(`Invalid program package index ${n}: identity ${JSON.stringify(o)} has invalid contextual cache keys`);t.set(o,{manifestSha256:a.manifestSha256,cacheKeys:c})}let i=new Map,s=e.packages;for(let[o,a]of Object.entries(s)){if(Jr(o,n,"package name",!1),typeof a!="object"||a===null||!Ze(a,["manifestSha256","arches","cacheKeys","dependencyClosures","members"])||!Array.isArray(a.arches)||typeof a.cacheKeys!="object"||a.cacheKeys===null||Array.isArray(a.cacheKeys)||typeof a.dependencyClosures!="object"||a.dependencyClosures===null||Array.isArray(a.dependencyClosures)||!Array.isArray(a.members)||typeof a.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(a.manifestSha256))throw new Error(`Invalid program package index ${n}: malformed package ${JSON.stringify(o)}`);let c=a.arches;if(c.length===0||new Set(c).size!==c.length||c.some(h=>typeof h!="string"||!ct.has(h)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has invalid arches`);let l=a.cacheKeys;if(!Ze(l,c)||Object.values(l).some(h=>typeof h!="string"||!/^[a-f0-9]{64}$/.test(h)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has invalid cache keys`);let d=a.dependencyClosures;if(!Ze(d,c))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has invalid dependency closure arches`);let u={};for(let h of c){let g=d[h];if(!Array.isArray(g))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has a malformed dependency closure for ${h}`);let _=new Set;u[h]=g.map((y,E)=>{if(typeof y!="object"||y===null||!Ze(y,["packageName","manifestSha256","cacheKey"])||typeof y.packageName!="string"||typeof y.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(y.manifestSha256)||typeof y.cacheKey!="string"||!/^[a-f0-9]{64}$/.test(y.cacheKey))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} dependency ${E+1} for ${h} is malformed`);let A=y;if(Jr(A.packageName,n,`${o} dependency packageName`,!1),A.packageName===o||_.has(A.packageName))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} dependency closure for ${h} must contain unique dependencies other than itself`);_.add(A.packageName);let w=t.get(A.packageName);if(!w||w.manifestSha256!==A.manifestSha256||w.cacheKeys[h]!==A.cacheKey)throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} dependency ${JSON.stringify(A.packageName)} for ${h} does not match the index's authoritative contextual identity`);return A})}let p=a.members.map((h,g)=>{if(typeof h!="object"||h===null||h.kind!=="output"&&h.kind!=="runtime-file"||typeof h.sourceArtifact!="string"||typeof h.mirrorPath!="string")throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} member ${g+1} is malformed`);let _=h,y=_.kind==="output"?["kind","sourceArtifact","mirrorPath","outputName","forkInstrumentation"]:["kind","sourceArtifact","mirrorPath","guestPath","mode"];if(!Ze(_,y))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} member ${g+1} has unknown or missing fields`);if(Ms(_.sourceArtifact,n,`${o} sourceArtifact`),Ms(_.mirrorPath,n,`${o} mirrorPath`),_.kind==="output"){if(typeof _.outputName!="string"||_.forkInstrumentation!=="auto"&&_.forkInstrumentation!=="disabled")throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} output member lacks outputName or forkInstrumentation`);Jr(_.outputName,n,`${o} outputName`)}else if(typeof _.guestPath!="string"||!_.guestPath.startsWith("/")||!Number.isInteger(_.mode)||_.mode<0||_.mode>511)throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} runtime member lacks valid guestPath or mode`);return _});if(p.length===0||new Set(p.map(h=>h.sourceArtifact)).size!==p.length||new Set(p.map(h=>h.mirrorPath)).size!==p.length||p.length===1&&p[0].mirrorPath.includes("/")||p.length>1&&p.some(h=>!h.mirrorPath.startsWith(`${o}/`)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} members are empty, collide, or violate scalar/package-directory layout`);let m=a.manifestSha256,f=t.get(o);if(!f||f.manifestSha256!==m||c.some(h=>f.cacheKeys[h]!==l[h]))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} does not match its contextual package identity`);i.set(o,{manifestSha256:m,arches:c,cacheKeys:l,dependencyClosures:u,members:p})}return{identities:t,packages:i,indexPath:n}}function Ys(n){return JSON.stringify({manifestSha256:n.manifestSha256,arches:n.arches,cacheKeys:Object.fromEntries(n.arches.map(e=>[e,n.cacheKeys[e]])),dependencyClosures:Object.fromEntries(n.arches.map(e=>[e,[...n.dependencyClosures[e]].sort((t,r)=>t.packageNamer.packageName?1:0)])),members:n.members.map(e=>e.kind==="output"?{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,outputName:e.outputName,forkInstrumentation:e.forkInstrumentation}:{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,guestPath:e.guestPath,mode:e.mode})})}function Oi(){let n=wi();return ae(n)?yi(n):null}function uu(n){let e=Oi();if(!e)return null;let t=n.split("/");if(t[0]!=="programs"||!ct.has(t[1]))return null;let r=t[1];if(t.length>=4){let s=t[2];return e.packages.get(s)?.arches.includes(r)?s:null}if(t.length!==3)return null;let i=t[2];for(let[s,o]of e.packages)if(o.arches.includes(r)&&o.members.some(a=>a.kind==="output"&&a.mirrorPath.split("/").at(-1)===i))return s;return null}function Bs(n){let e=uu(n);if(e)throw new Error(`Installed package resolver path ${JSON.stringify(n)} is owned by ${JSON.stringify(e)}, but that package is not selected by the configured program registry`)}function js(){let n=Zs(),e=new Map,t=new Map,r=new Map,i=new Map,s=[];if(n===null){let l=wi();if(!ae(l))return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:s};let d=yi(l);for(let[u,p]of d.identities)e.set(u,{...p,packageName:u,policyPath:`${d.indexPath}#identities.${u}`});for(let[u,p]of d.packages)s.push({packageName:u,projection:p,selected:!0}),r.set(u,{...p,packageName:u,policyPath:`${d.indexPath}#${u}`});return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:s}}let o=new Set,a=null,c=null;for(let l of n){if(!ae(l))continue;if(!Xe(l).isDirectory())throw new Error(`Program registry root is not a directory: ${l}`);let d=$(l,Fe);if(!ae(d))throw new Error(`Program registry ${l} is missing ${Fe}; generate it with xtask build-deps program-index`);let u=yi(d);a??=u.identities,c??=u.packages;let p=Gs(l,{withFileTypes:!0}).filter(m=>m.isDirectory()||m.isSymbolicLink()).sort((m,f)=>m.name.localeCompare(f.name));for(let m of p){let f=m.name,h=$(l,f,"package.toml");if(!ae(h))continue;let g=!1;try{g=Xe(h).isFile()}catch{g=!1}if(!g)continue;let _=u.packages.get(f),y=!o.has(f);if(_&&s.push({packageName:f,projection:_,selected:y}),!y)continue;o.add(f);let E=a.get(f);E?e.set(f,{...E,packageName:f,manifestPath:h,policyPath:h}):t.set(f,h);let A=c.get(f);if(!A){i.set(f,h);continue}r.set(f,{...A,packageName:f,manifestPath:h,policyPath:h})}}return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:s}}function $s(n){if(!n.manifestPath)return;let e;try{e=st(n.manifestPath)}catch(r){throw new Error(`Program package identity cannot verify ${n.manifestPath}: ${r instanceof Error?r.message:String(r)}`)}if(Hs("sha256").update(e).digest("hex")!==n.manifestSha256)throw new Error(`Program package identity is stale for ${n.manifestPath}; regenerate ${Fe}`)}function du(n){if(!n.manifestPath)return;let e;try{e=st(n.manifestPath)}catch(r){throw new Error(`Program package projection cannot verify ${n.manifestPath}: ${r instanceof Error?r.message:String(r)}`)}if(Hs("sha256").update(e).digest("hex")!==n.manifestSha256)throw new Error(`Program package projection is stale for ${n.manifestPath}; regenerate ${Fe}`)}function mr(n){let e=Ii(),t=e.packages.get(n);if(t)return du(t),t;let r=e.unprojectedPackages.get(n);if(r)throw new Error(`Package ${JSON.stringify(n)} is selected at ${r} but is absent from ${Fe}; regenerate the registry projection`);return null}function fu(n,e){let t=n.dependencyClosures[e];if(!t)throw Lt(n.policyPath,`package ${JSON.stringify(n.packageName)} lacks a dependency identity closure for ${e}`);let r=js(),i=r.identities.get(n.packageName);if(!i){let o=r.unidentifiedPackages.get(n.packageName);throw new Error(`Program package ${JSON.stringify(n.packageName)} has no authoritative contextual identity for ${e}${o?` at ${o}`:""}; regenerate ${Fe} with the exact ordered registry roots`)}$s(i);let s=i.cacheKeys[e];if(i.manifestSha256!==n.manifestSha256||s!==n.cacheKeys[e])throw new Error(`Program package ${JSON.stringify(n.packageName)} was projected with manifest ${n.manifestSha256} and cache key ${n.cacheKeys[e]} for ${e}, but the authoritative first-hit registry context at ${i.policyPath} requires manifest ${i.manifestSha256} and cache key ${s??""}. Regenerate this program projection with the exact ordered registry roots; the highest-priority index must carry the complete combined-context projection rather than relying on a lower suffix-context build identity.`);for(let o of t){let a=r.identities.get(o.packageName);if(!a){let l=r.unidentifiedPackages.get(o.packageName);throw l?new Error(`Program package ${JSON.stringify(n.packageName)} was generated against dependency ${JSON.stringify(o.packageName)}, but the first-hit package at ${l} has no contextual identity in ${Fe}`):new Error(`Program package ${JSON.stringify(n.packageName)} was generated against dependency ${JSON.stringify(o.packageName)}, but that dependency is absent from the configured first-hit registry roots`)}$s(a);let c=a.cacheKeys[e];if(a.manifestSha256!==o.manifestSha256||c!==o.cacheKey)throw new Error(`Program package ${JSON.stringify(n.packageName)} has a contextual cache identity mismatch for ${e}: its projection expects dependency ${JSON.stringify(o.packageName)} manifest ${o.manifestSha256} and cache key ${o.cacheKey}, but first-hit selection at ${a.policyPath} provides manifest ${a.manifestSha256} and cache key ${c??""}. Regenerate the program projection with the exact ordered registry roots; the complete highest-priority projection must bind every selected program to the same combined dependency context.`)}}function Ii(){let n=js(),{physicalProgramClaims:e,...t}=n,r={...t,legacyFlatOutputs:new Map,forkInstrumentationDisabledOutputs:new Map},i=[];for(let s of n.packages.values()){let o=s.members.length>1;for(let a of s.arches)for(let c of s.members){let l=i.find(m=>m.arch===a&&(m.path===c.mirrorPath||m.path.startsWith(`${c.mirrorPath}/`)||c.mirrorPath.startsWith(`${m.path}/`)));if(l)throw new Error(`Program resolver paths programs/${a}/${l.path} and programs/${a}/${c.mirrorPath} conflict between selected packages ${JSON.stringify(l.packageName)} and ${JSON.stringify(s.packageName)}`);if(i.push({arch:a,path:c.mirrorPath,packageName:s.packageName}),c.kind!=="output")continue;let d=c.mirrorPath.split("/").at(-1),u=`${a}/${d}`,p=r.legacyFlatOutputs.get(u);p||(p={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},r.legacyFlatOutputs.set(u,p)),o?p.packagePaths.set(`programs/${a}/${c.mirrorPath}`,s.packageName):p.scalarOwners.add(s.packageName),c.forkInstrumentation==="disabled"&&r.forkInstrumentationDisabledOutputs.set(`${a}/${c.mirrorPath}`,s.packageName)}}for(let{packageName:s,projection:o,selected:a}of e)if(!(a&&n.packages.has(s)))for(let c of o.arches)for(let l of o.members){if(l.kind!=="output")continue;let d=l.mirrorPath.split("/").at(-1),u=`${c}/${d}`,p=r.legacyFlatOutputs.get(u);p||(p={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},r.legacyFlatOutputs.set(u,p)),p.shadowedOwners.add(s)}return r}function hu(n){let e=n.split("/");if(e.length!==3||e[0]!=="programs"||!ct.has(e[1]))return null;let t=Ii().legacyFlatOutputs.get(`${e[1]}/${e[2]}`);if(!t)return null;for(let r of t.scalarOwners){let i=mr(r);if(i)return i}for(let r of t.packagePaths.values())mr(r);if(t.packagePaths.size>0)throw new Error(`Legacy flat resolver path ${JSON.stringify(n)} belongs to a multi-member package; use ${[...t.packagePaths.keys()].sort().map(r=>JSON.stringify(r)).join(" or ")}`);for(let r of t.shadowedOwners){let i=mr(r);if(i)return i;throw new Error(`Legacy flat resolver path ${JSON.stringify(n)} is claimed by a lower-root program package ${JSON.stringify(r)}, but its first-hit selected package does not project that program; stale scalar mirror fallback is forbidden`)}return null}function Us(n,e,t){if(!n.arches.includes(e))throw Lt(n.policyPath,`package ${JSON.stringify(n.packageName)} does not declare resolver artifacts for ${e}`);let r=n.cacheKeys[e];if(!r)throw Lt(n.policyPath,`package ${JSON.stringify(n.packageName)} lacks a cache identity for ${e}`);fu(n,e);let i=Ys(n),s=n.members.map(o=>({packageName:n.packageName,relPath:`programs/${e}/${o.mirrorPath}`,sourceArtifact:o.sourceArtifact,cacheKey:r,forkInstrumentation:o.kind==="output"?o.forkInstrumentation??null:null,projectionIdentity:i}));if(!s.some(o=>o.relPath===t))throw Lt(n.policyPath,`resolver path ${JSON.stringify(t)} is not a declared member of package ${JSON.stringify(n.packageName)}`);return{manifestPath:n.policyPath,packageName:n.packageName,members:s}}function pu(n){let e=Ye(n),t=e.split("/");if(t[0]==="programs"&&!tu()&&Oi()===null)throw new Error(`Installed host package is missing wasm/${Fe}; program artifacts cannot be resolved without packaged policy`);if(t.length===3){let o=hu(e);return o?Us(o,t[1],e):(Bs(e),null)}if(t.length<4||t[0]!=="programs"||!ct.has(t[1]))return null;let r=t[1],i=t[2],s=mr(i);return s?Us(s,r,e):(Bs(e),null)}function mu(n){let e=Ye(n);for(let t of["programs/wasm32/","programs/wasm64/"])if(e.startsWith(t))return e.slice(t.length);return null}function _u(n){let e=Ye(n);for(let t of ct){let r=`programs/${t}/`;if(e.startsWith(r)){let i=Ii().forkInstrumentationDisabledOutputs.get(`${t}/${e.slice(r.length)}`);return i?mr(i)!==null:!1}}return!1}function yu(n){let e=Ye(n);if(e==="kernel.wasm")return Qi;let t=mu(e);if(t&&t.endsWith(".wasm"))return Ql}function gu(n,e,t){if(!n.endsWith(".wasm"))return!1;try{let r=st(n),i=r.buffer.slice(r.byteOffset,r.byteOffset+r.byteLength),s=t===void 0?_u(e):t==="disabled";return uo(i,{expectedAbi:43,requiredExports:yu(e),requireForkInstrumentation:s?!1:void 0,forbidForkInstrumentation:s}).length>0}catch{return!0}}function Eu(n){if(!n.endsWith(".vfs")&&!n.endsWith(".vfs.zst"))return!1;try{let t=Yr.readImageMetadata(st(n))?.kernelAbi;return t!==void 0&&t!==43}catch{return!0}}function xi(n,e,t){return gu(n,e,t)||Eu(n)}function Js(n,e,t){let r=n.filter(ae);return r.length===0?null:r.find(i=>{try{return Xe(i).isFile()&&!xi(i,e,t)}catch{return!1}})??null}function Qs(n,e,t){try{if(!tn(n).isSymbolicLink())return n;let i=Ie(n);if(!Xe(i).isFile()||xi(i,e,t))throw new Error("canonical target is not an accepted regular file");if(Ye(e).startsWith("programs/")&&Su(i))throw new Error("resolver-owned program generation has no matching selected package projection");return i}catch(r){throw new Error(`Binary changed or became invalid while pinning ${e}: ${r instanceof Error?r.message:String(r)}`)}}function Su(n){let e=[Vs()];try{e.push($(at(),"local-binaries",".kandelo-local-generations"))}catch{}return e.some(t=>{try{return ae(t)&&Ri(Ie(t),n)}catch{return!1}})}function Ri(n,e){let t=Yl(n,e);return t===""||t!==".."&&!t.startsWith(`..${jl}`)&&!rn(t)}function wu(n,e){let t=e.split("/"),r=n;for(let i=0;ia.packageName!==s))return"declared package members do not share a valid program namespace";if(!Xe(e).isDirectory())return"shared package generation root is not a directory";let o=t[0].cacheKey;if(!/^[a-f0-9]{64}$/.test(o)||t.some(a=>a.cacheKey!==o))return"declared package members do not share one valid cache identity";if(n.identity==="local-generation"){let a=$(n.root,".kandelo-local-generations",i,s,o);if(!ae(a))return"local mirror targets are not one direct immutable local generation";let c=Ie(a);return _r(e)===c?null:"local mirror targets are not one direct immutable local generation"}if(n.identity==="program-cache"){let a=Vs();if(!ae(a))return"fetched mirror targets are not one canonical program-cache generation";let c=Ie(a),l=Xl(e),d=l.startsWith(`${s}-`)&&new RegExp(`-rev[0-9]+-${i}-${o}$`).test(l);return _r(e)===c&&d?null:"fetched mirror targets are not one canonical program-cache generation"}return"installed-package symlink closures are not an immutable installed identity"}function Ou(n,e,t){if(e.length!==t.length)return{failure:"internal member/path count mismatch"};try{let r=e.map(l=>{let d=tn(l);return d.isSymbolicLink()?"symlink":d.isFile()?"file":"other"});if(r.includes("other"))return{failure:"a selected mirror member is neither a regular file nor a symlink"};let i=r.every(l=>l==="symlink"),s=r.every(l=>l==="file");if(!i&&!s)return{failure:"regular files and symlinks cannot share one package identity"};if(s){if(!n.allowRegularFileClosure)return{failure:"a mutable source-checkout wasm tree is not an installed package identity"};let l=t[0].packageName,d=t[0].projectionIdentity;if(t.some(h=>h.packageName!==l||h.projectionIdentity!==d))return{failure:"declared members do not share one selected package projection"};let p=Oi()?.packages.get(l);if(!p||Ys(p)!==d)return{failure:"installed bytes do not match the selected package projection"};let m=Ie(n.root),f=[];for(let h of e){let g=Ie(h);if(!Ri(m,g)||!Xe(g).isFile())return{failure:"an installed-package member escapes its immutable wasm tree"};f.push(g)}return{paths:f}}let o=null,a=[];for(let l=0;lIu(n))}function Iu(n){let e=Ye(n),t=pu(e);if(t){let o=xu(t.members.map(a=>a.relPath),t.members);if(o)return o[t.members.findIndex(a=>a.relPath===e)];throw new en(`Package artifacts not found for ${t.packageName}: ${e}`)}let r=[],i=[];for(let o of qs())for(let a of o.candidatesFor(n))r.push(a),i.push(a);let s=Js(i,n);if(s)return Qs(s,n);throw i.some(ae)?new Error(`Binary exists but was rejected by artifact policy: ${n} +${r}`:""}`}function hd(n){let e=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,t=e?"rustc":"bash",r=e?["-vV"]:[$(n,"scripts","dev-shell.sh"),"rustc","-vV"],i=Oi(t,r,{cwd:n,encoding:"utf8"});if(i.status!==0)throw new Error(xi(t,r,i));let s=i.stdout.split(/\r?\n/).find(o=>o.startsWith("host: "))?.slice(6).trim();if(!s)throw new Error(`Could not determine the Rust host target for ${n}`);return s}function Si(n){try{if(an(n).isFile())return Re(n)}catch{}throw new Error(`Prepared xtask is not a regular file: ${n}`)}function pd(n){let e=process.env.WASM_POSIX_XTASK_BIN;if(e!==void 0){let u=cn(e)?Ie(e):Ie(n,e);return Si(u)}if(on?.sourceRepoRoot===n)return Si(on.xtaskPath);let t=hd(n),r=$(n,"target",t,"release",process.platform==="win32"?"xtask.exe":"xtask"),i=["build","--release","-p","xtask","--target",t,"--quiet"],s=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,o=s?"cargo":"bash",a=s?i:[$(n,"scripts","dev-shell.sh"),"cargo",...i],c=Oi(o,a,{cwd:n,encoding:"utf8"});if(c.status!==0)throw new Error(xi(o,a,c));return on={sourceRepoRoot:n,xtaskPath:Si(r)},on.xtaskPath}function md(){let n=ta();if(n===null)return;let e=ea();if(e===null)return;if(Hs){Hs(n,e);return}let t=pd(n),r=["build-deps","program-index-context-check","--source-repo-root",n],i=Oi(t,r,{cwd:n,encoding:"utf8",env:{...process.env,WASM_POSIX_DEPS_REGISTRY:e.join(":")}});if(i.status!==0)throw new Error(`Program package source projection is not current: +${xi(t,r,i)}`)}function _d(n,e){if(Ei>0||!n.some(t=>t.startsWith("programs/")))return e();Ei+=1;try{return md(),e()}finally{Ei-=1}}function Xe(n,e){let t=Object.keys(n).sort(),r=[...e].sort();return t.length===r.length&&t.every((i,s)=>i===r[s])}function wi(n){let e;try{e=JSON.parse(ct(n,"utf8"))}catch(o){throw new Error(`Invalid program package index ${n}: ${o instanceof Error?o.message:String(o)}`)}if(typeof e!="object"||e===null||!Xe(e,["format","identities","packages"])||e.format!==Gs||typeof e.identities!="object"||e.identities===null||Array.isArray(e.identities)||typeof e.packages!="object"||e.packages===null||Array.isArray(e.packages))throw new Error(`Invalid program package index ${n}: expected ${Gs}`);let t=new Map,r=e.identities;for(let[o,a]of Object.entries(r)){if(nn(o,n,"identity package name",!1),typeof a!="object"||a===null||!Xe(a,["manifestSha256","cacheKeys"])||typeof a.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(a.manifestSha256)||typeof a.cacheKeys!="object"||a.cacheKeys===null||Array.isArray(a.cacheKeys))throw new Error(`Invalid program package index ${n}: malformed identity ${JSON.stringify(o)}`);let c=a.cacheKeys;if(!Xe(c,["wasm32","wasm64"])||Object.values(c).some(u=>typeof u!="string"||!/^[a-f0-9]{64}$/.test(u)))throw new Error(`Invalid program package index ${n}: identity ${JSON.stringify(o)} has invalid contextual cache keys`);t.set(o,{manifestSha256:a.manifestSha256,cacheKeys:c})}let i=new Map,s=e.packages;for(let[o,a]of Object.entries(s)){if(nn(o,n,"package name",!1),typeof a!="object"||a===null||!Xe(a,["manifestSha256","arches","cacheKeys","dependencyClosures","members"])||!Array.isArray(a.arches)||typeof a.cacheKeys!="object"||a.cacheKeys===null||Array.isArray(a.cacheKeys)||typeof a.dependencyClosures!="object"||a.dependencyClosures===null||Array.isArray(a.dependencyClosures)||!Array.isArray(a.members)||typeof a.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(a.manifestSha256))throw new Error(`Invalid program package index ${n}: malformed package ${JSON.stringify(o)}`);let c=a.arches;if(c.length===0||new Set(c).size!==c.length||c.some(h=>typeof h!="string"||!dt.has(h)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has invalid arches`);let u=a.cacheKeys;if(!Xe(u,c)||Object.values(u).some(h=>typeof h!="string"||!/^[a-f0-9]{64}$/.test(h)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has invalid cache keys`);let l=a.dependencyClosures;if(!Xe(l,c))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has invalid dependency closure arches`);let d={};for(let h of c){let g=l[h];if(!Array.isArray(g))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has a malformed dependency closure for ${h}`);let _=new Set;d[h]=g.map((y,E)=>{if(typeof y!="object"||y===null||!Xe(y,["packageName","manifestSha256","cacheKey"])||typeof y.packageName!="string"||typeof y.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(y.manifestSha256)||typeof y.cacheKey!="string"||!/^[a-f0-9]{64}$/.test(y.cacheKey))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} dependency ${E+1} for ${h} is malformed`);let O=y;if(nn(O.packageName,n,`${o} dependency packageName`,!1),O.packageName===o||_.has(O.packageName))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} dependency closure for ${h} must contain unique dependencies other than itself`);_.add(O.packageName);let w=t.get(O.packageName);if(!w||w.manifestSha256!==O.manifestSha256||w.cacheKeys[h]!==O.cacheKey)throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} dependency ${JSON.stringify(O.packageName)} for ${h} does not match the index's authoritative contextual identity`);return O})}let p=a.members.map((h,g)=>{if(typeof h!="object"||h===null||h.kind!=="output"&&h.kind!=="runtime-file"||typeof h.sourceArtifact!="string"||typeof h.mirrorPath!="string")throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} member ${g+1} is malformed`);let _=h,y=_.kind==="output"?["kind","sourceArtifact","mirrorPath","outputName","forkInstrumentation"]:["kind","sourceArtifact","mirrorPath","guestPath","mode"];if(!Xe(_,y))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} member ${g+1} has unknown or missing fields`);if(Ws(_.sourceArtifact,n,`${o} sourceArtifact`),Ws(_.mirrorPath,n,`${o} mirrorPath`),_.kind==="output"){if(typeof _.outputName!="string"||_.forkInstrumentation!=="auto"&&_.forkInstrumentation!=="disabled")throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} output member lacks outputName or forkInstrumentation`);nn(_.outputName,n,`${o} outputName`)}else if(typeof _.guestPath!="string"||!_.guestPath.startsWith("/")||!Number.isInteger(_.mode)||_.mode<0||_.mode>511)throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} runtime member lacks valid guestPath or mode`);return _});if(p.length===0||new Set(p.map(h=>h.sourceArtifact)).size!==p.length||new Set(p.map(h=>h.mirrorPath)).size!==p.length||p.length===1&&p[0].mirrorPath.includes("/")||p.length>1&&p.some(h=>!h.mirrorPath.startsWith(`${o}/`)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} members are empty, collide, or violate scalar/package-directory layout`);let m=a.manifestSha256,f=t.get(o);if(!f||f.manifestSha256!==m||c.some(h=>f.cacheKeys[h]!==u[h]))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} does not match its contextual package identity`);i.set(o,{manifestSha256:m,arches:c,cacheKeys:u,dependencyClosures:d,members:p})}return{identities:t,packages:i,indexPath:n}}function ra(n){return JSON.stringify({manifestSha256:n.manifestSha256,arches:n.arches,cacheKeys:Object.fromEntries(n.arches.map(e=>[e,n.cacheKeys[e]])),dependencyClosures:Object.fromEntries(n.arches.map(e=>[e,[...n.dependencyClosures[e]].sort((t,r)=>t.packageNamer.packageName?1:0)])),members:n.members.map(e=>e.kind==="output"?{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,outputName:e.outputName,forkInstrumentation:e.forkInstrumentation}:{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,guestPath:e.guestPath,mode:e.mode})})}function Ti(){let n=Ri();return ce(n)?wi(n):null}function yd(n){let e=Ti();if(!e)return null;let t=n.split("/");if(t[0]!=="programs"||!dt.has(t[1]))return null;let r=t[1];if(t.length>=4){let s=t[2];return e.packages.get(s)?.arches.includes(r)?s:null}if(t.length!==3)return null;let i=t[2];for(let[s,o]of e.packages)if(o.arches.includes(r)&&o.members.some(a=>a.kind==="output"&&a.mirrorPath.split("/").at(-1)===i))return s;return null}function Vs(n){let e=yd(n);if(e)throw new Error(`Installed package resolver path ${JSON.stringify(n)} is owned by ${JSON.stringify(e)}, but that package is not selected by the configured program registry`)}function na(){let n=ea(),e=new Map,t=new Map,r=new Map,i=new Map,s=[];if(n===null){let u=Ri();if(!ce(u))return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:s};let l=wi(u);for(let[d,p]of l.identities)e.set(d,{...p,packageName:d,policyPath:`${l.indexPath}#identities.${d}`});for(let[d,p]of l.packages)s.push({packageName:d,projection:p,selected:!0}),r.set(d,{...p,packageName:d,policyPath:`${l.indexPath}#${d}`});return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:s}}let o=new Set,a=null,c=null;for(let u of n){if(!ce(u))continue;if(!Ye(u).isDirectory())throw new Error(`Program registry root is not a directory: ${u}`);let l=$(u,Ce);if(!ce(l))throw new Error(`Program registry ${u} is missing ${Ce}; generate it with xtask build-deps program-index`);let d=wi(l);a??=d.identities,c??=d.packages;let p=Ys(u,{withFileTypes:!0}).filter(m=>m.isDirectory()||m.isSymbolicLink()).sort((m,f)=>m.name.localeCompare(f.name));for(let m of p){let f=m.name,h=$(u,f,"package.toml");if(!ce(h))continue;let g=!1;try{g=Ye(h).isFile()}catch{g=!1}if(!g)continue;let _=d.packages.get(f),y=!o.has(f);if(_&&s.push({packageName:f,projection:_,selected:y}),!y)continue;o.add(f);let E=a.get(f);E?e.set(f,{...E,packageName:f,manifestPath:h,policyPath:h}):t.set(f,h);let O=c.get(f);if(!O){i.set(f,h);continue}r.set(f,{...O,packageName:f,manifestPath:h,policyPath:h})}}return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:s}}function qs(n){if(!n.manifestPath)return;let e;try{e=ct(n.manifestPath)}catch(r){throw new Error(`Program package identity cannot verify ${n.manifestPath}: ${r instanceof Error?r.message:String(r)}`)}if(js("sha256").update(e).digest("hex")!==n.manifestSha256)throw new Error(`Program package identity is stale for ${n.manifestPath}; regenerate ${Ce}`)}function gd(n){if(!n.manifestPath)return;let e;try{e=ct(n.manifestPath)}catch(r){throw new Error(`Program package projection cannot verify ${n.manifestPath}: ${r instanceof Error?r.message:String(r)}`)}if(js("sha256").update(e).digest("hex")!==n.manifestSha256)throw new Error(`Program package projection is stale for ${n.manifestPath}; regenerate ${Ce}`)}function Er(n){let e=vi(),t=e.packages.get(n);if(t)return gd(t),t;let r=e.unprojectedPackages.get(n);if(r)throw new Error(`Package ${JSON.stringify(n)} is selected at ${r} but is absent from ${Ce}; regenerate the registry projection`);return null}function Ed(n,e){let t=n.dependencyClosures[e];if(!t)throw bt(n.policyPath,`package ${JSON.stringify(n.packageName)} lacks a dependency identity closure for ${e}`);let r=na(),i=r.identities.get(n.packageName);if(!i){let o=r.unidentifiedPackages.get(n.packageName);throw new Error(`Program package ${JSON.stringify(n.packageName)} has no authoritative contextual identity for ${e}${o?` at ${o}`:""}; regenerate ${Ce} with the exact ordered registry roots`)}qs(i);let s=i.cacheKeys[e];if(i.manifestSha256!==n.manifestSha256||s!==n.cacheKeys[e])throw new Error(`Program package ${JSON.stringify(n.packageName)} was projected with manifest ${n.manifestSha256} and cache key ${n.cacheKeys[e]} for ${e}, but the authoritative first-hit registry context at ${i.policyPath} requires manifest ${i.manifestSha256} and cache key ${s??""}. Regenerate this program projection with the exact ordered registry roots; the highest-priority index must carry the complete combined-context projection rather than relying on a lower suffix-context build identity.`);for(let o of t){let a=r.identities.get(o.packageName);if(!a){let u=r.unidentifiedPackages.get(o.packageName);throw u?new Error(`Program package ${JSON.stringify(n.packageName)} was generated against dependency ${JSON.stringify(o.packageName)}, but the first-hit package at ${u} has no contextual identity in ${Ce}`):new Error(`Program package ${JSON.stringify(n.packageName)} was generated against dependency ${JSON.stringify(o.packageName)}, but that dependency is absent from the configured first-hit registry roots`)}qs(a);let c=a.cacheKeys[e];if(a.manifestSha256!==o.manifestSha256||c!==o.cacheKey)throw new Error(`Program package ${JSON.stringify(n.packageName)} has a contextual cache identity mismatch for ${e}: its projection expects dependency ${JSON.stringify(o.packageName)} manifest ${o.manifestSha256} and cache key ${o.cacheKey}, but first-hit selection at ${a.policyPath} provides manifest ${a.manifestSha256} and cache key ${c??""}. Regenerate the program projection with the exact ordered registry roots; the complete highest-priority projection must bind every selected program to the same combined dependency context.`)}}function vi(){let n=na(),{physicalProgramClaims:e,...t}=n,r={...t,legacyFlatOutputs:new Map,forkInstrumentationDisabledOutputs:new Map},i=[];for(let s of n.packages.values()){let o=s.members.length>1;for(let a of s.arches)for(let c of s.members){let u=i.find(m=>m.arch===a&&(m.path===c.mirrorPath||m.path.startsWith(`${c.mirrorPath}/`)||c.mirrorPath.startsWith(`${m.path}/`)));if(u)throw new Error(`Program resolver paths programs/${a}/${u.path} and programs/${a}/${c.mirrorPath} conflict between selected packages ${JSON.stringify(u.packageName)} and ${JSON.stringify(s.packageName)}`);if(i.push({arch:a,path:c.mirrorPath,packageName:s.packageName}),c.kind!=="output")continue;let l=c.mirrorPath.split("/").at(-1),d=`${a}/${l}`,p=r.legacyFlatOutputs.get(d);p||(p={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},r.legacyFlatOutputs.set(d,p)),o?p.packagePaths.set(`programs/${a}/${c.mirrorPath}`,s.packageName):p.scalarOwners.add(s.packageName),c.forkInstrumentation==="disabled"&&r.forkInstrumentationDisabledOutputs.set(`${a}/${c.mirrorPath}`,s.packageName)}}for(let{packageName:s,projection:o,selected:a}of e)if(!(a&&n.packages.has(s)))for(let c of o.arches)for(let u of o.members){if(u.kind!=="output")continue;let l=u.mirrorPath.split("/").at(-1),d=`${c}/${l}`,p=r.legacyFlatOutputs.get(d);p||(p={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},r.legacyFlatOutputs.set(d,p)),p.shadowedOwners.add(s)}return r}function Sd(n){let e=n.split("/");if(e.length!==3||e[0]!=="programs"||!dt.has(e[1]))return null;let t=vi().legacyFlatOutputs.get(`${e[1]}/${e[2]}`);if(!t)return null;for(let r of t.scalarOwners){let i=Er(r);if(i)return i}for(let r of t.packagePaths.values())Er(r);if(t.packagePaths.size>0)throw new Error(`Legacy flat resolver path ${JSON.stringify(n)} belongs to a multi-member package; use ${[...t.packagePaths.keys()].sort().map(r=>JSON.stringify(r)).join(" or ")}`);for(let r of t.shadowedOwners){let i=Er(r);if(i)return i;throw new Error(`Legacy flat resolver path ${JSON.stringify(n)} is claimed by a lower-root program package ${JSON.stringify(r)}, but its first-hit selected package does not project that program; stale scalar mirror fallback is forbidden`)}return null}function Zs(n,e,t){if(!n.arches.includes(e))throw bt(n.policyPath,`package ${JSON.stringify(n.packageName)} does not declare resolver artifacts for ${e}`);let r=n.cacheKeys[e];if(!r)throw bt(n.policyPath,`package ${JSON.stringify(n.packageName)} lacks a cache identity for ${e}`);Ed(n,e);let i=ra(n),s=n.members.map(o=>({packageName:n.packageName,relPath:`programs/${e}/${o.mirrorPath}`,sourceArtifact:o.sourceArtifact,cacheKey:r,forkInstrumentation:o.kind==="output"?o.forkInstrumentation??null:null,projectionIdentity:i}));if(!s.some(o=>o.relPath===t))throw bt(n.policyPath,`resolver path ${JSON.stringify(t)} is not a declared member of package ${JSON.stringify(n.packageName)}`);return{manifestPath:n.policyPath,packageName:n.packageName,members:s}}function wd(n){let e=je(n),t=e.split("/");if(t[0]==="programs"&&!cd()&&Ti()===null)throw new Error(`Installed host package is missing wasm/${Ce}; program artifacts cannot be resolved without packaged policy`);if(t.length===3){let o=Sd(e);return o?Zs(o,t[1],e):(Vs(e),null)}if(t.length<4||t[0]!=="programs"||!dt.has(t[1]))return null;let r=t[1],i=t[2],s=Er(i);return s?Zs(s,r,e):(Vs(e),null)}function Od(n){let e=je(n);for(let t of["programs/wasm32/","programs/wasm64/"])if(e.startsWith(t))return e.slice(t.length);return null}function Ad(n){let e=je(n);for(let t of dt){let r=`programs/${t}/`;if(e.startsWith(r)){let i=vi().forkInstrumentationDisabledOutputs.get(`${t}/${e.slice(r.length)}`);return i?Er(i)!==null:!1}}return!1}function Id(n){let e=je(n);if(e==="kernel.wasm")return no;let t=Od(e);if(t&&t.endsWith(".wasm"))return sd}function Rd(n,e,t){if(!n.endsWith(".wasm"))return!1;try{let r=ct(n),i=r.buffer.slice(r.byteOffset,r.byteOffset+r.byteLength),s=t===void 0?Ad(e):t==="disabled";return _o(i,{expectedAbi:43,requiredExports:Id(e),requireForkInstrumentation:s?!1:void 0,forbidForkInstrumentation:s}).length>0}catch{return!0}}function xd(n){if(!n.endsWith(".vfs")&&!n.endsWith(".vfs.zst"))return!1;try{let t=tn.readImageMetadata(ct(n))?.kernelAbi;return t!==void 0&&t!==43}catch{return!0}}function Li(n,e,t){return Rd(n,e,t)||xd(n)}function ia(n,e,t){let r=n.filter(ce);return r.length===0?null:r.find(i=>{try{return Ye(i).isFile()&&!Li(i,e,t)}catch{return!1}})??null}function oa(n,e,t){try{if(!an(n).isSymbolicLink())return n;let i=Re(n);if(!Ye(i).isFile()||Li(i,e,t))throw new Error("canonical target is not an accepted regular file");if(je(e).startsWith("programs/")&&Td(i))throw new Error("resolver-owned program generation has no matching selected package projection");return i}catch(r){throw new Error(`Binary changed or became invalid while pinning ${e}: ${r instanceof Error?r.message:String(r)}`)}}function Td(n){let e=[Js()];try{e.push($(ut(),"local-binaries",".kandelo-local-generations"))}catch{}return e.some(t=>{try{return ce(t)&&zi(Re(t),n)}catch{return!1}})}function zi(n,e){let t=nd(n,e);return t===""||t!==".."&&!t.startsWith(`..${id}`)&&!cn(t)}function vd(n,e){let t=e.split("/"),r=n;for(let i=0;ia.packageName!==s))return"declared package members do not share a valid program namespace";if(!Ye(e).isDirectory())return"shared package generation root is not a directory";let o=t[0].cacheKey;if(!/^[a-f0-9]{64}$/.test(o)||t.some(a=>a.cacheKey!==o))return"declared package members do not share one valid cache identity";if(n.identity==="local-generation"){let a=$(n.root,".kandelo-local-generations",i,s,o);if(!ce(a))return"local mirror targets are not one direct immutable local generation";let c=Re(a);return Sr(e)===c?null:"local mirror targets are not one direct immutable local generation"}if(n.identity==="program-cache"){let a=Js();if(!ce(a))return"fetched mirror targets are not one canonical program-cache generation";let c=Re(a),u=rd(e),l=u.startsWith(`${s}-`)&&new RegExp(`-rev[0-9]+-${i}-${o}$`).test(u);return Sr(e)===c&&l?null:"fetched mirror targets are not one canonical program-cache generation"}return"installed-package symlink closures are not an immutable installed identity"}function zd(n,e,t){if(e.length!==t.length)return{failure:"internal member/path count mismatch"};try{let r=e.map(u=>{let l=an(u);return l.isSymbolicLink()?"symlink":l.isFile()?"file":"other"});if(r.includes("other"))return{failure:"a selected mirror member is neither a regular file nor a symlink"};let i=r.every(u=>u==="symlink"),s=r.every(u=>u==="file");if(!i&&!s)return{failure:"regular files and symlinks cannot share one package identity"};if(s){if(!n.allowRegularFileClosure)return{failure:"a mutable source-checkout wasm tree is not an installed package identity"};let u=t[0].packageName,l=t[0].projectionIdentity;if(t.some(h=>h.packageName!==u||h.projectionIdentity!==l))return{failure:"declared members do not share one selected package projection"};let p=Ti()?.packages.get(u);if(!p||ra(p)!==l)return{failure:"installed bytes do not match the selected package projection"};let m=Re(n.root),f=[];for(let h of e){let g=Re(h);if(!zi(m,g)||!Ye(g).isFile())return{failure:"an installed-package member escapes its immutable wasm tree"};f.push(g)}return{paths:f}}let o=null,a=[];for(let u=0;ubd(n))}function bd(n){let e=je(n),t=wd(e);if(t){let o=Pd(t.members.map(a=>a.relPath),t.members);if(o)return o[t.members.findIndex(a=>a.relPath===e)];throw new sn(`Package artifacts not found for ${t.packageName}: ${e}`)}let r=[],i=[];for(let o of Qs())for(let a of o.candidatesFor(n))r.push(a),i.push(a);let s=ia(i,n);if(s)return oa(s,n);throw i.some(ce)?new Error(`Binary exists but was rejected by artifact policy: ${n} `+r.map(o=>` checked: ${o}`).join(` -`)):new en(`Binary not found: ${n} +`)):new sn(`Binary not found: ${n} `+r.map(o=>` checked: ${o}`).join(` `)+` - Run scripts/fetch-binaries.sh, place a file at local-binaries/${e}, or install a package that includes wasm/${n}.`)}function xu(n,e){if(n.length===0)return[];let t=!1,r=[];for(let i of qs()){let s=[],o=[];if(e){let[a,c,l]=e[0].relPath.split("/");a==="programs"&&c&&l&&(t||=ae($(i.root,a,c,l)))}for(let[a,c]of n.entries()){let l=i.candidatesFor(c),d=l.filter(ae);t||=d.length>0;let u=Js(l,c,e?.[a]?.forkInstrumentation);u?s.push(u):d.length>0?o.push(`${c} (rejected by artifact policy)`):o.push(`${c} (missing)`)}if(o.length===0&&e){let a=Ou(i,s,e);if("failure"in a)o.push(`shared package identity rejected: ${a.failure}`);else{let c=a.paths.flatMap((l,d)=>xi(l,n[d],e[d].forkInstrumentation)?[n[d]]:[]);if(c.length>0)o.push(`pinned package generation rejected by artifact policy: ${c.join(", ")}`);else return a.paths}}if(o.length===0)return s.map((a,c)=>Qs(a,n[c],e?.[c]?.forkInstrumentation));r.push(` ${i.label} (${i.root}): ${o.join(", ")}`)}if(!t)return null;throw new Error(`Package artifact closure is incomplete: no single provenance tier contains every accepted artifact, and tiers will not be mixed. + Run scripts/fetch-binaries.sh, place a file at local-binaries/${e}, or install a package that includes wasm/${n}.`)}function Pd(n,e){if(n.length===0)return[];let t=!1,r=[];for(let i of Qs()){let s=[],o=[];if(e){let[a,c,u]=e[0].relPath.split("/");a==="programs"&&c&&u&&(t||=ce($(i.root,a,c,u)))}for(let[a,c]of n.entries()){let u=i.candidatesFor(c),l=u.filter(ce);t||=l.length>0;let d=ia(u,c,e?.[a]?.forkInstrumentation);d?s.push(d):l.length>0?o.push(`${c} (rejected by artifact policy)`):o.push(`${c} (missing)`)}if(o.length===0&&e){let a=zd(i,s,e);if("failure"in a)o.push(`shared package identity rejected: ${a.failure}`);else{let c=a.paths.flatMap((u,l)=>Li(u,n[l],e[l].forkInstrumentation)?[n[l]]:[]);if(c.length>0)o.push(`pinned package generation rejected by artifact policy: ${c.join(", ")}`);else return a.paths}}if(o.length===0)return s.map((a,c)=>oa(a,n[c],e?.[c]?.forkInstrumentation));r.push(` ${i.label} (${i.root}): ${o.join(", ")}`)}if(!t)return null;throw new Error(`Package artifact closure is incomplete: no single provenance tier contains every accepted artifact, and tiers will not be mixed. `+r.join(` -`))}var[ta,...Ru]=process.argv.slice(2);(!ta||Ru.length>0)&&(console.error("usage: scripts/resolve-binary.sh "),process.exit(2));try{process.stdout.write(`${ea(ta)} +`))}var[aa,...kd]=process.argv.slice(2);(!aa||kd.length>0)&&(console.error("usage: scripts/resolve-binary.sh "),process.exit(2));try{process.stdout.write(`${sa(aa)} `)}catch(n){console.error(n instanceof Error?n.message:String(n)),process.exit(1)} diff --git a/tests/package-system/build-input-import-closure.test.ts b/tests/package-system/build-input-import-closure.test.ts index 9d4347da3b..6b2d1ca04f 100644 --- a/tests/package-system/build-input-import-closure.test.ts +++ b/tests/package-system/build-input-import-closure.test.ts @@ -143,22 +143,26 @@ describe("package build input import closure", () => { /materializeWordPressSqlitePlugin\(\s*fs,\s*inputs\.sqliteDirectory,\s*\)/, ); - for (const localDemoScript of [ - "packages/registry/wordpress/demo/build.sh", - "packages/registry/wordpress/demo/run.sh", - ]) { - const executableLines = readFileSync( - join(repoRoot, localDemoScript), - "utf8", - ) - .split(/\r?\n/) - .filter( - (line) => line.trim() !== "" && !line.trimStart().startsWith("#"), - ); - expect(executableLines.join("\n")).toContain( - 'bash "$SCRIPT_DIR/../setup.sh"', - ); - } + const localBuildScript = readFileSync( + join(repoRoot, "packages/registry/wordpress/demo/build.sh"), + "utf8", + ); + expect(localBuildScript).toContain('bash "$SCRIPT_DIR/../setup.sh"'); + + const localRunScript = readFileSync( + join(repoRoot, "packages/registry/wordpress/demo/run.sh"), + "utf8", + ); + // WHY: the Node and browser demos now consume the same image-owned dinit + // service topology. Running the demo must resolve or build that VFS image, + // not silently reconstruct the former loose local WordPress tree. + expect(localRunScript).not.toContain("../setup.sh"); + expect(localRunScript).toContain( + 'scripts/resolve-binary.sh" programs/wordpress.vfs.zst', + ); + expect(localRunScript).toContain( + 'bash "$REPO_ROOT/run.sh" build wp-vfs', + ); }); for (const packageName of packages) { diff --git a/tests/package-system/installed-host-package.test.ts b/tests/package-system/installed-host-package.test.ts index cd31b9736e..811afd1e0e 100644 --- a/tests/package-system/installed-host-package.test.ts +++ b/tests/package-system/installed-host-package.test.ts @@ -25,13 +25,40 @@ afterAll(() => { describe("installed host package binary policy", () => { it("binds installed scalar and multi-member bytes to packaged projection identity", () => { - execFileSync("npm", ["--prefix", "host", "run", "build"], { - cwd: repoRoot, + const root = mkdtempSync(join(tmpdir(), "kandelo-packed-host-")); + fixtureRoots.push(root); + const isolatedRepo = join(root, "source"); + const isolatedHost = join(isolatedRepo, "host"); + mkdirSync(isolatedHost, { recursive: true }); + cpSync(join(repoRoot, "host", "src"), join(isolatedHost, "src"), { + recursive: true, + }); + for (const file of [ + "package-lock.json", + "package.json", + "tsconfig.json", + "tsup.config.ts", + ]) { + cpSync(join(repoRoot, "host", file), join(isolatedHost, file)); + } + symlinkSync( + join(repoRoot, "host", "node_modules"), + join(isolatedHost, "node_modules"), + "dir", + ); + symlinkSync( + join(repoRoot, "packages"), + join(isolatedRepo, "packages"), + "dir", + ); + // WHY: tsup cleans its output directory before rebuilding. Vitest runs + // files concurrently, so building in the checkout could temporarily + // remove the worker entry used by an unrelated live Kandelo machine. + execFileSync("npm", ["run", "build"], { + cwd: isolatedHost, stdio: "pipe", }); - const root = mkdtempSync(join(tmpdir(), "kandelo-packed-host-")); - fixtureRoots.push(root); const staging = join(root, "staging"); const wasmRoot = join(staging, "wasm"); const multiName = "packed-runtime"; @@ -156,7 +183,7 @@ version = "1.0.0" mkdirSync(join(wasmRoot, dirname(imageRel)), { recursive: true }); mkdirSync(join(wasmRoot, dirname(runtimeRel)), { recursive: true }); mkdirSync(join(wasmRoot, dirname(scalarRel)), { recursive: true }); - cpSync(join(repoRoot, "host", "dist"), join(staging, "dist"), { + cpSync(join(isolatedHost, "dist"), join(staging, "dist"), { recursive: true, }); cpSync(join(repoRoot, "host", "package.json"), join(staging, "package.json")); diff --git a/tools/xtask/src/homebrew_sidecars.rs b/tools/xtask/src/homebrew_sidecars.rs index 522259056a..8d7215caea 100644 --- a/tools/xtask/src/homebrew_sidecars.rs +++ b/tools/xtask/src/homebrew_sidecars.rs @@ -1616,7 +1616,7 @@ mod tests { { "name": "node_smoke", "status": "success", - "passed": ["fixture"], + "passed": ["hello exits 0 under Node.js"], "failed": [], "skipped": [] } From 36b3491a7cc146f205e014a93047d700e4211428 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sat, 1 Aug 2026 07:55:58 -0400 Subject: [PATCH 54/82] Vfork: Complete ABI 43 transaction readiness Carry an explicit ordinary-fork or vfork selector from libc through the instrumented guest import, host workers, and kernel process-table entry. Validate the new ABI signature and keep both modes on the existing copied-memory transaction until the borrowed-memory lifetime is connected. --- abi/snapshot.json | 17 +- .../test/ruby-posix-spawn.spec.ts | 415 +++++----- .../test/vfork-lifecycle.spec.ts | 378 +++++++++ .../fork-instrument/src/contract_inventory.rs | 7 + .../tests/contract_inventory.rs | 14 +- crates/kernel/src/fork.rs | 29 +- crates/kernel/src/ofd.rs | 201 ++++- crates/kernel/src/pipe.rs | 35 +- crates/kernel/src/process.rs | 7 + crates/kernel/src/process_table.rs | 182 ++++- crates/kernel/src/procfs.rs | 4 +- crates/kernel/src/syscalls.rs | 448 +++++++---- crates/kernel/src/wasm_api.rs | 18 +- crates/shared/src/lib.rs | 60 +- docs/abi-versioning.md | 39 + docs/architecture.md | 76 +- docs/fork-instrumentation.md | 58 +- docs/future-improvements.md | 27 - .../2026-07-31-affordable-fork-then-exec.md | 450 ++++++++--- docs/plans/2026-08-01-abi-43-batch-plan.md | 211 ++++-- docs/posix-status.md | 40 +- host/src/browser-kernel-worker-entry.ts | 659 +++++++++++++++- host/src/constants.ts | 27 + host/src/fork-activation-registry.ts | 8 + host/src/fork-process-continuation.ts | 47 +- host/src/fork-reference-transaction.ts | 25 + host/src/generated/abi.ts | 6 + host/src/kernel-worker.ts | 113 ++- host/src/node-kernel-worker-entry.ts | 638 +++++++++++++++- host/src/process-memory-creator-gate.ts | 33 +- host/src/thread-allocator.ts | 29 +- host/src/vfork-lifetime.ts | 12 +- host/src/vfork-workspace.ts | 175 +++++ host/src/worker-main.ts | 434 ++++++++--- host/src/worker-protocol.ts | 20 + host/test/centralized-test-helper.ts | 12 + host/test/dlopen-host-imports.test.ts | 54 ++ host/test/dri-cube-pyramid.test.ts | 2 + host/test/fixtures/catch-ref-fresh-worker.wat | 4 +- .../gc-reference-state-fresh-worker-bytes.ts | 16 +- .../gc-reference-state-fresh-worker.wat | 4 +- .../reference-catch-payload-fresh-worker.wat | 5 +- host/test/fork-instrument-coverage.test.ts | 11 +- host/test/fork-process-continuation.test.ts | 18 +- host/test/fork-reference-transaction.test.ts | 31 + host/test/fork-replay-host-parity.test.ts | 15 +- host/test/kernel-late-channel.test.ts | 4 +- host/test/kernel-scratch-contract.test.ts | 4 +- host/test/multi-worker.test.ts | 90 ++- .../node-process-teardown-ordering.test.ts | 2 +- host/test/process-memory-creator-gate.test.ts | 52 ++ host/test/process-wait-lifecycle.test.ts | 40 + host/test/spawn-host-parity.test.ts | 11 +- host/test/spawn-pid-authority.test.ts | 3 +- host/test/startup-metadata-capacity.test.ts | 50 +- host/test/support/kernel-scratch-instance.ts | 2 +- host/test/thread-allocator.test.ts | 46 ++ host/test/vfork-lifecycle-guest.test.ts | 194 +++++ host/test/vfork-lifetime.test.ts | 3 + host/test/vfork-workspace.test.ts | 87 +++ host/test/wasm-binary-parse.test.ts | 18 +- host/test/worker-exit-trap.test.ts | 28 + libc/glue/abi_constants.h | 4 + libc/glue/channel_syscall.c | 41 +- libc/glue/syscall_glue.c | 4 +- libc/glue/syscall_imports.h | 2 +- .../test/process-tools.test.ts | 17 +- packages/registry/program-packages.json | 716 +++++++++--------- packages/registry/ruby/build-ruby.sh | 52 +- packages/registry/ruby/build.toml | 2 +- .../ruby/patches/kandelo-posix-spawn.patch | 341 --------- .../ruby/test/posix-spawn-contract.ts | 274 +------ .../registry/ruby/test/posix-spawn.test.ts | 129 +++- programs/p_08_vfork.c | 28 +- programs/vfork-external-signal.c | 83 ++ programs/vfork-fatal-lifecycle.c | 67 ++ programs/vfork-from-thread.c | 87 +++ programs/vfork-lifecycle.c | 124 +++ programs/vfork-posix-state.c | 82 ++ scripts/ci-vitest-resource-isolated-cases.tsv | 8 +- scripts/pack-ci-test-workspace.sh | 25 +- scripts/resolve-binary.bundle.mjs | 12 +- scripts/run-libc-tests.sh | 46 +- scripts/stage-portable-resolver-binaries.sh | 25 +- scripts/test-package-build-roots.sh | 64 +- scripts/test-wasm-artifact-guards.sh | 30 +- scripts/wasm-artifact-guards.sh | 9 +- .../test-artifacts/kernel-test-programs.json | 6 +- tools/xtask/src/build_deps.rs | 246 +++++- tools/xtask/src/dump_abi.rs | 65 +- 90 files changed, 6417 insertions(+), 1920 deletions(-) create mode 100644 apps/browser-demos/test/vfork-lifecycle.spec.ts create mode 100644 host/src/vfork-workspace.ts create mode 100644 host/test/vfork-lifecycle-guest.test.ts create mode 100644 host/test/vfork-workspace.test.ts create mode 100644 host/test/worker-exit-trap.test.ts delete mode 100644 packages/registry/ruby/patches/kandelo-posix-spawn.patch create mode 100644 programs/vfork-external-signal.c create mode 100644 programs/vfork-fatal-lifecycle.c create mode 100644 programs/vfork-from-thread.c create mode 100644 programs/vfork-lifecycle.c create mode 100644 programs/vfork-posix-state.c diff --git a/abi/snapshot.json b/abi/snapshot.json index 2a7bf88b97..11e7b1a8e6 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -2055,7 +2055,7 @@ { "kind": "func", "name": "kernel_fork_process", - "signature": "(i32,i32) -> (i32)" + "signature": "(i32,i32,i32) -> (i32)" }, { "kind": "func", @@ -6187,6 +6187,21 @@ } } }, + "process_import": { + "kind": "func", + "module": "kernel", + "name": "kernel_fork", + "params": [ + "i32" + ], + "results": [ + "i32" + ] + }, + "process_modes": { + "fork": 0, + "vfork": 1 + }, "required_exports": [ { "kind": "func", diff --git a/apps/browser-demos/test/ruby-posix-spawn.spec.ts b/apps/browser-demos/test/ruby-posix-spawn.spec.ts index b5ef10d1d8..37f73f7f2c 100644 --- a/apps/browser-demos/test/ruby-posix-spawn.spec.ts +++ b/apps/browser-demos/test/ruby-posix-spawn.spec.ts @@ -1,91 +1,94 @@ -import { expect, test } from "@playwright/test"; -import { randomUUID } from "node:crypto"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { expect, test, type Page } from "@playwright/test"; +import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { tryResolveBinary } from "../../../host/src/binary-resolver"; import { - RUBY_POSIX_SPAWN_CASES, - RUBY_POSIX_SPAWN_EXECUTABLE, + detectPtrWidth, + extractHeapBase, + WASM_PAGE_SIZE, +} from "../../../host/src/constants"; +import { computeProcessMemoryLayout } from "../../../host/src/process-memory"; +import { + RUBY_PRIVILEGED_FORK_MARKER, + RUBY_PRIVILEGED_FORK_PROGRAM, + RUBY_VFORK_EXEC_MARKER, + RUBY_VFORK_EXEC_PROGRAM, + RUBY_VFORK_EXECUTABLE, + RUBY_VFORK_FAILED_EXEC_MARKER, + RUBY_VFORK_FAILED_EXEC_PROGRAM, } from "../../../packages/registry/ruby/test/posix-spawn-contract"; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, "../../.."); -const rubyBinaryAvailable = - tryResolveBinary("programs/ruby/ruby.wasm") !== null; +const rubyBinaryPath = tryResolveBinary("programs/ruby/ruby.wasm"); +const execChildBinaryPath = tryResolveBinary("programs/exec-child.wasm"); +const artifactsAvailable = rubyBinaryPath !== null && execChildBinaryPath !== null; const browserKernelModulePath = resolve( repoRoot, "host/src/browser-kernel-host.ts", ); -const memoryFsModulePath = resolve( - repoRoot, - "host/src/vfs/memory-fs.ts", -); +const memoryFsModulePath = resolve(repoRoot, "host/src/vfs/memory-fs.ts"); const imageHelpersModulePath = resolve( repoRoot, "host/src/vfs/image-helpers.ts", ); -const rubyBinaryFixtureDirectory = resolve( - repoRoot, - "target/browser-test-runs", - `ruby-posix-spawn-${randomUUID()}`, -); -const rubyBinaryFixturePath = resolve(rubyBinaryFixtureDirectory, "ruby.ts"); - -test.beforeAll(() => { - mkdirSync(rubyBinaryFixtureDirectory, { recursive: true }); - // WHY: Keep this test-only import outside the authored browser tree. The - // product scanner must not mistake a regression fixture for a direct shell - // input, while Vite must still approve Ruby through the normal resolver. - writeFileSync( - rubyBinaryFixturePath, - [ - 'import rubyWasmUrl from "@binaries/programs/wasm32/ruby/ruby.wasm?url";', - "export default rubyWasmUrl;", - "", - ].join("\n"), - ); -}); -test.afterAll(() => { - rmSync(rubyBinaryFixtureDirectory, { recursive: true, force: true }); -}); - -test("Ruby uses direct posix_spawn in Chromium and preserves fork fallback", async ({ - page, - baseURL, - browserName, -}) => { - test.skip( - browserName !== "chromium", - "Chromium is the aggregate browser runtime gate", - ); - test.skip( - !rubyBinaryAvailable, - "The Ruby package artifact is not available", +function initialAddressSpaceBytes(programPath: string): number { + const file = readFileSync(programPath); + const bytes = file.buffer.slice( + file.byteOffset, + file.byteOffset + file.byteLength, ); - test.setTimeout(600_000); - if (!baseURL) throw new Error("Playwright baseURL is required"); + const ptrWidth = detectPtrWidth(bytes); + return computeProcessMemoryLayout({ + ptrWidth, + programBytes: bytes, + heapBase: extractHeapBase(bytes), + }).initialPages * WASM_PAGE_SIZE; +} - const runtimeErrors: string[] = []; - page.on("pageerror", (error) => runtimeErrors.push(error.message)); - page.on("console", (message) => { - if ( - message.type() === "error" - && !message.text().startsWith("Failed to load resource:") - ) { - runtimeErrors.push(message.text()); - } - }); +interface RubyBrowserCase { + marker: string; + program: string; + uid: number; + gid: number; + maxProcessMemoryBytes?: number; +} +interface RubyBrowserResult { + marker: string; + exitCode: number; + stdout: string; + stderr: string; + diagnostics: Array<{ source: string; message: string }>; + childEvents: string[]; + forkCounts: string[]; +} + +async function runRubyCases( + page: Page, + baseURL: string, + cases: readonly RubyBrowserCase[], +): Promise { + if (rubyBinaryPath === null || execChildBinaryPath === null) { + throw new Error("browser fixture unavailable"); + } + await page.route("**/favicon.ico", (route) => + route.fulfill({ status: 204, body: "" }), + ); await page.goto(new URL("/trap-signal-test.html", baseURL).href); - const result = await page.evaluate( + const asViteFsUrl = (path: string) => + new URL(`/@fs/${path}`, baseURL).href; + + return page.evaluate( async ({ browserKernelUrl, memoryFsUrl, imageHelpersUrl, - rubyBinaryFixtureUrl, + rubyUrl, + execChildUrl, executable, cases, }) => { @@ -98,154 +101,210 @@ test("Ruby uses direct posix_spawn in Chromium and preserves fork fallback", asy const { ensureDirRecursive, writeVfsBinary } = await import( /* @vite-ignore */ imageHelpersUrl ); - // The fixture's @binaries import crosses the resolver's exact-file - // capability boundary before exposing a URL to browser code. - const { default: rubyUrl } = await import( - /* @vite-ignore */ rubyBinaryFixtureUrl - ); - const rubyResponse = await fetch(rubyUrl); - if (!rubyResponse.ok) { - throw new Error(`Ruby fetch failed: ${rubyResponse.status}`); + const [rubyResponse, execChildResponse] = await Promise.all([ + fetch(rubyUrl), + fetch(execChildUrl), + ]); + if (!rubyResponse.ok || !execChildResponse.ok) { + throw new Error( + `Ruby fixture fetch failed: ruby=${rubyResponse.status} ` + + `exec-child=${execChildResponse.status}`, + ); } const rubyBytes = await rubyResponse.arrayBuffer(); + const execChildBytes = await execChildResponse.arrayBuffer(); - // Ruby is also the child executable. Keeping it in the VFS exercises - // the same kernel-owned resolution path used by the browser shell. - const maxImageBytes = 96 * 1024 * 1024; + const maxImageBytes = 8 * 1024 * 1024; const SharedArrayBufferCtor = SharedArrayBuffer as new ( byteLength: number, options?: { maxByteLength?: number }, ) => SharedArrayBuffer; - const buildFs = MemoryFileSystem.create( - new SharedArrayBufferCtor(16 * 1024 * 1024, { + const imageOwner = MemoryFileSystem.create( + new SharedArrayBufferCtor(2 * 1024 * 1024, { maxByteLength: maxImageBytes, }), maxImageBytes, ); - ensureDirRecursive(buildFs, "/usr/bin"); + ensureDirRecursive(imageOwner, "/tmp"); + ensureDirRecursive(imageOwner, "/bin"); writeVfsBinary( - buildFs, + imageOwner, executable, - new Uint8Array(rubyBytes), + new Uint8Array(execChildBytes), 0o755, ); - const vfsImage = await buildFs.saveImage(); - - let stdout = ""; - let stderr = ""; - const diagnostics: Array<{ source: string; message: string }> = []; - let activeParentPid: number | undefined; - let activeChildPid: number | undefined; - let activeForkSamples: Array> = []; - let activeChildEvents: Array<"spawn" | "exec" | "exit"> = []; - - const kernel = new BrowserKernel({ - kernelOwnedFs: true, - onStdout: (data: Uint8Array) => { - stdout += new TextDecoder().decode(data); - }, - onStderr: (data: Uint8Array) => { - stderr += new TextDecoder().decode(data); - }, - onHostDiagnostic: (diagnostic: { source: string; message: string }) => { - diagnostics.push(diagnostic); - }, - onProcessEvent: (event: { - kind: "spawn" | "exec" | "exit"; - pid: number; - ppid?: number; - }) => { - if ( - activeParentPid !== undefined - && event.kind === "spawn" - && event.ppid === activeParentPid - ) { - activeChildPid = event.pid; - activeChildEvents.push(event.kind); - activeForkSamples.push(kernel.getForkCount(activeParentPid)); - } else if (activeChildPid === event.pid) { - activeChildEvents.push(event.kind); - } - }, - }); - - try { - await kernel.initFromImage({ vfsImage }); - const results = []; - for (const spawnCase of cases) { - const stdoutStart = stdout.length; - const stderrStart = stderr.length; - const diagnosticsStart = diagnostics.length; - activeParentPid = undefined; - activeChildPid = undefined; - activeForkSamples = []; - activeChildEvents = []; + const vfsImage = await imageOwner.saveImage(); + const decoder = new TextDecoder(); + const results = []; + for (const rubyCase of cases) { + let stdout = ""; + let stderr = ""; + let parentPid: number | undefined; + const childPids = new Set(); + const childEvents: string[] = []; + const forkSamples: Array> = []; + const diagnostics: Array<{ source: string; message: string }> = []; + const kernel = new BrowserKernel({ + maxWorkers: 4, + ...(rubyCase.maxProcessMemoryBytes === undefined + ? {} + : { maxProcessMemoryBytes: rubyCase.maxProcessMemoryBytes }), + onStdout: (data: Uint8Array) => { + stdout += decoder.decode(data); + }, + onStderr: (data: Uint8Array) => { + stderr += decoder.decode(data); + }, + onHostDiagnostic: (diagnostic: { + source: string; + message: string; + }) => diagnostics.push(diagnostic), + onProcessEvent: (event: { + kind: string; + pid: number; + ppid?: number; + }) => { + if ( + parentPid !== undefined + && event.kind === "spawn" + && event.ppid === parentPid + ) { + childPids.add(event.pid); + childEvents.push(event.kind); + forkSamples.push(kernel.getForkCount(parentPid)); + } else if (childPids.has(event.pid)) { + childEvents.push(event.kind); + } + }, + }); + + try { + await kernel.initFromImage({ vfsImage: vfsImage.slice(0) }); const exitCode = await kernel.spawn( rubyBytes.slice(0), - ["ruby", "--disable-gems", "-e", spawnCase.program], + ["ruby", "--disable-gems", "-e", rubyCase.program], { - env: [ - "HOME=/tmp", - "TMPDIR=/tmp", - "K_TEST=inherited-env-ok", - ], + env: ["HOME=/tmp", "TMPDIR=/tmp"], + uid: rubyCase.uid, + gid: rubyCase.gid, onStarted: (pid: number) => { - activeParentPid = pid; + parentPid = pid; }, }, ); - const forkCounts = await Promise.all(activeForkSamples); results.push({ - marker: spawnCase.marker, - expectedForkCount: spawnCase.expectedForkCount, - expectedChildEvents: spawnCase.expectedChildEvents, + marker: rubyCase.marker, exitCode, - stdout: stdout.slice(stdoutStart), - stderr: stderr.slice(stderrStart), - diagnostics: diagnostics.slice(diagnosticsStart), - forkCounts: forkCounts.map((count) => count.toString()), - childEvents: [...activeChildEvents], + stdout, + stderr, + diagnostics, + childEvents, + forkCounts: (await Promise.all(forkSamples)).map(String), }); + } finally { + await kernel.destroy(); } - return results; - } finally { - await kernel.destroy(); } + return results; }, { - browserKernelUrl: new URL( - `/@fs/${browserKernelModulePath}`, - baseURL, - ).href, - memoryFsUrl: new URL( - `/@fs/${memoryFsModulePath}`, - baseURL, - ).href, - imageHelpersUrl: new URL(`/@fs/${imageHelpersModulePath}`, baseURL).href, - rubyBinaryFixtureUrl: new URL( - `/@fs/${rubyBinaryFixturePath}`, - baseURL, - ).href, - executable: RUBY_POSIX_SPAWN_EXECUTABLE, - cases: RUBY_POSIX_SPAWN_CASES.map((spawnCase) => ({ - marker: spawnCase.marker, - expectedForkCount: spawnCase.expectedForkCount.toString(), - expectedChildEvents: spawnCase.expectedChildEvents, - program: spawnCase.program, - })), + browserKernelUrl: asViteFsUrl(browserKernelModulePath), + memoryFsUrl: asViteFsUrl(memoryFsModulePath), + imageHelpersUrl: asViteFsUrl(imageHelpersModulePath), + // WHY: `@binaries` intentionally resolves a complete provenance tier. + // This focused runtime test already selected exact artifacts through + // Kandelo's resolver and must not require unrelated demo packages such + // as Bash merely to transfer those bytes into a browser worker. + rubyUrl: asViteFsUrl(rubyBinaryPath), + execChildUrl: asViteFsUrl(execChildBinaryPath), + executable: RUBY_VFORK_EXECUTABLE, + cases, }, ); +} - for (const spawnCase of result) { - expect(spawnCase.exitCode, spawnCase.stderr).toBe(0); - expect(spawnCase.stderr).toBe(""); - expect(spawnCase.stdout).toContain(`${spawnCase.marker}\n`); - expect(spawnCase.diagnostics).toEqual([]); - expect(spawnCase.forkCounts).toEqual([ - spawnCase.expectedForkCount, - ]); - expect(spawnCase.childEvents).toEqual(spawnCase.expectedChildEvents); - } +test("Ruby uid 1000 selects upstream vfork in every browser engine", async ({ + page, + baseURL, +}) => { + test.skip(!artifactsAvailable, "The Ruby package artifacts are unavailable"); + test.setTimeout(600_000); + if (!baseURL || !rubyBinaryPath) throw new Error("browser fixture unavailable"); + + const runtimeErrors: string[] = []; + page.on("pageerror", (error) => runtimeErrors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error") runtimeErrors.push(message.text()); + }); + const [result] = await runRubyCases(page, baseURL, [{ + marker: RUBY_VFORK_FAILED_EXEC_MARKER, + program: RUBY_VFORK_FAILED_EXEC_PROGRAM, + uid: 1000, + gid: 1000, + maxProcessMemoryBytes: initialAddressSpaceBytes(rubyBinaryPath), + }]); + + expect(result.exitCode, JSON.stringify(result, null, 2)).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain(`${RUBY_VFORK_FAILED_EXEC_MARKER}\n`); + expect(result.diagnostics).toEqual([]); + expect(result.childEvents).toEqual(["spawn", "exit"]); + expect(result.forkCounts).toEqual(["1"]); expect(runtimeErrors).toEqual([]); }); + +test("Ruby execs through vfork and root retains ordinary fork", async ({ + page, + baseURL, + browserName, +}) => { + test.skip(browserName !== "chromium", "Chromium is the aggregate Ruby gate"); + test.skip(!artifactsAvailable, "The Ruby package artifacts are unavailable"); + test.setTimeout(600_000); + if (!baseURL || !rubyBinaryPath) throw new Error("browser fixture unavailable"); + + const runtimeErrors: string[] = []; + page.on("pageerror", (error) => runtimeErrors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error") runtimeErrors.push(message.text()); + }); + const [execResult, rootResult] = await runRubyCases(page, baseURL, [ + { + marker: RUBY_VFORK_EXEC_MARKER, + program: RUBY_VFORK_EXEC_PROGRAM, + uid: 1000, + gid: 1000, + }, + { + marker: RUBY_PRIVILEGED_FORK_MARKER, + program: RUBY_PRIVILEGED_FORK_PROGRAM, + uid: 0, + gid: 0, + maxProcessMemoryBytes: initialAddressSpaceBytes(rubyBinaryPath), + }, + ]); + + expect(execResult.exitCode, JSON.stringify(execResult, null, 2)).toBe(0); + expect(execResult.stderr).toBe(""); + expect(execResult.stdout).toContain("argv[0]=ruby-vfork-child\n"); + expect(execResult.stdout).toContain("FROM=ruby-upstream-vfork\n"); + expect(execResult.stdout).toContain(`${RUBY_VFORK_EXEC_MARKER}\n`); + expect(execResult.diagnostics).toEqual([]); + expect(execResult.childEvents).toEqual(["spawn", "exec", "exit"]); + expect(execResult.forkCounts).toEqual(["1"]); + + expect(rootResult.exitCode, JSON.stringify(rootResult, null, 2)).toBe(0); + expect(rootResult.stderr).toBe(""); + expect(rootResult.stdout).toContain(`${RUBY_PRIVILEGED_FORK_MARKER}\n`); + expect(rootResult.diagnostics).toEqual([]); + expect(rootResult.childEvents).toEqual(["spawn", "spawn"]); + expect(rootResult.forkCounts).toHaveLength(2); + expect(rootResult.forkCounts.at(-1)).toBe("2"); + + const expectedCapacityErrors = runtimeErrors.filter((message) => + /fork worker launch failed:.*(?:admission budget|exhausted)/i.test(message) + ); + expect(expectedCapacityErrors).toHaveLength(2); + expect(runtimeErrors).toEqual(expectedCapacityErrors); +}); diff --git a/apps/browser-demos/test/vfork-lifecycle.spec.ts b/apps/browser-demos/test/vfork-lifecycle.spec.ts new file mode 100644 index 0000000000..ca1d9b6a98 --- /dev/null +++ b/apps/browser-demos/test/vfork-lifecycle.spec.ts @@ -0,0 +1,378 @@ +import { expect, test, type Page } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveBinary } from "../../../host/src/binary-resolver"; +import { + detectPtrWidth, + extractHeapBase, + WASM_PAGE_SIZE, +} from "../../../host/src/constants"; +import { computeProcessMemoryLayout } from "../../../host/src/process-memory"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const browserKernelModulePath = resolve( + __dirname, + "../../../host/src/browser-kernel-host.ts", +); +const memoryFsModulePath = resolve( + __dirname, + "../../../host/src/vfs/memory-fs.ts", +); +const lifecycleProgramPath = resolveBinary("programs/vfork-lifecycle.wasm"); +const threadProgramPath = resolveBinary("programs/vfork-from-thread.wasm"); +const fatalProgramPath = resolveBinary("programs/vfork-fatal-lifecycle.wasm"); +const externalSignalProgramPath = resolveBinary( + "programs/vfork-external-signal.wasm", +); +const stateProgramPath = resolveBinary("programs/vfork-posix-state.wasm"); +const execChildPath = resolveBinary("programs/exec-child.wasm"); + +function initialAddressSpaceBytes(programPath: string): number { + const file = readFileSync(programPath); + const bytes = file.buffer.slice( + file.byteOffset, + file.byteOffset + file.byteLength, + ); + const ptrWidth = detectPtrWidth(bytes); + return computeProcessMemoryLayout({ + ptrWidth, + programBytes: bytes, + heapBase: extractHeapBase(bytes), + }).initialPages * WASM_PAGE_SIZE; +} + +interface BrowserVforkResult { + exitCode: number; + stdout: string; + stderr: string; + diagnostics: Array<{ + pid?: number; + status?: number; + source: string; + message: string; + }>; + processEvents: string[]; +} + +function expectOrdered(output: string, markers: readonly string[]): void { + let previous = -1; + for (const marker of markers) { + const index = output.indexOf(marker); + expect(index, `missing output marker ${marker}`).toBeGreaterThan(previous); + previous = index; + } +} + +function captureRuntimeErrors(page: Page): string[] { + const errors: string[] = []; + page.on("pageerror", (error) => { + errors.push(`pageerror: ${error.message}`); + }); + page.on("console", (message) => { + if (message.type() === "error") { + errors.push(`console: ${message.text()}`); + } + }); + page.on("requestfailed", (request) => { + errors.push( + `requestfailed: ${request.url()} ${request.failure()?.errorText ?? "failed"}`, + ); + }); + return errors; +} + +async function runBrowserVforkFixture( + page: Page, + baseURL: string, + fixturePath: string, + execChildFixturePath?: string, + maxProcessMemoryBytes?: number, +): Promise { + const asViteFsUrl = (path: string) => new URL(`/@fs/${path}`, baseURL).href; + + // The bare fixture page has no product favicon. Keep Chromium's unrelated + // favicon 404 out of the runtime-error evidence collected below. + await page.route("**/favicon.ico", (route) => + route.fulfill({ status: 204, body: "" }), + ); + await page.goto(new URL("/trap-signal-test.html", baseURL).href); + return page.evaluate( + async ({ + browserKernelModuleUrl, + memoryFsModuleUrl, + fixtureUrl, + execChildFixtureUrl, + maxProcessMemoryBytes, + }) => { + // WHY: loading BrowserKernel first avoids racing two cold Vite imports + // through the same host-runtime dependency graph. + const { BrowserKernel } = await import( + /* @vite-ignore */ browserKernelModuleUrl + ); + const { MemoryFileSystem } = await import( + /* @vite-ignore */ memoryFsModuleUrl + ); + const decoder = new TextDecoder(); + let stdout = ""; + let stderr = ""; + const diagnostics: Array<{ + pid?: number; + status?: number; + source: string; + message: string; + }> = []; + const processEvents: string[] = []; + const kernel = new BrowserKernel({ + maxWorkers: 4, + ...(maxProcessMemoryBytes === undefined + ? {} + : { maxProcessMemoryBytes }), + onStdout: (data: Uint8Array) => { + stdout += decoder.decode(data); + }, + onStderr: (data: Uint8Array) => { + stderr += decoder.decode(data); + }, + onHostDiagnostic: (diagnostic: { + pid?: number; + status?: number; + source: string; + message: string; + }) => diagnostics.push(diagnostic), + onProcessEvent: (event: { kind: string }) => { + processEvents.push(event.kind); + }, + }); + let initialized = false; + + try { + const imageOwner = MemoryFileSystem.create( + new SharedArrayBuffer(2 * 1024 * 1024), + ); + imageOwner.mkdir("/tmp", 0o755); + if (execChildFixtureUrl) { + const childResponse = await fetch(execChildFixtureUrl); + if (!childResponse.ok) { + throw new Error( + `exec child fetch failed: ${childResponse.status} ${execChildFixtureUrl}`, + ); + } + imageOwner.mkdir("/bin", 0o755); + imageOwner.createFileWithOwner( + "/bin/vfork-exec-child", + 0o755, + 0, + 0, + new Uint8Array(await childResponse.arrayBuffer()), + ); + } + await kernel.initFromImage({ + vfsImage: await imageOwner.saveImage(), + }); + initialized = true; + + const fixtureResponse = await fetch(fixtureUrl); + if (!fixtureResponse.ok) { + throw new Error( + `fixture fetch failed: ${fixtureResponse.status} ${fixtureUrl}`, + ); + } + const exitCode = await kernel.spawn( + await fixtureResponse.arrayBuffer(), + ["vfork-browser-fixture"], + ); + return { + exitCode, + stdout, + stderr, + diagnostics, + processEvents, + }; + } finally { + if (initialized) await kernel.destroy(); + } + }, + { + browserKernelModuleUrl: asViteFsUrl(browserKernelModulePath), + memoryFsModuleUrl: asViteFsUrl(memoryFsModulePath), + fixtureUrl: asViteFsUrl(fixturePath), + execChildFixtureUrl: execChildFixturePath + ? asViteFsUrl(execChildFixturePath) + : undefined, + maxProcessMemoryBytes, + }, + ); +} + +test("vfork keeps its browser parent parked through exit and exec", async ({ + page, + baseURL, +}) => { + test.setTimeout(180_000); + expect(baseURL).toBeTruthy(); + const runtimeErrors = captureRuntimeErrors(page); + + const result = await runBrowserVforkFixture( + page, + baseURL!, + lifecycleProgramPath, + execChildPath, + ); + + expect(result.exitCode, JSON.stringify(result, null, 2)).toBe(0); + expect(result.stderr).toBe(""); + expect(result.diagnostics).toEqual([]); + expect(runtimeErrors).toEqual([]); + expectOrdered(result.stdout, [ + "CHILD_EXIT_ONE", + "PARENT_RESUME_ONE", + "CHILD_EXIT_TWO", + "PARENT_RESUME_TWO", + "CHILD_FAILED_EXEC", + "PARENT_AFTER_FAILED_EXEC_EXIT", + "CHILD_NESTED_FORK_EAGAIN", + "CHILD_NESTED_VFORK_EAGAIN", + "CHILD_PTHREAD_EAGAIN", + "PARENT_AFTER_REJECTED_OWNERSHIP", + "PARENT_AFTER_EXEC_COMMIT", + "PARENT_REAPED_EXEC_CHILD", + "PASS: VFORK_LIFECYCLE", + ]); + expect(result.processEvents).toContain("exec"); +}); + +test("vfork parks only its calling browser pthread", async ({ + page, + baseURL, +}) => { + test.setTimeout(180_000); + expect(baseURL).toBeTruthy(); + const runtimeErrors = captureRuntimeErrors(page); + + const result = await runBrowserVforkFixture( + page, + baseURL!, + threadProgramPath, + undefined, + // WHY: pthread creation grows this one admitted address space before + // vfork. Any full child allocation would then fail the sampled budget; + // passing proves the browser host retained the existing Memory instead. + initialAddressSpaceBytes(threadProgramPath), + ); + + expect(result.exitCode, JSON.stringify(result, null, 2)).toBe(0); + expect(result.stderr).toBe(""); + expect(result.diagnostics).toEqual([]); + expect(runtimeErrors).toEqual([]); + expectOrdered(result.stdout, [ + "THREAD_BEFORE_VFORK", + "MAIN_SIBLING_RAN", + "MAIN_RELEASED_CHILD", + "CHILD_THREAD_EXIT", + "THREAD_CALLER_RESUMED", + "MAIN_JOINED_CALLER", + "MAIN_REAPED_CHILD", + "PASS: VFORK_FROM_THREAD", + ]); +}); + +test("vfork releases its browser parent after trap and signal", async ({ + page, + baseURL, +}) => { + test.setTimeout(180_000); + expect(baseURL).toBeTruthy(); + const runtimeErrors = captureRuntimeErrors(page); + + const result = await runBrowserVforkFixture( + page, + baseURL!, + fatalProgramPath, + ); + + expect(result.exitCode, JSON.stringify(result, null, 2)).toBe(0); + expect(result.stderr).toBe(""); + // The intentional child trap is surfaced through both the structured host + // diagnostic and the browser console. Require that one known failure while + // rejecting any extra page, request, or lifecycle error. + expect(runtimeErrors).toHaveLength(1); + expect(runtimeErrors[0]).toMatch( + /console: \[process-worker\] Kernel worker failed:.*unreachable/is, + ); + expectOrdered(result.stdout, [ + "CHILD_BEFORE_TRAP", + "PARENT_AFTER_TRAP", + "PARENT_REAPED_TRAP", + "CHILD_BEFORE_SIGKILL", + "PARENT_AFTER_SIGKILL", + "PARENT_REAPED_SIGKILL", + "PASS: VFORK_FATAL_LIFECYCLE", + ]); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]).toMatchObject({ + status: 132, + source: "worker-main error message", + }); + expect(result.diagnostics[0].message).toMatch(/unreachable/i); +}); + +test("vfork contains a compute-running browser borrower", async ({ + page, + baseURL, +}) => { + test.setTimeout(180_000); + expect(baseURL).toBeTruthy(); + const runtimeErrors = captureRuntimeErrors(page); + + const result = await runBrowserVforkFixture( + page, + baseURL!, + externalSignalProgramPath, + ); + + expect(result.exitCode, JSON.stringify(result, null, 2)).toBe(139); + expect(result.stderr).toBe(""); + expectOrdered(result.stdout, [ + "VFORK_EXTERNAL_SIGNAL_BEGIN", + "KILLER_THREAD_READY", + "CHILD_COMPUTE_LOOP", + "KILLER_SENT_SIGKILL", + ]); + expect(result.stdout).not.toContain("UNSAFE_PARENT_RESUMED"); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]).toMatchObject({ + status: 139, + source: "vfork address-space containment", + }); + expect(result.diagnostics[0].message).toMatch(/ambiguous child teardown/); + expect(runtimeErrors).toHaveLength(1); + expect(runtimeErrors[0]).toMatch( + /console: \[vfork\] containing shared address space after ambiguous child teardown/, + ); +}); + +test("vfork preserves browser-visible POSIX process state", async ({ + page, + baseURL, +}) => { + test.setTimeout(180_000); + expect(baseURL).toBeTruthy(); + const runtimeErrors = captureRuntimeErrors(page); + + const result = await runBrowserVforkFixture( + page, + baseURL!, + stateProgramPath, + ); + + expect(result.exitCode, JSON.stringify(result, null, 2)).toBe(0); + expect(result.stderr).toBe(""); + expect(result.diagnostics).toEqual([]); + expect(runtimeErrors).toEqual([]); + expectOrdered(result.stdout, [ + "PARENT_AFTER_STATE_CHILD", + "PARENT_REAPED_STATE_CHILD", + "PASS: VFORK_POSIX_STATE", + ]); +}); diff --git a/crates/fork-instrument/src/contract_inventory.rs b/crates/fork-instrument/src/contract_inventory.rs index c8d2409df8..0001b63e8b 100644 --- a/crates/fork-instrument/src/contract_inventory.rs +++ b/crates/fork-instrument/src/contract_inventory.rs @@ -132,6 +132,7 @@ impl fmt::Display for ArtifactIdentity { #[derive(Debug, Clone, Copy)] enum ExpectedSignature { + I32ToI32, PointerToPointer, PointerToNil, NilToNil, @@ -185,6 +186,8 @@ pub fn fork_contract_inventory(bytes: &[u8]) -> Result { } if import.module == "kernel" && import.name == "kernel_fork" { inventory.imports_kernel_fork += 1; + checked_functions + .push((function_index, ExpectedSignature::I32ToI32)); } if import.module == "env" && import.name == "fork" { inventory.imports_side_fork += 1; @@ -735,6 +738,10 @@ fn unique_custom_section_hex(bytes: &[u8], expected_name: &str) -> Result bool { let (params, results): (&[ValType], &[ValType]) = match expected { + ExpectedSignature::I32ToI32 => ( + std::slice::from_ref(&ValType::I32), + std::slice::from_ref(&ValType::I32), + ), ExpectedSignature::PointerToPointer => ( std::slice::from_ref(&pointer), std::slice::from_ref(&pointer), diff --git a/crates/fork-instrument/tests/contract_inventory.rs b/crates/fork-instrument/tests/contract_inventory.rs index d40b3961b9..cb8e7c818c 100644 --- a/crates/fork-instrument/tests/contract_inventory.rs +++ b/crates/fork-instrument/tests/contract_inventory.rs @@ -21,7 +21,8 @@ fn contract_wat(pointer: &str, memory: &str) -> String { (type $cell (struct (field (mut i32)))) (type $exception (func (param i32))) (tag $exception_tag (type $exception)) - (import "kernel" "kernel_fork" (func $kernel_fork)) + (import "kernel" "kernel_fork" + (func $kernel_fork (param i32) (result i32))) (import "env" "__wpk_fork_frame_reserve" (func $frame_reserve (param {pointer}) (result {pointer}))) (import "env" "__wpk_fork_frame_commit" @@ -58,8 +59,9 @@ fn inventories_gc_and_exception_modules_without_decoding_code_bodies() { #[test] fn inventories_the_side_module_fork_entry() { let bytes = wat::parse_str(contract_wat("i32", "(memory 1)").replace( - r#"(import "kernel" "kernel_fork" (func $kernel_fork))"#, - r#"(import "env" "fork" (func $kernel_fork))"#, + r#"(import "kernel" "kernel_fork" + (func $kernel_fork (param i32) (result i32)))"#, + r#"(import "env" "fork" (func $kernel_fork (result i32)))"#, )) .expect("compile side-module contract WAT"); let inventory = fork_contract_inventory(&bytes).expect("inventory side module"); @@ -373,8 +375,10 @@ fn cli_reserved_import_inventory_emits_typed_rows() { fn inventories_the_reentrant_legacy_loader_import() { let bytes = wat::parse_str( contract_wat("i32", "(memory 1)").replace( - r#"(import "kernel" "kernel_fork" (func $kernel_fork))"#, - r#"(import "kernel" "kernel_fork" (func $kernel_fork)) + r#"(import "kernel" "kernel_fork" + (func $kernel_fork (param i32) (result i32)))"#, + r#"(import "kernel" "kernel_fork" + (func $kernel_fork (param i32) (result i32))) (import "env" "__wasm_dlopen" (func (param i32 i32 i32 i32 i32) (result i32)))"#, ), diff --git a/crates/kernel/src/fork.rs b/crates/kernel/src/fork.rs index b35747281a..b6d611d0f7 100644 --- a/crates/kernel/src/fork.rs +++ b/crates/kernel/src/fork.rs @@ -28,7 +28,7 @@ use wasm_posix_shared::fd_flags::FD_CLOFORK; use crate::fd::{FdEntry, FdTable, OpenFileDescRef}; use crate::lock::{FileId, KernelFileKind, OfdId}; use crate::memory::{MappedRegion, MemoryLayoutMetadata, MemoryManager}; -use crate::ofd::{FileType, OfdTable, OpenFileDesc}; +use crate::ofd::{FileType, OfdTable, OpenFileDesc, SharedOfdState}; use crate::process::{Process, ProcessState}; use crate::signal::{PerThreadSignalState, RtSigEntry, SignalAction, SignalHandler, SignalState}; use crate::socket::SocketTable; @@ -857,9 +857,9 @@ pub fn serialize_fork_state(proc: &Process, buf: &mut [u8]) -> Result Result<(), Er ofd_id, file_id, file_type, - status_flags, + shared_state: SharedOfdState::new(status_flags, offset, child_pid), host_handle, - offset, ref_count, - owner_pid: child_pid, path, dir_host_handle: -1, dir_synth_state: 0, dir_entry_offset: 0, + dir_position_generation: 0, dir_pending_entry: None, dri_state, }; @@ -1552,6 +1551,10 @@ fn deserialize_fork_state_into(buf: &[u8], child: &mut Process) -> Result<(), Er child.thread_name = [0u8; wasm_posix_shared::kernel_scratch_wire::PRCTL_NAME_BYTES as usize]; child.fork_child = true; + // The host selects ordinary versus vfork only after this common POSIX + // state snapshot has been installed. Never inherit a parent's transient + // borrowing marker through serialized process state. + child.vfork_child = false; child.sigsuspend_saved_mask = None; child.fork_exec_path = fork_exec_path; child.fork_exec_argv = fork_exec_argv; @@ -1683,9 +1686,9 @@ pub fn serialize_exec_state(proc: &Process, buf: &mut [u8]) -> Result Result { ofd_id, file_id, file_type, - status_flags, + shared_state: SharedOfdState::new(status_flags, offset, pid), host_handle, - offset, ref_count, - owner_pid: pid, path, dir_host_handle: -1, dir_synth_state: 0, dir_entry_offset: 0, + dir_position_generation: 0, dir_pending_entry: None, dri_state, }; @@ -2013,6 +2015,7 @@ pub fn deserialize_exec_state(buf: &[u8], pid: u32) -> Result { process.thread_name = [0u8; wasm_posix_shared::kernel_scratch_wire::PRCTL_NAME_BYTES as usize]; process.fork_child = false; + process.vfork_child = false; process.sigsuspend_saved_mask = None; process.fork_exec_path = None; process.fork_exec_argv = None; @@ -2124,7 +2127,7 @@ mod tests { parent.fd_table.alloc(OpenFileDescRef(ofd_idx), 0).unwrap(); { let ofd = parent.ofd_table.get_mut(ofd_idx).unwrap(); - ofd.offset = 4; + ofd.set_directory_offset(4); ofd.dir_host_handle = 701; ofd.dir_synth_state = 2; ofd.dir_entry_offset = 4; @@ -2140,7 +2143,7 @@ mod tests { let child = deserialize_fork_state(&buf[..written], 2).unwrap(); let inherited = child.ofd_table.get(ofd_idx).unwrap(); - assert_eq!(inherited.offset, 4); + assert_eq!(inherited.offset(), 4); assert_eq!(inherited.dir_entry_offset, 4); assert_eq!(inherited.dir_synth_state, 2); assert_eq!(inherited.dir_host_handle, -1); diff --git a/crates/kernel/src/ofd.rs b/crates/kernel/src/ofd.rs index 3760d0253d..f3afdd3c81 100644 --- a/crates/kernel/src/ofd.rs +++ b/crates/kernel/src/ofd.rs @@ -2,11 +2,12 @@ extern crate alloc; use alloc::boxed::Box; use alloc::collections::{BTreeMap, VecDeque}; +use alloc::rc::Rc; use alloc::vec::Vec; -use core::cell::UnsafeCell; +use core::cell::{Cell, UnsafeCell}; use core::sync::atomic::{AtomicU64, Ordering}; use wasm_posix_shared::Errno; -use wasm_posix_shared::flags::{O_APPEND, O_NONBLOCK, O_PATH}; +use wasm_posix_shared::flags::{O_ACCMODE, O_APPEND, O_NONBLOCK, O_PATH}; use crate::fd::FdTable; use crate::lock::{FileId, OfdId}; @@ -283,11 +284,16 @@ pub struct OpenFileDesc { /// their tagged object identity. pub file_id: Option, pub file_type: FileType, - pub status_flags: u32, + /// Mutable state shared by every descriptor naming this OFD. + /// + /// Descriptor tables and host directory handles remain process-owned, + /// but `dup`, `fork`, `vfork`, `posix_spawn`, and `SCM_RIGHTS` must all + /// observe one offset, one set of file-status flags, and one async owner. + /// The Wasm kernel serializes access on its dedicated worker, while Rc + /// gives this state exact ownership and retirement without a global map. + pub(crate) shared_state: SharedOfdState, pub host_handle: i64, - pub offset: i64, pub ref_count: u32, - pub owner_pid: u32, pub path: Vec, // resolved absolute path /// Host directory handle for getdents64 iteration (lazily opened). /// -1 means not yet opened, -2 means exhausted (EOF). @@ -296,6 +302,8 @@ pub struct OpenFileDesc { pub dir_synth_state: u8, /// Cumulative entry count across getdents64 calls — used as d_off cookie for seekdir. pub dir_entry_offset: i64, + /// Shared-position generation represented by this process-local iterator. + pub(crate) dir_position_generation: u64, /// Host entry already consumed by `host_readdir` but not yet exposed to /// the guest because it did not fit in the caller's getdents64 buffer. /// The next getdents64 call must retry this exact entry before advancing @@ -306,6 +314,62 @@ pub struct OpenFileDesc { pub dri_state: Option>, } +struct SharedOfdStateInner { + status_flags: Cell, + offset: Cell, + owner_pid: Cell, + position_generation: Cell, +} + +/// Exact-ownership handle for the mutable POSIX portion of an OFD. +/// +/// This is kernel-internal and does not change the guest/host ABI. +#[derive(Clone)] +pub(crate) struct SharedOfdState(Rc); + +impl SharedOfdState { + pub(crate) fn new(status_flags: u32, offset: i64, owner_pid: u32) -> Self { + Self(Rc::new(SharedOfdStateInner { + status_flags: Cell::new(status_flags), + offset: Cell::new(offset), + owner_pid: Cell::new(owner_pid), + position_generation: Cell::new(0), + })) + } + + fn status_flags(&self) -> u32 { + self.0.status_flags.get() + } + + fn set_status_flags(&self, value: u32) { + self.0.status_flags.set(value); + } + + fn offset(&self) -> i64 { + self.0.offset.get() + } + + fn set_offset(&self, value: i64) { + if self.0.offset.replace(value) != value { + self.0 + .position_generation + .set(self.0.position_generation.get().wrapping_add(1)); + } + } + + fn owner_pid(&self) -> u32 { + self.0.owner_pid.get() + } + + fn set_owner_pid(&self, value: u32) { + self.0.owner_pid.set(value); + } + + fn position_generation(&self) -> u64 { + self.0.position_generation.get() + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct PendingDirEntry { pub ino: u64, @@ -314,6 +378,46 @@ pub(crate) struct PendingDirEntry { } impl OpenFileDesc { + pub fn status_flags(&self) -> u32 { + self.shared_state.status_flags() + } + + pub(crate) fn set_status_flags_raw(&self, value: u32) { + self.shared_state.set_status_flags(value); + } + + pub fn offset(&self) -> i64 { + self.shared_state.offset() + } + + pub(crate) fn set_offset(&self, value: i64) { + self.shared_state.set_offset(value); + } + + pub(crate) fn owner_pid(&self) -> u32 { + self.shared_state.owner_pid() + } + + pub(crate) fn set_owner_pid(&self, value: u32) { + self.shared_state.set_owner_pid(value); + } + + pub(crate) fn shared_state(&self) -> SharedOfdState { + self.shared_state.clone() + } + + #[cfg(test)] + pub(crate) fn shared_state_ref_count(&self) -> usize { + Rc::strong_count(&self.shared_state.0) + } + + /// Restore object identity after fork deserialization preserved only an + /// OfdId and point-in-time scalar values. + fn link_shared_state(&mut self, state: SharedOfdState) { + self.shared_state = state; + self.reset_directory_iterator_for_reopen(); + } + /// Whether this open description denotes a terminal endpoint. /// /// Host-backed standard streams predate the dedicated PTY file types, so @@ -352,8 +456,8 @@ impl OpenFileDesc { return; } - debug_assert!(self.offset >= 0, "directory cookies cannot be negative"); - let cookie = self.offset.max(0); + debug_assert!(self.offset() >= 0, "directory cookies cannot be negative"); + let cookie = self.offset().max(0); self.dir_host_handle = if self.host_handle == crate::procfs::PROCFS_DIR_HANDLE || self.host_handle == crate::devfs::DEVFS_DIR_HANDLE { @@ -363,14 +467,25 @@ impl OpenFileDesc { }; self.dir_synth_state = cookie.min(2) as u8; self.dir_entry_offset = cookie; + self.dir_position_generation = self.shared_state.position_generation(); self.dir_pending_entry = None; } + pub(crate) fn directory_iterator_is_stale(&self) -> bool { + self.file_type == FileType::Directory + && self.dir_position_generation != self.shared_state.position_generation() + } + + pub(crate) fn set_directory_offset(&mut self, value: i64) { + self.set_offset(value); + self.dir_position_generation = self.shared_state.position_generation(); + } + /// Whether this OFD is a pathname capability rather than an I/O handle. /// Operations that act directly on an fd must reject path-only OFDs unless /// their contract explicitly accepts O_PATH/O_SEARCH descriptors. pub fn is_path_only(&self) -> bool { - self.status_flags & O_PATH != 0 + self.status_flags() & O_PATH != 0 } /// Access the `DriFdState` for renderD128- or card0-backed OFDs. @@ -448,15 +563,14 @@ impl OfdTable { ofd_id: allocate_ofd_id(), file_id: None, file_type, - status_flags, + shared_state: SharedOfdState::new(status_flags, 0, 0), host_handle, - offset: 0, ref_count: 1, - owner_pid: 0, path, dir_host_handle: -1, dir_synth_state: 0, dir_entry_offset: 0, + dir_position_generation: 0, dir_pending_entry: None, dri_state: None, }; @@ -474,23 +588,28 @@ impl OfdTable { file_type: FileType, status_flags: u32, host_handle: i64, - offset: i64, + _offset: i64, + shared_state: SharedOfdState, path: Vec, ) -> usize { observe_ofd_id(ofd_id); + debug_assert_eq!( + shared_state.status_flags() & O_ACCMODE, + status_flags & O_ACCMODE, + "SCM_RIGHTS changed an OFD's immutable access mode" + ); let mut ofd = OpenFileDesc { ofd_id, file_id, file_type, - status_flags, + shared_state, host_handle, - offset, ref_count: 1, - owner_pid: 0, path, dir_host_handle: -1, dir_synth_state: 0, dir_entry_offset: 0, + dir_position_generation: 0, dir_pending_entry: None, dri_state: None, }; @@ -632,11 +751,24 @@ impl OfdTable { pub fn set_status_flags(&mut self, idx: usize, new_flags: u32) { if let Some(ofd) = self.get_mut(idx) { // Preserve everything except the modifiable bits. - let preserved = ofd.status_flags & !SETFL_MODIFIABLE; + let preserved = ofd.status_flags() & !SETFL_MODIFIABLE; // Take only the modifiable bits from new_flags. let updated = new_flags & SETFL_MODIFIABLE; - ofd.status_flags = preserved | updated; + ofd.set_status_flags_raw(preserved | updated); + } + } + + /// Relink fork-deserialized entries to the source process's live shared + /// state. Fork preserves table slots; an identity mismatch is corruption. + pub(crate) fn link_shared_states_from(&mut self, source: &OfdTable) -> Result<(), Errno> { + for (index, ofd) in self.iter_mut() { + let source_ofd = source.get(index).ok_or(Errno::EINVAL)?; + if source_ofd.ofd_id != ofd.ofd_id { + return Err(Errno::EINVAL); + } + ofd.link_shared_state(source_ofd.shared_state()); } + Ok(()) } } @@ -653,11 +785,11 @@ mod tests { let ofd = table.get(idx).expect("OFD should exist at index 0"); assert_eq!(ofd.file_type, FileType::Regular); - assert_eq!(ofd.status_flags, O_RDWR | O_APPEND); + assert_eq!(ofd.status_flags(), O_RDWR | O_APPEND); assert_eq!(ofd.host_handle, 42); assert_ne!(ofd.ofd_id.0, 0); assert_eq!(ofd.file_id, None); - assert_eq!(ofd.offset, 0); + assert_eq!(ofd.offset(), 0); assert_eq!(ofd.ref_count, 1); assert_eq!(ofd.path, b"/test"); } @@ -728,8 +860,8 @@ mod tests { // Verify initial state: access mode is O_RDWR, O_APPEND is set let ofd = table.get(idx).unwrap(); - assert_eq!(ofd.status_flags & O_ACCMODE, O_RDWR); - assert_ne!(ofd.status_flags & O_APPEND, 0); + assert_eq!(ofd.status_flags() & O_ACCMODE, O_RDWR); + assert_ne!(ofd.status_flags() & O_APPEND, 0); // set_status_flags with O_NONBLOCK (no O_APPEND, different access mode bits) // Per POSIX F_SETFL: access mode must be preserved, only O_APPEND/O_NONBLOCK modifiable @@ -737,11 +869,11 @@ mod tests { let ofd = table.get(idx).unwrap(); // Access mode should still be O_RDWR - assert_eq!(ofd.status_flags & O_ACCMODE, O_RDWR); + assert_eq!(ofd.status_flags() & O_ACCMODE, O_RDWR); // O_APPEND should be removed (caller did not include it) - assert_eq!(ofd.status_flags & O_APPEND, 0); + assert_eq!(ofd.status_flags() & O_APPEND, 0); // O_NONBLOCK should be added - assert_ne!(ofd.status_flags & O_NONBLOCK, 0); + assert_ne!(ofd.status_flags() & O_NONBLOCK, 0); } #[test] @@ -820,15 +952,14 @@ mod tests { ofd_id: ofd.ofd_id, file_id: ofd.file_id, file_type: ofd.file_type, - status_flags: ofd.status_flags, + shared_state: ofd.shared_state(), host_handle: ofd.host_handle, - offset: ofd.offset, ref_count: ofd.ref_count, - owner_pid: ofd.owner_pid, path: ofd.path.clone(), dir_host_handle: -1, dir_synth_state: 0, dir_entry_offset: 0, + dir_position_generation: 0, dir_pending_entry: None, dri_state: None, }); @@ -849,7 +980,7 @@ mod tests { b"/transferred".to_vec(), ); let source_ofd = source.get_mut(source_idx).unwrap(); - source_ofd.offset = 7; + source_ofd.set_directory_offset(7); source_ofd.dir_host_handle = 99; source_ofd.dir_synth_state = 2; source_ofd.dir_entry_offset = 7; @@ -865,14 +996,15 @@ mod tests { source_snapshot.ofd_id, source_snapshot.file_id, source_snapshot.file_type, - source_snapshot.status_flags, + source_snapshot.status_flags(), source_snapshot.host_handle, - source_snapshot.offset, + source_snapshot.offset(), + source_snapshot.shared_state(), source_snapshot.path.clone(), ); let received = receiver.get(received_idx).unwrap(); assert_eq!(received.ofd_id, source_snapshot.ofd_id); - assert_eq!(received.offset, 7); + assert_eq!(received.offset(), 7); assert_eq!(received.dir_entry_offset, 7); assert_eq!(received.dir_synth_state, 2); assert_eq!(received.dir_host_handle, -1); @@ -907,6 +1039,7 @@ mod tests { O_RDONLY, sentinel, 5, + SharedOfdState::new(O_RDONLY, 5, 0), b"/virtual".to_vec(), ); let ofd = table.get(idx).unwrap(); @@ -1041,11 +1174,11 @@ mod tests { let mut visited = Vec::new(); for (i, ofd) in table.iter_mut() { - ofd.offset = (i as i64) * 100; + ofd.set_offset((i as i64) * 100); visited.push((i, ofd.host_handle)); } assert_eq!(visited, vec![(0, 1), (2, 3)]); - assert_eq!(table.get(0).unwrap().offset, 0); - assert_eq!(table.get(2).unwrap().offset, 200); + assert_eq!(table.get(0).unwrap().offset(), 0); + assert_eq!(table.get(2).unwrap().offset(), 200); } } diff --git a/crates/kernel/src/pipe.rs b/crates/kernel/src/pipe.rs index 3a8d012ede..d06195ed4b 100644 --- a/crates/kernel/src/pipe.rs +++ b/crates/kernel/src/pipe.rs @@ -8,7 +8,7 @@ use wasm_posix_shared::WasmStat; use wasm_posix_shared::Errno; use crate::lock::{FileId, OfdId}; -use crate::ofd::FileType; +use crate::ofd::{FileType, SharedOfdState}; /// POSIX default pipe capacity. pub const DEFAULT_PIPE_CAPACITY: usize = 65536; @@ -57,6 +57,10 @@ pub struct InFlightFd { pub status_flags: u32, pub host_handle: i64, pub offset: i64, + /// Exact shared OFD state retained while this descriptor is queued. + /// Scalar fields above remain validated reconstruction metadata; current + /// offset/status/owner values come from this handle at receive time. + shared_state: SharedOfdState, pub path: Vec, /// For kernel-backed pipe FDs: the exact reference transferred to the /// receiver. Non-pipe descriptors leave this as `None`. @@ -76,6 +80,29 @@ impl InFlightFd { host_handle: i64, offset: i64, path: Vec, + ) -> Self { + let shared_state = SharedOfdState::new(status_flags, offset, 0); + Self::new_with_shared_state( + ofd_id, + file_id, + file_type, + status_flags, + host_handle, + offset, + path, + shared_state, + ) + } + + pub(crate) fn new_with_shared_state( + ofd_id: OfdId, + file_id: Option, + file_type: FileType, + status_flags: u32, + host_handle: i64, + offset: i64, + path: Vec, + shared_state: SharedOfdState, ) -> Self { Self { ofd_id, @@ -84,6 +111,7 @@ impl InFlightFd { status_flags, host_handle, offset, + shared_state, path, pipe_ref_kind: None, owns_reference: false, @@ -154,6 +182,7 @@ impl InFlightFd { status_flags: self.status_flags, host_handle: self.host_handle, offset: self.offset, + shared_state: self.shared_state.clone(), path, pipe_ref_kind: self.pipe_ref_kind, owns_reference: false, @@ -167,6 +196,10 @@ impl InFlightFd { pub(crate) fn owns_reference(&self) -> bool { self.owns_reference } + + pub(crate) fn shared_state(&self) -> SharedOfdState { + self.shared_state.clone() + } } impl Drop for InFlightFd { diff --git a/crates/kernel/src/process.rs b/crates/kernel/src/process.rs index 12ed2670ed..3df690c3fc 100644 --- a/crates/kernel/src/process.rs +++ b/crates/kernel/src/process.rs @@ -783,6 +783,12 @@ pub struct Process { pub thread_name: [u8; wasm_posix_shared::kernel_scratch_wire::PRCTL_NAME_BYTES as usize], /// True if this process is a fork child that should exec on startup. pub fork_child: bool, + /// True while this process borrows its vfork parent's address space. + /// + /// This is kernel-internal lifecycle state, not guest-visible fork replay + /// state. It prevents the borrower from creating another address-space or + /// pthread owner before successful exec replaces the borrowed image. + pub vfork_child: bool, /// Saved signal mask during sigsuspend host retry. /// Set on first sigsuspend call, restored when a signal is delivered. pub sigsuspend_saved_mask: Option, @@ -1084,6 +1090,7 @@ impl Process { alarm_interval_ns: 0, thread_name: [0u8; wasm_posix_shared::kernel_scratch_wire::PRCTL_NAME_BYTES as usize], fork_child: false, + vfork_child: false, sigsuspend_saved_mask: None, fork_exec_path: None, fork_exec_argv: None, diff --git a/crates/kernel/src/process_table.rs b/crates/kernel/src/process_table.rs index 2d5750896d..eea591845e 100644 --- a/crates/kernel/src/process_table.rs +++ b/crates/kernel/src/process_table.rs @@ -278,7 +278,7 @@ pub(crate) fn bump_inherited_resource_refcounts( if ofd.file_type == FileType::Pipe && ofd.host_handle < 0 { let pipe_idx = (-(ofd.host_handle + 1)) as usize; if let Some(pipe) = pipe_table.get_mut(pipe_idx) { - if let Some(kind) = pipe.reference_kind(ofd.status_flags) { + if let Some(kind) = pipe.reference_kind(ofd.status_flags()) { pipe.add_reference(kind); } } @@ -376,7 +376,7 @@ fn build_fork_pipe_replay(child: &Process) -> Vec<(i32, i32)> { { continue; } - let access_mode = ofd.status_flags & O_ACCMODE; + let access_mode = ofd.status_flags() & O_ACCMODE; let pair = pipe_fd_pairs.entry(pipe_idx).or_insert((-1, -1)); if access_mode == wasm_posix_shared::flags::O_RDONLY { pair.0 = fd; @@ -529,7 +529,7 @@ impl ProcessTable { if ofd.file_type == FileType::Pipe && ofd.host_handle < 0 { let pipe_idx = (-(ofd.host_handle + 1)) as usize; if let Some(pipe) = pipe_table.get_mut(pipe_idx) { - if let Some(kind) = pipe.reference_kind(ofd.status_flags) { + if let Some(kind) = pipe.reference_kind(ofd.status_flags()) { pipe.close_reference(kind); } } @@ -1029,6 +1029,24 @@ impl ProcessTable { &mut self, parent_pid: u32, caller_tid: u32, + ) -> Result { + self.fork_process_for_caller_with_mode( + parent_pid, + caller_tid, + wasm_posix_shared::fork_contract::Mode::Fork, + ) + } + + /// Fork a process with an explicit host address-space lifetime mode. + /// + /// Both modes inherit identical POSIX process state. The vfork marker is + /// kernel-internal authority that rejects creation of another process or + /// pthread owner while the child still borrows its parent's Memory. + pub fn fork_process_for_caller_with_mode( + &mut self, + parent_pid: u32, + caller_tid: u32, + mode: wasm_posix_shared::fork_contract::Mode, ) -> Result { let (serialized_parent, caller_blocked) = { let parent = self.processes.get(&parent_pid).ok_or(Errno::ESRCH)?; @@ -1041,6 +1059,9 @@ impl ProcessTable { if !parent.is_live_explicit_tid(caller_tid) { return Err(Errno::ESRCH); } + if parent.vfork_child { + return Err(Errno::EAGAIN); + } ( serialize_fork_state_with_growing_buffer(parent)?, parent.blocked_for(caller_tid), @@ -1054,10 +1075,21 @@ impl ProcessTable { // already allocated here; the deserializer cannot select a PID. let mut child = Process::new_allocated_empty(child_task_id); crate::fork::deserialize_allocated_fork_state(&serialized_parent, &mut child)?; + // WHY: bytes preserve an OfdId and scalar snapshot, not object + // identity. Relink before publication so fork and vfork inherit the + // parent's exact open file description instead of a matching copy. + child.ofd_table.link_shared_states_from( + &self + .processes + .get(&parent_pid) + .ok_or(Errno::ESRCH)? + .ofd_table, + )?; // POSIX fork leaves one thread in the child, and that thread inherits // the mask of the task that called fork rather than the process // leader's mask. child.signals.blocked = caller_blocked; + child.vfork_child = mode == wasm_posix_shared::fork_contract::Mode::Vfork; // Bump cross-process refcounts on inherited fd state (host handles, // global pipes, PTYs, socket-pipes). Identical to spawn's needs — @@ -1104,6 +1136,9 @@ impl ProcessTable { if !parent.is_live_explicit_tid(caller_tid) { return Err(Errno::ESRCH); } + if parent.vfork_child { + return Err(Errno::EAGAIN); + } // Compute the SIG_IGN-disposition bitmask for signals 1..=64. let mut ignored_signals: u64 = 0; for sig in 1u32..=64 { @@ -1715,6 +1750,43 @@ mod wait_tests { assert_eq!(table.get(spawn_pid).unwrap().signals.blocked, 0x22); } + #[test] + fn vfork_child_rejects_nested_process_owners() { + use crate::process::test_host::NoopHost; + use crate::spawn::SpawnAttrs; + use wasm_posix_shared::fork_contract::Mode; + + let mut table = ProcessTable::new(); + let parent_pid = table.create_process().unwrap(); + let ordinary_child_pid = table + .fork_process_for_caller_with_mode(parent_pid, parent_pid, Mode::Fork) + .unwrap(); + assert!(!table.get(ordinary_child_pid).unwrap().vfork_child); + + let child_pid = table + .fork_process_for_caller_with_mode(parent_pid, parent_pid, Mode::Vfork) + .unwrap(); + assert!(table.get(child_pid).unwrap().vfork_child); + + assert_eq!( + table.fork_process_for_caller(child_pid, child_pid), + Err(Errno::EAGAIN), + ); + let mut host = NoopHost; + assert_eq!( + table.spawn_child_for_caller( + child_pid, + child_pid, + &[b"/bin/child".as_slice()], + &[], + &[], + &SpawnAttrs::empty(), + &mut host, + ), + Err(Errno::EAGAIN), + ); + } + #[test] fn fork_and_spawn_reject_unallocated_caller_task_ids() { use crate::process::test_host::NoopHost; @@ -2856,7 +2928,7 @@ mod tests { b"/inherited-directory".to_vec(), ); let ofd = parent.ofd_table.get_mut(ofd_idx).unwrap(); - ofd.offset = 4; + ofd.set_directory_offset(4); ofd.dir_host_handle = ITERATOR_HANDLE; ofd.dir_synth_state = 2; ofd.dir_entry_offset = 4; @@ -2893,7 +2965,7 @@ mod tests { .ofd_table .get(child_entry.ofd_ref.0) .unwrap(); - assert_eq!(child_ofd.offset, 4); + assert_eq!(child_ofd.offset(), 4); assert_eq!(child_ofd.dir_entry_offset, 4); assert_eq!(child_ofd.dir_synth_state, 2); assert_eq!(child_ofd.dir_host_handle, -1); @@ -2952,7 +3024,7 @@ mod tests { b"/forked-directory".to_vec(), ); let ofd = parent.ofd_table.get_mut(ofd_idx).unwrap(); - ofd.offset = 3; + ofd.set_directory_offset(3); ofd.dir_host_handle = ITERATOR_HANDLE; ofd.dir_synth_state = 2; ofd.dir_entry_offset = 3; @@ -2980,7 +3052,7 @@ mod tests { .ofd_table .get(child_entry.ofd_ref.0) .unwrap(); - assert_eq!(child_ofd.offset, 3); + assert_eq!(child_ofd.offset(), 3); assert_eq!(child_ofd.dir_entry_offset, 3); assert_eq!(child_ofd.dir_host_handle, -1); assert!(child_ofd.dir_pending_entry.is_none()); @@ -2998,6 +3070,102 @@ mod tests { ); } + #[test] + fn fork_modes_share_mutable_ofd_state_with_exact_lifetime() { + use crate::fd::OpenFileDescRef; + use wasm_posix_shared::flags::{O_APPEND, O_NONBLOCK, O_RDWR}; + use wasm_posix_shared::fork_contract::Mode; + + for mode in [Mode::Fork, Mode::Vfork] { + let mut table = ProcessTable::new(); + let parent_pid = table.create_process().unwrap(); + let ofd_idx = { + let parent = table.get_mut(parent_pid).unwrap(); + let ofd_idx = parent.ofd_table.create( + FileType::Regular, + O_RDWR, + 9_452_010, + b"/shared-ofd".to_vec(), + ); + parent + .fd_table + .alloc(OpenFileDescRef(ofd_idx), 0) + .unwrap(); + let ofd = parent.ofd_table.get_mut(ofd_idx).unwrap(); + ofd.set_offset(4); + ofd.set_owner_pid(parent_pid); + assert_eq!(ofd.shared_state_ref_count(), 1); + ofd_idx + }; + + let child_pid = table + .fork_process_for_caller_with_mode(parent_pid, parent_pid, mode) + .unwrap(); + assert_eq!( + table + .get(parent_pid) + .unwrap() + .ofd_table + .get(ofd_idx) + .unwrap() + .shared_state_ref_count(), + 2, + ); + + { + let child_ofd = table + .get_mut(child_pid) + .unwrap() + .ofd_table + .get_mut(ofd_idx) + .unwrap(); + child_ofd.set_offset(17); + child_ofd.set_status_flags_raw(O_RDWR | O_NONBLOCK); + child_ofd.set_owner_pid(child_pid); + } + let parent_ofd = table + .get(parent_pid) + .unwrap() + .ofd_table + .get(ofd_idx) + .unwrap(); + assert_eq!(parent_ofd.offset(), 17); + assert_eq!(parent_ofd.status_flags(), O_RDWR | O_NONBLOCK); + assert_eq!(parent_ofd.owner_pid(), child_pid); + + table + .get_mut(parent_pid) + .unwrap() + .ofd_table + .get_mut(ofd_idx) + .unwrap() + .set_status_flags_raw(O_RDWR | O_APPEND); + assert_eq!( + table + .get(child_pid) + .unwrap() + .ofd_table + .get(ofd_idx) + .unwrap() + .status_flags(), + O_RDWR | O_APPEND, + ); + + table.remove_process(child_pid).unwrap(); + assert_eq!( + table + .get(parent_pid) + .unwrap() + .ofd_table + .get(ofd_idx) + .unwrap() + .shared_state_ref_count(), + 1, + ); + table.remove_process(parent_pid).unwrap(); + } + } + #[test] fn process_exit_closes_tcp_pipes_orderly() { use crate::pipe::{DEFAULT_PIPE_CAPACITY, PipeBuffer, global_pipe_table}; diff --git a/crates/kernel/src/procfs.rs b/crates/kernel/src/procfs.rs index 768cf7e53f..0cd21bbbc3 100644 --- a/crates/kernel/src/procfs.rs +++ b/crates/kernel/src/procfs.rs @@ -429,11 +429,11 @@ pub fn generate_fdinfo(proc: &Process, fd: i32) -> Option> { let entry = proc.fd_table.get(fd).ok()?; let ofd = proc.ofd_table.get(entry.ofd_ref.0)?; let offset = - crate::descriptor_backing::current_offset(ofd.file_type, ofd.host_handle, ofd.offset) + crate::descriptor_backing::current_offset(ofd.file_type, ofd.host_handle, ofd.offset()) .ok()?; let content = format!( "pos:\t{}\nflags:\t{:o}\nmnt_id:\t0\n", - offset, ofd.status_flags, + offset, ofd.status_flags(), ); Some(content.into_bytes()) } diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 8c1a729631..e5f12635fd 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -590,10 +590,8 @@ fn handle_dsp_ioctl( } SNDCTL_DSP_NONBLOCK => { crate::audio::set_nonblock(handle, true)?; - proc.ofd_table - .get_mut(ofd_idx) - .ok_or(Errno::EBADF)? - .status_flags |= O_NONBLOCK; + let ofd = proc.ofd_table.get_mut(ofd_idx).ok_or(Errno::EBADF)?; + ofd.set_status_flags_raw(ofd.status_flags() | O_NONBLOCK); Ok(()) } SOUND_PCM_READ_RATE => { @@ -954,6 +952,7 @@ fn commit_exec_state_impl( } proc.posix_timers.clear(); proc.fork_child = false; + proc.vfork_child = false; proc.fork_exec_path = None; proc.fork_exec_argv = None; proc.fork_fd_actions.clear(); @@ -3203,20 +3202,21 @@ pub(crate) fn snapshot_scm_rights_fd( path.try_reserve_exact(ofd.path.len()) .map_err(|_| Errno::ENOMEM)?; path.extend_from_slice(&ofd.path); - let mut in_flight = crate::pipe::InFlightFd::new( + let mut in_flight = crate::pipe::InFlightFd::new_with_shared_state( ofd.ofd_id, ofd.file_id, ofd.file_type, - ofd.status_flags, + ofd.status_flags(), ofd.host_handle, - ofd.offset, + ofd.offset(), path, + ofd.shared_state(), ); if ofd.file_type == FileType::Pipe && ofd.host_handle < 0 { let pipe_idx = decode_scm_rights_kernel_pipe_handle(ofd.host_handle)?; in_flight.pipe_ref_kind = unsafe { crate::pipe::global_pipe_table().get(pipe_idx) } - .and_then(|pipe| pipe.reference_kind(ofd.status_flags)); + .and_then(|pipe| pipe.reference_kind(ofd.status_flags())); if in_flight.pipe_ref_kind.is_none() { return Err(Errno::EOPNOTSUPP); } @@ -3300,6 +3300,7 @@ pub(crate) fn install_scm_rights_fds_with_flags( entry.status_flags, entry.host_handle, entry.offset, + entry.shared_state(), // The receiver becomes the only consumer of this snapshot path; // moving it avoids an infallible allocation in a receive path // whose resource failures must remain recoverable. @@ -3457,7 +3458,7 @@ fn release_ofd_reference_impl( ( ofd.host_handle, ofd.file_type, - ofd.status_flags, + ofd.status_flags(), ofd.dir_host_handle, ofd.ofd_id, ) @@ -4133,7 +4134,7 @@ pub fn sys_read( let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; // Check that the fd is open for reading (access mode != O_WRONLY). - let access_mode = ofd.status_flags & O_ACCMODE; + let access_mode = ofd.status_flags() & O_ACCMODE; if ofd.is_path_only() || access_mode == O_WRONLY { return Err(Errno::EBADF); } @@ -4147,7 +4148,7 @@ pub fn sys_read( let host_handle = ofd.host_handle; let file_type = ofd.file_type; - let status_flags = ofd.status_flags; + let status_flags = ofd.status_flags(); match file_type { FileType::Pipe => { if host_handle >= 0 { @@ -4472,7 +4473,7 @@ pub fn sys_read( let current = crate::descriptor_backing::current_offset( ofd.file_type, ofd.host_handle, - ofd.offset, + ofd.offset(), )?; let offset = usize::try_from(current).map_err(|_| Errno::EOVERFLOW)?; let data = synthetic_file_content(&ofd.path).ok_or(Errno::EBADF)?; @@ -4490,7 +4491,11 @@ pub fn sys_read( return Ok(n); } - let current_offset = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?.offset; + let current_offset = proc + .ofd_table + .get(ofd_idx) + .ok_or(Errno::EBADF)? + .offset(); // WHY: Rust owns the ordinary-file cursor. A backend cursor can // be different after fork/SCM_RIGHTS metadata is copied, and a // seek/read pair would expose an intermediate cursor to nested or @@ -4505,7 +4510,10 @@ pub fn sys_read( host.host_read(host_handle, buf)? }; let new_offset = checked_host_cursor_advance(current_offset, buf.len(), n)?; - proc.ofd_table.get_mut(ofd_idx).ok_or(Errno::EBADF)?.offset = new_offset; + proc.ofd_table + .get_mut(ofd_idx) + .ok_or(Errno::EBADF)? + .set_offset(new_offset); Ok(n) } } @@ -4523,14 +4531,14 @@ pub fn sys_write( let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; // Check that the fd is open for writing (access mode != O_RDONLY). - let access_mode = ofd.status_flags & O_ACCMODE; + let access_mode = ofd.status_flags() & O_ACCMODE; if ofd.is_path_only() || access_mode == O_RDONLY { return Err(Errno::EBADF); } let host_handle = ofd.host_handle; let file_type = ofd.file_type; - let status_flags = ofd.status_flags; + let status_flags = ofd.status_flags(); // Zero-length write: POSIX returns 0 without writing if buf.is_empty() { @@ -4721,14 +4729,16 @@ pub fn sys_write( 0, ); } - let offset = ofd.offset.max(0) as usize; + let offset = ofd.offset().max(0) as usize; let max_off = (FB_SMEM_LEN as usize).saturating_sub(offset); let n = buf.len().min(max_off); if n > 0 { - let new_offset = checked_offset_advance(ofd.offset, n)?; + let new_offset = checked_offset_advance(ofd.offset(), n)?; host.fb_write(proc.pid as i32, offset, &buf[..n]); - proc.ofd_table.get_mut(ofd_idx).ok_or(Errno::EBADF)?.offset = - new_offset; + proc.ofd_table + .get_mut(ofd_idx) + .ok_or(Errno::EBADF)? + .set_offset(new_offset); } // Linux fbdev returns the requested length even // when capping at smem_len; we mirror that. @@ -4754,7 +4764,10 @@ pub fn sys_write( let outcome = host.host_append(host_handle, buf, fsize_limit)?; let (written, end) = validate_append_outcome(proc, caller_tid, buf.len(), fsize_limit, outcome)?; - proc.ofd_table.get_mut(ofd_idx).ok_or(Errno::EBADF)?.offset = end; + proc.ofd_table + .get_mut(ofd_idx) + .ok_or(Errno::EBADF)? + .set_offset(end); return Ok(written); } @@ -4802,18 +4815,28 @@ pub fn sys_write( // persistent backend flag or relies on its mutable cursor. let n = host.host_pwrite(host_handle, &buf[..writable_len], start)?; let new_offset = checked_host_cursor_advance(start, writable_len, n)?; - proc.ofd_table.get_mut(ofd_idx).ok_or(Errno::EBADF)?.offset = new_offset; + proc.ofd_table + .get_mut(ofd_idx) + .ok_or(Errno::EBADF)? + .set_offset(new_offset); return Ok(n); } - let current_offset = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?.offset; + let current_offset = proc + .ofd_table + .get(ofd_idx) + .ok_or(Errno::EBADF)? + .offset(); // A successful write consumes some prefix of the attempted // capacity. Prove even the complete attempt is representable // before the host can make an irreversible backing-file change. checked_offset_advance(current_offset, writable_len)?; let n = host.host_write(host_handle, &buf[..writable_len])?; let new_offset = checked_host_cursor_advance(current_offset, writable_len, n)?; - proc.ofd_table.get_mut(ofd_idx).ok_or(Errno::EBADF)?.offset = new_offset; + proc.ofd_table + .get_mut(ofd_idx) + .ok_or(Errno::EBADF)? + .set_offset(new_offset); Ok(n) } } @@ -4889,7 +4912,7 @@ pub fn sys_lseek( ofd.dir_synth_state = offset.min(2) as u8; ofd.dir_entry_offset = offset; ofd.dir_pending_entry = None; - ofd.offset = offset; + ofd.set_directory_offset(offset); if old_dir_handle >= 0 && old_dir_handle != sentinel { let _ = host.host_closedir(old_dir_handle); } @@ -4949,7 +4972,7 @@ pub fn sys_lseek( ofd.dir_synth_state = new_synth_state; ofd.dir_entry_offset = offset; ofd.dir_pending_entry = None; - ofd.offset = offset; + ofd.set_directory_offset(offset); if old_dir_handle >= 0 && old_dir_handle != new_dir_handle { let _ = host.host_closedir(old_dir_handle); } @@ -4962,7 +4985,7 @@ pub fn sys_lseek( if ofd.file_type == FileType::CharDevice { if let Some(dev) = VirtualDevice::from_host_handle(ofd.host_handle) { if dev == VirtualDevice::Fb0 { - let cur = ofd.offset; + let cur = ofd.offset(); let new_off = match whence { SEEK_SET => offset, SEEK_CUR => cur.checked_add(offset).ok_or(Errno::EOVERFLOW)?, @@ -4974,7 +4997,7 @@ pub fn sys_lseek( if new_off < 0 { return Err(Errno::EINVAL); } - ofd.offset = new_off; + ofd.set_offset(new_off); return Ok(new_off); } return Ok(0); @@ -4991,7 +5014,11 @@ pub fn sys_lseek( .ok_or(Errno::EBADF) })?; let current = - crate::descriptor_backing::current_offset(ofd.file_type, ofd.host_handle, ofd.offset)?; + crate::descriptor_backing::current_offset( + ofd.file_type, + ofd.host_handle, + ofd.offset(), + )?; let new_pos = match whence { SEEK_SET => offset, SEEK_CUR => current.checked_add(offset).ok_or(Errno::EOVERFLOW)?, @@ -5008,7 +5035,11 @@ pub fn sys_lseek( if crate::descriptor_backing::is_synthetic_regular_handle(ofd.host_handle) { let size = synthetic_file_content(&ofd.path).map_or(0, |d| d.len() as i64); let current = - crate::descriptor_backing::current_offset(ofd.file_type, ofd.host_handle, ofd.offset)?; + crate::descriptor_backing::current_offset( + ofd.file_type, + ofd.host_handle, + ofd.offset(), + )?; let new_pos = match whence { SEEK_SET => offset, SEEK_CUR => current.checked_add(offset).ok_or(Errno::EOVERFLOW)?, @@ -5032,7 +5063,11 @@ pub fn sys_lseek( .ok_or(Errno::EBADF) })?; let current = - crate::descriptor_backing::current_offset(ofd.file_type, ofd.host_handle, ofd.offset)?; + crate::descriptor_backing::current_offset( + ofd.file_type, + ofd.host_handle, + ofd.offset(), + )?; let new_pos = match whence { SEEK_SET => offset, SEEK_CUR => current.checked_add(offset).ok_or(Errno::EOVERFLOW)?, @@ -5055,7 +5090,10 @@ pub fn sys_lseek( offset } SEEK_CUR => { - let pos = ofd.offset.checked_add(offset).ok_or(Errno::EOVERFLOW)?; + let pos = ofd + .offset() + .checked_add(offset) + .ok_or(Errno::EOVERFLOW)?; if pos < 0 { return Err(Errno::EINVAL); } @@ -5070,7 +5108,7 @@ pub fn sys_lseek( return Err(Errno::EINVAL); } - ofd.offset = new_offset; + ofd.set_offset(new_offset); Ok(new_offset) } @@ -5089,7 +5127,7 @@ pub fn sys_pread( let ofd_idx = resolve_io_ofd(proc, fd)?; let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; - let access_mode = ofd.status_flags & O_ACCMODE; + let access_mode = ofd.status_flags() & O_ACCMODE; if access_mode == O_WRONLY { return Err(Errno::EBADF); } @@ -5271,9 +5309,9 @@ fn write_operation_plan( let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; ( ofd.file_type, - ofd.status_flags, + ofd.status_flags(), ofd.host_handle, - ofd.offset, + ofd.offset(), ofd.is_path_only(), ) }; @@ -5336,7 +5374,7 @@ pub(crate) fn write_operation_budget( fn validate_transfer_input(proc: &Process, fd: i32, offset: Option) -> Result<(), Errno> { let ofd_idx = resolve_io_ofd(proc, fd)?; let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; - if ofd.is_path_only() || ofd.status_flags & O_ACCMODE == O_WRONLY { + if ofd.is_path_only() || ofd.status_flags() & O_ACCMODE == O_WRONLY { return Err(Errno::EBADF); } if matches!(offset, Some(value) if value < 0) { @@ -5398,7 +5436,7 @@ fn stage_transfer_input( let ofd_idx = resolve_io_ofd(proc, fd)?; let (file_type, host_handle, local_offset) = { let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; - (ofd.file_type, ofd.host_handle, ofd.offset) + (ofd.file_type, ofd.host_handle, ofd.offset()) }; match file_type { FileType::Regular | FileType::MemFd => { @@ -5452,7 +5490,10 @@ fn commit_staged_transfer_input( return Err(Errno::EIO); } if !crate::descriptor_backing::set_current_offset(file_type, host_handle, end)? { - proc.ofd_table.get_mut(ofd_idx).ok_or(Errno::EBADF)?.offset = end; + proc.ofd_table + .get_mut(ofd_idx) + .ok_or(Errno::EBADF)? + .set_offset(end); } Ok(()) } @@ -5480,10 +5521,13 @@ fn transfer_output_plan( ) -> Result<(usize, bool), Errno> { let ofd_idx = resolve_io_ofd(proc, fd)?; let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; - if ofd.is_path_only() || ofd.status_flags & O_ACCMODE == O_RDONLY { + if ofd.is_path_only() || ofd.status_flags() & O_ACCMODE == O_RDONLY { return Err(Errno::EBADF); } - if offset.is_none() && ofd.file_type == FileType::Regular && ofd.status_flags & O_APPEND != 0 { + if offset.is_none() + && ofd.file_type == FileType::Regular + && ofd.status_flags() & O_APPEND != 0 + { // WHY: only the backing-owned append operation has a current EOF. // Do not pre-limit against fstat: concurrent growth could raise an // early EFBIG, and concurrent shrink could under-copy. The exact @@ -5511,7 +5555,7 @@ pub fn sys_pwrite( let ofd_idx = resolve_io_ofd(proc, fd)?; let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; - let access_mode = ofd.status_flags & O_ACCMODE; + let access_mode = ofd.status_flags() & O_ACCMODE; if access_mode == O_RDONLY { return Err(Errno::EBADF); } @@ -6125,10 +6169,10 @@ pub fn sys_pipe2(proc: &mut Process, flags: u32) -> Result<(i32, i32), Errno> { let read_ofd_idx = proc.fd_table.get(read_fd)?.ofd_ref.0; let write_ofd_idx = proc.fd_table.get(write_fd)?.ofd_ref.0; if let Some(ofd) = proc.ofd_table.get_mut(read_ofd_idx) { - ofd.status_flags |= O_NONBLOCK; + ofd.set_status_flags_raw(ofd.status_flags() | O_NONBLOCK); } if let Some(ofd) = proc.ofd_table.get_mut(write_ofd_idx) { - ofd.status_flags |= O_NONBLOCK; + ofd.set_status_flags_raw(ofd.status_flags() | O_NONBLOCK); } } @@ -6382,7 +6426,7 @@ pub fn sys_fcntl(proc: &mut Process, fd: i32, cmd: u32, arg: u32) -> Result Result { @@ -6424,7 +6468,7 @@ pub fn sys_fcntl(proc: &mut Process, fd: i32, cmd: u32, arg: u32) -> Result { // Lock operations need the flock struct - use sys_fcntl_lock instead @@ -6533,8 +6577,8 @@ fn sys_fcntl_lock_with_owner( ( ofd.host_handle, ofd.file_type, - ofd.status_flags, - ofd.offset, + ofd.status_flags(), + ofd.offset(), ofd.ofd_id, ) }; @@ -7663,6 +7707,21 @@ pub fn sys_getdents64( } let path = ofd.path.clone(); + let stale_dir_handle = + ofd.directory_iterator_is_stale().then_some(ofd.dir_host_handle); + if let Some(old_handle) = stale_dir_handle { + // WHY: inherited descriptors share the POSIX directory position, but + // the host iterator and pending entry are process-local. Rebuild this + // process's iterator whenever a peer advances the shared cookie. + proc.ofd_table + .get_mut(ofd_idx) + .ok_or(Errno::EBADF)? + .reset_directory_iterator_for_reopen(); + if old_handle >= 0 { + let _ = host.host_closedir(old_handle); + } + } + let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; let reopen_cookie = (ofd.dir_host_handle == -1 && ofd.dir_entry_offset > 2).then_some(ofd.dir_entry_offset); @@ -7697,7 +7756,7 @@ pub fn sys_getdents64( crate::devfs::devfs_getdents64(proc, &path, buf, entry_offset)?; if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { ofd.dir_entry_offset = new_offset; - ofd.offset = new_offset; + ofd.set_directory_offset(new_offset); if exhausted { ofd.dir_host_handle = -2; } @@ -7741,7 +7800,7 @@ pub fn sys_getdents64( )?; if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { ofd.dir_entry_offset = new_offset; - ofd.offset = new_offset; + ofd.set_directory_offset(new_offset); if exhausted { ofd.dir_host_handle = -2; } @@ -7755,7 +7814,7 @@ pub fn sys_getdents64( crate::procfs::procfs_getdents64(proc, &path, buf, entry_offset, &pids)?; if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { ofd.dir_entry_offset = new_offset; - ofd.offset = new_offset; + ofd.set_directory_offset(new_offset); if exhausted { ofd.dir_host_handle = -2; // mark exhausted } @@ -7796,7 +7855,7 @@ pub fn sys_getdents64( if written == 0 { if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { ofd.dir_entry_offset = entry_offset - 1; - ofd.offset = entry_offset - 1; + ofd.set_directory_offset(entry_offset - 1); ofd.dir_synth_state = 2 + i as u8; } if pos == 0 { @@ -7808,7 +7867,7 @@ pub fn sys_getdents64( } if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { ofd.dir_entry_offset = entry_offset; - ofd.offset = entry_offset; + ofd.set_directory_offset(entry_offset); ofd.dir_host_handle = -2; } return Ok(pos); @@ -7859,7 +7918,7 @@ pub fn sys_getdents64( if written == 0 { if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { ofd.dir_entry_offset = entry_offset - 1; // didn't emit this one - ofd.offset = entry_offset - 1; + ofd.set_directory_offset(entry_offset - 1); ofd.dir_synth_state = next_synth_state; } if pos == 0 { @@ -7890,7 +7949,7 @@ pub fn sys_getdents64( if name_len > name_buf.len() { if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { ofd.dir_entry_offset = entry_offset; - ofd.offset = entry_offset; + ofd.set_directory_offset(entry_offset); } if pos == 0 { return Err(Errno::EIO); @@ -7908,7 +7967,7 @@ pub fn sys_getdents64( Err(err) => { if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { ofd.dir_entry_offset = entry_offset; - ofd.offset = entry_offset; + ofd.set_directory_offset(entry_offset); } if pos == 0 { return Err(err); @@ -7955,7 +8014,7 @@ pub fn sys_getdents64( name: name.into_owned(), }); ofd.dir_entry_offset = entry_offset - 1; - ofd.offset = entry_offset - 1; + ofd.set_directory_offset(entry_offset - 1); } if pos == 0 { return Err(Errno::EINVAL); @@ -7999,7 +8058,7 @@ pub fn sys_getdents64( // Save progress — we'll resume from here on next call if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { ofd.dir_entry_offset = entry_offset - 1; - ofd.offset = entry_offset - 1; + ofd.set_directory_offset(entry_offset - 1); ofd.dir_host_handle = -3; // signal: host exhausted, virtuals pending ofd.dir_synth_state = 2 + i as u8; } @@ -8024,7 +8083,7 @@ pub fn sys_getdents64( // Persist the entry offset for subsequent calls if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { ofd.dir_entry_offset = entry_offset; - ofd.offset = entry_offset; + ofd.set_directory_offset(entry_offset); } Ok(pos) @@ -9077,7 +9136,7 @@ pub(crate) fn fd_supports_mmap_writeback(proc: &Process, fd: i32) -> bool { }; ofd.file_type == FileType::Regular && ofd.host_handle >= 0 - && (ofd.status_flags & O_ACCMODE) == O_RDWR + && (ofd.status_flags() & O_ACCMODE) == O_RDWR } /// mmap -- supports anonymous, file-backed MAP_PRIVATE and MAP_SHARED mappings. @@ -13240,7 +13299,7 @@ fn poll_check(proc: &mut Process, host: &mut dyn HostIO, fds: &mut [WasmPollFd]) let pipe_idx = (-(ofd.host_handle + 1)) as usize; let pipe = unsafe { crate::pipe::global_pipe_table().get(pipe_idx) }; if let Some(pipe) = pipe { - match ofd.status_flags & O_ACCMODE { + match ofd.status_flags() & O_ACCMODE { O_RDONLY => { if pollfd.events & POLLIN != 0 && pipe.available() > 0 { revents |= POLLIN; @@ -14026,9 +14085,13 @@ pub fn sys_ioctl( let ofd = proc.ofd_table.get_mut(ofd_idx).ok_or(Errno::EBADF)?; let pcm_handle = (ofd.file_type == FileType::PcmPlayback).then_some(ofd.host_handle); if val != 0 { - ofd.status_flags |= wasm_posix_shared::flags::O_NONBLOCK; + ofd.set_status_flags_raw( + ofd.status_flags() | wasm_posix_shared::flags::O_NONBLOCK, + ); } else { - ofd.status_flags &= !wasm_posix_shared::flags::O_NONBLOCK; + ofd.set_status_flags_raw( + ofd.status_flags() & !wasm_posix_shared::flags::O_NONBLOCK, + ); } if let Some(handle) = pcm_handle { crate::audio::set_nonblock(handle, val != 0)?; @@ -14044,9 +14107,9 @@ pub fn sys_ioctl( let val = i32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); let ofd = proc.ofd_table.get_mut(ofd_idx).ok_or(Errno::EBADF)?; if val != 0 { - ofd.status_flags |= wasm_posix_shared::flags::O_ASYNC; + ofd.set_status_flags_raw(ofd.status_flags() | wasm_posix_shared::flags::O_ASYNC); } else { - ofd.status_flags &= !wasm_posix_shared::flags::O_ASYNC; + ofd.set_status_flags_raw(ofd.status_flags() & !wasm_posix_shared::flags::O_ASYNC); } return Ok(()); } @@ -14669,6 +14732,9 @@ pub fn sys_clone( // channel mailbox but the kernel stores masks by TID. let caller_tid = table.current_tid(); let pid = table.current_pid(); + if table.get(pid).is_some_and(|proc| proc.vfork_child) { + return Err(Errno::EAGAIN); + } let tid = table.create_thread(pid, caller_tid, stack_ptr, effective_tls, effective_ctid)?; let _ = flags & CLONE_PARENT_SETTID; @@ -15586,7 +15652,7 @@ pub fn sys_ftruncate( let ofd_idx = entry.ofd_ref.0; let (file_type, status_flags, host_handle) = { let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; - (ofd.file_type, ofd.status_flags, ofd.host_handle) + (ofd.file_type, ofd.status_flags(), ofd.host_handle) }; // Must be a regular file or memfd @@ -18359,7 +18425,7 @@ mod tests { let (dir_fd, dir_ofd) = install_fd(&mut proc, FileType::Directory, 71, b"/dir"); { let ofd = proc.ofd_table.get_mut(dir_ofd).unwrap(); - ofd.offset = 4; + ofd.set_offset(4); ofd.dir_synth_state = 2; ofd.dir_entry_offset = 4; } @@ -18369,7 +18435,7 @@ mod tests { ); let ofd = proc.ofd_table.get(dir_ofd).unwrap(); assert_eq!( - (ofd.offset, ofd.dir_synth_state, ofd.dir_entry_offset), + (ofd.offset(), ofd.dir_synth_state, ofd.dir_entry_offset), (4, 2, 4) ); @@ -18379,12 +18445,12 @@ mod tests { crate::procfs::PROCFS_DIR_HANDLE, b"/proc", ); - proc.ofd_table.get_mut(proc_ofd).unwrap().offset = 3; + proc.ofd_table.get_mut(proc_ofd).unwrap().set_offset(3); assert_eq!( sys_lseek(&mut proc, &mut host, proc_fd, -1, SEEK_SET), Err(Errno::EINVAL) ); - assert_eq!(proc.ofd_table.get(proc_ofd).unwrap().offset, 3); + assert_eq!(proc.ofd_table.get(proc_ofd).unwrap().offset(), 3); let (fb_fd, fb_ofd) = install_fd( &mut proc, @@ -18397,7 +18463,7 @@ mod tests { sys_lseek(&mut proc, &mut host, fb_fd, i64::MAX, SEEK_CUR), Err(Errno::EOVERFLOW) ); - assert_eq!(proc.ofd_table.get(fb_ofd).unwrap().offset, 7); + assert_eq!(proc.ofd_table.get(fb_ofd).unwrap().offset(), 7); let synthetic_handle = crate::descriptor_backing::alloc_synthetic_regular(); let (synthetic_fd, synthetic_ofd) = @@ -18414,7 +18480,7 @@ mod tests { crate::descriptor_backing::current_offset( FileType::Regular, synthetic_handle, - proc.ofd_table.get(synthetic_ofd).unwrap().offset, + proc.ofd_table.get(synthetic_ofd).unwrap().offset(), ), Ok(2) ); @@ -19674,7 +19740,7 @@ mod tests { ofd.dir_host_handle, ofd.dir_synth_state, ofd.dir_entry_offset, - ofd.offset, + ofd.offset(), ofd.dir_pending_entry.clone(), ) }; @@ -19696,7 +19762,7 @@ mod tests { ofd.dir_host_handle, ofd.dir_synth_state, ofd.dir_entry_offset, - ofd.offset, + ofd.offset(), ofd.dir_pending_entry.clone(), ), before, @@ -19725,7 +19791,7 @@ mod tests { ofd.dir_host_handle, ofd.dir_synth_state, ofd.dir_entry_offset, - ofd.offset, + ofd.offset(), ofd.dir_pending_entry.clone(), ) }; @@ -19743,7 +19809,7 @@ mod tests { after_open_error.dir_host_handle, after_open_error.dir_synth_state, after_open_error.dir_entry_offset, - after_open_error.offset, + after_open_error.offset(), after_open_error.dir_pending_entry.clone(), ), snapshot, @@ -19764,7 +19830,7 @@ mod tests { after_replay_error.dir_host_handle, after_replay_error.dir_synth_state, after_replay_error.dir_entry_offset, - after_replay_error.offset, + after_replay_error.offset(), after_replay_error.dir_pending_entry.clone(), ), snapshot, @@ -19968,7 +20034,7 @@ mod tests { assert_eq!( ( proc.ofd_table.get(ofd_idx).unwrap().dir_entry_offset, - proc.ofd_table.get(ofd_idx).unwrap().offset, + proc.ofd_table.get(ofd_idx).unwrap().offset(), ), (0, 0), ); @@ -23296,9 +23362,9 @@ mod tests { ofd.ofd_id, ofd.file_id, ofd.file_type, - ofd.status_flags, + ofd.status_flags(), ofd.host_handle, - ofd.offset, + ofd.offset(), ofd.path.clone(), ) }; @@ -23353,9 +23419,9 @@ mod tests { ofd.ofd_id, ofd.file_id, ofd.file_type, - ofd.status_flags, + ofd.status_flags(), ofd.host_handle, - ofd.offset, + ofd.offset(), ofd.path.clone(), ); assert_eq!(queued.retain_reference(), Err(Errno::EOPNOTSUPP)); @@ -23513,9 +23579,9 @@ mod tests { carried_ofd.ofd_id, carried_ofd.file_id, carried_ofd.file_type, - carried_ofd.status_flags, + carried_ofd.status_flags(), carried_ofd.host_handle, - carried_ofd.offset, + carried_ofd.offset(), carried_ofd.path.clone(), ), ) @@ -23589,9 +23655,9 @@ mod tests { carried_ofd.ofd_id, carried_ofd.file_id, carried_ofd.file_type, - carried_ofd.status_flags, + carried_ofd.status_flags(), carried_ofd.host_handle, - carried_ofd.offset, + carried_ofd.offset(), carried_ofd.path.clone(), ), ) @@ -23748,7 +23814,7 @@ mod tests { let sender_ofd = sender.ofd_table.get(sender_ofd_idx).unwrap(); assert_eq!(sender_ofd.dir_host_handle, 200); assert_eq!(sender_ofd.dir_entry_offset, 3); - assert_eq!(sender_ofd.offset, 3); + assert_eq!(sender_ofd.offset(), 3); assert_eq!( sender_ofd.dir_pending_entry.as_ref().unwrap().name, b"foo.txt" @@ -23762,7 +23828,7 @@ mod tests { let received_ofd = receiver.ofd_table.get(received_ofd_idx).unwrap(); assert_eq!(received_ofd.dir_host_handle, -1); assert_eq!(received_ofd.dir_entry_offset, 3); - assert_eq!(received_ofd.offset, 3); + assert_eq!(received_ofd.offset(), 3); assert!(received_ofd.dir_pending_entry.is_none()); // A failed lazy reopen preserves the unopened snapshot state so the @@ -23814,7 +23880,7 @@ mod tests { } #[test] - fn fork_and_spawn_directories_reopen_at_the_snapshot_cookie() { + fn fork_and_spawn_directories_follow_one_shared_cookie() { use crate::process_table::ProcessTable; use crate::spawn::SpawnAttrs; @@ -23887,34 +23953,83 @@ mod tests { .unwrap(); assert_eq!(child_ofd.dir_host_handle, -1); assert_eq!(child_ofd.dir_entry_offset, 3); - assert_eq!(child_ofd.offset, 3); + assert_eq!(child_ofd.offset(), 3); assert!(child_ofd.dir_pending_entry.is_none()); + } - let mut one = [0u8; 32]; - let len = sys_getdents64(table.get_mut(pid).unwrap(), &mut host, parent_fd, &mut one) - .unwrap(); - assert_eq!(parse_linux_dirents64(&one, len)[0].2, b"foo.txt"); + let mut one = [0u8; 32]; + let len = sys_getdents64( + table.get_mut(fork_child).unwrap(), + &mut host, + parent_fd, + &mut one, + ) + .unwrap(); + assert_eq!(parse_linux_dirents64(&one, len)[0].2, b"foo.txt"); + for pid in [parent_pid, fork_child, spawn_child] { + let entry = table.get(pid).unwrap().fd_table.get(parent_fd).unwrap(); + assert_eq!( + table + .get(pid) + .unwrap() + .ofd_table + .get(entry.ofd_ref.0) + .unwrap() + .offset(), + 4, + ); } - // Both child iterators resumed independently, while the parent kept - // its live pending record at the same snapshot cookie. + let len = sys_getdents64( + table.get_mut(spawn_child).unwrap(), + &mut host, + parent_fd, + &mut one, + ) + .unwrap(); + assert_eq!(parse_linux_dirents64(&one, len)[0].2, b"bar.txt"); + for pid in [parent_pid, fork_child, spawn_child] { + let entry = table.get(pid).unwrap().fd_table.get(parent_fd).unwrap(); + assert_eq!( + table + .get(pid) + .unwrap() + .ofd_table + .get(entry.ofd_ref.0) + .unwrap() + .offset(), + 5, + ); + } + + // The parent's host iterator still carries a now-obsolete pending + // record. A read must discard it, replay the shared cookie, and see + // EOF rather than exposing `foo.txt` a second time. + assert_eq!( + sys_getdents64( + table.get_mut(parent_pid).unwrap(), + &mut host, + parent_fd, + &mut one, + ), + Ok(0), + ); let parent_ofd = table .get(parent_pid) .unwrap() .ofd_table .get(parent_ofd_idx) .unwrap(); - assert_eq!(parent_ofd.dir_host_handle, 200); - assert_eq!(parent_ofd.dir_entry_offset, 3); - assert_eq!( - parent_ofd.dir_pending_entry.as_ref().unwrap().name, - b"foo.txt" - ); + assert_eq!(parent_ofd.offset(), 5); + assert_eq!(parent_ofd.dir_entry_offset, 5); + assert_eq!(parent_ofd.dir_host_handle, -2); + assert!(parent_ofd.dir_pending_entry.is_none()); for pid in [fork_child, spawn_child, parent_pid] { sys_close(table.get_mut(pid).unwrap(), &mut host, parent_fd).unwrap(); } - assert_eq!(host.closed_dir_handles, [201, 202, 200]); + host.closed_dir_handles.sort_unstable(); + assert_eq!(host.closed_dir_handles, [200, 201, 202, 203]); assert_eq!( host.closed_handles .iter() @@ -23939,11 +24054,11 @@ mod tests { let sender_fd = sys_memfd_create(&mut sender, b"scm-ofd", 0).unwrap(); set_whole_file_ofd_lock(&mut sender, &mut locks, &mut host, sender_fd); - let (expected_ofd_id, expected_file_id) = { + let (sender_ofd_idx, expected_ofd_id, expected_file_id) = { let fd_entry = sender.fd_table.get(sender_fd).unwrap(); let ofd = sender.ofd_table.get(fd_entry.ofd_ref.0).unwrap(); assert_eq!(ofd.file_type, FileType::MemFd); - (ofd.ofd_id, ofd.file_id) + (fd_entry.ofd_ref.0, ofd.ofd_id, ofd.file_id) }; let deferred_before = crate::pipe::deferred_in_flight_release_state(); let queued = retain_fd_for_scm_rights(&sender, sender_fd); @@ -23953,6 +24068,14 @@ mod tests { assert_eq!(deferred_retained.0, deferred_before.0); assert_eq!(deferred_retained.1, deferred_before.1 + 1); + // SCM_RIGHTS transfers the open file description, not a frozen copy + // of its scalar fields. Mutations after send must remain observable + // even when the sender closes before the receiver installs the fd. + let sender_ofd = sender.ofd_table.get(sender_ofd_idx).unwrap(); + sender_ofd.set_offset(19); + sender_ofd.set_status_flags_raw(O_RDWR | O_NONBLOCK); + sender_ofd.set_owner_pid(9_999); + // Closing the sender is not the final machine reference while the // descriptor is queued, so OFD/flock ownership must remain live. sys_close_with_locks(&mut sender, &mut locks, &mut host, sender_fd).unwrap(); @@ -23971,6 +24094,9 @@ mod tests { assert_eq!(received_ofd.ofd_id, expected_ofd_id); assert_eq!(received_ofd.file_id, expected_file_id); assert_eq!(received_ofd.file_type, FileType::MemFd); + assert_eq!(received_ofd.offset(), 19); + assert_eq!(received_ofd.status_flags(), O_RDWR | O_NONBLOCK); + assert_eq!(received_ofd.owner_pid(), 9_999); sys_close_with_locks(&mut receiver, &mut locks, &mut host, received[0]).unwrap(); assert!(locks.is_empty()); @@ -25920,6 +26046,7 @@ mod tests { .unwrap(); proc.alarm_deadline_ns = 8_000_000_000; proc.alarm_interval_ns = 1_000_000_000; + proc.vfork_child = true; proc.posix_timers.push(Some(PosixTimerState { clock_id: 0, sigev_signo: SIGINT, @@ -25951,6 +26078,7 @@ mod tests { assert_eq!(proc.alarm_deadline_ns, 8_000_000_000); assert_eq!(proc.alarm_interval_ns, 1_000_000_000); assert!(proc.posix_timers.is_empty()); + assert!(!proc.vfork_child); assert!(proc.has_exec); } @@ -28395,7 +28523,10 @@ mod tests { .ofd_table .get(proc.fd_table.get(1).unwrap().ofd_ref.0) .unwrap(); - assert_ne!(ofd.status_flags & wasm_posix_shared::flags::O_NONBLOCK, 0); + assert_ne!( + ofd.status_flags() & wasm_posix_shared::flags::O_NONBLOCK, + 0 + ); // Clear it let mut buf = 0i32.to_le_bytes(); sys_ioctl(&mut proc, &mut host, 1, 0x5421, &mut buf).unwrap(); @@ -28403,7 +28534,10 @@ mod tests { .ofd_table .get(proc.fd_table.get(1).unwrap().ofd_ref.0) .unwrap(); - assert_eq!(ofd.status_flags & wasm_posix_shared::flags::O_NONBLOCK, 0); + assert_eq!( + ofd.status_flags() & wasm_posix_shared::flags::O_NONBLOCK, + 0 + ); } #[test] @@ -28585,7 +28719,7 @@ mod tests { let fd = result.unwrap(); let entry = proc.fd_table.get(fd).unwrap(); let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); - assert_eq!(ofd.status_flags & O_NOFOLLOW, 0); // Not in status flags + assert_eq!(ofd.status_flags() & O_NOFOLLOW, 0); // Not in status flags } #[test] @@ -29407,8 +29541,8 @@ mod tests { // Verify O_NONBLOCK is set on the OFDs let r_ofd = proc.ofd_table.get(r_entry.ofd_ref.0).unwrap(); let w_ofd = proc.ofd_table.get(w_entry.ofd_ref.0).unwrap(); - assert_ne!(r_ofd.status_flags & O_NONBLOCK, 0); - assert_ne!(w_ofd.status_flags & O_NONBLOCK, 0); + assert_ne!(r_ofd.status_flags() & O_NONBLOCK, 0); + assert_ne!(w_ofd.status_flags() & O_NONBLOCK, 0); } #[test] @@ -29643,7 +29777,7 @@ mod tests { ) .unwrap(); let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; - proc.ofd_table.get_mut(ofd_idx).unwrap().offset = 29; + proc.ofd_table.get_mut(ofd_idx).unwrap().set_offset(29); host.pread_reported = Some(2); let mut byte = [0u8; 1]; @@ -29651,11 +29785,11 @@ mod tests { sys_read(&mut proc, &mut host, fd, &mut byte), Err(Errno::EIO), ); - assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, 29); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset(), 29); host.pwrite_reported = Some(2); assert_eq!(sys_write(&mut proc, &mut host, fd, b"x"), Err(Errno::EIO),); - assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, 29); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset(), 29); } #[test] @@ -29673,29 +29807,35 @@ mod tests { let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; let mut byte = [0u8; 1]; - proc.ofd_table.get_mut(ofd_idx).unwrap().offset = i64::MAX - 1; + proc.ofd_table + .get_mut(ofd_idx) + .unwrap() + .set_offset(i64::MAX - 1); assert_eq!(sys_read(&mut proc, &mut host, fd, &mut byte), Ok(1)); - assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, i64::MAX); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset(), i64::MAX); host.pread_reported = Some(0); assert_eq!(sys_read(&mut proc, &mut host, fd, &mut byte), Ok(0),); - assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, i64::MAX); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset(), i64::MAX); assert_eq!( host.pread_calls.len(), 2, "the host must be consulted to distinguish EOF from overflow", ); - proc.ofd_table.get_mut(ofd_idx).unwrap().offset = i64::MAX - 1; + proc.ofd_table + .get_mut(ofd_idx) + .unwrap() + .set_offset(i64::MAX - 1); assert_eq!(sys_write(&mut proc, &mut host, fd, b"x"), Ok(1)); - assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, i64::MAX); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset(), i64::MAX); assert_eq!(host.pwrite_calls.len(), 1); assert_eq!( sys_write(&mut proc, &mut host, fd, b"x"), Err(Errno::EOVERFLOW), ); - assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, i64::MAX); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset(), i64::MAX); assert_eq!( host.pwrite_calls.len(), 1, @@ -29751,7 +29891,7 @@ mod tests { ); let entry = proc.fd_table.get(fd).unwrap(); assert_eq!( - proc.ofd_table.get(entry.ofd_ref.0).unwrap().offset, + proc.ofd_table.get(entry.ofd_ref.0).unwrap().offset(), 37, "positioned I/O must not mutate the shared open-file cursor", ); @@ -29842,11 +29982,11 @@ mod tests { assert_eq!(sys_write(&mut proc, &mut host, fd, b"cd"), Ok(2)); assert_eq!(host.append_calls, vec![(100, b"cd".to_vec(), None)]); let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; - assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, 12); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset(), 12); // Positioned writes ignore O_APPEND and leave the OFD cursor intact. assert_eq!(sys_pwrite(&mut proc, &mut host, duplicate, b"X", 1), Ok(1)); - assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, 12); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset(), 12); assert_eq!( host.pwrite_calls, vec![(100, 3, b"ab".to_vec()), (100, 1, b"X".to_vec()),], @@ -29864,7 +30004,7 @@ mod tests { host.seek_calls.clear(); assert_eq!(sys_write(&mut proc, &mut host, fd, b"Y"), Ok(1)); assert_eq!(host.pwrite_calls.last(), Some(&(100, 4, b"Y".to_vec())),); - assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, 5); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset(), 5); assert!( host.seek_calls.is_empty(), "ordinary regular writes must not synchronize a backend cursor", @@ -29884,7 +30024,7 @@ mod tests { ) .unwrap(); let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; - proc.ofd_table.get_mut(ofd_idx).unwrap().offset = 7; + proc.ofd_table.get_mut(ofd_idx).unwrap().set_offset(7); host.stat_size = i64::MAX as u64; assert_eq!( @@ -29893,14 +30033,14 @@ mod tests { ); assert_eq!(host.append_calls, vec![(100, b"x".to_vec(), None)]); assert!(host.append_mutations.is_empty()); - assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, 7); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset(), 7); host.append_calls.clear(); host.stat_size = 20; host.append_reported = Some(2); assert_eq!(sys_write(&mut proc, &mut host, fd, b"x"), Err(Errno::EIO),); assert_eq!(host.append_calls, vec![(100, b"x".to_vec(), None)]); - assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, 7); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset(), 7); } #[test] @@ -29916,18 +30056,18 @@ mod tests { ) .unwrap(); let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; - proc.ofd_table.get_mut(ofd_idx).unwrap().offset = 3; + proc.ofd_table.get_mut(ofd_idx).unwrap().set_offset(3); host.stat_size = 8; host.append_reported = Some(2); host.append_end = Some(1); assert_eq!(sys_write(&mut proc, &mut host, fd, b"ab"), Err(Errno::EIO),); - assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, 3); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset(), 3); host.append_end = Some(11); sys_setrlimit(&mut proc, RLIMIT_FSIZE, 10, 10).unwrap(); assert_eq!(sys_write(&mut proc, &mut host, fd, b"ab"), Err(Errno::EIO),); - assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, 3); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset(), 3); } #[test] @@ -29947,7 +30087,7 @@ mod tests { assert_eq!(sys_write(&mut proc, &mut host, fd, b"abcd"), Ok(2)); let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; - assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, 42); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset(), 42); assert_eq!(host.append_mutations, vec![b"ab".to_vec()]); } @@ -30294,7 +30434,7 @@ mod tests { assert_eq!(sys_write(&mut proc, &mut host, fd, b"abcde"), Ok(2)); let entry = proc.fd_table.get(fd).unwrap(); - assert_eq!(proc.ofd_table.get(entry.ofd_ref.0).unwrap().offset, 10); + assert_eq!(proc.ofd_table.get(entry.ofd_ref.0).unwrap().offset(), 10); assert!(host.seek_calls.is_empty()); assert_eq!(host.append_calls, vec![(100, b"abcde".to_vec(), Some(10))],); assert_eq!(host.append_mutations, vec![b"ab".to_vec()]); @@ -30316,12 +30456,12 @@ mod tests { ) .unwrap(); let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; - proc.ofd_table.get_mut(ofd_idx).unwrap().offset = 4; + proc.ofd_table.get_mut(ofd_idx).unwrap().set_offset(4); sys_setrlimit(&mut proc, RLIMIT_FSIZE, 10, 10).unwrap(); assert_eq!(sys_write(&mut proc, &mut host, fd, b"x"), Err(Errno::EFBIG),); assert!(fsize_signal_pending(&proc)); - assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset, 4); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().offset(), 4); assert_eq!(host.append_calls, vec![(100, b"x".to_vec(), Some(10))],); assert!(host.append_mutations.is_empty()); } @@ -30440,13 +30580,13 @@ mod tests { let empty_input = sys_memfd_create(&mut proc, b"empty-input", 0).unwrap(); let output_entry = proc.fd_table.get(output).unwrap(); let output_ofd = output_entry.ofd_ref.0; - let original_offset = proc.ofd_table.get(output_ofd).unwrap().offset; + let original_offset = proc.ofd_table.get(output_ofd).unwrap().offset(); assert_eq!( sys_sendfile(&mut proc, &mut host, output, empty_input, -1, 4), Ok(0) ); assert_eq!( - proc.ofd_table.get(output_ofd).unwrap().offset, + proc.ofd_table.get(output_ofd).unwrap().offset(), original_offset ); assert!(host.seek_calls.is_empty()); @@ -31348,6 +31488,7 @@ mod tests { fn test_fork_child_fields_default_to_false() { let proc = Process::new(1); assert!(!proc.fork_child); + assert!(!proc.vfork_child); assert!(proc.fork_exec_path.is_none()); assert!(proc.fork_exec_argv.is_none()); assert!(proc.fork_fd_actions.is_empty()); @@ -33658,7 +33799,7 @@ mod tests { let entry = proc.fd_table.get(accepted_fd).unwrap(); let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); assert_eq!( - ofd.status_flags & O_NONBLOCK, + ofd.status_flags() & O_NONBLOCK, 0, "accept() should leave the accepted OFD blocking", ); @@ -35816,6 +35957,31 @@ mod tests { assert!(table.get(pid).unwrap().get_thread(tid as u32).is_some()); } + #[test] + fn test_vfork_child_rejects_thread_clone() { + let mut table = crate::process_table::ProcessTable::new(); + let pid = table.create_process().unwrap(); + table.get_mut(pid).unwrap().vfork_child = true; + table.bind_current_tid(pid, pid).unwrap(); + const CLONE_VM: u32 = 0x00000100; + const CLONE_THREAD: u32 = 0x00010000; + + assert_eq!( + sys_clone( + &mut table, + 0, + 0x8000, + CLONE_VM | CLONE_THREAD, + 0, + 0, + 0, + 0, + ), + Err(Errno::EAGAIN), + ); + assert!(table.get(pid).unwrap().get_thread(pid + 1).is_none()); + } + #[test] fn test_gettid_returns_pid_for_main_thread() { let _guard = THREAD_IDENTITY_LOCK.lock().unwrap(); @@ -36108,7 +36274,7 @@ mod tests { let entry = proc.fd_table.get(fd).unwrap(); let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); assert_eq!(ofd.file_type, FileType::EventFd); - assert_eq!(ofd.status_flags & O_ACCMODE, O_RDWR); + assert_eq!(ofd.status_flags() & O_ACCMODE, O_RDWR); } #[test] @@ -36197,7 +36363,7 @@ mod tests { let fd = sys_eventfd2(&mut proc, 0, O_NONBLOCK).unwrap(); let entry = proc.fd_table.get(fd).unwrap(); let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); - assert_ne!(ofd.status_flags & O_NONBLOCK, 0); + assert_ne!(ofd.status_flags() & O_NONBLOCK, 0); } #[test] @@ -36375,7 +36541,7 @@ mod tests { let entry = proc.fd_table.get(fd).unwrap(); let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); - assert_ne!(ofd.status_flags & O_NONBLOCK, 0); + assert_ne!(ofd.status_flags() & O_NONBLOCK, 0); } #[test] @@ -36618,7 +36784,7 @@ mod tests { let entry = proc.fd_table.get(fd).unwrap(); let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); - assert_ne!(ofd.status_flags & O_NONBLOCK, 0); + assert_ne!(ofd.status_flags() & O_NONBLOCK, 0); } #[test] diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index 9908a29915..0bab44ac5d 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -1771,11 +1771,19 @@ pub extern "C" fn kernel_reap_process(pid: u32) -> i32 { /// Fork a process in the process table on behalf of a validated parent task. /// Clones parent's Process state under a kernel-allocated child pid and /// preserves the calling task's signal mask in the child. +/// +/// Both modes inherit the same kernel-owned process state. The distinction is +/// carried explicitly because the host memory/lifetime transaction differs: +/// ordinary fork owns a memory clone, while genuine vfork will borrow the +/// parent's memory and suspend only its calling thread until exec or exit. /// Returns the child pid on success, negative errno on error. #[unsafe(no_mangle)] -pub extern "C" fn kernel_fork_process(parent_pid: u32, caller_tid: u32) -> i32 { +pub extern "C" fn kernel_fork_process(parent_pid: u32, caller_tid: u32, mode: u32) -> i32 { + let Some(mode) = wasm_posix_shared::fork_contract::Mode::from_u32(mode) else { + return -(Errno::EINVAL as i32); + }; let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - match table.fork_process_for_caller(parent_pid, caller_tid) { + match table.fork_process_for_caller_with_mode(parent_pid, caller_tid, mode) { Ok(child_pid) => child_pid as i32, Err(e) => -(e as i32), } @@ -6506,7 +6514,7 @@ pub extern "C" fn kernel_get_pipe_ofds(buf_ptr: *mut u8, buf_len: u32) -> i32 { // positive host_handle index parity to distinguish read/write. // Since pipe pairs share the same |host_handle|, we check status_flags // for O_WRONLY (bit 0) to determine end. - let is_read = if ofd.status_flags & 1 == 0 { + let is_read = if ofd.status_flags() & 1 == 0 { 1u32 } else { 0u32 @@ -9486,7 +9494,7 @@ pub extern "C" fn kernel_accept4( if flags & SOCK_NONBLOCK != 0 { if let Ok(entry) = proc.fd_table.get(new_fd) { if let Some(ofd) = proc.ofd_table.get_mut(entry.ofd_ref.0) { - ofd.status_flags |= O_NONBLOCK; + ofd.set_status_flags_raw(ofd.status_flags() | O_NONBLOCK); } } } @@ -13386,7 +13394,7 @@ pub extern "C" fn kernel_is_fd_nonblock(pid: u32, fd: i32) -> i32 { Err(_) => -1, }; } - if ofd.status_flags & wasm_posix_shared::flags::O_NONBLOCK != 0 { + if ofd.status_flags() & wasm_posix_shared::flags::O_NONBLOCK != 0 { 1 } else { 0 diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index ceed1614ca..96acebc2e7 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -169,6 +169,36 @@ pub mod process_metadata_contract { pub const KIND_ENVIRONMENT: u32 = 1; } +/// Cross-layer selectors for the process-creation import that begins fork +/// continuation capture. +/// +/// The value is carried by the guest `kernel.kernel_fork` import, translated +/// to the corresponding host-intercepted syscall, and passed to the Rust +/// process-table export. Keeping it in shared ABI metadata prevents libc, +/// fork instrumentation, and the Node/browser hosts from assigning different +/// meanings to the same i32. +pub mod fork_contract { + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[repr(u32)] + pub enum Mode { + Fork = 0, + Vfork = 1, + } + + impl Mode { + pub const fn from_u32(value: u32) -> Option { + match value { + value if value == Self::Fork as u32 => Some(Self::Fork), + value if value == Self::Vfork as u32 => Some(Self::Vfork), + _ => None, + } + } + } + + pub const MODE_FORK: u32 = Mode::Fork as u32; + pub const MODE_VFORK: u32 = Mode::Vfork as u32; +} + /// Packed host/kernel wire layout for one process-table snapshot record. /// /// This record is not a native Rust or C structure: the `u64` field is @@ -2351,6 +2381,18 @@ pub mod abi { use ProgramArtifactValueType::{AnyRef, ExnRef, ExternRef, FuncRef, I32, I64, Pointer}; + /// Exact process-worker import that seeds main-program fork discovery. + /// + /// Side modules continue to enter through `env.fork`; this requirement is + /// therefore validated conditionally only when a program imports + /// `kernel.kernel_fork`. + pub const WPK_FORK_PROCESS_IMPORT: ProgramArtifactImport = ProgramArtifactImport { + module: "kernel", + name: "kernel_fork", + params: &[I32], + results: &[I32], + }; + pub const WPK_FORK_REQUIRED_IMPORTS: &[ProgramArtifactImport] = &[ ProgramArtifactImport { module: WPK_FORK_FRAME_IMPORT_MODULE, @@ -3587,7 +3629,7 @@ pub mod abi { WPK_FORK_MODULE_STATE_TABLE_BASELINE_FINGERPRINT_SIZE, WPK_FORK_MODULE_STATE_TABLE_DESCRIPTOR_PAYLOAD_SIZE, WPK_FORK_MODULE_STATE_TABLE_PAGE_SHIFT, WPK_FORK_REQUIRED_EXPORTS, - WPK_FORK_REQUIRED_IMPORTS, WPK_FORK_REQUIRED_TABLE_IMPORTS, + WPK_FORK_PROCESS_IMPORT, WPK_FORK_REQUIRED_IMPORTS, WPK_FORK_REQUIRED_TABLE_IMPORTS, extended_syscalls::SYSCALLS, wpk_fork_linked_chunk_header_size, wpk_fork_linked_node_header_size, wpk_fork_module_state_chunk_header_size, }; @@ -3701,6 +3743,22 @@ pub mod abi { #[test] fn linked_fork_program_artifact_contract_is_complete_and_sorted() { + assert_eq!(crate::fork_contract::MODE_FORK, 0); + assert_eq!(crate::fork_contract::MODE_VFORK, 1); + assert_eq!( + crate::fork_contract::Mode::from_u32(crate::fork_contract::MODE_FORK), + Some(crate::fork_contract::Mode::Fork), + ); + assert_eq!( + crate::fork_contract::Mode::from_u32(crate::fork_contract::MODE_VFORK), + Some(crate::fork_contract::Mode::Vfork), + ); + assert_eq!(crate::fork_contract::Mode::from_u32(2), None); + assert_eq!(WPK_FORK_PROCESS_IMPORT.module, "kernel"); + assert_eq!(WPK_FORK_PROCESS_IMPORT.name, "kernel_fork"); + assert_eq!(WPK_FORK_PROCESS_IMPORT.params, &[super::ProgramArtifactValueType::I32]); + assert_eq!(WPK_FORK_PROCESS_IMPORT.results, &[super::ProgramArtifactValueType::I32]); + assert_eq!(WPK_FORK_LINKED_FRAME_FORMAT_MAGIC, *b"KLCF"); assert_eq!(WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE, 24); assert_eq!(WPK_FORK_LINKED_FRAME_REQUIRED_FLAGS, 0b11); diff --git a/docs/abi-versioning.md b/docs/abi-versioning.md index 856cc109b9..7b51e1de37 100644 --- a/docs/abi-versioning.md +++ b/docs/abi-versioning.md @@ -383,6 +383,45 @@ loading treat the capability as part of the artifact contract. Missing, duplicate, malformed, unknown-version, unknown-bit, or safety-bit-free capabilities fail before execution. +ABI 43 also gives the process fork import an explicit transaction mode. +`kernel.kernel_fork` changes from `() -> i32` to `(i32) -> i32`, where mode 0 +is ordinary fork and mode 1 is vfork. The process Worker maps those modes to +`SYS_FORK` and `SYS_VFORK`, carries the selected mode through capture, parent +replay, abort replay, child launch, and Worker initialization, and rejects a +different mode at the inherited call site. The centralized host passes the +same mode to the incompatible +`kernel_fork_process(parent_pid, caller_tid, mode)` export; Rust rejects any +unknown value with `EINVAL`. Artifact admission requires the exact import +signature, and the ABI snapshot owns both values. + +For mode 1, the instrumented process Worker also places the exact aligned +private-prefix bytes in host-intercepted `SYS_VFORK` argument 0 and the +page-rounded reference/exception scratch high-water in argument 1. The host +admits at most 61,440 prefix bytes and 65,536 scratch bytes and returns +`EAGAIN` before the Rust child allocation when either bound is exceeded. This +is an ABI 43 semantic channel contract: it changes no syscall number, linked +frame encoding, kernel import/export signature, or structural snapshot field. +Ordinary `SYS_FORK` keeps all six arguments zero. + +Mode 1 now selects the shared-memory vfork lifetime. A separate child Worker +retains the parent's existing `Shared WebAssembly.Memory`; it constructs no +child process Memory and copies no address-space bytes. The child receives a +private syscall channel, bounded replay workspace, Wasm instance, loader, and +continuation controller. The asynchronous import keeps only the calling parent +thread parked until successful exec commit or exact `_exit()`/signal/trap +teardown, while sibling pthreads remain runnable. Failed exec returns to the +child without ending the lifetime. Ambiguous forced termination contains the +whole shared address space rather than publishing an unsafe parent return. +Ordinary fork behavior is unchanged. + +The exact-generation lifetime records and Node/browser Worker messages used to +coordinate launch and teardown are host-private protocol, not persisted guest +ABI. No new linked-frame field, marker getter, or public kernel export was +needed beyond the ABI 43 mode-aware import/export and bounded workspace +arguments described above. Release still requires the broader conformance, +upstream CRuby, Homebrew, and resident-memory proofs; those gates do not alter +the structural ABI decision. + The instrumenter also rejects any input that already carries fork control exports, linked-frame imports, or fork metadata. This prevents a transformed ABI 42 module from being run through the ABI 43 tool merely to acquire the new diff --git a/docs/architecture.md b/docs/architecture.md index e44e358ee0..0d7c496655 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,7 +73,7 @@ kernel_create_process() → assigned_pid | -errno kernel_create_process_with_stdio(stdin_kind, stdout_kind, stderr_kind) → assigned_pid | -errno kernel_validate_task(pid, tid) → 0 | -errno kernel_set_current_tid(pid, tid) → 0 | -errno -kernel_fork_process(parent_pid, caller_tid) → assigned_child_pid | -errno +kernel_fork_process(parent_pid, caller_tid, mode) → assigned_child_pid | -errno kernel_spawn_process(parent_pid, caller_tid, blob_ptr, blob_len) → assigned_child_pid | -errno kernel_remove_process(pid) → 0 kernel_handle_channel(channel_offset, channel_capacity, pid, retry_token) → result @@ -690,10 +690,11 @@ message or failed receiver fd allocation releases it and removes OFD/`flock()` records only if it was the true final reference. Destructors enqueue fixed cleanup metadata into pre-reserved, high-water storage, and cleanup runs after pipe-table borrows end. The host schedules the syscall but -never stores or examines lock state. Ordinary regular-file offsets and status -flags still live in per-process OFD records, so their sharing across fork and -`SCM_RIGHTS` remains the separate global-OFD gap documented in -[future-improvements.md](future-improvements.md). +never stores or examines lock state. Each process keeps its descriptor and OFD +table shell, but mutable offset, status flags, and async owner live in an +exactly owned `Rc` state shared across fork, vfork, spawn, and supported +`SCM_RIGHTS`. A queued descriptor keeps that same state live, so receipt sees +mutations made after send rather than a frozen scalar snapshot. On an AF_UNIX stream, retained rights are associated with absolute byte ranges in the stream rather than a separate first-in/first-out side queue. A receive @@ -1001,8 +1002,9 @@ embedded ABI version, linked-frame contract, control exports, and ABI 43 `FORK_CAP_ACTIVATION_STATE_SAFE` claim. Pthread and side-module entry points apply the same policy. -1. User calls `fork()` → musl → `__syscall(SYS_clone, ...)` → glue -2. The host's `kernel_fork` override begins one process continuation +1. User calls `fork()` → musl → `kernel_fork(FORK)`; the process adapter + validates the ABI-owned mode. +2. The host's `kernel_fork(mode)` override begins one process continuation transaction. It captures activation catalogs and module state, maps each participating activation's root continuation chunk, and calls `wpk_fork_unwind_begin(root + chunk_header_size)`. The tool-injected export @@ -1015,8 +1017,9 @@ apply the same policy. into one process recipe graph and the frame stores only its reference-vector ordinal. The host maps additional page-rounded chunks when necessary. No accepted frame names a module-instance reference-table slot. -4. Once `_start` returns (top-of-stack), the host sends SYS_FORK through the channel. -5. Kernel's `kernel_fork_process(parent_pid, caller_tid)` validates the caller, +4. Once `_start` returns (top-of-stack), the host sends `SYS_FORK` through the + channel for the captured ordinary-fork mode. +5. Kernel's `kernel_fork_process(parent_pid, caller_tid, mode)` validates the caller, allocates the child PID from the global task-ID sequence, and copies process metadata and the fd/OFD tables. The child receives the calling task's blocked signal mask, while inherited stateful descriptors retain references to their @@ -1039,13 +1042,44 @@ apply the same policy. 8. Each instrumented function's preamble requests and validates the next committed frame, then re-enters the call site where the parent was interrupted. Eventually it reaches the `kernel_fork` call site in the leaf function, which returns 0. Libc then refreshes the copied pthread TID from the kernel through `set_tid_address` before returning to user code. 9. `wpk_fork_rewind_end` resets state; parent and child independently unmap their continuation chunks; fork returns 0 in child and the child PID in the parent. +ABI 43 also lets libc call `kernel_fork(VFORK)`, which remains the same exact +mode through unwind/replay and reaches `SYS_VFORK`, the kernel export, and the +child Worker initialization record. After admission and kernel child creation, +that mode branches away from ordinary step 6: the host retains an exact alias +to the parent's existing `Shared WebAssembly.Memory` and launches a separate +child Worker without constructing or copying a child process Memory. The child +receives its own syscall channel, host-reserved replay workspace, Wasm +instance, loader, and continuation controller. Only the calling parent thread +stays parked in the asynchronous fork import; sibling pthreads continue to +run. + +The kernel marks the vfork child's independent Process record. Nested fork, +vfork, spawn, and pthread clone fail with `EAGAIN`; failed exec preserves the +marker and returns to the child. Successful exec commit, `_exit()`, and exact +signal/trap teardown quiesce the borrowing Worker, release its alias and +workspace, and resume the exact parked caller once. An ambiguous forced Worker +termination cannot prove that shared-memory access stopped, so the host +contains the complete address-space owner group rather than resuming the +parent unsafely. Ordinary fork continues to use only the ordinary mode and the +independent-memory path above. + +After vfork capture seals, its process Worker reports two exact workspace +requirements in host-intercepted syscall arguments: all active activation +prefixes after alignment and the reference/exception codec scratch high-water. +The centralized host accepts one four-page control slot and returns `EAGAIN` +before allocating the kernel child when the 61,440-byte prefix region or +65,536-byte scratch page would be exceeded. This preflight is connected, but +the workspace belongs to host-reserved control storage rather than a second +process address space. + Step 6 is materially different from native virtual-memory fork. Native kernels normally map the parent's pages into the child with copy-on-write ownership, so unchanged pages are not copied. Browser WebAssembly exposes no equivalent operation for cloning a `WebAssembly.Memory`. Kandelo must allocate -a fresh memory and copy the parent's complete current address space before the -child runs. A child that immediately calls `exec()` therefore pays for both -the discarded fork copy and the replacement program memory. Kandelo's +a fresh memory and copy the parent's complete current address space before an +ordinary fork child runs. An ordinary fork child that immediately calls +`exec()` therefore pays for both the discarded fork copy and the replacement +program memory. Kandelo's non-forking `posix_spawn()` path avoids that copy when the caller can describe the requested child entirely with spawn actions and attributes. @@ -1163,15 +1197,15 @@ from a pthread after dynamic loading; the fork child reconstructs only the calling thread but receives the process module/table recipe state. The generation fast path avoids reparsing or reinstantiating unchanged state. -Fork and non-forking spawn still copy each process's fd and OFD metadata. The -objects whose mutable state must remain identical across those copies use -refcounted kernel-global backings: eventfd counters, timerfd timers, signalfd -masks, memfd contents and cursors, and procfs snapshots and cursors. Pipes, -sockets, PTYs, terminal devices, and listener queues likewise retain their -existing global object identity. Ordinary regular-file OFD metadata, including -the seek position and status flags, is still copied rather than shared; that -remaining POSIX gap is tracked in [posix-status.md](posix-status.md) and -[future-improvements.md](future-improvements.md). +Fork and non-forking spawn copy each process's descriptor-table shell while +retaining one exact mutable OFD state object. Offset, status flags, and async +owner therefore remain shared across the copies. Directory host iterators are +process-local because their handles and pending records cannot be owned by two +processes safely; a shared position generation makes a stale iterator close, +reopen, and replay at the authoritative cookie before its next read. Stateful +objects additionally retain their kernel-global backings: eventfd counters, +timerfd timers, signalfd masks, memfd contents and cursors, procfs snapshots, +pipes, sockets, PTYs, terminal devices, and listener queues. ### exec() diff --git a/docs/fork-instrumentation.md b/docs/fork-instrumentation.md index ef72e41d9a..042d5bd361 100644 --- a/docs/fork-instrumentation.md +++ b/docs/fork-instrumentation.md @@ -388,18 +388,18 @@ main nodes may occupy several mappings and may contain a frame larger than one WebAssembly page. The coordinator completes and validates both continuations before it sends `SYS_FORK`. -### Borrowed replay foundation (not guest-visible vfork) +### Borrowed vfork replay A genuine vfork child cannot run ordinary copied activation or side-module -replay over the parent's live `Shared WebAssembly.Memory`. The ABI 43 host now -has an unwired foundation for that future launch mode. A fresh child Worker -validates the parent's process-wide module-state arena as borrowed, rebuilds -the complete activation registry, and gives every active main or side -activation its own child-private fixed prefix. Replay reads the parent's -committed frame nodes and recipe records but never marks nodes consumed, -releases mappings, clears the process launch anchor, or deallocates the -module-state arena. Failure midway through attachment detaches every child -controller so the suspended parent can still replay the original transaction. +replay over the parent's live `Shared WebAssembly.Memory`. The ABI 43 vfork +path launches a fresh child Worker that validates the parent's process-wide +module-state arena as borrowed, rebuilds the complete activation registry, and +gives every active main or side activation its own child-private fixed prefix. +Replay reads the parent's committed frame nodes and recipe records but never +marks nodes consumed, releases mappings, clears the process launch anchor, or +deallocates the module-state arena. Failure midway through attachment detaches +every child controller so the suspended parent can replay the original +transaction. Dynamic-linker reconstruction has a matching fail-closed mode. It accepts only shared Memory, passive data segments, and a complete loader transaction. @@ -412,12 +412,25 @@ start and active segments into the explicit staged bootstrap described above. An in-flight bootstrap, relocation, or constructor is rejected because guest code at that boundary may write arbitrary shared process memory. -The ordinary no-option path remains copied fork replay and retains independent -address-space ownership. These primitives do not make guest-visible vfork -functional: Kandelo's libc `vfork()` is still an alias for `fork()`, and no -kernel fork mode, child launch protocol, or parent suspension path calls the -borrowed APIs. That remaining semantic/protocol work must be explicit in the -ABI 43 batch and snapshot rather than hidden under the existing fork contract. +The ordinary mode remains copied fork replay and retains independent +address-space ownership. ABI 43 now distinguishes `kernel_fork(FORK)` from +`kernel_fork(VFORK)`, preserves the mode through unwind and replay, maps it to +`SYS_FORK` or `SYS_VFORK`, and carries it through the kernel and child-launch +protocol. Libc `vfork()` therefore no longer aliases the `fork()` wrapper or +runs `pthread_atfork` handlers. + +The vfork mode connects those borrowed APIs to the production Node and browser +launch paths. It retains the parent's Memory, parks the calling thread, and +releases that caller only after successful exec commit or exact child +teardown. The child owns private replay, loader, channel, and continuation +control state even though its ordinary guest loads and stores address the +borrowed bytes. Broad conformance, pristine upstream CRuby selection, +Homebrew lifecycle, and real resident-memory growth remain release gates +rather than properties inferred from component tests. A sibling-delivered +fatal signal against a compute-running borrower is tested separately: because +no browser Worker API provides an exact quiescence fence in that state, every +host contains the complete shared address space instead of resuming the +parent unsafely. ## Save buffer format @@ -459,7 +472,7 @@ callbacks pointed at the borrowed nodes. Making only the host replay cursor read-only is insufficient: passing the owner's prefix would overwrite the owner's active-frame word. Borrowed replay must leave node states and mappings untouched so the owner can later replay and release them. This is an internal -host invariant; it does not make ABI 43 `vfork()` functional. +host invariant used by the connected ABI 43 `vfork()` path. This does not introduce a new linked-frame encoding. `fixed_prefix_size` has always been a module-specific value in the version-1 descriptor, and each node @@ -468,6 +481,17 @@ and report their larger historical prefix; newly instrumented artifacts report the prefix they actually use. Import/export names, descriptor fields, and host parsing semantics are unchanged. +After sealing a vfork capture, the process Worker sums the aligned +`fixed_prefix_size` values for exactly the active activations. The reference +transaction separately reports the page-rounded scratch-capacity high-water +observed while the same generated codecs encoded the graph. Host-intercepted +`SYS_VFORK` carries those values in arguments 0 and 1. The centralized host +accepts at most one control slot: 61,440 prefix bytes and one 65,536-byte +scratch page. It returns `EAGAIN` before `kernel_fork_process` when either does +not fit. This is host transaction metadata, not a new linked-frame field, and +the admitted storage remains host-reserved rather than a copied child address +space. + At the first fork call, the host maps one page-rounded root large enough for the chunk header and fixed prefix. Each postamble already knows its own exact frame size and passes it to `reserve`; no extra frame-size-counting diff --git a/docs/future-improvements.md b/docs/future-improvements.md index 723b094039..aa7560e8ef 100644 --- a/docs/future-improvements.md +++ b/docs/future-improvements.md @@ -82,33 +82,6 @@ browser product that ships them. ## Kernel -### Per-process ordinary OFD metadata still breaks POSIX fork sharing -Open File Descriptions live inside `Process` (`crates/kernel/src/ofd.rs`'s -`OfdTable`), not in a kernel-global table. POSIX requires a child descriptor to -refer to the same open file description as its parent counterpart. Kandelo now -retains exact refcounted backings for stateful objects that cannot be safely -reconstructed: pipes, sockets, PTYs, eventfd, timerfd, signalfd, memfd, and -procfs snapshots/cursors. Those fixes preserve the underlying object state but -do not make the ordinary OFD record itself global. - -Regular-file seek positions, status flags, and owners are therefore still -deep-copied at fork/spawn and when `SCM_RIGHTS` installs a transferred regular -file in another process. The ancillary queue already preserves the stable -`OfdId`, `FileId`, backing lifetime, and OFD/`flock()` ownership; the remaining -gap is the mutable ordinary-file OFD metadata itself. A program that forks or -passes a regular fd and coordinates writes through it can observe divergent -positions or flag changes. - -The cleanest redesign is still to move OFDs to a kernel-global `OfdTable` and -have `Process` hold `FdTable`, where `OfdRef` is a stable index. Fork's -fd inheritance then becomes the pointer/refcount operation POSIX describes, -and much of the per-resource inheritance bookkeeping can collapse into the -global OFD lifetime. - -Cost of the redesign: locking / borrow-checker complexity around the global table, plus a careful migration that doesn't regress the syscall hot path. Worth scheduling on the next big initiative — the savings compound across fork, spawn, exec, and dup. - -**Files:** `crates/kernel/src/ofd.rs`, `crates/kernel/src/process.rs`, `crates/kernel/src/process_table.rs`, `crates/kernel/src/fork.rs`, `crates/kernel/src/syscalls.rs` - ### `sys_openat` duplicates `sys_open` logic `sys_openat` reimplements umask application, file type determination, creation flag stripping, and O_CLOEXEC handling rather than sharing code with `sys_open`. Consider extracting a shared internal helper or implementing `sys_open` as `sys_openat(proc, host, AT_FDCWD, path, oflags, mode)`. diff --git a/docs/measurements/2026-07-31-affordable-fork-then-exec.md b/docs/measurements/2026-07-31-affordable-fork-then-exec.md index b26856704b..8d675c8d74 100644 --- a/docs/measurements/2026-07-31-affordable-fork-then-exec.md +++ b/docs/measurements/2026-07-31-affordable-fork-then-exec.md @@ -24,15 +24,12 @@ one import closure and instance-global state, make pthread callers and side modules substantially harder to isolate, and make a child `exec()` retire the Worker that must later resume the parent. -This record establishes that architecture but does **not** claim that genuine -`vfork()` is implemented. The integration branch now carries the proposed ABI -43 activation-state protocol, but it still has no vfork guest import or fork -mode, kernel marker, child launch protocol, libc selection path, or parent -suspension path. Those semantic and structural choices must be explicit in the -same ABI 43 batch and snapshot, with the required approval, rather than hidden -under the copied-fork contract. +The integration branch now implements that core architecture. This is not yet +a broad release or Homebrew completion claim: complete conformance suites, +published upstream CRuby artifacts, real resident set size (RSS), artifact +publication, and the exact Homebrew lifecycle remain explicit gates. -Five independently reviewable foundations are implemented now: +The independently reviewable implementation now includes: - ordinary `fork()` performs retired-memory admission before constructing or copying child memory and returns `EAGAIN` when the retirement ledger is @@ -44,15 +41,30 @@ Five independently reviewable foundations are implemented now: mutable prefix; - a cross-host lifetime coordinator admits only one borrower per address space and distinguishes exact parent resumption, safe pre-launch failure, - and ambiguous termination that requires whole-address-space containment; and + and ambiguous termination that requires whole-address-space containment; - ABI 43 activation and dynamic-linker reconstruction can borrow the sealed process manifest and parent frame nodes while giving every active main/side - activation a private prefix and refusing loader-controlled memory writes. - -The last four are not connected to a guest-visible vfork path in ABI 43. They -prove and enforce host ownership, replay, and terminal-gating primitives the -selected architecture needs; they do not by themselves implement the guest -mode, child launch, or parent-channel completion. + activation a private prefix and refusing loader-controlled memory writes; +- ABI 43 owns ordinary/vfork mode values, an exact `(i32) -> i32` process + import, mode-stable capture/replay, a mode-aware kernel export, and symmetric + Node/browser child-launch metadata; +- the kernel marks the independent Process record created for a vfork + transaction, rejects nested fork, spawn, and pthread creation with `EAGAIN`, + and clears the marker only after successful exec replaces the borrowed + image; +- the production Node and browser hosts launch a separate vfork child Worker + over an exact alias to the parent's existing Memory without constructing or + copying a child process Memory; +- private syscall-channel, replay-prefix, reference-codec, loader, and + continuation-control state prevents the child from overwriting the parked + parent's control state; +- the asynchronous fork import parks only the calling parent thread through + failed exec and until successful exec commit or exact + `_exit()`/signal/trap teardown; +- fork, vfork, non-forking spawn, and supported `SCM_RIGHTS` retain one + exactly owned mutable OFD state for offsets, status flags, and async owner, + while directory host iterators remain process-local and replay the shared + cookie. Sparse exact cloning reduced resident set size (RSS) in a controlled sparse memory case, but increased scan/copy time and has no real Homebrew result. It @@ -84,16 +96,24 @@ The reviewable implementation slices on this branch are: - `2822cb109`, separate-Worker borrowed replay in Chromium, Firefox, and WebKit; - `ab2873a25`, the ABI 43 forward-port of borrowed process-wide activation and - write-free side-module reconstruction; and + write-free side-module reconstruction; - `6e71ac438`, ABI 43 main-continuation and wasm-ld reconstruction proofs in - Chromium, Firefox, and WebKit; and + Chromium, Firefox, and WebKit; - `6f481ba85`, ABI 43 active-side-continuation borrowing in separate Workers on - Chromium, Firefox, and WebKit. - -All slices after the ordinary-fork admission guard are deliberately unwired -foundations or component proofs. The guest import, libc, kernel state, Worker -protocol, Node/browser lifecycle integration, and fork-instrument seed changes -remain in the coordinated ABI series. + Chromium, Firefox, and WebKit; +- `1a245ddec` through `68d858757`, explicit mode, kernel ownership guards, + bounded workspace admission, and private borrowed replay state; +- `faef5e3d8` and `903dfa4ea`, truthful admission and shared-memory launch; +- `2d9cc839e` through `7765b75a2`, host-reserved control ownership, stable + parent anchors, and pthread-safe libc restoration; +- `c19fa2e44` through `43dedc20f`, cross-engine exit, suspension, zero-copy, + signal, trap, and fatal-teardown proofs; +- `02e2d60a4`, exact shared inherited OFD state; +- `eeb7d50cc`, the Node/browser POSIX process-state fixture; and +- `7961f47a1`, cross-engine compute-running borrower containment. + +The connected commits remain individual in the ABI 43 integration train. They +must not be squashed when the umbrella branch is linearized. The temporary CRuby change on [PR #1166](https://github.com/Automattic/kandelo/pull/1166) was inspected from @@ -205,11 +225,15 @@ instance receives its own value. Stale objects that depend on the legacy shared-memory channel-base fallback must fail the new ABI epoch rather than enter vfork. -The slot's fork-save/scratch page also supplies the child-private replay -prefix described below. The main prefix and an active side-module prefix must -fit before launch; otherwise vfork returns `EAGAIN` before creating a borrower. -The serialized descriptor already gives the exact prefix size. A future -implementation must not assume every possible pair fits merely because normal +The slot's fork-save page also supplies the child-private replay prefixes +described below, while its otherwise-unused TLS/control page supplies typed +reference and exception codec scratch. Every active main/side prefix must fit +within 61,440 bytes and the page-rounded scratch high-water must fit within +65,536 bytes. Otherwise vfork returns `EAGAIN` before allocating a child PID or +creating a borrower. Version-1 descriptors already give each exact prefix +size; the parent reference transaction now records the capacity high-water +observed while the same generated codecs encode the inherited graph. The +implementation must not assume every possible graph fits merely because normal programs use a small prefix. The host memory allocator needs retained leases for this one explicit sharing @@ -258,11 +282,12 @@ module and the same shared Memory to a module Worker. Chromium, Firefox, and WebKit each replayed through a private prefix without child allocation or release, left every parent chunk byte-identical, and then allowed the parent instance to replay and release the chain. This is cross-engine component -evidence for safe separate-Worker replay. It is not yet a guest-visible vfork -process-lifecycle test. +evidence for safe separate-Worker replay. At that checkpoint it was not yet a +guest-visible vfork process-lifecycle test; the later connected validation is +recorded below. -The host now has the corresponding unwired process-wide side-module -foundation. Borrowed dynamic-linker replay reconstructs each complete archive +The host has the corresponding connected process-wide side-module path. +Borrowed dynamic-linker replay reconstructs each complete archive entry at the parent's exact memory and table bases while creating fresh Worker-local instances, tables, imported globals, symbol maps, and activation controllers. After all activations exist, the process manifest identifies the @@ -321,8 +346,9 @@ point for both modes. It already: - validates the exact calling task; - creates a globally unique PID and one-task child; - inherits the caller's blocked signal mask; -- copies descriptor and OFD metadata while retaining kernel-global backing - references; +- copies the process-local descriptor/OFD table shell, relinks each inherited + OFD's mutable offset/status/owner state to the parent's exact object, and + retains kernel-global backing references; - copies cwd, credentials, process group, session, umask, limits, and signal dispositions; - preserves parentage and wait/reaping state; and @@ -335,9 +361,9 @@ credential checks, process-group changes, `exec()` commit, signal death, zombie state, and `waitpid()` therefore continue through the ordinary kernel path. -The coordinator must add an explicit vfork-child state so operations that -would create ambiguous shared-memory ownership fail before mutation. The -initial implementation should return `EAGAIN` for: +The kernel Process now carries an internal vfork-child marker so operations +that would create ambiguous shared-memory ownership fail before mutation. It +returns `EAGAIN` for: - another active vfork from the same address space; - nested vfork or ordinary fork from a vfork child; @@ -347,8 +373,16 @@ initial implementation should return `EAGAIN` for: Those calls are outside the permitted vfork-child pre-exec use. Returning a truthful failure is safer than silently treating them as ordinary fork or -allowing channel/control collisions. Sequential vfork calls after the prior -child completes remain supported and must be tested. +allowing channel/control collisions. The production lifecycle fixture covers +sequential vfork calls after the prior child completes. + +The marker is set only for the explicit vfork mode, is not inherited through +serialized process state, survives failed exec, and clears only when exec +successfully commits the replacement Process image. Process removal naturally +retires it on `_exit()` or signal death. It is kernel-internal state: no getter, +new export, or additional guest ABI field is needed because the mode-aware +fork export and Rust-owned spawn and clone paths enforce the affected +transitions directly. For nested `fork()`/`vfork()`, the vfork-child Worker must reject directly in its `kernel_fork` import before `beginUnwind()`, frame reservation, anchor @@ -399,7 +433,7 @@ leases keep the old backing alive for the child, but the coordinator suppresses the stale parent completion and releases the parent alias through the normal generation ledger. -### Host lifetime coordinator foundation +### Host lifetime coordinator The shared `VforkLifetimeCoordinator` records exact parent and child generation objects and keys active borrowing by `WebAssembly.Memory`, not numeric PID. It @@ -417,25 +451,25 @@ produces `contain-address-space`, never a normal return. Failed exec merely increments diagnostic state and leaves the lifetime pending. The coordinator retains the exact parent generation in every disposition. -The eventual Node/browser integration must still compare it with the current -PID registration before completing the parked channel; this preserves the -existing stale-generation suppression when a sibling pthread execs or exits -the parent. The 13 focused state-machine tests cover an unresolved caller gate +The Node/browser integrations compare it with the current PID registration +before completing the parked channel; this preserves the existing +stale-generation suppression when a sibling pthread execs or exits the parent. +The 13 focused state-machine tests cover an unresolved caller gate with unrelated event-loop progress, repeated failed exec, all exact terminal reasons, pre-launch rollback, pre-launch signal death, ambiguous termination, competing terminal notifications, overlapping/nested `EAGAIN`, distinct concurrent address spaces, sequential reuse, child-generation reuse rejection, and stale-parent identity. -This coordinator is deliberately unwired until the ABI mode and Process marker -are coordinated. Existing async `onFork` completion is the actual caller-thread -parking transport, and existing Worker-quiescence and exact-generation detach -ledgers remain the source of terminal evidence. +Production Node and browser handlers wire the coordinator to the ABI mode and +Process marker. Existing async `onFork` completion is the caller-thread parking +transport, and Worker-quiescence plus exact-generation detach ledgers remain +the source of terminal evidence. ### Test matrix for the vfork series -The vfork implementation is not complete until tests prove all of the -following on Node and the applicable browser hosts: +The broad vfork completion claim remains gated on the following evidence on +Node and the applicable browser hosts: - no `WebAssembly.Memory` constructor and no full-memory copy occur on vfork; - a main-thread caller cannot pass the call site before child exec/_exit; @@ -444,6 +478,8 @@ following on Node and the applicable browser hosts: - failed exec returns to the child and leaves the parent parked; - successful exec, `_exit`, caught signal death, trap, and Worker crash each settle exactly once and cannot wedge or prematurely resume the parent; +- a fatal signal delivered while the child has no pending syscall contains the + complete shared address space rather than publishing an unsafe parent return; - descriptors/OFDs, cwd, credentials, signal masks/dispositions, process groups, parentage, zombie state, and wait/reaping match the kernel contract; - repeated sequential calls work and unsupported overlapping/nested calls @@ -581,7 +617,7 @@ remove the pre-reclamation peak that motivates vfork. The browser process-retirement integration ran 100 real fork/exec iterations per engine and passed in Chromium, Firefox, and WebKit. This validates the ordinary fork/exec and retirement path affected by pre-copy admission. It is -not evidence for unimplemented vfork behavior or a browser RSS ceiling. +not evidence for the later connected vfork behavior or a browser RSS ceiling. ## Upstream CRuby integration @@ -602,48 +638,59 @@ as uid 1000 naturally selects upstream vfork for this eligible async-safe fork/exec path. Root and other privileged shapes intentionally retain ordinary fork. No Ruby-specific command classification is needed. -The worktree-local SDK already defaults `ac_cv_func_vfork=yes`, but -`packages/registry/ruby/build-ruby.sh` overrides it with -`ac_cv_func_vfork=no`. That override is truthful today because Kandelo's -`vfork()` is only an alias for fork. It must change to `yes` only after the -platform implementation and conformance evidence land in the coordinated ABI -epoch. - -PR #1166 adds `kandelo-posix-spawn.patch` and applies it to `process.c`. Its -tests correctly constrain the temporary exception to command shapes that the -current spawn contract can reproduce. That source patch is not part of the -vfork design and must be deleted, not generalized. +The ABI 43 integration recipe now supplies truthful working-vfork cache +answers, retains `HAVE_VFORK` and `HAVE_WORKING_VFORK`, and removes the +temporary `kandelo-posix-spawn.patch`. The checksum-pinned source tree still +receives Kandelo's unrelated portability edits and library-root patch, but +upstream `process.c` is no longer modified. Recipe assertions reject both +residue from PR #1166 and a configuration that does not enable the upstream +vfork branch. + +The first clean configure exposed an independent cross-probe error. The +recipe had disabled `getresuid()` and `getresgid()` while accidentally +allowing CRuby's AIX-only `getuidx()` and `getgidx()` fallback to be detected +from the build host. Kandelo libc and the kernel already implement the two +POSIX saved-ID queries, so the recipe now reports those functions as present +and the AIX interfaces as absent. No Ruby or libc source workaround was +added. ## ABI and release impact Changing `kernel.kernel_fork` from `() -> i32` to `(i32) -> i32` is an -incompatible process ABI change. The coordinated implementation must include: +incompatible process ABI change. The current ABI 43 integration checkpoint +includes: -- an `ABI_VERSION` bump from the active batch's base; +- the batch's `ABI_VERSION` 43 selection; - regenerated `abi/snapshot.json` and generated TypeScript constants; - libc `_Fork()`/`fork()`/`vfork()` callers with explicit mode constants; - host import closures for main and pthread Workers; -- side-module `env.fork` mode propagation; -- `ForkLaunchRequest` and Worker-init protocol metadata for vfork, inherited - process-control offset, and borrowed replay; -- fork-instrument tests proving a parameterized seed call preserves its mode - through unwind/replay; -- loud rejection of stale ABI 42 programs, packages, and VFS images; and -- rebuild/publish of every ABI-bound kernel, program, package archive, and VFS - artifact. - -As of this record, draft -[PR #1096](https://github.com/Automattic/kandelo/pull/1096) already owns the -ABI 43 activation-state-safe fork epoch, while draft -[PR #1098](https://github.com/Automattic/kandelo/pull/1098) also carries ABI 43 -host/kernel work. Brandon must choose the exact agreed base and whether vfork -joins that epoch or follows it. This branch must not independently claim ABI -43 or restack either draft. +- mode-stable main/pthread capture, replay, and abort replay; +- `ForkLaunchRequest` and Worker-init mode metadata in both hosts; +- host-intercepted `SYS_VFORK` arguments 0 and 1 carrying the measured replay + prefix and codec-scratch bytes, with bounded pre-PID admission; +- a mode-aware `kernel_fork_process(parent, caller, mode)` export; and +- exact artifact admission for the parameterized process import. + +The connected implementation now includes child-private process control, +borrowed Node/browser replay, parent-caller suspension, exact terminal +release, and side-module and nested-call failure proofs. Release still +requires broad stale-artifact rejection evidence plus rebuild and publication +of every ABI-bound kernel, program, package archive, and VFS artifact. + +The selected integration branch combines drafts +[PR #1096](https://github.com/Automattic/kandelo/pull/1096) and +[PR #1098](https://github.com/Automattic/kandelo/pull/1098) with the accepted +ABI 43 batch. This is development and review composition, not merge approval; +kernel, ABI, libc, host, and fork-instrument changes still require Brandon's +explicit approval before merge. The linked continuation descriptor does not need a new serialized field for borrowed replay, because it already carries `fixed_prefix_size`. The Worker -protocol does need separate addresses for the parent's continuation root and -the child's mutable prefix. The ABI bump is still mandatory: old host +protocol does need separate addresses for the parent's continuation root, the +child's mutable prefixes, and its codec scratch. The intercepted syscall now +carries exact byte requirements, but no new linked-frame field, syscall number, +kernel import, export, or ABI snapshot entry is required. The ABI bump is still +mandatory: old host semantics would consume the shared chain and old libc cannot express the mode. The current pre-copy admission change alters no guest-visible structure, @@ -714,7 +761,8 @@ would only mask ownership or admission defects. ## PR #1166 removal and proof plan -After the coordinated vfork series is complete: +The local integration branch has completed steps 1 through 5 below. They are +implementation evidence, not authorization to merge or publish the removal. 1. Delete `packages/registry/ruby/patches/kandelo-posix-spawn.patch` and its application block from `build-ruby.sh`; do not replace it with another @@ -729,7 +777,8 @@ After the coordinated vfork series is complete: 5. Add an upstream-selection fixture that runs as uid 1000 and proves `retry_fork_async_signal_safe()` reaches `SYS_VFORK` with no full memory allocation/copy. Run the matched root/privileged fixture and prove it - reaches ordinary `SYS_FORK` and still clones independently. + reaches ordinary `SYS_FORK`; retain the platform's independent ordinary + fork isolation tests alongside that selection proof. 6. Exercise failed exec, successful exec, `_exit`, trap/crash, descriptors, signals, cwd/credentials, process groups, main/pthread callers, sequential repetition, rejected nesting, and dynamic side modules through platform @@ -751,10 +800,11 @@ After the coordinated vfork series is complete: headroom to prove ordinary fork behavior independently; do not expect that fallback to have vfork's memory profile. -Only after that evidence should #1166's migration exception and documentation -be removed. Until then, this record supports a design, one generic admission -guardrail, and unwired host foundations, not the claim that Homebrew's -fork-then-exec problem is fully resolved. +The recipe removal must not be merged or published until the remaining +platform, publication, and lifecycle gates support it. Until then, this +record supports the connected platform implementation and ordinary-fork +admission guardrail, not the claim that Homebrew's fork-then-exec problem is +fully resolved. ## Validation recorded for this change @@ -825,10 +875,8 @@ evidence for either path. Still required before a broad vfork or Homebrew completion claim: -- the coordinated ABI snapshot/bump and complete vfork implementation; - libc, POSIX, Sortix, kernel, host, fork-instrument, ABI, Node, and browser - conformance suites selected for that implementation; -- vfork-specific failure/rollback and cross-engine tests listed above; + conformance suites selected for the connected implementation; - pristine upstream-selection tests at uid 1000 and privileged uid 0; - rebuilt and anonymously published Ruby/VFS artifacts; and - the exact real in-guest Homebrew lifecycle and RSS proof. @@ -855,6 +903,220 @@ Worker. Building that package in a private temporary source tree removed the race; this was not evidence of kernel stack loss, renderer OOM, or incorrect fork admission. -Guest-visible vfork remains unimplemented after this batch-validation update. -No result above should be read as proof of parent suspension, zero-copy vfork -launch, failed-exec lifetime handling, or pristine upstream Ruby selection. +At this batch-validation checkpoint, the guest-visible vfork mode was distinct +but genuine vfork remained unimplemented. The mode reached +`SYS_VFORK`, Rust, and symmetric Node/browser launch metadata while still +using a full copied child Memory. Rust marked that child's Process record, +rejected nested process/thread ownership, and cleared the marker after +successful exec; failed exec intentionally left it set. The process Worker +also published exact prefix/scratch requirements, and the centralized host +rejected an oversized one-slot workspace with `EAGAIN` before child PID +allocation. No result above should be read as proof of parent suspension, +zero-copy vfork launch, terminal host cleanup, or pristine upstream Ruby +selection. + +### Connected vfork and OFD validation — 2026-08-01 + +Subsequent purpose-scoped commits connected the admitted mode to production +shared-memory launch, caller suspension, private child replay/control state, +and exact terminal teardown on both hosts. A production guest also exposed a +generic inherited-open-file-description defect: Kandelo preserved `OfdId` but +copied mutable offset/status/owner fields. The kernel now retains those fields +in an exactly owned shared state object and reconstructs process-local +directory iterators at one shared cookie. + +All commands supporting the following claims ran through +`scripts/dev-shell.sh`: + +- the full kernel suite passed 1,522 unit tests, four integration tests, and + six doc tests; +- focused kernel tests proved shared fork/vfork OFD lifetime, shared directory + cookies, and post-send `SCM_RIGHTS` mutation/lifetime behavior; +- `bash scripts/check-abi-version.sh` passed native and wasm32/wasm64 layout, + kernel Wasm export, generated C/TypeScript, snapshot, and version checks; +- `bash build.sh` completed. Because the unpublished ABI 43 release index + returned 404, the resolver rebuilt its verified-source package closure and + produced `host/wasm/rootfs.vfs` at 16,787,687 bytes; +- the production Node lifecycle suite passed five cases: repeated `_exit()` + and failed/successful exec, pthread caller suspension with a runnable + sibling, exact trap/self-`SIGKILL` teardown, and independent POSIX Process + state with a shared OFD, plus sibling-delivered `SIGKILL` while the borrower + was in a no-syscall compute loop; +- the same five cases passed in Chromium, Firefox, and WebKit, for 15 browser + cases total; and +- the declared program build rebuilt P-08 with ABI 43 instrumentation, and its + focused production-host case passed (one selected, 50 skipped). + +Focused allocation assertions and the pthread fixture's already-exhausted +post-growth process-memory budget prove that the vfork launch retained the +existing Memory rather than constructing or copying a child process Memory. +Output ordering proves that the caller did not pass the vfork call site before +the terminal boundary, while the sibling-thread fixture proves that unrelated +parent pthreads remained runnable. The process-state fixture covers descriptor +flags/close isolation, shared seek position, cwd, credentials, process group, +and wait/reaping. The lifecycle fixture covers failed exec coherence, +successful exec release, sequential calls, and nested fork/vfork/pthread +`EAGAIN`. + +This is still not broad completion evidence. The external-signal case proves +the documented browser boundary: a compute-running borrower has no generally +available Worker quiescence fence, so the safe fallback is loud +whole-address-space containment rather than parent resumption. The complete +fork-instrument run is blocked by the stale/rejected `programs/sh.wasm` +artifact closure. A libc vfork runner timed out, and a direct `/bin/sh` exec +attempt reported an exec-format error; neither is recorded as a libc pass. The +development shell did not provide `cargo fmt`, so no formatting-pass claim is +made. Complete libc, POSIX, Sortix, host, browser, fork-instrument, +performance/RSS, artifact-publication, and Homebrew lifecycle proofs remain +outstanding. + +### Upstream CRuby selection and patch removal — 2026-08-01 + +The Ruby 4.0.5 recipe now removes PR #1166 rather than broadening it. Its build +revision is 14, its source marker rejects a work directory that contains the +retired patch, and its configure contract requires upstream working-vfork +selection plus Kandelo's real `getresuid()` and `getresgid()` support. + +A clean build used the official checksum-pinned tarball with SHA-256 +`7d6149079a63f8ae1d326c9fa65c6019ba2dc3155eae7b39159817911c88958e`. +The generated configuration contained `HAVE_VFORK`, +`HAVE_WORKING_VFORK`, and `HAVE_WORKING_FORK`. The recipe compiled upstream +`process.c`, linked Ruby, applied local-root spilling and ABI 43 fork +instrumentation, and staged both declared outputs through the sealed package +installer. The 23 MiB executable had SHA-256 +`7d6bedf59930881b7f87bad8c9ab78a1b93816d4b0bdba60ec949c497a12851f` +in two builds. + +The first final-install attempt found an integration seam: WABT 1.0.36 could +not decode the modern typed-reference entries emitted by the ABI 43 tools, +although Node/V8 compiled the module. The fail-closed guard was not bypassed. +The existing wasmparser-backed artifact decoder now inventories reserved +`env.__wasm_posix_*` imports structurally, retains WABT only as a source-only +fallback, and rejects decoder failure. Its 14 focused Rust tests and the +complete shell artifact-guard suite passed before the package build was +repeated. + +The extracted runtime ZIP contents were identical across the two builds, but +the ZIP container SHA changed because its entry timestamps were not +normalized. No byte-reproducibility claim is made for that archive; one exact +rebuilt archive must be selected and bound when publication is authorized. + +The exact local executable then passed three production Node cases. At uid +1000, failed exec returned `ENOENT` and successful exec replaced the child +while a memory ceiling equal to Ruby's initial address space admitted vfork. +At uid 0, upstream CRuby intentionally selected ordinary fork; both its first +attempt and garbage-collection retry were rejected before a full child clone +by the same ceiling. The uid-1000 failed-exec case also passed in Chromium, +Firefox, and WebKit. Chromium additionally passed successful exec and the +root fallback, for four passing Playwright cases and two intentional +cross-engine skips. + +No artifact was published, no Homebrew image was rebuilt, no real tap/install +lifecycle was run, and no application RSS claim was measured. Those remain +release gates along with the broad suites listed above. + +### Broad validation and current performance — 2026-08-01 + +The remaining locally runnable conformance gates were then run through +`scripts/dev-shell.sh` against commit `5c0455db6`: + +- the CI-shaped host run passed 339 files and skipped 28; it recorded + 4,131 passing tests, two expected failures, and 129 skips, including + the three upstream Ruby selection cases; +- the JavaScriptCore/Bun teardown and pthread supplement passed three + tests in two files; +- libc recorded 303 passes, zero failures, 20 expected failures, and one + passing flaky case out of 324; +- POSIX recorded 174 passes, zero failures, three expected failures, and + two skips out of 179; +- Sortix recorded 5,037 passes, zero failures, 23 expected failures, and + 53 skips out of 5,113; +- the Rust workspace gate completed successfully, including 1,522 kernel + tests, four pointer-contract tests, 13 root-spill tests, 48 shared-ABI + tests, fork-instrument, and documentation tests; +- `xtask` passed 639 unit tests and its cache-root integration test; and +- the ABI gate again matched native, wasm32, and wasm64 layouts, the + committed snapshot, generated C and TypeScript bindings, and the + 42-to-43 bump. + +The focused browser vfork lifecycle remained green in Chromium, Firefox, +and WebKit: five cases per engine, 15 total. The upstream Ruby browser +proof recorded four passes and two intentional cross-engine skips. The +full product browser suite could not start with a complete asset +closure: the ABI 43 release index at `binaries-abi-v43/index.toml` +returns HTTP 404, and the local tree has no accepted ABI 43 application +package set. +Substituting ABI 42 artifacts would violate the artifact and ABI +contracts, so no such fallback was used. + +The component RSS harness was repeated twice against the final +64,151-byte ABI 43 `fork-bench.wasm`: + +| Case | Run A | Run B | +|---|---:|---:| +| Worker-only peak RSS growth | 13.063 MiB | 13.453 MiB | +| Module-Worker peak RSS growth | 13.500 MiB | 13.172 MiB | +| Shared-memory Worker RSS growth | 11.094 MiB | 11.156 MiB | +| Full-clone RSS growth | 496.344 MiB | 496.344 MiB | +| Sparse-clone RSS growth | 262.656 MiB | 262.641 MiB | +| Full-clone elapsed | 35.287 ms | 35.338 ms | +| Sparse-clone elapsed | 95.125 ms | 79.470 ms | + +The final artifact therefore reproduces the architectural result: +sharing an existing 256 MiB Memory adds Worker-scale RSS, while a +complete clone adds 496.344 MiB in this sparse-parent experiment. Sparse +cloning saves RSS but still faults and scans the parent and takes +2.25 to 2.70 times as long. This remains component evidence, not a +Homebrew application RSS result. + +All three self-contained benchmark suites ran for three rounds on Node +and Chromium. The final medians included: + +| Host and suite | Selected medians | +|---|---| +| Node process lifecycle | hello 271.00 ms; fork 79.54 ms; exec 337.81 ms; clone 64.38 ms | +| Chromium process lifecycle | hello 373.20 ms; fork 41.00 ms; clone 28.00 ms | +| Node spawn scratch | spawn 74.50 ms; large first 70.69 ms; repeat 69.54 ms | +| Chromium spawn scratch | spawn 33.00 ms; large first 28.00 ms; repeat 26.40 ms | +| Node syscall I/O | pipe 62.37 MiB/s; syscall 27.30 us | +| Chromium syscall I/O | pipe 58.82 MiB/s; syscall 32.00 us | + +The spawn scratch shape retained exactly 84,386 bytes and ended with +17,760,256 kernel bytes on both hosts. The all-suite gates stopped +before any workload because Node lacked ABI 43 PHP, WordPress, and both +MariaDB architectures. The browser lacked the WordPress and two MariaDB +VFS images. No broad application-performance or no-regression claim is +made. + +A same-day rebuild of pre-batch commit `2f5b3c411` confirmed a real +lifecycle regression in the combined ABI train: + +| Metric | Pre-batch | Final | Change | +|---|---:|---:|---:| +| hello start | 190.10 ms | 271.00 ms | +42.6% | +| fork and child exit | 53.01 ms | 79.54 ms | +50.0% | +| exec | 234.43 ms | 337.81 ms | +44.1% | +| pthread clone | 46.92 ms | 64.38 ms | +37.2% | + +Seven-run empty-VFS phase measurements separated the broad ABI batch +from the later vfork commits: + +| Revision | Kernel-host init | Hello process launch | +|---|---:|---:| +| pre-batch `2f5b3c411` | 127.584 ms | 51.401 ms | +| pre-vfork ABI 43 `40992ab95` | 177.738 ms | 72.726 ms | +| final `5c0455db6` | 181.967 ms | 74.367 ms | + +The generated kernel Worker grew from 1,162,219 bytes before the batch +to 2,028,243 bytes before vfork and 2,055,252 bytes in the final tree. +The process Worker grew from 219,027 to 978,422 to 993,077 bytes. The +pre-vfork ABI 43 checkpoint therefore already contains about 39 to 42 +percent of the empty-VFS regression; the connected vfork stack adds +about 2.3 to 2.4 percent on top. This does not make the broad regression +acceptable, but it rejects vfork as its primary cause and keeps the +integration train bisectable for a separate startup-size investigation. + +Artifact publication remains the release boundary. Until the exact +ABI 43 Ruby, shell, and application closure is published, neither the +complete browser benchmark matrix nor the real in-guest Homebrew +tap/install and RSS lifecycle can be claimed. diff --git a/docs/plans/2026-08-01-abi-43-batch-plan.md b/docs/plans/2026-08-01-abi-43-batch-plan.md index 6a4cc5e9e5..928a94e3e0 100644 --- a/docs/plans/2026-08-01-abi-43-batch-plan.md +++ b/docs/plans/2026-08-01-abi-43-batch-plan.md @@ -9,9 +9,12 @@ open pull requests were forward-ported and composed. It does not authorize a push, merge, ABI release, package publication, Homebrew cutover, or removal of the temporary CRuby patch in pull request (PR) #1166. -The working branch is `integration/abi43-batch-20260731`. Kernel, ABI, libc, -host-runtime, and fork-instrument changes still require Brandon's explicit -approval before merge. +The linear handoff branch is +`integration/abi43-batch-linear-20260801`, based on `origin/main` at +`8a0ed31a5`. The pre-linearization recovery branch remains +`integration/abi43-batch-20260731`. Kernel, ABI, libc, host-runtime, and +fork-instrument changes still require Brandon's explicit approval before +merge. ## History contract @@ -20,19 +23,28 @@ must be rebase-merged and must not be squash-merged. Forward-ported commits retain original authorship; integration repairs remain separate commits with their own purpose. -The current local range has 145 commits above local `main` at -`c5a24dc148b2e69c0555d9e7802bee7cd48a18d7`. Its authors are: +At post-rebase projection checkpoint `554bdf542`, the local range has 171 +commits above `origin/main` at `8a0ed31a5`: 170 selected payload commits and +one separate generated-projection repair. Its authors are: -- 141 Brandon Payton commits, including 12 whose original GitHub committer is - retained in the source history; +- 167 Brandon Payton commits; - three Dependabot-authored dependency commits; and - one `mho22`-authored Windows VFS commit. -There is one temporary integration merge, `d850197a8`, used to absorb the -then-current `origin/main`. Before an umbrella PR, rebase the train once onto -the selected final mainline and remove that merge topology. Do not squash the -result. Verify the rewritten mapping with `git range-diff` and -`git log --format=fuller`. +The range contains no merge commits. The old integration merge `d850197a8` +remains only on the recovery branch. `git range-diff` mapped 163 of the 170 +replayed commits unchanged and seven with newer-main context adjustments. The +two upstream entries omitted by the mapping are `ce9b36a82` and `8a0ed31a5`; +they are already in the new base rather than duplicated in the payload. + +The adjusted commits preserve both sides of each overlap: current CI fixture +routing, staging-shell handoff metadata, browser memory64 fixtures, and the +generic shell-release finalizer remain intact while the selected changes are +applied. The image-ingest commit advances the changed shell inputs to revision +23 and pending state without restoring stale revision-specific finalizer +logic. `git log --format=fuller` confirms the three Dependabot authors and +`mho22` author, with the restacker recorded only as committer. The umbrella PR +must retain this linear topology and must not squash it. ## Frozen selected sources @@ -82,8 +94,8 @@ them necessary. ## Affordable fork work in the batch -The train also carries the Kandelo-owned foundation developed in the dedicated -fork worktree: +The train also carries the Kandelo-owned implementation developed in the +dedicated fork worktree: - ordinary fork admission rejects retired-memory saturation before allocating or copying child memory; @@ -91,15 +103,27 @@ fork worktree: - a child can replay a borrowed parent continuation through private mutable prefix storage without consuming the parent's frames; - active side-module state can be reconstructed without writing parent memory; - and - one exact-generation shared-memory lifetime coordinator prevents overlapping - borrowers and requires terminal evidence before parent resumption. - -These foundations remain intentionally disconnected from guest-visible -`vfork()`. Kandelo's libc still aliases `vfork()` to ordinary `fork()`, -`kernel_fork` still has no mode parameter, and the host still clones full -memory for `SYS_VFORK`. Documentation must continue to report that limitation -until the connected implementation and tests are complete. + borrowers and requires terminal evidence before parent resumption; +- ABI 43 carries an explicit ordinary/vfork mode through libc, + fork-instrumentation, the host channel, and the kernel; +- production Node and browser vfork launch a separate child Worker over an + exact alias to the parent's Memory with private channel, replay, loader, and + continuation-control state; +- only the calling parent thread remains parked through failed exec and until + successful exec commit or exact `_exit()`/signal/trap teardown; and +- inherited open file descriptions share mutable offset, status flags, and + async owner while descriptor tables and directory host iterators remain + process-local. + +Ordinary fork remains independent and copied. The connected vfork path +has passed the locally runnable broad conformance gates and component +resident set size (RSS) measurements. It is not yet a release claim: +published upstream CRuby artifacts, application RSS, the complete +application benchmark matrix, and the Homebrew lifecycle remain explicit +gates. A fatal signal against a compute-running borrower is covered on +every host: absent an exact Worker fence, Kandelo contains the whole +shared address space rather than resuming the parent unsafely. ## Composition repairs completed @@ -124,57 +148,98 @@ or kernel stack loss. All commands supporting claims below ran through `scripts/dev-shell.sh`. -- ABI snapshot, generated C/TypeScript bindings, and version checks passed. -- Rust workspace validation recorded 1,519 kernel tests and 48 shared tests - passing; xtask recorded 638 unit tests plus its integration test passing. -- The focused authority and lifecycle cluster passed 13 files and 167 tests. -- PCM and audio interruption coverage passed seven files and 39 tests. -- Runtime-file metadata and PHP consumers passed 35 tests with five - intentional skips. -- The exact 4,096-child `posix_spawn`/`waitpid` churn passed alone and in the - broad concurrent suite after installed-package build isolation. -- The latest broad host run recorded 4,027 passed, five failed, and 130 - skipped tests out of 4,162. All five failures are missing complete ABI 43 - program-artifact closures in `run-example-credentials` and - `run-example-resolver`; no source/runtime failure remains in that run. - -The full repository build is not a pass. It reached external Bash source -fallback and stopped on a GNU mirror HTTP 502. Browser production assembly and -artifact-dependent runtime suites remain gated by the same missing complete -ABI 43 program generations. Performance, libc, POSIX, Sortix, and complete -browser claims must wait for their exact prerequisites and suites. - -## Next implementation series - -Keep the connected vfork work as small purpose-scoped commits above this -reviewed batch: - -1. Add the explicit ordinary/vfork mode to the ABI 43 `kernel_fork` import, - generated constants, snapshot, and fork-instrument propagation. -2. Make libc `_Fork()` and `fork()` pass ordinary mode and `vfork()` pass - vfork mode without running `pthread_atfork` handlers. -3. Add authoritative kernel Process state for a vfork child, caller - suspension, overlap/nesting rejection, and exact wait/reaping behavior. -4. Reserve a distinct child channel/control slot before launch and start a - separate child Worker that aliases the parent's shared Memory without a - `WebAssembly.Memory` construction or byte copy. -5. Connect borrowed main and active-side replay while keeping every child - continuation cursor and mutable prefix private. -6. Resume only the calling parent thread after successful exec or exact - `_exit`/signal/crash teardown. Failed exec must leave the lifetime coherent - and the parent blocked. -7. Prove descriptors/open file descriptions, cwd, credentials, signals, - process groups, main and pthread callers, runnable siblings, sequential - calls, overlap rejection, traps, and rollback on Node and applicable - browsers. -8. Rebase linearly, rerun attribution and ABI audits, then open an umbrella PR - only when Brandon authorizes that external action. +- ABI snapshot, generated C/TypeScript bindings, native and + wasm32/wasm64 layouts, and version checks passed. +- The complete CI-shaped host run passed 339 files and skipped 28. It + recorded 4,131 passing tests, two expected failures, and 129 skips; + the Bun and JavaScriptCore supplement passed three tests in two files. +- The exact 4,096-child `posix_spawn`/`waitpid` churn passed alone and + in the broad concurrent suite after installed-package build + isolation. +- libc recorded 303 passes and zero failures out of 324; POSIX recorded + 174 passes and zero failures out of 179; Sortix recorded 5,037 passes + and zero failures out of 5,113. Expected failures and documented skips + remained classified rather than hidden. +- The Rust workspace gate completed, including 1,522 kernel tests, four + pointer-contract tests, 13 root-spill tests, 48 shared-ABI tests, + fork-instrument, and documentation tests. +- `xtask` passed 639 unit tests and its cache-root integration test. +- Five production vfork lifecycle cases passed in Node and in Chromium, + Firefox, and WebKit, for 15 browser cases. The upstream Ruby browser + proof recorded four passes and two intentional cross-engine skips. +- After the final linear rebase, the ABI snapshot, native and + wasm32/wasm64 layouts, generated C and TypeScript bindings, and ABI 42 to + 43 bump classification passed again. The current-main Homebrew input + changes made the program-package projection truthfully stale; it was + regenerated in separate commit `554bdf542`, and the freshness check and + standalone resolver-bundle check then passed. +- The post-rebase CI suite-routing contract passed, including exact staging + shell handoff and browser-memory64 workspace fixture paths. The complete + Homebrew main-shell closure contract also passed, including its 43 embedded + Node tests and revision/state/finalizer checks. + +At the initial batch checkpoint, the full repository build was not a pass. It +reached external Bash source fallback and stopped on a GNU mirror HTTP 502. + +After connecting vfork and shared OFD state, a later full production +build passed. ABI 43 had no release index, so the resolver truthfully +rebuilt its verified-source package closure and produced the +16,787,687-byte rootfs image. + +The component RSS result remained decisive: a shared 256 MiB Memory +added about 11.1 MiB for its Worker, while an exact full clone added +496.344 MiB. Sparse cloning added about 262.65 MiB but took 79 to 95 ms +versus 35 ms for a full clone. All three self-contained benchmark suites +passed for three rounds on Node and Chromium. + +The comparison also found a broad batch startup regression. A same-day +pre-batch build was 37 to 50 percent faster across the Node lifecycle +metrics. Empty-VFS phase measurements place nearly all of that +regression before the vfork stack: the pre-vfork ABI 43 checkpoint was +about 39 to 42 percent slower than the pre-batch baseline, while vfork +added about 2.3 to 2.4 percent. Keep that regression visible and +bisectable; no broad no-regression claim is made. + +The full product browser and application benchmark gates still lack a +complete ABI 43 package closure. The release index returns HTTP 404, and +no ABI 42 fallback is valid. Those gates, application RSS, and the +Homebrew lifecycle remain pending publication. + +## Remaining implementation and release series + +The mode ABI, libc split, kernel marker, borrowed Worker launch, private replay +state, caller suspension, exact cooperative/fatal teardown, inherited OFD +state, and Node/browser lifecycle fixtures are implemented as separate +purpose-scoped commits. Continue with these remaining gates: + +1. Completed locally: run the complete libc, POSIX, Sortix, host, Rust + workspace, `xtask`, ABI, and focused cross-engine vfork/Ruby suites. +2. Completed locally: repeat component RSS, ordinary-fork and + sparse-clone comparison, repeated vfork lifetimes, and all + self-contained Node and Chromium benchmarks. Preserve the measured + broad startup regression as an explicit integration risk. +3. Completed locally: remove PR #1166, enable upstream CRuby's + working-vfork branch, and prove uid 1000 selects vfork while the + privileged path retains ordinary fork in Node and Chromium. The + uid-1000 failed-exec proof also passes in Firefox and WebKit. +4. With publication authorization, publish the exact rebuilt ABI 43 + package closure, then run the full product browser and application + benchmark matrices without stale ABI fallback. +5. Reconstruct the Homebrew image and repeat the real tap/install + lifecycle with process-tree RSS evidence, no renderer loss, and no + history-proportional memory growth. +6. Completed locally: rebase linearly onto `origin/main` at `8a0ed31a5`, + remove the temporary merge, rerun attribution and ABI audits, and preserve + the post-rebase projection repair as its own commit. +7. Open an umbrella PR only when Brandon authorizes that external action. + Preserve every accepted PR and integration repair as an individual commit; + do not squash. ## PR #1166 removal gate -Do not alter or broaden PR #1166 during this series. Remove it only after -pristine upstream CRuby is rebuilt with working vfork enabled, uid 1000 proves -the upstream vfork path with no full-memory allocation/copy, privileged Ruby -proves its intentional ordinary-fork fallback, the exact artifacts are -published, and the real in-guest Homebrew tap/install lifecycle completes -without renderer loss or history-proportional memory growth. +The local recipe deletes PR #1166 without replacing it with another Ruby +command classifier. Do not merge or publish that removal until the exact +upstream-process-path artifacts are published and the real in-guest Homebrew +tap/install lifecycle completes without renderer loss or +history-proportional memory growth. The local uid-1000 and privileged proofs +satisfy the selection gate, but not those release gates. diff --git a/docs/posix-status.md b/docs/posix-status.md index e083a9d549..a2c2088e6b 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -25,17 +25,20 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve - **Process workers** communicate with the kernel via channel IPC — each process/thread has a channel region in shared memory, and the kernel services syscalls one at a time from the JS event loop - **Cross-process shared state** uses kernel-global or host-coordinated backings where implemented. Pipes, locks, IPC objects, sockets, and selected - stateful descriptors retain one backing across fork; ordinary regular-file - OFD seek positions and status flags are still copied per process. Directory - descriptors reopen a process-local host iterator at the copied - guest-visible cookie, but subsequent cursor movement is likewise not shared. + stateful descriptors retain one backing across fork. Inherited and + transferred open file descriptions retain one exactly owned mutable state + object for offsets, status flags, and async ownership. Directory descriptors + keep process-local host iterators but rebuild them whenever a peer advances + the shared guest-visible cookie. - **Serialized syscall execution** — the kernel handles one syscall at a time, which provides natural atomicity for kernel-owned operations such as memfd `O_APPEND` and `PIPE_BUF`-sized pipe writes; host-backed append additionally requires an exact backend outcome - **Signal delivery** across processes is direct — the kernel can write to any process's pending signal mask **Key kernel-side APIs:** - `kernel_create_process()` — allocate and register a new process, returning its PID - `kernel_create_process_with_stdio(stdin_kind, stdout_kind, stderr_kind)` — same allocation with explicit stdio semantics -- `kernel_fork_process(parent, caller_tid)` — validate the calling task, allocate a child PID, and copy inherited state including that task's signal mask +- `kernel_fork_process(parent, caller_tid, mode)` — validate the calling task + and ABI-owned ordinary/vfork mode, allocate a child PID, and copy inherited + state including that task's signal mask - `kernel_spawn_process(parent, caller_tid, blob_ptr, blob_len)` — validate the calling task, allocate the child PID, and apply spawn attributes and file actions - `kernel_remove_process(pid)` — clean up on exit - `kernel_handle_channel(offset, capacity, pid, retry_token)` — dispatch a @@ -59,11 +62,11 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve | `pread()` | Partial | Host-backed files use one positioned backend read without changing the OFD cursor; in-kernel files retain their native positioned path. Rejects pipes/sockets with ESPIPE. Signed-i64 offsets stay exact through the host contract; number-only backends return EOVERFLOW rather than rounding an unrepresentable offset. | | `write()` | Partial | Host-delegated for files. Pipe writes to kernel ring buffer with blocking when full (EINTR on signal). EPIPE + SIGPIPE on closed read end (POSIX-compliant). `O_APPEND` is one EOF/limit/write transaction that returns the exact written prefix and ending offset: memfds and shared-memory files serialize under their backing lock, OPFS serializes in its channel handler, and lifecycle-owned Node scratch mounts use a verified native append route. Node session seeds are copied to new private inodes before readiness and therefore retain that lifecycle-owned route; no mutation is written back to the source tree. Externally mutable `HostFileSystem` mounts and the legacy raw Node adapter cannot prove the exact ending offset and return `EOPNOTSUPP` before mutation. For regular files and memfds, `RLIMIT_FSIZE` applies once per logical operation: a crossing operation returns the prefix that fits without a signal; a later non-empty operation with no room fails with `EFBIG` and generates thread-directed `SIGXFSZ`. | | `pwrite()` | Partial | Host-backed files use one positioned backend write without changing the OFD cursor; in-kernel files retain their native positioned path. Rejects pipes/sockets with ESPIPE. Uses the same operation-wide RLIMIT_FSIZE rule as write. Number-only backends, including Node's synchronous positioned-write API above JavaScript's safe-integer range, return EOVERFLOW rather than rounding. | -| `lseek()` | Partial | Regular files support SEEK_SET, SEEK_CUR, and SEEK_END; SEEK_END delegates to the host for size calculation. Directories accept a nonnegative next-record cookie with SEEK_SET and expose the current cookie through SEEK_CUR with offset zero; other directory seeks fail with EINVAL without changing the cursor. A regular-file seek whose result would be negative likewise fails with EINVAL, and arithmetic or host-number overflow fails with EOVERFLOW. Ordinary-file and directory positions still have the cross-process OFD boundary documented below. | +| `lseek()` | Partial | Regular files support SEEK_SET, SEEK_CUR, and SEEK_END; SEEK_END delegates to the host for size calculation. Directories accept a nonnegative next-record cookie with SEEK_SET and expose the current cookie through SEEK_CUR with offset zero; other directory seeks fail with EINVAL without changing the cursor. A regular-file seek whose result would be negative likewise fails with EINVAL, and arithmetic or host-number overflow fails with EOVERFLOW. Inherited and transferred descriptors share the same OFD position. | | `dup()` | Full | Lowest available fd. FD_CLOEXEC cleared. Shares OFD with original. | | `dup2()` | Full | Atomic close-and-dup. Same-fd no-op. FD_CLOEXEC cleared. | | `dup3()` | Full | Like dup2 but returns EINVAL if oldfd==newfd. Supports O_CLOEXEC flag. | -| `pipe()` | Partial | Kernel-space ring buffer (64KB). PIPE_BUF=4096 atomicity is guaranteed by serialized kernel syscalls. O_NONBLOCK returns EAGAIN. Forked descriptors retain the same global pipe backing even though their per-process OFD metadata is copied. | +| `pipe()` | Partial | Kernel-space ring buffer (64KB). PIPE_BUF=4096 atomicity is guaranteed by serialized kernel syscalls. O_NONBLOCK returns EAGAIN. Forked descriptors retain the same global pipe backing and shared OFD status. | | `pipe2()` | Full | Like pipe with O_NONBLOCK and O_CLOEXEC flag support. | | `readv()` | Full | Validates the complete caller-native iovec table and `IOV_MAX`, performs one contiguous scalar read, then scatters only the returned prefix. This preserves datagram/record boundaries and stops naturally on a short read or EOF even when the vector exceeds ordinary channel scratch. | | `writev()` | Full | Validates and gathers the complete vector, then performs one scalar write. Pipe/datagram operation boundaries and operation-wide `RLIMIT_FSIZE` are preserved even when the vector exceeds ordinary channel scratch. | @@ -127,7 +130,7 @@ same final-OFD lifetime rules. | Function | Status | Notes | |----------|--------|-------| -| `fork()` | Partial | The kernel validates the calling task, allocates the child PID, and copies process state; the host starts a child Worker with copied Memory. The child inherits the calling task's blocked signal mask, and libc refreshes a copied pthread TID from the kernel before returning from `fork()`. Host-owned continuation and fork channel requests leave caught signals kernel-pending; after the import returns, libc performs an ordinary syscall checkpoint so the guest signal trampoline owns handler invocation and mask restoration without host-to-Wasm reentrancy. Initial launch mirrors the environment into kernel-owned process state; fork copies that metadata while instrumented rewind preserves the live libc `environ` in copied Memory, and `execve()` replaces both from its supplied `envp`. `wasm-fork-instrument` resumes the child at the call site with scalar locals in linked frames and versioned reconstruction recipes for references, exceptions, globals, tables, and dynamic-link activations. Root or later continuation-allocation failure and a negative `SYS_FORK` result unwind transactionally, create no child, and return the failure to the still-running parent. Main-thread and pthread fork are supported, including nested main/side-module stacks and process-owned dynamic-link/table replay. Pipes, sockets, PTYs, eventfd/timerfd/signalfd, memfd, procfs snapshots, and shared mappings retain their existing backings; signal and wait lifecycle state is copied/coordinated by the kernel. An inherited directory drops the parent's process-local host iterator and lazily reopens at the copied next-record cookie, so handles cannot alias, but later parent/child cursor movement is not shared. Ordinary regular-file OFD seek positions/status flags have the same copied rather than shared boundary. See [fork-instrumentation.md](fork-instrumentation.md) and the known OFD gap below. | +| `fork()` | Partial | The kernel validates the calling task, allocates the child PID, and copies process state; the host starts a child Worker with copied Memory. The child inherits the calling task's blocked signal mask, and libc refreshes a copied pthread TID from the kernel before returning from `fork()`. Host-owned continuation and fork channel requests leave caught signals kernel-pending; after the import returns, libc performs an ordinary syscall checkpoint so the guest signal trampoline owns handler invocation and mask restoration without host-to-Wasm reentrancy. Initial launch mirrors the environment into kernel-owned process state; fork copies that metadata while instrumented rewind preserves the live libc `environ` in copied Memory, and `execve()` replaces both from its supplied `envp`. `wasm-fork-instrument` resumes the child at the call site with scalar locals in linked frames and versioned reconstruction recipes for references, exceptions, globals, tables, and dynamic-link activations. Root or later continuation-allocation failure and a negative `SYS_FORK` result unwind transactionally, create no child, and return the failure to the still-running parent. Main-thread and pthread fork are supported, including nested main/side-module stacks and process-owned dynamic-link/table replay. Pipes, sockets, PTYs, eventfd/timerfd/signalfd, memfd, procfs snapshots, and shared mappings retain their existing backings; signal and wait lifecycle state is copied/coordinated by the kernel. Inherited OFDs share offset, status flags, and async owner through exact kernel ownership. Directory host iterators remain process-local and reopen at the one shared next-record cookie. See [fork-instrumentation.md](fork-instrumentation.md). | | `exec()` | Partial | Kernel-initiated via SYS_EXECVE (syscall 211). The host preflights the module, ABI, replacement memory, caller, generated 4,096/4,096 argv/environment count caps, deferred file actions, and a 4 MiB combined argv/environment representation (strings, terminators, and pointer entries) before replacing the image in place. Independently, each string is limited to the current 64 KiB process-metadata transfer; this is an implementation transport ceiling, not part of aggregate `ARG_MAX`, and oversize returns `E2BIG` without truncation. At `_start`, immutable entry reads are zero-capacity queried and then copied complete-or-`ERANGE` into one exact-lifetime guest `mmap`; allocation failure or changed length traps before libc publishes a partial vector. Preserves PID, non-CLOEXEC fds and their exact kernel-backed object state, new argv/envp (including an explicitly empty environment), CWD, the calling pthread's signal mask and directed queue, terminal queues, and `alarm()`/`ITIMER_REAL`; closes directory streams, deletes `timer_create()` timers, publishes and detaches old mappings, terminates sibling threads, and resets the program break before installing the new `__heap_base`. File mappings retain a stable writeback handle even after their original fd closes. Remaining gaps: POSIX message-queue descriptors are not process-owned and therefore cannot yet be closed on exec; epoll registrations track numeric fds rather than OFD identity, so close/dup and same-number replacement cases are incomplete; and main-thread-directed signals share the process-pending queue and therefore cannot be distinguished from process-directed signals when a worker pthread execs. | | `wait()` / `waitpid()` / `wait4()` / `waitid()` | Partial | Rust-owned child status covers stop, continue, normal exit, and signal death. New status replaces older unconsumed status; `waitid(WNOWAIT)` preserves the current record. `WNOHANG`, `WUNTRACED`/`WSTOPPED`, `WEXITED`, and `WCONTINUED` are supported, as are specific-PID, any-child, same-process-group, and specific-process-group selection. Stop/continue reports do not reap; consuming exit status does. A top-level host launch has `ppid=0`; its status is consumed by the host API, and the host asks Rust to reap it only after its Workers can issue no more syscalls. `wait4()` returns the zero-filled resource-usage wire record described under `getrusage()`. Remaining gap: a blocked `pid == 0` / `P_PGID,id == 0` wait currently re-evaluates the caller's process group on each host retry instead of freezing it at call entry. | | `exit()` / `_exit()` | Partial | Closes all fds and dir streams, releases locks and mapping/backing ownership, and retains the low eight status bits. Normal codes 128–255 remain distinct from signal termination, which is stored separately. SIGCHLD is delivered to a guest parent and guest-child zombie state remains until `waitpid()` reaps it. The host separately reaps only exited direct children of `ppid=0` after Worker teardown. Orphan adoption is not yet implemented when a guest parent exits. | @@ -150,10 +153,10 @@ same final-OFD lifetime rules. | `futex()` | Partial | FUTEX_WAIT, FUTEX_WAKE, FUTEX_REQUEUE, FUTEX_CMP_REQUEUE, and FUTEX_WAKE_OP operate on one process's shared memory. Main-process WAIT uses host `Atomics.waitAsync`; pthread workers use direct `Atomics.wait`. Separate processes have separate `SharedArrayBuffer` objects, so these operations do not wake or synchronize a peer PID even when the futex word lies in a host-coordinated MAP_SHARED mapping. | | `execve()` | Partial | Delegates to the in-place `exec()` path and has the same remaining descriptor/signal/mapping limitations described above. | | `execveat()` | Partial | SYS_EXECVEAT (386). `AT_EMPTY_PATH` resolves the supplied fd through `kernel_get_fd_path` for `fexecve()`. Other relative paths resolve against the supplied directory fd through `kernel_get_dirfd_path` (`AT_FDCWD` selects the process CWD); absolute paths are independent of the fd. It otherwise has the same remaining `exec()` limitations. | -| `fork()` (syscall) | Partial | Glue traps through channel IPC; the kernel copies process state, the host starts a child Worker, and `wasm-fork-instrument` replays the call stack so parent/child receive the POSIX return values. ABI 43 requires the activation-state-safe artifact capability before launch and validates the linked-frame, reference/exception recipe, mutable module-state, table-journal, and activation-catalog contracts. Unsafe ABI 42, malformed, or mixed-version artifacts fail before execution. Negative results replay to the caller without terminating the parent, including continuation-allocation failure before or during unwind. The ordinary-OFD limitations in the main `fork()` row still apply. | -| `vfork()` | Partial | Alias for `fork()` and therefore has the same continuation/OFD limitations. It neither suspends the calling parent thread nor shares that process memory with the child until `exec()` or `_exit()`, so it cannot avoid Kandelo's eager fork-memory copy. | -| `posix_spawn()` | Partial | **Non-forking implementation** (this kernel's invention; no Linux equivalent). Glue issues `SYS_SPAWN` (500) with a marshalled blob (argv + envp + file actions + spawn attrs). Generated platform limits supply the advertised 4 MiB combined argv/environment `ARG_MAX`, 4,096-byte `PATH_MAX` including NUL, and defensive 4,096-entry caps for each process-startup vector; the separate generated wire contract aliases those counts and defines a 40-byte header, 28-byte action records, 1,024 actions, and an 8,417,320-byte complete transport ceiling. These representation caps are not additional POSIX limits. Independently, each argv/environment string must fit the current 64 KiB process-metadata transfer. That host implementation ceiling is separate from aggregate `ARG_MAX`. Child startup uses the same immutable query/exact-copy guest-mapping contract as `exec()`, so it cannot silently clamp counts or keep only 64/128 KiB prefixes. The host proves caller ranges, parsed limits, the selected kernel-owned allocation capacity, and the current kernel-memory range independently; fitting inside total kernel Wasm memory is not proof that the destination allocation owns those bytes. Ordinary blobs reuse channel scratch. Each larger blob begins a fresh exclusive reservation on a Rust-owned reusable high-water buffer, reads its pointer and capacity, copies under one synchronous lease, and commits with the matching opaque token. Begin and pointer/capacity queries are nonblocking; commit and cancellation wait on a no-host-import critical section. After every successful begin, the host cancels in a `finally` block, including setup and copy failures, so it returns with either a released unconsumed token or a definitive already-consumed/stale result. Overlapping or reentrant large-spawn attempts cannot replace live bytes. The host passes the calling TID to `kernel_spawn_process` or `kernel_spawn_reserved_process`; the Rust `ProcessTable` validates that task, allocates the child PID from the global task-ID sequence, and builds the child Process descriptor before `onSpawn` launches a fresh Worker. The child inherits the calling task's signal mask unless `POSIX_SPAWN_SETSIGMASK` replaces it. No fork, no `wpk_fork_*` rewind, no exec replay. Supports POSIX_SPAWN_SETSID / SETPGROUP / SETSIGMASK / SETSIGDEF and FDOP_OPEN / CLOSE / DUP2 / CHDIR / FCHDIR. SIG_IGN dispositions persist across the implicit exec; custom handlers reset to SIG_DFL (POSIX exec semantics). An inherited directory never aliases the parent's live host iterator: spawn preserves its next-record cookie and lazily reopens a child-owned iterator there. Its later cursor movement and ordinary-file OFD metadata are still process-local rather than shared; see the known OFD gap below. Regression-guarded: `kernel_get_fork_count` exposes a per-process counter the test suite asserts is unchanged across SYS_SPAWN. See `docs/plans/2026-05-04-non-forking-posix-spawn-design.md`. | -| `posix_spawnp()` | Partial | PATH search lives in libc (`libc/musl-overlay/src/process/wasm32posix/posix_spawnp.c`); resolves the absolute path then delegates to `posix_spawn()`. Empty PATH entries are treated as `.` and EACCES is deferred per `__execvpe` policy. It inherits `posix_spawn()`'s cross-process open-file-description limitation. | +| `fork()` (syscall) | Partial | Glue traps through channel IPC; the kernel copies process state, the host starts a child Worker, and `wasm-fork-instrument` replays the call stack so parent/child receive the POSIX return values. ABI 43 requires the activation-state-safe artifact capability before launch and validates the linked-frame, reference/exception recipe, mutable module-state, table-journal, and activation-catalog contracts. Unsafe ABI 42, malformed, or mixed-version artifacts fail before execution. Negative results replay to the caller without terminating the parent, including continuation-allocation failure before or during unwind. | +| `vfork()` | Partial | ABI 43 gives vfork a distinct libc and host transaction mode, maps it to `SYS_VFORK`, and does not run `pthread_atfork` handlers. A separate child Worker aliases the parent's existing `Shared WebAssembly.Memory`; the launch constructs no child process Memory and copies no address-space bytes. The child has private syscall-channel, replay-prefix, reference-codec, loader, and continuation-control state plus an independent kernel Process record. Only the calling parent thread remains parked until successful exec commit or exact `_exit()`/signal/trap teardown; sibling pthreads remain runnable. Failed exec returns to the child and keeps the lifetime active. Nested fork/vfork, spawn, and pthread creation fail with `EAGAIN`. Inherited descriptor tables, cwd, credentials, and process groups remain independent, while each inherited OFD shares its mutable offset, status flags, and async owner. Node, Chromium, Firefox, and WebKit production paths cover these lifecycles. A fatal signal delivered while the child has no pending syscall cannot obtain an exact browser Worker-quiescence fence; Kandelo truthfully contains the complete shared address space instead of resuming the parent. This row remains Partial pending broad conformance, pristine upstream CRuby selection, and full Homebrew/RSS validation. | +| `posix_spawn()` | Partial | **Non-forking implementation** (this kernel's invention; no Linux equivalent). Glue issues `SYS_SPAWN` (500) with a marshalled blob (argv + envp + file actions + spawn attrs). Generated platform limits supply the advertised 4 MiB combined argv/environment `ARG_MAX`, 4,096-byte `PATH_MAX` including NUL, and defensive 4,096-entry caps for each process-startup vector; the separate generated wire contract aliases those counts and defines a 40-byte header, 28-byte action records, 1,024 actions, and an 8,417,320-byte complete transport ceiling. These representation caps are not additional POSIX limits. Independently, each argv/environment string must fit the current 64 KiB process-metadata transfer. That host implementation ceiling is separate from aggregate `ARG_MAX`. Child startup uses the same immutable query/exact-copy guest-mapping contract as `exec()`, so it cannot silently clamp counts or keep only 64/128 KiB prefixes. The host proves caller ranges, parsed limits, the selected kernel-owned allocation capacity, and the current kernel-memory range independently; fitting inside total kernel Wasm memory is not proof that the destination allocation owns those bytes. Ordinary blobs reuse channel scratch. Each larger blob begins a fresh exclusive reservation on a Rust-owned reusable high-water buffer, reads its pointer and capacity, copies under one synchronous lease, and commits with the matching opaque token. Begin and pointer/capacity queries are nonblocking; commit and cancellation wait on a no-host-import critical section. After every successful begin, the host cancels in a `finally` block, including setup and copy failures, so it returns with either a released unconsumed token or a definitive already-consumed/stale result. Overlapping or reentrant large-spawn attempts cannot replace live bytes. The host passes the calling TID to `kernel_spawn_process` or `kernel_spawn_reserved_process`; the Rust `ProcessTable` validates that task, allocates the child PID from the global task-ID sequence, and builds the child Process descriptor before `onSpawn` launches a fresh Worker. The child inherits the calling task's signal mask unless `POSIX_SPAWN_SETSIGMASK` replaces it. No fork, no `wpk_fork_*` rewind, no exec replay. Supports POSIX_SPAWN_SETSID / SETPGROUP / SETSIGMASK / SETSIGDEF and FDOP_OPEN / CLOSE / DUP2 / CHDIR / FCHDIR. SIG_IGN dispositions persist across the implicit exec; custom handlers reset to SIG_DFL (POSIX exec semantics). An inherited directory never aliases the parent's live host iterator: spawn preserves one shared next-record cookie and lazily reopens a child-owned iterator there. Inherited OFD offset, status, and owner state remain shared. Regression-guarded: `kernel_get_fork_count` exposes a per-process counter the test suite asserts is unchanged across SYS_SPAWN. See `docs/plans/2026-05-04-non-forking-posix-spawn-design.md`. | +| `posix_spawnp()` | Partial | PATH search lives in libc (`libc/musl-overlay/src/process/wasm32posix/posix_spawnp.c`); resolves the absolute path then delegates to `posix_spawn()`. Empty PATH entries are treated as `.` and EACCES is deferred per `__execvpe` policy. It otherwise inherits `posix_spawn()`'s status. | | `clone()` | Partial | Thread-style clone (CLONE_VM\|CLONE_THREAD) supported. The Rust `ProcessTable` allocates the TID from the same global task-ID sequence as every PID, and the host spawns a thread Worker sharing the parent's Memory. Normal pthread return, pthread_exit, and cancellation cleanup remain per-thread and wake join/clear-TID waiters; uncaught fatal Wasm traps in a pthread worker terminate the whole process with signal-style wait status. | | `personality()` | Stub | Returns 0 (PER_LINUX). | | `unshare()` / `setns()` | Stub | Returns EPERM. No namespace support. | @@ -175,7 +178,7 @@ same final-OFD lifetime rules. | `ioperm()` / `iopl()` | Stub | Returns EPERM. No I/O port access. | | `remap_file_pages()` | Stub | Returns ENOSYS. | | `getcontext()` / `setcontext()` / `makecontext()` / `swapcontext()` | Unsupported | Userspace stack-switching primitives, deprecated in POSIX.1-2008, not planned. See the "ucontext API unsupported" row under [Wasm-Inherent gaps](#wasm-inherent--gaps-that-cannot-be-fully-resolved-in-wasm) for rationale. | -| `fork()` called from an exception catch handler | Partial | ABI 43 supports mixed `Catch`, `CatchRef`, `CatchAll`, and `CatchAllRef` arms, including scalar, vector, reference, JSTag, and modern C++ cleanup payloads. Scalar tagged arms serialize one exact activation selector and maximum live operand tuple; complete exceptions use the process reference graph and are thrown inside the fresh Wasm instance so reference clauses receive child-local exnrefs. Multiple arms/targets, recursion, loop re-entry, nested catches, later merged-flow forks, reference locals/carryovers, mutable reference globals, and mutated tables use the same versioned ownership machinery without module-static stashes. Dash and the configured shell/rootfs closure rebuild through this path. This row remains Partial only because `fork()` retains the ordinary open-file-description gaps in the main row, not because catch/reference replay is intentionally excluded. See [fork-instrumentation.md](fork-instrumentation.md). | +| `fork()` called from an exception catch handler | Partial | ABI 43 supports mixed `Catch`, `CatchRef`, `CatchAll`, and `CatchAllRef` arms, including scalar, vector, reference, JSTag, and modern C++ cleanup payloads. Scalar tagged arms serialize one exact activation selector and maximum live operand tuple; complete exceptions use the process reference graph and are thrown inside the fresh Wasm instance so reference clauses receive child-local exnrefs. Multiple arms/targets, recursion, loop re-entry, nested catches, later merged-flow forks, reference locals/carryovers, mutable reference globals, and mutated tables use the same versioned ownership machinery without module-static stashes. Dash and the configured shell/rootfs closure rebuild through this path. This row inherits the broader incomplete `fork()` status; catch/reference replay itself is not intentionally excluded. See [fork-instrumentation.md](fork-instrumentation.md). | ## Signals @@ -250,8 +253,8 @@ to a different directory than the original OFD. | `renameat2()` | Full | Delegates to renameat. Extra flags parameter ignored. | | `faccessat2()` | Full | Delegates to faccessat. Extra flags parameter ignored. | | `fchmodat2()` | Full | Delegates to fchmodat. Extra flags parameter ignored. | -| `getdents64()` | Partial | Within one process-local open file description, host-backed directories, procfs, and devfs emit complete Linux directory records with the same cursor rules. A full buffer retains the next record, a later host error returns the complete prefix already copied, and EINVAL is returned only when an otherwise empty buffer cannot hold the next record. Each `d_off` is a stable next-record cookie: zero rewinds before `.`, the cookie after `.` resumes at `..`, and `lseek(fd, cookie, SEEK_SET)` resumes host and kernel-generated directories at that position. `dup()` aliases the same cursor. Fork, `posix_spawn()`, retained legacy exec, and `SCM_RIGHTS` safely discard process-local host iterator handles and reconstruct at the copied cookie, subject to the pathname-backed directory identity limitation above. Those process copies still do not advance one global cursor; see the known gap below. | -| `getdents()` (legacy) | Partial | Delegates to getdents64 and has the same cross-process open-file-description boundary. | +| `getdents64()` | Partial | Host-backed directories, procfs, and devfs emit complete Linux directory records with the same cursor rules. A full buffer retains the next record, a later host error returns the complete prefix already copied, and EINVAL is returned only when an otherwise empty buffer cannot hold the next record. Each `d_off` is a stable next-record cookie: zero rewinds before `.`, the cookie after `.` resumes at `..`, and `lseek(fd, cookie, SEEK_SET)` resumes host and kernel-generated directories at that position. `dup()`, fork, `posix_spawn()`, retained legacy exec, and `SCM_RIGHTS` share that cookie. Each process retains its own host iterator; a generation mismatch closes and reconstructs it at the latest shared cookie, subject to the pathname-backed directory identity limitation above. | +| `getdents()` (legacy) | Partial | Delegates to getdents64 and shares its cursor and pathname-identity semantics. | | `name_to_handle_at()` / `open_by_handle_at()` | Stub | Returns ENOSYS. | ## Linux-Compatible Device Extensions @@ -552,7 +555,7 @@ Systematic audit of all subsystems against POSIX specifications. Gaps are catego | Gap | Subsystem | Description | |-----|-----------|-------------| -| **fork, posix_spawn, and SCM_RIGHTS recipients have independent ordinary-file OFD metadata** | fork / spawn / fd / sockets | POSIX requires inherited and transferred descriptors to refer to the same open file description. Kandelo retains exact global backings for pipes, sockets inherited through process creation, PTYs, eventfd/timerfd/signalfd, memfd, and procfs snapshots. For supported non-socket descriptions, `SCM_RIGHTS` also preserves `OfdId`/`FileId` while queued and after receipt, so OFD and `flock()` ownership survives sender close and ends only on the true final reference; socket-descriptor transfer is explicitly rejected until one machine-wide socket backing can preserve the endpoint. The per-process `OfdTable` still copies ordinary regular-file seek positions, status flags, and related metadata. At fork, non-forking spawn, retained legacy exec, and `SCM_RIGHTS` receipt, a directory copy drops the source's live process-local iterator and lazily reconstructs its own iterator at the snapshot next-record cookie; this prevents handle aliasing, duplicate restart from cookie zero, and double-close. Parent and child or sender and receiver still do not advance one authoritative ordinary-file or directory cursor after that boundary. The global-OFD redesign remains tracked in [future-improvements.md](future-improvements.md). | +| ~~**fork, posix_spawn, and SCM_RIGHTS recipients have independent ordinary-file OFD metadata**~~ | fork / spawn / fd / sockets | **Resolved.** Per-process descriptor tables retain exact shared OFD state for offset, status flags, and async owner across fork, vfork, non-forking spawn, and supported `SCM_RIGHTS`. The ancillary queue retains the state even after sender close and observes mutations made after send. Directory host iterators remain process-local, but a shared position generation forces stale iterators to reopen and replay at the one authoritative cookie. `OfdId`, `FileId`, backing, and OFD/`flock()` lifetime remain exact until the final reference. Socket transfer is still rejected at its separate machine-wide-backing boundary. | ### High — Missing features that affect common programs @@ -674,7 +677,10 @@ These features require SharedArrayBuffer (and cross-origin isolation headers in - Message protocol for host ↔ worker communication 13b. **Phase 13b (Complete):** Fork & Waitpid - Binary fork state serialization/deserialization (Rust) -- `kernel_fork_process(parent, caller_tid)` validates the calling task, allocates the child identity, and copies its state; the caller-selected `kernel_init_from_fork(..., child_pid)` constructor was removed in ABI 42 +- `kernel_fork_process(parent, caller_tid, mode)` validates the calling task + and ABI 43 fork mode, allocates the child identity, and copies its state; the + caller-selected `kernel_init_from_fork(..., child_pid)` constructor was + removed in ABI 42 - ProcessManager.fork() with state transfer to child worker - ProcessManager.waitpid() with WNOHANG support 13c. **Phase 13c (Complete):** Cross-Process Pipes diff --git a/host/src/browser-kernel-worker-entry.ts b/host/src/browser-kernel-worker-entry.ts index f048d1d0b1..ba0ace692f 100644 --- a/host/src/browser-kernel-worker-entry.ts +++ b/host/src/browser-kernel-worker-entry.ts @@ -17,6 +17,7 @@ import { TERMINAL_STDIO, } from "./kernel-worker"; import type { + ForkBorrowedReplayWorkspace, ForkContinuationContext, ResolvedSpawnProgram, SpawnProgramResolution, @@ -81,19 +82,32 @@ import type { } from "./worker-protocol"; import { ThreadPageAllocator } from "./thread-allocator"; import { CH_TOTAL_SIZE, DEFAULT_MAX_PAGES, PAGES_PER_THREAD } from "./constants"; -import { FILE_MODES, OPEN_FLAGS } from "./generated/abi"; +import { + FILE_MODES, + OPEN_FLAGS, + PROCESS_FORK_MODE_VFORK, + type ProcessForkMode, +} from "./generated/abi"; import { acquireForkMemoryClone, computeProcessMemoryLayout, createProcessMemoryRetirementPressureHook, DEFAULT_PROCESS_THREAD_SLOTS, deriveProcessMemoryRetirementAdmissionThresholds, + FORK_SAVE_BUFFER_SIZE, ProcessMemoryCapacityError, ProcessMemoryAllocator, ProcessMemoryRetirementBacklogError, type ProcessMemoryLayout, type ProcessMemoryLease, } from "./process-memory"; +import { + VforkAddressSpaceBusyError, + VforkLifetimeCoordinator, + type VforkExactCompletionReason, + type VforkLifetime, + type VforkLifetimeDisposition, +} from "./vfork-lifetime"; import { ExactProcessGenerationDetachLedger, type ExactProcessGenerationDetachResult, @@ -147,6 +161,12 @@ interface ProcessGenerationOwnership { memoryLease: ProcessMemoryLease; } +interface VforkWorkspaceOwnership { + readonly allocator: ThreadPageAllocator; + readonly slotStartPage: number; + released: boolean; +} + interface ProcessInfo extends ProcessGenerationOwnership { /** Host-only identity for one execution image. A PID persists across exec. */ generation: number; @@ -171,8 +191,11 @@ interface ProcessInfo extends ProcessGenerationOwnership { externrefGeneration: ForkExternrefGeneration; /** Non-_start continuation root inherited from a pthread fork until exec. */ forkReplayContext?: ForkReplayContext; + /** Parent-owned control slot borrowed only until exact exec/exit teardown. */ + vforkWorkspace?: VforkWorkspaceOwnership; } const processes = new Map(); +const vforkLifetimes = new VforkLifetimeCoordinator(); const externrefProcessOwner = new ForkExternrefProcessOwner(); const forkHostImportOwnerRuntime = new ForkHostImportOwnerRuntime(externrefProcessOwner); @@ -373,9 +396,11 @@ async function awaitFinalizedProcessTeardown( exitStatus: number, expectedWorker: ProcessInfo["worker"], crashSignum?: number, + reason: VforkExactCompletionReason = + signalFromExitStatus(exitStatus) === null ? "exit" : "signal", ): Promise { if (!processTeardowns.has(expectedWorker)) { - handleExit(pid, exitStatus, crashSignum, expectedWorker); + handleExit(pid, exitStatus, crashSignum, expectedWorker, reason); } await processTeardowns.get(expectedWorker); } @@ -1054,8 +1079,15 @@ async function handleInit(msg: Extract) { processMemoryAllocator.observeTarget(memory, target); }, onKernelFatal: terminatePoisonedKernelWorker, - onFork: ({ parentPid, childPid, parentMemory, continuation }) => { - return processMemoryCreators.run("a fork process Worker", () => { + onFork: ({ + parentPid, + childPid, + mode, + parentMemory, + continuation, + borrowedReplay, + }) => { + const launch = (releaseCreatorAdmission?: () => void) => { // Tell the main thread a kernel-side fork happened so Inspector // panes can refresh their process table without polling. post({ @@ -1064,13 +1096,39 @@ async function handleInit(msg: Extract) { pid: childPid, ppid: parentPid, }); - return handleFork(parentPid, childPid, parentMemory, continuation); - }); + return handleFork( + parentPid, + childPid, + mode, + parentMemory, + continuation, + borrowedReplay, + releaseCreatorAdmission, + ); + }; + return mode === PROCESS_FORK_MODE_VFORK + ? processMemoryCreators.runUntilCommitted( + "a vfork process Worker", + (commit) => launch(commit), + ) + : processMemoryCreators.run( + "a fork process Worker", + () => launch(), + ); }, onExec: (pid, path, argv, envp, callerTid) => processMemoryCreators.run("an exec process Worker", async () => { - const previousWorker = processes.get(pid)?.worker; + const execGeneration = processes.get(pid); + const previousWorker = execGeneration?.worker; const result = await handleExec(pid, path, argv, envp, callerTid); + if ( + result < 0 + && execGeneration + && processes.get(pid) === execGeneration + && vforkLifetimes.isActiveBorrower(execGeneration) + ) { + vforkLifetimes.noteFailedExec(execGeneration, -result); + } // Fire after handleExec updates the kernel Process.argv. If this is // sent before registerProcess(..., { argv }), Kandelo's Procs tab // refreshes against stale cmdline data and only corrects on remount. @@ -1128,6 +1186,20 @@ async function handleInit(msg: Extract) { const processInfo = processes.get(pid); if (!processInfo) return; if (ev === "bind") { + if (processInfo.vforkWorkspace && !processInfo.vforkWorkspace.released) { + const error = new Error( + `vfork child ${pid} attempted to expose borrowed Memory to browser main`, + ); + if (vforkLifetimes.isActiveBorrower(processInfo)) { + vforkLifetimes.requireAddressSpaceContainment(processInfo, error); + reportHostDiagnostic({ + pid, + source: "vfork framebuffer ownership", + message: `[vfork] ${error.message}`, + }); + } + return; + } const b = kernelWorker.framebuffers.get(pid); const memory = kernelWorker.getProcessMemory(pid); if (!b || !memory) return; @@ -1163,6 +1235,20 @@ async function handleInit(msg: Extract) { const memory = kernelWorker.getProcessMemory(pid); const processInfo = processes.get(pid); if (memory && processInfo) { + if (processInfo.vforkWorkspace && !processInfo.vforkWorkspace.released) { + const error = new Error( + `vfork child ${pid} attempted to rebind borrowed Memory in browser main`, + ); + if (vforkLifetimes.isActiveBorrower(processInfo)) { + vforkLifetimes.requireAddressSpaceContainment(processInfo, error); + reportHostDiagnostic({ + pid, + source: "vfork framebuffer ownership", + message: `[vfork] ${error.message}`, + }); + } + return; + } post({ type: "fb_rebind_memory", pid, @@ -1461,7 +1547,15 @@ function installProcessWorkerListeners( }); } } finally { - handleExit(pid, status, crashSignum, worker); + handleExit( + pid, + status, + crashSignum, + worker, + failure === undefined + ? (signalFromExitStatus(status) === null ? "exit" : "signal") + : "trap", + ); } }; @@ -1549,6 +1643,486 @@ function installProcessWorkerListeners( async function handleFork( parentPid: number, childPid: number, + mode: ProcessForkMode, + parentMemory: WebAssembly.Memory, + continuation: ForkContinuationContext, + borrowedReplay?: ForkBorrowedReplayWorkspace, + releaseCreatorAdmission?: () => void, +): Promise { + if (mode === PROCESS_FORK_MODE_VFORK) { + if (!borrowedReplay) { + throw new VforkAddressSpaceBusyError( + "vfork launch is missing its admitted replay workspace", + ); + } + return handleVfork( + parentPid, + childPid, + parentMemory, + continuation, + borrowedReplay, + releaseCreatorAdmission, + ); + } + if (releaseCreatorAdmission) { + throw new Error("ordinary fork cannot release vfork creator admission"); + } + if (borrowedReplay) { + throw new Error("ordinary fork cannot borrow replay workspace"); + } + return handleOrdinaryFork( + parentPid, + childPid, + mode, + parentMemory, + continuation, + ); +} + +function releaseVforkWorkspace(info: ProcessInfo): void { + const workspace = info.vforkWorkspace; + if (!workspace || workspace.released) return; + workspace.released = true; + workspace.allocator.free(workspace.slotStartPage); +} + +function completeVforkGenerationTeardown( + info: ProcessInfo, + exact: boolean, + reason: VforkExactCompletionReason, + cause?: unknown, +): void { + const phase = vforkLifetimes.phaseForChild(info); + if (phase === undefined) return; + if (!exact) { + vforkLifetimes.requireAddressSpaceContainment( + info, + cause ?? new Error("vfork child teardown lacked an exact quiescence fence"), + ); + return; + } + releaseVforkWorkspace(info); + if (phase === "starting") { + vforkLifetimes.completeWithoutBorrow( + info, + reason === "exit" ? "exit" : "signal", + ); + } else { + vforkLifetimes.completeAfterExactTeardown(info, reason); + } +} + +async function containVforkAddressSpace( + disposition: Extract< + VforkLifetimeDisposition, + { kind: "contain-address-space" } + >, + childGeneration: ProcessInfo, + parentPid: number, +): Promise { + const status = signalExitStatus(SIGSEGV); + reportHostDiagnostic({ + pid: parentPid, + status, + source: "vfork address-space containment", + message: + `[vfork] containing shared address space after ambiguous child ` + + `teardown for pid=${disposition.childPid}: ${ + formatError(disposition.cause) + }`, + }); + + if (processes.get(disposition.childPid) === childGeneration) { + await finishProcessExit( + disposition.childPid, + status, + SIGSEGV, + childGeneration.worker, + "trap", + ); + } + if (processes.get(parentPid) === disposition.parentGeneration) { + await finishProcessExit( + parentPid, + status, + SIGSEGV, + disposition.parentGeneration.worker, + "trap", + ); + } + + if ( + processes.get(disposition.childPid) === childGeneration + || processes.get(parentPid) === disposition.parentGeneration + ) { + const error = new Error( + `could not contain ambiguous vfork address space for parent=${parentPid} ` + + `child=${disposition.childPid}`, + { cause: disposition.cause }, + ); + terminatePoisonedKernelWorker(error); + throw error; + } + + // WHY: rejecting onFork could roll back a child PID that already belongs to + // a successful exec replacement. The original parent's exact channel is now + // absent, so resolving cannot wake the parked vfork caller. + return []; +} + +async function finishVforkDisposition( + disposition: VforkLifetimeDisposition, + childGeneration: ProcessInfo, + parentPid: number, +): Promise { + if (disposition.kind === "return-error") { + throw new VforkAddressSpaceBusyError( + `vfork launch returned errno ${disposition.errno}`, + ); + } + if (disposition.kind === "contain-address-space") { + return containVforkAddressSpace(disposition, childGeneration, parentPid); + } + return [childGeneration.channelOffset]; +} + +async function handleVfork( + parentPid: number, + childPid: number, + parentMemory: WebAssembly.Memory, + continuation: ForkContinuationContext, + borrowedReplay: ForkBorrowedReplayWorkspace, + releaseCreatorAdmission: (() => void) | undefined, +): Promise { + const parentInfo = processes.get(parentPid); + if (!parentInfo || parentInfo.memory !== parentMemory) { + throw new Error(`Unknown parent generation for pid ${parentPid}`); + } + if (vforkLifetimes.hasActiveAddressSpace(parentMemory)) { + throw new VforkAddressSpaceBusyError(); + } + if ( + borrowedReplay.prefixBytes <= 0 + || borrowedReplay.prefixBytes > FORK_SAVE_BUFFER_SIZE + || borrowedReplay.scratchBytes < 0 + || borrowedReplay.scratchBytes > PAGE_SIZE + ) { + throw new VforkAddressSpaceBusyError( + "vfork replay workspace exceeds one host control slot", + ); + } + + if (!parentInfo.programModule) { + // Stay synchronous through lifetime installation. A sibling pthread may + // replace the parent generation in the first yielded browser-worker turn. + parentInfo.programModule = new WebAssembly.Module(parentInfo.programBytes); + } + + const childMemoryLease = parentInfo.memoryLease.retainAlias(); + let childMemoryLeaseConsumed = false; + let workspaceAllocation: ReturnType; + try { + workspaceAllocation = + parentInfo.threadAllocator.allocateHostControl(parentMemory); + } catch (error) { + childMemoryLease.release(); + throw new VforkAddressSpaceBusyError( + `vfork control workspace is unavailable: ${formatError(error)}`, + ); + } + const workspaceOwnership: VforkWorkspaceOwnership = { + allocator: parentInfo.threadAllocator, + slotStartPage: workspaceAllocation.slotStartPage, + released: false, + }; + const childChannelOffset = workspaceAllocation.channelOffset; + const childLayout = parentInfo.layout; + const ptrWidth = parentInfo.ptrWidth; + let childWorker: DeferredWorkerHandle | undefined; + let childGeneration: ProcessInfo | undefined; + let childExternrefGeneration: ForkExternrefGeneration | undefined; + let childForkHostImports: ForkHostImportOwnerWorker | undefined; + let registered = false; + let lifetimeStarted = false; + let lifetime: VforkLifetime | undefined; + const forkReplay = new ForkReplayGateCoordinator( + `vfork child pid=${childPid}`, + ); + + try { + const workspaceAddress = workspaceAllocation.slotStartPage * PAGE_SIZE; + kernelWorker.reserveHostRegionAt( + childPid, + workspaceAddress, + PAGES_PER_THREAD * PAGE_SIZE, + ); + kernelWorker.registerProcess(childPid, parentMemory, [childChannelOffset], { + ptrWidth, + maxAddr: childLayout.maxAddr, + mmapBase: childLayout.mmapBase, + borrowedAddressSpace: true, + }); + registered = true; + kernelWorker.inheritProcessSharedMappings(parentPid, childPid); + + const forkBufAddr = continuation.forkBufAddr; + const forkReplayContext: ForkReplayContext | undefined = + continuation.kind === "thread" + ? { + fnPtr: continuation.fnPtr, + argPtr: continuation.argPtr, + forkBufAddr, + } + : parentInfo.forkReplayContext + ? { ...parentInfo.forkReplayContext, forkBufAddr } + : undefined; + const externrefGrant = + externrefProcessOwner.forkGenerationFromContinuation( + parentInfo.externrefGeneration, + childPid, + parentMemory, + ptrWidth, + forkBufAddr, + ); + childExternrefGeneration = externrefGrant.generation; + let launchedWorker: DeferredWorkerHandle; + const forkHostImports = forkHostImportOwnerRuntime.createWorker({ + pid: childPid, + generationId: externrefGrant.generation.id, + authorizeSender: () => { + const current = processes.get(childPid); + if ( + !current + || current.worker !== launchedWorker + || current.externrefGeneration !== externrefGrant.generation + ) { + throw new Error( + `stale fork host-import sender for vfork child pid=${childPid}`, + ); + } + }, + }); + childForkHostImports = forkHostImports; + const childInitData: CentralizedWorkerInitMessage = { + type: "centralized_init", + pid: childPid, + programBytes: parentInfo.programBytes, + programModule: parentInfo.programModule, + memory: parentMemory, + channelOffset: childChannelOffset, + externrefGenerationId: externrefGrant.generation.id, + forkHostImports: forkHostImports.init, + isForkChild: true, + forkMode: PROCESS_FORK_MODE_VFORK, + forkMemoryOwnership: "borrowed", + forkBufAddr, + forkOwnerControlAddr: + parentInfo.channelOffset - FORK_SAVE_BUFFER_SIZE, + forkPrivatePrefixAddr: + childChannelOffset - FORK_SAVE_BUFFER_SIZE, + forkPrivatePrefixBytes: borrowedReplay.prefixBytes, + forkScratchAddr: workspaceAllocation.tlsOffset, + forkScratchBytes: borrowedReplay.scratchBytes, + forkReplayGate: forkReplay.gate, + forkChildThreadFnPtr: forkReplayContext?.fnPtr, + forkChildThreadArgPtr: forkReplayContext?.argPtr, + ptrWidth, + kernelAbiVersion: kernelWorker.getKernelAbiVersion(), + }; + + childWorker = new DeferredWorkerHandle( + () => workerAdapter.createWorker(childInitData), + ); + launchedWorker = childWorker; + bindForkHostImports(childWorker, forkHostImports); + childGeneration = { + generation: allocateProcessGeneration(), + memory: parentMemory, + memoryLease: childMemoryLease, + workerQuiescence: createWorkerQuiescence(), + execRetirement: createWorkerQuiescence(), + memoryRetirementSafe: true, + framebufferExposed: false, + programBytes: parentInfo.programBytes, + programModule: parentInfo.programModule, + worker: childWorker, + argv: parentInfo.argv, + channelOffset: childChannelOffset, + ptrWidth, + layout: childLayout, + threadAllocator: threadAllocatorForLayout(childLayout, ptrWidth, childPid), + forkReplayContext, + externrefGeneration: externrefGrant.generation, + vforkWorkspace: workspaceOwnership, + }; + lifetime = vforkLifetimes.begin( + parentPid, + childPid, + parentInfo, + childGeneration, + ); + lifetimeStarted = true; + processes.set(childPid, childGeneration); + // Host destroy can now sweep every alias even though onFork deliberately + // remains pending to park the calling guest thread until exec/_exit. + releaseCreatorAdmission?.(); + + observeForkReplayWorker( + forkReplay, + launchedWorker, + childPid, + () => processes.get(childPid)?.worker === launchedWorker, + ); + installProcessWorkerListeners(childWorker, childPid); + let startFailure: unknown; + const startDisposition = kernelWorker.startProcessWorkerWhenRunnable( + childPid, + parentMemory, + () => { + vforkLifetimes.markChildMayAccessMemory(childGeneration!); + try { + launchedWorker.start(); + } catch (error) { + startFailure = error; + forkReplay.cancel(error); + vforkLifetimes.requireAddressSpaceContainment( + childGeneration!, + error, + ); + } + }, + () => { + forkReplay.cancel( + new Error(`Vfork child ${childPid} launch was cancelled`), + ); + forkHostImports.close(); + void launchedWorker.terminate(); + }, + ); + if (startDisposition === "stale") { + throw new VforkAddressSpaceBusyError( + `Vfork child ${childPid} changed generation before Worker launch`, + ); + } + if (startDisposition === "dead") { + forkReplay.cancel( + new Error(`Vfork child ${childPid} exited before Worker launch`), + ); + forkHostImports.close(); + await terminateTrackedWorker(childWorker); + childGeneration.workerQuiescence.settle(); + const signal = kernelWorker.finalizePendingChildTermination(childPid); + await awaitFinalizedProcessTeardown( + childPid, + signal > 0 ? signalExitStatus(signal) : 0, + childWorker, + signal > 0 ? signal : undefined, + signal > 0 ? "signal" : "exit", + ); + return finishVforkDisposition( + await lifetime.completion, + childGeneration, + parentPid, + ); + } + + try { + await forkReplay.waitUntilReady(); + } catch (error) { + const phase = vforkLifetimes.phaseForChild(childGeneration); + if (phase === "starting") { + childGeneration.workerQuiescence.settle(); + const signal = kernelWorker.finalizePendingChildTermination(childPid); + await awaitFinalizedProcessTeardown( + childPid, + signal > 0 ? signalExitStatus(signal) : 0, + childWorker, + signal > 0 ? signal : undefined, + signal > 0 ? "signal" : "exit", + ); + } else if (phase === "borrowing" && startFailure === undefined) { + await finishProcessExit( + childPid, + signalExitStatus(SIGSEGV), + SIGSEGV, + childWorker, + "trap", + ); + } + return finishVforkDisposition( + await lifetime.completion, + childGeneration, + parentPid, + ); + } + if (processes.get(childPid) !== childGeneration) { + throw new Error( + `Vfork child ${childPid} changed generation before replay commit`, + ); + } + if (!kernelWorker.shouldLaunchPendingChild(childPid)) { + throw new Error(`Vfork child ${childPid} exited before replay commit`); + } + forkReplay.commit(); + return finishVforkDisposition( + await lifetime.completion, + childGeneration, + parentPid, + ); + } catch (error) { + if (childGeneration && lifetimeStarted) { + const phase = vforkLifetimes.phaseForChild(childGeneration); + if (phase === "borrowing") { + vforkLifetimes.requireAddressSpaceContainment(childGeneration, error); + return finishVforkDisposition( + await lifetime!.completion, + childGeneration, + parentPid, + ); + } + } + + forkReplay.cancel(error); + childForkHostImports?.close(); + if (childWorker) await terminateTrackedWorker(childWorker); + if (childExternrefGeneration) { + externrefProcessOwner.releaseGeneration(childExternrefGeneration); + } + if (childGeneration && registered) { + const detachResult = await detachExactProcessGeneration({ + pid: childPid, + generation: childGeneration, + operation: "deactivate", + retire: (commit) => { + childMemoryLease.release(); + childMemoryLeaseConsumed = true; + commit(); + }, + }); + if (detachResult.status !== "released") { + reportRetainedProcessGeneration( + childPid, + "vfork launch rollback", + detachResult, + ); + } + } + if (!childMemoryLeaseConsumed) childMemoryLease.release(); + if (!workspaceOwnership.released) { + workspaceOwnership.released = true; + workspaceOwnership.allocator.free(workspaceOwnership.slotStartPage); + } + if (childGeneration && lifetimeStarted) { + vforkLifetimes.abortBeforeChildStart(childGeneration, 11); + } + throw error; + } +} + +async function handleOrdinaryFork( + parentPid: number, + childPid: number, + mode: ProcessForkMode, parentMemory: WebAssembly.Memory, continuation: ForkContinuationContext, ): Promise { @@ -1657,6 +2231,7 @@ async function handleFork( externrefGenerationId: externrefGrant.generation.id, forkHostImports: forkHostImports.init, isForkChild: true, + forkMode: mode, forkBufAddr, forkReplayGate: forkReplay.gate, forkChildThreadFnPtr: forkReplayContext?.fnPtr, @@ -1799,6 +2374,7 @@ async function handleExec( ): Promise { const initiatingInfo = processes.get(pid); if (!initiatingInfo) return -3; // ESRCH + const vforkBorrower = vforkLifetimes.isActiveBorrower(initiatingInfo); const resolved = await resolveExecutableForLaunch(path, argv); if (!resolved) return -2; // ENOENT if ("errno" in resolved) return -resolved.errno; @@ -1945,6 +2521,7 @@ async function handleExec( signalExitStatus(handoffExitSignal), initiatingInfo.worker, handoffExitSignal, + "signal", ); return 0; } @@ -2065,15 +2642,36 @@ async function handleExec( processes.get(pid)?.workerQuiescence.settle(); kernelWorker.finishProcessExecHandoff(pid); const signal = kernelWorker.finalizeExecHandoffTermination(pid); + if (vforkBorrower) { + completeVforkGenerationTeardown( + initiatingInfo, + oldMemoryRetirementSafe, + "exec", + new Error( + `vfork child ${pid} exec retired without exact browser ownership`, + ), + ); + } await awaitFinalizedProcessTeardown( pid, signal > 0 ? signalExitStatus(signal) : 0, replacementWorker, signal > 0 ? signal : undefined, + signal > 0 ? "signal" : "exit", ); return 0; } kernelWorker.finishProcessExecHandoff(pid); + if (vforkBorrower) { + completeVforkGenerationTeardown( + initiatingInfo, + oldMemoryRetirementSafe, + "exec", + new Error( + `vfork child ${pid} exec retired without exact browser ownership`, + ), + ); + } return 0; } catch (err) { replacementForkHostImports?.close(); @@ -2134,6 +2732,18 @@ async function handleExec( } initiatingLeaseConsumed = true; } + if ( + vforkBorrower + && preparedTransferred + && vforkLifetimes.phaseForChild(initiatingInfo) !== undefined + ) { + completeVforkGenerationTeardown( + initiatingInfo, + oldMemoryRetirementSafe && initiatingLeaseConsumed, + "trap", + err, + ); + } const message = err instanceof Error ? err.message : String(err); try { @@ -2645,8 +3255,16 @@ function handleExit( exitStatus: number, crashSignum?: number, expectedWorker = processes.get(pid)?.worker, + vforkReason: VforkExactCompletionReason = + signalFromExitStatus(exitStatus) === null ? "exit" : "signal", ): void { - void finishProcessExit(pid, exitStatus, crashSignum, expectedWorker); + void finishProcessExit( + pid, + exitStatus, + crashSignum, + expectedWorker, + vforkReason, + ); } async function finishProcessExit( @@ -2654,6 +3272,8 @@ async function finishProcessExit( exitStatus: number, crashSignum: number = signalFromExitStatus(exitStatus) ?? SIGSEGV, expectedWorker = processes.get(pid)?.worker, + vforkReason: VforkExactCompletionReason = + signalFromExitStatus(exitStatus) === null ? "exit" : "signal", ): Promise { if (!expectedWorker) return; const info = processes.get(pid); @@ -2697,6 +3317,7 @@ async function finishProcessExit( // always deactivate after worker termination; the main thread tracks // exit promises, and no further guest syscalls can arrive on this // channel once the worker is gone. + let exactMemoryTeardown = false; const detachResult = await detachExactProcessGeneration({ pid, generation: info, @@ -2704,12 +3325,12 @@ async function finishProcessExit( retire: async (commit) => { const mainFramebufferReleased = await releaseMainFramebufferGeneration(pid, info); - if ( + exactMemoryTeardown = workerQuiescent && threadsQuiescent && info.memoryRetirementSafe - && mainFramebufferReleased - ) { + && mainFramebufferReleased; + if (exactMemoryTeardown) { info.memoryLease.release(); } else { // Browser Worker.terminate() returns before the underlying Worker @@ -2721,6 +3342,12 @@ async function finishProcessExit( }, }); if (detachResult.status !== "released") { + completeVforkGenerationTeardown( + info, + false, + vforkReason, + detachResult.error, + ); reportRetainedProcessGeneration( pid, "process channel teardown", @@ -2731,6 +3358,14 @@ async function finishProcessExit( } externrefProcessOwner.releaseGeneration(info.externrefGeneration); + completeVforkGenerationTeardown( + info, + exactMemoryTeardown, + vforkReason, + new Error( + `vfork child ${pid} exited without exact browser ownership fences`, + ), + ); if (!detachResult.mayReapPid) return; try { diff --git a/host/src/constants.ts b/host/src/constants.ts index 4960727dac..a6e487fd56 100644 --- a/host/src/constants.ts +++ b/host/src/constants.ts @@ -55,6 +55,7 @@ import { WPK_FORK_MODULE_STATE_RECORD_VERSION, WPK_FORK_MODULE_STATE_REQUIRED_FLAGS, WPK_FORK_MODULE_STATE_ROOT_POINTER_WORD_OFFSET, + WPK_FORK_PROCESS_IMPORT, WPK_FORK_REQUIRED_EXPORTS, WPK_FORK_REQUIRED_IMPORTS, WPK_FORK_REQUIRED_TABLE_IMPORTS, @@ -1995,6 +1996,32 @@ function describeForkArtifactContractFailures( ); } + if (facts.importsKernelFork) { + const identity = + `${WPK_FORK_PROCESS_IMPORT.module}.${WPK_FORK_PROCESS_IMPORT.name}`; + const signatures = facts.functionImports.get(identity); + if (signatures?.length !== 1) { + failures.push(`duplicate ABI 43 process-fork import ${identity}`); + } else if ( + !signatureMatches( + signatures[0], + WPK_FORK_PROCESS_IMPORT.params, + WPK_FORK_PROCESS_IMPORT.results, + 4, + ) + ) { + failures.push( + `ABI 43 process-fork import ${identity} has the wrong signature; expected ${ + signatureText( + WPK_FORK_PROCESS_IMPORT.params, + WPK_FORK_PROCESS_IMPORT.results, + 4, + ) + }`, + ); + } + } + let pointerWidth: number | null = null; if (facts.linkedFrameDescriptors.length === 0) { failures.push(`missing required ${WPK_FORK_LINKED_FRAME_FORMAT_SECTION} descriptor`); diff --git a/host/src/fork-activation-registry.ts b/host/src/fork-activation-registry.ts index 6707a10de7..85969c6128 100644 --- a/host/src/fork-activation-registry.ts +++ b/host/src/fork-activation-registry.ts @@ -1452,6 +1452,14 @@ export class ForkActivationRegistry { this.phase = "sealed-parent"; } + borrowedReplayScratchCapacity(): number { + this.requirePhase( + "sealed-parent", + "read borrowed replay scratch capacity", + ); + return this.currentReferences().borrowedReplayScratchCapacity(); + } + beginParentReplay(): void { this.requirePhase("sealed-parent", "begin parent activation replay"); this.currentReferences().beginParentReplay(); diff --git a/host/src/fork-process-continuation.ts b/host/src/fork-process-continuation.ts index af85dd817b..96f414df35 100644 --- a/host/src/fork-process-continuation.ts +++ b/host/src/fork-process-continuation.ts @@ -62,6 +62,11 @@ export type ForkBorrowedReplayPrefixAllocator = ( request: ForkBorrowedReplayPrefixRequest, ) => WasmGuestPointer; +export interface ForkBorrowedReplayWorkspaceRequirements { + readonly prefixBytes: number; + readonly scratchBytes: number; +} + export interface ForkProcessActivationBinding { readonly activationId: number; readonly continuation: LinkedForkContinuation; @@ -71,9 +76,11 @@ export interface ForkProcessActivationBinding { * Only activation zero owns this process-wide anchor. Its value may name * any active activation's continuation: a side module can call the fork * import without placing a main-module Wasm frame on the captured stack. + * A vfork child deliberately omits this writer: it may read the suspended + * parent's root for borrowed replay, but must never clear or replace it. */ readonly publishProcessLaunchRoot?: (address: number) => void; - /** Read the copied process launch root after fresh-child instantiation. */ + /** Read the copied or borrowed process launch root after instantiation. */ readonly readProcessLaunchRoot?: () => number; } @@ -163,11 +170,11 @@ export class ForkProcessContinuationCoordinator { ); } if ( - (binding.publishProcessLaunchRoot === undefined) - !== (binding.readProcessLaunchRoot === undefined) + binding.publishProcessLaunchRoot !== undefined + && binding.readProcessLaunchRoot === undefined ) { throw new Error( - `${this.label}: process launch anchor must provide both read and publish`, + `${this.label}: a writable process launch anchor must also be readable`, ); } this.prepared.set(binding.activationId, binding); @@ -671,6 +678,36 @@ export class ForkProcessContinuationCoordinator { return this.phase; } + /** + * Measure child-private workspace after the complete process graph seals. + * + * Prefixes remain live through inherited-frame rewind, while reference + * scratch is stack-disciplined and reports its capture high-water. Keeping + * the two regions separate lets the host use one control slot without + * allowing either allocator to overwrite the other. + */ + borrowedReplayWorkspaceRequirements(): ForkBorrowedReplayWorkspaceRequirements { + this.requirePhase( + "sealed-parent", + "measure borrowed replay workspace", + ); + let prefixBytes = 0; + for (const activation of this.activeActivations()) { + const { alignment, fixedPrefixSize } = activation.continuation.format; + prefixBytes = Math.ceil(prefixBytes / alignment) * alignment; + prefixBytes += fixedPrefixSize; + if (!Number.isSafeInteger(prefixBytes)) { + throw new RangeError( + `${this.label}: borrowed replay prefix size exceeds JavaScript precision`, + ); + } + } + return { + prefixBytes, + scratchBytes: this.registry.borrowedReplayScratchCapacity(), + }; + } + rootFor(activationId: number): number { return this.getActivation(activationId).root; } @@ -906,7 +943,7 @@ export class ForkProcessContinuationCoordinator { private readProcessLaunchRoot(): number { const owner = this.activations.get(0); const read = owner?.readProcessLaunchRoot; - if (!owner || !owner.publishProcessLaunchRoot || !read) { + if (!owner || !read) { throw new Error( `${this.label}: activation zero has no process launch anchor`, ); diff --git a/host/src/fork-reference-transaction.ts b/host/src/fork-reference-transaction.ts index 8d8ec0eb33..f2870b3d32 100644 --- a/host/src/fork-reference-transaction.ts +++ b/host/src/fork-reference-transaction.ts @@ -210,6 +210,8 @@ export class ForkReferenceTransaction { private exceptionSlots: ForkExceptionSlotProvider | undefined; private readonly scratchChunks: ScratchChunk[] = []; private readonly scratchReservations: ScratchReservation[] = []; + private scratchCapacityBytes = 0; + private scratchCapacityHighWaterBytes = 0; /** Index zero is the canonical empty-vector sentinel. */ private readonly referenceVectors = new PagedForkReferenceDirectory(); @@ -544,6 +546,21 @@ export class ForkReferenceTransaction { this.phase = "parent-replay"; } + /** + * Exact page-rounded scratch capacity observed while capturing this graph. + * + * Encode and replay use the same generated, stack-disciplined codecs. A + * vfork host can therefore reserve this capacity before the child may touch + * shared memory instead of discovering scratch exhaustion during replay. + */ + borrowedReplayScratchCapacity(): number { + this.requirePhase( + "sealed-parent", + "read borrowed replay scratch capacity", + ); + return this.scratchCapacityHighWaterBytes; + } + attachChild( source: | Parameters[0] @@ -876,6 +893,11 @@ export class ForkReferenceTransaction { } chunk = { addr, size: chunkSize, used: 0 }; this.scratchChunks.push(chunk); + this.scratchCapacityBytes += chunkSize; + this.scratchCapacityHighWaterBytes = Math.max( + this.scratchCapacityHighWaterBytes, + this.scratchCapacityBytes, + ); } const previousUsed = chunk.used; const addr = chunk.addr + previousUsed; @@ -920,6 +942,7 @@ export class ForkReferenceTransaction { && this.scratchChunks.length > 1 ) { this.scratchChunks.pop(); + this.scratchCapacityBytes -= tail.size; this.deallocateScratch!(tail.addr, tail.size); } } @@ -2016,6 +2039,8 @@ export class ForkReferenceTransaction { } this.scratchReservations.length = 0; const chunks = this.scratchChunks.splice(0).reverse(); + this.scratchCapacityBytes = 0; + this.scratchCapacityHighWaterBytes = 0; let firstScratchError: unknown; for (const chunk of chunks) { try { diff --git a/host/src/generated/abi.ts b/host/src/generated/abi.ts index 4b76b30d96..efb7c34b01 100644 --- a/host/src/generated/abi.ts +++ b/host/src/generated/abi.ts @@ -259,6 +259,12 @@ export const WPK_FORK_REFERENCE_IMPORT_VECTOR_APPEND = "__wpk_fork_ref_vector_ap export const WPK_FORK_REFERENCE_IMPORT_VECTOR_BEGIN = "__wpk_fork_ref_vector_begin" as const; export const WPK_FORK_REFERENCE_IMPORT_VECTOR_FINISH = "__wpk_fork_ref_vector_finish" as const; export const WPK_FORK_REFERENCE_IMPORT_VECTOR_GET = "__wpk_fork_ref_vector_get" as const; +export const PROCESS_FORK_MODE_FORK = 0 as const; +export const PROCESS_FORK_MODE_VFORK = 1 as const; +export type ProcessForkMode = + | typeof PROCESS_FORK_MODE_FORK + | typeof PROCESS_FORK_MODE_VFORK; +export const WPK_FORK_PROCESS_IMPORT = { module: "kernel", name: "kernel_fork", params: ["i32"], results: ["i32"] } as const; export const WPK_FORK_REQUIRED_IMPORTS = [ { module: "env", name: "__wpk_fork_frame_commit", params: ["ptr"], results: [] }, { module: "env", name: "__wpk_fork_frame_next", params: ["ptr"], results: ["ptr"] }, diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index 5c039338e4..f0142d4386 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -148,6 +148,8 @@ import { POSIX_NAME_MAX_BYTES, POSIX_NGROUPS_MAX, POSIX_PATH_MAX_BYTES, + PROCESS_FORK_MODE_FORK, + PROCESS_FORK_MODE_VFORK, MAX_REPORTABLE_TRANSFER_BYTES, MAX_TRANSFER_ALLOCATION_BYTES, PROCESS_METADATA_ENTRY_MAX_BYTES, @@ -168,6 +170,7 @@ import { PROCESS_SNAPSHOT_UID_OFFSET, PROCESS_SNAPSHOT_VSIZE_OFFSET, PROCESS_CMSGHDR_WASM32_ALIGN, + type ProcessForkMode, PROCESS_CMSGHDR_WASM32_DATA_OFFSET, PROCESS_CMSGHDR_WASM32_LEN_OFFSET, PROCESS_CMSGHDR_WASM32_LEVEL_OFFSET, @@ -271,6 +274,7 @@ import { PROCESS_MMAP_BASE, growMemoryToCover, } from "./process-memory"; +import { VforkAddressSpaceBusyError } from "./vfork-lifetime"; import { readForkContinuationAnchor } from "./fork-continuation"; import { EXEC_RETIRE_SIGNAL_CODE } from "./worker-protocol"; import { @@ -912,9 +916,10 @@ const P_PGID = 2; const SIGCHLD = 17; const SIGALRM = 14; const SIGSEGV = 11; -/** SIGKILL — used only as the host-teardown "exit now" marker handed to the - * guest glue (see killAllBlockedForTeardown). SIGKILL is never delivered to - * the guest in normal operation, so the glue treats it unambiguously. +/** SIGKILL — used as the host-owned "exit now" marker handed to the guest + * glue after Rust has already committed process death (see + * killAllBlockedForTeardown and borrowed-process completion below). It is never + * delivered as a catchable guest signal, so the glue treats it unambiguously. * [JSC-TERMINATE-ATOMICS-WAIT-LEAK] — part of the workaround; see * docs/jsc-terminate-atomics-wait-workaround.md. */ const SIGKILL = 9; @@ -1670,6 +1675,8 @@ interface ProcessRegistration { pid: number; memory: WebAssembly.Memory; channels: ChannelInfo[]; + /** True only while a vfork child borrows its suspended parent's Memory. */ + borrowedAddressSpace: boolean; /** Pointer width: 4 for wasm32, 8 for wasm64. */ ptrWidth: 4 | 8; /** @@ -1817,6 +1824,8 @@ interface RegisterProcessOptions { maxAddr?: number; /** brk ceiling below host-owned control pages. */ brkLimit?: number; + /** The process is a vfork child borrowing another process's Memory. */ + borrowedAddressSpace?: boolean; } type RegisterProcessStdioKind = "pipe" | "terminal"; @@ -1976,11 +1985,21 @@ export type ForkContinuationContext = readonly kind: "thread"; } & Readonly); +export interface ForkBorrowedReplayWorkspace { + /** Aligned bytes needed for every active activation's mutable prefix. */ + readonly prefixBytes: number; + /** Page-rounded reference/exception codec scratch high-water. */ + readonly scratchBytes: number; +} + export interface ForkLaunchRequest { readonly parentPid: number; readonly childPid: number; + readonly mode: ProcessForkMode; readonly parentMemory: WebAssembly.Memory; readonly continuation: ForkContinuationContext; + /** Present only for vfork, measured before the kernel child is allocated. */ + readonly borrowedReplay?: ForkBorrowedReplayWorkspace; } export interface ResolvedSpawnProgram { @@ -4012,14 +4031,18 @@ export class CentralizedKernelWorker { (entry) => { const forkProcess = this.#kernelInstanceForEntry(entry).exports .kernel_fork_process as - | ((parent: number, caller: number) => number) + | ((parent: number, caller: number, mode: number) => number) | undefined; if (forkProcess === undefined) { throw new Error( "kernel missing advisory-lock fork test export", ); } - const result = forkProcess(parentPid, callerTid); + const result = forkProcess( + parentPid, + callerTid, + PROCESS_FORK_MODE_FORK, + ); if ( !Number.isSafeInteger(result) || result <= 0 @@ -4470,6 +4493,7 @@ export class CentralizedKernelWorker { this.handleFork( channelSnapshot.channel, argsSnapshot.args, + PROCESS_FORK_MODE_FORK, entry, ); return undefined; @@ -4737,6 +4761,7 @@ export class CentralizedKernelWorker { pid, memory, channels, + borrowedAddressSpace: false, ptrWidth: pointerWidth, explicitMaxAddr: false, }); @@ -6752,6 +6777,7 @@ export class CentralizedKernelWorker { pid, memory, channels, + borrowedAddressSpace: options?.borrowedAddressSpace === true, ptrWidth, explicitMaxAddr: explicitMaxAddr !== undefined, }; @@ -11002,7 +11028,14 @@ export class CentralizedKernelWorker { if (syscallNr === SYS_FORK || syscallNr === SYS_VFORK) { if (logging) console.error(logEntry); - this.handleFork(channel, origArgs, entry); + this.handleFork( + channel, + origArgs, + syscallNr === SYS_VFORK + ? PROCESS_FORK_MODE_VFORK + : PROCESS_FORK_MODE_FORK, + entry, + ); return; } @@ -21211,9 +21244,11 @@ export class CentralizedKernelWorker { if ( this.#isAsyncChannelProcessActiveWithinKernelEntry(channel, entry) ) { - const errno = cause instanceof ProcessMemoryRetirementBacklogError - ? 11 // EAGAIN: bounded retired-memory debt denied admission. - : 12; // ENOMEM: worker launch or ordinary allocation failure. + const errno = + cause instanceof ProcessMemoryRetirementBacklogError + || cause instanceof VforkAddressSpaceBusyError + ? 11 // EAGAIN: bounded debt or vfork workspace denied admission. + : 12; // ENOMEM: worker launch or ordinary allocation failure. this.#completeForkWithinKernelEntry( channel, origArgs, @@ -21231,6 +21266,7 @@ export class CentralizedKernelWorker { private handleFork( channel: ChannelInfo, _origArgs: number[], + mode: ProcessForkMode, entry: KernelWorkerEntryContext, ): void { if (!this.callbacks.onFork) { @@ -21241,6 +21277,33 @@ export class CentralizedKernelWorker { return; } + let borrowedReplay: ForkBorrowedReplayWorkspace | undefined; + if (mode === PROCESS_FORK_MODE_VFORK) { + const prefixBytes = _origArgs[0]; + const scratchBytes = _origArgs[1]; + if ( + !Number.isSafeInteger(prefixBytes) + || prefixBytes <= 0 + || prefixBytes > FORK_BUF_SIZE + || !Number.isSafeInteger(scratchBytes) + || scratchBytes < 0 + || scratchBytes > WASM_PAGE_SIZE + ) { + // One host control slot is the bounded vfork workspace. Refuse the + // transaction before Rust allocates a child PID or any Worker can see + // shared memory; post-launch exhaustion cannot safely return errno. + this.#completeForkWithinKernelEntry( + channel, + _origArgs, + -1, + EAGAIN, + entry, + ); + return; + } + borrowedReplay = { prefixBytes, scratchBytes }; + } + const parentPid = channel.pid; const callerTid = this.guestTidForChannel(channel); // Publish the parent's private views before creating any kernel child. @@ -21321,8 +21384,8 @@ export class CentralizedKernelWorker { // Fork atomically allocates the child PID and inserts its Process in Rust. // The host receives that identity only after the authoritative state exists. const kernelForkProcess = this.#kernelInstanceForEntry(entry).exports.kernel_fork_process as - (parentPid: number, callerTid: number) => number; - const forkResult = kernelForkProcess(parentPid, callerTid); + (parentPid: number, callerTid: number, mode: ProcessForkMode) => number; + const forkResult = kernelForkProcess(parentPid, callerTid, mode); if (forkResult <= 0) { // Fork failed in kernel (e.g., ESRCH, ENOMEM) const errno = forkResult < 0 ? (-forkResult) >>> 0 : EIO; @@ -21409,8 +21472,10 @@ export class CentralizedKernelWorker { this.callbacks.onFork!({ parentPid, childPid, + mode, parentMemory: channel.memory, continuation, + ...(borrowedReplay ? { borrowedReplay } : {}), }), ); } catch (cause) { @@ -23483,6 +23548,15 @@ export class CentralizedKernelWorker { entry: KernelWorkerEntryContext, ): void { const exitingPid = channel.pid; + const registration = this.processes.get(exitingPid); + const canQuiesceBorrowedWorker = + registration?.borrowedAddressSpace === true + && registration.channels.length === 1 + && registration.channels[0] === channel + && Atomics.load( + new Int32Array(channel.memory.buffer, channel.channelOffset), + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + ) === CH_PENDING; this.discardStoppedChannelStateForProcess(exitingPid); // Idempotency guard — both handleExit and reapKilledProcessesAfterSyscall // can route here for the same pid; do the parent-wakeup work exactly @@ -23506,9 +23580,20 @@ export class CentralizedKernelWorker { this.#drainAndProcessWakeupEventsWithinKernelEntry(entry); this.notifyParentOfExitedProcess(exitingPid, entry); - // Do NOT complete the channel — the worker is blocked on Atomics.wait - // and waking it would cause the C code to continue executing. - // onExit will terminate the worker. + if (canQuiesceBorrowedWorker) { + // WHY: forced Worker termination cannot prove that a vfork child has + // stopped touching its parent's shared Memory. At an exact pending + // syscall boundary, replace the dead process's result with Kandelo's + // private exit marker. Libc enters kernel_exit before any user + // instruction can run, the wrapper publishes memory_quiescent, and the + // original Rust signal status remains authoritative for waitpid(). + this.wakeChannelForTeardownExit(channel, entry); + return; + } + + // A Worker with no pending channel has no cooperative ownership + // fence. Do not wake an ordinary dead process back into C; force its host + // teardown, retaining/containing shared Memory when quiescence is unknown. entry.deferProtocolEffect(() => { this.callbacks.onExit?.( exitingPid, diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts index b855b60e92..35d29a7a80 100644 --- a/host/src/node-kernel-worker-entry.ts +++ b/host/src/node-kernel-worker-entry.ts @@ -25,6 +25,7 @@ import { TERMINAL_STDIO, } from "./kernel-worker"; import type { + ForkBorrowedReplayWorkspace, ForkContinuationContext, ResolvedSpawnProgram, SpawnProgramResolution, @@ -64,7 +65,12 @@ import { isWasmModuleBytes, } from "./constants"; import { CH_TOTAL_SIZE, DEFAULT_MAX_PAGES, PAGES_PER_THREAD, WASM_PAGE_SIZE } from "./constants"; -import { FILE_MODES, OPEN_FLAGS } from "./generated/abi"; +import { + FILE_MODES, + OPEN_FLAGS, + PROCESS_FORK_MODE_VFORK, + type ProcessForkMode, +} from "./generated/abi"; import { classifiedSignalOrFallback, classifiedTrapExitStatus, @@ -99,12 +105,20 @@ import { createProcessMemoryRetirementPressureHook, DEFAULT_PROCESS_THREAD_SLOTS, deriveProcessMemoryRetirementAdmissionThresholds, + FORK_SAVE_BUFFER_SIZE, ProcessMemoryCapacityError, ProcessMemoryAllocator, ProcessMemoryRetirementBacklogError, type ProcessMemoryLayout, type ProcessMemoryLease, } from "./process-memory"; +import { + VforkAddressSpaceBusyError, + VforkLifetimeCoordinator, + type VforkExactCompletionReason, + type VforkLifetime, + type VforkLifetimeDisposition, +} from "./vfork-lifetime"; import { ExactProcessGenerationDetachLedger, type ExactProcessGenerationDetachResult, @@ -200,6 +214,12 @@ interface ProcessGenerationOwnership { memoryLease: ProcessMemoryLease; } +interface VforkWorkspaceOwnership { + readonly allocator: ThreadPageAllocator; + readonly slotStartPage: number; + released: boolean; +} + interface ProcessInfo extends ProcessGenerationOwnership { workerQuiescence: WorkerQuiescence; execRetirement: WorkerQuiescence; @@ -214,8 +234,11 @@ interface ProcessInfo extends ProcessGenerationOwnership { externrefGeneration: ForkExternrefGeneration; /** Non-_start continuation root inherited from a pthread fork until exec. */ forkReplayContext?: ForkReplayContext; + /** Parent-owned control slot borrowed only until exact exec/exit teardown. */ + vforkWorkspace?: VforkWorkspaceOwnership; } const processes = new Map(); +const vforkLifetimes = new VforkLifetimeCoordinator(); const externrefProcessOwner = new ForkExternrefProcessOwner(); const forkHostImportOwnerRuntime = new ForkHostImportOwnerRuntime(externrefProcessOwner); @@ -555,7 +578,7 @@ async function finalizeProcessWorker( // WHY: ordinary exits and crashes must share one teardown funnel. Keeping a // second cleanup sequence here previously let their Worker/channel ordering // drift and made it possible to reap Rust state before all Workers stopped. - await finishProcessExit(pid, exitStatus, worker); + await finishProcessExit(pid, exitStatus, worker, "trap"); } function processWorkerErrorDisposition(reason: string | undefined): { @@ -1022,8 +1045,15 @@ async function handleInit(msg: InitMessage) { processMemoryAllocator.observeTarget(memory, target); }, onKernelFatal: terminatePoisonedKernelWorker, - onFork: ({ parentPid, childPid, parentMemory, continuation }) => { - return processMemoryCreators.run("a fork process Worker", () => { + onFork: ({ + parentPid, + childPid, + mode, + parentMemory, + continuation, + borrowedReplay, + }) => { + const launch = (releaseCreatorAdmission?: () => void) => { // Notify the main thread of every kernel-side process event so // Inspector-style UIs (Kandelo) can refresh their process table // event-driven. Mirrors the browser-side worker entry. @@ -1033,13 +1063,42 @@ async function handleInit(msg: InitMessage) { pid: childPid, ppid: parentPid, }); - return handleFork(parentPid, childPid, parentMemory, continuation); - }); + return handleFork( + parentPid, + childPid, + mode, + parentMemory, + continuation, + borrowedReplay, + releaseCreatorAdmission, + ); + }; + return mode === PROCESS_FORK_MODE_VFORK + ? processMemoryCreators.runUntilCommitted( + "a vfork process Worker", + (commit) => launch(commit), + ) + : processMemoryCreators.run( + "a fork process Worker", + () => launch(), + ); }, onExec: (pid, path, argv, envp, callerTid) => processMemoryCreators.run("an exec process Worker", async () => { - const previousWorker = processes.get(pid)?.worker; + const execGeneration = processes.get(pid); + const previousWorker = execGeneration?.worker; const result = await handleExec(pid, path, argv, envp, callerTid); + if ( + result < 0 + && execGeneration + && processes.get(pid) === execGeneration + && vforkLifetimes.isActiveBorrower(execGeneration) + ) { + // A failed exec returns to the borrowing child. POSIX does not let + // that release the parent; only a later successful exec or _exit + // ends the shared-address-space lifetime. + vforkLifetimes.noteFailedExec(execGeneration, -result); + } // Notify after handleExec refreshes kernel-side Process.argv so // process-table consumers don't refetch stale command names. A // post-commit signal death also returns 0 because the old syscall @@ -1308,6 +1367,503 @@ async function handleSpawn(msg: SpawnMessage) { async function handleFork( parentPid: number, childPid: number, + mode: ProcessForkMode, + parentMemory: WebAssembly.Memory, + continuation: ForkContinuationContext, + borrowedReplay?: ForkBorrowedReplayWorkspace, + releaseCreatorAdmission?: () => void, +): Promise { + if (mode === PROCESS_FORK_MODE_VFORK) { + if (!borrowedReplay) { + throw new VforkAddressSpaceBusyError( + "vfork launch is missing its admitted replay workspace", + ); + } + return handleVfork( + parentPid, + childPid, + parentMemory, + continuation, + borrowedReplay, + releaseCreatorAdmission, + ); + } + if (releaseCreatorAdmission) { + throw new Error("ordinary fork cannot release vfork creator admission"); + } + if (borrowedReplay) { + throw new Error("ordinary fork cannot borrow replay workspace"); + } + return handleOrdinaryFork( + parentPid, + childPid, + mode, + parentMemory, + continuation, + ); +} + +function releaseVforkWorkspace(info: ProcessInfo): void { + const workspace = info.vforkWorkspace; + if (!workspace || workspace.released) return; + workspace.released = true; + workspace.allocator.free(workspace.slotStartPage); +} + +function completeVforkGenerationTeardown( + info: ProcessInfo, + exact: boolean, + reason: VforkExactCompletionReason, + cause?: unknown, +): void { + const phase = vforkLifetimes.phaseForChild(info); + if (phase === undefined) return; + if (!exact) { + vforkLifetimes.requireAddressSpaceContainment( + info, + cause ?? new Error("vfork child teardown lacked an exact quiescence fence"), + ); + return; + } + releaseVforkWorkspace(info); + if (phase === "starting") { + vforkLifetimes.completeWithoutBorrow( + info, + reason === "exit" ? "exit" : "signal", + ); + } else { + vforkLifetimes.completeAfterExactTeardown(info, reason); + } +} + +async function containVforkAddressSpace( + disposition: Extract< + VforkLifetimeDisposition, + { kind: "contain-address-space" } + >, + childGeneration: ProcessInfo, + parentPid: number, +): Promise { + const status = signalExitStatus(SIGSEGV); + reportHostDiagnostic({ + pid: parentPid, + status, + source: "vfork address-space containment", + message: + `[vfork] containing shared address space after ambiguous child ` + + `teardown for pid=${disposition.childPid}: ${ + disposition.cause instanceof Error + ? disposition.cause.message + : String(disposition.cause) + }`, + }); + + const childCurrent = processes.get(disposition.childPid); + if (childCurrent === childGeneration) { + try { + kernelWorker.notifyHostProcessCrashed(disposition.childPid, SIGSEGV); + } catch { + // Continue to the exact-generation teardown funnel. + } + await finishProcessExit( + disposition.childPid, + status, + childGeneration.worker, + "trap", + ); + } + + if (processes.get(parentPid) === disposition.parentGeneration) { + try { + kernelWorker.notifyHostProcessCrashed(parentPid, SIGSEGV); + } catch { + // Continue to forced host containment even if Rust already exited it. + } + await finishProcessExit( + parentPid, + status, + disposition.parentGeneration.worker, + "trap", + ); + } + + if ( + processes.get(disposition.childPid) === childGeneration + || processes.get(parentPid) === disposition.parentGeneration + ) { + const error = new Error( + `could not contain ambiguous vfork address space for parent=${parentPid} ` + + `child=${disposition.childPid}`, + { cause: disposition.cause }, + ); + terminatePoisonedKernelWorker(error); + throw error; + } + + // WHY: rejecting onFork here would ask KernelWorker to roll back childPid, + // which may already name a successful exec replacement. Resolving is safe + // only because the exact parked parent generation is now absent, so the + // kernel completion guard cannot publish into its retired channel. + return []; +} + +async function finishVforkDisposition( + disposition: VforkLifetimeDisposition, + childGeneration: ProcessInfo, + parentPid: number, +): Promise { + if (disposition.kind === "return-error") { + throw new VforkAddressSpaceBusyError( + `vfork launch returned errno ${disposition.errno}`, + ); + } + if (disposition.kind === "contain-address-space") { + return containVforkAddressSpace(disposition, childGeneration, parentPid); + } + // A sibling pthread can exec or exit the parent image while its calling + // thread is parked. In that case the original channel no longer exists and + // the kernel completion guard must observe no current parent generation. + return [childGeneration.channelOffset]; +} + +async function handleVfork( + parentPid: number, + childPid: number, + parentMemory: WebAssembly.Memory, + continuation: ForkContinuationContext, + borrowedReplay: ForkBorrowedReplayWorkspace, + releaseCreatorAdmission: (() => void) | undefined, +): Promise { + const parentInfo = processes.get(parentPid); + const parentProgram = parentInfo?.programBytes; + if (!parentProgram || parentInfo.memory !== parentMemory) { + throw new Error(`Unknown parent generation for pid ${parentPid}`); + } + if (vforkLifetimes.hasActiveAddressSpace(parentMemory)) { + throw new VforkAddressSpaceBusyError(); + } + if ( + borrowedReplay.prefixBytes <= 0 + || borrowedReplay.prefixBytes > FORK_SAVE_BUFFER_SIZE + || borrowedReplay.scratchBytes < 0 + || borrowedReplay.scratchBytes > WASM_PAGE_SIZE + ) { + throw new VforkAddressSpaceBusyError( + "vfork replay workspace exceeds one host control slot", + ); + } + + if (!parentInfo.programModule) { + // Stay synchronous until the alias lease, child generation, and lifetime + // are all installed. A sibling pthread may otherwise replace the parent + // generation in the first yielded turn. + parentInfo.programModule = new WebAssembly.Module(parentProgram); + } + + const childMemoryLease = parentInfo.memoryLease.retainAlias(); + let childMemoryLeaseConsumed = false; + let workspaceAllocation: ReturnType; + try { + workspaceAllocation = + parentInfo.threadAllocator.allocateHostControl(parentMemory); + } catch (error) { + childMemoryLease.release(); + throw new VforkAddressSpaceBusyError( + `vfork control workspace is unavailable: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + const workspaceOwnership: VforkWorkspaceOwnership = { + allocator: parentInfo.threadAllocator, + slotStartPage: workspaceAllocation.slotStartPage, + released: false, + }; + const childChannelOffset = workspaceAllocation.channelOffset; + const childLayout = parentInfo.layout; + const ptrWidth = parentInfo.ptrWidth; + let childWorker: DeferredWorkerHandle | undefined; + let childGeneration: ProcessInfo | undefined; + let childExternrefGeneration: ForkExternrefGeneration | undefined; + let childForkHostImports: ForkHostImportOwnerWorker | undefined; + let registered = false; + let lifetimeStarted = false; + let lifetime: VforkLifetime | undefined; + const forkReplay = new ForkReplayGateCoordinator( + `vfork child pid=${childPid}`, + ); + + try { + const workspaceAddress = + workspaceAllocation.slotStartPage * WASM_PAGE_SIZE; + kernelWorker.reserveHostRegionAt( + childPid, + workspaceAddress, + PAGES_PER_THREAD * WASM_PAGE_SIZE, + ); + kernelWorker.registerProcess(childPid, parentMemory, [childChannelOffset], { + ptrWidth, + maxAddr: childLayout.maxAddr, + mmapBase: childLayout.mmapBase, + borrowedAddressSpace: true, + }); + registered = true; + kernelWorker.inheritProcessSharedMappings(parentPid, childPid); + + const forkBufAddr = continuation.forkBufAddr; + const forkReplayContext: ForkReplayContext | undefined = + continuation.kind === "thread" + ? { + fnPtr: continuation.fnPtr, + argPtr: continuation.argPtr, + forkBufAddr, + } + : parentInfo.forkReplayContext + ? { ...parentInfo.forkReplayContext, forkBufAddr } + : undefined; + const externrefGrant = + externrefProcessOwner.forkGenerationFromContinuation( + parentInfo.externrefGeneration, + childPid, + parentMemory, + ptrWidth, + forkBufAddr, + ); + childExternrefGeneration = externrefGrant.generation; + let launchedWorker: DeferredWorkerHandle; + const forkHostImports = forkHostImportOwnerRuntime.createWorker({ + pid: childPid, + generationId: externrefGrant.generation.id, + authorizeSender: () => { + const current = processes.get(childPid); + if ( + !current + || current.worker !== launchedWorker + || current.externrefGeneration !== externrefGrant.generation + ) { + throw new Error( + `stale fork host-import sender for vfork child pid=${childPid}`, + ); + } + }, + }); + childForkHostImports = forkHostImports; + const childInitData: CentralizedWorkerInitMessage = { + type: "centralized_init", + pid: childPid, + programBytes: parentProgram, + programModule: parentInfo.programModule, + memory: parentMemory, + channelOffset: childChannelOffset, + externrefGenerationId: externrefGrant.generation.id, + forkHostImports: forkHostImports.init, + isForkChild: true, + forkMode: PROCESS_FORK_MODE_VFORK, + forkMemoryOwnership: "borrowed", + forkBufAddr, + forkOwnerControlAddr: + parentInfo.channelOffset - FORK_SAVE_BUFFER_SIZE, + forkPrivatePrefixAddr: + childChannelOffset - FORK_SAVE_BUFFER_SIZE, + forkPrivatePrefixBytes: borrowedReplay.prefixBytes, + forkScratchAddr: workspaceAllocation.tlsOffset, + forkScratchBytes: borrowedReplay.scratchBytes, + forkReplayGate: forkReplay.gate, + forkChildThreadFnPtr: forkReplayContext?.fnPtr, + forkChildThreadArgPtr: forkReplayContext?.argPtr, + ptrWidth, + kernelAbiVersion: kernelWorker.getKernelAbiVersion(), + }; + + childWorker = new DeferredWorkerHandle( + () => workerAdapter.createWorker(childInitData), + ); + launchedWorker = childWorker; + bindForkHostImports(childWorker, forkHostImports); + childGeneration = { + memory: parentMemory, + memoryLease: childMemoryLease, + workerQuiescence: createWorkerQuiescence(), + execRetirement: createWorkerQuiescence(), + programBytes: parentProgram, + programModule: parentInfo.programModule, + worker: childWorker, + channelOffset: childChannelOffset, + ptrWidth, + layout: childLayout, + threadAllocator: threadAllocatorForLayout(childLayout, ptrWidth, childPid), + forkReplayContext, + externrefGeneration: externrefGrant.generation, + vforkWorkspace: workspaceOwnership, + }; + lifetime = vforkLifetimes.begin( + parentPid, + childPid, + parentInfo, + childGeneration, + ); + lifetimeStarted = true; + processes.set(childPid, childGeneration); + // The exact generation is now sweepable by terminal host destroy. Keep the + // onFork promise pending to park only the calling guest thread. + releaseCreatorAdmission?.(); + + observeForkReplayWorker( + forkReplay, + launchedWorker, + childPid, + () => processes.get(childPid)?.worker === launchedWorker, + ); + installProcessWorkerListeners(childWorker, childPid); + let startFailure: unknown; + const startDisposition = kernelWorker.startProcessWorkerWhenRunnable( + childPid, + parentMemory, + () => { + vforkLifetimes.markChildMayAccessMemory(childGeneration!); + try { + launchedWorker.start(); + } catch (error) { + // Worker construction can partially publish a realm before throwing. + // Once marked borrowing, only whole-address-space containment may + // release the parent's parked syscall. + startFailure = error; + forkReplay.cancel(error); + vforkLifetimes.requireAddressSpaceContainment( + childGeneration!, + error, + ); + } + }, + () => { + forkReplay.cancel( + new Error(`Vfork child ${childPid} launch was cancelled`), + ); + forkHostImports.close(); + void launchedWorker.terminate(); + }, + ); + if (startDisposition === "stale") { + throw new VforkAddressSpaceBusyError( + `Vfork child ${childPid} changed generation before Worker launch`, + ); + } + if (startDisposition === "dead") { + forkReplay.cancel( + new Error(`Vfork child ${childPid} exited before Worker launch`), + ); + forkHostImports.close(); + await terminateTrackedWorker(childWorker); + childGeneration.workerQuiescence.settle(); + const signal = kernelWorker.finalizePendingChildTermination(childPid); + await awaitFinalizedProcessTeardown( + childPid, + signal > 0 ? signalExitStatus(signal) : 0, + childWorker, + signal > 0 ? "signal" : "exit", + ); + return finishVforkDisposition( + await lifetime.completion, + childGeneration, + parentPid, + ); + } + + try { + await forkReplay.waitUntilReady(); + } catch (error) { + const phase = vforkLifetimes.phaseForChild(childGeneration); + if (phase === "starting") { + childGeneration.workerQuiescence.settle(); + const signal = kernelWorker.finalizePendingChildTermination(childPid); + await awaitFinalizedProcessTeardown( + childPid, + signal > 0 ? signalExitStatus(signal) : 0, + childWorker, + signal > 0 ? "signal" : "exit", + ); + } else if (phase === "borrowing" && startFailure === undefined) { + await finalizeProcessWorker( + childPid, + childWorker, + signalExitStatus(SIGSEGV), + SIGSEGV, + ); + } + return finishVforkDisposition( + await lifetime.completion, + childGeneration, + parentPid, + ); + } + if (processes.get(childPid) !== childGeneration) { + throw new Error( + `Vfork child ${childPid} changed generation before replay commit`, + ); + } + if (!kernelWorker.shouldLaunchPendingChild(childPid)) { + throw new Error(`Vfork child ${childPid} exited before replay commit`); + } + forkReplay.commit(); + return finishVforkDisposition( + await lifetime.completion, + childGeneration, + parentPid, + ); + } catch (error) { + if (childGeneration && lifetimeStarted) { + const phase = vforkLifetimes.phaseForChild(childGeneration); + if (phase === "borrowing") { + vforkLifetimes.requireAddressSpaceContainment(childGeneration, error); + return finishVforkDisposition( + await lifetime!.completion, + childGeneration, + parentPid, + ); + } + } + + forkReplay.cancel(error); + childForkHostImports?.close(); + if (childWorker) await terminateTrackedWorker(childWorker); + if (childExternrefGeneration) { + externrefProcessOwner.releaseGeneration(childExternrefGeneration); + } + if (childGeneration && registered) { + const detachResult = await detachExactProcessGeneration({ + pid: childPid, + generation: childGeneration, + operation: "deactivate", + retire: (commit) => { + childMemoryLease.release(); + childMemoryLeaseConsumed = true; + commit(); + }, + }); + if (detachResult.status !== "released") { + reportRetainedProcessGeneration( + childPid, + "vfork launch rollback", + detachResult, + ); + } + } + if (!childMemoryLeaseConsumed) childMemoryLease.release(); + if (!workspaceOwnership.released) { + workspaceOwnership.released = true; + workspaceOwnership.allocator.free(workspaceOwnership.slotStartPage); + } + if (childGeneration && lifetimeStarted) { + vforkLifetimes.abortBeforeChildStart(childGeneration, 11); + } + throw error; + } +} + +async function handleOrdinaryFork( + parentPid: number, + childPid: number, + mode: ProcessForkMode, parentMemory: WebAssembly.Memory, continuation: ForkContinuationContext, ): Promise { @@ -1411,6 +1967,7 @@ async function handleFork( externrefGenerationId: externrefGrant.generation.id, forkHostImports: forkHostImports.init, isForkChild: true, + forkMode: mode, forkBufAddr, forkReplayGate: forkReplay.gate, forkChildThreadFnPtr: forkReplayContext?.fnPtr, @@ -1545,6 +2102,7 @@ async function handleExec( ): Promise { const initiatingInfo = processes.get(pid); if (!initiatingInfo) return -3; // ESRCH + const vforkBorrower = vforkLifetimes.isActiveBorrower(initiatingInfo); const resolved = await resolveExecutableForLaunch(path, argv); if (!resolved) return -2; // ENOENT if ("errno" in resolved) return -resolved.errno; @@ -1673,6 +2231,7 @@ async function handleExec( pid, signalExitStatus(handoffExitSignal), initiatingInfo.worker, + "signal", ); return 0; } @@ -1781,14 +2340,35 @@ async function handleExec( await terminateTrackedWorker(replacementWorker); kernelWorker.finishProcessExecHandoff(pid); const signal = kernelWorker.finalizeExecHandoffTermination(pid); + if (vforkBorrower) { + completeVforkGenerationTeardown( + initiatingInfo, + oldMemoryRetirementSafe, + "exec", + new Error( + `vfork child ${pid} exec retired without an exact old-memory fence`, + ), + ); + } await awaitFinalizedProcessTeardown( pid, signal > 0 ? signalExitStatus(signal) : 0, replacementWorker, + signal > 0 ? "signal" : "exit", ); return 0; } kernelWorker.finishProcessExecHandoff(pid); + if (vforkBorrower) { + completeVforkGenerationTeardown( + initiatingInfo, + oldMemoryRetirementSafe, + "exec", + new Error( + `vfork child ${pid} exec retired without an exact old-memory fence`, + ), + ); + } return 0; } catch (err) { replacementForkHostImports?.close(); @@ -1846,6 +2426,18 @@ async function handleExec( else initiatingInfo.memoryLease.releaseAfterForcedTermination(); initiatingLeaseConsumed = true; } + if ( + vforkBorrower + && preparedTransferred + && vforkLifetimes.phaseForChild(initiatingInfo) !== undefined + ) { + completeVforkGenerationTeardown( + initiatingInfo, + oldMemoryRetirementSafe && initiatingLeaseConsumed, + "trap", + err, + ); + } const message = err instanceof Error ? err.message : String(err); try { @@ -2314,16 +2906,25 @@ function handleThreadExit(pid: number, channelOffset: number): boolean { } function handleExit(pid: number, exitStatus: number): void { - void finishProcessExit(pid, exitStatus, processes.get(pid)?.worker); + const reason: VforkExactCompletionReason = + signalFromExitStatus(exitStatus) === null ? "exit" : "signal"; + void finishProcessExit( + pid, + exitStatus, + processes.get(pid)?.worker, + reason, + ); } async function awaitFinalizedProcessTeardown( pid: number, exitStatus: number, expectedWorker: ProcessInfo["worker"], + reason: VforkExactCompletionReason = + signalFromExitStatus(exitStatus) === null ? "exit" : "signal", ): Promise { if (!processTeardowns.has(expectedWorker)) { - void finishProcessExit(pid, exitStatus, expectedWorker); + void finishProcessExit(pid, exitStatus, expectedWorker, reason); } await processTeardowns.get(expectedWorker); } @@ -2332,6 +2933,8 @@ async function finishProcessExit( pid: number, exitStatus: number, expectedWorker = processes.get(pid)?.worker, + vforkReason: VforkExactCompletionReason = + signalFromExitStatus(exitStatus) === null ? "exit" : "signal", ): Promise { if (!expectedWorker) return; const info = processes.get(pid); @@ -2357,6 +2960,7 @@ async function finishProcessExit( ), terminateThreadWorkers(pid), ]); + const exactMemoryTeardown = workerQuiescent && threadsQuiescent; await terminateTrackedWorker(expectedWorker); // Deactivate process (zombie until reaped or destroy) after worker @@ -2366,7 +2970,7 @@ async function finishProcessExit( generation: info, operation: "deactivate", retire: (commit) => { - if (workerQuiescent && threadsQuiescent) { + if (exactMemoryTeardown) { info.memoryLease.release(); } else { info.memoryLease.releaseAfterForcedTermination(); @@ -2375,6 +2979,12 @@ async function finishProcessExit( }, }); if (detachResult.status !== "released") { + completeVforkGenerationTeardown( + info, + false, + vforkReason, + detachResult.error, + ); reportRetainedProcessGeneration( pid, "process channel teardown", @@ -2385,6 +2995,14 @@ async function finishProcessExit( } externrefProcessOwner.releaseGeneration(info.externrefGeneration); + completeVforkGenerationTeardown( + info, + exactMemoryTeardown, + vforkReason, + new Error( + `vfork child ${pid} exited without an exact Worker quiescence fence`, + ), + ); // A superseded old image must not reap the persistent PID that now belongs // to its exec successor. diff --git a/host/src/process-memory-creator-gate.ts b/host/src/process-memory-creator-gate.ts index 2b1414b6b1..fe47691e4c 100644 --- a/host/src/process-memory-creator-gate.ts +++ b/host/src/process-memory-creator-gate.ts @@ -16,6 +16,27 @@ export class ProcessMemoryCreatorGate { * Run one admitted creator and release its admission on every terminal path. */ run(operation: string, creator: () => T | PromiseLike): Promise { + return this.runUntilCommitted(operation, () => creator()); + } + + /** + * Admit a creator whose semantic completion can outlive its installation. + * + * `commit()` releases destroy admission once the exact generation and all + * of its ownership handles are published in the host process registry. The + * returned operation may remain pending afterward. This is required for + * vfork: its onFork promise parks the caller until child exec/_exit, but a + * terminal host destroy must be able to sweep that already-visible child + * instead of waiting for the parked syscall first. + * + * If the creator fails or completes before commit, its terminal path releases + * admission. Calling commit more than once is harmless so a common finally + * path cannot double-release the gate. + */ + runUntilCommitted( + operation: string, + creator: (commit: () => void) => T | PromiseLike, + ): Promise { if (!this.open) { return Promise.reject( new Error( @@ -24,15 +45,21 @@ export class ProcessMemoryCreatorGate { ); } this.activeCreators += 1; + let released = false; + const commit = () => { + if (released) return; + released = true; + this.releaseCreator(); + }; let result: T | PromiseLike; try { - result = creator(); + result = creator(commit); } catch (error) { - this.releaseCreator(); + commit(); return Promise.reject(error); } return Promise.resolve(result).finally(() => { - this.releaseCreator(); + commit(); }); } diff --git a/host/src/thread-allocator.ts b/host/src/thread-allocator.ts index d59dc3ff76..c70780d634 100644 --- a/host/src/thread-allocator.ts +++ b/host/src/thread-allocator.ts @@ -58,6 +58,7 @@ export class ThreadPageAllocator { private readonly reservedSlots: number; private readonly reserveSlotStartPage?: () => number; private activeCount = 0; + private readonly hostControlPages = new Set(); constructor(options: ThreadPageAllocatorOptions); constructor(maxPages: number); @@ -93,7 +94,26 @@ export class ThreadPageAllocator { /** Allocate pages for a new thread. Zeros the channel and TLS regions. */ allocate(memory: WebAssembly.Memory): ThreadAllocation { - if (this.activeCount >= this.reservedSlots) { + return this.allocateSlot(memory, false); + } + + /** + * Allocate a host-owned control slot outside the guest pthread quota. + * + * WHY: a single-threaded executable can truthfully declare zero pthread + * slots and still call vfork. Its borrowing child needs an independent + * syscall channel, replay prefix, and scratch page, but that platform state + * must neither require nor consume capacity promised to pthread_create. + */ + allocateHostControl(memory: WebAssembly.Memory): ThreadAllocation { + return this.allocateSlot(memory, true); + } + + private allocateSlot( + memory: WebAssembly.Memory, + hostControl: boolean, + ): ThreadAllocation { + if (!hostControl && this.activeCount >= this.reservedSlots) { throw new Error( `process pthread slot limit exhausted (limit=${this.reservedSlots}, ` + `active=${this.activeCount}). Rebuild with --kandelo-thread-slots=N ` + @@ -144,7 +164,8 @@ export class ThreadPageAllocator { new Uint8Array(memory.buffer, forkSaveOffset, WASM_PAGE_SIZE).fill(0); new Uint8Array(memory.buffer, forkSaveOffset, FORK_SAVE_BUFFER_SIZE).fill(0); - this.activeCount++; + if (hostControl) this.hostControlPages.add(slotStartPage); + else this.activeCount++; return { slotStartPage, basePage: slotStartPage, @@ -158,6 +179,8 @@ export class ThreadPageAllocator { /** Return pages to the free list after thread exit. */ free(slotStartPage: number): void { this.freePages.push(slotStartPage); - this.activeCount = Math.max(0, this.activeCount - 1); + if (!this.hostControlPages.delete(slotStartPage)) { + this.activeCount = Math.max(0, this.activeCount - 1); + } } } diff --git a/host/src/vfork-lifetime.ts b/host/src/vfork-lifetime.ts index 7537e72606..7bbb0d9e94 100644 --- a/host/src/vfork-lifetime.ts +++ b/host/src/vfork-lifetime.ts @@ -67,8 +67,10 @@ interface MutableVforkLifetime< export class VforkAddressSpaceBusyError extends Error { readonly errno = EAGAIN; - constructor() { - super("address space already has an active vfork lifetime"); + constructor( + message = "address space already has an active vfork lifetime", + ) { + super(message); this.name = "VforkAddressSpaceBusyError"; } } @@ -114,6 +116,12 @@ export class VforkLifetimeCoordinator< return lifetime?.phase === "borrowing"; } + phaseForChild( + generation: TGeneration, + ): VforkLifetimePhase | undefined { + return this.byChild.get(generation)?.phase; + } + begin( parentPid: number, childPid: number, diff --git a/host/src/vfork-workspace.ts b/host/src/vfork-workspace.ts new file mode 100644 index 0000000000..d81b512835 --- /dev/null +++ b/host/src/vfork-workspace.ts @@ -0,0 +1,175 @@ +import type { + ForkBorrowedReplayPrefixRequest, +} from "./fork-process-continuation"; +import type { WasmGuestPointer } from "./wasm-guest-pointer"; + +export interface BorrowedVforkWorkspaceLayout { + readonly prefixAddress: number; + readonly prefixBytes: number; + readonly scratchAddress: number; + readonly scratchBytes: number; +} + +interface ScratchReservation { + readonly address: number; + readonly size: number; + readonly previousCursor: number; +} + +function checkedEnd(address: number, bytes: number, label: string): number { + if ( + !Number.isSafeInteger(address) + || address <= 0 + || !Number.isSafeInteger(bytes) + || bytes < 0 + || address > Number.MAX_SAFE_INTEGER - bytes + ) { + throw new RangeError(`${label} is not an exact guest-memory range`); + } + return address + bytes; +} + +function alignUp(value: number, alignment: number, label: string): number { + if ( + !Number.isSafeInteger(alignment) + || alignment <= 0 + || (alignment & (alignment - 1)) !== 0 + ) { + throw new RangeError(`${label} has invalid alignment ${alignment}`); + } + const aligned = Math.ceil(value / alignment) * alignment; + if (!Number.isSafeInteger(aligned) || aligned < value) { + throw new RangeError(`${label} alignment overflows guest memory`); + } + return aligned; +} + +/** + * Child-private mutable ranges inside a shared vfork address space. + * + * WHY: generated rewind mutates each activation prefix, and reference codecs + * use nested scratch reservations. Neither may touch the suspended parent's + * prefix or syscall channel. The host reserves these exact ranges before the + * child Worker starts; this class never grows Memory or allocates a mapping. + */ +export class BorrowedVforkWorkspace { + private readonly prefixEnd: number; + private readonly scratchEnd: number; + private prefixCursor: number; + private scratchCursor: number; + private readonly scratchReservations: ScratchReservation[] = []; + + constructor( + private readonly memory: WebAssembly.Memory, + private readonly ptrWidth: 4 | 8, + private readonly layout: BorrowedVforkWorkspaceLayout, + private readonly label = "borrowed vfork workspace", + ) { + this.prefixEnd = checkedEnd( + layout.prefixAddress, + layout.prefixBytes, + `${label} prefix`, + ); + this.scratchEnd = checkedEnd( + layout.scratchAddress, + layout.scratchBytes, + `${label} scratch`, + ); + if ( + this.prefixEnd > memory.buffer.byteLength + || this.scratchEnd > memory.buffer.byteLength + ) { + throw new RangeError(`${label} exceeds shared WebAssembly.Memory`); + } + if ( + layout.prefixBytes > 0 + && layout.scratchBytes > 0 + && layout.prefixAddress < this.scratchEnd + && layout.scratchAddress < this.prefixEnd + ) { + throw new RangeError(`${label} prefix and scratch ranges overlap`); + } + this.prefixCursor = layout.prefixAddress; + this.scratchCursor = layout.scratchAddress; + } + + readonly reservePrefix = ( + request: ForkBorrowedReplayPrefixRequest, + ): WasmGuestPointer => { + const address = alignUp( + this.prefixCursor, + request.alignment, + `${this.label} activation ${request.activationId} prefix`, + ); + const end = checkedEnd( + address, + request.byteLength, + `${this.label} activation ${request.activationId} prefix`, + ); + if (end > this.prefixEnd) { + throw new RangeError( + `${this.label} activation ${request.activationId} prefix exceeds ` + + `${this.layout.prefixBytes} admitted bytes`, + ); + } + this.prefixCursor = end; + return this.ptrWidth === 8 ? BigInt(address) : address; + }; + + readonly allocateScratch = (size: number): number => { + if (!Number.isSafeInteger(size) || size <= 0) { + throw new RangeError(`${this.label} scratch size is invalid`); + } + const previousCursor = this.scratchCursor; + const address = alignUp( + previousCursor, + 16, + `${this.label} scratch allocation`, + ); + const end = checkedEnd(address, size, `${this.label} scratch allocation`); + if (end > this.scratchEnd) { + throw new RangeError( + `${this.label} scratch allocation exceeds ` + + `${this.layout.scratchBytes} admitted bytes`, + ); + } + new Uint8Array(this.memory.buffer, address, size).fill(0); + this.scratchReservations.push({ address, size, previousCursor }); + this.scratchCursor = end; + return address; + }; + + readonly deallocateScratch = (address: number, size: number): void => { + const reservation = this.scratchReservations.pop(); + if ( + !reservation + || reservation.address !== address + || reservation.size !== size + ) { + if (reservation) this.scratchReservations.push(reservation); + throw new Error(`${this.label} scratch release is not LIFO-exact`); + } + new Uint8Array(this.memory.buffer, address, size).fill(0); + this.scratchCursor = reservation.previousCursor; + }; + + /** Prove that capture's exact prefix measure and scratch lifetime matched. */ + assertAttachComplete(): void { + const prefixBytes = this.prefixCursor - this.layout.prefixAddress; + if (prefixBytes !== this.layout.prefixBytes) { + throw new Error( + `${this.label} consumed ${prefixBytes} prefix bytes; ` + + `admission declared ${this.layout.prefixBytes}`, + ); + } + if (this.scratchReservations.length !== 0) { + throw new Error( + `${this.label} retained ${this.scratchReservations.length} ` + + "scratch reservation(s) after attach", + ); + } + if (this.scratchCursor !== this.layout.scratchAddress) { + throw new Error(`${this.label} scratch cursor did not return to its base`); + } + } +} diff --git a/host/src/worker-main.ts b/host/src/worker-main.ts index a9eb4300b9..d9c9105aab 100644 --- a/host/src/worker-main.ts +++ b/host/src/worker-main.ts @@ -11,6 +11,7 @@ import { type CentralizedThreadInitMessage, type WorkerToHostMessage, } from "./worker-protocol"; +import { BorrowedVforkWorkspace } from "./vfork-workspace"; import { createCppExceptionTag, createLongjmpTag, @@ -54,6 +55,8 @@ import { CH_TOTAL_SIZE, HOST_INTERCEPTED_SYSCALLS, POSIX_ARG_MAX_BYTES, + PROCESS_FORK_MODE_FORK, + PROCESS_FORK_MODE_VFORK, PROCESS_METADATA_ENTRY_MAX_BYTES, PROCESS_STARTUP_MAX_ARGV_COUNT, PROCESS_STARTUP_MAX_ENVP_COUNT, @@ -64,6 +67,7 @@ import { WPK_FORK_REQUIRED_EXPORTS, WPK_FORK_REQUIRED_IMPORTS, WPK_FORK_CAP_ACTIVATION_STATE_SAFE, + type ProcessForkMode, } from "./generated/abi"; import { FORK_SAVE_BUFFER_SIZE, @@ -118,7 +122,10 @@ import { readForkGcCodecDescriptor, type ForkGcCodecProvider, } from "./fork-gc-codec"; -import { ForkProcessContinuationCoordinator } from "./fork-process-continuation"; +import { + ForkProcessContinuationCoordinator, + type ForkBorrowedReplayWorkspaceRequirements, +} from "./fork-process-continuation"; import { forkResumeTargetsFromInstance } from "./fork-resume-catalog"; import { ForkExternrefTokenCache, @@ -164,6 +171,16 @@ const SIGKILL = 9; class ExecRetirement extends Error {} +/** @internal Exported so cross-engine exit-trap recognition is tested. */ +export function isWasmUnreachableTrap(error: unknown): boolean { + // WHY: WebKit describes the same Wasm `unreachable` trap as + // "Unreachable code should not be executed" while V8 uses lowercase + // "unreachable". The RuntimeError guard keeps an ordinary JavaScript Error + // containing that word from masquerading as a committed guest exit. + return error instanceof WebAssembly.RuntimeError + && /\bunreachable\b/i.test(error.message); +} + /** @internal Exported so ABI-generated retirement-marker decoding is tested. */ export function isExecRetirementMarker( view: DataView, @@ -289,14 +306,27 @@ function continuationMunmap( */ type KernelImports = Record & { kernel_exit: (status: number) => void; - kernel_fork: (...args: unknown[]) => number; + kernel_fork: (mode: number) => number; }; const STARTUP_E2BIG = 7; +const STARTUP_EAGAIN = 11; const STARTUP_EFAULT = 14; const STARTUP_EINVAL = 22; const STARTUP_ERANGE = 34; +function processForkMode(value: number): ProcessForkMode | null { + if (value === PROCESS_FORK_MODE_FORK) return PROCESS_FORK_MODE_FORK; + if (value === PROCESS_FORK_MODE_VFORK) return PROCESS_FORK_MODE_VFORK; + return null; +} + +function processForkSyscall(mode: ProcessForkMode): number { + return mode === PROCESS_FORK_MODE_VFORK + ? HOST_INTERCEPTED_SYSCALLS.SYS_VFORK + : HOST_INTERCEPTED_SYSCALLS.SYS_FORK; +} + interface EncodedStartupMetadata { argv: readonly Uint8Array[]; env: readonly Uint8Array[]; @@ -524,13 +554,15 @@ function buildKernelImports( return result; }, - // Fork dispatches through channel (SYS_FORK) - kernel_fork: (): number => { + // Fork dispatches through the mode's dedicated channel syscall. + kernel_fork: (rawMode: number): number => { + const mode = processForkMode(rawMode); + if (mode === null) return -STARTUP_EINVAL; const view = new DataView(memory.buffer); const base = channelOffset; view.setInt32( base + CH_SYSCALL, - HOST_INTERCEPTED_SYSCALLS.SYS_FORK, + processForkSyscall(mode), true, ); for (let i = 0; i < 6; i++) @@ -1046,6 +1078,7 @@ export function buildDlopenImports( ) => void, hostImportRuntime?: ForkHostImportWorkerRuntime, workerIdentity = 1, + memoryOwnership: "copied" | "borrowed" = "copied", ): DlopenSupport { if ( !Number.isInteger(workerIdentity) || @@ -1063,6 +1096,13 @@ export function buildDlopenImports( const n = (v: number | bigint): number => typeof v === "bigint" ? Number(v) : v; const resolvedLibraryPaths = new Map(); + const requireOwnedMemory = (operation: string): void => { + if (memoryOwnership === "borrowed") { + throw new Error( + `borrowed vfork child cannot ${operation} before exec or _exit`, + ); + } + }; const headOffset = ptrWidth === 8 ? DLOPEN_HEAD_OFFSET_WASM64 : DLOPEN_HEAD_OFFSET_WASM32; @@ -1100,6 +1140,7 @@ export function buildDlopenImports( return Number(value); }; const writeGenerationFence = (generation: number): void => { + requireOwnedMemory("publish a dynamic-loader generation"); if (!Number.isSafeInteger(generation) || generation <= 0) { throw new RangeError( `invalid dlopen process generation ${String(generation)}`, @@ -1127,6 +1168,7 @@ export function buildDlopenImports( ? Number(Atomics.load(new BigUint64Array(memory.buffer, headSlot, 1), 0)) : Atomics.load(new Uint32Array(memory.buffer, headSlot, 1), 0); const writeArchiveHead = (value: number): void => { + requireOwnedMemory("replace the dynamic-loader archive"); if (ptrWidth === 8) { Atomics.store( new BigUint64Array(memory.buffer, headSlot, 1), @@ -1235,6 +1277,7 @@ export function buildDlopenImports( } }; const acquireArchiveWriter = (): void => { + requireOwnedMemory("acquire the dynamic-loader archive writer"); if (mainArchiveReaderDepth > 0) { throw new Error( "cannot acquire the process archive writer while owning a reader", @@ -1268,6 +1311,7 @@ export function buildDlopenImports( finishFreshWriterAcquisition(); }; const acquireArchiveReader = (): void => { + requireOwnedMemory("acquire the dynamic-loader archive reader"); if (mainDlopenDepth > 0) { throw new Error( "cannot acquire a process archive reader while owning its writer", @@ -1371,6 +1415,7 @@ export function buildDlopenImports( // The kernel mmap allocator. Shared with the linker, but also used // directly by persistArchiveEntry to obtain blocks for the archive. const allocateMemory = (size: number, align: number): number => { + requireOwnedMemory("allocate dynamic-loader memory"); const requested = size + Math.max(align, 1) - 1; const view = new DataView(memory.buffer); const base = channelOffset; @@ -1420,6 +1465,7 @@ export function buildDlopenImports( size: number, allowCopiedArchiveAllocation = false, ): void => { + requireOwnedMemory("release dynamic-loader memory"); const allocation = linkerAllocations.get(addr); if (!allocation && !allowCopiedArchiveAllocation) { throw new Error( @@ -1836,6 +1882,7 @@ export function buildDlopenImports( }; const resetForkChildLock = (): void => { + requireOwnedMemory("reset the parent's dynamic-loader lock"); Atomics.store(archiveLock, 0, 0); Atomics.notify(archiveLock, 0); const copiedOwner = Atomics.load(loaderOwner, 0); @@ -2191,6 +2238,19 @@ export function buildDlopenImports( }, }; + if (memoryOwnership === "borrowed") { + // POSIX permits a vfork child to call only exec-family functions or + // _exit(). Keep every dynamic-loader entry point fail-closed so undefined + // guest behavior cannot mutate the suspended parent's archive or memory. + for (const name of Object.keys(imports)) { + imports[name] = () => { + throw new Error( + `borrowed vfork child cannot call ${name} before exec or _exit`, + ); + }; + } + } + return { imports, readForkState, @@ -3204,8 +3264,84 @@ export async function centralizedWorkerMain( ); // Fork state — captured by kernel_fork closure let forkResult = 0; + let forkMode: ProcessForkMode = initData.isForkChild + ? (processForkMode(initData.forkMode ?? -1) ?? (() => { + throw new Error(`pid=${pid}: fork child is missing a valid fork mode`); + })()) + : PROCESS_FORK_MODE_FORK; let forkBufAddr = initData.forkBufAddr ?? 0; - const dlopenArchiveControlAddr = channelOffset - FORK_BUF_SIZE; + const forkMemoryOwnership = initData.isForkChild + ? (initData.forkMemoryOwnership ?? "copied") + : "copied"; + const borrowedForkChild = forkMemoryOwnership === "borrowed"; + if (borrowedForkChild && forkMode !== PROCESS_FORK_MODE_VFORK) { + throw new Error(`pid=${pid}: only a vfork child may borrow process memory`); + } + if ( + borrowedForkChild + && !wasmModuleImports(module).some( + (entry) => + entry.module === "env" + && entry.name === "__channel_base" + && entry.kind === "global", + ) + ) { + throw new Error( + `pid=${pid}: borrowed vfork requires imported env.__channel_base`, + ); + } + const requiredBorrowedNumber = ( + value: number | undefined, + name: string, + allowZero = false, + ): number => { + if ( + !Number.isSafeInteger(value) + || value === undefined + || (allowZero ? value < 0 : value <= 0) + ) { + throw new Error(`pid=${pid}: borrowed vfork has invalid ${name}`); + } + return value; + }; + const dlopenArchiveControlAddr = borrowedForkChild + ? requiredBorrowedNumber( + initData.forkOwnerControlAddr, + "owner control address", + ) + : channelOffset - FORK_BUF_SIZE; + const borrowedWorkspace = borrowedForkChild + ? new BorrowedVforkWorkspace( + memory, + ptrWidth, + { + prefixAddress: requiredBorrowedNumber( + initData.forkPrivatePrefixAddr, + "private prefix address", + ), + prefixBytes: requiredBorrowedNumber( + initData.forkPrivatePrefixBytes, + "private prefix bytes", + ), + scratchAddress: requiredBorrowedNumber( + initData.forkScratchAddr, + "scratch address", + ), + scratchBytes: requiredBorrowedNumber( + initData.forkScratchBytes, + "scratch bytes", + true, + ), + }, + `pid=${pid}: borrowed vfork workspace`, + ) + : null; + if (borrowedForkChild) { + // A vfork child may not create another pthread owner before exec. Keep + // the request off its channel entirely; Rust's Process marker remains a + // second defense for malformed or direct host traffic. + kernelImports.kernel_clone = () => -STARTUP_EAGAIN; + } if (hasForkInstrumentation) { const linkedFrameFormat = readLinkedFrameFormat(module); @@ -3231,21 +3367,33 @@ export async function centralizedWorkerMain( new ForkModuleStateArena( memory, ptrWidth, - (size) => - continuationMmap( + (size) => { + if (borrowedForkChild) { + throw new Error( + `pid=${pid}: borrowed child cannot allocate module state`, + ); + } + return continuationMmap( memory, channelOffset, size, `pid=${pid}: module state`, - ), - (addr, size) => + ); + }, + (addr, size) => { + if (borrowedForkChild) { + throw new Error( + `pid=${pid}: borrowed child cannot release parent module state`, + ); + } continuationMunmap( memory, channelOffset, addr, size, `pid=${pid}: module state`, - ), + ); + }, `pid=${pid}`, ); @@ -3284,21 +3432,27 @@ export async function centralizedWorkerMain( memory, externrefRecipes, `pid=${pid}: fork activations`, - (size) => - continuationMmap( - memory, - channelOffset, - size, - `pid=${pid}: reference scratch`, - ), - (addr, size) => + (size) => borrowedWorkspace + ? borrowedWorkspace.allocateScratch(size) + : continuationMmap( + memory, + channelOffset, + size, + `pid=${pid}: reference scratch`, + ), + (addr, size) => { + if (borrowedWorkspace) { + borrowedWorkspace.deallocateScratch(addr, size); + return; + } continuationMunmap( memory, channelOffset, addr, size, `pid=${pid}: reference scratch`, - ), + ); + }, ); // Every process instance, including a freshly reconstructed child, owns // the provenance manifest for any fork it may issue later. @@ -3418,15 +3572,17 @@ export async function centralizedWorkerMain( }; const readProcessLaunchRoot = (): number => { + if (borrowedForkChild) return forkBufAddr; const view = new DataView(memory.buffer); return ptrWidth === 8 ? Number(view.getBigUint64(dlopenArchiveControlAddr, true)) : view.getUint32(dlopenArchiveControlAddr, true); }; - let copiedLaunchRoot = 0; + let inheritedLaunchRoot = 0; let childArena: ForkModuleStateArena | null = null; if (initData.isForkChild) { if ( + !borrowedForkChild && initData.forkChildThreadFnPtr !== undefined && initData.forkBufAddr !== undefined ) { @@ -3441,45 +3597,56 @@ export async function centralizedWorkerMain( initData.forkBufAddr, ); } - copiedLaunchRoot = readProcessLaunchRoot(); + inheritedLaunchRoot = readProcessLaunchRoot(); if ( initData.forkBufAddr !== undefined && - copiedLaunchRoot !== initData.forkBufAddr + inheritedLaunchRoot !== initData.forkBufAddr ) { throw new Error( - `pid=${pid}: copied process launch root ${copiedLaunchRoot} ` + + `pid=${pid}: inherited process launch root ${inheritedLaunchRoot} ` + `does not match launch root ${initData.forkBufAddr}`, ); } - if (!Number.isSafeInteger(copiedLaunchRoot) || copiedLaunchRoot <= 0) { + if ( + !Number.isSafeInteger(inheritedLaunchRoot) + || inheritedLaunchRoot <= 0 + ) { throw new Error( - `pid=${pid}: fork child has no copied process launch root`, + `pid=${pid}: fork child has no inherited process launch root`, ); } const moduleStateRoot = readForkModuleStateRoot( memory, - copiedLaunchRoot, + inheritedLaunchRoot, ptrWidth, ); childArena = newModuleStateArena(); - childArena.attach( - ptrWidth === 8 ? BigInt(moduleStateRoot) : moduleStateRoot, - ); + const arenaRoot = ptrWidth === 8 + ? BigInt(moduleStateRoot) + : moduleStateRoot; + if (borrowedForkChild) childArena.attachBorrowed(arenaRoot); + else childArena.attach(arenaRoot); } processContinuation.prepareActivation({ activationId: 0, continuation: forkContinuation, - publishProcessLaunchRoot: (address) => { - // WHY: this copied control-page word is the fresh child's route to - // the main activation. No JavaScript closure survives fork. - writeForkContinuationAnchor( - memory, - dlopenArchiveControlAddr, - ptrWidth, - address, - ); - forkBufAddr = address; - }, + ...(borrowedForkChild + ? {} + : { + publishProcessLaunchRoot: (address: number) => { + // WHY: this copied control-page word is the fresh child's + // route to the main activation. No JavaScript closure + // survives fork. A borrowed vfork child receives no writer + // because this word still belongs to its suspended parent. + writeForkContinuationAnchor( + memory, + dlopenArchiveControlAddr, + ptrWidth, + address, + ); + forkBufAddr = address; + }, + }), readProcessLaunchRoot, }); @@ -3501,11 +3668,18 @@ export async function centralizedWorkerMain( } }; - kernelImports.kernel_fork = (): number => { + kernelImports.kernel_fork = (rawMode: number): number => { if (!processInstance) return -38; // ENOSYS + const mode = processForkMode(rawMode); + if (mode === null) return -STARTUP_EINVAL; const phase = processContinuation.phaseName(); if (phase === "parent-replay" || phase === "child-replay") { + if (mode !== forkMode) { + throw new Error( + `pid=${pid}: fork replay mode ${mode} does not match captured mode ${forkMode}`, + ); + } try { processContinuation.finishReplay(); } finally { @@ -3530,6 +3704,11 @@ export async function centralizedWorkerMain( return forkResult; } if (phase === "abort-replay") { + if (mode !== forkMode) { + throw new Error( + `pid=${pid}: fork abort mode ${mode} does not match captured mode ${forkMode}`, + ); + } const errno = forkContinuation.abortErrno(); try { processContinuation.finishAbortReplay(); @@ -3543,6 +3722,8 @@ export async function centralizedWorkerMain( `pid=${pid}: fork import reached while process continuation is ${phase}`, ); } + if (borrowedForkChild) return -STARTUP_EAGAIN; + forkMode = mode; // The arena and every activation prefix are allocated before any user // frame commits. If this fails, fork returns errno with no partially @@ -3629,6 +3810,7 @@ export async function centralizedWorkerMain( }, processHostImportRuntime, pid, + forkMemoryOwnership, ); processDlopenSupport = dlopenSupport; processTableReplication = createProcessTableReplicationOwner({ @@ -3637,9 +3819,11 @@ export async function centralizedWorkerMain( dlopen: dlopenSupport, newArena: newModuleStateArena, materializeModules: (snapshot) => { - dlopenSupport.replayDlopens(snapshot); + dlopenSupport.replayDlopens(snapshot, { + memoryOwnership: forkMemoryOwnership, + }); }, - // The copied fork arena restores a child process's complete + // The inherited fork arena restores a child process's complete // global/table/reference graph and preserves aliases with live frames. // The process table journal is for separately instantiated pthread // Workers and later generations, not a second initial child restore. @@ -3652,11 +3836,13 @@ export async function centralizedWorkerMain( `pid=${pid}: fork child lost its validated module-state arena`, ); } - // A parent can be copied while the archive mutex word names its - // now-nonexistent Worker. The validated archive bytes are immutable - // for this child launch, so clear that private lock before creating - // any loader state. - dlopenSupport.resetForkChildLock(); + if (!borrowedForkChild) { + // A parent can be copied while the archive mutex word names its + // now-nonexistent Worker. The validated archive bytes are immutable + // for this child launch, so clear that private lock before creating + // any loader state. A borrower must leave the parent's lock intact. + dlopenSupport.resetForkChildLock(); + } childDylinkState = dlopenSupport.readForkState(); const records = childArena.recordViews(); decodedChildReferences = decodeSegmentedForkReferenceTransaction( @@ -3703,21 +3889,27 @@ export async function centralizedWorkerMain( abort: () => activationRegistry.abortEarlyGcTransit(), }, memory, - allocateScratch: (size) => - continuationMmap( - memory, - channelOffset, - size, - `pid=${pid}: early reference scratch`, - ), - deallocateScratch: (addr, size) => + allocateScratch: (size) => borrowedWorkspace + ? borrowedWorkspace.allocateScratch(size) + : continuationMmap( + memory, + channelOffset, + size, + `pid=${pid}: early reference scratch`, + ), + deallocateScratch: (addr, size) => { + if (borrowedWorkspace) { + borrowedWorkspace.deallocateScratch(addr, size); + return; + } continuationMunmap( memory, channelOffset, addr, size, `pid=${pid}: early reference scratch`, - ), + ); + }, label: `pid=${pid}: early child references`, }); importedStatePlanner = new ForkImportedGlobalPlanner( @@ -3740,7 +3932,7 @@ export async function centralizedWorkerMain( ) ) { throw new Error( - `pid=${pid}: copied activation import dependencies require order ` + + `pid=${pid}: inherited activation import dependencies require order ` + `${plannedOrder.join(",")}, but the replay archive provides ` + archivedOrder.join(","), ); @@ -3903,10 +4095,15 @@ export async function centralizedWorkerMain( // whichever instance happens to load first in the child. try { if (!childDylinkState) { - throw new Error("copied dynamic-linker state was not prepared"); + throw new Error("inherited dynamic-linker state was not prepared"); } - dlopenSupport.replayDlopens(childDylinkState); - processTableReplication.reconcileNow(); + dlopenSupport.replayDlopens(childDylinkState, { + memoryOwnership: forkMemoryOwnership, + }); + // Ordinary children reconcile a copied archive under their private + // lock. Borrowed children already replayed the validated snapshot; + // taking either archive lock would mutate the suspended parent. + if (!borrowedForkChild) processTableReplication.reconcileNow(); } catch (error) { throw new Error( `fork-replay-dlopen failed: ${ @@ -3935,20 +4132,31 @@ export async function centralizedWorkerMain( ), ); const early = earlyChildReferences; - processContinuation.attachChild( - childArena, - () => { - early.adoptInto(activationRegistry.currentReferences()); - earlyChildReferences = null; - }, - decodedChildReferences ?? undefined, - ); + const adoptEarlyReferences = (): void => { + early.adoptInto(activationRegistry.currentReferences()); + earlyChildReferences = null; + }; + if (borrowedWorkspace) { + processContinuation.attachBorrowedChild( + childArena, + borrowedWorkspace.reservePrefix, + adoptEarlyReferences, + decodedChildReferences ?? undefined, + ); + borrowedWorkspace.assertAttachComplete(); + } else { + processContinuation.attachChild( + childArena, + adoptEarlyReferences, + decodedChildReferences ?? undefined, + ); + } decodedChildReferences = null; importedStatePlanner.clear(); importedStatePlanner = null; forkResult = 0; - // attachChild restores __tls_base/__stack_pointer for every + // Child attach restores __tls_base/__stack_pointer for every // activation before any continuation frame can execute. setupChannelBase( instance, @@ -4021,10 +4229,7 @@ export async function centralizedWorkerMain( } catch (e) { if (isForkUnwindException(e, processForkUnwindTag)) { transportedForkUnwind = true; - } else if ( - e instanceof Error && - e.message.includes("unreachable") - ) { + } else if (isWasmUnreachableTrap(e)) { if (kernelExitStatus !== null) { exitCode = kernelExitStatus; break; // Normal exit via kernel_exit -> unreachable trap @@ -4044,7 +4249,15 @@ export async function centralizedWorkerMain( } if (phase === "capture") { processContinuation.sealCapture(); - const childPid = sendForkSyscall(memory, channelOffset); + const borrowedReplay = Number(forkMode) === PROCESS_FORK_MODE_VFORK + ? processContinuation.borrowedReplayWorkspaceRequirements() + : undefined; + const childPid = sendForkSyscall( + memory, + channelOffset, + forkMode, + borrowedReplay, + ); forkResult = childPid; if (childPid < 0) { processContinuation.beginAbortReplay(-childPid); @@ -4069,11 +4282,7 @@ export async function centralizedWorkerMain( } catch (e) { processTableReplication.abortActiveMutations(); releaseProcessForkArchiveReader(); - if ( - e instanceof Error && - e.message.includes("unreachable") && - kernelExitStatus !== null - ) { + if (isWasmUnreachableTrap(e) && kernelExitStatus !== null) { exitCode = kernelExitStatus; } else { if (processContinuation.phaseName() !== "idle") { @@ -4102,7 +4311,7 @@ export async function centralizedWorkerMain( // No fork instrumentation: fork cannot be represented safely because // the child cannot resume at the fork call site. Fail loudly if the // program reaches kernel_fork instead of silently degrading. - kernelImports.kernel_fork = (): number => { + kernelImports.kernel_fork = (_mode: number): number => { throw new Error( `pid=${pid}: kernel_fork reached without complete wasm-fork-instrument ` + "exports. Rebuild the program with scripts/run-wasm-fork-instrument.sh.", @@ -4175,7 +4384,7 @@ export async function centralizedWorkerMain( exitCode = kernelExitStatus; } } catch (e) { - if (e instanceof Error && e.message.includes("unreachable")) { + if (isWasmUnreachableTrap(e)) { if (kernelExitStatus !== null) { exitCode = kernelExitStatus; } else { @@ -4485,16 +4694,33 @@ function setupChannelBase( function sendForkSyscall( memory: WebAssembly.Memory, channelOffset: number, + mode: ProcessForkMode, + borrowedReplay?: ForkBorrowedReplayWorkspaceRequirements, ): number { const view = new DataView(memory.buffer); view.setInt32( channelOffset + CH_SYSCALL, - HOST_INTERCEPTED_SYSCALLS.SYS_FORK, + processForkSyscall(mode), true, ); for (let i = 0; i < 6; i++) { view.setBigInt64(channelOffset + CH_ARGS + i * CH_ARG_SIZE, 0n, true); } + if (mode === PROCESS_FORK_MODE_VFORK) { + if (!borrowedReplay) { + throw new Error("vfork capture is missing borrowed replay workspace"); + } + view.setBigInt64( + channelOffset + CH_ARGS, + BigInt(borrowedReplay.prefixBytes), + true, + ); + view.setBigInt64( + channelOffset + CH_ARGS + CH_ARG_SIZE, + BigInt(borrowedReplay.scratchBytes), + true, + ); + } markDeferredSignalDelivery(view, channelOffset); const i32 = new Int32Array(memory.buffer); @@ -5303,6 +5529,7 @@ export async function centralizedThreadWorkerMain( }, }; let forkResult = 0; + let forkMode: ProcessForkMode = PROCESS_FORK_MODE_FORK; let kernelThreadExitStatus: number | null = null; const kernelImports = buildKernelImports( @@ -5316,11 +5543,19 @@ export async function centralizedThreadWorkerMain( }, ); if (hasForkInstrumentation) { - kernelImports.kernel_fork = (): number => { + kernelImports.kernel_fork = (rawMode: number): number => { if (!threadInstance || !threadProcessContinuation) return -38; // ENOSYS + const mode = processForkMode(rawMode); + if (mode === null) return -STARTUP_EINVAL; const phase = threadProcessContinuation.phaseName(); if (phase === "parent-replay") { + if (mode !== forkMode) { + throw new Error( + `pid=${pid} tid=${tid}: fork replay mode ${mode} does not ` + + `match captured mode ${forkMode}`, + ); + } try { threadProcessContinuation.finishReplay(); } finally { @@ -5329,6 +5564,12 @@ export async function centralizedThreadWorkerMain( return forkResult; } if (phase === "abort-replay") { + if (mode !== forkMode) { + throw new Error( + `pid=${pid} tid=${tid}: fork abort mode ${mode} does not ` + + `match captured mode ${forkMode}`, + ); + } const errno = threadForkContinuation!.abortErrno(); try { threadProcessContinuation.finishAbortReplay(); @@ -5343,6 +5584,7 @@ export async function centralizedThreadWorkerMain( `continuation is ${phase}`, ); } + forkMode = mode; try { // Reconciliation may instantiate a missing side module and execute @@ -5379,7 +5621,7 @@ export async function centralizedThreadWorkerMain( return 0; }; } else { - kernelImports.kernel_fork = (): number => { + kernelImports.kernel_fork = (_mode: number): number => { throw new Error( `pid=${pid} tid=${tid}: kernel_fork reached without complete ` + "wasm-fork-instrument exports. Rebuild the program with " + @@ -5666,9 +5908,7 @@ export async function centralizedThreadWorkerMain( if (isForkUnwindException(e, threadForkUnwindTag)) { transportedForkUnwind = true; } else if ( - e instanceof Error && - e.message.includes("unreachable") && - kernelThreadExitStatus !== null + isWasmUnreachableTrap(e) && kernelThreadExitStatus !== null ) { result = kernelThreadExitStatus; break; @@ -5686,7 +5926,15 @@ export async function centralizedThreadWorkerMain( } if (phase === "capture") { threadProcessContinuation.sealCapture(); - const childPid = sendForkSyscall(memory, channelOffset); + const borrowedReplay = Number(forkMode) === PROCESS_FORK_MODE_VFORK + ? threadProcessContinuation.borrowedReplayWorkspaceRequirements() + : undefined; + const childPid = sendForkSyscall( + memory, + channelOffset, + forkMode, + borrowedReplay, + ); forkResult = childPid; if (childPid < 0) { threadProcessContinuation.beginAbortReplay(-childPid); @@ -5708,11 +5956,7 @@ export async function centralizedThreadWorkerMain( const raw = threadFn(...threadArgs); result = Number(raw); } catch (e) { - if ( - e instanceof Error && - e.message.includes("unreachable") && - kernelThreadExitStatus !== null - ) { + if (isWasmUnreachableTrap(e) && kernelThreadExitStatus !== null) { result = kernelThreadExitStatus; } else { throw e; diff --git a/host/src/worker-protocol.ts b/host/src/worker-protocol.ts index 65a4cd352d..090b2f6d6c 100644 --- a/host/src/worker-protocol.ts +++ b/host/src/worker-protocol.ts @@ -4,6 +4,9 @@ import type { import type { ForkExternrefImportWake, } from "./fork-externref-import-mailbox"; +import type { ProcessForkMode } from "./generated/abi"; + +export type ForkMemoryOwnership = "copied" | "borrowed"; // --- Host → Worker messages --- @@ -61,8 +64,25 @@ export interface CentralizedWorkerInitMessage { cwd?: string; /** If true, this is a fork child — drive wpk_fork_rewind_begin instead of normal _start */ isForkChild?: boolean; + /** Exact ordinary/vfork mode captured by the inherited fork import. */ + forkMode?: ProcessForkMode; + /** + * Whether this child owns an independent copy or temporarily borrows its + * parent's exact Memory. Borrowed ownership is valid only for vfork. + */ + forkMemoryOwnership?: ForkMemoryOwnership; /** Address of the fork save-buffer in memory (used for fork child rewind) */ forkBufAddr?: number; + /** Parent process-wide archive/control anchor used read-only by a borrower. */ + forkOwnerControlAddr?: number; + /** First byte of the child-private activation-prefix region. */ + forkPrivatePrefixAddr?: number; + /** Exact admitted activation-prefix bytes. */ + forkPrivatePrefixBytes?: number; + /** First byte of child-private reference/exception codec scratch. */ + forkScratchAddr?: number; + /** Exact admitted scratch capacity. */ + forkScratchBytes?: number; /** * Two-phase launch gate for a fork child. The child announces that all * reconstruction and activation frames reached the inherited fork import, diff --git a/host/test/centralized-test-helper.ts b/host/test/centralized-test-helper.ts index cfa6bd400a..4314143e47 100644 --- a/host/test/centralized-test-helper.ts +++ b/host/test/centralized-test-helper.ts @@ -124,10 +124,16 @@ export interface RunProgramOptions { env?: string[]; /** Program arguments */ argv?: string[]; + /** Initial real/effective user ID. */ + uid?: number; + /** Initial real/effective group ID. */ + gid?: number; /** Timeout in ms (default: 30000) */ timeout?: number; /** Process memory ceiling for bounded allocation-failure tests. */ maxPages?: number; + /** Aggregate process-memory admission budget for allocation-path tests. */ + maxProcessMemoryBytes?: number; /** Custom PlatformIO (defaults to NodePlatformIO). * When provided, forces main-thread mode (PlatformIO can't be serialized). */ io?: PlatformIO; @@ -253,6 +259,7 @@ async function runInWorkerThread(options: RunProgramOptions): Promise { @@ -502,6 +512,7 @@ async function runOnMainThread(options: RunProgramOptions): Promise { + it("keeps a borrowed vfork child's loader view read-only", () => { + const memory = new WebAssembly.Memory({ + initial: 2, + maximum: 2, + shared: true, + }); + const table = new WebAssembly.Table({ initial: 1, element: "anyfunc" }); + const stackPointer = new WebAssembly.Global( + { value: "i32", mutable: true }, + 32_768, + ); + const archiveControlAddr = 128; + const support = buildDlopenImports( + memory, + 4_096, + archiveControlAddr, + () => table, + () => stackPointer, + () => undefined, + 4, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + 2, + "borrowed", + ); + const controlBefore = new Uint8Array( + new Uint8Array(memory.buffer, archiveControlAddr - 32, 32), + ); + + expect(support.readForkState()).toMatchObject({ + nextHandle: 2, + libraries: [], + }); + expect(() => support.acquireArchiveReader()).toThrow( + "cannot acquire the dynamic-loader archive reader", + ); + expect(() => support.resetForkChildLock()).toThrow( + "cannot reset the parent's dynamic-loader lock", + ); + expect(() => ( + support.imports.__wasm_dlopen_main as () => number + )()).toThrow( + "borrowed vfork child cannot call __wasm_dlopen_main", + ); + expect(new Uint8Array(memory.buffer, archiveControlAddr - 32, 32)).toEqual( + controlBefore, + ); + }); + it.each([4, 8] as const)( "reads memory%d pointers without changing int handles, lengths, or results", (ptrWidth) => { diff --git a/host/test/dri-cube-pyramid.test.ts b/host/test/dri-cube-pyramid.test.ts index 4588b3f5da..63231be5a0 100644 --- a/host/test/dri-cube-pyramid.test.ts +++ b/host/test/dri-cube-pyramid.test.ts @@ -123,6 +123,7 @@ describe.skipIf(!existsSync(programBinary) || !existsSync(kernelBinary))( onFork: async ({ parentPid: parentForkPid, childPid, + mode, parentMemory, continuation, }) => { @@ -181,6 +182,7 @@ describe.skipIf(!existsSync(programBinary) || !existsSync(kernelBinary))( memory: childMemory, channelOffset: childChannelOffset, isForkChild: true, + forkMode: mode, forkBufAddr, forkReplayGate: forkReplay.gate, ptrWidth, diff --git a/host/test/fixtures/catch-ref-fresh-worker.wat b/host/test/fixtures/catch-ref-fresh-worker.wat index bd191cdd27..d60799dae8 100644 --- a/host/test/fixtures/catch-ref-fresh-worker.wat +++ b/host/test/fixtures/catch-ref-fresh-worker.wat @@ -9,7 +9,8 @@ (import "env" "memory" (memory 1 16384 shared)) (import "env" "__channel_base" (global $__channel_base (mut i32))) (import "kernel" "kernel_exit" (func $kernel_exit (param i32))) - (import "kernel" "kernel_fork" (func $kernel_fork (result i32))) + (import "kernel" "kernel_fork" + (func $kernel_fork (param i32) (result i32))) (tag $payload (param i32)) @@ -130,6 +131,7 @@ drop local.set $caught + i32.const 0 call $kernel_fork local.set $pid diff --git a/host/test/fixtures/gc-reference-state-fresh-worker-bytes.ts b/host/test/fixtures/gc-reference-state-fresh-worker-bytes.ts index 49cba8ec3d..abcc09f4a8 100644 --- a/host/test/fixtures/gc-reference-state-fresh-worker-bytes.ts +++ b/host/test/fixtures/gc-reference-state-fresh-worker-bytes.ts @@ -3,13 +3,11 @@ // bytes for the adjacent, reviewed gc-reference-state-fresh-worker.wat source. // Node and browser integration tests share the exact input artifact. export const RAW_GC_REFERENCE_STATE_FRESH_WORKER_HEX = [ - "0061736d01000000011e065f027f0163000160017f006000017f60017f017f60016400017f60000002520403656e76066d656d6f727902030180800103656e760e5f5f6368616e6e656c5f62617365037f01066b65726e656c0b6b65726e656c", - "5f657869740001066b65726e656c0b6b65726e656c5f666f726b0002030504020304050406016300010101060e02630001d0000b7f01418080040b072c030f5f5f737461636b5f706f696e74657203020d5f5f6162695f76657273696f6e0002", - "065f737461727400050ad202040400412b0ba60101027f23002101200141046a418b01360200200141086a2000ac370300200141106a428008370300200141186a4200370300200141206a4200370300200141286a4200370300200141306a42", - "0037030020014101fe17020020014101fe0002001a024003402001fe1002004101470d0120014101427ffe0102001a0c000b0b200141c0006a2802000440417f210205200141386a290300a721020b20014100fe17020020020b530201630002", - "7f2000100121022101200245044020012000d323012000d371410025002000d3712000fb02000041cd00467120002000fb020001d4d3712103200345044041db001000000b41001000000b20020b4f02016300017f41cd00d000fb0000210020", - "00d42000fb050001200024014100200026002000d41004210120011003200147044041dc001000000b418008280200044041dc001000000b41001000000b00e401046e616d65014204000b6b65726e656c5f65786974010b6b65726e656c5f66", - "6f726b030a776169745f6368696c640419666f726b5f776974685f7265666572656e63655f7374617465024003030300037069640104626173650206726573756c74040400046e6f64650107636172726965640203706964030576616c696405", - "0200046e6f6465010370696403130103020008636f6d706c65746501047761697404070100046e6f6465050e01000b73617665645f7461626c65072903000e5f5f6368616e6e656c5f6261736501057361766564020f5f5f737461636b5f706f", - "696e746572", + "0061736d01000000011e065f027f0163000160017f0060017f017f6000017f60016400017f60000002520403656e76066d656d6f727902030180800103656e760e5f5f6368616e6e656c5f62617365037f01066b65726e656c0b6b65726e656c5f657869740001066b65726e656c0b6b65726e656c5f666f", + "726b0002030504030204050406016300010101060e02630001d0000b7f01418080040b072c030f5f5f737461636b5f706f696e74657203020d5f5f6162695f76657273696f6e0002065f737461727400050ad402040400412b0ba60101027f23002101200141046a418b01360200200141086a2000ac3703", + "00200141106a428008370300200141186a4200370300200141206a4200370300200141286a4200370300200141306a420037030020014101fe17020020014101fe0002001a024003402001fe1002004101470d0120014101427ffe0102001a0c000b0b200141c0006a2802000440417f210205200141386a", + "290300a721020b20014100fe17020020020b5502016300027f20004100100121022101200245044020012000d323012000d371410025002000d3712000fb02000041cd00467120002000fb020001d4d3712103200345044041db001000000b41001000000b20020b4f02016300017f41cd00d000fb000021", + "002000d42000fb050001200024014100200026002000d41004210120011003200147044041dc001000000b418008280200044041dc001000000b41001000000b00e401046e616d65014204000b6b65726e656c5f65786974010b6b65726e656c5f666f726b030a776169745f6368696c640419666f726b5f", + "776974685f7265666572656e63655f7374617465024003030300037069640104626173650206726573756c74040400046e6f64650107636172726965640203706964030576616c6964050200046e6f6465010370696403130103020008636f6d706c65746501047761697404070100046e6f6465050e0100", + "0b73617665645f7461626c65072903000e5f5f6368616e6e656c5f6261736501057361766564020f5f5f737461636b5f706f696e746572", ].join(""); diff --git a/host/test/fixtures/gc-reference-state-fresh-worker.wat b/host/test/fixtures/gc-reference-state-fresh-worker.wat index 1b6028ffaf..be071251b8 100644 --- a/host/test/fixtures/gc-reference-state-fresh-worker.wat +++ b/host/test/fixtures/gc-reference-state-fresh-worker.wat @@ -9,7 +9,8 @@ (import "env" "memory" (memory 1 16384 shared)) (import "env" "__channel_base" (global $__channel_base (mut i32))) (import "kernel" "kernel_exit" (func $kernel_exit (param i32))) - (import "kernel" "kernel_fork" (func $kernel_fork (result i32))) + (import "kernel" "kernel_fork" + (func $kernel_fork (param i32) (result i32))) (type $node (struct @@ -132,6 +133,7 @@ ;; Leave the reference below the fork result on the operand stack. This is ;; a real call carryover, not merely a local that happens to stay live. local.get $node + i32.const 0 call $kernel_fork local.set $pid local.set $carried diff --git a/host/test/fixtures/reference-catch-payload-fresh-worker.wat b/host/test/fixtures/reference-catch-payload-fresh-worker.wat index 36fca1d75b..c8fe0d2291 100644 --- a/host/test/fixtures/reference-catch-payload-fresh-worker.wat +++ b/host/test/fixtures/reference-catch-payload-fresh-worker.wat @@ -9,7 +9,8 @@ (import "env" "memory" (memory 1 16384 shared)) (import "env" "__channel_base" (global $__channel_base (mut i32))) (import "kernel" "kernel_exit" (func $kernel_exit (param i32))) - (import "kernel" "kernel_fork" (func $kernel_fork (result i32))) + (import "kernel" "kernel_fork" + (func $kernel_fork (param i32) (result i32))) (tag $func_payload (param funcref)) (tag $extern_payload (param externref)) @@ -156,6 +157,7 @@ drop local.set $caught + i32.const 0 call $kernel_fork local.set $pid local.get $pid @@ -194,6 +196,7 @@ drop local.set $caught + i32.const 0 call $kernel_fork local.set $pid local.get $pid diff --git a/host/test/fork-instrument-coverage.test.ts b/host/test/fork-instrument-coverage.test.ts index 8f4a69a10c..5fab231522 100644 --- a/host/test/fork-instrument-coverage.test.ts +++ b/host/test/fork-instrument-coverage.test.ts @@ -427,14 +427,11 @@ describe("fork_instrument_coverage / P-* process & threading", () => { }); }); - // P-08: vfork(). musl's vfork typically aliases fork (no copy-on- - // write distinction inside our kernel). If the libc returns - // ENOSYS or the symbol isn't linked, the test prints SKIP_VFORK - // and still passes — verifies the surface is at least gracefully - // handled. - it("P-08 vfork (or graceful unsupported skip)", async () => { + // P-08: ABI 43 vfork uses the borrowed-memory transaction and parks the + // caller until the child exits through the portable _exit-only path. + it("P-08 vfork child exit resumes the parent", async () => { await runFixture("programs/p_08_vfork.wasm", { - contains: ["PRE_VFORK", "PASS: P-08"], + contains: ["PRE_VFORK", "PARENT: child=", "PASS: P-08"], }); }); diff --git a/host/test/fork-process-continuation.test.ts b/host/test/fork-process-continuation.test.ts index 1723498108..0fb3280c7c 100644 --- a/host/test/fork-process-continuation.test.ts +++ b/host/test/fork-process-continuation.test.ts @@ -151,6 +151,7 @@ function makeCoordinator( calls: string[], roots: Map, label: string, + anchorMode: "writable" | "read-only" = "writable", ): { coordinator: ForkProcessContinuationCoordinator; arena: ForkModuleStateArena; @@ -177,9 +178,13 @@ function makeCoordinator( continuation, ...(activationId === 0 ? { - publishProcessLaunchRoot: (root: number) => { - roots.set(0, root); - }, + ...(anchorMode === "writable" + ? { + publishProcessLaunchRoot: (root: number) => { + roots.set(0, root); + }, + } + : {}), readProcessLaunchRoot: () => roots.get(0) ?? 0, } : {}), @@ -247,6 +252,10 @@ describe("ForkProcessContinuationCoordinator", () => { mainImports.__wpk_fork_frame_commit as (payload: number) => void )(mainPayload); parent.coordinator.sealCapture(); + expect(parent.coordinator.borrowedReplayWorkspaceRequirements()).toEqual({ + prefixBytes: 128, + scratchBytes: 0, + }); const borrowedRanges = [arenaRoot, ...[0, 4].map((activationId) => parent.coordinator.rootFor(activationId) - linkedFormat().chunkHeaderSize @@ -273,6 +282,7 @@ describe("ForkProcessContinuationCoordinator", () => { [], launchRoots, "borrowed child", + "read-only", ); child.arena.attachBorrowed(arenaRoot); const prefixRequests: number[] = []; @@ -300,6 +310,7 @@ describe("ForkProcessContinuationCoordinator", () => { .__wpk_fork_frame_next as (size: number) => number )(16); child.coordinator.finishReplay(); + child.coordinator.clear(); expect(child.coordinator.phaseName()).toBe("idle"); expect(child.arena.hasActiveArena()).toBe(false); @@ -369,6 +380,7 @@ describe("ForkProcessContinuationCoordinator", () => { [], launchRoots, "rollback child", + "read-only", ); child.arena.attachBorrowed(arenaRoot); expect(() => child.coordinator.attachBorrowedChild( diff --git a/host/test/fork-reference-transaction.test.ts b/host/test/fork-reference-transaction.test.ts index 3267ab020b..38b320e6c5 100644 --- a/host/test/fork-reference-transaction.test.ts +++ b/host/test/fork-reference-transaction.test.ts @@ -394,6 +394,37 @@ describe("ForkReferenceTransaction", () => { expect(released).toEqual([[0x1_0000, 65_536]]); }); + it("reports the exact capture scratch high-water before borrowed replay", () => { + const memory = new WebAssembly.Memory({ initial: 8 }); + let next = 0x1_0000; + const released: Array<[number, number]> = []; + const transaction = new ForkReferenceTransaction( + makeFunctionCatalog(0, []), + makeExternrefs().provider, + memory, + (size) => { + const addr = next; + next += size; + return addr; + }, + (addr, size) => released.push([addr, size]), + "borrowed scratch high-water test", + ); + transaction.beginCapture(); + const outer = transaction.reserveScratch(65_520); + const inner = transaction.reserveScratch(32); + transaction.releaseScratch(inner, 32); + transaction.releaseScratch(outer, 65_520); + + withArena((arena) => transaction.sealInto(arena)); + expect(transaction.borrowedReplayScratchCapacity()).toBe(2 * 65_536); + transaction.abort(); + expect(released).toEqual([ + [0x2_0000, 65_536], + [0x1_0000, 65_536], + ]); + }); + it("interns Wasm-only exception identity and transfers exact scalar/reference payloads", () => { const memory = new WebAssembly.Memory({ initial: 2 }); const thrown = new WebAssembly.Exception( diff --git a/host/test/fork-replay-host-parity.test.ts b/host/test/fork-replay-host-parity.test.ts index b3ed5ccf33..04505c1812 100644 --- a/host/test/fork-replay-host-parity.test.ts +++ b/host/test/fork-replay-host-parity.test.ts @@ -6,13 +6,14 @@ import { describe, expect, it } from "vitest"; const testDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(testDir, "..", ".."); -function forkHandlerSource(relativePath: string): string { +function ordinaryForkHandlerSource(relativePath: string): string { const path = join(repoRoot, relativePath); const source = readFileSync(path, "utf8"); - const start = source.indexOf("async function handleFork("); + const start = source.indexOf("async function handleOrdinaryFork("); const end = source.indexOf("\nasync function handleExec(", start); - expect(start, `${relativePath} must define handleFork`).toBeGreaterThanOrEqual(0); - expect(end, `${relativePath} must define handleExec after handleFork`) + expect(start, `${relativePath} must define handleOrdinaryFork`) + .toBeGreaterThanOrEqual(0); + expect(end, `${relativePath} must define handleExec after handleOrdinaryFork`) .toBeGreaterThan(start); return source.slice(start, end); } @@ -22,7 +23,7 @@ describe.each([ ["browser", "host/src/browser-kernel-worker-entry.ts"], ])("%s fork replay launch transaction", (_host, relativePath) => { it("waits for the exact child generation before committing and resolving", () => { - const handler = forkHandlerSource(relativePath); + const handler = ordinaryForkHandlerSource(relativePath); const wait = handler.indexOf("await forkReplay.waitUntilReady()"); const generationCheck = handler.indexOf( "processes.get(childPid)?.worker !== launchedWorker", @@ -40,7 +41,7 @@ describe.each([ }); it("cancels both a deferred launch and the rollback path", () => { - const handler = forkHandlerSource(relativePath); + const handler = ordinaryForkHandlerSource(relativePath); const launchGate = handler.indexOf("startProcessWorkerWhenRunnable("); const launchCancellation = handler.indexOf("forkReplay.cancel(", launchGate); const rollback = handler.indexOf("} catch (error)"); @@ -60,7 +61,7 @@ describe.each([ }); it("grants the exact copied externref graph before launch and retires rollback", () => { - const handler = forkHandlerSource(relativePath); + const handler = ordinaryForkHandlerSource(relativePath); const grant = handler.indexOf( "externrefProcessOwner.forkGenerationFromContinuation(", ); diff --git a/host/test/kernel-late-channel.test.ts b/host/test/kernel-late-channel.test.ts index 578e811002..6a3032f6eb 100644 --- a/host/test/kernel-late-channel.test.ts +++ b/host/test/kernel-late-channel.test.ts @@ -56,6 +56,7 @@ describe("kernel_handle_channel", () => { const forkProcess = instance.exports.kernel_fork_process as ( parentPid: number, callerTid: number, + mode: number, ) => number; const markProcessSignaled = instance.exports.kernel_mark_process_signaled as ( pid: number, @@ -73,7 +74,8 @@ describe("kernel_handle_channel", () => { ) => number; const parentPid = createProcess(); - const childPid = forkProcess(parentPid, parentPid); + expect(forkProcess(parentPid, parentPid, 2)).toBe(-22); + const childPid = forkProcess(parentPid, parentPid, 0); const channelOffset = allocScratch(CH_TOTAL_SIZE); const channel = new Uint8Array(memory.buffer, channelOffset, CH_TOTAL_SIZE); channel.fill(0); diff --git a/host/test/kernel-scratch-contract.test.ts b/host/test/kernel-scratch-contract.test.ts index a0300882f5..a13b9c00e9 100644 --- a/host/test/kernel-scratch-contract.test.ts +++ b/host/test/kernel-scratch-contract.test.ts @@ -583,7 +583,7 @@ const reviewedScalarKernelExportCalls: AuditAllowance[] = [ "host/src/kernel-worker.ts::CentralizedKernelWorker.#captureBlockingRetryDisposition::kernel-export-direct-use::isFdNonblock(channel.pid, fd)", ), reviewedScalarKernelExportCall( - "host/src/kernel-worker.ts::CentralizedKernelWorker.#createTestAuthority::kernel-export-direct-use::forkProcess(parentPid, callerTid)", + "host/src/kernel-worker.ts::CentralizedKernelWorker.#createTestAuthority::kernel-export-direct-use::forkProcess( parentPid, callerTid, PROCESS_FORK_MODE_FORK, )", ), reviewedScalarKernelExportCall( "host/src/kernel-worker.ts::CentralizedKernelWorker.firePosixTimer::kernel-export-direct-use::fire(pid, timerId)", @@ -756,7 +756,7 @@ const reviewedScalarKernelExportCalls: AuditAllowance[] = [ "host/src/kernel-worker.ts::CentralizedKernelWorker.handleFork::kernel-export-direct-use::clearForkChild(childPid)", ), reviewedScalarKernelExportCall( - "host/src/kernel-worker.ts::CentralizedKernelWorker.handleFork::kernel-export-direct-use::kernelForkProcess(parentPid, callerTid)", + "host/src/kernel-worker.ts::CentralizedKernelWorker.handleFork::kernel-export-direct-use::kernelForkProcess(parentPid, callerTid, mode)", ), reviewedScalarKernelExportCall( "host/src/kernel-worker.ts::CentralizedKernelWorker.handleIpcShmat::kernel-export-direct-use::kernelShmat( channel.pid, callerTid, shmid, // The kernel owns attachment accounting but not the process mapping // address; this legacy ABI slot is intentionally ignored by Rust. 0, flags, )", diff --git a/host/test/multi-worker.test.ts b/host/test/multi-worker.test.ts index 7dada4c28e..15ff26a168 100644 --- a/host/test/multi-worker.test.ts +++ b/host/test/multi-worker.test.ts @@ -45,6 +45,7 @@ import { HOST_INTERCEPTED_SYSCALLS, PROCESS_MEMORY_PAGES_PER_THREAD_SLOT, PROCESS_MEMORY_THREAD_SLOT_CHANNEL_PRIMARY_PAGE, + PROCESS_FORK_MODE_VFORK, PROCESS_STATE_EXITED, PROCESS_STATE_RUNNING, WPK_FORK_LINKED_FRAME_POINTER_WIDTHS, @@ -372,10 +373,11 @@ describe("CentralizedKernelWorker Process Management", () => { await waitForMailboxCompletion(memory, channelOffset); expect(kernelForkProcess).toHaveBeenCalledOnce(); - expect(kernelForkProcess).toHaveBeenCalledWith(parentPid, parentPid); + expect(kernelForkProcess).toHaveBeenCalledWith(parentPid, parentPid, 0); expect(onFork).toHaveBeenCalledWith({ parentPid, childPid: 101, + mode: 0, parentMemory: memory, continuation: { kind: "main", @@ -388,6 +390,90 @@ describe("CentralizedKernelWorker Process Management", () => { }); }); + it("carries vfork mode through kernel allocation and child launch", async () => { + const parentPid = 77; + const childPid = 104; + const memory = new WebAssembly.Memory({ + initial: 4, + maximum: 4, + shared: true, + }); + const channelOffset = WASM_PAGE_SIZE; + publishMainForkContinuation(memory, channelOffset); + const kernelForkProcess = vi.fn(() => childPid); + const onFork = vi.fn(() => Promise.resolve([WASM_PAGE_SIZE])); + const harness = createGatedLifecycleHarness({ + callbacks: { onFork }, + kernelExports: { kernel_fork_process: kernelForkProcess }, + }); + registerLifecycleProcess(harness, parentPid, memory, channelOffset); + + writePendingSyscall( + memory, + channelOffset, + HOST_INTERCEPTED_SYSCALLS.SYS_VFORK, + [128, WASM_PAGE_SIZE], + ); + await waitForMailboxCompletion(memory, channelOffset); + + expect(kernelForkProcess).toHaveBeenCalledWith( + parentPid, + parentPid, + PROCESS_FORK_MODE_VFORK, + ); + expect(onFork).toHaveBeenCalledWith({ + parentPid, + childPid, + mode: PROCESS_FORK_MODE_VFORK, + parentMemory: memory, + continuation: { + kind: "main", + forkBufAddr: TEST_FORK_CONTINUATION, + }, + borrowedReplay: { + prefixBytes: 128, + scratchBytes: WASM_PAGE_SIZE, + }, + }); + expect(readMailboxResult(memory, channelOffset)).toEqual({ + value: childPid, + errno: 0, + }); + }); + + it("rejects an oversized vfork workspace before allocating a child", async () => { + const parentPid = 77; + const memory = new WebAssembly.Memory({ + initial: 4, + maximum: 4, + shared: true, + }); + const channelOffset = WASM_PAGE_SIZE; + publishMainForkContinuation(memory, channelOffset); + const kernelForkProcess = vi.fn(() => 105); + const onFork = vi.fn(() => Promise.resolve([WASM_PAGE_SIZE])); + const harness = createGatedLifecycleHarness({ + callbacks: { onFork }, + kernelExports: { kernel_fork_process: kernelForkProcess }, + }); + registerLifecycleProcess(harness, parentPid, memory, channelOffset); + + writePendingSyscall( + memory, + channelOffset, + HOST_INTERCEPTED_SYSCALLS.SYS_VFORK, + [FORK_SAVE_BUFFER_SIZE + 1, 0], + ); + await waitForMailboxCompletion(memory, channelOffset); + + expect(kernelForkProcess).not.toHaveBeenCalled(); + expect(onFork).not.toHaveBeenCalled(); + expect(readMailboxResult(memory, channelOffset)).toEqual({ + value: -1, + errno: 11, + }); + }); + it("carries the exact pthread continuation anchor into the fork launch", async () => { const parentPid = 77; const childPid = 102; @@ -480,6 +566,7 @@ describe("CentralizedKernelWorker Process Management", () => { expect(onFork).toHaveBeenCalledWith({ parentPid, childPid, + mode: 0, parentMemory: memory, continuation: { kind: "thread", @@ -546,6 +633,7 @@ describe("CentralizedKernelWorker Process Management", () => { expect(onFork).toHaveBeenCalledWith({ parentPid, childPid, + mode: 0, parentMemory: memory, continuation: { kind: "main", diff --git a/host/test/node-process-teardown-ordering.test.ts b/host/test/node-process-teardown-ordering.test.ts index 6475d5e8c3..ffaf24ac22 100644 --- a/host/test/node-process-teardown-ordering.test.ts +++ b/host/test/node-process-teardown-ordering.test.ts @@ -42,7 +42,7 @@ describe("Node process Worker teardown ordering", () => { const inFlightGuard = finalize.indexOf("processTeardowns.has(worker)"); const crashNotification = finalize.indexOf("kernelWorker.notifyHostProcessCrashed"); const sharedTeardown = finalize.indexOf( - "await finishProcessExit(pid, exitStatus, worker)", + 'await finishProcessExit(pid, exitStatus, worker, "trap")', ); expect(inFlightGuard).toBeGreaterThanOrEqual(0); diff --git a/host/test/process-memory-creator-gate.test.ts b/host/test/process-memory-creator-gate.test.ts index a2b8aab733..eb626a546e 100644 --- a/host/test/process-memory-creator-gate.test.ts +++ b/host/test/process-memory-creator-gate.test.ts @@ -122,4 +122,56 @@ describe("process memory creator destroy gate", () => { await expect(destroy).resolves.toBeUndefined(); expect(sweep).toHaveBeenCalledOnce(); }); + + it("lets destroy sweep a committed generation while its syscall stays parked", async () => { + const gate = new ProcessMemoryCreatorGate(); + const vforkLifetime = deferred(); + const generationPublished = vi.fn(); + const sweep = vi.fn(() => ({ gracefulDetachComplete: true })); + const vfork = gate.runUntilCommitted( + "vfork process Worker", + async (commit) => { + generationPublished(); + commit(); + await vforkLifetime.promise; + return 41; + }, + ); + + const destroy = gate.closeAndRunAfterDrain(sweep); + await expect(destroy).resolves.toEqual({ gracefulDetachComplete: true }); + expect(generationPublished).toHaveBeenCalledOnce(); + expect(sweep).toHaveBeenCalledOnce(); + + let vforkFinished = false; + void vfork.then(() => { + vforkFinished = true; + }); + await Promise.resolve(); + expect(vforkFinished).toBe(false); + + vforkLifetime.resolve(); + await expect(vfork).resolves.toBe(41); + }); + + it("keeps pre-commit creation failures in the destroy drain", async () => { + const gate = new ProcessMemoryCreatorGate(); + const rollback = deferred(); + const sweep = vi.fn(); + const creator = gate.runUntilCommitted( + "failing vfork setup", + async (_commit) => { + await rollback.promise; + throw new Error("injected pre-commit failure"); + }, + ); + const destroy = gate.closeAndRunAfterDrain(sweep); + + await Promise.resolve(); + expect(sweep).not.toHaveBeenCalled(); + rollback.resolve(); + await expect(creator).rejects.toThrow("injected pre-commit failure"); + await expect(destroy).resolves.toBeUndefined(); + expect(sweep).toHaveBeenCalledOnce(); + }); }); diff --git a/host/test/process-wait-lifecycle.test.ts b/host/test/process-wait-lifecycle.test.ts index e01cb6b63a..92381722ca 100644 --- a/host/test/process-wait-lifecycle.test.ts +++ b/host/test/process-wait-lifecycle.test.ts @@ -2238,6 +2238,46 @@ describe("Rust-owned process wait lifecycle", () => { expect(onExit).toHaveBeenCalledWith(42, 137); }); + it("cooperatively quiesces a signaled vfork borrower at its pending channel", () => { + const memory = createSharedMemory(); + const channel = createChannel(42, memory); + const onExit = vi.fn(); + const worker = createWorkerHarness({ + kernel_get_process_exit_signal: vi.fn(() => 9), + }); + worker.processes = new Map([[42, { + channels: [channel], + memory, + borrowedAddressSpace: true, + }]]); + worker.hostReaped = new Set(); + worker.callbacks = { onExit }; + markPending(channel); + new DataView(memory.buffer, channel.channelOffset).setUint32( + CH_SYSCALL, + ABI_SYSCALLS.Kill, + true, + ); + + worker.handleProcessTerminated(channel); + + expect(worker.hostReaped.has(42)).toBe(true); + expect(readCompletion(channel)).toEqual({ + retVal: -1, + errVal: 4, + status: CHANNEL_STATUS_COMPLETE, + }); + expect( + new DataView(memory.buffer, channel.channelOffset).getUint32( + CH_SIG_SIGNUM, + true, + ), + ).toBe(9); + // The guest's kernel_exit import and terminal memory_quiescent message, + // rather than forced Worker termination, now own final host teardown. + expect(onExit).not.toHaveBeenCalled(); + }); + it("settles only the exit handshake after Rust has reaped a process", () => { const memory = createSharedMemory(); const channel = createChannel(42, memory); diff --git a/host/test/spawn-host-parity.test.ts b/host/test/spawn-host-parity.test.ts index 1b08cd5863..5458ec010b 100644 --- a/host/test/spawn-host-parity.test.ts +++ b/host/test/spawn-host-parity.test.ts @@ -44,8 +44,8 @@ function posixSpawnHandlerSource(src: string): string { return src.slice(start, end); } -function forkHandlerSource(src: string): string { - const start = src.indexOf("async function handleFork("); +function ordinaryForkHandlerSource(src: string): string { + const start = src.indexOf("async function handleOrdinaryFork("); const end = src.indexOf("\nasync function handleExec(", start); expect(start).toBeGreaterThanOrEqual(0); expect(end).toBeGreaterThan(start); @@ -103,7 +103,7 @@ function expectDeadStartUsesOrdinaryTeardown(handler: string, entry: string): vo describe("spawn host parity", () => { it("both hosts own the exact fork clone before their first async yield", () => { for (const entry of [nodeEntry, browserEntry]) { - const handler = forkHandlerSource(readFileSync(entry, "utf8")); + const handler = ordinaryForkHandlerSource(readFileSync(entry, "utf8")); const clone = handler.indexOf("acquireForkMemoryClone("); const firstAwait = handler.indexOf("await "); expect(clone, `${entry} must acquire the fork clone`).toBeGreaterThanOrEqual(0); @@ -118,7 +118,10 @@ describe("spawn host parity", () => { it("all fork and spawn dead-start paths transfer cleanup exactly once", () => { for (const entry of [nodeEntry, browserEntry]) { const source = readFileSync(entry, "utf8"); - expectDeadStartUsesOrdinaryTeardown(forkHandlerSource(source), entry); + expectDeadStartUsesOrdinaryTeardown( + ordinaryForkHandlerSource(source), + entry, + ); expectDeadStartUsesOrdinaryTeardown(posixSpawnHandlerSource(source), entry); } }); diff --git a/host/test/spawn-pid-authority.test.ts b/host/test/spawn-pid-authority.test.ts index 8b119c92ad..64b8f6faf6 100644 --- a/host/test/spawn-pid-authority.test.ts +++ b/host/test/spawn-pid-authority.test.ts @@ -349,10 +349,11 @@ describe("kernel task-ID authority", () => { await Promise.resolve(); expect(kernelForkProcess).toHaveBeenCalledOnce(); - expect(kernelForkProcess).toHaveBeenCalledWith(parentPid, parentPid); + expect(kernelForkProcess).toHaveBeenCalledWith(parentPid, parentPid, 0); expect(onFork).toHaveBeenCalledWith({ parentPid, childPid, + mode: 0, parentMemory: harness.processMemory, continuation: { kind: "main", diff --git a/host/test/startup-metadata-capacity.test.ts b/host/test/startup-metadata-capacity.test.ts index 77cbd0add8..56373d3881 100644 --- a/host/test/startup-metadata-capacity.test.ts +++ b/host/test/startup-metadata-capacity.test.ts @@ -1,6 +1,14 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { + CHANNEL_STATUS_COMPLETE, + CH_ERRNO, + CH_RETURN, + CH_STATUS, + CH_SYSCALL, + HOST_INTERCEPTED_SYSCALLS, POSIX_ARG_MAX_BYTES, + PROCESS_FORK_MODE_FORK, + PROCESS_FORK_MODE_VFORK, PROCESS_METADATA_ENTRY_MAX_BYTES, PROCESS_STARTUP_MAX_ARGV_COUNT, PROCESS_STARTUP_MAX_ENVP_COUNT, @@ -300,3 +308,43 @@ describe("process startup metadata capacity contract", () => { }, ); }); + +describe("process fork-mode import contract", () => { + it("routes ordinary fork and vfork to distinct syscalls", () => { + const memory = new WebAssembly.Memory({ + initial: 1, + maximum: 1, + shared: true, + }); + const imports = buildKernelImportsForTest(memory, 0, 4); + const kernelFork = imports.kernel_fork as (mode: number) => number; + const view = new DataView(memory.buffer); + view.setBigInt64(CH_RETURN, 73n, true); + view.setUint32(CH_ERRNO, 0, true); + const wait = vi.spyOn(Atomics, "wait").mockImplementation( + (array, index, expected) => { + if (Atomics.load(array, index) !== expected) return "not-equal"; + Atomics.store(array, index, CHANNEL_STATUS_COMPLETE); + return "ok"; + }, + ); + + try { + expect(kernelFork(PROCESS_FORK_MODE_FORK)).toBe(73); + expect(view.getUint32(CH_SYSCALL, true)).toBe( + HOST_INTERCEPTED_SYSCALLS.SYS_FORK, + ); + expect(kernelFork(PROCESS_FORK_MODE_VFORK)).toBe(73); + expect(view.getUint32(CH_SYSCALL, true)).toBe( + HOST_INTERCEPTED_SYSCALLS.SYS_VFORK, + ); + + const waitsBeforeInvalidMode = wait.mock.calls.length; + expect(kernelFork(2)).toBe(-EINVAL); + expect(wait).toHaveBeenCalledTimes(waitsBeforeInvalidMode); + expect(new Int32Array(memory.buffer)[CH_STATUS / 4]).toBe(0); + } finally { + wait.mockRestore(); + } + }); +}); diff --git a/host/test/support/kernel-scratch-instance.ts b/host/test/support/kernel-scratch-instance.ts index 59794290ed..8649c9874d 100644 --- a/host/test/support/kernel-scratch-instance.ts +++ b/host/test/support/kernel-scratch-instance.ts @@ -85,7 +85,7 @@ function signatures( result: i32, }, kernel_fork_process: { - parameters: [i32, i32], + parameters: [i32, i32, i32], result: i32, }, kernel_ftruncate: { diff --git a/host/test/thread-allocator.test.ts b/host/test/thread-allocator.test.ts index c6e582c083..3416d850e2 100644 --- a/host/test/thread-allocator.test.ts +++ b/host/test/thread-allocator.test.ts @@ -184,4 +184,50 @@ describe("ThreadPageAllocator", () => { expect(t2.slotStartPage).toBe(128 + PAGES_PER_THREAD); expect(reservations).toBe(2); }); + + it("reserves vfork host control for a zero-pthread process", () => { + let nextPage = 128; + const alloc = new ThreadPageAllocator({ + firstSlotStartPage: FIRST_THREAD_SLOT_PAGE, + maxPageExclusive: FIRST_THREAD_SLOT_PAGE, + reservedSlots: 0, + reserveSlotStartPage: () => { + const page = nextPage; + nextPage += PAGES_PER_THREAD; + return page; + }, + }); + const mem = makeMemory(); + + const control = alloc.allocateHostControl(mem); + + expect(control.slotStartPage).toBe(128); + expect(() => alloc.allocate(mem)).toThrow(/pthread slot limit exhausted/); + }); + + it("does not charge host control against the pthread quota", () => { + let nextPage = 128; + const alloc = new ThreadPageAllocator({ + firstSlotStartPage: FIRST_THREAD_SLOT_PAGE, + maxPageExclusive: FIRST_THREAD_SLOT_PAGE, + reservedSlots: 1, + reserveSlotStartPage: () => { + const page = nextPage; + nextPage += PAGES_PER_THREAD; + return page; + }, + }); + const mem = makeMemory(); + + const control = alloc.allocateHostControl(mem); + const pthread = alloc.allocate(mem); + expect(control.slotStartPage).toBe(128); + expect(pthread.slotStartPage).toBe(128 + PAGES_PER_THREAD); + expect(() => alloc.allocate(mem)).toThrow(/pthread slot limit exhausted/); + + alloc.free(control.slotStartPage); + expect(() => alloc.allocate(mem)).toThrow(/pthread slot limit exhausted/); + alloc.free(pthread.slotStartPage); + expect(alloc.allocate(mem).slotStartPage).toBe(pthread.slotStartPage); + }); }); diff --git a/host/test/vfork-lifecycle-guest.test.ts b/host/test/vfork-lifecycle-guest.test.ts new file mode 100644 index 0000000000..db6b528b25 --- /dev/null +++ b/host/test/vfork-lifecycle-guest.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; + +import { tryResolveBinary } from "../src/binary-resolver"; +import { + detectPtrWidth, + extractHeapBase, + WASM_PAGE_SIZE, +} from "../src/constants"; +import { computeProcessMemoryLayout } from "../src/process-memory"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const lifecycleProgram = tryResolveBinary("programs/vfork-lifecycle.wasm"); +const threadProgram = tryResolveBinary("programs/vfork-from-thread.wasm"); +const fatalProgram = tryResolveBinary("programs/vfork-fatal-lifecycle.wasm"); +const externalSignalProgram = tryResolveBinary( + "programs/vfork-external-signal.wasm", +); +const stateProgram = tryResolveBinary("programs/vfork-posix-state.wasm"); +const execChild = tryResolveBinary("programs/exec-child.wasm"); + +function initialAddressSpaceBytes(programPath: string): number { + const file = readFileSync(programPath); + const bytes = file.buffer.slice( + file.byteOffset, + file.byteOffset + file.byteLength, + ); + const ptrWidth = detectPtrWidth(bytes); + return computeProcessMemoryLayout({ + ptrWidth, + programBytes: bytes, + heapBase: extractHeapBase(bytes), + }).initialPages * WASM_PAGE_SIZE; +} + +function expectOrdered(output: string, markers: readonly string[]): void { + let previous = -1; + for (const marker of markers) { + const index = output.indexOf(marker); + expect(index, `missing output marker ${marker}`).toBeGreaterThan(previous); + previous = index; + } +} + +describe("production vfork lifecycle", () => { + it.skipIf(!lifecycleProgram || !execChild)( + "keeps the parent parked through exit and failed exec, then releases on exec", + async () => { + const events: string[] = []; + const result = await runCentralizedProgram({ + programPath: lifecycleProgram!, + argv: ["vfork-lifecycle"], + execPrograms: new Map([ + ["/bin/vfork-exec-child", execChild!], + ]), + useDefaultRootfs: false, + timeout: 15_000, + onProcessEvent: (event) => events.push(event.kind), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + expect(result.hostDiagnostics).toEqual([]); + expectOrdered(result.stdout, [ + "CHILD_EXIT_ONE", + "PARENT_RESUME_ONE", + "CHILD_EXIT_TWO", + "PARENT_RESUME_TWO", + "CHILD_FAILED_EXEC", + "PARENT_AFTER_FAILED_EXEC_EXIT", + "CHILD_NESTED_FORK_EAGAIN", + "CHILD_NESTED_VFORK_EAGAIN", + "CHILD_PTHREAD_EAGAIN", + "PARENT_AFTER_REJECTED_OWNERSHIP", + "PARENT_AFTER_EXEC_COMMIT", + "PARENT_REAPED_EXEC_CHILD", + "PASS: VFORK_LIFECYCLE", + ]); + expect(events).toContain("exec"); + }, + ); + + it.skipIf(!threadProgram)( + "parks a pthread caller while its sibling and child use independent channels", + async () => { + const result = await runCentralizedProgram({ + programPath: threadProgram!, + argv: ["vfork-from-thread"], + useDefaultRootfs: false, + timeout: 15_000, + // WHY: this budget admits exactly the parent's initial address space. + // pthread creation grows it before vfork, so any attempted child + // allocation would sample an already-exhausted budget and fail. A + // passing child therefore used the parent's existing Memory alias. + maxProcessMemoryBytes: initialAddressSpaceBytes(threadProgram!), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + expect(result.hostDiagnostics).toEqual([]); + expectOrdered(result.stdout, [ + "THREAD_BEFORE_VFORK", + "MAIN_SIBLING_RAN", + "MAIN_RELEASED_CHILD", + "CHILD_THREAD_EXIT", + "THREAD_CALLER_RESUMED", + "MAIN_JOINED_CALLER", + "MAIN_REAPED_CHILD", + "PASS: VFORK_FROM_THREAD", + ]); + }, + ); + + it.skipIf(!fatalProgram)( + "releases the parent after exact trap and signal teardown", + async () => { + const result = await runCentralizedProgram({ + programPath: fatalProgram!, + argv: ["vfork-fatal-lifecycle"], + useDefaultRootfs: false, + timeout: 15_000, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + expectOrdered(result.stdout, [ + "CHILD_BEFORE_TRAP", + "PARENT_AFTER_TRAP", + "PARENT_REAPED_TRAP", + "CHILD_BEFORE_SIGKILL", + "PARENT_AFTER_SIGKILL", + "PARENT_REAPED_SIGKILL", + "PASS: VFORK_FATAL_LIFECYCLE", + ]); + expect(result.hostDiagnostics).toHaveLength(1); + expect(result.hostDiagnostics[0]).toMatchObject({ + status: 132, + source: "worker-main error message", + }); + expect(result.hostDiagnostics[0].message).toMatch(/unreachable/i); + }, + ); + + it.skipIf(!externalSignalProgram)( + "contains a compute-running borrower after an external fatal signal", + async () => { + const result = await runCentralizedProgram({ + programPath: externalSignalProgram!, + argv: ["vfork-external-signal"], + useDefaultRootfs: false, + timeout: 15_000, + }); + + expect(result.exitCode, result.stderr).toBe(139); + expect(result.stderr).toBe(""); + expectOrdered(result.stdout, [ + "VFORK_EXTERNAL_SIGNAL_BEGIN", + "KILLER_THREAD_READY", + "CHILD_COMPUTE_LOOP", + "KILLER_SENT_SIGKILL", + ]); + expect(result.stdout).not.toContain("UNSAFE_PARENT_RESUMED"); + expect(result.hostDiagnostics).toHaveLength(1); + expect(result.hostDiagnostics[0]).toMatchObject({ + status: 139, + source: "vfork address-space containment", + }); + expect(result.hostDiagnostics[0].message).toMatch( + /ambiguous child teardown/, + ); + }, + 20_000, + ); + + it.skipIf(!stateProgram)( + "preserves independent POSIX state and shared open-file descriptions", + async () => { + const result = await runCentralizedProgram({ + programPath: stateProgram!, + argv: ["vfork-posix-state"], + timeout: 15_000, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + expect(result.hostDiagnostics).toEqual([]); + expectOrdered(result.stdout, [ + "PARENT_AFTER_STATE_CHILD", + "PARENT_REAPED_STATE_CHILD", + "PASS: VFORK_POSIX_STATE", + ]); + }, + ); +}); diff --git a/host/test/vfork-lifetime.test.ts b/host/test/vfork-lifetime.test.ts index 2c4c0b4748..ac38d367b2 100644 --- a/host/test/vfork-lifetime.test.ts +++ b/host/test/vfork-lifetime.test.ts @@ -180,7 +180,9 @@ describe("shared vfork lifetime coordinator", () => { const parent = generation("parent", memory); const firstChild = generation("first-child", memory); const first = coordinator.begin(70, 71, parent, firstChild); + expect(coordinator.phaseForChild(firstChild)).toBe("starting"); coordinator.markChildMayAccessMemory(firstChild); + expect(coordinator.phaseForChild(firstChild)).toBe("borrowing"); for (const [parentPid, childPid, initiator] of [ [70, 72, parent], @@ -202,6 +204,7 @@ describe("shared vfork lifetime coordinator", () => { } coordinator.completeAfterExactTeardown(firstChild, "exec"); + expect(coordinator.phaseForChild(firstChild)).toBeUndefined(); await first.completion; }); diff --git a/host/test/vfork-workspace.test.ts b/host/test/vfork-workspace.test.ts new file mode 100644 index 0000000000..80d92558f2 --- /dev/null +++ b/host/test/vfork-workspace.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { BorrowedVforkWorkspace } from "../src/vfork-workspace"; + +const PAGE = 65_536; + +function memory(): WebAssembly.Memory { + return new WebAssembly.Memory({ + initial: 6, + maximum: 6, + shared: true, + }); +} + +describe("borrowed vfork workspace", () => { + it.each([4, 8] as const)( + "allocates exact wasm%s prefixes and LIFO scratch without overlap", + (ptrWidth) => { + const shared = memory(); + const workspace = new BorrowedVforkWorkspace( + shared, + ptrWidth, + { + prefixAddress: PAGE + 4_096, + prefixBytes: 96, + scratchAddress: 3 * PAGE, + scratchBytes: PAGE, + }, + `wasm${ptrWidth * 8} test workspace`, + ); + const first = workspace.reservePrefix({ + activationId: 0, + byteLength: 32, + alignment: 16, + }); + const second = workspace.reservePrefix({ + activationId: 4, + byteLength: 64, + alignment: 16, + }); + expect(first).toBe(ptrWidth === 8 ? BigInt(PAGE + 4_096) : PAGE + 4_096); + expect(second).toBe(ptrWidth === 8 + ? BigInt(PAGE + 4_128) + : PAGE + 4_128); + + const outer = workspace.allocateScratch(32); + const inner = workspace.allocateScratch(64); + new Uint8Array(shared.buffer, inner, 64).fill(0xa5); + expect(() => workspace.deallocateScratch(outer, 32)).toThrow( + "not LIFO-exact", + ); + workspace.deallocateScratch(inner, 64); + expect(new Uint8Array(shared.buffer, inner, 64)).toEqual( + new Uint8Array(64), + ); + workspace.deallocateScratch(outer, 32); + workspace.assertAttachComplete(); + }, + ); + + it("rejects overlap, exhaustion, and an incomplete admitted prefix", () => { + const shared = memory(); + expect(() => new BorrowedVforkWorkspace(shared, 4, { + prefixAddress: PAGE, + prefixBytes: PAGE, + scratchAddress: PAGE + 32, + scratchBytes: 64, + })).toThrow("overlap"); + + const workspace = new BorrowedVforkWorkspace(shared, 4, { + prefixAddress: PAGE, + prefixBytes: 32, + scratchAddress: 3 * PAGE, + scratchBytes: 16, + }); + expect(() => workspace.reservePrefix({ + activationId: 0, + byteLength: 33, + alignment: 16, + })).toThrow("exceeds 32 admitted bytes"); + expect(() => workspace.allocateScratch(17)).toThrow( + "exceeds 16 admitted bytes", + ); + expect(() => workspace.assertAttachComplete()).toThrow( + "consumed 0 prefix bytes", + ); + }); +}); diff --git a/host/test/wasm-binary-parse.test.ts b/host/test/wasm-binary-parse.test.ts index 9d14b2c90a..46084431aa 100644 --- a/host/test/wasm-binary-parse.test.ts +++ b/host/test/wasm-binary-parse.test.ts @@ -552,6 +552,7 @@ function wasmValueType( function completeForkWasm(options: { pointerWidth?: 4 | 8; + kernelForkParams?: readonly ForkArtifactValueType[]; memoryPointerWidth?: 4 | 8; exportPointerWidth?: 4 | 8; capabilityFlags?: number | null; @@ -597,7 +598,11 @@ function completeForkWasm(options: { typeIndices.set(key, index); return index; }; - const kernelForkType = internType([], ["i32"], pointerWidth); + const kernelForkType = internType( + options.kernelForkParams ?? ["i32"], + ["i32"], + pointerWidth, + ); const emptyType = internType([], [], pointerWidth); const funcImports: FuncImport[] = [ { module: "kernel", name: "kernel_fork", typeIdx: kernelForkType }, @@ -1029,6 +1034,17 @@ describe("wasm artifact policy helpers", () => { } }); + it("rejects the obsolete no-argument process-fork import", () => { + const wasm = completeForkWasm({ kernelForkParams: [] }); + expect(wasmHasCompleteForkInstrumentation(wasm)).toBe(false); + expect(describeWasmArtifactPolicyFailures(wasm, { + expectedAbi: ABI_VERSION, + })).toContain( + "ABI 43 process-fork import kernel.kernel_fork has the wrong " + + "signature; expected (i32) -> (i32)", + ); + }); + it("requires the exact private exception transport before accepting ABI 43 safety", () => { const cases: Array<{ label: string; diff --git a/host/test/worker-exit-trap.test.ts b/host/test/worker-exit-trap.test.ts new file mode 100644 index 0000000000..3886fb13db --- /dev/null +++ b/host/test/worker-exit-trap.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { isWasmUnreachableTrap } from "../src/worker-main"; + +describe("committed worker exit traps", () => { + it("recognizes V8 and WebKit unreachable spellings", () => { + expect( + isWasmUnreachableTrap(new WebAssembly.RuntimeError("unreachable")), + ).toBe(true); + expect( + isWasmUnreachableTrap( + new WebAssembly.RuntimeError( + "Unreachable code should not be executed (evaluating 'trap()')", + ), + ), + ).toBe(true); + }); + + it("does not accept ordinary errors or unrelated Wasm traps", () => { + expect(isWasmUnreachableTrap(new Error("unreachable setup state"))).toBe( + false, + ); + expect( + isWasmUnreachableTrap( + new WebAssembly.RuntimeError("memory access out of bounds"), + ), + ).toBe(false); + }); +}); diff --git a/libc/glue/abi_constants.h b/libc/glue/abi_constants.h index 472aa96a4b..69f3a297b7 100644 --- a/libc/glue/abi_constants.h +++ b/libc/glue/abi_constants.h @@ -11,6 +11,10 @@ /* Non-forking spawn syscall number. */ #define WASM_POSIX_SYS_SPAWN 500u +/* Process-fork import mode selectors. */ +#define WASM_POSIX_FORK_MODE_FORK 0u +#define WASM_POSIX_FORK_MODE_VFORK 1u + /* Default process-wasm pthread slot declaration. */ #define WASM_POSIX_THREAD_SLOT_DECL_DEFAULT -1 diff --git a/libc/glue/channel_syscall.c b/libc/glue/channel_syscall.c index 7144fb6bbd..6694f9901e 100644 --- a/libc/glue/channel_syscall.c +++ b/libc/glue/channel_syscall.c @@ -312,7 +312,7 @@ uintptr_t __get_channel_base_addr(void) { * the host to save/restore the call stack across fork — so the child * resumes from the fork point with all local variables intact. * - * IMPORTANT: fork()/vfork()/_Fork() call kernel_fork() directly below, + * IMPORTANT: fork()/vfork()/_Fork() call kernel_fork(mode) directly below, * NOT through __do_syscall(). This keeps fork instrumentation limited * to the fork call chain. If kernel_fork were reachable from __do_syscall, * the tool would instrument every function that makes any syscall (~54K @@ -322,7 +322,7 @@ uintptr_t __get_channel_base_addr(void) { #define SYS_VFORK 213 __attribute__((import_module("kernel"), import_name("kernel_fork"))) -int32_t kernel_fork(void); +int32_t kernel_fork(int32_t mode); __attribute__((import_module("kernel"), import_name("kernel_exit"))) _Noreturn void kernel_exit(int32_t status); @@ -369,10 +369,8 @@ void __wasm_posix_after_fork_child(void); * as distinct non-inlined functions preserves both the fork call graph * and the observable side effect of the kernel_fork import. */ -__attribute__((noinline)) -int _Fork(void) +static int __wasm_posix_finish_fork(long ret) { - long ret = (long)kernel_fork(); if (ret == 0) { __wasm_posix_after_fork_child(); } else { @@ -392,6 +390,32 @@ int _Fork(void) return (int)ret; } +static int __wasm_posix_finish_vfork(long ret) +{ + /* + * WHY: the vfork child is still borrowing the suspended caller's TLS and + * libc globals. Ordinary fork must rebind a copied pthread descriptor to + * the new PID, but doing that here would overwrite the live parent's TID, + * thread list, and threads_minus_1 count. A successful exec replaces this + * state; _exit needs no libc-side child reinitialization. + */ + if (ret != 0) { + __wasm_posix_signal_checkpoint(); + } + if (ret < 0) { + *__errno_location() = (int)(-ret); + return -1; + } + return (int)ret; +} + +__attribute__((noinline)) +int _Fork(void) +{ + return __wasm_posix_finish_fork( + (long)kernel_fork(WASM_POSIX_FORK_MODE_FORK)); +} + __attribute__((noinline)) int fork(void) { @@ -404,7 +428,10 @@ int fork(void) __attribute__((noinline)) int vfork(void) { - return fork(); + /* vfork neither runs pthread_atfork handlers nor rewrites the borrowed + * caller state before exec/_exit. */ + return __wasm_posix_finish_vfork( + (long)kernel_fork(WASM_POSIX_FORK_MODE_VFORK)); } /* ------------------------------------------------------------------ */ @@ -577,7 +604,7 @@ static long __do_syscall_impl(long n, long long a1, long long a2, long long a3, int cancellation_point) { /* Fork/vfork are handled by fork()/_Fork()/vfork() overrides above, - * which call kernel_fork() directly. If we somehow get here (e.g. a + * which call kernel_fork(mode) directly. If we somehow get here (e.g. a * program calls __syscall(SYS_fork) directly), return ENOSYS because * fork instrumentation cannot save the call stack through the channel path. */ if (n == SYS_FORK || n == SYS_VFORK) { diff --git a/libc/glue/syscall_glue.c b/libc/glue/syscall_glue.c index ff9106469b..5f53f13770 100644 --- a/libc/glue/syscall_glue.c +++ b/libc/glue/syscall_glue.c @@ -1719,8 +1719,10 @@ static long __do_syscall(long n, long a1, long a2, long a3, /* ============================================================== */ case SYS_FORK: + return (long)kernel_fork(WASM_POSIX_FORK_MODE_FORK); + case SYS_VFORK: - return (long)kernel_fork(); + return (long)kernel_fork(WASM_POSIX_FORK_MODE_VFORK); case SYS_CLONE: /* Thread-style clone: a1=flags, a2=stack, a3=ptid, a4=tls, a5=ctid diff --git a/libc/glue/syscall_imports.h b/libc/glue/syscall_imports.h index 784f3fffd0..10640fc308 100644 --- a/libc/glue/syscall_imports.h +++ b/libc/glue/syscall_imports.h @@ -416,7 +416,7 @@ KERNEL_IMPORT(kernel_execve) int32_t kernel_execve(const uint8_t *path_ptr, uint32_t path_len); KERNEL_IMPORT(kernel_fork) -int32_t kernel_fork(void); +int32_t kernel_fork(int32_t mode); KERNEL_IMPORT(kernel_is_fork_child) int32_t kernel_is_fork_child(void); diff --git a/packages/registry/posix-utils-lite/test/process-tools.test.ts b/packages/registry/posix-utils-lite/test/process-tools.test.ts index c4b3d15ab8..afa44e8bbf 100644 --- a/packages/registry/posix-utils-lite/test/process-tools.test.ts +++ b/packages/registry/posix-utils-lite/test/process-tools.test.ts @@ -10,6 +10,18 @@ const artifactsAvailable = !!dash && !!pgrep && !!ps && !!coreutils; describe.skipIf(!artifactsAvailable)("posix-utils-lite process tools", () => { it("reports authoritative child and process state", async () => { + // WHY: the default rootfs supplies the executable identities needed by + // dash's PATH lookup, while this test's resolved fixtures supply their + // bytes. Keeping the mappings explicit avoids making a process-semantics + // test depend on network-backed lazy rootfs artifacts. + const execPrograms = new Map([ + ["/bin/pgrep", pgrep!], + ["/usr/bin/pgrep", pgrep!], + ["/bin/ps", ps!], + ["/usr/bin/ps", ps!], + ["/bin/sleep", coreutils!], + ["/usr/bin/sleep", coreutils!], + ]); const result = await runCentralizedProgram({ programPath: dash!, argv: [ @@ -37,6 +49,7 @@ describe.skipIf(!artifactsAvailable)("posix-utils-lite process tools", () => { ].join("; "), ], env: ["PATH=/bin:/usr/bin", "HOME=/tmp"], + execPrograms, timeout: 30_000, }); @@ -48,7 +61,9 @@ describe.skipIf(!artifactsAvailable)("posix-utils-lite process tools", () => { .filter(Boolean); expect(lines[0]).toBe("NO_CHILD_RC=1"); - const child = Number(lines.find((line) => line.startsWith("CHILD="))?.slice(6)); + const child = Number( + lines.find((line) => line.startsWith("CHILD="))?.slice(6), + ); expect(Number.isInteger(child) && child > 0).toBe(true); expect(lines).toContain(String(child)); expect(lines).toContain("MATCH_RC=0"); diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index ce01547e3a..0e1276d34a 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -4,344 +4,344 @@ "bash": { "manifestSha256": "5dc4d97dc4f154f265ff57e9d972e7d5a64a2e15fadedfa246733b830dda3497", "cacheKeys": { - "wasm32": "f72620b103f7606cb3c9e2550ea68dc7c35c6bfbc69b217bb10d906901ec1c6c", - "wasm64": "ce13dbc6980023cf85367554177f62a4c64ea2d32eb9adafba82e4c630be147f" + "wasm32": "43fd97bde16c9d5bc3bbc97f26e8e4cb25a1f95dbc6deb6a5909a0f5e124b6ff", + "wasm64": "65f3189dd9cde68e3666374aedbdf8b67096e7660f56c09c69611ae4461aeb80" } }, "bc": { "manifestSha256": "43b61e92c29098720bc7f4871a3b611cc01ffd124024290d42967cebb6ffd459", "cacheKeys": { - "wasm32": "43001c22570d4972d6143a04c7ba9103fc35a735c15070978f331353e1ed0108", - "wasm64": "41b9387e2bbcec8772f66f8c8aa6c4afc9e94de856e098392a3a05f7aed6a878" + "wasm32": "bdbdfdd24e73b2abaf6a251c4f256d79140fcf429b7f017315be44d9238da3eb", + "wasm64": "4ac0a4b7dc409b65c3923bed211dc4f63120fbf029930f4137910b670ff8fe22" } }, "bzip2": { "manifestSha256": "59e1e53f5675e7c9148634933ed0f4c7192743727025cae4e843b6924c489abf", "cacheKeys": { - "wasm32": "aa390b7415063fe2316048755a2c515c82840564e6c9ef5d0186be27acdec409", - "wasm64": "dd2af354cc47621d6f9cacebcd45411aca86c93e162ea7498d8e5507ddacbd2a" + "wasm32": "b95c9827ead7a75115450673b3d20e39c4e8cbbddbc29af1e423f318cdf0db44", + "wasm64": "c14dffab6d4cc78cdc3dde9ea8b23dceaaeae6afbf2374f51b97e788eb363cf7" } }, "coreutils": { "manifestSha256": "72fc9c3818fb11cebaa047a623743252395dc729163fa804a7272d59f87fe82d", "cacheKeys": { - "wasm32": "4583435763207ddfd63478fc7b3a263fdc6fae110bcd38109e1e1c26e621ad7f", - "wasm64": "74f816ee53c308c9e94e737275bdb42a71ffd75b5331422f8c106333b7609dc5" + "wasm32": "770b6ceaf5e508b0a9fff5798f131fd67dd9864054dc2cf6acf89836eabd85a1", + "wasm64": "6e015433d5bcad2709a5374916dfd81156dcf34531046b579b8cb621f15b0b70" } }, "cpython": { "manifestSha256": "7dd4f446697a73941ec940c2ccba4d53be73fe6947f1e701031a4d0aa964c4ff", "cacheKeys": { - "wasm32": "a2e894f110205a0cf42bc02a56a31156a82fad0c291595a508bdb7bd09e7a6c7", - "wasm64": "3cf8ae7f6b6393df6e0c3ea8072bc2a6c7581d813d75b6ce48042aa2c1c92f3b" + "wasm32": "a101343c9081af62dfe5b0b3c9d981926b458240c641edc80d7d4a4a5816adb8", + "wasm64": "4d01083f102c019ace6ffdc2a043761f39f1325841322b822609613b960d46fe" } }, "curl": { "manifestSha256": "55523d50261f46dc4aaaff458d1cd87c6f96eaecd687a7540ead35c96906366e", "cacheKeys": { - "wasm32": "2e4132091d535e4afe6ef5ca6e0952c3f459330736c0a7eb6125b28bd1f45369", - "wasm64": "195e130269e2854a233b5f163f50fbd26aa8bb31341ad3ac3bb342b955c61a9a" + "wasm32": "87f6acaf54efa8a8207f09679519a5b35efdaece28eb33c0ff866796e16eca0e", + "wasm64": "3fb2a517d7d2985e64282a7db734797faa4cbb017fbd4d086613e82892850bb9" } }, "dash": { "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", "cacheKeys": { - "wasm32": "c4b4345aa3f9011d0b214ad6adaa6c7fbe5eb2f6bbd0e5181b8f4be68f437bf3", - "wasm64": "f37e09c31002de4be5c25d4f789b81e2ced708016032756a6cafe010a4537811" + "wasm32": "e7a48fe30c5509f8152a20c11da66e056f5b07c343fb1ae06f64884bc39cdbad", + "wasm64": "7eea3d285157e4e5f16bd4a12323aa4c175efb49d9fb23cb1caad45c2b2850b8" } }, "diffutils": { "manifestSha256": "2286203eb762eca487939963e38ea1b901ac2dc9a830d3260c2fb291757df208", "cacheKeys": { - "wasm32": "d48b0feda4af14e2d08a803611d0b825ac51e9a9379bdb8b49e1170eaf75e5d0", - "wasm64": "b574d2b123a1d927e46d99624745567d382dd3b81d99f4cc5beb2bfb7e9fad0b" + "wasm32": "55ee5fd0c56cacbc3d21752feeba5e8628fb4dfe670eafd3bb14dae4959a3c10", + "wasm64": "6b29ed5a81238236cae0b119fb7cad79d25184bf508b1dfaa6963a1546c971b5" } }, "dinit": { "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", "cacheKeys": { - "wasm32": "543e082567cc0502609bf0cdef6eb1ba431cef97d798136e739c915f8392615a", - "wasm64": "872d4ca7596d100e781612e7e659bc840c74fdab8c0769746c48ff1d5ec478fb" + "wasm32": "c81e0b1b714646718033c1b2b43293d3d087d502abae5cfe82e6933dd8fe20f9", + "wasm64": "df5791013bc496b63584dbe766c735abb22fadc012fc23819bc339db9aa2461e" } }, "erlang": { "manifestSha256": "475d6037e73a0f1e2f4381067194b2422751206979ea17209e10efa5a2a93bbb", "cacheKeys": { - "wasm32": "9f4b0bfcf465fb5a83d04186672e5ca6b9e40f33db4f526e6438f0939bf9c8d2", - "wasm64": "4b36337b67b9911f085169937db021e7ec29b080bb9907ca232e7b96e5590808" + "wasm32": "d1e404a58baa024394755a8f250d0fc8de3d5384e252b4e12cac7bbe0e1ad04d", + "wasm64": "bf51d363249d2a572a5846e269b61d09fd7f90fd5dd2953ead0fb89db42799f8" } }, "erlang-vfs": { "manifestSha256": "15efb74f1825e2b85bedfeb8d98c19fa648c4be39cb9defb65330a8f21eb9141", "cacheKeys": { - "wasm32": "0b302ed848f5596a2c80569a6ee3b6426ae9778a5f0e15a824ee57c609960311", - "wasm64": "cdb9d3661305fa85850b6e4df581166fce5c7978565900f54efcb7c2217fae8b" + "wasm32": "fa6f701bd5445a7808e7c443c938bf8504548351495dec5594a0d7e6e2d7be39", + "wasm64": "3a9c75c05a5f8a222095fd78827c76f0e9116a125cd1781d075326df164eb952" } }, "fbdoom": { "manifestSha256": "7ff2127ca940e41be90ba45204c89089a7a2093567b9bff581a9549b57138626", "cacheKeys": { - "wasm32": "816074a21b019d5a52c7a676123b93ebbb0e841cab60a3c7553773520284733f", - "wasm64": "151fcd4ebdbadf44602ea10f2780fc1dbee281b2427829abe160db720e269028" + "wasm32": "9e2b644abb9aab6062b69a84d4f9d864df9dbc5ddd099392cea29b16bd40b924", + "wasm64": "ba0d9f266476a8028222b000ed6074f7757e4076b68ace0717af2ce5ed5d2b32" } }, "file": { "manifestSha256": "7874c1affcbbf2c8ab8c8d4beb087f275af57b5caae6a8947bf5a63a181552b2", "cacheKeys": { - "wasm32": "55d93c1fd59d890d6f78346197a6d474c654fc2a6687e1ff6e1ffb1ae09ce377", - "wasm64": "2ea8d818cf8ec473a4f77d812ea00c43615613a328e06cad6c5f523632a542c7" + "wasm32": "357a02fbd427f35255e349b6243f23b662fdb2608e09d6a08a040c6ae0a07cc3", + "wasm64": "9bb78d6fa4c414eb6657f2baf712ebf7e96d45d9dfc2121d87ff9aee16acd139" } }, "findutils": { "manifestSha256": "a9969cb102403cef105e758ef602692500fddd75ec96e4e3f168c50094b6c931", "cacheKeys": { - "wasm32": "b866449fe86205186c0537b6992df7747c852f0e07fde1da6f9d0a7b5808b152", - "wasm64": "095f02c50a616ef3983049ea209cb9464d85577d5fffd85fa13cb4aff0f78b7d" + "wasm32": "ff88da1019fb2d3b56399f9450baa23cdb5069458ea99dca1f3d2da19eee1501", + "wasm64": "6fa3876219b34e99a793027954ed9108bb972bdb52240c7b2d2d136dc4f8266b" } }, "gawk": { "manifestSha256": "b788f3de724cd363ea68d08826586ddf544a75fb5dd9df1369ffafe06d8681b7", "cacheKeys": { - "wasm32": "13a6253127ab0250d8dc39d51979aae88a0004cb5a268493cd332eaf564546d6", - "wasm64": "a8cd27b75e88597d5f9fb83a3877f14e8951d3ed2ae543dd4fa44c4f0d073923" + "wasm32": "0878a1253d8ed63fe64073b28351207fbe01dd12b6c166b7d6690e41164436c1", + "wasm64": "0a84e655511320364e483d8f873d2b2d9d55f1cd252ae5e262c9efef29c51237" } }, "git": { "manifestSha256": "c2bc79e62c9a4e840ae94a16af0f41070460eaf3976a990dc60d2db977bee952", "cacheKeys": { - "wasm32": "d96bb46b733a7cfb83b17b6d53f36c35b1617a75cb6c5b929237d35bff327b31", - "wasm64": "84999c3c616b868f93b1c9318f9d25e9d4d934a0a3142a2e38755cb13c0b15fb" + "wasm32": "248e67d35a337108e1e7fa1e3a468df2a94bbe5ba5e02e7ecef8d6b961a7076b", + "wasm64": "5e4f8423d87cdb3048a0c035a749a2620a09cc26c6da6eda83ba75e58c99272f" } }, "grep": { "manifestSha256": "7c61b894807b831c7e8816cb1f39c78e3a69a4ced2fa3d18f0abdf00bf18334b", "cacheKeys": { - "wasm32": "e75c15a6f6114b40248b6de7c3057b99b108dde867cda73c111e47c3acbb326f", - "wasm64": "c4f3de09c2acc03edcf4e4b217bdae3e65de546e2543b4bf1ba7db89d59d2bb7" + "wasm32": "e368d4eb26a528111c836689761ba645ad34d43aa21ae8c8420d16d1ace57da7", + "wasm64": "ec44c9a9c0d9743616cc2937fa0e6b905695e4913b3103b0eb3b0447effcd15f" } }, "gzip": { "manifestSha256": "1485add843bbcd744244e707c353208fe11192b7b248feed1550875d3b75a917", "cacheKeys": { - "wasm32": "0332b476d7ec3d6fcebec49aadf3facae32a69fc004cebfc6e5c7f3a195561b0", - "wasm64": "bcd0569a7bb38ead44161bb500fe6fcb60a1cb53982314beb8f34bb91061dbeb" + "wasm32": "c93bacf2b437fb73da6dd5cafa6ac00575dc7a130590ad6423d1a186d45468cf", + "wasm64": "289ca804b799114e4154428d10da223153f582b5465a34698ef919d4fc7221fb" } }, "homebrew-bootstrap": { "manifestSha256": "183004a8385ab5afc388cd43401341b82812c67f97b08ad349ebd7f4dbbebd0f", "cacheKeys": { - "wasm32": "ef0092c0b03589468a00976d8e5167f4a0a18260b8b61b2f7136263d480deaac", - "wasm64": "4bdc42eb5dfe7b8d9cb3e4a039a53816842097a538fd522e34f97d3390ea4b04" + "wasm32": "910d7ffed0e8cf7dc71d90c6d2300f53eaf266211b247b6e6a37bc302690322b", + "wasm64": "09b7aa8b7031d870ac92e2548d6a799950edb34d1af3aef75187549a8c23bc77" } }, "icu": { "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", "cacheKeys": { - "wasm32": "81677f4b7e2d40634555388d8d06757a052efcbb915bef1c6e57d444137848cd", - "wasm64": "91c5d7b785c8689058944da76ac2987993d90d5663a0003239feb1ec4050d934" + "wasm32": "869e58f1e945c89086b28e50dda9cd001d6205ae51404149404cbadbd338a3ef", + "wasm64": "7aa317a945d9d10f7db59813faf07457f67c4d27758eb87fe517aafa32869d0b" } }, "kandelo-sdk": { "manifestSha256": "c081879f1becd855917cff96f17429bcf2b9fd61bf95211eca9ff8fb625bb2eb", "cacheKeys": { - "wasm32": "ce884b1760130a2cb478fbe03f5ec2f407339e5631bebc516daf97a7d80fe096", - "wasm64": "0962794128f6c2b35277c561406bb5885d24618d5821a053fc70e909d89ea909" + "wasm32": "bc4a0e630b02b52dcefca5e807c712a7a1baf131e03b7ac2ffd9b56bcd425abd", + "wasm64": "d4853b71658aef7cadecaa0e51b79c0f85e618dc566cf3831883f2629482884b" } }, "kernel": { "manifestSha256": "db1d66db8575562ac7b3e720dbaa06d7dd5eef577d80220ea4167dc2d69b863b", "cacheKeys": { - "wasm32": "1272ecb067f8de873dec513e588c1f1c84186237ecdf69dd93fdb6849d8dea50", - "wasm64": "cc9c0326c42db84eef0a82f2202923b1cb7ee7ac24007dbbd684bbf8ab46736f" + "wasm32": "96aa66030a19468af2592bc4b7ed6f917dc65d1d0471ed61831a20166b36226d", + "wasm64": "c6b5d73ed421a7b9f29fcf8a65c368dc9f6942430588e2651da8acfa444d5b50" } }, "lamp": { "manifestSha256": "250b64635d64f8537178188fb5488dbfd2980d79a1c2ffbf346af67008088ba0", "cacheKeys": { - "wasm32": "9eafe9e962c351bcfe4fceaa2fbace1ad8b9c9c4a74b46fa2d6a80e7e4f6c3d9", - "wasm64": "7bac32a5ae47d59e5dac0d392032282d6458bb674e09a5e3e4b91c4d216b6a8f" + "wasm32": "7e80a42e9056453b9d755523e98a1d9fcd852d12bb96c534ab5d05da61716435", + "wasm64": "718d26d1f2e3bf6af25489db1f5022cdd0236340f55b7f5cbb621edbec17c1a1" } }, "less": { "manifestSha256": "996d61545cfe83dcb663a33e8763e0edbd4db4a605fd69a91b243cf79b1f9b17", "cacheKeys": { - "wasm32": "180f11bd766f6c2482eb68dba1803d81bcfedc79ff0ff40a4fba1d11219d6dd9", - "wasm64": "9794e1db462de7da1334e3d67b1014c26e81fc1ff1e87cd7c118b0d7cb6b9c21" + "wasm32": "d347d2f00c368bd2d9aef83d7dc57bb37a0c1af3ecc7f262310623a4c3538ec2", + "wasm64": "7d72b9378eaf74bf61ce42900f6991b44515404a1acdd300e687db267c57ee2c" } }, "libcurl": { "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", "cacheKeys": { - "wasm32": "6ce2dd7a19b7f5af78edc02fc75d08bbeade7f41e968f8b8a3f6e7e5fe4b26ae", - "wasm64": "69dde320d13b2d22f9cc5a7884e9bbc3597ed36f3fa8be00513a3f144c207967" + "wasm32": "13427d5971b26c2e3e2ee43756f88944e3b75385a4d13e6fd45dd63a6a7c9afe", + "wasm64": "eea268d22a367a326d50838b5ebbbdeaffed9d63e867fdc32f15d50e108699f5" } }, "libcxx": { "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", "cacheKeys": { - "wasm32": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023", - "wasm64": "06a4c50ff62d8d2e638f341cf8ec0b1837b24a3d602a437759f0c2d0a8396821" + "wasm32": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46", + "wasm64": "1799d410cef457fa39446d4d4a0a17fbd4311da2a21d65b9f0268275508b616c" } }, "libiconv": { "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", "cacheKeys": { - "wasm32": "8b132a257b9a02a3adccac9d5d6bedc00348a6ac92375401761312d7c46a2ecf", - "wasm64": "83ecaeabc5a95f57b5309bf957b3f9b0dde7bd39d8eef5cd978d9a15028ada59" + "wasm32": "e3b39e170e399c7c353bf41cf8340473dae7f378cd8ba8dbfd5fb9f4ef69f4c5", + "wasm64": "23260593af6215390da7fafd265bb6c69caba9aa18c65a694a24d6972f69f2ed" } }, "libpng": { "manifestSha256": "79ed5c5c072a0267cce5c7a2fe37d8494571b17e44b952f619e04ec1c4a9db10", "cacheKeys": { - "wasm32": "5862e6edd08188b92bb7f84c064d147326ec65ba5024bb86e93a8efa93ff13d8", - "wasm64": "430d5136cd30c07465c73b9994fbfe799357a06edf712eb625a2b8d8274d7374" + "wasm32": "578ab5f1f7dc19a936f4dd6e6ac2da7cb4683213085c933eeeaeb424410ac8dc", + "wasm64": "39e825154e8ba0a47fd53f3c947429e6bcb6304eafd55bdfac149037a849c84f" } }, "libxml2": { "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", "cacheKeys": { - "wasm32": "4873ada42645826762560ad1833364a60c630c6411f84a03456b171f279faf2b", - "wasm64": "6fe5c3313fb2ae976bc081a78ed82fa93736fa8797191db3f3f01fa2d9f6db46" + "wasm32": "222b11ed5313da06ba2368741a2e4589f78f89beebd184edcb0559b20bddc3f5", + "wasm64": "501c3b0ad9e958147ecd982d71024d70f822a5f52cedbcedaed0afa1e0156329" } }, "libzip": { "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", "cacheKeys": { - "wasm32": "32c0e36842b5f47e79c66b925601f3f30c4ac982303cd159ade0b6c5755e5b61", - "wasm64": "52b48e294612fb9e5b9c0b83e3ac0007677ed58243d58601a5ae416556ef63f6" + "wasm32": "e4cdb8156bdfcfd302a18db3d908898f57b3859a00b308bbefe3a0f9b85de483", + "wasm64": "4a4203487d98e9c7bfb98a225071b5402d35925e1bb6d3ef98dd5b0fc57a68af" } }, "lsof": { "manifestSha256": "cfd199bbe082435f7a2e703e2738a368af1eeb5b76b6e93c376054f21ce94419", "cacheKeys": { - "wasm32": "6f7cbc2839c96bdc4569c4845fd0c4671e7596cfd36483deb4e4a05e5d48bccb", - "wasm64": "39892c4b2d66a8e2d0108856fbf8dee8857b06d9f4ccc9c01c6a3d39daa78f4b" + "wasm32": "e262c9ac18046a4817a8fc25e5a356e814fec11a7f53b905f1718d4dffaca656", + "wasm64": "6b6d63ff79dff5992408b52be9a3f071c826d1dfcc1ef6ba7b4310c3e2d1be12" } }, "m4": { "manifestSha256": "9b5838bdde8531079f23a89e135aa3832bbbbb7ad6099dd1503db877d52afcad", "cacheKeys": { - "wasm32": "8eb7d929c608eecfe55cffe87513a1d8b7b58c3dbe80f350ab9e0eb9ad7416a8", - "wasm64": "1cdd52610d82f6c8cd191304fd4213b28caa155948d8464773a854df5c5dd1df" + "wasm32": "223de993ff0b49af372f84de543354c651a80ad0cc0d4a219b927f52fa522583", + "wasm64": "d20472ef9c62dea91aae82d116c6cfdb36e3d4bdf7caab866cfc496f47b1a7ef" } }, "make": { "manifestSha256": "62e8b36a6df7e981d191061a5bb7f2db85b2deaf365352a7d590a6c6c06f2b1e", "cacheKeys": { - "wasm32": "dc7fce3e4dd128bbdd6a89356224db601bebd13df9c63548f5ab969a250dfcc5", - "wasm64": "23b0d807988b5c123df13094d172813c4e678017db558a5469e72929dfb9e8ea" + "wasm32": "7ecea3370409cc9f5f40c92beeb29b944a6bfa3aa93e63f1400abc5ddf6a7bbd", + "wasm64": "d30ec7577013788b49899fc69cb5152af0a997da865e3f2bd11c27e6e655bcb9" } }, "mariadb": { "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", "cacheKeys": { - "wasm32": "a6fc124a4bc7562dd038f5e616c914837fc5882cd90f0199d469c0259d0a8b4c", - "wasm64": "8dca7f977ae361b01d67d1af6fa7e9847bb9499fd2fa746283dee757caf17837" + "wasm32": "22fb165b69847120ce44c5d6a7aef7b3c7d2804448eefec7a56b35c40631623d", + "wasm64": "5baf0991b29722ffd473317912e25525f5f9b463eceb3e942c52d6cbea183963" } }, "mariadb-test": { "manifestSha256": "d4ccce161d659a5a87c80fa1117054bbccea870e0d4a46bd8a070d3930ad0e3f", "cacheKeys": { - "wasm32": "1a48ab9b3bb0e378eef2d34d5dcab2084db47cac9da6e63ea77044f8fab184bf", - "wasm64": "b5874ac2d9bb3c1db5c6a4277e7efe9b24444eabfab55b61235ec4228ede972e" + "wasm32": "bbc45acd9b4faee06b25096af3fc97e801b519070df705e254e0dbc8335a1e50", + "wasm64": "86d5bad5ee12966287266a17448ee6c1aff5bff2cbfe13f10ac068a24163bf4c" } }, "mariadb-vfs": { "manifestSha256": "26e74ac84d89b2a839ed72783437061a23022ef2f88ad0f52b6aacd0442ee1cf", "cacheKeys": { - "wasm32": "5cf6491aead72e3a179b3a052059d91022f88614a297a95cff25373742397bb7", - "wasm64": "d3797f03263fe43e7766405c957c37e15c877912546353f3f4d19913db9bc07b" + "wasm32": "d7d550a16586c909f4d390dde0a2ed8fe51a93c343d1808fae9af8ac12d86407", + "wasm64": "37af6dba327533cb30baf70ea0bbe80855be2b2392e75b102d3f1d61ce1c7ceb" } }, "modeset": { "manifestSha256": "36a8683c1c7309701d361c5f77e543a6c1531397b26c72dbb864528c59c42954", "cacheKeys": { - "wasm32": "1d1f953607dc5796583d5284761f37e241bc860d9d874baa4eeb1493b908df6d", - "wasm64": "06f0007dcae2ebbc20b9a5a99235f1bbb7c0d73cac2efe5e39277b12ff5260e0" + "wasm32": "724e416a7663e8784edb7a0ea03d0c7534f3658dcce344b2dd019b17fc5e81d0", + "wasm64": "b96f08c807fa72b56a2cb26de850bf416589f30ec929959786d2d153cb4a9903" } }, "msmtpd": { "manifestSha256": "686ff665b5e7907e67618790b1a3eddce2ff47ed665595d17209bdcda1e0b274", "cacheKeys": { - "wasm32": "554ed9b04dcac3ed56656590be2baadc303cd9494f8742fcd6ab3e9323faf62a", - "wasm64": "bb692d67407811d14ab3eab7cdb81c8c71205a24d79da3ed8fdc4ea34906e441" + "wasm32": "f1b48a8a5e1fae2548dd48bae15f7b96b56689cd8429f3f57db762fb0118838d", + "wasm64": "e451bcf8e40e87fd1285fd62702a8c8f28468b4c2d12dc5b88f5c42570cc1c1f" } }, "nano": { "manifestSha256": "c5a52f0c37e08b475bb815367b1f0d63d0d2b06649d99fe9f461b72d0b9cff51", "cacheKeys": { - "wasm32": "25fe208fc4bfc8234d5145b76ddcdded2a1a17f16ff74886582a1c99b36807b2", - "wasm64": "8c5f3c178a45853409f1b5bf9794f98e3a99b1ff2af8a0835575ce8de5d9e366" + "wasm32": "83c0fa922e329fd32b693ed71cd0b52ef1154517deeccb9bd2ea3bb664cf20c1", + "wasm64": "b89dc4c443cfc9f6d9696ebe9d74d3f249c8216867e75e3d75f6067fd8f25124" } }, "ncurses": { "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", "cacheKeys": { - "wasm32": "cf48512ef35d9852f274d38266ba81c4eb39033027e1628712bd4318557a1b79", - "wasm64": "edf239ce74a0d847642d957410642beb3536363a22301bd6bea7f470130a39e0" + "wasm32": "f06bbb1f53f43c18c3133a6ef7601df696b8968104f312fc5b278b32f0097de8", + "wasm64": "b6a39c96dc1adf398368355a24ff57a14f3bf47138c7603af4e3a52808a2438c" } }, "netcat": { "manifestSha256": "4cf33cd1ab768b3ad0108da8b68c2ff72e98469cd86a0bf2d0da54a7d4f6fa16", "cacheKeys": { - "wasm32": "baea699ba5ffc02066335a2eba35fab1f0208a111c40a67bcbc7841a7a83b543", - "wasm64": "358cae24ff7d41509e1e9d5500b5b8e30544e2caf8b90e1c687098c2bb196740" + "wasm32": "fdd999a93a4909ab5ad9b3b77b8ccd70b1347ac59833de449c6b016388820f66", + "wasm64": "509b3a3e9e46b89aebd34314fdad6b59efff552017dab00ced8174c8894b504d" } }, "nethack": { "manifestSha256": "1a2f12ec2770bd8d40006d6c878a3493b97c71c2bb63ad08c7f6ad99bfd53504", "cacheKeys": { - "wasm32": "8b16339b4630bf4fabc8507793973453fc8dc0d3e82db9f16f513465c066659b", - "wasm64": "65ccd517bed1b4b47cebec0b52f0f684d63ee6e1cd9a753637b2cc8924b32885" + "wasm32": "1e30dfc4bdef94f60c18ebb30d6fbc38bdad557781a752d42e3d521d64e8d3e8", + "wasm64": "07416e4d189bc79cedbb1b9480f8582873e0fe1b10d6cde96ef0ce0e29b2c993" } }, "nethack-browser-bundle": { "manifestSha256": "b8294981da7ce4299dfb271d4aecb90ababa97a4d1acc9b716ae05bbe52beb53", "cacheKeys": { - "wasm32": "59ab4de06527df89271655257375163b3d571099fa077e419c967b4f653d732c", - "wasm64": "0f2157c456228c1a0e53098e2e7ca5b0b49e23c47395035b2035cc58aa29cb98" + "wasm32": "ed6ec18198a30ff239a0210d0ecf41b3b593571b714e9e91552b9c9200744c10", + "wasm64": "588b5cb44a1acf73a5e9dfdf923a5ecd6f6b0ab55af9bebbbec39e35fec7b6cb" } }, "nginx": { "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", "cacheKeys": { - "wasm32": "a477b0911328bdd36d79e2339ccad2b67d23c69589fd622af20d8afa5af77df4", - "wasm64": "1ee6b0039a8ccc1ab919dd2d573ef9a5145c565f377d2d6427e9ffb75d208de3" + "wasm32": "822af6438b8b472f4bf70a8d9d78fb2fe65980da303f2ff7034fb5ed67ccfbe3", + "wasm64": "0e3de299efeb149cc33fb7220fa1d12bd8a9fbc69a90f233a4ceb24b350ce534" } }, "nginx-php-vfs": { "manifestSha256": "97976410cb02f8ba710d856b4ac904bcf976d677b50eefdee39fb64176070d4b", "cacheKeys": { - "wasm32": "79e5ef3f9123c515d69600d9293dfdba3394b72aabc39cd4a346e5163365f913", - "wasm64": "e7736b7d04bfcd4062dd68fd5ea4a495c18dccc37132bff07cccf4b5310b1497" + "wasm32": "86777f0074c0653dae5428aef542dd43a1ef51c29a36b1e3d6586ae735c79572", + "wasm64": "e1c08b1b6ad71a0caa683b03037c0e2a25a8edccd5645a8d122b00ce03fa01b4" } }, "nginx-vfs": { "manifestSha256": "46aa2d3250ac5cd0f102a85c3a36a086c7a50ba1f9e05fa1c2aae3d07ad16f10", "cacheKeys": { - "wasm32": "ed0e8b6e71d96ca9ddd55e19983691efc56e50ba975e19147b2f4feef8067eee", - "wasm64": "3c7dcd2a932e0dbcd93e3d1f6643600ce0347b473fbb9463dc32a2e12892814f" + "wasm32": "6e8f0c4ae304f7efdcb621400bfb2b3c3a77655a7e2e20ea3b5e7136f05825bb", + "wasm64": "7f9ef2a48864bd8d8ab4f2b82fcba4819df15ae73ea859e16cae437992b2f454" } }, "node": { "manifestSha256": "2131a24dfda8587860e57fc65b573086477d376b065b3b9ca4e1dfe4d79f5790", "cacheKeys": { - "wasm32": "031b17aaa70fd9ad18f6b3414562564f33e88c53caa6dd71b9e85c2f2d326d4c", - "wasm64": "96bd073513e9f553f4736b7eb7919fd3bd67d4eea96d5cf88cebb1cc9f130f93" + "wasm32": "50887911c742eb0685693c9f2fa6aa959cd3191b27ca070233591589bef6d3a8", + "wasm64": "5173b798b9d6befb4e2197bcae3fd86642189fc58b5f36709e591cd8f077a21a" } }, "node-vfs": { "manifestSha256": "977f219defe57e18017ecc963159df32efdd21c53d6fea05943703d04739d8de", "cacheKeys": { - "wasm32": "705745bad6d92e50cddfc43b985d1095eee4d9069756ce75f65afdaa12b91e0b", - "wasm64": "92fc0ec544efabd9c8c9711e03befed7d283855077ef106eacdcfd1c1a594573" + "wasm32": "1067b082af6cd61c61a6093e01f8bbd0456a6cc9f8f5bff00691e7109b977e54", + "wasm64": "4f38681c31dd145e846e1a4900d5fcf2fc325e1a0e144cce22e67838ae821c61" } }, "openssl": { "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", "cacheKeys": { - "wasm32": "005e9cd3956f26bf2c6e97610ed4c0ca4ee21eaa0d4e59a710f861a78bb7020d", - "wasm64": "b0f46897a88d766840c6b87c376892370a6b37fc80bc4bd8eaeb6a8f15db5c33" + "wasm32": "211f5e6ea83462c8ebaf8a1a923b5f7f258143f6cfd762fc92b9ed23d615c339", + "wasm64": "5096a5a441634cc206720cfda9675211f52a2b49749959ee49bfb16c1fc4b2f7" } }, "pcre2-source": { @@ -354,225 +354,225 @@ "perl": { "manifestSha256": "6cdc4dbc54d0e4008cff41f82ec8918c0e44b4aef23903539c1bc0f538fe3ad2", "cacheKeys": { - "wasm32": "da7287e1ed6d2f9f39a2cc049a65f5fc76c00959ce7f8b9402f1a22ff8510276", - "wasm64": "9998b665864a44669f8710bba56a71d1a278036bad1e94aba6b8f56549b068b5" + "wasm32": "a6112b228ccbaf2087e4ac7bff8488fbe3b551a04366160edbe4f651fb1a520a", + "wasm64": "17960df83153d1491f31519744b5d521a1d05e16f9e605dbdc963d89c6ec015c" } }, "perl-vfs": { "manifestSha256": "1345cc102bc8c24a6bc276410c00b4fcc99ba5f6adc9b031f882aaa35d53c8bc", "cacheKeys": { - "wasm32": "0a9e90fbfc9c3b971ad2b035752019674b5a15467737871015887a4787384ab8", - "wasm64": "3a9616e0830c2ed1cfdb984928cddb7e2a4903cdbcf8c0ad755032703065bb8a" + "wasm32": "96848b01e749b6c9b8ba408a21ccec44e71fb6dfe38f359298ac09e12e74c4b1", + "wasm64": "2e337e7fc6596d0756c71beb0d18448eeba124908f6317d6ed67cb6c19926002" } }, "php": { "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", "cacheKeys": { - "wasm32": "98ecceaf50e4b0dbc10292b49b3c8ee2009e6ba965449123851818c123b9427b", - "wasm64": "64e753c28c8a134d66009f41b3e307d3c4342a1e5470ba30be074c50ed382de1" + "wasm32": "59f6df87ab85d47ee4c6b2a865d2180c1e62a960d782a4af60bdbba0af4e39a7", + "wasm64": "23e7a2010f78eddfc4884706ee21cbe73b750c57dcb7ef2f9a1f02f9f3b8eaef" } }, "posix-utils-lite": { "manifestSha256": "8fd7190b2848ef80143adc7b9268c79e13cd4db3430f7105faf49a4b4407a06f", "cacheKeys": { - "wasm32": "a195d1db14bc727860c53d0cd87843aa52a3842fad036180c75c6e19173b7624", - "wasm64": "5157b1a7623120f3b7ccaa0fd31aa5032daab462a29f0ab3b438d8550aa3db98" + "wasm32": "ac05dfca7fad4d6f4d8f371b85940f0c98063b581e18ee48af5609d3f41e20b4", + "wasm64": "af865775d5afa3c2a3c0d3ecfb674ad7553e8ff6fffde9a1bc51d64216269f83" } }, "python-vfs": { "manifestSha256": "d1aa99feae65f06c0ca8cf52c2176534b2723fd4ee6eba6e72c205245e1c9c26", "cacheKeys": { - "wasm32": "a2382e09282fc05f23e94a83fbdcb0595a094ce09d7a93f9fa8e2ae1bb6b006f", - "wasm64": "6dc471b14bea588f938b7264e2be5e27ee70bbf0ad0c90ac2016db88b7453093" + "wasm32": "de1aca82272b1914a5edc5f91c9e2a56a4100f6c29f4060d59407d97a8b0a26c", + "wasm64": "959d0f0b9ae8dfc2dc9769b74d990ba20ad1176fe25941c710bfeac316b28c8e" } }, "redis": { "manifestSha256": "151fb507de953ba2ba94b8f881bc7d66b4c741c1359a2f590cc07f9a4cd368a3", "cacheKeys": { - "wasm32": "fdb4a0416b46db4d5999696fe921a12e434b951312e29530d8b03dda03152eb7", - "wasm64": "16a1a5a147f3b315430ec165af110b0a49a0fde68587d901f32645fb0d157188" + "wasm32": "4cdaefdda2931b6718e8113640ab250948f1c639bf726615e800888722708b7b", + "wasm64": "bb50b6d656ed7dfba7c7169376c1b89193efe979b9d1e75195f5ca3f96053dc1" } }, "redis-vfs": { "manifestSha256": "234c45c40f94e2a89f98295313b82cb9fce15a412ac829d9e87394e0dc731813", "cacheKeys": { - "wasm32": "4b1a029a8da49d4b8ca43d63de06b10331ecdba4cfc1a16ed8a225824b54ce0f", - "wasm64": "fa0d8a5aed9ecf602d831fdbedf5c3768a22799eddb718fe2da515515f5bae88" + "wasm32": "4680323ca23aa4326807e09ab823e6b10e2cd4f387afc0319c41626a57cfb6a4", + "wasm64": "4dc534c9452d826ce1a1e834c334c278fda07c3b56901b76a2d13919a498507e" } }, "rootfs": { "manifestSha256": "0420201025170f48c32949ac62707d4577cdc13a53ad1f01f59d318ec07c8d6e", "cacheKeys": { - "wasm32": "639d2ea627f402d23bde67e0c370130ec1dac0ddea55af87b01b540a81072c34", - "wasm64": "e0a756b37a30db6245d6abc720c86c992c349918ff6fded752b8406c47063afa" + "wasm32": "de01588a7a5ab85427406f8770382dc9a093908609e31307fb81057186fe5eaf", + "wasm64": "5efaa5e7977d68c18805f19fde44996d42c1acabbb29a6827f0e302d0a0cf5ae" } }, "ruby": { "manifestSha256": "6ea67a246c29cd927a19decbb36ae4c4d478ff6642d2c77e08a6b2cfeb8251d2", "cacheKeys": { - "wasm32": "9c89b8e95447c8d17c4ca6c06b712f46b7fa9c4812bbcb637aa44b913092e80d", - "wasm64": "fe54cafbe963da1c1f41c6b80b62b3bfe1fca1abb2420f7c54aa115f2c39238b" + "wasm32": "316f9e58680969175df40915adae5f86a7b973f0ff448b077f45ab7e3210e3d2", + "wasm64": "55f59e59aee2ccf2fcce992b117b6fa6d54af37f62b311313f279464ed245e18" } }, "sdl-dsp-test": { "manifestSha256": "a988bef0b27403846a675965d951a286245fa79e84c209657d70a9a1200e8037", "cacheKeys": { - "wasm32": "422c6bfdf0bac96e006cd7d0f130cd1a4857be5b124a64965ede999f611c8542", - "wasm64": "5f4a100a587de94bcf503af58b4f4b1db40b6675eaa96eb089b162e26fb9c50a" + "wasm32": "42bb75d49df61508014684ce910aad8d16f76f5445bcab7b4191c19775370901", + "wasm64": "131aac533d7f227522859572f88940bab07fa8665ae6506cc0efb0e33788d124" } }, "sdl2": { "manifestSha256": "327ce0acca768f51e36ddab8ab58fd57b24cfd9e40722e818103c439f7263dd7", "cacheKeys": { - "wasm32": "9cc6d3328ea4fb6848530e0e9a8cb5c30a8bd03a356fda29d6b46a91e37a58e8", - "wasm64": "6145d6354b9f03396cfb821b111c8f84ec4829d79cb5c50687299f7d7bcb3340" + "wasm32": "f413896eee1d3f51953b367378203e187b868b6f4e311d7203bfa76525c70e12", + "wasm64": "8ec86d41c0f8f0b6572265c8a1deb809eb70fdb75cfdbe784de11058c536cdcc" } }, "sdl2-mixer-playwave": { "manifestSha256": "5ff3863e9f83cb9ad62931e067ee6e417826e06391862d9d0a58d6cc6b4dc570", "cacheKeys": { - "wasm32": "d9dbaaebde392a70f307fb54bdf7cbe069c42661f7b04cad8e9c58dfe89cbc1d", - "wasm64": "0c941862580abdeed64bb435c5f8c49d0c8d987739b75b1f1c259baa81eb396e" + "wasm32": "fad45b0fcb2c48a2711c4e6660fee5bd54e1622be0ee3203fae476f61ad64dca", + "wasm64": "3bddb72684e88d42be00a7aa19e58a0827b8d5f104949cc7cbc693779da4566e" } }, "sdl3": { "manifestSha256": "3b7c64605b941e14d336b1a127d8219c50402dece009e8855297a0a0696bd67e", "cacheKeys": { - "wasm32": "93bdeb1ecdb6e8d5e9e4bc0d14969ab3b4b6ece04481ef464f9050d08ef4036e", - "wasm64": "1afe335682a886843c5c532498ba255953a3beb7e7b69675830872c8275de969" + "wasm32": "07e104fa92d244f53779ce5d3fe29e98682f7bda9f6d5686dee6a5715f07a2de", + "wasm64": "2199c883301f2cdf43b73f1c1900f427f051c86f760dd76962023e30a3ffb1e4" } }, "sed": { "manifestSha256": "0c0d82d53907c19825941501f833252cf9adf35ebcebf6786baae28e5eb6958b", "cacheKeys": { - "wasm32": "2d8f0e8bafe76521572934b36ab8fc1eb327eac5492c1d96d18fee3b1df926a3", - "wasm64": "0ae1c38b1a3ff0877eaf2be24e9141f330acb24c6968bd145a01345221345b17" + "wasm32": "a14e7eacff9d49b35562c39ddaf2d0e5b6379fc5b834477897456198e7256b31", + "wasm64": "e9c22261695508c9e670b75722ab0a70884429ffda4ddc292b5ee652a0021b38" } }, "shell": { "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", "cacheKeys": { - "wasm32": "924040509cd02cedd551f4afa6568090fcd0bed488d44674c0c1802d33b3e6a4", - "wasm64": "c06b88d3049b3a6a3e122b191f7c9bb325464591267561e249f89c9526794cae" + "wasm32": "2e11b63640012671fb4144219d84c0606f2d4297154ad95ca2ab5607ed017f97", + "wasm64": "f0eee29c90ba0498c3ba4e5da932fe0325bf7738f6c86fe5d14690d876bdf2e7" } }, "spidermonkey": { "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", "cacheKeys": { - "wasm32": "997c17703209773768eee15f64449151904b0f89824ebec9649db902126a9573", - "wasm64": "91aa4f76c95a3bb36883858d68fabd2737589def3de6c1f07749ed0e1288756c" + "wasm32": "f2ea5982b8a18d9d49b9984275d6efc463491af28835b19be94c2ac30b903280", + "wasm64": "1a59d4df56e51309b65aadebdb519c7feea639ce0f1f77fbfc60b95b597ea805" } }, "spidermonkey-node": { "manifestSha256": "f156c4c5044d2a9447324e7a2a35f8c3e6fa367b3e44f674dcc30ab6b946fae7", "cacheKeys": { - "wasm32": "ffc7eecad0fb134fb01e8494621a16206d0b698828afe6c1c67cbdbbcfefbd7a", - "wasm64": "ce89980ddb57a50c6bf321341d97ed517472c59424ecbb0920bb5a8ac8221e7d" + "wasm32": "c5f5efbb3d10b7f4c0c9193b5dce57f6ba6d4d24bf3eb03a477da6d2d4fb494a", + "wasm64": "63027e555a45c4243f3fd1592363ab0f13900d375032636292075deed427f754" } }, "sqlite": { "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", "cacheKeys": { - "wasm32": "6e44f4ec127c258c4185142295492ab790cca757db35a284762a95668e0862e9", - "wasm64": "a8e0c763bdbedf8783b8c3a82942d73ea1e22234d23c2e95704c36b79862a81d" + "wasm32": "78240f0d6f2c12d2692cb6a400e8fb612fa39a5e4fc93121f3bf4654b2f50626", + "wasm64": "cf56c32ca4ca3fe55841d7adb0554d265d4e531ff15c887320b6e94ffbb8acc9" } }, "sqlite-cli": { "manifestSha256": "1cebb768f9473dc58fc6e72c9b24565760aedaec4cbdb29ad43b22a0a5fd7030", "cacheKeys": { - "wasm32": "8203b8f73e96deeefbe72c5eb9342cbd62b03fc31a4f3906cfbd4452b05c4c38", - "wasm64": "fa5ed8f2d10305310a4670d06fefcb10a00441379bb4faf016016bf7bdb3fe08" + "wasm32": "7a1fa56532b582ec826f17801b12503241bbeca421d5a9797b4dd1be0f8252d6", + "wasm64": "9d2bc4c4f855460b04b4351182d77fdf392cf78475e385bd69ccf4541bbcf0c3" } }, "tar": { "manifestSha256": "4502355c246362a2035e52aa7b35d274882fad4b4404c03470458baee723ae68", "cacheKeys": { - "wasm32": "e0e058c4333c4c94b7ed1fb0da398e3e874998c8bf1b3249f84417047a312db7", - "wasm64": "efb9b87404f3c7cf720b67f3ef5bd5247abe746584e328cf594135e0869775b8" + "wasm32": "ce34f76de356c21cf2ef66200523204ff08dc65cdb402fd7ec08ae90b3765c7f", + "wasm64": "3f7d5785aeb76d3809d7966135bac1e8b66f2fe69b881a396a579771246745ca" } }, "tcl": { "manifestSha256": "b98f237f741b11f5f5da55db75530b421755ba4c8a88d314553106faa1cca1ff", "cacheKeys": { - "wasm32": "fe826385f0bd64f30c93c32bb93cbbb8948a74cf83fd137596c1f5df002ece70", - "wasm64": "f5a911b2527fb245cdfe5570020f3526e5dedabcced69e0e03ca6a003bfbc2f9" + "wasm32": "66ef9fb3d9dd4c9198b9e07a033775c9a182edb71aadd3e94b286b1b78d73a69", + "wasm64": "7b201599ee875e7268ef3be225b3758e9b2f105d58a43e4c4076e7eee8671100" } }, "texlive": { "manifestSha256": "028ada023f8a8f9966c8a28575242a339aaaf0e8870fe4a221986133b381c3c0", "cacheKeys": { - "wasm32": "86434df9283dee6425eb388b6229fd19a8f9624bef425d5a9b6e37082636cf2a", - "wasm64": "43f26b79acb71939b06a9b48a7cfe5ef3d1c870f84e92e26beeb3dbfa288abb7" + "wasm32": "3ca8dda209b7161a58f5d9f2eee193b48cc8ba8582cb6dfa9a131a5461fe515b", + "wasm64": "9072edb577ffcaecdbd8a2fb526c5eea596211bd078acc55e1182286f6413abe" } }, "unzip": { "manifestSha256": "ef4e5e34258827ab3cbc1930da3fd9514f08f2d7d56c7c3e0d5c495a4d3a20e9", "cacheKeys": { - "wasm32": "e6f3c139b81f8398c9083a88c64c541b2d50aa59bdf1eb3795dbc8100c477279", - "wasm64": "e995d0c6a7bd88b2eaf564ccdbe1322010e2cb4d2a56df0d31564d2a94787c7f" + "wasm32": "ae1b008f8b3a9465906cf3fd4050b44d0a060d955117ceee2351a813d4b1fc23", + "wasm64": "e442d519d00f7a37e3366e6fc09c83f92e2526cbbcde63972dcdc2c1e9930aa0" } }, "userspace": { "manifestSha256": "221176f2a096dee19bbefd89da5c7f50138ecd92d37ec9d7d6b35dbb25e86924", "cacheKeys": { - "wasm32": "e83868a1ddf56dd5c1a92f8439208407fba632a8738c1fffb73533cd4b6f36d9", - "wasm64": "9ea28d54c0efb58e1b60b16daf99176e8f843f651ec7d6bfba0e4b01285ced3b" + "wasm32": "bdc4fad1b44eca4e005722be05b532efc78dd7ad7419b616932e122ce5d07f3c", + "wasm64": "396ea2661a47c3f06091531f94908710b4b75a96cfc3ca5da9f3814ac40ec670" } }, "vim": { "manifestSha256": "c211660ef41d01f95f3fbdf675b74891e897e3f304e04f1bf5586f326007382c", "cacheKeys": { - "wasm32": "a2ebb59c3b576c0eae37312c39711e3428cea60156614afbdb6c9de1540166eb", - "wasm64": "e922cd27cbd52e3bff06407d1a8c3eda10f439933911d0b5fff58de5bf4a0780" + "wasm32": "8ff4d7d725da72ba25b42a5406b5fd258aab0f1f572cfdd956faa2dd7fc7cc4d", + "wasm64": "3a14a22941aef19134c01104ca125dc0bea0baedb57b4fc59e8256476f3ff40e" } }, "vim-browser-bundle": { "manifestSha256": "e31d84057db49c3534381604d0c8f3c7d765e33ede560a7ea7b01a5a8f1aa0d3", "cacheKeys": { - "wasm32": "b4d85b2f95d7c6ec98fa9efe64aafa5238d9fdbce7eede2c93ae16b4c042d1d6", - "wasm64": "222251531fb46c29631c5a6e047ef9ac0a584655ff14335a8628de4acba085bd" + "wasm32": "c91715e0ee7db72fa1a3db567aa595979f5b6c404c7a7b7e8307e08e24d90de7", + "wasm64": "4b898cd5047af22b84e008f4ed29b8c889471cbb5f0b665d07b77d0fd106ad6e" } }, "wget": { "manifestSha256": "61420b6cb1be12471f425b0aebf5aec58c49d0b43d939dd54508b305accbf54e", "cacheKeys": { - "wasm32": "b9ca0d845774d9bc9421298065f0a0ac9c42bd5b31b52688d0ab35278c2adc68", - "wasm64": "89ecd15c2d8799d0cd695c0aadab8900d4ba90fac9fbe67bd22fbcc23c1d5541" + "wasm32": "fdd1243bbc56415f1c5f121ddc36efded472976189598aa5de8364703c971e0a", + "wasm64": "e9471812af87c22a5c15e8e336c10be24767ba84eeaa8d003f7097fca50d04d1" } }, "wordpress": { "manifestSha256": "36465e0596a06e855a8524eecfd87da98643bc13b37ee12939f5aab44bf33418", "cacheKeys": { - "wasm32": "4c18d0e78186915f6ea6e22e2ef769af3bb413a4b33220dd064aef10fc5a316a", - "wasm64": "5c4f162dbf381cf844642513cf9cbd6b57cbbb360788ff930f7e5aacf9f6ddfc" + "wasm32": "b8079a2b01cebaf4dbc26c6e9a6eedae258368d95cd756ff87b21bed377a56a7", + "wasm64": "8399ec1907394e97bf0d495c810a8680a2c69f293c3d89b1dd696feaf981f322" } }, "xz": { "manifestSha256": "8707d35b8647b1af8fa165dcb6ce7b2a6a007e19ab2daca2680c39395864a737", "cacheKeys": { - "wasm32": "105b5d372e4e3d6f1cc9bf2871f5a6d082336a0fbfbd044c536d6466b28541f5", - "wasm64": "c59b2ba3e89288ee89d6849ee42eed006a26fbe9834ce92851fd85794b9b5b97" + "wasm32": "44c91c6e853b02f601c64d93c2ea42c35b3c29ac424d606bc7e04cb39d26b54c", + "wasm64": "3075a574412c94412339326ade1b950e9e47fb11bfb7e30f05c5b3858f6f850d" } }, "zip": { "manifestSha256": "9c9cfa8220aaeaea26736b55f7cd0a7517b8853c07f9f0d241ad67dd43592e36", "cacheKeys": { - "wasm32": "de42ac678212b2313fc54c1c583877e1f69573e850f5634fc296e1985375459a", - "wasm64": "0abc6bb4d733cb454f14d4723bbea16d54830edf516c861515ff4cfda52765f5" + "wasm32": "c6f82bac695685734075f1e1a4909623a9be28d56881c7c7657d374afdbd5f93", + "wasm64": "a2312f92ee777bdc753061ca5247f2a81f7eb991433e51645ae7645b43fcf1cc" } }, "zlib": { "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", "cacheKeys": { - "wasm32": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b", - "wasm64": "68698763f0a75d77f9a6ed7b910cb84f424009940680e99718c83423c474751e" + "wasm32": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea", + "wasm64": "10f1183001a8d58b482d5a57681a88bc329c25151b89f6c2b8c26432f3b167bf" } }, "zstd": { "manifestSha256": "89f266938c2c253700a838e6352c9de66c976c84883325aaa979f72b732357e4", "cacheKeys": { - "wasm32": "b1f13d42e6ad8b54bb66c31ad8614bfd4f8f7989c4cd840131399dce1a15354f", - "wasm64": "28cd00493fe37461154d98877e576caa7d4de021edeba83eca9beafb493b0b86" + "wasm32": "9a68ab2d994c674006d1f50d4ad725f438599feea6b025b4b9d346932ad85dab", + "wasm64": "9c2cccf147b64f249d3b82c77752d463e4012a9dff870093591cce19d32eaaf5" } } }, @@ -583,14 +583,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f72620b103f7606cb3c9e2550ea68dc7c35c6bfbc69b217bb10d906901ec1c6c" + "wasm32": "43fd97bde16c9d5bc3bbc97f26e8e4cb25a1f95dbc6deb6a5909a0f5e124b6ff" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "cf48512ef35d9852f274d38266ba81c4eb39033027e1628712bd4318557a1b79" + "cacheKey": "f06bbb1f53f43c18c3133a6ef7601df696b8968104f312fc5b278b32f0097de8" } ] }, @@ -610,7 +610,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "43001c22570d4972d6143a04c7ba9103fc35a735c15070978f331353e1ed0108" + "wasm32": "bdbdfdd24e73b2abaf6a251c4f256d79140fcf429b7f017315be44d9238da3eb" }, "dependencyClosures": { "wasm32": [] @@ -631,7 +631,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "aa390b7415063fe2316048755a2c515c82840564e6c9ef5d0186be27acdec409" + "wasm32": "b95c9827ead7a75115450673b3d20e39c4e8cbbddbc29af1e423f318cdf0db44" }, "dependencyClosures": { "wasm32": [] @@ -652,7 +652,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "4583435763207ddfd63478fc7b3a263fdc6fae110bcd38109e1e1c26e621ad7f" + "wasm32": "770b6ceaf5e508b0a9fff5798f131fd67dd9864054dc2cf6acf89836eabd85a1" }, "dependencyClosures": { "wasm32": [] @@ -673,14 +673,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a2e894f110205a0cf42bc02a56a31156a82fad0c291595a508bdb7bd09e7a6c7" + "wasm32": "a101343c9081af62dfe5b0b3c9d981926b458240c641edc80d7d4a4a5816adb8" }, "dependencyClosures": { "wasm32": [ { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b" + "cacheKey": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea" } ] }, @@ -707,19 +707,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2e4132091d535e4afe6ef5ca6e0952c3f459330736c0a7eb6125b28bd1f45369" + "wasm32": "87f6acaf54efa8a8207f09679519a5b35efdaece28eb33c0ff866796e16eca0e" }, "dependencyClosures": { "wasm32": [ { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "005e9cd3956f26bf2c6e97610ed4c0ca4ee21eaa0d4e59a710f861a78bb7020d" + "cacheKey": "211f5e6ea83462c8ebaf8a1a923b5f7f258143f6cfd762fc92b9ed23d615c339" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b" + "cacheKey": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea" } ] }, @@ -739,7 +739,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c4b4345aa3f9011d0b214ad6adaa6c7fbe5eb2f6bbd0e5181b8f4be68f437bf3" + "wasm32": "e7a48fe30c5509f8152a20c11da66e056f5b07c343fb1ae06f64884bc39cdbad" }, "dependencyClosures": { "wasm32": [] @@ -760,7 +760,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "d48b0feda4af14e2d08a803611d0b825ac51e9a9379bdb8b49e1170eaf75e5d0" + "wasm32": "55ee5fd0c56cacbc3d21752feeba5e8628fb4dfe670eafd3bb14dae4959a3c10" }, "dependencyClosures": { "wasm32": [] @@ -802,14 +802,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "543e082567cc0502609bf0cdef6eb1ba431cef97d798136e739c915f8392615a" + "wasm32": "c81e0b1b714646718033c1b2b43293d3d087d502abae5cfe82e6933dd8fe20f9" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" + "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" } ] }, @@ -843,7 +843,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "9f4b0bfcf465fb5a83d04186672e5ca6b9e40f33db4f526e6438f0939bf9c8d2" + "wasm32": "d1e404a58baa024394755a8f250d0fc8de3d5384e252b4e12cac7bbe0e1ad04d" }, "dependencyClosures": { "wasm32": [] @@ -871,14 +871,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0b302ed848f5596a2c80569a6ee3b6426ae9778a5f0e15a824ee57c609960311" + "wasm32": "fa6f701bd5445a7808e7c443c938bf8504548351495dec5594a0d7e6e2d7be39" }, "dependencyClosures": { "wasm32": [ { "packageName": "erlang", "manifestSha256": "475d6037e73a0f1e2f4381067194b2422751206979ea17209e10efa5a2a93bbb", - "cacheKey": "9f4b0bfcf465fb5a83d04186672e5ca6b9e40f33db4f526e6438f0939bf9c8d2" + "cacheKey": "d1e404a58baa024394755a8f250d0fc8de3d5384e252b4e12cac7bbe0e1ad04d" } ] }, @@ -898,7 +898,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "816074a21b019d5a52c7a676123b93ebbb0e841cab60a3c7553773520284733f" + "wasm32": "9e2b644abb9aab6062b69a84d4f9d864df9dbc5ddd099392cea29b16bd40b924" }, "dependencyClosures": { "wasm32": [] @@ -919,7 +919,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "55d93c1fd59d890d6f78346197a6d474c654fc2a6687e1ff6e1ffb1ae09ce377" + "wasm32": "357a02fbd427f35255e349b6243f23b662fdb2608e09d6a08a040c6ae0a07cc3" }, "dependencyClosures": { "wasm32": [] @@ -947,7 +947,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b866449fe86205186c0537b6992df7747c852f0e07fde1da6f9d0a7b5808b152" + "wasm32": "ff88da1019fb2d3b56399f9450baa23cdb5069458ea99dca1f3d2da19eee1501" }, "dependencyClosures": { "wasm32": [] @@ -975,7 +975,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "13a6253127ab0250d8dc39d51979aae88a0004cb5a268493cd332eaf564546d6" + "wasm32": "0878a1253d8ed63fe64073b28351207fbe01dd12b6c166b7d6690e41164436c1" }, "dependencyClosures": { "wasm32": [] @@ -996,7 +996,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "d96bb46b733a7cfb83b17b6d53f36c35b1617a75cb6c5b929237d35bff327b31" + "wasm32": "248e67d35a337108e1e7fa1e3a468df2a94bbe5ba5e02e7ecef8d6b961a7076b" }, "dependencyClosures": { "wasm32": [] @@ -1024,7 +1024,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e75c15a6f6114b40248b6de7c3057b99b108dde867cda73c111e47c3acbb326f" + "wasm32": "e368d4eb26a528111c836689761ba645ad34d43aa21ae8c8420d16d1ace57da7" }, "dependencyClosures": { "wasm32": [] @@ -1045,7 +1045,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0332b476d7ec3d6fcebec49aadf3facae32a69fc004cebfc6e5c7f3a195561b0" + "wasm32": "c93bacf2b437fb73da6dd5cafa6ac00575dc7a130590ad6423d1a186d45468cf" }, "dependencyClosures": { "wasm32": [] @@ -1066,7 +1066,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ef0092c0b03589468a00976d8e5167f4a0a18260b8b61b2f7136263d480deaac" + "wasm32": "910d7ffed0e8cf7dc71d90c6d2300f53eaf266211b247b6e6a37bc302690322b" }, "dependencyClosures": { "wasm32": [] @@ -1094,14 +1094,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ce884b1760130a2cb478fbe03f5ec2f407339e5631bebc516daf97a7d80fe096" + "wasm32": "bc4a0e630b02b52dcefca5e807c712a7a1baf131e03b7ac2ffd9b56bcd425abd" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" + "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" } ] }, @@ -1121,64 +1121,64 @@ "wasm32" ], "cacheKeys": { - "wasm32": "9eafe9e962c351bcfe4fceaa2fbace1ad8b9c9c4a74b46fa2d6a80e7e4f6c3d9" + "wasm32": "7e80a42e9056453b9d755523e98a1d9fcd852d12bb96c534ab5d05da61716435" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "543e082567cc0502609bf0cdef6eb1ba431cef97d798136e739c915f8392615a" + "cacheKey": "c81e0b1b714646718033c1b2b43293d3d087d502abae5cfe82e6933dd8fe20f9" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "81677f4b7e2d40634555388d8d06757a052efcbb915bef1c6e57d444137848cd" + "cacheKey": "869e58f1e945c89086b28e50dda9cd001d6205ae51404149404cbadbd338a3ef" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "6ce2dd7a19b7f5af78edc02fc75d08bbeade7f41e968f8b8a3f6e7e5fe4b26ae" + "cacheKey": "13427d5971b26c2e3e2ee43756f88944e3b75385a4d13e6fd45dd63a6a7c9afe" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" + "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "8b132a257b9a02a3adccac9d5d6bedc00348a6ac92375401761312d7c46a2ecf" + "cacheKey": "e3b39e170e399c7c353bf41cf8340473dae7f378cd8ba8dbfd5fb9f4ef69f4c5" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "4873ada42645826762560ad1833364a60c630c6411f84a03456b171f279faf2b" + "cacheKey": "222b11ed5313da06ba2368741a2e4589f78f89beebd184edcb0559b20bddc3f5" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "32c0e36842b5f47e79c66b925601f3f30c4ac982303cd159ade0b6c5755e5b61" + "cacheKey": "e4cdb8156bdfcfd302a18db3d908898f57b3859a00b308bbefe3a0f9b85de483" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "a6fc124a4bc7562dd038f5e616c914837fc5882cd90f0199d469c0259d0a8b4c" + "cacheKey": "22fb165b69847120ce44c5d6a7aef7b3c7d2804448eefec7a56b35c40631623d" }, { "packageName": "msmtpd", "manifestSha256": "09de04a422ddf631a29a259d816e081f8d317d1077808ede55d8c500baae748f", - "cacheKey": "554ed9b04dcac3ed56656590be2baadc303cd9494f8742fcd6ab3e9323faf62a" + "cacheKey": "f1b48a8a5e1fae2548dd48bae15f7b96b56689cd8429f3f57db762fb0118838d" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "a477b0911328bdd36d79e2339ccad2b67d23c69589fd622af20d8afa5af77df4" + "cacheKey": "822af6438b8b472f4bf70a8d9d78fb2fe65980da303f2ff7034fb5ed67ccfbe3" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "005e9cd3956f26bf2c6e97610ed4c0ca4ee21eaa0d4e59a710f861a78bb7020d" + "cacheKey": "211f5e6ea83462c8ebaf8a1a923b5f7f258143f6cfd762fc92b9ed23d615c339" }, { "packageName": "pcre2-source", @@ -1188,22 +1188,22 @@ { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "98ecceaf50e4b0dbc10292b49b3c8ee2009e6ba965449123851818c123b9427b" + "cacheKey": "59f6df87ab85d47ee4c6b2a865d2180c1e62a960d782a4af60bdbba0af4e39a7" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "924040509cd02cedd551f4afa6568090fcd0bed488d44674c0c1802d33b3e6a4" + "cacheKey": "2e11b63640012671fb4144219d84c0606f2d4297154ad95ca2ab5607ed017f97" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "6e44f4ec127c258c4185142295492ab790cca757db35a284762a95668e0862e9" + "cacheKey": "78240f0d6f2c12d2692cb6a400e8fb612fa39a5e4fc93121f3bf4654b2f50626" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b" + "cacheKey": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea" } ] }, @@ -1223,7 +1223,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "180f11bd766f6c2482eb68dba1803d81bcfedc79ff0ff40a4fba1d11219d6dd9" + "wasm32": "d347d2f00c368bd2d9aef83d7dc57bb37a0c1af3ecc7f262310623a4c3538ec2" }, "dependencyClosures": { "wasm32": [] @@ -1244,7 +1244,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "6f7cbc2839c96bdc4569c4845fd0c4671e7596cfd36483deb4e4a05e5d48bccb" + "wasm32": "e262c9ac18046a4817a8fc25e5a356e814fec11a7f53b905f1718d4dffaca656" }, "dependencyClosures": { "wasm32": [] @@ -1265,7 +1265,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "8eb7d929c608eecfe55cffe87513a1d8b7b58c3dbe80f350ab9e0eb9ad7416a8" + "wasm32": "223de993ff0b49af372f84de543354c651a80ad0cc0d4a219b927f52fa522583" }, "dependencyClosures": { "wasm32": [] @@ -1286,7 +1286,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "dc7fce3e4dd128bbdd6a89356224db601bebd13df9c63548f5ab969a250dfcc5" + "wasm32": "7ecea3370409cc9f5f40c92beeb29b944a6bfa3aa93e63f1400abc5ddf6a7bbd" }, "dependencyClosures": { "wasm32": [] @@ -1308,15 +1308,15 @@ "wasm64" ], "cacheKeys": { - "wasm32": "a6fc124a4bc7562dd038f5e616c914837fc5882cd90f0199d469c0259d0a8b4c", - "wasm64": "8dca7f977ae361b01d67d1af6fa7e9847bb9499fd2fa746283dee757caf17837" + "wasm32": "22fb165b69847120ce44c5d6a7aef7b3c7d2804448eefec7a56b35c40631623d", + "wasm64": "5baf0991b29722ffd473317912e25525f5f9b463eceb3e942c52d6cbea183963" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" + "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" }, { "packageName": "pcre2-source", @@ -1328,7 +1328,7 @@ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "06a4c50ff62d8d2e638f341cf8ec0b1837b24a3d602a437759f0c2d0a8396821" + "cacheKey": "1799d410cef457fa39446d4d4a0a17fbd4311da2a21d65b9f0268275508b616c" }, { "packageName": "pcre2-source", @@ -1360,34 +1360,34 @@ "wasm32" ], "cacheKeys": { - "wasm32": "1a48ab9b3bb0e378eef2d34d5dcab2084db47cac9da6e63ea77044f8fab184bf" + "wasm32": "bbc45acd9b4faee06b25096af3fc97e801b519070df705e254e0dbc8335a1e50" }, "dependencyClosures": { "wasm32": [ { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "4583435763207ddfd63478fc7b3a263fdc6fae110bcd38109e1e1c26e621ad7f" + "cacheKey": "770b6ceaf5e508b0a9fff5798f131fd67dd9864054dc2cf6acf89836eabd85a1" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "c4b4345aa3f9011d0b214ad6adaa6c7fbe5eb2f6bbd0e5181b8f4be68f437bf3" + "cacheKey": "e7a48fe30c5509f8152a20c11da66e056f5b07c343fb1ae06f64884bc39cdbad" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "543e082567cc0502609bf0cdef6eb1ba431cef97d798136e739c915f8392615a" + "cacheKey": "c81e0b1b714646718033c1b2b43293d3d087d502abae5cfe82e6933dd8fe20f9" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" + "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "a6fc124a4bc7562dd038f5e616c914837fc5882cd90f0199d469c0259d0a8b4c" + "cacheKey": "22fb165b69847120ce44c5d6a7aef7b3c7d2804448eefec7a56b35c40631623d" }, { "packageName": "pcre2-source", @@ -1413,35 +1413,35 @@ "wasm64" ], "cacheKeys": { - "wasm32": "5cf6491aead72e3a179b3a052059d91022f88614a297a95cff25373742397bb7", - "wasm64": "d3797f03263fe43e7766405c957c37e15c877912546353f3f4d19913db9bc07b" + "wasm32": "d7d550a16586c909f4d390dde0a2ed8fe51a93c343d1808fae9af8ac12d86407", + "wasm64": "37af6dba327533cb30baf70ea0bbe80855be2b2392e75b102d3f1d61ce1c7ceb" }, "dependencyClosures": { "wasm32": [ { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "4583435763207ddfd63478fc7b3a263fdc6fae110bcd38109e1e1c26e621ad7f" + "cacheKey": "770b6ceaf5e508b0a9fff5798f131fd67dd9864054dc2cf6acf89836eabd85a1" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "c4b4345aa3f9011d0b214ad6adaa6c7fbe5eb2f6bbd0e5181b8f4be68f437bf3" + "cacheKey": "e7a48fe30c5509f8152a20c11da66e056f5b07c343fb1ae06f64884bc39cdbad" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "543e082567cc0502609bf0cdef6eb1ba431cef97d798136e739c915f8392615a" + "cacheKey": "c81e0b1b714646718033c1b2b43293d3d087d502abae5cfe82e6933dd8fe20f9" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" + "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "a6fc124a4bc7562dd038f5e616c914837fc5882cd90f0199d469c0259d0a8b4c" + "cacheKey": "22fb165b69847120ce44c5d6a7aef7b3c7d2804448eefec7a56b35c40631623d" }, { "packageName": "pcre2-source", @@ -1453,27 +1453,27 @@ { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "74f816ee53c308c9e94e737275bdb42a71ffd75b5331422f8c106333b7609dc5" + "cacheKey": "6e015433d5bcad2709a5374916dfd81156dcf34531046b579b8cb621f15b0b70" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "f37e09c31002de4be5c25d4f789b81e2ced708016032756a6cafe010a4537811" + "cacheKey": "7eea3d285157e4e5f16bd4a12323aa4c175efb49d9fb23cb1caad45c2b2850b8" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "872d4ca7596d100e781612e7e659bc840c74fdab8c0769746c48ff1d5ec478fb" + "cacheKey": "df5791013bc496b63584dbe766c735abb22fadc012fc23819bc339db9aa2461e" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "06a4c50ff62d8d2e638f341cf8ec0b1837b24a3d602a437759f0c2d0a8396821" + "cacheKey": "1799d410cef457fa39446d4d4a0a17fbd4311da2a21d65b9f0268275508b616c" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "8dca7f977ae361b01d67d1af6fa7e9847bb9499fd2fa746283dee757caf17837" + "cacheKey": "5baf0991b29722ffd473317912e25525f5f9b463eceb3e942c52d6cbea183963" }, { "packageName": "pcre2-source", @@ -1498,7 +1498,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "1d1f953607dc5796583d5284761f37e241bc860d9d874baa4eeb1493b908df6d" + "wasm32": "724e416a7663e8784edb7a0ea03d0c7534f3658dcce344b2dd019b17fc5e81d0" }, "dependencyClosures": { "wasm32": [] @@ -1519,7 +1519,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "554ed9b04dcac3ed56656590be2baadc303cd9494f8742fcd6ab3e9323faf62a" + "wasm32": "f1b48a8a5e1fae2548dd48bae15f7b96b56689cd8429f3f57db762fb0118838d" }, "dependencyClosures": { "wasm32": [] @@ -1540,7 +1540,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "25fe208fc4bfc8234d5145b76ddcdded2a1a17f16ff74886582a1c99b36807b2" + "wasm32": "83c0fa922e329fd32b693ed71cd0b52ef1154517deeccb9bd2ea3bb664cf20c1" }, "dependencyClosures": { "wasm32": [] @@ -1561,7 +1561,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "cf48512ef35d9852f274d38266ba81c4eb39033027e1628712bd4318557a1b79" + "wasm32": "f06bbb1f53f43c18c3133a6ef7601df696b8968104f312fc5b278b32f0097de8" }, "dependencyClosures": { "wasm32": [] @@ -1645,7 +1645,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "baea699ba5ffc02066335a2eba35fab1f0208a111c40a67bcbc7841a7a83b543" + "wasm32": "fdd999a93a4909ab5ad9b3b77b8ccd70b1347ac59833de449c6b016388820f66" }, "dependencyClosures": { "wasm32": [] @@ -1666,14 +1666,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "8b16339b4630bf4fabc8507793973453fc8dc0d3e82db9f16f513465c066659b" + "wasm32": "1e30dfc4bdef94f60c18ebb30d6fbc38bdad557781a752d42e3d521d64e8d3e8" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "cf48512ef35d9852f274d38266ba81c4eb39033027e1628712bd4318557a1b79" + "cacheKey": "f06bbb1f53f43c18c3133a6ef7601df696b8968104f312fc5b278b32f0097de8" } ] }, @@ -1693,19 +1693,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "59ab4de06527df89271655257375163b3d571099fa077e419c967b4f653d732c" + "wasm32": "ed6ec18198a30ff239a0210d0ecf41b3b593571b714e9e91552b9c9200744c10" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "cf48512ef35d9852f274d38266ba81c4eb39033027e1628712bd4318557a1b79" + "cacheKey": "f06bbb1f53f43c18c3133a6ef7601df696b8968104f312fc5b278b32f0097de8" }, { "packageName": "nethack", "manifestSha256": "1a2f12ec2770bd8d40006d6c878a3493b97c71c2bb63ad08c7f6ad99bfd53504", - "cacheKey": "8b16339b4630bf4fabc8507793973453fc8dc0d3e82db9f16f513465c066659b" + "cacheKey": "1e30dfc4bdef94f60c18ebb30d6fbc38bdad557781a752d42e3d521d64e8d3e8" } ] }, @@ -1725,7 +1725,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a477b0911328bdd36d79e2339ccad2b67d23c69589fd622af20d8afa5af77df4" + "wasm32": "822af6438b8b472f4bf70a8d9d78fb2fe65980da303f2ff7034fb5ed67ccfbe3" }, "dependencyClosures": { "wasm32": [] @@ -1746,79 +1746,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "79e5ef3f9123c515d69600d9293dfdba3394b72aabc39cd4a346e5163365f913" + "wasm32": "86777f0074c0653dae5428aef542dd43a1ef51c29a36b1e3d6586ae735c79572" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "543e082567cc0502609bf0cdef6eb1ba431cef97d798136e739c915f8392615a" + "cacheKey": "c81e0b1b714646718033c1b2b43293d3d087d502abae5cfe82e6933dd8fe20f9" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "81677f4b7e2d40634555388d8d06757a052efcbb915bef1c6e57d444137848cd" + "cacheKey": "869e58f1e945c89086b28e50dda9cd001d6205ae51404149404cbadbd338a3ef" }, { "packageName": "kernel", "manifestSha256": "db1d66db8575562ac7b3e720dbaa06d7dd5eef577d80220ea4167dc2d69b863b", - "cacheKey": "1272ecb067f8de873dec513e588c1f1c84186237ecdf69dd93fdb6849d8dea50" + "cacheKey": "96aa66030a19468af2592bc4b7ed6f917dc65d1d0471ed61831a20166b36226d" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "6ce2dd7a19b7f5af78edc02fc75d08bbeade7f41e968f8b8a3f6e7e5fe4b26ae" + "cacheKey": "13427d5971b26c2e3e2ee43756f88944e3b75385a4d13e6fd45dd63a6a7c9afe" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" + "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "8b132a257b9a02a3adccac9d5d6bedc00348a6ac92375401761312d7c46a2ecf" + "cacheKey": "e3b39e170e399c7c353bf41cf8340473dae7f378cd8ba8dbfd5fb9f4ef69f4c5" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "4873ada42645826762560ad1833364a60c630c6411f84a03456b171f279faf2b" + "cacheKey": "222b11ed5313da06ba2368741a2e4589f78f89beebd184edcb0559b20bddc3f5" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "32c0e36842b5f47e79c66b925601f3f30c4ac982303cd159ade0b6c5755e5b61" + "cacheKey": "e4cdb8156bdfcfd302a18db3d908898f57b3859a00b308bbefe3a0f9b85de483" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "a477b0911328bdd36d79e2339ccad2b67d23c69589fd622af20d8afa5af77df4" + "cacheKey": "822af6438b8b472f4bf70a8d9d78fb2fe65980da303f2ff7034fb5ed67ccfbe3" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "005e9cd3956f26bf2c6e97610ed4c0ca4ee21eaa0d4e59a710f861a78bb7020d" + "cacheKey": "211f5e6ea83462c8ebaf8a1a923b5f7f258143f6cfd762fc92b9ed23d615c339" }, { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "98ecceaf50e4b0dbc10292b49b3c8ee2009e6ba965449123851818c123b9427b" + "cacheKey": "59f6df87ab85d47ee4c6b2a865d2180c1e62a960d782a4af60bdbba0af4e39a7" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "924040509cd02cedd551f4afa6568090fcd0bed488d44674c0c1802d33b3e6a4" + "cacheKey": "2e11b63640012671fb4144219d84c0606f2d4297154ad95ca2ab5607ed017f97" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "6e44f4ec127c258c4185142295492ab790cca757db35a284762a95668e0862e9" + "cacheKey": "78240f0d6f2c12d2692cb6a400e8fb612fa39a5e4fc93121f3bf4654b2f50626" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b" + "cacheKey": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea" } ] }, @@ -1838,29 +1838,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ed0e8b6e71d96ca9ddd55e19983691efc56e50ba975e19147b2f4feef8067eee" + "wasm32": "6e8f0c4ae304f7efdcb621400bfb2b3c3a77655a7e2e20ea3b5e7136f05825bb" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "543e082567cc0502609bf0cdef6eb1ba431cef97d798136e739c915f8392615a" + "cacheKey": "c81e0b1b714646718033c1b2b43293d3d087d502abae5cfe82e6933dd8fe20f9" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" + "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "a477b0911328bdd36d79e2339ccad2b67d23c69589fd622af20d8afa5af77df4" + "cacheKey": "822af6438b8b472f4bf70a8d9d78fb2fe65980da303f2ff7034fb5ed67ccfbe3" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "924040509cd02cedd551f4afa6568090fcd0bed488d44674c0c1802d33b3e6a4" + "cacheKey": "2e11b63640012671fb4144219d84c0606f2d4297154ad95ca2ab5607ed017f97" } ] }, @@ -1880,29 +1880,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "031b17aaa70fd9ad18f6b3414562564f33e88c53caa6dd71b9e85c2f2d326d4c" + "wasm32": "50887911c742eb0685693c9f2fa6aa959cd3191b27ca070233591589bef6d3a8" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" + "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "005e9cd3956f26bf2c6e97610ed4c0ca4ee21eaa0d4e59a710f861a78bb7020d" + "cacheKey": "211f5e6ea83462c8ebaf8a1a923b5f7f258143f6cfd762fc92b9ed23d615c339" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "997c17703209773768eee15f64449151904b0f89824ebec9649db902126a9573" + "cacheKey": "f2ea5982b8a18d9d49b9984275d6efc463491af28835b19be94c2ac30b903280" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b" + "cacheKey": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea" } ] }, @@ -1922,39 +1922,39 @@ "wasm32" ], "cacheKeys": { - "wasm32": "705745bad6d92e50cddfc43b985d1095eee4d9069756ce75f65afdaa12b91e0b" + "wasm32": "1067b082af6cd61c61a6093e01f8bbd0456a6cc9f8f5bff00691e7109b977e54" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" + "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" }, { "packageName": "node", "manifestSha256": "2131a24dfda8587860e57fc65b573086477d376b065b3b9ca4e1dfe4d79f5790", - "cacheKey": "031b17aaa70fd9ad18f6b3414562564f33e88c53caa6dd71b9e85c2f2d326d4c" + "cacheKey": "50887911c742eb0685693c9f2fa6aa959cd3191b27ca070233591589bef6d3a8" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "005e9cd3956f26bf2c6e97610ed4c0ca4ee21eaa0d4e59a710f861a78bb7020d" + "cacheKey": "211f5e6ea83462c8ebaf8a1a923b5f7f258143f6cfd762fc92b9ed23d615c339" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "924040509cd02cedd551f4afa6568090fcd0bed488d44674c0c1802d33b3e6a4" + "cacheKey": "2e11b63640012671fb4144219d84c0606f2d4297154ad95ca2ab5607ed017f97" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "997c17703209773768eee15f64449151904b0f89824ebec9649db902126a9573" + "cacheKey": "f2ea5982b8a18d9d49b9984275d6efc463491af28835b19be94c2ac30b903280" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b" + "cacheKey": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea" } ] }, @@ -1974,7 +1974,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "da7287e1ed6d2f9f39a2cc049a65f5fc76c00959ce7f8b9402f1a22ff8510276" + "wasm32": "a6112b228ccbaf2087e4ac7bff8488fbe3b551a04366160edbe4f651fb1a520a" }, "dependencyClosures": { "wasm32": [] @@ -1995,14 +1995,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0a9e90fbfc9c3b971ad2b035752019674b5a15467737871015887a4787384ab8" + "wasm32": "96848b01e749b6c9b8ba408a21ccec44e71fb6dfe38f359298ac09e12e74c4b1" }, "dependencyClosures": { "wasm32": [ { "packageName": "perl", "manifestSha256": "6cdc4dbc54d0e4008cff41f82ec8918c0e44b4aef23903539c1bc0f538fe3ad2", - "cacheKey": "da7287e1ed6d2f9f39a2cc049a65f5fc76c00959ce7f8b9402f1a22ff8510276" + "cacheKey": "a6112b228ccbaf2087e4ac7bff8488fbe3b551a04366160edbe4f651fb1a520a" } ] }, @@ -2022,54 +2022,54 @@ "wasm32" ], "cacheKeys": { - "wasm32": "98ecceaf50e4b0dbc10292b49b3c8ee2009e6ba965449123851818c123b9427b" + "wasm32": "59f6df87ab85d47ee4c6b2a865d2180c1e62a960d782a4af60bdbba0af4e39a7" }, "dependencyClosures": { "wasm32": [ { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "81677f4b7e2d40634555388d8d06757a052efcbb915bef1c6e57d444137848cd" + "cacheKey": "869e58f1e945c89086b28e50dda9cd001d6205ae51404149404cbadbd338a3ef" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "6ce2dd7a19b7f5af78edc02fc75d08bbeade7f41e968f8b8a3f6e7e5fe4b26ae" + "cacheKey": "13427d5971b26c2e3e2ee43756f88944e3b75385a4d13e6fd45dd63a6a7c9afe" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" + "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "8b132a257b9a02a3adccac9d5d6bedc00348a6ac92375401761312d7c46a2ecf" + "cacheKey": "e3b39e170e399c7c353bf41cf8340473dae7f378cd8ba8dbfd5fb9f4ef69f4c5" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "4873ada42645826762560ad1833364a60c630c6411f84a03456b171f279faf2b" + "cacheKey": "222b11ed5313da06ba2368741a2e4589f78f89beebd184edcb0559b20bddc3f5" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "32c0e36842b5f47e79c66b925601f3f30c4ac982303cd159ade0b6c5755e5b61" + "cacheKey": "e4cdb8156bdfcfd302a18db3d908898f57b3859a00b308bbefe3a0f9b85de483" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "005e9cd3956f26bf2c6e97610ed4c0ca4ee21eaa0d4e59a710f861a78bb7020d" + "cacheKey": "211f5e6ea83462c8ebaf8a1a923b5f7f258143f6cfd762fc92b9ed23d615c339" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "6e44f4ec127c258c4185142295492ab790cca757db35a284762a95668e0862e9" + "cacheKey": "78240f0d6f2c12d2692cb6a400e8fb612fa39a5e4fc93121f3bf4654b2f50626" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b" + "cacheKey": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea" } ] }, @@ -2145,7 +2145,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a195d1db14bc727860c53d0cd87843aa52a3842fad036180c75c6e19173b7624" + "wasm32": "ac05dfca7fad4d6f4d8f371b85940f0c98063b581e18ee48af5609d3f41e20b4" }, "dependencyClosures": { "wasm32": [] @@ -2418,19 +2418,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a2382e09282fc05f23e94a83fbdcb0595a094ce09d7a93f9fa8e2ae1bb6b006f" + "wasm32": "de1aca82272b1914a5edc5f91c9e2a56a4100f6c29f4060d59407d97a8b0a26c" }, "dependencyClosures": { "wasm32": [ { "packageName": "cpython", "manifestSha256": "7dd4f446697a73941ec940c2ccba4d53be73fe6947f1e701031a4d0aa964c4ff", - "cacheKey": "a2e894f110205a0cf42bc02a56a31156a82fad0c291595a508bdb7bd09e7a6c7" + "cacheKey": "a101343c9081af62dfe5b0b3c9d981926b458240c641edc80d7d4a4a5816adb8" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b" + "cacheKey": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea" } ] }, @@ -2450,7 +2450,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "fdb4a0416b46db4d5999696fe921a12e434b951312e29530d8b03dda03152eb7" + "wasm32": "4cdaefdda2931b6718e8113640ab250948f1c639bf726615e800888722708b7b" }, "dependencyClosures": { "wasm32": [] @@ -2478,24 +2478,24 @@ "wasm32" ], "cacheKeys": { - "wasm32": "4b1a029a8da49d4b8ca43d63de06b10331ecdba4cfc1a16ed8a225824b54ce0f" + "wasm32": "4680323ca23aa4326807e09ab823e6b10e2cd4f387afc0319c41626a57cfb6a4" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "543e082567cc0502609bf0cdef6eb1ba431cef97d798136e739c915f8392615a" + "cacheKey": "c81e0b1b714646718033c1b2b43293d3d087d502abae5cfe82e6933dd8fe20f9" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" + "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" }, { "packageName": "redis", "manifestSha256": "151fb507de953ba2ba94b8f881bc7d66b4c741c1359a2f590cc07f9a4cd368a3", - "cacheKey": "fdb4a0416b46db4d5999696fe921a12e434b951312e29530d8b03dda03152eb7" + "cacheKey": "4cdaefdda2931b6718e8113640ab250948f1c639bf726615e800888722708b7b" } ] }, @@ -2515,79 +2515,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "639d2ea627f402d23bde67e0c370130ec1dac0ddea55af87b01b540a81072c34" + "wasm32": "de01588a7a5ab85427406f8770382dc9a093908609e31307fb81057186fe5eaf" }, "dependencyClosures": { "wasm32": [ { "packageName": "bash", "manifestSha256": "6478060f28d430d18a6ebe7c603392d35a41d9bcf6bc74322351d1450b9c5335", - "cacheKey": "f72620b103f7606cb3c9e2550ea68dc7c35c6bfbc69b217bb10d906901ec1c6c" + "cacheKey": "43fd97bde16c9d5bc3bbc97f26e8e4cb25a1f95dbc6deb6a5909a0f5e124b6ff" }, { "packageName": "bc", "manifestSha256": "a65661463bb7047b91ff00153bd99fd962c96e571934ab4e923f5215fb6ecfbd", - "cacheKey": "43001c22570d4972d6143a04c7ba9103fc35a735c15070978f331353e1ed0108" + "cacheKey": "bdbdfdd24e73b2abaf6a251c4f256d79140fcf429b7f017315be44d9238da3eb" }, { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "4583435763207ddfd63478fc7b3a263fdc6fae110bcd38109e1e1c26e621ad7f" + "cacheKey": "770b6ceaf5e508b0a9fff5798f131fd67dd9864054dc2cf6acf89836eabd85a1" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "c4b4345aa3f9011d0b214ad6adaa6c7fbe5eb2f6bbd0e5181b8f4be68f437bf3" + "cacheKey": "e7a48fe30c5509f8152a20c11da66e056f5b07c343fb1ae06f64884bc39cdbad" }, { "packageName": "diffutils", "manifestSha256": "3a78f0a46bae43ce5ea235c6638c2cbd1d8559b0b62b1fb42443b35968c1aca3", - "cacheKey": "d48b0feda4af14e2d08a803611d0b825ac51e9a9379bdb8b49e1170eaf75e5d0" + "cacheKey": "55ee5fd0c56cacbc3d21752feeba5e8628fb4dfe670eafd3bb14dae4959a3c10" }, { "packageName": "file", "manifestSha256": "7874c1affcbbf2c8ab8c8d4beb087f275af57b5caae6a8947bf5a63a181552b2", - "cacheKey": "55d93c1fd59d890d6f78346197a6d474c654fc2a6687e1ff6e1ffb1ae09ce377" + "cacheKey": "357a02fbd427f35255e349b6243f23b662fdb2608e09d6a08a040c6ae0a07cc3" }, { "packageName": "findutils", "manifestSha256": "cceedf52aea67fbb0da03cfce6f2e9b1a7b5561fa1656c2762b43c651a625d02", - "cacheKey": "b866449fe86205186c0537b6992df7747c852f0e07fde1da6f9d0a7b5808b152" + "cacheKey": "ff88da1019fb2d3b56399f9450baa23cdb5069458ea99dca1f3d2da19eee1501" }, { "packageName": "gawk", "manifestSha256": "a2567b8b0778805e385e1d3a41ac8b5d74aaf9d293b222001b17d09b11e5a0ba", - "cacheKey": "13a6253127ab0250d8dc39d51979aae88a0004cb5a268493cd332eaf564546d6" + "cacheKey": "0878a1253d8ed63fe64073b28351207fbe01dd12b6c166b7d6690e41164436c1" }, { "packageName": "grep", "manifestSha256": "59270d3bfb33167b32d13246bf855b49308f8c9c0a66d9635c804f702421fbc6", - "cacheKey": "e75c15a6f6114b40248b6de7c3057b99b108dde867cda73c111e47c3acbb326f" + "cacheKey": "e368d4eb26a528111c836689761ba645ad34d43aa21ae8c8420d16d1ace57da7" }, { "packageName": "m4", "manifestSha256": "2c6582d99d6eabfb9da52badf49b899e9fdcba76a728536b25bc3741f3331543", - "cacheKey": "8eb7d929c608eecfe55cffe87513a1d8b7b58c3dbe80f350ab9e0eb9ad7416a8" + "cacheKey": "223de993ff0b49af372f84de543354c651a80ad0cc0d4a219b927f52fa522583" }, { "packageName": "make", "manifestSha256": "f878d2d730f36a4c6dfe1fff1b4ccc704757ea22d8c4c8ccb95b11cd641e5fed", - "cacheKey": "dc7fce3e4dd128bbdd6a89356224db601bebd13df9c63548f5ab969a250dfcc5" + "cacheKey": "7ecea3370409cc9f5f40c92beeb29b944a6bfa3aa93e63f1400abc5ddf6a7bbd" }, { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "cf48512ef35d9852f274d38266ba81c4eb39033027e1628712bd4318557a1b79" + "cacheKey": "f06bbb1f53f43c18c3133a6ef7601df696b8968104f312fc5b278b32f0097de8" }, { "packageName": "posix-utils-lite", "manifestSha256": "8fd7190b2848ef80143adc7b9268c79e13cd4db3430f7105faf49a4b4407a06f", - "cacheKey": "a195d1db14bc727860c53d0cd87843aa52a3842fad036180c75c6e19173b7624" + "cacheKey": "ac05dfca7fad4d6f4d8f371b85940f0c98063b581e18ee48af5609d3f41e20b4" }, { "packageName": "sed", "manifestSha256": "2a08e9c5dacc5facc8983c1ff35ff2a72db328405e9a1f464cc7ad155c4d08af", - "cacheKey": "2d8f0e8bafe76521572934b36ab8fc1eb327eac5492c1d96d18fee3b1df926a3" + "cacheKey": "a14e7eacff9d49b35562c39ddaf2d0e5b6379fc5b834477897456198e7256b31" } ] }, @@ -2607,14 +2607,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "9c89b8e95447c8d17c4ca6c06b712f46b7fa9c4812bbcb637aa44b913092e80d" + "wasm32": "316f9e58680969175df40915adae5f86a7b973f0ff448b077f45ab7e3210e3d2" }, "dependencyClosures": { "wasm32": [ { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b" + "cacheKey": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea" } ] }, @@ -2641,19 +2641,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "422c6bfdf0bac96e006cd7d0f130cd1a4857be5b124a64965ede999f611c8542" + "wasm32": "42bb75d49df61508014684ce910aad8d16f76f5445bcab7b4191c19775370901" }, "dependencyClosures": { "wasm32": [ { "packageName": "sdl2", "manifestSha256": "327ce0acca768f51e36ddab8ab58fd57b24cfd9e40722e818103c439f7263dd7", - "cacheKey": "9cc6d3328ea4fb6848530e0e9a8cb5c30a8bd03a356fda29d6b46a91e37a58e8" + "cacheKey": "f413896eee1d3f51953b367378203e187b868b6f4e311d7203bfa76525c70e12" }, { "packageName": "sdl3", "manifestSha256": "3b7c64605b941e14d336b1a127d8219c50402dece009e8855297a0a0696bd67e", - "cacheKey": "93bdeb1ecdb6e8d5e9e4bc0d14969ab3b4b6ece04481ef464f9050d08ef4036e" + "cacheKey": "07e104fa92d244f53779ce5d3fe29e98682f7bda9f6d5686dee6a5715f07a2de" } ] }, @@ -2680,14 +2680,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "d9dbaaebde392a70f307fb54bdf7cbe069c42661f7b04cad8e9c58dfe89cbc1d" + "wasm32": "fad45b0fcb2c48a2711c4e6660fee5bd54e1622be0ee3203fae476f61ad64dca" }, "dependencyClosures": { "wasm32": [ { "packageName": "sdl2", "manifestSha256": "327ce0acca768f51e36ddab8ab58fd57b24cfd9e40722e818103c439f7263dd7", - "cacheKey": "9cc6d3328ea4fb6848530e0e9a8cb5c30a8bd03a356fda29d6b46a91e37a58e8" + "cacheKey": "f413896eee1d3f51953b367378203e187b868b6f4e311d7203bfa76525c70e12" } ] }, @@ -2707,7 +2707,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2d8f0e8bafe76521572934b36ab8fc1eb327eac5492c1d96d18fee3b1df926a3" + "wasm32": "a14e7eacff9d49b35562c39ddaf2d0e5b6379fc5b834477897456198e7256b31" }, "dependencyClosures": { "wasm32": [] @@ -2728,7 +2728,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "924040509cd02cedd551f4afa6568090fcd0bed488d44674c0c1802d33b3e6a4" + "wasm32": "2e11b63640012671fb4144219d84c0606f2d4297154ad95ca2ab5607ed017f97" }, "dependencyClosures": { "wasm32": [] @@ -2749,24 +2749,24 @@ "wasm32" ], "cacheKeys": { - "wasm32": "997c17703209773768eee15f64449151904b0f89824ebec9649db902126a9573" + "wasm32": "f2ea5982b8a18d9d49b9984275d6efc463491af28835b19be94c2ac30b903280" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" + "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "005e9cd3956f26bf2c6e97610ed4c0ca4ee21eaa0d4e59a710f861a78bb7020d" + "cacheKey": "211f5e6ea83462c8ebaf8a1a923b5f7f258143f6cfd762fc92b9ed23d615c339" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b" + "cacheKey": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea" } ] }, @@ -2786,29 +2786,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ffc7eecad0fb134fb01e8494621a16206d0b698828afe6c1c67cbdbbcfefbd7a" + "wasm32": "c5f5efbb3d10b7f4c0c9193b5dce57f6ba6d4d24bf3eb03a477da6d2d4fb494a" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" + "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "005e9cd3956f26bf2c6e97610ed4c0ca4ee21eaa0d4e59a710f861a78bb7020d" + "cacheKey": "211f5e6ea83462c8ebaf8a1a923b5f7f258143f6cfd762fc92b9ed23d615c339" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "997c17703209773768eee15f64449151904b0f89824ebec9649db902126a9573" + "cacheKey": "f2ea5982b8a18d9d49b9984275d6efc463491af28835b19be94c2ac30b903280" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b" + "cacheKey": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea" } ] }, @@ -2828,7 +2828,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "8203b8f73e96deeefbe72c5eb9342cbd62b03fc31a4f3906cfbd4452b05c4c38" + "wasm32": "7a1fa56532b582ec826f17801b12503241bbeca421d5a9797b4dd1be0f8252d6" }, "dependencyClosures": { "wasm32": [] @@ -2849,7 +2849,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e0e058c4333c4c94b7ed1fb0da398e3e874998c8bf1b3249f84417047a312db7" + "wasm32": "ce34f76de356c21cf2ef66200523204ff08dc65cdb402fd7ec08ae90b3765c7f" }, "dependencyClosures": { "wasm32": [] @@ -2870,7 +2870,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "fe826385f0bd64f30c93c32bb93cbbb8948a74cf83fd137596c1f5df002ece70" + "wasm32": "66ef9fb3d9dd4c9198b9e07a033775c9a182edb71aadd3e94b286b1b78d73a69" }, "dependencyClosures": { "wasm32": [] @@ -2891,19 +2891,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "86434df9283dee6425eb388b6229fd19a8f9624bef425d5a9b6e37082636cf2a" + "wasm32": "3ca8dda209b7161a58f5d9f2eee193b48cc8ba8582cb6dfa9a131a5461fe515b" }, "dependencyClosures": { "wasm32": [ { "packageName": "libpng", "manifestSha256": "79ed5c5c072a0267cce5c7a2fe37d8494571b17e44b952f619e04ec1c4a9db10", - "cacheKey": "5862e6edd08188b92bb7f84c064d147326ec65ba5024bb86e93a8efa93ff13d8" + "cacheKey": "578ab5f1f7dc19a936f4dd6e6ac2da7cb4683213085c933eeeaeb424410ac8dc" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b" + "cacheKey": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea" } ] }, @@ -2930,7 +2930,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e6f3c139b81f8398c9083a88c64c541b2d50aa59bdf1eb3795dbc8100c477279" + "wasm32": "ae1b008f8b3a9465906cf3fd4050b44d0a060d955117ceee2351a813d4b1fc23" }, "dependencyClosures": { "wasm32": [] @@ -2951,7 +2951,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a2ebb59c3b576c0eae37312c39711e3428cea60156614afbdb6c9de1540166eb" + "wasm32": "8ff4d7d725da72ba25b42a5406b5fd258aab0f1f572cfdd956faa2dd7fc7cc4d" }, "dependencyClosures": { "wasm32": [] @@ -2972,14 +2972,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b4d85b2f95d7c6ec98fa9efe64aafa5238d9fdbce7eede2c93ae16b4c042d1d6" + "wasm32": "c91715e0ee7db72fa1a3db567aa595979f5b6c404c7a7b7e8307e08e24d90de7" }, "dependencyClosures": { "wasm32": [ { "packageName": "vim", "manifestSha256": "c211660ef41d01f95f3fbdf675b74891e897e3f304e04f1bf5586f326007382c", - "cacheKey": "a2ebb59c3b576c0eae37312c39711e3428cea60156614afbdb6c9de1540166eb" + "cacheKey": "8ff4d7d725da72ba25b42a5406b5fd258aab0f1f572cfdd956faa2dd7fc7cc4d" } ] }, @@ -2999,7 +2999,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b9ca0d845774d9bc9421298065f0a0ac9c42bd5b31b52688d0ab35278c2adc68" + "wasm32": "fdd1243bbc56415f1c5f121ddc36efded472976189598aa5de8364703c971e0a" }, "dependencyClosures": { "wasm32": [] @@ -3020,79 +3020,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "4c18d0e78186915f6ea6e22e2ef769af3bb413a4b33220dd064aef10fc5a316a" + "wasm32": "b8079a2b01cebaf4dbc26c6e9a6eedae258368d95cd756ff87b21bed377a56a7" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "543e082567cc0502609bf0cdef6eb1ba431cef97d798136e739c915f8392615a" + "cacheKey": "c81e0b1b714646718033c1b2b43293d3d087d502abae5cfe82e6933dd8fe20f9" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "81677f4b7e2d40634555388d8d06757a052efcbb915bef1c6e57d444137848cd" + "cacheKey": "869e58f1e945c89086b28e50dda9cd001d6205ae51404149404cbadbd338a3ef" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "6ce2dd7a19b7f5af78edc02fc75d08bbeade7f41e968f8b8a3f6e7e5fe4b26ae" + "cacheKey": "13427d5971b26c2e3e2ee43756f88944e3b75385a4d13e6fd45dd63a6a7c9afe" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "8637589d7edff4677a49452a19fcd2881e993a778d27a0dacffc9622f3fb0023" + "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "8b132a257b9a02a3adccac9d5d6bedc00348a6ac92375401761312d7c46a2ecf" + "cacheKey": "e3b39e170e399c7c353bf41cf8340473dae7f378cd8ba8dbfd5fb9f4ef69f4c5" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "4873ada42645826762560ad1833364a60c630c6411f84a03456b171f279faf2b" + "cacheKey": "222b11ed5313da06ba2368741a2e4589f78f89beebd184edcb0559b20bddc3f5" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "32c0e36842b5f47e79c66b925601f3f30c4ac982303cd159ade0b6c5755e5b61" + "cacheKey": "e4cdb8156bdfcfd302a18db3d908898f57b3859a00b308bbefe3a0f9b85de483" }, { "packageName": "msmtpd", "manifestSha256": "09de04a422ddf631a29a259d816e081f8d317d1077808ede55d8c500baae748f", - "cacheKey": "554ed9b04dcac3ed56656590be2baadc303cd9494f8742fcd6ab3e9323faf62a" + "cacheKey": "f1b48a8a5e1fae2548dd48bae15f7b96b56689cd8429f3f57db762fb0118838d" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "a477b0911328bdd36d79e2339ccad2b67d23c69589fd622af20d8afa5af77df4" + "cacheKey": "822af6438b8b472f4bf70a8d9d78fb2fe65980da303f2ff7034fb5ed67ccfbe3" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "005e9cd3956f26bf2c6e97610ed4c0ca4ee21eaa0d4e59a710f861a78bb7020d" + "cacheKey": "211f5e6ea83462c8ebaf8a1a923b5f7f258143f6cfd762fc92b9ed23d615c339" }, { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "98ecceaf50e4b0dbc10292b49b3c8ee2009e6ba965449123851818c123b9427b" + "cacheKey": "59f6df87ab85d47ee4c6b2a865d2180c1e62a960d782a4af60bdbba0af4e39a7" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "924040509cd02cedd551f4afa6568090fcd0bed488d44674c0c1802d33b3e6a4" + "cacheKey": "2e11b63640012671fb4144219d84c0606f2d4297154ad95ca2ab5607ed017f97" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "6e44f4ec127c258c4185142295492ab790cca757db35a284762a95668e0862e9" + "cacheKey": "78240f0d6f2c12d2692cb6a400e8fb612fa39a5e4fc93121f3bf4654b2f50626" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "024e74a76a23e09d6984f737783eb3f15c4d6b9c05434d0390b2e93fc4daeb5b" + "cacheKey": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea" } ] }, @@ -3112,7 +3112,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "105b5d372e4e3d6f1cc9bf2871f5a6d082336a0fbfbd044c536d6466b28541f5" + "wasm32": "44c91c6e853b02f601c64d93c2ea42c35b3c29ac424d606bc7e04cb39d26b54c" }, "dependencyClosures": { "wasm32": [] @@ -3133,7 +3133,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "de42ac678212b2313fc54c1c583877e1f69573e850f5634fc296e1985375459a" + "wasm32": "c6f82bac695685734075f1e1a4909623a9be28d56881c7c7657d374afdbd5f93" }, "dependencyClosures": { "wasm32": [] @@ -3154,7 +3154,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b1f13d42e6ad8b54bb66c31ad8614bfd4f8f7989c4cd840131399dce1a15354f" + "wasm32": "9a68ab2d994c674006d1f50d4ad725f438599feea6b025b4b9d346932ad85dab" }, "dependencyClosures": { "wasm32": [] diff --git a/packages/registry/ruby/build-ruby.sh b/packages/registry/ruby/build-ruby.sh index be848bcde3..e8b35a9f05 100755 --- a/packages/registry/ruby/build-ruby.sh +++ b/packages/registry/ruby/build-ruby.sh @@ -35,6 +35,10 @@ SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://cache.ruby-lang.org/pub/ruby/${ SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-}" PACKAGE_NAME="${WASM_POSIX_DEP_NAME:-ruby}" GUEST_PREFIX="${WASM_POSIX_DEP_GUEST_PREFIX-/usr}" +# Bump this when a source-tree port edit changes. In particular, this keeps a +# developer work directory that contains the retired #1166 process.c patch +# from being reused after the recipe returns to upstream CRuby's exec path. +EXPECTED_SOURCE_MARKER="$RUBY_VERSION kandelo-port-14-upstream-vfork" validate_guest_prefix() { local value="$1" @@ -81,8 +85,8 @@ if [ -n "${WASM_POSIX_DEP_WORK_DIR:-}" ]; then fi export WASM_POSIX_SYSROOT="$SYSROOT" -if [ -d "$SRC_DIR" ] && [ "$(cat "$SOURCE_MARKER" 2>/dev/null || true)" != "$RUBY_VERSION" ]; then - echo "==> Existing Ruby source is not $RUBY_VERSION; cleaning Ruby build directories..." +if [ -d "$SRC_DIR" ] && [ "$(cat "$SOURCE_MARKER" 2>/dev/null || true)" != "$EXPECTED_SOURCE_MARKER" ]; then + echo "==> Existing Ruby source is not the selected $RUBY_VERSION port revision; cleaning Ruby build directories..." rm -rf "$SRC_DIR" "$HOST_BUILD_DIR" "$CROSS_BUILD_DIR" "$INSTALL_DIR" "$BIN_DIR" fi @@ -167,7 +171,7 @@ if [ ! -d "$SRC_DIR" ]; then mkdir -p "$SRC_DIR" tar xzf "/tmp/${TARBALL}" -C "$SRC_DIR" --strip-components=1 rm "/tmp/${TARBALL}" - printf '%s\n' "$RUBY_VERSION" > "$SOURCE_MARKER" + printf '%s\n' "$EXPECTED_SOURCE_MARKER" > "$SOURCE_MARKER" echo "==> Source extracted to $SRC_DIR" fi @@ -481,10 +485,22 @@ if ! grep -q 'kandelo_require_libraries_state' "$SRC_DIR/ruby.c"; then patch -d "$SRC_DIR" -p1 < "$SCRIPT_DIR/patches/kandelo-require-libraries-roots.patch" fi -if ! grep -q 'kandelo_execarg_can_posix_spawn' "$SRC_DIR/process.c"; then - echo "==> Patching process.c: using non-forking spawn when options are representable..." - patch -d "$SRC_DIR" -p1 < "$SCRIPT_DIR/patches/kandelo-posix-spawn.patch" +# WHY: CRuby already routes eligible unprivileged fork-then-exec operations +# through vfork when HAVE_WORKING_VFORK is true. Keep process.c on that upstream +# path; a stale #1166 work directory must fail rather than silently rebuilding +# the package-specific posix_spawn backend. +if grep -q 'kandelo_execarg_can_posix_spawn' "$SRC_DIR/process.c"; then + echo "ERROR: Ruby process.c still contains the retired #1166 patch" >&2 + exit 1 fi +grep -F '#if defined(HAVE_WORKING_VFORK)' "$SRC_DIR/process.c" >/dev/null || { + echo "ERROR: Ruby process.c is missing the upstream vfork selection guard" >&2 + exit 1 +} +grep -F 'pid = vfork();' "$SRC_DIR/process.c" >/dev/null || { + echo "ERROR: Ruby process.c is missing the upstream vfork call" >&2 + exit 1 +} reject_asyncify_coroutine() { if [ -f Makefile ] && grep -Eq '^(COROUTINE_TYPE = asyncify|COROUTINE_H = coroutine/asyncify/Context\.h)$|wasm/(setjmp|fiber|runtime|machine)|--asyncify|asyncify_' Makefile; then @@ -782,7 +798,11 @@ ac_cv_func_lutimes=no ac_cv_func_strlcpy=no ac_cv_func_strlcat=no ac_cv_func_strsignal=no -ac_cv_func_vfork=no +# Kandelo provides real vfork semantics. These cross-compile cache answers let +# AC_FUNC_FORK define both HAVE_VFORK and HAVE_WORKING_VFORK instead of +# rewriting vfork to fork. Runtime tests cover the semantic claim. +ac_cv_func_vfork=yes +ac_cv_func_vfork_works=yes ac_cv_func_tcgetattr=no ac_cv_func_tcsetattr=no ac_cv_func_tcflush=no @@ -809,8 +829,12 @@ ac_cv_func_sethostname=no ac_cv_func_if_nameindex=no ac_cv_func_mkfifoat=no ac_cv_func_siginterrupt=no -ac_cv_func_getresgid=no -ac_cv_func_getresuid=no +# CRuby's upstream vfork privilege guard needs saved IDs. Kandelo libc exposes +# the POSIX getres* interfaces; the AIX get*idx substitutes do not exist. +ac_cv_func_getresgid=yes +ac_cv_func_getresuid=yes +ac_cv_func_getgidx=no +ac_cv_func_getuidx=no ac_cv_func_getsid=no ac_cv_func_posix_fadvise=no ac_cv_func_posix_fallocate=no @@ -1015,7 +1039,7 @@ disable = { 'HAVE_GETRLIMIT', 'HAVE_SETRLIMIT', 'HAVE_GETRUSAGE', 'HAVE_CONFSTR', 'HAVE_FDATASYNC', 'HAVE_STRLCPY', 'HAVE_STRLCAT', 'HAVE_STRSIGNAL', - 'HAVE_VFORK', 'HAVE_TCGETATTR', 'HAVE_TCSETATTR', + 'HAVE_TCGETATTR', 'HAVE_TCSETATTR', 'HAVE_TCFLUSH', 'HAVE_TCGETPGRP', 'HAVE_TCSETPGRP', 'HAVE_CLOCK_SETTIME', 'HAVE_CLOCK_NANOSLEEP', 'HAVE_GETPRIORITY', 'HAVE_SETPRIORITY', 'HAVE_NICE', @@ -1081,6 +1105,14 @@ with open('$CONFIG_H', 'w') as f: f.write(content) print(f'Disabled {disabled} HAVE_* defines in $CONFIG_H') " + grep -Eq '^#define HAVE_VFORK 1$' "$CONFIG_H" || { + echo "ERROR: Ruby configure did not retain HAVE_VFORK" >&2 + exit 1 + } + grep -Eq '^#define HAVE_WORKING_VFORK 1$' "$CONFIG_H" || { + echo "ERROR: Ruby configure did not select working vfork" >&2 + exit 1 + } fi fi diff --git a/packages/registry/ruby/build.toml b/packages/registry/ruby/build.toml index 9a27291151..9c3c1f3b63 100644 --- a/packages/registry/ruby/build.toml +++ b/packages/registry/ruby/build.toml @@ -9,7 +9,7 @@ inputs = [ ] repo_url = "https://github.com/Automattic/kandelo.git" commit = "UNPUBLISHED" -revision = 13 +revision = 14 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/ruby/patches/kandelo-posix-spawn.patch b/packages/registry/ruby/patches/kandelo-posix-spawn.patch deleted file mode 100644 index 01b60bffe8..0000000000 --- a/packages/registry/ruby/patches/kandelo-posix-spawn.patch +++ /dev/null @@ -1,341 +0,0 @@ ---- a/process.c -+++ b/process.c -@@ -36,6 +36,10 @@ - - #ifdef HAVE_PROCESS_H - # include -+#endif -+ -+#ifdef RUBY_KANDELO_POSIX -+# include - #endif - - #ifndef EXIT_SUCCESS -@@ -4519,8 +4523,304 @@ - return StringValueCStr(*prog); - } - #endif -+ -+#ifdef RUBY_KANDELO_POSIX -+static int -+kandelo_execarg_fd_is_target(VALUE redirects, int fd) -+{ -+ long i; - -+ if (redirects == Qfalse) return 0; -+ for (i = 0; i < RARRAY_LEN(redirects); i++) { -+ VALUE entry = RARRAY_AREF(redirects, i); -+ if (FIX2INT(RARRAY_AREF(entry, 0)) == fd) return 1; -+ } -+ return 0; -+} -+ -+static int -+kandelo_execarg_redirect_source(const struct rb_execarg *eargp, int fd) -+{ -+ long i; -+ -+ if (eargp->fd_dup2 == Qfalse) return fd; -+ for (i = 0; i < RARRAY_LEN(eargp->fd_dup2); i++) { -+ VALUE entry = RARRAY_AREF(eargp->fd_dup2, i); -+ if (FIX2INT(RARRAY_AREF(entry, 0)) == fd) { -+ return FIX2INT(RARRAY_AREF(entry, 1)); -+ } -+ } -+ return fd; -+} -+ -+static int -+kandelo_execarg_has_independent_redirects(const struct rb_execarg *eargp) -+{ -+ long i; -+ -+ if (eargp->fd_dup2 == Qfalse) return 1; -+ for (i = 0; i < RARRAY_LEN(eargp->fd_dup2); i++) { -+ VALUE entry = RARRAY_AREF(eargp->fd_dup2, i); -+ int target = FIX2INT(RARRAY_AREF(entry, 0)); -+ int source = FIX2INT(RARRAY_AREF(entry, 1)); -+ -+ /* A source that is also replaced needs Ruby's temporary-fd graph -+ * algorithm. Applying such dup2 actions in array order can destroy -+ * the source before another action consumes it. */ -+ if (source != target && -+ kandelo_execarg_fd_is_target(eargp->fd_dup2, source)) { -+ return 0; -+ } -+ } -+ return 1; -+} -+ -+struct kandelo_fd_status_flags { -+ int fd; -+ int flags; -+}; -+ -+static int -+kandelo_execarg_clear_nonblock_stdio( -+ const struct rb_execarg *eargp, -+ struct kandelo_fd_status_flags saved[3]) -+{ -+ int fd; -+ int saved_count = 0; -+ -+ for (fd = 0; fd < 3; fd++) { -+ int source; -+ int flags; -+ int i; -+ -+ source = kandelo_execarg_redirect_source(eargp, fd); -+ -+ /* Two standard streams may share one source. Save and mutate each -+ * open-file description once so failure restoration cannot apply an -+ * already-cleared snapshot over its original O_NONBLOCK state. */ -+ for (i = 0; i < saved_count; i++) { -+ if (saved[i].fd == source) break; -+ } -+ if (i < saved_count) continue; -+ -+ flags = fcntl(source, F_GETFL); -+ if (flags < 0 || !(flags & O_NONBLOCK)) continue; -+ if (set_blocking(source) == 0) { -+ saved[saved_count].fd = source; -+ saved[saved_count].flags = flags; -+ saved_count++; -+ } -+ } -+ -+ return saved_count; -+} -+ -+static void -+kandelo_execarg_restore_fd_flags( -+ const struct kandelo_fd_status_flags saved[3], -+ int saved_count) -+{ -+ int i; -+ -+ for (i = 0; i < saved_count; i++) { -+ (void)fcntl(saved[i].fd, F_SETFL, saved[i].flags); -+ } -+} -+ -+static int -+kandelo_execarg_can_posix_spawn(const struct rb_execarg *eargp) -+{ -+ /* This first backend intentionally accepts only options Kandelo's -+ * non-forking spawn contract can reproduce exactly. Unsupported Ruby -+ * options stay on the established fork path instead of being ignored. */ -+ if (eargp->use_shell || NIL_P(eargp->invoke.cmd.command_abspath)) return 0; -+ if (eargp->umask_given || eargp->uid_given || eargp->gid_given) return 0; -+ if (eargp->rlimit_limits != Qfalse || eargp->fd_dup2_child != Qfalse) return 0; -+ /* Ruby reports an explicit close of an invalid descriptor as EBADF. -+ * Kandelo's current spawn action deliberately ignores close failures, so -+ * retain Ruby's fork backend whenever an explicit close was requested. */ -+ if (eargp->fd_close != Qfalse) return 0; -+ /* Kandelo resolves the executable before applying spawn file actions. -+ * Ruby applies chdir first, so a relative executable containing a slash -+ * must remain on the fork path to be resolved in the child's directory. */ -+ if (eargp->chdir_given && -+ !rb_is_absolute_path(RSTRING_PTR(eargp->invoke.cmd.command_abspath))) { -+ return 0; -+ } -+ /* Ruby-created descriptors are CLOEXEC, so omitting close_others needs no -+ * special action. An explicit true value asks Ruby to sweep arbitrary -+ * non-CLOEXEC descriptors, which Kandelo's spawn actions cannot express. */ -+ if (eargp->close_others_do) return 0; -+ -+ /* Kandelo supports creating the child's own process group. Explicitly -+ * joining another group still needs the kernel's full session and -+ * permission validation, so retain Ruby's fork/setpgid path for it. */ -+ if (eargp->pgroup_given && eargp->pgroup_pgid > 0) return 0; -+ -+ if (!kandelo_execarg_has_independent_redirects(eargp)) return 0; -+ return 1; -+} -+ -+static int -+kandelo_posix_spawn_add_file_actions( -+ posix_spawn_file_actions_t *actions, -+ const struct rb_execarg *eargp) -+{ -+ long i; -+ int error; -+ -+ if (eargp->fd_dup2 != Qfalse) { -+ for (i = 0; i < RARRAY_LEN(eargp->fd_dup2); i++) { -+ VALUE entry = RARRAY_AREF(eargp->fd_dup2, i); -+ int target = FIX2INT(RARRAY_AREF(entry, 0)); -+ int source = FIX2INT(RARRAY_AREF(entry, 1)); -+ error = posix_spawn_file_actions_adddup2(actions, source, target); -+ if (error) return error; -+ } -+ } -+ -+ /* Ruby applies chdir after descriptor redirection. Preserve that order; -+ * changing it would make relative redirection paths observe a different -+ * directory from the existing fork implementation. */ -+ if (eargp->chdir_given) { -+ error = posix_spawn_file_actions_addchdir( -+ actions, RSTRING_PTR(eargp->chdir_dir)); -+ if (error) return error; -+ } -+ -+ return 0; -+} -+ - static rb_pid_t -+kandelo_posix_spawn_process( -+ struct rb_execarg *eargp) -+{ -+ extern char **environ; -+ posix_spawn_file_actions_t actions; -+ posix_spawnattr_t attrs; -+ sigset_t empty_mask; -+ sigset_t default_mask; -+ short attr_flags = POSIX_SPAWN_SETSIGMASK; -+ rb_pid_t pid = -1; -+ int actions_initialized = 0; -+ int attrs_initialized = 0; -+ volatile int try_gc = 1; -+ struct kandelo_fd_status_flags saved_fd_flags[3]; -+ int saved_fd_count = 0; -+ char **envp; -+ int error; -+ -+ if (eargp->status) { -+ eargp->status->pid = 0; -+ eargp->status->status = 0; -+ eargp->status->error = 0; -+ } -+ -+ while (1) { -+ /* prefork() can raise while flushing Ruby streams. Run it before -+ * allocating C spawn objects so a nonlocal exit cannot leak them. */ -+ prefork(); -+ actions_initialized = 0; -+ attrs_initialized = 0; -+ saved_fd_count = 0; -+ -+ error = posix_spawn_file_actions_init(&actions); -+ if (error) goto attempt_done; -+ actions_initialized = 1; -+ -+ error = posix_spawnattr_init(&attrs); -+ if (error) goto attempt_done; -+ attrs_initialized = 1; -+ -+ error = kandelo_posix_spawn_add_file_actions(&actions, eargp); -+ if (error) goto attempt_done; -+ -+ /* Ruby's fork child unblocks every signal before exec and deliberately -+ * resets SIGPIPE even when the Ruby parent ignored it. The spawn child -+ * would otherwise inherit both pieces of Ruby runtime state. */ -+ if (sigemptyset(&empty_mask) < 0) { -+ error = errno; -+ goto attempt_done; -+ } -+ error = posix_spawnattr_setsigmask(&attrs, &empty_mask); -+ if (error) goto attempt_done; -+ -+ if (sigemptyset(&default_mask) < 0) { -+ error = errno; -+ goto attempt_done; -+ } -+#ifdef SIGPIPE -+ if (sigaddset(&default_mask, SIGPIPE) < 0) { -+ error = errno; -+ goto attempt_done; -+ } -+ attr_flags |= POSIX_SPAWN_SETSIGDEF; -+ error = posix_spawnattr_setsigdefault(&attrs, &default_mask); -+ if (error) goto attempt_done; -+#endif -+ -+ if (eargp->pgroup_given && eargp->pgroup_pgid == 0) { -+ attr_flags |= POSIX_SPAWN_SETPGROUP; -+ error = posix_spawnattr_setpgroup(&attrs, 0); -+ if (error) goto attempt_done; -+ } -+ error = posix_spawnattr_setflags(&attrs, attr_flags); -+ if (error) goto attempt_done; -+ -+ /* Ruby's fork child clears O_NONBLOCK after dup2. File status flags -+ * live on the shared open-file description, so the parent observes -+ * that change too. Clear each resulting stdio source here to preserve -+ * the same behavior without a nonstandard spawn file action. */ -+ saved_fd_count = kandelo_execarg_clear_nonblock_stdio( -+ eargp, saved_fd_flags); -+ /* CRuby leaves envp_str empty when Process.spawn has no environment -+ * options. Read environ immediately before every attempt: EAGAIN can -+ * let another Ruby thread replace that vector before a retry. */ -+ envp = eargp->envp_str ? RB_IMEMO_TMPBUF_PTR(eargp->envp_str) : environ; -+ error = posix_spawn( -+ &pid, -+ RSTRING_PTR(eargp->invoke.cmd.command_abspath), -+ &actions, -+ &attrs, -+ ARGVSTR2ARGV(eargp->invoke.cmd.argv_str), -+ envp); -+ -+attempt_done: -+ if (attrs_initialized) posix_spawnattr_destroy(&attrs); -+ if (actions_initialized) posix_spawn_file_actions_destroy(&actions); -+ if (!error) break; -+ /* No child exists after an error, so restore the parent's descriptor -+ * flags before Ruby performs its normal EAGAIN wait or one ENOMEM GC -+ * retry. Reusing handle_fork_error preserves the established retry -+ * and interruption behavior instead of inventing package policy. */ -+ kandelo_execarg_restore_fd_flags(saved_fd_flags, saved_fd_count); -+ saved_fd_count = 0; -+ if (handle_fork_error( -+ error, eargp->status, NULL, &try_gc) != 0) { -+ break; -+ } -+ } -+ -+ if (error) { -+ /* A successful spawn intentionally leaves the shared stdio open-file -+ * descriptions blocking, matching Ruby's fork path. If no child was -+ * created, put the parent's flags back exactly as they were. */ -+ kandelo_execarg_restore_fd_flags(saved_fd_flags, saved_fd_count); -+ if (eargp->status) eargp->status->error = error; -+ /* Return the syscall error unchanged. rb_spawn_process retries only -+ * ENOEXEC, whose Kandelo preflight is side-effect-free; retrying any -+ * later exec or file-action failure would be observably different. */ -+ errno = error; -+ return -1; -+ } -+ -+ if (eargp->status) eargp->status->pid = pid; -+ if (eargp->waitpid_state) eargp->waitpid_state->pid = pid; -+ return pid; -+} -+#endif -+ -+static rb_pid_t - rb_spawn_process(struct rb_execarg *eargp, char *errmsg, size_t errmsg_buflen) - { - rb_pid_t pid; -@@ -4530,6 +4830,22 @@ - # if !defined HAVE_SPAWNV - int status; - # endif -+#endif -+ -+#ifdef RUBY_KANDELO_POSIX -+ if (kandelo_execarg_can_posix_spawn(eargp)) { -+ pid = kandelo_posix_spawn_process(eargp); -+ if (pid >= 0 || errno != ENOEXEC) return pid; -+ -+ /* Ruby retries an ENOEXEC executable through /bin/sh. Kandelo checks -+ * the module format before it creates a child or applies file -+ * actions, so this one error can safely retain that behavior. */ -+ if (eargp->status) { -+ eargp->status->pid = 0; -+ eargp->status->status = 0; -+ eargp->status->error = 0; -+ } -+ } - #endif - - #if defined HAVE_WORKING_FORK && !USE_SPAWNV diff --git a/packages/registry/ruby/test/posix-spawn-contract.ts b/packages/registry/ruby/test/posix-spawn-contract.ts index 4f86238cf3..d0451426b7 100644 --- a/packages/registry/ruby/test/posix-spawn-contract.ts +++ b/packages/registry/ruby/test/posix-spawn-contract.ts @@ -1,255 +1,59 @@ -export const RUBY_POSIX_SPAWN_EXECUTABLE = "/usr/bin/ruby"; -export const RUBY_POSIX_SPAWN_CWD = "/tmp/ruby-posix-spawn-cwd"; - -const childProgram = String.raw` -input = STDIN.read -argv0 = File.binread("/proc/self/cmdline").split("\0", -1).fetch(0) -puts "argv0=#{argv0}" -puts "arg1=#{ARGV.fetch(0)}" -puts "env=#{ENV.fetch('K_TEST')}" -puts "cwd=#{Dir.pwd}" -puts "pid=#{Process.pid}" -puts "pgrp=#{Process.getpgrp}" -puts "input=#{input}" -warn "stderr-ok" -exit 23 -`; - -export interface RubyPosixSpawnCase { - marker: string; - expectedForkCount: bigint; - expectedChildEvents: readonly ("spawn" | "exec" | "exit")[]; - program: string; -} - -function rubySingleQuoted(value: string): string { - return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`; -} - -function parentProgram( - closeOthers: boolean, - marker: string, - explicitEnvironment = true, -): string { - const closeOthersOption = closeOthers ? ",\n close_others: true" : ""; - const environmentArgument = explicitEnvironment - ? ' { "K_TEST" => "env-ok" },\n' - : ""; - const expectedEnvironment = explicitEnvironment - ? "env-ok" - : "inherited-env-ok"; - const blockingAssertion = closeOthers - ? "" - : String.raw` -unless stdio_sources.all? { |io| (io.fcntl(3) & File::NONBLOCK) == 0 } - warn "spawn did not clear O_NONBLOCK on its final standard streams" - exit 11 +export const RUBY_VFORK_EXECUTABLE = "/bin/echo"; +export const RUBY_VFORK_MISSING_EXECUTABLE = + "/kandelo/tests/missing-ruby-vfork-target"; + +export const RUBY_VFORK_FAILED_EXEC_MARKER = + "RUBY_UPSTREAM_VFORK_FAILED_EXEC_OK"; +export const RUBY_VFORK_EXEC_MARKER = "RUBY_UPSTREAM_VFORK_EXEC_OK"; +export const RUBY_PRIVILEGED_FORK_MARKER = + "RUBY_PRIVILEGED_FORK_FALLBACK_OK"; + +export const RUBY_VFORK_FAILED_EXEC_PROGRAM = String.raw` +unless Process.uid == 1000 && Process.euid == 1000 && + Process.gid == 1000 && Process.egid == 1000 + warn "unexpected unprivileged credentials" + exit 10 end -`; - const failedSpawnRestoration = closeOthers - ? "" - : String.raw` -# WHY: the direct backend clears O_NONBLOCK before entering posix_spawn so a -# successful child receives ordinary blocking standard streams. A rejected -# spawn creates no child and must restore the parent's original flags. + begin - Process.spawn( -${environmentArgument} [${JSON.stringify(RUBY_POSIX_SPAWN_EXECUTABLE)}, "failed-probe"], - "--disable-gems", - "-e", - "exit 0", - in: input_read, - out: output_write, - err: error_write, - pgroup: true, - chdir: "#{child_cwd}/missing", - ) - warn "spawn unexpectedly accepted a missing working directory" - exit 16 + Process.spawn(${JSON.stringify(RUBY_VFORK_MISSING_EXECUTABLE)}) + warn "missing executable unexpectedly spawned" + exit 11 rescue Errno::ENOENT -end -unless stdio_sources.all? { |io| (io.fcntl(3) & File::NONBLOCK) != 0 } - warn "failed spawn did not restore O_NONBLOCK" - exit 17 + puts ${JSON.stringify(RUBY_VFORK_FAILED_EXEC_MARKER)} end `; - return String.raw` -child_program = ${rubySingleQuoted(childProgram)} -child_cwd = ${JSON.stringify(RUBY_POSIX_SPAWN_CWD)} -Dir.mkdir(child_cwd) unless Dir.exist?(child_cwd) - -input_read, input_write = IO.pipe -output_read, output_write = IO.pipe -error_read, error_write = IO.pipe -stdio_sources = [input_read, output_write, error_write] - -# WHY: Kandelo's Ruby pipes are nonblocking. CRuby's fork backend clears this -# flag on the child's final standard streams, which also clears it on these -# parent descriptors because both sides share the same open-file description. -# The direct posix_spawn backend must preserve that observable behavior. -unless stdio_sources.all? { |io| (io.fcntl(3) & File::NONBLOCK) != 0 } - warn "test setup did not create nonblocking pipes" - exit 10 +export const RUBY_VFORK_EXEC_PROGRAM = String.raw` +unless Process.uid == 1000 && Process.euid == 1000 + warn "successful spawn did not run as uid 1000" + exit 12 end -${failedSpawnRestoration} - pid = Process.spawn( -${environmentArgument} [${JSON.stringify(RUBY_POSIX_SPAWN_EXECUTABLE)}, "custom-argv-zero"], - "--disable-gems", - "-e", - child_program, + { "FROM" => "ruby-upstream-vfork" }, + [${JSON.stringify(RUBY_VFORK_EXECUTABLE)}, "ruby-vfork-child"], "argument-one", - in: input_read, - out: output_write, - err: error_write, - pgroup: true, - chdir: child_cwd${closeOthersOption}, ) - -${blockingAssertion} - -input_read.close -output_write.close -error_write.close -input_write.write("pipe-input") -input_write.close - -stdout = output_read.read -stderr = error_read.read -status = Process.detach(pid).value - -unless status.exitstatus == 23 - warn "unexpected child status: #{status.inspect}" - exit 12 -end - -lines = stdout.lines.map(&:chomp) -expected_lines = [ - "argv0=custom-argv-zero", - "arg1=argument-one", - "env=${expectedEnvironment}", - "input=pipe-input", -] -unless expected_lines.all? { |line| lines.include?(line) } && - lines.any? { |line| line.start_with?("cwd=") && line.end_with?("/ruby-posix-spawn-cwd") } - warn "unexpected child stdout: #{stdout.inspect}" +waited, status = Process.wait2(pid) +unless waited == pid && status.exited? && status.exitstatus == 42 + warn "unexpected exec child status: #{status.inspect}" exit 13 end - -pid_field = stdout[/^pid=(\d+)$/, 1] -pgrp_field = stdout[/^pgrp=(\d+)$/, 1] -unless pid_field && pid_field == pgrp_field - warn "child did not enter its own process group: #{stdout.inspect}" - exit 14 -end -unless stderr == "stderr-ok\n" - warn "unexpected child stderr: #{stderr.inspect}" - exit 15 -end - -puts ${JSON.stringify(marker)} -# Keep the parent alive briefly so a host-side fork-count query prompted by -# the child-spawn event cannot race process reaping. -sleep 0.25 +puts ${JSON.stringify(RUBY_VFORK_EXEC_MARKER)} `; -} -function explicitCloseFallbackProgram(marker: string): string { - return String.raw` -begin - Process.spawn( - [${JSON.stringify(RUBY_POSIX_SPAWN_EXECUTABLE)}, "invalid-close"], - "--disable-gems", - "-e", - "exit 0", - 999 => :close, - ) - warn "spawn ignored an invalid explicit close" - exit 18 -rescue Errno::EBADF +export const RUBY_PRIVILEGED_FORK_PROGRAM = String.raw` +unless Process.uid == 0 && Process.euid == 0 + warn "privileged fallback did not run as root" + exit 14 end -puts ${JSON.stringify(marker)} -# Keep the parent alive briefly so the host can sample the completed fork. -sleep 0.25 -`; -} - -function relativeExecutableFallbackProgram(marker: string): string { - return String.raw` -child_cwd = ${JSON.stringify(RUBY_POSIX_SPAWN_CWD)} -Dir.mkdir(child_cwd) unless Dir.exist?(child_cwd) - begin - # WHY: Ruby resolves this slash-containing relative path after chdir. The - # direct Kandelo spawn path resolves before its file actions, so using that - # backend here would incorrectly find /usr/bin/ruby from the parent cwd. - Process.spawn( - ["usr/bin/ruby", "relative-executable"], - "--disable-gems", - "-e", - "exit 0", - chdir: child_cwd, - ) - warn "spawn resolved a relative executable before chdir" - exit 19 -rescue Errno::ENOENT + Process.spawn(${JSON.stringify(RUBY_VFORK_MISSING_EXECUTABLE)}) + warn "root spawn unexpectedly bypassed ordinary-fork admission" + exit 15 +rescue Errno::ENOMEM + puts ${JSON.stringify(RUBY_PRIVILEGED_FORK_MARKER)} end - -puts ${JSON.stringify(marker)} -# Keep the parent alive briefly so the host can sample the completed fork. -sleep 0.25 `; -} - -export const RUBY_POSIX_SPAWN_CASES: readonly RubyPosixSpawnCase[] = [ - { - marker: "RUBY_POSIX_SPAWN_DIRECT_OK", - expectedForkCount: 0n, - expectedChildEvents: ["spawn", "exit"], - // No close_others option is intentional. This is Homebrew SystemCommand's - // shape; Ruby-created unrelated descriptors rely on ordinary CLOEXEC. - program: parentProgram(false, "RUBY_POSIX_SPAWN_DIRECT_OK"), - }, - { - marker: "RUBY_POSIX_SPAWN_INHERITED_ENV_OK", - expectedForkCount: 0n, - expectedChildEvents: ["spawn", "exit"], - // With no environment hash, CRuby leaves its private envp unset. The - // direct backend must pass the current process environment explicitly. - program: parentProgram( - false, - "RUBY_POSIX_SPAWN_INHERITED_ENV_OK", - false, - ), - }, - { - marker: "RUBY_POSIX_SPAWN_FORK_FALLBACK_OK", - expectedForkCount: 1n, - expectedChildEvents: ["spawn", "exec", "exit"], - // Explicit descriptor sweeping is not representable by Kandelo's current - // posix_spawn actions, so Ruby must retain its established fork backend. - program: parentProgram(true, "RUBY_POSIX_SPAWN_FORK_FALLBACK_OK"), - }, - { - marker: "RUBY_POSIX_SPAWN_EXPLICIT_CLOSE_FALLBACK_OK", - expectedForkCount: 1n, - expectedChildEvents: ["spawn", "exit"], - // Kandelo's spawn action ignores close(EBADF), while Ruby reports it. - // Retaining Ruby's fork backend preserves the caller-visible exception. - program: explicitCloseFallbackProgram( - "RUBY_POSIX_SPAWN_EXPLICIT_CLOSE_FALLBACK_OK", - ), - }, - { - marker: "RUBY_POSIX_SPAWN_RELATIVE_CHDIR_FALLBACK_OK", - expectedForkCount: 1n, - expectedChildEvents: ["spawn", "exit"], - // The child must change directory before resolving this relative path. - program: relativeExecutableFallbackProgram( - "RUBY_POSIX_SPAWN_RELATIVE_CHDIR_FALLBACK_OK", - ), - }, -] as const; diff --git a/packages/registry/ruby/test/posix-spawn.test.ts b/packages/registry/ruby/test/posix-spawn.test.ts index 2fcbddb9e8..4b2c1e63f9 100644 --- a/packages/registry/ruby/test/posix-spawn.test.ts +++ b/packages/registry/ruby/test/posix-spawn.test.ts @@ -1,53 +1,120 @@ +import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { tryResolveBinary } from "../../../../host/src/binary-resolver"; +import { + detectPtrWidth, + extractHeapBase, + WASM_PAGE_SIZE, +} from "../../../../host/src/constants"; +import { computeProcessMemoryLayout } from "../../../../host/src/process-memory"; import { runCentralizedProgram } from "../../../../host/test/centralized-test-helper"; import { - RUBY_POSIX_SPAWN_CASES, - RUBY_POSIX_SPAWN_EXECUTABLE, + RUBY_PRIVILEGED_FORK_MARKER, + RUBY_PRIVILEGED_FORK_PROGRAM, + RUBY_VFORK_EXEC_MARKER, + RUBY_VFORK_EXEC_PROGRAM, + RUBY_VFORK_EXECUTABLE, + RUBY_VFORK_FAILED_EXEC_MARKER, + RUBY_VFORK_FAILED_EXEC_PROGRAM, } from "./posix-spawn-contract"; const rubyBinary = tryResolveBinary("programs/ruby/ruby.wasm"); +const execChildBinary = tryResolveBinary("programs/exec-child.wasm"); + +function initialAddressSpaceBytes(programPath: string): number { + const file = readFileSync(programPath); + const bytes = file.buffer.slice( + file.byteOffset, + file.byteOffset + file.byteLength, + ); + const ptrWidth = detectPtrWidth(bytes); + return computeProcessMemoryLayout({ + ptrWidth, + programBytes: bytes, + heapBase: extractHeapBase(bytes), + }).initialPages * WASM_PAGE_SIZE; +} -describe.skipIf(!rubyBinary)("Ruby Process.spawn on Kandelo", () => { - for (const spawnCase of RUBY_POSIX_SPAWN_CASES) { - it(`${spawnCase.marker} preserves process semantics`, async () => { - const processEvents: Array<{ - kind: "spawn" | "exec" | "exit"; - pid: number; - ppid?: number; - }> = []; +describe.skipIf(!rubyBinary || !execChildBinary)( + "Ruby Process.spawn on Kandelo", + () => { + it(`${RUBY_VFORK_FAILED_EXEC_MARKER} preserves process semantics`, async () => { + const events: string[] = []; const result = await runCentralizedProgram({ programPath: rubyBinary!, - argv: ["ruby", "--disable-gems", "-e", spawnCase.program], - env: ["HOME=/tmp", "TMPDIR=/tmp", "K_TEST=inherited-env-ok"], + argv: ["ruby", "--disable-gems", "-e", RUBY_VFORK_FAILED_EXEC_PROGRAM], + env: ["HOME=/tmp", "TMPDIR=/tmp"], + uid: 1000, + gid: 1000, + // WHY: this admits the initial Ruby address space and nothing else. + // An ordinary fork would need a second full Memory and fail before it + // could report ENOENT. Reaching the failed exec proves CRuby selected + // Kandelo's borrowed-memory vfork transaction. + maxProcessMemoryBytes: initialAddressSpaceBytes(rubyBinary!), + captureForkCount: true, + onProcessEvent: (event) => events.push(event.kind), + useDefaultRootfs: false, + timeout: 180_000, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain(`${RUBY_VFORK_FAILED_EXEC_MARKER}\n`); + expect(result.hostDiagnostics).toEqual([]); + expect(result.forkCountSamples).toEqual([1n]); + expect(events).toEqual(["spawn", "spawn", "exit", "exit"]); + }, 240_000); + + it(`${RUBY_VFORK_EXEC_MARKER} preserves process semantics`, async () => { + const events: string[] = []; + const result = await runCentralizedProgram({ + programPath: rubyBinary!, + argv: ["ruby", "--disable-gems", "-e", RUBY_VFORK_EXEC_PROGRAM], + env: ["HOME=/tmp", "TMPDIR=/tmp"], + uid: 1000, + gid: 1000, execPrograms: new Map([ - [RUBY_POSIX_SPAWN_EXECUTABLE, rubyBinary!], + [RUBY_VFORK_EXECUTABLE, execChildBinary!], ]), captureForkCount: true, - onProcessEvent: (event) => processEvents.push(event), + onProcessEvent: (event) => events.push(event.kind), + useDefaultRootfs: false, + timeout: 180_000, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("argv[0]=ruby-vfork-child\n"); + expect(result.stdout).toContain("FROM=ruby-upstream-vfork\n"); + expect(result.stdout).toContain(`${RUBY_VFORK_EXEC_MARKER}\n`); + expect(result.hostDiagnostics).toEqual([]); + expect(result.forkCountSamples).toEqual([1n]); + expect(events).toEqual(["spawn", "spawn", "exec", "exit", "exit"]); + }, 240_000); + + it(`${RUBY_PRIVILEGED_FORK_MARKER} preserves process semantics`, async () => { + const events: string[] = []; + const result = await runCentralizedProgram({ + programPath: rubyBinary!, + argv: ["ruby", "--disable-gems", "-e", RUBY_PRIVILEGED_FORK_PROGRAM], + env: ["HOME=/tmp", "TMPDIR=/tmp"], + uid: 0, + gid: 0, + // Root is intentionally in CRuby's privileged ordinary-fork branch. + // With no room for a cloned Memory, both its initial attempt and the + // documented post-GC retry fail with ENOMEM. + maxProcessMemoryBytes: initialAddressSpaceBytes(rubyBinary!), + onProcessEvent: (event) => events.push(event.kind), useDefaultRootfs: false, timeout: 180_000, }); expect(result.exitCode, result.stderr).toBe(0); expect(result.stderr).toBe(""); - expect(result.stdout).toContain(`${spawnCase.marker}\n`); + expect(result.stdout).toContain(`${RUBY_PRIVILEGED_FORK_MARKER}\n`); expect(result.hostDiagnostics).toEqual([]); - expect(result.forkCountSamples).toEqual([ - spawnCase.expectedForkCount, - ]); - const rootPid = processEvents.find((event) => event.kind === "spawn")?.pid; - const childPid = processEvents.find( - (event) => event.kind === "spawn" && event.ppid === rootPid, - )?.pid; - expect(rootPid).toBeDefined(); - expect(childPid).toBeDefined(); - expect( - processEvents - .filter((event) => event.pid === childPid) - .map((event) => event.kind), - ).toEqual(spawnCase.expectedChildEvents); + expect(events).toEqual(["spawn", "spawn", "spawn", "exit"]); }, 240_000); - } -}); + }, +); diff --git a/programs/p_08_vfork.c b/programs/p_08_vfork.c index ee0aa87940..239ce64de2 100644 --- a/programs/p_08_vfork.c +++ b/programs/p_08_vfork.c @@ -1,27 +1,16 @@ -// P-08 — vfork(): if libc supports it, behavior should be parity -// with fork() for our kernel (which doesn't distinguish them). +// P-08 — vfork(): the parent resumes after the child's _exit(). // -// Coverage matrix: vfork() is a POSIX optimization where the child -// shares the parent's address space until exec/exit. Our kernel -// uses copy-on-write effectively, so vfork can degrade to fork. -// musl's vfork implementation typically aliases fork. -// -// If vfork is unsupported (returns -1 with ENOSYS), test passes -// trivially (marker: SKIP_VFORK). +// ABI 43 gives vfork a distinct transaction. The child Worker borrows the +// parent's address space without allocating/copying a process Memory, and the +// calling parent thread remains parked until exec or _exit. Keep the child in +// the portable pre-exec subset: it calls only _exit(). // // Expected output on PASS (vfork supported): // PRE_VFORK -// CHILD: ok // PARENT: child= // PASS: P-08 -// -// Expected output on PASS (vfork unsupported): -// PRE_VFORK -// SKIP_VFORK errno=... -// PASS: P-08 #include -#include #include #include #include @@ -32,17 +21,10 @@ int main(void) { pid_t pid = vfork(); if (pid < 0) { - if (errno == ENOSYS) { - printf("SKIP_VFORK errno=%d\n", errno); - printf("PASS: P-08\n"); - return 0; - } printf("FAIL: vfork errno=%d\n", errno); return 1; } if (pid == 0) { - printf("CHILD: ok\n"); - fflush(stdout); _exit(0); } printf("PARENT: child=%d\n", pid); diff --git a/programs/vfork-external-signal.c b/programs/vfork-external-signal.c new file mode 100644 index 0000000000..4396f43003 --- /dev/null +++ b/programs/vfork-external-signal.c @@ -0,0 +1,83 @@ +/* + * A fatal signal delivered while a vfork child is computing cannot use the + * child's syscall channel as an exact quiescence fence. + * + * The child deliberately enters a no-syscall loop after publishing its PID to + * a sibling parent pthread. The sibling sends SIGKILL. Kandelo must contain + * the complete shared address space instead of resuming the parked caller + * after a forced Worker termination whose completion is not an exact fence. + */ +#include +#include +#include +#include +#include + +static int child_ready[2]; +static int killer_started[2]; +static volatile unsigned compute_sink; + +static void write_all(int fd, const void *buffer, size_t length) { + const char *bytes = buffer; + while (length > 0) { + ssize_t written = write(fd, bytes, length); + if (written < 0 && errno == EINTR) continue; + if (written <= 0) _exit(120); + bytes += written; + length -= (size_t)written; + } +} + +static void read_all(int fd, void *buffer, size_t length) { + char *bytes = buffer; + while (length > 0) { + ssize_t count = read(fd, bytes, length); + if (count < 0 && errno == EINTR) continue; + if (count <= 0) _exit(121); + bytes += count; + length -= (size_t)count; + } +} + +#define MARKER(text) write_all(STDOUT_FILENO, text, sizeof(text) - 1) + +static void *kill_compute_borrower(void *argument) { + (void)argument; + write_all(killer_started[1], "R", 1); + pid_t pid; + read_all(child_ready[0], &pid, sizeof(pid)); + + if (kill(pid, SIGKILL) != 0) _exit(91); + MARKER("KILLER_SENT_SIGKILL\n"); + + // The host should terminate this sibling while containing the parent. + for (;;) pause(); +} + +int main(void) { + MARKER("VFORK_EXTERNAL_SIGNAL_BEGIN\n"); + + if (pipe(child_ready) != 0 || pipe(killer_started) != 0) return 1; + pthread_t killer; + if (pthread_create(&killer, NULL, kill_compute_borrower, NULL) != 0) { + return 2; + } + char ready; + read_all(killer_started[0], &ready, 1); + MARKER("KILLER_THREAD_READY\n"); + + pid_t pid = vfork(); + if (pid < 0) return 3; + if (pid == 0) { + pid_t self = getpid(); + MARKER("CHILD_COMPUTE_LOOP\n"); + write_all(child_ready[1], &self, sizeof(self)); + + // No syscall boundary follows this point. Forced Worker termination + // alone must not authorize the parent to touch the shared bytes. + for (;;) compute_sink++; + } + + MARKER("UNSAFE_PARENT_RESUMED\n"); + return 4; +} diff --git a/programs/vfork-fatal-lifecycle.c b/programs/vfork-fatal-lifecycle.c new file mode 100644 index 0000000000..62d90a93f3 --- /dev/null +++ b/programs/vfork-fatal-lifecycle.c @@ -0,0 +1,67 @@ +/* + * End-to-end fatal vfork child coverage. + * + * A child trap and an uncatchable signal must both terminate the borrowing + * Worker, release only after an exact ownership fence, wake the parked parent + * caller, and remain waitable with the truthful signal status. + */ +#include +#include +#include +#include +#include + +static void marker(const char *text, size_t length) { + while (length > 0) { + ssize_t written = write(STDOUT_FILENO, text, length); + if (written < 0 && errno == EINTR) continue; + if (written <= 0) _exit(120); + text += written; + length -= (size_t)written; + } +} + +#define MARKER(text) marker(text, sizeof(text) - 1) + +static int wait_for_signal(pid_t pid, int expected_signal) { + int status = 0; + if (waitpid(pid, &status, 0) != pid) return 1; + if (!WIFSIGNALED(status) || WTERMSIG(status) != expected_signal) return 1; + return 0; +} + +static int trap_cycle(void) { + pid_t pid = vfork(); + if (pid < 0) return 1; + if (pid == 0) { + MARKER("CHILD_BEFORE_TRAP\n"); + __builtin_trap(); + _exit(91); + } + MARKER("PARENT_AFTER_TRAP\n"); + if (wait_for_signal(pid, SIGILL) != 0) return 1; + MARKER("PARENT_REAPED_TRAP\n"); + return 0; +} + +static int signal_cycle(void) { + pid_t pid = vfork(); + if (pid < 0) return 1; + if (pid == 0) { + MARKER("CHILD_BEFORE_SIGKILL\n"); + if (kill(getpid(), SIGKILL) != 0) _exit(92); + _exit(93); + } + MARKER("PARENT_AFTER_SIGKILL\n"); + if (wait_for_signal(pid, SIGKILL) != 0) return 1; + MARKER("PARENT_REAPED_SIGKILL\n"); + return 0; +} + +int main(void) { + MARKER("VFORK_FATAL_BEGIN\n"); + if (trap_cycle() != 0) return 1; + if (signal_cycle() != 0) return 2; + MARKER("PASS: VFORK_FATAL_LIFECYCLE\n"); + return 0; +} diff --git a/programs/vfork-from-thread.c b/programs/vfork-from-thread.c new file mode 100644 index 0000000000..be863c468a --- /dev/null +++ b/programs/vfork-from-thread.c @@ -0,0 +1,87 @@ +/* + * Prove that vfork parks only its calling pthread. + * + * The vfork child cannot exit until the process main thread performs pipe I/O. + * Suspending the whole parent process would deadlock. Resuming the calling + * pthread early would put THREAD_CALLER_RESUMED before CHILD_THREAD_EXIT. + */ +#include +#include +#include +#include +#include + +static int child_ready[2]; +static int child_release[2]; +static pid_t child_pid = -1; +static int thread_error; + +static int write_all(int fd, const char *bytes, size_t length) { + while (length > 0) { + ssize_t written = write(fd, bytes, length); + if (written < 0 && errno == EINTR) continue; + if (written <= 0) return -1; + bytes += written; + length -= (size_t)written; + } + return 0; +} + +static int read_one(int fd) { + char byte; + for (;;) { + ssize_t count = read(fd, &byte, 1); + if (count == 1) return 0; + if (count < 0 && errno == EINTR) continue; + return -1; + } +} + +#define MARKER(text) write_all(STDOUT_FILENO, text, sizeof(text) - 1) + +static void *vforking_thread(void *unused) { + (void)unused; + if (MARKER("THREAD_BEFORE_VFORK\n") != 0) { + thread_error = 1; + return NULL; + } + + pid_t pid = vfork(); + if (pid < 0) { + thread_error = 2; + return NULL; + } + if (pid == 0) { + if (write_all(child_ready[1], "R", 1) != 0) _exit(91); + if (read_one(child_release[0]) != 0) _exit(92); + if (MARKER("CHILD_THREAD_EXIT\n") != 0) _exit(93); + _exit(0); + } + + child_pid = pid; + if (MARKER("THREAD_CALLER_RESUMED\n") != 0) thread_error = 3; + return NULL; +} + +int main(void) { + if (pipe(child_ready) != 0 || pipe(child_release) != 0) return 1; + + pthread_t thread; + if (pthread_create(&thread, NULL, vforking_thread, NULL) != 0) return 2; + + if (read_one(child_ready[0]) != 0) return 3; + if (MARKER("MAIN_SIBLING_RAN\n") != 0) return 4; + if (write_all(child_release[1], "X", 1) != 0) return 5; + if (MARKER("MAIN_RELEASED_CHILD\n") != 0) return 6; + + if (pthread_join(thread, NULL) != 0 || thread_error != 0) return 7; + if (MARKER("MAIN_JOINED_CALLER\n") != 0) return 8; + if (child_pid <= 0) return 9; + int status = 0; + if (waitpid(child_pid, &status, 0) != child_pid) return 10; + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) return 11; + if (MARKER("MAIN_REAPED_CHILD\n") != 0) return 12; + + if (MARKER("PASS: VFORK_FROM_THREAD\n") != 0) return 13; + return 0; +} diff --git a/programs/vfork-lifecycle.c b/programs/vfork-lifecycle.c new file mode 100644 index 0000000000..dcd485d31e --- /dev/null +++ b/programs/vfork-lifecycle.c @@ -0,0 +1,124 @@ +/* + * End-to-end vfork lifetime coverage. + * + * The child stays within Kandelo's supported pre-exec boundary: it either + * calls execve(), _exit(), or probes ownership-creating calls that must fail + * with EAGAIN before they can change parent-owned continuation state. + */ +#include +#include +#include +#include +#include + +static void marker(const char *text, size_t length) { + while (length > 0) { + ssize_t written = write(STDOUT_FILENO, text, length); + if (written < 0 && errno == EINTR) continue; + if (written <= 0) _exit(120); + text += written; + length -= (size_t)written; + } +} + +#define MARKER(text) marker(text, sizeof(text) - 1) + +static int wait_for_exit(pid_t pid, int expected) { + int status = 0; + if (waitpid(pid, &status, 0) != pid) return 1; + if (!WIFEXITED(status) || WEXITSTATUS(status) != expected) return 1; + return 0; +} + +static int exit_cycle(const char *child_text, size_t child_length, + const char *parent_text, size_t parent_length) { + pid_t pid = vfork(); + if (pid < 0) return 1; + if (pid == 0) { + marker(child_text, child_length); + _exit(0); + } + marker(parent_text, parent_length); + return wait_for_exit(pid, 0); +} + +static int failed_exec_cycle(void) { + pid_t pid = vfork(); + if (pid < 0) return 1; + if (pid == 0) { + char *const argv[] = { (char *)"missing-vfork-target", NULL }; + char *const envp[] = { NULL }; + execve("/bin/missing-vfork-target", argv, envp); + if (errno != ENOENT) _exit(91); + MARKER("CHILD_FAILED_EXEC\n"); + _exit(0); + } + MARKER("PARENT_AFTER_FAILED_EXEC_EXIT\n"); + return wait_for_exit(pid, 0); +} + +static void *unused_thread(void *argument) { + return argument; +} + +static int rejected_ownership_cycle(void) { + pid_t pid = vfork(); + if (pid < 0) return 1; + if (pid == 0) { + errno = 0; + if (fork() != -1 || errno != EAGAIN) _exit(92); + MARKER("CHILD_NESTED_FORK_EAGAIN\n"); + + errno = 0; + if (vfork() != -1 || errno != EAGAIN) _exit(93); + MARKER("CHILD_NESTED_VFORK_EAGAIN\n"); + + pthread_t thread; + if (pthread_create(&thread, NULL, unused_thread, NULL) != EAGAIN) { + _exit(94); + } + MARKER("CHILD_PTHREAD_EAGAIN\n"); + _exit(0); + } + MARKER("PARENT_AFTER_REJECTED_OWNERSHIP\n"); + return wait_for_exit(pid, 0); +} + +static int successful_exec_cycle(void) { + pid_t pid = vfork(); + if (pid < 0) return 1; + if (pid == 0) { + char *const argv[] = { + (char *)"vfork-exec-child", + (char *)"from-vfork", + NULL, + }; + char *const envp[] = { (char *)"FROM=vfork", NULL }; + execve("/bin/vfork-exec-child", argv, envp); + _exit(95); + } + // The caller resumes at the successful exec commit, not child exit. + MARKER("PARENT_AFTER_EXEC_COMMIT\n"); + if (wait_for_exit(pid, 42) != 0) return 1; + MARKER("PARENT_REAPED_EXEC_CHILD\n"); + return 0; +} + +int main(void) { + MARKER("VFORK_LIFECYCLE_BEGIN\n"); + if (exit_cycle( + "CHILD_EXIT_ONE\n", sizeof("CHILD_EXIT_ONE\n") - 1, + "PARENT_RESUME_ONE\n", sizeof("PARENT_RESUME_ONE\n") - 1)) { + return 1; + } + if (exit_cycle( + "CHILD_EXIT_TWO\n", sizeof("CHILD_EXIT_TWO\n") - 1, + "PARENT_RESUME_TWO\n", sizeof("PARENT_RESUME_TWO\n") - 1)) { + return 2; + } + if (failed_exec_cycle() != 0) return 3; + if (rejected_ownership_cycle() != 0) return 4; + if (successful_exec_cycle() != 0) return 5; + MARKER("PASS: VFORK_LIFECYCLE\n"); + return 0; +} diff --git a/programs/vfork-posix-state.c b/programs/vfork-posix-state.c new file mode 100644 index 0000000000..2c7ba62ade --- /dev/null +++ b/programs/vfork-posix-state.c @@ -0,0 +1,82 @@ +/* + * End-to-end vfork process-state coverage. + * + * The child borrows only the parent's address space. Its descriptor table, + * cwd, credentials, and process-group membership remain independent Process + * state, while inherited descriptors continue to reference the same open file + * description (OFD). + */ +#include +#include +#include +#include +#include +#include +#include + +static void marker(const char *text, size_t length) { + while (length > 0) { + ssize_t written = write(STDOUT_FILENO, text, length); + if (written < 0 && errno == EINTR) continue; + if (written <= 0) _exit(120); + text += written; + length -= (size_t)written; + } +} + +#define MARKER(text) marker(text, sizeof(text) - 1) + +int main(void) { + static const char contents[] = "abcdef"; + char cwd[64]; + char byte = '\0'; + int status = 0; + + if (getuid() != 0 || geteuid() != 0) return 1; + if (getgid() != 0 || getegid() != 0) return 2; + if (mkdir("/tmp/vfork-parent", 0755) != 0 && errno != EEXIST) return 3; + if (chdir("/tmp/vfork-parent") != 0) return 4; + + int fd = open("ofd-state", O_CREAT | O_TRUNC | O_RDWR, 0644); + if (fd < 0) return 5; + if (write(fd, contents, sizeof(contents) - 1) != sizeof(contents) - 1) { + return 6; + } + if (lseek(fd, 0, SEEK_SET) != 0) return 7; + if (fcntl(fd, F_GETFD) != 0) return 8; + + pid_t parent_pgrp = getpgrp(); + if (parent_pgrp <= 0) return 9; + + pid_t pid = vfork(); + if (pid < 0) return 10; + if (pid == 0) { + if (getuid() != 0 || getgid() != 0) _exit(21); + if (getpgrp() != parent_pgrp) _exit(22); + if (lseek(fd, 2, SEEK_SET) != 2) _exit(23); + if (fcntl(fd, F_SETFD, FD_CLOEXEC) != 0) _exit(24); + if (close(fd) != 0) _exit(25); + if (chdir("/") != 0) _exit(26); + if (setpgid(0, 0) != 0) _exit(27); + if (setgid(1234) != 0) _exit(28); + if (setuid(1234) != 0) _exit(29); + _exit(0); + } + + MARKER("PARENT_AFTER_STATE_CHILD\n"); + if (getuid() != 0 || geteuid() != 0) return 11; + if (getgid() != 0 || getegid() != 0) return 12; + if (getpgrp() != parent_pgrp) return 13; + if (getpgid(pid) != pid) return 14; + if (getcwd(cwd, sizeof(cwd)) == NULL) return 15; + if (strcmp(cwd, "/tmp/vfork-parent") != 0) return 16; + if (fcntl(fd, F_GETFD) != 0) return 17; + if (read(fd, &byte, 1) != 1 || byte != 'c') return 18; + if (waitpid(pid, &status, 0) != pid) return 19; + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) return 20; + if (close(fd) != 0) return 30; + + MARKER("PARENT_REAPED_STATE_CHILD\n"); + MARKER("PASS: VFORK_POSIX_STATE\n"); + return 0; +} diff --git a/scripts/ci-vitest-resource-isolated-cases.tsv b/scripts/ci-vitest-resource-isolated-cases.tsv index 1beb587d3a..200e149f4f 100644 --- a/scripts/ci-vitest-resource-isolated-cases.tsv +++ b/scripts/ci-vitest-resource-isolated-cases.tsv @@ -7,8 +7,6 @@ # the complete case inventory declarative lets CI exclude its file from the # ordinary shards and use fresh OS-process exit as the deterministic memory # reclamation boundary without dropping test coverage. -packages/registry/ruby/test/posix-spawn.test.ts RUBY_POSIX_SPAWN_DIRECT_OK -packages/registry/ruby/test/posix-spawn.test.ts RUBY_POSIX_SPAWN_INHERITED_ENV_OK -packages/registry/ruby/test/posix-spawn.test.ts RUBY_POSIX_SPAWN_FORK_FALLBACK_OK -packages/registry/ruby/test/posix-spawn.test.ts RUBY_POSIX_SPAWN_EXPLICIT_CLOSE_FALLBACK_OK -packages/registry/ruby/test/posix-spawn.test.ts RUBY_POSIX_SPAWN_RELATIVE_CHDIR_FALLBACK_OK +packages/registry/ruby/test/posix-spawn.test.ts RUBY_UPSTREAM_VFORK_FAILED_EXEC_OK +packages/registry/ruby/test/posix-spawn.test.ts RUBY_UPSTREAM_VFORK_EXEC_OK +packages/registry/ruby/test/posix-spawn.test.ts RUBY_PRIVILEGED_FORK_FALLBACK_OK diff --git a/scripts/pack-ci-test-workspace.sh b/scripts/pack-ci-test-workspace.sh index 0c2397920b..52d50071b6 100755 --- a/scripts/pack-ci-test-workspace.sh +++ b/scripts/pack-ci-test-workspace.sh @@ -406,20 +406,19 @@ if [ -e local-binaries ] || [ -L local-binaries ]; then unsafe_local_link="$( find "$stage/local-binaries" -type l -print0 | while IFS= read -r -d '' link; do - case "$(readlink "$link")" in - /*) - printf '%s\n' "$link" - break - ;; - esac + # WHY: macOS Bash 3.2 misparses case patterns inside this outer + # command substitution. Prefix removal preserves the same + # absolute/escape checks in prepared workspaces on every host. + link_target="$(readlink "$link")" + if [ "${link_target#/}" != "$link_target" ]; then + printf '%s\n' "$link" + break + fi resolved="$(realpath "$link" 2>/dev/null || true)" - case "$resolved" in - "$stage/local-binaries"/*) ;; - *) - printf '%s\n' "$link" - break - ;; - esac + if [ "${resolved#"$stage/local-binaries"/}" = "$resolved" ]; then + printf '%s\n' "$link" + break + fi done )" if [ -n "$unsafe_local_link" ]; then diff --git a/scripts/resolve-binary.bundle.mjs b/scripts/resolve-binary.bundle.mjs index bdb774d1df..972b72fdf7 100644 --- a/scripts/resolve-binary.bundle.mjs +++ b/scripts/resolve-binary.bundle.mjs @@ -1,13 +1,13 @@ // Generated by scripts/build-resolve-binary-bundle.sh; do not edit. Third-party notices: resolve-binary.bundle.LICENSES.txt -var ca=Object.defineProperty;var Ar=(n,e,t)=>()=>{if(t)throw t[0];try{return n&&(e=n(n=0)),e}catch(r){throw t=[r],r}};var bi=(n,e)=>{for(var t in e)ca(n,t,{get:e[t],enumerable:!0})};var Ft,Pi,Nt,ki,un,Fi,ne,Ni,Ci,Ir,Mi,dn,Di,Ki,Bi,$i,Rr,xr,ln,fn,hn,pn,mn,ht,Ct,Mt,Dt,ve,Ui,Wi,Tr,Se,Gi,vr,_n,Lr,zr,pt,Kt,$e,yn,gn,Hi,br,Vi,X,qi,Zi,Pr,Bt,Xi,Yi,ji,Y,Ji,Qi,kr,$t,eo,to,En,Sn,mt,wn,Ut,Wt,W,Gt,ro,no,q,et=Ar(()=>{"use strict";Ft="kandelo.wpk_fork.linked_frames",Pi=[75,76,67,70],Nt=24,ki=8,un=3,Fi=[{bytes:4,chunkHeaderSize:32,nodeHeaderSize:24},{bytes:8,chunkHeaderSize:56,nodeHeaderSize:32}],ne="kandelo.wpk_fork.module_state",Ni=1,Ci=[75,70,77,68],Ir=24,Mi=8,dn=7,Di=1,Ki=1,Bi=1,$i=[{bytes:4,chunkHeaderSize:40},{bytes:8,chunkHeaderSize:56}],Rr="__wpk_fork_global_",xr="__wpk_fork_table_",ln=1,fn=2,hn=3,pn=4,mn=5,ht=6,Ct=7,Mt=8,Dt=9,ve="kandelo.wpk_fork.capabilities",Ui=1,Wi=7,Tr=4,Se="kandelo.wpk_fork.exception_codec",Gi=1,vr=8,_n=16,Lr="env",zr="__wpk_fork_unwind",pt="kandelo.wpk_fork.unwind_transport",Kt="__wpk_fork_static_root_catalog",$e="kandelo.wpk_fork.static_root_catalog",yn=1,gn=0,Hi=1,br=12,Vi=[75,70,83,82],X="kandelo.wpk_fork.imported_globals",qi=[75,70,73,71],Zi=1,Pr=16,Bt=24,Xi=1,Yi=2,ji=3,Y="kandelo.wpk_fork.imported_tables",Ji=[75,70,73,84],Qi=1,kr=16,$t=24,eo=1,to=1,En="env",Sn="__wpk_fork_module_activation",mt=[{module:"env",name:"__wpk_fork_frame_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_frame_next",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_peek",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_reserve",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_record_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_module_state_record_find",params:["i32","i32","i32","i32"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_record_reserve",params:["i32","i32","i32","ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_table_dirty_count",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_module_state_table_dirty_mark",params:["i32","i64","i64"],results:[]},{module:"env",name:"__wpk_fork_module_state_table_dirty_page",params:["i32","i32"],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_mutation_abort",params:[],results:[]},{module:"env",name:"__wpk_fork_module_state_table_mutation_begin",params:[],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_mutation_commit",params:["i32","i64","i64"],results:[]},{module:"env",name:"__wpk_fork_module_state_table_reconcile",params:[],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_state_owned",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_decode_funcref",params:["i32"],results:["funcref"]},{module:"env",name:"__wpk_fork_ref_encode_funcref",params:["funcref"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_broker_encode",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_broker_throw_recipe",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_cache_index",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_claim",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_define",params:["i32","i32","i32","i32","ptr","i32","ptr","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_ingress_throw",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_load",params:["i32","i32","i32","i32","ptr","i32","ptr","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_lookup",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_route",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_broker_encode",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_capture_layout",params:["i32","i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_claim",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_define",params:["i32","i32","i32","i32","i32","ptr","i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_i31",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_load",params:["i32","i32","i32","i32","i32","ptr","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_lookup",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_payload_len",params:["i32","i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_provenance_begin",params:["i32","i32","i32","i32","i64","i64","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_provenance_end",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_provenance_ref",params:["i32","i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_route",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_scratch_release",params:["ptr","ptr"],results:[]},{module:"env",name:"__wpk_fork_ref_scratch_reserve",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_ref_vector_append",params:["i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_vector_begin",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_vector_finish",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_vector_get",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_resume_peek",params:["i32"],results:["i32"]}],wn=[{module:"env",name:"__wpk_fork_ref_gc_transit",table64:!1,element:"anyref",minimum:1,maximum:null},{module:"env",name:"__wpk_fork_resume_table",table64:!1,element:"funcref",minimum:1,maximum:null}],Ut=[{name:"__wpk_fork_exception_materialize",params:["i32"],results:[]},{name:"__wpk_fork_ref_decode_exnref",params:["i32"],results:["exnref"]},{name:"__wpk_fork_ref_encode_exnref",params:["exnref"],results:["i32"]},{name:"__wpk_fork_ref_exn_abort",params:[],results:[]},{name:"__wpk_fork_ref_exn_clear",params:[],results:[]},{name:"__wpk_fork_ref_exn_encode_ingress",params:["i32"],results:["i32"]},{name:"__wpk_fork_ref_exn_throw_recipe",params:["i32"],results:[]},{name:"__wpk_fork_ref_exn_throw_slot",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_allocate",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_encode_slot",params:["i32"],results:["i32"]},{name:"__wpk_fork_ref_gc_fill",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_probe",params:["i32"],results:["i64"]},{name:"__wpk_fork_ref_gc_publish_externref",params:["i32","externref"],results:[]},{name:"__wpk_fork_static_root_harvest",params:[],results:[]},{name:"wpk_fork_abort_begin",params:["ptr"],results:[]},{name:"wpk_fork_abort_end",params:[],results:[]},{name:"wpk_fork_module_bootstrap",params:[],results:[]},{name:"wpk_fork_module_state_finish_restore",params:["i32"],results:[]},{name:"wpk_fork_module_state_restore",params:["i32"],results:[]},{name:"wpk_fork_module_state_save",params:["i32"],results:[]},{name:"wpk_fork_module_table_state_restore",params:["i32"],results:[]},{name:"wpk_fork_module_table_state_save",params:["i32"],results:[]},{name:"wpk_fork_module_thread_bootstrap",params:[],results:[]},{name:"wpk_fork_rewind_begin",params:["ptr"],results:[]},{name:"wpk_fork_rewind_end",params:[],results:[]},{name:"wpk_fork_state",params:[],results:["i32"]},{name:"wpk_fork_unwind_begin",params:["ptr"],results:[]},{name:"wpk_fork_unwind_end",params:[],results:[]}],Wt={O_RDONLY:0,O_WRONLY:1,O_RDWR:2,O_ACCMODE:3,O_CREAT:64,O_EXCL:128,O_NOCTTY:256,O_TRUNC:512,O_APPEND:1024,O_NONBLOCK:2048,O_ASYNC:8192,O_DIRECTORY:65536,O_NOFOLLOW:131072,O_CLOEXEC:524288,O_PATH:2097152,O_CLOFORK:8388608},W={S_IFMT:61440,S_IFSOCK:49152,S_IFLNK:40960,S_IFREG:32768,S_IFBLK:24576,S_IFDIR:16384,S_IFCHR:8192,S_IFIFO:4096,S_ISUID:2048,S_ISGID:1024,S_ISVTX:512,S_IRWXU:448,S_IRUSR:256,S_IWUSR:128,S_IXUSR:64,S_IRWXG:56,S_IRGRP:32,S_IWGRP:16,S_IXGRP:8,S_IRWXO:7,S_IROTH:4,S_IWOTH:2,S_IXOTH:1,S_MODE_BITS:4095},Gt={DT_UNKNOWN:0,DT_FIFO:1,DT_CHR:2,DT_DIR:4,DT_BLK:6,DT_REG:8,DT_LNK:10,DT_SOCK:12},ro=4096,no=["__abi_version","kernel_alloc_scratch","kernel_blocking_retry_release","kernel_blocking_retry_token","kernel_commit_process_exit","kernel_create_process","kernel_create_process_with_stdio","kernel_dequeue_signal","kernel_exec_prepare","kernel_exec_setup_for_thread","kernel_fork_process","kernel_get_cwd","kernel_get_dirfd_path","kernel_get_fd_path","kernel_get_parent_pid","kernel_get_process_exit_signal","kernel_get_process_state","kernel_get_socket_timeout_ms","kernel_handle_channel","kernel_has_sa_nocldstop","kernel_host_adapter_manifest_len","kernel_host_adapter_manifest_ptr","kernel_ipc_shm_lookup_mapping_for_task","kernel_ipc_shm_record_mapping_for_process","kernel_ipc_shm_record_mapping_for_task","kernel_ipc_shmat_for_process","kernel_ipc_shmat_for_task","kernel_ipc_shmdt_addr_for_process","kernel_ipc_shmdt_addr_for_task","kernel_ipc_shmdt_for_process","kernel_ipc_shmdt_for_task","kernel_is_fd_nonblock","kernel_mark_process_signaled","kernel_mq_descriptor_msgsize","kernel_msqid_ds_bytes","kernel_pcm_claim_transport","kernel_pcm_clock_update","kernel_pcm_reconcile","kernel_pcm_transport_len","kernel_pcm_transport_ptr","kernel_pick_signal_target_tid","kernel_pick_tcp_listener_target","kernel_pipe_has_readers","kernel_posix_timer_fire","kernel_process_metadata_begin","kernel_process_metadata_cancel","kernel_process_metadata_commit","kernel_process_metadata_stage","kernel_reap_exited_child","kernel_remove_process","kernel_semctl_array_bytes","kernel_semid_ds_bytes","kernel_set_current_tid","kernel_set_cwd","kernel_shmid_ds_bytes","kernel_spawn_process","kernel_spawn_reserved_process","kernel_spawn_scratch_begin","kernel_spawn_scratch_cancel","kernel_spawn_scratch_capacity","kernel_spawn_scratch_pointer","kernel_spawn_scratch_retained_capacity","kernel_take_process_timer_cleanup","kernel_thread_exit","kernel_thread_has_deliverable","kernel_transfer_channel_execute","kernel_transfer_io_execute","kernel_transfer_scratch_begin","kernel_transfer_scratch_cancel","kernel_transfer_scratch_capacity","kernel_transfer_scratch_pointer","kernel_validate_task","kernel_wait_child_poll"],q={LINK_MAX:0,MAX_CANON:1,MAX_INPUT:2,NAME_MAX:3,PATH_MAX:4,PIPE_BUF:5,CHOWN_RESTRICTED:6,NO_TRUNC:7,VDISABLE:8,SYNC_IO:9,ASYNC_IO:10,PRIO_IO:11,SOCK_MAXBUF:12,FILESIZEBITS:13,REC_INCR_XFER_SIZE:14,REC_MAX_XFER_SIZE:15,REC_MIN_XFER_SIZE:16,REC_XFER_ALIGN:17,ALLOC_SIZE_MIN:18,SYMLINK_MAX:19,POSIX2_SYMLINKS:20,FALLOC:21,TEXTDOMAIN_MAX:22,TIMESTAMP_RESOLUTION:23}});import{createRequire as bc}from"module";function Qo(n,e){return Jo(n,{i:2},e&&e.out,e&&e.dictionary)}var Pc,Tt,kc,Fc,se,Rt,Nc,Ho,Vo,Cc,qo,Tt,Zo,Mc,Xo,Dc,Qd,qn,Pe,M,ar,cr,M,M,M,M,Yo,M,Kc,Bc,Hn,Ae,Vn,jo,Gr,$c,_e,Jo,Uc,Wc,xt,es,Gc,Hc,Zn=Ar(()=>{Pc=bc("/");try{Tt=Pc("worker_threads"),kc=Tt.Worker,Fc=Tt.isMarkedAsUntransferable}catch{}se=Uint8Array,Rt=Uint16Array,Nc=Int32Array,Ho=new se([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),Vo=new se([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),Cc=new se([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),qo=function(n,e){for(var t=new Rt(31),r=0;r<31;++r)t[r]=e+=1<>1|(M&21845)<<1,Pe=(Pe&52428)>>2|(Pe&13107)<<2,Pe=(Pe&61680)>>4|(Pe&3855)<<4,qn[M]=((Pe&65280)>>8|(Pe&255)<<8)>>1;ar=(function(n,e,t){for(var r=n.length,i=0,s=new Rt(e);i>c]=u}else for(a=new Rt(r),i=0;i>15-n[i]);return a}),cr=new se(288);for(M=0;M<144;++M)cr[M]=8;for(M=144;M<256;++M)cr[M]=9;for(M=256;M<280;++M)cr[M]=7;for(M=280;M<288;++M)cr[M]=8;Yo=new se(32);for(M=0;M<32;++M)Yo[M]=5;Kc=ar(cr,9,1),Bc=ar(Yo,5,1),Hn=function(n){for(var e=n[0],t=1;te&&(e=n[t]);return e},Ae=function(n,e,t){var r=e/8|0;return(n[r]|n[r+1]<<8)>>(e&7)&t},Vn=function(n,e){var t=e/8|0;return(n[t]|n[t+1]<<8|n[t+2]<<16)>>(e&7)},jo=function(n){return(n+7)/8|0},Gr=function(n,e,t){return(e==null||e<0)&&(e=0),(t==null||t>n.length)&&(t=n.length),new se(n.subarray(e,t))},$c=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],_e=function(n,e,t){var r=new Error(e||$c[n]);if(r.code=n,Error.captureStackTrace&&Error.captureStackTrace(r,_e),!t)throw r;return r},Jo=function(n,e,t,r){var i=n.length,s=r?r.length:0;if(!i||e.f&&!e.l)return t||new se(0);var o=!t,a=o||e.i!=2,c=e.i;o&&(t=new se(i*3));var u=function(Ke){var Be=t.length;if(Ke>Be){var Or=new se(Math.max(Be*2,Ke));Or.set(t),t=Or}},l=e.f||0,d=e.p||0,p=e.b||0,m=e.l,f=e.d,h=e.m,g=e.n,_=i*8;do{if(!m){l=Ae(n,d,1);var y=Ae(n,d+1,3);if(d+=3,y)if(y==1)m=Kc,f=Bc,h=9,g=5;else if(y==2){var S=Ae(n,d,31)+257,A=Ae(n,d+10,15)+4,R=S+Ae(n,d+5,31)+1;d+=14;for(var x=new se(R),v=new se(19),L=0;L>4;if(E<16)x[L++]=E;else{var U=0,le=0;for(E==16?(le=3+Ae(n,d,3),d+=2,U=x[L-1]):E==17?(le=3+Ae(n,d,7),d+=3):E==18&&(le=11+Ae(n,d,127),d+=7);le--;)x[L++]=U}}var C=x.subarray(0,S),V=x.subarray(S);h=Hn(C),g=Hn(V),m=ar(C,h,1),f=ar(V,g,1)}else _e(1);else{var E=jo(d)+4,O=n[E-4]|n[E-3]<<8,w=E+O;if(w>i){c&&_e(0);break}a&&u(p+O),t.set(n.subarray(E,w),p),e.b=p+=O,e.p=d=w*8,e.f=l;continue}if(d>_){c&&_e(0);break}}a&&u(p+131072);for(var Pt=(1<>4;if(d+=U&15,d>_){c&&_e(0);break}if(U||_e(2),Te<256)t[p++]=Te;else if(Te==256){Je=d,m=null;break}else{var kt=Te-254;if(Te>264){var L=Te-257,Me=Ho[L];kt=Ae(n,d,(1<>4;lt||_e(3),d+=lt&15;var V=Dc[Ee];if(Ee>3){var Me=Vo[Ee];V+=Vn(n,d)&(1<_){c&&_e(0);break}a&&u(p+131072);var De=p+kt;if(p>3&1)+(e>>4&1);r>0;r-=!n[t++]);return t+(e&2)},xt=(function(){function n(e,t){typeof e=="function"&&(t=e,e={}),this.ondata=t;var r=e&&e.dictionary&&e.dictionary.subarray(-32768);this.s={i:0,b:r?r.length:0},this.o=new se(32768),this.p=new se(0),r&&this.o.set(r)}return n.prototype.e=function(e){if(this.ondata||_e(5),this.d&&_e(4),!this.p.length)this.p=e;else if(e.length){var t=new se(this.p.length+e.length);t.set(this.p),t.set(e,this.p.length),this.p=t}},n.prototype.c=function(e){this.s.i=+(this.d=e||!1);var t=this.s.b,r=Jo(this.p,this.s,this.o);this.ondata(Gr(r,t,this.s.b),this.d),this.o=Gr(r,this.s.b-32768),this.s.b=this.o.length,this.p=Gr(this.p,this.s.p/8|0),this.s.p&=7},n.prototype.push=function(e,t){this.e(e),this.c(t)},n})();es=(function(){function n(e,t){this.v=1,this.r=0,xt.call(this,e,t)}return n.prototype.push=function(e,t){if(xt.prototype.e.call(this,e),this.r+=e.length,this.v){var r=this.p.subarray(this.v-1),i=r.length>3?Wc(r):4;if(i>r.length){if(!t)return}else this.v>1&&this.onmember&&this.onmember(this.r-r.length);this.p=r.subarray(i),this.v=0}xt.prototype.c.call(this,0),this.s.f&&!this.s.l?(this.v=jo(this.s.p)+9,this.s={i:0},this.o=new se(0),this.push(new se(0),t)):t&&xt.prototype.c.call(this,t)},n})(),Gc=typeof TextDecoder<"u"&&new TextDecoder,Hc=0;try{Gc.decode(Uc,{stream:!0}),Hc=1}catch{}});var jn={};bi(jn,{extractZipEntry:()=>Qc,extractZipEntryBounded:()=>eu,fetchZipCentralDirectory:()=>ru,parseZipCentralDirectory:()=>ur});function ss(n){let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=Math.max(0,n.length-ns);for(let r=n.length-Zc;r>=t;r--)if(e.getUint32(r,!0)===Vc)return r;throw new Error("Zip EOCD record not found")}function ur(n){let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=ss(n),r=e.getUint16(t+10,!0),i=e.getUint32(t+16,!0),s=[],o=i;for(let a=0;a>8,O;E===ts?O=h>>16&65535:y.startsWith("bin/")||y.startsWith("sbin/")||y.includes("/bin/")||y.includes("/sbin/")?O=493:O=420;let w=y.endsWith("/"),S=E===ts&&(O&Yc)===Xc;s.push({fileName:y,fileNameBytes:_,compressedSize:l,uncompressedSize:d,compressionMethod:u,localHeaderOffset:g,mode:O,isDirectory:w,isSymlink:S,externalAttrs:h,creatorOS:E}),o+=Xn+p+m+f}return s}function as(n,e){if(n.byteLength!==e.byteLength)return!1;for(let t=0;t{if(a.byteLength>t-s)throw new Error(`ZIP member ${e.fileName} expands beyond ${t} bytes`);i.set(a,s),s+=a.byteLength}).push(r,!0),s!==t)throw new Error(`ZIP member ${e.fileName} expanded ${s} bytes, expected ${t}`);return i}function tu(n,e){let t=new DataView(n.buffer,n.byteOffset,n.byteLength),r=e.localHeaderOffset;if(r<0||r>n.byteLength-Yn||t.getUint32(r,!0)!==rs)throw new Error(`Invalid local file header signature at offset ${r}`);let i=t.getUint16(r+8,!0),s=t.getUint16(r+26,!0),o=t.getUint16(r+28,!0),a=r+Yn,c=a+s+o,u=c+e.compressedSize;if(i!==e.compressionMethod||cn.byteLength||!as(n.subarray(a,a+s),e.fileNameBytes))throw new Error(`ZIP member ${e.fileName} has inconsistent local metadata`);return n.subarray(c,u)}async function ru(n){let e=await fetch(n,{method:"HEAD"});if(!e.ok)throw new Error(`HEAD request failed: ${e.status} ${e.statusText}`);let t=parseInt(e.headers.get("content-length")||"0",10),r=e.headers.get("accept-ranges");if(!t||r!=="bytes"){let _=await fetch(n);if(!_.ok)throw new Error(`Fetch failed: ${_.status} ${_.statusText}`);let y=new Uint8Array(await _.arrayBuffer());return{entries:ur(y),totalSize:y.length}}let i=Math.min(t,ns),s=t-i,o=await fetch(n,{headers:{Range:`bytes=${s}-${t-1}`}});if(o.status!==206){let _=await fetch(n);if(!_.ok)throw new Error(`Fetch failed: ${_.status} ${_.statusText}`);let y=new Uint8Array(await _.arrayBuffer());return{entries:ur(y),totalSize:y.length}}let a=new Uint8Array(await o.arrayBuffer()),c=new DataView(a.buffer,a.byteOffset,a.byteLength),u=ss(a),l=c.getUint32(u+12,!0),d=c.getUint32(u+16,!0);if(d>=s){let _=t,y=new Uint8Array(_);return y.set(a,s),{entries:ur(y),totalSize:_}}let p=d+l-1,m=await fetch(n,{headers:{Range:`bytes=${d}-${p}`}});if(m.status!==206)throw new Error(`Range request for CD failed: ${m.status}`);let f=new Uint8Array(await m.arrayBuffer()),h=t,g=new Uint8Array(h);return g.set(f,d),g.set(a,s),{entries:ur(g),totalSize:h}}var Vc,qc,rs,ns,Zc,Xn,Yn,is,os,ts,Xc,Yc,jc,Jc,Jn=Ar(()=>{"use strict";Zn();et();Vc=101010256,qc=33639248,rs=67324752,ns=65557,Zc=22,Xn=46,Yn=30,is=0,os=8,ts=3,{S_IFLNK:Xc,S_IFMT:Yc}=W,jc=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),Jc=new TextEncoder});var ps={};bi(ps,{DEFAULT_TAR_GZIP_LIMITS:()=>hs,TarParseError:()=>z,parseTarGzip:()=>su});function su(n,e={}){let t=e.label??"TAR gzip archive",r=cu(e.limits,t);if(n.byteLength===0||n.byteLength>r.maxCompressedBytes)throw new z(`${t}: compressed byte count ${n.byteLength} is outside 1..${r.maxCompressedBytes}`);let i=uu(n,t);if(i===0||i>r.maxUncompressedBytes)throw new z(`${t}: declared uncompressed byte count ${i} is outside 1..${r.maxUncompressedBytes}`);let s=du(n,t,i);if(s.byteLength!==i)throw new z(`${t}: gzip expanded to ${s.byteLength} bytes, expected ${i}`);let o=new DataView(n.buffer,n.byteOffset,n.byteLength).getUint32(n.byteLength-8,!0);if(lu(s)!==o)throw new z(`${t}: gzip CRC32 mismatch`);return au(s,t,r)}function au(n,e,t){if(n.byteLength%ke!==0)throw new z(`${e}: TAR byte count is not block-aligned`);let r=[],i=0,s=0,o=0,a=null,c={},u=!1;for(;i+ke<=n.byteLength;){let l=n.subarray(i,i+ke);if(i+=ke,ei(l)){if(i+ke>n.byteLength)throw new z(`${e}: TAR end marker is truncated`);let w=n.subarray(i,i+ke);if(!ei(w))throw new z(`${e}: TAR has only one zero end block`);if(i+=ke,!ei(n.subarray(i)))throw new z(`${e}: TAR has nonzero data after its end marker`);u=!0;break}mu(l,e);let d=dr(l,156,1,e)||"0",p=ri(l,124,12,`${e}: TAR entry size`),m=ri(l,100,8,`${e}: TAR entry mode`)&nu,f=_u(l,e,t.maxPathBytes),h=dr(l,157,100,e);if(d==="x"||d==="g"){if(o+=1,o>t.maxEntries+1)throw new z(`${e}: TAR extension header count exceeds ${t.maxEntries+1}`);let w=us(n,i,p,e);i=ds(i,p,n.byteLength,e);let S=hu(w,e,t);d==="x"?a=S:c={...c,...S};continue}if(s+=1,s>t.maxEntries)throw new z(`${e}: TAR entry count exceeds ${t.maxEntries}`);let g={...c,...a??{}};a=null;let _=g.size===void 0?p:pu(g.size,`${e}: PAX entry size`),y=us(n,i,_,e);i=ds(i,_,n.byteLength,e);let E=ti(g.path??f,e,t.maxPathBytes),O=g.linkpath??h;switch(d){case"0":case"\0":r.push({path:E,type:"file",mode:m,data:y});break;case"5":Qn(_,e,"directory",E),r.push({path:E,type:"directory",mode:m});break;case"2":Qn(_,e,"symlink",E),ls(O,e,E,t.maxLinkBytes,!1),r.push({path:E,type:"symlink",mode:m,linkName:O});break;case"1":Qn(_,e,"hardlink",E),ls(O,e,E,t.maxLinkBytes,!0),r.push({path:E,type:"hardlink",mode:m,linkName:ti(O,`${e}: hardlink target`,t.maxPathBytes)});break;case"3":case"4":case"6":throw new z(`${e}: unsupported TAR device/FIFO entry ${E}`);default:throw new z(`${e}: unsupported TAR entry type ${JSON.stringify(d)} for ${E}`)}}if(!u)throw new z(`${e}: TAR is missing its two-block end marker`);if(a!==null)throw new z(`${e}: local PAX header has no following entry`);return r}function cu(n,e){let t={...hs,...n};for(let[r,i]of Object.entries(t))if(!Number.isSafeInteger(i)||i<=0)throw new z(`${e}: ${r} must be a positive safe integer`);return t}function uu(n,e){if(n.byteLength<18||n[0]!==31||n[1]!==139||n[2]!==8)throw new z(`${e}: invalid gzip header`);return new DataView(n.buffer,n.byteOffset,n.byteLength).getUint32(n.byteLength-4,!0)}function du(n,e,t){let r=new Uint8Array(t),i=0,s=!1,o=new es(a=>{if(a.byteLength>t-i)throw new z(`${e}: gzip expansion exceeds its declared ${t} bytes`);r.set(a,i),i+=a.byteLength});o.onmember=()=>{throw s=!0,new z(`${e}: concatenated gzip members are unsupported`)};try{o.push(n,!0)}catch(a){throw a instanceof z?a:new z(`${e}: cannot gunzip archive: ${gu(a)}`)}if(s)throw new z(`${e}: concatenated gzip members are unsupported`);return r.subarray(0,i)}function lu(n){let e=4294967295;for(let t of n)e=ou[(e^t)&255]^e>>>8;return(e^4294967295)>>>0}function fu(){let n=new Uint32Array(256);for(let e=0;e>>1^((t&1)===0?0:3988292384);n[e]=t>>>0}return n}function us(n,e,t,r){if(t>n.byteLength-e)throw new z(`${r}: TAR entry is truncated`);return n.subarray(e,e+t)}function ds(n,e,t,r){let s=Math.ceil(e/ke)*ke;if(!Number.isSafeInteger(s)||s>t-n)throw new z(`${r}: TAR entry padding is truncated`);return n+s}function hu(n,e,t){let r={},i=0;for(;i9)throw new z(`${e}: invalid PAX record length`);if(o=o*10+h,!Number.isSafeInteger(o))throw new z(`${e}: invalid PAX record length`)}let a=i+o;if(o<=s-i+2||a>n.byteLength||n[a-1]!==10)throw new z(`${e}: truncated PAX record`);let c=s+1;for(;c=a-1)throw new z(`${e}: invalid PAX record`);let u=n.subarray(s+1,c);if(u.byteLength>256)throw new z(`${e}: PAX record key is too long`);let l=ni(u,`${e}: PAX record key`),d=n.subarray(c+1,a-1),p=l==="path"?t.maxPathBytes:l==="linkpath"?t.maxLinkBytes:l==="size"?32:0;if(p===0){i=a;continue}if(d.byteLength>p)throw new z(`${e}: PAX ${l} value is too long`);let m=ni(d,`${e}: PAX record value`);r[l]=m,i=a}return r}function pu(n,e){if(!/^(0|[1-9][0-9]*)$/.test(n))throw new z(`${e} is invalid`);let t=Number(n);if(!Number.isSafeInteger(t)||t<0)throw new z(`${e} is invalid`);return t}function mu(n,e){let t=ri(n,148,8,`${e}: TAR checksum`),r=0;for(let i=0;i=148&&i<156?32:n[i];if(t!==r)throw new z(`${e}: TAR checksum mismatch`)}function _u(n,e,t){let r=dr(n,0,100,e),i=dr(n,345,155,e);return ti(i?`${i}/${r}`:r,e,t)}function ti(n,e,t){let r=n;for(;r.startsWith("./");)r=r.slice(2);return r=r.replace(/\/+$/g,""),yu(r,`${e}: TAR path`,t),r}function dr(n,e,t,r){let i=e,s=e+t;for(;ir||n.includes("\0"))throw new z(`${e}: link target for ${t} is invalid`);if(i&&n.includes("\\"))throw new z(`${e}: hardlink target for ${t} is invalid`)}function yu(n,e,t){if(n.length===0||n.startsWith("/")||n.includes("\0")||n.includes("\\")||fs.encode(n).byteLength>t)throw new z(`${e} ${JSON.stringify(n)} must be a bounded relative POSIX path`);for(let r of n.split("/"))if(r.length===0||r==="."||r==="..")throw new z(`${e} ${JSON.stringify(n)} contains an unsafe path segment`)}function ei(n){for(let e of n)if(e!==0)return!1;return!0}function ni(n,e){try{return iu.decode(n)}catch{throw new z(`${e} contains non-UTF-8 text`)}}function gu(n){return n instanceof Error?n.message:String(n)}var ke,nu,cs,iu,fs,ou,hs,z,ms=Ar(()=>{"use strict";Zn();et();ke=512,nu=W.S_MODE_BITS,cs=1024*1024,iu=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),fs=new TextEncoder,ou=fu(),hs=Object.freeze({maxCompressedBytes:256*cs,maxUncompressedBytes:512*cs,maxEntries:1e5,maxPathBytes:4096,maxLinkBytes:65536}),z=class extends Error{constructor(e){super(e),this.name="TarParseError"}}});import{existsSync as gr,lstatSync as an,readdirSync as Ys,readFileSync as ct,realpathSync as Re,statSync as Ye}from"node:fs";import{createHash as js}from"node:crypto";import{spawnSync as Oi}from"node:child_process";import{basename as rd,dirname as Sr,isAbsolute as cn,join as $,relative as nd,resolve as Ie,sep as id}from"node:path";import{fileURLToPath as od}from"node:url";et();var da=Uint8Array.from(Vi);function T(n,e){let t=0,r=0,i=e;for(;;){let s=n[i++];if(t|=(s&127)<=n.length)throw new Error(`${r} is truncated`);if((n[e++]&128)===0)return e}throw new Error(`${r} has an overlong LEB128 encoding`)}function we(n,e,t){if(e>=n.length)throw new Error(`${t} is truncated`);let r=n[e++];switch(r){case 127:case 126:case 125:case 124:case 123:case 117:case 116:case 115:case 114:case 113:case 112:case 111:case 110:case 109:case 108:case 107:case 106:case 105:case 104:return{code:r,shared:!1,next:e};case 98:case 99:case 100:{let i=n[e]===101;i&&e++;let s=ho(n,e,5,`${t} heap type`),[o]=fo(n,e);return{code:r,heapType:Number(o),shared:i,next:s}}default:throw new Error(`${t} has unknown value type 0x${r.toString(16)}`)}}function la(n,e,t){return n[e]===120||n[e]===119?{code:n[e],shared:!1,next:e+1}:we(n,e,t)}function fa(n,e){let t=n[e];if(t===64||t===127||t===126||t===125||t===124||t===123||t===112||t===111)return e+1;let[,r]=An(n,e);return e+r}function ha(n,e,t){let[r,i]=T(n,e);e+=i;let s=[],o=[];for(let d=0;d=n.length)throw new Error(`${t} mutability is truncated`);let i=n[e++];if(i!==0&&i!==1)throw new Error(`${t} has invalid mutability ${i}`);return e}function pa(n,e,t,r){if(e===101){if(t>=n.length)throw new Error(`${r} shared type is truncated`);e=n[t++]}for(let i of[76,77]){if(e!==i)continue;let[,s]=T(n,t);if(t+=s,t>=n.length)throw new Error(`${r} descriptor is truncated`);e=n[t++]}if(e===96)return ha(n,t,r);if(e===95){let[i,s]=T(n,t);t+=s;for(let o=0;o=n.length)throw new Error(`${t} is truncated`);let r=n[e++];if(r===79||r===80){let[i,s]=T(n,e);e+=s;for(let o=0;o=n.length)throw new Error(`${t} body is truncated`);r=n[e++]}return pa(n,r,e,t)}function ma(n,e){let[t,r]=T(n,e);e+=r;let i=[];for(let s=0;s=21&&r<=34?Vt(e,t):r===84||r>=92&&r<=99||r>=112&&r<=123||r>=124&&r<=131||r>=156&&r<=159?t+1:t:n===254?r===0||r===1||r===2?Vt(e,t):r===3?t:r>=16&&r<=79?Vt(e,t):null:null}function ya(n,e,t){let[r,i]=T(n,e);e+=i+r;let[s,o]=T(n,e);e+=o+s;let a=n[e++];if(a===0){t.funcImports++;let[,c]=T(n,e);e+=c}else if(a===1)e=we(n,e,"table import type").next,e=We(n,e).next;else if(a===2)e=We(n,e).next;else if(a===3)t.globalImports++,e=we(n,e,"global import type").next,e++;else if(a===4){e++;let[,c]=T(n,e);e+=c}return e}function Fr(n){return n.length>=8&&n[0]===0&&n[1]===97&&n[2]===115&&n[3]===109}function Ue(n,e){let[t,r]=T(n,e);return e+=r,[new TextDecoder().decode(n.subarray(e,e+t)),e+t]}function ga(n,e){if(e.length===0)return!0;let t=new TextEncoder().encode(e);e:for(let r=0;r<=n.length-t.length;r++){for(let i=0;in);function so(n,e){switch(n.code){case 127:return ln;case 126:return fn;case 125:return hn;case 124:return pn;case 123:return mn;case 112:case 115:return ht;case 111:case 114:return Ct;case 105:case 116:return Mt;case 104:case 106:case 107:case 108:case 109:case 110:case 113:case 117:return Dt;case 98:case 99:case 100:{let t=n.heapType;return t===void 0?null:t===-16||t===-13?ht:t===-17||t===-14?Ct:t===-23||t===-12?Mt:t>=0&&e[t]!==void 0?ht:Dt}default:return null}}function On(n,e,t){if(!t)throw new Error(`function ${e} refers to an unknown type`);let r=n.get(e)??[];r.push(t),n.set(e,r)}function Ht(n,e,t){let r=n.get(e)??[];r.push(t),n.set(e,r)}function We(n,e){let[t,r]=T(n,e);e+=r;let[i,s]=T(n,e);e+=s;let o=null;if((t&1)!==0){let[a,c]=T(n,e);e+=c,o=a}return{flags:t,minimum:i,maximum:o,next:e}}function Sa(n){let e=new Uint8Array(n);if(!Fr(e))throw new Error("not a wasm binary");let t=[],r=[],i=[],s={functionTypes:t,functionTypeIndices:r,functionImports:new Map,functionImportEntries:[],globalImports:new Map,tableImports:new Map,tables:[],tagImports:new Map,functionExports:new Map,globalExports:new Map,tableExports:new Map,exports:new Map,memoryPointerWidths:[],forkCapabilities:[],linkedFrameDescriptors:[],exceptionCodecDescriptors:[],importedGlobalsDescriptors:[],importedTablesDescriptors:[],moduleStateDescriptors:[],staticRootDescriptors:[],unwindTransportDescriptors:[],nativeStartCount:0,importsKernelFork:!1},o=0,a=0,c=8;for(;ce.length)throw new Error("wasm section exceeds file size");let f=p,h=!1;if(u===0){let[g,_]=Ue(e,f);g===Ft?s.linkedFrameDescriptors.push(e.slice(_,m)):g===ve?s.forkCapabilities.push(e.slice(_,m)):g===Se?s.exceptionCodecDescriptors.push(e.slice(_,m)):g===X?s.importedGlobalsDescriptors.push(e.slice(_,m)):g===Y?s.importedTablesDescriptors.push(e.slice(_,m)):g===ne?s.moduleStateDescriptors.push(e.slice(_,m)):g===$e?s.staticRootDescriptors.push(e.slice(_,m)):g===pt&&s.unwindTransportDescriptors.push(e.slice(_,m))}else if(u===1){h=!0;let g=ma(e,f);t.push(...g.types),f=g.next}else if(u===2){h=!0;let[g,_]=T(e,f);f+=_;for(let y=0;y=e.length)throw new Error(`global import ${E}.${w} is truncated`);let x=e[f++];if((x&-4)!==0)throw new Error(`global import ${E}.${w} has invalid flags ${x}`);Ht(s.globalImports,`${E}.${w}`,{module:E,name:w,importOrdinal:y,index:o++,valueType:R.code,recipeTypeCode:so(R,t),mutable:(x&1)!==0,shared:(x&2)!==0})}else if(A===4){let R=e[f++];if(R!==0)throw new Error(`unsupported wasm tag attribute ${R}`);let[x,v]=T(e,f);f+=v,On(s.tagImports,`${E}.${w}`,t[x])}else throw new Error(`unsupported wasm import kind ${A}`)}}else if(u===3){h=!0;let[g,_]=T(e,f);f+=_;for(let y=0;yn[c]===a))throw new Error("linked-frame descriptor has invalid magic");let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=e.getUint16(4,!0);if(t!==1)throw new Error(`linked-frame descriptor version ${t} is unsupported`);let r=e.getUint16(6,!0);if(r!==Nt)throw new Error(`linked-frame descriptor declares size ${r}, expected ${Nt}`);let i=e.getUint8(8),s=Fi.find(({bytes:a})=>a===i);if(!s)throw new Error(`linked-frame descriptor pointer width ${i} is unsupported`);if(e.getUint8(9)!==ki)throw new Error(`linked-frame descriptor alignment ${e.getUint8(9)} is unsupported`);let o=e.getUint16(10,!0);if(o!==un)throw new Error(`linked-frame descriptor flags 0x${o.toString(16)} do not equal required flags 0x${un.toString(16)}`);if(e.getUint32(12,!0)!==s.chunkHeaderSize||e.getUint32(16,!0)!==s.nodeHeaderSize)throw new Error(`linked-frame descriptor header sizes do not match its ${i}-byte pointer width`);return s.bytes}function Oa(n){if(n.length===0)return[`missing required ${ve} capability`];if(n.length!==1)return[`has ${n.length} ${ve} sections, expected exactly one`];let e=n[0];if(e.byteLength!==2)return[`${ve} has ${e.byteLength} bytes, expected 2`];if(e[0]!==Ui)return[`${ve} version ${e[0]} is unsupported`];let t=e[1];return(t&~Wi)!==0?[`${ve} has unknown flags 0x${t.toString(16)}`]:(t&Tr)!==Tr?[`${ve} flags 0x${t.toString(16)} omit required activation-state safety flags 0x${Tr.toString(16)}`]:[]}function Aa(n){let e=[],t=`${Lr}.${zr}`,r=n.tagImports.get(t);if(r?r.length!==1?e.push(`duplicate private fork-unwind tag import ${t}`):(r[0].params.length!==0||r[0].results.length!==0)&&e.push(`private fork-unwind tag ${t} must have an empty payload`):e.push(`missing required private fork-unwind tag import ${t}`),n.unwindTransportDescriptors.length===0)e.push(`missing required ${pt} descriptor`);else if(n.unwindTransportDescriptors.length!==1)e.push(`has ${n.unwindTransportDescriptors.length} ${pt} descriptors, expected exactly one`);else{let i=n.unwindTransportDescriptors[0];(i.length!==2||i[0]!==yn||i[1]!==gn)&&e.push(`${pt} must be [${yn}, ${gn}]`)}return e}function Ia(n,e){if(n.length===0)return[`missing required ${ne} descriptor`];if(n.length!==1)return[`has ${n.length} ${ne} descriptors, expected exactly one`];let t=n[0];if(t.byteLength!==Ir)return[`${ne} has ${t.byteLength} bytes, expected ${Ir}`];if(!Ci.every((h,g)=>t[g]===h))return[`${ne} has invalid magic`];let r=new DataView(t.buffer,t.byteOffset,t.byteLength),i=r.getUint16(4,!0),s=r.getUint16(6,!0),o=r.getUint8(8),a=$i.find(({bytes:h})=>h===o),c=r.getUint8(9),u=r.getUint16(10,!0),l=r.getUint16(12,!0),d=r.getUint16(14,!0),p=r.getUint32(16,!0),m=r.getUint32(20,!0),f=[];return i!==Ni&&f.push(`${ne} version ${i} is unsupported`),s!==Ir&&f.push(`${ne} declares size ${s}`),a?e!==null&&o!==e&&f.push(`${ne} pointer width ${o} does not match linked frames ${e}`):f.push(`${ne} pointer width ${o} is unsupported`),c!==Mi&&f.push(`${ne} alignment ${c} is unsupported`),u!==dn&&f.push(`${ne} flags 0x${u.toString(16)} do not equal required flags 0x${dn.toString(16)}`),l!==Di&&f.push(`${ne} arena version ${l} is unsupported`),d!==Ki&&f.push(`${ne} record version ${d} is unsupported`),p!==Bi&&f.push(`${ne} root word ${p} is unsupported`),m!==0&&f.push(`${ne} reserved field is nonzero`),f}function Ra(n){if(n.length===0)return[`missing required ${Se} descriptor`];if(n.length!==1)return[`has ${n.length} ${Se} descriptors, expected exactly one`];let e=n[0];if(e.byteLength2147483647||o.has(l))&&r.push(`${Se} layout id ${l} is invalid or duplicated`),o.add(l)}return r}var xa=new Set([ln,fn,hn,pn,mn,ht,Ct,Mt,Dt]);function ao(n){return!(n.module===En&&(n.name===Sn||n.name==="__channel_base"||n.name==="__wpk_fork_module_state_table_generation_addr"))}function Ta(n){let e=n.importedGlobalsDescriptors;if(e.length===0)return[`missing required ${X} descriptor`];if(e.length!==1)return[`has ${e.length} ${X} descriptors, expected exactly one`];let t=e[0];if(t.byteLengtht[g]===h)||i.push(`${X} has invalid magic`),r.getUint16(4,!0)!==Zi&&i.push(`${X} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Pr&&i.push(`${X} declares an invalid header size`);let s=r.getUint32(8,!0);r.getUint32(12,!0)!==0&&i.push(`${X} reserved field is nonzero`);let o=new Set,a=new Set,c=new TextDecoder("utf-8",{fatal:!0}),u=[],l=-1,d=Pr;for(let h=0;ht.byteLength)return i.push(`${X} record ${h} header is truncated`),i;let g=r.getUint32(d,!0),_=r.getUint32(d+4,!0),y=r.getUint8(d+8),E=r.getUint8(d+9),O=r.getUint32(d+12,!0),w=r.getUint32(d+16,!0),S=r.getUint32(d+20,!0),A=Bt+O+w;if(!Number.isSafeInteger(A)||g!==A||gt.byteLength)return i.push(`${X} record ${h} has invalid bounds`),i;(_===0||o.has(_))&&i.push(`${X} record ${h} has invalid or duplicated owner ${_}`),o.add(_),xa.has(y)||i.push(`${X} record ${h} has unknown value type ${y}`),(E&~ji)!==0&&i.push(`${X} record ${h} has unknown flags 0x${E.toString(16)}`),r.getUint16(d+10,!0)!==0&&i.push(`${X} record ${h} reserved fields are nonzero`),(a.has(S)||S<=l)&&i.push(`${X} record ${h} has duplicated or unordered import ordinal`),a.add(S),l=S;let R=d+Bt;try{let x=c.decode(t.subarray(R,R+O)),v=c.decode(t.subarray(R+O,R+O+w));u.push({ownerId:_,typeCode:y,flags:E,importOrdinal:S,module:x,name:v})}catch{i.push(`${X} record ${h} contains invalid UTF-8`)}d+=g}d!==t.byteLength&&i.push(`${X} has trailing bytes`);let p=[...n.globalImports.values()].flat(),m=new Map(p.map(h=>[h.index,h])),f=new Set;for(let h of u){let g=`${Rr}${h.ownerId}`,_=n.exports.get(g);if(!_||_.length!==1||_[0].kind!==3){i.push(`${X} owner ${h.ownerId} lacks exactly one global catalog export ${g}`);continue}let y=m.get(_[0].index);if(!y||!ao(y)){i.push(`${X} owner ${h.ownerId} does not identify a reconstructible imported global`);continue}if(y.module!==h.module||y.name!==h.name||y.importOrdinal!==h.importOrdinal||y.recipeTypeCode!==h.typeCode||y.mutable!==((h.flags&Xi)!==0)||y.shared!==((h.flags&Yi)!==0)){i.push(`${X} owner ${h.ownerId} does not match its imported global declaration`);continue}if(f.has(y.index)){i.push(`${X} repeats imported global index ${y.index}`);continue}f.add(y.index)}for(let h of p)ao(h)&&!f.has(h.index)&&i.push(`${X} omits imported global ${h.module}.${h.name} at index ${h.index}`);for(let[h,g]of n.exports){if(!h.startsWith(Rr))continue;let _=h.slice(Rr.length),y=Number(_);(!/^[1-9][0-9]*$/.test(_)||!Number.isSafeInteger(y)||y>4294967295||g.length!==1||g[0].kind!==3)&&i.push(`malformed reserved fork global catalog export ${h}`)}return i}var va=new Set([ht,Ct,Mt,Dt]);function co(n){return!wn.some(({module:e,name:t})=>n.module===e&&n.name===t)}function La(n){let e=n.importedTablesDescriptors;if(e.length===0)return[`missing required ${Y} descriptor`];if(e.length!==1)return[`has ${e.length} ${Y} descriptors, expected exactly one`];let t=e[0];if(t.byteLengtht[g]===h)||i.push(`${Y} has invalid magic`),r.getUint16(4,!0)!==Qi&&i.push(`${Y} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==kr&&i.push(`${Y} declares an invalid header size`);let s=r.getUint32(8,!0);r.getUint32(12,!0)!==0&&i.push(`${Y} reserved field is nonzero`);let o=new Set,a=new Set,c=new TextDecoder("utf-8",{fatal:!0}),u=[],l=-1,d=kr;for(let h=0;ht.byteLength)return i.push(`${Y} record ${h} header is truncated`),i;let g=r.getUint32(d,!0),_=r.getUint32(d+4,!0),y=r.getUint8(d+8),E=r.getUint8(d+9),O=r.getUint32(d+12,!0),w=r.getUint32(d+16,!0),S=r.getUint32(d+20,!0),A=$t+O+w;if(!Number.isSafeInteger(A)||g!==A||g<$t||d+g>t.byteLength)return i.push(`${Y} record ${h} has invalid bounds`),i;(_===0||o.has(_))&&i.push(`${Y} record ${h} has invalid or duplicated owner ${_}`),o.add(_),va.has(y)||i.push(`${Y} record ${h} has unknown element type ${y}`),(E&~to)!==0&&i.push(`${Y} record ${h} has unknown flags 0x${E.toString(16)}`),r.getUint16(d+10,!0)!==0&&i.push(`${Y} record ${h} reserved fields are nonzero`),(a.has(S)||S<=l)&&i.push(`${Y} record ${h} has duplicated or unordered import ordinal`),a.add(S),l=S;let R=d+$t;try{let x=c.decode(t.subarray(R,R+O)),v=c.decode(t.subarray(R+O,R+O+w));u.push({ownerId:_,typeCode:y,flags:E,importOrdinal:S,module:x,name:v})}catch{i.push(`${Y} record ${h} contains invalid UTF-8`)}d+=g}d!==t.byteLength&&i.push(`${Y} has trailing bytes`);let p=[...n.tableImports.values()].flat(),m=new Map(p.map(h=>[h.index,h])),f=new Set;for(let h of u){let g=`${xr}${h.ownerId}`,_=n.exports.get(g);if(!_||_.length!==1||_[0].kind!==1){i.push(`${Y} owner ${h.ownerId} lacks exactly one table catalog export ${g}`);continue}let y=m.get(_[0].index);if(!y||!co(y)){i.push(`${Y} owner ${h.ownerId} does not identify a reconstructible imported table`);continue}if(y.module!==h.module||y.name!==h.name||y.importOrdinal!==h.importOrdinal||y.recipeTypeCode!==h.typeCode||y.table64!==((h.flags&eo)!==0)){i.push(`${Y} owner ${h.ownerId} does not match its imported table declaration`);continue}if(f.has(y.index)){i.push(`${Y} repeats imported table index ${y.index}`);continue}f.add(y.index)}for(let h of p)co(h)&&!f.has(h.index)&&i.push(`${Y} omits imported table ${h.module}.${h.name} at index ${h.index}`);for(let[h,g]of n.exports){if(!h.startsWith(xr))continue;let _=h.slice(xr.length),y=Number(_);(!/^[1-9][0-9]*$/.test(_)||!Number.isSafeInteger(y)||y>4294967295||g.length!==1||g[0].kind!==1)&&i.push(`malformed reserved fork table catalog export ${h}`)}return i}function In(n,e){switch(n){case"ptr":return e===8?126:127;case"i32":return 127;case"i64":return 126;case"anyref":return 110;case"exnref":return 105;case"externref":return 111;case"funcref":return 112}}function uo(n,e,t,r){return n.params.length===e.length&&n.results.length===t.length&&n.params.every((i,s)=>i===In(e[s],r))&&n.results.every((i,s)=>i===In(t[s],r))}function lo(n,e,t){let r=i=>i==="ptr"?t===8?"i64":"i32":i;return`(${n.map(r).join(", ")}) -> (${e.map(r).join(", ")})`}function za(n){let e=`${En}.${Sn}`,t=n.globalImports.get(e);return t?t.length!==1?[`duplicate exception-codec activation import ${e}`]:t[0].valueType!==127||t[0].mutable?[`exception-codec activation import ${e} must be immutable i32`]:[]:[`missing required immutable exception-codec activation import ${e}`]}function ba(n){let e=[];for(let t of wn){let r=`${t.module}.${t.name}`,i=n.tableImports.get(r);if(!i){e.push(`missing required ABI 43 fork-runtime table import ${r}`);continue}if(i.length!==1){e.push(`duplicate ABI 43 fork-runtime table import ${r}`);continue}let s=i[0],o=In(t.element,4);(s.elementType!==o||s.table64!==t.table64||s.minimum!==t.minimum||s.maximum!==t.maximum)&&e.push(`ABI 43 fork-runtime table import ${r} has the wrong type or limits`)}return e}function Pa(n){if(n.staticRootDescriptors.length===0)return[`missing required ${$e} descriptor`];if(n.staticRootDescriptors.length!==1)return[`has ${n.staticRootDescriptors.length} ${$e} descriptors, expected exactly one`];let e=n.staticRootDescriptors[0];if(e.byteLength!==br)return[`${$e} has ${e.byteLength} bytes, expected ${br}`];let t=[];da.some((u,l)=>e[l]!==u)&&t.push(`${$e} has invalid magic`);let r=new DataView(e.buffer,e.byteOffset,e.byteLength);r.getUint16(4,!0)!==Hi&&t.push(`${$e} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==br&&t.push(`${$e} declares an invalid header size`);let i=r.getUint32(8,!0),s=n.tableExports.get(Kt);if(!s||s.length!==1)return t.push(`missing exactly one table export ${Kt}`),t;let o=[...n.tableImports.values()].reduce((u,l)=>u+l.length,0),a=s[0],c=n.tables[a];return a!n.functionExports.has(c)).map(({name:c})=>c);t.length>0&&e.push(`incomplete wasm-fork-instrument exports; missing ${t.join(", ")}`);let r=null;if(n.linkedFrameDescriptors.length===0)e.push(`missing required ${Ft} descriptor`);else if(n.linkedFrameDescriptors.length!==1)e.push(`has ${n.linkedFrameDescriptors.length} ${Ft} descriptors, expected exactly one`);else try{r=wa(n.linkedFrameDescriptors[0])}catch(c){e.push(c instanceof Error?c.message:String(c))}e.push(...Ia(n.moduleStateDescriptors,r));let i=mt.filter(({module:c,name:u})=>n.functionImports.has(`${c}.${u}`)),s=`${Lr}.${zr}`,o=n.importsKernelFork||i.length>0;if((o||n.tagImports.has(s)||n.unwindTransportDescriptors.length>0)&&e.push(...Aa(n)),o){let c=mt.filter(({module:u,name:l})=>!n.functionImports.has(`${u}.${l}`)).map(({module:u,name:l})=>`${u}.${l}`);c.length>0&&e.push(`incomplete ABI 43 fork-runtime imports; missing ${c.join(", ")}`);for(let u of mt){let l=`${u.module}.${u.name}`,d=n.functionImports.get(l);d&&d.length!==1&&e.push(`duplicate ABI 43 fork-runtime import ${l}`)}}if(r!==null){if(n.memoryPointerWidths.length!==1)e.push(`ABI 43 fork instrumentation requires exactly one module memory, found ${n.memoryPointerWidths.length}`);else if(n.memoryPointerWidths[0]!==r){let c=r===8?"an":"a";e.push(`ABI 43 linked-frame descriptor declares ${c} ${r}-byte pointer but the module memory uses ${n.memoryPointerWidths[0]}-byte addresses`)}for(let c of Ut){let u=n.functionExports.get(c.name);u?.length===1&&!uo(u[0],c.params,c.results,r)&&e.push(`ABI 43 wasm-fork-instrument export ${c.name} has the wrong signature; expected ${lo(c.params,c.results,r)}`)}if(o)for(let c of mt){let u=`${c.module}.${c.name}`,l=n.functionImports.get(u);l?.length===1&&!uo(l[0],c.params,c.results,r)&&e.push(`ABI 43 fork-runtime import ${u} has the wrong signature; expected ${lo(c.params,c.results,r)}`)}}return e}function po(n,e){switch(n){case 0:return"function";case 1:return"table";case 2:return"memory";case 3:return"global";case 4:return"tag";default:throw new Error(`${e} has unsupported external kind ${n}`)}}function Fa(n){let e=new Uint8Array(n);if(!Fr(e))return[];let t=[],r=8;for(;r`${e}.${t}`)}function Ca(n){let e=new Uint8Array(n);if(!Fr(e))return[];let t=[],r=8;for(;re)}function mo(n){let e=new Uint8Array(n);if(!Fr(e))return[];let t=[],r=8;for(;rt.startsWith("reloc."))}function _o(n,e={}){let t=[],r=null;Da(n)&&t.push("contains asyncify_"),e.expectedAbi!==void 0&&e.expectedAbi!==null&&(r=$a(n),r!==null&&r!==e.expectedAbi&&t.push(`ABI ${r}, expected ${e.expectedAbi}`));let i=new Set(Ma(n));if(e.requiredExports){let E=e.requiredExports.filter(O=>!i.has(O));E.length>0&&t.push(`missing required exports: ${E.join(", ")}`)}let s=Ea.filter(E=>i.has(E)),o=Na(n),a=mo(n),c=mt.filter(({module:E,name:O})=>o.includes(`${E}.${O}`)),u=a.filter(E=>E===Ft).length,l=a.filter(E=>E===ve).length,d=a.filter(E=>E===ne).length,p=a.filter(E=>E===Se).length,m=a.filter(E=>E===X).length,f=a.filter(E=>E===Y).length,h=a.filter(E=>E===pt).length,g=o.includes(`${Lr}.${zr}`),_=s.length>0||c.length>0||u>0||l>0||d>0||p>0||m>0||f>0||h>0||g;if(e.expectedAbi!==void 0&&e.expectedAbi!==null&&_&&r===null&&t.push(`ABI ${e.expectedAbi} fork artifact is missing __abi_version; the activation-state capability epoch cannot be verified`),e.forbidForkInstrumentation&&_&&t.push("contains ABI 43 wasm-fork-instrument metadata, imports, or exports"),(e.requireForkInstrumentation??!Ka(n))&&(_||o.includes("kernel.kernel_fork")))try{t.push(...ka(Sa(n)))}catch(E){t.push(`cannot validate ABI 43 fork-artifact contract: ${E instanceof Error?E.message:String(E)}`)}return t}function Ba(n,e){let t=new Uint8Array(n);if(t.length<8)return null;let r=0,i=null,s=null,o=8;for(;o=c)return null;let h=a;for(let y=0;y=f)return null;let[h,g]=T(t,m);m+=g;for(let _=0;_f)return null}return m}function p(m,f=0){if(f>4)return null;let h=l(m);if(!h)return null;let g=d(h.start,h.end);if(g===null)return null;let _=g,y=h.end;for(;_=32&&E<=38||E===208){let[,O]=T(t,_);_+=O}else if(E>=40&&E<=62)_=Vt(t,_);else if(E===63||E===64)_++;else if(E===66){let[,O]=fo(t,_);_+=O}else if(E===67)_+=4;else if(E===68)_+=8;else if(E===252||E===253||E===254){let O=_a(E,t,_);if(O===null)return null;_=O}}return null}return p(i)}function $a(n){return Ba(n,"__abi_version")}et();var Ua=ArrayBuffer,J=Uint8Array,Nr=Uint16Array,Wa=Int16Array;var Cr=Int32Array,Rn=function(n,e,t){if(J.prototype.slice)return J.prototype.slice.call(n,e,t);(e==null||e<0)&&(e=0),(t==null||t>n.length)&&(t=n.length);var r=new J(t-e);return r.set(n.subarray(e,t)),r},Zt=function(n,e,t,r){if(J.prototype.fill)return J.prototype.fill.call(n,e,t,r);for((t==null||t<0)&&(t=0),(r==null||r>n.length)&&(r=n.length);tn.length)&&(r=n.length);t2046MB)","invalid block type","FSE accuracy too high","match distance too far back","unexpected EOF"],Q=function(n,e,t){var r=new Error(e||Ha[n]);if(r.code=n,Error.captureStackTrace&&Error.captureStackTrace(r,Q),!t)throw r;return r},yo=function(n,e,t){for(var r=0,i=0;r>>0},qa=function(n,e){var t=n[0]|n[1]<<8|n[2]<<16;if(t==3126568&&n[3]==253){var r=n[4],i=r>>5&1,s=r>>2&1,o=r&3,a=r>>6;r&8&&Q(0);var c=6-i,u=o==3?4:o,l=yo(n,c,u);c+=u;var d=a?1<>3);m=f+(f>>3)*(n[5]&7)}m>2145386496&&Q(1);var h=new J((e==1?p||m:e?0:m)+12);return h[0]=1,h[4]=4,h[8]=8,{b:c+d,y:0,l:0,d:l,w:e&&e!=1?e:h.subarray(12),e:m,o:new Cr(h.buffer,0,3),u:p,c:s,m:Math.min(131072,m)}}else if((t>>4|n[3]<<20)==25481893)return Va(n,4)+8;Q(0)},tt=function(n){for(var e=0;1<t&&Q(3);for(var s=1<0;){var y=tt(o+1),E=r>>3,O=(1<>(r&7)&O,S=(1<S&&(w-=A)),p[++a]=--w,w==-1?(o+=w,g[--l]=a):o-=w,!w)do{var x=r>>3;c=(n[x]|n[x+1]<<8)>>(r&7)&3,r+=2,a+=c}while(c==3)}(a>255||o)&&Q(0);for(var v=0,L=(s>>1)+(s>>3)+3,D=s-1,Z=0;Z<=a;++Z){var N=p[Z];if(N<1){m[Z]=-N;continue}for(u=0;u=l)}}for(v&&Q(0),u=0;u>3,{b:i,s:g,n:_,t:f}]},Za=function(n,e){var t=0,r=-1,i=new J(292),s=n[e],o=i.subarray(0,256),a=i.subarray(256,268),c=new Nr(i.buffer,268);if(s<128){var u=Xt(n,e+1,6),l=u[0],d=u[1];e+=s;var p=l<<3,m=n[e];m||Q(0);for(var f=0,h=0,g=d.b,_=g,y=(++e<<3)-8+tt(m);y-=g,!(y>3;if(f+=(n[E]|n[E+1]<<8)>>(y&7)&(1<>3,h+=(n[E]|n[E+1]<<8)>>(y&7)&(1<<_)-1,o[++r]=d.s[h],g=d.n[f],f=d.t[f],_=d.n[h],h=d.t[h]}++r>255&&Q(0)}else{for(r=s-127;t>4,o[t+1]=O&15}++e}var w=0;for(t=0;t11&&Q(0),w+=S&&1<0;--t){var Z=c[t];Zt(D,t,Z,c[t-1]=Z+a[t]*(1<a&&d>3,m=(n[p]|n[p+1]<<8|n[p+2]<<16)>>(l&7);c=(c<>2,o=s<<1,a=s+o;qt(n.subarray(r,r+=n[0]|n[1]<<8),e.subarray(0,s),t),qt(n.subarray(r,r+=n[2]|n[3]<<8),e.subarray(s,o),t),qt(n.subarray(r,r+=n[4]|n[5]<<8),e.subarray(o,a),t),qt(n.subarray(r),e.subarray(a),t)},tc=function(n,e,t){var r,i=e.b,s=n[i],o=s>>1&3;e.l=s&1;var a=s>>3|n[i+1]<<5|n[i+2]<<13,c=(i+=3)+a;if(o==1)return i>=n.length?void 0:(e.b=i+1,t?(Zt(t,n[i],e.y,e.y+=a),t):Zt(new J(a),n[i]));if(!(c>n.length)){if(o==0)return e.b=c,t?(t.set(n.subarray(i,c),e.y),e.y+=a,t):Rn(n,i,c);if(o==2){var u=n[i],l=u&3,d=u>>2&3,p=u>>4,m=0,f=0;l<2?d&1?p|=n[++i]<<4|(d&2&&n[++i]<<12):p=u>>3:(f=d,d<2?(p|=(n[++i]&63)<<4,m=n[i]>>6|n[++i]<<2):d==2?(p|=n[++i]<<4|(n[++i]&3)<<12,m=n[i]>>2|n[++i]<<6):(p|=n[++i]<<4|(n[++i]&63)<<12,m=n[i]>>6|n[++i]<<2|n[++i]<<10)),++i;var h=t?t.subarray(e.y,e.y+e.m):new J(e.m),g=h.length-p;if(l==0)h.set(n.subarray(i,i+=p),g);else if(l==1)Zt(h,n[i++],g);else{var _=e.h;if(l==2){var y=Za(n,i);m+=i-(i=y[0]),e.h=_=y[1]}else _||Q(0);(f?ec:qt)(n.subarray(i,i+=m),h.subarray(g),_)}var E=n[i++];if(E){E==255?E=(n[i++]|n[i++]<<8)+32512:E>127&&(E=E-128<<8|n[i++]);var O=n[i++];O&3&&Q(0);for(var w=[Ya,ja,Xa],S=2;S>-1;--S){var A=O>>(S<<1)+2&3;if(A==1){var R=new J([0,0,n[i++]]);w[S]={s:R.subarray(2,3),n:R.subarray(0,1),t:new Nr(R.buffer,0,1),b:0}}else A==2?(r=Xt(n,i,9-(S&1)),i=r[0],w[S]=r[1]):A==3&&(e.t||Q(0),w[S]=e.t[S])}var x=e.t=w,v=x[0],L=x[1],D=x[2],Z=n[c-1];Z||Q(0);var N=(c<<3)-8+tt(Z)-D.b,b=N>>3,U=0,le=(n[b]|n[b+1]<<8)>>(N&7)&(1<>3;var C=(n[b]|n[b+1]<<8)>>(N&7)&(1<>3;var V=(n[b]|n[b+1]<<8)>>(N&7)&(1<>3;var lt=1<>>(N&7)<-1);b=(N-=Tn[Je])>>3;var De=Qa[Je]+((n[b]|n[b+1]<<8|n[b+2]<<16)>>(N&7)&(1<>3;var Qe=Ja[Pt]+((n[b]|n[b+1]<<8|n[b+2]<<16)>>(N&7)&(1<>3,le=D.t[le]+((n[b]|n[b+1]<<8)>>(N&7)&(1<>3,V=v.t[V]+((n[b]|n[b+1]<<8)>>(N&7)&(1<>3,C=L.t[C]+((n[b]|n[b+1]<<8)>>(N&7)&(1<3)e.o[2]=e.o[1],e.o[1]=e.o[0],e.o[0]=Ee-=3;else{var ft=Ee-(Qe!=0);ft?(Ee=ft==3?e.o[0]-1:e.o[ft],ft>1&&(e.o[2]=e.o[1]),e.o[1]=e.o[0],e.o[0]=Ee):Ee=e.o[0]}for(var S=0;SDe&&(Be=De);for(var S=0;Soc)throw Yt("EOVERFLOW","file offset is outside signed i64");return n}function ac(n){if(Ln(n)<0n)throw Yt("EINVAL","negative positioned I/O offset");return n}function zn(n){let e=Ln(n);if(ewo)throw Yt("EOVERFLOW","backend cannot represent the file offset exactly");return So(e)}function bn(n){let e=ac(n);return zn(e)}function Oo(n){if(n===null)return null;let e=Ln(n);if(e<0n)throw Yt("EINVAL","negative file-size limit");return e>wo?null:So(e)}et();function Pn(n){let e=new Error(`EINVAL: pathconf name ${n} is not associated with this object`);throw e.code="EINVAL",e}function kn(n,e,t){switch(e){case q.LINK_MAX:return null;case q.NAME_MAX:return 255;case q.PATH_MAX:return ro;case q.CHOWN_RESTRICTED:return 1;case q.NO_TRUNC:return 1;case q.ASYNC_IO:return(n.mode&W.S_IFMT)===W.S_IFREG?1:Pn(e);case q.SYNC_IO:case q.PRIO_IO:case q.FILESIZEBITS:case q.REC_INCR_XFER_SIZE:case q.REC_MAX_XFER_SIZE:case q.REC_MIN_XFER_SIZE:case q.REC_XFER_ALIGN:case q.ALLOC_SIZE_MIN:case q.SYMLINK_MAX:case q.FALLOC:return null;case q.POSIX2_SYMLINKS:return t.supportsSymlinks?1:null;case q.TEXTDOMAIN_MAX:return 255;case q.TIMESTAMP_RESOLUTION:return t.timestampResolutionNs;case q.PIPE_BUF:{let r=n.mode&W.S_IFMT;return r===W.S_IFIFO||r===W.S_IFDIR?null:Pn(e)}case q.MAX_CANON:case q.MAX_INPUT:case q.VDISABLE:case q.SOCK_MAXBUF:return Pn(e);default:{let r=new Error(`EINVAL: invalid pathconf name ${e}`);throw r.code="EINVAL",r}}}et();var Mr=Math.floor(160),Fn=1397114451,Nn=1,jt=32768,H=16384,_t=40960,G=61440,cc=2048,uc=1024,dc=73,Ao=4294967295,rt=0,Io=1;var ir=64,Wn=128,sr=512,lc=1024,fc=65536,Jt=3,hc=0,pc=1,mc=2,k=8,_c=-1,Oe=-2,B=-5,re=-9,Bn=-16,Ot=-17,ze=-20,it=-21,j=-22,ko=-24,ot=-27,oe=-28,$n=-36,Un=-39,Fo=-40,No=-75,Cn=0,Mn=4,Dr=8,yt=12,Ge=16,gt=20,Kr=24,nt=28,Br=32,Ro=36,$r=40,yc=44,gc=48,Ec=52,Dn=56,Ur=60,Wr=64,Qt=68,xo=72,Et=0,F=8,K=12,P=16,fe=24,ee=32,er=40,ie=48,tr=88,St=92,rr=96,nr=100,ue=104,He=112,To=116,de=120,vo=4,Le=8,Lo=16,zo=20,bo=-2147483648,Sc=2147483647,wc=1034+1024*1024,Ve=wc*4096,Oc={[Oe]:"No such file or directory",[B]:"I/O error",[re]:"Bad file descriptor",[Bn]:"Device or resource busy",[Ot]:"File exists",[ze]:"Not a directory",[it]:"Is a directory",[j]:"Invalid argument",[ko]:"Too many open files",[ot]:"File too large",[oe]:"No space left on device",[$n]:"File name too long",[Un]:"Directory not empty",[Fo]:"Too many symbolic links",[No]:"Value too large for data type"},I=class extends Error{constructor(t,r){super(r||Oc[t]||`Error ${t}`);this.code=t;this.name="SFSError"}code},me=new TextEncoder,or=new TextDecoder,Po=me.encode("..");function Kn(n){return n==="."||n===".."}function wt(n){return n.buffer instanceof SharedArrayBuffer?or.decode(new Uint8Array(n)):or.decode(n)}function qe(n){return n+3&-4}var be=class n{constructor(e){this.buffer=e;this.view=new DataView(e),this.i32=new Int32Array(e),this.u8=new Uint8Array(e)}buffer;view;i32;u8;dirIndexes=new Map;blockAllocHint=0;inodeAllocHint=2;atomicsWaitAllowed;static DIR_INDEX_MIN_SIZE=64*1024;static mkfs(e,t){let r=e.byteLength;if(r<4096*16)throw new I(j);let i=Math.floor(r/4096),s=t?Math.floor(t/4096):i*4,o=Math.floor(s/4);o<32&&(o=32),o=Math.ceil(o/32)*32;let a=Math.ceil(o/(4096*8)),c=Math.ceil(s/(4096*8)),u=Math.ceil(o*128/4096),l=1,d=l+a,p=d+c,m=p+u;if(m>=i){let R=(m+1)*4096;try{e.grow(R)}catch{throw new I(oe)}if(i=Math.floor(e.byteLength/4096),m>=i)throw new I(oe)}new Uint8Array(e).fill(0);let f=new n(e);f.w32(Cn,Fn),f.w32(Mn,Nn),f.w32(Dr,4096),f.w32(yt,i),f.w32(Ge,o),f.w32(nt,l),f.w32(Br,d),f.w32(Ro,p),f.w32($r,m),f.w32(yc,a),f.w32(gc,c),f.w32(Ec,u),f.w32(Qt,s),f.w32(xo,256);let h=d*4096;for(let R=0;R>2)+(R>>5);f.i32[x]|=1<<(R&31)}let g=i-m;Atomics.store(f.i32,gt>>2,g),f.blockAllocHint=m;let _=l*4096;f.i32[_>>2]|=3,Atomics.store(f.i32,Kr>>2,o-2),f.inodeAllocHint=2;let y=f.inodeOffset(1);f.w32(y+F,H|493),f.w32(y+K,2),f.w64(y+ue,1);let E=f.blockAlloc();if(E<0)throw new I(oe);f.w32(y+ie,E);let O=E*4096,w=qe(k+1),S=qe(k+2);f.w32(O,1),f.view.setUint16(O+4,w,!0),f.view.setUint16(O+6,1,!0),f.u8[O+k]=46;let A=O+w;return f.w32(A,1),f.view.setUint16(A+4,S,!0),f.view.setUint16(A+6,2,!0),f.u8[A+k]=46,f.u8[A+k+1]=46,f.w64(y+P,w+S),Atomics.store(f.i32,Dn>>2,1),f}static inspectImageCapacity(e){if(e.byteLengththis.snapshotBytesUnlocked(e))}snapshotState(e){return this.withNamespaceLock(()=>({bytes:this.snapshotBytesUnlocked(e),identities:this.collectIdentityStateUnlocked()}))}identityState(){return this.withNamespaceLock(()=>this.collectIdentityStateUnlocked())}snapshotBytesUnlocked(e){let t=e?.normalizeTimestampsMs;if(t!==void 0&&(!Number.isSafeInteger(t)||t<0))throw new I(j,"Snapshot timestamp must be a non-negative safe integer in milliseconds");let r=t===void 0?void 0:BigInt(t);for(let a=0;a>2)!==0)throw new I(Bn,"Cannot save a VFS image with open descriptors")}let i=this.r32(Ge);for(let a=0;a=1&&this.inodeIsAllocated(a)?r:0n;o.setBigUint64(c+er,u,!0),o.setBigUint64(c+fe,u,!0),o.setBigUint64(c+ee,u,!0)}}return s}collectIdentityStateUnlocked(){let e=new Map,t=this.inodeOffset(1),r=this.r64(t+ue);e.set(`1:${r}`,{ino:1,generation:r,dataSequence:Atomics.load(this.i32,t+de>>2)>>>0,mode:this.r32(t+F),linkCount:this.r32(t+K),size:this.r64(t+P),uid:this.r32(t+rr),gid:this.r32(t+nr),paths:["/"]});let i=[{ino:1,path:"/"}],s=new Set;for(;i.length>0;){let o=i.pop();if(s.has(o.ino))throw new I(B);s.add(o.ino);let a=this.inodeOffset(o.ino);if((this.r32(a+F)&G)!==H)throw new I(B);let c=this.r64(a+P),u=0;for(;u>2)>>>0,mode:v,linkCount:this.r32(S+K),size:this.r64(S+P),uid:this.r32(S+rr),gid:this.r32(S+nr),...(v&G)===_t?{symlinkTarget:this.readSymlinkInodeUnlocked(_)}:{},paths:[]},e.set(R,x)}x.paths.push(w),(this.r32(S+F)&G)===H&&i.push({ino:_,path:w})}}h+=y}u+=f}}return e}statfs(){let e=this.r32(Dr),t=this.r32(yt),r=this.r32(Qt),i=typeof this.buffer.maxByteLength=="number"?this.buffer.maxByteLength:this.buffer.byteLength,s=Math.floor(i/e),o=Math.max(t,Math.min(r,s)),a=Atomics.load(this.i32,gt>>2),c=Math.max(0,o-t);return{blockSize:e,totalBlocks:o,freeBlocks:a+c,totalInodes:this.r32(Ge),freeInodes:Atomics.load(this.i32,Kr>>2),maxName:255}}r32(e){return this.view.getUint32(e,!0)}w32(e,t){this.view.setUint32(e,t,!0)}r64(e){return Number(this.view.getBigUint64(e,!0))}w64(e,t){this.view.setBigUint64(e,BigInt(t),!0)}waitForAtomicChange(e,t){if(this.atomicsWaitAllowed!==!1)try{Atomics.wait(this.i32,e,t),this.atomicsWaitAllowed=!0;return}catch(r){if(!(r instanceof TypeError))throw r;this.atomicsWaitAllowed=!1}for(;Atomics.load(this.i32,e)===t;);}resetAllocationHints(){this.blockAllocHint=this.findNextFreeBlockHint(),this.inodeAllocHint=this.findNextFreeInodeHint()}findNextFreeBlockHint(){let e=this.r32(yt),t=this.r32($r),r=this.r32(Br)*4096;for(let i=t;i>2)+(i>>5),o=i&31;if((Atomics.load(this.i32,s)&1<>2)+(r>>5),s=r&31;if((Atomics.load(this.i32,i)&1<>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}sbUnlock(){let e=Ur>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}namespaceLock(){let e=Wr>>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}namespaceUnlock(){let e=Wr>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}withNamespaceLock(e){this.namespaceLock();try{return e()}finally{this.namespaceUnlock()}}resetRestoredRuntimeState(){Atomics.store(this.i32,Ur>>2,0),Atomics.store(this.i32,Wr>>2,0),this.u8.fill(0,256,4096);let e=this.r32(Ge),t=this.r32(nt)*4096;for(let r=0;r>5)*4)&1<<(r&31))===0||this.r32(i+K)!==0)continue;let o=this.r32(i+F),a=this.r64(i+P);(o&G)===_t&&a<=40?(this.u8.fill(0,i+ie,i+ie+40),this.w64(i+P,0)):this.inodeTruncate(r,0),this.inodeFree(r)}}blockAlloc(){let e=this.r32(yt),t=this.r32(Br)*4096,r=this.r32($r),i=this.blockAllocHint>=r&&this.blockAllocHint>2)+(a>>5),u=a&31,l=Atomics.load(this.i32,c);if(l&1<>2,1),this.blockAllocHint=a+1>2)+(e>>5),i=e&31;for(;;){let s=Atomics.load(this.i32,r),o=s&~(1<>2,1),e>=this.r32($r)&&e>2)>0)return 0;let e=this.r32(yt),t=this.r32(Qt),r=this.r32(xo),i=e+r;if(i>t&&(i=t,r=i-e,r===0))return oe;let s=i*4096;if(this.buffer.byteLength>2,r),Atomics.add(this.i32,Dn>>2,1),this.blockAllocHint=e,0}finally{this.sbUnlock()}}inodeOffset(e){let r=this.r32(Ro)+Math.floor(e/32),i=e%32*128;return r*4096+i}inodeAlloc(){let e=this.r32(Ge),t=this.r32(nt)*4096,r=this.inodeAllocHint>=2&&this.inodeAllocHint>2)+(o>>5),c=o&31,u=Atomics.load(this.i32,a);if(u&1<>2,1),this.inodeAllocHint=o+1>2,1)+1}inodeFree(e){let r=(this.r32(nt)*4096>>2)+(e>>5),i=e&31;for(;;){let s=Atomics.load(this.i32,r);if((s&1<>2,1),e>=2&&e0&&this.w32(r+He,i-1),i<=1&&this.r32(r+K)===0&&(this.inodeTruncate(e,0),t=!0)}finally{this.inodeWriteUnlock(e)}t&&this.inodeFree(e)}inodeDropLinkRefLocked(e){let t=this.inodeOffset(e),r=this.r32(t+K);return r>1?(this.w32(t+K,r-1),this.w64(t+ee,Date.now()),!1):this.inodeOrphanLocked(e)}inodeOrphanLocked(e){let t=this.inodeOffset(e);if(this.w32(t+K,0),this.w64(t+ee,Date.now()),this.r32(t+He)>0)return!1;let r=this.r32(t+F),i=this.r64(t+P);return(r&G)===_t&&i<=40?(this.u8.fill(0,t+ie,t+ie+40),this.w64(t+P,0)):this.inodeTruncate(e,0),!0}inodeReadLock(e){let t=this.inodeOffset(e)+Et>>2;for(;;){let r=Atomics.load(this.i32,t);if(r&bo){this.waitForAtomicChange(t,r);continue}if(Atomics.compareExchange(this.i32,t,r,r+1)===r)return}}inodeReadUnlock(e){let t=this.inodeOffset(e)+Et>>2;(Atomics.sub(this.i32,t,1)&Sc)===1&&Atomics.notify(this.i32,t,1)}inodeWriteLock(e){let t=this.inodeOffset(e)+Et>>2;for(;;){let r=Atomics.load(this.i32,t);if(r!==0){this.waitForAtomicChange(t,r);continue}if(Atomics.compareExchange(this.i32,t,0,bo)===0)return}}inodeWriteUnlock(e){let t=this.inodeOffset(e)+Et>>2;Atomics.store(this.i32,t,0),Atomics.notify(this.i32,t,1/0)}inodeBlockMap(e,t,r){let i=this.inodeOffset(e);if(t<10){let s=this.r32(i+ie+t*4);if(s!==0)return s;if(!r)return 0;let o=this.blockAllocWithGrow();return o<0||this.w32(i+ie+t*4,o),o}if(t-=10,t<1024){let s=this.r32(i+tr),o=!1;if(s===0){if(!r)return 0;if(s=this.blockAllocWithGrow(),s<0)return s;this.w32(i+tr,s),o=!0}let a=s*4096+t*4,c=this.r32(a);if(c!==0)return c;if(!r)return 0;let u=this.blockAllocWithGrow();return u<0?(o&&(this.w32(i+tr,0),this.blockFree(s)),u):(this.w32(a,u),u)}if(t-=1024,t<1024*1024){let s=Math.floor(t/1024),o=t%1024,a=this.r32(i+St),c=!1;if(a===0){if(!r)return 0;if(a=this.blockAllocWithGrow(),a<0)return a;this.w32(i+St,a),c=!0}let u=a*4096+s*4,l=this.r32(u),d=!1;if(l===0){if(!r)return 0;if(l=this.blockAllocWithGrow(),l<0)return c&&(this.w32(i+St,0),this.blockFree(a)),l;this.w32(u,l),d=!0}let p=l*4096+o*4,m=this.r32(p);if(m!==0)return m;if(!r)return 0;let f=this.blockAllocWithGrow();return f<0?(d&&(this.w32(u,0),this.blockFree(l)),c&&(this.w32(i+St,0),this.blockFree(a)),f):(this.w32(p,f),f)}return j}inodeReadData(e,t,r,i){let s=this.inodeOffset(e),o=this.r64(s+P);if(t>=o)return 0;t+i>o&&(i=o-t);let a=0,c=0;for(;i>0;){let u=Math.floor(t/4096),l=t%4096,d=4096-l;d>i&&(d=i);let p=this.inodeBlockMap(e,u,!1);if(p<=0)r.fill(0,c,c+d);else{let m=p*4096+l;r.set(this.u8.subarray(m,m+d),c)}c+=d,t+=d,i-=d,a+=d}return a}inodeWriteData(e,t,r,i){let s=this.inodeOffset(e),o=this.r64(s+P);t>o&&this.zeroOldEofTail(e,o);let a=0,c=0;for(;i>0;){let u=Math.floor(t/4096),l=t%4096,d=4096-l;d>i&&(d=i);let p=this.inodeBlockMap(e,u,!0);if(p<0){if(a===0)return p;break}let m=p*4096+l;this.u8.set(r.subarray(c,c+d),m),c+=d,t+=d,i-=d,a+=d}if(a>0&&t>this.r64(s+P)&&this.w64(s+P,t),a>0){let u=Date.now();this.w64(s+fe,u),this.w64(s+ee,u),Atomics.add(this.i32,s+de>>2,1)}return a}zeroInodeRange(e,t,r){for(;t0){let c=a*4096+s;this.u8.fill(0,c,c+o)}t+=o}}zeroOldEofTail(e,t){let r=t%4096;if(r===0)return;let i=Math.floor(t/4096),s=this.inodeBlockMap(e,i,!1);if(s<=0)return;let o=s*4096+r;this.u8.fill(0,o,s*4096+4096)}freeBlocksFrom(e,t){let r=this.inodeOffset(e);for(let o=t;o<10;o++){let a=this.r32(r+ie+o*4);a&&(this.blockFree(a),this.w32(r+ie+o*4,0))}let i=this.r32(r+tr);if(i){let o=t>10?t-10:0;for(let a=o;a<1024;a++){let c=i*4096+a*4,u=this.r32(c);u&&(this.blockFree(u),this.w32(c,0))}o===0&&(this.blockFree(i),this.w32(r+tr,0))}let s=this.r32(r+St);if(s){let o=t>1034?t-10-1024:0,a=Math.floor(o/1024);for(let c=a;c<1024;c++){let u=s*4096+c*4,l=this.r32(u);if(!l)continue;let d=c===a?o%1024:0;for(let p=d;p<1024;p++){let m=l*4096+p*4,f=this.r32(m);f&&(this.blockFree(f),this.w32(m,0))}d===0&&(this.blockFree(l),this.w32(u,0))}a===0&&(this.blockFree(s),this.w32(r+St,0))}}inodeTruncate(e,t,r=!1){let i=this.inodeOffset(e),s=this.r64(i+P),o=t!==s;if(t>=s){if(t>s&&this.zeroOldEofTail(e,s),this.w64(i+P,t),o||r){let c=Date.now();this.w64(i+fe,c),this.w64(i+ee,c),Atomics.add(this.i32,i+de>>2,1)}return}t%4096!==0&&this.zeroInodeRange(e,t,Math.ceil(t/4096)*4096);let a=Math.ceil(t/4096);if(this.freeBlocksFrom(e,a),this.w64(i+P,t),o||r){let c=Date.now();this.w64(i+fe,c),this.w64(i+ee,c),Atomics.add(this.i32,i+de>>2,1)}}validateFileSize(e){if(!Number.isSafeInteger(e)||e<0)throw new I(j);if(e>Ve)throw new I(ot)}validateSeekPosition(e){if(!Number.isSafeInteger(e))throw new I(No);if(e<0)throw new I(j);if(e>Ve)throw new I(ot)}touchDirectoryMutation(e){let t=this.inodeOffset(e),r=Date.now();this.w64(t+fe,r),this.w64(t+ee,r);let i=Atomics.add(this.i32,t+To>>2,1)+1>>>0,s=this.dirIndexes.get(e);s&&(s.mutationSequence=i,s.size=this.r64(t+P))}dirNameKey(e){return wt(e)}dirEntryNameMatches(e,t){if(this.view.getUint16(e+6,!0)!==t.length)return!1;for(let i=0;i=k&&r%4===0&&e+r<=t&&i<=r-k}inodeIsAllocated(e){let t=this.r32(Ge);if(e<=0||e>=t)return!1;let r=this.r32(nt)*4096;return(Atomics.load(this.i32,(r>>2)+(e>>5))&1<<(e&31))!==0}rebuildDirIndex(e,t,r,i){let s=new Map,o=[],a=0;for(;a4096-l&&(m=4096-l);let f=l;for(;f=k&&o.push({abs:h,recLen:_});f+=_}a+=m}let c={generation:t,mutationSequence:r,size:i,entries:s,free:o};return this.dirIndexes.set(e,c),c}getDirIndex(e){let t=this.inodeOffset(e),r=this.r64(t+P),i=this.r64(t+ue),s=Atomics.load(this.i32,t+To>>2)>>>0,o=this.dirIndexes.get(e);return o&&o.generation===i&&o.mutationSequence===s&&o.size===r?o:(o&&this.dirIndexes.delete(e),r=0;o--){let a=e.free[o];if(!(a.recLen4096-c&&(d=4096-c);let p=c;for(;pr)return-1;a=c,o+=u}return o===r?a:-1}dirAppendEntry(e,t,r,i=-1){let s=this.inodeOffset(e),o=this.r64(s+P),a=qe(k+t.length),c=o,u=Math.floor(c/4096),l=c%4096,d=0;if(l!==0&&l+a>4096){let f=4096-l,h=0;if(f>=k){if(h=this.inodeBlockMap(e,u,!1),h<=0)return B}else if(i<0&&(i=this.findLastDirEntryInBlock(e,u,l)),i<0)return B;if(d=this.inodeBlockMap(e,u+1,!0),d<0)return d;if(f>=k){let g=h*4096+l;this.w32(g,0),this.view.setUint16(g+4,f,!0),this.view.setUint16(g+6,0,!0)}else{let _=this.view.getUint16(i+4,!0)+f;this.view.setUint16(i+4,_,!0),this.updateDirIndexRecLen(e,i,_)}c=(u+1)*4096,u++,l=0}let p;if(l===0){if(p=d||this.inodeBlockMap(e,u,!0),p<0)return p}else if(p=this.inodeBlockMap(e,u,!1),p<=0)return B;let m=p*4096+l;return this.w32(m,r),this.view.setUint16(m+4,a,!0),this.view.setUint16(m+6,t.length,!0),this.u8.set(t,m+k),this.w64(s+P,c+a),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,m,a),0}dirAddEntry(e,t,r){let i=this.getDirIndex(e);if(typeof i=="number")return i;if(i)return this.useDirIndexFreeSlot(i,e,t,r)?0:this.dirAppendEntry(e,t,r);let s=this.inodeOffset(e),o=this.r64(s+P),a=qe(k+t.length),c=-1,u=0;for(;u4096-d&&(f=4096-d);let h=d;for(;hd+f||E>y-k)return B;if(_===0&&y>=a)return this.w32(g,r),this.view.setUint16(g+6,t.length,!0),this.u8.set(t,g+k),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,g,y),0;let O=qe(k+E),w=y-O;if(_!==0&&w>=a){this.view.setUint16(g+4,O,!0);let S=g+O;return this.w32(S,r),this.view.setUint16(S+4,w,!0),this.view.setUint16(S+6,t.length,!0),this.u8.set(t,S+k),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,S,w),0}c=g,h+=y}u+=f}return this.dirAppendEntry(e,t,r,c)}dirRemoveEntry(e,t){let r=this.getDirIndex(e);if(typeof r=="number")return r;if(r){let a=this.dirNameKey(t),c=r.entries.get(a);if(!c)return Oe;if(this.r32(c.abs)===c.ino&&this.view.getUint16(c.abs+4,!0)===c.recLen&&this.view.getUint16(c.abs+6,!0)===c.nameLen&&this.dirEntryNameMatches(c.abs,t))return this.w32(c.abs,0),r.entries.delete(a),r.free.push({abs:c.abs,recLen:c.recLen}),this.touchDirectoryMutation(e),0;r.entries.delete(a)}let i=this.inodeOffset(e),s=this.r64(i+P),o=0;for(;o4096-c&&(d=4096-c);let p=c;for(;p4096-u&&(p=4096-u);let m=u;for(;m4096-o&&(u=4096-o);let l=o;for(;lo+u||f>m-k)throw new I(B);if(p!==0){if(f===1&&this.u8[d+k]===46){l+=m;continue}if(f===2&&this.u8[d+k]===46&&this.u8[d+k+1]===46){l+=m;continue}return!1}l+=m}i+=u}return!0}dirIsAncestor(e,t){let r=t;for(let i=0;i<8*1024;i++){if(r===e)return!0;if(r===1)return!1;let s=this.dirLookup(r,Po);if(s<0||s===r)throw new I(B);r=s}throw new I(B)}pathResolve(e,t){if(!e.startsWith("/"))return Oe;let r=1,i=e.split("/").filter(o=>o.length>0),s=0;for(let o=0;o255)return $n;let c=me.encode(a),u;this.inodeReadLock(r);try{let p=this.inodeOffset(r);if((this.r32(p+F)&G)!==H)return ze;u=this.dirLookup(r,c)}finally{this.inodeReadUnlock(r)}if(u<0)return u;let l=this.inodeOffset(u);if((this.r32(l+F)&G)===_t&&(!(o===i.length-1)||t)){if(++s>8)return Fo;let m=this.r64(l+P),f;if(m<=40)f=wt(this.u8.subarray(l+ie,l+ie+m));else{let h=new Uint8Array(m);this.inodeReadData(u,0,h,m),f=or.decode(h)}if(f.startsWith("/")){r=1;let h=f.split("/").filter(_=>_.length>0),g=i.slice(o+1);i.length=0,i.push(...h,...g),o=-1}else{let h=f.split("/").filter(_=>_.length>0),g=i.slice(o+1);i.length=o,i.push(...h,...g),o--}continue}r=u}return r}pathResolveParent(e){if(!e.startsWith("/"))throw new I(j,"Path must be absolute");let t=e.split("/").filter(c=>c.length>0);if(t.length===0)throw new I(j,"Cannot operate on /");let r=t.pop();if(r.length>255)throw new I($n);let i="/"+t.join("/"),s=this.pathResolve(i,!0);if(s<0)throw new I(s);let o=this.inodeOffset(s);if((this.r32(o+F)&G)!==H)throw new I(ze);return{parentIno:s,name:r}}fdAlloc(e,t,r){for(let i=0;i>2;if(Atomics.compareExchange(this.i32,o,0,1)===0)return this.w32(s+vo,e),this.w64(s+Le,0),this.w32(s+Lo,t),this.w32(s+zo,r?1:0),this.inodeAddOpenRef(e)?i:(Atomics.store(this.i32,o,0),Oe)}return ko}fdGet(e){if(e<0||e>=Mr)return null;let t=256+e*24;return Atomics.load(this.i32,t>>2)?{base:t,ino:this.r32(t+vo),offset:this.r64(t+Le),flags:this.r32(t+Lo),isDir:this.r32(t+zo)!==0}:null}fdFree(e){if(e>=0&&e>2,0)}}buildStat(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+ue),dataSequence:this.r32(t+de),mode:this.r32(t+F),linkCount:this.r32(t+K),size:this.r64(t+P),mtime:this.r64(t+fe),ctime:this.r64(t+ee),atime:this.r64(t+er),uid:this.r32(t+rr),gid:this.r32(t+nr)}}namespaceEntryIdentity(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+ue),linkCount:this.r32(t+K),mode:this.r32(t+F)}}open(e,t,r=420){return this.withNamespaceLock(()=>this.openUnlocked(e,t,r))}createLazyStub(e,t){return this.withNamespaceLock(()=>{let r=this.openUnlocked(e,Io|ir,t);try{let i=this.fdGet(r);if(!i)throw new I(re);this.inodeWriteLock(i.ino);try{return this.inodeTruncate(i.ino,0,!0),this.buildStat(i.ino)}finally{this.inodeWriteUnlock(i.ino)}}finally{this.closeUnlocked(r)}})}replaceIfIdentity(e,t,r,i,s){return this.withNamespaceLock(()=>{let o=this.pathResolve(e,!0);if(o<0||o!==t)return!1;let a=this.inodeOffset(o);if(this.r64(a+ue)!==r||this.r32(a+de)!==i||(this.r32(a+F)&G)!==jt)return!1;this.validateFileSize(s.byteLength),this.inodeWriteLock(o);try{if(this.r64(a+ue)!==r||this.r32(a+de)!==i||this.r64(a+P)!==0)return!1;let c=this.r64(a+fe),u=this.r64(a+ee);this.inodeTruncate(o,0,!0);let l=s.byteLength>0?this.inodeWriteData(o,0,s,s.byteLength):0;if(l!==s.byteLength)throw this.inodeTruncate(o,0,!0),Atomics.store(this.i32,a+de>>2,i),this.w64(a+fe,c),this.w64(a+ee,u),new I(l<0?l:oe);return!0}finally{this.inodeWriteUnlock(o)}})}replaceManyIfIdentities(e,t=[]){return e.length===0&&t.length===0?!0:this.withNamespaceLock(()=>{let r=[],i=new Set,s=a=>{let c=this.pathResolve(a.path,!1);if(c<0||c!==a.expectedIno)return!1;let u=this.inodeOffset(c);return this.r64(u+ue)===a.expectedGeneration&&this.r32(u+de)===a.expectedDataSequence&&this.r32(u+F)===a.expectedMode&&this.r32(u+K)===a.expectedLinkCount&&this.r64(u+P)===a.expectedSize&&this.r32(u+rr)===a.expectedUid&&this.r32(u+nr)===a.expectedGid};for(let a of t)if(!s(a))return!1;for(let a of e){this.validateFileSize(a.data.byteLength);let c=-1;for(let u of a.paths){let l=this.pathResolve(u,!0);if(l!==a.expectedIno)continue;let d=this.inodeOffset(l);if(this.r64(d+ue)===a.expectedGeneration&&this.r32(d+de)===a.expectedDataSequence&&(this.r32(d+F)&G)===jt&&this.r64(d+P)===0){c=l;break}}if(c<0)return!1;if(i.has(c))throw new I(j,"duplicate conditional replacement inode");i.add(c),r.push({...a,ino:c})}let o=[...i].sort((a,c)=>a-c);for(let a of o)this.inodeWriteLock(a);try{for(let u of r){let l=this.inodeOffset(u.ino);if(this.r64(l+ue)!==u.expectedGeneration||this.r32(l+de)!==u.expectedDataSequence||(this.r32(l+F)&G)!==jt||this.r64(l+P)!==0)return!1}for(let u of t)if(!s(u))return!1;let a=r.map(u=>{let l=this.inodeOffset(u.ino);return{ino:u.ino,dataSequence:this.r32(l+de),mtime:this.r64(l+fe),ctime:this.r64(l+ee)}}),c=0;try{for(let u of r){c++,this.inodeTruncate(u.ino,0,!0);let l=u.data.byteLength>0?this.inodeWriteData(u.ino,0,u.data,u.data.byteLength):0;if(l!==u.data.byteLength)throw new I(l<0?l:oe)}}catch(u){for(let l=c-1;l>=0;l--){let d=a[l],p=this.inodeOffset(d.ino);this.inodeTruncate(d.ino,0,!0),Atomics.store(this.i32,p+de>>2,d.dataSequence),this.w64(p+fe,d.mtime),this.w64(p+ee,d.ctime)}throw u}return!0}finally{for(let a=o.length-1;a>=0;a--)this.inodeWriteUnlock(o[a])}})}openUnlocked(e,t,r=420){let i=t&Jt,s=(t&ir)!==0,o=(t&Wn)!==0;if(s&&o){let d=this.pathResolve(e,!1);if(d>=0)throw new I(Ot);if(d!==Oe)throw new I(d)}let a=this.pathResolve(e,!0);if(a<0&&a===Oe&&s){let{parentIno:d,name:p}=this.pathResolveParent(e);this.inodeWriteLock(d);try{let m=me.encode(p),f=this.dirLookup(d,m);if(f>=0){if(o)throw new I(Ot);a=f}else{let h=this.inodeAlloc();if(h<0)throw new I(oe);let g=this.inodeOffset(h);this.w32(g+F,jt|r&4095),this.w32(g+K,1),this.w64(g+P,0);let _=Date.now();this.w64(g+er,_),this.w64(g+fe,_),this.w64(g+ee,_);let y=this.dirAddEntry(d,m,h);if(y<0)throw this.inodeFree(h),new I(y);a=h}}finally{this.inodeWriteUnlock(d)}}if(a<0)throw new I(a);let c=this.inodeOffset(a),u=this.r32(c+F);if((u&G)===H&&i!==rt)throw new I(it);if(t&fc&&(u&G)!==H)throw new I(ze);if(t&sr){if((u&G)===H)throw new I(it);this.inodeWriteLock(a),this.inodeTruncate(a,0,!0),this.inodeWriteUnlock(a)}let l=this.fdAlloc(a,t,!1);if(l<0)throw new I(l);return l}close(e){this.withNamespaceLock(()=>this.closeUnlocked(e))}closeUnlocked(e){let t=this.fdGet(e);if(!t)throw new I(re);this.fdFree(e),this.inodeDropOpenRef(t.ino)}read(e,t){let r=this.fdGet(e);if(!r)throw new I(re);let i=this.inodeOffset(r.ino);if((this.r32(i+F)&G)===H)throw new I(it);this.inodeReadLock(r.ino);try{let o=this.inodeReadData(r.ino,r.offset,t,t.length),a=256+e*24;return this.w64(a+Le,r.offset+o),o}finally{this.inodeReadUnlock(r.ino)}}readAt(e,t,r){let i=this.fdGet(e);if(!i)throw new I(re);let s=this.inodeOffset(i.ino);if((this.r32(s+F)&G)===H)throw new I(it);this.validateSeekPosition(r),this.inodeReadLock(i.ino);try{return this.inodeReadData(i.ino,r,t,t.length)}finally{this.inodeReadUnlock(i.ino)}}write(e,t){let r=this.fdGet(e);if(!r)throw new I(re);if((r.flags&Jt)===rt)throw new I(re);this.inodeWriteLock(r.ino);try{let s=r.offset;if(r.flags&lc){let c=this.inodeOffset(r.ino);s=this.r64(c+P)}if(!Number.isSafeInteger(s)||s<0)throw new I(j);if(s>Ve||t.length>Ve-s)throw new I(ot);let o=this.inodeWriteData(r.ino,s,t,t.length);if(o<0)return o;let a=256+e*24;return this.w64(a+Le,s+o),o}finally{this.inodeWriteUnlock(r.ino)}}append(e,t,r){let i=this.fdGet(e);if(!i)throw new I(re);if((i.flags&Jt)===rt)throw new I(re);if(r!==null&&(!Number.isSafeInteger(r)||r<0))throw new I(j);this.inodeWriteLock(i.ino);try{let o=this.inodeOffset(i.ino),a=this.r64(o+P);if(!Number.isSafeInteger(a)||a<0)throw new I(j);if(a>Ve)throw new I(ot);if(r!==null&&a>=r){let f=256+e*24;return this.w64(f+Le,a),{written:0,end:a}}let c=r===null?t.length:Math.min(t.length,r-a),u=Ve-a;if(c>u)throw new I(ot);let l=t.subarray(0,c),d=this.inodeWriteData(i.ino,a,l,l.length);if(d<0)throw new I(d);let p=256+e*24,m=a+d;return this.w64(p+Le,m),{written:d,end:m}}finally{this.inodeWriteUnlock(i.ino)}}writeAt(e,t,r){let i=this.fdGet(e);if(!i)throw new I(re);if((i.flags&Jt)===rt)throw new I(re);this.validateSeekPosition(r),this.inodeWriteLock(i.ino);try{if(r>Ve||t.length>Ve-r)throw new I(ot);return this.inodeWriteData(i.ino,r,t,t.length)}finally{this.inodeWriteUnlock(i.ino)}}lseek(e,t,r){let i=this.fdGet(e);if(!i)throw new I(re);let s;if(r===hc)s=t;else if(r===pc)s=i.offset+t;else if(r===mc){let a=this.inodeOffset(i.ino);s=this.r64(a+P)+t}else throw new I(j);this.validateSeekPosition(s);let o=256+e*24;return this.w64(o+Le,s),s}ftruncate(e,t){let r=this.fdGet(e);if(!r)throw new I(re);if((r.flags&Jt)===rt)throw new I(re);this.validateFileSize(t),this.inodeWriteLock(r.ino);try{this.inodeTruncate(r.ino,t,!0)}finally{this.inodeWriteUnlock(r.ino)}}fstat(e){let t=this.fdGet(e);if(!t)throw new I(re);this.inodeReadLock(t.ino);try{return this.buildStat(t.ino)}finally{this.inodeReadUnlock(t.ino)}}stat(e){return this.withNamespaceLock(()=>this.statUnlocked(e))}statUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new I(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}lstat(e){return this.withNamespaceLock(()=>this.lstatUnlocked(e))}lstatUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new I(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}unlink(e){return this.withNamespaceLock(()=>this.unlinkUnlocked(e))}unlinkUnlocked(e){let{parentIno:t,name:r}=this.pathResolveParent(e),i=me.encode(r),s=e.length>1&&e.endsWith("/");this.inodeWriteLock(t);try{let o=this.dirLookup(t,i);if(o<0)throw new I(o);let a=this.inodeOffset(o),c=this.r32(a+F);if(s&&(c&G)!==H)throw new I(ze);if((c&G)===H)throw new I(it);let u=this.namespaceEntryIdentity(o),l=this.dirRemoveEntry(t,i);if(l<0)throw new I(l);let d=!1;this.inodeWriteLock(o);try{d=this.inodeDropLinkRefLocked(o)}finally{this.inodeWriteUnlock(o)}return d&&this.inodeFree(o),u}finally{this.inodeWriteUnlock(t)}}rename(e,t){return this.withNamespaceLock(()=>this.renameUnlocked(e,t))}renameUnlocked(e,t){let{parentIno:r,name:i}=this.pathResolveParent(e),{parentIno:s,name:o}=this.pathResolveParent(t);if(Kn(i)||Kn(o))throw new I(j);let a=me.encode(i),c=me.encode(o),u=e.length>1&&e.endsWith("/"),l=t.length>1&&t.endsWith("/"),d=Math.min(r,s),p=Math.max(r,s);this.inodeWriteLock(d),d!==p&&this.inodeWriteLock(p);try{let m=this.dirLookup(r,a);if(m<0)throw new I(m);let f=this.inodeOffset(m),g=this.r32(f+F)&G,_=this.namespaceEntryIdentity(m);if((u||l)&&g!==H)throw new I(ze);if(g===H&&this.dirIsAncestor(m,s))throw new I(j);let y=this.dirLookup(s,c),E=!1,O;if(y>=0){if(y===m)return{source:_,replaced:_};O=this.namespaceEntryIdentity(y);let S=this.inodeOffset(y),R=this.r32(S+F)&G;if(g===H&&R!==H)throw new I(ze);if(g!==H&&R===H)throw new I(it);let x=!1,v=y===r||y===s;v||this.inodeWriteLock(y);try{if(R===H&&!this.dirIsEmpty(y))throw new I(Un);let L=this.dirReplaceEntryIno(s,c,m);if(L<0)throw new I(L);x=R===H?this.inodeOrphanLocked(y):this.inodeDropLinkRefLocked(y)}finally{v||this.inodeWriteUnlock(y)}x&&this.inodeFree(y),E=R===H}else{let S=this.dirAddEntry(s,c,m);if(S<0)throw new I(S)}let w=this.dirRemoveEntry(r,a);if(w<0)throw new I(w);if(g===H){if(r!==s){let S=this.inodeOffset(r);this.w32(S+K,this.r32(S+K)-1);let A=this.inodeOffset(s);this.w32(A+K,this.r32(A+K)+1),this.inodeWriteLock(m);try{let R=this.dirReplaceEntryIno(m,Po,s);if(R<0)throw new I(R);this.w64(f+ee,Date.now())}finally{this.inodeWriteUnlock(m)}}if(E){let S=this.inodeOffset(s);this.w32(S+K,this.r32(S+K)-1)}}else if(E){let S=this.inodeOffset(s);this.w32(S+K,this.r32(S+K)-1)}return{source:_,replaced:O}}finally{d!==p&&this.inodeWriteUnlock(p),this.inodeWriteUnlock(d)}}mkdir(e,t=493){this.withNamespaceLock(()=>this.mkdirUnlocked(e,t))}mkdirUnlocked(e,t=493){let{parentIno:r,name:i}=this.pathResolveParent(e),s=me.encode(i);this.inodeWriteLock(r);try{if(this.dirLookup(r,s)>=0)throw new I(Ot);let a=this.inodeAlloc();if(a<0)throw new I(oe);let c=this.inodeOffset(a);this.w32(c+F,H|t),this.w32(c+K,2),this.w64(c+P,0);let u=Date.now();this.w64(c+er,u),this.w64(c+fe,u),this.w64(c+ee,u);let l=this.blockAllocWithGrow();if(l<0)throw this.inodeFree(a),new I(oe);this.w32(c+ie,l);let d=l*4096,p=qe(k+1),m=qe(k+2);this.w32(d,a),this.view.setUint16(d+4,p,!0),this.view.setUint16(d+6,1,!0),this.u8[d+k]=46;let f=d+p;this.w32(f,r),this.view.setUint16(f+4,m,!0),this.view.setUint16(f+6,2,!0),this.u8[f+k]=46,this.u8[f+k+1]=46,this.w64(c+P,p+m);let h=this.dirAddEntry(r,s,a);if(h<0)throw this.blockFree(l),this.inodeFree(a),new I(h);let g=this.inodeOffset(r);this.w32(g+K,this.r32(g+K)+1)}finally{this.inodeWriteUnlock(r)}}rmdir(e){this.withNamespaceLock(()=>this.rmdirUnlocked(e))}rmdirUnlocked(e){let{parentIno:t,name:r}=this.pathResolveParent(e);if(Kn(r))throw new I(j);let i=me.encode(r);this.inodeWriteLock(t);try{let s=this.dirLookup(t,i);if(s<0)throw new I(s);let o=this.inodeOffset(s);if((this.r32(o+F)&G)!==H)throw new I(ze);let c=!1;this.inodeWriteLock(s);try{if(!this.dirIsEmpty(s))throw new I(Un);let l=this.dirRemoveEntry(t,i);if(l<0)throw new I(l);c=this.inodeOrphanLocked(s)}finally{this.inodeWriteUnlock(s)}c&&this.inodeFree(s);let u=this.inodeOffset(t);this.w32(u+K,this.r32(u+K)-1)}finally{this.inodeWriteUnlock(t)}}symlink(e,t){this.withNamespaceLock(()=>this.symlinkUnlocked(e,t))}symlinkUnlocked(e,t){let{parentIno:r,name:i}=this.pathResolveParent(t),s=me.encode(i),o=me.encode(e);this.inodeWriteLock(r);try{if(this.dirLookup(r,s)>=0)throw new I(Ot);let c=this.inodeAlloc();if(c<0)throw new I(oe);let u=this.inodeOffset(c);if(this.w32(u+F,_t|511),this.w32(u+K,1),o.length<=40)this.u8.set(o,u+ie),this.w64(u+P,o.length);else{this.w64(u+P,0);let d=this.inodeWriteData(c,0,o,o.length);if(d!==o.length)throw d>0&&this.inodeTruncate(c,0),this.inodeFree(c),new I(d<0?d:oe)}let l=this.dirAddEntry(r,s,c);if(l<0)throw o.length<=40?(this.u8.fill(0,u+ie,u+ie+40),this.w64(u+P,0)):this.inodeTruncate(c,0),this.inodeFree(c),new I(l)}finally{this.inodeWriteUnlock(r)}}chmod(e,t){this.withNamespaceLock(()=>this.chmodUnlocked(e,t))}chmodUnlocked(e,t){let r=this.pathResolve(e,!0);if(r<0)throw new I(r);this.inodeWriteLock(r);try{let i=this.inodeOffset(r),s=this.r32(i+F);this.w32(i+F,s&G|t&4095),this.w64(i+ee,Date.now())}finally{this.inodeWriteUnlock(r)}}fchmod(e,t){let r=this.fdGet(e);if(!r)throw new I(re);this.inodeWriteLock(r.ino);try{let i=this.inodeOffset(r.ino),s=this.r32(i+F);this.w32(i+F,s&G|t&4095),this.w64(i+ee,Date.now())}finally{this.inodeWriteUnlock(r.ino)}}chown(e,t,r){this.withNamespaceLock(()=>this.chownUnlocked(e,t,r))}chownUnlocked(e,t,r){let i=this.pathResolve(e,!0);if(i<0)throw new I(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,r)}finally{this.inodeWriteUnlock(i)}}fchown(e,t,r){let i=this.fdGet(e);if(!i)throw new I(re);this.inodeWriteLock(i.ino);try{this.chownInodeUnlocked(i.ino,t,r)}finally{this.inodeWriteUnlock(i.ino)}}lchown(e,t,r){this.withNamespaceLock(()=>this.lchownUnlocked(e,t,r))}lchownUnlocked(e,t,r){let i=this.pathResolve(e,!1);if(i<0)throw new I(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,r)}finally{this.inodeWriteUnlock(i)}}chownInodeUnlocked(e,t,r){let i=this.inodeOffset(e);t!==Ao&&this.w32(i+rr,t),r!==Ao&&this.w32(i+nr,r);let s=this.r32(i+F);(s&G)===jt&&(s&dc)!==0&&this.w32(i+F,s&~(cc|uc)),this.w64(i+ee,Date.now())}utimens(e,t,r,i,s){this.withNamespaceLock(()=>this.utimensUnlocked(e,t,r,i,s))}utimensUnlocked(e,t,r,i,s){let o=this.pathResolve(e,!0);if(o<0)throw new I(o);this.inodeWriteLock(o);try{let a=this.inodeOffset(o),c=1073741823,u=1073741822,l=Date.now();if(r!==u){let d=r===c?l:t*1e3+Math.floor(r/1e6);this.w64(a+er,d)}if(s!==u){let d=s===c?l:i*1e3+Math.floor(s/1e6);this.w64(a+fe,d)}this.w64(a+ee,l)}finally{this.inodeWriteUnlock(o)}}link(e,t){return this.withNamespaceLock(()=>this.linkUnlocked(e,t))}linkUnlocked(e,t){let r=this.pathResolve(e,!1);if(r<0)throw new I(r);let i=this.inodeOffset(r);if((this.r32(i+F)&G)===H)throw new I(_c);let{parentIno:o,name:a}=this.pathResolveParent(t),c=me.encode(a);this.inodeWriteLock(o);try{if(this.dirLookup(o,c)>=0)throw new I(Ot);let l=this.dirAddEntry(o,c,r);if(l<0)throw new I(l);this.inodeWriteLock(r);try{let d=this.r32(i+K);this.w32(i+K,d+1),this.w64(i+ee,Date.now())}finally{this.inodeWriteUnlock(r)}return{...this.namespaceEntryIdentity(r),linkCount:this.r32(i+K)}}finally{this.inodeWriteUnlock(o)}}readlink(e){return this.withNamespaceLock(()=>this.readlinkUnlocked(e))}readlinkUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new I(t);return this.readSymlinkInodeUnlocked(t)}readSymlinkInodeUnlocked(e){let t=this.inodeOffset(e);if((this.r32(t+F)&G)!==_t)throw new I(j);let i=this.r64(t+P);if(i<=40)return wt(this.u8.subarray(t+ie,t+ie+i));this.inodeReadLock(e);try{let s=new Uint8Array(i);return this.inodeReadData(e,0,s,i),or.decode(s)}finally{this.inodeReadUnlock(e)}}opendir(e){return this.withNamespaceLock(()=>this.opendirUnlocked(e))}opendirUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new I(t);let r=this.inodeOffset(t);if((this.r32(r+F)&G)!==H)throw new I(ze);let s=this.fdAlloc(t,rt,!0);if(s<0)throw new I(s);return s}readdirEntry(e){return this.withNamespaceLock(()=>this.readdirEntryUnlocked(e))}readdirEntryUnlocked(e){let t=this.fdGet(e);if(!t||!t.isDir)throw new I(re);let r=this.inodeOffset(t.ino),i=this.r64(r+P);for(;t.offset=this.r32(Ge))throw new I(B);let h=this.r32(nt)*4096;if((this.r32(h+(l>>5)*4)&1<<(l&31))===0)throw new I(B);let _=wt(this.u8.subarray(u+k,u+k+p)),y=this.buildStat(l);return this.w64(f+Le,m),t.offset=m,{name:_,stat:y}}return null}closedir(e){this.close(e)}readdir(e){let t=this.opendir(e),r=[];try{let i;for(;(i=this.readdirEntry(t))!==null;)i.name!=="."&&i.name!==".."&&r.push(i.name)}finally{this.closedir(t)}return r}writeFile(e,t){let r=typeof t=="string"?me.encode(t):t,i=this.open(e,Io|ir|sr);try{this.write(i,r)}finally{this.close(i)}}readFile(e){let t=this.open(e,rt);try{let r=this.fstat(t),i=new Uint8Array(r.size);return this.read(t,i),i}finally{this.close(t)}}readFileText(e){return or.decode(this.readFile(e))}};function Co(n,e){let t=new Map,r=new Map;for(let o of n){if(t.has(o.path))throw new Error(`${e} duplicates path ${o.path}`);if(t.set(o.path,o),o.type==="file"){if(!o.inodeGroup)throw new Error(`${e} file ${o.path} has no inode group`);if(r.has(o.inodeGroup))throw new Error(`${e} inode group ${o.inodeGroup} has multiple files`);r.set(o.inodeGroup,o)}}let i=new Set,s=new Map;for(let o of n){if(o.type!=="hardlink"||s.has(o.path))continue;let a=[],c=o,u;for(;c.type==="hardlink";){let d=s.get(c.path);if(d){u=d;break}if(i.has(c.path))throw new Error(`${e} hardlink cycle reaches ${c.path}`);if(i.add(c.path),a.push(c),!c.target)throw new Error(`${e} hardlink ${c.path} has no target`);let p=t.get(c.target);if(!p)throw new Error(`${e} hardlink ${c.path} target ${c.target} is missing`);if(p.type!=="file"&&p.type!=="hardlink"||!c.inodeGroup||p.inodeGroup!==c.inodeGroup||p.size!==c.size||p.mode!==c.mode)throw new Error(`${e} hardlink ${c.path} has an invalid target`);c=p}u??=c.type==="file"?c:void 0;let l=r.get(o.inodeGroup??"");if(!u||u!==l)throw new Error(`${e} hardlink ${o.path} does not resolve to its inode`);for(let d=a.length-1;d>=0;d-=1){let p=a[d];if(r.get(p.inodeGroup??"")!==u)throw new Error(`${e} hardlink ${p.path} does not resolve to its inode`);i.delete(p.path),s.set(p.path,u)}}return{canonicalByGroup:r,canonicalTargetByPath:s}}var he={maxArchiveBytes:268435456,maxExpandedBytes:268435456,maxPayloadBytes:268435456,maxEntries:1e5,maxPathBytes:4096,maxSymlinkTargetBytes:65536,maxStringBytes:8192,maxTransportsPerTree:8,maxActivationCapabilities:32,maxActivationRoots:64,maxActivationCapabilityBytes:255},xe={maxArchiveBytes:512*1024*1024,maxExpandedBytes:512*1024*1024,maxPayloadBytes:512*1024*1024,maxEntries:1e5,maxGroups:512};function Mo(n,e="Deferred tree collection"){for(let[t,r]of Object.entries(n))if(!Number.isSafeInteger(r)||r<0)throw new Error(`${e} ${t} usage is invalid`);if(n.groups>xe.maxGroups)throw new Error(`${e} exceeds the ${xe.maxGroups}-group cap`);if(n.archiveBytes>xe.maxArchiveBytes)throw new Error(`${e} exceeds the archive-byte cap`);if(n.expandedBytes>xe.maxExpandedBytes)throw new Error(`${e} exceeds the expansion cap`);if(n.payloadBytes>xe.maxPayloadBytes)throw new Error(`${e} exceeds the payload-byte cap`);if(n.entries>xe.maxEntries)throw new Error(`${e} exceeds the entry-count cap`)}var Do=Object.freeze({prefix:"/opt/kandelo/homebrew",cellar:"/opt/kandelo/homebrew/Cellar",repository:"/opt/kandelo/homebrew",stableEntrypoint:"/usr/bin/brew"});var Ko=1e5,Ac=4096;var At=Do.prefix,Uo=[["@@HOMEBREW_PREFIX@@",At],["@@HOMEBREW_CELLAR@@",`${At}/Cellar`],["@@HOMEBREW_REPOSITORY@@",At],["@@HOMEBREW_LIBRARY@@",`${At}/Library`],["@@HOMEBREW_PERL@@",`${At}/opt/perl/bin/perl`]],Gn="@@HOMEBREW_JAVA@@",Ic=/^openjdk(?:@\d+(?:\.\d+)*)?/,It=new TextEncoder,Rc=[...Uo.map(([n])=>n),Gn].map(n=>({placeholder:n,bytes:It.encode(n)}));function Wo(n){let e=xc(n),t=e.changed_files;if(t!=null&&!Array.isArray(t))throw new Error("INSTALL_RECEIPT.json changed_files must be an array or null when present");let r=Array.isArray(t)?t:[];if(r.length>Ko)throw new Error(`INSTALL_RECEIPT.json declares ${r.length} changed files, limit ${Ko}`);let i=[],s=new Set;for(let[o,a]of r.entries()){if(typeof a!="string")throw new Error(`INSTALL_RECEIPT.json changed_files[${o}] is not a string`);if(vc(a,"Homebrew changed file"),s.has(a))throw new Error(`INSTALL_RECEIPT.json repeats changed file ${a}`);s.add(a),i.push(a)}return{changedFiles:i,runtimeDependencies:e.runtime_dependencies}}function xc(n){let e;try{e=JSON.parse(new TextDecoder("utf-8",{fatal:!0}).decode(n))}catch(t){throw new Error("INSTALL_RECEIPT.json is not valid UTF-8 JSON: "+zc(t))}if(typeof e!="object"||e===null||Array.isArray(e))throw new Error("INSTALL_RECEIPT.json must contain an object");return e}function Go(n,e,t){let r=n;for(let[o,a]of Uo)r=$o(r,It.encode(o),It.encode(a));let i=It.encode(Gn);if(Bo(r,i)){let o=Tc(e.runtimeDependencies);if(o===void 0)throw new Error(`Homebrew changed file ${t} uses ${Gn} without exactly one OpenJDK runtime dependency`);r=$o(r,i,It.encode(o))}let s=Rc.find(({bytes:o})=>Bo(r,o));if(s!==void 0)throw new Error(`Homebrew changed file ${t} retains ${s.placeholder}`);return r}function Tc(n){if(!Array.isArray(n))return;let e=[];for(let r of n){if(typeof r!="object"||r===null||Array.isArray(r))continue;let i=r,s=typeof i.full_name=="string"?i.full_name.split("/").at(-1):typeof i.name=="string"?i.name.split("/").at(-1):void 0,o=s===void 0?null:Ic.exec(s);s!==void 0&&o?.[0]===s&&e.push(s)}let t=[...new Set(e)];return t.length===1?`${At}/opt/${t[0]}/libexec`:void 0}function vc(n,e){if(n.length===0||n.startsWith("/")||n.includes("\\")||n.includes("\0")||Lc(n)||It.encode(n).byteLength>Ac||n.split("/").some(t=>t===""||t==="."||t===".."))throw new Error(`${e} has an unsafe path segment: ${n}`)}function Lc(n){for(let e=0;e57343)){if(t<=56319&&e+1=56320&&n.charCodeAt(e+1)<=57343){e+=1;continue}return!0}}return!1}function Bo(n,e){if(e.byteLength===0||e.byteLength>n.byteLength)return!1;e:for(let t=0;t<=n.byteLength-e.byteLength;t+=1){for(let r=0;rrn||n.includes("\0")||n.includes("\\"))throw new Error(`Lazy archive mount prefix must be an absolute POSIX path: ${JSON.stringify(n)}`);let e=n.replace(/\/+$/,"");if(e==="")return"/";if(e.slice(1).split("/").some(r=>r===""||r==="."||r===".."))throw new Error(`Lazy archive mount prefix is not canonical: ${JSON.stringify(n)}`);return e}function zu(n,e,t,r){let i=_r(t),s=new Map,o=e.map(a=>{let c=a.fileName,u=`Lazy archive ${JSON.stringify(n)} member ${JSON.stringify(c)}`;if(c.length===0)throw new Error(`${u} has an empty path`);if(c.includes("\0"))throw new Error(`${u} contains a NUL byte`);if(c.includes("\\"))throw new Error(`${u} contains a backslash`);if(c.startsWith("/")||/^[A-Za-z]:\//.test(c))throw new Error(`${u} must be relative, not absolute`);if(a.isDirectory&&a.isSymlink)throw new Error(`${u} has conflicting directory and symlink types`);if(a.isDirectory!==c.endsWith("/"))throw new Error(`${u} has inconsistent directory metadata`);let l=a.isDirectory?c.slice(0,-1):c,d=l.split("/");if(l.length===0||d.some(p=>p===""||p==="."||p===".."))throw new Error(`${u} is not a canonical relative POSIX path`);if(s.has(l))throw new Error(`${u} collides with another member at ${JSON.stringify(l)}`);if(a.isSymlink&&!r?.has(c))throw new Error(`Lazy archive symlink target was not provided: ${c}`);return s.set(l,a),{entry:a,archivePath:l,vfsPath:i==="/"?`/${l}`:`${i}/${l}`}});for(let{archivePath:a}of o){let c=a.split("/");for(let u=1;uLt)throw new Error(`VFS image metadata exceeds ${Lt} bytes`);let e;try{e=JSON.parse(new TextDecoder().decode(n))}catch(t){let r=t instanceof Error?t.message:String(t);throw new Error(`Invalid VFS image metadata JSON: ${r}`)}return yi(e)}function ku(n){if(n===null)return new Uint8Array(0);let e=yi(n),t=new TextEncoder().encode(JSON.stringify(e));if(t.byteLength>Lt)throw new Error(`VFS image metadata exceeds ${Lt} bytes`);return t}function Fu(n){return n.byteLength>=lr.length&&n[0]===lr[0]&&n[1]===lr[1]&&n[2]===lr[2]&&n[3]===lr[3]?td(n):n}function Vr(n){let e=Fu(n);if(e.byteLengthXr)throw new Error(`VFS image lazy metadata exceeds ${Xr} bytes`);if(n.byteLengthYr)throw new Error(`VFS image lazy archive metadata exceeds ${Yr} bytes`);if(n.byteLength=0?r:void 0}function Mu(n){return n===408||n===429||n>=500&&n<=599}function Du(n,e=Date.now()){let t=n?.get("retry-after")?.trim();if(!t)return;let r;if(/^\d+$/.test(t))r=Number(t)*1e3;else{let i=Date.parse(t);if(!Number.isFinite(i))return;r=Math.max(0,i-e)}if(!(!Number.isSafeInteger(r)||r<0))return Math.min(r,bs)}function Ku(n){if(!(typeof n!="object"||n===null||!("cause"in n)))return n.cause}function Ps(n){if(!(typeof n!="object"||n===null||!("name"in n)))return typeof n.name=="string"?n.name:void 0}function ks(n){if(!(typeof n!="object"||n===null||!("code"in n)))return typeof n.code=="string"?n.code:void 0}function Fs(n,e){let t=new Set,r=n;for(let i=0;r!==void 0&&i<8;i+=1){if(t.has(r))return!1;if(t.add(r),e(r))return!0;r=Ku(r)}return!1}function Ns(n){return Fs(n,e=>Ps(e)==="AbortError"||ks(e)==="ABORT_ERR")}function Bu(n){return Ns(n)?!1:Fs(n,e=>{let t=Ps(e),r=ks(e);return e instanceof TypeError||t==="NetworkError"||t==="TimeoutError"||r!==void 0&&Lu.has(r)})}function $u(n,e){if(n instanceof Jr){if(!Mu(n.status))return null;if(n.retryAfterMs!==void 0)return n.retryAfterMs}else if(!Bu(n))return null;return Math.min(vu*2**e,bs)}function te(n){if(n?.aborted)throw n.reason}function Uu(n,e){return te(e),n===0?Promise.resolve():new Promise((t,r)=>{let i=setTimeout(()=>a(!1),n),s=()=>a(!0,e.reason),o=!1;function a(c,u){o||(o=!0,clearTimeout(i),e?.removeEventListener("abort",s),c?r(u):t())}e?.addEventListener("abort",s,{once:!0}),e?.aborted&&s()})}async function ai(n,e){try{await n.body?.cancel(e)}catch{}}function Wu(n,e){if(n.length===1)return n[0];let t=new Uint8Array(e),r=0;for(let i of n)t.set(i,r),r+=i.byteLength;return t}function yr(n){if(n===void 0)return;if(typeof n!="object"||n===null||Array.isArray(n))throw new Error("Lazy archive integrity must be an object");let e=n;if(Object.keys(e).length!==2||!("sha256"in e)||!("bytes"in e))throw new Error("Lazy archive integrity has unexpected fields");if(typeof e.sha256!="string"||!li.test(e.sha256))throw new Error("Lazy archive integrity has an invalid SHA-256 digest");if(!Number.isSafeInteger(e.bytes)||Number(e.bytes)<=0||Number(e.bytes)>ys)throw new Error(`Lazy archive integrity byte count must be between 1 and ${ys}`);return{sha256:e.sha256,bytes:Number(e.bytes)}}function Ze(n,e,t){if(typeof n!="object"||n===null||Array.isArray(n))throw new Error(`${t} must be an object`);let r=n;if(Object.keys(r).length!==e.length||e.some(s=>!Object.prototype.hasOwnProperty.call(r,s)))throw new Error(`${t} has unexpected or missing fields`);return r}function hi(n,e,t,r){if(typeof n!="object"||n===null||Array.isArray(n))throw new Error(`${r} must be an object`);let i=n,s=new Set(e);if(Object.keys(i).some(o=>!s.has(o))||t.some(o=>!Object.prototype.hasOwnProperty.call(i,o)))throw new Error(`${r} has unexpected or missing fields`);return i}function Ne(n,e,t,r){if(!Array.isArray(n)||n.lengthr)throw new Error(`${e} must contain ${t} to ${r} items`);return n}function ye(n,e,t){if(typeof n!="string"||n.length===0||n.includes("\0")||new TextEncoder().encode(n).byteLength>t)throw new Error(`${e} is invalid or exceeds ${t} bytes`);return n}function ae(n,e,t,r){if(!Number.isSafeInteger(n)||Number(n)r)throw new Error(`${e} must be an integer between ${t} and ${r}`);return Number(n)}function Qr(n,e=1){let t=n,r=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.source!==void 0,i=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.modePolicy!==void 0,s=Ze(n,["decoder","mediaType","sha256","bytes","expandedBytes","sourceEntryCount","transports",...i?["modePolicy"]:[],...r?["source"]:[]],"Lazy tree content"),o=s.decoder==="zip-v1"?"application/zip":s.decoder==="homebrew-bottle-tar-gzip-v1"?"application/vnd.oci.image.layer.v1.tar+gzip":null;if(o===null||s.mediaType!==o)throw new Error("Lazy tree decoder and media type are inconsistent");let a=yr({sha256:s.sha256,bytes:s.bytes});if(!a)throw new Error("Lazy tree integrity is required");let c=Ne(s.transports,"Lazy tree transports",e,he.maxTransportsPerTree).map((m,f)=>ye(m,`Lazy tree transport ${f}`,_i));if(new Set(c).size!==c.length)throw new Error("Lazy tree transports contain duplicates");let u=ae(s.expandedBytes,"Lazy tree expanded byte count",0,Au),l=ae(s.sourceEntryCount,"Lazy tree source entry count",1,zt),d=r?Vu(s.source,s.decoder):void 0,p=i?s.modePolicy:void 0;if(p!==void 0&&(p!=="portable-posix-v1"||s.decoder!=="zip-v1"||r))throw new Error("Lazy tree mode policy is invalid for its decoder");if(d!==void 0&&d.entries.length!==l)throw new Error("Lazy tree source inventory count differs from its content");return{decoder:s.decoder,mediaType:o,sha256:a.sha256,bytes:a.bytes,expandedBytes:u,sourceEntryCount:l,transports:c,...p===void 0?{}:{modePolicy:p},...d===void 0?{}:{source:d}}}function Cs(n){let e={groups:n.length,archiveBytes:0,expandedBytes:0,payloadBytes:0,entries:0};for(let t of n)t.content===void 0||t.inventory===void 0||(e.archiveBytes+=t.content.bytes,e.expandedBytes+=t.content.expandedBytes,e.payloadBytes+=t.inventory.filter(r=>r.type==="file").reduce((r,i)=>r+i.size,0),e.entries+=t.inventory.length+(t.content.source?.entries.length??0));return e}function pi(n){Mo(n,"Serialized lazy tree collection")}function Gu(n){pi(Cs(n))}function Hu(n){let e=new Map;for(let t of n){let r=t.activation?.atomicGroup;if(r===void 0)continue;if(!vt(r))throw new Error(`Serialized lazy atomic activation group ${r.id} is unsealed`);let i=e.get(r.id);if(i===void 0)i={expectedCount:r.expectedCount,cohortSha256:r.cohortSha256,members:new Set,descriptors:new Set},e.set(r.id,i);else if(i.expectedCount!==r.expectedCount||i.cohortSha256!==r.cohortSha256)throw new Error(`Serialized lazy atomic activation group ${r.id} has inconsistent seals`);if(i.members.has(r.member)||i.descriptors.has(r.descriptorSha256))throw new Error(`Serialized lazy atomic activation group ${r.id} duplicates a member`);i.members.add(r.member),i.descriptors.add(r.descriptorSha256)}for(let[t,r]of e)if(r.members.size!==r.expectedCount)throw new Error(`Serialized lazy atomic activation group ${t} has ${r.members.size} of ${r.expectedCount} members`)}function As(n){for(let[e,t]of n.entries())if(t.kind===mr||t.kind===fi||t.kind===at)Bs(t,t.kind);else if(t.kind===pr)mi(t,!1);else throw new Error(`Serialized lazy archive group ${e} has an unsupported kind`);Gu(n),Hu(n)}function Vu(n,e){if(e!=="homebrew-bottle-tar-gzip-v1")throw new Error("Lazy tree source inventory is valid only for original bottles");let t=Ze(n,["schema","kind","entries"],"Lazy tree source inventory");if(t.schema!==1||t.kind!=="homebrew-bottle-tar-gzip-v1")throw new Error("Lazy tree source inventory has an unsupported identity");let r=new Map,i=Ne(t.entries,"Lazy tree source entries",1,zt).map((o,a)=>{let c=o,u=typeof c=="object"&&c!==null&&!Array.isArray(c)?c.type:void 0,l=u==="directory"||u==="file"?["sourcePath","type","mode","size"]:u==="symlink"||u==="hardlink"?["sourcePath","type","mode","size","target"]:null;if(l===null)throw new Error(`Lazy tree source entry ${a} has invalid type`);let d=Ze(o,l,`Lazy tree source entry ${a}`),p=ge(d.sourcePath,!1,`Lazy tree source entry ${a} path`);if(r.has(p))throw new Error(`Lazy tree source inventory duplicates ${p}`);let m=ae(d.mode,`Lazy tree source entry ${p} mode`,0,W.S_MODE_BITS),f=ae(d.size,`Lazy tree source entry ${p} size`,0,jr),h;if((u==="directory"||u==="symlink"||u==="hardlink")&&f!==0)throw new Error(`Lazy tree source ${p} has payload for ${String(u)}`);u==="symlink"?h=ye(d.target,`Lazy tree source symlink ${p} target`,zs):u==="hardlink"&&(h=ge(d.target,!1,`Lazy tree source hardlink ${p} target`));let g={sourcePath:p,type:u,mode:m,size:f,...h===void 0?{}:{target:h}};return r.set(p,g),g}),s=i.map(o=>o.sourcePath);if(s.some((o,a)=>a>0&&s[a-1]>=o))throw new Error("Lazy tree source inventory is not in canonical path order");return{schema:1,kind:"homebrew-bottle-tar-gzip-v1",entries:i}}function Ms(n){let e=new Map(n.map(r=>[r.sourcePath,r])),t=new Map;for(let r of n){if(r.type!=="hardlink"||t.has(r.sourcePath))continue;let i=[],s=new Set,o=r,a;for(;o.type==="hardlink"&&(a=t.get(o.sourcePath),a===void 0);){if(s.has(o.sourcePath))throw new Error(`Lazy tree source hardlink cycle includes ${o.sourcePath}`);s.add(o.sourcePath),i.push(o);let c=e.get(o.target);if(c===void 0)throw new Error(`Lazy tree source hardlink ${o.sourcePath} target is absent`);if(c.type!=="file"&&c.type!=="hardlink")throw new Error(`Lazy tree source hardlink ${o.sourcePath} target is not regular`);o=c}a===void 0&&(a=o);for(let c of i)t.set(c.sourcePath,a)}return t}function ge(n,e,t,r=!1){if(typeof n!="string"||n.length===0||new TextEncoder().encode(n).byteLength>rn||n.includes("\0")||n.includes("\\")||n.startsWith("/")!==e)throw new Error(`${t} is not a canonical ${e?"absolute":"relative"} path`);if(r&&e&&n==="/")return n;if(n.slice(e?1:0).split("/").some(s=>s===""||s==="."||s===".."))throw new Error(`${t} has an unsafe path segment`);return n}function Ds(n){let e=typeof n=="object"&&n!==null&&!Array.isArray(n)?n:null,t=e!==null&&(Object.hasOwn(e,"descriptorSha256")||Object.hasOwn(e,"expectedCount")||Object.hasOwn(e,"cohortSha256")),r=Ze(n,t?["id","member","descriptorSha256","expectedCount","cohortSha256"]:["id","member"],"Lazy tree atomic activation membership"),i=ye(r.id,"Lazy tree atomic activation group",gs),s=ye(r.member,"Lazy tree atomic activation member",gs);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(i)||!/^[a-z0-9][a-z0-9:+._/-]*$/.test(s)||s.includes("//")||s.endsWith("/"))throw new Error("Lazy tree atomic activation membership is invalid");if(!t)return{id:i,member:s};let o=ye(r.descriptorSha256,"Lazy tree atomic member descriptor digest",64),a=ye(r.cohortSha256,"Lazy tree atomic cohort digest",64);if(!li.test(o)||!li.test(a))throw new Error("Lazy tree atomic activation digest is invalid");return{id:i,member:s,descriptorSha256:o,expectedCount:ae(r.expectedCount,"Lazy tree atomic activation expected member count",1,Ls),cohortSha256:a}}function vt(n){return n.descriptorSha256!==void 0&&n.expectedCount!==void 0&&n.cohortSha256!==void 0}function qu(n){let e=Ze(n,["uid","gid"],"Lazy tree registration owner");return{uid:ae(e.uid,"Lazy tree registration owner uid",0,Es),gid:ae(e.gid,"Lazy tree registration owner gid",0,Es)}}function Ks(n,e,t,r,i=1){let s=Qr(n,i),o=_r(t),a=["mode","capabilities","roots",...typeof r=="object"&&r!==null&&!Array.isArray(r)&&Object.hasOwn(r,"atomicGroup")?["atomicGroup"]:[]],c=Ze(r,a,"Lazy tree activation");if(c.mode!=="boot-prefetch"&&c.mode!=="first-use")throw new Error("Lazy tree activation mode is invalid");let u=Ne(c.capabilities,"Lazy tree activation capabilities",1,xu).map((S,A)=>{let R=ye(S,`Lazy tree activation capability ${A}`,he.maxActivationCapabilityBytes);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(R))throw new Error(`Lazy tree activation capability ${A} is invalid`);return R}),l=Ne(c.roots,"Lazy tree activation roots",1,Tu).map((S,A)=>ge(S,!0,`Lazy tree activation root ${A}`,!0));if(new Set(u).size!==u.length||new Set(l).size!==l.length)throw new Error("Lazy tree activation contains duplicates");let d=c.atomicGroup===void 0?void 0:Ds(c.atomicGroup);if(d!==void 0&&c.mode!=="first-use")throw new Error("Lazy tree atomic activation group requires a valid first-use identity");let p={mode:c.mode,capabilities:u,roots:l,...d===void 0?{}:{atomicGroup:d}},m=Ne(e,"Lazy tree inventory",1,zt),f=[],h=new Map,g=new Map,_=s.source===void 0?void 0:new Map(s.source.entries.map(S=>[S.sourcePath,S])),y=s.source===void 0?void 0:Ms(s.source.entries),E=0;for(let[S,A]of m.entries()){if(typeof A!="object"||A===null||Array.isArray(A))throw new Error(`Lazy tree entry ${S} must be an object`);let R=A.type,x=R==="directory"?["vfsPath","sourcePath","type","mode","size"]:R==="file"?["vfsPath","sourcePath","type","mode","size","inodeGroup"]:R==="symlink"?["vfsPath","sourcePath","type","mode","size","target"]:R==="hardlink"?["vfsPath","sourcePath","type","mode","size","target","inodeGroup"]:null;if(!x)throw new Error(`Lazy tree entry ${S} has an invalid type`);let v=Ze(A,[...x,..._===void 0?[]:["materialization"]],`Lazy tree entry ${S}`),L=ge(v.vfsPath,!0,`Lazy tree entry ${S} VFS path`),D=ge(v.sourcePath,!1,`Lazy tree entry ${S} source path`),Z=_===void 0?void 0:v.materialization;if(_!==void 0&&Z!=="archive"&&Z!=="archive-homebrew-relocate"&&Z!=="archive-copy"&&Z!=="archive-copy-mode"&&Z!=="descriptor")throw new Error(`Lazy tree entry ${L} has invalid materialization provenance`);if(o!=="/"&&L!==o&&!L.startsWith(`${o}/`))throw new Error(`Lazy tree entry ${L} escapes its mount prefix`);if(h.has(L))throw new Error(`Lazy tree duplicates VFS path ${L}`);let N=ae(v.mode,`Lazy tree entry ${L} mode`,0,W.S_MODE_BITS),b=ae(v.size,`Lazy tree entry ${L} size`,0,jr),U,le;if(R==="directory"){if(b!==0)throw new Error(`Lazy tree directory ${L} has nonzero size`)}else if(R==="symlink"){if(U=ye(v.target,`Lazy tree symlink ${L} target`,zs),new TextEncoder().encode(U).byteLength!==b)throw new Error(`Lazy tree symlink ${L} size differs from its target`)}else le=ye(v.inodeGroup,`Lazy tree entry ${L} inode group`,rn),R==="hardlink"&&(U=ge(v.target,!0,`Lazy tree hardlink ${L} target`));if(R!=="hardlink"&&(E+=b,E>jr))throw new Error("Lazy tree inventory exceeds the expansion limit");let C={vfsPath:L,sourcePath:D,...Z===void 0?{}:{materialization:Z},type:R,mode:N,size:b,...U===void 0?{}:{target:U},...le===void 0?{}:{inodeGroup:le}};if(_===void 0){let V=g.get(D);if(V){if(s.decoder!=="zip-v1"||C.type!=="hardlink"||V.inodeGroup!==C.inodeGroup)throw new Error(`Lazy tree duplicates source path ${D}`)}else{if(s.decoder==="zip-v1"&&C.type==="hardlink")throw new Error(`Lazy ZIP hardlink ${L} does not reuse a canonical source path`);g.set(D,C)}}else if(C.materialization==="descriptor"){if(C.type!=="directory"&&C.type!=="symlink")throw new Error(`Lazy tree descriptor entry ${L} is not structural`);if(_.has(D))throw new Error(`Lazy tree descriptor entry ${L} impersonates a source member`)}else{let V=_.get(D);if(V===void 0)throw new Error(`Lazy tree entry ${L} names absent source ${D}`);if(C.materialization==="archive-copy"||C.materialization==="archive-copy-mode"){if(C.type!=="file"||V.type!=="file"||C.materialization==="archive-copy"&&C.mode!==V.mode)throw new Error(`Lazy tree archive copy ${L} differs from its source`)}else if(C.materialization==="archive-homebrew-relocate"){if(C.type!=="file"&&C.type!=="hardlink"||V.type!==C.type||C.type==="file"&&V.mode!==C.mode)throw new Error(`Lazy tree receipt-relocated entry ${L} differs from its source`)}else if(V.type!==C.type||C.type==="symlink"&&V.target!==C.target||C.type!=="hardlink"&&V.mode!==C.mode)throw new Error(`Lazy tree archive entry ${L} differs from its source`)}f.push(C),h.set(L,C)}for(let S of f){let A=S.vfsPath.split("/").filter(Boolean);for(let R=1;R({path:S.vfsPath,type:S.type,mode:S.mode,size:S.size,target:S.target,inodeGroup:S.inodeGroup})),"Lazy tree");if(_!==void 0){let S=new Set;for(let A of f){if(A.materialization!=="archive-homebrew-relocate")continue;let R=_.get(A.sourcePath),x=R.type==="file"?R:y.get(R.sourcePath);if(x?.type!=="file")throw new Error(`Lazy tree receipt-relocated entry ${A.vfsPath} is not regular`);S.add(x.sourcePath)}for(let A of f){if(A.materialization==="descriptor"||A.type!=="file"&&A.type!=="hardlink")continue;let R=_.get(A.sourcePath),x=R.type==="file"?R:y.get(R.sourcePath);if(x?.type!=="file"||!S.has(x.sourcePath)&&A.size!==x.size)throw new Error(`Lazy tree archive entry ${A.vfsPath} differs from its source`)}for(let A of f){if(A.type!=="hardlink"||A.materialization!=="archive"&&A.materialization!=="archive-homebrew-relocate")continue;let R=_.get(A.sourcePath),x=h.get(A.target),v=y.get(R.sourcePath);if(R.target!==x?.sourcePath||v?.type!=="file"||v.mode!==A.mode||x?.mode!==A.mode)throw new Error(`Lazy tree hardlink ${A.vfsPath} differs from its source`)}}if(s.sourceEntryCount!==(_===void 0?g.size:_.size))throw new Error("Lazy tree source entry count differs from its inventory");if(s.source===void 0&&s.expandedBytesA.vfsPath===S||A.vfsPath.startsWith(`${S}/`)))throw new Error(`Lazy tree activation root ${S} is not owned by its inventory`);let w=new Map;for(let S of f)S.type==="file"&&w.set(S.inodeGroup,S);if(w.size!==O.canonicalByGroup.size)throw new Error("Lazy tree regular inode inventory is inconsistent");return{content:s,entries:f,mountPrefix:o,activation:p,canonicalByGroup:w}}function en(n){return JSON.stringify([n.sourcePath,n.type,n.inodeGroup,n.target])}function mi(n,e){let t=hi(n,["kind","content","url","mountPrefix","integrity","materialized","entries"],["url","mountPrefix","materialized","entries"],"Serialized legacy lazy archive");if(t.kind===void 0){if(!e)throw new Error("Serialized lazy archive is missing its kind discriminator")}else if(t.kind!==pr)throw new Error("Serialized legacy lazy archive has an unsupported kind");let r=ye(t.url,"Serialized legacy lazy archive URL",_i),i=_r(t.mountPrefix),s=yr(t.integrity);if(t.content!==void 0){if(!e||t.kind!==void 0)throw new Error("Typed legacy lazy archives cannot carry generic content");let c=Qr(t.content);if(c.decoder!=="zip-v1"||c.transports.length!==1||c.transports[0]!==r||!s||c.sha256!==s.sha256||c.bytes!==s.bytes)throw new Error("Untagged legacy ZIP content identity is inconsistent")}if(t.materialized!==!1)throw new Error("Serialized legacy lazy archive must describe pending content");let o=new Set,a=Ne(t.entries,"Serialized legacy lazy archive entries",1,zt).map((c,u)=>{let l=hi(c,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","size","isSymlink","deleted"],`Serialized legacy lazy archive entry ${u}`),d=ge(l.vfsPath,!0,`Serialized legacy lazy archive entry ${u} VFS path`);if(o.has(d))throw new Error(`Serialized legacy lazy archive duplicates path ${d}`);o.add(d);let p=ae(l.ino,`Serialized legacy lazy archive entry ${d} inode`,1,Number.MAX_SAFE_INTEGER),m=l.generation===void 0?void 0:ae(l.generation,`Serialized legacy lazy archive entry ${d} generation`,0,Number.MAX_SAFE_INTEGER),f=l.dataSequence===void 0?void 0:ae(l.dataSequence,`Serialized legacy lazy archive entry ${d} data sequence`,0,Number.MAX_SAFE_INTEGER),h=ae(l.size,`Serialized legacy lazy archive entry ${d} size`,0,jr);if(l.isSymlink!==!1||l.deleted!==!1||l.materialized!==void 0&&l.materialized!==!1)throw new Error(`Serialized legacy lazy archive entry ${d} is not pending`);if(l.type!==void 0&&l.type!=="file")throw new Error(`Serialized legacy lazy archive entry ${d} has an invalid type`);let g=l.archivePath===void 0?void 0:ge(l.archivePath,!1,`Serialized legacy lazy archive entry ${d} archive path`),_=l.sourcePath===void 0?void 0:ge(l.sourcePath,!1,`Serialized legacy lazy archive entry ${d} source path`),y=l.inodeGroup===void 0?void 0:ye(l.inodeGroup,`Serialized legacy lazy archive entry ${d} inode group`,rn);if(l.target!==void 0)throw new Error(`Serialized legacy lazy archive entry ${d} has a link target`);return{vfsPath:d,ino:p,...m===void 0?{}:{generation:m},...f===void 0?{}:{dataSequence:f},size:h,isSymlink:!1,deleted:!1,materialized:!1,...g===void 0?{}:{archivePath:g},..._===void 0?{}:{sourcePath:_},type:"file",...y===void 0?{}:{inodeGroup:y}}});return{kind:pr,url:r,mountPrefix:i,...s===void 0?{}:{integrity:s},materialized:!1,entries:a}}function Bs(n,e){let t=Ze(n,["kind","content","inventory","activation","url","mountPrefix","integrity","materialized","entries"],"Serialized lazy tree");if(t.kind!==e)throw new Error("Serialized lazy tree has an unsupported kind");let r=Ks(t.content,t.inventory,t.mountPrefix,t.activation);if(e!==at&&e===mr!=(r.content.source===void 0))throw new Error(e===mr?"Serialized deferred-tree-v1 cannot contain original-bottle source metadata":"Serialized deferred-tree-v2 requires original-bottle source metadata");let i=r.activation.atomicGroup;if(e===at?i===void 0||!vt(i):i!==void 0)throw new Error(e===at?"Serialized deferred-tree-v3 requires a sealed atomic activation":"Atomic activation requires serialized deferred-tree-v3");let s=ye(t.url,"Serialized lazy tree URL",_i);if(s!==r.content.transports[0])throw new Error("Serialized lazy tree URL differs from its primary transport");let o=yr(t.integrity);if(!o||o.sha256!==r.content.sha256||o.bytes!==r.content.bytes)throw new Error("Serialized lazy tree integrity differs from its content");if(t.materialized!==!1)throw new Error("Serialized lazy tree must describe pending content");let a=new Map(r.entries.map(p=>[p.vfsPath,p])),c=new Map(r.entries.map(p=>[en(p),p])),u=Ne(t.entries,"Serialized lazy tree entries",0,zt),l=new Set,d=u.map((p,m)=>{let f=hi(p,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup"],`Serialized lazy tree entry ${m}`),h=ge(f.vfsPath,!0,`Serialized lazy tree entry ${m} VFS path`);if(l.has(h))throw new Error(`Serialized lazy tree duplicates pending path ${h}`);l.add(h);let g=ge(f.sourcePath,!1,`Serialized lazy tree entry ${m} source path`),_=ge(f.archivePath,!1,`Serialized lazy tree entry ${m} archive path`),y=a.get(h),E=c.get(en({sourcePath:g,type:typeof f.type=="string"?f.type:void 0,inodeGroup:typeof f.inodeGroup=="string"?f.inodeGroup:void 0,target:typeof f.target=="string"?f.target:void 0}))??y;if(!E||E.type!=="file"&&E.type!=="hardlink"||y?.inodeGroup!==void 0&&y.inodeGroup!==E.inodeGroup)throw new Error(`Serialized lazy tree entry ${h} is absent from its inventory`);let O=r.canonicalByGroup.get(E.inodeGroup);if(f.type!==E.type||f.inodeGroup!==E.inodeGroup||f.size!==E.size||_!==O?.sourcePath||f.target!==E.target||f.isSymlink!==!1||f.deleted!==!1||f.materialized!==!1)throw new Error(`Serialized lazy tree entry ${h} disagrees with its inventory`);let w=ae(f.ino,`Serialized lazy tree entry ${h} inode`,1,Number.MAX_SAFE_INTEGER),S=ae(f.generation,`Serialized lazy tree entry ${h} generation`,0,Number.MAX_SAFE_INTEGER),A=ae(f.dataSequence,`Serialized lazy tree entry ${h} data sequence`,0,Number.MAX_SAFE_INTEGER);return{vfsPath:h,ino:w,generation:S,dataSequence:A,size:E.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:_,sourcePath:g,type:E.type,inodeGroup:E.inodeGroup,...E.target===void 0?{}:{target:E.target}}});for(let p of r.entries)if(r.activation.atomicGroup!==void 0&&(p.type==="file"||p.type==="hardlink")&&!l.has(p.vfsPath))throw new Error(`Serialized lazy tree omits pending path ${p.vfsPath}`);return{kind:e,content:r.content,inventory:r.entries,activation:r.activation,url:s,mountPrefix:r.mountPrefix,integrity:o,materialized:!1,entries:d}}async function hr(n,e){let t=globalThis.crypto?.subtle;if(!t)throw new Error(`${e} SHA-256 verification is unavailable`);let r=new Uint8Array(n.byteLength);r.set(n);let i=new Uint8Array(await t.digest("SHA-256",r));return Array.from(i,s=>s.toString(16).padStart(2,"0")).join("")}async function ci(n,e,t){if(t===void 0)return;if(n.byteLength!==t.bytes)throw new Error(`Lazy ${e} byte count ${n.byteLength} does not match expected ${t.bytes}`);let r=await hr(n,`Lazy ${e}`);if(r!==t.sha256)throw new Error(`Lazy ${e} SHA-256 ${r} does not match expected ${t.sha256}`)}function Zu(n,e,t,r){let i=r.atomicGroup;if(i===void 0)throw new Error("Lazy atomic member is missing its typed tree descriptor");let s={schema:1,content:{decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...n.source===void 0?{}:{source:n.source}},mountPrefix:t,inventory:[...e].sort((o,a)=>o.vfsPatha.vfsPath?1:0),activation:{mode:r.mode,capabilities:r.capabilities,roots:r.roots,atomicGroup:{id:i.id,member:i.member}}};return new TextEncoder().encode(JSON.stringify(s))}function Is(n,e){let t=e.map(({member:r,descriptorSha256:i})=>({member:r,descriptorSha256:i}));return new TextEncoder().encode(JSON.stringify({schema:1,id:n,members:t.sort((r,i)=>r.memberi.member?1:0)}))}function Xu(n,e){if(n.byteLength!==e.byteLength)return!1;for(let t=0;tObject.freeze({...i}))};return r!==void 0&&(Object.freeze(r.entries),Object.freeze(r)),Object.freeze({decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,transports:t,...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...r===void 0?{}:{source:r}})}function Rs(n){return{decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,transports:[...n.transports],...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...n.source===void 0?{}:{source:{schema:1,kind:"homebrew-bottle-tar-gzip-v1",entries:n.source.entries.map(e=>({...e}))}}}}function Yu(n){let e=n.map(t=>Object.freeze({...t}));return Object.freeze(e),e}function ju(n,e,t){let r=[...n.capabilities],i=[...n.roots];return Object.freeze(r),Object.freeze(i),Object.freeze({mode:n.mode,capabilities:r,roots:i,atomicGroup:Object.freeze({id:e,member:t})})}function Ju(n){return{mode:n.activation.mode,capabilities:[...n.activation.capabilities],roots:[...n.activation.roots],atomicGroup:{id:n.id,member:n.member,descriptorSha256:n.descriptorSha256,expectedCount:n.expectedCount,cohortSha256:n.cohortSha256}}}function Qu(n,e){return n.ino===e.ino&&n.generation===e.generation&&n.dataSequence===e.dataSequence&&n.size===e.size&&n.isSymlink===e.isSymlink&&n.deleted===e.deleted&&n.materialized===e.materialized&&n.archivePath===e.archivePath&&n.sourcePath===e.sourcePath&&n.type===e.type&&n.inodeGroup===e.inodeGroup&&n.target===e.target}function qr(n,e,t){let r=n.content,i=n.inventory,s=n.activation,o=n.integrity,a=n.entries,c=n.url,u=n.mountPrefix,l=n.materialized,d=s?.atomicGroup;if(r===void 0||i===void 0||s===void 0||d===void 0||s.mode!=="first-use"||d.id!==e||d.member!==t||l)throw new Error(`Lazy atomic activation member ${t} changed before snapshot`);if(o?.sha256!==r.sha256||o?.bytes!==r.bytes||c!==(r.transports[0]??""))throw new Error(`Lazy atomic activation member ${t} has inconsistent integrity`);let p=$s(r),m=Yu(i),f=ju(s,e,t),h=new Map;for(let O of m)O.type==="file"&&h.set(O.inodeGroup,O.sourcePath);let g=m.filter(O=>O.type!=="directory");if(a.size!==g.length)throw new Error(`Lazy atomic activation member ${t} has inconsistent runtime entries`);let _=g.map(O=>{let w=a.get(O.vfsPath),S=O.type==="symlink",A=S?O.sourcePath:h.get(O.inodeGroup),R=w!==void 0&&(w.sourcePath===O.sourcePath&&w.type===O.type&&w.target===O.target||O.type==="hardlink"&&w.sourcePath===A&&w.type==="file"&&w.target===void 0),x=w===void 0?["missing"]:[A===void 0?"archivePath source":void 0,w.generation===void 0?"generation":void 0,w.dataSequence===void 0?"dataSequence":void 0,w.size!==O.size?"size":void 0,w.isSymlink!==S?"symlink kind":void 0,w.deleted?"deletion state":void 0,w.materialized!==S?"materialization state":void 0,w.archivePath!==A?"archivePath":void 0,R?void 0:"descriptor mapping",w.inodeGroup!==O.inodeGroup?"inode group":void 0].filter(L=>L!==void 0);if(x.length>0)throw new Error(`Lazy atomic activation member ${t} has inconsistent mapping at ${O.vfsPath}: ${x.join(", ")}`);let v=w;return Object.freeze({vfsPath:O.vfsPath,ino:v.ino,generation:v.generation,dataSequence:v.dataSequence,size:v.size,isSymlink:v.isSymlink,deleted:!1,materialized:v.materialized,archivePath:A,sourcePath:O.sourcePath,type:O.type,...O.inodeGroup===void 0?{}:{inodeGroup:O.inodeGroup},...O.target===void 0?{}:{target:O.target}})});Object.freeze(_);let y=Object.freeze({sha256:p.sha256,bytes:p.bytes}),E=Zu(p,m,u,f);return Object.freeze({id:e,member:t,descriptorBytes:E,content:p,inventory:m,activation:f,url:p.transports[0]??"",mountPrefix:u,integrity:y,entries:_})}function xs(n,e,t,r){return Object.freeze({...n,descriptorSha256:e,expectedCount:t,cohortSha256:r})}function Ts(n,e){return n.id!==e.id||n.member!==e.member||n.url!==e.url||n.mountPrefix!==e.mountPrefix||n.integrity.sha256!==e.integrity.sha256||n.integrity.bytes!==e.integrity.bytes||n.content.transports.length!==e.content.transports.length||n.content.transports.some((t,r)=>t!==e.content.transports[r])||!Xu(n.descriptorBytes,e.descriptorBytes)||n.entries.length!==e.entries.length?!1:n.entries.every((t,r)=>{let i=e.entries[r];return i!==void 0&&t.vfsPath===i.vfsPath&&Qu(t,i)})}function ed(n,e){let t=$s(n.content,n.content.transports.map(e));return Object.freeze({...n,content:t,url:t.transports[0]??""})}function vs(n){return{paths:Array.from(n.paths),expectedIno:n.ino,expectedGeneration:n.generation,expectedDataSequence:n.dataSequence,data:n.content}}var tn=class n{fs;imageMetadata;lazyFiles=new Map;lazyArchiveGroups=[];deferredTreeMaterializationHandles=new WeakMap;lazyArchiveInodes=new Map;lazyAtomicGroups=new Map;lazyAtomicGroupByTree=new WeakMap;sealedLazyAtomicStates=new WeakMap;lazyDownloadListeners=new Set;lazyPreparations=new Map;lazyTransport={fetcher:(e,t)=>t===void 0?globalThis.fetch(e):globalThis.fetch(e,t)};constructor(e,t=null){this.fs=e,this.imageMetadata=t}static inodeKey(e,t){return`${e}:${t}`}static canAdoptLegacyLazyStub(e){return(e.mode&Fe)===fr&&e.size===0&&e.dataSequence<=1}reconcileLazyIdentityState(e){for(let[t,r]of this.lazyFiles){let i=e.get(t);if(!i||i.dataSequence!==r.dataSequence||i.paths.length===0){this.lazyFiles.delete(t);continue}r.paths=new Set(i.paths),r.paths.has(r.path)||(r.path=i.paths[0])}this.lazyArchiveInodes.clear();for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(!r?.committed)for(let c of i.snapshot.entries){if(c.isSymlink||c.materialized||c.generation===void 0)continue;let u=n.inodeKey(c.ino,c.generation),l=e.get(u);l!==void 0&&l.dataSequence===c.dataSequence&&l.paths.length>0&&this.lazyArchiveInodes.set(u,t)}continue}let s=t.content!==void 0&&t.inventory!==void 0&&!t.materialized,o=new Map;for(let c of t.entries.values()){if(c.deleted||c.materialized||c.generation===void 0)continue;let u=n.inodeKey(c.ino,c.generation);o.has(u)||o.set(u,c)}let a=new Map(Array.from(t.entries.entries()).filter(([,c])=>c.deleted||c.isSymlink&&!c.deleted));for(let[c,u]of o){let l=e.get(c);if(!(!l||l.dataSequence!==(u.dataSequence??0))){for(let d of l.paths)a.set(d,{...u,ino:l.ino,generation:l.generation,dataSequence:l.dataSequence,deleted:!1,materialized:!1});l.paths.length>0&&this.lazyArchiveInodes.set(c,t)}}t.entries=a,t.materialized=!Array.from(a.values()).some(c=>!c.isSymlink&&!c.materialized)&&!s&&(r===void 0||r.committed)}}validatePendingLazyTreeNamespaceState(e){let t=new Map;for(let r of e.values())for(let i of r.paths){if(t.has(i))throw new Error(`SharedFS namespace identity is ambiguous at ${i}`);t.set(i,r)}for(let r of this.lazyArchiveGroups){let i=this.lazyAtomicGroupByTree.get(r),o=this.sealedLazyAtomicStates.get(r)?.snapshot;if(i?.committed||o===void 0&&r.materialized||o===void 0&&(r.content===void 0||r.inventory===void 0))continue;let a=o?.inventory??r.inventory,c=o===void 0?r.entries:new Map(o.entries.map(m=>[m.vfsPath,m])),u=new Map,l=new Map,d=new Set;for(let m of c.values())m.deleted&&m.inodeGroup!==void 0&&d.add(m.inodeGroup);for(let m of a){if(m.type!=="file"&&m.type!=="hardlink")continue;u.set(m.inodeGroup,(u.get(m.inodeGroup)??0)+1);let f=l.get(m.inodeGroup)??[];f.push(m.vfsPath),l.set(m.inodeGroup,f)}let p=new Set([...d].filter(m=>l.get(m)?.every(f=>!t.has(f))));for(let m of a){let f=t.get(m.vfsPath);if(f===void 0){if(m.inodeGroup!==void 0&&p.has(m.inodeGroup))continue;throw new Error(`Lazy tree namespace entry ${m.vfsPath} is missing from the captured filesystem state`)}let h=m.type==="directory"?st:m.type==="symlink"?Hr:fr;if((f.mode&Fe)!==h||(f.mode&W.S_MODE_BITS)!==m.mode)throw new Error(`Lazy tree namespace entry ${m.vfsPath} disagrees with its captured type or mode`);if(m.type==="directory")continue;let g=c.get(m.vfsPath);if(g===void 0||g.ino!==f.ino||g.generation!==f.generation||g.dataSequence!==f.dataSequence)throw new Error(`Lazy tree namespace entry ${m.vfsPath} changed identity before serialization`);if(m.type==="symlink"){let _=new TextEncoder().encode(m.target).byteLength;if(f.linkCount!==1||f.size!==m.size||f.size!==_||f.symlinkTarget!==m.target)throw new Error(`Lazy tree symlink ${m.vfsPath} disagrees with its captured inventory`);continue}if(f.size!==0||f.linkCount!==u.get(m.inodeGroup))throw new Error(`Lazy tree stub ${m.vfsPath} has changed data or undeclared aliases`)}}}lazyFileForStat(e){let t=n.inodeKey(e.ino,e.generation),r=this.lazyFiles.get(t);if(r&&r.dataSequence!==e.dataSequence){this.lazyFiles.delete(t);return}return r}lazyArchiveEntriesForRead(e){let t=this.lazyAtomicGroupByTree.get(e),r=this.sealedLazyAtomicStates.get(e);return r!==void 0&&!t?.committed?r.snapshot.entries:Array.from(e.entries,([i,s])=>({vfsPath:i,...s}))}lazyArchiveForStat(e){let t=n.inodeKey(e.ino,e.generation),r=this.lazyArchiveInodes.get(t);if(!r)return;let i=this.lazyArchiveEntriesForRead(r).filter(o=>o.ino===e.ino&&o.generation===e.generation&&!o.deleted&&!o.materialized);if(i.some(o=>o.dataSequence===e.dataSequence))return r;if(this.lazyArchiveInodes.delete(t),this.sealedLazyAtomicStates.get(r)===void 0)for(let o of i)o.materialized=!0}lazyBackingForStat(e){let t=n.inodeKey(e.ino,e.generation),r=this.lazyFiles.get(t);if(r)return{token:r,path:r.path};let i=this.lazyArchiveInodes.get(t);if(!i)return null;let s=this.lazyArchiveEntriesForRead(i).find(a=>a.ino===e.ino&&a.generation===e.generation&&!a.deleted&&!a.materialized)?.vfsPath;if(s===void 0)return null;let o=this.lazyAtomicGroupByTree.get(i);return o===void 0?{token:i,path:s}:{token:o.token,path:s,atomicGroup:o}}lazyBackingForPath(e){let t=this.lazyArchiveGroups.find(r=>{let i=this.lazyAtomicGroupByTree.get(r),s=this.sealedLazyAtomicStates.get(r)?.snapshot,o=s===void 0?!r.materialized:!i?.committed,a=s?.content??r.content,c=s?.inventory??r.inventory,u=s?.activation??r.activation,l=s?.entries??Array.from(r.entries.values());return o&&a!==void 0&&c!==void 0&&u!==void 0&&l.every(d=>d.deleted||d.materialized||d.isSymlink)&&u.roots.some(d=>d==="/"||e===d||e.startsWith(`${d}/`))});if(t){let r=this.lazyAtomicGroupByTree.get(t);return{token:r?.token??t,path:e,directGroup:t,...r===void 0?{}:{atomicGroup:r}}}try{let r=this.fs.stat(e),i=this.lazyBackingForStat(r);return i?{...i,path:e}:null}catch{return null}}startLazyPreparation(e){let{path:t,token:r}=e,i={status:"pending",promise:Promise.resolve(!1)},s=e.atomicGroup?this.ensureAtomicLazyGroupMaterialized(e.atomicGroup).then(()=>!0):e.directGroup?this.ensureArchiveMaterialized(e.directGroup).then(()=>!0):this.materializePath(t);return i.promise=s.then(o=>(i.status="fulfilled",this.lazyPreparations.get(r)===i&&this.lazyPreparations.delete(r),o),o=>{throw i.status="rejected",i.error=o,o}),i.promise.catch(()=>{}),this.lazyPreparations.set(r,i),i}registerLazyAtomicGroupMembership(e,t=!1){let r=e.activation?.atomicGroup;if(r===void 0)return;let{id:i,member:s}=r;if(e.content===void 0||e.inventory===void 0||e.activation?.mode!=="first-use")throw new Error(`Lazy atomic activation group ${i} accepts only typed first-use trees`);let o=this.lazyAtomicGroups.get(i);if(o===void 0)o={id:i,token:Object.freeze({id:i}),groups:new Map,committed:!1},this.lazyAtomicGroups.set(i,o);else if(o.committed)throw new Error(`Lazy atomic activation group ${i} is already materialized`);if(o.groups.has(s))throw new Error(`Lazy atomic activation group ${i} duplicates member ${s}`);if(vt(r)){if(o.expectedCount!==void 0&&(o.expectedCount!==r.expectedCount||o.cohortSha256!==r.cohortSha256))throw new Error(`Lazy atomic activation group ${i} has inconsistent seals`);o.expectedCount=r.expectedCount,o.cohortSha256=r.cohortSha256;let a=qr(e,i,s);this.sealedLazyAtomicStates.set(e,{snapshot:xs(a,r.descriptorSha256,r.expectedCount,r.cohortSha256),verified:t})}else if(o.expectedCount!==void 0)throw new Error(`Lazy atomic activation group ${i} mixes sealed and unsealed members`);o.groups.set(s,e),this.lazyAtomicGroupByTree.set(e,o)}async sealLazyAtomicGroup(e,t){let r=t.map(u=>Ds({id:e,member:u}).member).sort();if(r.length===0||new Set(r).size!==r.length)throw new Error(`Lazy atomic activation group ${e} expected members are invalid`);let i=this.lazyAtomicGroups.get(e);if(i===void 0||i.committed)throw new Error(`Lazy atomic activation group ${e} is not pending`);let s=[...i.groups.keys()].sort();if(JSON.stringify(s)!==JSON.stringify(r))throw new Error(`Lazy atomic activation group ${e} members differ from its seal`);if(i.expectedCount!==void 0){if(i.expectedCount!==r.length||i.cohortSha256===void 0)throw new Error(`Lazy atomic activation group ${e} has an invalid existing seal`);await this.ensureLazyAtomicGroupSealValidated(i,r.map(u=>i.groups.get(u)),!0);return}let o=r.map(u=>qr(i.groups.get(u),e,u)),a=[];for(let u of o)a.push({member:u.member,descriptorSha256:await hr(u.descriptorBytes,`Lazy atomic member ${u.member}`),source:u});let c=await hr(Is(e,a),`Lazy atomic activation group ${e}`);for(let u of a){let l=i.groups.get(u.member),d=qr(l,e,u.member);if(!Ts(u.source,d))throw new Error(`Lazy atomic activation member ${u.member} changed while sealing`)}for(let u of a){let l=i.groups.get(u.member);l.activation.atomicGroup={id:e,member:u.member,descriptorSha256:u.descriptorSha256,expectedCount:a.length,cohortSha256:c},this.sealedLazyAtomicStates.set(l,{snapshot:xs(u.source,u.descriptorSha256,a.length,c),verified:!0})}i.expectedCount=a.length,i.cohortSha256=c}async verifyImportedLazyAtomicGroupSeals(){await this.validatePendingLazyAtomicGroupSeals(!0)}guardSynchronousLazyAccess(e){let t=this.lazyBackingForPath(e);if(!t)return;let r=this.lazyPreparations.get(t.token);if(r?.status==="fulfilled"){this.lazyPreparations.delete(t.token);let s=this.lazyBackingForPath(e);if(!s)return;r=this.lazyPreparations.get(s.token)??this.startLazyPreparation(s)}else if(r?.status==="rejected"){this.lazyPreparations.delete(t.token);let s=r.error instanceof Error?r.error.message:String(r.error),o=new Error(`EIO: lazy backing for ${e} failed: ${s}`);throw o.code="EIO",o.cause=r.error,o}else r||(r=this.startLazyPreparation(t));let i=new Error(`EAGAIN: lazy backing for ${e} is being prepared`);throw i.code="EAGAIN",i}invalidateLazyData(e){let t=n.inodeKey(e.ino,e.generation);this.lazyFiles.delete(t);let r=this.lazyArchiveInodes.get(t);if(r){this.lazyArchiveInodes.delete(t);for(let i of r.entries.values())i.ino===e.ino&&i.generation===e.generation&&(i.materialized=!0)}}rewriteLazyNamespacePaths(e,t,r){let i=t.length>1?t.replace(/\/+$/,""):t,s=r.length>1?r.replace(/\/+$/,""):r,o=`${i}/`,a=`${s}/`,c=n.inodeKey(e.ino,e.generation),u=(e.mode&Fe)===st,l=d=>d===i?s:u&&d.startsWith(o)?a+d.slice(o.length):d;for(let[d,p]of this.lazyFiles)!u&&d!==c||(p.paths=new Set(Array.from(p.paths,l)),p.path=l(p.path));for(let d of this.lazyArchiveGroups){let p=new Map;for(let[m,f]of d.entries){let h=f.generation===void 0?null:n.inodeKey(f.ino,f.generation);p.set(u||h===c?l(m):m,f)}d.entries=p,d.inventory&&(d.inventory=d.inventory.map(m=>({...m,vfsPath:l(m.vfsPath),...m.type==="hardlink"&&m.target!==void 0?{target:l(m.target)}:{}}))),d.activation&&(d.activation={...d.activation,roots:d.activation.roots.map(l)})}}get sharedBuffer(){return this.fs.buffer}static create(e,t){return new n(be.mkfs(e,t))}static fromExisting(e){return new n(be.mount(e))}rebaseToNewFileSystem(e){if(!Number.isSafeInteger(e)||e<=0)throw new Error(`Invalid MemoryFileSystem maxByteLength: ${e}`);let t=SharedArrayBuffer,{bytes:r,identities:i}=this.fs.snapshotState();this.reconcileLazyIdentityState(i);let s=this.serializeLazyEntries(),o=this.serializeValidatedLazyArchiveEntries(i),a=new t(r.byteLength);new Uint8Array(a).set(r);let c=new n(be.mount(a,{restoreImage:!0}),this.imageMetadata);c.importLazyEntries(s),c.importLazyArchiveEntriesInternal(o,!1,!0,"verified");let u=Math.min(e,Math.max(r.byteLength,Ou)),l=new t(u,{maxByteLength:e}),d=n.create(l,e);d.setImageMetadata(this.imageMetadata);let p=new Set(s.flatMap(f=>f.paths??[f.path])),m=new Set;for(let f of o)if(!f.materialized)for(let h of f.entries)!h.deleted&&!h.isSymlink&&m.add(h.vfsPath);return c.copyPathToFreshFileSystem("/",d,p,m,new Map),d.importLazyEntries(s.map(f=>{let h=d.fs.lstat(f.path);return{...f,ino:h.ino,generation:h.generation,dataSequence:h.dataSequence}})),d.importLazyArchiveEntriesInternal(o.map(f=>({...f,entries:f.entries.map(h=>{if(h.deleted)return{...h,ino:0,generation:void 0};let g=d.fs.lstat(h.vfsPath);return{...h,ino:g.ino,generation:g.generation,dataSequence:g.dataSequence}})})),!1,!0,"verified"),d}getImageMetadata(){return bu(this.imageMetadata)}setImageMetadata(e){this.imageMetadata=e===null?null:yi(e)}subscribeLazyDownloads(e){return this.lazyDownloadListeners.add(e),()=>this.lazyDownloadListeners.delete(e)}setLazyFetcher(e,t={}){this.lazyTransport={fetcher:e,...t.signal===void 0?{}:{signal:t.signal}}}emitLazyDownload(e){if(this.lazyDownloadListeners.size===0)return;let t={...e,t:Nu()};for(let r of this.lazyDownloadListeners)try{r(t)}catch{}}async fetchLazyBytes(e,t){let r=0,i=e.integrity?.bytes??e.fallbackTotalBytes,s={id:e.id,kind:e.kind,url:e.url,path:e.path,mountPrefix:e.mountPrefix};for(let o=0;oe.integrity.bytes)throw new Error(`Lazy ${e.kind} exceeded expected byte count ${e.integrity.bytes}`);this.emitLazyDownload({...s,status:"progress",loadedBytes:r,totalBytes:i})}}}catch(d){try{await c.cancel(d)}catch{}throw d}}finally{c.releaseLock()}let l=Wu(u,r);return te(t.signal),await ci(l,e.kind,e.integrity),te(t.signal),this.emitLazyDownload({...s,status:"complete",loadedBytes:r,totalBytes:i??r}),l}catch(a){if(t.signal?.aborted){let l=t.signal.reason,d=l instanceof Error?l.message:String(l);throw this.emitLazyDownload({...s,status:"error",loadedBytes:r,totalBytes:i,error:d}),l}let c=o+1({...y})),activation:d,entries:new Map},g=y=>{let E=y.split("/").filter(Boolean),O="";for(let w=0;wE.vfsPath.split("/").length-O.vfsPath.split("/").length))if(y.type==="directory"){g(y.vfsPath);try{this.fs.mkdir(y.vfsPath,y.mode),this.fs.chmod(y.vfsPath,y.mode)}catch{if((this.fs.lstat(y.vfsPath).mode&Fe)!==st)throw new Error(`Lazy tree directory collides at ${y.vfsPath}`)}}for(let y of u){if(y.type!=="symlink")continue;g(y.vfsPath),this.fs.symlink(y.target,y.vfsPath);let E=this.fs.lstat(y.vfsPath);h.entries.set(y.vfsPath,{ino:E.ino,generation:E.generation,dataSequence:E.dataSequence,size:y.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:y.sourcePath,sourcePath:y.sourcePath,type:"symlink",target:y.target})}let _=new Map;for(let y of u){if(y.type!=="file")continue;g(y.vfsPath);let E=this.fs.createLazyStub(y.vfsPath,y.mode);this.invalidateLazyData(E),_.set(y.inodeGroup,E);let O={ino:E.ino,generation:E.generation,dataSequence:E.dataSequence,size:y.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:y.sourcePath,sourcePath:y.sourcePath,type:"file",inodeGroup:y.inodeGroup};h.entries.set(y.vfsPath,O)}for(let y of u){if(y.type!=="hardlink")continue;let E=p.get(y.inodeGroup);g(y.vfsPath),this.fs.link(E.vfsPath,y.vfsPath);let O=this.fs.lstat(y.vfsPath),w=_.get(y.inodeGroup);if(O.ino!==w.ino||O.generation!==w.generation)throw new Error(`Lazy tree hardlink ${y.vfsPath} did not share its inode`);h.entries.set(y.vfsPath,{ino:O.ino,generation:O.generation,dataSequence:O.dataSequence,size:y.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:E.sourcePath,sourcePath:y.sourcePath,type:"hardlink",inodeGroup:y.inodeGroup,target:y.target})}if(m!==void 0)for(let y of u)this.lchown(y.vfsPath,m.uid,m.gid);for(let y of h.entries.values())y.isSymlink||y.generation===void 0||this.lazyArchiveInodes.set(n.inodeKey(y.ino,y.generation),h);return this.lazyArchiveGroups.push(h),this.registerLazyAtomicGroupMembership(h),h}registerLazyTreeWithMaterializationHandle(e,t,r="/",i,s){let o=this.registerLazyTreeInternal(e,t,r,i,!0,s),a=Object.freeze({[Eu]:!0});return this.deferredTreeMaterializationHandles.set(a,o),a}registerLazyArchiveFromEntries(e,t,r,i,s){let o=_r(r),a=zu(e,t,o,i);a.some(({entry:u})=>!u.isDirectory&&!u.isSymlink)&&this.assertCanRegisterPendingLazyArchiveGroup();let c={...s?{content:Qr({decoder:"zip-v1",mediaType:"application/zip",sha256:s.sha256,bytes:s.bytes,expandedBytes:a.reduce((u,l)=>u+l.entry.uncompressedSize,0),sourceEntryCount:a.length,transports:[e]})}:{},url:e,mountPrefix:o,integrity:yr(s),materialized:!1,entries:new Map};for(let{entry:u,vfsPath:l}of a){if(u.isDirectory)continue;let d=l.split("/").filter(Boolean),p="";for(let m=0;mu.deleted||u.materialized),this.lazyArchiveGroups.push(c),c}importLazyArchiveEntries(e){this.importLazyArchiveEntriesInternal(e,!1,!0,"reject")}async importVerifiedLazyArchiveEntries(e){let t=structuredClone(e),r=this.exportLazyArchiveEntries(),i=n.fromExisting(this.sharedBuffer);i.importLazyArchiveEntriesInternal([...r,...t],!1,!0,"pending"),await i.verifyImportedLazyAtomicGroupSeals(),this.importLazyArchiveEntriesInternal(t,!1,!0,"verified")}importLazyArchiveEntriesInternal(e,t,r,i){let s=Ne(e,"Serialized lazy archive groups",0,Ls).map((l,d)=>{if(typeof l!="object"||l===null||Array.isArray(l))throw new Error(`Serialized lazy archive group ${d} must be an object`);let p=l.kind;if(p===mr||p===fi||p===at)return Bs(l,p);if(p===pr)return mi(l,!1);if(p!==void 0)throw new Error(`Serialized lazy archive group ${d} has an unsupported kind`);if(r)throw new Error(`Serialized lazy archive group ${d} is missing its kind discriminator`);return mi(l,!0)}),o=this.fs.identityState();this.reconcileLazyIdentityState(o);let a=[...this.serializeValidatedLazyArchiveEntries(o),...s];As(a);let c=[],u=new Map;for(let l of s){let d=new Map,p=l.mountPrefix.replace(/\/+$/,""),m=l.content!==void 0&&l.inventory!==void 0&&l.activation!==void 0,f=m?new Map(l.inventory.map(w=>[w.vfsPath,w])):null,h=m?new Map(l.inventory.map(w=>[en(w),w])):null,g=new Map,_=new Map,y=new Map;for(let w of l.entries){let S=null,A=l.materialized||w.materialized===!0||w.isSymlink;if(!w.deleted&&!A){if((w.generation===void 0||w.dataSequence===void 0)&&!t)throw new Error("Live lazy-archive metadata requires inode generation and data sequence");try{S=this.fs.lstat(w.vfsPath)}catch{if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} is missing from the filesystem`);continue}if(S.ino!==w.ino){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} has a different inode`);continue}if(w.generation!==void 0&&S.generation!==w.generation){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} has a different generation`);continue}if(w.dataSequence===void 0){if(!n.canAdoptLegacyLazyStub(S)){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} is not pristine`);continue}}else if(S.dataSequence!==w.dataSequence){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} has a different data sequence`);continue}if(m){y.set(w.vfsPath,S);let x=f.get(w.vfsPath),v=h.get(en(w))??x;if(!v||(S.mode&Fe)!==fr||S.size!==0||(S.mode&W.S_MODE_BITS)!==v.mode||x?.inodeGroup!==void 0&&x.inodeGroup!==v.inodeGroup)throw new Error(`Serialized lazy tree stub ${w.vfsPath} disagrees with its inventory`);let L=n.inodeKey(S.ino,S.generation),D=w.inodeGroup,Z=g.get(D),N=_.get(L);if(Z!==void 0&&Z!==L||N!==void 0&&N!==D)throw new Error(`Serialized lazy tree inode group ${D} disagrees with the filesystem`);g.set(D,L),_.set(L,D)}}d.set(w.vfsPath,{ino:w.ino,generation:S?.generation??w.generation,dataSequence:S?.dataSequence??w.dataSequence,size:w.size,isSymlink:w.isSymlink,deleted:w.deleted,materialized:A,archivePath:w.archivePath??w.vfsPath.slice(p.length+1),sourcePath:w.sourcePath??w.archivePath??w.vfsPath.slice(p.length+1),type:w.type??(w.isSymlink?"symlink":"file"),inodeGroup:w.inodeGroup,target:w.target})}if(m){let w=new Map;for(let S of l.inventory){if(S.type==="file"||S.type==="hardlink"){w.set(S.inodeGroup,(w.get(S.inodeGroup)??0)+1);continue}let A;try{A=this.fs.lstat(S.vfsPath)}catch{throw new Error(`Serialized lazy tree namespace entry ${S.vfsPath} is missing from the filesystem`)}let R=S.type==="directory"?st:Hr;if((A.mode&Fe)!==R||(A.mode&W.S_MODE_BITS)!==S.mode||S.type==="symlink"&&(A.size!==new TextEncoder().encode(S.target).byteLength||this.fs.readlink(S.vfsPath)!==S.target))throw new Error(`Serialized lazy tree namespace entry ${S.vfsPath} disagrees with its inventory`);S.type==="symlink"&&d.set(S.vfsPath,{ino:A.ino,generation:A.generation,dataSequence:A.dataSequence,size:S.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:S.sourcePath,sourcePath:S.sourcePath,type:"symlink",target:S.target})}if(l.activation?.atomicGroup!==void 0)for(let S of l.inventory){if(S.type!=="file"&&S.type!=="hardlink")continue;if(y.get(S.vfsPath).linkCount!==w.get(S.inodeGroup))throw new Error(`Serialized lazy atomic tree inode group ${S.inodeGroup} has undeclared aliases`)}}let E=l.content===void 0?void 0:Qr(l.content),O={content:E,url:E?.transports[0]??l.url,mountPrefix:l.mountPrefix,integrity:E?{sha256:E.sha256,bytes:E.bytes}:yr(l.integrity),materialized:l.materialized||!(E&&l.inventory)&&Array.from(d.values()).every(w=>w.deleted||w.materialized),inventory:l.inventory?.map(w=>({...w})),activation:l.activation?{mode:l.activation.mode,capabilities:[...l.activation.capabilities],roots:[...l.activation.roots],...l.activation.atomicGroup===void 0?{}:{atomicGroup:{...l.activation.atomicGroup}}}:void 0,entries:d};if(c.push(O),!O.materialized){for(let[,w]of d)if(!w.deleted&&!w.materialized&&w.generation!==void 0){let S=n.inodeKey(w.ino,w.generation),A=u.get(S);if(A!==void 0&&A!==O)throw new Error(`Serialized lazy archive groups share pending inode ${S}`);if(this.lazyArchiveInodes.has(S))throw new Error(`Serialized lazy archive group collides with pending inode ${S}`);u.set(S,O)}}}for(let l of c){let d=l.activation?.atomicGroup;if(d!==void 0&&this.lazyAtomicGroups.get(d.id)?.committed)throw new Error(`Lazy atomic activation group ${d.id} is already materialized`)}if(i==="reject"&&c.some(l=>{let d=l.activation?.atomicGroup;return d!==void 0&&vt(d)}))throw new Error("Sealed lazy archive registrations require importVerifiedLazyArchiveEntries()");this.lazyArchiveGroups.push(...c);for(let l of c)this.registerLazyAtomicGroupMembership(l,i==="verified");for(let[l,d]of u)this.lazyArchiveInodes.set(l,d)}rewriteLazyArchiveUrls(e){for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0&&!r?.committed){this.assertLazyAtomicSnapshotMatchesPublic(t);let s=ed(i.snapshot,e);t.content=Rs(s.content),t.url=s.url,t.integrity={...s.integrity},i.snapshot=s;continue}t.content?(t.content={...t.content,transports:t.content.transports.map(e)},t.url=t.content.transports[0]):t.url=e(t.url)}}serializeLazyArchiveEntries(){let e=[];for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(r?.committed)continue;let c=i.snapshot;if(c.content.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");e.push({kind:at,content:Rs(c.content),inventory:c.inventory.map(u=>({...u})),activation:Ju(c),url:c.url,mountPrefix:c.mountPrefix,integrity:{...c.integrity},materialized:!1,entries:c.entries.filter(u=>!u.deleted&&!u.materialized).map(({vfsPath:u,...l})=>({vfsPath:u,...l}))});continue}let s=Array.from(t.entries,([c,u])=>({vfsPath:c,ino:u.ino,generation:u.generation,dataSequence:u.dataSequence,size:u.size,isSymlink:u.isSymlink,deleted:u.deleted,materialized:u.materialized,archivePath:u.archivePath,sourcePath:u.sourcePath,type:u.type,inodeGroup:u.inodeGroup,target:u.target})).filter(c=>!c.deleted&&!c.materialized);if(s.length===0&&!(t.content&&t.inventory&&!t.materialized))continue;let o=t.content!==void 0&&t.inventory!==void 0&&t.activation!==void 0;if(o&&t.content.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");let a=t.activation?.atomicGroup;if(a!==void 0&&!vt(a))throw new Error(`Lazy atomic activation group ${a.id} must be sealed before serialization`);e.push(o?{kind:a!==void 0?at:t.content.source===void 0?mr:fi,content:t.content,inventory:t.inventory,activation:t.activation,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:s}:{kind:pr,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:s})}return e}serializeValidatedLazyArchiveEntries(e){this.assertPendingLazyAtomicSnapshotsReadyForSerialization();let t=this.serializeLazyArchiveEntries();return As(t),this.validatePendingLazyTreeNamespaceState(e),t}exportLazyArchiveEntries(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),this.serializeValidatedLazyArchiveEntries(e)}pendingDeferredTreeUsage(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),Cs(this.serializeValidatedLazyArchiveEntries(e))}assertCanAppendDeferredTreeUsage(e){pi(e);let t=this.pendingDeferredTreeUsage();pi({groups:t.groups+e.groups,archiveBytes:t.archiveBytes+e.archiveBytes,expandedBytes:t.expandedBytes+e.expandedBytes,payloadBytes:t.payloadBytes+e.payloadBytes,entries:t.entries+e.entries})}assertCanRegisterPendingLazyArchiveGroup(){if(this.reconcileLazyIdentityState(this.fs.identityState()),this.lazyArchiveGroups.filter(t=>{let r=this.lazyAtomicGroupByTree.get(t);return this.sealedLazyAtomicStates.get(t)?.snapshot!==void 0?!r?.committed:!t.materialized&&(t.content!==void 0&&t.inventory!==void 0||Array.from(t.entries.values()).some(s=>!s.deleted&&!s.materialized))}).length>=xe.maxGroups)throw new Error(`Cannot register another lazy archive group: ${xe.maxGroups} pending groups already exist`)}async preparePath(e){let t=!1,r=Math.max(3,this.lazyArchiveGroups.length+1);for(let i=0;i!s.materialized&&s.activation?.mode==="boot-prefetch"),t=0,r,i=Array.from({length:Math.min(e.length,Iu)},async()=>{for(;r===void 0;){let s=t;if(t+=1,s>=e.length)return;try{await this.prepareLazyTreeGroup(e[s])}catch(o){r??=o}}});if(await Promise.all(i),r!==void 0)throw r;return e.length}async materializeRegisteredDeferredTree(e,t){let r=this.deferredTreeMaterializationHandles.get(e);if(r===void 0)throw new Error("Deferred-tree handle was not issued by this filesystem");let i=this.lazyAtomicGroupByTree.get(r);if(i!==void 0)throw new Error(`Deferred tree belongs to atomic activation group ${i.id}; materialize the complete group instead`);if(r.materialized)return!1;let s=this.lazyPreparations.get(r);if(s!==void 0)return s.promise;let o=new Uint8Array(t.byteLength);o.set(t);let a={status:"pending",promise:Promise.resolve(!1)};a.promise=Promise.resolve().then(async()=>(await ci(o,"tree",r.integrity),await this.materializeArchiveBytes(r,o),!0)).then(c=>(a.status="fulfilled",c),c=>{throw a.status="rejected",a.error=c,c}),a.promise.catch(()=>{}),this.lazyPreparations.set(r,a);try{return await a.promise}finally{this.lazyPreparations.get(r)===a&&this.lazyPreparations.delete(r)}}async prepareLazyTreeGroup(e){let t=this.lazyAtomicGroupByTree.get(e);if(t?.committed||t===void 0&&e.materialized)return!1;let r=this.sealedLazyAtomicStates.get(e)?.snapshot,i={token:t?.token??e,path:r?.activation.roots[0]??e.activation?.roots[0]??e.mountPrefix,directGroup:e,...t===void 0?{}:{atomicGroup:t}},s=this.lazyPreparations.get(i.token)??this.startLazyPreparation(i);try{return await s.promise}finally{this.lazyPreparations.get(i.token)===s&&this.lazyPreparations.delete(i.token)}}async ensureMaterialized(e){return this.preparePath(e)}async materializePath(e){if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0)return!1;let t;try{t=this.fs.stat(e)}catch{return!1}let r=n.inodeKey(t.ino,t.generation),i=this.lazyFiles.get(r);if(i){let o=this.lazyTransport,a=await this.fetchLazyBytes({id:`file:${t.ino}`,kind:"file",url:i.url,path:i.path,fallbackTotalBytes:i.size},o);for(let c=0;c<3;c++){if(this.lazyFiles.get(r)!==i)return!1;for(let u of new Set([e,...i.paths]))if(te(o.signal),this.fs.replaceIfIdentity(u,i.ino,i.generation,i.dataSequence,a))return i.path=u,this.lazyFiles.delete(r),!0;this.reconcileLazyIdentityState(this.fs.identityState())}throw new Error(`Lazy file kept changing names while materializing: ${e}`)}let s=this.lazyArchiveInodes.get(r);return s?(await this.ensureArchiveMaterialized(s,{path:e,ino:t.ino,generation:t.generation}),!this.lazyArchiveInodes.has(r)):!1}async decodeAndValidateLazyTree(e,t,r){let i=r?.content??e.content,s=r?.inventory??e.inventory;if(!i||!s)throw new Error("Lazy tree is missing its decoder or complete inventory");let o=new Map,a=new Map(s.map(p=>[p.vfsPath,p]));if(i.source!==void 0)for(let p of i.source.entries)o.set(p.sourcePath,p);else for(let p of s){if(p.type==="hardlink"){let f=a.get(p.target);if(!f)throw new Error(`Lazy tree hardlink target disappeared: ${p.target}`);if(p.sourcePath===f.sourcePath)continue}if(o.get(p.sourcePath))throw new Error(`Lazy tree inventory duplicates source member ${p.sourcePath}`);o.set(p.sourcePath,{sourcePath:p.sourcePath,type:p.type,mode:p.mode,size:p.size,...p.type==="symlink"?{target:p.target}:{},...p.type==="hardlink"?{target:a.get(p.target)?.sourcePath}:{}})}let c=new Map,u=0;if(i.decoder==="zip-v1"){let{parseZipCentralDirectory:p,extractZipEntryBounded:m}=await Promise.resolve().then(()=>(Jn(),jn)),f=p(t);if(f.length!==i.sourceEntryCount||f.length!==o.size)throw new Error("Lazy ZIP tree decoded inventory counts differ from its descriptor");for(let h of f){let g=h.isDirectory?h.fileName.replace(/\/$/,""):h.fileName;if(c.has(g))throw new Error(`Lazy ZIP tree duplicates source member ${g}`);let _=o.get(g);if(!_)throw new Error(`Lazy ZIP tree has undeclared source member ${g}`);if(u+=h.uncompressedSize,u>i.expandedBytes||h.uncompressedSize!==_.size)throw new Error(`Lazy ZIP tree member ${g} exceeds its inventory`);let y=h.isDirectory?"directory":h.isSymlink?"symlink":"file",E=i.modePolicy==="portable-posix-v1"?y==="directory"?493:y==="symlink"?511:(h.mode&73)!==0?493:420:h.mode&W.S_MODE_BITS;if(y!==_.type||E!==_.mode)throw new Error(`Lazy ZIP tree member ${g} differs from inventory`);if(h.isDirectory)c.set(g,{type:"directory",mode:E});else{let O=m(t,h,_.size);if(h.isSymlink){let w;try{w=new TextDecoder("utf-8",{fatal:!0}).decode(O)}catch{throw new Error(`Lazy ZIP tree symlink ${g} is not UTF-8`)}c.set(g,{type:"symlink",mode:E,target:w})}else c.set(g,{type:"file",mode:E,data:O})}}}else{let{parseTarGzip:p}=await Promise.resolve().then(()=>(ms(),ps)),m=p(t,{label:`Lazy tree ${i.sha256}`,limits:{maxCompressedBytes:i.bytes,maxUncompressedBytes:i.expandedBytes,maxEntries:i.sourceEntryCount}});u=new DataView(t.buffer,t.byteOffset,t.byteLength).getUint32(t.byteLength-4,!0);for(let f of m){if(c.has(f.path))throw new Error(`Lazy TAR tree duplicates source member ${f.path}`);f.type==="file"?c.set(f.path,{type:"file",mode:f.mode,data:f.data}):f.type==="directory"?c.set(f.path,{type:"directory",mode:f.mode}):c.set(f.path,{type:f.type,mode:f.mode,target:f.linkName})}}if(c.size!==i.sourceEntryCount||c.size!==o.size||u!==i.expandedBytes)throw new Error("Lazy tree decoded inventory counts differ from its descriptor");for(let[p,m]of o){let f=c.get(p);if(!f)throw new Error(`Lazy tree is missing source member ${p}`);let h=m.type;if(f.type!==h)throw new Error(`Lazy tree member ${p} is ${f.type}, expected ${h}`);if((f.mode&W.S_MODE_BITS)!==m.mode)throw new Error(`Lazy tree member ${p} mode differs from inventory`);if(h==="file"&&f.data?.byteLength!==m.size)throw new Error(`Lazy tree member ${p} size differs from inventory`);if(h==="symlink"&&f.target!==m.target)throw new Error(`Lazy tree symlink ${p} target differs from inventory`);if(h==="hardlink"&&f.target!==m.target)throw new Error(`Lazy tree hardlink ${p} target differs from inventory`)}let l=new Set(s.flatMap(p=>p.materialization==="archive-homebrew-relocate"?[p.sourcePath]:[]));if(i.source!==void 0){let p=new Map(i.source.entries.map(h=>[h.sourcePath,h])),m=Ms(i.source.entries),f=i.source.entries.filter(h=>h.sourcePath==="INSTALL_RECEIPT.json"||h.sourcePath.endsWith("/INSTALL_RECEIPT.json"));if(f.length>1)throw new Error(`Lazy Homebrew bottle has ${f.length} INSTALL_RECEIPT.json source members, expected at most one`);if(f.length===0){if(l.size>0)throw new Error("Lazy Homebrew bottle marks receipt relocation without INSTALL_RECEIPT.json")}else{let h=f[0],g=h.type==="file"?h:m.get(h.sourcePath),_=g===void 0?void 0:c.get(g.sourcePath);if(g?.type!=="file"||_?.type!=="file"||_.data===void 0)throw new Error("Lazy Homebrew bottle INSTALL_RECEIPT.json is not regular");let y=Wo(_.data),E=h.sourcePath.lastIndexOf("/"),O=E<0?"":h.sourcePath.slice(0,E),w=new Set(y.changedFiles.map(A=>O.length===0?A:`${O}/${A}`));if(l.size!==w.size||[...l].some(A=>!w.has(A)))throw new Error("Lazy Homebrew bottle relocation markers differ from INSTALL_RECEIPT.json");let S=new Set;for(let A of w){let R=p.get(A),x=R?.type==="file"?R:R===void 0?void 0:m.get(R.sourcePath),v=x===void 0?void 0:c.get(x.sourcePath);if(x?.type!=="file"||v?.type!=="file"||v.data===void 0)throw new Error(`Lazy Homebrew bottle changed source ${A} is not regular`);S.has(x.sourcePath)||(v.data=Go(v.data,y,A),S.add(x.sourcePath))}}}else if(l.size>0)throw new Error("Lazy tree receipt relocation requires original-bottle source truth");let d=new Map;for(let p of s){if(p.type!=="file"||p.materialization==="descriptor")continue;let m=c.get(p.sourcePath);if(m?.type!=="file"||!m.data)throw new Error(`Lazy tree has no file content for ${p.sourcePath}`);d.set(p.sourcePath,m.data)}return d}async ensureArchiveMaterialized(e,t){let r=this.lazyAtomicGroupByTree.get(e);if(r!==void 0){if(r.committed)return;let o=this.sealedLazyAtomicStates.get(e)?.snapshot,a=this.lazyPreparations.get(r.token)??this.startLazyPreparation({token:r.token,path:o?.activation.roots[0]??e.activation?.roots[0]??e.mountPrefix,atomicGroup:r});try{await a.promise}finally{this.lazyPreparations.get(r.token)===a&&this.lazyPreparations.delete(r.token)}return}if(e.materialized)return;let i=this.lazyTransport,s=await this.fetchLazyArchiveData(e,i);te(i.signal),await this.materializeArchiveBytes(e,s,t,i.signal)}async fetchLazyArchiveData(e,t,r){let i=r?.content??e.content,s=r?.inventory??e.inventory,o=i!==void 0&&s!==void 0,a=r?.mountPrefix??e.mountPrefix,c=r?.integrity??e.integrity,u=o?i.transports:[r?.url??e.url],l=[],d=null;for(let[p,m]of u.entries())try{d=await this.fetchLazyBytes({id:`archive:${a}:${i?.sha256??m}:${p}`,kind:o?"tree":"archive",url:m,mountPrefix:a,integrity:c},t);break}catch(f){if(te(t.signal),Ns(f))throw f;l.push(f instanceof Error?f.message:String(f))}if(te(t.signal),d===null)throw new Error(`All ${u.length} lazy ${o?"tree":"archive"} transports failed: ${l.join("; ")}`);return d}async materializeArchiveBytes(e,t,r,i){if(te(i),e.materialized)return;let s=await this.prepareLazyArchiveContents(e,t,i),o=r?n.inodeKey(r.ino,r.generation):null;for(let a=0;a<3;a++){let c=this.collectLazyArchiveReplacements(e,s,r);if(c.size>0&&(te(i),!this.fs.replaceManyIfIdentities(Array.from(c.values(),vs)))){if(this.reconcileLazyIdentityState(this.fs.identityState()),o&&!this.lazyArchiveInodes.has(o))return;continue}if(te(i),this.publishLazyArchiveReplacements(e,c),e.materialized||(this.reconcileLazyIdentityState(this.fs.identityState()),o&&!this.lazyArchiveInodes.has(o)))return}if(o&&this.lazyArchiveInodes.has(o))throw new Error(`Lazy archive member kept changing names while materializing: ${r?.path}`)}async prepareLazyArchiveContents(e,t,r,i){te(r);let s=i?.content??e.content,o=i?.inventory??e.inventory,c=s!==void 0&&o!==void 0?await this.decodeAndValidateLazyTree(e,t,i):null;te(r);let{parseZipCentralDirectory:u,extractZipEntry:l}=await Promise.resolve().then(()=>(Jn(),jn));te(r);let d=c?[]:u(t),p=new Map;for(let _ of d){if(p.has(_.fileName))throw new Error(`Lazy archive contains duplicate member: ${_.fileName}`);p.set(_.fileName,_)}let f=(i?.mountPrefix??e.mountPrefix).replace(/\/+$/,""),h=new Map,g=i===void 0?Array.from(e.entries):i.entries.map(_=>[_.vfsPath,_]);for(let[_,y]of g){if(y.deleted||y.materialized)continue;let E=y.archivePath??_.slice(f.length+1),O=c?void 0:p.get(E),w=c?.get(E);if(c){if(w===void 0||w.byteLength!==y.size)throw new Error(`Lazy tree member ${E} does not match its registered metadata`)}else if(O===void 0||O.isDirectory||O.isSymlink||O.uncompressedSize!==y.size)throw new Error(`Lazy archive member ${E} does not match its registered metadata`);if(y.generation===void 0)continue;let S=n.inodeKey(y.ino,y.generation),A=h.get(S);if(A&&A.archivePath!==E)throw new Error(`Lazy archive aliases for inode ${S} name different members`);if(!A){let R=w??l(t,O);if(R.byteLength!==y.size)throw new Error(`Lazy archive member ${E} extracted ${R.byteLength} bytes, expected ${y.size}`);h.set(S,{archivePath:E,content:R})}}return h}collectLazyArchiveReplacements(e,t,r,i){let s=new Map,o=i===void 0?Array.from(e.entries):i.entries.map(a=>[a.vfsPath,a]);for(let[a,c]of o){if(c.deleted||c.materialized||c.generation===void 0)continue;let u=n.inodeKey(c.ino,c.generation);if(this.lazyArchiveInodes.get(u)!==e)continue;let l=t.get(u);if(!l)throw new Error(`Lazy archive has no extracted content for inode ${u}`);let d=s.get(u);d||(d={ino:c.ino,generation:c.generation,dataSequence:c.dataSequence??0,paths:new Set,content:l.content},s.set(u,d)),d.paths.add(a),r&&r.ino===c.ino&&r.generation===c.generation&&d.paths.add(r.path)}return s}publishLazyArchiveReplacements(e,t){for(let[r,i]of t){this.lazyArchiveInodes.delete(r);for(let s of e.entries.values())s.ino===i.ino&&s.generation===i.generation&&(s.materialized=!0)}e.materialized=Array.from(e.entries.values()).every(r=>r.deleted||r.materialized)}collectAtomicTreeNamespace(e,t){let r=new Set,i=new Map,s=new Map,o=new Map(t.entries.map(c=>[c.vfsPath,c]));for(let c of t.inventory)(c.type==="file"||c.type==="hardlink")&&s.set(c.inodeGroup,(s.get(c.inodeGroup)??0)+1);let a=[];for(let c of t.inventory){let u;try{u=this.fs.lstat(c.vfsPath)}catch{throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`)}let l=c.type==="directory"?st:c.type==="symlink"?Hr:fr;if((u.mode&Fe)!==l||(u.mode&W.S_MODE_BITS)!==c.mode)throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`);if(c.type==="symlink"){let d=o.get(c.vfsPath);if(d===void 0||!d.isSymlink||d.deleted||d.ino!==u.ino||d.generation!==u.generation||d.dataSequence!==u.dataSequence||this.fs.readlink(c.vfsPath)!==c.target)throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`)}else if(c.type==="file"||c.type==="hardlink"){let d=o.get(c.vfsPath);if(d===void 0||d.deleted||d.materialized||d.isSymlink||d.generation===void 0||d.inodeGroup!==c.inodeGroup||d.ino!==u.ino||d.generation!==u.generation||d.dataSequence!==u.dataSequence||u.size!==0||u.linkCount!==s.get(c.inodeGroup))throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`);let p=n.inodeKey(d.ino,d.generation);if(this.lazyArchiveInodes.get(p)!==e)throw new Error(`Lazy atomic tree lost deferred ownership of ${c.vfsPath}`);let m=i.get(c.inodeGroup);if(m!==void 0&&m!==p)throw new Error(`Lazy atomic tree split hard links at ${c.vfsPath}`);i.set(c.inodeGroup,p),r.add(p)}a.push({path:c.vfsPath,expectedIno:u.ino,expectedGeneration:u.generation,expectedDataSequence:u.dataSequence,expectedMode:u.mode,expectedLinkCount:u.linkCount,expectedSize:u.size,expectedUid:u.uid,expectedGid:u.gid})}return{guards:a,pendingIdentities:r.size}}assertLazyAtomicSnapshotMatchesPublic(e){let t=this.sealedLazyAtomicStates.get(e),r=t?.snapshot,i=e.activation?.atomicGroup,s=r?.member??i?.member??"unknown",o;if(r!==void 0)try{o=qr(e,r.id,r.member)}catch{o=void 0}if(t===void 0||r===void 0||i===void 0||!vt(i)||i.id!==r.id||i.member!==r.member||i.descriptorSha256!==r.descriptorSha256||i.expectedCount!==r.expectedCount||i.cohortSha256!==r.cohortSha256||o===void 0||!Ts(r,o))throw new Error(`Lazy atomic activation member ${s} changed after sealing`);return t}assertPendingLazyAtomicSnapshotsReadyForSerialization(){for(let e of this.lazyArchiveGroups){if(!this.sealedLazyAtomicStates.has(e)||this.lazyAtomicGroupByTree.get(e)?.committed)continue;let r=this.assertLazyAtomicSnapshotMatchesPublic(e);if(!r.verified)throw new Error(`Lazy atomic activation group ${r.snapshot.id} has not been cryptographically verified after import`)}}async validatePendingLazyAtomicGroupSeals(e){for(let t of this.lazyAtomicGroups.values()){if(t.committed||t.expectedCount===void 0||t.cohortSha256===void 0)continue;let r=[...t.groups.entries()].sort(([i],[s])=>is?1:0).map(([,i])=>i);await this.ensureLazyAtomicGroupSealValidated(t,r,e)}}async ensureLazyAtomicGroupSealValidated(e,t,r){if(this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r))return;let i=e.sealValidationFlight;if(i===void 0&&(i=this.validateLazyAtomicGroupSealOnce(e,t),e.sealValidationFlight=i,i.then(()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)},()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)})),await i,!this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r))throw new Error(`Lazy atomic activation group ${e.id} seal verification did not authenticate every member`)}assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r){if(e.committed)return!0;if(e.expectedCount===void 0||e.cohortSha256===void 0||t.length!==e.expectedCount)throw new Error(`Lazy atomic activation group ${e.id} is not completely sealed`);let i=!0,s=[];for(let o of t){let a=this.assertLazyAtomicSnapshotMatchesPublic(o),c=a.snapshot;if(c.id!==e.id||c.expectedCount!==e.expectedCount||c.cohortSha256!==e.cohortSha256||e.groups.get(c.member)!==o)throw new Error(`Lazy atomic activation group ${e.id} has an inconsistent member`);i&&=a.verified,s.push(a)}if(i&&r)for(let o=0;ofh?1:0).map(([,f])=>f);if(t.length===0)throw new Error(`Lazy atomic activation group ${e.id} has no trees`);if(await this.ensureLazyAtomicGroupSealValidated(e,t,!0),e.committed)return;let r=t.map(f=>this.sealedLazyAtomicStates.get(f).snapshot),i=t.map((f,h)=>({group:f,...this.collectAtomicTreeNamespace(f,r[h])})),s=this.lazyTransport,o=new Array(t.length),a=0,c=!1,u,l=Array.from({length:Math.min(Ru,t.length)},async()=>{for(;!c;){let f=a++;if(f>=t.length)return;let h=t[f],g=r[f];try{let _=await this.fetchLazyArchiveData(h,s,g);te(s.signal),o[f]={group:h,snapshot:g,contents:await this.prepareLazyArchiveContents(h,_,s.signal,g)}}catch(_){c||(c=!0,u=_)}}});if(await Promise.all(l),c)throw o.fill(void 0),u;te(s.signal);for(let f of t)this.assertLazyAtomicSnapshotMatchesPublic(f);let d=[],p=[],m=[];for(let f=0;f{let a=this.lazyAtomicGroupByTree.get(o);return this.sealedLazyAtomicStates.get(o)?.snapshot===void 0?!o.materialized&&o.content!==void 0&&o.inventory!==void 0:!a?.committed});if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0&&r.length===0)return;let i=Array.from(this.lazyFiles.values(),o=>o.path);for(let o of i)await this.ensureMaterialized(o);let s=new Set(this.lazyArchiveInodes.values());for(let o of r)s.add(o);for(let o of s)await this.prepareLazyTreeGroup(o)}this.reconcileLazyIdentityState(this.fs.identityState());let e=this.lazyArchiveGroups.some(t=>{let r=this.lazyAtomicGroupByTree.get(t);return this.sealedLazyAtomicStates.get(t)?.snapshot===void 0?!t.materialized&&t.content!==void 0&&t.inventory!==void 0:!r?.committed});if(this.lazyFiles.size!==0||this.lazyArchiveInodes.size!==0||e)throw new Error("Cannot create a self-contained VFS image while lazy entries remain pending")}async saveImage(e){e?.materializeAll&&await this.materializeAllLazyEntries(),await this.validatePendingLazyAtomicGroupSeals(!1);let{bytes:t,identities:r}=this.fs.snapshotState({normalizeTimestampsMs:e?.normalizeTimestampsMs});this.reconcileLazyIdentityState(r);let i=this.serializeLazyEntries(),s=i.length>0,o=s?new TextEncoder().encode(JSON.stringify(i)):new Uint8Array(0);if(o.byteLength>Xr)throw new Error(`VFS image lazy metadata exceeds ${Xr} bytes`);let a=this.serializeValidatedLazyArchiveEntries(r),c=a.length>0,u=c?new TextEncoder().encode(JSON.stringify(a)):new Uint8Array(0);if(u.byteLength>Yr)throw new Error(`VFS image lazy archive metadata exceeds ${Yr} bytes`);let l=e?.metadata===void 0?this.imageMetadata:e.metadata,d=ku(l),p=d.byteLength>0,m=c?4+u.byteLength:0,f=p?4+d.byteLength:0,h=pe+t.byteLength+4+o.byteLength+m+f,g=new Uint8Array(h),_=new DataView(g.buffer);_.setUint32(0,ui,!0),_.setUint32(4,di,!0),_.setUint32(8,(s?ii:0)|(c?Zr:0)|(c?si:0)|(p?oi:0),!0),_.setUint32(12,t.byteLength,!0),g.set(t,pe);let y=pe+t.byteLength;if(_.setUint32(y,o.byteLength,!0),o.byteLength>0&&g.set(o,y+4),c){let E=y+4+o.byteLength;_.setUint32(E,u.byteLength,!0),g.set(u,E+4)}if(p){let E=y+4+o.byteLength+m;_.setUint32(E,d.byteLength,!0),g.set(d,E+4)}return g}static readImageMetadata(e){let t=Vr(e);if(!(t.flags&oi))return null;let{metadataOffset:r}=ws(t.image,t.view,t.flags,t.sabLen);if(t.image.byteLengthLt)throw new Error(`VFS image metadata exceeds ${Lt} bytes`);if(t.image.byteLength0){let g=r.subarray(f+4,f+4+h),_=Ne(Os(g,"VFS image lazy metadata"),"VFS image lazy entries",0,zt);m.importLazyEntriesInternal(_,!0)}if(s&Zr){let g=a.archiveOffset,_=i.getUint32(g,!0);if(_>0){let y=r.subarray(g+4,g+4+_),E=Os(y,"VFS image lazy archive metadata");m.importLazyArchiveEntriesInternal(E,!0,!!(s&si),"pending")}}return m}adaptStat(e){return{dev:0,ino:e.ino,mode:e.mode,nlink:e.linkCount,uid:e.uid,gid:e.gid,size:e.size,atimeMs:e.atime,mtimeMs:e.mtime,ctimeMs:e.ctime}}adaptStatWithLazySize(e){let t=this.adaptStat(e),r=this.lazyFileForStat(e);if(r)return t.size=r.size,t;let i=this.lazyArchiveForStat(e);if(i){for(let s of this.lazyArchiveEntriesForRead(i))if(s.ino===e.ino&&s.generation===e.generation&&!s.deleted){t.size=s.size;break}}return t}open(e,t,r){(t&sr)===0&&!((t&ir)!==0&&(t&Wn)!==0)&&this.guardSynchronousLazyAccess(e);let i=this.fs.open(e,t,r);return(t&sr)!==0&&this.invalidateLazyData(this.fs.fstat(i)),i}close(e){return this.fs.close(e),0}read(e,t,r,i){if(i>0){let s=this.lazyBackingForStat(this.fs.fstat(e));s&&(this.reconcileLazyIdentityState(this.fs.identityState()),s=this.lazyBackingForStat(this.fs.fstat(e)),s&&this.guardSynchronousLazyAccess(s.path))}return r!==null?this.fs.readAt(e,t.subarray(0,i),typeof r=="bigint"?bn(r):r):this.fs.read(e,t.subarray(0,i))}write(e,t,r,i){if(r!==null){let o=this.fs.writeAt(e,t.subarray(0,i),typeof r=="bigint"?bn(r):r);return o>0&&this.invalidateLazyData(this.fs.fstat(e)),o}let s=this.fs.write(e,t.subarray(0,i));return s>0&&this.invalidateLazyData(this.fs.fstat(e)),s}append(e,t,r,i){let s=this.fs.append(e,t.subarray(0,r),Oo(i));return s.written>0&&this.invalidateLazyData(this.fs.fstat(e)),s}seek(e,t,r){return this.fs.lseek(e,typeof t=="bigint"?zn(t):t,r)}fstat(e){return this.adaptStatWithLazySize(this.fs.fstat(e))}fpathconf(e,t){let r=this.fstat(e);return kn(r,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}ftruncate(e,t){this.fs.ftruncate(e,t),this.invalidateLazyData(this.fs.fstat(e))}fsync(e){}fchmod(e,t){this.fs.fchmod(e,t)}fchown(e,t,r){this.fs.fchown(e,t,r)}stat(e){return this.adaptStatWithLazySize(this.fs.stat(e))}lstat(e){return this.adaptStatWithLazySize(this.fs.lstat(e))}statfs(e){this.fs.stat(e);let t=this.fs.statfs();return{type:1397114451,bsize:t.blockSize,blocks:t.totalBlocks,bfree:t.freeBlocks,bavail:t.freeBlocks,files:t.totalInodes,ffree:t.freeInodes,fsid:0,namelen:t.maxName,frsize:t.blockSize,flags:0}}pathconf(e,t){let r=this.stat(e);return kn(r,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}mkdir(e,t){this.fs.mkdir(e,t)}rmdir(e){this.fs.rmdir(e)}unlink(e){let t=this.fs.unlink(e),r=n.inodeKey(t.ino,t.generation);if(t.linkCount>1&&(this.lazyFiles.has(r)||this.lazyArchiveInodes.has(r))){this.reconcileLazyIdentityState(this.fs.identityState());return}let i=this.lazyFiles.get(r);i&&(i.paths.delete(e),t.linkCount<=1?this.lazyFiles.delete(r):i.path===e&&(i.path=i.paths.values().next().value));let s=this.lazyArchiveInodes.get(r);if(s){let o=s.entries.get(e);if(t.linkCount<=1){for(let a of s.entries.values())a.ino===t.ino&&a.generation===t.generation&&(a.deleted=!0);this.lazyArchiveInodes.delete(r)}else o&&s.entries.delete(e)}}rename(e,t){let{source:r,replaced:i}=this.fs.rename(e,t);if(i&&i.ino===r.ino&&i.generation===r.generation)return;let s=!1;if(i){let o=n.inodeKey(i.ino,i.generation);i.linkCount>1&&(this.lazyFiles.has(o)||this.lazyArchiveInodes.has(o))&&(this.reconcileLazyIdentityState(this.fs.identityState()),s=!0);let a=this.lazyFiles.get(o);!s&&a&&(a.paths.delete(t),i.linkCount<=1?this.lazyFiles.delete(o):a.path===t&&(a.path=a.paths.values().next().value));let c=this.lazyArchiveInodes.get(o);if(!s&&c){let u=c.entries.get(t);i.linkCount<=1?(u&&(u.deleted=!0),this.lazyArchiveInodes.delete(o)):u&&c.entries.delete(t)}}s||this.rewriteLazyNamespacePaths(r,e,t)}link(e,t){let r=this.fs.link(e,t),i=n.inodeKey(r.ino,r.generation),s=this.lazyFiles.get(i);s&&s.paths.add(t);let o=this.lazyArchiveInodes.get(i);if(o){let a=Array.from(o.entries.values()).find(c=>c.ino===r.ino&&c.generation===r.generation);a&&o.entries.set(t,{...a})}}symlink(e,t){this.fs.symlink(e,t)}readlink(e){return this.fs.readlink(e)}chmod(e,t){this.fs.chmod(e,t)}chown(e,t,r){this.fs.chown(e,t,r)}lchown(e,t,r){this.fs.lchown(e,t,r)}createFileWithOwner(e,t,r,i,s){let o=this.open(e,_s,t);s.length>0&&this.write(o,s,null,s.length),this.close(o),this.chown(e,r,i),this.chmod(e,t)}mkdirWithOwner(e,t,r,i){this.mkdir(e,t),this.chown(e,r,i),this.chmod(e,t)}symlinkWithOwner(e,t,r,i){this.symlink(e,t),this.lchown(t,r,i)}copyPathToFreshFileSystem(e,t,r,i,s){let o=this.lstat(e),a=o.mode&Fe,c=o.mode&W.S_MODE_BITS;if(a===st){e==="/"?(t.chown(e,o.uid,o.gid),t.chmod(e,c)):t.mkdirWithOwner(e,c,o.uid,o.gid);let p=this.opendir(e);try{for(;;){let m=this.readdir(p);if(!m)break;m.name==="."||m.name===".."||this.copyPathToFreshFileSystem(e==="/"?`/${m.name}`:`${e}/${m.name}`,t,r,i,s)}}finally{this.closedir(p)}n.applyTimes(t,e,o);return}let u=o.nlink>1?`${o.dev}:${o.ino}`:null,l=u?s.get(u):void 0;if(l){t.link(l,e);return}if(a===Hr){t.symlinkWithOwner(this.readlink(e),e,o.uid,o.gid),u&&s.set(u,e);return}if(a!==fr)throw new Error(`Unsupported file type while rebasing VFS: ${e}`);if(r.has(e)||i.has(e)){t.createFileWithOwner(e,c,o.uid,o.gid,new Uint8Array(0)),n.applyTimes(t,e,o),u&&s.set(u,e);return}this.copyRegularFileToFreshFileSystem(e,t,o,c),u&&s.set(u,e)}copyRegularFileToFreshFileSystem(e,t,r,i){let s=this.open(e,Su,0),o=null;try{o=t.open(e,_s,i);let a=new Uint8Array(Math.min(wu,Math.max(1,r.size))),c=r.size;for(;c>0;){let u=Math.min(a.byteLength,c),l=this.read(s,a,null,u);if(l<=0)throw new Error(`Unexpected EOF while rebasing VFS file: ${e}`);let d=0;for(;d!e||e==="."||e===".."))throw new Error(`Binary resolver path must be a normalized portable relative path: ${JSON.stringify(n)}`);return n}var dt=new Set(["wasm32","wasm64"]);function je(n){if(dd(n),!n.startsWith("programs/"))return n;let e=n.slice(9),t=e.split("/",1)[0];return dt.has(t)?n:`programs/wasm32/${e}`}function ld(n,e=$(Ii(),"wasm")){let t=je(n),r=[$(e,t)];return n==="kernel.wasm"?r.push($(e,"kandelo-kernel.wasm")):n==="userspace.wasm"?r.push($(e,"wasm_posix_userspace.wasm")):n==="rootfs.vfs"&&r.push($(e,"rootfs.vfs")),r}var sn=class extends Error{constructor(e){super(e),this.name="BinaryNotFoundError"}};function Qs(){let n=[],e=!1;try{let r=ut();e=!0;for(let[i,s]of[["local-binaries",$(r,"local-binaries")],["binaries",$(r,"binaries")]])n.push({label:i,root:s,identity:i==="local-binaries"?"local-generation":"program-cache",allowRegularFileClosure:!1,candidatesFor(o){return[$(s,je(o))]}})}catch{}let t=$(Ii(),"wasm");return n.push({label:"installed package",root:t,identity:"installed-package",allowRegularFileClosure:!e,candidatesFor(r){return ld(r,t)}}),n}function bt(n,e){return new Error(`Invalid package manifest ${n}: ${e}`)}function ce(n){try{return an(n),!0}catch(e){if(e instanceof Error&&"code"in e&&e.code==="ENOENT")return!1;throw e}}function Ws(n,e,t){if(n.length===0||n.startsWith("/")||n.includes("\\")||n.includes("\0")||n.split("/").some(r=>!r||r==="."||r===".."))throw bt(e,`${t} must be a normalized portable relative path`);return n}function nn(n,e,t,r=!0){if(n.length===0||n==="."||n===".."||n.includes("/")||n.includes("\\")||n.includes("\0")||!r&&n.includes("@"))throw bt(e,`${t} must be a safe single path component`);return n}var Gs="kandelo-program-packages-v2",Ce="program-packages.json",Hs=null,fd=null,on=null,Ei=0;function Ri(){return fd??$(Ii(),"wasm",Ce)}function ea(){if(Object.prototype.hasOwnProperty.call(process.env,"WASM_POSIX_DEPS_REGISTRY")){let t=null;return(process.env.WASM_POSIX_DEPS_REGISTRY??"").split(":").filter(Boolean).map(r=>r.startsWith("~/")&&process.env.HOME!==void 0?$(process.env.HOME,r.slice(2)):cn(r)?Ie(r):(t??=ut(),Ie(t,r)))}let n;try{n=$(ut(),"packages","registry")}catch{return null}let e=!1;if(ce(n)){if(!Ye(n).isDirectory())return[n];e=Ys(n,{withFileTypes:!0}).filter(t=>t.isDirectory()||t.isSymbolicLink()).some(t=>ce($(n,t.name,"package.toml")))}return!e&&ta()===null&&ce(Ri())?null:[n]}function ta(){let n;try{n=ut()}catch{return null}if(!gr($(n,"tools","xtask","Cargo.toml"))||!gr($(n,"scripts","dev-shell.sh")))return null;try{let e=Re(Ai()),t=Re(n);return[$(t,"host"),$(t,"scripts")].some(i=>gr(i)&&zi(Re(i),e))?t:null}catch{return null}}function xi(n,e,t){let r=[typeof t.stderr=="string"?t.stderr.trim():"",typeof t.stdout=="string"?t.stdout.trim():"",t.error?.message??""].filter(Boolean).join(` +var ua=Object.defineProperty;var Ir=(n,e,t)=>()=>{if(t)throw t[0];try{return n&&(e=n(n=0)),e}catch(r){throw t=[r],r}};var Fi=(n,e)=>{for(var t in e)ua(n,t,{get:e[t],enumerable:!0})};var Nt,Ni,Ct,Ci,dn,Mi,ne,Di,Ki,Rr,Bi,ln,$i,Ui,Wi,Gi,xr,Tr,fn,hn,pn,mn,_n,pt,Mt,Dt,Kt,ve,Hi,Vi,vr,Se,qi,Lr,yn,zr,br,mt,Bt,$e,gn,En,Zi,Pr,Xi,X,Yi,ji,kr,$t,Ji,Qi,eo,Y,to,ro,Fr,Ut,no,io,Sn,wn,et,_t,On,Wt,Gt,W,Ht,oo,so,q,tt=Ir(()=>{"use strict";Nt="kandelo.wpk_fork.linked_frames",Ni=[75,76,67,70],Ct=24,Ci=8,dn=3,Mi=[{bytes:4,chunkHeaderSize:32,nodeHeaderSize:24},{bytes:8,chunkHeaderSize:56,nodeHeaderSize:32}],ne="kandelo.wpk_fork.module_state",Di=1,Ki=[75,70,77,68],Rr=24,Bi=8,ln=7,$i=1,Ui=1,Wi=1,Gi=[{bytes:4,chunkHeaderSize:40},{bytes:8,chunkHeaderSize:56}],xr="__wpk_fork_global_",Tr="__wpk_fork_table_",fn=1,hn=2,pn=3,mn=4,_n=5,pt=6,Mt=7,Dt=8,Kt=9,ve="kandelo.wpk_fork.capabilities",Hi=1,Vi=7,vr=4,Se="kandelo.wpk_fork.exception_codec",qi=1,Lr=8,yn=16,zr="env",br="__wpk_fork_unwind",mt="kandelo.wpk_fork.unwind_transport",Bt="__wpk_fork_static_root_catalog",$e="kandelo.wpk_fork.static_root_catalog",gn=1,En=0,Zi=1,Pr=12,Xi=[75,70,83,82],X="kandelo.wpk_fork.imported_globals",Yi=[75,70,73,71],ji=1,kr=16,$t=24,Ji=1,Qi=2,eo=3,Y="kandelo.wpk_fork.imported_tables",to=[75,70,73,84],ro=1,Fr=16,Ut=24,no=1,io=1,Sn="env",wn="__wpk_fork_module_activation",et={module:"kernel",name:"kernel_fork",params:["i32"],results:["i32"]},_t=[{module:"env",name:"__wpk_fork_frame_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_frame_next",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_peek",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_reserve",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_record_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_module_state_record_find",params:["i32","i32","i32","i32"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_record_reserve",params:["i32","i32","i32","ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_table_dirty_count",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_module_state_table_dirty_mark",params:["i32","i64","i64"],results:[]},{module:"env",name:"__wpk_fork_module_state_table_dirty_page",params:["i32","i32"],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_mutation_abort",params:[],results:[]},{module:"env",name:"__wpk_fork_module_state_table_mutation_begin",params:[],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_mutation_commit",params:["i32","i64","i64"],results:[]},{module:"env",name:"__wpk_fork_module_state_table_reconcile",params:[],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_state_owned",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_decode_funcref",params:["i32"],results:["funcref"]},{module:"env",name:"__wpk_fork_ref_encode_funcref",params:["funcref"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_broker_encode",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_broker_throw_recipe",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_cache_index",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_claim",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_define",params:["i32","i32","i32","i32","ptr","i32","ptr","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_ingress_throw",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_load",params:["i32","i32","i32","i32","ptr","i32","ptr","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_lookup",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_route",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_broker_encode",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_capture_layout",params:["i32","i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_claim",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_define",params:["i32","i32","i32","i32","i32","ptr","i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_i31",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_load",params:["i32","i32","i32","i32","i32","ptr","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_lookup",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_payload_len",params:["i32","i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_provenance_begin",params:["i32","i32","i32","i32","i64","i64","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_provenance_end",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_provenance_ref",params:["i32","i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_route",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_scratch_release",params:["ptr","ptr"],results:[]},{module:"env",name:"__wpk_fork_ref_scratch_reserve",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_ref_vector_append",params:["i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_vector_begin",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_vector_finish",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_vector_get",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_resume_peek",params:["i32"],results:["i32"]}],On=[{module:"env",name:"__wpk_fork_ref_gc_transit",table64:!1,element:"anyref",minimum:1,maximum:null},{module:"env",name:"__wpk_fork_resume_table",table64:!1,element:"funcref",minimum:1,maximum:null}],Wt=[{name:"__wpk_fork_exception_materialize",params:["i32"],results:[]},{name:"__wpk_fork_ref_decode_exnref",params:["i32"],results:["exnref"]},{name:"__wpk_fork_ref_encode_exnref",params:["exnref"],results:["i32"]},{name:"__wpk_fork_ref_exn_abort",params:[],results:[]},{name:"__wpk_fork_ref_exn_clear",params:[],results:[]},{name:"__wpk_fork_ref_exn_encode_ingress",params:["i32"],results:["i32"]},{name:"__wpk_fork_ref_exn_throw_recipe",params:["i32"],results:[]},{name:"__wpk_fork_ref_exn_throw_slot",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_allocate",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_encode_slot",params:["i32"],results:["i32"]},{name:"__wpk_fork_ref_gc_fill",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_probe",params:["i32"],results:["i64"]},{name:"__wpk_fork_ref_gc_publish_externref",params:["i32","externref"],results:[]},{name:"__wpk_fork_static_root_harvest",params:[],results:[]},{name:"wpk_fork_abort_begin",params:["ptr"],results:[]},{name:"wpk_fork_abort_end",params:[],results:[]},{name:"wpk_fork_module_bootstrap",params:[],results:[]},{name:"wpk_fork_module_state_finish_restore",params:["i32"],results:[]},{name:"wpk_fork_module_state_restore",params:["i32"],results:[]},{name:"wpk_fork_module_state_save",params:["i32"],results:[]},{name:"wpk_fork_module_table_state_restore",params:["i32"],results:[]},{name:"wpk_fork_module_table_state_save",params:["i32"],results:[]},{name:"wpk_fork_module_thread_bootstrap",params:[],results:[]},{name:"wpk_fork_rewind_begin",params:["ptr"],results:[]},{name:"wpk_fork_rewind_end",params:[],results:[]},{name:"wpk_fork_state",params:[],results:["i32"]},{name:"wpk_fork_unwind_begin",params:["ptr"],results:[]},{name:"wpk_fork_unwind_end",params:[],results:[]}],Gt={O_RDONLY:0,O_WRONLY:1,O_RDWR:2,O_ACCMODE:3,O_CREAT:64,O_EXCL:128,O_NOCTTY:256,O_TRUNC:512,O_APPEND:1024,O_NONBLOCK:2048,O_ASYNC:8192,O_DIRECTORY:65536,O_NOFOLLOW:131072,O_CLOEXEC:524288,O_PATH:2097152,O_CLOFORK:8388608},W={S_IFMT:61440,S_IFSOCK:49152,S_IFLNK:40960,S_IFREG:32768,S_IFBLK:24576,S_IFDIR:16384,S_IFCHR:8192,S_IFIFO:4096,S_ISUID:2048,S_ISGID:1024,S_ISVTX:512,S_IRWXU:448,S_IRUSR:256,S_IWUSR:128,S_IXUSR:64,S_IRWXG:56,S_IRGRP:32,S_IWGRP:16,S_IXGRP:8,S_IRWXO:7,S_IROTH:4,S_IWOTH:2,S_IXOTH:1,S_MODE_BITS:4095},Ht={DT_UNKNOWN:0,DT_FIFO:1,DT_CHR:2,DT_DIR:4,DT_BLK:6,DT_REG:8,DT_LNK:10,DT_SOCK:12},oo=4096,so=["__abi_version","kernel_alloc_scratch","kernel_blocking_retry_release","kernel_blocking_retry_token","kernel_commit_process_exit","kernel_create_process","kernel_create_process_with_stdio","kernel_dequeue_signal","kernel_exec_prepare","kernel_exec_setup_for_thread","kernel_fork_process","kernel_get_cwd","kernel_get_dirfd_path","kernel_get_fd_path","kernel_get_parent_pid","kernel_get_process_exit_signal","kernel_get_process_state","kernel_get_socket_timeout_ms","kernel_handle_channel","kernel_has_sa_nocldstop","kernel_host_adapter_manifest_len","kernel_host_adapter_manifest_ptr","kernel_ipc_shm_lookup_mapping_for_task","kernel_ipc_shm_record_mapping_for_process","kernel_ipc_shm_record_mapping_for_task","kernel_ipc_shmat_for_process","kernel_ipc_shmat_for_task","kernel_ipc_shmdt_addr_for_process","kernel_ipc_shmdt_addr_for_task","kernel_ipc_shmdt_for_process","kernel_ipc_shmdt_for_task","kernel_is_fd_nonblock","kernel_mark_process_signaled","kernel_mq_descriptor_msgsize","kernel_msqid_ds_bytes","kernel_pcm_claim_transport","kernel_pcm_clock_update","kernel_pcm_reconcile","kernel_pcm_transport_len","kernel_pcm_transport_ptr","kernel_pick_signal_target_tid","kernel_pick_tcp_listener_target","kernel_pipe_has_readers","kernel_posix_timer_fire","kernel_process_metadata_begin","kernel_process_metadata_cancel","kernel_process_metadata_commit","kernel_process_metadata_stage","kernel_reap_exited_child","kernel_remove_process","kernel_semctl_array_bytes","kernel_semid_ds_bytes","kernel_set_current_tid","kernel_set_cwd","kernel_shmid_ds_bytes","kernel_spawn_process","kernel_spawn_reserved_process","kernel_spawn_scratch_begin","kernel_spawn_scratch_cancel","kernel_spawn_scratch_capacity","kernel_spawn_scratch_pointer","kernel_spawn_scratch_retained_capacity","kernel_take_process_timer_cleanup","kernel_thread_exit","kernel_thread_has_deliverable","kernel_transfer_channel_execute","kernel_transfer_io_execute","kernel_transfer_scratch_begin","kernel_transfer_scratch_cancel","kernel_transfer_scratch_capacity","kernel_transfer_scratch_pointer","kernel_validate_task","kernel_wait_child_poll"],q={LINK_MAX:0,MAX_CANON:1,MAX_INPUT:2,NAME_MAX:3,PATH_MAX:4,PIPE_BUF:5,CHOWN_RESTRICTED:6,NO_TRUNC:7,VDISABLE:8,SYNC_IO:9,ASYNC_IO:10,PRIO_IO:11,SOCK_MAXBUF:12,FILESIZEBITS:13,REC_INCR_XFER_SIZE:14,REC_MAX_XFER_SIZE:15,REC_MIN_XFER_SIZE:16,REC_XFER_ALIGN:17,ALLOC_SIZE_MIN:18,SYMLINK_MAX:19,POSIX2_SYMLINKS:20,FALLOC:21,TEXTDOMAIN_MAX:22,TIMESTAMP_RESOLUTION:23}});import{createRequire as Pc}from"module";function es(n,e){return Qo(n,{i:2},e&&e.out,e&&e.dictionary)}var kc,vt,Fc,Nc,se,xt,Cc,Vo,qo,Mc,Zo,vt,Xo,Dc,Yo,Kc,el,Yn,Pe,M,cr,ur,M,M,M,M,jo,M,Bc,$c,Zn,Ae,Xn,Jo,Hr,Uc,_e,Qo,Wc,Gc,Tt,ts,Hc,Vc,jn=Ir(()=>{kc=Pc("/");try{vt=kc("worker_threads"),Fc=vt.Worker,Nc=vt.isMarkedAsUntransferable}catch{}se=Uint8Array,xt=Uint16Array,Cc=Int32Array,Vo=new se([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),qo=new se([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),Mc=new se([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),Zo=function(n,e){for(var t=new xt(31),r=0;r<31;++r)t[r]=e+=1<>1|(M&21845)<<1,Pe=(Pe&52428)>>2|(Pe&13107)<<2,Pe=(Pe&61680)>>4|(Pe&3855)<<4,Yn[M]=((Pe&65280)>>8|(Pe&255)<<8)>>1;cr=(function(n,e,t){for(var r=n.length,i=0,s=new xt(e);i>a]=u}else for(c=new xt(r),i=0;i>15-n[i]);return c}),ur=new se(288);for(M=0;M<144;++M)ur[M]=8;for(M=144;M<256;++M)ur[M]=9;for(M=256;M<280;++M)ur[M]=7;for(M=280;M<288;++M)ur[M]=8;jo=new se(32);for(M=0;M<32;++M)jo[M]=5;Bc=cr(ur,9,1),$c=cr(jo,5,1),Zn=function(n){for(var e=n[0],t=1;te&&(e=n[t]);return e},Ae=function(n,e,t){var r=e/8|0;return(n[r]|n[r+1]<<8)>>(e&7)&t},Xn=function(n,e){var t=e/8|0;return(n[t]|n[t+1]<<8|n[t+2]<<16)>>(e&7)},Jo=function(n){return(n+7)/8|0},Hr=function(n,e,t){return(e==null||e<0)&&(e=0),(t==null||t>n.length)&&(t=n.length),new se(n.subarray(e,t))},Uc=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],_e=function(n,e,t){var r=new Error(e||Uc[n]);if(r.code=n,Error.captureStackTrace&&Error.captureStackTrace(r,_e),!t)throw r;return r},Qo=function(n,e,t,r){var i=n.length,s=r?r.length:0;if(!i||e.f&&!e.l)return t||new se(0);var o=!t,c=o||e.i!=2,a=e.i;o&&(t=new se(i*3));var u=function(Ke){var Be=t.length;if(Ke>Be){var Ar=new se(Math.max(Be*2,Ke));Ar.set(t),t=Ar}},l=e.f||0,d=e.p||0,p=e.b||0,m=e.l,f=e.d,h=e.m,g=e.n,_=i*8;do{if(!m){l=Ae(n,d,1);var y=Ae(n,d+1,3);if(d+=3,y)if(y==1)m=Bc,f=$c,h=9,g=5;else if(y==2){var S=Ae(n,d,31)+257,A=Ae(n,d+10,15)+4,R=S+Ae(n,d+5,31)+1;d+=14;for(var x=new se(R),v=new se(19),L=0;L>4;if(E<16)x[L++]=E;else{var U=0,le=0;for(E==16?(le=3+Ae(n,d,3),d+=2,U=x[L-1]):E==17?(le=3+Ae(n,d,7),d+=3):E==18&&(le=11+Ae(n,d,127),d+=7);le--;)x[L++]=U}}var C=x.subarray(0,S),V=x.subarray(S);h=Zn(C),g=Zn(V),m=cr(C,h,1),f=cr(V,g,1)}else _e(1);else{var E=Jo(d)+4,O=n[E-4]|n[E-3]<<8,w=E+O;if(w>i){a&&_e(0);break}c&&u(p+O),t.set(n.subarray(E,w),p),e.b=p+=O,e.p=d=w*8,e.f=l;continue}if(d>_){a&&_e(0);break}}c&&u(p+131072);for(var kt=(1<>4;if(d+=U&15,d>_){a&&_e(0);break}if(U||_e(2),Te<256)t[p++]=Te;else if(Te==256){Je=d,m=null;break}else{var Ft=Te-254;if(Te>264){var L=Te-257,Me=Vo[L];Ft=Ae(n,d,(1<>4;ft||_e(3),d+=ft&15;var V=Kc[Ee];if(Ee>3){var Me=qo[Ee];V+=Xn(n,d)&(1<_){a&&_e(0);break}c&&u(p+131072);var De=p+Ft;if(p>3&1)+(e>>4&1);r>0;r-=!n[t++]);return t+(e&2)},Tt=(function(){function n(e,t){typeof e=="function"&&(t=e,e={}),this.ondata=t;var r=e&&e.dictionary&&e.dictionary.subarray(-32768);this.s={i:0,b:r?r.length:0},this.o=new se(32768),this.p=new se(0),r&&this.o.set(r)}return n.prototype.e=function(e){if(this.ondata||_e(5),this.d&&_e(4),!this.p.length)this.p=e;else if(e.length){var t=new se(this.p.length+e.length);t.set(this.p),t.set(e,this.p.length),this.p=t}},n.prototype.c=function(e){this.s.i=+(this.d=e||!1);var t=this.s.b,r=Qo(this.p,this.s,this.o);this.ondata(Hr(r,t,this.s.b),this.d),this.o=Hr(r,this.s.b-32768),this.s.b=this.o.length,this.p=Hr(this.p,this.s.p/8|0),this.s.p&=7},n.prototype.push=function(e,t){this.e(e),this.c(t)},n})();ts=(function(){function n(e,t){this.v=1,this.r=0,Tt.call(this,e,t)}return n.prototype.push=function(e,t){if(Tt.prototype.e.call(this,e),this.r+=e.length,this.v){var r=this.p.subarray(this.v-1),i=r.length>3?Gc(r):4;if(i>r.length){if(!t)return}else this.v>1&&this.onmember&&this.onmember(this.r-r.length);this.p=r.subarray(i),this.v=0}Tt.prototype.c.call(this,0),this.s.f&&!this.s.l?(this.v=Jo(this.s.p)+9,this.s={i:0},this.o=new se(0),this.push(new se(0),t)):t&&Tt.prototype.c.call(this,t)},n})(),Hc=typeof TextDecoder<"u"&&new TextDecoder,Vc=0;try{Hc.decode(Wc,{stream:!0}),Vc=1}catch{}});var ei={};Fi(ei,{extractZipEntry:()=>eu,extractZipEntryBounded:()=>tu,fetchZipCentralDirectory:()=>nu,parseZipCentralDirectory:()=>dr});function as(n){let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=Math.max(0,n.length-is);for(let r=n.length-Xc;r>=t;r--)if(e.getUint32(r,!0)===qc)return r;throw new Error("Zip EOCD record not found")}function dr(n){let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=as(n),r=e.getUint16(t+10,!0),i=e.getUint32(t+16,!0),s=[],o=i;for(let c=0;c>8,O;E===rs?O=h>>16&65535:y.startsWith("bin/")||y.startsWith("sbin/")||y.includes("/bin/")||y.includes("/sbin/")?O=493:O=420;let w=y.endsWith("/"),S=E===rs&&(O&jc)===Yc;s.push({fileName:y,fileNameBytes:_,compressedSize:l,uncompressedSize:d,compressionMethod:u,localHeaderOffset:g,mode:O,isDirectory:w,isSymlink:S,externalAttrs:h,creatorOS:E}),o+=Jn+p+m+f}return s}function cs(n,e){if(n.byteLength!==e.byteLength)return!1;for(let t=0;t{if(c.byteLength>t-s)throw new Error(`ZIP member ${e.fileName} expands beyond ${t} bytes`);i.set(c,s),s+=c.byteLength}).push(r,!0),s!==t)throw new Error(`ZIP member ${e.fileName} expanded ${s} bytes, expected ${t}`);return i}function ru(n,e){let t=new DataView(n.buffer,n.byteOffset,n.byteLength),r=e.localHeaderOffset;if(r<0||r>n.byteLength-Qn||t.getUint32(r,!0)!==ns)throw new Error(`Invalid local file header signature at offset ${r}`);let i=t.getUint16(r+8,!0),s=t.getUint16(r+26,!0),o=t.getUint16(r+28,!0),c=r+Qn,a=c+s+o,u=a+e.compressedSize;if(i!==e.compressionMethod||an.byteLength||!cs(n.subarray(c,c+s),e.fileNameBytes))throw new Error(`ZIP member ${e.fileName} has inconsistent local metadata`);return n.subarray(a,u)}async function nu(n){let e=await fetch(n,{method:"HEAD"});if(!e.ok)throw new Error(`HEAD request failed: ${e.status} ${e.statusText}`);let t=parseInt(e.headers.get("content-length")||"0",10),r=e.headers.get("accept-ranges");if(!t||r!=="bytes"){let _=await fetch(n);if(!_.ok)throw new Error(`Fetch failed: ${_.status} ${_.statusText}`);let y=new Uint8Array(await _.arrayBuffer());return{entries:dr(y),totalSize:y.length}}let i=Math.min(t,is),s=t-i,o=await fetch(n,{headers:{Range:`bytes=${s}-${t-1}`}});if(o.status!==206){let _=await fetch(n);if(!_.ok)throw new Error(`Fetch failed: ${_.status} ${_.statusText}`);let y=new Uint8Array(await _.arrayBuffer());return{entries:dr(y),totalSize:y.length}}let c=new Uint8Array(await o.arrayBuffer()),a=new DataView(c.buffer,c.byteOffset,c.byteLength),u=as(c),l=a.getUint32(u+12,!0),d=a.getUint32(u+16,!0);if(d>=s){let _=t,y=new Uint8Array(_);return y.set(c,s),{entries:dr(y),totalSize:_}}let p=d+l-1,m=await fetch(n,{headers:{Range:`bytes=${d}-${p}`}});if(m.status!==206)throw new Error(`Range request for CD failed: ${m.status}`);let f=new Uint8Array(await m.arrayBuffer()),h=t,g=new Uint8Array(h);return g.set(f,d),g.set(c,s),{entries:dr(g),totalSize:h}}var qc,Zc,ns,is,Xc,Jn,Qn,os,ss,rs,Yc,jc,Jc,Qc,ti=Ir(()=>{"use strict";jn();tt();qc=101010256,Zc=33639248,ns=67324752,is=65557,Xc=22,Jn=46,Qn=30,os=0,ss=8,rs=3,{S_IFLNK:Yc,S_IFMT:jc}=W,Jc=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),Qc=new TextEncoder});var ms={};Fi(ms,{DEFAULT_TAR_GZIP_LIMITS:()=>ps,TarParseError:()=>z,parseTarGzip:()=>au});function au(n,e={}){let t=e.label??"TAR gzip archive",r=uu(e.limits,t);if(n.byteLength===0||n.byteLength>r.maxCompressedBytes)throw new z(`${t}: compressed byte count ${n.byteLength} is outside 1..${r.maxCompressedBytes}`);let i=du(n,t);if(i===0||i>r.maxUncompressedBytes)throw new z(`${t}: declared uncompressed byte count ${i} is outside 1..${r.maxUncompressedBytes}`);let s=lu(n,t,i);if(s.byteLength!==i)throw new z(`${t}: gzip expanded to ${s.byteLength} bytes, expected ${i}`);let o=new DataView(n.buffer,n.byteOffset,n.byteLength).getUint32(n.byteLength-8,!0);if(fu(s)!==o)throw new z(`${t}: gzip CRC32 mismatch`);return cu(s,t,r)}function cu(n,e,t){if(n.byteLength%ke!==0)throw new z(`${e}: TAR byte count is not block-aligned`);let r=[],i=0,s=0,o=0,c=null,a={},u=!1;for(;i+ke<=n.byteLength;){let l=n.subarray(i,i+ke);if(i+=ke,ni(l)){if(i+ke>n.byteLength)throw new z(`${e}: TAR end marker is truncated`);let w=n.subarray(i,i+ke);if(!ni(w))throw new z(`${e}: TAR has only one zero end block`);if(i+=ke,!ni(n.subarray(i)))throw new z(`${e}: TAR has nonzero data after its end marker`);u=!0;break}_u(l,e);let d=lr(l,156,1,e)||"0",p=oi(l,124,12,`${e}: TAR entry size`),m=oi(l,100,8,`${e}: TAR entry mode`)&iu,f=yu(l,e,t.maxPathBytes),h=lr(l,157,100,e);if(d==="x"||d==="g"){if(o+=1,o>t.maxEntries+1)throw new z(`${e}: TAR extension header count exceeds ${t.maxEntries+1}`);let w=ds(n,i,p,e);i=ls(i,p,n.byteLength,e);let S=pu(w,e,t);d==="x"?c=S:a={...a,...S};continue}if(s+=1,s>t.maxEntries)throw new z(`${e}: TAR entry count exceeds ${t.maxEntries}`);let g={...a,...c??{}};c=null;let _=g.size===void 0?p:mu(g.size,`${e}: PAX entry size`),y=ds(n,i,_,e);i=ls(i,_,n.byteLength,e);let E=ii(g.path??f,e,t.maxPathBytes),O=g.linkpath??h;switch(d){case"0":case"\0":r.push({path:E,type:"file",mode:m,data:y});break;case"5":ri(_,e,"directory",E),r.push({path:E,type:"directory",mode:m});break;case"2":ri(_,e,"symlink",E),fs(O,e,E,t.maxLinkBytes,!1),r.push({path:E,type:"symlink",mode:m,linkName:O});break;case"1":ri(_,e,"hardlink",E),fs(O,e,E,t.maxLinkBytes,!0),r.push({path:E,type:"hardlink",mode:m,linkName:ii(O,`${e}: hardlink target`,t.maxPathBytes)});break;case"3":case"4":case"6":throw new z(`${e}: unsupported TAR device/FIFO entry ${E}`);default:throw new z(`${e}: unsupported TAR entry type ${JSON.stringify(d)} for ${E}`)}}if(!u)throw new z(`${e}: TAR is missing its two-block end marker`);if(c!==null)throw new z(`${e}: local PAX header has no following entry`);return r}function uu(n,e){let t={...ps,...n};for(let[r,i]of Object.entries(t))if(!Number.isSafeInteger(i)||i<=0)throw new z(`${e}: ${r} must be a positive safe integer`);return t}function du(n,e){if(n.byteLength<18||n[0]!==31||n[1]!==139||n[2]!==8)throw new z(`${e}: invalid gzip header`);return new DataView(n.buffer,n.byteOffset,n.byteLength).getUint32(n.byteLength-4,!0)}function lu(n,e,t){let r=new Uint8Array(t),i=0,s=!1,o=new ts(c=>{if(c.byteLength>t-i)throw new z(`${e}: gzip expansion exceeds its declared ${t} bytes`);r.set(c,i),i+=c.byteLength});o.onmember=()=>{throw s=!0,new z(`${e}: concatenated gzip members are unsupported`)};try{o.push(n,!0)}catch(c){throw c instanceof z?c:new z(`${e}: cannot gunzip archive: ${Eu(c)}`)}if(s)throw new z(`${e}: concatenated gzip members are unsupported`);return r.subarray(0,i)}function fu(n){let e=4294967295;for(let t of n)e=su[(e^t)&255]^e>>>8;return(e^4294967295)>>>0}function hu(){let n=new Uint32Array(256);for(let e=0;e>>1^((t&1)===0?0:3988292384);n[e]=t>>>0}return n}function ds(n,e,t,r){if(t>n.byteLength-e)throw new z(`${r}: TAR entry is truncated`);return n.subarray(e,e+t)}function ls(n,e,t,r){let s=Math.ceil(e/ke)*ke;if(!Number.isSafeInteger(s)||s>t-n)throw new z(`${r}: TAR entry padding is truncated`);return n+s}function pu(n,e,t){let r={},i=0;for(;i9)throw new z(`${e}: invalid PAX record length`);if(o=o*10+h,!Number.isSafeInteger(o))throw new z(`${e}: invalid PAX record length`)}let c=i+o;if(o<=s-i+2||c>n.byteLength||n[c-1]!==10)throw new z(`${e}: truncated PAX record`);let a=s+1;for(;a=c-1)throw new z(`${e}: invalid PAX record`);let u=n.subarray(s+1,a);if(u.byteLength>256)throw new z(`${e}: PAX record key is too long`);let l=si(u,`${e}: PAX record key`),d=n.subarray(a+1,c-1),p=l==="path"?t.maxPathBytes:l==="linkpath"?t.maxLinkBytes:l==="size"?32:0;if(p===0){i=c;continue}if(d.byteLength>p)throw new z(`${e}: PAX ${l} value is too long`);let m=si(d,`${e}: PAX record value`);r[l]=m,i=c}return r}function mu(n,e){if(!/^(0|[1-9][0-9]*)$/.test(n))throw new z(`${e} is invalid`);let t=Number(n);if(!Number.isSafeInteger(t)||t<0)throw new z(`${e} is invalid`);return t}function _u(n,e){let t=oi(n,148,8,`${e}: TAR checksum`),r=0;for(let i=0;i=148&&i<156?32:n[i];if(t!==r)throw new z(`${e}: TAR checksum mismatch`)}function yu(n,e,t){let r=lr(n,0,100,e),i=lr(n,345,155,e);return ii(i?`${i}/${r}`:r,e,t)}function ii(n,e,t){let r=n;for(;r.startsWith("./");)r=r.slice(2);return r=r.replace(/\/+$/g,""),gu(r,`${e}: TAR path`,t),r}function lr(n,e,t,r){let i=e,s=e+t;for(;ir||n.includes("\0"))throw new z(`${e}: link target for ${t} is invalid`);if(i&&n.includes("\\"))throw new z(`${e}: hardlink target for ${t} is invalid`)}function gu(n,e,t){if(n.length===0||n.startsWith("/")||n.includes("\0")||n.includes("\\")||hs.encode(n).byteLength>t)throw new z(`${e} ${JSON.stringify(n)} must be a bounded relative POSIX path`);for(let r of n.split("/"))if(r.length===0||r==="."||r==="..")throw new z(`${e} ${JSON.stringify(n)} contains an unsafe path segment`)}function ni(n){for(let e of n)if(e!==0)return!1;return!0}function si(n,e){try{return ou.decode(n)}catch{throw new z(`${e} contains non-UTF-8 text`)}}function Eu(n){return n instanceof Error?n.message:String(n)}var ke,iu,us,ou,hs,su,ps,z,_s=Ir(()=>{"use strict";jn();tt();ke=512,iu=W.S_MODE_BITS,us=1024*1024,ou=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),hs=new TextEncoder,su=hu(),ps=Object.freeze({maxCompressedBytes:256*us,maxUncompressedBytes:512*us,maxEntries:1e5,maxPathBytes:4096,maxLinkBytes:65536}),z=class extends Error{constructor(e){super(e),this.name="TarParseError"}}});import{existsSync as Er,lstatSync as cn,readdirSync as js,readFileSync as ut,realpathSync as Re,statSync as Ye}from"node:fs";import{createHash as Js}from"node:crypto";import{spawnSync as Ri}from"node:child_process";import{basename as nd,dirname as wr,isAbsolute as un,join as $,relative as id,resolve as Ie,sep as od}from"node:path";import{fileURLToPath as sd}from"node:url";tt();var la=Uint8Array.from(Xi);function T(n,e){let t=0,r=0,i=e;for(;;){let s=n[i++];if(t|=(s&127)<=n.length)throw new Error(`${r} is truncated`);if((n[e++]&128)===0)return e}throw new Error(`${r} has an overlong LEB128 encoding`)}function we(n,e,t){if(e>=n.length)throw new Error(`${t} is truncated`);let r=n[e++];switch(r){case 127:case 126:case 125:case 124:case 123:case 117:case 116:case 115:case 114:case 113:case 112:case 111:case 110:case 109:case 108:case 107:case 106:case 105:case 104:return{code:r,shared:!1,next:e};case 98:case 99:case 100:{let i=n[e]===101;i&&e++;let s=po(n,e,5,`${t} heap type`),[o]=ho(n,e);return{code:r,heapType:Number(o),shared:i,next:s}}default:throw new Error(`${t} has unknown value type 0x${r.toString(16)}`)}}function fa(n,e,t){return n[e]===120||n[e]===119?{code:n[e],shared:!1,next:e+1}:we(n,e,t)}function ha(n,e){let t=n[e];if(t===64||t===127||t===126||t===125||t===124||t===123||t===112||t===111)return e+1;let[,r]=xn(n,e);return e+r}function pa(n,e,t){let[r,i]=T(n,e);e+=i;let s=[],o=[];for(let d=0;d=n.length)throw new Error(`${t} mutability is truncated`);let i=n[e++];if(i!==0&&i!==1)throw new Error(`${t} has invalid mutability ${i}`);return e}function ma(n,e,t,r){if(e===101){if(t>=n.length)throw new Error(`${r} shared type is truncated`);e=n[t++]}for(let i of[76,77]){if(e!==i)continue;let[,s]=T(n,t);if(t+=s,t>=n.length)throw new Error(`${r} descriptor is truncated`);e=n[t++]}if(e===96)return pa(n,t,r);if(e===95){let[i,s]=T(n,t);t+=s;for(let o=0;o=n.length)throw new Error(`${t} is truncated`);let r=n[e++];if(r===79||r===80){let[i,s]=T(n,e);e+=s;for(let o=0;o=n.length)throw new Error(`${t} body is truncated`);r=n[e++]}return ma(n,r,e,t)}function _a(n,e){let[t,r]=T(n,e);e+=r;let i=[];for(let s=0;s=21&&r<=34?qt(e,t):r===84||r>=92&&r<=99||r>=112&&r<=123||r>=124&&r<=131||r>=156&&r<=159?t+1:t:n===254?r===0||r===1||r===2?qt(e,t):r===3?t:r>=16&&r<=79?qt(e,t):null:null}function ga(n,e,t){let[r,i]=T(n,e);e+=i+r;let[s,o]=T(n,e);e+=o+s;let c=n[e++];if(c===0){t.funcImports++;let[,a]=T(n,e);e+=a}else if(c===1)e=we(n,e,"table import type").next,e=We(n,e).next;else if(c===2)e=We(n,e).next;else if(c===3)t.globalImports++,e=we(n,e,"global import type").next,e++;else if(c===4){e++;let[,a]=T(n,e);e+=a}return e}function Nr(n){return n.length>=8&&n[0]===0&&n[1]===97&&n[2]===115&&n[3]===109}function Ue(n,e){let[t,r]=T(n,e);return e+=r,[new TextDecoder().decode(n.subarray(e,e+t)),e+t]}function Ea(n,e){if(e.length===0)return!0;let t=new TextEncoder().encode(e);e:for(let r=0;r<=n.length-t.length;r++){for(let i=0;in);function uo(n,e){switch(n.code){case 127:return fn;case 126:return hn;case 125:return pn;case 124:return mn;case 123:return _n;case 112:case 115:return pt;case 111:case 114:return Mt;case 105:case 116:return Dt;case 104:case 106:case 107:case 108:case 109:case 110:case 113:case 117:return Kt;case 98:case 99:case 100:{let t=n.heapType;return t===void 0?null:t===-16||t===-13?pt:t===-17||t===-14?Mt:t===-23||t===-12?Dt:t>=0&&e[t]!==void 0?pt:Kt}default:return null}}function An(n,e,t){if(!t)throw new Error(`function ${e} refers to an unknown type`);let r=n.get(e)??[];r.push(t),n.set(e,r)}function Vt(n,e,t){let r=n.get(e)??[];r.push(t),n.set(e,r)}function We(n,e){let[t,r]=T(n,e);e+=r;let[i,s]=T(n,e);e+=s;let o=null;if((t&1)!==0){let[c,a]=T(n,e);e+=a,o=c}return{flags:t,minimum:i,maximum:o,next:e}}function wa(n){let e=new Uint8Array(n);if(!Nr(e))throw new Error("not a wasm binary");let t=[],r=[],i=[],s={functionTypes:t,functionTypeIndices:r,functionImports:new Map,functionImportEntries:[],globalImports:new Map,tableImports:new Map,tables:[],tagImports:new Map,functionExports:new Map,globalExports:new Map,tableExports:new Map,exports:new Map,memoryPointerWidths:[],forkCapabilities:[],linkedFrameDescriptors:[],exceptionCodecDescriptors:[],importedGlobalsDescriptors:[],importedTablesDescriptors:[],moduleStateDescriptors:[],staticRootDescriptors:[],unwindTransportDescriptors:[],nativeStartCount:0,importsKernelFork:!1},o=0,c=0,a=8;for(;ae.length)throw new Error("wasm section exceeds file size");let f=p,h=!1;if(u===0){let[g,_]=Ue(e,f);g===Nt?s.linkedFrameDescriptors.push(e.slice(_,m)):g===ve?s.forkCapabilities.push(e.slice(_,m)):g===Se?s.exceptionCodecDescriptors.push(e.slice(_,m)):g===X?s.importedGlobalsDescriptors.push(e.slice(_,m)):g===Y?s.importedTablesDescriptors.push(e.slice(_,m)):g===ne?s.moduleStateDescriptors.push(e.slice(_,m)):g===$e?s.staticRootDescriptors.push(e.slice(_,m)):g===mt&&s.unwindTransportDescriptors.push(e.slice(_,m))}else if(u===1){h=!0;let g=_a(e,f);t.push(...g.types),f=g.next}else if(u===2){h=!0;let[g,_]=T(e,f);f+=_;for(let y=0;y=e.length)throw new Error(`global import ${E}.${w} is truncated`);let x=e[f++];if((x&-4)!==0)throw new Error(`global import ${E}.${w} has invalid flags ${x}`);Vt(s.globalImports,`${E}.${w}`,{module:E,name:w,importOrdinal:y,index:o++,valueType:R.code,recipeTypeCode:uo(R,t),mutable:(x&1)!==0,shared:(x&2)!==0})}else if(A===4){let R=e[f++];if(R!==0)throw new Error(`unsupported wasm tag attribute ${R}`);let[x,v]=T(e,f);f+=v,An(s.tagImports,`${E}.${w}`,t[x])}else throw new Error(`unsupported wasm import kind ${A}`)}}else if(u===3){h=!0;let[g,_]=T(e,f);f+=_;for(let y=0;yn[a]===c))throw new Error("linked-frame descriptor has invalid magic");let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=e.getUint16(4,!0);if(t!==1)throw new Error(`linked-frame descriptor version ${t} is unsupported`);let r=e.getUint16(6,!0);if(r!==Ct)throw new Error(`linked-frame descriptor declares size ${r}, expected ${Ct}`);let i=e.getUint8(8),s=Mi.find(({bytes:c})=>c===i);if(!s)throw new Error(`linked-frame descriptor pointer width ${i} is unsupported`);if(e.getUint8(9)!==Ci)throw new Error(`linked-frame descriptor alignment ${e.getUint8(9)} is unsupported`);let o=e.getUint16(10,!0);if(o!==dn)throw new Error(`linked-frame descriptor flags 0x${o.toString(16)} do not equal required flags 0x${dn.toString(16)}`);if(e.getUint32(12,!0)!==s.chunkHeaderSize||e.getUint32(16,!0)!==s.nodeHeaderSize)throw new Error(`linked-frame descriptor header sizes do not match its ${i}-byte pointer width`);return s.bytes}function Aa(n){if(n.length===0)return[`missing required ${ve} capability`];if(n.length!==1)return[`has ${n.length} ${ve} sections, expected exactly one`];let e=n[0];if(e.byteLength!==2)return[`${ve} has ${e.byteLength} bytes, expected 2`];if(e[0]!==Hi)return[`${ve} version ${e[0]} is unsupported`];let t=e[1];return(t&~Vi)!==0?[`${ve} has unknown flags 0x${t.toString(16)}`]:(t&vr)!==vr?[`${ve} flags 0x${t.toString(16)} omit required activation-state safety flags 0x${vr.toString(16)}`]:[]}function Ia(n){let e=[],t=`${zr}.${br}`,r=n.tagImports.get(t);if(r?r.length!==1?e.push(`duplicate private fork-unwind tag import ${t}`):(r[0].params.length!==0||r[0].results.length!==0)&&e.push(`private fork-unwind tag ${t} must have an empty payload`):e.push(`missing required private fork-unwind tag import ${t}`),n.unwindTransportDescriptors.length===0)e.push(`missing required ${mt} descriptor`);else if(n.unwindTransportDescriptors.length!==1)e.push(`has ${n.unwindTransportDescriptors.length} ${mt} descriptors, expected exactly one`);else{let i=n.unwindTransportDescriptors[0];(i.length!==2||i[0]!==gn||i[1]!==En)&&e.push(`${mt} must be [${gn}, ${En}]`)}return e}function Ra(n,e){if(n.length===0)return[`missing required ${ne} descriptor`];if(n.length!==1)return[`has ${n.length} ${ne} descriptors, expected exactly one`];let t=n[0];if(t.byteLength!==Rr)return[`${ne} has ${t.byteLength} bytes, expected ${Rr}`];if(!Ki.every((h,g)=>t[g]===h))return[`${ne} has invalid magic`];let r=new DataView(t.buffer,t.byteOffset,t.byteLength),i=r.getUint16(4,!0),s=r.getUint16(6,!0),o=r.getUint8(8),c=Gi.find(({bytes:h})=>h===o),a=r.getUint8(9),u=r.getUint16(10,!0),l=r.getUint16(12,!0),d=r.getUint16(14,!0),p=r.getUint32(16,!0),m=r.getUint32(20,!0),f=[];return i!==Di&&f.push(`${ne} version ${i} is unsupported`),s!==Rr&&f.push(`${ne} declares size ${s}`),c?e!==null&&o!==e&&f.push(`${ne} pointer width ${o} does not match linked frames ${e}`):f.push(`${ne} pointer width ${o} is unsupported`),a!==Bi&&f.push(`${ne} alignment ${a} is unsupported`),u!==ln&&f.push(`${ne} flags 0x${u.toString(16)} do not equal required flags 0x${ln.toString(16)}`),l!==$i&&f.push(`${ne} arena version ${l} is unsupported`),d!==Ui&&f.push(`${ne} record version ${d} is unsupported`),p!==Wi&&f.push(`${ne} root word ${p} is unsupported`),m!==0&&f.push(`${ne} reserved field is nonzero`),f}function xa(n){if(n.length===0)return[`missing required ${Se} descriptor`];if(n.length!==1)return[`has ${n.length} ${Se} descriptors, expected exactly one`];let e=n[0];if(e.byteLength2147483647||o.has(l))&&r.push(`${Se} layout id ${l} is invalid or duplicated`),o.add(l)}return r}var Ta=new Set([fn,hn,pn,mn,_n,pt,Mt,Dt,Kt]);function lo(n){return!(n.module===Sn&&(n.name===wn||n.name==="__channel_base"||n.name==="__wpk_fork_module_state_table_generation_addr"))}function va(n){let e=n.importedGlobalsDescriptors;if(e.length===0)return[`missing required ${X} descriptor`];if(e.length!==1)return[`has ${e.length} ${X} descriptors, expected exactly one`];let t=e[0];if(t.byteLengtht[g]===h)||i.push(`${X} has invalid magic`),r.getUint16(4,!0)!==ji&&i.push(`${X} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==kr&&i.push(`${X} declares an invalid header size`);let s=r.getUint32(8,!0);r.getUint32(12,!0)!==0&&i.push(`${X} reserved field is nonzero`);let o=new Set,c=new Set,a=new TextDecoder("utf-8",{fatal:!0}),u=[],l=-1,d=kr;for(let h=0;ht.byteLength)return i.push(`${X} record ${h} header is truncated`),i;let g=r.getUint32(d,!0),_=r.getUint32(d+4,!0),y=r.getUint8(d+8),E=r.getUint8(d+9),O=r.getUint32(d+12,!0),w=r.getUint32(d+16,!0),S=r.getUint32(d+20,!0),A=$t+O+w;if(!Number.isSafeInteger(A)||g!==A||g<$t||d+g>t.byteLength)return i.push(`${X} record ${h} has invalid bounds`),i;(_===0||o.has(_))&&i.push(`${X} record ${h} has invalid or duplicated owner ${_}`),o.add(_),Ta.has(y)||i.push(`${X} record ${h} has unknown value type ${y}`),(E&~eo)!==0&&i.push(`${X} record ${h} has unknown flags 0x${E.toString(16)}`),r.getUint16(d+10,!0)!==0&&i.push(`${X} record ${h} reserved fields are nonzero`),(c.has(S)||S<=l)&&i.push(`${X} record ${h} has duplicated or unordered import ordinal`),c.add(S),l=S;let R=d+$t;try{let x=a.decode(t.subarray(R,R+O)),v=a.decode(t.subarray(R+O,R+O+w));u.push({ownerId:_,typeCode:y,flags:E,importOrdinal:S,module:x,name:v})}catch{i.push(`${X} record ${h} contains invalid UTF-8`)}d+=g}d!==t.byteLength&&i.push(`${X} has trailing bytes`);let p=[...n.globalImports.values()].flat(),m=new Map(p.map(h=>[h.index,h])),f=new Set;for(let h of u){let g=`${xr}${h.ownerId}`,_=n.exports.get(g);if(!_||_.length!==1||_[0].kind!==3){i.push(`${X} owner ${h.ownerId} lacks exactly one global catalog export ${g}`);continue}let y=m.get(_[0].index);if(!y||!lo(y)){i.push(`${X} owner ${h.ownerId} does not identify a reconstructible imported global`);continue}if(y.module!==h.module||y.name!==h.name||y.importOrdinal!==h.importOrdinal||y.recipeTypeCode!==h.typeCode||y.mutable!==((h.flags&Ji)!==0)||y.shared!==((h.flags&Qi)!==0)){i.push(`${X} owner ${h.ownerId} does not match its imported global declaration`);continue}if(f.has(y.index)){i.push(`${X} repeats imported global index ${y.index}`);continue}f.add(y.index)}for(let h of p)lo(h)&&!f.has(h.index)&&i.push(`${X} omits imported global ${h.module}.${h.name} at index ${h.index}`);for(let[h,g]of n.exports){if(!h.startsWith(xr))continue;let _=h.slice(xr.length),y=Number(_);(!/^[1-9][0-9]*$/.test(_)||!Number.isSafeInteger(y)||y>4294967295||g.length!==1||g[0].kind!==3)&&i.push(`malformed reserved fork global catalog export ${h}`)}return i}var La=new Set([pt,Mt,Dt,Kt]);function fo(n){return!On.some(({module:e,name:t})=>n.module===e&&n.name===t)}function za(n){let e=n.importedTablesDescriptors;if(e.length===0)return[`missing required ${Y} descriptor`];if(e.length!==1)return[`has ${e.length} ${Y} descriptors, expected exactly one`];let t=e[0];if(t.byteLengtht[g]===h)||i.push(`${Y} has invalid magic`),r.getUint16(4,!0)!==ro&&i.push(`${Y} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Fr&&i.push(`${Y} declares an invalid header size`);let s=r.getUint32(8,!0);r.getUint32(12,!0)!==0&&i.push(`${Y} reserved field is nonzero`);let o=new Set,c=new Set,a=new TextDecoder("utf-8",{fatal:!0}),u=[],l=-1,d=Fr;for(let h=0;ht.byteLength)return i.push(`${Y} record ${h} header is truncated`),i;let g=r.getUint32(d,!0),_=r.getUint32(d+4,!0),y=r.getUint8(d+8),E=r.getUint8(d+9),O=r.getUint32(d+12,!0),w=r.getUint32(d+16,!0),S=r.getUint32(d+20,!0),A=Ut+O+w;if(!Number.isSafeInteger(A)||g!==A||gt.byteLength)return i.push(`${Y} record ${h} has invalid bounds`),i;(_===0||o.has(_))&&i.push(`${Y} record ${h} has invalid or duplicated owner ${_}`),o.add(_),La.has(y)||i.push(`${Y} record ${h} has unknown element type ${y}`),(E&~io)!==0&&i.push(`${Y} record ${h} has unknown flags 0x${E.toString(16)}`),r.getUint16(d+10,!0)!==0&&i.push(`${Y} record ${h} reserved fields are nonzero`),(c.has(S)||S<=l)&&i.push(`${Y} record ${h} has duplicated or unordered import ordinal`),c.add(S),l=S;let R=d+Ut;try{let x=a.decode(t.subarray(R,R+O)),v=a.decode(t.subarray(R+O,R+O+w));u.push({ownerId:_,typeCode:y,flags:E,importOrdinal:S,module:x,name:v})}catch{i.push(`${Y} record ${h} contains invalid UTF-8`)}d+=g}d!==t.byteLength&&i.push(`${Y} has trailing bytes`);let p=[...n.tableImports.values()].flat(),m=new Map(p.map(h=>[h.index,h])),f=new Set;for(let h of u){let g=`${Tr}${h.ownerId}`,_=n.exports.get(g);if(!_||_.length!==1||_[0].kind!==1){i.push(`${Y} owner ${h.ownerId} lacks exactly one table catalog export ${g}`);continue}let y=m.get(_[0].index);if(!y||!fo(y)){i.push(`${Y} owner ${h.ownerId} does not identify a reconstructible imported table`);continue}if(y.module!==h.module||y.name!==h.name||y.importOrdinal!==h.importOrdinal||y.recipeTypeCode!==h.typeCode||y.table64!==((h.flags&no)!==0)){i.push(`${Y} owner ${h.ownerId} does not match its imported table declaration`);continue}if(f.has(y.index)){i.push(`${Y} repeats imported table index ${y.index}`);continue}f.add(y.index)}for(let h of p)fo(h)&&!f.has(h.index)&&i.push(`${Y} omits imported table ${h.module}.${h.name} at index ${h.index}`);for(let[h,g]of n.exports){if(!h.startsWith(Tr))continue;let _=h.slice(Tr.length),y=Number(_);(!/^[1-9][0-9]*$/.test(_)||!Number.isSafeInteger(y)||y>4294967295||g.length!==1||g[0].kind!==1)&&i.push(`malformed reserved fork table catalog export ${h}`)}return i}function Tn(n,e){switch(n){case"ptr":return e===8?126:127;case"i32":return 127;case"i64":return 126;case"anyref":return 110;case"exnref":return 105;case"externref":return 111;case"funcref":return 112}}function In(n,e,t,r){return n.params.length===e.length&&n.results.length===t.length&&n.params.every((i,s)=>i===Tn(e[s],r))&&n.results.every((i,s)=>i===Tn(t[s],r))}function Rn(n,e,t){let r=i=>i==="ptr"?t===8?"i64":"i32":i;return`(${n.map(r).join(", ")}) -> (${e.map(r).join(", ")})`}function ba(n){let e=`${Sn}.${wn}`,t=n.globalImports.get(e);return t?t.length!==1?[`duplicate exception-codec activation import ${e}`]:t[0].valueType!==127||t[0].mutable?[`exception-codec activation import ${e} must be immutable i32`]:[]:[`missing required immutable exception-codec activation import ${e}`]}function Pa(n){let e=[];for(let t of On){let r=`${t.module}.${t.name}`,i=n.tableImports.get(r);if(!i){e.push(`missing required ABI 43 fork-runtime table import ${r}`);continue}if(i.length!==1){e.push(`duplicate ABI 43 fork-runtime table import ${r}`);continue}let s=i[0],o=Tn(t.element,4);(s.elementType!==o||s.table64!==t.table64||s.minimum!==t.minimum||s.maximum!==t.maximum)&&e.push(`ABI 43 fork-runtime table import ${r} has the wrong type or limits`)}return e}function ka(n){if(n.staticRootDescriptors.length===0)return[`missing required ${$e} descriptor`];if(n.staticRootDescriptors.length!==1)return[`has ${n.staticRootDescriptors.length} ${$e} descriptors, expected exactly one`];let e=n.staticRootDescriptors[0];if(e.byteLength!==Pr)return[`${$e} has ${e.byteLength} bytes, expected ${Pr}`];let t=[];la.some((u,l)=>e[l]!==u)&&t.push(`${$e} has invalid magic`);let r=new DataView(e.buffer,e.byteOffset,e.byteLength);r.getUint16(4,!0)!==Zi&&t.push(`${$e} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Pr&&t.push(`${$e} declares an invalid header size`);let i=r.getUint32(8,!0),s=n.tableExports.get(Bt);if(!s||s.length!==1)return t.push(`missing exactly one table export ${Bt}`),t;let o=[...n.tableImports.values()].reduce((u,l)=>u+l.length,0),c=s[0],a=n.tables[c];return c!n.functionExports.has(a)).map(({name:a})=>a);if(t.length>0&&e.push(`incomplete wasm-fork-instrument exports; missing ${t.join(", ")}`),n.importsKernelFork){let a=`${et.module}.${et.name}`,u=n.functionImports.get(a);u?.length!==1?e.push(`duplicate ABI 43 process-fork import ${a}`):In(u[0],et.params,et.results,4)||e.push(`ABI 43 process-fork import ${a} has the wrong signature; expected ${Rn(et.params,et.results,4)}`)}let r=null;if(n.linkedFrameDescriptors.length===0)e.push(`missing required ${Nt} descriptor`);else if(n.linkedFrameDescriptors.length!==1)e.push(`has ${n.linkedFrameDescriptors.length} ${Nt} descriptors, expected exactly one`);else try{r=Oa(n.linkedFrameDescriptors[0])}catch(a){e.push(a instanceof Error?a.message:String(a))}e.push(...Ra(n.moduleStateDescriptors,r));let i=_t.filter(({module:a,name:u})=>n.functionImports.has(`${a}.${u}`)),s=`${zr}.${br}`,o=n.importsKernelFork||i.length>0;if((o||n.tagImports.has(s)||n.unwindTransportDescriptors.length>0)&&e.push(...Ia(n)),o){let a=_t.filter(({module:u,name:l})=>!n.functionImports.has(`${u}.${l}`)).map(({module:u,name:l})=>`${u}.${l}`);a.length>0&&e.push(`incomplete ABI 43 fork-runtime imports; missing ${a.join(", ")}`);for(let u of _t){let l=`${u.module}.${u.name}`,d=n.functionImports.get(l);d&&d.length!==1&&e.push(`duplicate ABI 43 fork-runtime import ${l}`)}}if(r!==null){if(n.memoryPointerWidths.length!==1)e.push(`ABI 43 fork instrumentation requires exactly one module memory, found ${n.memoryPointerWidths.length}`);else if(n.memoryPointerWidths[0]!==r){let a=r===8?"an":"a";e.push(`ABI 43 linked-frame descriptor declares ${a} ${r}-byte pointer but the module memory uses ${n.memoryPointerWidths[0]}-byte addresses`)}for(let a of Wt){let u=n.functionExports.get(a.name);u?.length===1&&!In(u[0],a.params,a.results,r)&&e.push(`ABI 43 wasm-fork-instrument export ${a.name} has the wrong signature; expected ${Rn(a.params,a.results,r)}`)}if(o)for(let a of _t){let u=`${a.module}.${a.name}`,l=n.functionImports.get(u);l?.length===1&&!In(l[0],a.params,a.results,r)&&e.push(`ABI 43 fork-runtime import ${u} has the wrong signature; expected ${Rn(a.params,a.results,r)}`)}}return e}function mo(n,e){switch(n){case 0:return"function";case 1:return"table";case 2:return"memory";case 3:return"global";case 4:return"tag";default:throw new Error(`${e} has unsupported external kind ${n}`)}}function Na(n){let e=new Uint8Array(n);if(!Nr(e))return[];let t=[],r=8;for(;r`${e}.${t}`)}function Ma(n){let e=new Uint8Array(n);if(!Nr(e))return[];let t=[],r=8;for(;re)}function _o(n){let e=new Uint8Array(n);if(!Nr(e))return[];let t=[],r=8;for(;rt.startsWith("reloc."))}function yo(n,e={}){let t=[],r=null;Ka(n)&&t.push("contains asyncify_"),e.expectedAbi!==void 0&&e.expectedAbi!==null&&(r=Ua(n),r!==null&&r!==e.expectedAbi&&t.push(`ABI ${r}, expected ${e.expectedAbi}`));let i=new Set(Da(n));if(e.requiredExports){let E=e.requiredExports.filter(O=>!i.has(O));E.length>0&&t.push(`missing required exports: ${E.join(", ")}`)}let s=Sa.filter(E=>i.has(E)),o=Ca(n),c=_o(n),a=_t.filter(({module:E,name:O})=>o.includes(`${E}.${O}`)),u=c.filter(E=>E===Nt).length,l=c.filter(E=>E===ve).length,d=c.filter(E=>E===ne).length,p=c.filter(E=>E===Se).length,m=c.filter(E=>E===X).length,f=c.filter(E=>E===Y).length,h=c.filter(E=>E===mt).length,g=o.includes(`${zr}.${br}`),_=s.length>0||a.length>0||u>0||l>0||d>0||p>0||m>0||f>0||h>0||g;if(e.expectedAbi!==void 0&&e.expectedAbi!==null&&_&&r===null&&t.push(`ABI ${e.expectedAbi} fork artifact is missing __abi_version; the activation-state capability epoch cannot be verified`),e.forbidForkInstrumentation&&_&&t.push("contains ABI 43 wasm-fork-instrument metadata, imports, or exports"),(e.requireForkInstrumentation??!Ba(n))&&(_||o.includes("kernel.kernel_fork")))try{t.push(...Fa(wa(n)))}catch(E){t.push(`cannot validate ABI 43 fork-artifact contract: ${E instanceof Error?E.message:String(E)}`)}return t}function $a(n,e){let t=new Uint8Array(n);if(t.length<8)return null;let r=0,i=null,s=null,o=8;for(;o=a)return null;let h=c;for(let y=0;y=f)return null;let[h,g]=T(t,m);m+=g;for(let _=0;_f)return null}return m}function p(m,f=0){if(f>4)return null;let h=l(m);if(!h)return null;let g=d(h.start,h.end);if(g===null)return null;let _=g,y=h.end;for(;_=32&&E<=38||E===208){let[,O]=T(t,_);_+=O}else if(E>=40&&E<=62)_=qt(t,_);else if(E===63||E===64)_++;else if(E===66){let[,O]=ho(t,_);_+=O}else if(E===67)_+=4;else if(E===68)_+=8;else if(E===252||E===253||E===254){let O=ya(E,t,_);if(O===null)return null;_=O}}return null}return p(i)}function Ua(n){return $a(n,"__abi_version")}tt();var Wa=ArrayBuffer,J=Uint8Array,Cr=Uint16Array,Ga=Int16Array;var Mr=Int32Array,vn=function(n,e,t){if(J.prototype.slice)return J.prototype.slice.call(n,e,t);(e==null||e<0)&&(e=0),(t==null||t>n.length)&&(t=n.length);var r=new J(t-e);return r.set(n.subarray(e,t)),r},Xt=function(n,e,t,r){if(J.prototype.fill)return J.prototype.fill.call(n,e,t,r);for((t==null||t<0)&&(t=0),(r==null||r>n.length)&&(r=n.length);tn.length)&&(r=n.length);t2046MB)","invalid block type","FSE accuracy too high","match distance too far back","unexpected EOF"],Q=function(n,e,t){var r=new Error(e||Va[n]);if(r.code=n,Error.captureStackTrace&&Error.captureStackTrace(r,Q),!t)throw r;return r},go=function(n,e,t){for(var r=0,i=0;r>>0},Za=function(n,e){var t=n[0]|n[1]<<8|n[2]<<16;if(t==3126568&&n[3]==253){var r=n[4],i=r>>5&1,s=r>>2&1,o=r&3,c=r>>6;r&8&&Q(0);var a=6-i,u=o==3?4:o,l=go(n,a,u);a+=u;var d=c?1<>3);m=f+(f>>3)*(n[5]&7)}m>2145386496&&Q(1);var h=new J((e==1?p||m:e?0:m)+12);return h[0]=1,h[4]=4,h[8]=8,{b:a+d,y:0,l:0,d:l,w:e&&e!=1?e:h.subarray(12),e:m,o:new Mr(h.buffer,0,3),u:p,c:s,m:Math.min(131072,m)}}else if((t>>4|n[3]<<20)==25481893)return qa(n,4)+8;Q(0)},rt=function(n){for(var e=0;1<t&&Q(3);for(var s=1<0;){var y=rt(o+1),E=r>>3,O=(1<>(r&7)&O,S=(1<S&&(w-=A)),p[++c]=--w,w==-1?(o+=w,g[--l]=c):o-=w,!w)do{var x=r>>3;a=(n[x]|n[x+1]<<8)>>(r&7)&3,r+=2,c+=a}while(a==3)}(c>255||o)&&Q(0);for(var v=0,L=(s>>1)+(s>>3)+3,D=s-1,Z=0;Z<=c;++Z){var N=p[Z];if(N<1){m[Z]=-N;continue}for(u=0;u=l)}}for(v&&Q(0),u=0;u>3,{b:i,s:g,n:_,t:f}]},Xa=function(n,e){var t=0,r=-1,i=new J(292),s=n[e],o=i.subarray(0,256),c=i.subarray(256,268),a=new Cr(i.buffer,268);if(s<128){var u=Yt(n,e+1,6),l=u[0],d=u[1];e+=s;var p=l<<3,m=n[e];m||Q(0);for(var f=0,h=0,g=d.b,_=g,y=(++e<<3)-8+rt(m);y-=g,!(y>3;if(f+=(n[E]|n[E+1]<<8)>>(y&7)&(1<>3,h+=(n[E]|n[E+1]<<8)>>(y&7)&(1<<_)-1,o[++r]=d.s[h],g=d.n[f],f=d.t[f],_=d.n[h],h=d.t[h]}++r>255&&Q(0)}else{for(r=s-127;t>4,o[t+1]=O&15}++e}var w=0;for(t=0;t11&&Q(0),w+=S&&1<0;--t){var Z=a[t];Xt(D,t,Z,a[t-1]=Z+c[t]*(1<c&&d>3,m=(n[p]|n[p+1]<<8|n[p+2]<<16)>>(l&7);a=(a<>2,o=s<<1,c=s+o;Zt(n.subarray(r,r+=n[0]|n[1]<<8),e.subarray(0,s),t),Zt(n.subarray(r,r+=n[2]|n[3]<<8),e.subarray(s,o),t),Zt(n.subarray(r,r+=n[4]|n[5]<<8),e.subarray(o,c),t),Zt(n.subarray(r),e.subarray(c),t)},rc=function(n,e,t){var r,i=e.b,s=n[i],o=s>>1&3;e.l=s&1;var c=s>>3|n[i+1]<<5|n[i+2]<<13,a=(i+=3)+c;if(o==1)return i>=n.length?void 0:(e.b=i+1,t?(Xt(t,n[i],e.y,e.y+=c),t):Xt(new J(c),n[i]));if(!(a>n.length)){if(o==0)return e.b=a,t?(t.set(n.subarray(i,a),e.y),e.y+=c,t):vn(n,i,a);if(o==2){var u=n[i],l=u&3,d=u>>2&3,p=u>>4,m=0,f=0;l<2?d&1?p|=n[++i]<<4|(d&2&&n[++i]<<12):p=u>>3:(f=d,d<2?(p|=(n[++i]&63)<<4,m=n[i]>>6|n[++i]<<2):d==2?(p|=n[++i]<<4|(n[++i]&3)<<12,m=n[i]>>2|n[++i]<<6):(p|=n[++i]<<4|(n[++i]&63)<<12,m=n[i]>>6|n[++i]<<2|n[++i]<<10)),++i;var h=t?t.subarray(e.y,e.y+e.m):new J(e.m),g=h.length-p;if(l==0)h.set(n.subarray(i,i+=p),g);else if(l==1)Xt(h,n[i++],g);else{var _=e.h;if(l==2){var y=Xa(n,i);m+=i-(i=y[0]),e.h=_=y[1]}else _||Q(0);(f?tc:Zt)(n.subarray(i,i+=m),h.subarray(g),_)}var E=n[i++];if(E){E==255?E=(n[i++]|n[i++]<<8)+32512:E>127&&(E=E-128<<8|n[i++]);var O=n[i++];O&3&&Q(0);for(var w=[ja,Ja,Ya],S=2;S>-1;--S){var A=O>>(S<<1)+2&3;if(A==1){var R=new J([0,0,n[i++]]);w[S]={s:R.subarray(2,3),n:R.subarray(0,1),t:new Cr(R.buffer,0,1),b:0}}else A==2?(r=Yt(n,i,9-(S&1)),i=r[0],w[S]=r[1]):A==3&&(e.t||Q(0),w[S]=e.t[S])}var x=e.t=w,v=x[0],L=x[1],D=x[2],Z=n[a-1];Z||Q(0);var N=(a<<3)-8+rt(Z)-D.b,b=N>>3,U=0,le=(n[b]|n[b+1]<<8)>>(N&7)&(1<>3;var C=(n[b]|n[b+1]<<8)>>(N&7)&(1<>3;var V=(n[b]|n[b+1]<<8)>>(N&7)&(1<>3;var ft=1<>>(N&7)&ft-1);b=(N-=zn[Je])>>3;var De=ec[Je]+((n[b]|n[b+1]<<8|n[b+2]<<16)>>(N&7)&(1<>3;var Qe=Qa[kt]+((n[b]|n[b+1]<<8|n[b+2]<<16)>>(N&7)&(1<>3,le=D.t[le]+((n[b]|n[b+1]<<8)>>(N&7)&(1<>3,V=v.t[V]+((n[b]|n[b+1]<<8)>>(N&7)&(1<>3,C=L.t[C]+((n[b]|n[b+1]<<8)>>(N&7)&(1<3)e.o[2]=e.o[1],e.o[1]=e.o[0],e.o[0]=Ee-=3;else{var ht=Ee-(Qe!=0);ht?(Ee=ht==3?e.o[0]-1:e.o[ht],ht>1&&(e.o[2]=e.o[1]),e.o[1]=e.o[0],e.o[0]=Ee):Ee=e.o[0]}for(var S=0;SDe&&(Be=De);for(var S=0;Ssc)throw jt("EOVERFLOW","file offset is outside signed i64");return n}function cc(n){if(Pn(n)<0n)throw jt("EINVAL","negative positioned I/O offset");return n}function kn(n){let e=Pn(n);if(eOo)throw jt("EOVERFLOW","backend cannot represent the file offset exactly");return wo(e)}function Fn(n){let e=cc(n);return kn(e)}function Ao(n){if(n===null)return null;let e=Pn(n);if(e<0n)throw jt("EINVAL","negative file-size limit");return e>Oo?null:wo(e)}tt();function Nn(n){let e=new Error(`EINVAL: pathconf name ${n} is not associated with this object`);throw e.code="EINVAL",e}function Cn(n,e,t){switch(e){case q.LINK_MAX:return null;case q.NAME_MAX:return 255;case q.PATH_MAX:return oo;case q.CHOWN_RESTRICTED:return 1;case q.NO_TRUNC:return 1;case q.ASYNC_IO:return(n.mode&W.S_IFMT)===W.S_IFREG?1:Nn(e);case q.SYNC_IO:case q.PRIO_IO:case q.FILESIZEBITS:case q.REC_INCR_XFER_SIZE:case q.REC_MAX_XFER_SIZE:case q.REC_MIN_XFER_SIZE:case q.REC_XFER_ALIGN:case q.ALLOC_SIZE_MIN:case q.SYMLINK_MAX:case q.FALLOC:return null;case q.POSIX2_SYMLINKS:return t.supportsSymlinks?1:null;case q.TEXTDOMAIN_MAX:return 255;case q.TIMESTAMP_RESOLUTION:return t.timestampResolutionNs;case q.PIPE_BUF:{let r=n.mode&W.S_IFMT;return r===W.S_IFIFO||r===W.S_IFDIR?null:Nn(e)}case q.MAX_CANON:case q.MAX_INPUT:case q.VDISABLE:case q.SOCK_MAXBUF:return Nn(e);default:{let r=new Error(`EINVAL: invalid pathconf name ${e}`);throw r.code="EINVAL",r}}}tt();var Dr=Math.floor(160),Mn=1397114451,Dn=1,Jt=32768,H=16384,yt=40960,G=61440,uc=2048,dc=1024,lc=73,Io=4294967295,nt=0,Ro=1;var or=64,Vn=128,ar=512,fc=1024,hc=65536,Qt=3,pc=0,mc=1,_c=2,k=8,yc=-1,Oe=-2,B=-5,re=-9,Wn=-16,At=-17,ze=-20,ot=-21,j=-22,Fo=-24,st=-27,oe=-28,Gn=-36,Hn=-39,No=-40,Co=-75,Kn=0,Bn=4,Kr=8,gt=12,Ge=16,Et=20,Br=24,it=28,$r=32,xo=36,Ur=40,gc=44,Ec=48,Sc=52,$n=56,Wr=60,Gr=64,er=68,To=72,St=0,F=8,K=12,P=16,fe=24,ee=32,tr=40,ie=48,rr=88,wt=92,nr=96,ir=100,ue=104,He=112,vo=116,de=120,Lo=4,Le=8,zo=16,bo=20,Po=-2147483648,wc=2147483647,Oc=1034+1024*1024,Ve=Oc*4096,Ac={[Oe]:"No such file or directory",[B]:"I/O error",[re]:"Bad file descriptor",[Wn]:"Device or resource busy",[At]:"File exists",[ze]:"Not a directory",[ot]:"Is a directory",[j]:"Invalid argument",[Fo]:"Too many open files",[st]:"File too large",[oe]:"No space left on device",[Gn]:"File name too long",[Hn]:"Directory not empty",[No]:"Too many symbolic links",[Co]:"Value too large for data type"},I=class extends Error{constructor(t,r){super(r||Ac[t]||`Error ${t}`);this.code=t;this.name="SFSError"}code},me=new TextEncoder,sr=new TextDecoder,ko=me.encode("..");function Un(n){return n==="."||n===".."}function Ot(n){return n.buffer instanceof SharedArrayBuffer?sr.decode(new Uint8Array(n)):sr.decode(n)}function qe(n){return n+3&-4}var be=class n{constructor(e){this.buffer=e;this.view=new DataView(e),this.i32=new Int32Array(e),this.u8=new Uint8Array(e)}buffer;view;i32;u8;dirIndexes=new Map;blockAllocHint=0;inodeAllocHint=2;atomicsWaitAllowed;static DIR_INDEX_MIN_SIZE=64*1024;static mkfs(e,t){let r=e.byteLength;if(r<4096*16)throw new I(j);let i=Math.floor(r/4096),s=t?Math.floor(t/4096):i*4,o=Math.floor(s/4);o<32&&(o=32),o=Math.ceil(o/32)*32;let c=Math.ceil(o/(4096*8)),a=Math.ceil(s/(4096*8)),u=Math.ceil(o*128/4096),l=1,d=l+c,p=d+a,m=p+u;if(m>=i){let R=(m+1)*4096;try{e.grow(R)}catch{throw new I(oe)}if(i=Math.floor(e.byteLength/4096),m>=i)throw new I(oe)}new Uint8Array(e).fill(0);let f=new n(e);f.w32(Kn,Mn),f.w32(Bn,Dn),f.w32(Kr,4096),f.w32(gt,i),f.w32(Ge,o),f.w32(it,l),f.w32($r,d),f.w32(xo,p),f.w32(Ur,m),f.w32(gc,c),f.w32(Ec,a),f.w32(Sc,u),f.w32(er,s),f.w32(To,256);let h=d*4096;for(let R=0;R>2)+(R>>5);f.i32[x]|=1<<(R&31)}let g=i-m;Atomics.store(f.i32,Et>>2,g),f.blockAllocHint=m;let _=l*4096;f.i32[_>>2]|=3,Atomics.store(f.i32,Br>>2,o-2),f.inodeAllocHint=2;let y=f.inodeOffset(1);f.w32(y+F,H|493),f.w32(y+K,2),f.w64(y+ue,1);let E=f.blockAlloc();if(E<0)throw new I(oe);f.w32(y+ie,E);let O=E*4096,w=qe(k+1),S=qe(k+2);f.w32(O,1),f.view.setUint16(O+4,w,!0),f.view.setUint16(O+6,1,!0),f.u8[O+k]=46;let A=O+w;return f.w32(A,1),f.view.setUint16(A+4,S,!0),f.view.setUint16(A+6,2,!0),f.u8[A+k]=46,f.u8[A+k+1]=46,f.w64(y+P,w+S),Atomics.store(f.i32,$n>>2,1),f}static inspectImageCapacity(e){if(e.byteLengththis.snapshotBytesUnlocked(e))}snapshotState(e){return this.withNamespaceLock(()=>({bytes:this.snapshotBytesUnlocked(e),identities:this.collectIdentityStateUnlocked()}))}identityState(){return this.withNamespaceLock(()=>this.collectIdentityStateUnlocked())}snapshotBytesUnlocked(e){let t=e?.normalizeTimestampsMs;if(t!==void 0&&(!Number.isSafeInteger(t)||t<0))throw new I(j,"Snapshot timestamp must be a non-negative safe integer in milliseconds");let r=t===void 0?void 0:BigInt(t);for(let c=0;c>2)!==0)throw new I(Wn,"Cannot save a VFS image with open descriptors")}let i=this.r32(Ge);for(let c=0;c=1&&this.inodeIsAllocated(c)?r:0n;o.setBigUint64(a+tr,u,!0),o.setBigUint64(a+fe,u,!0),o.setBigUint64(a+ee,u,!0)}}return s}collectIdentityStateUnlocked(){let e=new Map,t=this.inodeOffset(1),r=this.r64(t+ue);e.set(`1:${r}`,{ino:1,generation:r,dataSequence:Atomics.load(this.i32,t+de>>2)>>>0,mode:this.r32(t+F),linkCount:this.r32(t+K),size:this.r64(t+P),uid:this.r32(t+nr),gid:this.r32(t+ir),paths:["/"]});let i=[{ino:1,path:"/"}],s=new Set;for(;i.length>0;){let o=i.pop();if(s.has(o.ino))throw new I(B);s.add(o.ino);let c=this.inodeOffset(o.ino);if((this.r32(c+F)&G)!==H)throw new I(B);let a=this.r64(c+P),u=0;for(;u>2)>>>0,mode:v,linkCount:this.r32(S+K),size:this.r64(S+P),uid:this.r32(S+nr),gid:this.r32(S+ir),...(v&G)===yt?{symlinkTarget:this.readSymlinkInodeUnlocked(_)}:{},paths:[]},e.set(R,x)}x.paths.push(w),(this.r32(S+F)&G)===H&&i.push({ino:_,path:w})}}h+=y}u+=f}}return e}statfs(){let e=this.r32(Kr),t=this.r32(gt),r=this.r32(er),i=typeof this.buffer.maxByteLength=="number"?this.buffer.maxByteLength:this.buffer.byteLength,s=Math.floor(i/e),o=Math.max(t,Math.min(r,s)),c=Atomics.load(this.i32,Et>>2),a=Math.max(0,o-t);return{blockSize:e,totalBlocks:o,freeBlocks:c+a,totalInodes:this.r32(Ge),freeInodes:Atomics.load(this.i32,Br>>2),maxName:255}}r32(e){return this.view.getUint32(e,!0)}w32(e,t){this.view.setUint32(e,t,!0)}r64(e){return Number(this.view.getBigUint64(e,!0))}w64(e,t){this.view.setBigUint64(e,BigInt(t),!0)}waitForAtomicChange(e,t){if(this.atomicsWaitAllowed!==!1)try{Atomics.wait(this.i32,e,t),this.atomicsWaitAllowed=!0;return}catch(r){if(!(r instanceof TypeError))throw r;this.atomicsWaitAllowed=!1}for(;Atomics.load(this.i32,e)===t;);}resetAllocationHints(){this.blockAllocHint=this.findNextFreeBlockHint(),this.inodeAllocHint=this.findNextFreeInodeHint()}findNextFreeBlockHint(){let e=this.r32(gt),t=this.r32(Ur),r=this.r32($r)*4096;for(let i=t;i>2)+(i>>5),o=i&31;if((Atomics.load(this.i32,s)&1<>2)+(r>>5),s=r&31;if((Atomics.load(this.i32,i)&1<>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}sbUnlock(){let e=Wr>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}namespaceLock(){let e=Gr>>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}namespaceUnlock(){let e=Gr>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}withNamespaceLock(e){this.namespaceLock();try{return e()}finally{this.namespaceUnlock()}}resetRestoredRuntimeState(){Atomics.store(this.i32,Wr>>2,0),Atomics.store(this.i32,Gr>>2,0),this.u8.fill(0,256,4096);let e=this.r32(Ge),t=this.r32(it)*4096;for(let r=0;r>5)*4)&1<<(r&31))===0||this.r32(i+K)!==0)continue;let o=this.r32(i+F),c=this.r64(i+P);(o&G)===yt&&c<=40?(this.u8.fill(0,i+ie,i+ie+40),this.w64(i+P,0)):this.inodeTruncate(r,0),this.inodeFree(r)}}blockAlloc(){let e=this.r32(gt),t=this.r32($r)*4096,r=this.r32(Ur),i=this.blockAllocHint>=r&&this.blockAllocHint>2)+(c>>5),u=c&31,l=Atomics.load(this.i32,a);if(l&1<>2,1),this.blockAllocHint=c+1>2)+(e>>5),i=e&31;for(;;){let s=Atomics.load(this.i32,r),o=s&~(1<>2,1),e>=this.r32(Ur)&&e>2)>0)return 0;let e=this.r32(gt),t=this.r32(er),r=this.r32(To),i=e+r;if(i>t&&(i=t,r=i-e,r===0))return oe;let s=i*4096;if(this.buffer.byteLength>2,r),Atomics.add(this.i32,$n>>2,1),this.blockAllocHint=e,0}finally{this.sbUnlock()}}inodeOffset(e){let r=this.r32(xo)+Math.floor(e/32),i=e%32*128;return r*4096+i}inodeAlloc(){let e=this.r32(Ge),t=this.r32(it)*4096,r=this.inodeAllocHint>=2&&this.inodeAllocHint>2)+(o>>5),a=o&31,u=Atomics.load(this.i32,c);if(u&1<>2,1),this.inodeAllocHint=o+1>2,1)+1}inodeFree(e){let r=(this.r32(it)*4096>>2)+(e>>5),i=e&31;for(;;){let s=Atomics.load(this.i32,r);if((s&1<>2,1),e>=2&&e0&&this.w32(r+He,i-1),i<=1&&this.r32(r+K)===0&&(this.inodeTruncate(e,0),t=!0)}finally{this.inodeWriteUnlock(e)}t&&this.inodeFree(e)}inodeDropLinkRefLocked(e){let t=this.inodeOffset(e),r=this.r32(t+K);return r>1?(this.w32(t+K,r-1),this.w64(t+ee,Date.now()),!1):this.inodeOrphanLocked(e)}inodeOrphanLocked(e){let t=this.inodeOffset(e);if(this.w32(t+K,0),this.w64(t+ee,Date.now()),this.r32(t+He)>0)return!1;let r=this.r32(t+F),i=this.r64(t+P);return(r&G)===yt&&i<=40?(this.u8.fill(0,t+ie,t+ie+40),this.w64(t+P,0)):this.inodeTruncate(e,0),!0}inodeReadLock(e){let t=this.inodeOffset(e)+St>>2;for(;;){let r=Atomics.load(this.i32,t);if(r&Po){this.waitForAtomicChange(t,r);continue}if(Atomics.compareExchange(this.i32,t,r,r+1)===r)return}}inodeReadUnlock(e){let t=this.inodeOffset(e)+St>>2;(Atomics.sub(this.i32,t,1)&wc)===1&&Atomics.notify(this.i32,t,1)}inodeWriteLock(e){let t=this.inodeOffset(e)+St>>2;for(;;){let r=Atomics.load(this.i32,t);if(r!==0){this.waitForAtomicChange(t,r);continue}if(Atomics.compareExchange(this.i32,t,0,Po)===0)return}}inodeWriteUnlock(e){let t=this.inodeOffset(e)+St>>2;Atomics.store(this.i32,t,0),Atomics.notify(this.i32,t,1/0)}inodeBlockMap(e,t,r){let i=this.inodeOffset(e);if(t<10){let s=this.r32(i+ie+t*4);if(s!==0)return s;if(!r)return 0;let o=this.blockAllocWithGrow();return o<0||this.w32(i+ie+t*4,o),o}if(t-=10,t<1024){let s=this.r32(i+rr),o=!1;if(s===0){if(!r)return 0;if(s=this.blockAllocWithGrow(),s<0)return s;this.w32(i+rr,s),o=!0}let c=s*4096+t*4,a=this.r32(c);if(a!==0)return a;if(!r)return 0;let u=this.blockAllocWithGrow();return u<0?(o&&(this.w32(i+rr,0),this.blockFree(s)),u):(this.w32(c,u),u)}if(t-=1024,t<1024*1024){let s=Math.floor(t/1024),o=t%1024,c=this.r32(i+wt),a=!1;if(c===0){if(!r)return 0;if(c=this.blockAllocWithGrow(),c<0)return c;this.w32(i+wt,c),a=!0}let u=c*4096+s*4,l=this.r32(u),d=!1;if(l===0){if(!r)return 0;if(l=this.blockAllocWithGrow(),l<0)return a&&(this.w32(i+wt,0),this.blockFree(c)),l;this.w32(u,l),d=!0}let p=l*4096+o*4,m=this.r32(p);if(m!==0)return m;if(!r)return 0;let f=this.blockAllocWithGrow();return f<0?(d&&(this.w32(u,0),this.blockFree(l)),a&&(this.w32(i+wt,0),this.blockFree(c)),f):(this.w32(p,f),f)}return j}inodeReadData(e,t,r,i){let s=this.inodeOffset(e),o=this.r64(s+P);if(t>=o)return 0;t+i>o&&(i=o-t);let c=0,a=0;for(;i>0;){let u=Math.floor(t/4096),l=t%4096,d=4096-l;d>i&&(d=i);let p=this.inodeBlockMap(e,u,!1);if(p<=0)r.fill(0,a,a+d);else{let m=p*4096+l;r.set(this.u8.subarray(m,m+d),a)}a+=d,t+=d,i-=d,c+=d}return c}inodeWriteData(e,t,r,i){let s=this.inodeOffset(e),o=this.r64(s+P);t>o&&this.zeroOldEofTail(e,o);let c=0,a=0;for(;i>0;){let u=Math.floor(t/4096),l=t%4096,d=4096-l;d>i&&(d=i);let p=this.inodeBlockMap(e,u,!0);if(p<0){if(c===0)return p;break}let m=p*4096+l;this.u8.set(r.subarray(a,a+d),m),a+=d,t+=d,i-=d,c+=d}if(c>0&&t>this.r64(s+P)&&this.w64(s+P,t),c>0){let u=Date.now();this.w64(s+fe,u),this.w64(s+ee,u),Atomics.add(this.i32,s+de>>2,1)}return c}zeroInodeRange(e,t,r){for(;t0){let a=c*4096+s;this.u8.fill(0,a,a+o)}t+=o}}zeroOldEofTail(e,t){let r=t%4096;if(r===0)return;let i=Math.floor(t/4096),s=this.inodeBlockMap(e,i,!1);if(s<=0)return;let o=s*4096+r;this.u8.fill(0,o,s*4096+4096)}freeBlocksFrom(e,t){let r=this.inodeOffset(e);for(let o=t;o<10;o++){let c=this.r32(r+ie+o*4);c&&(this.blockFree(c),this.w32(r+ie+o*4,0))}let i=this.r32(r+rr);if(i){let o=t>10?t-10:0;for(let c=o;c<1024;c++){let a=i*4096+c*4,u=this.r32(a);u&&(this.blockFree(u),this.w32(a,0))}o===0&&(this.blockFree(i),this.w32(r+rr,0))}let s=this.r32(r+wt);if(s){let o=t>1034?t-10-1024:0,c=Math.floor(o/1024);for(let a=c;a<1024;a++){let u=s*4096+a*4,l=this.r32(u);if(!l)continue;let d=a===c?o%1024:0;for(let p=d;p<1024;p++){let m=l*4096+p*4,f=this.r32(m);f&&(this.blockFree(f),this.w32(m,0))}d===0&&(this.blockFree(l),this.w32(u,0))}c===0&&(this.blockFree(s),this.w32(r+wt,0))}}inodeTruncate(e,t,r=!1){let i=this.inodeOffset(e),s=this.r64(i+P),o=t!==s;if(t>=s){if(t>s&&this.zeroOldEofTail(e,s),this.w64(i+P,t),o||r){let a=Date.now();this.w64(i+fe,a),this.w64(i+ee,a),Atomics.add(this.i32,i+de>>2,1)}return}t%4096!==0&&this.zeroInodeRange(e,t,Math.ceil(t/4096)*4096);let c=Math.ceil(t/4096);if(this.freeBlocksFrom(e,c),this.w64(i+P,t),o||r){let a=Date.now();this.w64(i+fe,a),this.w64(i+ee,a),Atomics.add(this.i32,i+de>>2,1)}}validateFileSize(e){if(!Number.isSafeInteger(e)||e<0)throw new I(j);if(e>Ve)throw new I(st)}validateSeekPosition(e){if(!Number.isSafeInteger(e))throw new I(Co);if(e<0)throw new I(j);if(e>Ve)throw new I(st)}touchDirectoryMutation(e){let t=this.inodeOffset(e),r=Date.now();this.w64(t+fe,r),this.w64(t+ee,r);let i=Atomics.add(this.i32,t+vo>>2,1)+1>>>0,s=this.dirIndexes.get(e);s&&(s.mutationSequence=i,s.size=this.r64(t+P))}dirNameKey(e){return Ot(e)}dirEntryNameMatches(e,t){if(this.view.getUint16(e+6,!0)!==t.length)return!1;for(let i=0;i=k&&r%4===0&&e+r<=t&&i<=r-k}inodeIsAllocated(e){let t=this.r32(Ge);if(e<=0||e>=t)return!1;let r=this.r32(it)*4096;return(Atomics.load(this.i32,(r>>2)+(e>>5))&1<<(e&31))!==0}rebuildDirIndex(e,t,r,i){let s=new Map,o=[],c=0;for(;c4096-l&&(m=4096-l);let f=l;for(;f=k&&o.push({abs:h,recLen:_});f+=_}c+=m}let a={generation:t,mutationSequence:r,size:i,entries:s,free:o};return this.dirIndexes.set(e,a),a}getDirIndex(e){let t=this.inodeOffset(e),r=this.r64(t+P),i=this.r64(t+ue),s=Atomics.load(this.i32,t+vo>>2)>>>0,o=this.dirIndexes.get(e);return o&&o.generation===i&&o.mutationSequence===s&&o.size===r?o:(o&&this.dirIndexes.delete(e),r=0;o--){let c=e.free[o];if(!(c.recLen4096-a&&(d=4096-a);let p=a;for(;pr)return-1;c=a,o+=u}return o===r?c:-1}dirAppendEntry(e,t,r,i=-1){let s=this.inodeOffset(e),o=this.r64(s+P),c=qe(k+t.length),a=o,u=Math.floor(a/4096),l=a%4096,d=0;if(l!==0&&l+c>4096){let f=4096-l,h=0;if(f>=k){if(h=this.inodeBlockMap(e,u,!1),h<=0)return B}else if(i<0&&(i=this.findLastDirEntryInBlock(e,u,l)),i<0)return B;if(d=this.inodeBlockMap(e,u+1,!0),d<0)return d;if(f>=k){let g=h*4096+l;this.w32(g,0),this.view.setUint16(g+4,f,!0),this.view.setUint16(g+6,0,!0)}else{let _=this.view.getUint16(i+4,!0)+f;this.view.setUint16(i+4,_,!0),this.updateDirIndexRecLen(e,i,_)}a=(u+1)*4096,u++,l=0}let p;if(l===0){if(p=d||this.inodeBlockMap(e,u,!0),p<0)return p}else if(p=this.inodeBlockMap(e,u,!1),p<=0)return B;let m=p*4096+l;return this.w32(m,r),this.view.setUint16(m+4,c,!0),this.view.setUint16(m+6,t.length,!0),this.u8.set(t,m+k),this.w64(s+P,a+c),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,m,c),0}dirAddEntry(e,t,r){let i=this.getDirIndex(e);if(typeof i=="number")return i;if(i)return this.useDirIndexFreeSlot(i,e,t,r)?0:this.dirAppendEntry(e,t,r);let s=this.inodeOffset(e),o=this.r64(s+P),c=qe(k+t.length),a=-1,u=0;for(;u4096-d&&(f=4096-d);let h=d;for(;hd+f||E>y-k)return B;if(_===0&&y>=c)return this.w32(g,r),this.view.setUint16(g+6,t.length,!0),this.u8.set(t,g+k),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,g,y),0;let O=qe(k+E),w=y-O;if(_!==0&&w>=c){this.view.setUint16(g+4,O,!0);let S=g+O;return this.w32(S,r),this.view.setUint16(S+4,w,!0),this.view.setUint16(S+6,t.length,!0),this.u8.set(t,S+k),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,S,w),0}a=g,h+=y}u+=f}return this.dirAppendEntry(e,t,r,a)}dirRemoveEntry(e,t){let r=this.getDirIndex(e);if(typeof r=="number")return r;if(r){let c=this.dirNameKey(t),a=r.entries.get(c);if(!a)return Oe;if(this.r32(a.abs)===a.ino&&this.view.getUint16(a.abs+4,!0)===a.recLen&&this.view.getUint16(a.abs+6,!0)===a.nameLen&&this.dirEntryNameMatches(a.abs,t))return this.w32(a.abs,0),r.entries.delete(c),r.free.push({abs:a.abs,recLen:a.recLen}),this.touchDirectoryMutation(e),0;r.entries.delete(c)}let i=this.inodeOffset(e),s=this.r64(i+P),o=0;for(;o4096-a&&(d=4096-a);let p=a;for(;p4096-u&&(p=4096-u);let m=u;for(;m4096-o&&(u=4096-o);let l=o;for(;lo+u||f>m-k)throw new I(B);if(p!==0){if(f===1&&this.u8[d+k]===46){l+=m;continue}if(f===2&&this.u8[d+k]===46&&this.u8[d+k+1]===46){l+=m;continue}return!1}l+=m}i+=u}return!0}dirIsAncestor(e,t){let r=t;for(let i=0;i<8*1024;i++){if(r===e)return!0;if(r===1)return!1;let s=this.dirLookup(r,ko);if(s<0||s===r)throw new I(B);r=s}throw new I(B)}pathResolve(e,t){if(!e.startsWith("/"))return Oe;let r=1,i=e.split("/").filter(o=>o.length>0),s=0;for(let o=0;o255)return Gn;let a=me.encode(c),u;this.inodeReadLock(r);try{let p=this.inodeOffset(r);if((this.r32(p+F)&G)!==H)return ze;u=this.dirLookup(r,a)}finally{this.inodeReadUnlock(r)}if(u<0)return u;let l=this.inodeOffset(u);if((this.r32(l+F)&G)===yt&&(!(o===i.length-1)||t)){if(++s>8)return No;let m=this.r64(l+P),f;if(m<=40)f=Ot(this.u8.subarray(l+ie,l+ie+m));else{let h=new Uint8Array(m);this.inodeReadData(u,0,h,m),f=sr.decode(h)}if(f.startsWith("/")){r=1;let h=f.split("/").filter(_=>_.length>0),g=i.slice(o+1);i.length=0,i.push(...h,...g),o=-1}else{let h=f.split("/").filter(_=>_.length>0),g=i.slice(o+1);i.length=o,i.push(...h,...g),o--}continue}r=u}return r}pathResolveParent(e){if(!e.startsWith("/"))throw new I(j,"Path must be absolute");let t=e.split("/").filter(a=>a.length>0);if(t.length===0)throw new I(j,"Cannot operate on /");let r=t.pop();if(r.length>255)throw new I(Gn);let i="/"+t.join("/"),s=this.pathResolve(i,!0);if(s<0)throw new I(s);let o=this.inodeOffset(s);if((this.r32(o+F)&G)!==H)throw new I(ze);return{parentIno:s,name:r}}fdAlloc(e,t,r){for(let i=0;i>2;if(Atomics.compareExchange(this.i32,o,0,1)===0)return this.w32(s+Lo,e),this.w64(s+Le,0),this.w32(s+zo,t),this.w32(s+bo,r?1:0),this.inodeAddOpenRef(e)?i:(Atomics.store(this.i32,o,0),Oe)}return Fo}fdGet(e){if(e<0||e>=Dr)return null;let t=256+e*24;return Atomics.load(this.i32,t>>2)?{base:t,ino:this.r32(t+Lo),offset:this.r64(t+Le),flags:this.r32(t+zo),isDir:this.r32(t+bo)!==0}:null}fdFree(e){if(e>=0&&e>2,0)}}buildStat(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+ue),dataSequence:this.r32(t+de),mode:this.r32(t+F),linkCount:this.r32(t+K),size:this.r64(t+P),mtime:this.r64(t+fe),ctime:this.r64(t+ee),atime:this.r64(t+tr),uid:this.r32(t+nr),gid:this.r32(t+ir)}}namespaceEntryIdentity(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+ue),linkCount:this.r32(t+K),mode:this.r32(t+F)}}open(e,t,r=420){return this.withNamespaceLock(()=>this.openUnlocked(e,t,r))}createLazyStub(e,t){return this.withNamespaceLock(()=>{let r=this.openUnlocked(e,Ro|or,t);try{let i=this.fdGet(r);if(!i)throw new I(re);this.inodeWriteLock(i.ino);try{return this.inodeTruncate(i.ino,0,!0),this.buildStat(i.ino)}finally{this.inodeWriteUnlock(i.ino)}}finally{this.closeUnlocked(r)}})}replaceIfIdentity(e,t,r,i,s){return this.withNamespaceLock(()=>{let o=this.pathResolve(e,!0);if(o<0||o!==t)return!1;let c=this.inodeOffset(o);if(this.r64(c+ue)!==r||this.r32(c+de)!==i||(this.r32(c+F)&G)!==Jt)return!1;this.validateFileSize(s.byteLength),this.inodeWriteLock(o);try{if(this.r64(c+ue)!==r||this.r32(c+de)!==i||this.r64(c+P)!==0)return!1;let a=this.r64(c+fe),u=this.r64(c+ee);this.inodeTruncate(o,0,!0);let l=s.byteLength>0?this.inodeWriteData(o,0,s,s.byteLength):0;if(l!==s.byteLength)throw this.inodeTruncate(o,0,!0),Atomics.store(this.i32,c+de>>2,i),this.w64(c+fe,a),this.w64(c+ee,u),new I(l<0?l:oe);return!0}finally{this.inodeWriteUnlock(o)}})}replaceManyIfIdentities(e,t=[]){return e.length===0&&t.length===0?!0:this.withNamespaceLock(()=>{let r=[],i=new Set,s=c=>{let a=this.pathResolve(c.path,!1);if(a<0||a!==c.expectedIno)return!1;let u=this.inodeOffset(a);return this.r64(u+ue)===c.expectedGeneration&&this.r32(u+de)===c.expectedDataSequence&&this.r32(u+F)===c.expectedMode&&this.r32(u+K)===c.expectedLinkCount&&this.r64(u+P)===c.expectedSize&&this.r32(u+nr)===c.expectedUid&&this.r32(u+ir)===c.expectedGid};for(let c of t)if(!s(c))return!1;for(let c of e){this.validateFileSize(c.data.byteLength);let a=-1;for(let u of c.paths){let l=this.pathResolve(u,!0);if(l!==c.expectedIno)continue;let d=this.inodeOffset(l);if(this.r64(d+ue)===c.expectedGeneration&&this.r32(d+de)===c.expectedDataSequence&&(this.r32(d+F)&G)===Jt&&this.r64(d+P)===0){a=l;break}}if(a<0)return!1;if(i.has(a))throw new I(j,"duplicate conditional replacement inode");i.add(a),r.push({...c,ino:a})}let o=[...i].sort((c,a)=>c-a);for(let c of o)this.inodeWriteLock(c);try{for(let u of r){let l=this.inodeOffset(u.ino);if(this.r64(l+ue)!==u.expectedGeneration||this.r32(l+de)!==u.expectedDataSequence||(this.r32(l+F)&G)!==Jt||this.r64(l+P)!==0)return!1}for(let u of t)if(!s(u))return!1;let c=r.map(u=>{let l=this.inodeOffset(u.ino);return{ino:u.ino,dataSequence:this.r32(l+de),mtime:this.r64(l+fe),ctime:this.r64(l+ee)}}),a=0;try{for(let u of r){a++,this.inodeTruncate(u.ino,0,!0);let l=u.data.byteLength>0?this.inodeWriteData(u.ino,0,u.data,u.data.byteLength):0;if(l!==u.data.byteLength)throw new I(l<0?l:oe)}}catch(u){for(let l=a-1;l>=0;l--){let d=c[l],p=this.inodeOffset(d.ino);this.inodeTruncate(d.ino,0,!0),Atomics.store(this.i32,p+de>>2,d.dataSequence),this.w64(p+fe,d.mtime),this.w64(p+ee,d.ctime)}throw u}return!0}finally{for(let c=o.length-1;c>=0;c--)this.inodeWriteUnlock(o[c])}})}openUnlocked(e,t,r=420){let i=t&Qt,s=(t&or)!==0,o=(t&Vn)!==0;if(s&&o){let d=this.pathResolve(e,!1);if(d>=0)throw new I(At);if(d!==Oe)throw new I(d)}let c=this.pathResolve(e,!0);if(c<0&&c===Oe&&s){let{parentIno:d,name:p}=this.pathResolveParent(e);this.inodeWriteLock(d);try{let m=me.encode(p),f=this.dirLookup(d,m);if(f>=0){if(o)throw new I(At);c=f}else{let h=this.inodeAlloc();if(h<0)throw new I(oe);let g=this.inodeOffset(h);this.w32(g+F,Jt|r&4095),this.w32(g+K,1),this.w64(g+P,0);let _=Date.now();this.w64(g+tr,_),this.w64(g+fe,_),this.w64(g+ee,_);let y=this.dirAddEntry(d,m,h);if(y<0)throw this.inodeFree(h),new I(y);c=h}}finally{this.inodeWriteUnlock(d)}}if(c<0)throw new I(c);let a=this.inodeOffset(c),u=this.r32(a+F);if((u&G)===H&&i!==nt)throw new I(ot);if(t&hc&&(u&G)!==H)throw new I(ze);if(t&ar){if((u&G)===H)throw new I(ot);this.inodeWriteLock(c),this.inodeTruncate(c,0,!0),this.inodeWriteUnlock(c)}let l=this.fdAlloc(c,t,!1);if(l<0)throw new I(l);return l}close(e){this.withNamespaceLock(()=>this.closeUnlocked(e))}closeUnlocked(e){let t=this.fdGet(e);if(!t)throw new I(re);this.fdFree(e),this.inodeDropOpenRef(t.ino)}read(e,t){let r=this.fdGet(e);if(!r)throw new I(re);let i=this.inodeOffset(r.ino);if((this.r32(i+F)&G)===H)throw new I(ot);this.inodeReadLock(r.ino);try{let o=this.inodeReadData(r.ino,r.offset,t,t.length),c=256+e*24;return this.w64(c+Le,r.offset+o),o}finally{this.inodeReadUnlock(r.ino)}}readAt(e,t,r){let i=this.fdGet(e);if(!i)throw new I(re);let s=this.inodeOffset(i.ino);if((this.r32(s+F)&G)===H)throw new I(ot);this.validateSeekPosition(r),this.inodeReadLock(i.ino);try{return this.inodeReadData(i.ino,r,t,t.length)}finally{this.inodeReadUnlock(i.ino)}}write(e,t){let r=this.fdGet(e);if(!r)throw new I(re);if((r.flags&Qt)===nt)throw new I(re);this.inodeWriteLock(r.ino);try{let s=r.offset;if(r.flags&fc){let a=this.inodeOffset(r.ino);s=this.r64(a+P)}if(!Number.isSafeInteger(s)||s<0)throw new I(j);if(s>Ve||t.length>Ve-s)throw new I(st);let o=this.inodeWriteData(r.ino,s,t,t.length);if(o<0)return o;let c=256+e*24;return this.w64(c+Le,s+o),o}finally{this.inodeWriteUnlock(r.ino)}}append(e,t,r){let i=this.fdGet(e);if(!i)throw new I(re);if((i.flags&Qt)===nt)throw new I(re);if(r!==null&&(!Number.isSafeInteger(r)||r<0))throw new I(j);this.inodeWriteLock(i.ino);try{let o=this.inodeOffset(i.ino),c=this.r64(o+P);if(!Number.isSafeInteger(c)||c<0)throw new I(j);if(c>Ve)throw new I(st);if(r!==null&&c>=r){let f=256+e*24;return this.w64(f+Le,c),{written:0,end:c}}let a=r===null?t.length:Math.min(t.length,r-c),u=Ve-c;if(a>u)throw new I(st);let l=t.subarray(0,a),d=this.inodeWriteData(i.ino,c,l,l.length);if(d<0)throw new I(d);let p=256+e*24,m=c+d;return this.w64(p+Le,m),{written:d,end:m}}finally{this.inodeWriteUnlock(i.ino)}}writeAt(e,t,r){let i=this.fdGet(e);if(!i)throw new I(re);if((i.flags&Qt)===nt)throw new I(re);this.validateSeekPosition(r),this.inodeWriteLock(i.ino);try{if(r>Ve||t.length>Ve-r)throw new I(st);return this.inodeWriteData(i.ino,r,t,t.length)}finally{this.inodeWriteUnlock(i.ino)}}lseek(e,t,r){let i=this.fdGet(e);if(!i)throw new I(re);let s;if(r===pc)s=t;else if(r===mc)s=i.offset+t;else if(r===_c){let c=this.inodeOffset(i.ino);s=this.r64(c+P)+t}else throw new I(j);this.validateSeekPosition(s);let o=256+e*24;return this.w64(o+Le,s),s}ftruncate(e,t){let r=this.fdGet(e);if(!r)throw new I(re);if((r.flags&Qt)===nt)throw new I(re);this.validateFileSize(t),this.inodeWriteLock(r.ino);try{this.inodeTruncate(r.ino,t,!0)}finally{this.inodeWriteUnlock(r.ino)}}fstat(e){let t=this.fdGet(e);if(!t)throw new I(re);this.inodeReadLock(t.ino);try{return this.buildStat(t.ino)}finally{this.inodeReadUnlock(t.ino)}}stat(e){return this.withNamespaceLock(()=>this.statUnlocked(e))}statUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new I(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}lstat(e){return this.withNamespaceLock(()=>this.lstatUnlocked(e))}lstatUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new I(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}unlink(e){return this.withNamespaceLock(()=>this.unlinkUnlocked(e))}unlinkUnlocked(e){let{parentIno:t,name:r}=this.pathResolveParent(e),i=me.encode(r),s=e.length>1&&e.endsWith("/");this.inodeWriteLock(t);try{let o=this.dirLookup(t,i);if(o<0)throw new I(o);let c=this.inodeOffset(o),a=this.r32(c+F);if(s&&(a&G)!==H)throw new I(ze);if((a&G)===H)throw new I(ot);let u=this.namespaceEntryIdentity(o),l=this.dirRemoveEntry(t,i);if(l<0)throw new I(l);let d=!1;this.inodeWriteLock(o);try{d=this.inodeDropLinkRefLocked(o)}finally{this.inodeWriteUnlock(o)}return d&&this.inodeFree(o),u}finally{this.inodeWriteUnlock(t)}}rename(e,t){return this.withNamespaceLock(()=>this.renameUnlocked(e,t))}renameUnlocked(e,t){let{parentIno:r,name:i}=this.pathResolveParent(e),{parentIno:s,name:o}=this.pathResolveParent(t);if(Un(i)||Un(o))throw new I(j);let c=me.encode(i),a=me.encode(o),u=e.length>1&&e.endsWith("/"),l=t.length>1&&t.endsWith("/"),d=Math.min(r,s),p=Math.max(r,s);this.inodeWriteLock(d),d!==p&&this.inodeWriteLock(p);try{let m=this.dirLookup(r,c);if(m<0)throw new I(m);let f=this.inodeOffset(m),g=this.r32(f+F)&G,_=this.namespaceEntryIdentity(m);if((u||l)&&g!==H)throw new I(ze);if(g===H&&this.dirIsAncestor(m,s))throw new I(j);let y=this.dirLookup(s,a),E=!1,O;if(y>=0){if(y===m)return{source:_,replaced:_};O=this.namespaceEntryIdentity(y);let S=this.inodeOffset(y),R=this.r32(S+F)&G;if(g===H&&R!==H)throw new I(ze);if(g!==H&&R===H)throw new I(ot);let x=!1,v=y===r||y===s;v||this.inodeWriteLock(y);try{if(R===H&&!this.dirIsEmpty(y))throw new I(Hn);let L=this.dirReplaceEntryIno(s,a,m);if(L<0)throw new I(L);x=R===H?this.inodeOrphanLocked(y):this.inodeDropLinkRefLocked(y)}finally{v||this.inodeWriteUnlock(y)}x&&this.inodeFree(y),E=R===H}else{let S=this.dirAddEntry(s,a,m);if(S<0)throw new I(S)}let w=this.dirRemoveEntry(r,c);if(w<0)throw new I(w);if(g===H){if(r!==s){let S=this.inodeOffset(r);this.w32(S+K,this.r32(S+K)-1);let A=this.inodeOffset(s);this.w32(A+K,this.r32(A+K)+1),this.inodeWriteLock(m);try{let R=this.dirReplaceEntryIno(m,ko,s);if(R<0)throw new I(R);this.w64(f+ee,Date.now())}finally{this.inodeWriteUnlock(m)}}if(E){let S=this.inodeOffset(s);this.w32(S+K,this.r32(S+K)-1)}}else if(E){let S=this.inodeOffset(s);this.w32(S+K,this.r32(S+K)-1)}return{source:_,replaced:O}}finally{d!==p&&this.inodeWriteUnlock(p),this.inodeWriteUnlock(d)}}mkdir(e,t=493){this.withNamespaceLock(()=>this.mkdirUnlocked(e,t))}mkdirUnlocked(e,t=493){let{parentIno:r,name:i}=this.pathResolveParent(e),s=me.encode(i);this.inodeWriteLock(r);try{if(this.dirLookup(r,s)>=0)throw new I(At);let c=this.inodeAlloc();if(c<0)throw new I(oe);let a=this.inodeOffset(c);this.w32(a+F,H|t),this.w32(a+K,2),this.w64(a+P,0);let u=Date.now();this.w64(a+tr,u),this.w64(a+fe,u),this.w64(a+ee,u);let l=this.blockAllocWithGrow();if(l<0)throw this.inodeFree(c),new I(oe);this.w32(a+ie,l);let d=l*4096,p=qe(k+1),m=qe(k+2);this.w32(d,c),this.view.setUint16(d+4,p,!0),this.view.setUint16(d+6,1,!0),this.u8[d+k]=46;let f=d+p;this.w32(f,r),this.view.setUint16(f+4,m,!0),this.view.setUint16(f+6,2,!0),this.u8[f+k]=46,this.u8[f+k+1]=46,this.w64(a+P,p+m);let h=this.dirAddEntry(r,s,c);if(h<0)throw this.blockFree(l),this.inodeFree(c),new I(h);let g=this.inodeOffset(r);this.w32(g+K,this.r32(g+K)+1)}finally{this.inodeWriteUnlock(r)}}rmdir(e){this.withNamespaceLock(()=>this.rmdirUnlocked(e))}rmdirUnlocked(e){let{parentIno:t,name:r}=this.pathResolveParent(e);if(Un(r))throw new I(j);let i=me.encode(r);this.inodeWriteLock(t);try{let s=this.dirLookup(t,i);if(s<0)throw new I(s);let o=this.inodeOffset(s);if((this.r32(o+F)&G)!==H)throw new I(ze);let a=!1;this.inodeWriteLock(s);try{if(!this.dirIsEmpty(s))throw new I(Hn);let l=this.dirRemoveEntry(t,i);if(l<0)throw new I(l);a=this.inodeOrphanLocked(s)}finally{this.inodeWriteUnlock(s)}a&&this.inodeFree(s);let u=this.inodeOffset(t);this.w32(u+K,this.r32(u+K)-1)}finally{this.inodeWriteUnlock(t)}}symlink(e,t){this.withNamespaceLock(()=>this.symlinkUnlocked(e,t))}symlinkUnlocked(e,t){let{parentIno:r,name:i}=this.pathResolveParent(t),s=me.encode(i),o=me.encode(e);this.inodeWriteLock(r);try{if(this.dirLookup(r,s)>=0)throw new I(At);let a=this.inodeAlloc();if(a<0)throw new I(oe);let u=this.inodeOffset(a);if(this.w32(u+F,yt|511),this.w32(u+K,1),o.length<=40)this.u8.set(o,u+ie),this.w64(u+P,o.length);else{this.w64(u+P,0);let d=this.inodeWriteData(a,0,o,o.length);if(d!==o.length)throw d>0&&this.inodeTruncate(a,0),this.inodeFree(a),new I(d<0?d:oe)}let l=this.dirAddEntry(r,s,a);if(l<0)throw o.length<=40?(this.u8.fill(0,u+ie,u+ie+40),this.w64(u+P,0)):this.inodeTruncate(a,0),this.inodeFree(a),new I(l)}finally{this.inodeWriteUnlock(r)}}chmod(e,t){this.withNamespaceLock(()=>this.chmodUnlocked(e,t))}chmodUnlocked(e,t){let r=this.pathResolve(e,!0);if(r<0)throw new I(r);this.inodeWriteLock(r);try{let i=this.inodeOffset(r),s=this.r32(i+F);this.w32(i+F,s&G|t&4095),this.w64(i+ee,Date.now())}finally{this.inodeWriteUnlock(r)}}fchmod(e,t){let r=this.fdGet(e);if(!r)throw new I(re);this.inodeWriteLock(r.ino);try{let i=this.inodeOffset(r.ino),s=this.r32(i+F);this.w32(i+F,s&G|t&4095),this.w64(i+ee,Date.now())}finally{this.inodeWriteUnlock(r.ino)}}chown(e,t,r){this.withNamespaceLock(()=>this.chownUnlocked(e,t,r))}chownUnlocked(e,t,r){let i=this.pathResolve(e,!0);if(i<0)throw new I(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,r)}finally{this.inodeWriteUnlock(i)}}fchown(e,t,r){let i=this.fdGet(e);if(!i)throw new I(re);this.inodeWriteLock(i.ino);try{this.chownInodeUnlocked(i.ino,t,r)}finally{this.inodeWriteUnlock(i.ino)}}lchown(e,t,r){this.withNamespaceLock(()=>this.lchownUnlocked(e,t,r))}lchownUnlocked(e,t,r){let i=this.pathResolve(e,!1);if(i<0)throw new I(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,r)}finally{this.inodeWriteUnlock(i)}}chownInodeUnlocked(e,t,r){let i=this.inodeOffset(e);t!==Io&&this.w32(i+nr,t),r!==Io&&this.w32(i+ir,r);let s=this.r32(i+F);(s&G)===Jt&&(s&lc)!==0&&this.w32(i+F,s&~(uc|dc)),this.w64(i+ee,Date.now())}utimens(e,t,r,i,s){this.withNamespaceLock(()=>this.utimensUnlocked(e,t,r,i,s))}utimensUnlocked(e,t,r,i,s){let o=this.pathResolve(e,!0);if(o<0)throw new I(o);this.inodeWriteLock(o);try{let c=this.inodeOffset(o),a=1073741823,u=1073741822,l=Date.now();if(r!==u){let d=r===a?l:t*1e3+Math.floor(r/1e6);this.w64(c+tr,d)}if(s!==u){let d=s===a?l:i*1e3+Math.floor(s/1e6);this.w64(c+fe,d)}this.w64(c+ee,l)}finally{this.inodeWriteUnlock(o)}}link(e,t){return this.withNamespaceLock(()=>this.linkUnlocked(e,t))}linkUnlocked(e,t){let r=this.pathResolve(e,!1);if(r<0)throw new I(r);let i=this.inodeOffset(r);if((this.r32(i+F)&G)===H)throw new I(yc);let{parentIno:o,name:c}=this.pathResolveParent(t),a=me.encode(c);this.inodeWriteLock(o);try{if(this.dirLookup(o,a)>=0)throw new I(At);let l=this.dirAddEntry(o,a,r);if(l<0)throw new I(l);this.inodeWriteLock(r);try{let d=this.r32(i+K);this.w32(i+K,d+1),this.w64(i+ee,Date.now())}finally{this.inodeWriteUnlock(r)}return{...this.namespaceEntryIdentity(r),linkCount:this.r32(i+K)}}finally{this.inodeWriteUnlock(o)}}readlink(e){return this.withNamespaceLock(()=>this.readlinkUnlocked(e))}readlinkUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new I(t);return this.readSymlinkInodeUnlocked(t)}readSymlinkInodeUnlocked(e){let t=this.inodeOffset(e);if((this.r32(t+F)&G)!==yt)throw new I(j);let i=this.r64(t+P);if(i<=40)return Ot(this.u8.subarray(t+ie,t+ie+i));this.inodeReadLock(e);try{let s=new Uint8Array(i);return this.inodeReadData(e,0,s,i),sr.decode(s)}finally{this.inodeReadUnlock(e)}}opendir(e){return this.withNamespaceLock(()=>this.opendirUnlocked(e))}opendirUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new I(t);let r=this.inodeOffset(t);if((this.r32(r+F)&G)!==H)throw new I(ze);let s=this.fdAlloc(t,nt,!0);if(s<0)throw new I(s);return s}readdirEntry(e){return this.withNamespaceLock(()=>this.readdirEntryUnlocked(e))}readdirEntryUnlocked(e){let t=this.fdGet(e);if(!t||!t.isDir)throw new I(re);let r=this.inodeOffset(t.ino),i=this.r64(r+P);for(;t.offset=this.r32(Ge))throw new I(B);let h=this.r32(it)*4096;if((this.r32(h+(l>>5)*4)&1<<(l&31))===0)throw new I(B);let _=Ot(this.u8.subarray(u+k,u+k+p)),y=this.buildStat(l);return this.w64(f+Le,m),t.offset=m,{name:_,stat:y}}return null}closedir(e){this.close(e)}readdir(e){let t=this.opendir(e),r=[];try{let i;for(;(i=this.readdirEntry(t))!==null;)i.name!=="."&&i.name!==".."&&r.push(i.name)}finally{this.closedir(t)}return r}writeFile(e,t){let r=typeof t=="string"?me.encode(t):t,i=this.open(e,Ro|or|ar);try{this.write(i,r)}finally{this.close(i)}}readFile(e){let t=this.open(e,nt);try{let r=this.fstat(t),i=new Uint8Array(r.size);return this.read(t,i),i}finally{this.close(t)}}readFileText(e){return sr.decode(this.readFile(e))}};function Mo(n,e){let t=new Map,r=new Map;for(let o of n){if(t.has(o.path))throw new Error(`${e} duplicates path ${o.path}`);if(t.set(o.path,o),o.type==="file"){if(!o.inodeGroup)throw new Error(`${e} file ${o.path} has no inode group`);if(r.has(o.inodeGroup))throw new Error(`${e} inode group ${o.inodeGroup} has multiple files`);r.set(o.inodeGroup,o)}}let i=new Set,s=new Map;for(let o of n){if(o.type!=="hardlink"||s.has(o.path))continue;let c=[],a=o,u;for(;a.type==="hardlink";){let d=s.get(a.path);if(d){u=d;break}if(i.has(a.path))throw new Error(`${e} hardlink cycle reaches ${a.path}`);if(i.add(a.path),c.push(a),!a.target)throw new Error(`${e} hardlink ${a.path} has no target`);let p=t.get(a.target);if(!p)throw new Error(`${e} hardlink ${a.path} target ${a.target} is missing`);if(p.type!=="file"&&p.type!=="hardlink"||!a.inodeGroup||p.inodeGroup!==a.inodeGroup||p.size!==a.size||p.mode!==a.mode)throw new Error(`${e} hardlink ${a.path} has an invalid target`);a=p}u??=a.type==="file"?a:void 0;let l=r.get(o.inodeGroup??"");if(!u||u!==l)throw new Error(`${e} hardlink ${o.path} does not resolve to its inode`);for(let d=c.length-1;d>=0;d-=1){let p=c[d];if(r.get(p.inodeGroup??"")!==u)throw new Error(`${e} hardlink ${p.path} does not resolve to its inode`);i.delete(p.path),s.set(p.path,u)}}return{canonicalByGroup:r,canonicalTargetByPath:s}}var he={maxArchiveBytes:268435456,maxExpandedBytes:268435456,maxPayloadBytes:268435456,maxEntries:1e5,maxPathBytes:4096,maxSymlinkTargetBytes:65536,maxStringBytes:8192,maxTransportsPerTree:8,maxActivationCapabilities:32,maxActivationRoots:64,maxActivationCapabilityBytes:255},xe={maxArchiveBytes:512*1024*1024,maxExpandedBytes:512*1024*1024,maxPayloadBytes:512*1024*1024,maxEntries:1e5,maxGroups:512};function Do(n,e="Deferred tree collection"){for(let[t,r]of Object.entries(n))if(!Number.isSafeInteger(r)||r<0)throw new Error(`${e} ${t} usage is invalid`);if(n.groups>xe.maxGroups)throw new Error(`${e} exceeds the ${xe.maxGroups}-group cap`);if(n.archiveBytes>xe.maxArchiveBytes)throw new Error(`${e} exceeds the archive-byte cap`);if(n.expandedBytes>xe.maxExpandedBytes)throw new Error(`${e} exceeds the expansion cap`);if(n.payloadBytes>xe.maxPayloadBytes)throw new Error(`${e} exceeds the payload-byte cap`);if(n.entries>xe.maxEntries)throw new Error(`${e} exceeds the entry-count cap`)}var Ko=Object.freeze({prefix:"/opt/kandelo/homebrew",cellar:"/opt/kandelo/homebrew/Cellar",repository:"/opt/kandelo/homebrew",stableEntrypoint:"/usr/bin/brew"});var Bo=1e5,Ic=4096;var It=Ko.prefix,Wo=[["@@HOMEBREW_PREFIX@@",It],["@@HOMEBREW_CELLAR@@",`${It}/Cellar`],["@@HOMEBREW_REPOSITORY@@",It],["@@HOMEBREW_LIBRARY@@",`${It}/Library`],["@@HOMEBREW_PERL@@",`${It}/opt/perl/bin/perl`]],qn="@@HOMEBREW_JAVA@@",Rc=/^openjdk(?:@\d+(?:\.\d+)*)?/,Rt=new TextEncoder,xc=[...Wo.map(([n])=>n),qn].map(n=>({placeholder:n,bytes:Rt.encode(n)}));function Go(n){let e=Tc(n),t=e.changed_files;if(t!=null&&!Array.isArray(t))throw new Error("INSTALL_RECEIPT.json changed_files must be an array or null when present");let r=Array.isArray(t)?t:[];if(r.length>Bo)throw new Error(`INSTALL_RECEIPT.json declares ${r.length} changed files, limit ${Bo}`);let i=[],s=new Set;for(let[o,c]of r.entries()){if(typeof c!="string")throw new Error(`INSTALL_RECEIPT.json changed_files[${o}] is not a string`);if(Lc(c,"Homebrew changed file"),s.has(c))throw new Error(`INSTALL_RECEIPT.json repeats changed file ${c}`);s.add(c),i.push(c)}return{changedFiles:i,runtimeDependencies:e.runtime_dependencies}}function Tc(n){let e;try{e=JSON.parse(new TextDecoder("utf-8",{fatal:!0}).decode(n))}catch(t){throw new Error("INSTALL_RECEIPT.json is not valid UTF-8 JSON: "+bc(t))}if(typeof e!="object"||e===null||Array.isArray(e))throw new Error("INSTALL_RECEIPT.json must contain an object");return e}function Ho(n,e,t){let r=n;for(let[o,c]of Wo)r=Uo(r,Rt.encode(o),Rt.encode(c));let i=Rt.encode(qn);if($o(r,i)){let o=vc(e.runtimeDependencies);if(o===void 0)throw new Error(`Homebrew changed file ${t} uses ${qn} without exactly one OpenJDK runtime dependency`);r=Uo(r,i,Rt.encode(o))}let s=xc.find(({bytes:o})=>$o(r,o));if(s!==void 0)throw new Error(`Homebrew changed file ${t} retains ${s.placeholder}`);return r}function vc(n){if(!Array.isArray(n))return;let e=[];for(let r of n){if(typeof r!="object"||r===null||Array.isArray(r))continue;let i=r,s=typeof i.full_name=="string"?i.full_name.split("/").at(-1):typeof i.name=="string"?i.name.split("/").at(-1):void 0,o=s===void 0?null:Rc.exec(s);s!==void 0&&o?.[0]===s&&e.push(s)}let t=[...new Set(e)];return t.length===1?`${It}/opt/${t[0]}/libexec`:void 0}function Lc(n,e){if(n.length===0||n.startsWith("/")||n.includes("\\")||n.includes("\0")||zc(n)||Rt.encode(n).byteLength>Ic||n.split("/").some(t=>t===""||t==="."||t===".."))throw new Error(`${e} has an unsafe path segment: ${n}`)}function zc(n){for(let e=0;e57343)){if(t<=56319&&e+1=56320&&n.charCodeAt(e+1)<=57343){e+=1;continue}return!0}}return!1}function $o(n,e){if(e.byteLength===0||e.byteLength>n.byteLength)return!1;e:for(let t=0;t<=n.byteLength-e.byteLength;t+=1){for(let r=0;rnn||n.includes("\0")||n.includes("\\"))throw new Error(`Lazy archive mount prefix must be an absolute POSIX path: ${JSON.stringify(n)}`);let e=n.replace(/\/+$/,"");if(e==="")return"/";if(e.slice(1).split("/").some(r=>r===""||r==="."||r===".."))throw new Error(`Lazy archive mount prefix is not canonical: ${JSON.stringify(n)}`);return e}function bu(n,e,t,r){let i=yr(t),s=new Map,o=e.map(c=>{let a=c.fileName,u=`Lazy archive ${JSON.stringify(n)} member ${JSON.stringify(a)}`;if(a.length===0)throw new Error(`${u} has an empty path`);if(a.includes("\0"))throw new Error(`${u} contains a NUL byte`);if(a.includes("\\"))throw new Error(`${u} contains a backslash`);if(a.startsWith("/")||/^[A-Za-z]:\//.test(a))throw new Error(`${u} must be relative, not absolute`);if(c.isDirectory&&c.isSymlink)throw new Error(`${u} has conflicting directory and symlink types`);if(c.isDirectory!==a.endsWith("/"))throw new Error(`${u} has inconsistent directory metadata`);let l=c.isDirectory?a.slice(0,-1):a,d=l.split("/");if(l.length===0||d.some(p=>p===""||p==="."||p===".."))throw new Error(`${u} is not a canonical relative POSIX path`);if(s.has(l))throw new Error(`${u} collides with another member at ${JSON.stringify(l)}`);if(c.isSymlink&&!r?.has(a))throw new Error(`Lazy archive symlink target was not provided: ${a}`);return s.set(l,c),{entry:c,archivePath:l,vfsPath:i==="/"?`/${l}`:`${i}/${l}`}});for(let{archivePath:c}of o){let a=c.split("/");for(let u=1;uzt)throw new Error(`VFS image metadata exceeds ${zt} bytes`);let e;try{e=JSON.parse(new TextDecoder().decode(n))}catch(t){let r=t instanceof Error?t.message:String(t);throw new Error(`Invalid VFS image metadata JSON: ${r}`)}return Si(e)}function Fu(n){if(n===null)return new Uint8Array(0);let e=Si(n),t=new TextEncoder().encode(JSON.stringify(e));if(t.byteLength>zt)throw new Error(`VFS image metadata exceeds ${zt} bytes`);return t}function Nu(n){return n.byteLength>=fr.length&&n[0]===fr[0]&&n[1]===fr[1]&&n[2]===fr[2]&&n[3]===fr[3]?rd(n):n}function qr(n){let e=Nu(n);if(e.byteLengthYr)throw new Error(`VFS image lazy metadata exceeds ${Yr} bytes`);if(n.byteLengthjr)throw new Error(`VFS image lazy archive metadata exceeds ${jr} bytes`);if(n.byteLength=0?r:void 0}function Du(n){return n===408||n===429||n>=500&&n<=599}function Ku(n,e=Date.now()){let t=n?.get("retry-after")?.trim();if(!t)return;let r;if(/^\d+$/.test(t))r=Number(t)*1e3;else{let i=Date.parse(t);if(!Number.isFinite(i))return;r=Math.max(0,i-e)}if(!(!Number.isSafeInteger(r)||r<0))return Math.min(r,Ps)}function Bu(n){if(!(typeof n!="object"||n===null||!("cause"in n)))return n.cause}function ks(n){if(!(typeof n!="object"||n===null||!("name"in n)))return typeof n.name=="string"?n.name:void 0}function Fs(n){if(!(typeof n!="object"||n===null||!("code"in n)))return typeof n.code=="string"?n.code:void 0}function Ns(n,e){let t=new Set,r=n;for(let i=0;r!==void 0&&i<8;i+=1){if(t.has(r))return!1;if(t.add(r),e(r))return!0;r=Bu(r)}return!1}function Cs(n){return Ns(n,e=>ks(e)==="AbortError"||Fs(e)==="ABORT_ERR")}function $u(n){return Cs(n)?!1:Ns(n,e=>{let t=ks(e),r=Fs(e);return e instanceof TypeError||t==="NetworkError"||t==="TimeoutError"||r!==void 0&&zu.has(r)})}function Uu(n,e){if(n instanceof Qr){if(!Du(n.status))return null;if(n.retryAfterMs!==void 0)return n.retryAfterMs}else if(!$u(n))return null;return Math.min(Lu*2**e,Ps)}function te(n){if(n?.aborted)throw n.reason}function Wu(n,e){return te(e),n===0?Promise.resolve():new Promise((t,r)=>{let i=setTimeout(()=>c(!1),n),s=()=>c(!0,e.reason),o=!1;function c(a,u){o||(o=!0,clearTimeout(i),e?.removeEventListener("abort",s),a?r(u):t())}e?.addEventListener("abort",s,{once:!0}),e?.aborted&&s()})}async function di(n,e){try{await n.body?.cancel(e)}catch{}}function Gu(n,e){if(n.length===1)return n[0];let t=new Uint8Array(e),r=0;for(let i of n)t.set(i,r),r+=i.byteLength;return t}function gr(n){if(n===void 0)return;if(typeof n!="object"||n===null||Array.isArray(n))throw new Error("Lazy archive integrity must be an object");let e=n;if(Object.keys(e).length!==2||!("sha256"in e)||!("bytes"in e))throw new Error("Lazy archive integrity has unexpected fields");if(typeof e.sha256!="string"||!pi.test(e.sha256))throw new Error("Lazy archive integrity has an invalid SHA-256 digest");if(!Number.isSafeInteger(e.bytes)||Number(e.bytes)<=0||Number(e.bytes)>gs)throw new Error(`Lazy archive integrity byte count must be between 1 and ${gs}`);return{sha256:e.sha256,bytes:Number(e.bytes)}}function Ze(n,e,t){if(typeof n!="object"||n===null||Array.isArray(n))throw new Error(`${t} must be an object`);let r=n;if(Object.keys(r).length!==e.length||e.some(s=>!Object.prototype.hasOwnProperty.call(r,s)))throw new Error(`${t} has unexpected or missing fields`);return r}function _i(n,e,t,r){if(typeof n!="object"||n===null||Array.isArray(n))throw new Error(`${r} must be an object`);let i=n,s=new Set(e);if(Object.keys(i).some(o=>!s.has(o))||t.some(o=>!Object.prototype.hasOwnProperty.call(i,o)))throw new Error(`${r} has unexpected or missing fields`);return i}function Ne(n,e,t,r){if(!Array.isArray(n)||n.lengthr)throw new Error(`${e} must contain ${t} to ${r} items`);return n}function ye(n,e,t){if(typeof n!="string"||n.length===0||n.includes("\0")||new TextEncoder().encode(n).byteLength>t)throw new Error(`${e} is invalid or exceeds ${t} bytes`);return n}function ae(n,e,t,r){if(!Number.isSafeInteger(n)||Number(n)r)throw new Error(`${e} must be an integer between ${t} and ${r}`);return Number(n)}function en(n,e=1){let t=n,r=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.source!==void 0,i=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.modePolicy!==void 0,s=Ze(n,["decoder","mediaType","sha256","bytes","expandedBytes","sourceEntryCount","transports",...i?["modePolicy"]:[],...r?["source"]:[]],"Lazy tree content"),o=s.decoder==="zip-v1"?"application/zip":s.decoder==="homebrew-bottle-tar-gzip-v1"?"application/vnd.oci.image.layer.v1.tar+gzip":null;if(o===null||s.mediaType!==o)throw new Error("Lazy tree decoder and media type are inconsistent");let c=gr({sha256:s.sha256,bytes:s.bytes});if(!c)throw new Error("Lazy tree integrity is required");let a=Ne(s.transports,"Lazy tree transports",e,he.maxTransportsPerTree).map((m,f)=>ye(m,`Lazy tree transport ${f}`,Ei));if(new Set(a).size!==a.length)throw new Error("Lazy tree transports contain duplicates");let u=ae(s.expandedBytes,"Lazy tree expanded byte count",0,Iu),l=ae(s.sourceEntryCount,"Lazy tree source entry count",1,bt),d=r?qu(s.source,s.decoder):void 0,p=i?s.modePolicy:void 0;if(p!==void 0&&(p!=="portable-posix-v1"||s.decoder!=="zip-v1"||r))throw new Error("Lazy tree mode policy is invalid for its decoder");if(d!==void 0&&d.entries.length!==l)throw new Error("Lazy tree source inventory count differs from its content");return{decoder:s.decoder,mediaType:o,sha256:c.sha256,bytes:c.bytes,expandedBytes:u,sourceEntryCount:l,transports:a,...p===void 0?{}:{modePolicy:p},...d===void 0?{}:{source:d}}}function Ms(n){let e={groups:n.length,archiveBytes:0,expandedBytes:0,payloadBytes:0,entries:0};for(let t of n)t.content===void 0||t.inventory===void 0||(e.archiveBytes+=t.content.bytes,e.expandedBytes+=t.content.expandedBytes,e.payloadBytes+=t.inventory.filter(r=>r.type==="file").reduce((r,i)=>r+i.size,0),e.entries+=t.inventory.length+(t.content.source?.entries.length??0));return e}function yi(n){Do(n,"Serialized lazy tree collection")}function Hu(n){yi(Ms(n))}function Vu(n){let e=new Map;for(let t of n){let r=t.activation?.atomicGroup;if(r===void 0)continue;if(!Lt(r))throw new Error(`Serialized lazy atomic activation group ${r.id} is unsealed`);let i=e.get(r.id);if(i===void 0)i={expectedCount:r.expectedCount,cohortSha256:r.cohortSha256,members:new Set,descriptors:new Set},e.set(r.id,i);else if(i.expectedCount!==r.expectedCount||i.cohortSha256!==r.cohortSha256)throw new Error(`Serialized lazy atomic activation group ${r.id} has inconsistent seals`);if(i.members.has(r.member)||i.descriptors.has(r.descriptorSha256))throw new Error(`Serialized lazy atomic activation group ${r.id} duplicates a member`);i.members.add(r.member),i.descriptors.add(r.descriptorSha256)}for(let[t,r]of e)if(r.members.size!==r.expectedCount)throw new Error(`Serialized lazy atomic activation group ${t} has ${r.members.size} of ${r.expectedCount} members`)}function Is(n){for(let[e,t]of n.entries())if(t.kind===_r||t.kind===mi||t.kind===ct)$s(t,t.kind);else if(t.kind===mr)gi(t,!1);else throw new Error(`Serialized lazy archive group ${e} has an unsupported kind`);Hu(n),Vu(n)}function qu(n,e){if(e!=="homebrew-bottle-tar-gzip-v1")throw new Error("Lazy tree source inventory is valid only for original bottles");let t=Ze(n,["schema","kind","entries"],"Lazy tree source inventory");if(t.schema!==1||t.kind!=="homebrew-bottle-tar-gzip-v1")throw new Error("Lazy tree source inventory has an unsupported identity");let r=new Map,i=Ne(t.entries,"Lazy tree source entries",1,bt).map((o,c)=>{let a=o,u=typeof a=="object"&&a!==null&&!Array.isArray(a)?a.type:void 0,l=u==="directory"||u==="file"?["sourcePath","type","mode","size"]:u==="symlink"||u==="hardlink"?["sourcePath","type","mode","size","target"]:null;if(l===null)throw new Error(`Lazy tree source entry ${c} has invalid type`);let d=Ze(o,l,`Lazy tree source entry ${c}`),p=ge(d.sourcePath,!1,`Lazy tree source entry ${c} path`);if(r.has(p))throw new Error(`Lazy tree source inventory duplicates ${p}`);let m=ae(d.mode,`Lazy tree source entry ${p} mode`,0,W.S_MODE_BITS),f=ae(d.size,`Lazy tree source entry ${p} size`,0,Jr),h;if((u==="directory"||u==="symlink"||u==="hardlink")&&f!==0)throw new Error(`Lazy tree source ${p} has payload for ${String(u)}`);u==="symlink"?h=ye(d.target,`Lazy tree source symlink ${p} target`,bs):u==="hardlink"&&(h=ge(d.target,!1,`Lazy tree source hardlink ${p} target`));let g={sourcePath:p,type:u,mode:m,size:f,...h===void 0?{}:{target:h}};return r.set(p,g),g}),s=i.map(o=>o.sourcePath);if(s.some((o,c)=>c>0&&s[c-1]>=o))throw new Error("Lazy tree source inventory is not in canonical path order");return{schema:1,kind:"homebrew-bottle-tar-gzip-v1",entries:i}}function Ds(n){let e=new Map(n.map(r=>[r.sourcePath,r])),t=new Map;for(let r of n){if(r.type!=="hardlink"||t.has(r.sourcePath))continue;let i=[],s=new Set,o=r,c;for(;o.type==="hardlink"&&(c=t.get(o.sourcePath),c===void 0);){if(s.has(o.sourcePath))throw new Error(`Lazy tree source hardlink cycle includes ${o.sourcePath}`);s.add(o.sourcePath),i.push(o);let a=e.get(o.target);if(a===void 0)throw new Error(`Lazy tree source hardlink ${o.sourcePath} target is absent`);if(a.type!=="file"&&a.type!=="hardlink")throw new Error(`Lazy tree source hardlink ${o.sourcePath} target is not regular`);o=a}c===void 0&&(c=o);for(let a of i)t.set(a.sourcePath,c)}return t}function ge(n,e,t,r=!1){if(typeof n!="string"||n.length===0||new TextEncoder().encode(n).byteLength>nn||n.includes("\0")||n.includes("\\")||n.startsWith("/")!==e)throw new Error(`${t} is not a canonical ${e?"absolute":"relative"} path`);if(r&&e&&n==="/")return n;if(n.slice(e?1:0).split("/").some(s=>s===""||s==="."||s===".."))throw new Error(`${t} has an unsafe path segment`);return n}function Ks(n){let e=typeof n=="object"&&n!==null&&!Array.isArray(n)?n:null,t=e!==null&&(Object.hasOwn(e,"descriptorSha256")||Object.hasOwn(e,"expectedCount")||Object.hasOwn(e,"cohortSha256")),r=Ze(n,t?["id","member","descriptorSha256","expectedCount","cohortSha256"]:["id","member"],"Lazy tree atomic activation membership"),i=ye(r.id,"Lazy tree atomic activation group",Es),s=ye(r.member,"Lazy tree atomic activation member",Es);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(i)||!/^[a-z0-9][a-z0-9:+._/-]*$/.test(s)||s.includes("//")||s.endsWith("/"))throw new Error("Lazy tree atomic activation membership is invalid");if(!t)return{id:i,member:s};let o=ye(r.descriptorSha256,"Lazy tree atomic member descriptor digest",64),c=ye(r.cohortSha256,"Lazy tree atomic cohort digest",64);if(!pi.test(o)||!pi.test(c))throw new Error("Lazy tree atomic activation digest is invalid");return{id:i,member:s,descriptorSha256:o,expectedCount:ae(r.expectedCount,"Lazy tree atomic activation expected member count",1,zs),cohortSha256:c}}function Lt(n){return n.descriptorSha256!==void 0&&n.expectedCount!==void 0&&n.cohortSha256!==void 0}function Zu(n){let e=Ze(n,["uid","gid"],"Lazy tree registration owner");return{uid:ae(e.uid,"Lazy tree registration owner uid",0,Ss),gid:ae(e.gid,"Lazy tree registration owner gid",0,Ss)}}function Bs(n,e,t,r,i=1){let s=en(n,i),o=yr(t),c=["mode","capabilities","roots",...typeof r=="object"&&r!==null&&!Array.isArray(r)&&Object.hasOwn(r,"atomicGroup")?["atomicGroup"]:[]],a=Ze(r,c,"Lazy tree activation");if(a.mode!=="boot-prefetch"&&a.mode!=="first-use")throw new Error("Lazy tree activation mode is invalid");let u=Ne(a.capabilities,"Lazy tree activation capabilities",1,Tu).map((S,A)=>{let R=ye(S,`Lazy tree activation capability ${A}`,he.maxActivationCapabilityBytes);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(R))throw new Error(`Lazy tree activation capability ${A} is invalid`);return R}),l=Ne(a.roots,"Lazy tree activation roots",1,vu).map((S,A)=>ge(S,!0,`Lazy tree activation root ${A}`,!0));if(new Set(u).size!==u.length||new Set(l).size!==l.length)throw new Error("Lazy tree activation contains duplicates");let d=a.atomicGroup===void 0?void 0:Ks(a.atomicGroup);if(d!==void 0&&a.mode!=="first-use")throw new Error("Lazy tree atomic activation group requires a valid first-use identity");let p={mode:a.mode,capabilities:u,roots:l,...d===void 0?{}:{atomicGroup:d}},m=Ne(e,"Lazy tree inventory",1,bt),f=[],h=new Map,g=new Map,_=s.source===void 0?void 0:new Map(s.source.entries.map(S=>[S.sourcePath,S])),y=s.source===void 0?void 0:Ds(s.source.entries),E=0;for(let[S,A]of m.entries()){if(typeof A!="object"||A===null||Array.isArray(A))throw new Error(`Lazy tree entry ${S} must be an object`);let R=A.type,x=R==="directory"?["vfsPath","sourcePath","type","mode","size"]:R==="file"?["vfsPath","sourcePath","type","mode","size","inodeGroup"]:R==="symlink"?["vfsPath","sourcePath","type","mode","size","target"]:R==="hardlink"?["vfsPath","sourcePath","type","mode","size","target","inodeGroup"]:null;if(!x)throw new Error(`Lazy tree entry ${S} has an invalid type`);let v=Ze(A,[...x,..._===void 0?[]:["materialization"]],`Lazy tree entry ${S}`),L=ge(v.vfsPath,!0,`Lazy tree entry ${S} VFS path`),D=ge(v.sourcePath,!1,`Lazy tree entry ${S} source path`),Z=_===void 0?void 0:v.materialization;if(_!==void 0&&Z!=="archive"&&Z!=="archive-homebrew-relocate"&&Z!=="archive-copy"&&Z!=="archive-copy-mode"&&Z!=="descriptor")throw new Error(`Lazy tree entry ${L} has invalid materialization provenance`);if(o!=="/"&&L!==o&&!L.startsWith(`${o}/`))throw new Error(`Lazy tree entry ${L} escapes its mount prefix`);if(h.has(L))throw new Error(`Lazy tree duplicates VFS path ${L}`);let N=ae(v.mode,`Lazy tree entry ${L} mode`,0,W.S_MODE_BITS),b=ae(v.size,`Lazy tree entry ${L} size`,0,Jr),U,le;if(R==="directory"){if(b!==0)throw new Error(`Lazy tree directory ${L} has nonzero size`)}else if(R==="symlink"){if(U=ye(v.target,`Lazy tree symlink ${L} target`,bs),new TextEncoder().encode(U).byteLength!==b)throw new Error(`Lazy tree symlink ${L} size differs from its target`)}else le=ye(v.inodeGroup,`Lazy tree entry ${L} inode group`,nn),R==="hardlink"&&(U=ge(v.target,!0,`Lazy tree hardlink ${L} target`));if(R!=="hardlink"&&(E+=b,E>Jr))throw new Error("Lazy tree inventory exceeds the expansion limit");let C={vfsPath:L,sourcePath:D,...Z===void 0?{}:{materialization:Z},type:R,mode:N,size:b,...U===void 0?{}:{target:U},...le===void 0?{}:{inodeGroup:le}};if(_===void 0){let V=g.get(D);if(V){if(s.decoder!=="zip-v1"||C.type!=="hardlink"||V.inodeGroup!==C.inodeGroup)throw new Error(`Lazy tree duplicates source path ${D}`)}else{if(s.decoder==="zip-v1"&&C.type==="hardlink")throw new Error(`Lazy ZIP hardlink ${L} does not reuse a canonical source path`);g.set(D,C)}}else if(C.materialization==="descriptor"){if(C.type!=="directory"&&C.type!=="symlink")throw new Error(`Lazy tree descriptor entry ${L} is not structural`);if(_.has(D))throw new Error(`Lazy tree descriptor entry ${L} impersonates a source member`)}else{let V=_.get(D);if(V===void 0)throw new Error(`Lazy tree entry ${L} names absent source ${D}`);if(C.materialization==="archive-copy"||C.materialization==="archive-copy-mode"){if(C.type!=="file"||V.type!=="file"||C.materialization==="archive-copy"&&C.mode!==V.mode)throw new Error(`Lazy tree archive copy ${L} differs from its source`)}else if(C.materialization==="archive-homebrew-relocate"){if(C.type!=="file"&&C.type!=="hardlink"||V.type!==C.type||C.type==="file"&&V.mode!==C.mode)throw new Error(`Lazy tree receipt-relocated entry ${L} differs from its source`)}else if(V.type!==C.type||C.type==="symlink"&&V.target!==C.target||C.type!=="hardlink"&&V.mode!==C.mode)throw new Error(`Lazy tree archive entry ${L} differs from its source`)}f.push(C),h.set(L,C)}for(let S of f){let A=S.vfsPath.split("/").filter(Boolean);for(let R=1;R({path:S.vfsPath,type:S.type,mode:S.mode,size:S.size,target:S.target,inodeGroup:S.inodeGroup})),"Lazy tree");if(_!==void 0){let S=new Set;for(let A of f){if(A.materialization!=="archive-homebrew-relocate")continue;let R=_.get(A.sourcePath),x=R.type==="file"?R:y.get(R.sourcePath);if(x?.type!=="file")throw new Error(`Lazy tree receipt-relocated entry ${A.vfsPath} is not regular`);S.add(x.sourcePath)}for(let A of f){if(A.materialization==="descriptor"||A.type!=="file"&&A.type!=="hardlink")continue;let R=_.get(A.sourcePath),x=R.type==="file"?R:y.get(R.sourcePath);if(x?.type!=="file"||!S.has(x.sourcePath)&&A.size!==x.size)throw new Error(`Lazy tree archive entry ${A.vfsPath} differs from its source`)}for(let A of f){if(A.type!=="hardlink"||A.materialization!=="archive"&&A.materialization!=="archive-homebrew-relocate")continue;let R=_.get(A.sourcePath),x=h.get(A.target),v=y.get(R.sourcePath);if(R.target!==x?.sourcePath||v?.type!=="file"||v.mode!==A.mode||x?.mode!==A.mode)throw new Error(`Lazy tree hardlink ${A.vfsPath} differs from its source`)}}if(s.sourceEntryCount!==(_===void 0?g.size:_.size))throw new Error("Lazy tree source entry count differs from its inventory");if(s.source===void 0&&s.expandedBytesA.vfsPath===S||A.vfsPath.startsWith(`${S}/`)))throw new Error(`Lazy tree activation root ${S} is not owned by its inventory`);let w=new Map;for(let S of f)S.type==="file"&&w.set(S.inodeGroup,S);if(w.size!==O.canonicalByGroup.size)throw new Error("Lazy tree regular inode inventory is inconsistent");return{content:s,entries:f,mountPrefix:o,activation:p,canonicalByGroup:w}}function tn(n){return JSON.stringify([n.sourcePath,n.type,n.inodeGroup,n.target])}function gi(n,e){let t=_i(n,["kind","content","url","mountPrefix","integrity","materialized","entries"],["url","mountPrefix","materialized","entries"],"Serialized legacy lazy archive");if(t.kind===void 0){if(!e)throw new Error("Serialized lazy archive is missing its kind discriminator")}else if(t.kind!==mr)throw new Error("Serialized legacy lazy archive has an unsupported kind");let r=ye(t.url,"Serialized legacy lazy archive URL",Ei),i=yr(t.mountPrefix),s=gr(t.integrity);if(t.content!==void 0){if(!e||t.kind!==void 0)throw new Error("Typed legacy lazy archives cannot carry generic content");let a=en(t.content);if(a.decoder!=="zip-v1"||a.transports.length!==1||a.transports[0]!==r||!s||a.sha256!==s.sha256||a.bytes!==s.bytes)throw new Error("Untagged legacy ZIP content identity is inconsistent")}if(t.materialized!==!1)throw new Error("Serialized legacy lazy archive must describe pending content");let o=new Set,c=Ne(t.entries,"Serialized legacy lazy archive entries",1,bt).map((a,u)=>{let l=_i(a,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","size","isSymlink","deleted"],`Serialized legacy lazy archive entry ${u}`),d=ge(l.vfsPath,!0,`Serialized legacy lazy archive entry ${u} VFS path`);if(o.has(d))throw new Error(`Serialized legacy lazy archive duplicates path ${d}`);o.add(d);let p=ae(l.ino,`Serialized legacy lazy archive entry ${d} inode`,1,Number.MAX_SAFE_INTEGER),m=l.generation===void 0?void 0:ae(l.generation,`Serialized legacy lazy archive entry ${d} generation`,0,Number.MAX_SAFE_INTEGER),f=l.dataSequence===void 0?void 0:ae(l.dataSequence,`Serialized legacy lazy archive entry ${d} data sequence`,0,Number.MAX_SAFE_INTEGER),h=ae(l.size,`Serialized legacy lazy archive entry ${d} size`,0,Jr);if(l.isSymlink!==!1||l.deleted!==!1||l.materialized!==void 0&&l.materialized!==!1)throw new Error(`Serialized legacy lazy archive entry ${d} is not pending`);if(l.type!==void 0&&l.type!=="file")throw new Error(`Serialized legacy lazy archive entry ${d} has an invalid type`);let g=l.archivePath===void 0?void 0:ge(l.archivePath,!1,`Serialized legacy lazy archive entry ${d} archive path`),_=l.sourcePath===void 0?void 0:ge(l.sourcePath,!1,`Serialized legacy lazy archive entry ${d} source path`),y=l.inodeGroup===void 0?void 0:ye(l.inodeGroup,`Serialized legacy lazy archive entry ${d} inode group`,nn);if(l.target!==void 0)throw new Error(`Serialized legacy lazy archive entry ${d} has a link target`);return{vfsPath:d,ino:p,...m===void 0?{}:{generation:m},...f===void 0?{}:{dataSequence:f},size:h,isSymlink:!1,deleted:!1,materialized:!1,...g===void 0?{}:{archivePath:g},..._===void 0?{}:{sourcePath:_},type:"file",...y===void 0?{}:{inodeGroup:y}}});return{kind:mr,url:r,mountPrefix:i,...s===void 0?{}:{integrity:s},materialized:!1,entries:c}}function $s(n,e){let t=Ze(n,["kind","content","inventory","activation","url","mountPrefix","integrity","materialized","entries"],"Serialized lazy tree");if(t.kind!==e)throw new Error("Serialized lazy tree has an unsupported kind");let r=Bs(t.content,t.inventory,t.mountPrefix,t.activation);if(e!==ct&&e===_r!=(r.content.source===void 0))throw new Error(e===_r?"Serialized deferred-tree-v1 cannot contain original-bottle source metadata":"Serialized deferred-tree-v2 requires original-bottle source metadata");let i=r.activation.atomicGroup;if(e===ct?i===void 0||!Lt(i):i!==void 0)throw new Error(e===ct?"Serialized deferred-tree-v3 requires a sealed atomic activation":"Atomic activation requires serialized deferred-tree-v3");let s=ye(t.url,"Serialized lazy tree URL",Ei);if(s!==r.content.transports[0])throw new Error("Serialized lazy tree URL differs from its primary transport");let o=gr(t.integrity);if(!o||o.sha256!==r.content.sha256||o.bytes!==r.content.bytes)throw new Error("Serialized lazy tree integrity differs from its content");if(t.materialized!==!1)throw new Error("Serialized lazy tree must describe pending content");let c=new Map(r.entries.map(p=>[p.vfsPath,p])),a=new Map(r.entries.map(p=>[tn(p),p])),u=Ne(t.entries,"Serialized lazy tree entries",0,bt),l=new Set,d=u.map((p,m)=>{let f=_i(p,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup"],`Serialized lazy tree entry ${m}`),h=ge(f.vfsPath,!0,`Serialized lazy tree entry ${m} VFS path`);if(l.has(h))throw new Error(`Serialized lazy tree duplicates pending path ${h}`);l.add(h);let g=ge(f.sourcePath,!1,`Serialized lazy tree entry ${m} source path`),_=ge(f.archivePath,!1,`Serialized lazy tree entry ${m} archive path`),y=c.get(h),E=a.get(tn({sourcePath:g,type:typeof f.type=="string"?f.type:void 0,inodeGroup:typeof f.inodeGroup=="string"?f.inodeGroup:void 0,target:typeof f.target=="string"?f.target:void 0}))??y;if(!E||E.type!=="file"&&E.type!=="hardlink"||y?.inodeGroup!==void 0&&y.inodeGroup!==E.inodeGroup)throw new Error(`Serialized lazy tree entry ${h} is absent from its inventory`);let O=r.canonicalByGroup.get(E.inodeGroup);if(f.type!==E.type||f.inodeGroup!==E.inodeGroup||f.size!==E.size||_!==O?.sourcePath||f.target!==E.target||f.isSymlink!==!1||f.deleted!==!1||f.materialized!==!1)throw new Error(`Serialized lazy tree entry ${h} disagrees with its inventory`);let w=ae(f.ino,`Serialized lazy tree entry ${h} inode`,1,Number.MAX_SAFE_INTEGER),S=ae(f.generation,`Serialized lazy tree entry ${h} generation`,0,Number.MAX_SAFE_INTEGER),A=ae(f.dataSequence,`Serialized lazy tree entry ${h} data sequence`,0,Number.MAX_SAFE_INTEGER);return{vfsPath:h,ino:w,generation:S,dataSequence:A,size:E.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:_,sourcePath:g,type:E.type,inodeGroup:E.inodeGroup,...E.target===void 0?{}:{target:E.target}}});for(let p of r.entries)if(r.activation.atomicGroup!==void 0&&(p.type==="file"||p.type==="hardlink")&&!l.has(p.vfsPath))throw new Error(`Serialized lazy tree omits pending path ${p.vfsPath}`);return{kind:e,content:r.content,inventory:r.entries,activation:r.activation,url:s,mountPrefix:r.mountPrefix,integrity:o,materialized:!1,entries:d}}async function pr(n,e){let t=globalThis.crypto?.subtle;if(!t)throw new Error(`${e} SHA-256 verification is unavailable`);let r=new Uint8Array(n.byteLength);r.set(n);let i=new Uint8Array(await t.digest("SHA-256",r));return Array.from(i,s=>s.toString(16).padStart(2,"0")).join("")}async function li(n,e,t){if(t===void 0)return;if(n.byteLength!==t.bytes)throw new Error(`Lazy ${e} byte count ${n.byteLength} does not match expected ${t.bytes}`);let r=await pr(n,`Lazy ${e}`);if(r!==t.sha256)throw new Error(`Lazy ${e} SHA-256 ${r} does not match expected ${t.sha256}`)}function Xu(n,e,t,r){let i=r.atomicGroup;if(i===void 0)throw new Error("Lazy atomic member is missing its typed tree descriptor");let s={schema:1,content:{decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...n.source===void 0?{}:{source:n.source}},mountPrefix:t,inventory:[...e].sort((o,c)=>o.vfsPathc.vfsPath?1:0),activation:{mode:r.mode,capabilities:r.capabilities,roots:r.roots,atomicGroup:{id:i.id,member:i.member}}};return new TextEncoder().encode(JSON.stringify(s))}function Rs(n,e){let t=e.map(({member:r,descriptorSha256:i})=>({member:r,descriptorSha256:i}));return new TextEncoder().encode(JSON.stringify({schema:1,id:n,members:t.sort((r,i)=>r.memberi.member?1:0)}))}function Yu(n,e){if(n.byteLength!==e.byteLength)return!1;for(let t=0;tObject.freeze({...i}))};return r!==void 0&&(Object.freeze(r.entries),Object.freeze(r)),Object.freeze({decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,transports:t,...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...r===void 0?{}:{source:r}})}function xs(n){return{decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,transports:[...n.transports],...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...n.source===void 0?{}:{source:{schema:1,kind:"homebrew-bottle-tar-gzip-v1",entries:n.source.entries.map(e=>({...e}))}}}}function ju(n){let e=n.map(t=>Object.freeze({...t}));return Object.freeze(e),e}function Ju(n,e,t){let r=[...n.capabilities],i=[...n.roots];return Object.freeze(r),Object.freeze(i),Object.freeze({mode:n.mode,capabilities:r,roots:i,atomicGroup:Object.freeze({id:e,member:t})})}function Qu(n){return{mode:n.activation.mode,capabilities:[...n.activation.capabilities],roots:[...n.activation.roots],atomicGroup:{id:n.id,member:n.member,descriptorSha256:n.descriptorSha256,expectedCount:n.expectedCount,cohortSha256:n.cohortSha256}}}function ed(n,e){return n.ino===e.ino&&n.generation===e.generation&&n.dataSequence===e.dataSequence&&n.size===e.size&&n.isSymlink===e.isSymlink&&n.deleted===e.deleted&&n.materialized===e.materialized&&n.archivePath===e.archivePath&&n.sourcePath===e.sourcePath&&n.type===e.type&&n.inodeGroup===e.inodeGroup&&n.target===e.target}function Zr(n,e,t){let r=n.content,i=n.inventory,s=n.activation,o=n.integrity,c=n.entries,a=n.url,u=n.mountPrefix,l=n.materialized,d=s?.atomicGroup;if(r===void 0||i===void 0||s===void 0||d===void 0||s.mode!=="first-use"||d.id!==e||d.member!==t||l)throw new Error(`Lazy atomic activation member ${t} changed before snapshot`);if(o?.sha256!==r.sha256||o?.bytes!==r.bytes||a!==(r.transports[0]??""))throw new Error(`Lazy atomic activation member ${t} has inconsistent integrity`);let p=Us(r),m=ju(i),f=Ju(s,e,t),h=new Map;for(let O of m)O.type==="file"&&h.set(O.inodeGroup,O.sourcePath);let g=m.filter(O=>O.type!=="directory");if(c.size!==g.length)throw new Error(`Lazy atomic activation member ${t} has inconsistent runtime entries`);let _=g.map(O=>{let w=c.get(O.vfsPath),S=O.type==="symlink",A=S?O.sourcePath:h.get(O.inodeGroup),R=w!==void 0&&(w.sourcePath===O.sourcePath&&w.type===O.type&&w.target===O.target||O.type==="hardlink"&&w.sourcePath===A&&w.type==="file"&&w.target===void 0),x=w===void 0?["missing"]:[A===void 0?"archivePath source":void 0,w.generation===void 0?"generation":void 0,w.dataSequence===void 0?"dataSequence":void 0,w.size!==O.size?"size":void 0,w.isSymlink!==S?"symlink kind":void 0,w.deleted?"deletion state":void 0,w.materialized!==S?"materialization state":void 0,w.archivePath!==A?"archivePath":void 0,R?void 0:"descriptor mapping",w.inodeGroup!==O.inodeGroup?"inode group":void 0].filter(L=>L!==void 0);if(x.length>0)throw new Error(`Lazy atomic activation member ${t} has inconsistent mapping at ${O.vfsPath}: ${x.join(", ")}`);let v=w;return Object.freeze({vfsPath:O.vfsPath,ino:v.ino,generation:v.generation,dataSequence:v.dataSequence,size:v.size,isSymlink:v.isSymlink,deleted:!1,materialized:v.materialized,archivePath:A,sourcePath:O.sourcePath,type:O.type,...O.inodeGroup===void 0?{}:{inodeGroup:O.inodeGroup},...O.target===void 0?{}:{target:O.target}})});Object.freeze(_);let y=Object.freeze({sha256:p.sha256,bytes:p.bytes}),E=Xu(p,m,u,f);return Object.freeze({id:e,member:t,descriptorBytes:E,content:p,inventory:m,activation:f,url:p.transports[0]??"",mountPrefix:u,integrity:y,entries:_})}function Ts(n,e,t,r){return Object.freeze({...n,descriptorSha256:e,expectedCount:t,cohortSha256:r})}function vs(n,e){return n.id!==e.id||n.member!==e.member||n.url!==e.url||n.mountPrefix!==e.mountPrefix||n.integrity.sha256!==e.integrity.sha256||n.integrity.bytes!==e.integrity.bytes||n.content.transports.length!==e.content.transports.length||n.content.transports.some((t,r)=>t!==e.content.transports[r])||!Yu(n.descriptorBytes,e.descriptorBytes)||n.entries.length!==e.entries.length?!1:n.entries.every((t,r)=>{let i=e.entries[r];return i!==void 0&&t.vfsPath===i.vfsPath&&ed(t,i)})}function td(n,e){let t=Us(n.content,n.content.transports.map(e));return Object.freeze({...n,content:t,url:t.transports[0]??""})}function Ls(n){return{paths:Array.from(n.paths),expectedIno:n.ino,expectedGeneration:n.generation,expectedDataSequence:n.dataSequence,data:n.content}}var rn=class n{fs;imageMetadata;lazyFiles=new Map;lazyArchiveGroups=[];deferredTreeMaterializationHandles=new WeakMap;lazyArchiveInodes=new Map;lazyAtomicGroups=new Map;lazyAtomicGroupByTree=new WeakMap;sealedLazyAtomicStates=new WeakMap;lazyDownloadListeners=new Set;lazyPreparations=new Map;lazyTransport={fetcher:(e,t)=>t===void 0?globalThis.fetch(e):globalThis.fetch(e,t)};constructor(e,t=null){this.fs=e,this.imageMetadata=t}static inodeKey(e,t){return`${e}:${t}`}static canAdoptLegacyLazyStub(e){return(e.mode&Fe)===hr&&e.size===0&&e.dataSequence<=1}reconcileLazyIdentityState(e){for(let[t,r]of this.lazyFiles){let i=e.get(t);if(!i||i.dataSequence!==r.dataSequence||i.paths.length===0){this.lazyFiles.delete(t);continue}r.paths=new Set(i.paths),r.paths.has(r.path)||(r.path=i.paths[0])}this.lazyArchiveInodes.clear();for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(!r?.committed)for(let a of i.snapshot.entries){if(a.isSymlink||a.materialized||a.generation===void 0)continue;let u=n.inodeKey(a.ino,a.generation),l=e.get(u);l!==void 0&&l.dataSequence===a.dataSequence&&l.paths.length>0&&this.lazyArchiveInodes.set(u,t)}continue}let s=t.content!==void 0&&t.inventory!==void 0&&!t.materialized,o=new Map;for(let a of t.entries.values()){if(a.deleted||a.materialized||a.generation===void 0)continue;let u=n.inodeKey(a.ino,a.generation);o.has(u)||o.set(u,a)}let c=new Map(Array.from(t.entries.entries()).filter(([,a])=>a.deleted||a.isSymlink&&!a.deleted));for(let[a,u]of o){let l=e.get(a);if(!(!l||l.dataSequence!==(u.dataSequence??0))){for(let d of l.paths)c.set(d,{...u,ino:l.ino,generation:l.generation,dataSequence:l.dataSequence,deleted:!1,materialized:!1});l.paths.length>0&&this.lazyArchiveInodes.set(a,t)}}t.entries=c,t.materialized=!Array.from(c.values()).some(a=>!a.isSymlink&&!a.materialized)&&!s&&(r===void 0||r.committed)}}validatePendingLazyTreeNamespaceState(e){let t=new Map;for(let r of e.values())for(let i of r.paths){if(t.has(i))throw new Error(`SharedFS namespace identity is ambiguous at ${i}`);t.set(i,r)}for(let r of this.lazyArchiveGroups){let i=this.lazyAtomicGroupByTree.get(r),o=this.sealedLazyAtomicStates.get(r)?.snapshot;if(i?.committed||o===void 0&&r.materialized||o===void 0&&(r.content===void 0||r.inventory===void 0))continue;let c=o?.inventory??r.inventory,a=o===void 0?r.entries:new Map(o.entries.map(m=>[m.vfsPath,m])),u=new Map,l=new Map,d=new Set;for(let m of a.values())m.deleted&&m.inodeGroup!==void 0&&d.add(m.inodeGroup);for(let m of c){if(m.type!=="file"&&m.type!=="hardlink")continue;u.set(m.inodeGroup,(u.get(m.inodeGroup)??0)+1);let f=l.get(m.inodeGroup)??[];f.push(m.vfsPath),l.set(m.inodeGroup,f)}let p=new Set([...d].filter(m=>l.get(m)?.every(f=>!t.has(f))));for(let m of c){let f=t.get(m.vfsPath);if(f===void 0){if(m.inodeGroup!==void 0&&p.has(m.inodeGroup))continue;throw new Error(`Lazy tree namespace entry ${m.vfsPath} is missing from the captured filesystem state`)}let h=m.type==="directory"?at:m.type==="symlink"?Vr:hr;if((f.mode&Fe)!==h||(f.mode&W.S_MODE_BITS)!==m.mode)throw new Error(`Lazy tree namespace entry ${m.vfsPath} disagrees with its captured type or mode`);if(m.type==="directory")continue;let g=a.get(m.vfsPath);if(g===void 0||g.ino!==f.ino||g.generation!==f.generation||g.dataSequence!==f.dataSequence)throw new Error(`Lazy tree namespace entry ${m.vfsPath} changed identity before serialization`);if(m.type==="symlink"){let _=new TextEncoder().encode(m.target).byteLength;if(f.linkCount!==1||f.size!==m.size||f.size!==_||f.symlinkTarget!==m.target)throw new Error(`Lazy tree symlink ${m.vfsPath} disagrees with its captured inventory`);continue}if(f.size!==0||f.linkCount!==u.get(m.inodeGroup))throw new Error(`Lazy tree stub ${m.vfsPath} has changed data or undeclared aliases`)}}}lazyFileForStat(e){let t=n.inodeKey(e.ino,e.generation),r=this.lazyFiles.get(t);if(r&&r.dataSequence!==e.dataSequence){this.lazyFiles.delete(t);return}return r}lazyArchiveEntriesForRead(e){let t=this.lazyAtomicGroupByTree.get(e),r=this.sealedLazyAtomicStates.get(e);return r!==void 0&&!t?.committed?r.snapshot.entries:Array.from(e.entries,([i,s])=>({vfsPath:i,...s}))}lazyArchiveForStat(e){let t=n.inodeKey(e.ino,e.generation),r=this.lazyArchiveInodes.get(t);if(!r)return;let i=this.lazyArchiveEntriesForRead(r).filter(o=>o.ino===e.ino&&o.generation===e.generation&&!o.deleted&&!o.materialized);if(i.some(o=>o.dataSequence===e.dataSequence))return r;if(this.lazyArchiveInodes.delete(t),this.sealedLazyAtomicStates.get(r)===void 0)for(let o of i)o.materialized=!0}lazyBackingForStat(e){let t=n.inodeKey(e.ino,e.generation),r=this.lazyFiles.get(t);if(r)return{token:r,path:r.path};let i=this.lazyArchiveInodes.get(t);if(!i)return null;let s=this.lazyArchiveEntriesForRead(i).find(c=>c.ino===e.ino&&c.generation===e.generation&&!c.deleted&&!c.materialized)?.vfsPath;if(s===void 0)return null;let o=this.lazyAtomicGroupByTree.get(i);return o===void 0?{token:i,path:s}:{token:o.token,path:s,atomicGroup:o}}lazyBackingForPath(e){let t=this.lazyArchiveGroups.find(r=>{let i=this.lazyAtomicGroupByTree.get(r),s=this.sealedLazyAtomicStates.get(r)?.snapshot,o=s===void 0?!r.materialized:!i?.committed,c=s?.content??r.content,a=s?.inventory??r.inventory,u=s?.activation??r.activation,l=s?.entries??Array.from(r.entries.values());return o&&c!==void 0&&a!==void 0&&u!==void 0&&l.every(d=>d.deleted||d.materialized||d.isSymlink)&&u.roots.some(d=>d==="/"||e===d||e.startsWith(`${d}/`))});if(t){let r=this.lazyAtomicGroupByTree.get(t);return{token:r?.token??t,path:e,directGroup:t,...r===void 0?{}:{atomicGroup:r}}}try{let r=this.fs.stat(e),i=this.lazyBackingForStat(r);return i?{...i,path:e}:null}catch{return null}}startLazyPreparation(e){let{path:t,token:r}=e,i={status:"pending",promise:Promise.resolve(!1)},s=e.atomicGroup?this.ensureAtomicLazyGroupMaterialized(e.atomicGroup).then(()=>!0):e.directGroup?this.ensureArchiveMaterialized(e.directGroup).then(()=>!0):this.materializePath(t);return i.promise=s.then(o=>(i.status="fulfilled",this.lazyPreparations.get(r)===i&&this.lazyPreparations.delete(r),o),o=>{throw i.status="rejected",i.error=o,o}),i.promise.catch(()=>{}),this.lazyPreparations.set(r,i),i}registerLazyAtomicGroupMembership(e,t=!1){let r=e.activation?.atomicGroup;if(r===void 0)return;let{id:i,member:s}=r;if(e.content===void 0||e.inventory===void 0||e.activation?.mode!=="first-use")throw new Error(`Lazy atomic activation group ${i} accepts only typed first-use trees`);let o=this.lazyAtomicGroups.get(i);if(o===void 0)o={id:i,token:Object.freeze({id:i}),groups:new Map,committed:!1},this.lazyAtomicGroups.set(i,o);else if(o.committed)throw new Error(`Lazy atomic activation group ${i} is already materialized`);if(o.groups.has(s))throw new Error(`Lazy atomic activation group ${i} duplicates member ${s}`);if(Lt(r)){if(o.expectedCount!==void 0&&(o.expectedCount!==r.expectedCount||o.cohortSha256!==r.cohortSha256))throw new Error(`Lazy atomic activation group ${i} has inconsistent seals`);o.expectedCount=r.expectedCount,o.cohortSha256=r.cohortSha256;let c=Zr(e,i,s);this.sealedLazyAtomicStates.set(e,{snapshot:Ts(c,r.descriptorSha256,r.expectedCount,r.cohortSha256),verified:t})}else if(o.expectedCount!==void 0)throw new Error(`Lazy atomic activation group ${i} mixes sealed and unsealed members`);o.groups.set(s,e),this.lazyAtomicGroupByTree.set(e,o)}async sealLazyAtomicGroup(e,t){let r=t.map(u=>Ks({id:e,member:u}).member).sort();if(r.length===0||new Set(r).size!==r.length)throw new Error(`Lazy atomic activation group ${e} expected members are invalid`);let i=this.lazyAtomicGroups.get(e);if(i===void 0||i.committed)throw new Error(`Lazy atomic activation group ${e} is not pending`);let s=[...i.groups.keys()].sort();if(JSON.stringify(s)!==JSON.stringify(r))throw new Error(`Lazy atomic activation group ${e} members differ from its seal`);if(i.expectedCount!==void 0){if(i.expectedCount!==r.length||i.cohortSha256===void 0)throw new Error(`Lazy atomic activation group ${e} has an invalid existing seal`);await this.ensureLazyAtomicGroupSealValidated(i,r.map(u=>i.groups.get(u)),!0);return}let o=r.map(u=>Zr(i.groups.get(u),e,u)),c=[];for(let u of o)c.push({member:u.member,descriptorSha256:await pr(u.descriptorBytes,`Lazy atomic member ${u.member}`),source:u});let a=await pr(Rs(e,c),`Lazy atomic activation group ${e}`);for(let u of c){let l=i.groups.get(u.member),d=Zr(l,e,u.member);if(!vs(u.source,d))throw new Error(`Lazy atomic activation member ${u.member} changed while sealing`)}for(let u of c){let l=i.groups.get(u.member);l.activation.atomicGroup={id:e,member:u.member,descriptorSha256:u.descriptorSha256,expectedCount:c.length,cohortSha256:a},this.sealedLazyAtomicStates.set(l,{snapshot:Ts(u.source,u.descriptorSha256,c.length,a),verified:!0})}i.expectedCount=c.length,i.cohortSha256=a}async verifyImportedLazyAtomicGroupSeals(){await this.validatePendingLazyAtomicGroupSeals(!0)}guardSynchronousLazyAccess(e){let t=this.lazyBackingForPath(e);if(!t)return;let r=this.lazyPreparations.get(t.token);if(r?.status==="fulfilled"){this.lazyPreparations.delete(t.token);let s=this.lazyBackingForPath(e);if(!s)return;r=this.lazyPreparations.get(s.token)??this.startLazyPreparation(s)}else if(r?.status==="rejected"){this.lazyPreparations.delete(t.token);let s=r.error instanceof Error?r.error.message:String(r.error),o=new Error(`EIO: lazy backing for ${e} failed: ${s}`);throw o.code="EIO",o.cause=r.error,o}else r||(r=this.startLazyPreparation(t));let i=new Error(`EAGAIN: lazy backing for ${e} is being prepared`);throw i.code="EAGAIN",i}invalidateLazyData(e){let t=n.inodeKey(e.ino,e.generation);this.lazyFiles.delete(t);let r=this.lazyArchiveInodes.get(t);if(r){this.lazyArchiveInodes.delete(t);for(let i of r.entries.values())i.ino===e.ino&&i.generation===e.generation&&(i.materialized=!0)}}rewriteLazyNamespacePaths(e,t,r){let i=t.length>1?t.replace(/\/+$/,""):t,s=r.length>1?r.replace(/\/+$/,""):r,o=`${i}/`,c=`${s}/`,a=n.inodeKey(e.ino,e.generation),u=(e.mode&Fe)===at,l=d=>d===i?s:u&&d.startsWith(o)?c+d.slice(o.length):d;for(let[d,p]of this.lazyFiles)!u&&d!==a||(p.paths=new Set(Array.from(p.paths,l)),p.path=l(p.path));for(let d of this.lazyArchiveGroups){let p=new Map;for(let[m,f]of d.entries){let h=f.generation===void 0?null:n.inodeKey(f.ino,f.generation);p.set(u||h===a?l(m):m,f)}d.entries=p,d.inventory&&(d.inventory=d.inventory.map(m=>({...m,vfsPath:l(m.vfsPath),...m.type==="hardlink"&&m.target!==void 0?{target:l(m.target)}:{}}))),d.activation&&(d.activation={...d.activation,roots:d.activation.roots.map(l)})}}get sharedBuffer(){return this.fs.buffer}static create(e,t){return new n(be.mkfs(e,t))}static fromExisting(e){return new n(be.mount(e))}rebaseToNewFileSystem(e){if(!Number.isSafeInteger(e)||e<=0)throw new Error(`Invalid MemoryFileSystem maxByteLength: ${e}`);let t=SharedArrayBuffer,{bytes:r,identities:i}=this.fs.snapshotState();this.reconcileLazyIdentityState(i);let s=this.serializeLazyEntries(),o=this.serializeValidatedLazyArchiveEntries(i),c=new t(r.byteLength);new Uint8Array(c).set(r);let a=new n(be.mount(c,{restoreImage:!0}),this.imageMetadata);a.importLazyEntries(s),a.importLazyArchiveEntriesInternal(o,!1,!0,"verified");let u=Math.min(e,Math.max(r.byteLength,Au)),l=new t(u,{maxByteLength:e}),d=n.create(l,e);d.setImageMetadata(this.imageMetadata);let p=new Set(s.flatMap(f=>f.paths??[f.path])),m=new Set;for(let f of o)if(!f.materialized)for(let h of f.entries)!h.deleted&&!h.isSymlink&&m.add(h.vfsPath);return a.copyPathToFreshFileSystem("/",d,p,m,new Map),d.importLazyEntries(s.map(f=>{let h=d.fs.lstat(f.path);return{...f,ino:h.ino,generation:h.generation,dataSequence:h.dataSequence}})),d.importLazyArchiveEntriesInternal(o.map(f=>({...f,entries:f.entries.map(h=>{if(h.deleted)return{...h,ino:0,generation:void 0};let g=d.fs.lstat(h.vfsPath);return{...h,ino:g.ino,generation:g.generation,dataSequence:g.dataSequence}})})),!1,!0,"verified"),d}getImageMetadata(){return Pu(this.imageMetadata)}setImageMetadata(e){this.imageMetadata=e===null?null:Si(e)}subscribeLazyDownloads(e){return this.lazyDownloadListeners.add(e),()=>this.lazyDownloadListeners.delete(e)}setLazyFetcher(e,t={}){this.lazyTransport={fetcher:e,...t.signal===void 0?{}:{signal:t.signal}}}emitLazyDownload(e){if(this.lazyDownloadListeners.size===0)return;let t={...e,t:Cu()};for(let r of this.lazyDownloadListeners)try{r(t)}catch{}}async fetchLazyBytes(e,t){let r=0,i=e.integrity?.bytes??e.fallbackTotalBytes,s={id:e.id,kind:e.kind,url:e.url,path:e.path,mountPrefix:e.mountPrefix};for(let o=0;oe.integrity.bytes)throw new Error(`Lazy ${e.kind} exceeded expected byte count ${e.integrity.bytes}`);this.emitLazyDownload({...s,status:"progress",loadedBytes:r,totalBytes:i})}}}catch(d){try{await a.cancel(d)}catch{}throw d}}finally{a.releaseLock()}let l=Gu(u,r);return te(t.signal),await li(l,e.kind,e.integrity),te(t.signal),this.emitLazyDownload({...s,status:"complete",loadedBytes:r,totalBytes:i??r}),l}catch(c){if(t.signal?.aborted){let l=t.signal.reason,d=l instanceof Error?l.message:String(l);throw this.emitLazyDownload({...s,status:"error",loadedBytes:r,totalBytes:i,error:d}),l}let a=o+1({...y})),activation:d,entries:new Map},g=y=>{let E=y.split("/").filter(Boolean),O="";for(let w=0;wE.vfsPath.split("/").length-O.vfsPath.split("/").length))if(y.type==="directory"){g(y.vfsPath);try{this.fs.mkdir(y.vfsPath,y.mode),this.fs.chmod(y.vfsPath,y.mode)}catch{if((this.fs.lstat(y.vfsPath).mode&Fe)!==at)throw new Error(`Lazy tree directory collides at ${y.vfsPath}`)}}for(let y of u){if(y.type!=="symlink")continue;g(y.vfsPath),this.fs.symlink(y.target,y.vfsPath);let E=this.fs.lstat(y.vfsPath);h.entries.set(y.vfsPath,{ino:E.ino,generation:E.generation,dataSequence:E.dataSequence,size:y.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:y.sourcePath,sourcePath:y.sourcePath,type:"symlink",target:y.target})}let _=new Map;for(let y of u){if(y.type!=="file")continue;g(y.vfsPath);let E=this.fs.createLazyStub(y.vfsPath,y.mode);this.invalidateLazyData(E),_.set(y.inodeGroup,E);let O={ino:E.ino,generation:E.generation,dataSequence:E.dataSequence,size:y.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:y.sourcePath,sourcePath:y.sourcePath,type:"file",inodeGroup:y.inodeGroup};h.entries.set(y.vfsPath,O)}for(let y of u){if(y.type!=="hardlink")continue;let E=p.get(y.inodeGroup);g(y.vfsPath),this.fs.link(E.vfsPath,y.vfsPath);let O=this.fs.lstat(y.vfsPath),w=_.get(y.inodeGroup);if(O.ino!==w.ino||O.generation!==w.generation)throw new Error(`Lazy tree hardlink ${y.vfsPath} did not share its inode`);h.entries.set(y.vfsPath,{ino:O.ino,generation:O.generation,dataSequence:O.dataSequence,size:y.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:E.sourcePath,sourcePath:y.sourcePath,type:"hardlink",inodeGroup:y.inodeGroup,target:y.target})}if(m!==void 0)for(let y of u)this.lchown(y.vfsPath,m.uid,m.gid);for(let y of h.entries.values())y.isSymlink||y.generation===void 0||this.lazyArchiveInodes.set(n.inodeKey(y.ino,y.generation),h);return this.lazyArchiveGroups.push(h),this.registerLazyAtomicGroupMembership(h),h}registerLazyTreeWithMaterializationHandle(e,t,r="/",i,s){let o=this.registerLazyTreeInternal(e,t,r,i,!0,s),c=Object.freeze({[Su]:!0});return this.deferredTreeMaterializationHandles.set(c,o),c}registerLazyArchiveFromEntries(e,t,r,i,s){let o=yr(r),c=bu(e,t,o,i);c.some(({entry:u})=>!u.isDirectory&&!u.isSymlink)&&this.assertCanRegisterPendingLazyArchiveGroup();let a={...s?{content:en({decoder:"zip-v1",mediaType:"application/zip",sha256:s.sha256,bytes:s.bytes,expandedBytes:c.reduce((u,l)=>u+l.entry.uncompressedSize,0),sourceEntryCount:c.length,transports:[e]})}:{},url:e,mountPrefix:o,integrity:gr(s),materialized:!1,entries:new Map};for(let{entry:u,vfsPath:l}of c){if(u.isDirectory)continue;let d=l.split("/").filter(Boolean),p="";for(let m=0;mu.deleted||u.materialized),this.lazyArchiveGroups.push(a),a}importLazyArchiveEntries(e){this.importLazyArchiveEntriesInternal(e,!1,!0,"reject")}async importVerifiedLazyArchiveEntries(e){let t=structuredClone(e),r=this.exportLazyArchiveEntries(),i=n.fromExisting(this.sharedBuffer);i.importLazyArchiveEntriesInternal([...r,...t],!1,!0,"pending"),await i.verifyImportedLazyAtomicGroupSeals(),this.importLazyArchiveEntriesInternal(t,!1,!0,"verified")}importLazyArchiveEntriesInternal(e,t,r,i){let s=Ne(e,"Serialized lazy archive groups",0,zs).map((l,d)=>{if(typeof l!="object"||l===null||Array.isArray(l))throw new Error(`Serialized lazy archive group ${d} must be an object`);let p=l.kind;if(p===_r||p===mi||p===ct)return $s(l,p);if(p===mr)return gi(l,!1);if(p!==void 0)throw new Error(`Serialized lazy archive group ${d} has an unsupported kind`);if(r)throw new Error(`Serialized lazy archive group ${d} is missing its kind discriminator`);return gi(l,!0)}),o=this.fs.identityState();this.reconcileLazyIdentityState(o);let c=[...this.serializeValidatedLazyArchiveEntries(o),...s];Is(c);let a=[],u=new Map;for(let l of s){let d=new Map,p=l.mountPrefix.replace(/\/+$/,""),m=l.content!==void 0&&l.inventory!==void 0&&l.activation!==void 0,f=m?new Map(l.inventory.map(w=>[w.vfsPath,w])):null,h=m?new Map(l.inventory.map(w=>[tn(w),w])):null,g=new Map,_=new Map,y=new Map;for(let w of l.entries){let S=null,A=l.materialized||w.materialized===!0||w.isSymlink;if(!w.deleted&&!A){if((w.generation===void 0||w.dataSequence===void 0)&&!t)throw new Error("Live lazy-archive metadata requires inode generation and data sequence");try{S=this.fs.lstat(w.vfsPath)}catch{if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} is missing from the filesystem`);continue}if(S.ino!==w.ino){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} has a different inode`);continue}if(w.generation!==void 0&&S.generation!==w.generation){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} has a different generation`);continue}if(w.dataSequence===void 0){if(!n.canAdoptLegacyLazyStub(S)){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} is not pristine`);continue}}else if(S.dataSequence!==w.dataSequence){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} has a different data sequence`);continue}if(m){y.set(w.vfsPath,S);let x=f.get(w.vfsPath),v=h.get(tn(w))??x;if(!v||(S.mode&Fe)!==hr||S.size!==0||(S.mode&W.S_MODE_BITS)!==v.mode||x?.inodeGroup!==void 0&&x.inodeGroup!==v.inodeGroup)throw new Error(`Serialized lazy tree stub ${w.vfsPath} disagrees with its inventory`);let L=n.inodeKey(S.ino,S.generation),D=w.inodeGroup,Z=g.get(D),N=_.get(L);if(Z!==void 0&&Z!==L||N!==void 0&&N!==D)throw new Error(`Serialized lazy tree inode group ${D} disagrees with the filesystem`);g.set(D,L),_.set(L,D)}}d.set(w.vfsPath,{ino:w.ino,generation:S?.generation??w.generation,dataSequence:S?.dataSequence??w.dataSequence,size:w.size,isSymlink:w.isSymlink,deleted:w.deleted,materialized:A,archivePath:w.archivePath??w.vfsPath.slice(p.length+1),sourcePath:w.sourcePath??w.archivePath??w.vfsPath.slice(p.length+1),type:w.type??(w.isSymlink?"symlink":"file"),inodeGroup:w.inodeGroup,target:w.target})}if(m){let w=new Map;for(let S of l.inventory){if(S.type==="file"||S.type==="hardlink"){w.set(S.inodeGroup,(w.get(S.inodeGroup)??0)+1);continue}let A;try{A=this.fs.lstat(S.vfsPath)}catch{throw new Error(`Serialized lazy tree namespace entry ${S.vfsPath} is missing from the filesystem`)}let R=S.type==="directory"?at:Vr;if((A.mode&Fe)!==R||(A.mode&W.S_MODE_BITS)!==S.mode||S.type==="symlink"&&(A.size!==new TextEncoder().encode(S.target).byteLength||this.fs.readlink(S.vfsPath)!==S.target))throw new Error(`Serialized lazy tree namespace entry ${S.vfsPath} disagrees with its inventory`);S.type==="symlink"&&d.set(S.vfsPath,{ino:A.ino,generation:A.generation,dataSequence:A.dataSequence,size:S.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:S.sourcePath,sourcePath:S.sourcePath,type:"symlink",target:S.target})}if(l.activation?.atomicGroup!==void 0)for(let S of l.inventory){if(S.type!=="file"&&S.type!=="hardlink")continue;if(y.get(S.vfsPath).linkCount!==w.get(S.inodeGroup))throw new Error(`Serialized lazy atomic tree inode group ${S.inodeGroup} has undeclared aliases`)}}let E=l.content===void 0?void 0:en(l.content),O={content:E,url:E?.transports[0]??l.url,mountPrefix:l.mountPrefix,integrity:E?{sha256:E.sha256,bytes:E.bytes}:gr(l.integrity),materialized:l.materialized||!(E&&l.inventory)&&Array.from(d.values()).every(w=>w.deleted||w.materialized),inventory:l.inventory?.map(w=>({...w})),activation:l.activation?{mode:l.activation.mode,capabilities:[...l.activation.capabilities],roots:[...l.activation.roots],...l.activation.atomicGroup===void 0?{}:{atomicGroup:{...l.activation.atomicGroup}}}:void 0,entries:d};if(a.push(O),!O.materialized){for(let[,w]of d)if(!w.deleted&&!w.materialized&&w.generation!==void 0){let S=n.inodeKey(w.ino,w.generation),A=u.get(S);if(A!==void 0&&A!==O)throw new Error(`Serialized lazy archive groups share pending inode ${S}`);if(this.lazyArchiveInodes.has(S))throw new Error(`Serialized lazy archive group collides with pending inode ${S}`);u.set(S,O)}}}for(let l of a){let d=l.activation?.atomicGroup;if(d!==void 0&&this.lazyAtomicGroups.get(d.id)?.committed)throw new Error(`Lazy atomic activation group ${d.id} is already materialized`)}if(i==="reject"&&a.some(l=>{let d=l.activation?.atomicGroup;return d!==void 0&&Lt(d)}))throw new Error("Sealed lazy archive registrations require importVerifiedLazyArchiveEntries()");this.lazyArchiveGroups.push(...a);for(let l of a)this.registerLazyAtomicGroupMembership(l,i==="verified");for(let[l,d]of u)this.lazyArchiveInodes.set(l,d)}rewriteLazyArchiveUrls(e){for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0&&!r?.committed){this.assertLazyAtomicSnapshotMatchesPublic(t);let s=td(i.snapshot,e);t.content=xs(s.content),t.url=s.url,t.integrity={...s.integrity},i.snapshot=s;continue}t.content?(t.content={...t.content,transports:t.content.transports.map(e)},t.url=t.content.transports[0]):t.url=e(t.url)}}serializeLazyArchiveEntries(){let e=[];for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(r?.committed)continue;let a=i.snapshot;if(a.content.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");e.push({kind:ct,content:xs(a.content),inventory:a.inventory.map(u=>({...u})),activation:Qu(a),url:a.url,mountPrefix:a.mountPrefix,integrity:{...a.integrity},materialized:!1,entries:a.entries.filter(u=>!u.deleted&&!u.materialized).map(({vfsPath:u,...l})=>({vfsPath:u,...l}))});continue}let s=Array.from(t.entries,([a,u])=>({vfsPath:a,ino:u.ino,generation:u.generation,dataSequence:u.dataSequence,size:u.size,isSymlink:u.isSymlink,deleted:u.deleted,materialized:u.materialized,archivePath:u.archivePath,sourcePath:u.sourcePath,type:u.type,inodeGroup:u.inodeGroup,target:u.target})).filter(a=>!a.deleted&&!a.materialized);if(s.length===0&&!(t.content&&t.inventory&&!t.materialized))continue;let o=t.content!==void 0&&t.inventory!==void 0&&t.activation!==void 0;if(o&&t.content.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");let c=t.activation?.atomicGroup;if(c!==void 0&&!Lt(c))throw new Error(`Lazy atomic activation group ${c.id} must be sealed before serialization`);e.push(o?{kind:c!==void 0?ct:t.content.source===void 0?_r:mi,content:t.content,inventory:t.inventory,activation:t.activation,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:s}:{kind:mr,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:s})}return e}serializeValidatedLazyArchiveEntries(e){this.assertPendingLazyAtomicSnapshotsReadyForSerialization();let t=this.serializeLazyArchiveEntries();return Is(t),this.validatePendingLazyTreeNamespaceState(e),t}exportLazyArchiveEntries(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),this.serializeValidatedLazyArchiveEntries(e)}pendingDeferredTreeUsage(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),Ms(this.serializeValidatedLazyArchiveEntries(e))}assertCanAppendDeferredTreeUsage(e){yi(e);let t=this.pendingDeferredTreeUsage();yi({groups:t.groups+e.groups,archiveBytes:t.archiveBytes+e.archiveBytes,expandedBytes:t.expandedBytes+e.expandedBytes,payloadBytes:t.payloadBytes+e.payloadBytes,entries:t.entries+e.entries})}assertCanRegisterPendingLazyArchiveGroup(){if(this.reconcileLazyIdentityState(this.fs.identityState()),this.lazyArchiveGroups.filter(t=>{let r=this.lazyAtomicGroupByTree.get(t);return this.sealedLazyAtomicStates.get(t)?.snapshot!==void 0?!r?.committed:!t.materialized&&(t.content!==void 0&&t.inventory!==void 0||Array.from(t.entries.values()).some(s=>!s.deleted&&!s.materialized))}).length>=xe.maxGroups)throw new Error(`Cannot register another lazy archive group: ${xe.maxGroups} pending groups already exist`)}async preparePath(e){let t=!1,r=Math.max(3,this.lazyArchiveGroups.length+1);for(let i=0;i!s.materialized&&s.activation?.mode==="boot-prefetch"),t=0,r,i=Array.from({length:Math.min(e.length,Ru)},async()=>{for(;r===void 0;){let s=t;if(t+=1,s>=e.length)return;try{await this.prepareLazyTreeGroup(e[s])}catch(o){r??=o}}});if(await Promise.all(i),r!==void 0)throw r;return e.length}async materializeRegisteredDeferredTree(e,t){let r=this.deferredTreeMaterializationHandles.get(e);if(r===void 0)throw new Error("Deferred-tree handle was not issued by this filesystem");let i=this.lazyAtomicGroupByTree.get(r);if(i!==void 0)throw new Error(`Deferred tree belongs to atomic activation group ${i.id}; materialize the complete group instead`);if(r.materialized)return!1;let s=this.lazyPreparations.get(r);if(s!==void 0)return s.promise;let o=new Uint8Array(t.byteLength);o.set(t);let c={status:"pending",promise:Promise.resolve(!1)};c.promise=Promise.resolve().then(async()=>(await li(o,"tree",r.integrity),await this.materializeArchiveBytes(r,o),!0)).then(a=>(c.status="fulfilled",a),a=>{throw c.status="rejected",c.error=a,a}),c.promise.catch(()=>{}),this.lazyPreparations.set(r,c);try{return await c.promise}finally{this.lazyPreparations.get(r)===c&&this.lazyPreparations.delete(r)}}async prepareLazyTreeGroup(e){let t=this.lazyAtomicGroupByTree.get(e);if(t?.committed||t===void 0&&e.materialized)return!1;let r=this.sealedLazyAtomicStates.get(e)?.snapshot,i={token:t?.token??e,path:r?.activation.roots[0]??e.activation?.roots[0]??e.mountPrefix,directGroup:e,...t===void 0?{}:{atomicGroup:t}},s=this.lazyPreparations.get(i.token)??this.startLazyPreparation(i);try{return await s.promise}finally{this.lazyPreparations.get(i.token)===s&&this.lazyPreparations.delete(i.token)}}async ensureMaterialized(e){return this.preparePath(e)}async materializePath(e){if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0)return!1;let t;try{t=this.fs.stat(e)}catch{return!1}let r=n.inodeKey(t.ino,t.generation),i=this.lazyFiles.get(r);if(i){let o=this.lazyTransport,c=await this.fetchLazyBytes({id:`file:${t.ino}`,kind:"file",url:i.url,path:i.path,fallbackTotalBytes:i.size},o);for(let a=0;a<3;a++){if(this.lazyFiles.get(r)!==i)return!1;for(let u of new Set([e,...i.paths]))if(te(o.signal),this.fs.replaceIfIdentity(u,i.ino,i.generation,i.dataSequence,c))return i.path=u,this.lazyFiles.delete(r),!0;this.reconcileLazyIdentityState(this.fs.identityState())}throw new Error(`Lazy file kept changing names while materializing: ${e}`)}let s=this.lazyArchiveInodes.get(r);return s?(await this.ensureArchiveMaterialized(s,{path:e,ino:t.ino,generation:t.generation}),!this.lazyArchiveInodes.has(r)):!1}async decodeAndValidateLazyTree(e,t,r){let i=r?.content??e.content,s=r?.inventory??e.inventory;if(!i||!s)throw new Error("Lazy tree is missing its decoder or complete inventory");let o=new Map,c=new Map(s.map(p=>[p.vfsPath,p]));if(i.source!==void 0)for(let p of i.source.entries)o.set(p.sourcePath,p);else for(let p of s){if(p.type==="hardlink"){let f=c.get(p.target);if(!f)throw new Error(`Lazy tree hardlink target disappeared: ${p.target}`);if(p.sourcePath===f.sourcePath)continue}if(o.get(p.sourcePath))throw new Error(`Lazy tree inventory duplicates source member ${p.sourcePath}`);o.set(p.sourcePath,{sourcePath:p.sourcePath,type:p.type,mode:p.mode,size:p.size,...p.type==="symlink"?{target:p.target}:{},...p.type==="hardlink"?{target:c.get(p.target)?.sourcePath}:{}})}let a=new Map,u=0;if(i.decoder==="zip-v1"){let{parseZipCentralDirectory:p,extractZipEntryBounded:m}=await Promise.resolve().then(()=>(ti(),ei)),f=p(t);if(f.length!==i.sourceEntryCount||f.length!==o.size)throw new Error("Lazy ZIP tree decoded inventory counts differ from its descriptor");for(let h of f){let g=h.isDirectory?h.fileName.replace(/\/$/,""):h.fileName;if(a.has(g))throw new Error(`Lazy ZIP tree duplicates source member ${g}`);let _=o.get(g);if(!_)throw new Error(`Lazy ZIP tree has undeclared source member ${g}`);if(u+=h.uncompressedSize,u>i.expandedBytes||h.uncompressedSize!==_.size)throw new Error(`Lazy ZIP tree member ${g} exceeds its inventory`);let y=h.isDirectory?"directory":h.isSymlink?"symlink":"file",E=i.modePolicy==="portable-posix-v1"?y==="directory"?493:y==="symlink"?511:(h.mode&73)!==0?493:420:h.mode&W.S_MODE_BITS;if(y!==_.type||E!==_.mode)throw new Error(`Lazy ZIP tree member ${g} differs from inventory`);if(h.isDirectory)a.set(g,{type:"directory",mode:E});else{let O=m(t,h,_.size);if(h.isSymlink){let w;try{w=new TextDecoder("utf-8",{fatal:!0}).decode(O)}catch{throw new Error(`Lazy ZIP tree symlink ${g} is not UTF-8`)}a.set(g,{type:"symlink",mode:E,target:w})}else a.set(g,{type:"file",mode:E,data:O})}}}else{let{parseTarGzip:p}=await Promise.resolve().then(()=>(_s(),ms)),m=p(t,{label:`Lazy tree ${i.sha256}`,limits:{maxCompressedBytes:i.bytes,maxUncompressedBytes:i.expandedBytes,maxEntries:i.sourceEntryCount}});u=new DataView(t.buffer,t.byteOffset,t.byteLength).getUint32(t.byteLength-4,!0);for(let f of m){if(a.has(f.path))throw new Error(`Lazy TAR tree duplicates source member ${f.path}`);f.type==="file"?a.set(f.path,{type:"file",mode:f.mode,data:f.data}):f.type==="directory"?a.set(f.path,{type:"directory",mode:f.mode}):a.set(f.path,{type:f.type,mode:f.mode,target:f.linkName})}}if(a.size!==i.sourceEntryCount||a.size!==o.size||u!==i.expandedBytes)throw new Error("Lazy tree decoded inventory counts differ from its descriptor");for(let[p,m]of o){let f=a.get(p);if(!f)throw new Error(`Lazy tree is missing source member ${p}`);let h=m.type;if(f.type!==h)throw new Error(`Lazy tree member ${p} is ${f.type}, expected ${h}`);if((f.mode&W.S_MODE_BITS)!==m.mode)throw new Error(`Lazy tree member ${p} mode differs from inventory`);if(h==="file"&&f.data?.byteLength!==m.size)throw new Error(`Lazy tree member ${p} size differs from inventory`);if(h==="symlink"&&f.target!==m.target)throw new Error(`Lazy tree symlink ${p} target differs from inventory`);if(h==="hardlink"&&f.target!==m.target)throw new Error(`Lazy tree hardlink ${p} target differs from inventory`)}let l=new Set(s.flatMap(p=>p.materialization==="archive-homebrew-relocate"?[p.sourcePath]:[]));if(i.source!==void 0){let p=new Map(i.source.entries.map(h=>[h.sourcePath,h])),m=Ds(i.source.entries),f=i.source.entries.filter(h=>h.sourcePath==="INSTALL_RECEIPT.json"||h.sourcePath.endsWith("/INSTALL_RECEIPT.json"));if(f.length>1)throw new Error(`Lazy Homebrew bottle has ${f.length} INSTALL_RECEIPT.json source members, expected at most one`);if(f.length===0){if(l.size>0)throw new Error("Lazy Homebrew bottle marks receipt relocation without INSTALL_RECEIPT.json")}else{let h=f[0],g=h.type==="file"?h:m.get(h.sourcePath),_=g===void 0?void 0:a.get(g.sourcePath);if(g?.type!=="file"||_?.type!=="file"||_.data===void 0)throw new Error("Lazy Homebrew bottle INSTALL_RECEIPT.json is not regular");let y=Go(_.data),E=h.sourcePath.lastIndexOf("/"),O=E<0?"":h.sourcePath.slice(0,E),w=new Set(y.changedFiles.map(A=>O.length===0?A:`${O}/${A}`));if(l.size!==w.size||[...l].some(A=>!w.has(A)))throw new Error("Lazy Homebrew bottle relocation markers differ from INSTALL_RECEIPT.json");let S=new Set;for(let A of w){let R=p.get(A),x=R?.type==="file"?R:R===void 0?void 0:m.get(R.sourcePath),v=x===void 0?void 0:a.get(x.sourcePath);if(x?.type!=="file"||v?.type!=="file"||v.data===void 0)throw new Error(`Lazy Homebrew bottle changed source ${A} is not regular`);S.has(x.sourcePath)||(v.data=Ho(v.data,y,A),S.add(x.sourcePath))}}}else if(l.size>0)throw new Error("Lazy tree receipt relocation requires original-bottle source truth");let d=new Map;for(let p of s){if(p.type!=="file"||p.materialization==="descriptor")continue;let m=a.get(p.sourcePath);if(m?.type!=="file"||!m.data)throw new Error(`Lazy tree has no file content for ${p.sourcePath}`);d.set(p.sourcePath,m.data)}return d}async ensureArchiveMaterialized(e,t){let r=this.lazyAtomicGroupByTree.get(e);if(r!==void 0){if(r.committed)return;let o=this.sealedLazyAtomicStates.get(e)?.snapshot,c=this.lazyPreparations.get(r.token)??this.startLazyPreparation({token:r.token,path:o?.activation.roots[0]??e.activation?.roots[0]??e.mountPrefix,atomicGroup:r});try{await c.promise}finally{this.lazyPreparations.get(r.token)===c&&this.lazyPreparations.delete(r.token)}return}if(e.materialized)return;let i=this.lazyTransport,s=await this.fetchLazyArchiveData(e,i);te(i.signal),await this.materializeArchiveBytes(e,s,t,i.signal)}async fetchLazyArchiveData(e,t,r){let i=r?.content??e.content,s=r?.inventory??e.inventory,o=i!==void 0&&s!==void 0,c=r?.mountPrefix??e.mountPrefix,a=r?.integrity??e.integrity,u=o?i.transports:[r?.url??e.url],l=[],d=null;for(let[p,m]of u.entries())try{d=await this.fetchLazyBytes({id:`archive:${c}:${i?.sha256??m}:${p}`,kind:o?"tree":"archive",url:m,mountPrefix:c,integrity:a},t);break}catch(f){if(te(t.signal),Cs(f))throw f;l.push(f instanceof Error?f.message:String(f))}if(te(t.signal),d===null)throw new Error(`All ${u.length} lazy ${o?"tree":"archive"} transports failed: ${l.join("; ")}`);return d}async materializeArchiveBytes(e,t,r,i){if(te(i),e.materialized)return;let s=await this.prepareLazyArchiveContents(e,t,i),o=r?n.inodeKey(r.ino,r.generation):null;for(let c=0;c<3;c++){let a=this.collectLazyArchiveReplacements(e,s,r);if(a.size>0&&(te(i),!this.fs.replaceManyIfIdentities(Array.from(a.values(),Ls)))){if(this.reconcileLazyIdentityState(this.fs.identityState()),o&&!this.lazyArchiveInodes.has(o))return;continue}if(te(i),this.publishLazyArchiveReplacements(e,a),e.materialized||(this.reconcileLazyIdentityState(this.fs.identityState()),o&&!this.lazyArchiveInodes.has(o)))return}if(o&&this.lazyArchiveInodes.has(o))throw new Error(`Lazy archive member kept changing names while materializing: ${r?.path}`)}async prepareLazyArchiveContents(e,t,r,i){te(r);let s=i?.content??e.content,o=i?.inventory??e.inventory,a=s!==void 0&&o!==void 0?await this.decodeAndValidateLazyTree(e,t,i):null;te(r);let{parseZipCentralDirectory:u,extractZipEntry:l}=await Promise.resolve().then(()=>(ti(),ei));te(r);let d=a?[]:u(t),p=new Map;for(let _ of d){if(p.has(_.fileName))throw new Error(`Lazy archive contains duplicate member: ${_.fileName}`);p.set(_.fileName,_)}let f=(i?.mountPrefix??e.mountPrefix).replace(/\/+$/,""),h=new Map,g=i===void 0?Array.from(e.entries):i.entries.map(_=>[_.vfsPath,_]);for(let[_,y]of g){if(y.deleted||y.materialized)continue;let E=y.archivePath??_.slice(f.length+1),O=a?void 0:p.get(E),w=a?.get(E);if(a){if(w===void 0||w.byteLength!==y.size)throw new Error(`Lazy tree member ${E} does not match its registered metadata`)}else if(O===void 0||O.isDirectory||O.isSymlink||O.uncompressedSize!==y.size)throw new Error(`Lazy archive member ${E} does not match its registered metadata`);if(y.generation===void 0)continue;let S=n.inodeKey(y.ino,y.generation),A=h.get(S);if(A&&A.archivePath!==E)throw new Error(`Lazy archive aliases for inode ${S} name different members`);if(!A){let R=w??l(t,O);if(R.byteLength!==y.size)throw new Error(`Lazy archive member ${E} extracted ${R.byteLength} bytes, expected ${y.size}`);h.set(S,{archivePath:E,content:R})}}return h}collectLazyArchiveReplacements(e,t,r,i){let s=new Map,o=i===void 0?Array.from(e.entries):i.entries.map(c=>[c.vfsPath,c]);for(let[c,a]of o){if(a.deleted||a.materialized||a.generation===void 0)continue;let u=n.inodeKey(a.ino,a.generation);if(this.lazyArchiveInodes.get(u)!==e)continue;let l=t.get(u);if(!l)throw new Error(`Lazy archive has no extracted content for inode ${u}`);let d=s.get(u);d||(d={ino:a.ino,generation:a.generation,dataSequence:a.dataSequence??0,paths:new Set,content:l.content},s.set(u,d)),d.paths.add(c),r&&r.ino===a.ino&&r.generation===a.generation&&d.paths.add(r.path)}return s}publishLazyArchiveReplacements(e,t){for(let[r,i]of t){this.lazyArchiveInodes.delete(r);for(let s of e.entries.values())s.ino===i.ino&&s.generation===i.generation&&(s.materialized=!0)}e.materialized=Array.from(e.entries.values()).every(r=>r.deleted||r.materialized)}collectAtomicTreeNamespace(e,t){let r=new Set,i=new Map,s=new Map,o=new Map(t.entries.map(a=>[a.vfsPath,a]));for(let a of t.inventory)(a.type==="file"||a.type==="hardlink")&&s.set(a.inodeGroup,(s.get(a.inodeGroup)??0)+1);let c=[];for(let a of t.inventory){let u;try{u=this.fs.lstat(a.vfsPath)}catch{throw new Error(`Lazy atomic tree changed at ${a.vfsPath}`)}let l=a.type==="directory"?at:a.type==="symlink"?Vr:hr;if((u.mode&Fe)!==l||(u.mode&W.S_MODE_BITS)!==a.mode)throw new Error(`Lazy atomic tree changed at ${a.vfsPath}`);if(a.type==="symlink"){let d=o.get(a.vfsPath);if(d===void 0||!d.isSymlink||d.deleted||d.ino!==u.ino||d.generation!==u.generation||d.dataSequence!==u.dataSequence||this.fs.readlink(a.vfsPath)!==a.target)throw new Error(`Lazy atomic tree changed at ${a.vfsPath}`)}else if(a.type==="file"||a.type==="hardlink"){let d=o.get(a.vfsPath);if(d===void 0||d.deleted||d.materialized||d.isSymlink||d.generation===void 0||d.inodeGroup!==a.inodeGroup||d.ino!==u.ino||d.generation!==u.generation||d.dataSequence!==u.dataSequence||u.size!==0||u.linkCount!==s.get(a.inodeGroup))throw new Error(`Lazy atomic tree changed at ${a.vfsPath}`);let p=n.inodeKey(d.ino,d.generation);if(this.lazyArchiveInodes.get(p)!==e)throw new Error(`Lazy atomic tree lost deferred ownership of ${a.vfsPath}`);let m=i.get(a.inodeGroup);if(m!==void 0&&m!==p)throw new Error(`Lazy atomic tree split hard links at ${a.vfsPath}`);i.set(a.inodeGroup,p),r.add(p)}c.push({path:a.vfsPath,expectedIno:u.ino,expectedGeneration:u.generation,expectedDataSequence:u.dataSequence,expectedMode:u.mode,expectedLinkCount:u.linkCount,expectedSize:u.size,expectedUid:u.uid,expectedGid:u.gid})}return{guards:c,pendingIdentities:r.size}}assertLazyAtomicSnapshotMatchesPublic(e){let t=this.sealedLazyAtomicStates.get(e),r=t?.snapshot,i=e.activation?.atomicGroup,s=r?.member??i?.member??"unknown",o;if(r!==void 0)try{o=Zr(e,r.id,r.member)}catch{o=void 0}if(t===void 0||r===void 0||i===void 0||!Lt(i)||i.id!==r.id||i.member!==r.member||i.descriptorSha256!==r.descriptorSha256||i.expectedCount!==r.expectedCount||i.cohortSha256!==r.cohortSha256||o===void 0||!vs(r,o))throw new Error(`Lazy atomic activation member ${s} changed after sealing`);return t}assertPendingLazyAtomicSnapshotsReadyForSerialization(){for(let e of this.lazyArchiveGroups){if(!this.sealedLazyAtomicStates.has(e)||this.lazyAtomicGroupByTree.get(e)?.committed)continue;let r=this.assertLazyAtomicSnapshotMatchesPublic(e);if(!r.verified)throw new Error(`Lazy atomic activation group ${r.snapshot.id} has not been cryptographically verified after import`)}}async validatePendingLazyAtomicGroupSeals(e){for(let t of this.lazyAtomicGroups.values()){if(t.committed||t.expectedCount===void 0||t.cohortSha256===void 0)continue;let r=[...t.groups.entries()].sort(([i],[s])=>is?1:0).map(([,i])=>i);await this.ensureLazyAtomicGroupSealValidated(t,r,e)}}async ensureLazyAtomicGroupSealValidated(e,t,r){if(this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r))return;let i=e.sealValidationFlight;if(i===void 0&&(i=this.validateLazyAtomicGroupSealOnce(e,t),e.sealValidationFlight=i,i.then(()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)},()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)})),await i,!this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r))throw new Error(`Lazy atomic activation group ${e.id} seal verification did not authenticate every member`)}assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r){if(e.committed)return!0;if(e.expectedCount===void 0||e.cohortSha256===void 0||t.length!==e.expectedCount)throw new Error(`Lazy atomic activation group ${e.id} is not completely sealed`);let i=!0,s=[];for(let o of t){let c=this.assertLazyAtomicSnapshotMatchesPublic(o),a=c.snapshot;if(a.id!==e.id||a.expectedCount!==e.expectedCount||a.cohortSha256!==e.cohortSha256||e.groups.get(a.member)!==o)throw new Error(`Lazy atomic activation group ${e.id} has an inconsistent member`);i&&=c.verified,s.push(c)}if(i&&r)for(let o=0;ofh?1:0).map(([,f])=>f);if(t.length===0)throw new Error(`Lazy atomic activation group ${e.id} has no trees`);if(await this.ensureLazyAtomicGroupSealValidated(e,t,!0),e.committed)return;let r=t.map(f=>this.sealedLazyAtomicStates.get(f).snapshot),i=t.map((f,h)=>({group:f,...this.collectAtomicTreeNamespace(f,r[h])})),s=this.lazyTransport,o=new Array(t.length),c=0,a=!1,u,l=Array.from({length:Math.min(xu,t.length)},async()=>{for(;!a;){let f=c++;if(f>=t.length)return;let h=t[f],g=r[f];try{let _=await this.fetchLazyArchiveData(h,s,g);te(s.signal),o[f]={group:h,snapshot:g,contents:await this.prepareLazyArchiveContents(h,_,s.signal,g)}}catch(_){a||(a=!0,u=_)}}});if(await Promise.all(l),a)throw o.fill(void 0),u;te(s.signal);for(let f of t)this.assertLazyAtomicSnapshotMatchesPublic(f);let d=[],p=[],m=[];for(let f=0;f{let c=this.lazyAtomicGroupByTree.get(o);return this.sealedLazyAtomicStates.get(o)?.snapshot===void 0?!o.materialized&&o.content!==void 0&&o.inventory!==void 0:!c?.committed});if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0&&r.length===0)return;let i=Array.from(this.lazyFiles.values(),o=>o.path);for(let o of i)await this.ensureMaterialized(o);let s=new Set(this.lazyArchiveInodes.values());for(let o of r)s.add(o);for(let o of s)await this.prepareLazyTreeGroup(o)}this.reconcileLazyIdentityState(this.fs.identityState());let e=this.lazyArchiveGroups.some(t=>{let r=this.lazyAtomicGroupByTree.get(t);return this.sealedLazyAtomicStates.get(t)?.snapshot===void 0?!t.materialized&&t.content!==void 0&&t.inventory!==void 0:!r?.committed});if(this.lazyFiles.size!==0||this.lazyArchiveInodes.size!==0||e)throw new Error("Cannot create a self-contained VFS image while lazy entries remain pending")}async saveImage(e){e?.materializeAll&&await this.materializeAllLazyEntries(),await this.validatePendingLazyAtomicGroupSeals(!1);let{bytes:t,identities:r}=this.fs.snapshotState({normalizeTimestampsMs:e?.normalizeTimestampsMs});this.reconcileLazyIdentityState(r);let i=this.serializeLazyEntries(),s=i.length>0,o=s?new TextEncoder().encode(JSON.stringify(i)):new Uint8Array(0);if(o.byteLength>Yr)throw new Error(`VFS image lazy metadata exceeds ${Yr} bytes`);let c=this.serializeValidatedLazyArchiveEntries(r),a=c.length>0,u=a?new TextEncoder().encode(JSON.stringify(c)):new Uint8Array(0);if(u.byteLength>jr)throw new Error(`VFS image lazy archive metadata exceeds ${jr} bytes`);let l=e?.metadata===void 0?this.imageMetadata:e.metadata,d=Fu(l),p=d.byteLength>0,m=a?4+u.byteLength:0,f=p?4+d.byteLength:0,h=pe+t.byteLength+4+o.byteLength+m+f,g=new Uint8Array(h),_=new DataView(g.buffer);_.setUint32(0,fi,!0),_.setUint32(4,hi,!0),_.setUint32(8,(s?ai:0)|(a?Xr:0)|(a?ui:0)|(p?ci:0),!0),_.setUint32(12,t.byteLength,!0),g.set(t,pe);let y=pe+t.byteLength;if(_.setUint32(y,o.byteLength,!0),o.byteLength>0&&g.set(o,y+4),a){let E=y+4+o.byteLength;_.setUint32(E,u.byteLength,!0),g.set(u,E+4)}if(p){let E=y+4+o.byteLength+m;_.setUint32(E,d.byteLength,!0),g.set(d,E+4)}return g}static readImageMetadata(e){let t=qr(e);if(!(t.flags&ci))return null;let{metadataOffset:r}=Os(t.image,t.view,t.flags,t.sabLen);if(t.image.byteLengthzt)throw new Error(`VFS image metadata exceeds ${zt} bytes`);if(t.image.byteLength0){let g=r.subarray(f+4,f+4+h),_=Ne(As(g,"VFS image lazy metadata"),"VFS image lazy entries",0,bt);m.importLazyEntriesInternal(_,!0)}if(s&Xr){let g=c.archiveOffset,_=i.getUint32(g,!0);if(_>0){let y=r.subarray(g+4,g+4+_),E=As(y,"VFS image lazy archive metadata");m.importLazyArchiveEntriesInternal(E,!0,!!(s&ui),"pending")}}return m}adaptStat(e){return{dev:0,ino:e.ino,mode:e.mode,nlink:e.linkCount,uid:e.uid,gid:e.gid,size:e.size,atimeMs:e.atime,mtimeMs:e.mtime,ctimeMs:e.ctime}}adaptStatWithLazySize(e){let t=this.adaptStat(e),r=this.lazyFileForStat(e);if(r)return t.size=r.size,t;let i=this.lazyArchiveForStat(e);if(i){for(let s of this.lazyArchiveEntriesForRead(i))if(s.ino===e.ino&&s.generation===e.generation&&!s.deleted){t.size=s.size;break}}return t}open(e,t,r){(t&ar)===0&&!((t&or)!==0&&(t&Vn)!==0)&&this.guardSynchronousLazyAccess(e);let i=this.fs.open(e,t,r);return(t&ar)!==0&&this.invalidateLazyData(this.fs.fstat(i)),i}close(e){return this.fs.close(e),0}read(e,t,r,i){if(i>0){let s=this.lazyBackingForStat(this.fs.fstat(e));s&&(this.reconcileLazyIdentityState(this.fs.identityState()),s=this.lazyBackingForStat(this.fs.fstat(e)),s&&this.guardSynchronousLazyAccess(s.path))}return r!==null?this.fs.readAt(e,t.subarray(0,i),typeof r=="bigint"?Fn(r):r):this.fs.read(e,t.subarray(0,i))}write(e,t,r,i){if(r!==null){let o=this.fs.writeAt(e,t.subarray(0,i),typeof r=="bigint"?Fn(r):r);return o>0&&this.invalidateLazyData(this.fs.fstat(e)),o}let s=this.fs.write(e,t.subarray(0,i));return s>0&&this.invalidateLazyData(this.fs.fstat(e)),s}append(e,t,r,i){let s=this.fs.append(e,t.subarray(0,r),Ao(i));return s.written>0&&this.invalidateLazyData(this.fs.fstat(e)),s}seek(e,t,r){return this.fs.lseek(e,typeof t=="bigint"?kn(t):t,r)}fstat(e){return this.adaptStatWithLazySize(this.fs.fstat(e))}fpathconf(e,t){let r=this.fstat(e);return Cn(r,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}ftruncate(e,t){this.fs.ftruncate(e,t),this.invalidateLazyData(this.fs.fstat(e))}fsync(e){}fchmod(e,t){this.fs.fchmod(e,t)}fchown(e,t,r){this.fs.fchown(e,t,r)}stat(e){return this.adaptStatWithLazySize(this.fs.stat(e))}lstat(e){return this.adaptStatWithLazySize(this.fs.lstat(e))}statfs(e){this.fs.stat(e);let t=this.fs.statfs();return{type:1397114451,bsize:t.blockSize,blocks:t.totalBlocks,bfree:t.freeBlocks,bavail:t.freeBlocks,files:t.totalInodes,ffree:t.freeInodes,fsid:0,namelen:t.maxName,frsize:t.blockSize,flags:0}}pathconf(e,t){let r=this.stat(e);return Cn(r,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}mkdir(e,t){this.fs.mkdir(e,t)}rmdir(e){this.fs.rmdir(e)}unlink(e){let t=this.fs.unlink(e),r=n.inodeKey(t.ino,t.generation);if(t.linkCount>1&&(this.lazyFiles.has(r)||this.lazyArchiveInodes.has(r))){this.reconcileLazyIdentityState(this.fs.identityState());return}let i=this.lazyFiles.get(r);i&&(i.paths.delete(e),t.linkCount<=1?this.lazyFiles.delete(r):i.path===e&&(i.path=i.paths.values().next().value));let s=this.lazyArchiveInodes.get(r);if(s){let o=s.entries.get(e);if(t.linkCount<=1){for(let c of s.entries.values())c.ino===t.ino&&c.generation===t.generation&&(c.deleted=!0);this.lazyArchiveInodes.delete(r)}else o&&s.entries.delete(e)}}rename(e,t){let{source:r,replaced:i}=this.fs.rename(e,t);if(i&&i.ino===r.ino&&i.generation===r.generation)return;let s=!1;if(i){let o=n.inodeKey(i.ino,i.generation);i.linkCount>1&&(this.lazyFiles.has(o)||this.lazyArchiveInodes.has(o))&&(this.reconcileLazyIdentityState(this.fs.identityState()),s=!0);let c=this.lazyFiles.get(o);!s&&c&&(c.paths.delete(t),i.linkCount<=1?this.lazyFiles.delete(o):c.path===t&&(c.path=c.paths.values().next().value));let a=this.lazyArchiveInodes.get(o);if(!s&&a){let u=a.entries.get(t);i.linkCount<=1?(u&&(u.deleted=!0),this.lazyArchiveInodes.delete(o)):u&&a.entries.delete(t)}}s||this.rewriteLazyNamespacePaths(r,e,t)}link(e,t){let r=this.fs.link(e,t),i=n.inodeKey(r.ino,r.generation),s=this.lazyFiles.get(i);s&&s.paths.add(t);let o=this.lazyArchiveInodes.get(i);if(o){let c=Array.from(o.entries.values()).find(a=>a.ino===r.ino&&a.generation===r.generation);c&&o.entries.set(t,{...c})}}symlink(e,t){this.fs.symlink(e,t)}readlink(e){return this.fs.readlink(e)}chmod(e,t){this.fs.chmod(e,t)}chown(e,t,r){this.fs.chown(e,t,r)}lchown(e,t,r){this.fs.lchown(e,t,r)}createFileWithOwner(e,t,r,i,s){let o=this.open(e,ys,t);s.length>0&&this.write(o,s,null,s.length),this.close(o),this.chown(e,r,i),this.chmod(e,t)}mkdirWithOwner(e,t,r,i){this.mkdir(e,t),this.chown(e,r,i),this.chmod(e,t)}symlinkWithOwner(e,t,r,i){this.symlink(e,t),this.lchown(t,r,i)}copyPathToFreshFileSystem(e,t,r,i,s){let o=this.lstat(e),c=o.mode&Fe,a=o.mode&W.S_MODE_BITS;if(c===at){e==="/"?(t.chown(e,o.uid,o.gid),t.chmod(e,a)):t.mkdirWithOwner(e,a,o.uid,o.gid);let p=this.opendir(e);try{for(;;){let m=this.readdir(p);if(!m)break;m.name==="."||m.name===".."||this.copyPathToFreshFileSystem(e==="/"?`/${m.name}`:`${e}/${m.name}`,t,r,i,s)}}finally{this.closedir(p)}n.applyTimes(t,e,o);return}let u=o.nlink>1?`${o.dev}:${o.ino}`:null,l=u?s.get(u):void 0;if(l){t.link(l,e);return}if(c===Vr){t.symlinkWithOwner(this.readlink(e),e,o.uid,o.gid),u&&s.set(u,e);return}if(c!==hr)throw new Error(`Unsupported file type while rebasing VFS: ${e}`);if(r.has(e)||i.has(e)){t.createFileWithOwner(e,a,o.uid,o.gid,new Uint8Array(0)),n.applyTimes(t,e,o),u&&s.set(u,e);return}this.copyRegularFileToFreshFileSystem(e,t,o,a),u&&s.set(u,e)}copyRegularFileToFreshFileSystem(e,t,r,i){let s=this.open(e,wu,0),o=null;try{o=t.open(e,ys,i);let c=new Uint8Array(Math.min(Ou,Math.max(1,r.size))),a=r.size;for(;a>0;){let u=Math.min(c.byteLength,a),l=this.read(s,c,null,u);if(l<=0)throw new Error(`Unexpected EOF while rebasing VFS file: ${e}`);let d=0;for(;d!e||e==="."||e===".."))throw new Error(`Binary resolver path must be a normalized portable relative path: ${JSON.stringify(n)}`);return n}var lt=new Set(["wasm32","wasm64"]);function je(n){if(ld(n),!n.startsWith("programs/"))return n;let e=n.slice(9),t=e.split("/",1)[0];return lt.has(t)?n:`programs/wasm32/${e}`}function fd(n,e=$(Ti(),"wasm")){let t=je(n),r=[$(e,t)];return n==="kernel.wasm"?r.push($(e,"kandelo-kernel.wasm")):n==="userspace.wasm"?r.push($(e,"wasm_posix_userspace.wasm")):n==="rootfs.vfs"&&r.push($(e,"rootfs.vfs")),r}var an=class extends Error{constructor(e){super(e),this.name="BinaryNotFoundError"}};function ea(){let n=[],e=!1;try{let r=dt();e=!0;for(let[i,s]of[["local-binaries",$(r,"local-binaries")],["binaries",$(r,"binaries")]])n.push({label:i,root:s,identity:i==="local-binaries"?"local-generation":"program-cache",allowRegularFileClosure:!1,candidatesFor(o){return[$(s,je(o))]}})}catch{}let t=$(Ti(),"wasm");return n.push({label:"installed package",root:t,identity:"installed-package",allowRegularFileClosure:!e,candidatesFor(r){return fd(r,t)}}),n}function Pt(n,e){return new Error(`Invalid package manifest ${n}: ${e}`)}function ce(n){try{return cn(n),!0}catch(e){if(e instanceof Error&&"code"in e&&e.code==="ENOENT")return!1;throw e}}function Gs(n,e,t){if(n.length===0||n.startsWith("/")||n.includes("\\")||n.includes("\0")||n.split("/").some(r=>!r||r==="."||r===".."))throw Pt(e,`${t} must be a normalized portable relative path`);return n}function on(n,e,t,r=!0){if(n.length===0||n==="."||n===".."||n.includes("/")||n.includes("\\")||n.includes("\0")||!r&&n.includes("@"))throw Pt(e,`${t} must be a safe single path component`);return n}var Hs="kandelo-program-packages-v2",Ce="program-packages.json",Vs=null,hd=null,sn=null,Oi=0;function vi(){return hd??$(Ti(),"wasm",Ce)}function ta(){if(Object.prototype.hasOwnProperty.call(process.env,"WASM_POSIX_DEPS_REGISTRY")){let t=null;return(process.env.WASM_POSIX_DEPS_REGISTRY??"").split(":").filter(Boolean).map(r=>r.startsWith("~/")&&process.env.HOME!==void 0?$(process.env.HOME,r.slice(2)):un(r)?Ie(r):(t??=dt(),Ie(t,r)))}let n;try{n=$(dt(),"packages","registry")}catch{return null}let e=!1;if(ce(n)){if(!Ye(n).isDirectory())return[n];e=js(n,{withFileTypes:!0}).filter(t=>t.isDirectory()||t.isSymbolicLink()).some(t=>ce($(n,t.name,"package.toml")))}return!e&&ra()===null&&ce(vi())?null:[n]}function ra(){let n;try{n=dt()}catch{return null}if(!Er($(n,"tools","xtask","Cargo.toml"))||!Er($(n,"scripts","dev-shell.sh")))return null;try{let e=Re(xi()),t=Re(n);return[$(t,"host"),$(t,"scripts")].some(i=>Er(i)&&ki(Re(i),e))?t:null}catch{return null}}function Li(n,e,t){let r=[typeof t.stderr=="string"?t.stderr.trim():"",typeof t.stdout=="string"?t.stdout.trim():"",t.error?.message??""].filter(Boolean).join(` `);return`${n} ${e.join(" ")} failed${t.status===null?"":` with status ${t.status}`}${r?`: -${r}`:""}`}function hd(n){let e=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,t=e?"rustc":"bash",r=e?["-vV"]:[$(n,"scripts","dev-shell.sh"),"rustc","-vV"],i=Oi(t,r,{cwd:n,encoding:"utf8"});if(i.status!==0)throw new Error(xi(t,r,i));let s=i.stdout.split(/\r?\n/).find(o=>o.startsWith("host: "))?.slice(6).trim();if(!s)throw new Error(`Could not determine the Rust host target for ${n}`);return s}function Si(n){try{if(an(n).isFile())return Re(n)}catch{}throw new Error(`Prepared xtask is not a regular file: ${n}`)}function pd(n){let e=process.env.WASM_POSIX_XTASK_BIN;if(e!==void 0){let u=cn(e)?Ie(e):Ie(n,e);return Si(u)}if(on?.sourceRepoRoot===n)return Si(on.xtaskPath);let t=hd(n),r=$(n,"target",t,"release",process.platform==="win32"?"xtask.exe":"xtask"),i=["build","--release","-p","xtask","--target",t,"--quiet"],s=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,o=s?"cargo":"bash",a=s?i:[$(n,"scripts","dev-shell.sh"),"cargo",...i],c=Oi(o,a,{cwd:n,encoding:"utf8"});if(c.status!==0)throw new Error(xi(o,a,c));return on={sourceRepoRoot:n,xtaskPath:Si(r)},on.xtaskPath}function md(){let n=ta();if(n===null)return;let e=ea();if(e===null)return;if(Hs){Hs(n,e);return}let t=pd(n),r=["build-deps","program-index-context-check","--source-repo-root",n],i=Oi(t,r,{cwd:n,encoding:"utf8",env:{...process.env,WASM_POSIX_DEPS_REGISTRY:e.join(":")}});if(i.status!==0)throw new Error(`Program package source projection is not current: -${xi(t,r,i)}`)}function _d(n,e){if(Ei>0||!n.some(t=>t.startsWith("programs/")))return e();Ei+=1;try{return md(),e()}finally{Ei-=1}}function Xe(n,e){let t=Object.keys(n).sort(),r=[...e].sort();return t.length===r.length&&t.every((i,s)=>i===r[s])}function wi(n){let e;try{e=JSON.parse(ct(n,"utf8"))}catch(o){throw new Error(`Invalid program package index ${n}: ${o instanceof Error?o.message:String(o)}`)}if(typeof e!="object"||e===null||!Xe(e,["format","identities","packages"])||e.format!==Gs||typeof e.identities!="object"||e.identities===null||Array.isArray(e.identities)||typeof e.packages!="object"||e.packages===null||Array.isArray(e.packages))throw new Error(`Invalid program package index ${n}: expected ${Gs}`);let t=new Map,r=e.identities;for(let[o,a]of Object.entries(r)){if(nn(o,n,"identity package name",!1),typeof a!="object"||a===null||!Xe(a,["manifestSha256","cacheKeys"])||typeof a.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(a.manifestSha256)||typeof a.cacheKeys!="object"||a.cacheKeys===null||Array.isArray(a.cacheKeys))throw new Error(`Invalid program package index ${n}: malformed identity ${JSON.stringify(o)}`);let c=a.cacheKeys;if(!Xe(c,["wasm32","wasm64"])||Object.values(c).some(u=>typeof u!="string"||!/^[a-f0-9]{64}$/.test(u)))throw new Error(`Invalid program package index ${n}: identity ${JSON.stringify(o)} has invalid contextual cache keys`);t.set(o,{manifestSha256:a.manifestSha256,cacheKeys:c})}let i=new Map,s=e.packages;for(let[o,a]of Object.entries(s)){if(nn(o,n,"package name",!1),typeof a!="object"||a===null||!Xe(a,["manifestSha256","arches","cacheKeys","dependencyClosures","members"])||!Array.isArray(a.arches)||typeof a.cacheKeys!="object"||a.cacheKeys===null||Array.isArray(a.cacheKeys)||typeof a.dependencyClosures!="object"||a.dependencyClosures===null||Array.isArray(a.dependencyClosures)||!Array.isArray(a.members)||typeof a.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(a.manifestSha256))throw new Error(`Invalid program package index ${n}: malformed package ${JSON.stringify(o)}`);let c=a.arches;if(c.length===0||new Set(c).size!==c.length||c.some(h=>typeof h!="string"||!dt.has(h)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has invalid arches`);let u=a.cacheKeys;if(!Xe(u,c)||Object.values(u).some(h=>typeof h!="string"||!/^[a-f0-9]{64}$/.test(h)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has invalid cache keys`);let l=a.dependencyClosures;if(!Xe(l,c))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has invalid dependency closure arches`);let d={};for(let h of c){let g=l[h];if(!Array.isArray(g))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has a malformed dependency closure for ${h}`);let _=new Set;d[h]=g.map((y,E)=>{if(typeof y!="object"||y===null||!Xe(y,["packageName","manifestSha256","cacheKey"])||typeof y.packageName!="string"||typeof y.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(y.manifestSha256)||typeof y.cacheKey!="string"||!/^[a-f0-9]{64}$/.test(y.cacheKey))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} dependency ${E+1} for ${h} is malformed`);let O=y;if(nn(O.packageName,n,`${o} dependency packageName`,!1),O.packageName===o||_.has(O.packageName))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} dependency closure for ${h} must contain unique dependencies other than itself`);_.add(O.packageName);let w=t.get(O.packageName);if(!w||w.manifestSha256!==O.manifestSha256||w.cacheKeys[h]!==O.cacheKey)throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} dependency ${JSON.stringify(O.packageName)} for ${h} does not match the index's authoritative contextual identity`);return O})}let p=a.members.map((h,g)=>{if(typeof h!="object"||h===null||h.kind!=="output"&&h.kind!=="runtime-file"||typeof h.sourceArtifact!="string"||typeof h.mirrorPath!="string")throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} member ${g+1} is malformed`);let _=h,y=_.kind==="output"?["kind","sourceArtifact","mirrorPath","outputName","forkInstrumentation"]:["kind","sourceArtifact","mirrorPath","guestPath","mode"];if(!Xe(_,y))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} member ${g+1} has unknown or missing fields`);if(Ws(_.sourceArtifact,n,`${o} sourceArtifact`),Ws(_.mirrorPath,n,`${o} mirrorPath`),_.kind==="output"){if(typeof _.outputName!="string"||_.forkInstrumentation!=="auto"&&_.forkInstrumentation!=="disabled")throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} output member lacks outputName or forkInstrumentation`);nn(_.outputName,n,`${o} outputName`)}else if(typeof _.guestPath!="string"||!_.guestPath.startsWith("/")||!Number.isInteger(_.mode)||_.mode<0||_.mode>511)throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} runtime member lacks valid guestPath or mode`);return _});if(p.length===0||new Set(p.map(h=>h.sourceArtifact)).size!==p.length||new Set(p.map(h=>h.mirrorPath)).size!==p.length||p.length===1&&p[0].mirrorPath.includes("/")||p.length>1&&p.some(h=>!h.mirrorPath.startsWith(`${o}/`)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} members are empty, collide, or violate scalar/package-directory layout`);let m=a.manifestSha256,f=t.get(o);if(!f||f.manifestSha256!==m||c.some(h=>f.cacheKeys[h]!==u[h]))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} does not match its contextual package identity`);i.set(o,{manifestSha256:m,arches:c,cacheKeys:u,dependencyClosures:d,members:p})}return{identities:t,packages:i,indexPath:n}}function ra(n){return JSON.stringify({manifestSha256:n.manifestSha256,arches:n.arches,cacheKeys:Object.fromEntries(n.arches.map(e=>[e,n.cacheKeys[e]])),dependencyClosures:Object.fromEntries(n.arches.map(e=>[e,[...n.dependencyClosures[e]].sort((t,r)=>t.packageNamer.packageName?1:0)])),members:n.members.map(e=>e.kind==="output"?{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,outputName:e.outputName,forkInstrumentation:e.forkInstrumentation}:{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,guestPath:e.guestPath,mode:e.mode})})}function Ti(){let n=Ri();return ce(n)?wi(n):null}function yd(n){let e=Ti();if(!e)return null;let t=n.split("/");if(t[0]!=="programs"||!dt.has(t[1]))return null;let r=t[1];if(t.length>=4){let s=t[2];return e.packages.get(s)?.arches.includes(r)?s:null}if(t.length!==3)return null;let i=t[2];for(let[s,o]of e.packages)if(o.arches.includes(r)&&o.members.some(a=>a.kind==="output"&&a.mirrorPath.split("/").at(-1)===i))return s;return null}function Vs(n){let e=yd(n);if(e)throw new Error(`Installed package resolver path ${JSON.stringify(n)} is owned by ${JSON.stringify(e)}, but that package is not selected by the configured program registry`)}function na(){let n=ea(),e=new Map,t=new Map,r=new Map,i=new Map,s=[];if(n===null){let u=Ri();if(!ce(u))return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:s};let l=wi(u);for(let[d,p]of l.identities)e.set(d,{...p,packageName:d,policyPath:`${l.indexPath}#identities.${d}`});for(let[d,p]of l.packages)s.push({packageName:d,projection:p,selected:!0}),r.set(d,{...p,packageName:d,policyPath:`${l.indexPath}#${d}`});return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:s}}let o=new Set,a=null,c=null;for(let u of n){if(!ce(u))continue;if(!Ye(u).isDirectory())throw new Error(`Program registry root is not a directory: ${u}`);let l=$(u,Ce);if(!ce(l))throw new Error(`Program registry ${u} is missing ${Ce}; generate it with xtask build-deps program-index`);let d=wi(l);a??=d.identities,c??=d.packages;let p=Ys(u,{withFileTypes:!0}).filter(m=>m.isDirectory()||m.isSymbolicLink()).sort((m,f)=>m.name.localeCompare(f.name));for(let m of p){let f=m.name,h=$(u,f,"package.toml");if(!ce(h))continue;let g=!1;try{g=Ye(h).isFile()}catch{g=!1}if(!g)continue;let _=d.packages.get(f),y=!o.has(f);if(_&&s.push({packageName:f,projection:_,selected:y}),!y)continue;o.add(f);let E=a.get(f);E?e.set(f,{...E,packageName:f,manifestPath:h,policyPath:h}):t.set(f,h);let O=c.get(f);if(!O){i.set(f,h);continue}r.set(f,{...O,packageName:f,manifestPath:h,policyPath:h})}}return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:s}}function qs(n){if(!n.manifestPath)return;let e;try{e=ct(n.manifestPath)}catch(r){throw new Error(`Program package identity cannot verify ${n.manifestPath}: ${r instanceof Error?r.message:String(r)}`)}if(js("sha256").update(e).digest("hex")!==n.manifestSha256)throw new Error(`Program package identity is stale for ${n.manifestPath}; regenerate ${Ce}`)}function gd(n){if(!n.manifestPath)return;let e;try{e=ct(n.manifestPath)}catch(r){throw new Error(`Program package projection cannot verify ${n.manifestPath}: ${r instanceof Error?r.message:String(r)}`)}if(js("sha256").update(e).digest("hex")!==n.manifestSha256)throw new Error(`Program package projection is stale for ${n.manifestPath}; regenerate ${Ce}`)}function Er(n){let e=vi(),t=e.packages.get(n);if(t)return gd(t),t;let r=e.unprojectedPackages.get(n);if(r)throw new Error(`Package ${JSON.stringify(n)} is selected at ${r} but is absent from ${Ce}; regenerate the registry projection`);return null}function Ed(n,e){let t=n.dependencyClosures[e];if(!t)throw bt(n.policyPath,`package ${JSON.stringify(n.packageName)} lacks a dependency identity closure for ${e}`);let r=na(),i=r.identities.get(n.packageName);if(!i){let o=r.unidentifiedPackages.get(n.packageName);throw new Error(`Program package ${JSON.stringify(n.packageName)} has no authoritative contextual identity for ${e}${o?` at ${o}`:""}; regenerate ${Ce} with the exact ordered registry roots`)}qs(i);let s=i.cacheKeys[e];if(i.manifestSha256!==n.manifestSha256||s!==n.cacheKeys[e])throw new Error(`Program package ${JSON.stringify(n.packageName)} was projected with manifest ${n.manifestSha256} and cache key ${n.cacheKeys[e]} for ${e}, but the authoritative first-hit registry context at ${i.policyPath} requires manifest ${i.manifestSha256} and cache key ${s??""}. Regenerate this program projection with the exact ordered registry roots; the highest-priority index must carry the complete combined-context projection rather than relying on a lower suffix-context build identity.`);for(let o of t){let a=r.identities.get(o.packageName);if(!a){let u=r.unidentifiedPackages.get(o.packageName);throw u?new Error(`Program package ${JSON.stringify(n.packageName)} was generated against dependency ${JSON.stringify(o.packageName)}, but the first-hit package at ${u} has no contextual identity in ${Ce}`):new Error(`Program package ${JSON.stringify(n.packageName)} was generated against dependency ${JSON.stringify(o.packageName)}, but that dependency is absent from the configured first-hit registry roots`)}qs(a);let c=a.cacheKeys[e];if(a.manifestSha256!==o.manifestSha256||c!==o.cacheKey)throw new Error(`Program package ${JSON.stringify(n.packageName)} has a contextual cache identity mismatch for ${e}: its projection expects dependency ${JSON.stringify(o.packageName)} manifest ${o.manifestSha256} and cache key ${o.cacheKey}, but first-hit selection at ${a.policyPath} provides manifest ${a.manifestSha256} and cache key ${c??""}. Regenerate the program projection with the exact ordered registry roots; the complete highest-priority projection must bind every selected program to the same combined dependency context.`)}}function vi(){let n=na(),{physicalProgramClaims:e,...t}=n,r={...t,legacyFlatOutputs:new Map,forkInstrumentationDisabledOutputs:new Map},i=[];for(let s of n.packages.values()){let o=s.members.length>1;for(let a of s.arches)for(let c of s.members){let u=i.find(m=>m.arch===a&&(m.path===c.mirrorPath||m.path.startsWith(`${c.mirrorPath}/`)||c.mirrorPath.startsWith(`${m.path}/`)));if(u)throw new Error(`Program resolver paths programs/${a}/${u.path} and programs/${a}/${c.mirrorPath} conflict between selected packages ${JSON.stringify(u.packageName)} and ${JSON.stringify(s.packageName)}`);if(i.push({arch:a,path:c.mirrorPath,packageName:s.packageName}),c.kind!=="output")continue;let l=c.mirrorPath.split("/").at(-1),d=`${a}/${l}`,p=r.legacyFlatOutputs.get(d);p||(p={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},r.legacyFlatOutputs.set(d,p)),o?p.packagePaths.set(`programs/${a}/${c.mirrorPath}`,s.packageName):p.scalarOwners.add(s.packageName),c.forkInstrumentation==="disabled"&&r.forkInstrumentationDisabledOutputs.set(`${a}/${c.mirrorPath}`,s.packageName)}}for(let{packageName:s,projection:o,selected:a}of e)if(!(a&&n.packages.has(s)))for(let c of o.arches)for(let u of o.members){if(u.kind!=="output")continue;let l=u.mirrorPath.split("/").at(-1),d=`${c}/${l}`,p=r.legacyFlatOutputs.get(d);p||(p={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},r.legacyFlatOutputs.set(d,p)),p.shadowedOwners.add(s)}return r}function Sd(n){let e=n.split("/");if(e.length!==3||e[0]!=="programs"||!dt.has(e[1]))return null;let t=vi().legacyFlatOutputs.get(`${e[1]}/${e[2]}`);if(!t)return null;for(let r of t.scalarOwners){let i=Er(r);if(i)return i}for(let r of t.packagePaths.values())Er(r);if(t.packagePaths.size>0)throw new Error(`Legacy flat resolver path ${JSON.stringify(n)} belongs to a multi-member package; use ${[...t.packagePaths.keys()].sort().map(r=>JSON.stringify(r)).join(" or ")}`);for(let r of t.shadowedOwners){let i=Er(r);if(i)return i;throw new Error(`Legacy flat resolver path ${JSON.stringify(n)} is claimed by a lower-root program package ${JSON.stringify(r)}, but its first-hit selected package does not project that program; stale scalar mirror fallback is forbidden`)}return null}function Zs(n,e,t){if(!n.arches.includes(e))throw bt(n.policyPath,`package ${JSON.stringify(n.packageName)} does not declare resolver artifacts for ${e}`);let r=n.cacheKeys[e];if(!r)throw bt(n.policyPath,`package ${JSON.stringify(n.packageName)} lacks a cache identity for ${e}`);Ed(n,e);let i=ra(n),s=n.members.map(o=>({packageName:n.packageName,relPath:`programs/${e}/${o.mirrorPath}`,sourceArtifact:o.sourceArtifact,cacheKey:r,forkInstrumentation:o.kind==="output"?o.forkInstrumentation??null:null,projectionIdentity:i}));if(!s.some(o=>o.relPath===t))throw bt(n.policyPath,`resolver path ${JSON.stringify(t)} is not a declared member of package ${JSON.stringify(n.packageName)}`);return{manifestPath:n.policyPath,packageName:n.packageName,members:s}}function wd(n){let e=je(n),t=e.split("/");if(t[0]==="programs"&&!cd()&&Ti()===null)throw new Error(`Installed host package is missing wasm/${Ce}; program artifacts cannot be resolved without packaged policy`);if(t.length===3){let o=Sd(e);return o?Zs(o,t[1],e):(Vs(e),null)}if(t.length<4||t[0]!=="programs"||!dt.has(t[1]))return null;let r=t[1],i=t[2],s=Er(i);return s?Zs(s,r,e):(Vs(e),null)}function Od(n){let e=je(n);for(let t of["programs/wasm32/","programs/wasm64/"])if(e.startsWith(t))return e.slice(t.length);return null}function Ad(n){let e=je(n);for(let t of dt){let r=`programs/${t}/`;if(e.startsWith(r)){let i=vi().forkInstrumentationDisabledOutputs.get(`${t}/${e.slice(r.length)}`);return i?Er(i)!==null:!1}}return!1}function Id(n){let e=je(n);if(e==="kernel.wasm")return no;let t=Od(e);if(t&&t.endsWith(".wasm"))return sd}function Rd(n,e,t){if(!n.endsWith(".wasm"))return!1;try{let r=ct(n),i=r.buffer.slice(r.byteOffset,r.byteOffset+r.byteLength),s=t===void 0?Ad(e):t==="disabled";return _o(i,{expectedAbi:43,requiredExports:Id(e),requireForkInstrumentation:s?!1:void 0,forbidForkInstrumentation:s}).length>0}catch{return!0}}function xd(n){if(!n.endsWith(".vfs")&&!n.endsWith(".vfs.zst"))return!1;try{let t=tn.readImageMetadata(ct(n))?.kernelAbi;return t!==void 0&&t!==43}catch{return!0}}function Li(n,e,t){return Rd(n,e,t)||xd(n)}function ia(n,e,t){let r=n.filter(ce);return r.length===0?null:r.find(i=>{try{return Ye(i).isFile()&&!Li(i,e,t)}catch{return!1}})??null}function oa(n,e,t){try{if(!an(n).isSymbolicLink())return n;let i=Re(n);if(!Ye(i).isFile()||Li(i,e,t))throw new Error("canonical target is not an accepted regular file");if(je(e).startsWith("programs/")&&Td(i))throw new Error("resolver-owned program generation has no matching selected package projection");return i}catch(r){throw new Error(`Binary changed or became invalid while pinning ${e}: ${r instanceof Error?r.message:String(r)}`)}}function Td(n){let e=[Js()];try{e.push($(ut(),"local-binaries",".kandelo-local-generations"))}catch{}return e.some(t=>{try{return ce(t)&&zi(Re(t),n)}catch{return!1}})}function zi(n,e){let t=nd(n,e);return t===""||t!==".."&&!t.startsWith(`..${id}`)&&!cn(t)}function vd(n,e){let t=e.split("/"),r=n;for(let i=0;ia.packageName!==s))return"declared package members do not share a valid program namespace";if(!Ye(e).isDirectory())return"shared package generation root is not a directory";let o=t[0].cacheKey;if(!/^[a-f0-9]{64}$/.test(o)||t.some(a=>a.cacheKey!==o))return"declared package members do not share one valid cache identity";if(n.identity==="local-generation"){let a=$(n.root,".kandelo-local-generations",i,s,o);if(!ce(a))return"local mirror targets are not one direct immutable local generation";let c=Re(a);return Sr(e)===c?null:"local mirror targets are not one direct immutable local generation"}if(n.identity==="program-cache"){let a=Js();if(!ce(a))return"fetched mirror targets are not one canonical program-cache generation";let c=Re(a),u=rd(e),l=u.startsWith(`${s}-`)&&new RegExp(`-rev[0-9]+-${i}-${o}$`).test(u);return Sr(e)===c&&l?null:"fetched mirror targets are not one canonical program-cache generation"}return"installed-package symlink closures are not an immutable installed identity"}function zd(n,e,t){if(e.length!==t.length)return{failure:"internal member/path count mismatch"};try{let r=e.map(u=>{let l=an(u);return l.isSymbolicLink()?"symlink":l.isFile()?"file":"other"});if(r.includes("other"))return{failure:"a selected mirror member is neither a regular file nor a symlink"};let i=r.every(u=>u==="symlink"),s=r.every(u=>u==="file");if(!i&&!s)return{failure:"regular files and symlinks cannot share one package identity"};if(s){if(!n.allowRegularFileClosure)return{failure:"a mutable source-checkout wasm tree is not an installed package identity"};let u=t[0].packageName,l=t[0].projectionIdentity;if(t.some(h=>h.packageName!==u||h.projectionIdentity!==l))return{failure:"declared members do not share one selected package projection"};let p=Ti()?.packages.get(u);if(!p||ra(p)!==l)return{failure:"installed bytes do not match the selected package projection"};let m=Re(n.root),f=[];for(let h of e){let g=Re(h);if(!zi(m,g)||!Ye(g).isFile())return{failure:"an installed-package member escapes its immutable wasm tree"};f.push(g)}return{paths:f}}let o=null,a=[];for(let u=0;ubd(n))}function bd(n){let e=je(n),t=wd(e);if(t){let o=Pd(t.members.map(a=>a.relPath),t.members);if(o)return o[t.members.findIndex(a=>a.relPath===e)];throw new sn(`Package artifacts not found for ${t.packageName}: ${e}`)}let r=[],i=[];for(let o of Qs())for(let a of o.candidatesFor(n))r.push(a),i.push(a);let s=ia(i,n);if(s)return oa(s,n);throw i.some(ce)?new Error(`Binary exists but was rejected by artifact policy: ${n} +${r}`:""}`}function pd(n){let e=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,t=e?"rustc":"bash",r=e?["-vV"]:[$(n,"scripts","dev-shell.sh"),"rustc","-vV"],i=Ri(t,r,{cwd:n,encoding:"utf8"});if(i.status!==0)throw new Error(Li(t,r,i));let s=i.stdout.split(/\r?\n/).find(o=>o.startsWith("host: "))?.slice(6).trim();if(!s)throw new Error(`Could not determine the Rust host target for ${n}`);return s}function Ai(n){try{if(cn(n).isFile())return Re(n)}catch{}throw new Error(`Prepared xtask is not a regular file: ${n}`)}function md(n){let e=process.env.WASM_POSIX_XTASK_BIN;if(e!==void 0){let u=un(e)?Ie(e):Ie(n,e);return Ai(u)}if(sn?.sourceRepoRoot===n)return Ai(sn.xtaskPath);let t=pd(n),r=$(n,"target",t,"release",process.platform==="win32"?"xtask.exe":"xtask"),i=["build","--release","-p","xtask","--target",t,"--quiet"],s=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,o=s?"cargo":"bash",c=s?i:[$(n,"scripts","dev-shell.sh"),"cargo",...i],a=Ri(o,c,{cwd:n,encoding:"utf8"});if(a.status!==0)throw new Error(Li(o,c,a));return sn={sourceRepoRoot:n,xtaskPath:Ai(r)},sn.xtaskPath}function _d(){let n=ra();if(n===null)return;let e=ta();if(e===null)return;if(Vs){Vs(n,e);return}let t=md(n),r=["build-deps","program-index-context-check","--source-repo-root",n],i=Ri(t,r,{cwd:n,encoding:"utf8",env:{...process.env,WASM_POSIX_DEPS_REGISTRY:e.join(":")}});if(i.status!==0)throw new Error(`Program package source projection is not current: +${Li(t,r,i)}`)}function yd(n,e){if(Oi>0||!n.some(t=>t.startsWith("programs/")))return e();Oi+=1;try{return _d(),e()}finally{Oi-=1}}function Xe(n,e){let t=Object.keys(n).sort(),r=[...e].sort();return t.length===r.length&&t.every((i,s)=>i===r[s])}function Ii(n){let e;try{e=JSON.parse(ut(n,"utf8"))}catch(o){throw new Error(`Invalid program package index ${n}: ${o instanceof Error?o.message:String(o)}`)}if(typeof e!="object"||e===null||!Xe(e,["format","identities","packages"])||e.format!==Hs||typeof e.identities!="object"||e.identities===null||Array.isArray(e.identities)||typeof e.packages!="object"||e.packages===null||Array.isArray(e.packages))throw new Error(`Invalid program package index ${n}: expected ${Hs}`);let t=new Map,r=e.identities;for(let[o,c]of Object.entries(r)){if(on(o,n,"identity package name",!1),typeof c!="object"||c===null||!Xe(c,["manifestSha256","cacheKeys"])||typeof c.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(c.manifestSha256)||typeof c.cacheKeys!="object"||c.cacheKeys===null||Array.isArray(c.cacheKeys))throw new Error(`Invalid program package index ${n}: malformed identity ${JSON.stringify(o)}`);let a=c.cacheKeys;if(!Xe(a,["wasm32","wasm64"])||Object.values(a).some(u=>typeof u!="string"||!/^[a-f0-9]{64}$/.test(u)))throw new Error(`Invalid program package index ${n}: identity ${JSON.stringify(o)} has invalid contextual cache keys`);t.set(o,{manifestSha256:c.manifestSha256,cacheKeys:a})}let i=new Map,s=e.packages;for(let[o,c]of Object.entries(s)){if(on(o,n,"package name",!1),typeof c!="object"||c===null||!Xe(c,["manifestSha256","arches","cacheKeys","dependencyClosures","members"])||!Array.isArray(c.arches)||typeof c.cacheKeys!="object"||c.cacheKeys===null||Array.isArray(c.cacheKeys)||typeof c.dependencyClosures!="object"||c.dependencyClosures===null||Array.isArray(c.dependencyClosures)||!Array.isArray(c.members)||typeof c.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(c.manifestSha256))throw new Error(`Invalid program package index ${n}: malformed package ${JSON.stringify(o)}`);let a=c.arches;if(a.length===0||new Set(a).size!==a.length||a.some(h=>typeof h!="string"||!lt.has(h)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has invalid arches`);let u=c.cacheKeys;if(!Xe(u,a)||Object.values(u).some(h=>typeof h!="string"||!/^[a-f0-9]{64}$/.test(h)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has invalid cache keys`);let l=c.dependencyClosures;if(!Xe(l,a))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has invalid dependency closure arches`);let d={};for(let h of a){let g=l[h];if(!Array.isArray(g))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has a malformed dependency closure for ${h}`);let _=new Set;d[h]=g.map((y,E)=>{if(typeof y!="object"||y===null||!Xe(y,["packageName","manifestSha256","cacheKey"])||typeof y.packageName!="string"||typeof y.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(y.manifestSha256)||typeof y.cacheKey!="string"||!/^[a-f0-9]{64}$/.test(y.cacheKey))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} dependency ${E+1} for ${h} is malformed`);let O=y;if(on(O.packageName,n,`${o} dependency packageName`,!1),O.packageName===o||_.has(O.packageName))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} dependency closure for ${h} must contain unique dependencies other than itself`);_.add(O.packageName);let w=t.get(O.packageName);if(!w||w.manifestSha256!==O.manifestSha256||w.cacheKeys[h]!==O.cacheKey)throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} dependency ${JSON.stringify(O.packageName)} for ${h} does not match the index's authoritative contextual identity`);return O})}let p=c.members.map((h,g)=>{if(typeof h!="object"||h===null||h.kind!=="output"&&h.kind!=="runtime-file"||typeof h.sourceArtifact!="string"||typeof h.mirrorPath!="string")throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} member ${g+1} is malformed`);let _=h,y=_.kind==="output"?["kind","sourceArtifact","mirrorPath","outputName","forkInstrumentation"]:["kind","sourceArtifact","mirrorPath","guestPath","mode"];if(!Xe(_,y))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} member ${g+1} has unknown or missing fields`);if(Gs(_.sourceArtifact,n,`${o} sourceArtifact`),Gs(_.mirrorPath,n,`${o} mirrorPath`),_.kind==="output"){if(typeof _.outputName!="string"||_.forkInstrumentation!=="auto"&&_.forkInstrumentation!=="disabled")throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} output member lacks outputName or forkInstrumentation`);on(_.outputName,n,`${o} outputName`)}else if(typeof _.guestPath!="string"||!_.guestPath.startsWith("/")||!Number.isInteger(_.mode)||_.mode<0||_.mode>511)throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} runtime member lacks valid guestPath or mode`);return _});if(p.length===0||new Set(p.map(h=>h.sourceArtifact)).size!==p.length||new Set(p.map(h=>h.mirrorPath)).size!==p.length||p.length===1&&p[0].mirrorPath.includes("/")||p.length>1&&p.some(h=>!h.mirrorPath.startsWith(`${o}/`)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} members are empty, collide, or violate scalar/package-directory layout`);let m=c.manifestSha256,f=t.get(o);if(!f||f.manifestSha256!==m||a.some(h=>f.cacheKeys[h]!==u[h]))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} does not match its contextual package identity`);i.set(o,{manifestSha256:m,arches:a,cacheKeys:u,dependencyClosures:d,members:p})}return{identities:t,packages:i,indexPath:n}}function na(n){return JSON.stringify({manifestSha256:n.manifestSha256,arches:n.arches,cacheKeys:Object.fromEntries(n.arches.map(e=>[e,n.cacheKeys[e]])),dependencyClosures:Object.fromEntries(n.arches.map(e=>[e,[...n.dependencyClosures[e]].sort((t,r)=>t.packageNamer.packageName?1:0)])),members:n.members.map(e=>e.kind==="output"?{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,outputName:e.outputName,forkInstrumentation:e.forkInstrumentation}:{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,guestPath:e.guestPath,mode:e.mode})})}function zi(){let n=vi();return ce(n)?Ii(n):null}function gd(n){let e=zi();if(!e)return null;let t=n.split("/");if(t[0]!=="programs"||!lt.has(t[1]))return null;let r=t[1];if(t.length>=4){let s=t[2];return e.packages.get(s)?.arches.includes(r)?s:null}if(t.length!==3)return null;let i=t[2];for(let[s,o]of e.packages)if(o.arches.includes(r)&&o.members.some(c=>c.kind==="output"&&c.mirrorPath.split("/").at(-1)===i))return s;return null}function qs(n){let e=gd(n);if(e)throw new Error(`Installed package resolver path ${JSON.stringify(n)} is owned by ${JSON.stringify(e)}, but that package is not selected by the configured program registry`)}function ia(){let n=ta(),e=new Map,t=new Map,r=new Map,i=new Map,s=[];if(n===null){let u=vi();if(!ce(u))return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:s};let l=Ii(u);for(let[d,p]of l.identities)e.set(d,{...p,packageName:d,policyPath:`${l.indexPath}#identities.${d}`});for(let[d,p]of l.packages)s.push({packageName:d,projection:p,selected:!0}),r.set(d,{...p,packageName:d,policyPath:`${l.indexPath}#${d}`});return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:s}}let o=new Set,c=null,a=null;for(let u of n){if(!ce(u))continue;if(!Ye(u).isDirectory())throw new Error(`Program registry root is not a directory: ${u}`);let l=$(u,Ce);if(!ce(l))throw new Error(`Program registry ${u} is missing ${Ce}; generate it with xtask build-deps program-index`);let d=Ii(l);c??=d.identities,a??=d.packages;let p=js(u,{withFileTypes:!0}).filter(m=>m.isDirectory()||m.isSymbolicLink()).sort((m,f)=>m.name.localeCompare(f.name));for(let m of p){let f=m.name,h=$(u,f,"package.toml");if(!ce(h))continue;let g=!1;try{g=Ye(h).isFile()}catch{g=!1}if(!g)continue;let _=d.packages.get(f),y=!o.has(f);if(_&&s.push({packageName:f,projection:_,selected:y}),!y)continue;o.add(f);let E=c.get(f);E?e.set(f,{...E,packageName:f,manifestPath:h,policyPath:h}):t.set(f,h);let O=a.get(f);if(!O){i.set(f,h);continue}r.set(f,{...O,packageName:f,manifestPath:h,policyPath:h})}}return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:s}}function Zs(n){if(!n.manifestPath)return;let e;try{e=ut(n.manifestPath)}catch(r){throw new Error(`Program package identity cannot verify ${n.manifestPath}: ${r instanceof Error?r.message:String(r)}`)}if(Js("sha256").update(e).digest("hex")!==n.manifestSha256)throw new Error(`Program package identity is stale for ${n.manifestPath}; regenerate ${Ce}`)}function Ed(n){if(!n.manifestPath)return;let e;try{e=ut(n.manifestPath)}catch(r){throw new Error(`Program package projection cannot verify ${n.manifestPath}: ${r instanceof Error?r.message:String(r)}`)}if(Js("sha256").update(e).digest("hex")!==n.manifestSha256)throw new Error(`Program package projection is stale for ${n.manifestPath}; regenerate ${Ce}`)}function Sr(n){let e=bi(),t=e.packages.get(n);if(t)return Ed(t),t;let r=e.unprojectedPackages.get(n);if(r)throw new Error(`Package ${JSON.stringify(n)} is selected at ${r} but is absent from ${Ce}; regenerate the registry projection`);return null}function Sd(n,e){let t=n.dependencyClosures[e];if(!t)throw Pt(n.policyPath,`package ${JSON.stringify(n.packageName)} lacks a dependency identity closure for ${e}`);let r=ia(),i=r.identities.get(n.packageName);if(!i){let o=r.unidentifiedPackages.get(n.packageName);throw new Error(`Program package ${JSON.stringify(n.packageName)} has no authoritative contextual identity for ${e}${o?` at ${o}`:""}; regenerate ${Ce} with the exact ordered registry roots`)}Zs(i);let s=i.cacheKeys[e];if(i.manifestSha256!==n.manifestSha256||s!==n.cacheKeys[e])throw new Error(`Program package ${JSON.stringify(n.packageName)} was projected with manifest ${n.manifestSha256} and cache key ${n.cacheKeys[e]} for ${e}, but the authoritative first-hit registry context at ${i.policyPath} requires manifest ${i.manifestSha256} and cache key ${s??""}. Regenerate this program projection with the exact ordered registry roots; the highest-priority index must carry the complete combined-context projection rather than relying on a lower suffix-context build identity.`);for(let o of t){let c=r.identities.get(o.packageName);if(!c){let u=r.unidentifiedPackages.get(o.packageName);throw u?new Error(`Program package ${JSON.stringify(n.packageName)} was generated against dependency ${JSON.stringify(o.packageName)}, but the first-hit package at ${u} has no contextual identity in ${Ce}`):new Error(`Program package ${JSON.stringify(n.packageName)} was generated against dependency ${JSON.stringify(o.packageName)}, but that dependency is absent from the configured first-hit registry roots`)}Zs(c);let a=c.cacheKeys[e];if(c.manifestSha256!==o.manifestSha256||a!==o.cacheKey)throw new Error(`Program package ${JSON.stringify(n.packageName)} has a contextual cache identity mismatch for ${e}: its projection expects dependency ${JSON.stringify(o.packageName)} manifest ${o.manifestSha256} and cache key ${o.cacheKey}, but first-hit selection at ${c.policyPath} provides manifest ${c.manifestSha256} and cache key ${a??""}. Regenerate the program projection with the exact ordered registry roots; the complete highest-priority projection must bind every selected program to the same combined dependency context.`)}}function bi(){let n=ia(),{physicalProgramClaims:e,...t}=n,r={...t,legacyFlatOutputs:new Map,forkInstrumentationDisabledOutputs:new Map},i=[];for(let s of n.packages.values()){let o=s.members.length>1;for(let c of s.arches)for(let a of s.members){let u=i.find(m=>m.arch===c&&(m.path===a.mirrorPath||m.path.startsWith(`${a.mirrorPath}/`)||a.mirrorPath.startsWith(`${m.path}/`)));if(u)throw new Error(`Program resolver paths programs/${c}/${u.path} and programs/${c}/${a.mirrorPath} conflict between selected packages ${JSON.stringify(u.packageName)} and ${JSON.stringify(s.packageName)}`);if(i.push({arch:c,path:a.mirrorPath,packageName:s.packageName}),a.kind!=="output")continue;let l=a.mirrorPath.split("/").at(-1),d=`${c}/${l}`,p=r.legacyFlatOutputs.get(d);p||(p={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},r.legacyFlatOutputs.set(d,p)),o?p.packagePaths.set(`programs/${c}/${a.mirrorPath}`,s.packageName):p.scalarOwners.add(s.packageName),a.forkInstrumentation==="disabled"&&r.forkInstrumentationDisabledOutputs.set(`${c}/${a.mirrorPath}`,s.packageName)}}for(let{packageName:s,projection:o,selected:c}of e)if(!(c&&n.packages.has(s)))for(let a of o.arches)for(let u of o.members){if(u.kind!=="output")continue;let l=u.mirrorPath.split("/").at(-1),d=`${a}/${l}`,p=r.legacyFlatOutputs.get(d);p||(p={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},r.legacyFlatOutputs.set(d,p)),p.shadowedOwners.add(s)}return r}function wd(n){let e=n.split("/");if(e.length!==3||e[0]!=="programs"||!lt.has(e[1]))return null;let t=bi().legacyFlatOutputs.get(`${e[1]}/${e[2]}`);if(!t)return null;for(let r of t.scalarOwners){let i=Sr(r);if(i)return i}for(let r of t.packagePaths.values())Sr(r);if(t.packagePaths.size>0)throw new Error(`Legacy flat resolver path ${JSON.stringify(n)} belongs to a multi-member package; use ${[...t.packagePaths.keys()].sort().map(r=>JSON.stringify(r)).join(" or ")}`);for(let r of t.shadowedOwners){let i=Sr(r);if(i)return i;throw new Error(`Legacy flat resolver path ${JSON.stringify(n)} is claimed by a lower-root program package ${JSON.stringify(r)}, but its first-hit selected package does not project that program; stale scalar mirror fallback is forbidden`)}return null}function Xs(n,e,t){if(!n.arches.includes(e))throw Pt(n.policyPath,`package ${JSON.stringify(n.packageName)} does not declare resolver artifacts for ${e}`);let r=n.cacheKeys[e];if(!r)throw Pt(n.policyPath,`package ${JSON.stringify(n.packageName)} lacks a cache identity for ${e}`);Sd(n,e);let i=na(n),s=n.members.map(o=>({packageName:n.packageName,relPath:`programs/${e}/${o.mirrorPath}`,sourceArtifact:o.sourceArtifact,cacheKey:r,forkInstrumentation:o.kind==="output"?o.forkInstrumentation??null:null,projectionIdentity:i}));if(!s.some(o=>o.relPath===t))throw Pt(n.policyPath,`resolver path ${JSON.stringify(t)} is not a declared member of package ${JSON.stringify(n.packageName)}`);return{manifestPath:n.policyPath,packageName:n.packageName,members:s}}function Od(n){let e=je(n),t=e.split("/");if(t[0]==="programs"&&!ud()&&zi()===null)throw new Error(`Installed host package is missing wasm/${Ce}; program artifacts cannot be resolved without packaged policy`);if(t.length===3){let o=wd(e);return o?Xs(o,t[1],e):(qs(e),null)}if(t.length<4||t[0]!=="programs"||!lt.has(t[1]))return null;let r=t[1],i=t[2],s=Sr(i);return s?Xs(s,r,e):(qs(e),null)}function Ad(n){let e=je(n);for(let t of["programs/wasm32/","programs/wasm64/"])if(e.startsWith(t))return e.slice(t.length);return null}function Id(n){let e=je(n);for(let t of lt){let r=`programs/${t}/`;if(e.startsWith(r)){let i=bi().forkInstrumentationDisabledOutputs.get(`${t}/${e.slice(r.length)}`);return i?Sr(i)!==null:!1}}return!1}function Rd(n){let e=je(n);if(e==="kernel.wasm")return so;let t=Ad(e);if(t&&t.endsWith(".wasm"))return ad}function xd(n,e,t){if(!n.endsWith(".wasm"))return!1;try{let r=ut(n),i=r.buffer.slice(r.byteOffset,r.byteOffset+r.byteLength),s=t===void 0?Id(e):t==="disabled";return yo(i,{expectedAbi:43,requiredExports:Rd(e),requireForkInstrumentation:s?!1:void 0,forbidForkInstrumentation:s}).length>0}catch{return!0}}function Td(n){if(!n.endsWith(".vfs")&&!n.endsWith(".vfs.zst"))return!1;try{let t=rn.readImageMetadata(ut(n))?.kernelAbi;return t!==void 0&&t!==43}catch{return!0}}function Pi(n,e,t){return xd(n,e,t)||Td(n)}function oa(n,e,t){let r=n.filter(ce);return r.length===0?null:r.find(i=>{try{return Ye(i).isFile()&&!Pi(i,e,t)}catch{return!1}})??null}function sa(n,e,t){try{if(!cn(n).isSymbolicLink())return n;let i=Re(n);if(!Ye(i).isFile()||Pi(i,e,t))throw new Error("canonical target is not an accepted regular file");if(je(e).startsWith("programs/")&&vd(i))throw new Error("resolver-owned program generation has no matching selected package projection");return i}catch(r){throw new Error(`Binary changed or became invalid while pinning ${e}: ${r instanceof Error?r.message:String(r)}`)}}function vd(n){let e=[Qs()];try{e.push($(dt(),"local-binaries",".kandelo-local-generations"))}catch{}return e.some(t=>{try{return ce(t)&&ki(Re(t),n)}catch{return!1}})}function ki(n,e){let t=id(n,e);return t===""||t!==".."&&!t.startsWith(`..${od}`)&&!un(t)}function Ld(n,e){let t=e.split("/"),r=n;for(let i=0;ic.packageName!==s))return"declared package members do not share a valid program namespace";if(!Ye(e).isDirectory())return"shared package generation root is not a directory";let o=t[0].cacheKey;if(!/^[a-f0-9]{64}$/.test(o)||t.some(c=>c.cacheKey!==o))return"declared package members do not share one valid cache identity";if(n.identity==="local-generation"){let c=$(n.root,".kandelo-local-generations",i,s,o);if(!ce(c))return"local mirror targets are not one direct immutable local generation";let a=Re(c);return wr(e)===a?null:"local mirror targets are not one direct immutable local generation"}if(n.identity==="program-cache"){let c=Qs();if(!ce(c))return"fetched mirror targets are not one canonical program-cache generation";let a=Re(c),u=nd(e),l=u.startsWith(`${s}-`)&&new RegExp(`-rev[0-9]+-${i}-${o}$`).test(u);return wr(e)===a&&l?null:"fetched mirror targets are not one canonical program-cache generation"}return"installed-package symlink closures are not an immutable installed identity"}function bd(n,e,t){if(e.length!==t.length)return{failure:"internal member/path count mismatch"};try{let r=e.map(u=>{let l=cn(u);return l.isSymbolicLink()?"symlink":l.isFile()?"file":"other"});if(r.includes("other"))return{failure:"a selected mirror member is neither a regular file nor a symlink"};let i=r.every(u=>u==="symlink"),s=r.every(u=>u==="file");if(!i&&!s)return{failure:"regular files and symlinks cannot share one package identity"};if(s){if(!n.allowRegularFileClosure)return{failure:"a mutable source-checkout wasm tree is not an installed package identity"};let u=t[0].packageName,l=t[0].projectionIdentity;if(t.some(h=>h.packageName!==u||h.projectionIdentity!==l))return{failure:"declared members do not share one selected package projection"};let p=zi()?.packages.get(u);if(!p||na(p)!==l)return{failure:"installed bytes do not match the selected package projection"};let m=Re(n.root),f=[];for(let h of e){let g=Re(h);if(!ki(m,g)||!Ye(g).isFile())return{failure:"an installed-package member escapes its immutable wasm tree"};f.push(g)}return{paths:f}}let o=null,c=[];for(let u=0;uPd(n))}function Pd(n){let e=je(n),t=Od(e);if(t){let o=kd(t.members.map(c=>c.relPath),t.members);if(o)return o[t.members.findIndex(c=>c.relPath===e)];throw new an(`Package artifacts not found for ${t.packageName}: ${e}`)}let r=[],i=[];for(let o of ea())for(let c of o.candidatesFor(n))r.push(c),i.push(c);let s=oa(i,n);if(s)return sa(s,n);throw i.some(ce)?new Error(`Binary exists but was rejected by artifact policy: ${n} `+r.map(o=>` checked: ${o}`).join(` -`)):new sn(`Binary not found: ${n} +`)):new an(`Binary not found: ${n} `+r.map(o=>` checked: ${o}`).join(` `)+` - Run scripts/fetch-binaries.sh, place a file at local-binaries/${e}, or install a package that includes wasm/${n}.`)}function Pd(n,e){if(n.length===0)return[];let t=!1,r=[];for(let i of Qs()){let s=[],o=[];if(e){let[a,c,u]=e[0].relPath.split("/");a==="programs"&&c&&u&&(t||=ce($(i.root,a,c,u)))}for(let[a,c]of n.entries()){let u=i.candidatesFor(c),l=u.filter(ce);t||=l.length>0;let d=ia(u,c,e?.[a]?.forkInstrumentation);d?s.push(d):l.length>0?o.push(`${c} (rejected by artifact policy)`):o.push(`${c} (missing)`)}if(o.length===0&&e){let a=zd(i,s,e);if("failure"in a)o.push(`shared package identity rejected: ${a.failure}`);else{let c=a.paths.flatMap((u,l)=>Li(u,n[l],e[l].forkInstrumentation)?[n[l]]:[]);if(c.length>0)o.push(`pinned package generation rejected by artifact policy: ${c.join(", ")}`);else return a.paths}}if(o.length===0)return s.map((a,c)=>oa(a,n[c],e?.[c]?.forkInstrumentation));r.push(` ${i.label} (${i.root}): ${o.join(", ")}`)}if(!t)return null;throw new Error(`Package artifact closure is incomplete: no single provenance tier contains every accepted artifact, and tiers will not be mixed. + Run scripts/fetch-binaries.sh, place a file at local-binaries/${e}, or install a package that includes wasm/${n}.`)}function kd(n,e){if(n.length===0)return[];let t=!1,r=[];for(let i of ea()){let s=[],o=[];if(e){let[c,a,u]=e[0].relPath.split("/");c==="programs"&&a&&u&&(t||=ce($(i.root,c,a,u)))}for(let[c,a]of n.entries()){let u=i.candidatesFor(a),l=u.filter(ce);t||=l.length>0;let d=oa(u,a,e?.[c]?.forkInstrumentation);d?s.push(d):l.length>0?o.push(`${a} (rejected by artifact policy)`):o.push(`${a} (missing)`)}if(o.length===0&&e){let c=bd(i,s,e);if("failure"in c)o.push(`shared package identity rejected: ${c.failure}`);else{let a=c.paths.flatMap((u,l)=>Pi(u,n[l],e[l].forkInstrumentation)?[n[l]]:[]);if(a.length>0)o.push(`pinned package generation rejected by artifact policy: ${a.join(", ")}`);else return c.paths}}if(o.length===0)return s.map((c,a)=>sa(c,n[a],e?.[a]?.forkInstrumentation));r.push(` ${i.label} (${i.root}): ${o.join(", ")}`)}if(!t)return null;throw new Error(`Package artifact closure is incomplete: no single provenance tier contains every accepted artifact, and tiers will not be mixed. `+r.join(` -`))}var[aa,...kd]=process.argv.slice(2);(!aa||kd.length>0)&&(console.error("usage: scripts/resolve-binary.sh "),process.exit(2));try{process.stdout.write(`${sa(aa)} +`))}var[ca,...Fd]=process.argv.slice(2);(!ca||Fd.length>0)&&(console.error("usage: scripts/resolve-binary.sh "),process.exit(2));try{process.stdout.write(`${aa(ca)} `)}catch(n){console.error(n instanceof Error?n.message:String(n)),process.exit(1)} diff --git a/scripts/run-libc-tests.sh b/scripts/run-libc-tests.sh index 674edaa987..eed6350a34 100755 --- a/scripts/run-libc-tests.sh +++ b/scripts/run-libc-tests.sh @@ -16,7 +16,9 @@ SYSROOT="$REPO_ROOT/sysroot" GLUE_DIR="$REPO_ROOT/libc/glue" LIBC_TEST="$REPO_ROOT/tests/libc/libc-test" BUILD_DIR="$REPO_ROOT/tests/libc/libc-test/build" +RUNNER_FIXTURE_ROOT="$BUILD_DIR/runner-fixture" KERNEL_WASM="$("$REPO_ROOT/scripts/resolve-binary.sh" kernel.wasm)" +PROGRAM_INDEX_CHECKER="" # ── Expected failures ────────────────────────────────────── # Tests known to fail due to wasm32 soft-float precision limits (no hardware FPU rounding control). @@ -255,6 +257,15 @@ run_test() { local category="$1" local test_name="$2" local wasm="$BUILD_DIR/$category/${test_name}.wasm" + local fixture_root="" + local fixture_cwd="" + local kernel_path="/usr/local/bin:/usr/bin:/bin" + + if [ "$category/$test_name" = "functional/spawn" ]; then + fixture_root="$RUNNER_FIXTURE_ROOT" + fixture_cwd="work" + kernel_path="/tmp/kandelo-run/bin:$kernel_path" + fi # Determine expected-failure list for this category local -a xfail_list=() @@ -292,8 +303,10 @@ run_test() { set +e output=$(cd "$REPO_ROOT" && \ KERNEL_CWD= \ - KANDELO_RUNNER_FIXTURE_ROOT= \ - KANDELO_RUNNER_FIXTURE_CWD= \ + KERNEL_PATH="$kernel_path" \ + WASM_POSIX_XTASK_BIN="$PROGRAM_INDEX_CHECKER" \ + KANDELO_RUNNER_FIXTURE_ROOT="$fixture_root" \ + KANDELO_RUNNER_FIXTURE_CWD="$fixture_cwd" \ KANDELO_RUNNER_GUEST_PROGRAM= \ KANDELO_RUNNER_VFS=isolated \ timeout "$TEST_TIMEOUT" node --experimental-wasm-exnref \ @@ -388,6 +401,22 @@ if [ ! -f "$KERNEL_WASM" ]; then echo "Error: kernel wasm not found. Run build.sh first." >&2 exit 1 fi + +# Prepare source-projection policy once outside the per-test timeout. Every +# runner process still executes this exact checker, so package freshness stays +# authoritative without charging a cold release build to guest runtime. +RUST_HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')" +if [ -z "$RUST_HOST_TARGET" ]; then + echo "Error: could not determine the Rust host target." >&2 + exit 1 +fi +cargo build --release -p xtask --target "$RUST_HOST_TARGET" --quiet +PROGRAM_INDEX_CHECKER="$REPO_ROOT/target/$RUST_HOST_TARGET/release/xtask" +if [ ! -f "$PROGRAM_INDEX_CHECKER" ]; then + echo "Error: prepared xtask was not found at $PROGRAM_INDEX_CHECKER" >&2 + exit 1 +fi + if ! build_example_program echo; then err=$(head -5 /tmp/libc-test-build-err.txt 2>/dev/null || echo "(no error output)") echo "Error: failed to build examples/echo.wasm" >&2 @@ -395,6 +424,19 @@ if ! build_example_program echo; then exit 1 fi +# WHY: posix_spawnp() must prove a PATH candidate exists with access(X_OK) +# before asking the host to launch it. The host-only exec map is intentionally +# not visible to VFS access(), and the canonical rootfs contains deferred +# package stubs whose payloads are outside this isolated conformance run. +# Snapshot the freshly built helper into owned guest scratch so the libc test +# exercises the real POSIX PATH lookup and the normal VFS-backed spawn path. +mkdir -p "$RUNNER_FIXTURE_ROOT/bin" "$RUNNER_FIXTURE_ROOT/work" +if [ -e "$RUNNER_FIXTURE_ROOT/bin/echo" ]; then + chmod 0755 "$RUNNER_FIXTURE_ROOT/bin/echo" +fi +cp "$REPO_ROOT/examples/echo.wasm" "$RUNNER_FIXTURE_ROOT/bin/echo" +chmod 0555 "$RUNNER_FIXTURE_ROOT/bin/echo" + PASS=0 FAIL=0 SKIP=0 diff --git a/scripts/stage-portable-resolver-binaries.sh b/scripts/stage-portable-resolver-binaries.sh index e33ed2ef51..fcc649348a 100755 --- a/scripts/stage-portable-resolver-binaries.sh +++ b/scripts/stage-portable-resolver-binaries.sh @@ -176,20 +176,19 @@ fi unsafe_link="$( find "${scan_roots[@]}" -xdev -type l -print0 | while IFS= read -r -d '' link; do - case "$(readlink "$link")" in - /*) - printf '%s\n' "$link" - break - ;; - esac + # WHY: macOS still ships Bash 3.2, whose runtime parser misreads case + # patterns inside this outer command substitution. Use prefix removal so + # the prepared-workspace path is portable. + link_target="$(readlink "$link")" + if [ "${link_target#/}" != "$link_target" ]; then + printf '%s\n' "$link" + break + fi resolved="$(realpath "$link" 2>/dev/null || true)" - case "$resolved" in - "$stage_root"/*) ;; - *) - printf '%s\n' "$link" - break - ;; - esac + if [ "${resolved#"$stage_root"/}" = "$resolved" ]; then + printf '%s\n' "$link" + break + fi done )" if [ -n "$unsafe_link" ]; then diff --git a/scripts/test-package-build-roots.sh b/scripts/test-package-build-roots.sh index e4061fac32..fc942819bd 100755 --- a/scripts/test-package-build-roots.sh +++ b/scripts/test-package-build-roots.sh @@ -432,7 +432,7 @@ bash "$REPO_ROOT/scripts/test-package-isolated-output-contracts.sh" # while giving configure stable command names and sysroot-independent flags. ruby_script="$REPO_ROOT/packages/registry/ruby/build-ruby.sh" ruby_cc_wrapper="$REPO_ROOT/packages/registry/ruby/kandelo-ruby-cc" -ruby_spawn_patch="$REPO_ROOT/packages/registry/ruby/patches/kandelo-posix-spawn.patch" +ruby_retired_spawn_patch="$REPO_ROOT/packages/registry/ruby/patches/kandelo-posix-spawn.patch" bash -n "$ruby_cc_wrapper" || fail "Ruby compiler prefix wrapper has invalid shell syntax" ruby_wrapper_err="$TMP_ROOT/ruby-wrapper-missing-work-root.err" if env -u KANDELO_RUBY_WORK_DIR bash "$ruby_cc_wrapper" --version \ @@ -482,37 +482,37 @@ grep -F 'libdir="$GUEST_PREFIX/lib"' "$ruby_script" >/dev/null || grep -F 'RUBY_INSTALL_ROOT="$INSTALL_DIR$GUEST_PREFIX"' "$ruby_script" >/dev/null || fail "Ruby runtime installation does not honor the caller-selected guest prefix" -# Ruby must select the non-forking backend before it starts a child. Every -# unsupported option shape remains on Ruby's established fork path rather than -# being silently weakened to fit posix_spawn. -grep -F 'patches/kandelo-posix-spawn.patch' "$ruby_script" >/dev/null || - fail "Ruby build does not apply its Kandelo posix_spawn backend patch" -for required_spawn_contract in \ - 'kandelo_execarg_can_posix_spawn' \ - 'kandelo_execarg_has_independent_redirects' \ - 'kandelo_execarg_clear_nonblock_stdio' \ - 'kandelo_execarg_restore_fd_flags' \ - 'eargp->use_shell || NIL_P(eargp->invoke.cmd.command_abspath)' \ - 'eargp->umask_given || eargp->uid_given || eargp->gid_given' \ - 'eargp->rlimit_limits != Qfalse || eargp->fd_dup2_child != Qfalse' \ - 'eargp->fd_close != Qfalse' \ - '!rb_is_absolute_path(RSTRING_PTR(eargp->invoke.cmd.command_abspath))' \ - 'eargp->close_others_do' \ - 'eargp->pgroup_given && eargp->pgroup_pgid > 0' \ - 'posix_spawn_file_actions_adddup2' \ - 'posix_spawn_file_actions_addchdir' \ - 'POSIX_SPAWN_SETSIGMASK' \ - 'POSIX_SPAWN_SETSIGDEF' \ - 'POSIX_SPAWN_SETPGROUP' \ - 'ARGVSTR2ARGV(eargp->invoke.cmd.argv_str)' \ - 'RB_IMEMO_TMPBUF_PTR(eargp->envp_str) : environ' \ - 'handle_fork_error(' \ - 'pid = kandelo_posix_spawn_process(eargp)' \ - 'pid >= 0 || errno != ENOEXEC' -do - grep -F "$required_spawn_contract" "$ruby_spawn_patch" >/dev/null || - fail "Ruby posix_spawn patch is missing contract: $required_spawn_contract" -done +# Ruby must build its upstream fork-then-exec implementation. Kandelo's real +# vfork semantics are declared through configure's cross-cache answers, and a +# source marker prevents an old work directory containing #1166 from leaking +# that retired package-specific backend into the rebuilt artifact. +[ ! -e "$ruby_retired_spawn_patch" ] || + fail "Ruby still ships its retired Kandelo posix_spawn patch" +if grep -F 'patches/kandelo-posix-spawn.patch' "$ruby_script" >/dev/null; then + fail "Ruby build still applies its retired Kandelo posix_spawn patch" +fi +grep -F 'EXPECTED_SOURCE_MARKER="$RUBY_VERSION kandelo-port-14-upstream-vfork"' \ + "$ruby_script" >/dev/null || + fail "Ruby source marker does not invalidate #1166 work directories" +grep -F 'ac_cv_func_vfork=yes' "$ruby_script" >/dev/null || + fail "Ruby configure does not declare Kandelo vfork" +grep -F 'ac_cv_func_vfork_works=yes' "$ruby_script" >/dev/null || + fail "Ruby configure does not declare working Kandelo vfork semantics" +grep -F 'ac_cv_func_getresuid=yes' "$ruby_script" >/dev/null || + fail "Ruby configure does not expose saved user IDs to its vfork guard" +grep -F 'ac_cv_func_getresgid=yes' "$ruby_script" >/dev/null || + fail "Ruby configure does not expose saved group IDs to its vfork guard" +grep -F 'ac_cv_func_getuidx=no' "$ruby_script" >/dev/null || + fail "Ruby configure still exposes the unavailable AIX getuidx fallback" +grep -F 'ac_cv_func_getgidx=no' "$ruby_script" >/dev/null || + fail "Ruby configure still exposes the unavailable AIX getgidx fallback" +grep -F "grep -Eq '^#define HAVE_WORKING_VFORK 1\$'" "$ruby_script" >/dev/null || + fail "Ruby build does not verify HAVE_WORKING_VFORK" +grep -F "grep -F 'pid = vfork();'" "$ruby_script" >/dev/null || + fail "Ruby build does not preserve the upstream vfork call" +if grep -F "'HAVE_VFORK', 'HAVE_TCGETATTR'" "$ruby_script" >/dev/null; then + fail "Ruby config postprocessing still disables HAVE_VFORK" +fi # Ruby concatenates this prefix with DESTDIR, embeds it into rbconfig, and uses # it for its built-in load path. Reject malformed caller input before reaching diff --git a/scripts/test-wasm-artifact-guards.sh b/scripts/test-wasm-artifact-guards.sh index f4e5562ed7..e9a71d3f08 100755 --- a/scripts/test-wasm-artifact-guards.sh +++ b/scripts/test-wasm-artifact-guards.sh @@ -562,7 +562,8 @@ cat >"$work/complete-fork.wat" <<'WAT' (@custom "kandelo.wpk_fork.linked_frames" "KLCF\01\00\18\00\04\08\03\00\20\00\00\00\18\00\00\00\10\00\00\00") (@custom "kandelo.wpk_fork.capabilities" "\01\04") - (import "kernel" "kernel_fork" (func $kernel_fork)) + (import "kernel" "kernel_fork" + (func $kernel_fork (param i32) (result i32))) (import "env" "__wpk_fork_frame_reserve" (func $frame_reserve (param i32) (result i32))) (import "env" "__wpk_fork_frame_commit" @@ -579,7 +580,9 @@ cat >"$work/complete-fork.wat" <<'WAT' (func (export "wpk_fork_state") (result i32) i32.const 0) (func (export "_start") - call $kernel_fork)) + i32.const 0 + call $kernel_fork + drop)) WAT wat2wasm --enable-annotations "$work/complete-fork.wat" -o "$work/complete-fork.wasm" if ! wasm_has_complete_fork_instrumentation "$work/complete-fork.wasm"; then @@ -594,7 +597,7 @@ wasm_require_fork_instrumentation_if_needed "$work/complete-fork.wasm" awk ' { print } - /\(import "kernel" "kernel_fork"/ { + /\(func \$kernel_fork/ { print " (import \"env\" \"__wasm_dlopen\"" print " (func (param i32 i32 i32 i32 i32) (result i32)))" } @@ -723,7 +726,8 @@ cat >"$work/complete-fork-wasm64.wat" <<'WAT' (@custom "kandelo.wpk_fork.linked_frames" "KLCF\01\00\18\00\08\08\03\00\38\00\00\00\20\00\00\00\10\00\00\00") (@custom "kandelo.wpk_fork.capabilities" "\01\04") - (import "kernel" "kernel_fork" (func $kernel_fork)) + (import "kernel" "kernel_fork" + (func $kernel_fork (param i32) (result i32))) (import "env" "__wpk_fork_frame_reserve" (func $frame_reserve (param i64) (result i64))) (import "env" "__wpk_fork_frame_commit" @@ -740,7 +744,9 @@ cat >"$work/complete-fork-wasm64.wat" <<'WAT' (func (export "wpk_fork_state") (result i32) i32.const 0) (func (export "_start") - call $kernel_fork)) + i32.const 0 + call $kernel_fork + drop)) WAT wat2wasm --enable-annotations --enable-memory64 "$work/complete-fork-wasm64.wat" \ -o "$work/complete-fork-wasm64.wasm" @@ -755,7 +761,8 @@ cat >"$work/partial-fork.wat" <<'WAT' (@custom "kandelo.wpk_fork.linked_frames" "KLCF\01\00\18\00\04\08\03\00\20\00\00\00\18\00\00\00\10\00\00\00") (@custom "kandelo.wpk_fork.capabilities" "\01\04") - (import "kernel" "kernel_fork" (func $kernel_fork)) + (import "kernel" "kernel_fork" + (func $kernel_fork (param i32) (result i32))) (import "env" "__wpk_fork_frame_reserve" (func $frame_reserve (param i32) (result i32))) (import "env" "__wpk_fork_frame_commit" @@ -770,7 +777,9 @@ cat >"$work/partial-fork.wat" <<'WAT' (func (export "wpk_fork_rewind_begin") (param i32)) (func (export "wpk_fork_rewind_end")) (func (export "_start") - call $kernel_fork)) + i32.const 0 + call $kernel_fork + drop)) WAT wat2wasm --enable-annotations "$work/partial-fork.wat" -o "$work/partial-fork.wasm" partial_fork_error="$work/partial-fork.error" @@ -1037,12 +1046,15 @@ fi cat >"$work/fake-fork-exports.wat" <<'WAT' (module - (import "kernel" "kernel_fork" (func $kernel_fork)) + (import "kernel" "kernel_fork" + (func $kernel_fork (param i32) (result i32))) (memory 1) (data (i32.const 0) "wpk_fork_unwind_begin wpk_fork_unwind_end wpk_fork_rewind_begin wpk_fork_rewind_end wpk_fork_state") (func (export "_start") - call $kernel_fork)) + i32.const 0 + call $kernel_fork + drop)) WAT wat2wasm "$work/fake-fork-exports.wat" -o "$work/fake-fork-exports.wasm" if ! wasm_has_missing_fork_instrumentation "$work/fake-fork-exports.wasm"; then diff --git a/scripts/wasm-artifact-guards.sh b/scripts/wasm-artifact-guards.sh index f0d65f3e8f..bd381d7b6a 100644 --- a/scripts/wasm-artifact-guards.sh +++ b/scripts/wasm-artifact-guards.sh @@ -1009,7 +1009,12 @@ _wasm_fork_contract_inventory() { /^ - func\[.* sig=[0-9]+/ { function_signatures[function_index($0)] = function_types[signature_index($0)] } - /^ - func\[.* <- (kernel\.kernel_fork|env\.fork)$/ { imports_fork = 1 } + /^ - func\[.* <- kernel\.kernel_fork$/ { + imports_fork = 1 + kernel_fork++ + kernel_fork_signatures[kernel_fork] = function_signatures[function_index($0)] + } + /^ - func\[.* <- env\.fork$/ { imports_fork = 1 } /^ - func\[.* <- env\.__wasm_dlopen$/ { legacy_dlopen++ } /^ - func\[.* <- env\.__wpk_fork_frame_reserve$/ { frame_reserve++ @@ -1063,6 +1068,8 @@ _wasm_fork_contract_inventory() { pointer_to_pointer = "(" pointer ") -> " pointer pointer_to_nil = "(" pointer ") -> nil" nil_to_nil = "() -> nil" + for (i = 1; i <= kernel_fork; i++) + if (kernel_fork_signatures[i] != "(i32) -> i32") signature_mismatch++ for (i = 1; i <= frame_reserve; i++) if (frame_reserve_signatures[i] != pointer_to_pointer) signature_mismatch++ for (i = 1; i <= frame_commit; i++) diff --git a/tests/test-artifacts/kernel-test-programs.json b/tests/test-artifacts/kernel-test-programs.json index e779dff6c9..ec02597e1d 100644 --- a/tests/test-artifacts/kernel-test-programs.json +++ b/tests/test-artifacts/kernel-test-programs.json @@ -29,8 +29,12 @@ "consumers": [ "apps/browser-demos/test/nonzero-exit-diagnostic.spec.ts", "apps/browser-demos/test/process-memory-retirement.spec.ts", + "apps/browser-demos/test/ruby-posix-spawn.spec.ts", + "apps/browser-demos/test/vfork-lifecycle.spec.ts", "host/test/exec.test.ts", - "host/test/fixtures/ordinary-nonzero-exit.ts" + "host/test/fixtures/ordinary-nonzero-exit.ts", + "host/test/vfork-lifecycle-guest.test.ts", + "packages/registry/ruby/test/posix-spawn.test.ts" ] }, { diff --git a/tools/xtask/src/build_deps.rs b/tools/xtask/src/build_deps.rs index 4d0a04b972..c11892519f 100644 --- a/tools/xtask/src/build_deps.rs +++ b/tools/xtask/src/build_deps.rs @@ -50,6 +50,7 @@ use std::path::{Component, Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; use sha2::{Digest, Sha256}; @@ -5534,6 +5535,40 @@ fn wasm_artifact_policy_failures_for( } } + if facts.imports_kernel_fork { + let requirement = wasm_posix_shared::abi::WPK_FORK_PROCESS_IMPORT; + let identity = (requirement.module.to_string(), requirement.name.to_string()); + match facts.function_imports.get(&identity).map(Vec::as_slice) { + Some([signature]) => { + if !program_artifact_signature_matches( + signature, + requirement.params, + requirement.results, + 4, + ) { + failures.push(format!( + "ABI 43 process-fork import {}.{} has the wrong signature; expected {}", + requirement.module, + requirement.name, + program_artifact_signature_text( + requirement.params, + requirement.results, + 4, + ) + )); + } + } + Some(_) => failures.push(format!( + "has duplicate ABI 43 process-fork import {}.{}", + requirement.module, requirement.name + )), + None => failures.push(format!( + "is missing ABI 43 process-fork import {}.{}", + requirement.module, requirement.name + )), + } + } + if let Err(error) = validate_fork_capabilities(&facts.fork_capabilities) { failures.push(error); } @@ -6875,7 +6910,15 @@ fn cmd_resolve( if let Some(bdir) = binaries_dir { if matches!(m.kind, ManifestKind::Program) && !m.program_outputs.is_empty() { let cache_key_sha = manifest_cache_key_sha(m, registry, arch, current_abi_version())?; - place_binaries_symlinks(m, &path, bdir, arch, &cache_key_sha)?; + publish_resolved_program_artifacts( + m, + &path, + repo, + bdir, + arch, + &cache_key_sha, + force_source_build, + )?; } } @@ -6883,6 +6926,124 @@ fn cmd_resolve( Ok(()) } +/// Publish one resolved program through the identity contract of its target +/// mirror. +/// +/// `--binaries-dir` normally materializes fetched-cache links. The repository's +/// `local-binaries` directory is different: the host treats it as the +/// higher-priority direct-build tier and accepts package-owned links only when +/// they select a claimed immutable local generation. A forced source proof +/// therefore copies the selected target package into that namespace instead +/// of creating cache links that the host must reject. Dependencies retain the +/// ordinary resolver path; `--force-source-build` deliberately applies only to +/// the selected package. +fn publish_resolved_program_artifacts( + manifest: &DepsManifest, + canonical: &Path, + repo: &Path, + binaries_dir: &Path, + arch: TargetArch, + cache_key_sha: &str, + force_source_build: bool, +) -> Result<(), String> { + let publication_root = canonical_real_directory(binaries_dir, "binaries publication root")?; + let repo_local_binaries = repo.join("local-binaries"); + let publishes_to_local_tier = match std::fs::canonicalize(&repo_local_binaries) { + Ok(path) => path == publication_root, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => { + return Err(format!( + "inspect repository local-binaries root {}: {error}", + repo_local_binaries.display(), + )); + } + }; + + if force_source_build && publishes_to_local_tier { + return publish_forced_source_local_generation( + manifest, + canonical, + &publication_root, + arch, + cache_key_sha, + ); + } + + place_binaries_symlinks( + manifest, + canonical, + &publication_root, + arch, + cache_key_sha, + ) +} + +fn publish_forced_source_local_generation( + manifest: &DepsManifest, + canonical: &Path, + binaries_dir: &Path, + arch: TargetArch, + cache_key_sha: &str, +) -> Result<(), String> { + validate_cache_artifacts(manifest, canonical)?; + let epoch_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| format!("create forced-source local generation identity: {error}"))? + .as_nanos(); + let sequence = MIRROR_TRANSACTION_COUNTER.fetch_add(1, Ordering::Relaxed); + let session = format!( + "source-resolve-{}-{epoch_nanos}-{sequence}", + std::process::id(), + ); + + let mut outcome = None; + for artifact in manifest + .program_outputs + .iter() + .map(|output| output.wasm.as_str()) + .chain( + manifest + .runtime_files + .iter() + .map(|runtime_file| runtime_file.artifact.as_str()), + ) + { + let source = canonical.join(artifact); + outcome = Some(install_local_artifact( + manifest, + cache_key_sha, + artifact, + &source, + &session, + binaries_dir, + arch, + )?); + } + + match outcome { + Some(LocalArtifactInstall::Published { .. }) + if manifest.uses_package_mirror_directory() => Ok(()), + Some(LocalArtifactInstall::Replaced { .. }) + if !manifest.uses_package_mirror_directory() => Ok(()), + Some(LocalArtifactInstall::Staged { + generation, + remaining, + }) => Err(format!( + "{}: forced-source local generation {} remained incomplete after collecting the declared closure ({remaining} missing)", + manifest.spec(), + generation.display(), + )), + Some(_) => Err(format!( + "{}: forced-source local generation produced an unexpected publication outcome", + manifest.spec(), + )), + None => Err(format!( + "{}: forced-source local generation has no declared artifacts", + manifest.spec(), + )), + } +} + const LOCAL_GENERATIONS_DIR: &str = ".kandelo-local-generations"; fn manifest_cache_key_sha( @@ -11596,12 +11757,19 @@ wasm = "second.wasm" // surface than the original three linked-frame hooks, and a // hand-maintained type-index switch silently went stale as reference // ownership was added. + let process_import = abi::WPK_FORK_PROCESS_IMPORT; let mut types = vec![ + wasm_contract_function_type( + process_import.params, + process_import.results, + signature_pointer_width, + ), wasm_contract_function_type(&[], &[I32], signature_pointer_width), wasm_contract_function_type(&[], &[], signature_pointer_width), ]; let kernel_fork_type = 0u32; - let empty_function_type = 1u32; + let abi_version_type = 1u32; + let empty_function_type = 2u32; let mut imports = Vec::new(); if include_kernel_fork { @@ -11643,7 +11811,7 @@ wasm = "second.wasm" )); local_functions.push((requirement.name, type_index, requirement.results)); } - local_functions.push(("__abi_version", kernel_fork_type, &[I32])); + local_functions.push(("__abi_version", abi_version_type, &[I32])); local_functions.push(("_start", empty_function_type, &[])); let mut type_section = uleb(types.len() as u32); @@ -11767,7 +11935,10 @@ wasm = "second.wasm" for name in custom_sections { bytes.extend(wasm_section(0, wasm_name(name))); } - bytes.extend(wasm_section(1, vec![0x01, 0x60, 0x00, 0x01, 0x7f])); + bytes.extend(wasm_section( + 1, + vec![0x01, 0x60, 0x01, 0x7f, 0x01, 0x7f], + )); let mut imports = vec![0x01]; imports.extend(wasm_name("kernel")); @@ -20589,6 +20760,73 @@ libs = ["lib/libF3b.a"] ); } + #[test] + fn forced_source_resolve_into_repo_local_binaries_claims_local_generation() { + let root = tempdir("resolve-local-generation-reg"); + let cache = tempdir("resolve-local-generation-cache"); + let bin_dir = root.join("local-binaries"); + std::fs::create_dir(&bin_dir).unwrap(); + write_program( + &root, + "localproof", + "0.1.0", + &[], + &emit_wasm_build_script("localproof.wasm", &minimal_executable_wasm()), + &[("localproof", "localproof.wasm")], + ); + let registry = Registry { + roots: vec![root.clone()], + }; + let manifest = registry.load("localproof").unwrap(); + let forced = BTreeSet::from([manifest.name.clone()]); + let opts = ResolveOpts { + cache_root: &cache, + local_libs: None, + force_source_build: Some(&forced), + fetch_only: false, + repo_root: Some(&root), + binaries_dir: None, + }; + let canonical = + ensure_built(&manifest, ®istry, TEST_ARCH, TEST_ABI, &opts).unwrap(); + let cache_key_sha = + manifest_cache_key_sha(&manifest, ®istry, TEST_ARCH, TEST_ABI).unwrap(); + + publish_resolved_program_artifacts( + &manifest, + &canonical, + &root, + &bin_dir, + TEST_ARCH, + &cache_key_sha, + true, + ) + .unwrap(); + + let mirror = bin_dir.join("programs/wasm32/localproof.wasm"); + let target = std::fs::read_link(&mirror).unwrap(); + let identity_root = std::fs::canonicalize( + bin_dir + .join(LOCAL_GENERATIONS_DIR) + .join("wasm32/localproof") + .join(&cache_key_sha), + ) + .unwrap(); + assert!(target.starts_with(&identity_root), "got: {}", target.display()); + let generation = target.parent().unwrap(); + let session = generation.file_name().unwrap().to_string_lossy(); + let claim = identity_root.join(format!(".{session}.publication-claimed")); + assert!(claim.is_file(), "missing claim: {}", claim.display()); + assert_eq!( + std::fs::read(&mirror).unwrap(), + minimal_executable_wasm(), + ); + assert!( + !target.starts_with(&cache), + "local mirror must not point directly into the fetched cache", + ); + } + #[test] fn cmd_resolve_materializes_program_runtime_file_under_package_directory() { let root = tempdir("resolve-bdir-runtime-reg"); diff --git a/tools/xtask/src/dump_abi.rs b/tools/xtask/src/dump_abi.rs index be6c7941f1..5c721fc4f8 100644 --- a/tools/xtask/src/dump_abi.rs +++ b/tools/xtask/src/dump_abi.rs @@ -16,6 +16,7 @@ //! descriptors //! * [`wasm_posix_shared::wakeup_event_wire`] — kernel wakeup-event layout //! and retry/lifecycle reason bits consumed by shared hosts +//! * [`wasm_posix_shared::fork_contract`] — process-fork import mode values //! * [`wasm_posix_shared::poll`], [`wasm_posix_shared::epoll`], and //! [`wasm_posix_shared::select`] — I/O multiplexing event metadata //! * [`wasm_posix_shared::flags`], [`wasm_posix_shared::access`], @@ -619,6 +620,10 @@ fn render_c_header() -> String { /* Non-forking spawn syscall number. */\n\ #define WASM_POSIX_SYS_SPAWN {sys_spawn}u\n\ \n\ + /* Process-fork import mode selectors. */\n\ + #define WASM_POSIX_FORK_MODE_FORK {fork_mode_fork}u\n\ + #define WASM_POSIX_FORK_MODE_VFORK {fork_mode_vfork}u\n\ + \n\ /* Default process-wasm pthread slot declaration. */\n\ #define WASM_POSIX_THREAD_SLOT_DECL_DEFAULT {thread_slots_default}\n\ \n\ @@ -630,6 +635,8 @@ fn render_c_header() -> String { \n", version = shared::ABI_VERSION, sys_spawn = shared::abi::host_intercepted::SYS_SPAWN, + fork_mode_fork = shared::fork_contract::MODE_FORK, + fork_mode_vfork = shared::fork_contract::MODE_VFORK, thread_slots_default = shared::process_memory::THREAD_SLOTS_USE_HOST_DEFAULT, rusage_wire_size = shared::WASM_RUSAGE_WIRE_SIZE, termios_size = shared::ioctl_contract::TERMIOS_SIZE, @@ -2081,6 +2088,25 @@ fn render_ts_module() -> String { ] { out.push_str(&format!("export const {name} = {value:?} as const;\n")); } + out.push_str(&format!( + "export const PROCESS_FORK_MODE_FORK = {} as const;\n", + shared::fork_contract::MODE_FORK, + )); + out.push_str(&format!( + "export const PROCESS_FORK_MODE_VFORK = {} as const;\n", + shared::fork_contract::MODE_VFORK, + )); + out.push_str( + "export type ProcessForkMode =\n | typeof PROCESS_FORK_MODE_FORK\n | typeof PROCESS_FORK_MODE_VFORK;\n", + ); + let process_fork_import = shared::abi::WPK_FORK_PROCESS_IMPORT; + out.push_str(&format!( + "export const WPK_FORK_PROCESS_IMPORT = {{ module: {:?}, name: {:?}, params: {}, results: {} }} as const;\n", + process_fork_import.module, + process_fork_import.name, + render_ts_program_artifact_types(process_fork_import.params), + render_ts_program_artifact_types(process_fork_import.results), + )); out.push_str("export const WPK_FORK_REQUIRED_IMPORTS = [\n"); for requirement in shared::abi::WPK_FORK_REQUIRED_IMPORTS { out.push_str(&format!( @@ -5720,8 +5746,8 @@ fn program_artifact() -> Value { WPK_FORK_REFERENCE_TRANSACTION_FLAG_SEALED, WPK_FORK_REFERENCE_TRANSACTION_KNOWN_FLAGS, WPK_FORK_REFERENCE_TRANSACTION_MAGIC, WPK_FORK_REFERENCE_TRANSACTION_MANIFEST_SIZE, WPK_FORK_REFERENCE_TRANSACTION_OWNER, WPK_FORK_REFERENCE_TRANSACTION_VERSION, - WPK_FORK_REFERENCE_VECTOR_INDEX_SIZE, WPK_FORK_REQUIRED_EXPORTS, WPK_FORK_REQUIRED_IMPORTS, - WPK_FORK_REQUIRED_TABLE_IMPORTS, WPK_FORK_STATIC_ROOT_CATALOG_EXPORT, + WPK_FORK_REFERENCE_VECTOR_INDEX_SIZE, WPK_FORK_PROCESS_IMPORT, WPK_FORK_REQUIRED_EXPORTS, + WPK_FORK_REQUIRED_IMPORTS, WPK_FORK_REQUIRED_TABLE_IMPORTS, WPK_FORK_STATIC_ROOT_CATALOG_EXPORT, WPK_FORK_STATIC_ROOT_CATALOG_HEADER_SIZE, WPK_FORK_STATIC_ROOT_CATALOG_MAGIC, WPK_FORK_STATIC_ROOT_CATALOG_SECTION, WPK_FORK_STATIC_ROOT_CATALOG_VERSION, WPK_FORK_STATIC_ROOT_HARVEST_EXPORT, WPK_FORK_UNWIND_TAG_IMPORT_MODULE, @@ -5795,6 +5821,19 @@ fn program_artifact() -> Value { }) .collect(); + let mut process_import: JsonMap = BTreeMap::new(); + process_import.insert("kind".into(), json!("func")); + process_import.insert("module".into(), json!(WPK_FORK_PROCESS_IMPORT.module)); + process_import.insert("name".into(), json!(WPK_FORK_PROCESS_IMPORT.name)); + process_import.insert( + "params".into(), + value_types(WPK_FORK_PROCESS_IMPORT.params), + ); + process_import.insert( + "results".into(), + value_types(WPK_FORK_PROCESS_IMPORT.results), + ); + let pointer_widths = WPK_FORK_LINKED_FRAME_POINTER_WIDTHS .iter() .map(|pointer_width| { @@ -6596,6 +6635,17 @@ fn program_artifact() -> Value { "module_state".into(), Value::Object(module_state.into_iter().collect()), ); + fork.insert( + "process_import".into(), + Value::Object(process_import.into_iter().collect()), + ); + fork.insert( + "process_modes".into(), + json!({ + "fork": shared::fork_contract::MODE_FORK, + "vfork": shared::fork_contract::MODE_VFORK, + }), + ); fork.insert("required_exports".into(), Value::Array(exports)); fork.insert("required_imports".into(), Value::Array(imports)); @@ -7752,6 +7802,17 @@ mod tests { fn program_artifact_snapshot_captures_complete_abi43_fork_contract() { let artifact = program_artifact(); let fork = &artifact["fork_instrumentation"]; + assert_eq!( + fork["process_import"], + json!({ + "kind": "func", + "module": "kernel", + "name": "kernel_fork", + "params": ["i32"], + "results": ["i32"] + }) + ); + assert_eq!(fork["process_modes"], json!({"fork": 0, "vfork": 1})); let descriptor = &fork["linked_frame_descriptor"]; assert_eq!( descriptor["section"], From 806704bbb84f6c63077e39f52adbd8c9d2c52e10 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Fri, 7 Aug 2026 13:41:29 -0400 Subject: [PATCH 55/82] Homebrew: Bind ABI-aware finalizer and launcher fixtures PR #1188's main-shell finalizer deliberately models the published ABI 42 bottle cohort. Its test copied the ambient ABI_VERSION, so the ABI 43 integration relabeled only the source side and failed before exercising the historical contract. Pin copied success fixtures to ABI 42 and retain a separate negative case that proves an ABI 43 source cannot seal the ABI 42 selection or mutate reviewed inputs. This keeps publication pending rather than inventing cross-ABI compatibility. --- ...8-10-abi43-login-sudo-vfork-integration.md | 3285 +++++++++++++++++ ...i43-login-sudo-vfork-integration-design.md | 964 +++++ host/src/homebrew-bottle-selection.ts | 11 +- host/test/fixtures/homebrew-flat-vfs.ts | 14 +- host/test/homebrew-bottle-selection.test.ts | 46 +- host/test/homebrew-flat-vfs-builder.test.ts | 33 +- host/test/homebrew-flat-vfs-cli.test.ts | 19 +- packages/registry/program-packages.json | 46 +- scripts/homebrew-tap-recipe-runner.py | 1 + ...st-finalize-homebrew-main-shell-release.py | 49 + scripts/test-homebrew-patched-launcher.sh | 2 + scripts/test-homebrew-tap-recipe-runner.py | 7 + 12 files changed, 4400 insertions(+), 77 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-10-abi43-login-sudo-vfork-integration.md create mode 100644 docs/superpowers/specs/2026-08-10-abi43-login-sudo-vfork-integration-design.md diff --git a/docs/superpowers/plans/2026-08-10-abi43-login-sudo-vfork-integration.md b/docs/superpowers/plans/2026-08-10-abi43-login-sudo-vfork-integration.md new file mode 100644 index 0000000000..88a0c6ee21 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-abi43-login-sudo-vfork-integration.md @@ -0,0 +1,3285 @@ +# ABI 43 Login, Sudo, and vfork Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship ABI 43 with a real login and sudo stack, exact set-ID exec +authority, secure startup, a Homebrew-independent VFS security boundary, and +evidence that Kandelo's genuine vfork path is safe and removes the need for +the temporary CRuby patch. + +**Architecture:** Forward-port behavior, not obsolete branch mechanics, into +the current linear ABI 43 batch. Generic VFS materialization and explicit +mount capabilities establish the executable trust boundary; one authoritative +process credential record and opaque prepared-exec targets make privilege +changes transactional. Existing genuine vfork remains an independent +shared-memory process path and must pass mechanism, integration, and final +artifact gates before pristine Ruby is released. + +**Tech Stack:** Rust `no_std` kernel and shared ABI crates, C/musl guest +runtime and programs, TypeScript host runtime and VFS, Node.js workers, +browser Web Workers, Vitest, Playwright, Cargo, Homebrew Formulae, shell and +Python release tooling. + +## Global Constraints + +- Work only on `integration/abi43-batch-linear-20260801`; keep its history + linear, retain conceptual commits, and require rebase-commit merging for + PR #1240. Never squash this batch. +- Preserve the exact pre-login tip `bd8ac83e3` under + `safety/abi43-pre-login-20260810` before implementation. +- Preserve unrelated dirty submodules, `.serena/`, and browser test results. + Stage explicit paths, never `git add -A`. +- Forward-port, do not merge or mechanically cherry-pick, + `8a66801e6353bed9ff55fa1dc5e3b7e1b0b53e24`, + `ebde506115e7b4bfe26a5eaf0b7d097c3e1ee939`, or the final twelve commits + from `emdash/support-logins-8yaz3`. +- Preserve Brandon Payton as author for materially derived VFS and login + commits; the forward porter remains committer. Verify with `git range-diff` + and `git log --format=fuller` before push. +- Do not restore Kandelo package-registry recipes or bridge login, sudo, + sudo-lite, Ruby, or shell binaries through `packages/registry/`. +- POSIX conformance is the target. Do not add Linux `__WALL`, Linux clone + child classes, System V compatibility, or package-specific kernel behavior. +- Node.js and browser hosts are peers. Shared host behavior requires matching + Node and browser coverage; browser coverage means Chromium, Firefox, and + WebKit wherever the path applies. +- ABI 43 requires the prepared-target protocol outright. Keep + `ABI_VERSION = 43`, remove targetless exec interfaces, regenerate + `abi/snapshot.json` and `host/src/generated/abi.ts`, and run snapshot checks. +- Do not add a new vfork import, fork mode, fork-instrument frame field, or + shared-memory ownership protocol under this design. Stop for design review + if evidence requires one. +- A vfork parent may resume after child memory access only after the exact + child-generated `memory_quiescent` fence. Timeout or Worker termination + return is never quiescence evidence. +- If browsers expose no exact forced-termination fence, retain loud + whole-address-space containment and document vfork as partial; do not add a + larger safe-point or coordinator architecture without a revised design and + Brandon's approval. +- Mounts default to `nosuid`. Only a reviewed, root-owned, non-guest-writable + product mount with stable executable identity may honor set-ID bits. +- Local bottles and sidecars have `local-test` provenance. Only reviewed + GitHub workflows may create or promote authorized candidates. +- Do not modify, merge, or rebase + `emdash/homebrew-pr-staging-1q1w6`. Consume its reviewed interfaces only + after they land and become active. +- Run every build and validation command through `scripts/dev-shell.sh`. + After any musl overlay or syscall-glue change, run + `scripts/dev-shell.sh bash scripts/build-musl.sh` before `build.sh` or tests. +- Do not merge kernel, ABI, libc, host-runtime, or fork-instrument changes + without Brandon's explicit approval. +- Commit and PR subjects use `Area: Purpose`. PR prose begins with `## Why` + and wraps prose at 72 columns. + +--- + +## File and Interface Map + +### New focused files + +- `crates/kernel/src/credentials.rs` owns `Credentials`, `NGROUPS_MAX`, and + POSIX UID/GID transition checks. `Process` is its sole owner. +- `crates/kernel/src/exec_target.rs` owns prepared-target tokens, owner and + generation binding, exact OFD retention, target revalidation, set-ID + proposals, and exactly-once cancellation or consumption. +- `host/src/vfs/materialization-plan.ts` owns generic bounded archive byte + assertions, transforms, and exact byte identities. +- `host/src/homebrew-deferred-tree-adapter.ts` validates Homebrew receipts and + erases Homebrew vocabulary into generic lazy-tree inputs. +- `host/src/exec-target.ts` defines the shared Node/browser opaque exec launch + request and target reader; host-specific entry points consume this module. +- `images/vfs/lib/demo-login.ts` is the one source of demo account constants, + password hash, and autologin message data. +- `apps/browser-demos/pages/kandelo/kernel-host/demo-terminal-sessions.ts` + maps the demo product to the reusable session policy. +- `scripts/run-vfork-readiness.sh` makes the mechanism and integration vfork + gates repeatable and records exact commands and browser engines. +- `scripts/run-login-stack-local.sh` builds local-test bottles, composes a + disposable product, runs Node/browser evidence, and emits one bound report. +- `docs/measurements/2026-08-10-vfork-readiness.md` records exact-head vfork + mechanism and integration results without turning unrun checks into claims. + +### Core interfaces + +The kernel credential record is: + +```rust +pub const NGROUPS_MAX: usize = 32; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Credentials { + pub ruid: u32, + pub euid: u32, + pub suid: u32, + pub rgid: u32, + pub egid: u32, + pub sgid: u32, + pub supplementary_groups: Vec, +} +``` + +`Process` owns `credentials: Credentials`, `secure_exec: bool`, +`exec_generation: u64`, and `prepared_exec_targets: PreparedExecLedger`. +Callers use `real_uid()`, `effective_uid()`, `real_gid()`, and +`effective_gid()` accessors rather than caching identity elsewhere. + +The ABI 43 prepared-target exports are: + +```text +kernel_exec_target_prepare( + pid: u32, caller_tid: u32, dirfd: i32, + path_ptr: usize, path_len: usize, flags: u32 +) -> i32 +kernel_spawn_exec_target_prepare( + parent_pid: u32, child_pid: u32, + path_ptr: usize, path_len: usize +) -> i32 +kernel_exec_target_size(owner_pid: u32, target: u32) -> i64 +kernel_exec_target_read( + owner_pid: u32, target: u32, + offset_lo: u32, offset_hi: i32, + buffer_ptr: usize, buffer_len: usize +) -> i32 +kernel_exec_target_cancel(owner_pid: u32, target: u32) -> i32 +kernel_exec_commit(pid: u32, caller_tid: u32, target: u32) -> i32 +kernel_spawn_exec_commit( + parent_pid: u32, child_pid: u32, target: u32 +) -> i32 +``` + +The host queries `kernel_process_secure_exec(pid: u32) -> i32` after an exec +commit and supplies the result in the kernel-owned Worker launch transaction. +The guest-facing required import remains the zero-argument +`kernel_get_secure_exec() -> i32`; `worker-main.ts` returns only the bound +launch value, never a guest or boot-descriptor field. + +The shared host request is: + +```ts +export interface PreparedExecLaunchRequest { + readonly ownerPid: number; + readonly callerTid: number; + readonly target: number; + readonly argv: readonly string[]; + readonly envp: readonly string[]; + readonly diagnosticPath: string; +} + +export type ExecLaunchCallback = ( + request: PreparedExecLaunchRequest, +) => Promise; +``` + +`diagnosticPath` is never execution authority. The host reads bytes only via +the target token and commits only that token. + +Generic archive materialization uses: + +```ts +export type LazyTreeDecoder = "zip-v1" | "tar-gzip-v1"; + +export interface LazyTreeByteIdentity { + sha256: string; + bytes: number; +} + +export interface LazyTreeByteTransformRecipe { + id: string; + replacements: readonly { + matchHex: string; + replacementHex: string; + }[]; + rejectHex: readonly string[]; +} + +export interface LazyTreeMaterializationPlan { + schema: 1; + kind: "archive-byte-transforms-v1"; + assertions: readonly { sourcePath: string; bytesHex: string }[]; + recipes: readonly LazyTreeByteTransformRecipe[]; + transforms: readonly { + sourcePath: string; + recipe: string; + input: LazyTreeByteIdentity; + output: LazyTreeByteIdentity; + }[]; +} +``` + +The mount security boundary is deliberately small: + +```ts +export type MountSetIdCapability = + | { kind: "nosuid" } + | { + kind: "trusted-root-product"; + guestWritable: false; + stableExecutableIdentity: true; + }; + +export interface MountConfig { + mountPoint: string; + backend: FileSystemBackend; + readonly?: boolean; + setIdCapability?: MountSetIdCapability; +} +``` + +Omission means `nosuid`. Mount construction rejects +`trusted-root-product` unless both booleans have the exact safe values and +the backend implements the stable executable-lease contract. + +Reusable terminal supervision uses: + +```ts +export interface TerminalProgram { + programPath: string; + programBytes?: ArrayBuffer; + argv: string[]; + env?: string[]; + cwd?: string; + uid?: number; + gid?: number; +} + +export interface TerminalSessionPolicy { + initial: TerminalProgram; + afterExit: TerminalProgram; + shortRunThresholdMs: 2_000; + initialRestartDelayMs: 250; + maximumRestartDelayMs: 5_000; +} +``` + +Every logical PTY consumes `initial` once, then uses `afterExit` for all +later process generations. UI detach is not logical PTY removal. + +## Gate Outcome Rule + +Each gate below is a stop condition. If a required invariant fails, keep the +smallest reproducer red, trace the actual owning layer, insert a +purpose-scoped repair commit immediately after that gate, and rerun the whole +gate. Do not reinterpret a skip, timeout, containment shutdown, or narrow unit +test as passing evidence. If the repair needs an interface forbidden by the +approved design, stop implementation and ask Brandon to revise the design. + +--- + +### Task 1: Preserve the pre-login tip and repair the launcher fixture + +**Files:** + +- Modify: `scripts/test-homebrew-patched-launcher.sh` +- Test: `scripts/test-homebrew-patched-launcher.sh` + +**Interfaces:** + +- Consumes: exact pre-login commit `bd8ac83e3` +- Produces: safety reference `safety/abi43-pre-login-20260810`; isolated + launcher fixtures containing every source imported by `run-example.ts` + +- [ ] **Step 1: Create and verify the safety reference** + +```bash +git branch safety/abi43-pre-login-20260810 bd8ac83e3 +test "$(git rev-parse safety/abi43-pre-login-20260810)" = \ + "bd8ac83e34f529887b0dd5ff4e1bb9d349bc7aed" +``` + +- [ ] **Step 2: Make the fixture test assert the missing dependency** + +Add `examples/run-example-vfs.ts` beside the existing +`run-example-output.ts` and `run-example-paths.ts` checks in both isolated +fixture lists. The expected failing condition is module resolution failure +for `./run-example-vfs`. + +- [ ] **Step 3: Run the focused test and observe the pre-fix failure** + +Run: + +```bash +scripts/dev-shell.sh bash scripts/test-homebrew-patched-launcher.sh +``` + +Expected before the copy-list repair: FAIL naming +`examples/run-example-vfs.ts` or its unresolved import. + +- [ ] **Step 4: Copy the dependency into every isolated runtime** + +In both source arrays, keep this complete adjacent set: + +```bash +examples/run-example.ts +examples/run-example-output.ts +examples/run-example-paths.ts +examples/run-example-vfs.ts +``` + +- [ ] **Step 5: Rerun and commit** + +```bash +scripts/dev-shell.sh bash scripts/test-homebrew-patched-launcher.sh +git add scripts/test-homebrew-patched-launcher.sh +git commit -m "Homebrew: Complete the isolated launcher fixture" +``` + +Expected: PASS with no use of the repository's ambient `examples/` tree. + +### Task 2: Give fork-instrument fixtures the explicit ABI 43 mode + +**Files:** + +- Modify: `scripts/build-fork-instrumented-test-fixture.sh` +- Test: `scripts/test-homebrew-inspect-bottle.sh` +- Test: `scripts/test-homebrew-tap-native-sidecars.sh` + +**Interfaces:** + +- Consumes: ABI 43 `kernel_fork(mode: i32) -> i32` +- Produces: structurally valid wasm32 and wasm64 ordinary-fork fixtures + +- [ ] **Step 1: Add a signature assertion to the generated fixture test** + +Require the generated WAT to contain this import and call shape for both +pointer widths: + +```wat +(import "kernel" "kernel_fork" + (func $kernel_fork (param i32) (result i32))) +(drop (call $kernel_fork (i32.const 0))) +``` + +Keep the deliberately malformed zero-argument negative fixture around line +448 of `scripts/test-homebrew-inspect-bottle.sh` unchanged. + +- [ ] **Step 2: Run the focused consumers and observe the structural failure** + +```bash +scripts/dev-shell.sh bash scripts/test-homebrew-inspect-bottle.sh +scripts/dev-shell.sh bash scripts/test-homebrew-tap-native-sidecars.sh +``` + +Expected before the repair: the generated positive fixture fails the ABI 43 +fork import signature check. + +- [ ] **Step 3: Update both WAT templates** + +Apply the exact import and call shown in Step 1 to the wasm32 and wasm64 +branches of `build-fork-instrumented-test-fixture.sh`. Mode `0` remains +ordinary fork; do not turn this fixture into vfork. + +- [ ] **Step 4: Rerun and commit** + +```bash +scripts/dev-shell.sh bash scripts/test-homebrew-inspect-bottle.sh +scripts/dev-shell.sh bash scripts/test-homebrew-tap-native-sidecars.sh +git add scripts/build-fork-instrumented-test-fixture.sh \ + scripts/test-homebrew-inspect-bottle.sh \ + scripts/test-homebrew-tap-native-sidecars.sh +git commit -m "ABI: Give fork fixtures an explicit fork mode" +``` + +Expected: both scripts PASS and the malformed negative fixture is still +rejected. + +### Task 3: Establish the vfork mechanism-readiness gate + +**Files:** + +- Create: `scripts/run-vfork-readiness.sh` +- Create: `docs/measurements/2026-08-10-vfork-readiness.md` +- Modify: `host/test/vfork-lifetime.test.ts` +- Modify: `host/test/vfork-lifecycle-guest.test.ts` +- Modify: `host/test/fork-process-continuation.test.ts` +- Modify: `host/test/fork-borrowed-replay.test.ts` +- Modify: `apps/browser-demos/test/vfork-lifecycle.spec.ts` +- Modify: `apps/browser-demos/test/borrowed-fork-replay.spec.ts` +- Test fixtures: `programs/vfork-lifecycle.c` +- Test fixtures: `programs/vfork-from-thread.c` +- Test fixtures: `programs/vfork-fatal-lifecycle.c` +- Test fixtures: `programs/vfork-external-signal.c` +- Test fixtures: `programs/vfork-posix-state.c` + +**Interfaces:** + +- Consumes: `VforkLifetimeCoordinator`, `BorrowedVforkWorkspace`, + `runWithProcessWorkerQuiescence`, ordinary fork mode `0`, vfork mode `1` +- Produces: `scripts/run-vfork-readiness.sh mechanism|integration`; exact + no-copy, suspension, isolation, rollback, lifecycle, and cross-host gate + +- [ ] **Step 1: Add explicit no-allocation and suspension assertions** + +In the host and browser guest lifecycle tests, set +`maxProcessMemoryBytes` equal to the parent memory's initial byte length and +assert all of these observations: + +```ts +expect(childMemory).toBe(parentMemory); +expect(fullProcessMemoryCreations).toBe(0); +expect(events).toContain("child-entered"); +expect(events).not.toContain("parent-resumed-before-release"); +expect(events).toContain("parent-resumed-after-release"); +``` + +Cover a main-thread caller, a pthread caller with a runnable sibling, repeated +calls, rejected overlap and nesting, a side module, and an ordinary fork +control that creates a distinct copied memory. + +- [ ] **Step 2: Add exact terminal-path assertions** + +Parameterize successful exec, failed exec followed by `_exit`, direct `_exit`, +cooperative signal death, trap, Worker crash before memory access, and forced +external kill after memory access. Assert one settlement and no parent wedge. +For the forced browser kill, require status 139, the containment diagnostic, +and absence of `UNSAFE_PARENT_RESUMED`; do not assert normal parent return. + +- [ ] **Step 3: Add private-control-state and POSIX-state assertions** + +Verify the child's syscall channel, replay prefix, imported mutable globals, +loader state, continuation controller, and scratch workspace are distinct. +Use `vfork-posix-state` to verify shared OFD offset semantics, independent fd +table changes, cwd, signal dispositions and masks, pgid/sid, parentage, +zombie/wait status, and exact reaping. + +- [ ] **Step 4: Run focused tests and retain every smallest failure** + +```bash +scripts/dev-shell.sh bash scripts/build-programs.sh +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run \ + test/vfork-lifetime.test.ts \ + test/vfork-workspace.test.ts \ + test/vfork-lifecycle-guest.test.ts \ + test/fork-process-continuation.test.ts \ + test/fork-borrowed-replay.test.ts \ + test/fork-from-dlopen-side-module-e2e.test.ts \ + test/dylink-fork-archive.test.ts' +scripts/dev-shell.sh bash -lc \ + 'cd apps/browser-demos && npx playwright test \ + test/vfork-lifecycle.spec.ts \ + test/borrowed-fork-replay.spec.ts \ + --project=chromium --project=firefox --project=webkit' +``` + +Expected: every named test executes; no missing-fixture skip is accepted. +Apply the Gate Outcome Rule before continuing if any invariant fails. + +- [ ] **Step 5: Implement the repeatable gate wrapper** + +The script accepts exactly `mechanism` or `integration`, rejects other +arguments with exit 2, asserts it is running from the repository root in the +dev shell, builds programs, runs the focused Vitest list, runs fork-instrument +tests on the host Rust target, and runs all three Playwright projects. The +`integration` mode additionally includes the credential, prepared-target, +secure-exec, and nosuid tests introduced later. + +```bash +case "${1:-}" in + mechanism) integration=false ;; + integration) integration=true ;; + *) echo "usage: scripts/run-vfork-readiness.sh mechanism|integration" >&2; + exit 2 ;; +esac +test -n "${IN_NIX_SHELL:-}" || { + echo "run through scripts/dev-shell.sh" >&2 + exit 2 +} +``` + +- [ ] **Step 6: Record only observed evidence** + +In the measurement document, record the exact commit, kernel and guest +artifact SHA-256 digests, Node version, browser versions, each command and +status, no-copy ceiling, forced-kill containment result, and any remaining +gap. Use `NOT RUN` for later integration and release columns. + +- [ ] **Step 7: Run the wrapper and commit the gate** + +```bash +scripts/dev-shell.sh bash scripts/run-vfork-readiness.sh mechanism +git add scripts/run-vfork-readiness.sh \ + docs/measurements/2026-08-10-vfork-readiness.md \ + host/test/vfork-lifetime.test.ts \ + host/test/vfork-lifecycle-guest.test.ts \ + host/test/fork-process-continuation.test.ts \ + host/test/fork-borrowed-replay.test.ts \ + apps/browser-demos/test/vfork-lifecycle.spec.ts \ + apps/browser-demos/test/borrowed-fork-replay.spec.ts \ + programs/vfork-lifecycle.c programs/vfork-from-thread.c \ + programs/vfork-fatal-lifecycle.c programs/vfork-external-signal.c \ + programs/vfork-posix-state.c +git commit -m "Tests: Make vfork readiness an explicit gate" +``` + +Expected: mechanism gate PASS, or a distinct, tested repair commit exists and +the rerun passes. The measurement file must still call external browser kill +partial if it uses containment. + +### Task 4: Forward-port authenticated immutable bottle destinations + +**Files:** + +- Modify: `host/src/homebrew-bottle-relocation.ts` +- Modify: `host/src/homebrew-vfs-builder.ts` +- Modify: `host/src/homebrew-runtime-layer-consumer.ts` +- Create: `host/test/homebrew-bottle-relocation.test.ts` +- Modify: `host/test/homebrew-vfs-builder.test.ts` + +**Interfaces:** + +- Consumes: authenticated receipt destination and source commit +- Produces: immutable `destinationPrefix` that is authoritative for bottle + relocation and runtime activation + +- [ ] **Step 1: Add failing prefix-authority cases** + +Test `/home/linuxbrew/.linuxbrew` and `/opt/kandelo/homebrew`, plus a mismatch +between receipt destination and an ambient runtime default. The mismatch must +fail before publication; it must not silently relocate to the default. + +- [ ] **Step 2: Run the focused tests to prove current behavior is wrong** + +```bash +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run \ + test/homebrew-bottle-relocation.test.ts \ + test/homebrew-vfs-builder.test.ts \ + test/homebrew-runtime-support-materializer.test.ts' +``` + +Expected before the forward port: at least the non-default authenticated +destination case fails. + +- [ ] **Step 3: Make authenticated destination data authoritative** + +Thread one normalized `destinationPrefix` from verified receipt parsing into +relocation, activation, sidecar identity, and runtime-layer validation. +Reject empty, relative, dot-segment, NUL-containing, or inconsistent values. +Never consult a Homebrew installation default after authentication. + +- [ ] **Step 4: Rerun and commit with source authorship** + +```bash +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run \ + test/homebrew-bottle-relocation.test.ts \ + test/homebrew-vfs-builder.test.ts \ + test/homebrew-runtime-support-materializer.test.ts' +git add host/src/homebrew-bottle-relocation.ts \ + host/src/homebrew-vfs-builder.ts \ + host/src/homebrew-runtime-layer-consumer.ts \ + host/test/homebrew-bottle-relocation.test.ts \ + host/test/homebrew-vfs-builder.test.ts \ + host/test/homebrew-runtime-support-materializer.test.ts +git commit --author='Brandon Payton ' \ + -m "Homebrew: Honor authenticated bottle destinations" +``` + +Expected: both historical prefixes pass and ambient prefix drift fails closed. + +### Task 5: Decouple generic VFS materialization from Homebrew + +**Files:** + +- Create: `host/src/vfs/materialization-plan.ts` +- Create: `host/src/homebrew-deferred-tree-adapter.ts` +- Modify: `host/src/vfs/memory-fs.ts` +- Modify: `host/src/homebrew-vfs-builder.ts` +- Modify: `host/src/homebrew-runtime-layer-consumer.ts` +- Modify: `host/src/homebrew-vfs-formula-layer.ts` +- Modify: `host/test/lazy-tree.test.ts` +- Modify: `host/test/homebrew-vfs-builder.test.ts` +- Modify: `host/test/homebrew-runtime-support-materializer.test.ts` +- Modify: `host/test/node-lazy-archive-runtime.test.ts` +- Modify: `apps/browser-demos/test/lazy-archive-runtime.spec.ts` +- Modify: `apps/browser-demos/test/browser-package-layer.spec.ts` + +**Interfaces:** + +- Consumes: authoritative `destinationPrefix` from Task 4 +- Produces: `LazyTreeMaterializationPlan`, `tar-gzip-v1`, and + `adaptHomebrewDeferredTree(tree): AdaptedHomebrewDeferredTree` + +- [ ] **Step 1: Add closed-schema parser tests** + +Construct a generic TAR fixture containing a directory, regular file, +symbolic link, and hard link. Test eager and lazy materialization with an +exact byte replacement. Reject unknown keys, duplicate recipes, duplicate +transforms, odd/non-hex byte strings, unbounded replacement counts, missing +source inventory entries, input/output digest drift, unsafe paths, and a +replacement whose byte length differs. + +The success fixture uses this complete shape: + +```ts +const plan: LazyTreeMaterializationPlan = { + schema: 1, + kind: "archive-byte-transforms-v1", + assertions: [{ sourcePath: "bin/tool", bytesHex: "2f6f6c642f" }], + recipes: [{ + id: "prefix", + replacements: [{ matchHex: "2f6f6c642f", replacementHex: "2f6e65772f" }], + rejectHex: ["2f666f7262696464656e2f"], + }], + transforms: [{ + sourcePath: "bin/tool", + recipe: "prefix", + input: { + sha256: "0da8bba3f971e84a1cb42935a03959b06879abcffc01c472d41030227bb19cf7", + bytes: 5, + }, + output: { + sha256: "92a2fb6a1bcf1f8af0366d946016ee2601311aae9106f6eccaf905b1bfc6ab04", + bytes: 5, + }, + }], +}; +``` + +- [ ] **Step 2: Run the generic tests and observe missing interfaces** + +```bash +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run test/lazy-tree.test.ts' +``` + +Expected: FAIL because `materialization-plan.ts`, `tar-gzip-v1`, or the +generic transform contract is absent. + +- [ ] **Step 3: Implement the bounded generic plan** + +Implement and export these exact functions: + +```ts +export function validateLazyTreeMaterializationPlan( + value: unknown, + inventory: LazyTreeMaterializationSourceInventory, +): LazyTreeMaterializationPlan; + +export function encodeMaterializationBytes(bytes: Uint8Array): string; +export function decodeMaterializationBytes(hex: string): Uint8Array; + +export function applyLazyTreeByteTransformRecipe( + source: Uint8Array, + recipe: LazyTreeByteTransformRecipe, +): Uint8Array; +``` + +Bound entries, recipes, replacements, assertions, byte-pattern lengths, and +total decoded plan bytes with named constants. Apply transforms from exact +source bytes, verify input before replacement and output afterward, and use +the same function for eager and lazy paths. Keep callbacks, regexes, scripts, +receipt fields, Formula names, Cellar paths, and keg terms out of this module +and `MemoryFileSystem`. + +- [ ] **Step 4: Implement the Homebrew adapter** + +The adapter owns receipt, changed-file, prefix, keg, canonical-hard-link, and +relocation validation and returns only: + +```ts +export interface AdaptedHomebrewDeferredTree { + decoder: LazyTreeDecoder; + source?: LazyTreeSourceInventory; + materialization?: LazyTreeMaterializationPlan; + entries: LazyTreeRegistrationEntry[]; +} + +export function adaptHomebrewDeferredTree( + tree: HomebrewDeferredTreeDescriptor, +): AdaptedHomebrewDeferredTree; +``` + +Map the former `homebrew-bottle-tar-gzip-v1` decoder to `tar-gzip-v1` only +after validating the complete Homebrew descriptor. + +- [ ] **Step 5: Advance the runtime-layer schema and fail closed** + +Emit schema 6 for the relocation-plan contract. Read schema 4 ZIP artifacts. +For schema 5, accept only artifacts that need no receipt relocation; reject a +schema-5 bottle that has relocation data. If current HEAD already uses 6 for a +different reviewed meaning, select 7 consistently and update this plan's task +notes before editing. + +- [ ] **Step 6: Prove cancellation, rollback, and atomic publication** + +Add tests that abort fetch, replace a lazy generation, exhaust VFS capacity, +fail one member of an atomic tree, and mutate source identity before commit. +Assert no destination entry becomes visible and the prior generation remains +intact. Verify restore/rebase preserves the generic plan and does not restore +Homebrew vocabulary into MemoryFS. + +- [ ] **Step 7: Run Node and browser coverage** + +```bash +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run \ + test/lazy-tree.test.ts \ + test/homebrew-vfs-builder.test.ts \ + test/homebrew-runtime-support-materializer.test.ts \ + test/node-lazy-archive-runtime.test.ts' +scripts/dev-shell.sh bash -lc \ + 'cd apps/browser-demos && npx playwright test \ + test/lazy-archive-runtime.spec.ts \ + test/browser-package-layer.spec.ts \ + --project=chromium --project=firefox --project=webkit' +``` + +Expected: generic and adapted Homebrew eager/lazy results are byte-identical, +and all malformed or partial states fail before publication. + +- [ ] **Step 8: Commit with source authorship** + +```bash +git add host/src/vfs/materialization-plan.ts \ + host/src/homebrew-deferred-tree-adapter.ts \ + host/src/vfs/memory-fs.ts \ + host/src/homebrew-vfs-builder.ts \ + host/src/homebrew-runtime-layer-consumer.ts \ + host/src/homebrew-vfs-formula-layer.ts \ + host/test/lazy-tree.test.ts \ + host/test/homebrew-vfs-builder.test.ts \ + host/test/homebrew-runtime-support-materializer.test.ts \ + host/test/node-lazy-archive-runtime.test.ts \ + apps/browser-demos/test/lazy-archive-runtime.spec.ts \ + apps/browser-demos/test/browser-package-layer.spec.ts +git commit --author='Brandon Payton ' \ + -m "VFS: Separate archive materialization from Homebrew policy" +``` + +### Task 6: Make set-ID execution an explicit mount capability + +**Files:** + +- Modify: `host/src/vfs/types.ts` +- Modify: `host/src/vfs/default-mounts.ts` +- Modify: `host/src/vfs/index.ts` +- Modify: `host/src/vfs/memory-fs.ts` +- Modify: `host/src/vfs/host-fs.ts` +- Modify: `host/src/vfs/sharedfs-vendor.ts` +- Modify: `host/src/platform/node.ts` +- Modify: `host/src/kernel-worker.ts` +- Modify: `crates/shared/src/lib.rs` +- Modify: `crates/kernel/src/syscalls.rs` +- Modify: `host/test/vfs/default-mounts.test.ts` +- Modify: `host/test/vfs.test.ts` +- Create: `host/test/nosuid-exec.test.ts` +- Create: `apps/browser-demos/test/nosuid-exec.spec.ts` + +**Interfaces:** + +- Consumes: generic materialized trees from Task 5 +- Produces: `MountSetIdCapability`; authoritative `ST_NOSUID`; an internal + immutable-handle-generation capability required by prepared targets + +- [ ] **Step 1: Write default-deny mount tests** + +Test that omitted capability, every writable scratch backend, every backend +without stable executable identity, and unknown mounts report `ST_NOSUID`. +Test that malformed `trusted-root-product` requests are rejected during mount +construction, not downgraded silently. + +```ts +expect(statfs.flags & ST_NOSUID).toBe(ST_NOSUID); +expect(() => resolveMountSetIdCapability({ + backend: immutableProductBackend, + readonly: true, + setIdCapability: { + kind: "trusted-root-product", + guestWritable: true, + stableExecutableIdentity: true, + } as unknown as MountSetIdCapability, +})).toThrow(/trusted root product mount must not be guest-writable/); + +expect(() => resolveMountSetIdCapability({ + backend: immutableProductBackend, + readonly: false, + setIdCapability: { + kind: "trusted-root-product", + guestWritable: false, + stableExecutableIdentity: true, + }, +})).toThrow(/trusted root product mount must be read-only/); +``` + +- [ ] **Step 2: Run the focused tests and observe the missing policy** + +```bash +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run \ + test/vfs/default-mounts.test.ts test/vfs.test.ts \ + test/nosuid-exec.test.ts' +``` + +Expected: FAIL because mount configuration does not carry or enforce a +set-ID capability. + +- [ ] **Step 3: Add the capability and backend eligibility contract** + +Add the `MountSetIdCapability` and `MountConfig` fields from the interface map. +Add this optional backend-owned capability: + +```ts +export interface FileSystemBackend { + readonly executableIdentityKind?: "immutable-handle-generation"; +} + +export function resolveMountSetIdCapability( + config: Pick, +): MountSetIdCapability; +``` + +Only the internal immutable product backend supplies that literal. Resolution +also requires `readonly === true`; the requested capability's literal +`guestWritable: false` cannot override a writable mount. Its existing open +handle retains one exact inode generation through unlink or rename. Mutable, +host, SharedFS, and user-provided backends omit it and cannot be mounted +`trusted-root-product`; configuration cannot manufacture it. +Prepared targets retain the exact OFD/host handle and the host rereads and +compares bytes immediately before commit. + +- [ ] **Step 4: Derive `ST_NOSUID` from the mounted backend** + +In `VirtualPlatformIO.statfs`, OR `ST_NOSUID` when the selected mount is +omitted, unknown, writable, identity-unstable, or explicitly `nosuid`. Keep +Node and browser on the same shared implementation. Add the ABI constant to +shared/generated bindings only if it is not already present. + +- [ ] **Step 5: Test execution policy without granting credentials yet** + +Expose the resolved mount capability and lease identity to the kernel's +future target record. Until Task 10 commits credentials, test the decision +helper directly: set-ID bits are ignored on nosuid and preserved as a proposed +transition only on a valid trusted mount. + +- [ ] **Step 6: Run Node and browser tests** + +```bash +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run \ + test/vfs/default-mounts.test.ts test/vfs.test.ts \ + test/nosuid-exec.test.ts' +scripts/dev-shell.sh bash -lc \ + 'cd apps/browser-demos && npx playwright test \ + test/nosuid-exec.spec.ts \ + --project=chromium --project=firefox --project=webkit' +``` + +Expected: every default and unsafe mount is nosuid on every host; the one +synthetic trusted mount is distinguishable but grants no credentials before +the target-aware commit exists. + +- [ ] **Step 7: Commit** + +```bash +git add host/src/vfs/types.ts host/src/vfs/default-mounts.ts \ + host/src/vfs/index.ts host/src/vfs/memory-fs.ts \ + host/src/vfs/host-fs.ts host/src/vfs/sharedfs-vendor.ts \ + host/src/platform/node.ts host/src/kernel-worker.ts \ + crates/shared/src/lib.rs crates/kernel/src/syscalls.rs \ + host/test/vfs/default-mounts.test.ts host/test/vfs.test.ts \ + host/test/nosuid-exec.test.ts \ + apps/browser-demos/test/nosuid-exec.spec.ts +git commit -m "VFS: Default executable mounts to nosuid" +``` + +### Task 7: Publish privileged programs as independent product inodes + +**Files:** + +- Create: `host/src/vfs/privileged-projection.ts` +- Modify: `host/src/homebrew-vfs-builder.ts` +- Modify: `host/src/homebrew-vfs-planner.ts` +- Modify: `host/src/homebrew-runtime-layer-consumer.ts` +- Modify: `images/vfs/scripts/build-homebrew-vfs-image.ts` +- Modify: `host/test/homebrew-vfs-builder.test.ts` +- Modify: `host/test/homebrew-vfs-planner.test.ts` +- Create: `host/test/privileged-projection.test.ts` +- Modify: `apps/browser-demos/test/browser-package-layer.spec.ts` + +**Interfaces:** + +- Consumes: generic archive inventory from Task 5 and trusted mount + capability from Task 6 +- Produces: `PrivilegedProgramProjection`; unique, root-owned regular inodes + at `/usr/bin/login`, `/usr/bin/sudo-lite`, and `/usr/bin/sudo` + +- [ ] **Step 1: Define and test the closed projection record** + +Use this exact shape: + +```ts +export interface PrivilegedProgramProjection { + schema: 1; + formula: string; + bottleSha256: string; + sourcePath: string; + destinationPath: string; + uid: 0; + gid: 0; + mode: number; + mountPoint: string; + artifactValidationSha256: string; +} +``` + +Require `mode` to be `0o4755`, destination to be one of the reviewed product +paths, and the mount to be `trusted-root-product`. Reject unknown keys and +duplicate destinations. + +- [ ] **Step 2: Add alias and policy failure tests** + +Reject a source symlink, a projected symlink, a preserved hard link, a shared +inode with the bottle tree, a writable alias, non-root owner, writable parent, +unstable backend, unrecognized mount, digest mismatch, and source member +absent from the complete inventory. + +- [ ] **Step 3: Run the tests and observe missing projection support** + +```bash +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run \ + test/privileged-projection.test.ts \ + test/homebrew-vfs-builder.test.ts \ + test/homebrew-vfs-planner.test.ts' +``` + +Expected: FAIL because the product cannot yet create a separate privileged +tree. + +- [ ] **Step 4: Materialize independent regular files atomically** + +Resolve a bottle hard link to its canonical regular source, authenticate its +bytes, then create a fresh destination inode with uid 0, gid 0, and mode +`04755`. Register the ordinary bottle tree and privileged tree separately. +Before publication compare `(dev, ino, generation)` for every projection +against all writable bottle inodes; abort the whole projection group on any +collision. + +- [ ] **Step 5: Run Node and browser product tests** + +```bash +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run \ + test/privileged-projection.test.ts \ + test/homebrew-vfs-builder.test.ts \ + test/homebrew-vfs-planner.test.ts \ + test/homebrew-runtime-support-materializer.test.ts' +scripts/dev-shell.sh bash -lc \ + 'cd apps/browser-demos && npx playwright test \ + test/browser-package-layer.spec.ts \ + --project=chromium --project=firefox --project=webkit' +``` + +Expected: product inodes are regular, unique, root-owned, non-writable, and +stable while the Homebrew prefix remains writable and nosuid. + +- [ ] **Step 6: Commit** + +```bash +git add host/src/vfs/privileged-projection.ts \ + host/src/homebrew-vfs-builder.ts \ + host/src/homebrew-vfs-planner.ts \ + host/src/homebrew-runtime-layer-consumer.ts \ + images/vfs/scripts/build-homebrew-vfs-image.ts \ + host/test/homebrew-vfs-builder.test.ts \ + host/test/homebrew-vfs-planner.test.ts \ + host/test/privileged-projection.test.ts \ + apps/browser-demos/test/browser-package-layer.spec.ts +git commit -m "Homebrew: Isolate privileged product programs" +``` + +### Task 8: Replace simulated identities with one POSIX credential record + +**Files:** + +- Create: `crates/kernel/src/credentials.rs` +- Modify: `crates/kernel/src/lib.rs` +- Modify: `crates/kernel/src/process.rs` +- Modify: `crates/kernel/src/process_table.rs` +- Modify: `crates/kernel/src/syscalls.rs` +- Modify: `crates/kernel/src/signal.rs` +- Modify: `crates/kernel/src/procfs.rs` +- Modify: `crates/kernel/src/terminal.rs` +- Modify: `crates/kernel/src/pty.rs` +- Modify: `crates/kernel/src/wasm_api.rs` +- Test: inline Rust tests in the modified modules + +**Interfaces:** + +- Consumes: `Credentials` shape and `NGROUPS_MAX = 32` from the interface map +- Produces: authoritative process-wide POSIX IDs and group membership checks + +- [ ] **Step 1: Write a transition table as failing Rust tests** + +Cover root and non-root `setuid`, `seteuid`, `setresuid`, and UID value +`u32::MAX` as the unchanged sentinel. Mirror the table for GIDs. Include: + +```rust +pub const ID_UNCHANGED: u32 = u32::MAX; + +assert_eq!( + nonroot.setresuid(ID_UNCHANGED, other, ID_UNCHANGED), + Err(Errno::EPERM), +); +assert_eq!( + nonroot.setresuid(ID_UNCHANGED, saved, ID_UNCHANGED), + Ok(()), +); +assert_eq!(root.setresuid(user, user, user), Ok(())); +assert_eq!((root.ruid, root.euid, root.suid), (user, user, user)); +``` + +Add tests that `setuid` by root sets all three IDs, while an unprivileged +caller may only select its real or saved ID for effective identity. Reject +partial mutation on every error. + +- [ ] **Step 2: Add group-list and access-decision tests** + +Test ordered supplementary groups, empty groups, exactly 32 groups, 33 groups, +root-only mutation, and effective-primary or supplementary group matching for +file access, signal permission, sticky directories, ownership, PTY access, +and process inspection. + +- [ ] **Step 3: Run kernel tests and observe simulated behavior** + +```bash +scripts/dev-shell.sh bash -lc \ + 'host_target=$(rustc -vV | sed -n "s/^host: //p"); \ + cargo test -p wasm-posix-kernel --target "$host_target" \ + credentials -- --nocapture' +``` + +Expected: FAIL because saved IDs and supplementary groups do not exist or +current syscalls are simulated aliases. + +- [ ] **Step 4: Implement `Credentials` and process accessors** + +Move every process identity field into the single record. Initialize root as +all zeroes and an explicitly configured UID/GID as all three values on its +side. Provide these accessors and membership helper: + +```rust +pub fn real_uid(&self) -> u32; +pub fn effective_uid(&self) -> u32; +pub fn saved_uid(&self) -> u32; +pub fn real_gid(&self) -> u32; +pub fn effective_gid(&self) -> u32; +pub fn saved_gid(&self) -> u32; +pub fn is_member_of_group(&self, gid: u32) -> bool; +pub fn setuid(&mut self, uid: u32) -> Result<(), Errno>; +pub fn seteuid(&mut self, uid: u32) -> Result<(), Errno>; +pub fn setresuid(&mut self, ruid: u32, euid: u32, suid: u32) + -> Result<(), Errno>; +pub fn setgid(&mut self, gid: u32) -> Result<(), Errno>; +pub fn setegid(&mut self, gid: u32) -> Result<(), Errno>; +pub fn setresgid(&mut self, rgid: u32, egid: u32, sgid: u32) + -> Result<(), Errno>; +``` + +Replace all direct `proc.uid`, `proc.euid`, `proc.gid`, and `proc.egid` +consumers. Do not introduce per-thread or host-side credential caches. + +- [ ] **Step 5: Implement atomic syscall semantics** + +Have transition methods compute a complete candidate record, validate it, +then assign once. `getgroups(0)` returns the count, insufficient nonzero +capacity returns `EINVAL`, and only effective uid 0 may call `setgroups`. +Credential-changing syscalls remain process-wide under the kernel entry gate. + +- [ ] **Step 6: Run kernel and process behavior tests** + +```bash +scripts/dev-shell.sh bash -lc \ + 'host_target=$(rustc -vV | sed -n "s/^host: //p"); \ + cargo test -p wasm-posix-kernel --target "$host_target" \ + credentials -- --nocapture; \ + cargo test -p wasm-posix-kernel --target "$host_target" \ + permission -- --nocapture; \ + cargo test -p wasm-posix-kernel --target "$host_target" \ + signal_permission -- --nocapture' +``` + +Expected: all transition, membership, permission, and atomicity cases PASS. + +- [ ] **Step 7: Commit with source authorship** + +```bash +git add crates/kernel/src/credentials.rs crates/kernel/src/lib.rs \ + crates/kernel/src/process.rs crates/kernel/src/process_table.rs \ + crates/kernel/src/syscalls.rs crates/kernel/src/signal.rs \ + crates/kernel/src/procfs.rs crates/kernel/src/terminal.rs \ + crates/kernel/src/pty.rs crates/kernel/src/wasm_api.rs +git commit --author='Brandon Payton ' \ + -m "POSIX: Make process credentials authoritative" +``` + +### Task 9: Serialize credentials and marshal complete group lists + +**Files:** + +- Modify: `crates/kernel/src/fork.rs` +- Modify: `crates/kernel/src/channel_scratch.rs` +- Modify: `crates/kernel/src/wasm_api.rs` +- Modify: `crates/shared/src/host_abi.rs` +- Modify: `crates/shared/src/channel_scalar.rs` +- Modify: `crates/shared/src/lib.rs` +- Modify: `host/src/kernel-worker.ts` +- Modify: `host/src/channel-scalar-contract.ts` +- Modify: `host/test/kernel-worker-copyback.test.ts` +- Modify: `host/test/kernel-scratch-transfer-boundaries.test.ts` +- Modify: `host/test/host-process-pointer-width.test.ts` +- Modify: `abi/snapshot.json` +- Modify: `host/src/generated/abi.ts` + +**Interfaces:** + +- Consumes: `Credentials`, `NGROUPS_MAX`, and process-wide atomicity from + Task 8 +- Produces: fork state version 15; bounded ABI descriptors for `getgroups` + and `setgroups`; wasm32/wasm64 parity + +- [ ] **Step 1: Add exact fork-state rejection tests** + +Round-trip real/effective/saved UID and GID, 0 and 32 supplementary groups, +and `secure_exec`. Reject version 14, version 16, count 33, truncation at each +new field, integer overflow, and trailing bytes where the current exact parser +rejects them. Assert a vfork child mutation changes only the child record. + +- [ ] **Step 2: Add channel transfer tests for both pointer widths** + +For `getgroups`, cover a zero-size query with no destination, nonzero null +destination (`EFAULT`), insufficient capacity (`EINVAL`), exact capacity, +unused trailing entries preserved, and a malicious count above 32. For +`setgroups`, lend exactly `count * 4` input bytes and reject multiplication or +scratch-capacity overflow before allocation. + +```ts +expect(getgroups({ size: 0, pointer: 0 })).toEqual({ count: 3 }); +expect(getgroups({ size: 2, pointer: valid })).toEqual({ errno: EINVAL }); +expect(getgroups({ size: 3, pointer: valid }).tail).toEqual(originalTail); +expect(setgroups({ size: 33, pointer: valid })).toEqual({ errno: EINVAL }); +``` + +- [ ] **Step 3: Run focused Rust and host tests** + +```bash +scripts/dev-shell.sh bash -lc \ + 'host_target=$(rustc -vV | sed -n "s/^host: //p"); \ + cargo test -p wasm-posix-kernel --target "$host_target" \ + fork -- --nocapture; \ + cargo test -p wasm-posix-shared --target "$host_target" \ + getgroups -- --nocapture' +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run \ + test/kernel-worker-copyback.test.ts \ + test/kernel-scratch-transfer-boundaries.test.ts \ + test/host-process-pointer-width.test.ts' +``` + +Expected before implementation: new state and multi-group cases FAIL. + +- [ ] **Step 4: Advance the exact fork format** + +Set `FORK_VERSION` to 15. Serialize IDs in this order: + +```text +ruid, euid, suid, rgid, egid, sgid, +supplementary_group_count, +supplementary_group[0] through +supplementary_group[supplementary_group_count - 1] in stored order, +secure_exec +``` + +Deserialize into local values, validate the complete buffer, then install one +`Credentials` value. Apply the same format to fork and exec-state transport. +Never serialize prepared-target tokens or vfork borrowed-workspace state. + +- [ ] **Step 5: Replace the special one-group host handler** + +Describe both syscalls in the existing shared `SyscallArgDescriptor` table. +Remove `handleGetgroups` and its interception from `kernel-worker.ts`; use the +normal bounded scratch copy-in/copy-out path. Copy back only the returned +group count so unused guest entries retain their original bytes. + +- [ ] **Step 6: Regenerate and check the ABI** + +```bash +scripts/dev-shell.sh bash scripts/check-abi-version.sh update +scripts/dev-shell.sh bash scripts/check-abi-version.sh +``` + +- [ ] **Step 7: Rerun all focused tests and commit** + +```bash +scripts/dev-shell.sh bash -lc \ + 'host_target=$(rustc -vV | sed -n "s/^host: //p"); \ + cargo test -p wasm-posix-kernel --target "$host_target" fork; \ + cargo test -p wasm-posix-shared --target "$host_target" getgroups' +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run \ + test/kernel-worker-copyback.test.ts \ + test/kernel-scratch-transfer-boundaries.test.ts \ + test/host-process-pointer-width.test.ts' +git add crates/kernel/src/fork.rs crates/kernel/src/channel_scratch.rs \ + crates/kernel/src/wasm_api.rs crates/shared/src/host_abi.rs \ + crates/shared/src/channel_scalar.rs crates/shared/src/lib.rs \ + host/src/kernel-worker.ts host/src/channel-scalar-contract.ts \ + host/test/kernel-worker-copyback.test.ts \ + host/test/kernel-scratch-transfer-boundaries.test.ts \ + host/test/host-process-pointer-width.test.ts \ + abi/snapshot.json libc/glue/abi_constants.h \ + libc/musl-overlay/include/bits/kandelo_limits.h \ + libc/musl-overlay/include/bits/kandelo_process_layouts.h \ + libc/musl-overlay/include/bits/kandelo_channel_scalars.h \ + libc/musl-overlay/include/bits/kandelo_thread_syscalls.h \ + libc/musl-overlay/src/process/wasm32posix/spawn_contract.h \ + host/src/generated/abi.ts +git commit --author='Brandon Payton ' \ + -m "ABI: Preserve complete credentials across process images" +``` + +### Task 10: Make exact prepared targets the kernel exec authority + +**Files:** + +- Create: `crates/kernel/src/exec_target.rs` +- Modify: `crates/kernel/src/lib.rs` +- Modify: `crates/kernel/src/process.rs` +- Modify: `crates/kernel/src/ofd.rs` +- Modify: `crates/kernel/src/syscalls.rs` +- Modify: `crates/kernel/src/wasm_api.rs` +- Modify: `crates/shared/src/lib.rs` +- Modify: `tools/xtask/src/dump_abi.rs` +- Modify: `host/src/kernel-worker.ts` +- Modify: `host/src/worker-main.ts` +- Modify: `host/src/node-kernel-worker-entry.ts` +- Modify: `host/src/browser-kernel-worker-entry.ts` +- Modify: `host/src/node-kernel-protocol.ts` +- Modify: `host/src/browser-kernel-protocol.ts` +- Create: `host/test/prepared-exec-target.test.ts` +- Modify: `host/test/kernel-exec-entry.test.ts` +- Modify: `host/test/kernel-entry-context-audit.test.ts` +- Modify: `host/test/kernel-scratch-contract.test.ts` +- Modify: `abi/snapshot.json` +- Modify: `host/src/generated/abi.ts` + +**Interfaces:** + +- Consumes: exact OFDs, stable executable leases, mount capabilities, and + credentials from Tasks 6, 8, and 9 +- Produces: `PreparedExecLedger` and every `kernel_*exec_target*` export from + the interface map; one atomic target-aware commit path + +- [ ] **Step 1: Add token lifetime and authority tests** + +Test positive nonzero monotonic tokens, exhaustion before wrap, wrong owner, +wrong caller TID, wrong exec generation, cross-process use, cancellation, +double cancel, double commit, stale token after a competing commit, and ledger +drain on exit, signal death, trap, failed vfork, containment, and host teardown. +Every failed operation returns a specific errno and releases exactly one +retained OFD/lease. + +- [ ] **Step 2: Add exact-object tests for path and fd execution** + +Prepare `execve`, pathname `execveat`, and `execveat(AT_EMPTY_PATH)` targets. +After preparation, close the guest fd, rename the path, unlink the file, and +replace the old pathname. Read and commit must still refer to the retained +original object. Reads at explicit offsets must not change the OFD cursor. + +- [ ] **Step 3: Add transaction and set-ID failure tests** + +Cover missing, directory, non-regular, non-executable, nosuid, unstable +backend, mutation before revalidation, stale mode/owner, `ETXTBSY`, `ENOTSUP`, +compile rejection before commit, address-space preparation failure, and a +race between two pthread exec calls. Assert credentials, CLOEXEC descriptors, +signal state, argv/environment, and exec generation remain unchanged before a +successful commit. + +- [ ] **Step 4: Run focused tests and observe missing exports** + +```bash +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run \ + test/prepared-exec-target.test.ts \ + test/kernel-exec-entry.test.ts \ + test/kernel-entry-context-audit.test.ts \ + test/kernel-scratch-contract.test.ts' +``` + +Expected: FAIL because target preparation, reading, cancellation, and commit +exports do not exist. + +- [ ] **Step 5: Implement the ledger and exact OFD lease** + +Use focused internal types: + +```rust +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PreparedExecOwner { + Process { pid: u32, caller_tid: u32, generation: u64 }, + Spawn { parent_pid: u32, child_pid: u32, launch: u64 }, +} + +pub struct PreparedExecTarget { + token: u32, + owner: PreparedExecOwner, + ofd_ref: OpenFileDescRef, + ofd_id: OfdId, + file_id: Option, + stat: WasmStat, + statfs: WasmStatfs, + diagnostic_path: Vec, +} +``` + +`PreparedExecLedger` owns the extra OFD reference and monotonically allocates +tokens. Path preparation opens an internal read-only OFD. Empty-path +preparation retains the exact existing OFD. Commit/cancel removes the ledger +entry before releasing resources so a reentrant failure cannot consume it +twice. + +- [ ] **Step 6: Implement read, revalidation, and proposed credentials** + +Read through the retained handle with explicit offsets. Revalidate file ID, +mode, uid/gid, mount ID, mount capability, source identity, and exact bytes or +pinned immutable generation. Return `ETXTBSY` for detected mutation and +`ENOTSUP` when stable identity cannot be proven for a credential-bearing +target. Ignore set-ID bits on scripts and nosuid; for the final binary compute +new effective and saved IDs in a local `Credentials` candidate. + +- [ ] **Step 7: Replace targetless kernel commit** + +Implement the seven exports from the interface map. `kernel_exec_commit` +validates owner and generation, consumes the token on success or failure, +then atomically closes CLOEXEC descriptors, resets exec-sensitive state, +installs credentials and `secure_exec`, and increments `exec_generation`. +`kernel_spawn_exec_commit` calls the same internal validator without inventing +a child caller TID. + +Remove public `kernel_exec_prepare`, `kernel_exec_setup`, and +`kernel_exec_setup_for_thread`. Remove path-only `HostIO::host_exec`; no +compatibility fallback remains. + +- [ ] **Step 8: Regenerate ABI artifacts and run Rust/host tests** + +```bash +scripts/dev-shell.sh bash scripts/check-abi-version.sh update +scripts/dev-shell.sh bash scripts/check-abi-version.sh +scripts/dev-shell.sh bash -lc \ + 'host_target=$(rustc -vV | sed -n "s/^host: //p"); \ + cargo test -p wasm-posix-kernel --target "$host_target" \ + exec_target -- --nocapture' +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run \ + test/prepared-exec-target.test.ts \ + test/kernel-exec-entry.test.ts \ + test/kernel-entry-context-audit.test.ts \ + test/kernel-scratch-contract.test.ts' +``` + +Expected: exact-object, token, set-ID proposal, cleanup, and race cases PASS; +ABI snapshot contains only the target-aware interfaces. + +- [ ] **Step 9: Commit** + +```bash +git add crates/kernel/src/exec_target.rs crates/kernel/src/lib.rs \ + crates/kernel/src/process.rs crates/kernel/src/ofd.rs \ + crates/kernel/src/syscalls.rs crates/kernel/src/wasm_api.rs \ + crates/shared/src/lib.rs tools/xtask/src/dump_abi.rs \ + host/src/kernel-worker.ts host/src/worker-main.ts \ + host/src/node-kernel-worker-entry.ts \ + host/src/browser-kernel-worker-entry.ts \ + host/src/node-kernel-protocol.ts host/src/browser-kernel-protocol.ts \ + host/test/prepared-exec-target.test.ts \ + host/test/kernel-exec-entry.test.ts \ + host/test/kernel-entry-context-audit.test.ts \ + host/test/kernel-scratch-contract.test.ts \ + abi/snapshot.json libc/glue/abi_constants.h \ + libc/musl-overlay/include/bits/kandelo_limits.h \ + libc/musl-overlay/include/bits/kandelo_process_layouts.h \ + libc/musl-overlay/include/bits/kandelo_channel_scalars.h \ + libc/musl-overlay/include/bits/kandelo_thread_syscalls.h \ + libc/musl-overlay/src/process/wasm32posix/spawn_contract.h \ + host/src/generated/abi.ts +git commit -m "ABI: Bind exec commits to exact prepared targets" +``` + +### Task 11: Carry opaque exec targets through both host runtimes + +**Files:** + +- Create: `host/src/exec-target.ts` +- Modify: `host/src/kernel-worker.ts` +- Modify: `host/src/kernel.ts` +- Modify: `host/src/worker-main.ts` +- Modify: `host/src/node-kernel-worker-entry.ts` +- Modify: `host/src/browser-kernel-worker-entry.ts` +- Modify: `host/src/node-kernel-host.ts` +- Modify: `host/src/browser-kernel-host.ts` +- Modify: `host/src/browser-kernel-protocol.ts` +- Modify: `host/test/exec-state-tracking.test.ts` +- Modify: `host/test/kernel-exec-entry.test.ts` +- Modify: `host/test/node-worker-adapter.test.ts` +- Modify: `host/test/spawn-host-parity.test.ts` +- Modify: `host/test/wasm-memory-write-audit.test.ts` +- Modify: `host/test/support/kernel-scratch-instance.ts` +- Create: `apps/browser-demos/test/prepared-exec-target.spec.ts` + +**Interfaces:** + +- Consumes: target exports from Task 10 +- Produces: shared `PreparedExecLaunchRequest`, exact target reader, and + target-shaped Node/browser Worker messages + +- [ ] **Step 1: Change test doubles to reject path authority** + +Replace five-argument `onExec(pid, path, argv, envp, callerTid)` mocks with +`ExecLaunchCallback`. Assert no callback or Worker message has +`credentialPath`, executable bytes from a path lookup, or a targetless setup +method. Keep `diagnosticPath` display-only by mutating it in a test and proving +the executed bytes remain those read through `target`. + +- [ ] **Step 2: Add shebang and post-commit launch-failure tests** + +For a shebang, prepare the script, ignore its set-ID bits, prepare the +interpreter as the final target, rewrite argv once, and commit only the +interpreter. Simulate replacement Worker construction failure after commit; +assert the old image does not return, the process dies, target resources +drain, and a vfork parent releases through the fatal-child path. + +- [ ] **Step 3: Run shared host tests and observe the old callback shape** + +```bash +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run \ + test/exec-state-tracking.test.ts \ + test/kernel-exec-entry.test.ts \ + test/node-worker-adapter.test.ts \ + test/spawn-host-parity.test.ts \ + test/wasm-memory-write-audit.test.ts' +``` + +Expected: FAIL because the host still resolves and authorizes execution by +pathname and calls targetless kernel setup. + +- [ ] **Step 4: Implement one shared target reader** + +`readPreparedExecTarget` first gets the signed 64-bit size, rejects negative +or unsafe JavaScript-number lengths, allocates once under the program-size +limit, and loops over `kernel_exec_target_read` until exact EOF. Split every +64-bit offset into `(offsetLo, offsetHi)` without precision loss. On any +precommit failure call cancel exactly once. + +```ts +export interface PreparedExecKernel { + execTargetSize(ownerPid: number, target: number): bigint; + execTargetRead( + ownerPid: number, + target: number, + offset: bigint, + destination: Uint8Array, + ): number; + execTargetCancel(ownerPid: number, target: number): number; +} + +export async function readPreparedExecTarget( + kernel: PreparedExecKernel, + ownerPid: number, + target: number, +): Promise; +``` + +- [ ] **Step 5: Replace host and Worker protocol shapes** + +Use `PreparedExecLaunchRequest` in shared kernel worker, Node entry, browser +entry, and browser protocol. The shared layer performs materialization hint, +final preparation, byte read, ABI validation, compilation, replacement-memory +preflight, final lease revalidation, and commit. Do not yield between final +revalidation and kernel commit. + +- [ ] **Step 6: Preserve entry-gate and memory-write boundaries** + +Marshal all target exports through `CentralizedKernelWorker` under the same +entry context. Update scratch and memory-write audits so target read writes +only the explicitly lent destination and commit has no guest-memory write. +Ensure cleanup callbacks run after entry revocation where they can re-enter. + +- [ ] **Step 7: Run Node and all-browser tests** + +```bash +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run \ + test/exec-state-tracking.test.ts \ + test/kernel-exec-entry.test.ts \ + test/node-worker-adapter.test.ts \ + test/spawn-host-parity.test.ts \ + test/wasm-memory-write-audit.test.ts' +scripts/dev-shell.sh bash -lc \ + 'cd apps/browser-demos && npx playwright test \ + test/prepared-exec-target.spec.ts \ + --project=chromium --project=firefox --project=webkit' +``` + +Expected: Node and browsers share the same target authority and every +precommit/postcommit failure has the specified old-image behavior. + +- [ ] **Step 8: Commit** + +```bash +git add host/src/exec-target.ts host/src/kernel-worker.ts host/src/kernel.ts \ + host/src/worker-main.ts host/src/node-kernel-worker-entry.ts \ + host/src/browser-kernel-worker-entry.ts host/src/node-kernel-host.ts \ + host/src/browser-kernel-host.ts host/src/browser-kernel-protocol.ts \ + host/test/exec-state-tracking.test.ts \ + host/test/kernel-exec-entry.test.ts \ + host/test/node-worker-adapter.test.ts \ + host/test/spawn-host-parity.test.ts \ + host/test/wasm-memory-write-audit.test.ts \ + host/test/support/kernel-scratch-instance.ts \ + apps/browser-demos/test/prepared-exec-target.spec.ts +git commit -m "Host: Launch exact prepared exec targets" +``` + +### Task 12: Make posix_spawn order credentials, actions, and commit once + +**Files:** + +- Modify: `crates/kernel/src/spawn.rs` +- Modify: `crates/kernel/src/process.rs` +- Modify: `crates/kernel/src/wasm_api.rs` +- Modify: `host/src/kernel-worker.ts` +- Modify: `host/src/node-kernel-worker-entry.ts` +- Modify: `host/src/browser-kernel-worker-entry.ts` +- Modify: `host/test/spawn-pid-authority.test.ts` +- Modify: `host/test/spawn-blob-transport.test.ts` +- Modify: `host/test/spawn-host-parity.test.ts` +- Create: `host/test/spawn-credential-order.test.ts` + +**Interfaces:** + +- Consumes: complete credentials and prepared spawn target export +- Produces: one pending-child launch transaction ordered as RESETIDS, + remaining attributes, file actions, authoritative target, commit, launch + +- [ ] **Step 1: Add an observable ordering fixture** + +Create a pending child whose parent has differing real/effective IDs and +supplementary groups. Make a credential-sensitive `open`, `chdir`, and +`fchdir` file action. Assert `POSIX_SPAWN_RESETIDS` changes effective IDs to +real IDs before those actions, supplementary groups remain inherited, and +each action runs once. + +- [ ] **Step 2: Add preflight/final-target divergence tests** + +The side-effect-free path candidate preflight may compile bytes A. Arrange a +file action or CWD change so the child's authoritative target is bytes B. +Assert B is recompiled, actions are not replayed, B is committed, and A is +discarded. On target failure, assert pending child rollback and unchanged +parent credentials. + +- [ ] **Step 3: Run focused tests and observe wrong ordering or authority** + +```bash +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run \ + test/spawn-credential-order.test.ts \ + test/spawn-pid-authority.test.ts \ + test/spawn-blob-transport.test.ts \ + test/spawn-host-parity.test.ts' +``` + +Expected: new order/target tests FAIL against the current path-shaped spawn. + +- [ ] **Step 4: Implement the pending-child transaction** + +Keep only side-effect-free path preflight before child creation. After +reservation, inherit the complete credential record, apply RESETIDS, apply +remaining attributes, drain file actions exactly once, prepare the target in +the child's final CWD/fd/credential state, recompile on digest difference, +call `kernel_spawn_exec_commit`, then launch. Every failure retires the child, +target, reserved PID, host mirrors, and scratch transaction exactly once. + +- [ ] **Step 5: Run focused and regression tests** + +```bash +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run \ + test/spawn-credential-order.test.ts \ + test/spawn-pid-authority.test.ts \ + test/spawn-blob-transport.test.ts \ + test/spawn-host-parity.test.ts \ + test/exec-state-tracking.test.ts' +``` + +Expected: RESETIDS/action ordering, one-shot side effects, target replacement, +and rollback PASS on both host adapters. + +- [ ] **Step 6: Commit** + +```bash +git add crates/kernel/src/spawn.rs crates/kernel/src/process.rs \ + crates/kernel/src/wasm_api.rs host/src/kernel-worker.ts \ + host/src/node-kernel-worker-entry.ts \ + host/src/browser-kernel-worker-entry.ts \ + host/test/spawn-pid-authority.test.ts \ + host/test/spawn-blob-transport.test.ts \ + host/test/spawn-host-parity.test.ts \ + host/test/spawn-credential-order.test.ts +git commit -m "POSIX: Order spawn credentials before file actions" +``` + +### Task 13: Enter secure musl startup for set-ID images + +**Files:** + +- Modify: `libc/musl-overlay/src/env/__libc_start_main.c` +- Modify: `libc/glue/syscall_imports.h` +- Modify: `crates/kernel/src/wasm_api.rs` +- Modify: `crates/shared/src/lib.rs` +- Modify: `tools/xtask/src/dump_abi.rs` +- Create: `programs/secure-exec-probe.c` +- Create: `host/test/secure-exec.test.ts` +- Modify: `abi/snapshot.json` +- Modify: `libc/glue/abi_constants.h` +- Modify: `host/src/generated/abi.ts` + +**Interfaces:** + +- Consumes: process `secure_exec` set by target-aware commit +- Produces: host query `kernel_process_secure_exec(pid) -> i32`, required guest + import `kernel_get_secure_exec() -> i32`, musl `libc.secure` before + constructors, and guaranteed open descriptors 0, 1, and 2 + +- [ ] **Step 1: Add a guest probe and failing host matrix** + +The probe prints `issetugid()`, whether `secure_getenv("UNTRUSTED")` is null, +constructor-observed security state, timezone/locale/message-catalog lookup +results, and open/closed status for fd 0, 1, and 2. Run it under ordinary +exec, trusted set-ID exec, nosuid exec, and spawn with and without RESETIDS. + +For each of the eight closed-stdio masks from `000` through `111`, close the +selected descriptors before exec and assert all three are open in the new +secure image and any replacement reads/writes `/dev/null`. + +- [ ] **Step 2: Run the focused test and observe insecure startup** + +```bash +scripts/dev-shell.sh bash scripts/build-programs.sh +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run test/secure-exec.test.ts' +``` + +Expected: FAIL because musl leaves `libc.secure` false and does not repair +closed standard descriptors. + +- [ ] **Step 3: Export and bind the authoritative marker** + +Add `kernel_process_secure_exec(pid: u32) -> i32` to the host/kernel ABI. The +central worker calls it after commit and puts the boolean in its private +process launch message. Add required guest import +`kernel_get_secure_exec() -> i32` in `worker-main.ts`; it closes over that +kernel-owned launch value. User boot descriptors and public spawn APIs cannot +supply or override the field. Do not inspect environment, argv, diagnostic +path, or host configuration. Missing import is an ABI mismatch. + +- [ ] **Step 4: Set `libc.secure` before constructors** + +In `__init_libc`, call `kernel_get_secure_exec()` before locale, environment, +constructors, or `main`, then assign: + +```c +libc.secure = kernel_get_secure_exec() != 0; +``` + +The marker remains true for the image even if application code later changes +effective IDs. + +- [ ] **Step 5: Repair standard descriptors through normal syscalls** + +For each fd from 0 through 2, use `fcntl(fd, F_GETFD)` to detect `EBADF`. Open +`/dev/null` with `O_RDWR`; because fd allocation is lowest-first it should +occupy the missing slot. If it does not, use `dup2(opened, fd)` then close the +temporary fd. On open or duplication failure, write no forged success and +terminate startup with status 127. + +```c +static void secure_standard_fds(void) { + for (int fd = 0; fd != 3; ++fd) { + if (__syscall(SYS_fcntl, fd, F_GETFD) != -EBADF) continue; + int opened = __syscall(SYS_openat, AT_FDCWD, "/dev/null", O_RDWR, 0); + if (opened < 0) __syscall(SYS_exit_group, 127); + if (opened != fd && __syscall(SYS_dup2, opened, fd) < 0) + __syscall(SYS_exit_group, 127); + if (opened != fd) __syscall(SYS_close, opened); + } +} +``` + +Call this only when `libc.secure` is true and before application code. + +- [ ] **Step 6: Regenerate ABI files and rebuild musl** + +```bash +scripts/dev-shell.sh bash scripts/check-abi-version.sh update +scripts/dev-shell.sh bash scripts/check-abi-version.sh +scripts/dev-shell.sh bash scripts/build-musl.sh +scripts/dev-shell.sh bash build.sh +scripts/dev-shell.sh bash scripts/build-programs.sh +``` + +Expected: ABI check PASS, musl rebuild PASS, and probe artifact uses ABI 43. + +- [ ] **Step 7: Run the full secure-startup matrix** + +```bash +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run \ + test/secure-exec.test.ts test/nosuid-exec.test.ts \ + test/spawn-credential-order.test.ts' +``` + +Expected: secure consumers reject untrusted environment lookup, every closed +stdio mask is repaired, and ordinary/nosuid images remain non-secure. + +- [ ] **Step 8: Commit** + +```bash +git add libc/musl-overlay/src/env/__libc_start_main.c \ + libc/glue/syscall_imports.h crates/kernel/src/wasm_api.rs \ + crates/shared/src/lib.rs tools/xtask/src/dump_abi.rs \ + host/src/kernel-worker.ts host/src/worker-main.ts \ + host/src/node-kernel-worker-entry.ts \ + host/src/browser-kernel-worker-entry.ts \ + host/src/node-kernel-protocol.ts host/src/browser-kernel-protocol.ts \ + programs/secure-exec-probe.c host/test/secure-exec.test.ts \ + abi/snapshot.json libc/glue/abi_constants.h \ + libc/musl-overlay/include/bits/kandelo_limits.h \ + libc/musl-overlay/include/bits/kandelo_process_layouts.h \ + libc/musl-overlay/include/bits/kandelo_channel_scalars.h \ + libc/musl-overlay/include/bits/kandelo_thread_syscalls.h \ + libc/musl-overlay/src/process/wasm32posix/spawn_contract.h \ + host/src/generated/abi.ts +git commit -m "Libc: Enter secure startup for set-ID images" +``` + +### Task 14: Clear set-ID bits after qualifying file mutations + +**Files:** + +- Modify: `host/src/platform/native-metadata.ts` +- Modify: `host/src/platform/node.ts` +- Modify: `host/src/vfs/memory-fs.ts` +- Modify: `host/src/vfs/host-fs.ts` +- Modify: `host/src/vfs/sharedfs-vendor.ts` +- Modify: `host/src/vfs/index.ts` +- Modify: `host/test/chown-sentinel.test.ts` +- Modify: `host/test/platform/native-metadata.test.ts` +- Modify: `host/test/node-host-vfs-only-metadata.test.ts` +- Modify: `host/test/vfs/host-fs-uid-gid.test.ts` +- Modify: `host/test/vfs/sharedfs-uid-gid.test.ts` +- Modify: `host/test/vfs.test.ts` +- Modify: `apps/browser-demos/test/chown-sentinel.spec.ts` + +**Interfaces:** + +- Consumes: authoritative uid/gid/mode and unique privileged inodes +- Produces: identical set-ID invalidation across MemoryFS, SharedFS, host FS, + Node, and browser paths + +- [ ] **Step 1: Build a backend mutation matrix** + +For every backend, create one `06755` regular file and exercise `write`, +positioned write, append, truncate, ftruncate, chown, fchown, and lchown. +Assert successful content mutation clears `S_ISUID` and the executable +`S_ISGID`; ownership mutation clears both. Assert failed and zero-byte +operations do not mutate mode. Verify path and fd stat agree after each case. + +- [ ] **Step 2: Run the matrix and identify only current gaps** + +```bash +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run \ + test/chown-sentinel.test.ts \ + test/platform/native-metadata.test.ts \ + test/node-host-vfs-only-metadata.test.ts \ + test/vfs/host-fs-uid-gid.test.ts \ + test/vfs/sharedfs-uid-gid.test.ts \ + test/vfs.test.ts' +``` + +Expected: at least one backend or descriptor mutation path retains stale +set-ID bits. Retain existing passing implementations unchanged. + +- [ ] **Step 3: Centralize the invalidation decision** + +Use one helper in native metadata and the equivalent MemoryFS primitive: + +```ts +export function modeAfterRegularFileMutation( + mode: number, + kind: "content" | "ownership", +): number { + if ((mode & S_IFMT) !== S_IFREG) return mode; + return mode & ~(S_ISUID | S_ISGID); +} +``` + +Call it only after a successful qualifying operation. Preserve reviewed uid, +gid, and mode for internal publication of authenticated lazy projection bytes; +subsequent guest-visible mutation follows the normal clearing rule. + +- [ ] **Step 4: Run Node and browser coverage** + +```bash +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run \ + test/chown-sentinel.test.ts \ + test/platform/native-metadata.test.ts \ + test/node-host-vfs-only-metadata.test.ts \ + test/vfs/host-fs-uid-gid.test.ts \ + test/vfs/sharedfs-uid-gid.test.ts \ + test/vfs.test.ts' +scripts/dev-shell.sh bash -lc \ + 'cd apps/browser-demos && npx playwright test \ + test/chown-sentinel.spec.ts \ + --project=chromium --project=firefox --project=webkit' +``` + +Expected: all backends expose identical metadata after the complete matrix. + +- [ ] **Step 5: Commit with source authorship** + +```bash +git add host/src/platform/native-metadata.ts host/src/platform/node.ts \ + host/src/vfs/memory-fs.ts host/src/vfs/host-fs.ts \ + host/src/vfs/sharedfs-vendor.ts host/src/vfs/index.ts \ + host/test/chown-sentinel.test.ts \ + host/test/platform/native-metadata.test.ts \ + host/test/node-host-vfs-only-metadata.test.ts \ + host/test/vfs/host-fs-uid-gid.test.ts \ + host/test/vfs/sharedfs-uid-gid.test.ts host/test/vfs.test.ts \ + apps/browser-demos/test/chown-sentinel.spec.ts +git commit --author='Brandon Payton ' \ + -m "VFS: Invalidate set-ID bits after file mutation" +``` + +### Task 15: Persist devpts ownership, mode, and permission checks + +**Files:** + +- Modify: `crates/kernel/src/pty.rs` +- Modify: `crates/kernel/src/terminal.rs` +- Modify: `crates/kernel/src/syscalls.rs` +- Modify: `crates/kernel/src/wasm_api.rs` +- Create: `programs/pty-ownership.c` +- Create: `host/test/pty-ownership.test.ts` +- Modify: `host/test/terminal-attributes-api.test.ts` +- Create: `apps/browser-demos/test/pty-ownership.spec.ts` + +**Interfaces:** + +- Consumes: authoritative credentials and group membership +- Produces: one persistent uid, gid, and mode per PTY pair; identical path/fd + metadata and open decisions + +- [ ] **Step 1: Add PTY lifetime and permission tests** + +Allocate a PTY as uid/gid 1000. Assert slave defaults to uid 1000, the +caller's effective tty group where configured, and mode `0620`. Compare path +stat and open-fd stat. Exercise root and owner chmod/chown, unauthorized +mutation, owner/group read/write, supplementary-group access, unrelated-user +denial, close/reopen, and pair destruction. + +- [ ] **Step 2: Run focused tests and observe synthesized metadata** + +```bash +scripts/dev-shell.sh bash scripts/build-programs.sh +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run \ + test/pty-ownership.test.ts test/terminal-attributes-api.test.ts' +``` + +Expected: new persistent metadata or permission cases FAIL. + +- [ ] **Step 3: Store metadata on `PtyPair`** + +Construct PTYs with caller identity and keep: + +```rust +pub struct PtyPair { + pub uid: u32, + pub gid: u32, + pub mode: u32, + // existing transport and terminal state +} +``` + +Use one `pty_pair_stat` helper for devpts path and descriptor stat. Route +chmod/chown through the stored pair and check slave open against the current +process credential record. Do not mirror PTY identity in the browser UI. + +- [ ] **Step 4: Run Rust, Node, and browser tests** + +```bash +scripts/dev-shell.sh bash -lc \ + 'host_target=$(rustc -vV | sed -n "s/^host: //p"); \ + cargo test -p wasm-posix-kernel --target "$host_target" pty' +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run \ + test/pty-ownership.test.ts test/terminal-attributes-api.test.ts' +scripts/dev-shell.sh bash -lc \ + 'cd apps/browser-demos && npx playwright test \ + test/pty-ownership.spec.ts \ + --project=chromium --project=firefox --project=webkit' +``` + +Expected: metadata and permission results agree for paths, fds, Node, and all +browser engines. + +- [ ] **Step 5: Commit with source authorship** + +```bash +git add crates/kernel/src/pty.rs crates/kernel/src/terminal.rs \ + crates/kernel/src/syscalls.rs crates/kernel/src/wasm_api.rs \ + programs/pty-ownership.c host/test/pty-ownership.test.ts \ + host/test/terminal-attributes-api.test.ts \ + apps/browser-demos/test/pty-ownership.spec.ts +git commit --author='Brandon Payton ' \ + -m "PTY: Preserve slave ownership and mode" +``` + +### Task 16: Gate ppoll and pselect interruption without Linux wait flags + +**Files:** + +- Modify if red: `crates/kernel/src/syscalls.rs` +- Modify if red: `host/src/kernel-worker.ts` +- Modify: `host/test/select-signal-guest.test.ts` +- Modify: `host/test/select-signal-outcome.test.ts` +- Modify: `host/test/readiness-wakeup.test.ts` +- Modify: `host/test/readiness-deadline.test.ts` +- Modify: `host/test/kernel-blocking-retry-snapshot.test.ts` + +**Interfaces:** + +- Consumes: current signal masks, blocking retry snapshots, and readiness + wakeups +- Produces: exact evidence for null/non-null replacement masks and + `SA_RESTART`; no `__WALL` constant or behavior + +- [ ] **Step 1: Add the source branch's exact interruption cases** + +Cover pending signal before entry and signal arriving while blocked for both +`ppoll` and `pselect`, each with null and non-null replacement masks, and with +and without `SA_RESTART`. Assert the original mask is restored exactly once, +the handler sees the temporary mask, readiness is not lost, and errno/result +matches POSIX. + +- [ ] **Step 2: Run the exact focused set** + +```bash +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run \ + test/select-signal-guest.test.ts \ + test/select-signal-outcome.test.ts \ + test/readiness-wakeup.test.ts \ + test/readiness-deadline.test.ts \ + test/kernel-blocking-retry-snapshot.test.ts' +``` + +Expected outcome A: all new cases PASS, proving current HEAD already contains +the behavior. Make no production change and keep only a focused test commit if +the added cases improve coverage. + +Expected outcome B: a case FAILS. Keep it red, correct only mask install, +wakeup, restart, or restore logic in the layer identified by the trace, then +rerun the full set. Do not copy the old advisory-lock mock change unless an +independent advisory-lock test fails. + +- [ ] **Step 3: Assert the Linux wait flag remains absent** + +```bash +! rg -n '__WALL|0x40000000' \ + crates/kernel crates/shared host/src libc/musl-overlay \ + --glob '!**/*test*' +``` + +Expected: no production match. The sudo Formula patch in Task 18 removes its +use while preserving `WUNTRACED` and `WNOHANG`. + +- [ ] **Step 4: Commit the regression boundary** + +If production changed: + +```bash +git add crates/kernel/src/syscalls.rs host/src/kernel-worker.ts \ + host/test/select-signal-guest.test.ts \ + host/test/select-signal-outcome.test.ts \ + host/test/readiness-wakeup.test.ts \ + host/test/readiness-deadline.test.ts \ + host/test/kernel-blocking-retry-snapshot.test.ts +git commit --author='Brandon Payton ' \ + -m "Signals: Preserve poll masks across interruption" +``` + +If current production already passes: + +```bash +git add host/test/select-signal-guest.test.ts \ + host/test/select-signal-outcome.test.ts \ + host/test/readiness-wakeup.test.ts \ + host/test/readiness-deadline.test.ts \ + host/test/kernel-blocking-retry-snapshot.test.ts +git diff --cached --quiet || \ + git commit --author='Brandon Payton ' \ + -m "Tests: Preserve poll interruption semantics" +``` + +### Task 17: Add first-party login and sudo-lite through the normal guest path + +**Files:** + +- Create: `programs/login.c` +- Create: `programs/sudo-lite.c` +- Create: `images/vfs/lib/demo-login.ts` +- Modify: `images/rootfs/etc/passwd` +- Modify: `images/rootfs/etc/group` +- Modify: `images/rootfs/etc/shadow` +- Create: `images/rootfs/etc/sudoers` +- Create: `images/rootfs/etc/motd.autologin` +- Modify: `MANIFEST` +- Modify: `scripts/build-programs.sh` +- Create: `host/test/login.test.ts` +- Create: `host/test/sudo-lite.test.ts` +- Create: `host/test/demo-login-image.test.ts` +- Create: `apps/browser-demos/test/sudo-lite.spec.ts` + +**Interfaces:** + +- Consumes: credentials, secure exec, PTY, root-owned privileged projection +- Produces: first-party source programs and truthful demo account/policy data; + product binaries still come from Homebrew Formulae + +- [ ] **Step 1: Add guest behavior tests before source files** + +For login, cover password success/failure, unknown user, `-p`, `-f`, non-root +rejection of either option, `setgroups` then `setgid` then `setuid`, home CWD, +safe environment, ordinary `/etc/motd`, preauth-only +`/etc/motd.autologin`, and shell exec failure. For sudo-lite, cover wheel and +non-wheel users, password success/failure, `sudo -l`, malformed sudoers, +root transition, supplementary groups, safe environment, and `execvp` failure. + +- [ ] **Step 2: Run focused tests and observe missing programs** + +```bash +scripts/dev-shell.sh bash scripts/build-programs.sh +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run \ + test/login.test.ts test/sudo-lite.test.ts \ + test/demo-login-image.test.ts' +``` + +Expected: FAIL because first-party sources and image policy are absent. + +- [ ] **Step 3: Forward-port the final source behavior** + +Use the final versions from `emdash/support-logins-8yaz3` as behavioral +reference, not an old patch application. `login` permits `-p` and `-f` only +when real uid is 0, initializes groups before dropping IDs, changes home, +prints the ordinary motd every time and the credential motd only for `-f`, +then execs the account shell. `sudo-lite` reads normal passwd/group/shadow and +sudoers files, requires wheel policy and password, establishes root IDs and +groups through syscalls, sanitizes environment, and execs the requested argv. + +- [ ] **Step 4: Make demo account data canonical** + +Use these constants only in `demo-login.ts` and derive product files from +them: + +```ts +export const DEMO_LOGIN_USERNAME = "maker"; +export const DEMO_LOGIN_PASSWORD = "kandelo"; +export const DEMO_LOGIN_PASSWORD_HASH = + "$6$kandelo$DKNPruix37YeUx9j4kJIGJ2NvXdqzxDr5b1D3xJZzbwFsNYuep8j3AtxB7OaTD6HWnz/adonyTamRx4XQwJ06/"; +``` + +Set maker uid/gid 1000, add `wheel:x:10:maker`, keep shadow root-owned and +non-world-readable, and write a minimal sudoers policy for wheel. The rootfs +does not contain host-side authentication or a preauthenticated shell. + +- [ ] **Step 5: Keep local builds test-only** + +Teach `build-programs.sh` to compile these sources as fixtures without placing +regular files into Homebrew/product-owned resolver paths. Product assembly +must consume Task 18 bottles and Task 7 projections. + +- [ ] **Step 6: Run Node and browser behavior** + +```bash +scripts/dev-shell.sh bash scripts/build-programs.sh +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run \ + test/login.test.ts test/sudo-lite.test.ts \ + test/demo-login-image.test.ts \ + test/secure-exec.test.ts test/pty-ownership.test.ts' +scripts/dev-shell.sh bash -lc \ + 'cd apps/browser-demos && npx playwright test \ + test/sudo-lite.spec.ts \ + --project=chromium --project=firefox --project=webkit' +``` + +Expected: authentication and denial are real guest results; no test inserts a +synthetic shell or host-native program. + +- [ ] **Step 7: Commit with source authorship** + +```bash +git add programs/login.c programs/sudo-lite.c \ + images/vfs/lib/demo-login.ts images/rootfs/etc/passwd \ + images/rootfs/etc/group images/rootfs/etc/shadow \ + images/rootfs/etc/sudoers images/rootfs/etc/motd.autologin \ + MANIFEST scripts/build-programs.sh \ + host/test/login.test.ts host/test/sudo-lite.test.ts \ + host/test/demo-login-image.test.ts \ + apps/browser-demos/test/sudo-lite.spec.ts +git commit --author='Brandon Payton ' \ + -m "POSIX: Add real login and sudo-lite programs" +``` + +### Task 18: Add login and sudo Formulae and preserve pristine Ruby + +**Files in a separate clean `Kandelo-dev/homebrew-tap-core` worktree:** + +- Create: `Formula/login.rb` +- Create: `Formula/sudo-lite.rb` +- Create: `Formula/sudo.rb` +- Create: `patches/sudo/0001-kandelo-portability.patch` +- Audit and modify if needed: `Formula/ruby.rb` and its declared build inputs +- Test: Formula `test do` blocks and Kandelo tap validation + +**Interfaces:** + +- Consumes: exact Kandelo Task 17 commit, SDK `kandelo_wasm_build`, ABI 43, + and normal Homebrew sidecar contracts +- Produces: three source-built Formulae; no bottle metadata is promotable + until reviewed GitHub candidate workflows build it + +- [ ] **Step 1: Create an isolated tap branch without touching staging** + +Use the worktree skill at execution time. Base the tap branch on the current +protected main commit, name it `emdash/abi43-login-sudo`, and verify it is a +clean checkout before editing: + +```bash +git status --short +git rev-parse HEAD +git branch --show-current +``` + +Do not use or modify Kandelo branch +`emdash/homebrew-pr-staging-1q1w6` for this work. + +- [ ] **Step 2: Write Formula tests first** + +`login` and `sudo-lite` tests execute the installed Wasm through the tap's +normal Kandelo test helper and verify `--help` or a deterministic invalid-use +exit. `sudo` tests its installed executable and records that its patched wait +source contains `WUNTRACED` and `WNOHANG` but not `__WALL`. + +- [ ] **Step 3: Define first-party Formulae from the exact Kandelo commit** + +Each first-party Formula pins the Automattic/kandelo archive for the exact +Task 17 commit and its SHA-256, selects only `programs/login.c` or +`programs/sudo-lite.c`, and invokes `kandelo_wasm_build`. It declares ABI 43, +normal dependencies, output members, license, and tests through current tap +conventions. It must not set `KANDELO_REGISTRY_BRIDGE`, invoke a registry +recipe, or copy `local-binaries`. + +- [ ] **Step 4: Port upstream sudo 1.9.17p2 at the narrow boundary** + +Pin the upstream archive and checksum. The patch removes `__WALL` from the two +child-wait option expressions while retaining `WUNTRACED` and `WNOHANG`; it +does not define `__WALL`, change Kandelo wait ABI, or claim Linux clone-child +support. Keep all other port changes tied to missing platform or build-system +boundaries and exercise normal PTY, signal, and wait code. + +- [ ] **Step 5: Keep generated bottle metadata out of the source commit** + +Declare dependencies, architectures, source identity, outputs, ABI 43, and +tests in the Formulae using current tap helpers. Do not add bottle stanzas, +`Kandelo/formula` sidecars, link manifests, provenance reports, or candidate +identities by hand; Task 20 generates local-test copies outside the tap and +Task 24's reviewed workflow generates candidate copies. + +- [ ] **Step 6: Run tap validation and local Formula builds** + +From the Kandelo worktree, with `KANDELO_TAP_ROOT` set to the absolute clean +tap worktree, first parse all Formulae: + +```bash +scripts/dev-shell.sh bash -lc \ + 'for formula_name in login sudo-lite sudo; do \ + ruby -c "$KANDELO_TAP_ROOT/Formula/$formula_name.rb"; \ + done' +``` + +Then run Task 20's local harness for actual bottle builds; static validation +alone is not Formula build evidence. + +- [ ] **Step 7: Commit in the tap with preserved source attribution** + +```bash +git add Formula/login.rb Formula/sudo-lite.rb Formula/sudo.rb \ + patches/sudo/0001-kandelo-portability.patch +git commit --author='Brandon Payton ' \ + -m "POSIX: Package login and sudo for Kandelo" +``` + +After committing, validate the exact Formula source closures: + +```bash +KANDELO_TAP_COMMIT="$(git -C "$KANDELO_TAP_ROOT" rev-parse HEAD)" +for formula_name in login sudo-lite sudo; do + scripts/dev-shell.sh bash scripts/homebrew-validate-formula-source-closure.sh \ + --tap-root "$KANDELO_TAP_ROOT" \ + --tap-repository kandelo-dev/homebrew-tap-core \ + --formula "$formula_name" \ + --base-ref "$KANDELO_TAP_COMMIT" +done +``` + +- [ ] **Step 8: Preserve the existing pristine-Ruby selection** + +Audit the migrated Ruby Formula against Brandon's existing Kandelo removal +commit `87d842814b050ba2c1acbaa880059b3d1aa0e321`. Require the pinned upstream +archive, no source patch, no `ac_cv_func_vfork=no`, positive +`HAVE_WORKING_VFORK` assertions, and a build-time check that extracted CRuby +source matches upstream before configure. If migration already preserved +these facts, make no commit. If it reintroduced the temporary path, remove +only that residue and commit the forward port independently: + +```bash +git add Formula/ruby.rb +git commit --author='Brandon Payton ' \ + -m "Ruby: Preserve pristine upstream vfork selection" +``` + +Do not stage generated bottle or sidecar metadata. Record the resulting exact +tap commit for Task 20's migration lock. + +Do not open or merge the companion tap PR until Task 22's local matrix passes. + +### Task 19: Supervise one login lifecycle per logical browser PTY + +**Files:** + +- Modify: `web-libs/kandelo-session/src/kernel-host.ts` +- Modify: `web-libs/kandelo-session/src/index.ts` +- Modify: `web-libs/kandelo-session/test/kandelo-session.test.ts` +- Create: `apps/browser-demos/pages/kandelo/kernel-host/demo-terminal-sessions.ts` +- Modify: `apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts` +- Modify: `apps/browser-demos/pages/kandelo/kernel-host/react.tsx` +- Modify: `apps/browser-demos/pages/kandelo/panes/Shell.tsx` +- Create: `apps/browser-demos/test/login-terminal-session.spec.ts` + +**Interfaces:** + +- Consumes: `TerminalProgram` and `TerminalSessionPolicy` from the interface + map; `/usr/bin/login` from the product projection +- Produces: initial autologin exactly once per logical PTY, ordinary login + afterward, bounded restart, and explicit logical removal + +- [ ] **Step 1: Add fake-clock session unit tests** + +Cover first attachment, repeat UI attachment, first process exit, logout, +ordinary-login exit, processes shorter and longer than two seconds, restart +delays `250, 500, 1000, 2000, 4000, 5000, 5000`, start failure, logical PTY +removal, kernel detach, reboot, destroy, and stale exit callbacks. Assert one +active process and at most one timer for each session. + +```ts +expect(spawns[0].argv).toEqual(["login", "-p", "-f", "maker"]); +expect(spawns[1].argv).toEqual(["login", "-p"]); +expect(reattachSpawnCount).toBe(0); +expect(activeRestartTimers(session)).toBeLessThanOrEqual(1); +``` + +- [ ] **Step 2: Run the unit test and observe current respawn behavior** + +```bash +scripts/dev-shell.sh bash -lc \ + 'cd web-libs/kandelo-session && npx vitest run \ + test/kandelo-session.test.ts' +``` + +Expected: FAIL because the current default shell record has no initial versus +post-exit policy or generation-safe restart. + +- [ ] **Step 3: Extend `LivePtySession` and public lifecycle methods** + +Store: + +```ts +interface LivePtySession { + // existing path, history, listeners, and process fields + logicalGeneration: number; + processGeneration: number; + autologinConsumed: boolean; + startedAt: number; + restartDelayMs: number; + restartTimer: ReturnType | null; + removed: boolean; +} +``` + +Add `removePty(path: string): void` to `KernelHost`. UI handle `close()` only +detaches listeners; `removePty` cancels the timer, invalidates generations, +closes the process/PTY, and deletes the logical record. + +- [ ] **Step 4: Implement generation-safe restart and diagnostics** + +Consume `initial` before starting it so a failed launch cannot retry +autologin. On process exit, compare logical and process generations, compute +runtime, reset delay to 250 ms at or above 2000 ms, otherwise double to at +most 5000 ms, and schedule one `afterExit` launch. A start failure appends a +plain terminal diagnostic and schedules nothing. Detach, reboot, and destroy +invalidate callbacks before clearing timers. + +- [ ] **Step 5: Configure the demo without moving auth into React** + +Export this policy from `demo-terminal-sessions.ts`: + +```ts +export const DEMO_TERMINAL_SESSION_POLICY: TerminalSessionPolicy = { + initial: { + programPath: "/usr/bin/login", + argv: ["login", "-p", "-f", "maker"], + uid: 0, + gid: 0, + }, + afterExit: { + programPath: "/usr/bin/login", + argv: ["login", "-p"], + uid: 0, + gid: 0, + }, + shortRunThresholdMs: 2_000, + initialRestartDelayMs: 250, + maximumRestartDelayMs: 5_000, +}; +``` + +React passes policy and calls `removePty` only when the user removes the +terminal. It never validates a password or advances generations. + +- [ ] **Step 6: Run unit and all-browser lifecycle tests** + +```bash +scripts/dev-shell.sh bash -lc \ + 'cd web-libs/kandelo-session && npx vitest run \ + test/kandelo-session.test.ts' +scripts/dev-shell.sh bash -lc \ + 'cd apps/browser-demos && npx playwright test \ + test/login-terminal-session.spec.ts \ + --project=chromium --project=firefox --project=webkit' +``` + +Expected: every new terminal autologins once, UI reattachment does not, logout +starts ordinary login, failed password stays failed, and restart/teardown is +bounded and generation-safe. + +- [ ] **Step 7: Commit with source authorship** + +```bash +git add web-libs/kandelo-session/src/kernel-host.ts \ + web-libs/kandelo-session/src/index.ts \ + web-libs/kandelo-session/test/kandelo-session.test.ts \ + apps/browser-demos/pages/kandelo/kernel-host/demo-terminal-sessions.ts \ + apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts \ + apps/browser-demos/pages/kandelo/kernel-host/react.tsx \ + apps/browser-demos/pages/kandelo/panes/Shell.tsx \ + apps/browser-demos/test/login-terminal-session.spec.ts +git commit --author='Brandon Payton ' \ + -m "Browser: Supervise real login sessions per terminal" +``` + +### Task 20: Compose the Homebrew product and local-test evidence harness + +**Files:** + +- Modify: `homebrew/main-shell.Brewfile` +- Modify: `homebrew/main-shell-default.json` +- Modify: `homebrew/main-shell-demo.json` +- Modify: `homebrew/main-shell-materialization-policy.json` +- Modify: `homebrew/main-shell-homebrew-runtime-support.json` +- Modify: `homebrew/main-shell-brew-package-tree.json` +- Modify: `homebrew/main-shell-migration-lock.json` +- Modify: `host/src/homebrew-vfs-builder.ts` +- Modify: `scripts/build-homebrew-main-shell-closure.sh` +- Modify: `scripts/homebrew-generate-sidecars-from-env.sh` +- Create: `scripts/run-login-stack-local.sh` +- Create: `scripts/measure-homebrew-vfork-rss.ts` +- Create: `host/test/homebrew-login-product.test.ts` +- Modify: `scripts/homebrew-main-shell-image-contract.test.ts` +- Modify: `scripts/homebrew-main-shell-node-smoke.ts` +- Modify: `scripts/create-homebrew-guest-lifecycle-fixture.ts` +- Create: `apps/browser-demos/test/homebrew-login-lifecycle.spec.ts` + +**Interfaces:** + +- Consumes: exact clean tap checkout, local Formulae, generic materialization, + privileged projections, login session policy, and existing Homebrew bottle, + sidecar, composition, Node smoke, and closed-mirror tools +- Produces: `run-login-stack-local.sh --tap-root --work-root + [--browser-demo]`; immutable local image/mirror; bound `local-test` evidence + +- [ ] **Step 1: Add product contract tests** + +Require login, sudo-lite, sudo, Ruby, and shell in the product closure; three +privileged projections with exact paths/owners/modes; ordinary Homebrew prefix +nosuid; no registry bridge; pristine upstream Ruby with no PR #1166 patch or +`ac_cv_func_vfork=no`; deferred upstream sudo allowed; and exact bottle, +sidecar, VFS, kernel, and ABI identities. Reject a local sidecar passed to any +publication, promotion, selection-lock, or authorized-candidate validator. + +- [ ] **Step 2: Add an explicit local provenance class** + +Permit this record only behind the local harness and +`--review-pending-artifact` composition path: + +```json +{ + "schema": 1, + "provenance_kind": "local-test", + "promotable": false, + "published": false +} +``` + +The local generator binds exact commits, Formula bytes, bottle digests, +dependency evidence, and runtime evidence, but never emits a GitHub run as +authority. Every remote publisher/selection validator rejects +`provenance_kind: local-test` before copying bytes or mutating state. + +- [ ] **Step 3: Run product tests and observe missing inputs** + +```bash +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run test/homebrew-login-product.test.ts' +scripts/dev-shell.sh bash -lc \ + 'npx vitest run scripts/homebrew-main-shell-image-contract.test.ts' +``` + +Expected: FAIL because product policy and local-test provenance do not yet +carry the new Formulae and projections. + +- [ ] **Step 4: Add the Formulae and projection policy** + +Select the normal Homebrew roots in the Brewfile and product JSON. Project +only `/usr/bin/login`, `/usr/bin/sudo-lite`, and `/usr/bin/sudo` into the +trusted product mount. Keep Ruby and shells in ordinary Homebrew placement. +Bind each projection to exact Formula, bottle, source member, destination, +artifact validation digest, uid 0, gid 0, and mode `04755`. + +- [ ] **Step 5: Implement strict harness argument and checkout validation** + +Accept exactly: + +```text +scripts/run-login-stack-local.sh \ + --tap-root /absolute/clean/homebrew-tap-core \ + --work-root /absolute/new-exclusive-directory \ + [--browser-demo] +``` + +Require `IN_NIX_SHELL`, absolute real tap directory, clean tracked and +untracked tap state, 40-character tap and Kandelo commits, ABI 43, and a +nonexistent work root below a real parent. Require the tap commit to equal +`catalog.tap_commit` in `homebrew/main-shell-migration-lock.json`; Task 20 +updates that lock to the exact Task 18 tap commit and complete selected +Formula closure. Before creating output, inspect the committed Ruby Formula, +its complete declared source closure, and the configured source marker. Reject +any reference to `kandelo-posix-spawn.patch`, PR #1166's patch digest, +`ac_cv_func_vfork=no`, or a source tree that differs from the pinned upstream +Ruby archive. Compute that tree identity immediately after extraction, before +configure creates build outputs; no Ruby source patch is permitted. Reject +`/`, symlinks, an existing work root, dirty tap checkout, lock drift, and +unknown flags before building. + +After validation, create the exclusive work root and a detached Kandelo +worktree at `$KANDELO_LOGIN_WORK_ROOT/kandelo-source` from the exact current +Kandelo `HEAD`, then initialize its submodules: + +```bash +git worktree add --detach \ + "$KANDELO_LOGIN_WORK_ROOT/kandelo-source" \ + "$KANDELO_LOGIN_KANDELO_COMMIT" +git -C "$KANDELO_LOGIN_WORK_ROOT/kandelo-source" \ + submodule update --init --recursive +``` + +All builds and tests below run from that detached source, not from the +possibly dirty invoking worktree. Preserve it with the reports as exact-head +evidence; any later cleanup must use `git worktree remove` on this resolved +path rather than recursively deleting an unresolved path. + +- [ ] **Step 6: Build musl, platform, fixtures, and local bottles** + +From the detached source, the harness runs, in order: + +```bash +bash scripts/build-musl.sh +bash build.sh +bash scripts/build-programs.sh +``` + +For `login sudo-lite sudo ruby` and each resolved dependency, invoke +`scripts/homebrew-bottle-build.sh` with the exact tap, `--arch wasm32`, a +formula-specific output directory, and the canonical bottle root URL returned +by `homebrew_bottle_root_url`. Collect the produced archive, bottle JSON, +dependency provenance, and runtime evidence. Never set `GITHUB_ACTIONS=true` +or reuse an ambient Homebrew prefix/cache. For Ruby, retain `config.h`, the +configure transcript, the extracted upstream `process.c` digest, and the final +instrumented Wasm digest. Require `HAVE_VFORK`, `HAVE_WORKING_VFORK`, and +`HAVE_WORKING_FORK`, and reject any PR #1166 source residue before accepting +the local bottle. + +- [ ] **Step 7: Generate local-test sidecars and a closed mirror** + +Invoke the sidecar generator once per bottle with exact file identities and +an explicit `KANDELO_HOMEBREW_PROVENANCE_KIND=local-test`. Validate each +sidecar in local mode, then construct the bottle mirror with the existing +closed-mirror helper. The published-sidecar validator must reject the same +directory. + +- [ ] **Step 8: Compose without replacing checked-in assets** + +Call: + +```bash +bash scripts/build-homebrew-main-shell-closure.sh \ + --tap-root "$KANDELO_LOGIN_TAP_ROOT" \ + --expected-tap-sha "$KANDELO_LOGIN_TAP_COMMIT" \ + --work-dir "$KANDELO_LOGIN_WORK_ROOT/composition" \ + --out "$KANDELO_LOGIN_WORK_ROOT/main-shell.vfs.zst" \ + --report "$KANDELO_LOGIN_WORK_ROOT/composition-report.json" \ + --bottle-cache "$KANDELO_LOGIN_WORK_ROOT/bottle-cache" \ + --lazy-shell --review-pending-artifact +``` + +Use task-specific variables, never `HOME`, and leave all output under the +exclusive work root. + +- [ ] **Step 9: Run the Node and browser lifecycle** + +Run the image contract and Node smoke against the generated image and closed +mirror. Generate a Playwright fixture with +`create-homebrew-guest-lifecycle-fixture.ts`, then run Chromium, Firefox, and +WebKit. The scripted interaction covers: + +```text +automatic maker login +id +sudo -l +sudo id +failed-password rejection +ordinary login after logout +nosuid execution rejection +Ruby spawning through vfork +brew tap/install/execute +``` + +With `--browser-demo`, print the exact `./run.sh browser` environment/asset +arguments and preserve the image and mirror for manual use; do not overwrite +repository assets. + +- [ ] **Step 10: Measure and write bound evidence** + +`measure-homebrew-vfork-rss.ts` samples the complete Node and Chromium process +trees before boot, before Ruby, at peak, after child reaping, and after three +repetitions. The harness writes JSON and Markdown reports containing exact +Kandelo/tap commits, ABI, Formula and bottle identities, kernel/VFS digests, +commands/statuses, browser versions/projects, vfork fork-mode evidence, RSS, +and `local-test` provenance for every artifact. + +- [ ] **Step 11: Commit Kandelo product integration** + +Commit before the exact-head run so the detached evidence source contains +the complete harness and implementation: + +```bash +git add homebrew/main-shell.Brewfile homebrew/main-shell-default.json \ + homebrew/main-shell-demo.json \ + homebrew/main-shell-materialization-policy.json \ + homebrew/main-shell-homebrew-runtime-support.json \ + homebrew/main-shell-brew-package-tree.json \ + homebrew/main-shell-migration-lock.json \ + host/src/homebrew-vfs-builder.ts \ + scripts/build-homebrew-main-shell-closure.sh \ + scripts/homebrew-generate-sidecars-from-env.sh \ + scripts/run-login-stack-local.sh scripts/measure-homebrew-vfork-rss.ts \ + host/test/homebrew-login-product.test.ts \ + scripts/homebrew-main-shell-image-contract.test.ts \ + scripts/homebrew-main-shell-node-smoke.ts \ + scripts/create-homebrew-guest-lifecycle-fixture.ts \ + apps/browser-demos/test/homebrew-login-lifecycle.spec.ts +git commit --author='Brandon Payton ' \ + -m "Homebrew: Compose the ABI 43 login product" +``` + +- [ ] **Step 12: Run the committed harness end to end** + +```bash +KANDELO_LOGIN_WORK_PARENT="$(mktemp -d)" +scripts/dev-shell.sh bash scripts/run-login-stack-local.sh \ + --tap-root "$KANDELO_TAP_ROOT" \ + --work-root "$KANDELO_LOGIN_WORK_PARENT/login-stack" \ + --browser-demo +``` + +Expected: command exits 0, all scripted lifecycle markers are present, all +three browser projects ran, reports identify local-test provenance, and the +image, mirror, detached source, and exact-head report remain in the work root. +If this gate finds a defect, apply the Gate Outcome Rule and rerun it against +the new committed `HEAD`; never relabel evidence from the earlier commit. + +### Task 21: Revalidate ordinary-fork admission, retirement, and alternatives + +**Files:** + +- Modify only if red: `host/src/process-memory.ts` +- Modify only if red: `host/src/process-memory-creator-gate.ts` +- Modify: `host/test/process-memory-allocator.test.ts` +- Modify: `host/test/process-memory-creator-gate.test.ts` +- Modify: `host/test/process-memory-reclamation-rss.test.ts` +- Modify: `host/test/fork-memory-clone-guest.test.ts` +- Modify: `host/test/multi-worker.test.ts` +- Modify: `benchmarks/measure-fork-memory-components.mjs` +- Modify: `docs/measurements/2026-07-31-affordable-fork-then-exec.md` + +**Interfaces:** + +- Consumes: exact memory ownership and retirement already in ABI 43 +- Produces: final-head evidence for pre-copy `EAGAIN`, actual-byte accounting, + bounded retirement fallback, sparse-clone decision, and Worker/module churn + +- [ ] **Step 1: Assert admission precedes allocation and copy** + +Saturate retired-memory debt with exact current byte lengths, spy on +`WebAssembly.Memory`, and call `acquireForkMemoryClone`. Require +`ProcessMemoryRetirementBacklogError` with errno 11, zero constructor calls, +unchanged parent bytes, no child PID/registration, and no extra retirement +record. Repeat for grown wasm32 and wasm64 memories. + +- [ ] **Step 2: Assert exact ownership and bounded fallback** + +Cover owner plus aliases, exec retirement, final exact release, forced +termination taint, optional `FinalizationRegistry` evidence, actual guest +growth, memory/byte thresholds, waiters, bounded time fallback, telemetry +trimming, and teardown. The timer may release admission debt after the +documented bound, but must never claim physical reclamation. + +- [ ] **Step 3: Run the ordinary-memory tests** + +```bash +scripts/dev-shell.sh bash -lc \ + 'cd host && npx vitest run \ + test/process-memory-allocator.test.ts \ + test/process-memory-creator-gate.test.ts \ + test/process-memory-reclamation-rss.test.ts \ + test/fork-memory-clone-guest.test.ts \ + test/multi-worker.test.ts' +``` + +Expected: all admission, ownership, reclamation, exact clone, and guest errno +cases PASS. Apply the Gate Outcome Rule to any failure before measuring. + +- [ ] **Step 4: Repeat component measurements twice** + +```bash +scripts/dev-shell.sh node benchmarks/measure-fork-memory-components.mjs \ + > /tmp/kandelo-fork-components-a.json +scripts/dev-shell.sh node benchmarks/measure-fork-memory-components.mjs \ + > /tmp/kandelo-fork-components-b.json +``` + +Record worker-only, module-worker, shared-memory Worker, full clone, and sparse +clone elapsed time and RSS. Sparse cloning is selectable only if the real +Homebrew lifecycle in Task 23 also lowers peak process-tree RSS without an +unacceptable latency/CPU increase on Node and browsers. Component results +alone do not authorize changing the production clone. + +- [ ] **Step 5: Record the final-head conclusion** + +Update the measurement document with exact commit/artifact hashes and current +results. Explicitly state whether Worker/module churn is material, whether +sparse cloning remains rejected, the admission thresholds, the fallback time +bound, and which data is allocation accounting versus physical RSS evidence. + +- [ ] **Step 6: Commit test or documentation changes** + +```bash +git add host/src/process-memory.ts \ + host/src/process-memory-creator-gate.ts \ + host/test/process-memory-allocator.test.ts \ + host/test/process-memory-creator-gate.test.ts \ + host/test/process-memory-reclamation-rss.test.ts \ + host/test/fork-memory-clone-guest.test.ts host/test/multi-worker.test.ts \ + benchmarks/measure-fork-memory-components.mjs \ + docs/measurements/2026-07-31-affordable-fork-then-exec.md +git diff --cached --quiet || \ + git commit -m "Fork: Revalidate memory admission and cloning costs" +``` + +### Task 22: Pass the post-integration vfork safety gate + +**Files:** + +- Modify: `host/test/vfork-lifetime.test.ts` +- Modify: `host/test/vfork-lifecycle-guest.test.ts` +- Modify: `host/test/fork-borrowed-replay.test.ts` +- Modify: `apps/browser-demos/test/vfork-lifecycle.spec.ts` +- Modify: `apps/browser-demos/test/borrowed-fork-replay.spec.ts` +- Modify: `programs/vfork-posix-state.c` +- Modify: `scripts/run-vfork-readiness.sh` +- Modify: `docs/measurements/2026-08-10-vfork-readiness.md` +- Modify if partial: `docs/future-improvements.md` +- Modify if partial: `docs/posix-status.md` + +**Interfaces:** + +- Consumes: credentials, prepared targets, spawn, secure startup, nosuid, and + existing exact `memory_quiescent` lifetime +- Produces: integration-readiness evidence and a truthful partial-vfork record + if browser forced termination still lacks an exact fence + +- [ ] **Step 1: Add child-only credential cases** + +From main-thread and pthread vfork callers, have the child change real, +effective, saved, and supplementary IDs, then exercise successful exec, +failed exec followed by `_exit`, direct `_exit`, trap, cooperative signal, and +forced external signal. Assert the parked parent retains its exact original +credential record and secure marker in every surviving path. + +- [ ] **Step 2: Add target lifetime cases under borrowed memory** + +Cover failed prepare, cancelled token, failed target read, mutation before +commit, successful set-ID commit, competing stale token, post-commit Worker +creation failure, and ledger drain during containment. Assert child control +state remains private and parent continuation cannot observe token scratch or +credential changes. + +- [ ] **Step 3: Re-audit every borrowed-memory start boundary** + +Instrument the lifecycle transition to distinguish: + +```text +pre_start -> child_may_access_memory -> memory_quiescent -> released +``` + +A pre-start failure may roll back with errno. After +`child_may_access_memory`, assert every normal parent release has exact +`memory_quiescent`. Inject timeout, removed process-map entry, resolved +`terminate()`, and unreachable Worker wrapper; none may release the parent. + +- [ ] **Step 4: Search for a portable exact forced-kill fence only within the +existing architecture** + +Test Node's awaited terminate/exit and the available Chromium, Firefox, and +WebKit Worker events. Accept a fence only when an event is specified and +observed after all accesses to the shared backing are impossible in all four +hosts. Do not use delay, polling, object reachability, or Node-only evidence. + +- [ ] **Step 5: Preserve and document containment if no exact fence exists** + +When the browser result remains negative, keep status 139 and whole-address- +space containment. Add a substantive future-work section recording: + +```text +missing guarantee: externally killed compute-bound borrower cannot publish + memory_quiescent +current behavior: loud whole-address-space containment, no parent resume +affected surfaces: browser Worker lifecycle; possible host/instrument ABI +acceptance proof: safe parent resume after external kill, exact quiescence, + Node/Chromium/Firefox/WebKit parity +``` + +Cross-link `vfork()` in `docs/posix-status.md` and call it partial for this +case. Do not describe containment as full POSIX vfork. + +- [ ] **Step 6: Run the integration gate** + +```bash +scripts/dev-shell.sh bash scripts/run-vfork-readiness.sh integration +``` + +Expected: no full child Memory allocation or copy, exact caller-thread +suspension, sibling progress, private control state, coherent failure and +terminal paths, original parent credentials, exact target cleanup, ordinary +fork independence, and all Node/Chromium/Firefox/WebKit tests PASS. The +external-kill case either proves an exact portable fence or proves containment +without unsafe resume. + +- [ ] **Step 7: Update exact-head evidence and commit** + +```bash +git add host/test/vfork-lifetime.test.ts \ + host/test/vfork-lifecycle-guest.test.ts \ + host/test/fork-borrowed-replay.test.ts \ + apps/browser-demos/test/vfork-lifecycle.spec.ts \ + apps/browser-demos/test/borrowed-fork-replay.spec.ts \ + programs/vfork-posix-state.c scripts/run-vfork-readiness.sh \ + docs/measurements/2026-08-10-vfork-readiness.md \ + docs/future-improvements.md docs/posix-status.md +git commit -m "Vfork: Preserve isolation across credentialed exec" +``` + +Do not stage `docs/future-improvements.md` or `docs/posix-status.md` when an +exact portable fence has made the partial-state text factually unnecessary. + +### Task 23: Run whole-batch validation, performance, and local demonstration + +**Files:** + +- Modify: `docs/architecture.md` +- Modify: `docs/abi-versioning.md` +- Modify: `docs/browser-support.md` +- Modify: `docs/homebrew-publishing.md` +- Modify: `docs/posix-status.md` +- Modify if surface changed: `docs/fork-instrumentation.md` +- Modify: `docs/measurements/2026-08-10-vfork-readiness.md` +- Create: `docs/measurements/2026-08-10-abi43-login-stack.md` + +**Interfaces:** + +- Consumes: exact Kandelo and tap heads after Tasks 1-22 +- Produces: whole-batch local readiness evidence; no hosted publication claim + +- [ ] **Step 1: Update authoritative documentation before validation** + +Document credentials, groups, secure exec, target transactions, nosuid mount +default, trusted projections, PTY ownership, browser login lifecycle, local +versus authorized bottle provenance, vfork status, and ABI 43 exports. Do not +mark hosted candidates, publication, pristine Ruby release, or full vfork +conformance complete unless the later evidence exists. + +- [ ] **Step 2: Rebuild every ABI-bound artifact** + +```bash +scripts/dev-shell.sh bash scripts/build-musl.sh +scripts/dev-shell.sh bash build.sh +scripts/dev-shell.sh bash scripts/build-programs.sh +scripts/dev-shell.sh bash scripts/check-abi-version.sh +``` + +Expected: clean builds and exact ABI snapshot/generated files. + +- [ ] **Step 3: Run Rust workspace and fork-instrument suites** + +```bash +scripts/dev-shell.sh bash -lc \ + 'host_target=$(rustc -vV | sed -n "s/^host: //p"); \ + cargo test --workspace --target "$host_target"' +scripts/dev-shell.sh bash -lc \ + 'host_target=$(rustc -vV | sed -n "s/^host: //p"); \ + cargo test -p fork-instrument --target "$host_target"' +``` + +Expected: PASS with no fork-instrument ABI/frame change. If the frame surface +did change, stop because the approved design forbids it. + +- [ ] **Step 4: Run complete host and reusable-session suites** + +```bash +scripts/dev-shell.sh bash -lc 'cd host && npx vitest run' +scripts/dev-shell.sh bash -lc \ + 'cd web-libs/kandelo-session && npx vitest run' +``` + +Expected: PASS with no skipped required fixture. + +- [ ] **Step 5: Run libc, POSIX, and Sortix conformance** + +```bash +scripts/dev-shell.sh bash scripts/run-libc-tests.sh +scripts/dev-shell.sh bash scripts/run-posix-tests.sh +scripts/dev-shell.sh bash scripts/run-sortix-tests.sh \ + process signal io paths pty +``` + +Expected: selected tests PASS or match existing documented non-compromising +xfails. Any new xfail needs root-cause evidence and design review; do not add +one merely to finish the batch. + +- [ ] **Step 6: Run Homebrew and local product evidence** + +```bash +scripts/dev-shell.sh bash scripts/test-homebrew-patched-launcher.sh +scripts/dev-shell.sh bash scripts/test-homebrew-inspect-bottle.sh +scripts/dev-shell.sh bash scripts/test-homebrew-tap-native-sidecars.sh +KANDELO_LOGIN_WORK_PARENT="$(mktemp -d)" +scripts/dev-shell.sh bash scripts/run-login-stack-local.sh \ + --tap-root "$KANDELO_TAP_ROOT" \ + --work-root "$KANDELO_LOGIN_WORK_PARENT/login-stack" \ + --browser-demo +``` + +Expected: Formula builds, sidecars, composition, Node lifecycle, closed +mirror, login/sudo/Ruby/brew lifecycle, and all local provenance checks PASS. + +- [ ] **Step 7: Run focused browsers and the complete browser suite** + +```bash +scripts/dev-shell.sh bash -lc \ + 'cd apps/browser-demos && npx playwright test \ + test/vfork-lifecycle.spec.ts \ + test/borrowed-fork-replay.spec.ts \ + test/prepared-exec-target.spec.ts \ + test/nosuid-exec.spec.ts \ + test/pty-ownership.spec.ts \ + test/login-terminal-session.spec.ts \ + test/sudo-lite.spec.ts \ + test/homebrew-login-lifecycle.spec.ts \ + --project=chromium --project=firefox --project=webkit' +scripts/dev-shell.sh bash -lc \ + 'cd apps/browser-demos && npx playwright test' +``` + +Expected: focused and complete browser suites PASS on applicable projects; +each platform-bound skip is named in the evidence report. + +- [ ] **Step 8: Validate browser assets and perform manual demonstration** + +```bash +scripts/dev-shell.sh bash scripts/ci-check-browser-assets.sh +./run.sh browser +``` + +Use the local harness's preserved image and mirror. Manually perform every +command in Task 20's lifecycle list and record browser engine, console errors, +renderer survival, and observed output. A code review or Playwright run does +not replace this manual check. + +- [ ] **Step 9: Run all performance suites before and after** + +Use the saved pre-login safety tip in an isolated worktree for `before` and the +current exact head in a second isolated worktree for `after`. Build each +worktree's own ABI-bound programs and application inputs; do not share build +outputs across the comparison: + +```bash +KANDELO_PERF_PARENT="$(mktemp -d)" +KANDELO_PERF_BEFORE="$KANDELO_PERF_PARENT/before" +KANDELO_PERF_AFTER="$KANDELO_PERF_PARENT/after" +KANDELO_PERF_EVIDENCE="$KANDELO_PERF_PARENT/evidence" +mkdir "$KANDELO_PERF_EVIDENCE" +git worktree add --detach "$KANDELO_PERF_BEFORE" \ + safety/abi43-pre-login-20260810 +git worktree add --detach "$KANDELO_PERF_AFTER" HEAD + +for KANDELO_PERF_SOURCE in \ + "$KANDELO_PERF_BEFORE" "$KANDELO_PERF_AFTER"; do + git -C "$KANDELO_PERF_SOURCE" submodule update --init --recursive + ( + cd "$KANDELO_PERF_SOURCE" + scripts/dev-shell.sh bash scripts/build-musl.sh + scripts/dev-shell.sh bash build.sh + scripts/dev-shell.sh bash scripts/build-programs.sh + scripts/dev-shell.sh bash -lc 'cd sdk && npm link' + scripts/dev-shell.sh bash packages/registry/php/build-php.sh + scripts/dev-shell.sh bash packages/registry/wordpress/setup.sh + scripts/dev-shell.sh bash packages/registry/wordpress/build-wordpress.sh + scripts/dev-shell.sh bash packages/registry/mariadb/build-mariadb.sh + scripts/dev-shell.sh bash packages/registry/mariadb/build-mariadb.sh \ + --wasm64 + scripts/dev-shell.sh bash \ + images/vfs/scripts/build-mariadb-vfs-image.sh + scripts/dev-shell.sh bash \ + images/vfs/scripts/build-mariadb-vfs-image.sh --wasm64 + ) +done + +KANDELO_BEFORE_NODE_RESULT="$( + cd "$KANDELO_PERF_BEFORE" + scripts/dev-shell.sh npx tsx benchmarks/run.ts --rounds=3 2>&1 | + tee "$KANDELO_PERF_EVIDENCE/before-node.log" | + sed -n 's/^Results saved to //p' | tail -n 1 +)" +test -f "$KANDELO_BEFORE_NODE_RESULT" +cp "$KANDELO_BEFORE_NODE_RESULT" \ + "$KANDELO_PERF_EVIDENCE/before-node.json" + +KANDELO_BEFORE_BROWSER_RESULT="$( + cd "$KANDELO_PERF_BEFORE" + scripts/dev-shell.sh npx tsx benchmarks/run.ts \ + --host=browser --rounds=3 2>&1 | + tee "$KANDELO_PERF_EVIDENCE/before-browser.log" | + sed -n 's/^Results saved to //p' | tail -n 1 +)" +test -f "$KANDELO_BEFORE_BROWSER_RESULT" +cp "$KANDELO_BEFORE_BROWSER_RESULT" \ + "$KANDELO_PERF_EVIDENCE/before-browser.json" + +KANDELO_AFTER_NODE_RESULT="$( + cd "$KANDELO_PERF_AFTER" + scripts/dev-shell.sh npx tsx benchmarks/run.ts --rounds=3 2>&1 | + tee "$KANDELO_PERF_EVIDENCE/after-node.log" | + sed -n 's/^Results saved to //p' | tail -n 1 +)" +test -f "$KANDELO_AFTER_NODE_RESULT" +cp "$KANDELO_AFTER_NODE_RESULT" \ + "$KANDELO_PERF_EVIDENCE/after-node.json" + +KANDELO_AFTER_BROWSER_RESULT="$( + cd "$KANDELO_PERF_AFTER" + scripts/dev-shell.sh npx tsx benchmarks/run.ts \ + --host=browser --rounds=3 2>&1 | + tee "$KANDELO_PERF_EVIDENCE/after-browser.log" | + sed -n 's/^Results saved to //p' | tail -n 1 +)" +test -f "$KANDELO_AFTER_BROWSER_RESULT" +cp "$KANDELO_AFTER_BROWSER_RESULT" \ + "$KANDELO_PERF_EVIDENCE/after-browser.json" + +( + cd "$KANDELO_PERF_AFTER" + scripts/dev-shell.sh npx tsx benchmarks/compare.ts \ + "$KANDELO_PERF_EVIDENCE/before-node.json" \ + "$KANDELO_PERF_EVIDENCE/after-node.json" | + tee "$KANDELO_PERF_EVIDENCE/node-comparison.md" + scripts/dev-shell.sh npx tsx benchmarks/compare.ts \ + "$KANDELO_PERF_EVIDENCE/before-browser.json" \ + "$KANDELO_PERF_EVIDENCE/after-browser.json" | + tee "$KANDELO_PERF_EVIDENCE/browser-comparison.md" +) +``` + +The four `test -f` checks make an aborted or skipped runner fail before a +comparison can be mislabeled. Preserve the evidence directory and record its +file hashes. Compare only common metrics as before/after evidence; name any +new or removed metric separately. Record Node and browser results plus Task +20's Node/Chromium process-tree RSS. Add Firefox/WebKit functional lifecycle +results; do not claim their RSS when the harness cannot measure a complete +engine process tree accurately. + +- [ ] **Step 10: Write the measured result without broadening claims** + +The dated measurement includes exact commits, artifacts, commands, statuses, +known skips, failure repairs, latency, RSS, retirement slopes, vfork no-copy +evidence, and the sparse-clone decision. Separate component, local product, +browser functional, and hosted release evidence. + +- [ ] **Step 11: Commit documentation and measured evidence** + +```bash +git add docs/architecture.md docs/abi-versioning.md \ + docs/browser-support.md docs/homebrew-publishing.md \ + docs/posix-status.md docs/fork-instrumentation.md \ + docs/measurements/2026-08-10-vfork-readiness.md \ + docs/measurements/2026-08-10-abi43-login-stack.md +git commit -m "Docs: Record ABI 43 login and vfork evidence" +``` + +Omit `docs/fork-instrumentation.md` from staging when the verified surface is +unchanged. + +### Task 24: Consume active staging, prove pristine Ruby, and finish PRs + +**Files:** + +- Modify only after staging lands: reviewed product/staging declarations named + by the landed `emdash/homebrew-pr-staging-1q1w6` interfaces +- Audit and modify if needed in tap: `Formula/ruby.rb` and its declared build + inputs +- Generated remotely: Ruby candidate metadata required by the landed staging + schema; do not edit it locally +- Modify: PR #1240 title and description +- Create: companion tap PR title and description + +**Interfaces:** + +- Consumes: active reviewed GitHub staging, exact final Kandelo/tap heads, + protected policy, authorized candidate artifacts, and local readiness +- Produces: pristine upstream CRuby candidates, final lifecycle/RSS evidence, + removal of PR #1166, and two reviewable rebase-merge PRs + +- [ ] **Step 1: Verify staging is landed and enforcing before use** + +Read the landed staging implementation and its approved roadmap. Confirm the +merge-gating workflow, exact-head request, required product manifest, evidence +schema, authorization, and promotion path are active rather than observe-only. +Do not copy or modify the old staging worktree. If it is not active, stop this +task; Tasks 1-23 remain locally complete but hosted/release readiness does not. + +- [ ] **Step 2: Add only the landed canonical product declarations** + +Declare ordinary login, sudo-lite, sudo, Ruby, and shell roots, +materialization, privileged projections, Node evidence, and browser evidence +through the landed schema. Let the tap planner resolve the exact tap snapshot +and dependency closure; do not add a hand-maintained Formula list or arbitrary +tap commit to the protocol. + +- [ ] **Step 3: Make PR #1166 removal an exact tap-source invariant** + +The current Kandelo history already contains Brandon's +`87d842814b050ba2c1acbaa880059b3d1aa0e321` pristine-CRuby selection. Audit +the migrated Ruby Formula and its entire declared build closure to ensure the +Homebrew move preserved that behavior. Reject the PR #1166 patch file or +digest, a `process.c` patch, `ac_cv_func_vfork=no`, missing working-vfork +configure assertions, or a source archive other than the pinned upstream +CRuby release. + +If migration already removed every residue, record the exact clean tap commit +and make no empty or cosmetic commit. Otherwise remove only the temporary +patch selection, preserve all ordinary build inputs, increment the Formula +revision, and commit with Brandon as author because this is the Homebrew +forward port of his existing removal: + +```bash +git add Formula/ruby.rb +git commit --author='Brandon Payton ' \ + -m "Ruby: Remove the temporary Kandelo spawn patch" +``` + +Do not hand-edit a bottle stanza, sidecar, candidate record, selection lock, +or published metadata in this commit. + +- [ ] **Step 4: Repeat the complete local-test proof on pristine Ruby** + +If Step 3 changed the tap commit, update Kandelo's migration lock and commit +that exact selection independently: + +```bash +git add homebrew/main-shell-migration-lock.json +git commit --author='Brandon Payton ' \ + -m "Homebrew: Select the pristine Ruby tap revision" +``` + +Then rerun `scripts/run-login-stack-local.sh` from Task 20. Require the pinned +extracted source tree to match upstream before configure and require +`HAVE_VFORK`, `HAVE_WORKING_VFORK`, and `HAVE_WORKING_FORK` afterward. As uid +1000, Ruby's eligible fork-then-exec route must invoke vfork mode and construct +no child process Memory. The root/privileged route must use ordinary fork, +construct a distinct copied child Memory, and obey retirement admission. Keep +all outputs `local-test` and non-promotable. + +- [ ] **Step 5: Request exact-head candidates through reviewed workflows** + +The request binds PR #1240 head SHA, ABI 43, protected policy, the exact +pristine-Ruby tap commit, required products, Formulae, sidecars, Node/browser +evidence, and authorization identity. Review the resulting candidate metadata +and verify that every bottle was built by GitHub's isolated Formula builder. +Do not upload, relabel, or promote any local-test byte. + +- [ ] **Step 6: Run the exact hosted Homebrew lifecycle and RSS proof** + +Against candidate bottles, perform real in-guest tap/install/execute for Ruby +and the complete closure, repeat at least three times, and record Node and +Chromium process-tree baseline/peak/post-reap RSS, renderer survival, parent +suspension, and fork mode. Run Firefox and WebKit functional coverage wherever +the platform path applies. Rerun ABI, libc, POSIX, Sortix, host, browser, +fork-instrument, and performance suites on the exact candidate head. Compare +the hosted result with Step 4's local proof, verify anonymous readback, and +promote only these fresh pristine-Ruby bytes. This is the required rebuild and +repeat of the exact lifecycle after #1166 removal; no earlier patched or local +artifact may satisfy it. + +- [ ] **Step 7: Verify linear history and contributor attribution** + +```bash +git log --merges origin/main..HEAD +git log --format=fuller origin/main..HEAD +git range-diff \ + 8a66801e6^..ebde50611 \ + safety/abi43-pre-login-20260810..HEAD +git range-diff \ + c44ae8019^..3e30a7765 \ + safety/abi43-pre-login-20260810..HEAD +git diff --check origin/main...HEAD +``` + +Expected: zero merge commits, purpose-scoped commits, Brandon authorship on +derived work, current agent as committer where applicable, and no whitespace +errors. The first range names the two VFS source commits and the second names +the twelve login source commits. Inspect all fourteen directly when evaluating +the range diffs; divergent topology is expected, lost attribution is not. + +- [ ] **Step 8: Update PR #1240 as an explicit batch PR** + +Use a title no broader than: + +```text +ABI: Batch ABI 43 process, VFS, login, sudo, and vfork changes +``` + +The description begins with `## Why`, lists every conceptual commit and source +PR/branch, explains ABI 43 and target removal, names the vfork partial boundary +if retained, links exact validation/evidence, names unrun or hosted-only gates, +and places this warning near the top and merge instructions: + +```text +MUST be merged with rebase commits. DO NOT squash or create a merge commit. +``` + +Wrap prose to 72 columns. Do not claim that a PR is merge-gated until the +required staging lane is active. + +- [ ] **Step 9: Open the companion tap PR** + +Begin with `## Why`, list login, sudo-lite, upstream sudo, Ruby patch removal, +Formula sources, narrow `__WALL` portability patch, candidates, and exact +validation. Explain that local-test bottles were never promoted and that final +candidates bind the exact Kandelo ABI 43 head. Require rebase commits if the +tap PR also contains multiple conceptual commits. + +- [ ] **Step 10: Stop before merge for Brandon's approval** + +Report exact PR URLs, head SHAs, commit list, attribution audit, validation, +remaining skips, candidate identities, and Ruby patch-removal proof. Do not +merge the ABI, kernel, libc, host, VFS security, fork-instrument, Kandelo PR, +or companion tap PR without Brandon's explicit approval. + +--- + +## Final Completion Checklist + +- [ ] Pre-login safety reference resolves to the exact approved tip. +- [ ] Both baseline fixture defects are repaired independently. +- [ ] VFS generic materialization contains no Homebrew policy vocabulary. +- [ ] Writable and identity-unstable mounts enforce and report `nosuid`. +- [ ] Privileged programs are unique root-owned regular inodes with no + writable aliases. +- [ ] Saved IDs, supplementary groups, permission checks, and fork state are + authoritative and bounded. +- [ ] ABI 43 exposes only target-aware exec commit and secure-startup paths. +- [ ] `fexecve` and empty-path `execveat` retain the exact OFD through rename, + unlink, close, and path replacement. +- [ ] Spawn RESETIDS, attributes, actions, target, commit, and launch order is + proven without side-effect replay. +- [ ] Secure images set musl security state before constructors and repair + every closed standard descriptor. +- [ ] Metadata mutation, devpts identity, poll interruption, login, sudo-lite, + and upstream sudo pass through normal platform paths. +- [ ] Every logical browser PTY autologins once, then runs ordinary login with + bounded generation-safe restart. +- [ ] vfork mechanism and integration gates prove no child memory allocation + or copy, exact caller suspension, private control state, coherent failures, + and ordinary-fork independence. +- [ ] Browser external-kill behavior has an exact portable fence or remains + loudly contained and substantively documented as partial future work. +- [ ] Ordinary fork admits before allocation, charges actual bytes, returns + truthful `EAGAIN`, and retains bounded documented retirement fallback. +- [ ] Sparse cloning and Worker/module churn conclusions use real RSS and are + not promoted from component measurements alone. +- [ ] Local Homebrew lifecycle and interactive browser demo finish entirely + from exact `local-test` inputs without remote mutation. +- [ ] Whole Rust, ABI, host, browser, libc, POSIX, Sortix, fork-instrument, + Homebrew, performance, RSS, and manual validation evidence is recorded. +- [ ] Active hosted staging builds final candidates from exact reviewed heads. +- [ ] Pristine upstream Ruby uses vfork as uid 1000 and ordinary fork as root. +- [ ] PR #1166 is removed only from a rebuilt, republished, re-proven Ruby + candidate. +- [ ] Kandelo and tap PRs preserve conceptual commits and require rebase merge, + never squash. diff --git a/docs/superpowers/specs/2026-08-10-abi43-login-sudo-vfork-integration-design.md b/docs/superpowers/specs/2026-08-10-abi43-login-sudo-vfork-integration-design.md new file mode 100644 index 0000000000..b4bc690bb7 --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-abi43-login-sudo-vfork-integration-design.md @@ -0,0 +1,964 @@ +# ABI 43 Login, Sudo, and vfork Integration + +## Status + +Approved by Brandon Payton on 2026-08-10. This document specifies the +forward-port architecture. It does not claim that the login stack, its +Homebrew bottles, the remaining vfork development, or the final vfork release +proof have been completed. + +The implementation target is the curated, linear ABI 43 integration branch +`integration/abi43-batch-linear-20260801` and its batch pull request, #1240. +The login source behavior is the final twelve-commit stack on +`emdash/support-logins-8yaz3`. The VFS prerequisite comes from the two commits +on `emdash/vfs-decouple-from-homebrew-mdx5x`. Both branches were built before +substantial current Homebrew and ABI 43 work and therefore cannot be merged or +cherry-picked as units. + +## Context + +ABI 43 already contains Kandelo's genuine vfork architecture candidate. A +vfork child has an independent kernel process record, syscall channel, replay +workspace, loader state, and continuation controls while borrowing the +parent's exact `WebAssembly.Memory`. The calling parent thread remains +suspended until the child successfully execs or exits, while other parent +pthreads remain runnable. This avoids the full address-space allocation and +copy used by ordinary fork without weakening ordinary fork semantics. It is +the baseline this work must finish auditing, repair wherever evidence finds a +defect, and preserve. Its presence is not itself a completion claim. + +The login source stack adds the platform behavior needed for real unprivileged +sessions: saved credentials, supplementary groups, set-ID exec, `nosuid`, +login, a small first-party sudo implementation, upstream sudo, PTY ownership, +browser autologin, and set-ID invalidation after file mutations. Its original +implementation predates the ABI 43 vfork transaction, exact memory ownership, +current exec handoff, current host entry gate, and the completed migration from +the Kandelo package registry to Homebrew. + +The current VFS materialization path also still recognizes Homebrew-specific +decoder, receipt, relocation, Cellar, keg, and prefix concepts inside +`MemoryFileSystem`. That is the wrong foundation for mount security. Generic +VFS code must own archive truth, projections, transformations, metadata, and +publication; a Homebrew adapter must own Homebrew policy. + +The current batch also has two unrelated CI fixture failures that must be +repaired before feature work is used as validation evidence: + +1. An isolated Homebrew launcher source fixture copies `run-example.ts` but + omits its newer `run-example-vfs.ts` dependency. +2. The generated fork-instrumentation fixture imports the pre-ABI-43 + zero-argument `kernel_fork`; ABI 43 requires the explicit fork-mode + argument. The resulting artifact has one structurally invalid signature. + +Neither failure is evidence of a vfork semantic defect. They remain required +baseline repairs. + +## Goals + +The forward port must: + +1. provide real saved UID/GID and supplementary-group state; +2. implement truthful credential-changing and group syscalls; +3. apply set-user-ID and set-group-ID exec transitions transactionally; +4. preserve credentials across ordinary fork, vfork, exec, posix_spawn, + pthread callers, wait, and reaping; +5. finish developing and testing vfork's no-copy memory architecture, parent + suspension, private control state, rollback, and lifecycle behavior; +6. remove Homebrew policy from generic VFS materialization before building + permission and mount security on that path; +7. enforce `nosuid`, ownership, permission, and set-ID invalidation behavior + through authoritative VFS state; +8. support real login, sudo-lite, and upstream sudo through normal Kandelo + syscalls, PTYs, signals, waits, and Homebrew bottles; +9. give Node.js and browser hosts the same observable platform behavior; +10. provide a complete local build, test, and interactive demonstration path + before GitHub publication; and +11. finish the Homebrew/Ruby vfork release proof on the final integrated tree. + +## Non-Goals + +This work will not: + +- restore or add Kandelo package-registry recipes; +- add broad Linux or System V compatibility; +- add Linux clone-child classes merely to accept sudo's `__WALL` flag; +- special-case Ruby, Homebrew, login, or sudo in the kernel or host runtime; +- modify upstream CRuby or broaden the temporary PR #1166 patch; +- reinterpret ordinary fork as vfork; +- replace the genuine vfork child Worker with an unproved same-worker design; +- publish or activate candidate bottles before local validation; +- merge kernel, ABI, libc, host-runtime, or fork-instrument changes; or +- squash the batch into one commit. + +## Chosen Approach + +Forward-port behavior into the current ABI 43 architecture in independently +reviewable layers. Preserve the original author's attribution, but do not +preserve obsolete mechanics merely to make old patches apply. + +Three alternatives are rejected: + +1. **Merge or cherry-pick the old branch wholesale.** This would restore ABI + 42 bindings, registry recipes, older exec handoff code, and older process + ownership assumptions over the current vfork implementation. +2. **Port only kernel credentials.** This would leave no normal distribution + path or end-user proof for login and sudo, and would not satisfy the request + to bring the complete source stack into the ABI 43 batch. +3. **Add permissions to the Homebrew-aware MemoryFS and decouple it later.** + This would make receipt and keg policy part of a security boundary, then + require a second risky rewrite of set-ID ownership and mount decisions. + +The current pre-login batch tip must remain recoverable through its existing +remote branch and pull request. Before implementation changes begin, create an +additional clearly named safety reference for the exact pre-login commit. + +After the baseline fixture repairs, the first feature phase will forward-port +the two VFS commits. Generic materialization lands before mount security; +mount security lands before privileged projections; credential-aware exec +then consumes those authoritative objects. Obsolete package-registry changes +from either source branch are excluded. + +## Process Credential Model + +`Process` will own the authoritative credential record: + +- real, effective, and saved user IDs; +- real, effective, and saved group IDs; and +- an ordered supplementary-group vector bounded by Kandelo's declared + `NGROUPS_MAX` of 32. + +Fresh root processes initialize every real, effective, and saved ID to zero. +An explicitly configured top-level UID or GID initializes all three IDs on that +side to the configured value. Supplementary groups start empty unless an +explicit process-creation contract supplies them. Login initializes the group +set through the normal `setgroups` syscall before dropping privileges. + +The `setuid`, `seteuid`, `setgid`, `setegid`, `setresuid`, and `setresgid` +implementations will use the real, effective, and saved sets rather than the +current simulated aliases. `getresuid` and `getresgid` will return the stored +sets. `getgroups` will return exactly the supplementary groups; it will return +the required count for a zero-size query and `EINVAL` when a nonzero caller +capacity is too small. Only an effective UID of zero may call `setgroups`. + +File access checks will consider the effective primary group and every +supplementary group. Signal permission, process inspection, ownership checks, +sticky-directory behavior, and any other credential consumers must continue +to read the same authoritative process record. A feature-specific secondary +credential cache is forbidden. + +Credential-changing syscalls are atomic process-wide transitions under the +kernel entry gate. After a successful call returns, every surviving pthread +observes the new record; Kandelo does not provide per-thread credentials. + +`Process` will also own an image-scoped secure-execution marker that guest +arguments and environment variables cannot set. A successful exec commit sets +it when the final real and effective user or group IDs differ, or when a future +reviewed platform policy explicitly requires secure startup. It is not cleared +by a later credential change within the same image. The marker exists to give +libc the secure-startup fact that native systems normally convey through +`AT_SECURE`; it is not a second credential authority. + +## Fork, vfork, and Exec State + +Ordinary fork and vfork inherit the complete credential record. The fork/exec +state format will advance from version 14 to version 15, which serializes the +saved IDs, a bounded supplementary-group list, and the secure-execution marker. +Deserialization will reject the wrong version, a group count above 32, +truncated data, and trailing or otherwise malformed state according to the +existing exact parser contract. + +The vfork child already owns a separate `Process`. Adding credentials to that +record must not move credentials into borrowed Wasm memory or any parent-owned +host object. A child-side credential transition may therefore change only the +child process record. The suspended parent must observe its original +credentials after child exec, `_exit`, failed exec, trap, signal termination, +or containment teardown. + +Nested vfork, ordinary fork, posix_spawn, or pthread creation from a live +vfork child remain rejected according to the existing vfork admission rules. +Side modules observe credentials through the kernel process, not through +module-local state, so no side-module credential copy is introduced. + +## vfork Readiness and Remaining Development + +The connected ABI 43 implementation is the starting point, not an assumption +that vfork is finished. Before credential or login behavior changes it, a +baseline readiness gate will build the current guest fixtures and run the +focused kernel, host, fork-instrument, Node.js, Chromium, Firefox, and WebKit +tests. Skipped tests caused by missing fixtures are not evidence. Every +reproduced defect must first receive a focused failing test and then a +purpose-scoped fix. + +That gate must establish all of the following on the current tree: + +- a vfork child neither constructs a full process `WebAssembly.Memory` nor + copies the parent's address space; +- the calling main thread or pthread remains parked until successful exec or + exact terminal teardown, while sibling parent pthreads remain runnable; +- the child's syscall channel, mutable imported globals, replay prefixes, + loader state, and continuation controller cannot overwrite the parent's + state; +- failed exec returns only to the child and leaves the lifetime coherent; +- successful exec, `_exit`, cooperative signal death, trap, and Worker crash + settle exactly once without prematurely resuming or permanently wedging the + parent; +- descriptor and open-file-description sharing, cwd, credentials, signals, + process group and session state, parentage, zombies, waits, and reaping + retain their ordinary process semantics; +- main-thread and pthread callers, repeated lifetimes, rejected overlap and + nesting, side modules, fork-instrument replay, and allocation or launch + rollback are covered; and +- ordinary fork remains a separate copied-address-space operation with its + own admission and retirement behavior. + +The implementation must also re-audit every point at which a child Worker can +start touching the borrowed memory. A pre-start failure may return an errno +and roll back. After that point, only an exact child-generated +`memory_quiescent` fence can authorize parent resumption or final alias +retirement. A timeout, a resolved host API call, absence from a process map, +or a Worker object becoming unreachable is not equivalent evidence. + +One known boundary requires an explicit safety rule. A compute-bound vfork +child cannot process a cooperative signal message or return to its wrapper to +publish `memory_quiescent`. The browser `Worker.terminate()` API returns +`undefined`, and the +[HTML Standard](https://html.spec.whatwg.org/multipage/workers.html#dom-worker-terminate-dev) +runs worker termination in parallel with the worker's main loop; it exposes no +completion event that Kandelo can use as a cross-engine memory-quiescence +fence. Node.js has a stronger, awaitable `worker.terminate()` and final `exit` +event in its +[Worker API](https://nodejs.org/api/worker_threads.html#workerterminate), but +correctness cannot depend on a Node-only guarantee when browser hosts expose +the same Kandelo process model. + +The current safe response to an externally forced fatal signal in that state +is whole-address-space containment: terminate the borrower and every process +Worker that could resume into the shared backing, emit a loud diagnostic, and +exit the affected process tree with status 139. The implementation phase will +look for a portable exact fence using the existing Worker architecture and +test it across Node.js, Chromium, Firefox, and WebKit if one exists. It must +never resume the parent merely because `terminate()` returned or a bounded +delay elapsed. + +If no portable exact fence exists, the batch retains containment, keeps +`vfork()` documented as partial for this asynchronous external-kill case, and +does not claim complete POSIX vfork conformance. Adding cooperative Wasm +safe-point instrumentation, a new coordinator architecture, or another ABI +protocol solely to close that boundary requires a revised design and explicit +approval; it is not smuggled into this already large batch. + +That partial state may ship only with an explicit, substantive follow-up in +`docs/future-improvements.md`, cross-linked from the `vfork()` entry in +`docs/posix-status.md`. The future-work entry must record the exact missing +guarantee, why current browser Worker APIs cannot provide it, the safe +containment behavior users observe meanwhile, likely ABI/host/instrumentation +surfaces, and the acceptance evidence required to remove the limitation. At a +minimum, that evidence includes an externally killed compute-bound borrower, +safe parent resumption, exact shared-memory quiescence, Node/browser parity, +and Chromium, Firefox, and WebKit coverage. A one-line backlog note or a +statement that the problem is merely a browser limitation is not sufficient. + +After the baseline gate, the credential, prepared-target exec, secure-startup, +and set-ID changes must rerun the same vfork matrix. In particular, child-only +credential transitions, failed prepared targets, post-commit launch failures, +secure marker transport, and target-ledger cleanup must leave the parked +parent's original credentials and continuation state intact. + +## Transactional Set-ID Exec + +The ABI 42 source implementation changed active credentials during an exec +preflight. That is not sufficient for ABI 43: a later program-load, +address-space, or worker-start failure must not leak a privilege transition +back into the old image or a failed vfork lifetime. + +ABI 43 will use one prepared-target transaction rather than passing a pathname +as executable authority. This closes a gap in the old design: `fexecve()` and +`execveat(AT_EMPTY_PATH)` name an open file description (OFD), which can remain +valid after its last pathname is renamed or unlinked. Re-resolving its +remembered pathname could load or grant credentials from a different file. + +The kernel will own an opaque `PreparedExecTarget` token. For ordinary exec, a +token is bound to the caller TID and current process execution generation. For +posix_spawn, it is bound to the pending child and the exact parent launch +transaction. In both cases it names either: + +- the final file opened by `execve()` or pathname-based `execveat()`; or +- a retained reference to the exact OFD supplied to + `execveat(AT_EMPTY_PATH)` or `fexecve()`. + +The remembered pathname is diagnostic data only. Closing, renaming, or +unlinking the user-visible descriptor after preparation cannot redirect or +invalidate the retained target. The target lease has its own lifetime and is +released exactly once by commit, explicit cancellation, process death, or +exec rollback. + +The shared kernel/host interface will expose these ABI 43 operations. Here, +`usize` follows kernel-memory pointer width; path bytes are copied into leased +kernel scratch before entry: + +```text +kernel_exec_target_prepare( + pid: u32, caller_tid: u32, dirfd: i32, + path_ptr: usize, path_len: usize, flags: u32 +) -> i32 +kernel_spawn_exec_target_prepare( + parent_pid: u32, child_pid: u32, + path_ptr: usize, path_len: usize +) -> i32 +kernel_exec_target_size(owner_pid: u32, target: u32) -> i64 +kernel_exec_target_read( + owner_pid: u32, target: u32, + offset_lo: u32, offset_hi: i32, + buffer_ptr: usize, buffer_len: usize +) -> i32 +kernel_exec_target_cancel(owner_pid: u32, target: u32) -> i32 +kernel_exec_commit(pid: u32, caller_tid: u32, target: u32) -> i32 +kernel_spawn_exec_commit( + parent_pid: u32, child_pid: u32, target: u32 +) -> i32 +``` + +The first prepare operation resolves the syscall's path, directory descriptor, +and flags in kernel state, validates the caller, opens or retains the final +regular file, and returns a positive target token or negative errno. For +ordinary `execve`, the host supplies `AT_FDCWD` and zero flags. The spawn +variant resolves after file actions against the pending child's authoritative +CWD and credentials. Size and read access the retained object without changing +an OFD's file offset. Cancel releases an uncommitted target. The two commit +operations consume the target; the spawn form applies the same credential +transition without inventing a caller thread in a process Worker that has not +started. + +The concrete snapshot signatures will use fixed-width kernel-memory pointers, +split 64-bit file offsets where JavaScript-number precision would otherwise be +ambiguous, and a nonzero 32-bit target token. Tokens are never reused within +their owning exec or spawn generation; exhaustion fails before allocation +rather than wrapping onto live authority. The shared +`CentralizedKernelWorker` wrapper owns marshalling so Node.js and browsers +cannot drift. + +The transaction is: + +1. While the old image remains live, the kernel prepares the exact target. + The host may use a non-authoritative resolved-path hint only to await + deferred VFS materialization. The final open or retained OFD, not that hint, + supplies identity; if the final binding names a different lazy object, the + host materializes and restarts target preparation before compilation. +2. The host reads the complete program through the target token, validates its + ABI, and compiles it. A prepared target retains the source bytes, final + `fstat` identity, mount identity, and a backend generation or equivalent + executable lease. +3. For a shebang, the host prepares the interpreter as a second target and + launches that final binary. Kandelo does not honor set-ID bits on script + files; only the final binary target can change credentials. +4. Existing fallible caller validation runs before the irreversible image + transition. Posix_spawn keeps its side-effect-free program preflight, then + creates the pending child. The child inherits the complete credential + record, applies `POSIX_SPAWN_RESETIDS` by setting effective IDs to real IDs, + applies the remaining process attributes, and performs file actions exactly + once. Supplementary groups remain inherited, as POSIX requires; callers + such as login must use `setgroups()` for a complete group transition. Spawn + then prepares the authoritative target in that child's resulting + descriptor, credential, and CWD state. If the final bytes differ from + preflight, the host recompiles the final target without replaying file + actions. The child is not launched until `kernel_spawn_exec_commit` + consumes that same target. +5. Replacement memory allocation and address-space writeback/detach preflight + complete without discarding the old image. +6. Immediately before commit, the shared host layer revalidates the retained + target against its prepared source. Immutable image and bottle backends pin + an exact generation. A mutable backend must either hold an executable lease + that prevents a conflicting mutation or compare the exact bytes again. A + backend that cannot prove a stable set-ID target fails before privilege is + granted. A conflicting mutable executable returns `ETXTBSY`; a backend that + cannot supply stable object identity returns `ENOTSUP`. Host-provided + bootstrap program maps are never credential-bearing. +7. Without an event-loop yield or another guest syscall between final + revalidation and commit, the kernel verifies the token, caller and process + generations, retained file identity, execute permission, current mode and + ownership, and the owning mount's `nosuid` state. It computes proposed + effective and saved IDs in local temporary state. +8. Only after every fallible check succeeds does `kernel_exec_commit` close + close-on-exec descriptors, reset exec-sensitive process state, and install + the proposed credentials and secure-execution marker. The target token is + consumed whether commit succeeds or returns an errno. +9. The host retires the old execution generation and launches the replacement. + A vfork parent resumes only through the existing successful-exec or terminal + child-release path. + +Only one target can commit for an execution generation. If pthreads race two +exec calls, the first successful commit advances the generation and atomically +invalidates every competing token. A failed precommit attempt cancels only its +own token. Process exit, signal death, vfork containment, host teardown, and a +trapped kernel entry drain or invalidate the complete target ledger so no open +handle or privilege authority survives its owner. + +There will be no persistent partially applied credential transition and no +compatibility fallback to a targetless exec setup. The ABI 43 host will require +the prepared-target exports. The path-only `host_exec` import and public +targetless `kernel_exec_setup` and `kernel_exec_setup_for_thread` exports will +be replaced rather than retained as set-ID or OFD-identity bypasses. The +Node.js and browser `onExec` callbacks and Worker messages carry the opaque +target transaction, not a `credentialPath`. Direct `execve`, `execveat`, +`fexecve`, main-thread callers, pthread callers, and vfork children must +converge on `kernel_exec_commit`. Posix_spawn uses its explicit child commit +export and the same internal target-validation and credential-transition +implementation. + +Before the commit, any failure returns the truthful errno and leaves the old +image's credentials intact. After the commit, a replacement-worker failure is +a fatal exec failure for that process; it must release a vfork parent through +the existing fatal-child path and must never resume the discarded child image. + +## ABI 43 Impact + +The login forward port is ABI-affecting. The ABI 43 batch will explicitly +include: + +- the new fork/exec state version and credential fields; +- the prepared-target exports, target token, target-aware exec commit, and + pending-child spawn commit; +- replacement of the path-only `host_exec` and `onExec` protocol with exact + prepared-target authority shared by Node.js and browser workers; +- the guest-facing `kernel_get_secure_exec() -> i32` startup import and the + image-scoped process field that supplies it; +- complete `getgroups` and `setgroups` scratch descriptors; +- supplementary-group limits used across kernel, host, and libc; and +- the removal of the targetless exec setup exports. + +`ABI_VERSION` remains 43 because this epoch has not been released. The ABI 43 +documentation must enumerate these additions, and `abi/snapshot.json`, the +generated TypeScript bindings, libc constants, fixtures, and consumers must be +regenerated together. Snapshot checks remain mandatory even where the fork +state buffer is semantic ABI that the structural snapshot cannot describe. + +This work does not require a new vfork import, a new fork mode, new +fork-instrument frame metadata, or a change to shared-memory ownership. It +extends process state and exec handoff within the already selected explicit +vfork transaction. If implementation evidence contradicts that assessment, +work stops and the ABI design is revised before code proceeds. + +No new wait-option ABI constant is authorized by this design. In particular, +Linux `__WALL` remains outside the platform contract. + +## Syscall Channel Marshalling + +The current host has a special one-group `getgroups` handler. It will be +replaced by a bounded ABI descriptor for the caller's requested `gid_t` array. +The buffer is an in/out transfer: copying the caller's requested range into +scratch and back preserves unused trailing entries while the kernel writes +only the returned group count. A zero-size query lends no destination. A +nonzero null destination returns `EFAULT`, insufficient caller capacity +returns `EINVAL`, and a count above `NGROUPS_MAX` is rejected before scratch +allocation. + +`setgroups` will lend exactly `count * sizeof(gid_t)` input bytes under the same +bound. Wasm32 and Wasm64 tests must prove pointer-width-independent behavior, +zero-length handling, insufficient capacity, maximum capacity, and malicious +counts. Node and browser hosts must share the same marshalling implementation +or equivalent behavior with an explicitly tested boundary. + +## Secure Libc Startup + +Kandelo's Wasm musl startup currently has no auxiliary vector and leaves +musl's `libc.secure` false. Set-ID execution makes that unsafe: libc facilities +such as `secure_getenv`, locale and message-catalog lookup, and timezone lookup +must not treat an untrusted environment as ordinary process configuration. + +The ABI 43 musl overlay will import +`kernel_get_secure_exec() -> i32` during `__init_libc`. The kernel returns only +zero or one from the authoritative process marker; a missing import is an ABI +mismatch, not a fallback to insecure startup. When the result is one, musl +sets `libc.secure` before any application constructor or `main` runs. + +Secure startup will also validate descriptors 0, 1, and 2. Every closed slot +is filled with a descriptor for `/dev/null` using the normal VFS and syscall +path before privileged application code runs. This prevents a later +security-sensitive open from unexpectedly becoming standard input, output, or +error. Failure to establish those descriptors terminates startup rather than +continuing with an ambiguous descriptor table. + +Static Wasm does not need a native ELF dynamic-loader policy, and this design +does not invent one. It does require musl's existing secure consumers to see +the correct marker. Tests will cover `secure_getenv`, `issetugid`, locale, +message-catalog and timezone path handling, all combinations of missing +standard descriptors, ordinary non-set-ID exec, set-ID exec, `nosuid`, and +posix_spawn with and without `POSIX_SPAWN_RESETIDS`. + +## Generic VFS Materialization Prerequisite + +The permissions work will first forward-port the two Brandon-authored commits +from `emdash/vfs-decouple-from-homebrew-mdx5x` into the current architecture: + +1. `8a66801e6353bed9ff55fa1dc5e3b7e1b0b53e24` makes the authenticated + receipt destination, rather than a runtime default, authoritative for an + immutable bottle's guest prefix. +2. `ebde506115e7b4bfe26a5eaf0b7d097c3e1ee939` moves Homebrew policy out of + generic VFS materialization. + +This is a behavioral forward port, not a merge or mechanical cherry-pick. The +source branch diverged before substantial Homebrew migration work and includes +obsolete package-registry files. Only the two conceptual changes, their +applicable tests, and their documentation will be adapted to current paths. +The derived commits retain Brandon Payton as author and the forward porter as +committer. + +`MemoryFileSystem` will accept only the generic closed contract: + +- `zip-v1` or `tar-gzip-v1` decoding; +- a complete `archive-source-inventory-v1` source inventory; +- exact source-to-destination entries and inode groups; +- optional bounded `archive-byte-transforms-v1` assertions and replacement + recipes with exact input and output identities; and +- generic integrity, ownership, activation, cancellation, rollback, and + atomic-publication state. + +The generic layer will contain no receipt discovery, `changed_files`, Formula, +Cellar, keg, bottle-prefix, or Homebrew relocation markers. Transformation +plans are inert, bounded data rather than callbacks, regular expressions, +plugins, or scripts. + +The Homebrew-owned adapter validates the exact receipt, destination prefix, +keg mapping, changed-file set, canonical hard-link sources, and relocation +recipe. Only after validation does it erase Homebrew vocabulary into the +generic archive inventory, projection, and transformation plan. Eager and lazy +materialization use the same recipe and exact source/output identities. + +The current runtime-layer descriptor is schema 5. This forward port will use +schema 6 for the incompatible relocation-plan contract and fail closed on a +schema-5 bottle that needs receipt relocation. Schema-4 ZIP artifacts remain +readable. If another reviewed runtime-layer evolution lands first, the work +will take the next unused schema rather than assigning two meanings to one +number. This descriptor schema is distinct from Kandelo's kernel ABI version. + +The generic projection contract preserves regular files, directories, +symbolic links, and hard links for ordinary trees. Privileged product entries +are a narrower consumer: each must be an independently created regular-file +inode. A privileged projection may resolve a bottle hard link to its canonical +regular source, but it may not preserve that inode identity, use a symbolic +link, or share a hard link with the guest-writable bottle tree. + +## VFS, Mount, and Metadata Semantics + +Mount configuration will carry set-ID execution as an explicit capability. +Mounts default to `nosuid`. A mount may honor set-ID bits only when product +policy identifies it as trusted, root-owned, non-guest-writable, and able to +provide stable executable identity through the complete prepare/commit +transaction. A writable or identity-unstable backend is forced to `nosuid` +even if malformed input asks otherwise. + +`statfs` reports `ST_NOSUID` for such mounts in both Node and browser hosts. +Set-ID exec consults this authoritative state at the target-aware commit +boundary and ignores both set-user-ID and set-group-ID bits on a `nosuid` +mount. Unknown mounts and host bootstrap program maps cannot grant set-ID +execution. + +The existing ABI 43 branch already contains part of the old branch's chown and +set-ID invalidation behavior. The forward port will audit and retain those +correct pieces rather than replaying them. Missing behavior will cover host +files, SharedFS, path and descriptor operations, writes, truncation, ownership +changes, and metadata-only operations. Regular-file set-ID bits must not +survive a content or ownership mutation when Kandelo's documented security +semantics require clearing them. + +Every backend must expose the same observable uid, gid, mode, and invalidation +result. Metadata must not be corrected only in the browser image or only in a +Node side table. + +Lazy materialization into a trusted projection is an internal publication of +already authenticated bytes, not a guest write, and preserves the reviewed +owner and mode. Any later guest-visible write, truncate, ownership change, or +other qualifying mutation follows ordinary permission checks and set-ID +invalidation rules. Product validation proves that the privileged inode has no +writable alias before the image is accepted. + +## PTY, Signal, and Wait Behavior + +Devpts slave nodes will retain their owner, group, and mode for the lifetime of +the PTY pair. Path and descriptor stat, chmod, chown, and open permission checks +must agree. Login and sudo will use the normal controlling-terminal, session, +process-group, signal, poll/select, and wait paths. + +Source commit `17384b2a5` mixes independent concerns. Its persistent devpts +slave uid, gid, mode, path/fd metadata, and open-permission behavior will be +forward-ported as one general device-semantics change. Its `ppoll()` and +`pselect()` pending-signal correction will be reproduced and, if still failing +on current HEAD, ported as a separate signal-interruption change. Existing +fixes in the batch will not be duplicated or overwritten. + +That source commit also changed an advisory-lock test's mocked kernel-exit +behavior without a corresponding production change. It is not part of the +PTY or sudo design and will be retained only if an independent current +baseline failure proves it is still required. + +Upstream sudo 1.9.17p2 unconditionally passes Linux `__WALL` in two child-wait +loops used by its execution paths. Kandelo has one child class and does not +implement Linux clone-child wait selection. The Kandelo platform will not add +`__WALL`, accept it as an inert flag, generate it into the host ABI, or +describe it as supported merely for this package. The sudo Formula will carry +an explicit, reviewed Kandelo compatibility patch that omits that flag while +retaining `WUNTRACED` and `WNOHANG`. This is a narrow upstream/platform +boundary, not a general Linux-compatibility promise. + +## Program and Homebrew Ownership + +The Kandelo repository will own the first-party program sources under the +normal `programs/` source tree: + +- `login.c`; and +- `sudo-lite.c`. + +The live `Kandelo-dev/homebrew-tap-core` repository will own their Formulae, +the upstream sudo Formula, build recipes, compatibility patches, tests, +sidecars, and bottle metadata. The first-party Formulae will fetch an exact +Kandelo source commit and compile those source files directly through +`kandelo_wasm_build`; they will not set `KANDELO_REGISTRY_BRIDGE` or call a +registry recipe. Upstream sudo will fetch its pinned upstream archive and use +the same SDK and artifact validation contracts. + +The Kandelo batch and tap change therefore land as coordinated pull requests: + +- PR #1240 owns kernel, ABI, host, VFS, source programs, rootfs, browser, + product selection, tests, and documentation. +- A companion tap pull request owns the three Formulae and candidate bottles. + +The main-shell Brewfile, migration and selection locks, materialization policy, +runtime support, bottle mirror, and image will admit the Formulae only through +the normal Homebrew composition path. The transitional helper that injected +registry-built platform programs into a Homebrew image will not be ported. + +Set-ID entry points cannot safely execute from the guest-writable Homebrew +prefix, which is always `nosuid`. A reviewed system-program projection in the +image policy will copy the exact Formula-owned members into a set-ID-capable, +root-owned product mount at non-user-writable paths: + +- `/usr/bin/login`, mode `04755`, uid 0, gid 0; +- `/usr/bin/sudo-lite`, mode `04755`, uid 0, gid 0; and +- `/usr/bin/sudo`, mode `04755`, uid 0, gid 0. + +The projection is bound to the selected Formula, bottle digest, canonical +source member, destination, uid, gid, mode, mount policy, and artifact +validation result. It uses the generic archive-copy contract with a unique +inode identity. It may preserve lazy immutable backing, but it cannot preserve +a bottle hard link or create a symlink into the Homebrew prefix. Product +validation compares the projected inode against every writable bottle inode +and rejects an alias. A guest user cannot replace its parent directory, link, +or target. Runtime `brew install` remains available for ordinary user-owned +software but cannot mint or replace these root-owned privileged entry points. + +The ordinary bottle tree and privileged projection are separate generic tree +registrations. The bottle tree retains Homebrew-owned placement and remains on +a writable `nosuid` mount. The projection tree is owned as a unit by uid 0 and +gid 0 and contains only reviewed product entries. This uses the existing +tree-owner boundary instead of adding Homebrew-specific per-entry ownership to +MemoryFS. Both trees authenticate the same immutable bottle bytes and complete +source inventory, while their destination inode groups remain disjoint. + +Generic candidate ABI bottle staging is being implemented separately on +`emdash/homebrew-pr-staging-1q1w6`; its approved design names this ABI 43 batch +as the first acceptance fixture. The login forward port will not modify, +merge, rebase, or otherwise take ownership of that branch. It will consume the +reviewed staging interfaces after they land. + +The staging request binds the exact Kandelo pull-request head, ABI, current +protected policy, and product requirements. Canonical VFS product manifests +declare the ordinary login, sudo-lite, sudo, Ruby, and shell Formula roots, +materialization, and product evidence. Test and browser consumer registries +select merge-gating products. The tap planner, not this integration, selects +the exact tap snapshot and resolves the transitive bottle closure. This design +therefore adds no hand-maintained staging Formula list and does not inject an +arbitrary tap commit into the cross-repository protocol. + +Staging availability is a prerequisite for hosted candidate publication and +promotion, not for implementing or locally validating the platform changes. +For PR merge, the exact head must pass required-product VFS composition and its +declared Node and browser evidence through the reviewed merge-gating lane that +is actually active; observe-only staging must not be described as enforced. +The complete stock in-guest tap/install/execute lifecycle remains a local and +final-release proof rather than an additional staging-MVP merge gate. Login +integration should finish before the final Ruby and shell candidates are built +so release evidence and artifacts come from the actual merge candidate. + +Only the reviewed remote GitHub workflows can create a promotable bottle +candidate. Promotion revalidates and publishes the exact authorized candidate +bytes and their bound tap, Kandelo, ABI, Formula, sidecar, and test identities. +A local bottle, local cache entry, local sidecar, or locally composed VFS is +never accepted as candidate provenance and cannot be relabeled, uploaded, or +promoted through this integration. + +## Rootfs and Browser Sessions + +The rootfs will contain truthful passwd, group, shadow, and sudoers state with +reviewed permissions and ownership. Demo credentials are product data for the +demo image, not host-side authentication or a UI simulation. + +The reusable session layer will own one generation-tracked lifecycle record +for each logical PTY. Its product-selected policy has an initial program and a +post-exit program. Every newly allocated demo terminal launches root-authorized +`login -p -f maker` exactly once. After that process or its login shell exits, +the same terminal launches ordinary `login -p`; reattaching a UI handle does +not reset the logical terminal or repeat autologin. + +`login` accepts `-f` preauthentication and `-p` environment preservation only +when its real uid is already zero. Acquiring effective uid zero by executing +the set-ID login binary is insufficient. The ordinary message of the day is +shown after every successful login. Credential hints live in a separate +root-owned `/etc/motd.autologin` and are printed only after a root-authorized +preauthenticated transition. + +The supervisor permits only one active process and one pending restart per +logical PTY. Post-exit restart delay backs off from 250 milliseconds to a +maximum of five seconds after consecutive processes that survive for less +than two seconds; a process that survives at least two seconds resets the +delay. A failure to start the replacement program is printed to the terminal +and is not retried automatically. Terminal removal, kernel detach, reboot, and +host destruction cancel pending timers and generation callbacks. + +A failed password remains a failed login, and an exec, PTY, or restart failure +remains visible rather than being replaced with a synthetic shell. React only +presents session state and terminal bytes; it does not authenticate users, +advance generations, or invent a successful process. Node does not need the +demo presentation, but it must run the same login and sudo binaries against +the same kernel, VFS, PTY, and credential semantics. + +Upstream sudo binaries may remain deferred until first execution, but the lazy +tree, Formula identity, receipt, and bottle bytes must be normal Homebrew +artifacts. Lazy activation failure must be reported as the underlying I/O or +artifact error. + +## Local Build and Demonstration Contract + +GitHub must not be the first environment in which the integrated behavior is +exercised. Add `scripts/run-login-stack-local.sh`, a local orchestration entry +point that accepts an exact tap checkout and an exclusive work directory, runs +inside `scripts/dev-shell.sh`, and never publishes or mutates authoritative +selection state. It will: + +1. build musl, the kernel, host runtime, and required guest fixtures; +2. build local ABI 43 bottles for login, sudo-lite, upstream sudo, pristine + Ruby, and any selected dependencies in a disposable Homebrew prefix; +3. generate and validate sidecars against the exact local tap commit; +4. compose a review-pending main-shell VFS from the local tap and verified + bottle cache; +5. run the Node image contract and complete guest lifecycle against a closed + local bottle mirror; +6. run focused Chromium, Firefox, and WebKit tests where the platform path + applies; and +7. leave the exact image and mirror available for an interactive + `./run.sh browser` demonstration without replacing checked-in product + assets. + +The local functional demonstration must visibly cover: + +```text +automatic maker login +id +sudo -l +sudo id +failed-password rejection +ordinary login after logout +nosuid execution rejection +Ruby spawning through vfork +brew tap/install/execute +``` + +The harness will emit an evidence report containing exact Kandelo and tap +commits, ABI, bottle identities, VFS digest, kernel digest, commands, exit +statuses, browser projects, and RSS measurements. The report identifies every +bottle and sidecar as `local-test` provenance. It will not describe local bytes +as authorized, promotable, published, public, anonymously readable, or +release-ready, and no remote workflow accepts this report as publication +evidence. + +## Failure and Security Behavior + +Tests must cover at least these failure boundaries: + +- malformed, oversized, incomplete, or noncanonical generic archive + inventories and transformation plans; +- source, input, output, receipt, prefix, changed-file, and runtime-layer + schema drift before generic-tree publication; +- eager/lazy disagreement, cancellation, generation replacement, capacity + rollback, and partial-publication attempts for generic materialization; +- privileged projection through a symlink, shared hard link, writable alias, + non-root owner, writable mount, unstable backend, or unrecognized mount; +- invalid old/new/saved ID transitions; +- oversized, undersized, null, and malformed supplementary-group buffers; +- `POSIX_SPAWN_RESETIDS` before credential-sensitive open, chdir, and fchdir + actions, including inherited supplementary groups; +- failed password and unknown user; +- non-root group changes; +- missing, non-regular, non-executable, or `nosuid` exec targets; +- path-target mutation before exec commit, including an unprovable mutable + set-ID source; +- `fexecve` and `execveat(AT_EMPTY_PATH)` when another thread closes the guest + descriptor, renames the pathname, or unlinks it after target preparation, + proving that the retained OFD is authoritative; +- stale, cross-process, reused, cancelled, double-consumed, exhausted, and + leaked prepared-target tokens; +- posix_spawn target failure after file actions, proving that actions run once, + the pending child rolls back, and the parent credentials do not change; +- failed program resolution, compilation, address-space preparation, and + replacement-worker creation; +- forged, missing, stale, or inconsistent secure-execution state; untrusted + environment lookups; and every closed standard-descriptor combination; +- vfork child exec failure, trap, signal death, and `_exit`; +- PTY ownership and permission denial; +- `ppoll()` and `pselect()` interruption with null and non-null replacement + masks, including `SA_RESTART` handlers; +- repeated terminal attachment, initial autologin, logout, failed password, + bounded restart backoff, start failure, terminal removal, reboot, and stale + generation callbacks; +- sudo policy denial, malformed sudoers, editor failure, child stop/continue, + and signal interruption; +- lazy bottle failure; and +- set-ID removal after every supported content and ownership mutation path. + +No failure may grant credentials, resume a discarded image, wedge a vfork +parent, bypass `nosuid`, invent a successful login, or silently substitute a +host-native program. + +## Validation + +Implementation will be test-driven. Focused failing tests precede each +behavioral change. Validation claims will use repository-declared tools through +`scripts/dev-shell.sh`. + +Validation has four boundaries that must not be collapsed into one claim: + +1. **vfork mechanism readiness:** audit and repair the existing implementation + before credentials are ported, including all no-copy, suspension, + isolation, lifecycle, failure, rollback, state, pthread, nesting, + side-module, and cross-host evidence above; +2. **vfork integration readiness:** rerun and extend that matrix after the + credential and prepared-target exec work, with child-only credential state + and every failed or terminal exec path covered; +3. **whole-batch readiness:** run the complete local matrix for VFS, mounts, + credentials, exec, PTYs, login, sudo, Homebrew, ABI, hosts, browsers, + conformance, and performance; and +4. **release readiness:** use reviewed GitHub workflows and the exact candidate + artifacts for bottle provenance, publication, activation, pristine CRuby, + and the real Homebrew memory proof. + +The required local matrix includes: + +- Rust workspace and xtask tests; +- ABI generation and snapshot checks; +- fork/exec/vfork serialization, prepared-target, OFD, and lifecycle tests; +- host Vitest suites, including Node and Wasm64 marshalling; +- generic non-Homebrew TAR projection and transformation tests, eager/lazy + equivalence, all supported entry types, and Homebrew-adapter regression + tests for current and legacy authenticated prefixes; +- libc-test, Open POSIX Test Suite, and Sortix os-test coverage selected for + credentials, exec, wait, signals, PTYs, VFS, and process lifecycle; +- Homebrew Formula build/test, sidecar generation, tap validation, image + composition, Node smoke, and complete closed-mirror guest lifecycle; +- Chromium, Firefox, and WebKit focused tests; +- browser asset validation and manual `./run.sh browser` verification; +- fork-instrument and side-module regression coverage; and +- before/after Node and browser performance and process-tree RSS measurements. + +The final publication proof additionally requires GitHub's isolated Formula +builder, exact candidate artifact relay, GHCR publication, anonymous bottle +readback, immutable selection/VFS release, and protected-main activation. +Those distribution and provenance facts cannot be claimed from local tests. + +## vfork Completion and PR #1166 Removal + +The existing vfork implementation must pass the mechanism-readiness gate and +receive any fixes that evidence requires; it is not exempted as pre-existing +work. Its integration gate then moves after the credential and set-ID exec +changes because those changes alter both process state and the exec lifecycle. +Only after both gates pass does the final artifact and application proof begin. + +After the final ABI 43 Kandelo and tap candidates exist: + +1. build and publish pristine upstream CRuby and the exact Homebrew closure; +2. prove uid 1000 selects CRuby's existing upstream vfork path; +3. prove the intentional root/privileged path still uses ordinary fork; +4. run the real in-guest tap/install/execute lifecycle without renderer loss; +5. measure Node and Chromium process-tree RSS, repeated-run bounds, and + renderer survival, with Firefox and WebKit coverage where applicable; +6. repeat the relevant ABI, libc, POSIX, Sortix, host, browser, + fork-instrument, and performance suites; and +7. rebuild and publish Ruby without PR #1166's Kandelo-only patch. + +If the asynchronous external-kill boundary remains at that point, the same +batch must also commit the detailed `docs/future-improvements.md` follow-up and +the matching truthful `docs/posix-status.md` status before #1166 removal or +release-completion claims. + +Only those exact final artifacts can support the claim that the temporary +patch has been removed from released products. Component microbenchmarks or a +local source build are insufficient. + +## Commit and Review Structure + +The old source commits are evidence and attribution inputs, not mechanical +cherry-pick units. Their behavior maps into purpose-scoped commits: + +| Source concern | Source commits | Forward-port boundary | +|---|---|---| +| Immutable bottle prefix | `8a66801e6` | Authenticated Homebrew destination and relocation prefix | +| Generic VFS materialization | `ebde50611` | Closed archive plan, Homebrew adapter, rollback, and host parity | +| Credential/login foundation | `c44ae8019` | POSIX credentials, exec, login, rootfs, and tests | +| Upstream sudo | `a85a742d8` | Formula, policy, PTY execution, and tests | +| Browser credential UX | `7b012f9fc`, `782c6d4c3`, `6a204573a`, `d197add84` | One coherent browser-session commit | +| Lazy sudo | `c985ee105` | Homebrew lazy-tree integration | +| Metadata correctness | `598459b69`, `3e30a7765` | VFS ownership and set-ID invalidation | +| ABI credential state | `af58c77b9` | ABI 43 fork/exec state and snapshot | +| Devpts metadata | `17384b2a5` | Persistent slave ownership, mode, path/fd identity, and access checks | +| Poll interruption | `17384b2a5` | `ppoll`/`pselect` signal behavior, only if current tests reproduce it | +| Homebrew shell integration | `418da44dc` | Formula-based product/image integration | + +All twelve login-source commits and both VFS-source commits were authored by +Brandon Payton. Derived commits will retain that authorship where the old work +materially supplies the change; the forward porter remains the committer. +Materially combined work uses co-author trailers where needed. `git +range-diff`, patch comparison, and `git log --format=fuller` will verify +attribution before push. + +Baseline CI repairs, immutable-prefix handling, generic VFS materialization, +mount security, privileged projections, platform credentials, ABI/exec, VFS +metadata, PTY/wait, programs, tap Formulae, browser sessions, vfork fixes, +product composition, documentation, and final evidence remain distinct +conceptual commits. Separate vfork defects remain separate commits when they +protect different invariants; mechanical test or generated-artifact changes +may accompany the behavior they prove. PR #1240 must be merged with rebase +commits and must never be squash-merged. + +## Completion Criteria + +The forward port is complete only when: + +- both pre-existing CI fixture failures are repaired; +- generic VFS materialization contains no Homebrew policy, and current + Homebrew eager/lazy products pass through the Homebrew adapter; +- every set-ID-capable product entry is an independent root-owned inode on a + trusted stable mount, while writable and identity-unstable mounts report and + enforce `nosuid`; +- every behavior in the final login source stack is either present or + explicitly rejected here for a documented platform reason; +- the ABI 43 snapshot and all generated consumers agree; +- the vfork mechanism-readiness and post-integration gates have run, every + reproduced defect has a focused regression, and the evidence proves no full + process-memory allocation or copy and correct caller-thread suspension; +- ordinary fork and genuine vfork retain their independent semantics, while + repeated calls, pthread callers, nesting rejection, side modules, and + failure rollback remain covered; +- failed exec cannot change active credentials or wedge the parent; +- exact vfork teardown resumes the parent only after `memory_quiescent`; an + asynchronous browser kill either gains a portable exact fence or retains + loud whole-address-space containment and remains documented as partial; +- any remaining partial vfork boundary has the required substantive future + work entry and synchronized POSIX status rather than an untracked note; +- set-ID images enter secure libc startup and cannot inherit closed standard + descriptors into privileged application code; +- login, sudo-lite, and upstream sudo pass in Node and browsers; +- no registry bridge supplies their product binaries; +- the local end-to-end demonstration completes from exact source and bottle + identities; +- final candidate Homebrew artifacts complete the real guest lifecycle and + memory proof; and +- the batch and companion tap pull requests clearly disclose validation, + remaining publication gates, attribution, and rebase-only merge policy. diff --git a/host/src/homebrew-bottle-selection.ts b/host/src/homebrew-bottle-selection.ts index 9cae5f6027..25895ecd58 100644 --- a/host/src/homebrew-bottle-selection.ts +++ b/host/src/homebrew-bottle-selection.ts @@ -91,11 +91,18 @@ export function projectHomebrewBottleSelection( root.requestedVfsFilename, "Homebrew bottle selection.requestedVfsFilename", ); + const requiredAbiToken = `abi${kandeloAbi}`; + const hasExactAbiToken = new RegExp( + `(?:^|[._-])${requiredAbiToken}(?:[._-]|$)`, + ).test(requestedVfsFilename); if ( - !OUTPUT_FILENAME_RE.test(requestedVfsFilename) + !OUTPUT_FILENAME_RE.test(requestedVfsFilename) || + !requestedVfsFilename.includes("experimental") || + !hasExactAbiToken ) { fail( - "Homebrew bottle selection.requestedVfsFilename must be a safe .vfs.zst basename", + "Homebrew bottle selection.requestedVfsFilename must be a safe .vfs.zst " + + `basename containing experimental and ${requiredAbiToken}`, ); } if ( diff --git a/host/test/fixtures/homebrew-flat-vfs.ts b/host/test/fixtures/homebrew-flat-vfs.ts index ff22254b0b..51628cb9b1 100644 --- a/host/test/fixtures/homebrew-flat-vfs.ts +++ b/host/test/fixtures/homebrew-flat-vfs.ts @@ -9,10 +9,16 @@ import type { HomebrewBottleSupportOutput, HomebrewLinkEntry, } from "../../src"; +import { ABI_VERSION } from "../../src/generated/abi"; import { encodeHomebrewBottleSelection } from "../../src/homebrew-bottle-selection"; export const HOMEBREW_TEST_PREFIX = "/opt/kandelo/homebrew"; export const HOMEBREW_TEST_CELLAR = `${HOMEBREW_TEST_PREFIX}/Cellar`; +export const HOMEBREW_TEST_ABI = ABI_VERSION; +export const HOMEBREW_TEST_SELECTION_NAME = + `experimental-abi${HOMEBREW_TEST_ABI}-flat-builder`; +export const HOMEBREW_TEST_VFS_FILENAME = + `kandelo-homebrew-experimental-abi${HOMEBREW_TEST_ABI}-wasm32.vfs.zst`; export const HOMEBREW_TEST_BOOTSTRAP_FULL_NAME = "kandelo-dev/tap-core/homebrew-bootstrap"; @@ -193,7 +199,7 @@ export function homebrewTestBottleDescriptor( revision: 0, bottleRebuild: 0, arch, - kandeloAbi: 42, + kandeloAbi: HOMEBREW_TEST_ABI, bottleTag: `${arch}_kandelo`, layout: "kandelo-homebrew-v1", materialization: options.materialization ?? "keg", @@ -221,11 +227,11 @@ export function homebrewTestSelectionBytes( ): Uint8Array { return encodeHomebrewBottleSelection({ schema: 1, - name: "experimental-abi42-flat-builder", + name: HOMEBREW_TEST_SELECTION_NAME, arch: "wasm32", - kandeloAbi: 42, + kandeloAbi: HOMEBREW_TEST_ABI, bottles, - requestedVfsFilename: "kandelo-homebrew-experimental-abi42-wasm32.vfs.zst", + requestedVfsFilename: HOMEBREW_TEST_VFS_FILENAME, resourcePolicy: "kandelo-homebrew-vfs-generous-v1", linkPolicy: "kandelo-homebrew-link-ownership-v1", runtimeSupport: "kandelo-homebrew-bootstrap-v1", diff --git a/host/test/homebrew-bottle-selection.test.ts b/host/test/homebrew-bottle-selection.test.ts index 6cce0fd941..9aee743186 100644 --- a/host/test/homebrew-bottle-selection.test.ts +++ b/host/test/homebrew-bottle-selection.test.ts @@ -167,38 +167,26 @@ describe("flat Homebrew bottle selection", () => { } }); - it("admits only the experimental and canonical main-shell product tuples", () => { - const experimental = selectionFixture(); - const mainShell = { - ...selectionFixture(), - name: "main-shell-abi42-wasm32", - requestedVfsFilename: "shell.vfs.zst", - resourcePolicy: "kandelo-homebrew-vfs-main-shell-v1", - }; + it("binds the output basename to the selection's exact ABI", () => { + const fixture = selectionFixture(); + fixture.name = "experimental-abi43-fixture"; + fixture.kandeloAbi = 43; + fixture.requestedVfsFilename = + "kandelo-homebrew-experimental-abi43-wasm32.vfs.zst"; + for (const bottle of fixture.bottles) bottle.kandeloAbi = 43; - expect(projectHomebrewBottleSelection(experimental)).toMatchObject({ - name: "experimental-abi42-fixture", - requestedVfsFilename: - "kandelo-homebrew-experimental-abi42-wasm32.vfs.zst", - resourcePolicy: "kandelo-homebrew-vfs-generous-v1", - }); - expect(projectHomebrewBottleSelection(mainShell)).toMatchObject({ - name: "main-shell-abi42-wasm32", - requestedVfsFilename: "shell.vfs.zst", - resourcePolicy: "kandelo-homebrew-vfs-main-shell-v1", - }); + expect(() => projectHomebrewBottleSelection(fixture, { expectedAbi: 43 })) + .not.toThrow(); - for (const crossed of [ - { ...experimental, resourcePolicy: "kandelo-homebrew-vfs-main-shell-v1" }, - { ...mainShell, resourcePolicy: "kandelo-homebrew-vfs-generous-v1" }, - { ...mainShell, name: "experimental-abi42-main-shell" }, - { - ...experimental, - requestedVfsFilename: "shell.vfs.zst", - }, + for (const requestedVfsFilename of [ + "kandelo-homebrew-experimental-abi42-wasm32.vfs.zst", + "kandelo-homebrew-experimental-abi430-wasm32.vfs.zst", + "kandelo-homebrew-experimental-xabi43x-wasm32.vfs.zst", ]) { - expect(() => projectHomebrewBottleSelection(crossed)) - .toThrow(/supported tuple/); + expect(() => projectHomebrewBottleSelection({ + ...fixture, + requestedVfsFilename, + })).toThrow(/requestedVfsFilename.*abi43/); } }); diff --git a/host/test/homebrew-flat-vfs-builder.test.ts b/host/test/homebrew-flat-vfs-builder.test.ts index 0ea482cc94..4d545d50b9 100644 --- a/host/test/homebrew-flat-vfs-builder.test.ts +++ b/host/test/homebrew-flat-vfs-builder.test.ts @@ -14,6 +14,9 @@ import { resolveHomebrewVfsResourcePolicy } from "../src/homebrew-vfs-resource-p import { ensureDirRecursive, writeVfsFile } from "../src/vfs/image-helpers"; import { MemoryFileSystem } from "../src/vfs/memory-fs"; import { + HOMEBREW_TEST_ABI, + HOMEBREW_TEST_SELECTION_NAME, + HOMEBREW_TEST_VFS_FILENAME, homebrewTestBootstrapEntries, homebrewTestBootstrapFixture, } from "./fixtures/homebrew-flat-vfs"; @@ -74,17 +77,19 @@ describe("flat Homebrew VFS builder", () => { }); const canonicalSelection = encodeHomebrewBottleSelection({ schema: 1, - name: "experimental-abi42-flat-builder", + name: HOMEBREW_TEST_SELECTION_NAME, arch: "wasm32", - kandeloAbi: 42, + kandeloAbi: HOMEBREW_TEST_ABI, bottles: [bootstrap, hello], - requestedVfsFilename: "kandelo-homebrew-experimental-abi42-wasm32.vfs.zst", + requestedVfsFilename: HOMEBREW_TEST_VFS_FILENAME, resourcePolicy: "kandelo-homebrew-vfs-generous-v1", linkPolicy: "kandelo-homebrew-link-ownership-v1", runtimeSupport: "kandelo-homebrew-bootstrap-v1", }); - const plan = planHomebrewVfsSelection(canonicalSelection, { expectedAbi: 42 }); + const plan = planHomebrewVfsSelection(canonicalSelection, { + expectedAbi: HOMEBREW_TEST_ABI, + }); const loaded: string[] = []; const result = await buildHomebrewVfsSelection(plan, { loadBottleBytes(pkg) { @@ -95,11 +100,11 @@ describe("flat Homebrew VFS builder", () => { expect(plan).toMatchObject({ schema: 1, - name: "experimental-abi42-flat-builder", + name: HOMEBREW_TEST_SELECTION_NAME, arch: "wasm32", - kandeloAbi: 42, + kandeloAbi: HOMEBREW_TEST_ABI, selectionSha256: sha256(canonicalSelection), - requestedVfsFilename: "kandelo-homebrew-experimental-abi42-wasm32.vfs.zst", + requestedVfsFilename: HOMEBREW_TEST_VFS_FILENAME, resourcePolicy: "kandelo-homebrew-vfs-generous-v1", linkPolicy: "kandelo-homebrew-link-ownership-v1", runtimeSupport: "kandelo-homebrew-bootstrap-v1", @@ -118,11 +123,11 @@ describe("flat Homebrew VFS builder", () => { const metadata = JSON.parse(readVfsFile(result.fs, "/etc/kandelo/homebrew-vfs.json")); expect(metadata).toMatchObject({ schema: 1, - name: "experimental-abi42-flat-builder", + name: HOMEBREW_TEST_SELECTION_NAME, arch: "wasm32", - kandelo_abi: 42, + kandelo_abi: HOMEBREW_TEST_ABI, selection_sha256: sha256(canonicalSelection), - requested_vfs_filename: "kandelo-homebrew-experimental-abi42-wasm32.vfs.zst", + requested_vfs_filename: HOMEBREW_TEST_VFS_FILENAME, resource_policy: "kandelo-homebrew-vfs-generous-v1", link_policy: "kandelo-homebrew-link-ownership-v1", runtime_support: "kandelo-homebrew-bootstrap-v1", @@ -876,7 +881,7 @@ function descriptor(options: { revision: 0, bottleRebuild: 0, arch: "wasm32", - kandeloAbi: 42, + kandeloAbi: HOMEBREW_TEST_ABI, bottleTag: "wasm32_kandelo", layout: "kandelo-homebrew-v1", materialization, @@ -955,11 +960,11 @@ function simpleBottle( function selectionBytes(bottles: HomebrewBottleDescriptor[]): Uint8Array { return encodeHomebrewBottleSelection({ schema: 1, - name: "experimental-abi42-flat-builder", + name: HOMEBREW_TEST_SELECTION_NAME, arch: "wasm32", - kandeloAbi: 42, + kandeloAbi: HOMEBREW_TEST_ABI, bottles, - requestedVfsFilename: "kandelo-homebrew-experimental-abi42-wasm32.vfs.zst", + requestedVfsFilename: HOMEBREW_TEST_VFS_FILENAME, resourcePolicy: "kandelo-homebrew-vfs-generous-v1", linkPolicy: "kandelo-homebrew-link-ownership-v1", runtimeSupport: "kandelo-homebrew-bootstrap-v1", diff --git a/host/test/homebrew-flat-vfs-cli.test.ts b/host/test/homebrew-flat-vfs-cli.test.ts index 8dd80ee4e1..038469f418 100644 --- a/host/test/homebrew-flat-vfs-cli.test.ts +++ b/host/test/homebrew-flat-vfs-cli.test.ts @@ -22,6 +22,8 @@ import { resolveHomebrewVfsResourcePolicy } from "../src/homebrew-vfs-resource-p import { ensureDirRecursive, writeVfsFile } from "../src/vfs/image-helpers"; import { MemoryFileSystem } from "../src/vfs/memory-fs"; import { + HOMEBREW_TEST_ABI, + HOMEBREW_TEST_VFS_FILENAME, homebrewTestBootstrapFixture, homebrewTestBottleDescriptor, homebrewTestBottleEntry, @@ -31,8 +33,7 @@ import { homebrewTestSelectionBytes, } from "./fixtures/homebrew-flat-vfs"; -const OUTPUT_FILENAME = - "kandelo-homebrew-experimental-abi42-wasm32.vfs.zst"; +const OUTPUT_FILENAME = HOMEBREW_TEST_VFS_FILENAME; const SHELL_CONFIG = fileURLToPath( new URL("../../homebrew/main-shell-default.json", import.meta.url), ); @@ -105,7 +106,7 @@ describe("flat Homebrew VFS CLI", () => { await restored.verifyImportedLazyAtomicGroupSeals(); expect(restored.getImageMetadata()).toMatchObject({ version: 1, - kernelAbi: 42, + kernelAbi: HOMEBREW_TEST_ABI, createdBy: "images/vfs/scripts/build-homebrew-flat-vfs-image.ts", homebrewFlat: { selectionSha256: sha(originalSelection), @@ -281,7 +282,11 @@ describe("flat Homebrew VFS CLI", () => { 0o755, ); writeFileSync(fixture.base, await baseFs.saveImage({ - metadata: { version: 1, kernelAbi: 42, createdBy: "lazy-base-test" }, + metadata: { + version: 1, + kernelAbi: HOMEBREW_TEST_ABI, + createdBy: "lazy-base-test", + }, normalizeTimestampsMs: 0, })); const fetchBottleBytes = vi.fn(async () => new Uint8Array()); @@ -443,7 +448,11 @@ async function createFixture( } const base = join(directory, "base.vfs"); writeFileSync(base, await baseFs.saveImage({ - metadata: { version: 1, kernelAbi: 42, createdBy: "flat-cli-test" }, + metadata: { + version: 1, + kernelAbi: HOMEBREW_TEST_ABI, + createdBy: "flat-cli-test", + }, normalizeTimestampsMs: 0, })); diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index 0e1276d34a..e27a452a4a 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -158,8 +158,8 @@ "lamp": { "manifestSha256": "250b64635d64f8537178188fb5488dbfd2980d79a1c2ffbf346af67008088ba0", "cacheKeys": { - "wasm32": "7e80a42e9056453b9d755523e98a1d9fcd852d12bb96c534ab5d05da61716435", - "wasm64": "718d26d1f2e3bf6af25489db1f5022cdd0236340f55b7f5cbb621edbec17c1a1" + "wasm32": "a57c26e27a8e30a8fa8b1cb4eb41435c14847f452bd84f41f578dbfe7e6873e6", + "wasm64": "6ae08f18e26edc89abced539b7e09eec0856eebd36788b315bfceb45b7c63698" } }, "less": { @@ -312,15 +312,15 @@ "nginx-php-vfs": { "manifestSha256": "97976410cb02f8ba710d856b4ac904bcf976d677b50eefdee39fb64176070d4b", "cacheKeys": { - "wasm32": "86777f0074c0653dae5428aef542dd43a1ef51c29a36b1e3d6586ae735c79572", - "wasm64": "e1c08b1b6ad71a0caa683b03037c0e2a25a8edccd5645a8d122b00ce03fa01b4" + "wasm32": "d97347607310c1c647a7eb4a0c4e7ddff072313a3190c313a6e65005c6d70bec", + "wasm64": "a921f77a85ec13b7dd63644791dfb331a2e90fa1b308ab3ba6e5b47046e6c51f" } }, "nginx-vfs": { "manifestSha256": "46aa2d3250ac5cd0f102a85c3a36a086c7a50ba1f9e05fa1c2aae3d07ad16f10", "cacheKeys": { - "wasm32": "6e8f0c4ae304f7efdcb621400bfb2b3c3a77655a7e2e20ea3b5e7136f05825bb", - "wasm64": "7f9ef2a48864bd8d8ab4f2b82fcba4819df15ae73ea859e16cae437992b2f454" + "wasm32": "200e3266962ea5d01489c810c4a96d99b22f57b959c043b4b0dea90d30a6ba2e", + "wasm64": "9872b4a37a4fa47cb1a5289cf748029ccc056c46595857ea2c9f0b6781900df3" } }, "node": { @@ -333,8 +333,8 @@ "node-vfs": { "manifestSha256": "977f219defe57e18017ecc963159df32efdd21c53d6fea05943703d04739d8de", "cacheKeys": { - "wasm32": "1067b082af6cd61c61a6093e01f8bbd0456a6cc9f8f5bff00691e7109b977e54", - "wasm64": "4f38681c31dd145e846e1a4900d5fcf2fc325e1a0e144cce22e67838ae821c61" + "wasm32": "5b0e4cbb2d08e9f0b9b1c422c18d027fe8da964d2625b4f36dac9b67c21ddd03", + "wasm64": "ce5cfcbf8292b0cd5953502c27eba9b84fd16b7b9940f3e4f807d97802a044b4" } }, "openssl": { @@ -452,8 +452,8 @@ "shell": { "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", "cacheKeys": { - "wasm32": "2e11b63640012671fb4144219d84c0606f2d4297154ad95ca2ab5607ed017f97", - "wasm64": "f0eee29c90ba0498c3ba4e5da932fe0325bf7738f6c86fe5d14690d876bdf2e7" + "wasm32": "62ee60524193ef96af5ef4b005c691b7227f819ee9e3f9e195a3ac8e6b168dde", + "wasm64": "44d7be441757bbbb02d907b9e9a812c9b4d6571697a26617cc911ed0f2379e5e" } }, "spidermonkey": { @@ -543,8 +543,8 @@ "wordpress": { "manifestSha256": "36465e0596a06e855a8524eecfd87da98643bc13b37ee12939f5aab44bf33418", "cacheKeys": { - "wasm32": "b8079a2b01cebaf4dbc26c6e9a6eedae258368d95cd756ff87b21bed377a56a7", - "wasm64": "8399ec1907394e97bf0d495c810a8680a2c69f293c3d89b1dd696feaf981f322" + "wasm32": "589162fa777487b8be4e0c1fa1ec24dfd3de0b590859b655328e2acc7b9e8fdf", + "wasm64": "fe0e7c675b3739bdb1f9c7ecd3c858eb9ff5b8fb68f85bf5abe0f462a4feade0" } }, "xz": { @@ -1121,7 +1121,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "7e80a42e9056453b9d755523e98a1d9fcd852d12bb96c534ab5d05da61716435" + "wasm32": "a57c26e27a8e30a8fa8b1cb4eb41435c14847f452bd84f41f578dbfe7e6873e6" }, "dependencyClosures": { "wasm32": [ @@ -1193,7 +1193,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "2e11b63640012671fb4144219d84c0606f2d4297154ad95ca2ab5607ed017f97" + "cacheKey": "62ee60524193ef96af5ef4b005c691b7227f819ee9e3f9e195a3ac8e6b168dde" }, { "packageName": "sqlite", @@ -1746,7 +1746,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "86777f0074c0653dae5428aef542dd43a1ef51c29a36b1e3d6586ae735c79572" + "wasm32": "d97347607310c1c647a7eb4a0c4e7ddff072313a3190c313a6e65005c6d70bec" }, "dependencyClosures": { "wasm32": [ @@ -1808,7 +1808,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "2e11b63640012671fb4144219d84c0606f2d4297154ad95ca2ab5607ed017f97" + "cacheKey": "62ee60524193ef96af5ef4b005c691b7227f819ee9e3f9e195a3ac8e6b168dde" }, { "packageName": "sqlite", @@ -1838,7 +1838,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "6e8f0c4ae304f7efdcb621400bfb2b3c3a77655a7e2e20ea3b5e7136f05825bb" + "wasm32": "200e3266962ea5d01489c810c4a96d99b22f57b959c043b4b0dea90d30a6ba2e" }, "dependencyClosures": { "wasm32": [ @@ -1860,7 +1860,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "2e11b63640012671fb4144219d84c0606f2d4297154ad95ca2ab5607ed017f97" + "cacheKey": "62ee60524193ef96af5ef4b005c691b7227f819ee9e3f9e195a3ac8e6b168dde" } ] }, @@ -1922,7 +1922,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "1067b082af6cd61c61a6093e01f8bbd0456a6cc9f8f5bff00691e7109b977e54" + "wasm32": "5b0e4cbb2d08e9f0b9b1c422c18d027fe8da964d2625b4f36dac9b67c21ddd03" }, "dependencyClosures": { "wasm32": [ @@ -1944,7 +1944,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "2e11b63640012671fb4144219d84c0606f2d4297154ad95ca2ab5607ed017f97" + "cacheKey": "62ee60524193ef96af5ef4b005c691b7227f819ee9e3f9e195a3ac8e6b168dde" }, { "packageName": "spidermonkey", @@ -2728,7 +2728,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2e11b63640012671fb4144219d84c0606f2d4297154ad95ca2ab5607ed017f97" + "wasm32": "62ee60524193ef96af5ef4b005c691b7227f819ee9e3f9e195a3ac8e6b168dde" }, "dependencyClosures": { "wasm32": [] @@ -3020,7 +3020,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b8079a2b01cebaf4dbc26c6e9a6eedae258368d95cd756ff87b21bed377a56a7" + "wasm32": "589162fa777487b8be4e0c1fa1ec24dfd3de0b590859b655328e2acc7b9e8fdf" }, "dependencyClosures": { "wasm32": [ @@ -3082,7 +3082,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "2e11b63640012671fb4144219d84c0606f2d4297154ad95ca2ab5607ed017f97" + "cacheKey": "62ee60524193ef96af5ef4b005c691b7227f819ee9e3f9e195a3ac8e6b168dde" }, { "packageName": "sqlite", diff --git a/scripts/homebrew-tap-recipe-runner.py b/scripts/homebrew-tap-recipe-runner.py index 48ae20f4bd..24c11c95b4 100644 --- a/scripts/homebrew-tap-recipe-runner.py +++ b/scripts/homebrew-tap-recipe-runner.py @@ -108,6 +108,7 @@ Path("examples/run-example-output.ts"), Path("examples/run-example-paths.ts"), Path("examples/run-example.ts"), + Path("examples/run-example-vfs.ts"), Path("package.json"), ) # WHY: Formula tests need package identity for only the physical generations diff --git a/scripts/test-finalize-homebrew-main-shell-release.py b/scripts/test-finalize-homebrew-main-shell-release.py index 888ff7019b..8bba96f1e4 100755 --- a/scripts/test-finalize-homebrew-main-shell-release.py +++ b/scripts/test-finalize-homebrew-main-shell-release.py @@ -120,6 +120,21 @@ def copy_source(root: pathlib.Path) -> pathlib.Path: destination = source / relative destination.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(REPO / relative, destination) + # WHY: this finalizer exercises the reviewed ABI-42 shell-delivery + # contract, including its ABI-42 bottle cohort. An unrelated Kandelo ABI + # bump must not silently turn those historical fixtures into an ABI-43 + # publication claim; a later ABI-43 Homebrew campaign must supply and seal + # its own matching bottles and selection. + abi_path = source / "crates/shared/src/lib.rs" + abi_source, replacements = re.subn( + r"^pub const ABI_VERSION: u32 = [0-9]+;$", + "pub const ABI_VERSION: u32 = 42;", + abi_path.read_text(), + count=1, + flags=re.MULTILINE, + ) + assert replacements == 1 + abi_path.write_text(abi_source) return source @@ -866,6 +881,40 @@ def misorder_embedded_formulae(policy: dict) -> None: } assert_product_state(source, "publishable") +with tempfile.TemporaryDirectory( + prefix="kandelo-shell-finalizer-cross-abi-selection." +) as temporary: + root = pathlib.Path(temporary) + source = copy_source(root) + selection, receipt, _source_commit = create_closed_selection(root, source) + abi_path = source / "crates/shared/src/lib.rs" + abi_source, replacements = re.subn( + r"^pub const ABI_VERSION: u32 = 42;$", + "pub const ABI_VERSION: u32 = 43;", + abi_path.read_text(), + count=1, + flags=re.MULTILINE, + ) + assert replacements == 1 + abi_path.write_text(abi_source) + paths = [source / relative for relative in COPIED] + before = {path: digest(path) for path in paths} + + cross_abi = run( + "--source-root", + str(source), + "--selection", + str(selection), + "--selection-receipt", + str(receipt), + success=False, + ) + assert_failure( + cross_abi, + "closed selection architecture or ABI differs from Kandelo", + ) + assert before == {path: digest(path) for path in paths} + with tempfile.TemporaryDirectory( prefix="kandelo-shell-finalizer-selection-authority." ) as temporary: diff --git a/scripts/test-homebrew-patched-launcher.sh b/scripts/test-homebrew-patched-launcher.sh index f54f92588c..7b246d27ad 100755 --- a/scripts/test-homebrew-patched-launcher.sh +++ b/scripts/test-homebrew-patched-launcher.sh @@ -835,6 +835,7 @@ NATIVE_INTERPRETER_EOF [ -z "${NODE_PATH+x}" ] for required in \ Cargo.toml package.json examples/run-example.ts \ + examples/run-example-vfs.ts \ host/src/node-kernel-host.ts host/wasm/kandelo-kernel.wasm \ host/wasm/program-packages.json \ packages/registry/openssl/src/tls/1_2/connection.ts \ @@ -2179,6 +2180,7 @@ PY "$REPO_ROOT/examples/run-example.ts" \ "$REPO_ROOT/examples/run-example-output.ts" \ "$REPO_ROOT/examples/run-example-paths.ts" \ + "$REPO_ROOT/examples/run-example-vfs.ts" \ "$isolated_kandelo/examples/" cp -- "$REPO_ROOT/package.json" \ "$isolated_kandelo/host/wasm/kandelo-kernel.wasm" diff --git a/scripts/test-homebrew-tap-recipe-runner.py b/scripts/test-homebrew-tap-recipe-runner.py index c249cb980f..9c01a59565 100644 --- a/scripts/test-homebrew-tap-recipe-runner.py +++ b/scripts/test-homebrew-tap-recipe-runner.py @@ -1314,6 +1314,7 @@ def add_node_module( "examples/run-example-output.ts": b"export const output = true;\n", "examples/run-example-paths.ts": b"export const paths = true;\n", "examples/run-example.ts": b"export const run = true;\n", + "examples/run-example-vfs.ts": b"export const vfs = true;\n", } for relative, data in selected_files.items(): path = source / relative @@ -1441,6 +1442,12 @@ def test_projects_only_the_closed_runtime_with_stable_digest(self) -> None: b'"identities":{},"packages":{}}\n' ), ) + self.assertEqual( + ( + destination / "examples/run-example-vfs.ts" + ).read_bytes(), + b"export const vfs = true;\n", + ) self.assertEqual( ( destination From 62100cc05c0956521aa1175a3a3f003c6e356531 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Mon, 10 Aug 2026 14:58:10 -0400 Subject: [PATCH 56/82] Vfork: Define and measure the production mechanism --- .../test/borrowed-fork-replay.spec.ts | 63 +++- .../borrowed-dylink-replay-browser-worker.ts | 56 +++- .../test/vfork-lifecycle.spec.ts | 295 +++++++++++++++++- .../2026-08-10-vfork-readiness.md | 246 +++++++++++++++ host/src/browser-kernel-worker-entry.ts | 100 +++++- host/src/fork-mechanism-trace.ts | 10 + host/src/node-kernel-worker-entry.ts | 74 +++++ host/src/worker-adapter.ts | 15 + host/src/worker-main.ts | 19 +- .../fixtures/vfork-production-trace-runner.ts | 80 +++++ host/test/fixtures/vfork-side-main.c | 33 ++ host/test/fixtures/vfork-side-module.c | 23 ++ .../fixtures/vfork-start-failure-runner.ts | 28 ++ host/test/fork-borrowed-replay.test.ts | 1 + .../fork-from-dlopen-side-module-e2e.test.ts | 34 ++ host/test/fork-mechanism-trace.test.ts | 22 ++ host/test/fork-process-continuation.test.ts | 10 + host/test/process-table-replication.test.ts | 37 +++ host/test/vfork-lifecycle-guest.test.ts | 77 +++-- host/test/vfork-lifetime.test.ts | 144 +++++++-- host/test/vfork-mechanism-trace.test.ts | 90 ++++++ host/test/vfork-mechanism-trace.ts | 246 +++++++++++++++ host/test/vfork-production-mechanism.test.ts | 124 ++++++++ host/test/vfork-side-module-fixture.test.ts | 44 +++ host/test/vfork-side-module-fixture.ts | 125 ++++++++ host/test/vfork-workspace.test.ts | 41 +++ packages/registry/program-packages.json | 18 +- programs/vfork-lifecycle.c | 8 +- programs/vfork-posix-state.c | 122 ++++++-- .../build-fork-instrumented-test-fixture.sh | 13 +- scripts/run-vfork-readiness.sh | 98 ++++++ scripts/test-homebrew-inspect-bottle.sh | 21 ++ scripts/test-vfork-readiness-interface.sh | 27 ++ 33 files changed, 2232 insertions(+), 112 deletions(-) create mode 100644 docs/measurements/2026-08-10-vfork-readiness.md create mode 100644 host/src/fork-mechanism-trace.ts create mode 100644 host/test/fixtures/vfork-production-trace-runner.ts create mode 100644 host/test/fixtures/vfork-side-main.c create mode 100644 host/test/fixtures/vfork-side-module.c create mode 100644 host/test/fixtures/vfork-start-failure-runner.ts create mode 100644 host/test/fork-mechanism-trace.test.ts create mode 100644 host/test/vfork-mechanism-trace.test.ts create mode 100644 host/test/vfork-mechanism-trace.ts create mode 100644 host/test/vfork-production-mechanism.test.ts create mode 100644 host/test/vfork-side-module-fixture.test.ts create mode 100644 host/test/vfork-side-module-fixture.ts create mode 100755 scripts/run-vfork-readiness.sh create mode 100644 scripts/test-vfork-readiness-interface.sh diff --git a/apps/browser-demos/test/borrowed-fork-replay.spec.ts b/apps/browser-demos/test/borrowed-fork-replay.spec.ts index a776dd88d1..cb97d3870e 100644 --- a/apps/browser-demos/test/borrowed-fork-replay.spec.ts +++ b/apps/browser-demos/test/borrowed-fork-replay.spec.ts @@ -387,20 +387,28 @@ test("borrowed side-module reconstruction does not write parent memory", async ( maximum: 100, shared: true, }); + const parentTable = new WebAssembly.Table({ + initial: 1, + element: "anyfunc", + }); + const parentStackPointer = new WebAssembly.Global( + { value: "i32", mutable: true }, + 65_536, + ); + const parentGlobalSymbols = new Map(); + const parentGot = new Map(); + const parentLoadedLibraries = new Map(); const parent = loadSharedLibrarySync( "libborrowed-browser-side.so", new Uint8Array(bytes), { memory, - table: new WebAssembly.Table({ initial: 1, element: "anyfunc" }), - stackPointer: new WebAssembly.Global( - { value: "i32", mutable: true }, - 65_536, - ), + table: parentTable, + stackPointer: parentStackPointer, heapPointer: { value: 4_096 }, - globalSymbols: new Map(), - got: new Map(), - loadedLibraries: new Map(), + globalSymbols: parentGlobalSymbols, + got: parentGot, + loadedLibraries: parentLoadedLibraries, }, ); (parent.exports.inc_counter as () => void)(); @@ -410,9 +418,27 @@ test("borrowed side-module reconstruction does not write parent memory", async ( parent.memoryBase, parent.metadata.memorySize, ).slice(); + const parentLoaderState = { + stackPointer: Number(parentStackPointer.value), + tableLength: parentTable.length, + globalSymbols: parentGlobalSymbols.size, + got: parentGot.size, + loadedLibraries: parentLoadedLibraries.size, + }; const childWorker = new Worker(childWorkerUrl, { type: "module" }); - let childResult: { value?: number; error?: string }; + let childResult: { + value?: number; + privateLoaderState?: { + stackPointer: number; + tableLengthBeforeMutation: number; + tableLengthAfterMutation: number; + hasGlobalSymbol: boolean; + hasGotEntry: boolean; + hasLoadedLibrary: boolean; + }; + error?: string; + }; try { childResult = await new Promise((resolve, reject) => { childWorker.onmessage = (event) => resolve(event.data); @@ -440,8 +466,18 @@ test("borrowed side-module reconstruction does not write parent memory", async ( return { parentBefore, childValue: childResult.value, + childLoaderState: childResult.privateLoaderState, dataUnchanged, parentAfter: (parent.exports.get_counter as () => number)(), + parentLoaderStateUnchanged: + Number(parentStackPointer.value) === parentLoaderState.stackPointer + && parentTable.length === parentLoaderState.tableLength + && parentGlobalSymbols.size === parentLoaderState.globalSymbols + && parentGot.size === parentLoaderState.got + && parentLoadedLibraries.size === parentLoaderState.loadedLibraries + && !parentGlobalSymbols.has("__borrowed_child_only") + && !parentGot.has("__borrowed_child_only") + && !parentLoadedLibraries.has("__borrowed_child_only"), }; }, { @@ -454,8 +490,17 @@ test("borrowed side-module reconstruction does not write parent memory", async ( expect(result, browserName).toEqual({ parentBefore: 42, childValue: 42, + childLoaderState: { + stackPointer: 77_777, + tableLengthBeforeMutation: 3, + tableLengthAfterMutation: 4, + hasGlobalSymbol: true, + hasGotEntry: true, + hasLoadedLibrary: true, + }, dataUnchanged: true, parentAfter: 42, + parentLoaderStateUnchanged: true, }); } finally { fixture.cleanup(); diff --git a/apps/browser-demos/test/fixtures/borrowed-dylink-replay-browser-worker.ts b/apps/browser-demos/test/fixtures/borrowed-dylink-replay-browser-worker.ts index 6e80fd34e3..dc3be3a3ef 100644 --- a/apps/browser-demos/test/fixtures/borrowed-dylink-replay-browser-worker.ts +++ b/apps/browser-demos/test/fixtures/borrowed-dylink-replay-browser-worker.ts @@ -10,37 +10,51 @@ interface BorrowedDylinkReplayRequest { interface BorrowedDylinkReplayResult { value?: number; + privateLoaderState?: { + stackPointer: number; + tableLengthBeforeMutation: number; + tableLengthAfterMutation: number; + hasGlobalSymbol: boolean; + hasGotEntry: boolean; + hasLoadedLibrary: boolean; + }; error?: string; } const workerScope = globalThis as unknown as { close(): void; - onmessage: ((event: MessageEvent) => void) | null; + onmessage: + ((event: MessageEvent) => void) | null; postMessage(message: BorrowedDylinkReplayResult): void; }; workerScope.onmessage = (event) => { try { const { bytes, memory, memoryBase, tableBase, tlsBase } = event.data; + const table = new WebAssembly.Table({ initial: 1, element: "anyfunc" }); + const stackPointer = new WebAssembly.Global( + { value: "i32", mutable: true }, + 65_536, + ); + const globalSymbols = new Map(); + const got = new Map(); + const loadedLibraries = new Map(); const library = loadSharedLibrarySync( "libborrowed-browser-side.so", new Uint8Array(bytes), { memory, - table: new WebAssembly.Table({ initial: 1, element: "anyfunc" }), - stackPointer: new WebAssembly.Global( - { value: "i32", mutable: true }, - 65_536, - ), + table, + stackPointer, allocateMemory: () => { throw new Error("borrowed browser side child must not allocate"); }, deallocateMemory: () => { throw new Error("borrowed browser side child must not release"); }, - globalSymbols: new Map(), - got: new Map(), - loadedLibraries: new Map(), + globalSymbols, + got, + loadedLibraries, }, { memoryBase, @@ -49,14 +63,32 @@ workerScope.onmessage = (event) => { memoryOwnership: "borrowed", }, ); + const tableLengthBeforeMutation = table.length; + stackPointer.value = 77_777; + table.grow(1); + globalSymbols.set("__borrowed_child_only", 1); + got.set( + "__borrowed_child_only", + new WebAssembly.Global({ value: "i32", mutable: true }, 2), + ); + loadedLibraries.set("__borrowed_child_only", {}); workerScope.postMessage({ value: (library.exports.get_counter as () => number)(), + privateLoaderState: { + stackPointer: Number(stackPointer.value), + tableLengthBeforeMutation, + tableLengthAfterMutation: table.length, + hasGlobalSymbol: globalSymbols.has("__borrowed_child_only"), + hasGotEntry: got.has("__borrowed_child_only"), + hasLoadedLibrary: loadedLibraries.has("__borrowed_child_only"), + }, }); } catch (error) { workerScope.postMessage({ - error: error instanceof Error - ? `${error.message}\n${error.stack ?? ""}` - : String(error), + error: + error instanceof Error + ? `${error.message}\n${error.stack ?? ""}` + : String(error), }); } finally { workerScope.close(); diff --git a/apps/browser-demos/test/vfork-lifecycle.spec.ts b/apps/browser-demos/test/vfork-lifecycle.spec.ts index ca1d9b6a98..ed2d787e9d 100644 --- a/apps/browser-demos/test/vfork-lifecycle.spec.ts +++ b/apps/browser-demos/test/vfork-lifecycle.spec.ts @@ -9,6 +9,15 @@ import { WASM_PAGE_SIZE, } from "../../../host/src/constants"; import { computeProcessMemoryLayout } from "../../../host/src/process-memory"; +import { buildVforkSideModuleFixture } from "../../../host/test/vfork-side-module-fixture"; +import { + parseMechanismTraceLine, + partitionForkDispatches, + requireCompleteVforkSequence, + requireVforkStartFailureSequence, + type MechanismTrace, + type MechanismTraceRun, +} from "../../../host/test/vfork-mechanism-trace"; const __dirname = dirname(fileURLToPath(import.meta.url)); const browserKernelModulePath = resolve( @@ -27,6 +36,10 @@ const externalSignalProgramPath = resolveBinary( ); const stateProgramPath = resolveBinary("programs/vfork-posix-state.wasm"); const execChildPath = resolveBinary("programs/exec-child.wasm"); +const ordinaryForkProgramPath = resolve( + __dirname, + "../../../host/test/fixtures/fork-memory-clone.wasm", +); function initialAddressSpaceBytes(programPath: string): number { const file = readFileSync(programPath); @@ -88,6 +101,10 @@ async function runBrowserVforkFixture( fixturePath: string, execChildFixturePath?: string, maxProcessMemoryBytes?: number, + fixtureArguments: string[] = ["vfork-browser-fixture"], + sideModuleFixturePath?: string, + enableMechanismTrace = false, + injectWorkerStartFailure = false, ): Promise { const asViteFsUrl = (path: string) => new URL(`/@fs/${path}`, baseURL).href; @@ -103,7 +120,11 @@ async function runBrowserVforkFixture( memoryFsModuleUrl, fixtureUrl, execChildFixtureUrl, + sideModuleFixtureUrl, maxProcessMemoryBytes, + fixtureArguments, + enableMechanismTrace, + injectWorkerStartFailure, }) => { // WHY: loading BrowserKernel first avoids racing two cold Vite imports // through the same host-runtime dependency graph. @@ -125,6 +146,10 @@ async function runBrowserVforkFixture( const processEvents: string[] = []; const kernel = new BrowserKernel({ maxWorkers: 4, + enableSyscallLog: enableMechanismTrace, + ...(injectWorkerStartFailure + ? { env: ["KANDELO_TEST_VFORK_WORKER_START_FAILURE=once"] } + : {}), ...(maxProcessMemoryBytes === undefined ? {} : { maxProcessMemoryBytes }), @@ -167,6 +192,22 @@ async function runBrowserVforkFixture( new Uint8Array(await childResponse.arrayBuffer()), ); } + if (sideModuleFixtureUrl) { + const sideResponse = await fetch(sideModuleFixtureUrl); + if (!sideResponse.ok) { + throw new Error( + `side module fetch failed: ${sideResponse.status} ${sideModuleFixtureUrl}`, + ); + } + imageOwner.mkdir("/lib", 0o755); + imageOwner.createFileWithOwner( + "/lib/libvfork-side.so", + 0o755, + 0, + 0, + new Uint8Array(await sideResponse.arrayBuffer()), + ); + } await kernel.initFromImage({ vfsImage: await imageOwner.saveImage(), }); @@ -180,7 +221,7 @@ async function runBrowserVforkFixture( } const exitCode = await kernel.spawn( await fixtureResponse.arrayBuffer(), - ["vfork-browser-fixture"], + fixtureArguments, ); return { exitCode, @@ -200,11 +241,215 @@ async function runBrowserVforkFixture( execChildFixtureUrl: execChildFixturePath ? asViteFsUrl(execChildFixturePath) : undefined, + sideModuleFixtureUrl: sideModuleFixturePath + ? asViteFsUrl(sideModuleFixturePath) + : undefined, maxProcessMemoryBytes, + fixtureArguments, + enableMechanismTrace, + injectWorkerStartFailure, }, ); } +function captureMechanismTraces(page: Page): MechanismTrace[] { + const traces: MechanismTrace[] = []; + page.on("console", (message) => { + const trace = parseMechanismTraceLine(message.text()); + if (trace) traces.push(trace); + }); + return traces; +} + +function browserTraceRun( + name: string, + traces: readonly MechanismTrace[], + start: number, + end: number, +): MechanismTraceRun { + return { name, traces: traces.slice(start, end) }; +} + +function expectPrivatePreparationEvidence(preparation: MechanismTrace): void { + expect(preparation.fields.get("mode"), preparation.line).toBe("1"); + expect(preparation.fields.get("memory_identity"), preparation.line).toBe("same"); + expect(preparation.fields.get("live_memory_delta"), preparation.line).toBe("0"); + expect(preparation.fields.get("alias_delta"), preparation.line).toBe("1"); + expect(preparation.fields.get("parent_channel"), preparation.line) + .not.toBe(preparation.fields.get("child_channel")); + expect(preparation.fields.get("owner_control"), preparation.line) + .not.toBe(preparation.fields.get("child_prefix")); + expect(preparation.fields.get("scratch"), preparation.line) + .not.toBe(preparation.fields.get("owner_control")); + expect(preparation.fields.get("scratch"), preparation.line) + .not.toBe(preparation.fields.get("child_prefix")); + expect(preparation.fields.get("externref_parent"), preparation.line) + .not.toBe(preparation.fields.get("externref_child")); +} + +test("observes real browser mode 1 quiescence and mode 0 copy dispatch", async ({ + page, + baseURL, +}) => { + test.setTimeout(180_000); + expect(baseURL).toBeTruthy(); + const runtimeErrors = captureRuntimeErrors(page); + const traces = captureMechanismTraces(page); + + const borrowedTraceStart = traces.length; + const borrowed = await runBrowserVforkFixture( + page, + baseURL!, + lifecycleProgramPath, + undefined, + initialAddressSpaceBytes(lifecycleProgramPath), + ["vfork-browser-trace", "no-successful-exec"], + undefined, + true, + ); + const borrowedTraceEnd = traces.length; + const ordinaryTraceStart = traces.length; + const ordinary = await runBrowserVforkFixture( + page, + baseURL!, + ordinaryForkProgramPath, + undefined, + undefined, + ["fork-browser-trace"], + undefined, + true, + ); + const ordinaryTraceEnd = traces.length; + + expect(borrowed.exitCode, JSON.stringify(borrowed, null, 2)).toBe(0); + expect(ordinary.exitCode, JSON.stringify(ordinary, null, 2)).toBe(0); + expect(ordinary.stdout).toContain("FORK_MEMORY_CLONE_PASS"); + expect( + runtimeErrors.filter((message) => !/^console: \[\d+\] /.test(message)), + ).toEqual([]); + + const borrowedDispatches = partitionForkDispatches(browserTraceRun( + "browser-lifecycle", + traces, + borrowedTraceStart, + borrowedTraceEnd, + )); + expect(borrowedDispatches.length).toBeGreaterThan(0); + for (const dispatch of borrowedDispatches) { + const sequence = requireCompleteVforkSequence(dispatch); + expectPrivatePreparationEvidence(sequence.preparation); + } + + const ordinaryDispatches = partitionForkDispatches(browserTraceRun( + "browser-ordinary-fork", + traces, + ordinaryTraceStart, + ordinaryTraceEnd, + )); + expect(ordinaryDispatches).toHaveLength(1); + expect(ordinaryDispatches[0].mode).toBe("0"); + const ordinaryPreparations = ordinaryDispatches[0].traces.filter( + (trace) => trace.event === "fork_prepared", + ); + expect(ordinaryPreparations).toHaveLength(1); + expect(ordinaryPreparations[0].fields.get("memory_identity")).toBe("distinct"); + expect(ordinaryPreparations[0].fields.get("live_memory_delta")).toBe("1"); +}); + +test("contains a browser Worker factory failure after the borrow boundary", async ({ + page, + baseURL, +}) => { + test.setTimeout(180_000); + expect(baseURL).toBeTruthy(); + const traces = captureMechanismTraces(page); + + const failureTraceStart = traces.length; + const result = await runBrowserVforkFixture( + page, + baseURL!, + lifecycleProgramPath, + undefined, + initialAddressSpaceBytes(lifecycleProgramPath), + ["vfork-browser-start-failure", "no-successful-exec"], + undefined, + true, + true, + ); + const failureTraceEnd = traces.length; + const failureDispatches = partitionForkDispatches(browserTraceRun( + "browser-start-failure", + traces, + failureTraceStart, + failureTraceEnd, + )); + expect(failureDispatches).toHaveLength(1); + const failureSequence = requireVforkStartFailureSequence( + failureDispatches[0], + ); + expectPrivatePreparationEvidence(failureSequence.preparation); + expect(result.exitCode, JSON.stringify(result, null, 2)).toBe(139); + expect(result.stdout).not.toContain("PARENT_RESUME_ONE"); + expect(result.diagnostics).toHaveLength(1); + const containment = result.diagnostics.filter((diagnostic) => + diagnostic.source === "vfork address-space containment" + ); + expect(containment).toHaveLength(1); + expect(containment[0]).toMatchObject({ + status: 139, + source: "vfork address-space containment", + }); +}); + +test("vfork replays an actual side module through browser mode 1", async ({ + page, + baseURL, +}) => { + test.setTimeout(180_000); + expect(baseURL).toBeTruthy(); + const runtimeErrors = captureRuntimeErrors(page); + const traces = captureMechanismTraces(page); + const fixture = buildVforkSideModuleFixture(); + + try { + const sideTraceStart = traces.length; + const result = await runBrowserVforkFixture( + page, + baseURL!, + fixture.programPath, + undefined, + initialAddressSpaceBytes(fixture.programPath), + ["vfork-side-main", "/lib/libvfork-side.so"], + fixture.libraryPath, + true, + ); + const sideTraceEnd = traces.length; + + expect(result.exitCode, JSON.stringify(result, null, 2)).toBe(0); + expect(result.stderr).toBe(""); + expect(result.diagnostics).toEqual([]); + expect( + runtimeErrors.filter((message) => !/^console: \[\d+\] /.test(message)), + ).toEqual([]); + expect(result.stdout.match(/PRODUCTION_SIDE_VFORK_ROUND_TRIP/g)) + .toHaveLength(2); + expect(result.stdout).toContain("PRODUCTION_SIDE_VFORK_PASS"); + const sideDispatches = partitionForkDispatches(browserTraceRun( + "browser-side-module", + traces, + sideTraceStart, + sideTraceEnd, + )); + expect(sideDispatches).toHaveLength(2); + for (const dispatch of sideDispatches) { + const sequence = requireCompleteVforkSequence(dispatch); + expectPrivatePreparationEvidence(sequence.preparation); + } + } finally { + fixture.cleanup(); + } +}); + test("vfork keeps its browser parent parked through exit and exec", async ({ page, baseURL, @@ -242,6 +487,43 @@ test("vfork keeps its browser parent parked through exit and exec", async ({ expect(result.processEvents).toContain("exec"); }); +test("vfork repeats on the browser main thread without a second full Memory", async ({ + page, + baseURL, +}) => { + test.setTimeout(180_000); + expect(baseURL).toBeTruthy(); + const runtimeErrors = captureRuntimeErrors(page); + + const result = await runBrowserVforkFixture( + page, + baseURL!, + lifecycleProgramPath, + undefined, + initialAddressSpaceBytes(lifecycleProgramPath), + ["vfork-browser-fixture", "no-successful-exec"], + ); + + expect(result.exitCode, JSON.stringify(result, null, 2)).toBe(0); + expect(result.stderr).toBe(""); + expect(result.diagnostics).toEqual([]); + expect(runtimeErrors).toEqual([]); + expectOrdered(result.stdout, [ + "CHILD_EXIT_ONE", + "PARENT_RESUME_ONE", + "CHILD_EXIT_TWO", + "PARENT_RESUME_TWO", + "CHILD_FAILED_EXEC", + "PARENT_AFTER_FAILED_EXEC_EXIT", + "CHILD_NESTED_FORK_EAGAIN", + "CHILD_NESTED_VFORK_EAGAIN", + "CHILD_PTHREAD_EAGAIN", + "PARENT_AFTER_REJECTED_OWNERSHIP", + "PARENT_SKIPPED_EXEC_UNDER_NO_COPY_CEILING", + "PASS: VFORK_LIFECYCLE", + ]); +}); + test("vfork parks only its calling browser pthread", async ({ page, baseURL, @@ -289,6 +571,8 @@ test("vfork releases its browser parent after trap and signal", async ({ page, baseURL!, fatalProgramPath, + undefined, + initialAddressSpaceBytes(fatalProgramPath), ); expect(result.exitCode, JSON.stringify(result, null, 2)).toBe(0); @@ -329,6 +613,8 @@ test("vfork contains a compute-running browser borrower", async ({ page, baseURL!, externalSignalProgramPath, + undefined, + initialAddressSpaceBytes(externalSignalProgramPath), ); expect(result.exitCode, JSON.stringify(result, null, 2)).toBe(139); @@ -364,6 +650,8 @@ test("vfork preserves browser-visible POSIX process state", async ({ page, baseURL!, stateProgramPath, + undefined, + initialAddressSpaceBytes(stateProgramPath), ); expect(result.exitCode, JSON.stringify(result, null, 2)).toBe(0); @@ -371,8 +659,13 @@ test("vfork preserves browser-visible POSIX process state", async ({ expect(result.diagnostics).toEqual([]); expect(runtimeErrors).toEqual([]); expectOrdered(result.stdout, [ + "CHILD_INHERITED_POSIX_STATE", + "CHILD_MUTATED_PRIVATE_POSIX_STATE", + "CHILD_CONFIRMED_PRIVATE_POSIX_MUTATIONS", "PARENT_AFTER_STATE_CHILD", + "PARENT_POSIX_STATE_UNCHANGED", "PARENT_REAPED_STATE_CHILD", + "PARENT_CONFIRMED_EXACT_REAP", "PASS: VFORK_POSIX_STATE", ]); }); diff --git a/docs/measurements/2026-08-10-vfork-readiness.md b/docs/measurements/2026-08-10-vfork-readiness.md new file mode 100644 index 0000000000..3a10bf48bb --- /dev/null +++ b/docs/measurements/2026-08-10-vfork-readiness.md @@ -0,0 +1,246 @@ +# ABI 43 vfork mechanism readiness — 2026-08-10 + +## Outcome + +The mechanism gate passed at implementation commit +`334703abc` after production side-module replay was repaired in +`7a25d127b`. Follow-up gate hardening in `fee665caa` bounds every observation +to one named test run and one production dispatch, `d33cc6619` keeps allocator +measurement off the untraced runtime path, and `334703abc` cleans the compiled +side-module fixture after every success or failure. The gate observes ABI 43 +mode 1 in the real Node and browser kernel-worker dispatch paths. It does not +infer the mechanism from a coordinator or allocator helper used in isolation. + +The production observations show that mode 1 retains the parent's exact +`WebAssembly.Memory`, increases the alias count by one, and creates no full +process memory. The calling syscall remains pending until the child Worker +posts its exact `memory_quiescent` message and exact-generation teardown +finishes. Mode 0 is exercised through an ordinary guest `fork()` and observes +a distinct memory plus one live process-memory allocation. + +This remains a **partial vfork mechanism**, not a claim of full POSIX +`vfork`. After `child_may_access_memory`, browser Worker termination does not +provide a portable exact fence proving that the Worker can no longer access +shared memory. Kandelo therefore retains loud status-139 whole-address-space +containment for ambiguous teardown. Timeout, delay, polling, Worker +termination, and JavaScript object reachability are not treated as +quiescence evidence. + +| Gate | Status | Evidence | +| --- | --- | --- | +| Mechanism | PASS | Exact wrapper exited 0 at `334703abc` | +| Integration | NOT RUN | Credential and secure-exec integration belongs to later tasks | +| Release | NOT RUN | Outside this mechanism-readiness task | + +`ABI_VERSION` remains 43. No vfork import, fork mode, instrument-frame field, +memory-ownership protocol, safe-point architecture, or host protocol was +added. Ordinary fork mode 0 remains independent. + +## Production-path evidence + +`host/test/vfork-production-mechanism.test.ts` launches real +`NodeKernelHost` subprocesses. The trace is emitted by +`host/src/node-kernel-worker-entry.ts` only when the existing syscall-debug +switch is enabled. Allocator statistics are sampled only on that traced path; +a counting-source regression proves that mode 0 and mode 1 make no measurement +call when tracing is disabled. For every mode-1 child, the test observes: + +```text +dispatch mode=1 +vfork_prepared memory_identity=same live_memory_delta=0 alias_delta=1 +child_may_access_memory +memory_quiescent +exact_teardown +parent_released +``` + +`memory_quiescent` is recorded only in the production listener handling the +exact message sent after `worker-main` returns. `parent_released` is recorded +only after the lifetime coordinator accepts that terminal evidence and exact +teardown completes. Test-only BEGIN/END delimiters partition output from each +fresh host. Within a run, each slice begins at a production `dispatch` and ends +at the next dispatch or run end. The shared parser rejects duplicate run names, +events outside a run, missing events, duplicate events, and reordered events. +Consequently, a reused PID or channel in a later host or dispatch cannot +satisfy an earlier vfork. It also observes different parent and child syscall +channels, owner-control and replay-prefix addresses, scratch storage, and +externref generations. + +The same subprocess runs an instrumented program that calls ordinary +`fork()`. Production dispatch records `mode=0`, `memory_identity=distinct`, +and `live_memory_delta=1`; the guest also verifies that parent and child +memory mutations remain independent. + +`apps/browser-demos/test/vfork-lifecycle.spec.ts` repeats both observations +through `BrowserKernel` on Chromium, Firefox, and WebKit. Each browser case +slices only the traces added during that case and applies the same +dispatch-bounded parser. Browser traces come from +`host/src/browser-kernel-worker-entry.ts` under the existing +`enableSyscallLog` setting and require every event, including +`child_may_access_memory`, with the same ordering and memory identity/count +assertions. + +## Side-module and private-state evidence + +The side-module fixture is compiled and fork-instrumented through the normal +toolchain. Its main module loads a real shared object, enters `vfork()` from a +side-module frame twice, verifies a preserved frame local in both continuations, +calls the loaded symbol in each child and parent, waits for each child, and +uses the loader again before `dlclose`. + +Both Node and all three browser projects observe two production mode-1 +dispatches for this fixture, same-memory borrowing, and distinct child syscall +channel, replay prefix, scratch workspace, and externref generation. The +child-private loader snapshot is materialized without writing the archive +owned by the parked parent. + +The initial real fixture exposed a production defect: child status 6 followed +a Worker failure reporting that the borrowed child could not acquire the +dynamic-loader archive writer. Archive reconciliation was traced backward to +`__wpk_fork_module_state_table_reconcile`. The parent deliberately holds the +archive reader across its parked vfork syscall, while the child has already +materialized that exact immutable generation. Repair commit `7a25d127b` +therefore lets the borrowed child adopt and observe the immutable published +generation without acquiring the writer. Mutation entry points still require +the writer and remain forbidden. + +The focused component tests for the lifetime coordinator, workspace, +continuation objects, and borrowed replay remain useful supporting coverage, +but they are not the evidence for production mode selection, memory identity, +quiescence ordering, or side-module dispatch. + +The side-module fixture owns an idempotent cleanup handle. Node and browser +call sites invoke it in `finally`, and focused tests cover both ordinary cleanup +and partial-build failure cleanup. Repeated mechanism gates therefore do not +accumulate new repository-local fixture directories. + +## Worker-start failure boundary + +Production marks `child_may_access_memory` immediately before invoking the +deferred Worker factory. Therefore a Worker constructor/factory failure is +not a pre-borrow rollback state in the approved architecture. The gate injects +a one-shot failure at that real factory boundary on Node and browser and +observes: + +- `worker_start_failed` strictly after `child_may_access_memory`; +- no child-generated `memory_quiescent`; +- no `parent_released` event or guest parent-resume marker; +- exactly one `vfork address-space containment` diagnostic; and +- process exit status 139 without a wedge. + +Earlier setup failures can roll back before memory access, but no Worker has +been constructed at that point. The gate does not relabel a coordinator-only +completion as a pre-borrow Worker crash. + +## POSIX and lifecycle evidence + +The production guests cover main-thread and pthread callers, repeated calls, +rejected nesting/overlap, direct `_exit`, successful and failed `exec`, trap, +cooperative signal death, external kill, and exact `waitpid` reaping. The +pthread fixture observes its sibling continue running while only the calling +thread remains parked. + +In `vfork-posix-state`, the child now reads state back after each mutation +before `_exit`: shared open-file-description offset, descriptor flags, +current working directory, process group and session/parentage, real and +effective group/user IDs, signal disposition, and signal mask. Only after all +readbacks succeed does it emit +`CHILD_CONFIRMED_PRIVATE_POSIX_MUTATIONS`. The parent then verifies that +descriptor-table, cwd, credential, process-group, signal-disposition, and +signal-mask mutations did not leak, while the shared open-file-description +offset did. + +## Environment and artifacts + +All commands ran through `scripts/dev-shell.sh`. + +| Component | Observed version | +| --- | --- | +| Node.js | 24.15.0 | +| Chromium | 149.0.7827.55 | +| Firefox | 151.0 | +| WebKit | 26.5 | + +| Artifact | SHA-256 | +| --- | --- | +| `local-binaries/kernel.wasm` | `ba9fda2e8ee45ee60048697577c46f80869494be77ccd4c499e6d5b175a3a946` | +| `vfork-lifecycle.wasm` | `1aac8d9f4d9f9ef8afd94a972b265026ee2c3f68f7944992b8217014d94d6f4c` | +| `vfork-from-thread.wasm` | `3f200ad015e262991ce8ece76a69325d50fb5f94da325f4846fb75873bbd2b1c` | +| `vfork-fatal-lifecycle.wasm` | `cfe13768fb204c486261ad78dbaf6203e12541576c612191a911d7bc6074724d` | +| `vfork-external-signal.wasm` | `87d6884830f0994e633a3bcc7ef502af4d13ee8990356ff81a8a3a05f148b49a` | +| `vfork-posix-state.wasm` | `97ff09e34ff33e1288679d6b6a56b46e15ccae971a7ce8e606eecec2140c0bd1` | +| `exec-child.wasm` | `9bd08d3cdd8db768af6162608df115b9ec9f73bc2a01ab69bcb53dab028f988f` | +| `fork-memory-clone.wasm` | `23f6ad1d41875c66693741a4f9c17aea517f933a28dd14b17fe2d48a01bfd306` | +| `vfork-side-main.wasm` | `44a03deef7aefa83205cbda489e93f9b8416ca83d5197d781cd484c029823fcf` | +| `libvfork-side.so` | `f426d4227aac1bad151aa6960f96dcb1950f75faa5bb37493cc3900db449dc52` | + +The no-copy guest ceiling remains 16,973,824 bytes for each lifecycle, +pthread, fatal-lifecycle, external-signal, and POSIX-state parent. Successful +`exec` is tested separately because it legitimately creates replacement +program memory. + +## Commands and observed status + +The exact final gate command was: + +```bash +scripts/dev-shell.sh bash scripts/run-vfork-readiness.sh mechanism +``` + +Status: exit 0 at `334703abc`. + +- wrapper interface: PASS; both modes plus any extra argument returned the + exact usage string and status 2; +- program build: PASS; +- host production build: PASS; +- Vitest: 14 files and 70 tests passed; +- complete host-target `fork-instrument` unit, integration, and doc-test + suite: PASS; and +- Playwright: 36 tests passed across Chromium, Firefox, and WebKit. + +The side-module production defect was reproduced with: + +```bash +scripts/dev-shell.sh bash -lc \ + 'cd host && KANDELO_REQUIRE_SIDE_MODULE_FORK_E2E=1 npx vitest run \ + test/fork-from-dlopen-side-module-e2e.test.ts -t "mode-1 vfork"' +``` + +RED: one selected test failed with child status 6 and the archive-writer +prohibition. After `7a25d127b`, the focused regression plus its immutable +generation test passed two selected assertions. + +Production observation and containment were checked with: + +```bash +scripts/dev-shell.sh bash -lc \ + 'cd host && npm run build && \ + npx vitest run test/vfork-production-mechanism.test.ts' +``` + +Status: PASS, two tests. The equivalent browser observations are part of the +36-test three-engine wrapper run. + +Child POSIX readback was assertion-first. Before adding the child marker, the +focused lifecycle test failed because +`CHILD_CONFIRMED_PRIVATE_POSIX_MUTATIONS` was absent; after implementing all +readbacks it passed. + +The generated package program index was regenerated because the affected +host-runtime sources participate in package build contexts, then verified: + +```bash +scripts/dev-shell.sh bash -lc ' +host_target=$(rustc -vV | sed -n "s/^host: //p") +target/$host_target/release/xtask build-deps program-index \ + --source-repo-root "$PWD" "$PWD/packages/registry" \ + "$PWD/packages/registry/program-packages.json" +target/$host_target/release/xtask build-deps program-index-context-check \ + --source-repo-root "$PWD" +' +``` + +Status: PASS. + +No performance measurement was made and no performance claim is attached to +this correctness gate. diff --git a/host/src/browser-kernel-worker-entry.ts b/host/src/browser-kernel-worker-entry.ts index ba0ace692f..1ac90b1c33 100644 --- a/host/src/browser-kernel-worker-entry.ts +++ b/host/src/browser-kernel-worker-entry.ts @@ -113,6 +113,7 @@ import { type ExactProcessGenerationDetachResult, } from "./process-generation-detach"; import { ProcessMemoryCreatorGate } from "./process-memory-creator-gate"; +import { sampleProcessMemoryStats } from "./fork-mechanism-trace"; import type { HostDiagnostic, MainToKernelMessage, @@ -148,6 +149,14 @@ const pendingLazyRegistrationMessages: LazyRegistrationMessage[] = []; let lazyRegistrationTail: Promise = Promise.resolve(); const rootfsSnapshotGate = new RootfsSnapshotGate(); const processMemoryCreators = new ProcessMemoryCreatorGate(); +let vforkMechanismTraceEnabled = false; +let injectVforkWorkerStartFailure = false; +let injectedVforkWorkerStartFailure = false; + +function traceVforkMechanism(event: string, fields: string): void { + if (!vforkMechanismTraceEnabled) return; + console.log(`[vfork-mechanism] event=${event} ${fields}`); +} // Process tracking interface ForkReplayContext { @@ -965,6 +974,11 @@ async function handleInit(msg: Extract) { maxPages = msg.config.maxMemoryPages; defaultThreadSlots = msg.config.defaultThreadSlots ?? DEFAULT_PROCESS_THREAD_SLOTS; defaultEnv = msg.config.env; + vforkMechanismTraceEnabled = msg.config.enableSyscallLog === true; + injectVforkWorkerStartFailure = defaultEnv.includes( + "KANDELO_TEST_VFORK_WORKER_START_FAILURE=once", + ); + injectedVforkWorkerStartFailure = false; processMemoryAllocator = new ProcessMemoryAllocator({ // The sampled byte budget is the concurrency authority. Keep the count // ceiling high enough that small address spaces do not inherit the @@ -1567,6 +1581,13 @@ function installProcessWorkerListeners( // m.status — worker-main posted {type:"exit"}, normal exit path. worker.on("error", (err: Error) => { if (intentionallyTerminated.has(worker as object)) return; + const active = processes.get(pid); + if ( + active?.worker === worker + && vforkLifetimes.phaseForChild(active) !== undefined + ) { + traceVforkMechanism("worker_crashed", `child=${pid}`); + } const signum = classifiedSignalOrFallback(err); const status = signalExitStatus(signum); finalize( @@ -1611,6 +1632,9 @@ function installProcessWorkerListeners( // The browser entry emits this only after worker-main returns. Unlike // Browser Worker.terminate(), this is an exact-generation ownership // fence and can authorize dropping the allocator's strong reference. + if (vforkLifetimes.phaseForChild(process) !== undefined) { + traceVforkMechanism("memory_quiescent", `child=${pid}`); + } process.workerQuiescence.settle(); } if (m.type === "exec_retired" && m.tid === undefined) { @@ -1649,6 +1673,10 @@ async function handleFork( borrowedReplay?: ForkBorrowedReplayWorkspace, releaseCreatorAdmission?: () => void, ): Promise { + traceVforkMechanism( + "dispatch", + `mode=${mode} parent=${parentPid} child=${childPid}`, + ); if (mode === PROCESS_FORK_MODE_VFORK) { if (!borrowedReplay) { throw new VforkAddressSpaceBusyError( @@ -1701,6 +1729,10 @@ function completeVforkGenerationTeardown( ); return; } + traceVforkMechanism( + "exact_teardown", + `child_channel=${info.channelOffset} reason=${reason}`, + ); releaseVforkWorkspace(info); if (phase === "starting") { vforkLifetimes.completeWithoutBorrow( @@ -1783,6 +1815,10 @@ async function finishVforkDisposition( if (disposition.kind === "contain-address-space") { return containVforkAddressSpace(disposition, childGeneration, parentPid); } + traceVforkMechanism( + "parent_released", + `parent=${parentPid} child=${disposition.childPid}`, + ); return [childGeneration.channelOffset]; } @@ -1818,7 +1854,15 @@ async function handleVfork( parentInfo.programModule = new WebAssembly.Module(parentInfo.programBytes); } + const memoryStatsBefore = sampleProcessMemoryStats( + vforkMechanismTraceEnabled, + processMemoryAllocator, + ); const childMemoryLease = parentInfo.memoryLease.retainAlias(); + const memoryStatsAfterAlias = sampleProcessMemoryStats( + vforkMechanismTraceEnabled, + processMemoryAllocator, + ); let childMemoryLeaseConsumed = false; let workspaceAllocation: ReturnType; try { @@ -1930,9 +1974,16 @@ async function handleVfork( kernelAbiVersion: kernelWorker.getKernelAbiVersion(), }; - childWorker = new DeferredWorkerHandle( - () => workerAdapter.createWorker(childInitData), - ); + childWorker = new DeferredWorkerHandle(() => { + if ( + injectVforkWorkerStartFailure + && !injectedVforkWorkerStartFailure + ) { + injectedVforkWorkerStartFailure = true; + throw new Error("injected vfork Worker constructor failure"); + } + return workerAdapter.createWorker(childInitData); + }); launchedWorker = childWorker; bindForkHostImports(childWorker, forkHostImports); childGeneration = { @@ -1955,6 +2006,23 @@ async function handleVfork( externrefGeneration: externrefGrant.generation, vforkWorkspace: workspaceOwnership, }; + if (memoryStatsBefore && memoryStatsAfterAlias) { + traceVforkMechanism( + "vfork_prepared", + `mode=1 parent=${parentPid} child=${childPid} memory_identity=${ + childGeneration.memory === parentMemory ? "same" : "distinct" + } live_memory_delta=${ + memoryStatsAfterAlias.liveMemories - memoryStatsBefore.liveMemories + } alias_delta=${ + memoryStatsAfterAlias.liveAliases - memoryStatsBefore.liveAliases + } parent_channel=${parentInfo.channelOffset} child_channel=${childChannelOffset} ` + + `owner_control=${childInitData.forkOwnerControlAddr} ` + + `child_prefix=${childInitData.forkPrivatePrefixAddr} ` + + `scratch=${childInitData.forkScratchAddr} ` + + `externref_parent=${parentInfo.externrefGeneration.id} ` + + `externref_child=${childGeneration.externrefGeneration.id}`, + ); + } lifetime = vforkLifetimes.begin( parentPid, childPid, @@ -1980,6 +2048,10 @@ async function handleVfork( parentMemory, () => { vforkLifetimes.markChildMayAccessMemory(childGeneration!); + traceVforkMechanism( + "child_may_access_memory", + `parent=${parentPid} child=${childPid}`, + ); try { launchedWorker.start(); } catch (error) { @@ -1989,6 +2061,10 @@ async function handleVfork( childGeneration!, error, ); + traceVforkMechanism( + "worker_start_failed", + `parent=${parentPid} child=${childPid}`, + ); } }, () => { @@ -2136,6 +2212,10 @@ async function handleOrdinaryFork( // WHY: teardown and compilation below yield. A sibling exec may then retire // the parent's exact generation, so the committed fork must pass // retired-memory admission and own its clone before the first await. + const memoryStatsBeforeClone = sampleProcessMemoryStats( + vforkMechanismTraceEnabled, + processMemoryAllocator, + ); const childMemoryLease = acquireForkMemoryClone( processMemoryAllocator, parentMemory, @@ -2143,6 +2223,20 @@ async function handleOrdinaryFork( childLayout.maximumPages, ); const childMemory = childMemoryLease.memory; + const memoryStatsAfterClone = sampleProcessMemoryStats( + vforkMechanismTraceEnabled, + processMemoryAllocator, + ); + if (memoryStatsBeforeClone && memoryStatsAfterClone) { + traceVforkMechanism( + "fork_prepared", + `mode=${mode} parent=${parentPid} child=${childPid} memory_identity=${ + childMemory === parentMemory ? "same" : "distinct" + } live_memory_delta=${ + memoryStatsAfterClone.liveMemories - memoryStatsBeforeClone.liveMemories + }`, + ); + } const childChannelOffset = childLayout.channelOffset; let childWorker: DeferredWorkerHandle | undefined; let registered = false; diff --git a/host/src/fork-mechanism-trace.ts b/host/src/fork-mechanism-trace.ts new file mode 100644 index 0000000000..8f2c80ca72 --- /dev/null +++ b/host/src/fork-mechanism-trace.ts @@ -0,0 +1,10 @@ +export interface ProcessMemoryStatsSource { + getRetirementStats(): T; +} + +export function sampleProcessMemoryStats( + enabled: boolean, + source: ProcessMemoryStatsSource, +): T | undefined { + return enabled ? source.getRetirementStats() : undefined; +} diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts index 35d29a7a80..c20f8de881 100644 --- a/host/src/node-kernel-worker-entry.ts +++ b/host/src/node-kernel-worker-entry.ts @@ -124,6 +124,7 @@ import { type ExactProcessGenerationDetachResult, } from "./process-generation-detach"; import { ProcessMemoryCreatorGate } from "./process-memory-creator-gate"; +import { sampleProcessMemoryStats } from "./fork-mechanism-trace"; import type { PlatformIO } from "./types"; import type { CentralizedWorkerInitMessage, @@ -251,6 +252,12 @@ const vmInterruptTimers = new VmInterruptTimerManager( const reportedExits = new Set(); const rootfsSnapshotGate = new RootfsSnapshotGate(); const processMemoryCreators = new ProcessMemoryCreatorGate(); +const vforkMechanismTraceEnabled = Boolean(process.env.KERNEL_SYSCALL_LOG); + +function traceVforkMechanism(event: string, fields: string): void { + if (!vforkMechanismTraceEnabled) return; + console.log(`[vfork-mechanism] event=${event} ${fields}`); +} // Workers terminated by the kernel-worker entry itself (handleExit / // handleExec / handleTerminate). The crash safety-net listener checks @@ -311,6 +318,9 @@ function installProcessWorkerListeners( && message.pid === pid && message.tid === undefined ) { + if (vforkLifetimes.phaseForChild(process) !== undefined) { + traceVforkMechanism("memory_quiescent", `child=${pid}`); + } process.workerQuiescence.settle(); return; } @@ -1373,6 +1383,10 @@ async function handleFork( borrowedReplay?: ForkBorrowedReplayWorkspace, releaseCreatorAdmission?: () => void, ): Promise { + traceVforkMechanism( + "dispatch", + `mode=${mode} parent=${parentPid} child=${childPid}`, + ); if (mode === PROCESS_FORK_MODE_VFORK) { if (!borrowedReplay) { throw new VforkAddressSpaceBusyError( @@ -1425,6 +1439,10 @@ function completeVforkGenerationTeardown( ); return; } + traceVforkMechanism( + "exact_teardown", + `child_channel=${info.channelOffset} reason=${reason}`, + ); releaseVforkWorkspace(info); if (phase === "starting") { vforkLifetimes.completeWithoutBorrow( @@ -1523,6 +1541,10 @@ async function finishVforkDisposition( // A sibling pthread can exec or exit the parent image while its calling // thread is parked. In that case the original channel no longer exists and // the kernel completion guard must observe no current parent generation. + traceVforkMechanism( + "parent_released", + `parent=${parentPid} child=${disposition.childPid}`, + ); return [childGeneration.channelOffset]; } @@ -1560,7 +1582,15 @@ async function handleVfork( parentInfo.programModule = new WebAssembly.Module(parentProgram); } + const memoryStatsBefore = sampleProcessMemoryStats( + vforkMechanismTraceEnabled, + processMemoryAllocator, + ); const childMemoryLease = parentInfo.memoryLease.retainAlias(); + const memoryStatsAfterAlias = sampleProcessMemoryStats( + vforkMechanismTraceEnabled, + processMemoryAllocator, + ); let childMemoryLeaseConsumed = false; let workspaceAllocation: ReturnType; try { @@ -1696,6 +1726,23 @@ async function handleVfork( externrefGeneration: externrefGrant.generation, vforkWorkspace: workspaceOwnership, }; + if (memoryStatsBefore && memoryStatsAfterAlias) { + traceVforkMechanism( + "vfork_prepared", + `mode=1 parent=${parentPid} child=${childPid} memory_identity=${ + childGeneration.memory === parentMemory ? "same" : "distinct" + } live_memory_delta=${ + memoryStatsAfterAlias.liveMemories - memoryStatsBefore.liveMemories + } alias_delta=${ + memoryStatsAfterAlias.liveAliases - memoryStatsBefore.liveAliases + } parent_channel=${parentInfo.channelOffset} child_channel=${childChannelOffset} ` + + `owner_control=${childInitData.forkOwnerControlAddr} ` + + `child_prefix=${childInitData.forkPrivatePrefixAddr} ` + + `scratch=${childInitData.forkScratchAddr} ` + + `externref_parent=${parentInfo.externrefGeneration.id} ` + + `externref_child=${childGeneration.externrefGeneration.id}`, + ); + } lifetime = vforkLifetimes.begin( parentPid, childPid, @@ -1721,6 +1768,10 @@ async function handleVfork( parentMemory, () => { vforkLifetimes.markChildMayAccessMemory(childGeneration!); + traceVforkMechanism( + "child_may_access_memory", + `parent=${parentPid} child=${childPid}`, + ); try { launchedWorker.start(); } catch (error) { @@ -1733,6 +1784,10 @@ async function handleVfork( childGeneration!, error, ); + traceVforkMechanism( + "worker_start_failed", + `parent=${parentPid} child=${childPid}`, + ); } }, () => { @@ -1878,6 +1933,10 @@ async function handleOrdinaryFork( // WHY: compilation below yields. A sibling exec may then retire the parent's // exact generation, so the committed fork must pass retired-memory // admission and own its clone before the first await. + const memoryStatsBeforeClone = sampleProcessMemoryStats( + vforkMechanismTraceEnabled, + processMemoryAllocator, + ); const childMemoryLease = acquireForkMemoryClone( processMemoryAllocator, parentMemory, @@ -1885,6 +1944,21 @@ async function handleOrdinaryFork( childLayout.maximumPages, ); const childMemory = childMemoryLease.memory; + const memoryStatsAfterClone = sampleProcessMemoryStats( + vforkMechanismTraceEnabled, + processMemoryAllocator, + ); + if (memoryStatsBeforeClone && memoryStatsAfterClone) { + traceVforkMechanism( + "fork_prepared", + `mode=${mode} parent=${parentPid} child=${childPid} memory_identity=${ + childMemory === parentMemory ? "same" : "distinct" + } live_memory_delta=${ + memoryStatsAfterClone.liveMemories + - memoryStatsBeforeClone.liveMemories + }`, + ); + } const childChannelOffset = childLayout.channelOffset; let childWorker: DeferredWorkerHandle | undefined; let registered = false; diff --git a/host/src/worker-adapter.ts b/host/src/worker-adapter.ts index bc9d7b1987..3e59891398 100644 --- a/host/src/worker-adapter.ts +++ b/host/src/worker-adapter.ts @@ -180,6 +180,7 @@ export class NodeWorkerAdapter implements WorkerAdapter { private _compiledEntry: URL | false | undefined; private _bundledSourceEntry: URL | false | undefined; private readonly initializeByMessage: boolean; + private injectedVforkStartFailure = false; constructor(entryUrl?: URL) { // WHY: arbitrary custom entries may read workerData directly and do not @@ -291,6 +292,20 @@ export class NodeWorkerAdapter implements WorkerAdapter { } createWorker(workerData: unknown): WorkerHandle { + // Test-only fault boundary for the production DeferredWorker factory. + // The mode check leaves kernel/top-level/ordinary Workers untouched, and + // the one-shot throw occurs exactly where a native Worker constructor can + // fail synchronously after vfork has crossed child_may_access_memory. + if ( + process.env.KANDELO_TEST_VFORK_WORKER_START_FAILURE === "once" + && !this.injectedVforkStartFailure + && typeof workerData === "object" + && workerData !== null + && (workerData as { forkMode?: unknown }).forkMode === 1 + ) { + this.injectedVforkStartFailure = true; + throw new Error("injected vfork Worker constructor failure"); + } const initialization = nodeWorkerInitialization( workerData, this.initializeByMessage, diff --git a/host/src/worker-main.ts b/host/src/worker-main.ts index d9c9105aab..bcb3ac34fb 100644 --- a/host/src/worker-main.ts +++ b/host/src/worker-main.ts @@ -2822,6 +2822,13 @@ function createProcessTableReplicationOwner(options: { readonly newArena: () => ForkModuleStateArena; readonly materializeModules: (snapshot: DylinkForkArchiveSnapshot) => void; readonly restoreSnapshots: boolean; + /** + * The vfork parent holds the archive reader from capture until its parked + * fork syscall returns, so the borrowed child's already-materialized + * snapshot cannot change. Generation guards may observe that local + * generation without mutating the parent's reader/writer lock words. + */ + readonly borrowedImmutableSnapshot?: boolean; readonly label: string; }): ProcessTableReplicationOwner { const generationAddress = new WebAssembly.Global( @@ -2870,6 +2877,13 @@ function createProcessTableReplicationOwner(options: { }, `${options.label}: table replica`, ); + if (options.borrowedImmutableSnapshot) { + // Capture holds the parent's process-archive reader until the parked + // syscall returns. Adopt the exact immutable generation the child has + // already materialized so side-module guards report truthful state + // without attempting to mutate either archive lock word. + replica.adoptPublishedGeneration(options.dlopen.archive.generation()); + } const reconcileLocked = (): number => { replicaMaterializing = true; @@ -2951,7 +2965,9 @@ function createProcessTableReplicationOwner(options: { }); const reconcileNow = (): number => - options.dlopen.withArchiveWriter(reconcileLocked); + options.borrowedImmutableSnapshot + ? replica.generation() + : options.dlopen.withArchiveWriter(reconcileLocked); const abortActiveMutations = (): void => { while (mutationContexts.length > 0) { mutationContexts.pop(); @@ -3828,6 +3844,7 @@ export async function centralizedWorkerMain( // The process table journal is for separately instantiated pthread // Workers and later generations, not a second initial child restore. restoreSnapshots: !initData.isForkChild, + borrowedImmutableSnapshot: borrowedForkChild, label: `pid=${pid}`, }); if (initData.isForkChild) { diff --git a/host/test/fixtures/vfork-production-trace-runner.ts b/host/test/fixtures/vfork-production-trace-runner.ts new file mode 100644 index 0000000000..870782695f --- /dev/null +++ b/host/test/fixtures/vfork-production-trace-runner.ts @@ -0,0 +1,80 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { MemoryFileSystem } from "../../src/vfs/memory-fs"; +import { runCentralizedProgram } from "../centralized-test-helper"; +import { buildVforkSideModuleFixture } from "../vfork-side-module-fixture"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../../.."); + +async function traceRun(name: string, operation: () => Promise): Promise { + console.log(`[vfork-mechanism-run] begin=${name}`); + try { + return await operation(); + } finally { + console.log(`[vfork-mechanism-run] end=${name}`); + } +} + +const lifecycle = await traceRun("lifecycle", () => runCentralizedProgram({ + programPath: join( + repoRoot, + "local-binaries/programs/wasm32/vfork-lifecycle.wasm", + ), + argv: ["vfork-production-trace", "no-successful-exec"], + useDefaultRootfs: false, + timeout: 15_000, +})); +if (lifecycle.exitCode !== 0) { + throw new Error( + `vfork trace fixture failed (${lifecycle.exitCode}): ${lifecycle.stderr}`, + ); +} + +const ordinary = await traceRun("ordinary", () => runCentralizedProgram({ + programPath: new URL("./fork-memory-clone.wasm", import.meta.url).pathname, + argv: ["fork-production-trace"], + useDefaultRootfs: false, + timeout: 15_000, +})); +if (ordinary.exitCode !== 0) { + throw new Error( + `fork trace fixture failed (${ordinary.exitCode}): ${ordinary.stderr}`, + ); +} + +const sideFixture = buildVforkSideModuleFixture(); +try { + const sideBytes = new Uint8Array(readFileSync(sideFixture.libraryPath)); + const imageOwner = MemoryFileSystem.create( + new SharedArrayBuffer(Math.max(2 * 1024 * 1024, sideBytes.length * 4)), + ); + imageOwner.mkdir("/lib", 0o755); + imageOwner.createFileWithOwner( + "/lib/libvfork-side.so", + 0o755, + 0, + 0, + sideBytes, + ); + const sideImage = await imageOwner.saveImage(); + const side = await traceRun("side-module", () => runCentralizedProgram({ + programPath: sideFixture.programPath, + argv: ["vfork-side-main", "/lib/libvfork-side.so"], + rootfsImage: sideImage, + timeout: 30_000, + })); + if ( + side.exitCode !== 0 + || !side.stdout.includes("PRODUCTION_SIDE_VFORK_PASS") + ) { + throw new Error( + `side-module vfork trace fixture failed (${side.exitCode}): ${side.stderr}`, + ); + } +} finally { + sideFixture.cleanup(); +} + +console.log("PRODUCTION_SIDE_TRACE_RUNNER_PASS"); +console.log("PRODUCTION_VFORK_TRACE_RUNNER_PASS"); diff --git a/host/test/fixtures/vfork-side-main.c b/host/test/fixtures/vfork-side-main.c new file mode 100644 index 0000000000..f8dc33ea0a --- /dev/null +++ b/host/test/fixtures/vfork-side-main.c @@ -0,0 +1,33 @@ +#include +#include +#include +#include + +typedef int (*side_vfork_fn)(int (*)(void)); +typedef int (*side_probe_fn)(void); + +int main(int argc, char **argv) { + void *lib = dlopen(argv[1], RTLD_NOW); + if (!lib) return 2; + side_vfork_fn side_vfork = (side_vfork_fn)dlsym(lib, "side_vfork"); + side_probe_fn side_probe = (side_probe_fn)dlsym(lib, "side_probe"); + if (!side_vfork || !side_probe || side_probe() != 73) return 3; + + for (int iteration = 0; iteration < 2; iteration++) { + int pid = side_vfork(vfork); + if (pid < 0) return 4; + if (pid == 0) { + if (side_probe() != 73) _exit(95); + _exit(0); + } + int status = 0; + if (waitpid(pid, &status, 0) != pid) return 5; + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) return 6; + if (side_probe() != 73) return 7; + puts("PRODUCTION_SIDE_VFORK_ROUND_TRIP"); + } + + if (dlclose(lib) != 0) return 8; + puts("PRODUCTION_SIDE_VFORK_PASS"); + return 0; +} diff --git a/host/test/fixtures/vfork-side-module.c b/host/test/fixtures/vfork-side-module.c new file mode 100644 index 0000000000..5cdbc84b31 --- /dev/null +++ b/host/test/fixtures/vfork-side-module.c @@ -0,0 +1,23 @@ +typedef int (*vfork_fn)(void); + +extern void _exit(int); + +static int loader_probe = 73; + +int side_probe(void) { + return loader_probe; +} + +int side_vfork(vfork_fn invoke_vfork) { + volatile int preserved = 101; + int pid = invoke_vfork(); + if (preserved != 101 || loader_probe != 73) _exit(94); + return pid; +} + +#include "abi_constants.h" + +__attribute__((export_name("__abi_version"))) +unsigned __abi_version(void) { + return WASM_POSIX_ABI_VERSION; +} diff --git a/host/test/fixtures/vfork-start-failure-runner.ts b/host/test/fixtures/vfork-start-failure-runner.ts new file mode 100644 index 0000000000..eabb299ece --- /dev/null +++ b/host/test/fixtures/vfork-start-failure-runner.ts @@ -0,0 +1,28 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runCentralizedProgram } from "../centralized-test-helper"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../../.."); + +console.log("[vfork-mechanism-run] begin=start-failure"); +let result; +try { + result = await runCentralizedProgram({ + programPath: join( + repoRoot, + "local-binaries/programs/wasm32/vfork-lifecycle.wasm", + ), + argv: ["vfork-start-failure", "no-successful-exec"], + useDefaultRootfs: false, + timeout: 15_000, + }); +} finally { + console.log("[vfork-mechanism-run] end=start-failure"); +} + +console.log(`VFORK_START_FAILURE_RESULT ${JSON.stringify({ + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + diagnostics: result.hostDiagnostics, +})}`); diff --git a/host/test/fork-borrowed-replay.test.ts b/host/test/fork-borrowed-replay.test.ts index 2a1d671275..84abbe17a1 100644 --- a/host/test/fork-borrowed-replay.test.ts +++ b/host/test/fork-borrowed-replay.test.ts @@ -130,6 +130,7 @@ describe("borrowed fork replay", () => { // final page models a child-owned mapping in the shared vfork address // space; parent continuation and module-state pages remain read-only. const childModuleBuffer = 7 * PAGE_SIZE; + expect(childModuleBuffer).not.toBe(moduleBuffer); const childWorker = new Worker( new URL("./fixtures/borrowed-fork-replay-worker.ts", import.meta.url), { diff --git a/host/test/fork-from-dlopen-side-module-e2e.test.ts b/host/test/fork-from-dlopen-side-module-e2e.test.ts index e55dcb9cb6..cb3c013917 100644 --- a/host/test/fork-from-dlopen-side-module-e2e.test.ts +++ b/host/test/fork-from-dlopen-side-module-e2e.test.ts @@ -18,6 +18,8 @@ import { readForkInstrumentCapabilities, } from "../src/dylink"; import { runCentralizedProgram } from "./centralized-test-helper"; +import { MemoryFileSystem } from "../src/vfs/memory-fs"; +import { buildVforkSideModuleFixture } from "./vfork-side-module-fixture"; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(__dirname, "../.."); @@ -213,6 +215,38 @@ describe.skipIf(!hasPrerequisites)("fork from a dlopened side module", () => { expect(result.stdout).toContain("side fork ok"); }, 30_000); + it("runs mode-1 vfork from a real side-module frame in the production worker path", async () => { + const fixture = buildVforkSideModuleFixture(); + try { + const libraryBytes = new Uint8Array(readFileSync(fixture.libraryPath)); + const imageOwner = MemoryFileSystem.create( + new SharedArrayBuffer(Math.max(2 * 1024 * 1024, libraryBytes.length * 4)), + ); + imageOwner.mkdir("/lib", 0o755); + imageOwner.createFileWithOwner( + "/lib/libvforkinside.so", + 0o755, + 0, + 0, + libraryBytes, + ); + + const result = await runCentralizedProgram({ + programPath: fixture.programPath, + argv: ["vfork-from-side-main", "/lib/libvforkinside.so"], + timeout: 30_000, + rootfsImage: await imageOwner.saveImage(), + }); + expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout.match(/PRODUCTION_SIDE_VFORK_ROUND_TRIP/g)) + .toHaveLength(2); + expect(result.stdout).toContain("PRODUCTION_SIDE_VFORK_PASS"); + } finally { + fixture.cleanup(); + } + }, 30_000); + it("replays a fork issued while dlopen runs a side-module constructor", async () => { const libraryPath = buildSharedLibrary(` extern int fork(void); diff --git a/host/test/fork-mechanism-trace.test.ts b/host/test/fork-mechanism-trace.test.ts new file mode 100644 index 0000000000..d0cd22c2a5 --- /dev/null +++ b/host/test/fork-mechanism-trace.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { sampleProcessMemoryStats } from "../src/fork-mechanism-trace"; + +describe("fork mechanism trace sampling", () => { + it("does not scan allocator records when tracing is disabled", () => { + let samples = 0; + const allocator = { + getRetirementStats: () => { + samples++; + return { liveMemories: 1, liveAliases: 1 }; + }, + }; + + expect(sampleProcessMemoryStats(false, allocator)).toBeUndefined(); + expect(samples).toBe(0); + expect(sampleProcessMemoryStats(true, allocator)).toEqual({ + liveMemories: 1, + liveAliases: 1, + }); + expect(samples).toBe(1); + }); +}); diff --git a/host/test/fork-process-continuation.test.ts b/host/test/fork-process-continuation.test.ts index 0fb3280c7c..bed17c92a5 100644 --- a/host/test/fork-process-continuation.test.ts +++ b/host/test/fork-process-continuation.test.ts @@ -284,6 +284,16 @@ describe("ForkProcessContinuationCoordinator", () => { "borrowed child", "read-only", ); + expect(child.coordinator).not.toBe(parent.coordinator); + expect(child.arena).not.toBe(parent.arena); + for (const activationId of [0, 4, 9]) { + expect(child.continuations.get(activationId)).not.toBe( + parent.continuations.get(activationId), + ); + } + for (const [activationId, privatePrefix] of privatePrefixes) { + expect(privatePrefix).not.toBe(parent.coordinator.rootFor(activationId)); + } child.arena.attachBorrowed(arenaRoot); const prefixRequests: number[] = []; child.coordinator.attachBorrowedChild(child.arena, (request) => { diff --git a/host/test/process-table-replication.test.ts b/host/test/process-table-replication.test.ts index 78f5ffae70..84b5ffa931 100644 --- a/host/test/process-table-replication.test.ts +++ b/host/test/process-table-replication.test.ts @@ -189,4 +189,41 @@ describe("process table replication publication", () => { child.reconcileNow(); expect(applied).toEqual([3]); }); + + it("observes a borrowed immutable generation without acquiring its writer", () => { + const { archive } = archiveFixture(); + archive.publishTablePatch(patch()); + const dlopen = dlopenFixture(archive); + let writerAcquisitions = 0; + dlopen.withArchiveWriter = (_operation: () => T): T => { + writerAcquisitions++; + throw new Error("borrowed snapshot attempted archive mutation"); + }; + dlopen.acquireArchiveWriter = () => { + writerAcquisitions++; + throw new Error("borrowed snapshot attempted archive mutation"); + }; + const child = __testCreateProcessTableReplicationOwner({ + generationAddress: 64, + registry: { + restoreTableState: () => {}, + applyFuncrefTablePatch: () => {}, + } as unknown as ForkActivationRegistry, + dlopen, + newArena: () => arenaFixture(512), + materializeModules: () => { + throw new Error("borrowed snapshot was already materialized"); + }, + restoreSnapshots: false, + borrowedImmutableSnapshot: true, + label: "borrowed vfork child", + }) as TestTableReplicationOwner; + + expect(child.reconcileNow()).toBe(archive.generation()); + expect(writerAcquisitions).toBe(0); + expect(() => child.beginMutation()).toThrow( + "borrowed snapshot attempted archive mutation", + ); + expect(writerAcquisitions).toBe(1); + }); }); diff --git a/host/test/vfork-lifecycle-guest.test.ts b/host/test/vfork-lifecycle-guest.test.ts index db6b528b25..34d85ce0db 100644 --- a/host/test/vfork-lifecycle-guest.test.ts +++ b/host/test/vfork-lifecycle-guest.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; -import { tryResolveBinary } from "../src/binary-resolver"; +import { resolveBinary } from "../src/binary-resolver"; import { detectPtrWidth, extractHeapBase, @@ -10,14 +10,14 @@ import { import { computeProcessMemoryLayout } from "../src/process-memory"; import { runCentralizedProgram } from "./centralized-test-helper"; -const lifecycleProgram = tryResolveBinary("programs/vfork-lifecycle.wasm"); -const threadProgram = tryResolveBinary("programs/vfork-from-thread.wasm"); -const fatalProgram = tryResolveBinary("programs/vfork-fatal-lifecycle.wasm"); -const externalSignalProgram = tryResolveBinary( +const lifecycleProgram = resolveBinary("programs/vfork-lifecycle.wasm"); +const threadProgram = resolveBinary("programs/vfork-from-thread.wasm"); +const fatalProgram = resolveBinary("programs/vfork-fatal-lifecycle.wasm"); +const externalSignalProgram = resolveBinary( "programs/vfork-external-signal.wasm", ); -const stateProgram = tryResolveBinary("programs/vfork-posix-state.wasm"); -const execChild = tryResolveBinary("programs/exec-child.wasm"); +const stateProgram = resolveBinary("programs/vfork-posix-state.wasm"); +const execChild = resolveBinary("programs/exec-child.wasm"); function initialAddressSpaceBytes(programPath: string): number { const file = readFileSync(programPath); @@ -43,15 +43,15 @@ function expectOrdered(output: string, markers: readonly string[]): void { } describe("production vfork lifecycle", () => { - it.skipIf(!lifecycleProgram || !execChild)( + it( "keeps the parent parked through exit and failed exec, then releases on exec", async () => { const events: string[] = []; const result = await runCentralizedProgram({ - programPath: lifecycleProgram!, + programPath: lifecycleProgram, argv: ["vfork-lifecycle"], execPrograms: new Map([ - ["/bin/vfork-exec-child", execChild!], + ["/bin/vfork-exec-child", execChild], ]), useDefaultRootfs: false, timeout: 15_000, @@ -80,11 +80,42 @@ describe("production vfork lifecycle", () => { }, ); - it.skipIf(!threadProgram)( + it( + "repeats main-thread vfork without admitting a second full Memory", + async () => { + const result = await runCentralizedProgram({ + programPath: lifecycleProgram, + argv: ["vfork-lifecycle", "no-successful-exec"], + useDefaultRootfs: false, + timeout: 15_000, + maxProcessMemoryBytes: initialAddressSpaceBytes(lifecycleProgram), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + expect(result.hostDiagnostics).toEqual([]); + expectOrdered(result.stdout, [ + "CHILD_EXIT_ONE", + "PARENT_RESUME_ONE", + "CHILD_EXIT_TWO", + "PARENT_RESUME_TWO", + "CHILD_FAILED_EXEC", + "PARENT_AFTER_FAILED_EXEC_EXIT", + "CHILD_NESTED_FORK_EAGAIN", + "CHILD_NESTED_VFORK_EAGAIN", + "CHILD_PTHREAD_EAGAIN", + "PARENT_AFTER_REJECTED_OWNERSHIP", + "PARENT_SKIPPED_EXEC_UNDER_NO_COPY_CEILING", + "PASS: VFORK_LIFECYCLE", + ]); + }, + ); + + it( "parks a pthread caller while its sibling and child use independent channels", async () => { const result = await runCentralizedProgram({ - programPath: threadProgram!, + programPath: threadProgram, argv: ["vfork-from-thread"], useDefaultRootfs: false, timeout: 15_000, @@ -92,7 +123,7 @@ describe("production vfork lifecycle", () => { // pthread creation grows it before vfork, so any attempted child // allocation would sample an already-exhausted budget and fail. A // passing child therefore used the parent's existing Memory alias. - maxProcessMemoryBytes: initialAddressSpaceBytes(threadProgram!), + maxProcessMemoryBytes: initialAddressSpaceBytes(threadProgram), }); expect(result.exitCode, result.stderr).toBe(0); @@ -111,14 +142,15 @@ describe("production vfork lifecycle", () => { }, ); - it.skipIf(!fatalProgram)( + it( "releases the parent after exact trap and signal teardown", async () => { const result = await runCentralizedProgram({ - programPath: fatalProgram!, + programPath: fatalProgram, argv: ["vfork-fatal-lifecycle"], useDefaultRootfs: false, timeout: 15_000, + maxProcessMemoryBytes: initialAddressSpaceBytes(fatalProgram), }); expect(result.exitCode, result.stderr).toBe(0); @@ -141,14 +173,15 @@ describe("production vfork lifecycle", () => { }, ); - it.skipIf(!externalSignalProgram)( + it( "contains a compute-running borrower after an external fatal signal", async () => { const result = await runCentralizedProgram({ - programPath: externalSignalProgram!, + programPath: externalSignalProgram, argv: ["vfork-external-signal"], useDefaultRootfs: false, timeout: 15_000, + maxProcessMemoryBytes: initialAddressSpaceBytes(externalSignalProgram), }); expect(result.exitCode, result.stderr).toBe(139); @@ -172,21 +205,27 @@ describe("production vfork lifecycle", () => { 20_000, ); - it.skipIf(!stateProgram)( + it( "preserves independent POSIX state and shared open-file descriptions", async () => { const result = await runCentralizedProgram({ - programPath: stateProgram!, + programPath: stateProgram, argv: ["vfork-posix-state"], timeout: 15_000, + maxProcessMemoryBytes: initialAddressSpaceBytes(stateProgram), }); expect(result.exitCode, result.stderr).toBe(0); expect(result.stderr).toBe(""); expect(result.hostDiagnostics).toEqual([]); expectOrdered(result.stdout, [ + "CHILD_INHERITED_POSIX_STATE", + "CHILD_MUTATED_PRIVATE_POSIX_STATE", + "CHILD_CONFIRMED_PRIVATE_POSIX_MUTATIONS", "PARENT_AFTER_STATE_CHILD", + "PARENT_POSIX_STATE_UNCHANGED", "PARENT_REAPED_STATE_CHILD", + "PARENT_CONFIRMED_EXACT_REAP", "PASS: VFORK_POSIX_STATE", ]); }, diff --git a/host/test/vfork-lifetime.test.ts b/host/test/vfork-lifetime.test.ts index ac38d367b2..98456e7728 100644 --- a/host/test/vfork-lifetime.test.ts +++ b/host/test/vfork-lifetime.test.ts @@ -1,4 +1,9 @@ import { describe, expect, it } from "vitest"; +import { WASM_PAGE_SIZE } from "../src/constants"; +import { + acquireForkMemoryClone, + ProcessMemoryAllocator, +} from "../src/process-memory"; import { VforkAddressSpaceBusyError, VforkLifetimeCoordinator, @@ -32,29 +37,87 @@ async function expectPending(promise: Promise): Promise { } describe("shared vfork lifetime coordinator", () => { + it("borrows the exact parent Memory without a full child allocation", async () => { + const allocator = new ProcessMemoryAllocator({ + maxMemories: 2, + maxTotalBytes: 4 * WASM_PAGE_SIZE, + }); + const parentLease = allocator.acquire({ + ptrWidth: 4, + initialPages: 2, + maximumPages: 2, + }); + const childLease = parentLease.retainAlias(); + const parentMemory = parentLease.memory; + const childMemory = childLease.memory; + const fullProcessMemoryCreations = + allocator.getRetirementStats().liveMemories - 1; + const coordinator = new VforkLifetimeCoordinator(); + const parent = generation("parent", parentMemory); + const child = generation("child", childMemory); + const events: string[] = []; + const lifetime = coordinator.begin(1, 2, parent, child); + let childReleasedMemory = false; + void lifetime.completion.then(() => { + events.push( + childReleasedMemory + ? "parent-resumed-after-release" + : "parent-resumed-before-release", + ); + }); + + events.push("child-entered"); + coordinator.markChildMayAccessMemory(child); + await expectPending(lifetime.completion); + + expect(childMemory).toBe(parentMemory); + expect(fullProcessMemoryCreations).toBe(0); + expect(events).toContain("child-entered"); + expect(events).not.toContain("parent-resumed-before-release"); + + childReleasedMemory = true; + coordinator.completeAfterExactTeardown(child, "exit"); + await lifetime.completion; + expect(events).toContain("parent-resumed-after-release"); + + childLease.release(); + const ordinaryForkLease = acquireForkMemoryClone( + allocator, + parentMemory, + 4, + 2, + ); + expect(ordinaryForkLease.memory).not.toBe(parentMemory); + expect(allocator.getRetirementStats().liveMemories - 1).toBe(1); + ordinaryForkLease.release(); + parentLease.release(); + allocator.clear(); + }); + it("requires an exact Shared Memory alias and distinct process identities", () => { const coordinator = new VforkLifetimeCoordinator(); const parent = generation("parent"); - expect(() => coordinator.begin( - 10, - 11, - parent, - generation("copied-child"), - )).toThrow("does not alias"); + expect(() => + coordinator.begin(10, 11, parent, generation("copied-child")), + ).toThrow("does not alias"); const privateMemory = new WebAssembly.Memory({ initial: 1, maximum: 1 }); - expect(() => coordinator.begin( - 10, - 11, - generation("private-parent", privateMemory), - generation("private-child", privateMemory), - )).toThrow("requires Shared"); - - expect(() => coordinator.begin(10, 10, parent, generation("child", parent.memory))) - .toThrow("PIDs must differ"); - expect(() => coordinator.begin(10, 11, parent, parent)) - .toThrow("generations must differ"); + expect(() => + coordinator.begin( + 10, + 11, + generation("private-parent", privateMemory), + generation("private-child", privateMemory), + ), + ).toThrow("requires Shared"); + + expect(() => + coordinator.begin(10, 10, parent, generation("child", parent.memory)), + ).toThrow("PIDs must differ"); + expect(() => coordinator.begin(10, 11, parent, parent)).toThrow( + "generations must differ", + ); }); it("parks the caller while sibling work and failed execs continue", async () => { @@ -86,26 +149,49 @@ describe("shared vfork lifetime coordinator", () => { }); }); - it.each(["exec", "exit", "signal", "trap"] as const)( - "accepts exact %s teardown evidence once", - async (reason) => { + it.each([ + { path: "successful exec", reason: "exec", failedExecErrnos: [] }, + { + path: "failed exec followed by _exit", + reason: "exit", + failedExecErrnos: [2], + }, + { path: "direct _exit", reason: "exit", failedExecErrnos: [] }, + { + path: "cooperative signal death", + reason: "signal", + failedExecErrnos: [], + }, + { path: "trap", reason: "trap", failedExecErrnos: [] }, + ] as const)( + "accepts exact teardown evidence once for $path", + async ({ reason, failedExecErrnos }) => { const coordinator = new VforkLifetimeCoordinator(); const memory = sharedMemory(); const parent = generation("parent", memory); const child = generation("child", memory); const lifetime = coordinator.begin(30, 31, parent, child); + let settlements = 0; + void lifetime.completion.then(() => settlements++); coordinator.markChildMayAccessMemory(child); + for (const errno of failedExecErrnos) { + coordinator.noteFailedExec(child, errno); + } + await expectPending(lifetime.completion); expect(coordinator.completeAfterExactTeardown(child, reason)).toBe(true); expect(coordinator.completeAfterExactTeardown(child, reason)).toBe(false); - expect(coordinator.requireAddressSpaceContainment(child, new Error("late"))) - .toBe(false); + expect( + coordinator.requireAddressSpaceContainment(child, new Error("late")), + ).toBe(false); await expect(lifetime.completion).resolves.toMatchObject({ kind: "resume-parent", parentGeneration: parent, childPid: 31, reason, }); + expect(lifetime.failedExecAttempts).toBe(failedExecErrnos.length); + expect(settlements).toBe(1); expect(lifetime.phase).toBe("settled"); expect(coordinator.activeCount).toBe(0); }, @@ -139,20 +225,24 @@ describe("shared vfork lifetime coordinator", () => { }); }); - it("resumes for an exact kernel death before Worker launch", async () => { + it("settles once for an exact Worker crash before memory access", async () => { const coordinator = new VforkLifetimeCoordinator(); const memory = sharedMemory(); const parent = generation("parent", memory); const child = generation("child", memory); const lifetime = coordinator.begin(50, 51, parent, child); - coordinator.completeWithoutBorrow(child, "signal"); + let settlements = 0; + void lifetime.completion.then(() => settlements++); + coordinator.completeWithoutBorrow(child, "trap"); await expect(lifetime.completion).resolves.toEqual({ kind: "resume-parent", parentGeneration: parent, childPid: 51, - reason: "signal", + reason: "trap", }); + expect(settlements).toBe(1); + expect(coordinator.activeCount).toBe(0); }); it("requires whole-address-space containment after ambiguous termination", async () => { @@ -161,6 +251,8 @@ describe("shared vfork lifetime coordinator", () => { const parent = generation("parent", memory); const child = generation("child", memory); const lifetime = coordinator.begin(60, 61, parent, child); + let settlements = 0; + void lifetime.completion.then(() => settlements++); coordinator.markChildMayAccessMemory(child); const crash = new Error("Worker stopped without memory_quiescent"); @@ -172,6 +264,8 @@ describe("shared vfork lifetime coordinator", () => { childPid: 61, cause: crash, }); + expect(settlements).toBe(1); + expect(coordinator.activeCount).toBe(0); }); it("rejects overlapping and nested borrowers with EAGAIN", async () => { diff --git a/host/test/vfork-mechanism-trace.test.ts b/host/test/vfork-mechanism-trace.test.ts new file mode 100644 index 0000000000..2fb1721f31 --- /dev/null +++ b/host/test/vfork-mechanism-trace.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { + parseMechanismTraceRuns, + partitionForkDispatches, + requireCompleteVforkSequence, + requireVforkStartFailureSequence, +} from "./vfork-mechanism-trace"; + +const preparation = + "[vfork-mechanism] event=vfork_prepared mode=1 parent=100 child=101 " + + "memory_identity=same live_memory_delta=0 alias_delta=1 " + + "parent_channel=1000 child_channel=2000 owner_control=3000 " + + "child_prefix=4000 scratch=5000 externref_parent=1 externref_child=2"; + +function run(name: string, events: readonly string[]): string { + return [ + `[vfork-mechanism-run] begin=${name}`, + ...events, + `[vfork-mechanism-run] end=${name}`, + ].join("\n"); +} + +const dispatch = "[vfork-mechanism] event=dispatch mode=1 parent=100 child=101"; +const mayAccess = + "[vfork-mechanism] event=child_may_access_memory parent=100 child=101"; +const quiescent = "[vfork-mechanism] event=memory_quiescent child=101"; +const teardown = + "[vfork-mechanism] event=exact_teardown child_channel=2000 reason=exit"; +const released = + "[vfork-mechanism] event=parent_released parent=100 child=101"; +const startFailed = + "[vfork-mechanism] event=worker_start_failed parent=100 child=101"; + +describe("vfork mechanism trace correlation", () => { + it("does not borrow reused pid/channel events from a later host run", () => { + const output = [ + run("missing-quiescence", [dispatch, preparation, mayAccess]), + run("later-reused-identifiers", [ + dispatch, + preparation, + mayAccess, + quiescent, + teardown, + released, + ]), + ].join("\n"); + const runs = parseMechanismTraceRuns(output); + const firstDispatch = partitionForkDispatches(runs[0])[0]; + + expect(() => requireCompleteVforkSequence(firstDispatch)).toThrow( + "missing memory_quiescent", + ); + }); + + it.each([ + { + name: "missing child memory access", + events: [dispatch, preparation, quiescent, teardown, released], + message: "missing child_may_access_memory", + }, + { + name: "release reordered before exact teardown", + events: [dispatch, preparation, mayAccess, released, quiescent, teardown], + message: "parent_released is out of order", + }, + ])("rejects $name", ({ events, message }) => { + const [traceRun] = parseMechanismTraceRuns(run("mutated", events)); + const [forkDispatch] = partitionForkDispatches(traceRun); + + expect(() => requireCompleteVforkSequence(forkDispatch)).toThrow(message); + }); + + it("bounds startup failure evidence to its exact run", () => { + const output = [ + run("missing-access", [dispatch, preparation]), + run("later-reused-identifiers", [ + dispatch, + preparation, + mayAccess, + startFailed, + ]), + ].join("\n"); + const [first] = parseMechanismTraceRuns(output); + const [forkDispatch] = partitionForkDispatches(first); + + expect(() => requireVforkStartFailureSequence(forkDispatch)).toThrow( + "missing child_may_access_memory", + ); + }); +}); diff --git a/host/test/vfork-mechanism-trace.ts b/host/test/vfork-mechanism-trace.ts new file mode 100644 index 0000000000..229fe6a3cd --- /dev/null +++ b/host/test/vfork-mechanism-trace.ts @@ -0,0 +1,246 @@ +export interface MechanismTrace { + readonly event: string; + readonly fields: ReadonlyMap; + readonly line: string; +} + +export interface MechanismTraceRun { + readonly name: string; + readonly traces: readonly MechanismTrace[]; +} + +export interface ForkDispatchTrace { + readonly runName: string; + readonly mode: string; + readonly parent: string; + readonly child: string; + readonly traces: readonly MechanismTrace[]; +} + +export interface CompleteVforkSequence { + readonly dispatch: MechanismTrace; + readonly preparation: MechanismTrace; + readonly childMayAccessMemory: MechanismTrace; + readonly memoryQuiescent: MechanismTrace; + readonly exactTeardown: MechanismTrace; + readonly parentReleased: MechanismTrace; +} + +function fieldsFromLine(prefix: string, line: string): Map { + const fields = new Map(); + for (const token of line.slice(prefix.length).split(" ")) { + const separator = token.indexOf("="); + if (separator > 0) { + fields.set(token.slice(0, separator), token.slice(separator + 1)); + } + } + return fields; +} + +export function parseMechanismTraceLine(line: string): MechanismTrace | undefined { + const prefix = "[vfork-mechanism] "; + if (!line.startsWith(prefix)) return undefined; + const fields = fieldsFromLine(prefix, line); + return { event: fields.get("event") ?? "", fields, line }; +} + +export function parseMechanismTraceRuns(output: string): MechanismTraceRun[] { + const markerPrefix = "[vfork-mechanism-run] "; + const runs: MechanismTraceRun[] = []; + let active: { name: string; traces: MechanismTrace[] } | undefined; + for (const line of output.split(/\r?\n/)) { + if (line.startsWith(markerPrefix)) { + const fields = fieldsFromLine(markerPrefix, line); + const begin = fields.get("begin"); + const end = fields.get("end"); + if (begin) { + if (active) throw new Error(`nested trace run ${begin}`); + if (runs.some((run) => run.name === begin)) { + throw new Error(`duplicate trace run ${begin}`); + } + active = { name: begin, traces: [] }; + } else if (end) { + if (!active || active.name !== end) { + throw new Error(`mismatched trace run end ${end}`); + } + runs.push(active); + active = undefined; + } else { + throw new Error(`invalid trace run marker: ${line}`); + } + continue; + } + const trace = parseMechanismTraceLine(line); + if (!trace) continue; + if (!active) throw new Error(`mechanism trace outside run: ${line}`); + active.traces.push(trace); + } + if (active) throw new Error(`unterminated trace run ${active.name}`); + return runs; +} + +function requiredField(trace: MechanismTrace, field: string): string { + const value = trace.fields.get(field); + if (value === undefined) { + throw new Error(`${trace.event} missing ${field}: ${trace.line}`); + } + return value; +} + +export function partitionForkDispatches( + run: MechanismTraceRun, +): ForkDispatchTrace[] { + const dispatchIndexes = run.traces.flatMap((trace, index) => + trace.event === "dispatch" ? [index] : [] + ); + if (dispatchIndexes.length === 0 && run.traces.length > 0) { + throw new Error(`trace run ${run.name} has no dispatch`); + } + if (dispatchIndexes[0] !== 0) { + throw new Error(`trace run ${run.name} has events before its first dispatch`); + } + return dispatchIndexes.map((start, index) => { + const dispatch = run.traces[start]; + const end = dispatchIndexes[index + 1] ?? run.traces.length; + return { + runName: run.name, + mode: requiredField(dispatch, "mode"), + parent: requiredField(dispatch, "parent"), + child: requiredField(dispatch, "child"), + traces: run.traces.slice(start, end), + }; + }); +} + +function requireSingleEvent( + dispatch: ForkDispatchTrace, + event: string, + matches: (trace: MechanismTrace) => boolean, +): { trace: MechanismTrace; index: number } { + const matching = dispatch.traces.flatMap((trace, index) => + trace.event === event && matches(trace) ? [{ trace, index }] : [] + ); + if (matching.length === 0) { + throw new Error( + `${dispatch.runName} child=${dispatch.child} missing ${event}`, + ); + } + if (matching.length !== 1) { + throw new Error( + `${dispatch.runName} child=${dispatch.child} has ${matching.length} ${event} events`, + ); + } + return matching[0]; +} + +export function requireCompleteVforkSequence( + dispatch: ForkDispatchTrace, +): CompleteVforkSequence { + if (dispatch.mode !== "1") { + throw new Error(`${dispatch.runName} child=${dispatch.child} is mode ${dispatch.mode}`); + } + const preparation = requireSingleEvent( + dispatch, + "vfork_prepared", + (trace) => trace.fields.get("parent") === dispatch.parent + && trace.fields.get("child") === dispatch.child, + ); + const childMayAccessMemory = requireSingleEvent( + dispatch, + "child_may_access_memory", + (trace) => trace.fields.get("parent") === dispatch.parent + && trace.fields.get("child") === dispatch.child, + ); + const memoryQuiescent = requireSingleEvent( + dispatch, + "memory_quiescent", + (trace) => trace.fields.get("child") === dispatch.child, + ); + const childChannel = requiredField(preparation.trace, "child_channel"); + const exactTeardown = requireSingleEvent( + dispatch, + "exact_teardown", + (trace) => trace.fields.get("child_channel") === childChannel, + ); + const parentReleased = requireSingleEvent( + dispatch, + "parent_released", + (trace) => trace.fields.get("parent") === dispatch.parent + && trace.fields.get("child") === dispatch.child, + ); + const ordered = [ + preparation, + childMayAccessMemory, + memoryQuiescent, + exactTeardown, + parentReleased, + ]; + for (let index = 1; index < ordered.length; index++) { + if (ordered[index].index <= ordered[index - 1].index) { + throw new Error( + `${dispatch.runName} child=${dispatch.child} ` + + `${ordered[index].trace.event} is out of order`, + ); + } + } + return { + dispatch: dispatch.traces[0], + preparation: preparation.trace, + childMayAccessMemory: childMayAccessMemory.trace, + memoryQuiescent: memoryQuiescent.trace, + exactTeardown: exactTeardown.trace, + parentReleased: parentReleased.trace, + }; +} + +export function requireVforkStartFailureSequence( + dispatch: ForkDispatchTrace, +): { + readonly preparation: MechanismTrace; + readonly childMayAccessMemory: MechanismTrace; + readonly workerStartFailed: MechanismTrace; +} { + if (dispatch.mode !== "1") { + throw new Error(`${dispatch.runName} child=${dispatch.child} is mode ${dispatch.mode}`); + } + const preparation = requireSingleEvent( + dispatch, + "vfork_prepared", + (trace) => trace.fields.get("parent") === dispatch.parent + && trace.fields.get("child") === dispatch.child, + ); + const childMayAccessMemory = requireSingleEvent( + dispatch, + "child_may_access_memory", + (trace) => trace.fields.get("parent") === dispatch.parent + && trace.fields.get("child") === dispatch.child, + ); + const workerStartFailed = requireSingleEvent( + dispatch, + "worker_start_failed", + (trace) => trace.fields.get("parent") === dispatch.parent + && trace.fields.get("child") === dispatch.child, + ); + if (childMayAccessMemory.index <= preparation.index) { + throw new Error( + `${dispatch.runName} child=${dispatch.child} child_may_access_memory is out of order`, + ); + } + if (workerStartFailed.index <= childMayAccessMemory.index) { + throw new Error( + `${dispatch.runName} child=${dispatch.child} worker_start_failed is out of order`, + ); + } + for (const forbidden of ["memory_quiescent", "parent_released"]) { + if (dispatch.traces.some((trace) => trace.event === forbidden)) { + throw new Error( + `${dispatch.runName} child=${dispatch.child} unexpectedly emitted ${forbidden}`, + ); + } + } + return { + preparation: preparation.trace, + childMayAccessMemory: childMayAccessMemory.trace, + workerStartFailed: workerStartFailed.trace, + }; +} diff --git a/host/test/vfork-production-mechanism.test.ts b/host/test/vfork-production-mechanism.test.ts new file mode 100644 index 0000000000..48d55062f8 --- /dev/null +++ b/host/test/vfork-production-mechanism.test.ts @@ -0,0 +1,124 @@ +import { execFileSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + parseMechanismTraceRuns, + partitionForkDispatches, + requireCompleteVforkSequence, + requireVforkStartFailureSequence, + type MechanismTrace, +} from "./vfork-mechanism-trace"; + +const testDir = dirname(fileURLToPath(import.meta.url)); + +function assertPrivatePreparationEvidence(preparation: MechanismTrace): void { + expect(preparation.fields.get("mode"), preparation.line).toBe("1"); + expect(preparation.fields.get("memory_identity"), preparation.line).toBe("same"); + expect(preparation.fields.get("live_memory_delta"), preparation.line).toBe("0"); + expect(preparation.fields.get("alias_delta"), preparation.line).toBe("1"); + expect(preparation.fields.get("parent_channel"), preparation.line) + .not.toBe(preparation.fields.get("child_channel")); + expect(preparation.fields.get("owner_control"), preparation.line) + .not.toBe(preparation.fields.get("child_prefix")); + expect(preparation.fields.get("scratch"), preparation.line) + .not.toBe(preparation.fields.get("owner_control")); + expect(preparation.fields.get("scratch"), preparation.line) + .not.toBe(preparation.fields.get("child_prefix")); + expect(preparation.fields.get("externref_parent"), preparation.line) + .not.toBe(preparation.fields.get("externref_child")); +} + +describe("production fork-mode mechanism evidence", () => { + it("observes borrowed mode 1 through exact child quiescence before parent release", () => { + const output = execFileSync( + "npx", + ["tsx", join(testDir, "fixtures/vfork-production-trace-runner.ts")], + { + cwd: join(testDir, ".."), + encoding: "utf8", + env: { ...process.env, KERNEL_SYSCALL_LOG: "1" }, + timeout: 60_000, + }, + ); + expect(output).toContain("PRODUCTION_VFORK_TRACE_RUNNER_PASS"); + expect(output).toContain("PRODUCTION_SIDE_TRACE_RUNNER_PASS"); + const runs = parseMechanismTraceRuns(output); + expect(runs.map((run) => run.name)).toEqual([ + "lifecycle", + "ordinary", + "side-module", + ]); + + const lifecycleDispatches = partitionForkDispatches(runs[0]); + expect(lifecycleDispatches.length).toBeGreaterThan(0); + for (const dispatch of lifecycleDispatches) { + const sequence = requireCompleteVforkSequence(dispatch); + assertPrivatePreparationEvidence(sequence.preparation); + } + + const ordinaryDispatches = partitionForkDispatches(runs[1]); + expect(ordinaryDispatches).toHaveLength(1); + expect(ordinaryDispatches[0].mode).toBe("0"); + const ordinaryPreparations = ordinaryDispatches[0].traces.filter( + (trace) => trace.event === "fork_prepared", + ); + expect(ordinaryPreparations).toHaveLength(1); + expect(ordinaryPreparations[0].fields.get("memory_identity")) + .toBe("distinct"); + expect(ordinaryPreparations[0].fields.get("live_memory_delta")).toBe("1"); + + const sideDispatches = partitionForkDispatches(runs[2]); + expect(sideDispatches).toHaveLength(2); + for (const dispatch of sideDispatches) { + const sequence = requireCompleteVforkSequence(dispatch); + assertPrivatePreparationEvidence(sequence.preparation); + } + }, 75_000); + + it("contains a real Worker factory failure after the borrow boundary", () => { + const output = execFileSync( + "npx", + ["tsx", join(testDir, "fixtures/vfork-start-failure-runner.ts")], + { + cwd: join(testDir, ".."), + encoding: "utf8", + env: { + ...process.env, + KERNEL_SYSCALL_LOG: "1", + KANDELO_TEST_VFORK_WORKER_START_FAILURE: "once", + }, + timeout: 60_000, + }, + ); + const runs = parseMechanismTraceRuns(output); + expect(runs.map((run) => run.name)).toEqual(["start-failure"]); + const dispatches = partitionForkDispatches(runs[0]); + expect(dispatches).toHaveLength(1); + const failureSequence = requireVforkStartFailureSequence(dispatches[0]); + assertPrivatePreparationEvidence(failureSequence.preparation); + const resultLine = output.split(/\r?\n/).find((line) => + line.startsWith("VFORK_START_FAILURE_RESULT ") + ); + expect(resultLine).toBeDefined(); + const result = JSON.parse( + resultLine!.slice("VFORK_START_FAILURE_RESULT ".length), + ) as { + exitCode: number; + stdout: string; + stderr: string; + diagnostics: Array<{ status?: number; source: string; message: string }>; + }; + expect(result.exitCode).toBe(139); + expect(result.stdout).not.toContain("PARENT_RESUME_ONE"); + expect(result.stderr).toBe(""); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]).toMatchObject({ + status: 139, + source: "vfork address-space containment", + }); + expect(result.diagnostics[0].message).toContain( + "injected vfork Worker constructor failure", + ); + }, 75_000); +}); diff --git a/host/test/vfork-side-module-fixture.test.ts b/host/test/vfork-side-module-fixture.test.ts new file mode 100644 index 0000000000..23894762b7 --- /dev/null +++ b/host/test/vfork-side-module-fixture.test.ts @@ -0,0 +1,44 @@ +import { + existsSync, + mkdtempSync, + readdirSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { buildVforkSideModuleFixture } from "./vfork-side-module-fixture"; + +function withOutputRoot(operation: (outputRoot: string) => void): void { + const outputRoot = mkdtempSync(join(tmpdir(), "kandelo-vfork-side-test-")); + try { + operation(outputRoot); + } finally { + rmSync(outputRoot, { recursive: true, force: true }); + } +} + +describe("vfork side-module fixture lifecycle", () => { + it("removes its build directory idempotently", () => { + withOutputRoot((outputRoot) => { + const fixture = buildVforkSideModuleFixture({ outputRoot }); + const buildDir = dirname(fixture.programPath); + expect(existsSync(buildDir)).toBe(true); + + fixture.cleanup(); + expect(existsSync(buildDir)).toBe(false); + expect(() => fixture.cleanup()).not.toThrow(); + expect(readdirSync(outputRoot)).toEqual([]); + }); + }); + + it("removes its build directory when compilation fails", () => { + withOutputRoot((outputRoot) => { + expect(() => buildVforkSideModuleFixture({ + outputRoot, + clangDriver: join(outputRoot, "missing-clang"), + })).toThrow(); + expect(readdirSync(outputRoot)).toEqual([]); + }); + }); +}); diff --git a/host/test/vfork-side-module-fixture.ts b/host/test/vfork-side-module-fixture.ts new file mode 100644 index 0000000000..21b3ee94b0 --- /dev/null +++ b/host/test/vfork-side-module-fixture.ts @@ -0,0 +1,125 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, renameSync, rmSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export interface VforkSideModuleFixture { + readonly programPath: string; + readonly libraryPath: string; + cleanup(): void; +} + +export interface VforkSideModuleFixtureOptions { + readonly outputRoot?: string; + readonly clangDriver?: string; +} + +function llvmTool(clang: string, name: "clang" | "wasm-ld"): string { + if (name === "wasm-ld" && process.env.WASM_LD) return process.env.WASM_LD; + return execFileSync(clang, [`-print-prog-name=${name}`], { + encoding: "utf8", + }).trim() || name; +} + +function instrumentInPlace(instrument: string, path: string, entry?: string): void { + const output = `${path}.instrumented`; + const args = [path, "-o", output]; + if (entry) args.push("--entry", entry); + execFileSync(instrument, args, { stdio: "pipe" }); + renameSync(output, path); +} + +export function buildVforkSideModuleFixture( + options: VforkSideModuleFixtureOptions = {}, +): VforkSideModuleFixture { + const testDir = dirname(fileURLToPath(import.meta.url)); + const repoRoot = join(testDir, "../.."); + const sysroot = join(repoRoot, "sysroot"); + const glueDir = join(repoRoot, "libc", "glue"); + const fixturesDir = join(testDir, "fixtures"); + const fixtureOutputRoot = options.outputRoot + ?? join(repoRoot, "local-binaries", "test-fixtures"); + mkdirSync(fixtureOutputRoot, { recursive: true }); + const buildDir = mkdtempSync(join(fixtureOutputRoot, "vfork-side-")); + let cleaned = false; + const cleanup = (): void => { + if (cleaned) return; + cleaned = true; + rmSync(buildDir, { recursive: true, force: true }); + }; + try { + const programPath = join(buildDir, "vfork-side-main.wasm"); + const libraryPath = join(buildDir, "libvfork-side.so"); + const sideObject = join(buildDir, "vfork-side-module.o"); + const clangDriver = options.clangDriver ?? process.env.CLANG ?? "clang"; + const clang = llvmTool(clangDriver, "clang"); + const wasmLd = llvmTool(clangDriver, "wasm-ld"); + const instrument = join(repoRoot, "scripts", "run-wasm-fork-instrument.sh"); + + execFileSync(clang, [ + "--target=wasm32-unknown-unknown", + "-fPIC", + "-O2", + "-matomics", + "-mbulk-memory", + `-I${glueDir}`, + "-c", + join(fixturesDir, "vfork-side-module.c"), + "-o", + sideObject, + ], { stdio: "pipe" }); + execFileSync(wasmLd, [ + "--experimental-pic", + "--shared", + "--shared-memory", + "--export-all", + "--allow-undefined", + "-o", + libraryPath, + sideObject, + ], { stdio: "pipe" }); + instrumentInPlace(instrument, libraryPath, "env.fork"); + + execFileSync(clang, [ + "--target=wasm32-unknown-unknown", + `--sysroot=${sysroot}`, + "-nostdlib", + "-O2", + "-matomics", + "-mbulk-memory", + "-fno-trapping-math", + join(fixturesDir, "vfork-side-main.c"), + join(glueDir, "channel_syscall.c"), + join(glueDir, "compiler_rt.c"), + join(glueDir, "dlopen.c"), + join(sysroot, "lib", "crt1.o"), + join(sysroot, "lib", "libc.a"), + "-Wl,--entry=_start", + "-Wl,--export=_start", + "-Wl,--export=__heap_base", + "-Wl,--import-memory", + "-Wl,--shared-memory", + "-Wl,--max-memory=1073741824", + "-Wl,--allow-undefined", + "-Wl,--global-base=1114112", + "-Wl,--table-base=3", + "-Wl,--export-table", + "-Wl,--growable-table", + "-Wl,--export=__wasm_init_tls", + "-Wl,--export=__tls_base", + "-Wl,--export=__tls_size", + "-Wl,--export=__tls_align", + "-Wl,--export=__stack_pointer", + "-Wl,--export=__wasm_thread_init", + "-Wl,--export-all", + "-o", + programPath, + ], { stdio: "pipe" }); + instrumentInPlace(instrument, programPath); + + return { programPath, libraryPath, cleanup }; + } catch (error) { + cleanup(); + throw error; + } +} diff --git a/host/test/vfork-workspace.test.ts b/host/test/vfork-workspace.test.ts index 80d92558f2..58fa860e9f 100644 --- a/host/test/vfork-workspace.test.ts +++ b/host/test/vfork-workspace.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { BorrowedVforkWorkspace } from "../src/vfork-workspace"; +import { ThreadPageAllocator } from "../src/thread-allocator"; const PAGE = 65_536; @@ -12,6 +13,46 @@ function memory(): WebAssembly.Memory { } describe("borrowed vfork workspace", () => { + it("gives the child a private syscall channel, replay prefix, and scratch page", () => { + const shared = new WebAssembly.Memory({ + initial: 10, + maximum: 10, + shared: true, + }); + const parentChannelOffset = 2 * PAGE; + const allocator = new ThreadPageAllocator({ + firstSlotStartPage: 6, + maxPageExclusive: 10, + reservedSlots: 0, + }); + const childControl = allocator.allocateHostControl(shared); + const workspace = new BorrowedVforkWorkspace( + shared, + 4, + { + prefixAddress: childControl.forkSaveOffset, + prefixBytes: 64, + scratchAddress: childControl.tlsOffset, + scratchBytes: PAGE, + }, + "private child control", + ); + + expect(childControl.channelOffset).not.toBe(parentChannelOffset); + expect(childControl.channelOffset).not.toBe(childControl.forkSaveOffset); + expect(childControl.channelOffset).not.toBe(childControl.tlsOffset); + expect(childControl.forkSaveOffset).not.toBe(childControl.tlsOffset); + expect(workspace.reservePrefix({ + activationId: 0, + byteLength: 64, + alignment: 16, + })).toBe(childControl.forkSaveOffset); + const scratch = workspace.allocateScratch(16); + expect(scratch).toBe(childControl.tlsOffset); + workspace.deallocateScratch(scratch, 16); + workspace.assertAttachComplete(); + }); + it.each([4, 8] as const)( "allocates exact wasm%s prefixes and LIFO scratch without overlap", (ptrWidth) => { diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index e27a452a4a..82f6b2cf95 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -158,8 +158,8 @@ "lamp": { "manifestSha256": "250b64635d64f8537178188fb5488dbfd2980d79a1c2ffbf346af67008088ba0", "cacheKeys": { - "wasm32": "a57c26e27a8e30a8fa8b1cb4eb41435c14847f452bd84f41f578dbfe7e6873e6", - "wasm64": "6ae08f18e26edc89abced539b7e09eec0856eebd36788b315bfceb45b7c63698" + "wasm32": "7bc5d07c9351bd2752f6da98efee5ce8befc108b228826844c8097375c7a49a3", + "wasm64": "548b56496a64316037f0f5226e14d95c225c9d4734072ad87af636695aea27be" } }, "less": { @@ -312,8 +312,8 @@ "nginx-php-vfs": { "manifestSha256": "97976410cb02f8ba710d856b4ac904bcf976d677b50eefdee39fb64176070d4b", "cacheKeys": { - "wasm32": "d97347607310c1c647a7eb4a0c4e7ddff072313a3190c313a6e65005c6d70bec", - "wasm64": "a921f77a85ec13b7dd63644791dfb331a2e90fa1b308ab3ba6e5b47046e6c51f" + "wasm32": "0dd380c1d3f9b84c0b6da35c56b4f7073d862ffbb5f383030f73b1763bf70583", + "wasm64": "0047a616c217e2bffad74d9599d3b821e2f43edbedaa2b24bc4761b436df3218" } }, "nginx-vfs": { @@ -543,8 +543,8 @@ "wordpress": { "manifestSha256": "36465e0596a06e855a8524eecfd87da98643bc13b37ee12939f5aab44bf33418", "cacheKeys": { - "wasm32": "589162fa777487b8be4e0c1fa1ec24dfd3de0b590859b655328e2acc7b9e8fdf", - "wasm64": "fe0e7c675b3739bdb1f9c7ecd3c858eb9ff5b8fb68f85bf5abe0f462a4feade0" + "wasm32": "736aa0505b544a9c1f0b47dc1b2b0200ecbaa8eb2a3fe6f6110d433e616c9f80", + "wasm64": "efa707e2e50097e8d0c179c77533bf4d0fb432e991bce41cb409b1825422df5e" } }, "xz": { @@ -1121,7 +1121,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a57c26e27a8e30a8fa8b1cb4eb41435c14847f452bd84f41f578dbfe7e6873e6" + "wasm32": "7bc5d07c9351bd2752f6da98efee5ce8befc108b228826844c8097375c7a49a3" }, "dependencyClosures": { "wasm32": [ @@ -1746,7 +1746,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "d97347607310c1c647a7eb4a0c4e7ddff072313a3190c313a6e65005c6d70bec" + "wasm32": "0dd380c1d3f9b84c0b6da35c56b4f7073d862ffbb5f383030f73b1763bf70583" }, "dependencyClosures": { "wasm32": [ @@ -3020,7 +3020,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "589162fa777487b8be4e0c1fa1ec24dfd3de0b590859b655328e2acc7b9e8fdf" + "wasm32": "736aa0505b544a9c1f0b47dc1b2b0200ecbaa8eb2a3fe6f6110d433e616c9f80" }, "dependencyClosures": { "wasm32": [ diff --git a/programs/vfork-lifecycle.c b/programs/vfork-lifecycle.c index dcd485d31e..96cba40c3b 100644 --- a/programs/vfork-lifecycle.c +++ b/programs/vfork-lifecycle.c @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -104,7 +105,7 @@ static int successful_exec_cycle(void) { return 0; } -int main(void) { +int main(int argc, char **argv) { MARKER("VFORK_LIFECYCLE_BEGIN\n"); if (exit_cycle( "CHILD_EXIT_ONE\n", sizeof("CHILD_EXIT_ONE\n") - 1, @@ -118,6 +119,11 @@ int main(void) { } if (failed_exec_cycle() != 0) return 3; if (rejected_ownership_cycle() != 0) return 4; + if (argc == 2 && strcmp(argv[1], "no-successful-exec") == 0) { + MARKER("PARENT_SKIPPED_EXEC_UNDER_NO_COPY_CEILING\n"); + MARKER("PASS: VFORK_LIFECYCLE\n"); + return 0; + } if (successful_exec_cycle() != 0) return 5; MARKER("PASS: VFORK_LIFECYCLE\n"); return 0; diff --git a/programs/vfork-posix-state.c b/programs/vfork-posix-state.c index 2c7ba62ade..e9796e5f19 100644 --- a/programs/vfork-posix-state.c +++ b/programs/vfork-posix-state.c @@ -8,6 +8,7 @@ */ #include #include +#include #include #include #include @@ -26,57 +27,122 @@ static void marker(const char *text, size_t length) { #define MARKER(text) marker(text, sizeof(text) - 1) +static void parent_signal_handler(int signal_number) { + (void)signal_number; +} + int main(void) { static const char contents[] = "abcdef"; char cwd[64]; char byte = '\0'; int status = 0; + struct sigaction parent_action; + struct sigaction observed_action; + sigset_t blocked_mask; + sigset_t original_mask; + sigset_t observed_mask; - if (getuid() != 0 || geteuid() != 0) return 1; - if (getgid() != 0 || getegid() != 0) return 2; - if (mkdir("/tmp/vfork-parent", 0755) != 0 && errno != EEXIST) return 3; - if (chdir("/tmp/vfork-parent") != 0) return 4; + memset(&parent_action, 0, sizeof(parent_action)); + parent_action.sa_handler = parent_signal_handler; + if (sigemptyset(&parent_action.sa_mask) != 0) return 1; + if (sigaction(SIGUSR1, &parent_action, NULL) != 0) return 2; + if (sigemptyset(&blocked_mask) != 0) return 3; + if (sigaddset(&blocked_mask, SIGUSR1) != 0) return 4; + if (sigprocmask(SIG_BLOCK, &blocked_mask, &original_mask) != 0) return 5; + + if (getuid() != 0 || geteuid() != 0) return 6; + if (getgid() != 0 || getegid() != 0) return 7; + if (mkdir("/tmp/vfork-parent", 0755) != 0 && errno != EEXIST) return 8; + if (chdir("/tmp/vfork-parent") != 0) return 9; int fd = open("ofd-state", O_CREAT | O_TRUNC | O_RDWR, 0644); - if (fd < 0) return 5; + if (fd < 0) return 10; if (write(fd, contents, sizeof(contents) - 1) != sizeof(contents) - 1) { - return 6; + return 11; } - if (lseek(fd, 0, SEEK_SET) != 0) return 7; - if (fcntl(fd, F_GETFD) != 0) return 8; + if (lseek(fd, 0, SEEK_SET) != 0) return 12; + if (fcntl(fd, F_GETFD) != 0) return 13; + pid_t parent_pid = getpid(); + pid_t parent_ppid = getppid(); pid_t parent_pgrp = getpgrp(); - if (parent_pgrp <= 0) return 9; + pid_t parent_sid = getsid(0); + if (parent_pid <= 0 || parent_ppid < 0 || parent_pgrp <= 0 || + parent_sid < 0) { + return 14; + } pid_t pid = vfork(); - if (pid < 0) return 10; + if (pid < 0) return 15; if (pid == 0) { if (getuid() != 0 || getgid() != 0) _exit(21); if (getpgrp() != parent_pgrp) _exit(22); - if (lseek(fd, 2, SEEK_SET) != 2) _exit(23); - if (fcntl(fd, F_SETFD, FD_CLOEXEC) != 0) _exit(24); - if (close(fd) != 0) _exit(25); - if (chdir("/") != 0) _exit(26); - if (setpgid(0, 0) != 0) _exit(27); - if (setgid(1234) != 0) _exit(28); - if (setuid(1234) != 0) _exit(29); + if (getsid(0) != parent_sid) _exit(23); + if (getppid() != parent_pid) _exit(24); + if (sigaction(SIGUSR1, NULL, &observed_action) != 0) _exit(25); + if (observed_action.sa_handler != parent_signal_handler) _exit(26); + if (sigprocmask(SIG_SETMASK, NULL, &observed_mask) != 0) _exit(27); + if (sigismember(&observed_mask, SIGUSR1) != 1) _exit(28); + MARKER("CHILD_INHERITED_POSIX_STATE\n"); + + if (lseek(fd, 2, SEEK_SET) != 2) _exit(29); + if (fcntl(fd, F_SETFD, FD_CLOEXEC) != 0) _exit(30); + if (lseek(fd, 0, SEEK_CUR) != 2) _exit(31); + if (fcntl(fd, F_GETFD) != FD_CLOEXEC) _exit(32); + if (close(fd) != 0) _exit(33); + if (chdir("/") != 0) _exit(34); + if (getcwd(cwd, sizeof(cwd)) == NULL || strcmp(cwd, "/") != 0) { + _exit(35); + } + if (setpgid(0, 0) != 0) _exit(36); + if (getpgrp() != getpid() || getpgid(0) != getpid()) _exit(37); + if (getppid() != parent_pid || getsid(0) != parent_sid) _exit(38); + if (setgid(1234) != 0) _exit(39); + if (getgid() != 1234 || getegid() != 1234) _exit(40); + if (setuid(1234) != 0) _exit(41); + if (getuid() != 1234 || geteuid() != 1234) _exit(42); + + memset(&observed_action, 0, sizeof(observed_action)); + observed_action.sa_handler = SIG_IGN; + if (sigemptyset(&observed_action.sa_mask) != 0) _exit(43); + if (sigaction(SIGUSR1, &observed_action, NULL) != 0) _exit(44); + memset(&observed_action, 0, sizeof(observed_action)); + if (sigaction(SIGUSR1, NULL, &observed_action) != 0) _exit(45); + if (observed_action.sa_handler != SIG_IGN) _exit(46); + if (sigemptyset(&observed_mask) != 0) _exit(47); + if (sigprocmask(SIG_SETMASK, &observed_mask, NULL) != 0) _exit(48); + if (sigprocmask(SIG_SETMASK, NULL, &observed_mask) != 0) _exit(49); + if (sigismember(&observed_mask, SIGUSR1) != 0) _exit(50); + MARKER("CHILD_MUTATED_PRIVATE_POSIX_STATE\n"); + MARKER("CHILD_CONFIRMED_PRIVATE_POSIX_MUTATIONS\n"); _exit(0); } MARKER("PARENT_AFTER_STATE_CHILD\n"); - if (getuid() != 0 || geteuid() != 0) return 11; - if (getgid() != 0 || getegid() != 0) return 12; - if (getpgrp() != parent_pgrp) return 13; - if (getpgid(pid) != pid) return 14; - if (getcwd(cwd, sizeof(cwd)) == NULL) return 15; - if (strcmp(cwd, "/tmp/vfork-parent") != 0) return 16; - if (fcntl(fd, F_GETFD) != 0) return 17; - if (read(fd, &byte, 1) != 1 || byte != 'c') return 18; - if (waitpid(pid, &status, 0) != pid) return 19; - if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) return 20; - if (close(fd) != 0) return 30; + if (getpid() != parent_pid || getppid() != parent_ppid) return 40; + if (getuid() != 0 || geteuid() != 0) return 41; + if (getgid() != 0 || getegid() != 0) return 42; + if (getpgrp() != parent_pgrp || getsid(0) != parent_sid) return 43; + if (getpgid(pid) != pid || getsid(pid) != parent_sid) return 44; + if (getcwd(cwd, sizeof(cwd)) == NULL) return 45; + if (strcmp(cwd, "/tmp/vfork-parent") != 0) return 46; + if (fcntl(fd, F_GETFD) != 0) return 47; + if (read(fd, &byte, 1) != 1 || byte != 'c') return 48; + if (sigaction(SIGUSR1, NULL, &observed_action) != 0) return 49; + if (observed_action.sa_handler != parent_signal_handler) return 50; + if (sigprocmask(SIG_SETMASK, NULL, &observed_mask) != 0) return 51; + if (sigismember(&observed_mask, SIGUSR1) != 1) return 52; + MARKER("PARENT_POSIX_STATE_UNCHANGED\n"); + + if (waitpid(pid, &status, 0) != pid) return 53; + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) return 54; + errno = 0; + if (waitpid(pid, &status, WNOHANG) != -1 || errno != ECHILD) return 55; + if (close(fd) != 0) return 56; + if (sigprocmask(SIG_SETMASK, &original_mask, NULL) != 0) return 57; MARKER("PARENT_REAPED_STATE_CHILD\n"); + MARKER("PARENT_CONFIRMED_EXACT_REAP\n"); MARKER("PASS: VFORK_POSIX_STATE\n"); return 0; } diff --git a/scripts/build-fork-instrumented-test-fixture.sh b/scripts/build-fork-instrumented-test-fixture.sh index 98bb7942b9..f9ebd2b932 100755 --- a/scripts/build-fork-instrumented-test-fixture.sh +++ b/scripts/build-fork-instrumented-test-fixture.sh @@ -4,6 +4,7 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ARCH="" OUTPUT="" +WAT_OUTPUT="${KANDELO_FORK_FIXTURE_WAT_OUTPUT:-}" while [ "$#" -gt 0 ]; do case "$1" in @@ -52,25 +53,29 @@ INPUT_WASM="$WORK_ROOT/input.wasm" if [ "$ARCH" = "wasm64" ]; then cat >"$INPUT_WAT" <"$INPUT_WAT" <&2 + exit 2 +fi + +case "$1" in + mechanism) integration=false ;; + integration) integration=true ;; + *) echo "usage: scripts/run-vfork-readiness.sh mechanism|integration" >&2; + exit 2 ;; +esac +test -n "${IN_NIX_SHELL:-}" || { + echo "run through scripts/dev-shell.sh" >&2 + exit 2 +} + +repo_root="$(cd "$(dirname "$0")/.." && pwd -P)" +if [ "$(pwd -P)" != "$repo_root" ]; then + echo "run scripts/run-vfork-readiness.sh from the repository root" >&2 + exit 2 +fi + +bash scripts/test-vfork-readiness-interface.sh + +# Keep concurrent worktrees from silently reusing an unrelated Vite server. +# Callers may still pin a reviewed port explicitly (for example in CI). +if [ -z "${KANDELO_PLAYWRIGHT_PORT:-}" ]; then + repo_checksum="$(printf '%s' "$repo_root" | cksum | awk '{print $1}')" + KANDELO_PLAYWRIGHT_PORT=$((20000 + (repo_checksum % 20000))) + export KANDELO_PLAYWRIGHT_PORT +fi + +bash scripts/build-programs.sh + +( + cd host + npm run build +) + +host_tests=( + test/vfork-lifetime.test.ts + test/vfork-workspace.test.ts + test/vfork-production-mechanism.test.ts + test/vfork-mechanism-trace.test.ts + test/fork-mechanism-trace.test.ts + test/vfork-side-module-fixture.test.ts + test/worker-quiescence.test.ts + test/vfork-lifecycle-guest.test.ts + test/fork-process-continuation.test.ts + test/fork-borrowed-replay.test.ts + test/fork-from-dlopen-side-module-e2e.test.ts + test/fork-memory-clone-guest.test.ts + test/process-table-replication.test.ts + test/dylink-fork-archive.test.ts +) +browser_tests=( + test/vfork-lifecycle.spec.ts + test/borrowed-fork-replay.spec.ts +) + +if $integration; then + host_tests+=( + test/prepared-exec-target.test.ts + test/secure-exec.test.ts + test/nosuid-exec.test.ts + test/spawn-credential-order.test.ts + ) + browser_tests+=( + test/prepared-exec-target.spec.ts + test/nosuid-exec.spec.ts + ) +fi + +( + cd host + KANDELO_REQUIRE_SIDE_MODULE_FORK_E2E=1 npx vitest run "${host_tests[@]}" +) + +host_target="$(rustc -vV | sed -n 's/^host: //p')" +if [ -z "$host_target" ]; then + echo "rustc -vV did not report a host target" >&2 + exit 2 +fi +cargo test -p fork-instrument --target "$host_target" + +if $integration; then + cargo test -p wasm-posix-kernel --target "$host_target" \ + credentials -- --nocapture +fi + +( + cd apps/browser-demos + npx playwright test "${browser_tests[@]}" \ + --project=chromium --project=firefox --project=webkit +) diff --git a/scripts/test-homebrew-inspect-bottle.sh b/scripts/test-homebrew-inspect-bottle.sh index 1b40d0f372..f1a9444ae2 100755 --- a/scripts/test-homebrew-inspect-bottle.sh +++ b/scripts/test-homebrew-inspect-bottle.sh @@ -12,6 +12,27 @@ WASM="$TMP_ROOT/tool.wasm" ABI_VERSION="$(sed -nE 's/^pub const ABI_VERSION: u32 = ([0-9]+);$/\1/p' \ "$REPO_ROOT/crates/shared/src/lib.rs" | head -n1)" FORBIDDEN_ROOT="/trusted/runner/workspace" + +assert_fork_fixture_wat() { + local arch="$1" + local wat="$TMP_ROOT/fork-fixture-$arch.wat" + local wasm="$TMP_ROOT/fork-fixture-$arch.wasm" + KANDELO_FORK_FIXTURE_WAT_OUTPUT="$wat" \ + bash "$REPO_ROOT/scripts/build-fork-instrumented-test-fixture.sh" \ + --arch "$arch" --output "$wasm" + grep -Fx ' (import "kernel" "kernel_fork" (func $kernel_fork (param i32) (result i32)))' "$wat" >/dev/null || { + echo "test-homebrew-inspect-bottle.sh: $arch fixture has wrong kernel_fork import" >&2 + exit 1 + } + grep -Fx ' (drop (call $kernel_fork (i32.const 0)))))' "$wat" >/dev/null || { + echo "test-homebrew-inspect-bottle.sh: $arch fixture does not call ordinary fork mode 0" >&2 + exit 1 + } +} + +assert_fork_fixture_wat wasm32 +assert_fork_fixture_wat wasm64 + cat >"$FORMULA_SOURCE" <<'RUBY' class Tool < Formula desc "Archive inspector fixture" diff --git a/scripts/test-vfork-readiness-interface.sh b/scripts/test-vfork-readiness-interface.sh new file mode 100644 index 0000000000..f6a2eb27eb --- /dev/null +++ b/scripts/test-vfork-readiness-interface.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd -P)" +gate="$repo_root/scripts/run-vfork-readiness.sh" +expected="usage: scripts/run-vfork-readiness.sh mechanism|integration" + +assert_extra_argument_rejected() { + local mode="$1" + local output status + set +e + output="$(cd "$repo_root/host" && "$gate" "$mode" unexpected 2>&1)" + status=$? + set -e + if [ "$status" -ne 2 ]; then + echo "expected $mode plus an extra argument to exit 2, got $status" >&2 + exit 1 + fi + if [ "$output" != "$expected" ]; then + echo "unexpected $mode plus an extra argument output: $output" >&2 + exit 1 + fi +} + +assert_extra_argument_rejected mechanism +assert_extra_argument_rejected integration +echo "test-vfork-readiness-interface.sh: passed" From fd5f5c4ff951e3fb80485b9303edee8bef0dc842 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Mon, 10 Aug 2026 17:12:41 -0400 Subject: [PATCH 57/82] Homebrew: Complete validated archive materialization --- .../test/browser-package-layer.spec.ts | 15 +- .../test/lazy-archive-runtime.spec.ts | 117 ++- docs/architecture.md | 16 +- docs/binary-releases.md | 2 +- docs/browser-support.md | 16 +- docs/homebrew-packaging-system.md | 21 + docs/homebrew-publishing.md | 32 +- docs/package-management.md | 17 + ...8-10-abi43-login-sudo-vfork-integration.md | 5 +- host/src/homebrew-bottle-relocation.ts | 198 ++-- host/src/homebrew-deferred-tree-adapter.ts | 333 +++++++ host/src/homebrew-lazy-layer-descriptor.ts | 46 +- host/src/homebrew-lazy-layer.ts | 132 ++- host/src/homebrew-runtime-layer-consumer.ts | 196 ++-- host/src/homebrew-vfs-composer.ts | 4 +- host/src/homebrew-vfs-materializer.ts | 5 +- host/src/vfs/canonical-text.ts | 44 + host/src/vfs/deferred-tree-limits.ts | 7 + host/src/vfs/index.ts | 16 + host/src/vfs/materialization-plan.ts | 577 +++++++++++ host/src/vfs/memory-fs.ts | 934 +++++++++++++----- host/test/homebrew-bottle-relocation.test.ts | 64 ++ host/test/homebrew-vfs-builder.test.ts | 399 +++++++- host/test/lazy-tree.test.ts | 731 +++++++++++++- host/test/node-lazy-archive-runtime.test.ts | 6 +- packages/registry/erlang-vfs/build.toml | 2 + packages/registry/kandelo-sdk/build.toml | 2 + packages/registry/mariadb-test/build.toml | 2 + packages/registry/mariadb-vfs/build.toml | 2 + packages/registry/nginx-vfs/build.toml | 1 + packages/registry/node-vfs/build.toml | 1 + packages/registry/perl-vfs/build.toml | 2 + packages/registry/program-packages.json | 96 +- packages/registry/python-vfs/build.toml | 2 + packages/registry/redis-vfs/build.toml | 1 + packages/registry/rootfs/build.toml | 2 + packages/registry/shell/build.toml | 18 +- scripts/homebrew-closed-lazy-assets.test.ts | 2 +- scripts/homebrew-vfs-release.py | 358 ++++++- scripts/test-homebrew-tap-native-sidecars.sh | 16 +- scripts/test-homebrew-vfs-release-fixture.ts | 2 +- scripts/test-homebrew-vfs-release.sh | 397 +++++++- 42 files changed, 4212 insertions(+), 627 deletions(-) create mode 100644 host/src/homebrew-deferred-tree-adapter.ts create mode 100644 host/src/vfs/canonical-text.ts create mode 100644 host/src/vfs/materialization-plan.ts create mode 100644 host/test/homebrew-bottle-relocation.test.ts diff --git a/apps/browser-demos/test/browser-package-layer.spec.ts b/apps/browser-demos/test/browser-package-layer.spec.ts index 08475cbdd9..c56263a7a3 100644 --- a/apps/browser-demos/test/browser-package-layer.spec.ts +++ b/apps/browser-demos/test/browser-package-layer.spec.ts @@ -818,7 +818,7 @@ async function createDirectBottleFixture(): Promise { ].sort((left, right) => compareHomebrewCanonicalText(left.id, right.id)); const packageOrder = [dependencyPackage.full_name, rootPackage.full_name]; const draft: HomebrewLazyLayerDraftDescriptor = { - schema: 5, + schema: 6, kind: "kandelo-homebrew-deferred-layer-draft", arch: "wasm32", mount_prefix: "/", @@ -1336,13 +1336,12 @@ test("browser discards each private package-layer stage after repeated boot-pref )).toBe(initialDiscards + attempt); } - expect(descriptorFetches).toBe(bootAttempts); - expect(directArchiveFetches).toBe( - bootAttempts * transientTransportAttempts, - ); - expect(mirrorArchiveFetches).toBe( - bootAttempts * transientTransportAttempts, - ); + expect(descriptorFetches).toBe(3); + // Each private stage owns a fresh lazy-transport attempt. HTTP 503 is + // retryable, so each transport receives its bounded three GETs before the + // stage is discarded and the next boot creates another private stage. + expect(directArchiveFetches).toBe(9); + expect(mirrorArchiveFetches).toBe(9); await expect(page.evaluate(async (path) => { try { await window.__readPackageLayerAcceptance(path); diff --git a/apps/browser-demos/test/lazy-archive-runtime.spec.ts b/apps/browser-demos/test/lazy-archive-runtime.spec.ts index e76d2678ac..898dcae696 100644 --- a/apps/browser-demos/test/lazy-archive-runtime.spec.ts +++ b/apps/browser-demos/test/lazy-archive-runtime.spec.ts @@ -12,7 +12,12 @@ import { ABI_VERSION } from "../../../host/src/generated/abi"; import { MemoryFileSystem, type LazyTreeRegistrationEntry, + type LazyTreeSourceInventory, } from "../../../host/src/vfs/memory-fs"; +import { + encodeMaterializationBytes, + type LazyTreeMaterializationPlan, +} from "../../../host/src/vfs/materialization-plan"; import { derivePackageDeferredZipTree, materializePackageDeferredZipTree, @@ -66,18 +71,24 @@ async function lazyImage(groups: Array<{ archive: Uint8Array; tarBytes?: number; inventory?: LazyTreeRegistrationEntry[]; + source?: LazyTreeSourceInventory; + materialization?: LazyTreeMaterializationPlan; }>): Promise { const fs = MemoryFileSystem.create(new SharedArrayBuffer(32 * 1024 * 1024)); fs.setImageMetadata({ version: 1, kernelAbi: ABI_VERSION }); for (const group of groups) { if (group.inventory && group.tarBytes !== undefined) { fs.registerLazyTree({ - decoder: "homebrew-bottle-tar-gzip-v1", + decoder: "tar-gzip-v1", mediaType: "application/vnd.oci.image.layer.v1.tar+gzip", ...identity(group.archive), expandedBytes: group.tarBytes, - sourceEntryCount: group.inventory.length, + sourceEntryCount: group.source?.entries.length ?? group.inventory.length, transports: [group.url], + ...(group.source === undefined ? {} : { source: group.source }), + ...(group.materialization === undefined + ? {} + : { materialization: group.materialization }), }, group.inventory); } else { fs.registerLazyArchiveFromEntries( @@ -344,6 +355,108 @@ test("Chromium retries a transient lazy-tree response before surfacing EIO", asy expect(fetches).toBe(2); }); +test("browser applies a generic authenticated archive transformation", async ({ + page, + baseURL, +}) => { + if (!baseURL) throw new Error("Playwright baseURL is required"); + const archiveUrl = "https://fixtures.kandelo.invalid/transformed.tar.gz"; + const imageUrl = "https://fixtures.kandelo.invalid/transformed.vfs"; + const sourceBytes = new TextEncoder().encode("prefix=@@ROOT@@\n"); + const outputBytes = new TextEncoder().encode("prefix=/etc\n"); + const tar = tarBytes([{ + path: "bundle/config", + mode: 0o644, + data: sourceBytes, + }]); + const archive = gzipSync(tar); + const source = { + schema: 1, + kind: "archive-source-inventory-v1", + entries: [{ + sourcePath: "bundle/config", + type: "file", + mode: 0o644, + size: sourceBytes.byteLength, + }], + } as const satisfies LazyTreeSourceInventory; + const recipe = { + id: "browser-root-relocation-v1", + replacements: [{ + matchHex: encodeMaterializationBytes( + new TextEncoder().encode("@@ROOT@@"), + ), + replacementHex: encodeMaterializationBytes( + new TextEncoder().encode("/etc"), + ), + }], + rejectHex: [ + encodeMaterializationBytes(new TextEncoder().encode("@@ROOT@@")), + ], + }; + const materialization = { + schema: 1, + kind: "archive-byte-transforms-v1", + assertions: [{ + sourcePath: "bundle/config", + bytesHex: encodeMaterializationBytes(sourceBytes), + }], + recipes: [recipe], + transforms: [{ + sourcePath: "bundle/config", + recipe: recipe.id, + input: identity(sourceBytes), + output: identity(outputBytes), + }], + } as const satisfies LazyTreeMaterializationPlan; + const image = await lazyImage([{ + url: archiveUrl, + archive, + tarBytes: tar.byteLength, + source, + materialization, + inventory: [{ + vfsPath: "/etc/transformed-data", + sourcePath: "bundle/config", + materialization: "archive", + type: "file", + mode: 0o644, + size: outputBytes.byteLength, + inodeGroup: "browser:transformed-data", + }], + }]); + let fetches = 0; + await routeBytes(page, imageUrl, image, "application/octet-stream"); + await page.route(archiveUrl, async (route) => { + fetches++; + await route.fulfill({ + status: 200, + body: Buffer.from(archive), + headers: { + "access-control-allow-origin": "*", + "content-length": String(archive.byteLength), + }, + }); + }); + + await page.goto(new URL("/pages/homebrew-vfs-test/", baseURL).href); + await expect.poll( + () => page.evaluate(() => window.__homebrewVfsTestReady), + { timeout: 120_000 }, + ).toBe(true); + const result = await page.evaluate( + (url) => window.__runLazyVfsAcceptance({ + vfsUrl: url, + readPath: "/etc/transformed-data", + timeoutMs: 30_000, + }), + imageUrl, + ); + + expect(result.readText).toBe("prefix=/etc\n"); + expect(fetches).toBe(1); +}); + test("browser workers proxy external lazy archives under cross-origin isolation", async ({ page, baseURL, diff --git a/docs/architecture.md b/docs/architecture.md index 0d7c496655..7069e78d7e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1684,8 +1684,9 @@ concrete files rather than copying aliases into independent inodes. A rebase walks one quiescent source snapshot, so a peer rename cannot mix lazy paths from one namespace state with bytes from another. -`registerLazyTree` is the format-neutral grouped form used by schema-4 package -layers. Its serialized metadata adds a closed decoder/media type, immutable +`registerLazyTree` is the format-neutral grouped form used by package layers +and other archive-backed consumers. Its serialized metadata adds a closed +decoder/media type, immutable digest and byte count, transport locations, activation policy, complete source and guest inventory, and regular-inode groups. Existing `registerLazyArchiveFromEntries` ZIP consumers remain supported. Registration @@ -1697,6 +1698,15 @@ identity-guarded batch replacement, so failure leaves all pending regular inodes unchanged. Hard-link aliases use one SharedFS inode and retain that identity when the lazy metadata is transferred or saved in an image. +Generic TAR+gzip trees use the closed `tar-gzip-v1` decoder. A tree may carry +a bounded `archive-byte-transforms-v1` plan containing exact source-byte +assertions, ordered literal byte-replacement recipes, and declared input and +output SHA-256/length identities. The VFS interprets no producer callbacks, +regular expressions, scripts, or package policy. It applies the same plan to +eager and lazy decoding, verifies both identities, and publishes only after +the complete transformed tree passes validation. Plan fields participate in +atomic-tree identity and survive image restore and filesystem rebase. + Several first-use trees can opt into one fail-closed activation cohort. Each tree registers a producer-stable member name, and the producer must explicitly seal the exact expected member set before the cohort can activate or serialize. @@ -1738,7 +1748,7 @@ Content, activation, mount prefix, inventory, and pending inode metadata reject unknown fields, unsafe or oversized strings, count/size disagreement, and missing, cyclic, or cross-inode hard-link targets before a group is installed. Serialized groups carry an explicit `kandelo-deferred-tree-v1` (derived ZIP), -`kandelo-deferred-tree-v2` (original bottle), or +`kandelo-deferred-tree-v2` (complete source inventory), or `kandelo-legacy-zip-v1` kind. A sealed multi-tree cohort uses `kandelo-deferred-tree-v3`, regardless of decoder, because its atomic membership is an additional closed wire contract; v1/v2 records cannot quietly acquire diff --git a/docs/binary-releases.md b/docs/binary-releases.md index d255b4294d..b725f05969 100644 --- a/docs/binary-releases.md +++ b/docs/binary-releases.md @@ -68,7 +68,7 @@ the source tap repository under `homebrew-vfs-sha256-`. Lazy runtime content publishes separately under `homebrew-runtime-layer-sha256-`; that closed identity binds its shell base, payload inventory, bottle provenance, and acceptance evidence. -The eager release contains its five acceptance assets. A schema-5 direct +The eager release contains its five acceptance assets. A schema-6 direct runtime release contains its closed descriptor plus one exact payload per deferred bottle; a historical schema-4 one-tree release contains its descriptor and single payload. Generic browser gallery output diff --git a/docs/browser-support.md b/docs/browser-support.md index df55748994..d24dfb7568 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -764,7 +764,7 @@ requested root equal to its layer ID. The wider 128-name descriptor/parser bound is shared with planning and leaves room for collection artifacts, but it does not turn this boot mount into a multi-root layer. Phase 3 composes the multi-root main shell through the bottle-collection primitive instead. -Schema-5 direct-bottle `deferred_trees` carry a complete source inventory and guest +Schema-6 direct-bottle `deferred_trees` carry a complete source inventory and guest projection: paths, types, modes, links, regular-inode groups, materialization provenance, immutable content identity, a closed decoder/media-type pair, and one to eight byte-identical immutable HTTPS transports. Exactly one @@ -870,18 +870,24 @@ layers and non-Homebrew deferred archives, but is not produced as a substitute for an original Homebrew bottle. The source inventory and materialization provenance are additive deferred-tree -metadata. Existing schema-4 ZIP descriptors and serialized legacy deferred -trees remain valid on the new host. An older host rejects a direct-bottle +metadata. Homebrew owns receipt parsing, changed-file and keg policy, prefix +authentication, and the legacy `homebrew-bottle-tar-gzip-v1` vocabulary. Its +adapter validates that policy and erases it into the generic `tar-gzip-v1` +source inventory and byte-transform plan before calling the VFS. Schema-5 +bottles remain readable only when they need no receipt relocation; a schema-5 +receipt-relocation marker or schema-6 plan fails closed. Existing schema-4 ZIP +descriptors and serialized legacy deferred trees remain valid on the new host. +An older host rejects a direct-bottle descriptor because the closed object contains fields it does not understand; it does not reinterpret the bottle as the older one-source-per-guest-entry shape. These metadata additions do not change the kernel/process ABI or the -ABI binding carried by a VFS image. +ABI binding carried by a VFS image; the kernel/process ABI remains 43. Boot accepts at most eight package layers and 16 MiB of descriptor bytes in aggregate. The shared consumer additionally caps aggregate compressed payload bytes, expanded bytes, and entry count. Boot-prefetch downloads use at most two workers. Each package's declared keg and `opt` link must match its indexed -paths. Every schema-5 ancestor at or below `/opt/kandelo/homebrew` must be +paths. Every schema-6 ancestor at or below the authenticated Homebrew prefix must be declared in the aggregate guest projection. Equal-mode `mergeable-directory` claims can create an absent directory once or reuse an equal-mode lower-image directory; undeclared ancestors, unequal modes, and non-directory collisions diff --git a/docs/homebrew-packaging-system.md b/docs/homebrew-packaging-system.md index 3eb96b5780..13b632988d 100644 --- a/docs/homebrew-packaging-system.md +++ b/docs/homebrew-packaging-system.md @@ -473,6 +473,27 @@ a separate immutable bottle transport. A sealed atomic runtime cohort may activate its selected dependency members together so the program never observes a partial closure; unrelated bottles remain lazy. +Homebrew owns the translation from a bottle to the generic VFS contract. +A schema-6 bottle descriptor authenticates the complete TAR inventory, +exact receipt bytes, guest projection, and closed byte-transformation plan. +The shared Homebrew adapter derives the prefix and Cellar from the receipt's +authenticated guest destination, checks that every direct member remains in +the same keg, compares the relocation set with receipt `changed_files`, and +proves that the generic recipe is exactly the Homebrew relocation recipe for +that prefix. Only then does it register generic `tar-gzip-v1` archive metadata +with MemoryFS. + +MemoryFS does not recognize Formulae, bottles, kegs, `Cellar`, Homebrew +prefixes, or `INSTALL_RECEIPT.json`. It verifies source bytes, the complete +decoded inventory, bounded transformations, output identities, paths, links, +collisions, and atomic publication. This division lets ordinary TAR or ZIP +sources use the same eager or lazy mechanism without being placed below a +Cellar. It also keeps Node and browser behavior on the same shared path. + +Schema-4 ZIP layers remain readable. A schema-5 direct bottle that needs +receipt relocation must be rebuilt as schema 6; the consumer fails closed +instead of reconstructing an unauthenticated plan from fetched bottle bytes. + Small, independent command binaries from a bundle such as `posix-utils-lite` may later use per-program lazy references. A program such as Vim needs runtime data beside its executable and should normally diff --git a/docs/homebrew-publishing.md b/docs/homebrew-publishing.md index c7cd82c109..a51d80b954 100644 --- a/docs/homebrew-publishing.md +++ b/docs/homebrew-publishing.md @@ -3898,20 +3898,24 @@ same browser-safe relocation implementation. First use still fetches and verifies the complete unmodified `.tar.gz`; the content digest and byte count never describe relocated or recompressed bytes. -After decoding and complete source-inventory validation, the runtime requires -its relocation markers to equal the exact receipt list, relocates the shared -regular inode once, preserves every hardlink alias, and only then atomically -commits the group. A missing changed file, unsafe or duplicate receipt path, -retained supported placeholder, unresolved Java dependency, marker mismatch, -or final-size mismatch leaves the whole group pending and retryable. Upstream -receipts may represent an empty `changed_files` list as either `null` or `[]`; -both mean that no archive member is relocated. - -The image builder emits an inert schema-5 draft because exact Node and Chromium +Before registration, the Homebrew adapter requires its relocation markers and +generic transform plan to equal the exact receipt list and authenticated +destination prefix. It then erases Homebrew policy into the generic +`tar-gzip-v1` VFS contract. After decoding and complete source-inventory +validation, the VFS verifies each transform input, applies its bounded literal +replacements, verifies the declared output digest and length, preserves every +hardlink alias, and only then atomically commits the group. A missing changed +file, unsafe or duplicate receipt path, retained supported placeholder, +unresolved Java dependency, marker mismatch, output identity mismatch, or +expansion past the global VFS cap leaves the whole group pending and retryable. +Upstream receipts may represent an empty `changed_files` list as either `null` +or `[]`; both mean that no archive member is relocated. + +The image builder emits an inert schema-6 draft because exact Node and Chromium evidence does not exist until the eager image has run. The credential-free release preparer validates that draft, every exact bottle payload, the eager descriptor, report, and both host evidence files, then closes the public -schema-5 descriptor. Its `deferred_trees[]` contract names a Formula identity, +schema-6 descriptor. Its `deferred_trees[]` contract names a Formula identity, immutable SHA-256 and byte count, decoder and media type, ordered transport locations, activation policy, complete source inventory, and complete guest projection. The closed descriptor also binds @@ -4583,7 +4587,7 @@ path: ?vfs=https://github.com//homebrew-/releases/download/homebrew-vfs-sha256-/kandelo-homebrew.vfs.zst ``` -`kandelo-homebrew--layer.json` is a separate closed schema-5 entry +`kandelo-homebrew--layer.json` is a separate closed schema-6 entry point for direct bottle content. Keeping it separate preserves the stable whole-image descriptor contract and gives the runtime layer an identity that cannot alias a changed base, payload, or inventory merely because the eager VFS @@ -4630,7 +4634,7 @@ descriptor and VFS path. A `package-layer` mount targets `/` and carries a bounded descriptor URL, exact descriptor byte count, and lowercase SHA-256 reference. Boot eagerly fetches and validates only those descriptor bytes. It then restores the exact compressed shell package output into a private -filesystem, binds the schema-5 descriptor to that base, its ABI, and +filesystem, binds the schema-6 descriptor to that base, its ABI, and `/etc/kandelo/homebrew-vfs.json` composition, and rejects base or pairwise package/path collisions. Only a completely registered selection whose required boot-prefetch trees have succeeded is returned to boot, so a failed composition @@ -4846,7 +4850,7 @@ is the only legacy exception: reconciliation verifies all seven current handoff files byte-for-byte. A partial legacy set, an unknown name, or a mismatched legacy payload fails; the publisher never fills or rewrites an immutable legacy release. New acceptance releases always use five assets, and every new closed -schema-5 direct layer uses an independent runtime release containing its +schema-6 direct layer uses an independent runtime release containing its descriptor and exactly one payload per deferred bottle. Historical schema-4 one-tree layers retain their two-asset release shape. The Actions receipt is only a receipt; release assets are the durable public product. diff --git a/docs/package-management.md b/docs/package-management.md index 99004c891f..dfcab1951c 100644 --- a/docs/package-management.md +++ b/docs/package-management.md @@ -743,6 +743,23 @@ atomically materializes its guest projection. It does not fetch individual TAR members or use HTTP ranges. Dependency bottles have separate identities and remain unfetched until a path owned by that dependency is used. +The underlying deferred-tree contract is package-manager neutral. A package or +image builder may authenticate a complete ZIP or TAR+gzip source inventory, +project ordinary files, directories, symbolic links, and hard links to +canonical VFS destinations, and optionally declare exact bounded byte +transformations. The lazy and eager paths consume the same source identity, +inventory, transformation plan, and destination inventory. A non-Homebrew +archive therefore does not need a Formula, Cellar path, receipt, or bottle +marker to remain lazy. + +Homebrew-specific descriptor code owns the additional receipt, prefix, Cellar, +keg, and link-manifest checks. Its schema-6 adapter validates those rules and +translates them into the generic archive contract before MemoryFS registration. +MemoryFS never infers Homebrew meaning from filenames or destinations. A stale +schema-5 bottle that requires receipt relocation must be rebuilt through the +normal bottle publication path because it lacks the authenticated generic +plan. + The guest `brew` implementation is distributed as the `homebrew-bootstrap` support-data Formula bottle. Its tap-native recipe declares two `libexec` outputs: `homebrew-bootstrap.zip`, a deterministic diff --git a/docs/superpowers/plans/2026-08-10-abi43-login-sudo-vfork-integration.md b/docs/superpowers/plans/2026-08-10-abi43-login-sudo-vfork-integration.md index 88a0c6ee21..db97b20d40 100644 --- a/docs/superpowers/plans/2026-08-10-abi43-login-sudo-vfork-integration.md +++ b/docs/superpowers/plans/2026-08-10-abi43-login-sudo-vfork-integration.md @@ -607,7 +607,10 @@ symbolic link, and hard link. Test eager and lazy materialization with an exact byte replacement. Reject unknown keys, duplicate recipes, duplicate transforms, odd/non-hex byte strings, unbounded replacement counts, missing source inventory entries, input/output digest drift, unsafe paths, and a -replacement whose byte length differs. +transformed output whose actual length or digest differs from its declaration. +Match and replacement lengths may differ because authenticated Homebrew +prefixes require bounded expansion; reject arithmetic overflow or expansion +past the named global VFS limit before allocating the output. The success fixture uses this complete shape: diff --git a/host/src/homebrew-bottle-relocation.ts b/host/src/homebrew-bottle-relocation.ts index 420b326ac2..d90cfd2334 100644 --- a/host/src/homebrew-bottle-relocation.ts +++ b/host/src/homebrew-bottle-relocation.ts @@ -6,31 +6,37 @@ * archive has been verified and decoded. */ -import { KANDELO_HOMEBREW_GUEST_LAYOUT } from "./homebrew-guest-layout"; +import { + applyLazyTreeByteTransformRecipe, + encodeMaterializationBytes, + type LazyTreeByteTransformRecipe, +} from "./vfs/materialization-plan"; const MAX_BOTTLE_CHANGED_FILES = 100_000; const MAX_BOTTLE_PATH_BYTES = 4096; const MAX_BOTTLE_RUNTIME_DEPENDENCIES = 512; const FULL_NAME_RE = /^([a-z0-9][a-z0-9._-]*)\/([a-z0-9][a-z0-9._-]*)\/([a-z0-9][a-z0-9._-]*)$/; -const HOMEBREW_PREFIX = KANDELO_HOMEBREW_GUEST_LAYOUT.prefix; -const HOMEBREW_REPLACEMENTS = [ - ["@@HOMEBREW_PREFIX@@", HOMEBREW_PREFIX], - ["@@HOMEBREW_CELLAR@@", `${HOMEBREW_PREFIX}/Cellar`], - ["@@HOMEBREW_REPOSITORY@@", HOMEBREW_PREFIX], - ["@@HOMEBREW_LIBRARY@@", `${HOMEBREW_PREFIX}/Library`], - ["@@HOMEBREW_PERL@@", `${HOMEBREW_PREFIX}/opt/perl/bin/perl`], -] as const; const HOMEBREW_JAVA_PLACEHOLDER = "@@HOMEBREW_JAVA@@"; const HOMEBREW_OPENJDK_NAME_RE = /^openjdk(?:@\d+(?:\.\d+)*)?/; const TEXT_ENCODER = new TextEncoder(); +const HOMEBREW_TEXT_PLACEHOLDERS = [ + "@@HOMEBREW_PREFIX@@", + "@@HOMEBREW_CELLAR@@", + "@@HOMEBREW_REPOSITORY@@", + "@@HOMEBREW_LIBRARY@@", + "@@HOMEBREW_PERL@@", +] as const; const PLACEHOLDER_BYTES = [ - ...HOMEBREW_REPLACEMENTS.map(([placeholder]) => placeholder), + ...HOMEBREW_TEXT_PLACEHOLDERS, HOMEBREW_JAVA_PLACEHOLDER, ].map((placeholder) => ({ placeholder, bytes: TEXT_ENCODER.encode(placeholder), })); +export const HOMEBREW_BOTTLE_RELOCATION_RECIPE_ID = + "homebrew-receipt-text-v1"; + export interface HomebrewInstallReceiptRelocation { changedFiles: readonly string[]; /** Kept opaque until a changed file actually uses the Java placeholder. */ @@ -43,6 +49,46 @@ export interface HomebrewInstallReceiptDirectDependency { revision: number; } +export interface HomebrewBottleRelocationDestination { + /** Authenticated prefix which owns the exact bottle destination. */ + destinationPrefix: string; + /** Guest or source path used only to identify a relocation failure. */ + path: string; +} + +/** + * Normalize the sole prefix allowed to interpret one authenticated bottle. + * + * Immutable descriptors bind guest paths and relocated byte sizes. Once a + * receipt destination has been authenticated, consulting a host default could + * silently produce bytes for a different image. + */ +export function normalizeHomebrewBottleDestinationPrefix(value: string): string { + validateSafeAbsolutePath(value, "Homebrew bottle destination prefix"); + if (value === "/") { + throw new Error("Homebrew bottle destination prefix must not be the filesystem root"); + } + return value; +} + +/** Derive and normalize a bottle prefix from one authenticated receipt path. */ +export function deriveHomebrewBottleDestinationPrefix( + receiptGuestPath: string, + receiptSourcePath: string, +): string { + validateSafeRelativePath(receiptSourcePath, "Homebrew receipt source path"); + validateSafeAbsolutePath(receiptGuestPath, "Homebrew receipt guest path"); + const cellarSuffix = `/Cellar/${receiptSourcePath}`; + if (!receiptGuestPath.endsWith(cellarSuffix)) { + throw new Error( + `Homebrew receipt guest path does not match its source path: ${receiptGuestPath}`, + ); + } + return normalizeHomebrewBottleDestinationPrefix( + receiptGuestPath.slice(0, -cellarSuffix.length), + ); +} + export function parseHomebrewInstallReceiptRelocation( bytes: Uint8Array, ): HomebrewInstallReceiptRelocation { @@ -169,37 +215,75 @@ function compareCanonicalText(left: string, right: string): number { export function relocateHomebrewBottleFile( bytes: Uint8Array, receipt: HomebrewInstallReceiptRelocation, - path: string, + destination: HomebrewBottleRelocationDestination, ): Uint8Array { - let relocated = bytes; - for (const [placeholder, replacement] of HOMEBREW_REPLACEMENTS) { - relocated = replaceBytes( - relocated, - TEXT_ENCODER.encode(placeholder), - TEXT_ENCODER.encode(replacement), - ); - } + const destinationPrefix = normalizeHomebrewBottleDestinationPrefix( + destination.destinationPrefix, + ); const javaPlaceholder = TEXT_ENCODER.encode(HOMEBREW_JAVA_PLACEHOLDER); - if (containsBytes(relocated, javaPlaceholder)) { - const javaHome = homebrewJavaHome(receipt.runtimeDependencies); + if (containsBytes(bytes, javaPlaceholder)) { + const javaHome = homebrewJavaHome(receipt.runtimeDependencies, destinationPrefix); if (javaHome === undefined) { throw new Error( - `Homebrew changed file ${path} uses ${HOMEBREW_JAVA_PLACEHOLDER} ` + - "without exactly one OpenJDK runtime dependency", + `Homebrew changed file ${destination.path} uses ${HOMEBREW_JAVA_PLACEHOLDER} ` + + "without exactly one OpenJDK runtime dependency", + ); + } + } + try { + return applyLazyTreeByteTransformRecipe( + bytes, + createHomebrewBottleRelocationRecipe(receipt, destination), + ); + } catch (error) { + const remaining = error instanceof Error + ? PLACEHOLDER_BYTES.find(({ bytes: placeholder }) => + error.message.endsWith( + `retains rejected byte sequence ${encodeMaterializationBytes(placeholder)}`, + ) + ) + : undefined; + if (remaining !== undefined) { + throw new Error( + `Homebrew changed file ${destination.path} retains ${remaining.placeholder}`, ); } - relocated = replaceBytes(relocated, javaPlaceholder, TEXT_ENCODER.encode(javaHome)); + throw error; } - const remaining = PLACEHOLDER_BYTES.find(({ bytes: placeholder }) => - containsBytes(relocated, placeholder) +} + +/** Translate authenticated receipt policy into the closed generic recipe. */ +export function createHomebrewBottleRelocationRecipe( + receipt: HomebrewInstallReceiptRelocation, + destination: HomebrewBottleRelocationDestination, +): LazyTreeByteTransformRecipe { + const destinationPrefix = normalizeHomebrewBottleDestinationPrefix( + destination.destinationPrefix, ); - if (remaining !== undefined) { - throw new Error(`Homebrew changed file ${path} retains ${remaining.placeholder}`); + const replacements: Array = [ + ["@@HOMEBREW_PREFIX@@", destinationPrefix], + ["@@HOMEBREW_CELLAR@@", `${destinationPrefix}/Cellar`], + ["@@HOMEBREW_REPOSITORY@@", destinationPrefix], + ["@@HOMEBREW_LIBRARY@@", `${destinationPrefix}/Library`], + ["@@HOMEBREW_PERL@@", `${destinationPrefix}/opt/perl/bin/perl`], + ]; + const javaHome = homebrewJavaHome(receipt.runtimeDependencies, destinationPrefix); + if (javaHome !== undefined) { + replacements.push([HOMEBREW_JAVA_PLACEHOLDER, javaHome]); } - return relocated; + return { + id: HOMEBREW_BOTTLE_RELOCATION_RECIPE_ID, + replacements: replacements.map(([match, replacement]) => ({ + matchHex: encodeMaterializationBytes(TEXT_ENCODER.encode(match)), + replacementHex: encodeMaterializationBytes(TEXT_ENCODER.encode(replacement)), + })), + rejectHex: PLACEHOLDER_BYTES.map(({ bytes }) => + encodeMaterializationBytes(bytes) + ), + }; } -function homebrewJavaHome(value: unknown): string | undefined { +function homebrewJavaHome(value: unknown, destinationPrefix: string): string | undefined { if (!Array.isArray(value)) return undefined; const names: string[] = []; for (const dependency of value) { @@ -221,10 +305,23 @@ function homebrewJavaHome(value: unknown): string | undefined { } const unique = [...new Set(names)]; return unique.length === 1 - ? `${HOMEBREW_PREFIX}/opt/${unique[0]}/libexec` + ? `${destinationPrefix}/opt/${unique[0]}/libexec` : undefined; } +function validateSafeAbsolutePath(value: string, label: string): void { + if ( + !value.startsWith("/") || value.includes("\\") || value.includes("\0") || + hasLoneUnicodeSurrogate(value) || + TEXT_ENCODER.encode(value).byteLength > MAX_BOTTLE_PATH_BYTES || + value.slice(1).split("/").some((part) => + part === "" || part === "." || part === ".." + ) + ) { + throw new Error(`${label} has an unsafe path segment: ${value}`); + } +} + function validateSafeRelativePath(value: string, label: string): void { if ( value.length === 0 || value.startsWith("/") || value.includes("\\") || @@ -266,45 +363,6 @@ function containsBytes(bytes: Uint8Array, needle: Uint8Array): boolean { return false; } -function replaceBytes( - bytes: Uint8Array, - needle: Uint8Array, - replacement: Uint8Array, -): Uint8Array { - const offsets: number[] = []; - for (let offset = 0; offset <= bytes.byteLength - needle.byteLength;) { - let equal = true; - for (let index = 0; index < needle.byteLength; index += 1) { - if (bytes[offset + index] !== needle[index]) { - equal = false; - break; - } - } - if (equal) { - offsets.push(offset); - offset += needle.byteLength; - } else { - offset += 1; - } - } - if (offsets.length === 0) return bytes; - const result = new Uint8Array( - bytes.byteLength + offsets.length * (replacement.byteLength - needle.byteLength), - ); - let sourceOffset = 0; - let targetOffset = 0; - for (const offset of offsets) { - const prefix = bytes.subarray(sourceOffset, offset); - result.set(prefix, targetOffset); - targetOffset += prefix.byteLength; - result.set(replacement, targetOffset); - targetOffset += replacement.byteLength; - sourceOffset = offset + needle.byteLength; - } - result.set(bytes.subarray(sourceOffset), targetOffset); - return result; -} - function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/host/src/homebrew-deferred-tree-adapter.ts b/host/src/homebrew-deferred-tree-adapter.ts new file mode 100644 index 0000000000..09d6ed021d --- /dev/null +++ b/host/src/homebrew-deferred-tree-adapter.ts @@ -0,0 +1,333 @@ +/** + * Validate authenticated Homebrew bottle policy, then erase it into the + * generic VFS deferred-tree contract. + */ + +import { + createHomebrewBottleRelocationRecipe, + deriveHomebrewBottleDestinationPrefix, + HOMEBREW_BOTTLE_RELOCATION_RECIPE_ID, + parseHomebrewInstallReceiptRelocation, +} from "./homebrew-bottle-relocation"; +import type { + HomebrewDeferredTreeDescriptor, + HomebrewDeferredTreeSourceEntry, +} from "./homebrew-lazy-layer-descriptor"; +import type { + LazyTreeDecoder, + LazyTreeRegistrationEntry, + LazyTreeSourceInventory, +} from "./vfs/memory-fs"; +import { + decodeMaterializationBytes, + validateLazyTreeMaterializationPlan, + type LazyTreeMaterializationPlan, +} from "./vfs/materialization-plan"; + +export interface AdaptedHomebrewDeferredTree { + decoder: LazyTreeDecoder; + source?: LazyTreeSourceInventory; + materialization?: LazyTreeMaterializationPlan; + entries: LazyTreeRegistrationEntry[]; +} + +/** Validate all bottle policy before returning only generic VFS inputs. */ +export function adaptHomebrewDeferredTree( + tree: HomebrewDeferredTreeDescriptor, +): AdaptedHomebrewDeferredTree { + const entries = tree.inventory.entries.map(homebrewEntryToGenericEntry); + if (tree.content.decoder === "zip-v1") { + if ( + tree.inventory.source !== undefined || + tree.inventory.relocation !== undefined || + tree.inventory.entries.some((entry) => entry.materialization !== undefined) + ) { + throw new Error("Homebrew ZIP tree carries original-bottle policy"); + } + return { decoder: "zip-v1", entries }; + } + + const source = homebrewSourceToGenericInventory(tree); + const sourceByPath = new Map( + tree.inventory.source!.entries.map((entry) => [entry.path, entry]), + ); + const canonicalByPath = resolveHomebrewSourceHardlinks( + tree.inventory.source!.entries, + sourceByPath, + ); + const receiptSources = tree.inventory.source!.entries.filter((entry) => + entry.path === "INSTALL_RECEIPT.json" || + entry.path.endsWith("/INSTALL_RECEIPT.json") + ); + const relocationEntries = tree.inventory.entries.filter((entry) => + entry.materialization === "archive-homebrew-relocate" + ); + const relocation = parseHomebrewRelocation(tree.inventory.relocation, source); + if (relocation === undefined) { + if (receiptSources.length > 0 || relocationEntries.length > 0) { + throw new Error( + "Homebrew bottle receipt relocation requires a schema-6 adapter plan", + ); + } + return { decoder: "tar-gzip-v1", source, entries }; + } + if (receiptSources.length !== 1) { + throw new Error( + `Homebrew bottle has ${receiptSources.length} INSTALL_RECEIPT.json ` + + "source members, expected one", + ); + } + const receiptSource = receiptSources[0]!; + if (relocation.receiptSourcePath !== receiptSource.path) { + throw new Error("Homebrew bottle relocation names a different receipt source"); + } + const receiptCanonical = receiptSource.type === "file" + ? receiptSource + : canonicalByPath.get(receiptSource.path); + if (receiptCanonical?.type !== "file") { + throw new Error("Homebrew bottle INSTALL_RECEIPT.json is not regular"); + } + if ( + relocation.materialization.assertions.length !== 1 || + relocation.materialization.assertions[0]!.sourcePath !== receiptCanonical.path + ) { + throw new Error( + "Homebrew bottle plan does not assert its canonical receipt bytes", + ); + } + const receipt = parseHomebrewInstallReceiptRelocation( + decodeMaterializationBytes( + relocation.materialization.assertions[0]!.bytesHex, + ), + ); + const receiptGuests = tree.inventory.entries.filter((entry) => + entry.source_path === receiptSource.path && + (entry.materialization === "archive" || + entry.materialization === "archive-homebrew-relocate") && + `/${entry.path}`.endsWith(`/Cellar/${receiptSource.path}`) + ); + if (receiptGuests.length !== 1) { + throw new Error( + "Homebrew bottle cannot identify one authenticated receipt destination", + ); + } + // WHY: these two paths are authenticated by the descriptor. Deriving the + // prefix here prevents an ambient runtime default from changing bottle bytes. + const destinationPrefix = deriveHomebrewBottleDestinationPrefix( + `/${receiptGuests[0]!.path}`, + receiptSource.path, + ); + const relativePrefix = destinationPrefix.slice(1); + for (const entry of tree.inventory.entries) { + if ( + entry.path !== relativePrefix && + !entry.path.startsWith(`${relativePrefix}/`) + ) { + throw new Error( + `Homebrew bottle entry /${entry.path} escapes its receipt prefix`, + ); + } + if ( + (entry.materialization === "archive" || + entry.materialization === "archive-homebrew-relocate") && + `/${entry.path}` !== `${destinationPrefix}/Cellar/${entry.source_path}` + ) { + throw new Error( + `Homebrew bottle maps an archive member outside its keg at /${entry.path}`, + ); + } + } + + const sourceRootEnd = receiptSource.path.lastIndexOf("/"); + const sourceRoot = sourceRootEnd < 0 + ? "" + : receiptSource.path.slice(0, sourceRootEnd); + const changedSources = new Set(receipt.changedFiles.map((path) => + sourceRoot.length === 0 ? path : `${sourceRoot}/${path}` + )); + const markedSources = new Set( + relocationEntries.map((entry) => entry.source_path), + ); + if (!equalSets(changedSources, markedSources)) { + throw new Error( + "Homebrew bottle relocation markers differ from INSTALL_RECEIPT.json", + ); + } + for (const entry of relocationEntries) { + if ( + `/${entry.path}` !== + `${destinationPrefix}/Cellar/${entry.source_path}` + ) { + throw new Error( + `Homebrew bottle changed destination /${entry.path} ` + + "does not match its receipt destination prefix", + ); + } + } + + const transformedCanonicalSources = new Set(); + for (const sourcePath of changedSources) { + const changed = sourceByPath.get(sourcePath); + const canonical = changed?.type === "file" + ? changed + : changed === undefined + ? undefined + : canonicalByPath.get(changed.path); + if (canonical?.type !== "file") { + throw new Error( + `Homebrew bottle changed source ${sourcePath} is not regular`, + ); + } + transformedCanonicalSources.add(canonical.path); + } + const plannedSources = new Set( + relocation.materialization.transforms.map((transform) => transform.sourcePath), + ); + if (!equalSets(transformedCanonicalSources, plannedSources)) { + throw new Error( + "Homebrew bottle plan transforms differ from INSTALL_RECEIPT.json", + ); + } + const expectedRecipe = createHomebrewBottleRelocationRecipe(receipt, { + destinationPrefix, + path: receiptSource.path, + }); + if (plannedSources.size === 0) { + if (relocation.materialization.recipes.length !== 0) { + throw new Error("Homebrew bottle plan has a recipe without changed files"); + } + } else if ( + relocation.materialization.recipes.length !== 1 || + !sameJson(relocation.materialization.recipes[0], expectedRecipe) || + relocation.materialization.transforms.some((transform) => + transform.recipe !== HOMEBREW_BOTTLE_RELOCATION_RECIPE_ID + ) + ) { + throw new Error( + "Homebrew bottle plan recipe differs from the authenticated receipt", + ); + } + + return { + decoder: "tar-gzip-v1", + source, + materialization: relocation.materialization, + entries, + }; +} + +function homebrewSourceToGenericInventory( + tree: HomebrewDeferredTreeDescriptor, +): LazyTreeSourceInventory { + if (tree.inventory.source === undefined) { + throw new Error("Homebrew original bottle has no complete source inventory"); + } + return { + schema: 1, + kind: "archive-source-inventory-v1", + entries: tree.inventory.source.entries.map((entry) => ({ + sourcePath: entry.path, + type: entry.type, + mode: entry.mode, + size: entry.size, + ...(entry.target === undefined ? {} : { target: entry.target }), + })), + }; +} + +function homebrewEntryToGenericEntry( + entry: HomebrewDeferredTreeDescriptor["inventory"]["entries"][number], +): LazyTreeRegistrationEntry { + return { + vfsPath: `/${entry.path}`, + sourcePath: entry.source_path, + ...(entry.materialization === undefined + ? {} + : { + materialization: entry.materialization === "archive-homebrew-relocate" + ? "archive" as const + : entry.materialization, + }), + type: entry.type, + mode: entry.mode, + size: entry.size, + ...(entry.target === undefined + ? {} + : { target: entry.type === "hardlink" ? `/${entry.target}` : entry.target }), + ...(entry.inode_group === undefined ? {} : { inodeGroup: entry.inode_group }), + }; +} + +function parseHomebrewRelocation( + value: unknown, + source: LazyTreeSourceInventory, +): { + receiptSourcePath: string; + materialization: LazyTreeMaterializationPlan; +} | undefined { + if (value === undefined) return undefined; + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("Homebrew bottle relocation must be an object"); + } + const record = value as Record; + const keys = Object.keys(record).sort(); + const expected = ["kind", "materialization", "receipt_source_path", "schema"]; + if ( + keys.length !== expected.length || + keys.some((key, index) => key !== expected[index]) || + record.schema !== 1 || record.kind !== "homebrew-bottle-relocation-v1" + ) { + throw new Error("Homebrew bottle relocation has an unsupported identity"); + } + if (typeof record.receipt_source_path !== "string") { + throw new Error("Homebrew bottle relocation receipt source is invalid"); + } + return { + receiptSourcePath: record.receipt_source_path, + materialization: validateLazyTreeMaterializationPlan( + record.materialization, + source, + ), + }; +} + +function resolveHomebrewSourceHardlinks( + entries: readonly HomebrewDeferredTreeSourceEntry[], + byPath: ReadonlyMap, +): Map { + const canonicalByPath = new Map(); + for (const start of entries) { + if (start.type !== "hardlink" || canonicalByPath.has(start.path)) continue; + const chain: HomebrewDeferredTreeSourceEntry[] = []; + const seen = new Set(); + let current = start; + let canonical: HomebrewDeferredTreeSourceEntry | undefined; + while (current.type === "hardlink") { + canonical = canonicalByPath.get(current.path); + if (canonical !== undefined) break; + if (seen.has(current.path)) { + throw new Error(`Homebrew bottle source hardlink cycle includes ${current.path}`); + } + seen.add(current.path); + chain.push(current); + const target = byPath.get(current.target!); + if (target === undefined || (target.type !== "file" && target.type !== "hardlink")) { + throw new Error( + `Homebrew bottle source hardlink ${current.path} target is invalid`, + ); + } + current = target; + } + canonical ??= current; + for (const link of chain) canonicalByPath.set(link.path, canonical); + } + return canonicalByPath; +} + +function equalSets(left: ReadonlySet, right: ReadonlySet): boolean { + return left.size === right.size && [...left].every((value) => right.has(value)); +} + +function sameJson(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} diff --git a/host/src/homebrew-lazy-layer-descriptor.ts b/host/src/homebrew-lazy-layer-descriptor.ts index 1150c61569..91ff3355ce 100644 --- a/host/src/homebrew-lazy-layer-descriptor.ts +++ b/host/src/homebrew-lazy-layer-descriptor.ts @@ -1,5 +1,11 @@ /** Browser-safe schema types shared by the lazy-layer producer and consumer. */ +import type { LazyTreeMaterializationPlan } from "./vfs/materialization-plan"; +import { + assertUnicodeScalarText, + compareUnicodeScalarText, +} from "./vfs/canonical-text"; + /** Immutable package-release identity for the exact lower VFS output. */ export interface HomebrewLazyLayerBasePackageSource { schema: 1; @@ -118,7 +124,7 @@ export interface HomebrewDeferredTreeDescriptor { layer_entry_count: number; /** Legacy schema-4 directories which must already exist in the base. */ shared_base_directory_count?: number; - /** Schema-5 directories which may be created once or merged with a real directory. */ + /** Schema-5/6 directories which may be created once or merged with a real directory. */ mergeable_directory_count?: number; /** Decoder expansion bound (ZIP member bytes or complete TAR bytes). */ expanded_bytes: number; @@ -133,6 +139,13 @@ export interface HomebrewDeferredTreeDescriptor { kind: "homebrew-bottle-tar-gzip-v1"; entries: HomebrewDeferredTreeSourceEntry[]; }; + /** Authenticated policy retained until the adapter erases producer terms. */ + relocation?: { + schema: 1; + kind: "homebrew-bottle-relocation-v1"; + receipt_source_path: string; + materialization: LazyTreeMaterializationPlan; + }; entries: HomebrewLazyLayerEntry[]; }; } @@ -200,8 +213,8 @@ export interface HomebrewLazyLayerPackageRecord { } interface HomebrewLazyLayerDescriptorCommon { - /** Schema 4 is the exact legacy ZIP contract; schema 5 owns original bottles. */ - schema: 4 | 5; + /** Schema 4 is legacy ZIP; schema 5 is unrelocated TAR; schema 6 adds plans. */ + schema: 4 | 5 | 6; arch: "wasm32" | "wasm64"; mount_prefix: "/"; tap: { @@ -399,32 +412,9 @@ function sortJson(value: unknown): unknown { * canonical-json-v1 has one cross-host key order for non-BMP text. */ export function compareHomebrewCanonicalText(left: string, right: string): number { - assertHomebrewCanonicalText(left); - assertHomebrewCanonicalText(right); - let leftOffset = 0; - let rightOffset = 0; - while (leftOffset < left.length && rightOffset < right.length) { - const leftScalar = left.codePointAt(leftOffset)!; - const rightScalar = right.codePointAt(rightOffset)!; - if (leftScalar !== rightScalar) return leftScalar < rightScalar ? -1 : 1; - leftOffset += leftScalar > 0xffff ? 2 : 1; - rightOffset += rightScalar > 0xffff ? 2 : 1; - } - return leftOffset < left.length ? 1 : rightOffset < right.length ? -1 : 0; + return compareUnicodeScalarText(left, right); } export function assertHomebrewCanonicalText(value: string): void { - for (let index = 0; index < value.length; index += 1) { - const unit = value.charCodeAt(index); - if (unit < 0xd800 || unit > 0xdfff) continue; - if ( - unit <= 0xdbff && index + 1 < value.length && - value.charCodeAt(index + 1) >= 0xdc00 && - value.charCodeAt(index + 1) <= 0xdfff - ) { - index += 1; - continue; - } - throw new Error("canonical-json-v1 strings must contain only Unicode scalar values"); - } + assertUnicodeScalarText(value, "canonical-json-v1 strings"); } diff --git a/host/src/homebrew-lazy-layer.ts b/host/src/homebrew-lazy-layer.ts index 615f60a049..9e452acc55 100644 --- a/host/src/homebrew-lazy-layer.ts +++ b/host/src/homebrew-lazy-layer.ts @@ -38,10 +38,13 @@ import { compareHomebrewCanonicalText, } from "./homebrew-lazy-layer-descriptor"; import { + createHomebrewBottleRelocationRecipe, + deriveHomebrewBottleDestinationPrefix, + HOMEBREW_BOTTLE_RELOCATION_RECIPE_ID, + normalizeHomebrewBottleDestinationPrefix, parseHomebrewInstallReceiptRelocation, relocateHomebrewBottleFile, } from "./homebrew-bottle-relocation"; -import { KANDELO_HOMEBREW_GUEST_LAYOUT } from "./homebrew-guest-layout"; export type { HomebrewDeferredTreeDescriptor, HomebrewDeferredTreeDraftDescriptor, @@ -57,6 +60,10 @@ export type { HomebrewRuntimeLayerAssetIdentity, } from "./homebrew-lazy-layer-descriptor"; import { MemoryFileSystem } from "./vfs/memory-fs"; +import { + encodeMaterializationBytes, + type LazyTreeMaterializationPlan, +} from "./vfs/materialization-plan"; import { assertVfsDeferredTreeCollectionUsage, } from "./vfs/deferred-tree-limits"; @@ -78,7 +85,6 @@ const HOMEBREW_VFS_REPORT_ASSET = "kandelo-homebrew-vfs-report.json"; const HOMEBREW_NODE_EVIDENCE_ASSET = "kandelo-homebrew-node-evidence.json"; const HOMEBREW_BROWSER_EVIDENCE_ASSET = "kandelo-homebrew-browser-evidence.json"; const HOMEBREW_COMPOSITION_PATH = "/etc/kandelo/homebrew-vfs.json"; -const HOME_BREW_PREFIX = KANDELO_HOMEBREW_GUEST_LAYOUT.prefix; const ZIP_EPOCH = new Date(1980, 0, 1, 0, 0, 0); const S_IFMT = 0xf000; const S_IFREG = 0x8000; @@ -198,6 +204,7 @@ export async function buildHomebrewOriginalBottleCollection( } await authenticateHomebrewCompositionBase(options.baseFs); commonArch(plan); + const destinationPrefix = homebrewPlanDestinationPrefix(plan); const tapLock = planTapLock(plan); validatePackageTapOwnership(plan.packages, tapLock); if (plan.packages.length === 0) { @@ -261,6 +268,9 @@ export async function buildHomebrewOriginalBottleCollection( sourceEntries, relocationSourcePaths: relocation.sourcePaths, relocatedBytesByCanonicalSource: relocation.bytesByCanonicalSource, + ...(relocation.relocation === undefined + ? {} + : { relocation: relocation.relocation }), }); } @@ -284,7 +294,11 @@ export async function buildHomebrewOriginalBottleCollection( compatibilityPolicy: options.compatibilityPolicy, consumerState: "defer", }); - const finalEntries = collectLayerEntries(options.fs, options.baseFs); + const finalEntries = collectLayerEntries( + options.fs, + options.baseFs, + destinationPrefix, + ); const trees = createOriginalBottleTrees( bottles, finalEntries, @@ -384,6 +398,10 @@ async function buildSelectedHomebrewLazyPackageCollection( const base = parseBaseComposition(options.baseVfs, selectedPlan.kandeloAbi); const tapLock = planTapLock(selectedPlan); validatePackageTapOwnership(selectedPlan.packages, tapLock); + // The descriptor publishes base and deferred records together. Authenticate + // one namespace before partitioning so a base record cannot retain a + // different destination than the deferred collection validates below. + homebrewPlanDestinationPrefix(selectedPlan); const basePackages: HomebrewVfsPackagePlan[] = []; const layerPackages: HomebrewVfsPackagePlan[] = []; @@ -421,7 +439,7 @@ async function buildSelectedHomebrewLazyPackageCollection( id: bundleId, payloads: collection.payloads, descriptor: { - schema: 5, + schema: 6, kind: "kandelo-homebrew-deferred-layer-draft", arch, mount_prefix: "/", @@ -529,6 +547,12 @@ interface PreparedOriginalBottle { sourceEntries: HomebrewDeferredTreeSourceEntry[]; relocationSourcePaths: Set; relocatedBytesByCanonicalSource: Map; + relocation?: { + schema: 1; + kind: "homebrew-bottle-relocation-v1"; + receipt_source_path: string; + materialization: LazyTreeMaterializationPlan; + }; } interface OriginalBottleTree { @@ -799,6 +823,9 @@ function createOriginalBottleTrees( kind: "homebrew-bottle-tar-gzip-v1", entries: sourceEntries, }, + ...(bottle.relocation === undefined + ? {} + : { relocation: bottle.relocation }), entries, }, }, @@ -962,6 +989,7 @@ function prepareOriginalBottleRelocation( ): { sourcePaths: Set; bytesByCanonicalSource: Map; + relocation?: PreparedOriginalBottle["relocation"]; } { const installReceipts = pkg.linkManifest.receipts.filter( (receipt) => receipt === "INSTALL_RECEIPT.json" || @@ -990,6 +1018,16 @@ function prepareOriginalBottleRelocation( ); } const receiptFile = resolveTarRegularSource(receiptSource, sourceByPath, pkg); + const destinationPrefix = deriveHomebrewBottleDestinationPrefix( + receiptGuestPath, + receiptSource.path, + ); + if (destinationPrefix !== pkg.prefix) { + throw new Error( + `Homebrew deferred bottle ${pkg.fullName} receipt prefix ` + + `${destinationPrefix} differs from package prefix ${pkg.prefix}`, + ); + } let receipt: ReturnType; try { receipt = parseHomebrewInstallReceiptRelocation(receiptFile.data); @@ -1013,7 +1051,10 @@ function prepareOriginalBottleRelocation( sourcePaths.add(source.path); const canonical = resolveTarRegularSource(source, sourceByPath, pkg); try { - const relocated = relocateHomebrewBottleFile(canonical.data, receipt, guestPath); + const relocated = relocateHomebrewBottleFile(canonical.data, receipt, { + destinationPrefix, + path: guestPath, + }); const prior = bytesByCanonicalSource.get(canonical.path); if (prior !== undefined && !bytesEqual(prior, relocated)) { throw new Error("hard-link aliases produce different relocated bytes"); @@ -1025,7 +1066,58 @@ function prepareOriginalBottleRelocation( ); } } - return { sourcePaths, bytesByCanonicalSource }; + const transforms = Array.from( + bytesByCanonicalSource, + ([sourcePath, output]) => { + const source = sourceByPath.get(sourcePath); + if (source?.type !== "file") { + throw new Error( + `Homebrew deferred bottle ${pkg.fullName} has no canonical source ` + + sourcePath, + ); + } + return { + sourcePath, + recipe: HOMEBREW_BOTTLE_RELOCATION_RECIPE_ID, + input: { + sha256: digest(source.data), + bytes: source.data.byteLength, + }, + output: { + sha256: digest(output), + bytes: output.byteLength, + }, + }; + }, + ).sort((left, right) => compareHomebrewCanonicalText( + left.sourcePath, + right.sourcePath, + )); + const materialization: LazyTreeMaterializationPlan = { + schema: 1, + kind: "archive-byte-transforms-v1", + assertions: [{ + sourcePath: receiptFile.path, + bytesHex: encodeMaterializationBytes(receiptFile.data), + }], + recipes: transforms.length === 0 + ? [] + : [createHomebrewBottleRelocationRecipe(receipt, { + destinationPrefix, + path: receiptSource.path, + })], + transforms, + }; + return { + sourcePaths, + bytesByCanonicalSource, + relocation: { + schema: 1, + kind: "homebrew-bottle-relocation-v1", + receipt_source_path: receiptSource.path, + materialization, + }, + }; } function resolveTarRegularSource( @@ -1296,7 +1388,7 @@ export function closeHomebrewLazyLayerDescriptor( evidence: HomebrewLazyLayerClosureEvidence, ): HomebrewLazyLayerDescriptor { if ( - (draft.schema !== 4 && draft.schema !== 5) || + (draft.schema !== 4 && draft.schema !== 5 && draft.schema !== 6) || draft.kind !== "kandelo-homebrew-deferred-layer-draft" ) { throw new Error("Homebrew lazy layer draft has an unsupported identity"); @@ -1480,7 +1572,7 @@ function assertLazyLayerDraftSchemaShape( `Homebrew lazy layer draft tree ${tree.id} has invalid activation roots`, ); } - const originalBottle = draft.schema === 5; + const originalBottle = draft.schema !== 4; const hasPackage = tree.package !== undefined; const hasSource = tree.inventory.source !== undefined; const hasCompleteMaterialization = tree.inventory.entries.every( @@ -1489,6 +1581,12 @@ function assertLazyLayerDraftSchemaShape( const hasAnyMaterialization = tree.inventory.entries.some( (entry) => entry.materialization !== undefined, ); + if (draft.schema !== 6 && tree.inventory.relocation !== undefined) { + throw new Error( + `Homebrew lazy layer schema ${draft.schema} tree ${tree.id} ` + + "cannot carry a schema-6 relocation plan", + ); + } if (originalBottle) { if ( !hasPackage || !hasSource || !hasCompleteMaterialization || @@ -1497,7 +1595,8 @@ function assertLazyLayerDraftSchemaShape( "application/vnd.oci.image.layer.v1.tar+gzip" ) { throw new Error( - `Homebrew lazy layer schema 5 tree ${tree.id} is not a complete original bottle`, + `Homebrew lazy layer schema ${draft.schema} tree ${tree.id} ` + + "is not a complete original bottle", ); } } else if ( @@ -1577,6 +1676,16 @@ function commonArch(plan: HomebrewVfsPlan): "wasm32" | "wasm64" { return arch; } +function homebrewPlanDestinationPrefix(plan: HomebrewVfsPlan): string { + const prefixes = new Set(plan.packages.map((pkg) => + normalizeHomebrewBottleDestinationPrefix(pkg.prefix) + )); + if (prefixes.size !== 1) { + throw new Error("Homebrew lazy layer plan has inconsistent bottle destinations"); + } + return prefixes.values().next().value!; +} + function planTapLock(plan: HomebrewVfsPlan): HomebrewVfsTapIdentity[] { const federated = plan as Partial; const candidates = Array.isArray(federated.taps) @@ -1868,12 +1977,13 @@ function packageArtifactIdentity(value: unknown): Record { function collectLayerEntries( layerFs: MemoryFileSystem, baseFs: MemoryFileSystem, + destinationPrefix: string, ): HomebrewLazyLayerEntry[] { - if (!pathExists(layerFs, HOME_BREW_PREFIX)) { + if (!pathExists(layerFs, destinationPrefix)) { throw new Error("Homebrew lazy layer is missing its poured prefix"); } const entries: HomebrewLazyLayerEntry[] = []; - collectPath(layerFs, HOME_BREW_PREFIX, entries, new Map()); + collectPath(layerFs, destinationPrefix, entries, new Map()); entries.sort((left, right) => compareHomebrewCanonicalText(left.path, right.path)); for (const entry of entries) { const basePath = `/${entry.path}`; diff --git a/host/src/homebrew-runtime-layer-consumer.ts b/host/src/homebrew-runtime-layer-consumer.ts index f9eed86a84..62529e9414 100644 --- a/host/src/homebrew-runtime-layer-consumer.ts +++ b/host/src/homebrew-runtime-layer-consumer.ts @@ -16,16 +16,19 @@ import { type LazyTreeGroup, type LazyTreeRegistrationEntry, } from "./vfs/memory-fs"; +import { + adaptHomebrewDeferredTree, + type AdaptedHomebrewDeferredTree, +} from "./homebrew-deferred-tree-adapter"; import type { VfsDeferredTreeUsage } from "./vfs/deferred-tree-limits"; import { resolveHardlinkGraph } from "./vfs/hardlink-graph"; import { HOMEBREW_RUNTIME_LAYER_LIMITS, isHomebrewRuntimeLayerId, } from "./homebrew-runtime-layer-limits"; -import { KANDELO_HOMEBREW_GUEST_LAYOUT } from "./homebrew-guest-layout"; +import { normalizeHomebrewBottleDestinationPrefix } from "./homebrew-bottle-relocation"; export { HOMEBREW_RUNTIME_LAYER_LIMITS } from "./homebrew-runtime-layer-limits"; -const HOMEBREW_PREFIX = KANDELO_HOMEBREW_GUEST_LAYOUT.prefix; const COMPOSITION_PATH = "/etc/kandelo/homebrew-vfs.json"; const ACCEPTANCE_ASSET = "kandelo-homebrew.vfs.zst"; const ACCEPTANCE_DESCRIPTOR_ASSET = "kandelo-homebrew-vfs.json"; @@ -93,12 +96,13 @@ interface LoadedLayer { interface PlannedTree { descriptor: HomebrewDeferredTreeDescriptor; + adapted: AdaptedHomebrewDeferredTree; entries: LazyTreeRegistrationEntry[]; } interface HomebrewDeferredTreeCollectionPlan { id: string; - schema: 4 | 5; + schema: 4 | 5 | 6; mountPrefix: string; trees: readonly HomebrewDeferredTreeDescriptor[]; } @@ -112,8 +116,8 @@ export interface RegisterHomebrewDeferredTreeCollectionOptions { fs: MemoryFileSystem; /** Human-readable identity used in collision diagnostics. */ id: string; - /** Schema 5 admits absent/equal mergeable directories. */ - schema: 4 | 5; + /** Schemas 5 and 6 admit absent/equal mergeable directories. */ + schema: 4 | 5 | 6; mountPrefix?: string; /** Producer-verified closed trees with concrete immutable transport URLs. */ trees: readonly HomebrewDeferredTreeDescriptor[]; @@ -493,7 +497,7 @@ function deferredTreeUsage( return usage; } -/** Parse a closed legacy schema-4 or original-bottle schema-5 descriptor. */ +/** Parse a closed legacy ZIP or original-bottle descriptor. */ export function parseHomebrewRuntimeLayerDescriptor( value: unknown, ): HomebrewLazyLayerDescriptor { @@ -516,7 +520,7 @@ export function parseHomebrewRuntimeLayerDescriptor( "deferred_trees", ], "Homebrew runtime layer descriptor"); if ( - (root.schema !== 4 && root.schema !== 5) || + (root.schema !== 4 && root.schema !== 5 && root.schema !== 6) || root.kind !== "kandelo-homebrew-deferred-layer" ) { throw new Error("Homebrew runtime layer descriptor has an unsupported identity"); @@ -703,6 +707,10 @@ export function parseHomebrewRuntimeLayerDescriptor( ) { throw new Error("Homebrew runtime layer package records differ from selection order"); } + const destinationPrefix = validateLayerDestinationPrefix([ + ...basePackages, + ...layerPackages, + ]); const baseVfs = validateBaseVfs(root.base_vfs, arch, kandeloAbi, baseOrder); @@ -872,13 +880,14 @@ export function parseHomebrewRuntimeLayerDescriptor( releaseRoot, bundledTrees, root.schema, + destinationPrefix, ); const entries = deferredTrees.flatMap((tree) => tree.inventory.entries); if (new Set(entries.map((entry) => entry.path)).size !== entries.length) { throw new Error("Homebrew runtime layer deferred trees duplicate a VFS path"); } - if (root.schema === 5) { - validateCompleteDirectBottleDirectories(entries); + if (root.schema !== 4) { + validateCompleteDirectBottleDirectories(entries, destinationPrefix); } validateDirectBottleBindings(layerPackages, deferredTrees); validateLayerPackageEntries(layerPackages, entries); @@ -889,9 +898,10 @@ export function parseHomebrewRuntimeLayerDescriptor( function validateCompleteDirectBottleDirectories( entries: readonly HomebrewLazyLayerEntry[], + destinationPrefix: string, ): void { const byPath = new Map(entries.map((entry) => [entry.path, entry])); - const prefix = HOMEBREW_PREFIX.slice(1); + const prefix = destinationPrefix.slice(1); const prefixDepth = prefix.split("/").length; for (const entry of entries) { const components = entry.path.split("/"); @@ -906,69 +916,16 @@ function validateCompleteDirectBottleDirectories( } } -function validateStandaloneDirectBottleBinding( - tree: HomebrewDeferredTreeDescriptor, - formula: string, -): void { - const expectedKegPrefix = `${HOMEBREW_PREFIX}/Cellar/${formula}/`; - if ( - tree.activation.roots.length !== 1 || - !tree.activation.roots[0]!.startsWith(expectedKegPrefix) || - tree.activation.roots[0]!.slice(expectedKegPrefix.length).includes("/") - ) { - throw new Error( - `Homebrew original-bottle ${formula} activation does not name its exact keg`, - ); - } - const kegPath = tree.activation.roots[0]!.slice(1); - const version = tree.activation.roots[0]!.slice(expectedKegPrefix.length); - const optPath = `${HOMEBREW_PREFIX}/opt/${formula}`.slice(1); - const entries = tree.inventory.entries; - const keg = entries.find((entry) => entry.path === kegPath); - const opt = entries.find((entry) => entry.path === optPath); - if ( - keg?.type !== "directory" || - keg.ownership !== "layer" || - opt?.type !== "symlink" || - opt.ownership !== "layer" || - opt.target !== `../Cellar/${formula}/${version}` - ) { - throw new Error( - `Homebrew original-bottle ${formula} does not own its keg and opt link`, - ); - } - for (const entry of entries) { - if (entry.type === "directory") { - const expectedOwnership = - entry.path === kegPath || entry.path.startsWith(`${kegPath}/`) - ? "layer" - : "mergeable-directory"; - if (entry.ownership !== expectedOwnership) { - throw new Error( - `Homebrew original-bottle ${formula} directory /${entry.path} ` + - `must have ${expectedOwnership} ownership`, - ); - } - } - if ( - (entry.materialization === "archive" || - entry.materialization === "archive-homebrew-relocate") && - entry.path !== kegPath && - !entry.path.startsWith(`${kegPath}/`) - ) { - throw new Error( - `Homebrew original-bottle ${formula} maps an archive member outside its keg`, - ); - } - if (entry.materialization === "archive" && entry.type === "symlink") { - validateArchiveSymlinkTarget( - entry.path, - entry.target!, - kegPath, - tree.package!, - ); - } +function validateLayerDestinationPrefix( + packages: readonly HomebrewLazyLayerPackageRecord[], +): string { + const prefixes = new Set(packages.map((pkg) => + normalizeHomebrewBottleDestinationPrefix(pkg.prefix) + )); + if (prefixes.size !== 1) { + throw new Error("Homebrew runtime layer package destinations are inconsistent"); } + return prefixes.values().next().value!; } function validateDirectBottleBindings( @@ -1036,6 +993,15 @@ function validateDirectBottleBindings( `Homebrew runtime layer bottle ${fullName} maps an archive member outside its keg`, ); } + if ( + entry.materialization === "archive-homebrew-relocate" && + entry.path !== `${pkg.prefix.slice(1)}/Cellar/${entry.source_path}` + ) { + throw new Error( + `Homebrew runtime layer bottle ${fullName} receipt-relocated destination ` + + `${entry.path} differs from its authenticated prefix`, + ); + } if (entry.materialization === "archive" && entry.type === "symlink") { validateArchiveSymlinkTarget(entry.path, entry.target!, kegPath, fullName); } @@ -1370,13 +1336,17 @@ function preflightDeferredTreeCollections( const directories: HomebrewLazyLayerEntry[] = []; const trees: PlannedTree[] = []; for (const tree of collection.trees) { + const adapted = adaptHomebrewDeferredTree(tree); + const adaptedByPath = new Map( + adapted.entries.map((entry) => [entry.vfsPath, entry]), + ); const registrationEntries: LazyTreeRegistrationEntry[] = []; for (const entry of tree.inventory.entries) { const vfsPath = `/${entry.path}`; const base = lstatOrNull(fs, vfsPath); if (entry.ownership === "mergeable-directory") { if ( - collection.schema !== 5 || + collection.schema === 4 || base !== null && (base.mode & S_IFMT) !== S_IFDIR ) { throw new Error( @@ -1439,22 +1409,9 @@ function preflightDeferredTreeCollections( ) { directories.push(entry); } - registrationEntries.push({ - vfsPath, - sourcePath: entry.source_path, - ...(entry.materialization === undefined - ? {} - : { materialization: entry.materialization }), - type: entry.type, - mode: entry.mode, - size: entry.size, - ...(entry.target === undefined - ? {} - : { target: entry.type === "hardlink" ? `/${entry.target}` : entry.target }), - ...(entry.inode_group === undefined ? {} : { inodeGroup: entry.inode_group }), - }); + registrationEntries.push(adaptedByPath.get(vfsPath)!); } - trees.push({ descriptor: tree, entries: registrationEntries }); + trees.push({ descriptor: tree, adapted, entries: registrationEntries }); } directories.sort((left, right) => pathDepth(left.path) - pathDepth(right.path) || @@ -1545,26 +1502,17 @@ function registerPlannedDeferredTreeWithHandle( function plannedDeferredTreeContent(tree: PlannedTree) { return { - decoder: tree.descriptor.content.decoder, + decoder: tree.adapted.decoder, mediaType: tree.descriptor.content.media_type, sha256: tree.descriptor.content.sha256, bytes: tree.descriptor.content.bytes, expandedBytes: tree.descriptor.inventory.expanded_bytes, sourceEntryCount: tree.descriptor.inventory.source_entry_count, transports: tree.descriptor.transports.map((transport) => transport.url), - ...(tree.descriptor.inventory.source === undefined ? {} : { - source: { - schema: 1 as const, - kind: "homebrew-bottle-tar-gzip-v1" as const, - entries: tree.descriptor.inventory.source.entries.map((entry) => ({ - sourcePath: entry.path, - type: entry.type, - mode: entry.mode, - size: entry.size, - ...(entry.target === undefined ? {} : { target: entry.target }), - })), - }, - }), + ...(tree.adapted.source === undefined ? {} : { source: tree.adapted.source }), + ...(tree.adapted.materialization === undefined + ? {} + : { materialization: tree.adapted.materialization }), }; } @@ -1760,18 +1708,18 @@ function validatePackageRecord( requireInteger(record.bytes, `${label} bytes`, 1, 2 * 1024 * 1024 * 1024); requireSha256(record.cache_key_sha, `${label} cache key`); requireSafeRelativePath(record.link_manifest, `${label} link manifest`); - if (record.prefix !== HOMEBREW_PREFIX) { - throw new Error(`${label} prefix is unsupported`); - } + const destinationPrefix = normalizeHomebrewBottleDestinationPrefix( + requireString(record.prefix, `${label} prefix`, 4096), + ); const keg = requireCanonicalAbsolutePath(record.keg, `${label} keg`, 4096); - const kegRoot = `${HOMEBREW_PREFIX}/Cellar/${name}/`; + const kegRoot = `${destinationPrefix}/Cellar/${name}/`; if (!keg.startsWith(kegRoot) || keg.slice(kegRoot.length).includes("/")) { throw new Error(`${label} keg escapes its package Cellar path`); } const opt = exactRecord(record.opt_link, ["path", "target"], `${label} opt link`); if ( opt.path !== `opt/${name}` || - opt.target !== `../${keg.slice(HOMEBREW_PREFIX.length + 1)}` + opt.target !== `../${keg.slice(destinationPrefix.length + 1)}` ) { throw new Error(`${label} opt link is inconsistent`); } @@ -1823,8 +1771,8 @@ function validateDeferredTrees( sha256: string; bytes: number; }>, - descriptorSchema: 4 | 5, - transportPolicy: "bundle-release" | "external-only" = "bundle-release", + descriptorSchema: 4 | 5 | 6, + destinationPrefix: string, ): HomebrewDeferredTreeDescriptor[] { const values = requireArray( value, @@ -1836,7 +1784,7 @@ function validateDeferredTrees( const digests = new Set(); const urls = new Set(); const trees = values.map((item, index) => { - const directBottle = descriptorSchema === 5; + const directBottle = descriptorSchema !== 4; const tree = exactRecord( item, [ @@ -2012,6 +1960,13 @@ function validateDeferredTrees( `Homebrew runtime layer deferred tree ${id} inventory`, ); const hasSource = initialInventory.source !== undefined; + const hasRelocation = initialInventory.relocation !== undefined; + if (descriptorSchema !== 6 && hasRelocation) { + throw new Error( + `Homebrew runtime layer schema ${descriptorSchema} tree ${id} ` + + "cannot carry a schema-6 relocation plan", + ); + } if (directBottle !== hasSource) { throw new Error( `Homebrew runtime layer schema ${descriptorSchema} tree ${id} has incompatible bottle metadata`, @@ -2031,6 +1986,7 @@ function validateDeferredTrees( "expanded_bytes", "payload_bytes", ...(hasSource ? ["source"] : []), + ...(hasRelocation ? ["relocation"] : []), "entries", ], `Homebrew runtime layer deferred tree ${id} inventory`, @@ -2043,6 +1999,7 @@ function validateDeferredTrees( inventory, content.decoder, source, + destinationPrefix, ); for (const root of roots) { const relative = root.slice(1); @@ -2053,7 +2010,11 @@ function validateDeferredTrees( } } void packageName; - return tree as unknown as HomebrewDeferredTreeDescriptor; + const validated = tree as unknown as HomebrewDeferredTreeDescriptor; + // WHY: Homebrew markers are erased only after the authenticated receipt, + // prefix, source inventory, and generic plan agree in full. + adaptHomebrewDeferredTree(validated); + return validated; }); if ( !arraysEqual( @@ -2197,10 +2158,11 @@ function validateEntries( value: unknown, inventory: Record, decoder: unknown, - sourceInventory?: { + sourceInventory: { entries: HomebrewDeferredTreeSourceEntry[]; canonicalByPath: Map; - }, + } | undefined, + destinationPrefix: string, ): HomebrewLazyLayerEntry[] { const values = requireArray( value, @@ -2284,10 +2246,8 @@ function validateEntries( ) { throw new Error(`Homebrew runtime layer entry ${index} materialization is invalid`); } - if ( - path !== HOMEBREW_PREFIX.slice(1) && - !path.startsWith(`${HOMEBREW_PREFIX.slice(1)}/`) - ) { + const prefix = destinationPrefix.slice(1); + if (path !== prefix && !path.startsWith(`${prefix}/`)) { throw new Error(`Homebrew runtime layer entry ${index} escapes the Homebrew prefix`); } if (paths.has(path)) { @@ -2525,7 +2485,7 @@ function validateLayerPackageEntries( ); } - const optPath = `${HOMEBREW_PREFIX.slice(1)}/${pkg.opt_link.path}`; + const optPath = `${pkg.prefix.slice(1)}/${pkg.opt_link.path}`; const opt = entriesByPath.get(optPath); if ( opt === undefined || diff --git a/host/src/homebrew-vfs-composer.ts b/host/src/homebrew-vfs-composer.ts index 53f88adf53..2f23cc79a8 100644 --- a/host/src/homebrew-vfs-composer.ts +++ b/host/src/homebrew-vfs-composer.ts @@ -204,7 +204,7 @@ export async function buildHomebrewMaterializedVfs( const registered = registerHomebrewDeferredTreeCollection({ fs: options.fs, id: "main-shell", - schema: 5, + schema: 6, trees: closedTrees, }); const registeredByPackage = bindRegisteredTrees(registered, bindings); @@ -334,7 +334,7 @@ export async function buildHomebrewMaterializedVfs( registerHomebrewDeferredTreeCollection({ fs: options.fs, id: runtimeSupport.contract.id, - schema: 5, + schema: 6, trees: supportTrees, atomicActivationGroup: runtimeSupport.contract.activation.atomicGroup, diff --git a/host/src/homebrew-vfs-materializer.ts b/host/src/homebrew-vfs-materializer.ts index f09ad36114..8e71921531 100644 --- a/host/src/homebrew-vfs-materializer.ts +++ b/host/src/homebrew-vfs-materializer.ts @@ -791,7 +791,10 @@ function relocateBottlePlaceholders( } let relocated: Uint8Array; try { - relocated = relocateHomebrewBottleFile(readVfsFile(fs, path), relocation, path); + relocated = relocateHomebrewBottleFile(readVfsFile(fs, path), relocation, { + destinationPrefix: pkg.prefix, + path, + }); } catch (error) { fail(pkg, errorMessage(error)); } diff --git a/host/src/vfs/canonical-text.ts b/host/src/vfs/canonical-text.ts new file mode 100644 index 0000000000..f00d05a7e8 --- /dev/null +++ b/host/src/vfs/canonical-text.ts @@ -0,0 +1,44 @@ +/** Reject JavaScript strings that cannot be represented as Unicode scalars. */ +export function assertUnicodeScalarText( + value: string, + label = "Canonical text", +): void { + for (let index = 0; index < value.length; index += 1) { + const unit = value.charCodeAt(index); + if (unit < 0xd800 || unit > 0xdfff) continue; + if ( + unit <= 0xdbff && index + 1 < value.length && + value.charCodeAt(index + 1) >= 0xdc00 && + value.charCodeAt(index + 1) <= 0xdfff + ) { + index += 1; + continue; + } + throw new Error(`${label} must contain only Unicode scalar values`); + } +} + +/** + * Compare strings by Unicode scalar value, matching Python's string order. + * + * JavaScript relational operators compare UTF-16 code units, which reverse + * U+E000 and U+10000. Wire producers use scalar ordering so every host must do + * the same before accepting or generating canonical metadata. + */ +export function compareUnicodeScalarText( + left: string, + right: string, +): number { + assertUnicodeScalarText(left); + assertUnicodeScalarText(right); + let leftOffset = 0; + let rightOffset = 0; + while (leftOffset < left.length && rightOffset < right.length) { + const leftScalar = left.codePointAt(leftOffset)!; + const rightScalar = right.codePointAt(rightOffset)!; + if (leftScalar !== rightScalar) return leftScalar < rightScalar ? -1 : 1; + leftOffset += leftScalar > 0xffff ? 2 : 1; + rightOffset += rightScalar > 0xffff ? 2 : 1; + } + return leftOffset < left.length ? 1 : rightOffset < right.length ? -1 : 0; +} diff --git a/host/src/vfs/deferred-tree-limits.ts b/host/src/vfs/deferred-tree-limits.ts index 2fc954ec67..2f8013c1fa 100644 --- a/host/src/vfs/deferred-tree-limits.ts +++ b/host/src/vfs/deferred-tree-limits.ts @@ -11,6 +11,13 @@ export const VFS_DEFERRED_TREE_LIMITS = { maxActivationCapabilities: 32, maxActivationRoots: 64, maxActivationCapabilityBytes: 255, + maxMaterializationAssertions: 32, + maxMaterializationAssertionBytes: 1024 * 1024, + maxMaterializationRecipes: 32, + maxMaterializationTransforms: 100_000, + maxMaterializationDecodedBytes: 8 * 1024 * 1024, + maxTransformReplacements: 32, + maxTransformPatternBytes: 8192, } as const; /** diff --git a/host/src/vfs/index.ts b/host/src/vfs/index.ts index f621efbed9..360e304890 100644 --- a/host/src/vfs/index.ts +++ b/host/src/vfs/index.ts @@ -9,6 +9,22 @@ export { VFS_DEFERRED_TREE_LIMITS, } from "./deferred-tree-limits"; export type { VfsDeferredTreeUsage } from "./deferred-tree-limits"; +export { + applyLazyTreeByteTransformRecipe, + decodeMaterializationBytes, + encodeMaterializationBytes, + validateLazyTreeMaterializationPlan, +} from "./materialization-plan"; +export type { + LazyTreeByteIdentity, + LazyTreeByteReplacement, + LazyTreeByteTransform, + LazyTreeByteTransformRecipe, + LazyTreeMaterializationPlan, + LazyTreeMaterializationSourceEntry, + LazyTreeMaterializationSourceInventory, + LazyTreeSourceAssertion, +} from "./materialization-plan"; export { createClosedLazyAssetFetcher, loadClosedLazyAssetSources, diff --git a/host/src/vfs/materialization-plan.ts b/host/src/vfs/materialization-plan.ts new file mode 100644 index 0000000000..7e63efe474 --- /dev/null +++ b/host/src/vfs/materialization-plan.ts @@ -0,0 +1,577 @@ +/** + * Closed byte transformations for one authenticated archive tree. + * + * Producers own path and packaging policy. This module only validates exact + * source identities, applies bounded byte replacements, and leaves callers to + * verify the declared output identity before publication. + */ + +import { VFS_DEFERRED_TREE_LIMITS } from "./deferred-tree-limits"; +import { + assertUnicodeScalarText, + compareUnicodeScalarText, +} from "./canonical-text"; + +export interface LazyTreeMaterializationSourceEntry { + sourcePath: string; + type: "directory" | "file" | "symlink" | "hardlink"; + size: number; +} + +export interface LazyTreeMaterializationSourceInventory { + entries: readonly LazyTreeMaterializationSourceEntry[]; +} + +export interface LazyTreeByteIdentity { + sha256: string; + bytes: number; +} + +export interface LazyTreeSourceAssertion { + sourcePath: string; + bytesHex: string; +} + +export interface LazyTreeByteReplacement { + matchHex: string; + replacementHex: string; +} + +export interface LazyTreeByteTransformRecipe { + id: string; + replacements: readonly LazyTreeByteReplacement[]; + rejectHex: readonly string[]; +} + +export interface LazyTreeByteTransform { + sourcePath: string; + recipe: string; + input: LazyTreeByteIdentity; + output: LazyTreeByteIdentity; +} + +export interface LazyTreeMaterializationPlan { + schema: 1; + kind: "archive-byte-transforms-v1"; + assertions: readonly LazyTreeSourceAssertion[]; + recipes: readonly LazyTreeByteTransformRecipe[]; + transforms: readonly LazyTreeByteTransform[]; +} + +/** Parse and bound the generic materialization-plan wire contract. */ +export function validateLazyTreeMaterializationPlan( + value: unknown, + inventory: LazyTreeMaterializationSourceInventory, +): LazyTreeMaterializationPlan { + if (inventory === undefined) { + throw new Error("Lazy tree materialization plan requires complete source truth"); + } + if ( + !Array.isArray(inventory.entries) || + inventory.entries.length > VFS_DEFERRED_TREE_LIMITS.maxEntries + ) { + throw new Error("Lazy tree materialization source inventory is unbounded"); + } + const sourceByPath = new Map(); + for (const [index, entry] of inventory.entries.entries()) { + const sourcePath = requireCanonicalSourcePath( + entry.sourcePath, + `Lazy tree materialization source ${index} path`, + ); + if (sourceByPath.has(sourcePath)) { + throw new Error(`Lazy tree materialization source repeats ${sourcePath}`); + } + if ( + entry.type !== "directory" && entry.type !== "file" && + entry.type !== "symlink" && entry.type !== "hardlink" + ) { + throw new Error(`Lazy tree materialization source ${sourcePath} has invalid type`); + } + requireInteger( + entry.size, + `Lazy tree materialization source ${sourcePath} byte count`, + 0, + VFS_DEFERRED_TREE_LIMITS.maxPayloadBytes, + ); + sourceByPath.set(sourcePath, entry); + } + + const record = exactRecord( + value, + ["schema", "kind", "assertions", "recipes", "transforms"], + "Lazy tree materialization plan", + ); + if (record.schema !== 1 || record.kind !== "archive-byte-transforms-v1") { + throw new Error("Lazy tree materialization plan has an unsupported identity"); + } + + let decodedPlanBytes = 0; + const assertionPaths = new Set(); + const assertions = requireArray( + record.assertions, + "Lazy tree materialization assertions", + 0, + VFS_DEFERRED_TREE_LIMITS.maxMaterializationAssertions, + ).map((value, index): LazyTreeSourceAssertion => { + const assertion = exactRecord( + value, + ["sourcePath", "bytesHex"], + `Lazy tree materialization assertion ${index}`, + ); + const sourcePath = requireCanonicalSourcePath( + assertion.sourcePath, + `Lazy tree materialization assertion ${index} source path`, + ); + if (assertionPaths.has(sourcePath)) { + throw new Error(`Lazy tree materialization repeats assertion ${sourcePath}`); + } + assertionPaths.add(sourcePath); + const source = sourceByPath.get(sourcePath); + if (source?.type !== "file") { + throw new Error( + `Lazy tree materialization assertion ${sourcePath} is not a regular source`, + ); + } + const bytesHex = requireCanonicalHex( + assertion.bytesHex, + `Lazy tree materialization assertion ${sourcePath} bytes`, + VFS_DEFERRED_TREE_LIMITS.maxMaterializationAssertionBytes, + true, + ); + decodedPlanBytes = addDecodedPlanBytes( + decodedPlanBytes, + bytesHex.length / 2, + ); + if (bytesHex.length / 2 !== source.size) { + throw new Error( + `Lazy tree materialization assertion ${sourcePath} size differs from source`, + ); + } + return { sourcePath, bytesHex }; + }); + + const recipesById = new Map(); + const recipes = requireArray( + record.recipes, + "Lazy tree materialization recipes", + 0, + VFS_DEFERRED_TREE_LIMITS.maxMaterializationRecipes, + ).map((value, index): LazyTreeByteTransformRecipe => { + const validated = validateRecipe( + value, + `Lazy tree materialization recipe ${index}`, + ); + if (recipesById.has(validated.recipe.id)) { + throw new Error(`Lazy tree materialization duplicates recipe ${validated.recipe.id}`); + } + decodedPlanBytes = addDecodedPlanBytes( + decodedPlanBytes, + validated.decodedBytes, + ); + recipesById.set(validated.recipe.id, validated.recipe); + return validated.recipe; + }); + + const transformPaths = new Set(); + const usedRecipes = new Set(); + const transforms = requireArray( + record.transforms, + "Lazy tree materialization transforms", + 0, + VFS_DEFERRED_TREE_LIMITS.maxMaterializationTransforms, + ).map((value, index): LazyTreeByteTransform => { + const transform = exactRecord( + value, + ["sourcePath", "recipe", "input", "output"], + `Lazy tree materialization transform ${index}`, + ); + const sourcePath = requireCanonicalSourcePath( + transform.sourcePath, + `Lazy tree materialization transform ${index} source path`, + ); + if (transformPaths.has(sourcePath)) { + throw new Error(`Lazy tree materialization repeats transform ${sourcePath}`); + } + transformPaths.add(sourcePath); + const source = sourceByPath.get(sourcePath); + if (source?.type !== "file") { + throw new Error( + `Lazy tree materialization transform ${sourcePath} is not a regular source`, + ); + } + const recipe = requireString( + transform.recipe, + `Lazy tree materialization transform ${sourcePath} recipe`, + VFS_DEFERRED_TREE_LIMITS.maxStringBytes, + ); + if (!recipesById.has(recipe)) { + throw new Error( + `Lazy tree materialization transform ${sourcePath} has no recipe ${recipe}`, + ); + } + usedRecipes.add(recipe); + const input = validateByteIdentity( + transform.input, + `Lazy tree materialization transform ${sourcePath} input`, + ); + const output = validateByteIdentity( + transform.output, + `Lazy tree materialization transform ${sourcePath} output`, + ); + if (input.bytes !== source.size) { + throw new Error( + `Lazy tree materialization transform ${sourcePath} input size differs from source`, + ); + } + return { sourcePath, recipe, input, output }; + }); + + if (assertions.length === 0 && transforms.length === 0) { + throw new Error("Lazy tree materialization plan has no assertions or transforms"); + } + if (recipes.some((recipe) => !usedRecipes.has(recipe.id))) { + throw new Error("Lazy tree materialization plan contains an unused recipe"); + } + if ( + !isCanonical(assertions.map((assertion) => assertion.sourcePath)) || + !isCanonical(recipes.map((recipe) => recipe.id)) || + !isCanonical(transforms.map((transform) => transform.sourcePath)) + ) { + throw new Error("Lazy tree materialization plan is not in canonical order"); + } + + return { + schema: 1, + kind: "archive-byte-transforms-v1", + assertions, + recipes, + transforms, + }; +} + +/** Encode arbitrary bytes in the canonical lowercase wire form. */ +export function encodeMaterializationBytes(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +/** Decode a canonical bounded wire byte string. */ +export function decodeMaterializationBytes(hex: string): Uint8Array { + const canonical = requireCanonicalHex( + hex, + "Materialization bytes", + VFS_DEFERRED_TREE_LIMITS.maxMaterializationDecodedBytes, + true, + ); + const bytes = new Uint8Array(canonical.length / 2); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(canonical.slice(index * 2, index * 2 + 2), 16); + } + return bytes; +} + +/** Apply one closed recipe under the global deferred-tree byte limit. */ +export function applyLazyTreeByteTransformRecipe( + source: Uint8Array, + recipe: LazyTreeByteTransformRecipe, +): Uint8Array { + if (source.byteLength > VFS_DEFERRED_TREE_LIMITS.maxPayloadBytes) { + throw new Error("Lazy tree byte transform exceeds its source-byte limit"); + } + const validated = validateRecipe(recipe, "Lazy tree byte transform recipe").recipe; + let transformed = source; + for (const replacement of validated.replacements) { + transformed = replaceBytesBounded( + transformed, + decodeMaterializationBytes(replacement.matchHex), + decodeMaterializationBytes(replacement.replacementHex), + ); + } + for (const rejected of validated.rejectHex) { + if (containsBytes(transformed, decodeMaterializationBytes(rejected))) { + throw new Error( + `Lazy tree byte transform retains rejected byte sequence ${rejected}`, + ); + } + } + return transformed; +} + +function validateRecipe( + value: unknown, + label: string, +): { recipe: LazyTreeByteTransformRecipe; decodedBytes: number } { + const recipe = exactRecord(value, ["id", "replacements", "rejectHex"], label); + const id = requireString( + recipe.id, + `${label} id`, + VFS_DEFERRED_TREE_LIMITS.maxStringBytes, + ); + if (!isRecipeId(id)) throw new Error(`${label} id is invalid`); + let decodedBytes = 0; + const replacements = requireArray( + recipe.replacements, + `${label} replacements`, + 0, + VFS_DEFERRED_TREE_LIMITS.maxTransformReplacements, + ).map((value, index): LazyTreeByteReplacement => { + const replacement = exactRecord( + value, + ["matchHex", "replacementHex"], + `${label} replacement ${index}`, + ); + const matchHex = requireCanonicalHex( + replacement.matchHex, + `${label} match`, + VFS_DEFERRED_TREE_LIMITS.maxTransformPatternBytes, + false, + ); + const replacementHex = requireCanonicalHex( + replacement.replacementHex, + `${label} replacement`, + VFS_DEFERRED_TREE_LIMITS.maxTransformPatternBytes, + true, + ); + decodedBytes = addDecodedPlanBytes( + decodedBytes, + matchHex.length / 2 + replacementHex.length / 2, + ); + return { matchHex, replacementHex }; + }); + const rejectHex = requireArray( + recipe.rejectHex, + `${label} rejected patterns`, + 0, + VFS_DEFERRED_TREE_LIMITS.maxTransformReplacements, + ).map((value, index) => { + const pattern = requireCanonicalHex( + value, + `${label} rejected pattern ${index}`, + VFS_DEFERRED_TREE_LIMITS.maxTransformPatternBytes, + false, + ); + decodedBytes = addDecodedPlanBytes(decodedBytes, pattern.length / 2); + return pattern; + }); + if ( + replacements.length === 0 && rejectHex.length === 0 || + new Set(rejectHex).size !== rejectHex.length + ) { + throw new Error(`${label} is empty or ambiguous`); + } + return { recipe: { id, replacements, rejectHex }, decodedBytes }; +} + +function replaceBytesBounded( + source: Uint8Array, + match: Uint8Array, + replacement: Uint8Array, +): Uint8Array { + let count = 0; + for (let offset = 0; offset <= source.byteLength - match.byteLength;) { + if (bytesEqualAt(source, match, offset)) { + count += 1; + offset += match.byteLength; + } else { + offset += 1; + } + } + if (count === 0) return source; + const delta = replacement.byteLength - match.byteLength; + const outputBytes = source.byteLength + count * delta; + if ( + !Number.isSafeInteger(outputBytes) || outputBytes < 0 || + outputBytes > VFS_DEFERRED_TREE_LIMITS.maxPayloadBytes + ) { + throw new Error("Lazy tree byte transform exceeds its transformed-byte limit"); + } + const output = new Uint8Array(outputBytes); + let sourceOffset = 0; + let outputOffset = 0; + while (sourceOffset < source.byteLength) { + if (bytesEqualAt(source, match, sourceOffset)) { + output.set(replacement, outputOffset); + sourceOffset += match.byteLength; + outputOffset += replacement.byteLength; + } else { + output[outputOffset] = source[sourceOffset]!; + sourceOffset += 1; + outputOffset += 1; + } + } + return output; +} + +function containsBytes(source: Uint8Array, match: Uint8Array): boolean { + if (match.byteLength > source.byteLength) return false; + for (let offset = 0; offset <= source.byteLength - match.byteLength; offset += 1) { + if (bytesEqualAt(source, match, offset)) return true; + } + return false; +} + +function bytesEqualAt( + source: Uint8Array, + match: Uint8Array, + offset: number, +): boolean { + if (offset + match.byteLength > source.byteLength) return false; + for (let index = 0; index < match.byteLength; index += 1) { + if (source[offset + index] !== match[index]) return false; + } + return true; +} + +function validateByteIdentity( + value: unknown, + label: string, +): LazyTreeByteIdentity { + const record = exactRecord(value, ["sha256", "bytes"], label); + if (typeof record.sha256 !== "string" || !isLowerHex(record.sha256, 64)) { + throw new Error(`${label} has an invalid SHA-256 digest`); + } + return { + sha256: record.sha256, + bytes: requireInteger( + record.bytes, + `${label} byte count`, + 0, + VFS_DEFERRED_TREE_LIMITS.maxPayloadBytes, + ), + }; +} + +function exactRecord( + value: unknown, + keys: readonly string[], + label: string, +): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + const record = value as Record; + const actual = Object.keys(record).sort(); + const expected = [...keys].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + throw new Error(`${label} has unexpected fields`); + } + return record; +} + +function requireArray( + value: unknown, + label: string, + minimum: number, + maximum: number, +): unknown[] { + if (!Array.isArray(value) || value.length < minimum || value.length > maximum) { + throw new Error(`${label} must contain ${minimum} to ${maximum} items`); + } + return value; +} + +function requireString( + value: unknown, + label: string, + maximumBytes: number, +): string { + if (typeof value !== "string") { + throw new Error(`${label} is invalid or exceeds ${maximumBytes} bytes`); + } + assertUnicodeScalarText(value, label); + if ( + value.length === 0 || value.includes("\0") || + new TextEncoder().encode(value).byteLength > maximumBytes + ) { + throw new Error(`${label} is invalid or exceeds ${maximumBytes} bytes`); + } + return value; +} + +function requireInteger( + value: unknown, + label: string, + minimum: number, + maximum: number, +): number { + if (!Number.isSafeInteger(value) || Number(value) < minimum || Number(value) > maximum) { + throw new Error(`${label} must be an integer between ${minimum} and ${maximum}`); + } + return Number(value); +} + +function requireCanonicalHex( + value: unknown, + label: string, + maximumBytes: number, + allowEmpty: boolean, +): string { + if ( + typeof value !== "string" || (!allowEmpty && value.length === 0) || + value.length % 2 !== 0 || value.length / 2 > maximumBytes || + !isLowerHex(value) + ) { + throw new Error(`${label} is not canonical bounded hexadecimal bytes`); + } + return value; +} + +function requireCanonicalSourcePath(value: unknown, label: string): string { + const path = requireString(value, label, VFS_DEFERRED_TREE_LIMITS.maxPathBytes); + if ( + path.startsWith("/") || path.includes("\\") || + path.split("/").some((segment) => + segment === "" || segment === "." || segment === ".." + ) + ) { + throw new Error(`${label} is not a canonical relative path`); + } + return path; +} + +function addDecodedPlanBytes(current: number, additional: number): number { + const total = current + additional; + if ( + !Number.isSafeInteger(total) || + total > VFS_DEFERRED_TREE_LIMITS.maxMaterializationDecodedBytes + ) { + throw new Error("Lazy tree materialization plan exceeds its decoded byte limit"); + } + return total; +} + +function isCanonical(values: readonly string[]): boolean { + return values.every((value, index) => + index === 0 || compareUnicodeScalarText(values[index - 1]!, value) < 0 + ); +} + +function isRecipeId(value: string): boolean { + if (!isLowerLetterOrDigit(value.charCodeAt(0))) return false; + for (let index = 1; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if ( + !isLowerLetterOrDigit(code) && code !== 0x3a && code !== 0x2e && + code !== 0x5f && code !== 0x2d + ) return false; + } + return true; +} + +function isLowerLetterOrDigit(code: number): boolean { + return code >= 0x61 && code <= 0x7a || code >= 0x30 && code <= 0x39; +} + +function isLowerHex(value: string, exactLength?: number): boolean { + if (exactLength !== undefined && value.length !== exactLength) return false; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if ( + !(code >= 0x30 && code <= 0x39) && + !(code >= 0x61 && code <= 0x66) + ) return false; + } + return true; +} diff --git a/host/src/vfs/memory-fs.ts b/host/src/vfs/memory-fs.ts index e921b36ed8..76fa60396c 100644 --- a/host/src/vfs/memory-fs.ts +++ b/host/src/vfs/memory-fs.ts @@ -34,9 +34,16 @@ import { type VfsDeferredTreeUsage, } from "./deferred-tree-limits"; import { - parseHomebrewInstallReceiptRelocation, - relocateHomebrewBottleFile, -} from "../homebrew-bottle-relocation"; + applyLazyTreeByteTransformRecipe, + decodeMaterializationBytes, + validateLazyTreeMaterializationPlan, + type LazyTreeByteIdentity, + type LazyTreeMaterializationPlan, +} from "./materialization-plan"; +import { + assertUnicodeScalarText, + compareUnicodeScalarText, +} from "./canonical-text"; /** Serializable lazy file entry for transfer between instances. */ export interface LazyFileEntry { @@ -151,6 +158,18 @@ interface SealedLazyAtomicState { verified: boolean; } +/** Caller-inaccessible authority for one ordinary pending generic tree. */ +interface LazyTreeDefinitionSnapshot { + content: LazyTreeContent; + inventory: LazyTreeRegistrationEntry[]; + activation: LazyTreeActivation; + url: string; + mountPrefix: string; + integrity: LazyArchiveIntegrity; + entries: LazyAtomicSnapshotEntry[]; + materialized: boolean; +} + /** Per-file metadata for a file inside a lazy archive. */ export interface LazyArchiveFileEntry { ino: number; @@ -178,7 +197,7 @@ export interface LazyArchiveIntegrity { } /** Closed decoder set for an immutable deferred filesystem tree. */ -export type LazyTreeDecoder = "zip-v1" | "homebrew-bottle-tar-gzip-v1"; +export type LazyTreeDecoder = "zip-v1" | "tar-gzip-v1"; export interface LazyTreeContent { decoder: LazyTreeDecoder; @@ -194,8 +213,10 @@ export interface LazyTreeContent { transports: string[]; /** Closed install-mode normalization for portable package ZIP outputs. */ modePolicy?: "portable-posix-v1"; - /** Complete source-member truth for a byte-identical original bottle. */ + /** Complete source-member truth for the authenticated archive. */ source?: LazyTreeSourceInventory; + /** Optional authenticated source assertions and byte transformations. */ + materialization?: LazyTreeMaterializationPlan; } export interface LazyTreeSourceEntry { @@ -208,7 +229,7 @@ export interface LazyTreeSourceEntry { export interface LazyTreeSourceInventory { schema: 1; - kind: "homebrew-bottle-tar-gzip-v1"; + kind: "archive-source-inventory-v1"; entries: LazyTreeSourceEntry[]; } @@ -217,10 +238,9 @@ export interface LazyTreeRegistrationEntry { vfsPath: string; /** Canonical member path interpreted by the selected decoder. */ sourcePath: string; - /** Explicit only for the original-bottle source-inventory contract. */ + /** Explicit only when a complete source inventory permits projections. */ materialization?: | "archive" - | "archive-homebrew-relocate" | "archive-copy" | "archive-copy-mode" | "descriptor"; @@ -1142,8 +1162,12 @@ function requireLazyTreeString( label: string, maximumBytes: number, ): string { + if (typeof value !== "string") { + throw new Error(`${label} is invalid or exceeds ${maximumBytes} bytes`); + } + assertUnicodeScalarText(value, label); if ( - typeof value !== "string" || value.length === 0 || value.includes("\0") || + value.length === 0 || value.includes("\0") || new TextEncoder().encode(value).byteLength > maximumBytes ) { throw new Error(`${label} is invalid or exceeds ${maximumBytes} bytes`); @@ -1172,6 +1196,8 @@ function validateLazyTreeContent( !Array.isArray(initial) && initial.source !== undefined; const hasModePolicy = typeof initial === "object" && initial !== null && !Array.isArray(initial) && initial.modePolicy !== undefined; + const hasMaterialization = typeof initial === "object" && initial !== null && + !Array.isArray(initial) && initial.materialization !== undefined; const record = exactLazyTreeRecord(value, [ "decoder", "mediaType", @@ -1182,10 +1208,11 @@ function validateLazyTreeContent( "transports", ...(hasModePolicy ? ["modePolicy"] : []), ...(hasSource ? ["source"] : []), + ...(hasMaterialization ? ["materialization"] : []), ], "Lazy tree content"); const expectedMediaType = record.decoder === "zip-v1" ? "application/zip" - : record.decoder === "homebrew-bottle-tar-gzip-v1" + : record.decoder === "tar-gzip-v1" ? "application/vnd.oci.image.layer.v1.tar+gzip" : null; if (expectedMediaType === null || record.mediaType !== expectedMediaType) { @@ -1226,6 +1253,9 @@ function validateLazyTreeContent( const source = hasSource ? validateLazyTreeSourceInventory(record.source, record.decoder) : undefined; + const materialization = hasMaterialization + ? validateLazyTreeMaterializationPlan(record.materialization, source!) + : undefined; const modePolicy = hasModePolicy ? record.modePolicy : undefined; if ( modePolicy !== undefined && @@ -1246,6 +1276,7 @@ function validateLazyTreeContent( transports, ...(modePolicy === undefined ? {} : { modePolicy }), ...(source === undefined ? {} : { source }), + ...(materialization === undefined ? {} : { materialization }), }; } @@ -1374,15 +1405,15 @@ function validateLazyTreeSourceInventory( value: unknown, decoder: unknown, ): LazyTreeSourceInventory { - if (decoder !== "homebrew-bottle-tar-gzip-v1") { - throw new Error("Lazy tree source inventory is valid only for original bottles"); + if (decoder !== "zip-v1" && decoder !== "tar-gzip-v1") { + throw new Error("Lazy tree source inventory requires a supported archive decoder"); } const record = exactLazyTreeRecord( value, ["schema", "kind", "entries"], "Lazy tree source inventory", ); - if (record.schema !== 1 || record.kind !== "homebrew-bottle-tar-gzip-v1") { + if (record.schema !== 1 || record.kind !== "archive-source-inventory-v1") { throw new Error("Lazy tree source inventory has an unsupported identity"); } const byPath = new Map(); @@ -1453,10 +1484,12 @@ function validateLazyTreeSourceInventory( return result; }); const paths = entries.map((entry) => entry.sourcePath); - if (paths.some((path, index) => index > 0 && paths[index - 1] >= path)) { + if (paths.some((path, index) => + index > 0 && compareUnicodeScalarText(paths[index - 1]!, path) >= 0 + )) { throw new Error("Lazy tree source inventory is not in canonical path order"); } - return { schema: 1, kind: "homebrew-bottle-tar-gzip-v1", entries }; + return { schema: 1, kind: "archive-source-inventory-v1", entries }; } function resolveLazyTreeSourceHardlinks( @@ -1728,6 +1761,12 @@ function validateLazyTreeDefinition( const canonicalSourceByPath = content.source === undefined ? undefined : resolveLazyTreeSourceHardlinks(content.source.entries); + const transformByCanonicalSource = new Map( + content.materialization?.transforms.map((transform) => [ + transform.sourcePath, + transform, + ]) ?? [], + ); let decodedPayloadBytes = 0; for (const [index, value] of rawEntries.entries()) { if (typeof value !== "object" || value === null || Array.isArray(value)) { @@ -1773,7 +1812,6 @@ function validateLazyTreeDefinition( if ( completeSources !== undefined && materialization !== "archive" && - materialization !== "archive-homebrew-relocate" && materialization !== "archive-copy" && materialization !== "archive-copy-mode" && materialization !== "descriptor" @@ -1887,14 +1925,6 @@ function validateLazyTreeDefinition( ) { throw new Error(`Lazy tree archive copy ${vfsPath} differs from its source`); } - } else if (entry.materialization === "archive-homebrew-relocate") { - if ( - (entry.type !== "file" && entry.type !== "hardlink") || - source.type !== entry.type || - (entry.type === "file" && source.mode !== entry.mode) - ) { - throw new Error(`Lazy tree receipt-relocated entry ${vfsPath} differs from its source`); - } } else if ( source.type !== entry.type || (entry.type === "symlink" && source.target !== entry.target) || @@ -1931,18 +1961,7 @@ function validateLazyTreeDefinition( "Lazy tree", ); if (completeSources !== undefined) { - const relocatedCanonicalSources = new Set(); - for (const entry of entries) { - if (entry.materialization !== "archive-homebrew-relocate") continue; - const source = completeSources.get(entry.sourcePath)!; - const canonical = source.type === "file" - ? source - : canonicalSourceByPath!.get(source.sourcePath); - if (canonical?.type !== "file") { - throw new Error(`Lazy tree receipt-relocated entry ${entry.vfsPath} is not regular`); - } - relocatedCanonicalSources.add(canonical.sourcePath); - } + const referencedCanonicalSources = new Set(); for (const entry of entries) { if ( entry.materialization === "descriptor" || @@ -1952,19 +1971,30 @@ function validateLazyTreeDefinition( const canonical = source.type === "file" ? source : canonicalSourceByPath!.get(source.sourcePath); + if (canonical?.type === "file") { + referencedCanonicalSources.add(canonical.sourcePath); + } + const transform = canonical?.type === "file" + ? transformByCanonicalSource.get(canonical.sourcePath) + : undefined; if ( canonical?.type !== "file" || - !relocatedCanonicalSources.has(canonical.sourcePath) && - entry.size !== canonical.size + entry.size !== (transform?.output.bytes ?? canonical.size) ) { throw new Error(`Lazy tree archive entry ${entry.vfsPath} differs from its source`); } } + for (const sourcePath of transformByCanonicalSource.keys()) { + if (!referencedCanonicalSources.has(sourcePath)) { + throw new Error( + `Lazy tree materialization transform ${sourcePath} has no destination`, + ); + } + } for (const entry of entries) { if ( entry.type !== "hardlink" || - (entry.materialization !== "archive" && - entry.materialization !== "archive-homebrew-relocate") + entry.materialization !== "archive" ) continue; const source = completeSources.get(entry.sourcePath)!; const target = byPath.get(entry.target!); @@ -2241,8 +2271,8 @@ function validateSerializedGenericTree( ) { throw new Error( expectedKind === SERIALIZED_DEFERRED_TREE_V1_KIND - ? "Serialized deferred-tree-v1 cannot contain original-bottle source metadata" - : "Serialized deferred-tree-v2 requires original-bottle source metadata", + ? "Serialized deferred-tree-v1 cannot contain complete source metadata" + : "Serialized deferred-tree-v2 requires complete source metadata", ); } const atomicMembership = definition.activation.atomicGroup; @@ -2468,6 +2498,25 @@ async function assertLazyIntegrity( } } +async function assertLazyTreeByteIdentity( + data: Uint8Array, + expected: LazyTreeByteIdentity, + label: string, +): Promise { + if (data.byteLength !== expected.bytes) { + throw new Error( + `${label} byte count ${data.byteLength} does not match expected ` + + `${expected.bytes}`, + ); + } + const actual = await sha256Hex(data, label); + if (actual !== expected.sha256) { + throw new Error( + `${label} SHA-256 ${actual} does not match expected ${expected.sha256}`, + ); + } +} + function lazyAtomicDescriptorIdentityBytesFromValues( content: LazyTreeContent, inventory: readonly LazyTreeRegistrationEntry[], @@ -2495,10 +2544,13 @@ function lazyAtomicDescriptorIdentityBytesFromValues( ? {} : { modePolicy: content.modePolicy }), ...(content.source === undefined ? {} : { source: content.source }), + ...(content.materialization === undefined + ? {} + : { materialization: content.materialization }), }, mountPrefix, inventory: [...inventory].sort((left, right) => - left.vfsPath < right.vfsPath ? -1 : left.vfsPath > right.vfsPath ? 1 : 0 + compareUnicodeScalarText(left.vfsPath, right.vfsPath) ), activation: { mode: activation.mode, @@ -2551,7 +2603,7 @@ function immutableLazyTreeContent( ? undefined : { schema: 1 as const, - kind: "homebrew-bottle-tar-gzip-v1" as const, + kind: "archive-source-inventory-v1" as const, entries: content.source.entries.map((entry) => Object.freeze({ ...entry }) ), @@ -2560,6 +2612,9 @@ function immutableLazyTreeContent( Object.freeze(source.entries); Object.freeze(source); } + const materialization = content.materialization === undefined + ? undefined + : immutableLazyTreeMaterializationPlan(content.materialization); return Object.freeze({ decoder: content.decoder, mediaType: content.mediaType, @@ -2572,9 +2627,53 @@ function immutableLazyTreeContent( ? {} : { modePolicy: content.modePolicy }), ...(source === undefined ? {} : { source }), + ...(materialization === undefined ? {} : { materialization }), }); } +function cloneLazyTreeMaterializationPlan( + plan: LazyTreeMaterializationPlan, +): LazyTreeMaterializationPlan { + return { + schema: 1, + kind: "archive-byte-transforms-v1", + assertions: plan.assertions.map((assertion) => ({ ...assertion })), + recipes: plan.recipes.map((recipe) => ({ + id: recipe.id, + replacements: recipe.replacements.map((replacement) => ({ ...replacement })), + rejectHex: [...recipe.rejectHex], + })), + transforms: plan.transforms.map((transform) => ({ + sourcePath: transform.sourcePath, + recipe: transform.recipe, + input: { ...transform.input }, + output: { ...transform.output }, + })), + }; +} + +function immutableLazyTreeMaterializationPlan( + plan: LazyTreeMaterializationPlan, +): LazyTreeMaterializationPlan { + const copy = cloneLazyTreeMaterializationPlan(plan); + for (const assertion of copy.assertions) Object.freeze(assertion); + Object.freeze(copy.assertions); + for (const recipe of copy.recipes) { + for (const replacement of recipe.replacements) Object.freeze(replacement); + Object.freeze(recipe.replacements); + Object.freeze(recipe.rejectHex); + Object.freeze(recipe); + } + Object.freeze(copy.recipes); + for (const transform of copy.transforms) { + Object.freeze(transform.input); + Object.freeze(transform.output); + Object.freeze(transform); + } + Object.freeze(copy.transforms); + return Object.freeze(copy); +} + function cloneLazyTreeContent(content: LazyTreeContent): LazyTreeContent { return { decoder: content.decoder, @@ -2592,10 +2691,17 @@ function cloneLazyTreeContent(content: LazyTreeContent): LazyTreeContent { : { source: { schema: 1, - kind: "homebrew-bottle-tar-gzip-v1", + kind: "archive-source-inventory-v1", entries: content.source.entries.map((entry) => ({ ...entry })), }, }), + ...(content.materialization === undefined + ? {} + : { + materialization: cloneLazyTreeMaterializationPlan( + content.materialization, + ), + }), }; } @@ -2624,6 +2730,77 @@ function immutableLazyTreeActivation( }); } +function immutableOrdinaryLazyTreeActivation( + activation: LazyTreeActivation, +): LazyTreeActivation { + const capabilities = [...activation.capabilities]; + const roots = [...activation.roots]; + Object.freeze(capabilities); + Object.freeze(roots); + return Object.freeze({ + mode: activation.mode, + capabilities, + roots, + }); +} + +function immutableLazyTreeDefinitionSnapshot( + content: LazyTreeContent, + inventory: readonly LazyTreeRegistrationEntry[], + activation: LazyTreeActivation, + url: string, + mountPrefix: string, + integrity: LazyArchiveIntegrity, + entries: readonly LazyAtomicSnapshotEntry[], + materialized: boolean, +): LazyTreeDefinitionSnapshot { + const immutableEntries = entries.map((entry) => + Object.freeze({ ...entry }) + ); + Object.freeze(immutableEntries); + return Object.freeze({ + content: immutableLazyTreeContent(content), + inventory: immutableLazyTreeInventory(inventory), + activation: immutableOrdinaryLazyTreeActivation(activation), + url, + mountPrefix, + integrity: Object.freeze({ ...integrity }), + entries: immutableEntries, + materialized, + }); +} + +function replaceImmutableLazyTreeRuntimeState( + definition: LazyTreeDefinitionSnapshot, + entries: readonly LazyAtomicSnapshotEntry[], + materialized: boolean, +): LazyTreeDefinitionSnapshot { + const immutableEntries = entries.map((entry) => + Object.freeze({ ...entry }) + ); + Object.freeze(immutableEntries); + return Object.freeze({ + ...definition, + entries: immutableEntries, + materialized, + }); +} + +function lazyTreeSnapshotEntries( + entries: ReadonlyMap, +): LazyAtomicSnapshotEntry[] { + return Array.from(entries, ([vfsPath, entry]) => ({ + vfsPath, + ...entry, + })); +} + +function cloneLazyTreeSnapshotEntryMap( + entries: readonly LazyAtomicSnapshotEntry[], +): Map { + return new Map(entries.map(({ vfsPath, ...entry }) => [vfsPath, entry])); +} + function sealedLazyTreeActivation( snapshot: SealedLazyAtomicSnapshot, ): LazyTreeActivation { @@ -2922,6 +3099,18 @@ export class MemoryFileSystem implements FileSystemBackend { LazyArchiveGroup, SealedLazyAtomicState >(); + /** + * Validated ordinary-tree definitions are private immutable authority. + * + * WHY: registerLazyTree() and higher-level adapters expose their group for + * diagnostics. Callers may mutate that compatibility object after validation; + * decode, export, restore, and rebase must continue from the exact accepted + * source inventory and materialization plan rather than re-reading it. + */ + private ordinaryLazyTreeDefinitions = new WeakMap< + LazyArchiveGroup, + LazyTreeDefinitionSnapshot + >(); private lazyDownloadListeners = new Set(); /** One in-flight fetch/commit per lazy file or archive group. */ private lazyPreparations = new Map(); @@ -2952,6 +3141,31 @@ export class MemoryFileSystem implements FileSystemBackend { ); } + private replaceOrdinaryLazyTreeRuntimeState( + group: LazyArchiveGroup, + entries: readonly LazyAtomicSnapshotEntry[], + materialized: boolean, + ): LazyTreeDefinitionSnapshot | undefined { + const definition = this.ordinaryLazyTreeDefinitions.get(group); + if (definition === undefined) return undefined; + const next = replaceImmutableLazyTreeRuntimeState( + definition, + entries, + materialized, + ); + this.ordinaryLazyTreeDefinitions.set(group, next); + // WHY: these fields remain caller-visible compatibility diagnostics. The + // private replacement above commits authority first, so a hostile public + // map cannot affect the operation and a frozen mirror cannot roll it back. + try { + group.entries = cloneLazyTreeSnapshotEntryMap(next.entries); + group.materialized = next.materialized; + } catch { + // Private authority remains complete even if a caller froze its mirror. + } + return next; + } + /** * Reconcile process-local lazy metadata with authoritative SharedFS names. * The identity map may come from the same transaction as a filesystem @@ -3007,10 +3221,79 @@ export class MemoryFileSystem implements FileSystemBackend { } continue; } - const unverifiedGenericTree = - group.content !== undefined && - group.inventory !== undefined && - !group.materialized; + const ordinaryDefinition = this.ordinaryLazyTreeDefinitions.get(group); + if (ordinaryDefinition !== undefined) { + if (ordinaryDefinition.materialized) { + this.replaceOrdinaryLazyTreeRuntimeState( + group, + ordinaryDefinition.entries, + true, + ); + continue; + } + const pendingByIdentity = new Map< + string, + LazyAtomicSnapshotEntry[] + >(); + const reconciledEntries = ordinaryDefinition.entries + .filter((entry) => + entry.deleted || entry.materialized || entry.isSymlink + ) + .map((entry) => ({ ...entry })); + for (const entry of ordinaryDefinition.entries) { + if ( + entry.deleted || entry.materialized || entry.isSymlink || + entry.generation === undefined + ) continue; + const key = MemoryFileSystem.inodeKey(entry.ino, entry.generation); + const aliases = pendingByIdentity.get(key) ?? []; + aliases.push(entry); + pendingByIdentity.set(key, aliases); + } + for (const [key, aliases] of pendingByIdentity) { + const identity = identities.get(key); + if ( + identity === undefined || + identity.dataSequence !== (aliases[0]!.dataSequence ?? 0) + ) { + if (identity !== undefined) { + // A concrete write legitimately retired this deferred inode. + // Preserve that transition privately without adopting any + // caller-visible mapping fields. + for (const entry of aliases) { + reconciledEntries.push({ ...entry, materialized: true }); + } + } + continue; + } + const byPath = new Map( + aliases.map((entry) => [entry.vfsPath, entry]), + ); + const canonical = aliases.find((entry) => entry.type === "file") ?? + aliases[0]!; + for (const path of identity.paths) { + const semantic = byPath.get(path) ?? canonical; + reconciledEntries.push({ + ...semantic, + vfsPath: path, + ino: identity.ino, + generation: identity.generation, + dataSequence: identity.dataSequence, + deleted: false, + materialized: false, + }); + } + if (identity.paths.length > 0) { + this.lazyArchiveInodes.set(key, group); + } + } + this.replaceOrdinaryLazyTreeRuntimeState( + group, + reconciledEntries, + false, + ); + continue; + } const pendingByIdentity = new Map(); for (const entry of group.entries.values()) { if ( @@ -3057,7 +3340,6 @@ export class MemoryFileSystem implements FileSystemBackend { !Array.from(reconciled.values()).some((entry) => !entry.isSymlink && !entry.materialized ) && - !unverifiedGenericTree && (atomicGroup === undefined || atomicGroup.committed); } } @@ -3092,20 +3374,23 @@ export class MemoryFileSystem implements FileSystemBackend { const atomicGroup = this.lazyAtomicGroupByTree.get(group); const atomicState = this.sealedLazyAtomicStates.get(group); const snapshot = atomicState?.snapshot; + const ordinaryDefinition = this.ordinaryLazyTreeDefinitions.get(group); if ( atomicGroup?.committed || - (snapshot === undefined && group.materialized) || (snapshot === undefined && - (group.content === undefined || group.inventory === undefined)) + (ordinaryDefinition?.materialized ?? group.materialized)) || + (snapshot === undefined && + ordinaryDefinition === undefined) ) { continue; } - const inventory = snapshot?.inventory ?? group.inventory!; - const registeredEntries = snapshot === undefined - ? group.entries - : new Map( - snapshot.entries.map((entry) => [entry.vfsPath, entry]), - ); + const inventory = snapshot?.inventory ?? ordinaryDefinition!.inventory; + const registeredEntries = new Map( + (snapshot?.entries ?? ordinaryDefinition!.entries).map((entry) => [ + entry.vfsPath, + entry, + ]), + ); const aliasesByInodeGroup = new Map(); const pathsByInodeGroup = new Map(); const locallyDeletedInodeGroups = new Set(); @@ -3222,6 +3507,8 @@ export class MemoryFileSystem implements FileSystemBackend { if (atomicState !== undefined && !atomicGroup?.committed) { return atomicState.snapshot.entries; } + const ordinaryDefinition = this.ordinaryLazyTreeDefinitions.get(group); + if (ordinaryDefinition !== undefined) return ordinaryDefinition.entries; return Array.from(group.entries, ([vfsPath, entry]) => ({ vfsPath, ...entry, @@ -3245,7 +3532,20 @@ export class MemoryFileSystem implements FileSystemBackend { this.lazyArchiveInodes.delete(key); const atomicState = this.sealedLazyAtomicStates.get(group); if (atomicState === undefined) { - for (const entry of entries) entry.materialized = true; + const ordinaryDefinition = this.ordinaryLazyTreeDefinitions.get(group); + if (ordinaryDefinition !== undefined) { + this.replaceOrdinaryLazyTreeRuntimeState( + group, + ordinaryDefinition.entries.map((entry) => + entry.ino === st.ino && entry.generation === st.generation + ? { ...entry, materialized: true } + : entry + ), + ordinaryDefinition.materialized, + ); + } else { + for (const entry of entries) entry.materialized = true; + } } return undefined; } @@ -3280,13 +3580,14 @@ export class MemoryFileSystem implements FileSystemBackend { const metadataOnlyGroup = this.lazyArchiveGroups.find((group) => { const atomicGroup = this.lazyAtomicGroupByTree.get(group); const snapshot = this.sealedLazyAtomicStates.get(group)?.snapshot; + const ordinaryDefinition = this.ordinaryLazyTreeDefinitions.get(group); const pending = snapshot === undefined - ? !group.materialized + ? !(ordinaryDefinition?.materialized ?? group.materialized) : !atomicGroup?.committed; - const content = snapshot?.content ?? group.content; - const inventory = snapshot?.inventory ?? group.inventory; - const activation = snapshot?.activation ?? group.activation; - const entries = snapshot?.entries ?? + const content = snapshot?.content ?? ordinaryDefinition?.content; + const inventory = snapshot?.inventory ?? ordinaryDefinition?.inventory; + const activation = snapshot?.activation ?? ordinaryDefinition?.activation; + const entries = snapshot?.entries ?? ordinaryDefinition?.entries ?? Array.from(group.entries.values()); return pending && content !== undefined && @@ -3589,12 +3890,24 @@ export class MemoryFileSystem implements FileSystemBackend { const group = this.lazyArchiveInodes.get(key); if (!group) return; this.lazyArchiveInodes.delete(key); + const ordinaryDefinition = this.ordinaryLazyTreeDefinitions.get(group); + if (ordinaryDefinition !== undefined) { + this.replaceOrdinaryLazyTreeRuntimeState( + group, + ordinaryDefinition.entries.map((entry) => + entry.ino === st.ino && entry.generation === st.generation + ? { ...entry, materialized: true } + : entry + ), + ordinaryDefinition.materialized, + ); + return; + } for (const entry of group.entries.values()) { - if (entry.ino === st.ino && entry.generation === st.generation) { - // Keep the concrete inode in the image, but prevent a later archive - // fetch from overwriting data the guest supplied through any alias. - entry.materialized = true; - } + if (entry.ino !== st.ino || entry.generation !== st.generation) continue; + // Keep the concrete inode in the image, but prevent a later archive + // fetch from overwriting data the guest supplied through any alias. + entry.materialized = true; } } @@ -3623,6 +3936,60 @@ export class MemoryFileSystem implements FileSystemBackend { } for (const group of this.lazyArchiveGroups) { + const ordinaryDefinition = this.ordinaryLazyTreeDefinitions.get(group); + if (ordinaryDefinition !== undefined) { + const entries = ordinaryDefinition.entries.map((entry) => { + const entryKey = entry.generation === undefined + ? null + : MemoryFileSystem.inodeKey(entry.ino, entry.generation); + const vfsPath = directory || entryKey === sourceKey + ? rewrite(entry.vfsPath) + : entry.vfsPath; + return { + ...entry, + vfsPath, + ...(entry.type === "hardlink" && entry.target !== undefined + ? { target: rewrite(entry.target) } + : {}), + }; + }); + const inventory = ordinaryDefinition.inventory.map((entry) => ({ + ...entry, + vfsPath: rewrite(entry.vfsPath), + ...(entry.type === "hardlink" && entry.target !== undefined + ? { target: rewrite(entry.target) } + : {}), + })); + const activation = { + ...ordinaryDefinition.activation, + capabilities: [...ordinaryDefinition.activation.capabilities], + roots: ordinaryDefinition.activation.roots.map(rewrite), + }; + const next = immutableLazyTreeDefinitionSnapshot( + ordinaryDefinition.content, + inventory, + activation, + ordinaryDefinition.url, + ordinaryDefinition.mountPrefix, + ordinaryDefinition.integrity, + entries, + ordinaryDefinition.materialized, + ); + this.ordinaryLazyTreeDefinitions.set(group, next); + try { + group.entries = cloneLazyTreeSnapshotEntryMap(next.entries); + group.materialized = next.materialized; + group.inventory = next.inventory.map((entry) => ({ ...entry })); + group.activation = { + ...next.activation, + capabilities: [...next.activation.capabilities], + roots: [...next.activation.roots], + }; + } catch { + // Authorized private namespace state does not depend on its mirror. + } + continue; + } const rewritten = new Map(); for (const [candidate, entry] of group.entries) { const entryKey = @@ -4366,6 +4733,21 @@ export class MemoryFileSystem implements FileSystemBackend { } this.lazyArchiveGroups.push(group); this.registerLazyAtomicGroupMembership(group); + if (activation.atomicGroup === undefined) { + this.ordinaryLazyTreeDefinitions.set( + group, + immutableLazyTreeDefinitionSnapshot( + content, + entries, + activation, + group.url, + validatedMountPrefix, + group.integrity!, + lazyTreeSnapshotEntries(group.entries), + false, + ), + ); + } return group; } @@ -4877,6 +5259,25 @@ export class MemoryFileSystem implements FileSystemBackend { group, sealedImportTrust === "verified", ); + if ( + group.content !== undefined && group.inventory !== undefined && + group.activation !== undefined && + group.activation.atomicGroup === undefined + ) { + this.ordinaryLazyTreeDefinitions.set( + group, + immutableLazyTreeDefinitionSnapshot( + group.content, + group.inventory, + group.activation, + group.url, + group.mountPrefix, + group.integrity!, + lazyTreeSnapshotEntries(group.entries), + group.materialized, + ), + ); + } } for (const [key, group] of plannedInodes) { this.lazyArchiveInodes.set(key, group); @@ -4907,7 +5308,27 @@ export class MemoryFileSystem implements FileSystemBackend { atomicState.snapshot = snapshot; continue; } - if (group.content) { + const ordinaryDefinition = this.ordinaryLazyTreeDefinitions.get(group); + if (ordinaryDefinition !== undefined) { + const content = immutableLazyTreeContent( + ordinaryDefinition.content, + ordinaryDefinition.content.transports.map(transform), + ); + const next = immutableLazyTreeDefinitionSnapshot( + content, + ordinaryDefinition.inventory, + ordinaryDefinition.activation, + content.transports[0]!, + ordinaryDefinition.mountPrefix, + ordinaryDefinition.integrity, + ordinaryDefinition.entries, + ordinaryDefinition.materialized, + ); + this.ordinaryLazyTreeDefinitions.set(group, next); + group.content = cloneLazyTreeContent(next.content); + group.url = next.url; + group.integrity = { ...next.integrity }; + } else if (group.content) { group.content = { ...group.content, transports: group.content.transports.map(transform), @@ -4947,33 +5368,30 @@ export class MemoryFileSystem implements FileSystemBackend { }); continue; } - const entries = Array.from(group.entries, ([vfsPath, entry]) => ({ - vfsPath, - ino: entry.ino, - generation: entry.generation, - dataSequence: entry.dataSequence, - size: entry.size, - isSymlink: entry.isSymlink, - deleted: entry.deleted, - materialized: entry.materialized, - archivePath: entry.archivePath, - sourcePath: entry.sourcePath, - type: entry.type, - inodeGroup: entry.inodeGroup, - target: entry.target, - })).filter((entry) => !entry.deleted && !entry.materialized); + const ordinaryDefinition = this.ordinaryLazyTreeDefinitions.get(group); + const materialized = ordinaryDefinition?.materialized ?? group.materialized; + const entries = ( + ordinaryDefinition?.entries ?? lazyTreeSnapshotEntries(group.entries) + ).map((entry) => ({ ...entry })) + .filter((entry) => !entry.deleted && !entry.materialized); + const content = ordinaryDefinition?.content ?? group.content; + const inventory = ordinaryDefinition?.inventory ?? group.inventory; + const activation = ordinaryDefinition?.activation ?? group.activation; + const url = ordinaryDefinition?.url ?? group.url; + const mountPrefix = ordinaryDefinition?.mountPrefix ?? group.mountPrefix; + const integrity = ordinaryDefinition?.integrity ?? group.integrity; if ( entries.length === 0 && - !(group.content && group.inventory && !group.materialized) + !(content !== undefined && inventory !== undefined && !materialized) ) continue; - const genericTree = group.content !== undefined && - group.inventory !== undefined && group.activation !== undefined; - if (genericTree && group.content!.transports.length === 0) { + const genericTree = content !== undefined && inventory !== undefined && + activation !== undefined; + if (genericTree && content.transports.length === 0) { throw new Error( "Direct-materialization tree must be materialized before serialization", ); } - const atomicMembership = group.activation?.atomicGroup; + const atomicMembership = activation?.atomicGroup; if ( atomicMembership !== undefined && !isSealedLazyAtomicMembership(atomicMembership) @@ -4987,15 +5405,19 @@ export class MemoryFileSystem implements FileSystemBackend { ? { kind: atomicMembership !== undefined ? SERIALIZED_DEFERRED_TREE_V3_KIND - : group.content!.source === undefined + : content.source === undefined ? SERIALIZED_DEFERRED_TREE_V1_KIND : SERIALIZED_DEFERRED_TREE_V2_KIND, - content: group.content, - inventory: group.inventory, - activation: group.activation, - url: group.url, - mountPrefix: group.mountPrefix, - integrity: group.integrity, + content: cloneLazyTreeContent(content), + inventory: inventory.map((entry) => ({ ...entry })), + activation: { + ...activation, + capabilities: [...activation.capabilities], + roots: [...activation.roots], + }, + url, + mountPrefix, + integrity: { ...integrity! }, materialized: false, entries, } @@ -5059,8 +5481,9 @@ export class MemoryFileSystem implements FileSystemBackend { const atomicGroup = this.lazyAtomicGroupByTree.get(group); const snapshot = this.sealedLazyAtomicStates.get(group)?.snapshot; if (snapshot !== undefined) return !atomicGroup?.committed; - return !group.materialized && ( - group.content !== undefined && group.inventory !== undefined || + const ordinaryDefinition = this.ordinaryLazyTreeDefinitions.get(group); + return !(ordinaryDefinition?.materialized ?? group.materialized) && ( + ordinaryDefinition !== undefined || Array.from(group.entries.values()).some((entry) => !entry.deleted && !entry.materialized ) @@ -5109,7 +5532,11 @@ export class MemoryFileSystem implements FileSystemBackend { */ async prepareBootDeferredTrees(): Promise { const groups = this.lazyArchiveGroups.filter( - (group) => !group.materialized && group.activation?.mode === "boot-prefetch", + (group) => { + const definition = this.ordinaryLazyTreeDefinitions.get(group); + return !(definition?.materialized ?? group.materialized) && + (definition?.activation ?? group.activation)?.mode === "boot-prefetch"; + }, ); let next = 0; let failure: unknown; @@ -5156,7 +5583,8 @@ export class MemoryFileSystem implements FileSystemBackend { "materialize the complete group instead", ); } - if (group.materialized) return false; + const ordinaryDefinition = this.ordinaryLazyTreeDefinitions.get(group); + if (ordinaryDefinition?.materialized ?? group.materialized) return false; const existing = this.lazyPreparations.get(group); if (existing !== undefined) return existing.promise; const bytes = new Uint8Array(exactBytes.byteLength); @@ -5169,7 +5597,9 @@ export class MemoryFileSystem implements FileSystemBackend { // so a concurrent guest preparePath() joins this exact-byte operation // instead of starting a transport fetch for the same group. preparation.promise = Promise.resolve().then(async () => { - await assertLazyIntegrity(bytes, "tree", group.integrity); + const integrity = this.ordinaryLazyTreeDefinitions.get(group)?.integrity ?? + group.integrity; + await assertLazyIntegrity(bytes, "tree", integrity); await this.materializeArchiveBytes(group, bytes); return true; }).then( @@ -5196,15 +5626,21 @@ export class MemoryFileSystem implements FileSystemBackend { private async prepareLazyTreeGroup(group: LazyTreeGroup): Promise { const atomicGroup = this.lazyAtomicGroupByTree.get(group); - if (atomicGroup?.committed || (atomicGroup === undefined && group.materialized)) { + const ordinaryDefinition = this.ordinaryLazyTreeDefinitions.get(group); + if ( + atomicGroup?.committed || + (atomicGroup === undefined && + (ordinaryDefinition?.materialized ?? group.materialized)) + ) { return false; } const snapshot = this.sealedLazyAtomicStates.get(group)?.snapshot; const backing: LazyBacking = { token: atomicGroup?.token ?? group, path: snapshot?.activation.roots[0] ?? - group.activation?.roots[0] ?? - group.mountPrefix, + ordinaryDefinition?.activation.roots[0] ?? + ordinaryDefinition?.mountPrefix ?? + group.activation?.roots[0] ?? group.mountPrefix, directGroup: group, ...(atomicGroup === undefined ? {} : { atomicGroup }), }; @@ -5286,8 +5722,11 @@ export class MemoryFileSystem implements FileSystemBackend { data: Uint8Array, atomicSnapshot?: SealedLazyAtomicSnapshot, ): Promise> { - const content = atomicSnapshot?.content ?? group.content; - const inventory = atomicSnapshot?.inventory ?? group.inventory; + const ordinaryDefinition = this.ordinaryLazyTreeDefinitions.get(group); + const content = atomicSnapshot?.content ?? ordinaryDefinition?.content ?? + group.content; + const inventory = atomicSnapshot?.inventory ?? ordinaryDefinition?.inventory ?? + group.inventory; if (!content || !inventory) { throw new Error("Lazy tree is missing its decoder or complete inventory"); } @@ -5470,86 +5909,47 @@ export class MemoryFileSystem implements FileSystemBackend { } } - const relocationSources = new Set( - inventory.flatMap((entry) => - entry.materialization === "archive-homebrew-relocate" - ? [entry.sourcePath] - : [] - ), - ); - if (content.source !== undefined) { - const sourceByPath = new Map( - content.source.entries.map((entry) => [entry.sourcePath, entry]), - ); - const canonicalByPath = resolveLazyTreeSourceHardlinks(content.source.entries); - const receiptSources = content.source.entries.filter((entry) => - entry.sourcePath === "INSTALL_RECEIPT.json" || - entry.sourcePath.endsWith("/INSTALL_RECEIPT.json") - ); - if (receiptSources.length > 1) { - throw new Error( - `Lazy Homebrew bottle has ${receiptSources.length} INSTALL_RECEIPT.json ` + - "source members, expected at most one", - ); - } - if (receiptSources.length === 0) { - if (relocationSources.size > 0) { - throw new Error( - "Lazy Homebrew bottle marks receipt relocation without INSTALL_RECEIPT.json", - ); - } - } else { - const receiptSource = receiptSources[0]!; - const receiptCanonical = receiptSource.type === "file" - ? receiptSource - : canonicalByPath.get(receiptSource.sourcePath); - const receiptDecoded = receiptCanonical === undefined - ? undefined - : decoded.get(receiptCanonical.sourcePath); - if (receiptCanonical?.type !== "file" || receiptDecoded?.type !== "file" || - receiptDecoded.data === undefined) { - throw new Error("Lazy Homebrew bottle INSTALL_RECEIPT.json is not regular"); - } - const receipt = parseHomebrewInstallReceiptRelocation(receiptDecoded.data); - const separator = receiptSource.sourcePath.lastIndexOf("/"); - const sourceRoot = separator < 0 - ? "" - : receiptSource.sourcePath.slice(0, separator); - const receiptChangedSources = new Set(receipt.changedFiles.map((path) => - sourceRoot.length === 0 ? path : `${sourceRoot}/${path}` - )); + const materialization = content.materialization; + if (materialization !== undefined) { + for (const assertion of materialization.assertions) { + const actual = decoded.get(assertion.sourcePath); + const expected = decodeMaterializationBytes(assertion.bytesHex); if ( - relocationSources.size !== receiptChangedSources.size || - [...relocationSources].some((path) => !receiptChangedSources.has(path)) + actual?.type !== "file" || actual.data === undefined || + actual.data.byteLength !== expected.byteLength || + actual.data.some((byte, index) => byte !== expected[index]) ) { throw new Error( - "Lazy Homebrew bottle relocation markers differ from INSTALL_RECEIPT.json", + `Lazy tree source assertion ${assertion.sourcePath} differs from archive bytes`, ); } - const relocatedCanonicalSources = new Set(); - for (const sourcePath of receiptChangedSources) { - const source = sourceByPath.get(sourcePath); - const canonical = source?.type === "file" - ? source - : source === undefined - ? undefined - : canonicalByPath.get(source.sourcePath); - const actual = canonical === undefined - ? undefined - : decoded.get(canonical.sourcePath); - if (canonical?.type !== "file" || actual?.type !== "file" || - actual.data === undefined) { - throw new Error( - `Lazy Homebrew bottle changed source ${sourcePath} is not regular`, - ); - } - if (relocatedCanonicalSources.has(canonical.sourcePath)) continue; - actual.data = relocateHomebrewBottleFile(actual.data, receipt, sourcePath); - relocatedCanonicalSources.add(canonical.sourcePath); + } + const recipes = new Map( + materialization.recipes.map((recipe) => [recipe.id, recipe]), + ); + for (const transform of materialization.transforms) { + const actual = decoded.get(transform.sourcePath); + if (actual?.type !== "file" || actual.data === undefined) { + throw new Error( + `Lazy tree transform ${transform.sourcePath} is not a regular source`, + ); } + await assertLazyTreeByteIdentity( + actual.data, + transform.input, + `Lazy tree transform ${transform.sourcePath} input`, + ); + const transformed = applyLazyTreeByteTransformRecipe( + actual.data, + recipes.get(transform.recipe)!, + ); + await assertLazyTreeByteIdentity( + transformed, + transform.output, + `Lazy tree transform ${transform.sourcePath} output`, + ); + actual.data = transformed; } - } else if (relocationSources.size > 0) { - throw new Error("Lazy tree receipt relocation requires original-bottle source truth"); } const files = new Map(); @@ -5595,7 +5995,8 @@ export class MemoryFileSystem implements FileSystemBackend { } return; } - if (group.materialized) return; + const ordinaryDefinition = this.ordinaryLazyTreeDefinitions.get(group); + if (ordinaryDefinition?.materialized ?? group.materialized) return; const transport = this.lazyTransport; const archiveData = await this.fetchLazyArchiveData(group, transport); throwIfLazyTransportAborted(transport.signal); @@ -5612,15 +6013,20 @@ export class MemoryFileSystem implements FileSystemBackend { transport: LazyTransport, atomicSnapshot?: SealedLazyAtomicSnapshot, ): Promise { - const content = atomicSnapshot?.content ?? group.content; - const inventory = atomicSnapshot?.inventory ?? group.inventory; + const ordinaryDefinition = this.ordinaryLazyTreeDefinitions.get(group); + const content = atomicSnapshot?.content ?? ordinaryDefinition?.content ?? + group.content; + const inventory = atomicSnapshot?.inventory ?? ordinaryDefinition?.inventory ?? + group.inventory; const genericTree = content !== undefined && inventory !== undefined; - const mountPrefix = atomicSnapshot?.mountPrefix ?? group.mountPrefix; - const integrity = atomicSnapshot?.integrity ?? group.integrity; + const mountPrefix = atomicSnapshot?.mountPrefix ?? + ordinaryDefinition?.mountPrefix ?? group.mountPrefix; + const integrity = atomicSnapshot?.integrity ?? ordinaryDefinition?.integrity ?? + group.integrity; const transports = genericTree ? content!.transports - : [atomicSnapshot?.url ?? group.url]; + : [atomicSnapshot?.url ?? ordinaryDefinition?.url ?? group.url]; const failures: string[] = []; let archiveData: Uint8Array | null = null; for (const [index, url] of transports.entries()) { @@ -5659,7 +6065,8 @@ export class MemoryFileSystem implements FileSystemBackend { signal?: AbortSignal, ): Promise { throwIfLazyTransportAborted(signal); - if (group.materialized) return; + const definition = this.ordinaryLazyTreeDefinitions.get(group); + if (definition?.materialized ?? group.materialized) return; const extractedByIdentity = await this.prepareLazyArchiveContents( group, archiveData, @@ -5691,7 +6098,10 @@ export class MemoryFileSystem implements FileSystemBackend { // same last cancellation boundary before publishing materialized state. throwIfLazyTransportAborted(signal); this.publishLazyArchiveReplacements(group, pending); - if (group.materialized) return; + if ( + this.ordinaryLazyTreeDefinitions.get(group)?.materialized ?? + group.materialized + ) return; this.reconcileLazyIdentityState(this.fs.identityState()); if (requestedKey && !this.lazyArchiveInodes.has(requestedKey)) return; } @@ -5713,8 +6123,11 @@ export class MemoryFileSystem implements FileSystemBackend { content: Uint8Array; }>> { throwIfLazyTransportAborted(signal); - const content = atomicSnapshot?.content ?? group.content; - const inventory = atomicSnapshot?.inventory ?? group.inventory; + const ordinaryDefinition = this.ordinaryLazyTreeDefinitions.get(group); + const content = atomicSnapshot?.content ?? ordinaryDefinition?.content ?? + group.content; + const inventory = atomicSnapshot?.inventory ?? ordinaryDefinition?.inventory ?? + group.inventory; const genericTree = content !== undefined && inventory !== undefined; const decodedTreeFiles = genericTree ? await this.decodeAndValidateLazyTree( @@ -5735,16 +6148,19 @@ export class MemoryFileSystem implements FileSystemBackend { zipLookup.set(ze.fileName, ze); } - const mountPrefix = atomicSnapshot?.mountPrefix ?? group.mountPrefix; + const mountPrefix = atomicSnapshot?.mountPrefix ?? + ordinaryDefinition?.mountPrefix ?? group.mountPrefix; const normalizedPrefix = mountPrefix.replace(/\/+$/, ""); const extractedByIdentity = new Map(); + const authoritativeEntries = atomicSnapshot?.entries ?? + ordinaryDefinition?.entries; const runtimeEntries: Array<[string, LazyArchiveFileEntry]> = - atomicSnapshot === undefined + authoritativeEntries === undefined ? Array.from(group.entries) - : atomicSnapshot.entries.map((entry) => [entry.vfsPath, entry]); + : authoritativeEntries.map((entry) => [entry.vfsPath, entry]); for (const [vfsPath, archiveEntry] of runtimeEntries) { if (archiveEntry.deleted || archiveEntry.materialized) continue; const zipFileName = @@ -5804,10 +6220,13 @@ export class MemoryFileSystem implements FileSystemBackend { atomicSnapshot?: SealedLazyAtomicSnapshot, ): Map { const pending = new Map(); + const ordinaryDefinition = this.ordinaryLazyTreeDefinitions.get(group); + const authoritativeEntries = atomicSnapshot?.entries ?? + ordinaryDefinition?.entries; const runtimeEntries: Array<[string, LazyArchiveFileEntry]> = - atomicSnapshot === undefined + authoritativeEntries === undefined ? Array.from(group.entries) - : atomicSnapshot.entries.map((entry) => [entry.vfsPath, entry]); + : authoritativeEntries.map((entry) => [entry.vfsPath, entry]); for (const [vfsPath, archiveEntry] of runtimeEntries) { if ( archiveEntry.deleted || @@ -5850,6 +6269,23 @@ export class MemoryFileSystem implements FileSystemBackend { group: LazyArchiveGroup, pending: ReadonlyMap, ): void { + const ordinaryDefinition = this.ordinaryLazyTreeDefinitions.get(group); + if (ordinaryDefinition !== undefined) { + const entries = ordinaryDefinition.entries.map((entry) => { + const key = entry.generation === undefined + ? undefined + : MemoryFileSystem.inodeKey(entry.ino, entry.generation); + if (key === undefined || !pending.has(key)) return entry; + this.lazyArchiveInodes.delete(key); + return { ...entry, materialized: true }; + }); + this.replaceOrdinaryLazyTreeRuntimeState( + group, + entries, + entries.every((entry) => entry.deleted || entry.materialized), + ); + return; + } for (const [key, replacement] of pending) { this.lazyArchiveInodes.delete(key); for (const alias of group.entries.values()) { @@ -6389,10 +6825,9 @@ export class MemoryFileSystem implements FileSystemBackend { const genericGroups = this.lazyArchiveGroups.filter((group) => { const atomicGroup = this.lazyAtomicGroupByTree.get(group); const snapshot = this.sealedLazyAtomicStates.get(group)?.snapshot; + const definition = this.ordinaryLazyTreeDefinitions.get(group); return snapshot === undefined - ? !group.materialized && - group.content !== undefined && - group.inventory !== undefined + ? definition !== undefined && !definition.materialized : !atomicGroup?.committed; }); if ( @@ -6419,10 +6854,9 @@ export class MemoryFileSystem implements FileSystemBackend { const pendingGenericTree = this.lazyArchiveGroups.some((group) => { const atomicGroup = this.lazyAtomicGroupByTree.get(group); const snapshot = this.sealedLazyAtomicStates.get(group)?.snapshot; + const definition = this.ordinaryLazyTreeDefinitions.get(group); return snapshot === undefined - ? !group.materialized && - group.content !== undefined && - group.inventory !== undefined + ? definition !== undefined && !definition.materialized : !atomicGroup?.committed; }); if ( @@ -6978,18 +7412,38 @@ export class MemoryFileSystem implements FileSystemBackend { const group = this.lazyArchiveInodes.get(key); if (group) { - const entry = group.entries.get(path); - if (removed.linkCount <= 1) { - for (const candidate of group.entries.values()) { - if ( + const ordinaryDefinition = this.ordinaryLazyTreeDefinitions.get(group); + if (ordinaryDefinition !== undefined) { + const entries = removed.linkCount <= 1 + ? ordinaryDefinition.entries.map((candidate) => candidate.ino === removed.ino && - candidate.generation === removed.generation + candidate.generation === removed.generation + ? { ...candidate, deleted: true } + : candidate ) - candidate.deleted = true; + : ordinaryDefinition.entries.filter((candidate) => + candidate.vfsPath !== path + ); + this.replaceOrdinaryLazyTreeRuntimeState( + group, + entries, + ordinaryDefinition.materialized, + ); + if (removed.linkCount <= 1) this.lazyArchiveInodes.delete(key); + } else { + const entry = group.entries.get(path); + if (removed.linkCount <= 1) { + for (const candidate of group.entries.values()) { + if ( + candidate.ino === removed.ino && + candidate.generation === removed.generation + ) + candidate.deleted = true; + } + this.lazyArchiveInodes.delete(key); + } else if (entry) { + group.entries.delete(path); } - this.lazyArchiveInodes.delete(key); - } else if (entry) { - group.entries.delete(path); } } } @@ -7033,12 +7487,35 @@ export class MemoryFileSystem implements FileSystemBackend { } const replacedGroup = this.lazyArchiveInodes.get(replacedKey); if (!reconciledNamespace && replacedGroup) { - const entry = replacedGroup.entries.get(newPath); - if (replaced.linkCount <= 1) { - if (entry) entry.deleted = true; - this.lazyArchiveInodes.delete(replacedKey); - } else if (entry) { - replacedGroup.entries.delete(newPath); + const ordinaryDefinition = + this.ordinaryLazyTreeDefinitions.get(replacedGroup); + if (ordinaryDefinition !== undefined) { + const entries = replaced.linkCount <= 1 + ? ordinaryDefinition.entries.map((candidate) => + candidate.ino === replaced.ino && + candidate.generation === replaced.generation + ? { ...candidate, deleted: true } + : candidate + ) + : ordinaryDefinition.entries.filter((candidate) => + candidate.vfsPath !== newPath + ); + this.replaceOrdinaryLazyTreeRuntimeState( + replacedGroup, + entries, + ordinaryDefinition.materialized, + ); + if (replaced.linkCount <= 1) { + this.lazyArchiveInodes.delete(replacedKey); + } + } else { + const entry = replacedGroup.entries.get(newPath); + if (replaced.linkCount <= 1) { + if (entry) entry.deleted = true; + this.lazyArchiveInodes.delete(replacedKey); + } else if (entry) { + replacedGroup.entries.delete(newPath); + } } } } @@ -7059,12 +7536,27 @@ export class MemoryFileSystem implements FileSystemBackend { const group = this.lazyArchiveInodes.get(key); if (group) { - const source = Array.from(group.entries.values()).find( - (entry) => + const ordinaryDefinition = this.ordinaryLazyTreeDefinitions.get(group); + if (ordinaryDefinition !== undefined) { + const source = ordinaryDefinition.entries.find((entry) => entry.ino === sourceIdentity.ino && - entry.generation === sourceIdentity.generation, - ); - if (source) group.entries.set(newPath, { ...source }); + entry.generation === sourceIdentity.generation + ); + if (source !== undefined) { + this.replaceOrdinaryLazyTreeRuntimeState( + group, + [...ordinaryDefinition.entries, { ...source, vfsPath: newPath }], + ordinaryDefinition.materialized, + ); + } + } else { + const source = Array.from(group.entries.values()).find( + (entry) => + entry.ino === sourceIdentity.ino && + entry.generation === sourceIdentity.generation, + ); + if (source) group.entries.set(newPath, { ...source }); + } } } diff --git a/host/test/homebrew-bottle-relocation.test.ts b/host/test/homebrew-bottle-relocation.test.ts new file mode 100644 index 0000000000..b7acf8855e --- /dev/null +++ b/host/test/homebrew-bottle-relocation.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { + parseHomebrewInstallReceiptRelocation, + relocateHomebrewBottleFile, +} from "../src/homebrew-bottle-relocation"; + +const AMBIENT_PREFIX = "/opt/kandelo/homebrew"; + +describe("Homebrew bottle receipt relocation", () => { + it.each([ + "/home/linuxbrew/.linuxbrew", + "/opt/kandelo/homebrew", + ])("uses the authenticated destination %s instead of an ambient Homebrew default", (destinationPrefix) => { + const source = new TextEncoder().encode([ + "prefix=@@HOMEBREW_PREFIX@@", + "library=@@HOMEBREW_LIBRARY@@", + "java=@@HOMEBREW_JAVA@@", + ].join("\n")); + const receipt = parseHomebrewInstallReceiptRelocation(new TextEncoder().encode(JSON.stringify({ + changed_files: ["lib/runtime.conf"], + runtime_dependencies: [{ full_name: "openjdk@21" }], + }))); + expect(new TextDecoder().decode(relocateHomebrewBottleFile(source, receipt, { + destinationPrefix, + path: `${destinationPrefix}/Cellar/runtime/1.0/lib/runtime.conf`, + }))).toBe([ + `prefix=${destinationPrefix}`, + `library=${destinationPrefix}/Library`, + `java=${destinationPrefix}/opt/openjdk@21/libexec`, + ].join("\n")); + }); + + it.each([ + "", + "relative/homebrew", + "/opt/../homebrew", + "/opt/./homebrew", + "/opt/kandelo\0homebrew", + ])("rejects an unsafe authenticated destination prefix %j", (destinationPrefix) => { + const receipt = parseHomebrewInstallReceiptRelocation(new TextEncoder().encode("{}")); + expect(() => relocateHomebrewBottleFile(new Uint8Array(), receipt, { + destinationPrefix, + path: "/untrusted/receipt", + })).toThrow(/destination prefix|guest prefix|unsafe path/i); + }); + + it("does not silently substitute the ambient prefix for an authenticated destination", () => { + const receipt = parseHomebrewInstallReceiptRelocation(new TextEncoder().encode(JSON.stringify({ + changed_files: ["lib/runtime.conf"], + }))); + const relocated = new TextDecoder().decode(relocateHomebrewBottleFile( + new TextEncoder().encode("@@HOMEBREW_PREFIX@@\n"), + receipt, + { + destinationPrefix: "/home/linuxbrew/.linuxbrew", + path: "/home/linuxbrew/.linuxbrew/Cellar/runtime/1.0/lib/runtime.conf", + }, + )); + + expect(relocated).not.toContain(AMBIENT_PREFIX); + expect(relocated).toBe("/home/linuxbrew/.linuxbrew\n"); + }); +}); diff --git a/host/test/homebrew-vfs-builder.test.ts b/host/test/homebrew-vfs-builder.test.ts index c0bc9171df..62d6c25d37 100644 --- a/host/test/homebrew-vfs-builder.test.ts +++ b/host/test/homebrew-vfs-builder.test.ts @@ -48,6 +48,8 @@ import { canonicalHomebrewRuntimeLayerBundleIdentityBytes, canonicalHomebrewRuntimeLayerDescriptorBytes, } from "../src/homebrew-lazy-layer-descriptor"; +import { adaptHomebrewDeferredTree } from + "../src/homebrew-deferred-tree-adapter"; import { HOMEBREW_RUNTIME_LAYER_LIMITS, parseHomebrewRuntimeLayerDescriptor, @@ -63,7 +65,11 @@ import { } from "../src/homebrew-vfs-planner"; import type { HomebrewRuntimeSupportContract } from "../src/homebrew-runtime-support"; -import { MemoryFileSystem } from "../src/vfs/memory-fs"; +import { + MemoryFileSystem, + type LazyArchiveFileEntry, + type LazyTreeGroup, +} from "../src/vfs/memory-fs"; import { derivePackageDeferredZipTree, registerPackageDeferredZipTree, @@ -554,6 +560,7 @@ function readVfsFile(fs: MemoryFileSystem, path: string): string { } async function lazyLayerFixture(options: { + destinationPrefix?: string; mutateBase?: (fs: MemoryFileSystem) => void; mutatePlan?: (plan: HomebrewVfsPlan) => void; mutateBaseSource?: (source: HomebrewLazyLayerBasePackageSource) => void; @@ -565,6 +572,8 @@ async function lazyLayerFixture(options: { runtimeReceipt?: string; runtimeExtraEntries?: TarSpec[]; } = {}) { + const destinationPrefix = options.destinationPrefix ?? PREFIX; + const destinationCellar = `${destinationPrefix}/Cellar`; const baseBytes = bottleTar(standardEntries()); const baseManifest = linkManifest(baseBytes); const basePlan = await planHomebrewVfs(metadataForBottle(baseBytes), { @@ -582,6 +591,15 @@ async function lazyLayerFixture(options: { kandeloCommit: basePlan.packages[0].kandeloCommit, formulaSha256: "6".repeat(64), }; + if (destinationPrefix !== PREFIX) { + const basePackage = basePlan.packages[0]; + basePackage.prefix = destinationPrefix; + basePackage.cellar = destinationCellar; + basePackage.keg = `${destinationCellar}/hello/${basePackage.version}`; + basePackage.linkManifest.prefix = destinationPrefix; + basePackage.linkManifest.cellar = destinationCellar; + basePackage.linkManifest.keg = basePackage.keg; + } const baseFs = MemoryFileSystem.create(new SharedArrayBuffer(16 * 1024 * 1024)); await buildHomebrewVfs(basePlan, { fs: baseFs, @@ -597,7 +615,7 @@ async function lazyLayerFixture(options: { options.mutateBase?.(baseFs); const runtimeVersion = "3.0"; - const runtimeKeg = `${CELLAR}/runtime/${runtimeVersion}`; + const runtimeKeg = `${destinationCellar}/runtime/${runtimeVersion}`; const runtimeBytes = bottleTar([ ...(options.overlappingDirectoryModes === undefined ? [] : [{ path: "Cellar/shared-runtime-state", @@ -676,7 +694,7 @@ async function lazyLayerFixture(options: { }, }; const dependencyVersion = "1.0"; - const dependencyKeg = `${CELLAR}/runtime-dep/${dependencyVersion}`; + const dependencyKeg = `${destinationCellar}/runtime-dep/${dependencyVersion}`; const dependencyBytes = bottleTar([ ...(options.overlappingDirectoryModes === undefined ? [] : [{ path: "Cellar/shared-runtime-state", @@ -852,12 +870,14 @@ async function lazyLayerFixture(options: { async function runtimeLayerConsumerFixture( options: { + destinationPrefix?: string; includeLayerDependency?: boolean; runtimeReceipt?: string; runtimeExtraEntries?: TarSpec[]; } = {}, ) { const fixture = await lazyLayerFixture({ + destinationPrefix: options.destinationPrefix, includeLayerDependency: options.includeLayerDependency, runtimeReceipt: options.runtimeReceipt, runtimeExtraEntries: options.runtimeExtraEntries, @@ -960,7 +980,7 @@ function refreshInventory(descriptor: HomebrewLazyLayerDescriptor): void { inventory.layer_entry_count = entries.filter( (entry) => entry.ownership === "layer", ).length; - if (descriptor.schema === 5) { + if (descriptor.schema !== 4) { inventory.mergeable_directory_count = entries.filter( (entry) => entry.ownership === "mergeable-directory", ).length; @@ -1040,6 +1060,16 @@ function runtimeLayerVariant( left.path < right.path ? -1 : left.path > right.path ? 1 : 0 ); } + if (tree.inventory.relocation !== undefined) { + const relocation = tree.inventory.relocation; + relocation.receipt_source_path = replaceName(relocation.receipt_source_path); + for (const assertion of relocation.materialization.assertions) { + assertion.sourcePath = replaceName(assertion.sourcePath); + } + for (const transform of relocation.materialization.transforms) { + transform.sourcePath = replaceName(transform.sourcePath); + } + } for (const entry of tree.inventory.entries) { entry.path = replaceName(entry.path); entry.source_path = replaceName(entry.source_path); @@ -1162,6 +1192,7 @@ function asLegacySharedDirectoryLayer( const tree = descriptorTree(descriptor); delete tree.package; delete tree.inventory.source; + delete tree.inventory.relocation; for (const entry of tree.inventory.entries) { delete entry.materialization; if (entry.path === sharedPath.slice(1)) { @@ -1512,6 +1543,23 @@ describe("Homebrew runtime layer consumer", () => { }], }); const tree = descriptorTree(fixture.descriptor); + expect(fixture.descriptor.schema).toBe(6); + expect(tree.inventory.relocation).toMatchObject({ + schema: 1, + kind: "homebrew-bottle-relocation-v1", + receipt_source_path: "runtime/3.0/INSTALL_RECEIPT.json", + materialization: { + schema: 1, + kind: "archive-byte-transforms-v1", + }, + }); + const adapted = adaptHomebrewDeferredTree(tree); + expect(adapted.decoder).toBe("tar-gzip-v1"); + expect(adapted.source?.kind).toBe("archive-source-inventory-v1"); + expect(adapted.materialization?.kind).toBe("archive-byte-transforms-v1"); + expect(adapted.entries.some((entry) => + (entry.materialization as string) === "archive-homebrew-relocate" + )).toBe(false); expect(tree.content.sha256).toBe(sha256(fixture.runtimeBytes)); expect(tree.content.bytes).toBe(fixture.runtimeBytes.byteLength); expect(tree.inventory.entries.filter((entry) => @@ -1530,6 +1578,13 @@ describe("Homebrew runtime layer consumer", () => { fetch: async () => new Response(runtime.bytes), archiveFetch: async () => new Response(fixture.runtimeBytes), }); + const exposed = composed.layers[0]!.deferredTrees.find((group) => + group.content?.materialization !== undefined + )!; + // WHY: the runtime returns these groups for status/diagnostics. They must + // never remain the authority for bytes after registration validates them. + (exposed.content!.materialization!.assertions[0] as { bytesHex: string }) + .bytesHex = "00"; await expect( composed.fs.ensureMaterialized(`${fixture.runtimeKeg}/lib/runtime.conf`), ).resolves.toBe(true); @@ -1545,6 +1600,220 @@ describe("Homebrew runtime layer consumer", () => { .toBe(0o640); }); + const homebrewEntryAuthorityMutations: readonly (readonly [ + label: string, + mutate: ( + group: LazyTreeGroup, + a: LazyArchiveFileEntry, + b: LazyArchiveFileEntry, + aPath: string, + ) => void, + ])[] = [ + ["archivePath", (_group, a, b) => a.archivePath = b.archivePath], + ["destination path", (group, a, _b, aPath) => { + group.entries.delete(aPath); + group.entries.set(`${aPath}.redirected`, a); + }], + ["entry presence", (group, _a, _b, aPath) => group.entries.delete(aPath)], + ["inode number", (_group, a, b) => a.ino = b.ino], + ["inode generation", (_group, a) => a.generation = 999_999], + ["data sequence", (_group, a) => { + a.dataSequence = (a.dataSequence ?? 0) + 1; + }], + ["size", (_group, a) => a.size = 0], + ["symlink kind", (_group, a) => a.isSymlink = true], + ["deletion state", (_group, a) => a.deleted = true], + ["materialization state", (_group, a) => a.materialized = true], + ["sourcePath", (_group, a, b) => a.sourcePath = b.sourcePath], + ["entry type", (_group, a) => a.type = "hardlink"], + ["inode group", (_group, a, b) => a.inodeGroup = b.inodeGroup], + ["link target", (_group, a, b) => a.target = b.sourcePath], + ["tree materialization state", (group) => group.materialized = true], + ["inventory mode", (group, _a, _b, aPath) => { + group.inventory!.find((entry) => entry.vfsPath === aPath)!.mode = 0; + }], + ]; + + it.each(homebrewEntryAuthorityMutations)( + "keeps Homebrew deferredTrees %s mutation out of authority", + async (_label, mutate) => { + const fixture = await runtimeLayerConsumerFixture({ + runtimeReceipt: JSON.stringify({ changed_files: [] }) + "\n", + runtimeExtraEntries: [{ + path: "runtime/3.0/lib/a", + data: "alpha", + mode: 0o640, + }, { + path: "runtime/3.0/lib/b", + data: "bravo", + mode: 0o640, + }], + }); + const runtime = runtimeLayerReference("runtime", fixture.descriptor); + const composed = await composeHomebrewRuntimeLayers({ + baseImageBytes: fixture.baseImageBytes, + arch: "wasm32", + kernelAbi: ABI_VERSION, + layers: [runtime.reference], + fetch: async () => new Response(runtime.bytes), + archiveFetch: async () => new Response(fixture.runtimeBytes), + }); + const aPath = `${fixture.runtimeKeg}/lib/a`; + const bPath = `${fixture.runtimeKeg}/lib/b`; + const exposed = composed.layers[0]!.deferredTrees.find((group) => + group.entries.has(aPath) + )!; + const a = exposed.entries.get(aPath)!; + const b = exposed.entries.get(bPath)!; + mutate(exposed, a, b, aPath); + + await expect(composed.fs.ensureMaterialized(aPath)).resolves.toBe(true); + expect(readVfsFile(composed.fs, aPath)).toBe("alpha"); + expect(readVfsFile(composed.fs, bPath)).toBe("bravo"); + expect(composed.fs.stat(aPath).mode & 0o7777).toBe(0o640); + }, + ); + + it("keeps Python Unicode-scalar order through the adapter and runtime", async () => { + const bmp = "\ue000"; + const nonBmp = "\u{10000}"; + const fixture = await runtimeLayerConsumerFixture({ + runtimeReceipt: JSON.stringify({ + changed_files: [`lib/${bmp}`, `lib/${nonBmp}`], + }) + "\n", + runtimeExtraEntries: [{ + path: `runtime/3.0/lib/${bmp}`, + data: "prefix=@@HOMEBREW_PREFIX@@\n", + mode: 0o640, + }, { + path: `runtime/3.0/lib/${nonBmp}`, + data: "cellar=@@HOMEBREW_CELLAR@@\n", + mode: 0o640, + }], + }); + const transforms = descriptorTree(fixture.descriptor).inventory.relocation! + .materialization.transforms; + expect(transforms.map((transform) => transform.sourcePath)).toEqual([ + `runtime/3.0/lib/${bmp}`, + `runtime/3.0/lib/${nonBmp}`, + ]); + + const runtime = runtimeLayerReference("runtime", fixture.descriptor); + const composed = await composeHomebrewRuntimeLayers({ + baseImageBytes: fixture.baseImageBytes, + arch: "wasm32", + kernelAbi: ABI_VERSION, + layers: [runtime.reference], + fetch: async () => new Response(runtime.bytes), + archiveFetch: async () => new Response(fixture.runtimeBytes), + }); + await expect( + composed.fs.ensureMaterialized(`${fixture.runtimeKeg}/lib/${bmp}`), + ).resolves.toBe(true); + expect(readVfsFile(composed.fs, `${fixture.runtimeKeg}/lib/${bmp}`)).toBe( + `prefix=${PREFIX}\n`, + ); + expect(readVfsFile(composed.fs, `${fixture.runtimeKeg}/lib/${nonBmp}`)).toBe( + `cellar=${CELLAR}\n`, + ); + }); + + it("materializes a historical receipt under its authenticated Linuxbrew destination", async () => { + const destinationPrefix = "/home/linuxbrew/.linuxbrew"; + const fixture = await runtimeLayerConsumerFixture({ + destinationPrefix, + runtimeReceipt: JSON.stringify({ + changed_files: ["INSTALL_RECEIPT.json", "lib/runtime.conf"], + runtime_dependencies: [{ full_name: "openjdk@21" }], + source: { path: "@@HOMEBREW_LIBRARY@@/Formula/runtime.rb" }, + }) + "\n", + runtimeExtraEntries: [{ + path: "runtime/3.0/lib/runtime.conf", + data: "prefix=@@HOMEBREW_PREFIX@@\njava=@@HOMEBREW_JAVA@@\n", + mode: 0o640, + }], + }); + expect([ + ...fixture.descriptor.packages.base, + ...fixture.descriptor.packages.layer, + ].every((pkg) => pkg.prefix === destinationPrefix)).toBe(true); + expect(descriptorTree(fixture.descriptor).activation.roots).toEqual([ + fixture.runtimeKeg, + ]); + expect(descriptorTree(fixture.descriptor).inventory.entries.every((entry) => + entry.path === destinationPrefix.slice(1) || + entry.path.startsWith(`${destinationPrefix.slice(1)}/`) + )).toBe(true); + + const runtime = runtimeLayerReference("runtime", fixture.descriptor); + const composed = await composeHomebrewRuntimeLayers({ + baseImageBytes: fixture.baseImageBytes, + arch: "wasm32", + kernelAbi: ABI_VERSION, + layers: [runtime.reference], + fetch: async () => new Response(runtime.bytes), + archiveFetch: async () => new Response(fixture.runtimeBytes), + }); + await expect( + composed.fs.ensureMaterialized(`${fixture.runtimeKeg}/lib/runtime.conf`), + ).resolves.toBe(true); + expect(readVfsFile(composed.fs, `${fixture.runtimeKeg}/lib/runtime.conf`)).toBe( + `prefix=${destinationPrefix}\njava=${destinationPrefix}/opt/openjdk@21/libexec\n`, + ); + expect(JSON.parse( + readVfsFile(composed.fs, `${fixture.runtimeKeg}/INSTALL_RECEIPT.json`), + )).toMatchObject({ + source: { path: `${destinationPrefix}/Library/Formula/runtime.rb` }, + }); + }); + + it("rejects a receipt-relocated destination that drifts from its authenticated keg", async () => { + const fixture = await runtimeLayerConsumerFixture({ + runtimeReceipt: JSON.stringify({ + changed_files: ["INSTALL_RECEIPT.json"], + source: { path: "@@HOMEBREW_LIBRARY@@/Formula/runtime.rb" }, + }) + "\n", + }); + const tree = descriptorTree(fixture.descriptor); + const receipt = tree.inventory.entries.find((entry) => + entry.source_path === "runtime/3.0/INSTALL_RECEIPT.json" + )!; + receipt.path = `${PREFIX.slice(1)}/Cellar/runtime/3.0/drifted-receipt.json`; + receipt.source_path = "runtime/3.0/INSTALL_RECEIPT.json"; + tree.inventory.entries.sort((left, right) => + left.path < right.path ? -1 : left.path > right.path ? 1 : 0 + ); + recloseRuntimeLayerDescriptor(fixture.descriptor); + const { reference, bytes } = runtimeLayerReference("runtime", fixture.descriptor); + + await expect(composeHomebrewRuntimeLayers({ + baseImageBytes: fixture.baseImageBytes, + arch: "wasm32", + kernelAbi: ABI_VERSION, + layers: [reference], + fetch: async () => new Response(bytes), + })).rejects.toThrow(/receipt|destination|prefix/i); + }); + + it("rejects runtime layers that mix authenticated base and layer destinations", async () => { + const fixture = await runtimeLayerConsumerFixture(); + const legacyPrefix = "/home/linuxbrew/.linuxbrew"; + const base = fixture.descriptor.packages.base[0]!; + base.prefix = legacyPrefix; + base.keg = `${legacyPrefix}/Cellar/${base.name}/${base.version}`; + base.opt_link.target = `../Cellar/${base.name}/${base.version}`; + recloseRuntimeLayerDescriptor(fixture.descriptor); + const { reference, bytes } = runtimeLayerReference("runtime", fixture.descriptor); + + await expect(composeHomebrewRuntimeLayers({ + baseImageBytes: fixture.baseImageBytes, + arch: "wasm32", + kernelAbi: ABI_VERSION, + layers: [reference], + fetch: async () => new Response(bytes), + })).rejects.toThrow(/package destinations are inconsistent/); + }); + it("accepts upstream null changed_files as an empty relocation set", async () => { const fixture = await runtimeLayerConsumerFixture({ runtimeReceipt: JSON.stringify({ changed_files: null }) + "\n", @@ -1618,17 +1887,14 @@ describe("Homebrew runtime layer consumer", () => { executable.materialization = "archive-homebrew-relocate"; recloseRuntimeLayerDescriptor(fixture.descriptor); const runtime = runtimeLayerReference("runtime", fixture.descriptor); - const composed = await composeHomebrewRuntimeLayers({ + await expect(composeHomebrewRuntimeLayers({ baseImageBytes: fixture.baseImageBytes, arch: "wasm32", kernelAbi: ABI_VERSION, layers: [runtime.reference], fetch: async () => new Response(runtime.bytes), archiveFetch: async () => new Response(fixture.runtimeBytes), - }); - await expect( - composed.fs.ensureMaterialized(`${fixture.runtimeKeg}/bin/runtime`), - ).rejects.toThrow(/relocation markers differ from INSTALL_RECEIPT.json/); + })).rejects.toThrow(/relocation markers differ from INSTALL_RECEIPT.json/); }); it("rejects a descriptor that hides every relocation named by its bottle receipt", async () => { @@ -1655,18 +1921,14 @@ describe("Homebrew runtime layer consumer", () => { refreshInventory(fixture.descriptor); recloseRuntimeLayerDescriptor(fixture.descriptor); const runtime = runtimeLayerReference("runtime", fixture.descriptor); - const composed = await composeHomebrewRuntimeLayers({ + await expect(composeHomebrewRuntimeLayers({ baseImageBytes: fixture.baseImageBytes, arch: "wasm32", kernelAbi: ABI_VERSION, layers: [runtime.reference], fetch: async () => new Response(runtime.bytes), archiveFetch: async () => new Response(fixture.runtimeBytes), - }); - - await expect( - composed.fs.ensureMaterialized(`${fixture.runtimeKeg}/lib/runtime.conf`), - ).rejects.toThrow(/relocation markers differ from INSTALL_RECEIPT.json/); + })).rejects.toThrow(/relocation markers differ from INSTALL_RECEIPT.json/); }); it("composes disjoint selected layers while leaving both archives lazy", async () => { @@ -1724,7 +1986,7 @@ describe("Homebrew runtime layer consumer", () => { } }); - it("reuses an existing real base directory for a schema-5 mergeable claim", async () => { + it("reuses an existing real base directory for a schema-6 mergeable claim", async () => { const fixture = await runtimeLayerConsumerFixture(); const sharedPath = `${PREFIX}/shared-prefix`; const base = MemoryFileSystem.fromImage(fixture.baseImageBytes); @@ -1913,13 +2175,27 @@ describe("Homebrew runtime layer consumer", () => { const missingSource = structuredClone(fixture.descriptor); delete descriptorTree(missingSource).inventory.source; expect(() => recloseRuntimeLayerDescriptor(missingSource)).toThrow( - /schema 5 tree .* is not a complete original bottle/, + /schema 6 tree .* is not a complete original bottle/, ); const missingBinding = structuredClone(fixture.descriptor); delete descriptorTree(missingBinding).package; expect(() => recloseRuntimeLayerDescriptor(missingBinding)).toThrow( - /schema 5 tree .* is not a complete original bottle/, + /schema 6 tree .* is not a complete original bottle/, + ); + + const planUnderSchemaFive = structuredClone(fixture.descriptor); + planUnderSchemaFive.schema = 5; + expect(() => recloseRuntimeLayerDescriptor(planUnderSchemaFive)).toThrow( + /schema 5 tree .* cannot carry a schema-6 relocation plan/, + ); + + const staleRelocation = structuredClone(fixture.descriptor); + staleRelocation.schema = 5; + delete descriptorTree(staleRelocation).inventory.relocation; + recloseRuntimeLayerDescriptor(staleRelocation); + expect(() => parseHomebrewRuntimeLayerDescriptor(staleRelocation)).toThrow( + /receipt relocation requires a schema-6 adapter plan/, ); const legacy = asLegacySharedDirectoryLayer( @@ -1929,7 +2205,7 @@ describe("Homebrew runtime layer consumer", () => { expect(() => parseHomebrewRuntimeLayerDescriptor(legacy)).not.toThrow(); const legacyUnderDirectSchema = structuredClone(legacy); - legacyUnderDirectSchema.schema = 5; + legacyUnderDirectSchema.schema = 6; expect(() => parseHomebrewRuntimeLayerDescriptor(legacyUnderDirectSchema)) .toThrow(/deferred tree 0 has unexpected or missing fields/); }); @@ -3255,7 +3531,7 @@ describe("Homebrew VFS builder", () => { base_package_order: ["kandelo-dev/tap-core/hello"], layer_package_order: ["kandelo-dev/tap-core/runtime"], }); - expect(first.descriptor.schema).toBe(5); + expect(first.descriptor.schema).toBe(6); expect(first.descriptor.kind).toBe("kandelo-homebrew-deferred-layer-draft"); expect(first.descriptor.base_vfs).toMatchObject({ sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", @@ -3483,6 +3759,89 @@ describe("Homebrew VFS builder", () => { ); }); + it("publishes a legacy-prefix bottle collection from its authenticated destination", async () => { + const legacyPrefix = "/home/linuxbrew/.linuxbrew"; + const fixture = await lazyLayerFixture({ + runtimeReceipt: JSON.stringify({ + changed_files: ["INSTALL_RECEIPT.json", "lib/runtime.conf"], + source: { path: "@@HOMEBREW_LIBRARY@@/Formula/runtime.rb" }, + }) + "\n", + runtimeExtraEntries: [{ + path: "runtime/3.0/lib/runtime.conf", + data: "prefix=@@HOMEBREW_PREFIX@@\n", + }], + }); + const runtime = structuredClone( + fixture.plan.packages.find((pkg) => pkg.name === "runtime")!, + ); + runtime.prefix = legacyPrefix; + runtime.cellar = `${legacyPrefix}/Cellar`; + runtime.keg = `${runtime.cellar}/runtime/${runtime.version}`; + runtime.linkManifest.prefix = runtime.prefix; + runtime.linkManifest.cellar = runtime.cellar; + runtime.linkManifest.keg = runtime.keg; + + const collection = await buildHomebrewOriginalBottleCollection({ + ...fixture.plan, + packages: [runtime], + }, { + fs: MemoryFileSystem.create(new SharedArrayBuffer(16 * 1024 * 1024)), + baseFs: fixture.baseFs, + loadBottleBytes: () => fixture.runtimeBytes, + }); + + expect(collection.packages[0]?.prefix).toBe(legacyPrefix); + expect(collection.deferredTrees[0]?.activation.roots).toEqual([runtime.keg]); + expect(collection.deferredTrees[0]?.inventory.entries).toContainEqual( + expect.objectContaining({ + path: `${legacyPrefix.slice(1)}/Cellar/runtime/3.0/lib/runtime.conf`, + materialization: "archive-homebrew-relocate", + }), + ); + }); + + it("rejects a bottle collection with inconsistent authenticated destinations", async () => { + const fixture = await lazyLayerFixture({ includeLayerDependency: true }); + const runtime = structuredClone( + fixture.plan.packages.find((pkg) => pkg.name === "runtime")!, + ); + runtime.prefix = "/home/linuxbrew/.linuxbrew"; + runtime.cellar = `${runtime.prefix}/Cellar`; + runtime.keg = `${runtime.cellar}/runtime/${runtime.version}`; + runtime.linkManifest.prefix = runtime.prefix; + runtime.linkManifest.cellar = runtime.cellar; + runtime.linkManifest.keg = runtime.keg; + const dependency = fixture.plan.packages.find((pkg) => pkg.name === "runtime-dep")!; + + await expect(buildHomebrewOriginalBottleCollection({ + ...fixture.plan, + packages: [dependency, runtime], + }, { + fs: MemoryFileSystem.create(new SharedArrayBuffer(16 * 1024 * 1024)), + baseFs: fixture.baseFs, + loadBottleBytes: () => fixture.runtimeBytes, + })).rejects.toThrow(/inconsistent bottle destinations/); + }); + + it("rejects a mixed authenticated base and lazy-layer partition before publication", async () => { + const legacyPrefix = "/home/linuxbrew/.linuxbrew"; + const fixture = await lazyLayerFixture({ + mutatePlan(plan) { + const runtime = plan.packages.find((pkg) => pkg.name === "runtime")!; + runtime.prefix = legacyPrefix; + runtime.cellar = `${legacyPrefix}/Cellar`; + runtime.keg = `${runtime.cellar}/runtime/${runtime.version}`; + runtime.linkManifest.prefix = runtime.prefix; + runtime.linkManifest.cellar = runtime.cellar; + runtime.linkManifest.keg = runtime.keg; + }, + }); + + await expect(fixture.build()).rejects.toThrow( + /inconsistent bottle destinations/, + ); + }); + it.each(["member", "cohort"] as const)( "rejects a forged imported %s seal before generic collection bottle loading or writes", async (forgery) => { diff --git a/host/test/lazy-tree.test.ts b/host/test/lazy-tree.test.ts index 0d3a38605d..3bf9a15e9e 100644 --- a/host/test/lazy-tree.test.ts +++ b/host/test/lazy-tree.test.ts @@ -12,6 +12,14 @@ import { VFS_DEFERRED_TREE_COLLECTION_LIMITS, VFS_DEFERRED_TREE_LIMITS, } from "../src/vfs/deferred-tree-limits"; +import { + applyLazyTreeByteTransformRecipe, + decodeMaterializationBytes, + encodeMaterializationBytes, + validateLazyTreeMaterializationPlan, + type LazyTreeMaterializationPlan, + type LazyTreeMaterializationSourceInventory, +} from "../src/vfs/materialization-plan"; const BLOCK = 512; const O_WRONLY_CREAT = 0x0041; @@ -221,6 +229,314 @@ describe("format-neutral deferred trees", () => { expect(afterTarget.nlink).toBe(2); }); + it("validates and applies the exact generic byte-transform contract", () => { + const plan = exactGenericMaterializationPlan(); + const inventory: LazyTreeMaterializationSourceInventory = { + entries: [{ + sourcePath: "bin/tool", + type: "file", + size: 5, + }], + }; + + expect(validateLazyTreeMaterializationPlan(plan, inventory)).toEqual(plan); + expect( + decoder.decode(applyLazyTreeByteTransformRecipe( + encoder.encode("/old/"), + plan.recipes[0]!, + )), + ).toBe("/new/"); + expect(decodeMaterializationBytes(encodeMaterializationBytes( + new Uint8Array([0, 1, 15, 16, 255]), + ))).toEqual(new Uint8Array([0, 1, 15, 16, 255])); + expect(decoder.decode(applyLazyTreeByteTransformRecipe( + encoder.encode("x/x"), + { + id: "length-changing", + replacements: [{ matchHex: "78", replacementHex: "616263" }], + rejectHex: ["78"], + }, + ))).toBe("abc/abc"); + expect(() => applyLazyTreeByteTransformRecipe( + new Uint8Array(32_769), + { + id: "bounded-expansion", + replacements: [{ + matchHex: "00", + replacementHex: "01".repeat(8192), + }], + rejectHex: ["00"], + }, + )).toThrow(/transformed-byte limit/); + }); + + it("rejects malformed or unbounded generic materialization plans", () => { + const inventory: LazyTreeMaterializationSourceInventory = { + entries: [{ + sourcePath: "bin/tool", + type: "file", + size: 5, + }], + }; + const cases: Array<{ + label: string; + mutate: (plan: Record) => void; + error: RegExp; + }> = [{ + label: "unknown key", + mutate: (plan) => plan.unexpected = true, + error: /unexpected fields/, + }, { + label: "duplicate recipe", + mutate: (plan) => plan.recipes.push(structuredClone(plan.recipes[0])), + error: /recipe 1 id is invalid|duplicates recipe/, + }, { + label: "duplicate transform", + mutate: (plan) => plan.transforms.push(structuredClone(plan.transforms[0])), + error: /repeats transform/, + }, { + label: "odd hexadecimal bytes", + mutate: (plan) => plan.assertions[0].bytesHex = "0", + error: /canonical bounded hexadecimal bytes/, + }, { + label: "non-hexadecimal bytes", + mutate: (plan) => plan.assertions[0].bytesHex = "zz", + error: /canonical bounded hexadecimal bytes/, + }, { + label: "replacement count", + mutate: (plan) => { + plan.recipes[0].replacements = new Array(33).fill({ + matchHex: "00", + replacementHex: "00", + }); + }, + error: /replacements must contain 0 to 32 items/, + }, { + label: "missing source member", + mutate: (plan) => plan.transforms[0].sourcePath = "bin/missing", + error: /not a regular source/, + }, { + label: "unsafe source path", + mutate: (plan) => plan.transforms[0].sourcePath = "bin/../tool", + error: /canonical relative path/, + }, { + label: "decoded plan byte budget", + mutate: (plan) => plan.assertions[0].bytesHex = "00".repeat(1_048_577), + error: /decoded byte limit|canonical bounded hexadecimal bytes/, + }]; + + for (const testCase of cases) { + const plan = structuredClone(exactGenericMaterializationPlan()) as + unknown as Record; + testCase.mutate(plan); + expect( + () => validateLazyTreeMaterializationPlan(plan, inventory), + testCase.label, + ).toThrow(testCase.error); + } + }); + + it("uses Unicode-scalar order for generic source inventories and plans", () => { + const bmp = "\ue000"; + const nonBmp = "\u{10000}"; + const sourceEntries = [ + { sourcePath: bmp, type: "file" as const, size: 1 }, + { sourcePath: nonBmp, type: "file" as const, size: 1 }, + ]; + const plan: LazyTreeMaterializationPlan = { + schema: 1, + kind: "archive-byte-transforms-v1", + assertions: sourceEntries.map((entry) => ({ + sourcePath: entry.sourcePath, + bytesHex: "78", + })), + recipes: [], + transforms: [], + }; + expect(validateLazyTreeMaterializationPlan(plan, { entries: sourceEntries })) + .toEqual(plan); + expect(() => validateLazyTreeMaterializationPlan({ + ...plan, + assertions: [...plan.assertions].reverse(), + }, { entries: sourceEntries })).toThrow(/canonical order/); + expect(() => validateLazyTreeMaterializationPlan({ + ...plan, + assertions: [{ sourcePath: "\ud800", bytesHex: "78" }], + }, { + entries: [{ sourcePath: "\ud800", type: "file", size: 1 }], + })).toThrow(/Unicode scalar values/); + + const specs: TarSpec[] = sourceEntries.map((entry) => ({ + path: entry.sourcePath, + mode: 0o644, + data: "x", + })); + const tar = tarBytes(specs); + const payload = gzipSync(tar); + const content = { + decoder: "tar-gzip-v1" as const, + mediaType: "application/vnd.oci.image.layer.v1.tar+gzip" as const, + sha256: createHash("sha256").update(payload).digest("hex"), + bytes: payload.byteLength, + expandedBytes: tar.byteLength, + sourceEntryCount: sourceEntries.length, + transports: ["https://example.invalid/unicode-scalars.tar.gz"], + source: { + schema: 1 as const, + kind: "archive-source-inventory-v1" as const, + entries: sourceEntries.map((entry) => ({ + ...entry, + mode: 0o644, + })), + }, + }; + const inventory = sourceEntries.map((entry) => ({ + vfsPath: `/${entry.sourcePath}`, + sourcePath: entry.sourcePath, + materialization: "archive" as const, + type: "file" as const, + mode: 0o644, + size: 1, + inodeGroup: `unicode:${entry.sourcePath}`, + })); + expect(() => createFs().registerLazyTree(content, inventory)).not.toThrow(); + expect(() => createFs().registerLazyTree({ + ...content, + source: { + ...content.source, + entries: [...content.source.entries].reverse(), + }, + }, inventory)).toThrow(/canonical path order/); + }); + + it("materializes a transformed generic TAR identically in eager and lazy paths", async () => { + const fixture = transformedGenericTarTreeFixture(); + const lazy = createFs(); + lazy.registerLazyTree( + fixture.content, + fixture.inventory, + "/srv", + fixture.activation, + ); + lazy.setLazyFetcher(async () => new Response(fixture.payload)); + await expect(lazy.preparePath("/srv/bin/tool-alias")).resolves.toBe(true); + + const eager = createFs(); + const handle = eager.registerLazyTreeWithMaterializationHandle( + { ...fixture.content, transports: [] }, + fixture.inventory, + "/srv", + fixture.activation, + ); + await expect( + eager.materializeRegisteredDeferredTree(handle, fixture.payload), + ).resolves.toBe(true); + + for (const path of [ + "/srv", + "/srv/bin", + "/srv/bin/tool", + "/srv/bin/tool-alias", + "/srv/current", + ]) { + const lazyStat = lazy.lstat(path); + const eagerStat = eager.lstat(path); + expect(lazyStat.mode & 0o177777).toBe(eagerStat.mode & 0o177777); + expect(lazyStat.size).toBe(eagerStat.size); + } + expect(readText(lazy, "/srv/bin/tool")).toBe("/new/"); + expect(readText(eager, "/srv/bin/tool")).toBe("/new/"); + expect(lazy.readlink("/srv/current")).toBe("bin/tool"); + expect(eager.readlink("/srv/current")).toBe("bin/tool"); + expect(lazy.lstat("/srv/bin/tool").ino).toBe( + lazy.lstat("/srv/bin/tool-alias").ino, + ); + expect(eager.lstat("/srv/bin/tool").ino).toBe( + eager.lstat("/srv/bin/tool-alias").ino, + ); + }); + + it("fails before publication when transform input or output identity drifts", async () => { + const fixture = transformedGenericTarTreeFixture(); + for (const field of ["input", "output"] as const) { + const content = structuredClone(fixture.content); + content.materialization.transforms[0]![field].sha256 = "0".repeat(64); + const fs = createFs(); + fs.registerLazyTree(content, fixture.inventory, "/srv", fixture.activation); + fs.setLazyFetcher(async () => new Response(fixture.payload)); + + await expect(fs.preparePath("/srv/bin/tool"), field).rejects.toThrow( + new RegExp(`transform .* ${field} SHA-256`), + ); + expect(fs.isPathDeferred("/srv/bin/tool")).toBe(true); + } + + const content = structuredClone(fixture.content); + content.materialization.transforms[0]!.output.bytes = 4; + const inventory = structuredClone(fixture.inventory); + for (const entry of inventory) { + if (entry.type === "file" || entry.type === "hardlink") entry.size = 4; + } + const fs = createFs(); + fs.registerLazyTree(content, inventory, "/srv", fixture.activation); + fs.setLazyFetcher(async () => new Response(fixture.payload)); + await expect(fs.preparePath("/srv/bin/tool")).rejects.toThrow( + /transform .* output byte count 5 does not match expected 4/, + ); + expect(fs.isPathDeferred("/srv/bin/tool")).toBe(true); + }); + + it("preserves a replacement across transformed generation cleanup", async () => { + const fixture = transformedGenericTarTreeFixture(); + const fs = createFs(); + fs.registerLazyTree( + fixture.content, + fixture.inventory, + "/srv", + fixture.activation, + ); + const peer = MemoryFileSystem.fromExisting(fs.sharedBuffer); + peer.unlink("/srv/bin/tool"); + const fd = peer.open("/srv/bin/tool", O_WRONLY_CREAT, 0o600); + const replacement = encoder.encode("replacement\n"); + expect(peer.write(fd, replacement, null, replacement.byteLength)) + .toBe(replacement.byteLength); + peer.close(fd); + fs.setLazyFetcher(async () => new Response(fixture.payload)); + + await expect(fs.preparePath("/srv/bin/tool-alias")).resolves.toBe(true); + expect(readText(fs, "/srv/bin/tool")).toBe("replacement\n"); + expect(readText(fs, "/srv/bin/tool-alias")).toBe("/new/"); + expect(fs.lstat("/srv/bin/tool").ino).not.toBe( + fs.lstat("/srv/bin/tool-alias").ino, + ); + expect(fs.exportLazyArchiveEntries()).toEqual([]); + }); + + it("preserves a generic plan through image restore and rebase", async () => { + const fixture = transformedGenericTarTreeFixture(); + const source = createFs(); + source.registerLazyTree( + fixture.content, + fixture.inventory, + "/srv", + fixture.activation, + ); + const restored = MemoryFileSystem.fromImage(await source.saveImage()); + expect(restored.exportLazyArchiveEntries()[0]!.content!.materialization) + .toEqual(fixture.content.materialization); + expect(JSON.stringify(restored.exportLazyArchiveEntries())).not.toMatch( + /homebrew|bottle|receipt|Cellar|keg|Formula/, + ); + + const rebased = restored.rebaseToNewFileSystem(8 * 1024 * 1024); + expect(rebased.exportLazyArchiveEntries()[0]!.content!.materialization) + .toEqual(fixture.content.materialization); + rebased.setLazyFetcher(async () => new Response(fixture.payload)); + await expect(rebased.preparePath("/srv/bin/tool")).resolves.toBe(true); + expect(readText(rebased, "/srv/bin/tool")).toBe("/new/"); + }); + it("commits an atomic activation group only after every tree validates", async () => { const bootstrap = tarTreeFixture("first-use", "bootstrap"); const runtime = tarTreeFixture("first-use", "runtime"); @@ -837,6 +1153,250 @@ describe("format-neutral deferred trees", () => { } }); + it.each([ + ["plan", (group: LazyTreeGroup) => { + group.content!.materialization = { + ...group.content!.materialization!, + assertions: [{ sourcePath: "bin/tool", bytesHex: "00" }], + }; + }], + ["assertions", (group: LazyTreeGroup) => { + (group.content!.materialization!.assertions[0] as { bytesHex: string }) + .bytesHex = "00"; + }], + ["recipes", (group: LazyTreeGroup) => { + (group.content!.materialization!.recipes[0]!.replacements[0] as { + replacementHex: string; + }).replacementHex = encodeMaterializationBytes(encoder.encode("/evil/")); + }], + ["transforms", (group: LazyTreeGroup) => { + (group.content!.materialization!.transforms[0]!.output as { bytes: number }) + .bytes = 1; + }], + ["source inventory", (group: LazyTreeGroup) => { + group.content!.source!.entries[1]!.sourcePath = "bin/substitute"; + }], + ["registration inventory", (group: LazyTreeGroup) => { + group.inventory!.find((entry) => entry.type === "file")!.sourcePath = + "bin/substitute"; + }], + ] as const)( + "materializes from its private snapshot after exposed %s mutation", + async (_label, mutate) => { + const fixture = transformedGenericTarTreeFixture(); + const fs = createFs(); + const group = fs.registerLazyTree( + fixture.content, + fixture.inventory, + "/srv", + fixture.activation, + ); + mutate(group); + fs.setLazyFetcher(async () => new Response(fixture.payload)); + + await expect(fs.preparePath("/srv/bin/tool")).resolves.toBe(true); + expect(readText(fs, "/srv/bin/tool")).toBe("/new/"); + }, + ); + + it("exports, restores, and rebases the private ordinary-tree snapshot", async () => { + const fixture = transformedGenericTarTreeFixture(); + const fs = createFs(); + const group = fs.registerLazyTree( + fixture.content, + fixture.inventory, + "/srv", + fixture.activation, + ); + (group.content!.materialization!.assertions[0] as { bytesHex: string }) + .bytesHex = "00"; + group.content!.source!.entries[1]!.sourcePath = "bin/substitute"; + group.inventory!.find((entry) => entry.type === "file")!.sourcePath = + "bin/substitute"; + + const exported = fs.exportLazyArchiveEntries(); + expect(exported[0]!.content!.materialization).toEqual( + fixture.content.materialization, + ); + expect(exported[0]!.content!.source).toEqual(fixture.content.source); + expect(exported[0]!.inventory).toEqual(fixture.inventory); + + const restored = MemoryFileSystem.fromImage(await fs.saveImage()); + const rebased = fs.rebaseToNewFileSystem(8 * 1024 * 1024); + for (const candidate of [fs, restored, rebased]) { + candidate.setLazyFetcher(async () => new Response(fixture.payload)); + await expect(candidate.preparePath("/srv/bin/tool")).resolves.toBe(true); + expect(readText(candidate, "/srv/bin/tool")).toBe("/new/"); + } + }); + + it("does not let an ordinary public archivePath substitute ZIP members", async () => { + const fixture = zipTreeFixture("edge", [ + ["a", "alpha"], + ["b", "bravo"], + ]); + const fs = createFs(); + const group = fs.registerLazyTree( + fixture.content, + fixture.inventory, + "/", + fixture.activation, + ); + group.entries.get("/edge/a")!.archivePath = + group.entries.get("/edge/b")!.archivePath; + fs.setLazyFetcher(async () => new Response(fixture.payload)); + + await expect(fs.preparePath("/edge/a")).resolves.toBe(true); + expect(readText(fs, "/edge/a")).toBe("alpha"); + expect(readText(fs, "/edge/b")).toBe("bravo"); + }); + + const ordinaryEntryAuthorityMutations: readonly (readonly [ + label: string, + mutate: ( + group: LazyTreeGroup, + a: LazyArchiveFileEntry, + b: LazyArchiveFileEntry, + ) => void, + ])[] = [ + ["archivePath", (_group, a, b) => a.archivePath = b.archivePath], + ["destination path", (group, a) => { + group.entries.delete("/edge/a"); + group.entries.set("/edge/redirected", a); + }], + ["entry presence", (group) => group.entries.delete("/edge/a")], + ["inode number", (_group, a, b) => a.ino = b.ino], + ["inode generation", (_group, a) => a.generation = 999_999], + ["data sequence", (_group, a) => { + a.dataSequence = (a.dataSequence ?? 0) + 1; + }], + ["size", (_group, a) => a.size = 0], + ["symlink kind", (_group, a) => a.isSymlink = true], + ["deletion state", (_group, a) => a.deleted = true], + ["materialization state", (_group, a) => a.materialized = true], + ["sourcePath", (_group, a, b) => a.sourcePath = b.sourcePath], + ["entry type", (_group, a) => a.type = "hardlink"], + ["inode group", (_group, a, b) => a.inodeGroup = b.inodeGroup], + ["link target", (_group, a, b) => a.target = b.sourcePath], + ["tree materialization state", (group) => group.materialized = true], + ["inventory mode", (group) => { + group.inventory!.find((entry) => entry.vfsPath === "/edge/a")!.mode = 0; + }], + ]; + + it.each(ordinaryEntryAuthorityMutations)( + "keeps ordinary %s mutation out of entry authority", + async (_label, mutate) => { + const fixture = zipTreeFixture("edge", [ + ["a", "alpha"], + ["b", "bravo"], + ]); + const register = () => { + const fs = createFs(); + const group = fs.registerLazyTree( + fixture.content, + fixture.inventory, + "/", + fixture.activation, + ); + const a = group.entries.get("/edge/a")!; + const b = group.entries.get("/edge/b")!; + const expectedIdentity = { + ino: a.ino, + generation: a.generation, + dataSequence: a.dataSequence, + }; + mutate(group, a, b); + return { fs, group, expectedIdentity }; + }; + + const materialized = register(); + materialized.fs.setLazyFetcher( + async () => new Response(fixture.payload), + ); + await expect(materialized.fs.preparePath("/edge/a")).resolves.toBe(true); + expect(readText(materialized.fs, "/edge/a")).toBe("alpha"); + expect(readText(materialized.fs, "/edge/b")).toBe("bravo"); + expect(materialized.fs.stat("/edge/a").mode & 0o7777).toBe(0o755); + + const exported = register(); + const serialized = exported.fs.exportLazyArchiveEntries()[0]!; + const serializedA = serialized.entries.find( + (entry) => entry.vfsPath === "/edge/a", + )!; + expect(serializedA).toMatchObject({ + vfsPath: "/edge/a", + ...exported.expectedIdentity, + size: 5, + isSymlink: false, + deleted: false, + materialized: false, + archivePath: "edge/a", + sourcePath: "edge/a", + type: "file", + inodeGroup: "edge:a", + }); + expect(serializedA.target).toBeUndefined(); + expect(serialized.inventory!.find( + (entry) => entry.vfsPath === "/edge/a", + )!.mode).toBe(0o755); + }, + ); + + it("keeps authorized rename, link, and chmod live during first use", async () => { + const fixture = zipTreeFixture("edge", [ + ["a", "alpha"], + ["b", "bravo"], + ]); + const fs = createFs(); + fs.registerLazyTree(fixture.content, fixture.inventory, "/", fixture.activation); + fs.rename("/edge/a", "/edge/moved"); + fs.link("/edge/moved", "/edge/alias"); + fs.chmod("/edge/moved", 0o640); + fs.setLazyFetcher(async () => new Response(fixture.payload)); + + await expect(fs.preparePath("/edge/alias")).resolves.toBe(true); + expect(readText(fs, "/edge/moved")).toBe("alpha"); + expect(readText(fs, "/edge/alias")).toBe("alpha"); + expect(readText(fs, "/edge/b")).toBe("bravo"); + expect(fs.stat("/edge/moved").mode & 0o7777).toBe(0o640); + }); + + it("preserves a replacement inode after authorized unlink", async () => { + const fixture = zipTreeFixture("edge", [ + ["a", "alpha"], + ["b", "bravo"], + ]); + const fs = createFs(); + fs.registerLazyTree(fixture.content, fixture.inventory, "/", fixture.activation); + fs.unlink("/edge/a"); + fs.createFileWithOwner("/edge/a", 0o600, 0, 0, encoder.encode("local")); + fs.setLazyFetcher(async () => new Response(fixture.payload)); + + await expect(fs.preparePath("/edge/b")).resolves.toBe(true); + expect(readText(fs, "/edge/a")).toBe("local"); + expect(readText(fs, "/edge/b")).toBe("bravo"); + }); + + it("restores and rebases an authorized ordinary-tree rename", async () => { + const fixture = zipTreeFixture("edge", [ + ["a", "alpha"], + ["b", "bravo"], + ]); + const fs = createFs(); + fs.registerLazyTree(fixture.content, fixture.inventory, "/", fixture.activation); + fs.rename("/edge/a", "/edge/moved"); + + const restored = MemoryFileSystem.fromImage(await fs.saveImage()); + const rebased = fs.rebaseToNewFileSystem(8 * 1024 * 1024); + for (const candidate of [fs, restored, rebased]) { + candidate.setLazyFetcher(async () => new Response(fixture.payload)); + await expect(candidate.preparePath("/edge/moved")).resolves.toBe(true); + expect(readText(candidate, "/edge/moved")).toBe("alpha"); + expect(readText(candidate, "/edge/b")).toBe("bravo"); + } + }); + it("publishes archive registrations only after their imported seals verify", async () => { const fixture = tarTreeFixture("first-use", "atomic-registration"); const source = createFs(); @@ -2285,8 +2845,8 @@ describe("format-neutral deferred trees", () => { expect(() => fs.lstat("/runtime")).toThrow(); }); - it("binds original-bottle copy modes unless the link manifest explicitly overrides them", async () => { - const fixture = originalBottleTreeFixture(); + it("binds source-copy modes unless the producer explicitly overrides them", async () => { + const fixture = completeSourceTreeFixture(); const mismatched = structuredClone(fixture.inventory); mismatched.find((entry) => entry.materialization === "archive-copy")!.mode = 0o644; const rejected = createFs(); @@ -2325,7 +2885,7 @@ describe("format-neutral deferred trees", () => { expect(rebased.stat("/runtime/tool-copy").mode & 0o777).toBe(0o644); }); - it("keeps legacy v1 and original-bottle v2 serialized shapes disjoint", () => { + it("keeps legacy v1 and complete-source v2 serialized shapes disjoint", () => { const legacyFixture = tarTreeFixture("first-use"); const legacy = createFs(); legacy.registerLazyTree( @@ -2339,9 +2899,9 @@ describe("format-neutral deferred trees", () => { legacyV1.kind = "kandelo-deferred-tree-v2"; expect(() => MemoryFileSystem.fromExisting(legacy.sharedBuffer) .importLazyArchiveEntries([legacyV1])) - .toThrow(/v2 requires original-bottle source metadata/); + .toThrow(/v2 requires complete source metadata/); - const directFixture = originalBottleTreeFixture(); + const directFixture = completeSourceTreeFixture(); const direct = createFs(); direct.registerLazyTree( directFixture.content, @@ -2354,7 +2914,7 @@ describe("format-neutral deferred trees", () => { directV2.kind = "kandelo-deferred-tree-v1"; expect(() => MemoryFileSystem.fromExisting(direct.sharedBuffer) .importLazyArchiveEntries([directV2])) - .toThrow(/v1 cannot contain original-bottle source metadata/); + .toThrow(/v1 cannot contain complete source metadata/); const incompleteV2 = structuredClone(direct.exportLazyArchiveEntries()[0]) as any; delete incompleteV2.content.source; @@ -2367,7 +2927,7 @@ describe("format-neutral deferred trees", () => { ); expect(() => MemoryFileSystem.fromExisting(direct.sharedBuffer) .importLazyArchiveEntries([incompleteV2])) - .toThrow(/v2 requires original-bottle source metadata/); + .toThrow(/v2 requires complete source metadata/); }); it("preserves ZIP inventories whose hardlinks reuse the canonical member", () => { @@ -2687,7 +3247,7 @@ describe("format-neutral deferred trees", () => { const payloadSource = createFs(); for (const root of ["payload-a", "payload-b", "payload-c"]) { - const fixture = originalBottleTreeFixture(root); + const fixture = completeSourceTreeFixture(root); payloadSource.registerLazyTree( fixture.content, fixture.inventory, @@ -2749,7 +3309,7 @@ describe("format-neutral deferred trees", () => { expect(entryPeer.exportLazyArchiveEntries()).toEqual([]); }); - it("applies the same generic-tree validator during restore and rebase", async () => { + it("validates restore and rebases from private generic-tree authority", async () => { const fixture = tarTreeFixture("first-use"); const source = createFs(); source.registerLazyTree(fixture.content, fixture.inventory, "/", fixture.activation); @@ -2795,8 +3355,10 @@ describe("format-neutral deferred trees", () => { lazyArchiveGroups: Array<{ content: { expandedBytes: number } }>; }; internal.lazyArchiveGroups[0].content.expandedBytes = 0; - expect(() => source.rebaseToNewFileSystem(8 * 1024 * 1024)) - .toThrow(/expanded byte count differs from its inventory/); + const rebased = source.rebaseToNewFileSystem(8 * 1024 * 1024); + expect(rebased.exportLazyArchiveEntries()[0]!.content!.expandedBytes).toBe( + fixture.content.expandedBytes, + ); }); }); @@ -2928,7 +3490,7 @@ function tarTreeFixture( payload, inventory, content: { - decoder: "homebrew-bottle-tar-gzip-v1" as const, + decoder: "tar-gzip-v1" as const, mediaType: "application/vnd.oci.image.layer.v1.tar+gzip" as const, sha256: createHash("sha256").update(payload).digest("hex"), bytes: payload.byteLength, @@ -2944,7 +3506,146 @@ function tarTreeFixture( }; } -function originalBottleTreeFixture(root = "runtime") { +function exactGenericMaterializationPlan(): LazyTreeMaterializationPlan { + return { + schema: 1, + kind: "archive-byte-transforms-v1", + assertions: [{ sourcePath: "bin/tool", bytesHex: "2f6f6c642f" }], + recipes: [{ + id: "prefix", + replacements: [{ + matchHex: "2f6f6c642f", + replacementHex: "2f6e65772f", + }], + rejectHex: ["2f666f7262696464656e2f"], + }], + transforms: [{ + sourcePath: "bin/tool", + recipe: "prefix", + input: { + sha256: "0da8bba3f971e84a1cb42935a03959b06879abcffc01c472d41030227bb19cf7", + bytes: 5, + }, + output: { + sha256: "92a2fb6a1bcf1f8af0366d946016ee2601311aae9106f6eccaf905b1bfc6ab04", + bytes: 5, + }, + }], + }; +} + +function transformedGenericTarTreeFixture() { + const specs: TarSpec[] = [{ + path: "bin", + type: "directory", + mode: 0o755, + }, { + path: "bin/tool", + mode: 0o755, + data: "/old/", + }, { + path: "bin/tool-alias", + type: "hardlink", + mode: 0o755, + target: "bin/tool", + }, { + path: "current", + type: "symlink", + mode: 0o777, + target: "bin/tool", + }]; + const tar = tarBytes(specs); + const payload = gzipSync(tar); + const content = { + decoder: "tar-gzip-v1" as const, + mediaType: "application/vnd.oci.image.layer.v1.tar+gzip" as const, + sha256: createHash("sha256").update(payload).digest("hex"), + bytes: payload.byteLength, + expandedBytes: tar.byteLength, + sourceEntryCount: 4, + transports: ["https://example.invalid/generic-transformed.tar.gz"], + source: { + schema: 1 as const, + kind: "archive-source-inventory-v1" as const, + entries: [{ + sourcePath: "bin", + type: "directory" as const, + mode: 0o755, + size: 0, + }, { + sourcePath: "bin/tool", + type: "file" as const, + mode: 0o755, + size: 5, + }, { + sourcePath: "bin/tool-alias", + type: "hardlink" as const, + mode: 0o755, + size: 0, + target: "bin/tool", + }, { + sourcePath: "current", + type: "symlink" as const, + mode: 0o777, + size: 0, + target: "bin/tool", + }], + }, + materialization: exactGenericMaterializationPlan(), + }; + const inventory: LazyTreeRegistrationEntry[] = [{ + vfsPath: "/srv", + sourcePath: "projection-root", + materialization: "descriptor", + type: "directory", + mode: 0o755, + size: 0, + }, { + vfsPath: "/srv/bin", + sourcePath: "bin", + materialization: "archive", + type: "directory", + mode: 0o755, + size: 0, + }, { + vfsPath: "/srv/bin/tool", + sourcePath: "bin/tool", + materialization: "archive", + type: "file", + mode: 0o755, + size: 5, + inodeGroup: "generic:tool", + }, { + vfsPath: "/srv/bin/tool-alias", + sourcePath: "bin/tool-alias", + materialization: "archive", + type: "hardlink", + mode: 0o755, + size: 5, + target: "/srv/bin/tool", + inodeGroup: "generic:tool", + }, { + vfsPath: "/srv/current", + sourcePath: "current", + materialization: "archive", + type: "symlink", + mode: 0o777, + size: 8, + target: "bin/tool", + }]; + return { + payload, + content, + inventory, + activation: { + mode: "first-use" as const, + capabilities: ["test:generic-transform"], + roots: ["/srv"], + } satisfies LazyTreeActivation, + }; +} + +function completeSourceTreeFixture(root = "runtime") { const fixture = tarTreeFixture("first-use", root); const inventory: LazyTreeRegistrationEntry[] = fixture.inventory.map((entry) => ({ ...entry, @@ -2965,7 +3666,7 @@ function originalBottleTreeFixture(root = "runtime") { ...fixture.content, source: { schema: 1 as const, - kind: "homebrew-bottle-tar-gzip-v1" as const, + kind: "archive-source-inventory-v1" as const, entries: [ { sourcePath: root, @@ -3014,7 +3715,7 @@ function symlinkTreeFixture() { target, }], content: { - decoder: "homebrew-bottle-tar-gzip-v1" as const, + decoder: "tar-gzip-v1" as const, mediaType: "application/vnd.oci.image.layer.v1.tar+gzip" as const, sha256: createHash("sha256").update(payload).digest("hex"), bytes: payload.byteLength, diff --git a/host/test/node-lazy-archive-runtime.test.ts b/host/test/node-lazy-archive-runtime.test.ts index 33253ddee5..4b223465b0 100644 --- a/host/test/node-lazy-archive-runtime.test.ts +++ b/host/test/node-lazy-archive-runtime.test.ts @@ -96,7 +96,7 @@ describe.skipIf(!available)("Node lazy archive runtime paths", () => { "https://github.com/example/project/releases/download/v1/unbound.tar.gz"; const fs = MemoryFileSystem.create(new SharedArrayBuffer(32 * 1024 * 1024)); fs.registerLazyTree({ - decoder: "homebrew-bottle-tar-gzip-v1", + decoder: "tar-gzip-v1", mediaType: "application/vnd.oci.image.layer.v1.tar+gzip", ...integrity(boundArchive), expandedBytes: boundTar.byteLength, @@ -111,7 +111,7 @@ describe.skipIf(!available)("Node lazy archive runtime paths", () => { inodeGroup: "closed-bound", }]); fs.registerLazyTree({ - decoder: "homebrew-bottle-tar-gzip-v1", + decoder: "tar-gzip-v1", mediaType: "application/vnd.oci.image.layer.v1.tar+gzip", ...integrity(unboundArchive), expandedBytes: unboundTar.byteLength, @@ -206,7 +206,7 @@ describe.skipIf(!available)("Node lazy archive runtime paths", () => { const fs = MemoryFileSystem.create(new SharedArrayBuffer(32 * 1024 * 1024)); fs.registerLazyTree({ - decoder: "homebrew-bottle-tar-gzip-v1", + decoder: "tar-gzip-v1", mediaType: "application/vnd.oci.image.layer.v1.tar+gzip", ...integrity(execArchive), expandedBytes: execTar.byteLength, diff --git a/packages/registry/erlang-vfs/build.toml b/packages/registry/erlang-vfs/build.toml index 7d2924cec5..52e9ef2a19 100644 --- a/packages/registry/erlang-vfs/build.toml +++ b/packages/registry/erlang-vfs/build.toml @@ -13,9 +13,11 @@ inputs = [ "host/src/pathconf.ts", "host/src/statfs.ts", "host/src/types.ts", + "host/src/vfs/canonical-text.ts", "host/src/vfs/deferred-tree-limits.ts", "host/src/vfs/hardlink-graph.ts", "host/src/vfs/image-helpers.ts", + "host/src/vfs/materialization-plan.ts", "host/src/vfs/memory-fs.ts", "host/src/vfs/sharedfs-vendor.ts", "host/src/vfs/tar.ts", diff --git a/packages/registry/kandelo-sdk/build.toml b/packages/registry/kandelo-sdk/build.toml index 98ced876b6..ddf7c756ca 100644 --- a/packages/registry/kandelo-sdk/build.toml +++ b/packages/registry/kandelo-sdk/build.toml @@ -24,9 +24,11 @@ inputs = [ "host/src/pathconf.ts", "host/src/statfs.ts", "host/src/types.ts", + "host/src/vfs/canonical-text.ts", "host/src/vfs/deferred-tree-limits.ts", "host/src/vfs/hardlink-graph.ts", "host/src/vfs/image-helpers.ts", + "host/src/vfs/materialization-plan.ts", "host/src/vfs/memory-fs.ts", "host/src/vfs/sharedfs-vendor.ts", "host/src/vfs/tar.ts", diff --git a/packages/registry/mariadb-test/build.toml b/packages/registry/mariadb-test/build.toml index e430c7aaa7..223dd62f88 100644 --- a/packages/registry/mariadb-test/build.toml +++ b/packages/registry/mariadb-test/build.toml @@ -19,9 +19,11 @@ inputs = [ "host/src/pathconf.ts", "host/src/statfs.ts", "host/src/types.ts", + "host/src/vfs/canonical-text.ts", "host/src/vfs/deferred-tree-limits.ts", "host/src/vfs/hardlink-graph.ts", "host/src/vfs/image-helpers.ts", + "host/src/vfs/materialization-plan.ts", "host/src/vfs/memory-fs.ts", "host/src/vfs/sharedfs-vendor.ts", "host/src/vfs/tar.ts", diff --git a/packages/registry/mariadb-vfs/build.toml b/packages/registry/mariadb-vfs/build.toml index 6228af3eb5..19200b5805 100644 --- a/packages/registry/mariadb-vfs/build.toml +++ b/packages/registry/mariadb-vfs/build.toml @@ -19,9 +19,11 @@ inputs = [ "host/src/pathconf.ts", "host/src/statfs.ts", "host/src/types.ts", + "host/src/vfs/canonical-text.ts", "host/src/vfs/deferred-tree-limits.ts", "host/src/vfs/hardlink-graph.ts", "host/src/vfs/image-helpers.ts", + "host/src/vfs/materialization-plan.ts", "host/src/vfs/memory-fs.ts", "host/src/vfs/sharedfs-vendor.ts", "host/src/vfs/tar.ts", diff --git a/packages/registry/nginx-vfs/build.toml b/packages/registry/nginx-vfs/build.toml index 6c12dc788a..11e3a83f53 100644 --- a/packages/registry/nginx-vfs/build.toml +++ b/packages/registry/nginx-vfs/build.toml @@ -19,6 +19,7 @@ inputs = [ "host/src/file-offset.ts", "host/src/generated/abi.ts", "host/src/homebrew-bottle-relocation.ts", + "host/src/vfs/materialization-plan.ts", "host/src/homebrew-guest-layout.ts", "homebrew/kandelo-guest-layout.json", "host/src/pathconf.ts", diff --git a/packages/registry/node-vfs/build.toml b/packages/registry/node-vfs/build.toml index c631a9d254..cab975249d 100644 --- a/packages/registry/node-vfs/build.toml +++ b/packages/registry/node-vfs/build.toml @@ -18,6 +18,7 @@ inputs = [ "host/src/file-offset.ts", "host/src/generated/abi.ts", "host/src/homebrew-bottle-relocation.ts", + "host/src/vfs/materialization-plan.ts", "host/src/homebrew-guest-layout.ts", "homebrew/kandelo-guest-layout.json", "host/src/pathconf.ts", diff --git a/packages/registry/perl-vfs/build.toml b/packages/registry/perl-vfs/build.toml index 42cdade815..e14fb062bf 100644 --- a/packages/registry/perl-vfs/build.toml +++ b/packages/registry/perl-vfs/build.toml @@ -15,9 +15,11 @@ inputs = [ "host/src/pathconf.ts", "host/src/statfs.ts", "host/src/types.ts", + "host/src/vfs/canonical-text.ts", "host/src/vfs/deferred-tree-limits.ts", "host/src/vfs/hardlink-graph.ts", "host/src/vfs/image-helpers.ts", + "host/src/vfs/materialization-plan.ts", "host/src/vfs/memory-fs.ts", "host/src/vfs/sharedfs-vendor.ts", "host/src/vfs/tar.ts", diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index 82f6b2cf95..18291d58ae 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -74,8 +74,8 @@ "erlang-vfs": { "manifestSha256": "15efb74f1825e2b85bedfeb8d98c19fa648c4be39cb9defb65330a8f21eb9141", "cacheKeys": { - "wasm32": "fa6f701bd5445a7808e7c443c938bf8504548351495dec5594a0d7e6e2d7be39", - "wasm64": "3a9c75c05a5f8a222095fd78827c76f0e9116a125cd1781d075326df164eb952" + "wasm32": "e7def6b3619e031084290c717f6cb79de6bdc36a1463a65863143cfb4ebbd7f7", + "wasm64": "c9f57606aa1e5853848b7964578002c4ab954bbc9a87ec4927c41938c1de1b93" } }, "fbdoom": { @@ -144,8 +144,8 @@ "kandelo-sdk": { "manifestSha256": "c081879f1becd855917cff96f17429bcf2b9fd61bf95211eca9ff8fb625bb2eb", "cacheKeys": { - "wasm32": "bc4a0e630b02b52dcefca5e807c712a7a1baf131e03b7ac2ffd9b56bcd425abd", - "wasm64": "d4853b71658aef7cadecaa0e51b79c0f85e618dc566cf3831883f2629482884b" + "wasm32": "698a5ef53195c472a0ba2a3e00820758fe5fbaac50aa47cd7cd00693ffbf3e83", + "wasm64": "435c56a8b5a8c25a2ac47968d76b766cfb6e6daa6190be839ecf29ca5f43d82d" } }, "kernel": { @@ -158,8 +158,8 @@ "lamp": { "manifestSha256": "250b64635d64f8537178188fb5488dbfd2980d79a1c2ffbf346af67008088ba0", "cacheKeys": { - "wasm32": "7bc5d07c9351bd2752f6da98efee5ce8befc108b228826844c8097375c7a49a3", - "wasm64": "548b56496a64316037f0f5226e14d95c225c9d4734072ad87af636695aea27be" + "wasm32": "93ca83b243bc11a75e4ebb73acbb1f3d08dd59ad20df436892647110adee181e", + "wasm64": "67c5ce616a57fd8b2184fd4901381f8dc00409b818bfcdb98b5c1376366e6b8c" } }, "less": { @@ -242,15 +242,15 @@ "mariadb-test": { "manifestSha256": "d4ccce161d659a5a87c80fa1117054bbccea870e0d4a46bd8a070d3930ad0e3f", "cacheKeys": { - "wasm32": "bbc45acd9b4faee06b25096af3fc97e801b519070df705e254e0dbc8335a1e50", - "wasm64": "86d5bad5ee12966287266a17448ee6c1aff5bff2cbfe13f10ac068a24163bf4c" + "wasm32": "e9cb9f5b1c3efa9a3916f2ce81f6012a392bce284afa31ea132edcb6fb10ab02", + "wasm64": "334a472b68bd82e168ed71d23aff67ee0253d189453219b5759d6a99cfab6602" } }, "mariadb-vfs": { "manifestSha256": "26e74ac84d89b2a839ed72783437061a23022ef2f88ad0f52b6aacd0442ee1cf", "cacheKeys": { - "wasm32": "d7d550a16586c909f4d390dde0a2ed8fe51a93c343d1808fae9af8ac12d86407", - "wasm64": "37af6dba327533cb30baf70ea0bbe80855be2b2392e75b102d3f1d61ce1c7ceb" + "wasm32": "a053305528534ac1aa3efb3a9eb4bbee21f915301588861e5365e454422e32f8", + "wasm64": "5fb381f76ab397f36319d50c1c35d02ddf9046fa3972c6c30f960bb15e99d455" } }, "modeset": { @@ -312,15 +312,15 @@ "nginx-php-vfs": { "manifestSha256": "97976410cb02f8ba710d856b4ac904bcf976d677b50eefdee39fb64176070d4b", "cacheKeys": { - "wasm32": "0dd380c1d3f9b84c0b6da35c56b4f7073d862ffbb5f383030f73b1763bf70583", - "wasm64": "0047a616c217e2bffad74d9599d3b821e2f43edbedaa2b24bc4761b436df3218" + "wasm32": "108c67e3ec4a2e5da9c847ac759818b00d48c3f3bb7845ebc8794eb53b23ea1e", + "wasm64": "8a9ae9c696fafabb82c7c5b33604e40a122d9b2080373f4fc2b65ced307402fa" } }, "nginx-vfs": { "manifestSha256": "46aa2d3250ac5cd0f102a85c3a36a086c7a50ba1f9e05fa1c2aae3d07ad16f10", "cacheKeys": { - "wasm32": "200e3266962ea5d01489c810c4a96d99b22f57b959c043b4b0dea90d30a6ba2e", - "wasm64": "9872b4a37a4fa47cb1a5289cf748029ccc056c46595857ea2c9f0b6781900df3" + "wasm32": "ab53188e3f56bd5478da98ed4301e784be2aeccc891f16a7979975cf972b8a84", + "wasm64": "465e0eec33e629da7d41f6ec280c4924c35d756384e918f35e1d3f070603d6f9" } }, "node": { @@ -333,8 +333,8 @@ "node-vfs": { "manifestSha256": "977f219defe57e18017ecc963159df32efdd21c53d6fea05943703d04739d8de", "cacheKeys": { - "wasm32": "5b0e4cbb2d08e9f0b9b1c422c18d027fe8da964d2625b4f36dac9b67c21ddd03", - "wasm64": "ce5cfcbf8292b0cd5953502c27eba9b84fd16b7b9940f3e4f807d97802a044b4" + "wasm32": "5121d21b7ce93c5ce4b38c65f107032994e1fb46b97628ff85d38db49d4ec8a9", + "wasm64": "20edeaccd1939f1a6b5bec8d0d46879c4a2d71779e9a6be4f741f9e26efa17c6" } }, "openssl": { @@ -361,8 +361,8 @@ "perl-vfs": { "manifestSha256": "1345cc102bc8c24a6bc276410c00b4fcc99ba5f6adc9b031f882aaa35d53c8bc", "cacheKeys": { - "wasm32": "96848b01e749b6c9b8ba408a21ccec44e71fb6dfe38f359298ac09e12e74c4b1", - "wasm64": "2e337e7fc6596d0756c71beb0d18448eeba124908f6317d6ed67cb6c19926002" + "wasm32": "87899325328a8b2d1445de8a44467ec9e61d776b8be809b42734453440f7cee2", + "wasm64": "38d9e4d30a540dfa40f427b0778fba00eec3536ebdf08940fe83c5cf2dd8906a" } }, "php": { @@ -382,8 +382,8 @@ "python-vfs": { "manifestSha256": "d1aa99feae65f06c0ca8cf52c2176534b2723fd4ee6eba6e72c205245e1c9c26", "cacheKeys": { - "wasm32": "de1aca82272b1914a5edc5f91c9e2a56a4100f6c29f4060d59407d97a8b0a26c", - "wasm64": "959d0f0b9ae8dfc2dc9769b74d990ba20ad1176fe25941c710bfeac316b28c8e" + "wasm32": "2d3916c174c6578a83ff7fd5ad4508fe4ebb9291cc6e4e5a62282221fefc65b7", + "wasm64": "8d4f7d23ca9c790d5da4a2d23015cfc38508a900fe9d4329717169dcc2dd25f4" } }, "redis": { @@ -396,15 +396,15 @@ "redis-vfs": { "manifestSha256": "234c45c40f94e2a89f98295313b82cb9fce15a412ac829d9e87394e0dc731813", "cacheKeys": { - "wasm32": "4680323ca23aa4326807e09ab823e6b10e2cd4f387afc0319c41626a57cfb6a4", - "wasm64": "4dc534c9452d826ce1a1e834c334c278fda07c3b56901b76a2d13919a498507e" + "wasm32": "63470d7f6aa96b5d49e68d2e4d0e18f08e5529d669f40f2941a4a8ca46dfd14f", + "wasm64": "b37a5d0280fa55f33c670b51368fb0027c76fa17d61e762c8420a6616d3664e2" } }, "rootfs": { "manifestSha256": "0420201025170f48c32949ac62707d4577cdc13a53ad1f01f59d318ec07c8d6e", "cacheKeys": { - "wasm32": "de01588a7a5ab85427406f8770382dc9a093908609e31307fb81057186fe5eaf", - "wasm64": "5efaa5e7977d68c18805f19fde44996d42c1acabbb29a6827f0e302d0a0cf5ae" + "wasm32": "868c7b8e5534a14d9adb3fbb67dafa98aa295eec0842c9bad65ba053c8abd9f2", + "wasm64": "23ec1ae7a76759ce48a0d666bbd97ba77395423955e5528c04496100ff3de87d" } }, "ruby": { @@ -452,8 +452,8 @@ "shell": { "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", "cacheKeys": { - "wasm32": "62ee60524193ef96af5ef4b005c691b7227f819ee9e3f9e195a3ac8e6b168dde", - "wasm64": "44d7be441757bbbb02d907b9e9a812c9b4d6571697a26617cc911ed0f2379e5e" + "wasm32": "f368afda165c066ff742b0d7e401118a270002cf853a8549b4a0d220e2b34a7a", + "wasm64": "37e3dcc8d23babcde0b50528c109d138096916a719e0b5e810bf105b826497b2" } }, "spidermonkey": { @@ -543,8 +543,8 @@ "wordpress": { "manifestSha256": "36465e0596a06e855a8524eecfd87da98643bc13b37ee12939f5aab44bf33418", "cacheKeys": { - "wasm32": "736aa0505b544a9c1f0b47dc1b2b0200ecbaa8eb2a3fe6f6110d433e616c9f80", - "wasm64": "efa707e2e50097e8d0c179c77533bf4d0fb432e991bce41cb409b1825422df5e" + "wasm32": "2ab149a6a7825dff222edc0530085cc154dd2907b14c82cd03339d31e5a86e1d", + "wasm64": "edc7a8112fd08bcf82e211800e28e63964c41f72f3b3415720ea19750e63b083" } }, "xz": { @@ -871,7 +871,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "fa6f701bd5445a7808e7c443c938bf8504548351495dec5594a0d7e6e2d7be39" + "wasm32": "e7def6b3619e031084290c717f6cb79de6bdc36a1463a65863143cfb4ebbd7f7" }, "dependencyClosures": { "wasm32": [ @@ -1094,7 +1094,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "bc4a0e630b02b52dcefca5e807c712a7a1baf131e03b7ac2ffd9b56bcd425abd" + "wasm32": "698a5ef53195c472a0ba2a3e00820758fe5fbaac50aa47cd7cd00693ffbf3e83" }, "dependencyClosures": { "wasm32": [ @@ -1121,7 +1121,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "7bc5d07c9351bd2752f6da98efee5ce8befc108b228826844c8097375c7a49a3" + "wasm32": "93ca83b243bc11a75e4ebb73acbb1f3d08dd59ad20df436892647110adee181e" }, "dependencyClosures": { "wasm32": [ @@ -1193,7 +1193,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "62ee60524193ef96af5ef4b005c691b7227f819ee9e3f9e195a3ac8e6b168dde" + "cacheKey": "f368afda165c066ff742b0d7e401118a270002cf853a8549b4a0d220e2b34a7a" }, { "packageName": "sqlite", @@ -1360,7 +1360,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "bbc45acd9b4faee06b25096af3fc97e801b519070df705e254e0dbc8335a1e50" + "wasm32": "e9cb9f5b1c3efa9a3916f2ce81f6012a392bce284afa31ea132edcb6fb10ab02" }, "dependencyClosures": { "wasm32": [ @@ -1413,8 +1413,8 @@ "wasm64" ], "cacheKeys": { - "wasm32": "d7d550a16586c909f4d390dde0a2ed8fe51a93c343d1808fae9af8ac12d86407", - "wasm64": "37af6dba327533cb30baf70ea0bbe80855be2b2392e75b102d3f1d61ce1c7ceb" + "wasm32": "a053305528534ac1aa3efb3a9eb4bbee21f915301588861e5365e454422e32f8", + "wasm64": "5fb381f76ab397f36319d50c1c35d02ddf9046fa3972c6c30f960bb15e99d455" }, "dependencyClosures": { "wasm32": [ @@ -1746,7 +1746,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0dd380c1d3f9b84c0b6da35c56b4f7073d862ffbb5f383030f73b1763bf70583" + "wasm32": "108c67e3ec4a2e5da9c847ac759818b00d48c3f3bb7845ebc8794eb53b23ea1e" }, "dependencyClosures": { "wasm32": [ @@ -1808,7 +1808,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "62ee60524193ef96af5ef4b005c691b7227f819ee9e3f9e195a3ac8e6b168dde" + "cacheKey": "f368afda165c066ff742b0d7e401118a270002cf853a8549b4a0d220e2b34a7a" }, { "packageName": "sqlite", @@ -1838,7 +1838,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "200e3266962ea5d01489c810c4a96d99b22f57b959c043b4b0dea90d30a6ba2e" + "wasm32": "ab53188e3f56bd5478da98ed4301e784be2aeccc891f16a7979975cf972b8a84" }, "dependencyClosures": { "wasm32": [ @@ -1860,7 +1860,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "62ee60524193ef96af5ef4b005c691b7227f819ee9e3f9e195a3ac8e6b168dde" + "cacheKey": "f368afda165c066ff742b0d7e401118a270002cf853a8549b4a0d220e2b34a7a" } ] }, @@ -1922,7 +1922,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "5b0e4cbb2d08e9f0b9b1c422c18d027fe8da964d2625b4f36dac9b67c21ddd03" + "wasm32": "5121d21b7ce93c5ce4b38c65f107032994e1fb46b97628ff85d38db49d4ec8a9" }, "dependencyClosures": { "wasm32": [ @@ -1944,7 +1944,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "62ee60524193ef96af5ef4b005c691b7227f819ee9e3f9e195a3ac8e6b168dde" + "cacheKey": "f368afda165c066ff742b0d7e401118a270002cf853a8549b4a0d220e2b34a7a" }, { "packageName": "spidermonkey", @@ -1995,7 +1995,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "96848b01e749b6c9b8ba408a21ccec44e71fb6dfe38f359298ac09e12e74c4b1" + "wasm32": "87899325328a8b2d1445de8a44467ec9e61d776b8be809b42734453440f7cee2" }, "dependencyClosures": { "wasm32": [ @@ -2418,7 +2418,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "de1aca82272b1914a5edc5f91c9e2a56a4100f6c29f4060d59407d97a8b0a26c" + "wasm32": "2d3916c174c6578a83ff7fd5ad4508fe4ebb9291cc6e4e5a62282221fefc65b7" }, "dependencyClosures": { "wasm32": [ @@ -2478,7 +2478,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "4680323ca23aa4326807e09ab823e6b10e2cd4f387afc0319c41626a57cfb6a4" + "wasm32": "63470d7f6aa96b5d49e68d2e4d0e18f08e5529d669f40f2941a4a8ca46dfd14f" }, "dependencyClosures": { "wasm32": [ @@ -2515,7 +2515,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "de01588a7a5ab85427406f8770382dc9a093908609e31307fb81057186fe5eaf" + "wasm32": "868c7b8e5534a14d9adb3fbb67dafa98aa295eec0842c9bad65ba053c8abd9f2" }, "dependencyClosures": { "wasm32": [ @@ -2728,7 +2728,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "62ee60524193ef96af5ef4b005c691b7227f819ee9e3f9e195a3ac8e6b168dde" + "wasm32": "f368afda165c066ff742b0d7e401118a270002cf853a8549b4a0d220e2b34a7a" }, "dependencyClosures": { "wasm32": [] @@ -3020,7 +3020,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "736aa0505b544a9c1f0b47dc1b2b0200ecbaa8eb2a3fe6f6110d433e616c9f80" + "wasm32": "2ab149a6a7825dff222edc0530085cc154dd2907b14c82cd03339d31e5a86e1d" }, "dependencyClosures": { "wasm32": [ @@ -3082,7 +3082,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "62ee60524193ef96af5ef4b005c691b7227f819ee9e3f9e195a3ac8e6b168dde" + "cacheKey": "f368afda165c066ff742b0d7e401118a270002cf853a8549b4a0d220e2b34a7a" }, { "packageName": "sqlite", diff --git a/packages/registry/python-vfs/build.toml b/packages/registry/python-vfs/build.toml index a32a7919de..aead075e79 100644 --- a/packages/registry/python-vfs/build.toml +++ b/packages/registry/python-vfs/build.toml @@ -13,9 +13,11 @@ inputs = [ "host/src/pathconf.ts", "host/src/statfs.ts", "host/src/types.ts", + "host/src/vfs/canonical-text.ts", "host/src/vfs/deferred-tree-limits.ts", "host/src/vfs/hardlink-graph.ts", "host/src/vfs/image-helpers.ts", + "host/src/vfs/materialization-plan.ts", "host/src/vfs/memory-fs.ts", "host/src/vfs/sharedfs-vendor.ts", "host/src/vfs/tar.ts", diff --git a/packages/registry/redis-vfs/build.toml b/packages/registry/redis-vfs/build.toml index 104965d0b4..8dafe3974c 100644 --- a/packages/registry/redis-vfs/build.toml +++ b/packages/registry/redis-vfs/build.toml @@ -13,6 +13,7 @@ inputs = [ "host/src/file-offset.ts", "host/src/generated/abi.ts", "host/src/homebrew-bottle-relocation.ts", + "host/src/vfs/materialization-plan.ts", "host/src/homebrew-guest-layout.ts", "homebrew/kandelo-guest-layout.json", "host/src/pathconf.ts", diff --git a/packages/registry/rootfs/build.toml b/packages/registry/rootfs/build.toml index 01b907894c..5070efa368 100644 --- a/packages/registry/rootfs/build.toml +++ b/packages/registry/rootfs/build.toml @@ -20,8 +20,10 @@ inputs = [ "host/src/pathconf.ts", "host/src/statfs.ts", "host/src/types.ts", + "host/src/vfs/canonical-text.ts", "host/src/vfs/deferred-tree-limits.ts", "host/src/vfs/hardlink-graph.ts", + "host/src/vfs/materialization-plan.ts", "host/src/vfs/memory-fs.ts", "host/src/vfs/sharedfs-vendor.ts", "host/src/vfs/tar.ts", diff --git a/packages/registry/shell/build.toml b/packages/registry/shell/build.toml index 4d3c90477b..b73ca9d9b4 100644 --- a/packages/registry/shell/build.toml +++ b/packages/registry/shell/build.toml @@ -27,6 +27,8 @@ inputs = [ "host/src/homebrew-bottle-relocation.ts", "host/src/homebrew-bottle-selection.ts", "host/src/homebrew-bottle-types.ts", + "host/src/homebrew-bottle-relocation.ts", + "host/src/homebrew-deferred-tree-adapter.ts", "host/src/homebrew-guest-layout.ts", "host/src/homebrew-lazy-layer-descriptor.ts", "host/src/homebrew-lazy-layer.ts", @@ -41,8 +43,20 @@ inputs = [ "host/src/homebrew-vfs-resource-policy.ts", "host/src/pathconf.ts", "host/src/statfs.ts", - "host/src/vfs", - "homebrew/kandelo-guest-layout.json", + "host/src/types.ts", + "host/src/vfs/canonical-text.ts", + "host/src/vfs/deferred-tree-limits.ts", + "host/src/vfs/closed-lazy-assets.ts", + "host/src/vfs/hardlink-graph.ts", + "host/src/vfs/image-helpers.ts", + "host/src/vfs/materialization-plan.ts", + "host/src/vfs/memory-fs.ts", + "host/src/vfs/package-deferred-tree-contract.ts", + "host/src/vfs/package-deferred-tree.ts", + "host/src/vfs/sharedfs-vendor.ts", + "host/src/vfs/tar.ts", + "host/src/vfs/types.ts", + "host/src/vfs/zip.ts", "web-libs/kandelo-session/src/shell-config.ts", "web-libs/kandelo-session/src/demo-config.ts", ] diff --git a/scripts/homebrew-closed-lazy-assets.test.ts b/scripts/homebrew-closed-lazy-assets.test.ts index e7dd29e0b2..711897163a 100644 --- a/scripts/homebrew-closed-lazy-assets.test.ts +++ b/scripts/homebrew-closed-lazy-assets.test.ts @@ -152,7 +152,7 @@ function createFixture(root: string, writePayload = true): { pendingTree: { kind: "kandelo-deferred-tree-v2", content: { - decoder: "homebrew-bottle-tar-gzip-v1", + decoder: "tar-gzip-v1", mediaType: "application/vnd.oci.image.layer.v1.tar+gzip", sha256: identity.sha256, bytes: identity.bytes, diff --git a/scripts/homebrew-vfs-release.py b/scripts/homebrew-vfs-release.py index 8de17fc513..adfa81ec48 100755 --- a/scripts/homebrew-vfs-release.py +++ b/scripts/homebrew-vfs-release.py @@ -40,9 +40,6 @@ GUEST_PATH_RE = re.compile( r"^/(?:[A-Za-z0-9._@%+=:-]+/)*[A-Za-z0-9._@%+=:-]+$" ) -HOMEBREW_COMMAND_RE = re.compile( - r"^/opt/kandelo/homebrew/(?:bin|sbin)/[A-Za-z0-9._@%+=:-]+$" -) IMAGE_ASSET = "kandelo-homebrew.vfs.zst" REPORT_ASSET = "kandelo-homebrew-vfs-report.json" @@ -70,16 +67,16 @@ ) MAX_LAZY_LAYER_ARCHIVE_BYTES = 256 * 1024 * 1024 MAX_LAZY_LAYER_UNCOMPRESSED_BYTES = 256 * 1024 * 1024 -HOMEBREW_PREFIX = "/opt/kandelo/homebrew" +MAX_LAZY_LAYER_MATERIALIZATION_ASSERTIONS = 32 +MAX_LAZY_LAYER_MATERIALIZATION_ASSERTION_BYTES = 1024 * 1024 +MAX_LAZY_LAYER_MATERIALIZATION_RECIPES = 32 +MAX_LAZY_LAYER_MATERIALIZATION_TRANSFORMS = 100_000 +MAX_LAZY_LAYER_MATERIALIZATION_DECODED_BYTES = 8 * 1024 * 1024 +MAX_LAZY_LAYER_TRANSFORM_REPLACEMENTS = 32 +MAX_LAZY_LAYER_TRANSFORM_PATTERN_BYTES = 8192 MAX_BOTTLE_CHANGED_FILES = 100_000 -HOMEBREW_REPLACEMENTS = ( - (b"@@HOMEBREW_PREFIX@@", HOMEBREW_PREFIX.encode()), - (b"@@HOMEBREW_CELLAR@@", f"{HOMEBREW_PREFIX}/Cellar".encode()), - (b"@@HOMEBREW_REPOSITORY@@", HOMEBREW_PREFIX.encode()), - (b"@@HOMEBREW_LIBRARY@@", f"{HOMEBREW_PREFIX}/Library".encode()), - (b"@@HOMEBREW_PERL@@", f"{HOMEBREW_PREFIX}/opt/perl/bin/perl".encode()), -) HOMEBREW_JAVA_PLACEHOLDER = b"@@HOMEBREW_JAVA@@" +HOMEBREW_RELOCATION_RECIPE_ID = "homebrew-receipt-text-v1" TAR_BLOCK_BYTES = 512 TAR_MAX_SAFE_INTEGER = (1 << 53) - 1 TAR_ZERO_BLOCK = bytes(TAR_BLOCK_BYTES) @@ -549,8 +546,8 @@ def validate_tap( fail("tap default-shell config has unexpected fields") exact(shell_config.get("version"), 1, "tap default-shell version") path = string(shell_config.get("path"), "tap default-shell path") - if not HOMEBREW_COMMAND_RE.fullmatch(path): - fail("tap default-shell path is not a canonical Homebrew command path") + if GUEST_PATH_RE.fullmatch(path) is None: + fail("tap default-shell path is not an absolute guest path") shell_argv = array(shell_config.get("argv"), "tap default-shell argv") if not shell_argv or len(shell_argv) > 64: fail("tap default-shell argv must contain 1 to 64 entries") @@ -597,10 +594,13 @@ def validate_link_manifest_contract( exact(manifest.get("version"), package.get("version"), f"{label} reviewed version") exact(manifest.get("arch"), package.get("arch"), f"{label} reviewed architecture") exact(manifest.get("kandelo_abi"), expected_abi, f"{label} reviewed Kandelo ABI") - exact(manifest.get("prefix"), package.get("prefix"), f"{label} reviewed prefix") + prefix = normalize_homebrew_destination_prefix( + package.get("prefix"), f"{label} package prefix" + ) + exact(manifest.get("prefix"), prefix, f"{label} reviewed prefix") exact(manifest.get("keg"), package.get("keg"), f"{label} reviewed keg") cellar = string(manifest.get("cellar"), f"{label} reviewed cellar", maximum=4096) - if GUEST_PATH_RE.fullmatch(cellar) is None or not package["keg"].startswith(f"{cellar}/"): + if cellar != f"{prefix}/Cellar" or not package["keg"].startswith(f"{cellar}/"): fail(f"{label} reviewed cellar is invalid") bottle = record(manifest.get("bottle"), f"{label} reviewed bottle") if set(bottle) != {"url", "sha256", "bytes", "cache_key_sha", "payload_root"}: @@ -979,6 +979,18 @@ def validate_evidence( manifest_link_contracts[full_name] = validate_link_manifest_contract( checkout["root"], package, f"VFS report package {full_name}", abi ) + destination_prefix = homebrew_destination_prefix( + list(report_packages.values()), "VFS report" + ) + validate_homebrew_command_path( + config["executable"], destination_prefix, "tap VFS acceptance executable" + ) + if config["default_shell"] is not None: + validate_homebrew_command_path( + config["default_shell"]["path"], + destination_prefix, + "tap default-shell path", + ) applied_link_contracts, link_conflicts = reconstruct_applied_link_contracts( report, report_values, manifest_link_contracts ) @@ -1153,6 +1165,7 @@ def validate_evidence( "link_conflicts": link_conflicts, "tap_checkouts": tap_checkouts, "root_full_name": root_full_name, + "destination_prefix": destination_prefix, } @@ -1179,7 +1192,45 @@ def safe_relative_path(value: Any, label: str) -> str: return path -def validate_lazy_package_record(value: Any, label: str) -> dict[str, Any]: +def normalize_homebrew_destination_prefix(value: Any, label: str) -> str: + """Validate the one receipt-authenticated destination namespace.""" + prefix = string(value, label, maximum=MAX_LAZY_LAYER_PATH_BYTES) + if ( + prefix == "/" + or GUEST_PATH_RE.fullmatch(prefix) is None + or "\\" in prefix + or any(component in ("", ".", "..") for component in prefix.split("/")[1:]) + ): + fail(f"{label} is not a safe absolute Homebrew destination prefix") + return prefix + + +def homebrew_destination_prefix( + packages: list[dict[str, Any]], label: str +) -> str: + prefixes = { + normalize_homebrew_destination_prefix( + package.get("prefix"), f"{label} package {index} prefix" + ) + for index, package in enumerate(packages) + } + if len(prefixes) != 1: + fail(f"{label} package destinations are inconsistent") + return next(iter(prefixes)) + + +def validate_homebrew_command_path(path: str, destination_prefix: str, label: str) -> None: + relative = path.removeprefix(f"{destination_prefix}/") + if ( + relative == path + or re.fullmatch(r"(?:bin|sbin)/[A-Za-z0-9._@%+=:-]+", relative) is None + ): + fail(f"{label} is not a canonical Homebrew command path") + + +def validate_lazy_package_record( + value: Any, label: str, destination_prefix: str +) -> dict[str, Any]: package = record(value, label) required = { "name", "full_name", "tap_repository", "tap_name", "tap_commit", @@ -1209,8 +1260,10 @@ def validate_lazy_package_record(value: Any, label: str) -> dict[str, Any]: safe_relative_path(package.get("link_manifest"), f"{label} link manifest") string(package.get("version"), f"{label} version", maximum=256) string(package.get("metadata_status"), f"{label} metadata status", maximum=256) - prefix = string(package.get("prefix"), f"{label} prefix", maximum=MAX_LAZY_LAYER_PATH_BYTES) - exact(prefix, HOMEBREW_PREFIX, f"{label} prefix") + prefix = normalize_homebrew_destination_prefix( + package.get("prefix"), f"{label} prefix" + ) + exact(prefix, destination_prefix, f"{label} prefix") keg = string(package.get("keg"), f"{label} keg", maximum=MAX_LAZY_LAYER_PATH_BYTES) keg_root = f"{prefix}/Cellar/{name}/" if ( @@ -1561,7 +1614,9 @@ def parse_homebrew_install_receipt(value: bytes) -> dict[str, Any]: } -def homebrew_java_home(runtime_dependencies: Any) -> bytes | None: +def homebrew_java_home( + runtime_dependencies: Any, destination_prefix: str +) -> bytes | None: if not isinstance(runtime_dependencies, list): return None names: set[str] = set() @@ -1578,24 +1633,126 @@ def homebrew_java_home(runtime_dependencies: Any) -> bytes | None: names.add(name) if len(names) != 1: return None - return f"{HOMEBREW_PREFIX}/opt/{next(iter(names))}/libexec".encode() + return f"{destination_prefix}/opt/{next(iter(names))}/libexec".encode() + + +def homebrew_replacements(destination_prefix: str) -> tuple[tuple[bytes, bytes], ...]: + return ( + (b"@@HOMEBREW_PREFIX@@", destination_prefix.encode()), + (b"@@HOMEBREW_CELLAR@@", f"{destination_prefix}/Cellar".encode()), + (b"@@HOMEBREW_REPOSITORY@@", destination_prefix.encode()), + (b"@@HOMEBREW_LIBRARY@@", f"{destination_prefix}/Library".encode()), + (b"@@HOMEBREW_PERL@@", f"{destination_prefix}/opt/perl/bin/perl".encode()), + ) + + +def replace_homebrew_bytes_bounded( + value: bytes, + match: bytes, + replacement: bytes, + path: str, +) -> bytes: + """Apply one replacement only after proving its allocation is bounded.""" + if not match: + fail(f"Homebrew changed file {path} has an empty replacement pattern") + if ( + len(match) > MAX_LAZY_LAYER_TRANSFORM_PATTERN_BYTES + or len(replacement) > MAX_LAZY_LAYER_TRANSFORM_PATTERN_BYTES + ): + fail(f"Homebrew changed file {path} exceeds the pattern byte limit") + if len(value) > MAX_LAZY_LAYER_UNCOMPRESSED_BYTES: + fail(f"Homebrew changed file {path} exceeds the source-byte limit") + if len(value) > TAR_MAX_SAFE_INTEGER: + fail(f"Homebrew changed file {path} exceeds the safe integer limit") + + count = value.count(match) + delta = len(replacement) - len(match) + if delta > 0 and count > (TAR_MAX_SAFE_INTEGER - len(value)) // delta: + fail(f"Homebrew changed file {path} exceeds the safe integer limit") + output_bytes = len(value) + count * delta + if output_bytes < 0 or output_bytes > TAR_MAX_SAFE_INTEGER: + fail(f"Homebrew changed file {path} exceeds the safe integer limit") + if output_bytes > MAX_LAZY_LAYER_UNCOMPRESSED_BYTES: + fail(f"Homebrew changed file {path} exceeds the transformed-byte limit") + # WHY: bytes.replace allocates the result. The exact sequential result was + # proven above before allowing that allocation to occur. + return value.replace(match, replacement) + + +def validate_homebrew_materialization_bounds( + receipt_bytes: bytes, + recipe_replacements: list[tuple[bytes, bytes]], + rejected_patterns: list[bytes], + transform_count: int, +) -> None: + """Keep the independent publisher within the generic runtime contract.""" + if 1 > MAX_LAZY_LAYER_MATERIALIZATION_ASSERTIONS: + fail("Homebrew materialization exceeds the assertion count limit") + if len(receipt_bytes) > MAX_LAZY_LAYER_MATERIALIZATION_ASSERTION_BYTES: + fail("Homebrew materialization exceeds the assertion byte limit") + if transform_count > MAX_LAZY_LAYER_MATERIALIZATION_TRANSFORMS: + fail("Homebrew materialization exceeds the transform count limit") + + recipe_count = 1 if transform_count else 0 + if recipe_count > MAX_LAZY_LAYER_MATERIALIZATION_RECIPES: + fail("Homebrew materialization exceeds the recipe count limit") + decoded_bytes = len(receipt_bytes) + if recipe_count: + if ( + len(recipe_replacements) > MAX_LAZY_LAYER_TRANSFORM_REPLACEMENTS + or len(rejected_patterns) > MAX_LAZY_LAYER_TRANSFORM_REPLACEMENTS + ): + fail("Homebrew materialization exceeds the replacement count limit") + for match, replacement in recipe_replacements: + if ( + not match + or len(match) > MAX_LAZY_LAYER_TRANSFORM_PATTERN_BYTES + or len(replacement) > MAX_LAZY_LAYER_TRANSFORM_PATTERN_BYTES + ): + fail("Homebrew materialization exceeds the pattern byte limit") + decoded_bytes += len(match) + len(replacement) + for pattern in rejected_patterns: + if ( + not pattern + or len(pattern) > MAX_LAZY_LAYER_TRANSFORM_PATTERN_BYTES + ): + fail("Homebrew materialization exceeds the pattern byte limit") + decoded_bytes += len(pattern) + if ( + decoded_bytes > TAR_MAX_SAFE_INTEGER + or decoded_bytes > MAX_LAZY_LAYER_MATERIALIZATION_DECODED_BYTES + ): + fail("Homebrew materialization exceeds the decoded byte limit") def relocate_homebrew_bottle_file( - value: bytes, receipt: dict[str, Any], path: str + value: bytes, receipt: dict[str, Any], path: str, destination_prefix: str ) -> bytes: + replacements = homebrew_replacements(destination_prefix) relocated = value - for placeholder, replacement in HOMEBREW_REPLACEMENTS: - relocated = relocated.replace(placeholder, replacement) + for placeholder, replacement in replacements: + relocated = replace_homebrew_bytes_bounded( + relocated, + placeholder, + replacement, + path, + ) if HOMEBREW_JAVA_PLACEHOLDER in relocated: - java_home = homebrew_java_home(receipt.get("runtime_dependencies")) + java_home = homebrew_java_home( + receipt.get("runtime_dependencies"), destination_prefix + ) if java_home is None: fail( f"Homebrew changed file {path} uses " "@@HOMEBREW_JAVA@@ without exactly one OpenJDK runtime dependency" ) - relocated = relocated.replace(HOMEBREW_JAVA_PLACEHOLDER, java_home) - for placeholder, _ in (*HOMEBREW_REPLACEMENTS, (HOMEBREW_JAVA_PLACEHOLDER, b"")): + relocated = replace_homebrew_bytes_bounded( + relocated, + HOMEBREW_JAVA_PLACEHOLDER, + java_home, + path, + ) + for placeholder, _ in (*replacements, (HOMEBREW_JAVA_PLACEHOLDER, b"")): if placeholder in relocated: fail( f"Homebrew changed file {path} retains " @@ -1609,6 +1766,7 @@ def original_bottle_relocation( source_entries: list[dict[str, Any]], canonical_source_by_path: dict[str, dict[str, Any]], expanded_bytes: int, + destination_prefix: str, ) -> dict[str, Any]: source_by_path = {entry["path"]: entry for entry in source_entries} receipts = [ @@ -1617,7 +1775,11 @@ def original_bottle_relocation( or entry["path"].endswith("/INSTALL_RECEIPT.json") ] if not receipts: - return {"source_paths": set(), "bytes_by_canonical": {}} + return { + "source_paths": set(), + "bytes_by_canonical": {}, + "descriptor": None, + } if len(receipts) > 1: fail( f"Homebrew deferred bottle has {len(receipts)} INSTALL_RECEIPT.json " @@ -1658,13 +1820,20 @@ def regular_bytes(source: dict[str, Any]) -> bytes: ) return extracted.read() - receipt = parse_homebrew_install_receipt(regular_bytes(receipt_source)) + receipt_bytes = regular_bytes(receipt_source) + if ( + len(receipt_bytes) + > MAX_LAZY_LAYER_MATERIALIZATION_ASSERTION_BYTES + ): + fail("Homebrew materialization exceeds the assertion byte limit") + receipt = parse_homebrew_install_receipt(receipt_bytes) separator = receipt_source["path"].rfind("/") source_root = ( "" if separator < 0 else receipt_source["path"][:separator] ) source_paths: set[str] = set() bytes_by_canonical: dict[str, bytes] = {} + input_by_canonical: dict[str, bytes] = {} for relative in receipt["changed_files"]: source_path = relative if not source_root else f"{source_root}/{relative}" source = source_by_path.get(source_path) @@ -1679,16 +1848,78 @@ def regular_bytes(source: dict[str, Any]) -> bytes: ) assert canonical is not None relocated = relocate_homebrew_bottle_file( - regular_bytes(source), receipt, source_path + regular_bytes(source), receipt, source_path, destination_prefix ) prior = bytes_by_canonical.get(canonical["path"]) if prior is not None and prior != relocated: fail("Homebrew hard-link aliases produce different relocated bytes") source_paths.add(source_path) + input_by_canonical[canonical["path"]] = regular_bytes(source) bytes_by_canonical[canonical["path"]] = relocated + replacements = homebrew_replacements(destination_prefix) + raw_recipe_replacements = list(replacements) + java_home = homebrew_java_home( + receipt.get("runtime_dependencies"), destination_prefix + ) + if java_home is not None: + raw_recipe_replacements.append( + (HOMEBREW_JAVA_PLACEHOLDER, java_home) + ) + rejected_patterns = [ + match + for match, _ in ( + *replacements, + (HOMEBREW_JAVA_PLACEHOLDER, b""), + ) + ] + validate_homebrew_materialization_bounds( + receipt_bytes, + raw_recipe_replacements, + rejected_patterns, + len(bytes_by_canonical), + ) + recipe_replacements = [ + {"matchHex": match.hex(), "replacementHex": replacement.hex()} + for match, replacement in raw_recipe_replacements + ] + transforms = [ + { + "sourcePath": path, + "recipe": HOMEBREW_RELOCATION_RECIPE_ID, + "input": { + "sha256": digest_bytes(input_by_canonical[path]), + "bytes": len(input_by_canonical[path]), + }, + "output": { + "sha256": digest_bytes(bytes_by_canonical[path]), + "bytes": len(bytes_by_canonical[path]), + }, + } + for path in sorted(bytes_by_canonical) + ] + materialization = { + "schema": 1, + "kind": "archive-byte-transforms-v1", + "assertions": [{ + "sourcePath": receipt_canonical["path"], + "bytesHex": receipt_bytes.hex(), + }], + "recipes": [] if not transforms else [{ + "id": HOMEBREW_RELOCATION_RECIPE_ID, + "replacements": recipe_replacements, + "rejectHex": [pattern.hex() for pattern in rejected_patterns], + }], + "transforms": transforms, + } return { "source_paths": source_paths, "bytes_by_canonical": bytes_by_canonical, + "descriptor": { + "schema": 1, + "kind": "homebrew-bottle-relocation-v1", + "receipt_source_path": receipt_source["path"], + "materialization": materialization, + }, } except tarfile.TarError as error: fail(f"Homebrew deferred TAR is invalid: {error}") @@ -1697,8 +1928,10 @@ def regular_bytes(source: dict[str, Any]) -> bytes: def validate_original_bottle_inventory( value: Any, *, + descriptor_schema: int, tree_id: str, archive_value: bytes, + destination_prefix: str, ) -> tuple[ list[dict[str, Any]], list[dict[str, Any]], @@ -1711,7 +1944,7 @@ def validate_original_bottle_inventory( "layer_entry_count", "mergeable_directory_count", "expanded_bytes", "payload_bytes", "source", "entries", } - if set(inventory) != expected_inventory: + if set(inventory) not in (expected_inventory, expected_inventory | {"relocation"}): fail(f"Homebrew deferred tree {tree_id} inventory has unexpected fields") source_entries, canonical_source_by_path = validate_original_bottle_source( inventory.get("source"), tree_id @@ -1726,7 +1959,22 @@ def validate_original_bottle_inventory( source_entries, canonical_source_by_path, expanded_bytes, + destination_prefix, ) + if relocation["descriptor"] is None: + if "relocation" in inventory: + fail(f"Homebrew deferred tree {tree_id} has relocation without a receipt") + elif descriptor_schema != 6: + fail( + f"Homebrew deferred tree {tree_id} receipt relocation requires " + "runtime-layer schema 6" + ) + else: + exact( + inventory.get("relocation"), + relocation["descriptor"], + f"Homebrew deferred tree {tree_id} relocation plan", + ) source_by_path = {entry["path"]: entry for entry in source_entries} raw_entries = array(inventory.get("entries"), f"Homebrew deferred tree {tree_id} entries") if not raw_entries or len(raw_entries) > MAX_LAZY_LAYER_ENTRIES: @@ -1758,7 +2006,7 @@ def validate_original_bottle_inventory( path = safe_relative_path( entry.get("path"), f"Homebrew deferred tree {tree_id} entry {index} path" ) - if path != HOMEBREW_PREFIX[1:] and not path.startswith(f"{HOMEBREW_PREFIX[1:]}/"): + if path != destination_prefix[1:] and not path.startswith(f"{destination_prefix[1:]}/"): fail(f"Homebrew deferred tree {tree_id} entry {index} escapes Homebrew") if path in by_path: fail(f"Homebrew deferred tree {tree_id} duplicates guest path {path}") @@ -2381,6 +2629,7 @@ def validate_canonical_original_bottle_trees( def validate_original_bottle_trees( trees: list[Any], *, + descriptor_schema: int, payload_values: dict[str, bytes], layer_packages: list[dict[str, Any]], applied_link_contracts: dict[str, dict[str, Any]], @@ -2389,6 +2638,7 @@ def validate_original_bottle_trees( runtime_id: str, root_full_name: str, draft: bool, + destination_prefix: str, ) -> None: if not trees or len(trees) > len(layer_packages): fail("Homebrew original bottle trees differ from the layer package set") @@ -2408,6 +2658,17 @@ def validate_original_bottle_trees( tree = record(raw, f"Homebrew deferred tree {index}") if set(tree) != {"id", "package", "activation", "content", "transports", "inventory"}: fail(f"Homebrew deferred tree {index} has unexpected fields") + if ( + descriptor_schema != 6 + and "relocation" in record( + tree.get("inventory"), + f"Homebrew deferred tree {index} inventory", + ) + ): + fail( + f"Homebrew deferred tree {index} carries a schema-6 relocation " + f"plan under schema {descriptor_schema}" + ) tree_id = string( tree.get("id"), f"Homebrew deferred tree {index} id", @@ -2518,7 +2779,11 @@ def validate_original_bottle_trees( canonical_source_by_path, relocation, ) = validate_original_bottle_inventory( - tree.get("inventory"), tree_id=tree_id, archive_value=archive_value + tree.get("inventory"), + descriptor_schema=descriptor_schema, + tree_id=tree_id, + archive_value=archive_value, + destination_prefix=destination_prefix, ) entries_by_path = {entry["path"]: entry for entry in entries} keg_path = package["keg"].removeprefix("/") @@ -2627,6 +2892,9 @@ def validate_lazy_layer( draft: bool = False, payload_paths: dict[str, Path] | None = None, ) -> None: + destination_prefix = normalize_homebrew_destination_prefix( + result.get("destination_prefix"), "authenticated Homebrew destination prefix" + ) descriptor_value, descriptor_raw = read_json( descriptor_path, "Homebrew lazy layer descriptor" ) @@ -2672,8 +2940,8 @@ def validate_lazy_layer( if set(descriptor) != expected_top_level: fail("Homebrew lazy layer descriptor has unexpected fields") descriptor_schema = descriptor.get("schema") - if descriptor_schema not in (4, 5): - fail("Homebrew lazy layer schema must be 4 or 5") + if descriptor_schema not in (4, 5, 6): + fail("Homebrew lazy layer schema must be 4, 5, or 6") exact( descriptor.get("kind"), ( @@ -2903,7 +3171,11 @@ def validate_lazy_layer( if set(packages_value) != {"base", "layer"}: fail("Homebrew lazy layer packages have unexpected fields") base_packages = [ - validate_lazy_package_record(value, f"Homebrew lazy layer base package {index}") + validate_lazy_package_record( + value, + f"Homebrew lazy layer base package {index}", + destination_prefix, + ) for index, value in enumerate( bounded_array( packages_value.get("base"), @@ -2913,7 +3185,11 @@ def validate_lazy_layer( ) ] layer_packages = [ - validate_lazy_package_record(value, f"Homebrew lazy layer package {index}") + validate_lazy_package_record( + value, + f"Homebrew lazy layer package {index}", + destination_prefix, + ) for index, value in enumerate( bounded_array( packages_value.get("layer"), @@ -3229,9 +3505,10 @@ def validate_lazy_layer( ) trees = array(descriptor.get("deferred_trees"), "Homebrew deferred trees") - if descriptor_schema == 5: + if descriptor_schema in (5, 6): validate_original_bottle_trees( trees, + descriptor_schema=descriptor_schema, payload_values=payload_values, layer_packages=layer_packages, applied_link_contracts=result["applied_link_contracts"], @@ -3240,6 +3517,7 @@ def validate_lazy_layer( runtime_id=runtime_id, root_full_name=result["root_full_name"], draft=draft, + destination_prefix=destination_prefix, ) if not draft: exact( @@ -3412,8 +3690,8 @@ def validate_lazy_layer( ): fail(f"Homebrew lazy layer entry {index} has an unsafe path") if not ( - path == "opt/kandelo/homebrew" - or path.startswith("opt/kandelo/homebrew/") + path == destination_prefix[1:] + or path.startswith(f"{destination_prefix[1:]}/") ): fail(f"Homebrew lazy layer entry {index} escapes the Homebrew prefix") if path in seen_paths: @@ -4087,7 +4365,7 @@ def close_lazy_layer_descriptor( browser_value: bytes, ) -> dict[str, Any]: if ( - draft.get("schema") not in (4, 5) + draft.get("schema") not in (4, 5, 6) or draft.get("kind") != "kandelo-homebrew-deferred-layer-draft" ): fail("Homebrew lazy layer closer received a non-draft descriptor") diff --git a/scripts/test-homebrew-tap-native-sidecars.sh b/scripts/test-homebrew-tap-native-sidecars.sh index 15a0054c64..7be33f44b9 100755 --- a/scripts/test-homebrew-tap-native-sidecars.sh +++ b/scripts/test-homebrew-tap-native-sidecars.sh @@ -1562,7 +1562,7 @@ PY )" jq -e ' . as $descriptor | - .schema == 5 and .kind == "kandelo-homebrew-deferred-layer-draft" and + .schema == 6 and .kind == "kandelo-homebrew-deferred-layer-draft" and .mount_prefix == "/" and .selection.requested_packages == ["sidecar-tool"] and .selection.package_order == [ @@ -1603,6 +1603,16 @@ jq -e ' $tree.inventory.entry_count == ($tree.inventory.entries | length) and $tree.inventory.source.schema == 1 and $tree.inventory.source.kind == "homebrew-bottle-tar-gzip-v1" and + $tree.inventory.relocation.schema == 1 and + $tree.inventory.relocation.kind == "homebrew-bottle-relocation-v1" and + $tree.inventory.relocation.receipt_source_path == + "sidecar-tool/2.0_3/INSTALL_RECEIPT.json" and + $tree.inventory.relocation.materialization.schema == 1 and + $tree.inventory.relocation.materialization.kind == + "archive-byte-transforms-v1" and + ($tree.inventory.relocation.materialization.assertions | length) == 1 and + $tree.inventory.relocation.materialization.recipes == [] and + $tree.inventory.relocation.materialization.transforms == [] and $tree.inventory.source_entry_count == ($tree.inventory.source.entries | length) and [$tree.inventory.source.entries[].path] == ([$tree.inventory.source.entries[].path] | unique | sort) and @@ -1629,7 +1639,7 @@ jq -e ' --argjson bottle_bytes "${tool_bottle[3]}" \ --argjson expanded_bytes "$RUNTIME_LAYER_EXPANDED_BYTES" \ "$RUNTIME_LAYER_DESCRIPTOR" >/dev/null || { - echo "schema-5 original-bottle descriptor assertion failed" >&2 + echo "schema-6 original-bottle descriptor assertion failed" >&2 jq . "$RUNTIME_LAYER_DESCRIPTOR" >&2 exit 1 } @@ -1654,7 +1664,7 @@ def require(condition, message): payload = payload_path.read_bytes() original = original_path.read_bytes() descriptor = json.loads(descriptor_path.read_text()) -require(descriptor["schema"] == 5, "runtime layer is not schema 5") +require(descriptor["schema"] == 6, "runtime layer is not schema 6") require( descriptor["kind"] == "kandelo-homebrew-deferred-layer-draft", "runtime layer is not an inert producer draft", diff --git a/scripts/test-homebrew-vfs-release-fixture.ts b/scripts/test-homebrew-vfs-release-fixture.ts index e2d51cfc17..e8558ac99d 100644 --- a/scripts/test-homebrew-vfs-release-fixture.ts +++ b/scripts/test-homebrew-vfs-release-fixture.ts @@ -136,7 +136,7 @@ const collection = await buildHomebrewOriginalBottleCollection(plan, { treeIdOverrides: new Map([["kandelo-dev/tap-core/file-formula", "file-formula"]]), }); -descriptor.schema = 5; +descriptor.schema = 6; descriptor.selection.requested_packages = [...plan.requestedPackages]; descriptor.selection.package_order = packagePlans.map((pkg) => pkg.fullName); descriptor.selection.base_package_order = []; diff --git a/scripts/test-homebrew-vfs-release.sh b/scripts/test-homebrew-vfs-release.sh index 127b9e05c7..886a389033 100755 --- a/scripts/test-homebrew-vfs-release.sh +++ b/scripts/test-homebrew-vfs-release.sh @@ -43,11 +43,11 @@ import tarfile root = pathlib.Path(sys.argv[1]) -def bottle(name, version, command): +def bottle(name, version, command, receipt=None): stream = io.BytesIO() payload_root = f"{name}/{version}" with tarfile.open(fileobj=stream, mode="w:", format=tarfile.USTAR_FORMAT) as archive: - for path in (payload_root, f"{payload_root}/bin"): + for path in (payload_root, f"{payload_root}/bin", f"{payload_root}/lib"): info = tarfile.TarInfo(f"{path}/") info.type = tarfile.DIRTYPE info.mode = 0o755 @@ -62,13 +62,32 @@ def bottle(name, version, command): link.mode = 0o777 link.linkname = command archive.addfile(link) + if receipt is not None: + receipt_value = ( + b'{"changed_files":["INSTALL_RECEIPT.json","lib/runtime.conf"],' + b'"runtime_dependencies":[{"full_name":"openjdk@21"}],' + b'"source":{"path":"@@HOMEBREW_LIBRARY@@/Formula/' + name.encode() + b'.rb"}}\n' + ) + receipt_info = tarfile.TarInfo(f"{payload_root}/INSTALL_RECEIPT.json") + receipt_info.mode = 0o644 + receipt_info.size = len(receipt_value) + archive.addfile(receipt_info, io.BytesIO(receipt_value)) + config_value = ( + b"prefix=@@HOMEBREW_PREFIX@@\n" + b"library=@@HOMEBREW_LIBRARY@@\n" + b"java=@@HOMEBREW_JAVA@@\n" + ) + config_info = tarfile.TarInfo(f"{payload_root}/lib/runtime.conf") + config_info.mode = 0o644 + config_info.size = len(config_value) + archive.addfile(config_info, io.BytesIO(config_value)) (root / f"{name}.bottle.tar.gz").write_bytes( gzip.compress(stream.getvalue(), mtime=0) ) bottle("dash", "0.5.12", "dash") -bottle("file-formula", "5.46", "file") +bottle("file-formula", "5.46", "file", receipt=True) PY file_bottle="$prebuilt/file-formula.bottle.tar.gz" dash_bottle="$prebuilt/dash.bottle.tar.gz" @@ -119,7 +138,11 @@ jq -nS \ }, {type: "file", source: "bin/file", target: "bin/file-default"} ], - receipts: ["Cellar/file-formula/5.46/bin/file"], + receipts: [ + "Cellar/file-formula/5.46/bin/file", + "Cellar/file-formula/5.46/INSTALL_RECEIPT.json", + "Cellar/file-formula/5.46/lib/runtime.conf" + ], env: {PATH_prepend: ["bin"]} } ' >"$tap/Kandelo/links/file-formula.json" @@ -249,7 +272,11 @@ jq -nS \ prefix: "/opt/kandelo/homebrew", keg: "/opt/kandelo/homebrew/Cellar/file-formula/5.46", staged_files: 1, staged_directories: 2, staged_symlinks: 1, - receipts: ["Cellar/file-formula/5.46/bin/file"], + receipts: [ + "Cellar/file-formula/5.46/bin/file", + "Cellar/file-formula/5.46/INSTALL_RECEIPT.json", + "Cellar/file-formula/5.46/lib/runtime.conf" + ], links: ["bin/file", "bin/file-default"], opt_link: {path: "opt/file-formula", target: "../Cellar/file-formula/5.46"}, built_from: { @@ -673,7 +700,7 @@ cmp "$direct_source/$dependency_asset" \ "$direct_handoff/$dependency_asset" >/dev/null || fail "release handoff recompressed the dependency bottle" jq -e ' - .schema == 5 and + .schema == 6 and .kind == "kandelo-homebrew-deferred-layer" and (.deferred_trees | length) == 2 and ([.deferred_trees[].package] | sort) == [ @@ -683,6 +710,133 @@ jq -e ' ' "$direct_handoff/kandelo-homebrew-file-formula-layer.json" >/dev/null || fail "direct multi-bottle closure is incomplete" +# A receipt-authenticated bottle destination is not a host default. Exercise +# the actual prepare/close/validate path with the historical Linuxbrew prefix; +# the generated sidecar, activation roots, and inventory must keep that exact +# namespace all the way through publication. +historical_prefix="/home/linuxbrew/.linuxbrew" +historical_tap="$TMP_ROOT/historical-tap" +historical_dependency_tap="$TMP_ROOT/historical-dependency-tap" +historical_source="$TMP_ROOT/historical-source" +cp -a "$tap" "$historical_tap" +cp -a "$dependency_tap" "$historical_dependency_tap" +cp -a "$source_root" "$historical_source" +PYTHONDONTWRITEBYTECODE=1 python3 - \ + "$historical_prefix" "$historical_tap" "$historical_dependency_tap" <<'PY' +import json +import pathlib +import sys + +prefix, *roots = sys.argv[1:] +old = "/opt/kandelo/homebrew" + +def rewrite(value): + if isinstance(value, str): + return value.replace(old, prefix) + if isinstance(value, list): + return [rewrite(item) for item in value] + if isinstance(value, dict): + return {key: rewrite(item) for key, item in value.items()} + return value + +for root_text in roots: + root = pathlib.Path(root_text) + for path in sorted(root.rglob("*.json")): + value = json.loads(path.read_text()) + path.write_text(json.dumps(rewrite(value), sort_keys=True, indent=2) + "\n") +PY +git -C "$historical_tap" add . +git -C "$historical_tap" commit -q -m "historical Linuxbrew prefix" +historical_tap_commit="$(git -C "$historical_tap" rev-parse HEAD)" +git -C "$historical_dependency_tap" add . +git -C "$historical_dependency_tap" commit -q -m "historical Linuxbrew prefix" +historical_dependency_tap_commit="$(git -C "$historical_dependency_tap" rev-parse HEAD)" +PYTHONDONTWRITEBYTECODE=1 python3 - \ + "$historical_source" "$historical_prefix" "$tap_commit" "$historical_tap_commit" \ + "$dependency_tap_commit" "$historical_dependency_tap_commit" "$historical_tap" <<'PY' +import hashlib +import json +import pathlib +import sys + +source = pathlib.Path(sys.argv[1]) +prefix, old_tap, new_tap, old_dependency, new_dependency, tap_root = sys.argv[2:] +old_prefix = "/opt/kandelo/homebrew" + +def rewrite(value): + if isinstance(value, str): + return (value.replace(old_prefix, prefix) + .replace(old_tap, new_tap) + .replace(old_dependency, new_dependency)) + if isinstance(value, list): + return [rewrite(item) for item in value] + if isinstance(value, dict): + return {key: rewrite(item) for key, item in value.items()} + return value + +for name in ("report.json", "node.json", "browser.json", "layer.json"): + path = source / name + path.write_text(json.dumps(rewrite(json.loads(path.read_text())), sort_keys=True, indent=2) + "\n") + +shell = (pathlib.Path(tap_root) / "Kandelo" / "shell.json").read_bytes() +for name in ("report.json", "node.json"): + path = source / name + value = json.loads(path.read_text()) + value["default_shell"]["config_sha256"] = hashlib.sha256(shell).hexdigest() + value["default_shell"]["config_bytes"] = len(shell) + path.write_text(json.dumps(value, sort_keys=True, indent=2) + "\n") +PY +"$TSX" "$REPO_ROOT/scripts/test-homebrew-vfs-release-fixture.ts" \ + "$historical_source" "$historical_tap" "$historical_dependency_tap" "$prebuilt" +historical_dependency_tree_id="$(jq -er ' + .deferred_trees[] | select(.package == "third-party/runtime/dash") | .id +' "$historical_source/layer.json")" +historical_dependency_asset="$(jq -er --arg id "$historical_dependency_tree_id" ' + .deferred_trees[] | select(.id == $id) | + .transports[] | select(.kind == "bundle-release") | .asset +' "$historical_source/layer.json")" +historical_handoff="$TMP_ROOT/historical-handoff" +historical_identity_args=( + --tap-root "$historical_tap" + --dependency-tap-root "third-party/runtime=$historical_dependency_tap" + --tap-repository kandelo-dev/homebrew-tap-core + --tap-name kandelo-dev/tap-core + --tap-commit "$historical_tap_commit" + --formula file-formula + --kandelo-commit "$kandelo_commit" + --abi 42 + --bottle-release-tag bottles-abi-v42 +) +python3 "$REPO_ROOT/scripts/homebrew-vfs-release.py" prepare \ + --image "$historical_source/image.vfs.zst" \ + --report "$historical_source/report.json" \ + --node-evidence "$historical_source/node.json" \ + --browser-evidence "$historical_source/browser.json" \ + --lazy-layer "$historical_source/direct-root.bin" \ + --lazy-layer-descriptor "$historical_source/layer.json" \ + --out "$historical_handoff" "${historical_identity_args[@]}" >/dev/null +python3 "$REPO_ROOT/scripts/homebrew-vfs-release.py" validate \ + --handoff "$historical_handoff" "${historical_identity_args[@]}" >/dev/null +jq -e --arg prefix "$historical_prefix" ' + ([.packages.base[], .packages.layer[]] | all(.[]; .prefix == $prefix)) and + ([.deferred_trees[].activation.roots[]] | + all(.[]; startswith($prefix + "/Cellar/"))) and + ([.deferred_trees[].inventory.entries[]] | + all(.[]; .path == $prefix[1:] or (.path | startswith($prefix[1:] + "/")))) and + ([.deferred_trees[].inventory.entries[] | + select(.source_path == "file-formula/5.46/lib/runtime.conf") | + .materialization] == ["archive-homebrew-relocate"]) and + ([.deferred_trees[].inventory.relocation?.materialization.transforms[]? | + select(.sourcePath == "file-formula/5.46/lib/runtime.conf")] | length) == 1 +' "$historical_handoff/kandelo-homebrew-file-formula-layer.json" >/dev/null || + fail "historical receipt destination escaped its authenticated namespace" +grep -F -- '/opt/kandelo/homebrew' \ + "$historical_handoff/kandelo-homebrew-file-formula-layer.json" >/dev/null && + fail "historical receipt destination retained the ambient Homebrew prefix" +cmp "$historical_source/$historical_dependency_asset" \ + "$historical_handoff/$historical_dependency_asset" >/dev/null || + fail "historical receipt publication recompressed a dependency bottle" + # A failed staged copy must leave no final handoff and the exact same output # path must be immediately retryable. python3 - \ @@ -813,6 +967,26 @@ expect_direct_prepare_failure() { --out "$output" "${common_args[@]}" } +direct_draft_negative="$TMP_ROOT/direct-draft-schema5-plan-negative" +cp -a "$direct_source" "$direct_draft_negative" +jq '.schema = 5' \ + "$direct_draft_negative/layer.json" >"$direct_draft_negative/layer.tmp" +mv "$direct_draft_negative/layer.tmp" "$direct_draft_negative/layer.json" +expect_direct_prepare_failure \ + "draft validator accepted a schema-6 relocation plan under schema 5" \ + "carries a schema-6 relocation plan under schema 5" \ + "$direct_draft_negative" "$TMP_ROOT/direct-schema5-plan-output" + +direct_draft_negative="$TMP_ROOT/direct-draft-schema5-receipt-negative" +cp -a "$direct_source" "$direct_draft_negative" +jq '.schema = 5 | del(.deferred_trees[].inventory.relocation)' \ + "$direct_draft_negative/layer.json" >"$direct_draft_negative/layer.tmp" +mv "$direct_draft_negative/layer.tmp" "$direct_draft_negative/layer.json" +expect_direct_prepare_failure \ + "draft validator accepted schema-5 receipt relocation without a plan" \ + "receipt relocation requires runtime-layer schema 6" \ + "$direct_draft_negative" "$TMP_ROOT/direct-schema5-receipt-output" + direct_draft_negative="$TMP_ROOT/direct-draft-mode-negative" cp -a "$direct_source" "$direct_draft_negative" jq '(.deferred_trees[].inventory.entries[] | @@ -986,7 +1160,9 @@ import sys release = runpy.run_path(sys.argv[1]) parse_receipt = release["parse_homebrew_install_receipt"] java_home = release["homebrew_java_home"] -prefix = release["HOMEBREW_PREFIX"] +normalize_destination = release["normalize_homebrew_destination_prefix"] +destination_for = release["homebrew_destination_prefix"] +prefix = "/opt/kandelo/homebrew" ValidationError = release["ValidationError"] assert parse_receipt(b'{"changed_files":null}') == { "changed_files": [], @@ -998,10 +1174,26 @@ except ValidationError as error: assert "changed_files must be an array or null" in str(error) else: raise AssertionError("non-null non-array changed_files was accepted") -assert java_home([{"full_name": None, "name": "openjdk@21"}]) == ( +assert java_home([{"full_name": None, "name": "openjdk@21"}], prefix) == ( f"{prefix}/opt/openjdk@21/libexec".encode() ) -assert java_home([{"full_name": "openjdk@21\n"}]) is None +assert java_home([{"full_name": "openjdk@21\n"}], prefix) is None +for invalid in ("", "relative/homebrew", "/opt/../homebrew", "/opt/./homebrew", "/opt\0homebrew"): + try: + normalize_destination(invalid, "fixture destination") + except ValidationError: + pass + else: + raise AssertionError(f"unsafe destination was accepted: {invalid!r}") +try: + destination_for([ + {"prefix": prefix}, + {"prefix": "/home/linuxbrew/.linuxbrew"}, + ], "fixture") +except ValidationError as error: + assert "destinations are inconsistent" in str(error) +else: + raise AssertionError("mixed destinations were accepted") validate = release["validate_canonical_original_bottle_trees"] tree_id = release["expected_original_bottle_tree_id"] @@ -1167,6 +1359,193 @@ PYTHONDONTWRITEBYTECODE=1 python3 - \ "$REPO_ROOT/scripts/homebrew-vfs-release.py" <<'PY' import gzip import io +import json +import runpy +import sys +import tarfile + +release = runpy.run_path(sys.argv[1]) +original_relocation = release["original_bottle_relocation"] +relocate_file = release["relocate_homebrew_bottle_file"] +ValidationError = release["ValidationError"] +runtime = original_relocation.__globals__ + +expected_limits = { + "MAX_LAZY_LAYER_MATERIALIZATION_ASSERTIONS": 32, + "MAX_LAZY_LAYER_MATERIALIZATION_ASSERTION_BYTES": 1024 * 1024, + "MAX_LAZY_LAYER_MATERIALIZATION_RECIPES": 32, + "MAX_LAZY_LAYER_MATERIALIZATION_TRANSFORMS": 100_000, + "MAX_LAZY_LAYER_MATERIALIZATION_DECODED_BYTES": 8 * 1024 * 1024, + "MAX_LAZY_LAYER_TRANSFORM_REPLACEMENTS": 32, + "MAX_LAZY_LAYER_TRANSFORM_PATTERN_BYTES": 8192, +} +for name, expected in expected_limits.items(): + if runtime[name] != expected: + raise AssertionError(f"{name} differs from the runtime bound") + + +def archive_fixture(changed_files): + receipt = json.dumps( + {"changed_files": changed_files}, + separators=(",", ":"), + ).encode() + b"\n" + values = {"pkg/1/INSTALL_RECEIPT.json": receipt} + for path in changed_files: + values[f"pkg/1/{path}"] = b"prefix=@@HOMEBREW_PREFIX@@\n" + stream = io.BytesIO() + with tarfile.open(fileobj=stream, mode="w:", format=tarfile.PAX_FORMAT) as archive: + for path in sorted(values): + value = values[path] + info = tarfile.TarInfo(path) + info.mode = 0o644 + info.size = len(value) + archive.addfile(info, io.BytesIO(value)) + tar_value = stream.getvalue() + entries = [ + {"path": path, "type": "file", "mode": 0o644, "size": len(value)} + for path, value in sorted(values.items()) + ] + return receipt, gzip.compress(tar_value, mtime=0), tar_value, entries + + +def publish(changed_files): + receipt, archive, tar_value, entries = archive_fixture(changed_files) + return receipt, original_relocation( + archive, + entries, + {}, + len(tar_value), + "/x", + ) + + +def expect_rejected(label, operation, message): + try: + operation() + except ValidationError as error: + if message not in str(error): + raise AssertionError( + f"{label} failed for the wrong reason: {error}" + ) from error + else: + raise AssertionError(f"release publisher accepted {label}") + + +bmp = "\ue000" +non_bmp = "\U00010000" +changed = [f"lib/{bmp}", f"lib/{non_bmp}"] +receipt, scalar = publish(changed) +plan = scalar["descriptor"]["materialization"] +if [item["sourcePath"] for item in plan["transforms"]] != [ + f"pkg/1/lib/{bmp}", + f"pkg/1/lib/{non_bmp}", +]: + raise AssertionError("publisher did not use Python Unicode-scalar order") + + +def check_boundary(name, boundary, message): + saved = runtime[name] + try: + runtime[name] = boundary + publish(changed) + runtime[name] = boundary - 1 + expect_rejected(name, lambda: publish(changed), message) + finally: + runtime[name] = saved + + +check_boundary( + "MAX_LAZY_LAYER_MATERIALIZATION_ASSERTIONS", + 1, + "assertion count limit", +) +check_boundary( + "MAX_LAZY_LAYER_MATERIALIZATION_ASSERTION_BYTES", + len(receipt), + "assertion byte limit", +) +check_boundary( + "MAX_LAZY_LAYER_MATERIALIZATION_RECIPES", + 1, + "recipe count limit", +) +check_boundary( + "MAX_LAZY_LAYER_MATERIALIZATION_TRANSFORMS", + len(changed), + "transform count limit", +) +check_boundary( + "MAX_LAZY_LAYER_TRANSFORM_REPLACEMENTS", + max( + len(plan["recipes"][0]["replacements"]), + len(plan["recipes"][0]["rejectHex"]), + ), + "replacement count limit", +) +pattern_boundary = max( + len(bytes.fromhex(value)) + for recipe in plan["recipes"] + for replacement in recipe["replacements"] + for value in (replacement["matchHex"], replacement["replacementHex"]) +) +pattern_boundary = max( + pattern_boundary, + max(len(bytes.fromhex(value)) for value in plan["recipes"][0]["rejectHex"]), +) +check_boundary( + "MAX_LAZY_LAYER_TRANSFORM_PATTERN_BYTES", + pattern_boundary, + "pattern byte limit", +) +decoded_boundary = sum( + len(bytes.fromhex(assertion["bytesHex"])) + for assertion in plan["assertions"] +) + sum( + len(bytes.fromhex(value)) + for recipe in plan["recipes"] + for replacement in recipe["replacements"] + for value in (replacement["matchHex"], replacement["replacementHex"]) +) + sum( + len(bytes.fromhex(value)) + for recipe in plan["recipes"] + for value in recipe["rejectHex"] +) +check_boundary( + "MAX_LAZY_LAYER_MATERIALIZATION_DECODED_BYTES", + decoded_boundary, + "decoded byte limit", +) + +expanding_source = b"@@HOMEBREW_PREFIX@@" * 2 +destination = "/" + "x" * 40 +expanded_boundary = len(destination.encode()) * 2 +saved_output = runtime["MAX_LAZY_LAYER_UNCOMPRESSED_BYTES"] +saved_safe = runtime["TAR_MAX_SAFE_INTEGER"] +try: + runtime["MAX_LAZY_LAYER_UNCOMPRESSED_BYTES"] = expanded_boundary + if len(relocate_file(expanding_source, {}, "bin/tool", destination)) != expanded_boundary: + raise AssertionError("publisher changed the exact transformed-byte boundary") + runtime["MAX_LAZY_LAYER_UNCOMPRESSED_BYTES"] = expanded_boundary - 1 + expect_rejected( + "high-expansion replacement", + lambda: relocate_file(expanding_source, {}, "bin/tool", destination), + "transformed-byte limit", + ) + runtime["MAX_LAZY_LAYER_UNCOMPRESSED_BYTES"] = expanded_boundary + runtime["TAR_MAX_SAFE_INTEGER"] = expanded_boundary - 1 + expect_rejected( + "unsafe replacement arithmetic", + lambda: relocate_file(expanding_source, {}, "bin/tool", destination), + "safe integer limit", + ) +finally: + runtime["MAX_LAZY_LAYER_UNCOMPRESSED_BYTES"] = saved_output + runtime["TAR_MAX_SAFE_INTEGER"] = saved_safe +PY +PYTHONDONTWRITEBYTECODE=1 python3 - \ + "$REPO_ROOT/scripts/homebrew-vfs-release.py" <<'PY' +import gzip +import io import runpy import sys import tarfile From c9a7302fed7bb9836309e87699ed3cb93548fce3 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Mon, 10 Aug 2026 20:02:34 -0400 Subject: [PATCH 58/82] VFS: Default executable mounts to nosuid Mounts now ignore set-ID bits unless a read-only internal product backend proves immutable open-handle generation identity. Invalid trusted requests fail during mount construction, and Node and browser report ST_NOSUID through the shared VFS path. Add the ABI-owned flag binding and a proposal-only kernel helper so the later target-aware exec transaction can compute credentials without granting them in this task. --- abi/snapshot.json | 6 + apps/browser-demos/test/nosuid-exec.spec.ts | 92 +++ crates/kernel/src/syscalls.rs | 65 +- crates/shared/src/lib.rs | 6 + docs/abi-versioning.md | 2 + docs/architecture.md | 13 +- docs/posix-status.md | 3 +- host/src/browser.ts | 14 +- host/src/generated/abi.ts | 4 + host/src/pathconf.ts | 85 ++- host/src/vfs/default-mounts-node.ts | 2 + host/src/vfs/default-mounts.ts | 6 +- host/src/vfs/host-fs.ts | 4 +- host/src/vfs/index.ts | 14 +- host/src/vfs/memory-fs.ts | 383 +++++++++-- host/src/vfs/sharedfs-vendor.ts | 7 +- host/src/vfs/types.ts | 13 + host/src/vfs/vfs.ts | 28 +- host/test/nosuid-exec.test.ts | 695 ++++++++++++++++++++ host/test/vfs.test.ts | 19 +- host/test/vfs/default-mounts.test.ts | 6 + tools/xtask/src/dump_abi.rs | 34 + 22 files changed, 1411 insertions(+), 90 deletions(-) create mode 100644 apps/browser-demos/test/nosuid-exec.spec.ts create mode 100644 host/test/nosuid-exec.test.ts diff --git a/abi/snapshot.json b/abi/snapshot.json index 11e7b1a8e6..e712850899 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -10176,6 +10176,12 @@ "name": "SEEK_END", "value": 2 } + ], + "statfs_flags": [ + { + "name": "ST_NOSUID", + "value": 2 + } ] }, "wait_contract": { diff --git a/apps/browser-demos/test/nosuid-exec.spec.ts b/apps/browser-demos/test/nosuid-exec.spec.ts new file mode 100644 index 0000000000..577c863291 --- /dev/null +++ b/apps/browser-demos/test/nosuid-exec.spec.ts @@ -0,0 +1,92 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "@playwright/test"; + +const testDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(testDir, "../../.."); +const memoryFsModulePath = resolve(repoRoot, "host/src/vfs/memory-fs.ts"); +const timeModulePath = resolve(repoRoot, "host/src/vfs/time.ts"); +const typesModulePath = resolve(repoRoot, "host/src/vfs/types.ts"); +const vfsModulePath = resolve(repoRoot, "host/src/vfs/vfs.ts"); + +test("browser mount policy defaults mutable execution to nosuid", async ({ + page, + baseURL, +}) => { + expect(baseURL).toBeTruthy(); + const asViteFsUrl = (path: string) => new URL(`/@fs${path}`, baseURL).href; + const modules = [ + asViteFsUrl(memoryFsModulePath), + asViteFsUrl(timeModulePath), + asViteFsUrl(typesModulePath), + asViteFsUrl(vfsModulePath), + ]; + for (const moduleUrl of modules) { + const response = await fetch(moduleUrl); + const body = await response.text(); + expect( + response.ok, + `${response.status} ${response.url}: ${body.slice(0, 500)}`, + ).toBe(true); + } + await page.goto(new URL("/trap-signal-test.html", baseURL).href); + + const result = await page.evaluate(async ({ modules }) => { + // Load the shared dependency graph serially so a cold Vite server never + // optimizes the same host modules through concurrent dynamic entries. + const memory = await import(/* @vite-ignore */ modules[0]); + const time = await import(/* @vite-ignore */ modules[1]); + const types = await import(/* @vite-ignore */ modules[2]); + const vfsModule = await import(/* @vite-ignore */ modules[3]); + const mutable = memory.MemoryFileSystem.create( + new SharedArrayBuffer(2 * 1024 * 1024), + ); + mutable.mkdir("/bin", 0o755); + mutable.createFileWithOwner( + "/bin/tool", + 0o6755, + 0, + 42, + new Uint8Array([0, 97, 115, 109]), + ); + const ordinary = new vfsModule.VirtualPlatformIO( + [{ mountPoint: "/", backend: mutable }], + new time.BrowserTimeProvider(), + ); + const trustedBackend = memory.createImmutableProductBackend(mutable); + const trusted = new vfsModule.VirtualPlatformIO( + [{ + mountPoint: "/", + backend: trustedBackend, + readonly: true, + setIdCapability: { + kind: "trusted-root-product", + guestWritable: false, + stableExecutableIdentity: true, + }, + }], + new time.BrowserTimeProvider(), + ); + + return { + mutableFlags: ordinary.statfs("/bin/tool").flags, + mutableCapability: ordinary.getMountSetIdCapability("/bin/tool"), + trustedFlags: trusted.statfs("/bin/tool").flags, + trustedCapability: trusted.getMountSetIdCapability("/bin/tool"), + trustedMode: trusted.stat("/bin/tool").mode, + stNosuid: types.ST_NOSUID, + }; + }, { + modules, + }); + + expect(result.mutableFlags & result.stNosuid).toBe(result.stNosuid); + expect(result.mutableCapability).toEqual({ kind: "nosuid" }); + expect(result.trustedFlags & result.stNosuid).toBe(0); + expect(result.trustedCapability).toEqual({ + kind: "trusted-root-product", + guestWritable: false, + stableExecutableIdentity: true, + }); + expect(result.trustedMode & 0o6000).toBe(0o6000); +}); diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index e5f12635fd..8afb097f8c 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -9,7 +9,9 @@ use wasm_posix_shared::fd_flags::{FD_CLOEXEC, FD_CLOFORK}; use wasm_posix_shared::flags::*; use wasm_posix_shared::flock_op::*; use wasm_posix_shared::lock_type::*; -use wasm_posix_shared::mode::{S_IFCHR, S_IFDIR, S_IFIFO, S_IFLNK, S_IFMT, S_IFREG}; +use wasm_posix_shared::mode::{ + S_IFCHR, S_IFDIR, S_IFIFO, S_IFLNK, S_IFMT, S_IFREG, S_ISGID, S_ISUID, +}; use wasm_posix_shared::rlimit::{RLIMIT_FSIZE, RLIM_INFINITY}; use wasm_posix_shared::seek::*; use wasm_posix_shared::Errno; @@ -16416,7 +16418,7 @@ fn default_statfs() -> WasmStatfs { f_fsid: 0, f_namelen: 255, f_frsize: 4096, - f_flags: 0, + f_flags: wasm_posix_shared::statfs_flags::ST_NOSUID, _pad: 0, } } @@ -16433,7 +16435,7 @@ fn procfs_statfs() -> WasmStatfs { f_fsid: 0, f_namelen: 255, f_frsize: 4096, - f_flags: 0, + f_flags: wasm_posix_shared::statfs_flags::ST_NOSUID, _pad: 0, } } @@ -16450,11 +16452,36 @@ fn devfs_statfs() -> WasmStatfs { f_fsid: 5, f_namelen: 255, f_frsize: 4096, - f_flags: 0, + f_flags: wasm_posix_shared::statfs_flags::ST_NOSUID, _pad: 0, } } +/// Credential change computed from one already prepared executable target. +/// +/// Task 6 deliberately stops at this value object. The target-aware exec +/// transaction owns validation and credential commit in a later task. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct SetIdTransitionProposal { + pub(crate) effective_uid: Option, + pub(crate) effective_gid: Option, +} + +/// Propose set-ID credentials from retained target metadata without applying +/// them to process state. +pub(crate) fn propose_set_id_transition( + stat: &WasmStat, + statfs: &WasmStatfs, +) -> SetIdTransitionProposal { + if (statfs.f_flags & wasm_posix_shared::statfs_flags::ST_NOSUID) != 0 { + return SetIdTransitionProposal::default(); + } + SetIdTransitionProposal { + effective_uid: (stat.st_mode & S_ISUID != 0).then_some(stat.st_uid), + effective_gid: (stat.st_mode & S_ISGID != 0).then_some(stat.st_gid), + } +} + fn virtual_statfs_for_path(resolved: &[u8], pid: u32) -> Option { if crate::procfs::match_procfs(resolved, pid).is_some() || resolved == b"/proc" @@ -38393,6 +38420,36 @@ mod tests { ); } + #[test] + fn set_id_transition_nosuid_target_ignores_bits_without_mutating_credentials() { + let mut stat = test_stat_with_mode(S_IFREG | S_ISUID | S_ISGID | 0o755); + stat.st_uid = 100; + stat.st_gid = 200; + let statfs = default_statfs(); + + assert_eq!( + propose_set_id_transition(&stat, &statfs), + SetIdTransitionProposal::default(), + ); + } + + #[test] + fn set_id_transition_trusted_target_preserves_bits_as_a_proposal_only() { + let mut stat = test_stat_with_mode(S_IFREG | S_ISUID | S_ISGID | 0o755); + stat.st_uid = 100; + stat.st_gid = 200; + let mut statfs = default_statfs(); + statfs.f_flags &= !wasm_posix_shared::statfs_flags::ST_NOSUID; + + assert_eq!( + propose_set_id_transition(&stat, &statfs), + SetIdTransitionProposal { + effective_uid: Some(100), + effective_gid: Some(200), + }, + ); + } + #[test] fn test_procfs_readlink_self() { let mut proc = Process::new(42); diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 96acebc2e7..5099496279 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -970,6 +970,12 @@ pub mod fd_flags { pub const FD_CLOFORK: u32 = 2; } +/// Filesystem mount flags reported through statfs(2). +pub mod statfs_flags { + /// Ignore set-user-ID and set-group-ID mode bits on execution. + pub const ST_NOSUID: u32 = 0x2; +} + /// fcntl command constants (F_*). pub mod fcntl_cmd { pub const F_DUPFD: u32 = 0; diff --git a/docs/abi-versioning.md b/docs/abi-versioning.md index 7b51e1de37..870e95264d 100644 --- a/docs/abi-versioning.md +++ b/docs/abi-versioning.md @@ -149,6 +149,8 @@ not require an `ABI_VERSION` bump: - Adding the initial `host_adapter` snapshot section or adding new optional host-adapter metadata while leaving required existing fields unchanged. +- Adding a new named VFS metadata category, such as `statfs_flags`, while + leaving every existing VFS metadata category unchanged. These additions still require regenerating and committing `abi/snapshot.json`. They do not permit older kernels to run newer diff --git a/docs/architecture.md b/docs/architecture.md index 7069e78d7e..95e4835892 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1567,15 +1567,15 @@ The kernel's hardcoded `INITIAL_BRK` (16MB) is a fallback for binaries that don' ### Mount table model -`VirtualPlatformIO` (`host/src/vfs/vfs.ts`) is the kernel's filesystem router on both hosts. It is configured with a list of `MountConfig { mountPoint, backend, readonly? }` entries and dispatches every path-based syscall to the backend whose mount prefix is the longest match. Cross-mount operations (`rename`, `link`) are rejected with `EXDEV`. A path that matches no mount returns `ENOENT`. `MountConfig.readonly` is currently advisory — write enforcement and full POSIX permission checks are deferred to a follow-up PR. +`VirtualPlatformIO` (`host/src/vfs/vfs.ts`) is the kernel's filesystem router on both hosts. It is configured with a list of `MountConfig { mountPoint, backend, readonly?, setIdCapability? }` entries and dispatches every path-based syscall to the backend whose mount prefix is the longest match. Cross-mount operations (`rename`, `link`) are rejected with `EXDEV`. A path that matches no mount returns `ENOENT`. Omitted set-ID capability means `nosuid`. A `trusted-root-product` request is accepted only when the mount is explicitly read-only and its backend carries the module-private immutable-product brand; no public structural field or configuration value can mint that brand. Malformed or unbranded requests fail during mount construction. The internal factory snapshots a quiescent, fully materialized product tree into privately owned storage before branding its null-prototype read-only facade. The private snapshot uses captured, frozen copies of the complete `MemoryFileSystem` and SharedFS operation prototypes; operation helpers and thresholds are module-lexical rather than mutable class properties; and generated open, access, pathconf, file-mode, and directory-type tables are captured as numeric scalars before they can participate in the trusted path. Each caller-supplied open flag or access mode is normalized and validated once, and only that same primitive integer is used for the guard and delegated operation. Retaining the producer tree, reaching its TypeScript-private backing reflectively, replacing either producer-reachable prototype or class property, mutating a generated table, or supplying a stateful coercible flag therefore grants no post-admission authority over trusted bytes, metadata, or read results. `VirtualPlatformIO.statfs` then authoritatively sets `ST_NOSUID` for nosuid mounts or clears it for the admitted trusted mount, regardless of the backend's raw flags. `MountConfig.readonly` remains advisory for ordinary mounts, while the trusted product facade rejects every guest-visible mutation with `EROFS`. `FileSystemBackend` (`host/src/vfs/types.ts`) is the per-mount interface (open/read/write/stat/readdir/symlink/...). Two backends are in use today: Guest-visible VFS numbers come from `crates/shared` and are recorded under `vfs_metadata` in `abi/snapshot.json`. The generated `host/src/generated/abi.ts` bindings supply open and `*at` flags, descriptor -and `fcntl` values, access modes, file modes, directory-entry types, and seek -constants to shared Node/browser host adapters. This records Kandelo's existing +and `fcntl` values, access modes, statfs flags, file modes, directory-entry +types, and seek constants to shared Node/browser host adapters. This records Kandelo's existing guest ABI; it does not establish a general Linux-compatibility contract. The standalone OPFS worker and the vendored SharedFS implementation retain local copies at their explicit entry-point and vendor boundaries. @@ -1624,6 +1624,13 @@ operations require a lifecycle-owned backing, not merely a reachable one. | `/root` | scratch | empty `MemoryFileSystem` SAB | `HostFileSystem` under sessionDir | | `/srv` | scratch | empty `MemoryFileSystem` SAB | `HostFileSystem` under sessionDir | +Every mount in the default layout is `nosuid` on both hosts, including the +advisory-read-only root image. A future reviewed product projection must use a +separate privately branded immutable product backend and request +`trusted-root-product` explicitly; ordinary image, scratch, host, OPFS, +device, and user-provided backends cannot acquire that capability from public +fields, prototypes, or configuration. + The browser host layers two additional, host-specific mounts on top: `/dev/shm` (the POSIX-semaphore SAB shared with main-thread surfaces) and `/dev` (`DeviceFileSystem` for `/dev/null`, `/dev/zero`, `/dev/urandom`, `/dev/ptmx`, `/dev/pts/N`). Sticky bits, the uid 1000 owner on `/home/user`, mode `0700` on `/root`, etc. are baked into the rootfs image at build time per the canonical `MANIFEST` and reflected honestly through the `MemoryFileSystem` inode metadata. Scratch mounts on Node start owned by uid/gid 0 because `HostFileSystem` synthesises them. ### rootfs image as the source of truth diff --git a/docs/posix-status.md b/docs/posix-status.md index a2c2088e6b..be6e36518e 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -239,6 +239,7 @@ to a different directory than the original OFD. | `link()` / `unlink()` | Partial | Host-delegated. Relative paths resolved via kernel cwd. Named-FIFO hard links share one pipe identity and update its authoritative link count; the backing survives the last unlink while an open description remains, with link count zero and ctime updated to the unlink time. | | `rename()` | Partial | Host-delegated. Both paths resolved via kernel cwd. Named-FIFO identities follow file and containing-directory renames, including destination replacement. | | `stat()` / `lstat()` | Partial | Host-delegated. stat follows symlinks, lstat does not. Procfs fd magic links are validated against live fd/OFD pairs: following `/proc//fd/N` returns the target OFD metadata even after its pathname is unlinked, while no-follow operations report the symlink and closed slots return ENOENT. Registered AF_UNIX pathname sockets preserve the backing VFS inode's uid, gid, permissions, timestamps, and link count while reporting `S_IFSOCK`. Registered named FIFOs likewise preserve VFS metadata while reporting `S_IFIFO`; `readdir()` and `getdents64()` report `DT_FIFO`. | +| `statfs()` / `fstatfs()` | Partial | Host-backed and virtual filesystem statistics are reported. Mounts default to `ST_NOSUID` in both Node and browser hosts. Only a read-only product backend admitted through a module-private brand over a privately snapshotted, fully materialized and behaviorally isolated tree can clear it; trusted operations use private prototype copies, module-lexical helpers, and captured scalar ABI semantics rather than producer-reachable prototypes, class properties, or generated tables. The resolved mount capability authoritatively sets or clears the bit instead of trusting raw backend flags. The kernel can compute a set-ID transition proposal from retained target metadata, but exec does not commit that proposal to process credentials yet. | | `chmod()` / `chown()` / `lchown()` | Partial | VFS metadata updates. `chown()` follows the final symlink; `lchown()` changes the link itself, including dangling links. Ownership calls preserve either unchanged-ID sentinel and validate the selected object and authorization before delegation. Root may select arbitrary IDs; an unprivileged owner must preserve its user ID and may select its effective GID or Kandelo's synthesized supplementary/real GID. On metadata-backed SharedFS and Node regular files, successful calls clear S_ISUID and S_ISGID when any execute bit is set while leaving non-executable files, directories, and symlink targets selected by `lchown()` unchanged. Node host-backed changes stay in virtual metadata; browser memory-backed mounts store them in the VFS. OPFS has neither symlinks nor ownership metadata, so its existing ownership operations are no-ops. Arbitrary supplementary-group lists remain unsupported. | | `access()` | Partial | Resolves the pathname component-wise and checks traversal plus target permissions with real credentials. `faccessat(..., AT_EACCESS)` selects effective credentials. Both include Kandelo's one synthesized supplementary/real GID in group checks; arbitrary supplementary-group lists remain unsupported. | | `realpath()` | Full | Uses the global component walker against cwd, including mount crossings and relative or absolute symlinks; `missing/..` fails instead of being collapsed lexically, trailing slash requires a directory, and more than 40 symlinks returns ELOOP. | @@ -594,7 +595,7 @@ Systematic audit of all subsystems against POSIX specifications. Gaps are catego | **No immediate cross-process shared-memory or futex semantics** | memory | Anonymous, SysV, and stable-identity regular-file shared mappings now merge and refresh across processes at syscall boundaries. They are not one physical linear memory: direct stores remain private until a syscall, a peer spinning only on loads sees no update, and futex WAIT/WAKE targets only the caller's process `SharedArrayBuffer`. Process-shared pthread locks and PHP opcache's normal shared-memory locking model therefore remain unsupported. The PHP package rejects its normal SHM mode and supports only explicitly configured `opcache.file_cache_only=1`; otherwise FPM workers would observe divergent cache and lock state. memfd `MAP_SHARED`, Linux `SIGBUS` on access beyond EOF, and detection of external host writes also remain gaps. | | **External raw UDP routes** | socket | AF_INET SOCK_DGRAM has POSIX-style in-kernel loopback/virtual semantics, but browsers cannot expose raw UDP and Node raw UDP is not yet wired behind HostIO. Non-loopback UDP routes currently return ENETUNREACH unless a future host backend/proxy handles them. | | **Stop is cooperative at a Wasm boundary** | process / signals | The kernel records stopped state immediately and the shared host withholds every exact channel completion until SIGCONT. This suspends code at syscall boundaries in both Node and browser, but a process executing CPU-bound Wasm without reaching a syscall cannot be stopped at an arbitrary instruction by current WebAssembly execution APIs. | -| **Setuid/setgid enforcement** | process | Single-user Wasm environment; privilege checks simulated only. | +| **Set-ID exec credential commit** | process | Mount admission defaults to `nosuid`, and the kernel proposal helper ignores set-ID bits there. The one internal trusted mount can preserve those bits as a proposal, but target-aware exec validation and atomic credential commit remain future work. | | **Permission checks** | filesystem | Delegated to host. Kernel does not independently verify file permissions. | | **getrusage() zeroed** | sysinfo | No actual resource tracking available in Wasm. Returns zero-filled struct. | | **ucontext API unsupported** | process | `makecontext()`, `swapcontext()`, `getcontext()`, `setcontext()` are userspace stack-switching primitives. Supporting them would require `wasm-fork-instrument`-style compile-time instrumentation extended to general stack-switching for every program that uses them — we already do this narrowly for `fork()` (see [fork-instrumentation.md](fork-instrumentation.md) and `plans/2026-04-20-fork-instrumentation-design.md`), but generalising the same machinery to ucontext multiplies the instrumentation surface for a feature **deprecated in POSIX.1-2008** and effectively unused in modern code. Programs needing coroutines implement their own at the runtime level (Erlang/BEAM, Ruby fibers, Python `greenlet`). | diff --git a/host/src/browser.ts b/host/src/browser.ts index 467277466e..d743923168 100644 --- a/host/src/browser.ts +++ b/host/src/browser.ts @@ -37,7 +37,10 @@ export type { CentralizedWorkerInitMessage, } from "./worker-protocol"; export { VirtualPlatformIO } from "./vfs/vfs"; -export { MemoryFileSystem } from "./vfs/memory-fs"; +export { + MemoryFileSystem, + resolveMountSetIdCapability, +} from "./vfs/memory-fs"; export { loadVfsImage, restoreVerifiedVfsImage, @@ -79,7 +82,14 @@ export { DeviceFileSystem } from "./vfs/device-fs"; export { OpfsFileSystem } from "./vfs/opfs"; export { BrowserTimeProvider } from "./vfs/time"; export { OpfsChannel, OpfsChannelStatus, OpfsOpcode, OPFS_CHANNEL_SIZE } from "./vfs/opfs-channel"; -export type { FileSystemBackend, TimeProvider, MountConfig, DirEntry } from "./vfs/types"; +export { ST_NOSUID } from "./vfs/types"; +export type { + FileSystemBackend, + TimeProvider, + MountConfig, + MountSetIdCapability, + DirEntry, +} from "./vfs/types"; export { HomebrewVfsPlanError, planFederatedHomebrewVfs, diff --git a/host/src/generated/abi.ts b/host/src/generated/abi.ts index efb7c34b01..d1aabce15f 100644 --- a/host/src/generated/abi.ts +++ b/host/src/generated/abi.ts @@ -449,6 +449,10 @@ export const ACCESS_MODES = { X_OK: 1, } as const; +export const STATFS_FLAGS = { + ST_NOSUID: 2, +} as const; + export const FILE_MODES = { S_IFMT: 61440, S_IFSOCK: 49152, diff --git a/host/src/pathconf.ts b/host/src/pathconf.ts index 61aaf580fd..278cc8f015 100644 --- a/host/src/pathconf.ts +++ b/host/src/pathconf.ts @@ -5,6 +5,34 @@ import { } from "./generated/abi"; import type { PathconfValue, StatResult } from "./types"; +const { + ALLOC_SIZE_MIN, + ASYNC_IO, + CHOWN_RESTRICTED, + FALLOC, + FILESIZEBITS, + LINK_MAX, + MAX_CANON, + MAX_INPUT, + NAME_MAX, + NO_TRUNC, + PATH_MAX, + PIPE_BUF, + POSIX2_SYMLINKS, + PRIO_IO, + REC_INCR_XFER_SIZE, + REC_MAX_XFER_SIZE, + REC_MIN_XFER_SIZE, + REC_XFER_ALIGN, + SOCK_MAXBUF, + SYMLINK_MAX, + SYNC_IO, + TEXTDOMAIN_MAX, + TIMESTAMP_RESOLUTION, + VDISABLE, +} = PATHCONF_NAMES; +const { S_IFDIR, S_IFIFO, S_IFMT, S_IFREG } = FILE_MODES; + export interface PathconfProfile { supportsSymlinks: boolean; timestampResolutionNs: number | null; @@ -29,55 +57,52 @@ export function filesystemPathconf( profile: PathconfProfile, ): PathconfValue { switch (name) { - case PATHCONF_NAMES.LINK_MAX: + case LINK_MAX: return null; // no backend currently enforces an authoritative maximum - case PATHCONF_NAMES.NAME_MAX: + case NAME_MAX: return 255; // enforced in bytes by the common namespace resolver - case PATHCONF_NAMES.PATH_MAX: + case PATH_MAX: return POSIX_PATH_MAX_BYTES; // enforced by the common namespace resolver - case PATHCONF_NAMES.CHOWN_RESTRICTED: + case CHOWN_RESTRICTED: // The kernel enforces chown authorization before every backend call, // including backends without persistent ownership metadata. return 1; - case PATHCONF_NAMES.NO_TRUNC: + case NO_TRUNC: return 1; // the common resolver rejects overlong byte components - case PATHCONF_NAMES.ASYNC_IO: + case ASYNC_IO: // musl implements AIO with guest pthreads over pread/pwrite/fsync. - return (stat.mode & FILE_MODES.S_IFMT) === FILE_MODES.S_IFREG + return (stat.mode & S_IFMT) === S_IFREG ? 1 : invalidAssociation(name); - case PATHCONF_NAMES.SYNC_IO: - case PATHCONF_NAMES.PRIO_IO: - case PATHCONF_NAMES.FILESIZEBITS: - case PATHCONF_NAMES.REC_INCR_XFER_SIZE: - case PATHCONF_NAMES.REC_MAX_XFER_SIZE: - case PATHCONF_NAMES.REC_MIN_XFER_SIZE: - case PATHCONF_NAMES.REC_XFER_ALIGN: - case PATHCONF_NAMES.ALLOC_SIZE_MIN: - case PATHCONF_NAMES.SYMLINK_MAX: - case PATHCONF_NAMES.FALLOC: + case SYNC_IO: + case PRIO_IO: + case FILESIZEBITS: + case REC_INCR_XFER_SIZE: + case REC_MAX_XFER_SIZE: + case REC_MIN_XFER_SIZE: + case REC_XFER_ALIGN: + case ALLOC_SIZE_MIN: + case SYMLINK_MAX: + case FALLOC: return null; - case PATHCONF_NAMES.POSIX2_SYMLINKS: + case POSIX2_SYMLINKS: return profile.supportsSymlinks ? 1 : null; - case PATHCONF_NAMES.TEXTDOMAIN_MAX: + case TEXTDOMAIN_MAX: return 255; - case PATHCONF_NAMES.TIMESTAMP_RESOLUTION: + case TIMESTAMP_RESOLUTION: return profile.timestampResolutionNs; - case PATHCONF_NAMES.PIPE_BUF: { - const fileType = stat.mode & FILE_MODES.S_IFMT; + case PIPE_BUF: { + const fileType = stat.mode & S_IFMT; // Named FIFO support and host atomicity are not uniform yet. Preserve // the valid association without fabricating a numeric guarantee. For a // directory the value applies to FIFOs created within that directory. - if ( - fileType === FILE_MODES.S_IFIFO || - fileType === FILE_MODES.S_IFDIR - ) return null; + if (fileType === S_IFIFO || fileType === S_IFDIR) return null; return invalidAssociation(name); } - case PATHCONF_NAMES.MAX_CANON: - case PATHCONF_NAMES.MAX_INPUT: - case PATHCONF_NAMES.VDISABLE: - case PATHCONF_NAMES.SOCK_MAXBUF: + case MAX_CANON: + case MAX_INPUT: + case VDISABLE: + case SOCK_MAXBUF: return invalidAssociation(name); default: { const error = new Error(`EINVAL: invalid pathconf name ${name}`) as Error & { diff --git a/host/src/vfs/default-mounts-node.ts b/host/src/vfs/default-mounts-node.ts index c824e85c00..0fea683359 100644 --- a/host/src/vfs/default-mounts-node.ts +++ b/host/src/vfs/default-mounts-node.ts @@ -90,6 +90,7 @@ async function resolveValidatedForNode( mountPoint: m.path, backend, readonly: m.readonly, + setIdCapability: m.setIdCapability, }); } else { const hostDir = join(sessionDir, m.path); @@ -104,6 +105,7 @@ async function resolveValidatedForNode( mountPoint: m.path, backend, readonly: m.readonly, + setIdCapability: m.setIdCapability, }); } } diff --git a/host/src/vfs/default-mounts.ts b/host/src/vfs/default-mounts.ts index a41263ba02..d0daee5a51 100644 --- a/host/src/vfs/default-mounts.ts +++ b/host/src/vfs/default-mounts.ts @@ -11,7 +11,7 @@ * in once the policy lands. */ -import type { MountConfig } from "./types"; +import type { MountConfig, MountSetIdCapability } from "./types"; import { FILE_MODES, OPEN_FLAGS } from "../generated/abi"; import { MemoryFileSystem } from "./memory-fs"; import { restoreVerifiedVfsImage } from "./load-image"; @@ -29,6 +29,8 @@ export interface MountSpec { source: "image" | "scratch"; /** Advisory until PR 5/5 enforces it on writes through `VirtualPlatformIO`. */ readonly?: boolean; + /** Omitted mounts are nosuid; trusted requests still require backend proof. */ + setIdCapability?: MountSetIdCapability; /** Directory mode for scratch mount roots. Mirrors MANIFEST for defaults. */ mode?: number; /** Virtual owner for scratch mount roots. Defaults to root. */ @@ -259,6 +261,7 @@ async function resolveValidatedForBrowser( mountPoint: m.path, backend, readonly: m.readonly, + setIdCapability: m.setIdCapability, }); } else { const bytes = options.scratchSabBytes?.[m.path] ?? BROWSER_SCRATCH_SAB_BYTES; @@ -272,6 +275,7 @@ async function resolveValidatedForBrowser( mountPoint: m.path, backend, readonly: m.readonly, + setIdCapability: m.setIdCapability, }); } } diff --git a/host/src/vfs/host-fs.ts b/host/src/vfs/host-fs.ts index 6a6d911c1c..cedd08b6f9 100644 --- a/host/src/vfs/host-fs.ts +++ b/host/src/vfs/host-fs.ts @@ -37,7 +37,7 @@ import { OPEN_FLAGS, SEEK_WHENCE, } from "../generated/abi"; -import type { FileSystemBackend, DirEntry } from "./types"; +import { ST_NOSUID, type FileSystemBackend, type DirEntry } from "./types"; import { DEFAULT_STATFS_BLOCK_SIZE, DEFAULT_STATFS_NAMELEN } from "../statfs"; const UTIME_NOW = 0x3fffffff; @@ -129,7 +129,7 @@ export function nativeStatfs(path: string): StatfsResult { fsid: 0, namelen: DEFAULT_STATFS_NAMELEN, frsize: bsize, - flags: 0, + flags: ST_NOSUID, }; } diff --git a/host/src/vfs/index.ts b/host/src/vfs/index.ts index 360e304890..a051bfc64b 100644 --- a/host/src/vfs/index.ts +++ b/host/src/vfs/index.ts @@ -2,7 +2,10 @@ export { readPreparedPlatformFile, VirtualPlatformIO } from "./vfs"; export type { HostFileOffset } from "../types"; export type { PreparedPlatformFile } from "./vfs"; export { HostFileSystem } from "./host-fs"; -export { MemoryFileSystem } from "./memory-fs"; +export { + MemoryFileSystem, + resolveMountSetIdCapability, +} from "./memory-fs"; export { assertVfsDeferredTreeCollectionUsage, VFS_DEFERRED_TREE_COLLECTION_LIMITS, @@ -80,7 +83,14 @@ export { DeviceFileSystem } from "./device-fs"; export { OpfsFileSystem } from "./opfs"; export { OpfsChannel, OpfsChannelStatus, OpfsOpcode, OPFS_CHANNEL_SIZE } from "./opfs-channel"; export { NodeTimeProvider, BrowserTimeProvider } from "./time"; -export type { FileSystemBackend, TimeProvider, MountConfig, DirEntry } from "./types"; +export { ST_NOSUID } from "./types"; +export type { + FileSystemBackend, + TimeProvider, + MountConfig, + MountSetIdCapability, + DirEntry, +} from "./types"; export { PATHCONF_NAMES } from "../generated/abi"; export { filesystemPathconf } from "../pathconf"; export type { PathconfProfile } from "../pathconf"; diff --git a/host/src/vfs/memory-fs.ts b/host/src/vfs/memory-fs.ts index 76fa60396c..d3a859e0c7 100644 --- a/host/src/vfs/memory-fs.ts +++ b/host/src/vfs/memory-fs.ts @@ -13,12 +13,25 @@ import { } from "../file-offset"; import { filesystemPathconf } from "../pathconf"; import { SFFS_SUPER_MAGIC } from "../statfs"; -import { DIRENT_TYPES, FILE_MODES, OPEN_FLAGS } from "../generated/abi"; -import type { FileSystemBackend, DirEntry } from "./types"; import { + ACCESS_MODES, + DIRENT_TYPES, + FILE_MODES, + OPEN_FLAGS, +} from "../generated/abi"; +import { + ST_NOSUID, + type FileSystemBackend, + type DirEntry, + type MountConfig, + type MountSetIdCapability, +} from "./types"; +import { + EROFS, O_CREAT, O_EXCL, O_TRUNC, + SFSError, SharedFS, type ConditionalNamespaceIdentity, type NamespaceEntryIdentity, @@ -45,6 +58,52 @@ import { compareUnicodeScalarText, } from "./canonical-text"; +const intrinsicApply = Reflect.apply; +const intrinsicObjectCreate = Object.create; +const intrinsicObjectDefineProperties = Object.defineProperties; +const intrinsicObjectFreeze = Object.freeze; +const intrinsicObjectGetOwnPropertyDescriptors = + Object.getOwnPropertyDescriptors; +const intrinsicObjectSetPrototypeOf = Object.setPrototypeOf; +const IntrinsicProxy = Proxy; +const IntrinsicSharedArrayBuffer = SharedArrayBuffer; +const IntrinsicUint8Array = Uint8Array; +const intrinsicUint8ArraySet = Uint8Array.prototype.set; +const intrinsicWeakSetAdd = WeakSet.prototype.add; +const intrinsicWeakSetHas = WeakSet.prototype.has; +const intrinsicSetHas = Set.prototype.has; +const intrinsicMapGet = Map.prototype.get; +const IntrinsicNumber = Number; +const intrinsicNumberIsInteger = Number.isInteger; +const IntrinsicTypeError = TypeError; +const intrinsicSharedFsMount = SharedFS.mount; +const intrinsicSharedFsSnapshotState = SharedFS.prototype.snapshotState; +const memoryFileSystemInstances = new WeakSet(); +const immutableProductBackends = new WeakSet(); + +function capturePrivatePrototype(prototype: object): object { + const captured = intrinsicObjectCreate(null) as object; + intrinsicObjectDefineProperties( + captured, + intrinsicObjectGetOwnPropertyDescriptors(prototype), + ); + return intrinsicObjectFreeze(captured); +} + +const immutableProductSharedFsPrototype = capturePrivatePrototype( + SharedFS.prototype, +); + +const NOSUID_CAPABILITY: MountSetIdCapability = intrinsicObjectFreeze({ + kind: "nosuid", +}); +const TRUSTED_ROOT_PRODUCT_CAPABILITY: MountSetIdCapability = + intrinsicObjectFreeze({ + kind: "trusted-root-product", + guestWritable: false, + stableExecutableIdentity: true, + }); + /** Serializable lazy file entry for transfer between instances. */ export interface LazyFileEntry { ino: number; @@ -424,7 +483,12 @@ const VFS_IMAGE_FLAG_HAS_METADATA = 1 << 2; const VFS_IMAGE_FLAG_HAS_TYPED_LAZY_ARCHIVES = 1 << 3; const VFS_IMAGE_HEADER_SIZE = 16; // magic(4) + version(4) + flags(4) + sabLen(4) const { S_IFMT, S_IFREG, S_IFDIR, S_IFLNK } = FILE_MODES; +const { DT_UNKNOWN, DT_REG, DT_DIR, DT_LNK } = DIRENT_TYPES; const O_RDONLY = OPEN_FLAGS.O_RDONLY; +const IMMUTABLE_PRODUCT_O_ACCMODE = OPEN_FLAGS.O_ACCMODE; +const IMMUTABLE_PRODUCT_O_CREAT = OPEN_FLAGS.O_CREAT; +const IMMUTABLE_PRODUCT_O_TRUNC = OPEN_FLAGS.O_TRUNC; +const IMMUTABLE_PRODUCT_W_OK = ACCESS_MODES.W_OK; const O_WRONLY_CREAT_TRUNC = OPEN_FLAGS.O_WRONLY | OPEN_FLAGS.O_CREAT | OPEN_FLAGS.O_TRUNC; const COPY_CHUNK_BYTES = 1024 * 1024; @@ -3058,6 +3122,10 @@ function conditionalFileReplacement( }; } +function memoryFileSystemInodeKey(ino: number, generation: number): string { + return `${ino}:${generation}`; +} + export class MemoryFileSystem implements FileSystemBackend { private fs: SharedFS; private imageMetadata: VfsImageMetadata | null; @@ -3125,10 +3193,34 @@ export class MemoryFileSystem implements FileSystemBackend { private constructor(fs: SharedFS, metadata: VfsImageMetadata | null = null) { this.fs = fs; this.imageMetadata = metadata; + intrinsicApply(intrinsicWeakSetAdd, memoryFileSystemInstances, [this]); } - private static inodeKey(ino: number, generation: number): string { - return `${ino}:${generation}`; + /** Capture one self-contained product tree without retaining producer state. */ + private snapshotForImmutableProduct(): MemoryFileSystem { + if (this.lazyFiles.size !== 0 || this.lazyArchiveInodes.size !== 0) { + throw new Error( + "immutable product source must be completely materialized", + ); + } + const { bytes } = intrinsicApply( + intrinsicSharedFsSnapshotState, + this.fs, + [], + ) as ReturnType; + const sab = new IntrinsicSharedArrayBuffer(bytes.byteLength); + intrinsicApply( + intrinsicUint8ArraySet, + new IntrinsicUint8Array(sab), + [bytes], + ); + const fs = intrinsicApply( + intrinsicSharedFsMount, + SharedFS, + [sab, { restoreImage: true }], + ) as SharedFS; + intrinsicObjectSetPrototypeOf(fs, immutableProductSharedFsPrototype); + return new MemoryFileSystem(fs, cloneMetadata(this.imageMetadata)); } private static canAdoptLegacyLazyStub(st: SfsStatResult): boolean { @@ -3205,7 +3297,7 @@ export class MemoryFileSystem implements FileSystemBackend { entry.materialized || entry.generation === undefined ) continue; - const key = MemoryFileSystem.inodeKey( + const key = memoryFileSystemInodeKey( entry.ino, entry.generation, ); @@ -3245,7 +3337,7 @@ export class MemoryFileSystem implements FileSystemBackend { entry.deleted || entry.materialized || entry.isSymlink || entry.generation === undefined ) continue; - const key = MemoryFileSystem.inodeKey(entry.ino, entry.generation); + const key = memoryFileSystemInodeKey(entry.ino, entry.generation); const aliases = pendingByIdentity.get(key) ?? []; aliases.push(entry); pendingByIdentity.set(key, aliases); @@ -3302,7 +3394,7 @@ export class MemoryFileSystem implements FileSystemBackend { entry.generation === undefined ) continue; - const key = MemoryFileSystem.inodeKey(entry.ino, entry.generation); + const key = memoryFileSystemInodeKey(entry.ino, entry.generation); if (!pendingByIdentity.has(key)) pendingByIdentity.set(key, entry); } @@ -3490,7 +3582,7 @@ export class MemoryFileSystem implements FileSystemBackend { } private lazyFileForStat(st: SfsStatResult) { - const key = MemoryFileSystem.inodeKey(st.ino, st.generation); + const key = memoryFileSystemInodeKey(st.ino, st.generation); const entry = this.lazyFiles.get(key); if (entry && entry.dataSequence !== st.dataSequence) { this.lazyFiles.delete(key); @@ -3516,7 +3608,7 @@ export class MemoryFileSystem implements FileSystemBackend { } private lazyArchiveForStat(st: SfsStatResult) { - const key = MemoryFileSystem.inodeKey(st.ino, st.generation); + const key = memoryFileSystemInodeKey(st.ino, st.generation); const group = this.lazyArchiveInodes.get(key); if (!group) return undefined; const entries = this.lazyArchiveEntriesForRead(group).filter( @@ -3551,7 +3643,7 @@ export class MemoryFileSystem implements FileSystemBackend { } private lazyBackingForStat(st: SfsStatResult): LazyBacking | null { - const key = MemoryFileSystem.inodeKey(st.ino, st.generation); + const key = memoryFileSystemInodeKey(st.ino, st.generation); // Preparation deliberately observes the registered identity even when a // peer advanced its data sequence. The identity-guarded commit will then // reconcile and preserve the peer's bytes, while callers still learn that @@ -3884,7 +3976,7 @@ export class MemoryFileSystem implements FileSystemBackend { /** A successful guest data mutation makes any deferred backing obsolete. */ private invalidateLazyData(st: SfsStatResult): void { - const key = MemoryFileSystem.inodeKey(st.ino, st.generation); + const key = memoryFileSystemInodeKey(st.ino, st.generation); this.lazyFiles.delete(key); const group = this.lazyArchiveInodes.get(key); @@ -3920,7 +4012,7 @@ export class MemoryFileSystem implements FileSystemBackend { const newBase = newPath.length > 1 ? newPath.replace(/\/+$/, "") : newPath; const oldPrefix = `${oldBase}/`; const newPrefix = `${newBase}/`; - const sourceKey = MemoryFileSystem.inodeKey(source.ino, source.generation); + const sourceKey = memoryFileSystemInodeKey(source.ino, source.generation); const directory = (source.mode & S_IFMT) === S_IFDIR; const rewrite = (candidate: string): string => candidate === oldBase @@ -3941,7 +4033,7 @@ export class MemoryFileSystem implements FileSystemBackend { const entries = ordinaryDefinition.entries.map((entry) => { const entryKey = entry.generation === undefined ? null - : MemoryFileSystem.inodeKey(entry.ino, entry.generation); + : memoryFileSystemInodeKey(entry.ino, entry.generation); const vfsPath = directory || entryKey === sourceKey ? rewrite(entry.vfsPath) : entry.vfsPath; @@ -3995,7 +4087,7 @@ export class MemoryFileSystem implements FileSystemBackend { const entryKey = entry.generation === undefined ? null - : MemoryFileSystem.inodeKey(entry.ino, entry.generation); + : memoryFileSystemInodeKey(entry.ino, entry.generation); rewritten.set( directory || entryKey === sourceKey ? rewrite(candidate) : candidate, entry, @@ -4390,7 +4482,7 @@ export class MemoryFileSystem implements FileSystemBackend { } const st = this.fs.createLazyStub(path, mode); this.invalidateLazyData(st); - this.lazyFiles.set(MemoryFileSystem.inodeKey(st.ino, st.generation), { + this.lazyFiles.set(memoryFileSystemInodeKey(st.ino, st.generation), { ino: st.ino, generation: st.generation, dataSequence: st.dataSequence, @@ -4446,7 +4538,7 @@ export class MemoryFileSystem implements FileSystemBackend { ? e.path : validPaths.values().next().value!; this.lazyFiles.set( - MemoryFileSystem.inodeKey(identity.ino, identity.generation), + memoryFileSystemInodeKey(identity.ino, identity.generation), { ino: identity.ino, generation: identity.generation, @@ -4727,7 +4819,7 @@ export class MemoryFileSystem implements FileSystemBackend { for (const entry of group.entries.values()) { if (entry.isSymlink || entry.generation === undefined) continue; this.lazyArchiveInodes.set( - MemoryFileSystem.inodeKey(entry.ino, entry.generation), + memoryFileSystemInodeKey(entry.ino, entry.generation), group, ); } @@ -4881,7 +4973,7 @@ export class MemoryFileSystem implements FileSystemBackend { }; group.entries.set(vfsPath, entry); this.lazyArchiveInodes.set( - MemoryFileSystem.inodeKey(st.ino, st.generation), + memoryFileSystemInodeKey(st.ino, st.generation), group, ); } @@ -5062,7 +5154,7 @@ export class MemoryFileSystem implements FileSystemBackend { `Serialized lazy tree stub ${e.vfsPath} disagrees with its inventory`, ); } - const identity = MemoryFileSystem.inodeKey(st.ino, st.generation); + const identity = memoryFileSystemInodeKey(st.ino, st.generation); const group = e.inodeGroup!; const priorIdentity = identityByGroup.get(group); const priorGroup = groupByIdentity.get(identity); @@ -5209,7 +5301,7 @@ export class MemoryFileSystem implements FileSystemBackend { !entry.materialized && entry.generation !== undefined ) { - const key = MemoryFileSystem.inodeKey(entry.ino, entry.generation); + const key = memoryFileSystemInodeKey(entry.ino, entry.generation); const planned = plannedInodes.get(key); if (planned !== undefined && planned !== group) { throw new Error( @@ -5669,7 +5761,7 @@ export class MemoryFileSystem implements FileSystemBackend { } catch { return false; } - const key = MemoryFileSystem.inodeKey(st.ino, st.generation); + const key = memoryFileSystemInodeKey(st.ino, st.generation); const entry = this.lazyFiles.get(key); if (entry) { const transport = this.lazyTransport; @@ -6073,7 +6165,7 @@ export class MemoryFileSystem implements FileSystemBackend { signal, ); const requestedKey = requested - ? MemoryFileSystem.inodeKey(requested.ino, requested.generation) + ? memoryFileSystemInodeKey(requested.ino, requested.generation) : null; for (let attempt = 0; attempt < 3; attempt++) { const pending = this.collectLazyArchiveReplacements( @@ -6183,7 +6275,7 @@ export class MemoryFileSystem implements FileSystemBackend { ); } if (archiveEntry.generation === undefined) continue; - const key = MemoryFileSystem.inodeKey( + const key = memoryFileSystemInodeKey( archiveEntry.ino, archiveEntry.generation, ); @@ -6233,7 +6325,7 @@ export class MemoryFileSystem implements FileSystemBackend { archiveEntry.materialized || archiveEntry.generation === undefined ) continue; - const key = MemoryFileSystem.inodeKey( + const key = memoryFileSystemInodeKey( archiveEntry.ino, archiveEntry.generation, ); @@ -6274,7 +6366,7 @@ export class MemoryFileSystem implements FileSystemBackend { const entries = ordinaryDefinition.entries.map((entry) => { const key = entry.generation === undefined ? undefined - : MemoryFileSystem.inodeKey(entry.ino, entry.generation); + : memoryFileSystemInodeKey(entry.ino, entry.generation); if (key === undefined || !pending.has(key)) return entry; this.lazyArchiveInodes.delete(key); return { ...entry, materialized: true }; @@ -6381,7 +6473,7 @@ export class MemoryFileSystem implements FileSystemBackend { `Lazy atomic tree changed at ${inventoryEntry.vfsPath}`, ); } - const key = MemoryFileSystem.inodeKey(entry.ino, entry.generation); + const key = memoryFileSystemInodeKey(entry.ino, entry.generation); if (this.lazyArchiveInodes.get(key) !== group) { throw new Error( `Lazy atomic tree lost deferred ownership of ${inventoryEntry.vfsPath}`, @@ -6766,7 +6858,7 @@ export class MemoryFileSystem implements FileSystemBackend { entry.materialized || entry.generation === undefined ) continue; - const key = MemoryFileSystem.inodeKey(entry.ino, entry.generation); + const key = memoryFileSystemInodeKey(entry.ino, entry.generation); if (!pending.has(key)) { throw new Error( `Lazy atomic activation group ${atomicGroup.id} has an incomplete publication`, @@ -7366,7 +7458,7 @@ export class MemoryFileSystem implements FileSystemBackend { fsid: 0, namelen: stats.maxName, frsize: stats.blockSize, - flags: 0, + flags: ST_NOSUID, }; } @@ -7388,7 +7480,7 @@ export class MemoryFileSystem implements FileSystemBackend { unlink(path: string): void { const removed = this.fs.unlink(path); - const key = MemoryFileSystem.inodeKey(removed.ino, removed.generation); + const key = memoryFileSystemInodeKey(removed.ino, removed.generation); if ( removed.linkCount > 1 && (this.lazyFiles.has(key) || this.lazyArchiveInodes.has(key)) @@ -7460,7 +7552,7 @@ export class MemoryFileSystem implements FileSystemBackend { let reconciledNamespace = false; if (replaced) { - const replacedKey = MemoryFileSystem.inodeKey( + const replacedKey = memoryFileSystemInodeKey( replaced.ino, replaced.generation, ); @@ -7527,7 +7619,7 @@ export class MemoryFileSystem implements FileSystemBackend { link(existingPath: string, newPath: string): void { const sourceIdentity = this.fs.link(existingPath, newPath); - const key = MemoryFileSystem.inodeKey( + const key = memoryFileSystemInodeKey( sourceIdentity.ino, sourceIdentity.generation, ); @@ -7758,13 +7850,10 @@ export class MemoryFileSystem implements FileSystemBackend { if (!entry) return null; // Determine d_type from mode const mode = entry.stat.mode; - let dtype: number = DIRENT_TYPES.DT_UNKNOWN; - if ((mode & FILE_MODES.S_IFMT) === FILE_MODES.S_IFREG) - dtype = DIRENT_TYPES.DT_REG; - else if ((mode & FILE_MODES.S_IFMT) === FILE_MODES.S_IFDIR) - dtype = DIRENT_TYPES.DT_DIR; - else if ((mode & FILE_MODES.S_IFMT) === FILE_MODES.S_IFLNK) - dtype = DIRENT_TYPES.DT_LNK; + let dtype: number = DT_UNKNOWN; + if ((mode & S_IFMT) === S_IFREG) dtype = DT_REG; + else if ((mode & S_IFMT) === S_IFDIR) dtype = DT_DIR; + else if ((mode & S_IFMT) === S_IFLNK) dtype = DT_LNK; return { name: entry.name, type: dtype, ino: entry.stat.ino }; } @@ -7773,6 +7862,224 @@ export class MemoryFileSystem implements FileSystemBackend { } } +const immutableProductMemoryFileSystemPrototype = capturePrivatePrototype( + MemoryFileSystem.prototype, +); + +const IMMUTABLE_PRODUCT_MUTATORS = new Set([ + "write", + "append", + "ftruncate", + "fchmod", + "fchown", + "mkdir", + "rmdir", + "unlink", + "rename", + "link", + "symlink", + "chmod", + "chown", + "lchown", + "utimensat", +]); + +const IMMUTABLE_PRODUCT_READ_OPERATIONS = new Map([ + ["preparePath", MemoryFileSystem.prototype.preparePath], + ["close", MemoryFileSystem.prototype.close], + ["read", MemoryFileSystem.prototype.read], + ["seek", MemoryFileSystem.prototype.seek], + ["fstat", MemoryFileSystem.prototype.fstat], + ["fpathconf", MemoryFileSystem.prototype.fpathconf], + ["fsync", MemoryFileSystem.prototype.fsync], + ["stat", MemoryFileSystem.prototype.stat], + ["lstat", MemoryFileSystem.prototype.lstat], + ["statfs", MemoryFileSystem.prototype.statfs], + ["pathconf", MemoryFileSystem.prototype.pathconf], + ["readlink", MemoryFileSystem.prototype.readlink], + ["opendir", MemoryFileSystem.prototype.opendir], + ["readdir", MemoryFileSystem.prototype.readdir], + ["closedir", MemoryFileSystem.prototype.closedir], +]); + +interface ImmutableProductSnapshotSource { + snapshotForImmutableProduct(): MemoryFileSystem; +} + +const intrinsicImmutableProductSnapshot = ( + MemoryFileSystem.prototype as unknown as ImmutableProductSnapshotSource +).snapshotForImmutableProduct; +const intrinsicMemoryFileSystemOpen = MemoryFileSystem.prototype.open; +const intrinsicMemoryFileSystemAccess = MemoryFileSystem.prototype.access; + +function immutableProductReadonlyFailure(): never { + throw new SFSError(EROFS, "EROFS: Read-only file system"); +} + +function normalizeImmutableProductInteger( + value: unknown, + label: string, +): number { + const normalized = IntrinsicNumber(value); + if (!intrinsicNumberIsInteger(normalized)) { + throw new IntrinsicTypeError(`${label} must be an integer`); + } + return normalized; +} + +/** + * Create the internal backend used for reviewed, immutable product binaries. + * + * This factory is deliberately not re-exported by the public VFS entry point. + * The wrapper preserves MemoryFS's open inode-generation lease while denying + * every guest-visible content, namespace, ownership, and mode mutation. + */ +export function createImmutableProductBackend( + source: MemoryFileSystem, +): FileSystemBackend { + if ( + !intrinsicApply( + intrinsicWeakSetHas, + memoryFileSystemInstances, + [source], + ) + ) { + throw new Error( + "immutable product source must be a genuine MemoryFileSystem", + ); + } + const snapshot = intrinsicApply( + intrinsicImmutableProductSnapshot, + source, + [], + ) as MemoryFileSystem; + if ( + snapshot === source || + !intrinsicApply( + intrinsicWeakSetHas, + memoryFileSystemInstances, + [snapshot], + ) + ) { + throw new Error("immutable product source snapshot was not isolated"); + } + intrinsicObjectSetPrototypeOf( + snapshot, + immutableProductMemoryFileSystemPrototype, + ); + + const facade = intrinsicObjectCreate(null) as FileSystemBackend; + const backend = new IntrinsicProxy(facade, { + get(_target, property) { + if ( + intrinsicApply( + intrinsicSetHas, + IMMUTABLE_PRODUCT_MUTATORS, + [property], + ) + ) { + return immutableProductReadonlyFailure; + } + if (property === "open") { + return (path: string, flags: number, mode: number): number => { + const normalizedFlags = normalizeImmutableProductInteger( + flags, + "immutable product open flags", + ); + const accessMode = normalizedFlags & IMMUTABLE_PRODUCT_O_ACCMODE; + const mutates = + (normalizedFlags & + (IMMUTABLE_PRODUCT_O_CREAT | IMMUTABLE_PRODUCT_O_TRUNC)) !== 0; + if (accessMode !== O_RDONLY || mutates) { + return immutableProductReadonlyFailure(); + } + return intrinsicApply( + intrinsicMemoryFileSystemOpen, + snapshot, + [path, normalizedFlags, mode], + ) as number; + }; + } + if (property === "access") { + return (path: string, mode: number): void => { + const normalizedMode = normalizeImmutableProductInteger( + mode, + "immutable product access mode", + ); + if ((normalizedMode & IMMUTABLE_PRODUCT_W_OK) !== 0) { + immutableProductReadonlyFailure(); + } + intrinsicApply( + intrinsicMemoryFileSystemAccess, + snapshot, + [path, normalizedMode], + ); + }; + } + const operation = intrinsicApply( + intrinsicMapGet, + IMMUTABLE_PRODUCT_READ_OPERATIONS, + [property], + ) as unknown; + if (typeof operation === "function") { + return (...args: unknown[]): unknown => + intrinsicApply(operation, snapshot, args); + } + // Do not leak MemoryFileSystem's producer-only helpers or private state + // through the narrower FileSystemBackend wrapper. + return undefined; + }, + set: () => false, + defineProperty: () => false, + deleteProperty: () => false, + }); + intrinsicApply(intrinsicWeakSetAdd, immutableProductBackends, [backend]); + return backend; +} + +/** Resolve one mount's set-ID policy from private backend provenance. */ +export function resolveMountSetIdCapability( + config: Pick, +): MountSetIdCapability { + const requested = config.setIdCapability; + if (requested === undefined) { + return NOSUID_CAPABILITY; + } + if ( + typeof requested !== "object" || requested === null || + Array.isArray(requested) + ) { + throw new Error("unknown set-ID mount capability"); + } + if (requested.kind === "nosuid") return NOSUID_CAPABILITY; + if (requested.kind !== "trusted-root-product") { + throw new Error("unknown set-ID mount capability"); + } + if (requested.guestWritable !== false) { + throw new Error("trusted root product mount must not be guest-writable"); + } + if (config.readonly !== true) { + throw new Error("trusted root product mount must be read-only"); + } + if (requested.stableExecutableIdentity !== true) { + throw new Error( + "trusted root product mount must require stable executable identity", + ); + } + if ( + !intrinsicApply( + intrinsicWeakSetHas, + immutableProductBackends, + [config.backend], + ) + ) { + throw new Error( + "trusted root product mount requires an admitted immutable product backend with immutable handle generation identity", + ); + } + return TRUSTED_ROOT_PRODUCT_CAPABILITY; +} + // fzstd is a regular sync static import (see top of file). Earlier we // tried lazy-loading it via top-level `await import("fzstd")`, but a // top-level await turns this module — and every consumer, including diff --git a/host/src/vfs/sharedfs-vendor.ts b/host/src/vfs/sharedfs-vendor.ts index db4fef16f8..b6c05dbc0b 100644 --- a/host/src/vfs/sharedfs-vendor.ts +++ b/host/src/vfs/sharedfs-vendor.ts @@ -64,6 +64,7 @@ export const SEEK_END = 2; // Dirent const DIRENT_HEADER_SIZE = 8; +const DIR_INDEX_MIN_SIZE = 64 * 1024; // Error codes export const EPERM = -1; @@ -78,6 +79,7 @@ export const EINVAL = -22; export const EMFILE = -24; export const EFBIG = -27; export const ENOSPC = -28; +export const EROFS = -30; export const ENAMETOOLONG = -36; export const ENOTEMPTY = -39; export const ELOOP = -40; @@ -263,6 +265,7 @@ const ERROR_MESSAGES: Record = { [EMFILE]: "Too many open files", [EFBIG]: "File too large", [ENOSPC]: "No space left on device", + [EROFS]: "Read-only file system", [ENAMETOOLONG]: "File name too long", [ENOTEMPTY]: "Directory not empty", [ELOOP]: "Too many symbolic links", @@ -322,8 +325,6 @@ export class SharedFS { * entry locations so each repeated exact-name lookup does not rescan every * preceding variable-length record. */ - private static readonly DIR_INDEX_MIN_SIZE = 64 * 1024; - private constructor(public readonly buffer: SharedArrayBuffer) { this.view = new DataView(buffer); this.i32 = new Int32Array(buffer); @@ -1599,7 +1600,7 @@ export class SharedFS { } if (cached) this.dirIndexes.delete(dirIno); - if (dirSize < SharedFS.DIR_INDEX_MIN_SIZE) return null; + if (dirSize < DIR_INDEX_MIN_SIZE) return null; return this.rebuildDirIndex(dirIno, generation, mutationSequence, dirSize); } diff --git a/host/src/vfs/types.ts b/host/src/vfs/types.ts index 0fde0c19dc..acecceac7b 100644 --- a/host/src/vfs/types.ts +++ b/host/src/vfs/types.ts @@ -5,6 +5,18 @@ import type { StatResult, StatfsResult, } from "../types"; +import { STATFS_FLAGS } from "../generated/abi"; + +/** POSIX statfs(2) flag for filesystems that ignore set-ID mode bits. */ +export const ST_NOSUID = STATFS_FLAGS.ST_NOSUID; + +export type MountSetIdCapability = + | { kind: "nosuid" } + | { + kind: "trusted-root-product"; + guestWritable: false; + stableExecutableIdentity: true; + }; export interface DirEntry { name: string; @@ -86,4 +98,5 @@ export interface MountConfig { mountPoint: string; backend: FileSystemBackend; readonly?: boolean; + setIdCapability?: MountSetIdCapability; } diff --git a/host/src/vfs/vfs.ts b/host/src/vfs/vfs.ts index b17c928e3c..959fe6ad03 100644 --- a/host/src/vfs/vfs.ts +++ b/host/src/vfs/vfs.ts @@ -7,12 +7,20 @@ import type { StatResult, StatfsResult, } from "../types"; -import type { FileSystemBackend, MountConfig, TimeProvider } from "./types"; +import { + ST_NOSUID, + type FileSystemBackend, + type MountConfig, + type MountSetIdCapability, + type TimeProvider, +} from "./types"; +import { resolveMountSetIdCapability } from "./memory-fs"; interface MountEntry { prefix: string; backend: FileSystemBackend; backendId: number; + setIdCapability: MountSetIdCapability; } interface HandleInfo { @@ -75,6 +83,7 @@ export class VirtualPlatformIO implements PlatformIO { prefix: normalizeMountPoint(m.mountPoint), backend: m.backend, backendId, + setIdCapability: resolveMountSetIdCapability(m), }; }) .sort((a, b) => b.prefix.length - a.prefix.length); @@ -84,9 +93,16 @@ export class VirtualPlatformIO implements PlatformIO { } } + /** Effective set-ID policy for the mount that owns an absolute guest path. */ + getMountSetIdCapability(path: string): MountSetIdCapability { + const { setIdCapability } = this.resolve(path); + return setIdCapability; + } + private resolve(path: string): { backend: FileSystemBackend; backendId: number; + setIdCapability: MountSetIdCapability; relativePath: string; } { for (const m of this.mounts) { @@ -94,6 +110,7 @@ export class VirtualPlatformIO implements PlatformIO { return { backend: m.backend, backendId: m.backendId, + setIdCapability: m.setIdCapability, relativePath: path, }; } @@ -103,6 +120,7 @@ export class VirtualPlatformIO implements PlatformIO { return { backend: m.backend, backendId: m.backendId, + setIdCapability: m.setIdCapability, relativePath: rel, }; } @@ -278,8 +296,12 @@ export class VirtualPlatformIO implements PlatformIO { } statfs(path: string): StatfsResult { - const { backend, relativePath } = this.resolve(path); - return backend.statfs(relativePath); + const { backend, relativePath, setIdCapability } = this.resolve(path); + const statfs = backend.statfs(relativePath); + const flags = setIdCapability.kind === "nosuid" + ? statfs.flags | ST_NOSUID + : statfs.flags & ~ST_NOSUID; + return { ...statfs, flags }; } pathconf(path: string, name: number): PathconfValue { diff --git a/host/test/nosuid-exec.test.ts b/host/test/nosuid-exec.test.ts new file mode 100644 index 0000000000..8f86644a11 --- /dev/null +++ b/host/test/nosuid-exec.test.ts @@ -0,0 +1,695 @@ +import { describe, expect, it } from "vitest"; +import { + DIRENT_TYPES, + FILE_MODES, + OPEN_FLAGS, + PATHCONF_NAMES, +} from "../src/generated/abi"; +import { NodePlatformIO } from "../src/platform/node"; +import { + createImmutableProductBackend, + MemoryFileSystem, + resolveMountSetIdCapability, +} from "../src/vfs/memory-fs"; +import { NodeTimeProvider } from "../src/vfs/time"; +import { + ST_NOSUID, + type FileSystemBackend, + type MountSetIdCapability, +} from "../src/vfs/types"; +import { VirtualPlatformIO } from "../src/vfs/vfs"; + +const TRUSTED_ROOT_PRODUCT = { + kind: "trusted-root-product", + guestWritable: false, + stableExecutableIdentity: true, +} as const; + +function createSetIdFileSystem(): MemoryFileSystem { + const fs = MemoryFileSystem.create( + new SharedArrayBuffer(2 * 1024 * 1024), + ); + fs.mkdir("/bin", 0o755); + fs.createFileWithOwner( + "/bin/tool", + 0o6755, + 0, + 42, + new Uint8Array([0, 97, 115, 109]), + ); + return fs; +} + +describe("set-ID mount capability validation", () => { + it("defaults an omitted capability to nosuid", () => { + const backend = createSetIdFileSystem(); + + expect(resolveMountSetIdCapability({ backend })).toEqual({ + kind: "nosuid", + }); + }); + + it("rejects a guest-writable trusted-root-product request", () => { + const immutableProductBackend = createImmutableProductBackend( + createSetIdFileSystem(), + ); + + expect(() => resolveMountSetIdCapability({ + backend: immutableProductBackend, + readonly: true, + setIdCapability: { + kind: "trusted-root-product", + guestWritable: true, + stableExecutableIdentity: true, + } as unknown as MountSetIdCapability, + })).toThrow(/trusted root product mount must not be guest-writable/); + }); + + it("rejects a writable trusted-root-product mount", () => { + const immutableProductBackend = createImmutableProductBackend( + createSetIdFileSystem(), + ); + + expect(() => resolveMountSetIdCapability({ + backend: immutableProductBackend, + readonly: false, + setIdCapability: TRUSTED_ROOT_PRODUCT, + })).toThrow(/trusted root product mount must be read-only/); + }); + + it("rejects a trusted request from a backend without stable executable identity", () => { + const identityUnstableBackend: FileSystemBackend = createSetIdFileSystem(); + + expect(() => resolveMountSetIdCapability({ + backend: identityUnstableBackend, + readonly: true, + setIdCapability: TRUSTED_ROOT_PRODUCT, + })).toThrow(/immutable handle generation/); + }); + + it("rejects a caller-defined backend that spoofs every public structural field", () => { + const writable = createSetIdFileSystem(); + const spoofed = Object.assign(writable, { + executableIdentityKind: "immutable-handle-generation" as const, + }); + + expect(() => new VirtualPlatformIO( + [{ + mountPoint: "/", + backend: spoofed, + readonly: true, + setIdCapability: TRUSTED_ROOT_PRODUCT, + }], + new NodeTimeProvider(), + )).toThrow(/immutable product backend/); + }); + + it("rejects a Proxy-wrapped structural source at the product factory", () => { + const source = createSetIdFileSystem(); + + expect(() => createImmutableProductBackend( + new Proxy(source, {}) as MemoryFileSystem, + )).toThrow(/genuine MemoryFileSystem/); + }); + + it("keeps admission brands exact under mutable WeakSet hooks", () => { + const addDescriptor = Object.getOwnPropertyDescriptor( + WeakSet.prototype, + "add", + )!; + const hasDescriptor = Object.getOwnPropertyDescriptor( + WeakSet.prototype, + "has", + )!; + const genuineSource = createSetIdFileSystem(); + const spoofed = Object.assign(createSetIdFileSystem(), { + executableIdentityKind: "immutable-handle-generation" as const, + }); + + try { + Object.defineProperty(WeakSet.prototype, "add", { + ...addDescriptor, + value(this: WeakSet) { + return this; + }, + }); + Object.defineProperty(WeakSet.prototype, "has", { + ...hasDescriptor, + value() { + return true; + }, + }); + const genuine = createImmutableProductBackend(genuineSource); + expect(() => new VirtualPlatformIO( + [{ + mountPoint: "/", + backend: genuine, + readonly: true, + setIdCapability: TRUSTED_ROOT_PRODUCT, + }], + new NodeTimeProvider(), + )).not.toThrow(); + expect(() => new VirtualPlatformIO( + [{ + mountPoint: "/", + backend: spoofed, + readonly: true, + setIdCapability: TRUSTED_ROOT_PRODUCT, + }], + new NodeTimeProvider(), + )).toThrow(/immutable product backend/); + } finally { + Object.defineProperty(WeakSet.prototype, "add", addDescriptor); + Object.defineProperty(WeakSet.prototype, "has", hasDescriptor); + } + }); + + it("rejects an unknown capability instead of downgrading it", () => { + const backend = createSetIdFileSystem(); + + expect(() => resolveMountSetIdCapability({ + backend, + readonly: true, + setIdCapability: { + kind: "future-capability", + } as unknown as MountSetIdCapability, + })).toThrow(/unknown set-ID mount capability/); + }); + + it("rejects a malformed capability while constructing the mount table", () => { + const backend = createImmutableProductBackend(createSetIdFileSystem()); + + expect(() => new VirtualPlatformIO( + [{ + mountPoint: "/", + backend, + readonly: true, + setIdCapability: { + kind: "trusted-root-product", + guestWritable: false, + stableExecutableIdentity: false, + } as unknown as MountSetIdCapability, + }], + new NodeTimeProvider(), + )).toThrow(/stable executable identity/); + }); +}); + +describe("set-ID execution mount evidence", () => { + it("keeps the raw Node platform adapter nosuid", () => { + const io = new NodePlatformIO(); + + expect(io.statfs(process.cwd()).flags & ST_NOSUID).toBe(ST_NOSUID); + }); + + it("keeps generic mutable MemoryFS execution nosuid", () => { + const backend = createSetIdFileSystem(); + const statfs = backend.statfs.bind(backend); + backend.statfs = (path) => ({ ...statfs(path), flags: 0 }); + const vfs = new VirtualPlatformIO( + [{ mountPoint: "/", backend }], + new NodeTimeProvider(), + ); + + expect(vfs.stat("/bin/tool").mode & 0o6000).toBe(0o6000); + expect(vfs.statfs("/bin/tool").flags & ST_NOSUID).toBe(ST_NOSUID); + expect(vfs.getMountSetIdCapability("/bin/tool")).toEqual({ + kind: "nosuid", + }); + }); + + it("clears backend ST_NOSUID only from the admitted trusted mount", () => { + const source = createSetIdFileSystem(); + const backend = createImmutableProductBackend(source); + expect(backend.statfs("/bin/tool").flags & ST_NOSUID).toBe(ST_NOSUID); + const vfs = new VirtualPlatformIO( + [{ + mountPoint: "/", + backend, + readonly: true, + setIdCapability: TRUSTED_ROOT_PRODUCT, + }], + new NodeTimeProvider(), + ); + + const stat = vfs.stat("/bin/tool"); + expect(stat.mode & FILE_MODES.S_ISUID).toBe(FILE_MODES.S_ISUID); + expect(stat.mode & FILE_MODES.S_ISGID).toBe(FILE_MODES.S_ISGID); + expect(vfs.statfs("/bin/tool").flags & ST_NOSUID).toBe(0); + expect(vfs.getMountSetIdCapability("/bin/tool")).toEqual( + TRUSTED_ROOT_PRODUCT, + ); + }); + + it("snapshots product state away from caller-retained mutation authority", () => { + const source = createSetIdFileSystem(); + const backend = createImmutableProductBackend(source); + const before = backend.stat("/bin/tool"); + + source.chown("/bin/tool", 501, 502); + source.chmod("/bin/tool", 0o755); + const sourceHandle = source.open( + "/bin/tool", + OPEN_FLAGS.O_WRONLY | OPEN_FLAGS.O_TRUNC, + 0, + ); + source.write( + sourceHandle, + new Uint8Array([1, 2, 3, 4]), + 0, + 4, + ); + source.close(sourceHandle); + + const after = backend.stat("/bin/tool"); + expect(after.uid).toBe(before.uid); + expect(after.gid).toBe(before.gid); + expect(after.mode).toBe(before.mode); + const trustedHandle = backend.open("/bin/tool", OPEN_FLAGS.O_RDONLY, 0); + const bytes = new Uint8Array(4); + expect(backend.read(trustedHandle, bytes, 0, bytes.byteLength)).toBe(4); + expect(Array.from(bytes)).toEqual([0, 97, 115, 109]); + backend.close(trustedHandle); + }); + + it("isolates trusted stat, open, and read from producer backing prototype mutation", () => { + const source = createSetIdFileSystem(); + const backend = createImmutableProductBackend(source); + const retainedHandle = backend.open( + "/bin/tool", + OPEN_FLAGS.O_RDONLY, + 0, + ); + const backing = (source as unknown as { fs: object }).fs; + const prototype = Object.getPrototypeOf(backing) as Record< + "stat" | "open" | "readAt", + (...args: unknown[]) => unknown + >; + const statDescriptor = Object.getOwnPropertyDescriptor(prototype, "stat")!; + const openDescriptor = Object.getOwnPropertyDescriptor(prototype, "open")!; + const readAtDescriptor = Object.getOwnPropertyDescriptor( + prototype, + "readAt", + )!; + const originalStat = statDescriptor.value as ( + this: object, + path: string, + ) => Record; + let observedStat: ReturnType | undefined; + let openedAfterPatch: number | undefined; + let openError: unknown; + let bytes = new Uint8Array(4); + + try { + Object.defineProperty(prototype, "stat", { + ...statDescriptor, + value(this: object, path: string) { + return { ...Reflect.apply(originalStat, this, [path]), uid: 777 }; + }, + }); + Object.defineProperty(prototype, "open", { + ...openDescriptor, + value() { + throw new Error("producer prototype open trap"); + }, + }); + Object.defineProperty(prototype, "readAt", { + ...readAtDescriptor, + value(_handle: number, buffer: Uint8Array) { + buffer.fill(7); + return buffer.byteLength; + }, + }); + + observedStat = backend.stat("/bin/tool"); + try { + openedAfterPatch = backend.open( + "/bin/tool", + OPEN_FLAGS.O_RDONLY, + 0, + ); + } catch (error) { + openError = error; + } + expect(backend.read( + retainedHandle, + bytes, + 0, + bytes.byteLength, + )).toBe(4); + } finally { + Object.defineProperty(prototype, "stat", statDescriptor); + Object.defineProperty(prototype, "open", openDescriptor); + Object.defineProperty(prototype, "readAt", readAtDescriptor); + if (openedAfterPatch !== undefined) backend.close(openedAfterPatch); + backend.close(retainedHandle); + } + + expect(observedStat?.uid).toBe(0); + expect(openError).toBeUndefined(); + expect(Array.from(bytes)).toEqual([0, 97, 115, 109]); + }); + + it("isolates trusted helpers from producer MemoryFS prototype mutation", () => { + const source = createSetIdFileSystem(); + const backend = createImmutableProductBackend(source); + const prototype = Object.getPrototypeOf(source) as Record< + "adaptStatWithLazySize" | "guardSynchronousLazyAccess", + (...args: unknown[]) => unknown + >; + const adaptDescriptor = Object.getOwnPropertyDescriptor( + prototype, + "adaptStatWithLazySize", + )!; + const guardDescriptor = Object.getOwnPropertyDescriptor( + prototype, + "guardSynchronousLazyAccess", + )!; + const originalAdapt = adaptDescriptor.value as ( + this: object, + stat: unknown, + ) => Record; + let observedStat: ReturnType | undefined; + let openError: unknown; + let handle: number | undefined; + + try { + Object.defineProperty(prototype, "adaptStatWithLazySize", { + ...adaptDescriptor, + value(this: object, stat: unknown) { + return { ...Reflect.apply(originalAdapt, this, [stat]), uid: 888 }; + }, + }); + Object.defineProperty(prototype, "guardSynchronousLazyAccess", { + ...guardDescriptor, + value() { + throw new Error("producer MemoryFS guard trap"); + }, + }); + observedStat = backend.stat("/bin/tool"); + try { + handle = backend.open("/bin/tool", OPEN_FLAGS.O_RDONLY, 0); + } catch (error) { + openError = error; + } + } finally { + Object.defineProperty( + prototype, + "adaptStatWithLazySize", + adaptDescriptor, + ); + Object.defineProperty( + prototype, + "guardSynchronousLazyAccess", + guardDescriptor, + ); + if (handle !== undefined) backend.close(handle); + } + + expect(observedStat?.uid).toBe(0); + expect(openError).toBeUndefined(); + }); + + it("isolates trusted behavior from producer-reachable class properties", () => { + const source = createSetIdFileSystem(); + const backend = createImmutableProductBackend(source); + const handle = backend.open("/bin/tool", OPEN_FLAGS.O_RDONLY, 0); + const backing = (source as unknown as { fs: object }).fs; + const sharedConstructor = Object.getPrototypeOf(backing).constructor as + Record; + const memoryConstructor = MemoryFileSystem as unknown as Record< + string, + unknown + >; + const thresholdDescriptor = Object.getOwnPropertyDescriptor( + sharedConstructor, + "DIR_INDEX_MIN_SIZE", + ); + const inodeKeyDescriptor = Object.getOwnPropertyDescriptor( + memoryConstructor, + "inodeKey", + ); + let pathStatError: unknown; + let handleStatError: unknown; + + try { + Object.defineProperty(sharedConstructor, "DIR_INDEX_MIN_SIZE", { + configurable: true, + get() { + throw new Error("producer SharedFS class trap"); + }, + }); + Object.defineProperty(memoryConstructor, "inodeKey", { + configurable: true, + get() { + throw new Error("producer MemoryFS class trap"); + }, + }); + try { + backend.stat("/bin/tool"); + } catch (error) { + pathStatError = error; + } + try { + backend.fstat(handle); + } catch (error) { + handleStatError = error; + } + } finally { + if (thresholdDescriptor) { + Object.defineProperty( + sharedConstructor, + "DIR_INDEX_MIN_SIZE", + thresholdDescriptor, + ); + } else { + Reflect.deleteProperty(sharedConstructor, "DIR_INDEX_MIN_SIZE"); + } + if (inodeKeyDescriptor) { + Object.defineProperty( + memoryConstructor, + "inodeKey", + inodeKeyDescriptor, + ); + } else { + Reflect.deleteProperty(memoryConstructor, "inodeKey"); + } + backend.close(handle); + } + + expect(pathStatError).toBeUndefined(); + expect(handleStatError).toBeUndefined(); + }); + + it("keeps open mutation checks independent from generated flag tables", () => { + const source = createSetIdFileSystem(); + const backend = createImmutableProductBackend(source); + const metadataBefore = backend.stat("/bin/tool"); + const mutableFlags = OPEN_FLAGS as unknown as Record; + const keys = ["O_RDONLY", "O_ACCMODE", "O_CREAT", "O_TRUNC"] as const; + const descriptors = new Map( + keys.map((key) => [ + key, + Object.getOwnPropertyDescriptor(mutableFlags, key)!, + ]), + ); + const attempts = [ + { path: "/bin/tool", flags: 1 }, + { path: "/bin/tool", flags: 2 }, + { path: "/bin/tool", flags: 512 }, + { path: "/bin/tool", flags: 513 }, + { path: "/bin/created", flags: 64 }, + { path: "/bin/created-truncated", flags: 576 }, + { path: "/bin/created-exclusive", flags: 193 }, + { path: "/bin/tool", flags: 1025 }, + ]; + const outcomes: string[] = []; + + try { + mutableFlags.O_RDONLY = 0; + mutableFlags.O_ACCMODE = 0; + mutableFlags.O_CREAT = 0; + mutableFlags.O_TRUNC = 0; + for (const attempt of attempts) { + try { + const handle = backend.open(attempt.path, attempt.flags, 0o755); + outcomes.push("opened"); + backend.close(handle); + } catch (error) { + outcomes.push(error instanceof Error ? error.message : String(error)); + } + } + } finally { + for (const [key, descriptor] of descriptors) { + Object.defineProperty(mutableFlags, key, descriptor); + } + } + + expect(outcomes).toEqual(Array(attempts.length).fill( + "EROFS: Read-only file system", + )); + expect(backend.stat("/bin/tool")).toEqual(metadataBefore); + const handle = backend.open("/bin/tool", OPEN_FLAGS.O_RDONLY, 0); + const bytes = new Uint8Array(4); + expect(backend.read(handle, bytes, 0, bytes.byteLength)).toBe(4); + expect(Array.from(bytes)).toEqual([0, 97, 115, 109]); + backend.close(handle); + expect(() => backend.stat("/bin/created")).toThrow(/No such file/); + expect(() => backend.stat("/bin/created-truncated")).toThrow( + /No such file/, + ); + expect(() => backend.stat("/bin/created-exclusive")).toThrow( + /No such file/, + ); + }); + + it("uses one normalized primitive for the open guard and backend", () => { + const backend = createImmutableProductBackend(createSetIdFileSystem()); + const metadataBefore = backend.stat("/bin/tool"); + let coercions = 0; + const changingFlags = { + [Symbol.toPrimitive]() { + coercions += 1; + return coercions <= 2 ? OPEN_FLAGS.O_RDONLY : OPEN_FLAGS.O_TRUNC; + }, + } as unknown as number; + const opened = backend.open("/bin/tool", changingFlags, 0); + backend.close(opened); + const metadataAfter = backend.stat("/bin/tool"); + const readHandle = backend.open("/bin/tool", OPEN_FLAGS.O_RDONLY, 0); + const bytes = new Uint8Array(4); + const bytesRead = backend.read( + readHandle, + bytes, + 0, + bytes.byteLength, + ); + backend.close(readHandle); + + expect(metadataAfter).toEqual(metadataBefore); + expect(bytesRead).toBe(4); + expect(Array.from(bytes)).toEqual([0, 97, 115, 109]); + expect(coercions).toBe(1); + }); + + it("rejects non-integer values for guarded numeric inputs", () => { + const backend = createImmutableProductBackend(createSetIdFileSystem()); + let invalidFlagHandle: number | undefined; + let openError: unknown; + let accessError: unknown; + + try { + invalidFlagHandle = backend.open("/bin/tool", 0.5, 0); + } catch (error) { + openError = error; + } finally { + if (invalidFlagHandle !== undefined) backend.close(invalidFlagHandle); + } + try { + backend.access("/bin/tool", 0.5); + } catch (error) { + accessError = error; + } + + expect(openError).toBeInstanceOf(TypeError); + expect(accessError).toBeInstanceOf(TypeError); + }); + + it("keeps trusted directory and pathconf results independent from generated tables", () => { + const backend = createImmutableProductBackend(createSetIdFileSystem()); + const tableEntries = [ + [FILE_MODES, "S_IFMT"], + [FILE_MODES, "S_IFREG"], + [DIRENT_TYPES, "DT_UNKNOWN"], + [DIRENT_TYPES, "DT_REG"], + [PATHCONF_NAMES, "ASYNC_IO"], + ] as const; + const descriptors = tableEntries.map(([table, key]) => [ + table, + key, + Object.getOwnPropertyDescriptor(table, key)!, + ] as const); + let pathconfResult: number | null | undefined; + let fpathconfResult: number | null | undefined; + let toolType: number | undefined; + const fileHandle = backend.open("/bin/tool", OPEN_FLAGS.O_RDONLY, 0); + const directoryHandle = backend.opendir("/bin"); + + try { + (FILE_MODES as unknown as Record).S_IFMT = 0; + (FILE_MODES as unknown as Record).S_IFREG = 0; + (DIRENT_TYPES as unknown as Record).DT_UNKNOWN = 99; + (DIRENT_TYPES as unknown as Record).DT_REG = 99; + (PATHCONF_NAMES as unknown as Record).ASYNC_IO = 999; + pathconfResult = backend.pathconf("/bin/tool", 10); + fpathconfResult = backend.fpathconf(fileHandle, 10); + for (;;) { + const entry = backend.readdir(directoryHandle); + if (!entry) break; + if (entry.name === "tool") toolType = entry.type; + } + } finally { + for (const [table, key, descriptor] of descriptors) { + Object.defineProperty(table, key, descriptor); + } + backend.closedir(directoryHandle); + backend.close(fileHandle); + } + + expect(pathconfResult).toBe(1); + expect(fpathconfResult).toBe(1); + expect(toolType).toBe(8); + }); + + it("retains the exact open generation through rename and unlink", () => { + const source = createSetIdFileSystem(); + const backend = createImmutableProductBackend(source); + const vfs = new VirtualPlatformIO( + [{ + mountPoint: "/", + backend, + readonly: true, + setIdCapability: TRUSTED_ROOT_PRODUCT, + }], + new NodeTimeProvider(), + ); + const handle = vfs.open("/bin/tool", OPEN_FLAGS.O_RDONLY, 0); + const before = vfs.fstat(handle); + const identity = vfs.fileHandleIdentity!( + handle, + BigInt(before.dev), + BigInt(before.ino), + ); + + source.rename("/bin/tool", "/bin/renamed"); + source.unlink("/bin/renamed"); + + const after = vfs.fstat(handle); + expect(after.ino).toBe(before.ino); + expect(vfs.fileHandleIdentity!( + handle, + BigInt(after.dev), + BigInt(after.ino), + )).toBe(identity); + expect(vfs.read(handle, new Uint8Array(4), 0, 4)).toBe(4); + vfs.close(handle); + }); + + it("rejects guest mutation through the trusted backend", () => { + const backend = createImmutableProductBackend(createSetIdFileSystem()); + + expect(() => backend.open( + "/bin/tool", + OPEN_FLAGS.O_WRONLY | OPEN_FLAGS.O_TRUNC, + 0, + )).toThrow(/EROFS/); + expect(() => backend.unlink("/bin/tool")).toThrow(/EROFS/); + expect( + (backend as unknown as Record).createFileWithOwner, + ).toBeUndefined(); + expect( + (backend as unknown as Record).fs, + ).toBeUndefined(); + expect(Reflect.ownKeys(backend)).toEqual([]); + }); +}); diff --git a/host/test/vfs.test.ts b/host/test/vfs.test.ts index cbe4ebdf89..cce593b53b 100644 --- a/host/test/vfs.test.ts +++ b/host/test/vfs.test.ts @@ -24,7 +24,11 @@ import { SFSError, } from "../src/vfs/sharedfs-vendor"; import { NodeTimeProvider } from "../src/vfs/time"; -import type { FileSystemBackend, MountConfig } from "../src/vfs/types"; +import { + ST_NOSUID, + type FileSystemBackend, + type MountConfig, +} from "../src/vfs/types"; import type { StatResult, StatfsResult } from "../src/types"; // --------------------------------------------------------------------------- @@ -175,6 +179,19 @@ function createMockBackend( // --------------------------------------------------------------------------- describe("VirtualPlatformIO mount resolution", () => { + it("reports omitted mount capabilities as ST_NOSUID", () => { + const root = createMockBackend(); + const vfs = new VirtualPlatformIO( + [{ mountPoint: "/", backend: root }], + new NodeTimeProvider(), + ); + + expect(vfs.statfs("/bin/tool").flags & ST_NOSUID).toBe(ST_NOSUID); + expect(vfs.getMountSetIdCapability("/bin/tool")).toEqual({ + kind: "nosuid", + }); + }); + it("routes root-level paths to the / mount", () => { const root = createMockBackend(); const vfs = new VirtualPlatformIO( diff --git a/host/test/vfs/default-mounts.test.ts b/host/test/vfs/default-mounts.test.ts index 3eddd93fc8..d6e9838a93 100644 --- a/host/test/vfs/default-mounts.test.ts +++ b/host/test/vfs/default-mounts.test.ts @@ -31,6 +31,8 @@ import { forgeLazyAtomicSeal, type LazyAtomicSealForgery, } from "../lazy-atomic-seal-fixture"; +import { resolveMountSetIdCapability } from "../../src/vfs/memory-fs"; +import { ST_NOSUID } from "../../src/vfs/types"; const O_RDONLY = 0x0000; const O_WRONLY = 0x0001; @@ -160,6 +162,8 @@ describe("resolveForNode", () => { for (const m of mounts) { expect(typeof m.mountPoint).toBe("string"); expect(m.backend).toBeDefined(); + expect(resolveMountSetIdCapability(m)).toEqual({ kind: "nosuid" }); + expect(m.backend.statfs("/").flags & ST_NOSUID).toBe(ST_NOSUID); } }); @@ -662,6 +666,8 @@ describe("resolveForBrowser", () => { for (const m of mounts) { expect(m.backend).toBeInstanceOf(MemoryFileSystem); expect(m.backend).not.toBeInstanceOf(HostFileSystem); + expect(resolveMountSetIdCapability(m)).toEqual({ kind: "nosuid" }); + expect(m.backend.statfs("/").flags & ST_NOSUID).toBe(ST_NOSUID); } }); diff --git a/tools/xtask/src/dump_abi.rs b/tools/xtask/src/dump_abi.rs index 5c721fc4f8..6e9d82d552 100644 --- a/tools/xtask/src/dump_abi.rs +++ b/tools/xtask/src/dump_abi.rs @@ -2247,6 +2247,11 @@ fn render_ts_module() -> String { out.push_str(&format!(" {}: {},\n", name, value)); } out.push_str("} as const;\n\n"); + out.push_str("export const STATFS_FLAGS = {\n"); + for (name, value) in statfs_flags() { + out.push_str(&format!(" {}: {},\n", name, value)); + } + out.push_str("} as const;\n\n"); out.push_str("export const FILE_MODES = {\n"); for (name, value) in file_modes() { out.push_str(&format!(" {}: {},\n", name, value)); @@ -4149,12 +4154,17 @@ fn vfs_metadata() -> Value { "fd_flags": named_values(fd_flags()), "fcntl_commands": named_values(fcntl_commands()), "access_modes": named_values(access_modes()), + "statfs_flags": named_values(statfs_flags()), "file_modes": named_values(file_modes()), "dirent_types": named_values(dirent_types()), "seek_whence": named_values(seek_whence()), }) } +fn statfs_flags() -> [(&'static str, u32); 1] { + [("ST_NOSUID", shared::statfs_flags::ST_NOSUID)] +} + fn channel_scalar_contract() -> Value { let syscalls: Vec = shared::channel_scalar::SYSCALLS .iter() @@ -6925,6 +6935,9 @@ fn classify_compat_change(old: &Value, new: &Value) -> Result { classify_additive_object_by_key(key, old_value, new_value, &mut report)? } + "vfs_metadata" => { + classify_additive_object_by_key(key, old_value, new_value, &mut report)? + } _ if old_value != new_value => { report .breaking @@ -7406,6 +7419,7 @@ mod tests { assert_eq!(metadata["fd_flags"].as_array().unwrap().len(), 2); assert_eq!(metadata["fcntl_commands"].as_array().unwrap().len(), 15); assert_eq!(metadata["access_modes"].as_array().unwrap().len(), 4); + assert_eq!(metadata["statfs_flags"].as_array().unwrap().len(), 1); assert_eq!(metadata["file_modes"].as_array().unwrap().len(), 24); assert_eq!(metadata["dirent_types"].as_array().unwrap().len(), 8); assert_eq!(metadata["seek_whence"].as_array().unwrap().len(), 3); @@ -7419,6 +7433,7 @@ mod tests { ("fd_flags", "FD_CLOFORK", json!(2)), ("fcntl_commands", "F_DUPFD_CLOFORK", json!(1028)), ("access_modes", "X_OK", json!(1)), + ("statfs_flags", "ST_NOSUID", json!(2)), ("file_modes", "S_IFREG", json!(0o100000)), ("file_modes", "S_MODE_BITS", json!(0o7777)), ("dirent_types", "DT_SOCK", json!(12)), @@ -7442,6 +7457,8 @@ mod tests { "export const FD_FLAGS = {", "export const FCNTL_COMMANDS = {", "export const ACCESS_MODES = {", + "export const STATFS_FLAGS = {", + " ST_NOSUID: 2,", "export const FILE_MODES = {", " S_MODE_BITS: 4095,", "export const DIRENT_TYPES = {", @@ -8264,6 +8281,23 @@ mod tests { ); } + #[test] + fn adding_vfs_metadata_entry_is_compatible() { + let mut old = base_snapshot(); + old["vfs_metadata"] + .as_object_mut() + .unwrap() + .remove("statfs_flags"); + let new = base_snapshot(); + + let report = classify_compat_change(&old, &new).unwrap(); + assert!(report.breaking.is_empty(), "{report:?}"); + assert_eq!( + report.additive, + vec!["added vfs_metadata entry \"statfs_flags\""] + ); + } + #[test] fn adding_wakeup_event_wire_section_requires_an_abi_bump() { let old = base_snapshot(); From 341f72ccb6bba97a951a87d5fda8c85ee0c1daff Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Mon, 10 Aug 2026 21:35:19 -0400 Subject: [PATCH 59/82] Homebrew: Isolate privileged product programs Copy the reviewed login and sudo-family bottle members into fresh, root-owned regular inodes on the private Task 6 product backend. Keep the ordinary bottle tree writable and nosuid, and reject the complete projection group on inventory, digest, alias, ownership, or inode identity drift. Wire opaque product-owned policy through build-time images and runtime Node/browser composition without granting credentials. --- .../lib/init/homebrew-package-layers.ts | 31 +- .../pages/homebrew-vfs-test/main.ts | 141 +++- .../test/browser-package-layer.spec.ts | 160 +++- docs/architecture.md | 21 +- docs/browser-support.md | 11 + docs/homebrew-publishing.md | 19 + docs/posix-status.md | 2 +- host/src/homebrew-runtime-layer-consumer.ts | 125 ++- host/src/homebrew-vfs-builder.ts | 107 ++- host/src/homebrew-vfs-planner.ts | 55 +- host/src/vfs/memory-fs.ts | 103 +++ host/src/vfs/privileged-projection.ts | 762 ++++++++++++++++++ host/test/homebrew-vfs-builder.test.ts | 203 +++++ host/test/homebrew-vfs-planner.test.ts | 47 ++ host/test/kernel-authority-boundary.test.ts | 25 + host/test/privileged-projection.test.ts | 506 ++++++++++++ .../vfs/scripts/build-homebrew-vfs-image.ts | 87 ++ packages/registry/program-packages.json | 96 +-- 18 files changed, 2435 insertions(+), 66 deletions(-) create mode 100644 host/src/vfs/privileged-projection.ts create mode 100644 host/test/privileged-projection.test.ts diff --git a/apps/browser-demos/lib/init/homebrew-package-layers.ts b/apps/browser-demos/lib/init/homebrew-package-layers.ts index b9f2a70977..e122d45338 100644 --- a/apps/browser-demos/lib/init/homebrew-package-layers.ts +++ b/apps/browser-demos/lib/init/homebrew-package-layers.ts @@ -1,8 +1,12 @@ import { composeHomebrewRuntimeLayers, + composeHomebrewRuntimeLayersWithReviewedProduct, type ComposedHomebrewRuntimeLayers, type HomebrewRuntimeLayerReference, } from "../../../../host/src/homebrew-runtime-layer-consumer"; +import type { + ReviewedPrivilegedProgramPolicy, +} from "../../../../host/src/vfs/privileged-projection"; import { restoreVerifiedVfsImage, restoreVerifiedVfsImagePreservingCapacity, @@ -70,10 +74,25 @@ export function homebrewRuntimeLayerReferences( */ export async function composeBootDescriptorVfs( options: ComposeBootDescriptorVfsOptions, +): Promise { + return composeBootDescriptorVfsInternal(options); +} + +/** Product-owned adapter; absent from boot/request and public host surfaces. */ +export async function composeBootDescriptorVfsWithReviewedProduct( + options: ComposeBootDescriptorVfsOptions, + policy: ReviewedPrivilegedProgramPolicy, +): Promise { + return composeBootDescriptorVfsInternal(options, policy); +} + +async function composeBootDescriptorVfsInternal( + options: ComposeBootDescriptorVfsOptions, + policy?: ReviewedPrivilegedProgramPolicy, ): Promise { validateBootDescriptor(options.descriptor); const references = homebrewRuntimeLayerReferences(options.descriptor); - if (references.length === 0) { + if (references.length === 0 && policy === undefined) { const fs = options.maxByteLength === undefined ? await restoreVerifiedVfsImagePreservingCapacity(options.baseImageBytes) : await restoreVerifiedVfsImage(options.baseImageBytes, { @@ -82,7 +101,7 @@ export async function composeBootDescriptorVfs( return { fs, layers: [], references }; } - const composed = await composeHomebrewRuntimeLayers({ + const runtimeOptions = { baseImageBytes: options.baseImageBytes, ...(options.maxByteLength === undefined ? {} @@ -99,6 +118,12 @@ export async function composeBootDescriptorVfs( : { onStagedFileSystemDiscarded: options.onStagedFileSystemDiscarded, }), - }); + }; + const composed = policy === undefined + ? await composeHomebrewRuntimeLayers(runtimeOptions) + : await composeHomebrewRuntimeLayersWithReviewedProduct( + runtimeOptions, + policy, + ); return { ...composed, references }; } diff --git a/apps/browser-demos/pages/homebrew-vfs-test/main.ts b/apps/browser-demos/pages/homebrew-vfs-test/main.ts index f04fd60c9e..2338f6da46 100644 --- a/apps/browser-demos/pages/homebrew-vfs-test/main.ts +++ b/apps/browser-demos/pages/homebrew-vfs-test/main.ts @@ -1,6 +1,9 @@ import { BrowserKernel } from "@host/browser-kernel-host"; import { ABI_VERSION } from "@host/generated/abi"; -import { MemoryFileSystem } from "@host/vfs/memory-fs"; +import { + MemoryFileSystem, + resolveMountSetIdCapability, +} from "@host/vfs/memory-fs"; import { restoreVerifiedVfsImage, restoreVerifiedVfsImagePreservingCapacity, @@ -12,7 +15,12 @@ import { } from "../../lib/kernel-owned-boot"; import { composeBootDescriptorVfs, + composeBootDescriptorVfsWithReviewedProduct, } from "../../lib/init/homebrew-package-layers"; +import { createReviewedPrivilegedProgramPolicy } from + "@host/vfs/privileged-projection"; +import * as privilegedProjectionModule from + "@host/vfs/privileged-projection"; import { homebrewClosedAcceptanceAssetRoot, } from "../../lib/homebrew-closed-acceptance"; @@ -108,16 +116,49 @@ interface LazyVfsAcceptanceResult { interface PackageLayerBootRequest { baseVfsUrl: string; descriptor: BootDescriptor; + reviewedProductProfile?: "package-layer-acceptance-v1"; inspect?: { statPaths: string[]; readdirPaths: string[]; }; } +const PACKAGE_LAYER_ACCEPTANCE_PRODUCT_POLICY = + createReviewedPrivilegedProgramPolicy( + ["login", "sudo-lite", "sudo"].map((name) => ({ + schema: 1, + formula: "kandelo-dev/tap-core/lazyfixture", + bottleSha256: + "3daab2c56480490730e08bd73ee06e6beb681fa45ada1179318514af9362c433", + sourcePath: "lazyfixture/1.0/bin/mount-probe", + destinationPath: `/usr/bin/${name}`, + uid: 0, + gid: 0, + mode: 0o4755, + mountPoint: "trusted-root-product", + artifactValidationSha256: + "bc22eba05a72927443ab9294a685b0bde701977c4e380d18c25f357f2d8aa584", + })), + ); + interface PackageLayerBootResult { layerIds: string[]; stats: Array<{ path: string; mode: number; size: number }>; directories: Array<{ path: string; names: string[] }>; + privilegedProduct?: { + stats: Array<{ + path: string; + mode: number; + uid: number; + gid: number; + nlink: number; + }>; + uniqueIdentityCount: number; + readonly: boolean; + trusted: boolean; + ordinaryBottleWritable: boolean; + ordinaryMountNosuid: boolean; + }; } interface PackageLayerExecRequest { @@ -188,6 +229,12 @@ declare global { ) => Promise; __destroyPackageLayerAcceptance: () => Promise; __packageLayerDiscardedBufferCount: () => number; + __inspectSharedWrapperAuthorityBoundary: () => { + distinctWrapper: boolean; + mutationShared: boolean; + candidateAdmission: boolean; + testCandidateAdmission: boolean; + }; __runRootfsExportAcceptance: ( request: RootfsExportAcceptanceRequest, ) => Promise; @@ -859,6 +906,28 @@ async function init(): Promise { }; window.__packageLayerDiscardedBufferCount = () => packageLayerDiscardedBufferCount; + window.__inspectSharedWrapperAuthorityBoundary = () => { + const candidate = MemoryFileSystem.create( + new SharedArrayBuffer(4 * 1024 * 1024), + ); + const distinctWrapper = structuredClone(candidate.sharedBuffer); + const writableAlias = MemoryFileSystem.fromExisting(distinctWrapper); + candidate.mkdir("/shared-wrapper-proof", 0o755); + return { + distinctWrapper: distinctWrapper !== candidate.sharedBuffer, + mutationShared: + (writableAlias.lstat("/shared-wrapper-proof").mode & 0o170000) === + 0o040000, + candidateAdmission: Reflect.has( + privilegedProjectionModule, + "admitPrivilegedProgramProductCandidate", + ), + testCandidateAdmission: Reflect.has( + privilegedProjectionModule, + "admitPrivilegedProgramProductCandidateForTest", + ), + }; + }; window.__bootPackageLayerAcceptance = async (request) => { await window.__destroyPackageLayerAcceptance(); @@ -872,7 +941,7 @@ async function init(): Promise { ABI_VERSION, "package-layer base VFS image", ); - const composed = await composeBootDescriptorVfs({ + const compositionOptions = { descriptor: request.descriptor, baseImageBytes, kernelAbi: ABI_VERSION, @@ -880,7 +949,19 @@ async function init(): Promise { packageLayerDiscardedBufferCount += 1; trackTransientImageBuffer(buffer); }, - }); + }; + if ( + request.reviewedProductProfile !== undefined && + request.reviewedProductProfile !== "package-layer-acceptance-v1" + ) { + throw new Error("unknown reviewed package-layer product profile"); + } + const composed = request.reviewedProductProfile === undefined + ? await composeBootDescriptorVfs(compositionOptions) + : await composeBootDescriptorVfsWithReviewedProduct( + compositionOptions, + PACKAGE_LAYER_ACCEPTANCE_PRODUCT_POLICY, + ); trackTransientImageBuffer(composed.fs.sharedBuffer); const stats = (request.inspect?.statPaths ?? []).map((path) => { const stat = composed.fs.stat(path); @@ -900,6 +981,59 @@ async function init(): Promise { } return { path, names: names.sort() }; }); + let privilegedProduct: PackageLayerBootResult["privilegedProduct"]; + if (composed.privilegedProduct !== undefined) { + const destinations = composed.privilegedProduct.projections.map( + (projection) => projection.destinationPath, + ); + let readonly = false; + try { + composed.privilegedProduct.mount.backend.unlink(destinations[0]!); + } catch (error) { + readonly = error instanceof Error && error.message.includes("EROFS"); + } + const firstProjection = composed.privilegedProduct.projections[0]!; + const ordinaryBottlePath = + `/opt/kandelo/homebrew/Cellar/${firstProjection.sourcePath}`; + const ordinaryBottleMode = composed.fs.lstat(ordinaryBottlePath).mode & + 0o7777; + let ordinaryBottleWritable = false; + try { + composed.fs.chmod(ordinaryBottlePath, ordinaryBottleMode ^ 0o200); + ordinaryBottleWritable = + (composed.fs.lstat(ordinaryBottlePath).mode & 0o7777) === + (ordinaryBottleMode ^ 0o200); + } finally { + composed.fs.chmod(ordinaryBottlePath, ordinaryBottleMode); + } + privilegedProduct = { + stats: destinations.map((path) => { + const stat = composed.privilegedProduct!.mount.backend.lstat(path); + return { + path, + mode: stat.mode, + uid: stat.uid, + gid: stat.gid, + nlink: stat.nlink, + }; + }), + uniqueIdentityCount: new Set( + composed.privilegedProduct.evidence.map((entry) => + `${entry.destinationIdentity.dev}:` + + `${entry.destinationIdentity.ino}:` + + `${entry.destinationIdentity.generation}` + ), + ).size, + readonly, + trusted: + resolveMountSetIdCapability(composed.privilegedProduct.mount).kind === + "trusted-root-product", + ordinaryBottleWritable, + ordinaryMountNosuid: + resolveMountSetIdCapability({ backend: composed.fs }).kind === + "nosuid", + }; + } const output = { stdout: "", stderr: "" }; kernel = new BrowserKernel({ kernelOwnedFs: true, @@ -919,6 +1053,7 @@ async function init(): Promise { layerIds: composed.layers.map((layer) => layer.id), stats, directories, + ...(privilegedProduct === undefined ? {} : { privilegedProduct }), }; } catch (error) { if (kernel) await kernel.destroy().catch(() => {}); diff --git a/apps/browser-demos/test/browser-package-layer.spec.ts b/apps/browser-demos/test/browser-package-layer.spec.ts index c56263a7a3..971b0b3666 100644 --- a/apps/browser-demos/test/browser-package-layer.spec.ts +++ b/apps/browser-demos/test/browser-package-layer.spec.ts @@ -35,6 +35,7 @@ declare global { __bootPackageLayerAcceptance: (request: { baseVfsUrl: string; descriptor: BootDescriptor; + reviewedProductProfile?: "package-layer-acceptance-v1"; inspect?: { statPaths: string[]; readdirPaths: string[]; @@ -43,6 +44,20 @@ declare global { layerIds: string[]; stats: Array<{ path: string; mode: number; size: number }>; directories: Array<{ path: string; names: string[] }>; + privilegedProduct?: { + stats: Array<{ + path: string; + mode: number; + uid: number; + gid: number; + nlink: number; + }>; + uniqueIdentityCount: number; + readonly: boolean; + trusted: boolean; + ordinaryBottleWritable: boolean; + ordinaryMountNosuid: boolean; + }; }>; __readPackageLayerAcceptance: (path: string) => Promise; __execPackageLayerAcceptance: (request: { @@ -53,6 +68,12 @@ declare global { }) => Promise<{ exitCode: number; stdout: string; stderr: string }>; __destroyPackageLayerAcceptance: () => Promise; __packageLayerDiscardedBufferCount: () => number; + __inspectSharedWrapperAuthorityBoundary: () => { + distinctWrapper: boolean; + mutationShared: boolean; + candidateAdmission: boolean; + testCandidateAdmission: boolean; + }; } } @@ -689,8 +710,8 @@ async function createDirectBottleFixture(): Promise { ]; const rootTar = directTarBytes(rootSpecs); const dependencyTar = directTarBytes(dependencySpecs); - const rootArchive = gzipSync(rootTar); - const dependencyArchive = gzipSync(dependencyTar); + const rootArchive = gzipSync(rootTar, { mtime: 0 }); + const dependencyArchive = gzipSync(dependencyTar, { mtime: 0 }); const rootPackage = directPackageRecord(PACKAGE, VERSION, rootArchive); const dependencyPackage = directPackageRecord( dependency, @@ -1282,6 +1303,141 @@ test("browser keeps independent original bottles lazy until their own first use" } }); +test("browser publishes reviewed programs as independent trusted product inodes", async ({ + page, + baseURL, +}) => { + test.setTimeout(180_000); + if (!baseURL) throw new Error("Playwright baseURL is required"); + const fixture = await createDirectBottleFixture(); + const executableBytes = new Uint8Array(readFileSync(mountProbeProgram)); + let rootBottleFetches = 0; + let dependencyBottleFetches = 0; + await routeBytes(page, fixture.urls.base, fixture.baseImage); + await routeBytes(page, fixture.urls.descriptor, fixture.descriptorBytes); + await routeBytes( + page, + fixture.urls.exec, + fixture.execArchive, + () => rootBottleFetches++, + ); + await routeBytes( + page, + fixture.urls.data, + fixture.dataArchive, + () => dependencyBottleFetches++, + ); + + await page.goto(new URL("/pages/homebrew-vfs-test/", baseURL).href); + await expect.poll( + () => page.evaluate(() => window.__homebrewVfsTestReady), + { timeout: 120_000 }, + ).toBe(true); + + try { + await expect(page.evaluate(() => + window.__inspectSharedWrapperAuthorityBoundary() + )).resolves.toMatchObject({ + distinctWrapper: true, + mutationShared: true, + candidateAdmission: false, + testCandidateAdmission: false, + }); + const projections = ["login", "sudo-lite", "sudo"].map((name) => ({ + schema: 1, + formula: `${TAP_NAME}/${PACKAGE}`, + bottleSha256: sha256(fixture.execArchive), + sourcePath: `${PACKAGE}/${VERSION}/bin/mount-probe`, + destinationPath: `/usr/bin/${name}`, + uid: 0, + gid: 0, + mode: 0o4755, + mountPoint: "trusted-root-product", + artifactValidationSha256: sha256(executableBytes), + })); + const descriptorInjected = await page.evaluate( + ({ baseVfsUrl, descriptor, privilegedProjections }) => + window.__bootPackageLayerAcceptance({ + baseVfsUrl, + descriptor: { + ...descriptor, + privilegedProjections, + } as unknown as BootDescriptor, + }), + { + baseVfsUrl: fixture.urls.base, + descriptor: fixture.descriptor, + privilegedProjections: projections, + }, + ); + expect(descriptorInjected.privilegedProduct).toBeUndefined(); + expect(rootBottleFetches).toBe(0); + await page.evaluate(() => window.__destroyPackageLayerAcceptance()); + const unreviewed = await page.evaluate( + ({ baseVfsUrl, descriptor, privilegedProjections }) => + window.__bootPackageLayerAcceptance({ + baseVfsUrl, + descriptor, + privilegedProjections, + } as Parameters[0] & { + privilegedProjections: unknown; + }), + { + baseVfsUrl: fixture.urls.base, + descriptor: fixture.descriptor, + privilegedProjections: projections, + }, + ); + expect(unreviewed.privilegedProduct).toBeUndefined(); + expect(rootBottleFetches).toBe(0); + await page.evaluate(() => window.__destroyPackageLayerAcceptance()); + + const boot = await page.evaluate( + ({ baseVfsUrl, descriptor }) => + window.__bootPackageLayerAcceptance({ + baseVfsUrl, + descriptor, + reviewedProductProfile: "package-layer-acceptance-v1", + }), + { + baseVfsUrl: fixture.urls.base, + descriptor: fixture.descriptor, + }, + ); + + expect(rootBottleFetches).toBe(1); + expect(dependencyBottleFetches).toBe(0); + expect(boot.privilegedProduct).toMatchObject({ + uniqueIdentityCount: 3, + readonly: true, + trusted: true, + ordinaryBottleWritable: true, + ordinaryMountNosuid: true, + }); + expect(boot.privilegedProduct?.stats.map((stat) => ({ + path: stat.path, + kind: stat.mode & 0o170000, + mode: stat.mode & 0o7777, + uid: stat.uid, + gid: stat.gid, + nlink: stat.nlink, + }))).toEqual([ + "/usr/bin/login", + "/usr/bin/sudo-lite", + "/usr/bin/sudo", + ].map((path) => ({ + path, + kind: 0o100000, + mode: 0o4755, + uid: 0, + gid: 0, + nlink: 1, + }))); + } finally { + await page.evaluate(() => window.__destroyPackageLayerAcceptance()); + } +}); + test("browser discards each private package-layer stage after repeated boot-prefetch failures", async ({ page, baseURL, diff --git a/docs/architecture.md b/docs/architecture.md index 95e4835892..b8b8486839 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1625,11 +1625,22 @@ operations require a lifecycle-owned backing, not merely a reachable one. | `/srv` | scratch | empty `MemoryFileSystem` SAB | `HostFileSystem` under sessionDir | Every mount in the default layout is `nosuid` on both hosts, including the -advisory-read-only root image. A future reviewed product projection must use a -separate privately branded immutable product backend and request -`trusted-root-product` explicitly; ordinary image, scratch, host, OPFS, -device, and user-provided backends cannot acquire that capability from public -fields, prototypes, or configuration. +advisory-read-only root image. Reviewed privileged-program policy can copy an +authenticated regular bottle member into a fresh root-owned inode on a +separate privately branded immutable product backend. Admission covers the +complete three-program group, rejects links and writable aliases, and compares +each `(dev, ino, generation)` identity with every inode in the writable bottle +tree before publication. The candidate is created on a fresh private +`SharedArrayBuffer`, and its one-shot construction proof is consumed before +admission; no second wrapper over writable backing can enter the production +path even though `structuredClone()` can create distinct wrappers for one +shared data block. The record value `trusted-root-product` names that policy; +it is not authority. Product review authority is a non-serializable opaque +capability minted only at internal product/build boundaries. Mount authority +still comes only from the private backend brand and resolved read-only mount +capability. Ordinary image, +scratch, host, OPFS, device, and user-provided backends cannot acquire that +capability from public fields, prototypes, or configuration. The browser host layers two additional, host-specific mounts on top: `/dev/shm` (the POSIX-semaphore SAB shared with main-thread surfaces) and `/dev` (`DeviceFileSystem` for `/dev/null`, `/dev/zero`, `/dev/urandom`, `/dev/ptmx`, `/dev/pts/N`). Sticky bits, the uid 1000 owner on `/home/user`, mode `0700` on `/root`, etc. are baked into the rootfs image at build time per the canonical `MANIFEST` and reflected honestly through the `MemoryFileSystem` inode metadata. Scratch mounts on Node start owned by uid/gid 0 because `HostFileSystem` synthesises them. diff --git a/docs/browser-support.md b/docs/browser-support.md index d24dfb7568..46108c0632 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -794,6 +794,17 @@ waits abortable, and rethrows its arbitrary `reason` unchanged before mirror fallback or VFS commit. Standard `AbortError`/`ABORT_ERR` failures remain the compatibility fallback when no signal is registered. Other 4xx responses and size, digest, or decode failures do not consume the same-URL retry budget. + +A public browser consumer cannot supply privileged projections through compose +options, boot requests, descriptors, or shared URLs. Product-owned browser code +may select a compiled reviewed profile whose opaque, non-serializable +capability never enters those input records. Its projections are rebound to +the descriptor's exact Formula owner, bottle digest, and complete source +inventory. The owning bottle is materialized within the private composition +transaction; unrelated bottles stay lazy. Chromium, Firefox, and WebKit use +the same copy-and-admit path as Node and receive a separate immutable product +backend, while the composed Homebrew filesystem remains the writable `nosuid` +tree. There is no per-file or byte-range retrieval inside the gzip/TAR. A failed fetch, digest, decode, inventory check, or allocation leaves every regular inode pending and diff --git a/docs/homebrew-publishing.md b/docs/homebrew-publishing.md index a51d80b954..5e4c3d1992 100644 --- a/docs/homebrew-publishing.md +++ b/docs/homebrew-publishing.md @@ -3604,6 +3604,25 @@ when its fallback fields are complete. Maintenance exposes only `rebuild` and Homebrew-derived VFS images are built from sidecars and verified bottle bytes, not from Formula Ruby. +An image build may also consume one closed privileged-program projection +policy. At this trusted build-operator boundary, the CLI parses the JSON policy +into a non-serializable opaque capability and associates it privately with the +in-memory plan. Public planner, builder, runtime, and browser option records +cannot mint or transport that authority. The association binds every record to +a Formula in the selected closure and its exact bottle digest. After the +ordinary bottle tree is materialized, +the builder resolves source hard links only through the complete authenticated +archive inventory, hashes the resulting regular bytes, and copies them into +fresh root-owned `04755` inodes in a separate product tree. The ordinary +Homebrew prefix remains writable and `nosuid`; the product tree is published +only as a read-only privately branded backend after the complete projection +group passes alias, ownership, parent-directory, digest, and +generation-qualified collision checks. `--privileged-projections` and +`--privileged-product-out` emit that separate tree and its artifact identity. +The emitted JSON and image are data, not reusable live mount authority. The +flags do not grant process credentials or make the policy string mount +authority. + The guest Homebrew bootstrap image is a separate diagnostic and integration artifact. Build it from the pinned upstream Homebrew revision, Kandelo's reviewed platform patch, and ABI-current Kandelo package artifacts with: diff --git a/docs/posix-status.md b/docs/posix-status.md index be6e36518e..17c00dad86 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -595,7 +595,7 @@ Systematic audit of all subsystems against POSIX specifications. Gaps are catego | **No immediate cross-process shared-memory or futex semantics** | memory | Anonymous, SysV, and stable-identity regular-file shared mappings now merge and refresh across processes at syscall boundaries. They are not one physical linear memory: direct stores remain private until a syscall, a peer spinning only on loads sees no update, and futex WAIT/WAKE targets only the caller's process `SharedArrayBuffer`. Process-shared pthread locks and PHP opcache's normal shared-memory locking model therefore remain unsupported. The PHP package rejects its normal SHM mode and supports only explicitly configured `opcache.file_cache_only=1`; otherwise FPM workers would observe divergent cache and lock state. memfd `MAP_SHARED`, Linux `SIGBUS` on access beyond EOF, and detection of external host writes also remain gaps. | | **External raw UDP routes** | socket | AF_INET SOCK_DGRAM has POSIX-style in-kernel loopback/virtual semantics, but browsers cannot expose raw UDP and Node raw UDP is not yet wired behind HostIO. Non-loopback UDP routes currently return ENETUNREACH unless a future host backend/proxy handles them. | | **Stop is cooperative at a Wasm boundary** | process / signals | The kernel records stopped state immediately and the shared host withholds every exact channel completion until SIGCONT. This suspends code at syscall boundaries in both Node and browser, but a process executing CPU-bound Wasm without reaching a syscall cannot be stopped at an arbitrary instruction by current WebAssembly execution APIs. | -| **Set-ID exec credential commit** | process | Mount admission defaults to `nosuid`, and the kernel proposal helper ignores set-ID bits there. The one internal trusted mount can preserve those bits as a proposal, but target-aware exec validation and atomic credential commit remain future work. | +| **Set-ID exec credential commit** | process | Mount admission defaults to `nosuid`, and the kernel proposal helper ignores set-ID bits there. The privileged-program publisher can copy reviewed bottle members into fresh root-owned `04755` inodes on the one internal trusted mount, but target-aware exec validation and atomic credential commit remain future work. | | **Permission checks** | filesystem | Delegated to host. Kernel does not independently verify file permissions. | | **getrusage() zeroed** | sysinfo | No actual resource tracking available in Wasm. Returns zero-filled struct. | | **ucontext API unsupported** | process | `makecontext()`, `swapcontext()`, `getcontext()`, `setcontext()` are userspace stack-switching primitives. Supporting them would require `wasm-fork-instrument`-style compile-time instrumentation extended to general stack-switching for every program that uses them — we already do this narrowly for `fork()` (see [fork-instrumentation.md](fork-instrumentation.md) and `plans/2026-04-20-fork-instrumentation-design.md`), but generalising the same machinery to ucontext multiplies the instrumentation surface for a feature **deprecated in POSIX.1-2008** and effectively unused in modern code. Programs needing coroutines implement their own at the runtime level (Erlang/BEAM, Ruby fibers, Python `greenlet`). | diff --git a/host/src/homebrew-runtime-layer-consumer.ts b/host/src/homebrew-runtime-layer-consumer.ts index 62529e9414..64c72e602f 100644 --- a/host/src/homebrew-runtime-layer-consumer.ts +++ b/host/src/homebrew-runtime-layer-consumer.ts @@ -16,6 +16,13 @@ import { type LazyTreeGroup, type LazyTreeRegistrationEntry, } from "./vfs/memory-fs"; +import { + publishPrivilegedProgramProduct, + readReviewedPrivilegedProgramPolicy, + type PrivilegedProgramSource, + type PublishedPrivilegedProgramProduct, + type ReviewedPrivilegedProgramPolicy, +} from "./vfs/privileged-projection"; import { adaptHomebrewDeferredTree, type AdaptedHomebrewDeferredTree, @@ -83,6 +90,7 @@ export interface RegisteredHomebrewRuntimeLayer { export interface ComposedHomebrewRuntimeLayers { fs: MemoryFileSystem; layers: RegisteredHomebrewRuntimeLayer[]; + privilegedProduct?: PublishedPrivilegedProgramProduct; } interface RegisterHomebrewRuntimeLayersOptions extends ComposeHomebrewRuntimeLayersOptions { @@ -170,6 +178,21 @@ export interface HomebrewOriginalBottleTreeDescriptorV1 { */ export async function composeHomebrewRuntimeLayers( options: ComposeHomebrewRuntimeLayersOptions, +): Promise { + return composeHomebrewRuntimeLayersInternal(options); +} + +/** Direct internal adapter; intentionally absent from public host barrels. */ +export async function composeHomebrewRuntimeLayersWithReviewedProduct( + options: ComposeHomebrewRuntimeLayersOptions, + policy: ReviewedPrivilegedProgramPolicy, +): Promise { + return composeHomebrewRuntimeLayersInternal(options, policy); +} + +async function composeHomebrewRuntimeLayersInternal( + options: ComposeHomebrewRuntimeLayersOptions, + policy?: ReviewedPrivilegedProgramPolicy, ): Promise { const fs = options.maxByteLength === undefined ? MemoryFileSystem.fromImagePreservingCapacity(options.baseImageBytes) @@ -184,7 +207,18 @@ export async function composeHomebrewRuntimeLayers( const layers = options.layers.length === 0 ? [] : await registerHomebrewRuntimeLayersOnStagedFileSystem({ ...options, fs }); - return { fs, layers }; + const privilegedProduct = policy === undefined + ? undefined + : await publishRuntimeLayerPrivilegedPrograms( + fs, + layers, + policy, + ); + return { + fs, + layers, + ...(privilegedProduct === undefined ? {} : { privilegedProduct }), + }; } catch (error) { // This filesystem was deliberately private until the composition // transaction completed, so a rejection gives the caller no other way to @@ -199,6 +233,95 @@ export async function composeHomebrewRuntimeLayers( } } +async function publishRuntimeLayerPrivilegedPrograms( + fs: MemoryFileSystem, + layers: readonly RegisteredHomebrewRuntimeLayer[], + policy: ReviewedPrivilegedProgramPolicy, +): Promise { + const projections = readReviewedPrivilegedProgramPolicy(policy); + const sources = runtimeLayerPrivilegedSources(fs, layers); + const sourcesByFormula = new Map(sources.map((source) => [source.formula, source])); + for (const projection of projections) { + const source = sourcesByFormula.get(projection.formula); + if (source === undefined) { + throw new Error( + `privileged projection formula ${projection.formula} has no authenticated runtime tree`, + ); + } + if (source.bottleSha256 !== projection.bottleSha256) { + throw new Error( + `privileged projection bottle digest mismatch for ${projection.formula}`, + ); + } + // Materialization remains part of the private composition transaction. + // No product backend is returned until every source and destination passes. + await fs.preparePath(source.guestPathForSource(projection.sourcePath)); + } + return publishPrivilegedProgramProduct({ + policy, + sources, + writableBottleFileSystems: [fs], + }); +} + +function runtimeLayerPrivilegedSources( + fs: MemoryFileSystem, + layers: readonly RegisteredHomebrewRuntimeLayer[], +): PrivilegedProgramSource[] { + const sources: PrivilegedProgramSource[] = []; + for (const layer of layers) { + const packagesByFormula = new Map( + layer.descriptor.packages.layer.map((pkg) => [pkg.full_name, pkg]), + ); + for (const tree of layer.descriptor.deferred_trees) { + if (tree.package === undefined || tree.inventory.source === undefined) continue; + const pkg = packagesByFormula.get(tree.package); + if (pkg === undefined) { + throw new Error( + `privileged runtime tree ${tree.id} has no exact Formula owner`, + ); + } + if (pkg.sha256 !== tree.content.sha256) { + throw new Error( + `privileged runtime tree ${tree.id} differs from its bottle digest`, + ); + } + const guestPathsBySource = new Map(); + for (const entry of tree.inventory.entries) { + if (entry.type !== "file" && entry.type !== "hardlink") continue; + const guestPath = `/${entry.path}`; + const prior = guestPathsBySource.get(entry.source_path); + if (prior === undefined || guestPath < prior) { + guestPathsBySource.set(entry.source_path, guestPath); + } + } + sources.push({ + formula: tree.package, + bottleSha256: tree.content.sha256, + fs, + inventory: { + entries: tree.inventory.source.entries.map((entry) => ({ + sourcePath: entry.path, + type: entry.type, + size: entry.size, + ...(entry.target === undefined ? {} : { target: entry.target }), + })), + }, + guestPathForSource(sourcePath) { + const path = guestPathsBySource.get(sourcePath); + if (path === undefined) { + throw new Error( + `privileged source ${sourcePath} is absent from runtime tree ${tree.id}`, + ); + } + return path; + }, + }); + } + } + return sources; +} + /** * Register one producer-verified tree collection on an exclusive composition * filesystem. All paths and aggregate resource limits are preflighted before diff --git a/host/src/homebrew-vfs-builder.ts b/host/src/homebrew-vfs-builder.ts index 3578913204..cf581b8475 100644 --- a/host/src/homebrew-vfs-builder.ts +++ b/host/src/homebrew-vfs-builder.ts @@ -13,6 +13,7 @@ import type { HomebrewVfsPackagePlan, HomebrewVfsPlan, } from "./homebrew-vfs-planner"; +import { reviewedPrivilegedProgramPolicyForPlan } from "./homebrew-vfs-planner"; import { applyHomebrewCanonicalOptLinks as applyMaterializedOptLinks, applyHomebrewCanonicalOptLink as applyMaterializedOptLink, @@ -49,6 +50,12 @@ import { writeVfsFile, } from "./vfs/image-helpers"; import { KANDELO_HOMEBREW_GUEST_LAYOUT } from "./homebrew-guest-layout"; +import { + publishPrivilegedProgramProduct, + readReviewedPrivilegedProgramPolicy, + type PrivilegedProgramSource, + type PublishedPrivilegedProgramProduct, +} from "./vfs/privileged-projection"; const DEFAULT_IMAGE_BYTES = 128 * 1024 * 1024; const S_IFMT = 0xf000; @@ -243,6 +250,10 @@ export interface HomebrewVfsBuildReport { compatibility_links?: HomebrewVfsCompatibilityLinkReport[]; link_conflicts?: HomebrewVfsLinkConflictReport[]; runtime_state?: HomebrewVfsRuntimeStateReport[]; + privileged_programs?: { + projections: PrivilegedProgramProjectionReport[]; + evidence: PrivilegedProgramProjectionEvidenceReport[]; + }; materialization?: { policy: "kandelo-homebrew-vfs-materialization-policy"; embedded_package_order: string[]; @@ -283,6 +294,28 @@ export interface HomebrewVfsBuildReport { export interface HomebrewVfsBuildResult { fs: MemoryFileSystem; report: HomebrewVfsBuildReport; + privilegedProduct?: PublishedPrivilegedProgramProduct; +} + +export interface PrivilegedProgramProjectionReport { + schema: 1; + formula: string; + bottle_sha256: string; + source_path: string; + destination_path: string; + uid: 0; + gid: 0; + mode: number; + mount_point: string; + artifact_validation_sha256: string; +} + +export interface PrivilegedProgramProjectionEvidenceReport { + source_path: string; + canonical_source_path: string; + destination_path: string; + independent_inode: true; + collides_with_writable_bottle: false; } export interface HomebrewFlatVfsBuildOptions { @@ -758,6 +791,14 @@ export async function buildHomebrewVfs( ensureDirRecursive(fs, "/etc/kandelo"); const materializationInputs: HomebrewBottleMaterializationPackage[] = []; + const privilegedSources: PrivilegedProgramSource[] = []; + const privilegedPolicy = reviewedPrivilegedProgramPolicyForPlan(plan); + const privilegedProjections = privilegedPolicy === undefined + ? undefined + : readReviewedPrivilegedProgramPolicy(privilegedPolicy); + const privilegedFormulae = new Set( + privilegedProjections?.map((projection) => projection.formula) ?? [], + ); for (const pkg of plan.packages) { const bottleBytes = await options.loadBottleBytes(pkg); @@ -767,6 +808,32 @@ export async function buildHomebrewVfs( receiptSource: "staged", })); const staged = runMaterializer(() => stagePreparedHomebrewKeg(fs, prepared)); + if (privilegedFormulae.has(pkg.fullName)) { + privilegedSources.push({ + formula: pkg.fullName, + bottleSha256: pkg.sha256, + fs, + inventory: { + entries: prepared.entries.map((entry) => ({ + sourcePath: entry.path, + type: entry.type, + size: entry.type === "file" ? entry.data.byteLength : 0, + ...(entry.type === "symlink" || entry.type === "hardlink" + ? { target: entry.linkName } + : {}), + })), + }, + guestPathForSource(sourcePath) { + const path = mapMaterializedBottleEntry(input, sourcePath); + if (path === null) { + throw new HomebrewVfsBuildError( + `privileged source ${sourcePath} maps to the bottle payload root`, + ); + } + return path; + }, + }); + } runMaterializer(() => releasePreparedHomebrewKegEntries(prepared)); prepared = runMaterializer(() => prepareStagedHomebrewKegReceipts(fs, prepared)); runMaterializer(() => relocatePreparedHomebrewKeg(fs, prepared)); @@ -825,6 +892,13 @@ export async function buildHomebrewVfs( runtimeStateDeclarations, ) : { compatibilityLinks: undefined, runtimeState: [] }; + const privilegedProduct = privilegedPolicy === undefined + ? undefined + : await publishPrivilegedProgramProduct({ + policy: privilegedPolicy, + sources: privilegedSources, + writableBottleFileSystems: [fs], + }); const report: HomebrewVfsBuildReport = { schema: 1, @@ -837,6 +911,33 @@ export async function buildHomebrewVfs( link_conflicts: linkResolution.reports, }), ...(runtimeState.length === 0 ? {} : { runtime_state: runtimeState }), + ...(privilegedProduct === undefined + ? {} + : { + privileged_programs: { + projections: privilegedProduct.projections.map((projection) => ({ + schema: projection.schema, + formula: projection.formula, + bottle_sha256: projection.bottleSha256, + source_path: projection.sourcePath, + destination_path: projection.destinationPath, + uid: projection.uid, + gid: projection.gid, + mode: projection.mode, + mount_point: projection.mountPoint, + artifact_validation_sha256: + projection.artifactValidationSha256, + })), + evidence: privilegedProduct.evidence.map((entry) => ({ + source_path: entry.sourcePath, + canonical_source_path: entry.canonicalSourcePath, + destination_path: entry.destinationPath, + independent_inode: true, + collides_with_writable_bottle: + entry.collidesWithWritableBottle, + })), + }, + }), ...(migrationLock === undefined ? {} : { migration_lock: migrationLock }), metadata: { tap_repository: plan.tapRepository, @@ -857,7 +958,11 @@ export async function buildHomebrewVfs( options.createdBy ?? "host/src/homebrew-vfs-builder.ts", ); - return { fs, report }; + return { + fs, + report, + ...(privilegedProduct === undefined ? {} : { privilegedProduct }), + }; } function applyHomebrewVfsConsumerStateWithResolution( diff --git a/host/src/homebrew-vfs-planner.ts b/host/src/homebrew-vfs-planner.ts index 138dd65e8b..99ce2e90ec 100644 --- a/host/src/homebrew-vfs-planner.ts +++ b/host/src/homebrew-vfs-planner.ts @@ -8,6 +8,13 @@ import { import type { HomebrewBottleDescriptor } from "./homebrew-bottle-descriptor"; import type { HomebrewBottleArch, HomebrewLinkEntry } from "./homebrew-bottle-types"; import type { HomebrewVfsResourcePolicyId } from "./homebrew-vfs-resource-policy"; +import { + readReviewedPrivilegedProgramPolicy, + type ReviewedPrivilegedProgramPolicy, +} from "./vfs/privileged-projection"; + +const reviewedProductPoliciesByPlan = + new WeakMap(); export type { HomebrewBottleArch, HomebrewLinkEntry } from "./homebrew-bottle-types"; export type HomebrewRuntime = "node" | "browser"; @@ -298,7 +305,6 @@ export async function planHomebrewVfs( expectedAbi, })); } - return { schema: 1, tapRepository: metadata.tap_repository, @@ -382,7 +388,6 @@ export async function planFederatedHomebrewVfs( loadLinkManifest: (path) => options.loadLinkManifest(tap, path), })); } - return { schema: 1, tapRepository: rootMetadata.tap_repository, @@ -401,6 +406,52 @@ export async function planFederatedHomebrewVfs( }; } +/** + * Associate product-owned review authority with one in-memory plan. The + * association is deliberately absent from public host/browser barrels and is + * not serialized into the caller-visible plan record. + */ +export function attachReviewedPrivilegedProgramPolicy( + plan: HomebrewVfsPlan, + policy: ReviewedPrivilegedProgramPolicy, +): HomebrewVfsPlan { + validateReviewedPrivilegedProgramPolicy(plan, policy); + reviewedProductPoliciesByPlan.set(plan, policy); + return plan; +} + +/** Resolve an internal plan association while rechecking mutable plan input. */ +export function reviewedPrivilegedProgramPolicyForPlan( + plan: HomebrewVfsPlan, +): ReviewedPrivilegedProgramPolicy | undefined { + const policy = reviewedProductPoliciesByPlan.get(plan); + if (policy !== undefined) validateReviewedPrivilegedProgramPolicy(plan, policy); + return policy; +} + +function validateReviewedPrivilegedProgramPolicy( + plan: HomebrewVfsPlan, + policy: ReviewedPrivilegedProgramPolicy, +): void { + const projections = readReviewedPrivilegedProgramPolicy(policy); + const packages = plan.packages; + const packagesByFormula = new Map(packages.map((pkg) => [pkg.fullName, pkg])); + for (const projection of projections) { + const pkg = packagesByFormula.get(projection.formula); + if (pkg === undefined) { + fail( + `privileged projection formula ${quote(projection.formula)} is not in the selected closure`, + ); + } + if (pkg.sha256 !== projection.bottleSha256) { + fail( + `privileged projection bottle digest for ${quote(projection.formula)} ` + + "does not match the selected bottle", + ); + } + } +} + function parseTapMetadata(value: unknown): HomebrewTapMetadata { const metadata = requireRecord(value, "metadata"); const schema = requiredInteger(metadata, "schema", "metadata"); diff --git a/host/src/vfs/memory-fs.ts b/host/src/vfs/memory-fs.ts index d3a859e0c7..759eff9755 100644 --- a/host/src/vfs/memory-fs.ts +++ b/host/src/vfs/memory-fs.ts @@ -71,14 +71,19 @@ const IntrinsicUint8Array = Uint8Array; const intrinsicUint8ArraySet = Uint8Array.prototype.set; const intrinsicWeakSetAdd = WeakSet.prototype.add; const intrinsicWeakSetHas = WeakSet.prototype.has; +const intrinsicWeakMapGet = WeakMap.prototype.get; +const intrinsicWeakMapSet = WeakMap.prototype.set; const intrinsicSetHas = Set.prototype.has; const intrinsicMapGet = Map.prototype.get; const IntrinsicNumber = Number; const intrinsicNumberIsInteger = Number.isInteger; const IntrinsicTypeError = TypeError; const intrinsicSharedFsMount = SharedFS.mount; +const intrinsicSharedFsMkfs = SharedFS.mkfs; const intrinsicSharedFsSnapshotState = SharedFS.prototype.snapshotState; const memoryFileSystemInstances = new WeakSet(); +const memoryFileSystemDeviceIds = new WeakMap(); +let nextMemoryFileSystemDeviceId = 1; const immutableProductBackends = new WeakSet(); function capturePrivatePrototype(prototype: object): object { @@ -3223,6 +3228,29 @@ export class MemoryFileSystem implements FileSystemBackend { return new MemoryFileSystem(fs, cloneMetadata(this.imageMetadata)); } + /** Capture one exact backend-qualified inode identity. */ + private qualifiedInodeIdentity(path: string): MemoryFileSystemInodeIdentity { + const stat = this.fs.lstat(path); + let dev = intrinsicApply( + intrinsicWeakMapGet, + memoryFileSystemDeviceIds, + [this.fs.buffer], + ) as number | undefined; + if (dev === undefined) { + dev = nextMemoryFileSystemDeviceId++; + intrinsicApply( + intrinsicWeakMapSet, + memoryFileSystemDeviceIds, + [this.fs.buffer, dev], + ); + } + return { + dev, + ino: stat.ino, + generation: stat.generation, + }; + } + private static canAdoptLegacyLazyStub(st: SfsStatResult): boolean { // Images from before data-sequence tracking stored regular lazy entries as // untouched zero-length stubs. Current registration performs one initial @@ -4124,6 +4152,26 @@ export class MemoryFileSystem implements FileSystemBackend { return new MemoryFileSystem(SharedFS.mkfs(sab, maxSizeBytes)); } + /** Construct a fresh MemoryFS without accepting caller-owned backing. */ + static createFresh(byteLength: number): MemoryFileSystem { + if ( + typeof byteLength !== "number" || + !intrinsicNumberIsInteger(byteLength) || + byteLength <= 0 + ) { + throw new IntrinsicTypeError( + "fresh MemoryFileSystem byte length must be a positive integer", + ); + } + const buffer = new IntrinsicSharedArrayBuffer(byteLength); + const fs = intrinsicApply( + intrinsicSharedFsMkfs, + SharedFS, + [buffer], + ) as SharedFS; + return new MemoryFileSystem(fs); + } + static fromExisting(sab: SharedArrayBuffer): MemoryFileSystem { return new MemoryFileSystem(SharedFS.mount(sab)); } @@ -7862,6 +7910,61 @@ export class MemoryFileSystem implements FileSystemBackend { } } +const intrinsicCreateFreshMemoryFileSystem = MemoryFileSystem.createFresh; + +/** Invoke the captured fresh-backing constructor. */ +export function createFreshMemoryFileSystem( + byteLength: number, +): MemoryFileSystem { + return intrinsicApply( + intrinsicCreateFreshMemoryFileSystem, + MemoryFileSystem, + [byteLength], + ) as MemoryFileSystem; +} + +export interface MemoryFileSystemInodeIdentity { + dev: number; + ino: number; + generation: number; +} + +interface MemoryFileSystemIdentitySource { + qualifiedInodeIdentity(path: string): MemoryFileSystemInodeIdentity; +} + +const intrinsicQualifiedInodeIdentity = ( + MemoryFileSystem.prototype as unknown as MemoryFileSystemIdentitySource +).qualifiedInodeIdentity; + +/** + * Capture one wrapper-qualified MemoryFS inode identity. JavaScript exposes no + * primitive for comparing distinct SharedArrayBuffer wrappers' backing data, + * so cross-wrapper security also requires fresh private construction. + * + * This read-only, single-path helper is deliberately absent from the public + * VFS entry point. It cannot mint the immutable-product brand. + */ +export function captureMemoryFileSystemInodeIdentity( + source: MemoryFileSystem, + path: string, +): MemoryFileSystemInodeIdentity { + if ( + !intrinsicApply( + intrinsicWeakSetHas, + memoryFileSystemInstances, + [source], + ) + ) { + throw new Error("inode identity source must be a genuine MemoryFileSystem"); + } + return intrinsicApply( + intrinsicQualifiedInodeIdentity, + source, + [path], + ) as MemoryFileSystemInodeIdentity; +} + const immutableProductMemoryFileSystemPrototype = capturePrivatePrototype( MemoryFileSystem.prototype, ); diff --git a/host/src/vfs/privileged-projection.ts b/host/src/vfs/privileged-projection.ts new file mode 100644 index 0000000000..86c490621c --- /dev/null +++ b/host/src/vfs/privileged-projection.ts @@ -0,0 +1,762 @@ +import { OPEN_FLAGS } from "../generated/abi"; +import type { FileSystemBackend, MountConfig } from "./types"; +import { ensureDirRecursive, writeVfsBinary } from "./image-helpers"; +import { + captureMemoryFileSystemInodeIdentity, + createFreshMemoryFileSystem, + createImmutableProductBackend, + MemoryFileSystem, + resolveMountSetIdCapability, + type MemoryFileSystemInodeIdentity, +} from "./memory-fs"; + +const S_IFMT = 0o170000; +const S_IFREG = 0o100000; +const S_IFDIR = 0o040000; +const MODE_BITS = 0o7777; +const SHA256_RE = /^[0-9a-f]{64}$/; +const PRODUCT_DESTINATIONS = [ + "/usr/bin/login", + "/usr/bin/sudo-lite", + "/usr/bin/sudo", +] as const; +const PRODUCT_DESTINATION_SET = new Set(PRODUCT_DESTINATIONS); +const PROJECTION_KEYS = [ + "schema", + "formula", + "bottleSha256", + "sourcePath", + "destinationPath", + "uid", + "gid", + "mode", + "mountPoint", + "artifactValidationSha256", +] as const; +const PROJECTION_KEY_SET = new Set(PROJECTION_KEYS); +const MIN_PRODUCT_CAPACITY = 4 * 1024 * 1024; +const MAX_PROGRAM_BYTES = 64 * 1024 * 1024; +const MAX_PRODUCT_BYTES = 128 * 1024 * 1024; +const intrinsicHasOwnProperty = Object.prototype.hasOwnProperty; +const reviewedPolicies = new WeakMap(); +const privatelyStagedCandidates = new WeakSet(); + +export interface PrivilegedProgramProjection { + schema: 1; + formula: string; + bottleSha256: string; + sourcePath: string; + destinationPath: string; + uid: 0; + gid: 0; + mode: number; + mountPoint: string; + artifactValidationSha256: string; +} + +/** Opaque authority for one product-owned, reviewed projection policy. */ +export interface ReviewedPrivilegedProgramPolicy { + readonly kind: "kandelo-reviewed-privileged-program-policy"; +} + +export interface PrivilegedProgramSourceInventoryEntry { + sourcePath: string; + type: "directory" | "file" | "symlink" | "hardlink"; + size: number; + target?: string; +} + +export interface PrivilegedProgramSource { + formula: string; + bottleSha256: string; + fs: MemoryFileSystem; + inventory: { + entries: readonly PrivilegedProgramSourceInventoryEntry[]; + }; + guestPathForSource(sourcePath: string): string; +} + +export interface PrivilegedProgramPublicationEvidence { + sourcePath: string; + canonicalSourcePath: string; + destinationPath: string; + sourceIdentity: MemoryFileSystemInodeIdentity; + destinationIdentity: MemoryFileSystemInodeIdentity; + collidesWithWritableBottle: false; +} + +export interface PublishedPrivilegedProgramProduct { + projections: PrivilegedProgramProjection[]; + evidence: PrivilegedProgramPublicationEvidence[]; + mount: MountConfig; + /** Serialized independent tree for build-time artifact publication. */ + imageBytes: Uint8Array; +} + +export interface PublishPrivilegedProgramProductOptions { + policy: ReviewedPrivilegedProgramPolicy; + sources: readonly PrivilegedProgramSource[]; + writableBottleFileSystems: readonly MemoryFileSystem[]; +} + +export interface ValidatePrivilegedProgramProductCandidateOptions + extends PublishPrivilegedProgramProductOptions { + candidateFs: MemoryFileSystem; +} + +interface AuthenticatedProgramSource { + projection: PrivilegedProgramProjection; + source: PrivilegedProgramSource; + canonicalSourcePath: string; + guestPath: string; + bytes: Uint8Array; + identity: MemoryFileSystemInodeIdentity; +} + +/** Parse the complete, closed set of reviewed system-program projections. */ +export function parsePrivilegedProgramProjections( + value: unknown, +): PrivilegedProgramProjection[] { + if (!Array.isArray(value) || value.length !== PRODUCT_DESTINATIONS.length) { + throw new Error( + `privileged projection group must contain exactly ${PRODUCT_DESTINATIONS.length} entries`, + ); + } + + const destinations = new Set(); + const parsed = value.map((entry, index) => { + const record = exactRecord(entry, `privileged projection ${index}`); + const unknownKeys = Object.keys(record).filter((key) => + !PROJECTION_KEY_SET.has(key) + ); + const missingKeys = PROJECTION_KEYS.filter((key) => + !Reflect.apply(intrinsicHasOwnProperty, record, [key]) + ); + if (unknownKeys.length !== 0 || missingKeys.length !== 0) { + throw new Error( + `privileged projection ${index} must use the closed schema`, + ); + } + if (record.schema !== 1) { + throw new Error(`privileged projection ${index} has unsupported schema`); + } + const formula = requireNonemptyString(record.formula, "projection formula"); + const bottleSha256 = requireSha256( + record.bottleSha256, + "projection bottle digest", + ); + const sourcePath = requireCanonicalSourcePath( + record.sourcePath, + "projection source path", + ); + const destinationPath = requireNonemptyString( + record.destinationPath, + "projection destination path", + ); + if (!PRODUCT_DESTINATION_SET.has(destinationPath)) { + throw new Error( + `privileged projection destination is not reviewed: ${destinationPath}`, + ); + } + if (destinations.has(destinationPath)) { + throw new Error( + `privileged projection has duplicate destination ${destinationPath}`, + ); + } + destinations.add(destinationPath); + if (record.uid !== 0 || record.gid !== 0) { + throw new Error("privileged projection must be owned by uid 0 and gid 0"); + } + if (record.mode !== 0o4755) { + throw new Error("privileged projection mode must be 04755"); + } + if (record.mountPoint !== "trusted-root-product") { + throw new Error("privileged projection mount is not recognized"); + } + const artifactValidationSha256 = requireSha256( + record.artifactValidationSha256, + "projection artifact validation digest", + ); + return { + schema: 1, + formula, + bottleSha256, + sourcePath, + destinationPath, + uid: 0, + gid: 0, + mode: 0o4755, + mountPoint: "trusted-root-product", + artifactValidationSha256, + } satisfies PrivilegedProgramProjection; + }); + + for (const destination of PRODUCT_DESTINATIONS) { + if (!destinations.has(destination)) { + throw new Error(`privileged projection group is missing ${destination}`); + } + } + return parsed; +} + +/** + * Mint reviewed policy authority at a product-owned build/code boundary. + * This factory is deliberately absent from every public host/browser barrel. + */ +export function createReviewedPrivilegedProgramPolicy( + value: unknown, +): ReviewedPrivilegedProgramPolicy { + const projections = parsePrivilegedProgramProjections(value); + const policy = Object.freeze({ + kind: "kandelo-reviewed-privileged-program-policy" as const, + }); + reviewedPolicies.set(policy, projections); + return policy; +} + +/** Read a branded policy without accepting a structurally similar object. */ +export function readReviewedPrivilegedProgramPolicy( + policy: ReviewedPrivilegedProgramPolicy, +): PrivilegedProgramProjection[] { + const projections = reviewedPolicies.get(policy); + if (projections === undefined) { + throw new Error("privileged program policy lacks product review authority"); + } + return projections.map((projection) => ({ ...projection })); +} + +/** + * Copy all reviewed members into one unpublished tree, then admit the group. + * A failed member leaves no returned backend and never mutates a bottle tree. + */ +export async function publishPrivilegedProgramProduct( + options: PublishPrivilegedProgramProductOptions, +): Promise { + const projections = readReviewedPrivilegedProgramPolicy(options.policy); + const authenticated = await authenticateProgramSources( + projections, + options.sources, + ); + const productBytes = authenticated.reduce( + (sum, source) => sum + source.bytes.byteLength, + 0, + ); + if (productBytes > MAX_PRODUCT_BYTES) { + throw new Error("privileged projection group exceeds the product byte limit"); + } + const capacity = Math.max( + MIN_PRODUCT_CAPACITY, + productBytes + 2 * 1024 * 1024, + ); + const candidateFs = createFreshMemoryFileSystem(capacity); + privatelyStagedCandidates.add(candidateFs); + initializeProductTree(candidateFs); + + for (const entry of authenticated) { + writeVfsBinary( + candidateFs, + entry.projection.destinationPath, + entry.bytes, + 0o755, + ); + // chown clears set-ID bits by design, so ownership precedes final mode. + candidateFs.chown(entry.projection.destinationPath, 0, 0); + candidateFs.chmod(entry.projection.destinationPath, 0o4755); + } + + return publishAuthenticatedCandidate({ + ...options, + candidateFs, + projections, + authenticated, + }); +} + +/** + * Validate caller-owned candidate invariants without publishing authority. + * + * This path never stages the candidate, snapshots or brands a backend, + * resolves a mount capability, or returns a publication object. + */ +export async function validatePrivilegedProgramProductCandidate( + options: ValidatePrivilegedProgramProductCandidateOptions, +): Promise { + const projections = readReviewedPrivilegedProgramPolicy(options.policy); + const authenticated = await authenticateProgramSources( + projections, + options.sources, + ); + validateAuthenticatedCandidate({ + ...options, + projections, + authenticated, + }); +} + +interface AuthenticatedCandidateOptions { + candidateFs: MemoryFileSystem; + writableBottleFileSystems: readonly MemoryFileSystem[]; + projections: PrivilegedProgramProjection[]; + authenticated: AuthenticatedProgramSource[]; +} + +async function publishAuthenticatedCandidate( + options: AuthenticatedCandidateOptions, +): Promise { + consumePrivatelyStagedCandidate(options.candidateFs); + const evidence = validateAuthenticatedCandidate(options); + // Snapshot immediately after the synchronous identity checks. All later + // authentication reads come from Task 6's isolated immutable copy, so a + // retained MemoryFS wrapper cannot race digest validation and publication. + const backend = createImmutableProductBackend(options.candidateFs); + await validateImmutableProductBackend(backend, options.authenticated); + const imageBytes = await serializeImmutableProduct( + backend, + options.authenticated, + ); + // WHY: the projection record's mountPoint is a policy identity, not mount + // authority. Only Task 6's private backend brand plus this resolved mount + // capability can authorize the trusted product tree. + const mount: MountConfig = { + mountPoint: "/", + backend, + readonly: true, + setIdCapability: { + kind: "trusted-root-product", + guestWritable: false, + stableExecutableIdentity: true, + }, + }; + resolveMountSetIdCapability(mount); + return { + projections: options.projections.map((projection) => ({ ...projection })), + evidence, + mount, + imageBytes, + }; +} + +function validateAuthenticatedCandidate( + options: AuthenticatedCandidateOptions, +): PrivilegedProgramPublicationEvidence[] { + assertSecureProductParents(options.candidateFs); + const writableIdentities = collectTreeIdentityKeys( + options.writableBottleFileSystems, + ); + const destinationIdentities = new Set(); + const evidence: PrivilegedProgramPublicationEvidence[] = []; + + for (const authenticated of options.authenticated) { + const { projection } = authenticated; + const stat = options.candidateFs.lstat(projection.destinationPath); + if ((stat.mode & S_IFMT) !== S_IFREG) { + throw new Error( + `projected program must be a regular file: ${projection.destinationPath}`, + ); + } + if (stat.uid !== 0 || stat.gid !== 0) { + throw new Error( + `projected program must be root-owned: ${projection.destinationPath}`, + ); + } + if ((stat.mode & MODE_BITS) !== 0o4755) { + throw new Error( + `projected program has an unreviewed mode: ${projection.destinationPath}`, + ); + } + const destinationIdentity = captureMemoryFileSystemInodeIdentity( + options.candidateFs, + projection.destinationPath, + ); + const destinationKey = identityKey(destinationIdentity); + // WHY: JavaScript cannot compare the underlying shared data block of two + // distinct SharedArrayBuffer wrappers. The one-shot private-construction + // proof consumed by the private publisher is authoritative for + // cross-wrapper non-aliasing. Validation-only callers receive no mount + // authority; this exact tuple comparison remains required evidence and + // rejects every identity the runtime can directly equate. + if (writableIdentities.has(destinationKey)) { + throw new Error( + `projected program collides with a writable bottle inode: ${projection.destinationPath}`, + ); + } + if (stat.nlink !== 1) { + throw new Error( + `projected program must have one unique inode and no writable alias: ${projection.destinationPath}`, + ); + } + if (destinationIdentities.has(destinationKey)) { + throw new Error( + `projected programs must not preserve a hard link: ${projection.destinationPath}`, + ); + } + destinationIdentities.add(destinationKey); + evidence.push({ + sourcePath: projection.sourcePath, + canonicalSourcePath: authenticated.canonicalSourcePath, + destinationPath: projection.destinationPath, + sourceIdentity: authenticated.identity, + destinationIdentity, + collidesWithWritableBottle: false, + }); + } + + assertExactProductNamespace(options.candidateFs); + return evidence; +} + +function consumePrivatelyStagedCandidate(candidateFs: MemoryFileSystem): void { + if (!privatelyStagedCandidates.delete(candidateFs)) { + throw new Error( + "privileged product candidate was not privately staged by the publisher", + ); + } +} + +function initializeProductTree(fs: MemoryFileSystem): void { + ensureDirRecursive(fs, "/usr/bin", 0o755); + for (const path of ["/", "/usr", "/usr/bin"]) { + fs.chown(path, 0, 0); + fs.chmod(path, 0o755); + } +} + +async function validateImmutableProductBackend( + backend: FileSystemBackend, + authenticatedSources: readonly AuthenticatedProgramSource[], +): Promise { + assertSecureProductParents(backend); + assertExactProductNamespace(backend); + for (const { projection } of authenticatedSources) { + const stat = backend.lstat(projection.destinationPath); + if ( + (stat.mode & S_IFMT) !== S_IFREG || stat.uid !== 0 || stat.gid !== 0 || + (stat.mode & MODE_BITS) !== 0o4755 || stat.nlink !== 1 + ) { + throw new Error( + `immutable projected program metadata changed: ${projection.destinationPath}`, + ); + } + const actualDigest = await sha256Hex( + readRegularFile(backend, projection.destinationPath), + ); + if (actualDigest !== projection.artifactValidationSha256) { + throw new Error( + `projected artifact digest mismatch for ${projection.destinationPath}`, + ); + } + } +} + +async function serializeImmutableProduct( + backend: FileSystemBackend, + authenticatedSources: readonly AuthenticatedProgramSource[], +): Promise { + const productBytes = authenticatedSources.reduce( + (sum, source) => sum + source.bytes.byteLength, + 0, + ); + const artifactFs = MemoryFileSystem.create(new SharedArrayBuffer(Math.max( + MIN_PRODUCT_CAPACITY, + productBytes + 2 * 1024 * 1024, + ))); + initializeProductTree(artifactFs); + for (const { projection } of authenticatedSources) { + writeVfsBinary( + artifactFs, + projection.destinationPath, + readRegularFile(backend, projection.destinationPath), + 0o755, + ); + artifactFs.chown(projection.destinationPath, 0, 0); + artifactFs.chmod(projection.destinationPath, 0o4755); + } + return artifactFs.saveImage({ normalizeTimestampsMs: 0 }); +} + +async function authenticateProgramSources( + projections: readonly PrivilegedProgramProjection[], + sources: readonly PrivilegedProgramSource[], +): Promise { + const sourcesByFormula = new Map(); + for (const source of sources) { + const formula = requireNonemptyString(source.formula, "source formula"); + if (sourcesByFormula.has(formula)) { + throw new Error(`duplicate privileged source formula ${formula}`); + } + sourcesByFormula.set(formula, source); + } + + const authenticated: AuthenticatedProgramSource[] = []; + for (const projection of projections) { + const source = sourcesByFormula.get(projection.formula); + if (source === undefined) { + throw new Error(`privileged source is missing for ${projection.formula}`); + } + if (source.bottleSha256 !== projection.bottleSha256) { + throw new Error(`bottle digest mismatch for ${projection.formula}`); + } + const inventory = validateSourceInventory(source.inventory.entries); + const canonicalSourcePath = resolveCanonicalRegularSource( + projection.sourcePath, + inventory, + ); + const guestPath = source.guestPathForSource(canonicalSourcePath); + requireCanonicalGuestPath(guestPath, "privileged source guest path"); + const stat = source.fs.lstat(guestPath); + if ((stat.mode & S_IFMT) !== S_IFREG) { + throw new Error( + `canonical privileged source is not regular: ${canonicalSourcePath}`, + ); + } + const canonicalEntry = inventory.get(canonicalSourcePath)!; + if (stat.size !== canonicalEntry.size) { + throw new Error(`privileged source size mismatch for ${projection.formula}`); + } + if (stat.size > MAX_PROGRAM_BYTES) { + throw new Error(`privileged source exceeds byte limit for ${projection.formula}`); + } + const bytes = readRegularFile(source.fs, guestPath); + const actualDigest = await sha256Hex(bytes); + if (actualDigest !== projection.artifactValidationSha256) { + throw new Error(`artifact digest mismatch for ${projection.formula}`); + } + authenticated.push({ + projection, + source, + canonicalSourcePath, + guestPath, + bytes, + identity: captureMemoryFileSystemInodeIdentity(source.fs, guestPath), + }); + } + return authenticated; +} + +function validateSourceInventory( + entries: readonly PrivilegedProgramSourceInventoryEntry[], +): Map { + if (!Array.isArray(entries)) { + throw new Error("privileged source requires a complete inventory"); + } + const byPath = new Map(); + for (const [index, entry] of entries.entries()) { + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) { + throw new Error(`privileged source inventory entry ${index} is invalid`); + } + const sourcePath = requireCanonicalSourcePath( + entry.sourcePath, + `privileged source inventory entry ${index}`, + ); + if (byPath.has(sourcePath)) { + throw new Error(`privileged source inventory duplicates ${sourcePath}`); + } + if (!["directory", "file", "symlink", "hardlink"].includes(entry.type)) { + throw new Error(`privileged source inventory type is invalid at ${sourcePath}`); + } + if (!Number.isSafeInteger(entry.size) || entry.size < 0) { + throw new Error(`privileged source inventory size is invalid at ${sourcePath}`); + } + if (entry.type === "file" || entry.type === "directory") { + if (entry.target !== undefined) { + throw new Error(`privileged source inventory target is invalid at ${sourcePath}`); + } + } else { + requireCanonicalSourcePath( + entry.target, + `privileged source inventory target at ${sourcePath}`, + ); + } + byPath.set(sourcePath, entry); + } + return byPath; +} + +function resolveCanonicalRegularSource( + sourcePath: string, + inventory: ReadonlyMap, +): string { + let current = sourcePath; + const seen = new Set(); + while (true) { + const entry = inventory.get(current); + if (entry === undefined) { + throw new Error( + `privileged source is absent from the complete inventory: ${current}`, + ); + } + if (entry.type === "symlink") { + throw new Error(`privileged source must not be a symlink: ${current}`); + } + if (entry.type === "file") return current; + if (entry.type !== "hardlink") { + throw new Error(`privileged source must resolve to a regular file: ${current}`); + } + if (seen.has(current)) { + throw new Error(`privileged source hard-link cycle at ${current}`); + } + seen.add(current); + current = entry.target!; + } +} + +function assertSecureProductParents(fs: FileSystemBackend): void { + for (const path of ["/", "/usr", "/usr/bin"]) { + const stat = fs.lstat(path); + if ((stat.mode & S_IFMT) !== S_IFDIR || stat.uid !== 0 || stat.gid !== 0) { + throw new Error(`privileged product parent must be a root-owned directory: ${path}`); + } + if ((stat.mode & 0o022) !== 0) { + throw new Error(`privileged product parent must not be writable: ${path}`); + } + } +} + +function assertExactProductNamespace(fs: FileSystemBackend): void { + const expected = new Set([ + "/", + "/usr", + "/usr/bin", + ...PRODUCT_DESTINATIONS, + ]); + for (const path of walkFileSystem(fs)) { + if (!expected.delete(path)) { + throw new Error(`privileged product has a writable alias or extra path: ${path}`); + } + } + if (expected.size !== 0) { + throw new Error( + `privileged product is incomplete: ${Array.from(expected).join(", ")}`, + ); + } +} + +function collectTreeIdentityKeys( + fileSystems: readonly MemoryFileSystem[], +): Set { + const identities = new Set(); + for (const fs of fileSystems) { + for (const path of walkFileSystem(fs)) { + identities.add(identityKey(captureMemoryFileSystemInodeIdentity(fs, path))); + } + } + return identities; +} + +function walkFileSystem(fs: FileSystemBackend): string[] { + const paths: string[] = []; + const pending = ["/"]; + while (pending.length !== 0) { + const path = pending.pop()!; + paths.push(path); + if ((fs.lstat(path).mode & S_IFMT) !== S_IFDIR) continue; + const handle = fs.opendir(path); + try { + while (true) { + const entry = fs.readdir(handle); + if (entry === null) break; + if (entry.name === "." || entry.name === "..") continue; + const child = path === "/" ? `/${entry.name}` : `${path}/${entry.name}`; + pending.push(child); + } + } finally { + fs.closedir(handle); + } + } + return paths; +} + +function readRegularFile(fs: FileSystemBackend, path: string): Uint8Array { + const stat = fs.lstat(path); + if ((stat.mode & S_IFMT) !== S_IFREG || !Number.isSafeInteger(stat.size)) { + throw new Error(`cannot authenticate non-regular file ${path}`); + } + const bytes = new Uint8Array(stat.size); + const handle = fs.open(path, OPEN_FLAGS.O_RDONLY, 0); + try { + let offset = 0; + while (offset < bytes.byteLength) { + const read = fs.read( + handle, + bytes.subarray(offset), + null, + bytes.byteLength - offset, + ); + if (!Number.isInteger(read) || read <= 0) { + throw new Error(`unexpected EOF while authenticating ${path}`); + } + offset += read; + } + } finally { + fs.close(handle); + } + return bytes; +} + +async function sha256Hex(bytes: Uint8Array): Promise { + if (globalThis.crypto?.subtle === undefined) { + throw new Error("Web Crypto SHA-256 is unavailable"); + } + const copy = new Uint8Array(bytes); + const digest = await globalThis.crypto.subtle.digest("SHA-256", copy); + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0") + ).join(""); +} + +function identityKey(identity: MemoryFileSystemInodeIdentity): string { + return `${identity.dev}:${identity.ino}:${identity.generation}`; +} + +function exactRecord(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${label} must be a record`); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error(`${label} must be a plain record`); + } + return value as Record; +} + +function requireNonemptyString(value: unknown, label: string): string { + if (typeof value !== "string" || value.length === 0 || value.trim() !== value) { + throw new Error(`${label} must be a non-empty canonical string`); + } + return value; +} + +function requireSha256(value: unknown, label: string): string { + const digest = requireNonemptyString(value, label); + if (!SHA256_RE.test(digest)) { + throw new Error(`${label} must be a lowercase SHA-256 digest`); + } + return digest; +} + +function requireCanonicalSourcePath(value: unknown, label: string): string { + const path = requireNonemptyString(value, label); + const segments = path.split("/"); + if ( + path.startsWith("/") || path.includes("\\") || path.includes("\0") || + segments.some((segment) => segment === "" || segment === "." || segment === "..") + ) { + throw new Error(`${label} must be a canonical relative path`); + } + return path; +} + +function requireCanonicalGuestPath(value: unknown, label: string): string { + const path = requireNonemptyString(value, label); + if (!path.startsWith("/")) { + throw new Error(`${label} must be absolute`); + } + const segments = path.slice(1).split("/"); + if ( + path.includes("\\") || path.includes("\0") || + segments.some((segment) => segment === "" || segment === "." || segment === "..") + ) { + throw new Error(`${label} must be canonical`); + } + return path; +} diff --git a/host/test/homebrew-vfs-builder.test.ts b/host/test/homebrew-vfs-builder.test.ts index 62d6c25d37..129ce0ba5e 100644 --- a/host/test/homebrew-vfs-builder.test.ts +++ b/host/test/homebrew-vfs-builder.test.ts @@ -15,7 +15,9 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it, vi } from "vitest"; import { gzipSync, zipSync, type Zippable } from "fflate"; +import * as browserEntry from "../src/browser"; import { ABI_VERSION } from "../src/generated/abi"; +import * as nodeEntry from "../src/index"; import { applyHomebrewVfsConsumerState, buildHomebrewVfs, @@ -54,19 +56,24 @@ import { HOMEBREW_RUNTIME_LAYER_LIMITS, parseHomebrewRuntimeLayerDescriptor, composeHomebrewRuntimeLayers, + composeHomebrewRuntimeLayersWithReviewedProduct, type HomebrewRuntimeLayerReference, } from "../src/homebrew-runtime-layer-consumer"; import { + attachReviewedPrivilegedProgramPolicy, planFederatedHomebrewVfs, planHomebrewVfs, type HomebrewLinkManifest, type HomebrewTapMetadata, type HomebrewVfsPlan, } from "../src/homebrew-vfs-planner"; +import { createReviewedPrivilegedProgramPolicy } from + "../src/vfs/privileged-projection"; import type { HomebrewRuntimeSupportContract } from "../src/homebrew-runtime-support"; import { MemoryFileSystem, + resolveMountSetIdCapability, type LazyArchiveFileEntry, type LazyTreeGroup, } from "../src/vfs/memory-fs"; @@ -1311,6 +1318,103 @@ function makeLazyLayerPlanFederated(plan: HomebrewVfsPlan): void { } describe("Homebrew runtime layer consumer", () => { + it("does not mint a trusted product from public caller projection data", async () => { + const programs = { + login: utf8("public login\n"), + "sudo-lite": utf8("public sudo-lite\n"), + sudo: utf8("public sudo\n"), + }; + const fixture = await runtimeLayerConsumerFixture({ + runtimeExtraEntries: Object.entries(programs).map(([name, data]) => ({ + path: `runtime/3.0/bin/${name}`, + data, + mode: 0o755, + })), + }); + const runtime = runtimeLayerReference("runtime", fixture.descriptor); + expect(nodeEntry.composeHomebrewRuntimeLayers).toBe( + browserEntry.composeHomebrewRuntimeLayers, + ); + + const composed = await nodeEntry.composeHomebrewRuntimeLayers({ + baseImageBytes: fixture.baseImageBytes, + arch: "wasm32", + kernelAbi: ABI_VERSION, + layers: [runtime.reference], + privilegedProjections: Object.entries(programs).map(([name, data]) => ({ + schema: 1, + formula: "kandelo-dev/tap-core/runtime", + bottleSha256: sha256(fixture.runtimeBytes), + sourcePath: `runtime/3.0/bin/${name}`, + destinationPath: `/usr/bin/${name}`, + uid: 0, + gid: 0, + mode: 0o4755, + mountPoint: "trusted-root-product", + artifactValidationSha256: sha256(data), + })), + fetch: async () => new Response(runtime.bytes), + archiveFetch: async () => new Response(fixture.archive), + } as Parameters[0] & { + privilegedProjections: unknown; + }); + + expect(composed.privilegedProduct).toBeUndefined(); + }); + + it("projects authenticated lazy bottle members before runtime publication", async () => { + const programs = { + login: utf8("runtime login\n"), + "sudo-lite": utf8("runtime sudo-lite\n"), + sudo: utf8("runtime sudo\n"), + }; + const fixture = await runtimeLayerConsumerFixture({ + runtimeExtraEntries: Object.entries(programs).map(([name, data]) => ({ + path: `runtime/3.0/bin/${name}`, + data, + mode: 0o755, + })), + }); + const runtime = runtimeLayerReference("runtime", fixture.descriptor); + let archiveFetches = 0; + const policy = createReviewedPrivilegedProgramPolicy( + Object.entries(programs).map(([name, data]) => ({ + schema: 1, + formula: "kandelo-dev/tap-core/runtime", + bottleSha256: sha256(fixture.runtimeBytes), + sourcePath: `runtime/3.0/bin/${name}`, + destinationPath: `/usr/bin/${name}`, + uid: 0, + gid: 0, + mode: 0o4755, + mountPoint: "trusted-root-product", + artifactValidationSha256: sha256(data), + })), + ); + const composed = await composeHomebrewRuntimeLayersWithReviewedProduct({ + baseImageBytes: fixture.baseImageBytes, + arch: "wasm32", + kernelAbi: ABI_VERSION, + layers: [runtime.reference], + fetch: async () => new Response(runtime.bytes), + archiveFetch: async () => { + archiveFetches += 1; + return new Response(fixture.archive); + }, + }, policy); + + expect(archiveFetches).toBe(1); + expect(composed.privilegedProduct?.evidence).toHaveLength(3); + expect(composed.fs.lstat(`${fixture.runtimeKeg}/bin/login`).mode & 0o7777) + .toBe(0o755); + expect(composed.privilegedProduct!.mount.backend.lstat("/usr/bin/login")) + .toMatchObject({ uid: 0, gid: 0, nlink: 1 }); + expect( + composed.privilegedProduct!.mount.backend.lstat("/usr/bin/login").mode & + 0o7777, + ).toBe(0o4755); + }); + it("authenticates sealed base trees before registering runtime layers", async () => { const fixture = await runtimeLayerConsumerFixture(); const base = MemoryFileSystem.fromImage(fixture.baseImageBytes); @@ -3508,6 +3612,105 @@ describe("Homebrew VFS planner public bounds", () => { }); describe("Homebrew VFS builder", () => { + it("publishes reviewed bottle members as a separate immutable product tree", async () => { + const programBytes = { + login: utf8("login program\n"), + "sudo-lite": utf8("sudo-lite program\n"), + sudo: utf8("sudo program\n"), + }; + const bytes = bottleTar(standardEntries([ + { + path: "hello/2.12.1/bin/login-real", + data: programBytes.login, + mode: 0o755, + }, + { + path: "hello/2.12.1/bin/login", + type: "hardlink", + linkName: "hello/2.12.1/bin/login-real", + mode: 0o755, + }, + { + path: "hello/2.12.1/bin/sudo-lite", + data: programBytes["sudo-lite"], + mode: 0o755, + }, + { + path: "hello/2.12.1/bin/sudo", + data: programBytes.sudo, + mode: 0o755, + }, + ])); + const rawProjections = [ + ["login", "/usr/bin/login"], + ["sudo-lite", "/usr/bin/sudo-lite"], + ["sudo", "/usr/bin/sudo"], + ].map(([name, destinationPath]) => ({ + schema: 1, + formula: "kandelo-dev/tap-core/hello", + bottleSha256: sha256(bytes), + sourcePath: `hello/2.12.1/bin/${name}`, + destinationPath, + uid: 0, + gid: 0, + mode: 0o4755, + mountPoint: "trusted-root-product", + artifactValidationSha256: + sha256(programBytes[name as keyof typeof programBytes]), + })); + const forged = await buildFixture(bytes, { + mutatePlan(plan) { + Object.assign(plan, { privilegedProjections: rawProjections }); + }, + }); + expect(forged.privilegedProduct).toBeUndefined(); + expect(forged.report.privileged_programs).toBeUndefined(); + + const result = await buildFixture(bytes, { + mutatePlan(plan) { + attachReviewedPrivilegedProgramPolicy( + plan, + createReviewedPrivilegedProgramPolicy(rawProjections), + ); + }, + }); + + expect(result.privilegedProduct).toBeDefined(); + expect(result.report.privileged_programs?.projections).toHaveLength(3); + expect(result.privilegedProduct!.evidence[0]).toMatchObject({ + sourcePath: "hello/2.12.1/bin/login", + canonicalSourcePath: "hello/2.12.1/bin/login-real", + destinationPath: "/usr/bin/login", + collidesWithWritableBottle: false, + }); + const identities = new Set( + result.privilegedProduct!.evidence.map((entry) => + JSON.stringify(entry.destinationIdentity) + ), + ); + expect(identities.size).toBe(3); + for (const destination of [ + "/usr/bin/login", + "/usr/bin/sudo-lite", + "/usr/bin/sudo", + ]) { + const stat = result.privilegedProduct!.mount.backend.lstat(destination); + expect(stat.mode & 0o170000).toBe(0o100000); + expect(stat.mode & 0o7777).toBe(0o4755); + expect(stat.uid).toBe(0); + expect(stat.gid).toBe(0); + expect(stat.nlink).toBe(1); + } + expect(() => result.privilegedProduct!.mount.backend.unlink("/usr/bin/login")) + .toThrow(/EROFS/); + expect(result.fs.lstat(`${KEG}/bin/login`).mode & 0o7777).toBe(0o755); + expect(resolveMountSetIdCapability({ backend: result.fs })).toEqual({ + kind: "nosuid", + }); + result.fs.chmod(`${KEG}/bin/login`, 0o555); + expect(result.fs.lstat(`${KEG}/bin/login`).mode & 0o7777).toBe(0o555); + }); + it("builds a deterministic deferred tree containing only base-exclusive package output", async () => { const fixture = await lazyLayerFixture(); const first = await fixture.build(); diff --git a/host/test/homebrew-vfs-planner.test.ts b/host/test/homebrew-vfs-planner.test.ts index 71a3d8b14e..c39e95f15f 100644 --- a/host/test/homebrew-vfs-planner.test.ts +++ b/host/test/homebrew-vfs-planner.test.ts @@ -1,12 +1,16 @@ import { describe, expect, it } from "vitest"; import { ABI_VERSION } from "../src/generated/abi"; import { + attachReviewedPrivilegedProgramPolicy, planFederatedHomebrewVfs, planHomebrewVfs, + reviewedPrivilegedProgramPolicyForPlan, type HomebrewLinkManifest, type HomebrewTapMetadata, type HomebrewVfsTapIdentity, } from "../src/homebrew-vfs-planner"; +import { createReviewedPrivilegedProgramPolicy } from + "../src/vfs/privileged-projection"; const SHA_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const SHA_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; @@ -234,6 +238,49 @@ function federatedManifestMap( } describe("Homebrew VFS planner", () => { + it("binds the closed privileged projection group to selected bottle digests", async () => { + const privilegedProjections = [ + ["login", "/usr/bin/login"], + ["sudo-lite", "/usr/bin/sudo-lite"], + ["sudo", "/usr/bin/sudo"], + ].map(([name, destinationPath]) => ({ + schema: 1, + formula: "kandelo-dev/tap-core/hello", + bottleSha256: SHA_B, + sourcePath: `hello/2.12.1/bin/${name}`, + destinationPath, + uid: 0, + gid: 0, + mode: 0o4755, + mountPoint: "trusted-root-product", + artifactValidationSha256: SHA_D, + })); + const options = { + packages: ["hello"], + arch: "wasm32" as const, + runtime: "node" as const, + loadLinkManifest: () => linkManifest("hello", "2.12.1"), + }; + + const plan = await planHomebrewVfs( + metadata([packageEntry("hello", "2.12.1")]), + { ...options, privilegedProjections } as typeof options & { + privilegedProjections: unknown; + }, + ); + expect(reviewedPrivilegedProgramPolicyForPlan(plan)).toBeUndefined(); + const policy = createReviewedPrivilegedProgramPolicy(privilegedProjections); + attachReviewedPrivilegedProgramPolicy(plan, policy); + expect(reviewedPrivilegedProgramPolicyForPlan(plan)).toBe(policy); + + const drifted = structuredClone(privilegedProjections); + drifted[2]!.bottleSha256 = SHA_A; + expect(() => attachReviewedPrivilegedProgramPolicy( + plan, + createReviewedPrivilegedProgramPolicy(drifted), + )).toThrow(/privileged projection bottle digest/i); + }); + it("resolves requested packages with dependencies in pour order", async () => { const tapMetadata = metadata([ packageEntry("hello", "2.12.1", [{ name: "zlib", version: "1.3.1" }]), diff --git a/host/test/kernel-authority-boundary.test.ts b/host/test/kernel-authority-boundary.test.ts index a4fd869c2d..da70eb83d2 100644 --- a/host/test/kernel-authority-boundary.test.ts +++ b/host/test/kernel-authority-boundary.test.ts @@ -4,6 +4,8 @@ import { describe, expect, it, vi } from "vitest"; import * as browserEntry from "../src/browser"; import * as nodeEntry from "../src/index"; +import * as privilegedProjectionModule from + "../src/vfs/privileged-projection"; import { CentralizedKernelWorker, createCentralizedKernelWorkerTestDouble, @@ -88,6 +90,15 @@ const hiddenPackageSymbols = [ "kernelEntryInvokerForInstance", "kernelEntryGateForInstance", "KernelEntryGate", + "createReviewedPrivilegedProgramPolicy", + "readReviewedPrivilegedProgramPolicy", + "attachReviewedPrivilegedProgramPolicy", + "reviewedPrivilegedProgramPolicyForPlan", + "composeHomebrewRuntimeLayersWithReviewedProduct", + "publishPrivilegedProgramProduct", + "admitPrivilegedProgramProductCandidate", + "admitPrivilegedProgramProductCandidateForTest", + "validatePrivilegedProgramProductCandidate", ] as const; describe("kernel authority boundary", () => { @@ -293,5 +304,19 @@ describe("kernel authority boundary", () => { expect(packageJson.exports).not.toHaveProperty("./kernel"); expect(packageJson.exports).not.toHaveProperty("./kernel-worker"); expect(packageJson.exports).not.toHaveProperty("./kernel-entry-gate"); + expect(packageJson.exports).not.toHaveProperty("./vfs/privileged-projection"); + }); + + it("exposes no arbitrary-candidate privileged publication path", () => { + expect(Reflect.has( + privilegedProjectionModule, + "admitPrivilegedProgramProductCandidate", + )).toBe(false); + expect(Reflect.has( + privilegedProjectionModule, + "admitPrivilegedProgramProductCandidateForTest", + )).toBe(false); + expect(typeof privilegedProjectionModule.validatePrivilegedProgramProductCandidate) + .toBe("function"); }); }); diff --git a/host/test/privileged-projection.test.ts b/host/test/privileged-projection.test.ts new file mode 100644 index 0000000000..e2dc73b1ff --- /dev/null +++ b/host/test/privileged-projection.test.ts @@ -0,0 +1,506 @@ +import { createHash } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { + createReviewedPrivilegedProgramPolicy, + parsePrivilegedProgramProjections, + publishPrivilegedProgramProduct, + validatePrivilegedProgramProductCandidate, + type PrivilegedProgramProjection, + type PrivilegedProgramSource, +} from "../src/vfs/privileged-projection"; +import * as privilegedProjectionModule from + "../src/vfs/privileged-projection"; +import { ensureDirRecursive, writeVfsBinary } from "../src/vfs/image-helpers"; +import { MemoryFileSystem } from "../src/vfs/memory-fs"; +import { VirtualPlatformIO } from "../src/vfs/vfs"; +import { NodeTimeProvider } from "../src/vfs/time"; + +const S_IFMT = 0o170000; +const S_IFREG = 0o100000; +const SOURCE_ROOT = "/opt/kandelo/homebrew/Cellar"; +const SHA_A = "a".repeat(64); +const PROGRAMS = [ + { + formula: "kandelo-dev/tap-core/login", + sourcePath: "login/1.0/bin/login", + destinationPath: "/usr/bin/login", + bytes: new TextEncoder().encode("login program\n"), + }, + { + formula: "kandelo-dev/tap-core/sudo-lite", + sourcePath: "sudo-lite/1.0/bin/sudo-lite", + destinationPath: "/usr/bin/sudo-lite", + bytes: new TextEncoder().encode("sudo-lite program\n"), + }, + { + formula: "kandelo-dev/tap-core/sudo", + sourcePath: "sudo/1.9.17p2/bin/sudo", + destinationPath: "/usr/bin/sudo", + bytes: new TextEncoder().encode("upstream sudo program\n"), + }, +] as const; + +function sha256(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +function createFs(): MemoryFileSystem { + return MemoryFileSystem.create(new SharedArrayBuffer(4 * 1024 * 1024)); +} + +function projections(): PrivilegedProgramProjection[] { + return PROGRAMS.map((program) => ({ + schema: 1, + formula: program.formula, + bottleSha256: SHA_A, + sourcePath: program.sourcePath, + destinationPath: program.destinationPath, + uid: 0, + gid: 0, + mode: 0o4755, + mountPoint: "trusted-root-product", + artifactValidationSha256: sha256(program.bytes), + })); +} + +function reviewedPolicy(value: unknown = projections()) { + return createReviewedPrivilegedProgramPolicy(value); +} + +function sourceFixture(options: { + sourceSymlink?: string; + sourceHardlink?: { from: string; to: string }; +} = {}): { + writableBottleFs: MemoryFileSystem; + sources: PrivilegedProgramSource[]; +} { + const writableBottleFs = createFs(); + const sources: PrivilegedProgramSource[] = []; + for (const program of PROGRAMS) { + const guestPath = `${SOURCE_ROOT}/${program.sourcePath}`; + ensureDirRecursive(writableBottleFs, guestPath.slice(0, guestPath.lastIndexOf("/"))); + if (options.sourceSymlink === program.sourcePath) { + writableBottleFs.symlink("login-real", guestPath); + } else if (options.sourceHardlink?.from === program.sourcePath) { + const canonicalGuestPath = `${SOURCE_ROOT}/${options.sourceHardlink.to}`; + ensureDirRecursive( + writableBottleFs, + canonicalGuestPath.slice(0, canonicalGuestPath.lastIndexOf("/")), + ); + writeVfsBinary(writableBottleFs, canonicalGuestPath, program.bytes, 0o755); + writableBottleFs.link(canonicalGuestPath, guestPath); + } else { + writeVfsBinary(writableBottleFs, guestPath, program.bytes, 0o755); + } + sources.push({ + formula: program.formula, + bottleSha256: SHA_A, + fs: writableBottleFs, + inventory: { + entries: options.sourceHardlink?.from === program.sourcePath + ? [ + { + sourcePath: options.sourceHardlink.to, + type: "file", + size: program.bytes.byteLength, + }, + { + sourcePath: program.sourcePath, + type: "hardlink", + size: 0, + target: options.sourceHardlink.to, + }, + ] + : [{ + sourcePath: program.sourcePath, + type: options.sourceSymlink === program.sourcePath ? "symlink" : "file", + size: options.sourceSymlink === program.sourcePath ? 0 : program.bytes.byteLength, + ...(options.sourceSymlink === program.sourcePath + ? { target: "login-real" } + : {}), + }], + }, + guestPathForSource(sourcePath) { + return `${SOURCE_ROOT}/${sourcePath}`; + }, + }); + } + return { writableBottleFs, sources }; +} + +describe("privileged projection record", () => { + it("accepts only the closed reviewed root-owned projection group", () => { + expect(parsePrivilegedProgramProjections(projections())).toEqual(projections()); + + for (const [label, mutate] of [ + ["unknown field", (record: Record) => record.extra = true], + ["wrong mode", (record: Record) => record.mode = 0o755], + ["wrong owner", (record: Record) => record.uid = 1000], + ["wrong group", (record: Record) => record.gid = 1000], + ["unrecognized mount", (record: Record) => record.mountPoint = "/"], + ["unreviewed destination", (record: Record) => { + record.destinationPath = "/usr/bin/su"; + }], + ] as const) { + const value = structuredClone(projections()) as unknown as Record[]; + mutate(value[0]!); + expect( + () => parsePrivilegedProgramProjections(value), + label, + ).toThrow(); + } + }); + + it("rejects duplicate or incomplete product destinations", () => { + const duplicate = projections(); + duplicate[1] = { ...duplicate[1]!, destinationPath: "/usr/bin/login" }; + expect(() => parsePrivilegedProgramProjections(duplicate)).toThrow(/duplicate/i); + expect(() => parsePrivilegedProgramProjections(projections().slice(0, 2))) + .toThrow(/exactly|missing/i); + }); + + it("rejects missing and inherited required projection fields", () => { + const missing = structuredClone(projections()) as unknown as + Record[]; + delete missing[0]!.formula; + expect(() => parsePrivilegedProgramProjections(missing)).toThrow(/closed schema/i); + + const inherited = projections(); + inherited[0] = Object.create(inherited[0]) as PrivilegedProgramProjection; + expect(() => parsePrivilegedProgramProjections(inherited)).toThrow(/plain record/i); + }); +}); + +describe("privileged product publication", () => { + it("does not grant product authority through caller-candidate seams", async () => { + const source = sourceFixture(); + const candidate = createFs(); + ensureDirRecursive(candidate, "/usr/bin"); + for (const program of PROGRAMS) { + writeVfsBinary(candidate, program.destinationPath, program.bytes, 0o755); + candidate.chown(program.destinationPath, 0, 0); + candidate.chmod(program.destinationPath, 0o4755); + } + const sharedAlias = MemoryFileSystem.fromExisting( + structuredClone(candidate.sharedBuffer), + ); + const unsafeAdmission = Reflect.get( + privilegedProjectionModule, + "admitPrivilegedProgramProductCandidateForTest", + ) as ((options: { + candidateFs: MemoryFileSystem; + policy: ReturnType; + sources: PrivilegedProgramSource[]; + writableBottleFileSystems: MemoryFileSystem[]; + }) => Promise<{ + mount: ConstructorParameters[0][number]; + }>) | + undefined; + + let grantedCapability: string | undefined; + if (unsafeAdmission !== undefined) { + const product = await unsafeAdmission({ + candidateFs: candidate, + policy: reviewedPolicy(), + sources: source.sources, + writableBottleFileSystems: [sharedAlias], + }); + grantedCapability = new VirtualPlatformIO( + [product.mount], + new NodeTimeProvider(), + ).getMountSetIdCapability("/usr/bin/login").kind; + } + + expect(grantedCapability).not.toBe("trusted-root-product"); + await expect(validatePrivilegedProgramProductCandidate({ + candidateFs: candidate, + policy: reviewedPolicy(), + sources: source.sources, + writableBottleFileSystems: [sharedAlias], + })).resolves.toBeUndefined(); + expect(() => new VirtualPlatformIO([{ + mountPoint: "/", + backend: candidate, + readonly: true, + setIdCapability: { + kind: "trusted-root-product", + guestWritable: false, + stableExecutableIdentity: true, + }, + }], new NodeTimeProvider())).toThrow(/immutable product backend/i); + expect(Reflect.has( + privilegedProjectionModule, + "admitPrivilegedProgramProductCandidate", + )).toBe(false); + expect(Reflect.has( + privilegedProjectionModule, + "admitPrivilegedProgramProductCandidateForTest", + )).toBe(false); + }); + + it("copies regular bytes into three fresh unique root-owned inodes", async () => { + const source = sourceFixture(); + const product = await publishPrivilegedProgramProduct({ + policy: reviewedPolicy(), + sources: source.sources, + writableBottleFileSystems: [source.writableBottleFs], + }); + const io = new VirtualPlatformIO([product.mount], new NodeTimeProvider()); + + expect(product.projections.map((entry) => entry.destinationPath)).toEqual([ + "/usr/bin/login", + "/usr/bin/sudo-lite", + "/usr/bin/sudo", + ]); + const productIdentities = new Set(); + const bottleIdentities = new Set(); + for (const [index, program] of PROGRAMS.entries()) { + const projected = io.lstat(program.destinationPath); + const bottle = source.writableBottleFs.lstat(`${SOURCE_ROOT}/${program.sourcePath}`); + expect(projected.mode & S_IFMT).toBe(S_IFREG); + expect(projected.mode & 0o7777).toBe(0o4755); + expect(projected.uid).toBe(0); + expect(projected.gid).toBe(0); + expect(projected.nlink).toBe(1); + expect(product.evidence[index]?.sourceIdentity) + .not.toEqual(product.evidence[index]?.destinationIdentity); + productIdentities.add(JSON.stringify(product.evidence[index]?.destinationIdentity)); + bottleIdentities.add(JSON.stringify({ dev: bottle.dev, ino: bottle.ino })); + } + expect(productIdentities.size).toBe(3); + expect(product.evidence.every((entry) => entry.collidesWithWritableBottle === false)) + .toBe(true); + expect(io.getMountSetIdCapability("/usr/bin/login")).toEqual({ + kind: "trusted-root-product", + guestWritable: false, + stableExecutableIdentity: true, + }); + expect(() => product.mount.backend.unlink("/usr/bin/login")).toThrow(/EROFS/); + expect(bottleIdentities.size).toBe(3); + + const restoredArtifact = MemoryFileSystem.fromImage(product.imageBytes); + expect(restoredArtifact.lstat("/usr/bin/login")).toMatchObject({ + uid: 0, + gid: 0, + nlink: 1, + }); + expect(restoredArtifact.lstat("/usr/bin/login").mode & 0o7777).toBe(0o4755); + + const repeated = await publishPrivilegedProgramProduct({ + policy: reviewedPolicy(), + sources: source.sources, + writableBottleFileSystems: [source.writableBottleFs], + }); + expect(repeated.imageBytes).toEqual(product.imageBytes); + }); + + it("resolves an authenticated bottle hardlink but publishes a fresh inode", async () => { + const source = sourceFixture({ + sourceHardlink: { + from: PROGRAMS[0].sourcePath, + to: "login/1.0/bin/login-real", + }, + }); + const product = await publishPrivilegedProgramProduct({ + policy: reviewedPolicy(), + sources: source.sources, + writableBottleFileSystems: [source.writableBottleFs], + }); + expect(product.evidence[0]).toMatchObject({ + sourcePath: PROGRAMS[0].sourcePath, + canonicalSourcePath: "login/1.0/bin/login-real", + destinationPath: "/usr/bin/login", + collidesWithWritableBottle: false, + }); + expect(product.evidence[0]?.sourceIdentity) + .not.toEqual(product.evidence[0]?.destinationIdentity); + }); + + it("rejects source symlinks and members absent from the complete inventory", async () => { + const symlink = sourceFixture({ sourceSymlink: PROGRAMS[0].sourcePath }); + await expect(publishPrivilegedProgramProduct({ + policy: reviewedPolicy(), + sources: symlink.sources, + writableBottleFileSystems: [symlink.writableBottleFs], + })).rejects.toThrow(/source.*symlink/i); + + const absent = sourceFixture(); + absent.sources[0] = { ...absent.sources[0]!, inventory: { entries: [] } }; + await expect(publishPrivilegedProgramProduct({ + policy: reviewedPolicy(), + sources: absent.sources, + writableBottleFileSystems: [absent.writableBottleFs], + })).rejects.toThrow(/absent.*complete.*inventory/i); + + const cycle = sourceFixture(); + cycle.sources[0] = { + ...cycle.sources[0]!, + inventory: { + entries: [ + { + sourcePath: PROGRAMS[0].sourcePath, + type: "hardlink", + size: 0, + target: "login/1.0/bin/login-cycle", + }, + { + sourcePath: "login/1.0/bin/login-cycle", + type: "hardlink", + size: 0, + target: PROGRAMS[0].sourcePath, + }, + ], + }, + }; + await expect(publishPrivilegedProgramProduct({ + policy: reviewedPolicy(), + sources: cycle.sources, + writableBottleFileSystems: [cycle.writableBottleFs], + })).rejects.toThrow(/hard-?link.*cycle/i); + }); + + it("rejects source or artifact digest drift before publication", async () => { + const source = sourceFixture(); + const bottleDrift = projections(); + bottleDrift[0] = { ...bottleDrift[0]!, bottleSha256: "b".repeat(64) }; + await expect(publishPrivilegedProgramProduct({ + policy: reviewedPolicy(bottleDrift), + sources: source.sources, + writableBottleFileSystems: [source.writableBottleFs], + })).rejects.toThrow(/bottle.*digest/i); + + const artifactDrift = projections(); + artifactDrift[0] = { + ...artifactDrift[0]!, + artifactValidationSha256: "c".repeat(64), + }; + await expect(publishPrivilegedProgramProduct({ + policy: reviewedPolicy(artifactDrift), + sources: source.sources, + writableBottleFileSystems: [source.writableBottleFs], + })).rejects.toThrow(/artifact.*digest/i); + }); + + it("rejects projected symlinks, hard links, writable aliases, and bottle collisions", async () => { + const source = sourceFixture(); + const base = createFs(); + ensureDirRecursive(base, "/usr/bin"); + for (const program of PROGRAMS) { + writeVfsBinary(base, program.destinationPath, program.bytes, 0o755); + base.chown(program.destinationPath, 0, 0); + base.chmod(program.destinationPath, 0o4755); + } + + const projectedSymlink = createFs(); + ensureDirRecursive(projectedSymlink, "/usr/bin"); + projectedSymlink.symlink("/opt/kandelo/homebrew/bin/login", "/usr/bin/login"); + for (const program of PROGRAMS.slice(1)) { + writeVfsBinary(projectedSymlink, program.destinationPath, program.bytes, 0o4755); + } + await expect(validatePrivilegedProgramProductCandidate({ + candidateFs: projectedSymlink, + policy: reviewedPolicy(), + sources: source.sources, + writableBottleFileSystems: [source.writableBottleFs], + })).rejects.toThrow(/projected.*regular/i); + + const preservedHardlink = base.rebaseToNewFileSystem(4 * 1024 * 1024); + preservedHardlink.unlink("/usr/bin/login"); + preservedHardlink.link("/usr/bin/sudo", "/usr/bin/login"); + await expect(validatePrivilegedProgramProductCandidate({ + candidateFs: preservedHardlink, + policy: reviewedPolicy(), + sources: source.sources, + writableBottleFileSystems: [source.writableBottleFs], + })).rejects.toThrow(/hard link|unique inode/i); + + const writableAlias = base.rebaseToNewFileSystem(4 * 1024 * 1024); + ensureDirRecursive(writableAlias, "/tmp"); + writableAlias.link("/usr/bin/login", "/tmp/writable-login"); + await expect(validatePrivilegedProgramProductCandidate({ + candidateFs: writableAlias, + policy: reviewedPolicy(), + sources: source.sources, + writableBottleFileSystems: [source.writableBottleFs], + })).rejects.toThrow(/writable alias|link count/i); + + ensureDirRecursive(source.writableBottleFs, "/usr/bin"); + for (const program of PROGRAMS) { + source.writableBottleFs.link( + `${SOURCE_ROOT}/${program.sourcePath}`, + program.destinationPath, + ); + source.writableBottleFs.chmod(program.destinationPath, 0o4755); + } + const writableBottlePeer = MemoryFileSystem.fromExisting( + source.writableBottleFs.sharedBuffer, + ); + await expect(validatePrivilegedProgramProductCandidate({ + candidateFs: writableBottlePeer, + policy: reviewedPolicy(), + sources: source.sources, + writableBottleFileSystems: [source.writableBottleFs], + })).rejects.toThrow(/writable bottle inode|collision/i); + }); + + it("rejects non-root files, writable parents, and an unstable backend", async () => { + const source = sourceFixture(); + const candidate = createFs(); + ensureDirRecursive(candidate, "/usr/bin"); + for (const program of PROGRAMS) { + writeVfsBinary(candidate, program.destinationPath, program.bytes, 0o4755); + } + candidate.chown("/usr/bin/login", 1000, 0); + await expect(validatePrivilegedProgramProductCandidate({ + candidateFs: candidate, + policy: reviewedPolicy(), + sources: source.sources, + writableBottleFileSystems: [source.writableBottleFs], + })).rejects.toThrow(/root-owned/i); + + candidate.chown("/usr/bin/login", 0, 0); + candidate.chmod("/usr/bin", 0o775); + await expect(validatePrivilegedProgramProductCandidate({ + candidateFs: candidate, + policy: reviewedPolicy(), + sources: source.sources, + writableBottleFileSystems: [source.writableBottleFs], + })).rejects.toThrow(/parent.*writable/i); + + candidate.chmod("/usr/bin", 0o755); + expect(() => new VirtualPlatformIO([{ + mountPoint: "/", + backend: candidate, + readonly: true, + setIdCapability: { + kind: "trusted-root-product", + guestWritable: false, + stableExecutableIdentity: true, + }, + }], new NodeTimeProvider())).toThrow(/immutable product backend/i); + }); + + it("rolls back the entire group when the final projection fails", async () => { + const source = sourceFixture(); + const invalid = projections(); + invalid[2] = { + ...invalid[2]!, + artifactValidationSha256: "d".repeat(64), + }; + let product: Awaited> | undefined; + await expect((async () => { + product = await publishPrivilegedProgramProduct({ + policy: reviewedPolicy(invalid), + sources: source.sources, + writableBottleFileSystems: [source.writableBottleFs], + }); + })()).rejects.toThrow(/artifact.*digest/i); + expect(product).toBeUndefined(); + for (const program of PROGRAMS) { + expect(source.writableBottleFs.lstat(`${SOURCE_ROOT}/${program.sourcePath}`).nlink) + .toBe(1); + } + }); +}); diff --git a/images/vfs/scripts/build-homebrew-vfs-image.ts b/images/vfs/scripts/build-homebrew-vfs-image.ts index 6f117eaab2..b5499ac9bc 100644 --- a/images/vfs/scripts/build-homebrew-vfs-image.ts +++ b/images/vfs/scripts/build-homebrew-vfs-image.ts @@ -40,6 +40,7 @@ import { import { KANDELO_HOMEBREW_GUEST_LAYOUT } from "../../../host/src/homebrew-guest-layout"; import { fetchHomebrewBottleBytes } from "../../../host/src/homebrew-vfs-fetch"; import { + attachReviewedPrivilegedProgramPolicy, planFederatedHomebrewVfs, planHomebrewVfs, type HomebrewBottleArch, @@ -47,6 +48,9 @@ import { type HomebrewVfsPackagePlan, type HomebrewVfsPlan, } from "../../../host/src/homebrew-vfs-planner"; +import { + createReviewedPrivilegedProgramPolicy, +} from "../../../host/src/vfs/privileged-projection"; import { assertHomebrewRuntimeSupportPlan, parseHomebrewRuntimeSupportContract, @@ -121,6 +125,8 @@ interface CliOptions { packageTreeArchive?: string; homebrewBootstrapEnv?: string; homebrewRuntimeSupport?: string; + privilegedProjections?: string; + privilegedProductOut?: string; materializePackageTree: boolean; } @@ -409,6 +415,18 @@ export async function runHomebrewVfsImageBuilder( }, ); + if (options.privilegedProjections !== undefined) { + // This CLI is a trusted build-operator boundary. Its JSON report/image is + // evidence, never reusable live mount authority; authority remains the + // non-serializable policy association on this in-memory plan. + attachReviewedPrivilegedProgramPolicy( + plan, + createReviewedPrivilegedProgramPolicy( + readJsonFile(options.privilegedProjections), + ), + ); + } + let runtimeSupport: | { contract: HomebrewRuntimeSupportContract; @@ -519,6 +537,14 @@ export async function runHomebrewVfsImageBuilder( }); result = materializedBuild.result; } + if ( + options.privilegedProjections !== undefined && + result.privilegedProduct === undefined + ) { + throw new Error( + "privileged projection policy did not produce an independent product tree", + ); + } let packageTree: | { derived: DerivedPackageDeferredZipTree; @@ -892,9 +918,32 @@ export async function runHomebrewVfsImageBuilder( : { homebrew_bootstrap: homebrewBootstrapConsumerState, }), + ...(result.privilegedProduct === undefined || + options.privilegedProductOut === undefined + ? {} + : { + privileged_product: { + image: basename(options.privilegedProductOut), + sha256: createHash("sha256") + .update(result.privilegedProduct.imageBytes) + .digest("hex"), + bytes: result.privilegedProduct.imageBytes.byteLength, + }, + }), // Report a reproducible artifact identity, not a runner/worktree path. image: basename(options.out), }; + if ( + result.privilegedProduct !== undefined && + options.privilegedProductOut !== undefined + ) { + mkdirSync(dirname(options.privilegedProductOut), { recursive: true }); + writeFileSync( + options.privilegedProductOut, + result.privilegedProduct.imageBytes, + ); + console.log(`Privileged product VFS: ${options.privilegedProductOut}`); + } mkdirSync(dirname(options.report), { recursive: true }); writeFileSync(options.report, `${JSON.stringify(report, null, 2)}\n`); console.log(`Homebrew VFS report: ${options.report}`); @@ -1085,6 +1134,18 @@ function parseArgs(args: string[]): CliOptions { } options.homebrewRuntimeSupport = requireValue(args, ++i, arg); break; + case "--privileged-projections": + if (options.privilegedProjections !== undefined) { + usage("--privileged-projections may be provided only once"); + } + options.privilegedProjections = requireValue(args, ++i, arg); + break; + case "--privileged-product-out": + if (options.privilegedProductOut !== undefined) { + usage("--privileged-product-out may be provided only once"); + } + options.privilegedProductOut = requireValue(args, ++i, arg); + break; case "--materialize-package-tree": if (options.materializePackageTree) { usage("--materialize-package-tree may be provided only once"); @@ -1142,6 +1203,30 @@ function parseArgs(args: string[]): CliOptions { if (options.demoConfig && !existsSync(options.demoConfig)) { usage(`demo config does not exist: ${options.demoConfig}`); } + if ( + Boolean(options.privilegedProjections) !== + Boolean(options.privilegedProductOut) + ) { + usage( + "--privileged-projections and --privileged-product-out must be provided together", + ); + } + if ( + options.privilegedProjections !== undefined && + !existsSync(options.privilegedProjections) + ) { + usage( + `privileged projection policy does not exist: ${options.privilegedProjections}`, + ); + } + if ( + options.privilegedProductOut !== undefined && + existsSync(options.privilegedProductOut) + ) { + usage( + `privileged product output must not already exist: ${options.privilegedProductOut}`, + ); + } const materializationOptionCount = [ options.materializationPolicy, options.bottleMirrorRepository, @@ -2188,6 +2273,8 @@ function usage(message?: string, code = 2): never { [--bottle-cache ] [--base-image ] \\ [--max-bytes ] [--write-profile] \\ [--shell-config ] [--demo-config ] \\ + [--privileged-projections \\ + --privileged-product-out ] \\ [--catalog-commit ] \\ [--migration-lock ] \\ [--materialization-policy \\ diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index 18291d58ae..494aa3ee29 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -74,8 +74,8 @@ "erlang-vfs": { "manifestSha256": "15efb74f1825e2b85bedfeb8d98c19fa648c4be39cb9defb65330a8f21eb9141", "cacheKeys": { - "wasm32": "e7def6b3619e031084290c717f6cb79de6bdc36a1463a65863143cfb4ebbd7f7", - "wasm64": "c9f57606aa1e5853848b7964578002c4ab954bbc9a87ec4927c41938c1de1b93" + "wasm32": "eb1c457e4ba3400b5f8a4f149e63020a344c7ef9d215dda6bd06e32fe665e48c", + "wasm64": "8d7ccd95c663214127fb2a8639e28a8d2bd5d1b22215a97ea821a699541bb662" } }, "fbdoom": { @@ -144,8 +144,8 @@ "kandelo-sdk": { "manifestSha256": "c081879f1becd855917cff96f17429bcf2b9fd61bf95211eca9ff8fb625bb2eb", "cacheKeys": { - "wasm32": "698a5ef53195c472a0ba2a3e00820758fe5fbaac50aa47cd7cd00693ffbf3e83", - "wasm64": "435c56a8b5a8c25a2ac47968d76b766cfb6e6daa6190be839ecf29ca5f43d82d" + "wasm32": "5d07e216601e1466c300e439002aafb23df3193db1b4f767cf5454390df0f472", + "wasm64": "27ab30a7b0bfccb3c1eb900fd660ee1410c722e9126dab9537dd3e9729e3d798" } }, "kernel": { @@ -158,8 +158,8 @@ "lamp": { "manifestSha256": "250b64635d64f8537178188fb5488dbfd2980d79a1c2ffbf346af67008088ba0", "cacheKeys": { - "wasm32": "93ca83b243bc11a75e4ebb73acbb1f3d08dd59ad20df436892647110adee181e", - "wasm64": "67c5ce616a57fd8b2184fd4901381f8dc00409b818bfcdb98b5c1376366e6b8c" + "wasm32": "04f6fb7e0f86ae44b016b26ccb8daa5eb1a21f0cc607de1aa630ef3ede8b7715", + "wasm64": "6e6d98111227d0646cfdb7738bdb19687634d2fdf4b34bf041e52590dcbe6fac" } }, "less": { @@ -242,15 +242,15 @@ "mariadb-test": { "manifestSha256": "d4ccce161d659a5a87c80fa1117054bbccea870e0d4a46bd8a070d3930ad0e3f", "cacheKeys": { - "wasm32": "e9cb9f5b1c3efa9a3916f2ce81f6012a392bce284afa31ea132edcb6fb10ab02", - "wasm64": "334a472b68bd82e168ed71d23aff67ee0253d189453219b5759d6a99cfab6602" + "wasm32": "01f3400be98fe966cd9ac2213c04cef76ba72c52a7aeb8a4591d598945bc137f", + "wasm64": "5893bd9b0bd0968d290a25f66947b5d03b8da21f45a89498175f87731866de2b" } }, "mariadb-vfs": { "manifestSha256": "26e74ac84d89b2a839ed72783437061a23022ef2f88ad0f52b6aacd0442ee1cf", "cacheKeys": { - "wasm32": "a053305528534ac1aa3efb3a9eb4bbee21f915301588861e5365e454422e32f8", - "wasm64": "5fb381f76ab397f36319d50c1c35d02ddf9046fa3972c6c30f960bb15e99d455" + "wasm32": "de98d36939b492929f2f1433d8ee62e5dd25ae7ed6a968e2ed0feb2cb9c8b1c6", + "wasm64": "3e022eec241af103e25a3b90686698c6c8d5d60dfaca22611d2be6fe03b50fac" } }, "modeset": { @@ -312,15 +312,15 @@ "nginx-php-vfs": { "manifestSha256": "97976410cb02f8ba710d856b4ac904bcf976d677b50eefdee39fb64176070d4b", "cacheKeys": { - "wasm32": "108c67e3ec4a2e5da9c847ac759818b00d48c3f3bb7845ebc8794eb53b23ea1e", - "wasm64": "8a9ae9c696fafabb82c7c5b33604e40a122d9b2080373f4fc2b65ced307402fa" + "wasm32": "694e918bc29048a8fc93ee11a5ccf518297743181718993b0543074f224ec963", + "wasm64": "69dcaef9ba3f75f50a4817936b4f8a193a2cf2603a3c99346a31505950c93cac" } }, "nginx-vfs": { "manifestSha256": "46aa2d3250ac5cd0f102a85c3a36a086c7a50ba1f9e05fa1c2aae3d07ad16f10", "cacheKeys": { - "wasm32": "ab53188e3f56bd5478da98ed4301e784be2aeccc891f16a7979975cf972b8a84", - "wasm64": "465e0eec33e629da7d41f6ec280c4924c35d756384e918f35e1d3f070603d6f9" + "wasm32": "a8b0beb30be4864ed693bb96041f9034fc3fb14cae0bf70f84ba14ba975737d2", + "wasm64": "ffe15ccbcb92c2961957645a49b939feef1fa8f8d08f9355f39a416c89670477" } }, "node": { @@ -333,8 +333,8 @@ "node-vfs": { "manifestSha256": "977f219defe57e18017ecc963159df32efdd21c53d6fea05943703d04739d8de", "cacheKeys": { - "wasm32": "5121d21b7ce93c5ce4b38c65f107032994e1fb46b97628ff85d38db49d4ec8a9", - "wasm64": "20edeaccd1939f1a6b5bec8d0d46879c4a2d71779e9a6be4f741f9e26efa17c6" + "wasm32": "0d6fcb431876cc2e4199a3ee2952a390b064cfbe4deccf9d58554a4faf0ced90", + "wasm64": "035aa481143916b1a227476476e7e4d0e17fb960e74826876507a496c1d6da3b" } }, "openssl": { @@ -361,8 +361,8 @@ "perl-vfs": { "manifestSha256": "1345cc102bc8c24a6bc276410c00b4fcc99ba5f6adc9b031f882aaa35d53c8bc", "cacheKeys": { - "wasm32": "87899325328a8b2d1445de8a44467ec9e61d776b8be809b42734453440f7cee2", - "wasm64": "38d9e4d30a540dfa40f427b0778fba00eec3536ebdf08940fe83c5cf2dd8906a" + "wasm32": "a338549c159a52b9f76840200077be97854353e7b508428ab56f161e1f259dd8", + "wasm64": "9e84630f80a2eb7985471bbf09b4439e9017448d35bea657ed31904423b4549d" } }, "php": { @@ -382,8 +382,8 @@ "python-vfs": { "manifestSha256": "d1aa99feae65f06c0ca8cf52c2176534b2723fd4ee6eba6e72c205245e1c9c26", "cacheKeys": { - "wasm32": "2d3916c174c6578a83ff7fd5ad4508fe4ebb9291cc6e4e5a62282221fefc65b7", - "wasm64": "8d4f7d23ca9c790d5da4a2d23015cfc38508a900fe9d4329717169dcc2dd25f4" + "wasm32": "e73cb09d67db2e82cf77371824d94955bfc64ee38db34b984540cd244f9845d3", + "wasm64": "f81e1c62853eee86a69201a4eb7f3f8df5fd4fb45864b9bb81d085f836bb8aed" } }, "redis": { @@ -396,15 +396,15 @@ "redis-vfs": { "manifestSha256": "234c45c40f94e2a89f98295313b82cb9fce15a412ac829d9e87394e0dc731813", "cacheKeys": { - "wasm32": "63470d7f6aa96b5d49e68d2e4d0e18f08e5529d669f40f2941a4a8ca46dfd14f", - "wasm64": "b37a5d0280fa55f33c670b51368fb0027c76fa17d61e762c8420a6616d3664e2" + "wasm32": "e90fb913cea9c21e7caea6c1b18d292568d198831fa76a8e1a7d8351c04a9567", + "wasm64": "184ceff8dadc0b20ed28f3d95bd0801f88e96922965ee31f53c2526cd7e2d250" } }, "rootfs": { "manifestSha256": "0420201025170f48c32949ac62707d4577cdc13a53ad1f01f59d318ec07c8d6e", "cacheKeys": { - "wasm32": "868c7b8e5534a14d9adb3fbb67dafa98aa295eec0842c9bad65ba053c8abd9f2", - "wasm64": "23ec1ae7a76759ce48a0d666bbd97ba77395423955e5528c04496100ff3de87d" + "wasm32": "b42d7c0384065715380ef5971b4ca6918b44d0541c51568a1b787fcad9e3deab", + "wasm64": "f04f603cfa6f995597d9accea00c988e53f1003dc609fe11b99188105173537f" } }, "ruby": { @@ -452,8 +452,8 @@ "shell": { "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", "cacheKeys": { - "wasm32": "f368afda165c066ff742b0d7e401118a270002cf853a8549b4a0d220e2b34a7a", - "wasm64": "37e3dcc8d23babcde0b50528c109d138096916a719e0b5e810bf105b826497b2" + "wasm32": "34ee75fdfa40e68f9577a7cd30469736b431244409f83525b437481a3bd9f236", + "wasm64": "367fa63c64e9b335f161d4b9eed7a3c4c2bd52ef04283a562ed7c5d41dd49ad0" } }, "spidermonkey": { @@ -543,8 +543,8 @@ "wordpress": { "manifestSha256": "36465e0596a06e855a8524eecfd87da98643bc13b37ee12939f5aab44bf33418", "cacheKeys": { - "wasm32": "2ab149a6a7825dff222edc0530085cc154dd2907b14c82cd03339d31e5a86e1d", - "wasm64": "edc7a8112fd08bcf82e211800e28e63964c41f72f3b3415720ea19750e63b083" + "wasm32": "486b19a6e1f89bdca70a3881b86a05242e698409ebe89f39948c9aeafe62284d", + "wasm64": "ffb7d26d11d224c70e1a6c7249c520954ea26ac1fb3f30f17f62cf0aab5a03d0" } }, "xz": { @@ -871,7 +871,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e7def6b3619e031084290c717f6cb79de6bdc36a1463a65863143cfb4ebbd7f7" + "wasm32": "eb1c457e4ba3400b5f8a4f149e63020a344c7ef9d215dda6bd06e32fe665e48c" }, "dependencyClosures": { "wasm32": [ @@ -1094,7 +1094,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "698a5ef53195c472a0ba2a3e00820758fe5fbaac50aa47cd7cd00693ffbf3e83" + "wasm32": "5d07e216601e1466c300e439002aafb23df3193db1b4f767cf5454390df0f472" }, "dependencyClosures": { "wasm32": [ @@ -1121,7 +1121,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "93ca83b243bc11a75e4ebb73acbb1f3d08dd59ad20df436892647110adee181e" + "wasm32": "04f6fb7e0f86ae44b016b26ccb8daa5eb1a21f0cc607de1aa630ef3ede8b7715" }, "dependencyClosures": { "wasm32": [ @@ -1193,7 +1193,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "f368afda165c066ff742b0d7e401118a270002cf853a8549b4a0d220e2b34a7a" + "cacheKey": "34ee75fdfa40e68f9577a7cd30469736b431244409f83525b437481a3bd9f236" }, { "packageName": "sqlite", @@ -1360,7 +1360,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e9cb9f5b1c3efa9a3916f2ce81f6012a392bce284afa31ea132edcb6fb10ab02" + "wasm32": "01f3400be98fe966cd9ac2213c04cef76ba72c52a7aeb8a4591d598945bc137f" }, "dependencyClosures": { "wasm32": [ @@ -1413,8 +1413,8 @@ "wasm64" ], "cacheKeys": { - "wasm32": "a053305528534ac1aa3efb3a9eb4bbee21f915301588861e5365e454422e32f8", - "wasm64": "5fb381f76ab397f36319d50c1c35d02ddf9046fa3972c6c30f960bb15e99d455" + "wasm32": "de98d36939b492929f2f1433d8ee62e5dd25ae7ed6a968e2ed0feb2cb9c8b1c6", + "wasm64": "3e022eec241af103e25a3b90686698c6c8d5d60dfaca22611d2be6fe03b50fac" }, "dependencyClosures": { "wasm32": [ @@ -1746,7 +1746,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "108c67e3ec4a2e5da9c847ac759818b00d48c3f3bb7845ebc8794eb53b23ea1e" + "wasm32": "694e918bc29048a8fc93ee11a5ccf518297743181718993b0543074f224ec963" }, "dependencyClosures": { "wasm32": [ @@ -1808,7 +1808,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "f368afda165c066ff742b0d7e401118a270002cf853a8549b4a0d220e2b34a7a" + "cacheKey": "34ee75fdfa40e68f9577a7cd30469736b431244409f83525b437481a3bd9f236" }, { "packageName": "sqlite", @@ -1838,7 +1838,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ab53188e3f56bd5478da98ed4301e784be2aeccc891f16a7979975cf972b8a84" + "wasm32": "a8b0beb30be4864ed693bb96041f9034fc3fb14cae0bf70f84ba14ba975737d2" }, "dependencyClosures": { "wasm32": [ @@ -1860,7 +1860,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "f368afda165c066ff742b0d7e401118a270002cf853a8549b4a0d220e2b34a7a" + "cacheKey": "34ee75fdfa40e68f9577a7cd30469736b431244409f83525b437481a3bd9f236" } ] }, @@ -1922,7 +1922,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "5121d21b7ce93c5ce4b38c65f107032994e1fb46b97628ff85d38db49d4ec8a9" + "wasm32": "0d6fcb431876cc2e4199a3ee2952a390b064cfbe4deccf9d58554a4faf0ced90" }, "dependencyClosures": { "wasm32": [ @@ -1944,7 +1944,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "f368afda165c066ff742b0d7e401118a270002cf853a8549b4a0d220e2b34a7a" + "cacheKey": "34ee75fdfa40e68f9577a7cd30469736b431244409f83525b437481a3bd9f236" }, { "packageName": "spidermonkey", @@ -1995,7 +1995,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "87899325328a8b2d1445de8a44467ec9e61d776b8be809b42734453440f7cee2" + "wasm32": "a338549c159a52b9f76840200077be97854353e7b508428ab56f161e1f259dd8" }, "dependencyClosures": { "wasm32": [ @@ -2418,7 +2418,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2d3916c174c6578a83ff7fd5ad4508fe4ebb9291cc6e4e5a62282221fefc65b7" + "wasm32": "e73cb09d67db2e82cf77371824d94955bfc64ee38db34b984540cd244f9845d3" }, "dependencyClosures": { "wasm32": [ @@ -2478,7 +2478,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "63470d7f6aa96b5d49e68d2e4d0e18f08e5529d669f40f2941a4a8ca46dfd14f" + "wasm32": "e90fb913cea9c21e7caea6c1b18d292568d198831fa76a8e1a7d8351c04a9567" }, "dependencyClosures": { "wasm32": [ @@ -2515,7 +2515,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "868c7b8e5534a14d9adb3fbb67dafa98aa295eec0842c9bad65ba053c8abd9f2" + "wasm32": "b42d7c0384065715380ef5971b4ca6918b44d0541c51568a1b787fcad9e3deab" }, "dependencyClosures": { "wasm32": [ @@ -2728,7 +2728,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f368afda165c066ff742b0d7e401118a270002cf853a8549b4a0d220e2b34a7a" + "wasm32": "34ee75fdfa40e68f9577a7cd30469736b431244409f83525b437481a3bd9f236" }, "dependencyClosures": { "wasm32": [] @@ -3020,7 +3020,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2ab149a6a7825dff222edc0530085cc154dd2907b14c82cd03339d31e5a86e1d" + "wasm32": "486b19a6e1f89bdca70a3881b86a05242e698409ebe89f39948c9aeafe62284d" }, "dependencyClosures": { "wasm32": [ @@ -3082,7 +3082,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "f368afda165c066ff742b0d7e401118a270002cf853a8549b4a0d220e2b34a7a" + "cacheKey": "34ee75fdfa40e68f9577a7cd30469736b431244409f83525b437481a3bd9f236" }, { "packageName": "sqlite", From 5a8bdc8442fbae677aa08f8b4685f59e034c2179 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Mon, 10 Aug 2026 23:16:46 -0400 Subject: [PATCH 60/82] POSIX: Make process credentials authoritative --- abi/snapshot.json | 30 +- .../test/kernel-scratch-runtime.spec.ts | 41 + crates/kernel/src/channel_scratch.rs | 64 +- crates/kernel/src/credentials.rs | 458 +++++++++++ crates/kernel/src/fork.rs | 491 +++++++++--- crates/kernel/src/lib.rs | 1 + crates/kernel/src/process.rs | 113 ++- crates/kernel/src/process_table.rs | 105 ++- crates/kernel/src/procfs.rs | 45 +- crates/kernel/src/pty.rs | 44 +- crates/kernel/src/signal.rs | 2 +- crates/kernel/src/syscalls.rs | 757 +++++++++++------- crates/kernel/src/wasm_api.rs | 174 ++-- crates/shared/src/channel_scalar.rs | 17 + crates/shared/src/host_abi.rs | 61 +- docs/abi-versioning.md | 17 + docs/posix-status.md | 10 +- examples/kernel_scratch_browser_test.c | 53 +- host/src/generated/abi.ts | 7 +- host/src/kernel-worker.ts | 168 +--- host/test/host-process-pointer-width.test.ts | 19 + host/test/kernel-scratch-contract.test.ts | 74 ++ host/test/kernel-scratch-runtime.test.ts | 6 + ...kernel-scratch-transfer-boundaries.test.ts | 182 +++-- libc/glue/syscall_glue.c | 6 +- libc/glue/syscall_imports.h | 3 +- .../include/bits/kandelo_channel_scalars.h | 5 + packages/registry/program-packages.json | 716 ++++++++--------- tools/xtask/src/dump_abi.rs | 21 +- 29 files changed, 2577 insertions(+), 1113 deletions(-) create mode 100644 crates/kernel/src/credentials.rs diff --git a/abi/snapshot.json b/abi/snapshot.json index e712850899..15e5ae8e33 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -456,6 +456,17 @@ "number": 128, "result": "i32" }, + { + "arguments": [ + { + "index": 0, + "kind": "process-size" + } + ], + "musl_name": "getgroups", + "number": 135, + "result": "i32" + }, { "arguments": [ { @@ -2255,7 +2266,7 @@ { "kind": "func", "name": "kernel_getgroups", - "signature": "(i32,i32,i32) -> (i32)" + "signature": "(i32,i32) -> (i32)" }, { "kind": "func", @@ -7457,6 +7468,23 @@ } } ], + "135": [ + { + "argIndex": 1, + "copyOutLength": { + "maxValue": 32, + "multiplier": 4, + "type": "return-value" + }, + "direction": "out", + "required": true, + "size": { + "argIndex": 0, + "multiplier": 4, + "type": "arg" + } + } + ], "136": [ { "argIndex": 1, diff --git a/apps/browser-demos/test/kernel-scratch-runtime.spec.ts b/apps/browser-demos/test/kernel-scratch-runtime.spec.ts index 6d172ca56d..c890afe6f4 100644 --- a/apps/browser-demos/test/kernel-scratch-runtime.spec.ts +++ b/apps/browser-demos/test/kernel-scratch-runtime.spec.ts @@ -222,3 +222,44 @@ for (const program of programs) { expect(runtimeErrors).toEqual([]); }); } + +for (const program of programs) { + test(`complete group lists use bounded scratch for ${program.arch}`, async ({ + page, + baseURL, + browserName, + }) => { + test.skip( + browserName === "webkit" && program.arch === "wasm64", + "WebKit rejects the fixture because Memory64 is not enabled", + ); + expect(baseURL).toBeTruthy(); + await page.goto(new URL("/pages/test-runner/?minimal=1", baseURL).href); + await page.waitForFunction(() => (window as any).__testRunnerReady === true); + + const programUrl = new URL(`/@fs/${program.path}`, baseURL).href; + const result = await page.evaluate( + async ({ programUrl }) => { + const response = await fetch(programUrl); + if (!response.ok) { + throw new Error( + `program fetch failed: ${response.status} ${response.url}`, + ); + } + return (window as any).__runTest( + await response.arrayBuffer(), + ["kernel-scratch-browser-test", "groups"], + 30_000, + ); + }, + { programUrl }, + ); + + expect(result.exitCode, JSON.stringify(result)).toBe(0); + expect(result.stdout).toContain( + `KERNEL_SCRATCH_GROUPS_PASS pointer_bits=${program.arch === "wasm64" ? 64 : 32} groups=32`, + ); + expect(result.stderr).toBe(""); + expect(result.hostDiagnostics).toEqual([]); + }); +} diff --git a/crates/kernel/src/channel_scratch.rs b/crates/kernel/src/channel_scratch.rs index 73afb85a65..8a4389894f 100644 --- a/crates/kernel/src/channel_scratch.rs +++ b/crates/kernel/src/channel_scratch.rs @@ -608,29 +608,6 @@ fn validate_special_layout( number if number == Syscall::Sendmsg as u32 || number == Syscall::Recvmsg as u32 => unsafe { validate_message_layout(args, region) }, - number if number == Syscall::Getgroups as u32 => { - let mut validated = ValidatedChannelScratchArgs::new(); - let count = checked_size_scalar(args[0])?; - if count == 0 { - if checked_pointer(args[1])? != 0 || args[2] != 0 { - return Err(Errno::EFAULT); - } - validated.mark_null(1)?; - } else { - if args[2] != size_of::() as i64 { - return Err(Errno::EINVAL); - } - checked_exact_range( - &mut validated, - args, - 1, - region.start, - size_of::(), - region, - )?; - } - Ok(validated) - } number if number == Syscall::Select as u32 => validate_select_layout(args, region, false), extended_syscalls::SYS_PSELECT6 => validate_select_layout(args, region, true), extended_syscalls::SYS_MSGRCV | extended_syscalls::SYS_MSGSND => { @@ -718,6 +695,13 @@ pub(crate) unsafe fn validate_channel_scratch_arguments( args: &[i64; 6], region: ChannelScratchRegion, ) -> Result { + if matches!( + syscall_number, + number if number == Syscall::Getgroups as u32 || number == Syscall::Setgroups as u32 + ) && checked_size_scalar(args[0])? > crate::credentials::NGROUPS_MAX + { + return Err(Errno::EINVAL); + } // PR_SET_NAME and PR_GET_NAME use arg 1 as the generated fixed-size name // pointer, while other prctl options use the same slot as a scalar. A // generic pointer descriptor would either dereference a scalar or fail to @@ -827,6 +811,40 @@ mod tests { } } + #[test] + fn group_descriptors_prove_complete_vectors_and_reject_oversized_counts() { + let bytes = vec![0u8; crate::credentials::NGROUPS_MAX * size_of::()]; + let start = bytes.as_ptr() as usize; + let region = ChannelScratchRegion::new(start, bytes.len()).unwrap(); + + let mut getgroups = [0i64; 6]; + getgroups[0] = 3; + getgroups[1] = pointer_arg(start); + let validated = unsafe { + validate_channel_scratch_arguments(Syscall::Getgroups as u32, &getgroups, region) + } + .unwrap(); + assert_eq!(validated.pointer(1), Ok(start)); + + getgroups[0] = (crate::credentials::NGROUPS_MAX + 1) as i64; + getgroups[2] = size_of::() as i64; + assert_eq!( + unsafe { + validate_channel_scratch_arguments(Syscall::Getgroups as u32, &getgroups, region) + }, + Err(Errno::EINVAL), + ); + + let mut setgroups = [0i64; 6]; + setgroups[0] = 3; + setgroups[1] = pointer_arg(start); + let validated = unsafe { + validate_channel_scratch_arguments(Syscall::Setgroups as u32, &setgroups, region) + } + .unwrap(); + assert_eq!(validated.pointer(1), Ok(start)); + } + #[test] fn zero_iovec_count_ignores_pointer_without_reading_it() { let bytes = [0u8; 1]; diff --git a/crates/kernel/src/credentials.rs b/crates/kernel/src/credentials.rs new file mode 100644 index 0000000000..dc3675463f --- /dev/null +++ b/crates/kernel/src/credentials.rs @@ -0,0 +1,458 @@ +extern crate alloc; + +use alloc::vec::Vec; +use wasm_posix_shared::Errno; + +pub const NGROUPS_MAX: usize = 32; +pub const ID_UNCHANGED: u32 = u32::MAX; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Credentials { + pub ruid: u32, + pub euid: u32, + pub suid: u32, + pub rgid: u32, + pub egid: u32, + pub sgid: u32, + pub supplementary_groups: Vec, +} + +impl Credentials { + pub fn root() -> Self { + Self::from_ids(0, 0) + } + + pub fn from_ids(uid: u32, gid: u32) -> Self { + Self { + ruid: uid, + euid: uid, + suid: uid, + rgid: gid, + egid: gid, + sgid: gid, + supplementary_groups: Vec::new(), + } + } + + pub fn is_member_of_group(&self, gid: u32) -> bool { + gid == self.egid || self.supplementary_groups.contains(&gid) + } + + pub fn setuid(&mut self, uid: u32) -> Result<(), Errno> { + let mut candidate = self.clone(); + if self.euid == 0 { + candidate.ruid = uid; + candidate.euid = uid; + candidate.suid = uid; + } else if uid == self.ruid || uid == self.suid { + candidate.euid = uid; + } else { + return Err(Errno::EPERM); + } + *self = candidate; + Ok(()) + } + + pub fn seteuid(&mut self, uid: u32) -> Result<(), Errno> { + let mut candidate = self.clone(); + if self.euid == 0 || uid == self.ruid || uid == self.suid { + candidate.euid = uid; + } else { + return Err(Errno::EPERM); + } + *self = candidate; + Ok(()) + } + + pub fn setresuid(&mut self, ruid: u32, euid: u32, suid: u32) -> Result<(), Errno> { + if self.euid != 0 + && [ruid, euid, suid].into_iter().any(|requested| { + requested != ID_UNCHANGED + && requested != self.ruid + && requested != self.euid + && requested != self.suid + }) + { + return Err(Errno::EPERM); + } + + let mut candidate = self.clone(); + if ruid != ID_UNCHANGED { + candidate.ruid = ruid; + } + if euid != ID_UNCHANGED { + candidate.euid = euid; + } + if suid != ID_UNCHANGED { + candidate.suid = suid; + } + *self = candidate; + Ok(()) + } + + pub fn setreuid(&mut self, ruid: u32, euid: u32) -> Result<(), Errno> { + if self.euid != 0 { + let real_allowed = ruid == ID_UNCHANGED || ruid == self.ruid; + let effective_allowed = euid == ID_UNCHANGED + || euid == self.ruid + || euid == self.euid + || euid == self.suid; + if !real_allowed || !effective_allowed { + return Err(Errno::EPERM); + } + } + + let mut candidate = self.clone(); + if ruid != ID_UNCHANGED { + candidate.ruid = ruid; + } + if euid != ID_UNCHANGED { + candidate.euid = euid; + } + if ruid != ID_UNCHANGED + || (euid != ID_UNCHANGED && candidate.euid != candidate.ruid) + { + candidate.suid = candidate.euid; + } + *self = candidate; + Ok(()) + } + + pub fn setgid(&mut self, gid: u32) -> Result<(), Errno> { + let mut candidate = self.clone(); + if self.euid == 0 { + candidate.rgid = gid; + candidate.egid = gid; + candidate.sgid = gid; + } else if gid == self.rgid || gid == self.sgid { + candidate.egid = gid; + } else { + return Err(Errno::EPERM); + } + *self = candidate; + Ok(()) + } + + pub fn setegid(&mut self, gid: u32) -> Result<(), Errno> { + let mut candidate = self.clone(); + if self.euid == 0 || gid == self.rgid || gid == self.sgid { + candidate.egid = gid; + } else { + return Err(Errno::EPERM); + } + *self = candidate; + Ok(()) + } + + pub fn setresgid(&mut self, rgid: u32, egid: u32, sgid: u32) -> Result<(), Errno> { + if self.euid != 0 + && [rgid, egid, sgid].into_iter().any(|requested| { + requested != ID_UNCHANGED + && requested != self.rgid + && requested != self.egid + && requested != self.sgid + }) + { + return Err(Errno::EPERM); + } + + let mut candidate = self.clone(); + if rgid != ID_UNCHANGED { + candidate.rgid = rgid; + } + if egid != ID_UNCHANGED { + candidate.egid = egid; + } + if sgid != ID_UNCHANGED { + candidate.sgid = sgid; + } + *self = candidate; + Ok(()) + } + + pub fn setregid(&mut self, rgid: u32, egid: u32) -> Result<(), Errno> { + if self.euid != 0 { + let real_allowed = + rgid == ID_UNCHANGED || rgid == self.rgid || rgid == self.sgid; + let effective_allowed = egid == ID_UNCHANGED + || egid == self.rgid + || egid == self.egid + || egid == self.sgid; + if !real_allowed || !effective_allowed { + return Err(Errno::EPERM); + } + } + + let mut candidate = self.clone(); + if rgid != ID_UNCHANGED { + candidate.rgid = rgid; + } + if egid != ID_UNCHANGED { + candidate.egid = egid; + } + if rgid != ID_UNCHANGED + || (egid != ID_UNCHANGED && candidate.egid != candidate.rgid) + { + candidate.sgid = candidate.egid; + } + *self = candidate; + Ok(()) + } + + pub fn setgroups(&mut self, groups: &[u32]) -> Result<(), Errno> { + if self.euid != 0 { + return Err(Errno::EPERM); + } + if groups.len() > NGROUPS_MAX { + return Err(Errno::EINVAL); + } + + let mut candidate = self.clone(); + candidate.supplementary_groups = groups.to_vec(); + *self = candidate; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::{Credentials, ID_UNCHANGED, NGROUPS_MAX}; + use wasm_posix_shared::Errno; + + fn nonroot() -> Credentials { + Credentials { + ruid: 1000, + euid: 2000, + suid: 3000, + rgid: 4000, + egid: 5000, + sgid: 6000, + supplementary_groups: alloc::vec![7000, 8000], + } + } + + #[test] + fn credentials_uid_transition_table_is_atomic() { + let mut root = Credentials::root(); + assert_eq!(root.setuid(1000), Ok(())); + assert_eq!((root.ruid, root.euid, root.suid), (1000, 1000, 1000)); + + let mut nonroot = nonroot(); + assert_eq!(nonroot.seteuid(1000), Ok(())); + assert_eq!( + (nonroot.ruid, nonroot.euid, nonroot.suid), + (1000, 1000, 3000) + ); + assert_eq!(nonroot.seteuid(3000), Ok(())); + assert_eq!( + (nonroot.ruid, nonroot.euid, nonroot.suid), + (1000, 3000, 3000) + ); + assert_eq!(nonroot.setuid(1000), Ok(())); + assert_eq!( + (nonroot.ruid, nonroot.euid, nonroot.suid), + (1000, 1000, 3000) + ); + + let before = nonroot.clone(); + assert_eq!(nonroot.setuid(9000), Err(Errno::EPERM)); + assert_eq!(nonroot, before); + + assert_eq!( + nonroot.setresuid(ID_UNCHANGED, 3000, ID_UNCHANGED), + Ok(()), + ); + assert_eq!( + (nonroot.ruid, nonroot.euid, nonroot.suid), + (1000, 3000, 3000) + ); + + let before = nonroot.clone(); + assert_eq!( + nonroot.setresuid(ID_UNCHANGED, 9000, ID_UNCHANGED), + Err(Errno::EPERM), + ); + assert_eq!(nonroot, before); + + let mut root = Credentials::root(); + assert_eq!(root.setresuid(1000, 1000, 1000), Ok(())); + assert_eq!((root.ruid, root.euid, root.suid), (1000, 1000, 1000)); + } + + #[test] + fn credentials_gid_transition_table_is_atomic() { + let mut root = Credentials::root(); + assert_eq!(root.setgid(4000), Ok(())); + assert_eq!((root.rgid, root.egid, root.sgid), (4000, 4000, 4000)); + + let mut nonroot = nonroot(); + assert_eq!(nonroot.setegid(4000), Ok(())); + assert_eq!( + (nonroot.rgid, nonroot.egid, nonroot.sgid), + (4000, 4000, 6000) + ); + assert_eq!(nonroot.setegid(6000), Ok(())); + assert_eq!( + (nonroot.rgid, nonroot.egid, nonroot.sgid), + (4000, 6000, 6000) + ); + assert_eq!(nonroot.setgid(4000), Ok(())); + assert_eq!( + (nonroot.rgid, nonroot.egid, nonroot.sgid), + (4000, 4000, 6000) + ); + + let before = nonroot.clone(); + assert_eq!(nonroot.setgid(9000), Err(Errno::EPERM)); + assert_eq!(nonroot, before); + + assert_eq!( + nonroot.setresgid(ID_UNCHANGED, 6000, ID_UNCHANGED), + Ok(()), + ); + assert_eq!( + (nonroot.rgid, nonroot.egid, nonroot.sgid), + (4000, 6000, 6000) + ); + + let before = nonroot.clone(); + assert_eq!( + nonroot.setresgid(ID_UNCHANGED, 9000, ID_UNCHANGED), + Err(Errno::EPERM), + ); + assert_eq!(nonroot, before); + + let mut root = Credentials::root(); + assert_eq!(root.setresgid(4000, 4000, 4000), Ok(())); + assert_eq!((root.rgid, root.egid, root.sgid), (4000, 4000, 4000)); + } + + #[test] + fn credentials_setreuid_transition_table_updates_saved_id_atomically() { + for (ruid, euid, expected) in [ + (1000, 1000, (1000, 1000, 1000)), + (ID_UNCHANGED, 1000, (0, 1000, 1000)), + (1000, ID_UNCHANGED, (1000, 0, 0)), + ] { + let mut root = Credentials::root(); + assert_eq!(root.setreuid(ruid, euid), Ok(())); + assert_eq!((root.ruid, root.euid, root.suid), expected); + } + + for (ruid, euid, expected) in [ + (ID_UNCHANGED, 1000, (1000, 1000, 3000)), + (ID_UNCHANGED, 2000, (1000, 2000, 2000)), + (ID_UNCHANGED, 3000, (1000, 3000, 3000)), + (1000, 1000, (1000, 1000, 1000)), + ] { + let mut credentials = nonroot(); + assert_eq!(credentials.setreuid(ruid, euid), Ok(())); + assert_eq!( + (credentials.ruid, credentials.euid, credentials.suid), + expected, + ); + } + + let mut credentials = nonroot(); + assert_eq!( + credentials.setreuid(ID_UNCHANGED, ID_UNCHANGED), + Ok(()), + ); + assert_eq!(credentials, nonroot()); + + for request in [ + (2000, ID_UNCHANGED), + (3000, ID_UNCHANGED), + (ID_UNCHANGED, 9000), + ] { + let mut credentials = nonroot(); + let before = credentials.clone(); + assert_eq!( + credentials.setreuid(request.0, request.1), + Err(Errno::EPERM), + ); + assert_eq!(credentials, before); + } + } + + #[test] + fn credentials_setregid_transition_table_updates_saved_id_atomically() { + for (rgid, egid, expected) in [ + (4000, 4000, (4000, 4000, 4000)), + (ID_UNCHANGED, 4000, (0, 4000, 4000)), + (4000, ID_UNCHANGED, (4000, 0, 0)), + ] { + let mut root = Credentials::root(); + assert_eq!(root.setregid(rgid, egid), Ok(())); + assert_eq!((root.rgid, root.egid, root.sgid), expected); + } + + for (rgid, egid, expected) in [ + (ID_UNCHANGED, 4000, (4000, 4000, 6000)), + (ID_UNCHANGED, 5000, (4000, 5000, 5000)), + (ID_UNCHANGED, 6000, (4000, 6000, 6000)), + (6000, ID_UNCHANGED, (6000, 5000, 5000)), + (4000, 4000, (4000, 4000, 4000)), + ] { + let mut credentials = nonroot(); + assert_eq!(credentials.setregid(rgid, egid), Ok(())); + assert_eq!( + (credentials.rgid, credentials.egid, credentials.sgid), + expected, + ); + } + + let mut credentials = nonroot(); + assert_eq!( + credentials.setregid(ID_UNCHANGED, ID_UNCHANGED), + Ok(()), + ); + assert_eq!(credentials, nonroot()); + + for request in [(5000, ID_UNCHANGED), (ID_UNCHANGED, 9000)] { + let mut credentials = nonroot(); + let before = credentials.clone(); + assert_eq!( + credentials.setregid(request.0, request.1), + Err(Errno::EPERM), + ); + assert_eq!(credentials, before); + } + } + + #[test] + fn supplementary_groups_preserve_order_and_enforce_the_limit_atomically() { + let mut root = Credentials::root(); + assert_eq!(root.setgroups(&[]), Ok(())); + assert!(root.supplementary_groups.is_empty()); + + let ordered = [9, 3, 9, 7]; + assert_eq!(root.setgroups(&ordered), Ok(())); + assert_eq!(root.supplementary_groups, ordered); + + let exact: alloc::vec::Vec = (0..NGROUPS_MAX as u32).collect(); + assert_eq!(root.setgroups(&exact), Ok(())); + assert_eq!(root.supplementary_groups, exact); + + let before = root.clone(); + let oversized: alloc::vec::Vec = (0..=NGROUPS_MAX as u32).collect(); + assert_eq!(root.setgroups(&oversized), Err(Errno::EINVAL)); + assert_eq!(root, before); + + let mut nonroot = nonroot(); + let before = nonroot.clone(); + assert_eq!(nonroot.setgroups(&[42]), Err(Errno::EPERM)); + assert_eq!(nonroot, before); + } + + #[test] + fn group_membership_uses_effective_primary_and_supplementary_groups() { + let credentials = nonroot(); + + assert!(credentials.is_member_of_group(5000)); + assert!(credentials.is_member_of_group(7000)); + assert!(credentials.is_member_of_group(8000)); + assert!(!credentials.is_member_of_group(4000)); + assert!(!credentials.is_member_of_group(9000)); + } +} diff --git a/crates/kernel/src/fork.rs b/crates/kernel/src/fork.rs index b6d611d0f7..b83592d354 100644 --- a/crates/kernel/src/fork.rs +++ b/crates/kernel/src/fork.rs @@ -2,8 +2,8 @@ //! //! The binary format is little-endian and consists of: //! - Header (12 bytes): magic, version, total_size -//! - Scalars (40 bytes): identity, credentials, process group/session, umask, -//! nice value, and session-leader state +//! - Identity, complete credentials, secure-exec state, process group/session, +//! umask, nice value, and session-leader state //! - Signal state (variable): blocked mask + non-default handlers //! - FD table (variable): max_fds, then each open fd entry //! - OFD table (variable): each open file description @@ -20,11 +20,13 @@ extern crate alloc; use alloc::collections::{BTreeMap, BTreeSet}; use alloc::vec::Vec; +use core::mem::size_of; use wasm_posix_shared::Errno; #[cfg(test)] use wasm_posix_shared::fd_flags::FD_CLOEXEC; use wasm_posix_shared::fd_flags::FD_CLOFORK; +use crate::credentials::{Credentials, NGROUPS_MAX}; use crate::fd::{FdEntry, FdTable, OpenFileDescRef}; use crate::lock::{FileId, KernelFileKind, OfdId}; use crate::memory::{MappedRegion, MemoryLayoutMetadata, MemoryManager}; @@ -37,12 +39,11 @@ use crate::terminal::{NCCS, TerminalState, WinSize}; const FORK_MAGIC: u32 = 0x464F524B; // "FORK" #[cfg(test)] const EXEC_MAGIC: u32 = 0x45584543; // "EXEC" -// This header version is also shared by the cfg(test) legacy exec-state -// fixture. v14 widens that fixture's directed-signal metadata to complete raw -// `union sigval` bits plus sender credentials. Production fork serialization -// still clears and omits every pending directed signal. The earlier v12 -// addition made PCM playback an OFD-owned backing retained by fork and exec. -const FORK_VERSION: u32 = 14; +// This header version is also shared by the cfg(test) exec-state fixture. +// v15 preserves complete credentials plus the kernel-owned secure-exec marker. +// Production fork serialization still clears and omits pending directed +// signals; the exec-state fixture preserves them for replacement tests. +const FORK_VERSION: u32 = 15; // Bounds for deserialization to prevent OOM from malformed buffers. const MAX_FDS: u32 = 65536; @@ -336,6 +337,70 @@ fn read_bounded_count(r: &mut Reader<'_>, max: usize) -> Result { Ok(count) } +fn write_credentials_and_secure_exec(w: &mut Writer<'_>, proc: &Process) -> Result<(), Errno> { + let credentials = proc.credentials(); + if credentials.supplementary_groups.len() > NGROUPS_MAX { + return Err(Errno::EINVAL); + } + w.write_u32(credentials.ruid)?; + w.write_u32(credentials.euid)?; + w.write_u32(credentials.suid)?; + w.write_u32(credentials.rgid)?; + w.write_u32(credentials.egid)?; + w.write_u32(credentials.sgid)?; + w.write_u32(credentials.supplementary_groups.len() as u32)?; + for group in &credentials.supplementary_groups { + w.write_u32(*group)?; + } + w.write_u32(u32::from(proc.secure_exec)) +} + +fn read_credentials_and_secure_exec(r: &mut Reader<'_>) -> Result<(Credentials, bool), Errno> { + let ruid = r.read_u32()?; + let euid = r.read_u32()?; + let suid = r.read_u32()?; + let rgid = r.read_u32()?; + let egid = r.read_u32()?; + let sgid = r.read_u32()?; + let group_count = r.read_u32()? as usize; + if group_count > NGROUPS_MAX { + return Err(Errno::EINVAL); + } + let group_bytes = group_count + .checked_mul(size_of::()) + .ok_or(Errno::EINVAL)?; + let remaining_required = group_bytes + .checked_add(size_of::()) + .ok_or(Errno::EINVAL)?; + if r.remaining() < remaining_required { + return Err(Errno::EINVAL); + } + let mut supplementary_groups = Vec::new(); + supplementary_groups + .try_reserve_exact(group_count) + .map_err(|_| Errno::ENOMEM)?; + for _ in 0..group_count { + supplementary_groups.push(r.read_u32()?); + } + let secure_exec = match r.read_u32()? { + 0 => false, + 1 => true, + _ => return Err(Errno::EINVAL), + }; + Ok(( + Credentials { + ruid, + euid, + suid, + rgid, + egid, + sgid, + supplementary_groups, + }, + secure_exec, + )) +} + fn read_ipv4_addr(r: &mut Reader<'_>) -> Result<[u8; 4], Errno> { let mut addr = [0u8; 4]; addr.copy_from_slice(r.read_bytes(4)?); @@ -787,13 +852,10 @@ pub fn serialize_fork_state(proc: &Process, buf: &mut [u8]) -> Result Result Result<(), Er if version != FORK_VERSION { return Err(Errno::EINVAL); } - let _total_size = r.read_u32()?; + let total_size = r.read_u32()? as usize; + if total_size != buf.len() { + return Err(Errno::EINVAL); + } - // ── Scalars ── + // ── Identity, credentials, and process scalars ── let ppid = r.read_u32()?; - let uid = r.read_u32()?; - let gid = r.read_u32()?; - let euid = r.read_u32()?; - let egid = r.read_u32()?; + let (credentials, secure_exec) = read_credentials_and_secure_exec(&mut r)?; let pgid = r.read_u32()?; let sid = r.read_u32()?; let umask = r.read_u32()?; @@ -1269,10 +1331,10 @@ fn deserialize_fork_state_into(buf: &[u8], child: &mut Process) -> Result<(), Er let ws_col = r.read_u16()?; let ws_xpixel = r.read_u16()?; let ws_ypixel = r.read_u16()?; - let c_line = r.read_u8().unwrap_or(0); - let c_ispeed = r.read_u32().unwrap_or(0o0000017); // B38400 - let c_ospeed = r.read_u32().unwrap_or(0o0000017); - let session_id = r.read_i32().unwrap_or(0); + let c_line = r.read_u8()?; + let c_ispeed = r.read_u32()?; + let c_ospeed = r.read_u32()?; + let session_id = r.read_i32()?; let foreground_pgid = r.read_i32()?; let terminal = TerminalState { @@ -1309,79 +1371,79 @@ fn deserialize_fork_state_into(buf: &[u8], child: &mut Process) -> Result<(), Er memory.set_layout_metadata(memory_layout); memory.set_brk(program_break as usize); - // ── mmap mappings (v5) ── - if r.remaining() >= 4 { - let mapping_count = r.read_u32()? as usize; - if mapping_count > 4096 { - return Err(Errno::EINVAL); - } - let mut mappings = Vec::with_capacity(mapping_count); - for _ in 0..mapping_count { - let addr = r.read_u32()? as usize; - let len = r.read_u32()? as usize; - let prot = r.read_u32()?; - let flags = r.read_u32()?; - mappings.push(MappedRegion { - addr, - len, - prot, - flags, - }); - } - memory.set_mappings(mappings); + // ── mmap mappings ── + let mapping_count = r.read_u32()? as usize; + if mapping_count > 4096 { + return Err(Errno::EINVAL); + } + let mapping_bytes = mapping_count.checked_mul(16).ok_or(Errno::EINVAL)?; + if r.remaining() < mapping_bytes { + return Err(Errno::EINVAL); } + let mut mappings = Vec::with_capacity(mapping_count); + for _ in 0..mapping_count { + let addr = r.read_u32()? as usize; + let len = r.read_u32()? as usize; + let prot = r.read_u32()?; + let flags = r.read_u32()?; + mappings.push(MappedRegion { + addr, + len, + prot, + flags, + }); + } + memory.set_mappings(mappings); - // ── Fork exec state (v3) ── - let fork_exec_path = if r.remaining() >= 4 { - let path_len = r.read_u32()? as usize; - if path_len > 0 { - Some(r.read_bounded_bytes(path_len, MAX_PATH_LEN)?.to_vec()) - } else { - None - } + // ── Fork exec state ── + let path_len = r.read_u32()? as usize; + let fork_exec_path = if path_len > 0 { + Some(r.read_bounded_bytes(path_len, MAX_PATH_LEN)?.to_vec()) } else { None }; - let fork_exec_argv = if r.remaining() >= 4 { - let argc = r.read_u32()? as usize; - if argc > 0 { - if argc > MAX_ARGV as usize { - return Err(Errno::EINVAL); - } - let mut args = Vec::with_capacity(argc); - for _ in 0..argc { - let len = r.read_u32()? as usize; - args.push(r.read_bounded_bytes(len, MAX_STRING_LEN)?.to_vec()); - } - Some(args) - } else { - None + let argc = r.read_u32()? as usize; + let fork_exec_argv = if argc > 0 { + if argc > MAX_ARGV as usize { + return Err(Errno::EINVAL); } + let mut args = Vec::with_capacity(argc); + for _ in 0..argc { + let len = r.read_u32()? as usize; + args.push(r.read_bounded_bytes(len, MAX_STRING_LEN)?.to_vec()); + } + Some(args) } else { None }; let mut fork_fd_actions = Vec::new(); - if r.remaining() >= 4 { - let action_count = r.read_u32()? as usize; - for _ in 0..action_count { - let action_type = r.read_u32()?; - let fd1 = r.read_u32()? as i32; - let fd2 = r.read_u32()? as i32; - use crate::process::FdAction; - match action_type { - 0 => fork_fd_actions.push(FdAction::Dup2 { - old_fd: fd1, - new_fd: fd2, - }), - 1 => fork_fd_actions.push(FdAction::Close { fd: fd1 }), - _ => {} // skip unknown actions + let action_count = r.read_u32()? as usize; + let action_bytes = action_count.checked_mul(12).ok_or(Errno::EINVAL)?; + if r.remaining() < action_bytes { + return Err(Errno::EINVAL); + } + for _ in 0..action_count { + let action_type = r.read_u32()?; + let fd1 = r.read_u32()? as i32; + let fd2 = r.read_u32()? as i32; + use crate::process::FdAction; + match action_type { + 0 => fork_fd_actions.push(FdAction::Dup2 { + old_fd: fd1, + new_fd: fd2, + }), + 1 => fork_fd_actions.push(FdAction::Close { fd: fd1 }), + 2 => { + // Open actions contain host-owned prepared state and are not + // reconstructed from the serialized compatibility record. } + _ => return Err(Errno::EINVAL), } } - // ── Socket table (v10) ── + // ── Socket table ── let mut sockets = SocketTable::new(); - if r.remaining() >= 8 { + { use crate::socket::{SocketDomain, SocketInfo, SocketState, SocketType}; let total_slots = r.read_u32()? as usize; let sock_count = r.read_u32()? as usize; @@ -1517,11 +1579,13 @@ fn deserialize_fork_state_into(buf: &[u8], child: &mut Process) -> Result<(), Er } } + if r.remaining() != 0 { + return Err(Errno::EINVAL); + } + child.ppid = ppid; - child.uid = uid; - child.gid = gid; - child.euid = euid; - child.egid = egid; + child.install_credentials(credentials); + child.secure_exec = secure_exec; child.pgid = pgid; child.sid = sid; // POSIX: fork children inherit sid but are NEVER session leaders. The @@ -1615,13 +1679,10 @@ pub fn serialize_exec_state(proc: &Process, buf: &mut [u8]) -> Result Result Result { if version != FORK_VERSION { return Err(Errno::EINVAL); } - let _total_size = r.read_u32()?; + let total_size = r.read_u32()? as usize; + if total_size != buf.len() { + return Err(Errno::EINVAL); + } - // ── Scalars ── + // ── Identity, credentials, and process scalars ── let ppid = r.read_u32()?; - let uid = r.read_u32()?; - let gid = r.read_u32()?; - let euid = r.read_u32()?; - let egid = r.read_u32()?; + let (credentials, secure_exec) = read_credentials_and_secure_exec(&mut r)?; let pgid = r.read_u32()?; let sid = r.read_u32()?; let is_session_leader = r.read_u32()? != 0; // preserved across exec @@ -1944,10 +2005,10 @@ pub fn deserialize_exec_state(buf: &[u8], pid: u32) -> Result { let ws_col = r.read_u16()?; let ws_xpixel = r.read_u16()?; let ws_ypixel = r.read_u16()?; - let c_line = r.read_u8().unwrap_or(0); - let c_ispeed = r.read_u32().unwrap_or(0o0000017); // B38400 - let c_ospeed = r.read_u32().unwrap_or(0o0000017); - let session_id = r.read_i32().unwrap_or(0); + let c_line = r.read_u8()?; + let c_ispeed = r.read_u32()?; + let c_ospeed = r.read_u32()?; + let session_id = r.read_i32()?; let foreground_pgid = r.read_i32()?; let terminal = TerminalState { @@ -1982,12 +2043,14 @@ pub fn deserialize_exec_state(buf: &[u8], pid: u32) -> Result { let _program_break = r.read_u32()?; let memory = MemoryManager::new(); + if r.remaining() != 0 { + return Err(Errno::EINVAL); + } + let mut process = Process::new_empty_for_test(pid); process.ppid = ppid; - process.uid = uid; - process.gid = gid; - process.euid = euid; - process.egid = egid; + process.install_credentials(credentials); + process.secure_exec = secure_exec; process.pgid = pgid; process.sid = sid; process.is_session_leader = is_session_leader; @@ -2043,6 +2106,11 @@ mod tests { use crate::process::Process; use crate::signal::SignalHandler; + fn rewrite_total_size(bytes: &mut [u8]) { + let total_size = u32::try_from(bytes.len()).unwrap(); + bytes[8..12].copy_from_slice(&total_size.to_le_bytes()); + } + fn install_socket_for_fork( proc: &mut Process, socket: crate::socket::SocketInfo, @@ -2074,8 +2142,8 @@ mod tests { assert!(child.wait_event.is_none()); assert_eq!(child.pid, 42); assert_eq!(child.ppid, proc.pid); // child's ppid is parent's pid - assert_eq!(child.uid, proc.uid); - assert_eq!(child.gid, proc.gid); + assert_eq!(child.real_uid(), proc.real_uid()); + assert_eq!(child.real_gid(), proc.real_gid()); assert_eq!(child.umask, proc.umask); assert_eq!(child.nice, proc.nice); assert_eq!(child.cwd, proc.cwd); @@ -2087,6 +2155,213 @@ mod tests { assert!(child.shm_mappings.is_empty()); } + #[test] + fn fork_version_15_roundtrips_complete_credentials_in_wire_order() { + let mut proc = Process::new(1); + proc.install_credentials(Credentials { + ruid: 1000, + euid: 2000, + suid: 3000, + rgid: 4000, + egid: 5000, + sgid: 6000, + supplementary_groups: vec![7000, 8000], + }); + proc.secure_exec = true; + let mut buf = vec![0u8; 64 * 1024]; + + let written = serialize_fork_state(&proc, &mut buf).unwrap(); + let child = deserialize_fork_state(&buf[..written], 42).unwrap(); + + assert_eq!(u32::from_le_bytes(buf[4..8].try_into().unwrap()), 15); + let credential_words: Vec = buf[16..52] + .chunks_exact(4) + .map(|bytes| u32::from_le_bytes(bytes.try_into().unwrap())) + .collect(); + assert_eq!( + credential_words, + vec![1000, 2000, 3000, 4000, 5000, 6000, 2, 7000, 8000], + ); + assert_eq!(u32::from_le_bytes(buf[52..56].try_into().unwrap()), 1); + assert_eq!(child.real_uid(), 1000); + assert_eq!(child.effective_uid(), 2000); + assert_eq!(child.saved_uid(), 3000); + assert_eq!(child.real_gid(), 4000); + assert_eq!(child.effective_gid(), 5000); + assert_eq!(child.saved_gid(), 6000); + assert_eq!(child.supplementary_groups(), &[7000, 8000]); + assert!(child.secure_exec); + } + + #[test] + fn fork_version_15_roundtrips_zero_and_ngroups_max_groups() { + for groups in [vec![], (0..32).map(|index| 20_000 + index).collect()] { + let mut proc = Process::new(1); + proc.install_credentials(Credentials { + supplementary_groups: groups.clone(), + ..Credentials::from_ids(1000, 2000) + }); + let mut buf = vec![0u8; 64 * 1024]; + + let written = serialize_fork_state(&proc, &mut buf).unwrap(); + let child = deserialize_fork_state(&buf[..written], 42).unwrap(); + + assert_eq!(child.supplementary_groups(), groups.as_slice()); + assert!(!child.secure_exec); + } + } + + #[test] + fn fork_version_15_rejects_wrong_version_malformed_groups_and_trailing_bytes() { + let mut proc = Process::new(1); + proc.install_credentials(Credentials { + ruid: 1000, + euid: 2000, + suid: 3000, + rgid: 4000, + egid: 5000, + sgid: 6000, + supplementary_groups: vec![7000, 8000], + }); + proc.secure_exec = true; + let mut buf = vec![0u8; 64 * 1024]; + let written = serialize_fork_state(&proc, &mut buf).unwrap(); + + for version in [14u32, 16] { + let mut malformed = buf[..written].to_vec(); + malformed[4..8].copy_from_slice(&version.to_le_bytes()); + assert!(deserialize_fork_state(&malformed, 42).is_err()); + } + for count in [33u32, u32::MAX] { + let mut malformed = buf[..written].to_vec(); + malformed[40..44].copy_from_slice(&count.to_le_bytes()); + assert!(matches!( + deserialize_fork_state(&malformed, 42), + Err(Errno::EINVAL), + )); + } + let mut trailing = buf[..written].to_vec(); + trailing.push(0xa5); + rewrite_total_size(&mut trailing); + assert!(matches!( + deserialize_fork_state(&trailing, 42), + Err(Errno::EINVAL), + )); + } + + #[test] + fn fork_version_15_rejects_truncation_at_every_new_credential_field() { + let mut proc = Process::new(1); + proc.install_credentials(Credentials { + ruid: 1000, + euid: 2000, + suid: 3000, + rgid: 4000, + egid: 5000, + sgid: 6000, + supplementary_groups: (0..32).collect(), + }); + proc.secure_exec = true; + let mut buf = vec![0u8; 64 * 1024]; + let _written = serialize_fork_state(&proc, &mut buf).unwrap(); + + // Header + ppid, then each complete v15 credential field. Every + // one-byte-short prefix must fail before a Process record is changed. + let credential_field_ends = (20usize..=44) + .step_by(4) + .chain((48usize..=172).step_by(4)) + .chain(core::iter::once(176)); + for end in credential_field_ends { + let mut truncated = buf[..end - 1].to_vec(); + rewrite_total_size(&mut truncated); + assert!( + matches!(deserialize_fork_state(&truncated, 42), Err(Errno::EINVAL),), + "accepted truncation before credential byte {end}" + ); + } + } + + #[test] + fn exec_version_15_roundtrips_complete_credentials_and_secure_exec() { + let mut proc = Process::new(1); + proc.install_credentials(Credentials { + ruid: 101, + euid: 202, + suid: 303, + rgid: 404, + egid: 505, + sgid: 606, + supplementary_groups: vec![707, 808, 909], + }); + proc.secure_exec = true; + let mut buf = vec![0u8; 64 * 1024]; + + let written = serialize_exec_state(&proc, &mut buf).unwrap(); + let restored = deserialize_exec_state(&buf[..written], proc.pid).unwrap(); + + assert_eq!(restored.credentials(), proc.credentials()); + assert!(restored.secure_exec); + } + + #[test] + fn exec_version_15_rejects_wrong_version_truncation_and_trailing_bytes() { + let mut proc = Process::new(1); + proc.install_credentials(Credentials { + ruid: 101, + euid: 202, + suid: 303, + rgid: 404, + egid: 505, + sgid: 606, + supplementary_groups: (0..32).collect(), + }); + proc.secure_exec = true; + let mut buf = vec![0u8; 64 * 1024]; + let written = serialize_exec_state(&proc, &mut buf).unwrap(); + + for version in [14u32, 16] { + let mut malformed = buf[..written].to_vec(); + malformed[4..8].copy_from_slice(&version.to_le_bytes()); + assert!(matches!( + deserialize_exec_state(&malformed, proc.pid), + Err(Errno::EINVAL), + )); + } + + for count in [33u32, u32::MAX] { + let mut malformed = buf[..written].to_vec(); + malformed[40..44].copy_from_slice(&count.to_le_bytes()); + assert!(matches!( + deserialize_exec_state(&malformed, proc.pid), + Err(Errno::EINVAL), + )); + } + + let credential_field_ends = (20usize..=44) + .step_by(4) + .chain((48usize..=172).step_by(4)) + .chain(core::iter::once(176)); + for end in credential_field_ends { + let mut truncated = buf[..end - 1].to_vec(); + rewrite_total_size(&mut truncated); + assert!( + matches!( + deserialize_exec_state(&truncated, proc.pid), + Err(Errno::EINVAL), + ), + "accepted exec truncation before credential byte {end}" + ); + } + + let mut trailing = buf[..written].to_vec(); + trailing.push(0xa5); + rewrite_total_size(&mut trailing); + assert!(matches!( + deserialize_exec_state(&trailing, proc.pid), + Err(Errno::EINVAL), + )); + } + #[test] fn test_roundtrip_with_environment() { let mut proc = Process::new(1); diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 770bfbf29f..f893e6e2d6 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -9,6 +9,7 @@ pub mod audio; pub(crate) mod blocked_retry; pub(crate) mod channel_result; pub(crate) mod channel_scratch; +pub mod credentials; pub(crate) mod descriptor_backing; pub mod devfs; pub mod dri; diff --git a/crates/kernel/src/process.rs b/crates/kernel/src/process.rs index 3df690c3fc..8e655a7535 100644 --- a/crates/kernel/src/process.rs +++ b/crates/kernel/src/process.rs @@ -7,6 +7,7 @@ use wasm_posix_shared::{ WasmStatfs, }; +use crate::credentials::Credentials; use crate::fd::FdTable; use crate::memory::MemoryManager; use crate::ofd::{FileType, OfdTable}; @@ -727,10 +728,12 @@ pub struct ProcessIdentity { pub struct Process { identity: ProcessIdentity, pub ppid: u32, - pub uid: u32, - pub gid: u32, - pub euid: u32, - pub egid: u32, + credentials: Credentials, + /// Kernel-owned secure-startup fact for the current process image. + /// + /// Task 9 only preserves this marker across process-state transport. + /// Target-aware exec commit is the sole future authority that may set it. + pub(crate) secure_exec: bool, pub pgid: u32, pub sid: u32, /// True iff this process is the session leader of its session (i.e. the @@ -1054,13 +1057,8 @@ impl Process { threads: Vec::new(), }, ppid: 0, - // Default to root (uid=0). The kernel is single-user; privilege - // drops happen explicitly via setuid/setgid and gate cross-user - // operations (kill, sched_*). - uid: 0, - gid: 0, - euid: 0, - egid: 0, + credentials: Credentials::root(), + secure_exec: false, pgid: pid, sid: 0, is_session_leader: false, @@ -1117,6 +1115,97 @@ impl Process { self.identity.pid } + pub fn real_uid(&self) -> u32 { + self.credentials.ruid + } + + pub fn effective_uid(&self) -> u32 { + self.credentials.euid + } + + pub fn saved_uid(&self) -> u32 { + self.credentials.suid + } + + pub fn real_gid(&self) -> u32 { + self.credentials.rgid + } + + pub fn effective_gid(&self) -> u32 { + self.credentials.egid + } + + pub fn saved_gid(&self) -> u32 { + self.credentials.sgid + } + + pub fn supplementary_groups(&self) -> &[u32] { + &self.credentials.supplementary_groups + } + + pub fn is_member_of_group(&self, gid: u32) -> bool { + self.credentials.is_member_of_group(gid) + } + + pub fn setuid(&mut self, uid: u32) -> Result<(), Errno> { + self.credentials.setuid(uid) + } + + pub fn seteuid(&mut self, uid: u32) -> Result<(), Errno> { + self.credentials.seteuid(uid) + } + + pub fn setresuid(&mut self, ruid: u32, euid: u32, suid: u32) -> Result<(), Errno> { + self.credentials.setresuid(ruid, euid, suid) + } + + pub fn setreuid(&mut self, ruid: u32, euid: u32) -> Result<(), Errno> { + self.credentials.setreuid(ruid, euid) + } + + pub fn setgid(&mut self, gid: u32) -> Result<(), Errno> { + self.credentials.setgid(gid) + } + + pub fn setegid(&mut self, gid: u32) -> Result<(), Errno> { + self.credentials.setegid(gid) + } + + pub fn setresgid(&mut self, rgid: u32, egid: u32, sgid: u32) -> Result<(), Errno> { + self.credentials.setresgid(rgid, egid, sgid) + } + + pub fn setregid(&mut self, rgid: u32, egid: u32) -> Result<(), Errno> { + self.credentials.setregid(rgid, egid) + } + + pub fn setgroups(&mut self, groups: &[u32]) -> Result<(), Errno> { + self.credentials.setgroups(groups) + } + + pub(crate) fn credentials(&self) -> &Credentials { + &self.credentials + } + + pub(crate) fn install_credentials(&mut self, credentials: Credentials) { + self.credentials = credentials; + } + + pub(crate) fn configure_ids(&mut self, uid: Option, gid: Option) { + let mut credentials = self.credentials.clone(); + if let Some(uid) = uid { + credentials.ruid = uid; + credentials.euid = uid; + credentials.suid = uid; + } + if let Some(gid) = gid { + credentials.rgid = gid; + credentials.egid = gid; + credentials.sgid = gid; + } + self.credentials = credentials; + } + /// Override a fixture identity without exposing a production mutation API. #[cfg(test)] pub(crate) fn set_pid_for_test(&mut self, pid: u32) { @@ -1140,7 +1229,7 @@ impl Process { wait_status, si_code, si_status, - child_uid: self.uid, + child_uid: self.real_uid(), rusage: KernelRusage::default(), }); } diff --git a/crates/kernel/src/process_table.rs b/crates/kernel/src/process_table.rs index eea591845e..30da544479 100644 --- a/crates/kernel/src/process_table.rs +++ b/crates/kernel/src/process_table.rs @@ -20,6 +20,7 @@ use core::sync::atomic::AtomicI32; use wasm_posix_shared::Errno; use wasm_posix_shared::flags::O_ACCMODE; +use crate::credentials::Credentials; use crate::lock::AdvisoryLockManager; use crate::ofd::FileType; #[cfg(test)] @@ -134,10 +135,7 @@ pub struct RemoveProcessResult { /// front under an immutable `&parent` borrow so the rest of `spawn_child` /// can mutate `self.processes` freely. struct SpawnInheritFromParent { - uid: u32, - gid: u32, - euid: u32, - egid: u32, + credentials: Credentials, pgid: u32, sid: u32, umask: u32, @@ -789,10 +787,8 @@ impl ProcessTable { fn limbo_process_from(proc: &Process) -> Process { let mut limbo = Process::new_allocated(AllocatedTaskId(proc.pid)); limbo.ppid = proc.ppid; - limbo.uid = proc.uid; - limbo.gid = proc.gid; - limbo.euid = proc.euid; - limbo.egid = proc.egid; + limbo.install_credentials(proc.credentials().clone()); + limbo.secure_exec = proc.secure_exec; limbo.pgid = proc.pgid; limbo.sid = proc.sid; limbo.is_session_leader = proc.is_session_leader; @@ -1147,10 +1143,7 @@ impl ProcessTable { } } SpawnInheritFromParent { - uid: parent.uid, - gid: parent.gid, - euid: parent.euid, - egid: parent.egid, + credentials: parent.credentials().clone(), pgid: parent.pgid, sid: parent.sid, umask: parent.umask, @@ -1171,10 +1164,7 @@ impl ProcessTable { // ── POSIX-required inheritance ───────────────────────────────── child.ppid = parent_pid; - child.uid = inherit.uid; - child.gid = inherit.gid; - child.euid = inherit.euid; - child.egid = inherit.egid; + child.install_credentials(inherit.credentials); child.pgid = inherit.pgid; // POSIX_SPAWN_SETPGROUP may override (Task 9). child.sid = inherit.sid; // POSIX_SPAWN_SETSID may override (Task 9). child.umask = inherit.umask; @@ -1670,6 +1660,44 @@ impl ProcessTable { mod wait_tests { use super::*; + #[test] + fn spawn_inherits_credentials_as_one_complete_process_record() { + use crate::credentials::Credentials; + use crate::process::test_host::NoopHost; + use crate::spawn::SpawnAttrs; + + let mut table = ProcessTable::new(); + let parent_pid = table.create_process().unwrap(); + let credentials = Credentials { + ruid: 1000, + euid: 2000, + suid: 3000, + rgid: 4000, + egid: 5000, + sgid: 6000, + supplementary_groups: vec![7000, 8000], + }; + table + .get_mut(parent_pid) + .unwrap() + .install_credentials(credentials.clone()); + + let mut host = NoopHost; + let spawn_pid = table + .spawn_child_for_caller( + parent_pid, + parent_pid, + &[b"/bin/child".as_slice()], + &[], + &[], + &SpawnAttrs::empty(), + &mut host, + ) + .unwrap(); + + assert_eq!(table.get(spawn_pid).unwrap().credentials(), &credentials); + } + #[test] fn task_ids_are_shared_by_create_clone_fork_and_spawn() { use crate::process::test_host::NoopHost; @@ -2085,6 +2113,51 @@ pub fn current_pid() -> u32 { mod tests { use super::*; + #[test] + fn vfork_child_credential_mutation_isolated_from_parent_record() { + use wasm_posix_shared::fork_contract::Mode; + + let mut table = ProcessTable::new(); + let parent_pid = table.create_process().unwrap(); + let parent_credentials = Credentials { + ruid: 1000, + euid: 0, + suid: 3000, + rgid: 4000, + egid: 5000, + sgid: 6000, + supplementary_groups: vec![7000, 8000], + }; + { + let parent = table.get_mut(parent_pid).unwrap(); + parent.install_credentials(parent_credentials.clone()); + parent.secure_exec = true; + } + + let child_pid = table + .fork_process_for_caller_with_mode(parent_pid, parent_pid, Mode::Vfork) + .unwrap(); + { + let child = table.get_mut(child_pid).unwrap(); + assert_eq!(child.credentials(), &parent_credentials); + assert!(child.secure_exec); + child.setgroups(&[42, 43]).unwrap(); + child.setresuid(9000, 9001, 9002).unwrap(); + child.secure_exec = false; + } + + let parent = table.get(parent_pid).unwrap(); + assert_eq!(parent.credentials(), &parent_credentials); + assert!(parent.secure_exec); + let child = table.get(child_pid).unwrap(); + assert_eq!( + (child.real_uid(), child.effective_uid(), child.saved_uid()), + (9000, 9001, 9002), + ); + assert_eq!(child.supplementary_groups(), &[42, 43]); + assert!(!child.secure_exec); + } + #[test] fn fork_pipe_replay_includes_fds_above_default_nofile_limit() { use crate::fd::OpenFileDescRef; diff --git a/crates/kernel/src/procfs.rs b/crates/kernel/src/procfs.rs index 0cd21bbbc3..5af3c82082 100644 --- a/crates/kernel/src/procfs.rs +++ b/crates/kernel/src/procfs.rs @@ -331,6 +331,12 @@ pub fn generate_status(proc: &Process) -> Vec { use alloc::format; let name = process_name(proc); + let groups = proc + .supplementary_groups() + .iter() + .map(|gid| format!("{gid}")) + .collect::>() + .join(" "); let state_str = match proc.state { crate::process::ProcessState::Running => "R (running)", @@ -349,6 +355,7 @@ pub fn generate_status(proc: &Process) -> Vec { TracerPid:\t0\n\ Uid:\t{}\t{}\t{}\t{}\n\ Gid:\t{}\t{}\t{}\t{}\n\ + Groups:\t{}\n\ FDSize:\t{}\n\ VmSize:\t0 kB\n\ Threads:\t{}\n\ @@ -360,14 +367,15 @@ pub fn generate_status(proc: &Process) -> Vec { proc.pid, proc.pid, proc.ppid, - proc.uid, - proc.euid, - proc.euid, - proc.euid, - proc.gid, - proc.egid, - proc.egid, - proc.egid, + proc.real_uid(), + proc.effective_uid(), + proc.saved_uid(), + proc.effective_uid(), + proc.real_gid(), + proc.effective_gid(), + proc.saved_gid(), + proc.effective_gid(), + groups, count_open_fds(&proc.fd_table), 1 + proc.threads.len(), // main thread + spawned threads proc.pending_for(proc.pid), @@ -1094,6 +1102,7 @@ fn count_open_fds(fd_table: &crate::fd::FdTable) -> usize { #[cfg(test)] mod tests { use super::*; + use crate::credentials::Credentials; use crate::process::Process; fn dirent_len(name: &[u8]) -> usize { @@ -1288,6 +1297,26 @@ mod tests { assert!(status_str.contains("SigPnd:\t0000000001000002\n")); } + #[test] + fn credentials_process_inspection_reports_saved_ids_and_ordered_groups() { + let mut proc = Process::new(41); + proc.install_credentials(Credentials { + ruid: 1000, + euid: 2000, + suid: 3000, + rgid: 4000, + egid: 5000, + sgid: 6000, + supplementary_groups: vec![7000, 8000, 7000], + }); + + let status = generate_status(&proc); + let status = core::str::from_utf8(&status).unwrap(); + assert!(status.contains("Uid:\t1000\t2000\t3000\t2000\n")); + assert!(status.contains("Gid:\t4000\t5000\t6000\t5000\n")); + assert!(status.contains("Groups:\t7000 8000 7000\n")); + } + #[test] fn stopped_process_uses_linux_t_state_in_stat_and_status() { let mut proc = Process::new(44); diff --git a/crates/kernel/src/pty.rs b/crates/kernel/src/pty.rs index c61e9ade9e..bd92ae145c 100644 --- a/crates/kernel/src/pty.rs +++ b/crates/kernel/src/pty.rs @@ -16,6 +16,9 @@ const PTY_BUF_CAPACITY: usize = 4096; /// master write → line discipline → slave read (input: keyboard → program) /// slave write → output processing → master read (output: program → screen) pub struct PtyPair { + /// Stable owner captured from the creator's effective credentials. + owner_uid: u32, + owner_gid: u32, /// Terminal state (termios attributes, winsize, foreground pgrp). pub terminal: TerminalState, /// Input buffer: data written by master, readable from slave (after line discipline). @@ -31,8 +34,10 @@ pub struct PtyPair { } impl PtyPair { - fn new() -> Self { + fn new(owner_uid: u32, owner_gid: u32) -> Self { PtyPair { + owner_uid, + owner_gid, terminal: TerminalState::new(), input_buf: VecDeque::with_capacity(PTY_BUF_CAPACITY), output_buf: VecDeque::with_capacity(PTY_BUF_CAPACITY), @@ -42,6 +47,14 @@ impl PtyPair { } } + pub fn owner_uid(&self) -> u32 { + self.owner_uid + } + + pub fn owner_gid(&self) -> u32 { + self.owner_gid + } + /// Process a byte through the line discipline (for master→slave input). /// Returns an optional signal number if ISIG matched a signal character. /// Echo bytes are appended to the output buffer (master read side). @@ -204,11 +217,11 @@ fn get_table() -> &'static mut [Option; MAX_PTYS] { } /// Allocate a new PTY pair. Returns the index (pty number) or None if full. -pub fn alloc_pty() -> Option { +pub fn alloc_pty(owner_uid: u32, owner_gid: u32) -> Option { let table = get_table(); for (i, slot) in table.iter_mut().enumerate() { if slot.is_none() { - *slot = Some(PtyPair::new()); + *slot = Some(PtyPair::new(owner_uid, owner_gid)); return Some(i); } } @@ -258,18 +271,19 @@ mod tests { let _pty_table = test_table_lock(); reset_table(); - let idx = alloc_pty().unwrap(); + let idx = alloc_pty(1000, 2000).unwrap(); assert_eq!(idx, 0); let pty = get_pty(idx).unwrap(); assert!(pty.locked); + assert_eq!((pty.owner_uid(), pty.owner_gid()), (1000, 2000)); assert_eq!(pty.master_refs, 0); assert_eq!(pty.slave_refs, 0); - let idx2 = alloc_pty().unwrap(); + let idx2 = alloc_pty(3000, 4000).unwrap(); assert_eq!(idx2, 1); free_pty(idx); - let idx3 = alloc_pty().unwrap(); + let idx3 = alloc_pty(5000, 6000).unwrap(); assert_eq!(idx3, 0); // reuses freed slot reset_table(); @@ -280,7 +294,7 @@ mod tests { let _pty_table = test_table_lock(); reset_table(); - let idx = alloc_pty().unwrap(); + let idx = alloc_pty(0, 0).unwrap(); let pty = get_pty(idx).unwrap(); pty.terminal.c_lflag &= !crate::terminal::ICANON; // raw mode pty.terminal.c_lflag &= !crate::terminal::ECHO; // no echo @@ -308,7 +322,7 @@ mod tests { let _pty_table = test_table_lock(); reset_table(); - let idx = alloc_pty().unwrap(); + let idx = alloc_pty(0, 0).unwrap(); let pty = get_pty(idx).unwrap(); // Default is canonical mode with echo @@ -333,7 +347,7 @@ mod tests { #[test] fn test_pty_canonical_to_raw_preserves_pending_input_order() { - let mut pty = PtyPair::new(); + let mut pty = PtyPair::new(0, 0); pty.terminal.c_lflag &= !crate::terminal::ECHO; for &byte in b"first\nq" { @@ -355,7 +369,7 @@ mod tests { #[test] fn test_pty_tcsaflush_discards_pending_input() { - let mut pty = PtyPair::new(); + let mut pty = PtyPair::new(0, 0); pty.terminal.c_lflag &= !crate::terminal::ECHO; for &byte in b"first\nq" { @@ -374,7 +388,7 @@ mod tests { #[test] fn test_pty_raw_to_canonical_makes_unread_input_immediately_readable() { - let mut pty = PtyPair::new(); + let mut pty = PtyPair::new(0, 0); pty.terminal.c_lflag &= !(crate::terminal::ICANON | crate::terminal::ECHO); pty.process_master_input(b'q'); pty.process_master_input(0); @@ -397,7 +411,7 @@ mod tests { let _pty_table = test_table_lock(); reset_table(); - let idx = alloc_pty().unwrap(); + let idx = alloc_pty(0, 0).unwrap(); let pty = get_pty(idx).unwrap(); // OPOST | ONLCR is on by default @@ -415,7 +429,7 @@ mod tests { let _pty_table = test_table_lock(); reset_table(); - let idx = alloc_pty().unwrap(); + let idx = alloc_pty(0, 0).unwrap(); let pty = get_pty(idx).unwrap(); // Default: canonical + ISIG + ECHO @@ -443,7 +457,7 @@ mod tests { let _pty_table = test_table_lock(); reset_table(); - let idx = alloc_pty().unwrap(); + let idx = alloc_pty(0, 0).unwrap(); let pty = get_pty(idx).unwrap(); pty.terminal.c_lflag &= !crate::terminal::ICANON; // raw mode // ISIG still set by default @@ -463,7 +477,7 @@ mod tests { let _pty_table = test_table_lock(); reset_table(); - let idx = alloc_pty().unwrap(); + let idx = alloc_pty(0, 0).unwrap(); let pty = get_pty(idx).unwrap(); pty.terminal.c_lflag &= !crate::terminal::ICANON; // raw mode pty.terminal.c_lflag &= !crate::terminal::ISIG; // disable ISIG diff --git a/crates/kernel/src/signal.rs b/crates/kernel/src/signal.rs index 0274e898be..732ffca1c0 100644 --- a/crates/kernel/src/signal.rs +++ b/crates/kernel/src/signal.rs @@ -160,7 +160,7 @@ pub(crate) fn dequeue_signal_for( ) -> (u32, u64, i32, i32, i32) { if proc.state == ProcessState::Stopped && signum == wasm_posix_shared::signal::SIGKILL { proc.clear_signal_everywhere(signum); - return (signum, 0, 0, proc.pid as i32, proc.uid as i32); + return (signum, 0, 0, proc.pid as i32, proc.real_uid() as i32); } let info = proc.consume_signal_for(tid, signum).unwrap_or_default(); let (word_1, word_2) = match info.timer_id { diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 8afb097f8c..687e36a705 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -326,8 +326,8 @@ fn dev_fd_lstat(proc: &Process, path: &[u8], target_fd: i32) -> Result bool { @@ -1930,7 +1930,7 @@ fn has_access_for_ids( let available = if uid == st.st_uid { (st.st_mode >> 6) & 0o7 - } else if gid == st.st_gid || supplementary_gid == st.st_gid { + } else if gid == st.st_gid || supplementary_groups.contains(&st.st_gid) { (st.st_mode >> 3) & 0o7 } else { st.st_mode & 0o7 @@ -1940,7 +1940,13 @@ fn has_access_for_ids( } fn has_access(proc: &Process, st: &WasmStat, amode: u32) -> bool { - has_access_for_ids(proc.euid, proc.egid, proc.gid, st, amode) + has_access_for_ids( + proc.effective_uid(), + proc.effective_gid(), + proc.supplementary_groups(), + st, + amode, + ) } fn check_access(proc: &Process, st: &WasmStat, amode: u32) -> Result<(), Errno> { @@ -1954,11 +1960,11 @@ fn check_access(proc: &Process, st: &WasmStat, amode: u32) -> Result<(), Errno> fn check_access_for_ids( uid: u32, gid: u32, - supplementary_gid: u32, + supplementary_groups: &[u32], st: &WasmStat, amode: u32, ) -> Result<(), Errno> { - if has_access_for_ids(uid, gid, supplementary_gid, st, amode) { + if has_access_for_ids(uid, gid, supplementary_groups, st, amode) { Ok(()) } else { Err(Errno::EACCES) @@ -2023,8 +2029,8 @@ fn dev_fd_path_stat(proc: &Process) -> WasmStat { st_ino: 0, st_mode: S_IFCHR | 0o666, st_nlink: 1, - st_uid: proc.euid, - st_gid: proc.egid, + st_uid: proc.effective_uid(), + st_gid: proc.effective_gid(), st_size: 0, st_atime_sec: 0, st_atime_nsec: 0, @@ -2074,19 +2080,27 @@ fn namespace_lstat_raw( Err(error) => return Err(error), } } - if let Some(st) = crate::devfs::match_devfs_stat(path, proc.euid, proc.egid) { + if let Some(st) = crate::devfs::match_devfs_stat( + path, + proc.effective_uid(), + proc.effective_gid(), + ) { return Ok(st); } if let Some(dev) = match_virtual_device(path) { - return Ok(virtual_device_stat(dev, proc.euid, proc.egid)); + return Ok(virtual_device_stat( + dev, + proc.effective_uid(), + proc.effective_gid(), + )); } - if let Some(st) = match_pty_stat(path, proc.euid, proc.egid) { + if let Some(st) = match_pty_stat(path) { return Ok(st); } if match_dev_fd(path).is_some() { return Ok(dev_fd_path_stat(proc)); } - if let Some(st) = synthetic_file_stat(path, proc.euid, proc.egid) { + if let Some(st) = synthetic_file_stat(path, proc.effective_uid(), proc.effective_gid()) { return Ok(st); } // Like procfs, kernel devfs owns its namespace. `/dev/shm` is the one @@ -2209,11 +2223,17 @@ fn resolve_namespace_path_from( return Err(Errno::ENOTDIR); } let (search_uid, search_gid) = if options.use_real_ids { - (proc.uid, proc.gid) + (proc.real_uid(), proc.real_gid()) } else { - (proc.euid, proc.egid) + (proc.effective_uid(), proc.effective_gid()) }; - check_access_for_ids(search_uid, search_gid, proc.gid, &root_stat, X_OK)?; + check_access_for_ids( + search_uid, + search_gid, + proc.supplementary_groups(), + &root_stat, + X_OK, + )?; let mut resolved = alloc::vec![b'/']; let mut final_stat = Some(root_stat); @@ -2230,7 +2250,13 @@ fn resolve_namespace_path_from( if stat.st_mode & S_IFMT != S_IFDIR { return Err(Errno::ENOTDIR); } - check_access_for_ids(search_uid, search_gid, proc.gid, &stat, X_OK)?; + check_access_for_ids( + search_uid, + search_gid, + proc.supplementary_groups(), + &stat, + X_OK, + )?; } final_stat = Some(stat); continue; @@ -2329,7 +2355,13 @@ fn resolve_namespace_path_from( if stat.st_mode & S_IFMT != S_IFDIR { return Err(Errno::ENOTDIR); } - check_access_for_ids(search_uid, search_gid, proc.gid, &stat, X_OK)?; + check_access_for_ids( + search_uid, + search_gid, + proc.supplementary_groups(), + &stat, + X_OK, + )?; } resolved = candidate; final_stat = Some(stat); @@ -2426,12 +2458,12 @@ fn check_parent_writable(proc: &Process, host: &mut dyn HostIO, path: &[u8]) -> fn check_sticky_child(proc: &Process, host: &mut dyn HostIO, path: &[u8]) -> Result<(), Errno> { let parent = parent_path(path); let parent_st = namespace_lstat_raw(proc, host, &parent)?; - if parent_st.st_mode & S_ISVTX == 0 || proc.euid == 0 { + if parent_st.st_mode & S_ISVTX == 0 || proc.effective_uid() == 0 { return Ok(()); } let child_st = namespace_lstat_raw(proc, host, path)?; - if proc.euid == parent_st.st_uid || proc.euid == child_st.st_uid { + if proc.effective_uid() == parent_st.st_uid || proc.effective_uid() == child_st.st_uid { Ok(()) } else { Err(Errno::EPERM) @@ -2486,7 +2518,7 @@ fn check_open_permissions( } fn check_owner_or_root(proc: &Process, st: &WasmStat) -> Result<(), Errno> { - if proc.euid == 0 || proc.euid == st.st_uid { + if proc.effective_uid() == 0 || proc.effective_uid() == st.st_uid { Ok(()) } else { Err(Errno::EPERM) @@ -2502,10 +2534,6 @@ fn check_exec_path(proc: &Process, host: &mut dyn HostIO, path: &[u8]) -> Result check_access(proc, &st, X_OK) } -fn requested_id_allowed(current_real: u32, current_effective: u32, requested: u32) -> bool { - requested == 0xFFFFFFFF || requested == current_real || requested == current_effective -} - fn fifo_open_owner(proc: &Process) -> u64 { let tid = current_tid_for_process(proc); let guest_tid = if tid == 0 { proc.pid } else { tid }; @@ -2920,7 +2948,8 @@ pub fn sys_open( // /dev/ptmx — allocate a new PTY master if resolved == b"/dev/ptmx" { - let pty_idx = crate::pty::alloc_pty().ok_or(Errno::ENOSPC)?; + let pty_idx = crate::pty::alloc_pty(proc.effective_uid(), proc.effective_gid()) + .ok_or(Errno::ENOSPC)?; let pty = crate::pty::get_pty(pty_idx).ok_or(Errno::EIO)?; pty.master_refs += 1; let status_flags = oflags & !CREATION_FLAGS; @@ -2940,6 +2969,8 @@ pub fn sys_open( if pty.locked { return Err(Errno::EIO); // must call unlockpt first } + let stat = pty_pair_stat(pty_idx, pty.owner_uid(), pty.owner_gid()); + check_access(proc, &stat, open_access_mask(oflags, &stat))?; pty.slave_refs += 1; let status_flags = oflags & !CREATION_FLAGS; let ofd_idx = @@ -3024,7 +3055,7 @@ pub fn sys_open( let host_handle = host.host_open(&resolved, oflags, effective_mode)?; if created { - host.host_chown(&resolved, proc.euid, proc.egid)?; + host.host_chown(&resolved, proc.effective_uid(), proc.effective_gid())?; } // Cache lock identity from fstat on the live handle. Path-based stat is @@ -6241,12 +6272,16 @@ pub fn sys_fstat(proc: &Process, host: &mut dyn HostIO, fd: i32) -> Result Result Result Result Result Result Option { - if resolved == b"/dev/ptmx" || resolved == b"/dev/tty" || resolved.starts_with(b"/dev/pts/") { - Some(WasmStat { - st_dev: 5, - st_ino: 0x50545900, - st_mode: S_IFCHR | 0o620, - st_nlink: 1, - st_uid: uid, - st_gid: gid, - st_size: 0, - st_atime_sec: 0, - st_atime_nsec: 0, - st_mtime_sec: 0, - st_mtime_nsec: 0, - st_ctime_sec: 0, - st_ctime_nsec: 0, - _pad: 0, - }) - } else { - None +fn pty_pair_stat(pty_idx: usize, uid: u32, gid: u32) -> WasmStat { + WasmStat { + st_dev: 5, + st_ino: 0x50545900 + pty_idx as u64, + st_mode: S_IFCHR | 0o620, + st_nlink: 1, + st_uid: uid, + st_gid: gid, + st_size: 0, + st_atime_sec: 0, + st_atime_nsec: 0, + st_mtime_sec: 0, + st_mtime_nsec: 0, + st_ctime_sec: 0, + st_ctime_nsec: 0, + _pad: 0, + } +} + +/// Return stable PTY-pair metadata. The non-pair clone and controlling-device +/// nodes are root-owned device metadata rather than observer-owned aliases. +fn match_pty_stat(resolved: &[u8]) -> Option { + if resolved == b"/dev/ptmx" || resolved == b"/dev/tty" { + return Some(pty_pair_stat(0, 0, 0)); } + let suffix = resolved.strip_prefix(b"/dev/pts/")?; + let pty_idx = parse_ascii_usize(suffix)?; + let pty = crate::pty::get_pty(pty_idx)?; + Some(pty_pair_stat(pty_idx, pty.owner_uid(), pty.owner_gid())) } fn unix_socket_path_stat( @@ -6905,9 +6938,13 @@ fn procfs_entry_stat( pub fn sys_stat(proc: &mut Process, host: &mut dyn HostIO, path: &[u8]) -> Result { let resolved = resolve_namespace_path(proc, host, path, PathResolveOptions::FOLLOW)?.path; if let Some(dev) = match_virtual_device(&resolved) { - return Ok(virtual_device_stat(dev, proc.euid, proc.egid)); + return Ok(virtual_device_stat( + dev, + proc.effective_uid(), + proc.effective_gid(), + )); } - if let Some(st) = match_pty_stat(&resolved, proc.euid, proc.egid) { + if let Some(st) = match_pty_stat(&resolved) { return Ok(st); } if let Some(target_fd) = match_dev_fd(&resolved) { @@ -6917,11 +6954,15 @@ pub fn sys_stat(proc: &mut Process, host: &mut dyn HostIO, path: &[u8]) -> Resul return procfs_entry_stat(proc, host, &entry, true); } if !is_host_backed_devfs_path(&resolved) { - if let Some(st) = crate::devfs::match_devfs_stat(&resolved, proc.euid, proc.egid) { + if let Some(st) = crate::devfs::match_devfs_stat( + &resolved, + proc.effective_uid(), + proc.effective_gid(), + ) { return Ok(st); } } - if let Some(st) = synthetic_file_stat(&resolved, proc.euid, proc.egid) { + if let Some(st) = synthetic_file_stat(&resolved, proc.effective_uid(), proc.effective_gid()) { return Ok(st); } if let Some(st) = fifo_path_stat(proc, host, &resolved, true)? { @@ -6943,9 +6984,13 @@ pub fn sys_lstat( ) -> Result { let resolved = resolve_namespace_path(proc, host, path, PathResolveOptions::NOFOLLOW)?.path; if let Some(dev) = match_virtual_device(&resolved) { - return Ok(virtual_device_stat(dev, proc.euid, proc.egid)); + return Ok(virtual_device_stat( + dev, + proc.effective_uid(), + proc.effective_gid(), + )); } - if let Some(st) = match_pty_stat(&resolved, proc.euid, proc.egid) { + if let Some(st) = match_pty_stat(&resolved) { return Ok(st); } if let Some(target_fd) = match_dev_fd(&resolved) { @@ -6955,11 +7000,15 @@ pub fn sys_lstat( return procfs_entry_stat(proc, host, &entry, false); } if !is_host_backed_devfs_path(&resolved) { - if let Some(st) = crate::devfs::match_devfs_stat(&resolved, proc.euid, proc.egid) { + if let Some(st) = crate::devfs::match_devfs_stat( + &resolved, + proc.effective_uid(), + proc.effective_gid(), + ) { return Ok(st); } } - if let Some(st) = synthetic_file_stat(&resolved, proc.euid, proc.egid) { + if let Some(st) = synthetic_file_stat(&resolved, proc.effective_uid(), proc.effective_gid()) { return Ok(st); } if let Some(st) = fifo_path_stat(proc, host, &resolved, false)? { @@ -6986,7 +7035,7 @@ pub fn sys_mkdir( let effective_mode = mode & !proc.umask; check_parent_writable(proc, host, &resolved)?; host.host_mkdir(&resolved, effective_mode)?; - host.host_chown(&resolved, proc.euid, proc.egid) + host.host_chown(&resolved, proc.effective_uid(), proc.effective_gid()) } pub fn sys_rmdir(proc: &mut Process, host: &mut dyn HostIO, path: &[u8]) -> Result<(), Errno> { @@ -7037,7 +7086,11 @@ fn make_fifo( let effective_mode = mode & !proc.umask; let flags = O_CREAT | O_EXCL | O_WRONLY; let handle = host.host_open(&resolved.path, flags, effective_mode)?; - if let Err(error) = host.host_chown(&resolved.path, proc.euid, proc.egid) { + if let Err(error) = host.host_chown( + &resolved.path, + proc.effective_uid(), + proc.effective_gid(), + ) { let _ = host.host_close(handle); let _ = host.host_unlink(&resolved.path); return Err(error); @@ -7304,16 +7357,13 @@ fn prepare_chown_ids( uid: u32, gid: u32, ) -> Result<(u32, u32), Errno> { - if proc.euid != 0 { + if proc.effective_uid() != 0 { // _POSIX_CHOWN_RESTRICTED: an unprivileged caller must own the file, // cannot give it to another user, and can select only a group in its - // group set. Kandelo currently exposes one synthesized supplementary - // group through getgroups(): the real GID. Keep that documented - // credential model coherent by accepting either it or the effective - // GID here. The unchanged sentinels preserve their corresponding IDs. - if proc.euid != st.st_uid + // group set. The unchanged sentinels preserve their corresponding IDs. + if proc.effective_uid() != st.st_uid || (uid != CHOWN_ID_UNCHANGED && uid != st.st_uid) - || (gid != CHOWN_ID_UNCHANGED && gid != proc.egid && gid != proc.gid) + || (gid != CHOWN_ID_UNCHANGED && !proc.is_member_of_group(gid)) { return Err(Errno::EPERM); } @@ -7387,7 +7437,13 @@ pub fn sys_access( return Err(Errno::EACCES); } let st = resolved.stat.ok_or(Errno::ENOENT)?; - check_access_for_ids(proc.uid, proc.gid, proc.gid, &st, amode) + check_access_for_ids( + proc.real_uid(), + proc.real_gid(), + proc.supplementary_groups(), + &st, + amode, + ) } /// Change the current working directory. @@ -8103,22 +8159,22 @@ pub fn sys_getppid(proc: &Process) -> i32 { /// Get the real user ID. pub fn sys_getuid(proc: &Process) -> u32 { - proc.uid + proc.real_uid() } /// Get the effective user ID. pub fn sys_geteuid(proc: &Process) -> u32 { - proc.euid + proc.effective_uid() } /// Get the real group ID. pub fn sys_getgid(proc: &Process) -> u32 { - proc.gid + proc.real_gid() } /// Get the effective group ID. pub fn sys_getegid(proc: &Process) -> u32 { - proc.egid + proc.effective_gid() } /// getpgrp -- get process group ID. @@ -8200,7 +8256,7 @@ pub fn sys_kill(proc: &mut Process, pid: i32, sig: u32) -> Result<(), Errno> { // synthesizing the recipient's identity. This process-local compatibility // path is self-delivery, so retain the same SI_USER metadata observable // through SA_SIGINFO as ordinary kill(getpid(), sig). - proc.raise_signal_with_metadata(sig, 0, 0, proc.pid, proc.uid); + proc.raise_signal_with_metadata(sig, 0, 0, proc.pid, proc.real_uid()); Ok(()) } @@ -8824,7 +8880,10 @@ fn check_utimens_permissions( let both_now = times.is_some_and(|ts| ts[0].tv_nsec == UTIME_NOW && ts[1].tv_nsec == UTIME_NOW); if times.is_none() || both_now { - if proc.euid == 0 || proc.euid == st.st_uid || has_access(proc, st, W_OK) { + if proc.effective_uid() == 0 + || proc.effective_uid() == st.st_uid + || has_access(proc, st, W_OK) + { return Ok(true); } return Err(Errno::EACCES); @@ -10327,7 +10386,7 @@ fn udp_send_datagram( } else { src_addr }; - let (src_pid, src_uid, src_gid) = (proc.pid, proc.uid, proc.gid); + let (src_pid, src_uid, src_gid) = (proc.pid, proc.real_uid(), proc.real_gid()); if is_ipv4_multicast_addr(dst_addr) { use wasm_posix_shared::socket::{IPPROTO_IP, IP_MULTICAST_IF, IP_MULTICAST_LOOP}; @@ -10465,7 +10524,7 @@ fn unix_dgram_send_to_sock( return Err(Errno::EPIPE); } - let (src_pid, src_uid, src_gid) = (proc.pid, proc.uid, proc.gid); + let (src_pid, src_uid, src_gid) = (proc.pid, proc.real_uid(), proc.real_gid()); let target = proc .sockets .get_mut(dst_sock_idx) @@ -10693,7 +10752,7 @@ fn udp6_send_datagram( } else { src_addr }; - let (src_pid, src_uid, src_gid) = (proc.pid, proc.uid, proc.gid); + let (src_pid, src_uid, src_gid) = (proc.pid, proc.real_uid(), proc.real_gid()); let mut delivered = false; let sock_count = proc.sockets.len(); @@ -10842,8 +10901,8 @@ pub(crate) fn cross_process_loopback_udp_route( Some(CrossProcessLoopbackUdpRoute { sender_pid: proc.pid, sender_sock_idx, - sender_uid: proc.uid, - sender_gid: proc.gid, + sender_uid: proc.real_uid(), + sender_gid: proc.real_gid(), connected: sock.state == SocketState::Connected, dst_addr, dst_port, @@ -12000,7 +12059,7 @@ pub fn sys_bind( Err(Errno::EEXIST) => return Err(Errno::EADDRINUSE), Err(e) => return Err(e), }; - host.host_chown(&resolved, proc.euid, proc.egid)?; + host.host_chown(&resolved, proc.effective_uid(), proc.effective_gid())?; let _ = host.host_close(h); } @@ -13651,7 +13710,8 @@ pub fn sys_openat( // /dev/ptmx — allocate a new PTY master if resolved == b"/dev/ptmx" { - let pty_idx = crate::pty::alloc_pty().ok_or(Errno::ENOSPC)?; + let pty_idx = crate::pty::alloc_pty(proc.effective_uid(), proc.effective_gid()) + .ok_or(Errno::ENOSPC)?; let pty = crate::pty::get_pty(pty_idx).ok_or(Errno::EIO)?; pty.master_refs += 1; let status_flags = oflags & !CREATION_FLAGS; @@ -13671,6 +13731,8 @@ pub fn sys_openat( if pty.locked { return Err(Errno::EIO); } + let stat = pty_pair_stat(pty_idx, pty.owner_uid(), pty.owner_gid()); + check_access(proc, &stat, open_access_mask(oflags, &stat))?; pty.slave_refs += 1; let status_flags = oflags & !CREATION_FLAGS; let ofd_idx = @@ -13759,7 +13821,7 @@ pub fn sys_openat( let host_handle = host.host_open(&resolved, oflags, effective_mode)?; if created { - host.host_chown(&resolved, proc.euid, proc.egid)?; + host.host_chown(&resolved, proc.effective_uid(), proc.effective_gid())?; } // Keep openat(2) on the same live-handle identity contract as open(2). @@ -13833,7 +13895,11 @@ pub fn sys_fstatat( }; let resolved = resolve_at_path(proc, host, dirfd, path, options)?.path; if let Some(dev) = match_virtual_device(&resolved) { - return Ok(virtual_device_stat(dev, proc.euid, proc.egid)); + return Ok(virtual_device_stat( + dev, + proc.effective_uid(), + proc.effective_gid(), + )); } if let Some(target_fd) = match_dev_fd(&resolved) { if flags & AT_SYMLINK_NOFOLLOW != 0 { @@ -13846,11 +13912,15 @@ pub fn sys_fstatat( return procfs_entry_stat(proc, host, &entry, follow); } if !is_host_backed_devfs_path(&resolved) { - if let Some(st) = crate::devfs::match_devfs_stat(&resolved, proc.euid, proc.egid) { + if let Some(st) = crate::devfs::match_devfs_stat( + &resolved, + proc.effective_uid(), + proc.effective_gid(), + ) { return Ok(st); } } - if let Some(st) = synthetic_file_stat(&resolved, proc.euid, proc.egid) { + if let Some(st) = synthetic_file_stat(&resolved, proc.effective_uid(), proc.effective_gid()) { return Ok(st); } if let Some(st) = fifo_path_stat(proc, host, &resolved, flags & AT_SYMLINK_NOFOLLOW == 0)? { @@ -13930,7 +14000,7 @@ pub fn sys_mkdirat( let effective_mode = mode & !proc.umask; check_parent_writable(proc, host, &resolved)?; host.host_mkdir(&resolved, effective_mode)?; - host.host_chown(&resolved, proc.euid, proc.egid) + host.host_chown(&resolved, proc.effective_uid(), proc.effective_gid()) } /// renameat -- rename relative to directory fds. @@ -15927,7 +15997,7 @@ pub fn sys_setrlimit(proc: &mut Process, resource: u32, soft: u64, hard: u64) -> return Err(Errno::EINVAL); } let current_hard = proc.rlimits[resource as usize][1]; - if proc.euid != 0 && hard > current_hard { + if proc.effective_uid() != 0 && hard > current_hard { return Err(Errno::EPERM); } proc.rlimits[resource as usize] = [soft, hard]; @@ -15980,11 +16050,11 @@ pub fn sys_faccessat( } let st = resolved.stat.ok_or(Errno::ENOENT)?; let (uid, gid) = if flags & AT_EACCESS != 0 { - (proc.euid, proc.egid) + (proc.effective_uid(), proc.effective_gid()) } else { - (proc.uid, proc.gid) + (proc.real_uid(), proc.real_gid()) }; - check_access_for_ids(uid, gid, proc.gid, &st, amode) + check_access_for_ids(uid, gid, proc.supplementary_groups(), &st, amode) } /// fchmodat -- change file mode relative to directory fd. @@ -16246,78 +16316,44 @@ pub fn sys_select( Err(Errno::EAGAIN) } -/// setuid -- set real and effective user ID. -/// -/// POSIX semantics (no saved-set-user-ID tracked): -/// - If caller's euid is 0 (root), set both `uid` and `euid` to `uid`. -/// - Else if the target `uid` equals the caller's real `uid`, set `euid` only. -/// - Otherwise return EPERM. +/// setuid -- update process-wide user credentials. pub fn sys_setuid(proc: &mut Process, uid: u32) -> Result<(), Errno> { - if proc.euid == 0 { - proc.uid = uid; - proc.euid = uid; - Ok(()) - } else if uid == proc.uid { - proc.euid = uid; - Ok(()) - } else { - Err(Errno::EPERM) - } + proc.setuid(uid) } -/// setgid -- set real and effective group ID. -/// -/// Mirrors setuid's privilege rules, gated on euid (the kernel doesn't track a -/// separate "appropriate privileges" capability). +/// setgid -- update process-wide group credentials. pub fn sys_setgid(proc: &mut Process, gid: u32) -> Result<(), Errno> { - if proc.euid == 0 { - proc.gid = gid; - proc.egid = gid; - Ok(()) - } else if gid == proc.gid { - proc.egid = gid; - Ok(()) - } else { - Err(Errno::EPERM) - } + proc.setgid(gid) } -/// seteuid -- set effective user ID. -/// -/// Root may set euid to any value. Others may only reset euid to their real uid. +/// seteuid -- set effective user ID from the permitted real/saved set. pub fn sys_seteuid(proc: &mut Process, euid: u32) -> Result<(), Errno> { - if proc.euid == 0 || euid == proc.uid { - proc.euid = euid; - Ok(()) - } else { - Err(Errno::EPERM) - } + proc.seteuid(euid) } /// setegid -- set effective group ID. pub fn sys_setegid(proc: &mut Process, egid: u32) -> Result<(), Errno> { - if proc.euid == 0 || egid == proc.gid { - proc.egid = egid; - Ok(()) - } else { - Err(Errno::EPERM) - } + proc.setegid(egid) } /// POSIX permission test for `kill(pid, sig)`. /// /// Returns true if a process with (sender_uid, sender_euid) may signal a -/// process with (target_uid, target_euid). Per POSIX: sender's real or -/// effective uid must match target's real or saved-set-uid. Since we don't -/// track saved-set-user-ID separately, the real uid stands in for it. -pub fn can_signal(sender_uid: u32, sender_euid: u32, target_uid: u32, target_euid: u32) -> bool { +/// process with (target_uid, target_saved_uid). Per POSIX: sender's real or +/// effective UID must match the target's real or saved-set-user-ID. +pub fn can_signal( + sender_uid: u32, + sender_euid: u32, + target_uid: u32, + target_saved_uid: u32, +) -> bool { if sender_euid == 0 { return true; } sender_uid == target_uid - || sender_uid == target_euid + || sender_uid == target_saved_uid || sender_euid == target_uid - || sender_euid == target_euid + || sender_euid == target_saved_uid } /// POSIX permission test for `sched_getparam`/`sched_getscheduler` and their @@ -16379,7 +16415,7 @@ pub fn sys_setpriority(proc: &mut Process, which: i32, who: u32, prio: i32) -> R } // Clamp to [-20, 19] let clamped = prio.max(-20).min(19); - if proc.euid != 0 && clamped < proc.nice { + if proc.effective_uid() != 0 && clamped < proc.nice { return Err(Errno::EPERM); } proc.nice = clamped; @@ -16551,69 +16587,79 @@ pub fn sys_fstatfs( } } -/// setresuid — set real, effective, and saved user IDs (simulated). +/// setresuid — atomically set real, effective, and saved user IDs. pub fn sys_setresuid(proc: &mut Process, ruid: u32, euid: u32, suid: u32) -> Result<(), Errno> { - if proc.euid != 0 - && (!requested_id_allowed(proc.uid, proc.euid, ruid) - || !requested_id_allowed(proc.uid, proc.euid, euid) - || !requested_id_allowed(proc.uid, proc.euid, suid)) - { - return Err(Errno::EPERM); - } - if ruid != 0xFFFFFFFF { - proc.uid = ruid; - } - if euid != 0xFFFFFFFF { - proc.euid = euid; - } - Ok(()) + proc.setresuid(ruid, euid, suid) +} + +/// setreuid — atomically set real/effective UID and reseat the saved UID. +pub fn sys_setreuid(proc: &mut Process, ruid: u32, euid: u32) -> Result<(), Errno> { + proc.setreuid(ruid, euid) } /// getresuid — get real, effective, and saved user IDs. pub fn sys_getresuid(proc: &Process) -> (u32, u32, u32) { - (proc.uid, proc.euid, proc.uid) + (proc.real_uid(), proc.effective_uid(), proc.saved_uid()) } -/// setresgid — set real, effective, and saved group IDs (simulated). +/// setresgid — atomically set real, effective, and saved group IDs. pub fn sys_setresgid(proc: &mut Process, rgid: u32, egid: u32, sgid: u32) -> Result<(), Errno> { - if proc.euid != 0 - && (!requested_id_allowed(proc.gid, proc.egid, rgid) - || !requested_id_allowed(proc.gid, proc.egid, egid) - || !requested_id_allowed(proc.gid, proc.egid, sgid)) - { - return Err(Errno::EPERM); - } - if rgid != 0xFFFFFFFF { - proc.gid = rgid; - } - if egid != 0xFFFFFFFF { - proc.egid = egid; - } - Ok(()) + proc.setresgid(rgid, egid, sgid) +} + +/// setregid — atomically set real/effective GID and reseat the saved GID. +pub fn sys_setregid(proc: &mut Process, rgid: u32, egid: u32) -> Result<(), Errno> { + proc.setregid(rgid, egid) } /// getresgid — get real, effective, and saved group IDs. pub fn sys_getresgid(proc: &Process) -> (u32, u32, u32) { - (proc.gid, proc.egid, proc.gid) + (proc.real_gid(), proc.effective_gid(), proc.saved_gid()) } /// getgroups — get supplementary group IDs. -/// Returns 1 group (the process's primary gid). -pub fn sys_getgroups(proc: &Process, size: u32) -> Result<(u32, u32), Errno> { - if size == 0 { - // Return count only - return Ok((1, 0)); +pub fn sys_getgroups(proc: &Process, size: u32) -> Result<&[u32], Errno> { + if size as usize > crate::credentials::NGROUPS_MAX { + return Err(Errno::EINVAL); + } + if size != 0 && (size as usize) < proc.supplementary_groups().len() { + return Err(Errno::EINVAL); } - // Return count=1 and the group - Ok((1, proc.gid)) + Ok(proc.supplementary_groups()) } -/// setgroups — set supplementary group IDs (no-op). -pub fn sys_setgroups(proc: &mut Process, _size: u32) -> Result<(), Errno> { - if proc.euid != 0 { - return Err(Errno::EPERM); +/// Copy a successful getgroups result into the adapter-provided destination. +/// +/// A size-zero query is count-only and must not inspect or touch the pointer. +#[cfg(any(test, target_arch = "wasm32", target_arch = "wasm64"))] +pub(crate) fn copy_getgroups_to_destination( + size: u32, + list_ptr: *mut u32, + list_capacity_bytes: u32, + groups: &[u32], +) -> Result { + let count = u32::try_from(groups.len()).map_err(|_| Errno::EOVERFLOW)?; + if size == 0 { + return Ok(count); } - Ok(()) + let required_bytes = groups + .len() + .checked_mul(core::mem::size_of::()) + .ok_or(Errno::EOVERFLOW)?; + if list_ptr.is_null() || required_bytes > list_capacity_bytes as usize { + return Err(Errno::EFAULT); + } + if required_bytes > 0 { + unsafe { + core::ptr::copy_nonoverlapping(groups.as_ptr(), list_ptr, groups.len()); + } + } + Ok(count) +} + +/// setgroups — atomically replace the ordered supplementary group list. +pub fn sys_setgroups(proc: &mut Process, groups: &[u32]) -> Result<(), Errno> { + proc.setgroups(groups) } /// Send one message and its SCM_RIGHTS descriptors as a single owned @@ -16930,6 +16976,7 @@ pub fn sys_memfd_create(proc: &mut Process, name: &[u8], flags: u32) -> Result, proc: Process, @@ -16951,7 +17017,7 @@ mod tests { fn new() -> Self { let pty_table = crate::pty::test_table_lock(); let mut proc = Process::new(1); - let pty_idx = crate::pty::alloc_pty().expect("test PTY allocation"); + let pty_idx = crate::pty::alloc_pty(0, 0).expect("test PTY allocation"); let pty = crate::pty::get_pty(pty_idx).expect("allocated test PTY"); pty.locked = false; pty.master_refs = 1; @@ -18147,10 +18213,7 @@ mod tests { fn user_process(pid: u32) -> Process { let mut proc = Process::new(pid); - proc.uid = 1000; - proc.euid = 1000; - proc.gid = 1000; - proc.egid = 1000; + set_test_credentials(&mut proc, 1000, 1000, 1000, 1000, &[]); proc } @@ -18275,8 +18338,8 @@ mod tests { let err = sys_setresuid(&mut proc, 0, 0, 0xFFFFFFFF).unwrap_err(); assert_eq!(err, Errno::EPERM); - assert_eq!(proc.uid, 1000); - assert_eq!(proc.euid, 1000); + assert_eq!(proc.real_uid(), 1000); + assert_eq!(proc.effective_uid(), 1000); } #[test] @@ -18286,19 +18349,155 @@ mod tests { let err = sys_setresgid(&mut proc, 0, 0, 0xFFFFFFFF).unwrap_err(); assert_eq!(err, Errno::EPERM); - assert_eq!(proc.gid, 1000); - assert_eq!(proc.egid, 1000); + assert_eq!(proc.real_gid(), 1000); + assert_eq!(proc.effective_gid(), 1000); } #[test] fn test_non_root_setgroups_denied() { let mut proc = user_process(19); - let err = sys_setgroups(&mut proc, 0).unwrap_err(); + let err = sys_setgroups(&mut proc, &[]).unwrap_err(); assert_eq!(err, Errno::EPERM); } + #[test] + fn permission_getgroups_sizes_and_setgroups_is_root_only_and_atomic() { + let mut proc = Process::new(20); + let ordered = [41, 7, 41]; + assert_eq!(sys_setgroups(&mut proc, &ordered), Ok(())); + assert_eq!(sys_getgroups(&proc, 0), Ok(ordered.as_slice())); + assert_eq!( + sys_getgroups(&proc, ordered.len() as u32), + Ok(ordered.as_slice()), + ); + assert_eq!( + sys_getgroups(&proc, ordered.len() as u32 - 1), + Err(Errno::EINVAL), + ); + + assert_eq!(proc.seteuid(1000), Ok(())); + let before = proc.credentials().clone(); + assert_eq!(sys_setgroups(&mut proc, &[99]), Err(Errno::EPERM)); + assert_eq!(proc.credentials(), &before); + } + + #[test] + fn wasm_adapter_count_query_with_groups_never_touches_a_destination() { + let groups = [41, 42]; + let mut untouched = 0xA5A5_A5A5; + + assert_eq!( + copy_getgroups_to_destination(0, &mut untouched, 0, &groups), + Ok(2), + ); + assert_eq!(untouched, 0xA5A5_A5A5); + assert_eq!( + copy_getgroups_to_destination(0, core::ptr::null_mut(), 0, &groups), + Ok(2), + ); + } + + #[test] + fn permission_file_group_access_uses_supplementary_membership() { + let mut proc = Process::new(21); + set_test_credentials(&mut proc, 1000, 1000, 2000, 3000, &[7000]); + let file = WasmStat { + st_dev: 1, + st_ino: 1, + st_mode: S_IFREG | 0o060, + st_nlink: 1, + st_uid: 9999, + st_gid: 7000, + st_size: 0, + st_atime_sec: 0, + st_atime_nsec: 0, + st_mtime_sec: 0, + st_mtime_nsec: 0, + st_ctime_sec: 0, + st_ctime_nsec: 0, + _pad: 0, + }; + assert!(has_access(&proc, &file, R_OK | W_OK)); + + set_test_credentials(&mut proc, 1000, 1000, 2000, 3000, &[]); + assert!(!has_access(&proc, &file, R_OK)); + } + + #[test] + fn pty_production_stat_and_fstat_keep_the_creators_owner() { + let _pty_table = crate::pty::test_table_lock(); + let mut host = MockHostIO::new(); + let mut creator = Process::new(23); + set_test_credentials(&mut creator, 1000, 1000, 2000, 2000, &[]); + let master_fd = sys_open(&mut creator, &mut host, b"/dev/ptmx", O_RDWR, 0).unwrap(); + let master_ofd = creator.fd_table.get(master_fd).unwrap().ofd_ref.0; + let pty_idx = creator.ofd_table.get(master_ofd).unwrap().host_handle as usize; + crate::pty::get_pty(pty_idx).unwrap().locked = false; + let slave_path = format!("/dev/pts/{pty_idx}").into_bytes(); + + let mut observer = Process::new(24); + set_test_credentials(&mut observer, 3000, 3000, 4000, 4000, &[2000]); + let path_stat = sys_stat(&mut observer, &mut host, &slave_path).unwrap(); + assert_eq!((path_stat.st_uid, path_stat.st_gid), (1000, 2000)); + + let slave_fd = sys_open(&mut observer, &mut host, &slave_path, O_WRONLY, 0).unwrap(); + let fd_stat = sys_fstat(&observer, &mut host, slave_fd).unwrap(); + assert_eq!((fd_stat.st_uid, fd_stat.st_gid), (1000, 2000)); + crate::pty::free_pty(pty_idx); + } + + #[test] + fn pty_slave_open_enforces_owner_group_supplementary_and_root_access() { + let _pty_table = crate::pty::test_table_lock(); + let mut host = MockHostIO::new(); + let mut creator = Process::new(25); + set_test_credentials(&mut creator, 1000, 1000, 2000, 2000, &[]); + let master_fd = sys_open(&mut creator, &mut host, b"/dev/ptmx", O_RDWR, 0).unwrap(); + let master_ofd = creator.fd_table.get(master_fd).unwrap().ofd_ref.0; + let pty_idx = creator.ofd_table.get(master_ofd).unwrap().host_handle as usize; + crate::pty::get_pty(pty_idx).unwrap().locked = false; + let slave_path = format!("/dev/pts/{pty_idx}").into_bytes(); + + let mut observer = Process::new(26); + set_test_credentials(&mut observer, 3000, 3000, 4000, 4000, &[]); + assert_eq!( + sys_open(&mut observer, &mut host, &slave_path, O_WRONLY, 0), + Err(Errno::EACCES), + ); + + set_test_credentials(&mut observer, 3000, 3000, 4000, 4000, &[2000]); + assert!(sys_open(&mut observer, &mut host, &slave_path, O_WRONLY, 0).is_ok()); + assert!(sys_open(&mut creator, &mut host, &slave_path, O_RDWR, 0).is_ok()); + + let mut root = Process::new(27); + assert!(sys_open(&mut root, &mut host, &slave_path, O_RDWR, 0).is_ok()); + crate::pty::free_pty(pty_idx); + } + + #[test] + fn permission_sticky_directory_uses_effective_uid_not_saved_uid() { + let mut proc = Process::new(22); + proc.install_credentials(Credentials { + ruid: 1000, + euid: 3000, + suid: 2000, + rgid: 1000, + egid: 1000, + sgid: 1000, + supplementary_groups: vec![], + }); + let mut host = MockHostIO::new(); + host.set_dir_with_owner(b"/tmp", 0, 0, 0o1777); + host.set_file_with_owner(b"/tmp/saved-owned", 2000, 0, 0o644, b""); + + assert_eq!( + sys_unlink(&mut proc, &mut host, b"/tmp/saved-owned"), + Err(Errno::EPERM), + ); + } + #[test] fn test_dup_shares_ofd() { let mut proc = Process::new(1); @@ -18845,16 +19044,13 @@ mod tests { let _thread_guard = THREAD_IDENTITY_LOCK.lock().unwrap(); set_test_current_tid(0); let mut proc = Process::new(81_007); - proc.euid = 0; - proc.egid = 0; proc.umask = 0; let mut host = MockHostIO::new(); let fifo = b"/tmp/fifo_futimens_permissions"; create_test_fifo(&mut proc, &mut host, fifo, 0o666); let fd = sys_open(&mut proc, &mut host, fifo, O_RDWR, 0).unwrap(); - proc.euid = 1000; - proc.egid = 1000; + set_test_credentials(&mut proc, 0, 1000, 0, 1000, &[]); host.clock_time = (1_500_000_010, 123_456_789); assert_eq!( sys_utimensat(&mut proc, &mut host, fd, b"", None, 0), @@ -18913,8 +19109,7 @@ mod tests { ), ); - proc.euid = 0; - proc.egid = 0; + set_test_credentials(&mut proc, 0, 0, 0, 0, &[]); sys_close(&mut proc, &mut host, fd).unwrap(); sys_unlink(&mut proc, &mut host, fifo).unwrap(); } @@ -19013,8 +19208,6 @@ mod tests { let _thread_guard = THREAD_IDENTITY_LOCK.lock().unwrap(); set_test_current_tid(0); let mut proc = Process::new(81_030); - proc.euid = 0; - proc.egid = 0; let mut host = MockHostIO::new(); let old = b"/tmp/fifo_metadata_old"; let new = b"/tmp/fifo_metadata_new"; @@ -20159,8 +20352,8 @@ mod tests { /// VFS is the source of truth for file ownership. sys_stat must propagate /// uid/gid from the host VFS, not overwrite with the caller's effective - /// ids. proc.euid defaults to 0; with a host_stat returning (1000, 1000), - /// any override would clobber back to 0 (or to proc.euid) and fail. + /// IDs. The caller defaults to effective UID 0; with a host_stat returning + /// (1000, 1000), any caller-identity override would clobber the result. #[test] fn test_sys_stat_returns_host_uid_gid() { let mut proc = Process::new(1); @@ -20210,8 +20403,8 @@ mod tests { /// sys_chown must propagate uid/gid into the host VFS so that a subsequent /// sys_stat returns the freshly written values. The asymmetric (uid != gid, - /// neither equal to proc.euid) pair catches any single-field clobber and - /// any "kernel overrides chown args with caller's euid" regression. + /// neither equal to the caller's effective UID) pair catches any + /// single-field clobber and any caller-identity override regression. #[test] fn test_sys_chown_round_trip_through_host() { let mut proc = Process::new(1); @@ -20226,12 +20419,9 @@ mod tests { } #[test] - fn test_prepare_chown_ids_enforces_the_exposed_group_set() { + fn permission_prepare_chown_ids_enforces_the_authoritative_group_set() { let mut proc = Process::new(1); - proc.uid = 1000; - proc.euid = 1000; - proc.gid = 2000; // Kandelo's synthesized supplementary group. - proc.egid = 3000; + set_test_credentials(&mut proc, 1000, 1000, 2000, 3000, &[2000]); let st = WasmStat { st_dev: 0, st_ino: 1, @@ -20267,7 +20457,7 @@ mod tests { ); // An unprivileged owner cannot give a file away or name a group - // outside the effective-plus-synthesized group set. POSIX requires + // outside the effective-plus-supplementary group set. POSIX requires // the unchanged sentinel to preserve an existing foreign group; // explicitly naming that group is not authorized. assert_eq!( @@ -20283,23 +20473,20 @@ mod tests { Err(Errno::EPERM), ); - proc.euid = 1001; + set_test_credentials(&mut proc, 1000, 1001, 2000, 3000, &[2000]); assert_eq!( prepare_chown_ids(&proc, &st, CHOWN_ID_UNCHANGED, CHOWN_ID_UNCHANGED), Err(Errno::EPERM), ); - proc.euid = 0; + set_test_credentials(&mut proc, 1000, 0, 2000, 3000, &[2000]); assert_eq!(prepare_chown_ids(&proc, &st, 5000, 6000), Ok((5000, 6000)),); } #[test] fn test_sys_chown_allows_owner_group_changes_but_rejects_foreign_ids() { let mut proc = Process::new(1); - proc.uid = 1000; - proc.euid = 1000; - proc.gid = 2000; - proc.egid = 1000; + set_test_credentials(&mut proc, 1000, 1000, 2000, 1000, &[2000]); let mut host = MockHostIO::new(); host.set_file_with_owner(b"/owned", 1000, 4000, 0o755, b"hi"); @@ -20357,7 +20544,7 @@ mod tests { #[test] fn test_sys_chown_both_sentinels_require_owner_and_validate_path() { let mut proc = Process::new(1); - proc.euid = 1000; + set_test_credentials(&mut proc, 1000, 1000, 0, 0, &[]); let mut host = MockHostIO::new(); host.set_file_with_owner(b"/owned", 1000, 2000, 0o644, b"hi"); @@ -20371,7 +20558,7 @@ mod tests { .unwrap(); assert_eq!(host.chown_calls, vec![(b"/owned".to_vec(), 1000, 2000)],); - proc.euid = 2000; + set_test_credentials(&mut proc, 1000, 2000, 0, 0, &[]); assert_eq!( sys_chown( &mut proc, @@ -20441,7 +20628,7 @@ mod tests { #[test] fn test_sys_lchown_sentinels_use_link_owner_and_still_delegate() { let mut proc = Process::new(1); - proc.euid = 123; + set_test_credentials(&mut proc, 123, 123, 0, 0, &[]); let mut host = MockHostIO::new(); host.set_symlink(b"/owned-link", b"/target"); host.file_owners.insert(b"/owned-link".to_vec(), (123, 456)); @@ -20471,10 +20658,7 @@ mod tests { #[test] fn test_sys_lchown_authorizes_against_link_owner_and_caller_groups() { let mut proc = Process::new(1); - proc.uid = 1000; - proc.euid = 1000; - proc.gid = 2000; - proc.egid = 3000; + set_test_credentials(&mut proc, 1000, 1000, 2000, 3000, &[2000]); let mut host = MockHostIO::new(); host.set_symlink(b"/link", b"/target"); host.file_owners.insert(b"/link".to_vec(), (1000, 4000)); @@ -20515,7 +20699,7 @@ mod tests { sys_fchown(&mut proc, &mut host, fd, CHOWN_ID_UNCHANGED, 30).unwrap(); sys_fchown(&mut proc, &mut host, fd, 40, CHOWN_ID_UNCHANGED).unwrap(); - proc.euid = 40; + set_test_credentials(&mut proc, 40, 40, 0, 0, &[]); sys_fchown( &mut proc, &mut host, @@ -20547,15 +20731,12 @@ mod tests { } #[test] - fn test_sys_fchown_uses_effective_and_synthesized_groups() { + fn test_sys_fchown_uses_effective_and_supplementary_groups() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); host.set_file_with_owner(b"/fd-group", 1000, 4000, 0o755, b"hi"); let fd = sys_open(&mut proc, &mut host, b"/fd-group", O_RDONLY, 0).unwrap(); - proc.uid = 1000; - proc.euid = 1000; - proc.gid = 2000; - proc.egid = 3000; + set_test_credentials(&mut proc, 1000, 1000, 2000, 3000, &[2000]); sys_fchown(&mut proc, &mut host, fd, CHOWN_ID_UNCHANGED, 3000).unwrap(); sys_fchown(&mut proc, &mut host, fd, CHOWN_ID_UNCHANGED, 2000).unwrap(); @@ -20660,10 +20841,7 @@ mod tests { host.set_symlink(b"/dir/link", b"target"); host.file_owners.insert(b"/dir/link".to_vec(), (1000, 4000)); let dirfd = sys_open(&mut proc, &mut host, b"/dir", O_RDONLY | O_DIRECTORY, 0).unwrap(); - proc.uid = 1000; - proc.euid = 1000; - proc.gid = 2000; - proc.egid = 3000; + set_test_credentials(&mut proc, 1000, 1000, 2000, 3000, &[2000]); sys_fchownat( &mut proc, @@ -20917,10 +21095,7 @@ mod tests { #[test] fn access_uses_real_ids_for_component_search() { let mut proc = Process::new(1); - proc.uid = 1000; - proc.gid = 1000; - proc.euid = 0; - proc.egid = 0; + set_test_credentials(&mut proc, 1000, 0, 1000, 0, &[]); let mut host = MockHostIO::new(); host.set_dir_with_owner(b"/secret", 0, 0, 0o700); host.set_file_with_owner(b"/secret/file", 0, 0, 0o644, b""); @@ -20931,10 +21106,7 @@ mod tests { ); assert!(sys_stat(&mut proc, &mut host, b"/secret/file").is_ok()); - proc.uid = 0; - proc.gid = 0; - proc.euid = 1000; - proc.egid = 1000; + set_test_credentials(&mut proc, 0, 1000, 0, 1000, &[]); host.set_dir_with_owner(b"/root-only", 0, 0, 0o700); host.set_file_with_owner(b"/root-only/file", 0, 0, 0o644, b""); @@ -20956,12 +21128,9 @@ mod tests { } #[test] - fn access_uses_effective_and_synthesized_supplementary_groups() { + fn access_uses_effective_and_authoritative_supplementary_groups() { let mut proc = Process::new(1); - proc.uid = 1000; - proc.euid = 1000; - proc.gid = 2000; - proc.egid = 3000; + set_test_credentials(&mut proc, 1000, 1000, 2000, 3000, &[2000]); let mut host = MockHostIO::new(); host.set_dir_with_owner(b"/supp", 9999, 2000, 0o710); host.set_file_with_owner(b"/supp/file", 9999, 2000, 0o040, b"data"); @@ -21731,7 +21900,7 @@ mod tests { #[test] fn test_raise_preserves_self_sender_metadata() { let mut proc = Process::new(17); - proc.uid = 29; + set_test_credentials(&mut proc, 29, 29, 0, 0, &[]); sys_raise(&mut proc, 10).unwrap(); let info = proc.consume_signal_for(17, 10).unwrap(); @@ -23243,7 +23412,7 @@ mod tests { )); let pty_table = crate::pty::test_table_lock(); - let pty_index = crate::pty::alloc_pty().unwrap(); + let pty_index = crate::pty::alloc_pty(0, 0).unwrap(); for file_type in [FileType::PtyMaster, FileType::PtySlave] { assert_eq!( validate_scm_rights_transfer_metadata(file_type, pty_index as i64), @@ -31246,10 +31415,11 @@ mod tests { #[test] fn test_setuid_as_root_sets_both() { let mut proc = Process::new(1); - assert_eq!(proc.uid, 0); + assert_eq!(proc.real_uid(), 0); sys_setuid(&mut proc, 42).unwrap(); - assert_eq!(proc.uid, 42); - assert_eq!(proc.euid, 42); + assert_eq!(proc.real_uid(), 42); + assert_eq!(proc.effective_uid(), 42); + assert_eq!(proc.saved_uid(), 42); } #[test] @@ -31259,7 +31429,7 @@ mod tests { // Simulate regaining privilege partly: impossible without saved-set, // but setting euid back to real uid is always allowed. sys_seteuid(&mut proc, 7).unwrap(); - assert_eq!(proc.euid, 7); + assert_eq!(proc.effective_uid(), 7); } #[test] @@ -31267,16 +31437,17 @@ mod tests { let mut proc = Process::new(1); sys_setuid(&mut proc, 7).unwrap(); assert_eq!(sys_setuid(&mut proc, 99), Err(Errno::EPERM)); - assert_eq!(proc.uid, 7); - assert_eq!(proc.euid, 7); + assert_eq!(proc.real_uid(), 7); + assert_eq!(proc.effective_uid(), 7); } #[test] fn test_setgid_as_root_sets_both() { let mut proc = Process::new(1); sys_setgid(&mut proc, 42).unwrap(); - assert_eq!(proc.gid, 42); - assert_eq!(proc.egid, 42); + assert_eq!(proc.real_gid(), 42); + assert_eq!(proc.effective_gid(), 42); + assert_eq!(proc.saved_gid(), 42); } #[test] @@ -31290,8 +31461,8 @@ mod tests { fn test_seteuid_as_root_allows_any() { let mut proc = Process::new(1); sys_seteuid(&mut proc, 500).unwrap(); - assert_eq!(proc.uid, 0); // real uid unchanged - assert_eq!(proc.euid, 500); + assert_eq!(proc.real_uid(), 0); // real uid unchanged + assert_eq!(proc.effective_uid(), 500); } #[test] @@ -31299,7 +31470,7 @@ mod tests { let mut proc = Process::new(1); sys_setuid(&mut proc, 7).unwrap(); sys_seteuid(&mut proc, 7).unwrap(); - assert_eq!(proc.euid, 7); + assert_eq!(proc.effective_uid(), 7); } #[test] @@ -31313,8 +31484,8 @@ mod tests { fn test_setegid_as_root_allows_any() { let mut proc = Process::new(1); sys_setegid(&mut proc, 500).unwrap(); - assert_eq!(proc.gid, 0); - assert_eq!(proc.egid, 500); + assert_eq!(proc.real_gid(), 0); + assert_eq!(proc.effective_gid(), 500); } #[test] @@ -31329,10 +31500,10 @@ mod tests { } #[test] - fn test_can_signal_matches_real_or_effective() { + fn signal_permission_matches_target_real_or_saved_uid() { // sender.euid matches target.ruid assert!(can_signal(99, 7, 7, 0)); - // sender.ruid matches target.euid + // sender.ruid matches target.suid assert!(can_signal(7, 99, 0, 7)); } diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index 0bab44ac5d..03b8ae69c9 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -1262,7 +1262,7 @@ fn current_pid_eids() -> (u32, u32, u32) { let table = unsafe { &*PROCESS_TABLE.0.get() }; let pid = table.current_pid(); match table.get(pid) { - Some(p) => (pid, p.euid, p.egid), + Some(p) => (pid, p.effective_uid(), p.effective_gid()), None => (pid, 0, 0), } } @@ -1610,14 +1610,10 @@ pub extern "C" fn kernel_set_cwd(pid: u32, path_ptr: *const u8, path_len: u32) - pub extern "C" fn kernel_set_process_credentials(pid: u32, uid: u32, gid: u32) -> i32 { let table = unsafe { &mut *PROCESS_TABLE.0.get() }; if let Some(proc) = table.get_mut(pid) { - if uid != u32::MAX { - proc.uid = uid; - proc.euid = uid; - } - if gid != u32::MAX { - proc.gid = gid; - proc.egid = gid; - } + proc.configure_ids( + (uid != u32::MAX).then_some(uid), + (gid != u32::MAX).then_some(gid), + ); 0 } else { -(Errno::ESRCH as i32) @@ -2291,7 +2287,7 @@ pub extern "C" fn kernel_generate_host_signal(pid: u32, signum: u32) -> i32 { return 0; } - let sender_uid = proc.uid; + let sender_uid = proc.real_uid(); proc.raise_signal_with_metadata(signum, 0, 0, pid, sender_uid); if let Some(target_tid) = proc.pick_thread_for_shared_signal(signum) { let mut host = WasmHostIO; @@ -2540,8 +2536,8 @@ pub extern "C" fn kernel_enum_procs(out_ptr: *mut u8, out_len: u32) -> i32 { &ProcessSnapshotHeader { pid: proc.pid, ppid: proc.ppid, - uid: proc.euid, - gid: proc.egid, + uid: proc.effective_uid(), + gid: proc.effective_gid(), vsize, state, comm_len, @@ -4271,12 +4267,13 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr channel_mut_ptr!(2, u32), ), // SYS_GETRESGID 135 => { - let list_pointer = if a1 == 0 { + let size = process_size_u32!(0); + let list_pointer = if size == 0 { core::ptr::null_mut() } else { channel_mut_ptr!(1, u32) }; - kernel_getgroups(a1 as u32, list_pointer, a3 as u32) + kernel_getgroups(size, list_pointer) } // SYS_GETGROUPS 136 => kernel_setgroups(process_size_u32!(0), channel_const_ptr!(1, u32)), // SYS_SETGROUPS @@ -4351,9 +4348,8 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr } 113 => kernel_fpathconf(a1, a2, channel_mut_ptr!(2, i64)), // SYS_FPATHCONF - // setreuid/setregid — map to setresuid/setresgid with -1 for saved ID - 215 => kernel_setresuid(a1 as u32, a2 as u32, 0xFFFFFFFF), // SYS_SETREUID - 216 => kernel_setresgid(a1 as u32, a2 as u32, 0xFFFFFFFF), // SYS_SETREGID + 215 => kernel_setreuid(a1 as u32, a2 as u32), // SYS_SETREUID + 216 => kernel_setregid(a1 as u32, a2 as u32), // SYS_SETREGID // Timer 225 => { @@ -5322,11 +5318,11 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr // --- setfsuid/setfsgid: return previous fsuid/fsgid (we mirror euid/egid) --- 370 => { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; - proc.euid as i32 + proc.effective_uid() as i32 } 371 => { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; - proc.egid as i32 + proc.effective_gid() as i32 } // --- faccessat2/fchmodat2: delegate to existing implementations --- @@ -5471,7 +5467,7 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr let sender_pid = process_table.current_pid(); let sender_uid = process_table .get(sender_pid) - .map(|process| process.uid) + .map(Process::real_uid) .unwrap_or(0); if queue_mqueue_signal_notification( process_table, @@ -6066,7 +6062,7 @@ pub extern "C" fn kernel_ipc_shmat_for_process( if pid != crate::process_table::SYNTHETIC_INIT_PID && matches!(proc.state, ProcessState::Running | ProcessState::Stopped) => { - (proc.euid, proc.egid) + (proc.effective_uid(), proc.effective_gid()) } _ => return -(Errno::ESRCH as i32), }; @@ -6366,7 +6362,7 @@ pub extern "C" fn kernel_semctl_array_bytes(pid: u32, tid: u32, semid: i32, cmd: return -(error as i32); } let (uid, gid) = match table.get(pid) { - Some(process) => (process.euid, process.egid), + Some(process) => (process.effective_uid(), process.effective_gid()), None => return -(Errno::ESRCH as i32), }; let ipc = unsafe { crate::ipc::global_ipc_table() }; @@ -7706,7 +7702,11 @@ fn kernel_kill_with_metadata(pid: i32, sig: u32, si_value_bits: u64, si_code: i3 let caller_tid = table.current_tid(); let (caller_pgid, sender_uid, sender_euid) = match table.get(caller_pid) { Some(caller) if caller.is_live_explicit_tid(caller_tid) => { - (caller.pgid, caller.uid, caller.euid) + ( + caller.pgid, + caller.real_uid(), + caller.effective_uid(), + ) } None => return -(Errno::ESRCH as i32), Some(_) => return -(Errno::ESRCH as i32), @@ -7729,7 +7729,12 @@ fn kernel_kill_with_metadata(pid: i32, sig: u32, si_value_bits: u64, si_code: i3 let target_pid = pid as u32; let result = match table.get(target_pid) { Some(target) - if !syscalls::can_signal(sender_uid, sender_euid, target.uid, target.euid) => + if !syscalls::can_signal( + sender_uid, + sender_euid, + target.real_uid(), + target.saved_uid(), + ) => { -(Errno::EPERM as i32) } @@ -7778,7 +7783,12 @@ fn kernel_kill_with_metadata(pid: i32, sig: u32, si_value_bits: u64, si_code: i3 let Some(target) = table.get(target_pid) else { continue; }; - if !syscalls::can_signal(sender_uid, sender_euid, target.uid, target.euid) { + if !syscalls::can_signal( + sender_uid, + sender_euid, + target.real_uid(), + target.saved_uid(), + ) { any_perm_denied = true; continue; } @@ -7829,7 +7839,12 @@ fn kernel_kill_with_metadata(pid: i32, sig: u32, si_value_bits: u64, si_code: i3 let mut any_perm_denied = false; for &target_pid in &pids { if let Some(target) = table.get(target_pid) { - if !syscalls::can_signal(sender_uid, sender_euid, target.uid, target.euid) { + if !syscalls::can_signal( + sender_uid, + sender_euid, + target.real_uid(), + target.saved_uid(), + ) { any_perm_denied = true; continue; } @@ -7972,13 +7987,17 @@ fn kernel_sched_validate_pid(pid: i32) -> i32 { return 0; // pid 0 means current process } let (_gkl, caller) = unsafe { get_process() }; - let sender_euid = caller.euid; + let sender_euid = caller.effective_uid(); let table = unsafe { &mut *PROCESS_TABLE.0.get() }; table.ensure_init(); match table.get(pid as u32) { None => -(Errno::ESRCH as i32), Some(target) => { - if syscalls::can_query_sched(sender_euid, target.uid, target.euid) { + if syscalls::can_query_sched( + sender_euid, + target.real_uid(), + target.effective_uid(), + ) { 0 } else { -(Errno::EPERM as i32) @@ -8170,7 +8189,7 @@ fn kernel_tkill_with_value(tid: u32, sig: u32, si_value_bits: u64, si_code: i32) } let sender_pid = proc.pid; - let sender_uid = proc.uid; + let sender_uid = proc.real_uid(); // Main thread: use its directed queue rather than the process-shared set. if proc.is_main_thread(tid) { @@ -8501,6 +8520,30 @@ pub extern "C" fn kernel_fstatfs(fd: i32, buf_ptr: *mut u8, process_pointer_widt result } +/// setreuid/setregid channel implementations. These remain private because +/// syscall dispatch, not a new Wasm export, is their ABI-43 entry point. +fn kernel_setreuid(ruid: u32, euid: u32) -> i32 { + let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; + let result = match syscalls::sys_setreuid(proc, ruid, euid) { + Ok(()) => 0, + Err(error) => -(error as i32), + }; + let mut host = WasmHostIO; + deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); + result +} + +fn kernel_setregid(rgid: u32, egid: u32) -> i32 { + let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; + let result = match syscalls::sys_setregid(proc, rgid, egid) { + Ok(()) => 0, + Err(error) => -(error as i32), + }; + let mut host = WasmHostIO; + deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); + result +} + /// setresuid — set real, effective, and saved user IDs. #[unsafe(no_mangle)] pub extern "C" fn kernel_setresuid(ruid: u32, euid: u32, suid: u32) -> i32 { @@ -8569,36 +8612,34 @@ pub extern "C" fn kernel_getresgid( /// getgroups — get supplementary group IDs. /// Returns count on success, negative errno on error. -fn validate_getgroups_destination( - size: u32, - list_ptr: *mut u32, - list_capacity_bytes: u32, -) -> Result<(), Errno> { - if size > 0 && (list_ptr.is_null() || list_capacity_bytes < core::mem::size_of::() as u32) - { +fn validate_getgroups_destination(size: u32, list_ptr: *mut u32) -> Result<(), Errno> { + if size as usize > crate::credentials::NGROUPS_MAX { + return Err(Errno::EINVAL); + } + if size > 0 && list_ptr.is_null() { return Err(Errno::EFAULT); } Ok(()) } #[unsafe(no_mangle)] -pub extern "C" fn kernel_getgroups(size: u32, list_ptr: *mut u32, list_capacity_bytes: u32) -> i32 { - if let Err(error) = validate_getgroups_destination(size, list_ptr, list_capacity_bytes) { +pub extern "C" fn kernel_getgroups(size: u32, list_ptr: *mut u32) -> i32 { + if let Err(error) = validate_getgroups_destination(size, list_ptr) { return -(error as i32); } let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let result = match syscalls::sys_getgroups(proc, size) { - Ok((count, gid)) => { - if size > 0 { - // WHY: the host lends exactly the declared allocation - // capacity. Kernel linear-memory bounds alone do not prove - // that the following four bytes belong to that allocation. - unsafe { - *list_ptr = gid; - } - } - count as i32 - } + // WHY: the shared descriptor lends exactly size * sizeof(gid_t). + // Count-only queries never name or inspect a destination. + Ok(groups) => match syscalls::copy_getgroups_to_destination( + size, + list_ptr, + size * size_of::() as u32, + groups, + ) { + Ok(count) => count as i32, + Err(error) => -(error as i32), + }, Err(e) => -(e as i32), }; let mut host = WasmHostIO; @@ -8613,32 +8654,45 @@ mod getgroups_destination_tests { #[test] fn count_query_does_not_require_a_destination() { assert_eq!( - validate_getgroups_destination(0, core::ptr::null_mut(), 0), + validate_getgroups_destination(0, core::ptr::null_mut()), Ok(()) ); } #[test] - fn positive_request_requires_the_explicit_gid_capacity() { + fn positive_request_requires_a_destination_and_bounded_count() { let pointer = core::ptr::NonNull::::dangling().as_ptr(); assert_eq!( - validate_getgroups_destination(1, core::ptr::null_mut(), 4), + validate_getgroups_destination(1, core::ptr::null_mut()), Err(Errno::EFAULT) ); assert_eq!( - validate_getgroups_destination(1, pointer, 3), - Err(Errno::EFAULT) + validate_getgroups_destination( + crate::credentials::NGROUPS_MAX as u32 + 1, + pointer, + ), + Err(Errno::EINVAL) ); - assert_eq!(validate_getgroups_destination(1, pointer, 4), Ok(())); - assert_eq!(validate_getgroups_destination(1, pointer, 5), Ok(())); + assert_eq!(validate_getgroups_destination(1, pointer), Ok(())); } } -/// setgroups — set supplementary group IDs (no-op). +/// setgroups — replace the complete ordered supplementary group list. #[unsafe(no_mangle)] -pub extern "C" fn kernel_setgroups(size: u32, _list_ptr: *const u32) -> i32 { +pub extern "C" fn kernel_setgroups(size: u32, list_ptr: *const u32) -> i32 { + if size as usize > crate::credentials::NGROUPS_MAX { + return -(Errno::EINVAL as i32); + } + if size > 0 && list_ptr.is_null() { + return -(Errno::EFAULT as i32); + } let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; - let result = match syscalls::sys_setgroups(proc, size) { + let groups = if size == 0 { + &[][..] + } else { + unsafe { core::slice::from_raw_parts(list_ptr, size as usize) } + }; + let result = match syscalls::sys_setgroups(proc, groups) { Ok(()) => 0, Err(e) => -(e as i32), }; @@ -13506,7 +13560,7 @@ pub extern "C" fn kernel_pty_create(pid: u32) -> i32 { }; // Allocate a new PTY pair - let pty_idx = match crate::pty::alloc_pty() { + let pty_idx = match crate::pty::alloc_pty(proc.effective_uid(), proc.effective_gid()) { Some(idx) => idx, None => return -(Errno::ENOSPC as i32), }; diff --git a/crates/shared/src/channel_scalar.rs b/crates/shared/src/channel_scalar.rs index c383a90207..9c4a34c75b 100644 --- a/crates/shared/src/channel_scalar.rs +++ b/crates/shared/src/channel_scalar.rs @@ -489,6 +489,12 @@ pub const SYSCALLS: &[ChannelScalarSyscall] = &[ arguments: PROCESS_ADDRESS_AND_SIZE_ARGUMENTS_0_1, result: ChannelResultKind::I32, }, + ChannelScalarSyscall { + syscall_number: Syscall::Getgroups as u32, + musl_name: "getgroups", + arguments: PROCESS_SIZE_ARGUMENT_0, + result: ChannelResultKind::I32, + }, ChannelScalarSyscall { syscall_number: Syscall::Setgroups as u32, musl_name: "setgroups", @@ -863,6 +869,17 @@ mod tests { assert_eq!(result_kind(Syscall::Seek as u32), ChannelResultKind::I64); } + #[test] + fn group_count_scalars_use_the_callers_process_size_width() { + for syscall in [Syscall::Getgroups, Syscall::Setgroups] { + let contract = SYSCALLS + .iter() + .find(|entry| entry.syscall_number == syscall as u32) + .expect("group syscall scalar contract"); + assert_eq!(contract.arguments, PROCESS_SIZE_ARGUMENT_0); + } + } + #[test] fn native_width_and_exact_u32_scalars_never_alias_high_bits() { let four_gib_plus_page = 0x1_0000_1000u64; diff --git a/crates/shared/src/host_abi.rs b/crates/shared/src/host_abi.rs index 4dd06b6849..c13e0f7841 100644 --- a/crates/shared/src/host_abi.rs +++ b/crates/shared/src/host_abi.rs @@ -62,6 +62,15 @@ pub enum SyscallArgSize { pub enum SyscallArgCopyOutLength { /// Read a little-endian `u32` field from another staged argument. U32Field { arg_index: u8, offset: u32 }, + /// Bound a successful syscall return value, then multiply it by a fixed + /// byte width. + /// + /// This covers results such as `getgroups`, whose return value counts + /// native entries instead of bytes. + ReturnValue { + multiplier: u32, + max_value: u32, + }, } /// One pointer argument descriptor for host-side marshalling. @@ -91,6 +100,14 @@ impl SyscallArgDesc { }); self } + + const fn with_copy_out_return_value(mut self, multiplier: u32, max_value: u32) -> Self { + self.copy_out_length = Some(SyscallArgCopyOutLength::ReturnValue { + multiplier, + max_value, + }); + self + } } /// All pointer argument descriptors for one syscall number. @@ -616,6 +633,11 @@ pub const SYSCALL_ARG_DESCRIPTORS: &[SyscallArgDescriptor] = &[ desc!(2, Out, fixed!(4), required), ] ), + entry!( + Syscall::Getgroups as u32, + [desc!(1, Out, arg!(0, mul 4), required) + .with_copy_out_return_value(4, platform_limits::NGROUPS_MAX as u32)] + ), entry!( Syscall::Setgroups as u32, [desc!(1, In, arg!(0, mul 4), required)] @@ -1432,6 +1454,26 @@ mod tests { ); assert!(setgroups.required); + let getgroups = find(Syscall::Getgroups as u32).args[0]; + assert_eq!(getgroups.arg_index, 1); + assert_eq!(getgroups.direction, SyscallArgDirection::Out); + assert_eq!( + getgroups.size, + SyscallArgSize::Arg { + arg_index: 0, + multiplier: 4, + add: 0, + } + ); + assert_eq!( + getgroups.copy_out_length, + Some(SyscallArgCopyOutLength::ReturnValue { + multiplier: 4, + max_value: platform_limits::NGROUPS_MAX as u32, + }), + ); + assert!(getgroups.required); + let semop = find(extra_syscalls::SYS_SEMOP).args[0].size; assert_eq!( semop, @@ -1649,9 +1691,7 @@ mod tests { for entry in SYSCALL_ARG_DESCRIPTORS { for desc in entry.args { - let Some(SyscallArgCopyOutLength::U32Field { arg_index, offset }) = - desc.copy_out_length - else { + let Some(copy_out_length) = desc.copy_out_length else { continue; }; assert_eq!( @@ -1661,6 +1701,20 @@ mod tests { entry.syscall_number, desc.arg_index, ); + let (arg_index, offset) = match copy_out_length { + SyscallArgCopyOutLength::U32Field { arg_index, offset } => { + (arg_index, offset) + } + SyscallArgCopyOutLength::ReturnValue { + multiplier, + max_value, + } => { + assert_ne!(multiplier, 0); + assert_ne!(max_value, 0); + assert!(multiplier.checked_mul(max_value).is_some()); + continue; + } + }; assert!( matches!(desc.size, SyscallArgSize::Arg { .. }), "syscall {} arg {} copy-out override needs an explicit caller capacity", @@ -1694,7 +1748,6 @@ mod tests { extra_syscalls::SYS_PWRITEV2, Syscall::Sendmsg as u32, Syscall::Recvmsg as u32, - Syscall::Getgroups as u32, extra_syscalls::SYS_MSGRCV, extra_syscalls::SYS_MSGSND, ] { diff --git a/docs/abi-versioning.md b/docs/abi-versioning.md index 870e95264d..91def96ef0 100644 --- a/docs/abi-versioning.md +++ b/docs/abi-versioning.md @@ -575,6 +575,23 @@ eight-byte alignment bucket; the exact capacity comes from the host's pre-captured value under the single synchronous, non-reentrant lease. Adding a second per-descriptor capacity would itself be a future ABI design change. +ABI 43 describes `getgroups` and `setgroups` through that generic pointer +table. Their count is a caller-native process-size scalar and their vector is +exactly `count * sizeof(gid_t)` bytes, bounded by `NGROUPS_MAX` before scratch +allocation. `getgroups` adds a generated return-value copy-out rule: the host +copies `return_value * sizeof(gid_t)` bytes, so a count-only query lends no +destination and a larger caller buffer keeps its unused tail. The public +`kernel_getgroups` export consequently takes only `(size, list)`; the former +host-selected capacity argument and special one-group handler are removed. + +The same unpublished ABI 43 batch advances the exact fork and test-only exec +state record to version 15. After the parent identity it stores real, +effective, and saved UID; real, effective, and saved GID; an ordered bounded +supplementary-group vector; and the kernel-owned `secure_exec` bit. Versions +14 and 16, malformed counts, truncation, and trailing bytes are rejected +instead of reconstructed through a compatibility fallback. The complete +record is validated before the credential value or `secure_exec` is installed. + `prctl` deliberately has no generic pointer descriptor. Only `PR_SET_NAME` and `PR_GET_NAME` interpret argument 1 as a required exact 16-byte scratch buffer; other options preserve its low 32-bit scalar value. Treating that slot as one diff --git a/docs/posix-status.md b/docs/posix-status.md index 17c00dad86..65e8a4f0bf 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -138,9 +138,9 @@ same final-OFD lifetime rules. | `getppid()` | Partial | Returns the stored ppid (0 for virtual init and a top-level process launched directly by the host). An orphaned guest child is not yet reparented to virtual init. | | `getuid()` / `geteuid()` | Full | Simulated; defaults to uid=0 (root). Configurable via setuid/seteuid. | | `getgid()` / `getegid()` | Full | Simulated; defaults to gid=0 (root). Configurable via setgid/setegid. | -| `setuid()` / `seteuid()` | Full | POSIX semantics (no saved-set-uid tracked). As root: setuid sets both uid and euid; seteuid sets any euid. Non-root: setuid only to own uid; seteuid only to own ruid. Returns EPERM otherwise. | -| `setgid()` / `setegid()` | Full | POSIX semantics mirroring setuid — gated on euid==0 for privileged changes. Returns EPERM for non-root trying to change to a foreign gid. | -| `getgroups()` / `setgroups()` | Partial | The current credential model exposes the real GID as one synthesized supplementary group, in addition to the effective GID used by permission and ownership checks. Privileged `setgroups()` is still a no-op and cannot install an arbitrary supplementary-group list; adding a real inherited list requires a future process-state and fork/exec serialization change. | +| `setuid()` / `seteuid()` | Full | The process retains real, effective, and saved IDs. Privileged `setuid()` updates all three; an unprivileged process may select its real or saved ID as effective. Transitions are atomic and return `EPERM` without mutation when disallowed. | +| `setgid()` / `setegid()` | Full | Mirrors the complete real/effective/saved UID model and uses effective UID 0 for privileged changes. Disallowed transitions return `EPERM` without mutation. | +| `getgroups()` / `setgroups()` | Full | Stores an ordered supplementary-group list of zero through `NGROUPS_MAX` (32) entries. A zero-size `getgroups()` query returns the count without a destination; short capacity returns `EINVAL`, and successful copyout touches only the returned entries. `setgroups()` is root-only and atomically replaces the complete list. Fork, vfork, and exec-state transport preserve the list independently. | | `getpriority()` / `setpriority()` | Partial | Stores a per-process nice value; WebAssembly has no host CPU scheduler to apply it to. Linux-compatible `/proc//stat` exposes scheduler priority in field 18 and nice in field 19. Procfs metadata operations reject missing or reaped PID scopes. | | `getpgrp()` | Full | Returns process group ID (simulated, defaults to pid). | | `setpgid()` | Partial | Sets process group ID. pid=0 means self. pgid=0 means use target pid. Only supports setting own pgid; other processes return ESRCH. | @@ -241,7 +241,7 @@ to a different directory than the original OFD. | `stat()` / `lstat()` | Partial | Host-delegated. stat follows symlinks, lstat does not. Procfs fd magic links are validated against live fd/OFD pairs: following `/proc//fd/N` returns the target OFD metadata even after its pathname is unlinked, while no-follow operations report the symlink and closed slots return ENOENT. Registered AF_UNIX pathname sockets preserve the backing VFS inode's uid, gid, permissions, timestamps, and link count while reporting `S_IFSOCK`. Registered named FIFOs likewise preserve VFS metadata while reporting `S_IFIFO`; `readdir()` and `getdents64()` report `DT_FIFO`. | | `statfs()` / `fstatfs()` | Partial | Host-backed and virtual filesystem statistics are reported. Mounts default to `ST_NOSUID` in both Node and browser hosts. Only a read-only product backend admitted through a module-private brand over a privately snapshotted, fully materialized and behaviorally isolated tree can clear it; trusted operations use private prototype copies, module-lexical helpers, and captured scalar ABI semantics rather than producer-reachable prototypes, class properties, or generated tables. The resolved mount capability authoritatively sets or clears the bit instead of trusting raw backend flags. The kernel can compute a set-ID transition proposal from retained target metadata, but exec does not commit that proposal to process credentials yet. | | `chmod()` / `chown()` / `lchown()` | Partial | VFS metadata updates. `chown()` follows the final symlink; `lchown()` changes the link itself, including dangling links. Ownership calls preserve either unchanged-ID sentinel and validate the selected object and authorization before delegation. Root may select arbitrary IDs; an unprivileged owner must preserve its user ID and may select its effective GID or Kandelo's synthesized supplementary/real GID. On metadata-backed SharedFS and Node regular files, successful calls clear S_ISUID and S_ISGID when any execute bit is set while leaving non-executable files, directories, and symlink targets selected by `lchown()` unchanged. Node host-backed changes stay in virtual metadata; browser memory-backed mounts store them in the VFS. OPFS has neither symlinks nor ownership metadata, so its existing ownership operations are no-ops. Arbitrary supplementary-group lists remain unsupported. | -| `access()` | Partial | Resolves the pathname component-wise and checks traversal plus target permissions with real credentials. `faccessat(..., AT_EACCESS)` selects effective credentials. Both include Kandelo's one synthesized supplementary/real GID in group checks; arbitrary supplementary-group lists remain unsupported. | +| `access()` | Partial | Resolves the pathname component-wise and checks traversal plus target permissions with real credentials. `faccessat(..., AT_EACCESS)` selects effective credentials. Both use effective GID and the process's complete supplementary-group membership for group checks. | | `realpath()` | Full | Uses the global component walker against cwd, including mount crossings and relative or absolute symlinks; `missing/..` fails instead of being collapsed lexically, trailing slash requires a directory, and more than 40 symlinks returns ELOOP. | | `symlink()` / `readlink()` | Partial | Host-delegated. Symlink target stored as-is, linkpath resolved. | | `sync()` / `syncfs()` | Stub | Returns 0 (no-op). Filesystem sync managed by host. | @@ -728,7 +728,7 @@ Target use case: hosting PHP-WASM (as used by WordPress Playground) on this kern | ~~`getrandom()` syscall~~ | random | **Done.** Host-delegated to `crypto.getRandomValues()`. | ~~Easy~~ | | ~~`putenv()` syscall~~ | environment | **Done.** Parses `KEY=VALUE` string, delegates to setenv. | ~~Easy~~ | | ~~Virtual device files in VFS~~ | VFS | **Done.** `/dev/null`, `/dev/zero`, `/dev/urandom`, `/dev/full`, `/dev/fd/N`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr` all handled in-kernel. | ~~Medium~~ | -| ~~`initgroups()` stub~~ | process | **Done.** musl's initgroups() calls setgroups(), which is a no-op stub. | ~~Easy~~ | +| ~~`initgroups()` stub~~ | process | **Done.** musl's `initgroups()` calls the root-only, atomic complete-list `setgroups()` implementation. | ~~Easy~~ | ### Phase B — Networking (enables WordPress HTTP requests + MySQL) diff --git a/examples/kernel_scratch_browser_test.c b/examples/kernel_scratch_browser_test.c index cf55602cab..0e16ca31cd 100644 --- a/examples/kernel_scratch_browser_test.c +++ b/examples/kernel_scratch_browser_test.c @@ -284,6 +284,55 @@ static int test_zero_iov(void) return 0; } +static int test_groups(void) +{ + gid_t initial[] = { 7000, 42, 9000 }; + gid_t output[5] = { 0, 0, 0, 0x5a5a, 0xa5a5 }; + + if (setgroups(3, initial) < 0) + fail("setgroups complete vector"); + int group_count = getgroups(0, NULL); + if (group_count != 3) { + fprintf(stderr, "getgroups count=%d errno=%d\n", group_count, errno); + errno = EIO; + fail("getgroups count query"); + } + errno = 0; + expect_errno_result(getgroups(2, output), EINVAL, + "getgroups insufficient capacity"); + errno = 0; + expect_errno_result(getgroups(1, NULL), EFAULT, + "getgroups null output"); + if (getgroups(5, output) != 3 || + memcmp(output, initial, sizeof(initial)) != 0 || + output[3] != 0x5a5a || output[4] != 0xa5a5) { + errno = EIO; + fail("getgroups bounded copyback"); + } + + gid_t maximum[32]; + gid_t maximum_output[32]; + for (size_t index = 0; index < 32; index++) + maximum[index] = (gid_t)(10000 + index * 7); + if (setgroups(32, maximum) < 0) + fail("setgroups maximum vector"); + if (getgroups(32, maximum_output) != 32 || + memcmp(maximum_output, maximum, sizeof(maximum)) != 0) { + errno = EIO; + fail("getgroups maximum vector"); + } + errno = 0; + expect_errno_result(setgroups(33, maximum), EINVAL, + "setgroups oversized vector"); + errno = 0; + expect_errno_result(setgroups(1, NULL), EFAULT, + "setgroups null input"); + + printf("KERNEL_SCRATCH_GROUPS_PASS pointer_bits=%zu groups=%zu\n", + sizeof(uintptr_t) * CHAR_BIT, sizeof(maximum) / sizeof(maximum[0])); + return 0; +} + static int test_readv(size_t iovec_count, size_t bytes_per_iovec) { if (iovec_count > IOV_MAX || @@ -581,6 +630,8 @@ int main(int argc, char **argv) return test_append_flags(); if (argc == 2 && strcmp(argv[1], "zero-iov") == 0) return test_zero_iov(); + if (argc == 2 && strcmp(argv[1], "groups") == 0) + return test_groups(); if (argc == 4 && strcmp(argv[1], "readv") == 0) { return test_readv( parse_size(argv[2], "readv iovec count"), @@ -611,7 +662,7 @@ int main(int argc, char **argv) "usage: %s readv IOVEC_COUNT BYTES_PER_IOVEC | " "dgram-vector IOVEC_COUNT BYTES_PER_IOVEC | " "positioned-vector IOVEC_COUNT BYTES_PER_IOVEC | " - "append-flags | zero-iov | " + "append-flags | zero-iov | groups | " "pty EXPECTED_LENGTH EXPECTED_BYTE\n", argv[0]); return 2; diff --git a/host/src/generated/abi.ts b/host/src/generated/abi.ts index d1aabce15f..b55586c5f5 100644 --- a/host/src/generated/abi.ts +++ b/host/src/generated/abi.ts @@ -1198,6 +1198,7 @@ export const CHANNEL_SCALAR_SLOT_CONTRACTS: Readonly< 122: { 2: "process-size", }, 126: { 0: "process-address", 1: "process-size", 2: "process-size", }, 128: { 0: "process-address", 1: "process-size", }, + 135: { 0: "process-size", }, 136: { 0: "process-size", }, 200: { 0: "process-address", }, 203: { 0: "process-address", }, @@ -1512,7 +1513,8 @@ export type SyscallArgSizeSpec = | { type: "process-layout"; wasm32Size: number; wasm64Size: number }; export type SyscallArgCopyOutLengthSpec = - { type: "u32-field"; argIndex: number; offset: number }; + | { type: "u32-field"; argIndex: number; offset: number } + | { type: "return-value"; multiplier: number; maxValue: number }; export const PROCESS_POINTER_WIDTH_ARG_INDEX = 5 as const; @@ -1887,6 +1889,9 @@ export const SYSCALL_ARGS: Record = { { argIndex: 1, direction: "out", size: { type: "fixed", size: 4 }, required: true }, { argIndex: 2, direction: "out", size: { type: "fixed", size: 4 }, required: true }, ], + 135: [ + { argIndex: 1, direction: "out", size: { type: "arg", argIndex: 0, multiplier: 4 }, required: true, copyOutLength: { type: "return-value", multiplier: 4, maxValue: 32 } }, + ], 136: [ { argIndex: 1, direction: "in", size: { type: "arg", argIndex: 0, multiplier: 4 }, required: true }, ], diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index f0142d4386..99877dd72f 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -782,7 +782,10 @@ function validateCompleteChannelInputSize( ); } if ( - syscallNr === ABI_SYSCALLS.Setgroups + ( + syscallNr === ABI_SYSCALLS.Getgroups + || syscallNr === ABI_SYSCALLS.Setgroups + ) && argIndex === 1 && size > POSIX_NGROUPS_MAX * 4 ) { @@ -1035,8 +1038,6 @@ const SYS_PREADV = ABI_SYSCALLS.Preadv; const SYS_PWRITEV = ABI_SYSCALLS.Pwritev; const SYS_PREADV2 = ABI_SYSCALLS.Preadv2; const SYS_PWRITEV2 = ABI_SYSCALLS.Pwritev2; -const SYS_GETGROUPS = ABI_SYSCALLS.Getgroups; - /** fcntl commands that take a struct flock pointer */ const SYS_FCNTL = ABI_SYSCALLS.Fcntl; @@ -5925,6 +5926,36 @@ export class CentralizedKernelWorker { break; } copySize = actualLength; + } else if ( + desc.direction === "out" + && copyOutLength?.type === "return-value" + ) { + // A zero-capacity query may return a bounded logical count without + // producing bytes. Validate that producer count and its complete + // byte extent before the no-copy exit so the query cannot publish + // an impossible result. + if ( + !Number.isSafeInteger(retVal) + || retVal < 0 + || retVal > copyOutLength.maxValue + ) { + outputContractViolation = true; + break; + } + const actualLength = retVal * copyOutLength.multiplier; + if ( + !Number.isSafeInteger(actualLength) + || actualLength < 0 + ) { + outputContractViolation = true; + break; + } + if (planned.size === 0) continue; + if (actualLength > planned.size) { + outputContractViolation = true; + break; + } + copySize = actualLength; } else if ( desc.direction === "out" && desc.size.type === "arg" @@ -11141,15 +11172,6 @@ export class CentralizedKernelWorker { return; } - // --- getgroups: the return value is an entry count, not a byte count --- - // A simple output descriptor cannot express that getgroups(0, list) must - // not touch list while every positive-size call exposes exactly one - // four-byte slot in Kandelo's current single-supplementary-group model. - if (syscallNr === SYS_GETGROUPS) { - this.handleGetgroups(channel, origArgs, rawArgs, entry); - return; - } - // --- Large write/pwrite/read/pread: one kernel-owned transfer region --- // The ordinary channel has a fixed data capacity. Preserve one POSIX I/O // operation above that boundary instead of splitting datagrams, pipe @@ -19303,128 +19325,6 @@ export class CentralizedKernelWorker { this.finishNetworkIoctl(channel, entry); } - /** - * Marshal getgroups without treating its entry-count return value as bytes. - * - * WHY: Kandelo currently exposes one supplementary gid. A positive-size - * request therefore lends Rust one exact four-byte destination; a size-zero - * count query lends no pointer at all. Passing the caller pointer directly - * or inferring capacity from total kernel memory would lose both facts. - */ - private handleGetgroups( - channel: ChannelInfo, - origArgs: number[], - rawArgs: readonly bigint[], - entry: KernelWorkerEntryContext, - ): void { - const rawSize = rawArgs[0] ?? 0n; - if (rawSize < 0n || rawSize > 0x7fff_ffffn) { - this.completeChannelRawAndRelisten(channel, -1, EINVAL, entry); - return; - } - const size = Number(rawSize); - let processPointer = 0; - if (size > 0) { - try { - processPointer = this.checkedProcessRange( - channel, - rawArgs[1] ?? 0n, - 4, - "getgroups output", - ).pointer; - } catch (error) { - this.#rejectScratchTransfer(channel, error, entry); - return; - } - } - - let result: { - retVal: number; - errVal: number; - output: Uint8Array | null; - }; - try { - result = this.#requireMainScratchRegion().withLease((lease) => { - const kernelView = lease.dataView(0, CH_TOTAL_SIZE); - for (let index = 0; index < CH_ARGS_COUNT; index++) { - kernelView.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, 0n, true); - } - kernelView.setUint32(CH_SYSCALL, SYS_GETGROUPS, true); - kernelView.setBigInt64(CH_ARGS, BigInt(size), true); - if (size > 0) { - lease.fill(0, CH_DATA, 4); - lease.writeAddress( - CH_ARGS + CH_ARG_SIZE, - CH_DATA, - 4, - "u64-le", - ); - kernelView.setBigInt64( - CH_ARGS + 2 * CH_ARG_SIZE, - 4n, - true, - ); - } - - this.#bindKernelTidForChannel(channel, entry); - this.currentHandlePid = channel.pid; - try { - this.#invokeEntryScratchExport( - entry, - lease, - "kernel_handle_channel", - [ - lease.exportPointer(0, CH_TOTAL_SIZE), - CH_TOTAL_SIZE, - channel.pid, - 0n, - ], - ); - } finally { - this.currentHandlePid = 0; - } - - let retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - let errVal = kernelView.getUint32(CH_ERRNO, true); - let output: Uint8Array | null = null; - if ( - !Number.isSafeInteger(retVal) - || (retVal >= 0 && size > 0 && (retVal > size || retVal > 1)) - ) { - retVal = -1; - errVal = EIO; - } else if (retVal > 0 && size > 0) { - output = lease.copyOut(CH_DATA, retVal * 4); - } - return { retVal, errVal, output }; - }); - } catch (error) { - this.#rethrowKernelEntryFatal(error); - if (error instanceof KernelScratchError) { - this.#rejectScratchTransfer(channel, error, entry); - } else { - this.completeChannelRawAndRelisten(channel, -1, EIO, entry); - } - return; - } - - this.#dequeueSignalForDelivery(channel, entry); - if (this.#finishSignalTermination(channel, entry)) return; - this.completeChannel( - channel, - SYS_GETGROUPS, - origArgs, - undefined, - result.retVal, - result.errVal, - result.output - ? [{ ptr: processPointer, bytes: result.output }] - : undefined, - undefined, - entry, - ); - } - /** Map scalar and vector variants to one contiguous kernel operation. */ #scalarTransferSyscall(syscallNr: number): number { switch (syscallNr) { diff --git a/host/test/host-process-pointer-width.test.ts b/host/test/host-process-pointer-width.test.ts index 7d78f11534..ad9826072c 100644 --- a/host/test/host-process-pointer-width.test.ts +++ b/host/test/host-process-pointer-width.test.ts @@ -6,6 +6,9 @@ import { CH_ARG_SIZE, CH_STATUS, CH_SYSCALL, + CHANNEL_SCALAR_SLOT_CONTRACTS, + POSIX_NGROUPS_MAX, + SYSCALL_ARGS, } from "../src/generated/abi"; import { createCentralizedKernelWorkerTestDouble, @@ -104,6 +107,22 @@ function writeSyscall( } describe("handwritten host process-pointer width checks", () => { + it("uses process-size group counts and complete vector descriptors", () => { + for (const syscall of [ABI_SYSCALLS.Getgroups, ABI_SYSCALLS.Setgroups]) { + expect(CHANNEL_SCALAR_SLOT_CONTRACTS[syscall]?.[0]).toBe("process-size"); + expect(SYSCALL_ARGS[syscall]?.[0]?.size).toEqual({ + type: "arg", + argIndex: 0, + multiplier: 4, + }); + } + expect(SYSCALL_ARGS[ABI_SYSCALLS.Getgroups]?.[0]?.copyOutLength).toEqual({ + type: "return-value", + multiplier: 4, + maxValue: POSIX_NGROUPS_MAX, + }); + }); + it("keeps a lossless wasm64 MAP_FIXED address above 4 GiB out of low memory", () => { const h = workerHarness(8); const lowAlias = 0x8000; diff --git a/host/test/kernel-scratch-contract.test.ts b/host/test/kernel-scratch-contract.test.ts index a13b9c00e9..04282fca0e 100644 --- a/host/test/kernel-scratch-contract.test.ts +++ b/host/test/kernel-scratch-contract.test.ts @@ -157,6 +157,64 @@ function kernelExportNamesFromSnapshot(source: string): Set { } return uniqueNames; } + +function kernelExportSignatureFromSnapshot( + source: string, + exportName: string, +): string { + const snapshot = JSON.parse(source) as { + kernel_exports?: Array<{ name?: unknown; signature?: unknown }>; + }; + const matches = (snapshot.kernel_exports ?? []).filter( + (entry) => entry.name === exportName, + ); + if (matches.length !== 1 || typeof matches[0].signature !== "string") { + throw new Error(`missing exact ABI signature for ${exportName}`); + } + return matches[0].signature; +} + +function cFunctionArgumentCount(source: string, functionName: string): number { + const marker = `${functionName}(`; + const offsets: number[] = []; + let searchOffset = 0; + while (searchOffset < source.length) { + const candidateOffset = source.indexOf(marker, searchOffset); + if (candidateOffset < 0) break; + offsets.push(candidateOffset + functionName.length); + searchOffset = candidateOffset + marker.length; + } + if (offsets.length !== 1) { + throw new Error( + `${functionName} must have one exact C declaration or call, found ${offsets.length}`, + ); + } + + const open = offsets[0]; + let depth = 0; + let argumentCount = 0; + let sawArgumentToken = false; + for (let offset = open; offset < source.length; offset++) { + const char = source[offset]; + if (char === "(") { + depth++; + if (depth > 1) sawArgumentToken = true; + continue; + } + if (char === ")") { + depth--; + if (depth === 0) return sawArgumentToken ? argumentCount + 1 : 0; + sawArgumentToken = true; + continue; + } + if (depth === 1 && char === ",") { + argumentCount++; + continue; + } + if (depth === 1 && !/\s/.test(char)) sawArgumentToken = true; + } + throw new Error(`unterminated C argument list for ${functionName}`); +} const abiKernelExportNames = kernelExportNamesFromSnapshot(abiSnapshotSource); const hostKernelWorkerSource = readFileSync( new URL("../src/kernel-worker.ts", import.meta.url), @@ -1481,6 +1539,22 @@ const auditAllowances: AuditAllowance[] = [ ]; describe("kernel scratch static contract", () => { + it("keeps retained getgroups C sources on the exact ABI signature", () => { + expect( + kernelExportSignatureFromSnapshot(abiSnapshotSource, "kernel_getgroups"), + ).toBe("(i32,i32) -> (i32)"); + expect(rustKernelExportParameters(kernelWasmApiSource, "kernel_getgroups")) + .toHaveLength(2); + expect(cFunctionArgumentCount( + legacySyscallImportsSource, + "kernel_getgroups", + )).toBe(2); + expect(cFunctionArgumentCount( + legacySyscallGlueSource, + "kernel_getgroups", + )).toBe(2); + }); + it("keeps host pointer roles aligned with Rust export parameters", () => { for (const exportName of KERNEL_SCRATCH_EXPORT_NAMES) { expect(() => diff --git a/host/test/kernel-scratch-runtime.test.ts b/host/test/kernel-scratch-runtime.test.ts index 858e773150..8632e12ead 100644 --- a/host/test/kernel-scratch-runtime.test.ts +++ b/host/test/kernel-scratch-runtime.test.ts @@ -161,6 +161,12 @@ describe("owned kernel scratch in the real Node runtime", () => { `KERNEL_SCRATCH_ZERO_IOV_PASS pointer_bits=${arch === "wasm64" ? 64 : 32}`, useDefaultRootfs: false, }, + { + argv: ["kernel-scratch-browser-test", "groups"], + marker: + `KERNEL_SCRATCH_GROUPS_PASS pointer_bits=${arch === "wasm64" ? 64 : 32} groups=32`, + useDefaultRootfs: false, + }, ]; for (const fixture of cases) { diff --git a/host/test/kernel-scratch-transfer-boundaries.test.ts b/host/test/kernel-scratch-transfer-boundaries.test.ts index a7d579edd7..803d35e828 100644 --- a/host/test/kernel-scratch-transfer-boundaries.test.ts +++ b/host/test/kernel-scratch-transfer-boundaries.test.ts @@ -1543,10 +1543,13 @@ describe("kernel scratch transfer capacity regressions", () => { expectScratchTailUntouched(harness); }); - it("lends getgroups exactly one gid slot and snapshots it before reuse", () => { - const harness = makeScratchHarness(); - const destination = harness.processBytes.byteLength - 4; - const gid = 0x1234_5678; + it("lends the complete getgroups capacity and copies back only returned groups", () => { + const harness = makeScratchHarness(8); + prepareGenericSyscallHarness(harness, 8); + const destination = harness.processBytes.byteLength - 20; + const originalTail = Uint8Array.of(0xa1, 0xa2, 0xa3, 0xa4, 0xb1, 0xb2, 0xb3, 0xb4); + harness.processBytes.set(originalTail, destination + 12); + const groups = [0x1234_5678, 0x89ab_cdef, 0x1020_3040]; harness.handleChannel.mockImplementation(() => { const channelView = new DataView( harness.kernelBytes.buffer, @@ -1555,104 +1558,151 @@ describe("kernel scratch transfer capacity regressions", () => { const outputPointer = Number( channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true), ); - const outputCapacity = Number( - channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true), - ); expect(outputPointer).toBeGreaterThanOrEqual( harness.scratchOffset + CH_DATA, ); - expect(outputCapacity).toBe(4); - new DataView(harness.kernelBytes.buffer).setUint32( - outputPointer, - gid, - true, - ); - channelView.setBigInt64(CH_RETURN, 1n, true); + const output = new DataView(harness.kernelBytes.buffer); + groups.forEach((gid, index) => output.setUint32(outputPointer + index * 4, gid, true)); + channelView.setBigInt64(CH_RETURN, BigInt(groups.length), true); channelView.setUint32(CH_ERRNO, 0, true); return 0; }); - dispatchScratchBoundarySyscallWithArgs(harness, ABI_SYSCALLS.Getgroups, [ - 1n, + writeChannelSyscall(harness, ABI_SYSCALLS.Getgroups, [ + 5n, BigInt(destination), - 0n, - 0n, - 0n, - 0n, ]); + dispatchScratchBoundarySyscall(harness); expect(harness.completeChannel).toHaveBeenCalledTimes(1); const completion = harness.completeChannel.mock.calls[0]; - expect(completion.slice(4, 6)).toEqual([1, 0]); + expect(completion.slice(4, 6)).toEqual([groups.length, 0]); expect(completion[6]).toEqual([ { ptr: destination, - bytes: new Uint8Array([0x78, 0x56, 0x34, 0x12]), + bytes: new Uint8Array([ + 0x78, 0x56, 0x34, 0x12, + 0xef, 0xcd, 0xab, 0x89, + 0x40, 0x30, 0x20, 0x10, + ]), }, ]); + expect(harness.processBytes.slice(destination + 12, destination + 20)) + .toEqual(originalTail); expectScratchTailUntouched(harness); }); - it("keeps a getgroups count query pointer-free", () => { - const harness = makeScratchHarness(8); - harness.handleChannel.mockImplementation(() => { - const channelView = new DataView( - harness.kernelBytes.buffer, - harness.scratchOffset, + it.each([4, 8] as const)( + "keeps a valid wasm%s getgroups count query pointer-free", + (pointerWidth) => { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + harness.handleChannel.mockImplementation(() => { + const channelView = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + expect(channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true)).toBe( + BigInt(harness.scratchOffset + CH_DATA), + ); + channelView.setBigInt64(CH_RETURN, 3n, true); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + writeChannelSyscall(harness, ABI_SYSCALLS.Getgroups, [ + 0n, + 0n, + ]); + dispatchScratchBoundarySyscall(harness); + + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + ABI_SYSCALLS.Getgroups, + // A count query names no destination in either process data model. + [0, 0, 0, 0, 0, 0], + SYSCALL_ARGS[ABI_SYSCALLS.Getgroups], + 3, + 0, + [], ); - expect(channelView.getBigInt64(CH_ARGS + CH_ARG_SIZE, true)).toBe(0n); - expect(channelView.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true)).toBe(0n); - channelView.setBigInt64(CH_RETURN, 1n, true); - channelView.setUint32(CH_ERRNO, 0, true); - return 0; - }); + expectScratchTailUntouched(harness); + }, + ); - dispatchScratchBoundarySyscallWithArgs(harness, ABI_SYSCALLS.Getgroups, [ - 0n, - BigInt(Number.MAX_SAFE_INTEGER) + 1n, - 0n, - 0n, - 0n, - 0n, - ]); + it.each([4, 8] as const)( + "rejects a wasm%s getgroups zero-query over-report without publication", + (pointerWidth) => { + const harness = makeScratchHarness(pointerWidth); + prepareGenericSyscallHarness(harness, pointerWidth); + harness.handleChannel.mockImplementation(() => { + const channelView = new DataView( + harness.kernelBytes.buffer, + harness.scratchOffset, + ); + channelView.setBigInt64( + CH_RETURN, + BigInt(POSIX_NGROUPS_MAX + 1), + true, + ); + channelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); - expect(harness.completeChannel).toHaveBeenCalledWith( - harness.channel, - ABI_SYSCALLS.Getgroups, - // The pointer is ignored when size is zero. Keep its unsafe wasm64 bits - // out of the Number-valued host-control projection. - [0, 0, 0, 0, 0, 0], - undefined, - 1, - 0, - undefined, - ); - expectScratchTailUntouched(harness); + writeChannelSyscall(harness, ABI_SYSCALLS.Getgroups, [0n, 0n]); + dispatchScratchBoundarySyscall(harness); + + expect(harness.handleChannel).toHaveBeenCalledOnce(); + expect(harness.completeChannel.mock.calls[0]?.slice(4, 6)).toEqual([ + -1, + EIO, + ]); + expect(harness.completeChannel.mock.calls[0]?.[6] ?? []).toEqual([]); + expectScratchTailUntouched(harness); + }, + ); + + it("generates the logical getgroups return bound", () => { + expect(SYSCALL_ARGS[ABI_SYSCALLS.Getgroups][0]?.copyOutLength).toEqual({ + type: "return-value", + multiplier: 4, + maxValue: POSIX_NGROUPS_MAX, + }); }); it.each([ - ["negative size", -1n, 1n, EINVAL], - ["oversized size", 0x8000_0000n, 1n, EINVAL], + ["oversized count", BigInt(POSIX_NGROUPS_MAX + 1), 1n, EINVAL], + ["multiplication overflow", BigInt(Number.MAX_SAFE_INTEGER), 1n, EINVAL], ["null output", 1n, 0n, EFAULT], ] as const)( "rejects an invalid getgroups %s before kernel dispatch", (_name, size, pointer, errno) => { const harness = makeScratchHarness(8); - dispatchScratchBoundarySyscallWithArgs(harness, ABI_SYSCALLS.Getgroups, [ + prepareGenericSyscallHarness(harness, 8); + writeChannelSyscall(harness, ABI_SYSCALLS.Getgroups, [ size, pointer, - 0n, - 0n, - 0n, - 0n, ]); + dispatchScratchBoundarySyscall(harness); expect(harness.handleChannel).not.toHaveBeenCalled(); - expect(harness.completeChannelRaw).toHaveBeenCalledWith( - harness.channel, - -1, - errno, - ); + if (_name === "oversized count") { + expect(harness.completeChannel).not.toHaveBeenCalled(); + expect(harness.completeChannelRaw).toHaveBeenCalledWith( + harness.channel, + -1, + errno, + ); + } else { + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + ABI_SYSCALLS.Getgroups, + expect.any(Array), + undefined, + -1, + errno, + ); + } expectScratchTailUntouched(harness); }, ); diff --git a/libc/glue/syscall_glue.c b/libc/glue/syscall_glue.c index 5f53f13770..eb233f1466 100644 --- a/libc/glue/syscall_glue.c +++ b/libc/glue/syscall_glue.c @@ -1284,10 +1284,8 @@ static long __do_syscall(long n, long a1, long a2, long a3, case SYS_GETGROUPS: if (a1 < 0) return -22; /* EINVAL */ - return (long)kernel_getgroups( - (uint32_t)a1, - (uint32_t *)(uintptr_t)a2, - a1 > 0 ? (uint32_t)sizeof(uint32_t) : 0); + return (long)kernel_getgroups((uint32_t)a1, + (uint32_t *)(uintptr_t)a2); /* setgroups — (size, list_ptr) */ case SYS_SETGROUPS: diff --git a/libc/glue/syscall_imports.h b/libc/glue/syscall_imports.h index 10640fc308..6da83205f8 100644 --- a/libc/glue/syscall_imports.h +++ b/libc/glue/syscall_imports.h @@ -685,8 +685,7 @@ int32_t kernel_getresgid(uint32_t *rgid_ptr, uint32_t *egid_ptr, uint32_t *sgid_ptr); KERNEL_IMPORT(kernel_getgroups) -int32_t kernel_getgroups(uint32_t size, uint32_t *list_ptr, - uint32_t list_capacity_bytes); +int32_t kernel_getgroups(uint32_t size, uint32_t *list_ptr); KERNEL_IMPORT(kernel_setgroups) int32_t kernel_setgroups(uint32_t size, const uint32_t *list_ptr); diff --git a/libc/musl-overlay/include/bits/kandelo_channel_scalars.h b/libc/musl-overlay/include/bits/kandelo_channel_scalars.h index 38940962e6..fc48678a7d 100644 --- a/libc/musl-overlay/include/bits/kandelo_channel_scalars.h +++ b/libc/musl-overlay/include/bits/kandelo_channel_scalars.h @@ -167,6 +167,11 @@ _Static_assert(__NR_mremap == 126u, #endif _Static_assert(__NR_madvise == 128u, "musl __NR_madvise drifted from the Kandelo channel scalar contract"); +#ifndef __NR_getgroups +#error "musl is missing __NR_getgroups required by the Kandelo channel scalar contract" +#endif +_Static_assert(__NR_getgroups == 135u, +"musl __NR_getgroups drifted from the Kandelo channel scalar contract"); #ifndef __NR_setgroups #error "musl is missing __NR_setgroups required by the Kandelo channel scalar contract" #endif diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index 494aa3ee29..6c94df2935 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -4,344 +4,344 @@ "bash": { "manifestSha256": "5dc4d97dc4f154f265ff57e9d972e7d5a64a2e15fadedfa246733b830dda3497", "cacheKeys": { - "wasm32": "43fd97bde16c9d5bc3bbc97f26e8e4cb25a1f95dbc6deb6a5909a0f5e124b6ff", - "wasm64": "65f3189dd9cde68e3666374aedbdf8b67096e7660f56c09c69611ae4461aeb80" + "wasm32": "f07a60d864434909721977f82b8a3467b54e32dc42e358b530e8ded261da0538", + "wasm64": "4d6c6fdb4f64d1a0eea097b7a21067227d8dee1b82e5daa9eafae6a4094586f6" } }, "bc": { "manifestSha256": "43b61e92c29098720bc7f4871a3b611cc01ffd124024290d42967cebb6ffd459", "cacheKeys": { - "wasm32": "bdbdfdd24e73b2abaf6a251c4f256d79140fcf429b7f017315be44d9238da3eb", - "wasm64": "4ac0a4b7dc409b65c3923bed211dc4f63120fbf029930f4137910b670ff8fe22" + "wasm32": "408e5b33718c929736bc0a5c697be47fc7e39ab9874b3c4b4344b99fdbd06858", + "wasm64": "fe16744ddcb25529c447a9e31d88aec82a8a19525d92831951b2d22687634fc2" } }, "bzip2": { "manifestSha256": "59e1e53f5675e7c9148634933ed0f4c7192743727025cae4e843b6924c489abf", "cacheKeys": { - "wasm32": "b95c9827ead7a75115450673b3d20e39c4e8cbbddbc29af1e423f318cdf0db44", - "wasm64": "c14dffab6d4cc78cdc3dde9ea8b23dceaaeae6afbf2374f51b97e788eb363cf7" + "wasm32": "fb25499f183169e8db67b97587baf931d5b02e34b4603a17c11e044247f2970f", + "wasm64": "f9698d5c1bf3f00214f5401b5522269da306dd3748a3a90d62d8139aea34e22d" } }, "coreutils": { "manifestSha256": "72fc9c3818fb11cebaa047a623743252395dc729163fa804a7272d59f87fe82d", "cacheKeys": { - "wasm32": "770b6ceaf5e508b0a9fff5798f131fd67dd9864054dc2cf6acf89836eabd85a1", - "wasm64": "6e015433d5bcad2709a5374916dfd81156dcf34531046b579b8cb621f15b0b70" + "wasm32": "cce36c322c0b3920569d5a2c270ed073aba92ceb684af714b497a805a25c4b94", + "wasm64": "7878a840c190fe2dc7666fd459253dd9becb32c2a49ae96ecd1a81976a67e1ad" } }, "cpython": { "manifestSha256": "7dd4f446697a73941ec940c2ccba4d53be73fe6947f1e701031a4d0aa964c4ff", "cacheKeys": { - "wasm32": "a101343c9081af62dfe5b0b3c9d981926b458240c641edc80d7d4a4a5816adb8", - "wasm64": "4d01083f102c019ace6ffdc2a043761f39f1325841322b822609613b960d46fe" + "wasm32": "5cc4364991db0475c376fe9b665b98f028b193080389ca3bfff6571050f5740c", + "wasm64": "03314c1a6672c3a4d1eeab859a59f6d9a86d2a3b10303b6655a210d957b17ed9" } }, "curl": { "manifestSha256": "55523d50261f46dc4aaaff458d1cd87c6f96eaecd687a7540ead35c96906366e", "cacheKeys": { - "wasm32": "87f6acaf54efa8a8207f09679519a5b35efdaece28eb33c0ff866796e16eca0e", - "wasm64": "3fb2a517d7d2985e64282a7db734797faa4cbb017fbd4d086613e82892850bb9" + "wasm32": "08caaa4fa5611ec4f8fadbd17172305a1efda6e5861f87430ae0c56b0ed89863", + "wasm64": "092af89d05c259f7f36f3374b35ae4532ab5faedf4dde17bb761c34fb02ac344" } }, "dash": { "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", "cacheKeys": { - "wasm32": "e7a48fe30c5509f8152a20c11da66e056f5b07c343fb1ae06f64884bc39cdbad", - "wasm64": "7eea3d285157e4e5f16bd4a12323aa4c175efb49d9fb23cb1caad45c2b2850b8" + "wasm32": "9c286ef92425fea87cd0c23f3ebacad335791389bc0fe8cd00572fac8c2b3dca", + "wasm64": "4899a2545851c9cc17d489308f64f99aa66652349a3caebf1ef1532984579d29" } }, "diffutils": { "manifestSha256": "2286203eb762eca487939963e38ea1b901ac2dc9a830d3260c2fb291757df208", "cacheKeys": { - "wasm32": "55ee5fd0c56cacbc3d21752feeba5e8628fb4dfe670eafd3bb14dae4959a3c10", - "wasm64": "6b29ed5a81238236cae0b119fb7cad79d25184bf508b1dfaa6963a1546c971b5" + "wasm32": "febb5cd14a0493af2060c4dab4ea9316fb814f5861c6af3e31fd19f9c5d99678", + "wasm64": "eec48b6f0fc19fb5cd147acf1705234693e0c1a6f088f37157666bdd08706e43" } }, "dinit": { "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", "cacheKeys": { - "wasm32": "c81e0b1b714646718033c1b2b43293d3d087d502abae5cfe82e6933dd8fe20f9", - "wasm64": "df5791013bc496b63584dbe766c735abb22fadc012fc23819bc339db9aa2461e" + "wasm32": "81dcb6d5f937d5cca3481492df72cb19bf9f8c84973ef6093dde0e149ba42daa", + "wasm64": "b201d49a3d0d5ce1203443a382281a885cafdaf52305b5c44c4c1a87e392fb5d" } }, "erlang": { "manifestSha256": "475d6037e73a0f1e2f4381067194b2422751206979ea17209e10efa5a2a93bbb", "cacheKeys": { - "wasm32": "d1e404a58baa024394755a8f250d0fc8de3d5384e252b4e12cac7bbe0e1ad04d", - "wasm64": "bf51d363249d2a572a5846e269b61d09fd7f90fd5dd2953ead0fb89db42799f8" + "wasm32": "86516154326e2765f9ac8d232c4be618191de0a3757891ba284d826622b84d19", + "wasm64": "34427fd4797b61bfb39e44a637639d3291f0e55c1d7ef916b6ee17aabeef11f7" } }, "erlang-vfs": { "manifestSha256": "15efb74f1825e2b85bedfeb8d98c19fa648c4be39cb9defb65330a8f21eb9141", "cacheKeys": { - "wasm32": "eb1c457e4ba3400b5f8a4f149e63020a344c7ef9d215dda6bd06e32fe665e48c", - "wasm64": "8d7ccd95c663214127fb2a8639e28a8d2bd5d1b22215a97ea821a699541bb662" + "wasm32": "5faf02e2dc9833eaef227471d2f82790f2e4406f0dcf8805f11d24d97543a7a4", + "wasm64": "2f72591b77662d57113331dc8f7f3a03c75b1415cf26b0adf41aa4b729342419" } }, "fbdoom": { "manifestSha256": "7ff2127ca940e41be90ba45204c89089a7a2093567b9bff581a9549b57138626", "cacheKeys": { - "wasm32": "9e2b644abb9aab6062b69a84d4f9d864df9dbc5ddd099392cea29b16bd40b924", - "wasm64": "ba0d9f266476a8028222b000ed6074f7757e4076b68ace0717af2ce5ed5d2b32" + "wasm32": "2bf2226875f72a078553c3ec6dc6c51bc59ff73aa010937c4b39b679fee48422", + "wasm64": "d9b417e64fff81383c9bfc26cd2870d55867d535f7ab10345eda8afef714a9b3" } }, "file": { "manifestSha256": "7874c1affcbbf2c8ab8c8d4beb087f275af57b5caae6a8947bf5a63a181552b2", "cacheKeys": { - "wasm32": "357a02fbd427f35255e349b6243f23b662fdb2608e09d6a08a040c6ae0a07cc3", - "wasm64": "9bb78d6fa4c414eb6657f2baf712ebf7e96d45d9dfc2121d87ff9aee16acd139" + "wasm32": "954c87e26e61e45fda6aa1953de7738da41760d7e6a15c153e7b0ee5a6510e18", + "wasm64": "376b8f8f204562a66d9975fb57d9631471a704145351d5fbd2d9e21138fa3a10" } }, "findutils": { "manifestSha256": "a9969cb102403cef105e758ef602692500fddd75ec96e4e3f168c50094b6c931", "cacheKeys": { - "wasm32": "ff88da1019fb2d3b56399f9450baa23cdb5069458ea99dca1f3d2da19eee1501", - "wasm64": "6fa3876219b34e99a793027954ed9108bb972bdb52240c7b2d2d136dc4f8266b" + "wasm32": "1927dd4585688c78835726fbc504184b52af1901b4da0baf60485cae3ab18c9b", + "wasm64": "366fef7e641315da941918ee22ab42f73c5c27c3a996acf0a7cc5ca1602406ec" } }, "gawk": { "manifestSha256": "b788f3de724cd363ea68d08826586ddf544a75fb5dd9df1369ffafe06d8681b7", "cacheKeys": { - "wasm32": "0878a1253d8ed63fe64073b28351207fbe01dd12b6c166b7d6690e41164436c1", - "wasm64": "0a84e655511320364e483d8f873d2b2d9d55f1cd252ae5e262c9efef29c51237" + "wasm32": "ef897546ac91a4c6879281455689ad9a7d6cc2acb50c3dd356dc6fb5df30d380", + "wasm64": "662591a7bfcaaa596ae454a63e3c6863d6d238d0bb6349f47d80222db7ac2212" } }, "git": { "manifestSha256": "c2bc79e62c9a4e840ae94a16af0f41070460eaf3976a990dc60d2db977bee952", "cacheKeys": { - "wasm32": "248e67d35a337108e1e7fa1e3a468df2a94bbe5ba5e02e7ecef8d6b961a7076b", - "wasm64": "5e4f8423d87cdb3048a0c035a749a2620a09cc26c6da6eda83ba75e58c99272f" + "wasm32": "91dc732cb54b0ae02135f60f815d728a9a52e5d3efe470d51199b498c3aa49e4", + "wasm64": "4dbc31f08bfdad8685e265f1791ee41ae58bb0660b69626ec077fac11b1799f2" } }, "grep": { "manifestSha256": "7c61b894807b831c7e8816cb1f39c78e3a69a4ced2fa3d18f0abdf00bf18334b", "cacheKeys": { - "wasm32": "e368d4eb26a528111c836689761ba645ad34d43aa21ae8c8420d16d1ace57da7", - "wasm64": "ec44c9a9c0d9743616cc2937fa0e6b905695e4913b3103b0eb3b0447effcd15f" + "wasm32": "c1c9f271d06c271dd1e85f008f986b835d0ffd40a64537da54dfda1ce9f3b824", + "wasm64": "a1251865f2fc5ba31c411ce6239080505305d635ef0226545d841c92ce7b9afe" } }, "gzip": { "manifestSha256": "1485add843bbcd744244e707c353208fe11192b7b248feed1550875d3b75a917", "cacheKeys": { - "wasm32": "c93bacf2b437fb73da6dd5cafa6ac00575dc7a130590ad6423d1a186d45468cf", - "wasm64": "289ca804b799114e4154428d10da223153f582b5465a34698ef919d4fc7221fb" + "wasm32": "0367e5e2485ddaf8a30fd43d95584271ae2d7096d4935de8727b65ace3f9105d", + "wasm64": "decf2ded37c5e2a9d9a370afdea1119e1b7e2a3f25ab2ce332ab7c03dc807839" } }, "homebrew-bootstrap": { "manifestSha256": "183004a8385ab5afc388cd43401341b82812c67f97b08ad349ebd7f4dbbebd0f", "cacheKeys": { - "wasm32": "910d7ffed0e8cf7dc71d90c6d2300f53eaf266211b247b6e6a37bc302690322b", - "wasm64": "09b7aa8b7031d870ac92e2548d6a799950edb34d1af3aef75187549a8c23bc77" + "wasm32": "bf4a420c5bde02b90ce908972fce7b7da51a58ca2604224e89ce64900293b572", + "wasm64": "cd748c06d9d93ab13b529fa9297c4015ab21e75a7dc5747070c4f8018606626c" } }, "icu": { "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", "cacheKeys": { - "wasm32": "869e58f1e945c89086b28e50dda9cd001d6205ae51404149404cbadbd338a3ef", - "wasm64": "7aa317a945d9d10f7db59813faf07457f67c4d27758eb87fe517aafa32869d0b" + "wasm32": "2785cf0bf052747d2ade36d90fe0ddb40a058f305d78e845ca5bb25dc553cf5d", + "wasm64": "f51016e902caa89ba7d66803599622a3c04c6d0ad0481c8dab72f9315effa246" } }, "kandelo-sdk": { "manifestSha256": "c081879f1becd855917cff96f17429bcf2b9fd61bf95211eca9ff8fb625bb2eb", "cacheKeys": { - "wasm32": "5d07e216601e1466c300e439002aafb23df3193db1b4f767cf5454390df0f472", - "wasm64": "27ab30a7b0bfccb3c1eb900fd660ee1410c722e9126dab9537dd3e9729e3d798" + "wasm32": "dc98bcfd4e6ad69eeb10b04715395a7a5772a240b168dc2827f07a48a4d0d3c0", + "wasm64": "6d898877c97a2edd4da6dc76b27738771394ba1b79497d0147f63a8c7cd353b1" } }, "kernel": { "manifestSha256": "db1d66db8575562ac7b3e720dbaa06d7dd5eef577d80220ea4167dc2d69b863b", "cacheKeys": { - "wasm32": "96aa66030a19468af2592bc4b7ed6f917dc65d1d0471ed61831a20166b36226d", - "wasm64": "c6b5d73ed421a7b9f29fcf8a65c368dc9f6942430588e2651da8acfa444d5b50" + "wasm32": "0645f1c9514cdc63e297c4c66111011d462165cf134bcadeabf31f54324557d4", + "wasm64": "5d36d84daca5a8161d1bf4b8952c6879c4ca9406e5fa5c2fd1e91202ef3aa5ae" } }, "lamp": { "manifestSha256": "250b64635d64f8537178188fb5488dbfd2980d79a1c2ffbf346af67008088ba0", "cacheKeys": { - "wasm32": "04f6fb7e0f86ae44b016b26ccb8daa5eb1a21f0cc607de1aa630ef3ede8b7715", - "wasm64": "6e6d98111227d0646cfdb7738bdb19687634d2fdf4b34bf041e52590dcbe6fac" + "wasm32": "a94661681c2ed22a6d85605ce52a187a28b160cee3f0b22059dbc16f8329799a", + "wasm64": "8e2440276cef92168eefc39f59d9df2c210f16743b7d8d6195887de4cecc5841" } }, "less": { "manifestSha256": "996d61545cfe83dcb663a33e8763e0edbd4db4a605fd69a91b243cf79b1f9b17", "cacheKeys": { - "wasm32": "d347d2f00c368bd2d9aef83d7dc57bb37a0c1af3ecc7f262310623a4c3538ec2", - "wasm64": "7d72b9378eaf74bf61ce42900f6991b44515404a1acdd300e687db267c57ee2c" + "wasm32": "9bb5323df1cb2ef84cf27efc789566560a79649bd3deb00be219131ee2ea8911", + "wasm64": "326adbe9177f840d860a07eeaa067cdaa2d5bbd643a84104a787d117dd568674" } }, "libcurl": { "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", "cacheKeys": { - "wasm32": "13427d5971b26c2e3e2ee43756f88944e3b75385a4d13e6fd45dd63a6a7c9afe", - "wasm64": "eea268d22a367a326d50838b5ebbbdeaffed9d63e867fdc32f15d50e108699f5" + "wasm32": "797282f3a1e94fb74c16c1fe0924d478eceb9fc9ae23cdefa40d675a76616ff7", + "wasm64": "18644e97abf2a0b5175ea0dbd8ff3c1ff77f84666f463ad852aa439ec6436e48" } }, "libcxx": { "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", "cacheKeys": { - "wasm32": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46", - "wasm64": "1799d410cef457fa39446d4d4a0a17fbd4311da2a21d65b9f0268275508b616c" + "wasm32": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866", + "wasm64": "fd3503736c73dcbe633dca3304e10e72ad4dfb9c8597f301a064180a3e193e91" } }, "libiconv": { "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", "cacheKeys": { - "wasm32": "e3b39e170e399c7c353bf41cf8340473dae7f378cd8ba8dbfd5fb9f4ef69f4c5", - "wasm64": "23260593af6215390da7fafd265bb6c69caba9aa18c65a694a24d6972f69f2ed" + "wasm32": "918054f8e258e000a8f3ac9ef70870d7a0cb1153ecb6e0ac5b99b6152abae905", + "wasm64": "d860fcb323ef454ae7e7f7f26868e2430deeb1d1ee5af491266920f31782841d" } }, "libpng": { "manifestSha256": "79ed5c5c072a0267cce5c7a2fe37d8494571b17e44b952f619e04ec1c4a9db10", "cacheKeys": { - "wasm32": "578ab5f1f7dc19a936f4dd6e6ac2da7cb4683213085c933eeeaeb424410ac8dc", - "wasm64": "39e825154e8ba0a47fd53f3c947429e6bcb6304eafd55bdfac149037a849c84f" + "wasm32": "cbf7b239bb280c976b263f0a4f7fe6ce7b458f1d555e8306e8625d43c097547b", + "wasm64": "a258a9d0e309d77ba120e02de167a513145da565929e088a8038e1fbc016a70d" } }, "libxml2": { "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", "cacheKeys": { - "wasm32": "222b11ed5313da06ba2368741a2e4589f78f89beebd184edcb0559b20bddc3f5", - "wasm64": "501c3b0ad9e958147ecd982d71024d70f822a5f52cedbcedaed0afa1e0156329" + "wasm32": "72264a21723fe72302c5e1b5205aad7117476ef656f54538e98a66c70219be8d", + "wasm64": "bf7a86b08899217e0b94018e8b612a48463e84c7de8c4ee40305945b19061602" } }, "libzip": { "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", "cacheKeys": { - "wasm32": "e4cdb8156bdfcfd302a18db3d908898f57b3859a00b308bbefe3a0f9b85de483", - "wasm64": "4a4203487d98e9c7bfb98a225071b5402d35925e1bb6d3ef98dd5b0fc57a68af" + "wasm32": "01eec0bd3a1139f3baea193789492b632875c70d38aaf0be16794e43d0874245", + "wasm64": "a26b93101b8d05382ee4e2211c8a0688353d13a2c1a2baa07d6c129d0e4b396b" } }, "lsof": { "manifestSha256": "cfd199bbe082435f7a2e703e2738a368af1eeb5b76b6e93c376054f21ce94419", "cacheKeys": { - "wasm32": "e262c9ac18046a4817a8fc25e5a356e814fec11a7f53b905f1718d4dffaca656", - "wasm64": "6b6d63ff79dff5992408b52be9a3f071c826d1dfcc1ef6ba7b4310c3e2d1be12" + "wasm32": "539fe25b0bfe42849aed4bb1203fef9924225f243196185c663c70ecdc9c0593", + "wasm64": "3a52e57e3bc1b03991bd101c6059d574a6283cf46b9676e5b0528cd9ba0e1bfe" } }, "m4": { "manifestSha256": "9b5838bdde8531079f23a89e135aa3832bbbbb7ad6099dd1503db877d52afcad", "cacheKeys": { - "wasm32": "223de993ff0b49af372f84de543354c651a80ad0cc0d4a219b927f52fa522583", - "wasm64": "d20472ef9c62dea91aae82d116c6cfdb36e3d4bdf7caab866cfc496f47b1a7ef" + "wasm32": "3da16454c105fff20c5e2cb8fef979e3a1a6166aa6e8eaa02a1bbef4b879f91f", + "wasm64": "b8dea51e77300f2067e09a45a046a491731f8f7e11efe23b6e2c31bdb89bf33d" } }, "make": { "manifestSha256": "62e8b36a6df7e981d191061a5bb7f2db85b2deaf365352a7d590a6c6c06f2b1e", "cacheKeys": { - "wasm32": "7ecea3370409cc9f5f40c92beeb29b944a6bfa3aa93e63f1400abc5ddf6a7bbd", - "wasm64": "d30ec7577013788b49899fc69cb5152af0a997da865e3f2bd11c27e6e655bcb9" + "wasm32": "0c45cd8ffb70f1a4615b8c0b7a0cff5acbe2645ffb795be91642bb510d070822", + "wasm64": "eb6542f3a2a610025f8ca2840641453d1ff6f443c66ec74363e8bf3b900b7fd9" } }, "mariadb": { "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", "cacheKeys": { - "wasm32": "22fb165b69847120ce44c5d6a7aef7b3c7d2804448eefec7a56b35c40631623d", - "wasm64": "5baf0991b29722ffd473317912e25525f5f9b463eceb3e942c52d6cbea183963" + "wasm32": "8f70905c8e42c2fcba97985f98ca1754ef32b0045daa638cc51410e90debb876", + "wasm64": "57287289e381c0e77d76a2bab7825b897a736f7b51fa0c41ff98dbe2047e9d53" } }, "mariadb-test": { "manifestSha256": "d4ccce161d659a5a87c80fa1117054bbccea870e0d4a46bd8a070d3930ad0e3f", "cacheKeys": { - "wasm32": "01f3400be98fe966cd9ac2213c04cef76ba72c52a7aeb8a4591d598945bc137f", - "wasm64": "5893bd9b0bd0968d290a25f66947b5d03b8da21f45a89498175f87731866de2b" + "wasm32": "fec07b5ce79b2739d02d2cb83309781a6ab1354748c75df7f31803120b898a82", + "wasm64": "76bffb60f0681759a9563c45606feea5300e36ebf6777210b75b2e1c1d3d7396" } }, "mariadb-vfs": { "manifestSha256": "26e74ac84d89b2a839ed72783437061a23022ef2f88ad0f52b6aacd0442ee1cf", "cacheKeys": { - "wasm32": "de98d36939b492929f2f1433d8ee62e5dd25ae7ed6a968e2ed0feb2cb9c8b1c6", - "wasm64": "3e022eec241af103e25a3b90686698c6c8d5d60dfaca22611d2be6fe03b50fac" + "wasm32": "46578c96408828cca65c7e137124559a32f2a0a61812c17c4edbce8a0112af74", + "wasm64": "980ef7bed8c2b5d76aaf01d0ec7165978a207bd305858b4419fa6a69903115bd" } }, "modeset": { "manifestSha256": "36a8683c1c7309701d361c5f77e543a6c1531397b26c72dbb864528c59c42954", "cacheKeys": { - "wasm32": "724e416a7663e8784edb7a0ea03d0c7534f3658dcce344b2dd019b17fc5e81d0", - "wasm64": "b96f08c807fa72b56a2cb26de850bf416589f30ec929959786d2d153cb4a9903" + "wasm32": "c1bca0979d20f7414d74615869904854fec454a3ccf1c08035c30135ba8d503b", + "wasm64": "0de21db45ad8f701626567a351ab800861b5b214eb71d8dc939fe14797532b21" } }, "msmtpd": { "manifestSha256": "686ff665b5e7907e67618790b1a3eddce2ff47ed665595d17209bdcda1e0b274", "cacheKeys": { - "wasm32": "f1b48a8a5e1fae2548dd48bae15f7b96b56689cd8429f3f57db762fb0118838d", - "wasm64": "e451bcf8e40e87fd1285fd62702a8c8f28468b4c2d12dc5b88f5c42570cc1c1f" + "wasm32": "cde71f771c392d62a09b6227c18c4c4ba5ede3605a085c57289966f5734e09d3", + "wasm64": "290f24e6624afac54d68ec99d262c20511fbf3a7e212c57a917fd4ac0c513820" } }, "nano": { "manifestSha256": "c5a52f0c37e08b475bb815367b1f0d63d0d2b06649d99fe9f461b72d0b9cff51", "cacheKeys": { - "wasm32": "83c0fa922e329fd32b693ed71cd0b52ef1154517deeccb9bd2ea3bb664cf20c1", - "wasm64": "b89dc4c443cfc9f6d9696ebe9d74d3f249c8216867e75e3d75f6067fd8f25124" + "wasm32": "245ebf3255ccf561998dffcdc1be5b6cc584c6db353279fac0ac3ff5db449118", + "wasm64": "ca58ba1842ea4c42197f67e0f9ece74130671a67cab92a5d46a8bbb375046daa" } }, "ncurses": { "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", "cacheKeys": { - "wasm32": "f06bbb1f53f43c18c3133a6ef7601df696b8968104f312fc5b278b32f0097de8", - "wasm64": "b6a39c96dc1adf398368355a24ff57a14f3bf47138c7603af4e3a52808a2438c" + "wasm32": "b12820cd19b19d2999c58fbcf2afaeafacf7cc897268558936aea2b76d4a5182", + "wasm64": "e5858f05f3604dbbfa4a283ffa6b8675464e214d44e51d6a1292661fa3f80e9a" } }, "netcat": { "manifestSha256": "4cf33cd1ab768b3ad0108da8b68c2ff72e98469cd86a0bf2d0da54a7d4f6fa16", "cacheKeys": { - "wasm32": "fdd999a93a4909ab5ad9b3b77b8ccd70b1347ac59833de449c6b016388820f66", - "wasm64": "509b3a3e9e46b89aebd34314fdad6b59efff552017dab00ced8174c8894b504d" + "wasm32": "8f6a38fe2dcf6645ce394283228cc6c1274a74d0711304b2b480ae5eee6fc38e", + "wasm64": "3784cd11eca4d59b6b30d222c34d481b2a92f6106bebed078fc33cd77af92fed" } }, "nethack": { "manifestSha256": "1a2f12ec2770bd8d40006d6c878a3493b97c71c2bb63ad08c7f6ad99bfd53504", "cacheKeys": { - "wasm32": "1e30dfc4bdef94f60c18ebb30d6fbc38bdad557781a752d42e3d521d64e8d3e8", - "wasm64": "07416e4d189bc79cedbb1b9480f8582873e0fe1b10d6cde96ef0ce0e29b2c993" + "wasm32": "37063f8f247d962d17573762cf5f2b8cc285aeed905fd9aeb75a9ed518502f95", + "wasm64": "37d5b955c47f3fd8d80386f013d956843e31a3dfa26401814cf6b34f99ce95a2" } }, "nethack-browser-bundle": { "manifestSha256": "b8294981da7ce4299dfb271d4aecb90ababa97a4d1acc9b716ae05bbe52beb53", "cacheKeys": { - "wasm32": "ed6ec18198a30ff239a0210d0ecf41b3b593571b714e9e91552b9c9200744c10", - "wasm64": "588b5cb44a1acf73a5e9dfdf923a5ecd6f6b0ab55af9bebbbec39e35fec7b6cb" + "wasm32": "21d8c4f96a174ea9dae1ef8b55d6c43762d3c511074501c8f12ff8ab3aa6c85d", + "wasm64": "15f5be5f3300dd36a4ee6f31ba2c0781669ff30d4eb54117824a3ad1d227809a" } }, "nginx": { "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", "cacheKeys": { - "wasm32": "822af6438b8b472f4bf70a8d9d78fb2fe65980da303f2ff7034fb5ed67ccfbe3", - "wasm64": "0e3de299efeb149cc33fb7220fa1d12bd8a9fbc69a90f233a4ceb24b350ce534" + "wasm32": "a678a30cf48177514b258ae186bc92282a3f564327629060f9e75f19443b75a2", + "wasm64": "8e1841744c2abc7f49cc3be268a0156c4da856a7b5ef5890bf9b31fcdf2462e8" } }, "nginx-php-vfs": { "manifestSha256": "97976410cb02f8ba710d856b4ac904bcf976d677b50eefdee39fb64176070d4b", "cacheKeys": { - "wasm32": "694e918bc29048a8fc93ee11a5ccf518297743181718993b0543074f224ec963", - "wasm64": "69dcaef9ba3f75f50a4817936b4f8a193a2cf2603a3c99346a31505950c93cac" + "wasm32": "6cb5b584eaadfadf743d253ec7472b0fb0aa0dd83c65a38169811240ecfef305", + "wasm64": "18cfce4685e91d1ad73afab04154a07b044cd2dfeedfaab0012878cc135025f9" } }, "nginx-vfs": { "manifestSha256": "46aa2d3250ac5cd0f102a85c3a36a086c7a50ba1f9e05fa1c2aae3d07ad16f10", "cacheKeys": { - "wasm32": "a8b0beb30be4864ed693bb96041f9034fc3fb14cae0bf70f84ba14ba975737d2", - "wasm64": "ffe15ccbcb92c2961957645a49b939feef1fa8f8d08f9355f39a416c89670477" + "wasm32": "f81a75e080bb17dc0a25d6f1686504ba75b0c3661bbdb5ef1611adfd22d83e6a", + "wasm64": "12603fd210c648b0b9f47ad5629a9ff0df65e064624007dd40be0bf83a91f928" } }, "node": { "manifestSha256": "2131a24dfda8587860e57fc65b573086477d376b065b3b9ca4e1dfe4d79f5790", "cacheKeys": { - "wasm32": "50887911c742eb0685693c9f2fa6aa959cd3191b27ca070233591589bef6d3a8", - "wasm64": "5173b798b9d6befb4e2197bcae3fd86642189fc58b5f36709e591cd8f077a21a" + "wasm32": "9ebb636f0ebc5111d9cc16c4194eab9ab54ee537a777bd772b4ae4c0ece7d24d", + "wasm64": "a7a36a3e566ae8aa463203565f92d82e5653d9f7898dc95ed2940cc4b40dcbdf" } }, "node-vfs": { "manifestSha256": "977f219defe57e18017ecc963159df32efdd21c53d6fea05943703d04739d8de", "cacheKeys": { - "wasm32": "0d6fcb431876cc2e4199a3ee2952a390b064cfbe4deccf9d58554a4faf0ced90", - "wasm64": "035aa481143916b1a227476476e7e4d0e17fb960e74826876507a496c1d6da3b" + "wasm32": "9604a7e34ea2211523bed0f7185efe2c1b388c7f7093c04144fe88c9ba473f90", + "wasm64": "f8d2c9e823a41f1c5b4b1dcd704dcde3996e66a4b43a5ea905c98acce0f30b43" } }, "openssl": { "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", "cacheKeys": { - "wasm32": "211f5e6ea83462c8ebaf8a1a923b5f7f258143f6cfd762fc92b9ed23d615c339", - "wasm64": "5096a5a441634cc206720cfda9675211f52a2b49749959ee49bfb16c1fc4b2f7" + "wasm32": "8645e6be336d82ed97665c93c9122f4046869d0495f464b9c89b3157426b39e7", + "wasm64": "c55193193dad788287e65554d297d8303684f09b1e5552c9326b0799b5280e90" } }, "pcre2-source": { @@ -354,225 +354,225 @@ "perl": { "manifestSha256": "6cdc4dbc54d0e4008cff41f82ec8918c0e44b4aef23903539c1bc0f538fe3ad2", "cacheKeys": { - "wasm32": "a6112b228ccbaf2087e4ac7bff8488fbe3b551a04366160edbe4f651fb1a520a", - "wasm64": "17960df83153d1491f31519744b5d521a1d05e16f9e605dbdc963d89c6ec015c" + "wasm32": "c8cf2040ac50eaf4de04912a3c4fc437004a54358c97bc2f8e2b4df40544ab01", + "wasm64": "e1bfd873c505a551e85e5f76c88c4b3dd227f977cdece78021866fe3a0f47f38" } }, "perl-vfs": { "manifestSha256": "1345cc102bc8c24a6bc276410c00b4fcc99ba5f6adc9b031f882aaa35d53c8bc", "cacheKeys": { - "wasm32": "a338549c159a52b9f76840200077be97854353e7b508428ab56f161e1f259dd8", - "wasm64": "9e84630f80a2eb7985471bbf09b4439e9017448d35bea657ed31904423b4549d" + "wasm32": "acadee4d8f1e4099ec6e6c4673e4ee5f85efb177bb95bcdfc163eaee1627c3b5", + "wasm64": "d04867569c17a2b0f30f588aeceaa4a91866a908fc3ca00a642645220070942a" } }, "php": { "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", "cacheKeys": { - "wasm32": "59f6df87ab85d47ee4c6b2a865d2180c1e62a960d782a4af60bdbba0af4e39a7", - "wasm64": "23e7a2010f78eddfc4884706ee21cbe73b750c57dcb7ef2f9a1f02f9f3b8eaef" + "wasm32": "0e623672a33a21b893c7f44854e7215bdda530a9e66a87dd9d91602e123d1d89", + "wasm64": "072e91a8aa48ab42a9ca7fa1d43537f835d325ddfef4b426394c0dc7325f191c" } }, "posix-utils-lite": { "manifestSha256": "8fd7190b2848ef80143adc7b9268c79e13cd4db3430f7105faf49a4b4407a06f", "cacheKeys": { - "wasm32": "ac05dfca7fad4d6f4d8f371b85940f0c98063b581e18ee48af5609d3f41e20b4", - "wasm64": "af865775d5afa3c2a3c0d3ecfb674ad7553e8ff6fffde9a1bc51d64216269f83" + "wasm32": "e41f65aaebc190dcee228d1ea98c2457067fe7d663600295edd3c4dba5fa7fe9", + "wasm64": "f1f74230a45246a83199389b0720846c0d1bca49cb74b89c27e5183c88c34bf4" } }, "python-vfs": { "manifestSha256": "d1aa99feae65f06c0ca8cf52c2176534b2723fd4ee6eba6e72c205245e1c9c26", "cacheKeys": { - "wasm32": "e73cb09d67db2e82cf77371824d94955bfc64ee38db34b984540cd244f9845d3", - "wasm64": "f81e1c62853eee86a69201a4eb7f3f8df5fd4fb45864b9bb81d085f836bb8aed" + "wasm32": "c2750749e26f91b085d37a475deb8ae645f039d71f3a98dca0dc312d91bc78d5", + "wasm64": "a587588700974b6b40001fcf408593dc264cc082f3a9358f9ddcd3d0ad9c725f" } }, "redis": { "manifestSha256": "151fb507de953ba2ba94b8f881bc7d66b4c741c1359a2f590cc07f9a4cd368a3", "cacheKeys": { - "wasm32": "4cdaefdda2931b6718e8113640ab250948f1c639bf726615e800888722708b7b", - "wasm64": "bb50b6d656ed7dfba7c7169376c1b89193efe979b9d1e75195f5ca3f96053dc1" + "wasm32": "68c241d9052b09d4f4f59f8632f504b723c15d2fd2141f8f13996890423c3a34", + "wasm64": "1dfac5d2ff93f5485902696a2c444901288b616e123a51d227c7636e34a7e5eb" } }, "redis-vfs": { "manifestSha256": "234c45c40f94e2a89f98295313b82cb9fce15a412ac829d9e87394e0dc731813", "cacheKeys": { - "wasm32": "e90fb913cea9c21e7caea6c1b18d292568d198831fa76a8e1a7d8351c04a9567", - "wasm64": "184ceff8dadc0b20ed28f3d95bd0801f88e96922965ee31f53c2526cd7e2d250" + "wasm32": "b5e03fbcd0a4f58df9d6d3697e7be136d72f4546c5a668b4e4d80155df404537", + "wasm64": "1f5986669365aae264986398355fcfc32764f7e0f7f9c9c795ec935079ee2683" } }, "rootfs": { "manifestSha256": "0420201025170f48c32949ac62707d4577cdc13a53ad1f01f59d318ec07c8d6e", "cacheKeys": { - "wasm32": "b42d7c0384065715380ef5971b4ca6918b44d0541c51568a1b787fcad9e3deab", - "wasm64": "f04f603cfa6f995597d9accea00c988e53f1003dc609fe11b99188105173537f" + "wasm32": "0aeef8161c450202f54e6aa95d41a4561ca59274878f596d6239457475913652", + "wasm64": "a93f084831163268a7e73dd8f741317e44b769fcf6cea2536a8186dd5e4c0f69" } }, "ruby": { "manifestSha256": "6ea67a246c29cd927a19decbb36ae4c4d478ff6642d2c77e08a6b2cfeb8251d2", "cacheKeys": { - "wasm32": "316f9e58680969175df40915adae5f86a7b973f0ff448b077f45ab7e3210e3d2", - "wasm64": "55f59e59aee2ccf2fcce992b117b6fa6d54af37f62b311313f279464ed245e18" + "wasm32": "41165bfbfcf42d350f539ac8a20d05f909f617cbc3c31257de01e64a4cbef237", + "wasm64": "75dc1bd97ef72cebde9d02e0555fe5ccb3f170f3399d4d25baaf1814972adb06" } }, "sdl-dsp-test": { "manifestSha256": "a988bef0b27403846a675965d951a286245fa79e84c209657d70a9a1200e8037", "cacheKeys": { - "wasm32": "42bb75d49df61508014684ce910aad8d16f76f5445bcab7b4191c19775370901", - "wasm64": "131aac533d7f227522859572f88940bab07fa8665ae6506cc0efb0e33788d124" + "wasm32": "23ddd4d1743f14e568e25d94947036e4a98a6eef19142bec72b16ba5d631ee17", + "wasm64": "c8f9b1cd958c93221e45093c9ec37b9c27d277971f407b8e24b0e8b3abdd98fa" } }, "sdl2": { "manifestSha256": "327ce0acca768f51e36ddab8ab58fd57b24cfd9e40722e818103c439f7263dd7", "cacheKeys": { - "wasm32": "f413896eee1d3f51953b367378203e187b868b6f4e311d7203bfa76525c70e12", - "wasm64": "8ec86d41c0f8f0b6572265c8a1deb809eb70fdb75cfdbe784de11058c536cdcc" + "wasm32": "85121752bf52d980d182b208b2d7f57492fdf7b8e888b43d7caf3a1d771ed478", + "wasm64": "116def5078eab67849bcaaaf72356aff96439762214b6594e9f0961f13ce8ed1" } }, "sdl2-mixer-playwave": { "manifestSha256": "5ff3863e9f83cb9ad62931e067ee6e417826e06391862d9d0a58d6cc6b4dc570", "cacheKeys": { - "wasm32": "fad45b0fcb2c48a2711c4e6660fee5bd54e1622be0ee3203fae476f61ad64dca", - "wasm64": "3bddb72684e88d42be00a7aa19e58a0827b8d5f104949cc7cbc693779da4566e" + "wasm32": "5230b431f084081e44db0bbd1506f7f84c3822c1e642bb0fa0c289486317817a", + "wasm64": "8d8a65de82e80fe7c4fd575ae4bb24329535df7049ed54a4771ad33358c1b698" } }, "sdl3": { "manifestSha256": "3b7c64605b941e14d336b1a127d8219c50402dece009e8855297a0a0696bd67e", "cacheKeys": { - "wasm32": "07e104fa92d244f53779ce5d3fe29e98682f7bda9f6d5686dee6a5715f07a2de", - "wasm64": "2199c883301f2cdf43b73f1c1900f427f051c86f760dd76962023e30a3ffb1e4" + "wasm32": "fa755e2cfc15d19c47865398b970f3fcd01fe62f0bcac272a5eeab22a40892f1", + "wasm64": "3792ae18a69857236241f438b29d30cb10c34d5b61e4abdbb3f511af119f7df2" } }, "sed": { "manifestSha256": "0c0d82d53907c19825941501f833252cf9adf35ebcebf6786baae28e5eb6958b", "cacheKeys": { - "wasm32": "a14e7eacff9d49b35562c39ddaf2d0e5b6379fc5b834477897456198e7256b31", - "wasm64": "e9c22261695508c9e670b75722ab0a70884429ffda4ddc292b5ee652a0021b38" + "wasm32": "38d1afd014557aea38dbbcade2478fffffd2d23ec5541defbe48fa3bd5d04991", + "wasm64": "c5b8f6b65705b7b44f71a1206a8517794d3a4a02df6f077be36379d307ae8186" } }, "shell": { "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", "cacheKeys": { - "wasm32": "34ee75fdfa40e68f9577a7cd30469736b431244409f83525b437481a3bd9f236", - "wasm64": "367fa63c64e9b335f161d4b9eed7a3c4c2bd52ef04283a562ed7c5d41dd49ad0" + "wasm32": "ecd448c7d47adcfdf2ea93ef1f0af98d61ca70ce2fe71e7c72055ef56a4a8265", + "wasm64": "575d8eedf6e3257668e998fd06def2301fc958796b0ecb7377612d9efc84758e" } }, "spidermonkey": { "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", "cacheKeys": { - "wasm32": "f2ea5982b8a18d9d49b9984275d6efc463491af28835b19be94c2ac30b903280", - "wasm64": "1a59d4df56e51309b65aadebdb519c7feea639ce0f1f77fbfc60b95b597ea805" + "wasm32": "87b26b326af7e0b7c9fa6f9241cfad32900a995883615d3b1bfff22d18d2f0e5", + "wasm64": "40e4937c69c24d1d723bf7fcba666af4014b210b4ff220360f3953ba1587521f" } }, "spidermonkey-node": { "manifestSha256": "f156c4c5044d2a9447324e7a2a35f8c3e6fa367b3e44f674dcc30ab6b946fae7", "cacheKeys": { - "wasm32": "c5f5efbb3d10b7f4c0c9193b5dce57f6ba6d4d24bf3eb03a477da6d2d4fb494a", - "wasm64": "63027e555a45c4243f3fd1592363ab0f13900d375032636292075deed427f754" + "wasm32": "e57264ee39fcf39708f464e9d124edca122c9e82eed80f68abe39783ba36b42a", + "wasm64": "c60bbf37cf43aef1ec150e1cbb52f9e37185881933031226dbb1fb62007a882f" } }, "sqlite": { "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", "cacheKeys": { - "wasm32": "78240f0d6f2c12d2692cb6a400e8fb612fa39a5e4fc93121f3bf4654b2f50626", - "wasm64": "cf56c32ca4ca3fe55841d7adb0554d265d4e531ff15c887320b6e94ffbb8acc9" + "wasm32": "fa91f92503bb45a1c89a925780c6b43f4eeb50c9fda686c15c2357e50d448e29", + "wasm64": "7f54d0c6782e686de04a6521fedd584669e6ebe387e335723efa7cac7b3c43fd" } }, "sqlite-cli": { "manifestSha256": "1cebb768f9473dc58fc6e72c9b24565760aedaec4cbdb29ad43b22a0a5fd7030", "cacheKeys": { - "wasm32": "7a1fa56532b582ec826f17801b12503241bbeca421d5a9797b4dd1be0f8252d6", - "wasm64": "9d2bc4c4f855460b04b4351182d77fdf392cf78475e385bd69ccf4541bbcf0c3" + "wasm32": "f1a0483d3dc6f1f65b97cb7fa7ea6c337523a1fe006fe645694ccd36e70f8c41", + "wasm64": "fe01dc84fffd6f331ed5c3abd353617eb06fd7252a55c135fd7d481511a8642f" } }, "tar": { "manifestSha256": "4502355c246362a2035e52aa7b35d274882fad4b4404c03470458baee723ae68", "cacheKeys": { - "wasm32": "ce34f76de356c21cf2ef66200523204ff08dc65cdb402fd7ec08ae90b3765c7f", - "wasm64": "3f7d5785aeb76d3809d7966135bac1e8b66f2fe69b881a396a579771246745ca" + "wasm32": "5a11abbdc91622ae1883c3ab3adc47651d5688843bce2248387651861256b2fb", + "wasm64": "476b7603c7336635a873d4d796c5e0ea83f5a5ec9a56d41922633abdaafac03d" } }, "tcl": { "manifestSha256": "b98f237f741b11f5f5da55db75530b421755ba4c8a88d314553106faa1cca1ff", "cacheKeys": { - "wasm32": "66ef9fb3d9dd4c9198b9e07a033775c9a182edb71aadd3e94b286b1b78d73a69", - "wasm64": "7b201599ee875e7268ef3be225b3758e9b2f105d58a43e4c4076e7eee8671100" + "wasm32": "6e9f0cfe84b03be8525fcb19bf79b3782e5b09dda920ba9c33752f457fe103af", + "wasm64": "0fcedf5bfd13031b03ba5451354c80ddb67f5721e92734382c54527a7c6b9174" } }, "texlive": { "manifestSha256": "028ada023f8a8f9966c8a28575242a339aaaf0e8870fe4a221986133b381c3c0", "cacheKeys": { - "wasm32": "3ca8dda209b7161a58f5d9f2eee193b48cc8ba8582cb6dfa9a131a5461fe515b", - "wasm64": "9072edb577ffcaecdbd8a2fb526c5eea596211bd078acc55e1182286f6413abe" + "wasm32": "d5c3a05d1aee35d8c8d5e86b57d039a7f06a47a8e55afb3d047ba69f31c8ae92", + "wasm64": "7de9a225b30fb7a0953c50b5bc333e70ddf7da7367848df02ca5f399c22f18a5" } }, "unzip": { "manifestSha256": "ef4e5e34258827ab3cbc1930da3fd9514f08f2d7d56c7c3e0d5c495a4d3a20e9", "cacheKeys": { - "wasm32": "ae1b008f8b3a9465906cf3fd4050b44d0a060d955117ceee2351a813d4b1fc23", - "wasm64": "e442d519d00f7a37e3366e6fc09c83f92e2526cbbcde63972dcdc2c1e9930aa0" + "wasm32": "5e292a602b1b645bdf8c4582fb3b3f1b323ef8d2f3642bd74a9c033f21d04fd4", + "wasm64": "5d956eb94a073ecf79fd330c283883dea278ee77a66eaa55467e46295a51c798" } }, "userspace": { "manifestSha256": "221176f2a096dee19bbefd89da5c7f50138ecd92d37ec9d7d6b35dbb25e86924", "cacheKeys": { - "wasm32": "bdc4fad1b44eca4e005722be05b532efc78dd7ad7419b616932e122ce5d07f3c", - "wasm64": "396ea2661a47c3f06091531f94908710b4b75a96cfc3ca5da9f3814ac40ec670" + "wasm32": "922bf7641c67c4521a684c39b27332329fc113b173c2338ee5007457d3e552a3", + "wasm64": "eaf6e71447b343c7b978a77f4c83afd45b6bc3e1d1fd6feebbf7e6c3b64063bc" } }, "vim": { "manifestSha256": "c211660ef41d01f95f3fbdf675b74891e897e3f304e04f1bf5586f326007382c", "cacheKeys": { - "wasm32": "8ff4d7d725da72ba25b42a5406b5fd258aab0f1f572cfdd956faa2dd7fc7cc4d", - "wasm64": "3a14a22941aef19134c01104ca125dc0bea0baedb57b4fc59e8256476f3ff40e" + "wasm32": "94bc46ef11607b637182ec2d8d7612321768db384c4b955889dee573e65fab61", + "wasm64": "0e31d190fc4d43af81a12bc887701e2f2dbf9b75eed8b6ee0a9a8ff3e554dc74" } }, "vim-browser-bundle": { "manifestSha256": "e31d84057db49c3534381604d0c8f3c7d765e33ede560a7ea7b01a5a8f1aa0d3", "cacheKeys": { - "wasm32": "c91715e0ee7db72fa1a3db567aa595979f5b6c404c7a7b7e8307e08e24d90de7", - "wasm64": "4b898cd5047af22b84e008f4ed29b8c889471cbb5f0b665d07b77d0fd106ad6e" + "wasm32": "bbfc2756d6d5ac700fbfadd2743b22b041f141b10ce78de366e1bb58c3ecee01", + "wasm64": "e761bd455ba69db80317de10b344bab0b5b53e283065b326528023c8fc0778ff" } }, "wget": { "manifestSha256": "61420b6cb1be12471f425b0aebf5aec58c49d0b43d939dd54508b305accbf54e", "cacheKeys": { - "wasm32": "fdd1243bbc56415f1c5f121ddc36efded472976189598aa5de8364703c971e0a", - "wasm64": "e9471812af87c22a5c15e8e336c10be24767ba84eeaa8d003f7097fca50d04d1" + "wasm32": "e3ba55fc9ecb1094c18f0ca40c9fb6767785201bfebbb64d02e2efffdce5bed2", + "wasm64": "42c994cc3c9fd7bb213133ed45827642c870e6c71960e1bda8874f526d0a3e1f" } }, "wordpress": { "manifestSha256": "36465e0596a06e855a8524eecfd87da98643bc13b37ee12939f5aab44bf33418", "cacheKeys": { - "wasm32": "486b19a6e1f89bdca70a3881b86a05242e698409ebe89f39948c9aeafe62284d", - "wasm64": "ffb7d26d11d224c70e1a6c7249c520954ea26ac1fb3f30f17f62cf0aab5a03d0" + "wasm32": "ed6338ea1f095ac13ce4b7dad3d81ef18ce08919e7bec0fec9940e843d2c9528", + "wasm64": "7618e1fbdbe1c26767f7549bb0dc49418f3e2ae19e065c1529324ac0c0731a76" } }, "xz": { "manifestSha256": "8707d35b8647b1af8fa165dcb6ce7b2a6a007e19ab2daca2680c39395864a737", "cacheKeys": { - "wasm32": "44c91c6e853b02f601c64d93c2ea42c35b3c29ac424d606bc7e04cb39d26b54c", - "wasm64": "3075a574412c94412339326ade1b950e9e47fb11bfb7e30f05c5b3858f6f850d" + "wasm32": "16efcc1048e122ed6098b46edb053dc5ee50ac5caaeba9e93b767df224e75921", + "wasm64": "9f0fd11f06c53a28d35add458916a300dbebd845972fadf434097cf0ca5bf33d" } }, "zip": { "manifestSha256": "9c9cfa8220aaeaea26736b55f7cd0a7517b8853c07f9f0d241ad67dd43592e36", "cacheKeys": { - "wasm32": "c6f82bac695685734075f1e1a4909623a9be28d56881c7c7657d374afdbd5f93", - "wasm64": "a2312f92ee777bdc753061ca5247f2a81f7eb991433e51645ae7645b43fcf1cc" + "wasm32": "307cb35847ff802b569012e26c3f42b308ab6dea07c62ffe6428d886f3c81189", + "wasm64": "eeab81ad2f0cfa1c8d0fe5a6218d14e2d4f2dc437d5a12e3ac994489ded2179d" } }, "zlib": { "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", "cacheKeys": { - "wasm32": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea", - "wasm64": "10f1183001a8d58b482d5a57681a88bc329c25151b89f6c2b8c26432f3b167bf" + "wasm32": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966", + "wasm64": "4f92cfedd1f97a89c34ca0dca461851c8bdeb2da6395e27c4ee9762a97f185cd" } }, "zstd": { "manifestSha256": "89f266938c2c253700a838e6352c9de66c976c84883325aaa979f72b732357e4", "cacheKeys": { - "wasm32": "9a68ab2d994c674006d1f50d4ad725f438599feea6b025b4b9d346932ad85dab", - "wasm64": "9c2cccf147b64f249d3b82c77752d463e4012a9dff870093591cce19d32eaaf5" + "wasm32": "013d3afc8e3ea69b7b88d930a8c845860f060b1b50ae80c66206871e8917c20b", + "wasm64": "a11e20fe3f3dee6c4a538f689f4df5c39e6b753500952700f139e3696c9c97c2" } } }, @@ -583,14 +583,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "43fd97bde16c9d5bc3bbc97f26e8e4cb25a1f95dbc6deb6a5909a0f5e124b6ff" + "wasm32": "f07a60d864434909721977f82b8a3467b54e32dc42e358b530e8ded261da0538" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "f06bbb1f53f43c18c3133a6ef7601df696b8968104f312fc5b278b32f0097de8" + "cacheKey": "b12820cd19b19d2999c58fbcf2afaeafacf7cc897268558936aea2b76d4a5182" } ] }, @@ -610,7 +610,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "bdbdfdd24e73b2abaf6a251c4f256d79140fcf429b7f017315be44d9238da3eb" + "wasm32": "408e5b33718c929736bc0a5c697be47fc7e39ab9874b3c4b4344b99fdbd06858" }, "dependencyClosures": { "wasm32": [] @@ -631,7 +631,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b95c9827ead7a75115450673b3d20e39c4e8cbbddbc29af1e423f318cdf0db44" + "wasm32": "fb25499f183169e8db67b97587baf931d5b02e34b4603a17c11e044247f2970f" }, "dependencyClosures": { "wasm32": [] @@ -652,7 +652,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "770b6ceaf5e508b0a9fff5798f131fd67dd9864054dc2cf6acf89836eabd85a1" + "wasm32": "cce36c322c0b3920569d5a2c270ed073aba92ceb684af714b497a805a25c4b94" }, "dependencyClosures": { "wasm32": [] @@ -673,14 +673,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a101343c9081af62dfe5b0b3c9d981926b458240c641edc80d7d4a4a5816adb8" + "wasm32": "5cc4364991db0475c376fe9b665b98f028b193080389ca3bfff6571050f5740c" }, "dependencyClosures": { "wasm32": [ { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea" + "cacheKey": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966" } ] }, @@ -707,19 +707,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "87f6acaf54efa8a8207f09679519a5b35efdaece28eb33c0ff866796e16eca0e" + "wasm32": "08caaa4fa5611ec4f8fadbd17172305a1efda6e5861f87430ae0c56b0ed89863" }, "dependencyClosures": { "wasm32": [ { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "211f5e6ea83462c8ebaf8a1a923b5f7f258143f6cfd762fc92b9ed23d615c339" + "cacheKey": "8645e6be336d82ed97665c93c9122f4046869d0495f464b9c89b3157426b39e7" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea" + "cacheKey": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966" } ] }, @@ -739,7 +739,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e7a48fe30c5509f8152a20c11da66e056f5b07c343fb1ae06f64884bc39cdbad" + "wasm32": "9c286ef92425fea87cd0c23f3ebacad335791389bc0fe8cd00572fac8c2b3dca" }, "dependencyClosures": { "wasm32": [] @@ -760,7 +760,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "55ee5fd0c56cacbc3d21752feeba5e8628fb4dfe670eafd3bb14dae4959a3c10" + "wasm32": "febb5cd14a0493af2060c4dab4ea9316fb814f5861c6af3e31fd19f9c5d99678" }, "dependencyClosures": { "wasm32": [] @@ -802,14 +802,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c81e0b1b714646718033c1b2b43293d3d087d502abae5cfe82e6933dd8fe20f9" + "wasm32": "81dcb6d5f937d5cca3481492df72cb19bf9f8c84973ef6093dde0e149ba42daa" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" + "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" } ] }, @@ -843,7 +843,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "d1e404a58baa024394755a8f250d0fc8de3d5384e252b4e12cac7bbe0e1ad04d" + "wasm32": "86516154326e2765f9ac8d232c4be618191de0a3757891ba284d826622b84d19" }, "dependencyClosures": { "wasm32": [] @@ -871,14 +871,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "eb1c457e4ba3400b5f8a4f149e63020a344c7ef9d215dda6bd06e32fe665e48c" + "wasm32": "5faf02e2dc9833eaef227471d2f82790f2e4406f0dcf8805f11d24d97543a7a4" }, "dependencyClosures": { "wasm32": [ { "packageName": "erlang", "manifestSha256": "475d6037e73a0f1e2f4381067194b2422751206979ea17209e10efa5a2a93bbb", - "cacheKey": "d1e404a58baa024394755a8f250d0fc8de3d5384e252b4e12cac7bbe0e1ad04d" + "cacheKey": "86516154326e2765f9ac8d232c4be618191de0a3757891ba284d826622b84d19" } ] }, @@ -898,7 +898,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "9e2b644abb9aab6062b69a84d4f9d864df9dbc5ddd099392cea29b16bd40b924" + "wasm32": "2bf2226875f72a078553c3ec6dc6c51bc59ff73aa010937c4b39b679fee48422" }, "dependencyClosures": { "wasm32": [] @@ -919,7 +919,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "357a02fbd427f35255e349b6243f23b662fdb2608e09d6a08a040c6ae0a07cc3" + "wasm32": "954c87e26e61e45fda6aa1953de7738da41760d7e6a15c153e7b0ee5a6510e18" }, "dependencyClosures": { "wasm32": [] @@ -947,7 +947,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ff88da1019fb2d3b56399f9450baa23cdb5069458ea99dca1f3d2da19eee1501" + "wasm32": "1927dd4585688c78835726fbc504184b52af1901b4da0baf60485cae3ab18c9b" }, "dependencyClosures": { "wasm32": [] @@ -975,7 +975,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0878a1253d8ed63fe64073b28351207fbe01dd12b6c166b7d6690e41164436c1" + "wasm32": "ef897546ac91a4c6879281455689ad9a7d6cc2acb50c3dd356dc6fb5df30d380" }, "dependencyClosures": { "wasm32": [] @@ -996,7 +996,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "248e67d35a337108e1e7fa1e3a468df2a94bbe5ba5e02e7ecef8d6b961a7076b" + "wasm32": "91dc732cb54b0ae02135f60f815d728a9a52e5d3efe470d51199b498c3aa49e4" }, "dependencyClosures": { "wasm32": [] @@ -1024,7 +1024,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e368d4eb26a528111c836689761ba645ad34d43aa21ae8c8420d16d1ace57da7" + "wasm32": "c1c9f271d06c271dd1e85f008f986b835d0ffd40a64537da54dfda1ce9f3b824" }, "dependencyClosures": { "wasm32": [] @@ -1045,7 +1045,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c93bacf2b437fb73da6dd5cafa6ac00575dc7a130590ad6423d1a186d45468cf" + "wasm32": "0367e5e2485ddaf8a30fd43d95584271ae2d7096d4935de8727b65ace3f9105d" }, "dependencyClosures": { "wasm32": [] @@ -1066,7 +1066,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "910d7ffed0e8cf7dc71d90c6d2300f53eaf266211b247b6e6a37bc302690322b" + "wasm32": "bf4a420c5bde02b90ce908972fce7b7da51a58ca2604224e89ce64900293b572" }, "dependencyClosures": { "wasm32": [] @@ -1094,14 +1094,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "5d07e216601e1466c300e439002aafb23df3193db1b4f767cf5454390df0f472" + "wasm32": "dc98bcfd4e6ad69eeb10b04715395a7a5772a240b168dc2827f07a48a4d0d3c0" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" + "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" } ] }, @@ -1121,64 +1121,64 @@ "wasm32" ], "cacheKeys": { - "wasm32": "04f6fb7e0f86ae44b016b26ccb8daa5eb1a21f0cc607de1aa630ef3ede8b7715" + "wasm32": "a94661681c2ed22a6d85605ce52a187a28b160cee3f0b22059dbc16f8329799a" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "c81e0b1b714646718033c1b2b43293d3d087d502abae5cfe82e6933dd8fe20f9" + "cacheKey": "81dcb6d5f937d5cca3481492df72cb19bf9f8c84973ef6093dde0e149ba42daa" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "869e58f1e945c89086b28e50dda9cd001d6205ae51404149404cbadbd338a3ef" + "cacheKey": "2785cf0bf052747d2ade36d90fe0ddb40a058f305d78e845ca5bb25dc553cf5d" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "13427d5971b26c2e3e2ee43756f88944e3b75385a4d13e6fd45dd63a6a7c9afe" + "cacheKey": "797282f3a1e94fb74c16c1fe0924d478eceb9fc9ae23cdefa40d675a76616ff7" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" + "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "e3b39e170e399c7c353bf41cf8340473dae7f378cd8ba8dbfd5fb9f4ef69f4c5" + "cacheKey": "918054f8e258e000a8f3ac9ef70870d7a0cb1153ecb6e0ac5b99b6152abae905" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "222b11ed5313da06ba2368741a2e4589f78f89beebd184edcb0559b20bddc3f5" + "cacheKey": "72264a21723fe72302c5e1b5205aad7117476ef656f54538e98a66c70219be8d" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "e4cdb8156bdfcfd302a18db3d908898f57b3859a00b308bbefe3a0f9b85de483" + "cacheKey": "01eec0bd3a1139f3baea193789492b632875c70d38aaf0be16794e43d0874245" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "22fb165b69847120ce44c5d6a7aef7b3c7d2804448eefec7a56b35c40631623d" + "cacheKey": "8f70905c8e42c2fcba97985f98ca1754ef32b0045daa638cc51410e90debb876" }, { "packageName": "msmtpd", "manifestSha256": "09de04a422ddf631a29a259d816e081f8d317d1077808ede55d8c500baae748f", - "cacheKey": "f1b48a8a5e1fae2548dd48bae15f7b96b56689cd8429f3f57db762fb0118838d" + "cacheKey": "cde71f771c392d62a09b6227c18c4c4ba5ede3605a085c57289966f5734e09d3" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "822af6438b8b472f4bf70a8d9d78fb2fe65980da303f2ff7034fb5ed67ccfbe3" + "cacheKey": "a678a30cf48177514b258ae186bc92282a3f564327629060f9e75f19443b75a2" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "211f5e6ea83462c8ebaf8a1a923b5f7f258143f6cfd762fc92b9ed23d615c339" + "cacheKey": "8645e6be336d82ed97665c93c9122f4046869d0495f464b9c89b3157426b39e7" }, { "packageName": "pcre2-source", @@ -1188,22 +1188,22 @@ { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "59f6df87ab85d47ee4c6b2a865d2180c1e62a960d782a4af60bdbba0af4e39a7" + "cacheKey": "0e623672a33a21b893c7f44854e7215bdda530a9e66a87dd9d91602e123d1d89" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "34ee75fdfa40e68f9577a7cd30469736b431244409f83525b437481a3bd9f236" + "cacheKey": "ecd448c7d47adcfdf2ea93ef1f0af98d61ca70ce2fe71e7c72055ef56a4a8265" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "78240f0d6f2c12d2692cb6a400e8fb612fa39a5e4fc93121f3bf4654b2f50626" + "cacheKey": "fa91f92503bb45a1c89a925780c6b43f4eeb50c9fda686c15c2357e50d448e29" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea" + "cacheKey": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966" } ] }, @@ -1223,7 +1223,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "d347d2f00c368bd2d9aef83d7dc57bb37a0c1af3ecc7f262310623a4c3538ec2" + "wasm32": "9bb5323df1cb2ef84cf27efc789566560a79649bd3deb00be219131ee2ea8911" }, "dependencyClosures": { "wasm32": [] @@ -1244,7 +1244,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e262c9ac18046a4817a8fc25e5a356e814fec11a7f53b905f1718d4dffaca656" + "wasm32": "539fe25b0bfe42849aed4bb1203fef9924225f243196185c663c70ecdc9c0593" }, "dependencyClosures": { "wasm32": [] @@ -1265,7 +1265,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "223de993ff0b49af372f84de543354c651a80ad0cc0d4a219b927f52fa522583" + "wasm32": "3da16454c105fff20c5e2cb8fef979e3a1a6166aa6e8eaa02a1bbef4b879f91f" }, "dependencyClosures": { "wasm32": [] @@ -1286,7 +1286,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "7ecea3370409cc9f5f40c92beeb29b944a6bfa3aa93e63f1400abc5ddf6a7bbd" + "wasm32": "0c45cd8ffb70f1a4615b8c0b7a0cff5acbe2645ffb795be91642bb510d070822" }, "dependencyClosures": { "wasm32": [] @@ -1308,15 +1308,15 @@ "wasm64" ], "cacheKeys": { - "wasm32": "22fb165b69847120ce44c5d6a7aef7b3c7d2804448eefec7a56b35c40631623d", - "wasm64": "5baf0991b29722ffd473317912e25525f5f9b463eceb3e942c52d6cbea183963" + "wasm32": "8f70905c8e42c2fcba97985f98ca1754ef32b0045daa638cc51410e90debb876", + "wasm64": "57287289e381c0e77d76a2bab7825b897a736f7b51fa0c41ff98dbe2047e9d53" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" + "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" }, { "packageName": "pcre2-source", @@ -1328,7 +1328,7 @@ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "1799d410cef457fa39446d4d4a0a17fbd4311da2a21d65b9f0268275508b616c" + "cacheKey": "fd3503736c73dcbe633dca3304e10e72ad4dfb9c8597f301a064180a3e193e91" }, { "packageName": "pcre2-source", @@ -1360,34 +1360,34 @@ "wasm32" ], "cacheKeys": { - "wasm32": "01f3400be98fe966cd9ac2213c04cef76ba72c52a7aeb8a4591d598945bc137f" + "wasm32": "fec07b5ce79b2739d02d2cb83309781a6ab1354748c75df7f31803120b898a82" }, "dependencyClosures": { "wasm32": [ { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "770b6ceaf5e508b0a9fff5798f131fd67dd9864054dc2cf6acf89836eabd85a1" + "cacheKey": "cce36c322c0b3920569d5a2c270ed073aba92ceb684af714b497a805a25c4b94" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "e7a48fe30c5509f8152a20c11da66e056f5b07c343fb1ae06f64884bc39cdbad" + "cacheKey": "9c286ef92425fea87cd0c23f3ebacad335791389bc0fe8cd00572fac8c2b3dca" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "c81e0b1b714646718033c1b2b43293d3d087d502abae5cfe82e6933dd8fe20f9" + "cacheKey": "81dcb6d5f937d5cca3481492df72cb19bf9f8c84973ef6093dde0e149ba42daa" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" + "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "22fb165b69847120ce44c5d6a7aef7b3c7d2804448eefec7a56b35c40631623d" + "cacheKey": "8f70905c8e42c2fcba97985f98ca1754ef32b0045daa638cc51410e90debb876" }, { "packageName": "pcre2-source", @@ -1413,35 +1413,35 @@ "wasm64" ], "cacheKeys": { - "wasm32": "de98d36939b492929f2f1433d8ee62e5dd25ae7ed6a968e2ed0feb2cb9c8b1c6", - "wasm64": "3e022eec241af103e25a3b90686698c6c8d5d60dfaca22611d2be6fe03b50fac" + "wasm32": "46578c96408828cca65c7e137124559a32f2a0a61812c17c4edbce8a0112af74", + "wasm64": "980ef7bed8c2b5d76aaf01d0ec7165978a207bd305858b4419fa6a69903115bd" }, "dependencyClosures": { "wasm32": [ { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "770b6ceaf5e508b0a9fff5798f131fd67dd9864054dc2cf6acf89836eabd85a1" + "cacheKey": "cce36c322c0b3920569d5a2c270ed073aba92ceb684af714b497a805a25c4b94" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "e7a48fe30c5509f8152a20c11da66e056f5b07c343fb1ae06f64884bc39cdbad" + "cacheKey": "9c286ef92425fea87cd0c23f3ebacad335791389bc0fe8cd00572fac8c2b3dca" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "c81e0b1b714646718033c1b2b43293d3d087d502abae5cfe82e6933dd8fe20f9" + "cacheKey": "81dcb6d5f937d5cca3481492df72cb19bf9f8c84973ef6093dde0e149ba42daa" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" + "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "22fb165b69847120ce44c5d6a7aef7b3c7d2804448eefec7a56b35c40631623d" + "cacheKey": "8f70905c8e42c2fcba97985f98ca1754ef32b0045daa638cc51410e90debb876" }, { "packageName": "pcre2-source", @@ -1453,27 +1453,27 @@ { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "6e015433d5bcad2709a5374916dfd81156dcf34531046b579b8cb621f15b0b70" + "cacheKey": "7878a840c190fe2dc7666fd459253dd9becb32c2a49ae96ecd1a81976a67e1ad" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "7eea3d285157e4e5f16bd4a12323aa4c175efb49d9fb23cb1caad45c2b2850b8" + "cacheKey": "4899a2545851c9cc17d489308f64f99aa66652349a3caebf1ef1532984579d29" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "df5791013bc496b63584dbe766c735abb22fadc012fc23819bc339db9aa2461e" + "cacheKey": "b201d49a3d0d5ce1203443a382281a885cafdaf52305b5c44c4c1a87e392fb5d" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "1799d410cef457fa39446d4d4a0a17fbd4311da2a21d65b9f0268275508b616c" + "cacheKey": "fd3503736c73dcbe633dca3304e10e72ad4dfb9c8597f301a064180a3e193e91" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "5baf0991b29722ffd473317912e25525f5f9b463eceb3e942c52d6cbea183963" + "cacheKey": "57287289e381c0e77d76a2bab7825b897a736f7b51fa0c41ff98dbe2047e9d53" }, { "packageName": "pcre2-source", @@ -1498,7 +1498,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "724e416a7663e8784edb7a0ea03d0c7534f3658dcce344b2dd019b17fc5e81d0" + "wasm32": "c1bca0979d20f7414d74615869904854fec454a3ccf1c08035c30135ba8d503b" }, "dependencyClosures": { "wasm32": [] @@ -1519,7 +1519,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f1b48a8a5e1fae2548dd48bae15f7b96b56689cd8429f3f57db762fb0118838d" + "wasm32": "cde71f771c392d62a09b6227c18c4c4ba5ede3605a085c57289966f5734e09d3" }, "dependencyClosures": { "wasm32": [] @@ -1540,7 +1540,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "83c0fa922e329fd32b693ed71cd0b52ef1154517deeccb9bd2ea3bb664cf20c1" + "wasm32": "245ebf3255ccf561998dffcdc1be5b6cc584c6db353279fac0ac3ff5db449118" }, "dependencyClosures": { "wasm32": [] @@ -1561,7 +1561,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f06bbb1f53f43c18c3133a6ef7601df696b8968104f312fc5b278b32f0097de8" + "wasm32": "b12820cd19b19d2999c58fbcf2afaeafacf7cc897268558936aea2b76d4a5182" }, "dependencyClosures": { "wasm32": [] @@ -1645,7 +1645,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "fdd999a93a4909ab5ad9b3b77b8ccd70b1347ac59833de449c6b016388820f66" + "wasm32": "8f6a38fe2dcf6645ce394283228cc6c1274a74d0711304b2b480ae5eee6fc38e" }, "dependencyClosures": { "wasm32": [] @@ -1666,14 +1666,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "1e30dfc4bdef94f60c18ebb30d6fbc38bdad557781a752d42e3d521d64e8d3e8" + "wasm32": "37063f8f247d962d17573762cf5f2b8cc285aeed905fd9aeb75a9ed518502f95" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "f06bbb1f53f43c18c3133a6ef7601df696b8968104f312fc5b278b32f0097de8" + "cacheKey": "b12820cd19b19d2999c58fbcf2afaeafacf7cc897268558936aea2b76d4a5182" } ] }, @@ -1693,19 +1693,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ed6ec18198a30ff239a0210d0ecf41b3b593571b714e9e91552b9c9200744c10" + "wasm32": "21d8c4f96a174ea9dae1ef8b55d6c43762d3c511074501c8f12ff8ab3aa6c85d" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "f06bbb1f53f43c18c3133a6ef7601df696b8968104f312fc5b278b32f0097de8" + "cacheKey": "b12820cd19b19d2999c58fbcf2afaeafacf7cc897268558936aea2b76d4a5182" }, { "packageName": "nethack", "manifestSha256": "1a2f12ec2770bd8d40006d6c878a3493b97c71c2bb63ad08c7f6ad99bfd53504", - "cacheKey": "1e30dfc4bdef94f60c18ebb30d6fbc38bdad557781a752d42e3d521d64e8d3e8" + "cacheKey": "37063f8f247d962d17573762cf5f2b8cc285aeed905fd9aeb75a9ed518502f95" } ] }, @@ -1725,7 +1725,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "822af6438b8b472f4bf70a8d9d78fb2fe65980da303f2ff7034fb5ed67ccfbe3" + "wasm32": "a678a30cf48177514b258ae186bc92282a3f564327629060f9e75f19443b75a2" }, "dependencyClosures": { "wasm32": [] @@ -1746,79 +1746,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "694e918bc29048a8fc93ee11a5ccf518297743181718993b0543074f224ec963" + "wasm32": "6cb5b584eaadfadf743d253ec7472b0fb0aa0dd83c65a38169811240ecfef305" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "c81e0b1b714646718033c1b2b43293d3d087d502abae5cfe82e6933dd8fe20f9" + "cacheKey": "81dcb6d5f937d5cca3481492df72cb19bf9f8c84973ef6093dde0e149ba42daa" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "869e58f1e945c89086b28e50dda9cd001d6205ae51404149404cbadbd338a3ef" + "cacheKey": "2785cf0bf052747d2ade36d90fe0ddb40a058f305d78e845ca5bb25dc553cf5d" }, { "packageName": "kernel", "manifestSha256": "db1d66db8575562ac7b3e720dbaa06d7dd5eef577d80220ea4167dc2d69b863b", - "cacheKey": "96aa66030a19468af2592bc4b7ed6f917dc65d1d0471ed61831a20166b36226d" + "cacheKey": "0645f1c9514cdc63e297c4c66111011d462165cf134bcadeabf31f54324557d4" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "13427d5971b26c2e3e2ee43756f88944e3b75385a4d13e6fd45dd63a6a7c9afe" + "cacheKey": "797282f3a1e94fb74c16c1fe0924d478eceb9fc9ae23cdefa40d675a76616ff7" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" + "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "e3b39e170e399c7c353bf41cf8340473dae7f378cd8ba8dbfd5fb9f4ef69f4c5" + "cacheKey": "918054f8e258e000a8f3ac9ef70870d7a0cb1153ecb6e0ac5b99b6152abae905" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "222b11ed5313da06ba2368741a2e4589f78f89beebd184edcb0559b20bddc3f5" + "cacheKey": "72264a21723fe72302c5e1b5205aad7117476ef656f54538e98a66c70219be8d" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "e4cdb8156bdfcfd302a18db3d908898f57b3859a00b308bbefe3a0f9b85de483" + "cacheKey": "01eec0bd3a1139f3baea193789492b632875c70d38aaf0be16794e43d0874245" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "822af6438b8b472f4bf70a8d9d78fb2fe65980da303f2ff7034fb5ed67ccfbe3" + "cacheKey": "a678a30cf48177514b258ae186bc92282a3f564327629060f9e75f19443b75a2" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "211f5e6ea83462c8ebaf8a1a923b5f7f258143f6cfd762fc92b9ed23d615c339" + "cacheKey": "8645e6be336d82ed97665c93c9122f4046869d0495f464b9c89b3157426b39e7" }, { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "59f6df87ab85d47ee4c6b2a865d2180c1e62a960d782a4af60bdbba0af4e39a7" + "cacheKey": "0e623672a33a21b893c7f44854e7215bdda530a9e66a87dd9d91602e123d1d89" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "34ee75fdfa40e68f9577a7cd30469736b431244409f83525b437481a3bd9f236" + "cacheKey": "ecd448c7d47adcfdf2ea93ef1f0af98d61ca70ce2fe71e7c72055ef56a4a8265" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "78240f0d6f2c12d2692cb6a400e8fb612fa39a5e4fc93121f3bf4654b2f50626" + "cacheKey": "fa91f92503bb45a1c89a925780c6b43f4eeb50c9fda686c15c2357e50d448e29" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea" + "cacheKey": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966" } ] }, @@ -1838,29 +1838,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a8b0beb30be4864ed693bb96041f9034fc3fb14cae0bf70f84ba14ba975737d2" + "wasm32": "f81a75e080bb17dc0a25d6f1686504ba75b0c3661bbdb5ef1611adfd22d83e6a" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "c81e0b1b714646718033c1b2b43293d3d087d502abae5cfe82e6933dd8fe20f9" + "cacheKey": "81dcb6d5f937d5cca3481492df72cb19bf9f8c84973ef6093dde0e149ba42daa" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" + "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "822af6438b8b472f4bf70a8d9d78fb2fe65980da303f2ff7034fb5ed67ccfbe3" + "cacheKey": "a678a30cf48177514b258ae186bc92282a3f564327629060f9e75f19443b75a2" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "34ee75fdfa40e68f9577a7cd30469736b431244409f83525b437481a3bd9f236" + "cacheKey": "ecd448c7d47adcfdf2ea93ef1f0af98d61ca70ce2fe71e7c72055ef56a4a8265" } ] }, @@ -1880,29 +1880,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "50887911c742eb0685693c9f2fa6aa959cd3191b27ca070233591589bef6d3a8" + "wasm32": "9ebb636f0ebc5111d9cc16c4194eab9ab54ee537a777bd772b4ae4c0ece7d24d" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" + "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "211f5e6ea83462c8ebaf8a1a923b5f7f258143f6cfd762fc92b9ed23d615c339" + "cacheKey": "8645e6be336d82ed97665c93c9122f4046869d0495f464b9c89b3157426b39e7" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "f2ea5982b8a18d9d49b9984275d6efc463491af28835b19be94c2ac30b903280" + "cacheKey": "87b26b326af7e0b7c9fa6f9241cfad32900a995883615d3b1bfff22d18d2f0e5" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea" + "cacheKey": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966" } ] }, @@ -1922,39 +1922,39 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0d6fcb431876cc2e4199a3ee2952a390b064cfbe4deccf9d58554a4faf0ced90" + "wasm32": "9604a7e34ea2211523bed0f7185efe2c1b388c7f7093c04144fe88c9ba473f90" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" + "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" }, { "packageName": "node", "manifestSha256": "2131a24dfda8587860e57fc65b573086477d376b065b3b9ca4e1dfe4d79f5790", - "cacheKey": "50887911c742eb0685693c9f2fa6aa959cd3191b27ca070233591589bef6d3a8" + "cacheKey": "9ebb636f0ebc5111d9cc16c4194eab9ab54ee537a777bd772b4ae4c0ece7d24d" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "211f5e6ea83462c8ebaf8a1a923b5f7f258143f6cfd762fc92b9ed23d615c339" + "cacheKey": "8645e6be336d82ed97665c93c9122f4046869d0495f464b9c89b3157426b39e7" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "34ee75fdfa40e68f9577a7cd30469736b431244409f83525b437481a3bd9f236" + "cacheKey": "ecd448c7d47adcfdf2ea93ef1f0af98d61ca70ce2fe71e7c72055ef56a4a8265" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "f2ea5982b8a18d9d49b9984275d6efc463491af28835b19be94c2ac30b903280" + "cacheKey": "87b26b326af7e0b7c9fa6f9241cfad32900a995883615d3b1bfff22d18d2f0e5" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea" + "cacheKey": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966" } ] }, @@ -1974,7 +1974,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a6112b228ccbaf2087e4ac7bff8488fbe3b551a04366160edbe4f651fb1a520a" + "wasm32": "c8cf2040ac50eaf4de04912a3c4fc437004a54358c97bc2f8e2b4df40544ab01" }, "dependencyClosures": { "wasm32": [] @@ -1995,14 +1995,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a338549c159a52b9f76840200077be97854353e7b508428ab56f161e1f259dd8" + "wasm32": "acadee4d8f1e4099ec6e6c4673e4ee5f85efb177bb95bcdfc163eaee1627c3b5" }, "dependencyClosures": { "wasm32": [ { "packageName": "perl", "manifestSha256": "6cdc4dbc54d0e4008cff41f82ec8918c0e44b4aef23903539c1bc0f538fe3ad2", - "cacheKey": "a6112b228ccbaf2087e4ac7bff8488fbe3b551a04366160edbe4f651fb1a520a" + "cacheKey": "c8cf2040ac50eaf4de04912a3c4fc437004a54358c97bc2f8e2b4df40544ab01" } ] }, @@ -2022,54 +2022,54 @@ "wasm32" ], "cacheKeys": { - "wasm32": "59f6df87ab85d47ee4c6b2a865d2180c1e62a960d782a4af60bdbba0af4e39a7" + "wasm32": "0e623672a33a21b893c7f44854e7215bdda530a9e66a87dd9d91602e123d1d89" }, "dependencyClosures": { "wasm32": [ { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "869e58f1e945c89086b28e50dda9cd001d6205ae51404149404cbadbd338a3ef" + "cacheKey": "2785cf0bf052747d2ade36d90fe0ddb40a058f305d78e845ca5bb25dc553cf5d" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "13427d5971b26c2e3e2ee43756f88944e3b75385a4d13e6fd45dd63a6a7c9afe" + "cacheKey": "797282f3a1e94fb74c16c1fe0924d478eceb9fc9ae23cdefa40d675a76616ff7" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" + "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "e3b39e170e399c7c353bf41cf8340473dae7f378cd8ba8dbfd5fb9f4ef69f4c5" + "cacheKey": "918054f8e258e000a8f3ac9ef70870d7a0cb1153ecb6e0ac5b99b6152abae905" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "222b11ed5313da06ba2368741a2e4589f78f89beebd184edcb0559b20bddc3f5" + "cacheKey": "72264a21723fe72302c5e1b5205aad7117476ef656f54538e98a66c70219be8d" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "e4cdb8156bdfcfd302a18db3d908898f57b3859a00b308bbefe3a0f9b85de483" + "cacheKey": "01eec0bd3a1139f3baea193789492b632875c70d38aaf0be16794e43d0874245" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "211f5e6ea83462c8ebaf8a1a923b5f7f258143f6cfd762fc92b9ed23d615c339" + "cacheKey": "8645e6be336d82ed97665c93c9122f4046869d0495f464b9c89b3157426b39e7" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "78240f0d6f2c12d2692cb6a400e8fb612fa39a5e4fc93121f3bf4654b2f50626" + "cacheKey": "fa91f92503bb45a1c89a925780c6b43f4eeb50c9fda686c15c2357e50d448e29" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea" + "cacheKey": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966" } ] }, @@ -2145,7 +2145,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ac05dfca7fad4d6f4d8f371b85940f0c98063b581e18ee48af5609d3f41e20b4" + "wasm32": "e41f65aaebc190dcee228d1ea98c2457067fe7d663600295edd3c4dba5fa7fe9" }, "dependencyClosures": { "wasm32": [] @@ -2418,19 +2418,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e73cb09d67db2e82cf77371824d94955bfc64ee38db34b984540cd244f9845d3" + "wasm32": "c2750749e26f91b085d37a475deb8ae645f039d71f3a98dca0dc312d91bc78d5" }, "dependencyClosures": { "wasm32": [ { "packageName": "cpython", "manifestSha256": "7dd4f446697a73941ec940c2ccba4d53be73fe6947f1e701031a4d0aa964c4ff", - "cacheKey": "a101343c9081af62dfe5b0b3c9d981926b458240c641edc80d7d4a4a5816adb8" + "cacheKey": "5cc4364991db0475c376fe9b665b98f028b193080389ca3bfff6571050f5740c" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea" + "cacheKey": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966" } ] }, @@ -2450,7 +2450,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "4cdaefdda2931b6718e8113640ab250948f1c639bf726615e800888722708b7b" + "wasm32": "68c241d9052b09d4f4f59f8632f504b723c15d2fd2141f8f13996890423c3a34" }, "dependencyClosures": { "wasm32": [] @@ -2478,24 +2478,24 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e90fb913cea9c21e7caea6c1b18d292568d198831fa76a8e1a7d8351c04a9567" + "wasm32": "b5e03fbcd0a4f58df9d6d3697e7be136d72f4546c5a668b4e4d80155df404537" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "c81e0b1b714646718033c1b2b43293d3d087d502abae5cfe82e6933dd8fe20f9" + "cacheKey": "81dcb6d5f937d5cca3481492df72cb19bf9f8c84973ef6093dde0e149ba42daa" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" + "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" }, { "packageName": "redis", "manifestSha256": "151fb507de953ba2ba94b8f881bc7d66b4c741c1359a2f590cc07f9a4cd368a3", - "cacheKey": "4cdaefdda2931b6718e8113640ab250948f1c639bf726615e800888722708b7b" + "cacheKey": "68c241d9052b09d4f4f59f8632f504b723c15d2fd2141f8f13996890423c3a34" } ] }, @@ -2515,79 +2515,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b42d7c0384065715380ef5971b4ca6918b44d0541c51568a1b787fcad9e3deab" + "wasm32": "0aeef8161c450202f54e6aa95d41a4561ca59274878f596d6239457475913652" }, "dependencyClosures": { "wasm32": [ { "packageName": "bash", "manifestSha256": "6478060f28d430d18a6ebe7c603392d35a41d9bcf6bc74322351d1450b9c5335", - "cacheKey": "43fd97bde16c9d5bc3bbc97f26e8e4cb25a1f95dbc6deb6a5909a0f5e124b6ff" + "cacheKey": "f07a60d864434909721977f82b8a3467b54e32dc42e358b530e8ded261da0538" }, { "packageName": "bc", "manifestSha256": "a65661463bb7047b91ff00153bd99fd962c96e571934ab4e923f5215fb6ecfbd", - "cacheKey": "bdbdfdd24e73b2abaf6a251c4f256d79140fcf429b7f017315be44d9238da3eb" + "cacheKey": "408e5b33718c929736bc0a5c697be47fc7e39ab9874b3c4b4344b99fdbd06858" }, { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "770b6ceaf5e508b0a9fff5798f131fd67dd9864054dc2cf6acf89836eabd85a1" + "cacheKey": "cce36c322c0b3920569d5a2c270ed073aba92ceb684af714b497a805a25c4b94" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "e7a48fe30c5509f8152a20c11da66e056f5b07c343fb1ae06f64884bc39cdbad" + "cacheKey": "9c286ef92425fea87cd0c23f3ebacad335791389bc0fe8cd00572fac8c2b3dca" }, { "packageName": "diffutils", "manifestSha256": "3a78f0a46bae43ce5ea235c6638c2cbd1d8559b0b62b1fb42443b35968c1aca3", - "cacheKey": "55ee5fd0c56cacbc3d21752feeba5e8628fb4dfe670eafd3bb14dae4959a3c10" + "cacheKey": "febb5cd14a0493af2060c4dab4ea9316fb814f5861c6af3e31fd19f9c5d99678" }, { "packageName": "file", "manifestSha256": "7874c1affcbbf2c8ab8c8d4beb087f275af57b5caae6a8947bf5a63a181552b2", - "cacheKey": "357a02fbd427f35255e349b6243f23b662fdb2608e09d6a08a040c6ae0a07cc3" + "cacheKey": "954c87e26e61e45fda6aa1953de7738da41760d7e6a15c153e7b0ee5a6510e18" }, { "packageName": "findutils", "manifestSha256": "cceedf52aea67fbb0da03cfce6f2e9b1a7b5561fa1656c2762b43c651a625d02", - "cacheKey": "ff88da1019fb2d3b56399f9450baa23cdb5069458ea99dca1f3d2da19eee1501" + "cacheKey": "1927dd4585688c78835726fbc504184b52af1901b4da0baf60485cae3ab18c9b" }, { "packageName": "gawk", "manifestSha256": "a2567b8b0778805e385e1d3a41ac8b5d74aaf9d293b222001b17d09b11e5a0ba", - "cacheKey": "0878a1253d8ed63fe64073b28351207fbe01dd12b6c166b7d6690e41164436c1" + "cacheKey": "ef897546ac91a4c6879281455689ad9a7d6cc2acb50c3dd356dc6fb5df30d380" }, { "packageName": "grep", "manifestSha256": "59270d3bfb33167b32d13246bf855b49308f8c9c0a66d9635c804f702421fbc6", - "cacheKey": "e368d4eb26a528111c836689761ba645ad34d43aa21ae8c8420d16d1ace57da7" + "cacheKey": "c1c9f271d06c271dd1e85f008f986b835d0ffd40a64537da54dfda1ce9f3b824" }, { "packageName": "m4", "manifestSha256": "2c6582d99d6eabfb9da52badf49b899e9fdcba76a728536b25bc3741f3331543", - "cacheKey": "223de993ff0b49af372f84de543354c651a80ad0cc0d4a219b927f52fa522583" + "cacheKey": "3da16454c105fff20c5e2cb8fef979e3a1a6166aa6e8eaa02a1bbef4b879f91f" }, { "packageName": "make", "manifestSha256": "f878d2d730f36a4c6dfe1fff1b4ccc704757ea22d8c4c8ccb95b11cd641e5fed", - "cacheKey": "7ecea3370409cc9f5f40c92beeb29b944a6bfa3aa93e63f1400abc5ddf6a7bbd" + "cacheKey": "0c45cd8ffb70f1a4615b8c0b7a0cff5acbe2645ffb795be91642bb510d070822" }, { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "f06bbb1f53f43c18c3133a6ef7601df696b8968104f312fc5b278b32f0097de8" + "cacheKey": "b12820cd19b19d2999c58fbcf2afaeafacf7cc897268558936aea2b76d4a5182" }, { "packageName": "posix-utils-lite", "manifestSha256": "8fd7190b2848ef80143adc7b9268c79e13cd4db3430f7105faf49a4b4407a06f", - "cacheKey": "ac05dfca7fad4d6f4d8f371b85940f0c98063b581e18ee48af5609d3f41e20b4" + "cacheKey": "e41f65aaebc190dcee228d1ea98c2457067fe7d663600295edd3c4dba5fa7fe9" }, { "packageName": "sed", "manifestSha256": "2a08e9c5dacc5facc8983c1ff35ff2a72db328405e9a1f464cc7ad155c4d08af", - "cacheKey": "a14e7eacff9d49b35562c39ddaf2d0e5b6379fc5b834477897456198e7256b31" + "cacheKey": "38d1afd014557aea38dbbcade2478fffffd2d23ec5541defbe48fa3bd5d04991" } ] }, @@ -2607,14 +2607,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "316f9e58680969175df40915adae5f86a7b973f0ff448b077f45ab7e3210e3d2" + "wasm32": "41165bfbfcf42d350f539ac8a20d05f909f617cbc3c31257de01e64a4cbef237" }, "dependencyClosures": { "wasm32": [ { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea" + "cacheKey": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966" } ] }, @@ -2641,19 +2641,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "42bb75d49df61508014684ce910aad8d16f76f5445bcab7b4191c19775370901" + "wasm32": "23ddd4d1743f14e568e25d94947036e4a98a6eef19142bec72b16ba5d631ee17" }, "dependencyClosures": { "wasm32": [ { "packageName": "sdl2", "manifestSha256": "327ce0acca768f51e36ddab8ab58fd57b24cfd9e40722e818103c439f7263dd7", - "cacheKey": "f413896eee1d3f51953b367378203e187b868b6f4e311d7203bfa76525c70e12" + "cacheKey": "85121752bf52d980d182b208b2d7f57492fdf7b8e888b43d7caf3a1d771ed478" }, { "packageName": "sdl3", "manifestSha256": "3b7c64605b941e14d336b1a127d8219c50402dece009e8855297a0a0696bd67e", - "cacheKey": "07e104fa92d244f53779ce5d3fe29e98682f7bda9f6d5686dee6a5715f07a2de" + "cacheKey": "fa755e2cfc15d19c47865398b970f3fcd01fe62f0bcac272a5eeab22a40892f1" } ] }, @@ -2680,14 +2680,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "fad45b0fcb2c48a2711c4e6660fee5bd54e1622be0ee3203fae476f61ad64dca" + "wasm32": "5230b431f084081e44db0bbd1506f7f84c3822c1e642bb0fa0c289486317817a" }, "dependencyClosures": { "wasm32": [ { "packageName": "sdl2", "manifestSha256": "327ce0acca768f51e36ddab8ab58fd57b24cfd9e40722e818103c439f7263dd7", - "cacheKey": "f413896eee1d3f51953b367378203e187b868b6f4e311d7203bfa76525c70e12" + "cacheKey": "85121752bf52d980d182b208b2d7f57492fdf7b8e888b43d7caf3a1d771ed478" } ] }, @@ -2707,7 +2707,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a14e7eacff9d49b35562c39ddaf2d0e5b6379fc5b834477897456198e7256b31" + "wasm32": "38d1afd014557aea38dbbcade2478fffffd2d23ec5541defbe48fa3bd5d04991" }, "dependencyClosures": { "wasm32": [] @@ -2728,7 +2728,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "34ee75fdfa40e68f9577a7cd30469736b431244409f83525b437481a3bd9f236" + "wasm32": "ecd448c7d47adcfdf2ea93ef1f0af98d61ca70ce2fe71e7c72055ef56a4a8265" }, "dependencyClosures": { "wasm32": [] @@ -2749,24 +2749,24 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f2ea5982b8a18d9d49b9984275d6efc463491af28835b19be94c2ac30b903280" + "wasm32": "87b26b326af7e0b7c9fa6f9241cfad32900a995883615d3b1bfff22d18d2f0e5" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" + "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "211f5e6ea83462c8ebaf8a1a923b5f7f258143f6cfd762fc92b9ed23d615c339" + "cacheKey": "8645e6be336d82ed97665c93c9122f4046869d0495f464b9c89b3157426b39e7" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea" + "cacheKey": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966" } ] }, @@ -2786,29 +2786,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c5f5efbb3d10b7f4c0c9193b5dce57f6ba6d4d24bf3eb03a477da6d2d4fb494a" + "wasm32": "e57264ee39fcf39708f464e9d124edca122c9e82eed80f68abe39783ba36b42a" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" + "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "211f5e6ea83462c8ebaf8a1a923b5f7f258143f6cfd762fc92b9ed23d615c339" + "cacheKey": "8645e6be336d82ed97665c93c9122f4046869d0495f464b9c89b3157426b39e7" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "f2ea5982b8a18d9d49b9984275d6efc463491af28835b19be94c2ac30b903280" + "cacheKey": "87b26b326af7e0b7c9fa6f9241cfad32900a995883615d3b1bfff22d18d2f0e5" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea" + "cacheKey": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966" } ] }, @@ -2828,7 +2828,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "7a1fa56532b582ec826f17801b12503241bbeca421d5a9797b4dd1be0f8252d6" + "wasm32": "f1a0483d3dc6f1f65b97cb7fa7ea6c337523a1fe006fe645694ccd36e70f8c41" }, "dependencyClosures": { "wasm32": [] @@ -2849,7 +2849,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ce34f76de356c21cf2ef66200523204ff08dc65cdb402fd7ec08ae90b3765c7f" + "wasm32": "5a11abbdc91622ae1883c3ab3adc47651d5688843bce2248387651861256b2fb" }, "dependencyClosures": { "wasm32": [] @@ -2870,7 +2870,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "66ef9fb3d9dd4c9198b9e07a033775c9a182edb71aadd3e94b286b1b78d73a69" + "wasm32": "6e9f0cfe84b03be8525fcb19bf79b3782e5b09dda920ba9c33752f457fe103af" }, "dependencyClosures": { "wasm32": [] @@ -2891,19 +2891,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "3ca8dda209b7161a58f5d9f2eee193b48cc8ba8582cb6dfa9a131a5461fe515b" + "wasm32": "d5c3a05d1aee35d8c8d5e86b57d039a7f06a47a8e55afb3d047ba69f31c8ae92" }, "dependencyClosures": { "wasm32": [ { "packageName": "libpng", "manifestSha256": "79ed5c5c072a0267cce5c7a2fe37d8494571b17e44b952f619e04ec1c4a9db10", - "cacheKey": "578ab5f1f7dc19a936f4dd6e6ac2da7cb4683213085c933eeeaeb424410ac8dc" + "cacheKey": "cbf7b239bb280c976b263f0a4f7fe6ce7b458f1d555e8306e8625d43c097547b" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea" + "cacheKey": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966" } ] }, @@ -2930,7 +2930,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ae1b008f8b3a9465906cf3fd4050b44d0a060d955117ceee2351a813d4b1fc23" + "wasm32": "5e292a602b1b645bdf8c4582fb3b3f1b323ef8d2f3642bd74a9c033f21d04fd4" }, "dependencyClosures": { "wasm32": [] @@ -2951,7 +2951,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "8ff4d7d725da72ba25b42a5406b5fd258aab0f1f572cfdd956faa2dd7fc7cc4d" + "wasm32": "94bc46ef11607b637182ec2d8d7612321768db384c4b955889dee573e65fab61" }, "dependencyClosures": { "wasm32": [] @@ -2972,14 +2972,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c91715e0ee7db72fa1a3db567aa595979f5b6c404c7a7b7e8307e08e24d90de7" + "wasm32": "bbfc2756d6d5ac700fbfadd2743b22b041f141b10ce78de366e1bb58c3ecee01" }, "dependencyClosures": { "wasm32": [ { "packageName": "vim", "manifestSha256": "c211660ef41d01f95f3fbdf675b74891e897e3f304e04f1bf5586f326007382c", - "cacheKey": "8ff4d7d725da72ba25b42a5406b5fd258aab0f1f572cfdd956faa2dd7fc7cc4d" + "cacheKey": "94bc46ef11607b637182ec2d8d7612321768db384c4b955889dee573e65fab61" } ] }, @@ -2999,7 +2999,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "fdd1243bbc56415f1c5f121ddc36efded472976189598aa5de8364703c971e0a" + "wasm32": "e3ba55fc9ecb1094c18f0ca40c9fb6767785201bfebbb64d02e2efffdce5bed2" }, "dependencyClosures": { "wasm32": [] @@ -3020,79 +3020,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "486b19a6e1f89bdca70a3881b86a05242e698409ebe89f39948c9aeafe62284d" + "wasm32": "ed6338ea1f095ac13ce4b7dad3d81ef18ce08919e7bec0fec9940e843d2c9528" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "c81e0b1b714646718033c1b2b43293d3d087d502abae5cfe82e6933dd8fe20f9" + "cacheKey": "81dcb6d5f937d5cca3481492df72cb19bf9f8c84973ef6093dde0e149ba42daa" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "869e58f1e945c89086b28e50dda9cd001d6205ae51404149404cbadbd338a3ef" + "cacheKey": "2785cf0bf052747d2ade36d90fe0ddb40a058f305d78e845ca5bb25dc553cf5d" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "13427d5971b26c2e3e2ee43756f88944e3b75385a4d13e6fd45dd63a6a7c9afe" + "cacheKey": "797282f3a1e94fb74c16c1fe0924d478eceb9fc9ae23cdefa40d675a76616ff7" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "10bdd03e430e97f0dfebe62d19c380b737265eda3a527f23622e4cc8c22f3e46" + "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "e3b39e170e399c7c353bf41cf8340473dae7f378cd8ba8dbfd5fb9f4ef69f4c5" + "cacheKey": "918054f8e258e000a8f3ac9ef70870d7a0cb1153ecb6e0ac5b99b6152abae905" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "222b11ed5313da06ba2368741a2e4589f78f89beebd184edcb0559b20bddc3f5" + "cacheKey": "72264a21723fe72302c5e1b5205aad7117476ef656f54538e98a66c70219be8d" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "e4cdb8156bdfcfd302a18db3d908898f57b3859a00b308bbefe3a0f9b85de483" + "cacheKey": "01eec0bd3a1139f3baea193789492b632875c70d38aaf0be16794e43d0874245" }, { "packageName": "msmtpd", "manifestSha256": "09de04a422ddf631a29a259d816e081f8d317d1077808ede55d8c500baae748f", - "cacheKey": "f1b48a8a5e1fae2548dd48bae15f7b96b56689cd8429f3f57db762fb0118838d" + "cacheKey": "cde71f771c392d62a09b6227c18c4c4ba5ede3605a085c57289966f5734e09d3" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "822af6438b8b472f4bf70a8d9d78fb2fe65980da303f2ff7034fb5ed67ccfbe3" + "cacheKey": "a678a30cf48177514b258ae186bc92282a3f564327629060f9e75f19443b75a2" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "211f5e6ea83462c8ebaf8a1a923b5f7f258143f6cfd762fc92b9ed23d615c339" + "cacheKey": "8645e6be336d82ed97665c93c9122f4046869d0495f464b9c89b3157426b39e7" }, { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "59f6df87ab85d47ee4c6b2a865d2180c1e62a960d782a4af60bdbba0af4e39a7" + "cacheKey": "0e623672a33a21b893c7f44854e7215bdda530a9e66a87dd9d91602e123d1d89" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "34ee75fdfa40e68f9577a7cd30469736b431244409f83525b437481a3bd9f236" + "cacheKey": "ecd448c7d47adcfdf2ea93ef1f0af98d61ca70ce2fe71e7c72055ef56a4a8265" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "78240f0d6f2c12d2692cb6a400e8fb612fa39a5e4fc93121f3bf4654b2f50626" + "cacheKey": "fa91f92503bb45a1c89a925780c6b43f4eeb50c9fda686c15c2357e50d448e29" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "ae1007ade85b52459c0b23bbd8998b7c9d1a2a279b7fbc64f4b8264fb9c515ea" + "cacheKey": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966" } ] }, @@ -3112,7 +3112,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "44c91c6e853b02f601c64d93c2ea42c35b3c29ac424d606bc7e04cb39d26b54c" + "wasm32": "16efcc1048e122ed6098b46edb053dc5ee50ac5caaeba9e93b767df224e75921" }, "dependencyClosures": { "wasm32": [] @@ -3133,7 +3133,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c6f82bac695685734075f1e1a4909623a9be28d56881c7c7657d374afdbd5f93" + "wasm32": "307cb35847ff802b569012e26c3f42b308ab6dea07c62ffe6428d886f3c81189" }, "dependencyClosures": { "wasm32": [] @@ -3154,7 +3154,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "9a68ab2d994c674006d1f50d4ad725f438599feea6b025b4b9d346932ad85dab" + "wasm32": "013d3afc8e3ea69b7b88d930a8c845860f060b1b50ae80c66206871e8917c20b" }, "dependencyClosures": { "wasm32": [] diff --git a/tools/xtask/src/dump_abi.rs b/tools/xtask/src/dump_abi.rs index 6e9d82d552..9dfb7d9051 100644 --- a/tools/xtask/src/dump_abi.rs +++ b/tools/xtask/src/dump_abi.rs @@ -3376,7 +3376,10 @@ fn render_ts_module() -> String { out.push_str(" | { type: \"fixed\"; size: number }\n"); out.push_str(" | { type: \"process-layout\"; wasm32Size: number; wasm64Size: number };\n\n"); out.push_str("export type SyscallArgCopyOutLengthSpec =\n"); - out.push_str(" { type: \"u32-field\"; argIndex: number; offset: number };\n\n"); + out.push_str(" | { type: \"u32-field\"; argIndex: number; offset: number }\n"); + out.push_str( + " | { type: \"return-value\"; multiplier: number; maxValue: number };\n\n", + ); out.push_str(&format!( "export const PROCESS_POINTER_WIDTH_ARG_INDEX = {} as const;\n\n", shared::host_abi::PROCESS_POINTER_WIDTH_ARG_INDEX @@ -3542,6 +3545,14 @@ fn ts_syscall_arg_copy_out_length( "{{ type: \"u32-field\", argIndex: {arg_index}, offset: {offset} }}" ) } + SyscallArgCopyOutLength::ReturnValue { + multiplier, + max_value, + } => { + format!( + "{{ type: \"return-value\", multiplier: {multiplier}, maxValue: {max_value} }}" + ) + } } } @@ -5587,6 +5598,14 @@ fn syscall_arg_copy_out_length_json( m.insert("argIndex".into(), json!(arg_index)); m.insert("offset".into(), json!(offset)); } + SyscallArgCopyOutLength::ReturnValue { + multiplier, + max_value, + } => { + m.insert("type".into(), json!("return-value")); + m.insert("multiplier".into(), json!(multiplier)); + m.insert("maxValue".into(), json!(max_value)); + } } Value::Object(m.into_iter().collect()) } From 92145af511a620fbb831671f6cd1e8ca61298706 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Tue, 11 Aug 2026 02:03:50 -0400 Subject: [PATCH 61/82] ABI: Bind and launch exact prepared exec targets --- abi/snapshot.json | 38 +- apps/browser-demos/test/nosuid-exec.spec.ts | 37 + .../test/prepared-exec-target.spec.ts | 111 ++ crates/kernel/src/exec_target.rs | 763 +++++++++ crates/kernel/src/fork.rs | 2 - crates/kernel/src/lib.rs | 1 + crates/kernel/src/process.rs | 86 +- crates/kernel/src/process_table.rs | 84 + crates/kernel/src/syscalls.rs | 1391 ++++++++++++++++- crates/kernel/src/wasm_api.rs | 374 +++-- crates/shared/src/lib.rs | 14 +- docs/architecture.md | 121 +- docs/posix-status.md | 4 +- host/src/binary-resolver.ts | 17 + host/src/browser-kernel-worker-entry.ts | 793 ++++++---- host/src/constants.ts | 7 + host/src/exec-target.ts | 364 +++++ host/src/generated/abi.ts | 9 +- host/src/kernel-scratch.ts | 8 + host/src/kernel-worker.ts | 349 ++++- host/src/kernel.ts | 49 +- host/src/node-kernel-host.ts | 9 +- host/src/node-kernel-protocol.ts | 10 +- host/src/node-kernel-worker-entry.ts | 726 +++++---- host/src/platform/node.ts | 10 + host/src/process-memory-creator-gate.ts | 44 +- host/src/types.ts | 2 + host/src/vfs/vfs.ts | 25 +- host/src/worker-adapter-browser.ts | 19 +- host/test/advisory-lock-retry.test.ts | 19 +- host/test/binary-resolver.test.ts | 64 +- host/test/browser-worker-adapter.test.ts | 37 + host/test/centralized-test-helper.ts | 311 ++-- host/test/deferred-worker-start.test.ts | 27 + host/test/exec-state-tracking.test.ts | 442 +++++- host/test/exec.test.ts | 80 +- host/test/kernel-exec-entry.test.ts | 123 +- host/test/kernel-scratch-contract.test.ts | 18 +- host/test/nosuid-exec.test.ts | 28 + host/test/prepared-exec-target.test.ts | 141 ++ host/test/process-memory-creator-gate.test.ts | 18 + host/test/spawn-host-parity.test.ts | 50 + host/test/spawn-pid-authority.test.ts | 61 +- host/test/support/kernel-scratch-instance.ts | 24 +- packages/registry/kernel/build-kernel.sh | 11 +- packages/registry/program-packages.json | 96 +- run.sh | 12 +- scripts/resolve-binary.bundle.mjs | 16 +- scripts/test-wasm-artifact-guards.sh | 42 + scripts/wasm-artifact-guards.sh | 43 + 50 files changed, 5757 insertions(+), 1373 deletions(-) create mode 100644 apps/browser-demos/test/prepared-exec-target.spec.ts create mode 100644 crates/kernel/src/exec_target.rs create mode 100644 host/src/exec-target.ts create mode 100644 host/test/prepared-exec-target.test.ts diff --git a/abi/snapshot.json b/abi/snapshot.json index 15e5ae8e33..0f3edc95c2 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -1061,8 +1061,11 @@ "kernel_create_process", "kernel_create_process_with_stdio", "kernel_dequeue_signal", - "kernel_exec_prepare", - "kernel_exec_setup_for_thread", + "kernel_exec_commit", + "kernel_exec_target_cancel", + "kernel_exec_target_prepare", + "kernel_exec_target_read", + "kernel_exec_target_size", "kernel_fork_process", "kernel_get_cwd", "kernel_get_dirfd_path", @@ -1108,6 +1111,8 @@ "kernel_set_current_tid", "kernel_set_cwd", "kernel_shmid_ds_bytes", + "kernel_spawn_exec_commit", + "kernel_spawn_exec_target_prepare", "kernel_spawn_process", "kernel_spawn_reserved_process", "kernel_spawn_scratch_begin", @@ -1975,23 +1980,28 @@ }, { "kind": "func", - "name": "kernel_exec_prepare", - "signature": "(i32,i32) -> (i32)" + "name": "kernel_exec_commit", + "signature": "(i32,i32,i32) -> (i32)" }, { "kind": "func", - "name": "kernel_exec_setup_for_thread", + "name": "kernel_exec_target_cancel", "signature": "(i32,i32) -> (i32)" }, { "kind": "func", - "name": "kernel_execve", - "signature": "(i32,i32) -> (i32)" + "name": "kernel_exec_target_prepare", + "signature": "(i32,i32,i32,i32,i32,i32) -> (i32)" }, { "kind": "func", - "name": "kernel_execveat", - "signature": "(i32,i32,i32,i32) -> (i32)" + "name": "kernel_exec_target_read", + "signature": "(i32,i32,i32,i32,i32,i32) -> (i32)" + }, + { + "kind": "func", + "name": "kernel_exec_target_size", + "signature": "(i32,i32) -> (i64)" }, { "kind": "func", @@ -3088,6 +3098,16 @@ "name": "kernel_socketpair", "signature": "(i32,i32,i32,i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_spawn_exec_commit", + "signature": "(i32,i32,i32) -> (i32)" + }, + { + "kind": "func", + "name": "kernel_spawn_exec_target_prepare", + "signature": "(i32,i32,i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_spawn_process", diff --git a/apps/browser-demos/test/nosuid-exec.spec.ts b/apps/browser-demos/test/nosuid-exec.spec.ts index 577c863291..18f1e34a35 100644 --- a/apps/browser-demos/test/nosuid-exec.spec.ts +++ b/apps/browser-demos/test/nosuid-exec.spec.ts @@ -8,6 +8,7 @@ const memoryFsModulePath = resolve(repoRoot, "host/src/vfs/memory-fs.ts"); const timeModulePath = resolve(repoRoot, "host/src/vfs/time.ts"); const typesModulePath = resolve(repoRoot, "host/src/vfs/types.ts"); const vfsModulePath = resolve(repoRoot, "host/src/vfs/vfs.ts"); +const abiModulePath = resolve(repoRoot, "host/src/generated/abi.ts"); test("browser mount policy defaults mutable execution to nosuid", async ({ page, @@ -20,6 +21,7 @@ test("browser mount policy defaults mutable execution to nosuid", async ({ asViteFsUrl(timeModulePath), asViteFsUrl(typesModulePath), asViteFsUrl(vfsModulePath), + asViteFsUrl(abiModulePath), ]; for (const moduleUrl of modules) { const response = await fetch(moduleUrl); @@ -38,6 +40,7 @@ test("browser mount policy defaults mutable execution to nosuid", async ({ const time = await import(/* @vite-ignore */ modules[1]); const types = await import(/* @vite-ignore */ modules[2]); const vfsModule = await import(/* @vite-ignore */ modules[3]); + const abi = await import(/* @vite-ignore */ modules[4]); const mutable = memory.MemoryFileSystem.create( new SharedArrayBuffer(2 * 1024 * 1024), ); @@ -67,6 +70,36 @@ test("browser mount policy defaults mutable execution to nosuid", async ({ }], new time.BrowserTimeProvider(), ); + const aliased = new vfsModule.VirtualPlatformIO( + [ + { + mountPoint: "/trusted", + backend: trustedBackend, + readonly: true, + setIdCapability: { + kind: "trusted-root-product", + guestWritable: false, + stableExecutableIdentity: true, + }, + }, + { mountPoint: "/raw", backend: trustedBackend, readonly: true }, + ], + new time.BrowserTimeProvider(), + ); + const trustedHandle = aliased.open( + "/trusted/bin/tool", + abi.OPEN_FLAGS.O_RDONLY, + 0, + ); + const rawHandle = aliased.open( + "/raw/bin/tool", + abi.OPEN_FLAGS.O_RDONLY, + 0, + ); + const trustedHandleFlags = aliased.fstatfs(trustedHandle).flags; + const rawHandleFlags = aliased.fstatfs(rawHandle).flags; + aliased.close(trustedHandle); + aliased.close(rawHandle); return { mutableFlags: ordinary.statfs("/bin/tool").flags, @@ -74,6 +107,8 @@ test("browser mount policy defaults mutable execution to nosuid", async ({ trustedFlags: trusted.statfs("/bin/tool").flags, trustedCapability: trusted.getMountSetIdCapability("/bin/tool"), trustedMode: trusted.stat("/bin/tool").mode, + trustedHandleFlags, + rawHandleFlags, stNosuid: types.ST_NOSUID, }; }, { @@ -89,4 +124,6 @@ test("browser mount policy defaults mutable execution to nosuid", async ({ stableExecutableIdentity: true, }); expect(result.trustedMode & 0o6000).toBe(0o6000); + expect(result.trustedHandleFlags & result.stNosuid).toBe(0); + expect(result.rawHandleFlags & result.stNosuid).toBe(result.stNosuid); }); diff --git a/apps/browser-demos/test/prepared-exec-target.spec.ts b/apps/browser-demos/test/prepared-exec-target.spec.ts new file mode 100644 index 0000000000..7606596b64 --- /dev/null +++ b/apps/browser-demos/test/prepared-exec-target.spec.ts @@ -0,0 +1,111 @@ +import { expect, test } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const browserKernelModulePath = resolve( + here, + "../../../host/src/browser-kernel-host.ts", +); +const memoryFsModulePath = resolve( + here, + "../../../host/src/vfs/memory-fs.ts", +); +const lifecycleProgramPath = resolve( + here, + "../../../local-binaries/programs/wasm32/vfork-lifecycle.wasm", +); +const execChildPath = resolve( + here, + "../../../local-binaries/programs/wasm32/exec-child.wasm", +); + +function bytes(path: string): number[] { + return Array.from(readFileSync(path)); +} + +test("a replacement Worker failure after exact-target commit is fatal", async ({ + page, + baseURL, +}) => { + test.setTimeout(180_000); + expect(baseURL).toBeTruthy(); + const asViteUrl = (path: string) => new URL(`/@fs/${path}`, baseURL).href; + await page.goto(new URL("/trap-signal-test.html", baseURL).href); + + const result = await page.evaluate(async ({ + browserKernelModuleUrl, + memoryFsModuleUrl, + lifecycleBytes, + childBytes, + }) => { + const { BrowserKernel } = await import( + /* @vite-ignore */ browserKernelModuleUrl + ); + const { MemoryFileSystem } = await import( + /* @vite-ignore */ memoryFsModuleUrl + ); + const decoder = new TextDecoder(); + let stdout = ""; + const diagnostics: Array<{ + status?: number; + source: string; + message: string; + }> = []; + const image = MemoryFileSystem.create( + new SharedArrayBuffer(4 * 1024 * 1024), + ); + image.mkdir("/bin", 0o755); + image.mkdir("/tmp", 0o755); + image.createFileWithOwner( + "/bin/vfork-exec-child", + 0o755, + 0, + 0, + new Uint8Array(childBytes), + ); + const kernel = new BrowserKernel({ + maxWorkers: 4, + env: ["KANDELO_TEST_EXEC_WORKER_CONSTRUCTION_FAILURE=once"], + onStdout: (data: Uint8Array) => { + stdout += decoder.decode(data); + }, + onHostDiagnostic: (diagnostic: { + status?: number; + source: string; + message: string; + }) => diagnostics.push(diagnostic), + }); + await kernel.initFromImage({ vfsImage: await image.saveImage() }); + try { + const exitCode = await kernel.spawn( + new Uint8Array(lifecycleBytes).buffer, + ["prepared-exec-postcommit-failure"], + ); + return { exitCode, stdout, diagnostics }; + } finally { + await kernel.destroy(); + } + }, { + browserKernelModuleUrl: asViteUrl(browserKernelModulePath), + memoryFsModuleUrl: asViteUrl(memoryFsModulePath), + lifecycleBytes: bytes(lifecycleProgramPath), + childBytes: bytes(execChildPath), + }); + + expect(result.stdout).toContain("PARENT_AFTER_EXEC_COMMIT"); + expect(result.stdout).not.toContain("argc=2"); + expect(result.stdout).not.toContain("PARENT_REAPED_EXEC_CHILD"); + expect(result.stdout).not.toContain("PASS: VFORK_LIFECYCLE"); + // The parent resumes, observes the child's fatal SIGSEGV status instead of + // the fixture's ordinary status 42, and truthfully fails that cycle. + expect(result.exitCode).toBe(5); + expect(result.diagnostics).toEqual([ + expect.objectContaining({ + status: 139, + source: "exec post-commit transition", + message: expect.stringContaining("injected exec Worker construction failure"), + }), + ]); +}); diff --git a/crates/kernel/src/exec_target.rs b/crates/kernel/src/exec_target.rs new file mode 100644 index 0000000000..e475a897d1 --- /dev/null +++ b/crates/kernel/src/exec_target.rs @@ -0,0 +1,763 @@ +extern crate alloc; + +use alloc::collections::BTreeMap; +use alloc::vec::Vec; + +use wasm_posix_shared::flags::{AT_EMPTY_PATH, AT_SYMLINK_NOFOLLOW}; +use wasm_posix_shared::mode::{S_ISGID, S_ISUID}; +use wasm_posix_shared::statfs_flags::ST_NOSUID; +use wasm_posix_shared::{Errno, WasmStat, WasmStatfs, platform_limits}; + +use crate::fd::OpenFileDescRef; +use crate::lock::{AdvisoryLockManager, FileId, OfdId}; +use crate::ofd::FileType; +use crate::process::{HostIO, Process}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PreparedExecOwner { + Process { + pid: u32, + caller_tid: u32, + generation: u64, + }, + Spawn { + parent_pid: u32, + child_pid: u32, + launch: u64, + }, +} + +impl PreparedExecOwner { + pub(crate) fn validate_process( + self, + pid: u32, + caller_tid: u32, + generation: u64, + ) -> Result<(), Errno> { + let Self::Process { + pid: expected_pid, + caller_tid: expected_tid, + generation: expected_generation, + } = self + else { + return Err(Errno::EINVAL); + }; + if pid != expected_pid { + return Err(Errno::ESRCH); + } + if caller_tid != expected_tid || generation != expected_generation { + return Err(Errno::EINVAL); + } + Ok(()) + } + + pub(crate) fn validate_spawn( + self, + parent_pid: u32, + child_pid: u32, + launch: u64, + ) -> Result<(), Errno> { + let Self::Spawn { + parent_pid: expected_parent, + child_pid: expected_child, + launch: expected_launch, + } = self + else { + return Err(Errno::EINVAL); + }; + if parent_pid != expected_parent || child_pid != expected_child { + return Err(Errno::ESRCH); + } + if launch != expected_launch { + return Err(Errno::EINVAL); + } + Ok(()) + } +} + +/// One exact executable object retained independently of guest descriptors. +/// +/// The pathname is diagnostic only. `ofd_ref` plus `ofd_id` is the object +/// lease, and `observed_bytes` is filled only by positioned reads through that +/// lease. Commit requires complete coverage and compares the same bytes again. +pub struct PreparedExecTarget { + token: u32, + owner: PreparedExecOwner, + ofd_ref: OpenFileDescRef, + ofd_id: OfdId, + file_id: Option, + stat: WasmStat, + statfs: WasmStatfs, + #[allow(dead_code)] // Retained only for Task 11 diagnostics, never authority. + diagnostic_path: Vec, + observed_bytes: Vec, + observed_ranges: Vec<(usize, usize)>, + content_drifted: bool, +} + +impl PreparedExecTarget { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + owner: PreparedExecOwner, + ofd_ref: OpenFileDescRef, + ofd_id: OfdId, + file_id: Option, + stat: WasmStat, + statfs: WasmStatfs, + diagnostic_path: Vec, + ) -> Result { + let size = usize::try_from(stat.st_size).map_err(|_| Errno::EOVERFLOW)?; + if size > platform_limits::MAX_REPORTABLE_TRANSFER_BYTES { + return Err(Errno::EFBIG); + } + let mut observed_bytes = Vec::new(); + observed_bytes + .try_reserve_exact(size) + .map_err(|_| Errno::ENOMEM)?; + observed_bytes.resize(size, 0); + + Ok(Self { + token: 0, + owner, + ofd_ref, + ofd_id, + file_id, + stat, + statfs, + diagnostic_path, + observed_bytes, + observed_ranges: Vec::new(), + content_drifted: false, + }) + } + + pub(crate) fn owner(&self) -> PreparedExecOwner { + self.owner + } + + pub(crate) fn ofd_ref(&self) -> OpenFileDescRef { + self.ofd_ref + } + + pub(crate) fn ofd_id(&self) -> OfdId { + self.ofd_id + } + + pub(crate) fn file_id(&self) -> Option { + self.file_id + } + + pub(crate) fn stat(&self) -> &WasmStat { + &self.stat + } + + pub(crate) fn statfs(&self) -> &WasmStatfs { + &self.statfs + } + + pub(crate) fn size(&self) -> usize { + self.observed_bytes.len() + } + + pub(crate) fn is_fully_observed(&self) -> bool { + self.observed_bytes.is_empty() + || (self.observed_ranges.len() == 1 + && self.observed_ranges[0] == (0, self.observed_bytes.len())) + } + + pub(crate) fn mark_content_drifted(&mut self) { + self.content_drifted = true; + } + + pub(crate) fn observed_bytes(&self) -> Result<&[u8], Errno> { + if !self.is_fully_observed() { + return Err(Errno::EINVAL); + } + if self.content_drifted { + return Err(Errno::ETXTBSY); + } + Ok(&self.observed_bytes) + } + + pub(crate) fn record_read(&mut self, offset: usize, bytes: &[u8]) -> Result<(), Errno> { + let end = offset.checked_add(bytes.len()).ok_or(Errno::EOVERFLOW)?; + if end > self.observed_bytes.len() { + return Err(Errno::EINVAL); + } + + for &(covered_start, covered_end) in &self.observed_ranges { + let overlap_start = covered_start.max(offset); + let overlap_end = covered_end.min(end); + if overlap_start < overlap_end { + let existing = &self.observed_bytes[overlap_start..overlap_end]; + let incoming = &bytes[overlap_start - offset..overlap_end - offset]; + if existing != incoming { + self.content_drifted = true; + } + } + } + self.observed_bytes[offset..end].copy_from_slice(bytes); + if offset == end { + return Ok(()); + } + + let mut merged_start = offset; + let mut merged_end = end; + let mut first = 0; + while first < self.observed_ranges.len() && self.observed_ranges[first].1 < merged_start { + first += 1; + } + let mut last = first; + while last < self.observed_ranges.len() && self.observed_ranges[last].0 <= merged_end { + merged_start = merged_start.min(self.observed_ranges[last].0); + merged_end = merged_end.max(self.observed_ranges[last].1); + last += 1; + } + self.observed_ranges + .splice(first..last, core::iter::once((merged_start, merged_end))); + Ok(()) + } + + pub(crate) fn is_script(&self) -> Result { + Ok(self.observed_bytes()?.starts_with(b"#!")) + } +} + +pub struct PreparedExecLedger { + entries: BTreeMap, + next_token: Option, +} + +impl PreparedExecLedger { + pub const fn new() -> Self { + Self { + entries: BTreeMap::new(), + next_token: Some(1), + } + } + + pub(crate) fn insert(&mut self, mut target: PreparedExecTarget) -> Result { + let token = self.next_token.ok_or(Errno::EOVERFLOW)?; + debug_assert_ne!(token, 0); + debug_assert!(token <= i32::MAX as u32); + self.next_token = token.checked_add(1).filter(|next| *next <= i32::MAX as u32); + target.token = token; + if self.entries.insert(token, target).is_some() { + return Err(Errno::EOVERFLOW); + } + Ok(token) + } + + pub(crate) fn ensure_insert_capacity(&self) -> Result<(), Errno> { + self.next_token.map(|_| ()).ok_or(Errno::EOVERFLOW) + } + + pub(crate) fn get(&self, token: u32) -> Result<&PreparedExecTarget, Errno> { + self.entries.get(&token).ok_or(Errno::EINVAL) + } + + pub(crate) fn get_mut(&mut self, token: u32) -> Result<&mut PreparedExecTarget, Errno> { + self.entries.get_mut(&token).ok_or(Errno::EINVAL) + } + + pub(crate) fn take(&mut self, token: u32) -> Result { + self.entries.remove(&token).ok_or(Errno::EINVAL) + } + + pub(crate) fn drain(&mut self) -> impl Iterator + '_ { + core::mem::take(&mut self.entries).into_values() + } + + #[cfg(test)] + pub(crate) fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + #[cfg(test)] + fn set_next_token_for_test(&mut self, next: u32) { + self.next_token = Some(next); + } +} + +fn owner_matches_ledger_pid(owner: PreparedExecOwner, pid: u32) -> Result<(), Errno> { + let expected_pid = match owner { + PreparedExecOwner::Process { pid, .. } => pid, + PreparedExecOwner::Spawn { child_pid, .. } => child_pid, + }; + if pid == expected_pid { + Ok(()) + } else { + Err(Errno::ESRCH) + } +} + +fn release_target( + proc: &mut Process, + locks: &mut AdvisoryLockManager, + host: &mut dyn HostIO, + target: &PreparedExecTarget, +) { + let _ = + crate::syscalls::release_prepared_exec_ofd_with_locks(proc, locks, host, target.ofd_ref()); +} + +fn insert_opened_target( + proc: &mut Process, + locks: &mut AdvisoryLockManager, + host: &mut dyn HostIO, + owner: PreparedExecOwner, + opened: crate::syscalls::PreparedExecOpen, +) -> Result { + let ofd_ref = opened.ofd_ref; + let target = match PreparedExecTarget::new( + owner, + opened.ofd_ref, + opened.ofd_id, + opened.file_id, + opened.stat, + opened.statfs, + opened.diagnostic_path, + ) { + Ok(target) => target, + Err(error) => { + let _ = + crate::syscalls::release_prepared_exec_ofd_with_locks(proc, locks, host, ofd_ref); + return Err(error); + } + }; + match proc.prepared_exec_targets.insert(target) { + Ok(token) => Ok(token), + Err(error) => { + let _ = + crate::syscalls::release_prepared_exec_ofd_with_locks(proc, locks, host, ofd_ref); + Err(error) + } + } +} + +fn retain_empty_path_target( + proc: &mut Process, + host: &mut dyn HostIO, + fd: i32, +) -> Result { + let ofd_ref = proc.fd_table.get(fd)?.ofd_ref; + let (ofd_id, file_type, host_handle, diagnostic_path) = { + let ofd = proc.ofd_table.get(ofd_ref.0).ok_or(Errno::EBADF)?; + (ofd.ofd_id, ofd.file_type, ofd.host_handle, ofd.path.clone()) + }; + if file_type != FileType::Regular || host_handle < 0 { + return Err(Errno::EACCES); + } + let stat = host.host_fstat(host_handle)?; + crate::syscalls::check_prepared_exec_stat(proc, &stat)?; + let statfs = crate::syscalls::host_fstatfs_or_default(host, host_handle)?; + let file_id = (stat.st_ino != 0).then_some(FileId::Host { + dev: stat.st_dev, + ino: stat.st_ino, + }); + proc.ofd_table.try_inc_ref_exact(ofd_ref.0, ofd_id)?; + Ok(crate::syscalls::PreparedExecOpen { + ofd_ref, + ofd_id, + file_id, + stat, + statfs, + diagnostic_path, + }) +} + +/// Retain the exact object selected by one pathname or `AT_EMPTY_PATH` +/// request. The returned token is process-local, positive, and one-shot. +pub(crate) fn prepare( + proc: &mut Process, + locks: &mut AdvisoryLockManager, + host: &mut dyn HostIO, + owner: PreparedExecOwner, + dirfd: i32, + path: &[u8], + flags: u32, +) -> Result { + proc.prepared_exec_targets.ensure_insert_capacity()?; + if flags & !(AT_EMPTY_PATH | AT_SYMLINK_NOFOLLOW) != 0 { + return Err(Errno::EINVAL); + } + let opened = if path.is_empty() { + if flags & AT_EMPTY_PATH == 0 { + return Err(Errno::ENOENT); + } + retain_empty_path_target(proc, host, dirfd)? + } else { + crate::syscalls::open_prepared_exec_target(proc, host, dirfd, path, flags)? + }; + insert_opened_target(proc, locks, host, owner, opened) +} + +fn retained_host_handle(proc: &Process, target: &PreparedExecTarget) -> Result { + let ofd = proc + .ofd_table + .get(target.ofd_ref().0) + .ok_or(Errno::ETXTBSY)?; + if ofd.ofd_id != target.ofd_id() || ofd.file_type != FileType::Regular || ofd.host_handle < 0 { + return Err(Errno::ETXTBSY); + } + Ok(ofd.host_handle) +} + +pub(crate) fn size(proc: &Process, owner_pid: u32, token: u32) -> Result { + let target = proc.prepared_exec_targets.get(token)?; + owner_matches_ledger_pid(target.owner(), owner_pid)?; + i64::try_from(target.size()).map_err(|_| Errno::EOVERFLOW) +} + +pub(crate) fn read( + proc: &mut Process, + host: &mut dyn HostIO, + owner_pid: u32, + token: u32, + offset: i64, + buffer: &mut [u8], +) -> Result { + if offset < 0 { + return Err(Errno::EINVAL); + } + let offset = usize::try_from(offset).map_err(|_| Errno::EOVERFLOW)?; + let (handle, size) = { + let target = proc.prepared_exec_targets.get(token)?; + owner_matches_ledger_pid(target.owner(), owner_pid)?; + (retained_host_handle(proc, target)?, target.size()) + }; + if offset >= size || buffer.is_empty() { + return Ok(0); + } + let wanted = buffer.len().min(size - offset); + let read = host.host_pread(handle, &mut buffer[..wanted], offset as i64)?; + if read > wanted { + return Err(Errno::EIO); + } + let target = proc.prepared_exec_targets.get_mut(token)?; + if read == 0 && offset < size { + target.mark_content_drifted(); + } + target.record_read(offset, &buffer[..read])?; + Ok(read) +} + +pub(crate) fn cancel( + proc: &mut Process, + locks: &mut AdvisoryLockManager, + host: &mut dyn HostIO, + owner_pid: u32, + token: u32, +) -> Result<(), Errno> { + { + let target = proc.prepared_exec_targets.get(token)?; + owner_matches_ledger_pid(target.owner(), owner_pid)?; + } + let target = proc.prepared_exec_targets.take(token)?; + release_target(proc, locks, host, &target); + Ok(()) +} + +fn metadata_matches(prepared: &WasmStat, live: &WasmStat) -> bool { + prepared.st_dev == live.st_dev + && prepared.st_ino == live.st_ino + && prepared.st_mode == live.st_mode + && prepared.st_uid == live.st_uid + && prepared.st_gid == live.st_gid + && prepared.st_size == live.st_size + && prepared.st_mtime_sec == live.st_mtime_sec + && prepared.st_mtime_nsec == live.st_mtime_nsec +} + +fn mount_matches(prepared: &WasmStatfs, live: &WasmStatfs) -> bool { + prepared.f_type == live.f_type + && prepared.f_fsid == live.f_fsid + && prepared.f_flags == live.f_flags +} + +fn has_credential_transition(target: &PreparedExecTarget) -> Result { + if target.is_script()? || target.stat().st_mode & (S_ISUID | S_ISGID) == 0 { + return Ok(false); + } + let proposal = crate::syscalls::propose_set_id_transition(target.stat(), target.statfs()); + Ok(proposal.effective_uid.is_some() || proposal.effective_gid.is_some()) +} + +fn validate_stable_privileged_source(target: &PreparedExecTarget) -> Result<(), Errno> { + if target.statfs().f_flags & ST_NOSUID != 0 { + return Ok(()); + } + match target.file_id() { + Some(FileId::Host { dev, ino }) + if dev == target.stat().st_dev && ino == target.stat().st_ino && ino != 0 => + { + Ok(()) + } + _ => Err(Errno::ENOTSUP), + } +} + +fn revalidate( + proc: &Process, + host: &mut dyn HostIO, + target: &PreparedExecTarget, +) -> Result<(), Errno> { + let expected = target.observed_bytes()?; + let handle = retained_host_handle(proc, target)?; + let before = host.host_fstat(handle)?; + if !metadata_matches(target.stat(), &before) { + return Err(Errno::ETXTBSY); + } + + let credential_bearing = has_credential_transition(target)?; + if credential_bearing { + validate_stable_privileged_source(target)?; + } + let live = host.host_fstatfs(handle).map_err(|_| Errno::ENOTSUP)?; + if !mount_matches(target.statfs(), &live) { + return Err(Errno::ETXTBSY); + } + + let mut offset = 0usize; + let mut scratch = [0u8; 64 * 1024]; + while offset < expected.len() { + let wanted = scratch.len().min(expected.len() - offset); + let read = host.host_pread(handle, &mut scratch[..wanted], offset as i64)?; + if read == 0 || read > wanted || scratch[..read] != expected[offset..offset + read] { + return Err(Errno::ETXTBSY); + } + offset += read; + } + let after = host.host_fstat(handle)?; + if !metadata_matches(target.stat(), &after) { + return Err(Errno::ETXTBSY); + } + Ok(()) +} + +fn proposed_credentials( + proc: &Process, + target: &PreparedExecTarget, +) -> Result { + let mut candidate = proc.credentials().clone(); + if !target.is_script()? { + let proposal = crate::syscalls::propose_set_id_transition(target.stat(), target.statfs()); + if let Some(uid) = proposal.effective_uid { + candidate.euid = uid; + } + if let Some(gid) = proposal.effective_gid { + candidate.egid = gid; + } + } + candidate.suid = candidate.euid; + candidate.sgid = candidate.egid; + Ok(candidate) +} + +fn finish_commit( + proc: &mut Process, + locks: &mut AdvisoryLockManager, + host: &mut dyn HostIO, + target: PreparedExecTarget, + caller_tid: Option, +) -> Result<(), Errno> { + let next_generation = proc + .exec_generation + .checked_add(1) + .ok_or(Errno::EOVERFLOW)?; + revalidate(proc, host, &target)?; + let credentials = proposed_credentials(proc, &target)?; + match caller_tid { + Some(tid) => crate::syscalls::commit_exec_state_with_locks(proc, locks, host, tid)?, + None => crate::syscalls::commit_spawn_exec_state_with_locks(proc, locks, host)?, + } + proc.secure_exec = credentials.euid != credentials.ruid || credentials.egid != credentials.rgid; + proc.install_credentials(credentials); + proc.exec_generation = next_generation; + + let competing: Vec<_> = proc.prepared_exec_targets.drain().collect(); + for stale in competing { + release_target(proc, locks, host, &stale); + } + Ok(()) +} + +pub(crate) fn commit_process( + proc: &mut Process, + locks: &mut AdvisoryLockManager, + host: &mut dyn HostIO, + pid: u32, + caller_tid: u32, + token: u32, +) -> Result<(), Errno> { + let target = proc.prepared_exec_targets.take(token)?; + let validation = target + .owner() + .validate_process(pid, caller_tid, proc.exec_generation); + commit_taken_target(proc, locks, host, target, validation, Some(caller_tid)) +} + +fn commit_taken_target( + proc: &mut Process, + locks: &mut AdvisoryLockManager, + host: &mut dyn HostIO, + target: PreparedExecTarget, + validation: Result<(), Errno>, + caller_tid: Option, +) -> Result<(), Errno> { + if let Err(error) = validation { + release_target(proc, locks, host, &target); + return Err(error); + } + let ofd_ref = target.ofd_ref(); + let result = finish_commit(proc, locks, host, target, caller_tid); + let _ = crate::syscalls::release_prepared_exec_ofd_with_locks(proc, locks, host, ofd_ref); + result +} + +pub(crate) fn commit_spawn( + proc: &mut Process, + locks: &mut AdvisoryLockManager, + host: &mut dyn HostIO, + parent_pid: u32, + child_pid: u32, + token: u32, +) -> Result<(), Errno> { + let target = proc.prepared_exec_targets.take(token)?; + let validation = target + .owner() + .validate_spawn(parent_pid, child_pid, proc.exec_generation); + commit_taken_target(proc, locks, host, target, validation, None) +} + +pub(crate) fn drain_prepared_exec_targets( + proc: &mut Process, + locks: &mut AdvisoryLockManager, + host: &mut dyn HostIO, +) { + let targets: Vec<_> = proc.prepared_exec_targets.drain().collect(); + for target in targets { + release_target(proc, locks, host, &target); + } +} + +impl Default for PreparedExecLedger { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::{PreparedExecLedger, PreparedExecOwner, PreparedExecTarget}; + use crate::fd::OpenFileDescRef; + use crate::lock::{FileId, OfdId}; + use wasm_posix_shared::{Errno, WasmStat, WasmStatfs}; + + fn stat() -> WasmStat { + WasmStat { + st_dev: 7, + st_ino: 11, + st_mode: wasm_posix_shared::mode::S_IFREG | 0o755, + st_nlink: 1, + st_uid: 1000, + st_gid: 1000, + st_size: 4, + st_atime_sec: 0, + st_atime_nsec: 0, + st_mtime_sec: 1, + st_mtime_nsec: 2, + st_ctime_sec: 3, + st_ctime_nsec: 4, + _pad: 0, + } + } + + fn statfs() -> WasmStatfs { + WasmStatfs { + f_type: 1, + f_bsize: 4096, + f_blocks: 1, + f_bfree: 0, + f_bavail: 0, + f_files: 1, + f_ffree: 0, + f_fsid: 19, + f_namelen: 255, + f_frsize: 4096, + f_flags: wasm_posix_shared::statfs_flags::ST_NOSUID, + _pad: 0, + } + } + + fn target(owner: PreparedExecOwner, index: usize, id: u64) -> PreparedExecTarget { + PreparedExecTarget::new( + owner, + OpenFileDescRef(index), + OfdId(id), + Some(FileId::Host { dev: 7, ino: 11 }), + stat(), + statfs(), + b"/bin/program".to_vec(), + ) + .unwrap() + } + + #[test] + fn tokens_are_positive_monotonic_one_shot_authority() { + let owner = PreparedExecOwner::Process { + pid: 41, + caller_tid: 43, + generation: 9, + }; + let mut ledger = PreparedExecLedger::new(); + let first = ledger.insert(target(owner, 3, 101)).unwrap(); + let second = ledger.insert(target(owner, 4, 102)).unwrap(); + + assert!(first > 0); + assert_eq!(second, first + 1); + assert_eq!(ledger.take(first).unwrap().ofd_id(), OfdId(101)); + assert!(matches!(ledger.take(first), Err(Errno::EINVAL))); + assert_eq!(ledger.take(second).unwrap().ofd_id(), OfdId(102)); + assert!(ledger.is_empty()); + } + + #[test] + fn token_allocator_exhausts_before_signed_wrap_or_reuse() { + let owner = PreparedExecOwner::Process { + pid: 1, + caller_tid: 1, + generation: 0, + }; + let mut ledger = PreparedExecLedger::new(); + ledger.set_next_token_for_test(i32::MAX as u32); + + assert_eq!(ledger.insert(target(owner, 0, 1)).unwrap(), i32::MAX as u32,); + assert_eq!(ledger.insert(target(owner, 1, 2)), Err(Errno::EOVERFLOW),); + } + + #[test] + fn owner_validation_binds_pid_tid_generation_and_spawn_launch() { + let process_owner = PreparedExecOwner::Process { + pid: 7, + caller_tid: 9, + generation: 11, + }; + assert_eq!(process_owner.validate_process(7, 9, 11), Ok(())); + assert_eq!(process_owner.validate_process(8, 9, 11), Err(Errno::ESRCH),); + assert_eq!( + process_owner.validate_process(7, 10, 11), + Err(Errno::EINVAL), + ); + assert_eq!(process_owner.validate_process(7, 9, 12), Err(Errno::EINVAL),); + + let spawn_owner = PreparedExecOwner::Spawn { + parent_pid: 3, + child_pid: 5, + launch: 13, + }; + assert_eq!(spawn_owner.validate_spawn(3, 5, 13), Ok(())); + assert_eq!(spawn_owner.validate_spawn(3, 6, 13), Err(Errno::ESRCH),); + assert_eq!(spawn_owner.validate_spawn(3, 5, 14), Err(Errno::EINVAL),); + } +} diff --git a/crates/kernel/src/fork.rs b/crates/kernel/src/fork.rs index b83592d354..2e733de3c2 100644 --- a/crates/kernel/src/fork.rs +++ b/crates/kernel/src/fork.rs @@ -1623,7 +1623,6 @@ fn deserialize_fork_state_into(buf: &[u8], child: &mut Process) -> Result<(), Er child.fork_exec_path = fork_exec_path; child.fork_exec_argv = fork_exec_argv; child.fork_fd_actions = fork_fd_actions; - child.exec_prepared_tid = None; child.next_ephemeral_port = 49152; child.clear_threads(); // POSIX: child has one task, the process leader. child.epolls.clear(); @@ -2083,7 +2082,6 @@ pub fn deserialize_exec_state(buf: &[u8], pid: u32) -> Result { process.fork_exec_path = None; process.fork_exec_argv = None; process.fork_fd_actions.clear(); - process.exec_prepared_tid = None; process.next_ephemeral_port = 49152; process.clear_threads(); // exec resets to the process leader only. process.epolls.clear(); diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index f893e6e2d6..f245c19bad 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -13,6 +13,7 @@ pub mod credentials; pub(crate) mod descriptor_backing; pub mod devfs; pub mod dri; +pub(crate) mod exec_target; pub mod fd; pub mod fifo; pub mod fork; diff --git a/crates/kernel/src/process.rs b/crates/kernel/src/process.rs index 8e655a7535..ed7ad1e2ca 100644 --- a/crates/kernel/src/process.rs +++ b/crates/kernel/src/process.rs @@ -8,6 +8,7 @@ use wasm_posix_shared::{ }; use crate::credentials::Credentials; +use crate::exec_target::PreparedExecLedger; use crate::fd::FdTable; use crate::memory::MemoryManager; use crate::ofd::{FileType, OfdTable}; @@ -67,6 +68,11 @@ pub trait HostIO { fn host_statfs(&mut self, _path: &[u8]) -> Result { Err(Errno::ENOSYS) } + /// Query filesystem policy through an already-open exact host object. + /// Pathname lookup is not an acceptable fallback for retained authority. + fn host_fstatfs(&mut self, _handle: i64) -> Result { + Err(Errno::ENOSYS) + } fn host_pathconf(&mut self, _path: &[u8], _name: i32) -> Result, Errno> { Err(Errno::ENOSYS) } @@ -104,7 +110,6 @@ pub trait HostIO { fn host_fsync(&mut self, handle: i64) -> Result<(), Errno>; fn host_fchmod(&mut self, handle: i64, mode: u32) -> Result<(), Errno>; fn host_fchown(&mut self, handle: i64, uid: u32, gid: u32) -> Result<(), Errno>; - fn host_exec(&mut self, path: &[u8]) -> Result<(), Errno>; fn host_set_alarm(&mut self, seconds: u32) -> Result<(), Errno>; /// Arm/disarm a POSIX timer on the host. /// `timer_id` is the per-process timer slot index. @@ -734,6 +739,12 @@ pub struct Process { /// Task 9 only preserves this marker across process-state transport. /// Target-aware exec commit is the sole future authority that may set it. pub(crate) secure_exec: bool, + /// Successful image replacements advance this generation exactly once. + /// Prepared exec targets bind to its current value and cannot survive a + /// competing commit for the same persistent PID. + pub(crate) exec_generation: u64, + /// Kernel-owned exact executable-object leases awaiting commit/cancel. + pub(crate) prepared_exec_targets: PreparedExecLedger, pub pgid: u32, pub sid: u32, /// True iff this process is the session leader of its session (i.e. the @@ -801,13 +812,6 @@ pub struct Process { pub fork_exec_argv: Option>>, /// FD actions to apply before exec in fork child. pub fork_fd_actions: Vec, - /// Exact live task that completed the fallible exec-prepare phase. - /// - /// This is an ephemeral host/kernel handoff token. It is deliberately not - /// serialized across fork or legacy exec-state transfer: a replacement - /// image must be committed only by the same kernel-owned task that the - /// host explicitly prepared in the current process. - pub(crate) exec_prepared_tid: Option, /// Next ephemeral port to assign for bind(port=0). pub next_ephemeral_port: u16, /// Epoll instances owned by this process. @@ -1059,6 +1063,8 @@ impl Process { ppid: 0, credentials: Credentials::root(), secure_exec: false, + exec_generation: 0, + prepared_exec_targets: PreparedExecLedger::new(), pgid: pid, sid: 0, is_session_leader: false, @@ -1093,7 +1099,6 @@ impl Process { fork_exec_path: None, fork_exec_argv: None, fork_fd_actions: Vec::new(), - exec_prepared_tid: None, next_ephemeral_port: 49152, epolls: Vec::new(), posix_timers: Vec::new(), @@ -1565,40 +1570,6 @@ impl Process { && (tid == self.pid || self.get_thread(tid).is_some()) } - /// Begin the fallible exec phase for an exact kernel-owned caller. - pub(crate) fn begin_exec_prepare(&mut self, caller_tid: u32) -> Result<(), Errno> { - // A failed or superseded prepare must never authorize a later commit. - self.exec_prepared_tid = None; - if !self.is_live_explicit_tid(caller_tid) { - return Err(Errno::ESRCH); - } - Ok(()) - } - - /// Mark a successful exec prepare after all fallible file actions finish. - pub(crate) fn finish_exec_prepare(&mut self, caller_tid: u32) { - debug_assert!(self.is_live_explicit_tid(caller_tid)); - self.exec_prepared_tid = Some(caller_tid); - } - - /// Consume the one-shot exec authorization for the same exact caller. - pub(crate) fn consume_exec_prepare(&mut self, caller_tid: u32) -> Result<(), Errno> { - // Every setup attempt consumes the token, including an invalid or - // mismatched attempt, so stale authority cannot be retried later. - let prepared_tid = self.exec_prepared_tid.take(); - if !self.is_live_explicit_tid(caller_tid) { - return Err(Errno::ESRCH); - } - if prepared_tid != Some(caller_tid) { - return Err(Errno::EINVAL); - } - Ok(()) - } - - pub(crate) fn clear_exec_prepare(&mut self) { - self.exec_prepared_tid = None; - } - /// Effective blocked mask for the given TID. pub fn blocked_for(&self, tid: u32) -> u64 { if self.is_main_thread(tid) { @@ -2215,9 +2186,6 @@ pub(crate) mod test_host { fn host_fchown(&mut self, _h: i64, _u: u32, _g: u32) -> Result<(), Errno> { Ok(()) } - fn host_exec(&mut self, _p: &[u8]) -> Result<(), Errno> { - Err(Errno::ENOSYS) - } fn host_set_alarm(&mut self, _s: u32) -> Result<(), Errno> { Ok(()) } @@ -2612,32 +2580,6 @@ mod tests { assert_eq!(proc.pick_thread_for_shared_signal(15), None); } - #[test] - fn exec_prepare_authorization_is_exact_and_one_shot() { - let mut proc = Process::new(41); - proc.add_thread(ThreadInfo::new(42, 0, 0, 0)); - - assert_eq!(proc.begin_exec_prepare(0), Err(Errno::ESRCH)); - assert_eq!(proc.consume_exec_prepare(41), Err(Errno::EINVAL)); - proc.begin_exec_prepare(42).unwrap(); - proc.finish_exec_prepare(42); - assert_eq!(proc.consume_exec_prepare(41), Err(Errno::EINVAL)); - assert_eq!(proc.consume_exec_prepare(42), Err(Errno::EINVAL)); - - proc.begin_exec_prepare(42).unwrap(); - proc.finish_exec_prepare(42); - assert_eq!(proc.begin_exec_prepare(9_999), Err(Errno::ESRCH)); - assert_eq!(proc.consume_exec_prepare(42), Err(Errno::EINVAL)); - - proc.begin_exec_prepare(42).unwrap(); - proc.finish_exec_prepare(42); - assert_eq!(proc.consume_exec_prepare(42), Ok(())); - assert_eq!(proc.consume_exec_prepare(42), Err(Errno::EINVAL)); - - let mut synthetic_init = Process::new(1); - assert_eq!(synthetic_init.begin_exec_prepare(1), Err(Errno::ESRCH)); - } - #[test] fn legacy_interval_fire_preserves_host_signal_contract() { let mut proc = Process::new(1); diff --git a/crates/kernel/src/process_table.rs b/crates/kernel/src/process_table.rs index 30da544479..8f914d0fc6 100644 --- a/crates/kernel/src/process_table.rs +++ b/crates/kernel/src/process_table.rs @@ -789,6 +789,7 @@ impl ProcessTable { limbo.ppid = proc.ppid; limbo.install_credentials(proc.credentials().clone()); limbo.secure_exec = proc.secure_exec; + limbo.exec_generation = proc.exec_generation; limbo.pgid = proc.pgid; limbo.sid = proc.sid; limbo.is_session_leader = proc.is_session_leader; @@ -2113,6 +2114,89 @@ pub fn current_pid() -> u32 { mod tests { use super::*; + #[test] + fn exec_target_kernel_table_removal_fallback_closes_each_lease_once() { + use crate::exec_target::{PreparedExecOwner, PreparedExecTarget}; + use crate::fd::OpenFileDescRef; + use crate::lock::FileId; + use wasm_posix_shared::{WasmStat, WasmStatfs}; + use wasm_posix_shared::flags::O_RDONLY; + use wasm_posix_shared::mode::S_IFREG; + use wasm_posix_shared::statfs_flags::ST_NOSUID; + + // Host teardown and forced vfork containment both converge on this + // table-removal fallback after their route-specific host fences. The + // vfork marker must not change exact OFD retirement here. + for (case, vfork_child) in [(0i64, false), (1, true)] { + let handle = 9_452_100 + case; + let mut table = ProcessTable::new(); + let pid = table.create_process().unwrap(); + let proc = table.get_mut(pid).unwrap(); + proc.vfork_child = vfork_child; + let ofd_index = proc.ofd_table.create( + FileType::Regular, + O_RDONLY, + handle, + b"/bin/retained-removal-target".to_vec(), + ); + let ofd_id = proc.ofd_table.get(ofd_index).unwrap().ofd_id; + let target = PreparedExecTarget::new( + PreparedExecOwner::Process { + pid, + caller_tid: pid, + generation: 0, + }, + OpenFileDescRef(ofd_index), + ofd_id, + Some(FileId::Host { dev: 7, ino: 11 }), + WasmStat { + st_dev: 7, + st_ino: 11, + st_mode: S_IFREG | 0o755, + st_nlink: 1, + st_uid: 0, + st_gid: 0, + st_size: 0, + st_atime_sec: 0, + st_atime_nsec: 0, + st_mtime_sec: 0, + st_mtime_nsec: 0, + st_ctime_sec: 0, + st_ctime_nsec: 0, + _pad: 0, + }, + WasmStatfs { + f_type: 1, + f_bsize: 4096, + f_blocks: 1, + f_bfree: 0, + f_bavail: 0, + f_files: 1, + f_ffree: 0, + f_fsid: 19, + f_namelen: 255, + f_frsize: 4096, + f_flags: ST_NOSUID, + _pad: 0, + }, + b"/bin/retained-removal-target".to_vec(), + ) + .unwrap(); + proc.prepared_exec_targets.insert(target).unwrap(); + + let removed = table.remove_process(pid).unwrap(); + assert_eq!( + removed + .host_closes + .iter() + .filter(|&&candidate| candidate == handle) + .count(), + 1, + ); + assert_eq!(crate::ofd::host_handle_ref_count(handle), 0); + } + } + #[test] fn vfork_child_credential_mutation_isolated_from_parent_record() { use wasm_posix_shared::fork_contract::Mode; diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 687e36a705..d04da3aab7 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -849,7 +849,7 @@ pub(crate) fn commit_exec_state( host: &mut dyn HostIO, caller_tid: u32, ) -> Result<(), Errno> { - commit_exec_state_impl(proc, None, host, caller_tid) + commit_exec_state_impl(proc, None, host, Some(caller_tid)) } pub(crate) fn commit_exec_state_with_locks( @@ -858,14 +858,25 @@ pub(crate) fn commit_exec_state_with_locks( host: &mut dyn HostIO, caller_tid: u32, ) -> Result<(), Errno> { - commit_exec_state_impl(proc, Some(locks), host, caller_tid) + commit_exec_state_impl(proc, Some(locks), host, Some(caller_tid)) +} + +/// Commit a spawned image before the child has a host-visible caller thread. +/// The prepared target's parent/child launch tuple is the authority here; a +/// synthetic child TID would weaken that binding. +pub(crate) fn commit_spawn_exec_state_with_locks( + proc: &mut Process, + locks: &mut AdvisoryLockManager, + host: &mut dyn HostIO, +) -> Result<(), Errno> { + commit_exec_state_impl(proc, Some(locks), host, None) } fn commit_exec_state_impl( proc: &mut Process, mut locks: Option<&mut AdvisoryLockManager>, host: &mut dyn HostIO, - caller_tid: u32, + caller_tid: Option, ) -> Result<(), Errno> { if matches!( proc.state, @@ -873,8 +884,10 @@ fn commit_exec_state_impl( ) { return Err(Errno::ESRCH); } - if caller_tid != 0 && caller_tid != proc.pid && proc.get_thread(caller_tid).is_none() { - return Err(Errno::ESRCH); + if let Some(caller_tid) = caller_tid { + if caller_tid != 0 && caller_tid != proc.pid && proc.get_thread(caller_tid).is_none() { + return Err(Errno::ESRCH); + } } // The old image owns every blocked request snapshot. Release its stable // kernel targets before closing CLOEXEC descriptors or discarding sibling @@ -895,10 +908,12 @@ fn commit_exec_state_impl( // can be generated while the host is asynchronously resolving the new // executable, so retain it through the irreversible commit point. let lifecycle_state = proc.state; - if caller_tid != 0 && caller_tid != proc.pid { - let caller = proc.remove_thread(caller_tid).ok_or(Errno::ESRCH)?; - proc.signals.blocked = caller.signals.blocked; - proc.main_thread_signals = caller.signals; + if let Some(caller_tid) = caller_tid { + if caller_tid != 0 && caller_tid != proc.pid { + let caller = proc.remove_thread(caller_tid).ok_or(Errno::ESRCH)?; + proc.signals.blocked = caller.signals.blocked; + proc.main_thread_signals = caller.signals; + } } cancel_fifo_opens_for_process(proc); @@ -958,7 +973,6 @@ fn commit_exec_state_impl( proc.fork_exec_path = None; proc.fork_exec_argv = None; proc.fork_fd_actions.clear(); - proc.clear_exec_prepare(); proc.fork_pipe_replay.clear(); proc.fork_count = 0; proc.has_exec = true; @@ -2525,13 +2539,97 @@ fn check_owner_or_root(proc: &Process, st: &WasmStat) -> Result<(), Errno> { } } -fn check_exec_path(proc: &Process, host: &mut dyn HostIO, path: &[u8]) -> Result<(), Errno> { - check_search_path(proc, host, path)?; - let st = host.host_stat(path)?; - if st.st_mode & S_IFMT != S_IFREG { +pub(crate) fn check_prepared_exec_stat( + proc: &Process, + stat: &WasmStat, +) -> Result<(), Errno> { + if stat.st_mode & S_IFMT != S_IFREG { return Err(Errno::EACCES); } - check_access(proc, &st, X_OK) + check_access(proc, stat, X_OK) +} + +pub(crate) struct PreparedExecOpen { + pub(crate) ofd_ref: OpenFileDescRef, + pub(crate) ofd_id: OfdId, + pub(crate) file_id: Option, + pub(crate) stat: WasmStat, + pub(crate) statfs: WasmStatfs, + pub(crate) diagnostic_path: Vec, +} + +/// Open one pathname target directly into the OFD table without publishing a +/// guest descriptor. The resulting reference is owned by the prepared-target +/// ledger, so RLIMIT_NOFILE and POSIX close-any-fd lock semantics do not enter +/// this internal authority path. +pub(crate) fn open_prepared_exec_target( + proc: &mut Process, + host: &mut dyn HostIO, + dirfd: i32, + path: &[u8], + flags: u32, +) -> Result { + if path.is_empty() { + return Err(Errno::ENOENT); + } + if flags & !(AT_EMPTY_PATH | AT_SYMLINK_NOFOLLOW) != 0 { + return Err(Errno::EINVAL); + } + let options = if flags & AT_SYMLINK_NOFOLLOW != 0 { + PathResolveOptions::NOFOLLOW + } else { + PathResolveOptions::FOLLOW + }; + let resolved = resolve_at_path(proc, host, dirfd, path, options)?.path; + check_search_path(proc, host, &resolved)?; + + let open_flags = O_RDONLY + | if flags & AT_SYMLINK_NOFOLLOW != 0 { + O_NOFOLLOW + } else { + 0 + }; + let host_handle = host.host_open(&resolved, open_flags, 0)?; + let stat = match host.host_fstat(host_handle) { + Ok(stat) => stat, + Err(error) => { + let _ = host.host_close(host_handle); + return Err(error); + } + }; + if stat.st_mode & S_IFMT != S_IFREG { + let _ = host.host_close(host_handle); + return Err(Errno::EACCES); + } + if let Err(error) = check_access(proc, &stat, X_OK) { + let _ = host.host_close(host_handle); + return Err(error); + } + let statfs = match host_fstatfs_or_default(host, host_handle) { + Ok(statfs) => statfs, + Err(error) => { + let _ = host.host_close(host_handle); + return Err(error); + } + }; + let file_id = (stat.st_ino != 0).then_some(FileId::Host { + dev: stat.st_dev, + ino: stat.st_ino, + }); + let ofd_index = proc + .ofd_table + .create(FileType::Regular, O_RDONLY, host_handle, resolved.clone()); + let ofd = proc.ofd_table.get_mut(ofd_index).ok_or(Errno::EIO)?; + ofd.file_id = file_id; + + Ok(PreparedExecOpen { + ofd_ref: OpenFileDescRef(ofd_index), + ofd_id: ofd.ofd_id, + file_id, + stat, + statfs, + diagnostic_path: resolved, + }) } fn fifo_open_owner(proc: &Process) -> u64 { @@ -3741,6 +3839,18 @@ fn release_ofd_reference_impl( Ok(()) } +/// Release one kernel-owned prepared-target reference without pretending a +/// guest descriptor was closed. The exact OFD cleanup path still owns final +/// host-handle, device, and advisory-lock retirement. +pub(crate) fn release_prepared_exec_ofd_with_locks( + proc: &mut Process, + locks: &mut AdvisoryLockManager, + host: &mut dyn HostIO, + ofd_ref: OpenFileDescRef, +) -> Result<(), Errno> { + release_ofd_reference_impl(proc, Some(locks), host, ofd_ref.0) +} + fn stable_ofd_target(proc: &Process, fd: i32) -> Result { let ofd_idx = proc.fd_table.get(fd)?.ofd_ref.0; let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; @@ -8265,24 +8375,18 @@ pub fn sys_raise(proc: &mut Process, sig: u32) -> Result<(), Errno> { sys_kill(proc, proc.pid as i32, sig) } -/// Execute a new program. Delegates to host for binary loading. -/// On success, the kernel process will be replaced (POSIX: exec doesn't return on success). -/// On failure, returns the error. +/// Reject the legacy path-only exec dispatch. +/// +/// Executable selection and image commit require a prepared target token; +/// callers must use the target-aware Wasm interface instead. pub fn sys_execve(proc: &mut Process, host: &mut dyn HostIO, path: &[u8]) -> Result<(), Errno> { - if path.is_empty() { - return Err(Errno::ENOENT); - } - // Resolve and validate before tearing down mappings. POSIX exec - // failure must leave the current image intact. - let resolved = resolve_namespace_path(proc, host, path, PathResolveOptions::FOLLOW)?.path; - check_exec_path(proc, host, &resolved)?; - release_exec_image_state(proc, host); - host.host_exec(&resolved) + let _ = (proc, host, path); + Err(Errno::ENOSYS) } -/// Execute a new program using a file descriptor (fexecve / execveat). -/// When AT_EMPTY_PATH is set and path is empty, resolves the fd's file path. -/// Otherwise resolves path relative to the directory fd. +/// Reject the legacy path/fd-only exec dispatch. +/// +/// `execveat` target selection is authorized only by a prepared target token. pub fn sys_execveat( proc: &mut Process, host: &mut dyn HostIO, @@ -8290,26 +8394,8 @@ pub fn sys_execveat( path: &[u8], flags: u32, ) -> Result<(), Errno> { - if flags & AT_EMPTY_PATH != 0 && path.is_empty() { - // fexecve path: exec the file referenced by dirfd - let entry = proc.fd_table.get(dirfd)?; - let ofd_idx = entry.ofd_ref.0; - let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; - if ofd.path.is_empty() { - return Err(Errno::ENOENT); - } - let exec_path = ofd.path.clone(); - check_exec_path(proc, host, &exec_path)?; - release_exec_image_state(proc, host); - host.host_exec(&exec_path) - } else if path.is_empty() { - Err(Errno::ENOENT) - } else { - let resolved = resolve_at_path(proc, host, dirfd, path, PathResolveOptions::FOLLOW)?.path; - check_exec_path(proc, host, &resolved)?; - release_exec_image_state(proc, host); - host.host_exec(&resolved) - } + let _ = (proc, host, dirfd, path, flags); + Err(Errno::ENOSYS) } /// Schedule a SIGALRM signal after `seconds` seconds. @@ -8633,6 +8719,16 @@ fn cleanup_process_for_exit( mut locks: Option<&mut AdvisoryLockManager>, host: &mut dyn HostIO, ) { + // Prepared executable tokens are exact OFD leases owned by this image, + // not guest descriptors. Retire them before the ordinary fd walk so exit, + // signal death, and trap cleanup cannot leave host-visible authority alive. + if let Some(machine_locks) = locks.as_deref_mut() { + crate::exec_target::drain_prepared_exec_targets(proc, machine_locks, host); + } else { + let mut isolated_locks = AdvisoryLockManager::new(); + crate::exec_target::drain_prepared_exec_targets(proc, &mut isolated_locks, host); + } + // The exiting image owns every blocked request. Consume its stable // targets before the ordinary fd walk so a guest-closed descriptor, MQ // pin, SysV pin, or SCM_RIGHTS template cannot remain reachable only from @@ -16545,6 +16641,17 @@ fn host_statfs_or_default(host: &mut dyn HostIO, path: &[u8]) -> Result Result { + match host.host_fstatfs(handle) { + Ok(statfs) => Ok(statfs), + Err(Errno::ENOSYS) => Ok(default_statfs()), + Err(error) => Err(error), + } +} + /// statfs — get filesystem statistics for an existing path. pub fn sys_statfs( proc: &mut Process, @@ -17364,12 +17471,16 @@ mod tests { /// returns the same owners host_stat would for the path. handle_owners: std::collections::HashMap, handle_paths: std::collections::HashMap>, + freeze_exec_handles: bool, + frozen_handle_stats: std::collections::HashMap, + frozen_handle_bytes: std::collections::HashMap>, path_inodes: std::collections::HashMap, u64>, next_inode: u64, missing_paths: std::collections::HashSet>, symlink_targets: std::collections::HashMap, Vec>, lstat_paths: Vec>, statfs_by_path: std::collections::HashMap, WasmStatfs>, + handle_statfs: std::collections::HashMap, fsync_calls: Vec, /// Recorded `(pid, bo_id, addr, len)` for every `gbm_bo_bind` call so /// the DRI mmap path can be asserted against. @@ -17423,6 +17534,7 @@ mod tests { pwrite_error: Option, pread_reported: Option, pwrite_reported: Option, + prepared_exec_bytes: Option>, } impl MockHostIO { @@ -17449,12 +17561,16 @@ mod tests { file_times: std::collections::HashMap::new(), handle_owners: std::collections::HashMap::new(), handle_paths: std::collections::HashMap::new(), + freeze_exec_handles: false, + frozen_handle_stats: std::collections::HashMap::new(), + frozen_handle_bytes: std::collections::HashMap::new(), path_inodes: std::collections::HashMap::new(), next_inode: 1, missing_paths: std::collections::HashSet::new(), symlink_targets: std::collections::HashMap::new(), lstat_paths: Vec::new(), statfs_by_path: std::collections::HashMap::new(), + handle_statfs: std::collections::HashMap::new(), fsync_calls: Vec::new(), gbm_bo_bind_calls: Vec::new(), gbm_bo_unbind_calls: Vec::new(), @@ -17500,6 +17616,7 @@ mod tests { pwrite_error: None, pread_reported: None, pwrite_reported: None, + prepared_exec_bytes: None, } } @@ -17566,6 +17683,20 @@ mod tests { self.path_inodes.insert(path.to_vec(), inode); } self.handle_paths.insert(handle, path.to_vec()); + self.handle_statfs.insert( + handle, + self.statfs_by_path + .get(path) + .copied() + .unwrap_or_else(default_statfs), + ); + if self.freeze_exec_handles { + let snapshot = self.host_fstat(handle)?; + self.frozen_handle_stats.insert(handle, snapshot); + if let Some(bytes) = self.prepared_exec_bytes.clone() { + self.frozen_handle_bytes.insert(handle, bytes); + } + } Ok(handle) } @@ -17630,6 +17761,17 @@ mod tests { if let Some(error) = self.pread_error { return Err(error); } + if let Some(data) = self + .frozen_handle_bytes + .get(&handle) + .or(self.prepared_exec_bytes.as_ref()) + { + let offset = usize::try_from(offset).map_err(|_| Errno::EINVAL)?; + let available = data.get(offset..).unwrap_or(&[]); + let copied = buf.len().min(available.len()); + buf[..copied].copy_from_slice(&available[..copied]); + return Ok(self.pread_reported.unwrap_or(copied)); + } let data = b"hello"; let copied = buf.len().min(data.len()); buf[..copied].copy_from_slice(&data[..copied]); @@ -17657,6 +17799,9 @@ mod tests { if let Some(err) = self.fstat_error { return Err(err); } + if let Some(stat) = self.frozen_handle_stats.get(&handle) { + return Ok(*stat); + } let (uid, gid) = self.handle_owners.get(&handle).copied().unwrap_or((0, 0)); let mode = self .handle_paths @@ -17772,6 +17917,13 @@ mod tests { .unwrap_or_else(default_statfs)) } + fn host_fstatfs(&mut self, handle: i64) -> Result { + self.handle_statfs + .get(&handle) + .copied() + .ok_or(Errno::EBADF) + } + fn host_pathconf(&mut self, path: &[u8], name: i32) -> Result, Errno> { self.pathconf_calls.push((path.to_vec(), name)); self.pathconf_result @@ -18030,10 +18182,6 @@ mod tests { Ok(()) } - fn host_exec(&mut self, _path: &[u8]) -> Result<(), Errno> { - Ok(()) - } - fn host_set_alarm(&mut self, _seconds: u32) -> Result<(), Errno> { Ok(()) } @@ -18298,10 +18446,25 @@ mod tests { fn test_non_root_exec_without_execute_denied() { let mut proc = user_process(14); let mut host = MockHostIO::new(); + let mut locks = AdvisoryLockManager::new(); host.set_dir_with_owner(b"/home/user", 1000, 1000, 0o755); host.set_file_with_owner(b"/home/user/script", 1000, 1000, 0o644, b""); - let err = sys_execve(&mut proc, &mut host, b"/home/user/script").unwrap_err(); + let owner = crate::exec_target::PreparedExecOwner::Process { + pid: proc.pid, + caller_tid: proc.pid, + generation: proc.exec_generation, + }; + let err = crate::exec_target::prepare( + &mut proc, + &mut locks, + &mut host, + owner, + AT_FDCWD, + b"/home/user/script", + 0, + ) + .unwrap_err(); assert_eq!(err, Errno::EACCES); } @@ -31693,19 +31856,15 @@ mod tests { } #[test] - fn test_execve_empty_path_returns_enoent() { + fn test_direct_exec_dispatch_has_no_path_authority() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let result = sys_execve(&mut proc, &mut host, b""); - assert_eq!(result, Err(Errno::ENOENT)); - } - #[test] - fn test_execve_delegates_to_host() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let result = sys_execve(&mut proc, &mut host, b"/bin/ls"); - assert!(result.is_ok()); + assert_eq!(sys_execve(&mut proc, &mut host, b""), Err(Errno::ENOSYS)); + assert_eq!( + sys_execve(&mut proc, &mut host, b"/bin/ls"), + Err(Errno::ENOSYS), + ); } #[test] @@ -32030,9 +32189,6 @@ mod tests { fn host_fchown(&mut self, _handle: i64, _uid: u32, _gid: u32) -> Result<(), Errno> { Ok(()) } - fn host_exec(&mut self, _path: &[u8]) -> Result<(), Errno> { - Ok(()) - } fn host_set_alarm(&mut self, _seconds: u32) -> Result<(), Errno> { Ok(()) } @@ -33187,9 +33343,6 @@ mod tests { fn host_fchown(&mut self, _h: i64, _u: u32, _g: u32) -> Result<(), Errno> { Ok(()) } - fn host_exec(&mut self, _p: &[u8]) -> Result<(), Errno> { - Ok(()) - } fn host_set_alarm(&mut self, _s: u32) -> Result<(), Errno> { Ok(()) } @@ -37399,9 +37552,6 @@ mod tests { fn host_fchown(&mut self, _h: i64, _u: u32, _g: u32) -> Result<(), Errno> { Ok(()) } - fn host_exec(&mut self, _p: &[u8]) -> Result<(), Errno> { - Ok(()) - } fn host_set_alarm(&mut self, _s: u32) -> Result<(), Errno> { Ok(()) } @@ -37616,9 +37766,6 @@ mod tests { fn host_fchown(&mut self, _h: i64, _u: u32, _g: u32) -> Result<(), Errno> { Ok(()) } - fn host_exec(&mut self, _p: &[u8]) -> Result<(), Errno> { - Ok(()) - } fn host_set_alarm(&mut self, _s: u32) -> Result<(), Errno> { Ok(()) } @@ -37830,9 +37977,6 @@ mod tests { fn host_fchown(&mut self, _h: i64, _u: u32, _g: u32) -> Result<(), Errno> { Ok(()) } - fn host_exec(&mut self, _p: &[u8]) -> Result<(), Errno> { - Ok(()) - } fn host_set_alarm(&mut self, _s: u32) -> Result<(), Errno> { Ok(()) } @@ -39178,7 +39322,7 @@ mod tests { } #[test] - fn execve_unbinds_fb_mapping_but_keeps_open_fd_owner() { + fn exec_commit_unbinds_fb_mapping_but_keeps_open_fd_owner() { use core::sync::atomic::Ordering; use wasm_posix_shared::flags::O_RDWR; use wasm_posix_shared::mmap::{MAP_SHARED, PROT_READ, PROT_WRITE}; @@ -39199,8 +39343,7 @@ mod tests { 0, ) .unwrap(); - // execve invokes host_exec which the tracking host returns Ok(()) for. - sys_execve(&mut proc, &mut host, b"/bin/sh").unwrap(); + commit_exec_state(&mut proc, &mut host, 0).unwrap(); assert!(proc.fb_binding.is_none()); assert_eq!(host.unbind_framebuffer_calls, alloc::vec![proc.pid as i32]); assert_eq!( @@ -39384,7 +39527,7 @@ mod tests { ); crate::mouse::inject_event(2, 2, 0); - sys_execve(&mut proc, &mut host, b"/bin/sh").unwrap(); + commit_exec_state(&mut proc, &mut host, 0).unwrap(); assert_eq!( crate::mouse::MICE_OWNER.load(Ordering::SeqCst), proc.pid as i32 @@ -42042,7 +42185,7 @@ mod tests { } #[test] - fn execve_releases_dri_bo_and_gl_mappings() { + fn exec_commit_releases_dri_bo_and_gl_mappings() { use wasm_posix_shared::gl; use wasm_posix_shared::mmap::{MAP_SHARED, PROT_READ, PROT_WRITE}; let _g = crate::dri::bo::TEST_REGISTRY_LOCK @@ -42082,7 +42225,7 @@ mod tests { ) .unwrap(); - sys_execve(&mut proc, &mut host, b"/bin/sh").unwrap(); + commit_exec_state(&mut proc, &mut host, 0).unwrap(); let bo_id = (offset >> 12) as u32; assert!(proc.dri_bindings.is_empty()); assert_eq!( @@ -42908,4 +43051,1080 @@ mod tests { Err(Errno::ESRCH) )); } + + fn prepare_test_exec( + proc: &mut Process, + locks: &mut AdvisoryLockManager, + host: &mut MockHostIO, + path: &[u8], + ) -> u32 { + host.stat_size = 5; + host.prepared_exec_bytes = Some(b"hello".to_vec()); + host.set_file_with_owner(path, 0, 0, S_IFREG | 0o755, b"hello"); + crate::exec_target::prepare( + proc, + locks, + host, + crate::exec_target::PreparedExecOwner::Process { + pid: proc.pid, + caller_tid: proc.pid, + generation: proc.exec_generation, + }, + AT_FDCWD, + path, + 0, + ) + .unwrap() + } + + #[test] + fn exec_target_reads_are_positioned_and_cancel_is_exactly_once() { + let mut proc = Process::new(81); + let pid = proc.pid; + let mut locks = AdvisoryLockManager::new(); + let mut host = MockHostIO::new(); + let token = prepare_test_exec(&mut proc, &mut locks, &mut host, b"/bin/exact"); + let ofd_ref = proc + .prepared_exec_targets + .get(token) + .unwrap() + .ofd_ref(); + proc.ofd_table.get(ofd_ref.0).unwrap().set_offset(73); + + let mut bytes = [0u8; 5]; + assert_eq!( + crate::exec_target::read(&mut proc, &mut host, pid, token, 0, &mut bytes), + Ok(5), + ); + assert_eq!(&bytes, b"hello"); + assert_eq!(proc.ofd_table.get(ofd_ref.0).unwrap().offset(), 73); + assert_eq!(host.pread_calls, vec![(100, 0, 5)]); + + crate::exec_target::cancel(&mut proc, &mut locks, &mut host, pid, token).unwrap(); + assert_eq!(host.closed_handles, vec![100]); + assert_eq!( + crate::exec_target::cancel(&mut proc, &mut locks, &mut host, pid, token), + Err(Errno::EINVAL), + ); + assert_eq!(host.closed_handles, vec![100]); + } + + #[test] + fn exec_target_empty_path_outlives_guest_fd_close() { + let mut proc = Process::new(82); + let pid = proc.pid; + let mut locks = AdvisoryLockManager::new(); + let mut host = MockHostIO::new(); + host.stat_size = 5; + host.prepared_exec_bytes = Some(b"hello".to_vec()); + host.set_file_with_owner(b"/bin/fd-exec", 0, 0, S_IFREG | 0o755, b"hello"); + let fd = sys_open(&mut proc, &mut host, b"/bin/fd-exec", O_RDONLY, 0).unwrap(); + let owner = crate::exec_target::PreparedExecOwner::Process { + pid: proc.pid, + caller_tid: proc.pid, + generation: 0, + }; + let token = crate::exec_target::prepare( + &mut proc, + &mut locks, + &mut host, + owner, + fd, + b"", + AT_EMPTY_PATH, + ) + .unwrap(); + sys_close_with_locks(&mut proc, &mut locks, &mut host, fd).unwrap(); + assert!(host.closed_handles.is_empty()); + + let mut bytes = [0u8; 5]; + assert_eq!( + crate::exec_target::read(&mut proc, &mut host, pid, token, 0, &mut bytes), + Ok(5), + ); + assert_eq!(&bytes, b"hello"); + crate::exec_target::cancel(&mut proc, &mut locks, &mut host, pid, token).unwrap(); + assert_eq!(host.closed_handles, vec![100]); + } + + #[test] + fn exec_target_pathname_execveat_resolves_relative_to_live_dirfd() { + let mut proc = Process::new(101); + let pid = proc.pid; + let mut locks = AdvisoryLockManager::new(); + let mut host = MockHostIO::new(); + host.set_dir_with_owner(b"/opt/apps", 0, 0, 0o755); + host.set_dir_with_owner(b"/opt/apps/bin", 0, 0, 0o755); + host.set_file_with_owner( + b"/opt/apps/bin/program", + 0, + 0, + S_IFREG | 0o755, + b"hello", + ); + host.stat_size = 5; + host.prepared_exec_bytes = Some(b"hello".to_vec()); + let dirfd = sys_open(&mut proc, &mut host, b"/opt/apps", O_RDONLY, 0).unwrap(); + let token = crate::exec_target::prepare( + &mut proc, + &mut locks, + &mut host, + crate::exec_target::PreparedExecOwner::Process { + pid, + caller_tid: pid, + generation: 0, + }, + dirfd, + b"bin/program", + 0, + ) + .unwrap(); + let target_ofd = proc + .prepared_exec_targets + .get(token) + .unwrap() + .ofd_ref(); + assert_eq!( + proc.ofd_table.get(target_ofd.0).unwrap().path, + b"/opt/apps/bin/program", + ); + let mut image = [0u8; 5]; + crate::exec_target::read(&mut proc, &mut host, pid, token, 0, &mut image) + .unwrap(); + assert_eq!(&image, b"hello"); + + crate::exec_target::commit_process( + &mut proc, + &mut locks, + &mut host, + pid, + pid, + token, + ) + .unwrap(); + assert_eq!(proc.exec_generation, 1); + assert!(proc.has_exec); + } + + #[test] + fn exec_target_rename_unlink_and_replacement_keep_the_retained_object() { + let mut proc = Process::new(86); + let pid = proc.pid; + let mut locks = AdvisoryLockManager::new(); + let mut host = MockHostIO::new(); + host.freeze_exec_handles = true; + let token = prepare_test_exec(&mut proc, &mut locks, &mut host, b"/bin/original"); + + host.host_rename(b"/bin/original", b"/bin/moved").unwrap(); + host.host_unlink(b"/bin/moved").unwrap(); + host.set_file_with_owner(b"/bin/original", 0, 0, S_IFREG | 0o755, b"world"); + host.prepared_exec_bytes = Some(b"world".to_vec()); + + let mut bytes = [0u8; 5]; + assert_eq!( + crate::exec_target::read(&mut proc, &mut host, pid, token, 0, &mut bytes), + Ok(5), + ); + assert_eq!(&bytes, b"hello"); + crate::exec_target::commit_process( + &mut proc, + &mut locks, + &mut host, + pid, + pid, + token, + ) + .unwrap(); + assert_eq!(proc.exec_generation, 1); + assert_eq!(host.closed_handles, vec![100]); + } + + #[test] + fn exec_target_wrong_caller_generation_and_process_consume_exactly_once() { + let mut proc = Process::new(87); + let pid = proc.pid; + let mut locks = AdvisoryLockManager::new(); + let mut host = MockHostIO::new(); + + let wrong_caller = prepare_test_exec(&mut proc, &mut locks, &mut host, b"/bin/tid"); + let mut bytes = [0u8; 5]; + crate::exec_target::read( + &mut proc, + &mut host, + pid, + wrong_caller, + 0, + &mut bytes, + ) + .unwrap(); + assert_eq!( + crate::exec_target::commit_process( + &mut proc, + &mut locks, + &mut host, + pid, + pid + 1, + wrong_caller, + ), + Err(Errno::EINVAL), + ); + assert_eq!(host.closed_handles, vec![100]); + + let stale_generation = + prepare_test_exec(&mut proc, &mut locks, &mut host, b"/bin/generation"); + crate::exec_target::read( + &mut proc, + &mut host, + pid, + stale_generation, + 0, + &mut bytes, + ) + .unwrap(); + proc.exec_generation = 1; + assert_eq!( + crate::exec_target::commit_process( + &mut proc, + &mut locks, + &mut host, + pid, + pid, + stale_generation, + ), + Err(Errno::EINVAL), + ); + assert_eq!(host.closed_handles, vec![100, 101]); + + proc.exec_generation = 0; + let wrong_process = + prepare_test_exec(&mut proc, &mut locks, &mut host, b"/bin/process"); + assert_eq!( + crate::exec_target::commit_process( + &mut proc, + &mut locks, + &mut host, + pid + 1, + pid, + wrong_process, + ), + Err(Errno::ESRCH), + ); + assert_eq!(host.closed_handles, vec![100, 101, 102]); + assert_eq!(proc.exec_generation, 0); + } + + #[test] + fn exec_target_spawn_owner_commits_without_a_fake_tid_and_rolls_back_stale_launch() { + let parent_pid = 73; + for (child_pid, make_stale) in [(74, false), (75, true)] { + let mut proc = Process::new(child_pid); + let original_credentials = proc.credentials().clone(); + let mut locks = AdvisoryLockManager::new(); + let mut host = MockHostIO::new(); + host.stat_size = 5; + host.prepared_exec_bytes = Some(b"hello".to_vec()); + host.set_file_with_owner( + b"/bin/spawn-target", + 0, + 0, + S_IFREG | 0o755, + b"hello", + ); + let launch = proc.exec_generation; + let token = crate::exec_target::prepare( + &mut proc, + &mut locks, + &mut host, + crate::exec_target::PreparedExecOwner::Spawn { + parent_pid, + child_pid, + launch, + }, + AT_FDCWD, + b"/bin/spawn-target", + 0, + ) + .unwrap(); + let mut bytes = [0u8; 5]; + crate::exec_target::read( + &mut proc, + &mut host, + child_pid, + token, + 0, + &mut bytes, + ) + .unwrap(); + + if make_stale { + proc.exec_generation += 1; + assert_eq!( + crate::exec_target::commit_spawn( + &mut proc, + &mut locks, + &mut host, + parent_pid, + child_pid, + token, + ), + Err(Errno::EINVAL), + ); + assert!(!proc.has_exec); + assert_eq!(proc.credentials(), &original_credentials); + assert_eq!(proc.exec_generation, 1); + } else { + crate::exec_target::commit_spawn( + &mut proc, + &mut locks, + &mut host, + parent_pid, + child_pid, + token, + ) + .unwrap(); + assert!(proc.has_exec); + assert_eq!(proc.exec_generation, 1); + } + assert_eq!(host.closed_handles, vec![100]); + assert_eq!( + crate::exec_target::commit_spawn( + &mut proc, + &mut locks, + &mut host, + parent_pid, + child_pid, + token, + ), + Err(Errno::EINVAL), + ); + assert_eq!(host.closed_handles, vec![100]); + } + } + + #[test] + fn exec_target_normal_and_signal_exit_drain_all_leases() { + // Failed-vfork children converge on the normal exit half; worker traps + // and containment call kernel_mark_process_signaled, which converges + // on the signal half. Host-route tests assert those entry paths. + for signal in [None, Some(wasm_posix_shared::signal::SIGTERM)] { + let mut proc = Process::new(88 + signal.is_some() as u32); + let pid = proc.pid; + let mut locks = AdvisoryLockManager::new(); + let mut host = MockHostIO::new(); + for path in [b"/bin/first".as_slice(), b"/bin/second".as_slice()] { + prepare_test_exec(&mut proc, &mut locks, &mut host, path); + } + + if let Some(signum) = signal { + sys_exit_by_signal_with_locks(&mut proc, &mut locks, &mut host, signum); + assert_eq!(proc.exit_signal, signum); + } else { + sys_exit_with_locks(&mut proc, &mut locks, &mut host, 7); + assert_eq!(proc.exit_status, 7); + } + assert_eq!(proc.pid, pid); + assert_eq!(proc.state, ProcessState::Exited); + assert!(proc.prepared_exec_targets.is_empty()); + assert_eq!(&host.closed_handles[..2], &[100, 101]); + assert_eq!( + host.closed_handles.iter().filter(|&&handle| handle == 100).count(), + 1, + ); + assert_eq!( + host.closed_handles.iter().filter(|&&handle| handle == 101).count(), + 1, + ); + } + } + + #[test] + fn exec_target_incomplete_image_fails_without_committing_state() { + let mut proc = Process::new(90); + let pid = proc.pid; + let mut locks = AdvisoryLockManager::new(); + let mut host = MockHostIO::new(); + let token = prepare_test_exec(&mut proc, &mut locks, &mut host, b"/bin/incomplete"); + let mut prefix = [0u8; 2]; + crate::exec_target::read(&mut proc, &mut host, pid, token, 0, &mut prefix).unwrap(); + + assert_eq!( + crate::exec_target::commit_process( + &mut proc, + &mut locks, + &mut host, + pid, + pid, + token, + ), + Err(Errno::EINVAL), + ); + assert_eq!(proc.exec_generation, 0); + assert!(!proc.has_exec); + assert_eq!(host.closed_handles, vec![100]); + } + + #[test] + fn exec_target_prepare_rejects_missing_directory_and_non_executable_files() { + let mut proc = Process::new(93); + let mut locks = AdvisoryLockManager::new(); + let mut host = MockHostIO::new(); + let owner = crate::exec_target::PreparedExecOwner::Process { + pid: proc.pid, + caller_tid: proc.pid, + generation: 0, + }; + host.set_missing_path(b"/bin/missing"); + assert_eq!( + crate::exec_target::prepare( + &mut proc, + &mut locks, + &mut host, + owner, + AT_FDCWD, + b"/bin/missing", + 0, + ), + Err(Errno::ENOENT), + ); + + host.set_dir_with_owner(b"/bin/directory", 0, 0, 0o755); + assert_eq!( + crate::exec_target::prepare( + &mut proc, + &mut locks, + &mut host, + owner, + AT_FDCWD, + b"/bin/directory", + 0, + ), + Err(Errno::EACCES), + ); + + host.set_file_with_owner(b"/bin/noexec", 0, 0, S_IFREG | 0o644, b"hello"); + assert_eq!( + crate::exec_target::prepare( + &mut proc, + &mut locks, + &mut host, + owner, + AT_FDCWD, + b"/bin/noexec", + 0, + ), + Err(Errno::EACCES), + ); + assert!(proc.prepared_exec_targets.is_empty()); + assert_eq!(host.closed_handles, vec![100, 101]); + } + + #[test] + fn exec_target_scripts_and_nosuid_mounts_ignore_set_id_bits() { + for (pid, path, bytes, statfs) in [ + ( + 91, + b"/bin/script".as_slice(), + b"#!ok\n".as_slice(), + WasmStatfs { + f_flags: 0, + f_fsid: 91, + ..default_statfs() + }, + ), + ( + 92, + b"/bin/nosuid".as_slice(), + b"hello".as_slice(), + default_statfs(), + ), + ] { + let mut proc = Process::new(pid); + set_test_credentials(&mut proc, 1000, 1000, 1000, 1000, &[]); + let mut locks = AdvisoryLockManager::new(); + let mut host = MockHostIO::new(); + host.set_statfs(path, statfs); + host.set_file_with_owner(path, 42, 43, S_IFREG | S_ISUID | S_ISGID | 0o755, bytes); + host.stat_size = bytes.len() as u64; + host.prepared_exec_bytes = Some(bytes.to_vec()); + let token = crate::exec_target::prepare( + &mut proc, + &mut locks, + &mut host, + crate::exec_target::PreparedExecOwner::Process { + pid, + caller_tid: pid, + generation: 0, + }, + AT_FDCWD, + path, + 0, + ) + .unwrap(); + let mut image = vec![0; bytes.len()]; + crate::exec_target::read(&mut proc, &mut host, pid, token, 0, &mut image).unwrap(); + crate::exec_target::commit_process( + &mut proc, + &mut locks, + &mut host, + pid, + pid, + token, + ) + .unwrap(); + + assert_eq!(proc.effective_uid(), 1000); + assert_eq!(proc.effective_gid(), 1000); + assert!(!proc.secure_exec); + assert_eq!(proc.exec_generation, 1); + } + } + + #[test] + fn exec_target_mutation_fails_closed_without_partial_commit() { + let mut proc = Process::new(83); + let pid = proc.pid; + set_test_credentials(&mut proc, 1000, 1000, 1000, 1000, &[20, 30]); + let metadata = proc.begin_metadata_replacement().unwrap(); + proc.stage_metadata_entry( + metadata, + wasm_posix_shared::process_metadata_contract::KIND_ARGV, + b"old-program", + ) + .unwrap(); + proc.stage_metadata_entry( + metadata, + wasm_posix_shared::process_metadata_contract::KIND_ARGV, + b"old-argument", + ) + .unwrap(); + proc.stage_metadata_entry( + metadata, + wasm_posix_shared::process_metadata_contract::KIND_ENVIRONMENT, + b"OLD=value", + ) + .unwrap(); + proc.commit_metadata_replacement(metadata).unwrap(); + sys_sigaction( + &mut proc, + wasm_posix_shared::signal::SIGUSR1, + 0x1234, + wasm_posix_shared::signal::SA_RESTART, + crate::signal::sig_bit(wasm_posix_shared::signal::SIGTERM), + ) + .unwrap(); + proc.signals.raise(wasm_posix_shared::signal::SIGUSR1); + let original_credentials = proc.credentials().clone(); + let original_argv = proc.argv.clone(); + let original_environment = proc.environ.clone(); + let original_action = proc + .signals + .get_action(wasm_posix_shared::signal::SIGUSR1); + let original_pending = proc.signals.pending; + let mut locks = AdvisoryLockManager::new(); + let mut host = MockHostIO::new(); + let token = prepare_test_exec(&mut proc, &mut locks, &mut host, b"/bin/drift"); + let cloexec = sys_open(&mut proc, &mut host, b"/tmp/keep-before-commit", O_RDONLY, 0) + .unwrap(); + proc.fd_table.get_mut(cloexec).unwrap().fd_flags = FD_CLOEXEC; + let mut bytes = [0u8; 5]; + crate::exec_target::read(&mut proc, &mut host, pid, token, 0, &mut bytes).unwrap(); + host.prepared_exec_bytes = Some(b"world".to_vec()); + + assert_eq!( + crate::exec_target::commit_process( + &mut proc, + &mut locks, + &mut host, + pid, + pid, + token, + ), + Err(Errno::ETXTBSY), + ); + assert_eq!(proc.exec_generation, 0); + assert_eq!(proc.credentials(), &original_credentials); + assert_eq!(proc.argv, original_argv); + assert_eq!(proc.environ, original_environment); + let action = proc + .signals + .get_action(wasm_posix_shared::signal::SIGUSR1); + assert_eq!(action.handler, original_action.handler); + assert_eq!(action.flags, original_action.flags); + assert_eq!(action.mask, original_action.mask); + assert_eq!(proc.signals.pending, original_pending); + assert!(proc.fd_table.get(cloexec).is_ok()); + assert_eq!( + crate::exec_target::commit_process( + &mut proc, + &mut locks, + &mut host, + pid, + pid, + token, + ), + Err(Errno::EINVAL), + ); + assert_eq!(host.closed_handles, vec![100]); + } + + #[test] + fn exec_target_two_live_callers_compete_with_exactly_one_commit() { + use crate::process::ThreadInfo; + + for winner_index in 0..2 { + let mut proc = Process::new(102 + winner_index as u32); + let pid = proc.pid; + let tids = [201, 202]; + proc.add_thread(ThreadInfo::new(tids[0], 0, 0, 0)); + proc.add_thread(ThreadInfo::new(tids[1], 0, 0, 0)); + let mut locks = AdvisoryLockManager::new(); + let mut host = MockHostIO::new(); + host.stat_size = 5; + host.prepared_exec_bytes = Some(b"hello".to_vec()); + let mut tokens = [0u32; 2]; + for (index, tid) in tids.into_iter().enumerate() { + let path = if index == 0 { + b"/bin/thread-one".as_slice() + } else { + b"/bin/thread-two".as_slice() + }; + host.set_file_with_owner(path, 0, 0, S_IFREG | 0o755, b"hello"); + tokens[index] = crate::exec_target::prepare( + &mut proc, + &mut locks, + &mut host, + crate::exec_target::PreparedExecOwner::Process { + pid, + caller_tid: tid, + generation: 0, + }, + AT_FDCWD, + path, + 0, + ) + .unwrap(); + let mut image = [0u8; 5]; + crate::exec_target::read( + &mut proc, + &mut host, + pid, + tokens[index], + 0, + &mut image, + ) + .unwrap(); + } + + let loser_index = 1 - winner_index; + crate::exec_target::commit_process( + &mut proc, + &mut locks, + &mut host, + pid, + tids[winner_index], + tokens[winner_index], + ) + .unwrap(); + assert_eq!(proc.exec_generation, 1); + assert!(proc.has_exec); + assert!(proc.threads.is_empty()); + assert!(proc.prepared_exec_targets.is_empty()); + assert_eq!(host.closed_handles.len(), 2); + + assert_eq!( + crate::exec_target::commit_process( + &mut proc, + &mut locks, + &mut host, + pid, + tids[loser_index], + tokens[loser_index], + ), + Err(Errno::EINVAL), + ); + assert_eq!(proc.exec_generation, 1); + assert_eq!(host.closed_handles.len(), 2); + } + } + + #[test] + fn exec_target_metadata_and_mount_drift_fail_before_image_commit() { + for drift in ["mode", "owner", "mount"] { + let mut proc = Process::new(96); + let pid = proc.pid; + set_test_credentials(&mut proc, 1000, 1000, 1000, 1000, &[20, 30]); + let original_credentials = proc.credentials().clone(); + let mut locks = AdvisoryLockManager::new(); + let mut host = MockHostIO::new(); + let path = b"/bin/metadata-drift"; + let token = if drift == "mount" { + host.set_statfs( + path, + WasmStatfs { + f_flags: 0, + f_fsid: 77, + ..default_statfs() + }, + ); + host.set_file_with_owner( + path, + 42, + 43, + S_IFREG | S_ISUID | S_ISGID | 0o755, + b"hello", + ); + host.stat_size = 5; + host.prepared_exec_bytes = Some(b"hello".to_vec()); + crate::exec_target::prepare( + &mut proc, + &mut locks, + &mut host, + crate::exec_target::PreparedExecOwner::Process { + pid, + caller_tid: pid, + generation: 0, + }, + AT_FDCWD, + path, + 0, + ) + .unwrap() + } else { + prepare_test_exec(&mut proc, &mut locks, &mut host, path) + }; + let handle = proc + .prepared_exec_targets + .get(token) + .and_then(|target| { + proc.ofd_table + .get(target.ofd_ref().0) + .map(|ofd| ofd.host_handle) + .ok_or(Errno::EBADF) + }) + .unwrap(); + let mut bytes = [0u8; 5]; + crate::exec_target::read(&mut proc, &mut host, pid, token, 0, &mut bytes) + .unwrap(); + + match drift { + "mode" => { + host.file_modes + .insert(path.to_vec(), S_IFREG | 0o700); + } + "owner" => { + host.handle_owners.insert(handle, (42, 43)); + } + "mount" => { + host.handle_statfs.insert( + handle, + WasmStatfs { + f_fsid: 99, + ..default_statfs() + }, + ); + } + _ => unreachable!(), + } + + assert_eq!( + crate::exec_target::commit_process( + &mut proc, + &mut locks, + &mut host, + pid, + pid, + token, + ), + Err(Errno::ETXTBSY), + "{drift} drift must fail closed", + ); + assert_eq!(proc.credentials(), &original_credentials); + assert_eq!(proc.exec_generation, 0); + assert!(!proc.has_exec); + assert_eq!(host.closed_handles, vec![handle]); + } + } + + #[test] + fn exec_target_revalidates_mount_source_and_policy_for_every_target() { + for (pid, path, mode, live_statfs) in [ + ( + 99, + b"/bin/ordinary-mount-drift".as_slice(), + S_IFREG | 0o755, + WasmStatfs { + f_fsid: 99, + ..default_statfs() + }, + ), + ( + 100, + b"/bin/nosuid-policy-drift".as_slice(), + S_IFREG | S_ISUID | 0o755, + WasmStatfs { + f_flags: 0, + ..default_statfs() + }, + ), + ] { + let mut proc = Process::new(pid); + set_test_credentials(&mut proc, 1000, 1000, 1000, 1000, &[]); + let original_credentials = proc.credentials().clone(); + let mut locks = AdvisoryLockManager::new(); + let mut host = MockHostIO::new(); + host.set_file_with_owner(path, 42, 43, mode, b"hello"); + host.stat_size = 5; + host.prepared_exec_bytes = Some(b"hello".to_vec()); + let token = crate::exec_target::prepare( + &mut proc, + &mut locks, + &mut host, + crate::exec_target::PreparedExecOwner::Process { + pid, + caller_tid: pid, + generation: 0, + }, + AT_FDCWD, + path, + 0, + ) + .unwrap(); + let handle = proc + .prepared_exec_targets + .get(token) + .and_then(|target| { + proc.ofd_table + .get(target.ofd_ref().0) + .map(|ofd| ofd.host_handle) + .ok_or(Errno::EBADF) + }) + .unwrap(); + let mut bytes = [0u8; 5]; + crate::exec_target::read(&mut proc, &mut host, pid, token, 0, &mut bytes) + .unwrap(); + + host.handle_statfs.insert(handle, live_statfs); + + assert_eq!( + crate::exec_target::commit_process( + &mut proc, + &mut locks, + &mut host, + pid, + pid, + token, + ), + Err(Errno::ETXTBSY), + ); + assert_eq!(proc.credentials(), &original_credentials); + assert_eq!(proc.exec_generation, 0); + assert!(!proc.has_exec); + assert_eq!(host.closed_handles, vec![handle]); + } + } + + #[test] + fn exec_target_successful_set_id_commit_is_atomic_and_invalidates_competitors() { + let mut proc = Process::new(84); + let pid = proc.pid; + set_test_credentials(&mut proc, 1000, 1000, 1000, 1000, &[20, 30]); + let mut locks = AdvisoryLockManager::new(); + let mut host = MockHostIO::new(); + let trusted = WasmStatfs { + f_flags: 0, + f_fsid: 77, + ..default_statfs() + }; + host.set_statfs(b"/bin/privileged", trusted); + host.set_file_with_owner( + b"/bin/privileged", + 42, + 43, + S_IFREG | S_ISUID | S_ISGID | 0o755, + b"hello", + ); + host.stat_size = 5; + host.prepared_exec_bytes = Some(b"hello".to_vec()); + let owner = crate::exec_target::PreparedExecOwner::Process { + pid: proc.pid, + caller_tid: proc.pid, + generation: 0, + }; + let first = crate::exec_target::prepare( + &mut proc, + &mut locks, + &mut host, + owner, + AT_FDCWD, + b"/bin/privileged", + 0, + ) + .unwrap(); + let second = crate::exec_target::prepare( + &mut proc, + &mut locks, + &mut host, + owner, + AT_FDCWD, + b"/bin/privileged", + 0, + ) + .unwrap(); + for token in [first, second] { + let mut bytes = [0u8; 5]; + crate::exec_target::read(&mut proc, &mut host, pid, token, 0, &mut bytes) + .unwrap(); + } + + crate::exec_target::commit_process( + &mut proc, + &mut locks, + &mut host, + pid, + pid, + first, + ) + .unwrap(); + assert_eq!(proc.exec_generation, 1); + assert_eq!(proc.real_uid(), 1000); + assert_eq!(proc.effective_uid(), 42); + assert_eq!(proc.credentials().suid, 42); + assert_eq!(proc.real_gid(), 1000); + assert_eq!(proc.effective_gid(), 43); + assert_eq!(proc.credentials().sgid, 43); + assert!(proc.secure_exec); + assert_eq!( + crate::exec_target::cancel(&mut proc, &mut locks, &mut host, pid, second), + Err(Errno::EINVAL), + ); + assert_eq!(host.closed_handles, vec![101, 100]); + } + + #[test] + fn exec_target_diagnostic_path_cannot_change_exact_mount_policy() { + for (pid, prepared_flags, replacement_flags, expected_uid) in [ + (97, 0, wasm_posix_shared::statfs_flags::ST_NOSUID, 42), + (98, wasm_posix_shared::statfs_flags::ST_NOSUID, 0, 1000), + ] { + let mut proc = Process::new(pid); + set_test_credentials(&mut proc, 1000, 1000, 1000, 1000, &[]); + let mut locks = AdvisoryLockManager::new(); + let mut host = MockHostIO::new(); + let path = b"/bin/path-policy-is-not-authority"; + host.set_statfs( + path, + WasmStatfs { + f_flags: prepared_flags, + f_fsid: 77, + ..default_statfs() + }, + ); + host.set_file_with_owner( + path, + 42, + 43, + S_IFREG | S_ISUID | S_ISGID | 0o755, + b"hello", + ); + host.stat_size = 5; + host.prepared_exec_bytes = Some(b"hello".to_vec()); + let token = crate::exec_target::prepare( + &mut proc, + &mut locks, + &mut host, + crate::exec_target::PreparedExecOwner::Process { + pid, + caller_tid: pid, + generation: 0, + }, + AT_FDCWD, + path, + 0, + ) + .unwrap(); + let mut bytes = [0u8; 5]; + crate::exec_target::read(&mut proc, &mut host, pid, token, 0, &mut bytes) + .unwrap(); + + // Replace what a lookup through the old pathname reports. The + // retained object and its admitted mount route did not change. + host.set_statfs( + path, + WasmStatfs { + f_flags: replacement_flags, + f_fsid: 99, + ..default_statfs() + }, + ); + + crate::exec_target::commit_process( + &mut proc, + &mut locks, + &mut host, + pid, + pid, + token, + ) + .unwrap(); + assert_eq!(proc.effective_uid(), expected_uid); + assert_eq!(proc.secure_exec, expected_uid == 42); + } + } + + #[test] + fn exec_target_unstable_set_id_source_is_rejected_without_credentials() { + let mut proc = Process::new(85); + let pid = proc.pid; + set_test_credentials(&mut proc, 1000, 1000, 1000, 1000, &[]); + let original = proc.credentials().clone(); + let mut locks = AdvisoryLockManager::new(); + let mut host = MockHostIO::new(); + host.set_statfs( + b"/bin/unstable", + WasmStatfs { + f_flags: 0, + ..default_statfs() + }, + ); + host.set_file_with_owner( + b"/bin/unstable", + 0, + 0, + S_IFREG | S_ISUID | 0o755, + b"hello", + ); + host.path_inodes.insert(b"/bin/unstable".to_vec(), 0); + host.stat_size = 5; + host.prepared_exec_bytes = Some(b"hello".to_vec()); + let token = crate::exec_target::prepare( + &mut proc, + &mut locks, + &mut host, + crate::exec_target::PreparedExecOwner::Process { + pid, + caller_tid: pid, + generation: 0, + }, + AT_FDCWD, + b"/bin/unstable", + 0, + ) + .unwrap(); + let mut bytes = [0u8; 5]; + crate::exec_target::read(&mut proc, &mut host, pid, token, 0, &mut bytes).unwrap(); + assert_eq!( + crate::exec_target::commit_process( + &mut proc, + &mut locks, + &mut host, + pid, + pid, + token, + ), + Err(Errno::ENOTSUP), + ); + assert_eq!(proc.credentials(), &original); + assert_eq!(proc.exec_generation, 0); + assert_eq!(host.closed_handles, vec![100]); + } } diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index 03b8ae69c9..472dcb7d5b 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -85,6 +85,7 @@ unsafe extern "C" { fn host_stat(path_ptr: *const u8, path_len: u32, stat_ptr: *mut u8) -> i32; fn host_lstat(path_ptr: *const u8, path_len: u32, stat_ptr: *mut u8) -> i32; fn host_statfs(path_ptr: *const u8, path_len: u32, statfs_ptr: *mut u8) -> i32; + fn host_fstatfs(handle: i64, statfs_ptr: *mut u8) -> i32; fn host_pathconf(path_ptr: *const u8, path_len: u32, name: i32, value_ptr: *mut i64) -> i32; fn host_fpathconf(handle: i64, name: i32, value_ptr: *mut i64) -> i32; fn host_mkdir(path_ptr: *const u8, path_len: u32, mode: u32) -> i32; @@ -112,7 +113,6 @@ unsafe extern "C" { fn host_fsync(handle: i64) -> i32; fn host_fchmod(handle: i64, mode: u32) -> i32; fn host_fchown(handle: i64, uid: u32, gid: u32) -> i32; - fn host_exec(path_ptr: *const u8, path_len: u32) -> i32; fn host_set_alarm(seconds: u32) -> i32; fn host_set_posix_timer( timer_id: i32, @@ -450,6 +450,27 @@ impl HostIO for WasmHostIO { Ok(statfs) } + fn host_fstatfs(&mut self, handle: i64) -> Result { + let mut statfs = WasmStatfs { + f_type: 0, + f_bsize: 0, + f_blocks: 0, + f_bfree: 0, + f_bavail: 0, + f_files: 0, + f_ffree: 0, + f_fsid: 0, + f_namelen: 0, + f_frsize: 0, + f_flags: 0, + _pad: 0, + }; + let statfs_ptr = &mut statfs as *mut WasmStatfs as *mut u8; + let result = unsafe { host_fstatfs(handle, statfs_ptr) }; + i32_to_result(result)?; + Ok(statfs) + } + fn host_pathconf(&mut self, path: &[u8], name: i32) -> Result, Errno> { let mut value = -1i64; let result = unsafe { @@ -649,18 +670,6 @@ impl HostIO for WasmHostIO { i32_to_result(result) } - fn host_exec(&mut self, path: &[u8]) -> Result<(), Errno> { - let ret = unsafe { host_exec(path.as_ptr(), path.len() as u32) }; - if ret < 0 { - match Errno::from_u32((-ret) as u32) { - Some(e) => Err(e), - None => Err(Errno::EIO), - } - } else { - Ok(()) - } - } - fn host_set_alarm(&mut self, seconds: u32) -> Result<(), Errno> { let result = unsafe { host_set_alarm(seconds) }; i32_to_result(result) @@ -2715,51 +2724,26 @@ pub extern "C" fn kernel_dequeue_signal( } } -/// Thread-aware exec setup. When a pthread invokes exec, its signal mask and -/// directed pending signals become the surviving process thread's state. The -/// same exact kernel-owned task must first have completed -/// [`kernel_exec_prepare`]. -#[unsafe(no_mangle)] -pub extern "C" fn kernel_exec_setup_for_thread(pid: u32, caller_tid: u32) -> i32 { - kernel_exec_setup_inner(pid, caller_tid) -} - -/// Validate the exec caller and apply any deferred posix_spawn file actions. -/// -/// The host calls this before it starts the irreversible address-space -/// transition. Keeping these fallible operations separate means a bad caller -/// tid or failed file action cannot strand a process after its old image has -/// already been discarded. -#[unsafe(no_mangle)] -pub extern "C" fn kernel_exec_prepare(pid: u32, caller_tid: u32) -> i32 { - match prepare_exec_state(pid, caller_tid) { - Ok(()) => 0, - Err(e) => -(e as i32), - } -} - -fn prepare_exec_state(pid: u32, caller_tid: u32) -> Result<(), Errno> { - let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - let (proc, advisory_locks) = table.process_and_advisory_locks(pid).ok_or(Errno::ESRCH)?; - - proc.begin_exec_prepare(caller_tid)?; - +fn apply_pending_exec_fd_actions( + proc: &mut Process, + advisory_locks: &mut crate::lock::AdvisoryLockManager, + host: &mut dyn HostIO, +) -> Result<(), Errno> { // Apply pending fork fd actions (from posix_spawn) before exec. // These are dup2/close/open operations that rearrange descriptors (for // example, a pipe write end onto fd 1) and must precede CLOEXEC removal. let actions: alloc::vec::Vec<_> = proc.fork_fd_actions.drain(..).collect(); - let mut host = WasmHostIO; for action in actions { use crate::process::FdAction; match action { FdAction::Dup2 { old_fd, new_fd } => { - syscalls::sys_dup2_with_locks(proc, advisory_locks, &mut host, old_fd, new_fd)?; + syscalls::sys_dup2_with_locks(proc, advisory_locks, host, old_fd, new_fd)?; } FdAction::Close { fd } => { syscalls::sys_close_implicit_with_locks( proc, advisory_locks, - &mut host, + host, fd, )?; } @@ -2770,36 +2754,221 @@ fn prepare_exec_state(pid: u32, caller_tid: u32) -> Result<(), Errno> { mode, } => { let opened_fd = - syscalls::sys_open(proc, &mut host, path, flags as u32, mode as u32)?; + syscalls::sys_open(proc, host, path, flags as u32, mode as u32)?; if opened_fd != fd { - syscalls::sys_dup2_with_locks(proc, advisory_locks, &mut host, opened_fd, fd)?; + syscalls::sys_dup2_with_locks(proc, advisory_locks, host, opened_fd, fd)?; let _ = syscalls::sys_close_implicit_with_locks( proc, advisory_locks, - &mut host, + host, opened_fd, ); } } } } - proc.finish_exec_prepare(caller_tid); Ok(()) } -fn kernel_exec_setup_inner(pid: u32, caller_tid: u32) -> i32 { +fn checked_exec_path<'a>(path_ptr: usize, path_len: usize) -> Result<&'a [u8], Errno> { + if path_len > wasm_posix_shared::platform_limits::PATH_MAX_BYTES { + return Err(Errno::ENAMETOOLONG); + } + if path_len == 0 { + return Ok(&[]); + } + if path_ptr == 0 || path_ptr.checked_add(path_len).is_none() { + return Err(Errno::EFAULT); + } + Ok(unsafe { core::slice::from_raw_parts(path_ptr as *const u8, path_len) }) +} + +/// Prepare one exact pathname or `AT_EMPTY_PATH` executable object. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_exec_target_prepare( + pid: u32, + caller_tid: u32, + dirfd: i32, + path_ptr: usize, + path_len: usize, + flags: u32, +) -> i32 { + let path = match checked_exec_path(path_ptr, path_len) { + Ok(path) => path, + Err(error) => return -(error as i32), + }; let table = unsafe { &mut *PROCESS_TABLE.0.get() }; let (proc, advisory_locks) = match table.process_and_advisory_locks(pid) { Some(pair) => pair, None => return -(Errno::ESRCH as i32), }; - if let Err(e) = proc.consume_exec_prepare(caller_tid) { - return -(e as i32); + if !proc.is_live_explicit_tid(caller_tid) { + return -(Errno::ESRCH as i32); + } + let mut host = WasmHostIO; + let owner = crate::exec_target::PreparedExecOwner::Process { + pid, + caller_tid, + generation: proc.exec_generation, + }; + match crate::exec_target::prepare( + proc, + advisory_locks, + &mut host, + owner, + dirfd, + path, + flags, + ) { + Ok(token) => token as i32, + Err(error) => -(error as i32), } +} + +/// Prepare the exact initial executable for a newly allocated spawn child. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_spawn_exec_target_prepare( + parent_pid: u32, + child_pid: u32, + path_ptr: usize, + path_len: usize, +) -> i32 { + let path = match checked_exec_path(path_ptr, path_len) { + Ok(path) if !path.is_empty() => path, + Ok(_) => return -(Errno::ENOENT as i32), + Err(error) => return -(error as i32), + }; + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let parent_exists = table.get(parent_pid).is_some(); + let (child, advisory_locks) = match table.process_and_advisory_locks(child_pid) { + Some(pair) if parent_exists && pair.0.ppid == parent_pid => pair, + _ => return -(Errno::ESRCH as i32), + }; let mut host = WasmHostIO; - match syscalls::commit_exec_state_with_locks(proc, advisory_locks, &mut host, caller_tid) { + if let Err(error) = apply_pending_exec_fd_actions(child, advisory_locks, &mut host) { + return -(error as i32); + } + let owner = crate::exec_target::PreparedExecOwner::Spawn { + parent_pid, + child_pid, + launch: child.exec_generation, + }; + match crate::exec_target::prepare( + child, + advisory_locks, + &mut host, + owner, + wasm_posix_shared::flags::AT_FDCWD, + path, + 0, + ) { + Ok(token) => token as i32, + Err(error) => -(error as i32), + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn kernel_exec_target_size(owner_pid: u32, target: u32) -> i64 { + let table = unsafe { &*PROCESS_TABLE.0.get() }; + let Some(proc) = table.get(owner_pid) else { + return -(Errno::ESRCH as i64); + }; + match crate::exec_target::size(proc, owner_pid, target) { + Ok(size) => size, + Err(error) => -(error as i64), + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn kernel_exec_target_read( + owner_pid: u32, + target: u32, + offset_lo: u32, + offset_hi: i32, + buffer_ptr: usize, + buffer_len: usize, +) -> i32 { + if buffer_len > i32::MAX as usize { + return -(Errno::EOVERFLOW as i32); + } + if buffer_len != 0 && (buffer_ptr == 0 || buffer_ptr.checked_add(buffer_len).is_none()) { + return -(Errno::EFAULT as i32); + } + let buffer = if buffer_len == 0 { + &mut [] + } else { + unsafe { core::slice::from_raw_parts_mut(buffer_ptr as *mut u8, buffer_len) } + }; + let offset = ((offset_hi as i64) << 32) | i64::from(offset_lo); + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let Some(proc) = table.get_mut(owner_pid) else { + return -(Errno::ESRCH as i32); + }; + let mut host = WasmHostIO; + match crate::exec_target::read(proc, &mut host, owner_pid, target, offset, buffer) { + Ok(read) => read as i32, + Err(error) => -(error as i32), + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn kernel_exec_target_cancel(owner_pid: u32, target: u32) -> i32 { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let (proc, advisory_locks) = match table.process_and_advisory_locks(owner_pid) { + Some(pair) => pair, + None => return -(Errno::ESRCH as i32), + }; + let mut host = WasmHostIO; + match crate::exec_target::cancel(proc, advisory_locks, &mut host, owner_pid, target) { Ok(()) => 0, - Err(e) => -(e as i32), + Err(error) => -(error as i32), + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn kernel_exec_commit(pid: u32, caller_tid: u32, target: u32) -> i32 { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let (proc, advisory_locks) = match table.process_and_advisory_locks(pid) { + Some(pair) => pair, + None => return -(Errno::ESRCH as i32), + }; + let mut host = WasmHostIO; + match crate::exec_target::commit_process( + proc, + advisory_locks, + &mut host, + pid, + caller_tid, + target, + ) { + Ok(()) => 0, + Err(error) => -(error as i32), + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn kernel_spawn_exec_commit( + parent_pid: u32, + child_pid: u32, + target: u32, +) -> i32 { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let parent_exists = table.get(parent_pid).is_some(); + let (child, advisory_locks) = match table.process_and_advisory_locks(child_pid) { + Some(pair) if parent_exists && pair.0.ppid == parent_pid => pair, + _ => return -(Errno::ESRCH as i32), + }; + let mut host = WasmHostIO; + match crate::exec_target::commit_spawn( + child, + advisory_locks, + &mut host, + parent_pid, + child_pid, + target, + ) { + Ok(()) => 0, + Err(error) => -(error as i32), } } @@ -4286,18 +4455,10 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr ), // SYS_WAIT4 // Fork/exec/clone - 211 => { - // SYS_EXECVE: (path, ...) - let p = channel_const_ptr!(0, u8); - let len = channel_cstr_len!(p); - kernel_execve(p, len) - } - 386 => { - // SYS_EXECVEAT: (dirfd, path, argv, envp, flags) - let p = channel_const_ptr!(1, u8); - let len = channel_cstr_len!(p); - kernel_execveat(a1 as i32, p, len, a5 as u32) - } + // Exec launch is a host-orchestrated exact-target transaction. Direct + // channel dispatch has neither a retained object token nor authority + // to replace a worker, so it must not revive pathname-only exec. + 211 | 386 => -(Errno::ENOSYS as i32), // SYS_EXECVE / SYS_EXECVEAT // The centralized host must intercept fork and ask ProcessTable to // allocate the child identity. A direct dispatch cannot create a // worker without bypassing that authority, so fail truthfully. @@ -11809,87 +11970,6 @@ pub extern "C" fn kernel_realpath( result } -// --------------------------------------------------------------------------- -// execve -// --------------------------------------------------------------------------- - -/// Execute a new program. On success, traps (never returns) because the -/// process image is being replaced asynchronously by the host. On failure, -/// returns negative errno. -#[unsafe(no_mangle)] -pub extern "C" fn kernel_execve(path_ptr: *const u8, path_len: u32) -> i32 { - let result = { - let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; - let path = unsafe { slice::from_raw_parts(path_ptr, path_len as usize) }; - let mut host = WasmHostIO; - match syscalls::sys_execve(proc, &mut host, path) { - Ok(()) => Ok(()), - Err(e) => { - deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); - Err(e) - } - } - // _gkl is dropped here, releasing the Global Kernel Lock - }; - - match result { - Ok(()) => { - // Exec succeeded — the host is asynchronously replacing this process - // image. Trap to stop the current wasm execution immediately. - // GKL must be released before trapping so subsequent centralized - // kernel calls during the host's exec transition do not deadlock. - unsafe { &mut *PROCESS_TABLE.0.get() }.clear_current_tid_binding(); - #[cfg(any(target_arch = "wasm32", target_arch = "wasm64"))] - unsafe { - core::hint::unreachable_unchecked(); - } - #[cfg(not(any(target_arch = "wasm32", target_arch = "wasm64")))] - { - 0 - } - } - Err(e) => -(e as i32), - } -} - -/// Execute a new program via file descriptor (fexecve / execveat). -/// Same trap-on-success semantics as kernel_execve. -#[unsafe(no_mangle)] -pub extern "C" fn kernel_execveat( - dirfd: i32, - path_ptr: *const u8, - path_len: u32, - flags: u32, -) -> i32 { - let result = { - let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; - let path = unsafe { slice::from_raw_parts(path_ptr, path_len as usize) }; - let mut host = WasmHostIO; - match syscalls::sys_execveat(proc, &mut host, dirfd, path, flags) { - Ok(()) => Ok(()), - Err(e) => { - deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); - Err(e) - } - } - }; - - match result { - Ok(()) => { - unsafe { &mut *PROCESS_TABLE.0.get() }.clear_current_tid_binding(); - #[cfg(any(target_arch = "wasm32", target_arch = "wasm64"))] - unsafe { - core::hint::unreachable_unchecked(); - } - #[cfg(not(any(target_arch = "wasm32", target_arch = "wasm64")))] - { - 0 - } - } - Err(e) => -(e as i32), - } -} - /// clone — spawn a new thread. Returns child TID in parent, negative errno on error. #[unsafe(no_mangle)] pub extern "C" fn kernel_clone( diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 5099496279..1547547861 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -832,6 +832,7 @@ pub enum Errno { ENFILE = 23, EMFILE = 24, ENOTTY = 25, + ETXTBSY = 26, EFBIG = 27, ENOSPC = 28, ESPIPE = 29, @@ -872,6 +873,9 @@ pub enum Errno { } impl Errno { + /// POSIX permits ENOTSUP and EOPNOTSUPP to share one numeric value. + pub const ENOTSUP: Self = Self::EOPNOTSUPP; + /// Convert a raw u32 value to an Errno variant. pub fn from_u32(val: u32) -> Option { match val { @@ -898,6 +902,7 @@ impl Errno { 23 => Some(Errno::ENFILE), 24 => Some(Errno::EMFILE), 25 => Some(Errno::ENOTTY), + 26 => Some(Errno::ETXTBSY), 27 => Some(Errno::EFBIG), 28 => Some(Errno::ENOSPC), 29 => Some(Errno::ESPIPE), @@ -2972,8 +2977,11 @@ pub mod abi { "kernel_create_process", "kernel_create_process_with_stdio", "kernel_dequeue_signal", - "kernel_exec_prepare", - "kernel_exec_setup_for_thread", + "kernel_exec_commit", + "kernel_exec_target_cancel", + "kernel_exec_target_prepare", + "kernel_exec_target_read", + "kernel_exec_target_size", "kernel_fork_process", "kernel_get_cwd", "kernel_get_dirfd_path", @@ -3019,6 +3027,8 @@ pub mod abi { "kernel_set_current_tid", "kernel_set_cwd", "kernel_shmid_ds_bytes", + "kernel_spawn_exec_commit", + "kernel_spawn_exec_target_prepare", "kernel_spawn_process", "kernel_spawn_reserved_process", "kernel_spawn_scratch_begin", diff --git a/docs/architecture.md b/docs/architecture.md index b8b8486839..1ca78655d0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -79,8 +79,13 @@ kernel_remove_process(pid) → 0 kernel_handle_channel(channel_offset, channel_capacity, pid, retry_token) → result kernel_blocking_retry_token(pid, tid, syscall_nr) → opaque_token | -errno kernel_blocking_retry_release(pid, tid, opaque_token) → 0 | -errno -kernel_exec_prepare(pid, caller_tid) → 0 | -errno -kernel_exec_setup_for_thread(pid, caller_tid) → 0 | -errno +kernel_exec_target_prepare(pid, caller_tid, dirfd, path_ptr, path_len, flags) → opaque_target | -errno +kernel_spawn_exec_target_prepare(parent_pid, child_pid, path_ptr, path_len) → opaque_target | -errno +kernel_exec_target_size(owner_pid, opaque_target) → byte_length | -errno +kernel_exec_target_read(owner_pid, opaque_target, offset_lo, offset_hi, dst_ptr, dst_capacity) → bytes_read | -errno +kernel_exec_target_cancel(owner_pid, opaque_target) → 0 | -errno +kernel_exec_commit(pid, caller_tid, opaque_target) → 0 | -errno +kernel_spawn_exec_commit(parent_pid, child_pid, opaque_target) → 0 | -errno kernel_thread_exit(pid, tid) → 0 | -errno kernel_commit_process_exit(status) → committed_low_8_bits kernel_dequeue_signal(pid, tid, out_ptr, out_capacity) → 0 | signum | -errno @@ -1209,37 +1214,57 @@ pipes, sockets, PTYs, terminal devices, and listener queues. ### exec() -1. User calls `execve(path, argv, envp)` → kernel returns exec request to host -2. Host resolves `path` to a Wasm binary (via filesystem or program map) -3. The host compiles the replacement module, checks its ABI marker, and preallocates its fresh `WebAssembly.Memory` before the irreversible transition. It also validates a 4 MiB combined argv/environment representation (UTF-8 strings, NUL terminators, and caller-width pointer entries), plus the generated defensive caps of 4,096 entries in each vector. Independently, each string must fit the current 64 KiB process-metadata transfer; that is an implementation transport limit, not part of the public aggregate `ARG_MAX` definition. Oversized metadata returns `E2BIG` to the old image. After commit, argv and environment entries cross the fixed host scratch allocation one at a time into a token-bound Rust staging transaction. The live process metadata remains unchanged until one allocation-free commit swaps both complete vectors; a later entry-allocation failure cancels the transaction instead of exposing a prefix, and an empty vector deliberately replaces its prior vector with empty state. Supplying only argv or only environment is not a supported transaction, because validating that partial input would not prove the aggregate size of the preserved pair. -4. The host calls `kernel_exec_prepare(pid, caller_tid)` while the old image is - still live. The kernel validates that the exact caller is a live task owned - by the process and applies deferred `posix_spawn` file actions; any failure - returns before the address-space transition. The host then publishes and - flushes writable tracked mappings while the old image is still live. - Tracked shared file mappings hold a lifetime-stable host handle independent - of the guest fd, so closing the original fd does not by itself prevent - writeback. A failed flush leaves the old mapping trackers and SysV - attachments in place. - At the commit boundary, `kernel_exec_setup_for_thread(pid, caller_tid)` - closes CLOEXEC fds and directory streams and resets image-specific state - **in place**, including the program break (POSIX/Linux behavior — the prior - program's brk does not carry over). Exact kernel objects - behind surviving descriptors are never fork-cloned or reconstructed: socket - queues, eventfd/epoll/timerfd/signalfd state, memfd contents, procfs - snapshots, terminal input, and OFD identity therefore survive without - refcount churn. At that same commit Rust detaches the old address space's - SysV segments; the host then forgets its non-authoritative byte mirrors and - the other old mapping trackers. The calling pthread's signal mask and - directed queue become the process state; sibling workers terminate. - `alarm()`/`ITIMER_REAL` survives, while `timer_create()` timers are deleted. - The conformance gaps in [posix-status.md](posix-status.md) still apply, - notably numeric-fd epoll tracking and main-thread-directed signal - attribution. -5. Host terminates the old process and sibling-thread workers, then re-registers the PID with the preallocated memory -6. Host parses the new binary's `__heap_base` export and calls `kernel_set_brk_base(pid, __heap_base)` so `brk(0)` returns a value above the new program's data + stack region -7. Host spawns a new worker with the new program binary -8. New program starts from `_start` with the given argv/envp. The process +1. A process calls `execve()` or `execveat()`. The centralized kernel worker + reads the pathname arguments and derives a `diagnosticPath`. For + `AT_EMPTY_PATH` and relative `execveat()`, path getters may help produce that + display string. The host may pass it to `preparePath()` as a lazy VFS + materialization hint. Both uses are diagnostic-only: neither the string, a + path getter, nor a host program map authorizes the executable. +2. While the old image is live, the same kernel-worker entry calls + `kernel_exec_target_prepare(pid, caller_tid, dirfd, path, flags)`. Rust + validates the exact caller and `execveat()` lookup rules, resolves the + executable from authoritative process/VFS state, checks execute capability, + and returns an owner-bound one-shot token retaining the exact open file + description (OFD), bytes, and security metadata. +3. The host queries `kernel_exec_target_size` and copies the retained target + through bounded `kernel_exec_target_read` calls into only the explicitly + lent destination. A precommit failure calls `kernel_exec_target_cancel` + exactly once. For a shebang, the script token is canceled, `argv` is + rewritten once, and a separately prepared interpreter becomes the sole + final target; script set-ID state is never applied. +4. The host validates the exact bytes' ABI marker and fork-artifact policy, + compiles those same bytes, validates the 4 MiB combined argv/environment + representation and generated vector caps, and completes replacement + `WebAssembly.Memory` allocation/layout preflight before the irreversible + transition. Each metadata string must also fit the current 64 KiB transfer. + Any failure here cancels the token and returns to the old image. +5. Still before commit, the host publishes and flushes writable tracked + mappings. A failed flush leaves the old mapping trackers and SysV + attachments in place. Tracked shared mappings retain a lifetime-stable host + handle independent of the guest fd, so closing that fd does not prevent + writeback. +6. The asynchronous host callback receives target-derived data but no token or + commit closure, and returns a bounded replacement-memory launch plan. The + shared launcher then invokes + `kernel_exec_commit(pid, caller_tid, opaque_target)` synchronously through + the same centralized entry and starts the returned postcommit action before + yielding. Rust consumes the token, + revalidates the final retained target's exact handle, byte length, bytes, + metadata, and execute capability, then atomically commits the in-place + process transition. Commit closes CLOEXEC fds and directory streams, resets + image-specific state including the program break, and preserves the exact + kernel objects behind surviving descriptors without pathname re-resolution. + The diagnostic-only path and caller-provided bytes are never commit + authority. +7. After commit, the host retires the old process and sibling-thread workers, + detaches old SysV mappings, installs the preflighted replacement memory, and + starts the replacement Worker. Failure to construct or initialize that + Worker is fatal because the committed old image cannot return. The host + parses the replacement's `__heap_base` and registers it so `brk(0)` starts + above the new data and stack layout. `alarm()`/`ITIMER_REAL` survives, while + `timer_create()` timers are deleted. The remaining descriptor, signal, and + mapping gaps are tracked in [posix-status.md](posix-status.md). +8. The new program starts from `_start` with the given argv/envp. The process worker holds one immutable UTF-8 snapshot for the complete launch. The CRT first queries every entry length with zero destination capacity, verifies the generated count, per-entry, and caller-width aggregate limits, and then @@ -1252,7 +1277,12 @@ pipes, sockets, PTYs, terminal devices, and listener queues. This is guest-process memory, not kernel scratch, and it avoids reserving a 4 MiB worst-case static buffer in every program. -Step 6 is required: without it, `MemoryManager` falls back to a hardcoded 16MB `INITIAL_BRK`, which can land *inside* the stack region of programs whose data section pushes `__heap_base` above 16MB (mariadbd's `__heap_base ≈ 16.32MB`). Heap allocations there collide with shadow-stack frames during C++ static initialization, corrupting memory and hanging in `__wasm_call_ctors`. +The `__heap_base` registration in step 7 is required: without it, +`MemoryManager` falls back to a hardcoded 16MB `INITIAL_BRK`, which can land +*inside* the stack region of programs whose data section pushes `__heap_base` +above 16MB (mariadbd's `__heap_base ≈ 16.32MB`). Heap allocations there collide +with shadow-stack frames during C++ static initialization, corrupting memory +and hanging in `__wasm_call_ctors`. ### posix_spawn() (non-forking) @@ -1658,7 +1688,28 @@ VFS images can also carry image-level metadata outside the guest file tree. The ### Node host -`NodeKernelHost` accepts `rootfsImage: "default" | ArrayBuffer | Uint8Array | undefined`. With `"default"` (the path used by the vitest suite), the worker reads `host/wasm/rootfs.vfs`, applies `DEFAULT_MOUNT_SPEC` via the private-session Node resolver, and constructs a `VirtualPlatformIO` for the kernel. The image supplies both `/etc/ssl/cert.pem` and `/etc/ssl/certs/ca-certificates.crt`; Node does not silently add them to caller-supplied images. Optional `sessionSeedTrees` require a rootfs image and absolute host source paths; each source must remain quiescent until `init()` resolves. Graceful destroy, initialization failure, and fatal worker paths attempt to remove the complete session tree; abrupt process termination cannot run that best-effort hook, so cleanup is not the ownership proof. New private inodes and publication-before-`ready` establish ownership. Explicit exec mappings are considered before VFS lookup: `execPrograms` names host paths whose generations must remain immutable, while `execProgramBytes` is copied during `init()` and retained by the worker so later caller mutation or replacement cannot change a launch. A virtual path cannot use both sources. Without a rootfs image, the worker falls back to raw `NodePlatformIO` (every host path reachable) — kept for legacy callers that haven't migrated. +`NodeKernelHost` accepts +`rootfsImage: "default" | ArrayBuffer | Uint8Array | undefined`. With +`"default"` (the path used by the vitest suite), the worker reads +`host/wasm/rootfs.vfs`, applies `DEFAULT_MOUNT_SPEC` via the private-session +Node resolver, and constructs a `VirtualPlatformIO` for the kernel. The image +supplies both `/etc/ssl/cert.pem` and +`/etc/ssl/certs/ca-certificates.crt`; Node does not silently add them to +caller-supplied images. Optional `sessionSeedTrees` require a rootfs image and +absolute host source paths; each source must remain quiescent until `init()` +resolves. Graceful destroy, initialization failure, and fatal worker paths +attempt to remove the complete session tree; abrupt process termination +cannot run that best-effort hook, so cleanup is not the ownership proof. New +private inodes and publication-before-`ready` establish ownership. + +`execPrograms` and `execProgramBytes` are spawn-preflight inputs only. They +cannot authorize `execve` or `execveat`, whose executable bytes and metadata +come exclusively from the exact retained target prepared through the calling +process's kernel VFS state. Tests that name an exec fixture stage it into an +explicit test rootfs before boot. A virtual spawn-preflight path cannot use +both mapping sources. Without a rootfs image, the worker falls back to raw +`NodePlatformIO` (every host path reachable) — kept for legacy callers that +have not migrated. ### Browser host diff --git a/docs/posix-status.md b/docs/posix-status.md index 65e8a4f0bf..23a392513e 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -152,7 +152,7 @@ same final-OFD lifetime rules. | `set_robust_list()` | Stub | No-op. Robust futex list tracking deferred until threading is fully tested. | | `futex()` | Partial | FUTEX_WAIT, FUTEX_WAKE, FUTEX_REQUEUE, FUTEX_CMP_REQUEUE, and FUTEX_WAKE_OP operate on one process's shared memory. Main-process WAIT uses host `Atomics.waitAsync`; pthread workers use direct `Atomics.wait`. Separate processes have separate `SharedArrayBuffer` objects, so these operations do not wake or synchronize a peer PID even when the futex word lies in a host-coordinated MAP_SHARED mapping. | | `execve()` | Partial | Delegates to the in-place `exec()` path and has the same remaining descriptor/signal/mapping limitations described above. | -| `execveat()` | Partial | SYS_EXECVEAT (386). `AT_EMPTY_PATH` resolves the supplied fd through `kernel_get_fd_path` for `fexecve()`. Other relative paths resolve against the supplied directory fd through `kernel_get_dirfd_path` (`AT_FDCWD` selects the process CWD); absolute paths are independent of the fd. It otherwise has the same remaining `exec()` limitations. | +| `execveat()` | Partial | SYS_EXECVEAT (386). Host-derived paths, including strings from `kernel_get_fd_path` or `kernel_get_dirfd_path`, are diagnostic-only and may be used as a lazy VFS materialization hint; they never authorize execution. The centralized kernel entry passes the original fd/path/flags to `kernel_exec_target_prepare`, then uses the owner-bound token with `kernel_exec_target_size` and bounded `kernel_exec_target_read`. The host validates and compiles the exact bytes under the current ABI/artifact policy and completes replacement-memory preflight. Precommit failure uses exactly one `kernel_exec_target_cancel`; success calls `kernel_exec_commit`, which revalidates the retained exact handle, bytes, metadata, and capability before its atomic in-place commit. `AT_EMPTY_PATH`, relative-dirfd, and absolute-path semantics are therefore resolved from authoritative process/VFS state rather than path getters or program maps. It otherwise has the same remaining `exec()` limitations. | | `fork()` (syscall) | Partial | Glue traps through channel IPC; the kernel copies process state, the host starts a child Worker, and `wasm-fork-instrument` replays the call stack so parent/child receive the POSIX return values. ABI 43 requires the activation-state-safe artifact capability before launch and validates the linked-frame, reference/exception recipe, mutable module-state, table-journal, and activation-catalog contracts. Unsafe ABI 42, malformed, or mixed-version artifacts fail before execution. Negative results replay to the caller without terminating the parent, including continuation-allocation failure before or during unwind. | | `vfork()` | Partial | ABI 43 gives vfork a distinct libc and host transaction mode, maps it to `SYS_VFORK`, and does not run `pthread_atfork` handlers. A separate child Worker aliases the parent's existing `Shared WebAssembly.Memory`; the launch constructs no child process Memory and copies no address-space bytes. The child has private syscall-channel, replay-prefix, reference-codec, loader, and continuation-control state plus an independent kernel Process record. Only the calling parent thread remains parked until successful exec commit or exact `_exit()`/signal/trap teardown; sibling pthreads remain runnable. Failed exec returns to the child and keeps the lifetime active. Nested fork/vfork, spawn, and pthread creation fail with `EAGAIN`. Inherited descriptor tables, cwd, credentials, and process groups remain independent, while each inherited OFD shares its mutable offset, status flags, and async owner. Node, Chromium, Firefox, and WebKit production paths cover these lifecycles. A fatal signal delivered while the child has no pending syscall cannot obtain an exact browser Worker-quiescence fence; Kandelo truthfully contains the complete shared address space instead of resuming the parent. This row remains Partial pending broad conformance, pristine upstream CRuby selection, and full Homebrew/RSS validation. | | `posix_spawn()` | Partial | **Non-forking implementation** (this kernel's invention; no Linux equivalent). Glue issues `SYS_SPAWN` (500) with a marshalled blob (argv + envp + file actions + spawn attrs). Generated platform limits supply the advertised 4 MiB combined argv/environment `ARG_MAX`, 4,096-byte `PATH_MAX` including NUL, and defensive 4,096-entry caps for each process-startup vector; the separate generated wire contract aliases those counts and defines a 40-byte header, 28-byte action records, 1,024 actions, and an 8,417,320-byte complete transport ceiling. These representation caps are not additional POSIX limits. Independently, each argv/environment string must fit the current 64 KiB process-metadata transfer. That host implementation ceiling is separate from aggregate `ARG_MAX`. Child startup uses the same immutable query/exact-copy guest-mapping contract as `exec()`, so it cannot silently clamp counts or keep only 64/128 KiB prefixes. The host proves caller ranges, parsed limits, the selected kernel-owned allocation capacity, and the current kernel-memory range independently; fitting inside total kernel Wasm memory is not proof that the destination allocation owns those bytes. Ordinary blobs reuse channel scratch. Each larger blob begins a fresh exclusive reservation on a Rust-owned reusable high-water buffer, reads its pointer and capacity, copies under one synchronous lease, and commits with the matching opaque token. Begin and pointer/capacity queries are nonblocking; commit and cancellation wait on a no-host-import critical section. After every successful begin, the host cancels in a `finally` block, including setup and copy failures, so it returns with either a released unconsumed token or a definitive already-consumed/stale result. Overlapping or reentrant large-spawn attempts cannot replace live bytes. The host passes the calling TID to `kernel_spawn_process` or `kernel_spawn_reserved_process`; the Rust `ProcessTable` validates that task, allocates the child PID from the global task-ID sequence, and builds the child Process descriptor before `onSpawn` launches a fresh Worker. The child inherits the calling task's signal mask unless `POSIX_SPAWN_SETSIGMASK` replaces it. No fork, no `wpk_fork_*` rewind, no exec replay. Supports POSIX_SPAWN_SETSID / SETPGROUP / SETSIGMASK / SETSIGDEF and FDOP_OPEN / CLOSE / DUP2 / CHDIR / FCHDIR. SIG_IGN dispositions persist across the implicit exec; custom handlers reset to SIG_DFL (POSIX exec semantics). An inherited directory never aliases the parent's live host iterator: spawn preserves one shared next-record cookie and lazily reopens a child-owned iterator there. Inherited OFD offset, status, and owner state remain shared. Regression-guarded: `kernel_get_fork_count` exposes a per-process counter the test suite asserts is unchanged across SYS_SPAWN. See `docs/plans/2026-05-04-non-forking-posix-spawn-design.md`. | @@ -695,7 +695,7 @@ These features require SharedArrayBuffer (and cross-origin isolation headers in - Obsolete host-side `DeliverSignalMessage` / `ProcessManager.deliverSignal()` authority removed in ABI 42 13e. **Phase 13e (historical milestone complete; current conformance remains Partial):** Exec - In-place centralized exec: CLOEXEC filtering, signal disposition reset, pending-queue preservation -- Obsolete `kernel_get_exec_state` / `kernel_init_from_exec` Wasm exports removed in ABI 42; exec now uses only the centralized in-place `kernel_exec_prepare` / `kernel_exec_setup_for_thread` path +- Obsolete targetless/pathname-authority exports are removed. ABI 43 exec uses only centralized `kernel_exec_target_prepare` / `kernel_exec_target_size` / `kernel_exec_target_read` / `kernel_exec_target_cancel` and final retained-target `kernel_exec_commit` operations. - host_exec Wasm import and sys_execve syscall - Worker re-initialization against the continuing centralized kernel Process - ProcessManager.exec() for host-initiated exec diff --git a/host/src/binary-resolver.ts b/host/src/binary-resolver.ts index 56b712d1c9..70687c9670 100644 --- a/host/src/binary-resolver.ts +++ b/host/src/binary-resolver.ts @@ -1912,6 +1912,22 @@ function requiredExportsForRelPath(relPath: string): readonly string[] | undefin return undefined; } +const LEGACY_KERNEL_EXEC_EXPORTS = Object.freeze([ + "kernel_exec_prepare", + "kernel_exec_setup", + "kernel_exec_setup_for_thread", + "kernel_execve", + "kernel_execveat", +] as const); + +function forbiddenExportsForRelPath( + relPath: string, +): readonly string[] | undefined { + return applyDefaultArch(relPath) === "kernel.wasm" + ? LEGACY_KERNEL_EXEC_EXPORTS + : undefined; +} + function hasWasmArtifactPolicyFailures( path: string, relPath: string, @@ -1927,6 +1943,7 @@ function hasWasmArtifactPolicyFailures( return describeWasmArtifactPolicyFailures(programBytes, { expectedAbi: ABI_VERSION, requiredExports: requiredExportsForRelPath(relPath), + forbiddenExports: forbiddenExportsForRelPath(relPath), requireForkInstrumentation: forkDisabled ? false : undefined, forbidForkInstrumentation: forkDisabled, }).length > 0; diff --git a/host/src/browser-kernel-worker-entry.ts b/host/src/browser-kernel-worker-entry.ts index 1ac90b1c33..6b7e5cdb29 100644 --- a/host/src/browser-kernel-worker-entry.ts +++ b/host/src/browser-kernel-worker-entry.ts @@ -25,6 +25,10 @@ import type { } from "./kernel-worker"; import { BrowserWorkerAdapter } from "./worker-adapter-browser"; import { DeferredWorkerHandle } from "./deferred-worker-handle"; +import type { + PreparedExecLaunchPlan, + PreparedExecLaunchRequest, +} from "./exec-target"; import { readPreparedPlatformFile, VirtualPlatformIO, @@ -152,6 +156,8 @@ const processMemoryCreators = new ProcessMemoryCreatorGate(); let vforkMechanismTraceEnabled = false; let injectVforkWorkerStartFailure = false; let injectedVforkWorkerStartFailure = false; +let injectExecWorkerConstructionFailure = false; +let injectedExecWorkerConstructionFailure = false; function traceVforkMechanism(event: string, fields: string): void { if (!vforkMechanismTraceEnabled) return; @@ -979,6 +985,10 @@ async function handleInit(msg: Extract) { "KANDELO_TEST_VFORK_WORKER_START_FAILURE=once", ); injectedVforkWorkerStartFailure = false; + injectExecWorkerConstructionFailure = defaultEnv.includes( + "KANDELO_TEST_EXEC_WORKER_CONSTRUCTION_FAILURE=once", + ); + injectedExecWorkerConstructionFailure = false; processMemoryAllocator = new ProcessMemoryAllocator({ // The sampled byte budget is the concurrency authority. Keep the count // ceiling high enough that small address spaces do not inherit the @@ -1130,34 +1140,71 @@ async function handleInit(msg: Extract) { () => launch(), ); }, - onExec: (pid, path, argv, envp, callerTid) => - processMemoryCreators.run("an exec process Worker", async () => { + onExec: async (request) => { + const creatorAdmission = processMemoryCreators.acquire( + "an exec process Worker", + ); + try { + const { pid } = request; const execGeneration = processes.get(pid); const previousWorker = execGeneration?.worker; - const result = await handleExec(pid, path, argv, envp, callerTid); + const result = await handleExec(request); if ( - result < 0 + typeof result === "number" + && result < 0 && execGeneration && processes.get(pid) === execGeneration && vforkLifetimes.isActiveBorrower(execGeneration) ) { vforkLifetimes.noteFailedExec(execGeneration, -result); } - // Fire after handleExec updates the kernel Process.argv. If this is - // sent before registerProcess(..., { argv }), Kandelo's Procs tab - // refreshes against stale cmdline data and only corrects on remount. - // Fatal post-commit handoffs return 0 too, but install no new worker. - const installedWorker = processes.get(pid)?.worker; - if ( - result === 0 - && installedWorker - && installedWorker !== previousWorker - && kernelWorker.isProcessExecutionActive(pid) - ) { - post({ type: "proc_event", kind: "exec", pid }); + if (typeof result === "number") { + creatorAdmission.release(); + return result; } - return result; - }), + + let planState: "ready" | "settled" = "ready"; + return { + onCommitFailure: (commitResult?: number) => { + if (planState !== "ready") return; + planState = "settled"; + try { + result.onCommitFailure(commitResult); + } finally { + creatorAdmission.release(); + } + }, + startAfterCommit: async () => { + if (planState !== "ready") { + throw new Error("exec replacement plan already settled"); + } + planState = "settled"; + try { + const startResult = await result.startAfterCommit(); + // Fire after handleExec updates the kernel Process.argv. If this is + // sent before registerProcess(..., { argv }), Kandelo's Procs tab + // refreshes against stale cmdline data and only corrects on remount. + // Fatal post-commit handoffs return 0 too, but install no new worker. + const installedWorker = processes.get(pid)?.worker; + if ( + startResult === 0 + && installedWorker + && installedWorker !== previousWorker + && kernelWorker.isProcessExecutionActive(pid) + ) { + post({ type: "proc_event", kind: "exec", pid }); + } + return startResult; + } finally { + creatorAdmission.release(); + } + }, + } satisfies PreparedExecLaunchPlan; + } catch (error) { + creatorAdmission.release(); + throw error; + } + }, onResolveSpawn: handlePosixSpawnResolve, onSpawn: (parentPid, childPid, program, envp) => processMemoryCreators.run( @@ -2460,19 +2507,19 @@ async function handleOrdinaryFork( } async function handleExec( - pid: number, - path: string, - argv: string[], - envp: string[], - callerTid: number, -): Promise { + request: PreparedExecLaunchRequest, +): Promise { + const { + pid, + targetBytes: bytes, + targetModule: programModule, + argv: launchArgv, + envp, + diagnosticPath, + } = request; const initiatingInfo = processes.get(pid); if (!initiatingInfo) return -3; // ESRCH const vforkBorrower = vforkLifetimes.isActiveBorrower(initiatingInfo); - const resolved = await resolveExecutableForLaunch(path, argv); - if (!resolved) return -2; // ENOENT - if ("errno" in resolved) return -resolved.errno; - const { programBytes: bytes, programModule, argv: launchArgv } = resolved; // Preallocate the replacement address space before the irreversible commit. const ptrWidth = detectPtrWidth(bytes); const metadataResult = kernelWorker.validateExecMetadata( @@ -2489,9 +2536,9 @@ async function handleExec( ptrWidth, maxPages, { - operation: "exec", - path, - argv: launchArgv, + operation: "exec", + path: diagnosticPath, + argv: launchArgv, }, ); } catch (error) { @@ -2515,11 +2562,6 @@ async function handleExec( prepared.memoryLease.release(); return -3; // ESRCH } - const prepareResult = kernelWorker.kernelExecPrepare(pid, callerTid); - if (prepareResult < 0) { - prepared.memoryLease.release(); - return prepareResult; - } const addressSpaceResult = kernelWorker.prepareAddressSpaceForExec(pid); if (addressSpaceResult < 0) { prepared.memoryLease.release(); @@ -2528,332 +2570,411 @@ async function handleExec( let replacementWorker: ReturnType | undefined; let replacementExternrefGeneration: ForkExternrefGeneration | undefined; let replacementForkHostImports: ForkHostImportOwnerWorker | undefined; - try { - const setupResult = kernelWorker.kernelExecSetup(pid, callerTid); - if (setupResult < 0) { + let launchPlanState: "ready" | "discarded" | "started" = "ready"; + const onCommitFailure = (commitResult?: number): void => { + if (launchPlanState !== "ready") return; + launchPlanState = "discarded"; + try { prepared.memoryLease.release(); - return setupResult; + preparedLeaseConsumed = true; + } catch { + // Preserve the kernel's authoritative commit result. } - vmInterruptTimers.clear(pid, initiatingInfo); - - // Wake the exact old execution generation through the existing internal - // SIGKILL path. worker-main recognizes the exec-retire marker, skips - // SYS_EXIT for the persistent PID, returns, and lets worker-entry publish - // the only browser-safe memory ownership fence. - // Suppress ordinary crash/exit finalizers before the first retirement - // wake. The persistent PID is already past exec's commit point, so an - // error from the discarded generation must never kill the replacement. - if (initiatingInfo.worker) { - intentionallyTerminated.add(initiatingInfo.worker as object); + if ( + commitResult !== undefined + && commitResult < 0 + && vforkBorrower + && vforkLifetimes.phaseForChild(initiatingInfo) !== undefined + ) { + vforkLifetimes.noteFailedExec(initiatingInfo, -commitResult); } - for (const thread of threadWorkers.get(pid) ?? []) { - intentionallyTerminated.add(thread.worker as object); + }; + const startAfterCommit = async (): Promise => { + if (launchPlanState !== "ready") { + throw new Error(`Exec launch plan for pid ${pid} was already consumed`); } - const retiredOffsets = - kernelWorker.wakeProcessWorkersForExecRetirement( - pid, - initiatingInfo.memory, + launchPlanState = "started"; + try { + vmInterruptTimers.clear(pid, initiatingInfo); + + // Wake the exact old execution generation through the existing internal + // SIGKILL path. worker-main recognizes the exec-retire marker, skips + // SYS_EXIT for the persistent PID, returns, and lets worker-entry publish + // the only browser-safe memory ownership fence. + // Suppress ordinary crash/exit finalizers before the first retirement + // wake. The persistent PID is already past exec's commit point, so an + // error from the discarded generation must never kill the replacement. + if (initiatingInfo.worker) { + intentionallyTerminated.add(initiatingInfo.worker as object); + } + for (const thread of threadWorkers.get(pid) ?? []) { + intentionallyTerminated.add(thread.worker as object); + } + const retiredOffsets = + kernelWorker.wakeProcessWorkersForExecRetirement( + pid, + initiatingInfo.memory, + ); + const mainRetirementStarted = retiredOffsets.has( + initiatingInfo.channelOffset, + ); + threadedProcessPids.delete(pid); + if (!kernelWorker.prepareProcessForExec(pid, initiatingInfo.memory)) { + throw new Error(`Exec pid ${pid} changed generation during commit`); + } + replacementExternrefGeneration = externrefProcessOwner.replaceGeneration( + initiatingInfo.externrefGeneration, ); - const mainRetirementStarted = retiredOffsets.has( - initiatingInfo.channelOffset, - ); - threadedProcessPids.delete(pid); - if (!kernelWorker.prepareProcessForExec(pid, initiatingInfo.memory)) { - throw new Error(`Exec pid ${pid} changed generation during commit`); - } - replacementExternrefGeneration = externrefProcessOwner.replaceGeneration( - initiatingInfo.externrefGeneration, - ); - const finalizeResult = kernelWorker.finalizeAddressSpaceForExec(pid); - if (finalizeResult < 0) { - throw new Error("failed to detach the discarded address space"); - } + const finalizeResult = kernelWorker.finalizeAddressSpaceForExec(pid); + if (finalizeResult < 0) { + throw new Error("failed to detach the discarded address space"); + } - const [mainQuiescent, threadsQuiescent] = await Promise.all([ - mainRetirementStarted - ? waitForExecRetirement( - initiatingInfo.execRetirement, - initiatingInfo.workerQuiescence, - ) - : Promise.resolve(false), - terminateThreadWorkers(pid, true), - ]); - if (initiatingInfo.worker) { - intentionallyTerminated.add(initiatingInfo.worker as object); - forkHostImportsByWorker.get(initiatingInfo.worker as object)?.close(); - await initiatingInfo.worker.terminate().catch(() => {}); - } - if (mainQuiescent) { - // Thread fences retire their own exact listeners during slot reclaim. - // Settle the main channel independently so one unresponsive sibling - // cannot retain an otherwise finished waitAsync closure. - await kernelWorker.settleRetiredChannelListeners( + const [mainQuiescent, threadsQuiescent] = await Promise.all([ + mainRetirementStarted + ? waitForExecRetirement( + initiatingInfo.execRetirement, + initiatingInfo.workerQuiescence, + ) + : Promise.resolve(false), + terminateThreadWorkers(pid, true), + ]); + if (initiatingInfo.worker) { + intentionallyTerminated.add(initiatingInfo.worker as object); + forkHostImportsByWorker.get(initiatingInfo.worker as object)?.close(); + await initiatingInfo.worker.terminate().catch(() => {}); + } + if (mainQuiescent) { + // Thread fences retire their own exact listeners during slot reclaim. + // Settle the main channel independently so one unresponsive sibling + // cannot retain an otherwise finished waitAsync closure. + await kernelWorker.settleRetiredChannelListeners( + pid, + initiatingInfo.memory, + initiatingInfo.channelOffset, + ); + } + const mainFramebufferReleased = + await releaseMainFramebufferGeneration(pid, initiatingInfo); + oldMemoryRetirementSafe = + mainQuiescent + && threadsQuiescent + && initiatingInfo.memoryRetirementSafe + && mainFramebufferReleased; + const handoffExitSignal = + kernelWorker.finalizeExecHandoffTermination(pid); + if (handoffExitSignal > 0) { + prepared.memoryLease.release(); + preparedLeaseConsumed = true; + externrefProcessOwner.releaseGeneration( + replacementExternrefGeneration, + ); + replacementExternrefGeneration = undefined; + await awaitFinalizedProcessTeardown( + pid, + signalExitStatus(handoffExitSignal), + initiatingInfo.worker, + handoffExitSignal, + "signal", + ); + return 0; + } + + // DIAGNOSTIC: track pid → exec path so the sysprof dump can name + // each pid (otherwise the table is just opaque numbers). + { + const g = globalThis as { __pidMap?: Map }; + if (!g.__pidMap) g.__pidMap = new Map(); + g.__pidMap.set(pid, diagnosticPath); + } + const { + memory: newMemory, + memoryLease: newMemoryLease, + layout: newLayout, + threadAllocator: newThreadAllocator, + } = prepared; + const newChannelOffset = newLayout.channelOffset; + replacementForkHostImports = forkHostImportOwnerRuntime.createWorker({ pid, - initiatingInfo.memory, - initiatingInfo.channelOffset, - ); - } - const mainFramebufferReleased = - await releaseMainFramebufferGeneration(pid, initiatingInfo); - oldMemoryRetirementSafe = - mainQuiescent - && threadsQuiescent - && initiatingInfo.memoryRetirementSafe - && mainFramebufferReleased; - const handoffExitSignal = - kernelWorker.finalizeExecHandoffTermination(pid); - if (handoffExitSignal > 0) { - prepared.memoryLease.release(); - preparedLeaseConsumed = true; - externrefProcessOwner.releaseGeneration( - replacementExternrefGeneration, - ); - replacementExternrefGeneration = undefined; - await awaitFinalizedProcessTeardown( + generationId: replacementExternrefGeneration.id, + authorizeSender: () => { + const current = processes.get(pid); + if ( + !replacementWorker + || !current + || current.worker !== replacementWorker + || current.externrefGeneration !== replacementExternrefGeneration + ) { + throw new Error(`stale fork host-import sender for exec pid=${pid}`); + } + }, + }); + + const execInitData: CentralizedWorkerInitMessage = { + type: "centralized_init", pid, - signalExitStatus(handoffExitSignal), - initiatingInfo.worker, - handoffExitSignal, - "signal", - ); - return 0; - } + programBytes: bytes, + programModule, + memory: newMemory, + channelOffset: newChannelOffset, + externrefGenerationId: replacementExternrefGeneration.id, + forkHostImports: replacementForkHostImports.init, + argv: launchArgv, + env: envp, + ptrWidth, + kernelAbiVersion: kernelWorker.getKernelAbiVersion(), + }; - // DIAGNOSTIC: track pid → exec path so the sysprof dump can name - // each pid (otherwise the table is just opaque numbers). - { - const g = globalThis as { __pidMap?: Map }; - if (!g.__pidMap) g.__pidMap = new Map(); - g.__pidMap.set(pid, path); - } - const { - memory: newMemory, - memoryLease: newMemoryLease, - layout: newLayout, - threadAllocator: newThreadAllocator, - } = prepared; - const newChannelOffset = newLayout.channelOffset; - replacementForkHostImports = forkHostImportOwnerRuntime.createWorker({ - pid, - generationId: replacementExternrefGeneration.id, - authorizeSender: () => { - const current = processes.get(pid); + replacementWorker = new DeferredWorkerHandle(() => { if ( - !replacementWorker - || !current - || current.worker !== replacementWorker - || current.externrefGeneration !== replacementExternrefGeneration + injectExecWorkerConstructionFailure + && !injectedExecWorkerConstructionFailure ) { - throw new Error(`stale fork host-import sender for exec pid=${pid}`); + injectedExecWorkerConstructionFailure = true; + const initialPostFailureData = { ...execInitData }; + Object.defineProperty( + initialPostFailureData, + "__kandeloTestInitialPostFailure", + { + enumerable: true, + get: () => { + throw new Error("injected exec Worker construction failure"); + }, + }, + ); + return workerAdapter.createWorker(initialPostFailureData); } - }, - }); - - const execInitData: CentralizedWorkerInitMessage = { - type: "centralized_init", - pid, - programBytes: bytes, - programModule, - memory: newMemory, - channelOffset: newChannelOffset, - externrefGenerationId: replacementExternrefGeneration.id, - forkHostImports: replacementForkHostImports.init, - argv: launchArgv, - env: envp, - ptrWidth, - kernelAbiVersion: kernelWorker.getKernelAbiVersion(), - }; - - replacementWorker = new DeferredWorkerHandle( - () => workerAdapter.createWorker(execInitData), - ); - kernelWorker.registerProcess(pid, newMemory, [newChannelOffset], { - preserveProcessState: true, - ptrWidth, - metadataPtrWidth: initiatingInfo.ptrWidth, - brkBase: newLayout.brkBase, - mmapBase: newLayout.mmapBase, - maxAddr: newLayout.maxAddr, - // Refresh kernel-owned argv/environment for procfs and kernel APIs. - argv: launchArgv, - env: envp, - }); - replacementRegistered = true; - bindForkHostImports(replacementWorker, replacementForkHostImports); + if ( + envp.includes("KANDELO_TEST_EXEC_WORKER_CONSTRUCTION_FAILURE=once") + && !injectedExecWorkerConstructionFailure + ) { + injectedExecWorkerConstructionFailure = true; + throw new Error("injected exec Worker construction failure"); + } + return workerAdapter.createWorker(execInitData); + }); + kernelWorker.registerProcess(pid, newMemory, [newChannelOffset], { + preserveProcessState: true, + ptrWidth, + metadataPtrWidth: initiatingInfo.ptrWidth, + brkBase: newLayout.brkBase, + mmapBase: newLayout.mmapBase, + maxAddr: newLayout.maxAddr, + // Refresh kernel-owned argv/environment for procfs and kernel APIs. + argv: launchArgv, + env: envp, + }); + replacementRegistered = true; + bindForkHostImports(replacementWorker, replacementForkHostImports); - // Clear cached thread module — the new program binary is different - threadModuleCache.delete(pid); + // Clear cached thread module — the new program binary is different + threadModuleCache.delete(pid); - processes.set(pid, { - generation: allocateProcessGeneration(), - memory: newMemory, - memoryLease: newMemoryLease, - workerQuiescence: createWorkerQuiescence(), - execRetirement: createWorkerQuiescence(), - memoryRetirementSafe: true, - framebufferExposed: false, - programBytes: bytes, - programModule, - worker: replacementWorker, - argv: launchArgv, - channelOffset: newChannelOffset, - ptrWidth, - layout: newLayout, - threadAllocator: newThreadAllocator, - externrefGeneration: replacementExternrefGeneration, - }); - preparedTransferred = true; + processes.set(pid, { + generation: allocateProcessGeneration(), + memory: newMemory, + memoryLease: newMemoryLease, + workerQuiescence: createWorkerQuiescence(), + execRetirement: createWorkerQuiescence(), + memoryRetirementSafe: true, + framebufferExposed: false, + programBytes: bytes, + programModule, + worker: replacementWorker, + argv: launchArgv, + channelOffset: newChannelOffset, + ptrWidth, + layout: newLayout, + threadAllocator: newThreadAllocator, + externrefGeneration: replacementExternrefGeneration, + }); + preparedTransferred = true; - if (oldMemoryRetirementSafe) initiatingInfo.memoryLease.release(); - else initiatingInfo.memoryLease.releaseAfterForcedTermination(); - initiatingLeaseConsumed = true; + if (oldMemoryRetirementSafe) initiatingInfo.memoryLease.release(); + else initiatingInfo.memoryLease.releaseAfterForcedTermination(); + initiatingLeaseConsumed = true; - // Wire post-exec error/exit handling. The handleFork listener (on the - // pre-exec worker) is gone with the terminated worker; without re-arming - // here, a wasm trap in the exec'd binary leaves waitpid blocked forever. - installProcessWorkerListeners(replacementWorker, pid); - const startDisposition = kernelWorker.startProcessWorkerWhenRunnable( - pid, - newMemory, - () => { - replacementStartAttempted = true; - (replacementWorker as DeferredWorkerHandle).start(); - }, - () => { - replacementForkHostImports?.close(); - void replacementWorker?.terminate(); - }, - ); - if (startDisposition === "stale") { - throw new Error(`Exec pid ${pid} changed generation before Worker launch`); - } - if (startDisposition === "dead") { - replacementForkHostImports.close(); - // startProcessWorkerWhenRunnable proved that the replacement Worker was - // never started. Publish the equivalent ownership fence so the ordinary - // exit teardown can retire listeners and release its lease safely. - processes.get(pid)?.workerQuiescence.settle(); - kernelWorker.finishProcessExecHandoff(pid); - const signal = kernelWorker.finalizeExecHandoffTermination(pid); - if (vforkBorrower) { - completeVforkGenerationTeardown( - initiatingInfo, - oldMemoryRetirementSafe, - "exec", - new Error( - `vfork child ${pid} exec retired without exact browser ownership`, - ), - ); - } - await awaitFinalizedProcessTeardown( - pid, - signal > 0 ? signalExitStatus(signal) : 0, - replacementWorker, - signal > 0 ? signal : undefined, - signal > 0 ? "signal" : "exit", - ); - return 0; - } - kernelWorker.finishProcessExecHandoff(pid); - if (vforkBorrower) { - completeVforkGenerationTeardown( - initiatingInfo, - oldMemoryRetirementSafe, - "exec", - new Error( - `vfork child ${pid} exec retired without exact browser ownership`, - ), - ); - } - return 0; - } catch (err) { - replacementForkHostImports?.close(); - if (replacementExternrefGeneration) { - externrefProcessOwner.releaseGeneration(replacementExternrefGeneration); - replacementExternrefGeneration = undefined; - } - if (initiatingInfo.worker) { - intentionallyTerminated.add(initiatingInfo.worker as object); - } - threadedProcessPids.delete(pid); - try { - const failedGenerationMemory = - preparedTransferred || replacementRegistered - ? prepared.memoryLease.memory - : initiatingInfo.memory; - kernelWorker.prepareProcessForExec(pid, failedGenerationMemory); - } catch { - // Continue with best-effort process death below. - } - if (replacementWorker && processes.get(pid)?.worker !== replacementWorker) { - await terminateTrackedWorker(replacementWorker); - } - if (!preparedTransferred && !preparedLeaseConsumed) { - const replacementGeneration = { - memory: prepared.memoryLease.memory, - memoryLease: prepared.memoryLease, - }; - const detachResult = await detachExactProcessGeneration({ + // Wire post-exec error/exit handling. The handleFork listener (on the + // pre-exec worker) is gone with the terminated worker; without re-arming + // here, a wasm trap in the exec'd binary leaves waitpid blocked forever. + installProcessWorkerListeners(replacementWorker, pid); + const startDisposition = kernelWorker.startProcessWorkerWhenRunnable( pid, - generation: replacementGeneration, - operation: replacementRegistered ? "deactivate" : "none", - retire: (commit) => { - if (replacementStartAttempted) { - prepared.memoryLease.releaseAfterForcedTermination(); - } else { - prepared.memoryLease.release(); + newMemory, + () => { + replacementStartAttempted = true; + if (!(replacementWorker as DeferredWorkerHandle).start()) { + throw new Error(`Exec replacement Worker for pid ${pid} was cancelled`); + } + if ( + vforkBorrower + && vforkLifetimes.phaseForChild(initiatingInfo) !== undefined + ) { + completeVforkGenerationTeardown( + initiatingInfo, + oldMemoryRetirementSafe && initiatingLeaseConsumed, + "exec", + new Error( + `vfork child ${pid} exec retired without exact browser ownership`, + ), + ); } - commit(); }, - }); - if (detachResult.status === "released") { - preparedLeaseConsumed = true; - } else { - reportRetainedProcessGeneration( + () => { + replacementForkHostImports?.close(); + void replacementWorker?.terminate(); + }, + (error) => { + if ( + vforkBorrower + && vforkLifetimes.phaseForChild(initiatingInfo) !== undefined + ) { + completeVforkGenerationTeardown( + initiatingInfo, + oldMemoryRetirementSafe && initiatingLeaseConsumed, + "trap", + error, + ); + } + const message = error instanceof Error ? error.message : String(error); + reportHostDiagnostic({ + pid, + status: signalExitStatus(SIGSEGV), + source: "exec post-commit transition", + message: `[exec] post-commit transition failed: ${message}`, + }); + handleExit( + pid, + signalExitStatus(SIGSEGV), + SIGSEGV, + replacementWorker, + "trap", + ); + return true; + }, + ); + if (startDisposition === "stale") { + throw new Error(`Exec pid ${pid} changed generation before Worker launch`); + } + if (startDisposition === "dead") { + replacementForkHostImports.close(); + // startProcessWorkerWhenRunnable proved that the replacement Worker was + // never started. Publish the equivalent ownership fence so the ordinary + // exit teardown can retire listeners and release its lease safely. + processes.get(pid)?.workerQuiescence.settle(); + kernelWorker.finishProcessExecHandoff(pid); + const signal = kernelWorker.finalizeExecHandoffTermination(pid); + if (vforkBorrower) { + completeVforkGenerationTeardown( + initiatingInfo, + oldMemoryRetirementSafe, + "exec", + new Error( + `vfork child ${pid} exec retired without exact browser ownership`, + ), + ); + } + await awaitFinalizedProcessTeardown( pid, - "exec replacement rollback", - detachResult, - signalExitStatus(SIGSEGV), + signal > 0 ? signalExitStatus(signal) : 0, + replacementWorker, + signal > 0 ? signal : undefined, + signal > 0 ? "signal" : "exit", ); + return 0; } - } - if (preparedTransferred && !initiatingLeaseConsumed) { - if (oldMemoryRetirementSafe) { - initiatingInfo.memoryLease.release(); - } else { - initiatingInfo.memoryLease.releaseAfterForcedTermination(); + kernelWorker.finishProcessExecHandoff(pid); + return 0; + } catch (err) { + replacementForkHostImports?.close(); + if (replacementExternrefGeneration) { + externrefProcessOwner.releaseGeneration(replacementExternrefGeneration); + replacementExternrefGeneration = undefined; + } + if (initiatingInfo.worker) { + intentionallyTerminated.add(initiatingInfo.worker as object); + } + threadedProcessPids.delete(pid); + try { + const failedGenerationMemory = + preparedTransferred || replacementRegistered + ? prepared.memoryLease.memory + : initiatingInfo.memory; + kernelWorker.prepareProcessForExec(pid, failedGenerationMemory); + } catch { + // Continue with best-effort process death below. + } + if (replacementWorker && processes.get(pid)?.worker !== replacementWorker) { + await terminateTrackedWorker(replacementWorker); + } + if (!preparedTransferred && !preparedLeaseConsumed) { + const replacementGeneration = { + memory: prepared.memoryLease.memory, + memoryLease: prepared.memoryLease, + }; + const detachResult = await detachExactProcessGeneration({ + pid, + generation: replacementGeneration, + operation: replacementRegistered ? "deactivate" : "none", + retire: (commit) => { + if (replacementStartAttempted) { + prepared.memoryLease.releaseAfterForcedTermination(); + } else { + prepared.memoryLease.release(); + } + commit(); + }, + }); + if (detachResult.status === "released") { + preparedLeaseConsumed = true; + } else { + reportRetainedProcessGeneration( + pid, + "exec replacement rollback", + detachResult, + signalExitStatus(SIGSEGV), + ); + } + } + if (preparedTransferred && !initiatingLeaseConsumed) { + if (oldMemoryRetirementSafe) { + initiatingInfo.memoryLease.release(); + } else { + initiatingInfo.memoryLease.releaseAfterForcedTermination(); + } + initiatingLeaseConsumed = true; + } + if ( + vforkBorrower + && preparedTransferred + && vforkLifetimes.phaseForChild(initiatingInfo) !== undefined + ) { + completeVforkGenerationTeardown( + initiatingInfo, + oldMemoryRetirementSafe && initiatingLeaseConsumed, + "trap", + err, + ); } - initiatingLeaseConsumed = true; - } - if ( - vforkBorrower - && preparedTransferred - && vforkLifetimes.phaseForChild(initiatingInfo) !== undefined - ) { - completeVforkGenerationTeardown( - initiatingInfo, - oldMemoryRetirementSafe && initiatingLeaseConsumed, - "trap", - err, - ); - } - const message = err instanceof Error ? err.message : String(err); - try { - reportHostDiagnostic({ - pid, - status: signalExitStatus(SIGSEGV), - source: "exec post-commit transition", - message: `[exec] post-commit transition failed: ${message}`, - }); - } catch { - // A closed host port must not prevent kernel-side reap. + const message = err instanceof Error ? err.message : String(err); + try { + reportHostDiagnostic({ + pid, + status: signalExitStatus(SIGSEGV), + source: "exec post-commit transition", + message: `[exec] post-commit transition failed: ${message}`, + }); + } catch { + // A closed host port must not prevent kernel-side reap. + } + try { kernelWorker.notifyHostProcessCrashed(pid, SIGSEGV); } catch { /* best-effort */ } + handleExit(pid, signalExitStatus(SIGSEGV), SIGSEGV); + return 0; } - try { kernelWorker.notifyHostProcessCrashed(pid, SIGSEGV); } catch { /* best-effort */ } - handleExit(pid, signalExitStatus(SIGSEGV), SIGSEGV); - return 0; - } + }; + return { onCommitFailure, startAfterCommit }; } /** diff --git a/host/src/constants.ts b/host/src/constants.ts index a6e487fd56..10dc6e0d09 100644 --- a/host/src/constants.ts +++ b/host/src/constants.ts @@ -2405,6 +2405,7 @@ export function describeWasmArtifactPolicyFailures( options: { expectedAbi?: number | null; requiredExports?: readonly string[]; + forbiddenExports?: readonly string[]; requireForkInstrumentation?: boolean; forbidForkInstrumentation?: boolean; } = {}, @@ -2429,6 +2430,12 @@ export function describeWasmArtifactPolicyFailures( failures.push(`missing required exports: ${missing.join(", ")}`); } } + if (options.forbiddenExports) { + const forbidden = options.forbiddenExports.filter((name) => exports.has(name)); + if (forbidden.length > 0) { + failures.push(`forbidden exports present: ${forbidden.join(", ")}`); + } + } const presentWpkExports = WPK_FORK_EXPORTS.filter((name) => exports.has(name)); const importNames = readWasmImportNames(programBytes); diff --git a/host/src/exec-target.ts b/host/src/exec-target.ts new file mode 100644 index 0000000000..5d16a04446 --- /dev/null +++ b/host/src/exec-target.ts @@ -0,0 +1,364 @@ +import { + describeWasmArtifactPolicyFailures, + extractAbiVersion, + isWasmModuleBytes, +} from "./constants"; +import { + CH_DATA_SIZE, + MAX_REPORTABLE_TRANSFER_BYTES, +} from "./generated/abi"; + +const EFBIG = 27; +const EIO = 5; +const ENOEXEC = 8; +const ENOMEM = 12; +const EOVERFLOW = 75; +const MAX_SHEBANG_LINE_BYTES = 4096; + +export interface PreparedExecKernel { + execTargetSize(ownerPid: number, target: number): bigint; + execTargetRead( + ownerPid: number, + target: number, + offset: bigint, + destination: Uint8Array, + ): number; + execTargetCancel(ownerPid: number, target: number): number; +} + +export class PreparedExecTargetError extends Error { + readonly errno: number; + targetCancelled: boolean; + + constructor(message: string, errno: number, targetCancelled = false) { + super(message); + this.name = "PreparedExecTargetError"; + this.errno = errno; + this.targetCancelled = targetCancelled; + } +} + +function targetError(cause: unknown, fallback: string): PreparedExecTargetError { + if (cause instanceof PreparedExecTargetError) return cause; + return new PreparedExecTargetError( + cause instanceof Error ? `${fallback}: ${cause.message}` : fallback, + EIO, + ); +} + +function errnoFromNegativeResult(result: number | bigint): number { + const errno = typeof result === "bigint" ? -result : -result; + if (errno > 0 && errno <= 4095) return Number(errno); + return EIO; +} + +function cancelPreparedTarget( + kernel: PreparedExecKernel, + ownerPid: number, + target: number, + error: PreparedExecTargetError, +): PreparedExecTargetError { + if (error.targetCancelled) return error; + // One attempt consumes this host-side cancellation obligation even when a + // corrupt kernel reports an error. Retrying could consume a reused token. + error.targetCancelled = true; + try { + kernel.execTargetCancel(ownerPid, target); + } catch { + // Preserve the original precommit failure. The kernel entry/fatal boundary + // owns any exception raised while trying to release its retained target. + } + return error; +} + +export async function readPreparedExecTarget( + kernel: PreparedExecKernel, + ownerPid: number, + target: number, +): Promise { + try { + const size = kernel.execTargetSize(ownerPid, target); + if (size < 0n) { + throw new PreparedExecTargetError( + "prepared exec target size failed", + errnoFromNegativeResult(size), + ); + } + if (size > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new PreparedExecTargetError( + "prepared exec target size is not a safe JavaScript length", + EOVERFLOW, + ); + } + if (size > BigInt(MAX_REPORTABLE_TRANSFER_BYTES)) { + throw new PreparedExecTargetError( + "prepared exec target exceeds the program-size limit", + EFBIG, + ); + } + + let output: Uint8Array; + try { + // This is the sole target-sized allocation. Every kernel read is copied + // into a bounded view of this exact destination. + output = new Uint8Array(Number(size)); + } catch (cause) { + throw new PreparedExecTargetError( + cause instanceof Error + ? `unable to allocate prepared exec target: ${cause.message}` + : "unable to allocate prepared exec target", + ENOMEM, + ); + } + + let offset = 0n; + while (offset < size) { + const start = Number(offset); + const capacity = Math.min( + CH_DATA_SIZE, + output.byteLength - start, + ); + const destination = output.subarray(start, start + capacity); + const read = kernel.execTargetRead( + ownerPid, + target, + offset, + destination, + ); + if (read < 0) { + throw new PreparedExecTargetError( + "prepared exec target read failed", + errnoFromNegativeResult(read), + ); + } + if (!Number.isSafeInteger(read) || read === 0 || read > capacity) { + throw new PreparedExecTargetError( + "prepared exec target returned a non-progressing or oversized read", + EIO, + ); + } + offset += BigInt(read); + } + return output; + } catch (cause) { + throw cancelPreparedTarget( + kernel, + ownerPid, + target, + targetError(cause, "prepared exec target read failed"), + ); + } +} + +export interface PreparedExecLaunchRequest { + readonly pid: number; + diagnosticPath: string; + readonly argv: string[]; + readonly envp: string[]; + readonly targetBytes: ArrayBuffer; + readonly targetModule: WebAssembly.Module; +} + +/** Host work that becomes legal only after the shared launcher commits. */ +export interface PreparedExecLaunchPlan { + /** Release replacement resources when the kernel rejects the commit. */ + readonly onCommitFailure: (result?: number) => void; + /** Retire the old image and start the replacement without another commit. */ + readonly startAfterCommit: () => Promise; +} + +export type ExecLaunchCallback = ( + request: PreparedExecLaunchRequest, +) => Promise; + +export interface PreparedExecLaunchOptions { + readonly kernel: PreparedExecKernel; + readonly ownerPid: number; + readonly pid: number; + readonly callerTid: number; + readonly diagnosticPath: string; + readonly argv: string[]; + readonly envp: string[]; + readonly expectedAbi: number; + readonly materializePath: (diagnosticPath: string) => Promise; + readonly prepareInitialTarget: () => number; + readonly prepareInterpreterTarget: (interpreterPath: string) => number; + readonly commitTarget: (target: number, expectedSize: number) => number; +} + +function parseShebang(bytes: Uint8Array): { + interpreter: string; + argument?: string; +} | null { + if (bytes.byteLength < 2 || bytes[0] !== 0x23 || bytes[1] !== 0x21) { + return null; + } + let end = 2; + while ( + end < bytes.byteLength + && end < MAX_SHEBANG_LINE_BYTES + && bytes[end] !== 0x0a + ) { + end += 1; + } + const line = new TextDecoder() + .decode(bytes.subarray(2, end)) + .replace(/\r$/, "") + .trim(); + const match = /^(\S+)(?:\s+(.*))?$/.exec(line); + if (!match) return null; + return { interpreter: match[1]!, argument: match[2] }; +} + +function preparedTargetToken(result: number): number { + if (Number.isSafeInteger(result) && result > 0) return result; + throw new PreparedExecTargetError( + "prepared exec target creation failed", + result < 0 ? errnoFromNegativeResult(result) : EIO, + ); +} + +function exactArrayBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer as ArrayBuffer; +} + +export async function launchPreparedExecTarget( + options: PreparedExecLaunchOptions, + callback: ExecLaunchCallback, +): Promise { + await options.materializePath(options.diagnosticPath); + let target = preparedTargetToken(options.prepareInitialTarget()); + let bytes = await readPreparedExecTarget( + options.kernel, + options.ownerPid, + target, + ); + + let targetLive = true; + let launchArgv = [...options.argv]; + let finalDiagnosticPath = options.diagnosticPath; + const script = parseShebang(bytes); + if (script !== null) { + // Script set-ID state is deliberately never committed. Consume the script + // token before preparing the interpreter as the sole final authority. + targetLive = false; + const cancelResult = options.kernel.execTargetCancel( + options.ownerPid, + target, + ); + if (cancelResult < 0) { + throw new PreparedExecTargetError( + "unable to cancel prepared script target", + errnoFromNegativeResult(cancelResult), + true, + ); + } + launchArgv = [ + script.interpreter, + ...(script.argument ? [script.argument] : []), + options.diagnosticPath, + ...options.argv.slice(1), + ]; + finalDiagnosticPath = script.interpreter; + await options.materializePath(script.interpreter); + target = preparedTargetToken( + options.prepareInterpreterTarget(script.interpreter), + ); + bytes = await readPreparedExecTarget( + options.kernel, + options.ownerPid, + target, + ); + targetLive = true; + if (parseShebang(bytes) !== null) { + throw cancelPreparedTarget( + options.kernel, + options.ownerPid, + target, + new PreparedExecTargetError( + "the prepared shebang interpreter is itself a script", + ENOEXEC, + ), + ); + } + } + + try { + const targetBytes = exactArrayBuffer(bytes); + if (!isWasmModuleBytes(targetBytes)) { + throw new PreparedExecTargetError( + "prepared exec target is not a WebAssembly module", + ENOEXEC, + ); + } + const targetAbi = extractAbiVersion(targetBytes); + if ( + describeWasmArtifactPolicyFailures(targetBytes, { + expectedAbi: options.expectedAbi, + }).length > 0 + || (targetAbi !== null && targetAbi !== options.expectedAbi) + ) { + throw new PreparedExecTargetError( + "prepared exec target violates the artifact ABI policy", + ENOEXEC, + ); + } + + let targetModule: WebAssembly.Module; + try { + targetModule = await WebAssembly.compile(targetBytes); + } catch (cause) { + if (cause instanceof WebAssembly.CompileError) { + throw new PreparedExecTargetError( + "prepared exec target failed WebAssembly compilation", + ENOEXEC, + ); + } + throw cause; + } + + const request: PreparedExecLaunchRequest = { + pid: options.pid, + diagnosticPath: finalDiagnosticPath, + argv: launchArgv, + envp: [...options.envp], + targetBytes, + targetModule, + }; + const decision = await callback(request); + if (typeof decision === "number") { + targetLive = false; + options.kernel.execTargetCancel(options.ownerPid, target); + return decision < 0 ? decision : -EIO; + } + + // The opaque token never entered the async callback. The shared launcher + // alone owns this no-yield commit edge and invokes the postcommit action + // immediately after Rust consumes the token. + targetLive = false; + let commitResult: number; + try { + commitResult = options.commitTarget(target, targetBytes.byteLength); + } catch (cause) { + decision.onCommitFailure(); + throw cause; + } + if (commitResult < 0) { + decision.onCommitFailure(commitResult); + return commitResult; + } + return await decision.startAfterCommit(); + } catch (cause) { + if (targetLive) { + targetLive = false; + const error = targetError(cause, "prepared exec launch failed"); + throw cancelPreparedTarget( + options.kernel, + options.ownerPid, + target, + error, + ); + } + throw cause; + } +} diff --git a/host/src/generated/abi.ts b/host/src/generated/abi.ts index b55586c5f5..c471cab031 100644 --- a/host/src/generated/abi.ts +++ b/host/src/generated/abi.ts @@ -646,8 +646,11 @@ export const HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS = [ "kernel_create_process", "kernel_create_process_with_stdio", "kernel_dequeue_signal", - "kernel_exec_prepare", - "kernel_exec_setup_for_thread", + "kernel_exec_commit", + "kernel_exec_target_cancel", + "kernel_exec_target_prepare", + "kernel_exec_target_read", + "kernel_exec_target_size", "kernel_fork_process", "kernel_get_cwd", "kernel_get_dirfd_path", @@ -693,6 +696,8 @@ export const HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS = [ "kernel_set_current_tid", "kernel_set_cwd", "kernel_shmid_ds_bytes", + "kernel_spawn_exec_commit", + "kernel_spawn_exec_target_prepare", "kernel_spawn_process", "kernel_spawn_reserved_process", "kernel_spawn_scratch_begin", diff --git a/host/src/kernel-scratch.ts b/host/src/kernel-scratch.ts index 513e8188a3..b7a78bfb73 100644 --- a/host/src/kernel-scratch.ts +++ b/host/src/kernel-scratch.ts @@ -130,6 +130,8 @@ export const KERNEL_SCRATCH_EXPORT_NAMES = intrinsicObjectFreeze([ "kernel_drain_audio", "kernel_drain_wakeup_events", "kernel_enum_procs", + "kernel_exec_target_prepare", + "kernel_exec_target_read", "kernel_get_cwd", "kernel_get_dirfd_path", "kernel_get_fd_path", @@ -206,6 +208,7 @@ const REQUIRED_POINTER_1 = intrinsicObjectFreeze([1] as const); const REQUIRED_POINTER_2 = intrinsicObjectFreeze([2] as const); const REQUIRED_POINTER_3 = intrinsicObjectFreeze([3] as const); const REQUIRED_POINTER_3_5 = intrinsicObjectFreeze([3, 5] as const); +const REQUIRED_POINTER_4 = intrinsicObjectFreeze([4] as const); const REQUIRED_POINTER_5 = intrinsicObjectFreeze([5] as const); const REQUIRED_POINTER_11 = intrinsicObjectFreeze([11] as const); const NULLABLE_POINTER_1_3_5 = intrinsicObjectFreeze([1, 3, 5] as const); @@ -249,9 +252,12 @@ export function kernelScratchRequiredPointerArguments( case "kernel_tcsetattr": return REQUIRED_POINTER_2; case "kernel_process_metadata_stage": + case "kernel_exec_target_prepare": case "kernel_setsockopt": case "kernel_socketpair": return REQUIRED_POINTER_3; + case "kernel_exec_target_read": + return REQUIRED_POINTER_4; case "kernel_getsockopt": return REQUIRED_POINTER_3_5; case "kernel_wait_child_poll": @@ -296,6 +302,8 @@ function isKernelScratchExportName( case "kernel_drain_audio": case "kernel_drain_wakeup_events": case "kernel_enum_procs": + case "kernel_exec_target_prepare": + case "kernel_exec_target_read": case "kernel_get_cwd": case "kernel_get_dirfd_path": case "kernel_get_fd_path": diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index 99877dd72f..cb72c9387c 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -68,6 +68,12 @@ import { reapHostOwnedExitedProcess as reapHostOwnedExitedProcessFromKernel, type HostOwnedProcessReapResult, } from "./host-owned-process-reap"; +import { + launchPreparedExecTarget, + PreparedExecTargetError, + type ExecLaunchCallback, + type PreparedExecKernel, +} from "./exec-target"; import { buildRawHttpRequest, parseRawHttpResponse, @@ -2292,13 +2298,7 @@ export interface CentralizedKernelCallbacks { * new binary, and attach its channels to the existing kernel Process. * Returns 0 on success, negative errno on error. */ - onExec?: ( - pid: number, - path: string, - argv: string[], - envp: string[], - callerTid: number, - ) => Promise; + onExec?: ExecLaunchCallback; /** * Pre-flight resolution step for SYS_SPAWN. Returns the validated program @@ -8162,76 +8162,218 @@ export class CentralizedKernelWorker { } /** - * Validate the exec caller and apply deferred posix_spawn file actions. - * This is the fallible kernel preflight; no image-owned state is discarded. + * Prepare one exact executable target through Rust's process-relative path + * resolver. The path bytes exist only inside this entry-scoped lease. */ - kernelExecPrepare(pid: number, callerTid: number): number { + execTargetPrepare( + pid: number, + callerTid: number, + dirfd: number, + path: string, + flags: number, + ): number { if (this.#kernelFatalError !== null) throw this.#kernelFatalError; - if (this.#kernelEntryGate.shouldDeferVoidIngress) { - // Exec preflight returns an authoritative synchronous result. Queuing it - // would let the discarded caller continue before Rust validates it. - throw new KernelReentrantEntryError("kernel exec preparation"); + const encodedPath = new TextEncoder().encode(path); + if (encodedPath.byteLength > POSIX_PATH_MAX_BYTES) return -ENAMETOOLONG; + const region = this.#requireMainScratchRegion(); + if (encodedPath.byteLength > region.capacity) return -ENAMETOOLONG; + let result = -EIO; + let completed = false; + const deferred = this.#runOrDeferKernelEntry( + `kernel exec target prepare pid=${pid}`, + (entry) => { + const previousPid = this.currentHandlePid; + this.currentHandlePid = pid; + try { + result = region.withLease((lease) => { + lease.copyFrom(encodedPath); + return this.#invokeEntryScratchExport( + entry, + lease, + "kernel_exec_target_prepare", + [ + pid, + callerTid, + dirfd, + lease.exportPointer(0, encodedPath.byteLength), + encodedPath.byteLength, + flags, + ], + ); + }); + completed = true; + } finally { + this.currentHandlePid = previousPid; + } + return undefined; + }, + ); + if (deferred || !completed) { + throw new KernelReentrantEntryError("kernel exec target prepare"); } - let result = 0; + return result; + } + + execTargetSize(ownerPid: number, target: number): bigint { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + let result = -EIO as number | bigint; let completed = false; let missingExportError: Error | undefined; const deferred = this.#runOrDeferKernelEntry( - `kernel exec preparation pid=${pid}`, + `kernel exec target size pid=${ownerPid} target=${target}`, (entry) => { - const prepare = this.#kernelInstanceForEntry(entry).exports - .kernel_exec_prepare as - ((pid: number, callerTid: number) => number) | undefined; - if (!prepare) { + const size = this.#kernelInstanceForEntry(entry).exports + .kernel_exec_target_size as + ((ownerPid: number, target: number) => bigint) | undefined; + if (!size) { missingExportError = new Error( - "Kernel missing required kernel_exec_prepare export", + "Kernel missing required kernel_exec_target_size export", ); return undefined; } const previousPid = this.currentHandlePid; - this.currentHandlePid = pid; + this.currentHandlePid = ownerPid; try { - result = prepare(pid, callerTid); + result = size(ownerPid, target); + completed = true; + } finally { + this.currentHandlePid = previousPid; + } + return undefined; + }, + ); + if (missingExportError) throw missingExportError; + if (deferred || !completed || typeof result !== "bigint") { + throw new KernelReentrantEntryError("kernel exec target size"); + } + return result; + } + + execTargetRead( + ownerPid: number, + target: number, + offset: bigint, + destination: Uint8Array, + ): number { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + const exactDestination = intrinsicUint8ArrayView( + destination, + "prepared exec target destination", + ); + const region = this.#requireMainScratchRegion(); + if (exactDestination.byteLength > region.capacity) return -EOVERFLOW; + if (offset < 0n || offset > 0x7fff_ffff_ffff_ffffn) return -EOVERFLOW; + let result = -EIO; + let completed = false; + const deferred = this.#runOrDeferKernelEntry( + `kernel exec target read pid=${ownerPid} target=${target}`, + (entry) => { + const previousPid = this.currentHandlePid; + this.currentHandlePid = ownerPid; + try { + result = region.withLease((lease) => { + const read = this.#invokeEntryScratchExport( + entry, + lease, + "kernel_exec_target_read", + [ + ownerPid, + target, + Number(offset & 0xffff_ffffn), + Number((offset >> 32n) & 0xffff_ffffn), + lease.exportPointer(0, exactDestination.byteLength), + exactDestination.byteLength, + ], + ); + if (read > 0) { + const byteLength = this.#checkedScratchProducerByteLength( + read, + exactDestination.byteLength, + "kernel_exec_target_read", + ); + lease.copyTo(exactDestination, 0, 0, byteLength); + } + return read; + }); + completed = true; + } finally { + this.currentHandlePid = previousPid; + } + return undefined; + }, + ); + if (deferred || !completed) { + throw new KernelReentrantEntryError("kernel exec target read"); + } + return result; + } + + execTargetCancel(ownerPid: number, target: number): number { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + let result = -EIO; + let completed = false; + let missingExportError: Error | undefined; + const deferred = this.#runOrDeferKernelEntry( + `kernel exec target cancel pid=${ownerPid} target=${target}`, + (entry) => { + const cancel = this.#kernelInstanceForEntry(entry).exports + .kernel_exec_target_cancel as + ((ownerPid: number, target: number) => number) | undefined; + if (!cancel) { + missingExportError = new Error( + "Kernel missing required kernel_exec_target_cancel export", + ); + return undefined; + } + const previousPid = this.currentHandlePid; + this.currentHandlePid = ownerPid; + try { + result = cancel(ownerPid, target); completed = true; } finally { this.currentHandlePid = previousPid; } - // Deferred spawn actions can close descriptors and publish a Rust - // advisory-lock wake even when a later action makes prepare fail. this.#drainAndProcessWakeupEventsWithinKernelEntry(entry); return undefined; }, ); - if (missingExportError !== undefined) throw missingExportError; + if (missingExportError) throw missingExportError; if (deferred || !completed) { - throw new KernelReentrantEntryError("kernel exec preparation"); + throw new KernelReentrantEntryError("kernel exec target cancel"); } return result; } /** - * Run kernel-side exec setup: close CLOEXEC fds, reset signal handlers. + * Atomically commit the exact prepared target selected by `target`. * Returns 0 on success, negative errno on failure. - * Called by onExec callbacks after confirming the target program exists. + * Called only by the shared prepared-target launcher after the asynchronous + * host callback returns a bounded preflight plan. */ - kernelExecSetup(pid: number, callerTid: number): number { + kernelExecCommit( + pid: number, + callerTid: number, + target: number, + expectedSize?: number, + ): number { if (this.#kernelFatalError !== null) throw this.#kernelFatalError; if (this.#kernelEntryGate.shouldDeferVoidIngress) { // A successful setup commits exec in Rust. Its result cannot be queued // behind the caller that needs to decide whether the old image survives. - throw new KernelReentrantEntryError("kernel exec setup"); + throw new KernelReentrantEntryError("kernel exec commit"); } let result = 0; let completed = false; let missingExportError: Error | undefined; const deferred = this.#runOrDeferKernelEntry( - `kernel exec setup pid=${pid}`, + `kernel exec commit pid=${pid} target=${target}`, (entry) => { - const threadAware = this.#kernelInstanceForEntry(entry).exports - .kernel_exec_setup_for_thread as - ((pid: number, callerTid: number) => number) | undefined; - if (!threadAware) { + const commit = this.#kernelInstanceForEntry(entry).exports + .kernel_exec_commit as + ((pid: number, callerTid: number, target: number) => number) | undefined; + if (!commit) { missingExportError = new Error( - "Kernel missing required kernel_exec_setup_for_thread export", + "Kernel missing required kernel_exec_commit export", ); return undefined; } @@ -8241,14 +8383,46 @@ export class CentralizedKernelWorker { try { const listenerWakeSnapshot = this.#snapshotExecTcpListenerWakeIdsWithinKernelEntry(pid, entry); - result = threadAware(pid, callerTid); - completed = true; - if (result === 0) { - prunePlan = this.#prepareExecFdMirrorPruneWithinKernelEntry( - pid, - listenerWakeSnapshot, - entry, - ); + let leaseSizeMatches = true; + if (expectedSize !== undefined) { + const size = this.#kernelInstanceForEntry(entry).exports + .kernel_exec_target_size as + ((ownerPid: number, target: number) => bigint) | undefined; + if (!size) { + missingExportError = new Error( + "Kernel missing required kernel_exec_target_size export", + ); + return undefined; + } + const currentSize = size(pid, target); + if (currentSize !== BigInt(expectedSize)) { + leaseSizeMatches = false; + const cancel = this.#kernelInstanceForEntry(entry).exports + .kernel_exec_target_cancel as + ((ownerPid: number, target: number) => number) | undefined; + if (!cancel) { + missingExportError = new Error( + "Kernel missing required kernel_exec_target_cancel export", + ); + return undefined; + } + const cancelled = cancel(pid, target); + result = currentSize < 0n + ? Number(currentSize) + : cancelled < 0 ? cancelled : -EIO; + completed = true; + } + } + if (leaseSizeMatches) { + result = commit(pid, callerTid, target); + completed = true; + if (result === 0) { + prunePlan = this.#prepareExecFdMirrorPruneWithinKernelEntry( + pid, + listenerWakeSnapshot, + entry, + ); + } } } finally { this.currentHandlePid = previousPid; @@ -8271,7 +8445,7 @@ export class CentralizedKernelWorker { ); if (missingExportError !== undefined) throw missingExportError; if (deferred || !completed) { - throw new KernelReentrantEntryError("kernel exec setup"); + throw new KernelReentrantEntryError("kernel exec commit"); } return result; } @@ -8757,7 +8931,7 @@ export class CentralizedKernelWorker { } this.invalidateSharedMmapFdCacheForPid(pid); - // kernelExecSetup is the irreversible Rust commit. It has already drained + // kernelExecCommit is the irreversible Rust commit. It has already drained // the authoritative attachment records and decremented nattch; repeating // detach here would release a different same-segment attachment. this.shmMappings.delete(pid); @@ -9612,7 +9786,13 @@ export class CentralizedKernelWorker { // is authoritative for launch permission. this.stoppedPids.delete(pid); entry.deferProtocolTransactionStart(() => { - start(); + try { + start(); + } catch (error) { + cancel(); + if (onStartError?.(error) === true) return undefined; + throw error; + } return undefined; }); return "started"; @@ -11055,7 +11235,7 @@ export class CentralizedKernelWorker { // --- Intercept fork/exec/clone/exit before calling kernel --- // These syscalls need special async handling that can't go through - // direct kernel dispatch or the blocking host_exec import. + // direct kernel dispatch. if (syscallNr === SYS_FORK || syscallNr === SYS_VFORK) { if (logging) console.error(logEntry); @@ -22401,6 +22581,56 @@ export class CentralizedKernelWorker { ); } + async #launchPreparedExec( + pid: number, + callerTid: number, + dirfd: number, + authorityPath: string, + flags: number, + diagnosticPath: string, + argv: string[], + envp: string[], + ): Promise { + const callback = this.callbacks.onExec; + if (!callback) return -ENOSYS; + try { + return await launchPreparedExecTarget({ + kernel: this as PreparedExecKernel, + ownerPid: pid, + pid, + callerTid, + diagnosticPath, + argv, + envp, + expectedAbi: this.getKernelAbiVersion(), + materializePath: async (path) => { + await this.io.preparePath?.(path); + }, + prepareInitialTarget: () => + this.execTargetPrepare( + pid, + callerTid, + dirfd, + authorityPath, + flags, + ), + prepareInterpreterTarget: (interpreterPath) => + this.execTargetPrepare( + pid, + callerTid, + AT_FDCWD, + interpreterPath, + 0, + ), + commitTarget: (target, expectedSize) => + this.kernelExecCommit(pid, callerTid, target, expectedSize), + }, callback); + } catch (error) { + if (error instanceof PreparedExecTargetError) return -error.errno; + throw error; + } + } + /** * Handle SYS_EXECVE: read path, argv, and envp from process memory, * then call the onExec callback to load the new program. @@ -22429,7 +22659,8 @@ export class CentralizedKernelWorker { ); return; } - let path = pathResult.value; + const authorityPath = pathResult.value; + let path = authorityPath; const argvResult = this.readStringArrayFromProcess( processMem, origArgs[1], @@ -22538,7 +22769,16 @@ export class CentralizedKernelWorker { let transaction: Promise; try { transaction = this.#resolvePromise( - this.callbacks.onExec!(pid, path, argv, envp, callerTid), + this.#launchPreparedExec( + pid, + callerTid, + AT_FDCWD, + authorityPath, + 0, + path, + argv, + envp, + ), ); } catch (cause) { this.#runOrDeferChannelKernelEntry( @@ -22791,12 +23031,15 @@ export class CentralizedKernelWorker { let transaction: Promise; try { transaction = this.#resolvePromise( - this.callbacks.onExec!( + this.#launchPreparedExec( pid, + callerTid, + dirfd, + pathStr, + flags, execPath, argv, envp, - callerTid, ), ); } catch (cause) { diff --git a/host/src/kernel.ts b/host/src/kernel.ts index 41b7d57bfe..860feb8429 100644 --- a/host/src/kernel.ts +++ b/host/src/kernel.ts @@ -14,6 +14,7 @@ * env.host_seek(handle: i64, offset_lo, offset_hi, whence) -> i64 * env.host_fstat(handle: i64, stat_ptr) -> i32 * env.host_statfs(path_ptr, path_len, statfs_ptr) -> i32 + * env.host_fstatfs(handle, statfs_ptr) -> i32 * * IMPORTANT: Wasm i64 values appear as BigInt in JavaScript. */ @@ -770,7 +771,6 @@ const WASM_STATFS_SIZE = STRUCT_SIZE_WASM_STATFS; const WASM_DIRENT_SIZE = STRUCT_SIZE_WASM_DIRENT; export interface KernelCallbacks { - onExec?: (path: string) => number; onAlarm?: (seconds: number) => number; onPosixTimer?: (timerId: number, signo: number, valueMs: number, intervalMs: number) => number; onWaitpid?: (targetPid: number, options: number) => void; @@ -1634,6 +1634,20 @@ export class WasmPosixKernel { return -14; // EFAULT } }, + host_fstatfs: (handle: bigint, statfsPtr: KernelPointer): number => { + try { + return this.#hostFstatfs( + handle, + this.#rustLentKernelDestination( + statfsPtr, + WASM_STATFS_SIZE, + "host_fstatfs destination", + ), + ); + } catch { + return -14; // EFAULT + } + }, host_pathconf: (pathPtr: KernelPointer, pathLen: number, name: number, valuePtr: KernelPointer): number => { try { return this.#hostPathconf( @@ -1769,9 +1783,6 @@ export class WasmPosixKernel { host_fchown: (handle: bigint, uid: number, gid: number): number => { return this.#hostFchown(handle, uid, gid); }, - host_exec: (pathPtr: KernelPointer, pathLen: number): number => { - return this.#hostExec(pathPtr, pathLen); - }, host_set_alarm: (seconds: number): number => { return this.#hostSetAlarm(seconds); }, @@ -3103,6 +3114,20 @@ export class WasmPosixKernel { } } + #hostFstatfs( + handle: bigint, + destination: RustLentKernelDestination, + ): number { + if (!this.io.fstatfs) return -38; // ENOSYS + try { + const statfs = this.io.fstatfs(Number(handle)); + this.#writeStatfsToMemory(destination, statfs); + return 0; + } catch (e) { + return negErrno(e); + } + } + #hostPathconf( pathPtr: KernelPointer, pathLen: number, @@ -3646,22 +3671,6 @@ export class WasmPosixKernel { } } - // ---- Phase 13e: Exec ---- - - #hostExec(pathPtr: KernelPointer, pathLen: number): number { - if (this.callbacks.onExec) { - try { - const path = new TextDecoder().decode( - this.#readKernelBytes(pathPtr, pathLen), - ); - return this.callbacks.onExec(path); - } catch (error) { - return negErrno(error); - } - } - return -2; // -ENOENT - } - // ---- Phase 14: Alarm ---- #hostSetAlarm(seconds: number): number { diff --git a/host/src/node-kernel-host.ts b/host/src/node-kernel-host.ts index 497dfb5273..1e4830f56b 100644 --- a/host/src/node-kernel-host.ts +++ b/host/src/node-kernel-host.ts @@ -87,12 +87,15 @@ export interface NodeKernelHostOptions { * Increase for programs that do large pwrite() calls (e.g. InnoDB). */ dataBufferSize?: number; /** - * Virtual path → immutable host filesystem generation for exec resolution - * inside the worker. + * Virtual path → immutable host filesystem generation for the Task 12 spawn + * preflight. Exec does not consult this map; its authority is an executable + * already present in the kernel-owned VFS. */ execPrograms?: Record; /** - * Virtual path → exact program bytes for pre-VFS exec resolution. + * Virtual path → exact program bytes for the Task 12 spawn preflight. Exec + * does not consult this map; its authority is an executable already present + * in the kernel-owned VFS. * * Ordinary ArrayBuffer-backed bytes are copied during init and owned by the * worker for its complete lifetime; concurrently mutable SharedArrayBuffer diff --git a/host/src/node-kernel-protocol.ts b/host/src/node-kernel-protocol.ts index b5613b2d87..be3b485482 100644 --- a/host/src/node-kernel-protocol.ts +++ b/host/src/node-kernel-protocol.ts @@ -38,9 +38,15 @@ export interface InitMessage { dataBufferSize?: number; useSharedMemory?: boolean; }; - /** Virtual path → immutable host filesystem generation for exec resolution. */ + /** + * Virtual path → immutable host file for spawn-only preflight. Exec never + * consults this map and uses only a retained kernel VFS target. + */ execPrograms?: Record; - /** Virtual path → worker-owned exact program bytes for pre-VFS resolution. */ + /** + * Virtual path → worker-owned bytes for spawn-only preflight through Task + * 12. Exec never consults this map. + */ execProgramBytes?: Record; /** * Bytes of `host/wasm/rootfs.vfs`, read on the main thread and forwarded diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts index c20f8de881..3eae7f583d 100644 --- a/host/src/node-kernel-worker-entry.ts +++ b/host/src/node-kernel-worker-entry.ts @@ -54,6 +54,10 @@ import { TcpNetworkBackend } from "./networking/tcp-backend"; import { findRepoRoot } from "./binary-resolver"; import { NodeWorkerAdapter } from "./worker-adapter"; import { DeferredWorkerHandle } from "./deferred-worker-handle"; +import type { + PreparedExecLaunchPlan, + PreparedExecLaunchRequest, +} from "./exec-target"; import { ThreadPageAllocator } from "./thread-allocator"; import { patchWasmForThread } from "./worker-main"; import { ThreadExitCoordinator } from "./thread-exit-coordinator"; @@ -192,6 +196,7 @@ let vfsExecIO: PlatformIO | null = null; let rootfsMemfs: MemoryFileSystem | null = null; let initReady = false; let kernelFatalReported = false; +let injectedExecWorkerConstructionFailure = false; /** Per-boot scratch directory; cleaned up on `destroy`. Only set when the * worker constructs a `VirtualPlatformIO` from the default mount spec. */ let sessionDir: string | null = null; @@ -1004,6 +1009,7 @@ function cleanupSessionDir(): void { async function handleInit(msg: InitMessage) { initReady = false; + injectedExecWorkerConstructionFailure = false; maxPages = msg.config.maxPages ?? DEFAULT_MAX_PAGES; defaultThreadSlots = msg.config.defaultThreadSlots ?? DEFAULT_PROCESS_THREAD_SLOTS; processMemoryAllocator = new ProcessMemoryAllocator({ @@ -1093,13 +1099,18 @@ async function handleInit(msg: InitMessage) { () => launch(), ); }, - onExec: (pid, path, argv, envp, callerTid) => - processMemoryCreators.run("an exec process Worker", async () => { + onExec: async (request) => { + const creatorAdmission = processMemoryCreators.acquire( + "an exec process Worker", + ); + try { + const { pid } = request; const execGeneration = processes.get(pid); const previousWorker = execGeneration?.worker; - const result = await handleExec(pid, path, argv, envp, callerTid); + const result = await handleExec(request); if ( - result < 0 + typeof result === "number" + && result < 0 && execGeneration && processes.get(pid) === execGeneration && vforkLifetimes.isActiveBorrower(execGeneration) @@ -1109,21 +1120,53 @@ async function handleInit(msg: InitMessage) { // ends the shared-address-space lifetime. vforkLifetimes.noteFailedExec(execGeneration, -result); } - // Notify after handleExec refreshes kernel-side Process.argv so - // process-table consumers don't refetch stale command names. A - // post-commit signal death also returns 0 because the old syscall - // can no longer return; only emit exec when a replacement exists. - const installedWorker = processes.get(pid)?.worker; - if ( - result === 0 - && installedWorker - && installedWorker !== previousWorker - && kernelWorker.isProcessExecutionActive(pid) - ) { - post({ type: "proc_event", kind: "exec", pid }); + if (typeof result === "number") { + creatorAdmission.release(); + return result; } - return result; - }), + + let planState: "ready" | "settled" = "ready"; + return { + onCommitFailure: (commitResult?: number) => { + if (planState !== "ready") return; + planState = "settled"; + try { + result.onCommitFailure(commitResult); + } finally { + creatorAdmission.release(); + } + }, + startAfterCommit: async () => { + if (planState !== "ready") { + throw new Error("exec replacement plan already settled"); + } + planState = "settled"; + try { + const startResult = await result.startAfterCommit(); + // Notify after handleExec refreshes kernel-side Process.argv so + // process-table consumers don't refetch stale command names. A + // post-commit signal death also returns 0 because the old syscall + // can no longer return; only emit exec when a replacement exists. + const installedWorker = processes.get(pid)?.worker; + if ( + startResult === 0 + && installedWorker + && installedWorker !== previousWorker + && kernelWorker.isProcessExecutionActive(pid) + ) { + post({ type: "proc_event", kind: "exec", pid }); + } + return startResult; + } finally { + creatorAdmission.release(); + } + }, + } satisfies PreparedExecLaunchPlan; + } catch (error) { + creatorAdmission.release(); + throw error; + } + }, onResolveSpawn: handlePosixSpawnResolve, onSpawn: (parentPid, childPid, program, envp) => processMemoryCreators.run( @@ -2168,19 +2211,18 @@ async function handleOrdinaryFork( } async function handleExec( - pid: number, - path: string, - argv: string[], - envp: string[], - callerTid: number, -): Promise { + request: PreparedExecLaunchRequest, +): Promise { + const { + pid, + targetBytes: programBytes, + targetModule: programModule, + argv: launchArgv, + envp, + } = request; const initiatingInfo = processes.get(pid); if (!initiatingInfo) return -3; // ESRCH const vforkBorrower = vforkLifetimes.isActiveBorrower(initiatingInfo); - const resolved = await resolveExecutableForLaunch(path, argv); - if (!resolved) return -2; // ENOENT - if ("errno" in resolved) return -resolved.errno; - const { programBytes, programModule, argv: launchArgv } = resolved; const newPtrWidth = detectPtrWidth(programBytes); const metadataResult = kernelWorker.validateExecMetadata( launchArgv, @@ -2215,11 +2257,6 @@ async function handleExec( prepared.memoryLease.release(); return -3; // ESRCH } - const prepareResult = kernelWorker.kernelExecPrepare(pid, callerTid); - if (prepareResult < 0) { - prepared.memoryLease.release(); - return prepareResult; - } const addressSpaceResult = kernelWorker.prepareAddressSpaceForExec(pid); if (addressSpaceResult < 0) { prepared.memoryLease.release(); @@ -2228,306 +2265,373 @@ async function handleExec( let replacementWorker: ReturnType | undefined; let replacementExternrefGeneration: ForkExternrefGeneration | undefined; let replacementForkHostImports: ForkHostImportOwnerWorker | undefined; - try { - const setupResult = kernelWorker.kernelExecSetup(pid, callerTid); - if (setupResult < 0) { + let launchPlanState: "ready" | "discarded" | "started" = "ready"; + const onCommitFailure = (commitResult?: number): void => { + if (launchPlanState !== "ready") return; + launchPlanState = "discarded"; + try { prepared.memoryLease.release(); - return setupResult; + preparedLeaseConsumed = true; + } catch { + // Preserve the kernel's authoritative commit result. } - vmInterruptTimers.clear(pid, initiatingInfo); - - // Wake the exact old execution generation through the internal exec - // retirement path. worker-main returns without exiting the persistent - // kernel process, then worker-entry publishes both exec_retired and - // memory_quiescent. Those messages are the only proof that the old realm - // stopped using its Shared Memory; Worker.terminate() alone is not such a - // fence on every Node-compatible engine. - if (initiatingInfo.worker) { - intentionallyTerminated.add(initiatingInfo.worker as object); + if ( + commitResult !== undefined + && commitResult < 0 + && vforkBorrower + && vforkLifetimes.phaseForChild(initiatingInfo) !== undefined + ) { + vforkLifetimes.noteFailedExec(initiatingInfo, -commitResult); } - for (const thread of threadWorkers.get(pid) ?? []) { - intentionallyTerminated.add(thread.worker as object); + }; + const startAfterCommit = async (): Promise => { + if (launchPlanState !== "ready") { + throw new Error(`Exec launch plan for pid ${pid} was already consumed`); } - const retiredOffsets = - kernelWorker.wakeProcessWorkersForExecRetirement( - pid, - initiatingInfo.memory, + launchPlanState = "started"; + try { + vmInterruptTimers.clear(pid, initiatingInfo); + + // Wake the exact old execution generation through the internal exec + // retirement path. worker-main returns without exiting the persistent + // kernel process, then worker-entry publishes both exec_retired and + // memory_quiescent. Those messages are the only proof that the old realm + // stopped using its Shared Memory; Worker.terminate() alone is not such a + // fence on every Node-compatible engine. + if (initiatingInfo.worker) { + intentionallyTerminated.add(initiatingInfo.worker as object); + } + for (const thread of threadWorkers.get(pid) ?? []) { + intentionallyTerminated.add(thread.worker as object); + } + const retiredOffsets = + kernelWorker.wakeProcessWorkersForExecRetirement( + pid, + initiatingInfo.memory, + ); + const mainRetirementStarted = retiredOffsets.has( + initiatingInfo.channelOffset, + ); + if (!kernelWorker.prepareProcessForExec(pid, initiatingInfo.memory)) { + throw new Error(`Exec pid ${pid} changed generation during commit`); + } + replacementExternrefGeneration = externrefProcessOwner.replaceGeneration( + initiatingInfo.externrefGeneration, ); - const mainRetirementStarted = retiredOffsets.has( - initiatingInfo.channelOffset, - ); - if (!kernelWorker.prepareProcessForExec(pid, initiatingInfo.memory)) { - throw new Error(`Exec pid ${pid} changed generation during commit`); - } - replacementExternrefGeneration = externrefProcessOwner.replaceGeneration( - initiatingInfo.externrefGeneration, - ); - const finalizeResult = kernelWorker.finalizeAddressSpaceForExec(pid); - if (finalizeResult < 0) { - throw new Error("failed to detach the discarded address space"); - } + const finalizeResult = kernelWorker.finalizeAddressSpaceForExec(pid); + if (finalizeResult < 0) { + throw new Error("failed to detach the discarded address space"); + } - const [mainQuiescent, threadsQuiescent] = await Promise.all([ - mainRetirementStarted - ? waitForExecRetirement( - initiatingInfo.execRetirement, - initiatingInfo.workerQuiescence, - EXEC_WORKER_RETIREMENT_WAIT_MS, - ) - : Promise.resolve(false), - terminateThreadWorkers(pid, true), - ]); - oldMemoryRetirementSafe = mainQuiescent && threadsQuiescent; - if (initiatingInfo.worker) { - await terminateTrackedWorker(initiatingInfo.worker); - } - if (mainQuiescent) { - // Thread fences retire their own exact listeners during slot reclaim. - // Settle the main listener separately so one unresponsive sibling does - // not retain an otherwise quiescent generation. - await kernelWorker.settleRetiredChannelListeners( + const [mainQuiescent, threadsQuiescent] = await Promise.all([ + mainRetirementStarted + ? waitForExecRetirement( + initiatingInfo.execRetirement, + initiatingInfo.workerQuiescence, + EXEC_WORKER_RETIREMENT_WAIT_MS, + ) + : Promise.resolve(false), + terminateThreadWorkers(pid, true), + ]); + oldMemoryRetirementSafe = mainQuiescent && threadsQuiescent; + if (initiatingInfo.worker) { + await terminateTrackedWorker(initiatingInfo.worker); + } + if (mainQuiescent) { + // Thread fences retire their own exact listeners during slot reclaim. + // Settle the main listener separately so one unresponsive sibling does + // not retain an otherwise quiescent generation. + await kernelWorker.settleRetiredChannelListeners( + pid, + initiatingInfo.memory, + initiatingInfo.channelOffset, + ); + } + const handoffExitSignal = + kernelWorker.finalizeExecHandoffTermination(pid); + if (handoffExitSignal > 0) { + prepared.memoryLease.release(); + preparedLeaseConsumed = true; + externrefProcessOwner.releaseGeneration( + replacementExternrefGeneration, + ); + replacementExternrefGeneration = undefined; + await awaitFinalizedProcessTeardown( + pid, + signalExitStatus(handoffExitSignal), + initiatingInfo.worker, + "signal", + ); + return 0; + } + + const { + memory: newMemory, + memoryLease: newMemoryLease, + layout: newLayout, + threadAllocator: newThreadAllocator, + } = prepared; + const newChannelOffset = newLayout.channelOffset; + replacementForkHostImports = forkHostImportOwnerRuntime.createWorker({ pid, - initiatingInfo.memory, - initiatingInfo.channelOffset, - ); - } - const handoffExitSignal = - kernelWorker.finalizeExecHandoffTermination(pid); - if (handoffExitSignal > 0) { - prepared.memoryLease.release(); - preparedLeaseConsumed = true; - externrefProcessOwner.releaseGeneration( - replacementExternrefGeneration, - ); - replacementExternrefGeneration = undefined; - await awaitFinalizedProcessTeardown( + generationId: replacementExternrefGeneration.id, + authorizeSender: () => { + const current = processes.get(pid); + if ( + !replacementWorker + || !current + || current.worker !== replacementWorker + || current.externrefGeneration !== replacementExternrefGeneration + ) { + throw new Error(`stale fork host-import sender for exec pid=${pid}`); + } + }, + }); + + const initData: CentralizedWorkerInitMessage = { + type: "centralized_init", pid, - signalExitStatus(handoffExitSignal), - initiatingInfo.worker, - "signal", - ); - return 0; - } + programBytes, + programModule, + memory: newMemory, + channelOffset: newChannelOffset, + externrefGenerationId: replacementExternrefGeneration.id, + forkHostImports: replacementForkHostImports.init, + argv: launchArgv, + env: envp, + ptrWidth: newPtrWidth, + kernelAbiVersion: kernelWorker.getKernelAbiVersion(), + }; - const { - memory: newMemory, - memoryLease: newMemoryLease, - layout: newLayout, - threadAllocator: newThreadAllocator, - } = prepared; - const newChannelOffset = newLayout.channelOffset; - replacementForkHostImports = forkHostImportOwnerRuntime.createWorker({ - pid, - generationId: replacementExternrefGeneration.id, - authorizeSender: () => { - const current = processes.get(pid); + replacementWorker = new DeferredWorkerHandle(() => { if ( - !replacementWorker - || !current - || current.worker !== replacementWorker - || current.externrefGeneration !== replacementExternrefGeneration + ( + process.env.KANDELO_TEST_EXEC_WORKER_CONSTRUCTION_FAILURE === "once" + || envp.includes( + "KANDELO_TEST_EXEC_WORKER_CONSTRUCTION_FAILURE=once", + ) + ) + && !injectedExecWorkerConstructionFailure ) { - throw new Error(`stale fork host-import sender for exec pid=${pid}`); + injectedExecWorkerConstructionFailure = true; + throw new Error("injected exec Worker construction failure"); } - }, - }); - - const initData: CentralizedWorkerInitMessage = { - type: "centralized_init", - pid, - programBytes, - programModule, - memory: newMemory, - channelOffset: newChannelOffset, - externrefGenerationId: replacementExternrefGeneration.id, - forkHostImports: replacementForkHostImports.init, - argv: launchArgv, - env: envp, - ptrWidth: newPtrWidth, - kernelAbiVersion: kernelWorker.getKernelAbiVersion(), - }; - - replacementWorker = new DeferredWorkerHandle( - () => workerAdapter.createWorker(initData), - ); - kernelWorker.registerProcess(pid, newMemory, [newChannelOffset], { - preserveProcessState: true, - ptrWidth: newPtrWidth, - metadataPtrWidth: initiatingInfo.ptrWidth, - brkBase: newLayout.brkBase, - mmapBase: newLayout.mmapBase, - maxAddr: newLayout.maxAddr, - // Refresh kernel-side Process.argv and environment so procfs and - // kernel APIs reflect the replacement image. - argv: launchArgv, - env: envp, - }); - replacementRegistered = true; - bindForkHostImports(replacementWorker, replacementForkHostImports); + return workerAdapter.createWorker(initData); + }); + kernelWorker.registerProcess(pid, newMemory, [newChannelOffset], { + preserveProcessState: true, + ptrWidth: newPtrWidth, + metadataPtrWidth: initiatingInfo.ptrWidth, + brkBase: newLayout.brkBase, + mmapBase: newLayout.mmapBase, + maxAddr: newLayout.maxAddr, + // Refresh kernel-side Process.argv and environment so procfs and + // kernel APIs reflect the replacement image. + argv: launchArgv, + env: envp, + }); + replacementRegistered = true; + bindForkHostImports(replacementWorker, replacementForkHostImports); - // Clear thread module cache — new program binary is different - threadModuleCache.delete(pid); + // Clear thread module cache — new program binary is different + threadModuleCache.delete(pid); - processes.set(pid, { - memory: newMemory, - memoryLease: newMemoryLease, - workerQuiescence: createWorkerQuiescence(), - execRetirement: createWorkerQuiescence(), - programBytes, - programModule, - worker: replacementWorker, - channelOffset: newChannelOffset, - ptrWidth: newPtrWidth, - layout: newLayout, - threadAllocator: newThreadAllocator, - externrefGeneration: replacementExternrefGeneration, - }); - preparedTransferred = true; + processes.set(pid, { + memory: newMemory, + memoryLease: newMemoryLease, + workerQuiescence: createWorkerQuiescence(), + execRetirement: createWorkerQuiescence(), + programBytes, + programModule, + worker: replacementWorker, + channelOffset: newChannelOffset, + ptrWidth: newPtrWidth, + layout: newLayout, + threadAllocator: newThreadAllocator, + externrefGeneration: replacementExternrefGeneration, + }); + preparedTransferred = true; - // WHY: only terminal messages from every old Worker prove that no realm - // can still touch this address space. A timeout uses forced retirement, - // which drops the kernel alias but never recycles the backing. - if (oldMemoryRetirementSafe) initiatingInfo.memoryLease.release(); - else initiatingInfo.memoryLease.releaseAfterForcedTermination(); - initiatingLeaseConsumed = true; + // WHY: only terminal messages from every old Worker prove that no realm + // can still touch this address space. A timeout uses forced retirement, + // which drops the kernel alias but never recycles the backing. + if (oldMemoryRetirementSafe) initiatingInfo.memoryLease.release(); + else initiatingInfo.memoryLease.releaseAfterForcedTermination(); + initiatingLeaseConsumed = true; - installProcessWorkerListeners( - replacementWorker, - pid, - "exec worker error", - ); - const startDisposition = kernelWorker.startProcessWorkerWhenRunnable( - pid, - newMemory, - () => { (replacementWorker as DeferredWorkerHandle).start(); }, - () => { - replacementForkHostImports?.close(); - void replacementWorker?.terminate(); - }, - ); - if (startDisposition === "stale") { - throw new Error(`Exec pid ${pid} changed generation before Worker launch`); - } - if (startDisposition === "dead") { - replacementForkHostImports.close(); - await terminateTrackedWorker(replacementWorker); - kernelWorker.finishProcessExecHandoff(pid); - const signal = kernelWorker.finalizeExecHandoffTermination(pid); - if (vforkBorrower) { - completeVforkGenerationTeardown( - initiatingInfo, - oldMemoryRetirementSafe, - "exec", - new Error( - `vfork child ${pid} exec retired without an exact old-memory fence`, - ), - ); - } - await awaitFinalizedProcessTeardown( - pid, - signal > 0 ? signalExitStatus(signal) : 0, + installProcessWorkerListeners( replacementWorker, - signal > 0 ? "signal" : "exit", - ); - return 0; - } - kernelWorker.finishProcessExecHandoff(pid); - if (vforkBorrower) { - completeVforkGenerationTeardown( - initiatingInfo, - oldMemoryRetirementSafe, - "exec", - new Error( - `vfork child ${pid} exec retired without an exact old-memory fence`, - ), + pid, + "exec worker error", ); - } - return 0; - } catch (err) { - replacementForkHostImports?.close(); - if (replacementExternrefGeneration) { - externrefProcessOwner.releaseGeneration(replacementExternrefGeneration); - replacementExternrefGeneration = undefined; - } - // A kernel trap can leave the commit point uncertain. We cannot safely - // return to the caller, so invalidate the old generation before yielding - // and report a truthful signal death. - if (initiatingInfo.worker) { - intentionallyTerminated.add(initiatingInfo.worker as object); - } - try { - const failedGenerationMemory = - preparedTransferred || replacementRegistered - ? prepared.memoryLease.memory - : initiatingInfo.memory; - kernelWorker.prepareProcessForExec(pid, failedGenerationMemory); - } catch { - // Continue with best-effort process death below. - } - if (replacementWorker && processes.get(pid)?.worker !== replacementWorker) { - await terminateTrackedWorker(replacementWorker); - } - if (!preparedTransferred && !preparedLeaseConsumed) { - const replacementGeneration = { - memory: prepared.memoryLease.memory, - memoryLease: prepared.memoryLease, - }; - const detachResult = await detachExactProcessGeneration({ + const startDisposition = kernelWorker.startProcessWorkerWhenRunnable( pid, - generation: replacementGeneration, - operation: replacementRegistered ? "deactivate" : "none", - // A non-transferred DeferredWorker was never started, so this - // replacement still has exact single-realm ownership. - retire: (commit) => { - prepared.memoryLease.release(); - commit(); + newMemory, + () => { + if (!(replacementWorker as DeferredWorkerHandle).start()) { + throw new Error(`Exec replacement Worker for pid ${pid} was cancelled`); + } + if ( + vforkBorrower + && vforkLifetimes.phaseForChild(initiatingInfo) !== undefined + ) { + completeVforkGenerationTeardown( + initiatingInfo, + oldMemoryRetirementSafe && initiatingLeaseConsumed, + "exec", + new Error( + `vfork child ${pid} exec retired without an exact old-memory fence`, + ), + ); + } }, - }); - if (detachResult.status === "released") { - preparedLeaseConsumed = true; - } else { - reportRetainedProcessGeneration( + () => { + replacementForkHostImports?.close(); + void replacementWorker?.terminate(); + }, + (error) => { + if ( + vforkBorrower + && vforkLifetimes.phaseForChild(initiatingInfo) !== undefined + ) { + completeVforkGenerationTeardown( + initiatingInfo, + oldMemoryRetirementSafe && initiatingLeaseConsumed, + "trap", + error, + ); + } + const message = error instanceof Error ? error.message : String(error); + reportHostDiagnostic({ + pid, + status: signalExitStatus(SIGSEGV), + source: "exec post-commit transition", + message: `[exec] post-commit transition failed: ${message}`, + }); + void finalizeProcessWorker( + pid, + replacementWorker as DeferredWorkerHandle, + signalExitStatus(SIGSEGV), + SIGSEGV, + ); + return true; + }, + ); + if (startDisposition === "stale") { + throw new Error(`Exec pid ${pid} changed generation before Worker launch`); + } + if (startDisposition === "dead") { + replacementForkHostImports.close(); + await terminateTrackedWorker(replacementWorker); + kernelWorker.finishProcessExecHandoff(pid); + const signal = kernelWorker.finalizeExecHandoffTermination(pid); + if (vforkBorrower) { + completeVforkGenerationTeardown( + initiatingInfo, + oldMemoryRetirementSafe, + "exec", + new Error( + `vfork child ${pid} exec retired without an exact old-memory fence`, + ), + ); + } + await awaitFinalizedProcessTeardown( pid, - "exec replacement rollback", - detachResult, - signalExitStatus(SIGSEGV), + signal > 0 ? signalExitStatus(signal) : 0, + replacementWorker, + signal > 0 ? "signal" : "exit", + ); + return 0; + } + kernelWorker.finishProcessExecHandoff(pid); + return 0; + } catch (err) { + replacementForkHostImports?.close(); + if (replacementExternrefGeneration) { + externrefProcessOwner.releaseGeneration(replacementExternrefGeneration); + replacementExternrefGeneration = undefined; + } + // A kernel trap can leave the commit point uncertain. We cannot safely + // return to the caller, so invalidate the old generation before yielding + // and report a truthful signal death. + if (initiatingInfo.worker) { + intentionallyTerminated.add(initiatingInfo.worker as object); + } + try { + const failedGenerationMemory = + preparedTransferred || replacementRegistered + ? prepared.memoryLease.memory + : initiatingInfo.memory; + kernelWorker.prepareProcessForExec(pid, failedGenerationMemory); + } catch { + // Continue with best-effort process death below. + } + if (replacementWorker && processes.get(pid)?.worker !== replacementWorker) { + await terminateTrackedWorker(replacementWorker); + } + if (!preparedTransferred && !preparedLeaseConsumed) { + const replacementGeneration = { + memory: prepared.memoryLease.memory, + memoryLease: prepared.memoryLease, + }; + const detachResult = await detachExactProcessGeneration({ + pid, + generation: replacementGeneration, + operation: replacementRegistered ? "deactivate" : "none", + // A non-transferred DeferredWorker was never started, so this + // replacement still has exact single-realm ownership. + retire: (commit) => { + prepared.memoryLease.release(); + commit(); + }, + }); + if (detachResult.status === "released") { + preparedLeaseConsumed = true; + } else { + reportRetainedProcessGeneration( + pid, + "exec replacement rollback", + detachResult, + signalExitStatus(SIGSEGV), + ); + } + } + if (preparedTransferred && !initiatingLeaseConsumed) { + if (oldMemoryRetirementSafe) initiatingInfo.memoryLease.release(); + else initiatingInfo.memoryLease.releaseAfterForcedTermination(); + initiatingLeaseConsumed = true; + } + if ( + vforkBorrower + && preparedTransferred + && vforkLifetimes.phaseForChild(initiatingInfo) !== undefined + ) { + completeVforkGenerationTeardown( + initiatingInfo, + oldMemoryRetirementSafe && initiatingLeaseConsumed, + "trap", + err, ); } - } - if (preparedTransferred && !initiatingLeaseConsumed) { - if (oldMemoryRetirementSafe) initiatingInfo.memoryLease.release(); - else initiatingInfo.memoryLease.releaseAfterForcedTermination(); - initiatingLeaseConsumed = true; - } - if ( - vforkBorrower - && preparedTransferred - && vforkLifetimes.phaseForChild(initiatingInfo) !== undefined - ) { - completeVforkGenerationTeardown( - initiatingInfo, - oldMemoryRetirementSafe && initiatingLeaseConsumed, - "trap", - err, - ); - } - const message = err instanceof Error ? err.message : String(err); - try { - reportHostDiagnostic({ - pid, - status: signalExitStatus(SIGSEGV), - source: "exec post-commit transition", - message: `[exec] post-commit transition failed: ${message}`, - }); - } catch { - // A closed host port must not prevent kernel-side reap. + const message = err instanceof Error ? err.message : String(err); + try { + reportHostDiagnostic({ + pid, + status: signalExitStatus(SIGSEGV), + source: "exec post-commit transition", + message: `[exec] post-commit transition failed: ${message}`, + }); + } catch { + // A closed host port must not prevent kernel-side reap. + } + try { kernelWorker.notifyHostProcessCrashed(pid, SIGSEGV); } catch { /* best-effort */ } + handleExit(pid, signalExitStatus(SIGSEGV)); + return 0; } - try { kernelWorker.notifyHostProcessCrashed(pid, SIGSEGV); } catch { /* best-effort */ } - handleExit(pid, signalExitStatus(SIGSEGV)); - return 0; - } + }; + return { onCommitFailure, startAfterCommit }; } /** @@ -2547,10 +2651,10 @@ async function handleExec( */ /** * Pre-flight resolver for SYS_SPAWN. Side-effect-free: looks up program - * bytes for `path` (via the same execPrograms map + main-thread fallback - * `resolveExec` already uses for execve), follows shebangs, and compiles the - * final Wasm module. Returns null on ENOENT and `{ errno }` when the located - * target cannot be launched. + * bytes for `path` through the spawn-only execPrograms/main-thread fallback, + * follows shebangs, and compiles the final Wasm module. Exec never enters + * this resolver: its bytes come only from the retained kernel target. Returns + * null on ENOENT and `{ errno }` when the located target cannot be launched. * * `handleSpawn` in `host/src/kernel-worker.ts` calls this BEFORE * `kernel_spawn_process` so that file_actions (which the kernel runs diff --git a/host/src/platform/node.ts b/host/src/platform/node.ts index 931a1230b3..6a5fcba453 100644 --- a/host/src/platform/node.ts +++ b/host/src/platform/node.ts @@ -30,6 +30,8 @@ import { } from "../native-positioned-write"; import { filesystemPathconf } from "../pathconf"; import { nativeStatfs, translateOpenFlags } from "../vfs/host-fs"; +import { zeroCapacityStatfs } from "../statfs"; +import { ST_NOSUID } from "../vfs/types"; import { NativeMetadataOverlay } from "./native-metadata"; const UTIME_NOW = 0x3fffffff; @@ -235,6 +237,14 @@ export class NodePlatformIO implements PlatformIO { return this.metadata.toStatResult(fs.fstatSync(handle, { bigint: true })); } + fstatfs(handle: number): StatfsResult { + // Node exposes statfs(path) but no portable fstatfs(fd). This direct host + // backend is never admitted for set-ID execution, so validate the exact + // handle and publish its fixed nosuid route without consulting a path. + this.fstat(handle); + return { ...zeroCapacityStatfs(0), flags: ST_NOSUID }; + } + fpathconf(handle: number, name: number): PathconfValue { // Validate the live descriptor rather than re-resolving its original // pathname. This keeps fpathconf valid after rename or unlink. diff --git a/host/src/process-memory-creator-gate.ts b/host/src/process-memory-creator-gate.ts index fe47691e4c..7b20013031 100644 --- a/host/src/process-memory-creator-gate.ts +++ b/host/src/process-memory-creator-gate.ts @@ -19,6 +19,31 @@ export class ProcessMemoryCreatorGate { return this.runUntilCommitted(operation, () => creator()); } + /** + * Admit ownership that must transfer out of an async setup callback. + * + * The owner must release the admission after either publishing the exact + * process generation or abandoning it. Release is idempotent so terminal + * cleanup can share one path with setup failures without double-releasing + * the gate. + */ + acquire(operation: string): { release: () => void } { + if (!this.open) { + throw new Error( + `kernel worker is being destroyed; cannot start ${operation}`, + ); + } + this.activeCreators += 1; + let released = false; + return { + release: () => { + if (released) return; + released = true; + this.releaseCreator(); + }, + }; + } + /** * Admit a creator whose semantic completion can outlive its installation. * @@ -37,20 +62,13 @@ export class ProcessMemoryCreatorGate { operation: string, creator: (commit: () => void) => T | PromiseLike, ): Promise { - if (!this.open) { - return Promise.reject( - new Error( - `kernel worker is being destroyed; cannot start ${operation}`, - ), - ); + let admission: { release: () => void }; + try { + admission = this.acquire(operation); + } catch (error) { + return Promise.reject(error); } - this.activeCreators += 1; - let released = false; - const commit = () => { - if (released) return; - released = true; - this.releaseCreator(); - }; + const commit = admission.release; let result: T | PromiseLike; try { result = creator(commit); diff --git a/host/src/types.ts b/host/src/types.ts index a4b068bc6f..5e8af83725 100644 --- a/host/src/types.ts +++ b/host/src/types.ts @@ -100,6 +100,8 @@ export interface PlatformIO { whence: number, ): HostFileOffset; fstat(handle: number): StatResult; + /** Filesystem identity and set-ID policy bound to this exact open handle. */ + fstatfs?(handle: number): StatfsResult; fpathconf(handle: number, name: number): PathconfValue; /** diff --git a/host/src/vfs/vfs.ts b/host/src/vfs/vfs.ts index 959fe6ad03..d47906ab39 100644 --- a/host/src/vfs/vfs.ts +++ b/host/src/vfs/vfs.ts @@ -27,6 +27,7 @@ interface HandleInfo { backend: FileSystemBackend; backendId: number; localHandle: number; + statfs?: StatfsResult; } const MAX_U64 = (1n << 64n) - 1n; @@ -200,10 +201,22 @@ export class VirtualPlatformIO implements PlatformIO { } open(path: string, flags: number, mode: number): number { - const { backend, backendId, relativePath } = this.resolve(path); + const { backend, backendId, relativePath, setIdCapability } = this.resolve(path); + const backendStatfs = backend.statfs(relativePath); + const statfs = { + ...backendStatfs, + flags: setIdCapability.kind === "nosuid" + ? backendStatfs.flags | ST_NOSUID + : backendStatfs.flags & ~ST_NOSUID, + }; const localHandle = backend.open(relativePath, flags, mode); const globalHandle = this.nextFileHandle++; - this.fileHandles.set(globalHandle, { backend, backendId, localHandle }); + this.fileHandles.set(globalHandle, { + backend, + backendId, + localHandle, + statfs, + }); return globalHandle; } @@ -258,6 +271,14 @@ export class VirtualPlatformIO implements PlatformIO { return this.qualifyStat(info.backend, info.backend.fstat(info.localHandle)); } + fstatfs(handle: number): StatfsResult { + const info = this.getFileHandle(handle); + if (info.statfs === undefined) { + throw new Error(`EBADF: file handle ${handle} has no mount route`); + } + return { ...info.statfs }; + } + fpathconf(handle: number, name: number): PathconfValue { const info = this.getFileHandle(handle); return info.backend.fpathconf(info.localHandle, name); diff --git a/host/src/worker-adapter-browser.ts b/host/src/worker-adapter-browser.ts index c4c2917b9e..63f5ae2d0a 100644 --- a/host/src/worker-adapter-browser.ts +++ b/host/src/worker-adapter-browser.ts @@ -10,9 +10,22 @@ export class BrowserWorkerAdapter implements WorkerAdapter { createWorker(workerData: unknown): WorkerHandle { const worker = new Worker(this.entryUrl, { type: "module" }); // Web Workers don't have workerData — send init data via postMessage - const handle = new BrowserWorkerHandle(worker); - worker.postMessage(workerData); - return handle; + try { + worker.postMessage(workerData); + } catch (error) { + // No handle owns this Worker yet. Terminate the exact constructed realm + // synchronously, then preserve the structured-clone failure for the + // caller's existing launch/fatal boundary. + try { + worker.terminate(); + } catch { + // The initial post failure is the authoritative launch error. + } + throw error; + } + // Install callbacks only after the initial post succeeds. A failed clone + // therefore leaves neither a Worker realm nor unreachable callbacks. + return new BrowserWorkerHandle(worker); } } diff --git a/host/test/advisory-lock-retry.test.ts b/host/test/advisory-lock-retry.test.ts index b3e1c9ea76..97676ad687 100644 --- a/host/test/advisory-lock-retry.test.ts +++ b/host/test/advisory-lock-retry.test.ts @@ -238,26 +238,21 @@ describe("Rust-owned advisory-lock retry scheduling", () => { ); }); - it("drains Rust lock wakes after both exec cleanup phases", () => { - const prepare = vi.fn(() => -5); - const setup = vi.fn(() => -5); + it("drains Rust lock wakes after the atomic target-aware exec commit", () => { + const commit = vi.fn(() => -5); const drain = vi.fn(() => 0); const worker = createWorker({ kernel_drain_wakeup_events: drain, - kernel_exec_prepare: prepare, - kernel_exec_setup_for_thread: setup, + kernel_exec_commit: commit, }); - expect(worker.kernelExecPrepare(19, 19)).toBe(-5); - expect(worker.kernelExecSetup(19, 19)).toBe(-5); + expect(worker.kernelExecCommit(19, 19, 23)).toBe(-5); - expect(drain).toHaveBeenCalledTimes(2); - expect(prepare.mock.invocationCallOrder[0]).toBeLessThan( + expect(drain).toHaveBeenCalledOnce(); + expect(commit).toHaveBeenCalledExactlyOnceWith(19, 19, 23); + expect(commit.mock.invocationCallOrder[0]).toBeLessThan( drain.mock.invocationCallOrder[0], ); - expect(setup.mock.invocationCallOrder[0]).toBeLessThan( - drain.mock.invocationCallOrder[1], - ); }); it("retires old-image lock retries at exec handoff", () => { diff --git a/host/test/binary-resolver.test.ts b/host/test/binary-resolver.test.ts index f4ce617910..ecd61283c6 100644 --- a/host/test/binary-resolver.test.ts +++ b/host/test/binary-resolver.test.ts @@ -34,7 +34,10 @@ import { tryResolveBinaries, tryResolveBinarySet, } from "../src/binary-resolver"; -import { ABI_VERSION } from "../src/generated/abi"; +import { + ABI_VERSION, + HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS, +} from "../src/generated/abi"; import { MemoryFileSystem, type VfsImageMetadata, @@ -192,6 +195,32 @@ function executableWasmWithAbi(abi: number): Uint8Array { return new Uint8Array(bytes); } +function kernelWasmWithExports( + abi: number, + extraExports: readonly string[] = [], +): Uint8Array { + const exportNames = [ + ...new Set([...HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS, ...extraExports]), + ]; + const bytes: number[] = [ + 0x00, 0x61, 0x73, 0x6d, + 0x01, 0x00, 0x00, 0x00, + ]; + + bytes.push(...section(1, [0x01, 0x60, 0x00, 0x01, 0x7f])); + bytes.push(...section(3, [0x01, 0x00])); + bytes.push(...section(7, [ + ...uleb128(exportNames.length), + ...exportNames.flatMap((name) => [...nameBytes(name), 0x00, 0x00]), + ])); + bytes.push(...section(10, [ + 0x01, + ...functionBody([0x41, ...sleb128I32(abi)]), + ])); + + return new Uint8Array(bytes); +} + async function vfsImage( metadata: VfsImageMetadata | null | undefined, compressed: boolean, @@ -344,6 +373,39 @@ function writeFixturePackageIdentity( }; } +describe("kernel artifact exec authority", () => { + it("rejects a hybrid kernel that exports target-aware and legacy exec entrypoints", () => { + const sourceRepo = mkdtempSync(join(tmpdir(), "kandelo-kernel-resolver-")); + cleanupDirs.add(sourceRepo); + writeFileSync(join(sourceRepo, "Cargo.toml"), "[workspace]\n"); + writeFileSync( + join(sourceRepo, "package.json"), + JSON.stringify({ name: "kandelo" }), + ); + process.env.WASM_POSIX_BINARY_RESOLVER_REPO_ROOT = sourceRepo; + + const hybrid = writeCandidate( + join(sourceRepo, "local-binaries"), + "kernel.wasm", + kernelWasmWithExports(ABI_VERSION, [ + "kernel_exec_prepare", + "kernel_exec_setup", + "kernel_exec_setup_for_thread", + "kernel_execve", + "kernel_execveat", + ]), + ); + const targetAware = writeCandidate( + join(sourceRepo, "binaries"), + "kernel.wasm", + kernelWasmWithExports(ABI_VERSION), + ); + + expect(hybrid).not.toBe(targetAware); + expect(resolveBinary("kernel.wasm")).toBe(targetAware); + }); +}); + describe("program package source freshness boundary", () => { it("checks every public program-resolution boundary without duplicate nested checks", () => { const relPath = fixtureRelPath(".dat"); diff --git a/host/test/browser-worker-adapter.test.ts b/host/test/browser-worker-adapter.test.ts index 82274c1575..d39620f013 100644 --- a/host/test/browser-worker-adapter.test.ts +++ b/host/test/browser-worker-adapter.test.ts @@ -105,6 +105,43 @@ describe("BrowserWorkerAdapter", () => { expect(typeof handle.off).toBe("function"); expect(typeof handle.terminate).toBe("function"); }); + + it("terminates the exact Worker when its initial post cannot be cloned", () => { + const cloneFailure = new DOMException( + "workerData could not be cloned", + "DataCloneError", + ); + let constructions = 0; + let postAttempts = 0; + let terminations = 0; + class InitialPostFailureWorker extends MockBrowserWorker { + constructor(url: string | URL, options?: any) { + super(url, options); + constructions += 1; + lastMockWorker = this; + } + + override postMessage(_msg: unknown, _transfer?: any[]): void { + postAttempts += 1; + throw cloneFailure; + } + + override terminate(): void { + terminations += 1; + super.terminate(); + } + } + vi.stubGlobal("Worker", InitialPostFailureWorker); + const adapter = new BrowserWorkerAdapter("worker.js"); + + expect(() => adapter.createWorker({ pid: 42 })).toThrow(cloneFailure); + expect(constructions).toBe(1); + expect(postAttempts).toBe(1); + expect(terminations).toBe(1); + expect(lastMockWorker!.terminated).toBe(true); + expect(lastMockWorker!.onmessage).toBeNull(); + expect(lastMockWorker!.onerror).toBeNull(); + }); }); // ---- BrowserWorkerHandle message routing -------------------------------- diff --git a/host/test/centralized-test-helper.ts b/host/test/centralized-test-helper.ts index 4314143e47..2d071688e5 100644 --- a/host/test/centralized-test-helper.ts +++ b/host/test/centralized-test-helper.ts @@ -19,7 +19,15 @@ import { createProcessMemory, type ProcessMemoryLayout, } from "../src/process-memory"; -import { NodeKernelHost } from "../src/node-kernel-host"; +import { + NodeKernelHost, + resolveRootfsArtifact, +} from "../src/node-kernel-host"; +import { MemoryFileSystem } from "../src/vfs/memory-fs"; +import { + ensureDirRecursive, + writeVfsBinary, +} from "../src/vfs/image-helpers"; import { ForkHostImportOwnerRuntime, type ForkHostImportOwnerWorker, @@ -141,7 +149,10 @@ export interface RunProgramOptions { * programs can dial external hosts via real Node sockets. Worker-thread * mode only — incompatible with `io`. */ enableTcpNetwork?: boolean; - /** Map of virtual path → .wasm file path for exec targets */ + /** + * Map of virtual path → .wasm file path staged into the test rootfs for + * exact-target exec. The map also remains available to spawn preflight. + */ execPrograms?: Map; /** Data to provide on stdin (process will see EOF after this data) */ stdin?: string; @@ -242,6 +253,8 @@ async function runInWorkerThread(options: RunProgramOptions): Promise { stdout += new TextDecoder().decode(data); @@ -365,6 +377,45 @@ async function runInWorkerThread(options: RunProgramOptions): Promise { + const configured = options.rootfsImage + ?? (options.useDefaultRootfs === false ? undefined : "default"); + if (!options.execPrograms || options.execPrograms.size === 0) { + return configured; + } + + let rootfs: MemoryFileSystem; + if (configured === undefined) { + let programBytes = 0; + for (const hostPath of options.execPrograms.values()) { + programBytes += readFileSync(hostPath).byteLength; + } + const capacity = Math.max(4 * 1024 * 1024, programBytes + 1024 * 1024); + if (!Number.isSafeInteger(capacity)) { + throw new Error("test exec target rootfs capacity overflows"); + } + rootfs = MemoryFileSystem.create(new SharedArrayBuffer(capacity)); + } else { + const image = configured === "default" + ? new Uint8Array(readFileSync(resolveRootfsArtifact().selectedPath)) + : configured instanceof Uint8Array + ? configured + : new Uint8Array(configured); + rootfs = MemoryFileSystem.fromImagePreservingCapacity(image); + } + + for (const [path, hostPath] of options.execPrograms) { + if (!path.startsWith("/") || path.includes("\0")) { + throw new Error(`test exec target is not an absolute guest path: ${path}`); + } + ensureDirRecursive(rootfs, dirname(path)); + writeVfsBinary(rootfs, path, new Uint8Array(readFileSync(hostPath)), 0o755); + } + return rootfs.saveImage(); +} + // --------------------------------------------------------------------------- // Main-thread mode (fallback for custom PlatformIO) // --------------------------------------------------------------------------- @@ -610,10 +661,14 @@ async function runOnMainThread(options: RunProgramOptions): Promise { - const wasmPath = options.execPrograms?.get(path); - if (!wasmPath) return -2; - const newProgramBytes = loadProgramWasm(wasmPath); + onExec: async (request) => { + const { + pid: execPid, + targetBytes: newProgramBytes, + targetModule: newProgramModule, + argv, + envp, + } = request; const newPtrWidth = detectPtrWidth(newProgramBytes); const sourcePtrWidth = processPtrWidths.get(execPid) ?? newPtrWidth; const metadataResult = kernelWorker.validateExecMetadata(argv, envp, sourcePtrWidth); @@ -634,130 +689,142 @@ async function runOnMainThread(options: RunProgramOptions): Promise | undefined; let replacementGeneration: ForkExternrefGeneration | undefined; let replacementForkHostImports: ForkHostImportOwnerWorker | undefined; - try { - const setupResult = kernelWorker.kernelExecSetup(execPid, callerTid); - if (setupResult < 0) return setupResult; - kernelWorker.prepareProcessForExec(execPid); - const previousGeneration = externrefGenerations.get(execPid); - if (!previousGeneration) { - throw new Error( - `Unknown externref generation for exec pid ${execPid}`, - ); - } - replacementGeneration = - externrefProcessOwner.replaceGeneration(previousGeneration); - externrefGenerations.set(execPid, replacementGeneration); + let launchPlanState: "ready" | "discarded" | "started" = "ready"; + return { + onCommitFailure: () => { + if (launchPlanState !== "ready") return; + launchPlanState = "discarded"; + }, + startAfterCommit: async () => { + if (launchPlanState !== "ready") { + throw new Error( + `Centralized-test exec plan for pid ${execPid} was already consumed`, + ); + } + launchPlanState = "started"; + try { + kernelWorker.prepareProcessForExec(execPid); + const previousGeneration = externrefGenerations.get(execPid); + if (!previousGeneration) { + throw new Error( + `Unknown externref generation for exec pid ${execPid}`, + ); + } + replacementGeneration = + externrefProcessOwner.replaceGeneration(previousGeneration); + externrefGenerations.set(execPid, replacementGeneration); - const finalizeResult = kernelWorker.finalizeAddressSpaceForExec(execPid); - if (finalizeResult < 0) { - throw new Error("failed to detach the discarded address space"); - } + const finalizeResult = kernelWorker.finalizeAddressSpaceForExec(execPid); + if (finalizeResult < 0) { + throw new Error("failed to detach the discarded address space"); + } - const oldWorker = workers.get(execPid); - processForkHostImports.get(execPid)?.close(); - processForkHostImports.delete(execPid); - if (oldWorker) { - await oldWorker.terminate().catch(() => {}); - workers.delete(execPid); - } - if (kernelWorker.finalizeExecHandoffTermination(execPid) > 0) { - externrefProcessOwner.releaseGeneration(replacementGeneration); - externrefGenerations.delete(execPid); - replacementGeneration = undefined; - return 0; - } + const oldWorker = workers.get(execPid); + processForkHostImports.get(execPid)?.close(); + processForkHostImports.delete(execPid); + if (oldWorker) { + await oldWorker.terminate().catch(() => {}); + workers.delete(execPid); + } + if (kernelWorker.finalizeExecHandoffTermination(execPid) > 0) { + externrefProcessOwner.releaseGeneration(replacementGeneration); + externrefGenerations.delete(execPid); + replacementGeneration = undefined; + return 0; + } - kernelWorker.registerProcess(execPid, newMemory, [newChannelOffset], { - preserveProcessState: true, - ptrWidth: newPtrWidth, - metadataPtrWidth: sourcePtrWidth, - brkBase: newLayout.brkBase, - mmapBase: newLayout.mmapBase, - maxAddr: newLayout.maxAddr, - argv, - env: envp, - }); - processProgramBytes.set(execPid, newProgramBytes); - processLayouts.set(execPid, newLayout); - threadAllocators.set(execPid, newThreadAllocator); - processPtrWidths.set(execPid, newPtrWidth); - forkReplayContexts.delete(execPid); - - replacementForkHostImports = - forkHostImportOwnerRuntime.createWorker({ - pid: execPid, - generationId: replacementGeneration.id, - authorizeSender: () => { - if ( - !replacementWorker - || workers.get(execPid) !== replacementWorker - || externrefGenerations.get(execPid) - !== replacementGeneration - ) { - throw new Error( - `stale centralized-test host-import sender for exec pid=${execPid}`, - ); + kernelWorker.registerProcess(execPid, newMemory, [newChannelOffset], { + preserveProcessState: true, + ptrWidth: newPtrWidth, + metadataPtrWidth: sourcePtrWidth, + brkBase: newLayout.brkBase, + mmapBase: newLayout.mmapBase, + maxAddr: newLayout.maxAddr, + argv, + env: envp, + }); + processProgramBytes.set(execPid, newProgramBytes); + processLayouts.set(execPid, newLayout); + threadAllocators.set(execPid, newThreadAllocator); + processPtrWidths.set(execPid, newPtrWidth); + forkReplayContexts.delete(execPid); + + replacementForkHostImports = + forkHostImportOwnerRuntime.createWorker({ + pid: execPid, + generationId: replacementGeneration.id, + authorizeSender: () => { + if ( + !replacementWorker + || workers.get(execPid) !== replacementWorker + || externrefGenerations.get(execPid) + !== replacementGeneration + ) { + throw new Error( + `stale centralized-test host-import sender for exec pid=${execPid}`, + ); + } + }, + }); + const initData: CentralizedWorkerInitMessage = { + type: "centralized_init", + pid: execPid, + programBytes: newProgramBytes, + programModule: newProgramModule, + memory: newMemory, + channelOffset: newChannelOffset, + argv, + env: envp, + ptrWidth: newPtrWidth, + externrefGenerationId: replacementGeneration.id, + forkHostImports: replacementForkHostImports.init, + }; + + replacementWorker = workerAdapter.createWorker(initData); + workers.set(execPid, replacementWorker); + processForkHostImports.set(execPid, replacementForkHostImports); + replacementWorker.on("error", (err: Error) => { + console.error(`[exec] worker error for pid ${execPid}:`, err); + }); + replacementWorker.on("message", (msg: unknown) => { + const m = msg as WorkerToHostMessage; + if (m.type === "fork_host_import") { + replacementForkHostImports?.dispatch(m.wake); } - }, - }); - const initData: CentralizedWorkerInitMessage = { - type: "centralized_init", - pid: execPid, - programBytes: newProgramBytes, - memory: newMemory, - channelOffset: newChannelOffset, - argv, - env: envp, - ptrWidth: newPtrWidth, - externrefGenerationId: replacementGeneration.id, - forkHostImports: replacementForkHostImports.init, - }; - - replacementWorker = workerAdapter.createWorker(initData); - workers.set(execPid, replacementWorker); - processForkHostImports.set(execPid, replacementForkHostImports); - replacementWorker.on("error", (err: Error) => { - console.error(`[exec] worker error for pid ${execPid}:`, err); - }); - replacementWorker.on("message", (msg: unknown) => { - const m = msg as WorkerToHostMessage; - if (m.type === "fork_host_import") { - replacementForkHostImports?.dispatch(m.wake); + }); + kernelWorker.finishProcessExecHandoff(execPid); + return 0; + } catch (err) { + replacementForkHostImports?.close(); + try { kernelWorker.prepareProcessForExec(execPid); } catch { /* best-effort */ } + if (replacementWorker && workers.get(execPid) !== replacementWorker) { + await replacementWorker.terminate().catch(() => {}); + } + const currentWorker = workers.get(execPid); + if (currentWorker) { + await currentWorker.terminate().catch(() => {}); + workers.delete(execPid); + } + try { kernelWorker.notifyHostProcessCrashed(execPid, SIGSEGV); } catch { /* best-effort */ } + try { kernelWorker.deactivateProcess(execPid); } catch { /* best-effort */ } + processProgramBytes.delete(execPid); + processLayouts.delete(execPid); + threadAllocators.delete(execPid); + processPtrWidths.delete(execPid); + forkReplayContexts.delete(execPid); + releaseProcessReferenceOwner(execPid); + const message = err instanceof Error ? err.message : String(err); + stderr += `[exec] post-commit transition failed: ${message}\n`; + if (execPid === pid) resolveExit(128 + SIGSEGV); + return 0; } - }); - kernelWorker.finishProcessExecHandoff(execPid); - return 0; - } catch (err) { - replacementForkHostImports?.close(); - try { kernelWorker.prepareProcessForExec(execPid); } catch { /* best-effort */ } - if (replacementWorker && workers.get(execPid) !== replacementWorker) { - await replacementWorker.terminate().catch(() => {}); - } - const currentWorker = workers.get(execPid); - if (currentWorker) { - await currentWorker.terminate().catch(() => {}); - workers.delete(execPid); - } - try { kernelWorker.notifyHostProcessCrashed(execPid, SIGSEGV); } catch { /* best-effort */ } - try { kernelWorker.deactivateProcess(execPid); } catch { /* best-effort */ } - processProgramBytes.delete(execPid); - processLayouts.delete(execPid); - threadAllocators.delete(execPid); - processPtrWidths.delete(execPid); - forkReplayContexts.delete(execPid); - releaseProcessReferenceOwner(execPid); - const message = err instanceof Error ? err.message : String(err); - stderr += `[exec] post-commit transition failed: ${message}\n`; - if (execPid === pid) resolveExit(128 + SIGSEGV); - return 0; - } + }, + }; }, onClone: async (attachment) => { const { diff --git a/host/test/deferred-worker-start.test.ts b/host/test/deferred-worker-start.test.ts index ee4274f891..0d90b927c2 100644 --- a/host/test/deferred-worker-start.test.ts +++ b/host/test/deferred-worker-start.test.ts @@ -405,6 +405,33 @@ describe("stopped process Worker launch gate", () => { expect(cancel).toHaveBeenCalledOnce(); }); + it("routes a running constructor failure through its exact launch owner", async () => { + const memory = createSharedMemory(); + const failure = new Error("replacement Worker construction failed"); + const start = vi.fn(() => { + throw failure; + }); + const cancel = vi.fn(); + const onStartError = vi.fn(() => true); + const { worker } = createWorkerHarness(memory, () => 0); + + expect( + worker.startProcessWorkerWhenRunnable( + 41, + memory, + start, + cancel, + onStartError, + ), + ).toBe("started"); + await drainLifecycleGate(); + + expect(start).toHaveBeenCalledOnce(); + expect(cancel).toHaveBeenCalledOnce(); + expect(onStartError).toHaveBeenCalledOnce(); + expect(onStartError).toHaveBeenCalledWith(failure); + }); + it("turns deferred constructor failure into process exit and full teardown", async () => { let processState = 1; const memory = createSharedMemory(); diff --git a/host/test/exec-state-tracking.test.ts b/host/test/exec-state-tracking.test.ts index b5661b36d6..495bbb993f 100644 --- a/host/test/exec-state-tracking.test.ts +++ b/host/test/exec-state-tracking.test.ts @@ -1,4 +1,10 @@ +import { readFileSync } from "node:fs"; import { describe, expect, it, vi } from "vitest"; +import { + launchPreparedExecTarget, + readPreparedExecTarget, + type PreparedExecKernel, +} from "../src/exec-target"; import { createCentralizedKernelWorkerTestDouble, CentralizedKernelWorker, @@ -21,12 +27,377 @@ import { CH_STATUS, CH_SYSCALL, HOST_INTERCEPTED_SYSCALLS, + ABI_VERSION, PROCESS_STARTUP_MAX_ARGV_COUNT, PROCESS_STARTUP_MAX_ENVP_COUNT, } from "../src/generated/abi"; import { EXEC_RETIRE_SIGNAL_CODE } from "../src/worker-protocol"; import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; +const preparedExecFixture = new Uint8Array( + readFileSync("../local-binaries/programs/wasm32/exec-child.wasm"), +); + +function preparedExecExports(memory: WebAssembly.Memory) { + return { + kernel_exec_target_prepare: vi.fn(() => 31), + kernel_exec_target_size: vi.fn(() => BigInt(preparedExecFixture.byteLength)), + kernel_exec_target_read: vi.fn(( + _ownerPid: number, + _target: number, + offsetLo: number, + offsetHi: number, + destination: number, + capacity: number, + ) => { + const offset = Number( + (BigInt(offsetHi >>> 0) << 32n) | BigInt(offsetLo >>> 0), + ); + const count = Math.min( + capacity, + preparedExecFixture.byteLength - offset, + ); + new Uint8Array(memory.buffer, destination, count).set( + preparedExecFixture.subarray(offset, offset + count), + ); + return count; + }), + kernel_exec_target_cancel: vi.fn(() => 0), + }; +} + +describe("opaque prepared exec target launch", () => { + it("reads exact short chunks and rejects an unsafe signed size with one cancel", async () => { + const expected = Uint8Array.from([1, 2, 3, 4, 5]); + const cancel = vi.fn(() => 0); + const offsets: bigint[] = []; + const kernel: PreparedExecKernel = { + execTargetSize: () => BigInt(expected.byteLength), + execTargetRead: (_ownerPid, _target, offset, destination) => { + offsets.push(offset); + const start = Number(offset); + const count = Math.min(2, expected.byteLength - start); + destination.set(expected.subarray(start, start + count)); + return count; + }, + execTargetCancel: cancel, + }; + + await expect(readPreparedExecTarget(kernel, 7, 11)).resolves.toEqual( + expected, + ); + expect(offsets).toEqual([0n, 2n, 4n]); + expect(cancel).not.toHaveBeenCalled(); + + kernel.execTargetSize = () => BigInt(Number.MAX_SAFE_INTEGER) + 1n; + await expect(readPreparedExecTarget(kernel, 7, 12)).rejects.toEqual( + expect.objectContaining({ + name: "PreparedExecTargetError", + errno: 75, + targetCancelled: true, + }), + ); + expect(cancel).toHaveBeenCalledExactlyOnceWith(7, 12); + }); + + it("prepares only the shebang interpreter and keeps diagnosticPath display-only", async () => { + const interpreter = new Uint8Array( + readFileSync("../local-binaries/programs/wasm32/exec-child.wasm"), + ); + const script = new TextEncoder().encode( + "#!/bin/exact-interpreter --flag\necho must-not-launch\n", + ); + const targets = new Map([ + [31, script], + [32, interpreter], + ]); + const cancelled: number[] = []; + const committed: number[] = []; + const materialized: string[] = []; + const kernel: PreparedExecKernel = { + execTargetSize: (_ownerPid, target) => + BigInt(targets.get(target)!.byteLength), + execTargetRead: (_ownerPid, target, offset, destination) => { + const bytes = targets.get(target)!; + const start = Number(offset); + const count = Math.min(destination.byteLength, bytes.byteLength - start); + destination.set(bytes.subarray(start, start + count)); + return count; + }, + execTargetCancel: (_ownerPid, target) => { + cancelled.push(target); + return 0; + }, + }; + + const result = await launchPreparedExecTarget({ + kernel, + ownerPid: 7, + pid: 7, + callerTid: 9, + diagnosticPath: "/bin/script", + argv: ["script", "argument"], + envp: ["A=B"], + expectedAbi: ABI_VERSION, + materializePath: async (path) => { + materialized.push(path); + }, + prepareInitialTarget: () => 31, + prepareInterpreterTarget: (path) => { + expect(path).toBe("/bin/exact-interpreter"); + return 32; + }, + commitTarget: (target, expectedSize) => { + expect(expectedSize).toBe(interpreter.byteLength); + committed.push(target); + return 0; + }, + }, async (request) => { + expect(Reflect.has(request, "target")).toBe(false); + expect(Reflect.has(request, "commit")).toBe(false); + expect(request.argv).toEqual([ + "/bin/exact-interpreter", + "--flag", + "/bin/script", + "argument", + ]); + expect(new Uint8Array(request.targetBytes)).toEqual(interpreter); + request.diagnosticPath = "/attacker/replaced-display-text"; + expect(new Uint8Array(request.targetBytes)).toEqual(interpreter); + return { + onCommitFailure: () => { + throw new Error("the successful commit was discarded"); + }, + startAfterCommit: async () => 0, + }; + }); + + expect(result).toBe(0); + expect(materialized).toEqual(["/bin/script", "/bin/exact-interpreter"]); + expect(cancelled).toEqual([31]); + expect(committed).toEqual([32]); + }); + + it("cancels one precommit callback failure but never cancels after commit", async () => { + const bytes = new Uint8Array( + readFileSync("../local-binaries/programs/wasm32/exec-child.wasm"), + ); + let nextTarget = 40; + const cancel = vi.fn(() => 0); + const kernel: PreparedExecKernel = { + execTargetSize: () => BigInt(bytes.byteLength), + execTargetRead: (_ownerPid, _target, offset, destination) => { + const start = Number(offset); + const count = Math.min(destination.byteLength, bytes.byteLength - start); + destination.set(bytes.subarray(start, start + count)); + return count; + }, + execTargetCancel: cancel, + }; + const options = () => ({ + kernel, + ownerPid: 7, + pid: 7, + callerTid: 7, + diagnosticPath: "/bin/program", + argv: ["program"], + envp: [] as string[], + expectedAbi: ABI_VERSION, + materializePath: async () => {}, + prepareInitialTarget: () => nextTarget++, + prepareInterpreterTarget: () => { + throw new Error("not a script"); + }, + commitTarget: vi.fn(() => 0), + }); + + await expect( + launchPreparedExecTarget(options(), async () => -12), + ).resolves.toBe(-12); + expect(cancel).toHaveBeenCalledExactlyOnceWith(7, 40); + + await expect( + launchPreparedExecTarget(options(), async () => { + throw new Error("replacement memory preflight failed"); + }), + ).rejects.toThrow("replacement memory preflight failed"); + expect(cancel).toHaveBeenNthCalledWith(2, 7, 41); + + await expect( + launchPreparedExecTarget(options(), async () => ({ + onCommitFailure: () => { + throw new Error("the successful commit was discarded"); + }, + startAfterCommit: async () => { + throw new Error("replacement Worker constructor failed"); + }, + })), + ).rejects.toThrow("replacement Worker constructor failed"); + expect(cancel).toHaveBeenCalledTimes(2); + }); + + it("does not lend commit authority to a callback-queued microtask", async () => { + const bytes = new Uint8Array( + readFileSync("../local-binaries/programs/wasm32/exec-child.wasm"), + ); + const events: string[] = []; + const cancel = vi.fn(() => { + events.push("cancel"); + return 0; + }); + const commitTarget = vi.fn(() => { + events.push("kernel-commit"); + return 0; + }); + let escapedCommit: (() => number) | undefined; + const launch = launchPreparedExecTarget({ + kernel: { + execTargetSize: () => BigInt(bytes.byteLength), + execTargetRead: (_ownerPid, _target, offset, destination) => { + const start = Number(offset); + const count = Math.min( + destination.byteLength, + bytes.byteLength - start, + ); + destination.set(bytes.subarray(start, start + count)); + return count; + }, + execTargetCancel: cancel, + }, + ownerPid: 7, + pid: 7, + callerTid: 7, + diagnosticPath: "/bin/program", + argv: ["program"], + envp: [], + expectedAbi: ABI_VERSION, + materializePath: async () => {}, + prepareInitialTarget: () => 51, + prepareInterpreterTarget: () => { + throw new Error("not a script"); + }, + commitTarget, + }, async (request) => { + escapedCommit = Reflect.get(request, "commit") as + | (() => number) + | undefined; + queueMicrotask(() => { + events.push( + escapedCommit + ? `queued-commit:${escapedCommit()}` + : "queued-commit:absent", + ); + }); + return -12; + }).then((result) => { + events.push("settled"); + return result; + }); + + await expect(launch).resolves.toBe(-12); + expect(events).toEqual(["queued-commit:absent", "cancel", "settled"]); + expect(escapedCommit).toBeUndefined(); + expect(cancel).toHaveBeenCalledExactlyOnceWith(7, 51); + expect(commitTarget).not.toHaveBeenCalled(); + }); + + it("allows only one kernel commit before one postcommit start", async () => { + const bytes = new Uint8Array( + readFileSync("../local-binaries/programs/wasm32/exec-child.wasm"), + ); + const cancel = vi.fn(() => 0); + const events: string[] = []; + const commitTarget = vi.fn(() => { + events.push("commit"); + return 0; + }); + const onCommitFailure = vi.fn(); + const startAfterCommit = vi.fn(async () => { + events.push("start"); + return 0; + }); + + await expect(launchPreparedExecTarget({ + kernel: { + execTargetSize: () => BigInt(bytes.byteLength), + execTargetRead: (_ownerPid, _target, offset, destination) => { + const start = Number(offset); + const count = Math.min( + destination.byteLength, + bytes.byteLength - start, + ); + destination.set(bytes.subarray(start, start + count)); + return count; + }, + execTargetCancel: cancel, + }, + ownerPid: 7, + pid: 7, + callerTid: 7, + diagnosticPath: "/bin/program", + argv: ["program"], + envp: [], + expectedAbi: ABI_VERSION, + materializePath: async () => {}, + prepareInitialTarget: () => 52, + prepareInterpreterTarget: () => { + throw new Error("not a script"); + }, + commitTarget, + }, async () => ({ onCommitFailure, startAfterCommit }))).resolves.toBe(0); + + expect(commitTarget).toHaveBeenCalledExactlyOnceWith(52, bytes.byteLength); + expect(startAfterCommit).toHaveBeenCalledOnce(); + expect(onCommitFailure).not.toHaveBeenCalled(); + expect(cancel).not.toHaveBeenCalled(); + expect(events).toEqual(["commit", "start"]); + }); + + it("discards one replacement plan after one rejected kernel commit", async () => { + const bytes = new Uint8Array( + readFileSync("../local-binaries/programs/wasm32/exec-child.wasm"), + ); + const cancel = vi.fn(() => 0); + const commitTarget = vi.fn(() => -3); + const onCommitFailure = vi.fn(); + const startAfterCommit = vi.fn(async () => 0); + + await expect(launchPreparedExecTarget({ + kernel: { + execTargetSize: () => BigInt(bytes.byteLength), + execTargetRead: (_ownerPid, _target, offset, destination) => { + const start = Number(offset); + const count = Math.min( + destination.byteLength, + bytes.byteLength - start, + ); + destination.set(bytes.subarray(start, start + count)); + return count; + }, + execTargetCancel: cancel, + }, + ownerPid: 7, + pid: 7, + callerTid: 7, + diagnosticPath: "/bin/program", + argv: ["program"], + envp: [], + expectedAbi: ABI_VERSION, + materializePath: async () => {}, + prepareInitialTarget: () => 53, + prepareInterpreterTarget: () => { + throw new Error("not a script"); + }, + commitTarget, + }, async () => ({ onCommitFailure, startAfterCommit }))).resolves.toBe(-3); + + expect(commitTarget).toHaveBeenCalledExactlyOnceWith(53, bytes.byteLength); + expect(onCommitFailure).toHaveBeenCalledExactlyOnceWith(-3); + expect(startAfterCommit).not.toHaveBeenCalled(); + expect(cancel).not.toHaveBeenCalled(); + }); +}); + describe("exec host-state transition", () => { it("retires only the exact pending exec generation without relistening", () => { const memory = new WebAssembly.Memory({ @@ -366,6 +737,7 @@ describe("exec host-state transition", () => { }); const getProcessExitSignal = vi.fn(() => 11); const onExit = vi.fn(); + const kernelMemory = new WebAssembly.Memory({ initial: 4, maximum: 8 }); const worker = createWorker({ processes: new Map([[7, { channels: [channel], memory }]]), callbacks: { @@ -373,8 +745,13 @@ describe("exec host-state transition", () => { onExit, }, kernelInstance: { - exports: { kernel_get_process_exit_signal: getProcessExitSignal }, + exports: { + kernel_get_process_exit_signal: getProcessExitSignal, + ...preparedExecExports(kernelMemory), + }, }, + kernelMemory, + kernelAbiVersion: ABI_VERSION, }); writeChannelSyscall( @@ -415,12 +792,18 @@ describe("exec host-state transition", () => { finishExec = resolve; }); const getProcessExitSignal = vi.fn(() => 0); + const kernelMemory = new WebAssembly.Memory({ initial: 4, maximum: 8 }); const worker = createWorker({ processes: new Map([[7, { channels: [channel], memory }]]), callbacks: { onExec: vi.fn(() => launched) }, kernelInstance: { - exports: { kernel_get_process_exit_signal: getProcessExitSignal }, + exports: { + kernel_get_process_exit_signal: getProcessExitSignal, + ...preparedExecExports(kernelMemory), + }, }, + kernelMemory, + kernelAbiVersion: ABI_VERSION, }); writeChannelSyscall( @@ -1137,21 +1520,23 @@ describe("exec host-state transition", () => { expect(worker.shmMappings.has(7)).toBe(false); }); - it("validates the caller before setup and prunes closed epoll mirrors", () => { + it("commits the exact caller and target before pruning closed epoll mirrors", () => { let ambientPid = 0; - let preparedCaller = 0; + let committedCaller = 0; + let committedTarget = 0; const openFds = new Set([6, 8]); const worker = createWorker({ currentHandlePid: 0, kernelInstance: { exports: { - kernel_exec_prepare: (_pid: number, tid: number) => { - ambientPid = worker.currentHandlePid; - preparedCaller = tid; - return 0; - }, - kernel_exec_setup_for_thread: (_pid: number, _tid: number) => { + kernel_exec_commit: ( + _pid: number, + tid: number, + target: number, + ) => { ambientPid = worker.currentHandlePid; + committedCaller = tid; + committedTarget = target; return 0; }, kernel_fd_is_open: (_pid: number, fd: number) => openFds.has(fd) ? 1 : 0, @@ -1166,11 +1551,9 @@ describe("exec host-state transition", () => { ]), }); - expect(worker.kernelExecPrepare(7, 11)).toBe(0); - expect(preparedCaller).toBe(11); - expect(ambientPid).toBe(7); - expect(worker.currentHandlePid).toBe(0); - expect(worker.kernelExecSetup(7, 11)).toBe(0); + expect(worker.kernelExecCommit(7, 11, 13)).toBe(0); + expect(committedCaller).toBe(11); + expect(committedTarget).toBe(13); expect(ambientPid).toBe(7); expect(worker.currentHandlePid).toBe(0); expect(worker.epollInterests.get("7:6")).toEqual([ @@ -1179,25 +1562,16 @@ describe("exec host-state transition", () => { expect(worker.epollInterests.has("7:10")).toBe(false); }); - it("fails loudly when either exact-caller exec export is absent", () => { - const missingPrepare = createWorker({ + it("fails loudly when the target-aware commit export is absent", () => { + const missingCommit = createWorker({ currentHandlePid: 0, kernelInstance: { - exports: { kernel_exec_setup_for_thread: vi.fn(() => 0) }, - }, - }); - const missingSetup = createWorker({ - currentHandlePid: 0, - kernelInstance: { - exports: { kernel_exec_prepare: vi.fn(() => 0) }, + exports: {}, }, }); - expect(() => missingPrepare.kernelExecPrepare(7, 11)).toThrow( - "Kernel missing required kernel_exec_prepare export", - ); - expect(() => missingSetup.kernelExecSetup(7, 11)).toThrow( - "Kernel missing required kernel_exec_setup_for_thread export", + expect(() => missingCommit.kernelExecCommit(7, 11, 13)).toThrow( + "Kernel missing required kernel_exec_commit export", ); }); @@ -1214,7 +1588,7 @@ describe("exec host-state transition", () => { currentHandlePid: 0, kernelInstance: { exports: { - kernel_exec_setup_for_thread: () => { + kernel_exec_commit: () => { committed = true; return 0; }, @@ -1232,7 +1606,7 @@ describe("exec host-state transition", () => { tcpListeners: new Map([["7:4", listener]]), }); - expect(worker.kernelExecSetup(7, 7)).toBe(0); + expect(worker.kernelExecCommit(7, 7, 13)).toBe(0); expect(worker.tcpListenerTargets.get(8080)).toEqual([{ pid: 7, fd: 2048 }]); expect(worker.tcpListeners.has("7:4")).toBe(false); expect(worker.tcpListeners.get("7:2048")).toEqual(listener); @@ -1250,7 +1624,7 @@ describe("exec host-state transition", () => { currentHandlePid: 0, kernelInstance: { exports: { - kernel_exec_setup_for_thread: () => 0, + kernel_exec_commit: () => 0, kernel_fd_is_open: (_pid: number, fd: number) => fd === 2048 ? 1 : 0, kernel_get_fd_accept_wake_idx: (_pid: number, fd: number) => fd === 2048 ? 41 : -1, @@ -1267,7 +1641,7 @@ describe("exec host-state transition", () => { tcpListeners: new Map([["7:4", listener]]), }); - expect(worker.kernelExecSetup(7, 7)).toBe(0); + expect(worker.kernelExecCommit(7, 7, 13)).toBe(0); expect(worker.tcpListenerTargets.get(8080)).toEqual([{ pid: 7, fd: 2048, @@ -1611,7 +1985,7 @@ async function flushMicrotasksUntil( ): Promise { for (let index = 0; index < turns; index++) { if (condition()) return; - await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); } if (!condition()) throw new Error(failureMessage); } diff --git a/host/test/exec.test.ts b/host/test/exec.test.ts index d4a5869062..bbd5a07bce 100644 --- a/host/test/exec.test.ts +++ b/host/test/exec.test.ts @@ -11,6 +11,7 @@ import { tryResolveBinary } from "../src/binary-resolver"; const execCallerBinary = tryResolveBinary("programs/exec-caller.wasm"); const execChildBinary = tryResolveBinary("programs/exec-child.wasm"); const forkExecBinary = tryResolveBinary("programs/fork-exec.wasm"); +const vforkLifecycleBinary = tryResolveBinary("programs/vfork-lifecycle.wasm"); const execPrograms = new Map( execChildBinary ? [["/bin/exec-child", execChildBinary]] : [], @@ -18,6 +19,7 @@ const execPrograms = new Map( const hasExecCaller = !!execCallerBinary; const hasForkExec = !!forkExecBinary; +const hasVforkLifecycle = !!vforkLifecycleBinary && !!execChildBinary; describe("execve", () => { it.skipIf(!hasExecCaller)("replaces the current process with a new program", async () => { @@ -69,12 +71,47 @@ describe("execve", () => { expect(result.stdout).toContain("FROM=fork"); }); + it.skipIf(!hasExecCaller || !execChildBinary)( + "replaces a shebang script target with its exact interpreter once", + async () => { + const tempDir = mkdtempSync(join(tmpdir(), "kandelo-exec-shebang-")); + const scriptPath = join(tempDir, "script"); + try { + writeFileSync( + scriptPath, + "#!/bin/exact-interpreter --script-flag\nignored body\n", + { mode: 0o4755 }, + ); + const result = await runCentralizedProgram({ + programPath: execCallerBinary!, + argv: ["exec-caller"], + timeout: 15_000, + execPrograms: new Map([ + ["/bin/exec-child", scriptPath], + ["/bin/exact-interpreter", execChildBinary!], + ]), + useDefaultRootfs: false, + }); + + expect(result.exitCode).toBe(42); + expect(result.stdout).toContain("argc=5"); + expect(result.stdout).toContain("argv[0]=/bin/exact-interpreter"); + expect(result.stdout).toContain("argv[1]=--script-flag"); + expect(result.stdout).toContain("argv[2]=/bin/exec-child"); + expect(result.stdout).toContain("argv[3]=hello"); + expect(result.stdout).toContain("argv[4]=world"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }, + ); + it.skipIf(!hasExecCaller)("rejects malformed Wasm before committing exec", async () => { const tempDir = mkdtempSync(join(tmpdir(), "kandelo-exec-malformed-wasm-")); const malformedWasm = join(tempDir, "malformed.wasm"); try { // Valid Wasm magic/version with a truncated type section. This reaches - // compilation, which must fail before kernelExecSetup discards the old + // compilation, which must fail before kernelExecCommit discards the old // process image. writeFileSync(malformedWasm, Buffer.from([ 0x00, 0x61, 0x73, 0x6d, @@ -99,4 +136,45 @@ describe("execve", () => { rmSync(tempDir, { recursive: true, force: true }); } }); + + it.skipIf(!hasVforkLifecycle)( + "kills a committed vfork child when Node replacement Worker construction fails", + async () => { + const injection = "KANDELO_TEST_EXEC_WORKER_CONSTRUCTION_FAILURE"; + const previousInjection = process.env[injection]; + process.env[injection] = "once"; + try { + const result = await runCentralizedProgram({ + programPath: vforkLifecycleBinary!, + argv: ["vfork-lifecycle-node-postcommit-fatal"], + execPrograms: new Map([ + ["/bin/vfork-exec-child", execChildBinary!], + ]), + useDefaultRootfs: false, + timeout: 20_000, + }); + + // The parent resumes through the fatal-child edge, then deliberately + // exits 5 because the committed child died from SIGSEGV instead of the + // fixture's expected status 42. + expect(result.exitCode).toBe(5); + expect(result.stdout).toContain("PARENT_AFTER_EXEC_COMMIT"); + expect(result.stdout).not.toContain("argc=2"); + expect(result.stdout).not.toContain("PARENT_REAPED_EXEC_CHILD"); + expect(result.stdout).not.toContain("PASS: VFORK_LIFECYCLE"); + expect(result.hostDiagnostics).toEqual([ + expect.objectContaining({ + source: "exec post-commit transition", + status: 139, + message: expect.stringContaining( + "injected exec Worker construction failure", + ), + }), + ]); + } finally { + if (previousInjection === undefined) delete process.env[injection]; + else process.env[injection] = previousInjection; + } + }, + ); }); diff --git a/host/test/kernel-exec-entry.test.ts b/host/test/kernel-exec-entry.test.ts index b1b350453b..83ccabfb30 100644 --- a/host/test/kernel-exec-entry.test.ts +++ b/host/test/kernel-exec-entry.test.ts @@ -16,8 +16,11 @@ import { createKernelScratchTestInstance } from "./support/kernel-scratch-instan const KERNEL_EXPORT_NAMES = [ "kernel_drain_wakeup_events", - "kernel_exec_prepare", - "kernel_exec_setup_for_thread", + "kernel_exec_commit", + "kernel_exec_target_cancel", + "kernel_exec_target_prepare", + "kernel_exec_target_read", + "kernel_exec_target_size", "kernel_fd_is_open", "kernel_find_listener_fd_by_accept_wake", "kernel_get_fd_accept_wake_idx", @@ -49,6 +52,7 @@ interface ExecWorkerState { interface ExecEntryHarness { readonly worker: CentralizedKernelWorker; readonly gatedInstance: WebAssembly.Instance; + readonly kernelMemory: WebAssembly.Memory; readonly implementations: Record; } @@ -101,33 +105,95 @@ function makeHarness( gate, mainScratch, }); - return { worker, gatedInstance, implementations }; + return { worker, gatedInstance, kernelMemory, implementations }; } describe("kernel exec entry authority", () => { + it("marshals prepare/read/size/cancel under entry and preserves a 64-bit offset", () => { + const preparedPath: number[] = []; + const readWords: Array<[number, number]> = []; + const cancel = vi.fn(() => 0); + const commit = vi.fn(() => 0); + const harness = makeHarness({ + kernel_drain_wakeup_events: () => 0, + kernel_exec_commit: commit, + kernel_exec_target_cancel: cancel, + kernel_exec_target_prepare: ( + pid: number, + callerTid: number, + dirfd: number, + pathPointer: number, + pathLength: number, + flags: number, + ) => { + expect([pid, callerTid, dirfd, flags]).toEqual([7, 9, -100, 0]); + preparedPath.push( + ...new Uint8Array( + harness.kernelMemory.buffer, + pathPointer, + pathLength, + ), + ); + return 31; + }, + kernel_exec_target_size: () => 3n, + kernel_exec_target_read: ( + _ownerPid: number, + _target: number, + offsetLo: number, + offsetHi: number, + destination: number, + capacity: number, + ) => { + readWords.push([offsetLo, offsetHi]); + new Uint8Array( + harness.kernelMemory.buffer, + destination, + capacity, + ).set([4, 5, 6]); + return 3; + }, + kernel_fd_is_open: () => 0, + kernel_find_listener_fd_by_accept_wake: () => -1, + kernel_get_fd_accept_wake_idx: () => -1, + kernel_vblank: () => 0, + }); + + expect( + harness.worker.execTargetPrepare(7, 9, -100, "/bin/exact", 0), + ).toBe(31); + expect(new TextDecoder().decode(Uint8Array.from(preparedPath))).toBe( + "/bin/exact", + ); + expect(harness.worker.execTargetSize(7, 31)).toBe(3n); + const destination = new Uint8Array(3); + expect( + harness.worker.execTargetRead(7, 31, 0x1_0000_0001n, destination), + ).toBe(3); + expect(readWords).toEqual([[1, 1]]); + expect(destination).toEqual(Uint8Array.from([4, 5, 6])); + expect(harness.worker.kernelExecCommit(7, 9, 31, 3)).toBe(0); + expect(commit).toHaveBeenCalledExactlyOnceWith(7, 9, 31); + expect(harness.worker.execTargetCancel(7, 31)).toBe(0); + expect(cancel).toHaveBeenCalledExactlyOnceWith(7, 31); + }); + it("rejects synchronous exec results during a live export", async () => { - const prepare = vi.fn(() => 0); - const setup = vi.fn(() => 0); + const commit = vi.fn(() => 0); const drain = vi.fn(() => 0); const caught: unknown[] = []; let harness!: ExecEntryHarness; harness = makeHarness({ kernel_drain_wakeup_events: drain, - kernel_exec_prepare: prepare, - kernel_exec_setup_for_thread: setup, + kernel_exec_commit: commit, kernel_fd_is_open: () => 0, kernel_find_listener_fd_by_accept_wake: () => -1, kernel_get_fd_accept_wake_idx: () => -1, kernel_vblank: () => { - for (const operation of [ - () => harness.worker.kernelExecPrepare(7, 11), - () => harness.worker.kernelExecSetup(7, 11), - ]) { - try { - operation(); - } catch (error) { - caught.push(error); - } + try { + harness.worker.kernelExecCommit(7, 11, 13); + } catch (error) { + caught.push(error); } return 0; }, @@ -136,20 +202,17 @@ describe("kernel exec entry authority", () => { (harness.gatedInstance.exports.kernel_vblank as () => number)(); await Promise.resolve(); - expect(caught).toHaveLength(2); + expect(caught).toHaveLength(1); for (const error of caught) { expect(error).toBeInstanceOf(KernelReentrantEntryError); } - expect(prepare).not.toHaveBeenCalled(); - expect(setup).not.toHaveBeenCalled(); + expect(commit).not.toHaveBeenCalled(); expect(drain).not.toHaveBeenCalled(); // Rejection does not queue an authority result or poison the generation. - expect(harness.worker.kernelExecPrepare(7, 11)).toBe(0); - expect(harness.worker.kernelExecSetup(7, 11)).toBe(0); - expect(prepare).toHaveBeenCalledOnce(); - expect(setup).toHaveBeenCalledOnce(); - expect(drain).toHaveBeenCalledTimes(2); + expect(harness.worker.kernelExecCommit(7, 11, 13)).toBe(0); + expect(commit).toHaveBeenCalledExactlyOnceWith(7, 11, 13); + expect(drain).toHaveBeenCalledOnce(); }); it("publishes a complete mirror plan before closing host listeners", () => { @@ -181,8 +244,7 @@ describe("kernel exec entry authority", () => { observe("wake drain", state); return 0; }, - kernel_exec_prepare: () => 0, - kernel_exec_setup_for_thread: () => { + kernel_exec_commit: () => { expect(state.currentHandlePid).toBe(7); committed = true; return 0; @@ -226,7 +288,7 @@ describe("kernel exec entry authority", () => { ]); state.tcpVirtualListenerKeys = new Map([[8080, "virtual:7:4"]]); - expect(harness.worker.kernelExecSetup(7, 11)).toBe(0); + expect(harness.worker.kernelExecCommit(7, 11, 13)).toBe(0); expect(observations).toEqual([ { @@ -259,12 +321,11 @@ describe("kernel exec entry authority", () => { expect(state.tcpListenerRRIndex.has(8080)).toBe(false); }); - it("keeps every host mirror intact when exec setup fails", () => { + it("keeps every host mirror intact when exec commit fails", () => { const fdIsOpen = vi.fn(() => 0); const harness = makeHarness({ kernel_drain_wakeup_events: () => 0, - kernel_exec_prepare: () => 0, - kernel_exec_setup_for_thread: () => -5, + kernel_exec_commit: () => -5, kernel_fd_is_open: fdIsOpen, kernel_find_listener_fd_by_accept_wake: () => -1, kernel_get_fd_accept_wake_idx: () => -1, @@ -274,7 +335,7 @@ describe("kernel exec entry authority", () => { const interests = [{ fd: 9, events: 1, data: 11n }]; state.epollInterests = new Map([["7:6", interests]]); - expect(harness.worker.kernelExecSetup(7, 11)).toBe(-5); + expect(harness.worker.kernelExecCommit(7, 11, 13)).toBe(-5); expect(state.epollInterests.get("7:6")).toBe(interests); expect(fdIsOpen).not.toHaveBeenCalled(); }); diff --git a/host/test/kernel-scratch-contract.test.ts b/host/test/kernel-scratch-contract.test.ts index 04282fca0e..4aea58145b 100644 --- a/host/test/kernel-scratch-contract.test.ts +++ b/host/test/kernel-scratch-contract.test.ts @@ -844,10 +844,19 @@ const reviewedScalarKernelExportCalls: AuditAllowance[] = [ "host/src/kernel-worker.ts::CentralizedKernelWorker.inheritHostFdMirrors::kernel-export-direct-use::getAcceptWake?.(parentPid, parentTarget.fd)", ), reviewedScalarKernelExportCall( - "host/src/kernel-worker.ts::CentralizedKernelWorker.kernelExecPrepare::kernel-export-direct-use::prepare(pid, callerTid)", + "host/src/kernel-worker.ts::CentralizedKernelWorker.execTargetCancel::kernel-export-direct-use::cancel(ownerPid, target)", ), reviewedScalarKernelExportCall( - "host/src/kernel-worker.ts::CentralizedKernelWorker.kernelExecSetup::kernel-export-direct-use::threadAware(pid, callerTid)", + "host/src/kernel-worker.ts::CentralizedKernelWorker.execTargetSize::kernel-export-direct-use::size(ownerPid, target)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.kernelExecCommit::kernel-export-direct-use::cancel(pid, target)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.kernelExecCommit::kernel-export-direct-use::commit(pid, callerTid, target)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.kernelExecCommit::kernel-export-direct-use::size(pid, target)", ), reviewedScalarKernelExportCall( "host/src/kernel-worker.ts::CentralizedKernelWorker.notifyParentOfChildStateTransition::kernel-export-direct-use::hasNoCldStop(parentPid)", @@ -1416,6 +1425,11 @@ const auditAllowances: AuditAllowance[] = [ disposition: "rust-lent", why: "The host_statfs import binds its exact pointer formal to the generated fixed filesystem-stat capacity before backend work.", }, + { + key: 'host/src/kernel.ts::WasmPosixKernel.#buildImportObject::kernel-destination-factory-call::this.#rustLentKernelDestination( statfsPtr, WASM_STATFS_SIZE, "host_fstatfs destination", )', + disposition: "rust-lent", + why: "The exact-handle host_fstatfs import binds its pointer formal to the generated fixed filesystem-stat capacity before retained-route policy lookup.", + }, { key: 'host/src/kernel.ts::WasmPosixKernel.#buildImportObject::kernel-destination-factory-call::this.#rustLentKernelDestination( valuePtr, 8, "host_pathconf destination", )', disposition: "rust-lent", diff --git a/host/test/nosuid-exec.test.ts b/host/test/nosuid-exec.test.ts index 8f86644a11..061bf51d5f 100644 --- a/host/test/nosuid-exec.test.ts +++ b/host/test/nosuid-exec.test.ts @@ -241,6 +241,34 @@ describe("set-ID execution mount evidence", () => { ); }); + it("binds set-ID policy to the exact open route instead of a later path lookup", () => { + const backend = createImmutableProductBackend(createSetIdFileSystem()); + const vfs = new VirtualPlatformIO( + [ + { + mountPoint: "/trusted", + backend, + readonly: true, + setIdCapability: TRUSTED_ROOT_PRODUCT, + }, + { mountPoint: "/raw", backend, readonly: true }, + ], + new NodeTimeProvider(), + ); + + const trustedHandle = vfs.open( + "/trusted/bin/tool", + OPEN_FLAGS.O_RDONLY, + 0, + ); + const rawHandle = vfs.open("/raw/bin/tool", OPEN_FLAGS.O_RDONLY, 0); + expect(vfs.fstatfs(trustedHandle).flags & ST_NOSUID).toBe(0); + expect(vfs.fstatfs(rawHandle).flags & ST_NOSUID).toBe(ST_NOSUID); + expect(vfs.statfs("/raw/bin/tool").flags & ST_NOSUID).toBe(ST_NOSUID); + vfs.close(trustedHandle); + vfs.close(rawHandle); + }); + it("snapshots product state away from caller-retained mutation authority", () => { const source = createSetIdFileSystem(); const backend = createImmutableProductBackend(source); diff --git a/host/test/prepared-exec-target.test.ts b/host/test/prepared-exec-target.test.ts new file mode 100644 index 0000000000..f2102b8928 --- /dev/null +++ b/host/test/prepared-exec-target.test.ts @@ -0,0 +1,141 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +import { HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS } from "../src/generated/abi"; +import type { PreparedExecLaunchRequest } from "../src/exec-target"; + +const PREPARED_EXEC_EXPORTS = [ + "kernel_exec_target_prepare", + "kernel_spawn_exec_target_prepare", + "kernel_exec_target_size", + "kernel_exec_target_read", + "kernel_exec_target_cancel", + "kernel_exec_commit", + "kernel_spawn_exec_commit", +] as const; + +const LEGACY_PATHNAME_EXEC_EXPORTS = [ + "kernel_exec_prepare", + "kernel_exec_setup", + "kernel_exec_setup_for_thread", + "kernel_execve", + "kernel_execveat", +] as const; + +describe("prepared exec target ABI", () => { + it("keeps kernel commit authority out of the async launch request", () => { + type NoCommitAuthority = Extract< + "commit" | "target" | "ownerPid" | "callerTid", + keyof PreparedExecLaunchRequest + > extends never + ? "sealed" + : never; + const noCommitAuthority: NoCommitAuthority = "sealed"; + const source = readFileSync( + new URL("../src/exec-target.ts", import.meta.url), + "utf8", + ); + const requestStart = source.indexOf( + "export interface PreparedExecLaunchRequest", + ); + const requestEnd = source.indexOf("\n}", requestStart); + + expect(noCommitAuthority).toBe("sealed"); + expect(requestStart).toBeGreaterThanOrEqual(0); + const requestSource = source.slice(requestStart, requestEnd); + expect(requestSource).not.toContain("commit"); + expect(requestSource).not.toMatch(/\b(target|ownerPid|callerTid)\b/); + }); + + it("requires every exact-target operation and no targetless exec authority", () => { + expect(HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS).toEqual( + expect.arrayContaining(PREPARED_EXEC_EXPORTS), + ); + for (const legacy of LEGACY_PATHNAME_EXEC_EXPORTS) { + expect(HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS).not.toContain(legacy); + } + }); + + it("publishes only exact-target exec exports in the ABI snapshot", () => { + const snapshot = JSON.parse(readFileSync( + new URL("../../abi/snapshot.json", import.meta.url), + "utf8", + )) as { + kernel_exports: Array<{ name: string; signature: string }>; + }; + const execExports = snapshot.kernel_exports + .filter(({ name }) => + (PREPARED_EXEC_EXPORTS as readonly string[]).includes(name) + ) + .map(({ name, signature }) => ({ name, signature })); + expect(execExports).toEqual([ + { name: "kernel_exec_commit", signature: "(i32,i32,i32) -> (i32)" }, + { name: "kernel_exec_target_cancel", signature: "(i32,i32) -> (i32)" }, + { + name: "kernel_exec_target_prepare", + signature: "(i32,i32,i32,i32,i32,i32) -> (i32)", + }, + { + name: "kernel_exec_target_read", + signature: "(i32,i32,i32,i32,i32,i32) -> (i32)", + }, + { name: "kernel_exec_target_size", signature: "(i32,i32) -> (i64)" }, + { name: "kernel_spawn_exec_commit", signature: "(i32,i32,i32) -> (i32)" }, + { + name: "kernel_spawn_exec_target_prepare", + signature: "(i32,i32,i32,i32) -> (i32)", + }, + ]); + for (const legacy of LEGACY_PATHNAME_EXEC_EXPORTS) { + expect(snapshot.kernel_exports.find(({ name }) => name === legacy)) + .toBeUndefined(); + } + }); + + it("documents exact prepared-target authority without legacy pathname authority", () => { + const architecture = readFileSync( + new URL("../../docs/architecture.md", import.meta.url), + "utf8", + ); + const execFlowStart = architecture.indexOf("### exec()"); + const execFlowEnd = architecture.indexOf("\n### ", execFlowStart + 1); + expect(execFlowStart).toBeGreaterThanOrEqual(0); + expect(execFlowEnd).toBeGreaterThan(execFlowStart); + const execFlow = architecture.slice(execFlowStart, execFlowEnd); + const posixStatus = readFileSync( + new URL("../../docs/posix-status.md", import.meta.url), + "utf8", + ); + const execveatRow = posixStatus.split("\n") + .find((line) => line.startsWith("| `execveat()`")); + + expect(execFlow).toContain("materialization hint"); + expect(execFlow).toContain("diagnostic-only"); + expect(execFlow).toMatch(/replacement\s+`WebAssembly\.Memory`/); + for (const operation of [ + "kernel_exec_target_prepare", + "kernel_exec_target_size", + "kernel_exec_target_read", + "kernel_exec_target_cancel", + "kernel_exec_commit", + ]) { + expect(execFlow, `exec flow must name ${operation}`).toContain(operation); + expect(execveatRow, `execveat row must name ${operation}`).toContain(operation); + } + expect(execFlow).toMatch(/exact bytes.*ABI.*compil/is); + expect(execFlow).toMatch(/retained target.*revalidat.*commit/is); + expect(execveatRow).toContain("diagnostic-only"); + expect(execveatRow).toContain("replacement-memory preflight"); + expect(execveatRow).toMatch(/compil.*exact bytes.*ABI/is); + expect(execveatRow).toMatch(/revalidat.*commit/is); + + for (const legacy of [ + "kernel_exec_prepare", + "kernel_exec_setup_for_thread", + ]) { + expect(architecture).not.toMatch(new RegExp(`${legacy}(?:\\(|\\b)`)); + expect(execveatRow).not.toMatch(new RegExp(`${legacy}(?:\\(|\\b)`)); + } + expect(execFlow).not.toMatch(/resolves .*program map/is); + }); +}); diff --git a/host/test/process-memory-creator-gate.test.ts b/host/test/process-memory-creator-gate.test.ts index eb626a546e..67666cdbbd 100644 --- a/host/test/process-memory-creator-gate.test.ts +++ b/host/test/process-memory-creator-gate.test.ts @@ -92,6 +92,24 @@ describe("process memory creator destroy gate", () => { ]); }); + it("keeps transferred exec-plan ownership in the destroy drain until release", async () => { + const gate = new ProcessMemoryCreatorGate(); + const sweep = vi.fn(); + const admission = gate.acquire("an exec replacement plan"); + + const destroy = gate.closeAndRunAfterDrain(sweep); + await Promise.resolve(); + expect(sweep).not.toHaveBeenCalled(); + + admission.release(); + admission.release(); + await expect(destroy).resolves.toBeUndefined(); + expect(sweep).toHaveBeenCalledOnce(); + expect(() => gate.acquire("a late exec replacement plan")).toThrow( + "kernel worker is being destroyed; cannot start a late exec replacement plan", + ); + }); + it("releases admission when a creator throws", async () => { const gate = new ProcessMemoryCreatorGate(); const sweep = vi.fn(); diff --git a/host/test/spawn-host-parity.test.ts b/host/test/spawn-host-parity.test.ts index 5458ec010b..c2b122e693 100644 --- a/host/test/spawn-host-parity.test.ts +++ b/host/test/spawn-host-parity.test.ts @@ -35,6 +35,8 @@ const repoRoot = join(__dirname, "..", ".."); const nodeEntry = join(repoRoot, "host", "src", "node-kernel-worker-entry.ts"); const browserEntry = join(repoRoot, "host", "src", "browser-kernel-worker-entry.ts"); +const sharedWorker = join(repoRoot, "host", "src", "kernel-worker.ts"); +const sharedExecTarget = join(repoRoot, "host", "src", "exec-target.ts"); function posixSpawnHandlerSource(src: string): string { const start = src.indexOf("async function handlePosixSpawn("); @@ -52,6 +54,14 @@ function ordinaryForkHandlerSource(src: string): string { return src.slice(start, end); } +function execHandlerSource(src: string): string { + const start = src.indexOf("async function handleExec("); + const end = src.indexOf("\n/**\n * Handle SYS_SPAWN", start); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + return src.slice(start, end); +} + function centralizedInitMessageSource(handler: string): string { const start = handler.indexOf("const initData: CentralizedWorkerInitMessage"); expect(start).toBeGreaterThanOrEqual(0); @@ -101,6 +111,46 @@ function expectDeadStartUsesOrdinaryTeardown(handler: string, entry: string): vo } describe("spawn host parity", () => { + it("both exec callbacks carry one opaque prepared-target request", () => { + const shared = readFileSync(sharedWorker, "utf8"); + expect(shared).toMatch(/onExec\?:\s*ExecLaunchCallback/); + expect(shared).not.toMatch( + /onExec\?:\s*\(\s*pid:\s*number,\s*path:\s*string,/s, + ); + + for (const entry of [nodeEntry, browserEntry]) { + const source = readFileSync(entry, "utf8"); + expect(source, `${entry} must accept a target-shaped request`).toMatch( + /onExec:\s*async\s*\(request\)\s*=>[\s\S]*handleExec\(request\)/, + ); + expect(source, `${entry} must remove the Task 10 staging gate`).not.toContain( + "preparedExecTargetReaderPending", + ); + expect(source, `${entry} must not carry credential path authority`).not.toContain( + "credentialPath", + ); + const handler = execHandlerSource(source); + expect(handler, `${entry} must source replacement bytes from the target request`) + .toMatch(/targetBytes:\s*(?:programBytes|bytes)/); + expect(handler, `${entry} must send only target-derived bytes to the Worker`) + .toMatch(/(?:\bprogramBytes,|programBytes:\s*bytes,)/); + expect(handler, `${entry} must not receive kernel commit authority`) + .not.toMatch(/request\.commit\(\)|kernelExecCommit\(/); + expect( + handler, + `${entry} must return a bounded postcommit launch action`, + ).toContain("startAfterCommit"); + expect(handler, `${entry} must not resolve exec by path`).not.toMatch( + /resolveExecutableForLaunch|resolveExec\(|execPrograms|execProgramBytes|readFileSync/, + ); + } + expect( + readFileSync(sharedExecTarget, "utf8"), + "the shared launcher must own the only target commit", + ) + .toContain("options.commitTarget(target, targetBytes.byteLength)"); + }); + it("both hosts own the exact fork clone before their first async yield", () => { for (const entry of [nodeEntry, browserEntry]) { const handler = ordinaryForkHandlerSource(readFileSync(entry, "utf8")); diff --git a/host/test/spawn-pid-authority.test.ts b/host/test/spawn-pid-authority.test.ts index 64b8f6faf6..55f874c3e9 100644 --- a/host/test/spawn-pid-authority.test.ts +++ b/host/test/spawn-pid-authority.test.ts @@ -9,6 +9,7 @@ import { CAPTURED_STDIO, } from "../src/kernel-worker"; import { KernelReentrantEntryError } from "../src/kernel-entry-gate"; +import type { PreparedExecLaunchRequest } from "../src/exec-target"; import { WASM_PAGE_SIZE } from "../src/constants"; import { writeForkContinuationAnchor } from "../src/fork-continuation"; import { FORK_SAVE_BUFFER_SIZE } from "../src/process-memory"; @@ -19,6 +20,7 @@ import { CH_ARG_SIZE, CH_STATUS, CH_SYSCALL, + ABI_VERSION, HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS, HOST_INTERCEPTED_SYSCALLS, WPK_FORK_LINKED_FRAME_POINTER_WIDTHS, @@ -32,6 +34,9 @@ const WASM32_CONTINUATION_HEADER_SIZE = const TEST_FORK_CONTINUATION = 2 * WASM_PAGE_SIZE + WASM32_CONTINUATION_HEADER_SIZE; const UNTRACKED_THREAD_CHANNEL_OFFSET = 2 * WASM_PAGE_SIZE; +const preparedExecFixture = new Uint8Array( + readFileSync(join(repoRoot, "local-binaries/programs/wasm32/exec-child.wasm")), +); function publishMainForkContinuation( memory: WebAssembly.Memory, @@ -205,7 +210,19 @@ describe("kernel task-ID authority", () => { return executable.byteLength; }, ); - const onEmptyPathExec = vi.fn(async () => -2); + const onEmptyPathExec = vi.fn(async (request: PreparedExecLaunchRequest) => { + expect(request.pid).toBe(pid); + expect(Reflect.has(request, "ownerPid")).toBe(false); + expect(Reflect.has(request, "callerTid")).toBe(false); + expect(Reflect.has(request, "target")).toBe(false); + expect(Reflect.has(request, "commit")).toBe(false); + expect(request.diagnosticPath).toBe("/bin/program"); + expect(request.argv).toEqual([]); + expect(request.envp).toEqual([]); + expect(new Uint8Array(request.targetBytes)).toEqual(preparedExecFixture); + return -2; + }); + const prepareTarget = vi.fn(() => 31); const emptyPath = createTaskAuthorityHarness({ pid, callbacks: { onExec: onEmptyPathExec }, @@ -214,6 +231,31 @@ describe("kernel task-ID authority", () => { throw new Error("AT_EMPTY_PATH used a directory-only path getter"); }), kernel_get_fd_path: getRegularPath, + kernel_exec_target_prepare: prepareTarget, + kernel_exec_target_size: vi.fn(() => BigInt(preparedExecFixture.byteLength)), + kernel_exec_target_read: vi.fn(( + _ownerPid: number, + _target: number, + offsetLo: number, + offsetHi: number, + destination: number, + capacity: number, + ) => { + const offset = Number( + (BigInt(offsetHi >>> 0) << 32n) | BigInt(offsetLo >>> 0), + ); + const count = Math.min( + capacity, + preparedExecFixture.byteLength - offset, + ); + new Uint8Array( + emptyPath.kernelMemory.buffer, + destination, + count, + ).set(preparedExecFixture.subarray(offset, offset + count)); + return count; + }), + kernel_exec_target_cancel: vi.fn(() => 0), }, }); kernelBytes = new Uint8Array(emptyPath.kernelMemory.buffer); @@ -229,14 +271,16 @@ describe("kernel task-ID authority", () => { emptyPath.channel, ); await drainTaskAuthorityGate(); + await vi.waitFor(() => expect(onEmptyPathExec).toHaveBeenCalledOnce()); expect(getRegularPath).toHaveBeenCalledOnce(); - expect(onEmptyPathExec).toHaveBeenCalledWith( + expect(prepareTarget).toHaveBeenCalledWith( pid, - "/bin/program", - [], - [], pid, + fd, + emptyPath.scratchPointer, + 0, + 0x1000, ); }); @@ -555,8 +599,10 @@ describe("kernel task-ID authority", () => { it("requires every kernel child-allocation path at startup and artifact validation", () => { const requiredAuthorityExports = [ - "kernel_exec_prepare", - "kernel_exec_setup_for_thread", + "kernel_exec_target_prepare", + "kernel_spawn_exec_target_prepare", + "kernel_exec_commit", + "kernel_spawn_exec_commit", "kernel_fork_process", "kernel_spawn_process", "kernel_thread_exit", @@ -706,6 +752,7 @@ function createTaskAuthorityHarness( const worker = createCentralizedKernelWorkerTestDouble({ callbacks: options.callbacks, }); + Reflect.set(worker, "kernelAbiVersion", ABI_VERSION); const scratchPointer = installKernelWorkerTestScratch( worker, kernelMemory, diff --git a/host/test/support/kernel-scratch-instance.ts b/host/test/support/kernel-scratch-instance.ts index 8649c9874d..cbb84a19ba 100644 --- a/host/test/support/kernel-scratch-instance.ts +++ b/host/test/support/kernel-scratch-instance.ts @@ -64,12 +64,32 @@ function signatures( parameters: [pointer, i32], result: i32, }, - kernel_exec_prepare: { + kernel_exec_commit: { + parameters: [i32, i32, i32], + result: i32, + }, + kernel_exec_target_cancel: { parameters: [i32, i32], result: i32, }, - kernel_exec_setup_for_thread: { + kernel_exec_target_prepare: { + parameters: [i32, i32, i32, pointer, pointer, i32], + result: i32, + }, + kernel_exec_target_read: { + parameters: [i32, i32, i32, i32, pointer, pointer], + result: i32, + }, + kernel_exec_target_size: { parameters: [i32, i32], + result: i64, + }, + kernel_spawn_exec_commit: { + parameters: [i32, i32, i32], + result: i32, + }, + kernel_spawn_exec_target_prepare: { + parameters: [i32, i32, pointer, pointer], result: i32, }, kernel_fd_is_open: { diff --git a/packages/registry/kernel/build-kernel.sh b/packages/registry/kernel/build-kernel.sh index a37794498c..3470aafdf4 100755 --- a/packages/registry/kernel/build-kernel.sh +++ b/packages/registry/kernel/build-kernel.sh @@ -33,8 +33,11 @@ wasm_require_exports "$OUT" \ kernel_create_process \ kernel_create_process_with_stdio \ kernel_dequeue_signal \ - kernel_exec_prepare \ - kernel_exec_setup_for_thread \ + kernel_exec_commit \ + kernel_exec_target_cancel \ + kernel_exec_target_prepare \ + kernel_exec_target_read \ + kernel_exec_target_size \ kernel_fork_process \ kernel_get_cwd \ kernel_get_dirfd_path \ @@ -80,6 +83,8 @@ wasm_require_exports "$OUT" \ kernel_set_current_tid \ kernel_set_cwd \ kernel_shmid_ds_bytes \ + kernel_spawn_exec_commit \ + kernel_spawn_exec_target_prepare \ kernel_spawn_process \ kernel_spawn_reserved_process \ kernel_spawn_scratch_begin \ @@ -99,6 +104,8 @@ wasm_require_exports "$OUT" \ kernel_validate_task \ kernel_wait_child_poll +wasm_require_target_aware_exec_authority "$OUT" + if [ -n "${WASM_POSIX_DEP_OUT_DIR:-}" ]; then # WHY: a resolver build owns only its sealed output directory. Writing a # checkout-wide resolver mirror here would leak an untracked side effect diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index 6c94df2935..86e67c67be 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -74,8 +74,8 @@ "erlang-vfs": { "manifestSha256": "15efb74f1825e2b85bedfeb8d98c19fa648c4be39cb9defb65330a8f21eb9141", "cacheKeys": { - "wasm32": "5faf02e2dc9833eaef227471d2f82790f2e4406f0dcf8805f11d24d97543a7a4", - "wasm64": "2f72591b77662d57113331dc8f7f3a03c75b1415cf26b0adf41aa4b729342419" + "wasm32": "8f7181c48a9b5b3cde3d51a9b966fd04a99eba5dc7a429c31f39e73924004506", + "wasm64": "a09c85016a4f93094bf11f8c7cda736ad4a68b66669ba0c44510289413ce4470" } }, "fbdoom": { @@ -144,8 +144,8 @@ "kandelo-sdk": { "manifestSha256": "c081879f1becd855917cff96f17429bcf2b9fd61bf95211eca9ff8fb625bb2eb", "cacheKeys": { - "wasm32": "dc98bcfd4e6ad69eeb10b04715395a7a5772a240b168dc2827f07a48a4d0d3c0", - "wasm64": "6d898877c97a2edd4da6dc76b27738771394ba1b79497d0147f63a8c7cd353b1" + "wasm32": "bd4d62d3ff0e8c2fec8660d0235b58d61d05e7500725275ebbda2bf912d7f6f7", + "wasm64": "a4f727f9d25b5dceb8ea54434e9474ca7cf75afe6952d11c497be33c081f359d" } }, "kernel": { @@ -158,8 +158,8 @@ "lamp": { "manifestSha256": "250b64635d64f8537178188fb5488dbfd2980d79a1c2ffbf346af67008088ba0", "cacheKeys": { - "wasm32": "a94661681c2ed22a6d85605ce52a187a28b160cee3f0b22059dbc16f8329799a", - "wasm64": "8e2440276cef92168eefc39f59d9df2c210f16743b7d8d6195887de4cecc5841" + "wasm32": "88ffb0d0d271764a3686f271f496bba0b29577d574c1e4cdf769fd2fa6f3e44e", + "wasm64": "3cc9f430924898cf0406fb8d349fc35f51e43c34509a3c5344e4453128096f1b" } }, "less": { @@ -242,15 +242,15 @@ "mariadb-test": { "manifestSha256": "d4ccce161d659a5a87c80fa1117054bbccea870e0d4a46bd8a070d3930ad0e3f", "cacheKeys": { - "wasm32": "fec07b5ce79b2739d02d2cb83309781a6ab1354748c75df7f31803120b898a82", - "wasm64": "76bffb60f0681759a9563c45606feea5300e36ebf6777210b75b2e1c1d3d7396" + "wasm32": "ce482c4fb5a3dfb32ab9a236a4dbc1be1750b583bff304859d6676d38f25c4c5", + "wasm64": "9898127b28009ac13fb8a0ada939f4f1c0bbebbbd62aa5773b6a976c5c7be65f" } }, "mariadb-vfs": { "manifestSha256": "26e74ac84d89b2a839ed72783437061a23022ef2f88ad0f52b6aacd0442ee1cf", "cacheKeys": { - "wasm32": "46578c96408828cca65c7e137124559a32f2a0a61812c17c4edbce8a0112af74", - "wasm64": "980ef7bed8c2b5d76aaf01d0ec7165978a207bd305858b4419fa6a69903115bd" + "wasm32": "9998bd99380bacb6651f6b7a9404483656d85a09292fdbf2a2b38a6460221e0e", + "wasm64": "5066260266c8837b74b4a9e6b5deee2a5565f568ceaf638f46f08cee2ac6068d" } }, "modeset": { @@ -312,15 +312,15 @@ "nginx-php-vfs": { "manifestSha256": "97976410cb02f8ba710d856b4ac904bcf976d677b50eefdee39fb64176070d4b", "cacheKeys": { - "wasm32": "6cb5b584eaadfadf743d253ec7472b0fb0aa0dd83c65a38169811240ecfef305", - "wasm64": "18cfce4685e91d1ad73afab04154a07b044cd2dfeedfaab0012878cc135025f9" + "wasm32": "b582a6a30deea8417405bdba9adbba3fc799f5c2383e868dc83e176405a5788c", + "wasm64": "d21c2c99323c4736765f449dca4bc964bf2b8252faa77ddeb256608f966524f7" } }, "nginx-vfs": { "manifestSha256": "46aa2d3250ac5cd0f102a85c3a36a086c7a50ba1f9e05fa1c2aae3d07ad16f10", "cacheKeys": { - "wasm32": "f81a75e080bb17dc0a25d6f1686504ba75b0c3661bbdb5ef1611adfd22d83e6a", - "wasm64": "12603fd210c648b0b9f47ad5629a9ff0df65e064624007dd40be0bf83a91f928" + "wasm32": "28f1657a0e76d31eff503f6576ccea247632de6100dffd2f7b3e3f13ffc33632", + "wasm64": "939e8b67b1e3fe80093273e47fdffd67423985f421a0ba7d78079c590f02ba59" } }, "node": { @@ -333,8 +333,8 @@ "node-vfs": { "manifestSha256": "977f219defe57e18017ecc963159df32efdd21c53d6fea05943703d04739d8de", "cacheKeys": { - "wasm32": "9604a7e34ea2211523bed0f7185efe2c1b388c7f7093c04144fe88c9ba473f90", - "wasm64": "f8d2c9e823a41f1c5b4b1dcd704dcde3996e66a4b43a5ea905c98acce0f30b43" + "wasm32": "adf2ac7a772c1f870f52304383c7fa796e8118174b24991388657d315854a195", + "wasm64": "6c15ddc14c9551a32df0d9d8f0b96ca36d213414529753a57f04e5c026a2635f" } }, "openssl": { @@ -361,8 +361,8 @@ "perl-vfs": { "manifestSha256": "1345cc102bc8c24a6bc276410c00b4fcc99ba5f6adc9b031f882aaa35d53c8bc", "cacheKeys": { - "wasm32": "acadee4d8f1e4099ec6e6c4673e4ee5f85efb177bb95bcdfc163eaee1627c3b5", - "wasm64": "d04867569c17a2b0f30f588aeceaa4a91866a908fc3ca00a642645220070942a" + "wasm32": "c9d067a16fac628ec814814dc5f0f26e6c40ffcbe42e15e046878323749dabb1", + "wasm64": "dba3fe1f77e2c0e750f0f7b2a1516d254720fc15ac7e4ad45f7da03f93837886" } }, "php": { @@ -382,8 +382,8 @@ "python-vfs": { "manifestSha256": "d1aa99feae65f06c0ca8cf52c2176534b2723fd4ee6eba6e72c205245e1c9c26", "cacheKeys": { - "wasm32": "c2750749e26f91b085d37a475deb8ae645f039d71f3a98dca0dc312d91bc78d5", - "wasm64": "a587588700974b6b40001fcf408593dc264cc082f3a9358f9ddcd3d0ad9c725f" + "wasm32": "5f51c1ef484457d6621c2e5c3ed2ed84499897319a700e0d53960188b2ea2c17", + "wasm64": "08da49cc391eed5977f52da11b1dc3acd8ea82aa703ef150c656bc06c872f515" } }, "redis": { @@ -396,15 +396,15 @@ "redis-vfs": { "manifestSha256": "234c45c40f94e2a89f98295313b82cb9fce15a412ac829d9e87394e0dc731813", "cacheKeys": { - "wasm32": "b5e03fbcd0a4f58df9d6d3697e7be136d72f4546c5a668b4e4d80155df404537", - "wasm64": "1f5986669365aae264986398355fcfc32764f7e0f7f9c9c795ec935079ee2683" + "wasm32": "1045094260a96f080ba96edea9c1ad457f02aa1ad2f69b78f52ed6c52478065c", + "wasm64": "7a9959f8df59181bcfebf29227bcafbe5cc9503ce8e126f56ec552080a132afe" } }, "rootfs": { "manifestSha256": "0420201025170f48c32949ac62707d4577cdc13a53ad1f01f59d318ec07c8d6e", "cacheKeys": { - "wasm32": "0aeef8161c450202f54e6aa95d41a4561ca59274878f596d6239457475913652", - "wasm64": "a93f084831163268a7e73dd8f741317e44b769fcf6cea2536a8186dd5e4c0f69" + "wasm32": "35777ccb64bcda5784e2252fb02c7ed0b5ec5d9f73d9c10e5d1ddcea97699cd2", + "wasm64": "73e0b78a3b128b79c9fc65d6f1dcc01f9376d5f045e6f24c54ca53f8c68a80c2" } }, "ruby": { @@ -452,8 +452,8 @@ "shell": { "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", "cacheKeys": { - "wasm32": "ecd448c7d47adcfdf2ea93ef1f0af98d61ca70ce2fe71e7c72055ef56a4a8265", - "wasm64": "575d8eedf6e3257668e998fd06def2301fc958796b0ecb7377612d9efc84758e" + "wasm32": "0494df11f0e4e40e0138cca31e1a2fcd983b28e10392543fd8e1492609fd76a9", + "wasm64": "61056569bf5ed94acaeac08b1c193252d4cf2d0d469f52ed76f3405d3c3bbf24" } }, "spidermonkey": { @@ -543,8 +543,8 @@ "wordpress": { "manifestSha256": "36465e0596a06e855a8524eecfd87da98643bc13b37ee12939f5aab44bf33418", "cacheKeys": { - "wasm32": "ed6338ea1f095ac13ce4b7dad3d81ef18ce08919e7bec0fec9940e843d2c9528", - "wasm64": "7618e1fbdbe1c26767f7549bb0dc49418f3e2ae19e065c1529324ac0c0731a76" + "wasm32": "445dc1e6893f39932e4c688040fb56584e5a9be6f1a1f0b0e0a745ac18cd5d2b", + "wasm64": "b656234c6f15fd06b17229f2b3387533621cefcd8fbb0880a27477ac81a958bc" } }, "xz": { @@ -871,7 +871,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "5faf02e2dc9833eaef227471d2f82790f2e4406f0dcf8805f11d24d97543a7a4" + "wasm32": "8f7181c48a9b5b3cde3d51a9b966fd04a99eba5dc7a429c31f39e73924004506" }, "dependencyClosures": { "wasm32": [ @@ -1094,7 +1094,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "dc98bcfd4e6ad69eeb10b04715395a7a5772a240b168dc2827f07a48a4d0d3c0" + "wasm32": "bd4d62d3ff0e8c2fec8660d0235b58d61d05e7500725275ebbda2bf912d7f6f7" }, "dependencyClosures": { "wasm32": [ @@ -1121,7 +1121,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a94661681c2ed22a6d85605ce52a187a28b160cee3f0b22059dbc16f8329799a" + "wasm32": "88ffb0d0d271764a3686f271f496bba0b29577d574c1e4cdf769fd2fa6f3e44e" }, "dependencyClosures": { "wasm32": [ @@ -1193,7 +1193,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "ecd448c7d47adcfdf2ea93ef1f0af98d61ca70ce2fe71e7c72055ef56a4a8265" + "cacheKey": "0494df11f0e4e40e0138cca31e1a2fcd983b28e10392543fd8e1492609fd76a9" }, { "packageName": "sqlite", @@ -1360,7 +1360,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "fec07b5ce79b2739d02d2cb83309781a6ab1354748c75df7f31803120b898a82" + "wasm32": "ce482c4fb5a3dfb32ab9a236a4dbc1be1750b583bff304859d6676d38f25c4c5" }, "dependencyClosures": { "wasm32": [ @@ -1413,8 +1413,8 @@ "wasm64" ], "cacheKeys": { - "wasm32": "46578c96408828cca65c7e137124559a32f2a0a61812c17c4edbce8a0112af74", - "wasm64": "980ef7bed8c2b5d76aaf01d0ec7165978a207bd305858b4419fa6a69903115bd" + "wasm32": "9998bd99380bacb6651f6b7a9404483656d85a09292fdbf2a2b38a6460221e0e", + "wasm64": "5066260266c8837b74b4a9e6b5deee2a5565f568ceaf638f46f08cee2ac6068d" }, "dependencyClosures": { "wasm32": [ @@ -1746,7 +1746,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "6cb5b584eaadfadf743d253ec7472b0fb0aa0dd83c65a38169811240ecfef305" + "wasm32": "b582a6a30deea8417405bdba9adbba3fc799f5c2383e868dc83e176405a5788c" }, "dependencyClosures": { "wasm32": [ @@ -1808,7 +1808,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "ecd448c7d47adcfdf2ea93ef1f0af98d61ca70ce2fe71e7c72055ef56a4a8265" + "cacheKey": "0494df11f0e4e40e0138cca31e1a2fcd983b28e10392543fd8e1492609fd76a9" }, { "packageName": "sqlite", @@ -1838,7 +1838,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f81a75e080bb17dc0a25d6f1686504ba75b0c3661bbdb5ef1611adfd22d83e6a" + "wasm32": "28f1657a0e76d31eff503f6576ccea247632de6100dffd2f7b3e3f13ffc33632" }, "dependencyClosures": { "wasm32": [ @@ -1860,7 +1860,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "ecd448c7d47adcfdf2ea93ef1f0af98d61ca70ce2fe71e7c72055ef56a4a8265" + "cacheKey": "0494df11f0e4e40e0138cca31e1a2fcd983b28e10392543fd8e1492609fd76a9" } ] }, @@ -1922,7 +1922,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "9604a7e34ea2211523bed0f7185efe2c1b388c7f7093c04144fe88c9ba473f90" + "wasm32": "adf2ac7a772c1f870f52304383c7fa796e8118174b24991388657d315854a195" }, "dependencyClosures": { "wasm32": [ @@ -1944,7 +1944,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "ecd448c7d47adcfdf2ea93ef1f0af98d61ca70ce2fe71e7c72055ef56a4a8265" + "cacheKey": "0494df11f0e4e40e0138cca31e1a2fcd983b28e10392543fd8e1492609fd76a9" }, { "packageName": "spidermonkey", @@ -1995,7 +1995,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "acadee4d8f1e4099ec6e6c4673e4ee5f85efb177bb95bcdfc163eaee1627c3b5" + "wasm32": "c9d067a16fac628ec814814dc5f0f26e6c40ffcbe42e15e046878323749dabb1" }, "dependencyClosures": { "wasm32": [ @@ -2418,7 +2418,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c2750749e26f91b085d37a475deb8ae645f039d71f3a98dca0dc312d91bc78d5" + "wasm32": "5f51c1ef484457d6621c2e5c3ed2ed84499897319a700e0d53960188b2ea2c17" }, "dependencyClosures": { "wasm32": [ @@ -2478,7 +2478,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b5e03fbcd0a4f58df9d6d3697e7be136d72f4546c5a668b4e4d80155df404537" + "wasm32": "1045094260a96f080ba96edea9c1ad457f02aa1ad2f69b78f52ed6c52478065c" }, "dependencyClosures": { "wasm32": [ @@ -2515,7 +2515,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0aeef8161c450202f54e6aa95d41a4561ca59274878f596d6239457475913652" + "wasm32": "35777ccb64bcda5784e2252fb02c7ed0b5ec5d9f73d9c10e5d1ddcea97699cd2" }, "dependencyClosures": { "wasm32": [ @@ -2728,7 +2728,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ecd448c7d47adcfdf2ea93ef1f0af98d61ca70ce2fe71e7c72055ef56a4a8265" + "wasm32": "0494df11f0e4e40e0138cca31e1a2fcd983b28e10392543fd8e1492609fd76a9" }, "dependencyClosures": { "wasm32": [] @@ -3020,7 +3020,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ed6338ea1f095ac13ce4b7dad3d81ef18ce08919e7bec0fec9940e843d2c9528" + "wasm32": "445dc1e6893f39932e4c688040fb56584e5a9be6f1a1f0b0e0a745ac18cd5d2b" }, "dependencyClosures": { "wasm32": [ @@ -3082,7 +3082,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "ecd448c7d47adcfdf2ea93ef1f0af98d61ca70ce2fe71e7c72055ef56a4a8265" + "cacheKey": "0494df11f0e4e40e0138cca31e1a2fcd983b28e10392543fd8e1492609fd76a9" }, { "packageName": "sqlite", diff --git a/run.sh b/run.sh index 0b7d5f507a..5dbd6e1714 100755 --- a/run.sh +++ b/run.sh @@ -272,8 +272,11 @@ KERNEL_REQUIRED_EXPORTS=( kernel_create_process kernel_create_process_with_stdio kernel_dequeue_signal - kernel_exec_prepare - kernel_exec_setup_for_thread + kernel_exec_commit + kernel_exec_target_cancel + kernel_exec_target_prepare + kernel_exec_target_read + kernel_exec_target_size kernel_fork_process kernel_get_cwd kernel_get_dirfd_path @@ -319,6 +322,8 @@ KERNEL_REQUIRED_EXPORTS=( kernel_set_current_tid kernel_set_cwd kernel_shmid_ds_bytes + kernel_spawn_exec_commit + kernel_spawn_exec_target_prepare kernel_spawn_process kernel_spawn_reserved_process kernel_spawn_scratch_begin @@ -346,7 +351,8 @@ has_valid_kernel_file() { current_abi="$(wasm_current_abi_version "$REPO_ROOT" || true)" ! wasm_has_legacy_asyncify "$path" && ! wasm_has_stale_abi "$path" "$current_abi" && - ! wasm_has_missing_exports "$path" "${KERNEL_REQUIRED_EXPORTS[@]}" + ! wasm_has_missing_exports "$path" "${KERNEL_REQUIRED_EXPORTS[@]}" && + wasm_require_target_aware_exec_authority "$path" >/dev/null 2>&1 } # pkg_xtask_bin: build xtask once (lazy) and return the binary path so diff --git a/scripts/resolve-binary.bundle.mjs b/scripts/resolve-binary.bundle.mjs index 972b72fdf7..a05ddae790 100644 --- a/scripts/resolve-binary.bundle.mjs +++ b/scripts/resolve-binary.bundle.mjs @@ -1,13 +1,13 @@ // Generated by scripts/build-resolve-binary-bundle.sh; do not edit. Third-party notices: resolve-binary.bundle.LICENSES.txt -var ua=Object.defineProperty;var Ir=(n,e,t)=>()=>{if(t)throw t[0];try{return n&&(e=n(n=0)),e}catch(r){throw t=[r],r}};var Fi=(n,e)=>{for(var t in e)ua(n,t,{get:e[t],enumerable:!0})};var Nt,Ni,Ct,Ci,dn,Mi,ne,Di,Ki,Rr,Bi,ln,$i,Ui,Wi,Gi,xr,Tr,fn,hn,pn,mn,_n,pt,Mt,Dt,Kt,ve,Hi,Vi,vr,Se,qi,Lr,yn,zr,br,mt,Bt,$e,gn,En,Zi,Pr,Xi,X,Yi,ji,kr,$t,Ji,Qi,eo,Y,to,ro,Fr,Ut,no,io,Sn,wn,et,_t,On,Wt,Gt,W,Ht,oo,so,q,tt=Ir(()=>{"use strict";Nt="kandelo.wpk_fork.linked_frames",Ni=[75,76,67,70],Ct=24,Ci=8,dn=3,Mi=[{bytes:4,chunkHeaderSize:32,nodeHeaderSize:24},{bytes:8,chunkHeaderSize:56,nodeHeaderSize:32}],ne="kandelo.wpk_fork.module_state",Di=1,Ki=[75,70,77,68],Rr=24,Bi=8,ln=7,$i=1,Ui=1,Wi=1,Gi=[{bytes:4,chunkHeaderSize:40},{bytes:8,chunkHeaderSize:56}],xr="__wpk_fork_global_",Tr="__wpk_fork_table_",fn=1,hn=2,pn=3,mn=4,_n=5,pt=6,Mt=7,Dt=8,Kt=9,ve="kandelo.wpk_fork.capabilities",Hi=1,Vi=7,vr=4,Se="kandelo.wpk_fork.exception_codec",qi=1,Lr=8,yn=16,zr="env",br="__wpk_fork_unwind",mt="kandelo.wpk_fork.unwind_transport",Bt="__wpk_fork_static_root_catalog",$e="kandelo.wpk_fork.static_root_catalog",gn=1,En=0,Zi=1,Pr=12,Xi=[75,70,83,82],X="kandelo.wpk_fork.imported_globals",Yi=[75,70,73,71],ji=1,kr=16,$t=24,Ji=1,Qi=2,eo=3,Y="kandelo.wpk_fork.imported_tables",to=[75,70,73,84],ro=1,Fr=16,Ut=24,no=1,io=1,Sn="env",wn="__wpk_fork_module_activation",et={module:"kernel",name:"kernel_fork",params:["i32"],results:["i32"]},_t=[{module:"env",name:"__wpk_fork_frame_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_frame_next",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_peek",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_reserve",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_record_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_module_state_record_find",params:["i32","i32","i32","i32"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_record_reserve",params:["i32","i32","i32","ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_table_dirty_count",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_module_state_table_dirty_mark",params:["i32","i64","i64"],results:[]},{module:"env",name:"__wpk_fork_module_state_table_dirty_page",params:["i32","i32"],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_mutation_abort",params:[],results:[]},{module:"env",name:"__wpk_fork_module_state_table_mutation_begin",params:[],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_mutation_commit",params:["i32","i64","i64"],results:[]},{module:"env",name:"__wpk_fork_module_state_table_reconcile",params:[],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_state_owned",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_decode_funcref",params:["i32"],results:["funcref"]},{module:"env",name:"__wpk_fork_ref_encode_funcref",params:["funcref"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_broker_encode",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_broker_throw_recipe",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_cache_index",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_claim",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_define",params:["i32","i32","i32","i32","ptr","i32","ptr","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_ingress_throw",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_load",params:["i32","i32","i32","i32","ptr","i32","ptr","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_lookup",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_route",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_broker_encode",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_capture_layout",params:["i32","i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_claim",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_define",params:["i32","i32","i32","i32","i32","ptr","i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_i31",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_load",params:["i32","i32","i32","i32","i32","ptr","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_lookup",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_payload_len",params:["i32","i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_provenance_begin",params:["i32","i32","i32","i32","i64","i64","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_provenance_end",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_provenance_ref",params:["i32","i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_route",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_scratch_release",params:["ptr","ptr"],results:[]},{module:"env",name:"__wpk_fork_ref_scratch_reserve",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_ref_vector_append",params:["i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_vector_begin",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_vector_finish",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_vector_get",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_resume_peek",params:["i32"],results:["i32"]}],On=[{module:"env",name:"__wpk_fork_ref_gc_transit",table64:!1,element:"anyref",minimum:1,maximum:null},{module:"env",name:"__wpk_fork_resume_table",table64:!1,element:"funcref",minimum:1,maximum:null}],Wt=[{name:"__wpk_fork_exception_materialize",params:["i32"],results:[]},{name:"__wpk_fork_ref_decode_exnref",params:["i32"],results:["exnref"]},{name:"__wpk_fork_ref_encode_exnref",params:["exnref"],results:["i32"]},{name:"__wpk_fork_ref_exn_abort",params:[],results:[]},{name:"__wpk_fork_ref_exn_clear",params:[],results:[]},{name:"__wpk_fork_ref_exn_encode_ingress",params:["i32"],results:["i32"]},{name:"__wpk_fork_ref_exn_throw_recipe",params:["i32"],results:[]},{name:"__wpk_fork_ref_exn_throw_slot",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_allocate",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_encode_slot",params:["i32"],results:["i32"]},{name:"__wpk_fork_ref_gc_fill",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_probe",params:["i32"],results:["i64"]},{name:"__wpk_fork_ref_gc_publish_externref",params:["i32","externref"],results:[]},{name:"__wpk_fork_static_root_harvest",params:[],results:[]},{name:"wpk_fork_abort_begin",params:["ptr"],results:[]},{name:"wpk_fork_abort_end",params:[],results:[]},{name:"wpk_fork_module_bootstrap",params:[],results:[]},{name:"wpk_fork_module_state_finish_restore",params:["i32"],results:[]},{name:"wpk_fork_module_state_restore",params:["i32"],results:[]},{name:"wpk_fork_module_state_save",params:["i32"],results:[]},{name:"wpk_fork_module_table_state_restore",params:["i32"],results:[]},{name:"wpk_fork_module_table_state_save",params:["i32"],results:[]},{name:"wpk_fork_module_thread_bootstrap",params:[],results:[]},{name:"wpk_fork_rewind_begin",params:["ptr"],results:[]},{name:"wpk_fork_rewind_end",params:[],results:[]},{name:"wpk_fork_state",params:[],results:["i32"]},{name:"wpk_fork_unwind_begin",params:["ptr"],results:[]},{name:"wpk_fork_unwind_end",params:[],results:[]}],Gt={O_RDONLY:0,O_WRONLY:1,O_RDWR:2,O_ACCMODE:3,O_CREAT:64,O_EXCL:128,O_NOCTTY:256,O_TRUNC:512,O_APPEND:1024,O_NONBLOCK:2048,O_ASYNC:8192,O_DIRECTORY:65536,O_NOFOLLOW:131072,O_CLOEXEC:524288,O_PATH:2097152,O_CLOFORK:8388608},W={S_IFMT:61440,S_IFSOCK:49152,S_IFLNK:40960,S_IFREG:32768,S_IFBLK:24576,S_IFDIR:16384,S_IFCHR:8192,S_IFIFO:4096,S_ISUID:2048,S_ISGID:1024,S_ISVTX:512,S_IRWXU:448,S_IRUSR:256,S_IWUSR:128,S_IXUSR:64,S_IRWXG:56,S_IRGRP:32,S_IWGRP:16,S_IXGRP:8,S_IRWXO:7,S_IROTH:4,S_IWOTH:2,S_IXOTH:1,S_MODE_BITS:4095},Ht={DT_UNKNOWN:0,DT_FIFO:1,DT_CHR:2,DT_DIR:4,DT_BLK:6,DT_REG:8,DT_LNK:10,DT_SOCK:12},oo=4096,so=["__abi_version","kernel_alloc_scratch","kernel_blocking_retry_release","kernel_blocking_retry_token","kernel_commit_process_exit","kernel_create_process","kernel_create_process_with_stdio","kernel_dequeue_signal","kernel_exec_prepare","kernel_exec_setup_for_thread","kernel_fork_process","kernel_get_cwd","kernel_get_dirfd_path","kernel_get_fd_path","kernel_get_parent_pid","kernel_get_process_exit_signal","kernel_get_process_state","kernel_get_socket_timeout_ms","kernel_handle_channel","kernel_has_sa_nocldstop","kernel_host_adapter_manifest_len","kernel_host_adapter_manifest_ptr","kernel_ipc_shm_lookup_mapping_for_task","kernel_ipc_shm_record_mapping_for_process","kernel_ipc_shm_record_mapping_for_task","kernel_ipc_shmat_for_process","kernel_ipc_shmat_for_task","kernel_ipc_shmdt_addr_for_process","kernel_ipc_shmdt_addr_for_task","kernel_ipc_shmdt_for_process","kernel_ipc_shmdt_for_task","kernel_is_fd_nonblock","kernel_mark_process_signaled","kernel_mq_descriptor_msgsize","kernel_msqid_ds_bytes","kernel_pcm_claim_transport","kernel_pcm_clock_update","kernel_pcm_reconcile","kernel_pcm_transport_len","kernel_pcm_transport_ptr","kernel_pick_signal_target_tid","kernel_pick_tcp_listener_target","kernel_pipe_has_readers","kernel_posix_timer_fire","kernel_process_metadata_begin","kernel_process_metadata_cancel","kernel_process_metadata_commit","kernel_process_metadata_stage","kernel_reap_exited_child","kernel_remove_process","kernel_semctl_array_bytes","kernel_semid_ds_bytes","kernel_set_current_tid","kernel_set_cwd","kernel_shmid_ds_bytes","kernel_spawn_process","kernel_spawn_reserved_process","kernel_spawn_scratch_begin","kernel_spawn_scratch_cancel","kernel_spawn_scratch_capacity","kernel_spawn_scratch_pointer","kernel_spawn_scratch_retained_capacity","kernel_take_process_timer_cleanup","kernel_thread_exit","kernel_thread_has_deliverable","kernel_transfer_channel_execute","kernel_transfer_io_execute","kernel_transfer_scratch_begin","kernel_transfer_scratch_cancel","kernel_transfer_scratch_capacity","kernel_transfer_scratch_pointer","kernel_validate_task","kernel_wait_child_poll"],q={LINK_MAX:0,MAX_CANON:1,MAX_INPUT:2,NAME_MAX:3,PATH_MAX:4,PIPE_BUF:5,CHOWN_RESTRICTED:6,NO_TRUNC:7,VDISABLE:8,SYNC_IO:9,ASYNC_IO:10,PRIO_IO:11,SOCK_MAXBUF:12,FILESIZEBITS:13,REC_INCR_XFER_SIZE:14,REC_MAX_XFER_SIZE:15,REC_MIN_XFER_SIZE:16,REC_XFER_ALIGN:17,ALLOC_SIZE_MIN:18,SYMLINK_MAX:19,POSIX2_SYMLINKS:20,FALLOC:21,TEXTDOMAIN_MAX:22,TIMESTAMP_RESOLUTION:23}});import{createRequire as Pc}from"module";function es(n,e){return Qo(n,{i:2},e&&e.out,e&&e.dictionary)}var kc,vt,Fc,Nc,se,xt,Cc,Vo,qo,Mc,Zo,vt,Xo,Dc,Yo,Kc,el,Yn,Pe,M,cr,ur,M,M,M,M,jo,M,Bc,$c,Zn,Ae,Xn,Jo,Hr,Uc,_e,Qo,Wc,Gc,Tt,ts,Hc,Vc,jn=Ir(()=>{kc=Pc("/");try{vt=kc("worker_threads"),Fc=vt.Worker,Nc=vt.isMarkedAsUntransferable}catch{}se=Uint8Array,xt=Uint16Array,Cc=Int32Array,Vo=new se([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),qo=new se([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),Mc=new se([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),Zo=function(n,e){for(var t=new xt(31),r=0;r<31;++r)t[r]=e+=1<>1|(M&21845)<<1,Pe=(Pe&52428)>>2|(Pe&13107)<<2,Pe=(Pe&61680)>>4|(Pe&3855)<<4,Yn[M]=((Pe&65280)>>8|(Pe&255)<<8)>>1;cr=(function(n,e,t){for(var r=n.length,i=0,s=new xt(e);i>a]=u}else for(c=new xt(r),i=0;i>15-n[i]);return c}),ur=new se(288);for(M=0;M<144;++M)ur[M]=8;for(M=144;M<256;++M)ur[M]=9;for(M=256;M<280;++M)ur[M]=7;for(M=280;M<288;++M)ur[M]=8;jo=new se(32);for(M=0;M<32;++M)jo[M]=5;Bc=cr(ur,9,1),$c=cr(jo,5,1),Zn=function(n){for(var e=n[0],t=1;te&&(e=n[t]);return e},Ae=function(n,e,t){var r=e/8|0;return(n[r]|n[r+1]<<8)>>(e&7)&t},Xn=function(n,e){var t=e/8|0;return(n[t]|n[t+1]<<8|n[t+2]<<16)>>(e&7)},Jo=function(n){return(n+7)/8|0},Hr=function(n,e,t){return(e==null||e<0)&&(e=0),(t==null||t>n.length)&&(t=n.length),new se(n.subarray(e,t))},Uc=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],_e=function(n,e,t){var r=new Error(e||Uc[n]);if(r.code=n,Error.captureStackTrace&&Error.captureStackTrace(r,_e),!t)throw r;return r},Qo=function(n,e,t,r){var i=n.length,s=r?r.length:0;if(!i||e.f&&!e.l)return t||new se(0);var o=!t,c=o||e.i!=2,a=e.i;o&&(t=new se(i*3));var u=function(Ke){var Be=t.length;if(Ke>Be){var Ar=new se(Math.max(Be*2,Ke));Ar.set(t),t=Ar}},l=e.f||0,d=e.p||0,p=e.b||0,m=e.l,f=e.d,h=e.m,g=e.n,_=i*8;do{if(!m){l=Ae(n,d,1);var y=Ae(n,d+1,3);if(d+=3,y)if(y==1)m=Bc,f=$c,h=9,g=5;else if(y==2){var S=Ae(n,d,31)+257,A=Ae(n,d+10,15)+4,R=S+Ae(n,d+5,31)+1;d+=14;for(var x=new se(R),v=new se(19),L=0;L>4;if(E<16)x[L++]=E;else{var U=0,le=0;for(E==16?(le=3+Ae(n,d,3),d+=2,U=x[L-1]):E==17?(le=3+Ae(n,d,7),d+=3):E==18&&(le=11+Ae(n,d,127),d+=7);le--;)x[L++]=U}}var C=x.subarray(0,S),V=x.subarray(S);h=Zn(C),g=Zn(V),m=cr(C,h,1),f=cr(V,g,1)}else _e(1);else{var E=Jo(d)+4,O=n[E-4]|n[E-3]<<8,w=E+O;if(w>i){a&&_e(0);break}c&&u(p+O),t.set(n.subarray(E,w),p),e.b=p+=O,e.p=d=w*8,e.f=l;continue}if(d>_){a&&_e(0);break}}c&&u(p+131072);for(var kt=(1<>4;if(d+=U&15,d>_){a&&_e(0);break}if(U||_e(2),Te<256)t[p++]=Te;else if(Te==256){Je=d,m=null;break}else{var Ft=Te-254;if(Te>264){var L=Te-257,Me=Vo[L];Ft=Ae(n,d,(1<>4;ft||_e(3),d+=ft&15;var V=Kc[Ee];if(Ee>3){var Me=qo[Ee];V+=Xn(n,d)&(1<_){a&&_e(0);break}c&&u(p+131072);var De=p+Ft;if(p>3&1)+(e>>4&1);r>0;r-=!n[t++]);return t+(e&2)},Tt=(function(){function n(e,t){typeof e=="function"&&(t=e,e={}),this.ondata=t;var r=e&&e.dictionary&&e.dictionary.subarray(-32768);this.s={i:0,b:r?r.length:0},this.o=new se(32768),this.p=new se(0),r&&this.o.set(r)}return n.prototype.e=function(e){if(this.ondata||_e(5),this.d&&_e(4),!this.p.length)this.p=e;else if(e.length){var t=new se(this.p.length+e.length);t.set(this.p),t.set(e,this.p.length),this.p=t}},n.prototype.c=function(e){this.s.i=+(this.d=e||!1);var t=this.s.b,r=Qo(this.p,this.s,this.o);this.ondata(Hr(r,t,this.s.b),this.d),this.o=Hr(r,this.s.b-32768),this.s.b=this.o.length,this.p=Hr(this.p,this.s.p/8|0),this.s.p&=7},n.prototype.push=function(e,t){this.e(e),this.c(t)},n})();ts=(function(){function n(e,t){this.v=1,this.r=0,Tt.call(this,e,t)}return n.prototype.push=function(e,t){if(Tt.prototype.e.call(this,e),this.r+=e.length,this.v){var r=this.p.subarray(this.v-1),i=r.length>3?Gc(r):4;if(i>r.length){if(!t)return}else this.v>1&&this.onmember&&this.onmember(this.r-r.length);this.p=r.subarray(i),this.v=0}Tt.prototype.c.call(this,0),this.s.f&&!this.s.l?(this.v=Jo(this.s.p)+9,this.s={i:0},this.o=new se(0),this.push(new se(0),t)):t&&Tt.prototype.c.call(this,t)},n})(),Hc=typeof TextDecoder<"u"&&new TextDecoder,Vc=0;try{Hc.decode(Wc,{stream:!0}),Vc=1}catch{}});var ei={};Fi(ei,{extractZipEntry:()=>eu,extractZipEntryBounded:()=>tu,fetchZipCentralDirectory:()=>nu,parseZipCentralDirectory:()=>dr});function as(n){let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=Math.max(0,n.length-is);for(let r=n.length-Xc;r>=t;r--)if(e.getUint32(r,!0)===qc)return r;throw new Error("Zip EOCD record not found")}function dr(n){let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=as(n),r=e.getUint16(t+10,!0),i=e.getUint32(t+16,!0),s=[],o=i;for(let c=0;c>8,O;E===rs?O=h>>16&65535:y.startsWith("bin/")||y.startsWith("sbin/")||y.includes("/bin/")||y.includes("/sbin/")?O=493:O=420;let w=y.endsWith("/"),S=E===rs&&(O&jc)===Yc;s.push({fileName:y,fileNameBytes:_,compressedSize:l,uncompressedSize:d,compressionMethod:u,localHeaderOffset:g,mode:O,isDirectory:w,isSymlink:S,externalAttrs:h,creatorOS:E}),o+=Jn+p+m+f}return s}function cs(n,e){if(n.byteLength!==e.byteLength)return!1;for(let t=0;t{if(c.byteLength>t-s)throw new Error(`ZIP member ${e.fileName} expands beyond ${t} bytes`);i.set(c,s),s+=c.byteLength}).push(r,!0),s!==t)throw new Error(`ZIP member ${e.fileName} expanded ${s} bytes, expected ${t}`);return i}function ru(n,e){let t=new DataView(n.buffer,n.byteOffset,n.byteLength),r=e.localHeaderOffset;if(r<0||r>n.byteLength-Qn||t.getUint32(r,!0)!==ns)throw new Error(`Invalid local file header signature at offset ${r}`);let i=t.getUint16(r+8,!0),s=t.getUint16(r+26,!0),o=t.getUint16(r+28,!0),c=r+Qn,a=c+s+o,u=a+e.compressedSize;if(i!==e.compressionMethod||an.byteLength||!cs(n.subarray(c,c+s),e.fileNameBytes))throw new Error(`ZIP member ${e.fileName} has inconsistent local metadata`);return n.subarray(a,u)}async function nu(n){let e=await fetch(n,{method:"HEAD"});if(!e.ok)throw new Error(`HEAD request failed: ${e.status} ${e.statusText}`);let t=parseInt(e.headers.get("content-length")||"0",10),r=e.headers.get("accept-ranges");if(!t||r!=="bytes"){let _=await fetch(n);if(!_.ok)throw new Error(`Fetch failed: ${_.status} ${_.statusText}`);let y=new Uint8Array(await _.arrayBuffer());return{entries:dr(y),totalSize:y.length}}let i=Math.min(t,is),s=t-i,o=await fetch(n,{headers:{Range:`bytes=${s}-${t-1}`}});if(o.status!==206){let _=await fetch(n);if(!_.ok)throw new Error(`Fetch failed: ${_.status} ${_.statusText}`);let y=new Uint8Array(await _.arrayBuffer());return{entries:dr(y),totalSize:y.length}}let c=new Uint8Array(await o.arrayBuffer()),a=new DataView(c.buffer,c.byteOffset,c.byteLength),u=as(c),l=a.getUint32(u+12,!0),d=a.getUint32(u+16,!0);if(d>=s){let _=t,y=new Uint8Array(_);return y.set(c,s),{entries:dr(y),totalSize:_}}let p=d+l-1,m=await fetch(n,{headers:{Range:`bytes=${d}-${p}`}});if(m.status!==206)throw new Error(`Range request for CD failed: ${m.status}`);let f=new Uint8Array(await m.arrayBuffer()),h=t,g=new Uint8Array(h);return g.set(f,d),g.set(c,s),{entries:dr(g),totalSize:h}}var qc,Zc,ns,is,Xc,Jn,Qn,os,ss,rs,Yc,jc,Jc,Qc,ti=Ir(()=>{"use strict";jn();tt();qc=101010256,Zc=33639248,ns=67324752,is=65557,Xc=22,Jn=46,Qn=30,os=0,ss=8,rs=3,{S_IFLNK:Yc,S_IFMT:jc}=W,Jc=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),Qc=new TextEncoder});var ms={};Fi(ms,{DEFAULT_TAR_GZIP_LIMITS:()=>ps,TarParseError:()=>z,parseTarGzip:()=>au});function au(n,e={}){let t=e.label??"TAR gzip archive",r=uu(e.limits,t);if(n.byteLength===0||n.byteLength>r.maxCompressedBytes)throw new z(`${t}: compressed byte count ${n.byteLength} is outside 1..${r.maxCompressedBytes}`);let i=du(n,t);if(i===0||i>r.maxUncompressedBytes)throw new z(`${t}: declared uncompressed byte count ${i} is outside 1..${r.maxUncompressedBytes}`);let s=lu(n,t,i);if(s.byteLength!==i)throw new z(`${t}: gzip expanded to ${s.byteLength} bytes, expected ${i}`);let o=new DataView(n.buffer,n.byteOffset,n.byteLength).getUint32(n.byteLength-8,!0);if(fu(s)!==o)throw new z(`${t}: gzip CRC32 mismatch`);return cu(s,t,r)}function cu(n,e,t){if(n.byteLength%ke!==0)throw new z(`${e}: TAR byte count is not block-aligned`);let r=[],i=0,s=0,o=0,c=null,a={},u=!1;for(;i+ke<=n.byteLength;){let l=n.subarray(i,i+ke);if(i+=ke,ni(l)){if(i+ke>n.byteLength)throw new z(`${e}: TAR end marker is truncated`);let w=n.subarray(i,i+ke);if(!ni(w))throw new z(`${e}: TAR has only one zero end block`);if(i+=ke,!ni(n.subarray(i)))throw new z(`${e}: TAR has nonzero data after its end marker`);u=!0;break}_u(l,e);let d=lr(l,156,1,e)||"0",p=oi(l,124,12,`${e}: TAR entry size`),m=oi(l,100,8,`${e}: TAR entry mode`)&iu,f=yu(l,e,t.maxPathBytes),h=lr(l,157,100,e);if(d==="x"||d==="g"){if(o+=1,o>t.maxEntries+1)throw new z(`${e}: TAR extension header count exceeds ${t.maxEntries+1}`);let w=ds(n,i,p,e);i=ls(i,p,n.byteLength,e);let S=pu(w,e,t);d==="x"?c=S:a={...a,...S};continue}if(s+=1,s>t.maxEntries)throw new z(`${e}: TAR entry count exceeds ${t.maxEntries}`);let g={...a,...c??{}};c=null;let _=g.size===void 0?p:mu(g.size,`${e}: PAX entry size`),y=ds(n,i,_,e);i=ls(i,_,n.byteLength,e);let E=ii(g.path??f,e,t.maxPathBytes),O=g.linkpath??h;switch(d){case"0":case"\0":r.push({path:E,type:"file",mode:m,data:y});break;case"5":ri(_,e,"directory",E),r.push({path:E,type:"directory",mode:m});break;case"2":ri(_,e,"symlink",E),fs(O,e,E,t.maxLinkBytes,!1),r.push({path:E,type:"symlink",mode:m,linkName:O});break;case"1":ri(_,e,"hardlink",E),fs(O,e,E,t.maxLinkBytes,!0),r.push({path:E,type:"hardlink",mode:m,linkName:ii(O,`${e}: hardlink target`,t.maxPathBytes)});break;case"3":case"4":case"6":throw new z(`${e}: unsupported TAR device/FIFO entry ${E}`);default:throw new z(`${e}: unsupported TAR entry type ${JSON.stringify(d)} for ${E}`)}}if(!u)throw new z(`${e}: TAR is missing its two-block end marker`);if(c!==null)throw new z(`${e}: local PAX header has no following entry`);return r}function uu(n,e){let t={...ps,...n};for(let[r,i]of Object.entries(t))if(!Number.isSafeInteger(i)||i<=0)throw new z(`${e}: ${r} must be a positive safe integer`);return t}function du(n,e){if(n.byteLength<18||n[0]!==31||n[1]!==139||n[2]!==8)throw new z(`${e}: invalid gzip header`);return new DataView(n.buffer,n.byteOffset,n.byteLength).getUint32(n.byteLength-4,!0)}function lu(n,e,t){let r=new Uint8Array(t),i=0,s=!1,o=new ts(c=>{if(c.byteLength>t-i)throw new z(`${e}: gzip expansion exceeds its declared ${t} bytes`);r.set(c,i),i+=c.byteLength});o.onmember=()=>{throw s=!0,new z(`${e}: concatenated gzip members are unsupported`)};try{o.push(n,!0)}catch(c){throw c instanceof z?c:new z(`${e}: cannot gunzip archive: ${Eu(c)}`)}if(s)throw new z(`${e}: concatenated gzip members are unsupported`);return r.subarray(0,i)}function fu(n){let e=4294967295;for(let t of n)e=su[(e^t)&255]^e>>>8;return(e^4294967295)>>>0}function hu(){let n=new Uint32Array(256);for(let e=0;e>>1^((t&1)===0?0:3988292384);n[e]=t>>>0}return n}function ds(n,e,t,r){if(t>n.byteLength-e)throw new z(`${r}: TAR entry is truncated`);return n.subarray(e,e+t)}function ls(n,e,t,r){let s=Math.ceil(e/ke)*ke;if(!Number.isSafeInteger(s)||s>t-n)throw new z(`${r}: TAR entry padding is truncated`);return n+s}function pu(n,e,t){let r={},i=0;for(;i9)throw new z(`${e}: invalid PAX record length`);if(o=o*10+h,!Number.isSafeInteger(o))throw new z(`${e}: invalid PAX record length`)}let c=i+o;if(o<=s-i+2||c>n.byteLength||n[c-1]!==10)throw new z(`${e}: truncated PAX record`);let a=s+1;for(;a=c-1)throw new z(`${e}: invalid PAX record`);let u=n.subarray(s+1,a);if(u.byteLength>256)throw new z(`${e}: PAX record key is too long`);let l=si(u,`${e}: PAX record key`),d=n.subarray(a+1,c-1),p=l==="path"?t.maxPathBytes:l==="linkpath"?t.maxLinkBytes:l==="size"?32:0;if(p===0){i=c;continue}if(d.byteLength>p)throw new z(`${e}: PAX ${l} value is too long`);let m=si(d,`${e}: PAX record value`);r[l]=m,i=c}return r}function mu(n,e){if(!/^(0|[1-9][0-9]*)$/.test(n))throw new z(`${e} is invalid`);let t=Number(n);if(!Number.isSafeInteger(t)||t<0)throw new z(`${e} is invalid`);return t}function _u(n,e){let t=oi(n,148,8,`${e}: TAR checksum`),r=0;for(let i=0;i=148&&i<156?32:n[i];if(t!==r)throw new z(`${e}: TAR checksum mismatch`)}function yu(n,e,t){let r=lr(n,0,100,e),i=lr(n,345,155,e);return ii(i?`${i}/${r}`:r,e,t)}function ii(n,e,t){let r=n;for(;r.startsWith("./");)r=r.slice(2);return r=r.replace(/\/+$/g,""),gu(r,`${e}: TAR path`,t),r}function lr(n,e,t,r){let i=e,s=e+t;for(;ir||n.includes("\0"))throw new z(`${e}: link target for ${t} is invalid`);if(i&&n.includes("\\"))throw new z(`${e}: hardlink target for ${t} is invalid`)}function gu(n,e,t){if(n.length===0||n.startsWith("/")||n.includes("\0")||n.includes("\\")||hs.encode(n).byteLength>t)throw new z(`${e} ${JSON.stringify(n)} must be a bounded relative POSIX path`);for(let r of n.split("/"))if(r.length===0||r==="."||r==="..")throw new z(`${e} ${JSON.stringify(n)} contains an unsafe path segment`)}function ni(n){for(let e of n)if(e!==0)return!1;return!0}function si(n,e){try{return ou.decode(n)}catch{throw new z(`${e} contains non-UTF-8 text`)}}function Eu(n){return n instanceof Error?n.message:String(n)}var ke,iu,us,ou,hs,su,ps,z,_s=Ir(()=>{"use strict";jn();tt();ke=512,iu=W.S_MODE_BITS,us=1024*1024,ou=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),hs=new TextEncoder,su=hu(),ps=Object.freeze({maxCompressedBytes:256*us,maxUncompressedBytes:512*us,maxEntries:1e5,maxPathBytes:4096,maxLinkBytes:65536}),z=class extends Error{constructor(e){super(e),this.name="TarParseError"}}});import{existsSync as Er,lstatSync as cn,readdirSync as js,readFileSync as ut,realpathSync as Re,statSync as Ye}from"node:fs";import{createHash as Js}from"node:crypto";import{spawnSync as Ri}from"node:child_process";import{basename as nd,dirname as wr,isAbsolute as un,join as $,relative as id,resolve as Ie,sep as od}from"node:path";import{fileURLToPath as sd}from"node:url";tt();var la=Uint8Array.from(Xi);function T(n,e){let t=0,r=0,i=e;for(;;){let s=n[i++];if(t|=(s&127)<=n.length)throw new Error(`${r} is truncated`);if((n[e++]&128)===0)return e}throw new Error(`${r} has an overlong LEB128 encoding`)}function we(n,e,t){if(e>=n.length)throw new Error(`${t} is truncated`);let r=n[e++];switch(r){case 127:case 126:case 125:case 124:case 123:case 117:case 116:case 115:case 114:case 113:case 112:case 111:case 110:case 109:case 108:case 107:case 106:case 105:case 104:return{code:r,shared:!1,next:e};case 98:case 99:case 100:{let i=n[e]===101;i&&e++;let s=po(n,e,5,`${t} heap type`),[o]=ho(n,e);return{code:r,heapType:Number(o),shared:i,next:s}}default:throw new Error(`${t} has unknown value type 0x${r.toString(16)}`)}}function fa(n,e,t){return n[e]===120||n[e]===119?{code:n[e],shared:!1,next:e+1}:we(n,e,t)}function ha(n,e){let t=n[e];if(t===64||t===127||t===126||t===125||t===124||t===123||t===112||t===111)return e+1;let[,r]=xn(n,e);return e+r}function pa(n,e,t){let[r,i]=T(n,e);e+=i;let s=[],o=[];for(let d=0;d=n.length)throw new Error(`${t} mutability is truncated`);let i=n[e++];if(i!==0&&i!==1)throw new Error(`${t} has invalid mutability ${i}`);return e}function ma(n,e,t,r){if(e===101){if(t>=n.length)throw new Error(`${r} shared type is truncated`);e=n[t++]}for(let i of[76,77]){if(e!==i)continue;let[,s]=T(n,t);if(t+=s,t>=n.length)throw new Error(`${r} descriptor is truncated`);e=n[t++]}if(e===96)return pa(n,t,r);if(e===95){let[i,s]=T(n,t);t+=s;for(let o=0;o=n.length)throw new Error(`${t} is truncated`);let r=n[e++];if(r===79||r===80){let[i,s]=T(n,e);e+=s;for(let o=0;o=n.length)throw new Error(`${t} body is truncated`);r=n[e++]}return ma(n,r,e,t)}function _a(n,e){let[t,r]=T(n,e);e+=r;let i=[];for(let s=0;s=21&&r<=34?qt(e,t):r===84||r>=92&&r<=99||r>=112&&r<=123||r>=124&&r<=131||r>=156&&r<=159?t+1:t:n===254?r===0||r===1||r===2?qt(e,t):r===3?t:r>=16&&r<=79?qt(e,t):null:null}function ga(n,e,t){let[r,i]=T(n,e);e+=i+r;let[s,o]=T(n,e);e+=o+s;let c=n[e++];if(c===0){t.funcImports++;let[,a]=T(n,e);e+=a}else if(c===1)e=we(n,e,"table import type").next,e=We(n,e).next;else if(c===2)e=We(n,e).next;else if(c===3)t.globalImports++,e=we(n,e,"global import type").next,e++;else if(c===4){e++;let[,a]=T(n,e);e+=a}return e}function Nr(n){return n.length>=8&&n[0]===0&&n[1]===97&&n[2]===115&&n[3]===109}function Ue(n,e){let[t,r]=T(n,e);return e+=r,[new TextDecoder().decode(n.subarray(e,e+t)),e+t]}function Ea(n,e){if(e.length===0)return!0;let t=new TextEncoder().encode(e);e:for(let r=0;r<=n.length-t.length;r++){for(let i=0;in);function uo(n,e){switch(n.code){case 127:return fn;case 126:return hn;case 125:return pn;case 124:return mn;case 123:return _n;case 112:case 115:return pt;case 111:case 114:return Mt;case 105:case 116:return Dt;case 104:case 106:case 107:case 108:case 109:case 110:case 113:case 117:return Kt;case 98:case 99:case 100:{let t=n.heapType;return t===void 0?null:t===-16||t===-13?pt:t===-17||t===-14?Mt:t===-23||t===-12?Dt:t>=0&&e[t]!==void 0?pt:Kt}default:return null}}function An(n,e,t){if(!t)throw new Error(`function ${e} refers to an unknown type`);let r=n.get(e)??[];r.push(t),n.set(e,r)}function Vt(n,e,t){let r=n.get(e)??[];r.push(t),n.set(e,r)}function We(n,e){let[t,r]=T(n,e);e+=r;let[i,s]=T(n,e);e+=s;let o=null;if((t&1)!==0){let[c,a]=T(n,e);e+=a,o=c}return{flags:t,minimum:i,maximum:o,next:e}}function wa(n){let e=new Uint8Array(n);if(!Nr(e))throw new Error("not a wasm binary");let t=[],r=[],i=[],s={functionTypes:t,functionTypeIndices:r,functionImports:new Map,functionImportEntries:[],globalImports:new Map,tableImports:new Map,tables:[],tagImports:new Map,functionExports:new Map,globalExports:new Map,tableExports:new Map,exports:new Map,memoryPointerWidths:[],forkCapabilities:[],linkedFrameDescriptors:[],exceptionCodecDescriptors:[],importedGlobalsDescriptors:[],importedTablesDescriptors:[],moduleStateDescriptors:[],staticRootDescriptors:[],unwindTransportDescriptors:[],nativeStartCount:0,importsKernelFork:!1},o=0,c=0,a=8;for(;ae.length)throw new Error("wasm section exceeds file size");let f=p,h=!1;if(u===0){let[g,_]=Ue(e,f);g===Nt?s.linkedFrameDescriptors.push(e.slice(_,m)):g===ve?s.forkCapabilities.push(e.slice(_,m)):g===Se?s.exceptionCodecDescriptors.push(e.slice(_,m)):g===X?s.importedGlobalsDescriptors.push(e.slice(_,m)):g===Y?s.importedTablesDescriptors.push(e.slice(_,m)):g===ne?s.moduleStateDescriptors.push(e.slice(_,m)):g===$e?s.staticRootDescriptors.push(e.slice(_,m)):g===mt&&s.unwindTransportDescriptors.push(e.slice(_,m))}else if(u===1){h=!0;let g=_a(e,f);t.push(...g.types),f=g.next}else if(u===2){h=!0;let[g,_]=T(e,f);f+=_;for(let y=0;y=e.length)throw new Error(`global import ${E}.${w} is truncated`);let x=e[f++];if((x&-4)!==0)throw new Error(`global import ${E}.${w} has invalid flags ${x}`);Vt(s.globalImports,`${E}.${w}`,{module:E,name:w,importOrdinal:y,index:o++,valueType:R.code,recipeTypeCode:uo(R,t),mutable:(x&1)!==0,shared:(x&2)!==0})}else if(A===4){let R=e[f++];if(R!==0)throw new Error(`unsupported wasm tag attribute ${R}`);let[x,v]=T(e,f);f+=v,An(s.tagImports,`${E}.${w}`,t[x])}else throw new Error(`unsupported wasm import kind ${A}`)}}else if(u===3){h=!0;let[g,_]=T(e,f);f+=_;for(let y=0;yn[a]===c))throw new Error("linked-frame descriptor has invalid magic");let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=e.getUint16(4,!0);if(t!==1)throw new Error(`linked-frame descriptor version ${t} is unsupported`);let r=e.getUint16(6,!0);if(r!==Ct)throw new Error(`linked-frame descriptor declares size ${r}, expected ${Ct}`);let i=e.getUint8(8),s=Mi.find(({bytes:c})=>c===i);if(!s)throw new Error(`linked-frame descriptor pointer width ${i} is unsupported`);if(e.getUint8(9)!==Ci)throw new Error(`linked-frame descriptor alignment ${e.getUint8(9)} is unsupported`);let o=e.getUint16(10,!0);if(o!==dn)throw new Error(`linked-frame descriptor flags 0x${o.toString(16)} do not equal required flags 0x${dn.toString(16)}`);if(e.getUint32(12,!0)!==s.chunkHeaderSize||e.getUint32(16,!0)!==s.nodeHeaderSize)throw new Error(`linked-frame descriptor header sizes do not match its ${i}-byte pointer width`);return s.bytes}function Aa(n){if(n.length===0)return[`missing required ${ve} capability`];if(n.length!==1)return[`has ${n.length} ${ve} sections, expected exactly one`];let e=n[0];if(e.byteLength!==2)return[`${ve} has ${e.byteLength} bytes, expected 2`];if(e[0]!==Hi)return[`${ve} version ${e[0]} is unsupported`];let t=e[1];return(t&~Vi)!==0?[`${ve} has unknown flags 0x${t.toString(16)}`]:(t&vr)!==vr?[`${ve} flags 0x${t.toString(16)} omit required activation-state safety flags 0x${vr.toString(16)}`]:[]}function Ia(n){let e=[],t=`${zr}.${br}`,r=n.tagImports.get(t);if(r?r.length!==1?e.push(`duplicate private fork-unwind tag import ${t}`):(r[0].params.length!==0||r[0].results.length!==0)&&e.push(`private fork-unwind tag ${t} must have an empty payload`):e.push(`missing required private fork-unwind tag import ${t}`),n.unwindTransportDescriptors.length===0)e.push(`missing required ${mt} descriptor`);else if(n.unwindTransportDescriptors.length!==1)e.push(`has ${n.unwindTransportDescriptors.length} ${mt} descriptors, expected exactly one`);else{let i=n.unwindTransportDescriptors[0];(i.length!==2||i[0]!==gn||i[1]!==En)&&e.push(`${mt} must be [${gn}, ${En}]`)}return e}function Ra(n,e){if(n.length===0)return[`missing required ${ne} descriptor`];if(n.length!==1)return[`has ${n.length} ${ne} descriptors, expected exactly one`];let t=n[0];if(t.byteLength!==Rr)return[`${ne} has ${t.byteLength} bytes, expected ${Rr}`];if(!Ki.every((h,g)=>t[g]===h))return[`${ne} has invalid magic`];let r=new DataView(t.buffer,t.byteOffset,t.byteLength),i=r.getUint16(4,!0),s=r.getUint16(6,!0),o=r.getUint8(8),c=Gi.find(({bytes:h})=>h===o),a=r.getUint8(9),u=r.getUint16(10,!0),l=r.getUint16(12,!0),d=r.getUint16(14,!0),p=r.getUint32(16,!0),m=r.getUint32(20,!0),f=[];return i!==Di&&f.push(`${ne} version ${i} is unsupported`),s!==Rr&&f.push(`${ne} declares size ${s}`),c?e!==null&&o!==e&&f.push(`${ne} pointer width ${o} does not match linked frames ${e}`):f.push(`${ne} pointer width ${o} is unsupported`),a!==Bi&&f.push(`${ne} alignment ${a} is unsupported`),u!==ln&&f.push(`${ne} flags 0x${u.toString(16)} do not equal required flags 0x${ln.toString(16)}`),l!==$i&&f.push(`${ne} arena version ${l} is unsupported`),d!==Ui&&f.push(`${ne} record version ${d} is unsupported`),p!==Wi&&f.push(`${ne} root word ${p} is unsupported`),m!==0&&f.push(`${ne} reserved field is nonzero`),f}function xa(n){if(n.length===0)return[`missing required ${Se} descriptor`];if(n.length!==1)return[`has ${n.length} ${Se} descriptors, expected exactly one`];let e=n[0];if(e.byteLength2147483647||o.has(l))&&r.push(`${Se} layout id ${l} is invalid or duplicated`),o.add(l)}return r}var Ta=new Set([fn,hn,pn,mn,_n,pt,Mt,Dt,Kt]);function lo(n){return!(n.module===Sn&&(n.name===wn||n.name==="__channel_base"||n.name==="__wpk_fork_module_state_table_generation_addr"))}function va(n){let e=n.importedGlobalsDescriptors;if(e.length===0)return[`missing required ${X} descriptor`];if(e.length!==1)return[`has ${e.length} ${X} descriptors, expected exactly one`];let t=e[0];if(t.byteLengtht[g]===h)||i.push(`${X} has invalid magic`),r.getUint16(4,!0)!==ji&&i.push(`${X} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==kr&&i.push(`${X} declares an invalid header size`);let s=r.getUint32(8,!0);r.getUint32(12,!0)!==0&&i.push(`${X} reserved field is nonzero`);let o=new Set,c=new Set,a=new TextDecoder("utf-8",{fatal:!0}),u=[],l=-1,d=kr;for(let h=0;ht.byteLength)return i.push(`${X} record ${h} header is truncated`),i;let g=r.getUint32(d,!0),_=r.getUint32(d+4,!0),y=r.getUint8(d+8),E=r.getUint8(d+9),O=r.getUint32(d+12,!0),w=r.getUint32(d+16,!0),S=r.getUint32(d+20,!0),A=$t+O+w;if(!Number.isSafeInteger(A)||g!==A||g<$t||d+g>t.byteLength)return i.push(`${X} record ${h} has invalid bounds`),i;(_===0||o.has(_))&&i.push(`${X} record ${h} has invalid or duplicated owner ${_}`),o.add(_),Ta.has(y)||i.push(`${X} record ${h} has unknown value type ${y}`),(E&~eo)!==0&&i.push(`${X} record ${h} has unknown flags 0x${E.toString(16)}`),r.getUint16(d+10,!0)!==0&&i.push(`${X} record ${h} reserved fields are nonzero`),(c.has(S)||S<=l)&&i.push(`${X} record ${h} has duplicated or unordered import ordinal`),c.add(S),l=S;let R=d+$t;try{let x=a.decode(t.subarray(R,R+O)),v=a.decode(t.subarray(R+O,R+O+w));u.push({ownerId:_,typeCode:y,flags:E,importOrdinal:S,module:x,name:v})}catch{i.push(`${X} record ${h} contains invalid UTF-8`)}d+=g}d!==t.byteLength&&i.push(`${X} has trailing bytes`);let p=[...n.globalImports.values()].flat(),m=new Map(p.map(h=>[h.index,h])),f=new Set;for(let h of u){let g=`${xr}${h.ownerId}`,_=n.exports.get(g);if(!_||_.length!==1||_[0].kind!==3){i.push(`${X} owner ${h.ownerId} lacks exactly one global catalog export ${g}`);continue}let y=m.get(_[0].index);if(!y||!lo(y)){i.push(`${X} owner ${h.ownerId} does not identify a reconstructible imported global`);continue}if(y.module!==h.module||y.name!==h.name||y.importOrdinal!==h.importOrdinal||y.recipeTypeCode!==h.typeCode||y.mutable!==((h.flags&Ji)!==0)||y.shared!==((h.flags&Qi)!==0)){i.push(`${X} owner ${h.ownerId} does not match its imported global declaration`);continue}if(f.has(y.index)){i.push(`${X} repeats imported global index ${y.index}`);continue}f.add(y.index)}for(let h of p)lo(h)&&!f.has(h.index)&&i.push(`${X} omits imported global ${h.module}.${h.name} at index ${h.index}`);for(let[h,g]of n.exports){if(!h.startsWith(xr))continue;let _=h.slice(xr.length),y=Number(_);(!/^[1-9][0-9]*$/.test(_)||!Number.isSafeInteger(y)||y>4294967295||g.length!==1||g[0].kind!==3)&&i.push(`malformed reserved fork global catalog export ${h}`)}return i}var La=new Set([pt,Mt,Dt,Kt]);function fo(n){return!On.some(({module:e,name:t})=>n.module===e&&n.name===t)}function za(n){let e=n.importedTablesDescriptors;if(e.length===0)return[`missing required ${Y} descriptor`];if(e.length!==1)return[`has ${e.length} ${Y} descriptors, expected exactly one`];let t=e[0];if(t.byteLengtht[g]===h)||i.push(`${Y} has invalid magic`),r.getUint16(4,!0)!==ro&&i.push(`${Y} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Fr&&i.push(`${Y} declares an invalid header size`);let s=r.getUint32(8,!0);r.getUint32(12,!0)!==0&&i.push(`${Y} reserved field is nonzero`);let o=new Set,c=new Set,a=new TextDecoder("utf-8",{fatal:!0}),u=[],l=-1,d=Fr;for(let h=0;ht.byteLength)return i.push(`${Y} record ${h} header is truncated`),i;let g=r.getUint32(d,!0),_=r.getUint32(d+4,!0),y=r.getUint8(d+8),E=r.getUint8(d+9),O=r.getUint32(d+12,!0),w=r.getUint32(d+16,!0),S=r.getUint32(d+20,!0),A=Ut+O+w;if(!Number.isSafeInteger(A)||g!==A||gt.byteLength)return i.push(`${Y} record ${h} has invalid bounds`),i;(_===0||o.has(_))&&i.push(`${Y} record ${h} has invalid or duplicated owner ${_}`),o.add(_),La.has(y)||i.push(`${Y} record ${h} has unknown element type ${y}`),(E&~io)!==0&&i.push(`${Y} record ${h} has unknown flags 0x${E.toString(16)}`),r.getUint16(d+10,!0)!==0&&i.push(`${Y} record ${h} reserved fields are nonzero`),(c.has(S)||S<=l)&&i.push(`${Y} record ${h} has duplicated or unordered import ordinal`),c.add(S),l=S;let R=d+Ut;try{let x=a.decode(t.subarray(R,R+O)),v=a.decode(t.subarray(R+O,R+O+w));u.push({ownerId:_,typeCode:y,flags:E,importOrdinal:S,module:x,name:v})}catch{i.push(`${Y} record ${h} contains invalid UTF-8`)}d+=g}d!==t.byteLength&&i.push(`${Y} has trailing bytes`);let p=[...n.tableImports.values()].flat(),m=new Map(p.map(h=>[h.index,h])),f=new Set;for(let h of u){let g=`${Tr}${h.ownerId}`,_=n.exports.get(g);if(!_||_.length!==1||_[0].kind!==1){i.push(`${Y} owner ${h.ownerId} lacks exactly one table catalog export ${g}`);continue}let y=m.get(_[0].index);if(!y||!fo(y)){i.push(`${Y} owner ${h.ownerId} does not identify a reconstructible imported table`);continue}if(y.module!==h.module||y.name!==h.name||y.importOrdinal!==h.importOrdinal||y.recipeTypeCode!==h.typeCode||y.table64!==((h.flags&no)!==0)){i.push(`${Y} owner ${h.ownerId} does not match its imported table declaration`);continue}if(f.has(y.index)){i.push(`${Y} repeats imported table index ${y.index}`);continue}f.add(y.index)}for(let h of p)fo(h)&&!f.has(h.index)&&i.push(`${Y} omits imported table ${h.module}.${h.name} at index ${h.index}`);for(let[h,g]of n.exports){if(!h.startsWith(Tr))continue;let _=h.slice(Tr.length),y=Number(_);(!/^[1-9][0-9]*$/.test(_)||!Number.isSafeInteger(y)||y>4294967295||g.length!==1||g[0].kind!==1)&&i.push(`malformed reserved fork table catalog export ${h}`)}return i}function Tn(n,e){switch(n){case"ptr":return e===8?126:127;case"i32":return 127;case"i64":return 126;case"anyref":return 110;case"exnref":return 105;case"externref":return 111;case"funcref":return 112}}function In(n,e,t,r){return n.params.length===e.length&&n.results.length===t.length&&n.params.every((i,s)=>i===Tn(e[s],r))&&n.results.every((i,s)=>i===Tn(t[s],r))}function Rn(n,e,t){let r=i=>i==="ptr"?t===8?"i64":"i32":i;return`(${n.map(r).join(", ")}) -> (${e.map(r).join(", ")})`}function ba(n){let e=`${Sn}.${wn}`,t=n.globalImports.get(e);return t?t.length!==1?[`duplicate exception-codec activation import ${e}`]:t[0].valueType!==127||t[0].mutable?[`exception-codec activation import ${e} must be immutable i32`]:[]:[`missing required immutable exception-codec activation import ${e}`]}function Pa(n){let e=[];for(let t of On){let r=`${t.module}.${t.name}`,i=n.tableImports.get(r);if(!i){e.push(`missing required ABI 43 fork-runtime table import ${r}`);continue}if(i.length!==1){e.push(`duplicate ABI 43 fork-runtime table import ${r}`);continue}let s=i[0],o=Tn(t.element,4);(s.elementType!==o||s.table64!==t.table64||s.minimum!==t.minimum||s.maximum!==t.maximum)&&e.push(`ABI 43 fork-runtime table import ${r} has the wrong type or limits`)}return e}function ka(n){if(n.staticRootDescriptors.length===0)return[`missing required ${$e} descriptor`];if(n.staticRootDescriptors.length!==1)return[`has ${n.staticRootDescriptors.length} ${$e} descriptors, expected exactly one`];let e=n.staticRootDescriptors[0];if(e.byteLength!==Pr)return[`${$e} has ${e.byteLength} bytes, expected ${Pr}`];let t=[];la.some((u,l)=>e[l]!==u)&&t.push(`${$e} has invalid magic`);let r=new DataView(e.buffer,e.byteOffset,e.byteLength);r.getUint16(4,!0)!==Zi&&t.push(`${$e} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Pr&&t.push(`${$e} declares an invalid header size`);let i=r.getUint32(8,!0),s=n.tableExports.get(Bt);if(!s||s.length!==1)return t.push(`missing exactly one table export ${Bt}`),t;let o=[...n.tableImports.values()].reduce((u,l)=>u+l.length,0),c=s[0],a=n.tables[c];return c!n.functionExports.has(a)).map(({name:a})=>a);if(t.length>0&&e.push(`incomplete wasm-fork-instrument exports; missing ${t.join(", ")}`),n.importsKernelFork){let a=`${et.module}.${et.name}`,u=n.functionImports.get(a);u?.length!==1?e.push(`duplicate ABI 43 process-fork import ${a}`):In(u[0],et.params,et.results,4)||e.push(`ABI 43 process-fork import ${a} has the wrong signature; expected ${Rn(et.params,et.results,4)}`)}let r=null;if(n.linkedFrameDescriptors.length===0)e.push(`missing required ${Nt} descriptor`);else if(n.linkedFrameDescriptors.length!==1)e.push(`has ${n.linkedFrameDescriptors.length} ${Nt} descriptors, expected exactly one`);else try{r=Oa(n.linkedFrameDescriptors[0])}catch(a){e.push(a instanceof Error?a.message:String(a))}e.push(...Ra(n.moduleStateDescriptors,r));let i=_t.filter(({module:a,name:u})=>n.functionImports.has(`${a}.${u}`)),s=`${zr}.${br}`,o=n.importsKernelFork||i.length>0;if((o||n.tagImports.has(s)||n.unwindTransportDescriptors.length>0)&&e.push(...Ia(n)),o){let a=_t.filter(({module:u,name:l})=>!n.functionImports.has(`${u}.${l}`)).map(({module:u,name:l})=>`${u}.${l}`);a.length>0&&e.push(`incomplete ABI 43 fork-runtime imports; missing ${a.join(", ")}`);for(let u of _t){let l=`${u.module}.${u.name}`,d=n.functionImports.get(l);d&&d.length!==1&&e.push(`duplicate ABI 43 fork-runtime import ${l}`)}}if(r!==null){if(n.memoryPointerWidths.length!==1)e.push(`ABI 43 fork instrumentation requires exactly one module memory, found ${n.memoryPointerWidths.length}`);else if(n.memoryPointerWidths[0]!==r){let a=r===8?"an":"a";e.push(`ABI 43 linked-frame descriptor declares ${a} ${r}-byte pointer but the module memory uses ${n.memoryPointerWidths[0]}-byte addresses`)}for(let a of Wt){let u=n.functionExports.get(a.name);u?.length===1&&!In(u[0],a.params,a.results,r)&&e.push(`ABI 43 wasm-fork-instrument export ${a.name} has the wrong signature; expected ${Rn(a.params,a.results,r)}`)}if(o)for(let a of _t){let u=`${a.module}.${a.name}`,l=n.functionImports.get(u);l?.length===1&&!In(l[0],a.params,a.results,r)&&e.push(`ABI 43 fork-runtime import ${u} has the wrong signature; expected ${Rn(a.params,a.results,r)}`)}}return e}function mo(n,e){switch(n){case 0:return"function";case 1:return"table";case 2:return"memory";case 3:return"global";case 4:return"tag";default:throw new Error(`${e} has unsupported external kind ${n}`)}}function Na(n){let e=new Uint8Array(n);if(!Nr(e))return[];let t=[],r=8;for(;r`${e}.${t}`)}function Ma(n){let e=new Uint8Array(n);if(!Nr(e))return[];let t=[],r=8;for(;re)}function _o(n){let e=new Uint8Array(n);if(!Nr(e))return[];let t=[],r=8;for(;rt.startsWith("reloc."))}function yo(n,e={}){let t=[],r=null;Ka(n)&&t.push("contains asyncify_"),e.expectedAbi!==void 0&&e.expectedAbi!==null&&(r=Ua(n),r!==null&&r!==e.expectedAbi&&t.push(`ABI ${r}, expected ${e.expectedAbi}`));let i=new Set(Da(n));if(e.requiredExports){let E=e.requiredExports.filter(O=>!i.has(O));E.length>0&&t.push(`missing required exports: ${E.join(", ")}`)}let s=Sa.filter(E=>i.has(E)),o=Ca(n),c=_o(n),a=_t.filter(({module:E,name:O})=>o.includes(`${E}.${O}`)),u=c.filter(E=>E===Nt).length,l=c.filter(E=>E===ve).length,d=c.filter(E=>E===ne).length,p=c.filter(E=>E===Se).length,m=c.filter(E=>E===X).length,f=c.filter(E=>E===Y).length,h=c.filter(E=>E===mt).length,g=o.includes(`${zr}.${br}`),_=s.length>0||a.length>0||u>0||l>0||d>0||p>0||m>0||f>0||h>0||g;if(e.expectedAbi!==void 0&&e.expectedAbi!==null&&_&&r===null&&t.push(`ABI ${e.expectedAbi} fork artifact is missing __abi_version; the activation-state capability epoch cannot be verified`),e.forbidForkInstrumentation&&_&&t.push("contains ABI 43 wasm-fork-instrument metadata, imports, or exports"),(e.requireForkInstrumentation??!Ba(n))&&(_||o.includes("kernel.kernel_fork")))try{t.push(...Fa(wa(n)))}catch(E){t.push(`cannot validate ABI 43 fork-artifact contract: ${E instanceof Error?E.message:String(E)}`)}return t}function $a(n,e){let t=new Uint8Array(n);if(t.length<8)return null;let r=0,i=null,s=null,o=8;for(;o=a)return null;let h=c;for(let y=0;y=f)return null;let[h,g]=T(t,m);m+=g;for(let _=0;_f)return null}return m}function p(m,f=0){if(f>4)return null;let h=l(m);if(!h)return null;let g=d(h.start,h.end);if(g===null)return null;let _=g,y=h.end;for(;_=32&&E<=38||E===208){let[,O]=T(t,_);_+=O}else if(E>=40&&E<=62)_=qt(t,_);else if(E===63||E===64)_++;else if(E===66){let[,O]=ho(t,_);_+=O}else if(E===67)_+=4;else if(E===68)_+=8;else if(E===252||E===253||E===254){let O=ya(E,t,_);if(O===null)return null;_=O}}return null}return p(i)}function Ua(n){return $a(n,"__abi_version")}tt();var Wa=ArrayBuffer,J=Uint8Array,Cr=Uint16Array,Ga=Int16Array;var Mr=Int32Array,vn=function(n,e,t){if(J.prototype.slice)return J.prototype.slice.call(n,e,t);(e==null||e<0)&&(e=0),(t==null||t>n.length)&&(t=n.length);var r=new J(t-e);return r.set(n.subarray(e,t)),r},Xt=function(n,e,t,r){if(J.prototype.fill)return J.prototype.fill.call(n,e,t,r);for((t==null||t<0)&&(t=0),(r==null||r>n.length)&&(r=n.length);tn.length)&&(r=n.length);t2046MB)","invalid block type","FSE accuracy too high","match distance too far back","unexpected EOF"],Q=function(n,e,t){var r=new Error(e||Va[n]);if(r.code=n,Error.captureStackTrace&&Error.captureStackTrace(r,Q),!t)throw r;return r},go=function(n,e,t){for(var r=0,i=0;r>>0},Za=function(n,e){var t=n[0]|n[1]<<8|n[2]<<16;if(t==3126568&&n[3]==253){var r=n[4],i=r>>5&1,s=r>>2&1,o=r&3,c=r>>6;r&8&&Q(0);var a=6-i,u=o==3?4:o,l=go(n,a,u);a+=u;var d=c?1<>3);m=f+(f>>3)*(n[5]&7)}m>2145386496&&Q(1);var h=new J((e==1?p||m:e?0:m)+12);return h[0]=1,h[4]=4,h[8]=8,{b:a+d,y:0,l:0,d:l,w:e&&e!=1?e:h.subarray(12),e:m,o:new Mr(h.buffer,0,3),u:p,c:s,m:Math.min(131072,m)}}else if((t>>4|n[3]<<20)==25481893)return qa(n,4)+8;Q(0)},rt=function(n){for(var e=0;1<t&&Q(3);for(var s=1<0;){var y=rt(o+1),E=r>>3,O=(1<>(r&7)&O,S=(1<S&&(w-=A)),p[++c]=--w,w==-1?(o+=w,g[--l]=c):o-=w,!w)do{var x=r>>3;a=(n[x]|n[x+1]<<8)>>(r&7)&3,r+=2,c+=a}while(a==3)}(c>255||o)&&Q(0);for(var v=0,L=(s>>1)+(s>>3)+3,D=s-1,Z=0;Z<=c;++Z){var N=p[Z];if(N<1){m[Z]=-N;continue}for(u=0;u=l)}}for(v&&Q(0),u=0;u>3,{b:i,s:g,n:_,t:f}]},Xa=function(n,e){var t=0,r=-1,i=new J(292),s=n[e],o=i.subarray(0,256),c=i.subarray(256,268),a=new Cr(i.buffer,268);if(s<128){var u=Yt(n,e+1,6),l=u[0],d=u[1];e+=s;var p=l<<3,m=n[e];m||Q(0);for(var f=0,h=0,g=d.b,_=g,y=(++e<<3)-8+rt(m);y-=g,!(y>3;if(f+=(n[E]|n[E+1]<<8)>>(y&7)&(1<>3,h+=(n[E]|n[E+1]<<8)>>(y&7)&(1<<_)-1,o[++r]=d.s[h],g=d.n[f],f=d.t[f],_=d.n[h],h=d.t[h]}++r>255&&Q(0)}else{for(r=s-127;t>4,o[t+1]=O&15}++e}var w=0;for(t=0;t11&&Q(0),w+=S&&1<0;--t){var Z=a[t];Xt(D,t,Z,a[t-1]=Z+c[t]*(1<c&&d>3,m=(n[p]|n[p+1]<<8|n[p+2]<<16)>>(l&7);a=(a<>2,o=s<<1,c=s+o;Zt(n.subarray(r,r+=n[0]|n[1]<<8),e.subarray(0,s),t),Zt(n.subarray(r,r+=n[2]|n[3]<<8),e.subarray(s,o),t),Zt(n.subarray(r,r+=n[4]|n[5]<<8),e.subarray(o,c),t),Zt(n.subarray(r),e.subarray(c),t)},rc=function(n,e,t){var r,i=e.b,s=n[i],o=s>>1&3;e.l=s&1;var c=s>>3|n[i+1]<<5|n[i+2]<<13,a=(i+=3)+c;if(o==1)return i>=n.length?void 0:(e.b=i+1,t?(Xt(t,n[i],e.y,e.y+=c),t):Xt(new J(c),n[i]));if(!(a>n.length)){if(o==0)return e.b=a,t?(t.set(n.subarray(i,a),e.y),e.y+=c,t):vn(n,i,a);if(o==2){var u=n[i],l=u&3,d=u>>2&3,p=u>>4,m=0,f=0;l<2?d&1?p|=n[++i]<<4|(d&2&&n[++i]<<12):p=u>>3:(f=d,d<2?(p|=(n[++i]&63)<<4,m=n[i]>>6|n[++i]<<2):d==2?(p|=n[++i]<<4|(n[++i]&3)<<12,m=n[i]>>2|n[++i]<<6):(p|=n[++i]<<4|(n[++i]&63)<<12,m=n[i]>>6|n[++i]<<2|n[++i]<<10)),++i;var h=t?t.subarray(e.y,e.y+e.m):new J(e.m),g=h.length-p;if(l==0)h.set(n.subarray(i,i+=p),g);else if(l==1)Xt(h,n[i++],g);else{var _=e.h;if(l==2){var y=Xa(n,i);m+=i-(i=y[0]),e.h=_=y[1]}else _||Q(0);(f?tc:Zt)(n.subarray(i,i+=m),h.subarray(g),_)}var E=n[i++];if(E){E==255?E=(n[i++]|n[i++]<<8)+32512:E>127&&(E=E-128<<8|n[i++]);var O=n[i++];O&3&&Q(0);for(var w=[ja,Ja,Ya],S=2;S>-1;--S){var A=O>>(S<<1)+2&3;if(A==1){var R=new J([0,0,n[i++]]);w[S]={s:R.subarray(2,3),n:R.subarray(0,1),t:new Cr(R.buffer,0,1),b:0}}else A==2?(r=Yt(n,i,9-(S&1)),i=r[0],w[S]=r[1]):A==3&&(e.t||Q(0),w[S]=e.t[S])}var x=e.t=w,v=x[0],L=x[1],D=x[2],Z=n[a-1];Z||Q(0);var N=(a<<3)-8+rt(Z)-D.b,b=N>>3,U=0,le=(n[b]|n[b+1]<<8)>>(N&7)&(1<>3;var C=(n[b]|n[b+1]<<8)>>(N&7)&(1<>3;var V=(n[b]|n[b+1]<<8)>>(N&7)&(1<>3;var ft=1<>>(N&7)&ft-1);b=(N-=zn[Je])>>3;var De=ec[Je]+((n[b]|n[b+1]<<8|n[b+2]<<16)>>(N&7)&(1<>3;var Qe=Qa[kt]+((n[b]|n[b+1]<<8|n[b+2]<<16)>>(N&7)&(1<>3,le=D.t[le]+((n[b]|n[b+1]<<8)>>(N&7)&(1<>3,V=v.t[V]+((n[b]|n[b+1]<<8)>>(N&7)&(1<>3,C=L.t[C]+((n[b]|n[b+1]<<8)>>(N&7)&(1<3)e.o[2]=e.o[1],e.o[1]=e.o[0],e.o[0]=Ee-=3;else{var ht=Ee-(Qe!=0);ht?(Ee=ht==3?e.o[0]-1:e.o[ht],ht>1&&(e.o[2]=e.o[1]),e.o[1]=e.o[0],e.o[0]=Ee):Ee=e.o[0]}for(var S=0;SDe&&(Be=De);for(var S=0;Ssc)throw jt("EOVERFLOW","file offset is outside signed i64");return n}function cc(n){if(Pn(n)<0n)throw jt("EINVAL","negative positioned I/O offset");return n}function kn(n){let e=Pn(n);if(eOo)throw jt("EOVERFLOW","backend cannot represent the file offset exactly");return wo(e)}function Fn(n){let e=cc(n);return kn(e)}function Ao(n){if(n===null)return null;let e=Pn(n);if(e<0n)throw jt("EINVAL","negative file-size limit");return e>Oo?null:wo(e)}tt();function Nn(n){let e=new Error(`EINVAL: pathconf name ${n} is not associated with this object`);throw e.code="EINVAL",e}function Cn(n,e,t){switch(e){case q.LINK_MAX:return null;case q.NAME_MAX:return 255;case q.PATH_MAX:return oo;case q.CHOWN_RESTRICTED:return 1;case q.NO_TRUNC:return 1;case q.ASYNC_IO:return(n.mode&W.S_IFMT)===W.S_IFREG?1:Nn(e);case q.SYNC_IO:case q.PRIO_IO:case q.FILESIZEBITS:case q.REC_INCR_XFER_SIZE:case q.REC_MAX_XFER_SIZE:case q.REC_MIN_XFER_SIZE:case q.REC_XFER_ALIGN:case q.ALLOC_SIZE_MIN:case q.SYMLINK_MAX:case q.FALLOC:return null;case q.POSIX2_SYMLINKS:return t.supportsSymlinks?1:null;case q.TEXTDOMAIN_MAX:return 255;case q.TIMESTAMP_RESOLUTION:return t.timestampResolutionNs;case q.PIPE_BUF:{let r=n.mode&W.S_IFMT;return r===W.S_IFIFO||r===W.S_IFDIR?null:Nn(e)}case q.MAX_CANON:case q.MAX_INPUT:case q.VDISABLE:case q.SOCK_MAXBUF:return Nn(e);default:{let r=new Error(`EINVAL: invalid pathconf name ${e}`);throw r.code="EINVAL",r}}}tt();var Dr=Math.floor(160),Mn=1397114451,Dn=1,Jt=32768,H=16384,yt=40960,G=61440,uc=2048,dc=1024,lc=73,Io=4294967295,nt=0,Ro=1;var or=64,Vn=128,ar=512,fc=1024,hc=65536,Qt=3,pc=0,mc=1,_c=2,k=8,yc=-1,Oe=-2,B=-5,re=-9,Wn=-16,At=-17,ze=-20,ot=-21,j=-22,Fo=-24,st=-27,oe=-28,Gn=-36,Hn=-39,No=-40,Co=-75,Kn=0,Bn=4,Kr=8,gt=12,Ge=16,Et=20,Br=24,it=28,$r=32,xo=36,Ur=40,gc=44,Ec=48,Sc=52,$n=56,Wr=60,Gr=64,er=68,To=72,St=0,F=8,K=12,P=16,fe=24,ee=32,tr=40,ie=48,rr=88,wt=92,nr=96,ir=100,ue=104,He=112,vo=116,de=120,Lo=4,Le=8,zo=16,bo=20,Po=-2147483648,wc=2147483647,Oc=1034+1024*1024,Ve=Oc*4096,Ac={[Oe]:"No such file or directory",[B]:"I/O error",[re]:"Bad file descriptor",[Wn]:"Device or resource busy",[At]:"File exists",[ze]:"Not a directory",[ot]:"Is a directory",[j]:"Invalid argument",[Fo]:"Too many open files",[st]:"File too large",[oe]:"No space left on device",[Gn]:"File name too long",[Hn]:"Directory not empty",[No]:"Too many symbolic links",[Co]:"Value too large for data type"},I=class extends Error{constructor(t,r){super(r||Ac[t]||`Error ${t}`);this.code=t;this.name="SFSError"}code},me=new TextEncoder,sr=new TextDecoder,ko=me.encode("..");function Un(n){return n==="."||n===".."}function Ot(n){return n.buffer instanceof SharedArrayBuffer?sr.decode(new Uint8Array(n)):sr.decode(n)}function qe(n){return n+3&-4}var be=class n{constructor(e){this.buffer=e;this.view=new DataView(e),this.i32=new Int32Array(e),this.u8=new Uint8Array(e)}buffer;view;i32;u8;dirIndexes=new Map;blockAllocHint=0;inodeAllocHint=2;atomicsWaitAllowed;static DIR_INDEX_MIN_SIZE=64*1024;static mkfs(e,t){let r=e.byteLength;if(r<4096*16)throw new I(j);let i=Math.floor(r/4096),s=t?Math.floor(t/4096):i*4,o=Math.floor(s/4);o<32&&(o=32),o=Math.ceil(o/32)*32;let c=Math.ceil(o/(4096*8)),a=Math.ceil(s/(4096*8)),u=Math.ceil(o*128/4096),l=1,d=l+c,p=d+a,m=p+u;if(m>=i){let R=(m+1)*4096;try{e.grow(R)}catch{throw new I(oe)}if(i=Math.floor(e.byteLength/4096),m>=i)throw new I(oe)}new Uint8Array(e).fill(0);let f=new n(e);f.w32(Kn,Mn),f.w32(Bn,Dn),f.w32(Kr,4096),f.w32(gt,i),f.w32(Ge,o),f.w32(it,l),f.w32($r,d),f.w32(xo,p),f.w32(Ur,m),f.w32(gc,c),f.w32(Ec,a),f.w32(Sc,u),f.w32(er,s),f.w32(To,256);let h=d*4096;for(let R=0;R>2)+(R>>5);f.i32[x]|=1<<(R&31)}let g=i-m;Atomics.store(f.i32,Et>>2,g),f.blockAllocHint=m;let _=l*4096;f.i32[_>>2]|=3,Atomics.store(f.i32,Br>>2,o-2),f.inodeAllocHint=2;let y=f.inodeOffset(1);f.w32(y+F,H|493),f.w32(y+K,2),f.w64(y+ue,1);let E=f.blockAlloc();if(E<0)throw new I(oe);f.w32(y+ie,E);let O=E*4096,w=qe(k+1),S=qe(k+2);f.w32(O,1),f.view.setUint16(O+4,w,!0),f.view.setUint16(O+6,1,!0),f.u8[O+k]=46;let A=O+w;return f.w32(A,1),f.view.setUint16(A+4,S,!0),f.view.setUint16(A+6,2,!0),f.u8[A+k]=46,f.u8[A+k+1]=46,f.w64(y+P,w+S),Atomics.store(f.i32,$n>>2,1),f}static inspectImageCapacity(e){if(e.byteLengththis.snapshotBytesUnlocked(e))}snapshotState(e){return this.withNamespaceLock(()=>({bytes:this.snapshotBytesUnlocked(e),identities:this.collectIdentityStateUnlocked()}))}identityState(){return this.withNamespaceLock(()=>this.collectIdentityStateUnlocked())}snapshotBytesUnlocked(e){let t=e?.normalizeTimestampsMs;if(t!==void 0&&(!Number.isSafeInteger(t)||t<0))throw new I(j,"Snapshot timestamp must be a non-negative safe integer in milliseconds");let r=t===void 0?void 0:BigInt(t);for(let c=0;c>2)!==0)throw new I(Wn,"Cannot save a VFS image with open descriptors")}let i=this.r32(Ge);for(let c=0;c=1&&this.inodeIsAllocated(c)?r:0n;o.setBigUint64(a+tr,u,!0),o.setBigUint64(a+fe,u,!0),o.setBigUint64(a+ee,u,!0)}}return s}collectIdentityStateUnlocked(){let e=new Map,t=this.inodeOffset(1),r=this.r64(t+ue);e.set(`1:${r}`,{ino:1,generation:r,dataSequence:Atomics.load(this.i32,t+de>>2)>>>0,mode:this.r32(t+F),linkCount:this.r32(t+K),size:this.r64(t+P),uid:this.r32(t+nr),gid:this.r32(t+ir),paths:["/"]});let i=[{ino:1,path:"/"}],s=new Set;for(;i.length>0;){let o=i.pop();if(s.has(o.ino))throw new I(B);s.add(o.ino);let c=this.inodeOffset(o.ino);if((this.r32(c+F)&G)!==H)throw new I(B);let a=this.r64(c+P),u=0;for(;u>2)>>>0,mode:v,linkCount:this.r32(S+K),size:this.r64(S+P),uid:this.r32(S+nr),gid:this.r32(S+ir),...(v&G)===yt?{symlinkTarget:this.readSymlinkInodeUnlocked(_)}:{},paths:[]},e.set(R,x)}x.paths.push(w),(this.r32(S+F)&G)===H&&i.push({ino:_,path:w})}}h+=y}u+=f}}return e}statfs(){let e=this.r32(Kr),t=this.r32(gt),r=this.r32(er),i=typeof this.buffer.maxByteLength=="number"?this.buffer.maxByteLength:this.buffer.byteLength,s=Math.floor(i/e),o=Math.max(t,Math.min(r,s)),c=Atomics.load(this.i32,Et>>2),a=Math.max(0,o-t);return{blockSize:e,totalBlocks:o,freeBlocks:c+a,totalInodes:this.r32(Ge),freeInodes:Atomics.load(this.i32,Br>>2),maxName:255}}r32(e){return this.view.getUint32(e,!0)}w32(e,t){this.view.setUint32(e,t,!0)}r64(e){return Number(this.view.getBigUint64(e,!0))}w64(e,t){this.view.setBigUint64(e,BigInt(t),!0)}waitForAtomicChange(e,t){if(this.atomicsWaitAllowed!==!1)try{Atomics.wait(this.i32,e,t),this.atomicsWaitAllowed=!0;return}catch(r){if(!(r instanceof TypeError))throw r;this.atomicsWaitAllowed=!1}for(;Atomics.load(this.i32,e)===t;);}resetAllocationHints(){this.blockAllocHint=this.findNextFreeBlockHint(),this.inodeAllocHint=this.findNextFreeInodeHint()}findNextFreeBlockHint(){let e=this.r32(gt),t=this.r32(Ur),r=this.r32($r)*4096;for(let i=t;i>2)+(i>>5),o=i&31;if((Atomics.load(this.i32,s)&1<>2)+(r>>5),s=r&31;if((Atomics.load(this.i32,i)&1<>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}sbUnlock(){let e=Wr>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}namespaceLock(){let e=Gr>>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}namespaceUnlock(){let e=Gr>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}withNamespaceLock(e){this.namespaceLock();try{return e()}finally{this.namespaceUnlock()}}resetRestoredRuntimeState(){Atomics.store(this.i32,Wr>>2,0),Atomics.store(this.i32,Gr>>2,0),this.u8.fill(0,256,4096);let e=this.r32(Ge),t=this.r32(it)*4096;for(let r=0;r>5)*4)&1<<(r&31))===0||this.r32(i+K)!==0)continue;let o=this.r32(i+F),c=this.r64(i+P);(o&G)===yt&&c<=40?(this.u8.fill(0,i+ie,i+ie+40),this.w64(i+P,0)):this.inodeTruncate(r,0),this.inodeFree(r)}}blockAlloc(){let e=this.r32(gt),t=this.r32($r)*4096,r=this.r32(Ur),i=this.blockAllocHint>=r&&this.blockAllocHint>2)+(c>>5),u=c&31,l=Atomics.load(this.i32,a);if(l&1<>2,1),this.blockAllocHint=c+1>2)+(e>>5),i=e&31;for(;;){let s=Atomics.load(this.i32,r),o=s&~(1<>2,1),e>=this.r32(Ur)&&e>2)>0)return 0;let e=this.r32(gt),t=this.r32(er),r=this.r32(To),i=e+r;if(i>t&&(i=t,r=i-e,r===0))return oe;let s=i*4096;if(this.buffer.byteLength>2,r),Atomics.add(this.i32,$n>>2,1),this.blockAllocHint=e,0}finally{this.sbUnlock()}}inodeOffset(e){let r=this.r32(xo)+Math.floor(e/32),i=e%32*128;return r*4096+i}inodeAlloc(){let e=this.r32(Ge),t=this.r32(it)*4096,r=this.inodeAllocHint>=2&&this.inodeAllocHint>2)+(o>>5),a=o&31,u=Atomics.load(this.i32,c);if(u&1<>2,1),this.inodeAllocHint=o+1>2,1)+1}inodeFree(e){let r=(this.r32(it)*4096>>2)+(e>>5),i=e&31;for(;;){let s=Atomics.load(this.i32,r);if((s&1<>2,1),e>=2&&e0&&this.w32(r+He,i-1),i<=1&&this.r32(r+K)===0&&(this.inodeTruncate(e,0),t=!0)}finally{this.inodeWriteUnlock(e)}t&&this.inodeFree(e)}inodeDropLinkRefLocked(e){let t=this.inodeOffset(e),r=this.r32(t+K);return r>1?(this.w32(t+K,r-1),this.w64(t+ee,Date.now()),!1):this.inodeOrphanLocked(e)}inodeOrphanLocked(e){let t=this.inodeOffset(e);if(this.w32(t+K,0),this.w64(t+ee,Date.now()),this.r32(t+He)>0)return!1;let r=this.r32(t+F),i=this.r64(t+P);return(r&G)===yt&&i<=40?(this.u8.fill(0,t+ie,t+ie+40),this.w64(t+P,0)):this.inodeTruncate(e,0),!0}inodeReadLock(e){let t=this.inodeOffset(e)+St>>2;for(;;){let r=Atomics.load(this.i32,t);if(r&Po){this.waitForAtomicChange(t,r);continue}if(Atomics.compareExchange(this.i32,t,r,r+1)===r)return}}inodeReadUnlock(e){let t=this.inodeOffset(e)+St>>2;(Atomics.sub(this.i32,t,1)&wc)===1&&Atomics.notify(this.i32,t,1)}inodeWriteLock(e){let t=this.inodeOffset(e)+St>>2;for(;;){let r=Atomics.load(this.i32,t);if(r!==0){this.waitForAtomicChange(t,r);continue}if(Atomics.compareExchange(this.i32,t,0,Po)===0)return}}inodeWriteUnlock(e){let t=this.inodeOffset(e)+St>>2;Atomics.store(this.i32,t,0),Atomics.notify(this.i32,t,1/0)}inodeBlockMap(e,t,r){let i=this.inodeOffset(e);if(t<10){let s=this.r32(i+ie+t*4);if(s!==0)return s;if(!r)return 0;let o=this.blockAllocWithGrow();return o<0||this.w32(i+ie+t*4,o),o}if(t-=10,t<1024){let s=this.r32(i+rr),o=!1;if(s===0){if(!r)return 0;if(s=this.blockAllocWithGrow(),s<0)return s;this.w32(i+rr,s),o=!0}let c=s*4096+t*4,a=this.r32(c);if(a!==0)return a;if(!r)return 0;let u=this.blockAllocWithGrow();return u<0?(o&&(this.w32(i+rr,0),this.blockFree(s)),u):(this.w32(c,u),u)}if(t-=1024,t<1024*1024){let s=Math.floor(t/1024),o=t%1024,c=this.r32(i+wt),a=!1;if(c===0){if(!r)return 0;if(c=this.blockAllocWithGrow(),c<0)return c;this.w32(i+wt,c),a=!0}let u=c*4096+s*4,l=this.r32(u),d=!1;if(l===0){if(!r)return 0;if(l=this.blockAllocWithGrow(),l<0)return a&&(this.w32(i+wt,0),this.blockFree(c)),l;this.w32(u,l),d=!0}let p=l*4096+o*4,m=this.r32(p);if(m!==0)return m;if(!r)return 0;let f=this.blockAllocWithGrow();return f<0?(d&&(this.w32(u,0),this.blockFree(l)),a&&(this.w32(i+wt,0),this.blockFree(c)),f):(this.w32(p,f),f)}return j}inodeReadData(e,t,r,i){let s=this.inodeOffset(e),o=this.r64(s+P);if(t>=o)return 0;t+i>o&&(i=o-t);let c=0,a=0;for(;i>0;){let u=Math.floor(t/4096),l=t%4096,d=4096-l;d>i&&(d=i);let p=this.inodeBlockMap(e,u,!1);if(p<=0)r.fill(0,a,a+d);else{let m=p*4096+l;r.set(this.u8.subarray(m,m+d),a)}a+=d,t+=d,i-=d,c+=d}return c}inodeWriteData(e,t,r,i){let s=this.inodeOffset(e),o=this.r64(s+P);t>o&&this.zeroOldEofTail(e,o);let c=0,a=0;for(;i>0;){let u=Math.floor(t/4096),l=t%4096,d=4096-l;d>i&&(d=i);let p=this.inodeBlockMap(e,u,!0);if(p<0){if(c===0)return p;break}let m=p*4096+l;this.u8.set(r.subarray(a,a+d),m),a+=d,t+=d,i-=d,c+=d}if(c>0&&t>this.r64(s+P)&&this.w64(s+P,t),c>0){let u=Date.now();this.w64(s+fe,u),this.w64(s+ee,u),Atomics.add(this.i32,s+de>>2,1)}return c}zeroInodeRange(e,t,r){for(;t0){let a=c*4096+s;this.u8.fill(0,a,a+o)}t+=o}}zeroOldEofTail(e,t){let r=t%4096;if(r===0)return;let i=Math.floor(t/4096),s=this.inodeBlockMap(e,i,!1);if(s<=0)return;let o=s*4096+r;this.u8.fill(0,o,s*4096+4096)}freeBlocksFrom(e,t){let r=this.inodeOffset(e);for(let o=t;o<10;o++){let c=this.r32(r+ie+o*4);c&&(this.blockFree(c),this.w32(r+ie+o*4,0))}let i=this.r32(r+rr);if(i){let o=t>10?t-10:0;for(let c=o;c<1024;c++){let a=i*4096+c*4,u=this.r32(a);u&&(this.blockFree(u),this.w32(a,0))}o===0&&(this.blockFree(i),this.w32(r+rr,0))}let s=this.r32(r+wt);if(s){let o=t>1034?t-10-1024:0,c=Math.floor(o/1024);for(let a=c;a<1024;a++){let u=s*4096+a*4,l=this.r32(u);if(!l)continue;let d=a===c?o%1024:0;for(let p=d;p<1024;p++){let m=l*4096+p*4,f=this.r32(m);f&&(this.blockFree(f),this.w32(m,0))}d===0&&(this.blockFree(l),this.w32(u,0))}c===0&&(this.blockFree(s),this.w32(r+wt,0))}}inodeTruncate(e,t,r=!1){let i=this.inodeOffset(e),s=this.r64(i+P),o=t!==s;if(t>=s){if(t>s&&this.zeroOldEofTail(e,s),this.w64(i+P,t),o||r){let a=Date.now();this.w64(i+fe,a),this.w64(i+ee,a),Atomics.add(this.i32,i+de>>2,1)}return}t%4096!==0&&this.zeroInodeRange(e,t,Math.ceil(t/4096)*4096);let c=Math.ceil(t/4096);if(this.freeBlocksFrom(e,c),this.w64(i+P,t),o||r){let a=Date.now();this.w64(i+fe,a),this.w64(i+ee,a),Atomics.add(this.i32,i+de>>2,1)}}validateFileSize(e){if(!Number.isSafeInteger(e)||e<0)throw new I(j);if(e>Ve)throw new I(st)}validateSeekPosition(e){if(!Number.isSafeInteger(e))throw new I(Co);if(e<0)throw new I(j);if(e>Ve)throw new I(st)}touchDirectoryMutation(e){let t=this.inodeOffset(e),r=Date.now();this.w64(t+fe,r),this.w64(t+ee,r);let i=Atomics.add(this.i32,t+vo>>2,1)+1>>>0,s=this.dirIndexes.get(e);s&&(s.mutationSequence=i,s.size=this.r64(t+P))}dirNameKey(e){return Ot(e)}dirEntryNameMatches(e,t){if(this.view.getUint16(e+6,!0)!==t.length)return!1;for(let i=0;i=k&&r%4===0&&e+r<=t&&i<=r-k}inodeIsAllocated(e){let t=this.r32(Ge);if(e<=0||e>=t)return!1;let r=this.r32(it)*4096;return(Atomics.load(this.i32,(r>>2)+(e>>5))&1<<(e&31))!==0}rebuildDirIndex(e,t,r,i){let s=new Map,o=[],c=0;for(;c4096-l&&(m=4096-l);let f=l;for(;f=k&&o.push({abs:h,recLen:_});f+=_}c+=m}let a={generation:t,mutationSequence:r,size:i,entries:s,free:o};return this.dirIndexes.set(e,a),a}getDirIndex(e){let t=this.inodeOffset(e),r=this.r64(t+P),i=this.r64(t+ue),s=Atomics.load(this.i32,t+vo>>2)>>>0,o=this.dirIndexes.get(e);return o&&o.generation===i&&o.mutationSequence===s&&o.size===r?o:(o&&this.dirIndexes.delete(e),r=0;o--){let c=e.free[o];if(!(c.recLen4096-a&&(d=4096-a);let p=a;for(;pr)return-1;c=a,o+=u}return o===r?c:-1}dirAppendEntry(e,t,r,i=-1){let s=this.inodeOffset(e),o=this.r64(s+P),c=qe(k+t.length),a=o,u=Math.floor(a/4096),l=a%4096,d=0;if(l!==0&&l+c>4096){let f=4096-l,h=0;if(f>=k){if(h=this.inodeBlockMap(e,u,!1),h<=0)return B}else if(i<0&&(i=this.findLastDirEntryInBlock(e,u,l)),i<0)return B;if(d=this.inodeBlockMap(e,u+1,!0),d<0)return d;if(f>=k){let g=h*4096+l;this.w32(g,0),this.view.setUint16(g+4,f,!0),this.view.setUint16(g+6,0,!0)}else{let _=this.view.getUint16(i+4,!0)+f;this.view.setUint16(i+4,_,!0),this.updateDirIndexRecLen(e,i,_)}a=(u+1)*4096,u++,l=0}let p;if(l===0){if(p=d||this.inodeBlockMap(e,u,!0),p<0)return p}else if(p=this.inodeBlockMap(e,u,!1),p<=0)return B;let m=p*4096+l;return this.w32(m,r),this.view.setUint16(m+4,c,!0),this.view.setUint16(m+6,t.length,!0),this.u8.set(t,m+k),this.w64(s+P,a+c),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,m,c),0}dirAddEntry(e,t,r){let i=this.getDirIndex(e);if(typeof i=="number")return i;if(i)return this.useDirIndexFreeSlot(i,e,t,r)?0:this.dirAppendEntry(e,t,r);let s=this.inodeOffset(e),o=this.r64(s+P),c=qe(k+t.length),a=-1,u=0;for(;u4096-d&&(f=4096-d);let h=d;for(;hd+f||E>y-k)return B;if(_===0&&y>=c)return this.w32(g,r),this.view.setUint16(g+6,t.length,!0),this.u8.set(t,g+k),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,g,y),0;let O=qe(k+E),w=y-O;if(_!==0&&w>=c){this.view.setUint16(g+4,O,!0);let S=g+O;return this.w32(S,r),this.view.setUint16(S+4,w,!0),this.view.setUint16(S+6,t.length,!0),this.u8.set(t,S+k),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,S,w),0}a=g,h+=y}u+=f}return this.dirAppendEntry(e,t,r,a)}dirRemoveEntry(e,t){let r=this.getDirIndex(e);if(typeof r=="number")return r;if(r){let c=this.dirNameKey(t),a=r.entries.get(c);if(!a)return Oe;if(this.r32(a.abs)===a.ino&&this.view.getUint16(a.abs+4,!0)===a.recLen&&this.view.getUint16(a.abs+6,!0)===a.nameLen&&this.dirEntryNameMatches(a.abs,t))return this.w32(a.abs,0),r.entries.delete(c),r.free.push({abs:a.abs,recLen:a.recLen}),this.touchDirectoryMutation(e),0;r.entries.delete(c)}let i=this.inodeOffset(e),s=this.r64(i+P),o=0;for(;o4096-a&&(d=4096-a);let p=a;for(;p4096-u&&(p=4096-u);let m=u;for(;m4096-o&&(u=4096-o);let l=o;for(;lo+u||f>m-k)throw new I(B);if(p!==0){if(f===1&&this.u8[d+k]===46){l+=m;continue}if(f===2&&this.u8[d+k]===46&&this.u8[d+k+1]===46){l+=m;continue}return!1}l+=m}i+=u}return!0}dirIsAncestor(e,t){let r=t;for(let i=0;i<8*1024;i++){if(r===e)return!0;if(r===1)return!1;let s=this.dirLookup(r,ko);if(s<0||s===r)throw new I(B);r=s}throw new I(B)}pathResolve(e,t){if(!e.startsWith("/"))return Oe;let r=1,i=e.split("/").filter(o=>o.length>0),s=0;for(let o=0;o255)return Gn;let a=me.encode(c),u;this.inodeReadLock(r);try{let p=this.inodeOffset(r);if((this.r32(p+F)&G)!==H)return ze;u=this.dirLookup(r,a)}finally{this.inodeReadUnlock(r)}if(u<0)return u;let l=this.inodeOffset(u);if((this.r32(l+F)&G)===yt&&(!(o===i.length-1)||t)){if(++s>8)return No;let m=this.r64(l+P),f;if(m<=40)f=Ot(this.u8.subarray(l+ie,l+ie+m));else{let h=new Uint8Array(m);this.inodeReadData(u,0,h,m),f=sr.decode(h)}if(f.startsWith("/")){r=1;let h=f.split("/").filter(_=>_.length>0),g=i.slice(o+1);i.length=0,i.push(...h,...g),o=-1}else{let h=f.split("/").filter(_=>_.length>0),g=i.slice(o+1);i.length=o,i.push(...h,...g),o--}continue}r=u}return r}pathResolveParent(e){if(!e.startsWith("/"))throw new I(j,"Path must be absolute");let t=e.split("/").filter(a=>a.length>0);if(t.length===0)throw new I(j,"Cannot operate on /");let r=t.pop();if(r.length>255)throw new I(Gn);let i="/"+t.join("/"),s=this.pathResolve(i,!0);if(s<0)throw new I(s);let o=this.inodeOffset(s);if((this.r32(o+F)&G)!==H)throw new I(ze);return{parentIno:s,name:r}}fdAlloc(e,t,r){for(let i=0;i>2;if(Atomics.compareExchange(this.i32,o,0,1)===0)return this.w32(s+Lo,e),this.w64(s+Le,0),this.w32(s+zo,t),this.w32(s+bo,r?1:0),this.inodeAddOpenRef(e)?i:(Atomics.store(this.i32,o,0),Oe)}return Fo}fdGet(e){if(e<0||e>=Dr)return null;let t=256+e*24;return Atomics.load(this.i32,t>>2)?{base:t,ino:this.r32(t+Lo),offset:this.r64(t+Le),flags:this.r32(t+zo),isDir:this.r32(t+bo)!==0}:null}fdFree(e){if(e>=0&&e>2,0)}}buildStat(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+ue),dataSequence:this.r32(t+de),mode:this.r32(t+F),linkCount:this.r32(t+K),size:this.r64(t+P),mtime:this.r64(t+fe),ctime:this.r64(t+ee),atime:this.r64(t+tr),uid:this.r32(t+nr),gid:this.r32(t+ir)}}namespaceEntryIdentity(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+ue),linkCount:this.r32(t+K),mode:this.r32(t+F)}}open(e,t,r=420){return this.withNamespaceLock(()=>this.openUnlocked(e,t,r))}createLazyStub(e,t){return this.withNamespaceLock(()=>{let r=this.openUnlocked(e,Ro|or,t);try{let i=this.fdGet(r);if(!i)throw new I(re);this.inodeWriteLock(i.ino);try{return this.inodeTruncate(i.ino,0,!0),this.buildStat(i.ino)}finally{this.inodeWriteUnlock(i.ino)}}finally{this.closeUnlocked(r)}})}replaceIfIdentity(e,t,r,i,s){return this.withNamespaceLock(()=>{let o=this.pathResolve(e,!0);if(o<0||o!==t)return!1;let c=this.inodeOffset(o);if(this.r64(c+ue)!==r||this.r32(c+de)!==i||(this.r32(c+F)&G)!==Jt)return!1;this.validateFileSize(s.byteLength),this.inodeWriteLock(o);try{if(this.r64(c+ue)!==r||this.r32(c+de)!==i||this.r64(c+P)!==0)return!1;let a=this.r64(c+fe),u=this.r64(c+ee);this.inodeTruncate(o,0,!0);let l=s.byteLength>0?this.inodeWriteData(o,0,s,s.byteLength):0;if(l!==s.byteLength)throw this.inodeTruncate(o,0,!0),Atomics.store(this.i32,c+de>>2,i),this.w64(c+fe,a),this.w64(c+ee,u),new I(l<0?l:oe);return!0}finally{this.inodeWriteUnlock(o)}})}replaceManyIfIdentities(e,t=[]){return e.length===0&&t.length===0?!0:this.withNamespaceLock(()=>{let r=[],i=new Set,s=c=>{let a=this.pathResolve(c.path,!1);if(a<0||a!==c.expectedIno)return!1;let u=this.inodeOffset(a);return this.r64(u+ue)===c.expectedGeneration&&this.r32(u+de)===c.expectedDataSequence&&this.r32(u+F)===c.expectedMode&&this.r32(u+K)===c.expectedLinkCount&&this.r64(u+P)===c.expectedSize&&this.r32(u+nr)===c.expectedUid&&this.r32(u+ir)===c.expectedGid};for(let c of t)if(!s(c))return!1;for(let c of e){this.validateFileSize(c.data.byteLength);let a=-1;for(let u of c.paths){let l=this.pathResolve(u,!0);if(l!==c.expectedIno)continue;let d=this.inodeOffset(l);if(this.r64(d+ue)===c.expectedGeneration&&this.r32(d+de)===c.expectedDataSequence&&(this.r32(d+F)&G)===Jt&&this.r64(d+P)===0){a=l;break}}if(a<0)return!1;if(i.has(a))throw new I(j,"duplicate conditional replacement inode");i.add(a),r.push({...c,ino:a})}let o=[...i].sort((c,a)=>c-a);for(let c of o)this.inodeWriteLock(c);try{for(let u of r){let l=this.inodeOffset(u.ino);if(this.r64(l+ue)!==u.expectedGeneration||this.r32(l+de)!==u.expectedDataSequence||(this.r32(l+F)&G)!==Jt||this.r64(l+P)!==0)return!1}for(let u of t)if(!s(u))return!1;let c=r.map(u=>{let l=this.inodeOffset(u.ino);return{ino:u.ino,dataSequence:this.r32(l+de),mtime:this.r64(l+fe),ctime:this.r64(l+ee)}}),a=0;try{for(let u of r){a++,this.inodeTruncate(u.ino,0,!0);let l=u.data.byteLength>0?this.inodeWriteData(u.ino,0,u.data,u.data.byteLength):0;if(l!==u.data.byteLength)throw new I(l<0?l:oe)}}catch(u){for(let l=a-1;l>=0;l--){let d=c[l],p=this.inodeOffset(d.ino);this.inodeTruncate(d.ino,0,!0),Atomics.store(this.i32,p+de>>2,d.dataSequence),this.w64(p+fe,d.mtime),this.w64(p+ee,d.ctime)}throw u}return!0}finally{for(let c=o.length-1;c>=0;c--)this.inodeWriteUnlock(o[c])}})}openUnlocked(e,t,r=420){let i=t&Qt,s=(t&or)!==0,o=(t&Vn)!==0;if(s&&o){let d=this.pathResolve(e,!1);if(d>=0)throw new I(At);if(d!==Oe)throw new I(d)}let c=this.pathResolve(e,!0);if(c<0&&c===Oe&&s){let{parentIno:d,name:p}=this.pathResolveParent(e);this.inodeWriteLock(d);try{let m=me.encode(p),f=this.dirLookup(d,m);if(f>=0){if(o)throw new I(At);c=f}else{let h=this.inodeAlloc();if(h<0)throw new I(oe);let g=this.inodeOffset(h);this.w32(g+F,Jt|r&4095),this.w32(g+K,1),this.w64(g+P,0);let _=Date.now();this.w64(g+tr,_),this.w64(g+fe,_),this.w64(g+ee,_);let y=this.dirAddEntry(d,m,h);if(y<0)throw this.inodeFree(h),new I(y);c=h}}finally{this.inodeWriteUnlock(d)}}if(c<0)throw new I(c);let a=this.inodeOffset(c),u=this.r32(a+F);if((u&G)===H&&i!==nt)throw new I(ot);if(t&hc&&(u&G)!==H)throw new I(ze);if(t&ar){if((u&G)===H)throw new I(ot);this.inodeWriteLock(c),this.inodeTruncate(c,0,!0),this.inodeWriteUnlock(c)}let l=this.fdAlloc(c,t,!1);if(l<0)throw new I(l);return l}close(e){this.withNamespaceLock(()=>this.closeUnlocked(e))}closeUnlocked(e){let t=this.fdGet(e);if(!t)throw new I(re);this.fdFree(e),this.inodeDropOpenRef(t.ino)}read(e,t){let r=this.fdGet(e);if(!r)throw new I(re);let i=this.inodeOffset(r.ino);if((this.r32(i+F)&G)===H)throw new I(ot);this.inodeReadLock(r.ino);try{let o=this.inodeReadData(r.ino,r.offset,t,t.length),c=256+e*24;return this.w64(c+Le,r.offset+o),o}finally{this.inodeReadUnlock(r.ino)}}readAt(e,t,r){let i=this.fdGet(e);if(!i)throw new I(re);let s=this.inodeOffset(i.ino);if((this.r32(s+F)&G)===H)throw new I(ot);this.validateSeekPosition(r),this.inodeReadLock(i.ino);try{return this.inodeReadData(i.ino,r,t,t.length)}finally{this.inodeReadUnlock(i.ino)}}write(e,t){let r=this.fdGet(e);if(!r)throw new I(re);if((r.flags&Qt)===nt)throw new I(re);this.inodeWriteLock(r.ino);try{let s=r.offset;if(r.flags&fc){let a=this.inodeOffset(r.ino);s=this.r64(a+P)}if(!Number.isSafeInteger(s)||s<0)throw new I(j);if(s>Ve||t.length>Ve-s)throw new I(st);let o=this.inodeWriteData(r.ino,s,t,t.length);if(o<0)return o;let c=256+e*24;return this.w64(c+Le,s+o),o}finally{this.inodeWriteUnlock(r.ino)}}append(e,t,r){let i=this.fdGet(e);if(!i)throw new I(re);if((i.flags&Qt)===nt)throw new I(re);if(r!==null&&(!Number.isSafeInteger(r)||r<0))throw new I(j);this.inodeWriteLock(i.ino);try{let o=this.inodeOffset(i.ino),c=this.r64(o+P);if(!Number.isSafeInteger(c)||c<0)throw new I(j);if(c>Ve)throw new I(st);if(r!==null&&c>=r){let f=256+e*24;return this.w64(f+Le,c),{written:0,end:c}}let a=r===null?t.length:Math.min(t.length,r-c),u=Ve-c;if(a>u)throw new I(st);let l=t.subarray(0,a),d=this.inodeWriteData(i.ino,c,l,l.length);if(d<0)throw new I(d);let p=256+e*24,m=c+d;return this.w64(p+Le,m),{written:d,end:m}}finally{this.inodeWriteUnlock(i.ino)}}writeAt(e,t,r){let i=this.fdGet(e);if(!i)throw new I(re);if((i.flags&Qt)===nt)throw new I(re);this.validateSeekPosition(r),this.inodeWriteLock(i.ino);try{if(r>Ve||t.length>Ve-r)throw new I(st);return this.inodeWriteData(i.ino,r,t,t.length)}finally{this.inodeWriteUnlock(i.ino)}}lseek(e,t,r){let i=this.fdGet(e);if(!i)throw new I(re);let s;if(r===pc)s=t;else if(r===mc)s=i.offset+t;else if(r===_c){let c=this.inodeOffset(i.ino);s=this.r64(c+P)+t}else throw new I(j);this.validateSeekPosition(s);let o=256+e*24;return this.w64(o+Le,s),s}ftruncate(e,t){let r=this.fdGet(e);if(!r)throw new I(re);if((r.flags&Qt)===nt)throw new I(re);this.validateFileSize(t),this.inodeWriteLock(r.ino);try{this.inodeTruncate(r.ino,t,!0)}finally{this.inodeWriteUnlock(r.ino)}}fstat(e){let t=this.fdGet(e);if(!t)throw new I(re);this.inodeReadLock(t.ino);try{return this.buildStat(t.ino)}finally{this.inodeReadUnlock(t.ino)}}stat(e){return this.withNamespaceLock(()=>this.statUnlocked(e))}statUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new I(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}lstat(e){return this.withNamespaceLock(()=>this.lstatUnlocked(e))}lstatUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new I(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}unlink(e){return this.withNamespaceLock(()=>this.unlinkUnlocked(e))}unlinkUnlocked(e){let{parentIno:t,name:r}=this.pathResolveParent(e),i=me.encode(r),s=e.length>1&&e.endsWith("/");this.inodeWriteLock(t);try{let o=this.dirLookup(t,i);if(o<0)throw new I(o);let c=this.inodeOffset(o),a=this.r32(c+F);if(s&&(a&G)!==H)throw new I(ze);if((a&G)===H)throw new I(ot);let u=this.namespaceEntryIdentity(o),l=this.dirRemoveEntry(t,i);if(l<0)throw new I(l);let d=!1;this.inodeWriteLock(o);try{d=this.inodeDropLinkRefLocked(o)}finally{this.inodeWriteUnlock(o)}return d&&this.inodeFree(o),u}finally{this.inodeWriteUnlock(t)}}rename(e,t){return this.withNamespaceLock(()=>this.renameUnlocked(e,t))}renameUnlocked(e,t){let{parentIno:r,name:i}=this.pathResolveParent(e),{parentIno:s,name:o}=this.pathResolveParent(t);if(Un(i)||Un(o))throw new I(j);let c=me.encode(i),a=me.encode(o),u=e.length>1&&e.endsWith("/"),l=t.length>1&&t.endsWith("/"),d=Math.min(r,s),p=Math.max(r,s);this.inodeWriteLock(d),d!==p&&this.inodeWriteLock(p);try{let m=this.dirLookup(r,c);if(m<0)throw new I(m);let f=this.inodeOffset(m),g=this.r32(f+F)&G,_=this.namespaceEntryIdentity(m);if((u||l)&&g!==H)throw new I(ze);if(g===H&&this.dirIsAncestor(m,s))throw new I(j);let y=this.dirLookup(s,a),E=!1,O;if(y>=0){if(y===m)return{source:_,replaced:_};O=this.namespaceEntryIdentity(y);let S=this.inodeOffset(y),R=this.r32(S+F)&G;if(g===H&&R!==H)throw new I(ze);if(g!==H&&R===H)throw new I(ot);let x=!1,v=y===r||y===s;v||this.inodeWriteLock(y);try{if(R===H&&!this.dirIsEmpty(y))throw new I(Hn);let L=this.dirReplaceEntryIno(s,a,m);if(L<0)throw new I(L);x=R===H?this.inodeOrphanLocked(y):this.inodeDropLinkRefLocked(y)}finally{v||this.inodeWriteUnlock(y)}x&&this.inodeFree(y),E=R===H}else{let S=this.dirAddEntry(s,a,m);if(S<0)throw new I(S)}let w=this.dirRemoveEntry(r,c);if(w<0)throw new I(w);if(g===H){if(r!==s){let S=this.inodeOffset(r);this.w32(S+K,this.r32(S+K)-1);let A=this.inodeOffset(s);this.w32(A+K,this.r32(A+K)+1),this.inodeWriteLock(m);try{let R=this.dirReplaceEntryIno(m,ko,s);if(R<0)throw new I(R);this.w64(f+ee,Date.now())}finally{this.inodeWriteUnlock(m)}}if(E){let S=this.inodeOffset(s);this.w32(S+K,this.r32(S+K)-1)}}else if(E){let S=this.inodeOffset(s);this.w32(S+K,this.r32(S+K)-1)}return{source:_,replaced:O}}finally{d!==p&&this.inodeWriteUnlock(p),this.inodeWriteUnlock(d)}}mkdir(e,t=493){this.withNamespaceLock(()=>this.mkdirUnlocked(e,t))}mkdirUnlocked(e,t=493){let{parentIno:r,name:i}=this.pathResolveParent(e),s=me.encode(i);this.inodeWriteLock(r);try{if(this.dirLookup(r,s)>=0)throw new I(At);let c=this.inodeAlloc();if(c<0)throw new I(oe);let a=this.inodeOffset(c);this.w32(a+F,H|t),this.w32(a+K,2),this.w64(a+P,0);let u=Date.now();this.w64(a+tr,u),this.w64(a+fe,u),this.w64(a+ee,u);let l=this.blockAllocWithGrow();if(l<0)throw this.inodeFree(c),new I(oe);this.w32(a+ie,l);let d=l*4096,p=qe(k+1),m=qe(k+2);this.w32(d,c),this.view.setUint16(d+4,p,!0),this.view.setUint16(d+6,1,!0),this.u8[d+k]=46;let f=d+p;this.w32(f,r),this.view.setUint16(f+4,m,!0),this.view.setUint16(f+6,2,!0),this.u8[f+k]=46,this.u8[f+k+1]=46,this.w64(a+P,p+m);let h=this.dirAddEntry(r,s,c);if(h<0)throw this.blockFree(l),this.inodeFree(c),new I(h);let g=this.inodeOffset(r);this.w32(g+K,this.r32(g+K)+1)}finally{this.inodeWriteUnlock(r)}}rmdir(e){this.withNamespaceLock(()=>this.rmdirUnlocked(e))}rmdirUnlocked(e){let{parentIno:t,name:r}=this.pathResolveParent(e);if(Un(r))throw new I(j);let i=me.encode(r);this.inodeWriteLock(t);try{let s=this.dirLookup(t,i);if(s<0)throw new I(s);let o=this.inodeOffset(s);if((this.r32(o+F)&G)!==H)throw new I(ze);let a=!1;this.inodeWriteLock(s);try{if(!this.dirIsEmpty(s))throw new I(Hn);let l=this.dirRemoveEntry(t,i);if(l<0)throw new I(l);a=this.inodeOrphanLocked(s)}finally{this.inodeWriteUnlock(s)}a&&this.inodeFree(s);let u=this.inodeOffset(t);this.w32(u+K,this.r32(u+K)-1)}finally{this.inodeWriteUnlock(t)}}symlink(e,t){this.withNamespaceLock(()=>this.symlinkUnlocked(e,t))}symlinkUnlocked(e,t){let{parentIno:r,name:i}=this.pathResolveParent(t),s=me.encode(i),o=me.encode(e);this.inodeWriteLock(r);try{if(this.dirLookup(r,s)>=0)throw new I(At);let a=this.inodeAlloc();if(a<0)throw new I(oe);let u=this.inodeOffset(a);if(this.w32(u+F,yt|511),this.w32(u+K,1),o.length<=40)this.u8.set(o,u+ie),this.w64(u+P,o.length);else{this.w64(u+P,0);let d=this.inodeWriteData(a,0,o,o.length);if(d!==o.length)throw d>0&&this.inodeTruncate(a,0),this.inodeFree(a),new I(d<0?d:oe)}let l=this.dirAddEntry(r,s,a);if(l<0)throw o.length<=40?(this.u8.fill(0,u+ie,u+ie+40),this.w64(u+P,0)):this.inodeTruncate(a,0),this.inodeFree(a),new I(l)}finally{this.inodeWriteUnlock(r)}}chmod(e,t){this.withNamespaceLock(()=>this.chmodUnlocked(e,t))}chmodUnlocked(e,t){let r=this.pathResolve(e,!0);if(r<0)throw new I(r);this.inodeWriteLock(r);try{let i=this.inodeOffset(r),s=this.r32(i+F);this.w32(i+F,s&G|t&4095),this.w64(i+ee,Date.now())}finally{this.inodeWriteUnlock(r)}}fchmod(e,t){let r=this.fdGet(e);if(!r)throw new I(re);this.inodeWriteLock(r.ino);try{let i=this.inodeOffset(r.ino),s=this.r32(i+F);this.w32(i+F,s&G|t&4095),this.w64(i+ee,Date.now())}finally{this.inodeWriteUnlock(r.ino)}}chown(e,t,r){this.withNamespaceLock(()=>this.chownUnlocked(e,t,r))}chownUnlocked(e,t,r){let i=this.pathResolve(e,!0);if(i<0)throw new I(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,r)}finally{this.inodeWriteUnlock(i)}}fchown(e,t,r){let i=this.fdGet(e);if(!i)throw new I(re);this.inodeWriteLock(i.ino);try{this.chownInodeUnlocked(i.ino,t,r)}finally{this.inodeWriteUnlock(i.ino)}}lchown(e,t,r){this.withNamespaceLock(()=>this.lchownUnlocked(e,t,r))}lchownUnlocked(e,t,r){let i=this.pathResolve(e,!1);if(i<0)throw new I(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,r)}finally{this.inodeWriteUnlock(i)}}chownInodeUnlocked(e,t,r){let i=this.inodeOffset(e);t!==Io&&this.w32(i+nr,t),r!==Io&&this.w32(i+ir,r);let s=this.r32(i+F);(s&G)===Jt&&(s&lc)!==0&&this.w32(i+F,s&~(uc|dc)),this.w64(i+ee,Date.now())}utimens(e,t,r,i,s){this.withNamespaceLock(()=>this.utimensUnlocked(e,t,r,i,s))}utimensUnlocked(e,t,r,i,s){let o=this.pathResolve(e,!0);if(o<0)throw new I(o);this.inodeWriteLock(o);try{let c=this.inodeOffset(o),a=1073741823,u=1073741822,l=Date.now();if(r!==u){let d=r===a?l:t*1e3+Math.floor(r/1e6);this.w64(c+tr,d)}if(s!==u){let d=s===a?l:i*1e3+Math.floor(s/1e6);this.w64(c+fe,d)}this.w64(c+ee,l)}finally{this.inodeWriteUnlock(o)}}link(e,t){return this.withNamespaceLock(()=>this.linkUnlocked(e,t))}linkUnlocked(e,t){let r=this.pathResolve(e,!1);if(r<0)throw new I(r);let i=this.inodeOffset(r);if((this.r32(i+F)&G)===H)throw new I(yc);let{parentIno:o,name:c}=this.pathResolveParent(t),a=me.encode(c);this.inodeWriteLock(o);try{if(this.dirLookup(o,a)>=0)throw new I(At);let l=this.dirAddEntry(o,a,r);if(l<0)throw new I(l);this.inodeWriteLock(r);try{let d=this.r32(i+K);this.w32(i+K,d+1),this.w64(i+ee,Date.now())}finally{this.inodeWriteUnlock(r)}return{...this.namespaceEntryIdentity(r),linkCount:this.r32(i+K)}}finally{this.inodeWriteUnlock(o)}}readlink(e){return this.withNamespaceLock(()=>this.readlinkUnlocked(e))}readlinkUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new I(t);return this.readSymlinkInodeUnlocked(t)}readSymlinkInodeUnlocked(e){let t=this.inodeOffset(e);if((this.r32(t+F)&G)!==yt)throw new I(j);let i=this.r64(t+P);if(i<=40)return Ot(this.u8.subarray(t+ie,t+ie+i));this.inodeReadLock(e);try{let s=new Uint8Array(i);return this.inodeReadData(e,0,s,i),sr.decode(s)}finally{this.inodeReadUnlock(e)}}opendir(e){return this.withNamespaceLock(()=>this.opendirUnlocked(e))}opendirUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new I(t);let r=this.inodeOffset(t);if((this.r32(r+F)&G)!==H)throw new I(ze);let s=this.fdAlloc(t,nt,!0);if(s<0)throw new I(s);return s}readdirEntry(e){return this.withNamespaceLock(()=>this.readdirEntryUnlocked(e))}readdirEntryUnlocked(e){let t=this.fdGet(e);if(!t||!t.isDir)throw new I(re);let r=this.inodeOffset(t.ino),i=this.r64(r+P);for(;t.offset=this.r32(Ge))throw new I(B);let h=this.r32(it)*4096;if((this.r32(h+(l>>5)*4)&1<<(l&31))===0)throw new I(B);let _=Ot(this.u8.subarray(u+k,u+k+p)),y=this.buildStat(l);return this.w64(f+Le,m),t.offset=m,{name:_,stat:y}}return null}closedir(e){this.close(e)}readdir(e){let t=this.opendir(e),r=[];try{let i;for(;(i=this.readdirEntry(t))!==null;)i.name!=="."&&i.name!==".."&&r.push(i.name)}finally{this.closedir(t)}return r}writeFile(e,t){let r=typeof t=="string"?me.encode(t):t,i=this.open(e,Ro|or|ar);try{this.write(i,r)}finally{this.close(i)}}readFile(e){let t=this.open(e,nt);try{let r=this.fstat(t),i=new Uint8Array(r.size);return this.read(t,i),i}finally{this.close(t)}}readFileText(e){return sr.decode(this.readFile(e))}};function Mo(n,e){let t=new Map,r=new Map;for(let o of n){if(t.has(o.path))throw new Error(`${e} duplicates path ${o.path}`);if(t.set(o.path,o),o.type==="file"){if(!o.inodeGroup)throw new Error(`${e} file ${o.path} has no inode group`);if(r.has(o.inodeGroup))throw new Error(`${e} inode group ${o.inodeGroup} has multiple files`);r.set(o.inodeGroup,o)}}let i=new Set,s=new Map;for(let o of n){if(o.type!=="hardlink"||s.has(o.path))continue;let c=[],a=o,u;for(;a.type==="hardlink";){let d=s.get(a.path);if(d){u=d;break}if(i.has(a.path))throw new Error(`${e} hardlink cycle reaches ${a.path}`);if(i.add(a.path),c.push(a),!a.target)throw new Error(`${e} hardlink ${a.path} has no target`);let p=t.get(a.target);if(!p)throw new Error(`${e} hardlink ${a.path} target ${a.target} is missing`);if(p.type!=="file"&&p.type!=="hardlink"||!a.inodeGroup||p.inodeGroup!==a.inodeGroup||p.size!==a.size||p.mode!==a.mode)throw new Error(`${e} hardlink ${a.path} has an invalid target`);a=p}u??=a.type==="file"?a:void 0;let l=r.get(o.inodeGroup??"");if(!u||u!==l)throw new Error(`${e} hardlink ${o.path} does not resolve to its inode`);for(let d=c.length-1;d>=0;d-=1){let p=c[d];if(r.get(p.inodeGroup??"")!==u)throw new Error(`${e} hardlink ${p.path} does not resolve to its inode`);i.delete(p.path),s.set(p.path,u)}}return{canonicalByGroup:r,canonicalTargetByPath:s}}var he={maxArchiveBytes:268435456,maxExpandedBytes:268435456,maxPayloadBytes:268435456,maxEntries:1e5,maxPathBytes:4096,maxSymlinkTargetBytes:65536,maxStringBytes:8192,maxTransportsPerTree:8,maxActivationCapabilities:32,maxActivationRoots:64,maxActivationCapabilityBytes:255},xe={maxArchiveBytes:512*1024*1024,maxExpandedBytes:512*1024*1024,maxPayloadBytes:512*1024*1024,maxEntries:1e5,maxGroups:512};function Do(n,e="Deferred tree collection"){for(let[t,r]of Object.entries(n))if(!Number.isSafeInteger(r)||r<0)throw new Error(`${e} ${t} usage is invalid`);if(n.groups>xe.maxGroups)throw new Error(`${e} exceeds the ${xe.maxGroups}-group cap`);if(n.archiveBytes>xe.maxArchiveBytes)throw new Error(`${e} exceeds the archive-byte cap`);if(n.expandedBytes>xe.maxExpandedBytes)throw new Error(`${e} exceeds the expansion cap`);if(n.payloadBytes>xe.maxPayloadBytes)throw new Error(`${e} exceeds the payload-byte cap`);if(n.entries>xe.maxEntries)throw new Error(`${e} exceeds the entry-count cap`)}var Ko=Object.freeze({prefix:"/opt/kandelo/homebrew",cellar:"/opt/kandelo/homebrew/Cellar",repository:"/opt/kandelo/homebrew",stableEntrypoint:"/usr/bin/brew"});var Bo=1e5,Ic=4096;var It=Ko.prefix,Wo=[["@@HOMEBREW_PREFIX@@",It],["@@HOMEBREW_CELLAR@@",`${It}/Cellar`],["@@HOMEBREW_REPOSITORY@@",It],["@@HOMEBREW_LIBRARY@@",`${It}/Library`],["@@HOMEBREW_PERL@@",`${It}/opt/perl/bin/perl`]],qn="@@HOMEBREW_JAVA@@",Rc=/^openjdk(?:@\d+(?:\.\d+)*)?/,Rt=new TextEncoder,xc=[...Wo.map(([n])=>n),qn].map(n=>({placeholder:n,bytes:Rt.encode(n)}));function Go(n){let e=Tc(n),t=e.changed_files;if(t!=null&&!Array.isArray(t))throw new Error("INSTALL_RECEIPT.json changed_files must be an array or null when present");let r=Array.isArray(t)?t:[];if(r.length>Bo)throw new Error(`INSTALL_RECEIPT.json declares ${r.length} changed files, limit ${Bo}`);let i=[],s=new Set;for(let[o,c]of r.entries()){if(typeof c!="string")throw new Error(`INSTALL_RECEIPT.json changed_files[${o}] is not a string`);if(Lc(c,"Homebrew changed file"),s.has(c))throw new Error(`INSTALL_RECEIPT.json repeats changed file ${c}`);s.add(c),i.push(c)}return{changedFiles:i,runtimeDependencies:e.runtime_dependencies}}function Tc(n){let e;try{e=JSON.parse(new TextDecoder("utf-8",{fatal:!0}).decode(n))}catch(t){throw new Error("INSTALL_RECEIPT.json is not valid UTF-8 JSON: "+bc(t))}if(typeof e!="object"||e===null||Array.isArray(e))throw new Error("INSTALL_RECEIPT.json must contain an object");return e}function Ho(n,e,t){let r=n;for(let[o,c]of Wo)r=Uo(r,Rt.encode(o),Rt.encode(c));let i=Rt.encode(qn);if($o(r,i)){let o=vc(e.runtimeDependencies);if(o===void 0)throw new Error(`Homebrew changed file ${t} uses ${qn} without exactly one OpenJDK runtime dependency`);r=Uo(r,i,Rt.encode(o))}let s=xc.find(({bytes:o})=>$o(r,o));if(s!==void 0)throw new Error(`Homebrew changed file ${t} retains ${s.placeholder}`);return r}function vc(n){if(!Array.isArray(n))return;let e=[];for(let r of n){if(typeof r!="object"||r===null||Array.isArray(r))continue;let i=r,s=typeof i.full_name=="string"?i.full_name.split("/").at(-1):typeof i.name=="string"?i.name.split("/").at(-1):void 0,o=s===void 0?null:Rc.exec(s);s!==void 0&&o?.[0]===s&&e.push(s)}let t=[...new Set(e)];return t.length===1?`${It}/opt/${t[0]}/libexec`:void 0}function Lc(n,e){if(n.length===0||n.startsWith("/")||n.includes("\\")||n.includes("\0")||zc(n)||Rt.encode(n).byteLength>Ic||n.split("/").some(t=>t===""||t==="."||t===".."))throw new Error(`${e} has an unsafe path segment: ${n}`)}function zc(n){for(let e=0;e57343)){if(t<=56319&&e+1=56320&&n.charCodeAt(e+1)<=57343){e+=1;continue}return!0}}return!1}function $o(n,e){if(e.byteLength===0||e.byteLength>n.byteLength)return!1;e:for(let t=0;t<=n.byteLength-e.byteLength;t+=1){for(let r=0;rnn||n.includes("\0")||n.includes("\\"))throw new Error(`Lazy archive mount prefix must be an absolute POSIX path: ${JSON.stringify(n)}`);let e=n.replace(/\/+$/,"");if(e==="")return"/";if(e.slice(1).split("/").some(r=>r===""||r==="."||r===".."))throw new Error(`Lazy archive mount prefix is not canonical: ${JSON.stringify(n)}`);return e}function bu(n,e,t,r){let i=yr(t),s=new Map,o=e.map(c=>{let a=c.fileName,u=`Lazy archive ${JSON.stringify(n)} member ${JSON.stringify(a)}`;if(a.length===0)throw new Error(`${u} has an empty path`);if(a.includes("\0"))throw new Error(`${u} contains a NUL byte`);if(a.includes("\\"))throw new Error(`${u} contains a backslash`);if(a.startsWith("/")||/^[A-Za-z]:\//.test(a))throw new Error(`${u} must be relative, not absolute`);if(c.isDirectory&&c.isSymlink)throw new Error(`${u} has conflicting directory and symlink types`);if(c.isDirectory!==a.endsWith("/"))throw new Error(`${u} has inconsistent directory metadata`);let l=c.isDirectory?a.slice(0,-1):a,d=l.split("/");if(l.length===0||d.some(p=>p===""||p==="."||p===".."))throw new Error(`${u} is not a canonical relative POSIX path`);if(s.has(l))throw new Error(`${u} collides with another member at ${JSON.stringify(l)}`);if(c.isSymlink&&!r?.has(a))throw new Error(`Lazy archive symlink target was not provided: ${a}`);return s.set(l,c),{entry:c,archivePath:l,vfsPath:i==="/"?`/${l}`:`${i}/${l}`}});for(let{archivePath:c}of o){let a=c.split("/");for(let u=1;uzt)throw new Error(`VFS image metadata exceeds ${zt} bytes`);let e;try{e=JSON.parse(new TextDecoder().decode(n))}catch(t){let r=t instanceof Error?t.message:String(t);throw new Error(`Invalid VFS image metadata JSON: ${r}`)}return Si(e)}function Fu(n){if(n===null)return new Uint8Array(0);let e=Si(n),t=new TextEncoder().encode(JSON.stringify(e));if(t.byteLength>zt)throw new Error(`VFS image metadata exceeds ${zt} bytes`);return t}function Nu(n){return n.byteLength>=fr.length&&n[0]===fr[0]&&n[1]===fr[1]&&n[2]===fr[2]&&n[3]===fr[3]?rd(n):n}function qr(n){let e=Nu(n);if(e.byteLengthYr)throw new Error(`VFS image lazy metadata exceeds ${Yr} bytes`);if(n.byteLengthjr)throw new Error(`VFS image lazy archive metadata exceeds ${jr} bytes`);if(n.byteLength=0?r:void 0}function Du(n){return n===408||n===429||n>=500&&n<=599}function Ku(n,e=Date.now()){let t=n?.get("retry-after")?.trim();if(!t)return;let r;if(/^\d+$/.test(t))r=Number(t)*1e3;else{let i=Date.parse(t);if(!Number.isFinite(i))return;r=Math.max(0,i-e)}if(!(!Number.isSafeInteger(r)||r<0))return Math.min(r,Ps)}function Bu(n){if(!(typeof n!="object"||n===null||!("cause"in n)))return n.cause}function ks(n){if(!(typeof n!="object"||n===null||!("name"in n)))return typeof n.name=="string"?n.name:void 0}function Fs(n){if(!(typeof n!="object"||n===null||!("code"in n)))return typeof n.code=="string"?n.code:void 0}function Ns(n,e){let t=new Set,r=n;for(let i=0;r!==void 0&&i<8;i+=1){if(t.has(r))return!1;if(t.add(r),e(r))return!0;r=Bu(r)}return!1}function Cs(n){return Ns(n,e=>ks(e)==="AbortError"||Fs(e)==="ABORT_ERR")}function $u(n){return Cs(n)?!1:Ns(n,e=>{let t=ks(e),r=Fs(e);return e instanceof TypeError||t==="NetworkError"||t==="TimeoutError"||r!==void 0&&zu.has(r)})}function Uu(n,e){if(n instanceof Qr){if(!Du(n.status))return null;if(n.retryAfterMs!==void 0)return n.retryAfterMs}else if(!$u(n))return null;return Math.min(Lu*2**e,Ps)}function te(n){if(n?.aborted)throw n.reason}function Wu(n,e){return te(e),n===0?Promise.resolve():new Promise((t,r)=>{let i=setTimeout(()=>c(!1),n),s=()=>c(!0,e.reason),o=!1;function c(a,u){o||(o=!0,clearTimeout(i),e?.removeEventListener("abort",s),a?r(u):t())}e?.addEventListener("abort",s,{once:!0}),e?.aborted&&s()})}async function di(n,e){try{await n.body?.cancel(e)}catch{}}function Gu(n,e){if(n.length===1)return n[0];let t=new Uint8Array(e),r=0;for(let i of n)t.set(i,r),r+=i.byteLength;return t}function gr(n){if(n===void 0)return;if(typeof n!="object"||n===null||Array.isArray(n))throw new Error("Lazy archive integrity must be an object");let e=n;if(Object.keys(e).length!==2||!("sha256"in e)||!("bytes"in e))throw new Error("Lazy archive integrity has unexpected fields");if(typeof e.sha256!="string"||!pi.test(e.sha256))throw new Error("Lazy archive integrity has an invalid SHA-256 digest");if(!Number.isSafeInteger(e.bytes)||Number(e.bytes)<=0||Number(e.bytes)>gs)throw new Error(`Lazy archive integrity byte count must be between 1 and ${gs}`);return{sha256:e.sha256,bytes:Number(e.bytes)}}function Ze(n,e,t){if(typeof n!="object"||n===null||Array.isArray(n))throw new Error(`${t} must be an object`);let r=n;if(Object.keys(r).length!==e.length||e.some(s=>!Object.prototype.hasOwnProperty.call(r,s)))throw new Error(`${t} has unexpected or missing fields`);return r}function _i(n,e,t,r){if(typeof n!="object"||n===null||Array.isArray(n))throw new Error(`${r} must be an object`);let i=n,s=new Set(e);if(Object.keys(i).some(o=>!s.has(o))||t.some(o=>!Object.prototype.hasOwnProperty.call(i,o)))throw new Error(`${r} has unexpected or missing fields`);return i}function Ne(n,e,t,r){if(!Array.isArray(n)||n.lengthr)throw new Error(`${e} must contain ${t} to ${r} items`);return n}function ye(n,e,t){if(typeof n!="string"||n.length===0||n.includes("\0")||new TextEncoder().encode(n).byteLength>t)throw new Error(`${e} is invalid or exceeds ${t} bytes`);return n}function ae(n,e,t,r){if(!Number.isSafeInteger(n)||Number(n)r)throw new Error(`${e} must be an integer between ${t} and ${r}`);return Number(n)}function en(n,e=1){let t=n,r=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.source!==void 0,i=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.modePolicy!==void 0,s=Ze(n,["decoder","mediaType","sha256","bytes","expandedBytes","sourceEntryCount","transports",...i?["modePolicy"]:[],...r?["source"]:[]],"Lazy tree content"),o=s.decoder==="zip-v1"?"application/zip":s.decoder==="homebrew-bottle-tar-gzip-v1"?"application/vnd.oci.image.layer.v1.tar+gzip":null;if(o===null||s.mediaType!==o)throw new Error("Lazy tree decoder and media type are inconsistent");let c=gr({sha256:s.sha256,bytes:s.bytes});if(!c)throw new Error("Lazy tree integrity is required");let a=Ne(s.transports,"Lazy tree transports",e,he.maxTransportsPerTree).map((m,f)=>ye(m,`Lazy tree transport ${f}`,Ei));if(new Set(a).size!==a.length)throw new Error("Lazy tree transports contain duplicates");let u=ae(s.expandedBytes,"Lazy tree expanded byte count",0,Iu),l=ae(s.sourceEntryCount,"Lazy tree source entry count",1,bt),d=r?qu(s.source,s.decoder):void 0,p=i?s.modePolicy:void 0;if(p!==void 0&&(p!=="portable-posix-v1"||s.decoder!=="zip-v1"||r))throw new Error("Lazy tree mode policy is invalid for its decoder");if(d!==void 0&&d.entries.length!==l)throw new Error("Lazy tree source inventory count differs from its content");return{decoder:s.decoder,mediaType:o,sha256:c.sha256,bytes:c.bytes,expandedBytes:u,sourceEntryCount:l,transports:a,...p===void 0?{}:{modePolicy:p},...d===void 0?{}:{source:d}}}function Ms(n){let e={groups:n.length,archiveBytes:0,expandedBytes:0,payloadBytes:0,entries:0};for(let t of n)t.content===void 0||t.inventory===void 0||(e.archiveBytes+=t.content.bytes,e.expandedBytes+=t.content.expandedBytes,e.payloadBytes+=t.inventory.filter(r=>r.type==="file").reduce((r,i)=>r+i.size,0),e.entries+=t.inventory.length+(t.content.source?.entries.length??0));return e}function yi(n){Do(n,"Serialized lazy tree collection")}function Hu(n){yi(Ms(n))}function Vu(n){let e=new Map;for(let t of n){let r=t.activation?.atomicGroup;if(r===void 0)continue;if(!Lt(r))throw new Error(`Serialized lazy atomic activation group ${r.id} is unsealed`);let i=e.get(r.id);if(i===void 0)i={expectedCount:r.expectedCount,cohortSha256:r.cohortSha256,members:new Set,descriptors:new Set},e.set(r.id,i);else if(i.expectedCount!==r.expectedCount||i.cohortSha256!==r.cohortSha256)throw new Error(`Serialized lazy atomic activation group ${r.id} has inconsistent seals`);if(i.members.has(r.member)||i.descriptors.has(r.descriptorSha256))throw new Error(`Serialized lazy atomic activation group ${r.id} duplicates a member`);i.members.add(r.member),i.descriptors.add(r.descriptorSha256)}for(let[t,r]of e)if(r.members.size!==r.expectedCount)throw new Error(`Serialized lazy atomic activation group ${t} has ${r.members.size} of ${r.expectedCount} members`)}function Is(n){for(let[e,t]of n.entries())if(t.kind===_r||t.kind===mi||t.kind===ct)$s(t,t.kind);else if(t.kind===mr)gi(t,!1);else throw new Error(`Serialized lazy archive group ${e} has an unsupported kind`);Hu(n),Vu(n)}function qu(n,e){if(e!=="homebrew-bottle-tar-gzip-v1")throw new Error("Lazy tree source inventory is valid only for original bottles");let t=Ze(n,["schema","kind","entries"],"Lazy tree source inventory");if(t.schema!==1||t.kind!=="homebrew-bottle-tar-gzip-v1")throw new Error("Lazy tree source inventory has an unsupported identity");let r=new Map,i=Ne(t.entries,"Lazy tree source entries",1,bt).map((o,c)=>{let a=o,u=typeof a=="object"&&a!==null&&!Array.isArray(a)?a.type:void 0,l=u==="directory"||u==="file"?["sourcePath","type","mode","size"]:u==="symlink"||u==="hardlink"?["sourcePath","type","mode","size","target"]:null;if(l===null)throw new Error(`Lazy tree source entry ${c} has invalid type`);let d=Ze(o,l,`Lazy tree source entry ${c}`),p=ge(d.sourcePath,!1,`Lazy tree source entry ${c} path`);if(r.has(p))throw new Error(`Lazy tree source inventory duplicates ${p}`);let m=ae(d.mode,`Lazy tree source entry ${p} mode`,0,W.S_MODE_BITS),f=ae(d.size,`Lazy tree source entry ${p} size`,0,Jr),h;if((u==="directory"||u==="symlink"||u==="hardlink")&&f!==0)throw new Error(`Lazy tree source ${p} has payload for ${String(u)}`);u==="symlink"?h=ye(d.target,`Lazy tree source symlink ${p} target`,bs):u==="hardlink"&&(h=ge(d.target,!1,`Lazy tree source hardlink ${p} target`));let g={sourcePath:p,type:u,mode:m,size:f,...h===void 0?{}:{target:h}};return r.set(p,g),g}),s=i.map(o=>o.sourcePath);if(s.some((o,c)=>c>0&&s[c-1]>=o))throw new Error("Lazy tree source inventory is not in canonical path order");return{schema:1,kind:"homebrew-bottle-tar-gzip-v1",entries:i}}function Ds(n){let e=new Map(n.map(r=>[r.sourcePath,r])),t=new Map;for(let r of n){if(r.type!=="hardlink"||t.has(r.sourcePath))continue;let i=[],s=new Set,o=r,c;for(;o.type==="hardlink"&&(c=t.get(o.sourcePath),c===void 0);){if(s.has(o.sourcePath))throw new Error(`Lazy tree source hardlink cycle includes ${o.sourcePath}`);s.add(o.sourcePath),i.push(o);let a=e.get(o.target);if(a===void 0)throw new Error(`Lazy tree source hardlink ${o.sourcePath} target is absent`);if(a.type!=="file"&&a.type!=="hardlink")throw new Error(`Lazy tree source hardlink ${o.sourcePath} target is not regular`);o=a}c===void 0&&(c=o);for(let a of i)t.set(a.sourcePath,c)}return t}function ge(n,e,t,r=!1){if(typeof n!="string"||n.length===0||new TextEncoder().encode(n).byteLength>nn||n.includes("\0")||n.includes("\\")||n.startsWith("/")!==e)throw new Error(`${t} is not a canonical ${e?"absolute":"relative"} path`);if(r&&e&&n==="/")return n;if(n.slice(e?1:0).split("/").some(s=>s===""||s==="."||s===".."))throw new Error(`${t} has an unsafe path segment`);return n}function Ks(n){let e=typeof n=="object"&&n!==null&&!Array.isArray(n)?n:null,t=e!==null&&(Object.hasOwn(e,"descriptorSha256")||Object.hasOwn(e,"expectedCount")||Object.hasOwn(e,"cohortSha256")),r=Ze(n,t?["id","member","descriptorSha256","expectedCount","cohortSha256"]:["id","member"],"Lazy tree atomic activation membership"),i=ye(r.id,"Lazy tree atomic activation group",Es),s=ye(r.member,"Lazy tree atomic activation member",Es);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(i)||!/^[a-z0-9][a-z0-9:+._/-]*$/.test(s)||s.includes("//")||s.endsWith("/"))throw new Error("Lazy tree atomic activation membership is invalid");if(!t)return{id:i,member:s};let o=ye(r.descriptorSha256,"Lazy tree atomic member descriptor digest",64),c=ye(r.cohortSha256,"Lazy tree atomic cohort digest",64);if(!pi.test(o)||!pi.test(c))throw new Error("Lazy tree atomic activation digest is invalid");return{id:i,member:s,descriptorSha256:o,expectedCount:ae(r.expectedCount,"Lazy tree atomic activation expected member count",1,zs),cohortSha256:c}}function Lt(n){return n.descriptorSha256!==void 0&&n.expectedCount!==void 0&&n.cohortSha256!==void 0}function Zu(n){let e=Ze(n,["uid","gid"],"Lazy tree registration owner");return{uid:ae(e.uid,"Lazy tree registration owner uid",0,Ss),gid:ae(e.gid,"Lazy tree registration owner gid",0,Ss)}}function Bs(n,e,t,r,i=1){let s=en(n,i),o=yr(t),c=["mode","capabilities","roots",...typeof r=="object"&&r!==null&&!Array.isArray(r)&&Object.hasOwn(r,"atomicGroup")?["atomicGroup"]:[]],a=Ze(r,c,"Lazy tree activation");if(a.mode!=="boot-prefetch"&&a.mode!=="first-use")throw new Error("Lazy tree activation mode is invalid");let u=Ne(a.capabilities,"Lazy tree activation capabilities",1,Tu).map((S,A)=>{let R=ye(S,`Lazy tree activation capability ${A}`,he.maxActivationCapabilityBytes);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(R))throw new Error(`Lazy tree activation capability ${A} is invalid`);return R}),l=Ne(a.roots,"Lazy tree activation roots",1,vu).map((S,A)=>ge(S,!0,`Lazy tree activation root ${A}`,!0));if(new Set(u).size!==u.length||new Set(l).size!==l.length)throw new Error("Lazy tree activation contains duplicates");let d=a.atomicGroup===void 0?void 0:Ks(a.atomicGroup);if(d!==void 0&&a.mode!=="first-use")throw new Error("Lazy tree atomic activation group requires a valid first-use identity");let p={mode:a.mode,capabilities:u,roots:l,...d===void 0?{}:{atomicGroup:d}},m=Ne(e,"Lazy tree inventory",1,bt),f=[],h=new Map,g=new Map,_=s.source===void 0?void 0:new Map(s.source.entries.map(S=>[S.sourcePath,S])),y=s.source===void 0?void 0:Ds(s.source.entries),E=0;for(let[S,A]of m.entries()){if(typeof A!="object"||A===null||Array.isArray(A))throw new Error(`Lazy tree entry ${S} must be an object`);let R=A.type,x=R==="directory"?["vfsPath","sourcePath","type","mode","size"]:R==="file"?["vfsPath","sourcePath","type","mode","size","inodeGroup"]:R==="symlink"?["vfsPath","sourcePath","type","mode","size","target"]:R==="hardlink"?["vfsPath","sourcePath","type","mode","size","target","inodeGroup"]:null;if(!x)throw new Error(`Lazy tree entry ${S} has an invalid type`);let v=Ze(A,[...x,..._===void 0?[]:["materialization"]],`Lazy tree entry ${S}`),L=ge(v.vfsPath,!0,`Lazy tree entry ${S} VFS path`),D=ge(v.sourcePath,!1,`Lazy tree entry ${S} source path`),Z=_===void 0?void 0:v.materialization;if(_!==void 0&&Z!=="archive"&&Z!=="archive-homebrew-relocate"&&Z!=="archive-copy"&&Z!=="archive-copy-mode"&&Z!=="descriptor")throw new Error(`Lazy tree entry ${L} has invalid materialization provenance`);if(o!=="/"&&L!==o&&!L.startsWith(`${o}/`))throw new Error(`Lazy tree entry ${L} escapes its mount prefix`);if(h.has(L))throw new Error(`Lazy tree duplicates VFS path ${L}`);let N=ae(v.mode,`Lazy tree entry ${L} mode`,0,W.S_MODE_BITS),b=ae(v.size,`Lazy tree entry ${L} size`,0,Jr),U,le;if(R==="directory"){if(b!==0)throw new Error(`Lazy tree directory ${L} has nonzero size`)}else if(R==="symlink"){if(U=ye(v.target,`Lazy tree symlink ${L} target`,bs),new TextEncoder().encode(U).byteLength!==b)throw new Error(`Lazy tree symlink ${L} size differs from its target`)}else le=ye(v.inodeGroup,`Lazy tree entry ${L} inode group`,nn),R==="hardlink"&&(U=ge(v.target,!0,`Lazy tree hardlink ${L} target`));if(R!=="hardlink"&&(E+=b,E>Jr))throw new Error("Lazy tree inventory exceeds the expansion limit");let C={vfsPath:L,sourcePath:D,...Z===void 0?{}:{materialization:Z},type:R,mode:N,size:b,...U===void 0?{}:{target:U},...le===void 0?{}:{inodeGroup:le}};if(_===void 0){let V=g.get(D);if(V){if(s.decoder!=="zip-v1"||C.type!=="hardlink"||V.inodeGroup!==C.inodeGroup)throw new Error(`Lazy tree duplicates source path ${D}`)}else{if(s.decoder==="zip-v1"&&C.type==="hardlink")throw new Error(`Lazy ZIP hardlink ${L} does not reuse a canonical source path`);g.set(D,C)}}else if(C.materialization==="descriptor"){if(C.type!=="directory"&&C.type!=="symlink")throw new Error(`Lazy tree descriptor entry ${L} is not structural`);if(_.has(D))throw new Error(`Lazy tree descriptor entry ${L} impersonates a source member`)}else{let V=_.get(D);if(V===void 0)throw new Error(`Lazy tree entry ${L} names absent source ${D}`);if(C.materialization==="archive-copy"||C.materialization==="archive-copy-mode"){if(C.type!=="file"||V.type!=="file"||C.materialization==="archive-copy"&&C.mode!==V.mode)throw new Error(`Lazy tree archive copy ${L} differs from its source`)}else if(C.materialization==="archive-homebrew-relocate"){if(C.type!=="file"&&C.type!=="hardlink"||V.type!==C.type||C.type==="file"&&V.mode!==C.mode)throw new Error(`Lazy tree receipt-relocated entry ${L} differs from its source`)}else if(V.type!==C.type||C.type==="symlink"&&V.target!==C.target||C.type!=="hardlink"&&V.mode!==C.mode)throw new Error(`Lazy tree archive entry ${L} differs from its source`)}f.push(C),h.set(L,C)}for(let S of f){let A=S.vfsPath.split("/").filter(Boolean);for(let R=1;R({path:S.vfsPath,type:S.type,mode:S.mode,size:S.size,target:S.target,inodeGroup:S.inodeGroup})),"Lazy tree");if(_!==void 0){let S=new Set;for(let A of f){if(A.materialization!=="archive-homebrew-relocate")continue;let R=_.get(A.sourcePath),x=R.type==="file"?R:y.get(R.sourcePath);if(x?.type!=="file")throw new Error(`Lazy tree receipt-relocated entry ${A.vfsPath} is not regular`);S.add(x.sourcePath)}for(let A of f){if(A.materialization==="descriptor"||A.type!=="file"&&A.type!=="hardlink")continue;let R=_.get(A.sourcePath),x=R.type==="file"?R:y.get(R.sourcePath);if(x?.type!=="file"||!S.has(x.sourcePath)&&A.size!==x.size)throw new Error(`Lazy tree archive entry ${A.vfsPath} differs from its source`)}for(let A of f){if(A.type!=="hardlink"||A.materialization!=="archive"&&A.materialization!=="archive-homebrew-relocate")continue;let R=_.get(A.sourcePath),x=h.get(A.target),v=y.get(R.sourcePath);if(R.target!==x?.sourcePath||v?.type!=="file"||v.mode!==A.mode||x?.mode!==A.mode)throw new Error(`Lazy tree hardlink ${A.vfsPath} differs from its source`)}}if(s.sourceEntryCount!==(_===void 0?g.size:_.size))throw new Error("Lazy tree source entry count differs from its inventory");if(s.source===void 0&&s.expandedBytesA.vfsPath===S||A.vfsPath.startsWith(`${S}/`)))throw new Error(`Lazy tree activation root ${S} is not owned by its inventory`);let w=new Map;for(let S of f)S.type==="file"&&w.set(S.inodeGroup,S);if(w.size!==O.canonicalByGroup.size)throw new Error("Lazy tree regular inode inventory is inconsistent");return{content:s,entries:f,mountPrefix:o,activation:p,canonicalByGroup:w}}function tn(n){return JSON.stringify([n.sourcePath,n.type,n.inodeGroup,n.target])}function gi(n,e){let t=_i(n,["kind","content","url","mountPrefix","integrity","materialized","entries"],["url","mountPrefix","materialized","entries"],"Serialized legacy lazy archive");if(t.kind===void 0){if(!e)throw new Error("Serialized lazy archive is missing its kind discriminator")}else if(t.kind!==mr)throw new Error("Serialized legacy lazy archive has an unsupported kind");let r=ye(t.url,"Serialized legacy lazy archive URL",Ei),i=yr(t.mountPrefix),s=gr(t.integrity);if(t.content!==void 0){if(!e||t.kind!==void 0)throw new Error("Typed legacy lazy archives cannot carry generic content");let a=en(t.content);if(a.decoder!=="zip-v1"||a.transports.length!==1||a.transports[0]!==r||!s||a.sha256!==s.sha256||a.bytes!==s.bytes)throw new Error("Untagged legacy ZIP content identity is inconsistent")}if(t.materialized!==!1)throw new Error("Serialized legacy lazy archive must describe pending content");let o=new Set,c=Ne(t.entries,"Serialized legacy lazy archive entries",1,bt).map((a,u)=>{let l=_i(a,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","size","isSymlink","deleted"],`Serialized legacy lazy archive entry ${u}`),d=ge(l.vfsPath,!0,`Serialized legacy lazy archive entry ${u} VFS path`);if(o.has(d))throw new Error(`Serialized legacy lazy archive duplicates path ${d}`);o.add(d);let p=ae(l.ino,`Serialized legacy lazy archive entry ${d} inode`,1,Number.MAX_SAFE_INTEGER),m=l.generation===void 0?void 0:ae(l.generation,`Serialized legacy lazy archive entry ${d} generation`,0,Number.MAX_SAFE_INTEGER),f=l.dataSequence===void 0?void 0:ae(l.dataSequence,`Serialized legacy lazy archive entry ${d} data sequence`,0,Number.MAX_SAFE_INTEGER),h=ae(l.size,`Serialized legacy lazy archive entry ${d} size`,0,Jr);if(l.isSymlink!==!1||l.deleted!==!1||l.materialized!==void 0&&l.materialized!==!1)throw new Error(`Serialized legacy lazy archive entry ${d} is not pending`);if(l.type!==void 0&&l.type!=="file")throw new Error(`Serialized legacy lazy archive entry ${d} has an invalid type`);let g=l.archivePath===void 0?void 0:ge(l.archivePath,!1,`Serialized legacy lazy archive entry ${d} archive path`),_=l.sourcePath===void 0?void 0:ge(l.sourcePath,!1,`Serialized legacy lazy archive entry ${d} source path`),y=l.inodeGroup===void 0?void 0:ye(l.inodeGroup,`Serialized legacy lazy archive entry ${d} inode group`,nn);if(l.target!==void 0)throw new Error(`Serialized legacy lazy archive entry ${d} has a link target`);return{vfsPath:d,ino:p,...m===void 0?{}:{generation:m},...f===void 0?{}:{dataSequence:f},size:h,isSymlink:!1,deleted:!1,materialized:!1,...g===void 0?{}:{archivePath:g},..._===void 0?{}:{sourcePath:_},type:"file",...y===void 0?{}:{inodeGroup:y}}});return{kind:mr,url:r,mountPrefix:i,...s===void 0?{}:{integrity:s},materialized:!1,entries:c}}function $s(n,e){let t=Ze(n,["kind","content","inventory","activation","url","mountPrefix","integrity","materialized","entries"],"Serialized lazy tree");if(t.kind!==e)throw new Error("Serialized lazy tree has an unsupported kind");let r=Bs(t.content,t.inventory,t.mountPrefix,t.activation);if(e!==ct&&e===_r!=(r.content.source===void 0))throw new Error(e===_r?"Serialized deferred-tree-v1 cannot contain original-bottle source metadata":"Serialized deferred-tree-v2 requires original-bottle source metadata");let i=r.activation.atomicGroup;if(e===ct?i===void 0||!Lt(i):i!==void 0)throw new Error(e===ct?"Serialized deferred-tree-v3 requires a sealed atomic activation":"Atomic activation requires serialized deferred-tree-v3");let s=ye(t.url,"Serialized lazy tree URL",Ei);if(s!==r.content.transports[0])throw new Error("Serialized lazy tree URL differs from its primary transport");let o=gr(t.integrity);if(!o||o.sha256!==r.content.sha256||o.bytes!==r.content.bytes)throw new Error("Serialized lazy tree integrity differs from its content");if(t.materialized!==!1)throw new Error("Serialized lazy tree must describe pending content");let c=new Map(r.entries.map(p=>[p.vfsPath,p])),a=new Map(r.entries.map(p=>[tn(p),p])),u=Ne(t.entries,"Serialized lazy tree entries",0,bt),l=new Set,d=u.map((p,m)=>{let f=_i(p,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup"],`Serialized lazy tree entry ${m}`),h=ge(f.vfsPath,!0,`Serialized lazy tree entry ${m} VFS path`);if(l.has(h))throw new Error(`Serialized lazy tree duplicates pending path ${h}`);l.add(h);let g=ge(f.sourcePath,!1,`Serialized lazy tree entry ${m} source path`),_=ge(f.archivePath,!1,`Serialized lazy tree entry ${m} archive path`),y=c.get(h),E=a.get(tn({sourcePath:g,type:typeof f.type=="string"?f.type:void 0,inodeGroup:typeof f.inodeGroup=="string"?f.inodeGroup:void 0,target:typeof f.target=="string"?f.target:void 0}))??y;if(!E||E.type!=="file"&&E.type!=="hardlink"||y?.inodeGroup!==void 0&&y.inodeGroup!==E.inodeGroup)throw new Error(`Serialized lazy tree entry ${h} is absent from its inventory`);let O=r.canonicalByGroup.get(E.inodeGroup);if(f.type!==E.type||f.inodeGroup!==E.inodeGroup||f.size!==E.size||_!==O?.sourcePath||f.target!==E.target||f.isSymlink!==!1||f.deleted!==!1||f.materialized!==!1)throw new Error(`Serialized lazy tree entry ${h} disagrees with its inventory`);let w=ae(f.ino,`Serialized lazy tree entry ${h} inode`,1,Number.MAX_SAFE_INTEGER),S=ae(f.generation,`Serialized lazy tree entry ${h} generation`,0,Number.MAX_SAFE_INTEGER),A=ae(f.dataSequence,`Serialized lazy tree entry ${h} data sequence`,0,Number.MAX_SAFE_INTEGER);return{vfsPath:h,ino:w,generation:S,dataSequence:A,size:E.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:_,sourcePath:g,type:E.type,inodeGroup:E.inodeGroup,...E.target===void 0?{}:{target:E.target}}});for(let p of r.entries)if(r.activation.atomicGroup!==void 0&&(p.type==="file"||p.type==="hardlink")&&!l.has(p.vfsPath))throw new Error(`Serialized lazy tree omits pending path ${p.vfsPath}`);return{kind:e,content:r.content,inventory:r.entries,activation:r.activation,url:s,mountPrefix:r.mountPrefix,integrity:o,materialized:!1,entries:d}}async function pr(n,e){let t=globalThis.crypto?.subtle;if(!t)throw new Error(`${e} SHA-256 verification is unavailable`);let r=new Uint8Array(n.byteLength);r.set(n);let i=new Uint8Array(await t.digest("SHA-256",r));return Array.from(i,s=>s.toString(16).padStart(2,"0")).join("")}async function li(n,e,t){if(t===void 0)return;if(n.byteLength!==t.bytes)throw new Error(`Lazy ${e} byte count ${n.byteLength} does not match expected ${t.bytes}`);let r=await pr(n,`Lazy ${e}`);if(r!==t.sha256)throw new Error(`Lazy ${e} SHA-256 ${r} does not match expected ${t.sha256}`)}function Xu(n,e,t,r){let i=r.atomicGroup;if(i===void 0)throw new Error("Lazy atomic member is missing its typed tree descriptor");let s={schema:1,content:{decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...n.source===void 0?{}:{source:n.source}},mountPrefix:t,inventory:[...e].sort((o,c)=>o.vfsPathc.vfsPath?1:0),activation:{mode:r.mode,capabilities:r.capabilities,roots:r.roots,atomicGroup:{id:i.id,member:i.member}}};return new TextEncoder().encode(JSON.stringify(s))}function Rs(n,e){let t=e.map(({member:r,descriptorSha256:i})=>({member:r,descriptorSha256:i}));return new TextEncoder().encode(JSON.stringify({schema:1,id:n,members:t.sort((r,i)=>r.memberi.member?1:0)}))}function Yu(n,e){if(n.byteLength!==e.byteLength)return!1;for(let t=0;tObject.freeze({...i}))};return r!==void 0&&(Object.freeze(r.entries),Object.freeze(r)),Object.freeze({decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,transports:t,...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...r===void 0?{}:{source:r}})}function xs(n){return{decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,transports:[...n.transports],...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...n.source===void 0?{}:{source:{schema:1,kind:"homebrew-bottle-tar-gzip-v1",entries:n.source.entries.map(e=>({...e}))}}}}function ju(n){let e=n.map(t=>Object.freeze({...t}));return Object.freeze(e),e}function Ju(n,e,t){let r=[...n.capabilities],i=[...n.roots];return Object.freeze(r),Object.freeze(i),Object.freeze({mode:n.mode,capabilities:r,roots:i,atomicGroup:Object.freeze({id:e,member:t})})}function Qu(n){return{mode:n.activation.mode,capabilities:[...n.activation.capabilities],roots:[...n.activation.roots],atomicGroup:{id:n.id,member:n.member,descriptorSha256:n.descriptorSha256,expectedCount:n.expectedCount,cohortSha256:n.cohortSha256}}}function ed(n,e){return n.ino===e.ino&&n.generation===e.generation&&n.dataSequence===e.dataSequence&&n.size===e.size&&n.isSymlink===e.isSymlink&&n.deleted===e.deleted&&n.materialized===e.materialized&&n.archivePath===e.archivePath&&n.sourcePath===e.sourcePath&&n.type===e.type&&n.inodeGroup===e.inodeGroup&&n.target===e.target}function Zr(n,e,t){let r=n.content,i=n.inventory,s=n.activation,o=n.integrity,c=n.entries,a=n.url,u=n.mountPrefix,l=n.materialized,d=s?.atomicGroup;if(r===void 0||i===void 0||s===void 0||d===void 0||s.mode!=="first-use"||d.id!==e||d.member!==t||l)throw new Error(`Lazy atomic activation member ${t} changed before snapshot`);if(o?.sha256!==r.sha256||o?.bytes!==r.bytes||a!==(r.transports[0]??""))throw new Error(`Lazy atomic activation member ${t} has inconsistent integrity`);let p=Us(r),m=ju(i),f=Ju(s,e,t),h=new Map;for(let O of m)O.type==="file"&&h.set(O.inodeGroup,O.sourcePath);let g=m.filter(O=>O.type!=="directory");if(c.size!==g.length)throw new Error(`Lazy atomic activation member ${t} has inconsistent runtime entries`);let _=g.map(O=>{let w=c.get(O.vfsPath),S=O.type==="symlink",A=S?O.sourcePath:h.get(O.inodeGroup),R=w!==void 0&&(w.sourcePath===O.sourcePath&&w.type===O.type&&w.target===O.target||O.type==="hardlink"&&w.sourcePath===A&&w.type==="file"&&w.target===void 0),x=w===void 0?["missing"]:[A===void 0?"archivePath source":void 0,w.generation===void 0?"generation":void 0,w.dataSequence===void 0?"dataSequence":void 0,w.size!==O.size?"size":void 0,w.isSymlink!==S?"symlink kind":void 0,w.deleted?"deletion state":void 0,w.materialized!==S?"materialization state":void 0,w.archivePath!==A?"archivePath":void 0,R?void 0:"descriptor mapping",w.inodeGroup!==O.inodeGroup?"inode group":void 0].filter(L=>L!==void 0);if(x.length>0)throw new Error(`Lazy atomic activation member ${t} has inconsistent mapping at ${O.vfsPath}: ${x.join(", ")}`);let v=w;return Object.freeze({vfsPath:O.vfsPath,ino:v.ino,generation:v.generation,dataSequence:v.dataSequence,size:v.size,isSymlink:v.isSymlink,deleted:!1,materialized:v.materialized,archivePath:A,sourcePath:O.sourcePath,type:O.type,...O.inodeGroup===void 0?{}:{inodeGroup:O.inodeGroup},...O.target===void 0?{}:{target:O.target}})});Object.freeze(_);let y=Object.freeze({sha256:p.sha256,bytes:p.bytes}),E=Xu(p,m,u,f);return Object.freeze({id:e,member:t,descriptorBytes:E,content:p,inventory:m,activation:f,url:p.transports[0]??"",mountPrefix:u,integrity:y,entries:_})}function Ts(n,e,t,r){return Object.freeze({...n,descriptorSha256:e,expectedCount:t,cohortSha256:r})}function vs(n,e){return n.id!==e.id||n.member!==e.member||n.url!==e.url||n.mountPrefix!==e.mountPrefix||n.integrity.sha256!==e.integrity.sha256||n.integrity.bytes!==e.integrity.bytes||n.content.transports.length!==e.content.transports.length||n.content.transports.some((t,r)=>t!==e.content.transports[r])||!Yu(n.descriptorBytes,e.descriptorBytes)||n.entries.length!==e.entries.length?!1:n.entries.every((t,r)=>{let i=e.entries[r];return i!==void 0&&t.vfsPath===i.vfsPath&&ed(t,i)})}function td(n,e){let t=Us(n.content,n.content.transports.map(e));return Object.freeze({...n,content:t,url:t.transports[0]??""})}function Ls(n){return{paths:Array.from(n.paths),expectedIno:n.ino,expectedGeneration:n.generation,expectedDataSequence:n.dataSequence,data:n.content}}var rn=class n{fs;imageMetadata;lazyFiles=new Map;lazyArchiveGroups=[];deferredTreeMaterializationHandles=new WeakMap;lazyArchiveInodes=new Map;lazyAtomicGroups=new Map;lazyAtomicGroupByTree=new WeakMap;sealedLazyAtomicStates=new WeakMap;lazyDownloadListeners=new Set;lazyPreparations=new Map;lazyTransport={fetcher:(e,t)=>t===void 0?globalThis.fetch(e):globalThis.fetch(e,t)};constructor(e,t=null){this.fs=e,this.imageMetadata=t}static inodeKey(e,t){return`${e}:${t}`}static canAdoptLegacyLazyStub(e){return(e.mode&Fe)===hr&&e.size===0&&e.dataSequence<=1}reconcileLazyIdentityState(e){for(let[t,r]of this.lazyFiles){let i=e.get(t);if(!i||i.dataSequence!==r.dataSequence||i.paths.length===0){this.lazyFiles.delete(t);continue}r.paths=new Set(i.paths),r.paths.has(r.path)||(r.path=i.paths[0])}this.lazyArchiveInodes.clear();for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(!r?.committed)for(let a of i.snapshot.entries){if(a.isSymlink||a.materialized||a.generation===void 0)continue;let u=n.inodeKey(a.ino,a.generation),l=e.get(u);l!==void 0&&l.dataSequence===a.dataSequence&&l.paths.length>0&&this.lazyArchiveInodes.set(u,t)}continue}let s=t.content!==void 0&&t.inventory!==void 0&&!t.materialized,o=new Map;for(let a of t.entries.values()){if(a.deleted||a.materialized||a.generation===void 0)continue;let u=n.inodeKey(a.ino,a.generation);o.has(u)||o.set(u,a)}let c=new Map(Array.from(t.entries.entries()).filter(([,a])=>a.deleted||a.isSymlink&&!a.deleted));for(let[a,u]of o){let l=e.get(a);if(!(!l||l.dataSequence!==(u.dataSequence??0))){for(let d of l.paths)c.set(d,{...u,ino:l.ino,generation:l.generation,dataSequence:l.dataSequence,deleted:!1,materialized:!1});l.paths.length>0&&this.lazyArchiveInodes.set(a,t)}}t.entries=c,t.materialized=!Array.from(c.values()).some(a=>!a.isSymlink&&!a.materialized)&&!s&&(r===void 0||r.committed)}}validatePendingLazyTreeNamespaceState(e){let t=new Map;for(let r of e.values())for(let i of r.paths){if(t.has(i))throw new Error(`SharedFS namespace identity is ambiguous at ${i}`);t.set(i,r)}for(let r of this.lazyArchiveGroups){let i=this.lazyAtomicGroupByTree.get(r),o=this.sealedLazyAtomicStates.get(r)?.snapshot;if(i?.committed||o===void 0&&r.materialized||o===void 0&&(r.content===void 0||r.inventory===void 0))continue;let c=o?.inventory??r.inventory,a=o===void 0?r.entries:new Map(o.entries.map(m=>[m.vfsPath,m])),u=new Map,l=new Map,d=new Set;for(let m of a.values())m.deleted&&m.inodeGroup!==void 0&&d.add(m.inodeGroup);for(let m of c){if(m.type!=="file"&&m.type!=="hardlink")continue;u.set(m.inodeGroup,(u.get(m.inodeGroup)??0)+1);let f=l.get(m.inodeGroup)??[];f.push(m.vfsPath),l.set(m.inodeGroup,f)}let p=new Set([...d].filter(m=>l.get(m)?.every(f=>!t.has(f))));for(let m of c){let f=t.get(m.vfsPath);if(f===void 0){if(m.inodeGroup!==void 0&&p.has(m.inodeGroup))continue;throw new Error(`Lazy tree namespace entry ${m.vfsPath} is missing from the captured filesystem state`)}let h=m.type==="directory"?at:m.type==="symlink"?Vr:hr;if((f.mode&Fe)!==h||(f.mode&W.S_MODE_BITS)!==m.mode)throw new Error(`Lazy tree namespace entry ${m.vfsPath} disagrees with its captured type or mode`);if(m.type==="directory")continue;let g=a.get(m.vfsPath);if(g===void 0||g.ino!==f.ino||g.generation!==f.generation||g.dataSequence!==f.dataSequence)throw new Error(`Lazy tree namespace entry ${m.vfsPath} changed identity before serialization`);if(m.type==="symlink"){let _=new TextEncoder().encode(m.target).byteLength;if(f.linkCount!==1||f.size!==m.size||f.size!==_||f.symlinkTarget!==m.target)throw new Error(`Lazy tree symlink ${m.vfsPath} disagrees with its captured inventory`);continue}if(f.size!==0||f.linkCount!==u.get(m.inodeGroup))throw new Error(`Lazy tree stub ${m.vfsPath} has changed data or undeclared aliases`)}}}lazyFileForStat(e){let t=n.inodeKey(e.ino,e.generation),r=this.lazyFiles.get(t);if(r&&r.dataSequence!==e.dataSequence){this.lazyFiles.delete(t);return}return r}lazyArchiveEntriesForRead(e){let t=this.lazyAtomicGroupByTree.get(e),r=this.sealedLazyAtomicStates.get(e);return r!==void 0&&!t?.committed?r.snapshot.entries:Array.from(e.entries,([i,s])=>({vfsPath:i,...s}))}lazyArchiveForStat(e){let t=n.inodeKey(e.ino,e.generation),r=this.lazyArchiveInodes.get(t);if(!r)return;let i=this.lazyArchiveEntriesForRead(r).filter(o=>o.ino===e.ino&&o.generation===e.generation&&!o.deleted&&!o.materialized);if(i.some(o=>o.dataSequence===e.dataSequence))return r;if(this.lazyArchiveInodes.delete(t),this.sealedLazyAtomicStates.get(r)===void 0)for(let o of i)o.materialized=!0}lazyBackingForStat(e){let t=n.inodeKey(e.ino,e.generation),r=this.lazyFiles.get(t);if(r)return{token:r,path:r.path};let i=this.lazyArchiveInodes.get(t);if(!i)return null;let s=this.lazyArchiveEntriesForRead(i).find(c=>c.ino===e.ino&&c.generation===e.generation&&!c.deleted&&!c.materialized)?.vfsPath;if(s===void 0)return null;let o=this.lazyAtomicGroupByTree.get(i);return o===void 0?{token:i,path:s}:{token:o.token,path:s,atomicGroup:o}}lazyBackingForPath(e){let t=this.lazyArchiveGroups.find(r=>{let i=this.lazyAtomicGroupByTree.get(r),s=this.sealedLazyAtomicStates.get(r)?.snapshot,o=s===void 0?!r.materialized:!i?.committed,c=s?.content??r.content,a=s?.inventory??r.inventory,u=s?.activation??r.activation,l=s?.entries??Array.from(r.entries.values());return o&&c!==void 0&&a!==void 0&&u!==void 0&&l.every(d=>d.deleted||d.materialized||d.isSymlink)&&u.roots.some(d=>d==="/"||e===d||e.startsWith(`${d}/`))});if(t){let r=this.lazyAtomicGroupByTree.get(t);return{token:r?.token??t,path:e,directGroup:t,...r===void 0?{}:{atomicGroup:r}}}try{let r=this.fs.stat(e),i=this.lazyBackingForStat(r);return i?{...i,path:e}:null}catch{return null}}startLazyPreparation(e){let{path:t,token:r}=e,i={status:"pending",promise:Promise.resolve(!1)},s=e.atomicGroup?this.ensureAtomicLazyGroupMaterialized(e.atomicGroup).then(()=>!0):e.directGroup?this.ensureArchiveMaterialized(e.directGroup).then(()=>!0):this.materializePath(t);return i.promise=s.then(o=>(i.status="fulfilled",this.lazyPreparations.get(r)===i&&this.lazyPreparations.delete(r),o),o=>{throw i.status="rejected",i.error=o,o}),i.promise.catch(()=>{}),this.lazyPreparations.set(r,i),i}registerLazyAtomicGroupMembership(e,t=!1){let r=e.activation?.atomicGroup;if(r===void 0)return;let{id:i,member:s}=r;if(e.content===void 0||e.inventory===void 0||e.activation?.mode!=="first-use")throw new Error(`Lazy atomic activation group ${i} accepts only typed first-use trees`);let o=this.lazyAtomicGroups.get(i);if(o===void 0)o={id:i,token:Object.freeze({id:i}),groups:new Map,committed:!1},this.lazyAtomicGroups.set(i,o);else if(o.committed)throw new Error(`Lazy atomic activation group ${i} is already materialized`);if(o.groups.has(s))throw new Error(`Lazy atomic activation group ${i} duplicates member ${s}`);if(Lt(r)){if(o.expectedCount!==void 0&&(o.expectedCount!==r.expectedCount||o.cohortSha256!==r.cohortSha256))throw new Error(`Lazy atomic activation group ${i} has inconsistent seals`);o.expectedCount=r.expectedCount,o.cohortSha256=r.cohortSha256;let c=Zr(e,i,s);this.sealedLazyAtomicStates.set(e,{snapshot:Ts(c,r.descriptorSha256,r.expectedCount,r.cohortSha256),verified:t})}else if(o.expectedCount!==void 0)throw new Error(`Lazy atomic activation group ${i} mixes sealed and unsealed members`);o.groups.set(s,e),this.lazyAtomicGroupByTree.set(e,o)}async sealLazyAtomicGroup(e,t){let r=t.map(u=>Ks({id:e,member:u}).member).sort();if(r.length===0||new Set(r).size!==r.length)throw new Error(`Lazy atomic activation group ${e} expected members are invalid`);let i=this.lazyAtomicGroups.get(e);if(i===void 0||i.committed)throw new Error(`Lazy atomic activation group ${e} is not pending`);let s=[...i.groups.keys()].sort();if(JSON.stringify(s)!==JSON.stringify(r))throw new Error(`Lazy atomic activation group ${e} members differ from its seal`);if(i.expectedCount!==void 0){if(i.expectedCount!==r.length||i.cohortSha256===void 0)throw new Error(`Lazy atomic activation group ${e} has an invalid existing seal`);await this.ensureLazyAtomicGroupSealValidated(i,r.map(u=>i.groups.get(u)),!0);return}let o=r.map(u=>Zr(i.groups.get(u),e,u)),c=[];for(let u of o)c.push({member:u.member,descriptorSha256:await pr(u.descriptorBytes,`Lazy atomic member ${u.member}`),source:u});let a=await pr(Rs(e,c),`Lazy atomic activation group ${e}`);for(let u of c){let l=i.groups.get(u.member),d=Zr(l,e,u.member);if(!vs(u.source,d))throw new Error(`Lazy atomic activation member ${u.member} changed while sealing`)}for(let u of c){let l=i.groups.get(u.member);l.activation.atomicGroup={id:e,member:u.member,descriptorSha256:u.descriptorSha256,expectedCount:c.length,cohortSha256:a},this.sealedLazyAtomicStates.set(l,{snapshot:Ts(u.source,u.descriptorSha256,c.length,a),verified:!0})}i.expectedCount=c.length,i.cohortSha256=a}async verifyImportedLazyAtomicGroupSeals(){await this.validatePendingLazyAtomicGroupSeals(!0)}guardSynchronousLazyAccess(e){let t=this.lazyBackingForPath(e);if(!t)return;let r=this.lazyPreparations.get(t.token);if(r?.status==="fulfilled"){this.lazyPreparations.delete(t.token);let s=this.lazyBackingForPath(e);if(!s)return;r=this.lazyPreparations.get(s.token)??this.startLazyPreparation(s)}else if(r?.status==="rejected"){this.lazyPreparations.delete(t.token);let s=r.error instanceof Error?r.error.message:String(r.error),o=new Error(`EIO: lazy backing for ${e} failed: ${s}`);throw o.code="EIO",o.cause=r.error,o}else r||(r=this.startLazyPreparation(t));let i=new Error(`EAGAIN: lazy backing for ${e} is being prepared`);throw i.code="EAGAIN",i}invalidateLazyData(e){let t=n.inodeKey(e.ino,e.generation);this.lazyFiles.delete(t);let r=this.lazyArchiveInodes.get(t);if(r){this.lazyArchiveInodes.delete(t);for(let i of r.entries.values())i.ino===e.ino&&i.generation===e.generation&&(i.materialized=!0)}}rewriteLazyNamespacePaths(e,t,r){let i=t.length>1?t.replace(/\/+$/,""):t,s=r.length>1?r.replace(/\/+$/,""):r,o=`${i}/`,c=`${s}/`,a=n.inodeKey(e.ino,e.generation),u=(e.mode&Fe)===at,l=d=>d===i?s:u&&d.startsWith(o)?c+d.slice(o.length):d;for(let[d,p]of this.lazyFiles)!u&&d!==a||(p.paths=new Set(Array.from(p.paths,l)),p.path=l(p.path));for(let d of this.lazyArchiveGroups){let p=new Map;for(let[m,f]of d.entries){let h=f.generation===void 0?null:n.inodeKey(f.ino,f.generation);p.set(u||h===a?l(m):m,f)}d.entries=p,d.inventory&&(d.inventory=d.inventory.map(m=>({...m,vfsPath:l(m.vfsPath),...m.type==="hardlink"&&m.target!==void 0?{target:l(m.target)}:{}}))),d.activation&&(d.activation={...d.activation,roots:d.activation.roots.map(l)})}}get sharedBuffer(){return this.fs.buffer}static create(e,t){return new n(be.mkfs(e,t))}static fromExisting(e){return new n(be.mount(e))}rebaseToNewFileSystem(e){if(!Number.isSafeInteger(e)||e<=0)throw new Error(`Invalid MemoryFileSystem maxByteLength: ${e}`);let t=SharedArrayBuffer,{bytes:r,identities:i}=this.fs.snapshotState();this.reconcileLazyIdentityState(i);let s=this.serializeLazyEntries(),o=this.serializeValidatedLazyArchiveEntries(i),c=new t(r.byteLength);new Uint8Array(c).set(r);let a=new n(be.mount(c,{restoreImage:!0}),this.imageMetadata);a.importLazyEntries(s),a.importLazyArchiveEntriesInternal(o,!1,!0,"verified");let u=Math.min(e,Math.max(r.byteLength,Au)),l=new t(u,{maxByteLength:e}),d=n.create(l,e);d.setImageMetadata(this.imageMetadata);let p=new Set(s.flatMap(f=>f.paths??[f.path])),m=new Set;for(let f of o)if(!f.materialized)for(let h of f.entries)!h.deleted&&!h.isSymlink&&m.add(h.vfsPath);return a.copyPathToFreshFileSystem("/",d,p,m,new Map),d.importLazyEntries(s.map(f=>{let h=d.fs.lstat(f.path);return{...f,ino:h.ino,generation:h.generation,dataSequence:h.dataSequence}})),d.importLazyArchiveEntriesInternal(o.map(f=>({...f,entries:f.entries.map(h=>{if(h.deleted)return{...h,ino:0,generation:void 0};let g=d.fs.lstat(h.vfsPath);return{...h,ino:g.ino,generation:g.generation,dataSequence:g.dataSequence}})})),!1,!0,"verified"),d}getImageMetadata(){return Pu(this.imageMetadata)}setImageMetadata(e){this.imageMetadata=e===null?null:Si(e)}subscribeLazyDownloads(e){return this.lazyDownloadListeners.add(e),()=>this.lazyDownloadListeners.delete(e)}setLazyFetcher(e,t={}){this.lazyTransport={fetcher:e,...t.signal===void 0?{}:{signal:t.signal}}}emitLazyDownload(e){if(this.lazyDownloadListeners.size===0)return;let t={...e,t:Cu()};for(let r of this.lazyDownloadListeners)try{r(t)}catch{}}async fetchLazyBytes(e,t){let r=0,i=e.integrity?.bytes??e.fallbackTotalBytes,s={id:e.id,kind:e.kind,url:e.url,path:e.path,mountPrefix:e.mountPrefix};for(let o=0;oe.integrity.bytes)throw new Error(`Lazy ${e.kind} exceeded expected byte count ${e.integrity.bytes}`);this.emitLazyDownload({...s,status:"progress",loadedBytes:r,totalBytes:i})}}}catch(d){try{await a.cancel(d)}catch{}throw d}}finally{a.releaseLock()}let l=Gu(u,r);return te(t.signal),await li(l,e.kind,e.integrity),te(t.signal),this.emitLazyDownload({...s,status:"complete",loadedBytes:r,totalBytes:i??r}),l}catch(c){if(t.signal?.aborted){let l=t.signal.reason,d=l instanceof Error?l.message:String(l);throw this.emitLazyDownload({...s,status:"error",loadedBytes:r,totalBytes:i,error:d}),l}let a=o+1({...y})),activation:d,entries:new Map},g=y=>{let E=y.split("/").filter(Boolean),O="";for(let w=0;wE.vfsPath.split("/").length-O.vfsPath.split("/").length))if(y.type==="directory"){g(y.vfsPath);try{this.fs.mkdir(y.vfsPath,y.mode),this.fs.chmod(y.vfsPath,y.mode)}catch{if((this.fs.lstat(y.vfsPath).mode&Fe)!==at)throw new Error(`Lazy tree directory collides at ${y.vfsPath}`)}}for(let y of u){if(y.type!=="symlink")continue;g(y.vfsPath),this.fs.symlink(y.target,y.vfsPath);let E=this.fs.lstat(y.vfsPath);h.entries.set(y.vfsPath,{ino:E.ino,generation:E.generation,dataSequence:E.dataSequence,size:y.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:y.sourcePath,sourcePath:y.sourcePath,type:"symlink",target:y.target})}let _=new Map;for(let y of u){if(y.type!=="file")continue;g(y.vfsPath);let E=this.fs.createLazyStub(y.vfsPath,y.mode);this.invalidateLazyData(E),_.set(y.inodeGroup,E);let O={ino:E.ino,generation:E.generation,dataSequence:E.dataSequence,size:y.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:y.sourcePath,sourcePath:y.sourcePath,type:"file",inodeGroup:y.inodeGroup};h.entries.set(y.vfsPath,O)}for(let y of u){if(y.type!=="hardlink")continue;let E=p.get(y.inodeGroup);g(y.vfsPath),this.fs.link(E.vfsPath,y.vfsPath);let O=this.fs.lstat(y.vfsPath),w=_.get(y.inodeGroup);if(O.ino!==w.ino||O.generation!==w.generation)throw new Error(`Lazy tree hardlink ${y.vfsPath} did not share its inode`);h.entries.set(y.vfsPath,{ino:O.ino,generation:O.generation,dataSequence:O.dataSequence,size:y.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:E.sourcePath,sourcePath:y.sourcePath,type:"hardlink",inodeGroup:y.inodeGroup,target:y.target})}if(m!==void 0)for(let y of u)this.lchown(y.vfsPath,m.uid,m.gid);for(let y of h.entries.values())y.isSymlink||y.generation===void 0||this.lazyArchiveInodes.set(n.inodeKey(y.ino,y.generation),h);return this.lazyArchiveGroups.push(h),this.registerLazyAtomicGroupMembership(h),h}registerLazyTreeWithMaterializationHandle(e,t,r="/",i,s){let o=this.registerLazyTreeInternal(e,t,r,i,!0,s),c=Object.freeze({[Su]:!0});return this.deferredTreeMaterializationHandles.set(c,o),c}registerLazyArchiveFromEntries(e,t,r,i,s){let o=yr(r),c=bu(e,t,o,i);c.some(({entry:u})=>!u.isDirectory&&!u.isSymlink)&&this.assertCanRegisterPendingLazyArchiveGroup();let a={...s?{content:en({decoder:"zip-v1",mediaType:"application/zip",sha256:s.sha256,bytes:s.bytes,expandedBytes:c.reduce((u,l)=>u+l.entry.uncompressedSize,0),sourceEntryCount:c.length,transports:[e]})}:{},url:e,mountPrefix:o,integrity:gr(s),materialized:!1,entries:new Map};for(let{entry:u,vfsPath:l}of c){if(u.isDirectory)continue;let d=l.split("/").filter(Boolean),p="";for(let m=0;mu.deleted||u.materialized),this.lazyArchiveGroups.push(a),a}importLazyArchiveEntries(e){this.importLazyArchiveEntriesInternal(e,!1,!0,"reject")}async importVerifiedLazyArchiveEntries(e){let t=structuredClone(e),r=this.exportLazyArchiveEntries(),i=n.fromExisting(this.sharedBuffer);i.importLazyArchiveEntriesInternal([...r,...t],!1,!0,"pending"),await i.verifyImportedLazyAtomicGroupSeals(),this.importLazyArchiveEntriesInternal(t,!1,!0,"verified")}importLazyArchiveEntriesInternal(e,t,r,i){let s=Ne(e,"Serialized lazy archive groups",0,zs).map((l,d)=>{if(typeof l!="object"||l===null||Array.isArray(l))throw new Error(`Serialized lazy archive group ${d} must be an object`);let p=l.kind;if(p===_r||p===mi||p===ct)return $s(l,p);if(p===mr)return gi(l,!1);if(p!==void 0)throw new Error(`Serialized lazy archive group ${d} has an unsupported kind`);if(r)throw new Error(`Serialized lazy archive group ${d} is missing its kind discriminator`);return gi(l,!0)}),o=this.fs.identityState();this.reconcileLazyIdentityState(o);let c=[...this.serializeValidatedLazyArchiveEntries(o),...s];Is(c);let a=[],u=new Map;for(let l of s){let d=new Map,p=l.mountPrefix.replace(/\/+$/,""),m=l.content!==void 0&&l.inventory!==void 0&&l.activation!==void 0,f=m?new Map(l.inventory.map(w=>[w.vfsPath,w])):null,h=m?new Map(l.inventory.map(w=>[tn(w),w])):null,g=new Map,_=new Map,y=new Map;for(let w of l.entries){let S=null,A=l.materialized||w.materialized===!0||w.isSymlink;if(!w.deleted&&!A){if((w.generation===void 0||w.dataSequence===void 0)&&!t)throw new Error("Live lazy-archive metadata requires inode generation and data sequence");try{S=this.fs.lstat(w.vfsPath)}catch{if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} is missing from the filesystem`);continue}if(S.ino!==w.ino){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} has a different inode`);continue}if(w.generation!==void 0&&S.generation!==w.generation){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} has a different generation`);continue}if(w.dataSequence===void 0){if(!n.canAdoptLegacyLazyStub(S)){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} is not pristine`);continue}}else if(S.dataSequence!==w.dataSequence){if(m)throw new Error(`Serialized lazy tree stub ${w.vfsPath} has a different data sequence`);continue}if(m){y.set(w.vfsPath,S);let x=f.get(w.vfsPath),v=h.get(tn(w))??x;if(!v||(S.mode&Fe)!==hr||S.size!==0||(S.mode&W.S_MODE_BITS)!==v.mode||x?.inodeGroup!==void 0&&x.inodeGroup!==v.inodeGroup)throw new Error(`Serialized lazy tree stub ${w.vfsPath} disagrees with its inventory`);let L=n.inodeKey(S.ino,S.generation),D=w.inodeGroup,Z=g.get(D),N=_.get(L);if(Z!==void 0&&Z!==L||N!==void 0&&N!==D)throw new Error(`Serialized lazy tree inode group ${D} disagrees with the filesystem`);g.set(D,L),_.set(L,D)}}d.set(w.vfsPath,{ino:w.ino,generation:S?.generation??w.generation,dataSequence:S?.dataSequence??w.dataSequence,size:w.size,isSymlink:w.isSymlink,deleted:w.deleted,materialized:A,archivePath:w.archivePath??w.vfsPath.slice(p.length+1),sourcePath:w.sourcePath??w.archivePath??w.vfsPath.slice(p.length+1),type:w.type??(w.isSymlink?"symlink":"file"),inodeGroup:w.inodeGroup,target:w.target})}if(m){let w=new Map;for(let S of l.inventory){if(S.type==="file"||S.type==="hardlink"){w.set(S.inodeGroup,(w.get(S.inodeGroup)??0)+1);continue}let A;try{A=this.fs.lstat(S.vfsPath)}catch{throw new Error(`Serialized lazy tree namespace entry ${S.vfsPath} is missing from the filesystem`)}let R=S.type==="directory"?at:Vr;if((A.mode&Fe)!==R||(A.mode&W.S_MODE_BITS)!==S.mode||S.type==="symlink"&&(A.size!==new TextEncoder().encode(S.target).byteLength||this.fs.readlink(S.vfsPath)!==S.target))throw new Error(`Serialized lazy tree namespace entry ${S.vfsPath} disagrees with its inventory`);S.type==="symlink"&&d.set(S.vfsPath,{ino:A.ino,generation:A.generation,dataSequence:A.dataSequence,size:S.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:S.sourcePath,sourcePath:S.sourcePath,type:"symlink",target:S.target})}if(l.activation?.atomicGroup!==void 0)for(let S of l.inventory){if(S.type!=="file"&&S.type!=="hardlink")continue;if(y.get(S.vfsPath).linkCount!==w.get(S.inodeGroup))throw new Error(`Serialized lazy atomic tree inode group ${S.inodeGroup} has undeclared aliases`)}}let E=l.content===void 0?void 0:en(l.content),O={content:E,url:E?.transports[0]??l.url,mountPrefix:l.mountPrefix,integrity:E?{sha256:E.sha256,bytes:E.bytes}:gr(l.integrity),materialized:l.materialized||!(E&&l.inventory)&&Array.from(d.values()).every(w=>w.deleted||w.materialized),inventory:l.inventory?.map(w=>({...w})),activation:l.activation?{mode:l.activation.mode,capabilities:[...l.activation.capabilities],roots:[...l.activation.roots],...l.activation.atomicGroup===void 0?{}:{atomicGroup:{...l.activation.atomicGroup}}}:void 0,entries:d};if(a.push(O),!O.materialized){for(let[,w]of d)if(!w.deleted&&!w.materialized&&w.generation!==void 0){let S=n.inodeKey(w.ino,w.generation),A=u.get(S);if(A!==void 0&&A!==O)throw new Error(`Serialized lazy archive groups share pending inode ${S}`);if(this.lazyArchiveInodes.has(S))throw new Error(`Serialized lazy archive group collides with pending inode ${S}`);u.set(S,O)}}}for(let l of a){let d=l.activation?.atomicGroup;if(d!==void 0&&this.lazyAtomicGroups.get(d.id)?.committed)throw new Error(`Lazy atomic activation group ${d.id} is already materialized`)}if(i==="reject"&&a.some(l=>{let d=l.activation?.atomicGroup;return d!==void 0&&Lt(d)}))throw new Error("Sealed lazy archive registrations require importVerifiedLazyArchiveEntries()");this.lazyArchiveGroups.push(...a);for(let l of a)this.registerLazyAtomicGroupMembership(l,i==="verified");for(let[l,d]of u)this.lazyArchiveInodes.set(l,d)}rewriteLazyArchiveUrls(e){for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0&&!r?.committed){this.assertLazyAtomicSnapshotMatchesPublic(t);let s=td(i.snapshot,e);t.content=xs(s.content),t.url=s.url,t.integrity={...s.integrity},i.snapshot=s;continue}t.content?(t.content={...t.content,transports:t.content.transports.map(e)},t.url=t.content.transports[0]):t.url=e(t.url)}}serializeLazyArchiveEntries(){let e=[];for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(r?.committed)continue;let a=i.snapshot;if(a.content.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");e.push({kind:ct,content:xs(a.content),inventory:a.inventory.map(u=>({...u})),activation:Qu(a),url:a.url,mountPrefix:a.mountPrefix,integrity:{...a.integrity},materialized:!1,entries:a.entries.filter(u=>!u.deleted&&!u.materialized).map(({vfsPath:u,...l})=>({vfsPath:u,...l}))});continue}let s=Array.from(t.entries,([a,u])=>({vfsPath:a,ino:u.ino,generation:u.generation,dataSequence:u.dataSequence,size:u.size,isSymlink:u.isSymlink,deleted:u.deleted,materialized:u.materialized,archivePath:u.archivePath,sourcePath:u.sourcePath,type:u.type,inodeGroup:u.inodeGroup,target:u.target})).filter(a=>!a.deleted&&!a.materialized);if(s.length===0&&!(t.content&&t.inventory&&!t.materialized))continue;let o=t.content!==void 0&&t.inventory!==void 0&&t.activation!==void 0;if(o&&t.content.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");let c=t.activation?.atomicGroup;if(c!==void 0&&!Lt(c))throw new Error(`Lazy atomic activation group ${c.id} must be sealed before serialization`);e.push(o?{kind:c!==void 0?ct:t.content.source===void 0?_r:mi,content:t.content,inventory:t.inventory,activation:t.activation,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:s}:{kind:mr,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:s})}return e}serializeValidatedLazyArchiveEntries(e){this.assertPendingLazyAtomicSnapshotsReadyForSerialization();let t=this.serializeLazyArchiveEntries();return Is(t),this.validatePendingLazyTreeNamespaceState(e),t}exportLazyArchiveEntries(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),this.serializeValidatedLazyArchiveEntries(e)}pendingDeferredTreeUsage(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),Ms(this.serializeValidatedLazyArchiveEntries(e))}assertCanAppendDeferredTreeUsage(e){yi(e);let t=this.pendingDeferredTreeUsage();yi({groups:t.groups+e.groups,archiveBytes:t.archiveBytes+e.archiveBytes,expandedBytes:t.expandedBytes+e.expandedBytes,payloadBytes:t.payloadBytes+e.payloadBytes,entries:t.entries+e.entries})}assertCanRegisterPendingLazyArchiveGroup(){if(this.reconcileLazyIdentityState(this.fs.identityState()),this.lazyArchiveGroups.filter(t=>{let r=this.lazyAtomicGroupByTree.get(t);return this.sealedLazyAtomicStates.get(t)?.snapshot!==void 0?!r?.committed:!t.materialized&&(t.content!==void 0&&t.inventory!==void 0||Array.from(t.entries.values()).some(s=>!s.deleted&&!s.materialized))}).length>=xe.maxGroups)throw new Error(`Cannot register another lazy archive group: ${xe.maxGroups} pending groups already exist`)}async preparePath(e){let t=!1,r=Math.max(3,this.lazyArchiveGroups.length+1);for(let i=0;i!s.materialized&&s.activation?.mode==="boot-prefetch"),t=0,r,i=Array.from({length:Math.min(e.length,Ru)},async()=>{for(;r===void 0;){let s=t;if(t+=1,s>=e.length)return;try{await this.prepareLazyTreeGroup(e[s])}catch(o){r??=o}}});if(await Promise.all(i),r!==void 0)throw r;return e.length}async materializeRegisteredDeferredTree(e,t){let r=this.deferredTreeMaterializationHandles.get(e);if(r===void 0)throw new Error("Deferred-tree handle was not issued by this filesystem");let i=this.lazyAtomicGroupByTree.get(r);if(i!==void 0)throw new Error(`Deferred tree belongs to atomic activation group ${i.id}; materialize the complete group instead`);if(r.materialized)return!1;let s=this.lazyPreparations.get(r);if(s!==void 0)return s.promise;let o=new Uint8Array(t.byteLength);o.set(t);let c={status:"pending",promise:Promise.resolve(!1)};c.promise=Promise.resolve().then(async()=>(await li(o,"tree",r.integrity),await this.materializeArchiveBytes(r,o),!0)).then(a=>(c.status="fulfilled",a),a=>{throw c.status="rejected",c.error=a,a}),c.promise.catch(()=>{}),this.lazyPreparations.set(r,c);try{return await c.promise}finally{this.lazyPreparations.get(r)===c&&this.lazyPreparations.delete(r)}}async prepareLazyTreeGroup(e){let t=this.lazyAtomicGroupByTree.get(e);if(t?.committed||t===void 0&&e.materialized)return!1;let r=this.sealedLazyAtomicStates.get(e)?.snapshot,i={token:t?.token??e,path:r?.activation.roots[0]??e.activation?.roots[0]??e.mountPrefix,directGroup:e,...t===void 0?{}:{atomicGroup:t}},s=this.lazyPreparations.get(i.token)??this.startLazyPreparation(i);try{return await s.promise}finally{this.lazyPreparations.get(i.token)===s&&this.lazyPreparations.delete(i.token)}}async ensureMaterialized(e){return this.preparePath(e)}async materializePath(e){if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0)return!1;let t;try{t=this.fs.stat(e)}catch{return!1}let r=n.inodeKey(t.ino,t.generation),i=this.lazyFiles.get(r);if(i){let o=this.lazyTransport,c=await this.fetchLazyBytes({id:`file:${t.ino}`,kind:"file",url:i.url,path:i.path,fallbackTotalBytes:i.size},o);for(let a=0;a<3;a++){if(this.lazyFiles.get(r)!==i)return!1;for(let u of new Set([e,...i.paths]))if(te(o.signal),this.fs.replaceIfIdentity(u,i.ino,i.generation,i.dataSequence,c))return i.path=u,this.lazyFiles.delete(r),!0;this.reconcileLazyIdentityState(this.fs.identityState())}throw new Error(`Lazy file kept changing names while materializing: ${e}`)}let s=this.lazyArchiveInodes.get(r);return s?(await this.ensureArchiveMaterialized(s,{path:e,ino:t.ino,generation:t.generation}),!this.lazyArchiveInodes.has(r)):!1}async decodeAndValidateLazyTree(e,t,r){let i=r?.content??e.content,s=r?.inventory??e.inventory;if(!i||!s)throw new Error("Lazy tree is missing its decoder or complete inventory");let o=new Map,c=new Map(s.map(p=>[p.vfsPath,p]));if(i.source!==void 0)for(let p of i.source.entries)o.set(p.sourcePath,p);else for(let p of s){if(p.type==="hardlink"){let f=c.get(p.target);if(!f)throw new Error(`Lazy tree hardlink target disappeared: ${p.target}`);if(p.sourcePath===f.sourcePath)continue}if(o.get(p.sourcePath))throw new Error(`Lazy tree inventory duplicates source member ${p.sourcePath}`);o.set(p.sourcePath,{sourcePath:p.sourcePath,type:p.type,mode:p.mode,size:p.size,...p.type==="symlink"?{target:p.target}:{},...p.type==="hardlink"?{target:c.get(p.target)?.sourcePath}:{}})}let a=new Map,u=0;if(i.decoder==="zip-v1"){let{parseZipCentralDirectory:p,extractZipEntryBounded:m}=await Promise.resolve().then(()=>(ti(),ei)),f=p(t);if(f.length!==i.sourceEntryCount||f.length!==o.size)throw new Error("Lazy ZIP tree decoded inventory counts differ from its descriptor");for(let h of f){let g=h.isDirectory?h.fileName.replace(/\/$/,""):h.fileName;if(a.has(g))throw new Error(`Lazy ZIP tree duplicates source member ${g}`);let _=o.get(g);if(!_)throw new Error(`Lazy ZIP tree has undeclared source member ${g}`);if(u+=h.uncompressedSize,u>i.expandedBytes||h.uncompressedSize!==_.size)throw new Error(`Lazy ZIP tree member ${g} exceeds its inventory`);let y=h.isDirectory?"directory":h.isSymlink?"symlink":"file",E=i.modePolicy==="portable-posix-v1"?y==="directory"?493:y==="symlink"?511:(h.mode&73)!==0?493:420:h.mode&W.S_MODE_BITS;if(y!==_.type||E!==_.mode)throw new Error(`Lazy ZIP tree member ${g} differs from inventory`);if(h.isDirectory)a.set(g,{type:"directory",mode:E});else{let O=m(t,h,_.size);if(h.isSymlink){let w;try{w=new TextDecoder("utf-8",{fatal:!0}).decode(O)}catch{throw new Error(`Lazy ZIP tree symlink ${g} is not UTF-8`)}a.set(g,{type:"symlink",mode:E,target:w})}else a.set(g,{type:"file",mode:E,data:O})}}}else{let{parseTarGzip:p}=await Promise.resolve().then(()=>(_s(),ms)),m=p(t,{label:`Lazy tree ${i.sha256}`,limits:{maxCompressedBytes:i.bytes,maxUncompressedBytes:i.expandedBytes,maxEntries:i.sourceEntryCount}});u=new DataView(t.buffer,t.byteOffset,t.byteLength).getUint32(t.byteLength-4,!0);for(let f of m){if(a.has(f.path))throw new Error(`Lazy TAR tree duplicates source member ${f.path}`);f.type==="file"?a.set(f.path,{type:"file",mode:f.mode,data:f.data}):f.type==="directory"?a.set(f.path,{type:"directory",mode:f.mode}):a.set(f.path,{type:f.type,mode:f.mode,target:f.linkName})}}if(a.size!==i.sourceEntryCount||a.size!==o.size||u!==i.expandedBytes)throw new Error("Lazy tree decoded inventory counts differ from its descriptor");for(let[p,m]of o){let f=a.get(p);if(!f)throw new Error(`Lazy tree is missing source member ${p}`);let h=m.type;if(f.type!==h)throw new Error(`Lazy tree member ${p} is ${f.type}, expected ${h}`);if((f.mode&W.S_MODE_BITS)!==m.mode)throw new Error(`Lazy tree member ${p} mode differs from inventory`);if(h==="file"&&f.data?.byteLength!==m.size)throw new Error(`Lazy tree member ${p} size differs from inventory`);if(h==="symlink"&&f.target!==m.target)throw new Error(`Lazy tree symlink ${p} target differs from inventory`);if(h==="hardlink"&&f.target!==m.target)throw new Error(`Lazy tree hardlink ${p} target differs from inventory`)}let l=new Set(s.flatMap(p=>p.materialization==="archive-homebrew-relocate"?[p.sourcePath]:[]));if(i.source!==void 0){let p=new Map(i.source.entries.map(h=>[h.sourcePath,h])),m=Ds(i.source.entries),f=i.source.entries.filter(h=>h.sourcePath==="INSTALL_RECEIPT.json"||h.sourcePath.endsWith("/INSTALL_RECEIPT.json"));if(f.length>1)throw new Error(`Lazy Homebrew bottle has ${f.length} INSTALL_RECEIPT.json source members, expected at most one`);if(f.length===0){if(l.size>0)throw new Error("Lazy Homebrew bottle marks receipt relocation without INSTALL_RECEIPT.json")}else{let h=f[0],g=h.type==="file"?h:m.get(h.sourcePath),_=g===void 0?void 0:a.get(g.sourcePath);if(g?.type!=="file"||_?.type!=="file"||_.data===void 0)throw new Error("Lazy Homebrew bottle INSTALL_RECEIPT.json is not regular");let y=Go(_.data),E=h.sourcePath.lastIndexOf("/"),O=E<0?"":h.sourcePath.slice(0,E),w=new Set(y.changedFiles.map(A=>O.length===0?A:`${O}/${A}`));if(l.size!==w.size||[...l].some(A=>!w.has(A)))throw new Error("Lazy Homebrew bottle relocation markers differ from INSTALL_RECEIPT.json");let S=new Set;for(let A of w){let R=p.get(A),x=R?.type==="file"?R:R===void 0?void 0:m.get(R.sourcePath),v=x===void 0?void 0:a.get(x.sourcePath);if(x?.type!=="file"||v?.type!=="file"||v.data===void 0)throw new Error(`Lazy Homebrew bottle changed source ${A} is not regular`);S.has(x.sourcePath)||(v.data=Ho(v.data,y,A),S.add(x.sourcePath))}}}else if(l.size>0)throw new Error("Lazy tree receipt relocation requires original-bottle source truth");let d=new Map;for(let p of s){if(p.type!=="file"||p.materialization==="descriptor")continue;let m=a.get(p.sourcePath);if(m?.type!=="file"||!m.data)throw new Error(`Lazy tree has no file content for ${p.sourcePath}`);d.set(p.sourcePath,m.data)}return d}async ensureArchiveMaterialized(e,t){let r=this.lazyAtomicGroupByTree.get(e);if(r!==void 0){if(r.committed)return;let o=this.sealedLazyAtomicStates.get(e)?.snapshot,c=this.lazyPreparations.get(r.token)??this.startLazyPreparation({token:r.token,path:o?.activation.roots[0]??e.activation?.roots[0]??e.mountPrefix,atomicGroup:r});try{await c.promise}finally{this.lazyPreparations.get(r.token)===c&&this.lazyPreparations.delete(r.token)}return}if(e.materialized)return;let i=this.lazyTransport,s=await this.fetchLazyArchiveData(e,i);te(i.signal),await this.materializeArchiveBytes(e,s,t,i.signal)}async fetchLazyArchiveData(e,t,r){let i=r?.content??e.content,s=r?.inventory??e.inventory,o=i!==void 0&&s!==void 0,c=r?.mountPrefix??e.mountPrefix,a=r?.integrity??e.integrity,u=o?i.transports:[r?.url??e.url],l=[],d=null;for(let[p,m]of u.entries())try{d=await this.fetchLazyBytes({id:`archive:${c}:${i?.sha256??m}:${p}`,kind:o?"tree":"archive",url:m,mountPrefix:c,integrity:a},t);break}catch(f){if(te(t.signal),Cs(f))throw f;l.push(f instanceof Error?f.message:String(f))}if(te(t.signal),d===null)throw new Error(`All ${u.length} lazy ${o?"tree":"archive"} transports failed: ${l.join("; ")}`);return d}async materializeArchiveBytes(e,t,r,i){if(te(i),e.materialized)return;let s=await this.prepareLazyArchiveContents(e,t,i),o=r?n.inodeKey(r.ino,r.generation):null;for(let c=0;c<3;c++){let a=this.collectLazyArchiveReplacements(e,s,r);if(a.size>0&&(te(i),!this.fs.replaceManyIfIdentities(Array.from(a.values(),Ls)))){if(this.reconcileLazyIdentityState(this.fs.identityState()),o&&!this.lazyArchiveInodes.has(o))return;continue}if(te(i),this.publishLazyArchiveReplacements(e,a),e.materialized||(this.reconcileLazyIdentityState(this.fs.identityState()),o&&!this.lazyArchiveInodes.has(o)))return}if(o&&this.lazyArchiveInodes.has(o))throw new Error(`Lazy archive member kept changing names while materializing: ${r?.path}`)}async prepareLazyArchiveContents(e,t,r,i){te(r);let s=i?.content??e.content,o=i?.inventory??e.inventory,a=s!==void 0&&o!==void 0?await this.decodeAndValidateLazyTree(e,t,i):null;te(r);let{parseZipCentralDirectory:u,extractZipEntry:l}=await Promise.resolve().then(()=>(ti(),ei));te(r);let d=a?[]:u(t),p=new Map;for(let _ of d){if(p.has(_.fileName))throw new Error(`Lazy archive contains duplicate member: ${_.fileName}`);p.set(_.fileName,_)}let f=(i?.mountPrefix??e.mountPrefix).replace(/\/+$/,""),h=new Map,g=i===void 0?Array.from(e.entries):i.entries.map(_=>[_.vfsPath,_]);for(let[_,y]of g){if(y.deleted||y.materialized)continue;let E=y.archivePath??_.slice(f.length+1),O=a?void 0:p.get(E),w=a?.get(E);if(a){if(w===void 0||w.byteLength!==y.size)throw new Error(`Lazy tree member ${E} does not match its registered metadata`)}else if(O===void 0||O.isDirectory||O.isSymlink||O.uncompressedSize!==y.size)throw new Error(`Lazy archive member ${E} does not match its registered metadata`);if(y.generation===void 0)continue;let S=n.inodeKey(y.ino,y.generation),A=h.get(S);if(A&&A.archivePath!==E)throw new Error(`Lazy archive aliases for inode ${S} name different members`);if(!A){let R=w??l(t,O);if(R.byteLength!==y.size)throw new Error(`Lazy archive member ${E} extracted ${R.byteLength} bytes, expected ${y.size}`);h.set(S,{archivePath:E,content:R})}}return h}collectLazyArchiveReplacements(e,t,r,i){let s=new Map,o=i===void 0?Array.from(e.entries):i.entries.map(c=>[c.vfsPath,c]);for(let[c,a]of o){if(a.deleted||a.materialized||a.generation===void 0)continue;let u=n.inodeKey(a.ino,a.generation);if(this.lazyArchiveInodes.get(u)!==e)continue;let l=t.get(u);if(!l)throw new Error(`Lazy archive has no extracted content for inode ${u}`);let d=s.get(u);d||(d={ino:a.ino,generation:a.generation,dataSequence:a.dataSequence??0,paths:new Set,content:l.content},s.set(u,d)),d.paths.add(c),r&&r.ino===a.ino&&r.generation===a.generation&&d.paths.add(r.path)}return s}publishLazyArchiveReplacements(e,t){for(let[r,i]of t){this.lazyArchiveInodes.delete(r);for(let s of e.entries.values())s.ino===i.ino&&s.generation===i.generation&&(s.materialized=!0)}e.materialized=Array.from(e.entries.values()).every(r=>r.deleted||r.materialized)}collectAtomicTreeNamespace(e,t){let r=new Set,i=new Map,s=new Map,o=new Map(t.entries.map(a=>[a.vfsPath,a]));for(let a of t.inventory)(a.type==="file"||a.type==="hardlink")&&s.set(a.inodeGroup,(s.get(a.inodeGroup)??0)+1);let c=[];for(let a of t.inventory){let u;try{u=this.fs.lstat(a.vfsPath)}catch{throw new Error(`Lazy atomic tree changed at ${a.vfsPath}`)}let l=a.type==="directory"?at:a.type==="symlink"?Vr:hr;if((u.mode&Fe)!==l||(u.mode&W.S_MODE_BITS)!==a.mode)throw new Error(`Lazy atomic tree changed at ${a.vfsPath}`);if(a.type==="symlink"){let d=o.get(a.vfsPath);if(d===void 0||!d.isSymlink||d.deleted||d.ino!==u.ino||d.generation!==u.generation||d.dataSequence!==u.dataSequence||this.fs.readlink(a.vfsPath)!==a.target)throw new Error(`Lazy atomic tree changed at ${a.vfsPath}`)}else if(a.type==="file"||a.type==="hardlink"){let d=o.get(a.vfsPath);if(d===void 0||d.deleted||d.materialized||d.isSymlink||d.generation===void 0||d.inodeGroup!==a.inodeGroup||d.ino!==u.ino||d.generation!==u.generation||d.dataSequence!==u.dataSequence||u.size!==0||u.linkCount!==s.get(a.inodeGroup))throw new Error(`Lazy atomic tree changed at ${a.vfsPath}`);let p=n.inodeKey(d.ino,d.generation);if(this.lazyArchiveInodes.get(p)!==e)throw new Error(`Lazy atomic tree lost deferred ownership of ${a.vfsPath}`);let m=i.get(a.inodeGroup);if(m!==void 0&&m!==p)throw new Error(`Lazy atomic tree split hard links at ${a.vfsPath}`);i.set(a.inodeGroup,p),r.add(p)}c.push({path:a.vfsPath,expectedIno:u.ino,expectedGeneration:u.generation,expectedDataSequence:u.dataSequence,expectedMode:u.mode,expectedLinkCount:u.linkCount,expectedSize:u.size,expectedUid:u.uid,expectedGid:u.gid})}return{guards:c,pendingIdentities:r.size}}assertLazyAtomicSnapshotMatchesPublic(e){let t=this.sealedLazyAtomicStates.get(e),r=t?.snapshot,i=e.activation?.atomicGroup,s=r?.member??i?.member??"unknown",o;if(r!==void 0)try{o=Zr(e,r.id,r.member)}catch{o=void 0}if(t===void 0||r===void 0||i===void 0||!Lt(i)||i.id!==r.id||i.member!==r.member||i.descriptorSha256!==r.descriptorSha256||i.expectedCount!==r.expectedCount||i.cohortSha256!==r.cohortSha256||o===void 0||!vs(r,o))throw new Error(`Lazy atomic activation member ${s} changed after sealing`);return t}assertPendingLazyAtomicSnapshotsReadyForSerialization(){for(let e of this.lazyArchiveGroups){if(!this.sealedLazyAtomicStates.has(e)||this.lazyAtomicGroupByTree.get(e)?.committed)continue;let r=this.assertLazyAtomicSnapshotMatchesPublic(e);if(!r.verified)throw new Error(`Lazy atomic activation group ${r.snapshot.id} has not been cryptographically verified after import`)}}async validatePendingLazyAtomicGroupSeals(e){for(let t of this.lazyAtomicGroups.values()){if(t.committed||t.expectedCount===void 0||t.cohortSha256===void 0)continue;let r=[...t.groups.entries()].sort(([i],[s])=>is?1:0).map(([,i])=>i);await this.ensureLazyAtomicGroupSealValidated(t,r,e)}}async ensureLazyAtomicGroupSealValidated(e,t,r){if(this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r))return;let i=e.sealValidationFlight;if(i===void 0&&(i=this.validateLazyAtomicGroupSealOnce(e,t),e.sealValidationFlight=i,i.then(()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)},()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)})),await i,!this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r))throw new Error(`Lazy atomic activation group ${e.id} seal verification did not authenticate every member`)}assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r){if(e.committed)return!0;if(e.expectedCount===void 0||e.cohortSha256===void 0||t.length!==e.expectedCount)throw new Error(`Lazy atomic activation group ${e.id} is not completely sealed`);let i=!0,s=[];for(let o of t){let c=this.assertLazyAtomicSnapshotMatchesPublic(o),a=c.snapshot;if(a.id!==e.id||a.expectedCount!==e.expectedCount||a.cohortSha256!==e.cohortSha256||e.groups.get(a.member)!==o)throw new Error(`Lazy atomic activation group ${e.id} has an inconsistent member`);i&&=c.verified,s.push(c)}if(i&&r)for(let o=0;ofh?1:0).map(([,f])=>f);if(t.length===0)throw new Error(`Lazy atomic activation group ${e.id} has no trees`);if(await this.ensureLazyAtomicGroupSealValidated(e,t,!0),e.committed)return;let r=t.map(f=>this.sealedLazyAtomicStates.get(f).snapshot),i=t.map((f,h)=>({group:f,...this.collectAtomicTreeNamespace(f,r[h])})),s=this.lazyTransport,o=new Array(t.length),c=0,a=!1,u,l=Array.from({length:Math.min(xu,t.length)},async()=>{for(;!a;){let f=c++;if(f>=t.length)return;let h=t[f],g=r[f];try{let _=await this.fetchLazyArchiveData(h,s,g);te(s.signal),o[f]={group:h,snapshot:g,contents:await this.prepareLazyArchiveContents(h,_,s.signal,g)}}catch(_){a||(a=!0,u=_)}}});if(await Promise.all(l),a)throw o.fill(void 0),u;te(s.signal);for(let f of t)this.assertLazyAtomicSnapshotMatchesPublic(f);let d=[],p=[],m=[];for(let f=0;f{let c=this.lazyAtomicGroupByTree.get(o);return this.sealedLazyAtomicStates.get(o)?.snapshot===void 0?!o.materialized&&o.content!==void 0&&o.inventory!==void 0:!c?.committed});if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0&&r.length===0)return;let i=Array.from(this.lazyFiles.values(),o=>o.path);for(let o of i)await this.ensureMaterialized(o);let s=new Set(this.lazyArchiveInodes.values());for(let o of r)s.add(o);for(let o of s)await this.prepareLazyTreeGroup(o)}this.reconcileLazyIdentityState(this.fs.identityState());let e=this.lazyArchiveGroups.some(t=>{let r=this.lazyAtomicGroupByTree.get(t);return this.sealedLazyAtomicStates.get(t)?.snapshot===void 0?!t.materialized&&t.content!==void 0&&t.inventory!==void 0:!r?.committed});if(this.lazyFiles.size!==0||this.lazyArchiveInodes.size!==0||e)throw new Error("Cannot create a self-contained VFS image while lazy entries remain pending")}async saveImage(e){e?.materializeAll&&await this.materializeAllLazyEntries(),await this.validatePendingLazyAtomicGroupSeals(!1);let{bytes:t,identities:r}=this.fs.snapshotState({normalizeTimestampsMs:e?.normalizeTimestampsMs});this.reconcileLazyIdentityState(r);let i=this.serializeLazyEntries(),s=i.length>0,o=s?new TextEncoder().encode(JSON.stringify(i)):new Uint8Array(0);if(o.byteLength>Yr)throw new Error(`VFS image lazy metadata exceeds ${Yr} bytes`);let c=this.serializeValidatedLazyArchiveEntries(r),a=c.length>0,u=a?new TextEncoder().encode(JSON.stringify(c)):new Uint8Array(0);if(u.byteLength>jr)throw new Error(`VFS image lazy archive metadata exceeds ${jr} bytes`);let l=e?.metadata===void 0?this.imageMetadata:e.metadata,d=Fu(l),p=d.byteLength>0,m=a?4+u.byteLength:0,f=p?4+d.byteLength:0,h=pe+t.byteLength+4+o.byteLength+m+f,g=new Uint8Array(h),_=new DataView(g.buffer);_.setUint32(0,fi,!0),_.setUint32(4,hi,!0),_.setUint32(8,(s?ai:0)|(a?Xr:0)|(a?ui:0)|(p?ci:0),!0),_.setUint32(12,t.byteLength,!0),g.set(t,pe);let y=pe+t.byteLength;if(_.setUint32(y,o.byteLength,!0),o.byteLength>0&&g.set(o,y+4),a){let E=y+4+o.byteLength;_.setUint32(E,u.byteLength,!0),g.set(u,E+4)}if(p){let E=y+4+o.byteLength+m;_.setUint32(E,d.byteLength,!0),g.set(d,E+4)}return g}static readImageMetadata(e){let t=qr(e);if(!(t.flags&ci))return null;let{metadataOffset:r}=Os(t.image,t.view,t.flags,t.sabLen);if(t.image.byteLengthzt)throw new Error(`VFS image metadata exceeds ${zt} bytes`);if(t.image.byteLength0){let g=r.subarray(f+4,f+4+h),_=Ne(As(g,"VFS image lazy metadata"),"VFS image lazy entries",0,bt);m.importLazyEntriesInternal(_,!0)}if(s&Xr){let g=c.archiveOffset,_=i.getUint32(g,!0);if(_>0){let y=r.subarray(g+4,g+4+_),E=As(y,"VFS image lazy archive metadata");m.importLazyArchiveEntriesInternal(E,!0,!!(s&ui),"pending")}}return m}adaptStat(e){return{dev:0,ino:e.ino,mode:e.mode,nlink:e.linkCount,uid:e.uid,gid:e.gid,size:e.size,atimeMs:e.atime,mtimeMs:e.mtime,ctimeMs:e.ctime}}adaptStatWithLazySize(e){let t=this.adaptStat(e),r=this.lazyFileForStat(e);if(r)return t.size=r.size,t;let i=this.lazyArchiveForStat(e);if(i){for(let s of this.lazyArchiveEntriesForRead(i))if(s.ino===e.ino&&s.generation===e.generation&&!s.deleted){t.size=s.size;break}}return t}open(e,t,r){(t&ar)===0&&!((t&or)!==0&&(t&Vn)!==0)&&this.guardSynchronousLazyAccess(e);let i=this.fs.open(e,t,r);return(t&ar)!==0&&this.invalidateLazyData(this.fs.fstat(i)),i}close(e){return this.fs.close(e),0}read(e,t,r,i){if(i>0){let s=this.lazyBackingForStat(this.fs.fstat(e));s&&(this.reconcileLazyIdentityState(this.fs.identityState()),s=this.lazyBackingForStat(this.fs.fstat(e)),s&&this.guardSynchronousLazyAccess(s.path))}return r!==null?this.fs.readAt(e,t.subarray(0,i),typeof r=="bigint"?Fn(r):r):this.fs.read(e,t.subarray(0,i))}write(e,t,r,i){if(r!==null){let o=this.fs.writeAt(e,t.subarray(0,i),typeof r=="bigint"?Fn(r):r);return o>0&&this.invalidateLazyData(this.fs.fstat(e)),o}let s=this.fs.write(e,t.subarray(0,i));return s>0&&this.invalidateLazyData(this.fs.fstat(e)),s}append(e,t,r,i){let s=this.fs.append(e,t.subarray(0,r),Ao(i));return s.written>0&&this.invalidateLazyData(this.fs.fstat(e)),s}seek(e,t,r){return this.fs.lseek(e,typeof t=="bigint"?kn(t):t,r)}fstat(e){return this.adaptStatWithLazySize(this.fs.fstat(e))}fpathconf(e,t){let r=this.fstat(e);return Cn(r,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}ftruncate(e,t){this.fs.ftruncate(e,t),this.invalidateLazyData(this.fs.fstat(e))}fsync(e){}fchmod(e,t){this.fs.fchmod(e,t)}fchown(e,t,r){this.fs.fchown(e,t,r)}stat(e){return this.adaptStatWithLazySize(this.fs.stat(e))}lstat(e){return this.adaptStatWithLazySize(this.fs.lstat(e))}statfs(e){this.fs.stat(e);let t=this.fs.statfs();return{type:1397114451,bsize:t.blockSize,blocks:t.totalBlocks,bfree:t.freeBlocks,bavail:t.freeBlocks,files:t.totalInodes,ffree:t.freeInodes,fsid:0,namelen:t.maxName,frsize:t.blockSize,flags:0}}pathconf(e,t){let r=this.stat(e);return Cn(r,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}mkdir(e,t){this.fs.mkdir(e,t)}rmdir(e){this.fs.rmdir(e)}unlink(e){let t=this.fs.unlink(e),r=n.inodeKey(t.ino,t.generation);if(t.linkCount>1&&(this.lazyFiles.has(r)||this.lazyArchiveInodes.has(r))){this.reconcileLazyIdentityState(this.fs.identityState());return}let i=this.lazyFiles.get(r);i&&(i.paths.delete(e),t.linkCount<=1?this.lazyFiles.delete(r):i.path===e&&(i.path=i.paths.values().next().value));let s=this.lazyArchiveInodes.get(r);if(s){let o=s.entries.get(e);if(t.linkCount<=1){for(let c of s.entries.values())c.ino===t.ino&&c.generation===t.generation&&(c.deleted=!0);this.lazyArchiveInodes.delete(r)}else o&&s.entries.delete(e)}}rename(e,t){let{source:r,replaced:i}=this.fs.rename(e,t);if(i&&i.ino===r.ino&&i.generation===r.generation)return;let s=!1;if(i){let o=n.inodeKey(i.ino,i.generation);i.linkCount>1&&(this.lazyFiles.has(o)||this.lazyArchiveInodes.has(o))&&(this.reconcileLazyIdentityState(this.fs.identityState()),s=!0);let c=this.lazyFiles.get(o);!s&&c&&(c.paths.delete(t),i.linkCount<=1?this.lazyFiles.delete(o):c.path===t&&(c.path=c.paths.values().next().value));let a=this.lazyArchiveInodes.get(o);if(!s&&a){let u=a.entries.get(t);i.linkCount<=1?(u&&(u.deleted=!0),this.lazyArchiveInodes.delete(o)):u&&a.entries.delete(t)}}s||this.rewriteLazyNamespacePaths(r,e,t)}link(e,t){let r=this.fs.link(e,t),i=n.inodeKey(r.ino,r.generation),s=this.lazyFiles.get(i);s&&s.paths.add(t);let o=this.lazyArchiveInodes.get(i);if(o){let c=Array.from(o.entries.values()).find(a=>a.ino===r.ino&&a.generation===r.generation);c&&o.entries.set(t,{...c})}}symlink(e,t){this.fs.symlink(e,t)}readlink(e){return this.fs.readlink(e)}chmod(e,t){this.fs.chmod(e,t)}chown(e,t,r){this.fs.chown(e,t,r)}lchown(e,t,r){this.fs.lchown(e,t,r)}createFileWithOwner(e,t,r,i,s){let o=this.open(e,ys,t);s.length>0&&this.write(o,s,null,s.length),this.close(o),this.chown(e,r,i),this.chmod(e,t)}mkdirWithOwner(e,t,r,i){this.mkdir(e,t),this.chown(e,r,i),this.chmod(e,t)}symlinkWithOwner(e,t,r,i){this.symlink(e,t),this.lchown(t,r,i)}copyPathToFreshFileSystem(e,t,r,i,s){let o=this.lstat(e),c=o.mode&Fe,a=o.mode&W.S_MODE_BITS;if(c===at){e==="/"?(t.chown(e,o.uid,o.gid),t.chmod(e,a)):t.mkdirWithOwner(e,a,o.uid,o.gid);let p=this.opendir(e);try{for(;;){let m=this.readdir(p);if(!m)break;m.name==="."||m.name===".."||this.copyPathToFreshFileSystem(e==="/"?`/${m.name}`:`${e}/${m.name}`,t,r,i,s)}}finally{this.closedir(p)}n.applyTimes(t,e,o);return}let u=o.nlink>1?`${o.dev}:${o.ino}`:null,l=u?s.get(u):void 0;if(l){t.link(l,e);return}if(c===Vr){t.symlinkWithOwner(this.readlink(e),e,o.uid,o.gid),u&&s.set(u,e);return}if(c!==hr)throw new Error(`Unsupported file type while rebasing VFS: ${e}`);if(r.has(e)||i.has(e)){t.createFileWithOwner(e,a,o.uid,o.gid,new Uint8Array(0)),n.applyTimes(t,e,o),u&&s.set(u,e);return}this.copyRegularFileToFreshFileSystem(e,t,o,a),u&&s.set(u,e)}copyRegularFileToFreshFileSystem(e,t,r,i){let s=this.open(e,wu,0),o=null;try{o=t.open(e,ys,i);let c=new Uint8Array(Math.min(Ou,Math.max(1,r.size))),a=r.size;for(;a>0;){let u=Math.min(c.byteLength,a),l=this.read(s,c,null,u);if(l<=0)throw new Error(`Unexpected EOF while rebasing VFS file: ${e}`);let d=0;for(;d!e||e==="."||e===".."))throw new Error(`Binary resolver path must be a normalized portable relative path: ${JSON.stringify(n)}`);return n}var lt=new Set(["wasm32","wasm64"]);function je(n){if(ld(n),!n.startsWith("programs/"))return n;let e=n.slice(9),t=e.split("/",1)[0];return lt.has(t)?n:`programs/wasm32/${e}`}function fd(n,e=$(Ti(),"wasm")){let t=je(n),r=[$(e,t)];return n==="kernel.wasm"?r.push($(e,"kandelo-kernel.wasm")):n==="userspace.wasm"?r.push($(e,"wasm_posix_userspace.wasm")):n==="rootfs.vfs"&&r.push($(e,"rootfs.vfs")),r}var an=class extends Error{constructor(e){super(e),this.name="BinaryNotFoundError"}};function ea(){let n=[],e=!1;try{let r=dt();e=!0;for(let[i,s]of[["local-binaries",$(r,"local-binaries")],["binaries",$(r,"binaries")]])n.push({label:i,root:s,identity:i==="local-binaries"?"local-generation":"program-cache",allowRegularFileClosure:!1,candidatesFor(o){return[$(s,je(o))]}})}catch{}let t=$(Ti(),"wasm");return n.push({label:"installed package",root:t,identity:"installed-package",allowRegularFileClosure:!e,candidatesFor(r){return fd(r,t)}}),n}function Pt(n,e){return new Error(`Invalid package manifest ${n}: ${e}`)}function ce(n){try{return cn(n),!0}catch(e){if(e instanceof Error&&"code"in e&&e.code==="ENOENT")return!1;throw e}}function Gs(n,e,t){if(n.length===0||n.startsWith("/")||n.includes("\\")||n.includes("\0")||n.split("/").some(r=>!r||r==="."||r===".."))throw Pt(e,`${t} must be a normalized portable relative path`);return n}function on(n,e,t,r=!0){if(n.length===0||n==="."||n===".."||n.includes("/")||n.includes("\\")||n.includes("\0")||!r&&n.includes("@"))throw Pt(e,`${t} must be a safe single path component`);return n}var Hs="kandelo-program-packages-v2",Ce="program-packages.json",Vs=null,hd=null,sn=null,Oi=0;function vi(){return hd??$(Ti(),"wasm",Ce)}function ta(){if(Object.prototype.hasOwnProperty.call(process.env,"WASM_POSIX_DEPS_REGISTRY")){let t=null;return(process.env.WASM_POSIX_DEPS_REGISTRY??"").split(":").filter(Boolean).map(r=>r.startsWith("~/")&&process.env.HOME!==void 0?$(process.env.HOME,r.slice(2)):un(r)?Ie(r):(t??=dt(),Ie(t,r)))}let n;try{n=$(dt(),"packages","registry")}catch{return null}let e=!1;if(ce(n)){if(!Ye(n).isDirectory())return[n];e=js(n,{withFileTypes:!0}).filter(t=>t.isDirectory()||t.isSymbolicLink()).some(t=>ce($(n,t.name,"package.toml")))}return!e&&ra()===null&&ce(vi())?null:[n]}function ra(){let n;try{n=dt()}catch{return null}if(!Er($(n,"tools","xtask","Cargo.toml"))||!Er($(n,"scripts","dev-shell.sh")))return null;try{let e=Re(xi()),t=Re(n);return[$(t,"host"),$(t,"scripts")].some(i=>Er(i)&&ki(Re(i),e))?t:null}catch{return null}}function Li(n,e,t){let r=[typeof t.stderr=="string"?t.stderr.trim():"",typeof t.stdout=="string"?t.stdout.trim():"",t.error?.message??""].filter(Boolean).join(` +var Fa=Object.defineProperty;var br=(n,e,t)=>()=>{if(t)throw t[0];try{return n&&(e=n(n=0)),e}catch(r){throw t=[r],r}};var qi=(n,e)=>{for(var t in e)Fa(n,t,{get:e[t],enumerable:!0})};var Bt,Zi,$t,Yi,gn,Xi,ne,ji,Ji,vr,Qi,En,eo,to,ro,no,Pr,kr,Sn,wn,On,An,zn,gt,Ut,Wt,Gt,Pe,io,oo,Fr,ze,so,Nr,xn,Cr,Mr,Et,Ht,Ge,In,Tn,ao,Dr,co,Y,uo,lo,Kr,Vt,fo,po,ho,X,mo,yo,Br,qt,_o,go,Rn,Ln,ot,St,bn,Zt,He,Eo,So,ie,wo,Oo,Ao,zo,Ve=br(()=>{"use strict";Bt="kandelo.wpk_fork.linked_frames",Zi=[75,76,67,70],$t=24,Yi=8,gn=3,Xi=[{bytes:4,chunkHeaderSize:32,nodeHeaderSize:24},{bytes:8,chunkHeaderSize:56,nodeHeaderSize:32}],ne="kandelo.wpk_fork.module_state",ji=1,Ji=[75,70,77,68],vr=24,Qi=8,En=7,eo=1,to=1,ro=1,no=[{bytes:4,chunkHeaderSize:40},{bytes:8,chunkHeaderSize:56}],Pr="__wpk_fork_global_",kr="__wpk_fork_table_",Sn=1,wn=2,On=3,An=4,zn=5,gt=6,Ut=7,Wt=8,Gt=9,Pe="kandelo.wpk_fork.capabilities",io=1,oo=7,Fr=4,ze="kandelo.wpk_fork.exception_codec",so=1,Nr=8,xn=16,Cr="env",Mr="__wpk_fork_unwind",Et="kandelo.wpk_fork.unwind_transport",Ht="__wpk_fork_static_root_catalog",Ge="kandelo.wpk_fork.static_root_catalog",In=1,Tn=0,ao=1,Dr=12,co=[75,70,83,82],Y="kandelo.wpk_fork.imported_globals",uo=[75,70,73,71],lo=1,Kr=16,Vt=24,fo=1,po=2,ho=3,X="kandelo.wpk_fork.imported_tables",mo=[75,70,73,84],yo=1,Br=16,qt=24,_o=1,go=1,Rn="env",Ln="__wpk_fork_module_activation",ot={module:"kernel",name:"kernel_fork",params:["i32"],results:["i32"]},St=[{module:"env",name:"__wpk_fork_frame_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_frame_next",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_peek",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_reserve",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_record_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_module_state_record_find",params:["i32","i32","i32","i32"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_record_reserve",params:["i32","i32","i32","ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_table_dirty_count",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_module_state_table_dirty_mark",params:["i32","i64","i64"],results:[]},{module:"env",name:"__wpk_fork_module_state_table_dirty_page",params:["i32","i32"],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_mutation_abort",params:[],results:[]},{module:"env",name:"__wpk_fork_module_state_table_mutation_begin",params:[],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_mutation_commit",params:["i32","i64","i64"],results:[]},{module:"env",name:"__wpk_fork_module_state_table_reconcile",params:[],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_state_owned",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_decode_funcref",params:["i32"],results:["funcref"]},{module:"env",name:"__wpk_fork_ref_encode_funcref",params:["funcref"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_broker_encode",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_broker_throw_recipe",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_cache_index",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_claim",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_define",params:["i32","i32","i32","i32","ptr","i32","ptr","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_ingress_throw",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_load",params:["i32","i32","i32","i32","ptr","i32","ptr","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_lookup",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_route",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_broker_encode",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_capture_layout",params:["i32","i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_claim",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_define",params:["i32","i32","i32","i32","i32","ptr","i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_i31",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_load",params:["i32","i32","i32","i32","i32","ptr","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_lookup",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_payload_len",params:["i32","i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_provenance_begin",params:["i32","i32","i32","i32","i64","i64","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_provenance_end",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_provenance_ref",params:["i32","i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_route",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_scratch_release",params:["ptr","ptr"],results:[]},{module:"env",name:"__wpk_fork_ref_scratch_reserve",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_ref_vector_append",params:["i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_vector_begin",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_vector_finish",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_vector_get",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_resume_peek",params:["i32"],results:["i32"]}],bn=[{module:"env",name:"__wpk_fork_ref_gc_transit",table64:!1,element:"anyref",minimum:1,maximum:null},{module:"env",name:"__wpk_fork_resume_table",table64:!1,element:"funcref",minimum:1,maximum:null}],Zt=[{name:"__wpk_fork_exception_materialize",params:["i32"],results:[]},{name:"__wpk_fork_ref_decode_exnref",params:["i32"],results:["exnref"]},{name:"__wpk_fork_ref_encode_exnref",params:["exnref"],results:["i32"]},{name:"__wpk_fork_ref_exn_abort",params:[],results:[]},{name:"__wpk_fork_ref_exn_clear",params:[],results:[]},{name:"__wpk_fork_ref_exn_encode_ingress",params:["i32"],results:["i32"]},{name:"__wpk_fork_ref_exn_throw_recipe",params:["i32"],results:[]},{name:"__wpk_fork_ref_exn_throw_slot",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_allocate",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_encode_slot",params:["i32"],results:["i32"]},{name:"__wpk_fork_ref_gc_fill",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_probe",params:["i32"],results:["i64"]},{name:"__wpk_fork_ref_gc_publish_externref",params:["i32","externref"],results:[]},{name:"__wpk_fork_static_root_harvest",params:[],results:[]},{name:"wpk_fork_abort_begin",params:["ptr"],results:[]},{name:"wpk_fork_abort_end",params:[],results:[]},{name:"wpk_fork_module_bootstrap",params:[],results:[]},{name:"wpk_fork_module_state_finish_restore",params:["i32"],results:[]},{name:"wpk_fork_module_state_restore",params:["i32"],results:[]},{name:"wpk_fork_module_state_save",params:["i32"],results:[]},{name:"wpk_fork_module_table_state_restore",params:["i32"],results:[]},{name:"wpk_fork_module_table_state_save",params:["i32"],results:[]},{name:"wpk_fork_module_thread_bootstrap",params:[],results:[]},{name:"wpk_fork_rewind_begin",params:["ptr"],results:[]},{name:"wpk_fork_rewind_end",params:[],results:[]},{name:"wpk_fork_state",params:[],results:["i32"]},{name:"wpk_fork_unwind_begin",params:["ptr"],results:[]},{name:"wpk_fork_unwind_end",params:[],results:[]}],He={O_RDONLY:0,O_WRONLY:1,O_RDWR:2,O_ACCMODE:3,O_CREAT:64,O_EXCL:128,O_NOCTTY:256,O_TRUNC:512,O_APPEND:1024,O_NONBLOCK:2048,O_ASYNC:8192,O_DIRECTORY:65536,O_NOFOLLOW:131072,O_CLOEXEC:524288,O_PATH:2097152,O_CLOFORK:8388608},Eo={F_OK:0,R_OK:4,W_OK:2,X_OK:1},So={ST_NOSUID:2},ie={S_IFMT:61440,S_IFSOCK:49152,S_IFLNK:40960,S_IFREG:32768,S_IFBLK:24576,S_IFDIR:16384,S_IFCHR:8192,S_IFIFO:4096,S_ISUID:2048,S_ISGID:1024,S_ISVTX:512,S_IRWXU:448,S_IRUSR:256,S_IWUSR:128,S_IXUSR:64,S_IRWXG:56,S_IRGRP:32,S_IWGRP:16,S_IXGRP:8,S_IRWXO:7,S_IROTH:4,S_IWOTH:2,S_IXOTH:1,S_MODE_BITS:4095},wo={DT_UNKNOWN:0,DT_FIFO:1,DT_CHR:2,DT_DIR:4,DT_BLK:6,DT_REG:8,DT_LNK:10,DT_SOCK:12},Oo=4096,Ao=["__abi_version","kernel_alloc_scratch","kernel_blocking_retry_release","kernel_blocking_retry_token","kernel_commit_process_exit","kernel_create_process","kernel_create_process_with_stdio","kernel_dequeue_signal","kernel_exec_commit","kernel_exec_target_cancel","kernel_exec_target_prepare","kernel_exec_target_read","kernel_exec_target_size","kernel_fork_process","kernel_get_cwd","kernel_get_dirfd_path","kernel_get_fd_path","kernel_get_parent_pid","kernel_get_process_exit_signal","kernel_get_process_state","kernel_get_socket_timeout_ms","kernel_handle_channel","kernel_has_sa_nocldstop","kernel_host_adapter_manifest_len","kernel_host_adapter_manifest_ptr","kernel_ipc_shm_lookup_mapping_for_task","kernel_ipc_shm_record_mapping_for_process","kernel_ipc_shm_record_mapping_for_task","kernel_ipc_shmat_for_process","kernel_ipc_shmat_for_task","kernel_ipc_shmdt_addr_for_process","kernel_ipc_shmdt_addr_for_task","kernel_ipc_shmdt_for_process","kernel_ipc_shmdt_for_task","kernel_is_fd_nonblock","kernel_mark_process_signaled","kernel_mq_descriptor_msgsize","kernel_msqid_ds_bytes","kernel_pcm_claim_transport","kernel_pcm_clock_update","kernel_pcm_reconcile","kernel_pcm_transport_len","kernel_pcm_transport_ptr","kernel_pick_signal_target_tid","kernel_pick_tcp_listener_target","kernel_pipe_has_readers","kernel_posix_timer_fire","kernel_process_metadata_begin","kernel_process_metadata_cancel","kernel_process_metadata_commit","kernel_process_metadata_stage","kernel_reap_exited_child","kernel_remove_process","kernel_semctl_array_bytes","kernel_semid_ds_bytes","kernel_set_current_tid","kernel_set_cwd","kernel_shmid_ds_bytes","kernel_spawn_exec_commit","kernel_spawn_exec_target_prepare","kernel_spawn_process","kernel_spawn_reserved_process","kernel_spawn_scratch_begin","kernel_spawn_scratch_cancel","kernel_spawn_scratch_capacity","kernel_spawn_scratch_pointer","kernel_spawn_scratch_retained_capacity","kernel_take_process_timer_cleanup","kernel_thread_exit","kernel_thread_has_deliverable","kernel_transfer_channel_execute","kernel_transfer_io_execute","kernel_transfer_scratch_begin","kernel_transfer_scratch_cancel","kernel_transfer_scratch_capacity","kernel_transfer_scratch_pointer","kernel_validate_task","kernel_wait_child_poll"],zo={LINK_MAX:0,MAX_CANON:1,MAX_INPUT:2,NAME_MAX:3,PATH_MAX:4,PIPE_BUF:5,CHOWN_RESTRICTED:6,NO_TRUNC:7,VDISABLE:8,SYNC_IO:9,ASYNC_IO:10,PRIO_IO:11,SOCK_MAXBUF:12,FILESIZEBITS:13,REC_INCR_XFER_SIZE:14,REC_MAX_XFER_SIZE:15,REC_MIN_XFER_SIZE:16,REC_XFER_ALIGN:17,ALLOC_SIZE_MIN:18,SYMLINK_MAX:19,POSIX2_SYMLINKS:20,FALLOC:21,TEXTDOMAIN_MAX:22,TIMESTAMP_RESOLUTION:23}});import{createRequire as Iu}from"module";function Ss(n,e){return Es(n,{i:2},e&&e.out,e&&e.dictionary)}var Tu,Pt,Ru,Lu,ae,bt,bu,fs,ps,vu,hs,Pt,ms,Pu,ys,ku,Af,ai,Ne,K,mr,yr,K,K,K,K,_s,K,Fu,Nu,oi,Te,si,gs,Jr,Cu,ge,Es,Mu,Du,vt,ws,Ku,Bu,ci=br(()=>{Tu=Iu("/");try{Pt=Tu("worker_threads"),Ru=Pt.Worker,Lu=Pt.isMarkedAsUntransferable}catch{}ae=Uint8Array,bt=Uint16Array,bu=Int32Array,fs=new ae([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),ps=new ae([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),vu=new ae([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),hs=function(n,e){for(var t=new bt(31),r=0;r<31;++r)t[r]=e+=1<>1|(K&21845)<<1,Ne=(Ne&52428)>>2|(Ne&13107)<<2,Ne=(Ne&61680)>>4|(Ne&3855)<<4,ai[K]=((Ne&65280)>>8|(Ne&255)<<8)>>1;mr=(function(n,e,t){for(var r=n.length,i=0,o=new bt(e);i>c]=u}else for(a=new bt(r),i=0;i>15-n[i]);return a}),yr=new ae(288);for(K=0;K<144;++K)yr[K]=8;for(K=144;K<256;++K)yr[K]=9;for(K=256;K<280;++K)yr[K]=7;for(K=280;K<288;++K)yr[K]=8;_s=new ae(32);for(K=0;K<32;++K)_s[K]=5;Fu=mr(yr,9,1),Nu=mr(_s,5,1),oi=function(n){for(var e=n[0],t=1;te&&(e=n[t]);return e},Te=function(n,e,t){var r=e/8|0;return(n[r]|n[r+1]<<8)>>(e&7)&t},si=function(n,e){var t=e/8|0;return(n[t]|n[t+1]<<8|n[t+2]<<16)>>(e&7)},gs=function(n){return(n+7)/8|0},Jr=function(n,e,t){return(e==null||e<0)&&(e=0),(t==null||t>n.length)&&(t=n.length),new ae(n.subarray(e,t))},Cu=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],ge=function(n,e,t){var r=new Error(e||Cu[n]);if(r.code=n,Error.captureStackTrace&&Error.captureStackTrace(r,ge),!t)throw r;return r},Es=function(n,e,t,r){var i=n.length,o=r?r.length:0;if(!i||e.f&&!e.l)return t||new ae(0);var s=!t,a=s||e.i!=2,c=e.i;s&&(t=new ae(i*3));var u=function(Ue){var We=t.length;if(Ue>We){var Lr=new ae(Math.max(We*2,Ue));Lr.set(t),t=Lr}},l=e.f||0,d=e.p||0,h=e.b||0,m=e.l,f=e.d,p=e.m,_=e.n,y=i*8;do{if(!m){l=Te(n,d,1);var g=Te(n,d+1,3);if(d+=3,g)if(g==1)m=Fu,f=Nu,p=9,_=5;else if(g==2){var w=Te(n,d,31)+257,z=Te(n,d+10,15)+4,x=w+Te(n,d+5,31)+1;d+=14;for(var I=new ae(x),R=new ae(19),L=0;L>4;if(E<16)I[L++]=E;else{var G=0,ue=0;for(E==16?(ue=3+Te(n,d,3),d+=2,G=I[L-1]):E==17?(ue=3+Te(n,d,7),d+=3):E==18&&(ue=11+Te(n,d,127),d+=7);ue--;)I[L++]=G}}var Oe=I.subarray(0,w),M=I.subarray(w);p=oi(Oe),_=oi(M),m=mr(Oe,p,1),f=mr(M,_,1)}else ge(1);else{var E=gs(d)+4,O=n[E-4]|n[E-3]<<8,S=E+O;if(S>i){c&&ge(0);break}a&&u(h+O),t.set(n.subarray(E,S),h),e.b=h+=O,e.p=d=S*8,e.f=l;continue}if(d>y){c&&ge(0);break}}a&&u(h+131072);for(var de=(1<>4;if(d+=G&15,d>y){c&&ge(0);break}if(G||ge(2),ve<256)t[h++]=ve;else if(ve==256){nt=d,m=null;break}else{var Kt=ve-254;if(ve>264){var L=ve-257,Be=fs[L];Kt=Te(n,d,(1<>4;yt||ge(3),d+=yt&15;var M=ku[Ae];if(Ae>3){var Be=ps[Ae];M+=si(n,d)&(1<y){c&&ge(0);break}a&&u(h+131072);var $e=h+Kt;if(h>3&1)+(e>>4&1);r>0;r-=!n[t++]);return t+(e&2)},vt=(function(){function n(e,t){typeof e=="function"&&(t=e,e={}),this.ondata=t;var r=e&&e.dictionary&&e.dictionary.subarray(-32768);this.s={i:0,b:r?r.length:0},this.o=new ae(32768),this.p=new ae(0),r&&this.o.set(r)}return n.prototype.e=function(e){if(this.ondata||ge(5),this.d&&ge(4),!this.p.length)this.p=e;else if(e.length){var t=new ae(this.p.length+e.length);t.set(this.p),t.set(e,this.p.length),this.p=t}},n.prototype.c=function(e){this.s.i=+(this.d=e||!1);var t=this.s.b,r=Es(this.p,this.s,this.o);this.ondata(Jr(r,t,this.s.b),this.d),this.o=Jr(r,this.s.b-32768),this.s.b=this.o.length,this.p=Jr(this.p,this.s.p/8|0),this.s.p&=7},n.prototype.push=function(e,t){this.e(e),this.c(t)},n})();ws=(function(){function n(e,t){this.v=1,this.r=0,vt.call(this,e,t)}return n.prototype.push=function(e,t){if(vt.prototype.e.call(this,e),this.r+=e.length,this.v){var r=this.p.subarray(this.v-1),i=r.length>3?Du(r):4;if(i>r.length){if(!t)return}else this.v>1&&this.onmember&&this.onmember(this.r-r.length);this.p=r.subarray(i),this.v=0}vt.prototype.c.call(this,0),this.s.f&&!this.s.l?(this.v=gs(this.s.p)+9,this.s={i:0},this.o=new ae(0),this.push(new ae(0),t)):t&&vt.prototype.c.call(this,t)},n})(),Ku=typeof TextDecoder<"u"&&new TextDecoder,Bu=0;try{Ku.decode(Mu,{stream:!0}),Bu=1}catch{}});var li={};qi(li,{extractZipEntry:()=>Zu,extractZipEntryBounded:()=>Yu,fetchZipCentralDirectory:()=>ju,parseZipCentralDirectory:()=>_r});function Ts(n){let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=Math.max(0,n.length-zs);for(let r=n.length-Wu;r>=t;r--)if(e.getUint32(r,!0)===$u)return r;throw new Error("Zip EOCD record not found")}function _r(n){let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=Ts(n),r=e.getUint16(t+10,!0),i=e.getUint32(t+16,!0),o=[],s=i;for(let a=0;a>8,O;E===Os?O=p>>16&65535:g.startsWith("bin/")||g.startsWith("sbin/")||g.includes("/bin/")||g.includes("/sbin/")?O=493:O=420;let S=g.endsWith("/"),w=E===Os&&(O&Hu)===Gu;o.push({fileName:g,fileNameBytes:y,compressedSize:l,uncompressedSize:d,compressionMethod:u,localHeaderOffset:_,mode:O,isDirectory:S,isSymlink:w,externalAttrs:p,creatorOS:E}),s+=ui+h+m+f}return o}function Rs(n,e){if(n.byteLength!==e.byteLength)return!1;for(let t=0;t{if(a.byteLength>t-o)throw new Error(`ZIP member ${e.fileName} expands beyond ${t} bytes`);i.set(a,o),o+=a.byteLength}).push(r,!0),o!==t)throw new Error(`ZIP member ${e.fileName} expanded ${o} bytes, expected ${t}`);return i}function Xu(n,e){let t=new DataView(n.buffer,n.byteOffset,n.byteLength),r=e.localHeaderOffset;if(r<0||r>n.byteLength-di||t.getUint32(r,!0)!==As)throw new Error(`Invalid local file header signature at offset ${r}`);let i=t.getUint16(r+8,!0),o=t.getUint16(r+26,!0),s=t.getUint16(r+28,!0),a=r+di,c=a+o+s,u=c+e.compressedSize;if(i!==e.compressionMethod||cn.byteLength||!Rs(n.subarray(a,a+o),e.fileNameBytes))throw new Error(`ZIP member ${e.fileName} has inconsistent local metadata`);return n.subarray(c,u)}async function ju(n){let e=await fetch(n,{method:"HEAD"});if(!e.ok)throw new Error(`HEAD request failed: ${e.status} ${e.statusText}`);let t=parseInt(e.headers.get("content-length")||"0",10),r=e.headers.get("accept-ranges");if(!t||r!=="bytes"){let y=await fetch(n);if(!y.ok)throw new Error(`Fetch failed: ${y.status} ${y.statusText}`);let g=new Uint8Array(await y.arrayBuffer());return{entries:_r(g),totalSize:g.length}}let i=Math.min(t,zs),o=t-i,s=await fetch(n,{headers:{Range:`bytes=${o}-${t-1}`}});if(s.status!==206){let y=await fetch(n);if(!y.ok)throw new Error(`Fetch failed: ${y.status} ${y.statusText}`);let g=new Uint8Array(await y.arrayBuffer());return{entries:_r(g),totalSize:g.length}}let a=new Uint8Array(await s.arrayBuffer()),c=new DataView(a.buffer,a.byteOffset,a.byteLength),u=Ts(a),l=c.getUint32(u+12,!0),d=c.getUint32(u+16,!0);if(d>=o){let y=t,g=new Uint8Array(y);return g.set(a,o),{entries:_r(g),totalSize:y}}let h=d+l-1,m=await fetch(n,{headers:{Range:`bytes=${d}-${h}`}});if(m.status!==206)throw new Error(`Range request for CD failed: ${m.status}`);let f=new Uint8Array(await m.arrayBuffer()),p=t,_=new Uint8Array(p);return _.set(f,d),_.set(a,o),{entries:_r(_),totalSize:p}}var $u,Uu,As,zs,Wu,ui,di,xs,Is,Os,Gu,Hu,Vu,qu,fi=br(()=>{"use strict";ci();Ve();$u=101010256,Uu=33639248,As=67324752,zs=65557,Wu=22,ui=46,di=30,xs=0,Is=8,Os=3,{S_IFLNK:Gu,S_IFMT:Hu}=ie,Vu=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),qu=new TextEncoder});var Ns={};qi(Ns,{DEFAULT_TAR_GZIP_LIMITS:()=>Fs,TarParseError:()=>b,parseTarGzip:()=>td});function td(n,e={}){let t=e.label??"TAR gzip archive",r=nd(e.limits,t);if(n.byteLength===0||n.byteLength>r.maxCompressedBytes)throw new b(`${t}: compressed byte count ${n.byteLength} is outside 1..${r.maxCompressedBytes}`);let i=id(n,t);if(i===0||i>r.maxUncompressedBytes)throw new b(`${t}: declared uncompressed byte count ${i} is outside 1..${r.maxUncompressedBytes}`);let o=od(n,t,i);if(o.byteLength!==i)throw new b(`${t}: gzip expanded to ${o.byteLength} bytes, expected ${i}`);let s=new DataView(n.buffer,n.byteOffset,n.byteLength).getUint32(n.byteLength-8,!0);if(sd(o)!==s)throw new b(`${t}: gzip CRC32 mismatch`);return rd(o,t,r)}function rd(n,e,t){if(n.byteLength%Ce!==0)throw new b(`${e}: TAR byte count is not block-aligned`);let r=[],i=0,o=0,s=0,a=null,c={},u=!1;for(;i+Ce<=n.byteLength;){let l=n.subarray(i,i+Ce);if(i+=Ce,hi(l)){if(i+Ce>n.byteLength)throw new b(`${e}: TAR end marker is truncated`);let S=n.subarray(i,i+Ce);if(!hi(S))throw new b(`${e}: TAR has only one zero end block`);if(i+=Ce,!hi(n.subarray(i)))throw new b(`${e}: TAR has nonzero data after its end marker`);u=!0;break}dd(l,e);let d=gr(l,156,1,e)||"0",h=yi(l,124,12,`${e}: TAR entry size`),m=yi(l,100,8,`${e}: TAR entry mode`)&Ju,f=ld(l,e,t.maxPathBytes),p=gr(l,157,100,e);if(d==="x"||d==="g"){if(s+=1,s>t.maxEntries+1)throw new b(`${e}: TAR extension header count exceeds ${t.maxEntries+1}`);let S=bs(n,i,h,e);i=vs(i,h,n.byteLength,e);let w=cd(S,e,t);d==="x"?a=w:c={...c,...w};continue}if(o+=1,o>t.maxEntries)throw new b(`${e}: TAR entry count exceeds ${t.maxEntries}`);let _={...c,...a??{}};a=null;let y=_.size===void 0?h:ud(_.size,`${e}: PAX entry size`),g=bs(n,i,y,e);i=vs(i,y,n.byteLength,e);let E=mi(_.path??f,e,t.maxPathBytes),O=_.linkpath??p;switch(d){case"0":case"\0":r.push({path:E,type:"file",mode:m,data:g});break;case"5":pi(y,e,"directory",E),r.push({path:E,type:"directory",mode:m});break;case"2":pi(y,e,"symlink",E),Ps(O,e,E,t.maxLinkBytes,!1),r.push({path:E,type:"symlink",mode:m,linkName:O});break;case"1":pi(y,e,"hardlink",E),Ps(O,e,E,t.maxLinkBytes,!0),r.push({path:E,type:"hardlink",mode:m,linkName:mi(O,`${e}: hardlink target`,t.maxPathBytes)});break;case"3":case"4":case"6":throw new b(`${e}: unsupported TAR device/FIFO entry ${E}`);default:throw new b(`${e}: unsupported TAR entry type ${JSON.stringify(d)} for ${E}`)}}if(!u)throw new b(`${e}: TAR is missing its two-block end marker`);if(a!==null)throw new b(`${e}: local PAX header has no following entry`);return r}function nd(n,e){let t={...Fs,...n};for(let[r,i]of Object.entries(t))if(!Number.isSafeInteger(i)||i<=0)throw new b(`${e}: ${r} must be a positive safe integer`);return t}function id(n,e){if(n.byteLength<18||n[0]!==31||n[1]!==139||n[2]!==8)throw new b(`${e}: invalid gzip header`);return new DataView(n.buffer,n.byteOffset,n.byteLength).getUint32(n.byteLength-4,!0)}function od(n,e,t){let r=new Uint8Array(t),i=0,o=!1,s=new ws(a=>{if(a.byteLength>t-i)throw new b(`${e}: gzip expansion exceeds its declared ${t} bytes`);r.set(a,i),i+=a.byteLength});s.onmember=()=>{throw o=!0,new b(`${e}: concatenated gzip members are unsupported`)};try{s.push(n,!0)}catch(a){throw a instanceof b?a:new b(`${e}: cannot gunzip archive: ${pd(a)}`)}if(o)throw new b(`${e}: concatenated gzip members are unsupported`);return r.subarray(0,i)}function sd(n){let e=4294967295;for(let t of n)e=ed[(e^t)&255]^e>>>8;return(e^4294967295)>>>0}function ad(){let n=new Uint32Array(256);for(let e=0;e>>1^((t&1)===0?0:3988292384);n[e]=t>>>0}return n}function bs(n,e,t,r){if(t>n.byteLength-e)throw new b(`${r}: TAR entry is truncated`);return n.subarray(e,e+t)}function vs(n,e,t,r){let o=Math.ceil(e/Ce)*Ce;if(!Number.isSafeInteger(o)||o>t-n)throw new b(`${r}: TAR entry padding is truncated`);return n+o}function cd(n,e,t){let r={},i=0;for(;i9)throw new b(`${e}: invalid PAX record length`);if(s=s*10+p,!Number.isSafeInteger(s))throw new b(`${e}: invalid PAX record length`)}let a=i+s;if(s<=o-i+2||a>n.byteLength||n[a-1]!==10)throw new b(`${e}: truncated PAX record`);let c=o+1;for(;c=a-1)throw new b(`${e}: invalid PAX record`);let u=n.subarray(o+1,c);if(u.byteLength>256)throw new b(`${e}: PAX record key is too long`);let l=_i(u,`${e}: PAX record key`),d=n.subarray(c+1,a-1),h=l==="path"?t.maxPathBytes:l==="linkpath"?t.maxLinkBytes:l==="size"?32:0;if(h===0){i=a;continue}if(d.byteLength>h)throw new b(`${e}: PAX ${l} value is too long`);let m=_i(d,`${e}: PAX record value`);r[l]=m,i=a}return r}function ud(n,e){if(!/^(0|[1-9][0-9]*)$/.test(n))throw new b(`${e} is invalid`);let t=Number(n);if(!Number.isSafeInteger(t)||t<0)throw new b(`${e} is invalid`);return t}function dd(n,e){let t=yi(n,148,8,`${e}: TAR checksum`),r=0;for(let i=0;i=148&&i<156?32:n[i];if(t!==r)throw new b(`${e}: TAR checksum mismatch`)}function ld(n,e,t){let r=gr(n,0,100,e),i=gr(n,345,155,e);return mi(i?`${i}/${r}`:r,e,t)}function mi(n,e,t){let r=n;for(;r.startsWith("./");)r=r.slice(2);return r=r.replace(/\/+$/g,""),fd(r,`${e}: TAR path`,t),r}function gr(n,e,t,r){let i=e,o=e+t;for(;ir||n.includes("\0"))throw new b(`${e}: link target for ${t} is invalid`);if(i&&n.includes("\\"))throw new b(`${e}: hardlink target for ${t} is invalid`)}function fd(n,e,t){if(n.length===0||n.startsWith("/")||n.includes("\0")||n.includes("\\")||ks.encode(n).byteLength>t)throw new b(`${e} ${JSON.stringify(n)} must be a bounded relative POSIX path`);for(let r of n.split("/"))if(r.length===0||r==="."||r==="..")throw new b(`${e} ${JSON.stringify(n)} contains an unsafe path segment`)}function hi(n){for(let e of n)if(e!==0)return!1;return!0}function _i(n,e){try{return Qu.decode(n)}catch{throw new b(`${e} contains non-UTF-8 text`)}}function pd(n){return n instanceof Error?n.message:String(n)}var Ce,Ju,Ls,Qu,ks,ed,Fs,b,Cs=br(()=>{"use strict";ci();Ve();Ce=512,Ju=ie.S_MODE_BITS,Ls=1024*1024,Qu=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),ks=new TextEncoder,ed=ad(),Fs=Object.freeze({maxCompressedBytes:256*Ls,maxUncompressedBytes:512*Ls,maxEntries:1e5,maxPathBytes:4096,maxLinkBytes:65536}),b=class extends Error{constructor(e){super(e),this.name="TarParseError"}}});import{existsSync as xr,lstatSync as yn,readdirSync as Oa,readFileSync as pt,realpathSync as Le,statSync as rt}from"node:fs";import{createHash as Aa}from"node:crypto";import{spawnSync as Di}from"node:child_process";import{basename as Sl,dirname as Tr,isAbsolute as _n,join as U,relative as wl,resolve as Re,sep as Ol}from"node:path";import{fileURLToPath as Al}from"node:url";Ve();var Ca=Uint8Array.from(co);function T(n,e){let t=0,r=0,i=e;for(;;){let o=n[i++];if(t|=(o&127)<=n.length)throw new Error(`${r} is truncated`);if((n[e++]&128)===0)return e}throw new Error(`${r} has an overlong LEB128 encoding`)}function xe(n,e,t){if(e>=n.length)throw new Error(`${t} is truncated`);let r=n[e++];switch(r){case 127:case 126:case 125:case 124:case 123:case 117:case 116:case 115:case 114:case 113:case 112:case 111:case 110:case 109:case 108:case 107:case 106:case 105:case 104:return{code:r,shared:!1,next:e};case 98:case 99:case 100:{let i=n[e]===101;i&&e++;let o=vo(n,e,5,`${t} heap type`),[s]=bo(n,e);return{code:r,heapType:Number(s),shared:i,next:o}}default:throw new Error(`${t} has unknown value type 0x${r.toString(16)}`)}}function Ma(n,e,t){return n[e]===120||n[e]===119?{code:n[e],shared:!1,next:e+1}:xe(n,e,t)}function Da(n,e){let t=n[e];if(t===64||t===127||t===126||t===125||t===124||t===123||t===112||t===111)return e+1;let[,r]=Fn(n,e);return e+r}function Ka(n,e,t){let[r,i]=T(n,e);e+=i;let o=[],s=[];for(let d=0;d=n.length)throw new Error(`${t} mutability is truncated`);let i=n[e++];if(i!==0&&i!==1)throw new Error(`${t} has invalid mutability ${i}`);return e}function Ba(n,e,t,r){if(e===101){if(t>=n.length)throw new Error(`${r} shared type is truncated`);e=n[t++]}for(let i of[76,77]){if(e!==i)continue;let[,o]=T(n,t);if(t+=o,t>=n.length)throw new Error(`${r} descriptor is truncated`);e=n[t++]}if(e===96)return Ka(n,t,r);if(e===95){let[i,o]=T(n,t);t+=o;for(let s=0;s=n.length)throw new Error(`${t} is truncated`);let r=n[e++];if(r===79||r===80){let[i,o]=T(n,e);e+=o;for(let s=0;s=n.length)throw new Error(`${t} body is truncated`);r=n[e++]}return Ba(n,r,e,t)}function $a(n,e){let[t,r]=T(n,e);e+=r;let i=[];for(let o=0;o=21&&r<=34?Xt(e,t):r===84||r>=92&&r<=99||r>=112&&r<=123||r>=124&&r<=131||r>=156&&r<=159?t+1:t:n===254?r===0||r===1||r===2?Xt(e,t):r===3?t:r>=16&&r<=79?Xt(e,t):null:null}function Wa(n,e,t){let[r,i]=T(n,e);e+=i+r;let[o,s]=T(n,e);e+=s+o;let a=n[e++];if(a===0){t.funcImports++;let[,c]=T(n,e);e+=c}else if(a===1)e=xe(n,e,"table import type").next,e=Ze(n,e).next;else if(a===2)e=Ze(n,e).next;else if(a===3)t.globalImports++,e=xe(n,e,"global import type").next,e++;else if(a===4){e++;let[,c]=T(n,e);e+=c}return e}function $r(n){return n.length>=8&&n[0]===0&&n[1]===97&&n[2]===115&&n[3]===109}function qe(n,e){let[t,r]=T(n,e);return e+=r,[new TextDecoder().decode(n.subarray(e,e+t)),e+t]}function Ga(n,e){if(e.length===0)return!0;let t=new TextEncoder().encode(e);e:for(let r=0;r<=n.length-t.length;r++){for(let i=0;in);function To(n,e){switch(n.code){case 127:return Sn;case 126:return wn;case 125:return On;case 124:return An;case 123:return zn;case 112:case 115:return gt;case 111:case 114:return Ut;case 105:case 116:return Wt;case 104:case 106:case 107:case 108:case 109:case 110:case 113:case 117:return Gt;case 98:case 99:case 100:{let t=n.heapType;return t===void 0?null:t===-16||t===-13?gt:t===-17||t===-14?Ut:t===-23||t===-12?Wt:t>=0&&e[t]!==void 0?gt:Gt}default:return null}}function vn(n,e,t){if(!t)throw new Error(`function ${e} refers to an unknown type`);let r=n.get(e)??[];r.push(t),n.set(e,r)}function Yt(n,e,t){let r=n.get(e)??[];r.push(t),n.set(e,r)}function Ze(n,e){let[t,r]=T(n,e);e+=r;let[i,o]=T(n,e);e+=o;let s=null;if((t&1)!==0){let[a,c]=T(n,e);e+=c,s=a}return{flags:t,minimum:i,maximum:s,next:e}}function Va(n){let e=new Uint8Array(n);if(!$r(e))throw new Error("not a wasm binary");let t=[],r=[],i=[],o={functionTypes:t,functionTypeIndices:r,functionImports:new Map,functionImportEntries:[],globalImports:new Map,tableImports:new Map,tables:[],tagImports:new Map,functionExports:new Map,globalExports:new Map,tableExports:new Map,exports:new Map,memoryPointerWidths:[],forkCapabilities:[],linkedFrameDescriptors:[],exceptionCodecDescriptors:[],importedGlobalsDescriptors:[],importedTablesDescriptors:[],moduleStateDescriptors:[],staticRootDescriptors:[],unwindTransportDescriptors:[],nativeStartCount:0,importsKernelFork:!1},s=0,a=0,c=8;for(;ce.length)throw new Error("wasm section exceeds file size");let f=h,p=!1;if(u===0){let[_,y]=qe(e,f);_===Bt?o.linkedFrameDescriptors.push(e.slice(y,m)):_===Pe?o.forkCapabilities.push(e.slice(y,m)):_===ze?o.exceptionCodecDescriptors.push(e.slice(y,m)):_===Y?o.importedGlobalsDescriptors.push(e.slice(y,m)):_===X?o.importedTablesDescriptors.push(e.slice(y,m)):_===ne?o.moduleStateDescriptors.push(e.slice(y,m)):_===Ge?o.staticRootDescriptors.push(e.slice(y,m)):_===Et&&o.unwindTransportDescriptors.push(e.slice(y,m))}else if(u===1){p=!0;let _=$a(e,f);t.push(..._.types),f=_.next}else if(u===2){p=!0;let[_,y]=T(e,f);f+=y;for(let g=0;g<_;g++){let[E,O]=qe(e,f),[S,w]=qe(e,O);f=w;let z=e[f++];if(z===0){let[x,I]=T(e,f);f+=I;let R=r.length;r.push(x);let L=`${E}.${S}`,v=t[x];vn(o.functionImports,L,v),o.functionImportEntries.push({module:E,name:S,importOrdinal:g,functionIndex:R,signature:v}),L==="kernel.kernel_fork"&&(o.importsKernelFork=!0)}else if(z===1){let x=xe(e,f,`table import ${E}.${S}`);f=x.next;let I=Ze(e,f);f=I.next,Yt(o.tableImports,`${E}.${S}`,{module:E,name:S,importOrdinal:g,index:a++,elementType:x.code,recipeTypeCode:To(x,t),table64:(I.flags&4)!==0,minimum:I.minimum,maximum:I.maximum}),o.tables.push({elementType:x.code,table64:(I.flags&4)!==0,minimum:I.minimum,maximum:I.maximum})}else if(z===2){let x=Ze(e,f);f=x.next,o.memoryPointerWidths.push((x.flags&4)!==0?8:4)}else if(z===3){let x=xe(e,f,`global import ${E}.${S}`);if(f=x.next,f>=e.length)throw new Error(`global import ${E}.${S} is truncated`);let I=e[f++];if((I&-4)!==0)throw new Error(`global import ${E}.${S} has invalid flags ${I}`);Yt(o.globalImports,`${E}.${S}`,{module:E,name:S,importOrdinal:g,index:s++,valueType:x.code,recipeTypeCode:To(x,t),mutable:(I&1)!==0,shared:(I&2)!==0})}else if(z===4){let x=e[f++];if(x!==0)throw new Error(`unsupported wasm tag attribute ${x}`);let[I,R]=T(e,f);f+=R,vn(o.tagImports,`${E}.${S}`,t[I])}else throw new Error(`unsupported wasm import kind ${z}`)}}else if(u===3){p=!0;let[_,y]=T(e,f);f+=y;for(let g=0;g<_;g++){let[E,O]=T(e,f);f+=O,r.push(E)}}else if(u===4){p=!0;let[_,y]=T(e,f);f+=y;for(let g=0;g<_;g++){let E=xe(e,f,`defined table ${g}`);f=E.next;let O=Ze(e,f);f=O.next,o.tables.push({elementType:E.code,table64:(O.flags&4)!==0,minimum:O.minimum,maximum:O.maximum})}}else if(u===5){p=!0;let[_,y]=T(e,f);f+=y;for(let g=0;g<_;g++){let E=Ze(e,f);f=E.next,o.memoryPointerWidths.push((E.flags&4)!==0?8:4)}}else if(u===7){p=!0;let[_,y]=T(e,f);f+=y;for(let g=0;g<_;g++){let[E,O]=qe(e,f);f=O;let S=e[f++],[w,z]=T(e,f);f+=z,Yt(o.exports,E,{kind:S,index:w}),S===0?i.push({name:E,index:w}):S===3?Yt(o.globalExports,E,w):S===1&&Yt(o.tableExports,E,w)}}else if(u===8){p=!0,o.nativeStartCount++;let[,_]=T(e,f);f+=_}if(p&&f!==m)throw new Error(`malformed wasm section ${u}`);c=m}for(let{name:u,index:l}of i){let d=r[l];vn(o.functionExports,u,t[d])}return o}function qa(n){if(n.byteLength!==$t)throw new Error(`linked-frame descriptor has ${n.byteLength} bytes, expected ${$t}`);if(!Zi.every((a,c)=>n[c]===a))throw new Error("linked-frame descriptor has invalid magic");let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=e.getUint16(4,!0);if(t!==1)throw new Error(`linked-frame descriptor version ${t} is unsupported`);let r=e.getUint16(6,!0);if(r!==$t)throw new Error(`linked-frame descriptor declares size ${r}, expected ${$t}`);let i=e.getUint8(8),o=Xi.find(({bytes:a})=>a===i);if(!o)throw new Error(`linked-frame descriptor pointer width ${i} is unsupported`);if(e.getUint8(9)!==Yi)throw new Error(`linked-frame descriptor alignment ${e.getUint8(9)} is unsupported`);let s=e.getUint16(10,!0);if(s!==gn)throw new Error(`linked-frame descriptor flags 0x${s.toString(16)} do not equal required flags 0x${gn.toString(16)}`);if(e.getUint32(12,!0)!==o.chunkHeaderSize||e.getUint32(16,!0)!==o.nodeHeaderSize)throw new Error(`linked-frame descriptor header sizes do not match its ${i}-byte pointer width`);return o.bytes}function Za(n){if(n.length===0)return[`missing required ${Pe} capability`];if(n.length!==1)return[`has ${n.length} ${Pe} sections, expected exactly one`];let e=n[0];if(e.byteLength!==2)return[`${Pe} has ${e.byteLength} bytes, expected 2`];if(e[0]!==io)return[`${Pe} version ${e[0]} is unsupported`];let t=e[1];return(t&~oo)!==0?[`${Pe} has unknown flags 0x${t.toString(16)}`]:(t&Fr)!==Fr?[`${Pe} flags 0x${t.toString(16)} omit required activation-state safety flags 0x${Fr.toString(16)}`]:[]}function Ya(n){let e=[],t=`${Cr}.${Mr}`,r=n.tagImports.get(t);if(r?r.length!==1?e.push(`duplicate private fork-unwind tag import ${t}`):(r[0].params.length!==0||r[0].results.length!==0)&&e.push(`private fork-unwind tag ${t} must have an empty payload`):e.push(`missing required private fork-unwind tag import ${t}`),n.unwindTransportDescriptors.length===0)e.push(`missing required ${Et} descriptor`);else if(n.unwindTransportDescriptors.length!==1)e.push(`has ${n.unwindTransportDescriptors.length} ${Et} descriptors, expected exactly one`);else{let i=n.unwindTransportDescriptors[0];(i.length!==2||i[0]!==In||i[1]!==Tn)&&e.push(`${Et} must be [${In}, ${Tn}]`)}return e}function Xa(n,e){if(n.length===0)return[`missing required ${ne} descriptor`];if(n.length!==1)return[`has ${n.length} ${ne} descriptors, expected exactly one`];let t=n[0];if(t.byteLength!==vr)return[`${ne} has ${t.byteLength} bytes, expected ${vr}`];if(!Ji.every((p,_)=>t[_]===p))return[`${ne} has invalid magic`];let r=new DataView(t.buffer,t.byteOffset,t.byteLength),i=r.getUint16(4,!0),o=r.getUint16(6,!0),s=r.getUint8(8),a=no.find(({bytes:p})=>p===s),c=r.getUint8(9),u=r.getUint16(10,!0),l=r.getUint16(12,!0),d=r.getUint16(14,!0),h=r.getUint32(16,!0),m=r.getUint32(20,!0),f=[];return i!==ji&&f.push(`${ne} version ${i} is unsupported`),o!==vr&&f.push(`${ne} declares size ${o}`),a?e!==null&&s!==e&&f.push(`${ne} pointer width ${s} does not match linked frames ${e}`):f.push(`${ne} pointer width ${s} is unsupported`),c!==Qi&&f.push(`${ne} alignment ${c} is unsupported`),u!==En&&f.push(`${ne} flags 0x${u.toString(16)} do not equal required flags 0x${En.toString(16)}`),l!==eo&&f.push(`${ne} arena version ${l} is unsupported`),d!==to&&f.push(`${ne} record version ${d} is unsupported`),h!==ro&&f.push(`${ne} root word ${h} is unsupported`),m!==0&&f.push(`${ne} reserved field is nonzero`),f}function ja(n){if(n.length===0)return[`missing required ${ze} descriptor`];if(n.length!==1)return[`has ${n.length} ${ze} descriptors, expected exactly one`];let e=n[0];if(e.byteLength2147483647||s.has(l))&&r.push(`${ze} layout id ${l} is invalid or duplicated`),s.add(l)}return r}var Ja=new Set([Sn,wn,On,An,zn,gt,Ut,Wt,Gt]);function Ro(n){return!(n.module===Rn&&(n.name===Ln||n.name==="__channel_base"||n.name==="__wpk_fork_module_state_table_generation_addr"))}function Qa(n){let e=n.importedGlobalsDescriptors;if(e.length===0)return[`missing required ${Y} descriptor`];if(e.length!==1)return[`has ${e.length} ${Y} descriptors, expected exactly one`];let t=e[0];if(t.byteLengtht[_]===p)||i.push(`${Y} has invalid magic`),r.getUint16(4,!0)!==lo&&i.push(`${Y} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Kr&&i.push(`${Y} declares an invalid header size`);let o=r.getUint32(8,!0);r.getUint32(12,!0)!==0&&i.push(`${Y} reserved field is nonzero`);let s=new Set,a=new Set,c=new TextDecoder("utf-8",{fatal:!0}),u=[],l=-1,d=Kr;for(let p=0;pt.byteLength)return i.push(`${Y} record ${p} header is truncated`),i;let _=r.getUint32(d,!0),y=r.getUint32(d+4,!0),g=r.getUint8(d+8),E=r.getUint8(d+9),O=r.getUint32(d+12,!0),S=r.getUint32(d+16,!0),w=r.getUint32(d+20,!0),z=Vt+O+S;if(!Number.isSafeInteger(z)||_!==z||_t.byteLength)return i.push(`${Y} record ${p} has invalid bounds`),i;(y===0||s.has(y))&&i.push(`${Y} record ${p} has invalid or duplicated owner ${y}`),s.add(y),Ja.has(g)||i.push(`${Y} record ${p} has unknown value type ${g}`),(E&~ho)!==0&&i.push(`${Y} record ${p} has unknown flags 0x${E.toString(16)}`),r.getUint16(d+10,!0)!==0&&i.push(`${Y} record ${p} reserved fields are nonzero`),(a.has(w)||w<=l)&&i.push(`${Y} record ${p} has duplicated or unordered import ordinal`),a.add(w),l=w;let x=d+Vt;try{let I=c.decode(t.subarray(x,x+O)),R=c.decode(t.subarray(x+O,x+O+S));u.push({ownerId:y,typeCode:g,flags:E,importOrdinal:w,module:I,name:R})}catch{i.push(`${Y} record ${p} contains invalid UTF-8`)}d+=_}d!==t.byteLength&&i.push(`${Y} has trailing bytes`);let h=[...n.globalImports.values()].flat(),m=new Map(h.map(p=>[p.index,p])),f=new Set;for(let p of u){let _=`${Pr}${p.ownerId}`,y=n.exports.get(_);if(!y||y.length!==1||y[0].kind!==3){i.push(`${Y} owner ${p.ownerId} lacks exactly one global catalog export ${_}`);continue}let g=m.get(y[0].index);if(!g||!Ro(g)){i.push(`${Y} owner ${p.ownerId} does not identify a reconstructible imported global`);continue}if(g.module!==p.module||g.name!==p.name||g.importOrdinal!==p.importOrdinal||g.recipeTypeCode!==p.typeCode||g.mutable!==((p.flags&fo)!==0)||g.shared!==((p.flags&po)!==0)){i.push(`${Y} owner ${p.ownerId} does not match its imported global declaration`);continue}if(f.has(g.index)){i.push(`${Y} repeats imported global index ${g.index}`);continue}f.add(g.index)}for(let p of h)Ro(p)&&!f.has(p.index)&&i.push(`${Y} omits imported global ${p.module}.${p.name} at index ${p.index}`);for(let[p,_]of n.exports){if(!p.startsWith(Pr))continue;let y=p.slice(Pr.length),g=Number(y);(!/^[1-9][0-9]*$/.test(y)||!Number.isSafeInteger(g)||g>4294967295||_.length!==1||_[0].kind!==3)&&i.push(`malformed reserved fork global catalog export ${p}`)}return i}var ec=new Set([gt,Ut,Wt,Gt]);function Lo(n){return!bn.some(({module:e,name:t})=>n.module===e&&n.name===t)}function tc(n){let e=n.importedTablesDescriptors;if(e.length===0)return[`missing required ${X} descriptor`];if(e.length!==1)return[`has ${e.length} ${X} descriptors, expected exactly one`];let t=e[0];if(t.byteLengtht[_]===p)||i.push(`${X} has invalid magic`),r.getUint16(4,!0)!==yo&&i.push(`${X} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Br&&i.push(`${X} declares an invalid header size`);let o=r.getUint32(8,!0);r.getUint32(12,!0)!==0&&i.push(`${X} reserved field is nonzero`);let s=new Set,a=new Set,c=new TextDecoder("utf-8",{fatal:!0}),u=[],l=-1,d=Br;for(let p=0;pt.byteLength)return i.push(`${X} record ${p} header is truncated`),i;let _=r.getUint32(d,!0),y=r.getUint32(d+4,!0),g=r.getUint8(d+8),E=r.getUint8(d+9),O=r.getUint32(d+12,!0),S=r.getUint32(d+16,!0),w=r.getUint32(d+20,!0),z=qt+O+S;if(!Number.isSafeInteger(z)||_!==z||_t.byteLength)return i.push(`${X} record ${p} has invalid bounds`),i;(y===0||s.has(y))&&i.push(`${X} record ${p} has invalid or duplicated owner ${y}`),s.add(y),ec.has(g)||i.push(`${X} record ${p} has unknown element type ${g}`),(E&~go)!==0&&i.push(`${X} record ${p} has unknown flags 0x${E.toString(16)}`),r.getUint16(d+10,!0)!==0&&i.push(`${X} record ${p} reserved fields are nonzero`),(a.has(w)||w<=l)&&i.push(`${X} record ${p} has duplicated or unordered import ordinal`),a.add(w),l=w;let x=d+qt;try{let I=c.decode(t.subarray(x,x+O)),R=c.decode(t.subarray(x+O,x+O+S));u.push({ownerId:y,typeCode:g,flags:E,importOrdinal:w,module:I,name:R})}catch{i.push(`${X} record ${p} contains invalid UTF-8`)}d+=_}d!==t.byteLength&&i.push(`${X} has trailing bytes`);let h=[...n.tableImports.values()].flat(),m=new Map(h.map(p=>[p.index,p])),f=new Set;for(let p of u){let _=`${kr}${p.ownerId}`,y=n.exports.get(_);if(!y||y.length!==1||y[0].kind!==1){i.push(`${X} owner ${p.ownerId} lacks exactly one table catalog export ${_}`);continue}let g=m.get(y[0].index);if(!g||!Lo(g)){i.push(`${X} owner ${p.ownerId} does not identify a reconstructible imported table`);continue}if(g.module!==p.module||g.name!==p.name||g.importOrdinal!==p.importOrdinal||g.recipeTypeCode!==p.typeCode||g.table64!==((p.flags&_o)!==0)){i.push(`${X} owner ${p.ownerId} does not match its imported table declaration`);continue}if(f.has(g.index)){i.push(`${X} repeats imported table index ${g.index}`);continue}f.add(g.index)}for(let p of h)Lo(p)&&!f.has(p.index)&&i.push(`${X} omits imported table ${p.module}.${p.name} at index ${p.index}`);for(let[p,_]of n.exports){if(!p.startsWith(kr))continue;let y=p.slice(kr.length),g=Number(y);(!/^[1-9][0-9]*$/.test(y)||!Number.isSafeInteger(g)||g>4294967295||_.length!==1||_[0].kind!==1)&&i.push(`malformed reserved fork table catalog export ${p}`)}return i}function Nn(n,e){switch(n){case"ptr":return e===8?126:127;case"i32":return 127;case"i64":return 126;case"anyref":return 110;case"exnref":return 105;case"externref":return 111;case"funcref":return 112}}function Pn(n,e,t,r){return n.params.length===e.length&&n.results.length===t.length&&n.params.every((i,o)=>i===Nn(e[o],r))&&n.results.every((i,o)=>i===Nn(t[o],r))}function kn(n,e,t){let r=i=>i==="ptr"?t===8?"i64":"i32":i;return`(${n.map(r).join(", ")}) -> (${e.map(r).join(", ")})`}function rc(n){let e=`${Rn}.${Ln}`,t=n.globalImports.get(e);return t?t.length!==1?[`duplicate exception-codec activation import ${e}`]:t[0].valueType!==127||t[0].mutable?[`exception-codec activation import ${e} must be immutable i32`]:[]:[`missing required immutable exception-codec activation import ${e}`]}function nc(n){let e=[];for(let t of bn){let r=`${t.module}.${t.name}`,i=n.tableImports.get(r);if(!i){e.push(`missing required ABI 43 fork-runtime table import ${r}`);continue}if(i.length!==1){e.push(`duplicate ABI 43 fork-runtime table import ${r}`);continue}let o=i[0],s=Nn(t.element,4);(o.elementType!==s||o.table64!==t.table64||o.minimum!==t.minimum||o.maximum!==t.maximum)&&e.push(`ABI 43 fork-runtime table import ${r} has the wrong type or limits`)}return e}function ic(n){if(n.staticRootDescriptors.length===0)return[`missing required ${Ge} descriptor`];if(n.staticRootDescriptors.length!==1)return[`has ${n.staticRootDescriptors.length} ${Ge} descriptors, expected exactly one`];let e=n.staticRootDescriptors[0];if(e.byteLength!==Dr)return[`${Ge} has ${e.byteLength} bytes, expected ${Dr}`];let t=[];Ca.some((u,l)=>e[l]!==u)&&t.push(`${Ge} has invalid magic`);let r=new DataView(e.buffer,e.byteOffset,e.byteLength);r.getUint16(4,!0)!==ao&&t.push(`${Ge} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Dr&&t.push(`${Ge} declares an invalid header size`);let i=r.getUint32(8,!0),o=n.tableExports.get(Ht);if(!o||o.length!==1)return t.push(`missing exactly one table export ${Ht}`),t;let s=[...n.tableImports.values()].reduce((u,l)=>u+l.length,0),a=o[0],c=n.tables[a];return a!n.functionExports.has(c)).map(({name:c})=>c);if(t.length>0&&e.push(`incomplete wasm-fork-instrument exports; missing ${t.join(", ")}`),n.importsKernelFork){let c=`${ot.module}.${ot.name}`,u=n.functionImports.get(c);u?.length!==1?e.push(`duplicate ABI 43 process-fork import ${c}`):Pn(u[0],ot.params,ot.results,4)||e.push(`ABI 43 process-fork import ${c} has the wrong signature; expected ${kn(ot.params,ot.results,4)}`)}let r=null;if(n.linkedFrameDescriptors.length===0)e.push(`missing required ${Bt} descriptor`);else if(n.linkedFrameDescriptors.length!==1)e.push(`has ${n.linkedFrameDescriptors.length} ${Bt} descriptors, expected exactly one`);else try{r=qa(n.linkedFrameDescriptors[0])}catch(c){e.push(c instanceof Error?c.message:String(c))}e.push(...Xa(n.moduleStateDescriptors,r));let i=St.filter(({module:c,name:u})=>n.functionImports.has(`${c}.${u}`)),o=`${Cr}.${Mr}`,s=n.importsKernelFork||i.length>0;if((s||n.tagImports.has(o)||n.unwindTransportDescriptors.length>0)&&e.push(...Ya(n)),s){let c=St.filter(({module:u,name:l})=>!n.functionImports.has(`${u}.${l}`)).map(({module:u,name:l})=>`${u}.${l}`);c.length>0&&e.push(`incomplete ABI 43 fork-runtime imports; missing ${c.join(", ")}`);for(let u of St){let l=`${u.module}.${u.name}`,d=n.functionImports.get(l);d&&d.length!==1&&e.push(`duplicate ABI 43 fork-runtime import ${l}`)}}if(r!==null){if(n.memoryPointerWidths.length!==1)e.push(`ABI 43 fork instrumentation requires exactly one module memory, found ${n.memoryPointerWidths.length}`);else if(n.memoryPointerWidths[0]!==r){let c=r===8?"an":"a";e.push(`ABI 43 linked-frame descriptor declares ${c} ${r}-byte pointer but the module memory uses ${n.memoryPointerWidths[0]}-byte addresses`)}for(let c of Zt){let u=n.functionExports.get(c.name);u?.length===1&&!Pn(u[0],c.params,c.results,r)&&e.push(`ABI 43 wasm-fork-instrument export ${c.name} has the wrong signature; expected ${kn(c.params,c.results,r)}`)}if(s)for(let c of St){let u=`${c.module}.${c.name}`,l=n.functionImports.get(u);l?.length===1&&!Pn(l[0],c.params,c.results,r)&&e.push(`ABI 43 fork-runtime import ${u} has the wrong signature; expected ${kn(c.params,c.results,r)}`)}}return e}function Po(n,e){switch(n){case 0:return"function";case 1:return"table";case 2:return"memory";case 3:return"global";case 4:return"tag";default:throw new Error(`${e} has unsupported external kind ${n}`)}}function sc(n){let e=new Uint8Array(n);if(!$r(e))return[];let t=[],r=8;for(;r`${e}.${t}`)}function cc(n){let e=new Uint8Array(n);if(!$r(e))return[];let t=[],r=8;for(;re)}function ko(n){let e=new Uint8Array(n);if(!$r(e))return[];let t=[],r=8;for(;rt.startsWith("reloc."))}function Fo(n,e={}){let t=[],r=null;dc(n)&&t.push("contains asyncify_"),e.expectedAbi!==void 0&&e.expectedAbi!==null&&(r=pc(n),r!==null&&r!==e.expectedAbi&&t.push(`ABI ${r}, expected ${e.expectedAbi}`));let i=new Set(uc(n));if(e.requiredExports){let E=e.requiredExports.filter(O=>!i.has(O));E.length>0&&t.push(`missing required exports: ${E.join(", ")}`)}if(e.forbiddenExports){let E=e.forbiddenExports.filter(O=>i.has(O));E.length>0&&t.push(`forbidden exports present: ${E.join(", ")}`)}let o=Ha.filter(E=>i.has(E)),s=ac(n),a=ko(n),c=St.filter(({module:E,name:O})=>s.includes(`${E}.${O}`)),u=a.filter(E=>E===Bt).length,l=a.filter(E=>E===Pe).length,d=a.filter(E=>E===ne).length,h=a.filter(E=>E===ze).length,m=a.filter(E=>E===Y).length,f=a.filter(E=>E===X).length,p=a.filter(E=>E===Et).length,_=s.includes(`${Cr}.${Mr}`),y=o.length>0||c.length>0||u>0||l>0||d>0||h>0||m>0||f>0||p>0||_;if(e.expectedAbi!==void 0&&e.expectedAbi!==null&&y&&r===null&&t.push(`ABI ${e.expectedAbi} fork artifact is missing __abi_version; the activation-state capability epoch cannot be verified`),e.forbidForkInstrumentation&&y&&t.push("contains ABI 43 wasm-fork-instrument metadata, imports, or exports"),(e.requireForkInstrumentation??!lc(n))&&(y||s.includes("kernel.kernel_fork")))try{t.push(...oc(Va(n)))}catch(E){t.push(`cannot validate ABI 43 fork-artifact contract: ${E instanceof Error?E.message:String(E)}`)}return t}function fc(n,e){let t=new Uint8Array(n);if(t.length<8)return null;let r=0,i=null,o=null,s=8;for(;s=c)return null;let p=a;for(let g=0;g=f)return null;let[p,_]=T(t,m);m+=_;for(let y=0;yf)return null}return m}function h(m,f=0){if(f>4)return null;let p=l(m);if(!p)return null;let _=d(p.start,p.end);if(_===null)return null;let y=_,g=p.end;for(;y=32&&E<=38||E===208){let[,O]=T(t,y);y+=O}else if(E>=40&&E<=62)y=Xt(t,y);else if(E===63||E===64)y++;else if(E===66){let[,O]=bo(t,y);y+=O}else if(E===67)y+=4;else if(E===68)y+=8;else if(E===252||E===253||E===254){let O=Ua(E,t,y);if(O===null)return null;y=O}}return null}return h(i)}function pc(n){return fc(n,"__abi_version")}Ve();var hc=ArrayBuffer,J=Uint8Array,Ur=Uint16Array,mc=Int16Array;var Wr=Int32Array,Cn=function(n,e,t){if(J.prototype.slice)return J.prototype.slice.call(n,e,t);(e==null||e<0)&&(e=0),(t==null||t>n.length)&&(t=n.length);var r=new J(t-e);return r.set(n.subarray(e,t)),r},Jt=function(n,e,t,r){if(J.prototype.fill)return J.prototype.fill.call(n,e,t,r);for((t==null||t<0)&&(t=0),(r==null||r>n.length)&&(r=n.length);tn.length)&&(r=n.length);t2046MB)","invalid block type","FSE accuracy too high","match distance too far back","unexpected EOF"],Q=function(n,e,t){var r=new Error(e||_c[n]);if(r.code=n,Error.captureStackTrace&&Error.captureStackTrace(r,Q),!t)throw r;return r},No=function(n,e,t){for(var r=0,i=0;r>>0},Ec=function(n,e){var t=n[0]|n[1]<<8|n[2]<<16;if(t==3126568&&n[3]==253){var r=n[4],i=r>>5&1,o=r>>2&1,s=r&3,a=r>>6;r&8&&Q(0);var c=6-i,u=s==3?4:s,l=No(n,c,u);c+=u;var d=a?1<>3);m=f+(f>>3)*(n[5]&7)}m>2145386496&&Q(1);var p=new J((e==1?h||m:e?0:m)+12);return p[0]=1,p[4]=4,p[8]=8,{b:c+d,y:0,l:0,d:l,w:e&&e!=1?e:p.subarray(12),e:m,o:new Wr(p.buffer,0,3),u:h,c:o,m:Math.min(131072,m)}}else if((t>>4|n[3]<<20)==25481893)return gc(n,4)+8;Q(0)},st=function(n){for(var e=0;1<t&&Q(3);for(var o=1<0;){var g=st(s+1),E=r>>3,O=(1<>(r&7)&O,w=(1<w&&(S-=z)),h[++a]=--S,S==-1?(s+=S,_[--l]=a):s-=S,!S)do{var I=r>>3;c=(n[I]|n[I+1]<<8)>>(r&7)&3,r+=2,a+=c}while(c==3)}(a>255||s)&&Q(0);for(var R=0,L=(o>>1)+(o>>3)+3,v=o-1,Z=0;Z<=a;++Z){var N=h[Z];if(N<1){m[Z]=-N;continue}for(u=0;u=l)}}for(R&&Q(0),u=0;u>3,{b:i,s:_,n:y,t:f}]},Sc=function(n,e){var t=0,r=-1,i=new J(292),o=n[e],s=i.subarray(0,256),a=i.subarray(256,268),c=new Ur(i.buffer,268);if(o<128){var u=Qt(n,e+1,6),l=u[0],d=u[1];e+=o;var h=l<<3,m=n[e];m||Q(0);for(var f=0,p=0,_=d.b,y=_,g=(++e<<3)-8+st(m);g-=_,!(g>3;if(f+=(n[E]|n[E+1]<<8)>>(g&7)&(1<<_)-1,s[++r]=d.s[f],g-=y,g>3,p+=(n[E]|n[E+1]<<8)>>(g&7)&(1<255&&Q(0)}else{for(r=o-127;t>4,s[t+1]=O&15}++e}var S=0;for(t=0;t11&&Q(0),S+=w&&1<0;--t){var Z=c[t];Jt(v,t,Z,c[t-1]=Z+a[t]*(1<a&&d>3,m=(n[h]|n[h+1]<<8|n[h+2]<<16)>>(l&7);c=(c<>2,s=o<<1,a=o+s;jt(n.subarray(r,r+=n[0]|n[1]<<8),e.subarray(0,o),t),jt(n.subarray(r,r+=n[2]|n[3]<<8),e.subarray(o,s),t),jt(n.subarray(r,r+=n[4]|n[5]<<8),e.subarray(s,a),t),jt(n.subarray(r),e.subarray(a),t)},Tc=function(n,e,t){var r,i=e.b,o=n[i],s=o>>1&3;e.l=o&1;var a=o>>3|n[i+1]<<5|n[i+2]<<13,c=(i+=3)+a;if(s==1)return i>=n.length?void 0:(e.b=i+1,t?(Jt(t,n[i],e.y,e.y+=a),t):Jt(new J(a),n[i]));if(!(c>n.length)){if(s==0)return e.b=c,t?(t.set(n.subarray(i,c),e.y),e.y+=a,t):Cn(n,i,c);if(s==2){var u=n[i],l=u&3,d=u>>2&3,h=u>>4,m=0,f=0;l<2?d&1?h|=n[++i]<<4|(d&2&&n[++i]<<12):h=u>>3:(f=d,d<2?(h|=(n[++i]&63)<<4,m=n[i]>>6|n[++i]<<2):d==2?(h|=n[++i]<<4|(n[++i]&3)<<12,m=n[i]>>2|n[++i]<<6):(h|=n[++i]<<4|(n[++i]&63)<<12,m=n[i]>>6|n[++i]<<2|n[++i]<<10)),++i;var p=t?t.subarray(e.y,e.y+e.m):new J(e.m),_=p.length-h;if(l==0)p.set(n.subarray(i,i+=h),_);else if(l==1)Jt(p,n[i++],_);else{var y=e.h;if(l==2){var g=Sc(n,i);m+=i-(i=g[0]),e.h=y=g[1]}else y||Q(0);(f?Ic:jt)(n.subarray(i,i+=m),p.subarray(_),y)}var E=n[i++];if(E){E==255?E=(n[i++]|n[i++]<<8)+32512:E>127&&(E=E-128<<8|n[i++]);var O=n[i++];O&3&&Q(0);for(var S=[Oc,Ac,wc],w=2;w>-1;--w){var z=O>>(w<<1)+2&3;if(z==1){var x=new J([0,0,n[i++]]);S[w]={s:x.subarray(2,3),n:x.subarray(0,1),t:new Ur(x.buffer,0,1),b:0}}else z==2?(r=Qt(n,i,9-(w&1)),i=r[0],S[w]=r[1]):z==3&&(e.t||Q(0),S[w]=e.t[w])}var I=e.t=S,R=I[0],L=I[1],v=I[2],Z=n[c-1];Z||Q(0);var N=(c<<3)-8+st(Z)-v.b,k=N>>3,G=0,ue=(n[k]|n[k+1]<<8)>>(N&7)&(1<>3;var Oe=(n[k]|n[k+1]<<8)>>(N&7)&(1<>3;var M=(n[k]|n[k+1]<<8)>>(N&7)&(1<>3;var yt=1<>>(N&7)&yt-1);k=(N-=Dn[nt])>>3;var $e=xc[nt]+((n[k]|n[k+1]<<8|n[k+2]<<16)>>(N&7)&(1<>3;var it=zc[de]+((n[k]|n[k+1]<<8|n[k+2]<<16)>>(N&7)&(1<>3,ue=v.t[ue]+((n[k]|n[k+1]<<8)>>(N&7)&(1<>3,M=R.t[M]+((n[k]|n[k+1]<<8)>>(N&7)&(1<>3,Oe=L.t[Oe]+((n[k]|n[k+1]<<8)>>(N&7)&(1<3)e.o[2]=e.o[1],e.o[1]=e.o[0],e.o[0]=Ae-=3;else{var _t=Ae-(it!=0);_t?(Ae=_t==3?e.o[0]-1:e.o[_t],_t>1&&(e.o[2]=e.o[1]),e.o[1]=e.o[0],e.o[0]=Ae):Ae=e.o[0]}for(var w=0;w$e&&(We=$e);for(var w=0;wvc)throw er("EOVERFLOW","file offset is outside signed i64");return n}function kc(n){if(Bn(n)<0n)throw er("EINVAL","negative positioned I/O offset");return n}function $n(n){let e=Bn(n);if(eKo)throw er("EOVERFLOW","backend cannot represent the file offset exactly");return Do(e)}function Un(n){let e=kc(n);return $n(e)}function Bo(n){if(n===null)return null;let e=Bn(n);if(e<0n)throw er("EINVAL","negative file-size limit");return e>Ko?null:Do(e)}Ve();var{ALLOC_SIZE_MIN:Fc,ASYNC_IO:Nc,CHOWN_RESTRICTED:Cc,FALLOC:Mc,FILESIZEBITS:Dc,LINK_MAX:Kc,MAX_CANON:Bc,MAX_INPUT:$c,NAME_MAX:Uc,NO_TRUNC:Wc,PATH_MAX:Gc,PIPE_BUF:Hc,POSIX2_SYMLINKS:Vc,PRIO_IO:qc,REC_INCR_XFER_SIZE:Zc,REC_MAX_XFER_SIZE:Yc,REC_MIN_XFER_SIZE:Xc,REC_XFER_ALIGN:jc,SOCK_MAXBUF:Jc,SYMLINK_MAX:Qc,SYNC_IO:eu,TEXTDOMAIN_MAX:tu,TIMESTAMP_RESOLUTION:ru,VDISABLE:nu}=zo,{S_IFDIR:iu,S_IFIFO:ou,S_IFMT:$o,S_IFREG:su}=ie;function Wn(n){let e=new Error(`EINVAL: pathconf name ${n} is not associated with this object`);throw e.code="EINVAL",e}function Gn(n,e,t){switch(e){case Kc:return null;case Uc:return 255;case Gc:return Oo;case Cc:return 1;case Wc:return 1;case Nc:return(n.mode&$o)===su?1:Wn(e);case eu:case qc:case Dc:case Zc:case Yc:case Xc:case jc:case Fc:case Qc:case Mc:return null;case Vc:return t.supportsSymlinks?1:null;case tu:return 255;case ru:return t.timestampResolutionNs;case Hc:{let r=n.mode&$o;return r===ou||r===iu?null:Wn(e)}case Bc:case $c:case nu:case Jc:return Wn(e);default:{let r=new Error(`EINVAL: invalid pathconf name ${e}`);throw r.code="EINVAL",r}}}Ve();Ve();var Uo=So.ST_NOSUID;var Gr=Math.floor(160),Hn=1397114451,Vn=1,tr=32768,H=16384,wt=40960,W=61440,au=2048,cu=1024,uu=73,Wo=4294967295,at=0,Go=1;var cr=64,ei=128,dr=512,du=1024,lu=65536,rr=3,fu=0,pu=1,hu=2,F=8,mu=64*1024,yu=-1,Ie=-2,$=-5,re=-9,jn=-16,Tt=-17,Fe=-20,ut=-21,j=-22,Qo=-24,dt=-27,se=-28,es=-30,Jn=-36,Qn=-39,ts=-40,rs=-75,qn=0,Zn=4,Hr=8,Ot=12,Ye=16,At=20,Vr=24,ct=28,qr=32,Ho=36,Zr=40,_u=44,gu=48,Eu=52,Yn=56,Yr=60,Xr=64,nr=68,Vo=72,zt=0,C=8,B=12,P=16,me=24,ee=32,ir=40,oe=48,or=88,xt=92,sr=96,ar=100,pe=104,Xe=112,qo=116,he=120,Zo=4,ke=8,Yo=16,Xo=20,jo=-2147483648,Su=2147483647,wu=1034+1024*1024,je=wu*4096,Ou={[Ie]:"No such file or directory",[$]:"I/O error",[re]:"Bad file descriptor",[jn]:"Device or resource busy",[Tt]:"File exists",[Fe]:"Not a directory",[ut]:"Is a directory",[j]:"Invalid argument",[Qo]:"Too many open files",[dt]:"File too large",[se]:"No space left on device",[es]:"Read-only file system",[Jn]:"File name too long",[Qn]:"Directory not empty",[ts]:"Too many symbolic links",[rs]:"Value too large for data type"},A=class extends Error{constructor(t,r){super(r||Ou[t]||`Error ${t}`);this.code=t;this.name="SFSError"}code},_e=new TextEncoder,ur=new TextDecoder,Jo=_e.encode("..");function Xn(n){return n==="."||n===".."}function It(n){return n.buffer instanceof SharedArrayBuffer?ur.decode(new Uint8Array(n)):ur.decode(n)}function Je(n){return n+3&-4}var le=class n{constructor(e){this.buffer=e;this.view=new DataView(e),this.i32=new Int32Array(e),this.u8=new Uint8Array(e)}buffer;view;i32;u8;dirIndexes=new Map;blockAllocHint=0;inodeAllocHint=2;atomicsWaitAllowed;static mkfs(e,t){let r=e.byteLength;if(r<4096*16)throw new A(j);let i=Math.floor(r/4096),o=t?Math.floor(t/4096):i*4,s=Math.floor(o/4);s<32&&(s=32),s=Math.ceil(s/32)*32;let a=Math.ceil(s/(4096*8)),c=Math.ceil(o/(4096*8)),u=Math.ceil(s*128/4096),l=1,d=l+a,h=d+c,m=h+u;if(m>=i){let x=(m+1)*4096;try{e.grow(x)}catch{throw new A(se)}if(i=Math.floor(e.byteLength/4096),m>=i)throw new A(se)}new Uint8Array(e).fill(0);let f=new n(e);f.w32(qn,Hn),f.w32(Zn,Vn),f.w32(Hr,4096),f.w32(Ot,i),f.w32(Ye,s),f.w32(ct,l),f.w32(qr,d),f.w32(Ho,h),f.w32(Zr,m),f.w32(_u,a),f.w32(gu,c),f.w32(Eu,u),f.w32(nr,o),f.w32(Vo,256);let p=d*4096;for(let x=0;x>2)+(x>>5);f.i32[I]|=1<<(x&31)}let _=i-m;Atomics.store(f.i32,At>>2,_),f.blockAllocHint=m;let y=l*4096;f.i32[y>>2]|=3,Atomics.store(f.i32,Vr>>2,s-2),f.inodeAllocHint=2;let g=f.inodeOffset(1);f.w32(g+C,H|493),f.w32(g+B,2),f.w64(g+pe,1);let E=f.blockAlloc();if(E<0)throw new A(se);f.w32(g+oe,E);let O=E*4096,S=Je(F+1),w=Je(F+2);f.w32(O,1),f.view.setUint16(O+4,S,!0),f.view.setUint16(O+6,1,!0),f.u8[O+F]=46;let z=O+S;return f.w32(z,1),f.view.setUint16(z+4,w,!0),f.view.setUint16(z+6,2,!0),f.u8[z+F]=46,f.u8[z+F+1]=46,f.w64(g+P,S+w),Atomics.store(f.i32,Yn>>2,1),f}static inspectImageCapacity(e){if(e.byteLengththis.snapshotBytesUnlocked(e))}snapshotState(e){return this.withNamespaceLock(()=>({bytes:this.snapshotBytesUnlocked(e),identities:this.collectIdentityStateUnlocked()}))}identityState(){return this.withNamespaceLock(()=>this.collectIdentityStateUnlocked())}snapshotBytesUnlocked(e){let t=e?.normalizeTimestampsMs;if(t!==void 0&&(!Number.isSafeInteger(t)||t<0))throw new A(j,"Snapshot timestamp must be a non-negative safe integer in milliseconds");let r=t===void 0?void 0:BigInt(t);for(let a=0;a>2)!==0)throw new A(jn,"Cannot save a VFS image with open descriptors")}let i=this.r32(Ye);for(let a=0;a=1&&this.inodeIsAllocated(a)?r:0n;s.setBigUint64(c+ir,u,!0),s.setBigUint64(c+me,u,!0),s.setBigUint64(c+ee,u,!0)}}return o}collectIdentityStateUnlocked(){let e=new Map,t=this.inodeOffset(1),r=this.r64(t+pe);e.set(`1:${r}`,{ino:1,generation:r,dataSequence:Atomics.load(this.i32,t+he>>2)>>>0,mode:this.r32(t+C),linkCount:this.r32(t+B),size:this.r64(t+P),uid:this.r32(t+sr),gid:this.r32(t+ar),paths:["/"]});let i=[{ino:1,path:"/"}],o=new Set;for(;i.length>0;){let s=i.pop();if(o.has(s.ino))throw new A($);o.add(s.ino);let a=this.inodeOffset(s.ino);if((this.r32(a+C)&W)!==H)throw new A($);let c=this.r64(a+P),u=0;for(;u>2)>>>0,mode:R,linkCount:this.r32(w+B),size:this.r64(w+P),uid:this.r32(w+sr),gid:this.r32(w+ar),...(R&W)===wt?{symlinkTarget:this.readSymlinkInodeUnlocked(y)}:{},paths:[]},e.set(x,I)}I.paths.push(S),(this.r32(w+C)&W)===H&&i.push({ino:y,path:S})}}p+=g}u+=f}}return e}statfs(){let e=this.r32(Hr),t=this.r32(Ot),r=this.r32(nr),i=typeof this.buffer.maxByteLength=="number"?this.buffer.maxByteLength:this.buffer.byteLength,o=Math.floor(i/e),s=Math.max(t,Math.min(r,o)),a=Atomics.load(this.i32,At>>2),c=Math.max(0,s-t);return{blockSize:e,totalBlocks:s,freeBlocks:a+c,totalInodes:this.r32(Ye),freeInodes:Atomics.load(this.i32,Vr>>2),maxName:255}}r32(e){return this.view.getUint32(e,!0)}w32(e,t){this.view.setUint32(e,t,!0)}r64(e){return Number(this.view.getBigUint64(e,!0))}w64(e,t){this.view.setBigUint64(e,BigInt(t),!0)}waitForAtomicChange(e,t){if(this.atomicsWaitAllowed!==!1)try{Atomics.wait(this.i32,e,t),this.atomicsWaitAllowed=!0;return}catch(r){if(!(r instanceof TypeError))throw r;this.atomicsWaitAllowed=!1}for(;Atomics.load(this.i32,e)===t;);}resetAllocationHints(){this.blockAllocHint=this.findNextFreeBlockHint(),this.inodeAllocHint=this.findNextFreeInodeHint()}findNextFreeBlockHint(){let e=this.r32(Ot),t=this.r32(Zr),r=this.r32(qr)*4096;for(let i=t;i>2)+(i>>5),s=i&31;if((Atomics.load(this.i32,o)&1<>2)+(r>>5),o=r&31;if((Atomics.load(this.i32,i)&1<>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}sbUnlock(){let e=Yr>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}namespaceLock(){let e=Xr>>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}namespaceUnlock(){let e=Xr>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}withNamespaceLock(e){this.namespaceLock();try{return e()}finally{this.namespaceUnlock()}}resetRestoredRuntimeState(){Atomics.store(this.i32,Yr>>2,0),Atomics.store(this.i32,Xr>>2,0),this.u8.fill(0,256,4096);let e=this.r32(Ye),t=this.r32(ct)*4096;for(let r=0;r>5)*4)&1<<(r&31))===0||this.r32(i+B)!==0)continue;let s=this.r32(i+C),a=this.r64(i+P);(s&W)===wt&&a<=40?(this.u8.fill(0,i+oe,i+oe+40),this.w64(i+P,0)):this.inodeTruncate(r,0),this.inodeFree(r)}}blockAlloc(){let e=this.r32(Ot),t=this.r32(qr)*4096,r=this.r32(Zr),i=this.blockAllocHint>=r&&this.blockAllocHint>2)+(a>>5),u=a&31,l=Atomics.load(this.i32,c);if(l&1<>2,1),this.blockAllocHint=a+1>2)+(e>>5),i=e&31;for(;;){let o=Atomics.load(this.i32,r),s=o&~(1<>2,1),e>=this.r32(Zr)&&e>2)>0)return 0;let e=this.r32(Ot),t=this.r32(nr),r=this.r32(Vo),i=e+r;if(i>t&&(i=t,r=i-e,r===0))return se;let o=i*4096;if(this.buffer.byteLength>2,r),Atomics.add(this.i32,Yn>>2,1),this.blockAllocHint=e,0}finally{this.sbUnlock()}}inodeOffset(e){let r=this.r32(Ho)+Math.floor(e/32),i=e%32*128;return r*4096+i}inodeAlloc(){let e=this.r32(Ye),t=this.r32(ct)*4096,r=this.inodeAllocHint>=2&&this.inodeAllocHint>2)+(s>>5),c=s&31,u=Atomics.load(this.i32,a);if(u&1<>2,1),this.inodeAllocHint=s+1>2,1)+1}inodeFree(e){let r=(this.r32(ct)*4096>>2)+(e>>5),i=e&31;for(;;){let o=Atomics.load(this.i32,r);if((o&1<>2,1),e>=2&&e0&&this.w32(r+Xe,i-1),i<=1&&this.r32(r+B)===0&&(this.inodeTruncate(e,0),t=!0)}finally{this.inodeWriteUnlock(e)}t&&this.inodeFree(e)}inodeDropLinkRefLocked(e){let t=this.inodeOffset(e),r=this.r32(t+B);return r>1?(this.w32(t+B,r-1),this.w64(t+ee,Date.now()),!1):this.inodeOrphanLocked(e)}inodeOrphanLocked(e){let t=this.inodeOffset(e);if(this.w32(t+B,0),this.w64(t+ee,Date.now()),this.r32(t+Xe)>0)return!1;let r=this.r32(t+C),i=this.r64(t+P);return(r&W)===wt&&i<=40?(this.u8.fill(0,t+oe,t+oe+40),this.w64(t+P,0)):this.inodeTruncate(e,0),!0}inodeReadLock(e){let t=this.inodeOffset(e)+zt>>2;for(;;){let r=Atomics.load(this.i32,t);if(r&jo){this.waitForAtomicChange(t,r);continue}if(Atomics.compareExchange(this.i32,t,r,r+1)===r)return}}inodeReadUnlock(e){let t=this.inodeOffset(e)+zt>>2;(Atomics.sub(this.i32,t,1)&Su)===1&&Atomics.notify(this.i32,t,1)}inodeWriteLock(e){let t=this.inodeOffset(e)+zt>>2;for(;;){let r=Atomics.load(this.i32,t);if(r!==0){this.waitForAtomicChange(t,r);continue}if(Atomics.compareExchange(this.i32,t,0,jo)===0)return}}inodeWriteUnlock(e){let t=this.inodeOffset(e)+zt>>2;Atomics.store(this.i32,t,0),Atomics.notify(this.i32,t,1/0)}inodeBlockMap(e,t,r){let i=this.inodeOffset(e);if(t<10){let o=this.r32(i+oe+t*4);if(o!==0)return o;if(!r)return 0;let s=this.blockAllocWithGrow();return s<0||this.w32(i+oe+t*4,s),s}if(t-=10,t<1024){let o=this.r32(i+or),s=!1;if(o===0){if(!r)return 0;if(o=this.blockAllocWithGrow(),o<0)return o;this.w32(i+or,o),s=!0}let a=o*4096+t*4,c=this.r32(a);if(c!==0)return c;if(!r)return 0;let u=this.blockAllocWithGrow();return u<0?(s&&(this.w32(i+or,0),this.blockFree(o)),u):(this.w32(a,u),u)}if(t-=1024,t<1024*1024){let o=Math.floor(t/1024),s=t%1024,a=this.r32(i+xt),c=!1;if(a===0){if(!r)return 0;if(a=this.blockAllocWithGrow(),a<0)return a;this.w32(i+xt,a),c=!0}let u=a*4096+o*4,l=this.r32(u),d=!1;if(l===0){if(!r)return 0;if(l=this.blockAllocWithGrow(),l<0)return c&&(this.w32(i+xt,0),this.blockFree(a)),l;this.w32(u,l),d=!0}let h=l*4096+s*4,m=this.r32(h);if(m!==0)return m;if(!r)return 0;let f=this.blockAllocWithGrow();return f<0?(d&&(this.w32(u,0),this.blockFree(l)),c&&(this.w32(i+xt,0),this.blockFree(a)),f):(this.w32(h,f),f)}return j}inodeReadData(e,t,r,i){let o=this.inodeOffset(e),s=this.r64(o+P);if(t>=s)return 0;t+i>s&&(i=s-t);let a=0,c=0;for(;i>0;){let u=Math.floor(t/4096),l=t%4096,d=4096-l;d>i&&(d=i);let h=this.inodeBlockMap(e,u,!1);if(h<=0)r.fill(0,c,c+d);else{let m=h*4096+l;r.set(this.u8.subarray(m,m+d),c)}c+=d,t+=d,i-=d,a+=d}return a}inodeWriteData(e,t,r,i){let o=this.inodeOffset(e),s=this.r64(o+P);t>s&&this.zeroOldEofTail(e,s);let a=0,c=0;for(;i>0;){let u=Math.floor(t/4096),l=t%4096,d=4096-l;d>i&&(d=i);let h=this.inodeBlockMap(e,u,!0);if(h<0){if(a===0)return h;break}let m=h*4096+l;this.u8.set(r.subarray(c,c+d),m),c+=d,t+=d,i-=d,a+=d}if(a>0&&t>this.r64(o+P)&&this.w64(o+P,t),a>0){let u=Date.now();this.w64(o+me,u),this.w64(o+ee,u),Atomics.add(this.i32,o+he>>2,1)}return a}zeroInodeRange(e,t,r){for(;t0){let c=a*4096+o;this.u8.fill(0,c,c+s)}t+=s}}zeroOldEofTail(e,t){let r=t%4096;if(r===0)return;let i=Math.floor(t/4096),o=this.inodeBlockMap(e,i,!1);if(o<=0)return;let s=o*4096+r;this.u8.fill(0,s,o*4096+4096)}freeBlocksFrom(e,t){let r=this.inodeOffset(e);for(let s=t;s<10;s++){let a=this.r32(r+oe+s*4);a&&(this.blockFree(a),this.w32(r+oe+s*4,0))}let i=this.r32(r+or);if(i){let s=t>10?t-10:0;for(let a=s;a<1024;a++){let c=i*4096+a*4,u=this.r32(c);u&&(this.blockFree(u),this.w32(c,0))}s===0&&(this.blockFree(i),this.w32(r+or,0))}let o=this.r32(r+xt);if(o){let s=t>1034?t-10-1024:0,a=Math.floor(s/1024);for(let c=a;c<1024;c++){let u=o*4096+c*4,l=this.r32(u);if(!l)continue;let d=c===a?s%1024:0;for(let h=d;h<1024;h++){let m=l*4096+h*4,f=this.r32(m);f&&(this.blockFree(f),this.w32(m,0))}d===0&&(this.blockFree(l),this.w32(u,0))}a===0&&(this.blockFree(o),this.w32(r+xt,0))}}inodeTruncate(e,t,r=!1){let i=this.inodeOffset(e),o=this.r64(i+P),s=t!==o;if(t>=o){if(t>o&&this.zeroOldEofTail(e,o),this.w64(i+P,t),s||r){let c=Date.now();this.w64(i+me,c),this.w64(i+ee,c),Atomics.add(this.i32,i+he>>2,1)}return}t%4096!==0&&this.zeroInodeRange(e,t,Math.ceil(t/4096)*4096);let a=Math.ceil(t/4096);if(this.freeBlocksFrom(e,a),this.w64(i+P,t),s||r){let c=Date.now();this.w64(i+me,c),this.w64(i+ee,c),Atomics.add(this.i32,i+he>>2,1)}}validateFileSize(e){if(!Number.isSafeInteger(e)||e<0)throw new A(j);if(e>je)throw new A(dt)}validateSeekPosition(e){if(!Number.isSafeInteger(e))throw new A(rs);if(e<0)throw new A(j);if(e>je)throw new A(dt)}touchDirectoryMutation(e){let t=this.inodeOffset(e),r=Date.now();this.w64(t+me,r),this.w64(t+ee,r);let i=Atomics.add(this.i32,t+qo>>2,1)+1>>>0,o=this.dirIndexes.get(e);o&&(o.mutationSequence=i,o.size=this.r64(t+P))}dirNameKey(e){return It(e)}dirEntryNameMatches(e,t){if(this.view.getUint16(e+6,!0)!==t.length)return!1;for(let i=0;i=F&&r%4===0&&e+r<=t&&i<=r-F}inodeIsAllocated(e){let t=this.r32(Ye);if(e<=0||e>=t)return!1;let r=this.r32(ct)*4096;return(Atomics.load(this.i32,(r>>2)+(e>>5))&1<<(e&31))!==0}rebuildDirIndex(e,t,r,i){let o=new Map,s=[],a=0;for(;a4096-l&&(m=4096-l);let f=l;for(;f=F&&s.push({abs:p,recLen:y});f+=y}a+=m}let c={generation:t,mutationSequence:r,size:i,entries:o,free:s};return this.dirIndexes.set(e,c),c}getDirIndex(e){let t=this.inodeOffset(e),r=this.r64(t+P),i=this.r64(t+pe),o=Atomics.load(this.i32,t+qo>>2)>>>0,s=this.dirIndexes.get(e);return s&&s.generation===i&&s.mutationSequence===o&&s.size===r?s:(s&&this.dirIndexes.delete(e),r=0;s--){let a=e.free[s];if(!(a.recLen4096-c&&(d=4096-c);let h=c;for(;hr)return-1;a=c,s+=u}return s===r?a:-1}dirAppendEntry(e,t,r,i=-1){let o=this.inodeOffset(e),s=this.r64(o+P),a=Je(F+t.length),c=s,u=Math.floor(c/4096),l=c%4096,d=0;if(l!==0&&l+a>4096){let f=4096-l,p=0;if(f>=F){if(p=this.inodeBlockMap(e,u,!1),p<=0)return $}else if(i<0&&(i=this.findLastDirEntryInBlock(e,u,l)),i<0)return $;if(d=this.inodeBlockMap(e,u+1,!0),d<0)return d;if(f>=F){let _=p*4096+l;this.w32(_,0),this.view.setUint16(_+4,f,!0),this.view.setUint16(_+6,0,!0)}else{let y=this.view.getUint16(i+4,!0)+f;this.view.setUint16(i+4,y,!0),this.updateDirIndexRecLen(e,i,y)}c=(u+1)*4096,u++,l=0}let h;if(l===0){if(h=d||this.inodeBlockMap(e,u,!0),h<0)return h}else if(h=this.inodeBlockMap(e,u,!1),h<=0)return $;let m=h*4096+l;return this.w32(m,r),this.view.setUint16(m+4,a,!0),this.view.setUint16(m+6,t.length,!0),this.u8.set(t,m+F),this.w64(o+P,c+a),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,m,a),0}dirAddEntry(e,t,r){let i=this.getDirIndex(e);if(typeof i=="number")return i;if(i)return this.useDirIndexFreeSlot(i,e,t,r)?0:this.dirAppendEntry(e,t,r);let o=this.inodeOffset(e),s=this.r64(o+P),a=Je(F+t.length),c=-1,u=0;for(;u4096-d&&(f=4096-d);let p=d;for(;pd+f||E>g-F)return $;if(y===0&&g>=a)return this.w32(_,r),this.view.setUint16(_+6,t.length,!0),this.u8.set(t,_+F),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,_,g),0;let O=Je(F+E),S=g-O;if(y!==0&&S>=a){this.view.setUint16(_+4,O,!0);let w=_+O;return this.w32(w,r),this.view.setUint16(w+4,S,!0),this.view.setUint16(w+6,t.length,!0),this.u8.set(t,w+F),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,w,S),0}c=_,p+=g}u+=f}return this.dirAppendEntry(e,t,r,c)}dirRemoveEntry(e,t){let r=this.getDirIndex(e);if(typeof r=="number")return r;if(r){let a=this.dirNameKey(t),c=r.entries.get(a);if(!c)return Ie;if(this.r32(c.abs)===c.ino&&this.view.getUint16(c.abs+4,!0)===c.recLen&&this.view.getUint16(c.abs+6,!0)===c.nameLen&&this.dirEntryNameMatches(c.abs,t))return this.w32(c.abs,0),r.entries.delete(a),r.free.push({abs:c.abs,recLen:c.recLen}),this.touchDirectoryMutation(e),0;r.entries.delete(a)}let i=this.inodeOffset(e),o=this.r64(i+P),s=0;for(;s4096-c&&(d=4096-c);let h=c;for(;h4096-u&&(h=4096-u);let m=u;for(;m4096-s&&(u=4096-s);let l=s;for(;ls+u||f>m-F)throw new A($);if(h!==0){if(f===1&&this.u8[d+F]===46){l+=m;continue}if(f===2&&this.u8[d+F]===46&&this.u8[d+F+1]===46){l+=m;continue}return!1}l+=m}i+=u}return!0}dirIsAncestor(e,t){let r=t;for(let i=0;i<8*1024;i++){if(r===e)return!0;if(r===1)return!1;let o=this.dirLookup(r,Jo);if(o<0||o===r)throw new A($);r=o}throw new A($)}pathResolve(e,t){if(!e.startsWith("/"))return Ie;let r=1,i=e.split("/").filter(s=>s.length>0),o=0;for(let s=0;s255)return Jn;let c=_e.encode(a),u;this.inodeReadLock(r);try{let h=this.inodeOffset(r);if((this.r32(h+C)&W)!==H)return Fe;u=this.dirLookup(r,c)}finally{this.inodeReadUnlock(r)}if(u<0)return u;let l=this.inodeOffset(u);if((this.r32(l+C)&W)===wt&&(!(s===i.length-1)||t)){if(++o>8)return ts;let m=this.r64(l+P),f;if(m<=40)f=It(this.u8.subarray(l+oe,l+oe+m));else{let p=new Uint8Array(m);this.inodeReadData(u,0,p,m),f=ur.decode(p)}if(f.startsWith("/")){r=1;let p=f.split("/").filter(y=>y.length>0),_=i.slice(s+1);i.length=0,i.push(...p,..._),s=-1}else{let p=f.split("/").filter(y=>y.length>0),_=i.slice(s+1);i.length=s,i.push(...p,..._),s--}continue}r=u}return r}pathResolveParent(e){if(!e.startsWith("/"))throw new A(j,"Path must be absolute");let t=e.split("/").filter(c=>c.length>0);if(t.length===0)throw new A(j,"Cannot operate on /");let r=t.pop();if(r.length>255)throw new A(Jn);let i="/"+t.join("/"),o=this.pathResolve(i,!0);if(o<0)throw new A(o);let s=this.inodeOffset(o);if((this.r32(s+C)&W)!==H)throw new A(Fe);return{parentIno:o,name:r}}fdAlloc(e,t,r){for(let i=0;i>2;if(Atomics.compareExchange(this.i32,s,0,1)===0)return this.w32(o+Zo,e),this.w64(o+ke,0),this.w32(o+Yo,t),this.w32(o+Xo,r?1:0),this.inodeAddOpenRef(e)?i:(Atomics.store(this.i32,s,0),Ie)}return Qo}fdGet(e){if(e<0||e>=Gr)return null;let t=256+e*24;return Atomics.load(this.i32,t>>2)?{base:t,ino:this.r32(t+Zo),offset:this.r64(t+ke),flags:this.r32(t+Yo),isDir:this.r32(t+Xo)!==0}:null}fdFree(e){if(e>=0&&e>2,0)}}buildStat(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+pe),dataSequence:this.r32(t+he),mode:this.r32(t+C),linkCount:this.r32(t+B),size:this.r64(t+P),mtime:this.r64(t+me),ctime:this.r64(t+ee),atime:this.r64(t+ir),uid:this.r32(t+sr),gid:this.r32(t+ar)}}namespaceEntryIdentity(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+pe),linkCount:this.r32(t+B),mode:this.r32(t+C)}}open(e,t,r=420){return this.withNamespaceLock(()=>this.openUnlocked(e,t,r))}createLazyStub(e,t){return this.withNamespaceLock(()=>{let r=this.openUnlocked(e,Go|cr,t);try{let i=this.fdGet(r);if(!i)throw new A(re);this.inodeWriteLock(i.ino);try{return this.inodeTruncate(i.ino,0,!0),this.buildStat(i.ino)}finally{this.inodeWriteUnlock(i.ino)}}finally{this.closeUnlocked(r)}})}replaceIfIdentity(e,t,r,i,o){return this.withNamespaceLock(()=>{let s=this.pathResolve(e,!0);if(s<0||s!==t)return!1;let a=this.inodeOffset(s);if(this.r64(a+pe)!==r||this.r32(a+he)!==i||(this.r32(a+C)&W)!==tr)return!1;this.validateFileSize(o.byteLength),this.inodeWriteLock(s);try{if(this.r64(a+pe)!==r||this.r32(a+he)!==i||this.r64(a+P)!==0)return!1;let c=this.r64(a+me),u=this.r64(a+ee);this.inodeTruncate(s,0,!0);let l=o.byteLength>0?this.inodeWriteData(s,0,o,o.byteLength):0;if(l!==o.byteLength)throw this.inodeTruncate(s,0,!0),Atomics.store(this.i32,a+he>>2,i),this.w64(a+me,c),this.w64(a+ee,u),new A(l<0?l:se);return!0}finally{this.inodeWriteUnlock(s)}})}replaceManyIfIdentities(e,t=[]){return e.length===0&&t.length===0?!0:this.withNamespaceLock(()=>{let r=[],i=new Set,o=a=>{let c=this.pathResolve(a.path,!1);if(c<0||c!==a.expectedIno)return!1;let u=this.inodeOffset(c);return this.r64(u+pe)===a.expectedGeneration&&this.r32(u+he)===a.expectedDataSequence&&this.r32(u+C)===a.expectedMode&&this.r32(u+B)===a.expectedLinkCount&&this.r64(u+P)===a.expectedSize&&this.r32(u+sr)===a.expectedUid&&this.r32(u+ar)===a.expectedGid};for(let a of t)if(!o(a))return!1;for(let a of e){this.validateFileSize(a.data.byteLength);let c=-1;for(let u of a.paths){let l=this.pathResolve(u,!0);if(l!==a.expectedIno)continue;let d=this.inodeOffset(l);if(this.r64(d+pe)===a.expectedGeneration&&this.r32(d+he)===a.expectedDataSequence&&(this.r32(d+C)&W)===tr&&this.r64(d+P)===0){c=l;break}}if(c<0)return!1;if(i.has(c))throw new A(j,"duplicate conditional replacement inode");i.add(c),r.push({...a,ino:c})}let s=[...i].sort((a,c)=>a-c);for(let a of s)this.inodeWriteLock(a);try{for(let u of r){let l=this.inodeOffset(u.ino);if(this.r64(l+pe)!==u.expectedGeneration||this.r32(l+he)!==u.expectedDataSequence||(this.r32(l+C)&W)!==tr||this.r64(l+P)!==0)return!1}for(let u of t)if(!o(u))return!1;let a=r.map(u=>{let l=this.inodeOffset(u.ino);return{ino:u.ino,dataSequence:this.r32(l+he),mtime:this.r64(l+me),ctime:this.r64(l+ee)}}),c=0;try{for(let u of r){c++,this.inodeTruncate(u.ino,0,!0);let l=u.data.byteLength>0?this.inodeWriteData(u.ino,0,u.data,u.data.byteLength):0;if(l!==u.data.byteLength)throw new A(l<0?l:se)}}catch(u){for(let l=c-1;l>=0;l--){let d=a[l],h=this.inodeOffset(d.ino);this.inodeTruncate(d.ino,0,!0),Atomics.store(this.i32,h+he>>2,d.dataSequence),this.w64(h+me,d.mtime),this.w64(h+ee,d.ctime)}throw u}return!0}finally{for(let a=s.length-1;a>=0;a--)this.inodeWriteUnlock(s[a])}})}openUnlocked(e,t,r=420){let i=t&rr,o=(t&cr)!==0,s=(t&ei)!==0;if(o&&s){let d=this.pathResolve(e,!1);if(d>=0)throw new A(Tt);if(d!==Ie)throw new A(d)}let a=this.pathResolve(e,!0);if(a<0&&a===Ie&&o){let{parentIno:d,name:h}=this.pathResolveParent(e);this.inodeWriteLock(d);try{let m=_e.encode(h),f=this.dirLookup(d,m);if(f>=0){if(s)throw new A(Tt);a=f}else{let p=this.inodeAlloc();if(p<0)throw new A(se);let _=this.inodeOffset(p);this.w32(_+C,tr|r&4095),this.w32(_+B,1),this.w64(_+P,0);let y=Date.now();this.w64(_+ir,y),this.w64(_+me,y),this.w64(_+ee,y);let g=this.dirAddEntry(d,m,p);if(g<0)throw this.inodeFree(p),new A(g);a=p}}finally{this.inodeWriteUnlock(d)}}if(a<0)throw new A(a);let c=this.inodeOffset(a),u=this.r32(c+C);if((u&W)===H&&i!==at)throw new A(ut);if(t&lu&&(u&W)!==H)throw new A(Fe);if(t&dr){if((u&W)===H)throw new A(ut);this.inodeWriteLock(a),this.inodeTruncate(a,0,!0),this.inodeWriteUnlock(a)}let l=this.fdAlloc(a,t,!1);if(l<0)throw new A(l);return l}close(e){this.withNamespaceLock(()=>this.closeUnlocked(e))}closeUnlocked(e){let t=this.fdGet(e);if(!t)throw new A(re);this.fdFree(e),this.inodeDropOpenRef(t.ino)}read(e,t){let r=this.fdGet(e);if(!r)throw new A(re);let i=this.inodeOffset(r.ino);if((this.r32(i+C)&W)===H)throw new A(ut);this.inodeReadLock(r.ino);try{let s=this.inodeReadData(r.ino,r.offset,t,t.length),a=256+e*24;return this.w64(a+ke,r.offset+s),s}finally{this.inodeReadUnlock(r.ino)}}readAt(e,t,r){let i=this.fdGet(e);if(!i)throw new A(re);let o=this.inodeOffset(i.ino);if((this.r32(o+C)&W)===H)throw new A(ut);this.validateSeekPosition(r),this.inodeReadLock(i.ino);try{return this.inodeReadData(i.ino,r,t,t.length)}finally{this.inodeReadUnlock(i.ino)}}write(e,t){let r=this.fdGet(e);if(!r)throw new A(re);if((r.flags&rr)===at)throw new A(re);this.inodeWriteLock(r.ino);try{let o=r.offset;if(r.flags&du){let c=this.inodeOffset(r.ino);o=this.r64(c+P)}if(!Number.isSafeInteger(o)||o<0)throw new A(j);if(o>je||t.length>je-o)throw new A(dt);let s=this.inodeWriteData(r.ino,o,t,t.length);if(s<0)return s;let a=256+e*24;return this.w64(a+ke,o+s),s}finally{this.inodeWriteUnlock(r.ino)}}append(e,t,r){let i=this.fdGet(e);if(!i)throw new A(re);if((i.flags&rr)===at)throw new A(re);if(r!==null&&(!Number.isSafeInteger(r)||r<0))throw new A(j);this.inodeWriteLock(i.ino);try{let s=this.inodeOffset(i.ino),a=this.r64(s+P);if(!Number.isSafeInteger(a)||a<0)throw new A(j);if(a>je)throw new A(dt);if(r!==null&&a>=r){let f=256+e*24;return this.w64(f+ke,a),{written:0,end:a}}let c=r===null?t.length:Math.min(t.length,r-a),u=je-a;if(c>u)throw new A(dt);let l=t.subarray(0,c),d=this.inodeWriteData(i.ino,a,l,l.length);if(d<0)throw new A(d);let h=256+e*24,m=a+d;return this.w64(h+ke,m),{written:d,end:m}}finally{this.inodeWriteUnlock(i.ino)}}writeAt(e,t,r){let i=this.fdGet(e);if(!i)throw new A(re);if((i.flags&rr)===at)throw new A(re);this.validateSeekPosition(r),this.inodeWriteLock(i.ino);try{if(r>je||t.length>je-r)throw new A(dt);return this.inodeWriteData(i.ino,r,t,t.length)}finally{this.inodeWriteUnlock(i.ino)}}lseek(e,t,r){let i=this.fdGet(e);if(!i)throw new A(re);let o;if(r===fu)o=t;else if(r===pu)o=i.offset+t;else if(r===hu){let a=this.inodeOffset(i.ino);o=this.r64(a+P)+t}else throw new A(j);this.validateSeekPosition(o);let s=256+e*24;return this.w64(s+ke,o),o}ftruncate(e,t){let r=this.fdGet(e);if(!r)throw new A(re);if((r.flags&rr)===at)throw new A(re);this.validateFileSize(t),this.inodeWriteLock(r.ino);try{this.inodeTruncate(r.ino,t,!0)}finally{this.inodeWriteUnlock(r.ino)}}fstat(e){let t=this.fdGet(e);if(!t)throw new A(re);this.inodeReadLock(t.ino);try{return this.buildStat(t.ino)}finally{this.inodeReadUnlock(t.ino)}}stat(e){return this.withNamespaceLock(()=>this.statUnlocked(e))}statUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new A(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}lstat(e){return this.withNamespaceLock(()=>this.lstatUnlocked(e))}lstatUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new A(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}unlink(e){return this.withNamespaceLock(()=>this.unlinkUnlocked(e))}unlinkUnlocked(e){let{parentIno:t,name:r}=this.pathResolveParent(e),i=_e.encode(r),o=e.length>1&&e.endsWith("/");this.inodeWriteLock(t);try{let s=this.dirLookup(t,i);if(s<0)throw new A(s);let a=this.inodeOffset(s),c=this.r32(a+C);if(o&&(c&W)!==H)throw new A(Fe);if((c&W)===H)throw new A(ut);let u=this.namespaceEntryIdentity(s),l=this.dirRemoveEntry(t,i);if(l<0)throw new A(l);let d=!1;this.inodeWriteLock(s);try{d=this.inodeDropLinkRefLocked(s)}finally{this.inodeWriteUnlock(s)}return d&&this.inodeFree(s),u}finally{this.inodeWriteUnlock(t)}}rename(e,t){return this.withNamespaceLock(()=>this.renameUnlocked(e,t))}renameUnlocked(e,t){let{parentIno:r,name:i}=this.pathResolveParent(e),{parentIno:o,name:s}=this.pathResolveParent(t);if(Xn(i)||Xn(s))throw new A(j);let a=_e.encode(i),c=_e.encode(s),u=e.length>1&&e.endsWith("/"),l=t.length>1&&t.endsWith("/"),d=Math.min(r,o),h=Math.max(r,o);this.inodeWriteLock(d),d!==h&&this.inodeWriteLock(h);try{let m=this.dirLookup(r,a);if(m<0)throw new A(m);let f=this.inodeOffset(m),_=this.r32(f+C)&W,y=this.namespaceEntryIdentity(m);if((u||l)&&_!==H)throw new A(Fe);if(_===H&&this.dirIsAncestor(m,o))throw new A(j);let g=this.dirLookup(o,c),E=!1,O;if(g>=0){if(g===m)return{source:y,replaced:y};O=this.namespaceEntryIdentity(g);let w=this.inodeOffset(g),x=this.r32(w+C)&W;if(_===H&&x!==H)throw new A(Fe);if(_!==H&&x===H)throw new A(ut);let I=!1,R=g===r||g===o;R||this.inodeWriteLock(g);try{if(x===H&&!this.dirIsEmpty(g))throw new A(Qn);let L=this.dirReplaceEntryIno(o,c,m);if(L<0)throw new A(L);I=x===H?this.inodeOrphanLocked(g):this.inodeDropLinkRefLocked(g)}finally{R||this.inodeWriteUnlock(g)}I&&this.inodeFree(g),E=x===H}else{let w=this.dirAddEntry(o,c,m);if(w<0)throw new A(w)}let S=this.dirRemoveEntry(r,a);if(S<0)throw new A(S);if(_===H){if(r!==o){let w=this.inodeOffset(r);this.w32(w+B,this.r32(w+B)-1);let z=this.inodeOffset(o);this.w32(z+B,this.r32(z+B)+1),this.inodeWriteLock(m);try{let x=this.dirReplaceEntryIno(m,Jo,o);if(x<0)throw new A(x);this.w64(f+ee,Date.now())}finally{this.inodeWriteUnlock(m)}}if(E){let w=this.inodeOffset(o);this.w32(w+B,this.r32(w+B)-1)}}else if(E){let w=this.inodeOffset(o);this.w32(w+B,this.r32(w+B)-1)}return{source:y,replaced:O}}finally{d!==h&&this.inodeWriteUnlock(h),this.inodeWriteUnlock(d)}}mkdir(e,t=493){this.withNamespaceLock(()=>this.mkdirUnlocked(e,t))}mkdirUnlocked(e,t=493){let{parentIno:r,name:i}=this.pathResolveParent(e),o=_e.encode(i);this.inodeWriteLock(r);try{if(this.dirLookup(r,o)>=0)throw new A(Tt);let a=this.inodeAlloc();if(a<0)throw new A(se);let c=this.inodeOffset(a);this.w32(c+C,H|t),this.w32(c+B,2),this.w64(c+P,0);let u=Date.now();this.w64(c+ir,u),this.w64(c+me,u),this.w64(c+ee,u);let l=this.blockAllocWithGrow();if(l<0)throw this.inodeFree(a),new A(se);this.w32(c+oe,l);let d=l*4096,h=Je(F+1),m=Je(F+2);this.w32(d,a),this.view.setUint16(d+4,h,!0),this.view.setUint16(d+6,1,!0),this.u8[d+F]=46;let f=d+h;this.w32(f,r),this.view.setUint16(f+4,m,!0),this.view.setUint16(f+6,2,!0),this.u8[f+F]=46,this.u8[f+F+1]=46,this.w64(c+P,h+m);let p=this.dirAddEntry(r,o,a);if(p<0)throw this.blockFree(l),this.inodeFree(a),new A(p);let _=this.inodeOffset(r);this.w32(_+B,this.r32(_+B)+1)}finally{this.inodeWriteUnlock(r)}}rmdir(e){this.withNamespaceLock(()=>this.rmdirUnlocked(e))}rmdirUnlocked(e){let{parentIno:t,name:r}=this.pathResolveParent(e);if(Xn(r))throw new A(j);let i=_e.encode(r);this.inodeWriteLock(t);try{let o=this.dirLookup(t,i);if(o<0)throw new A(o);let s=this.inodeOffset(o);if((this.r32(s+C)&W)!==H)throw new A(Fe);let c=!1;this.inodeWriteLock(o);try{if(!this.dirIsEmpty(o))throw new A(Qn);let l=this.dirRemoveEntry(t,i);if(l<0)throw new A(l);c=this.inodeOrphanLocked(o)}finally{this.inodeWriteUnlock(o)}c&&this.inodeFree(o);let u=this.inodeOffset(t);this.w32(u+B,this.r32(u+B)-1)}finally{this.inodeWriteUnlock(t)}}symlink(e,t){this.withNamespaceLock(()=>this.symlinkUnlocked(e,t))}symlinkUnlocked(e,t){let{parentIno:r,name:i}=this.pathResolveParent(t),o=_e.encode(i),s=_e.encode(e);this.inodeWriteLock(r);try{if(this.dirLookup(r,o)>=0)throw new A(Tt);let c=this.inodeAlloc();if(c<0)throw new A(se);let u=this.inodeOffset(c);if(this.w32(u+C,wt|511),this.w32(u+B,1),s.length<=40)this.u8.set(s,u+oe),this.w64(u+P,s.length);else{this.w64(u+P,0);let d=this.inodeWriteData(c,0,s,s.length);if(d!==s.length)throw d>0&&this.inodeTruncate(c,0),this.inodeFree(c),new A(d<0?d:se)}let l=this.dirAddEntry(r,o,c);if(l<0)throw s.length<=40?(this.u8.fill(0,u+oe,u+oe+40),this.w64(u+P,0)):this.inodeTruncate(c,0),this.inodeFree(c),new A(l)}finally{this.inodeWriteUnlock(r)}}chmod(e,t){this.withNamespaceLock(()=>this.chmodUnlocked(e,t))}chmodUnlocked(e,t){let r=this.pathResolve(e,!0);if(r<0)throw new A(r);this.inodeWriteLock(r);try{let i=this.inodeOffset(r),o=this.r32(i+C);this.w32(i+C,o&W|t&4095),this.w64(i+ee,Date.now())}finally{this.inodeWriteUnlock(r)}}fchmod(e,t){let r=this.fdGet(e);if(!r)throw new A(re);this.inodeWriteLock(r.ino);try{let i=this.inodeOffset(r.ino),o=this.r32(i+C);this.w32(i+C,o&W|t&4095),this.w64(i+ee,Date.now())}finally{this.inodeWriteUnlock(r.ino)}}chown(e,t,r){this.withNamespaceLock(()=>this.chownUnlocked(e,t,r))}chownUnlocked(e,t,r){let i=this.pathResolve(e,!0);if(i<0)throw new A(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,r)}finally{this.inodeWriteUnlock(i)}}fchown(e,t,r){let i=this.fdGet(e);if(!i)throw new A(re);this.inodeWriteLock(i.ino);try{this.chownInodeUnlocked(i.ino,t,r)}finally{this.inodeWriteUnlock(i.ino)}}lchown(e,t,r){this.withNamespaceLock(()=>this.lchownUnlocked(e,t,r))}lchownUnlocked(e,t,r){let i=this.pathResolve(e,!1);if(i<0)throw new A(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,r)}finally{this.inodeWriteUnlock(i)}}chownInodeUnlocked(e,t,r){let i=this.inodeOffset(e);t!==Wo&&this.w32(i+sr,t),r!==Wo&&this.w32(i+ar,r);let o=this.r32(i+C);(o&W)===tr&&(o&uu)!==0&&this.w32(i+C,o&~(au|cu)),this.w64(i+ee,Date.now())}utimens(e,t,r,i,o){this.withNamespaceLock(()=>this.utimensUnlocked(e,t,r,i,o))}utimensUnlocked(e,t,r,i,o){let s=this.pathResolve(e,!0);if(s<0)throw new A(s);this.inodeWriteLock(s);try{let a=this.inodeOffset(s),c=1073741823,u=1073741822,l=Date.now();if(r!==u){let d=r===c?l:t*1e3+Math.floor(r/1e6);this.w64(a+ir,d)}if(o!==u){let d=o===c?l:i*1e3+Math.floor(o/1e6);this.w64(a+me,d)}this.w64(a+ee,l)}finally{this.inodeWriteUnlock(s)}}link(e,t){return this.withNamespaceLock(()=>this.linkUnlocked(e,t))}linkUnlocked(e,t){let r=this.pathResolve(e,!1);if(r<0)throw new A(r);let i=this.inodeOffset(r);if((this.r32(i+C)&W)===H)throw new A(yu);let{parentIno:s,name:a}=this.pathResolveParent(t),c=_e.encode(a);this.inodeWriteLock(s);try{if(this.dirLookup(s,c)>=0)throw new A(Tt);let l=this.dirAddEntry(s,c,r);if(l<0)throw new A(l);this.inodeWriteLock(r);try{let d=this.r32(i+B);this.w32(i+B,d+1),this.w64(i+ee,Date.now())}finally{this.inodeWriteUnlock(r)}return{...this.namespaceEntryIdentity(r),linkCount:this.r32(i+B)}}finally{this.inodeWriteUnlock(s)}}readlink(e){return this.withNamespaceLock(()=>this.readlinkUnlocked(e))}readlinkUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new A(t);return this.readSymlinkInodeUnlocked(t)}readSymlinkInodeUnlocked(e){let t=this.inodeOffset(e);if((this.r32(t+C)&W)!==wt)throw new A(j);let i=this.r64(t+P);if(i<=40)return It(this.u8.subarray(t+oe,t+oe+i));this.inodeReadLock(e);try{let o=new Uint8Array(i);return this.inodeReadData(e,0,o,i),ur.decode(o)}finally{this.inodeReadUnlock(e)}}opendir(e){return this.withNamespaceLock(()=>this.opendirUnlocked(e))}opendirUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new A(t);let r=this.inodeOffset(t);if((this.r32(r+C)&W)!==H)throw new A(Fe);let o=this.fdAlloc(t,at,!0);if(o<0)throw new A(o);return o}readdirEntry(e){return this.withNamespaceLock(()=>this.readdirEntryUnlocked(e))}readdirEntryUnlocked(e){let t=this.fdGet(e);if(!t||!t.isDir)throw new A(re);let r=this.inodeOffset(t.ino),i=this.r64(r+P);for(;t.offset=this.r32(Ye))throw new A($);let p=this.r32(ct)*4096;if((this.r32(p+(l>>5)*4)&1<<(l&31))===0)throw new A($);let y=It(this.u8.subarray(u+F,u+F+h)),g=this.buildStat(l);return this.w64(f+ke,m),t.offset=m,{name:y,stat:g}}return null}closedir(e){this.close(e)}readdir(e){let t=this.opendir(e),r=[];try{let i;for(;(i=this.readdirEntry(t))!==null;)i.name!=="."&&i.name!==".."&&r.push(i.name)}finally{this.closedir(t)}return r}writeFile(e,t){let r=typeof t=="string"?_e.encode(t):t,i=this.open(e,Go|cr|dr);try{this.write(i,r)}finally{this.close(i)}}readFile(e){let t=this.open(e,at);try{let r=this.fstat(t),i=new Uint8Array(r.size);return this.read(t,i),i}finally{this.close(t)}}readFileText(e){return ur.decode(this.readFile(e))}};function ns(n,e){let t=new Map,r=new Map;for(let s of n){if(t.has(s.path))throw new Error(`${e} duplicates path ${s.path}`);if(t.set(s.path,s),s.type==="file"){if(!s.inodeGroup)throw new Error(`${e} file ${s.path} has no inode group`);if(r.has(s.inodeGroup))throw new Error(`${e} inode group ${s.inodeGroup} has multiple files`);r.set(s.inodeGroup,s)}}let i=new Set,o=new Map;for(let s of n){if(s.type!=="hardlink"||o.has(s.path))continue;let a=[],c=s,u;for(;c.type==="hardlink";){let d=o.get(c.path);if(d){u=d;break}if(i.has(c.path))throw new Error(`${e} hardlink cycle reaches ${c.path}`);if(i.add(c.path),a.push(c),!c.target)throw new Error(`${e} hardlink ${c.path} has no target`);let h=t.get(c.target);if(!h)throw new Error(`${e} hardlink ${c.path} target ${c.target} is missing`);if(h.type!=="file"&&h.type!=="hardlink"||!c.inodeGroup||h.inodeGroup!==c.inodeGroup||h.size!==c.size||h.mode!==c.mode)throw new Error(`${e} hardlink ${c.path} has an invalid target`);c=h}u??=c.type==="file"?c:void 0;let l=r.get(s.inodeGroup??"");if(!u||u!==l)throw new Error(`${e} hardlink ${s.path} does not resolve to its inode`);for(let d=a.length-1;d>=0;d-=1){let h=a[d];if(r.get(h.inodeGroup??"")!==u)throw new Error(`${e} hardlink ${h.path} does not resolve to its inode`);i.delete(h.path),o.set(h.path,u)}}return{canonicalByGroup:r,canonicalTargetByPath:o}}var D={maxArchiveBytes:268435456,maxExpandedBytes:268435456,maxPayloadBytes:268435456,maxEntries:1e5,maxPathBytes:4096,maxSymlinkTargetBytes:65536,maxStringBytes:8192,maxTransportsPerTree:8,maxActivationCapabilities:32,maxActivationRoots:64,maxActivationCapabilityBytes:255,maxMaterializationAssertions:32,maxMaterializationAssertionBytes:1048576,maxMaterializationRecipes:32,maxMaterializationTransforms:1e5,maxMaterializationDecodedBytes:8388608,maxTransformReplacements:32,maxTransformPatternBytes:8192},be={maxArchiveBytes:512*1024*1024,maxExpandedBytes:512*1024*1024,maxPayloadBytes:512*1024*1024,maxEntries:1e5,maxGroups:512};function is(n,e="Deferred tree collection"){for(let[t,r]of Object.entries(n))if(!Number.isSafeInteger(r)||r<0)throw new Error(`${e} ${t} usage is invalid`);if(n.groups>be.maxGroups)throw new Error(`${e} exceeds the ${be.maxGroups}-group cap`);if(n.archiveBytes>be.maxArchiveBytes)throw new Error(`${e} exceeds the archive-byte cap`);if(n.expandedBytes>be.maxExpandedBytes)throw new Error(`${e} exceeds the expansion cap`);if(n.payloadBytes>be.maxPayloadBytes)throw new Error(`${e} exceeds the payload-byte cap`);if(n.entries>be.maxEntries)throw new Error(`${e} exceeds the entry-count cap`)}function Rt(n,e="Canonical text"){for(let t=0;t57343)){if(r<=56319&&t+1=56320&&n.charCodeAt(t+1)<=57343){t+=1;continue}throw new Error(`${e} must contain only Unicode scalar values`)}}}function lr(n,e){Rt(n),Rt(e);let t=0,r=0;for(;t65535?2:1,r+=o>65535?2:1}return tD.maxEntries)throw new Error("Lazy tree materialization source inventory is unbounded");let t=new Map;for(let[h,m]of e.entries.entries()){let f=ti(m.sourcePath,`Lazy tree materialization source ${h} path`);if(t.has(f))throw new Error(`Lazy tree materialization source repeats ${f}`);if(m.type!=="directory"&&m.type!=="file"&&m.type!=="symlink"&&m.type!=="hardlink")throw new Error(`Lazy tree materialization source ${f} has invalid type`);ds(m.size,`Lazy tree materialization source ${f} byte count`,0,D.maxPayloadBytes),t.set(f,m)}let r=Lt(n,["schema","kind","assertions","recipes","transforms"],"Lazy tree materialization plan");if(r.schema!==1||r.kind!=="archive-byte-transforms-v1")throw new Error("Lazy tree materialization plan has an unsupported identity");let i=0,o=new Set,s=pr(r.assertions,"Lazy tree materialization assertions",0,D.maxMaterializationAssertions).map((h,m)=>{let f=Lt(h,["sourcePath","bytesHex"],`Lazy tree materialization assertion ${m}`),p=ti(f.sourcePath,`Lazy tree materialization assertion ${m} source path`);if(o.has(p))throw new Error(`Lazy tree materialization repeats assertion ${p}`);o.add(p);let _=t.get(p);if(_?.type!=="file")throw new Error(`Lazy tree materialization assertion ${p} is not a regular source`);let y=hr(f.bytesHex,`Lazy tree materialization assertion ${p} bytes`,D.maxMaterializationAssertionBytes,!0);if(i=jr(i,y.length/2),y.length/2!==_.size)throw new Error(`Lazy tree materialization assertion ${p} size differs from source`);return{sourcePath:p,bytesHex:y}}),a=new Map,c=pr(r.recipes,"Lazy tree materialization recipes",0,D.maxMaterializationRecipes).map((h,m)=>{let f=us(h,`Lazy tree materialization recipe ${m}`);if(a.has(f.recipe.id))throw new Error(`Lazy tree materialization duplicates recipe ${f.recipe.id}`);return i=jr(i,f.decodedBytes),a.set(f.recipe.id,f.recipe),f.recipe}),u=new Set,l=new Set,d=pr(r.transforms,"Lazy tree materialization transforms",0,D.maxMaterializationTransforms).map((h,m)=>{let f=Lt(h,["sourcePath","recipe","input","output"],`Lazy tree materialization transform ${m}`),p=ti(f.sourcePath,`Lazy tree materialization transform ${m} source path`);if(u.has(p))throw new Error(`Lazy tree materialization repeats transform ${p}`);u.add(p);let _=t.get(p);if(_?.type!=="file")throw new Error(`Lazy tree materialization transform ${p} is not a regular source`);let y=ii(f.recipe,`Lazy tree materialization transform ${p} recipe`,D.maxStringBytes);if(!a.has(y))throw new Error(`Lazy tree materialization transform ${p} has no recipe ${y}`);l.add(y);let g=os(f.input,`Lazy tree materialization transform ${p} input`),E=os(f.output,`Lazy tree materialization transform ${p} output`);if(g.bytes!==_.size)throw new Error(`Lazy tree materialization transform ${p} input size differs from source`);return{sourcePath:p,recipe:y,input:g,output:E}});if(s.length===0&&d.length===0)throw new Error("Lazy tree materialization plan has no assertions or transforms");if(c.some(h=>!l.has(h.id)))throw new Error("Lazy tree materialization plan contains an unused recipe");if(!ri(s.map(h=>h.sourcePath))||!ri(c.map(h=>h.id))||!ri(d.map(h=>h.sourcePath)))throw new Error("Lazy tree materialization plan is not in canonical order");return{schema:1,kind:"archive-byte-transforms-v1",assertions:s,recipes:c,transforms:d}}function fr(n){let e=hr(n,"Materialization bytes",D.maxMaterializationDecodedBytes,!0),t=new Uint8Array(e.length/2);for(let r=0;rD.maxPayloadBytes)throw new Error("Lazy tree byte transform exceeds its source-byte limit");let t=us(e,"Lazy tree byte transform recipe").recipe,r=n;for(let i of t.replacements)r=Au(r,fr(i.matchHex),fr(i.replacementHex));for(let i of t.rejectHex)if(zu(r,fr(i)))throw new Error(`Lazy tree byte transform retains rejected byte sequence ${i}`);return r}function us(n,e){let t=Lt(n,["id","replacements","rejectHex"],e),r=ii(t.id,`${e} id`,D.maxStringBytes);if(!xu(r))throw new Error(`${e} id is invalid`);let i=0,o=pr(t.replacements,`${e} replacements`,0,D.maxTransformReplacements).map((a,c)=>{let u=Lt(a,["matchHex","replacementHex"],`${e} replacement ${c}`),l=hr(u.matchHex,`${e} match`,D.maxTransformPatternBytes,!1),d=hr(u.replacementHex,`${e} replacement`,D.maxTransformPatternBytes,!0);return i=jr(i,l.length/2+d.length/2),{matchHex:l,replacementHex:d}}),s=pr(t.rejectHex,`${e} rejected patterns`,0,D.maxTransformReplacements).map((a,c)=>{let u=hr(a,`${e} rejected pattern ${c}`,D.maxTransformPatternBytes,!1);return i=jr(i,u.length/2),u});if(o.length===0&&s.length===0||new Set(s).size!==s.length)throw new Error(`${e} is empty or ambiguous`);return{recipe:{id:r,replacements:o,rejectHex:s},decodedBytes:i}}function Au(n,e,t){let r=0;for(let u=0;u<=n.byteLength-e.byteLength;)ni(n,e,u)?(r+=1,u+=e.byteLength):u+=1;if(r===0)return n;let i=t.byteLength-e.byteLength,o=n.byteLength+r*i;if(!Number.isSafeInteger(o)||o<0||o>D.maxPayloadBytes)throw new Error("Lazy tree byte transform exceeds its transformed-byte limit");let s=new Uint8Array(o),a=0,c=0;for(;an.byteLength)return!1;for(let t=0;t<=n.byteLength-e.byteLength;t+=1)if(ni(n,e,t))return!0;return!1}function ni(n,e,t){if(t+e.byteLength>n.byteLength)return!1;for(let r=0;rs!==o[a]))throw new Error(`${t} has unexpected fields`);return r}function pr(n,e,t,r){if(!Array.isArray(n)||n.lengthr)throw new Error(`${e} must contain ${t} to ${r} items`);return n}function ii(n,e,t){if(typeof n!="string")throw new Error(`${e} is invalid or exceeds ${t} bytes`);if(Rt(n,e),n.length===0||n.includes("\0")||new TextEncoder().encode(n).byteLength>t)throw new Error(`${e} is invalid or exceeds ${t} bytes`);return n}function ds(n,e,t,r){if(!Number.isSafeInteger(n)||Number(n)r)throw new Error(`${e} must be an integer between ${t} and ${r}`);return Number(n)}function hr(n,e,t,r){if(typeof n!="string"||!r&&n.length===0||n.length%2!==0||n.length/2>t||!ls(n))throw new Error(`${e} is not canonical bounded hexadecimal bytes`);return n}function ti(n,e){let t=ii(n,e,D.maxPathBytes);if(t.startsWith("/")||t.includes("\\")||t.split("/").some(r=>r===""||r==="."||r===".."))throw new Error(`${e} is not a canonical relative path`);return t}function jr(n,e){let t=n+e;if(!Number.isSafeInteger(t)||t>D.maxMaterializationDecodedBytes)throw new Error("Lazy tree materialization plan exceeds its decoded byte limit");return t}function ri(n){return n.every((e,t)=>t===0||lr(n[t-1],e)<0)}function xu(n){if(!ss(n.charCodeAt(0)))return!1;for(let e=1;e=97&&n<=122||n>=48&&n<=57}function ls(n,e){if(e!==void 0&&n.length!==e)return!1;for(let t=0;t=48&&r<=57)&&!(r>=97&&r<=102))return!1}return!0}var lt=Reflect.apply,hd=Object.create,md=Object.defineProperties,vi=Object.freeze,yd=Object.getOwnPropertyDescriptors,_d=Object.setPrototypeOf;var Ms=SharedArrayBuffer,gd=Uint8Array,Ed=Uint8Array.prototype.set,Sd=WeakSet.prototype.add,Bf=WeakSet.prototype.has,wd=WeakMap.prototype.get,Od=WeakMap.prototype.set,$f=Set.prototype.has,Uf=Map.prototype.get;var Ad=Number.isInteger,zd=TypeError,xd=le.mount,Id=le.mkfs,Td=le.prototype.snapshotState,Rd=new WeakSet,Ds=new WeakMap,Ld=1;function ea(n){let e=hd(null);return md(e,yd(n)),vi(e)}var bd=ea(le.prototype),Wf=vi({kind:"nosuid"}),Gf=vi({kind:"trusted-root-product",guestWritable:!1,stableExecutableIdentity:!0}),vd=Symbol("DeferredTreeMaterializationHandle"),Er=[40,181,47,253],zi=1447449417,xi=1,gi=1,nn=2,Ei=4,Si=8,ye=16,{S_IFMT:Ee,S_IFREG:kt,S_IFDIR:Qe,S_IFLNK:Sr}=ie,{DT_UNKNOWN:Pd,DT_REG:kd,DT_DIR:Fd,DT_LNK:Nd}=wo,Cd=He.O_RDONLY,Hf=He.O_ACCMODE,Vf=He.O_CREAT,qf=He.O_TRUNC,Zf=Eo.W_OK,Ks=He.O_WRONLY|He.O_CREAT|He.O_TRUNC,Md=1024*1024,Dd=16*1024*1024,Ct=64*1024,on=16*1024*1024,sn=16*1024*1024,Bs=D.maxArchiveBytes,Kd=D.maxExpandedBytes,an=D.maxPayloadBytes,Bd=2,$d=4,Mt=D.maxEntries,ta=be.maxGroups,ln=D.maxPathBytes,ra=D.maxSymlinkTargetBytes,Pi=D.maxStringBytes,Ud=D.maxActivationCapabilities,Wd=D.maxActivationRoots,$s=D.maxActivationCapabilityBytes,Us=4294967294,Ws=3,Gd=250,na=5e3,Ii=/^[0-9a-f]{64}$/,wr="kandelo-legacy-zip-v1",Or="kandelo-deferred-tree-v1",Ti="kandelo-deferred-tree-v2",ft="kandelo-deferred-tree-v3",Hd=new Set(["ECONNABORTED","ECONNREFUSED","ECONNRESET","EHOSTUNREACH","ENETDOWN","ENETRESET","ENETUNREACH","EPIPE","ETIMEDOUT","EAI_AGAIN","UND_ERR_CONNECT_TIMEOUT","UND_ERR_HEADERS_TIMEOUT","UND_ERR_SOCKET"]),cn=class extends Error{constructor(t,r){super(`HTTP ${t}`);this.status=t;this.retryAfterMs=r;this.name="LazyHttpResponseError"}status;retryAfterMs};function Ar(n){if(typeof n!="string"||!n.startsWith("/")||new TextEncoder().encode(n).byteLength>ln||n.includes("\0")||n.includes("\\"))throw new Error(`Lazy archive mount prefix must be an absolute POSIX path: ${JSON.stringify(n)}`);let e=n.replace(/\/+$/,"");if(e==="")return"/";if(e.slice(1).split("/").some(r=>r===""||r==="."||r===".."))throw new Error(`Lazy archive mount prefix is not canonical: ${JSON.stringify(n)}`);return e}function Vd(n,e,t,r){let i=Ar(t),o=new Map,s=e.map(a=>{let c=a.fileName,u=`Lazy archive ${JSON.stringify(n)} member ${JSON.stringify(c)}`;if(c.length===0)throw new Error(`${u} has an empty path`);if(c.includes("\0"))throw new Error(`${u} contains a NUL byte`);if(c.includes("\\"))throw new Error(`${u} contains a backslash`);if(c.startsWith("/")||/^[A-Za-z]:\//.test(c))throw new Error(`${u} must be relative, not absolute`);if(a.isDirectory&&a.isSymlink)throw new Error(`${u} has conflicting directory and symlink types`);if(a.isDirectory!==c.endsWith("/"))throw new Error(`${u} has inconsistent directory metadata`);let l=a.isDirectory?c.slice(0,-1):c,d=l.split("/");if(l.length===0||d.some(h=>h===""||h==="."||h===".."))throw new Error(`${u} is not a canonical relative POSIX path`);if(o.has(l))throw new Error(`${u} collides with another member at ${JSON.stringify(l)}`);if(a.isSymlink&&!r?.has(c))throw new Error(`Lazy archive symlink target was not provided: ${c}`);return o.set(l,a),{entry:a,archivePath:l,vfsPath:i==="/"?`/${l}`:`${i}/${l}`}});for(let{archivePath:a}of s){let c=a.split("/");for(let u=1;uCt)throw new Error(`VFS image metadata exceeds ${Ct} bytes`);let e;try{e=JSON.parse(new TextDecoder().decode(n))}catch(t){let r=t instanceof Error?t.message:String(t);throw new Error(`Invalid VFS image metadata JSON: ${r}`)}return ki(e)}function Zd(n){if(n===null)return new Uint8Array(0);let e=ki(n),t=new TextEncoder().encode(JSON.stringify(e));if(t.byteLength>Ct)throw new Error(`VFS image metadata exceeds ${Ct} bytes`);return t}function Yd(n){return n.byteLength>=Er.length&&n[0]===Er[0]&&n[1]===Er[1]&&n[2]===Er[2]&&n[3]===Er[3]?El(n):n}function Qr(n){let e=Yd(n);if(e.byteLengthon)throw new Error(`VFS image lazy metadata exceeds ${on} bytes`);if(n.byteLengthsn)throw new Error(`VFS image lazy archive metadata exceeds ${sn} bytes`);if(n.byteLength=0?r:void 0}function Jd(n){return n===408||n===429||n>=500&&n<=599}function Qd(n,e=Date.now()){let t=n?.get("retry-after")?.trim();if(!t)return;let r;if(/^\d+$/.test(t))r=Number(t)*1e3;else{let i=Date.parse(t);if(!Number.isFinite(i))return;r=Math.max(0,i-e)}if(!(!Number.isSafeInteger(r)||r<0))return Math.min(r,na)}function el(n){if(!(typeof n!="object"||n===null||!("cause"in n)))return n.cause}function ia(n){if(!(typeof n!="object"||n===null||!("name"in n)))return typeof n.name=="string"?n.name:void 0}function oa(n){if(!(typeof n!="object"||n===null||!("code"in n)))return typeof n.code=="string"?n.code:void 0}function sa(n,e){let t=new Set,r=n;for(let i=0;r!==void 0&&i<8;i+=1){if(t.has(r))return!1;if(t.add(r),e(r))return!0;r=el(r)}return!1}function aa(n){return sa(n,e=>ia(e)==="AbortError"||oa(e)==="ABORT_ERR")}function tl(n){return aa(n)?!1:sa(n,e=>{let t=ia(e),r=oa(e);return e instanceof TypeError||t==="NetworkError"||t==="TimeoutError"||r!==void 0&&Hd.has(r)})}function rl(n,e){if(n instanceof cn){if(!Jd(n.status))return null;if(n.retryAfterMs!==void 0)return n.retryAfterMs}else if(!tl(n))return null;return Math.min(Gd*2**e,na)}function te(n){if(n?.aborted)throw n.reason}function nl(n,e){return te(e),n===0?Promise.resolve():new Promise((t,r)=>{let i=setTimeout(()=>a(!1),n),o=()=>a(!0,e.reason),s=!1;function a(c,u){s||(s=!0,clearTimeout(i),e?.removeEventListener("abort",o),c?r(u):t())}e?.addEventListener("abort",o,{once:!0}),e?.aborted&&o()})}async function wi(n,e){try{await n.body?.cancel(e)}catch{}}function il(n,e){if(n.length===1)return n[0];let t=new Uint8Array(e),r=0;for(let i of n)t.set(i,r),r+=i.byteLength;return t}function zr(n){if(n===void 0)return;if(typeof n!="object"||n===null||Array.isArray(n))throw new Error("Lazy archive integrity must be an object");let e=n;if(Object.keys(e).length!==2||!("sha256"in e)||!("bytes"in e))throw new Error("Lazy archive integrity has unexpected fields");if(typeof e.sha256!="string"||!Ii.test(e.sha256))throw new Error("Lazy archive integrity has an invalid SHA-256 digest");if(!Number.isSafeInteger(e.bytes)||Number(e.bytes)<=0||Number(e.bytes)>Bs)throw new Error(`Lazy archive integrity byte count must be between 1 and ${Bs}`);return{sha256:e.sha256,bytes:Number(e.bytes)}}function et(n,e,t){if(typeof n!="object"||n===null||Array.isArray(n))throw new Error(`${t} must be an object`);let r=n;if(Object.keys(r).length!==e.length||e.some(o=>!Object.prototype.hasOwnProperty.call(r,o)))throw new Error(`${t} has unexpected or missing fields`);return r}function Ri(n,e,t,r){if(typeof n!="object"||n===null||Array.isArray(n))throw new Error(`${r} must be an object`);let i=n,o=new Set(e);if(Object.keys(i).some(s=>!o.has(s))||t.some(s=>!Object.prototype.hasOwnProperty.call(i,s)))throw new Error(`${r} has unexpected or missing fields`);return i}function Me(n,e,t,r){if(!Array.isArray(n)||n.lengthr)throw new Error(`${e} must contain ${t} to ${r} items`);return n}function Se(n,e,t){if(typeof n!="string")throw new Error(`${e} is invalid or exceeds ${t} bytes`);if(Rt(n,e),n.length===0||n.includes("\0")||new TextEncoder().encode(n).byteLength>t)throw new Error(`${e} is invalid or exceeds ${t} bytes`);return n}function ce(n,e,t,r){if(!Number.isSafeInteger(n)||Number(n)r)throw new Error(`${e} must be an integer between ${t} and ${r}`);return Number(n)}function un(n,e=1){let t=n,r=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.source!==void 0,i=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.modePolicy!==void 0,o=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.materialization!==void 0,s=et(n,["decoder","mediaType","sha256","bytes","expandedBytes","sourceEntryCount","transports",...i?["modePolicy"]:[],...r?["source"]:[],...o?["materialization"]:[]],"Lazy tree content"),a=s.decoder==="zip-v1"?"application/zip":s.decoder==="tar-gzip-v1"?"application/vnd.oci.image.layer.v1.tar+gzip":null;if(a===null||s.mediaType!==a)throw new Error("Lazy tree decoder and media type are inconsistent");let c=zr({sha256:s.sha256,bytes:s.bytes});if(!c)throw new Error("Lazy tree integrity is required");let u=Me(s.transports,"Lazy tree transports",e,D.maxTransportsPerTree).map((p,_)=>Se(p,`Lazy tree transport ${_}`,Pi));if(new Set(u).size!==u.length)throw new Error("Lazy tree transports contain duplicates");let l=ce(s.expandedBytes,"Lazy tree expanded byte count",0,Kd),d=ce(s.sourceEntryCount,"Lazy tree source entry count",1,Mt),h=r?al(s.source,s.decoder):void 0,m=o?as(s.materialization,h):void 0,f=i?s.modePolicy:void 0;if(f!==void 0&&(f!=="portable-posix-v1"||s.decoder!=="zip-v1"||r))throw new Error("Lazy tree mode policy is invalid for its decoder");if(h!==void 0&&h.entries.length!==d)throw new Error("Lazy tree source inventory count differs from its content");return{decoder:s.decoder,mediaType:a,sha256:c.sha256,bytes:c.bytes,expandedBytes:l,sourceEntryCount:d,transports:u,...f===void 0?{}:{modePolicy:f},...h===void 0?{}:{source:h},...m===void 0?{}:{materialization:m}}}function ca(n){let e={groups:n.length,archiveBytes:0,expandedBytes:0,payloadBytes:0,entries:0};for(let t of n)t.content===void 0||t.inventory===void 0||(e.archiveBytes+=t.content.bytes,e.expandedBytes+=t.content.expandedBytes,e.payloadBytes+=t.inventory.filter(r=>r.type==="file").reduce((r,i)=>r+i.size,0),e.entries+=t.inventory.length+(t.content.source?.entries.length??0));return e}function Li(n){is(n,"Serialized lazy tree collection")}function ol(n){Li(ca(n))}function sl(n){let e=new Map;for(let t of n){let r=t.activation?.atomicGroup;if(r===void 0)continue;if(!Ft(r))throw new Error(`Serialized lazy atomic activation group ${r.id} is unsealed`);let i=e.get(r.id);if(i===void 0)i={expectedCount:r.expectedCount,cohortSha256:r.cohortSha256,members:new Set,descriptors:new Set},e.set(r.id,i);else if(i.expectedCount!==r.expectedCount||i.cohortSha256!==r.cohortSha256)throw new Error(`Serialized lazy atomic activation group ${r.id} has inconsistent seals`);if(i.members.has(r.member)||i.descriptors.has(r.descriptorSha256))throw new Error(`Serialized lazy atomic activation group ${r.id} duplicates a member`);i.members.add(r.member),i.descriptors.add(r.descriptorSha256)}for(let[t,r]of e)if(r.members.size!==r.expectedCount)throw new Error(`Serialized lazy atomic activation group ${t} has ${r.members.size} of ${r.expectedCount} members`)}function qs(n){for(let[e,t]of n.entries())if(t.kind===Or||t.kind===Ti||t.kind===ft)la(t,t.kind);else if(t.kind===wr)bi(t,!1);else throw new Error(`Serialized lazy archive group ${e} has an unsupported kind`);ol(n),sl(n)}function al(n,e){if(e!=="zip-v1"&&e!=="tar-gzip-v1")throw new Error("Lazy tree source inventory requires a supported archive decoder");let t=et(n,["schema","kind","entries"],"Lazy tree source inventory");if(t.schema!==1||t.kind!=="archive-source-inventory-v1")throw new Error("Lazy tree source inventory has an unsupported identity");let r=new Map,i=Me(t.entries,"Lazy tree source entries",1,Mt).map((s,a)=>{let c=s,u=typeof c=="object"&&c!==null&&!Array.isArray(c)?c.type:void 0,l=u==="directory"||u==="file"?["sourcePath","type","mode","size"]:u==="symlink"||u==="hardlink"?["sourcePath","type","mode","size","target"]:null;if(l===null)throw new Error(`Lazy tree source entry ${a} has invalid type`);let d=et(s,l,`Lazy tree source entry ${a}`),h=we(d.sourcePath,!1,`Lazy tree source entry ${a} path`);if(r.has(h))throw new Error(`Lazy tree source inventory duplicates ${h}`);let m=ce(d.mode,`Lazy tree source entry ${h} mode`,0,ie.S_MODE_BITS),f=ce(d.size,`Lazy tree source entry ${h} size`,0,an),p;if((u==="directory"||u==="symlink"||u==="hardlink")&&f!==0)throw new Error(`Lazy tree source ${h} has payload for ${String(u)}`);u==="symlink"?p=Se(d.target,`Lazy tree source symlink ${h} target`,ra):u==="hardlink"&&(p=we(d.target,!1,`Lazy tree source hardlink ${h} target`));let _={sourcePath:h,type:u,mode:m,size:f,...p===void 0?{}:{target:p}};return r.set(h,_),_}),o=i.map(s=>s.sourcePath);if(o.some((s,a)=>a>0&&lr(o[a-1],s)>=0))throw new Error("Lazy tree source inventory is not in canonical path order");return{schema:1,kind:"archive-source-inventory-v1",entries:i}}function cl(n){let e=new Map(n.map(r=>[r.sourcePath,r])),t=new Map;for(let r of n){if(r.type!=="hardlink"||t.has(r.sourcePath))continue;let i=[],o=new Set,s=r,a;for(;s.type==="hardlink"&&(a=t.get(s.sourcePath),a===void 0);){if(o.has(s.sourcePath))throw new Error(`Lazy tree source hardlink cycle includes ${s.sourcePath}`);o.add(s.sourcePath),i.push(s);let c=e.get(s.target);if(c===void 0)throw new Error(`Lazy tree source hardlink ${s.sourcePath} target is absent`);if(c.type!=="file"&&c.type!=="hardlink")throw new Error(`Lazy tree source hardlink ${s.sourcePath} target is not regular`);s=c}a===void 0&&(a=s);for(let c of i)t.set(c.sourcePath,a)}return t}function we(n,e,t,r=!1){if(typeof n!="string"||n.length===0||new TextEncoder().encode(n).byteLength>ln||n.includes("\0")||n.includes("\\")||n.startsWith("/")!==e)throw new Error(`${t} is not a canonical ${e?"absolute":"relative"} path`);if(r&&e&&n==="/")return n;if(n.slice(e?1:0).split("/").some(o=>o===""||o==="."||o===".."))throw new Error(`${t} has an unsafe path segment`);return n}function ua(n){let e=typeof n=="object"&&n!==null&&!Array.isArray(n)?n:null,t=e!==null&&(Object.hasOwn(e,"descriptorSha256")||Object.hasOwn(e,"expectedCount")||Object.hasOwn(e,"cohortSha256")),r=et(n,t?["id","member","descriptorSha256","expectedCount","cohortSha256"]:["id","member"],"Lazy tree atomic activation membership"),i=Se(r.id,"Lazy tree atomic activation group",$s),o=Se(r.member,"Lazy tree atomic activation member",$s);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(i)||!/^[a-z0-9][a-z0-9:+._/-]*$/.test(o)||o.includes("//")||o.endsWith("/"))throw new Error("Lazy tree atomic activation membership is invalid");if(!t)return{id:i,member:o};let s=Se(r.descriptorSha256,"Lazy tree atomic member descriptor digest",64),a=Se(r.cohortSha256,"Lazy tree atomic cohort digest",64);if(!Ii.test(s)||!Ii.test(a))throw new Error("Lazy tree atomic activation digest is invalid");return{id:i,member:o,descriptorSha256:s,expectedCount:ce(r.expectedCount,"Lazy tree atomic activation expected member count",1,ta),cohortSha256:a}}function Ft(n){return n.descriptorSha256!==void 0&&n.expectedCount!==void 0&&n.cohortSha256!==void 0}function ul(n){let e=et(n,["uid","gid"],"Lazy tree registration owner");return{uid:ce(e.uid,"Lazy tree registration owner uid",0,Us),gid:ce(e.gid,"Lazy tree registration owner gid",0,Us)}}function da(n,e,t,r,i=1){let o=un(n,i),s=Ar(t),a=["mode","capabilities","roots",...typeof r=="object"&&r!==null&&!Array.isArray(r)&&Object.hasOwn(r,"atomicGroup")?["atomicGroup"]:[]],c=et(r,a,"Lazy tree activation");if(c.mode!=="boot-prefetch"&&c.mode!=="first-use")throw new Error("Lazy tree activation mode is invalid");let u=Me(c.capabilities,"Lazy tree activation capabilities",1,Ud).map((z,x)=>{let I=Se(z,`Lazy tree activation capability ${x}`,D.maxActivationCapabilityBytes);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(I))throw new Error(`Lazy tree activation capability ${x} is invalid`);return I}),l=Me(c.roots,"Lazy tree activation roots",1,Wd).map((z,x)=>we(z,!0,`Lazy tree activation root ${x}`,!0));if(new Set(u).size!==u.length||new Set(l).size!==l.length)throw new Error("Lazy tree activation contains duplicates");let d=c.atomicGroup===void 0?void 0:ua(c.atomicGroup);if(d!==void 0&&c.mode!=="first-use")throw new Error("Lazy tree atomic activation group requires a valid first-use identity");let h={mode:c.mode,capabilities:u,roots:l,...d===void 0?{}:{atomicGroup:d}},m=Me(e,"Lazy tree inventory",1,Mt),f=[],p=new Map,_=new Map,y=o.source===void 0?void 0:new Map(o.source.entries.map(z=>[z.sourcePath,z])),g=o.source===void 0?void 0:cl(o.source.entries),E=new Map(o.materialization?.transforms.map(z=>[z.sourcePath,z])??[]),O=0;for(let[z,x]of m.entries()){if(typeof x!="object"||x===null||Array.isArray(x))throw new Error(`Lazy tree entry ${z} must be an object`);let I=x.type,R=I==="directory"?["vfsPath","sourcePath","type","mode","size"]:I==="file"?["vfsPath","sourcePath","type","mode","size","inodeGroup"]:I==="symlink"?["vfsPath","sourcePath","type","mode","size","target"]:I==="hardlink"?["vfsPath","sourcePath","type","mode","size","target","inodeGroup"]:null;if(!R)throw new Error(`Lazy tree entry ${z} has an invalid type`);let L=et(x,[...R,...y===void 0?[]:["materialization"]],`Lazy tree entry ${z}`),v=we(L.vfsPath,!0,`Lazy tree entry ${z} VFS path`),Z=we(L.sourcePath,!1,`Lazy tree entry ${z} source path`),N=y===void 0?void 0:L.materialization;if(y!==void 0&&N!=="archive"&&N!=="archive-copy"&&N!=="archive-copy-mode"&&N!=="descriptor")throw new Error(`Lazy tree entry ${v} has invalid materialization provenance`);if(s!=="/"&&v!==s&&!v.startsWith(`${s}/`))throw new Error(`Lazy tree entry ${v} escapes its mount prefix`);if(p.has(v))throw new Error(`Lazy tree duplicates VFS path ${v}`);let k=ce(L.mode,`Lazy tree entry ${v} mode`,0,ie.S_MODE_BITS),G=ce(L.size,`Lazy tree entry ${v} size`,0,an),ue,Oe;if(I==="directory"){if(G!==0)throw new Error(`Lazy tree directory ${v} has nonzero size`)}else if(I==="symlink"){if(ue=Se(L.target,`Lazy tree symlink ${v} target`,ra),new TextEncoder().encode(ue).byteLength!==G)throw new Error(`Lazy tree symlink ${v} size differs from its target`)}else Oe=Se(L.inodeGroup,`Lazy tree entry ${v} inode group`,ln),I==="hardlink"&&(ue=we(L.target,!0,`Lazy tree hardlink ${v} target`));if(I!=="hardlink"&&(O+=G,O>an))throw new Error("Lazy tree inventory exceeds the expansion limit");let M={vfsPath:v,sourcePath:Z,...N===void 0?{}:{materialization:N},type:I,mode:k,size:G,...ue===void 0?{}:{target:ue},...Oe===void 0?{}:{inodeGroup:Oe}};if(y===void 0){let de=_.get(Z);if(de){if(o.decoder!=="zip-v1"||M.type!=="hardlink"||de.inodeGroup!==M.inodeGroup)throw new Error(`Lazy tree duplicates source path ${Z}`)}else{if(o.decoder==="zip-v1"&&M.type==="hardlink")throw new Error(`Lazy ZIP hardlink ${v} does not reuse a canonical source path`);_.set(Z,M)}}else if(M.materialization==="descriptor"){if(M.type!=="directory"&&M.type!=="symlink")throw new Error(`Lazy tree descriptor entry ${v} is not structural`);if(y.has(Z))throw new Error(`Lazy tree descriptor entry ${v} impersonates a source member`)}else{let de=y.get(Z);if(de===void 0)throw new Error(`Lazy tree entry ${v} names absent source ${Z}`);if(M.materialization==="archive-copy"||M.materialization==="archive-copy-mode"){if(M.type!=="file"||de.type!=="file"||M.materialization==="archive-copy"&&M.mode!==de.mode)throw new Error(`Lazy tree archive copy ${v} differs from its source`)}else if(de.type!==M.type||M.type==="symlink"&&de.target!==M.target||M.type!=="hardlink"&&de.mode!==M.mode)throw new Error(`Lazy tree archive entry ${v} differs from its source`)}f.push(M),p.set(v,M)}for(let z of f){let x=z.vfsPath.split("/").filter(Boolean);for(let I=1;I({path:z.vfsPath,type:z.type,mode:z.mode,size:z.size,target:z.target,inodeGroup:z.inodeGroup})),"Lazy tree");if(y!==void 0){let z=new Set;for(let x of f){if(x.materialization==="descriptor"||x.type!=="file"&&x.type!=="hardlink")continue;let I=y.get(x.sourcePath),R=I.type==="file"?I:g.get(I.sourcePath);R?.type==="file"&&z.add(R.sourcePath);let L=R?.type==="file"?E.get(R.sourcePath):void 0;if(R?.type!=="file"||x.size!==(L?.output.bytes??R.size))throw new Error(`Lazy tree archive entry ${x.vfsPath} differs from its source`)}for(let x of E.keys())if(!z.has(x))throw new Error(`Lazy tree materialization transform ${x} has no destination`);for(let x of f){if(x.type!=="hardlink"||x.materialization!=="archive")continue;let I=y.get(x.sourcePath),R=p.get(x.target),L=g.get(I.sourcePath);if(I.target!==R?.sourcePath||L?.type!=="file"||L.mode!==x.mode||R?.mode!==x.mode)throw new Error(`Lazy tree hardlink ${x.vfsPath} differs from its source`)}}if(o.sourceEntryCount!==(y===void 0?_.size:y.size))throw new Error("Lazy tree source entry count differs from its inventory");if(o.source===void 0&&o.expandedBytesx.vfsPath===z||x.vfsPath.startsWith(`${z}/`)))throw new Error(`Lazy tree activation root ${z} is not owned by its inventory`);let w=new Map;for(let z of f)z.type==="file"&&w.set(z.inodeGroup,z);if(w.size!==S.canonicalByGroup.size)throw new Error("Lazy tree regular inode inventory is inconsistent");return{content:o,entries:f,mountPrefix:s,activation:h,canonicalByGroup:w}}function dn(n){return JSON.stringify([n.sourcePath,n.type,n.inodeGroup,n.target])}function bi(n,e){let t=Ri(n,["kind","content","url","mountPrefix","integrity","materialized","entries"],["url","mountPrefix","materialized","entries"],"Serialized legacy lazy archive");if(t.kind===void 0){if(!e)throw new Error("Serialized lazy archive is missing its kind discriminator")}else if(t.kind!==wr)throw new Error("Serialized legacy lazy archive has an unsupported kind");let r=Se(t.url,"Serialized legacy lazy archive URL",Pi),i=Ar(t.mountPrefix),o=zr(t.integrity);if(t.content!==void 0){if(!e||t.kind!==void 0)throw new Error("Typed legacy lazy archives cannot carry generic content");let c=un(t.content);if(c.decoder!=="zip-v1"||c.transports.length!==1||c.transports[0]!==r||!o||c.sha256!==o.sha256||c.bytes!==o.bytes)throw new Error("Untagged legacy ZIP content identity is inconsistent")}if(t.materialized!==!1)throw new Error("Serialized legacy lazy archive must describe pending content");let s=new Set,a=Me(t.entries,"Serialized legacy lazy archive entries",1,Mt).map((c,u)=>{let l=Ri(c,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","size","isSymlink","deleted"],`Serialized legacy lazy archive entry ${u}`),d=we(l.vfsPath,!0,`Serialized legacy lazy archive entry ${u} VFS path`);if(s.has(d))throw new Error(`Serialized legacy lazy archive duplicates path ${d}`);s.add(d);let h=ce(l.ino,`Serialized legacy lazy archive entry ${d} inode`,1,Number.MAX_SAFE_INTEGER),m=l.generation===void 0?void 0:ce(l.generation,`Serialized legacy lazy archive entry ${d} generation`,0,Number.MAX_SAFE_INTEGER),f=l.dataSequence===void 0?void 0:ce(l.dataSequence,`Serialized legacy lazy archive entry ${d} data sequence`,0,Number.MAX_SAFE_INTEGER),p=ce(l.size,`Serialized legacy lazy archive entry ${d} size`,0,an);if(l.isSymlink!==!1||l.deleted!==!1||l.materialized!==void 0&&l.materialized!==!1)throw new Error(`Serialized legacy lazy archive entry ${d} is not pending`);if(l.type!==void 0&&l.type!=="file")throw new Error(`Serialized legacy lazy archive entry ${d} has an invalid type`);let _=l.archivePath===void 0?void 0:we(l.archivePath,!1,`Serialized legacy lazy archive entry ${d} archive path`),y=l.sourcePath===void 0?void 0:we(l.sourcePath,!1,`Serialized legacy lazy archive entry ${d} source path`),g=l.inodeGroup===void 0?void 0:Se(l.inodeGroup,`Serialized legacy lazy archive entry ${d} inode group`,ln);if(l.target!==void 0)throw new Error(`Serialized legacy lazy archive entry ${d} has a link target`);return{vfsPath:d,ino:h,...m===void 0?{}:{generation:m},...f===void 0?{}:{dataSequence:f},size:p,isSymlink:!1,deleted:!1,materialized:!1,..._===void 0?{}:{archivePath:_},...y===void 0?{}:{sourcePath:y},type:"file",...g===void 0?{}:{inodeGroup:g}}});return{kind:wr,url:r,mountPrefix:i,...o===void 0?{}:{integrity:o},materialized:!1,entries:a}}function la(n,e){let t=et(n,["kind","content","inventory","activation","url","mountPrefix","integrity","materialized","entries"],"Serialized lazy tree");if(t.kind!==e)throw new Error("Serialized lazy tree has an unsupported kind");let r=da(t.content,t.inventory,t.mountPrefix,t.activation);if(e!==ft&&e===Or!=(r.content.source===void 0))throw new Error(e===Or?"Serialized deferred-tree-v1 cannot contain complete source metadata":"Serialized deferred-tree-v2 requires complete source metadata");let i=r.activation.atomicGroup;if(e===ft?i===void 0||!Ft(i):i!==void 0)throw new Error(e===ft?"Serialized deferred-tree-v3 requires a sealed atomic activation":"Atomic activation requires serialized deferred-tree-v3");let o=Se(t.url,"Serialized lazy tree URL",Pi);if(o!==r.content.transports[0])throw new Error("Serialized lazy tree URL differs from its primary transport");let s=zr(t.integrity);if(!s||s.sha256!==r.content.sha256||s.bytes!==r.content.bytes)throw new Error("Serialized lazy tree integrity differs from its content");if(t.materialized!==!1)throw new Error("Serialized lazy tree must describe pending content");let a=new Map(r.entries.map(h=>[h.vfsPath,h])),c=new Map(r.entries.map(h=>[dn(h),h])),u=Me(t.entries,"Serialized lazy tree entries",0,Mt),l=new Set,d=u.map((h,m)=>{let f=Ri(h,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup"],`Serialized lazy tree entry ${m}`),p=we(f.vfsPath,!0,`Serialized lazy tree entry ${m} VFS path`);if(l.has(p))throw new Error(`Serialized lazy tree duplicates pending path ${p}`);l.add(p);let _=we(f.sourcePath,!1,`Serialized lazy tree entry ${m} source path`),y=we(f.archivePath,!1,`Serialized lazy tree entry ${m} archive path`),g=a.get(p),E=c.get(dn({sourcePath:_,type:typeof f.type=="string"?f.type:void 0,inodeGroup:typeof f.inodeGroup=="string"?f.inodeGroup:void 0,target:typeof f.target=="string"?f.target:void 0}))??g;if(!E||E.type!=="file"&&E.type!=="hardlink"||g?.inodeGroup!==void 0&&g.inodeGroup!==E.inodeGroup)throw new Error(`Serialized lazy tree entry ${p} is absent from its inventory`);let O=r.canonicalByGroup.get(E.inodeGroup);if(f.type!==E.type||f.inodeGroup!==E.inodeGroup||f.size!==E.size||y!==O?.sourcePath||f.target!==E.target||f.isSymlink!==!1||f.deleted!==!1||f.materialized!==!1)throw new Error(`Serialized lazy tree entry ${p} disagrees with its inventory`);let S=ce(f.ino,`Serialized lazy tree entry ${p} inode`,1,Number.MAX_SAFE_INTEGER),w=ce(f.generation,`Serialized lazy tree entry ${p} generation`,0,Number.MAX_SAFE_INTEGER),z=ce(f.dataSequence,`Serialized lazy tree entry ${p} data sequence`,0,Number.MAX_SAFE_INTEGER);return{vfsPath:p,ino:S,generation:w,dataSequence:z,size:E.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:y,sourcePath:_,type:E.type,inodeGroup:E.inodeGroup,...E.target===void 0?{}:{target:E.target}}});for(let h of r.entries)if(r.activation.atomicGroup!==void 0&&(h.type==="file"||h.type==="hardlink")&&!l.has(h.vfsPath))throw new Error(`Serialized lazy tree omits pending path ${h.vfsPath}`);return{kind:e,content:r.content,inventory:r.entries,activation:r.activation,url:o,mountPrefix:r.mountPrefix,integrity:s,materialized:!1,entries:d}}async function Nt(n,e){let t=globalThis.crypto?.subtle;if(!t)throw new Error(`${e} SHA-256 verification is unavailable`);let r=new Uint8Array(n.byteLength);r.set(n);let i=new Uint8Array(await t.digest("SHA-256",r));return Array.from(i,o=>o.toString(16).padStart(2,"0")).join("")}async function Oi(n,e,t){if(t===void 0)return;if(n.byteLength!==t.bytes)throw new Error(`Lazy ${e} byte count ${n.byteLength} does not match expected ${t.bytes}`);let r=await Nt(n,`Lazy ${e}`);if(r!==t.sha256)throw new Error(`Lazy ${e} SHA-256 ${r} does not match expected ${t.sha256}`)}async function Zs(n,e,t){if(n.byteLength!==e.bytes)throw new Error(`${t} byte count ${n.byteLength} does not match expected ${e.bytes}`);let r=await Nt(n,t);if(r!==e.sha256)throw new Error(`${t} SHA-256 ${r} does not match expected ${e.sha256}`)}function dl(n,e,t,r){let i=r.atomicGroup;if(i===void 0)throw new Error("Lazy atomic member is missing its typed tree descriptor");let o={schema:1,content:{decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...n.source===void 0?{}:{source:n.source},...n.materialization===void 0?{}:{materialization:n.materialization}},mountPrefix:t,inventory:[...e].sort((s,a)=>lr(s.vfsPath,a.vfsPath)),activation:{mode:r.mode,capabilities:r.capabilities,roots:r.roots,atomicGroup:{id:i.id,member:i.member}}};return new TextEncoder().encode(JSON.stringify(o))}function Ys(n,e){let t=e.map(({member:r,descriptorSha256:i})=>({member:r,descriptorSha256:i}));return new TextEncoder().encode(JSON.stringify({schema:1,id:n,members:t.sort((r,i)=>r.memberi.member?1:0)}))}function ll(n,e){if(n.byteLength!==e.byteLength)return!1;for(let t=0;tObject.freeze({...o}))};r!==void 0&&(Object.freeze(r.entries),Object.freeze(r));let i=n.materialization===void 0?void 0:fl(n.materialization);return Object.freeze({decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,transports:t,...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...r===void 0?{}:{source:r},...i===void 0?{}:{materialization:i}})}function fa(n){return{schema:1,kind:"archive-byte-transforms-v1",assertions:n.assertions.map(e=>({...e})),recipes:n.recipes.map(e=>({id:e.id,replacements:e.replacements.map(t=>({...t})),rejectHex:[...e.rejectHex]})),transforms:n.transforms.map(e=>({sourcePath:e.sourcePath,recipe:e.recipe,input:{...e.input},output:{...e.output}}))}}function fl(n){let e=fa(n);for(let t of e.assertions)Object.freeze(t);Object.freeze(e.assertions);for(let t of e.recipes){for(let r of t.replacements)Object.freeze(r);Object.freeze(t.replacements),Object.freeze(t.rejectHex),Object.freeze(t)}Object.freeze(e.recipes);for(let t of e.transforms)Object.freeze(t.input),Object.freeze(t.output),Object.freeze(t);return Object.freeze(e.transforms),Object.freeze(e)}function en(n){return{decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,transports:[...n.transports],...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...n.source===void 0?{}:{source:{schema:1,kind:"archive-source-inventory-v1",entries:n.source.entries.map(e=>({...e}))}},...n.materialization===void 0?{}:{materialization:fa(n.materialization)}}}function pa(n){let e=n.map(t=>Object.freeze({...t}));return Object.freeze(e),e}function pl(n,e,t){let r=[...n.capabilities],i=[...n.roots];return Object.freeze(r),Object.freeze(i),Object.freeze({mode:n.mode,capabilities:r,roots:i,atomicGroup:Object.freeze({id:e,member:t})})}function hl(n){let e=[...n.capabilities],t=[...n.roots];return Object.freeze(e),Object.freeze(t),Object.freeze({mode:n.mode,capabilities:e,roots:t})}function tn(n,e,t,r,i,o,s,a){let c=s.map(u=>Object.freeze({...u}));return Object.freeze(c),Object.freeze({content:fn(n),inventory:pa(e),activation:hl(t),url:r,mountPrefix:i,integrity:Object.freeze({...o}),entries:c,materialized:a})}function ml(n,e,t){let r=e.map(i=>Object.freeze({...i}));return Object.freeze(r),Object.freeze({...n,entries:r,materialized:t})}function Ai(n){return Array.from(n,([e,t])=>({vfsPath:e,...t}))}function Xs(n){return new Map(n.map(({vfsPath:e,...t})=>[e,t]))}function yl(n){return{mode:n.activation.mode,capabilities:[...n.activation.capabilities],roots:[...n.activation.roots],atomicGroup:{id:n.id,member:n.member,descriptorSha256:n.descriptorSha256,expectedCount:n.expectedCount,cohortSha256:n.cohortSha256}}}function _l(n,e){return n.ino===e.ino&&n.generation===e.generation&&n.dataSequence===e.dataSequence&&n.size===e.size&&n.isSymlink===e.isSymlink&&n.deleted===e.deleted&&n.materialized===e.materialized&&n.archivePath===e.archivePath&&n.sourcePath===e.sourcePath&&n.type===e.type&&n.inodeGroup===e.inodeGroup&&n.target===e.target}function rn(n,e,t){let r=n.content,i=n.inventory,o=n.activation,s=n.integrity,a=n.entries,c=n.url,u=n.mountPrefix,l=n.materialized,d=o?.atomicGroup;if(r===void 0||i===void 0||o===void 0||d===void 0||o.mode!=="first-use"||d.id!==e||d.member!==t||l)throw new Error(`Lazy atomic activation member ${t} changed before snapshot`);if(s?.sha256!==r.sha256||s?.bytes!==r.bytes||c!==(r.transports[0]??""))throw new Error(`Lazy atomic activation member ${t} has inconsistent integrity`);let h=fn(r),m=pa(i),f=pl(o,e,t),p=new Map;for(let O of m)O.type==="file"&&p.set(O.inodeGroup,O.sourcePath);let _=m.filter(O=>O.type!=="directory");if(a.size!==_.length)throw new Error(`Lazy atomic activation member ${t} has inconsistent runtime entries`);let y=_.map(O=>{let S=a.get(O.vfsPath),w=O.type==="symlink",z=w?O.sourcePath:p.get(O.inodeGroup),x=S!==void 0&&(S.sourcePath===O.sourcePath&&S.type===O.type&&S.target===O.target||O.type==="hardlink"&&S.sourcePath===z&&S.type==="file"&&S.target===void 0),I=S===void 0?["missing"]:[z===void 0?"archivePath source":void 0,S.generation===void 0?"generation":void 0,S.dataSequence===void 0?"dataSequence":void 0,S.size!==O.size?"size":void 0,S.isSymlink!==w?"symlink kind":void 0,S.deleted?"deletion state":void 0,S.materialized!==w?"materialization state":void 0,S.archivePath!==z?"archivePath":void 0,x?void 0:"descriptor mapping",S.inodeGroup!==O.inodeGroup?"inode group":void 0].filter(L=>L!==void 0);if(I.length>0)throw new Error(`Lazy atomic activation member ${t} has inconsistent mapping at ${O.vfsPath}: ${I.join(", ")}`);let R=S;return Object.freeze({vfsPath:O.vfsPath,ino:R.ino,generation:R.generation,dataSequence:R.dataSequence,size:R.size,isSymlink:R.isSymlink,deleted:!1,materialized:R.materialized,archivePath:z,sourcePath:O.sourcePath,type:O.type,...O.inodeGroup===void 0?{}:{inodeGroup:O.inodeGroup},...O.target===void 0?{}:{target:O.target}})});Object.freeze(y);let g=Object.freeze({sha256:h.sha256,bytes:h.bytes}),E=dl(h,m,u,f);return Object.freeze({id:e,member:t,descriptorBytes:E,content:h,inventory:m,activation:f,url:h.transports[0]??"",mountPrefix:u,integrity:g,entries:y})}function js(n,e,t,r){return Object.freeze({...n,descriptorSha256:e,expectedCount:t,cohortSha256:r})}function Js(n,e){return n.id!==e.id||n.member!==e.member||n.url!==e.url||n.mountPrefix!==e.mountPrefix||n.integrity.sha256!==e.integrity.sha256||n.integrity.bytes!==e.integrity.bytes||n.content.transports.length!==e.content.transports.length||n.content.transports.some((t,r)=>t!==e.content.transports[r])||!ll(n.descriptorBytes,e.descriptorBytes)||n.entries.length!==e.entries.length?!1:n.entries.every((t,r)=>{let i=e.entries[r];return i!==void 0&&t.vfsPath===i.vfsPath&&_l(t,i)})}function gl(n,e){let t=fn(n.content,n.content.transports.map(e));return Object.freeze({...n,content:t,url:t.transports[0]??""})}function Qs(n){return{paths:Array.from(n.paths),expectedIno:n.ino,expectedGeneration:n.generation,expectedDataSequence:n.dataSequence,data:n.content}}function V(n,e){return`${n}:${e}`}var q=class n{fs;imageMetadata;lazyFiles=new Map;lazyArchiveGroups=[];deferredTreeMaterializationHandles=new WeakMap;lazyArchiveInodes=new Map;lazyAtomicGroups=new Map;lazyAtomicGroupByTree=new WeakMap;sealedLazyAtomicStates=new WeakMap;ordinaryLazyTreeDefinitions=new WeakMap;lazyDownloadListeners=new Set;lazyPreparations=new Map;lazyTransport={fetcher:(e,t)=>t===void 0?globalThis.fetch(e):globalThis.fetch(e,t)};constructor(e,t=null){this.fs=e,this.imageMetadata=t,lt(Sd,Rd,[this])}snapshotForImmutableProduct(){if(this.lazyFiles.size!==0||this.lazyArchiveInodes.size!==0)throw new Error("immutable product source must be completely materialized");let{bytes:e}=lt(Td,this.fs,[]),t=new Ms(e.byteLength);lt(Ed,new gd(t),[e]);let r=lt(xd,le,[t,{restoreImage:!0}]);return _d(r,bd),new n(r,Gs(this.imageMetadata))}qualifiedInodeIdentity(e){let t=this.fs.lstat(e),r=lt(wd,Ds,[this.fs.buffer]);return r===void 0&&(r=Ld++,lt(Od,Ds,[this.fs.buffer,r])),{dev:r,ino:t.ino,generation:t.generation}}static canAdoptLegacyLazyStub(e){return(e.mode&Ee)===kt&&e.size===0&&e.dataSequence<=1}replaceOrdinaryLazyTreeRuntimeState(e,t,r){let i=this.ordinaryLazyTreeDefinitions.get(e);if(i===void 0)return;let o=ml(i,t,r);this.ordinaryLazyTreeDefinitions.set(e,o);try{e.entries=Xs(o.entries),e.materialized=o.materialized}catch{}return o}reconcileLazyIdentityState(e){for(let[t,r]of this.lazyFiles){let i=e.get(t);if(!i||i.dataSequence!==r.dataSequence||i.paths.length===0){this.lazyFiles.delete(t);continue}r.paths=new Set(i.paths),r.paths.has(r.path)||(r.path=i.paths[0])}this.lazyArchiveInodes.clear();for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(!r?.committed)for(let c of i.snapshot.entries){if(c.isSymlink||c.materialized||c.generation===void 0)continue;let u=V(c.ino,c.generation),l=e.get(u);l!==void 0&&l.dataSequence===c.dataSequence&&l.paths.length>0&&this.lazyArchiveInodes.set(u,t)}continue}let o=this.ordinaryLazyTreeDefinitions.get(t);if(o!==void 0){if(o.materialized){this.replaceOrdinaryLazyTreeRuntimeState(t,o.entries,!0);continue}let c=new Map,u=o.entries.filter(l=>l.deleted||l.materialized||l.isSymlink).map(l=>({...l}));for(let l of o.entries){if(l.deleted||l.materialized||l.isSymlink||l.generation===void 0)continue;let d=V(l.ino,l.generation),h=c.get(d)??[];h.push(l),c.set(d,h)}for(let[l,d]of c){let h=e.get(l);if(h===void 0||h.dataSequence!==(d[0].dataSequence??0)){if(h!==void 0)for(let p of d)u.push({...p,materialized:!0});continue}let m=new Map(d.map(p=>[p.vfsPath,p])),f=d.find(p=>p.type==="file")??d[0];for(let p of h.paths){let _=m.get(p)??f;u.push({..._,vfsPath:p,ino:h.ino,generation:h.generation,dataSequence:h.dataSequence,deleted:!1,materialized:!1})}h.paths.length>0&&this.lazyArchiveInodes.set(l,t)}this.replaceOrdinaryLazyTreeRuntimeState(t,u,!1);continue}let s=new Map;for(let c of t.entries.values()){if(c.deleted||c.materialized||c.generation===void 0)continue;let u=V(c.ino,c.generation);s.has(u)||s.set(u,c)}let a=new Map(Array.from(t.entries.entries()).filter(([,c])=>c.deleted||c.isSymlink&&!c.deleted));for(let[c,u]of s){let l=e.get(c);if(!(!l||l.dataSequence!==(u.dataSequence??0))){for(let d of l.paths)a.set(d,{...u,ino:l.ino,generation:l.generation,dataSequence:l.dataSequence,deleted:!1,materialized:!1});l.paths.length>0&&this.lazyArchiveInodes.set(c,t)}}t.entries=a,t.materialized=!Array.from(a.values()).some(c=>!c.isSymlink&&!c.materialized)&&(r===void 0||r.committed)}}validatePendingLazyTreeNamespaceState(e){let t=new Map;for(let r of e.values())for(let i of r.paths){if(t.has(i))throw new Error(`SharedFS namespace identity is ambiguous at ${i}`);t.set(i,r)}for(let r of this.lazyArchiveGroups){let i=this.lazyAtomicGroupByTree.get(r),s=this.sealedLazyAtomicStates.get(r)?.snapshot,a=this.ordinaryLazyTreeDefinitions.get(r);if(i?.committed||s===void 0&&(a?.materialized??r.materialized)||s===void 0&&a===void 0)continue;let c=s?.inventory??a.inventory,u=new Map((s?.entries??a.entries).map(f=>[f.vfsPath,f])),l=new Map,d=new Map,h=new Set;for(let f of u.values())f.deleted&&f.inodeGroup!==void 0&&h.add(f.inodeGroup);for(let f of c){if(f.type!=="file"&&f.type!=="hardlink")continue;l.set(f.inodeGroup,(l.get(f.inodeGroup)??0)+1);let p=d.get(f.inodeGroup)??[];p.push(f.vfsPath),d.set(f.inodeGroup,p)}let m=new Set([...h].filter(f=>d.get(f)?.every(p=>!t.has(p))));for(let f of c){let p=t.get(f.vfsPath);if(p===void 0){if(f.inodeGroup!==void 0&&m.has(f.inodeGroup))continue;throw new Error(`Lazy tree namespace entry ${f.vfsPath} is missing from the captured filesystem state`)}let _=f.type==="directory"?Qe:f.type==="symlink"?Sr:kt;if((p.mode&Ee)!==_||(p.mode&ie.S_MODE_BITS)!==f.mode)throw new Error(`Lazy tree namespace entry ${f.vfsPath} disagrees with its captured type or mode`);if(f.type==="directory")continue;let y=u.get(f.vfsPath);if(y===void 0||y.ino!==p.ino||y.generation!==p.generation||y.dataSequence!==p.dataSequence)throw new Error(`Lazy tree namespace entry ${f.vfsPath} changed identity before serialization`);if(f.type==="symlink"){let g=new TextEncoder().encode(f.target).byteLength;if(p.linkCount!==1||p.size!==f.size||p.size!==g||p.symlinkTarget!==f.target)throw new Error(`Lazy tree symlink ${f.vfsPath} disagrees with its captured inventory`);continue}if(p.size!==0||p.linkCount!==l.get(f.inodeGroup))throw new Error(`Lazy tree stub ${f.vfsPath} has changed data or undeclared aliases`)}}}lazyFileForStat(e){let t=V(e.ino,e.generation),r=this.lazyFiles.get(t);if(r&&r.dataSequence!==e.dataSequence){this.lazyFiles.delete(t);return}return r}lazyArchiveEntriesForRead(e){let t=this.lazyAtomicGroupByTree.get(e),r=this.sealedLazyAtomicStates.get(e);if(r!==void 0&&!t?.committed)return r.snapshot.entries;let i=this.ordinaryLazyTreeDefinitions.get(e);return i!==void 0?i.entries:Array.from(e.entries,([o,s])=>({vfsPath:o,...s}))}lazyArchiveForStat(e){let t=V(e.ino,e.generation),r=this.lazyArchiveInodes.get(t);if(!r)return;let i=this.lazyArchiveEntriesForRead(r).filter(s=>s.ino===e.ino&&s.generation===e.generation&&!s.deleted&&!s.materialized);if(i.some(s=>s.dataSequence===e.dataSequence))return r;if(this.lazyArchiveInodes.delete(t),this.sealedLazyAtomicStates.get(r)===void 0){let s=this.ordinaryLazyTreeDefinitions.get(r);if(s!==void 0)this.replaceOrdinaryLazyTreeRuntimeState(r,s.entries.map(a=>a.ino===e.ino&&a.generation===e.generation?{...a,materialized:!0}:a),s.materialized);else for(let a of i)a.materialized=!0}}lazyBackingForStat(e){let t=V(e.ino,e.generation),r=this.lazyFiles.get(t);if(r)return{token:r,path:r.path};let i=this.lazyArchiveInodes.get(t);if(!i)return null;let o=this.lazyArchiveEntriesForRead(i).find(a=>a.ino===e.ino&&a.generation===e.generation&&!a.deleted&&!a.materialized)?.vfsPath;if(o===void 0)return null;let s=this.lazyAtomicGroupByTree.get(i);return s===void 0?{token:i,path:o}:{token:s.token,path:o,atomicGroup:s}}lazyBackingForPath(e){let t=this.lazyArchiveGroups.find(r=>{let i=this.lazyAtomicGroupByTree.get(r),o=this.sealedLazyAtomicStates.get(r)?.snapshot,s=this.ordinaryLazyTreeDefinitions.get(r),a=o===void 0?!(s?.materialized??r.materialized):!i?.committed,c=o?.content??s?.content,u=o?.inventory??s?.inventory,l=o?.activation??s?.activation,d=o?.entries??s?.entries??Array.from(r.entries.values());return a&&c!==void 0&&u!==void 0&&l!==void 0&&d.every(h=>h.deleted||h.materialized||h.isSymlink)&&l.roots.some(h=>h==="/"||e===h||e.startsWith(`${h}/`))});if(t){let r=this.lazyAtomicGroupByTree.get(t);return{token:r?.token??t,path:e,directGroup:t,...r===void 0?{}:{atomicGroup:r}}}try{let r=this.fs.stat(e),i=this.lazyBackingForStat(r);return i?{...i,path:e}:null}catch{return null}}startLazyPreparation(e){let{path:t,token:r}=e,i={status:"pending",promise:Promise.resolve(!1)},o=e.atomicGroup?this.ensureAtomicLazyGroupMaterialized(e.atomicGroup).then(()=>!0):e.directGroup?this.ensureArchiveMaterialized(e.directGroup).then(()=>!0):this.materializePath(t);return i.promise=o.then(s=>(i.status="fulfilled",this.lazyPreparations.get(r)===i&&this.lazyPreparations.delete(r),s),s=>{throw i.status="rejected",i.error=s,s}),i.promise.catch(()=>{}),this.lazyPreparations.set(r,i),i}registerLazyAtomicGroupMembership(e,t=!1){let r=e.activation?.atomicGroup;if(r===void 0)return;let{id:i,member:o}=r;if(e.content===void 0||e.inventory===void 0||e.activation?.mode!=="first-use")throw new Error(`Lazy atomic activation group ${i} accepts only typed first-use trees`);let s=this.lazyAtomicGroups.get(i);if(s===void 0)s={id:i,token:Object.freeze({id:i}),groups:new Map,committed:!1},this.lazyAtomicGroups.set(i,s);else if(s.committed)throw new Error(`Lazy atomic activation group ${i} is already materialized`);if(s.groups.has(o))throw new Error(`Lazy atomic activation group ${i} duplicates member ${o}`);if(Ft(r)){if(s.expectedCount!==void 0&&(s.expectedCount!==r.expectedCount||s.cohortSha256!==r.cohortSha256))throw new Error(`Lazy atomic activation group ${i} has inconsistent seals`);s.expectedCount=r.expectedCount,s.cohortSha256=r.cohortSha256;let a=rn(e,i,o);this.sealedLazyAtomicStates.set(e,{snapshot:js(a,r.descriptorSha256,r.expectedCount,r.cohortSha256),verified:t})}else if(s.expectedCount!==void 0)throw new Error(`Lazy atomic activation group ${i} mixes sealed and unsealed members`);s.groups.set(o,e),this.lazyAtomicGroupByTree.set(e,s)}async sealLazyAtomicGroup(e,t){let r=t.map(u=>ua({id:e,member:u}).member).sort();if(r.length===0||new Set(r).size!==r.length)throw new Error(`Lazy atomic activation group ${e} expected members are invalid`);let i=this.lazyAtomicGroups.get(e);if(i===void 0||i.committed)throw new Error(`Lazy atomic activation group ${e} is not pending`);let o=[...i.groups.keys()].sort();if(JSON.stringify(o)!==JSON.stringify(r))throw new Error(`Lazy atomic activation group ${e} members differ from its seal`);if(i.expectedCount!==void 0){if(i.expectedCount!==r.length||i.cohortSha256===void 0)throw new Error(`Lazy atomic activation group ${e} has an invalid existing seal`);await this.ensureLazyAtomicGroupSealValidated(i,r.map(u=>i.groups.get(u)),!0);return}let s=r.map(u=>rn(i.groups.get(u),e,u)),a=[];for(let u of s)a.push({member:u.member,descriptorSha256:await Nt(u.descriptorBytes,`Lazy atomic member ${u.member}`),source:u});let c=await Nt(Ys(e,a),`Lazy atomic activation group ${e}`);for(let u of a){let l=i.groups.get(u.member),d=rn(l,e,u.member);if(!Js(u.source,d))throw new Error(`Lazy atomic activation member ${u.member} changed while sealing`)}for(let u of a){let l=i.groups.get(u.member);l.activation.atomicGroup={id:e,member:u.member,descriptorSha256:u.descriptorSha256,expectedCount:a.length,cohortSha256:c},this.sealedLazyAtomicStates.set(l,{snapshot:js(u.source,u.descriptorSha256,a.length,c),verified:!0})}i.expectedCount=a.length,i.cohortSha256=c}async verifyImportedLazyAtomicGroupSeals(){await this.validatePendingLazyAtomicGroupSeals(!0)}guardSynchronousLazyAccess(e){let t=this.lazyBackingForPath(e);if(!t)return;let r=this.lazyPreparations.get(t.token);if(r?.status==="fulfilled"){this.lazyPreparations.delete(t.token);let o=this.lazyBackingForPath(e);if(!o)return;r=this.lazyPreparations.get(o.token)??this.startLazyPreparation(o)}else if(r?.status==="rejected"){this.lazyPreparations.delete(t.token);let o=r.error instanceof Error?r.error.message:String(r.error),s=new Error(`EIO: lazy backing for ${e} failed: ${o}`);throw s.code="EIO",s.cause=r.error,s}else r||(r=this.startLazyPreparation(t));let i=new Error(`EAGAIN: lazy backing for ${e} is being prepared`);throw i.code="EAGAIN",i}invalidateLazyData(e){let t=V(e.ino,e.generation);this.lazyFiles.delete(t);let r=this.lazyArchiveInodes.get(t);if(!r)return;this.lazyArchiveInodes.delete(t);let i=this.ordinaryLazyTreeDefinitions.get(r);if(i!==void 0){this.replaceOrdinaryLazyTreeRuntimeState(r,i.entries.map(o=>o.ino===e.ino&&o.generation===e.generation?{...o,materialized:!0}:o),i.materialized);return}for(let o of r.entries.values())o.ino!==e.ino||o.generation!==e.generation||(o.materialized=!0)}rewriteLazyNamespacePaths(e,t,r){let i=t.length>1?t.replace(/\/+$/,""):t,o=r.length>1?r.replace(/\/+$/,""):r,s=`${i}/`,a=`${o}/`,c=V(e.ino,e.generation),u=(e.mode&Ee)===Qe,l=d=>d===i?o:u&&d.startsWith(s)?a+d.slice(s.length):d;for(let[d,h]of this.lazyFiles)!u&&d!==c||(h.paths=new Set(Array.from(h.paths,l)),h.path=l(h.path));for(let d of this.lazyArchiveGroups){let h=this.ordinaryLazyTreeDefinitions.get(d);if(h!==void 0){let f=h.entries.map(g=>{let E=g.generation===void 0?null:V(g.ino,g.generation),O=u||E===c?l(g.vfsPath):g.vfsPath;return{...g,vfsPath:O,...g.type==="hardlink"&&g.target!==void 0?{target:l(g.target)}:{}}}),p=h.inventory.map(g=>({...g,vfsPath:l(g.vfsPath),...g.type==="hardlink"&&g.target!==void 0?{target:l(g.target)}:{}})),_={...h.activation,capabilities:[...h.activation.capabilities],roots:h.activation.roots.map(l)},y=tn(h.content,p,_,h.url,h.mountPrefix,h.integrity,f,h.materialized);this.ordinaryLazyTreeDefinitions.set(d,y);try{d.entries=Xs(y.entries),d.materialized=y.materialized,d.inventory=y.inventory.map(g=>({...g})),d.activation={...y.activation,capabilities:[...y.activation.capabilities],roots:[...y.activation.roots]}}catch{}continue}let m=new Map;for(let[f,p]of d.entries){let _=p.generation===void 0?null:V(p.ino,p.generation);m.set(u||_===c?l(f):f,p)}d.entries=m,d.inventory&&(d.inventory=d.inventory.map(f=>({...f,vfsPath:l(f.vfsPath),...f.type==="hardlink"&&f.target!==void 0?{target:l(f.target)}:{}}))),d.activation&&(d.activation={...d.activation,roots:d.activation.roots.map(l)})}}get sharedBuffer(){return this.fs.buffer}static create(e,t){return new n(le.mkfs(e,t))}static createFresh(e){if(typeof e!="number"||!Ad(e)||e<=0)throw new zd("fresh MemoryFileSystem byte length must be a positive integer");let t=new Ms(e),r=lt(Id,le,[t]);return new n(r)}static fromExisting(e){return new n(le.mount(e))}rebaseToNewFileSystem(e){if(!Number.isSafeInteger(e)||e<=0)throw new Error(`Invalid MemoryFileSystem maxByteLength: ${e}`);let t=SharedArrayBuffer,{bytes:r,identities:i}=this.fs.snapshotState();this.reconcileLazyIdentityState(i);let o=this.serializeLazyEntries(),s=this.serializeValidatedLazyArchiveEntries(i),a=new t(r.byteLength);new Uint8Array(a).set(r);let c=new n(le.mount(a,{restoreImage:!0}),this.imageMetadata);c.importLazyEntries(o),c.importLazyArchiveEntriesInternal(s,!1,!0,"verified");let u=Math.min(e,Math.max(r.byteLength,Dd)),l=new t(u,{maxByteLength:e}),d=n.create(l,e);d.setImageMetadata(this.imageMetadata);let h=new Set(o.flatMap(f=>f.paths??[f.path])),m=new Set;for(let f of s)if(!f.materialized)for(let p of f.entries)!p.deleted&&!p.isSymlink&&m.add(p.vfsPath);return c.copyPathToFreshFileSystem("/",d,h,m,new Map),d.importLazyEntries(o.map(f=>{let p=d.fs.lstat(f.path);return{...f,ino:p.ino,generation:p.generation,dataSequence:p.dataSequence}})),d.importLazyArchiveEntriesInternal(s.map(f=>({...f,entries:f.entries.map(p=>{if(p.deleted)return{...p,ino:0,generation:void 0};let _=d.fs.lstat(p.vfsPath);return{...p,ino:_.ino,generation:_.generation,dataSequence:_.dataSequence}})})),!1,!0,"verified"),d}getImageMetadata(){return Gs(this.imageMetadata)}setImageMetadata(e){this.imageMetadata=e===null?null:ki(e)}subscribeLazyDownloads(e){return this.lazyDownloadListeners.add(e),()=>this.lazyDownloadListeners.delete(e)}setLazyFetcher(e,t={}){this.lazyTransport={fetcher:e,...t.signal===void 0?{}:{signal:t.signal}}}emitLazyDownload(e){if(this.lazyDownloadListeners.size===0)return;let t={...e,t:Xd()};for(let r of this.lazyDownloadListeners)try{r(t)}catch{}}async fetchLazyBytes(e,t){let r=0,i=e.integrity?.bytes??e.fallbackTotalBytes,o={id:e.id,kind:e.kind,url:e.url,path:e.path,mountPrefix:e.mountPrefix};for(let s=0;se.integrity.bytes)throw new Error(`Lazy ${e.kind} exceeded expected byte count ${e.integrity.bytes}`);this.emitLazyDownload({...o,status:"progress",loadedBytes:r,totalBytes:i})}}}catch(d){try{await c.cancel(d)}catch{}throw d}}finally{c.releaseLock()}let l=il(u,r);return te(t.signal),await Oi(l,e.kind,e.integrity),te(t.signal),this.emitLazyDownload({...o,status:"complete",loadedBytes:r,totalBytes:i??r}),l}catch(a){if(t.signal?.aborted){let l=t.signal.reason,d=l instanceof Error?l.message:String(l);throw this.emitLazyDownload({...o,status:"error",loadedBytes:r,totalBytes:i,error:d}),l}let c=s+1({...g})),activation:d,entries:new Map},_=g=>{let E=g.split("/").filter(Boolean),O="";for(let S=0;SE.vfsPath.split("/").length-O.vfsPath.split("/").length))if(g.type==="directory"){_(g.vfsPath);try{this.fs.mkdir(g.vfsPath,g.mode),this.fs.chmod(g.vfsPath,g.mode)}catch{if((this.fs.lstat(g.vfsPath).mode&Ee)!==Qe)throw new Error(`Lazy tree directory collides at ${g.vfsPath}`)}}for(let g of u){if(g.type!=="symlink")continue;_(g.vfsPath),this.fs.symlink(g.target,g.vfsPath);let E=this.fs.lstat(g.vfsPath);p.entries.set(g.vfsPath,{ino:E.ino,generation:E.generation,dataSequence:E.dataSequence,size:g.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:g.sourcePath,sourcePath:g.sourcePath,type:"symlink",target:g.target})}let y=new Map;for(let g of u){if(g.type!=="file")continue;_(g.vfsPath);let E=this.fs.createLazyStub(g.vfsPath,g.mode);this.invalidateLazyData(E),y.set(g.inodeGroup,E);let O={ino:E.ino,generation:E.generation,dataSequence:E.dataSequence,size:g.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:g.sourcePath,sourcePath:g.sourcePath,type:"file",inodeGroup:g.inodeGroup};p.entries.set(g.vfsPath,O)}for(let g of u){if(g.type!=="hardlink")continue;let E=h.get(g.inodeGroup);_(g.vfsPath),this.fs.link(E.vfsPath,g.vfsPath);let O=this.fs.lstat(g.vfsPath),S=y.get(g.inodeGroup);if(O.ino!==S.ino||O.generation!==S.generation)throw new Error(`Lazy tree hardlink ${g.vfsPath} did not share its inode`);p.entries.set(g.vfsPath,{ino:O.ino,generation:O.generation,dataSequence:O.dataSequence,size:g.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:E.sourcePath,sourcePath:g.sourcePath,type:"hardlink",inodeGroup:g.inodeGroup,target:g.target})}if(m!==void 0)for(let g of u)this.lchown(g.vfsPath,m.uid,m.gid);for(let g of p.entries.values())g.isSymlink||g.generation===void 0||this.lazyArchiveInodes.set(V(g.ino,g.generation),p);return this.lazyArchiveGroups.push(p),this.registerLazyAtomicGroupMembership(p),d.atomicGroup===void 0&&this.ordinaryLazyTreeDefinitions.set(p,tn(c,u,d,p.url,l,p.integrity,Ai(p.entries),!1)),p}registerLazyTreeWithMaterializationHandle(e,t,r="/",i,o){let s=this.registerLazyTreeInternal(e,t,r,i,!0,o),a=Object.freeze({[vd]:!0});return this.deferredTreeMaterializationHandles.set(a,s),a}registerLazyArchiveFromEntries(e,t,r,i,o){let s=Ar(r),a=Vd(e,t,s,i);a.some(({entry:u})=>!u.isDirectory&&!u.isSymlink)&&this.assertCanRegisterPendingLazyArchiveGroup();let c={...o?{content:un({decoder:"zip-v1",mediaType:"application/zip",sha256:o.sha256,bytes:o.bytes,expandedBytes:a.reduce((u,l)=>u+l.entry.uncompressedSize,0),sourceEntryCount:a.length,transports:[e]})}:{},url:e,mountPrefix:s,integrity:zr(o),materialized:!1,entries:new Map};for(let{entry:u,vfsPath:l}of a){if(u.isDirectory)continue;let d=l.split("/").filter(Boolean),h="";for(let m=0;mu.deleted||u.materialized),this.lazyArchiveGroups.push(c),c}importLazyArchiveEntries(e){this.importLazyArchiveEntriesInternal(e,!1,!0,"reject")}async importVerifiedLazyArchiveEntries(e){let t=structuredClone(e),r=this.exportLazyArchiveEntries(),i=n.fromExisting(this.sharedBuffer);i.importLazyArchiveEntriesInternal([...r,...t],!1,!0,"pending"),await i.verifyImportedLazyAtomicGroupSeals(),this.importLazyArchiveEntriesInternal(t,!1,!0,"verified")}importLazyArchiveEntriesInternal(e,t,r,i){let o=Me(e,"Serialized lazy archive groups",0,ta).map((l,d)=>{if(typeof l!="object"||l===null||Array.isArray(l))throw new Error(`Serialized lazy archive group ${d} must be an object`);let h=l.kind;if(h===Or||h===Ti||h===ft)return la(l,h);if(h===wr)return bi(l,!1);if(h!==void 0)throw new Error(`Serialized lazy archive group ${d} has an unsupported kind`);if(r)throw new Error(`Serialized lazy archive group ${d} is missing its kind discriminator`);return bi(l,!0)}),s=this.fs.identityState();this.reconcileLazyIdentityState(s);let a=[...this.serializeValidatedLazyArchiveEntries(s),...o];qs(a);let c=[],u=new Map;for(let l of o){let d=new Map,h=l.mountPrefix.replace(/\/+$/,""),m=l.content!==void 0&&l.inventory!==void 0&&l.activation!==void 0,f=m?new Map(l.inventory.map(S=>[S.vfsPath,S])):null,p=m?new Map(l.inventory.map(S=>[dn(S),S])):null,_=new Map,y=new Map,g=new Map;for(let S of l.entries){let w=null,z=l.materialized||S.materialized===!0||S.isSymlink;if(!S.deleted&&!z){if((S.generation===void 0||S.dataSequence===void 0)&&!t)throw new Error("Live lazy-archive metadata requires inode generation and data sequence");try{w=this.fs.lstat(S.vfsPath)}catch{if(m)throw new Error(`Serialized lazy tree stub ${S.vfsPath} is missing from the filesystem`);continue}if(w.ino!==S.ino){if(m)throw new Error(`Serialized lazy tree stub ${S.vfsPath} has a different inode`);continue}if(S.generation!==void 0&&w.generation!==S.generation){if(m)throw new Error(`Serialized lazy tree stub ${S.vfsPath} has a different generation`);continue}if(S.dataSequence===void 0){if(!n.canAdoptLegacyLazyStub(w)){if(m)throw new Error(`Serialized lazy tree stub ${S.vfsPath} is not pristine`);continue}}else if(w.dataSequence!==S.dataSequence){if(m)throw new Error(`Serialized lazy tree stub ${S.vfsPath} has a different data sequence`);continue}if(m){g.set(S.vfsPath,w);let I=f.get(S.vfsPath),R=p.get(dn(S))??I;if(!R||(w.mode&Ee)!==kt||w.size!==0||(w.mode&ie.S_MODE_BITS)!==R.mode||I?.inodeGroup!==void 0&&I.inodeGroup!==R.inodeGroup)throw new Error(`Serialized lazy tree stub ${S.vfsPath} disagrees with its inventory`);let L=V(w.ino,w.generation),v=S.inodeGroup,Z=_.get(v),N=y.get(L);if(Z!==void 0&&Z!==L||N!==void 0&&N!==v)throw new Error(`Serialized lazy tree inode group ${v} disagrees with the filesystem`);_.set(v,L),y.set(L,v)}}d.set(S.vfsPath,{ino:S.ino,generation:w?.generation??S.generation,dataSequence:w?.dataSequence??S.dataSequence,size:S.size,isSymlink:S.isSymlink,deleted:S.deleted,materialized:z,archivePath:S.archivePath??S.vfsPath.slice(h.length+1),sourcePath:S.sourcePath??S.archivePath??S.vfsPath.slice(h.length+1),type:S.type??(S.isSymlink?"symlink":"file"),inodeGroup:S.inodeGroup,target:S.target})}if(m){let S=new Map;for(let w of l.inventory){if(w.type==="file"||w.type==="hardlink"){S.set(w.inodeGroup,(S.get(w.inodeGroup)??0)+1);continue}let z;try{z=this.fs.lstat(w.vfsPath)}catch{throw new Error(`Serialized lazy tree namespace entry ${w.vfsPath} is missing from the filesystem`)}let x=w.type==="directory"?Qe:Sr;if((z.mode&Ee)!==x||(z.mode&ie.S_MODE_BITS)!==w.mode||w.type==="symlink"&&(z.size!==new TextEncoder().encode(w.target).byteLength||this.fs.readlink(w.vfsPath)!==w.target))throw new Error(`Serialized lazy tree namespace entry ${w.vfsPath} disagrees with its inventory`);w.type==="symlink"&&d.set(w.vfsPath,{ino:z.ino,generation:z.generation,dataSequence:z.dataSequence,size:w.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:w.sourcePath,sourcePath:w.sourcePath,type:"symlink",target:w.target})}if(l.activation?.atomicGroup!==void 0)for(let w of l.inventory){if(w.type!=="file"&&w.type!=="hardlink")continue;if(g.get(w.vfsPath).linkCount!==S.get(w.inodeGroup))throw new Error(`Serialized lazy atomic tree inode group ${w.inodeGroup} has undeclared aliases`)}}let E=l.content===void 0?void 0:un(l.content),O={content:E,url:E?.transports[0]??l.url,mountPrefix:l.mountPrefix,integrity:E?{sha256:E.sha256,bytes:E.bytes}:zr(l.integrity),materialized:l.materialized||!(E&&l.inventory)&&Array.from(d.values()).every(S=>S.deleted||S.materialized),inventory:l.inventory?.map(S=>({...S})),activation:l.activation?{mode:l.activation.mode,capabilities:[...l.activation.capabilities],roots:[...l.activation.roots],...l.activation.atomicGroup===void 0?{}:{atomicGroup:{...l.activation.atomicGroup}}}:void 0,entries:d};if(c.push(O),!O.materialized){for(let[,S]of d)if(!S.deleted&&!S.materialized&&S.generation!==void 0){let w=V(S.ino,S.generation),z=u.get(w);if(z!==void 0&&z!==O)throw new Error(`Serialized lazy archive groups share pending inode ${w}`);if(this.lazyArchiveInodes.has(w))throw new Error(`Serialized lazy archive group collides with pending inode ${w}`);u.set(w,O)}}}for(let l of c){let d=l.activation?.atomicGroup;if(d!==void 0&&this.lazyAtomicGroups.get(d.id)?.committed)throw new Error(`Lazy atomic activation group ${d.id} is already materialized`)}if(i==="reject"&&c.some(l=>{let d=l.activation?.atomicGroup;return d!==void 0&&Ft(d)}))throw new Error("Sealed lazy archive registrations require importVerifiedLazyArchiveEntries()");this.lazyArchiveGroups.push(...c);for(let l of c)this.registerLazyAtomicGroupMembership(l,i==="verified"),l.content!==void 0&&l.inventory!==void 0&&l.activation!==void 0&&l.activation.atomicGroup===void 0&&this.ordinaryLazyTreeDefinitions.set(l,tn(l.content,l.inventory,l.activation,l.url,l.mountPrefix,l.integrity,Ai(l.entries),l.materialized));for(let[l,d]of u)this.lazyArchiveInodes.set(l,d)}rewriteLazyArchiveUrls(e){for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0&&!r?.committed){this.assertLazyAtomicSnapshotMatchesPublic(t);let s=gl(i.snapshot,e);t.content=en(s.content),t.url=s.url,t.integrity={...s.integrity},i.snapshot=s;continue}let o=this.ordinaryLazyTreeDefinitions.get(t);if(o!==void 0){let s=fn(o.content,o.content.transports.map(e)),a=tn(s,o.inventory,o.activation,s.transports[0],o.mountPrefix,o.integrity,o.entries,o.materialized);this.ordinaryLazyTreeDefinitions.set(t,a),t.content=en(a.content),t.url=a.url,t.integrity={...a.integrity}}else t.content?(t.content={...t.content,transports:t.content.transports.map(e)},t.url=t.content.transports[0]):t.url=e(t.url)}}serializeLazyArchiveEntries(){let e=[];for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(r?.committed)continue;let _=i.snapshot;if(_.content.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");e.push({kind:ft,content:en(_.content),inventory:_.inventory.map(y=>({...y})),activation:yl(_),url:_.url,mountPrefix:_.mountPrefix,integrity:{..._.integrity},materialized:!1,entries:_.entries.filter(y=>!y.deleted&&!y.materialized).map(({vfsPath:y,...g})=>({vfsPath:y,...g}))});continue}let o=this.ordinaryLazyTreeDefinitions.get(t),s=o?.materialized??t.materialized,a=(o?.entries??Ai(t.entries)).map(_=>({..._})).filter(_=>!_.deleted&&!_.materialized),c=o?.content??t.content,u=o?.inventory??t.inventory,l=o?.activation??t.activation,d=o?.url??t.url,h=o?.mountPrefix??t.mountPrefix,m=o?.integrity??t.integrity;if(a.length===0&&!(c!==void 0&&u!==void 0&&!s))continue;let f=c!==void 0&&u!==void 0&&l!==void 0;if(f&&c.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");let p=l?.atomicGroup;if(p!==void 0&&!Ft(p))throw new Error(`Lazy atomic activation group ${p.id} must be sealed before serialization`);e.push(f?{kind:p!==void 0?ft:c.source===void 0?Or:Ti,content:en(c),inventory:u.map(_=>({..._})),activation:{...l,capabilities:[...l.capabilities],roots:[...l.roots]},url:d,mountPrefix:h,integrity:{...m},materialized:!1,entries:a}:{kind:wr,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:a})}return e}serializeValidatedLazyArchiveEntries(e){this.assertPendingLazyAtomicSnapshotsReadyForSerialization();let t=this.serializeLazyArchiveEntries();return qs(t),this.validatePendingLazyTreeNamespaceState(e),t}exportLazyArchiveEntries(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),this.serializeValidatedLazyArchiveEntries(e)}pendingDeferredTreeUsage(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),ca(this.serializeValidatedLazyArchiveEntries(e))}assertCanAppendDeferredTreeUsage(e){Li(e);let t=this.pendingDeferredTreeUsage();Li({groups:t.groups+e.groups,archiveBytes:t.archiveBytes+e.archiveBytes,expandedBytes:t.expandedBytes+e.expandedBytes,payloadBytes:t.payloadBytes+e.payloadBytes,entries:t.entries+e.entries})}assertCanRegisterPendingLazyArchiveGroup(){if(this.reconcileLazyIdentityState(this.fs.identityState()),this.lazyArchiveGroups.filter(t=>{let r=this.lazyAtomicGroupByTree.get(t);if(this.sealedLazyAtomicStates.get(t)?.snapshot!==void 0)return!r?.committed;let o=this.ordinaryLazyTreeDefinitions.get(t);return!(o?.materialized??t.materialized)&&(o!==void 0||Array.from(t.entries.values()).some(s=>!s.deleted&&!s.materialized))}).length>=be.maxGroups)throw new Error(`Cannot register another lazy archive group: ${be.maxGroups} pending groups already exist`)}async preparePath(e){let t=!1,r=Math.max(3,this.lazyArchiveGroups.length+1);for(let i=0;i{let s=this.ordinaryLazyTreeDefinitions.get(o);return!(s?.materialized??o.materialized)&&(s?.activation??o.activation)?.mode==="boot-prefetch"}),t=0,r,i=Array.from({length:Math.min(e.length,Bd)},async()=>{for(;r===void 0;){let o=t;if(t+=1,o>=e.length)return;try{await this.prepareLazyTreeGroup(e[o])}catch(s){r??=s}}});if(await Promise.all(i),r!==void 0)throw r;return e.length}async materializeRegisteredDeferredTree(e,t){let r=this.deferredTreeMaterializationHandles.get(e);if(r===void 0)throw new Error("Deferred-tree handle was not issued by this filesystem");let i=this.lazyAtomicGroupByTree.get(r);if(i!==void 0)throw new Error(`Deferred tree belongs to atomic activation group ${i.id}; materialize the complete group instead`);if(this.ordinaryLazyTreeDefinitions.get(r)?.materialized??r.materialized)return!1;let s=this.lazyPreparations.get(r);if(s!==void 0)return s.promise;let a=new Uint8Array(t.byteLength);a.set(t);let c={status:"pending",promise:Promise.resolve(!1)};c.promise=Promise.resolve().then(async()=>{let u=this.ordinaryLazyTreeDefinitions.get(r)?.integrity??r.integrity;return await Oi(a,"tree",u),await this.materializeArchiveBytes(r,a),!0}).then(u=>(c.status="fulfilled",u),u=>{throw c.status="rejected",c.error=u,u}),c.promise.catch(()=>{}),this.lazyPreparations.set(r,c);try{return await c.promise}finally{this.lazyPreparations.get(r)===c&&this.lazyPreparations.delete(r)}}async prepareLazyTreeGroup(e){let t=this.lazyAtomicGroupByTree.get(e),r=this.ordinaryLazyTreeDefinitions.get(e);if(t?.committed||t===void 0&&(r?.materialized??e.materialized))return!1;let i=this.sealedLazyAtomicStates.get(e)?.snapshot,o={token:t?.token??e,path:i?.activation.roots[0]??r?.activation.roots[0]??r?.mountPrefix??e.activation?.roots[0]??e.mountPrefix,directGroup:e,...t===void 0?{}:{atomicGroup:t}},s=this.lazyPreparations.get(o.token)??this.startLazyPreparation(o);try{return await s.promise}finally{this.lazyPreparations.get(o.token)===s&&this.lazyPreparations.delete(o.token)}}async ensureMaterialized(e){return this.preparePath(e)}async materializePath(e){if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0)return!1;let t;try{t=this.fs.stat(e)}catch{return!1}let r=V(t.ino,t.generation),i=this.lazyFiles.get(r);if(i){let s=this.lazyTransport,a=await this.fetchLazyBytes({id:`file:${t.ino}`,kind:"file",url:i.url,path:i.path,fallbackTotalBytes:i.size},s);for(let c=0;c<3;c++){if(this.lazyFiles.get(r)!==i)return!1;for(let u of new Set([e,...i.paths]))if(te(s.signal),this.fs.replaceIfIdentity(u,i.ino,i.generation,i.dataSequence,a))return i.path=u,this.lazyFiles.delete(r),!0;this.reconcileLazyIdentityState(this.fs.identityState())}throw new Error(`Lazy file kept changing names while materializing: ${e}`)}let o=this.lazyArchiveInodes.get(r);return o?(await this.ensureArchiveMaterialized(o,{path:e,ino:t.ino,generation:t.generation}),!this.lazyArchiveInodes.has(r)):!1}async decodeAndValidateLazyTree(e,t,r){let i=this.ordinaryLazyTreeDefinitions.get(e),o=r?.content??i?.content??e.content,s=r?.inventory??i?.inventory??e.inventory;if(!o||!s)throw new Error("Lazy tree is missing its decoder or complete inventory");let a=new Map,c=new Map(s.map(m=>[m.vfsPath,m]));if(o.source!==void 0)for(let m of o.source.entries)a.set(m.sourcePath,m);else for(let m of s){if(m.type==="hardlink"){let p=c.get(m.target);if(!p)throw new Error(`Lazy tree hardlink target disappeared: ${m.target}`);if(m.sourcePath===p.sourcePath)continue}if(a.get(m.sourcePath))throw new Error(`Lazy tree inventory duplicates source member ${m.sourcePath}`);a.set(m.sourcePath,{sourcePath:m.sourcePath,type:m.type,mode:m.mode,size:m.size,...m.type==="symlink"?{target:m.target}:{},...m.type==="hardlink"?{target:c.get(m.target)?.sourcePath}:{}})}let u=new Map,l=0;if(o.decoder==="zip-v1"){let{parseZipCentralDirectory:m,extractZipEntryBounded:f}=await Promise.resolve().then(()=>(fi(),li)),p=m(t);if(p.length!==o.sourceEntryCount||p.length!==a.size)throw new Error("Lazy ZIP tree decoded inventory counts differ from its descriptor");for(let _ of p){let y=_.isDirectory?_.fileName.replace(/\/$/,""):_.fileName;if(u.has(y))throw new Error(`Lazy ZIP tree duplicates source member ${y}`);let g=a.get(y);if(!g)throw new Error(`Lazy ZIP tree has undeclared source member ${y}`);if(l+=_.uncompressedSize,l>o.expandedBytes||_.uncompressedSize!==g.size)throw new Error(`Lazy ZIP tree member ${y} exceeds its inventory`);let E=_.isDirectory?"directory":_.isSymlink?"symlink":"file",O=o.modePolicy==="portable-posix-v1"?E==="directory"?493:E==="symlink"?511:(_.mode&73)!==0?493:420:_.mode&ie.S_MODE_BITS;if(E!==g.type||O!==g.mode)throw new Error(`Lazy ZIP tree member ${y} differs from inventory`);if(_.isDirectory)u.set(y,{type:"directory",mode:O});else{let S=f(t,_,g.size);if(_.isSymlink){let w;try{w=new TextDecoder("utf-8",{fatal:!0}).decode(S)}catch{throw new Error(`Lazy ZIP tree symlink ${y} is not UTF-8`)}u.set(y,{type:"symlink",mode:O,target:w})}else u.set(y,{type:"file",mode:O,data:S})}}}else{let{parseTarGzip:m}=await Promise.resolve().then(()=>(Cs(),Ns)),f=m(t,{label:`Lazy tree ${o.sha256}`,limits:{maxCompressedBytes:o.bytes,maxUncompressedBytes:o.expandedBytes,maxEntries:o.sourceEntryCount}});l=new DataView(t.buffer,t.byteOffset,t.byteLength).getUint32(t.byteLength-4,!0);for(let p of f){if(u.has(p.path))throw new Error(`Lazy TAR tree duplicates source member ${p.path}`);p.type==="file"?u.set(p.path,{type:"file",mode:p.mode,data:p.data}):p.type==="directory"?u.set(p.path,{type:"directory",mode:p.mode}):u.set(p.path,{type:p.type,mode:p.mode,target:p.linkName})}}if(u.size!==o.sourceEntryCount||u.size!==a.size||l!==o.expandedBytes)throw new Error("Lazy tree decoded inventory counts differ from its descriptor");for(let[m,f]of a){let p=u.get(m);if(!p)throw new Error(`Lazy tree is missing source member ${m}`);let _=f.type;if(p.type!==_)throw new Error(`Lazy tree member ${m} is ${p.type}, expected ${_}`);if((p.mode&ie.S_MODE_BITS)!==f.mode)throw new Error(`Lazy tree member ${m} mode differs from inventory`);if(_==="file"&&p.data?.byteLength!==f.size)throw new Error(`Lazy tree member ${m} size differs from inventory`);if(_==="symlink"&&p.target!==f.target)throw new Error(`Lazy tree symlink ${m} target differs from inventory`);if(_==="hardlink"&&p.target!==f.target)throw new Error(`Lazy tree hardlink ${m} target differs from inventory`)}let d=o.materialization;if(d!==void 0){for(let f of d.assertions){let p=u.get(f.sourcePath),_=fr(f.bytesHex);if(p?.type!=="file"||p.data===void 0||p.data.byteLength!==_.byteLength||p.data.some((y,g)=>y!==_[g]))throw new Error(`Lazy tree source assertion ${f.sourcePath} differs from archive bytes`)}let m=new Map(d.recipes.map(f=>[f.id,f]));for(let f of d.transforms){let p=u.get(f.sourcePath);if(p?.type!=="file"||p.data===void 0)throw new Error(`Lazy tree transform ${f.sourcePath} is not a regular source`);await Zs(p.data,f.input,`Lazy tree transform ${f.sourcePath} input`);let _=cs(p.data,m.get(f.recipe));await Zs(_,f.output,`Lazy tree transform ${f.sourcePath} output`),p.data=_}}let h=new Map;for(let m of s){if(m.type!=="file"||m.materialization==="descriptor")continue;let f=u.get(m.sourcePath);if(f?.type!=="file"||!f.data)throw new Error(`Lazy tree has no file content for ${m.sourcePath}`);h.set(m.sourcePath,f.data)}return h}async ensureArchiveMaterialized(e,t){let r=this.lazyAtomicGroupByTree.get(e);if(r!==void 0){if(r.committed)return;let a=this.sealedLazyAtomicStates.get(e)?.snapshot,c=this.lazyPreparations.get(r.token)??this.startLazyPreparation({token:r.token,path:a?.activation.roots[0]??e.activation?.roots[0]??e.mountPrefix,atomicGroup:r});try{await c.promise}finally{this.lazyPreparations.get(r.token)===c&&this.lazyPreparations.delete(r.token)}return}if(this.ordinaryLazyTreeDefinitions.get(e)?.materialized??e.materialized)return;let o=this.lazyTransport,s=await this.fetchLazyArchiveData(e,o);te(o.signal),await this.materializeArchiveBytes(e,s,t,o.signal)}async fetchLazyArchiveData(e,t,r){let i=this.ordinaryLazyTreeDefinitions.get(e),o=r?.content??i?.content??e.content,s=r?.inventory??i?.inventory??e.inventory,a=o!==void 0&&s!==void 0,c=r?.mountPrefix??i?.mountPrefix??e.mountPrefix,u=r?.integrity??i?.integrity??e.integrity,l=a?o.transports:[r?.url??i?.url??e.url],d=[],h=null;for(let[m,f]of l.entries())try{h=await this.fetchLazyBytes({id:`archive:${c}:${o?.sha256??f}:${m}`,kind:a?"tree":"archive",url:f,mountPrefix:c,integrity:u},t);break}catch(p){if(te(t.signal),aa(p))throw p;d.push(p instanceof Error?p.message:String(p))}if(te(t.signal),h===null)throw new Error(`All ${l.length} lazy ${a?"tree":"archive"} transports failed: ${d.join("; ")}`);return h}async materializeArchiveBytes(e,t,r,i){if(te(i),this.ordinaryLazyTreeDefinitions.get(e)?.materialized??e.materialized)return;let s=await this.prepareLazyArchiveContents(e,t,i),a=r?V(r.ino,r.generation):null;for(let c=0;c<3;c++){let u=this.collectLazyArchiveReplacements(e,s,r);if(u.size>0&&(te(i),!this.fs.replaceManyIfIdentities(Array.from(u.values(),Qs)))){if(this.reconcileLazyIdentityState(this.fs.identityState()),a&&!this.lazyArchiveInodes.has(a))return;continue}if(te(i),this.publishLazyArchiveReplacements(e,u),(this.ordinaryLazyTreeDefinitions.get(e)?.materialized??e.materialized)||(this.reconcileLazyIdentityState(this.fs.identityState()),a&&!this.lazyArchiveInodes.has(a)))return}if(a&&this.lazyArchiveInodes.has(a))throw new Error(`Lazy archive member kept changing names while materializing: ${r?.path}`)}async prepareLazyArchiveContents(e,t,r,i){te(r);let o=this.ordinaryLazyTreeDefinitions.get(e),s=i?.content??o?.content??e.content,a=i?.inventory??o?.inventory??e.inventory,u=s!==void 0&&a!==void 0?await this.decodeAndValidateLazyTree(e,t,i):null;te(r);let{parseZipCentralDirectory:l,extractZipEntry:d}=await Promise.resolve().then(()=>(fi(),li));te(r);let h=u?[]:l(t),m=new Map;for(let E of h){if(m.has(E.fileName))throw new Error(`Lazy archive contains duplicate member: ${E.fileName}`);m.set(E.fileName,E)}let p=(i?.mountPrefix??o?.mountPrefix??e.mountPrefix).replace(/\/+$/,""),_=new Map,y=i?.entries??o?.entries,g=y===void 0?Array.from(e.entries):y.map(E=>[E.vfsPath,E]);for(let[E,O]of g){if(O.deleted||O.materialized)continue;let S=O.archivePath??E.slice(p.length+1),w=u?void 0:m.get(S),z=u?.get(S);if(u){if(z===void 0||z.byteLength!==O.size)throw new Error(`Lazy tree member ${S} does not match its registered metadata`)}else if(w===void 0||w.isDirectory||w.isSymlink||w.uncompressedSize!==O.size)throw new Error(`Lazy archive member ${S} does not match its registered metadata`);if(O.generation===void 0)continue;let x=V(O.ino,O.generation),I=_.get(x);if(I&&I.archivePath!==S)throw new Error(`Lazy archive aliases for inode ${x} name different members`);if(!I){let R=z??d(t,w);if(R.byteLength!==O.size)throw new Error(`Lazy archive member ${S} extracted ${R.byteLength} bytes, expected ${O.size}`);_.set(x,{archivePath:S,content:R})}}return _}collectLazyArchiveReplacements(e,t,r,i){let o=new Map,s=this.ordinaryLazyTreeDefinitions.get(e),a=i?.entries??s?.entries,c=a===void 0?Array.from(e.entries):a.map(u=>[u.vfsPath,u]);for(let[u,l]of c){if(l.deleted||l.materialized||l.generation===void 0)continue;let d=V(l.ino,l.generation);if(this.lazyArchiveInodes.get(d)!==e)continue;let h=t.get(d);if(!h)throw new Error(`Lazy archive has no extracted content for inode ${d}`);let m=o.get(d);m||(m={ino:l.ino,generation:l.generation,dataSequence:l.dataSequence??0,paths:new Set,content:h.content},o.set(d,m)),m.paths.add(u),r&&r.ino===l.ino&&r.generation===l.generation&&m.paths.add(r.path)}return o}publishLazyArchiveReplacements(e,t){let r=this.ordinaryLazyTreeDefinitions.get(e);if(r!==void 0){let i=r.entries.map(o=>{let s=o.generation===void 0?void 0:V(o.ino,o.generation);return s===void 0||!t.has(s)?o:(this.lazyArchiveInodes.delete(s),{...o,materialized:!0})});this.replaceOrdinaryLazyTreeRuntimeState(e,i,i.every(o=>o.deleted||o.materialized));return}for(let[i,o]of t){this.lazyArchiveInodes.delete(i);for(let s of e.entries.values())s.ino===o.ino&&s.generation===o.generation&&(s.materialized=!0)}e.materialized=Array.from(e.entries.values()).every(i=>i.deleted||i.materialized)}collectAtomicTreeNamespace(e,t){let r=new Set,i=new Map,o=new Map,s=new Map(t.entries.map(c=>[c.vfsPath,c]));for(let c of t.inventory)(c.type==="file"||c.type==="hardlink")&&o.set(c.inodeGroup,(o.get(c.inodeGroup)??0)+1);let a=[];for(let c of t.inventory){let u;try{u=this.fs.lstat(c.vfsPath)}catch{throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`)}let l=c.type==="directory"?Qe:c.type==="symlink"?Sr:kt;if((u.mode&Ee)!==l||(u.mode&ie.S_MODE_BITS)!==c.mode)throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`);if(c.type==="symlink"){let d=s.get(c.vfsPath);if(d===void 0||!d.isSymlink||d.deleted||d.ino!==u.ino||d.generation!==u.generation||d.dataSequence!==u.dataSequence||this.fs.readlink(c.vfsPath)!==c.target)throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`)}else if(c.type==="file"||c.type==="hardlink"){let d=s.get(c.vfsPath);if(d===void 0||d.deleted||d.materialized||d.isSymlink||d.generation===void 0||d.inodeGroup!==c.inodeGroup||d.ino!==u.ino||d.generation!==u.generation||d.dataSequence!==u.dataSequence||u.size!==0||u.linkCount!==o.get(c.inodeGroup))throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`);let h=V(d.ino,d.generation);if(this.lazyArchiveInodes.get(h)!==e)throw new Error(`Lazy atomic tree lost deferred ownership of ${c.vfsPath}`);let m=i.get(c.inodeGroup);if(m!==void 0&&m!==h)throw new Error(`Lazy atomic tree split hard links at ${c.vfsPath}`);i.set(c.inodeGroup,h),r.add(h)}a.push({path:c.vfsPath,expectedIno:u.ino,expectedGeneration:u.generation,expectedDataSequence:u.dataSequence,expectedMode:u.mode,expectedLinkCount:u.linkCount,expectedSize:u.size,expectedUid:u.uid,expectedGid:u.gid})}return{guards:a,pendingIdentities:r.size}}assertLazyAtomicSnapshotMatchesPublic(e){let t=this.sealedLazyAtomicStates.get(e),r=t?.snapshot,i=e.activation?.atomicGroup,o=r?.member??i?.member??"unknown",s;if(r!==void 0)try{s=rn(e,r.id,r.member)}catch{s=void 0}if(t===void 0||r===void 0||i===void 0||!Ft(i)||i.id!==r.id||i.member!==r.member||i.descriptorSha256!==r.descriptorSha256||i.expectedCount!==r.expectedCount||i.cohortSha256!==r.cohortSha256||s===void 0||!Js(r,s))throw new Error(`Lazy atomic activation member ${o} changed after sealing`);return t}assertPendingLazyAtomicSnapshotsReadyForSerialization(){for(let e of this.lazyArchiveGroups){if(!this.sealedLazyAtomicStates.has(e)||this.lazyAtomicGroupByTree.get(e)?.committed)continue;let r=this.assertLazyAtomicSnapshotMatchesPublic(e);if(!r.verified)throw new Error(`Lazy atomic activation group ${r.snapshot.id} has not been cryptographically verified after import`)}}async validatePendingLazyAtomicGroupSeals(e){for(let t of this.lazyAtomicGroups.values()){if(t.committed||t.expectedCount===void 0||t.cohortSha256===void 0)continue;let r=[...t.groups.entries()].sort(([i],[o])=>io?1:0).map(([,i])=>i);await this.ensureLazyAtomicGroupSealValidated(t,r,e)}}async ensureLazyAtomicGroupSealValidated(e,t,r){if(this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r))return;let i=e.sealValidationFlight;if(i===void 0&&(i=this.validateLazyAtomicGroupSealOnce(e,t),e.sealValidationFlight=i,i.then(()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)},()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)})),await i,!this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r))throw new Error(`Lazy atomic activation group ${e.id} seal verification did not authenticate every member`)}assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r){if(e.committed)return!0;if(e.expectedCount===void 0||e.cohortSha256===void 0||t.length!==e.expectedCount)throw new Error(`Lazy atomic activation group ${e.id} is not completely sealed`);let i=!0,o=[];for(let s of t){let a=this.assertLazyAtomicSnapshotMatchesPublic(s),c=a.snapshot;if(c.id!==e.id||c.expectedCount!==e.expectedCount||c.cohortSha256!==e.cohortSha256||e.groups.get(c.member)!==s)throw new Error(`Lazy atomic activation group ${e.id} has an inconsistent member`);i&&=a.verified,o.push(a)}if(i&&r)for(let s=0;sfp?1:0).map(([,f])=>f);if(t.length===0)throw new Error(`Lazy atomic activation group ${e.id} has no trees`);if(await this.ensureLazyAtomicGroupSealValidated(e,t,!0),e.committed)return;let r=t.map(f=>this.sealedLazyAtomicStates.get(f).snapshot),i=t.map((f,p)=>({group:f,...this.collectAtomicTreeNamespace(f,r[p])})),o=this.lazyTransport,s=new Array(t.length),a=0,c=!1,u,l=Array.from({length:Math.min($d,t.length)},async()=>{for(;!c;){let f=a++;if(f>=t.length)return;let p=t[f],_=r[f];try{let y=await this.fetchLazyArchiveData(p,o,_);te(o.signal),s[f]={group:p,snapshot:_,contents:await this.prepareLazyArchiveContents(p,y,o.signal,_)}}catch(y){c||(c=!0,u=y)}}});if(await Promise.all(l),c)throw s.fill(void 0),u;te(o.signal);for(let f of t)this.assertLazyAtomicSnapshotMatchesPublic(f);let d=[],h=[],m=[];for(let f=0;f{let a=this.lazyAtomicGroupByTree.get(s),c=this.sealedLazyAtomicStates.get(s)?.snapshot,u=this.ordinaryLazyTreeDefinitions.get(s);return c===void 0?u!==void 0&&!u.materialized:!a?.committed});if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0&&r.length===0)return;let i=Array.from(this.lazyFiles.values(),s=>s.path);for(let s of i)await this.ensureMaterialized(s);let o=new Set(this.lazyArchiveInodes.values());for(let s of r)o.add(s);for(let s of o)await this.prepareLazyTreeGroup(s)}this.reconcileLazyIdentityState(this.fs.identityState());let e=this.lazyArchiveGroups.some(t=>{let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t)?.snapshot,o=this.ordinaryLazyTreeDefinitions.get(t);return i===void 0?o!==void 0&&!o.materialized:!r?.committed});if(this.lazyFiles.size!==0||this.lazyArchiveInodes.size!==0||e)throw new Error("Cannot create a self-contained VFS image while lazy entries remain pending")}async saveImage(e){e?.materializeAll&&await this.materializeAllLazyEntries(),await this.validatePendingLazyAtomicGroupSeals(!1);let{bytes:t,identities:r}=this.fs.snapshotState({normalizeTimestampsMs:e?.normalizeTimestampsMs});this.reconcileLazyIdentityState(r);let i=this.serializeLazyEntries(),o=i.length>0,s=o?new TextEncoder().encode(JSON.stringify(i)):new Uint8Array(0);if(s.byteLength>on)throw new Error(`VFS image lazy metadata exceeds ${on} bytes`);let a=this.serializeValidatedLazyArchiveEntries(r),c=a.length>0,u=c?new TextEncoder().encode(JSON.stringify(a)):new Uint8Array(0);if(u.byteLength>sn)throw new Error(`VFS image lazy archive metadata exceeds ${sn} bytes`);let l=e?.metadata===void 0?this.imageMetadata:e.metadata,d=Zd(l),h=d.byteLength>0,m=c?4+u.byteLength:0,f=h?4+d.byteLength:0,p=ye+t.byteLength+4+s.byteLength+m+f,_=new Uint8Array(p),y=new DataView(_.buffer);y.setUint32(0,zi,!0),y.setUint32(4,xi,!0),y.setUint32(8,(o?gi:0)|(c?nn:0)|(c?Si:0)|(h?Ei:0),!0),y.setUint32(12,t.byteLength,!0),_.set(t,ye);let g=ye+t.byteLength;if(y.setUint32(g,s.byteLength,!0),s.byteLength>0&&_.set(s,g+4),c){let E=g+4+s.byteLength;y.setUint32(E,u.byteLength,!0),_.set(u,E+4)}if(h){let E=g+4+s.byteLength+m;y.setUint32(E,d.byteLength,!0),_.set(d,E+4)}return _}static readImageMetadata(e){let t=Qr(e);if(!(t.flags&Ei))return null;let{metadataOffset:r}=Hs(t.image,t.view,t.flags,t.sabLen);if(t.image.byteLengthCt)throw new Error(`VFS image metadata exceeds ${Ct} bytes`);if(t.image.byteLength0){let _=r.subarray(f+4,f+4+p),y=Me(Vs(_,"VFS image lazy metadata"),"VFS image lazy entries",0,Mt);m.importLazyEntriesInternal(y,!0)}if(o&nn){let _=a.archiveOffset,y=i.getUint32(_,!0);if(y>0){let g=r.subarray(_+4,_+4+y),E=Vs(g,"VFS image lazy archive metadata");m.importLazyArchiveEntriesInternal(E,!0,!!(o&Si),"pending")}}return m}adaptStat(e){return{dev:0,ino:e.ino,mode:e.mode,nlink:e.linkCount,uid:e.uid,gid:e.gid,size:e.size,atimeMs:e.atime,mtimeMs:e.mtime,ctimeMs:e.ctime}}adaptStatWithLazySize(e){let t=this.adaptStat(e),r=this.lazyFileForStat(e);if(r)return t.size=r.size,t;let i=this.lazyArchiveForStat(e);if(i){for(let o of this.lazyArchiveEntriesForRead(i))if(o.ino===e.ino&&o.generation===e.generation&&!o.deleted){t.size=o.size;break}}return t}open(e,t,r){(t&dr)===0&&!((t&cr)!==0&&(t&ei)!==0)&&this.guardSynchronousLazyAccess(e);let i=this.fs.open(e,t,r);return(t&dr)!==0&&this.invalidateLazyData(this.fs.fstat(i)),i}close(e){return this.fs.close(e),0}read(e,t,r,i){if(i>0){let o=this.lazyBackingForStat(this.fs.fstat(e));o&&(this.reconcileLazyIdentityState(this.fs.identityState()),o=this.lazyBackingForStat(this.fs.fstat(e)),o&&this.guardSynchronousLazyAccess(o.path))}return r!==null?this.fs.readAt(e,t.subarray(0,i),typeof r=="bigint"?Un(r):r):this.fs.read(e,t.subarray(0,i))}write(e,t,r,i){if(r!==null){let s=this.fs.writeAt(e,t.subarray(0,i),typeof r=="bigint"?Un(r):r);return s>0&&this.invalidateLazyData(this.fs.fstat(e)),s}let o=this.fs.write(e,t.subarray(0,i));return o>0&&this.invalidateLazyData(this.fs.fstat(e)),o}append(e,t,r,i){let o=this.fs.append(e,t.subarray(0,r),Bo(i));return o.written>0&&this.invalidateLazyData(this.fs.fstat(e)),o}seek(e,t,r){return this.fs.lseek(e,typeof t=="bigint"?$n(t):t,r)}fstat(e){return this.adaptStatWithLazySize(this.fs.fstat(e))}fpathconf(e,t){let r=this.fstat(e);return Gn(r,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}ftruncate(e,t){this.fs.ftruncate(e,t),this.invalidateLazyData(this.fs.fstat(e))}fsync(e){}fchmod(e,t){this.fs.fchmod(e,t)}fchown(e,t,r){this.fs.fchown(e,t,r)}stat(e){return this.adaptStatWithLazySize(this.fs.stat(e))}lstat(e){return this.adaptStatWithLazySize(this.fs.lstat(e))}statfs(e){this.fs.stat(e);let t=this.fs.statfs();return{type:1397114451,bsize:t.blockSize,blocks:t.totalBlocks,bfree:t.freeBlocks,bavail:t.freeBlocks,files:t.totalInodes,ffree:t.freeInodes,fsid:0,namelen:t.maxName,frsize:t.blockSize,flags:Uo}}pathconf(e,t){let r=this.stat(e);return Gn(r,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}mkdir(e,t){this.fs.mkdir(e,t)}rmdir(e){this.fs.rmdir(e)}unlink(e){let t=this.fs.unlink(e),r=V(t.ino,t.generation);if(t.linkCount>1&&(this.lazyFiles.has(r)||this.lazyArchiveInodes.has(r))){this.reconcileLazyIdentityState(this.fs.identityState());return}let i=this.lazyFiles.get(r);i&&(i.paths.delete(e),t.linkCount<=1?this.lazyFiles.delete(r):i.path===e&&(i.path=i.paths.values().next().value));let o=this.lazyArchiveInodes.get(r);if(o){let s=this.ordinaryLazyTreeDefinitions.get(o);if(s!==void 0){let a=t.linkCount<=1?s.entries.map(c=>c.ino===t.ino&&c.generation===t.generation?{...c,deleted:!0}:c):s.entries.filter(c=>c.vfsPath!==e);this.replaceOrdinaryLazyTreeRuntimeState(o,a,s.materialized),t.linkCount<=1&&this.lazyArchiveInodes.delete(r)}else{let a=o.entries.get(e);if(t.linkCount<=1){for(let c of o.entries.values())c.ino===t.ino&&c.generation===t.generation&&(c.deleted=!0);this.lazyArchiveInodes.delete(r)}else a&&o.entries.delete(e)}}}rename(e,t){let{source:r,replaced:i}=this.fs.rename(e,t);if(i&&i.ino===r.ino&&i.generation===r.generation)return;let o=!1;if(i){let s=V(i.ino,i.generation);i.linkCount>1&&(this.lazyFiles.has(s)||this.lazyArchiveInodes.has(s))&&(this.reconcileLazyIdentityState(this.fs.identityState()),o=!0);let a=this.lazyFiles.get(s);!o&&a&&(a.paths.delete(t),i.linkCount<=1?this.lazyFiles.delete(s):a.path===t&&(a.path=a.paths.values().next().value));let c=this.lazyArchiveInodes.get(s);if(!o&&c){let u=this.ordinaryLazyTreeDefinitions.get(c);if(u!==void 0){let l=i.linkCount<=1?u.entries.map(d=>d.ino===i.ino&&d.generation===i.generation?{...d,deleted:!0}:d):u.entries.filter(d=>d.vfsPath!==t);this.replaceOrdinaryLazyTreeRuntimeState(c,l,u.materialized),i.linkCount<=1&&this.lazyArchiveInodes.delete(s)}else{let l=c.entries.get(t);i.linkCount<=1?(l&&(l.deleted=!0),this.lazyArchiveInodes.delete(s)):l&&c.entries.delete(t)}}}o||this.rewriteLazyNamespacePaths(r,e,t)}link(e,t){let r=this.fs.link(e,t),i=V(r.ino,r.generation),o=this.lazyFiles.get(i);o&&o.paths.add(t);let s=this.lazyArchiveInodes.get(i);if(s){let a=this.ordinaryLazyTreeDefinitions.get(s);if(a!==void 0){let c=a.entries.find(u=>u.ino===r.ino&&u.generation===r.generation);c!==void 0&&this.replaceOrdinaryLazyTreeRuntimeState(s,[...a.entries,{...c,vfsPath:t}],a.materialized)}else{let c=Array.from(s.entries.values()).find(u=>u.ino===r.ino&&u.generation===r.generation);c&&s.entries.set(t,{...c})}}}symlink(e,t){this.fs.symlink(e,t)}readlink(e){return this.fs.readlink(e)}chmod(e,t){this.fs.chmod(e,t)}chown(e,t,r){this.fs.chown(e,t,r)}lchown(e,t,r){this.fs.lchown(e,t,r)}createFileWithOwner(e,t,r,i,o){let s=this.open(e,Ks,t);o.length>0&&this.write(s,o,null,o.length),this.close(s),this.chown(e,r,i),this.chmod(e,t)}mkdirWithOwner(e,t,r,i){this.mkdir(e,t),this.chown(e,r,i),this.chmod(e,t)}symlinkWithOwner(e,t,r,i){this.symlink(e,t),this.lchown(t,r,i)}copyPathToFreshFileSystem(e,t,r,i,o){let s=this.lstat(e),a=s.mode&Ee,c=s.mode&ie.S_MODE_BITS;if(a===Qe){e==="/"?(t.chown(e,s.uid,s.gid),t.chmod(e,c)):t.mkdirWithOwner(e,c,s.uid,s.gid);let h=this.opendir(e);try{for(;;){let m=this.readdir(h);if(!m)break;m.name==="."||m.name===".."||this.copyPathToFreshFileSystem(e==="/"?`/${m.name}`:`${e}/${m.name}`,t,r,i,o)}}finally{this.closedir(h)}n.applyTimes(t,e,s);return}let u=s.nlink>1?`${s.dev}:${s.ino}`:null,l=u?o.get(u):void 0;if(l){t.link(l,e);return}if(a===Sr){t.symlinkWithOwner(this.readlink(e),e,s.uid,s.gid),u&&o.set(u,e);return}if(a!==kt)throw new Error(`Unsupported file type while rebasing VFS: ${e}`);if(r.has(e)||i.has(e)){t.createFileWithOwner(e,c,s.uid,s.gid,new Uint8Array(0)),n.applyTimes(t,e,s),u&&o.set(u,e);return}this.copyRegularFileToFreshFileSystem(e,t,s,c),u&&o.set(u,e)}copyRegularFileToFreshFileSystem(e,t,r,i){let o=this.open(e,Cd,0),s=null;try{s=t.open(e,Ks,i);let a=new Uint8Array(Math.min(Md,Math.max(1,r.size))),c=r.size;for(;c>0;){let u=Math.min(a.byteLength,c),l=this.read(o,a,null,u);if(l<=0)throw new Error(`Unexpected EOF while rebasing VFS file: ${e}`);let d=0;for(;d!e||e==="."||e===".."))throw new Error(`Binary resolver path must be a normalized portable relative path: ${JSON.stringify(n)}`);return n}var mt=new Set(["wasm32","wasm64"]);function Ke(n){if(Rl(n),!n.startsWith("programs/"))return n;let e=n.slice(9),t=e.split("/",1)[0];return mt.has(t)?n:`programs/wasm32/${e}`}function Ll(n,e=U(Bi(),"wasm")){let t=Ke(n),r=[U(e,t)];return n==="kernel.wasm"?r.push(U(e,"kandelo-kernel.wasm")):n==="userspace.wasm"?r.push(U(e,"wasm_posix_userspace.wasm")):n==="rootfs.vfs"&&r.push(U(e,"rootfs.vfs")),r}var mn=class extends Error{constructor(e){super(e),this.name="BinaryNotFoundError"}};function xa(){let n=[],e=!1;try{let r=ht();e=!0;for(let[i,o]of[["local-binaries",U(r,"local-binaries")],["binaries",U(r,"binaries")]])n.push({label:i,root:o,identity:i==="local-binaries"?"local-generation":"program-cache",allowRegularFileClosure:!1,candidatesFor(s){return[U(o,Ke(s))]}})}catch{}let t=U(Bi(),"wasm");return n.push({label:"installed package",root:t,identity:"installed-package",allowRegularFileClosure:!e,candidatesFor(r){return Ll(r,t)}}),n}function Dt(n,e){return new Error(`Invalid package manifest ${n}: ${e}`)}function fe(n){try{return yn(n),!0}catch(e){if(e instanceof Error&&"code"in e&&e.code==="ENOENT")return!1;throw e}}function ma(n,e,t){if(n.length===0||n.startsWith("/")||n.includes("\\")||n.includes("\0")||n.split("/").some(r=>!r||r==="."||r===".."))throw Dt(e,`${t} must be a normalized portable relative path`);return n}function pn(n,e,t,r=!0){if(n.length===0||n==="."||n===".."||n.includes("/")||n.includes("\\")||n.includes("\0")||!r&&n.includes("@"))throw Dt(e,`${t} must be a safe single path component`);return n}var ya="kandelo-program-packages-v2",De="program-packages.json",_a=null,bl=null,hn=null,Ni=0;function $i(){return bl??U(Bi(),"wasm",De)}function Ia(){if(Object.prototype.hasOwnProperty.call(process.env,"WASM_POSIX_DEPS_REGISTRY")){let t=null;return(process.env.WASM_POSIX_DEPS_REGISTRY??"").split(":").filter(Boolean).map(r=>r.startsWith("~/")&&process.env.HOME!==void 0?U(process.env.HOME,r.slice(2)):_n(r)?Re(r):(t??=ht(),Re(t,r)))}let n;try{n=U(ht(),"packages","registry")}catch{return null}let e=!1;if(fe(n)){if(!rt(n).isDirectory())return[n];e=Oa(n,{withFileTypes:!0}).filter(t=>t.isDirectory()||t.isSymbolicLink()).some(t=>fe(U(n,t.name,"package.toml")))}return!e&&Ta()===null&&fe($i())?null:[n]}function Ta(){let n;try{n=ht()}catch{return null}if(!xr(U(n,"tools","xtask","Cargo.toml"))||!xr(U(n,"scripts","dev-shell.sh")))return null;try{let e=Le(Ki()),t=Le(n);return[U(t,"host"),U(t,"scripts")].some(i=>xr(i)&&Vi(Le(i),e))?t:null}catch{return null}}function Ui(n,e,t){let r=[typeof t.stderr=="string"?t.stderr.trim():"",typeof t.stdout=="string"?t.stdout.trim():"",t.error?.message??""].filter(Boolean).join(` `);return`${n} ${e.join(" ")} failed${t.status===null?"":` with status ${t.status}`}${r?`: -${r}`:""}`}function pd(n){let e=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,t=e?"rustc":"bash",r=e?["-vV"]:[$(n,"scripts","dev-shell.sh"),"rustc","-vV"],i=Ri(t,r,{cwd:n,encoding:"utf8"});if(i.status!==0)throw new Error(Li(t,r,i));let s=i.stdout.split(/\r?\n/).find(o=>o.startsWith("host: "))?.slice(6).trim();if(!s)throw new Error(`Could not determine the Rust host target for ${n}`);return s}function Ai(n){try{if(cn(n).isFile())return Re(n)}catch{}throw new Error(`Prepared xtask is not a regular file: ${n}`)}function md(n){let e=process.env.WASM_POSIX_XTASK_BIN;if(e!==void 0){let u=un(e)?Ie(e):Ie(n,e);return Ai(u)}if(sn?.sourceRepoRoot===n)return Ai(sn.xtaskPath);let t=pd(n),r=$(n,"target",t,"release",process.platform==="win32"?"xtask.exe":"xtask"),i=["build","--release","-p","xtask","--target",t,"--quiet"],s=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,o=s?"cargo":"bash",c=s?i:[$(n,"scripts","dev-shell.sh"),"cargo",...i],a=Ri(o,c,{cwd:n,encoding:"utf8"});if(a.status!==0)throw new Error(Li(o,c,a));return sn={sourceRepoRoot:n,xtaskPath:Ai(r)},sn.xtaskPath}function _d(){let n=ra();if(n===null)return;let e=ta();if(e===null)return;if(Vs){Vs(n,e);return}let t=md(n),r=["build-deps","program-index-context-check","--source-repo-root",n],i=Ri(t,r,{cwd:n,encoding:"utf8",env:{...process.env,WASM_POSIX_DEPS_REGISTRY:e.join(":")}});if(i.status!==0)throw new Error(`Program package source projection is not current: -${Li(t,r,i)}`)}function yd(n,e){if(Oi>0||!n.some(t=>t.startsWith("programs/")))return e();Oi+=1;try{return _d(),e()}finally{Oi-=1}}function Xe(n,e){let t=Object.keys(n).sort(),r=[...e].sort();return t.length===r.length&&t.every((i,s)=>i===r[s])}function Ii(n){let e;try{e=JSON.parse(ut(n,"utf8"))}catch(o){throw new Error(`Invalid program package index ${n}: ${o instanceof Error?o.message:String(o)}`)}if(typeof e!="object"||e===null||!Xe(e,["format","identities","packages"])||e.format!==Hs||typeof e.identities!="object"||e.identities===null||Array.isArray(e.identities)||typeof e.packages!="object"||e.packages===null||Array.isArray(e.packages))throw new Error(`Invalid program package index ${n}: expected ${Hs}`);let t=new Map,r=e.identities;for(let[o,c]of Object.entries(r)){if(on(o,n,"identity package name",!1),typeof c!="object"||c===null||!Xe(c,["manifestSha256","cacheKeys"])||typeof c.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(c.manifestSha256)||typeof c.cacheKeys!="object"||c.cacheKeys===null||Array.isArray(c.cacheKeys))throw new Error(`Invalid program package index ${n}: malformed identity ${JSON.stringify(o)}`);let a=c.cacheKeys;if(!Xe(a,["wasm32","wasm64"])||Object.values(a).some(u=>typeof u!="string"||!/^[a-f0-9]{64}$/.test(u)))throw new Error(`Invalid program package index ${n}: identity ${JSON.stringify(o)} has invalid contextual cache keys`);t.set(o,{manifestSha256:c.manifestSha256,cacheKeys:a})}let i=new Map,s=e.packages;for(let[o,c]of Object.entries(s)){if(on(o,n,"package name",!1),typeof c!="object"||c===null||!Xe(c,["manifestSha256","arches","cacheKeys","dependencyClosures","members"])||!Array.isArray(c.arches)||typeof c.cacheKeys!="object"||c.cacheKeys===null||Array.isArray(c.cacheKeys)||typeof c.dependencyClosures!="object"||c.dependencyClosures===null||Array.isArray(c.dependencyClosures)||!Array.isArray(c.members)||typeof c.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(c.manifestSha256))throw new Error(`Invalid program package index ${n}: malformed package ${JSON.stringify(o)}`);let a=c.arches;if(a.length===0||new Set(a).size!==a.length||a.some(h=>typeof h!="string"||!lt.has(h)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has invalid arches`);let u=c.cacheKeys;if(!Xe(u,a)||Object.values(u).some(h=>typeof h!="string"||!/^[a-f0-9]{64}$/.test(h)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has invalid cache keys`);let l=c.dependencyClosures;if(!Xe(l,a))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has invalid dependency closure arches`);let d={};for(let h of a){let g=l[h];if(!Array.isArray(g))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} has a malformed dependency closure for ${h}`);let _=new Set;d[h]=g.map((y,E)=>{if(typeof y!="object"||y===null||!Xe(y,["packageName","manifestSha256","cacheKey"])||typeof y.packageName!="string"||typeof y.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(y.manifestSha256)||typeof y.cacheKey!="string"||!/^[a-f0-9]{64}$/.test(y.cacheKey))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} dependency ${E+1} for ${h} is malformed`);let O=y;if(on(O.packageName,n,`${o} dependency packageName`,!1),O.packageName===o||_.has(O.packageName))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} dependency closure for ${h} must contain unique dependencies other than itself`);_.add(O.packageName);let w=t.get(O.packageName);if(!w||w.manifestSha256!==O.manifestSha256||w.cacheKeys[h]!==O.cacheKey)throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} dependency ${JSON.stringify(O.packageName)} for ${h} does not match the index's authoritative contextual identity`);return O})}let p=c.members.map((h,g)=>{if(typeof h!="object"||h===null||h.kind!=="output"&&h.kind!=="runtime-file"||typeof h.sourceArtifact!="string"||typeof h.mirrorPath!="string")throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} member ${g+1} is malformed`);let _=h,y=_.kind==="output"?["kind","sourceArtifact","mirrorPath","outputName","forkInstrumentation"]:["kind","sourceArtifact","mirrorPath","guestPath","mode"];if(!Xe(_,y))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} member ${g+1} has unknown or missing fields`);if(Gs(_.sourceArtifact,n,`${o} sourceArtifact`),Gs(_.mirrorPath,n,`${o} mirrorPath`),_.kind==="output"){if(typeof _.outputName!="string"||_.forkInstrumentation!=="auto"&&_.forkInstrumentation!=="disabled")throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} output member lacks outputName or forkInstrumentation`);on(_.outputName,n,`${o} outputName`)}else if(typeof _.guestPath!="string"||!_.guestPath.startsWith("/")||!Number.isInteger(_.mode)||_.mode<0||_.mode>511)throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} runtime member lacks valid guestPath or mode`);return _});if(p.length===0||new Set(p.map(h=>h.sourceArtifact)).size!==p.length||new Set(p.map(h=>h.mirrorPath)).size!==p.length||p.length===1&&p[0].mirrorPath.includes("/")||p.length>1&&p.some(h=>!h.mirrorPath.startsWith(`${o}/`)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} members are empty, collide, or violate scalar/package-directory layout`);let m=c.manifestSha256,f=t.get(o);if(!f||f.manifestSha256!==m||a.some(h=>f.cacheKeys[h]!==u[h]))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(o)} does not match its contextual package identity`);i.set(o,{manifestSha256:m,arches:a,cacheKeys:u,dependencyClosures:d,members:p})}return{identities:t,packages:i,indexPath:n}}function na(n){return JSON.stringify({manifestSha256:n.manifestSha256,arches:n.arches,cacheKeys:Object.fromEntries(n.arches.map(e=>[e,n.cacheKeys[e]])),dependencyClosures:Object.fromEntries(n.arches.map(e=>[e,[...n.dependencyClosures[e]].sort((t,r)=>t.packageNamer.packageName?1:0)])),members:n.members.map(e=>e.kind==="output"?{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,outputName:e.outputName,forkInstrumentation:e.forkInstrumentation}:{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,guestPath:e.guestPath,mode:e.mode})})}function zi(){let n=vi();return ce(n)?Ii(n):null}function gd(n){let e=zi();if(!e)return null;let t=n.split("/");if(t[0]!=="programs"||!lt.has(t[1]))return null;let r=t[1];if(t.length>=4){let s=t[2];return e.packages.get(s)?.arches.includes(r)?s:null}if(t.length!==3)return null;let i=t[2];for(let[s,o]of e.packages)if(o.arches.includes(r)&&o.members.some(c=>c.kind==="output"&&c.mirrorPath.split("/").at(-1)===i))return s;return null}function qs(n){let e=gd(n);if(e)throw new Error(`Installed package resolver path ${JSON.stringify(n)} is owned by ${JSON.stringify(e)}, but that package is not selected by the configured program registry`)}function ia(){let n=ta(),e=new Map,t=new Map,r=new Map,i=new Map,s=[];if(n===null){let u=vi();if(!ce(u))return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:s};let l=Ii(u);for(let[d,p]of l.identities)e.set(d,{...p,packageName:d,policyPath:`${l.indexPath}#identities.${d}`});for(let[d,p]of l.packages)s.push({packageName:d,projection:p,selected:!0}),r.set(d,{...p,packageName:d,policyPath:`${l.indexPath}#${d}`});return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:s}}let o=new Set,c=null,a=null;for(let u of n){if(!ce(u))continue;if(!Ye(u).isDirectory())throw new Error(`Program registry root is not a directory: ${u}`);let l=$(u,Ce);if(!ce(l))throw new Error(`Program registry ${u} is missing ${Ce}; generate it with xtask build-deps program-index`);let d=Ii(l);c??=d.identities,a??=d.packages;let p=js(u,{withFileTypes:!0}).filter(m=>m.isDirectory()||m.isSymbolicLink()).sort((m,f)=>m.name.localeCompare(f.name));for(let m of p){let f=m.name,h=$(u,f,"package.toml");if(!ce(h))continue;let g=!1;try{g=Ye(h).isFile()}catch{g=!1}if(!g)continue;let _=d.packages.get(f),y=!o.has(f);if(_&&s.push({packageName:f,projection:_,selected:y}),!y)continue;o.add(f);let E=c.get(f);E?e.set(f,{...E,packageName:f,manifestPath:h,policyPath:h}):t.set(f,h);let O=a.get(f);if(!O){i.set(f,h);continue}r.set(f,{...O,packageName:f,manifestPath:h,policyPath:h})}}return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:s}}function Zs(n){if(!n.manifestPath)return;let e;try{e=ut(n.manifestPath)}catch(r){throw new Error(`Program package identity cannot verify ${n.manifestPath}: ${r instanceof Error?r.message:String(r)}`)}if(Js("sha256").update(e).digest("hex")!==n.manifestSha256)throw new Error(`Program package identity is stale for ${n.manifestPath}; regenerate ${Ce}`)}function Ed(n){if(!n.manifestPath)return;let e;try{e=ut(n.manifestPath)}catch(r){throw new Error(`Program package projection cannot verify ${n.manifestPath}: ${r instanceof Error?r.message:String(r)}`)}if(Js("sha256").update(e).digest("hex")!==n.manifestSha256)throw new Error(`Program package projection is stale for ${n.manifestPath}; regenerate ${Ce}`)}function Sr(n){let e=bi(),t=e.packages.get(n);if(t)return Ed(t),t;let r=e.unprojectedPackages.get(n);if(r)throw new Error(`Package ${JSON.stringify(n)} is selected at ${r} but is absent from ${Ce}; regenerate the registry projection`);return null}function Sd(n,e){let t=n.dependencyClosures[e];if(!t)throw Pt(n.policyPath,`package ${JSON.stringify(n.packageName)} lacks a dependency identity closure for ${e}`);let r=ia(),i=r.identities.get(n.packageName);if(!i){let o=r.unidentifiedPackages.get(n.packageName);throw new Error(`Program package ${JSON.stringify(n.packageName)} has no authoritative contextual identity for ${e}${o?` at ${o}`:""}; regenerate ${Ce} with the exact ordered registry roots`)}Zs(i);let s=i.cacheKeys[e];if(i.manifestSha256!==n.manifestSha256||s!==n.cacheKeys[e])throw new Error(`Program package ${JSON.stringify(n.packageName)} was projected with manifest ${n.manifestSha256} and cache key ${n.cacheKeys[e]} for ${e}, but the authoritative first-hit registry context at ${i.policyPath} requires manifest ${i.manifestSha256} and cache key ${s??""}. Regenerate this program projection with the exact ordered registry roots; the highest-priority index must carry the complete combined-context projection rather than relying on a lower suffix-context build identity.`);for(let o of t){let c=r.identities.get(o.packageName);if(!c){let u=r.unidentifiedPackages.get(o.packageName);throw u?new Error(`Program package ${JSON.stringify(n.packageName)} was generated against dependency ${JSON.stringify(o.packageName)}, but the first-hit package at ${u} has no contextual identity in ${Ce}`):new Error(`Program package ${JSON.stringify(n.packageName)} was generated against dependency ${JSON.stringify(o.packageName)}, but that dependency is absent from the configured first-hit registry roots`)}Zs(c);let a=c.cacheKeys[e];if(c.manifestSha256!==o.manifestSha256||a!==o.cacheKey)throw new Error(`Program package ${JSON.stringify(n.packageName)} has a contextual cache identity mismatch for ${e}: its projection expects dependency ${JSON.stringify(o.packageName)} manifest ${o.manifestSha256} and cache key ${o.cacheKey}, but first-hit selection at ${c.policyPath} provides manifest ${c.manifestSha256} and cache key ${a??""}. Regenerate the program projection with the exact ordered registry roots; the complete highest-priority projection must bind every selected program to the same combined dependency context.`)}}function bi(){let n=ia(),{physicalProgramClaims:e,...t}=n,r={...t,legacyFlatOutputs:new Map,forkInstrumentationDisabledOutputs:new Map},i=[];for(let s of n.packages.values()){let o=s.members.length>1;for(let c of s.arches)for(let a of s.members){let u=i.find(m=>m.arch===c&&(m.path===a.mirrorPath||m.path.startsWith(`${a.mirrorPath}/`)||a.mirrorPath.startsWith(`${m.path}/`)));if(u)throw new Error(`Program resolver paths programs/${c}/${u.path} and programs/${c}/${a.mirrorPath} conflict between selected packages ${JSON.stringify(u.packageName)} and ${JSON.stringify(s.packageName)}`);if(i.push({arch:c,path:a.mirrorPath,packageName:s.packageName}),a.kind!=="output")continue;let l=a.mirrorPath.split("/").at(-1),d=`${c}/${l}`,p=r.legacyFlatOutputs.get(d);p||(p={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},r.legacyFlatOutputs.set(d,p)),o?p.packagePaths.set(`programs/${c}/${a.mirrorPath}`,s.packageName):p.scalarOwners.add(s.packageName),a.forkInstrumentation==="disabled"&&r.forkInstrumentationDisabledOutputs.set(`${c}/${a.mirrorPath}`,s.packageName)}}for(let{packageName:s,projection:o,selected:c}of e)if(!(c&&n.packages.has(s)))for(let a of o.arches)for(let u of o.members){if(u.kind!=="output")continue;let l=u.mirrorPath.split("/").at(-1),d=`${a}/${l}`,p=r.legacyFlatOutputs.get(d);p||(p={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},r.legacyFlatOutputs.set(d,p)),p.shadowedOwners.add(s)}return r}function wd(n){let e=n.split("/");if(e.length!==3||e[0]!=="programs"||!lt.has(e[1]))return null;let t=bi().legacyFlatOutputs.get(`${e[1]}/${e[2]}`);if(!t)return null;for(let r of t.scalarOwners){let i=Sr(r);if(i)return i}for(let r of t.packagePaths.values())Sr(r);if(t.packagePaths.size>0)throw new Error(`Legacy flat resolver path ${JSON.stringify(n)} belongs to a multi-member package; use ${[...t.packagePaths.keys()].sort().map(r=>JSON.stringify(r)).join(" or ")}`);for(let r of t.shadowedOwners){let i=Sr(r);if(i)return i;throw new Error(`Legacy flat resolver path ${JSON.stringify(n)} is claimed by a lower-root program package ${JSON.stringify(r)}, but its first-hit selected package does not project that program; stale scalar mirror fallback is forbidden`)}return null}function Xs(n,e,t){if(!n.arches.includes(e))throw Pt(n.policyPath,`package ${JSON.stringify(n.packageName)} does not declare resolver artifacts for ${e}`);let r=n.cacheKeys[e];if(!r)throw Pt(n.policyPath,`package ${JSON.stringify(n.packageName)} lacks a cache identity for ${e}`);Sd(n,e);let i=na(n),s=n.members.map(o=>({packageName:n.packageName,relPath:`programs/${e}/${o.mirrorPath}`,sourceArtifact:o.sourceArtifact,cacheKey:r,forkInstrumentation:o.kind==="output"?o.forkInstrumentation??null:null,projectionIdentity:i}));if(!s.some(o=>o.relPath===t))throw Pt(n.policyPath,`resolver path ${JSON.stringify(t)} is not a declared member of package ${JSON.stringify(n.packageName)}`);return{manifestPath:n.policyPath,packageName:n.packageName,members:s}}function Od(n){let e=je(n),t=e.split("/");if(t[0]==="programs"&&!ud()&&zi()===null)throw new Error(`Installed host package is missing wasm/${Ce}; program artifacts cannot be resolved without packaged policy`);if(t.length===3){let o=wd(e);return o?Xs(o,t[1],e):(qs(e),null)}if(t.length<4||t[0]!=="programs"||!lt.has(t[1]))return null;let r=t[1],i=t[2],s=Sr(i);return s?Xs(s,r,e):(qs(e),null)}function Ad(n){let e=je(n);for(let t of["programs/wasm32/","programs/wasm64/"])if(e.startsWith(t))return e.slice(t.length);return null}function Id(n){let e=je(n);for(let t of lt){let r=`programs/${t}/`;if(e.startsWith(r)){let i=bi().forkInstrumentationDisabledOutputs.get(`${t}/${e.slice(r.length)}`);return i?Sr(i)!==null:!1}}return!1}function Rd(n){let e=je(n);if(e==="kernel.wasm")return so;let t=Ad(e);if(t&&t.endsWith(".wasm"))return ad}function xd(n,e,t){if(!n.endsWith(".wasm"))return!1;try{let r=ut(n),i=r.buffer.slice(r.byteOffset,r.byteOffset+r.byteLength),s=t===void 0?Id(e):t==="disabled";return yo(i,{expectedAbi:43,requiredExports:Rd(e),requireForkInstrumentation:s?!1:void 0,forbidForkInstrumentation:s}).length>0}catch{return!0}}function Td(n){if(!n.endsWith(".vfs")&&!n.endsWith(".vfs.zst"))return!1;try{let t=rn.readImageMetadata(ut(n))?.kernelAbi;return t!==void 0&&t!==43}catch{return!0}}function Pi(n,e,t){return xd(n,e,t)||Td(n)}function oa(n,e,t){let r=n.filter(ce);return r.length===0?null:r.find(i=>{try{return Ye(i).isFile()&&!Pi(i,e,t)}catch{return!1}})??null}function sa(n,e,t){try{if(!cn(n).isSymbolicLink())return n;let i=Re(n);if(!Ye(i).isFile()||Pi(i,e,t))throw new Error("canonical target is not an accepted regular file");if(je(e).startsWith("programs/")&&vd(i))throw new Error("resolver-owned program generation has no matching selected package projection");return i}catch(r){throw new Error(`Binary changed or became invalid while pinning ${e}: ${r instanceof Error?r.message:String(r)}`)}}function vd(n){let e=[Qs()];try{e.push($(dt(),"local-binaries",".kandelo-local-generations"))}catch{}return e.some(t=>{try{return ce(t)&&ki(Re(t),n)}catch{return!1}})}function ki(n,e){let t=id(n,e);return t===""||t!==".."&&!t.startsWith(`..${od}`)&&!un(t)}function Ld(n,e){let t=e.split("/"),r=n;for(let i=0;ic.packageName!==s))return"declared package members do not share a valid program namespace";if(!Ye(e).isDirectory())return"shared package generation root is not a directory";let o=t[0].cacheKey;if(!/^[a-f0-9]{64}$/.test(o)||t.some(c=>c.cacheKey!==o))return"declared package members do not share one valid cache identity";if(n.identity==="local-generation"){let c=$(n.root,".kandelo-local-generations",i,s,o);if(!ce(c))return"local mirror targets are not one direct immutable local generation";let a=Re(c);return wr(e)===a?null:"local mirror targets are not one direct immutable local generation"}if(n.identity==="program-cache"){let c=Qs();if(!ce(c))return"fetched mirror targets are not one canonical program-cache generation";let a=Re(c),u=nd(e),l=u.startsWith(`${s}-`)&&new RegExp(`-rev[0-9]+-${i}-${o}$`).test(u);return wr(e)===a&&l?null:"fetched mirror targets are not one canonical program-cache generation"}return"installed-package symlink closures are not an immutable installed identity"}function bd(n,e,t){if(e.length!==t.length)return{failure:"internal member/path count mismatch"};try{let r=e.map(u=>{let l=cn(u);return l.isSymbolicLink()?"symlink":l.isFile()?"file":"other"});if(r.includes("other"))return{failure:"a selected mirror member is neither a regular file nor a symlink"};let i=r.every(u=>u==="symlink"),s=r.every(u=>u==="file");if(!i&&!s)return{failure:"regular files and symlinks cannot share one package identity"};if(s){if(!n.allowRegularFileClosure)return{failure:"a mutable source-checkout wasm tree is not an installed package identity"};let u=t[0].packageName,l=t[0].projectionIdentity;if(t.some(h=>h.packageName!==u||h.projectionIdentity!==l))return{failure:"declared members do not share one selected package projection"};let p=zi()?.packages.get(u);if(!p||na(p)!==l)return{failure:"installed bytes do not match the selected package projection"};let m=Re(n.root),f=[];for(let h of e){let g=Re(h);if(!ki(m,g)||!Ye(g).isFile())return{failure:"an installed-package member escapes its immutable wasm tree"};f.push(g)}return{paths:f}}let o=null,c=[];for(let u=0;uPd(n))}function Pd(n){let e=je(n),t=Od(e);if(t){let o=kd(t.members.map(c=>c.relPath),t.members);if(o)return o[t.members.findIndex(c=>c.relPath===e)];throw new an(`Package artifacts not found for ${t.packageName}: ${e}`)}let r=[],i=[];for(let o of ea())for(let c of o.candidatesFor(n))r.push(c),i.push(c);let s=oa(i,n);if(s)return sa(s,n);throw i.some(ce)?new Error(`Binary exists but was rejected by artifact policy: ${n} -`+r.map(o=>` checked: ${o}`).join(` -`)):new an(`Binary not found: ${n} -`+r.map(o=>` checked: ${o}`).join(` +${r}`:""}`}function vl(n){let e=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,t=e?"rustc":"bash",r=e?["-vV"]:[U(n,"scripts","dev-shell.sh"),"rustc","-vV"],i=Di(t,r,{cwd:n,encoding:"utf8"});if(i.status!==0)throw new Error(Ui(t,r,i));let o=i.stdout.split(/\r?\n/).find(s=>s.startsWith("host: "))?.slice(6).trim();if(!o)throw new Error(`Could not determine the Rust host target for ${n}`);return o}function Ci(n){try{if(yn(n).isFile())return Le(n)}catch{}throw new Error(`Prepared xtask is not a regular file: ${n}`)}function Pl(n){let e=process.env.WASM_POSIX_XTASK_BIN;if(e!==void 0){let u=_n(e)?Re(e):Re(n,e);return Ci(u)}if(hn?.sourceRepoRoot===n)return Ci(hn.xtaskPath);let t=vl(n),r=U(n,"target",t,"release",process.platform==="win32"?"xtask.exe":"xtask"),i=["build","--release","-p","xtask","--target",t,"--quiet"],o=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,s=o?"cargo":"bash",a=o?i:[U(n,"scripts","dev-shell.sh"),"cargo",...i],c=Di(s,a,{cwd:n,encoding:"utf8"});if(c.status!==0)throw new Error(Ui(s,a,c));return hn={sourceRepoRoot:n,xtaskPath:Ci(r)},hn.xtaskPath}function kl(){let n=Ta();if(n===null)return;let e=Ia();if(e===null)return;if(_a){_a(n,e);return}let t=Pl(n),r=["build-deps","program-index-context-check","--source-repo-root",n],i=Di(t,r,{cwd:n,encoding:"utf8",env:{...process.env,WASM_POSIX_DEPS_REGISTRY:e.join(":")}});if(i.status!==0)throw new Error(`Program package source projection is not current: +${Ui(t,r,i)}`)}function Fl(n,e){if(Ni>0||!n.some(t=>t.startsWith("programs/")))return e();Ni+=1;try{return kl(),e()}finally{Ni-=1}}function tt(n,e){let t=Object.keys(n).sort(),r=[...e].sort();return t.length===r.length&&t.every((i,o)=>i===r[o])}function Mi(n){let e;try{e=JSON.parse(pt(n,"utf8"))}catch(s){throw new Error(`Invalid program package index ${n}: ${s instanceof Error?s.message:String(s)}`)}if(typeof e!="object"||e===null||!tt(e,["format","identities","packages"])||e.format!==ya||typeof e.identities!="object"||e.identities===null||Array.isArray(e.identities)||typeof e.packages!="object"||e.packages===null||Array.isArray(e.packages))throw new Error(`Invalid program package index ${n}: expected ${ya}`);let t=new Map,r=e.identities;for(let[s,a]of Object.entries(r)){if(pn(s,n,"identity package name",!1),typeof a!="object"||a===null||!tt(a,["manifestSha256","cacheKeys"])||typeof a.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(a.manifestSha256)||typeof a.cacheKeys!="object"||a.cacheKeys===null||Array.isArray(a.cacheKeys))throw new Error(`Invalid program package index ${n}: malformed identity ${JSON.stringify(s)}`);let c=a.cacheKeys;if(!tt(c,["wasm32","wasm64"])||Object.values(c).some(u=>typeof u!="string"||!/^[a-f0-9]{64}$/.test(u)))throw new Error(`Invalid program package index ${n}: identity ${JSON.stringify(s)} has invalid contextual cache keys`);t.set(s,{manifestSha256:a.manifestSha256,cacheKeys:c})}let i=new Map,o=e.packages;for(let[s,a]of Object.entries(o)){if(pn(s,n,"package name",!1),typeof a!="object"||a===null||!tt(a,["manifestSha256","arches","cacheKeys","dependencyClosures","members"])||!Array.isArray(a.arches)||typeof a.cacheKeys!="object"||a.cacheKeys===null||Array.isArray(a.cacheKeys)||typeof a.dependencyClosures!="object"||a.dependencyClosures===null||Array.isArray(a.dependencyClosures)||!Array.isArray(a.members)||typeof a.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(a.manifestSha256))throw new Error(`Invalid program package index ${n}: malformed package ${JSON.stringify(s)}`);let c=a.arches;if(c.length===0||new Set(c).size!==c.length||c.some(p=>typeof p!="string"||!mt.has(p)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} has invalid arches`);let u=a.cacheKeys;if(!tt(u,c)||Object.values(u).some(p=>typeof p!="string"||!/^[a-f0-9]{64}$/.test(p)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} has invalid cache keys`);let l=a.dependencyClosures;if(!tt(l,c))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} has invalid dependency closure arches`);let d={};for(let p of c){let _=l[p];if(!Array.isArray(_))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} has a malformed dependency closure for ${p}`);let y=new Set;d[p]=_.map((g,E)=>{if(typeof g!="object"||g===null||!tt(g,["packageName","manifestSha256","cacheKey"])||typeof g.packageName!="string"||typeof g.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(g.manifestSha256)||typeof g.cacheKey!="string"||!/^[a-f0-9]{64}$/.test(g.cacheKey))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} dependency ${E+1} for ${p} is malformed`);let O=g;if(pn(O.packageName,n,`${s} dependency packageName`,!1),O.packageName===s||y.has(O.packageName))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} dependency closure for ${p} must contain unique dependencies other than itself`);y.add(O.packageName);let S=t.get(O.packageName);if(!S||S.manifestSha256!==O.manifestSha256||S.cacheKeys[p]!==O.cacheKey)throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} dependency ${JSON.stringify(O.packageName)} for ${p} does not match the index's authoritative contextual identity`);return O})}let h=a.members.map((p,_)=>{if(typeof p!="object"||p===null||p.kind!=="output"&&p.kind!=="runtime-file"||typeof p.sourceArtifact!="string"||typeof p.mirrorPath!="string")throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} member ${_+1} is malformed`);let y=p,g=y.kind==="output"?["kind","sourceArtifact","mirrorPath","outputName","forkInstrumentation"]:["kind","sourceArtifact","mirrorPath","guestPath","mode"];if(!tt(y,g))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} member ${_+1} has unknown or missing fields`);if(ma(y.sourceArtifact,n,`${s} sourceArtifact`),ma(y.mirrorPath,n,`${s} mirrorPath`),y.kind==="output"){if(typeof y.outputName!="string"||y.forkInstrumentation!=="auto"&&y.forkInstrumentation!=="disabled")throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} output member lacks outputName or forkInstrumentation`);pn(y.outputName,n,`${s} outputName`)}else if(typeof y.guestPath!="string"||!y.guestPath.startsWith("/")||!Number.isInteger(y.mode)||y.mode<0||y.mode>511)throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} runtime member lacks valid guestPath or mode`);return y});if(h.length===0||new Set(h.map(p=>p.sourceArtifact)).size!==h.length||new Set(h.map(p=>p.mirrorPath)).size!==h.length||h.length===1&&h[0].mirrorPath.includes("/")||h.length>1&&h.some(p=>!p.mirrorPath.startsWith(`${s}/`)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} members are empty, collide, or violate scalar/package-directory layout`);let m=a.manifestSha256,f=t.get(s);if(!f||f.manifestSha256!==m||c.some(p=>f.cacheKeys[p]!==u[p]))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} does not match its contextual package identity`);i.set(s,{manifestSha256:m,arches:c,cacheKeys:u,dependencyClosures:d,members:h})}return{identities:t,packages:i,indexPath:n}}function Ra(n){return JSON.stringify({manifestSha256:n.manifestSha256,arches:n.arches,cacheKeys:Object.fromEntries(n.arches.map(e=>[e,n.cacheKeys[e]])),dependencyClosures:Object.fromEntries(n.arches.map(e=>[e,[...n.dependencyClosures[e]].sort((t,r)=>t.packageNamer.packageName?1:0)])),members:n.members.map(e=>e.kind==="output"?{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,outputName:e.outputName,forkInstrumentation:e.forkInstrumentation}:{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,guestPath:e.guestPath,mode:e.mode})})}function Wi(){let n=$i();return fe(n)?Mi(n):null}function Nl(n){let e=Wi();if(!e)return null;let t=n.split("/");if(t[0]!=="programs"||!mt.has(t[1]))return null;let r=t[1];if(t.length>=4){let o=t[2];return e.packages.get(o)?.arches.includes(r)?o:null}if(t.length!==3)return null;let i=t[2];for(let[o,s]of e.packages)if(s.arches.includes(r)&&s.members.some(a=>a.kind==="output"&&a.mirrorPath.split("/").at(-1)===i))return o;return null}function ga(n){let e=Nl(n);if(e)throw new Error(`Installed package resolver path ${JSON.stringify(n)} is owned by ${JSON.stringify(e)}, but that package is not selected by the configured program registry`)}function La(){let n=Ia(),e=new Map,t=new Map,r=new Map,i=new Map,o=[];if(n===null){let u=$i();if(!fe(u))return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:o};let l=Mi(u);for(let[d,h]of l.identities)e.set(d,{...h,packageName:d,policyPath:`${l.indexPath}#identities.${d}`});for(let[d,h]of l.packages)o.push({packageName:d,projection:h,selected:!0}),r.set(d,{...h,packageName:d,policyPath:`${l.indexPath}#${d}`});return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:o}}let s=new Set,a=null,c=null;for(let u of n){if(!fe(u))continue;if(!rt(u).isDirectory())throw new Error(`Program registry root is not a directory: ${u}`);let l=U(u,De);if(!fe(l))throw new Error(`Program registry ${u} is missing ${De}; generate it with xtask build-deps program-index`);let d=Mi(l);a??=d.identities,c??=d.packages;let h=Oa(u,{withFileTypes:!0}).filter(m=>m.isDirectory()||m.isSymbolicLink()).sort((m,f)=>m.name.localeCompare(f.name));for(let m of h){let f=m.name,p=U(u,f,"package.toml");if(!fe(p))continue;let _=!1;try{_=rt(p).isFile()}catch{_=!1}if(!_)continue;let y=d.packages.get(f),g=!s.has(f);if(y&&o.push({packageName:f,projection:y,selected:g}),!g)continue;s.add(f);let E=a.get(f);E?e.set(f,{...E,packageName:f,manifestPath:p,policyPath:p}):t.set(f,p);let O=c.get(f);if(!O){i.set(f,p);continue}r.set(f,{...O,packageName:f,manifestPath:p,policyPath:p})}}return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:o}}function Ea(n){if(!n.manifestPath)return;let e;try{e=pt(n.manifestPath)}catch(r){throw new Error(`Program package identity cannot verify ${n.manifestPath}: ${r instanceof Error?r.message:String(r)}`)}if(Aa("sha256").update(e).digest("hex")!==n.manifestSha256)throw new Error(`Program package identity is stale for ${n.manifestPath}; regenerate ${De}`)}function Cl(n){if(!n.manifestPath)return;let e;try{e=pt(n.manifestPath)}catch(r){throw new Error(`Program package projection cannot verify ${n.manifestPath}: ${r instanceof Error?r.message:String(r)}`)}if(Aa("sha256").update(e).digest("hex")!==n.manifestSha256)throw new Error(`Program package projection is stale for ${n.manifestPath}; regenerate ${De}`)}function Ir(n){let e=Gi(),t=e.packages.get(n);if(t)return Cl(t),t;let r=e.unprojectedPackages.get(n);if(r)throw new Error(`Package ${JSON.stringify(n)} is selected at ${r} but is absent from ${De}; regenerate the registry projection`);return null}function Ml(n,e){let t=n.dependencyClosures[e];if(!t)throw Dt(n.policyPath,`package ${JSON.stringify(n.packageName)} lacks a dependency identity closure for ${e}`);let r=La(),i=r.identities.get(n.packageName);if(!i){let s=r.unidentifiedPackages.get(n.packageName);throw new Error(`Program package ${JSON.stringify(n.packageName)} has no authoritative contextual identity for ${e}${s?` at ${s}`:""}; regenerate ${De} with the exact ordered registry roots`)}Ea(i);let o=i.cacheKeys[e];if(i.manifestSha256!==n.manifestSha256||o!==n.cacheKeys[e])throw new Error(`Program package ${JSON.stringify(n.packageName)} was projected with manifest ${n.manifestSha256} and cache key ${n.cacheKeys[e]} for ${e}, but the authoritative first-hit registry context at ${i.policyPath} requires manifest ${i.manifestSha256} and cache key ${o??""}. Regenerate this program projection with the exact ordered registry roots; the highest-priority index must carry the complete combined-context projection rather than relying on a lower suffix-context build identity.`);for(let s of t){let a=r.identities.get(s.packageName);if(!a){let u=r.unidentifiedPackages.get(s.packageName);throw u?new Error(`Program package ${JSON.stringify(n.packageName)} was generated against dependency ${JSON.stringify(s.packageName)}, but the first-hit package at ${u} has no contextual identity in ${De}`):new Error(`Program package ${JSON.stringify(n.packageName)} was generated against dependency ${JSON.stringify(s.packageName)}, but that dependency is absent from the configured first-hit registry roots`)}Ea(a);let c=a.cacheKeys[e];if(a.manifestSha256!==s.manifestSha256||c!==s.cacheKey)throw new Error(`Program package ${JSON.stringify(n.packageName)} has a contextual cache identity mismatch for ${e}: its projection expects dependency ${JSON.stringify(s.packageName)} manifest ${s.manifestSha256} and cache key ${s.cacheKey}, but first-hit selection at ${a.policyPath} provides manifest ${a.manifestSha256} and cache key ${c??""}. Regenerate the program projection with the exact ordered registry roots; the complete highest-priority projection must bind every selected program to the same combined dependency context.`)}}function Gi(){let n=La(),{physicalProgramClaims:e,...t}=n,r={...t,legacyFlatOutputs:new Map,forkInstrumentationDisabledOutputs:new Map},i=[];for(let o of n.packages.values()){let s=o.members.length>1;for(let a of o.arches)for(let c of o.members){let u=i.find(m=>m.arch===a&&(m.path===c.mirrorPath||m.path.startsWith(`${c.mirrorPath}/`)||c.mirrorPath.startsWith(`${m.path}/`)));if(u)throw new Error(`Program resolver paths programs/${a}/${u.path} and programs/${a}/${c.mirrorPath} conflict between selected packages ${JSON.stringify(u.packageName)} and ${JSON.stringify(o.packageName)}`);if(i.push({arch:a,path:c.mirrorPath,packageName:o.packageName}),c.kind!=="output")continue;let l=c.mirrorPath.split("/").at(-1),d=`${a}/${l}`,h=r.legacyFlatOutputs.get(d);h||(h={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},r.legacyFlatOutputs.set(d,h)),s?h.packagePaths.set(`programs/${a}/${c.mirrorPath}`,o.packageName):h.scalarOwners.add(o.packageName),c.forkInstrumentation==="disabled"&&r.forkInstrumentationDisabledOutputs.set(`${a}/${c.mirrorPath}`,o.packageName)}}for(let{packageName:o,projection:s,selected:a}of e)if(!(a&&n.packages.has(o)))for(let c of s.arches)for(let u of s.members){if(u.kind!=="output")continue;let l=u.mirrorPath.split("/").at(-1),d=`${c}/${l}`,h=r.legacyFlatOutputs.get(d);h||(h={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},r.legacyFlatOutputs.set(d,h)),h.shadowedOwners.add(o)}return r}function Dl(n){let e=n.split("/");if(e.length!==3||e[0]!=="programs"||!mt.has(e[1]))return null;let t=Gi().legacyFlatOutputs.get(`${e[1]}/${e[2]}`);if(!t)return null;for(let r of t.scalarOwners){let i=Ir(r);if(i)return i}for(let r of t.packagePaths.values())Ir(r);if(t.packagePaths.size>0)throw new Error(`Legacy flat resolver path ${JSON.stringify(n)} belongs to a multi-member package; use ${[...t.packagePaths.keys()].sort().map(r=>JSON.stringify(r)).join(" or ")}`);for(let r of t.shadowedOwners){let i=Ir(r);if(i)return i;throw new Error(`Legacy flat resolver path ${JSON.stringify(n)} is claimed by a lower-root program package ${JSON.stringify(r)}, but its first-hit selected package does not project that program; stale scalar mirror fallback is forbidden`)}return null}function Sa(n,e,t){if(!n.arches.includes(e))throw Dt(n.policyPath,`package ${JSON.stringify(n.packageName)} does not declare resolver artifacts for ${e}`);let r=n.cacheKeys[e];if(!r)throw Dt(n.policyPath,`package ${JSON.stringify(n.packageName)} lacks a cache identity for ${e}`);Ml(n,e);let i=Ra(n),o=n.members.map(s=>({packageName:n.packageName,relPath:`programs/${e}/${s.mirrorPath}`,sourceArtifact:s.sourceArtifact,cacheKey:r,forkInstrumentation:s.kind==="output"?s.forkInstrumentation??null:null,projectionIdentity:i}));if(!o.some(s=>s.relPath===t))throw Dt(n.policyPath,`resolver path ${JSON.stringify(t)} is not a declared member of package ${JSON.stringify(n.packageName)}`);return{manifestPath:n.policyPath,packageName:n.packageName,members:o}}function Kl(n){let e=Ke(n),t=e.split("/");if(t[0]==="programs"&&!Il()&&Wi()===null)throw new Error(`Installed host package is missing wasm/${De}; program artifacts cannot be resolved without packaged policy`);if(t.length===3){let s=Dl(e);return s?Sa(s,t[1],e):(ga(e),null)}if(t.length<4||t[0]!=="programs"||!mt.has(t[1]))return null;let r=t[1],i=t[2],o=Ir(i);return o?Sa(o,r,e):(ga(e),null)}function Bl(n){let e=Ke(n);for(let t of["programs/wasm32/","programs/wasm64/"])if(e.startsWith(t))return e.slice(t.length);return null}function $l(n){let e=Ke(n);for(let t of mt){let r=`programs/${t}/`;if(e.startsWith(r)){let i=Gi().forkInstrumentationDisabledOutputs.get(`${t}/${e.slice(r.length)}`);return i?Ir(i)!==null:!1}}return!1}function Ul(n){let e=Ke(n);if(e==="kernel.wasm")return Ao;let t=Bl(e);if(t&&t.endsWith(".wasm"))return zl}var Wl=Object.freeze(["kernel_exec_prepare","kernel_exec_setup","kernel_exec_setup_for_thread","kernel_execve","kernel_execveat"]);function Gl(n){return Ke(n)==="kernel.wasm"?Wl:void 0}function Hl(n,e,t){if(!n.endsWith(".wasm"))return!1;try{let r=pt(n),i=r.buffer.slice(r.byteOffset,r.byteOffset+r.byteLength),o=t===void 0?$l(e):t==="disabled";return Fo(i,{expectedAbi:43,requiredExports:Ul(e),forbiddenExports:Gl(e),requireForkInstrumentation:o?!1:void 0,forbidForkInstrumentation:o}).length>0}catch{return!0}}function Vl(n){if(!n.endsWith(".vfs")&&!n.endsWith(".vfs.zst"))return!1;try{let t=q.readImageMetadata(pt(n))?.kernelAbi;return t!==void 0&&t!==43}catch{return!0}}function Hi(n,e,t){return Hl(n,e,t)||Vl(n)}function ba(n,e,t){let r=n.filter(fe);return r.length===0?null:r.find(i=>{try{return rt(i).isFile()&&!Hi(i,e,t)}catch{return!1}})??null}function va(n,e,t){try{if(!yn(n).isSymbolicLink())return n;let i=Le(n);if(!rt(i).isFile()||Hi(i,e,t))throw new Error("canonical target is not an accepted regular file");if(Ke(e).startsWith("programs/")&&ql(i))throw new Error("resolver-owned program generation has no matching selected package projection");return i}catch(r){throw new Error(`Binary changed or became invalid while pinning ${e}: ${r instanceof Error?r.message:String(r)}`)}}function ql(n){let e=[za()];try{e.push(U(ht(),"local-binaries",".kandelo-local-generations"))}catch{}return e.some(t=>{try{return fe(t)&&Vi(Le(t),n)}catch{return!1}})}function Vi(n,e){let t=wl(n,e);return t===""||t!==".."&&!t.startsWith(`..${Ol}`)&&!_n(t)}function Zl(n,e){let t=e.split("/"),r=n;for(let i=0;ia.packageName!==o))return"declared package members do not share a valid program namespace";if(!rt(e).isDirectory())return"shared package generation root is not a directory";let s=t[0].cacheKey;if(!/^[a-f0-9]{64}$/.test(s)||t.some(a=>a.cacheKey!==s))return"declared package members do not share one valid cache identity";if(n.identity==="local-generation"){let a=U(n.root,".kandelo-local-generations",i,o,s);if(!fe(a))return"local mirror targets are not one direct immutable local generation";let c=Le(a);return Tr(e)===c?null:"local mirror targets are not one direct immutable local generation"}if(n.identity==="program-cache"){let a=za();if(!fe(a))return"fetched mirror targets are not one canonical program-cache generation";let c=Le(a),u=Sl(e),l=u.startsWith(`${o}-`)&&new RegExp(`-rev[0-9]+-${i}-${s}$`).test(u);return Tr(e)===c&&l?null:"fetched mirror targets are not one canonical program-cache generation"}return"installed-package symlink closures are not an immutable installed identity"}function Xl(n,e,t){if(e.length!==t.length)return{failure:"internal member/path count mismatch"};try{let r=e.map(u=>{let l=yn(u);return l.isSymbolicLink()?"symlink":l.isFile()?"file":"other"});if(r.includes("other"))return{failure:"a selected mirror member is neither a regular file nor a symlink"};let i=r.every(u=>u==="symlink"),o=r.every(u=>u==="file");if(!i&&!o)return{failure:"regular files and symlinks cannot share one package identity"};if(o){if(!n.allowRegularFileClosure)return{failure:"a mutable source-checkout wasm tree is not an installed package identity"};let u=t[0].packageName,l=t[0].projectionIdentity;if(t.some(p=>p.packageName!==u||p.projectionIdentity!==l))return{failure:"declared members do not share one selected package projection"};let h=Wi()?.packages.get(u);if(!h||Ra(h)!==l)return{failure:"installed bytes do not match the selected package projection"};let m=Le(n.root),f=[];for(let p of e){let _=Le(p);if(!Vi(m,_)||!rt(_).isFile())return{failure:"an installed-package member escapes its immutable wasm tree"};f.push(_)}return{paths:f}}let s=null,a=[];for(let u=0;ujl(n))}function jl(n){let e=Ke(n),t=Kl(e);if(t){let s=Jl(t.members.map(a=>a.relPath),t.members);if(s)return s[t.members.findIndex(a=>a.relPath===e)];throw new mn(`Package artifacts not found for ${t.packageName}: ${e}`)}let r=[],i=[];for(let s of xa())for(let a of s.candidatesFor(n))r.push(a),i.push(a);let o=ba(i,n);if(o)return va(o,n);throw i.some(fe)?new Error(`Binary exists but was rejected by artifact policy: ${n} +`+r.map(s=>` checked: ${s}`).join(` +`)):new mn(`Binary not found: ${n} +`+r.map(s=>` checked: ${s}`).join(` `)+` - Run scripts/fetch-binaries.sh, place a file at local-binaries/${e}, or install a package that includes wasm/${n}.`)}function kd(n,e){if(n.length===0)return[];let t=!1,r=[];for(let i of ea()){let s=[],o=[];if(e){let[c,a,u]=e[0].relPath.split("/");c==="programs"&&a&&u&&(t||=ce($(i.root,c,a,u)))}for(let[c,a]of n.entries()){let u=i.candidatesFor(a),l=u.filter(ce);t||=l.length>0;let d=oa(u,a,e?.[c]?.forkInstrumentation);d?s.push(d):l.length>0?o.push(`${a} (rejected by artifact policy)`):o.push(`${a} (missing)`)}if(o.length===0&&e){let c=bd(i,s,e);if("failure"in c)o.push(`shared package identity rejected: ${c.failure}`);else{let a=c.paths.flatMap((u,l)=>Pi(u,n[l],e[l].forkInstrumentation)?[n[l]]:[]);if(a.length>0)o.push(`pinned package generation rejected by artifact policy: ${a.join(", ")}`);else return c.paths}}if(o.length===0)return s.map((c,a)=>sa(c,n[a],e?.[a]?.forkInstrumentation));r.push(` ${i.label} (${i.root}): ${o.join(", ")}`)}if(!t)return null;throw new Error(`Package artifact closure is incomplete: no single provenance tier contains every accepted artifact, and tiers will not be mixed. + Run scripts/fetch-binaries.sh, place a file at local-binaries/${e}, or install a package that includes wasm/${n}.`)}function Jl(n,e){if(n.length===0)return[];let t=!1,r=[];for(let i of xa()){let o=[],s=[];if(e){let[a,c,u]=e[0].relPath.split("/");a==="programs"&&c&&u&&(t||=fe(U(i.root,a,c,u)))}for(let[a,c]of n.entries()){let u=i.candidatesFor(c),l=u.filter(fe);t||=l.length>0;let d=ba(u,c,e?.[a]?.forkInstrumentation);d?o.push(d):l.length>0?s.push(`${c} (rejected by artifact policy)`):s.push(`${c} (missing)`)}if(s.length===0&&e){let a=Xl(i,o,e);if("failure"in a)s.push(`shared package identity rejected: ${a.failure}`);else{let c=a.paths.flatMap((u,l)=>Hi(u,n[l],e[l].forkInstrumentation)?[n[l]]:[]);if(c.length>0)s.push(`pinned package generation rejected by artifact policy: ${c.join(", ")}`);else return a.paths}}if(s.length===0)return o.map((a,c)=>va(a,n[c],e?.[c]?.forkInstrumentation));r.push(` ${i.label} (${i.root}): ${s.join(", ")}`)}if(!t)return null;throw new Error(`Package artifact closure is incomplete: no single provenance tier contains every accepted artifact, and tiers will not be mixed. `+r.join(` -`))}var[ca,...Fd]=process.argv.slice(2);(!ca||Fd.length>0)&&(console.error("usage: scripts/resolve-binary.sh "),process.exit(2));try{process.stdout.write(`${aa(ca)} +`))}var[ka,...Ql]=process.argv.slice(2);(!ka||Ql.length>0)&&(console.error("usage: scripts/resolve-binary.sh "),process.exit(2));try{process.stdout.write(`${Pa(ka)} `)}catch(n){console.error(n instanceof Error?n.message:String(n)),process.exit(1)} diff --git a/scripts/test-wasm-artifact-guards.sh b/scripts/test-wasm-artifact-guards.sh index e9a71d3f08..342d51f46c 100755 --- a/scripts/test-wasm-artifact-guards.sh +++ b/scripts/test-wasm-artifact-guards.sh @@ -21,6 +21,48 @@ cat >"$work/abi.wat" <<'WAT' WAT wat2wasm --debug-names "$work/abi.wat" -o "$work/abi.wasm" +cat >"$work/target-aware-exec.wat" <<'WAT' +(module + (func (export "kernel_exec_target_prepare")) + (func (export "kernel_spawn_exec_target_prepare")) + (func (export "kernel_exec_target_size")) + (func (export "kernel_exec_target_read")) + (func (export "kernel_exec_target_cancel")) + (func (export "kernel_exec_commit")) + (func (export "kernel_spawn_exec_commit"))) +WAT +wat2wasm "$work/target-aware-exec.wat" -o "$work/target-aware-exec.wasm" + +cat >"$work/hybrid-exec.wat" <<'WAT' +(module + (func (export "kernel_exec_target_prepare")) + (func (export "kernel_spawn_exec_target_prepare")) + (func (export "kernel_exec_target_size")) + (func (export "kernel_exec_target_read")) + (func (export "kernel_exec_target_cancel")) + (func (export "kernel_exec_commit")) + (func (export "kernel_spawn_exec_commit")) + (func (export "kernel_exec_prepare")) + (func (export "kernel_exec_setup")) + (func (export "kernel_exec_setup_for_thread")) + (func (export "kernel_execve")) + (func (export "kernel_execveat"))) +WAT +wat2wasm "$work/hybrid-exec.wat" -o "$work/hybrid-exec.wasm" + +if ! declare -F wasm_require_target_aware_exec_authority >/dev/null; then + echo "ERROR: target-aware exec artifact guard is unavailable" >&2 + exit 1 +fi +if ! wasm_require_target_aware_exec_authority "$work/target-aware-exec.wasm"; then + echo "ERROR: target-aware exec artifact was rejected" >&2 + exit 1 +fi +if wasm_require_target_aware_exec_authority "$work/hybrid-exec.wasm"; then + echo "ERROR: hybrid target-aware/legacy exec artifact was accepted" >&2 + exit 1 +fi + real_objdump="$(command -v wasm-objdump)" mkdir "$work/bin" missing_structural_tool="$work/bin/missing-wasm-fork-instrument" diff --git a/scripts/wasm-artifact-guards.sh b/scripts/wasm-artifact-guards.sh index bd381d7b6a..908f238f84 100644 --- a/scripts/wasm-artifact-guards.sh +++ b/scripts/wasm-artifact-guards.sh @@ -1293,6 +1293,49 @@ wasm_require_exports() { fi } +wasm_reject_exports() { + local path="${1:-}" + shift || true + local present=() + local name export_status decoder_failed=0 + for name in "$@"; do + export_status=0 + wasm_has_export "$path" "$name" || export_status=$? + case "$export_status" in + 0) present+=("$name") ;; + 1) ;; + *) decoder_failed=1 ;; + esac + done + if [ "$decoder_failed" -eq 1 ]; then + echo "ERROR: unable to inspect forbidden wasm exports: $path" >&2 + return 1 + fi + if [ ${#present[@]} -gt 0 ]; then + echo "ERROR: refusing wasm artifact with forbidden exports: $path" >&2 + printf ' forbidden: %s\n' "${present[*]}" >&2 + return 1 + fi +} + +wasm_require_target_aware_exec_authority() { + local path="${1:-}" + wasm_require_exports "$path" \ + kernel_exec_target_prepare \ + kernel_spawn_exec_target_prepare \ + kernel_exec_target_size \ + kernel_exec_target_read \ + kernel_exec_target_cancel \ + kernel_exec_commit \ + kernel_spawn_exec_commit && + wasm_reject_exports "$path" \ + kernel_exec_prepare \ + kernel_exec_setup \ + kernel_exec_setup_for_thread \ + kernel_execve \ + kernel_execveat +} + wasm_has_complete_fork_instrumentation() { local path="${1:-}" local inventory inventory_status=0 From 7c2a1b602ae5780a327a3a294d7fe9d0b8f4c4f1 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Tue, 11 Aug 2026 05:52:25 -0400 Subject: [PATCH 62/82] POSIX: Order spawn credentials before file actions --- abi/snapshot.json | 6 + crates/kernel/src/process.rs | 15 + crates/kernel/src/process_table.rs | 304 ++++++- crates/kernel/src/spawn.rs | 8 +- crates/kernel/src/syscalls.rs | 186 +++- crates/kernel/src/wasm_api.rs | 67 +- crates/shared/src/lib.rs | 1 + docs/abi-versioning.md | 30 +- docs/architecture.md | 70 +- docs/posix-status.md | 2 +- host/src/browser-kernel-worker-entry.ts | 24 +- host/src/exec-target.ts | 134 ++- host/src/generated/abi.ts | 1 + host/src/kernel-scratch.ts | 12 +- host/src/kernel-worker.ts | 540 +++++++++-- host/src/node-kernel-worker-entry.ts | 25 +- host/test/exec-state-tracking.test.ts | 123 ++- host/test/kernel-entry-context-audit.test.ts | 41 + host/test/kernel-late-channel.test.ts | 89 ++ host/test/kernel-scratch-contract.test.ts | 12 + host/test/prepared-exec-target.test.ts | 2 + host/test/spawn-blob-transport.test.ts | 200 ++++- host/test/spawn-credential-order.test.ts | 838 ++++++++++++++++++ host/test/spawn-host-parity.test.ts | 26 +- host/test/spawn-pid-authority.test.ts | 1 + .../support/kernel-entry-context-audit.ts | 25 +- .../support/kernel-export-failure-audit.ts | 1 + host/test/support/kernel-scratch-instance.ts | 4 + packages/registry/kernel/build-kernel.sh | 1 + packages/registry/program-packages.json | 96 +- run.sh | 1 + scripts/resolve-binary.bundle.mjs | 2 +- scripts/test-wasm-artifact-guards.sh | 2 + scripts/wasm-artifact-guards.sh | 1 + 34 files changed, 2607 insertions(+), 283 deletions(-) create mode 100644 host/test/spawn-credential-order.test.ts diff --git a/abi/snapshot.json b/abi/snapshot.json index 0f3edc95c2..bad5bbb544 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -1111,6 +1111,7 @@ "kernel_set_current_tid", "kernel_set_cwd", "kernel_shmid_ds_bytes", + "kernel_publish_spawn_child", "kernel_spawn_exec_commit", "kernel_spawn_exec_target_prepare", "kernel_spawn_process", @@ -2783,6 +2784,11 @@ "name": "kernel_pty_set_winsize", "signature": "(i32,i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_publish_spawn_child", + "signature": "(i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_push_argv", diff --git a/crates/kernel/src/process.rs b/crates/kernel/src/process.rs index ed7ad1e2ca..c7f426167b 100644 --- a/crates/kernel/src/process.rs +++ b/crates/kernel/src/process.rs @@ -745,6 +745,10 @@ pub struct Process { pub(crate) exec_generation: u64, /// Kernel-owned exact executable-object leases awaiting commit/cancel. pub(crate) prepared_exec_targets: PreparedExecLedger, + /// A `posix_spawn` child is a real signal target while its host launch is + /// pending, but it is not yet part of the parent's waitable child set. + /// Only the parent-bound spawn publication transaction may clear this. + pub(crate) spawn_publication_pending: bool, pub pgid: u32, pub sid: u32, /// True iff this process is the session leader of its session (i.e. the @@ -1065,6 +1069,7 @@ impl Process { secure_exec: false, exec_generation: 0, prepared_exec_targets: PreparedExecLedger::new(), + spawn_publication_pending: false, pgid: pid, sid: 0, is_session_leader: false, @@ -1196,6 +1201,16 @@ impl Process { self.credentials = credentials; } + /// Apply POSIX_SPAWN_RESETIDS to the inherited child record. + /// + /// Saved IDs and supplementary groups remain exactly as inherited. This + /// mutation is intentionally private to the kernel's pending-child setup; + /// ordinary credential syscalls have their own permission transitions. + pub(crate) fn reset_effective_ids_to_real(&mut self) { + self.credentials.euid = self.credentials.ruid; + self.credentials.egid = self.credentials.rgid; + } + pub(crate) fn configure_ids(&mut self, uid: Option, gid: Option) { let mut credentials = self.credentials.clone(); if let Some(uid) = uid { diff --git a/crates/kernel/src/process_table.rs b/crates/kernel/src/process_table.rs index 8f914d0fc6..657193c2f7 100644 --- a/crates/kernel/src/process_table.rs +++ b/crates/kernel/src/process_table.rs @@ -1222,11 +1222,14 @@ impl ProcessTable { } } - // Apply spawn attrs in POSIX order (SETSID → SETPGROUP → SETSIGMASK - // → SETSIGDEF). All operate on local Process state and are - // infallible; happens before file actions, before insertion. + // Apply spawn attrs in POSIX order (RESETIDS → SETSID → SETPGROUP + // → SETSIGMASK → SETSIGDEF). All operate on local Process state + // and are infallible; happens before file actions, before insertion. { use crate::spawn::attr_flags; + if attrs.flags & attr_flags::RESETIDS != 0 { + child.reset_effective_ids_to_real(); + } if attrs.flags & attr_flags::SETSID != 0 { child.sid = child.pid; child.pgid = child.pid; @@ -1262,6 +1265,11 @@ impl ProcessTable { // helper fork uses — this is the genuinely-shared concern. bump_inherited_resource_refcounts(parent_pid, &child)?; + // The child is a real kernel process and signal target, but the + // parent has not received a successful posix_spawn result yet. Wait + // selection must not consume it until the host completes the exact + // target launch transaction and publishes that result. + child.spawn_publication_pending = true; self.processes.insert(child_pid, child); // Apply file actions in forward order against the child. Any failure @@ -1359,28 +1367,9 @@ impl ProcessTable { } } - // POSIX exec semantics: after file_actions are applied, any fd that - // still has FD_CLOEXEC set is closed before the new program image - // runs. The dup2(N, N) self-dup pattern clears FD_CLOEXEC on the - // target fd specifically to RESCUE it from this closure (sortix - // basic/spawn/posix_spawn_file_actions_adddup2 exercises exactly - // this). Run after the action loop so file actions can rescue or - // clear individual fds before the sweep. - let (child, advisory_locks) = self - .process_and_advisory_locks(child_pid) - .ok_or(Errno::ESRCH)?; - let cloexec_fds: Vec = child - .fd_table - .iter() - .filter(|(_fd, e)| e.fd_flags & wasm_posix_shared::fd_flags::FD_CLOEXEC != 0) - .map(|(fd, _)| fd) - .collect(); - for fd in cloexec_fds { - // POSIX: close errors here are silently ignored — same policy - // as the FileAction::Close handler above. - let _ = crate::syscalls::sys_close_with_locks(child, advisory_locks, host, fd); - } - + // FD_CLOEXEC closure is part of the exact prepared-target commit, not + // child construction. Keeping it there lets final-target preparation + // observe the descriptor state produced by the one file-action pass. Ok(()) } @@ -1508,9 +1497,56 @@ impl ProcessTable { .collect() } - /// Return the recorded parent pid for a process. + /// Return the host-visible parent pid for a process. An unpublished spawn + /// child is hidden so an early signal-exit finalizer cannot emit SIGCHLD + /// before the parent receives the successful spawn result. pub fn parent_pid(&self, pid: u32) -> Option { - self.processes.get(&pid).map(|proc| proc.ppid) + self.processes + .get(&pid) + .filter(|proc| !proc.spawn_publication_pending) + .map(|proc| proc.ppid) + } + + /// Publish one pending spawn child to its exact parent. + /// + /// The result deliberately mirrors `kernel_get_process_exit_signal`: `-1` + /// means the child is live, `0` is a normal zombie, and a positive value + /// is the terminating signal. The caller can therefore publish the spawn + /// result before waking waiters without conflating a live child with the + /// `-ESRCH` absence error. + pub fn publish_spawn_child( + &mut self, + parent_pid: u32, + child_pid: u32, + ) -> Result { + let parent_accepts_publication = self + .processes + .get(&parent_pid) + .is_some_and(|parent| { + matches!(parent.state, ProcessState::Running | ProcessState::Stopped) + }); + let child = self.processes.get_mut(&child_pid).ok_or(Errno::ESRCH)?; + if child.ppid != parent_pid { + return Err(Errno::ESRCH); + } + if !child.spawn_publication_pending { + return Err(Errno::EINVAL); + } + if child.state == ProcessState::Limbo { + return Err(Errno::ESRCH); + } + if !parent_accepts_publication { + // The exact unpublished child still exists and must be removed by + // the host's ordinary rollback seam. Distinguish that ownership + // from ESRCH, which means there is no exact child left to remove. + return Err(Errno::ECHILD); + } + child.spawn_publication_pending = false; + Ok(if child.state == ProcessState::Exited { + child.exit_signal as i32 + } else { + -1 + }) } /// Pick the process/fd that should receive the next host-bridged TCP @@ -1608,6 +1644,13 @@ impl ProcessTable { } saw_matching_child = true; + // The pending child is real enough to keep a blocking waiter + // parked, but neither WNOHANG nor a queued waiter may observe or + // consume its status before posix_spawn publishes the PID/result. + if child.spawn_publication_pending { + continue; + } + let Some(event) = child.wait_event else { continue; }; @@ -1631,7 +1674,11 @@ impl ProcessTable { pub fn is_exited_child_of(&self, parent_pid: u32, child_pid: u32) -> bool { self.processes .get(&child_pid) - .map(|child| child.ppid == parent_pid && child.state == ProcessState::Exited) + .map(|child| { + child.ppid == parent_pid + && !child.spawn_publication_pending + && child.state == ProcessState::Exited + }) .unwrap_or(false) } @@ -1699,6 +1746,67 @@ mod wait_tests { assert_eq!(table.get(spawn_pid).unwrap().credentials(), &credentials); } + #[test] + fn spawn_resetids_changes_only_effective_ids_before_child_publication() { + use crate::credentials::Credentials; + use crate::process::test_host::NoopHost; + use crate::spawn::SpawnAttrs; + + let mut table = ProcessTable::new(); + let parent_pid = table.create_process().unwrap(); + let parent_credentials = Credentials { + ruid: 1000, + euid: 2000, + suid: 3000, + rgid: 4000, + egid: 5000, + sgid: 6000, + supplementary_groups: vec![7000, 8000], + }; + table + .get_mut(parent_pid) + .unwrap() + .install_credentials(parent_credentials.clone()); + + let attrs = SpawnAttrs { + flags: wasm_posix_shared::spawn_contract::ATTR_RESETIDS, + pgrp: 0, + sigdef: 0, + sigmask: 0, + }; + let mut host = NoopHost; + let child_pid = table + .spawn_child_for_caller( + parent_pid, + parent_pid, + &[b"/bin/child".as_slice()], + &[], + &[], + &attrs, + &mut host, + ) + .unwrap(); + + let child = table.get(child_pid).unwrap(); + assert_eq!( + ( + child.real_uid(), + child.effective_uid(), + child.saved_uid(), + child.real_gid(), + child.effective_gid(), + child.saved_gid(), + ), + (1000, 1000, 3000, 4000, 4000, 6000), + ); + assert_eq!(child.supplementary_groups(), &[7000, 8000]); + assert_eq!( + table.get(parent_pid).unwrap().credentials(), + &parent_credentials, + "spawn attributes must never mutate the parent credential record", + ); + } + #[test] fn task_ids_are_shared_by_create_clone_fork_and_spawn() { use crate::process::test_host::NoopHost; @@ -3579,6 +3687,146 @@ mod tests { assert!(table.get(child_pid).unwrap().wait_event.is_some()); } + #[test] + fn pending_spawn_exit_is_hidden_until_one_parent_bound_publication() { + use crate::process::test_host::NoopHost; + use crate::spawn::SpawnAttrs; + use wasm_posix_shared::signal::SIGTERM; + use wasm_posix_shared::wait::{CLD_KILLED, EVENT_EXITED}; + + let mut table = ProcessTable::new(); + let parent_pid = table.create_process().unwrap(); + let mut host = NoopHost; + let child_pid = table + .spawn_child_for_caller( + parent_pid, + parent_pid, + &[b"/bin/child".as_slice()], + &[], + &[], + &SpawnAttrs::empty(), + &mut host, + ) + .unwrap(); + + assert!(table.get_mut(child_pid).unwrap().record_signal_exit(SIGTERM)); + assert_eq!( + table.poll_wait_event(parent_pid, -1, EVENT_EXITED, 0), + Ok(None), + "a sibling waiter may park but cannot consume an unpublished spawn child", + ); + assert!(!table.is_exited_child_of(parent_pid, child_pid)); + + assert_eq!( + table.publish_spawn_child(parent_pid, child_pid), + Ok(SIGTERM as i32), + ); + let (waited_pid, event) = table + .poll_wait_event(parent_pid, -1, EVENT_EXITED, 0) + .unwrap() + .unwrap(); + assert_eq!(waited_pid, child_pid); + assert_eq!(event.wait_status, SIGTERM as i32); + assert_eq!(event.si_code, CLD_KILLED); + assert!(table.is_exited_child_of(parent_pid, child_pid)); + assert_eq!( + table.publish_spawn_child(parent_pid, child_pid), + Err(Errno::EINVAL), + "publication is a one-shot parent/child transaction", + ); + } + + #[test] + fn failed_pending_spawn_removal_releases_the_hidden_wait_relationship() { + use crate::process::test_host::NoopHost; + use crate::spawn::SpawnAttrs; + use wasm_posix_shared::wait::EVENT_EXITED; + + let mut table = ProcessTable::new(); + let parent_pid = table.create_process().unwrap(); + let mut host = NoopHost; + let child_pid = table + .spawn_child_for_caller( + parent_pid, + parent_pid, + &[], + &[], + &[], + &SpawnAttrs::empty(), + &mut host, + ) + .unwrap(); + + assert_eq!( + table.poll_wait_event(parent_pid, -1, EVENT_EXITED, 0), + Ok(None), + ); + assert!(table.remove_process(child_pid).is_some()); + assert_eq!( + table.poll_wait_event(parent_pid, -1, EVENT_EXITED, 0), + Err(Errno::ECHILD), + ); + } + + #[test] + fn pending_spawn_with_absent_parent_remains_owned_for_one_rollback() { + use crate::process::test_host::NoopHost; + use crate::spawn::SpawnAttrs; + + let mut table = ProcessTable::new(); + let parent_pid = table.create_process().unwrap(); + let mut host = NoopHost; + let child_pid = table + .spawn_child_for_caller( + parent_pid, + parent_pid, + &[], + &[], + &[], + &SpawnAttrs::empty(), + &mut host, + ) + .unwrap(); + + assert!(table.remove_process(parent_pid).is_some()); + assert_eq!( + table.publish_spawn_child(parent_pid, child_pid), + Err(Errno::ECHILD), + "ECHILD tells the host that the exact unpublished child still needs rollback", + ); + assert!(table.remove_process(child_pid).is_some()); + assert!(table.get(child_pid).is_none()); + } + + #[test] + fn pending_spawn_with_exited_parent_remains_owned_for_one_rollback() { + use crate::process::test_host::NoopHost; + use crate::spawn::SpawnAttrs; + + let mut table = ProcessTable::new(); + let parent_pid = table.create_process().unwrap(); + let mut host = NoopHost; + let child_pid = table + .spawn_child_for_caller( + parent_pid, + parent_pid, + &[], + &[], + &[], + &SpawnAttrs::empty(), + &mut host, + ) + .unwrap(); + + assert!(table.get_mut(parent_pid).unwrap().record_normal_exit(0)); + assert_eq!( + table.publish_spawn_child(parent_pid, child_pid), + Err(Errno::ECHILD), + "an exited parent cannot receive the pending spawn result", + ); + assert!(table.remove_process(child_pid).is_some()); + } + #[test] fn poll_wait_event_nonmatching_mask_preserves_latest_record() { use wasm_posix_shared::signal::SIGTSTP; diff --git a/crates/kernel/src/spawn.rs b/crates/kernel/src/spawn.rs index 1df8212390..6b514d896a 100644 --- a/crates/kernel/src/spawn.rs +++ b/crates/kernel/src/spawn.rs @@ -206,8 +206,8 @@ pub fn parse_reserved_spawn_blob(token: i64, length: usize) -> Result i32 { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + match table.publish_spawn_child(parent_pid, child_pid) { + Ok(disposition) => disposition, + Err(error) => -(error as i32), + } +} + fn spawn_parsed_for_caller( parent_pid: u32, caller_tid: u32, @@ -1964,7 +1979,8 @@ pub extern "C" fn kernel_get_process_exit_signal(pid: u32) -> i32 { } } -/// Return the recorded parent pid for a process, or -ESRCH if absent. +/// Return the host-visible parent pid for a process, or -ESRCH if absent or +/// still hidden inside an unpublished spawn transaction. #[unsafe(no_mangle)] pub extern "C" fn kernel_get_parent_pid(pid: u32) -> i32 { let table = unsafe { &*PROCESS_TABLE.0.get() }; @@ -2724,52 +2740,6 @@ pub extern "C" fn kernel_dequeue_signal( } } -fn apply_pending_exec_fd_actions( - proc: &mut Process, - advisory_locks: &mut crate::lock::AdvisoryLockManager, - host: &mut dyn HostIO, -) -> Result<(), Errno> { - // Apply pending fork fd actions (from posix_spawn) before exec. - // These are dup2/close/open operations that rearrange descriptors (for - // example, a pipe write end onto fd 1) and must precede CLOEXEC removal. - let actions: alloc::vec::Vec<_> = proc.fork_fd_actions.drain(..).collect(); - for action in actions { - use crate::process::FdAction; - match action { - FdAction::Dup2 { old_fd, new_fd } => { - syscalls::sys_dup2_with_locks(proc, advisory_locks, host, old_fd, new_fd)?; - } - FdAction::Close { fd } => { - syscalls::sys_close_implicit_with_locks( - proc, - advisory_locks, - host, - fd, - )?; - } - FdAction::Open { - fd, - ref path, - flags, - mode, - } => { - let opened_fd = - syscalls::sys_open(proc, host, path, flags as u32, mode as u32)?; - if opened_fd != fd { - syscalls::sys_dup2_with_locks(proc, advisory_locks, host, opened_fd, fd)?; - let _ = syscalls::sys_close_implicit_with_locks( - proc, - advisory_locks, - host, - opened_fd, - ); - } - } - } - } - Ok(()) -} - fn checked_exec_path<'a>(path_ptr: usize, path_len: usize) -> Result<&'a [u8], Errno> { if path_len > wasm_posix_shared::platform_limits::PATH_MAX_BYTES { return Err(Errno::ENAMETOOLONG); @@ -2845,9 +2815,6 @@ pub extern "C" fn kernel_spawn_exec_target_prepare( _ => return -(Errno::ESRCH as i32), }; let mut host = WasmHostIO; - if let Err(error) = apply_pending_exec_fd_actions(child, advisory_locks, &mut host) { - return -(error as i32); - } let owner = crate::exec_target::PreparedExecOwner::Spawn { parent_pid, child_pid, diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 1547547861..1c736cbc7d 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -3027,6 +3027,7 @@ pub mod abi { "kernel_set_current_tid", "kernel_set_cwd", "kernel_shmid_ds_bytes", + "kernel_publish_spawn_child", "kernel_spawn_exec_commit", "kernel_spawn_exec_target_prepare", "kernel_spawn_process", diff --git a/docs/abi-versioning.md b/docs/abi-versioning.md index 91def96ef0..639c26d7f1 100644 --- a/docs/abi-versioning.md +++ b/docs/abi-versioning.md @@ -671,6 +671,27 @@ reentrant host operations cannot replace bytes being consumed. The previous pointer-returning `kernel_spawn_scratch_reserve` interface and fixed worst-case compatibility fallback are not part of ABI 43. +The same ABI 43 spawn transaction requires +`kernel_publish_spawn_child(parent_pid, child_pid)`. Rust marks a newly +reserved spawn child as unpublished, so wait selection cannot consume it while +the host performs asynchronous exact-target read, validation, compilation, +commit, and Worker launch. Publication verifies the exact parent/child pair, +clears that state once, and returns `-1` for a live child, zero for ordinary +exit, or the positive terminating signal. `-ESRCH` remains authoritative +child absence and is not a live sentinel; `-ECHILD` means the exact hidden +child remains owned but its bound parent is absent or has already exited, so +rollback must remove it. +The host publishes the successful spawn +result in the same serialized entry before waking queued waiters; failure uses +the existing exact removal path and also wakes them. That detached completion +does not depend on the parent mailbox registration remaining live, while every +parent-memory write still requires the exact active channel. No older export can own +that atomic boundary: target commit necessarily precedes a fallible Worker +launch, and process removal is the opposite, failure-only transition. This is +an additive structural change to the still-unpublished ABI 43 export set, so +the ABI snapshot and generated host manifest carry it without inventing ABI +44 or permitting a fallback. + ABI 43 requires `host_pread` and `host_pwrite` so positioned regular-file I/O keeps a signed 64-bit offset lossless and does not mutate a shared open-file-description cursor through seek emulation. It also requires the @@ -931,10 +952,11 @@ five action opcodes; musl's complete transported attribute byte; the spawn-only action count cap; and the derived 8,417,320-byte whole-blob ceiling. Rust, TypeScript, and C therefore consume the same numeric wire contract. Transporting all eight attribute bits is distinct from implementing -them: the kernel currently acts on `SETPGROUP`, `SETSIGDEF`, `SETSIGMASK`, and -`SETSID`, while `RESETIDS`, `SETSCHEDPARAM`, `SETSCHEDULER`, and `USEVFORK` -remain uninterpreted. The shared startup counts and spawn-only action/complete -wire caps are defensive representation limits, not new POSIX promises. +them: the kernel currently acts on `RESETIDS`, `SETPGROUP`, `SETSIGDEF`, +`SETSIGMASK`, and `SETSID`, while `SETSCHEDPARAM`, `SETSCHEDULER`, and +`USEVFORK` remain uninterpreted. The shared startup counts and spawn-only +action/complete wire caps are defensive representation limits, not new POSIX +promises. Channel scalar widths are likewise Rust-owned. The generator writes `host/src/generated/abi.ts` and diff --git a/docs/architecture.md b/docs/architecture.md index 1ca78655d0..87b24d78a0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,6 +86,7 @@ kernel_exec_target_read(owner_pid, opaque_target, offset_lo, offset_hi, dst_ptr, kernel_exec_target_cancel(owner_pid, opaque_target) → 0 | -errno kernel_exec_commit(pid, caller_tid, opaque_target) → 0 | -errno kernel_spawn_exec_commit(parent_pid, child_pid, opaque_target) → 0 | -errno +kernel_publish_spawn_child(parent_pid, child_pid) → disposition | -errno kernel_thread_exit(pid, tid) → 0 | -errno kernel_commit_process_exit(status) → committed_low_8_bits kernel_dequeue_signal(pid, tid, out_ptr, out_capacity) → 0 | signum | -errno @@ -1300,7 +1301,12 @@ caller now take. `docs/plans/2026-05-04-non-forking-posix-spawn-design.md` Section 1. 2. Host (`handleSpawn` in `kernel-worker.ts`) reads the blob from caller memory, validates argv + envp against the same 4 MiB `ARG_MAX` - contract as `execve`, and copies it to bounded kernel-owned scratch. + contract as `execve`, and performs a side-effect-free candidate lookup and + compilation. Shared trusted code immediately snapshots the resolver bytes + and compiles the candidate module from that exact isolated snapshot; a + separately callback-supplied module is ignored. That preflight prevents + failed PATH probes from creating a child, but it is never executable + authority. The host then copies the blob to bounded kernel-owned scratch. Each argv/environment entry also has the separate 64 KiB process-metadata transport limit described for `execve`; this implementation ceiling is not `ARG_MAX`. @@ -1336,9 +1342,9 @@ caller now take. `CHDIR`, and `FCHDIR` opcodes; musl's complete transported spawn-attribute byte; the shared argv and environment entry caps; 1,024 actions; and the complete ceiling. Transporting an attribute bit does not claim its - behavior is implemented: the kernel currently interprets only - `SETPGROUP`, `SETSIGDEF`, `SETSIGMASK`, and `SETSID`; `RESETIDS`, - `SETSCHEDPARAM`, `SETSCHEDULER`, and `USEVFORK` remain unimplemented. The + behavior is implemented: the kernel currently interprets `RESETIDS`, + `SETPGROUP`, `SETSIGDEF`, `SETSIGMASK`, and `SETSID`; `SETSCHEDPARAM`, + `SETSCHEDULER`, and `USEVFORK` remain unimplemented. The argv/environment count caps defend the admitted process representation and are not additional POSIX `ARG_MAX` promises. The action count remains a spawn-parser limit. @@ -1349,23 +1355,53 @@ caller now take. 4. `spawn_child_for_caller` allocates the child PID from the same global task-ID sequence used by top-level creation, fork, and clone, then consumes that opaque allocation token to build the child Process plus selective inheritance from the - parent (uid/gid/pgid/sid/cwd/umask/rlimits, the calling task's blocked + parent (the complete real/effective/saved uid/gid and supplementary-group + record, pgid/sid/cwd/umask/rlimits, the calling task's blocked signal mask, fd_table + ofd_table + sockets via the `bump_inherited_resource_refcounts` helper that - fork also uses), applies attrs in POSIX order (SETSID → SETPGROUP → - SETSIGMASK → SETSIGDEF), so `POSIX_SPAWN_SETSIGMASK` replaces the - inherited caller mask, then applies file actions in forward - order. Failure on any action rolls back via `remove_process`. -5. The kernel returns the allocated pid via `pid_out_ptr` in caller - memory. The host's `onSpawn` callback (Node: + fork also uses), applies attrs in POSIX order (RESETIDS → SETSID → + SETPGROUP → SETSIGMASK → SETSIGDEF), so `POSIX_SPAWN_RESETIDS` changes only + effective IDs to the inherited real IDs and `POSIX_SPAWN_SETSIGMASK` + replaces the inherited caller mask, then applies file actions once in + forward order. Failure on any action rolls back via `remove_process`. +5. In the resulting child CWD, descriptor, and credential state, the host asks + Rust to prepare an exact executable target. The host reads those retained + bytes and reuses only the module compiled from the isolated preflight + snapshot, and only when every byte is identical; otherwise it recompiles + the final bytes. The opaque child-bound token is + committed with `kernel_spawn_exec_commit`, which evaluates set-ID and + trusted-mount/nosuid policy and closes remaining `FD_CLOEXEC` descriptors. + Any prepare, read, policy, compile, or commit failure cancels the exact + target and removes the pending child and its host mirrors without replaying + file actions or mutating the parent. +6. Only after exact commit does the host's `onSpawn` callback (Node: `host/src/node-kernel-worker-entry.ts::handlePosixSpawn`; Browser: `host/src/browser-kernel-worker-entry.ts::handlePosixSpawn`) - receives the authoritative parent pid, resolves the program bytes, - instantiates a fresh Worker for the child, and publishes a parented - `proc_event` spawn notification. The host registers the Worker's memory and - channels against the Process the kernel already inserted; registration does - not create or select the child identity. Its initialization metadata carries - the same parent pid. + receive the target-derived bytes and module and instantiate a fresh Worker + for the child. Until that callback succeeds, the kernel marks the child as + an unpublished spawn transaction: it remains signalable and retains real + exit status, but sibling `waitpid()` calls cannot select or reap it. + Completion calls the parent-bound `kernel_publish_spawn_child` exactly once + in the same serialized kernel entry that publishes the spawn result, then + wakes queued waiters. A child that died during asynchronous target work is + therefore returned successfully to `posix_spawn()` and becomes a waitable + zombie only after its PID is published. Ordinary failure removes the hidden + child and wakes parked waiters to observe `ECHILD`. Existing target commit + cannot provide this seam because it runs before Worker launch, while + `kernel_remove_process` is failure-only; neither can atomically change wait + visibility and return the final disposition after host launch. + If the parent exits before publication, Rust returns `ECHILD` while retaining + exact ownership of the hidden child so the same rollback seam removes it + once; an absent child instead returns `ESRCH` and is never removed again. + The detached completion enters the serialized kernel directly rather than + through the parent's mailbox registration, so parent Worker teardown cannot + drop that final removal. Parent memory is written only after the completion + separately proves that the exact channel registration is still active. + The host registers the Worker's memory and channels against the Process the + kernel already inserted; registration does not create or select the child + identity. The kernel returns the allocated pid via `pid_out_ptr` only after + this launch and publication succeed. A parented `proc_event` spawn + notification remains a separate observer effect. PATH search lives in libc (`posix_spawnp.c`); the kernel never sees PATH-relative names. diff --git a/docs/posix-status.md b/docs/posix-status.md index 23a392513e..92b485a1a8 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -155,7 +155,7 @@ same final-OFD lifetime rules. | `execveat()` | Partial | SYS_EXECVEAT (386). Host-derived paths, including strings from `kernel_get_fd_path` or `kernel_get_dirfd_path`, are diagnostic-only and may be used as a lazy VFS materialization hint; they never authorize execution. The centralized kernel entry passes the original fd/path/flags to `kernel_exec_target_prepare`, then uses the owner-bound token with `kernel_exec_target_size` and bounded `kernel_exec_target_read`. The host validates and compiles the exact bytes under the current ABI/artifact policy and completes replacement-memory preflight. Precommit failure uses exactly one `kernel_exec_target_cancel`; success calls `kernel_exec_commit`, which revalidates the retained exact handle, bytes, metadata, and capability before its atomic in-place commit. `AT_EMPTY_PATH`, relative-dirfd, and absolute-path semantics are therefore resolved from authoritative process/VFS state rather than path getters or program maps. It otherwise has the same remaining `exec()` limitations. | | `fork()` (syscall) | Partial | Glue traps through channel IPC; the kernel copies process state, the host starts a child Worker, and `wasm-fork-instrument` replays the call stack so parent/child receive the POSIX return values. ABI 43 requires the activation-state-safe artifact capability before launch and validates the linked-frame, reference/exception recipe, mutable module-state, table-journal, and activation-catalog contracts. Unsafe ABI 42, malformed, or mixed-version artifacts fail before execution. Negative results replay to the caller without terminating the parent, including continuation-allocation failure before or during unwind. | | `vfork()` | Partial | ABI 43 gives vfork a distinct libc and host transaction mode, maps it to `SYS_VFORK`, and does not run `pthread_atfork` handlers. A separate child Worker aliases the parent's existing `Shared WebAssembly.Memory`; the launch constructs no child process Memory and copies no address-space bytes. The child has private syscall-channel, replay-prefix, reference-codec, loader, and continuation-control state plus an independent kernel Process record. Only the calling parent thread remains parked until successful exec commit or exact `_exit()`/signal/trap teardown; sibling pthreads remain runnable. Failed exec returns to the child and keeps the lifetime active. Nested fork/vfork, spawn, and pthread creation fail with `EAGAIN`. Inherited descriptor tables, cwd, credentials, and process groups remain independent, while each inherited OFD shares its mutable offset, status flags, and async owner. Node, Chromium, Firefox, and WebKit production paths cover these lifecycles. A fatal signal delivered while the child has no pending syscall cannot obtain an exact browser Worker-quiescence fence; Kandelo truthfully contains the complete shared address space instead of resuming the parent. This row remains Partial pending broad conformance, pristine upstream CRuby selection, and full Homebrew/RSS validation. | -| `posix_spawn()` | Partial | **Non-forking implementation** (this kernel's invention; no Linux equivalent). Glue issues `SYS_SPAWN` (500) with a marshalled blob (argv + envp + file actions + spawn attrs). Generated platform limits supply the advertised 4 MiB combined argv/environment `ARG_MAX`, 4,096-byte `PATH_MAX` including NUL, and defensive 4,096-entry caps for each process-startup vector; the separate generated wire contract aliases those counts and defines a 40-byte header, 28-byte action records, 1,024 actions, and an 8,417,320-byte complete transport ceiling. These representation caps are not additional POSIX limits. Independently, each argv/environment string must fit the current 64 KiB process-metadata transfer. That host implementation ceiling is separate from aggregate `ARG_MAX`. Child startup uses the same immutable query/exact-copy guest-mapping contract as `exec()`, so it cannot silently clamp counts or keep only 64/128 KiB prefixes. The host proves caller ranges, parsed limits, the selected kernel-owned allocation capacity, and the current kernel-memory range independently; fitting inside total kernel Wasm memory is not proof that the destination allocation owns those bytes. Ordinary blobs reuse channel scratch. Each larger blob begins a fresh exclusive reservation on a Rust-owned reusable high-water buffer, reads its pointer and capacity, copies under one synchronous lease, and commits with the matching opaque token. Begin and pointer/capacity queries are nonblocking; commit and cancellation wait on a no-host-import critical section. After every successful begin, the host cancels in a `finally` block, including setup and copy failures, so it returns with either a released unconsumed token or a definitive already-consumed/stale result. Overlapping or reentrant large-spawn attempts cannot replace live bytes. The host passes the calling TID to `kernel_spawn_process` or `kernel_spawn_reserved_process`; the Rust `ProcessTable` validates that task, allocates the child PID from the global task-ID sequence, and builds the child Process descriptor before `onSpawn` launches a fresh Worker. The child inherits the calling task's signal mask unless `POSIX_SPAWN_SETSIGMASK` replaces it. No fork, no `wpk_fork_*` rewind, no exec replay. Supports POSIX_SPAWN_SETSID / SETPGROUP / SETSIGMASK / SETSIGDEF and FDOP_OPEN / CLOSE / DUP2 / CHDIR / FCHDIR. SIG_IGN dispositions persist across the implicit exec; custom handlers reset to SIG_DFL (POSIX exec semantics). An inherited directory never aliases the parent's live host iterator: spawn preserves one shared next-record cookie and lazily reopens a child-owned iterator there. Inherited OFD offset, status, and owner state remain shared. Regression-guarded: `kernel_get_fork_count` exposes a per-process counter the test suite asserts is unchanged across SYS_SPAWN. See `docs/plans/2026-05-04-non-forking-posix-spawn-design.md`. | +| `posix_spawn()` | Partial | **Non-forking implementation** (this kernel's invention; no Linux equivalent). Glue issues `SYS_SPAWN` (500) with a marshalled blob (argv + envp + file actions + spawn attrs). Generated platform limits supply the advertised 4 MiB combined argv/environment `ARG_MAX`, 4,096-byte `PATH_MAX` including NUL, and defensive 4,096-entry caps for each process-startup vector; the separate generated wire contract aliases those counts and defines a 40-byte header, 28-byte action records, 1,024 actions, and an 8,417,320-byte complete transport ceiling. These representation caps are not additional POSIX limits. Independently, each argv/environment string must fit the current 64 KiB process-metadata transfer. That host implementation ceiling is separate from aggregate `ARG_MAX`. Child startup uses the same immutable query/exact-copy guest-mapping contract as `exec()`, so it cannot silently clamp counts or keep only 64/128 KiB prefixes. The host proves caller ranges, parsed limits, the selected kernel-owned allocation capacity, and the current kernel-memory range independently; fitting inside total kernel Wasm memory is not proof that the destination allocation owns those bytes. Ordinary blobs reuse channel scratch. Each larger blob begins a fresh exclusive reservation on a Rust-owned reusable high-water buffer, reads its pointer and capacity, copies under one synchronous lease, and commits with the matching opaque token. Begin and pointer/capacity queries are nonblocking; commit and cancellation wait on a no-host-import critical section. After every successful begin, the host cancels in a `finally` block, including setup and copy failures, so it returns with either a released unconsumed token or a definitive already-consumed/stale result. Overlapping or reentrant large-spawn attempts cannot replace live bytes. Shared trusted code snapshots candidate bytes immediately, compiles only that isolated snapshot, and ignores a separately supplied module; candidate lookup remains side-effect-free and cannot become launch authority. The Rust `ProcessTable` validates the calling TID, reserves the child PID, inherits the complete credential record, applies `POSIX_SPAWN_RESETIDS` before remaining attributes, and drains file actions once. In the resulting child CWD/fd/credential state, Rust retains an exact executable target; byte divergence from the isolated candidate snapshot triggers recompilation, and `kernel_spawn_exec_commit` evaluates set-ID/nosuid state and closes `FD_CLOEXEC` before `onSpawn` launches either host's Worker. Until successful launch publishes the PID, authoritative kernel state hides the pending child from `waitpid()` selection/reaping while retaining signal-exit status. The parent-bound publication operation makes a child killed during async target work visible as a real waitable zombie only after `posix_spawn()` returns success; ordinary failure removes the hidden child once and wakes parked waiters. Failure cancels the exact target and removes host mirrors without action replay or parent mutation. The child inherits the calling task's signal mask unless `POSIX_SPAWN_SETSIGMASK` replaces it. No fork, no `wpk_fork_*` rewind, no exec replay. Supports POSIX_SPAWN_RESETIDS / SETSID / SETPGROUP / SETSIGMASK / SETSIGDEF and FDOP_OPEN / CLOSE / DUP2 / CHDIR / FCHDIR. SIG_IGN dispositions persist across the implicit exec; custom handlers reset to SIG_DFL (POSIX exec semantics). An inherited directory never aliases the parent's live host iterator: spawn preserves one shared next-record cookie and lazily reopens a child-owned iterator there. Inherited OFD offset, status, and owner state remain shared. Regression-guarded: `kernel_get_fork_count` exposes a per-process counter the test suite asserts is unchanged across SYS_SPAWN. See `docs/plans/2026-05-04-non-forking-posix-spawn-design.md`. | | `posix_spawnp()` | Partial | PATH search lives in libc (`libc/musl-overlay/src/process/wasm32posix/posix_spawnp.c`); resolves the absolute path then delegates to `posix_spawn()`. Empty PATH entries are treated as `.` and EACCES is deferred per `__execvpe` policy. It otherwise inherits `posix_spawn()`'s status. | | `clone()` | Partial | Thread-style clone (CLONE_VM\|CLONE_THREAD) supported. The Rust `ProcessTable` allocates the TID from the same global task-ID sequence as every PID, and the host spawns a thread Worker sharing the parent's Memory. Normal pthread return, pthread_exit, and cancellation cleanup remain per-thread and wake join/clear-TID waiters; uncaught fatal Wasm traps in a pthread worker terminate the whole process with signal-style wait status. | | `personality()` | Stub | Returns 0 (PER_LINUX). | diff --git a/host/src/browser-kernel-worker-entry.ts b/host/src/browser-kernel-worker-entry.ts index 6b7e5cdb29..94b8c4a97a 100644 --- a/host/src/browser-kernel-worker-entry.ts +++ b/host/src/browser-kernel-worker-entry.ts @@ -2977,23 +2977,6 @@ async function handleExec( return { onCommitFailure, startAfterCommit }; } -/** - * Handle SYS_SPAWN (non-forking posix_spawn) on the browser host. - * - * The kernel has already constructed the child Process descriptor under - * `childPid` with attrs and file actions applied. This callback receives the - * preflight's compiled program, allocates a fresh Memory for the child, and - * attaches it to the Process the kernel already created, and spawns a Worker. - * - * Distinct from handleExec (which replaces the calling worker) and - * handleFork (which clones the parent's Memory): this always creates a - * fresh Memory and runs the new program from `_start`. - * - * Mirrors handlePosixSpawn in host/src/node-kernel-worker-entry.ts — - * per CLAUDE.md the two hosts must move in lockstep. - * - * Returns 0 on success, negative errno on failure. - */ /** * Pre-flight resolver — see node-kernel-worker-entry.ts:handlePosixSpawnResolve. * Browser-side equivalent: materialize the lazy file (async fetch via @@ -3009,9 +2992,10 @@ async function handlePosixSpawnResolve( } /** - * Launch a worker for a SYS_SPAWN child whose program has already been - * resolved and compiled by `handlePosixSpawnResolve`. Mirrors the Node - * entry's `handlePosixSpawn`. + * Launch a worker for a SYS_SPAWN child whose program is derived from the + * exact target already committed by the shared worker. Preflight is only a + * side-effect-free candidate; child-state divergence is resolved and compiled + * before this callback. Mirrors the Node entry's `handlePosixSpawn`. */ async function handlePosixSpawn( parentPid: number, diff --git a/host/src/exec-target.ts b/host/src/exec-target.ts index 5d16a04446..4eb86eb80b 100644 --- a/host/src/exec-target.ts +++ b/host/src/exec-target.ts @@ -183,7 +183,16 @@ export interface PreparedExecLaunchOptions { readonly materializePath: (diagnosticPath: string) => Promise; readonly prepareInitialTarget: () => number; readonly prepareInterpreterTarget: (interpreterPath: string) => number; - readonly commitTarget: (target: number, expectedSize: number) => number; + readonly commitTarget: ( + target: number, + expectedSize: number, + markTargetConsumed: () => void, + ) => number; + /** Side-effect-free candidate that may be reused only on exact byte identity. */ + readonly preflightCandidate?: Readonly<{ + targetBytes: ArrayBuffer; + targetModule: WebAssembly.Module; + }>; } function parseShebang(bytes: Uint8Array): { @@ -222,6 +231,84 @@ function exactArrayBuffer(bytes: Uint8Array): ArrayBuffer { return bytes.buffer as ArrayBuffer; } +function exactlyMatchesPreflightCandidate( + bytes: Uint8Array, + candidate: ArrayBuffer, +): boolean { + if (bytes.byteLength !== candidate.byteLength) return false; + const candidateBytes = new Uint8Array(candidate); + for (let index = 0; index < bytes.byteLength; index += 1) { + if (bytes[index] !== candidateBytes[index]) return false; + } + return true; +} + +/** + * Snapshot and compile one side-effect-free spawn candidate before the child + * exists. The resolver's separately supplied module is intentionally absent: + * only a module compiled here from this isolated byte snapshot may be reused + * when the authoritative final target has exact byte identity. + */ +export async function compileSpawnCandidateSnapshot( + programBytes: ArrayBuffer, + expectedAbi: number, +): Promise> { + let snapshot: Uint8Array; + try { + const source = new Uint8Array(programBytes); + if (source.byteLength > MAX_REPORTABLE_TRANSFER_BYTES) { + throw new PreparedExecTargetError( + "spawn candidate exceeds the program-size limit", + EFBIG, + ); + } + // Copy before the first await. A resolver retains no mutable authority + // over the candidate compared or launched by the shared worker. + snapshot = source.slice(); + } catch (cause) { + if (cause instanceof PreparedExecTargetError) throw cause; + throw new PreparedExecTargetError( + "spawn candidate bytes are unavailable", + ENOEXEC, + ); + } + + const targetBytes = exactArrayBuffer(snapshot); + if (!isWasmModuleBytes(targetBytes)) { + throw new PreparedExecTargetError( + "spawn candidate is not a WebAssembly module", + ENOEXEC, + ); + } + const targetAbi = extractAbiVersion(targetBytes); + if ( + describeWasmArtifactPolicyFailures(targetBytes, { expectedAbi }).length > 0 + || (targetAbi !== null && targetAbi !== expectedAbi) + ) { + throw new PreparedExecTargetError( + "spawn candidate violates the artifact ABI policy", + ENOEXEC, + ); + } + + let targetModule: WebAssembly.Module; + try { + targetModule = await WebAssembly.compile(targetBytes); + } catch (cause) { + if (cause instanceof WebAssembly.CompileError) { + throw new PreparedExecTargetError( + "spawn candidate failed WebAssembly compilation", + ENOEXEC, + ); + } + throw cause; + } + return { targetBytes, targetModule }; +} + export async function launchPreparedExecTarget( options: PreparedExecLaunchOptions, callback: ExecLaunchCallback, @@ -304,17 +391,25 @@ export async function launchPreparedExecTarget( ); } - let targetModule: WebAssembly.Module; - try { - targetModule = await WebAssembly.compile(targetBytes); - } catch (cause) { - if (cause instanceof WebAssembly.CompileError) { - throw new PreparedExecTargetError( - "prepared exec target failed WebAssembly compilation", - ENOEXEC, - ); + let targetModule = options.preflightCandidate + && exactlyMatchesPreflightCandidate( + bytes, + options.preflightCandidate.targetBytes, + ) + ? options.preflightCandidate.targetModule + : undefined; + if (targetModule === undefined) { + try { + targetModule = await WebAssembly.compile(targetBytes); + } catch (cause) { + if (cause instanceof WebAssembly.CompileError) { + throw new PreparedExecTargetError( + "prepared exec target failed WebAssembly compilation", + ENOEXEC, + ); + } + throw cause; } - throw cause; } const request: PreparedExecLaunchRequest = { @@ -335,10 +430,23 @@ export async function launchPreparedExecTarget( // The opaque token never entered the async callback. The shared launcher // alone owns this no-yield commit edge and invokes the postcommit action // immediately after Rust consumes the token. - targetLive = false; let commitResult: number; try { - commitResult = options.commitTarget(target, targetBytes.byteLength); + commitResult = options.commitTarget( + target, + targetBytes.byteLength, + () => { + // The production wrapper calls this immediately before the raw + // commit/cancel export. A throw before that edge leaves the token + // cancellable; a host-import throw after it has uncertain/consumed + // Rust ownership and must never retry the token. + targetLive = false; + }, + ); + // Every numeric kernel result has settled the exact token, including a + // rejected commit. Test doubles may omit the marker because they cannot + // throw from inside Rust after taking the target. + targetLive = false; } catch (cause) { decision.onCommitFailure(); throw cause; diff --git a/host/src/generated/abi.ts b/host/src/generated/abi.ts index c471cab031..aec5f45b26 100644 --- a/host/src/generated/abi.ts +++ b/host/src/generated/abi.ts @@ -696,6 +696,7 @@ export const HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS = [ "kernel_set_current_tid", "kernel_set_cwd", "kernel_shmid_ds_bytes", + "kernel_publish_spawn_child", "kernel_spawn_exec_commit", "kernel_spawn_exec_target_prepare", "kernel_spawn_process", diff --git a/host/src/kernel-scratch.ts b/host/src/kernel-scratch.ts index b7a78bfb73..536d7ae67b 100644 --- a/host/src/kernel-scratch.ts +++ b/host/src/kernel-scratch.ts @@ -119,10 +119,11 @@ const typedArrayByteLength = intrinsicObjectGetOwnPropertyDescriptor( * `kernel_spawn_process` parses the complete blob into owned Rust values * before it enters process-table or host work; and * `kernel_process_metadata_stage` copies one complete entry into a token-owned - * Rust vector before returning. The transfer execute export names no raw - * pointer, but its token authorizes Rust to borrow the allocation represented - * by this exact lease. Adding a name requires the same lifetime review and a - * pointer-position update below. + * Rust vector before returning; both executable-target prepare exports copy + * the path before returning. The transfer execute export names no raw pointer, + * but its token authorizes Rust to borrow the allocation represented by this + * exact lease. Adding a name requires the same lifetime review and a pointer- + * position update below. */ /** @internal Exported only for the Rust/host semantic-role drift contract. */ export const KERNEL_SCRATCH_EXPORT_NAMES = intrinsicObjectFreeze([ @@ -158,6 +159,7 @@ export const KERNEL_SCRATCH_EXPORT_NAMES = intrinsicObjectFreeze([ "kernel_set_cwd", "kernel_setsockopt", "kernel_socketpair", + "kernel_spawn_exec_target_prepare", "kernel_spawn_process", "kernel_take_process_timer_cleanup", "kernel_tcgetattr", @@ -248,6 +250,7 @@ export function kernelScratchRequiredPointerArguments( case "kernel_pipe_read": case "kernel_pipe_write": case "kernel_pick_tcp_listener_target": + case "kernel_spawn_exec_target_prepare": case "kernel_spawn_process": case "kernel_tcsetattr": return REQUIRED_POINTER_2; @@ -330,6 +333,7 @@ function isKernelScratchExportName( case "kernel_set_cwd": case "kernel_setsockopt": case "kernel_socketpair": + case "kernel_spawn_exec_target_prepare": case "kernel_spawn_process": case "kernel_take_process_timer_cleanup": case "kernel_tcgetattr": diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index cb72c9387c..d32e4dd544 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -69,6 +69,7 @@ import { type HostOwnedProcessReapResult, } from "./host-owned-process-reap"; import { + compileSpawnCandidateSnapshot, launchPreparedExecTarget, PreparedExecTargetError, type ExecLaunchCallback, @@ -429,6 +430,7 @@ const FORK_BUF_SIZE = FORK_SAVE_BUFFER_SIZE; /** Errno values */ const E2BIG = 7; const ESRCH = 3; +const ECHILD = 10; const EAGAIN = 11; const EACCES = 13; const EBADF = 9; @@ -2318,13 +2320,13 @@ export interface CentralizedKernelCallbacks { onResolveSpawn?: (path: string, argv: string[]) => Promise; /** - * Launch a worker for the spawned child with the already-resolved bytes, - * compiled module, and argv from `onResolveSpawn`. The kernel has - * constructed the child Process descriptor under `childPid` with - * `parentPid` as its authoritative parent - * and applied file actions + attrs by the time this is called. The callback - * instantiates a fresh Worker and attaches its channels to the Process the - * kernel already created. + * Launch a worker for the spawned child with bytes and module derived from + * its exact committed target. `onResolveSpawn` is only a side-effect-free + * candidate; the shared worker re-resolves after attrs/file actions and + * recompiles whenever those bytes differ. The kernel has constructed and + * committed the child Process under `childPid` by the time this is called. + * The callback instantiates a fresh Worker and attaches its channels to that + * existing Process. * * Returns 0 on success, negative errno on failure. On non-zero return * the kernel descriptor is rolled back via `kernel_remove_process`. @@ -2663,6 +2665,8 @@ interface CentralizedKernelWorkerTestAuthority { readonly blobLen: number; readonly program: ResolvedSpawnProgram; readonly envp: string[]; + readonly authorityPath?: string; + readonly originalArgv?: string[]; }): void; replaceProcessRegistrationForLifecycleTest(options: { readonly pid: number; @@ -4658,7 +4662,9 @@ export class CentralizedKernelWorker { options.pidOutPtr, options.blobBytes, options.blobLen, + options.authorityPath ?? options.program.argv?.[0] ?? "", options.program, + options.originalArgv ?? options.program.argv, options.envp, entry, ); @@ -8214,6 +8220,52 @@ export class CentralizedKernelWorker { return result; } + /** Prepare one target through the pending spawn child's final namespace. */ + spawnExecTargetPrepare( + parentPid: number, + childPid: number, + path: string, + ): number { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + const encodedPath = new TextEncoder().encode(path); + if (encodedPath.byteLength > POSIX_PATH_MAX_BYTES) return -ENAMETOOLONG; + const region = this.#requireMainScratchRegion(); + if (encodedPath.byteLength > region.capacity) return -ENAMETOOLONG; + let result = -EIO; + let completed = false; + const deferred = this.#runOrDeferKernelEntry( + `kernel spawn exec target prepare child=${childPid}`, + (entry) => { + const previousPid = this.currentHandlePid; + this.currentHandlePid = childPid; + try { + result = region.withLease((lease) => { + lease.copyFrom(encodedPath); + return this.#invokeEntryScratchExport( + entry, + lease, + "kernel_spawn_exec_target_prepare", + [ + parentPid, + childPid, + lease.exportPointer(0, encodedPath.byteLength), + encodedPath.byteLength, + ], + ); + }); + completed = true; + } finally { + this.currentHandlePid = previousPid; + } + return undefined; + }, + ); + if (deferred || !completed) { + throw new KernelReentrantEntryError("kernel spawn exec target prepare"); + } + return result; + } + execTargetSize(ownerPid: number, target: number): bigint { if (this.#kernelFatalError !== null) throw this.#kernelFatalError; let result = -EIO as number | bigint; @@ -8355,6 +8407,7 @@ export class CentralizedKernelWorker { callerTid: number, target: number, expectedSize?: number, + markTargetConsumed: () => void = () => {}, ): number { if (this.#kernelFatalError !== null) throw this.#kernelFatalError; if (this.#kernelEntryGate.shouldDeferVoidIngress) { @@ -8406,6 +8459,7 @@ export class CentralizedKernelWorker { ); return undefined; } + markTargetConsumed(); const cancelled = cancel(pid, target); result = currentSize < 0n ? Number(currentSize) @@ -8414,6 +8468,7 @@ export class CentralizedKernelWorker { } } if (leaseSizeMatches) { + markTargetConsumed(); result = commit(pid, callerTid, target); completed = true; if (result === 0) { @@ -8450,6 +8505,106 @@ export class CentralizedKernelWorker { return result; } + /** Commit one exact target for a child that has not launched yet. */ + kernelSpawnExecCommit( + parentPid: number, + childPid: number, + target: number, + expectedSize?: number, + markTargetConsumed: () => void = () => {}, + ): number { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError("kernel spawn exec commit"); + } + let result = 0; + let completed = false; + let missingExportError: Error | undefined; + const deferred = this.#runOrDeferKernelEntry( + `kernel spawn exec commit child=${childPid} target=${target}`, + (entry) => { + const commit = this.#kernelInstanceForEntry(entry).exports + .kernel_spawn_exec_commit as + ((parentPid: number, childPid: number, target: number) => number) + | undefined; + if (!commit) { + missingExportError = new Error( + "Kernel missing required kernel_spawn_exec_commit export", + ); + return undefined; + } + const previousPid = this.currentHandlePid; + this.currentHandlePid = childPid; + let prunePlan: ExecFdMirrorPrunePlan | null = null; + try { + const listenerWakeSnapshot = + this.#snapshotExecTcpListenerWakeIdsWithinKernelEntry( + childPid, + entry, + ); + let leaseSizeMatches = true; + if (expectedSize !== undefined) { + const size = this.#kernelInstanceForEntry(entry).exports + .kernel_exec_target_size as + ((ownerPid: number, target: number) => bigint) | undefined; + if (!size) { + missingExportError = new Error( + "Kernel missing required kernel_exec_target_size export", + ); + return undefined; + } + const currentSize = size(childPid, target); + if (currentSize !== BigInt(expectedSize)) { + leaseSizeMatches = false; + const cancel = this.#kernelInstanceForEntry(entry).exports + .kernel_exec_target_cancel as + ((ownerPid: number, target: number) => number) | undefined; + if (!cancel) { + missingExportError = new Error( + "Kernel missing required kernel_exec_target_cancel export", + ); + return undefined; + } + markTargetConsumed(); + const cancelled = cancel(childPid, target); + result = currentSize < 0n + ? Number(currentSize) + : cancelled < 0 ? cancelled : -EIO; + completed = true; + } + } + if (leaseSizeMatches) { + markTargetConsumed(); + result = commit(parentPid, childPid, target); + completed = true; + if (result === 0) { + prunePlan = this.#prepareExecFdMirrorPruneWithinKernelEntry( + childPid, + listenerWakeSnapshot, + entry, + ); + } + } + } finally { + this.currentHandlePid = previousPid; + } + this.#drainAndProcessWakeupEventsWithinKernelEntry(entry); + if (prunePlan !== null) { + entry.deferProtocolEffect(() => { + this.#publishExecFdMirrorPrune(prunePlan); + return undefined; + }); + } + return undefined; + }, + ); + if (missingExportError !== undefined) throw missingExportError; + if (deferred || !completed) { + throw new KernelReentrantEntryError("kernel spawn exec commit"); + } + return result; + } + /** Snapshot stable accept-queue identities before CLOEXEC closes aliases. */ #snapshotExecTcpListenerWakeIdsWithinKernelEntry( pid: number, @@ -21822,6 +21977,10 @@ export class CentralizedKernelWorker { ); return; } + // Preflight resolvers may follow a shebang and return rewritten argv. + // Preserve the blob's original vector so the authoritative child-state + // target parser performs that rewrite exactly once. + const originalArgv = [...argv]; // ── PRE-FLIGHT: resolve and compile BEFORE calling the kernel ── // POSIX requires file_actions to run "exactly once." `posix_spawnp`'s @@ -21833,16 +21992,38 @@ export class CentralizedKernelWorker { // program actually exists and compiles. const resolveSpawnProgram = async (): Promise => { const resolved = await this.callbacks.onResolveSpawn!(path, argv); - if (resolved || rawPath === path || !rawPath || rawPath.startsWith("/")) { - return resolved; + let selected = resolved; + if ( + !selected + && rawPath !== path + && rawPath + && !rawPath.startsWith("/") + ) { + // SYS_SPAWN is also used by posix_spawnp-style PATH probes. Those + // callers may hand us a relative executable name that exists only in + // the host execPrograms map, not in the kernel VFS at CWD/name. + // Keep the CWD-resolved path as the primary POSIX exec target, but + // fall back to the original token for host-side program maps. + selected = await this.callbacks.onResolveSpawn!(rawPath, argv); } + if (!selected || isSpawnResolveError(selected)) return selected; - // SYS_SPAWN is also used by posix_spawnp-style PATH probes. Those - // callers may hand us a relative executable name that exists only in - // the host execPrograms map, not in the kernel VFS at CWD/name. - // Keep the CWD-resolved path as the primary POSIX exec target, but - // fall back to the original token for host-side program maps. - return this.callbacks.onResolveSpawn!(rawPath, argv); + try { + const candidate = await compileSpawnCandidateSnapshot( + selected.programBytes, + this.getKernelAbiVersion(), + ); + return { + programBytes: candidate.targetBytes, + programModule: candidate.targetModule, + argv: [...selected.argv], + }; + } catch (cause) { + if (cause instanceof PreparedExecTargetError) { + return { errno: cause.errno }; + } + throw cause; + } }; entry.deferProtocolTransactionStart(() => { @@ -21889,7 +22070,9 @@ export class CentralizedKernelWorker { checkedPidOutPtr, blobBytes, blobLen, + rawPath, resolved, + originalArgv, envp, resolutionEntry, ); @@ -22054,6 +22237,122 @@ export class CentralizedKernelWorker { ); } + #completeSuccessfulSpawnWithinKernelEntry( + channel: ChannelInfo, + origArgs: number[], + parentPid: number, + childPid: number, + pidOutPtr: number, + entry: KernelWorkerEntryContext, + ): void { + if (!this.#isAsyncChannelProcessActiveWithinKernelEntry(channel, entry)) { + if (this.#getProcessExitSignal(childPid, entry) === -ESRCH) { + // The detached completion still runs after parent transport teardown. + // If Rust also says the exact child is absent, only host mirrors may + // remain; issuing a second numeric removal would invent ownership. + this.#rollbackChildHostRegistrationWithinKernelEntry(childPid, entry); + this.wakeWaitingParent(parentPid, entry); + return; + } + // The parent has no live channel on which the spawn PID/result can be + // published. Retire the still-hidden child through the ordinary exact + // rollback seam instead of leaking an unreachable Process/PID. + this.#rollbackSpawnWithinKernelEntry( + channel, + origArgs, + parentPid, + childPid, + ECHILD, + undefined, + entry, + ); + return; + } + const publishSpawnChild = this.#kernelInstanceForEntry(entry).exports + .kernel_publish_spawn_child as + ((parentPid: number, childPid: number) => number) | undefined; + if (typeof publishSpawnChild !== "function") { + this.#terminateForKernelProtocolFailureWithinKernelEntry( + channel, + "kernel spawn publication export is unavailable", + entry, + ); + return; + } + const disposition = publishSpawnChild(parentPid, childPid); + if (disposition === -ESRCH) { + // The exact child is already absent, so a second numeric removal would + // target no transaction and could conceal a double-reap. Retire any + // surviving host registration and report the authoritative absence. + this.#rollbackChildHostRegistrationWithinKernelEntry(childPid, entry); + this.#completeSpawnWithinKernelEntry( + channel, + origArgs, + -1, + ESRCH, + entry, + ); + this.wakeWaitingParent(parentPid, entry); + return; + } + if (disposition === -ECHILD) { + // Rust still owns the exact unpublished child, but its bound parent no + // longer exists. The ordinary rollback consumes that remaining child; + // ESRCH above deliberately skips numeric removal because it means the + // child itself is already absent. + this.#rollbackSpawnWithinKernelEntry( + channel, + origArgs, + parentPid, + childPid, + ECHILD, + undefined, + entry, + ); + return; + } + if (!Number.isSafeInteger(disposition) || disposition < -1) { + this.#terminateForKernelProtocolFailureWithinKernelEntry( + channel, + `kernel rejected spawn child ${childPid} publication: ${disposition}`, + entry, + ); + return; + } + if (pidOutPtr !== 0) { + new DataView(channel.memory.buffer).setInt32(pidOutPtr, childPid, true); + } + // Publish the spawn result before a waiter can consume the newly visible + // status. Both writes occur within this serialized kernel entry. + this.#completeSpawnWithinKernelEntry(channel, origArgs, 0, 0, entry); + if (disposition >= 0) { + this.notifyParentOfExitedProcess(childPid, entry); + } else { + // A waiter may have parked when this pending child was the only match. + // Live publication has no status to complete, but re-polling preserves + // that queued wait under the now-public child relationship. + this.wakeWaitingParent(parentPid, entry); + } + } + + /** + * Complete one detached pending-child transaction even if the parent Worker + * and its mailbox registration disappeared while target work was awaiting. + * Parent memory is consulted only later by the liveness-gated publication + * seam; this ingress exists solely so exact child cleanup cannot be dropped. + */ + #runOrDeferPendingSpawnCompletionKernelEntry( + childPid: number, + label: string, + operation: (entry: KernelWorkerEntryContext) => undefined, + ): void { + this.#kernelEntryGate.runOrDeferVoidIngress( + `${label} child=${childPid}`, + (scope, effects) => + this.#runKernelEntryOperation(scope, effects, operation), + ); + } + #rollbackSpawnWithinKernelEntry( channel: ChannelInfo, origArgs: number[], @@ -22096,6 +22395,9 @@ export class CentralizedKernelWorker { ); return; } + // A sibling wait may have parked because the unpublished child was a real + // matching relationship. Removal makes that wait resolve to ECHILD. + this.wakeWaitingParent(parentPid, entry); if ( this.#isAsyncChannelProcessActiveWithinKernelEntry(channel, entry) ) { @@ -22117,7 +22419,9 @@ export class CentralizedKernelWorker { pidOutPtr: number, blobBytes: Uint8Array, blobLen: number, + authorityPath: string, program: ResolvedSpawnProgram, + originalArgv: string[], envp: string[], entry: KernelWorkerEntryContext, ): void { @@ -22324,6 +22628,32 @@ export class CentralizedKernelWorker { } const childPid = result >>> 0; + // The preflight candidate is deliberately not pathname authority. Resolve + // diagnostics again from the resulting child CWD after RESETIDS, attrs, + // and the one file-action pass; Rust receives the original token below and + // performs the authoritative lookup in that same child state. + let finalDiagnosticPath = authorityPath; + if (finalDiagnosticPath && !finalDiagnosticPath.startsWith("/")) { + const resolvedPath = this.resolveExecPathAgainstCwd( + childPid, + finalDiagnosticPath, + entry, + ); + if (resolvedPath.kind === "error") { + this.#rollbackSpawnWithinKernelEntry( + channel, + origArgs, + parentPid, + childPid, + resolvedPath.errno, + undefined, + entry, + ); + return; + } + finalDiagnosticPath = resolvedPath.value; + } + // posix_spawn clones listener sockets after applying fd actions. Install // those mirrors before async Worker launch so parent exec cannot close the // shared backend. Epoll backing tables are not yet cloned by spawn_child, @@ -22344,21 +22674,45 @@ export class CentralizedKernelWorker { return; } - // Launching the Worker starts a host-owned asynchronous transaction after - // scope revocation. Its continuation retains only detached inputs and - // opens a new exact channel entry before consulting kernel liveness, - // rolling back, or publishing success. + // Prepared-target materialization, exact-byte validation, compilation, + // commit, and Worker launch form one detached pending-child transaction. + // Its continuation opens a new exact channel entry before consulting + // kernel liveness, rolling back, or publishing success. entry.deferProtocolTransactionStart(() => { let launch: Promise; try { launch = this.#resolvePromise( - this.callbacks.onSpawn!(parentPid, childPid, program, envp), + this.#launchPreparedSpawn( + parentPid, + childPid, + authorityPath, + finalDiagnosticPath, + program, + originalArgv, + envp, + ), ); } catch (cause) { - this.#runOrDeferChannelKernelEntry( - channel, + this.#runOrDeferPendingSpawnCompletionKernelEntry( + childPid, "spawn launch failure", - (rollbackEntry) => { + (completionEntry) => { + const exitSignal = + this.#finalizePendingChildTerminationWithinKernelEntry( + childPid, + completionEntry, + ); + if (exitSignal > 0 || exitSignal < -1) { + this.#completeSuccessfulSpawnWithinKernelEntry( + channel, + origArgs, + parentPid, + childPid, + pidOutPtr, + completionEntry, + ); + return undefined; + } this.#rollbackSpawnWithinKernelEntry( channel, origArgs, @@ -22366,7 +22720,7 @@ export class CentralizedKernelWorker { childPid, EIO, cause, - rollbackEntry, + completionEntry, ); return undefined; }, @@ -22374,53 +22728,70 @@ export class CentralizedKernelWorker { return undefined; } this.#continuePromise(launch, (rc) => { - this.#runOrDeferChannelKernelEntry( - channel, + this.#runOrDeferPendingSpawnCompletionKernelEntry( + childPid, "spawn launch completion", (completionEntry) => { - if (rc < 0) { - this.#rollbackSpawnWithinKernelEntry( + const exitSignal = + this.#finalizePendingChildTerminationWithinKernelEntry( + childPid, + completionEntry, + ); + if (exitSignal < -1) { + this.#completeSuccessfulSpawnWithinKernelEntry( channel, origArgs, parentPid, childPid, - (-rc) >>> 0, - undefined, + pidOutPtr, completionEntry, ); return undefined; } - this.#finalizePendingChildTerminationWithinKernelEntry( - childPid, - completionEntry, - ); - if ( - !this.#isAsyncChannelProcessActiveWithinKernelEntry( + if (rc < 0 && exitSignal <= 0) { + this.#rollbackSpawnWithinKernelEntry( channel, + origArgs, + parentPid, + childPid, + (-rc) >>> 0, + undefined, completionEntry, - ) - ) { + ); return undefined; } - if (pidOutPtr !== 0) { - new DataView(channel.memory.buffer) - .setInt32(pidOutPtr, childPid, true); - } - this.#completeSpawnWithinKernelEntry( + this.#completeSuccessfulSpawnWithinKernelEntry( channel, origArgs, - 0, - 0, + parentPid, + childPid, + pidOutPtr, completionEntry, ); return undefined; }, ); }, (cause) => { - this.#runOrDeferChannelKernelEntry( - channel, + this.#runOrDeferPendingSpawnCompletionKernelEntry( + childPid, "spawn launch rejection", - (rollbackEntry) => { + (completionEntry) => { + const exitSignal = + this.#finalizePendingChildTerminationWithinKernelEntry( + childPid, + completionEntry, + ); + if (exitSignal > 0 || exitSignal < -1) { + this.#completeSuccessfulSpawnWithinKernelEntry( + channel, + origArgs, + parentPid, + childPid, + pidOutPtr, + completionEntry, + ); + return undefined; + } this.#rollbackSpawnWithinKernelEntry( channel, origArgs, @@ -22428,7 +22799,7 @@ export class CentralizedKernelWorker { childPid, EIO, cause, - rollbackEntry, + completionEntry, ); return undefined; }, @@ -22581,6 +22952,67 @@ export class CentralizedKernelWorker { ); } + async #launchPreparedSpawn( + parentPid: number, + childPid: number, + authorityPath: string, + diagnosticPath: string, + candidate: ResolvedSpawnProgram, + originalArgv: string[], + envp: string[], + ): Promise { + const callback = this.callbacks.onSpawn; + if (!callback) return -ENOSYS; + try { + return await launchPreparedExecTarget({ + kernel: this as PreparedExecKernel, + ownerPid: childPid, + pid: childPid, + callerTid: childPid, + diagnosticPath, + argv: originalArgv, + envp, + expectedAbi: this.getKernelAbiVersion(), + materializePath: async (path) => { + await this.io.preparePath?.(path); + }, + prepareInitialTarget: () => + this.spawnExecTargetPrepare(parentPid, childPid, authorityPath), + prepareInterpreterTarget: (interpreterPath) => + this.spawnExecTargetPrepare(parentPid, childPid, interpreterPath), + commitTarget: (target, expectedSize, markTargetConsumed) => + this.kernelSpawnExecCommit( + parentPid, + childPid, + target, + expectedSize, + markTargetConsumed, + ), + preflightCandidate: { + targetBytes: candidate.programBytes, + targetModule: candidate.programModule, + }, + }, async (request) => ({ + // onSpawn owns no replacement image before commit. If a future host + // adds staged resources, they must remain bounded to this hook. + onCommitFailure: () => {}, + startAfterCommit: () => callback( + parentPid, + childPid, + { + programBytes: request.targetBytes, + programModule: request.targetModule, + argv: request.argv, + }, + request.envp, + ), + })); + } catch (error) { + if (error instanceof PreparedExecTargetError) return -error.errno; + throw error; + } + } + async #launchPreparedExec( pid: number, callerTid: number, @@ -22622,8 +23054,14 @@ export class CentralizedKernelWorker { interpreterPath, 0, ), - commitTarget: (target, expectedSize) => - this.kernelExecCommit(pid, callerTid, target, expectedSize), + commitTarget: (target, expectedSize, markTargetConsumed) => + this.kernelExecCommit( + pid, + callerTid, + target, + expectedSize, + markTargetConsumed, + ), }, callback); } catch (error) { if (error instanceof PreparedExecTargetError) return -error.errno; diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts index 3eae7f583d..eeff148252 100644 --- a/host/src/node-kernel-worker-entry.ts +++ b/host/src/node-kernel-worker-entry.ts @@ -2634,21 +2634,6 @@ async function handleExec( return { onCommitFailure, startAfterCommit }; } -/** - * Handle SYS_SPAWN (non-forking posix_spawn). - * - * The kernel has already constructed the child Process descriptor in its - * ProcessTable under `childPid` (with attrs and file actions applied). - * This callback resolves the program bytes for `path`, allocates a fresh - * Memory for the child, attaches it to the existing kernel Process, and - * launches a Worker for it. - * - * Distinct from handleExec (which replaces the calling worker) and - * handleFork (which clones the parent's Memory): handlePosixSpawn always - * creates a fresh Memory and runs the new program from `_start`. - * - * Returns 0 on success, negative errno on failure (e.g. -ENOENT). - */ /** * Pre-flight resolver for SYS_SPAWN. Side-effect-free: looks up program * bytes for `path` through the spawn-only execPrograms/main-thread fallback, @@ -2669,11 +2654,11 @@ async function handlePosixSpawnResolve( } /** - * Launch a worker for a SYS_SPAWN child whose program has already been - * resolved and compiled by `handlePosixSpawnResolve`. The kernel has built - * the child Process descriptor + applied file actions by the time we get - * here, so this just allocates a Memory, registers the process, and spawns - * the worker. + * Launch a worker for a SYS_SPAWN child whose program is derived from the + * exact target already committed by the shared worker. The earlier resolver + * was only side-effect-free candidate preflight; a changed child CWD, fd + * table, or credential view selects and recompiles the final bytes before this + * callback. This phase only allocates Memory, registers, and launches. */ async function handlePosixSpawn( parentPid: number, diff --git a/host/test/exec-state-tracking.test.ts b/host/test/exec-state-tracking.test.ts index 495bbb993f..b742ab70c7 100644 --- a/host/test/exec-state-tracking.test.ts +++ b/host/test/exec-state-tracking.test.ts @@ -236,6 +236,60 @@ describe("opaque prepared exec target launch", () => { expect(cancel).toHaveBeenCalledTimes(2); }); + it("cancels a target only when a thrown commit never consumed it", async () => { + const bytes = new Uint8Array( + readFileSync("../local-binaries/programs/wasm32/exec-child.wasm"), + ); + const cancel = vi.fn(() => 0); + const kernel: PreparedExecKernel = { + execTargetSize: () => BigInt(bytes.byteLength), + execTargetRead: (_ownerPid, _target, offset, destination) => { + const start = Number(offset); + const count = Math.min(destination.byteLength, bytes.byteLength - start); + destination.set(bytes.subarray(start, start + count)); + return count; + }, + execTargetCancel: cancel, + }; + const launch = ( + target: number, + commitTarget: ( + target: number, + expectedSize: number, + markTargetConsumed: () => void, + ) => number, + ) => launchPreparedExecTarget({ + kernel, + ownerPid: 7, + pid: 7, + callerTid: 7, + diagnosticPath: "/bin/program", + argv: ["program"], + envp: [], + expectedAbi: ABI_VERSION, + materializePath: async () => {}, + prepareInitialTarget: () => target, + prepareInterpreterTarget: () => { + throw new Error("not a script"); + }, + commitTarget, + }, async () => ({ + onCommitFailure: vi.fn(), + startAfterCommit: vi.fn(async () => 0), + })); + + await expect(launch(42, () => { + throw new Error("kernel entry was busy"); + })).rejects.toThrow("kernel entry was busy"); + expect(cancel).toHaveBeenCalledExactlyOnceWith(7, 42); + + await expect(launch(43, (_target, _size, markTargetConsumed) => { + markTargetConsumed(); + throw new Error("host import threw after Rust consumed the target"); + })).rejects.toThrow("host import threw after Rust consumed the target"); + expect(cancel).toHaveBeenCalledTimes(1); + }); + it("does not lend commit authority to a callback-queued microtask", async () => { const bytes = new Uint8Array( readFileSync("../local-binaries/programs/wasm32/exec-child.wasm"), @@ -346,7 +400,11 @@ describe("opaque prepared exec target launch", () => { commitTarget, }, async () => ({ onCommitFailure, startAfterCommit }))).resolves.toBe(0); - expect(commitTarget).toHaveBeenCalledExactlyOnceWith(52, bytes.byteLength); + expect(commitTarget).toHaveBeenCalledExactlyOnceWith( + 52, + bytes.byteLength, + expect.any(Function), + ); expect(startAfterCommit).toHaveBeenCalledOnce(); expect(onCommitFailure).not.toHaveBeenCalled(); expect(cancel).not.toHaveBeenCalled(); @@ -391,7 +449,11 @@ describe("opaque prepared exec target launch", () => { commitTarget, }, async () => ({ onCommitFailure, startAfterCommit }))).resolves.toBe(-3); - expect(commitTarget).toHaveBeenCalledExactlyOnceWith(53, bytes.byteLength); + expect(commitTarget).toHaveBeenCalledExactlyOnceWith( + 53, + bytes.byteLength, + expect.any(Function), + ); expect(onCommitFailure).toHaveBeenCalledExactlyOnceWith(-3); expect(startAfterCommit).not.toHaveBeenCalled(); expect(cancel).not.toHaveBeenCalled(); @@ -912,7 +974,7 @@ describe("exec host-state transition", () => { }); }); - it("keeps a created spawn child but suppresses stale parent completion", async () => { + it("removes a hidden spawn child when async completion loses its parent", async () => { const memory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); const channel = createChannel(7, memory, 0); const pathPtr = 0x100; @@ -925,6 +987,7 @@ describe("exec host-state transition", () => { finishSpawn = resolve; }); const kernelSpawn = vi.fn(() => 100); + const publishSpawnChild = vi.fn(() => -10); const removeProcess = vi.fn(); const onSpawn = vi.fn(() => spawned); const program = resolvedProgram(); @@ -938,6 +1001,7 @@ describe("exec host-state transition", () => { kernelInstance: { exports: { kernel_spawn_process: kernelSpawn, + kernel_publish_spawn_child: publishSpawnChild, kernel_remove_process: removeProcess, }, }, @@ -949,7 +1013,10 @@ describe("exec host-state transition", () => { [pathPtr, path.length, blobPtr, 40, 0, 0], ); worker.handleSyscall(channel); - await flushMicrotasks(); + await flushMicrotasksUntil( + () => kernelSpawn.mock.calls.length === 1, + "spawn child was not created after candidate compilation", + ); expect(worker.callbacks.onResolveSpawn).toHaveBeenCalledOnce(); expect( kernelSpawn, @@ -962,7 +1029,8 @@ describe("exec host-state transition", () => { await flushMicrotasks(); expect(kernelSpawn).toHaveBeenCalled(); - expect(removeProcess).not.toHaveBeenCalled(); + expect(publishSpawnChild).not.toHaveBeenCalled(); + expect(removeProcess).toHaveBeenCalledExactlyOnceWith(100); expect(readChannelStatus(channel)).toBe(CHANNEL_STATUS_PENDING); }); @@ -1030,7 +1098,10 @@ describe("exec host-state transition", () => { [pathPtr, path.length, blobPtr, 40, 0, 0], ); worker.handleSyscall(channel); - await flushMicrotasks(); + await flushMicrotasksUntil( + () => worker.callbacks.onSpawn.mock.calls.length === 1, + "spawn worker launch did not begin after candidate compilation", + ); expect(worker.callbacks.onResolveSpawn).toHaveBeenCalledOnce(); expect( worker.callbacks.onSpawn, @@ -1814,6 +1885,46 @@ function createWorker(overrides: Record): any { const kernelMemory = overrides.kernelMemory instanceof WebAssembly.Memory ? overrides.kernelMemory : new WebAssembly.Memory({ initial: 4, maximum: 8 }); + const spawnTargetBytes = new Uint8Array(resolvedProgram().programBytes); + if (!("kernel_spawn_exec_target_prepare" in exports)) { + exports.kernel_spawn_exec_target_prepare = vi.fn(() => 31); + } + if (!("kernel_exec_target_size" in exports)) { + exports.kernel_exec_target_size = vi.fn(() => + BigInt(spawnTargetBytes.byteLength) + ); + } + if (!("kernel_exec_target_read" in exports)) { + exports.kernel_exec_target_read = vi.fn(( + _ownerPid: number, + _target: number, + offsetLo: number, + offsetHi: number, + destination: number, + capacity: number, + ) => { + const offset = Number( + (BigInt(offsetHi >>> 0) << 32n) | BigInt(offsetLo >>> 0), + ); + const count = Math.min( + capacity, + spawnTargetBytes.byteLength - offset, + ); + new Uint8Array(kernelMemory.buffer, destination, count).set( + spawnTargetBytes.subarray(offset, offset + count), + ); + return count; + }); + } + if (!("kernel_exec_target_cancel" in exports)) { + exports.kernel_exec_target_cancel = vi.fn(() => 0); + } + if (!("kernel_spawn_exec_commit" in exports)) { + exports.kernel_spawn_exec_commit = vi.fn(() => 0); + } + if (!("kernel_publish_spawn_child" in exports)) { + exports.kernel_publish_spawn_child = vi.fn(() => -1); + } const requestedPointerWidth = ( overrides.kernel as { getKernelPtrWidth?: () => unknown } | undefined )?.getKernelPtrWidth?.(); diff --git a/host/test/kernel-entry-context-audit.test.ts b/host/test/kernel-entry-context-audit.test.ts index 861b40ff19..01142d7646 100644 --- a/host/test/kernel-entry-context-audit.test.ts +++ b/host/test/kernel-entry-context-audit.test.ts @@ -595,6 +595,11 @@ describe("kernel entry-context static audit", () => { _label: string, _operation: (entry: KernelWorkerEntryContext) => void, ): void {} + #runOrDeferPendingSpawnCompletionKernelEntry( + _childPid: number, + _label: string, + _operation: (entry: KernelWorkerEntryContext) => void, + ): void {} #kernelInstanceForEntry( entry?: KernelWorkerEntryContext, ): WebAssembly.Instance { @@ -706,6 +711,42 @@ describe("kernel entry-context static audit", () => { }); `)); expect(safe).toEqual([]); + + const safeAfterParentRetirement = auditKernelEntryContext(source(` + entry.deferProtocolTransactionStart(() => { + void this.#continuePromise( + this.callbacks.launch(), + (_result) => { + this.#runOrDeferPendingSpawnCompletionKernelEntry( + 42, + "finish detached spawn", + (innerEntry) => this.#finish(innerEntry), + ); + }, + ); + return undefined; + }); + `)); + expect(safeAfterParentRetirement).toEqual([]); + + const nonlexicalDetachedSpawnCompletion = auditKernelEntryContext(source(` + entry.deferProtocolTransactionStart(() => { + const finish = (innerEntry: KernelWorkerEntryContext) => { + this.#finish(innerEntry); + }; + void this.#continuePromise(this.callbacks.launch(), (_result) => { + this.#runOrDeferPendingSpawnCompletionKernelEntry( + 42, + "finish detached spawn", + finish, + ); + }); + return undefined; + }); + `)); + expect(nonlexicalDetachedSpawnCompletion).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "nonlexical-entry-operation" }), + ])); }); it("keeps unknown HOFs synchronous and honors lexical shadowing", () => { diff --git a/host/test/kernel-late-channel.test.ts b/host/test/kernel-late-channel.test.ts index 6a3032f6eb..c0e0164c83 100644 --- a/host/test/kernel-late-channel.test.ts +++ b/host/test/kernel-late-channel.test.ts @@ -10,6 +10,12 @@ import { CH_RETURN, CH_SYSCALL, CH_TOTAL_SIZE, + KERNEL_WAIT_RESULT_SI_CODE_OFFSET, + KERNEL_WAIT_RESULT_SI_STATUS_OFFSET, + KERNEL_WAIT_RESULT_WAIT_STATUS_OFFSET, + STRUCT_SIZE_KERNEL_WAIT_RESULT, + WAIT_CLD_KILLED, + WAIT_EVENT_EXITED, } from "../src/generated/abi"; const ESRCH = 3; @@ -101,4 +107,87 @@ describe("kernel_handle_channel", () => { expect(view.getBigInt64(CH_RETURN, true)).toBe(-1n); expect(view.getUint32(CH_ERRNO, true)).toBe(ESRCH); }); + + it("keeps a signaled spawn child hidden from wasm wait/reap until publication", async () => { + const instance = await instantiateKernelOnly(readFileSync(resolveBinary("kernel.wasm"))); + const memory = instance.exports.memory as WebAssembly.Memory; + const allocScratch = instance.exports.kernel_alloc_scratch as (size: number) => number; + const createProcess = instance.exports.kernel_create_process as () => number; + const spawnProcess = instance.exports.kernel_spawn_process as ( + parentPid: number, + callerTid: number, + blobPtr: number, + blobLen: number, + ) => number; + const markProcessSignaled = instance.exports.kernel_mark_process_signaled as ( + pid: number, + signum: number, + ) => number; + const waitChildPoll = instance.exports.kernel_wait_child_poll as ( + parentPid: number, + callerTid: number, + targetPid: number, + eventMask: number, + flags: number, + outPtr: number, + outCapacity: number, + ) => number; + const reapExitedChild = instance.exports.kernel_reap_exited_child as ( + parentPid: number, + childPid: number, + ) => number; + const publishSpawnChild = instance.exports.kernel_publish_spawn_child as ( + parentPid: number, + childPid: number, + ) => number; + const getExitSignal = instance.exports.kernel_get_process_exit_signal as ( + pid: number, + ) => number; + + const parentPid = createProcess(); + const blob = new Uint8Array(40); + const blobPtr = allocScratch(blob.byteLength); + new Uint8Array(memory.buffer, blobPtr, blob.byteLength).set(blob); + const childPid = spawnProcess(parentPid, parentPid, blobPtr, blob.byteLength); + const waitResultPtr = allocScratch(STRUCT_SIZE_KERNEL_WAIT_RESULT); + + expect(childPid).toBeGreaterThan(0); + expect(markProcessSignaled(childPid, 15)).toBe(0); + expect( + waitChildPoll( + parentPid, + parentPid, + -1, + WAIT_EVENT_EXITED, + 0, + waitResultPtr, + STRUCT_SIZE_KERNEL_WAIT_RESULT, + ), + ).toBe(0); + expect(reapExitedChild(parentPid, childPid)).toBe(-10); + + expect(publishSpawnChild(parentPid, childPid)).toBe(15); + expect( + waitChildPoll( + parentPid, + parentPid, + -1, + WAIT_EVENT_EXITED, + 0, + waitResultPtr, + STRUCT_SIZE_KERNEL_WAIT_RESULT, + ), + ).toBe(childPid); + const waitResult = new DataView( + memory.buffer, + waitResultPtr, + STRUCT_SIZE_KERNEL_WAIT_RESULT, + ); + expect(waitResult.getInt32(KERNEL_WAIT_RESULT_WAIT_STATUS_OFFSET, true)).toBe(15); + expect(waitResult.getInt32(KERNEL_WAIT_RESULT_SI_CODE_OFFSET, true)).toBe( + WAIT_CLD_KILLED, + ); + expect(waitResult.getInt32(KERNEL_WAIT_RESULT_SI_STATUS_OFFSET, true)).toBe(15); + expect(getExitSignal(childPid)).toBe(-ESRCH); + }); }); diff --git a/host/test/kernel-scratch-contract.test.ts b/host/test/kernel-scratch-contract.test.ts index 4aea58145b..b5fcf7ab0d 100644 --- a/host/test/kernel-scratch-contract.test.ts +++ b/host/test/kernel-scratch-contract.test.ts @@ -640,6 +640,9 @@ const reviewedScalarKernelExportCalls: AuditAllowance[] = [ reviewedScalarKernelExportCall( "host/src/kernel-worker.ts::CentralizedKernelWorker.#captureBlockingRetryDisposition::kernel-export-direct-use::isFdNonblock(channel.pid, fd)", ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.#completeSuccessfulSpawnWithinKernelEntry::kernel-export-direct-use::publishSpawnChild(parentPid, childPid)", + ), reviewedScalarKernelExportCall( "host/src/kernel-worker.ts::CentralizedKernelWorker.#createTestAuthority::kernel-export-direct-use::forkProcess( parentPid, callerTid, PROCESS_FORK_MODE_FORK, )", ), @@ -858,6 +861,15 @@ const reviewedScalarKernelExportCalls: AuditAllowance[] = [ reviewedScalarKernelExportCall( "host/src/kernel-worker.ts::CentralizedKernelWorker.kernelExecCommit::kernel-export-direct-use::size(pid, target)", ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.kernelSpawnExecCommit::kernel-export-direct-use::cancel(childPid, target)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.kernelSpawnExecCommit::kernel-export-direct-use::commit(parentPid, childPid, target)", + ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.kernelSpawnExecCommit::kernel-export-direct-use::size(childPid, target)", + ), reviewedScalarKernelExportCall( "host/src/kernel-worker.ts::CentralizedKernelWorker.notifyParentOfChildStateTransition::kernel-export-direct-use::hasNoCldStop(parentPid)", ), diff --git a/host/test/prepared-exec-target.test.ts b/host/test/prepared-exec-target.test.ts index f2102b8928..0ba2917111 100644 --- a/host/test/prepared-exec-target.test.ts +++ b/host/test/prepared-exec-target.test.ts @@ -11,6 +11,7 @@ const PREPARED_EXEC_EXPORTS = [ "kernel_exec_target_read", "kernel_exec_target_cancel", "kernel_exec_commit", + "kernel_publish_spawn_child", "kernel_spawn_exec_commit", ] as const; @@ -80,6 +81,7 @@ describe("prepared exec target ABI", () => { signature: "(i32,i32,i32,i32,i32,i32) -> (i32)", }, { name: "kernel_exec_target_size", signature: "(i32,i32) -> (i64)" }, + { name: "kernel_publish_spawn_child", signature: "(i32,i32) -> (i32)" }, { name: "kernel_spawn_exec_commit", signature: "(i32,i32,i32) -> (i32)" }, { name: "kernel_spawn_exec_target_prepare", diff --git a/host/test/spawn-blob-transport.test.ts b/host/test/spawn-blob-transport.test.ts index 73676d8a03..a42d86f2eb 100644 --- a/host/test/spawn-blob-transport.test.ts +++ b/host/test/spawn-blob-transport.test.ts @@ -370,12 +370,24 @@ describe("SYS_SPAWN blob transport", () => { expect.any(Object), envp, ); + // The large blob never occupies main scratch. The mandatory final-target + // transaction legitimately reuses its bounded prefix for the path/read; + // bytes beyond both exact transfers must remain untouched. + const preparedBytes = new Uint8Array(resolvedProgram().programBytes); expect( kernelBytes.slice( generalScratchOffset, + generalScratchOffset + preparedBytes.byteLength, + ), + ).toEqual(preparedBytes); + expect( + kernelBytes.slice( + generalScratchOffset + path.byteLength, generalScratchOffset + CH_TOTAL_SIZE, ), - ).toEqual(new Uint8Array(CH_TOTAL_SIZE).fill(0xa5)); + ).toEqual( + new Uint8Array(CH_TOTAL_SIZE - path.byteLength).fill(0xa5), + ); worker.handleSpawnAfterResolve( channel, @@ -392,6 +404,42 @@ describe("SYS_SPAWN blob transport", () => { expect(kernelReservedSpawn).toHaveBeenCalledTimes(2); }); + it("ignores a resolver module that was not compiled from its candidate bytes", async () => { + const candidateBytes = moduleWithNamedExport("aaaa"); + const mismatchedModule = new WebAssembly.Module( + moduleWithNamedExport("bbbb"), + ); + + const launched = await launchCandidateBindingSpawn({ + candidateBytes, + candidateModule: mismatchedModule, + authoritativeBytes: candidateBytes, + }); + + expect(WebAssembly.Module.exports(launched.programModule)).toEqual([ + { name: "aaaa", kind: "function" }, + ]); + expect(launched.programModule).not.toBe(mismatchedModule); + }); + + it("binds the candidate module before resolver bytes mutate after preflight", async () => { + const candidateBytes = moduleWithNamedExport("aaaa"); + const authoritativeBytes = moduleWithNamedExport("bbbb"); + const resolverModule = new WebAssembly.Module(candidateBytes); + + const launched = await launchCandidateBindingSpawn({ + candidateBytes, + candidateModule: resolverModule, + authoritativeBytes, + afterPreflight: () => candidateBytes.set(authoritativeBytes), + }); + + expect(WebAssembly.Module.exports(launched.programModule)).toEqual([ + { name: "bbbb", kind: "function" }, + ]); + expect(launched.programModule).not.toBe(resolverModule); + }); + it("keeps ordinary spawn blobs in the existing channel-sized scratch", () => { const blob = buildSpawnBlob(["child"], ["A=B"]); const kernelMemory = new WebAssembly.Memory({ initial: 2, maximum: 2 }); @@ -1425,10 +1473,43 @@ function createWorker( Number.isSafeInteger(scratchPointer) && scratchPointer! > 0 ? scratchPointer! : 1024; + const defaultTargetBytes = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, + 0x01, 0x00, 0x00, 0x00, + ]); const implementations: Record = { + kernel_drain_wakeup_events: vi.fn(() => 0), + kernel_exec_target_cancel: vi.fn(() => 0), + kernel_exec_target_read: vi.fn(( + _ownerPid: number, + _target: number, + offsetLo: number, + offsetHi: number, + destination: number | bigint, + capacity: number | bigint, + ) => { + const offset = Number( + (BigInt(offsetHi >>> 0) << 32n) | BigInt(offsetLo >>> 0), + ); + const count = Math.min( + Number(capacity), + defaultTargetBytes.byteLength - offset, + ); + new Uint8Array( + kernelMemory.buffer, + Number(destination), + count, + ).set(defaultTargetBytes.subarray(offset, offset + count)); + return count; + }), + kernel_exec_target_size: vi.fn(() => BigInt(defaultTargetBytes.byteLength)), kernel_get_parent_pid: vi.fn(() => -1), kernel_get_process_exit_signal: vi.fn(() => -1), kernel_mark_process_signaled: vi.fn(() => 0), + kernel_remove_process: vi.fn(() => 0), + kernel_publish_spawn_child: vi.fn(() => -1), + kernel_spawn_exec_commit: vi.fn(() => 0), + kernel_spawn_exec_target_prepare: vi.fn(() => 1), ...(kernelExports ?? {}), }; const gate = new KernelEntryGate(); @@ -1512,11 +1593,124 @@ async function drainSpawnGate(): Promise { // The complete preflight path crosses resolution, a fresh result ingress, // and launch publication. Keep a fixed upper bound rather than using a // timer-based poll that could hide a permanently stuck gate. - for (let turn = 0; turn < 24; turn++) { - await Promise.resolve(); + for (let turn = 0; turn < 12; turn++) { + await new Promise((resolve) => setImmediate(resolve)); + for (let microtask = 0; microtask < 12; microtask++) { + await Promise.resolve(); + } } } +function moduleWithNamedExport(name: "aaaa" | "bbbb"): Uint8Array { + return Uint8Array.from([ + 0x00, 0x61, 0x73, 0x6d, + 0x01, 0x00, 0x00, 0x00, + 0x01, 0x04, 0x01, 0x60, 0x00, 0x00, + 0x03, 0x02, 0x01, 0x00, + 0x07, 0x08, 0x01, 0x04, + ...new TextEncoder().encode(name), + 0x00, 0x00, + 0x0a, 0x04, 0x01, 0x02, 0x00, 0x0b, + ]); +} + +async function launchCandidateBindingSpawn(options: { + candidateBytes: Uint8Array; + candidateModule: WebAssembly.Module; + authoritativeBytes: Uint8Array; + afterPreflight?: () => void; +}): Promise<{ + programBytes: ArrayBuffer; + programModule: WebAssembly.Module; + argv: string[]; +}> { + const parentPid = 7; + const childPid = 42; + const path = new TextEncoder().encode("/bin/child"); + const blob = buildSpawnBlob(["/bin/child"], []); + const processMemory = sharedMemoryFor(4096 + blob.byteLength); + const processBytes = new Uint8Array(processMemory.buffer); + const pathPtr = 256; + const blobPtr = 4096; + processBytes.set(path, pathPtr); + processBytes.set(blob, blobPtr); + + const kernelMemory = new WebAssembly.Memory({ initial: 4, maximum: 4 }); + let launched: + | { + programBytes: ArrayBuffer; + programModule: WebAssembly.Module; + argv: string[]; + } + | undefined; + const onSpawn = vi.fn(async ( + _parentPid: number, + _childPid: number, + program: { + programBytes: ArrayBuffer; + programModule: WebAssembly.Module; + argv: string[]; + }, + ) => { + launched = program; + return 0; + }); + const worker = createWorker({ + callbacks: { + onResolveSpawn: vi.fn(async () => ({ + programBytes: options.candidateBytes.buffer as ArrayBuffer, + programModule: options.candidateModule, + argv: ["/bin/child"], + })), + onSpawn, + }, + kernelMemory, + kernelExports: { + kernel_spawn_process: vi.fn(() => { + options.afterPreflight?.(); + return childPid; + }), + kernel_exec_target_size: vi.fn(() => + BigInt(options.authoritativeBytes.byteLength) + ), + kernel_exec_target_read: vi.fn(( + _ownerPid: number, + _target: number, + offsetLo: number, + offsetHi: number, + destination: number, + capacity: number, + ) => { + const offset = Number( + (BigInt(offsetHi >>> 0) << 32n) | BigInt(offsetLo >>> 0), + ); + const count = Math.min( + capacity, + options.authoritativeBytes.byteLength - offset, + ); + new Uint8Array(kernelMemory.buffer, destination, count).set( + options.authoritativeBytes.subarray(offset, offset + count), + ); + return count; + }), + }, + }); + const channel = createChannel(parentPid, processMemory); + worker.handleSpawn(channel, [ + pathPtr, + path.byteLength, + blobPtr, + blob.byteLength, + 0, + 0, + ]); + await drainSpawnGate(); + + expect(onSpawn).toHaveBeenCalledOnce(); + if (!launched) throw new Error("spawn candidate binding did not launch"); + return launched; +} + function createChannel(pid: number, memory: WebAssembly.Memory): any { const i32View = new Int32Array(memory.buffer); Atomics.store( diff --git a/host/test/spawn-credential-order.test.ts b/host/test/spawn-credential-order.test.ts new file mode 100644 index 0000000000..45a2142ad8 --- /dev/null +++ b/host/test/spawn-credential-order.test.ts @@ -0,0 +1,838 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it, vi } from "vitest"; + +import { + type CentralizedKernelCallbacks, + createCentralizedKernelWorkerTestDouble, + type ResolvedSpawnProgram, +} from "../src/kernel-worker"; +import { WASM_PAGE_SIZE } from "../src/constants"; +import { + ABI_SYSCALLS, + ABI_VERSION, + CHANNEL_STATUS_PENDING, + CH_ARGS, + CH_ARG_SIZE, + CH_STATUS, + CH_SYSCALL, + HOST_INTERCEPTED_SYSCALLS, + KERNEL_WAIT_RESULT_SI_CODE_OFFSET, + KERNEL_WAIT_RESULT_SI_STATUS_OFFSET, + KERNEL_WAIT_RESULT_WAIT_STATUS_OFFSET, + WAIT_CLD_KILLED, + WAIT_WNOHANG, +} from "../src/generated/abi"; +import { installKernelWorkerTestScratch } from "./kernel-worker-test-scratch"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const candidateA = new Uint8Array( + readFileSync(join(repoRoot, "local-binaries/programs/wasm32/exec-child.wasm")), +); +const authoritativeB = new Uint8Array( + readFileSync(join(repoRoot, "examples/hello.wasm")), +); + +describe("posix_spawn credential/action/target order", () => { + it("commits child-state target B after the one reserve/action transaction", async () => { + const order: string[] = []; + const kernelMemory = new WebAssembly.Memory({ initial: 4, maximum: 4 }); + const spawnProcess = vi.fn(() => { + order.push("reserve-resetids-attrs-actions"); + return 100; + }); + const prepareTarget = vi.fn(() => { + order.push("prepare-B"); + return 31; + }); + const commitTarget = vi.fn(() => { + order.push("commit-B"); + return 0; + }); + const onSpawn = vi.fn(async ( + _parentPid: number, + _childPid: number, + program: ResolvedSpawnProgram, + ) => { + order.push("launch-B"); + expect(new Uint8Array(program.programBytes)).toEqual(authoritativeB); + expect(program.programModule).not.toBe(preflight.programModule); + return 0; + }); + const preflight = resolvedProgram(candidateA, ["relative-child"]); + const harness = createSpawnHarness({ + kernelMemory, + callbacks: { onSpawn }, + kernelExports: preparedSpawnExports({ + kernelMemory, + targetBytes: authoritativeB, + spawnProcess, + prepareTarget, + commitTarget, + }), + }); + + harness.dispatch(preflight, "relative-child"); + await drainSpawnTransaction(); + + expect(spawnProcess).toHaveBeenCalledOnce(); + expect(prepareTarget).toHaveBeenCalledOnce(); + expect(commitTarget).toHaveBeenCalledExactlyOnceWith(7, 100, 31); + expect(onSpawn).toHaveBeenCalledOnce(); + expect(order).toEqual([ + "reserve-resetids-attrs-actions", + "prepare-B", + "commit-B", + "launch-B", + ]); + }); + + it("cancels the exact target and retires the pending child once on final read failure", async () => { + const kernelMemory = new WebAssembly.Memory({ initial: 4, maximum: 4 }); + const cancelTarget = vi.fn(() => 0); + const removeProcess = vi.fn(() => 0); + const commitTarget = vi.fn(() => 0); + const onSpawn = vi.fn(async () => 0); + const exports = preparedSpawnExports({ + kernelMemory, + targetBytes: authoritativeB, + spawnProcess: vi.fn(() => 100), + prepareTarget: vi.fn(() => 41), + commitTarget, + }); + exports.kernel_exec_target_read = vi.fn(() => -5); + exports.kernel_exec_target_cancel = cancelTarget; + exports.kernel_remove_process = removeProcess; + const harness = createSpawnHarness({ + kernelMemory, + callbacks: { onSpawn }, + kernelExports: exports, + }); + + harness.dispatch(resolvedProgram(candidateA, ["relative-child"]), "relative-child"); + await drainSpawnTransaction(); + + expect(cancelTarget).toHaveBeenCalledExactlyOnceWith(100, 41); + expect(removeProcess).toHaveBeenCalledExactlyOnceWith(100); + expect(commitTarget).not.toHaveBeenCalled(); + expect(onSpawn).not.toHaveBeenCalled(); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN, + harness.origArgs, + undefined, + -1, + 5, + ); + }); + + it("parses an authoritative shebang from the original spawn argv exactly once", async () => { + const kernelMemory = new WebAssembly.Memory({ initial: 4, maximum: 4 }); + const scriptBytes = new TextEncoder().encode( + "#!/bin/interpreter --flag\n", + ); + const prepareTarget = vi.fn(( + _parentPid: number, + _childPid: number, + _pathPtr: number, + _pathLen: number, + ) => prepareTarget.mock.calls.length === 1 ? 31 : 32); + const cancelTarget = vi.fn(() => 0); + const commitTarget = vi.fn(() => 0); + let launchedArgv: string[] | undefined; + const onSpawn = vi.fn(async ( + _parentPid: number, + _childPid: number, + program: ResolvedSpawnProgram, + ) => { + launchedArgv = program.argv; + return 0; + }); + const exports = preparedSpawnExports({ + kernelMemory, + targetBytes: authoritativeB, + spawnProcess: vi.fn(() => 100), + prepareTarget, + commitTarget, + }); + const bytesForTarget = (target: number): Uint8Array => + target === 31 ? scriptBytes : authoritativeB; + exports.kernel_exec_target_size = vi.fn(( + _ownerPid: number, + target: number, + ) => BigInt(bytesForTarget(target).byteLength)); + exports.kernel_exec_target_read = vi.fn(( + _ownerPid: number, + target: number, + offsetLo: number, + offsetHi: number, + destination: number, + capacity: number, + ) => { + const bytes = bytesForTarget(target); + const offset = Number( + (BigInt(offsetHi >>> 0) << 32n) | BigInt(offsetLo >>> 0), + ); + const count = Math.min(capacity, bytes.byteLength - offset); + new Uint8Array(kernelMemory.buffer, destination, count).set( + bytes.subarray(offset, offset + count), + ); + return count; + }); + exports.kernel_exec_target_cancel = cancelTarget; + const harness = createSpawnHarness({ + kernelMemory, + callbacks: { onSpawn }, + kernelExports: exports, + }); + const preflightAlreadyRewritten = resolvedProgram(authoritativeB, [ + "/bin/interpreter", + "--flag", + "relative-script", + "argument", + ]); + + harness.dispatch( + preflightAlreadyRewritten, + "relative-script", + ["relative-script", "argument"], + ); + await drainSpawnTransaction(); + + expect(prepareTarget).toHaveBeenCalledTimes(2); + expect(cancelTarget).toHaveBeenCalledExactlyOnceWith(100, 31); + expect(commitTarget).toHaveBeenCalledExactlyOnceWith(7, 100, 32); + expect(onSpawn).toHaveBeenCalledOnce(); + expect(launchedArgv).toEqual([ + "/bin/interpreter", + "--flag", + "/after-actions/relative-script", + "argument", + ]); + }); + + it("preserves a child signaled during final-target work as a waitable zombie", async () => { + const kernelMemory = new WebAssembly.Memory({ initial: 4, maximum: 4 }); + const removeProcess = vi.fn(() => 0); + const onExit = vi.fn(); + const onSpawn = vi.fn(async () => 0); + const commitTarget = vi.fn(() => 0); + const exports = preparedSpawnExports({ + kernelMemory, + targetBytes: authoritativeB, + spawnProcess: vi.fn(() => 100), + prepareTarget: vi.fn(() => 31), + commitTarget, + }); + const targetSize = BigInt(authoritativeB.byteLength); + exports.kernel_exec_target_size = vi.fn() + .mockReturnValueOnce(targetSize) + .mockReturnValue(-22n); + exports.kernel_exec_target_cancel = vi.fn(() => -22); + exports.kernel_publish_spawn_child = vi.fn(() => 15); + exports.kernel_get_process_exit_signal = vi.fn((pid: number) => + pid === 100 ? 15 : -1 + ); + exports.kernel_remove_process = removeProcess; + const harness = createSpawnHarness({ + kernelMemory, + callbacks: { onExit, onSpawn }, + kernelExports: exports, + }); + + harness.dispatch(resolvedProgram(candidateA, ["relative-child"]), "relative-child"); + await drainSpawnTransaction(); + + expect(onSpawn).not.toHaveBeenCalled(); + expect(commitTarget).not.toHaveBeenCalled(); + expect(removeProcess).not.toHaveBeenCalled(); + expect(onExit).toHaveBeenCalledExactlyOnceWith(100, 143); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN, + harness.origArgs, + undefined, + 0, + 0, + ); + }); + + it("publishes spawn success before waking a sibling waiter for the hidden zombie", async () => { + const kernelMemory = new WebAssembly.Memory({ initial: 4, maximum: 4 }); + let published = false; + let childSignal = -1; + let finishLaunch!: (result: number) => void; + const onSpawn = vi.fn(() => new Promise((resolve) => { + finishLaunch = resolve; + })); + const publishSpawnChild = vi.fn(() => { + published = true; + return childSignal; + }); + const waitChildPoll = vi.fn(( + _parentPid: number, + _callerTid: number, + _targetPid: number, + _eventMask: number, + _flags: number, + destination: number, + ) => { + if (!published || childSignal <= 0) return 0; + const result = new DataView(kernelMemory.buffer); + result.setInt32( + destination + KERNEL_WAIT_RESULT_WAIT_STATUS_OFFSET, + childSignal, + true, + ); + result.setInt32( + destination + KERNEL_WAIT_RESULT_SI_CODE_OFFSET, + WAIT_CLD_KILLED, + true, + ); + result.setInt32( + destination + KERNEL_WAIT_RESULT_SI_STATUS_OFFSET, + childSignal, + true, + ); + return 100; + }); + const removeProcess = vi.fn(() => 0); + const exports = preparedSpawnExports({ + kernelMemory, + targetBytes: authoritativeB, + spawnProcess: vi.fn(() => 100), + prepareTarget: vi.fn(() => 31), + commitTarget: vi.fn(() => 0), + }); + exports.kernel_publish_spawn_child = publishSpawnChild; + exports.kernel_wait_child_poll = waitChildPoll; + exports.kernel_get_process_exit_signal = vi.fn((pid: number) => + pid === 100 ? childSignal : -1 + ); + exports.kernel_get_parent_pid = vi.fn((pid: number) => + pid === 100 && published ? 7 : -3 + ); + exports.kernel_generate_host_signal = vi.fn(() => 0); + exports.kernel_pick_signal_target_tid = vi.fn(() => 0); + exports.kernel_has_sa_nocldwait = vi.fn(() => 0); + exports.kernel_dequeue_signal = vi.fn(() => 0); + exports.kernel_remove_process = removeProcess; + const harness = createSpawnHarness({ + kernelMemory, + callbacks: { onSpawn }, + kernelExports: exports, + }); + + harness.dispatch( + resolvedProgram(candidateA, ["relative-child"]), + "relative-child", + ); + await drainSpawnTransaction(); + expect(onSpawn).toHaveBeenCalledOnce(); + + childSignal = 15; + harness.dispatchSiblingWait(); + expect(waitChildPoll).toHaveBeenCalledOnce(); + expect(harness.completeChannel).not.toHaveBeenCalled(); + + finishLaunch(0); + await drainSpawnTransaction(); + + expect(publishSpawnChild).toHaveBeenCalledExactlyOnceWith(7, 100); + expect(waitChildPoll).toHaveBeenCalledTimes(2); + expect(removeProcess).not.toHaveBeenCalled(); + const spawnCompletion = harness.completeChannel.mock.calls.findIndex( + (call) => call[1] === HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN, + ); + const waitCompletion = harness.completeChannel.mock.calls.findIndex( + (call) => call[1] === ABI_SYSCALLS.Wait4, + ); + expect(spawnCompletion).toBeGreaterThanOrEqual(0); + expect(waitCompletion).toBeGreaterThan(spawnCompletion); + expect(harness.completeChannel.mock.calls[waitCompletion]).toEqual([ + harness.siblingWaitChannel, + ABI_SYSCALLS.Wait4, + [-1, 0, 0, 0, 0, 0], + undefined, + 100, + 0, + ]); + }); + + it("removes a failed pending spawn once and wakes its parked waiter with ECHILD", async () => { + const kernelMemory = new WebAssembly.Memory({ initial: 4, maximum: 4 }); + let removed = false; + let finishLaunch!: (result: number) => void; + const onSpawn = vi.fn(() => new Promise((resolve) => { + finishLaunch = resolve; + })); + const waitChildPoll = vi.fn(() => removed ? -10 : 0); + const removeProcess = vi.fn(() => { + removed = true; + return 0; + }); + const exports = preparedSpawnExports({ + kernelMemory, + targetBytes: authoritativeB, + spawnProcess: vi.fn(() => 100), + prepareTarget: vi.fn(() => 31), + commitTarget: vi.fn(() => 0), + }); + exports.kernel_wait_child_poll = waitChildPoll; + exports.kernel_dequeue_signal = vi.fn(() => 0); + exports.kernel_remove_process = removeProcess; + const harness = createSpawnHarness({ + kernelMemory, + callbacks: { onSpawn }, + kernelExports: exports, + }); + + harness.dispatch( + resolvedProgram(candidateA, ["relative-child"]), + "relative-child", + ); + await drainSpawnTransaction(); + harness.dispatchSiblingWait(); + expect(waitChildPoll).toHaveBeenCalledOnce(); + expect(harness.completeChannel).not.toHaveBeenCalled(); + + finishLaunch(-5); + await drainSpawnTransaction(); + + expect(removeProcess).toHaveBeenCalledExactlyOnceWith(100); + expect(waitChildPoll).toHaveBeenCalledTimes(2); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.siblingWaitChannel, + ABI_SYSCALLS.Wait4, + [-1, 0, 0, 0, 0, 0], + undefined, + -1, + 10, + ); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN, + harness.origArgs, + undefined, + -1, + 5, + ); + }); + + it("reports WNOHANG while the unpublished spawn child remains hidden", async () => { + const kernelMemory = new WebAssembly.Memory({ initial: 4, maximum: 4 }); + let finishLaunch!: (result: number) => void; + const onSpawn = vi.fn(() => new Promise((resolve) => { + finishLaunch = resolve; + })); + const waitChildPoll = vi.fn(() => 0); + const exports = preparedSpawnExports({ + kernelMemory, + targetBytes: authoritativeB, + spawnProcess: vi.fn(() => 100), + prepareTarget: vi.fn(() => 31), + commitTarget: vi.fn(() => 0), + }); + exports.kernel_wait_child_poll = waitChildPoll; + exports.kernel_dequeue_signal = vi.fn(() => 0); + const harness = createSpawnHarness({ + kernelMemory, + callbacks: { onSpawn }, + kernelExports: exports, + }); + + harness.dispatch( + resolvedProgram(candidateA, ["relative-child"]), + "relative-child", + ); + await drainSpawnTransaction(); + harness.dispatchSiblingWait(WAIT_WNOHANG); + + expect(waitChildPoll).toHaveBeenCalledOnce(); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.siblingWaitChannel, + ABI_SYSCALLS.Wait4, + [-1, 0, WAIT_WNOHANG, 0, 0, 0], + undefined, + 0, + 0, + ); + + finishLaunch(0); + await drainSpawnTransaction(); + expect(exports.kernel_publish_spawn_child).toHaveBeenCalledExactlyOnceWith( + 7, + 100, + ); + expect(exports.kernel_remove_process).not.toHaveBeenCalled(); + }); + + it("rolls back an ordinary commit ESRCH while the child remains live", async () => { + const kernelMemory = new WebAssembly.Memory({ initial: 4, maximum: 4 }); + const removeProcess = vi.fn(() => 0); + const onExit = vi.fn(); + const onSpawn = vi.fn(async () => 0); + const exports = preparedSpawnExports({ + kernelMemory, + targetBytes: authoritativeB, + spawnProcess: vi.fn(() => 100), + prepareTarget: vi.fn(() => 31), + commitTarget: vi.fn(() => -3), + }); + exports.kernel_get_process_exit_signal = vi.fn(() => -1); + exports.kernel_remove_process = removeProcess; + const harness = createSpawnHarness({ + kernelMemory, + callbacks: { onExit, onSpawn }, + kernelExports: exports, + }); + + harness.dispatch(resolvedProgram(candidateA, ["relative-child"]), "relative-child"); + await drainSpawnTransaction(); + + expect(onSpawn).not.toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); + expect(removeProcess).toHaveBeenCalledExactlyOnceWith(100); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN, + harness.origArgs, + undefined, + -1, + 3, + ); + }); + + it("reports an absent child without issuing a second numeric removal", async () => { + const kernelMemory = new WebAssembly.Memory({ initial: 4, maximum: 4 }); + const removeProcess = vi.fn(() => 0); + const onSpawn = vi.fn(async () => 0); + const exports = preparedSpawnExports({ + kernelMemory, + targetBytes: authoritativeB, + spawnProcess: vi.fn(() => 100), + prepareTarget: vi.fn(() => 31), + commitTarget: vi.fn(() => -3), + }); + exports.kernel_get_process_exit_signal = vi.fn(() => -3); + exports.kernel_publish_spawn_child = vi.fn(() => -3); + exports.kernel_remove_process = removeProcess; + const harness = createSpawnHarness({ + kernelMemory, + callbacks: { onSpawn }, + kernelExports: exports, + }); + + harness.dispatch( + resolvedProgram(candidateA, ["relative-child"]), + "relative-child", + ); + await drainSpawnTransaction(); + + expect(onSpawn).not.toHaveBeenCalled(); + expect(exports.kernel_publish_spawn_child).toHaveBeenCalledExactlyOnceWith( + 7, + 100, + ); + expect(removeProcess).not.toHaveBeenCalled(); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN, + harness.origArgs, + undefined, + -1, + 3, + ); + }); + + it("does not remove an absent child after the parent channel retires", async () => { + const kernelMemory = new WebAssembly.Memory({ initial: 4, maximum: 4 }); + let childSignal = -1; + let finishLaunch!: (result: number) => void; + const removeProcess = vi.fn(() => 0); + const onSpawn = vi.fn(() => new Promise((resolve) => { + finishLaunch = resolve; + })); + const exports = preparedSpawnExports({ + kernelMemory, + targetBytes: authoritativeB, + spawnProcess: vi.fn(() => 100), + prepareTarget: vi.fn(() => 31), + commitTarget: vi.fn(() => 0), + }); + const getProcessExitSignal = vi.fn((pid: number) => + pid === 100 ? childSignal : -1 + ); + exports.kernel_get_process_exit_signal = getProcessExitSignal; + exports.kernel_remove_process = removeProcess; + const harness = createSpawnHarness({ + kernelMemory, + callbacks: { onSpawn }, + kernelExports: exports, + }); + + harness.dispatch( + resolvedProgram(candidateA, ["relative-child"]), + "relative-child", + ); + await drainSpawnTransaction(); + expect(onSpawn).toHaveBeenCalledOnce(); + + childSignal = -3; + harness.retireParentChannel(); + finishLaunch(0); + await drainSpawnTransaction(); + + expect(exports.kernel_publish_spawn_child).not.toHaveBeenCalled(); + expect(getProcessExitSignal).toHaveBeenCalledWith(100); + expect(removeProcess).not.toHaveBeenCalled(); + }); + + it("removes a live hidden child once after the parent channel retires", async () => { + const kernelMemory = new WebAssembly.Memory({ initial: 4, maximum: 4 }); + let finishLaunch!: (result: number) => void; + const removeProcess = vi.fn(() => 0); + const onSpawn = vi.fn(() => new Promise((resolve) => { + finishLaunch = resolve; + })); + const exports = preparedSpawnExports({ + kernelMemory, + targetBytes: authoritativeB, + spawnProcess: vi.fn(() => 100), + prepareTarget: vi.fn(() => 31), + commitTarget: vi.fn(() => 0), + }); + exports.kernel_remove_process = removeProcess; + const harness = createSpawnHarness({ + kernelMemory, + callbacks: { onSpawn }, + kernelExports: exports, + }); + + harness.dispatch( + resolvedProgram(candidateA, ["relative-child"]), + "relative-child", + ); + await drainSpawnTransaction(); + expect(onSpawn).toHaveBeenCalledOnce(); + + harness.retireParentChannel(); + finishLaunch(0); + await drainSpawnTransaction(); + + expect(exports.kernel_publish_spawn_child).not.toHaveBeenCalled(); + expect(removeProcess).toHaveBeenCalledExactlyOnceWith(100); + }); + + it("removes the still-owned pending child when its bound parent is absent", async () => { + const kernelMemory = new WebAssembly.Memory({ initial: 4, maximum: 4 }); + const removeProcess = vi.fn(() => 0); + const onSpawn = vi.fn(async () => 0); + const exports = preparedSpawnExports({ + kernelMemory, + targetBytes: authoritativeB, + spawnProcess: vi.fn(() => 100), + prepareTarget: vi.fn(() => 31), + commitTarget: vi.fn(() => -3), + }); + exports.kernel_get_process_exit_signal = vi.fn(() => -3); + exports.kernel_publish_spawn_child = vi.fn(() => -10); + exports.kernel_remove_process = removeProcess; + const harness = createSpawnHarness({ + kernelMemory, + callbacks: { onSpawn }, + kernelExports: exports, + }); + + harness.dispatch( + resolvedProgram(candidateA, ["relative-child"]), + "relative-child", + ); + await drainSpawnTransaction(); + + expect(onSpawn).not.toHaveBeenCalled(); + expect(exports.kernel_publish_spawn_child).toHaveBeenCalledExactlyOnceWith( + 7, + 100, + ); + expect(removeProcess).toHaveBeenCalledExactlyOnceWith(100); + expect(harness.completeChannel).toHaveBeenCalledWith( + harness.channel, + HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN, + harness.origArgs, + undefined, + -1, + 10, + ); + }); +}); + +function resolvedProgram( + bytes: Uint8Array, + argv: string[], +): ResolvedSpawnProgram { + const owned = bytes.slice(); + return { + programBytes: owned.buffer, + programModule: new WebAssembly.Module(owned), + argv, + }; +} + +function preparedSpawnExports(options: { + kernelMemory: WebAssembly.Memory; + targetBytes: Uint8Array; + spawnProcess: ReturnType; + prepareTarget: ReturnType; + commitTarget: ReturnType; +}): Record { + const cwd = new TextEncoder().encode("/after-actions"); + return { + kernel_spawn_process: options.spawnProcess, + kernel_spawn_exec_target_prepare: options.prepareTarget, + kernel_exec_target_size: vi.fn(() => BigInt(options.targetBytes.byteLength)), + kernel_exec_target_read: vi.fn(( + _ownerPid: number, + _target: number, + offsetLo: number, + offsetHi: number, + destination: number, + capacity: number, + ) => { + const offset = Number( + (BigInt(offsetHi >>> 0) << 32n) | BigInt(offsetLo >>> 0), + ); + const count = Math.min(capacity, options.targetBytes.byteLength - offset); + new Uint8Array(options.kernelMemory.buffer, destination, count).set( + options.targetBytes.subarray(offset, offset + count), + ); + return count; + }), + kernel_exec_target_cancel: vi.fn(() => 0), + kernel_publish_spawn_child: vi.fn(() => -1), + kernel_spawn_exec_commit: options.commitTarget, + kernel_get_cwd: vi.fn((_pid: number, destination: number, capacity: number) => { + if (capacity < cwd.byteLength) return -34; + new Uint8Array(options.kernelMemory.buffer, destination, cwd.byteLength).set(cwd); + return cwd.byteLength; + }), + kernel_remove_process: vi.fn(() => 0), + }; +} + +function createSpawnHarness(options: { + kernelMemory: WebAssembly.Memory; + callbacks: CentralizedKernelCallbacks; + kernelExports: Record; +}) { + const processMemory = new WebAssembly.Memory({ + initial: 4, + maximum: 4, + shared: true, + }); + const completeChannel = vi.fn(); + const worker = createCentralizedKernelWorkerTestDouble({ + callbacks: options.callbacks, + }); + let parentChannelActive = true; + Reflect.set(worker, "kernelAbiVersion", ABI_VERSION); + installKernelWorkerTestScratch(worker, options.kernelMemory, 4096, 4, { + kernelExports: { + kernel_drain_wakeup_events: vi.fn(() => 0), + kernel_get_parent_pid: vi.fn(() => -1), + kernel_get_process_exit_signal: vi.fn(() => -1), + kernel_mark_process_signaled: vi.fn(() => 0), + kernel_set_current_tid: vi.fn(() => 0), + ...options.kernelExports, + }, + }); + const [channel, siblingWaitChannel] = + worker.testAuthority.replaceProcessRegistrationForLifecycleTest({ + pid: 7, + memory: processMemory, + channelOffsets: [WASM_PAGE_SIZE, 2 * WASM_PAGE_SIZE], + }); + if (!channel || !siblingWaitChannel) { + throw new Error("spawn harness did not register both parent channels"); + } + Atomics.store( + new Int32Array(processMemory.buffer, channel.channelOffset), + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + CHANNEL_STATUS_PENDING, + ); + worker.testAuthority.configureScratchBoundaryHooksForTest({ + completeChannel, + isRegisteredChannel: (candidate) => + candidate.pid !== 7 || parentChannelActive, + }); + const origArgs = [0, 0, 0, 1, 0, 0]; + return { + channel, + completeChannel, + origArgs, + siblingWaitChannel, + retireParentChannel(): void { + parentChannelActive = false; + }, + dispatch( + program: ResolvedSpawnProgram, + authorityPath: string, + originalArgv: string[] = program.argv, + ): void { + worker.testAuthority.dispatchSpawnAfterResolveForTest({ + channel, + origArgs, + parentPid: 7, + callerTid: 7, + pidOutPtr: 0, + blobBytes: new Uint8Array([1]), + blobLen: 1, + program, + envp: [], + authorityPath, + originalArgv, + } as Parameters< + typeof worker.testAuthority.dispatchSpawnAfterResolveForTest + >[0]); + }, + dispatchSiblingWait(options = 0): void { + const waitArgs = [-1, 0, options, 0, 0, 0]; + const waitView = new DataView( + siblingWaitChannel.memory.buffer, + siblingWaitChannel.channelOffset, + ); + Atomics.store( + new Int32Array( + siblingWaitChannel.memory.buffer, + siblingWaitChannel.channelOffset, + ), + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + CHANNEL_STATUS_PENDING, + ); + waitView.setUint32(CH_SYSCALL, ABI_SYSCALLS.Wait4, true); + for (let index = 0; index < waitArgs.length; index++) { + waitView.setBigInt64( + CH_ARGS + index * CH_ARG_SIZE, + BigInt(waitArgs[index]!), + true, + ); + } + worker.testAuthority.dispatchScratchBoundarySyscallForTest( + siblingWaitChannel, + ); + }, + }; +} + +async function drainSpawnTransaction(): Promise { + // Compiling divergent authoritative bytes uses the host's async Wasm + // compiler. Give concurrent Vitest files enough event-loop turns for that + // real detached phase and its fresh completion ingress to settle. + for (let turn = 0; turn < 12; turn++) { + await new Promise((resolve) => setImmediate(resolve)); + for (let microtask = 0; microtask < 12; microtask++) { + await Promise.resolve(); + } + } +} diff --git a/host/test/spawn-host-parity.test.ts b/host/test/spawn-host-parity.test.ts index c2b122e693..c8ce20ca25 100644 --- a/host/test/spawn-host-parity.test.ts +++ b/host/test/spawn-host-parity.test.ts @@ -56,7 +56,7 @@ function ordinaryForkHandlerSource(src: string): string { function execHandlerSource(src: string): string { const start = src.indexOf("async function handleExec("); - const end = src.indexOf("\n/**\n * Handle SYS_SPAWN", start); + const end = src.indexOf("\n/**\n * Pre-flight resolver", start); expect(start).toBeGreaterThanOrEqual(0); expect(end).toBeGreaterThan(start); return src.slice(start, end); @@ -148,7 +148,29 @@ describe("spawn host parity", () => { readFileSync(sharedExecTarget, "utf8"), "the shared launcher must own the only target commit", ) - .toContain("options.commitTarget(target, targetBytes.byteLength)"); + .toContain("options.commitTarget("); + }); + + it("both spawn adapters receive only the shared exact committed target", () => { + const shared = readFileSync(sharedWorker, "utf8"); + const prepare = shared.indexOf("this.spawnExecTargetPrepare("); + const commit = shared.indexOf("this.kernelSpawnExecCommit("); + const launch = shared.indexOf("startAfterCommit: () => callback("); + expect(prepare).toBeGreaterThanOrEqual(0); + expect(commit).toBeGreaterThan(prepare); + expect(launch).toBeGreaterThan(commit); + expect(shared).toMatch( + /programBytes:\s*request\.targetBytes,[\s\S]*programModule:\s*request\.targetModule/, + ); + + for (const entry of [nodeEntry, browserEntry]) { + const handler = posixSpawnHandlerSource(readFileSync(entry, "utf8")); + expect(handler, `${entry} must launch the supplied committed module`) + .toContain("const { programBytes, programModule, argv } = program;"); + expect(handler, `${entry} must not repeat candidate resolution`).not.toMatch( + /resolveExecutableForLaunch|handlePosixSpawnResolve|execPrograms|readExecFromVfs/, + ); + } }); it("both hosts own the exact fork clone before their first async yield", () => { diff --git a/host/test/spawn-pid-authority.test.ts b/host/test/spawn-pid-authority.test.ts index 55f874c3e9..22730912d4 100644 --- a/host/test/spawn-pid-authority.test.ts +++ b/host/test/spawn-pid-authority.test.ts @@ -603,6 +603,7 @@ describe("kernel task-ID authority", () => { "kernel_spawn_exec_target_prepare", "kernel_exec_commit", "kernel_spawn_exec_commit", + "kernel_publish_spawn_child", "kernel_fork_process", "kernel_spawn_process", "kernel_thread_exit", diff --git a/host/test/support/kernel-entry-context-audit.ts b/host/test/support/kernel-entry-context-audit.ts index 9bf7ccdad6..3e03007cc9 100644 --- a/host/test/support/kernel-entry-context-audit.ts +++ b/host/test/support/kernel-entry-context-audit.ts @@ -87,6 +87,14 @@ const ROOT_INGRESS_METHODS = new Map([ ["#runImmediateKernelEntry", 1], ["#runOrDeferKernelEntry", 1], ["#runOrDeferChannelKernelEntry", 2], + ["#runOrDeferPendingSpawnCompletionKernelEntry", 2], +]); +const TRANSACTION_COMPLETION_INGRESS_METHODS = new Set([ + "#runOrDeferChannelKernelEntry", + // Spawn owns a second kernel Process after child reservation. Its detached + // completion must retain cleanup authority when the parent channel has + // retired, while the completion body separately gates parent-memory writes. + "#runOrDeferPendingSpawnCompletionKernelEntry", ]); const ENTRY_SELECTORS = new Set([ "#kernelInstanceForEntry", @@ -1564,7 +1572,7 @@ export function auditKernelEntryContext( } return callbacks; }; - const directlyOpensChannelIngress = ( + const directlyOpensTransactionCompletionIngress = ( callback: ts.ArrowFunction | ts.FunctionExpression, ): boolean => { let found = false; @@ -1573,7 +1581,9 @@ export function auditKernelEntryContext( if (node !== callback.body && isFunctionExpressionLike(node)) return; if ( ts.isCallExpression(node) - && resolvedThisMethodName(node) === "#runOrDeferChannelKernelEntry" + && TRANSACTION_COMPLETION_INGRESS_METHODS.has( + resolvedThisMethodName(node) ?? "", + ) ) { found = true; return; @@ -1602,14 +1612,14 @@ export function auditKernelEntryContext( } if ( callbackPhase === "transaction-continuation" - && !directlyOpensChannelIngress(callback) + && !directlyOpensTransactionCompletionIngress(callback) ) { report( "transaction-continuation-without-channel-ingress", owner, callback, "a protocol transaction continuation must directly re-enter through " - + "#runOrDeferChannelKernelEntry before completion or rollback", + + "a reviewed completion ingress before completion or rollback", ); } visitCallable(callback, callbackPhase); @@ -2484,14 +2494,15 @@ export function auditKernelEntryContext( if (ROOT_INGRESS_METHODS.has(call.callee)) { if ( call.phase === "transaction-continuation" - && call.callee !== "#runOrDeferChannelKernelEntry" + && !TRANSACTION_COMPLETION_INGRESS_METHODS.has(call.callee) ) { report( "transaction-continuation-without-channel-ingress", scope.owner, call.node, - "protocol transaction completion must use the channel ingress " - + "that validates exact registration and CH_PENDING", + "protocol transaction completion must use a reviewed ingress " + + "that either validates the exact pending channel or owns " + + "pending-spawn cleanup after parent retirement", ); } continue; diff --git a/host/test/support/kernel-export-failure-audit.ts b/host/test/support/kernel-export-failure-audit.ts index e63a63cdf2..8f13c2e73e 100644 --- a/host/test/support/kernel-export-failure-audit.ts +++ b/host/test/support/kernel-export-failure-audit.ts @@ -228,6 +228,7 @@ const SYNCHRONOUS_CALLBACK_CALLS = new Set([ "#invokeSharedMmapHostOperation", "#runOrDeferChannelKernelEntry", "#runOrDeferKernelEntry", + "#runOrDeferPendingSpawnCompletionKernelEntry", "withLease", ]); diff --git a/host/test/support/kernel-scratch-instance.ts b/host/test/support/kernel-scratch-instance.ts index cbb84a19ba..50b3c8c118 100644 --- a/host/test/support/kernel-scratch-instance.ts +++ b/host/test/support/kernel-scratch-instance.ts @@ -92,6 +92,10 @@ function signatures( parameters: [i32, i32, pointer, pointer], result: i32, }, + kernel_publish_spawn_child: { + parameters: [i32, i32], + result: i32, + }, kernel_fd_is_open: { parameters: [i32, i32], result: i32, diff --git a/packages/registry/kernel/build-kernel.sh b/packages/registry/kernel/build-kernel.sh index 3470aafdf4..af86fafc11 100755 --- a/packages/registry/kernel/build-kernel.sh +++ b/packages/registry/kernel/build-kernel.sh @@ -83,6 +83,7 @@ wasm_require_exports "$OUT" \ kernel_set_current_tid \ kernel_set_cwd \ kernel_shmid_ds_bytes \ + kernel_publish_spawn_child \ kernel_spawn_exec_commit \ kernel_spawn_exec_target_prepare \ kernel_spawn_process \ diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index 86e67c67be..ef8d6a429c 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -74,8 +74,8 @@ "erlang-vfs": { "manifestSha256": "15efb74f1825e2b85bedfeb8d98c19fa648c4be39cb9defb65330a8f21eb9141", "cacheKeys": { - "wasm32": "8f7181c48a9b5b3cde3d51a9b966fd04a99eba5dc7a429c31f39e73924004506", - "wasm64": "a09c85016a4f93094bf11f8c7cda736ad4a68b66669ba0c44510289413ce4470" + "wasm32": "621bcdd99ed2660c5daed2981241676bf3befde53bda53e0f4213bb27e2e4c17", + "wasm64": "ab3050fc5056186dc395203221aec37401d52ae3c97401fc5226f79c0451fa32" } }, "fbdoom": { @@ -144,8 +144,8 @@ "kandelo-sdk": { "manifestSha256": "c081879f1becd855917cff96f17429bcf2b9fd61bf95211eca9ff8fb625bb2eb", "cacheKeys": { - "wasm32": "bd4d62d3ff0e8c2fec8660d0235b58d61d05e7500725275ebbda2bf912d7f6f7", - "wasm64": "a4f727f9d25b5dceb8ea54434e9474ca7cf75afe6952d11c497be33c081f359d" + "wasm32": "462a0eab5fc9741b99b0ab4b4e07e2bdfc7ccd78e6a84463c9c28c15b94f389b", + "wasm64": "c622818d4529848c2986786ff1791e2be8590da9e84ced8af0440ad0f3358c38" } }, "kernel": { @@ -158,8 +158,8 @@ "lamp": { "manifestSha256": "250b64635d64f8537178188fb5488dbfd2980d79a1c2ffbf346af67008088ba0", "cacheKeys": { - "wasm32": "88ffb0d0d271764a3686f271f496bba0b29577d574c1e4cdf769fd2fa6f3e44e", - "wasm64": "3cc9f430924898cf0406fb8d349fc35f51e43c34509a3c5344e4453128096f1b" + "wasm32": "c29e679e6caf9c2f50d12df47f080b21cd7f0f223e4150222f6bf5877ccb7129", + "wasm64": "b18a0317ce8a554b2ed873e4069abbdaf5358145864681ec8702325a4d01e229" } }, "less": { @@ -242,15 +242,15 @@ "mariadb-test": { "manifestSha256": "d4ccce161d659a5a87c80fa1117054bbccea870e0d4a46bd8a070d3930ad0e3f", "cacheKeys": { - "wasm32": "ce482c4fb5a3dfb32ab9a236a4dbc1be1750b583bff304859d6676d38f25c4c5", - "wasm64": "9898127b28009ac13fb8a0ada939f4f1c0bbebbbd62aa5773b6a976c5c7be65f" + "wasm32": "c6ea058fadd400811a3278f8fd3e5c457e65c321784894ecd1186169d4bf4ef9", + "wasm64": "5239737854203bec755ca69da5845111cb9ca83d94e0b59ecfca593e471d762c" } }, "mariadb-vfs": { "manifestSha256": "26e74ac84d89b2a839ed72783437061a23022ef2f88ad0f52b6aacd0442ee1cf", "cacheKeys": { - "wasm32": "9998bd99380bacb6651f6b7a9404483656d85a09292fdbf2a2b38a6460221e0e", - "wasm64": "5066260266c8837b74b4a9e6b5deee2a5565f568ceaf638f46f08cee2ac6068d" + "wasm32": "5d73bc2fddc13270b98c9bd446a871c944d20b64222b1ce1f7f72d004625916c", + "wasm64": "838bcf9a59b106bb02221d0aae8c53730d98e5dc24661fcb080059345c99cf7a" } }, "modeset": { @@ -312,15 +312,15 @@ "nginx-php-vfs": { "manifestSha256": "97976410cb02f8ba710d856b4ac904bcf976d677b50eefdee39fb64176070d4b", "cacheKeys": { - "wasm32": "b582a6a30deea8417405bdba9adbba3fc799f5c2383e868dc83e176405a5788c", - "wasm64": "d21c2c99323c4736765f449dca4bc964bf2b8252faa77ddeb256608f966524f7" + "wasm32": "7e17179fb8a3b3264f944ca62a6418af19387bbfa879ffd87bdcc6b75e7c13a8", + "wasm64": "1f86154e03f2ca07e84977cad71d9e98cb09720b71be33f21757c1d09175ddd3" } }, "nginx-vfs": { "manifestSha256": "46aa2d3250ac5cd0f102a85c3a36a086c7a50ba1f9e05fa1c2aae3d07ad16f10", "cacheKeys": { - "wasm32": "28f1657a0e76d31eff503f6576ccea247632de6100dffd2f7b3e3f13ffc33632", - "wasm64": "939e8b67b1e3fe80093273e47fdffd67423985f421a0ba7d78079c590f02ba59" + "wasm32": "e70252d6f9dc8738b944236168f343f87aea2ac1684f21b44d8f7260087c37ba", + "wasm64": "904623e429616b482a09b6ea2881b1c205ba9dd0ef527db8ad29e34866452714" } }, "node": { @@ -333,8 +333,8 @@ "node-vfs": { "manifestSha256": "977f219defe57e18017ecc963159df32efdd21c53d6fea05943703d04739d8de", "cacheKeys": { - "wasm32": "adf2ac7a772c1f870f52304383c7fa796e8118174b24991388657d315854a195", - "wasm64": "6c15ddc14c9551a32df0d9d8f0b96ca36d213414529753a57f04e5c026a2635f" + "wasm32": "a23dc8ce824ad822e0f2491691fb0a9872ab3842e1eac0721d89f766edc4f603", + "wasm64": "6de8d9fcbf40d86e1cf22b9fe4bc0c6a18dc8f9e486782ee772987cc9af2588e" } }, "openssl": { @@ -361,8 +361,8 @@ "perl-vfs": { "manifestSha256": "1345cc102bc8c24a6bc276410c00b4fcc99ba5f6adc9b031f882aaa35d53c8bc", "cacheKeys": { - "wasm32": "c9d067a16fac628ec814814dc5f0f26e6c40ffcbe42e15e046878323749dabb1", - "wasm64": "dba3fe1f77e2c0e750f0f7b2a1516d254720fc15ac7e4ad45f7da03f93837886" + "wasm32": "1600ccb83a5d813b97cd998fe6be47fa67dc9d7fe4eefca8198d9a85d8c6bb2e", + "wasm64": "200cfe3ddd6e38e5abfe6985f61033c8026c2d3c2f87c4474c216cc11def158a" } }, "php": { @@ -382,8 +382,8 @@ "python-vfs": { "manifestSha256": "d1aa99feae65f06c0ca8cf52c2176534b2723fd4ee6eba6e72c205245e1c9c26", "cacheKeys": { - "wasm32": "5f51c1ef484457d6621c2e5c3ed2ed84499897319a700e0d53960188b2ea2c17", - "wasm64": "08da49cc391eed5977f52da11b1dc3acd8ea82aa703ef150c656bc06c872f515" + "wasm32": "a2c3bbc0db8e3b1e219839757b46be850e7e31c5e8b0ffa75ef1d217516887fa", + "wasm64": "9e2ee4f073c9a26bdc818cf27b38b75a917f95193c3c4732b32859e5a08413fd" } }, "redis": { @@ -396,15 +396,15 @@ "redis-vfs": { "manifestSha256": "234c45c40f94e2a89f98295313b82cb9fce15a412ac829d9e87394e0dc731813", "cacheKeys": { - "wasm32": "1045094260a96f080ba96edea9c1ad457f02aa1ad2f69b78f52ed6c52478065c", - "wasm64": "7a9959f8df59181bcfebf29227bcafbe5cc9503ce8e126f56ec552080a132afe" + "wasm32": "3c5f12bc722762e45ef2f30741e2def167d4797051eaae874df2f455a2ef95f8", + "wasm64": "de15b4b9f890b477727b219765eafdc92a1738624f1c8d03ad8f0d6c220dc7b9" } }, "rootfs": { "manifestSha256": "0420201025170f48c32949ac62707d4577cdc13a53ad1f01f59d318ec07c8d6e", "cacheKeys": { - "wasm32": "35777ccb64bcda5784e2252fb02c7ed0b5ec5d9f73d9c10e5d1ddcea97699cd2", - "wasm64": "73e0b78a3b128b79c9fc65d6f1dcc01f9376d5f045e6f24c54ca53f8c68a80c2" + "wasm32": "56ae667726be17a2b20e5a5a577d907fb8706f643bfb6703c44248b9de0dd192", + "wasm64": "b3c4e178198703b5bb5f56d7b21ad177b07064d7965b3997887498533cddc6ba" } }, "ruby": { @@ -452,8 +452,8 @@ "shell": { "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", "cacheKeys": { - "wasm32": "0494df11f0e4e40e0138cca31e1a2fcd983b28e10392543fd8e1492609fd76a9", - "wasm64": "61056569bf5ed94acaeac08b1c193252d4cf2d0d469f52ed76f3405d3c3bbf24" + "wasm32": "1fe39b11e701eeddeba9be5dcc7521f51df1b889fb7eb2ba35098f9a071dcc97", + "wasm64": "b2490222101a2d008cdd3cd9541737c1695e69bd1589cae8144142efaf2566d2" } }, "spidermonkey": { @@ -543,8 +543,8 @@ "wordpress": { "manifestSha256": "36465e0596a06e855a8524eecfd87da98643bc13b37ee12939f5aab44bf33418", "cacheKeys": { - "wasm32": "445dc1e6893f39932e4c688040fb56584e5a9be6f1a1f0b0e0a745ac18cd5d2b", - "wasm64": "b656234c6f15fd06b17229f2b3387533621cefcd8fbb0880a27477ac81a958bc" + "wasm32": "46a808b1ec1670f2611cd69d0ae1b27a6990c8e79a5b5c4b67b8fb35c26d4571", + "wasm64": "6ca16ac43b2c5317666a396133f97b6af100a79651f3d1312a89af7b41205dad" } }, "xz": { @@ -871,7 +871,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "8f7181c48a9b5b3cde3d51a9b966fd04a99eba5dc7a429c31f39e73924004506" + "wasm32": "621bcdd99ed2660c5daed2981241676bf3befde53bda53e0f4213bb27e2e4c17" }, "dependencyClosures": { "wasm32": [ @@ -1094,7 +1094,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "bd4d62d3ff0e8c2fec8660d0235b58d61d05e7500725275ebbda2bf912d7f6f7" + "wasm32": "462a0eab5fc9741b99b0ab4b4e07e2bdfc7ccd78e6a84463c9c28c15b94f389b" }, "dependencyClosures": { "wasm32": [ @@ -1121,7 +1121,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "88ffb0d0d271764a3686f271f496bba0b29577d574c1e4cdf769fd2fa6f3e44e" + "wasm32": "c29e679e6caf9c2f50d12df47f080b21cd7f0f223e4150222f6bf5877ccb7129" }, "dependencyClosures": { "wasm32": [ @@ -1193,7 +1193,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "0494df11f0e4e40e0138cca31e1a2fcd983b28e10392543fd8e1492609fd76a9" + "cacheKey": "1fe39b11e701eeddeba9be5dcc7521f51df1b889fb7eb2ba35098f9a071dcc97" }, { "packageName": "sqlite", @@ -1360,7 +1360,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ce482c4fb5a3dfb32ab9a236a4dbc1be1750b583bff304859d6676d38f25c4c5" + "wasm32": "c6ea058fadd400811a3278f8fd3e5c457e65c321784894ecd1186169d4bf4ef9" }, "dependencyClosures": { "wasm32": [ @@ -1413,8 +1413,8 @@ "wasm64" ], "cacheKeys": { - "wasm32": "9998bd99380bacb6651f6b7a9404483656d85a09292fdbf2a2b38a6460221e0e", - "wasm64": "5066260266c8837b74b4a9e6b5deee2a5565f568ceaf638f46f08cee2ac6068d" + "wasm32": "5d73bc2fddc13270b98c9bd446a871c944d20b64222b1ce1f7f72d004625916c", + "wasm64": "838bcf9a59b106bb02221d0aae8c53730d98e5dc24661fcb080059345c99cf7a" }, "dependencyClosures": { "wasm32": [ @@ -1746,7 +1746,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b582a6a30deea8417405bdba9adbba3fc799f5c2383e868dc83e176405a5788c" + "wasm32": "7e17179fb8a3b3264f944ca62a6418af19387bbfa879ffd87bdcc6b75e7c13a8" }, "dependencyClosures": { "wasm32": [ @@ -1808,7 +1808,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "0494df11f0e4e40e0138cca31e1a2fcd983b28e10392543fd8e1492609fd76a9" + "cacheKey": "1fe39b11e701eeddeba9be5dcc7521f51df1b889fb7eb2ba35098f9a071dcc97" }, { "packageName": "sqlite", @@ -1838,7 +1838,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "28f1657a0e76d31eff503f6576ccea247632de6100dffd2f7b3e3f13ffc33632" + "wasm32": "e70252d6f9dc8738b944236168f343f87aea2ac1684f21b44d8f7260087c37ba" }, "dependencyClosures": { "wasm32": [ @@ -1860,7 +1860,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "0494df11f0e4e40e0138cca31e1a2fcd983b28e10392543fd8e1492609fd76a9" + "cacheKey": "1fe39b11e701eeddeba9be5dcc7521f51df1b889fb7eb2ba35098f9a071dcc97" } ] }, @@ -1922,7 +1922,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "adf2ac7a772c1f870f52304383c7fa796e8118174b24991388657d315854a195" + "wasm32": "a23dc8ce824ad822e0f2491691fb0a9872ab3842e1eac0721d89f766edc4f603" }, "dependencyClosures": { "wasm32": [ @@ -1944,7 +1944,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "0494df11f0e4e40e0138cca31e1a2fcd983b28e10392543fd8e1492609fd76a9" + "cacheKey": "1fe39b11e701eeddeba9be5dcc7521f51df1b889fb7eb2ba35098f9a071dcc97" }, { "packageName": "spidermonkey", @@ -1995,7 +1995,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c9d067a16fac628ec814814dc5f0f26e6c40ffcbe42e15e046878323749dabb1" + "wasm32": "1600ccb83a5d813b97cd998fe6be47fa67dc9d7fe4eefca8198d9a85d8c6bb2e" }, "dependencyClosures": { "wasm32": [ @@ -2418,7 +2418,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "5f51c1ef484457d6621c2e5c3ed2ed84499897319a700e0d53960188b2ea2c17" + "wasm32": "a2c3bbc0db8e3b1e219839757b46be850e7e31c5e8b0ffa75ef1d217516887fa" }, "dependencyClosures": { "wasm32": [ @@ -2478,7 +2478,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "1045094260a96f080ba96edea9c1ad457f02aa1ad2f69b78f52ed6c52478065c" + "wasm32": "3c5f12bc722762e45ef2f30741e2def167d4797051eaae874df2f455a2ef95f8" }, "dependencyClosures": { "wasm32": [ @@ -2515,7 +2515,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "35777ccb64bcda5784e2252fb02c7ed0b5ec5d9f73d9c10e5d1ddcea97699cd2" + "wasm32": "56ae667726be17a2b20e5a5a577d907fb8706f643bfb6703c44248b9de0dd192" }, "dependencyClosures": { "wasm32": [ @@ -2728,7 +2728,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0494df11f0e4e40e0138cca31e1a2fcd983b28e10392543fd8e1492609fd76a9" + "wasm32": "1fe39b11e701eeddeba9be5dcc7521f51df1b889fb7eb2ba35098f9a071dcc97" }, "dependencyClosures": { "wasm32": [] @@ -3020,7 +3020,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "445dc1e6893f39932e4c688040fb56584e5a9be6f1a1f0b0e0a745ac18cd5d2b" + "wasm32": "46a808b1ec1670f2611cd69d0ae1b27a6990c8e79a5b5c4b67b8fb35c26d4571" }, "dependencyClosures": { "wasm32": [ @@ -3082,7 +3082,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "0494df11f0e4e40e0138cca31e1a2fcd983b28e10392543fd8e1492609fd76a9" + "cacheKey": "1fe39b11e701eeddeba9be5dcc7521f51df1b889fb7eb2ba35098f9a071dcc97" }, { "packageName": "sqlite", diff --git a/run.sh b/run.sh index 5dbd6e1714..96759942c7 100755 --- a/run.sh +++ b/run.sh @@ -322,6 +322,7 @@ KERNEL_REQUIRED_EXPORTS=( kernel_set_current_tid kernel_set_cwd kernel_shmid_ds_bytes + kernel_publish_spawn_child kernel_spawn_exec_commit kernel_spawn_exec_target_prepare kernel_spawn_process diff --git a/scripts/resolve-binary.bundle.mjs b/scripts/resolve-binary.bundle.mjs index a05ddae790..9e7c09fd29 100644 --- a/scripts/resolve-binary.bundle.mjs +++ b/scripts/resolve-binary.bundle.mjs @@ -1,5 +1,5 @@ // Generated by scripts/build-resolve-binary-bundle.sh; do not edit. Third-party notices: resolve-binary.bundle.LICENSES.txt -var Fa=Object.defineProperty;var br=(n,e,t)=>()=>{if(t)throw t[0];try{return n&&(e=n(n=0)),e}catch(r){throw t=[r],r}};var qi=(n,e)=>{for(var t in e)Fa(n,t,{get:e[t],enumerable:!0})};var Bt,Zi,$t,Yi,gn,Xi,ne,ji,Ji,vr,Qi,En,eo,to,ro,no,Pr,kr,Sn,wn,On,An,zn,gt,Ut,Wt,Gt,Pe,io,oo,Fr,ze,so,Nr,xn,Cr,Mr,Et,Ht,Ge,In,Tn,ao,Dr,co,Y,uo,lo,Kr,Vt,fo,po,ho,X,mo,yo,Br,qt,_o,go,Rn,Ln,ot,St,bn,Zt,He,Eo,So,ie,wo,Oo,Ao,zo,Ve=br(()=>{"use strict";Bt="kandelo.wpk_fork.linked_frames",Zi=[75,76,67,70],$t=24,Yi=8,gn=3,Xi=[{bytes:4,chunkHeaderSize:32,nodeHeaderSize:24},{bytes:8,chunkHeaderSize:56,nodeHeaderSize:32}],ne="kandelo.wpk_fork.module_state",ji=1,Ji=[75,70,77,68],vr=24,Qi=8,En=7,eo=1,to=1,ro=1,no=[{bytes:4,chunkHeaderSize:40},{bytes:8,chunkHeaderSize:56}],Pr="__wpk_fork_global_",kr="__wpk_fork_table_",Sn=1,wn=2,On=3,An=4,zn=5,gt=6,Ut=7,Wt=8,Gt=9,Pe="kandelo.wpk_fork.capabilities",io=1,oo=7,Fr=4,ze="kandelo.wpk_fork.exception_codec",so=1,Nr=8,xn=16,Cr="env",Mr="__wpk_fork_unwind",Et="kandelo.wpk_fork.unwind_transport",Ht="__wpk_fork_static_root_catalog",Ge="kandelo.wpk_fork.static_root_catalog",In=1,Tn=0,ao=1,Dr=12,co=[75,70,83,82],Y="kandelo.wpk_fork.imported_globals",uo=[75,70,73,71],lo=1,Kr=16,Vt=24,fo=1,po=2,ho=3,X="kandelo.wpk_fork.imported_tables",mo=[75,70,73,84],yo=1,Br=16,qt=24,_o=1,go=1,Rn="env",Ln="__wpk_fork_module_activation",ot={module:"kernel",name:"kernel_fork",params:["i32"],results:["i32"]},St=[{module:"env",name:"__wpk_fork_frame_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_frame_next",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_peek",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_reserve",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_record_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_module_state_record_find",params:["i32","i32","i32","i32"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_record_reserve",params:["i32","i32","i32","ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_table_dirty_count",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_module_state_table_dirty_mark",params:["i32","i64","i64"],results:[]},{module:"env",name:"__wpk_fork_module_state_table_dirty_page",params:["i32","i32"],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_mutation_abort",params:[],results:[]},{module:"env",name:"__wpk_fork_module_state_table_mutation_begin",params:[],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_mutation_commit",params:["i32","i64","i64"],results:[]},{module:"env",name:"__wpk_fork_module_state_table_reconcile",params:[],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_state_owned",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_decode_funcref",params:["i32"],results:["funcref"]},{module:"env",name:"__wpk_fork_ref_encode_funcref",params:["funcref"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_broker_encode",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_broker_throw_recipe",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_cache_index",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_claim",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_define",params:["i32","i32","i32","i32","ptr","i32","ptr","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_ingress_throw",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_load",params:["i32","i32","i32","i32","ptr","i32","ptr","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_lookup",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_route",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_broker_encode",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_capture_layout",params:["i32","i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_claim",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_define",params:["i32","i32","i32","i32","i32","ptr","i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_i31",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_load",params:["i32","i32","i32","i32","i32","ptr","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_lookup",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_payload_len",params:["i32","i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_provenance_begin",params:["i32","i32","i32","i32","i64","i64","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_provenance_end",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_provenance_ref",params:["i32","i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_route",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_scratch_release",params:["ptr","ptr"],results:[]},{module:"env",name:"__wpk_fork_ref_scratch_reserve",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_ref_vector_append",params:["i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_vector_begin",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_vector_finish",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_vector_get",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_resume_peek",params:["i32"],results:["i32"]}],bn=[{module:"env",name:"__wpk_fork_ref_gc_transit",table64:!1,element:"anyref",minimum:1,maximum:null},{module:"env",name:"__wpk_fork_resume_table",table64:!1,element:"funcref",minimum:1,maximum:null}],Zt=[{name:"__wpk_fork_exception_materialize",params:["i32"],results:[]},{name:"__wpk_fork_ref_decode_exnref",params:["i32"],results:["exnref"]},{name:"__wpk_fork_ref_encode_exnref",params:["exnref"],results:["i32"]},{name:"__wpk_fork_ref_exn_abort",params:[],results:[]},{name:"__wpk_fork_ref_exn_clear",params:[],results:[]},{name:"__wpk_fork_ref_exn_encode_ingress",params:["i32"],results:["i32"]},{name:"__wpk_fork_ref_exn_throw_recipe",params:["i32"],results:[]},{name:"__wpk_fork_ref_exn_throw_slot",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_allocate",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_encode_slot",params:["i32"],results:["i32"]},{name:"__wpk_fork_ref_gc_fill",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_probe",params:["i32"],results:["i64"]},{name:"__wpk_fork_ref_gc_publish_externref",params:["i32","externref"],results:[]},{name:"__wpk_fork_static_root_harvest",params:[],results:[]},{name:"wpk_fork_abort_begin",params:["ptr"],results:[]},{name:"wpk_fork_abort_end",params:[],results:[]},{name:"wpk_fork_module_bootstrap",params:[],results:[]},{name:"wpk_fork_module_state_finish_restore",params:["i32"],results:[]},{name:"wpk_fork_module_state_restore",params:["i32"],results:[]},{name:"wpk_fork_module_state_save",params:["i32"],results:[]},{name:"wpk_fork_module_table_state_restore",params:["i32"],results:[]},{name:"wpk_fork_module_table_state_save",params:["i32"],results:[]},{name:"wpk_fork_module_thread_bootstrap",params:[],results:[]},{name:"wpk_fork_rewind_begin",params:["ptr"],results:[]},{name:"wpk_fork_rewind_end",params:[],results:[]},{name:"wpk_fork_state",params:[],results:["i32"]},{name:"wpk_fork_unwind_begin",params:["ptr"],results:[]},{name:"wpk_fork_unwind_end",params:[],results:[]}],He={O_RDONLY:0,O_WRONLY:1,O_RDWR:2,O_ACCMODE:3,O_CREAT:64,O_EXCL:128,O_NOCTTY:256,O_TRUNC:512,O_APPEND:1024,O_NONBLOCK:2048,O_ASYNC:8192,O_DIRECTORY:65536,O_NOFOLLOW:131072,O_CLOEXEC:524288,O_PATH:2097152,O_CLOFORK:8388608},Eo={F_OK:0,R_OK:4,W_OK:2,X_OK:1},So={ST_NOSUID:2},ie={S_IFMT:61440,S_IFSOCK:49152,S_IFLNK:40960,S_IFREG:32768,S_IFBLK:24576,S_IFDIR:16384,S_IFCHR:8192,S_IFIFO:4096,S_ISUID:2048,S_ISGID:1024,S_ISVTX:512,S_IRWXU:448,S_IRUSR:256,S_IWUSR:128,S_IXUSR:64,S_IRWXG:56,S_IRGRP:32,S_IWGRP:16,S_IXGRP:8,S_IRWXO:7,S_IROTH:4,S_IWOTH:2,S_IXOTH:1,S_MODE_BITS:4095},wo={DT_UNKNOWN:0,DT_FIFO:1,DT_CHR:2,DT_DIR:4,DT_BLK:6,DT_REG:8,DT_LNK:10,DT_SOCK:12},Oo=4096,Ao=["__abi_version","kernel_alloc_scratch","kernel_blocking_retry_release","kernel_blocking_retry_token","kernel_commit_process_exit","kernel_create_process","kernel_create_process_with_stdio","kernel_dequeue_signal","kernel_exec_commit","kernel_exec_target_cancel","kernel_exec_target_prepare","kernel_exec_target_read","kernel_exec_target_size","kernel_fork_process","kernel_get_cwd","kernel_get_dirfd_path","kernel_get_fd_path","kernel_get_parent_pid","kernel_get_process_exit_signal","kernel_get_process_state","kernel_get_socket_timeout_ms","kernel_handle_channel","kernel_has_sa_nocldstop","kernel_host_adapter_manifest_len","kernel_host_adapter_manifest_ptr","kernel_ipc_shm_lookup_mapping_for_task","kernel_ipc_shm_record_mapping_for_process","kernel_ipc_shm_record_mapping_for_task","kernel_ipc_shmat_for_process","kernel_ipc_shmat_for_task","kernel_ipc_shmdt_addr_for_process","kernel_ipc_shmdt_addr_for_task","kernel_ipc_shmdt_for_process","kernel_ipc_shmdt_for_task","kernel_is_fd_nonblock","kernel_mark_process_signaled","kernel_mq_descriptor_msgsize","kernel_msqid_ds_bytes","kernel_pcm_claim_transport","kernel_pcm_clock_update","kernel_pcm_reconcile","kernel_pcm_transport_len","kernel_pcm_transport_ptr","kernel_pick_signal_target_tid","kernel_pick_tcp_listener_target","kernel_pipe_has_readers","kernel_posix_timer_fire","kernel_process_metadata_begin","kernel_process_metadata_cancel","kernel_process_metadata_commit","kernel_process_metadata_stage","kernel_reap_exited_child","kernel_remove_process","kernel_semctl_array_bytes","kernel_semid_ds_bytes","kernel_set_current_tid","kernel_set_cwd","kernel_shmid_ds_bytes","kernel_spawn_exec_commit","kernel_spawn_exec_target_prepare","kernel_spawn_process","kernel_spawn_reserved_process","kernel_spawn_scratch_begin","kernel_spawn_scratch_cancel","kernel_spawn_scratch_capacity","kernel_spawn_scratch_pointer","kernel_spawn_scratch_retained_capacity","kernel_take_process_timer_cleanup","kernel_thread_exit","kernel_thread_has_deliverable","kernel_transfer_channel_execute","kernel_transfer_io_execute","kernel_transfer_scratch_begin","kernel_transfer_scratch_cancel","kernel_transfer_scratch_capacity","kernel_transfer_scratch_pointer","kernel_validate_task","kernel_wait_child_poll"],zo={LINK_MAX:0,MAX_CANON:1,MAX_INPUT:2,NAME_MAX:3,PATH_MAX:4,PIPE_BUF:5,CHOWN_RESTRICTED:6,NO_TRUNC:7,VDISABLE:8,SYNC_IO:9,ASYNC_IO:10,PRIO_IO:11,SOCK_MAXBUF:12,FILESIZEBITS:13,REC_INCR_XFER_SIZE:14,REC_MAX_XFER_SIZE:15,REC_MIN_XFER_SIZE:16,REC_XFER_ALIGN:17,ALLOC_SIZE_MIN:18,SYMLINK_MAX:19,POSIX2_SYMLINKS:20,FALLOC:21,TEXTDOMAIN_MAX:22,TIMESTAMP_RESOLUTION:23}});import{createRequire as Iu}from"module";function Ss(n,e){return Es(n,{i:2},e&&e.out,e&&e.dictionary)}var Tu,Pt,Ru,Lu,ae,bt,bu,fs,ps,vu,hs,Pt,ms,Pu,ys,ku,Af,ai,Ne,K,mr,yr,K,K,K,K,_s,K,Fu,Nu,oi,Te,si,gs,Jr,Cu,ge,Es,Mu,Du,vt,ws,Ku,Bu,ci=br(()=>{Tu=Iu("/");try{Pt=Tu("worker_threads"),Ru=Pt.Worker,Lu=Pt.isMarkedAsUntransferable}catch{}ae=Uint8Array,bt=Uint16Array,bu=Int32Array,fs=new ae([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),ps=new ae([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),vu=new ae([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),hs=function(n,e){for(var t=new bt(31),r=0;r<31;++r)t[r]=e+=1<>1|(K&21845)<<1,Ne=(Ne&52428)>>2|(Ne&13107)<<2,Ne=(Ne&61680)>>4|(Ne&3855)<<4,ai[K]=((Ne&65280)>>8|(Ne&255)<<8)>>1;mr=(function(n,e,t){for(var r=n.length,i=0,o=new bt(e);i>c]=u}else for(a=new bt(r),i=0;i>15-n[i]);return a}),yr=new ae(288);for(K=0;K<144;++K)yr[K]=8;for(K=144;K<256;++K)yr[K]=9;for(K=256;K<280;++K)yr[K]=7;for(K=280;K<288;++K)yr[K]=8;_s=new ae(32);for(K=0;K<32;++K)_s[K]=5;Fu=mr(yr,9,1),Nu=mr(_s,5,1),oi=function(n){for(var e=n[0],t=1;te&&(e=n[t]);return e},Te=function(n,e,t){var r=e/8|0;return(n[r]|n[r+1]<<8)>>(e&7)&t},si=function(n,e){var t=e/8|0;return(n[t]|n[t+1]<<8|n[t+2]<<16)>>(e&7)},gs=function(n){return(n+7)/8|0},Jr=function(n,e,t){return(e==null||e<0)&&(e=0),(t==null||t>n.length)&&(t=n.length),new ae(n.subarray(e,t))},Cu=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],ge=function(n,e,t){var r=new Error(e||Cu[n]);if(r.code=n,Error.captureStackTrace&&Error.captureStackTrace(r,ge),!t)throw r;return r},Es=function(n,e,t,r){var i=n.length,o=r?r.length:0;if(!i||e.f&&!e.l)return t||new ae(0);var s=!t,a=s||e.i!=2,c=e.i;s&&(t=new ae(i*3));var u=function(Ue){var We=t.length;if(Ue>We){var Lr=new ae(Math.max(We*2,Ue));Lr.set(t),t=Lr}},l=e.f||0,d=e.p||0,h=e.b||0,m=e.l,f=e.d,p=e.m,_=e.n,y=i*8;do{if(!m){l=Te(n,d,1);var g=Te(n,d+1,3);if(d+=3,g)if(g==1)m=Fu,f=Nu,p=9,_=5;else if(g==2){var w=Te(n,d,31)+257,z=Te(n,d+10,15)+4,x=w+Te(n,d+5,31)+1;d+=14;for(var I=new ae(x),R=new ae(19),L=0;L>4;if(E<16)I[L++]=E;else{var G=0,ue=0;for(E==16?(ue=3+Te(n,d,3),d+=2,G=I[L-1]):E==17?(ue=3+Te(n,d,7),d+=3):E==18&&(ue=11+Te(n,d,127),d+=7);ue--;)I[L++]=G}}var Oe=I.subarray(0,w),M=I.subarray(w);p=oi(Oe),_=oi(M),m=mr(Oe,p,1),f=mr(M,_,1)}else ge(1);else{var E=gs(d)+4,O=n[E-4]|n[E-3]<<8,S=E+O;if(S>i){c&&ge(0);break}a&&u(h+O),t.set(n.subarray(E,S),h),e.b=h+=O,e.p=d=S*8,e.f=l;continue}if(d>y){c&&ge(0);break}}a&&u(h+131072);for(var de=(1<>4;if(d+=G&15,d>y){c&&ge(0);break}if(G||ge(2),ve<256)t[h++]=ve;else if(ve==256){nt=d,m=null;break}else{var Kt=ve-254;if(ve>264){var L=ve-257,Be=fs[L];Kt=Te(n,d,(1<>4;yt||ge(3),d+=yt&15;var M=ku[Ae];if(Ae>3){var Be=ps[Ae];M+=si(n,d)&(1<y){c&&ge(0);break}a&&u(h+131072);var $e=h+Kt;if(h>3&1)+(e>>4&1);r>0;r-=!n[t++]);return t+(e&2)},vt=(function(){function n(e,t){typeof e=="function"&&(t=e,e={}),this.ondata=t;var r=e&&e.dictionary&&e.dictionary.subarray(-32768);this.s={i:0,b:r?r.length:0},this.o=new ae(32768),this.p=new ae(0),r&&this.o.set(r)}return n.prototype.e=function(e){if(this.ondata||ge(5),this.d&&ge(4),!this.p.length)this.p=e;else if(e.length){var t=new ae(this.p.length+e.length);t.set(this.p),t.set(e,this.p.length),this.p=t}},n.prototype.c=function(e){this.s.i=+(this.d=e||!1);var t=this.s.b,r=Es(this.p,this.s,this.o);this.ondata(Jr(r,t,this.s.b),this.d),this.o=Jr(r,this.s.b-32768),this.s.b=this.o.length,this.p=Jr(this.p,this.s.p/8|0),this.s.p&=7},n.prototype.push=function(e,t){this.e(e),this.c(t)},n})();ws=(function(){function n(e,t){this.v=1,this.r=0,vt.call(this,e,t)}return n.prototype.push=function(e,t){if(vt.prototype.e.call(this,e),this.r+=e.length,this.v){var r=this.p.subarray(this.v-1),i=r.length>3?Du(r):4;if(i>r.length){if(!t)return}else this.v>1&&this.onmember&&this.onmember(this.r-r.length);this.p=r.subarray(i),this.v=0}vt.prototype.c.call(this,0),this.s.f&&!this.s.l?(this.v=gs(this.s.p)+9,this.s={i:0},this.o=new ae(0),this.push(new ae(0),t)):t&&vt.prototype.c.call(this,t)},n})(),Ku=typeof TextDecoder<"u"&&new TextDecoder,Bu=0;try{Ku.decode(Mu,{stream:!0}),Bu=1}catch{}});var li={};qi(li,{extractZipEntry:()=>Zu,extractZipEntryBounded:()=>Yu,fetchZipCentralDirectory:()=>ju,parseZipCentralDirectory:()=>_r});function Ts(n){let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=Math.max(0,n.length-zs);for(let r=n.length-Wu;r>=t;r--)if(e.getUint32(r,!0)===$u)return r;throw new Error("Zip EOCD record not found")}function _r(n){let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=Ts(n),r=e.getUint16(t+10,!0),i=e.getUint32(t+16,!0),o=[],s=i;for(let a=0;a>8,O;E===Os?O=p>>16&65535:g.startsWith("bin/")||g.startsWith("sbin/")||g.includes("/bin/")||g.includes("/sbin/")?O=493:O=420;let S=g.endsWith("/"),w=E===Os&&(O&Hu)===Gu;o.push({fileName:g,fileNameBytes:y,compressedSize:l,uncompressedSize:d,compressionMethod:u,localHeaderOffset:_,mode:O,isDirectory:S,isSymlink:w,externalAttrs:p,creatorOS:E}),s+=ui+h+m+f}return o}function Rs(n,e){if(n.byteLength!==e.byteLength)return!1;for(let t=0;t{if(a.byteLength>t-o)throw new Error(`ZIP member ${e.fileName} expands beyond ${t} bytes`);i.set(a,o),o+=a.byteLength}).push(r,!0),o!==t)throw new Error(`ZIP member ${e.fileName} expanded ${o} bytes, expected ${t}`);return i}function Xu(n,e){let t=new DataView(n.buffer,n.byteOffset,n.byteLength),r=e.localHeaderOffset;if(r<0||r>n.byteLength-di||t.getUint32(r,!0)!==As)throw new Error(`Invalid local file header signature at offset ${r}`);let i=t.getUint16(r+8,!0),o=t.getUint16(r+26,!0),s=t.getUint16(r+28,!0),a=r+di,c=a+o+s,u=c+e.compressedSize;if(i!==e.compressionMethod||cn.byteLength||!Rs(n.subarray(a,a+o),e.fileNameBytes))throw new Error(`ZIP member ${e.fileName} has inconsistent local metadata`);return n.subarray(c,u)}async function ju(n){let e=await fetch(n,{method:"HEAD"});if(!e.ok)throw new Error(`HEAD request failed: ${e.status} ${e.statusText}`);let t=parseInt(e.headers.get("content-length")||"0",10),r=e.headers.get("accept-ranges");if(!t||r!=="bytes"){let y=await fetch(n);if(!y.ok)throw new Error(`Fetch failed: ${y.status} ${y.statusText}`);let g=new Uint8Array(await y.arrayBuffer());return{entries:_r(g),totalSize:g.length}}let i=Math.min(t,zs),o=t-i,s=await fetch(n,{headers:{Range:`bytes=${o}-${t-1}`}});if(s.status!==206){let y=await fetch(n);if(!y.ok)throw new Error(`Fetch failed: ${y.status} ${y.statusText}`);let g=new Uint8Array(await y.arrayBuffer());return{entries:_r(g),totalSize:g.length}}let a=new Uint8Array(await s.arrayBuffer()),c=new DataView(a.buffer,a.byteOffset,a.byteLength),u=Ts(a),l=c.getUint32(u+12,!0),d=c.getUint32(u+16,!0);if(d>=o){let y=t,g=new Uint8Array(y);return g.set(a,o),{entries:_r(g),totalSize:y}}let h=d+l-1,m=await fetch(n,{headers:{Range:`bytes=${d}-${h}`}});if(m.status!==206)throw new Error(`Range request for CD failed: ${m.status}`);let f=new Uint8Array(await m.arrayBuffer()),p=t,_=new Uint8Array(p);return _.set(f,d),_.set(a,o),{entries:_r(_),totalSize:p}}var $u,Uu,As,zs,Wu,ui,di,xs,Is,Os,Gu,Hu,Vu,qu,fi=br(()=>{"use strict";ci();Ve();$u=101010256,Uu=33639248,As=67324752,zs=65557,Wu=22,ui=46,di=30,xs=0,Is=8,Os=3,{S_IFLNK:Gu,S_IFMT:Hu}=ie,Vu=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),qu=new TextEncoder});var Ns={};qi(Ns,{DEFAULT_TAR_GZIP_LIMITS:()=>Fs,TarParseError:()=>b,parseTarGzip:()=>td});function td(n,e={}){let t=e.label??"TAR gzip archive",r=nd(e.limits,t);if(n.byteLength===0||n.byteLength>r.maxCompressedBytes)throw new b(`${t}: compressed byte count ${n.byteLength} is outside 1..${r.maxCompressedBytes}`);let i=id(n,t);if(i===0||i>r.maxUncompressedBytes)throw new b(`${t}: declared uncompressed byte count ${i} is outside 1..${r.maxUncompressedBytes}`);let o=od(n,t,i);if(o.byteLength!==i)throw new b(`${t}: gzip expanded to ${o.byteLength} bytes, expected ${i}`);let s=new DataView(n.buffer,n.byteOffset,n.byteLength).getUint32(n.byteLength-8,!0);if(sd(o)!==s)throw new b(`${t}: gzip CRC32 mismatch`);return rd(o,t,r)}function rd(n,e,t){if(n.byteLength%Ce!==0)throw new b(`${e}: TAR byte count is not block-aligned`);let r=[],i=0,o=0,s=0,a=null,c={},u=!1;for(;i+Ce<=n.byteLength;){let l=n.subarray(i,i+Ce);if(i+=Ce,hi(l)){if(i+Ce>n.byteLength)throw new b(`${e}: TAR end marker is truncated`);let S=n.subarray(i,i+Ce);if(!hi(S))throw new b(`${e}: TAR has only one zero end block`);if(i+=Ce,!hi(n.subarray(i)))throw new b(`${e}: TAR has nonzero data after its end marker`);u=!0;break}dd(l,e);let d=gr(l,156,1,e)||"0",h=yi(l,124,12,`${e}: TAR entry size`),m=yi(l,100,8,`${e}: TAR entry mode`)&Ju,f=ld(l,e,t.maxPathBytes),p=gr(l,157,100,e);if(d==="x"||d==="g"){if(s+=1,s>t.maxEntries+1)throw new b(`${e}: TAR extension header count exceeds ${t.maxEntries+1}`);let S=bs(n,i,h,e);i=vs(i,h,n.byteLength,e);let w=cd(S,e,t);d==="x"?a=w:c={...c,...w};continue}if(o+=1,o>t.maxEntries)throw new b(`${e}: TAR entry count exceeds ${t.maxEntries}`);let _={...c,...a??{}};a=null;let y=_.size===void 0?h:ud(_.size,`${e}: PAX entry size`),g=bs(n,i,y,e);i=vs(i,y,n.byteLength,e);let E=mi(_.path??f,e,t.maxPathBytes),O=_.linkpath??p;switch(d){case"0":case"\0":r.push({path:E,type:"file",mode:m,data:g});break;case"5":pi(y,e,"directory",E),r.push({path:E,type:"directory",mode:m});break;case"2":pi(y,e,"symlink",E),Ps(O,e,E,t.maxLinkBytes,!1),r.push({path:E,type:"symlink",mode:m,linkName:O});break;case"1":pi(y,e,"hardlink",E),Ps(O,e,E,t.maxLinkBytes,!0),r.push({path:E,type:"hardlink",mode:m,linkName:mi(O,`${e}: hardlink target`,t.maxPathBytes)});break;case"3":case"4":case"6":throw new b(`${e}: unsupported TAR device/FIFO entry ${E}`);default:throw new b(`${e}: unsupported TAR entry type ${JSON.stringify(d)} for ${E}`)}}if(!u)throw new b(`${e}: TAR is missing its two-block end marker`);if(a!==null)throw new b(`${e}: local PAX header has no following entry`);return r}function nd(n,e){let t={...Fs,...n};for(let[r,i]of Object.entries(t))if(!Number.isSafeInteger(i)||i<=0)throw new b(`${e}: ${r} must be a positive safe integer`);return t}function id(n,e){if(n.byteLength<18||n[0]!==31||n[1]!==139||n[2]!==8)throw new b(`${e}: invalid gzip header`);return new DataView(n.buffer,n.byteOffset,n.byteLength).getUint32(n.byteLength-4,!0)}function od(n,e,t){let r=new Uint8Array(t),i=0,o=!1,s=new ws(a=>{if(a.byteLength>t-i)throw new b(`${e}: gzip expansion exceeds its declared ${t} bytes`);r.set(a,i),i+=a.byteLength});s.onmember=()=>{throw o=!0,new b(`${e}: concatenated gzip members are unsupported`)};try{s.push(n,!0)}catch(a){throw a instanceof b?a:new b(`${e}: cannot gunzip archive: ${pd(a)}`)}if(o)throw new b(`${e}: concatenated gzip members are unsupported`);return r.subarray(0,i)}function sd(n){let e=4294967295;for(let t of n)e=ed[(e^t)&255]^e>>>8;return(e^4294967295)>>>0}function ad(){let n=new Uint32Array(256);for(let e=0;e>>1^((t&1)===0?0:3988292384);n[e]=t>>>0}return n}function bs(n,e,t,r){if(t>n.byteLength-e)throw new b(`${r}: TAR entry is truncated`);return n.subarray(e,e+t)}function vs(n,e,t,r){let o=Math.ceil(e/Ce)*Ce;if(!Number.isSafeInteger(o)||o>t-n)throw new b(`${r}: TAR entry padding is truncated`);return n+o}function cd(n,e,t){let r={},i=0;for(;i9)throw new b(`${e}: invalid PAX record length`);if(s=s*10+p,!Number.isSafeInteger(s))throw new b(`${e}: invalid PAX record length`)}let a=i+s;if(s<=o-i+2||a>n.byteLength||n[a-1]!==10)throw new b(`${e}: truncated PAX record`);let c=o+1;for(;c=a-1)throw new b(`${e}: invalid PAX record`);let u=n.subarray(o+1,c);if(u.byteLength>256)throw new b(`${e}: PAX record key is too long`);let l=_i(u,`${e}: PAX record key`),d=n.subarray(c+1,a-1),h=l==="path"?t.maxPathBytes:l==="linkpath"?t.maxLinkBytes:l==="size"?32:0;if(h===0){i=a;continue}if(d.byteLength>h)throw new b(`${e}: PAX ${l} value is too long`);let m=_i(d,`${e}: PAX record value`);r[l]=m,i=a}return r}function ud(n,e){if(!/^(0|[1-9][0-9]*)$/.test(n))throw new b(`${e} is invalid`);let t=Number(n);if(!Number.isSafeInteger(t)||t<0)throw new b(`${e} is invalid`);return t}function dd(n,e){let t=yi(n,148,8,`${e}: TAR checksum`),r=0;for(let i=0;i=148&&i<156?32:n[i];if(t!==r)throw new b(`${e}: TAR checksum mismatch`)}function ld(n,e,t){let r=gr(n,0,100,e),i=gr(n,345,155,e);return mi(i?`${i}/${r}`:r,e,t)}function mi(n,e,t){let r=n;for(;r.startsWith("./");)r=r.slice(2);return r=r.replace(/\/+$/g,""),fd(r,`${e}: TAR path`,t),r}function gr(n,e,t,r){let i=e,o=e+t;for(;ir||n.includes("\0"))throw new b(`${e}: link target for ${t} is invalid`);if(i&&n.includes("\\"))throw new b(`${e}: hardlink target for ${t} is invalid`)}function fd(n,e,t){if(n.length===0||n.startsWith("/")||n.includes("\0")||n.includes("\\")||ks.encode(n).byteLength>t)throw new b(`${e} ${JSON.stringify(n)} must be a bounded relative POSIX path`);for(let r of n.split("/"))if(r.length===0||r==="."||r==="..")throw new b(`${e} ${JSON.stringify(n)} contains an unsafe path segment`)}function hi(n){for(let e of n)if(e!==0)return!1;return!0}function _i(n,e){try{return Qu.decode(n)}catch{throw new b(`${e} contains non-UTF-8 text`)}}function pd(n){return n instanceof Error?n.message:String(n)}var Ce,Ju,Ls,Qu,ks,ed,Fs,b,Cs=br(()=>{"use strict";ci();Ve();Ce=512,Ju=ie.S_MODE_BITS,Ls=1024*1024,Qu=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),ks=new TextEncoder,ed=ad(),Fs=Object.freeze({maxCompressedBytes:256*Ls,maxUncompressedBytes:512*Ls,maxEntries:1e5,maxPathBytes:4096,maxLinkBytes:65536}),b=class extends Error{constructor(e){super(e),this.name="TarParseError"}}});import{existsSync as xr,lstatSync as yn,readdirSync as Oa,readFileSync as pt,realpathSync as Le,statSync as rt}from"node:fs";import{createHash as Aa}from"node:crypto";import{spawnSync as Di}from"node:child_process";import{basename as Sl,dirname as Tr,isAbsolute as _n,join as U,relative as wl,resolve as Re,sep as Ol}from"node:path";import{fileURLToPath as Al}from"node:url";Ve();var Ca=Uint8Array.from(co);function T(n,e){let t=0,r=0,i=e;for(;;){let o=n[i++];if(t|=(o&127)<=n.length)throw new Error(`${r} is truncated`);if((n[e++]&128)===0)return e}throw new Error(`${r} has an overlong LEB128 encoding`)}function xe(n,e,t){if(e>=n.length)throw new Error(`${t} is truncated`);let r=n[e++];switch(r){case 127:case 126:case 125:case 124:case 123:case 117:case 116:case 115:case 114:case 113:case 112:case 111:case 110:case 109:case 108:case 107:case 106:case 105:case 104:return{code:r,shared:!1,next:e};case 98:case 99:case 100:{let i=n[e]===101;i&&e++;let o=vo(n,e,5,`${t} heap type`),[s]=bo(n,e);return{code:r,heapType:Number(s),shared:i,next:o}}default:throw new Error(`${t} has unknown value type 0x${r.toString(16)}`)}}function Ma(n,e,t){return n[e]===120||n[e]===119?{code:n[e],shared:!1,next:e+1}:xe(n,e,t)}function Da(n,e){let t=n[e];if(t===64||t===127||t===126||t===125||t===124||t===123||t===112||t===111)return e+1;let[,r]=Fn(n,e);return e+r}function Ka(n,e,t){let[r,i]=T(n,e);e+=i;let o=[],s=[];for(let d=0;d=n.length)throw new Error(`${t} mutability is truncated`);let i=n[e++];if(i!==0&&i!==1)throw new Error(`${t} has invalid mutability ${i}`);return e}function Ba(n,e,t,r){if(e===101){if(t>=n.length)throw new Error(`${r} shared type is truncated`);e=n[t++]}for(let i of[76,77]){if(e!==i)continue;let[,o]=T(n,t);if(t+=o,t>=n.length)throw new Error(`${r} descriptor is truncated`);e=n[t++]}if(e===96)return Ka(n,t,r);if(e===95){let[i,o]=T(n,t);t+=o;for(let s=0;s=n.length)throw new Error(`${t} is truncated`);let r=n[e++];if(r===79||r===80){let[i,o]=T(n,e);e+=o;for(let s=0;s=n.length)throw new Error(`${t} body is truncated`);r=n[e++]}return Ba(n,r,e,t)}function $a(n,e){let[t,r]=T(n,e);e+=r;let i=[];for(let o=0;o=21&&r<=34?Xt(e,t):r===84||r>=92&&r<=99||r>=112&&r<=123||r>=124&&r<=131||r>=156&&r<=159?t+1:t:n===254?r===0||r===1||r===2?Xt(e,t):r===3?t:r>=16&&r<=79?Xt(e,t):null:null}function Wa(n,e,t){let[r,i]=T(n,e);e+=i+r;let[o,s]=T(n,e);e+=s+o;let a=n[e++];if(a===0){t.funcImports++;let[,c]=T(n,e);e+=c}else if(a===1)e=xe(n,e,"table import type").next,e=Ze(n,e).next;else if(a===2)e=Ze(n,e).next;else if(a===3)t.globalImports++,e=xe(n,e,"global import type").next,e++;else if(a===4){e++;let[,c]=T(n,e);e+=c}return e}function $r(n){return n.length>=8&&n[0]===0&&n[1]===97&&n[2]===115&&n[3]===109}function qe(n,e){let[t,r]=T(n,e);return e+=r,[new TextDecoder().decode(n.subarray(e,e+t)),e+t]}function Ga(n,e){if(e.length===0)return!0;let t=new TextEncoder().encode(e);e:for(let r=0;r<=n.length-t.length;r++){for(let i=0;in);function To(n,e){switch(n.code){case 127:return Sn;case 126:return wn;case 125:return On;case 124:return An;case 123:return zn;case 112:case 115:return gt;case 111:case 114:return Ut;case 105:case 116:return Wt;case 104:case 106:case 107:case 108:case 109:case 110:case 113:case 117:return Gt;case 98:case 99:case 100:{let t=n.heapType;return t===void 0?null:t===-16||t===-13?gt:t===-17||t===-14?Ut:t===-23||t===-12?Wt:t>=0&&e[t]!==void 0?gt:Gt}default:return null}}function vn(n,e,t){if(!t)throw new Error(`function ${e} refers to an unknown type`);let r=n.get(e)??[];r.push(t),n.set(e,r)}function Yt(n,e,t){let r=n.get(e)??[];r.push(t),n.set(e,r)}function Ze(n,e){let[t,r]=T(n,e);e+=r;let[i,o]=T(n,e);e+=o;let s=null;if((t&1)!==0){let[a,c]=T(n,e);e+=c,s=a}return{flags:t,minimum:i,maximum:s,next:e}}function Va(n){let e=new Uint8Array(n);if(!$r(e))throw new Error("not a wasm binary");let t=[],r=[],i=[],o={functionTypes:t,functionTypeIndices:r,functionImports:new Map,functionImportEntries:[],globalImports:new Map,tableImports:new Map,tables:[],tagImports:new Map,functionExports:new Map,globalExports:new Map,tableExports:new Map,exports:new Map,memoryPointerWidths:[],forkCapabilities:[],linkedFrameDescriptors:[],exceptionCodecDescriptors:[],importedGlobalsDescriptors:[],importedTablesDescriptors:[],moduleStateDescriptors:[],staticRootDescriptors:[],unwindTransportDescriptors:[],nativeStartCount:0,importsKernelFork:!1},s=0,a=0,c=8;for(;ce.length)throw new Error("wasm section exceeds file size");let f=h,p=!1;if(u===0){let[_,y]=qe(e,f);_===Bt?o.linkedFrameDescriptors.push(e.slice(y,m)):_===Pe?o.forkCapabilities.push(e.slice(y,m)):_===ze?o.exceptionCodecDescriptors.push(e.slice(y,m)):_===Y?o.importedGlobalsDescriptors.push(e.slice(y,m)):_===X?o.importedTablesDescriptors.push(e.slice(y,m)):_===ne?o.moduleStateDescriptors.push(e.slice(y,m)):_===Ge?o.staticRootDescriptors.push(e.slice(y,m)):_===Et&&o.unwindTransportDescriptors.push(e.slice(y,m))}else if(u===1){p=!0;let _=$a(e,f);t.push(..._.types),f=_.next}else if(u===2){p=!0;let[_,y]=T(e,f);f+=y;for(let g=0;g<_;g++){let[E,O]=qe(e,f),[S,w]=qe(e,O);f=w;let z=e[f++];if(z===0){let[x,I]=T(e,f);f+=I;let R=r.length;r.push(x);let L=`${E}.${S}`,v=t[x];vn(o.functionImports,L,v),o.functionImportEntries.push({module:E,name:S,importOrdinal:g,functionIndex:R,signature:v}),L==="kernel.kernel_fork"&&(o.importsKernelFork=!0)}else if(z===1){let x=xe(e,f,`table import ${E}.${S}`);f=x.next;let I=Ze(e,f);f=I.next,Yt(o.tableImports,`${E}.${S}`,{module:E,name:S,importOrdinal:g,index:a++,elementType:x.code,recipeTypeCode:To(x,t),table64:(I.flags&4)!==0,minimum:I.minimum,maximum:I.maximum}),o.tables.push({elementType:x.code,table64:(I.flags&4)!==0,minimum:I.minimum,maximum:I.maximum})}else if(z===2){let x=Ze(e,f);f=x.next,o.memoryPointerWidths.push((x.flags&4)!==0?8:4)}else if(z===3){let x=xe(e,f,`global import ${E}.${S}`);if(f=x.next,f>=e.length)throw new Error(`global import ${E}.${S} is truncated`);let I=e[f++];if((I&-4)!==0)throw new Error(`global import ${E}.${S} has invalid flags ${I}`);Yt(o.globalImports,`${E}.${S}`,{module:E,name:S,importOrdinal:g,index:s++,valueType:x.code,recipeTypeCode:To(x,t),mutable:(I&1)!==0,shared:(I&2)!==0})}else if(z===4){let x=e[f++];if(x!==0)throw new Error(`unsupported wasm tag attribute ${x}`);let[I,R]=T(e,f);f+=R,vn(o.tagImports,`${E}.${S}`,t[I])}else throw new Error(`unsupported wasm import kind ${z}`)}}else if(u===3){p=!0;let[_,y]=T(e,f);f+=y;for(let g=0;g<_;g++){let[E,O]=T(e,f);f+=O,r.push(E)}}else if(u===4){p=!0;let[_,y]=T(e,f);f+=y;for(let g=0;g<_;g++){let E=xe(e,f,`defined table ${g}`);f=E.next;let O=Ze(e,f);f=O.next,o.tables.push({elementType:E.code,table64:(O.flags&4)!==0,minimum:O.minimum,maximum:O.maximum})}}else if(u===5){p=!0;let[_,y]=T(e,f);f+=y;for(let g=0;g<_;g++){let E=Ze(e,f);f=E.next,o.memoryPointerWidths.push((E.flags&4)!==0?8:4)}}else if(u===7){p=!0;let[_,y]=T(e,f);f+=y;for(let g=0;g<_;g++){let[E,O]=qe(e,f);f=O;let S=e[f++],[w,z]=T(e,f);f+=z,Yt(o.exports,E,{kind:S,index:w}),S===0?i.push({name:E,index:w}):S===3?Yt(o.globalExports,E,w):S===1&&Yt(o.tableExports,E,w)}}else if(u===8){p=!0,o.nativeStartCount++;let[,_]=T(e,f);f+=_}if(p&&f!==m)throw new Error(`malformed wasm section ${u}`);c=m}for(let{name:u,index:l}of i){let d=r[l];vn(o.functionExports,u,t[d])}return o}function qa(n){if(n.byteLength!==$t)throw new Error(`linked-frame descriptor has ${n.byteLength} bytes, expected ${$t}`);if(!Zi.every((a,c)=>n[c]===a))throw new Error("linked-frame descriptor has invalid magic");let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=e.getUint16(4,!0);if(t!==1)throw new Error(`linked-frame descriptor version ${t} is unsupported`);let r=e.getUint16(6,!0);if(r!==$t)throw new Error(`linked-frame descriptor declares size ${r}, expected ${$t}`);let i=e.getUint8(8),o=Xi.find(({bytes:a})=>a===i);if(!o)throw new Error(`linked-frame descriptor pointer width ${i} is unsupported`);if(e.getUint8(9)!==Yi)throw new Error(`linked-frame descriptor alignment ${e.getUint8(9)} is unsupported`);let s=e.getUint16(10,!0);if(s!==gn)throw new Error(`linked-frame descriptor flags 0x${s.toString(16)} do not equal required flags 0x${gn.toString(16)}`);if(e.getUint32(12,!0)!==o.chunkHeaderSize||e.getUint32(16,!0)!==o.nodeHeaderSize)throw new Error(`linked-frame descriptor header sizes do not match its ${i}-byte pointer width`);return o.bytes}function Za(n){if(n.length===0)return[`missing required ${Pe} capability`];if(n.length!==1)return[`has ${n.length} ${Pe} sections, expected exactly one`];let e=n[0];if(e.byteLength!==2)return[`${Pe} has ${e.byteLength} bytes, expected 2`];if(e[0]!==io)return[`${Pe} version ${e[0]} is unsupported`];let t=e[1];return(t&~oo)!==0?[`${Pe} has unknown flags 0x${t.toString(16)}`]:(t&Fr)!==Fr?[`${Pe} flags 0x${t.toString(16)} omit required activation-state safety flags 0x${Fr.toString(16)}`]:[]}function Ya(n){let e=[],t=`${Cr}.${Mr}`,r=n.tagImports.get(t);if(r?r.length!==1?e.push(`duplicate private fork-unwind tag import ${t}`):(r[0].params.length!==0||r[0].results.length!==0)&&e.push(`private fork-unwind tag ${t} must have an empty payload`):e.push(`missing required private fork-unwind tag import ${t}`),n.unwindTransportDescriptors.length===0)e.push(`missing required ${Et} descriptor`);else if(n.unwindTransportDescriptors.length!==1)e.push(`has ${n.unwindTransportDescriptors.length} ${Et} descriptors, expected exactly one`);else{let i=n.unwindTransportDescriptors[0];(i.length!==2||i[0]!==In||i[1]!==Tn)&&e.push(`${Et} must be [${In}, ${Tn}]`)}return e}function Xa(n,e){if(n.length===0)return[`missing required ${ne} descriptor`];if(n.length!==1)return[`has ${n.length} ${ne} descriptors, expected exactly one`];let t=n[0];if(t.byteLength!==vr)return[`${ne} has ${t.byteLength} bytes, expected ${vr}`];if(!Ji.every((p,_)=>t[_]===p))return[`${ne} has invalid magic`];let r=new DataView(t.buffer,t.byteOffset,t.byteLength),i=r.getUint16(4,!0),o=r.getUint16(6,!0),s=r.getUint8(8),a=no.find(({bytes:p})=>p===s),c=r.getUint8(9),u=r.getUint16(10,!0),l=r.getUint16(12,!0),d=r.getUint16(14,!0),h=r.getUint32(16,!0),m=r.getUint32(20,!0),f=[];return i!==ji&&f.push(`${ne} version ${i} is unsupported`),o!==vr&&f.push(`${ne} declares size ${o}`),a?e!==null&&s!==e&&f.push(`${ne} pointer width ${s} does not match linked frames ${e}`):f.push(`${ne} pointer width ${s} is unsupported`),c!==Qi&&f.push(`${ne} alignment ${c} is unsupported`),u!==En&&f.push(`${ne} flags 0x${u.toString(16)} do not equal required flags 0x${En.toString(16)}`),l!==eo&&f.push(`${ne} arena version ${l} is unsupported`),d!==to&&f.push(`${ne} record version ${d} is unsupported`),h!==ro&&f.push(`${ne} root word ${h} is unsupported`),m!==0&&f.push(`${ne} reserved field is nonzero`),f}function ja(n){if(n.length===0)return[`missing required ${ze} descriptor`];if(n.length!==1)return[`has ${n.length} ${ze} descriptors, expected exactly one`];let e=n[0];if(e.byteLength2147483647||s.has(l))&&r.push(`${ze} layout id ${l} is invalid or duplicated`),s.add(l)}return r}var Ja=new Set([Sn,wn,On,An,zn,gt,Ut,Wt,Gt]);function Ro(n){return!(n.module===Rn&&(n.name===Ln||n.name==="__channel_base"||n.name==="__wpk_fork_module_state_table_generation_addr"))}function Qa(n){let e=n.importedGlobalsDescriptors;if(e.length===0)return[`missing required ${Y} descriptor`];if(e.length!==1)return[`has ${e.length} ${Y} descriptors, expected exactly one`];let t=e[0];if(t.byteLengtht[_]===p)||i.push(`${Y} has invalid magic`),r.getUint16(4,!0)!==lo&&i.push(`${Y} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Kr&&i.push(`${Y} declares an invalid header size`);let o=r.getUint32(8,!0);r.getUint32(12,!0)!==0&&i.push(`${Y} reserved field is nonzero`);let s=new Set,a=new Set,c=new TextDecoder("utf-8",{fatal:!0}),u=[],l=-1,d=Kr;for(let p=0;pt.byteLength)return i.push(`${Y} record ${p} header is truncated`),i;let _=r.getUint32(d,!0),y=r.getUint32(d+4,!0),g=r.getUint8(d+8),E=r.getUint8(d+9),O=r.getUint32(d+12,!0),S=r.getUint32(d+16,!0),w=r.getUint32(d+20,!0),z=Vt+O+S;if(!Number.isSafeInteger(z)||_!==z||_t.byteLength)return i.push(`${Y} record ${p} has invalid bounds`),i;(y===0||s.has(y))&&i.push(`${Y} record ${p} has invalid or duplicated owner ${y}`),s.add(y),Ja.has(g)||i.push(`${Y} record ${p} has unknown value type ${g}`),(E&~ho)!==0&&i.push(`${Y} record ${p} has unknown flags 0x${E.toString(16)}`),r.getUint16(d+10,!0)!==0&&i.push(`${Y} record ${p} reserved fields are nonzero`),(a.has(w)||w<=l)&&i.push(`${Y} record ${p} has duplicated or unordered import ordinal`),a.add(w),l=w;let x=d+Vt;try{let I=c.decode(t.subarray(x,x+O)),R=c.decode(t.subarray(x+O,x+O+S));u.push({ownerId:y,typeCode:g,flags:E,importOrdinal:w,module:I,name:R})}catch{i.push(`${Y} record ${p} contains invalid UTF-8`)}d+=_}d!==t.byteLength&&i.push(`${Y} has trailing bytes`);let h=[...n.globalImports.values()].flat(),m=new Map(h.map(p=>[p.index,p])),f=new Set;for(let p of u){let _=`${Pr}${p.ownerId}`,y=n.exports.get(_);if(!y||y.length!==1||y[0].kind!==3){i.push(`${Y} owner ${p.ownerId} lacks exactly one global catalog export ${_}`);continue}let g=m.get(y[0].index);if(!g||!Ro(g)){i.push(`${Y} owner ${p.ownerId} does not identify a reconstructible imported global`);continue}if(g.module!==p.module||g.name!==p.name||g.importOrdinal!==p.importOrdinal||g.recipeTypeCode!==p.typeCode||g.mutable!==((p.flags&fo)!==0)||g.shared!==((p.flags&po)!==0)){i.push(`${Y} owner ${p.ownerId} does not match its imported global declaration`);continue}if(f.has(g.index)){i.push(`${Y} repeats imported global index ${g.index}`);continue}f.add(g.index)}for(let p of h)Ro(p)&&!f.has(p.index)&&i.push(`${Y} omits imported global ${p.module}.${p.name} at index ${p.index}`);for(let[p,_]of n.exports){if(!p.startsWith(Pr))continue;let y=p.slice(Pr.length),g=Number(y);(!/^[1-9][0-9]*$/.test(y)||!Number.isSafeInteger(g)||g>4294967295||_.length!==1||_[0].kind!==3)&&i.push(`malformed reserved fork global catalog export ${p}`)}return i}var ec=new Set([gt,Ut,Wt,Gt]);function Lo(n){return!bn.some(({module:e,name:t})=>n.module===e&&n.name===t)}function tc(n){let e=n.importedTablesDescriptors;if(e.length===0)return[`missing required ${X} descriptor`];if(e.length!==1)return[`has ${e.length} ${X} descriptors, expected exactly one`];let t=e[0];if(t.byteLengtht[_]===p)||i.push(`${X} has invalid magic`),r.getUint16(4,!0)!==yo&&i.push(`${X} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Br&&i.push(`${X} declares an invalid header size`);let o=r.getUint32(8,!0);r.getUint32(12,!0)!==0&&i.push(`${X} reserved field is nonzero`);let s=new Set,a=new Set,c=new TextDecoder("utf-8",{fatal:!0}),u=[],l=-1,d=Br;for(let p=0;pt.byteLength)return i.push(`${X} record ${p} header is truncated`),i;let _=r.getUint32(d,!0),y=r.getUint32(d+4,!0),g=r.getUint8(d+8),E=r.getUint8(d+9),O=r.getUint32(d+12,!0),S=r.getUint32(d+16,!0),w=r.getUint32(d+20,!0),z=qt+O+S;if(!Number.isSafeInteger(z)||_!==z||_t.byteLength)return i.push(`${X} record ${p} has invalid bounds`),i;(y===0||s.has(y))&&i.push(`${X} record ${p} has invalid or duplicated owner ${y}`),s.add(y),ec.has(g)||i.push(`${X} record ${p} has unknown element type ${g}`),(E&~go)!==0&&i.push(`${X} record ${p} has unknown flags 0x${E.toString(16)}`),r.getUint16(d+10,!0)!==0&&i.push(`${X} record ${p} reserved fields are nonzero`),(a.has(w)||w<=l)&&i.push(`${X} record ${p} has duplicated or unordered import ordinal`),a.add(w),l=w;let x=d+qt;try{let I=c.decode(t.subarray(x,x+O)),R=c.decode(t.subarray(x+O,x+O+S));u.push({ownerId:y,typeCode:g,flags:E,importOrdinal:w,module:I,name:R})}catch{i.push(`${X} record ${p} contains invalid UTF-8`)}d+=_}d!==t.byteLength&&i.push(`${X} has trailing bytes`);let h=[...n.tableImports.values()].flat(),m=new Map(h.map(p=>[p.index,p])),f=new Set;for(let p of u){let _=`${kr}${p.ownerId}`,y=n.exports.get(_);if(!y||y.length!==1||y[0].kind!==1){i.push(`${X} owner ${p.ownerId} lacks exactly one table catalog export ${_}`);continue}let g=m.get(y[0].index);if(!g||!Lo(g)){i.push(`${X} owner ${p.ownerId} does not identify a reconstructible imported table`);continue}if(g.module!==p.module||g.name!==p.name||g.importOrdinal!==p.importOrdinal||g.recipeTypeCode!==p.typeCode||g.table64!==((p.flags&_o)!==0)){i.push(`${X} owner ${p.ownerId} does not match its imported table declaration`);continue}if(f.has(g.index)){i.push(`${X} repeats imported table index ${g.index}`);continue}f.add(g.index)}for(let p of h)Lo(p)&&!f.has(p.index)&&i.push(`${X} omits imported table ${p.module}.${p.name} at index ${p.index}`);for(let[p,_]of n.exports){if(!p.startsWith(kr))continue;let y=p.slice(kr.length),g=Number(y);(!/^[1-9][0-9]*$/.test(y)||!Number.isSafeInteger(g)||g>4294967295||_.length!==1||_[0].kind!==1)&&i.push(`malformed reserved fork table catalog export ${p}`)}return i}function Nn(n,e){switch(n){case"ptr":return e===8?126:127;case"i32":return 127;case"i64":return 126;case"anyref":return 110;case"exnref":return 105;case"externref":return 111;case"funcref":return 112}}function Pn(n,e,t,r){return n.params.length===e.length&&n.results.length===t.length&&n.params.every((i,o)=>i===Nn(e[o],r))&&n.results.every((i,o)=>i===Nn(t[o],r))}function kn(n,e,t){let r=i=>i==="ptr"?t===8?"i64":"i32":i;return`(${n.map(r).join(", ")}) -> (${e.map(r).join(", ")})`}function rc(n){let e=`${Rn}.${Ln}`,t=n.globalImports.get(e);return t?t.length!==1?[`duplicate exception-codec activation import ${e}`]:t[0].valueType!==127||t[0].mutable?[`exception-codec activation import ${e} must be immutable i32`]:[]:[`missing required immutable exception-codec activation import ${e}`]}function nc(n){let e=[];for(let t of bn){let r=`${t.module}.${t.name}`,i=n.tableImports.get(r);if(!i){e.push(`missing required ABI 43 fork-runtime table import ${r}`);continue}if(i.length!==1){e.push(`duplicate ABI 43 fork-runtime table import ${r}`);continue}let o=i[0],s=Nn(t.element,4);(o.elementType!==s||o.table64!==t.table64||o.minimum!==t.minimum||o.maximum!==t.maximum)&&e.push(`ABI 43 fork-runtime table import ${r} has the wrong type or limits`)}return e}function ic(n){if(n.staticRootDescriptors.length===0)return[`missing required ${Ge} descriptor`];if(n.staticRootDescriptors.length!==1)return[`has ${n.staticRootDescriptors.length} ${Ge} descriptors, expected exactly one`];let e=n.staticRootDescriptors[0];if(e.byteLength!==Dr)return[`${Ge} has ${e.byteLength} bytes, expected ${Dr}`];let t=[];Ca.some((u,l)=>e[l]!==u)&&t.push(`${Ge} has invalid magic`);let r=new DataView(e.buffer,e.byteOffset,e.byteLength);r.getUint16(4,!0)!==ao&&t.push(`${Ge} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Dr&&t.push(`${Ge} declares an invalid header size`);let i=r.getUint32(8,!0),o=n.tableExports.get(Ht);if(!o||o.length!==1)return t.push(`missing exactly one table export ${Ht}`),t;let s=[...n.tableImports.values()].reduce((u,l)=>u+l.length,0),a=o[0],c=n.tables[a];return a!n.functionExports.has(c)).map(({name:c})=>c);if(t.length>0&&e.push(`incomplete wasm-fork-instrument exports; missing ${t.join(", ")}`),n.importsKernelFork){let c=`${ot.module}.${ot.name}`,u=n.functionImports.get(c);u?.length!==1?e.push(`duplicate ABI 43 process-fork import ${c}`):Pn(u[0],ot.params,ot.results,4)||e.push(`ABI 43 process-fork import ${c} has the wrong signature; expected ${kn(ot.params,ot.results,4)}`)}let r=null;if(n.linkedFrameDescriptors.length===0)e.push(`missing required ${Bt} descriptor`);else if(n.linkedFrameDescriptors.length!==1)e.push(`has ${n.linkedFrameDescriptors.length} ${Bt} descriptors, expected exactly one`);else try{r=qa(n.linkedFrameDescriptors[0])}catch(c){e.push(c instanceof Error?c.message:String(c))}e.push(...Xa(n.moduleStateDescriptors,r));let i=St.filter(({module:c,name:u})=>n.functionImports.has(`${c}.${u}`)),o=`${Cr}.${Mr}`,s=n.importsKernelFork||i.length>0;if((s||n.tagImports.has(o)||n.unwindTransportDescriptors.length>0)&&e.push(...Ya(n)),s){let c=St.filter(({module:u,name:l})=>!n.functionImports.has(`${u}.${l}`)).map(({module:u,name:l})=>`${u}.${l}`);c.length>0&&e.push(`incomplete ABI 43 fork-runtime imports; missing ${c.join(", ")}`);for(let u of St){let l=`${u.module}.${u.name}`,d=n.functionImports.get(l);d&&d.length!==1&&e.push(`duplicate ABI 43 fork-runtime import ${l}`)}}if(r!==null){if(n.memoryPointerWidths.length!==1)e.push(`ABI 43 fork instrumentation requires exactly one module memory, found ${n.memoryPointerWidths.length}`);else if(n.memoryPointerWidths[0]!==r){let c=r===8?"an":"a";e.push(`ABI 43 linked-frame descriptor declares ${c} ${r}-byte pointer but the module memory uses ${n.memoryPointerWidths[0]}-byte addresses`)}for(let c of Zt){let u=n.functionExports.get(c.name);u?.length===1&&!Pn(u[0],c.params,c.results,r)&&e.push(`ABI 43 wasm-fork-instrument export ${c.name} has the wrong signature; expected ${kn(c.params,c.results,r)}`)}if(s)for(let c of St){let u=`${c.module}.${c.name}`,l=n.functionImports.get(u);l?.length===1&&!Pn(l[0],c.params,c.results,r)&&e.push(`ABI 43 fork-runtime import ${u} has the wrong signature; expected ${kn(c.params,c.results,r)}`)}}return e}function Po(n,e){switch(n){case 0:return"function";case 1:return"table";case 2:return"memory";case 3:return"global";case 4:return"tag";default:throw new Error(`${e} has unsupported external kind ${n}`)}}function sc(n){let e=new Uint8Array(n);if(!$r(e))return[];let t=[],r=8;for(;r`${e}.${t}`)}function cc(n){let e=new Uint8Array(n);if(!$r(e))return[];let t=[],r=8;for(;re)}function ko(n){let e=new Uint8Array(n);if(!$r(e))return[];let t=[],r=8;for(;rt.startsWith("reloc."))}function Fo(n,e={}){let t=[],r=null;dc(n)&&t.push("contains asyncify_"),e.expectedAbi!==void 0&&e.expectedAbi!==null&&(r=pc(n),r!==null&&r!==e.expectedAbi&&t.push(`ABI ${r}, expected ${e.expectedAbi}`));let i=new Set(uc(n));if(e.requiredExports){let E=e.requiredExports.filter(O=>!i.has(O));E.length>0&&t.push(`missing required exports: ${E.join(", ")}`)}if(e.forbiddenExports){let E=e.forbiddenExports.filter(O=>i.has(O));E.length>0&&t.push(`forbidden exports present: ${E.join(", ")}`)}let o=Ha.filter(E=>i.has(E)),s=ac(n),a=ko(n),c=St.filter(({module:E,name:O})=>s.includes(`${E}.${O}`)),u=a.filter(E=>E===Bt).length,l=a.filter(E=>E===Pe).length,d=a.filter(E=>E===ne).length,h=a.filter(E=>E===ze).length,m=a.filter(E=>E===Y).length,f=a.filter(E=>E===X).length,p=a.filter(E=>E===Et).length,_=s.includes(`${Cr}.${Mr}`),y=o.length>0||c.length>0||u>0||l>0||d>0||h>0||m>0||f>0||p>0||_;if(e.expectedAbi!==void 0&&e.expectedAbi!==null&&y&&r===null&&t.push(`ABI ${e.expectedAbi} fork artifact is missing __abi_version; the activation-state capability epoch cannot be verified`),e.forbidForkInstrumentation&&y&&t.push("contains ABI 43 wasm-fork-instrument metadata, imports, or exports"),(e.requireForkInstrumentation??!lc(n))&&(y||s.includes("kernel.kernel_fork")))try{t.push(...oc(Va(n)))}catch(E){t.push(`cannot validate ABI 43 fork-artifact contract: ${E instanceof Error?E.message:String(E)}`)}return t}function fc(n,e){let t=new Uint8Array(n);if(t.length<8)return null;let r=0,i=null,o=null,s=8;for(;s=c)return null;let p=a;for(let g=0;g=f)return null;let[p,_]=T(t,m);m+=_;for(let y=0;yf)return null}return m}function h(m,f=0){if(f>4)return null;let p=l(m);if(!p)return null;let _=d(p.start,p.end);if(_===null)return null;let y=_,g=p.end;for(;y=32&&E<=38||E===208){let[,O]=T(t,y);y+=O}else if(E>=40&&E<=62)y=Xt(t,y);else if(E===63||E===64)y++;else if(E===66){let[,O]=bo(t,y);y+=O}else if(E===67)y+=4;else if(E===68)y+=8;else if(E===252||E===253||E===254){let O=Ua(E,t,y);if(O===null)return null;y=O}}return null}return h(i)}function pc(n){return fc(n,"__abi_version")}Ve();var hc=ArrayBuffer,J=Uint8Array,Ur=Uint16Array,mc=Int16Array;var Wr=Int32Array,Cn=function(n,e,t){if(J.prototype.slice)return J.prototype.slice.call(n,e,t);(e==null||e<0)&&(e=0),(t==null||t>n.length)&&(t=n.length);var r=new J(t-e);return r.set(n.subarray(e,t)),r},Jt=function(n,e,t,r){if(J.prototype.fill)return J.prototype.fill.call(n,e,t,r);for((t==null||t<0)&&(t=0),(r==null||r>n.length)&&(r=n.length);tn.length)&&(r=n.length);t2046MB)","invalid block type","FSE accuracy too high","match distance too far back","unexpected EOF"],Q=function(n,e,t){var r=new Error(e||_c[n]);if(r.code=n,Error.captureStackTrace&&Error.captureStackTrace(r,Q),!t)throw r;return r},No=function(n,e,t){for(var r=0,i=0;r>>0},Ec=function(n,e){var t=n[0]|n[1]<<8|n[2]<<16;if(t==3126568&&n[3]==253){var r=n[4],i=r>>5&1,o=r>>2&1,s=r&3,a=r>>6;r&8&&Q(0);var c=6-i,u=s==3?4:s,l=No(n,c,u);c+=u;var d=a?1<>3);m=f+(f>>3)*(n[5]&7)}m>2145386496&&Q(1);var p=new J((e==1?h||m:e?0:m)+12);return p[0]=1,p[4]=4,p[8]=8,{b:c+d,y:0,l:0,d:l,w:e&&e!=1?e:p.subarray(12),e:m,o:new Wr(p.buffer,0,3),u:h,c:o,m:Math.min(131072,m)}}else if((t>>4|n[3]<<20)==25481893)return gc(n,4)+8;Q(0)},st=function(n){for(var e=0;1<t&&Q(3);for(var o=1<0;){var g=st(s+1),E=r>>3,O=(1<>(r&7)&O,w=(1<w&&(S-=z)),h[++a]=--S,S==-1?(s+=S,_[--l]=a):s-=S,!S)do{var I=r>>3;c=(n[I]|n[I+1]<<8)>>(r&7)&3,r+=2,a+=c}while(c==3)}(a>255||s)&&Q(0);for(var R=0,L=(o>>1)+(o>>3)+3,v=o-1,Z=0;Z<=a;++Z){var N=h[Z];if(N<1){m[Z]=-N;continue}for(u=0;u=l)}}for(R&&Q(0),u=0;u>3,{b:i,s:_,n:y,t:f}]},Sc=function(n,e){var t=0,r=-1,i=new J(292),o=n[e],s=i.subarray(0,256),a=i.subarray(256,268),c=new Ur(i.buffer,268);if(o<128){var u=Qt(n,e+1,6),l=u[0],d=u[1];e+=o;var h=l<<3,m=n[e];m||Q(0);for(var f=0,p=0,_=d.b,y=_,g=(++e<<3)-8+st(m);g-=_,!(g>3;if(f+=(n[E]|n[E+1]<<8)>>(g&7)&(1<<_)-1,s[++r]=d.s[f],g-=y,g>3,p+=(n[E]|n[E+1]<<8)>>(g&7)&(1<255&&Q(0)}else{for(r=o-127;t>4,s[t+1]=O&15}++e}var S=0;for(t=0;t11&&Q(0),S+=w&&1<0;--t){var Z=c[t];Jt(v,t,Z,c[t-1]=Z+a[t]*(1<a&&d>3,m=(n[h]|n[h+1]<<8|n[h+2]<<16)>>(l&7);c=(c<>2,s=o<<1,a=o+s;jt(n.subarray(r,r+=n[0]|n[1]<<8),e.subarray(0,o),t),jt(n.subarray(r,r+=n[2]|n[3]<<8),e.subarray(o,s),t),jt(n.subarray(r,r+=n[4]|n[5]<<8),e.subarray(s,a),t),jt(n.subarray(r),e.subarray(a),t)},Tc=function(n,e,t){var r,i=e.b,o=n[i],s=o>>1&3;e.l=o&1;var a=o>>3|n[i+1]<<5|n[i+2]<<13,c=(i+=3)+a;if(s==1)return i>=n.length?void 0:(e.b=i+1,t?(Jt(t,n[i],e.y,e.y+=a),t):Jt(new J(a),n[i]));if(!(c>n.length)){if(s==0)return e.b=c,t?(t.set(n.subarray(i,c),e.y),e.y+=a,t):Cn(n,i,c);if(s==2){var u=n[i],l=u&3,d=u>>2&3,h=u>>4,m=0,f=0;l<2?d&1?h|=n[++i]<<4|(d&2&&n[++i]<<12):h=u>>3:(f=d,d<2?(h|=(n[++i]&63)<<4,m=n[i]>>6|n[++i]<<2):d==2?(h|=n[++i]<<4|(n[++i]&3)<<12,m=n[i]>>2|n[++i]<<6):(h|=n[++i]<<4|(n[++i]&63)<<12,m=n[i]>>6|n[++i]<<2|n[++i]<<10)),++i;var p=t?t.subarray(e.y,e.y+e.m):new J(e.m),_=p.length-h;if(l==0)p.set(n.subarray(i,i+=h),_);else if(l==1)Jt(p,n[i++],_);else{var y=e.h;if(l==2){var g=Sc(n,i);m+=i-(i=g[0]),e.h=y=g[1]}else y||Q(0);(f?Ic:jt)(n.subarray(i,i+=m),p.subarray(_),y)}var E=n[i++];if(E){E==255?E=(n[i++]|n[i++]<<8)+32512:E>127&&(E=E-128<<8|n[i++]);var O=n[i++];O&3&&Q(0);for(var S=[Oc,Ac,wc],w=2;w>-1;--w){var z=O>>(w<<1)+2&3;if(z==1){var x=new J([0,0,n[i++]]);S[w]={s:x.subarray(2,3),n:x.subarray(0,1),t:new Ur(x.buffer,0,1),b:0}}else z==2?(r=Qt(n,i,9-(w&1)),i=r[0],S[w]=r[1]):z==3&&(e.t||Q(0),S[w]=e.t[w])}var I=e.t=S,R=I[0],L=I[1],v=I[2],Z=n[c-1];Z||Q(0);var N=(c<<3)-8+st(Z)-v.b,k=N>>3,G=0,ue=(n[k]|n[k+1]<<8)>>(N&7)&(1<>3;var Oe=(n[k]|n[k+1]<<8)>>(N&7)&(1<>3;var M=(n[k]|n[k+1]<<8)>>(N&7)&(1<>3;var yt=1<>>(N&7)&yt-1);k=(N-=Dn[nt])>>3;var $e=xc[nt]+((n[k]|n[k+1]<<8|n[k+2]<<16)>>(N&7)&(1<>3;var it=zc[de]+((n[k]|n[k+1]<<8|n[k+2]<<16)>>(N&7)&(1<>3,ue=v.t[ue]+((n[k]|n[k+1]<<8)>>(N&7)&(1<>3,M=R.t[M]+((n[k]|n[k+1]<<8)>>(N&7)&(1<>3,Oe=L.t[Oe]+((n[k]|n[k+1]<<8)>>(N&7)&(1<3)e.o[2]=e.o[1],e.o[1]=e.o[0],e.o[0]=Ae-=3;else{var _t=Ae-(it!=0);_t?(Ae=_t==3?e.o[0]-1:e.o[_t],_t>1&&(e.o[2]=e.o[1]),e.o[1]=e.o[0],e.o[0]=Ae):Ae=e.o[0]}for(var w=0;w$e&&(We=$e);for(var w=0;wvc)throw er("EOVERFLOW","file offset is outside signed i64");return n}function kc(n){if(Bn(n)<0n)throw er("EINVAL","negative positioned I/O offset");return n}function $n(n){let e=Bn(n);if(eKo)throw er("EOVERFLOW","backend cannot represent the file offset exactly");return Do(e)}function Un(n){let e=kc(n);return $n(e)}function Bo(n){if(n===null)return null;let e=Bn(n);if(e<0n)throw er("EINVAL","negative file-size limit");return e>Ko?null:Do(e)}Ve();var{ALLOC_SIZE_MIN:Fc,ASYNC_IO:Nc,CHOWN_RESTRICTED:Cc,FALLOC:Mc,FILESIZEBITS:Dc,LINK_MAX:Kc,MAX_CANON:Bc,MAX_INPUT:$c,NAME_MAX:Uc,NO_TRUNC:Wc,PATH_MAX:Gc,PIPE_BUF:Hc,POSIX2_SYMLINKS:Vc,PRIO_IO:qc,REC_INCR_XFER_SIZE:Zc,REC_MAX_XFER_SIZE:Yc,REC_MIN_XFER_SIZE:Xc,REC_XFER_ALIGN:jc,SOCK_MAXBUF:Jc,SYMLINK_MAX:Qc,SYNC_IO:eu,TEXTDOMAIN_MAX:tu,TIMESTAMP_RESOLUTION:ru,VDISABLE:nu}=zo,{S_IFDIR:iu,S_IFIFO:ou,S_IFMT:$o,S_IFREG:su}=ie;function Wn(n){let e=new Error(`EINVAL: pathconf name ${n} is not associated with this object`);throw e.code="EINVAL",e}function Gn(n,e,t){switch(e){case Kc:return null;case Uc:return 255;case Gc:return Oo;case Cc:return 1;case Wc:return 1;case Nc:return(n.mode&$o)===su?1:Wn(e);case eu:case qc:case Dc:case Zc:case Yc:case Xc:case jc:case Fc:case Qc:case Mc:return null;case Vc:return t.supportsSymlinks?1:null;case tu:return 255;case ru:return t.timestampResolutionNs;case Hc:{let r=n.mode&$o;return r===ou||r===iu?null:Wn(e)}case Bc:case $c:case nu:case Jc:return Wn(e);default:{let r=new Error(`EINVAL: invalid pathconf name ${e}`);throw r.code="EINVAL",r}}}Ve();Ve();var Uo=So.ST_NOSUID;var Gr=Math.floor(160),Hn=1397114451,Vn=1,tr=32768,H=16384,wt=40960,W=61440,au=2048,cu=1024,uu=73,Wo=4294967295,at=0,Go=1;var cr=64,ei=128,dr=512,du=1024,lu=65536,rr=3,fu=0,pu=1,hu=2,F=8,mu=64*1024,yu=-1,Ie=-2,$=-5,re=-9,jn=-16,Tt=-17,Fe=-20,ut=-21,j=-22,Qo=-24,dt=-27,se=-28,es=-30,Jn=-36,Qn=-39,ts=-40,rs=-75,qn=0,Zn=4,Hr=8,Ot=12,Ye=16,At=20,Vr=24,ct=28,qr=32,Ho=36,Zr=40,_u=44,gu=48,Eu=52,Yn=56,Yr=60,Xr=64,nr=68,Vo=72,zt=0,C=8,B=12,P=16,me=24,ee=32,ir=40,oe=48,or=88,xt=92,sr=96,ar=100,pe=104,Xe=112,qo=116,he=120,Zo=4,ke=8,Yo=16,Xo=20,jo=-2147483648,Su=2147483647,wu=1034+1024*1024,je=wu*4096,Ou={[Ie]:"No such file or directory",[$]:"I/O error",[re]:"Bad file descriptor",[jn]:"Device or resource busy",[Tt]:"File exists",[Fe]:"Not a directory",[ut]:"Is a directory",[j]:"Invalid argument",[Qo]:"Too many open files",[dt]:"File too large",[se]:"No space left on device",[es]:"Read-only file system",[Jn]:"File name too long",[Qn]:"Directory not empty",[ts]:"Too many symbolic links",[rs]:"Value too large for data type"},A=class extends Error{constructor(t,r){super(r||Ou[t]||`Error ${t}`);this.code=t;this.name="SFSError"}code},_e=new TextEncoder,ur=new TextDecoder,Jo=_e.encode("..");function Xn(n){return n==="."||n===".."}function It(n){return n.buffer instanceof SharedArrayBuffer?ur.decode(new Uint8Array(n)):ur.decode(n)}function Je(n){return n+3&-4}var le=class n{constructor(e){this.buffer=e;this.view=new DataView(e),this.i32=new Int32Array(e),this.u8=new Uint8Array(e)}buffer;view;i32;u8;dirIndexes=new Map;blockAllocHint=0;inodeAllocHint=2;atomicsWaitAllowed;static mkfs(e,t){let r=e.byteLength;if(r<4096*16)throw new A(j);let i=Math.floor(r/4096),o=t?Math.floor(t/4096):i*4,s=Math.floor(o/4);s<32&&(s=32),s=Math.ceil(s/32)*32;let a=Math.ceil(s/(4096*8)),c=Math.ceil(o/(4096*8)),u=Math.ceil(s*128/4096),l=1,d=l+a,h=d+c,m=h+u;if(m>=i){let x=(m+1)*4096;try{e.grow(x)}catch{throw new A(se)}if(i=Math.floor(e.byteLength/4096),m>=i)throw new A(se)}new Uint8Array(e).fill(0);let f=new n(e);f.w32(qn,Hn),f.w32(Zn,Vn),f.w32(Hr,4096),f.w32(Ot,i),f.w32(Ye,s),f.w32(ct,l),f.w32(qr,d),f.w32(Ho,h),f.w32(Zr,m),f.w32(_u,a),f.w32(gu,c),f.w32(Eu,u),f.w32(nr,o),f.w32(Vo,256);let p=d*4096;for(let x=0;x>2)+(x>>5);f.i32[I]|=1<<(x&31)}let _=i-m;Atomics.store(f.i32,At>>2,_),f.blockAllocHint=m;let y=l*4096;f.i32[y>>2]|=3,Atomics.store(f.i32,Vr>>2,s-2),f.inodeAllocHint=2;let g=f.inodeOffset(1);f.w32(g+C,H|493),f.w32(g+B,2),f.w64(g+pe,1);let E=f.blockAlloc();if(E<0)throw new A(se);f.w32(g+oe,E);let O=E*4096,S=Je(F+1),w=Je(F+2);f.w32(O,1),f.view.setUint16(O+4,S,!0),f.view.setUint16(O+6,1,!0),f.u8[O+F]=46;let z=O+S;return f.w32(z,1),f.view.setUint16(z+4,w,!0),f.view.setUint16(z+6,2,!0),f.u8[z+F]=46,f.u8[z+F+1]=46,f.w64(g+P,S+w),Atomics.store(f.i32,Yn>>2,1),f}static inspectImageCapacity(e){if(e.byteLengththis.snapshotBytesUnlocked(e))}snapshotState(e){return this.withNamespaceLock(()=>({bytes:this.snapshotBytesUnlocked(e),identities:this.collectIdentityStateUnlocked()}))}identityState(){return this.withNamespaceLock(()=>this.collectIdentityStateUnlocked())}snapshotBytesUnlocked(e){let t=e?.normalizeTimestampsMs;if(t!==void 0&&(!Number.isSafeInteger(t)||t<0))throw new A(j,"Snapshot timestamp must be a non-negative safe integer in milliseconds");let r=t===void 0?void 0:BigInt(t);for(let a=0;a>2)!==0)throw new A(jn,"Cannot save a VFS image with open descriptors")}let i=this.r32(Ye);for(let a=0;a=1&&this.inodeIsAllocated(a)?r:0n;s.setBigUint64(c+ir,u,!0),s.setBigUint64(c+me,u,!0),s.setBigUint64(c+ee,u,!0)}}return o}collectIdentityStateUnlocked(){let e=new Map,t=this.inodeOffset(1),r=this.r64(t+pe);e.set(`1:${r}`,{ino:1,generation:r,dataSequence:Atomics.load(this.i32,t+he>>2)>>>0,mode:this.r32(t+C),linkCount:this.r32(t+B),size:this.r64(t+P),uid:this.r32(t+sr),gid:this.r32(t+ar),paths:["/"]});let i=[{ino:1,path:"/"}],o=new Set;for(;i.length>0;){let s=i.pop();if(o.has(s.ino))throw new A($);o.add(s.ino);let a=this.inodeOffset(s.ino);if((this.r32(a+C)&W)!==H)throw new A($);let c=this.r64(a+P),u=0;for(;u>2)>>>0,mode:R,linkCount:this.r32(w+B),size:this.r64(w+P),uid:this.r32(w+sr),gid:this.r32(w+ar),...(R&W)===wt?{symlinkTarget:this.readSymlinkInodeUnlocked(y)}:{},paths:[]},e.set(x,I)}I.paths.push(S),(this.r32(w+C)&W)===H&&i.push({ino:y,path:S})}}p+=g}u+=f}}return e}statfs(){let e=this.r32(Hr),t=this.r32(Ot),r=this.r32(nr),i=typeof this.buffer.maxByteLength=="number"?this.buffer.maxByteLength:this.buffer.byteLength,o=Math.floor(i/e),s=Math.max(t,Math.min(r,o)),a=Atomics.load(this.i32,At>>2),c=Math.max(0,s-t);return{blockSize:e,totalBlocks:s,freeBlocks:a+c,totalInodes:this.r32(Ye),freeInodes:Atomics.load(this.i32,Vr>>2),maxName:255}}r32(e){return this.view.getUint32(e,!0)}w32(e,t){this.view.setUint32(e,t,!0)}r64(e){return Number(this.view.getBigUint64(e,!0))}w64(e,t){this.view.setBigUint64(e,BigInt(t),!0)}waitForAtomicChange(e,t){if(this.atomicsWaitAllowed!==!1)try{Atomics.wait(this.i32,e,t),this.atomicsWaitAllowed=!0;return}catch(r){if(!(r instanceof TypeError))throw r;this.atomicsWaitAllowed=!1}for(;Atomics.load(this.i32,e)===t;);}resetAllocationHints(){this.blockAllocHint=this.findNextFreeBlockHint(),this.inodeAllocHint=this.findNextFreeInodeHint()}findNextFreeBlockHint(){let e=this.r32(Ot),t=this.r32(Zr),r=this.r32(qr)*4096;for(let i=t;i>2)+(i>>5),s=i&31;if((Atomics.load(this.i32,o)&1<>2)+(r>>5),o=r&31;if((Atomics.load(this.i32,i)&1<>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}sbUnlock(){let e=Yr>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}namespaceLock(){let e=Xr>>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}namespaceUnlock(){let e=Xr>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}withNamespaceLock(e){this.namespaceLock();try{return e()}finally{this.namespaceUnlock()}}resetRestoredRuntimeState(){Atomics.store(this.i32,Yr>>2,0),Atomics.store(this.i32,Xr>>2,0),this.u8.fill(0,256,4096);let e=this.r32(Ye),t=this.r32(ct)*4096;for(let r=0;r>5)*4)&1<<(r&31))===0||this.r32(i+B)!==0)continue;let s=this.r32(i+C),a=this.r64(i+P);(s&W)===wt&&a<=40?(this.u8.fill(0,i+oe,i+oe+40),this.w64(i+P,0)):this.inodeTruncate(r,0),this.inodeFree(r)}}blockAlloc(){let e=this.r32(Ot),t=this.r32(qr)*4096,r=this.r32(Zr),i=this.blockAllocHint>=r&&this.blockAllocHint>2)+(a>>5),u=a&31,l=Atomics.load(this.i32,c);if(l&1<>2,1),this.blockAllocHint=a+1>2)+(e>>5),i=e&31;for(;;){let o=Atomics.load(this.i32,r),s=o&~(1<>2,1),e>=this.r32(Zr)&&e>2)>0)return 0;let e=this.r32(Ot),t=this.r32(nr),r=this.r32(Vo),i=e+r;if(i>t&&(i=t,r=i-e,r===0))return se;let o=i*4096;if(this.buffer.byteLength>2,r),Atomics.add(this.i32,Yn>>2,1),this.blockAllocHint=e,0}finally{this.sbUnlock()}}inodeOffset(e){let r=this.r32(Ho)+Math.floor(e/32),i=e%32*128;return r*4096+i}inodeAlloc(){let e=this.r32(Ye),t=this.r32(ct)*4096,r=this.inodeAllocHint>=2&&this.inodeAllocHint>2)+(s>>5),c=s&31,u=Atomics.load(this.i32,a);if(u&1<>2,1),this.inodeAllocHint=s+1>2,1)+1}inodeFree(e){let r=(this.r32(ct)*4096>>2)+(e>>5),i=e&31;for(;;){let o=Atomics.load(this.i32,r);if((o&1<>2,1),e>=2&&e0&&this.w32(r+Xe,i-1),i<=1&&this.r32(r+B)===0&&(this.inodeTruncate(e,0),t=!0)}finally{this.inodeWriteUnlock(e)}t&&this.inodeFree(e)}inodeDropLinkRefLocked(e){let t=this.inodeOffset(e),r=this.r32(t+B);return r>1?(this.w32(t+B,r-1),this.w64(t+ee,Date.now()),!1):this.inodeOrphanLocked(e)}inodeOrphanLocked(e){let t=this.inodeOffset(e);if(this.w32(t+B,0),this.w64(t+ee,Date.now()),this.r32(t+Xe)>0)return!1;let r=this.r32(t+C),i=this.r64(t+P);return(r&W)===wt&&i<=40?(this.u8.fill(0,t+oe,t+oe+40),this.w64(t+P,0)):this.inodeTruncate(e,0),!0}inodeReadLock(e){let t=this.inodeOffset(e)+zt>>2;for(;;){let r=Atomics.load(this.i32,t);if(r&jo){this.waitForAtomicChange(t,r);continue}if(Atomics.compareExchange(this.i32,t,r,r+1)===r)return}}inodeReadUnlock(e){let t=this.inodeOffset(e)+zt>>2;(Atomics.sub(this.i32,t,1)&Su)===1&&Atomics.notify(this.i32,t,1)}inodeWriteLock(e){let t=this.inodeOffset(e)+zt>>2;for(;;){let r=Atomics.load(this.i32,t);if(r!==0){this.waitForAtomicChange(t,r);continue}if(Atomics.compareExchange(this.i32,t,0,jo)===0)return}}inodeWriteUnlock(e){let t=this.inodeOffset(e)+zt>>2;Atomics.store(this.i32,t,0),Atomics.notify(this.i32,t,1/0)}inodeBlockMap(e,t,r){let i=this.inodeOffset(e);if(t<10){let o=this.r32(i+oe+t*4);if(o!==0)return o;if(!r)return 0;let s=this.blockAllocWithGrow();return s<0||this.w32(i+oe+t*4,s),s}if(t-=10,t<1024){let o=this.r32(i+or),s=!1;if(o===0){if(!r)return 0;if(o=this.blockAllocWithGrow(),o<0)return o;this.w32(i+or,o),s=!0}let a=o*4096+t*4,c=this.r32(a);if(c!==0)return c;if(!r)return 0;let u=this.blockAllocWithGrow();return u<0?(s&&(this.w32(i+or,0),this.blockFree(o)),u):(this.w32(a,u),u)}if(t-=1024,t<1024*1024){let o=Math.floor(t/1024),s=t%1024,a=this.r32(i+xt),c=!1;if(a===0){if(!r)return 0;if(a=this.blockAllocWithGrow(),a<0)return a;this.w32(i+xt,a),c=!0}let u=a*4096+o*4,l=this.r32(u),d=!1;if(l===0){if(!r)return 0;if(l=this.blockAllocWithGrow(),l<0)return c&&(this.w32(i+xt,0),this.blockFree(a)),l;this.w32(u,l),d=!0}let h=l*4096+s*4,m=this.r32(h);if(m!==0)return m;if(!r)return 0;let f=this.blockAllocWithGrow();return f<0?(d&&(this.w32(u,0),this.blockFree(l)),c&&(this.w32(i+xt,0),this.blockFree(a)),f):(this.w32(h,f),f)}return j}inodeReadData(e,t,r,i){let o=this.inodeOffset(e),s=this.r64(o+P);if(t>=s)return 0;t+i>s&&(i=s-t);let a=0,c=0;for(;i>0;){let u=Math.floor(t/4096),l=t%4096,d=4096-l;d>i&&(d=i);let h=this.inodeBlockMap(e,u,!1);if(h<=0)r.fill(0,c,c+d);else{let m=h*4096+l;r.set(this.u8.subarray(m,m+d),c)}c+=d,t+=d,i-=d,a+=d}return a}inodeWriteData(e,t,r,i){let o=this.inodeOffset(e),s=this.r64(o+P);t>s&&this.zeroOldEofTail(e,s);let a=0,c=0;for(;i>0;){let u=Math.floor(t/4096),l=t%4096,d=4096-l;d>i&&(d=i);let h=this.inodeBlockMap(e,u,!0);if(h<0){if(a===0)return h;break}let m=h*4096+l;this.u8.set(r.subarray(c,c+d),m),c+=d,t+=d,i-=d,a+=d}if(a>0&&t>this.r64(o+P)&&this.w64(o+P,t),a>0){let u=Date.now();this.w64(o+me,u),this.w64(o+ee,u),Atomics.add(this.i32,o+he>>2,1)}return a}zeroInodeRange(e,t,r){for(;t0){let c=a*4096+o;this.u8.fill(0,c,c+s)}t+=s}}zeroOldEofTail(e,t){let r=t%4096;if(r===0)return;let i=Math.floor(t/4096),o=this.inodeBlockMap(e,i,!1);if(o<=0)return;let s=o*4096+r;this.u8.fill(0,s,o*4096+4096)}freeBlocksFrom(e,t){let r=this.inodeOffset(e);for(let s=t;s<10;s++){let a=this.r32(r+oe+s*4);a&&(this.blockFree(a),this.w32(r+oe+s*4,0))}let i=this.r32(r+or);if(i){let s=t>10?t-10:0;for(let a=s;a<1024;a++){let c=i*4096+a*4,u=this.r32(c);u&&(this.blockFree(u),this.w32(c,0))}s===0&&(this.blockFree(i),this.w32(r+or,0))}let o=this.r32(r+xt);if(o){let s=t>1034?t-10-1024:0,a=Math.floor(s/1024);for(let c=a;c<1024;c++){let u=o*4096+c*4,l=this.r32(u);if(!l)continue;let d=c===a?s%1024:0;for(let h=d;h<1024;h++){let m=l*4096+h*4,f=this.r32(m);f&&(this.blockFree(f),this.w32(m,0))}d===0&&(this.blockFree(l),this.w32(u,0))}a===0&&(this.blockFree(o),this.w32(r+xt,0))}}inodeTruncate(e,t,r=!1){let i=this.inodeOffset(e),o=this.r64(i+P),s=t!==o;if(t>=o){if(t>o&&this.zeroOldEofTail(e,o),this.w64(i+P,t),s||r){let c=Date.now();this.w64(i+me,c),this.w64(i+ee,c),Atomics.add(this.i32,i+he>>2,1)}return}t%4096!==0&&this.zeroInodeRange(e,t,Math.ceil(t/4096)*4096);let a=Math.ceil(t/4096);if(this.freeBlocksFrom(e,a),this.w64(i+P,t),s||r){let c=Date.now();this.w64(i+me,c),this.w64(i+ee,c),Atomics.add(this.i32,i+he>>2,1)}}validateFileSize(e){if(!Number.isSafeInteger(e)||e<0)throw new A(j);if(e>je)throw new A(dt)}validateSeekPosition(e){if(!Number.isSafeInteger(e))throw new A(rs);if(e<0)throw new A(j);if(e>je)throw new A(dt)}touchDirectoryMutation(e){let t=this.inodeOffset(e),r=Date.now();this.w64(t+me,r),this.w64(t+ee,r);let i=Atomics.add(this.i32,t+qo>>2,1)+1>>>0,o=this.dirIndexes.get(e);o&&(o.mutationSequence=i,o.size=this.r64(t+P))}dirNameKey(e){return It(e)}dirEntryNameMatches(e,t){if(this.view.getUint16(e+6,!0)!==t.length)return!1;for(let i=0;i=F&&r%4===0&&e+r<=t&&i<=r-F}inodeIsAllocated(e){let t=this.r32(Ye);if(e<=0||e>=t)return!1;let r=this.r32(ct)*4096;return(Atomics.load(this.i32,(r>>2)+(e>>5))&1<<(e&31))!==0}rebuildDirIndex(e,t,r,i){let o=new Map,s=[],a=0;for(;a4096-l&&(m=4096-l);let f=l;for(;f=F&&s.push({abs:p,recLen:y});f+=y}a+=m}let c={generation:t,mutationSequence:r,size:i,entries:o,free:s};return this.dirIndexes.set(e,c),c}getDirIndex(e){let t=this.inodeOffset(e),r=this.r64(t+P),i=this.r64(t+pe),o=Atomics.load(this.i32,t+qo>>2)>>>0,s=this.dirIndexes.get(e);return s&&s.generation===i&&s.mutationSequence===o&&s.size===r?s:(s&&this.dirIndexes.delete(e),r=0;s--){let a=e.free[s];if(!(a.recLen4096-c&&(d=4096-c);let h=c;for(;hr)return-1;a=c,s+=u}return s===r?a:-1}dirAppendEntry(e,t,r,i=-1){let o=this.inodeOffset(e),s=this.r64(o+P),a=Je(F+t.length),c=s,u=Math.floor(c/4096),l=c%4096,d=0;if(l!==0&&l+a>4096){let f=4096-l,p=0;if(f>=F){if(p=this.inodeBlockMap(e,u,!1),p<=0)return $}else if(i<0&&(i=this.findLastDirEntryInBlock(e,u,l)),i<0)return $;if(d=this.inodeBlockMap(e,u+1,!0),d<0)return d;if(f>=F){let _=p*4096+l;this.w32(_,0),this.view.setUint16(_+4,f,!0),this.view.setUint16(_+6,0,!0)}else{let y=this.view.getUint16(i+4,!0)+f;this.view.setUint16(i+4,y,!0),this.updateDirIndexRecLen(e,i,y)}c=(u+1)*4096,u++,l=0}let h;if(l===0){if(h=d||this.inodeBlockMap(e,u,!0),h<0)return h}else if(h=this.inodeBlockMap(e,u,!1),h<=0)return $;let m=h*4096+l;return this.w32(m,r),this.view.setUint16(m+4,a,!0),this.view.setUint16(m+6,t.length,!0),this.u8.set(t,m+F),this.w64(o+P,c+a),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,m,a),0}dirAddEntry(e,t,r){let i=this.getDirIndex(e);if(typeof i=="number")return i;if(i)return this.useDirIndexFreeSlot(i,e,t,r)?0:this.dirAppendEntry(e,t,r);let o=this.inodeOffset(e),s=this.r64(o+P),a=Je(F+t.length),c=-1,u=0;for(;u4096-d&&(f=4096-d);let p=d;for(;pd+f||E>g-F)return $;if(y===0&&g>=a)return this.w32(_,r),this.view.setUint16(_+6,t.length,!0),this.u8.set(t,_+F),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,_,g),0;let O=Je(F+E),S=g-O;if(y!==0&&S>=a){this.view.setUint16(_+4,O,!0);let w=_+O;return this.w32(w,r),this.view.setUint16(w+4,S,!0),this.view.setUint16(w+6,t.length,!0),this.u8.set(t,w+F),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,w,S),0}c=_,p+=g}u+=f}return this.dirAppendEntry(e,t,r,c)}dirRemoveEntry(e,t){let r=this.getDirIndex(e);if(typeof r=="number")return r;if(r){let a=this.dirNameKey(t),c=r.entries.get(a);if(!c)return Ie;if(this.r32(c.abs)===c.ino&&this.view.getUint16(c.abs+4,!0)===c.recLen&&this.view.getUint16(c.abs+6,!0)===c.nameLen&&this.dirEntryNameMatches(c.abs,t))return this.w32(c.abs,0),r.entries.delete(a),r.free.push({abs:c.abs,recLen:c.recLen}),this.touchDirectoryMutation(e),0;r.entries.delete(a)}let i=this.inodeOffset(e),o=this.r64(i+P),s=0;for(;s4096-c&&(d=4096-c);let h=c;for(;h4096-u&&(h=4096-u);let m=u;for(;m4096-s&&(u=4096-s);let l=s;for(;ls+u||f>m-F)throw new A($);if(h!==0){if(f===1&&this.u8[d+F]===46){l+=m;continue}if(f===2&&this.u8[d+F]===46&&this.u8[d+F+1]===46){l+=m;continue}return!1}l+=m}i+=u}return!0}dirIsAncestor(e,t){let r=t;for(let i=0;i<8*1024;i++){if(r===e)return!0;if(r===1)return!1;let o=this.dirLookup(r,Jo);if(o<0||o===r)throw new A($);r=o}throw new A($)}pathResolve(e,t){if(!e.startsWith("/"))return Ie;let r=1,i=e.split("/").filter(s=>s.length>0),o=0;for(let s=0;s255)return Jn;let c=_e.encode(a),u;this.inodeReadLock(r);try{let h=this.inodeOffset(r);if((this.r32(h+C)&W)!==H)return Fe;u=this.dirLookup(r,c)}finally{this.inodeReadUnlock(r)}if(u<0)return u;let l=this.inodeOffset(u);if((this.r32(l+C)&W)===wt&&(!(s===i.length-1)||t)){if(++o>8)return ts;let m=this.r64(l+P),f;if(m<=40)f=It(this.u8.subarray(l+oe,l+oe+m));else{let p=new Uint8Array(m);this.inodeReadData(u,0,p,m),f=ur.decode(p)}if(f.startsWith("/")){r=1;let p=f.split("/").filter(y=>y.length>0),_=i.slice(s+1);i.length=0,i.push(...p,..._),s=-1}else{let p=f.split("/").filter(y=>y.length>0),_=i.slice(s+1);i.length=s,i.push(...p,..._),s--}continue}r=u}return r}pathResolveParent(e){if(!e.startsWith("/"))throw new A(j,"Path must be absolute");let t=e.split("/").filter(c=>c.length>0);if(t.length===0)throw new A(j,"Cannot operate on /");let r=t.pop();if(r.length>255)throw new A(Jn);let i="/"+t.join("/"),o=this.pathResolve(i,!0);if(o<0)throw new A(o);let s=this.inodeOffset(o);if((this.r32(s+C)&W)!==H)throw new A(Fe);return{parentIno:o,name:r}}fdAlloc(e,t,r){for(let i=0;i>2;if(Atomics.compareExchange(this.i32,s,0,1)===0)return this.w32(o+Zo,e),this.w64(o+ke,0),this.w32(o+Yo,t),this.w32(o+Xo,r?1:0),this.inodeAddOpenRef(e)?i:(Atomics.store(this.i32,s,0),Ie)}return Qo}fdGet(e){if(e<0||e>=Gr)return null;let t=256+e*24;return Atomics.load(this.i32,t>>2)?{base:t,ino:this.r32(t+Zo),offset:this.r64(t+ke),flags:this.r32(t+Yo),isDir:this.r32(t+Xo)!==0}:null}fdFree(e){if(e>=0&&e>2,0)}}buildStat(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+pe),dataSequence:this.r32(t+he),mode:this.r32(t+C),linkCount:this.r32(t+B),size:this.r64(t+P),mtime:this.r64(t+me),ctime:this.r64(t+ee),atime:this.r64(t+ir),uid:this.r32(t+sr),gid:this.r32(t+ar)}}namespaceEntryIdentity(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+pe),linkCount:this.r32(t+B),mode:this.r32(t+C)}}open(e,t,r=420){return this.withNamespaceLock(()=>this.openUnlocked(e,t,r))}createLazyStub(e,t){return this.withNamespaceLock(()=>{let r=this.openUnlocked(e,Go|cr,t);try{let i=this.fdGet(r);if(!i)throw new A(re);this.inodeWriteLock(i.ino);try{return this.inodeTruncate(i.ino,0,!0),this.buildStat(i.ino)}finally{this.inodeWriteUnlock(i.ino)}}finally{this.closeUnlocked(r)}})}replaceIfIdentity(e,t,r,i,o){return this.withNamespaceLock(()=>{let s=this.pathResolve(e,!0);if(s<0||s!==t)return!1;let a=this.inodeOffset(s);if(this.r64(a+pe)!==r||this.r32(a+he)!==i||(this.r32(a+C)&W)!==tr)return!1;this.validateFileSize(o.byteLength),this.inodeWriteLock(s);try{if(this.r64(a+pe)!==r||this.r32(a+he)!==i||this.r64(a+P)!==0)return!1;let c=this.r64(a+me),u=this.r64(a+ee);this.inodeTruncate(s,0,!0);let l=o.byteLength>0?this.inodeWriteData(s,0,o,o.byteLength):0;if(l!==o.byteLength)throw this.inodeTruncate(s,0,!0),Atomics.store(this.i32,a+he>>2,i),this.w64(a+me,c),this.w64(a+ee,u),new A(l<0?l:se);return!0}finally{this.inodeWriteUnlock(s)}})}replaceManyIfIdentities(e,t=[]){return e.length===0&&t.length===0?!0:this.withNamespaceLock(()=>{let r=[],i=new Set,o=a=>{let c=this.pathResolve(a.path,!1);if(c<0||c!==a.expectedIno)return!1;let u=this.inodeOffset(c);return this.r64(u+pe)===a.expectedGeneration&&this.r32(u+he)===a.expectedDataSequence&&this.r32(u+C)===a.expectedMode&&this.r32(u+B)===a.expectedLinkCount&&this.r64(u+P)===a.expectedSize&&this.r32(u+sr)===a.expectedUid&&this.r32(u+ar)===a.expectedGid};for(let a of t)if(!o(a))return!1;for(let a of e){this.validateFileSize(a.data.byteLength);let c=-1;for(let u of a.paths){let l=this.pathResolve(u,!0);if(l!==a.expectedIno)continue;let d=this.inodeOffset(l);if(this.r64(d+pe)===a.expectedGeneration&&this.r32(d+he)===a.expectedDataSequence&&(this.r32(d+C)&W)===tr&&this.r64(d+P)===0){c=l;break}}if(c<0)return!1;if(i.has(c))throw new A(j,"duplicate conditional replacement inode");i.add(c),r.push({...a,ino:c})}let s=[...i].sort((a,c)=>a-c);for(let a of s)this.inodeWriteLock(a);try{for(let u of r){let l=this.inodeOffset(u.ino);if(this.r64(l+pe)!==u.expectedGeneration||this.r32(l+he)!==u.expectedDataSequence||(this.r32(l+C)&W)!==tr||this.r64(l+P)!==0)return!1}for(let u of t)if(!o(u))return!1;let a=r.map(u=>{let l=this.inodeOffset(u.ino);return{ino:u.ino,dataSequence:this.r32(l+he),mtime:this.r64(l+me),ctime:this.r64(l+ee)}}),c=0;try{for(let u of r){c++,this.inodeTruncate(u.ino,0,!0);let l=u.data.byteLength>0?this.inodeWriteData(u.ino,0,u.data,u.data.byteLength):0;if(l!==u.data.byteLength)throw new A(l<0?l:se)}}catch(u){for(let l=c-1;l>=0;l--){let d=a[l],h=this.inodeOffset(d.ino);this.inodeTruncate(d.ino,0,!0),Atomics.store(this.i32,h+he>>2,d.dataSequence),this.w64(h+me,d.mtime),this.w64(h+ee,d.ctime)}throw u}return!0}finally{for(let a=s.length-1;a>=0;a--)this.inodeWriteUnlock(s[a])}})}openUnlocked(e,t,r=420){let i=t&rr,o=(t&cr)!==0,s=(t&ei)!==0;if(o&&s){let d=this.pathResolve(e,!1);if(d>=0)throw new A(Tt);if(d!==Ie)throw new A(d)}let a=this.pathResolve(e,!0);if(a<0&&a===Ie&&o){let{parentIno:d,name:h}=this.pathResolveParent(e);this.inodeWriteLock(d);try{let m=_e.encode(h),f=this.dirLookup(d,m);if(f>=0){if(s)throw new A(Tt);a=f}else{let p=this.inodeAlloc();if(p<0)throw new A(se);let _=this.inodeOffset(p);this.w32(_+C,tr|r&4095),this.w32(_+B,1),this.w64(_+P,0);let y=Date.now();this.w64(_+ir,y),this.w64(_+me,y),this.w64(_+ee,y);let g=this.dirAddEntry(d,m,p);if(g<0)throw this.inodeFree(p),new A(g);a=p}}finally{this.inodeWriteUnlock(d)}}if(a<0)throw new A(a);let c=this.inodeOffset(a),u=this.r32(c+C);if((u&W)===H&&i!==at)throw new A(ut);if(t&lu&&(u&W)!==H)throw new A(Fe);if(t&dr){if((u&W)===H)throw new A(ut);this.inodeWriteLock(a),this.inodeTruncate(a,0,!0),this.inodeWriteUnlock(a)}let l=this.fdAlloc(a,t,!1);if(l<0)throw new A(l);return l}close(e){this.withNamespaceLock(()=>this.closeUnlocked(e))}closeUnlocked(e){let t=this.fdGet(e);if(!t)throw new A(re);this.fdFree(e),this.inodeDropOpenRef(t.ino)}read(e,t){let r=this.fdGet(e);if(!r)throw new A(re);let i=this.inodeOffset(r.ino);if((this.r32(i+C)&W)===H)throw new A(ut);this.inodeReadLock(r.ino);try{let s=this.inodeReadData(r.ino,r.offset,t,t.length),a=256+e*24;return this.w64(a+ke,r.offset+s),s}finally{this.inodeReadUnlock(r.ino)}}readAt(e,t,r){let i=this.fdGet(e);if(!i)throw new A(re);let o=this.inodeOffset(i.ino);if((this.r32(o+C)&W)===H)throw new A(ut);this.validateSeekPosition(r),this.inodeReadLock(i.ino);try{return this.inodeReadData(i.ino,r,t,t.length)}finally{this.inodeReadUnlock(i.ino)}}write(e,t){let r=this.fdGet(e);if(!r)throw new A(re);if((r.flags&rr)===at)throw new A(re);this.inodeWriteLock(r.ino);try{let o=r.offset;if(r.flags&du){let c=this.inodeOffset(r.ino);o=this.r64(c+P)}if(!Number.isSafeInteger(o)||o<0)throw new A(j);if(o>je||t.length>je-o)throw new A(dt);let s=this.inodeWriteData(r.ino,o,t,t.length);if(s<0)return s;let a=256+e*24;return this.w64(a+ke,o+s),s}finally{this.inodeWriteUnlock(r.ino)}}append(e,t,r){let i=this.fdGet(e);if(!i)throw new A(re);if((i.flags&rr)===at)throw new A(re);if(r!==null&&(!Number.isSafeInteger(r)||r<0))throw new A(j);this.inodeWriteLock(i.ino);try{let s=this.inodeOffset(i.ino),a=this.r64(s+P);if(!Number.isSafeInteger(a)||a<0)throw new A(j);if(a>je)throw new A(dt);if(r!==null&&a>=r){let f=256+e*24;return this.w64(f+ke,a),{written:0,end:a}}let c=r===null?t.length:Math.min(t.length,r-a),u=je-a;if(c>u)throw new A(dt);let l=t.subarray(0,c),d=this.inodeWriteData(i.ino,a,l,l.length);if(d<0)throw new A(d);let h=256+e*24,m=a+d;return this.w64(h+ke,m),{written:d,end:m}}finally{this.inodeWriteUnlock(i.ino)}}writeAt(e,t,r){let i=this.fdGet(e);if(!i)throw new A(re);if((i.flags&rr)===at)throw new A(re);this.validateSeekPosition(r),this.inodeWriteLock(i.ino);try{if(r>je||t.length>je-r)throw new A(dt);return this.inodeWriteData(i.ino,r,t,t.length)}finally{this.inodeWriteUnlock(i.ino)}}lseek(e,t,r){let i=this.fdGet(e);if(!i)throw new A(re);let o;if(r===fu)o=t;else if(r===pu)o=i.offset+t;else if(r===hu){let a=this.inodeOffset(i.ino);o=this.r64(a+P)+t}else throw new A(j);this.validateSeekPosition(o);let s=256+e*24;return this.w64(s+ke,o),o}ftruncate(e,t){let r=this.fdGet(e);if(!r)throw new A(re);if((r.flags&rr)===at)throw new A(re);this.validateFileSize(t),this.inodeWriteLock(r.ino);try{this.inodeTruncate(r.ino,t,!0)}finally{this.inodeWriteUnlock(r.ino)}}fstat(e){let t=this.fdGet(e);if(!t)throw new A(re);this.inodeReadLock(t.ino);try{return this.buildStat(t.ino)}finally{this.inodeReadUnlock(t.ino)}}stat(e){return this.withNamespaceLock(()=>this.statUnlocked(e))}statUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new A(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}lstat(e){return this.withNamespaceLock(()=>this.lstatUnlocked(e))}lstatUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new A(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}unlink(e){return this.withNamespaceLock(()=>this.unlinkUnlocked(e))}unlinkUnlocked(e){let{parentIno:t,name:r}=this.pathResolveParent(e),i=_e.encode(r),o=e.length>1&&e.endsWith("/");this.inodeWriteLock(t);try{let s=this.dirLookup(t,i);if(s<0)throw new A(s);let a=this.inodeOffset(s),c=this.r32(a+C);if(o&&(c&W)!==H)throw new A(Fe);if((c&W)===H)throw new A(ut);let u=this.namespaceEntryIdentity(s),l=this.dirRemoveEntry(t,i);if(l<0)throw new A(l);let d=!1;this.inodeWriteLock(s);try{d=this.inodeDropLinkRefLocked(s)}finally{this.inodeWriteUnlock(s)}return d&&this.inodeFree(s),u}finally{this.inodeWriteUnlock(t)}}rename(e,t){return this.withNamespaceLock(()=>this.renameUnlocked(e,t))}renameUnlocked(e,t){let{parentIno:r,name:i}=this.pathResolveParent(e),{parentIno:o,name:s}=this.pathResolveParent(t);if(Xn(i)||Xn(s))throw new A(j);let a=_e.encode(i),c=_e.encode(s),u=e.length>1&&e.endsWith("/"),l=t.length>1&&t.endsWith("/"),d=Math.min(r,o),h=Math.max(r,o);this.inodeWriteLock(d),d!==h&&this.inodeWriteLock(h);try{let m=this.dirLookup(r,a);if(m<0)throw new A(m);let f=this.inodeOffset(m),_=this.r32(f+C)&W,y=this.namespaceEntryIdentity(m);if((u||l)&&_!==H)throw new A(Fe);if(_===H&&this.dirIsAncestor(m,o))throw new A(j);let g=this.dirLookup(o,c),E=!1,O;if(g>=0){if(g===m)return{source:y,replaced:y};O=this.namespaceEntryIdentity(g);let w=this.inodeOffset(g),x=this.r32(w+C)&W;if(_===H&&x!==H)throw new A(Fe);if(_!==H&&x===H)throw new A(ut);let I=!1,R=g===r||g===o;R||this.inodeWriteLock(g);try{if(x===H&&!this.dirIsEmpty(g))throw new A(Qn);let L=this.dirReplaceEntryIno(o,c,m);if(L<0)throw new A(L);I=x===H?this.inodeOrphanLocked(g):this.inodeDropLinkRefLocked(g)}finally{R||this.inodeWriteUnlock(g)}I&&this.inodeFree(g),E=x===H}else{let w=this.dirAddEntry(o,c,m);if(w<0)throw new A(w)}let S=this.dirRemoveEntry(r,a);if(S<0)throw new A(S);if(_===H){if(r!==o){let w=this.inodeOffset(r);this.w32(w+B,this.r32(w+B)-1);let z=this.inodeOffset(o);this.w32(z+B,this.r32(z+B)+1),this.inodeWriteLock(m);try{let x=this.dirReplaceEntryIno(m,Jo,o);if(x<0)throw new A(x);this.w64(f+ee,Date.now())}finally{this.inodeWriteUnlock(m)}}if(E){let w=this.inodeOffset(o);this.w32(w+B,this.r32(w+B)-1)}}else if(E){let w=this.inodeOffset(o);this.w32(w+B,this.r32(w+B)-1)}return{source:y,replaced:O}}finally{d!==h&&this.inodeWriteUnlock(h),this.inodeWriteUnlock(d)}}mkdir(e,t=493){this.withNamespaceLock(()=>this.mkdirUnlocked(e,t))}mkdirUnlocked(e,t=493){let{parentIno:r,name:i}=this.pathResolveParent(e),o=_e.encode(i);this.inodeWriteLock(r);try{if(this.dirLookup(r,o)>=0)throw new A(Tt);let a=this.inodeAlloc();if(a<0)throw new A(se);let c=this.inodeOffset(a);this.w32(c+C,H|t),this.w32(c+B,2),this.w64(c+P,0);let u=Date.now();this.w64(c+ir,u),this.w64(c+me,u),this.w64(c+ee,u);let l=this.blockAllocWithGrow();if(l<0)throw this.inodeFree(a),new A(se);this.w32(c+oe,l);let d=l*4096,h=Je(F+1),m=Je(F+2);this.w32(d,a),this.view.setUint16(d+4,h,!0),this.view.setUint16(d+6,1,!0),this.u8[d+F]=46;let f=d+h;this.w32(f,r),this.view.setUint16(f+4,m,!0),this.view.setUint16(f+6,2,!0),this.u8[f+F]=46,this.u8[f+F+1]=46,this.w64(c+P,h+m);let p=this.dirAddEntry(r,o,a);if(p<0)throw this.blockFree(l),this.inodeFree(a),new A(p);let _=this.inodeOffset(r);this.w32(_+B,this.r32(_+B)+1)}finally{this.inodeWriteUnlock(r)}}rmdir(e){this.withNamespaceLock(()=>this.rmdirUnlocked(e))}rmdirUnlocked(e){let{parentIno:t,name:r}=this.pathResolveParent(e);if(Xn(r))throw new A(j);let i=_e.encode(r);this.inodeWriteLock(t);try{let o=this.dirLookup(t,i);if(o<0)throw new A(o);let s=this.inodeOffset(o);if((this.r32(s+C)&W)!==H)throw new A(Fe);let c=!1;this.inodeWriteLock(o);try{if(!this.dirIsEmpty(o))throw new A(Qn);let l=this.dirRemoveEntry(t,i);if(l<0)throw new A(l);c=this.inodeOrphanLocked(o)}finally{this.inodeWriteUnlock(o)}c&&this.inodeFree(o);let u=this.inodeOffset(t);this.w32(u+B,this.r32(u+B)-1)}finally{this.inodeWriteUnlock(t)}}symlink(e,t){this.withNamespaceLock(()=>this.symlinkUnlocked(e,t))}symlinkUnlocked(e,t){let{parentIno:r,name:i}=this.pathResolveParent(t),o=_e.encode(i),s=_e.encode(e);this.inodeWriteLock(r);try{if(this.dirLookup(r,o)>=0)throw new A(Tt);let c=this.inodeAlloc();if(c<0)throw new A(se);let u=this.inodeOffset(c);if(this.w32(u+C,wt|511),this.w32(u+B,1),s.length<=40)this.u8.set(s,u+oe),this.w64(u+P,s.length);else{this.w64(u+P,0);let d=this.inodeWriteData(c,0,s,s.length);if(d!==s.length)throw d>0&&this.inodeTruncate(c,0),this.inodeFree(c),new A(d<0?d:se)}let l=this.dirAddEntry(r,o,c);if(l<0)throw s.length<=40?(this.u8.fill(0,u+oe,u+oe+40),this.w64(u+P,0)):this.inodeTruncate(c,0),this.inodeFree(c),new A(l)}finally{this.inodeWriteUnlock(r)}}chmod(e,t){this.withNamespaceLock(()=>this.chmodUnlocked(e,t))}chmodUnlocked(e,t){let r=this.pathResolve(e,!0);if(r<0)throw new A(r);this.inodeWriteLock(r);try{let i=this.inodeOffset(r),o=this.r32(i+C);this.w32(i+C,o&W|t&4095),this.w64(i+ee,Date.now())}finally{this.inodeWriteUnlock(r)}}fchmod(e,t){let r=this.fdGet(e);if(!r)throw new A(re);this.inodeWriteLock(r.ino);try{let i=this.inodeOffset(r.ino),o=this.r32(i+C);this.w32(i+C,o&W|t&4095),this.w64(i+ee,Date.now())}finally{this.inodeWriteUnlock(r.ino)}}chown(e,t,r){this.withNamespaceLock(()=>this.chownUnlocked(e,t,r))}chownUnlocked(e,t,r){let i=this.pathResolve(e,!0);if(i<0)throw new A(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,r)}finally{this.inodeWriteUnlock(i)}}fchown(e,t,r){let i=this.fdGet(e);if(!i)throw new A(re);this.inodeWriteLock(i.ino);try{this.chownInodeUnlocked(i.ino,t,r)}finally{this.inodeWriteUnlock(i.ino)}}lchown(e,t,r){this.withNamespaceLock(()=>this.lchownUnlocked(e,t,r))}lchownUnlocked(e,t,r){let i=this.pathResolve(e,!1);if(i<0)throw new A(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,r)}finally{this.inodeWriteUnlock(i)}}chownInodeUnlocked(e,t,r){let i=this.inodeOffset(e);t!==Wo&&this.w32(i+sr,t),r!==Wo&&this.w32(i+ar,r);let o=this.r32(i+C);(o&W)===tr&&(o&uu)!==0&&this.w32(i+C,o&~(au|cu)),this.w64(i+ee,Date.now())}utimens(e,t,r,i,o){this.withNamespaceLock(()=>this.utimensUnlocked(e,t,r,i,o))}utimensUnlocked(e,t,r,i,o){let s=this.pathResolve(e,!0);if(s<0)throw new A(s);this.inodeWriteLock(s);try{let a=this.inodeOffset(s),c=1073741823,u=1073741822,l=Date.now();if(r!==u){let d=r===c?l:t*1e3+Math.floor(r/1e6);this.w64(a+ir,d)}if(o!==u){let d=o===c?l:i*1e3+Math.floor(o/1e6);this.w64(a+me,d)}this.w64(a+ee,l)}finally{this.inodeWriteUnlock(s)}}link(e,t){return this.withNamespaceLock(()=>this.linkUnlocked(e,t))}linkUnlocked(e,t){let r=this.pathResolve(e,!1);if(r<0)throw new A(r);let i=this.inodeOffset(r);if((this.r32(i+C)&W)===H)throw new A(yu);let{parentIno:s,name:a}=this.pathResolveParent(t),c=_e.encode(a);this.inodeWriteLock(s);try{if(this.dirLookup(s,c)>=0)throw new A(Tt);let l=this.dirAddEntry(s,c,r);if(l<0)throw new A(l);this.inodeWriteLock(r);try{let d=this.r32(i+B);this.w32(i+B,d+1),this.w64(i+ee,Date.now())}finally{this.inodeWriteUnlock(r)}return{...this.namespaceEntryIdentity(r),linkCount:this.r32(i+B)}}finally{this.inodeWriteUnlock(s)}}readlink(e){return this.withNamespaceLock(()=>this.readlinkUnlocked(e))}readlinkUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new A(t);return this.readSymlinkInodeUnlocked(t)}readSymlinkInodeUnlocked(e){let t=this.inodeOffset(e);if((this.r32(t+C)&W)!==wt)throw new A(j);let i=this.r64(t+P);if(i<=40)return It(this.u8.subarray(t+oe,t+oe+i));this.inodeReadLock(e);try{let o=new Uint8Array(i);return this.inodeReadData(e,0,o,i),ur.decode(o)}finally{this.inodeReadUnlock(e)}}opendir(e){return this.withNamespaceLock(()=>this.opendirUnlocked(e))}opendirUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new A(t);let r=this.inodeOffset(t);if((this.r32(r+C)&W)!==H)throw new A(Fe);let o=this.fdAlloc(t,at,!0);if(o<0)throw new A(o);return o}readdirEntry(e){return this.withNamespaceLock(()=>this.readdirEntryUnlocked(e))}readdirEntryUnlocked(e){let t=this.fdGet(e);if(!t||!t.isDir)throw new A(re);let r=this.inodeOffset(t.ino),i=this.r64(r+P);for(;t.offset=this.r32(Ye))throw new A($);let p=this.r32(ct)*4096;if((this.r32(p+(l>>5)*4)&1<<(l&31))===0)throw new A($);let y=It(this.u8.subarray(u+F,u+F+h)),g=this.buildStat(l);return this.w64(f+ke,m),t.offset=m,{name:y,stat:g}}return null}closedir(e){this.close(e)}readdir(e){let t=this.opendir(e),r=[];try{let i;for(;(i=this.readdirEntry(t))!==null;)i.name!=="."&&i.name!==".."&&r.push(i.name)}finally{this.closedir(t)}return r}writeFile(e,t){let r=typeof t=="string"?_e.encode(t):t,i=this.open(e,Go|cr|dr);try{this.write(i,r)}finally{this.close(i)}}readFile(e){let t=this.open(e,at);try{let r=this.fstat(t),i=new Uint8Array(r.size);return this.read(t,i),i}finally{this.close(t)}}readFileText(e){return ur.decode(this.readFile(e))}};function ns(n,e){let t=new Map,r=new Map;for(let s of n){if(t.has(s.path))throw new Error(`${e} duplicates path ${s.path}`);if(t.set(s.path,s),s.type==="file"){if(!s.inodeGroup)throw new Error(`${e} file ${s.path} has no inode group`);if(r.has(s.inodeGroup))throw new Error(`${e} inode group ${s.inodeGroup} has multiple files`);r.set(s.inodeGroup,s)}}let i=new Set,o=new Map;for(let s of n){if(s.type!=="hardlink"||o.has(s.path))continue;let a=[],c=s,u;for(;c.type==="hardlink";){let d=o.get(c.path);if(d){u=d;break}if(i.has(c.path))throw new Error(`${e} hardlink cycle reaches ${c.path}`);if(i.add(c.path),a.push(c),!c.target)throw new Error(`${e} hardlink ${c.path} has no target`);let h=t.get(c.target);if(!h)throw new Error(`${e} hardlink ${c.path} target ${c.target} is missing`);if(h.type!=="file"&&h.type!=="hardlink"||!c.inodeGroup||h.inodeGroup!==c.inodeGroup||h.size!==c.size||h.mode!==c.mode)throw new Error(`${e} hardlink ${c.path} has an invalid target`);c=h}u??=c.type==="file"?c:void 0;let l=r.get(s.inodeGroup??"");if(!u||u!==l)throw new Error(`${e} hardlink ${s.path} does not resolve to its inode`);for(let d=a.length-1;d>=0;d-=1){let h=a[d];if(r.get(h.inodeGroup??"")!==u)throw new Error(`${e} hardlink ${h.path} does not resolve to its inode`);i.delete(h.path),o.set(h.path,u)}}return{canonicalByGroup:r,canonicalTargetByPath:o}}var D={maxArchiveBytes:268435456,maxExpandedBytes:268435456,maxPayloadBytes:268435456,maxEntries:1e5,maxPathBytes:4096,maxSymlinkTargetBytes:65536,maxStringBytes:8192,maxTransportsPerTree:8,maxActivationCapabilities:32,maxActivationRoots:64,maxActivationCapabilityBytes:255,maxMaterializationAssertions:32,maxMaterializationAssertionBytes:1048576,maxMaterializationRecipes:32,maxMaterializationTransforms:1e5,maxMaterializationDecodedBytes:8388608,maxTransformReplacements:32,maxTransformPatternBytes:8192},be={maxArchiveBytes:512*1024*1024,maxExpandedBytes:512*1024*1024,maxPayloadBytes:512*1024*1024,maxEntries:1e5,maxGroups:512};function is(n,e="Deferred tree collection"){for(let[t,r]of Object.entries(n))if(!Number.isSafeInteger(r)||r<0)throw new Error(`${e} ${t} usage is invalid`);if(n.groups>be.maxGroups)throw new Error(`${e} exceeds the ${be.maxGroups}-group cap`);if(n.archiveBytes>be.maxArchiveBytes)throw new Error(`${e} exceeds the archive-byte cap`);if(n.expandedBytes>be.maxExpandedBytes)throw new Error(`${e} exceeds the expansion cap`);if(n.payloadBytes>be.maxPayloadBytes)throw new Error(`${e} exceeds the payload-byte cap`);if(n.entries>be.maxEntries)throw new Error(`${e} exceeds the entry-count cap`)}function Rt(n,e="Canonical text"){for(let t=0;t57343)){if(r<=56319&&t+1=56320&&n.charCodeAt(t+1)<=57343){t+=1;continue}throw new Error(`${e} must contain only Unicode scalar values`)}}}function lr(n,e){Rt(n),Rt(e);let t=0,r=0;for(;t65535?2:1,r+=o>65535?2:1}return tD.maxEntries)throw new Error("Lazy tree materialization source inventory is unbounded");let t=new Map;for(let[h,m]of e.entries.entries()){let f=ti(m.sourcePath,`Lazy tree materialization source ${h} path`);if(t.has(f))throw new Error(`Lazy tree materialization source repeats ${f}`);if(m.type!=="directory"&&m.type!=="file"&&m.type!=="symlink"&&m.type!=="hardlink")throw new Error(`Lazy tree materialization source ${f} has invalid type`);ds(m.size,`Lazy tree materialization source ${f} byte count`,0,D.maxPayloadBytes),t.set(f,m)}let r=Lt(n,["schema","kind","assertions","recipes","transforms"],"Lazy tree materialization plan");if(r.schema!==1||r.kind!=="archive-byte-transforms-v1")throw new Error("Lazy tree materialization plan has an unsupported identity");let i=0,o=new Set,s=pr(r.assertions,"Lazy tree materialization assertions",0,D.maxMaterializationAssertions).map((h,m)=>{let f=Lt(h,["sourcePath","bytesHex"],`Lazy tree materialization assertion ${m}`),p=ti(f.sourcePath,`Lazy tree materialization assertion ${m} source path`);if(o.has(p))throw new Error(`Lazy tree materialization repeats assertion ${p}`);o.add(p);let _=t.get(p);if(_?.type!=="file")throw new Error(`Lazy tree materialization assertion ${p} is not a regular source`);let y=hr(f.bytesHex,`Lazy tree materialization assertion ${p} bytes`,D.maxMaterializationAssertionBytes,!0);if(i=jr(i,y.length/2),y.length/2!==_.size)throw new Error(`Lazy tree materialization assertion ${p} size differs from source`);return{sourcePath:p,bytesHex:y}}),a=new Map,c=pr(r.recipes,"Lazy tree materialization recipes",0,D.maxMaterializationRecipes).map((h,m)=>{let f=us(h,`Lazy tree materialization recipe ${m}`);if(a.has(f.recipe.id))throw new Error(`Lazy tree materialization duplicates recipe ${f.recipe.id}`);return i=jr(i,f.decodedBytes),a.set(f.recipe.id,f.recipe),f.recipe}),u=new Set,l=new Set,d=pr(r.transforms,"Lazy tree materialization transforms",0,D.maxMaterializationTransforms).map((h,m)=>{let f=Lt(h,["sourcePath","recipe","input","output"],`Lazy tree materialization transform ${m}`),p=ti(f.sourcePath,`Lazy tree materialization transform ${m} source path`);if(u.has(p))throw new Error(`Lazy tree materialization repeats transform ${p}`);u.add(p);let _=t.get(p);if(_?.type!=="file")throw new Error(`Lazy tree materialization transform ${p} is not a regular source`);let y=ii(f.recipe,`Lazy tree materialization transform ${p} recipe`,D.maxStringBytes);if(!a.has(y))throw new Error(`Lazy tree materialization transform ${p} has no recipe ${y}`);l.add(y);let g=os(f.input,`Lazy tree materialization transform ${p} input`),E=os(f.output,`Lazy tree materialization transform ${p} output`);if(g.bytes!==_.size)throw new Error(`Lazy tree materialization transform ${p} input size differs from source`);return{sourcePath:p,recipe:y,input:g,output:E}});if(s.length===0&&d.length===0)throw new Error("Lazy tree materialization plan has no assertions or transforms");if(c.some(h=>!l.has(h.id)))throw new Error("Lazy tree materialization plan contains an unused recipe");if(!ri(s.map(h=>h.sourcePath))||!ri(c.map(h=>h.id))||!ri(d.map(h=>h.sourcePath)))throw new Error("Lazy tree materialization plan is not in canonical order");return{schema:1,kind:"archive-byte-transforms-v1",assertions:s,recipes:c,transforms:d}}function fr(n){let e=hr(n,"Materialization bytes",D.maxMaterializationDecodedBytes,!0),t=new Uint8Array(e.length/2);for(let r=0;rD.maxPayloadBytes)throw new Error("Lazy tree byte transform exceeds its source-byte limit");let t=us(e,"Lazy tree byte transform recipe").recipe,r=n;for(let i of t.replacements)r=Au(r,fr(i.matchHex),fr(i.replacementHex));for(let i of t.rejectHex)if(zu(r,fr(i)))throw new Error(`Lazy tree byte transform retains rejected byte sequence ${i}`);return r}function us(n,e){let t=Lt(n,["id","replacements","rejectHex"],e),r=ii(t.id,`${e} id`,D.maxStringBytes);if(!xu(r))throw new Error(`${e} id is invalid`);let i=0,o=pr(t.replacements,`${e} replacements`,0,D.maxTransformReplacements).map((a,c)=>{let u=Lt(a,["matchHex","replacementHex"],`${e} replacement ${c}`),l=hr(u.matchHex,`${e} match`,D.maxTransformPatternBytes,!1),d=hr(u.replacementHex,`${e} replacement`,D.maxTransformPatternBytes,!0);return i=jr(i,l.length/2+d.length/2),{matchHex:l,replacementHex:d}}),s=pr(t.rejectHex,`${e} rejected patterns`,0,D.maxTransformReplacements).map((a,c)=>{let u=hr(a,`${e} rejected pattern ${c}`,D.maxTransformPatternBytes,!1);return i=jr(i,u.length/2),u});if(o.length===0&&s.length===0||new Set(s).size!==s.length)throw new Error(`${e} is empty or ambiguous`);return{recipe:{id:r,replacements:o,rejectHex:s},decodedBytes:i}}function Au(n,e,t){let r=0;for(let u=0;u<=n.byteLength-e.byteLength;)ni(n,e,u)?(r+=1,u+=e.byteLength):u+=1;if(r===0)return n;let i=t.byteLength-e.byteLength,o=n.byteLength+r*i;if(!Number.isSafeInteger(o)||o<0||o>D.maxPayloadBytes)throw new Error("Lazy tree byte transform exceeds its transformed-byte limit");let s=new Uint8Array(o),a=0,c=0;for(;an.byteLength)return!1;for(let t=0;t<=n.byteLength-e.byteLength;t+=1)if(ni(n,e,t))return!0;return!1}function ni(n,e,t){if(t+e.byteLength>n.byteLength)return!1;for(let r=0;rs!==o[a]))throw new Error(`${t} has unexpected fields`);return r}function pr(n,e,t,r){if(!Array.isArray(n)||n.lengthr)throw new Error(`${e} must contain ${t} to ${r} items`);return n}function ii(n,e,t){if(typeof n!="string")throw new Error(`${e} is invalid or exceeds ${t} bytes`);if(Rt(n,e),n.length===0||n.includes("\0")||new TextEncoder().encode(n).byteLength>t)throw new Error(`${e} is invalid or exceeds ${t} bytes`);return n}function ds(n,e,t,r){if(!Number.isSafeInteger(n)||Number(n)r)throw new Error(`${e} must be an integer between ${t} and ${r}`);return Number(n)}function hr(n,e,t,r){if(typeof n!="string"||!r&&n.length===0||n.length%2!==0||n.length/2>t||!ls(n))throw new Error(`${e} is not canonical bounded hexadecimal bytes`);return n}function ti(n,e){let t=ii(n,e,D.maxPathBytes);if(t.startsWith("/")||t.includes("\\")||t.split("/").some(r=>r===""||r==="."||r===".."))throw new Error(`${e} is not a canonical relative path`);return t}function jr(n,e){let t=n+e;if(!Number.isSafeInteger(t)||t>D.maxMaterializationDecodedBytes)throw new Error("Lazy tree materialization plan exceeds its decoded byte limit");return t}function ri(n){return n.every((e,t)=>t===0||lr(n[t-1],e)<0)}function xu(n){if(!ss(n.charCodeAt(0)))return!1;for(let e=1;e=97&&n<=122||n>=48&&n<=57}function ls(n,e){if(e!==void 0&&n.length!==e)return!1;for(let t=0;t=48&&r<=57)&&!(r>=97&&r<=102))return!1}return!0}var lt=Reflect.apply,hd=Object.create,md=Object.defineProperties,vi=Object.freeze,yd=Object.getOwnPropertyDescriptors,_d=Object.setPrototypeOf;var Ms=SharedArrayBuffer,gd=Uint8Array,Ed=Uint8Array.prototype.set,Sd=WeakSet.prototype.add,Bf=WeakSet.prototype.has,wd=WeakMap.prototype.get,Od=WeakMap.prototype.set,$f=Set.prototype.has,Uf=Map.prototype.get;var Ad=Number.isInteger,zd=TypeError,xd=le.mount,Id=le.mkfs,Td=le.prototype.snapshotState,Rd=new WeakSet,Ds=new WeakMap,Ld=1;function ea(n){let e=hd(null);return md(e,yd(n)),vi(e)}var bd=ea(le.prototype),Wf=vi({kind:"nosuid"}),Gf=vi({kind:"trusted-root-product",guestWritable:!1,stableExecutableIdentity:!0}),vd=Symbol("DeferredTreeMaterializationHandle"),Er=[40,181,47,253],zi=1447449417,xi=1,gi=1,nn=2,Ei=4,Si=8,ye=16,{S_IFMT:Ee,S_IFREG:kt,S_IFDIR:Qe,S_IFLNK:Sr}=ie,{DT_UNKNOWN:Pd,DT_REG:kd,DT_DIR:Fd,DT_LNK:Nd}=wo,Cd=He.O_RDONLY,Hf=He.O_ACCMODE,Vf=He.O_CREAT,qf=He.O_TRUNC,Zf=Eo.W_OK,Ks=He.O_WRONLY|He.O_CREAT|He.O_TRUNC,Md=1024*1024,Dd=16*1024*1024,Ct=64*1024,on=16*1024*1024,sn=16*1024*1024,Bs=D.maxArchiveBytes,Kd=D.maxExpandedBytes,an=D.maxPayloadBytes,Bd=2,$d=4,Mt=D.maxEntries,ta=be.maxGroups,ln=D.maxPathBytes,ra=D.maxSymlinkTargetBytes,Pi=D.maxStringBytes,Ud=D.maxActivationCapabilities,Wd=D.maxActivationRoots,$s=D.maxActivationCapabilityBytes,Us=4294967294,Ws=3,Gd=250,na=5e3,Ii=/^[0-9a-f]{64}$/,wr="kandelo-legacy-zip-v1",Or="kandelo-deferred-tree-v1",Ti="kandelo-deferred-tree-v2",ft="kandelo-deferred-tree-v3",Hd=new Set(["ECONNABORTED","ECONNREFUSED","ECONNRESET","EHOSTUNREACH","ENETDOWN","ENETRESET","ENETUNREACH","EPIPE","ETIMEDOUT","EAI_AGAIN","UND_ERR_CONNECT_TIMEOUT","UND_ERR_HEADERS_TIMEOUT","UND_ERR_SOCKET"]),cn=class extends Error{constructor(t,r){super(`HTTP ${t}`);this.status=t;this.retryAfterMs=r;this.name="LazyHttpResponseError"}status;retryAfterMs};function Ar(n){if(typeof n!="string"||!n.startsWith("/")||new TextEncoder().encode(n).byteLength>ln||n.includes("\0")||n.includes("\\"))throw new Error(`Lazy archive mount prefix must be an absolute POSIX path: ${JSON.stringify(n)}`);let e=n.replace(/\/+$/,"");if(e==="")return"/";if(e.slice(1).split("/").some(r=>r===""||r==="."||r===".."))throw new Error(`Lazy archive mount prefix is not canonical: ${JSON.stringify(n)}`);return e}function Vd(n,e,t,r){let i=Ar(t),o=new Map,s=e.map(a=>{let c=a.fileName,u=`Lazy archive ${JSON.stringify(n)} member ${JSON.stringify(c)}`;if(c.length===0)throw new Error(`${u} has an empty path`);if(c.includes("\0"))throw new Error(`${u} contains a NUL byte`);if(c.includes("\\"))throw new Error(`${u} contains a backslash`);if(c.startsWith("/")||/^[A-Za-z]:\//.test(c))throw new Error(`${u} must be relative, not absolute`);if(a.isDirectory&&a.isSymlink)throw new Error(`${u} has conflicting directory and symlink types`);if(a.isDirectory!==c.endsWith("/"))throw new Error(`${u} has inconsistent directory metadata`);let l=a.isDirectory?c.slice(0,-1):c,d=l.split("/");if(l.length===0||d.some(h=>h===""||h==="."||h===".."))throw new Error(`${u} is not a canonical relative POSIX path`);if(o.has(l))throw new Error(`${u} collides with another member at ${JSON.stringify(l)}`);if(a.isSymlink&&!r?.has(c))throw new Error(`Lazy archive symlink target was not provided: ${c}`);return o.set(l,a),{entry:a,archivePath:l,vfsPath:i==="/"?`/${l}`:`${i}/${l}`}});for(let{archivePath:a}of s){let c=a.split("/");for(let u=1;uCt)throw new Error(`VFS image metadata exceeds ${Ct} bytes`);let e;try{e=JSON.parse(new TextDecoder().decode(n))}catch(t){let r=t instanceof Error?t.message:String(t);throw new Error(`Invalid VFS image metadata JSON: ${r}`)}return ki(e)}function Zd(n){if(n===null)return new Uint8Array(0);let e=ki(n),t=new TextEncoder().encode(JSON.stringify(e));if(t.byteLength>Ct)throw new Error(`VFS image metadata exceeds ${Ct} bytes`);return t}function Yd(n){return n.byteLength>=Er.length&&n[0]===Er[0]&&n[1]===Er[1]&&n[2]===Er[2]&&n[3]===Er[3]?El(n):n}function Qr(n){let e=Yd(n);if(e.byteLengthon)throw new Error(`VFS image lazy metadata exceeds ${on} bytes`);if(n.byteLengthsn)throw new Error(`VFS image lazy archive metadata exceeds ${sn} bytes`);if(n.byteLength=0?r:void 0}function Jd(n){return n===408||n===429||n>=500&&n<=599}function Qd(n,e=Date.now()){let t=n?.get("retry-after")?.trim();if(!t)return;let r;if(/^\d+$/.test(t))r=Number(t)*1e3;else{let i=Date.parse(t);if(!Number.isFinite(i))return;r=Math.max(0,i-e)}if(!(!Number.isSafeInteger(r)||r<0))return Math.min(r,na)}function el(n){if(!(typeof n!="object"||n===null||!("cause"in n)))return n.cause}function ia(n){if(!(typeof n!="object"||n===null||!("name"in n)))return typeof n.name=="string"?n.name:void 0}function oa(n){if(!(typeof n!="object"||n===null||!("code"in n)))return typeof n.code=="string"?n.code:void 0}function sa(n,e){let t=new Set,r=n;for(let i=0;r!==void 0&&i<8;i+=1){if(t.has(r))return!1;if(t.add(r),e(r))return!0;r=el(r)}return!1}function aa(n){return sa(n,e=>ia(e)==="AbortError"||oa(e)==="ABORT_ERR")}function tl(n){return aa(n)?!1:sa(n,e=>{let t=ia(e),r=oa(e);return e instanceof TypeError||t==="NetworkError"||t==="TimeoutError"||r!==void 0&&Hd.has(r)})}function rl(n,e){if(n instanceof cn){if(!Jd(n.status))return null;if(n.retryAfterMs!==void 0)return n.retryAfterMs}else if(!tl(n))return null;return Math.min(Gd*2**e,na)}function te(n){if(n?.aborted)throw n.reason}function nl(n,e){return te(e),n===0?Promise.resolve():new Promise((t,r)=>{let i=setTimeout(()=>a(!1),n),o=()=>a(!0,e.reason),s=!1;function a(c,u){s||(s=!0,clearTimeout(i),e?.removeEventListener("abort",o),c?r(u):t())}e?.addEventListener("abort",o,{once:!0}),e?.aborted&&o()})}async function wi(n,e){try{await n.body?.cancel(e)}catch{}}function il(n,e){if(n.length===1)return n[0];let t=new Uint8Array(e),r=0;for(let i of n)t.set(i,r),r+=i.byteLength;return t}function zr(n){if(n===void 0)return;if(typeof n!="object"||n===null||Array.isArray(n))throw new Error("Lazy archive integrity must be an object");let e=n;if(Object.keys(e).length!==2||!("sha256"in e)||!("bytes"in e))throw new Error("Lazy archive integrity has unexpected fields");if(typeof e.sha256!="string"||!Ii.test(e.sha256))throw new Error("Lazy archive integrity has an invalid SHA-256 digest");if(!Number.isSafeInteger(e.bytes)||Number(e.bytes)<=0||Number(e.bytes)>Bs)throw new Error(`Lazy archive integrity byte count must be between 1 and ${Bs}`);return{sha256:e.sha256,bytes:Number(e.bytes)}}function et(n,e,t){if(typeof n!="object"||n===null||Array.isArray(n))throw new Error(`${t} must be an object`);let r=n;if(Object.keys(r).length!==e.length||e.some(o=>!Object.prototype.hasOwnProperty.call(r,o)))throw new Error(`${t} has unexpected or missing fields`);return r}function Ri(n,e,t,r){if(typeof n!="object"||n===null||Array.isArray(n))throw new Error(`${r} must be an object`);let i=n,o=new Set(e);if(Object.keys(i).some(s=>!o.has(s))||t.some(s=>!Object.prototype.hasOwnProperty.call(i,s)))throw new Error(`${r} has unexpected or missing fields`);return i}function Me(n,e,t,r){if(!Array.isArray(n)||n.lengthr)throw new Error(`${e} must contain ${t} to ${r} items`);return n}function Se(n,e,t){if(typeof n!="string")throw new Error(`${e} is invalid or exceeds ${t} bytes`);if(Rt(n,e),n.length===0||n.includes("\0")||new TextEncoder().encode(n).byteLength>t)throw new Error(`${e} is invalid or exceeds ${t} bytes`);return n}function ce(n,e,t,r){if(!Number.isSafeInteger(n)||Number(n)r)throw new Error(`${e} must be an integer between ${t} and ${r}`);return Number(n)}function un(n,e=1){let t=n,r=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.source!==void 0,i=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.modePolicy!==void 0,o=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.materialization!==void 0,s=et(n,["decoder","mediaType","sha256","bytes","expandedBytes","sourceEntryCount","transports",...i?["modePolicy"]:[],...r?["source"]:[],...o?["materialization"]:[]],"Lazy tree content"),a=s.decoder==="zip-v1"?"application/zip":s.decoder==="tar-gzip-v1"?"application/vnd.oci.image.layer.v1.tar+gzip":null;if(a===null||s.mediaType!==a)throw new Error("Lazy tree decoder and media type are inconsistent");let c=zr({sha256:s.sha256,bytes:s.bytes});if(!c)throw new Error("Lazy tree integrity is required");let u=Me(s.transports,"Lazy tree transports",e,D.maxTransportsPerTree).map((p,_)=>Se(p,`Lazy tree transport ${_}`,Pi));if(new Set(u).size!==u.length)throw new Error("Lazy tree transports contain duplicates");let l=ce(s.expandedBytes,"Lazy tree expanded byte count",0,Kd),d=ce(s.sourceEntryCount,"Lazy tree source entry count",1,Mt),h=r?al(s.source,s.decoder):void 0,m=o?as(s.materialization,h):void 0,f=i?s.modePolicy:void 0;if(f!==void 0&&(f!=="portable-posix-v1"||s.decoder!=="zip-v1"||r))throw new Error("Lazy tree mode policy is invalid for its decoder");if(h!==void 0&&h.entries.length!==d)throw new Error("Lazy tree source inventory count differs from its content");return{decoder:s.decoder,mediaType:a,sha256:c.sha256,bytes:c.bytes,expandedBytes:l,sourceEntryCount:d,transports:u,...f===void 0?{}:{modePolicy:f},...h===void 0?{}:{source:h},...m===void 0?{}:{materialization:m}}}function ca(n){let e={groups:n.length,archiveBytes:0,expandedBytes:0,payloadBytes:0,entries:0};for(let t of n)t.content===void 0||t.inventory===void 0||(e.archiveBytes+=t.content.bytes,e.expandedBytes+=t.content.expandedBytes,e.payloadBytes+=t.inventory.filter(r=>r.type==="file").reduce((r,i)=>r+i.size,0),e.entries+=t.inventory.length+(t.content.source?.entries.length??0));return e}function Li(n){is(n,"Serialized lazy tree collection")}function ol(n){Li(ca(n))}function sl(n){let e=new Map;for(let t of n){let r=t.activation?.atomicGroup;if(r===void 0)continue;if(!Ft(r))throw new Error(`Serialized lazy atomic activation group ${r.id} is unsealed`);let i=e.get(r.id);if(i===void 0)i={expectedCount:r.expectedCount,cohortSha256:r.cohortSha256,members:new Set,descriptors:new Set},e.set(r.id,i);else if(i.expectedCount!==r.expectedCount||i.cohortSha256!==r.cohortSha256)throw new Error(`Serialized lazy atomic activation group ${r.id} has inconsistent seals`);if(i.members.has(r.member)||i.descriptors.has(r.descriptorSha256))throw new Error(`Serialized lazy atomic activation group ${r.id} duplicates a member`);i.members.add(r.member),i.descriptors.add(r.descriptorSha256)}for(let[t,r]of e)if(r.members.size!==r.expectedCount)throw new Error(`Serialized lazy atomic activation group ${t} has ${r.members.size} of ${r.expectedCount} members`)}function qs(n){for(let[e,t]of n.entries())if(t.kind===Or||t.kind===Ti||t.kind===ft)la(t,t.kind);else if(t.kind===wr)bi(t,!1);else throw new Error(`Serialized lazy archive group ${e} has an unsupported kind`);ol(n),sl(n)}function al(n,e){if(e!=="zip-v1"&&e!=="tar-gzip-v1")throw new Error("Lazy tree source inventory requires a supported archive decoder");let t=et(n,["schema","kind","entries"],"Lazy tree source inventory");if(t.schema!==1||t.kind!=="archive-source-inventory-v1")throw new Error("Lazy tree source inventory has an unsupported identity");let r=new Map,i=Me(t.entries,"Lazy tree source entries",1,Mt).map((s,a)=>{let c=s,u=typeof c=="object"&&c!==null&&!Array.isArray(c)?c.type:void 0,l=u==="directory"||u==="file"?["sourcePath","type","mode","size"]:u==="symlink"||u==="hardlink"?["sourcePath","type","mode","size","target"]:null;if(l===null)throw new Error(`Lazy tree source entry ${a} has invalid type`);let d=et(s,l,`Lazy tree source entry ${a}`),h=we(d.sourcePath,!1,`Lazy tree source entry ${a} path`);if(r.has(h))throw new Error(`Lazy tree source inventory duplicates ${h}`);let m=ce(d.mode,`Lazy tree source entry ${h} mode`,0,ie.S_MODE_BITS),f=ce(d.size,`Lazy tree source entry ${h} size`,0,an),p;if((u==="directory"||u==="symlink"||u==="hardlink")&&f!==0)throw new Error(`Lazy tree source ${h} has payload for ${String(u)}`);u==="symlink"?p=Se(d.target,`Lazy tree source symlink ${h} target`,ra):u==="hardlink"&&(p=we(d.target,!1,`Lazy tree source hardlink ${h} target`));let _={sourcePath:h,type:u,mode:m,size:f,...p===void 0?{}:{target:p}};return r.set(h,_),_}),o=i.map(s=>s.sourcePath);if(o.some((s,a)=>a>0&&lr(o[a-1],s)>=0))throw new Error("Lazy tree source inventory is not in canonical path order");return{schema:1,kind:"archive-source-inventory-v1",entries:i}}function cl(n){let e=new Map(n.map(r=>[r.sourcePath,r])),t=new Map;for(let r of n){if(r.type!=="hardlink"||t.has(r.sourcePath))continue;let i=[],o=new Set,s=r,a;for(;s.type==="hardlink"&&(a=t.get(s.sourcePath),a===void 0);){if(o.has(s.sourcePath))throw new Error(`Lazy tree source hardlink cycle includes ${s.sourcePath}`);o.add(s.sourcePath),i.push(s);let c=e.get(s.target);if(c===void 0)throw new Error(`Lazy tree source hardlink ${s.sourcePath} target is absent`);if(c.type!=="file"&&c.type!=="hardlink")throw new Error(`Lazy tree source hardlink ${s.sourcePath} target is not regular`);s=c}a===void 0&&(a=s);for(let c of i)t.set(c.sourcePath,a)}return t}function we(n,e,t,r=!1){if(typeof n!="string"||n.length===0||new TextEncoder().encode(n).byteLength>ln||n.includes("\0")||n.includes("\\")||n.startsWith("/")!==e)throw new Error(`${t} is not a canonical ${e?"absolute":"relative"} path`);if(r&&e&&n==="/")return n;if(n.slice(e?1:0).split("/").some(o=>o===""||o==="."||o===".."))throw new Error(`${t} has an unsafe path segment`);return n}function ua(n){let e=typeof n=="object"&&n!==null&&!Array.isArray(n)?n:null,t=e!==null&&(Object.hasOwn(e,"descriptorSha256")||Object.hasOwn(e,"expectedCount")||Object.hasOwn(e,"cohortSha256")),r=et(n,t?["id","member","descriptorSha256","expectedCount","cohortSha256"]:["id","member"],"Lazy tree atomic activation membership"),i=Se(r.id,"Lazy tree atomic activation group",$s),o=Se(r.member,"Lazy tree atomic activation member",$s);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(i)||!/^[a-z0-9][a-z0-9:+._/-]*$/.test(o)||o.includes("//")||o.endsWith("/"))throw new Error("Lazy tree atomic activation membership is invalid");if(!t)return{id:i,member:o};let s=Se(r.descriptorSha256,"Lazy tree atomic member descriptor digest",64),a=Se(r.cohortSha256,"Lazy tree atomic cohort digest",64);if(!Ii.test(s)||!Ii.test(a))throw new Error("Lazy tree atomic activation digest is invalid");return{id:i,member:o,descriptorSha256:s,expectedCount:ce(r.expectedCount,"Lazy tree atomic activation expected member count",1,ta),cohortSha256:a}}function Ft(n){return n.descriptorSha256!==void 0&&n.expectedCount!==void 0&&n.cohortSha256!==void 0}function ul(n){let e=et(n,["uid","gid"],"Lazy tree registration owner");return{uid:ce(e.uid,"Lazy tree registration owner uid",0,Us),gid:ce(e.gid,"Lazy tree registration owner gid",0,Us)}}function da(n,e,t,r,i=1){let o=un(n,i),s=Ar(t),a=["mode","capabilities","roots",...typeof r=="object"&&r!==null&&!Array.isArray(r)&&Object.hasOwn(r,"atomicGroup")?["atomicGroup"]:[]],c=et(r,a,"Lazy tree activation");if(c.mode!=="boot-prefetch"&&c.mode!=="first-use")throw new Error("Lazy tree activation mode is invalid");let u=Me(c.capabilities,"Lazy tree activation capabilities",1,Ud).map((z,x)=>{let I=Se(z,`Lazy tree activation capability ${x}`,D.maxActivationCapabilityBytes);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(I))throw new Error(`Lazy tree activation capability ${x} is invalid`);return I}),l=Me(c.roots,"Lazy tree activation roots",1,Wd).map((z,x)=>we(z,!0,`Lazy tree activation root ${x}`,!0));if(new Set(u).size!==u.length||new Set(l).size!==l.length)throw new Error("Lazy tree activation contains duplicates");let d=c.atomicGroup===void 0?void 0:ua(c.atomicGroup);if(d!==void 0&&c.mode!=="first-use")throw new Error("Lazy tree atomic activation group requires a valid first-use identity");let h={mode:c.mode,capabilities:u,roots:l,...d===void 0?{}:{atomicGroup:d}},m=Me(e,"Lazy tree inventory",1,Mt),f=[],p=new Map,_=new Map,y=o.source===void 0?void 0:new Map(o.source.entries.map(z=>[z.sourcePath,z])),g=o.source===void 0?void 0:cl(o.source.entries),E=new Map(o.materialization?.transforms.map(z=>[z.sourcePath,z])??[]),O=0;for(let[z,x]of m.entries()){if(typeof x!="object"||x===null||Array.isArray(x))throw new Error(`Lazy tree entry ${z} must be an object`);let I=x.type,R=I==="directory"?["vfsPath","sourcePath","type","mode","size"]:I==="file"?["vfsPath","sourcePath","type","mode","size","inodeGroup"]:I==="symlink"?["vfsPath","sourcePath","type","mode","size","target"]:I==="hardlink"?["vfsPath","sourcePath","type","mode","size","target","inodeGroup"]:null;if(!R)throw new Error(`Lazy tree entry ${z} has an invalid type`);let L=et(x,[...R,...y===void 0?[]:["materialization"]],`Lazy tree entry ${z}`),v=we(L.vfsPath,!0,`Lazy tree entry ${z} VFS path`),Z=we(L.sourcePath,!1,`Lazy tree entry ${z} source path`),N=y===void 0?void 0:L.materialization;if(y!==void 0&&N!=="archive"&&N!=="archive-copy"&&N!=="archive-copy-mode"&&N!=="descriptor")throw new Error(`Lazy tree entry ${v} has invalid materialization provenance`);if(s!=="/"&&v!==s&&!v.startsWith(`${s}/`))throw new Error(`Lazy tree entry ${v} escapes its mount prefix`);if(p.has(v))throw new Error(`Lazy tree duplicates VFS path ${v}`);let k=ce(L.mode,`Lazy tree entry ${v} mode`,0,ie.S_MODE_BITS),G=ce(L.size,`Lazy tree entry ${v} size`,0,an),ue,Oe;if(I==="directory"){if(G!==0)throw new Error(`Lazy tree directory ${v} has nonzero size`)}else if(I==="symlink"){if(ue=Se(L.target,`Lazy tree symlink ${v} target`,ra),new TextEncoder().encode(ue).byteLength!==G)throw new Error(`Lazy tree symlink ${v} size differs from its target`)}else Oe=Se(L.inodeGroup,`Lazy tree entry ${v} inode group`,ln),I==="hardlink"&&(ue=we(L.target,!0,`Lazy tree hardlink ${v} target`));if(I!=="hardlink"&&(O+=G,O>an))throw new Error("Lazy tree inventory exceeds the expansion limit");let M={vfsPath:v,sourcePath:Z,...N===void 0?{}:{materialization:N},type:I,mode:k,size:G,...ue===void 0?{}:{target:ue},...Oe===void 0?{}:{inodeGroup:Oe}};if(y===void 0){let de=_.get(Z);if(de){if(o.decoder!=="zip-v1"||M.type!=="hardlink"||de.inodeGroup!==M.inodeGroup)throw new Error(`Lazy tree duplicates source path ${Z}`)}else{if(o.decoder==="zip-v1"&&M.type==="hardlink")throw new Error(`Lazy ZIP hardlink ${v} does not reuse a canonical source path`);_.set(Z,M)}}else if(M.materialization==="descriptor"){if(M.type!=="directory"&&M.type!=="symlink")throw new Error(`Lazy tree descriptor entry ${v} is not structural`);if(y.has(Z))throw new Error(`Lazy tree descriptor entry ${v} impersonates a source member`)}else{let de=y.get(Z);if(de===void 0)throw new Error(`Lazy tree entry ${v} names absent source ${Z}`);if(M.materialization==="archive-copy"||M.materialization==="archive-copy-mode"){if(M.type!=="file"||de.type!=="file"||M.materialization==="archive-copy"&&M.mode!==de.mode)throw new Error(`Lazy tree archive copy ${v} differs from its source`)}else if(de.type!==M.type||M.type==="symlink"&&de.target!==M.target||M.type!=="hardlink"&&de.mode!==M.mode)throw new Error(`Lazy tree archive entry ${v} differs from its source`)}f.push(M),p.set(v,M)}for(let z of f){let x=z.vfsPath.split("/").filter(Boolean);for(let I=1;I({path:z.vfsPath,type:z.type,mode:z.mode,size:z.size,target:z.target,inodeGroup:z.inodeGroup})),"Lazy tree");if(y!==void 0){let z=new Set;for(let x of f){if(x.materialization==="descriptor"||x.type!=="file"&&x.type!=="hardlink")continue;let I=y.get(x.sourcePath),R=I.type==="file"?I:g.get(I.sourcePath);R?.type==="file"&&z.add(R.sourcePath);let L=R?.type==="file"?E.get(R.sourcePath):void 0;if(R?.type!=="file"||x.size!==(L?.output.bytes??R.size))throw new Error(`Lazy tree archive entry ${x.vfsPath} differs from its source`)}for(let x of E.keys())if(!z.has(x))throw new Error(`Lazy tree materialization transform ${x} has no destination`);for(let x of f){if(x.type!=="hardlink"||x.materialization!=="archive")continue;let I=y.get(x.sourcePath),R=p.get(x.target),L=g.get(I.sourcePath);if(I.target!==R?.sourcePath||L?.type!=="file"||L.mode!==x.mode||R?.mode!==x.mode)throw new Error(`Lazy tree hardlink ${x.vfsPath} differs from its source`)}}if(o.sourceEntryCount!==(y===void 0?_.size:y.size))throw new Error("Lazy tree source entry count differs from its inventory");if(o.source===void 0&&o.expandedBytesx.vfsPath===z||x.vfsPath.startsWith(`${z}/`)))throw new Error(`Lazy tree activation root ${z} is not owned by its inventory`);let w=new Map;for(let z of f)z.type==="file"&&w.set(z.inodeGroup,z);if(w.size!==S.canonicalByGroup.size)throw new Error("Lazy tree regular inode inventory is inconsistent");return{content:o,entries:f,mountPrefix:s,activation:h,canonicalByGroup:w}}function dn(n){return JSON.stringify([n.sourcePath,n.type,n.inodeGroup,n.target])}function bi(n,e){let t=Ri(n,["kind","content","url","mountPrefix","integrity","materialized","entries"],["url","mountPrefix","materialized","entries"],"Serialized legacy lazy archive");if(t.kind===void 0){if(!e)throw new Error("Serialized lazy archive is missing its kind discriminator")}else if(t.kind!==wr)throw new Error("Serialized legacy lazy archive has an unsupported kind");let r=Se(t.url,"Serialized legacy lazy archive URL",Pi),i=Ar(t.mountPrefix),o=zr(t.integrity);if(t.content!==void 0){if(!e||t.kind!==void 0)throw new Error("Typed legacy lazy archives cannot carry generic content");let c=un(t.content);if(c.decoder!=="zip-v1"||c.transports.length!==1||c.transports[0]!==r||!o||c.sha256!==o.sha256||c.bytes!==o.bytes)throw new Error("Untagged legacy ZIP content identity is inconsistent")}if(t.materialized!==!1)throw new Error("Serialized legacy lazy archive must describe pending content");let s=new Set,a=Me(t.entries,"Serialized legacy lazy archive entries",1,Mt).map((c,u)=>{let l=Ri(c,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","size","isSymlink","deleted"],`Serialized legacy lazy archive entry ${u}`),d=we(l.vfsPath,!0,`Serialized legacy lazy archive entry ${u} VFS path`);if(s.has(d))throw new Error(`Serialized legacy lazy archive duplicates path ${d}`);s.add(d);let h=ce(l.ino,`Serialized legacy lazy archive entry ${d} inode`,1,Number.MAX_SAFE_INTEGER),m=l.generation===void 0?void 0:ce(l.generation,`Serialized legacy lazy archive entry ${d} generation`,0,Number.MAX_SAFE_INTEGER),f=l.dataSequence===void 0?void 0:ce(l.dataSequence,`Serialized legacy lazy archive entry ${d} data sequence`,0,Number.MAX_SAFE_INTEGER),p=ce(l.size,`Serialized legacy lazy archive entry ${d} size`,0,an);if(l.isSymlink!==!1||l.deleted!==!1||l.materialized!==void 0&&l.materialized!==!1)throw new Error(`Serialized legacy lazy archive entry ${d} is not pending`);if(l.type!==void 0&&l.type!=="file")throw new Error(`Serialized legacy lazy archive entry ${d} has an invalid type`);let _=l.archivePath===void 0?void 0:we(l.archivePath,!1,`Serialized legacy lazy archive entry ${d} archive path`),y=l.sourcePath===void 0?void 0:we(l.sourcePath,!1,`Serialized legacy lazy archive entry ${d} source path`),g=l.inodeGroup===void 0?void 0:Se(l.inodeGroup,`Serialized legacy lazy archive entry ${d} inode group`,ln);if(l.target!==void 0)throw new Error(`Serialized legacy lazy archive entry ${d} has a link target`);return{vfsPath:d,ino:h,...m===void 0?{}:{generation:m},...f===void 0?{}:{dataSequence:f},size:p,isSymlink:!1,deleted:!1,materialized:!1,..._===void 0?{}:{archivePath:_},...y===void 0?{}:{sourcePath:y},type:"file",...g===void 0?{}:{inodeGroup:g}}});return{kind:wr,url:r,mountPrefix:i,...o===void 0?{}:{integrity:o},materialized:!1,entries:a}}function la(n,e){let t=et(n,["kind","content","inventory","activation","url","mountPrefix","integrity","materialized","entries"],"Serialized lazy tree");if(t.kind!==e)throw new Error("Serialized lazy tree has an unsupported kind");let r=da(t.content,t.inventory,t.mountPrefix,t.activation);if(e!==ft&&e===Or!=(r.content.source===void 0))throw new Error(e===Or?"Serialized deferred-tree-v1 cannot contain complete source metadata":"Serialized deferred-tree-v2 requires complete source metadata");let i=r.activation.atomicGroup;if(e===ft?i===void 0||!Ft(i):i!==void 0)throw new Error(e===ft?"Serialized deferred-tree-v3 requires a sealed atomic activation":"Atomic activation requires serialized deferred-tree-v3");let o=Se(t.url,"Serialized lazy tree URL",Pi);if(o!==r.content.transports[0])throw new Error("Serialized lazy tree URL differs from its primary transport");let s=zr(t.integrity);if(!s||s.sha256!==r.content.sha256||s.bytes!==r.content.bytes)throw new Error("Serialized lazy tree integrity differs from its content");if(t.materialized!==!1)throw new Error("Serialized lazy tree must describe pending content");let a=new Map(r.entries.map(h=>[h.vfsPath,h])),c=new Map(r.entries.map(h=>[dn(h),h])),u=Me(t.entries,"Serialized lazy tree entries",0,Mt),l=new Set,d=u.map((h,m)=>{let f=Ri(h,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup"],`Serialized lazy tree entry ${m}`),p=we(f.vfsPath,!0,`Serialized lazy tree entry ${m} VFS path`);if(l.has(p))throw new Error(`Serialized lazy tree duplicates pending path ${p}`);l.add(p);let _=we(f.sourcePath,!1,`Serialized lazy tree entry ${m} source path`),y=we(f.archivePath,!1,`Serialized lazy tree entry ${m} archive path`),g=a.get(p),E=c.get(dn({sourcePath:_,type:typeof f.type=="string"?f.type:void 0,inodeGroup:typeof f.inodeGroup=="string"?f.inodeGroup:void 0,target:typeof f.target=="string"?f.target:void 0}))??g;if(!E||E.type!=="file"&&E.type!=="hardlink"||g?.inodeGroup!==void 0&&g.inodeGroup!==E.inodeGroup)throw new Error(`Serialized lazy tree entry ${p} is absent from its inventory`);let O=r.canonicalByGroup.get(E.inodeGroup);if(f.type!==E.type||f.inodeGroup!==E.inodeGroup||f.size!==E.size||y!==O?.sourcePath||f.target!==E.target||f.isSymlink!==!1||f.deleted!==!1||f.materialized!==!1)throw new Error(`Serialized lazy tree entry ${p} disagrees with its inventory`);let S=ce(f.ino,`Serialized lazy tree entry ${p} inode`,1,Number.MAX_SAFE_INTEGER),w=ce(f.generation,`Serialized lazy tree entry ${p} generation`,0,Number.MAX_SAFE_INTEGER),z=ce(f.dataSequence,`Serialized lazy tree entry ${p} data sequence`,0,Number.MAX_SAFE_INTEGER);return{vfsPath:p,ino:S,generation:w,dataSequence:z,size:E.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:y,sourcePath:_,type:E.type,inodeGroup:E.inodeGroup,...E.target===void 0?{}:{target:E.target}}});for(let h of r.entries)if(r.activation.atomicGroup!==void 0&&(h.type==="file"||h.type==="hardlink")&&!l.has(h.vfsPath))throw new Error(`Serialized lazy tree omits pending path ${h.vfsPath}`);return{kind:e,content:r.content,inventory:r.entries,activation:r.activation,url:o,mountPrefix:r.mountPrefix,integrity:s,materialized:!1,entries:d}}async function Nt(n,e){let t=globalThis.crypto?.subtle;if(!t)throw new Error(`${e} SHA-256 verification is unavailable`);let r=new Uint8Array(n.byteLength);r.set(n);let i=new Uint8Array(await t.digest("SHA-256",r));return Array.from(i,o=>o.toString(16).padStart(2,"0")).join("")}async function Oi(n,e,t){if(t===void 0)return;if(n.byteLength!==t.bytes)throw new Error(`Lazy ${e} byte count ${n.byteLength} does not match expected ${t.bytes}`);let r=await Nt(n,`Lazy ${e}`);if(r!==t.sha256)throw new Error(`Lazy ${e} SHA-256 ${r} does not match expected ${t.sha256}`)}async function Zs(n,e,t){if(n.byteLength!==e.bytes)throw new Error(`${t} byte count ${n.byteLength} does not match expected ${e.bytes}`);let r=await Nt(n,t);if(r!==e.sha256)throw new Error(`${t} SHA-256 ${r} does not match expected ${e.sha256}`)}function dl(n,e,t,r){let i=r.atomicGroup;if(i===void 0)throw new Error("Lazy atomic member is missing its typed tree descriptor");let o={schema:1,content:{decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...n.source===void 0?{}:{source:n.source},...n.materialization===void 0?{}:{materialization:n.materialization}},mountPrefix:t,inventory:[...e].sort((s,a)=>lr(s.vfsPath,a.vfsPath)),activation:{mode:r.mode,capabilities:r.capabilities,roots:r.roots,atomicGroup:{id:i.id,member:i.member}}};return new TextEncoder().encode(JSON.stringify(o))}function Ys(n,e){let t=e.map(({member:r,descriptorSha256:i})=>({member:r,descriptorSha256:i}));return new TextEncoder().encode(JSON.stringify({schema:1,id:n,members:t.sort((r,i)=>r.memberi.member?1:0)}))}function ll(n,e){if(n.byteLength!==e.byteLength)return!1;for(let t=0;tObject.freeze({...o}))};r!==void 0&&(Object.freeze(r.entries),Object.freeze(r));let i=n.materialization===void 0?void 0:fl(n.materialization);return Object.freeze({decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,transports:t,...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...r===void 0?{}:{source:r},...i===void 0?{}:{materialization:i}})}function fa(n){return{schema:1,kind:"archive-byte-transforms-v1",assertions:n.assertions.map(e=>({...e})),recipes:n.recipes.map(e=>({id:e.id,replacements:e.replacements.map(t=>({...t})),rejectHex:[...e.rejectHex]})),transforms:n.transforms.map(e=>({sourcePath:e.sourcePath,recipe:e.recipe,input:{...e.input},output:{...e.output}}))}}function fl(n){let e=fa(n);for(let t of e.assertions)Object.freeze(t);Object.freeze(e.assertions);for(let t of e.recipes){for(let r of t.replacements)Object.freeze(r);Object.freeze(t.replacements),Object.freeze(t.rejectHex),Object.freeze(t)}Object.freeze(e.recipes);for(let t of e.transforms)Object.freeze(t.input),Object.freeze(t.output),Object.freeze(t);return Object.freeze(e.transforms),Object.freeze(e)}function en(n){return{decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,transports:[...n.transports],...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...n.source===void 0?{}:{source:{schema:1,kind:"archive-source-inventory-v1",entries:n.source.entries.map(e=>({...e}))}},...n.materialization===void 0?{}:{materialization:fa(n.materialization)}}}function pa(n){let e=n.map(t=>Object.freeze({...t}));return Object.freeze(e),e}function pl(n,e,t){let r=[...n.capabilities],i=[...n.roots];return Object.freeze(r),Object.freeze(i),Object.freeze({mode:n.mode,capabilities:r,roots:i,atomicGroup:Object.freeze({id:e,member:t})})}function hl(n){let e=[...n.capabilities],t=[...n.roots];return Object.freeze(e),Object.freeze(t),Object.freeze({mode:n.mode,capabilities:e,roots:t})}function tn(n,e,t,r,i,o,s,a){let c=s.map(u=>Object.freeze({...u}));return Object.freeze(c),Object.freeze({content:fn(n),inventory:pa(e),activation:hl(t),url:r,mountPrefix:i,integrity:Object.freeze({...o}),entries:c,materialized:a})}function ml(n,e,t){let r=e.map(i=>Object.freeze({...i}));return Object.freeze(r),Object.freeze({...n,entries:r,materialized:t})}function Ai(n){return Array.from(n,([e,t])=>({vfsPath:e,...t}))}function Xs(n){return new Map(n.map(({vfsPath:e,...t})=>[e,t]))}function yl(n){return{mode:n.activation.mode,capabilities:[...n.activation.capabilities],roots:[...n.activation.roots],atomicGroup:{id:n.id,member:n.member,descriptorSha256:n.descriptorSha256,expectedCount:n.expectedCount,cohortSha256:n.cohortSha256}}}function _l(n,e){return n.ino===e.ino&&n.generation===e.generation&&n.dataSequence===e.dataSequence&&n.size===e.size&&n.isSymlink===e.isSymlink&&n.deleted===e.deleted&&n.materialized===e.materialized&&n.archivePath===e.archivePath&&n.sourcePath===e.sourcePath&&n.type===e.type&&n.inodeGroup===e.inodeGroup&&n.target===e.target}function rn(n,e,t){let r=n.content,i=n.inventory,o=n.activation,s=n.integrity,a=n.entries,c=n.url,u=n.mountPrefix,l=n.materialized,d=o?.atomicGroup;if(r===void 0||i===void 0||o===void 0||d===void 0||o.mode!=="first-use"||d.id!==e||d.member!==t||l)throw new Error(`Lazy atomic activation member ${t} changed before snapshot`);if(s?.sha256!==r.sha256||s?.bytes!==r.bytes||c!==(r.transports[0]??""))throw new Error(`Lazy atomic activation member ${t} has inconsistent integrity`);let h=fn(r),m=pa(i),f=pl(o,e,t),p=new Map;for(let O of m)O.type==="file"&&p.set(O.inodeGroup,O.sourcePath);let _=m.filter(O=>O.type!=="directory");if(a.size!==_.length)throw new Error(`Lazy atomic activation member ${t} has inconsistent runtime entries`);let y=_.map(O=>{let S=a.get(O.vfsPath),w=O.type==="symlink",z=w?O.sourcePath:p.get(O.inodeGroup),x=S!==void 0&&(S.sourcePath===O.sourcePath&&S.type===O.type&&S.target===O.target||O.type==="hardlink"&&S.sourcePath===z&&S.type==="file"&&S.target===void 0),I=S===void 0?["missing"]:[z===void 0?"archivePath source":void 0,S.generation===void 0?"generation":void 0,S.dataSequence===void 0?"dataSequence":void 0,S.size!==O.size?"size":void 0,S.isSymlink!==w?"symlink kind":void 0,S.deleted?"deletion state":void 0,S.materialized!==w?"materialization state":void 0,S.archivePath!==z?"archivePath":void 0,x?void 0:"descriptor mapping",S.inodeGroup!==O.inodeGroup?"inode group":void 0].filter(L=>L!==void 0);if(I.length>0)throw new Error(`Lazy atomic activation member ${t} has inconsistent mapping at ${O.vfsPath}: ${I.join(", ")}`);let R=S;return Object.freeze({vfsPath:O.vfsPath,ino:R.ino,generation:R.generation,dataSequence:R.dataSequence,size:R.size,isSymlink:R.isSymlink,deleted:!1,materialized:R.materialized,archivePath:z,sourcePath:O.sourcePath,type:O.type,...O.inodeGroup===void 0?{}:{inodeGroup:O.inodeGroup},...O.target===void 0?{}:{target:O.target}})});Object.freeze(y);let g=Object.freeze({sha256:h.sha256,bytes:h.bytes}),E=dl(h,m,u,f);return Object.freeze({id:e,member:t,descriptorBytes:E,content:h,inventory:m,activation:f,url:h.transports[0]??"",mountPrefix:u,integrity:g,entries:y})}function js(n,e,t,r){return Object.freeze({...n,descriptorSha256:e,expectedCount:t,cohortSha256:r})}function Js(n,e){return n.id!==e.id||n.member!==e.member||n.url!==e.url||n.mountPrefix!==e.mountPrefix||n.integrity.sha256!==e.integrity.sha256||n.integrity.bytes!==e.integrity.bytes||n.content.transports.length!==e.content.transports.length||n.content.transports.some((t,r)=>t!==e.content.transports[r])||!ll(n.descriptorBytes,e.descriptorBytes)||n.entries.length!==e.entries.length?!1:n.entries.every((t,r)=>{let i=e.entries[r];return i!==void 0&&t.vfsPath===i.vfsPath&&_l(t,i)})}function gl(n,e){let t=fn(n.content,n.content.transports.map(e));return Object.freeze({...n,content:t,url:t.transports[0]??""})}function Qs(n){return{paths:Array.from(n.paths),expectedIno:n.ino,expectedGeneration:n.generation,expectedDataSequence:n.dataSequence,data:n.content}}function V(n,e){return`${n}:${e}`}var q=class n{fs;imageMetadata;lazyFiles=new Map;lazyArchiveGroups=[];deferredTreeMaterializationHandles=new WeakMap;lazyArchiveInodes=new Map;lazyAtomicGroups=new Map;lazyAtomicGroupByTree=new WeakMap;sealedLazyAtomicStates=new WeakMap;ordinaryLazyTreeDefinitions=new WeakMap;lazyDownloadListeners=new Set;lazyPreparations=new Map;lazyTransport={fetcher:(e,t)=>t===void 0?globalThis.fetch(e):globalThis.fetch(e,t)};constructor(e,t=null){this.fs=e,this.imageMetadata=t,lt(Sd,Rd,[this])}snapshotForImmutableProduct(){if(this.lazyFiles.size!==0||this.lazyArchiveInodes.size!==0)throw new Error("immutable product source must be completely materialized");let{bytes:e}=lt(Td,this.fs,[]),t=new Ms(e.byteLength);lt(Ed,new gd(t),[e]);let r=lt(xd,le,[t,{restoreImage:!0}]);return _d(r,bd),new n(r,Gs(this.imageMetadata))}qualifiedInodeIdentity(e){let t=this.fs.lstat(e),r=lt(wd,Ds,[this.fs.buffer]);return r===void 0&&(r=Ld++,lt(Od,Ds,[this.fs.buffer,r])),{dev:r,ino:t.ino,generation:t.generation}}static canAdoptLegacyLazyStub(e){return(e.mode&Ee)===kt&&e.size===0&&e.dataSequence<=1}replaceOrdinaryLazyTreeRuntimeState(e,t,r){let i=this.ordinaryLazyTreeDefinitions.get(e);if(i===void 0)return;let o=ml(i,t,r);this.ordinaryLazyTreeDefinitions.set(e,o);try{e.entries=Xs(o.entries),e.materialized=o.materialized}catch{}return o}reconcileLazyIdentityState(e){for(let[t,r]of this.lazyFiles){let i=e.get(t);if(!i||i.dataSequence!==r.dataSequence||i.paths.length===0){this.lazyFiles.delete(t);continue}r.paths=new Set(i.paths),r.paths.has(r.path)||(r.path=i.paths[0])}this.lazyArchiveInodes.clear();for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(!r?.committed)for(let c of i.snapshot.entries){if(c.isSymlink||c.materialized||c.generation===void 0)continue;let u=V(c.ino,c.generation),l=e.get(u);l!==void 0&&l.dataSequence===c.dataSequence&&l.paths.length>0&&this.lazyArchiveInodes.set(u,t)}continue}let o=this.ordinaryLazyTreeDefinitions.get(t);if(o!==void 0){if(o.materialized){this.replaceOrdinaryLazyTreeRuntimeState(t,o.entries,!0);continue}let c=new Map,u=o.entries.filter(l=>l.deleted||l.materialized||l.isSymlink).map(l=>({...l}));for(let l of o.entries){if(l.deleted||l.materialized||l.isSymlink||l.generation===void 0)continue;let d=V(l.ino,l.generation),h=c.get(d)??[];h.push(l),c.set(d,h)}for(let[l,d]of c){let h=e.get(l);if(h===void 0||h.dataSequence!==(d[0].dataSequence??0)){if(h!==void 0)for(let p of d)u.push({...p,materialized:!0});continue}let m=new Map(d.map(p=>[p.vfsPath,p])),f=d.find(p=>p.type==="file")??d[0];for(let p of h.paths){let _=m.get(p)??f;u.push({..._,vfsPath:p,ino:h.ino,generation:h.generation,dataSequence:h.dataSequence,deleted:!1,materialized:!1})}h.paths.length>0&&this.lazyArchiveInodes.set(l,t)}this.replaceOrdinaryLazyTreeRuntimeState(t,u,!1);continue}let s=new Map;for(let c of t.entries.values()){if(c.deleted||c.materialized||c.generation===void 0)continue;let u=V(c.ino,c.generation);s.has(u)||s.set(u,c)}let a=new Map(Array.from(t.entries.entries()).filter(([,c])=>c.deleted||c.isSymlink&&!c.deleted));for(let[c,u]of s){let l=e.get(c);if(!(!l||l.dataSequence!==(u.dataSequence??0))){for(let d of l.paths)a.set(d,{...u,ino:l.ino,generation:l.generation,dataSequence:l.dataSequence,deleted:!1,materialized:!1});l.paths.length>0&&this.lazyArchiveInodes.set(c,t)}}t.entries=a,t.materialized=!Array.from(a.values()).some(c=>!c.isSymlink&&!c.materialized)&&(r===void 0||r.committed)}}validatePendingLazyTreeNamespaceState(e){let t=new Map;for(let r of e.values())for(let i of r.paths){if(t.has(i))throw new Error(`SharedFS namespace identity is ambiguous at ${i}`);t.set(i,r)}for(let r of this.lazyArchiveGroups){let i=this.lazyAtomicGroupByTree.get(r),s=this.sealedLazyAtomicStates.get(r)?.snapshot,a=this.ordinaryLazyTreeDefinitions.get(r);if(i?.committed||s===void 0&&(a?.materialized??r.materialized)||s===void 0&&a===void 0)continue;let c=s?.inventory??a.inventory,u=new Map((s?.entries??a.entries).map(f=>[f.vfsPath,f])),l=new Map,d=new Map,h=new Set;for(let f of u.values())f.deleted&&f.inodeGroup!==void 0&&h.add(f.inodeGroup);for(let f of c){if(f.type!=="file"&&f.type!=="hardlink")continue;l.set(f.inodeGroup,(l.get(f.inodeGroup)??0)+1);let p=d.get(f.inodeGroup)??[];p.push(f.vfsPath),d.set(f.inodeGroup,p)}let m=new Set([...h].filter(f=>d.get(f)?.every(p=>!t.has(p))));for(let f of c){let p=t.get(f.vfsPath);if(p===void 0){if(f.inodeGroup!==void 0&&m.has(f.inodeGroup))continue;throw new Error(`Lazy tree namespace entry ${f.vfsPath} is missing from the captured filesystem state`)}let _=f.type==="directory"?Qe:f.type==="symlink"?Sr:kt;if((p.mode&Ee)!==_||(p.mode&ie.S_MODE_BITS)!==f.mode)throw new Error(`Lazy tree namespace entry ${f.vfsPath} disagrees with its captured type or mode`);if(f.type==="directory")continue;let y=u.get(f.vfsPath);if(y===void 0||y.ino!==p.ino||y.generation!==p.generation||y.dataSequence!==p.dataSequence)throw new Error(`Lazy tree namespace entry ${f.vfsPath} changed identity before serialization`);if(f.type==="symlink"){let g=new TextEncoder().encode(f.target).byteLength;if(p.linkCount!==1||p.size!==f.size||p.size!==g||p.symlinkTarget!==f.target)throw new Error(`Lazy tree symlink ${f.vfsPath} disagrees with its captured inventory`);continue}if(p.size!==0||p.linkCount!==l.get(f.inodeGroup))throw new Error(`Lazy tree stub ${f.vfsPath} has changed data or undeclared aliases`)}}}lazyFileForStat(e){let t=V(e.ino,e.generation),r=this.lazyFiles.get(t);if(r&&r.dataSequence!==e.dataSequence){this.lazyFiles.delete(t);return}return r}lazyArchiveEntriesForRead(e){let t=this.lazyAtomicGroupByTree.get(e),r=this.sealedLazyAtomicStates.get(e);if(r!==void 0&&!t?.committed)return r.snapshot.entries;let i=this.ordinaryLazyTreeDefinitions.get(e);return i!==void 0?i.entries:Array.from(e.entries,([o,s])=>({vfsPath:o,...s}))}lazyArchiveForStat(e){let t=V(e.ino,e.generation),r=this.lazyArchiveInodes.get(t);if(!r)return;let i=this.lazyArchiveEntriesForRead(r).filter(s=>s.ino===e.ino&&s.generation===e.generation&&!s.deleted&&!s.materialized);if(i.some(s=>s.dataSequence===e.dataSequence))return r;if(this.lazyArchiveInodes.delete(t),this.sealedLazyAtomicStates.get(r)===void 0){let s=this.ordinaryLazyTreeDefinitions.get(r);if(s!==void 0)this.replaceOrdinaryLazyTreeRuntimeState(r,s.entries.map(a=>a.ino===e.ino&&a.generation===e.generation?{...a,materialized:!0}:a),s.materialized);else for(let a of i)a.materialized=!0}}lazyBackingForStat(e){let t=V(e.ino,e.generation),r=this.lazyFiles.get(t);if(r)return{token:r,path:r.path};let i=this.lazyArchiveInodes.get(t);if(!i)return null;let o=this.lazyArchiveEntriesForRead(i).find(a=>a.ino===e.ino&&a.generation===e.generation&&!a.deleted&&!a.materialized)?.vfsPath;if(o===void 0)return null;let s=this.lazyAtomicGroupByTree.get(i);return s===void 0?{token:i,path:o}:{token:s.token,path:o,atomicGroup:s}}lazyBackingForPath(e){let t=this.lazyArchiveGroups.find(r=>{let i=this.lazyAtomicGroupByTree.get(r),o=this.sealedLazyAtomicStates.get(r)?.snapshot,s=this.ordinaryLazyTreeDefinitions.get(r),a=o===void 0?!(s?.materialized??r.materialized):!i?.committed,c=o?.content??s?.content,u=o?.inventory??s?.inventory,l=o?.activation??s?.activation,d=o?.entries??s?.entries??Array.from(r.entries.values());return a&&c!==void 0&&u!==void 0&&l!==void 0&&d.every(h=>h.deleted||h.materialized||h.isSymlink)&&l.roots.some(h=>h==="/"||e===h||e.startsWith(`${h}/`))});if(t){let r=this.lazyAtomicGroupByTree.get(t);return{token:r?.token??t,path:e,directGroup:t,...r===void 0?{}:{atomicGroup:r}}}try{let r=this.fs.stat(e),i=this.lazyBackingForStat(r);return i?{...i,path:e}:null}catch{return null}}startLazyPreparation(e){let{path:t,token:r}=e,i={status:"pending",promise:Promise.resolve(!1)},o=e.atomicGroup?this.ensureAtomicLazyGroupMaterialized(e.atomicGroup).then(()=>!0):e.directGroup?this.ensureArchiveMaterialized(e.directGroup).then(()=>!0):this.materializePath(t);return i.promise=o.then(s=>(i.status="fulfilled",this.lazyPreparations.get(r)===i&&this.lazyPreparations.delete(r),s),s=>{throw i.status="rejected",i.error=s,s}),i.promise.catch(()=>{}),this.lazyPreparations.set(r,i),i}registerLazyAtomicGroupMembership(e,t=!1){let r=e.activation?.atomicGroup;if(r===void 0)return;let{id:i,member:o}=r;if(e.content===void 0||e.inventory===void 0||e.activation?.mode!=="first-use")throw new Error(`Lazy atomic activation group ${i} accepts only typed first-use trees`);let s=this.lazyAtomicGroups.get(i);if(s===void 0)s={id:i,token:Object.freeze({id:i}),groups:new Map,committed:!1},this.lazyAtomicGroups.set(i,s);else if(s.committed)throw new Error(`Lazy atomic activation group ${i} is already materialized`);if(s.groups.has(o))throw new Error(`Lazy atomic activation group ${i} duplicates member ${o}`);if(Ft(r)){if(s.expectedCount!==void 0&&(s.expectedCount!==r.expectedCount||s.cohortSha256!==r.cohortSha256))throw new Error(`Lazy atomic activation group ${i} has inconsistent seals`);s.expectedCount=r.expectedCount,s.cohortSha256=r.cohortSha256;let a=rn(e,i,o);this.sealedLazyAtomicStates.set(e,{snapshot:js(a,r.descriptorSha256,r.expectedCount,r.cohortSha256),verified:t})}else if(s.expectedCount!==void 0)throw new Error(`Lazy atomic activation group ${i} mixes sealed and unsealed members`);s.groups.set(o,e),this.lazyAtomicGroupByTree.set(e,s)}async sealLazyAtomicGroup(e,t){let r=t.map(u=>ua({id:e,member:u}).member).sort();if(r.length===0||new Set(r).size!==r.length)throw new Error(`Lazy atomic activation group ${e} expected members are invalid`);let i=this.lazyAtomicGroups.get(e);if(i===void 0||i.committed)throw new Error(`Lazy atomic activation group ${e} is not pending`);let o=[...i.groups.keys()].sort();if(JSON.stringify(o)!==JSON.stringify(r))throw new Error(`Lazy atomic activation group ${e} members differ from its seal`);if(i.expectedCount!==void 0){if(i.expectedCount!==r.length||i.cohortSha256===void 0)throw new Error(`Lazy atomic activation group ${e} has an invalid existing seal`);await this.ensureLazyAtomicGroupSealValidated(i,r.map(u=>i.groups.get(u)),!0);return}let s=r.map(u=>rn(i.groups.get(u),e,u)),a=[];for(let u of s)a.push({member:u.member,descriptorSha256:await Nt(u.descriptorBytes,`Lazy atomic member ${u.member}`),source:u});let c=await Nt(Ys(e,a),`Lazy atomic activation group ${e}`);for(let u of a){let l=i.groups.get(u.member),d=rn(l,e,u.member);if(!Js(u.source,d))throw new Error(`Lazy atomic activation member ${u.member} changed while sealing`)}for(let u of a){let l=i.groups.get(u.member);l.activation.atomicGroup={id:e,member:u.member,descriptorSha256:u.descriptorSha256,expectedCount:a.length,cohortSha256:c},this.sealedLazyAtomicStates.set(l,{snapshot:js(u.source,u.descriptorSha256,a.length,c),verified:!0})}i.expectedCount=a.length,i.cohortSha256=c}async verifyImportedLazyAtomicGroupSeals(){await this.validatePendingLazyAtomicGroupSeals(!0)}guardSynchronousLazyAccess(e){let t=this.lazyBackingForPath(e);if(!t)return;let r=this.lazyPreparations.get(t.token);if(r?.status==="fulfilled"){this.lazyPreparations.delete(t.token);let o=this.lazyBackingForPath(e);if(!o)return;r=this.lazyPreparations.get(o.token)??this.startLazyPreparation(o)}else if(r?.status==="rejected"){this.lazyPreparations.delete(t.token);let o=r.error instanceof Error?r.error.message:String(r.error),s=new Error(`EIO: lazy backing for ${e} failed: ${o}`);throw s.code="EIO",s.cause=r.error,s}else r||(r=this.startLazyPreparation(t));let i=new Error(`EAGAIN: lazy backing for ${e} is being prepared`);throw i.code="EAGAIN",i}invalidateLazyData(e){let t=V(e.ino,e.generation);this.lazyFiles.delete(t);let r=this.lazyArchiveInodes.get(t);if(!r)return;this.lazyArchiveInodes.delete(t);let i=this.ordinaryLazyTreeDefinitions.get(r);if(i!==void 0){this.replaceOrdinaryLazyTreeRuntimeState(r,i.entries.map(o=>o.ino===e.ino&&o.generation===e.generation?{...o,materialized:!0}:o),i.materialized);return}for(let o of r.entries.values())o.ino!==e.ino||o.generation!==e.generation||(o.materialized=!0)}rewriteLazyNamespacePaths(e,t,r){let i=t.length>1?t.replace(/\/+$/,""):t,o=r.length>1?r.replace(/\/+$/,""):r,s=`${i}/`,a=`${o}/`,c=V(e.ino,e.generation),u=(e.mode&Ee)===Qe,l=d=>d===i?o:u&&d.startsWith(s)?a+d.slice(s.length):d;for(let[d,h]of this.lazyFiles)!u&&d!==c||(h.paths=new Set(Array.from(h.paths,l)),h.path=l(h.path));for(let d of this.lazyArchiveGroups){let h=this.ordinaryLazyTreeDefinitions.get(d);if(h!==void 0){let f=h.entries.map(g=>{let E=g.generation===void 0?null:V(g.ino,g.generation),O=u||E===c?l(g.vfsPath):g.vfsPath;return{...g,vfsPath:O,...g.type==="hardlink"&&g.target!==void 0?{target:l(g.target)}:{}}}),p=h.inventory.map(g=>({...g,vfsPath:l(g.vfsPath),...g.type==="hardlink"&&g.target!==void 0?{target:l(g.target)}:{}})),_={...h.activation,capabilities:[...h.activation.capabilities],roots:h.activation.roots.map(l)},y=tn(h.content,p,_,h.url,h.mountPrefix,h.integrity,f,h.materialized);this.ordinaryLazyTreeDefinitions.set(d,y);try{d.entries=Xs(y.entries),d.materialized=y.materialized,d.inventory=y.inventory.map(g=>({...g})),d.activation={...y.activation,capabilities:[...y.activation.capabilities],roots:[...y.activation.roots]}}catch{}continue}let m=new Map;for(let[f,p]of d.entries){let _=p.generation===void 0?null:V(p.ino,p.generation);m.set(u||_===c?l(f):f,p)}d.entries=m,d.inventory&&(d.inventory=d.inventory.map(f=>({...f,vfsPath:l(f.vfsPath),...f.type==="hardlink"&&f.target!==void 0?{target:l(f.target)}:{}}))),d.activation&&(d.activation={...d.activation,roots:d.activation.roots.map(l)})}}get sharedBuffer(){return this.fs.buffer}static create(e,t){return new n(le.mkfs(e,t))}static createFresh(e){if(typeof e!="number"||!Ad(e)||e<=0)throw new zd("fresh MemoryFileSystem byte length must be a positive integer");let t=new Ms(e),r=lt(Id,le,[t]);return new n(r)}static fromExisting(e){return new n(le.mount(e))}rebaseToNewFileSystem(e){if(!Number.isSafeInteger(e)||e<=0)throw new Error(`Invalid MemoryFileSystem maxByteLength: ${e}`);let t=SharedArrayBuffer,{bytes:r,identities:i}=this.fs.snapshotState();this.reconcileLazyIdentityState(i);let o=this.serializeLazyEntries(),s=this.serializeValidatedLazyArchiveEntries(i),a=new t(r.byteLength);new Uint8Array(a).set(r);let c=new n(le.mount(a,{restoreImage:!0}),this.imageMetadata);c.importLazyEntries(o),c.importLazyArchiveEntriesInternal(s,!1,!0,"verified");let u=Math.min(e,Math.max(r.byteLength,Dd)),l=new t(u,{maxByteLength:e}),d=n.create(l,e);d.setImageMetadata(this.imageMetadata);let h=new Set(o.flatMap(f=>f.paths??[f.path])),m=new Set;for(let f of s)if(!f.materialized)for(let p of f.entries)!p.deleted&&!p.isSymlink&&m.add(p.vfsPath);return c.copyPathToFreshFileSystem("/",d,h,m,new Map),d.importLazyEntries(o.map(f=>{let p=d.fs.lstat(f.path);return{...f,ino:p.ino,generation:p.generation,dataSequence:p.dataSequence}})),d.importLazyArchiveEntriesInternal(s.map(f=>({...f,entries:f.entries.map(p=>{if(p.deleted)return{...p,ino:0,generation:void 0};let _=d.fs.lstat(p.vfsPath);return{...p,ino:_.ino,generation:_.generation,dataSequence:_.dataSequence}})})),!1,!0,"verified"),d}getImageMetadata(){return Gs(this.imageMetadata)}setImageMetadata(e){this.imageMetadata=e===null?null:ki(e)}subscribeLazyDownloads(e){return this.lazyDownloadListeners.add(e),()=>this.lazyDownloadListeners.delete(e)}setLazyFetcher(e,t={}){this.lazyTransport={fetcher:e,...t.signal===void 0?{}:{signal:t.signal}}}emitLazyDownload(e){if(this.lazyDownloadListeners.size===0)return;let t={...e,t:Xd()};for(let r of this.lazyDownloadListeners)try{r(t)}catch{}}async fetchLazyBytes(e,t){let r=0,i=e.integrity?.bytes??e.fallbackTotalBytes,o={id:e.id,kind:e.kind,url:e.url,path:e.path,mountPrefix:e.mountPrefix};for(let s=0;se.integrity.bytes)throw new Error(`Lazy ${e.kind} exceeded expected byte count ${e.integrity.bytes}`);this.emitLazyDownload({...o,status:"progress",loadedBytes:r,totalBytes:i})}}}catch(d){try{await c.cancel(d)}catch{}throw d}}finally{c.releaseLock()}let l=il(u,r);return te(t.signal),await Oi(l,e.kind,e.integrity),te(t.signal),this.emitLazyDownload({...o,status:"complete",loadedBytes:r,totalBytes:i??r}),l}catch(a){if(t.signal?.aborted){let l=t.signal.reason,d=l instanceof Error?l.message:String(l);throw this.emitLazyDownload({...o,status:"error",loadedBytes:r,totalBytes:i,error:d}),l}let c=s+1({...g})),activation:d,entries:new Map},_=g=>{let E=g.split("/").filter(Boolean),O="";for(let S=0;SE.vfsPath.split("/").length-O.vfsPath.split("/").length))if(g.type==="directory"){_(g.vfsPath);try{this.fs.mkdir(g.vfsPath,g.mode),this.fs.chmod(g.vfsPath,g.mode)}catch{if((this.fs.lstat(g.vfsPath).mode&Ee)!==Qe)throw new Error(`Lazy tree directory collides at ${g.vfsPath}`)}}for(let g of u){if(g.type!=="symlink")continue;_(g.vfsPath),this.fs.symlink(g.target,g.vfsPath);let E=this.fs.lstat(g.vfsPath);p.entries.set(g.vfsPath,{ino:E.ino,generation:E.generation,dataSequence:E.dataSequence,size:g.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:g.sourcePath,sourcePath:g.sourcePath,type:"symlink",target:g.target})}let y=new Map;for(let g of u){if(g.type!=="file")continue;_(g.vfsPath);let E=this.fs.createLazyStub(g.vfsPath,g.mode);this.invalidateLazyData(E),y.set(g.inodeGroup,E);let O={ino:E.ino,generation:E.generation,dataSequence:E.dataSequence,size:g.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:g.sourcePath,sourcePath:g.sourcePath,type:"file",inodeGroup:g.inodeGroup};p.entries.set(g.vfsPath,O)}for(let g of u){if(g.type!=="hardlink")continue;let E=h.get(g.inodeGroup);_(g.vfsPath),this.fs.link(E.vfsPath,g.vfsPath);let O=this.fs.lstat(g.vfsPath),S=y.get(g.inodeGroup);if(O.ino!==S.ino||O.generation!==S.generation)throw new Error(`Lazy tree hardlink ${g.vfsPath} did not share its inode`);p.entries.set(g.vfsPath,{ino:O.ino,generation:O.generation,dataSequence:O.dataSequence,size:g.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:E.sourcePath,sourcePath:g.sourcePath,type:"hardlink",inodeGroup:g.inodeGroup,target:g.target})}if(m!==void 0)for(let g of u)this.lchown(g.vfsPath,m.uid,m.gid);for(let g of p.entries.values())g.isSymlink||g.generation===void 0||this.lazyArchiveInodes.set(V(g.ino,g.generation),p);return this.lazyArchiveGroups.push(p),this.registerLazyAtomicGroupMembership(p),d.atomicGroup===void 0&&this.ordinaryLazyTreeDefinitions.set(p,tn(c,u,d,p.url,l,p.integrity,Ai(p.entries),!1)),p}registerLazyTreeWithMaterializationHandle(e,t,r="/",i,o){let s=this.registerLazyTreeInternal(e,t,r,i,!0,o),a=Object.freeze({[vd]:!0});return this.deferredTreeMaterializationHandles.set(a,s),a}registerLazyArchiveFromEntries(e,t,r,i,o){let s=Ar(r),a=Vd(e,t,s,i);a.some(({entry:u})=>!u.isDirectory&&!u.isSymlink)&&this.assertCanRegisterPendingLazyArchiveGroup();let c={...o?{content:un({decoder:"zip-v1",mediaType:"application/zip",sha256:o.sha256,bytes:o.bytes,expandedBytes:a.reduce((u,l)=>u+l.entry.uncompressedSize,0),sourceEntryCount:a.length,transports:[e]})}:{},url:e,mountPrefix:s,integrity:zr(o),materialized:!1,entries:new Map};for(let{entry:u,vfsPath:l}of a){if(u.isDirectory)continue;let d=l.split("/").filter(Boolean),h="";for(let m=0;mu.deleted||u.materialized),this.lazyArchiveGroups.push(c),c}importLazyArchiveEntries(e){this.importLazyArchiveEntriesInternal(e,!1,!0,"reject")}async importVerifiedLazyArchiveEntries(e){let t=structuredClone(e),r=this.exportLazyArchiveEntries(),i=n.fromExisting(this.sharedBuffer);i.importLazyArchiveEntriesInternal([...r,...t],!1,!0,"pending"),await i.verifyImportedLazyAtomicGroupSeals(),this.importLazyArchiveEntriesInternal(t,!1,!0,"verified")}importLazyArchiveEntriesInternal(e,t,r,i){let o=Me(e,"Serialized lazy archive groups",0,ta).map((l,d)=>{if(typeof l!="object"||l===null||Array.isArray(l))throw new Error(`Serialized lazy archive group ${d} must be an object`);let h=l.kind;if(h===Or||h===Ti||h===ft)return la(l,h);if(h===wr)return bi(l,!1);if(h!==void 0)throw new Error(`Serialized lazy archive group ${d} has an unsupported kind`);if(r)throw new Error(`Serialized lazy archive group ${d} is missing its kind discriminator`);return bi(l,!0)}),s=this.fs.identityState();this.reconcileLazyIdentityState(s);let a=[...this.serializeValidatedLazyArchiveEntries(s),...o];qs(a);let c=[],u=new Map;for(let l of o){let d=new Map,h=l.mountPrefix.replace(/\/+$/,""),m=l.content!==void 0&&l.inventory!==void 0&&l.activation!==void 0,f=m?new Map(l.inventory.map(S=>[S.vfsPath,S])):null,p=m?new Map(l.inventory.map(S=>[dn(S),S])):null,_=new Map,y=new Map,g=new Map;for(let S of l.entries){let w=null,z=l.materialized||S.materialized===!0||S.isSymlink;if(!S.deleted&&!z){if((S.generation===void 0||S.dataSequence===void 0)&&!t)throw new Error("Live lazy-archive metadata requires inode generation and data sequence");try{w=this.fs.lstat(S.vfsPath)}catch{if(m)throw new Error(`Serialized lazy tree stub ${S.vfsPath} is missing from the filesystem`);continue}if(w.ino!==S.ino){if(m)throw new Error(`Serialized lazy tree stub ${S.vfsPath} has a different inode`);continue}if(S.generation!==void 0&&w.generation!==S.generation){if(m)throw new Error(`Serialized lazy tree stub ${S.vfsPath} has a different generation`);continue}if(S.dataSequence===void 0){if(!n.canAdoptLegacyLazyStub(w)){if(m)throw new Error(`Serialized lazy tree stub ${S.vfsPath} is not pristine`);continue}}else if(w.dataSequence!==S.dataSequence){if(m)throw new Error(`Serialized lazy tree stub ${S.vfsPath} has a different data sequence`);continue}if(m){g.set(S.vfsPath,w);let I=f.get(S.vfsPath),R=p.get(dn(S))??I;if(!R||(w.mode&Ee)!==kt||w.size!==0||(w.mode&ie.S_MODE_BITS)!==R.mode||I?.inodeGroup!==void 0&&I.inodeGroup!==R.inodeGroup)throw new Error(`Serialized lazy tree stub ${S.vfsPath} disagrees with its inventory`);let L=V(w.ino,w.generation),v=S.inodeGroup,Z=_.get(v),N=y.get(L);if(Z!==void 0&&Z!==L||N!==void 0&&N!==v)throw new Error(`Serialized lazy tree inode group ${v} disagrees with the filesystem`);_.set(v,L),y.set(L,v)}}d.set(S.vfsPath,{ino:S.ino,generation:w?.generation??S.generation,dataSequence:w?.dataSequence??S.dataSequence,size:S.size,isSymlink:S.isSymlink,deleted:S.deleted,materialized:z,archivePath:S.archivePath??S.vfsPath.slice(h.length+1),sourcePath:S.sourcePath??S.archivePath??S.vfsPath.slice(h.length+1),type:S.type??(S.isSymlink?"symlink":"file"),inodeGroup:S.inodeGroup,target:S.target})}if(m){let S=new Map;for(let w of l.inventory){if(w.type==="file"||w.type==="hardlink"){S.set(w.inodeGroup,(S.get(w.inodeGroup)??0)+1);continue}let z;try{z=this.fs.lstat(w.vfsPath)}catch{throw new Error(`Serialized lazy tree namespace entry ${w.vfsPath} is missing from the filesystem`)}let x=w.type==="directory"?Qe:Sr;if((z.mode&Ee)!==x||(z.mode&ie.S_MODE_BITS)!==w.mode||w.type==="symlink"&&(z.size!==new TextEncoder().encode(w.target).byteLength||this.fs.readlink(w.vfsPath)!==w.target))throw new Error(`Serialized lazy tree namespace entry ${w.vfsPath} disagrees with its inventory`);w.type==="symlink"&&d.set(w.vfsPath,{ino:z.ino,generation:z.generation,dataSequence:z.dataSequence,size:w.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:w.sourcePath,sourcePath:w.sourcePath,type:"symlink",target:w.target})}if(l.activation?.atomicGroup!==void 0)for(let w of l.inventory){if(w.type!=="file"&&w.type!=="hardlink")continue;if(g.get(w.vfsPath).linkCount!==S.get(w.inodeGroup))throw new Error(`Serialized lazy atomic tree inode group ${w.inodeGroup} has undeclared aliases`)}}let E=l.content===void 0?void 0:un(l.content),O={content:E,url:E?.transports[0]??l.url,mountPrefix:l.mountPrefix,integrity:E?{sha256:E.sha256,bytes:E.bytes}:zr(l.integrity),materialized:l.materialized||!(E&&l.inventory)&&Array.from(d.values()).every(S=>S.deleted||S.materialized),inventory:l.inventory?.map(S=>({...S})),activation:l.activation?{mode:l.activation.mode,capabilities:[...l.activation.capabilities],roots:[...l.activation.roots],...l.activation.atomicGroup===void 0?{}:{atomicGroup:{...l.activation.atomicGroup}}}:void 0,entries:d};if(c.push(O),!O.materialized){for(let[,S]of d)if(!S.deleted&&!S.materialized&&S.generation!==void 0){let w=V(S.ino,S.generation),z=u.get(w);if(z!==void 0&&z!==O)throw new Error(`Serialized lazy archive groups share pending inode ${w}`);if(this.lazyArchiveInodes.has(w))throw new Error(`Serialized lazy archive group collides with pending inode ${w}`);u.set(w,O)}}}for(let l of c){let d=l.activation?.atomicGroup;if(d!==void 0&&this.lazyAtomicGroups.get(d.id)?.committed)throw new Error(`Lazy atomic activation group ${d.id} is already materialized`)}if(i==="reject"&&c.some(l=>{let d=l.activation?.atomicGroup;return d!==void 0&&Ft(d)}))throw new Error("Sealed lazy archive registrations require importVerifiedLazyArchiveEntries()");this.lazyArchiveGroups.push(...c);for(let l of c)this.registerLazyAtomicGroupMembership(l,i==="verified"),l.content!==void 0&&l.inventory!==void 0&&l.activation!==void 0&&l.activation.atomicGroup===void 0&&this.ordinaryLazyTreeDefinitions.set(l,tn(l.content,l.inventory,l.activation,l.url,l.mountPrefix,l.integrity,Ai(l.entries),l.materialized));for(let[l,d]of u)this.lazyArchiveInodes.set(l,d)}rewriteLazyArchiveUrls(e){for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0&&!r?.committed){this.assertLazyAtomicSnapshotMatchesPublic(t);let s=gl(i.snapshot,e);t.content=en(s.content),t.url=s.url,t.integrity={...s.integrity},i.snapshot=s;continue}let o=this.ordinaryLazyTreeDefinitions.get(t);if(o!==void 0){let s=fn(o.content,o.content.transports.map(e)),a=tn(s,o.inventory,o.activation,s.transports[0],o.mountPrefix,o.integrity,o.entries,o.materialized);this.ordinaryLazyTreeDefinitions.set(t,a),t.content=en(a.content),t.url=a.url,t.integrity={...a.integrity}}else t.content?(t.content={...t.content,transports:t.content.transports.map(e)},t.url=t.content.transports[0]):t.url=e(t.url)}}serializeLazyArchiveEntries(){let e=[];for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(r?.committed)continue;let _=i.snapshot;if(_.content.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");e.push({kind:ft,content:en(_.content),inventory:_.inventory.map(y=>({...y})),activation:yl(_),url:_.url,mountPrefix:_.mountPrefix,integrity:{..._.integrity},materialized:!1,entries:_.entries.filter(y=>!y.deleted&&!y.materialized).map(({vfsPath:y,...g})=>({vfsPath:y,...g}))});continue}let o=this.ordinaryLazyTreeDefinitions.get(t),s=o?.materialized??t.materialized,a=(o?.entries??Ai(t.entries)).map(_=>({..._})).filter(_=>!_.deleted&&!_.materialized),c=o?.content??t.content,u=o?.inventory??t.inventory,l=o?.activation??t.activation,d=o?.url??t.url,h=o?.mountPrefix??t.mountPrefix,m=o?.integrity??t.integrity;if(a.length===0&&!(c!==void 0&&u!==void 0&&!s))continue;let f=c!==void 0&&u!==void 0&&l!==void 0;if(f&&c.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");let p=l?.atomicGroup;if(p!==void 0&&!Ft(p))throw new Error(`Lazy atomic activation group ${p.id} must be sealed before serialization`);e.push(f?{kind:p!==void 0?ft:c.source===void 0?Or:Ti,content:en(c),inventory:u.map(_=>({..._})),activation:{...l,capabilities:[...l.capabilities],roots:[...l.roots]},url:d,mountPrefix:h,integrity:{...m},materialized:!1,entries:a}:{kind:wr,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:a})}return e}serializeValidatedLazyArchiveEntries(e){this.assertPendingLazyAtomicSnapshotsReadyForSerialization();let t=this.serializeLazyArchiveEntries();return qs(t),this.validatePendingLazyTreeNamespaceState(e),t}exportLazyArchiveEntries(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),this.serializeValidatedLazyArchiveEntries(e)}pendingDeferredTreeUsage(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),ca(this.serializeValidatedLazyArchiveEntries(e))}assertCanAppendDeferredTreeUsage(e){Li(e);let t=this.pendingDeferredTreeUsage();Li({groups:t.groups+e.groups,archiveBytes:t.archiveBytes+e.archiveBytes,expandedBytes:t.expandedBytes+e.expandedBytes,payloadBytes:t.payloadBytes+e.payloadBytes,entries:t.entries+e.entries})}assertCanRegisterPendingLazyArchiveGroup(){if(this.reconcileLazyIdentityState(this.fs.identityState()),this.lazyArchiveGroups.filter(t=>{let r=this.lazyAtomicGroupByTree.get(t);if(this.sealedLazyAtomicStates.get(t)?.snapshot!==void 0)return!r?.committed;let o=this.ordinaryLazyTreeDefinitions.get(t);return!(o?.materialized??t.materialized)&&(o!==void 0||Array.from(t.entries.values()).some(s=>!s.deleted&&!s.materialized))}).length>=be.maxGroups)throw new Error(`Cannot register another lazy archive group: ${be.maxGroups} pending groups already exist`)}async preparePath(e){let t=!1,r=Math.max(3,this.lazyArchiveGroups.length+1);for(let i=0;i{let s=this.ordinaryLazyTreeDefinitions.get(o);return!(s?.materialized??o.materialized)&&(s?.activation??o.activation)?.mode==="boot-prefetch"}),t=0,r,i=Array.from({length:Math.min(e.length,Bd)},async()=>{for(;r===void 0;){let o=t;if(t+=1,o>=e.length)return;try{await this.prepareLazyTreeGroup(e[o])}catch(s){r??=s}}});if(await Promise.all(i),r!==void 0)throw r;return e.length}async materializeRegisteredDeferredTree(e,t){let r=this.deferredTreeMaterializationHandles.get(e);if(r===void 0)throw new Error("Deferred-tree handle was not issued by this filesystem");let i=this.lazyAtomicGroupByTree.get(r);if(i!==void 0)throw new Error(`Deferred tree belongs to atomic activation group ${i.id}; materialize the complete group instead`);if(this.ordinaryLazyTreeDefinitions.get(r)?.materialized??r.materialized)return!1;let s=this.lazyPreparations.get(r);if(s!==void 0)return s.promise;let a=new Uint8Array(t.byteLength);a.set(t);let c={status:"pending",promise:Promise.resolve(!1)};c.promise=Promise.resolve().then(async()=>{let u=this.ordinaryLazyTreeDefinitions.get(r)?.integrity??r.integrity;return await Oi(a,"tree",u),await this.materializeArchiveBytes(r,a),!0}).then(u=>(c.status="fulfilled",u),u=>{throw c.status="rejected",c.error=u,u}),c.promise.catch(()=>{}),this.lazyPreparations.set(r,c);try{return await c.promise}finally{this.lazyPreparations.get(r)===c&&this.lazyPreparations.delete(r)}}async prepareLazyTreeGroup(e){let t=this.lazyAtomicGroupByTree.get(e),r=this.ordinaryLazyTreeDefinitions.get(e);if(t?.committed||t===void 0&&(r?.materialized??e.materialized))return!1;let i=this.sealedLazyAtomicStates.get(e)?.snapshot,o={token:t?.token??e,path:i?.activation.roots[0]??r?.activation.roots[0]??r?.mountPrefix??e.activation?.roots[0]??e.mountPrefix,directGroup:e,...t===void 0?{}:{atomicGroup:t}},s=this.lazyPreparations.get(o.token)??this.startLazyPreparation(o);try{return await s.promise}finally{this.lazyPreparations.get(o.token)===s&&this.lazyPreparations.delete(o.token)}}async ensureMaterialized(e){return this.preparePath(e)}async materializePath(e){if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0)return!1;let t;try{t=this.fs.stat(e)}catch{return!1}let r=V(t.ino,t.generation),i=this.lazyFiles.get(r);if(i){let s=this.lazyTransport,a=await this.fetchLazyBytes({id:`file:${t.ino}`,kind:"file",url:i.url,path:i.path,fallbackTotalBytes:i.size},s);for(let c=0;c<3;c++){if(this.lazyFiles.get(r)!==i)return!1;for(let u of new Set([e,...i.paths]))if(te(s.signal),this.fs.replaceIfIdentity(u,i.ino,i.generation,i.dataSequence,a))return i.path=u,this.lazyFiles.delete(r),!0;this.reconcileLazyIdentityState(this.fs.identityState())}throw new Error(`Lazy file kept changing names while materializing: ${e}`)}let o=this.lazyArchiveInodes.get(r);return o?(await this.ensureArchiveMaterialized(o,{path:e,ino:t.ino,generation:t.generation}),!this.lazyArchiveInodes.has(r)):!1}async decodeAndValidateLazyTree(e,t,r){let i=this.ordinaryLazyTreeDefinitions.get(e),o=r?.content??i?.content??e.content,s=r?.inventory??i?.inventory??e.inventory;if(!o||!s)throw new Error("Lazy tree is missing its decoder or complete inventory");let a=new Map,c=new Map(s.map(m=>[m.vfsPath,m]));if(o.source!==void 0)for(let m of o.source.entries)a.set(m.sourcePath,m);else for(let m of s){if(m.type==="hardlink"){let p=c.get(m.target);if(!p)throw new Error(`Lazy tree hardlink target disappeared: ${m.target}`);if(m.sourcePath===p.sourcePath)continue}if(a.get(m.sourcePath))throw new Error(`Lazy tree inventory duplicates source member ${m.sourcePath}`);a.set(m.sourcePath,{sourcePath:m.sourcePath,type:m.type,mode:m.mode,size:m.size,...m.type==="symlink"?{target:m.target}:{},...m.type==="hardlink"?{target:c.get(m.target)?.sourcePath}:{}})}let u=new Map,l=0;if(o.decoder==="zip-v1"){let{parseZipCentralDirectory:m,extractZipEntryBounded:f}=await Promise.resolve().then(()=>(fi(),li)),p=m(t);if(p.length!==o.sourceEntryCount||p.length!==a.size)throw new Error("Lazy ZIP tree decoded inventory counts differ from its descriptor");for(let _ of p){let y=_.isDirectory?_.fileName.replace(/\/$/,""):_.fileName;if(u.has(y))throw new Error(`Lazy ZIP tree duplicates source member ${y}`);let g=a.get(y);if(!g)throw new Error(`Lazy ZIP tree has undeclared source member ${y}`);if(l+=_.uncompressedSize,l>o.expandedBytes||_.uncompressedSize!==g.size)throw new Error(`Lazy ZIP tree member ${y} exceeds its inventory`);let E=_.isDirectory?"directory":_.isSymlink?"symlink":"file",O=o.modePolicy==="portable-posix-v1"?E==="directory"?493:E==="symlink"?511:(_.mode&73)!==0?493:420:_.mode&ie.S_MODE_BITS;if(E!==g.type||O!==g.mode)throw new Error(`Lazy ZIP tree member ${y} differs from inventory`);if(_.isDirectory)u.set(y,{type:"directory",mode:O});else{let S=f(t,_,g.size);if(_.isSymlink){let w;try{w=new TextDecoder("utf-8",{fatal:!0}).decode(S)}catch{throw new Error(`Lazy ZIP tree symlink ${y} is not UTF-8`)}u.set(y,{type:"symlink",mode:O,target:w})}else u.set(y,{type:"file",mode:O,data:S})}}}else{let{parseTarGzip:m}=await Promise.resolve().then(()=>(Cs(),Ns)),f=m(t,{label:`Lazy tree ${o.sha256}`,limits:{maxCompressedBytes:o.bytes,maxUncompressedBytes:o.expandedBytes,maxEntries:o.sourceEntryCount}});l=new DataView(t.buffer,t.byteOffset,t.byteLength).getUint32(t.byteLength-4,!0);for(let p of f){if(u.has(p.path))throw new Error(`Lazy TAR tree duplicates source member ${p.path}`);p.type==="file"?u.set(p.path,{type:"file",mode:p.mode,data:p.data}):p.type==="directory"?u.set(p.path,{type:"directory",mode:p.mode}):u.set(p.path,{type:p.type,mode:p.mode,target:p.linkName})}}if(u.size!==o.sourceEntryCount||u.size!==a.size||l!==o.expandedBytes)throw new Error("Lazy tree decoded inventory counts differ from its descriptor");for(let[m,f]of a){let p=u.get(m);if(!p)throw new Error(`Lazy tree is missing source member ${m}`);let _=f.type;if(p.type!==_)throw new Error(`Lazy tree member ${m} is ${p.type}, expected ${_}`);if((p.mode&ie.S_MODE_BITS)!==f.mode)throw new Error(`Lazy tree member ${m} mode differs from inventory`);if(_==="file"&&p.data?.byteLength!==f.size)throw new Error(`Lazy tree member ${m} size differs from inventory`);if(_==="symlink"&&p.target!==f.target)throw new Error(`Lazy tree symlink ${m} target differs from inventory`);if(_==="hardlink"&&p.target!==f.target)throw new Error(`Lazy tree hardlink ${m} target differs from inventory`)}let d=o.materialization;if(d!==void 0){for(let f of d.assertions){let p=u.get(f.sourcePath),_=fr(f.bytesHex);if(p?.type!=="file"||p.data===void 0||p.data.byteLength!==_.byteLength||p.data.some((y,g)=>y!==_[g]))throw new Error(`Lazy tree source assertion ${f.sourcePath} differs from archive bytes`)}let m=new Map(d.recipes.map(f=>[f.id,f]));for(let f of d.transforms){let p=u.get(f.sourcePath);if(p?.type!=="file"||p.data===void 0)throw new Error(`Lazy tree transform ${f.sourcePath} is not a regular source`);await Zs(p.data,f.input,`Lazy tree transform ${f.sourcePath} input`);let _=cs(p.data,m.get(f.recipe));await Zs(_,f.output,`Lazy tree transform ${f.sourcePath} output`),p.data=_}}let h=new Map;for(let m of s){if(m.type!=="file"||m.materialization==="descriptor")continue;let f=u.get(m.sourcePath);if(f?.type!=="file"||!f.data)throw new Error(`Lazy tree has no file content for ${m.sourcePath}`);h.set(m.sourcePath,f.data)}return h}async ensureArchiveMaterialized(e,t){let r=this.lazyAtomicGroupByTree.get(e);if(r!==void 0){if(r.committed)return;let a=this.sealedLazyAtomicStates.get(e)?.snapshot,c=this.lazyPreparations.get(r.token)??this.startLazyPreparation({token:r.token,path:a?.activation.roots[0]??e.activation?.roots[0]??e.mountPrefix,atomicGroup:r});try{await c.promise}finally{this.lazyPreparations.get(r.token)===c&&this.lazyPreparations.delete(r.token)}return}if(this.ordinaryLazyTreeDefinitions.get(e)?.materialized??e.materialized)return;let o=this.lazyTransport,s=await this.fetchLazyArchiveData(e,o);te(o.signal),await this.materializeArchiveBytes(e,s,t,o.signal)}async fetchLazyArchiveData(e,t,r){let i=this.ordinaryLazyTreeDefinitions.get(e),o=r?.content??i?.content??e.content,s=r?.inventory??i?.inventory??e.inventory,a=o!==void 0&&s!==void 0,c=r?.mountPrefix??i?.mountPrefix??e.mountPrefix,u=r?.integrity??i?.integrity??e.integrity,l=a?o.transports:[r?.url??i?.url??e.url],d=[],h=null;for(let[m,f]of l.entries())try{h=await this.fetchLazyBytes({id:`archive:${c}:${o?.sha256??f}:${m}`,kind:a?"tree":"archive",url:f,mountPrefix:c,integrity:u},t);break}catch(p){if(te(t.signal),aa(p))throw p;d.push(p instanceof Error?p.message:String(p))}if(te(t.signal),h===null)throw new Error(`All ${l.length} lazy ${a?"tree":"archive"} transports failed: ${d.join("; ")}`);return h}async materializeArchiveBytes(e,t,r,i){if(te(i),this.ordinaryLazyTreeDefinitions.get(e)?.materialized??e.materialized)return;let s=await this.prepareLazyArchiveContents(e,t,i),a=r?V(r.ino,r.generation):null;for(let c=0;c<3;c++){let u=this.collectLazyArchiveReplacements(e,s,r);if(u.size>0&&(te(i),!this.fs.replaceManyIfIdentities(Array.from(u.values(),Qs)))){if(this.reconcileLazyIdentityState(this.fs.identityState()),a&&!this.lazyArchiveInodes.has(a))return;continue}if(te(i),this.publishLazyArchiveReplacements(e,u),(this.ordinaryLazyTreeDefinitions.get(e)?.materialized??e.materialized)||(this.reconcileLazyIdentityState(this.fs.identityState()),a&&!this.lazyArchiveInodes.has(a)))return}if(a&&this.lazyArchiveInodes.has(a))throw new Error(`Lazy archive member kept changing names while materializing: ${r?.path}`)}async prepareLazyArchiveContents(e,t,r,i){te(r);let o=this.ordinaryLazyTreeDefinitions.get(e),s=i?.content??o?.content??e.content,a=i?.inventory??o?.inventory??e.inventory,u=s!==void 0&&a!==void 0?await this.decodeAndValidateLazyTree(e,t,i):null;te(r);let{parseZipCentralDirectory:l,extractZipEntry:d}=await Promise.resolve().then(()=>(fi(),li));te(r);let h=u?[]:l(t),m=new Map;for(let E of h){if(m.has(E.fileName))throw new Error(`Lazy archive contains duplicate member: ${E.fileName}`);m.set(E.fileName,E)}let p=(i?.mountPrefix??o?.mountPrefix??e.mountPrefix).replace(/\/+$/,""),_=new Map,y=i?.entries??o?.entries,g=y===void 0?Array.from(e.entries):y.map(E=>[E.vfsPath,E]);for(let[E,O]of g){if(O.deleted||O.materialized)continue;let S=O.archivePath??E.slice(p.length+1),w=u?void 0:m.get(S),z=u?.get(S);if(u){if(z===void 0||z.byteLength!==O.size)throw new Error(`Lazy tree member ${S} does not match its registered metadata`)}else if(w===void 0||w.isDirectory||w.isSymlink||w.uncompressedSize!==O.size)throw new Error(`Lazy archive member ${S} does not match its registered metadata`);if(O.generation===void 0)continue;let x=V(O.ino,O.generation),I=_.get(x);if(I&&I.archivePath!==S)throw new Error(`Lazy archive aliases for inode ${x} name different members`);if(!I){let R=z??d(t,w);if(R.byteLength!==O.size)throw new Error(`Lazy archive member ${S} extracted ${R.byteLength} bytes, expected ${O.size}`);_.set(x,{archivePath:S,content:R})}}return _}collectLazyArchiveReplacements(e,t,r,i){let o=new Map,s=this.ordinaryLazyTreeDefinitions.get(e),a=i?.entries??s?.entries,c=a===void 0?Array.from(e.entries):a.map(u=>[u.vfsPath,u]);for(let[u,l]of c){if(l.deleted||l.materialized||l.generation===void 0)continue;let d=V(l.ino,l.generation);if(this.lazyArchiveInodes.get(d)!==e)continue;let h=t.get(d);if(!h)throw new Error(`Lazy archive has no extracted content for inode ${d}`);let m=o.get(d);m||(m={ino:l.ino,generation:l.generation,dataSequence:l.dataSequence??0,paths:new Set,content:h.content},o.set(d,m)),m.paths.add(u),r&&r.ino===l.ino&&r.generation===l.generation&&m.paths.add(r.path)}return o}publishLazyArchiveReplacements(e,t){let r=this.ordinaryLazyTreeDefinitions.get(e);if(r!==void 0){let i=r.entries.map(o=>{let s=o.generation===void 0?void 0:V(o.ino,o.generation);return s===void 0||!t.has(s)?o:(this.lazyArchiveInodes.delete(s),{...o,materialized:!0})});this.replaceOrdinaryLazyTreeRuntimeState(e,i,i.every(o=>o.deleted||o.materialized));return}for(let[i,o]of t){this.lazyArchiveInodes.delete(i);for(let s of e.entries.values())s.ino===o.ino&&s.generation===o.generation&&(s.materialized=!0)}e.materialized=Array.from(e.entries.values()).every(i=>i.deleted||i.materialized)}collectAtomicTreeNamespace(e,t){let r=new Set,i=new Map,o=new Map,s=new Map(t.entries.map(c=>[c.vfsPath,c]));for(let c of t.inventory)(c.type==="file"||c.type==="hardlink")&&o.set(c.inodeGroup,(o.get(c.inodeGroup)??0)+1);let a=[];for(let c of t.inventory){let u;try{u=this.fs.lstat(c.vfsPath)}catch{throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`)}let l=c.type==="directory"?Qe:c.type==="symlink"?Sr:kt;if((u.mode&Ee)!==l||(u.mode&ie.S_MODE_BITS)!==c.mode)throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`);if(c.type==="symlink"){let d=s.get(c.vfsPath);if(d===void 0||!d.isSymlink||d.deleted||d.ino!==u.ino||d.generation!==u.generation||d.dataSequence!==u.dataSequence||this.fs.readlink(c.vfsPath)!==c.target)throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`)}else if(c.type==="file"||c.type==="hardlink"){let d=s.get(c.vfsPath);if(d===void 0||d.deleted||d.materialized||d.isSymlink||d.generation===void 0||d.inodeGroup!==c.inodeGroup||d.ino!==u.ino||d.generation!==u.generation||d.dataSequence!==u.dataSequence||u.size!==0||u.linkCount!==o.get(c.inodeGroup))throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`);let h=V(d.ino,d.generation);if(this.lazyArchiveInodes.get(h)!==e)throw new Error(`Lazy atomic tree lost deferred ownership of ${c.vfsPath}`);let m=i.get(c.inodeGroup);if(m!==void 0&&m!==h)throw new Error(`Lazy atomic tree split hard links at ${c.vfsPath}`);i.set(c.inodeGroup,h),r.add(h)}a.push({path:c.vfsPath,expectedIno:u.ino,expectedGeneration:u.generation,expectedDataSequence:u.dataSequence,expectedMode:u.mode,expectedLinkCount:u.linkCount,expectedSize:u.size,expectedUid:u.uid,expectedGid:u.gid})}return{guards:a,pendingIdentities:r.size}}assertLazyAtomicSnapshotMatchesPublic(e){let t=this.sealedLazyAtomicStates.get(e),r=t?.snapshot,i=e.activation?.atomicGroup,o=r?.member??i?.member??"unknown",s;if(r!==void 0)try{s=rn(e,r.id,r.member)}catch{s=void 0}if(t===void 0||r===void 0||i===void 0||!Ft(i)||i.id!==r.id||i.member!==r.member||i.descriptorSha256!==r.descriptorSha256||i.expectedCount!==r.expectedCount||i.cohortSha256!==r.cohortSha256||s===void 0||!Js(r,s))throw new Error(`Lazy atomic activation member ${o} changed after sealing`);return t}assertPendingLazyAtomicSnapshotsReadyForSerialization(){for(let e of this.lazyArchiveGroups){if(!this.sealedLazyAtomicStates.has(e)||this.lazyAtomicGroupByTree.get(e)?.committed)continue;let r=this.assertLazyAtomicSnapshotMatchesPublic(e);if(!r.verified)throw new Error(`Lazy atomic activation group ${r.snapshot.id} has not been cryptographically verified after import`)}}async validatePendingLazyAtomicGroupSeals(e){for(let t of this.lazyAtomicGroups.values()){if(t.committed||t.expectedCount===void 0||t.cohortSha256===void 0)continue;let r=[...t.groups.entries()].sort(([i],[o])=>io?1:0).map(([,i])=>i);await this.ensureLazyAtomicGroupSealValidated(t,r,e)}}async ensureLazyAtomicGroupSealValidated(e,t,r){if(this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r))return;let i=e.sealValidationFlight;if(i===void 0&&(i=this.validateLazyAtomicGroupSealOnce(e,t),e.sealValidationFlight=i,i.then(()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)},()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)})),await i,!this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r))throw new Error(`Lazy atomic activation group ${e.id} seal verification did not authenticate every member`)}assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r){if(e.committed)return!0;if(e.expectedCount===void 0||e.cohortSha256===void 0||t.length!==e.expectedCount)throw new Error(`Lazy atomic activation group ${e.id} is not completely sealed`);let i=!0,o=[];for(let s of t){let a=this.assertLazyAtomicSnapshotMatchesPublic(s),c=a.snapshot;if(c.id!==e.id||c.expectedCount!==e.expectedCount||c.cohortSha256!==e.cohortSha256||e.groups.get(c.member)!==s)throw new Error(`Lazy atomic activation group ${e.id} has an inconsistent member`);i&&=a.verified,o.push(a)}if(i&&r)for(let s=0;sfp?1:0).map(([,f])=>f);if(t.length===0)throw new Error(`Lazy atomic activation group ${e.id} has no trees`);if(await this.ensureLazyAtomicGroupSealValidated(e,t,!0),e.committed)return;let r=t.map(f=>this.sealedLazyAtomicStates.get(f).snapshot),i=t.map((f,p)=>({group:f,...this.collectAtomicTreeNamespace(f,r[p])})),o=this.lazyTransport,s=new Array(t.length),a=0,c=!1,u,l=Array.from({length:Math.min($d,t.length)},async()=>{for(;!c;){let f=a++;if(f>=t.length)return;let p=t[f],_=r[f];try{let y=await this.fetchLazyArchiveData(p,o,_);te(o.signal),s[f]={group:p,snapshot:_,contents:await this.prepareLazyArchiveContents(p,y,o.signal,_)}}catch(y){c||(c=!0,u=y)}}});if(await Promise.all(l),c)throw s.fill(void 0),u;te(o.signal);for(let f of t)this.assertLazyAtomicSnapshotMatchesPublic(f);let d=[],h=[],m=[];for(let f=0;f{let a=this.lazyAtomicGroupByTree.get(s),c=this.sealedLazyAtomicStates.get(s)?.snapshot,u=this.ordinaryLazyTreeDefinitions.get(s);return c===void 0?u!==void 0&&!u.materialized:!a?.committed});if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0&&r.length===0)return;let i=Array.from(this.lazyFiles.values(),s=>s.path);for(let s of i)await this.ensureMaterialized(s);let o=new Set(this.lazyArchiveInodes.values());for(let s of r)o.add(s);for(let s of o)await this.prepareLazyTreeGroup(s)}this.reconcileLazyIdentityState(this.fs.identityState());let e=this.lazyArchiveGroups.some(t=>{let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t)?.snapshot,o=this.ordinaryLazyTreeDefinitions.get(t);return i===void 0?o!==void 0&&!o.materialized:!r?.committed});if(this.lazyFiles.size!==0||this.lazyArchiveInodes.size!==0||e)throw new Error("Cannot create a self-contained VFS image while lazy entries remain pending")}async saveImage(e){e?.materializeAll&&await this.materializeAllLazyEntries(),await this.validatePendingLazyAtomicGroupSeals(!1);let{bytes:t,identities:r}=this.fs.snapshotState({normalizeTimestampsMs:e?.normalizeTimestampsMs});this.reconcileLazyIdentityState(r);let i=this.serializeLazyEntries(),o=i.length>0,s=o?new TextEncoder().encode(JSON.stringify(i)):new Uint8Array(0);if(s.byteLength>on)throw new Error(`VFS image lazy metadata exceeds ${on} bytes`);let a=this.serializeValidatedLazyArchiveEntries(r),c=a.length>0,u=c?new TextEncoder().encode(JSON.stringify(a)):new Uint8Array(0);if(u.byteLength>sn)throw new Error(`VFS image lazy archive metadata exceeds ${sn} bytes`);let l=e?.metadata===void 0?this.imageMetadata:e.metadata,d=Zd(l),h=d.byteLength>0,m=c?4+u.byteLength:0,f=h?4+d.byteLength:0,p=ye+t.byteLength+4+s.byteLength+m+f,_=new Uint8Array(p),y=new DataView(_.buffer);y.setUint32(0,zi,!0),y.setUint32(4,xi,!0),y.setUint32(8,(o?gi:0)|(c?nn:0)|(c?Si:0)|(h?Ei:0),!0),y.setUint32(12,t.byteLength,!0),_.set(t,ye);let g=ye+t.byteLength;if(y.setUint32(g,s.byteLength,!0),s.byteLength>0&&_.set(s,g+4),c){let E=g+4+s.byteLength;y.setUint32(E,u.byteLength,!0),_.set(u,E+4)}if(h){let E=g+4+s.byteLength+m;y.setUint32(E,d.byteLength,!0),_.set(d,E+4)}return _}static readImageMetadata(e){let t=Qr(e);if(!(t.flags&Ei))return null;let{metadataOffset:r}=Hs(t.image,t.view,t.flags,t.sabLen);if(t.image.byteLengthCt)throw new Error(`VFS image metadata exceeds ${Ct} bytes`);if(t.image.byteLength0){let _=r.subarray(f+4,f+4+p),y=Me(Vs(_,"VFS image lazy metadata"),"VFS image lazy entries",0,Mt);m.importLazyEntriesInternal(y,!0)}if(o&nn){let _=a.archiveOffset,y=i.getUint32(_,!0);if(y>0){let g=r.subarray(_+4,_+4+y),E=Vs(g,"VFS image lazy archive metadata");m.importLazyArchiveEntriesInternal(E,!0,!!(o&Si),"pending")}}return m}adaptStat(e){return{dev:0,ino:e.ino,mode:e.mode,nlink:e.linkCount,uid:e.uid,gid:e.gid,size:e.size,atimeMs:e.atime,mtimeMs:e.mtime,ctimeMs:e.ctime}}adaptStatWithLazySize(e){let t=this.adaptStat(e),r=this.lazyFileForStat(e);if(r)return t.size=r.size,t;let i=this.lazyArchiveForStat(e);if(i){for(let o of this.lazyArchiveEntriesForRead(i))if(o.ino===e.ino&&o.generation===e.generation&&!o.deleted){t.size=o.size;break}}return t}open(e,t,r){(t&dr)===0&&!((t&cr)!==0&&(t&ei)!==0)&&this.guardSynchronousLazyAccess(e);let i=this.fs.open(e,t,r);return(t&dr)!==0&&this.invalidateLazyData(this.fs.fstat(i)),i}close(e){return this.fs.close(e),0}read(e,t,r,i){if(i>0){let o=this.lazyBackingForStat(this.fs.fstat(e));o&&(this.reconcileLazyIdentityState(this.fs.identityState()),o=this.lazyBackingForStat(this.fs.fstat(e)),o&&this.guardSynchronousLazyAccess(o.path))}return r!==null?this.fs.readAt(e,t.subarray(0,i),typeof r=="bigint"?Un(r):r):this.fs.read(e,t.subarray(0,i))}write(e,t,r,i){if(r!==null){let s=this.fs.writeAt(e,t.subarray(0,i),typeof r=="bigint"?Un(r):r);return s>0&&this.invalidateLazyData(this.fs.fstat(e)),s}let o=this.fs.write(e,t.subarray(0,i));return o>0&&this.invalidateLazyData(this.fs.fstat(e)),o}append(e,t,r,i){let o=this.fs.append(e,t.subarray(0,r),Bo(i));return o.written>0&&this.invalidateLazyData(this.fs.fstat(e)),o}seek(e,t,r){return this.fs.lseek(e,typeof t=="bigint"?$n(t):t,r)}fstat(e){return this.adaptStatWithLazySize(this.fs.fstat(e))}fpathconf(e,t){let r=this.fstat(e);return Gn(r,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}ftruncate(e,t){this.fs.ftruncate(e,t),this.invalidateLazyData(this.fs.fstat(e))}fsync(e){}fchmod(e,t){this.fs.fchmod(e,t)}fchown(e,t,r){this.fs.fchown(e,t,r)}stat(e){return this.adaptStatWithLazySize(this.fs.stat(e))}lstat(e){return this.adaptStatWithLazySize(this.fs.lstat(e))}statfs(e){this.fs.stat(e);let t=this.fs.statfs();return{type:1397114451,bsize:t.blockSize,blocks:t.totalBlocks,bfree:t.freeBlocks,bavail:t.freeBlocks,files:t.totalInodes,ffree:t.freeInodes,fsid:0,namelen:t.maxName,frsize:t.blockSize,flags:Uo}}pathconf(e,t){let r=this.stat(e);return Gn(r,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}mkdir(e,t){this.fs.mkdir(e,t)}rmdir(e){this.fs.rmdir(e)}unlink(e){let t=this.fs.unlink(e),r=V(t.ino,t.generation);if(t.linkCount>1&&(this.lazyFiles.has(r)||this.lazyArchiveInodes.has(r))){this.reconcileLazyIdentityState(this.fs.identityState());return}let i=this.lazyFiles.get(r);i&&(i.paths.delete(e),t.linkCount<=1?this.lazyFiles.delete(r):i.path===e&&(i.path=i.paths.values().next().value));let o=this.lazyArchiveInodes.get(r);if(o){let s=this.ordinaryLazyTreeDefinitions.get(o);if(s!==void 0){let a=t.linkCount<=1?s.entries.map(c=>c.ino===t.ino&&c.generation===t.generation?{...c,deleted:!0}:c):s.entries.filter(c=>c.vfsPath!==e);this.replaceOrdinaryLazyTreeRuntimeState(o,a,s.materialized),t.linkCount<=1&&this.lazyArchiveInodes.delete(r)}else{let a=o.entries.get(e);if(t.linkCount<=1){for(let c of o.entries.values())c.ino===t.ino&&c.generation===t.generation&&(c.deleted=!0);this.lazyArchiveInodes.delete(r)}else a&&o.entries.delete(e)}}}rename(e,t){let{source:r,replaced:i}=this.fs.rename(e,t);if(i&&i.ino===r.ino&&i.generation===r.generation)return;let o=!1;if(i){let s=V(i.ino,i.generation);i.linkCount>1&&(this.lazyFiles.has(s)||this.lazyArchiveInodes.has(s))&&(this.reconcileLazyIdentityState(this.fs.identityState()),o=!0);let a=this.lazyFiles.get(s);!o&&a&&(a.paths.delete(t),i.linkCount<=1?this.lazyFiles.delete(s):a.path===t&&(a.path=a.paths.values().next().value));let c=this.lazyArchiveInodes.get(s);if(!o&&c){let u=this.ordinaryLazyTreeDefinitions.get(c);if(u!==void 0){let l=i.linkCount<=1?u.entries.map(d=>d.ino===i.ino&&d.generation===i.generation?{...d,deleted:!0}:d):u.entries.filter(d=>d.vfsPath!==t);this.replaceOrdinaryLazyTreeRuntimeState(c,l,u.materialized),i.linkCount<=1&&this.lazyArchiveInodes.delete(s)}else{let l=c.entries.get(t);i.linkCount<=1?(l&&(l.deleted=!0),this.lazyArchiveInodes.delete(s)):l&&c.entries.delete(t)}}}o||this.rewriteLazyNamespacePaths(r,e,t)}link(e,t){let r=this.fs.link(e,t),i=V(r.ino,r.generation),o=this.lazyFiles.get(i);o&&o.paths.add(t);let s=this.lazyArchiveInodes.get(i);if(s){let a=this.ordinaryLazyTreeDefinitions.get(s);if(a!==void 0){let c=a.entries.find(u=>u.ino===r.ino&&u.generation===r.generation);c!==void 0&&this.replaceOrdinaryLazyTreeRuntimeState(s,[...a.entries,{...c,vfsPath:t}],a.materialized)}else{let c=Array.from(s.entries.values()).find(u=>u.ino===r.ino&&u.generation===r.generation);c&&s.entries.set(t,{...c})}}}symlink(e,t){this.fs.symlink(e,t)}readlink(e){return this.fs.readlink(e)}chmod(e,t){this.fs.chmod(e,t)}chown(e,t,r){this.fs.chown(e,t,r)}lchown(e,t,r){this.fs.lchown(e,t,r)}createFileWithOwner(e,t,r,i,o){let s=this.open(e,Ks,t);o.length>0&&this.write(s,o,null,o.length),this.close(s),this.chown(e,r,i),this.chmod(e,t)}mkdirWithOwner(e,t,r,i){this.mkdir(e,t),this.chown(e,r,i),this.chmod(e,t)}symlinkWithOwner(e,t,r,i){this.symlink(e,t),this.lchown(t,r,i)}copyPathToFreshFileSystem(e,t,r,i,o){let s=this.lstat(e),a=s.mode&Ee,c=s.mode&ie.S_MODE_BITS;if(a===Qe){e==="/"?(t.chown(e,s.uid,s.gid),t.chmod(e,c)):t.mkdirWithOwner(e,c,s.uid,s.gid);let h=this.opendir(e);try{for(;;){let m=this.readdir(h);if(!m)break;m.name==="."||m.name===".."||this.copyPathToFreshFileSystem(e==="/"?`/${m.name}`:`${e}/${m.name}`,t,r,i,o)}}finally{this.closedir(h)}n.applyTimes(t,e,s);return}let u=s.nlink>1?`${s.dev}:${s.ino}`:null,l=u?o.get(u):void 0;if(l){t.link(l,e);return}if(a===Sr){t.symlinkWithOwner(this.readlink(e),e,s.uid,s.gid),u&&o.set(u,e);return}if(a!==kt)throw new Error(`Unsupported file type while rebasing VFS: ${e}`);if(r.has(e)||i.has(e)){t.createFileWithOwner(e,c,s.uid,s.gid,new Uint8Array(0)),n.applyTimes(t,e,s),u&&o.set(u,e);return}this.copyRegularFileToFreshFileSystem(e,t,s,c),u&&o.set(u,e)}copyRegularFileToFreshFileSystem(e,t,r,i){let o=this.open(e,Cd,0),s=null;try{s=t.open(e,Ks,i);let a=new Uint8Array(Math.min(Md,Math.max(1,r.size))),c=r.size;for(;c>0;){let u=Math.min(a.byteLength,c),l=this.read(o,a,null,u);if(l<=0)throw new Error(`Unexpected EOF while rebasing VFS file: ${e}`);let d=0;for(;d!e||e==="."||e===".."))throw new Error(`Binary resolver path must be a normalized portable relative path: ${JSON.stringify(n)}`);return n}var mt=new Set(["wasm32","wasm64"]);function Ke(n){if(Rl(n),!n.startsWith("programs/"))return n;let e=n.slice(9),t=e.split("/",1)[0];return mt.has(t)?n:`programs/wasm32/${e}`}function Ll(n,e=U(Bi(),"wasm")){let t=Ke(n),r=[U(e,t)];return n==="kernel.wasm"?r.push(U(e,"kandelo-kernel.wasm")):n==="userspace.wasm"?r.push(U(e,"wasm_posix_userspace.wasm")):n==="rootfs.vfs"&&r.push(U(e,"rootfs.vfs")),r}var mn=class extends Error{constructor(e){super(e),this.name="BinaryNotFoundError"}};function xa(){let n=[],e=!1;try{let r=ht();e=!0;for(let[i,o]of[["local-binaries",U(r,"local-binaries")],["binaries",U(r,"binaries")]])n.push({label:i,root:o,identity:i==="local-binaries"?"local-generation":"program-cache",allowRegularFileClosure:!1,candidatesFor(s){return[U(o,Ke(s))]}})}catch{}let t=U(Bi(),"wasm");return n.push({label:"installed package",root:t,identity:"installed-package",allowRegularFileClosure:!e,candidatesFor(r){return Ll(r,t)}}),n}function Dt(n,e){return new Error(`Invalid package manifest ${n}: ${e}`)}function fe(n){try{return yn(n),!0}catch(e){if(e instanceof Error&&"code"in e&&e.code==="ENOENT")return!1;throw e}}function ma(n,e,t){if(n.length===0||n.startsWith("/")||n.includes("\\")||n.includes("\0")||n.split("/").some(r=>!r||r==="."||r===".."))throw Dt(e,`${t} must be a normalized portable relative path`);return n}function pn(n,e,t,r=!0){if(n.length===0||n==="."||n===".."||n.includes("/")||n.includes("\\")||n.includes("\0")||!r&&n.includes("@"))throw Dt(e,`${t} must be a safe single path component`);return n}var ya="kandelo-program-packages-v2",De="program-packages.json",_a=null,bl=null,hn=null,Ni=0;function $i(){return bl??U(Bi(),"wasm",De)}function Ia(){if(Object.prototype.hasOwnProperty.call(process.env,"WASM_POSIX_DEPS_REGISTRY")){let t=null;return(process.env.WASM_POSIX_DEPS_REGISTRY??"").split(":").filter(Boolean).map(r=>r.startsWith("~/")&&process.env.HOME!==void 0?U(process.env.HOME,r.slice(2)):_n(r)?Re(r):(t??=ht(),Re(t,r)))}let n;try{n=U(ht(),"packages","registry")}catch{return null}let e=!1;if(fe(n)){if(!rt(n).isDirectory())return[n];e=Oa(n,{withFileTypes:!0}).filter(t=>t.isDirectory()||t.isSymbolicLink()).some(t=>fe(U(n,t.name,"package.toml")))}return!e&&Ta()===null&&fe($i())?null:[n]}function Ta(){let n;try{n=ht()}catch{return null}if(!xr(U(n,"tools","xtask","Cargo.toml"))||!xr(U(n,"scripts","dev-shell.sh")))return null;try{let e=Le(Ki()),t=Le(n);return[U(t,"host"),U(t,"scripts")].some(i=>xr(i)&&Vi(Le(i),e))?t:null}catch{return null}}function Ui(n,e,t){let r=[typeof t.stderr=="string"?t.stderr.trim():"",typeof t.stdout=="string"?t.stdout.trim():"",t.error?.message??""].filter(Boolean).join(` +var Fa=Object.defineProperty;var br=(n,e,t)=>()=>{if(t)throw t[0];try{return n&&(e=n(n=0)),e}catch(r){throw t=[r],r}};var qi=(n,e)=>{for(var t in e)Fa(n,t,{get:e[t],enumerable:!0})};var Bt,Zi,$t,Yi,gn,Xi,ne,ji,Ji,vr,Qi,En,eo,to,ro,no,Pr,kr,Sn,wn,On,An,zn,gt,Ut,Wt,Gt,Pe,io,oo,Fr,ze,so,Nr,xn,Cr,Mr,Et,Ht,Ge,In,Tn,ao,Dr,co,Y,uo,lo,Kr,Vt,fo,po,ho,X,mo,yo,Br,qt,_o,go,Rn,Ln,ot,St,bn,Zt,He,Eo,So,ie,wo,Oo,Ao,zo,Ve=br(()=>{"use strict";Bt="kandelo.wpk_fork.linked_frames",Zi=[75,76,67,70],$t=24,Yi=8,gn=3,Xi=[{bytes:4,chunkHeaderSize:32,nodeHeaderSize:24},{bytes:8,chunkHeaderSize:56,nodeHeaderSize:32}],ne="kandelo.wpk_fork.module_state",ji=1,Ji=[75,70,77,68],vr=24,Qi=8,En=7,eo=1,to=1,ro=1,no=[{bytes:4,chunkHeaderSize:40},{bytes:8,chunkHeaderSize:56}],Pr="__wpk_fork_global_",kr="__wpk_fork_table_",Sn=1,wn=2,On=3,An=4,zn=5,gt=6,Ut=7,Wt=8,Gt=9,Pe="kandelo.wpk_fork.capabilities",io=1,oo=7,Fr=4,ze="kandelo.wpk_fork.exception_codec",so=1,Nr=8,xn=16,Cr="env",Mr="__wpk_fork_unwind",Et="kandelo.wpk_fork.unwind_transport",Ht="__wpk_fork_static_root_catalog",Ge="kandelo.wpk_fork.static_root_catalog",In=1,Tn=0,ao=1,Dr=12,co=[75,70,83,82],Y="kandelo.wpk_fork.imported_globals",uo=[75,70,73,71],lo=1,Kr=16,Vt=24,fo=1,po=2,ho=3,X="kandelo.wpk_fork.imported_tables",mo=[75,70,73,84],yo=1,Br=16,qt=24,_o=1,go=1,Rn="env",Ln="__wpk_fork_module_activation",ot={module:"kernel",name:"kernel_fork",params:["i32"],results:["i32"]},St=[{module:"env",name:"__wpk_fork_frame_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_frame_next",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_peek",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_reserve",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_record_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_module_state_record_find",params:["i32","i32","i32","i32"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_record_reserve",params:["i32","i32","i32","ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_table_dirty_count",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_module_state_table_dirty_mark",params:["i32","i64","i64"],results:[]},{module:"env",name:"__wpk_fork_module_state_table_dirty_page",params:["i32","i32"],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_mutation_abort",params:[],results:[]},{module:"env",name:"__wpk_fork_module_state_table_mutation_begin",params:[],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_mutation_commit",params:["i32","i64","i64"],results:[]},{module:"env",name:"__wpk_fork_module_state_table_reconcile",params:[],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_state_owned",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_decode_funcref",params:["i32"],results:["funcref"]},{module:"env",name:"__wpk_fork_ref_encode_funcref",params:["funcref"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_broker_encode",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_broker_throw_recipe",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_cache_index",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_claim",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_define",params:["i32","i32","i32","i32","ptr","i32","ptr","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_ingress_throw",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_load",params:["i32","i32","i32","i32","ptr","i32","ptr","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_lookup",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_route",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_broker_encode",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_capture_layout",params:["i32","i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_claim",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_define",params:["i32","i32","i32","i32","i32","ptr","i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_i31",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_load",params:["i32","i32","i32","i32","i32","ptr","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_lookup",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_payload_len",params:["i32","i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_provenance_begin",params:["i32","i32","i32","i32","i64","i64","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_provenance_end",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_provenance_ref",params:["i32","i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_route",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_scratch_release",params:["ptr","ptr"],results:[]},{module:"env",name:"__wpk_fork_ref_scratch_reserve",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_ref_vector_append",params:["i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_vector_begin",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_vector_finish",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_vector_get",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_resume_peek",params:["i32"],results:["i32"]}],bn=[{module:"env",name:"__wpk_fork_ref_gc_transit",table64:!1,element:"anyref",minimum:1,maximum:null},{module:"env",name:"__wpk_fork_resume_table",table64:!1,element:"funcref",minimum:1,maximum:null}],Zt=[{name:"__wpk_fork_exception_materialize",params:["i32"],results:[]},{name:"__wpk_fork_ref_decode_exnref",params:["i32"],results:["exnref"]},{name:"__wpk_fork_ref_encode_exnref",params:["exnref"],results:["i32"]},{name:"__wpk_fork_ref_exn_abort",params:[],results:[]},{name:"__wpk_fork_ref_exn_clear",params:[],results:[]},{name:"__wpk_fork_ref_exn_encode_ingress",params:["i32"],results:["i32"]},{name:"__wpk_fork_ref_exn_throw_recipe",params:["i32"],results:[]},{name:"__wpk_fork_ref_exn_throw_slot",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_allocate",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_encode_slot",params:["i32"],results:["i32"]},{name:"__wpk_fork_ref_gc_fill",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_probe",params:["i32"],results:["i64"]},{name:"__wpk_fork_ref_gc_publish_externref",params:["i32","externref"],results:[]},{name:"__wpk_fork_static_root_harvest",params:[],results:[]},{name:"wpk_fork_abort_begin",params:["ptr"],results:[]},{name:"wpk_fork_abort_end",params:[],results:[]},{name:"wpk_fork_module_bootstrap",params:[],results:[]},{name:"wpk_fork_module_state_finish_restore",params:["i32"],results:[]},{name:"wpk_fork_module_state_restore",params:["i32"],results:[]},{name:"wpk_fork_module_state_save",params:["i32"],results:[]},{name:"wpk_fork_module_table_state_restore",params:["i32"],results:[]},{name:"wpk_fork_module_table_state_save",params:["i32"],results:[]},{name:"wpk_fork_module_thread_bootstrap",params:[],results:[]},{name:"wpk_fork_rewind_begin",params:["ptr"],results:[]},{name:"wpk_fork_rewind_end",params:[],results:[]},{name:"wpk_fork_state",params:[],results:["i32"]},{name:"wpk_fork_unwind_begin",params:["ptr"],results:[]},{name:"wpk_fork_unwind_end",params:[],results:[]}],He={O_RDONLY:0,O_WRONLY:1,O_RDWR:2,O_ACCMODE:3,O_CREAT:64,O_EXCL:128,O_NOCTTY:256,O_TRUNC:512,O_APPEND:1024,O_NONBLOCK:2048,O_ASYNC:8192,O_DIRECTORY:65536,O_NOFOLLOW:131072,O_CLOEXEC:524288,O_PATH:2097152,O_CLOFORK:8388608},Eo={F_OK:0,R_OK:4,W_OK:2,X_OK:1},So={ST_NOSUID:2},ie={S_IFMT:61440,S_IFSOCK:49152,S_IFLNK:40960,S_IFREG:32768,S_IFBLK:24576,S_IFDIR:16384,S_IFCHR:8192,S_IFIFO:4096,S_ISUID:2048,S_ISGID:1024,S_ISVTX:512,S_IRWXU:448,S_IRUSR:256,S_IWUSR:128,S_IXUSR:64,S_IRWXG:56,S_IRGRP:32,S_IWGRP:16,S_IXGRP:8,S_IRWXO:7,S_IROTH:4,S_IWOTH:2,S_IXOTH:1,S_MODE_BITS:4095},wo={DT_UNKNOWN:0,DT_FIFO:1,DT_CHR:2,DT_DIR:4,DT_BLK:6,DT_REG:8,DT_LNK:10,DT_SOCK:12},Oo=4096,Ao=["__abi_version","kernel_alloc_scratch","kernel_blocking_retry_release","kernel_blocking_retry_token","kernel_commit_process_exit","kernel_create_process","kernel_create_process_with_stdio","kernel_dequeue_signal","kernel_exec_commit","kernel_exec_target_cancel","kernel_exec_target_prepare","kernel_exec_target_read","kernel_exec_target_size","kernel_fork_process","kernel_get_cwd","kernel_get_dirfd_path","kernel_get_fd_path","kernel_get_parent_pid","kernel_get_process_exit_signal","kernel_get_process_state","kernel_get_socket_timeout_ms","kernel_handle_channel","kernel_has_sa_nocldstop","kernel_host_adapter_manifest_len","kernel_host_adapter_manifest_ptr","kernel_ipc_shm_lookup_mapping_for_task","kernel_ipc_shm_record_mapping_for_process","kernel_ipc_shm_record_mapping_for_task","kernel_ipc_shmat_for_process","kernel_ipc_shmat_for_task","kernel_ipc_shmdt_addr_for_process","kernel_ipc_shmdt_addr_for_task","kernel_ipc_shmdt_for_process","kernel_ipc_shmdt_for_task","kernel_is_fd_nonblock","kernel_mark_process_signaled","kernel_mq_descriptor_msgsize","kernel_msqid_ds_bytes","kernel_pcm_claim_transport","kernel_pcm_clock_update","kernel_pcm_reconcile","kernel_pcm_transport_len","kernel_pcm_transport_ptr","kernel_pick_signal_target_tid","kernel_pick_tcp_listener_target","kernel_pipe_has_readers","kernel_posix_timer_fire","kernel_process_metadata_begin","kernel_process_metadata_cancel","kernel_process_metadata_commit","kernel_process_metadata_stage","kernel_reap_exited_child","kernel_remove_process","kernel_semctl_array_bytes","kernel_semid_ds_bytes","kernel_set_current_tid","kernel_set_cwd","kernel_shmid_ds_bytes","kernel_publish_spawn_child","kernel_spawn_exec_commit","kernel_spawn_exec_target_prepare","kernel_spawn_process","kernel_spawn_reserved_process","kernel_spawn_scratch_begin","kernel_spawn_scratch_cancel","kernel_spawn_scratch_capacity","kernel_spawn_scratch_pointer","kernel_spawn_scratch_retained_capacity","kernel_take_process_timer_cleanup","kernel_thread_exit","kernel_thread_has_deliverable","kernel_transfer_channel_execute","kernel_transfer_io_execute","kernel_transfer_scratch_begin","kernel_transfer_scratch_cancel","kernel_transfer_scratch_capacity","kernel_transfer_scratch_pointer","kernel_validate_task","kernel_wait_child_poll"],zo={LINK_MAX:0,MAX_CANON:1,MAX_INPUT:2,NAME_MAX:3,PATH_MAX:4,PIPE_BUF:5,CHOWN_RESTRICTED:6,NO_TRUNC:7,VDISABLE:8,SYNC_IO:9,ASYNC_IO:10,PRIO_IO:11,SOCK_MAXBUF:12,FILESIZEBITS:13,REC_INCR_XFER_SIZE:14,REC_MAX_XFER_SIZE:15,REC_MIN_XFER_SIZE:16,REC_XFER_ALIGN:17,ALLOC_SIZE_MIN:18,SYMLINK_MAX:19,POSIX2_SYMLINKS:20,FALLOC:21,TEXTDOMAIN_MAX:22,TIMESTAMP_RESOLUTION:23}});import{createRequire as Iu}from"module";function Ss(n,e){return Es(n,{i:2},e&&e.out,e&&e.dictionary)}var Tu,Pt,Ru,Lu,ae,bt,bu,fs,ps,vu,hs,Pt,ms,Pu,ys,ku,Af,ai,Ne,K,mr,yr,K,K,K,K,_s,K,Fu,Nu,oi,Te,si,gs,Jr,Cu,ge,Es,Mu,Du,vt,ws,Ku,Bu,ci=br(()=>{Tu=Iu("/");try{Pt=Tu("worker_threads"),Ru=Pt.Worker,Lu=Pt.isMarkedAsUntransferable}catch{}ae=Uint8Array,bt=Uint16Array,bu=Int32Array,fs=new ae([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),ps=new ae([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),vu=new ae([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),hs=function(n,e){for(var t=new bt(31),r=0;r<31;++r)t[r]=e+=1<>1|(K&21845)<<1,Ne=(Ne&52428)>>2|(Ne&13107)<<2,Ne=(Ne&61680)>>4|(Ne&3855)<<4,ai[K]=((Ne&65280)>>8|(Ne&255)<<8)>>1;mr=(function(n,e,t){for(var r=n.length,i=0,o=new bt(e);i>c]=u}else for(a=new bt(r),i=0;i>15-n[i]);return a}),yr=new ae(288);for(K=0;K<144;++K)yr[K]=8;for(K=144;K<256;++K)yr[K]=9;for(K=256;K<280;++K)yr[K]=7;for(K=280;K<288;++K)yr[K]=8;_s=new ae(32);for(K=0;K<32;++K)_s[K]=5;Fu=mr(yr,9,1),Nu=mr(_s,5,1),oi=function(n){for(var e=n[0],t=1;te&&(e=n[t]);return e},Te=function(n,e,t){var r=e/8|0;return(n[r]|n[r+1]<<8)>>(e&7)&t},si=function(n,e){var t=e/8|0;return(n[t]|n[t+1]<<8|n[t+2]<<16)>>(e&7)},gs=function(n){return(n+7)/8|0},Jr=function(n,e,t){return(e==null||e<0)&&(e=0),(t==null||t>n.length)&&(t=n.length),new ae(n.subarray(e,t))},Cu=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],ge=function(n,e,t){var r=new Error(e||Cu[n]);if(r.code=n,Error.captureStackTrace&&Error.captureStackTrace(r,ge),!t)throw r;return r},Es=function(n,e,t,r){var i=n.length,o=r?r.length:0;if(!i||e.f&&!e.l)return t||new ae(0);var s=!t,a=s||e.i!=2,c=e.i;s&&(t=new ae(i*3));var u=function(Ue){var We=t.length;if(Ue>We){var Lr=new ae(Math.max(We*2,Ue));Lr.set(t),t=Lr}},l=e.f||0,d=e.p||0,h=e.b||0,m=e.l,f=e.d,p=e.m,_=e.n,y=i*8;do{if(!m){l=Te(n,d,1);var g=Te(n,d+1,3);if(d+=3,g)if(g==1)m=Fu,f=Nu,p=9,_=5;else if(g==2){var w=Te(n,d,31)+257,z=Te(n,d+10,15)+4,x=w+Te(n,d+5,31)+1;d+=14;for(var I=new ae(x),R=new ae(19),L=0;L>4;if(E<16)I[L++]=E;else{var G=0,ue=0;for(E==16?(ue=3+Te(n,d,3),d+=2,G=I[L-1]):E==17?(ue=3+Te(n,d,7),d+=3):E==18&&(ue=11+Te(n,d,127),d+=7);ue--;)I[L++]=G}}var Oe=I.subarray(0,w),M=I.subarray(w);p=oi(Oe),_=oi(M),m=mr(Oe,p,1),f=mr(M,_,1)}else ge(1);else{var E=gs(d)+4,O=n[E-4]|n[E-3]<<8,S=E+O;if(S>i){c&&ge(0);break}a&&u(h+O),t.set(n.subarray(E,S),h),e.b=h+=O,e.p=d=S*8,e.f=l;continue}if(d>y){c&&ge(0);break}}a&&u(h+131072);for(var de=(1<>4;if(d+=G&15,d>y){c&&ge(0);break}if(G||ge(2),ve<256)t[h++]=ve;else if(ve==256){nt=d,m=null;break}else{var Kt=ve-254;if(ve>264){var L=ve-257,Be=fs[L];Kt=Te(n,d,(1<>4;yt||ge(3),d+=yt&15;var M=ku[Ae];if(Ae>3){var Be=ps[Ae];M+=si(n,d)&(1<y){c&&ge(0);break}a&&u(h+131072);var $e=h+Kt;if(h>3&1)+(e>>4&1);r>0;r-=!n[t++]);return t+(e&2)},vt=(function(){function n(e,t){typeof e=="function"&&(t=e,e={}),this.ondata=t;var r=e&&e.dictionary&&e.dictionary.subarray(-32768);this.s={i:0,b:r?r.length:0},this.o=new ae(32768),this.p=new ae(0),r&&this.o.set(r)}return n.prototype.e=function(e){if(this.ondata||ge(5),this.d&&ge(4),!this.p.length)this.p=e;else if(e.length){var t=new ae(this.p.length+e.length);t.set(this.p),t.set(e,this.p.length),this.p=t}},n.prototype.c=function(e){this.s.i=+(this.d=e||!1);var t=this.s.b,r=Es(this.p,this.s,this.o);this.ondata(Jr(r,t,this.s.b),this.d),this.o=Jr(r,this.s.b-32768),this.s.b=this.o.length,this.p=Jr(this.p,this.s.p/8|0),this.s.p&=7},n.prototype.push=function(e,t){this.e(e),this.c(t)},n})();ws=(function(){function n(e,t){this.v=1,this.r=0,vt.call(this,e,t)}return n.prototype.push=function(e,t){if(vt.prototype.e.call(this,e),this.r+=e.length,this.v){var r=this.p.subarray(this.v-1),i=r.length>3?Du(r):4;if(i>r.length){if(!t)return}else this.v>1&&this.onmember&&this.onmember(this.r-r.length);this.p=r.subarray(i),this.v=0}vt.prototype.c.call(this,0),this.s.f&&!this.s.l?(this.v=gs(this.s.p)+9,this.s={i:0},this.o=new ae(0),this.push(new ae(0),t)):t&&vt.prototype.c.call(this,t)},n})(),Ku=typeof TextDecoder<"u"&&new TextDecoder,Bu=0;try{Ku.decode(Mu,{stream:!0}),Bu=1}catch{}});var li={};qi(li,{extractZipEntry:()=>Zu,extractZipEntryBounded:()=>Yu,fetchZipCentralDirectory:()=>ju,parseZipCentralDirectory:()=>_r});function Ts(n){let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=Math.max(0,n.length-zs);for(let r=n.length-Wu;r>=t;r--)if(e.getUint32(r,!0)===$u)return r;throw new Error("Zip EOCD record not found")}function _r(n){let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=Ts(n),r=e.getUint16(t+10,!0),i=e.getUint32(t+16,!0),o=[],s=i;for(let a=0;a>8,O;E===Os?O=p>>16&65535:g.startsWith("bin/")||g.startsWith("sbin/")||g.includes("/bin/")||g.includes("/sbin/")?O=493:O=420;let S=g.endsWith("/"),w=E===Os&&(O&Hu)===Gu;o.push({fileName:g,fileNameBytes:y,compressedSize:l,uncompressedSize:d,compressionMethod:u,localHeaderOffset:_,mode:O,isDirectory:S,isSymlink:w,externalAttrs:p,creatorOS:E}),s+=ui+h+m+f}return o}function Rs(n,e){if(n.byteLength!==e.byteLength)return!1;for(let t=0;t{if(a.byteLength>t-o)throw new Error(`ZIP member ${e.fileName} expands beyond ${t} bytes`);i.set(a,o),o+=a.byteLength}).push(r,!0),o!==t)throw new Error(`ZIP member ${e.fileName} expanded ${o} bytes, expected ${t}`);return i}function Xu(n,e){let t=new DataView(n.buffer,n.byteOffset,n.byteLength),r=e.localHeaderOffset;if(r<0||r>n.byteLength-di||t.getUint32(r,!0)!==As)throw new Error(`Invalid local file header signature at offset ${r}`);let i=t.getUint16(r+8,!0),o=t.getUint16(r+26,!0),s=t.getUint16(r+28,!0),a=r+di,c=a+o+s,u=c+e.compressedSize;if(i!==e.compressionMethod||cn.byteLength||!Rs(n.subarray(a,a+o),e.fileNameBytes))throw new Error(`ZIP member ${e.fileName} has inconsistent local metadata`);return n.subarray(c,u)}async function ju(n){let e=await fetch(n,{method:"HEAD"});if(!e.ok)throw new Error(`HEAD request failed: ${e.status} ${e.statusText}`);let t=parseInt(e.headers.get("content-length")||"0",10),r=e.headers.get("accept-ranges");if(!t||r!=="bytes"){let y=await fetch(n);if(!y.ok)throw new Error(`Fetch failed: ${y.status} ${y.statusText}`);let g=new Uint8Array(await y.arrayBuffer());return{entries:_r(g),totalSize:g.length}}let i=Math.min(t,zs),o=t-i,s=await fetch(n,{headers:{Range:`bytes=${o}-${t-1}`}});if(s.status!==206){let y=await fetch(n);if(!y.ok)throw new Error(`Fetch failed: ${y.status} ${y.statusText}`);let g=new Uint8Array(await y.arrayBuffer());return{entries:_r(g),totalSize:g.length}}let a=new Uint8Array(await s.arrayBuffer()),c=new DataView(a.buffer,a.byteOffset,a.byteLength),u=Ts(a),l=c.getUint32(u+12,!0),d=c.getUint32(u+16,!0);if(d>=o){let y=t,g=new Uint8Array(y);return g.set(a,o),{entries:_r(g),totalSize:y}}let h=d+l-1,m=await fetch(n,{headers:{Range:`bytes=${d}-${h}`}});if(m.status!==206)throw new Error(`Range request for CD failed: ${m.status}`);let f=new Uint8Array(await m.arrayBuffer()),p=t,_=new Uint8Array(p);return _.set(f,d),_.set(a,o),{entries:_r(_),totalSize:p}}var $u,Uu,As,zs,Wu,ui,di,xs,Is,Os,Gu,Hu,Vu,qu,fi=br(()=>{"use strict";ci();Ve();$u=101010256,Uu=33639248,As=67324752,zs=65557,Wu=22,ui=46,di=30,xs=0,Is=8,Os=3,{S_IFLNK:Gu,S_IFMT:Hu}=ie,Vu=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),qu=new TextEncoder});var Ns={};qi(Ns,{DEFAULT_TAR_GZIP_LIMITS:()=>Fs,TarParseError:()=>b,parseTarGzip:()=>td});function td(n,e={}){let t=e.label??"TAR gzip archive",r=nd(e.limits,t);if(n.byteLength===0||n.byteLength>r.maxCompressedBytes)throw new b(`${t}: compressed byte count ${n.byteLength} is outside 1..${r.maxCompressedBytes}`);let i=id(n,t);if(i===0||i>r.maxUncompressedBytes)throw new b(`${t}: declared uncompressed byte count ${i} is outside 1..${r.maxUncompressedBytes}`);let o=od(n,t,i);if(o.byteLength!==i)throw new b(`${t}: gzip expanded to ${o.byteLength} bytes, expected ${i}`);let s=new DataView(n.buffer,n.byteOffset,n.byteLength).getUint32(n.byteLength-8,!0);if(sd(o)!==s)throw new b(`${t}: gzip CRC32 mismatch`);return rd(o,t,r)}function rd(n,e,t){if(n.byteLength%Ce!==0)throw new b(`${e}: TAR byte count is not block-aligned`);let r=[],i=0,o=0,s=0,a=null,c={},u=!1;for(;i+Ce<=n.byteLength;){let l=n.subarray(i,i+Ce);if(i+=Ce,hi(l)){if(i+Ce>n.byteLength)throw new b(`${e}: TAR end marker is truncated`);let S=n.subarray(i,i+Ce);if(!hi(S))throw new b(`${e}: TAR has only one zero end block`);if(i+=Ce,!hi(n.subarray(i)))throw new b(`${e}: TAR has nonzero data after its end marker`);u=!0;break}dd(l,e);let d=gr(l,156,1,e)||"0",h=yi(l,124,12,`${e}: TAR entry size`),m=yi(l,100,8,`${e}: TAR entry mode`)&Ju,f=ld(l,e,t.maxPathBytes),p=gr(l,157,100,e);if(d==="x"||d==="g"){if(s+=1,s>t.maxEntries+1)throw new b(`${e}: TAR extension header count exceeds ${t.maxEntries+1}`);let S=bs(n,i,h,e);i=vs(i,h,n.byteLength,e);let w=cd(S,e,t);d==="x"?a=w:c={...c,...w};continue}if(o+=1,o>t.maxEntries)throw new b(`${e}: TAR entry count exceeds ${t.maxEntries}`);let _={...c,...a??{}};a=null;let y=_.size===void 0?h:ud(_.size,`${e}: PAX entry size`),g=bs(n,i,y,e);i=vs(i,y,n.byteLength,e);let E=mi(_.path??f,e,t.maxPathBytes),O=_.linkpath??p;switch(d){case"0":case"\0":r.push({path:E,type:"file",mode:m,data:g});break;case"5":pi(y,e,"directory",E),r.push({path:E,type:"directory",mode:m});break;case"2":pi(y,e,"symlink",E),Ps(O,e,E,t.maxLinkBytes,!1),r.push({path:E,type:"symlink",mode:m,linkName:O});break;case"1":pi(y,e,"hardlink",E),Ps(O,e,E,t.maxLinkBytes,!0),r.push({path:E,type:"hardlink",mode:m,linkName:mi(O,`${e}: hardlink target`,t.maxPathBytes)});break;case"3":case"4":case"6":throw new b(`${e}: unsupported TAR device/FIFO entry ${E}`);default:throw new b(`${e}: unsupported TAR entry type ${JSON.stringify(d)} for ${E}`)}}if(!u)throw new b(`${e}: TAR is missing its two-block end marker`);if(a!==null)throw new b(`${e}: local PAX header has no following entry`);return r}function nd(n,e){let t={...Fs,...n};for(let[r,i]of Object.entries(t))if(!Number.isSafeInteger(i)||i<=0)throw new b(`${e}: ${r} must be a positive safe integer`);return t}function id(n,e){if(n.byteLength<18||n[0]!==31||n[1]!==139||n[2]!==8)throw new b(`${e}: invalid gzip header`);return new DataView(n.buffer,n.byteOffset,n.byteLength).getUint32(n.byteLength-4,!0)}function od(n,e,t){let r=new Uint8Array(t),i=0,o=!1,s=new ws(a=>{if(a.byteLength>t-i)throw new b(`${e}: gzip expansion exceeds its declared ${t} bytes`);r.set(a,i),i+=a.byteLength});s.onmember=()=>{throw o=!0,new b(`${e}: concatenated gzip members are unsupported`)};try{s.push(n,!0)}catch(a){throw a instanceof b?a:new b(`${e}: cannot gunzip archive: ${pd(a)}`)}if(o)throw new b(`${e}: concatenated gzip members are unsupported`);return r.subarray(0,i)}function sd(n){let e=4294967295;for(let t of n)e=ed[(e^t)&255]^e>>>8;return(e^4294967295)>>>0}function ad(){let n=new Uint32Array(256);for(let e=0;e>>1^((t&1)===0?0:3988292384);n[e]=t>>>0}return n}function bs(n,e,t,r){if(t>n.byteLength-e)throw new b(`${r}: TAR entry is truncated`);return n.subarray(e,e+t)}function vs(n,e,t,r){let o=Math.ceil(e/Ce)*Ce;if(!Number.isSafeInteger(o)||o>t-n)throw new b(`${r}: TAR entry padding is truncated`);return n+o}function cd(n,e,t){let r={},i=0;for(;i9)throw new b(`${e}: invalid PAX record length`);if(s=s*10+p,!Number.isSafeInteger(s))throw new b(`${e}: invalid PAX record length`)}let a=i+s;if(s<=o-i+2||a>n.byteLength||n[a-1]!==10)throw new b(`${e}: truncated PAX record`);let c=o+1;for(;c=a-1)throw new b(`${e}: invalid PAX record`);let u=n.subarray(o+1,c);if(u.byteLength>256)throw new b(`${e}: PAX record key is too long`);let l=_i(u,`${e}: PAX record key`),d=n.subarray(c+1,a-1),h=l==="path"?t.maxPathBytes:l==="linkpath"?t.maxLinkBytes:l==="size"?32:0;if(h===0){i=a;continue}if(d.byteLength>h)throw new b(`${e}: PAX ${l} value is too long`);let m=_i(d,`${e}: PAX record value`);r[l]=m,i=a}return r}function ud(n,e){if(!/^(0|[1-9][0-9]*)$/.test(n))throw new b(`${e} is invalid`);let t=Number(n);if(!Number.isSafeInteger(t)||t<0)throw new b(`${e} is invalid`);return t}function dd(n,e){let t=yi(n,148,8,`${e}: TAR checksum`),r=0;for(let i=0;i=148&&i<156?32:n[i];if(t!==r)throw new b(`${e}: TAR checksum mismatch`)}function ld(n,e,t){let r=gr(n,0,100,e),i=gr(n,345,155,e);return mi(i?`${i}/${r}`:r,e,t)}function mi(n,e,t){let r=n;for(;r.startsWith("./");)r=r.slice(2);return r=r.replace(/\/+$/g,""),fd(r,`${e}: TAR path`,t),r}function gr(n,e,t,r){let i=e,o=e+t;for(;ir||n.includes("\0"))throw new b(`${e}: link target for ${t} is invalid`);if(i&&n.includes("\\"))throw new b(`${e}: hardlink target for ${t} is invalid`)}function fd(n,e,t){if(n.length===0||n.startsWith("/")||n.includes("\0")||n.includes("\\")||ks.encode(n).byteLength>t)throw new b(`${e} ${JSON.stringify(n)} must be a bounded relative POSIX path`);for(let r of n.split("/"))if(r.length===0||r==="."||r==="..")throw new b(`${e} ${JSON.stringify(n)} contains an unsafe path segment`)}function hi(n){for(let e of n)if(e!==0)return!1;return!0}function _i(n,e){try{return Qu.decode(n)}catch{throw new b(`${e} contains non-UTF-8 text`)}}function pd(n){return n instanceof Error?n.message:String(n)}var Ce,Ju,Ls,Qu,ks,ed,Fs,b,Cs=br(()=>{"use strict";ci();Ve();Ce=512,Ju=ie.S_MODE_BITS,Ls=1024*1024,Qu=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),ks=new TextEncoder,ed=ad(),Fs=Object.freeze({maxCompressedBytes:256*Ls,maxUncompressedBytes:512*Ls,maxEntries:1e5,maxPathBytes:4096,maxLinkBytes:65536}),b=class extends Error{constructor(e){super(e),this.name="TarParseError"}}});import{existsSync as xr,lstatSync as yn,readdirSync as Oa,readFileSync as pt,realpathSync as Le,statSync as rt}from"node:fs";import{createHash as Aa}from"node:crypto";import{spawnSync as Di}from"node:child_process";import{basename as Sl,dirname as Tr,isAbsolute as _n,join as U,relative as wl,resolve as Re,sep as Ol}from"node:path";import{fileURLToPath as Al}from"node:url";Ve();var Ca=Uint8Array.from(co);function T(n,e){let t=0,r=0,i=e;for(;;){let o=n[i++];if(t|=(o&127)<=n.length)throw new Error(`${r} is truncated`);if((n[e++]&128)===0)return e}throw new Error(`${r} has an overlong LEB128 encoding`)}function xe(n,e,t){if(e>=n.length)throw new Error(`${t} is truncated`);let r=n[e++];switch(r){case 127:case 126:case 125:case 124:case 123:case 117:case 116:case 115:case 114:case 113:case 112:case 111:case 110:case 109:case 108:case 107:case 106:case 105:case 104:return{code:r,shared:!1,next:e};case 98:case 99:case 100:{let i=n[e]===101;i&&e++;let o=vo(n,e,5,`${t} heap type`),[s]=bo(n,e);return{code:r,heapType:Number(s),shared:i,next:o}}default:throw new Error(`${t} has unknown value type 0x${r.toString(16)}`)}}function Ma(n,e,t){return n[e]===120||n[e]===119?{code:n[e],shared:!1,next:e+1}:xe(n,e,t)}function Da(n,e){let t=n[e];if(t===64||t===127||t===126||t===125||t===124||t===123||t===112||t===111)return e+1;let[,r]=Fn(n,e);return e+r}function Ka(n,e,t){let[r,i]=T(n,e);e+=i;let o=[],s=[];for(let d=0;d=n.length)throw new Error(`${t} mutability is truncated`);let i=n[e++];if(i!==0&&i!==1)throw new Error(`${t} has invalid mutability ${i}`);return e}function Ba(n,e,t,r){if(e===101){if(t>=n.length)throw new Error(`${r} shared type is truncated`);e=n[t++]}for(let i of[76,77]){if(e!==i)continue;let[,o]=T(n,t);if(t+=o,t>=n.length)throw new Error(`${r} descriptor is truncated`);e=n[t++]}if(e===96)return Ka(n,t,r);if(e===95){let[i,o]=T(n,t);t+=o;for(let s=0;s=n.length)throw new Error(`${t} is truncated`);let r=n[e++];if(r===79||r===80){let[i,o]=T(n,e);e+=o;for(let s=0;s=n.length)throw new Error(`${t} body is truncated`);r=n[e++]}return Ba(n,r,e,t)}function $a(n,e){let[t,r]=T(n,e);e+=r;let i=[];for(let o=0;o=21&&r<=34?Xt(e,t):r===84||r>=92&&r<=99||r>=112&&r<=123||r>=124&&r<=131||r>=156&&r<=159?t+1:t:n===254?r===0||r===1||r===2?Xt(e,t):r===3?t:r>=16&&r<=79?Xt(e,t):null:null}function Wa(n,e,t){let[r,i]=T(n,e);e+=i+r;let[o,s]=T(n,e);e+=s+o;let a=n[e++];if(a===0){t.funcImports++;let[,c]=T(n,e);e+=c}else if(a===1)e=xe(n,e,"table import type").next,e=Ze(n,e).next;else if(a===2)e=Ze(n,e).next;else if(a===3)t.globalImports++,e=xe(n,e,"global import type").next,e++;else if(a===4){e++;let[,c]=T(n,e);e+=c}return e}function $r(n){return n.length>=8&&n[0]===0&&n[1]===97&&n[2]===115&&n[3]===109}function qe(n,e){let[t,r]=T(n,e);return e+=r,[new TextDecoder().decode(n.subarray(e,e+t)),e+t]}function Ga(n,e){if(e.length===0)return!0;let t=new TextEncoder().encode(e);e:for(let r=0;r<=n.length-t.length;r++){for(let i=0;in);function To(n,e){switch(n.code){case 127:return Sn;case 126:return wn;case 125:return On;case 124:return An;case 123:return zn;case 112:case 115:return gt;case 111:case 114:return Ut;case 105:case 116:return Wt;case 104:case 106:case 107:case 108:case 109:case 110:case 113:case 117:return Gt;case 98:case 99:case 100:{let t=n.heapType;return t===void 0?null:t===-16||t===-13?gt:t===-17||t===-14?Ut:t===-23||t===-12?Wt:t>=0&&e[t]!==void 0?gt:Gt}default:return null}}function vn(n,e,t){if(!t)throw new Error(`function ${e} refers to an unknown type`);let r=n.get(e)??[];r.push(t),n.set(e,r)}function Yt(n,e,t){let r=n.get(e)??[];r.push(t),n.set(e,r)}function Ze(n,e){let[t,r]=T(n,e);e+=r;let[i,o]=T(n,e);e+=o;let s=null;if((t&1)!==0){let[a,c]=T(n,e);e+=c,s=a}return{flags:t,minimum:i,maximum:s,next:e}}function Va(n){let e=new Uint8Array(n);if(!$r(e))throw new Error("not a wasm binary");let t=[],r=[],i=[],o={functionTypes:t,functionTypeIndices:r,functionImports:new Map,functionImportEntries:[],globalImports:new Map,tableImports:new Map,tables:[],tagImports:new Map,functionExports:new Map,globalExports:new Map,tableExports:new Map,exports:new Map,memoryPointerWidths:[],forkCapabilities:[],linkedFrameDescriptors:[],exceptionCodecDescriptors:[],importedGlobalsDescriptors:[],importedTablesDescriptors:[],moduleStateDescriptors:[],staticRootDescriptors:[],unwindTransportDescriptors:[],nativeStartCount:0,importsKernelFork:!1},s=0,a=0,c=8;for(;ce.length)throw new Error("wasm section exceeds file size");let f=h,p=!1;if(u===0){let[_,y]=qe(e,f);_===Bt?o.linkedFrameDescriptors.push(e.slice(y,m)):_===Pe?o.forkCapabilities.push(e.slice(y,m)):_===ze?o.exceptionCodecDescriptors.push(e.slice(y,m)):_===Y?o.importedGlobalsDescriptors.push(e.slice(y,m)):_===X?o.importedTablesDescriptors.push(e.slice(y,m)):_===ne?o.moduleStateDescriptors.push(e.slice(y,m)):_===Ge?o.staticRootDescriptors.push(e.slice(y,m)):_===Et&&o.unwindTransportDescriptors.push(e.slice(y,m))}else if(u===1){p=!0;let _=$a(e,f);t.push(..._.types),f=_.next}else if(u===2){p=!0;let[_,y]=T(e,f);f+=y;for(let g=0;g<_;g++){let[E,O]=qe(e,f),[S,w]=qe(e,O);f=w;let z=e[f++];if(z===0){let[x,I]=T(e,f);f+=I;let R=r.length;r.push(x);let L=`${E}.${S}`,v=t[x];vn(o.functionImports,L,v),o.functionImportEntries.push({module:E,name:S,importOrdinal:g,functionIndex:R,signature:v}),L==="kernel.kernel_fork"&&(o.importsKernelFork=!0)}else if(z===1){let x=xe(e,f,`table import ${E}.${S}`);f=x.next;let I=Ze(e,f);f=I.next,Yt(o.tableImports,`${E}.${S}`,{module:E,name:S,importOrdinal:g,index:a++,elementType:x.code,recipeTypeCode:To(x,t),table64:(I.flags&4)!==0,minimum:I.minimum,maximum:I.maximum}),o.tables.push({elementType:x.code,table64:(I.flags&4)!==0,minimum:I.minimum,maximum:I.maximum})}else if(z===2){let x=Ze(e,f);f=x.next,o.memoryPointerWidths.push((x.flags&4)!==0?8:4)}else if(z===3){let x=xe(e,f,`global import ${E}.${S}`);if(f=x.next,f>=e.length)throw new Error(`global import ${E}.${S} is truncated`);let I=e[f++];if((I&-4)!==0)throw new Error(`global import ${E}.${S} has invalid flags ${I}`);Yt(o.globalImports,`${E}.${S}`,{module:E,name:S,importOrdinal:g,index:s++,valueType:x.code,recipeTypeCode:To(x,t),mutable:(I&1)!==0,shared:(I&2)!==0})}else if(z===4){let x=e[f++];if(x!==0)throw new Error(`unsupported wasm tag attribute ${x}`);let[I,R]=T(e,f);f+=R,vn(o.tagImports,`${E}.${S}`,t[I])}else throw new Error(`unsupported wasm import kind ${z}`)}}else if(u===3){p=!0;let[_,y]=T(e,f);f+=y;for(let g=0;g<_;g++){let[E,O]=T(e,f);f+=O,r.push(E)}}else if(u===4){p=!0;let[_,y]=T(e,f);f+=y;for(let g=0;g<_;g++){let E=xe(e,f,`defined table ${g}`);f=E.next;let O=Ze(e,f);f=O.next,o.tables.push({elementType:E.code,table64:(O.flags&4)!==0,minimum:O.minimum,maximum:O.maximum})}}else if(u===5){p=!0;let[_,y]=T(e,f);f+=y;for(let g=0;g<_;g++){let E=Ze(e,f);f=E.next,o.memoryPointerWidths.push((E.flags&4)!==0?8:4)}}else if(u===7){p=!0;let[_,y]=T(e,f);f+=y;for(let g=0;g<_;g++){let[E,O]=qe(e,f);f=O;let S=e[f++],[w,z]=T(e,f);f+=z,Yt(o.exports,E,{kind:S,index:w}),S===0?i.push({name:E,index:w}):S===3?Yt(o.globalExports,E,w):S===1&&Yt(o.tableExports,E,w)}}else if(u===8){p=!0,o.nativeStartCount++;let[,_]=T(e,f);f+=_}if(p&&f!==m)throw new Error(`malformed wasm section ${u}`);c=m}for(let{name:u,index:l}of i){let d=r[l];vn(o.functionExports,u,t[d])}return o}function qa(n){if(n.byteLength!==$t)throw new Error(`linked-frame descriptor has ${n.byteLength} bytes, expected ${$t}`);if(!Zi.every((a,c)=>n[c]===a))throw new Error("linked-frame descriptor has invalid magic");let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=e.getUint16(4,!0);if(t!==1)throw new Error(`linked-frame descriptor version ${t} is unsupported`);let r=e.getUint16(6,!0);if(r!==$t)throw new Error(`linked-frame descriptor declares size ${r}, expected ${$t}`);let i=e.getUint8(8),o=Xi.find(({bytes:a})=>a===i);if(!o)throw new Error(`linked-frame descriptor pointer width ${i} is unsupported`);if(e.getUint8(9)!==Yi)throw new Error(`linked-frame descriptor alignment ${e.getUint8(9)} is unsupported`);let s=e.getUint16(10,!0);if(s!==gn)throw new Error(`linked-frame descriptor flags 0x${s.toString(16)} do not equal required flags 0x${gn.toString(16)}`);if(e.getUint32(12,!0)!==o.chunkHeaderSize||e.getUint32(16,!0)!==o.nodeHeaderSize)throw new Error(`linked-frame descriptor header sizes do not match its ${i}-byte pointer width`);return o.bytes}function Za(n){if(n.length===0)return[`missing required ${Pe} capability`];if(n.length!==1)return[`has ${n.length} ${Pe} sections, expected exactly one`];let e=n[0];if(e.byteLength!==2)return[`${Pe} has ${e.byteLength} bytes, expected 2`];if(e[0]!==io)return[`${Pe} version ${e[0]} is unsupported`];let t=e[1];return(t&~oo)!==0?[`${Pe} has unknown flags 0x${t.toString(16)}`]:(t&Fr)!==Fr?[`${Pe} flags 0x${t.toString(16)} omit required activation-state safety flags 0x${Fr.toString(16)}`]:[]}function Ya(n){let e=[],t=`${Cr}.${Mr}`,r=n.tagImports.get(t);if(r?r.length!==1?e.push(`duplicate private fork-unwind tag import ${t}`):(r[0].params.length!==0||r[0].results.length!==0)&&e.push(`private fork-unwind tag ${t} must have an empty payload`):e.push(`missing required private fork-unwind tag import ${t}`),n.unwindTransportDescriptors.length===0)e.push(`missing required ${Et} descriptor`);else if(n.unwindTransportDescriptors.length!==1)e.push(`has ${n.unwindTransportDescriptors.length} ${Et} descriptors, expected exactly one`);else{let i=n.unwindTransportDescriptors[0];(i.length!==2||i[0]!==In||i[1]!==Tn)&&e.push(`${Et} must be [${In}, ${Tn}]`)}return e}function Xa(n,e){if(n.length===0)return[`missing required ${ne} descriptor`];if(n.length!==1)return[`has ${n.length} ${ne} descriptors, expected exactly one`];let t=n[0];if(t.byteLength!==vr)return[`${ne} has ${t.byteLength} bytes, expected ${vr}`];if(!Ji.every((p,_)=>t[_]===p))return[`${ne} has invalid magic`];let r=new DataView(t.buffer,t.byteOffset,t.byteLength),i=r.getUint16(4,!0),o=r.getUint16(6,!0),s=r.getUint8(8),a=no.find(({bytes:p})=>p===s),c=r.getUint8(9),u=r.getUint16(10,!0),l=r.getUint16(12,!0),d=r.getUint16(14,!0),h=r.getUint32(16,!0),m=r.getUint32(20,!0),f=[];return i!==ji&&f.push(`${ne} version ${i} is unsupported`),o!==vr&&f.push(`${ne} declares size ${o}`),a?e!==null&&s!==e&&f.push(`${ne} pointer width ${s} does not match linked frames ${e}`):f.push(`${ne} pointer width ${s} is unsupported`),c!==Qi&&f.push(`${ne} alignment ${c} is unsupported`),u!==En&&f.push(`${ne} flags 0x${u.toString(16)} do not equal required flags 0x${En.toString(16)}`),l!==eo&&f.push(`${ne} arena version ${l} is unsupported`),d!==to&&f.push(`${ne} record version ${d} is unsupported`),h!==ro&&f.push(`${ne} root word ${h} is unsupported`),m!==0&&f.push(`${ne} reserved field is nonzero`),f}function ja(n){if(n.length===0)return[`missing required ${ze} descriptor`];if(n.length!==1)return[`has ${n.length} ${ze} descriptors, expected exactly one`];let e=n[0];if(e.byteLength2147483647||s.has(l))&&r.push(`${ze} layout id ${l} is invalid or duplicated`),s.add(l)}return r}var Ja=new Set([Sn,wn,On,An,zn,gt,Ut,Wt,Gt]);function Ro(n){return!(n.module===Rn&&(n.name===Ln||n.name==="__channel_base"||n.name==="__wpk_fork_module_state_table_generation_addr"))}function Qa(n){let e=n.importedGlobalsDescriptors;if(e.length===0)return[`missing required ${Y} descriptor`];if(e.length!==1)return[`has ${e.length} ${Y} descriptors, expected exactly one`];let t=e[0];if(t.byteLengtht[_]===p)||i.push(`${Y} has invalid magic`),r.getUint16(4,!0)!==lo&&i.push(`${Y} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Kr&&i.push(`${Y} declares an invalid header size`);let o=r.getUint32(8,!0);r.getUint32(12,!0)!==0&&i.push(`${Y} reserved field is nonzero`);let s=new Set,a=new Set,c=new TextDecoder("utf-8",{fatal:!0}),u=[],l=-1,d=Kr;for(let p=0;pt.byteLength)return i.push(`${Y} record ${p} header is truncated`),i;let _=r.getUint32(d,!0),y=r.getUint32(d+4,!0),g=r.getUint8(d+8),E=r.getUint8(d+9),O=r.getUint32(d+12,!0),S=r.getUint32(d+16,!0),w=r.getUint32(d+20,!0),z=Vt+O+S;if(!Number.isSafeInteger(z)||_!==z||_t.byteLength)return i.push(`${Y} record ${p} has invalid bounds`),i;(y===0||s.has(y))&&i.push(`${Y} record ${p} has invalid or duplicated owner ${y}`),s.add(y),Ja.has(g)||i.push(`${Y} record ${p} has unknown value type ${g}`),(E&~ho)!==0&&i.push(`${Y} record ${p} has unknown flags 0x${E.toString(16)}`),r.getUint16(d+10,!0)!==0&&i.push(`${Y} record ${p} reserved fields are nonzero`),(a.has(w)||w<=l)&&i.push(`${Y} record ${p} has duplicated or unordered import ordinal`),a.add(w),l=w;let x=d+Vt;try{let I=c.decode(t.subarray(x,x+O)),R=c.decode(t.subarray(x+O,x+O+S));u.push({ownerId:y,typeCode:g,flags:E,importOrdinal:w,module:I,name:R})}catch{i.push(`${Y} record ${p} contains invalid UTF-8`)}d+=_}d!==t.byteLength&&i.push(`${Y} has trailing bytes`);let h=[...n.globalImports.values()].flat(),m=new Map(h.map(p=>[p.index,p])),f=new Set;for(let p of u){let _=`${Pr}${p.ownerId}`,y=n.exports.get(_);if(!y||y.length!==1||y[0].kind!==3){i.push(`${Y} owner ${p.ownerId} lacks exactly one global catalog export ${_}`);continue}let g=m.get(y[0].index);if(!g||!Ro(g)){i.push(`${Y} owner ${p.ownerId} does not identify a reconstructible imported global`);continue}if(g.module!==p.module||g.name!==p.name||g.importOrdinal!==p.importOrdinal||g.recipeTypeCode!==p.typeCode||g.mutable!==((p.flags&fo)!==0)||g.shared!==((p.flags&po)!==0)){i.push(`${Y} owner ${p.ownerId} does not match its imported global declaration`);continue}if(f.has(g.index)){i.push(`${Y} repeats imported global index ${g.index}`);continue}f.add(g.index)}for(let p of h)Ro(p)&&!f.has(p.index)&&i.push(`${Y} omits imported global ${p.module}.${p.name} at index ${p.index}`);for(let[p,_]of n.exports){if(!p.startsWith(Pr))continue;let y=p.slice(Pr.length),g=Number(y);(!/^[1-9][0-9]*$/.test(y)||!Number.isSafeInteger(g)||g>4294967295||_.length!==1||_[0].kind!==3)&&i.push(`malformed reserved fork global catalog export ${p}`)}return i}var ec=new Set([gt,Ut,Wt,Gt]);function Lo(n){return!bn.some(({module:e,name:t})=>n.module===e&&n.name===t)}function tc(n){let e=n.importedTablesDescriptors;if(e.length===0)return[`missing required ${X} descriptor`];if(e.length!==1)return[`has ${e.length} ${X} descriptors, expected exactly one`];let t=e[0];if(t.byteLengtht[_]===p)||i.push(`${X} has invalid magic`),r.getUint16(4,!0)!==yo&&i.push(`${X} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Br&&i.push(`${X} declares an invalid header size`);let o=r.getUint32(8,!0);r.getUint32(12,!0)!==0&&i.push(`${X} reserved field is nonzero`);let s=new Set,a=new Set,c=new TextDecoder("utf-8",{fatal:!0}),u=[],l=-1,d=Br;for(let p=0;pt.byteLength)return i.push(`${X} record ${p} header is truncated`),i;let _=r.getUint32(d,!0),y=r.getUint32(d+4,!0),g=r.getUint8(d+8),E=r.getUint8(d+9),O=r.getUint32(d+12,!0),S=r.getUint32(d+16,!0),w=r.getUint32(d+20,!0),z=qt+O+S;if(!Number.isSafeInteger(z)||_!==z||_t.byteLength)return i.push(`${X} record ${p} has invalid bounds`),i;(y===0||s.has(y))&&i.push(`${X} record ${p} has invalid or duplicated owner ${y}`),s.add(y),ec.has(g)||i.push(`${X} record ${p} has unknown element type ${g}`),(E&~go)!==0&&i.push(`${X} record ${p} has unknown flags 0x${E.toString(16)}`),r.getUint16(d+10,!0)!==0&&i.push(`${X} record ${p} reserved fields are nonzero`),(a.has(w)||w<=l)&&i.push(`${X} record ${p} has duplicated or unordered import ordinal`),a.add(w),l=w;let x=d+qt;try{let I=c.decode(t.subarray(x,x+O)),R=c.decode(t.subarray(x+O,x+O+S));u.push({ownerId:y,typeCode:g,flags:E,importOrdinal:w,module:I,name:R})}catch{i.push(`${X} record ${p} contains invalid UTF-8`)}d+=_}d!==t.byteLength&&i.push(`${X} has trailing bytes`);let h=[...n.tableImports.values()].flat(),m=new Map(h.map(p=>[p.index,p])),f=new Set;for(let p of u){let _=`${kr}${p.ownerId}`,y=n.exports.get(_);if(!y||y.length!==1||y[0].kind!==1){i.push(`${X} owner ${p.ownerId} lacks exactly one table catalog export ${_}`);continue}let g=m.get(y[0].index);if(!g||!Lo(g)){i.push(`${X} owner ${p.ownerId} does not identify a reconstructible imported table`);continue}if(g.module!==p.module||g.name!==p.name||g.importOrdinal!==p.importOrdinal||g.recipeTypeCode!==p.typeCode||g.table64!==((p.flags&_o)!==0)){i.push(`${X} owner ${p.ownerId} does not match its imported table declaration`);continue}if(f.has(g.index)){i.push(`${X} repeats imported table index ${g.index}`);continue}f.add(g.index)}for(let p of h)Lo(p)&&!f.has(p.index)&&i.push(`${X} omits imported table ${p.module}.${p.name} at index ${p.index}`);for(let[p,_]of n.exports){if(!p.startsWith(kr))continue;let y=p.slice(kr.length),g=Number(y);(!/^[1-9][0-9]*$/.test(y)||!Number.isSafeInteger(g)||g>4294967295||_.length!==1||_[0].kind!==1)&&i.push(`malformed reserved fork table catalog export ${p}`)}return i}function Nn(n,e){switch(n){case"ptr":return e===8?126:127;case"i32":return 127;case"i64":return 126;case"anyref":return 110;case"exnref":return 105;case"externref":return 111;case"funcref":return 112}}function Pn(n,e,t,r){return n.params.length===e.length&&n.results.length===t.length&&n.params.every((i,o)=>i===Nn(e[o],r))&&n.results.every((i,o)=>i===Nn(t[o],r))}function kn(n,e,t){let r=i=>i==="ptr"?t===8?"i64":"i32":i;return`(${n.map(r).join(", ")}) -> (${e.map(r).join(", ")})`}function rc(n){let e=`${Rn}.${Ln}`,t=n.globalImports.get(e);return t?t.length!==1?[`duplicate exception-codec activation import ${e}`]:t[0].valueType!==127||t[0].mutable?[`exception-codec activation import ${e} must be immutable i32`]:[]:[`missing required immutable exception-codec activation import ${e}`]}function nc(n){let e=[];for(let t of bn){let r=`${t.module}.${t.name}`,i=n.tableImports.get(r);if(!i){e.push(`missing required ABI 43 fork-runtime table import ${r}`);continue}if(i.length!==1){e.push(`duplicate ABI 43 fork-runtime table import ${r}`);continue}let o=i[0],s=Nn(t.element,4);(o.elementType!==s||o.table64!==t.table64||o.minimum!==t.minimum||o.maximum!==t.maximum)&&e.push(`ABI 43 fork-runtime table import ${r} has the wrong type or limits`)}return e}function ic(n){if(n.staticRootDescriptors.length===0)return[`missing required ${Ge} descriptor`];if(n.staticRootDescriptors.length!==1)return[`has ${n.staticRootDescriptors.length} ${Ge} descriptors, expected exactly one`];let e=n.staticRootDescriptors[0];if(e.byteLength!==Dr)return[`${Ge} has ${e.byteLength} bytes, expected ${Dr}`];let t=[];Ca.some((u,l)=>e[l]!==u)&&t.push(`${Ge} has invalid magic`);let r=new DataView(e.buffer,e.byteOffset,e.byteLength);r.getUint16(4,!0)!==ao&&t.push(`${Ge} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Dr&&t.push(`${Ge} declares an invalid header size`);let i=r.getUint32(8,!0),o=n.tableExports.get(Ht);if(!o||o.length!==1)return t.push(`missing exactly one table export ${Ht}`),t;let s=[...n.tableImports.values()].reduce((u,l)=>u+l.length,0),a=o[0],c=n.tables[a];return a!n.functionExports.has(c)).map(({name:c})=>c);if(t.length>0&&e.push(`incomplete wasm-fork-instrument exports; missing ${t.join(", ")}`),n.importsKernelFork){let c=`${ot.module}.${ot.name}`,u=n.functionImports.get(c);u?.length!==1?e.push(`duplicate ABI 43 process-fork import ${c}`):Pn(u[0],ot.params,ot.results,4)||e.push(`ABI 43 process-fork import ${c} has the wrong signature; expected ${kn(ot.params,ot.results,4)}`)}let r=null;if(n.linkedFrameDescriptors.length===0)e.push(`missing required ${Bt} descriptor`);else if(n.linkedFrameDescriptors.length!==1)e.push(`has ${n.linkedFrameDescriptors.length} ${Bt} descriptors, expected exactly one`);else try{r=qa(n.linkedFrameDescriptors[0])}catch(c){e.push(c instanceof Error?c.message:String(c))}e.push(...Xa(n.moduleStateDescriptors,r));let i=St.filter(({module:c,name:u})=>n.functionImports.has(`${c}.${u}`)),o=`${Cr}.${Mr}`,s=n.importsKernelFork||i.length>0;if((s||n.tagImports.has(o)||n.unwindTransportDescriptors.length>0)&&e.push(...Ya(n)),s){let c=St.filter(({module:u,name:l})=>!n.functionImports.has(`${u}.${l}`)).map(({module:u,name:l})=>`${u}.${l}`);c.length>0&&e.push(`incomplete ABI 43 fork-runtime imports; missing ${c.join(", ")}`);for(let u of St){let l=`${u.module}.${u.name}`,d=n.functionImports.get(l);d&&d.length!==1&&e.push(`duplicate ABI 43 fork-runtime import ${l}`)}}if(r!==null){if(n.memoryPointerWidths.length!==1)e.push(`ABI 43 fork instrumentation requires exactly one module memory, found ${n.memoryPointerWidths.length}`);else if(n.memoryPointerWidths[0]!==r){let c=r===8?"an":"a";e.push(`ABI 43 linked-frame descriptor declares ${c} ${r}-byte pointer but the module memory uses ${n.memoryPointerWidths[0]}-byte addresses`)}for(let c of Zt){let u=n.functionExports.get(c.name);u?.length===1&&!Pn(u[0],c.params,c.results,r)&&e.push(`ABI 43 wasm-fork-instrument export ${c.name} has the wrong signature; expected ${kn(c.params,c.results,r)}`)}if(s)for(let c of St){let u=`${c.module}.${c.name}`,l=n.functionImports.get(u);l?.length===1&&!Pn(l[0],c.params,c.results,r)&&e.push(`ABI 43 fork-runtime import ${u} has the wrong signature; expected ${kn(c.params,c.results,r)}`)}}return e}function Po(n,e){switch(n){case 0:return"function";case 1:return"table";case 2:return"memory";case 3:return"global";case 4:return"tag";default:throw new Error(`${e} has unsupported external kind ${n}`)}}function sc(n){let e=new Uint8Array(n);if(!$r(e))return[];let t=[],r=8;for(;r`${e}.${t}`)}function cc(n){let e=new Uint8Array(n);if(!$r(e))return[];let t=[],r=8;for(;re)}function ko(n){let e=new Uint8Array(n);if(!$r(e))return[];let t=[],r=8;for(;rt.startsWith("reloc."))}function Fo(n,e={}){let t=[],r=null;dc(n)&&t.push("contains asyncify_"),e.expectedAbi!==void 0&&e.expectedAbi!==null&&(r=pc(n),r!==null&&r!==e.expectedAbi&&t.push(`ABI ${r}, expected ${e.expectedAbi}`));let i=new Set(uc(n));if(e.requiredExports){let E=e.requiredExports.filter(O=>!i.has(O));E.length>0&&t.push(`missing required exports: ${E.join(", ")}`)}if(e.forbiddenExports){let E=e.forbiddenExports.filter(O=>i.has(O));E.length>0&&t.push(`forbidden exports present: ${E.join(", ")}`)}let o=Ha.filter(E=>i.has(E)),s=ac(n),a=ko(n),c=St.filter(({module:E,name:O})=>s.includes(`${E}.${O}`)),u=a.filter(E=>E===Bt).length,l=a.filter(E=>E===Pe).length,d=a.filter(E=>E===ne).length,h=a.filter(E=>E===ze).length,m=a.filter(E=>E===Y).length,f=a.filter(E=>E===X).length,p=a.filter(E=>E===Et).length,_=s.includes(`${Cr}.${Mr}`),y=o.length>0||c.length>0||u>0||l>0||d>0||h>0||m>0||f>0||p>0||_;if(e.expectedAbi!==void 0&&e.expectedAbi!==null&&y&&r===null&&t.push(`ABI ${e.expectedAbi} fork artifact is missing __abi_version; the activation-state capability epoch cannot be verified`),e.forbidForkInstrumentation&&y&&t.push("contains ABI 43 wasm-fork-instrument metadata, imports, or exports"),(e.requireForkInstrumentation??!lc(n))&&(y||s.includes("kernel.kernel_fork")))try{t.push(...oc(Va(n)))}catch(E){t.push(`cannot validate ABI 43 fork-artifact contract: ${E instanceof Error?E.message:String(E)}`)}return t}function fc(n,e){let t=new Uint8Array(n);if(t.length<8)return null;let r=0,i=null,o=null,s=8;for(;s=c)return null;let p=a;for(let g=0;g=f)return null;let[p,_]=T(t,m);m+=_;for(let y=0;yf)return null}return m}function h(m,f=0){if(f>4)return null;let p=l(m);if(!p)return null;let _=d(p.start,p.end);if(_===null)return null;let y=_,g=p.end;for(;y=32&&E<=38||E===208){let[,O]=T(t,y);y+=O}else if(E>=40&&E<=62)y=Xt(t,y);else if(E===63||E===64)y++;else if(E===66){let[,O]=bo(t,y);y+=O}else if(E===67)y+=4;else if(E===68)y+=8;else if(E===252||E===253||E===254){let O=Ua(E,t,y);if(O===null)return null;y=O}}return null}return h(i)}function pc(n){return fc(n,"__abi_version")}Ve();var hc=ArrayBuffer,J=Uint8Array,Ur=Uint16Array,mc=Int16Array;var Wr=Int32Array,Cn=function(n,e,t){if(J.prototype.slice)return J.prototype.slice.call(n,e,t);(e==null||e<0)&&(e=0),(t==null||t>n.length)&&(t=n.length);var r=new J(t-e);return r.set(n.subarray(e,t)),r},Jt=function(n,e,t,r){if(J.prototype.fill)return J.prototype.fill.call(n,e,t,r);for((t==null||t<0)&&(t=0),(r==null||r>n.length)&&(r=n.length);tn.length)&&(r=n.length);t2046MB)","invalid block type","FSE accuracy too high","match distance too far back","unexpected EOF"],Q=function(n,e,t){var r=new Error(e||_c[n]);if(r.code=n,Error.captureStackTrace&&Error.captureStackTrace(r,Q),!t)throw r;return r},No=function(n,e,t){for(var r=0,i=0;r>>0},Ec=function(n,e){var t=n[0]|n[1]<<8|n[2]<<16;if(t==3126568&&n[3]==253){var r=n[4],i=r>>5&1,o=r>>2&1,s=r&3,a=r>>6;r&8&&Q(0);var c=6-i,u=s==3?4:s,l=No(n,c,u);c+=u;var d=a?1<>3);m=f+(f>>3)*(n[5]&7)}m>2145386496&&Q(1);var p=new J((e==1?h||m:e?0:m)+12);return p[0]=1,p[4]=4,p[8]=8,{b:c+d,y:0,l:0,d:l,w:e&&e!=1?e:p.subarray(12),e:m,o:new Wr(p.buffer,0,3),u:h,c:o,m:Math.min(131072,m)}}else if((t>>4|n[3]<<20)==25481893)return gc(n,4)+8;Q(0)},st=function(n){for(var e=0;1<t&&Q(3);for(var o=1<0;){var g=st(s+1),E=r>>3,O=(1<>(r&7)&O,w=(1<w&&(S-=z)),h[++a]=--S,S==-1?(s+=S,_[--l]=a):s-=S,!S)do{var I=r>>3;c=(n[I]|n[I+1]<<8)>>(r&7)&3,r+=2,a+=c}while(c==3)}(a>255||s)&&Q(0);for(var R=0,L=(o>>1)+(o>>3)+3,v=o-1,Z=0;Z<=a;++Z){var N=h[Z];if(N<1){m[Z]=-N;continue}for(u=0;u=l)}}for(R&&Q(0),u=0;u>3,{b:i,s:_,n:y,t:f}]},Sc=function(n,e){var t=0,r=-1,i=new J(292),o=n[e],s=i.subarray(0,256),a=i.subarray(256,268),c=new Ur(i.buffer,268);if(o<128){var u=Qt(n,e+1,6),l=u[0],d=u[1];e+=o;var h=l<<3,m=n[e];m||Q(0);for(var f=0,p=0,_=d.b,y=_,g=(++e<<3)-8+st(m);g-=_,!(g>3;if(f+=(n[E]|n[E+1]<<8)>>(g&7)&(1<<_)-1,s[++r]=d.s[f],g-=y,g>3,p+=(n[E]|n[E+1]<<8)>>(g&7)&(1<255&&Q(0)}else{for(r=o-127;t>4,s[t+1]=O&15}++e}var S=0;for(t=0;t11&&Q(0),S+=w&&1<0;--t){var Z=c[t];Jt(v,t,Z,c[t-1]=Z+a[t]*(1<a&&d>3,m=(n[h]|n[h+1]<<8|n[h+2]<<16)>>(l&7);c=(c<>2,s=o<<1,a=o+s;jt(n.subarray(r,r+=n[0]|n[1]<<8),e.subarray(0,o),t),jt(n.subarray(r,r+=n[2]|n[3]<<8),e.subarray(o,s),t),jt(n.subarray(r,r+=n[4]|n[5]<<8),e.subarray(s,a),t),jt(n.subarray(r),e.subarray(a),t)},Tc=function(n,e,t){var r,i=e.b,o=n[i],s=o>>1&3;e.l=o&1;var a=o>>3|n[i+1]<<5|n[i+2]<<13,c=(i+=3)+a;if(s==1)return i>=n.length?void 0:(e.b=i+1,t?(Jt(t,n[i],e.y,e.y+=a),t):Jt(new J(a),n[i]));if(!(c>n.length)){if(s==0)return e.b=c,t?(t.set(n.subarray(i,c),e.y),e.y+=a,t):Cn(n,i,c);if(s==2){var u=n[i],l=u&3,d=u>>2&3,h=u>>4,m=0,f=0;l<2?d&1?h|=n[++i]<<4|(d&2&&n[++i]<<12):h=u>>3:(f=d,d<2?(h|=(n[++i]&63)<<4,m=n[i]>>6|n[++i]<<2):d==2?(h|=n[++i]<<4|(n[++i]&3)<<12,m=n[i]>>2|n[++i]<<6):(h|=n[++i]<<4|(n[++i]&63)<<12,m=n[i]>>6|n[++i]<<2|n[++i]<<10)),++i;var p=t?t.subarray(e.y,e.y+e.m):new J(e.m),_=p.length-h;if(l==0)p.set(n.subarray(i,i+=h),_);else if(l==1)Jt(p,n[i++],_);else{var y=e.h;if(l==2){var g=Sc(n,i);m+=i-(i=g[0]),e.h=y=g[1]}else y||Q(0);(f?Ic:jt)(n.subarray(i,i+=m),p.subarray(_),y)}var E=n[i++];if(E){E==255?E=(n[i++]|n[i++]<<8)+32512:E>127&&(E=E-128<<8|n[i++]);var O=n[i++];O&3&&Q(0);for(var S=[Oc,Ac,wc],w=2;w>-1;--w){var z=O>>(w<<1)+2&3;if(z==1){var x=new J([0,0,n[i++]]);S[w]={s:x.subarray(2,3),n:x.subarray(0,1),t:new Ur(x.buffer,0,1),b:0}}else z==2?(r=Qt(n,i,9-(w&1)),i=r[0],S[w]=r[1]):z==3&&(e.t||Q(0),S[w]=e.t[w])}var I=e.t=S,R=I[0],L=I[1],v=I[2],Z=n[c-1];Z||Q(0);var N=(c<<3)-8+st(Z)-v.b,k=N>>3,G=0,ue=(n[k]|n[k+1]<<8)>>(N&7)&(1<>3;var Oe=(n[k]|n[k+1]<<8)>>(N&7)&(1<>3;var M=(n[k]|n[k+1]<<8)>>(N&7)&(1<>3;var yt=1<>>(N&7)&yt-1);k=(N-=Dn[nt])>>3;var $e=xc[nt]+((n[k]|n[k+1]<<8|n[k+2]<<16)>>(N&7)&(1<>3;var it=zc[de]+((n[k]|n[k+1]<<8|n[k+2]<<16)>>(N&7)&(1<>3,ue=v.t[ue]+((n[k]|n[k+1]<<8)>>(N&7)&(1<>3,M=R.t[M]+((n[k]|n[k+1]<<8)>>(N&7)&(1<>3,Oe=L.t[Oe]+((n[k]|n[k+1]<<8)>>(N&7)&(1<3)e.o[2]=e.o[1],e.o[1]=e.o[0],e.o[0]=Ae-=3;else{var _t=Ae-(it!=0);_t?(Ae=_t==3?e.o[0]-1:e.o[_t],_t>1&&(e.o[2]=e.o[1]),e.o[1]=e.o[0],e.o[0]=Ae):Ae=e.o[0]}for(var w=0;w$e&&(We=$e);for(var w=0;wvc)throw er("EOVERFLOW","file offset is outside signed i64");return n}function kc(n){if(Bn(n)<0n)throw er("EINVAL","negative positioned I/O offset");return n}function $n(n){let e=Bn(n);if(eKo)throw er("EOVERFLOW","backend cannot represent the file offset exactly");return Do(e)}function Un(n){let e=kc(n);return $n(e)}function Bo(n){if(n===null)return null;let e=Bn(n);if(e<0n)throw er("EINVAL","negative file-size limit");return e>Ko?null:Do(e)}Ve();var{ALLOC_SIZE_MIN:Fc,ASYNC_IO:Nc,CHOWN_RESTRICTED:Cc,FALLOC:Mc,FILESIZEBITS:Dc,LINK_MAX:Kc,MAX_CANON:Bc,MAX_INPUT:$c,NAME_MAX:Uc,NO_TRUNC:Wc,PATH_MAX:Gc,PIPE_BUF:Hc,POSIX2_SYMLINKS:Vc,PRIO_IO:qc,REC_INCR_XFER_SIZE:Zc,REC_MAX_XFER_SIZE:Yc,REC_MIN_XFER_SIZE:Xc,REC_XFER_ALIGN:jc,SOCK_MAXBUF:Jc,SYMLINK_MAX:Qc,SYNC_IO:eu,TEXTDOMAIN_MAX:tu,TIMESTAMP_RESOLUTION:ru,VDISABLE:nu}=zo,{S_IFDIR:iu,S_IFIFO:ou,S_IFMT:$o,S_IFREG:su}=ie;function Wn(n){let e=new Error(`EINVAL: pathconf name ${n} is not associated with this object`);throw e.code="EINVAL",e}function Gn(n,e,t){switch(e){case Kc:return null;case Uc:return 255;case Gc:return Oo;case Cc:return 1;case Wc:return 1;case Nc:return(n.mode&$o)===su?1:Wn(e);case eu:case qc:case Dc:case Zc:case Yc:case Xc:case jc:case Fc:case Qc:case Mc:return null;case Vc:return t.supportsSymlinks?1:null;case tu:return 255;case ru:return t.timestampResolutionNs;case Hc:{let r=n.mode&$o;return r===ou||r===iu?null:Wn(e)}case Bc:case $c:case nu:case Jc:return Wn(e);default:{let r=new Error(`EINVAL: invalid pathconf name ${e}`);throw r.code="EINVAL",r}}}Ve();Ve();var Uo=So.ST_NOSUID;var Gr=Math.floor(160),Hn=1397114451,Vn=1,tr=32768,H=16384,wt=40960,W=61440,au=2048,cu=1024,uu=73,Wo=4294967295,at=0,Go=1;var cr=64,ei=128,dr=512,du=1024,lu=65536,rr=3,fu=0,pu=1,hu=2,F=8,mu=64*1024,yu=-1,Ie=-2,$=-5,re=-9,jn=-16,Tt=-17,Fe=-20,ut=-21,j=-22,Qo=-24,dt=-27,se=-28,es=-30,Jn=-36,Qn=-39,ts=-40,rs=-75,qn=0,Zn=4,Hr=8,Ot=12,Ye=16,At=20,Vr=24,ct=28,qr=32,Ho=36,Zr=40,_u=44,gu=48,Eu=52,Yn=56,Yr=60,Xr=64,nr=68,Vo=72,zt=0,C=8,B=12,P=16,me=24,ee=32,ir=40,oe=48,or=88,xt=92,sr=96,ar=100,pe=104,Xe=112,qo=116,he=120,Zo=4,ke=8,Yo=16,Xo=20,jo=-2147483648,Su=2147483647,wu=1034+1024*1024,je=wu*4096,Ou={[Ie]:"No such file or directory",[$]:"I/O error",[re]:"Bad file descriptor",[jn]:"Device or resource busy",[Tt]:"File exists",[Fe]:"Not a directory",[ut]:"Is a directory",[j]:"Invalid argument",[Qo]:"Too many open files",[dt]:"File too large",[se]:"No space left on device",[es]:"Read-only file system",[Jn]:"File name too long",[Qn]:"Directory not empty",[ts]:"Too many symbolic links",[rs]:"Value too large for data type"},A=class extends Error{constructor(t,r){super(r||Ou[t]||`Error ${t}`);this.code=t;this.name="SFSError"}code},_e=new TextEncoder,ur=new TextDecoder,Jo=_e.encode("..");function Xn(n){return n==="."||n===".."}function It(n){return n.buffer instanceof SharedArrayBuffer?ur.decode(new Uint8Array(n)):ur.decode(n)}function Je(n){return n+3&-4}var le=class n{constructor(e){this.buffer=e;this.view=new DataView(e),this.i32=new Int32Array(e),this.u8=new Uint8Array(e)}buffer;view;i32;u8;dirIndexes=new Map;blockAllocHint=0;inodeAllocHint=2;atomicsWaitAllowed;static mkfs(e,t){let r=e.byteLength;if(r<4096*16)throw new A(j);let i=Math.floor(r/4096),o=t?Math.floor(t/4096):i*4,s=Math.floor(o/4);s<32&&(s=32),s=Math.ceil(s/32)*32;let a=Math.ceil(s/(4096*8)),c=Math.ceil(o/(4096*8)),u=Math.ceil(s*128/4096),l=1,d=l+a,h=d+c,m=h+u;if(m>=i){let x=(m+1)*4096;try{e.grow(x)}catch{throw new A(se)}if(i=Math.floor(e.byteLength/4096),m>=i)throw new A(se)}new Uint8Array(e).fill(0);let f=new n(e);f.w32(qn,Hn),f.w32(Zn,Vn),f.w32(Hr,4096),f.w32(Ot,i),f.w32(Ye,s),f.w32(ct,l),f.w32(qr,d),f.w32(Ho,h),f.w32(Zr,m),f.w32(_u,a),f.w32(gu,c),f.w32(Eu,u),f.w32(nr,o),f.w32(Vo,256);let p=d*4096;for(let x=0;x>2)+(x>>5);f.i32[I]|=1<<(x&31)}let _=i-m;Atomics.store(f.i32,At>>2,_),f.blockAllocHint=m;let y=l*4096;f.i32[y>>2]|=3,Atomics.store(f.i32,Vr>>2,s-2),f.inodeAllocHint=2;let g=f.inodeOffset(1);f.w32(g+C,H|493),f.w32(g+B,2),f.w64(g+pe,1);let E=f.blockAlloc();if(E<0)throw new A(se);f.w32(g+oe,E);let O=E*4096,S=Je(F+1),w=Je(F+2);f.w32(O,1),f.view.setUint16(O+4,S,!0),f.view.setUint16(O+6,1,!0),f.u8[O+F]=46;let z=O+S;return f.w32(z,1),f.view.setUint16(z+4,w,!0),f.view.setUint16(z+6,2,!0),f.u8[z+F]=46,f.u8[z+F+1]=46,f.w64(g+P,S+w),Atomics.store(f.i32,Yn>>2,1),f}static inspectImageCapacity(e){if(e.byteLengththis.snapshotBytesUnlocked(e))}snapshotState(e){return this.withNamespaceLock(()=>({bytes:this.snapshotBytesUnlocked(e),identities:this.collectIdentityStateUnlocked()}))}identityState(){return this.withNamespaceLock(()=>this.collectIdentityStateUnlocked())}snapshotBytesUnlocked(e){let t=e?.normalizeTimestampsMs;if(t!==void 0&&(!Number.isSafeInteger(t)||t<0))throw new A(j,"Snapshot timestamp must be a non-negative safe integer in milliseconds");let r=t===void 0?void 0:BigInt(t);for(let a=0;a>2)!==0)throw new A(jn,"Cannot save a VFS image with open descriptors")}let i=this.r32(Ye);for(let a=0;a=1&&this.inodeIsAllocated(a)?r:0n;s.setBigUint64(c+ir,u,!0),s.setBigUint64(c+me,u,!0),s.setBigUint64(c+ee,u,!0)}}return o}collectIdentityStateUnlocked(){let e=new Map,t=this.inodeOffset(1),r=this.r64(t+pe);e.set(`1:${r}`,{ino:1,generation:r,dataSequence:Atomics.load(this.i32,t+he>>2)>>>0,mode:this.r32(t+C),linkCount:this.r32(t+B),size:this.r64(t+P),uid:this.r32(t+sr),gid:this.r32(t+ar),paths:["/"]});let i=[{ino:1,path:"/"}],o=new Set;for(;i.length>0;){let s=i.pop();if(o.has(s.ino))throw new A($);o.add(s.ino);let a=this.inodeOffset(s.ino);if((this.r32(a+C)&W)!==H)throw new A($);let c=this.r64(a+P),u=0;for(;u>2)>>>0,mode:R,linkCount:this.r32(w+B),size:this.r64(w+P),uid:this.r32(w+sr),gid:this.r32(w+ar),...(R&W)===wt?{symlinkTarget:this.readSymlinkInodeUnlocked(y)}:{},paths:[]},e.set(x,I)}I.paths.push(S),(this.r32(w+C)&W)===H&&i.push({ino:y,path:S})}}p+=g}u+=f}}return e}statfs(){let e=this.r32(Hr),t=this.r32(Ot),r=this.r32(nr),i=typeof this.buffer.maxByteLength=="number"?this.buffer.maxByteLength:this.buffer.byteLength,o=Math.floor(i/e),s=Math.max(t,Math.min(r,o)),a=Atomics.load(this.i32,At>>2),c=Math.max(0,s-t);return{blockSize:e,totalBlocks:s,freeBlocks:a+c,totalInodes:this.r32(Ye),freeInodes:Atomics.load(this.i32,Vr>>2),maxName:255}}r32(e){return this.view.getUint32(e,!0)}w32(e,t){this.view.setUint32(e,t,!0)}r64(e){return Number(this.view.getBigUint64(e,!0))}w64(e,t){this.view.setBigUint64(e,BigInt(t),!0)}waitForAtomicChange(e,t){if(this.atomicsWaitAllowed!==!1)try{Atomics.wait(this.i32,e,t),this.atomicsWaitAllowed=!0;return}catch(r){if(!(r instanceof TypeError))throw r;this.atomicsWaitAllowed=!1}for(;Atomics.load(this.i32,e)===t;);}resetAllocationHints(){this.blockAllocHint=this.findNextFreeBlockHint(),this.inodeAllocHint=this.findNextFreeInodeHint()}findNextFreeBlockHint(){let e=this.r32(Ot),t=this.r32(Zr),r=this.r32(qr)*4096;for(let i=t;i>2)+(i>>5),s=i&31;if((Atomics.load(this.i32,o)&1<>2)+(r>>5),o=r&31;if((Atomics.load(this.i32,i)&1<>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}sbUnlock(){let e=Yr>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}namespaceLock(){let e=Xr>>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}namespaceUnlock(){let e=Xr>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}withNamespaceLock(e){this.namespaceLock();try{return e()}finally{this.namespaceUnlock()}}resetRestoredRuntimeState(){Atomics.store(this.i32,Yr>>2,0),Atomics.store(this.i32,Xr>>2,0),this.u8.fill(0,256,4096);let e=this.r32(Ye),t=this.r32(ct)*4096;for(let r=0;r>5)*4)&1<<(r&31))===0||this.r32(i+B)!==0)continue;let s=this.r32(i+C),a=this.r64(i+P);(s&W)===wt&&a<=40?(this.u8.fill(0,i+oe,i+oe+40),this.w64(i+P,0)):this.inodeTruncate(r,0),this.inodeFree(r)}}blockAlloc(){let e=this.r32(Ot),t=this.r32(qr)*4096,r=this.r32(Zr),i=this.blockAllocHint>=r&&this.blockAllocHint>2)+(a>>5),u=a&31,l=Atomics.load(this.i32,c);if(l&1<>2,1),this.blockAllocHint=a+1>2)+(e>>5),i=e&31;for(;;){let o=Atomics.load(this.i32,r),s=o&~(1<>2,1),e>=this.r32(Zr)&&e>2)>0)return 0;let e=this.r32(Ot),t=this.r32(nr),r=this.r32(Vo),i=e+r;if(i>t&&(i=t,r=i-e,r===0))return se;let o=i*4096;if(this.buffer.byteLength>2,r),Atomics.add(this.i32,Yn>>2,1),this.blockAllocHint=e,0}finally{this.sbUnlock()}}inodeOffset(e){let r=this.r32(Ho)+Math.floor(e/32),i=e%32*128;return r*4096+i}inodeAlloc(){let e=this.r32(Ye),t=this.r32(ct)*4096,r=this.inodeAllocHint>=2&&this.inodeAllocHint>2)+(s>>5),c=s&31,u=Atomics.load(this.i32,a);if(u&1<>2,1),this.inodeAllocHint=s+1>2,1)+1}inodeFree(e){let r=(this.r32(ct)*4096>>2)+(e>>5),i=e&31;for(;;){let o=Atomics.load(this.i32,r);if((o&1<>2,1),e>=2&&e0&&this.w32(r+Xe,i-1),i<=1&&this.r32(r+B)===0&&(this.inodeTruncate(e,0),t=!0)}finally{this.inodeWriteUnlock(e)}t&&this.inodeFree(e)}inodeDropLinkRefLocked(e){let t=this.inodeOffset(e),r=this.r32(t+B);return r>1?(this.w32(t+B,r-1),this.w64(t+ee,Date.now()),!1):this.inodeOrphanLocked(e)}inodeOrphanLocked(e){let t=this.inodeOffset(e);if(this.w32(t+B,0),this.w64(t+ee,Date.now()),this.r32(t+Xe)>0)return!1;let r=this.r32(t+C),i=this.r64(t+P);return(r&W)===wt&&i<=40?(this.u8.fill(0,t+oe,t+oe+40),this.w64(t+P,0)):this.inodeTruncate(e,0),!0}inodeReadLock(e){let t=this.inodeOffset(e)+zt>>2;for(;;){let r=Atomics.load(this.i32,t);if(r&jo){this.waitForAtomicChange(t,r);continue}if(Atomics.compareExchange(this.i32,t,r,r+1)===r)return}}inodeReadUnlock(e){let t=this.inodeOffset(e)+zt>>2;(Atomics.sub(this.i32,t,1)&Su)===1&&Atomics.notify(this.i32,t,1)}inodeWriteLock(e){let t=this.inodeOffset(e)+zt>>2;for(;;){let r=Atomics.load(this.i32,t);if(r!==0){this.waitForAtomicChange(t,r);continue}if(Atomics.compareExchange(this.i32,t,0,jo)===0)return}}inodeWriteUnlock(e){let t=this.inodeOffset(e)+zt>>2;Atomics.store(this.i32,t,0),Atomics.notify(this.i32,t,1/0)}inodeBlockMap(e,t,r){let i=this.inodeOffset(e);if(t<10){let o=this.r32(i+oe+t*4);if(o!==0)return o;if(!r)return 0;let s=this.blockAllocWithGrow();return s<0||this.w32(i+oe+t*4,s),s}if(t-=10,t<1024){let o=this.r32(i+or),s=!1;if(o===0){if(!r)return 0;if(o=this.blockAllocWithGrow(),o<0)return o;this.w32(i+or,o),s=!0}let a=o*4096+t*4,c=this.r32(a);if(c!==0)return c;if(!r)return 0;let u=this.blockAllocWithGrow();return u<0?(s&&(this.w32(i+or,0),this.blockFree(o)),u):(this.w32(a,u),u)}if(t-=1024,t<1024*1024){let o=Math.floor(t/1024),s=t%1024,a=this.r32(i+xt),c=!1;if(a===0){if(!r)return 0;if(a=this.blockAllocWithGrow(),a<0)return a;this.w32(i+xt,a),c=!0}let u=a*4096+o*4,l=this.r32(u),d=!1;if(l===0){if(!r)return 0;if(l=this.blockAllocWithGrow(),l<0)return c&&(this.w32(i+xt,0),this.blockFree(a)),l;this.w32(u,l),d=!0}let h=l*4096+s*4,m=this.r32(h);if(m!==0)return m;if(!r)return 0;let f=this.blockAllocWithGrow();return f<0?(d&&(this.w32(u,0),this.blockFree(l)),c&&(this.w32(i+xt,0),this.blockFree(a)),f):(this.w32(h,f),f)}return j}inodeReadData(e,t,r,i){let o=this.inodeOffset(e),s=this.r64(o+P);if(t>=s)return 0;t+i>s&&(i=s-t);let a=0,c=0;for(;i>0;){let u=Math.floor(t/4096),l=t%4096,d=4096-l;d>i&&(d=i);let h=this.inodeBlockMap(e,u,!1);if(h<=0)r.fill(0,c,c+d);else{let m=h*4096+l;r.set(this.u8.subarray(m,m+d),c)}c+=d,t+=d,i-=d,a+=d}return a}inodeWriteData(e,t,r,i){let o=this.inodeOffset(e),s=this.r64(o+P);t>s&&this.zeroOldEofTail(e,s);let a=0,c=0;for(;i>0;){let u=Math.floor(t/4096),l=t%4096,d=4096-l;d>i&&(d=i);let h=this.inodeBlockMap(e,u,!0);if(h<0){if(a===0)return h;break}let m=h*4096+l;this.u8.set(r.subarray(c,c+d),m),c+=d,t+=d,i-=d,a+=d}if(a>0&&t>this.r64(o+P)&&this.w64(o+P,t),a>0){let u=Date.now();this.w64(o+me,u),this.w64(o+ee,u),Atomics.add(this.i32,o+he>>2,1)}return a}zeroInodeRange(e,t,r){for(;t0){let c=a*4096+o;this.u8.fill(0,c,c+s)}t+=s}}zeroOldEofTail(e,t){let r=t%4096;if(r===0)return;let i=Math.floor(t/4096),o=this.inodeBlockMap(e,i,!1);if(o<=0)return;let s=o*4096+r;this.u8.fill(0,s,o*4096+4096)}freeBlocksFrom(e,t){let r=this.inodeOffset(e);for(let s=t;s<10;s++){let a=this.r32(r+oe+s*4);a&&(this.blockFree(a),this.w32(r+oe+s*4,0))}let i=this.r32(r+or);if(i){let s=t>10?t-10:0;for(let a=s;a<1024;a++){let c=i*4096+a*4,u=this.r32(c);u&&(this.blockFree(u),this.w32(c,0))}s===0&&(this.blockFree(i),this.w32(r+or,0))}let o=this.r32(r+xt);if(o){let s=t>1034?t-10-1024:0,a=Math.floor(s/1024);for(let c=a;c<1024;c++){let u=o*4096+c*4,l=this.r32(u);if(!l)continue;let d=c===a?s%1024:0;for(let h=d;h<1024;h++){let m=l*4096+h*4,f=this.r32(m);f&&(this.blockFree(f),this.w32(m,0))}d===0&&(this.blockFree(l),this.w32(u,0))}a===0&&(this.blockFree(o),this.w32(r+xt,0))}}inodeTruncate(e,t,r=!1){let i=this.inodeOffset(e),o=this.r64(i+P),s=t!==o;if(t>=o){if(t>o&&this.zeroOldEofTail(e,o),this.w64(i+P,t),s||r){let c=Date.now();this.w64(i+me,c),this.w64(i+ee,c),Atomics.add(this.i32,i+he>>2,1)}return}t%4096!==0&&this.zeroInodeRange(e,t,Math.ceil(t/4096)*4096);let a=Math.ceil(t/4096);if(this.freeBlocksFrom(e,a),this.w64(i+P,t),s||r){let c=Date.now();this.w64(i+me,c),this.w64(i+ee,c),Atomics.add(this.i32,i+he>>2,1)}}validateFileSize(e){if(!Number.isSafeInteger(e)||e<0)throw new A(j);if(e>je)throw new A(dt)}validateSeekPosition(e){if(!Number.isSafeInteger(e))throw new A(rs);if(e<0)throw new A(j);if(e>je)throw new A(dt)}touchDirectoryMutation(e){let t=this.inodeOffset(e),r=Date.now();this.w64(t+me,r),this.w64(t+ee,r);let i=Atomics.add(this.i32,t+qo>>2,1)+1>>>0,o=this.dirIndexes.get(e);o&&(o.mutationSequence=i,o.size=this.r64(t+P))}dirNameKey(e){return It(e)}dirEntryNameMatches(e,t){if(this.view.getUint16(e+6,!0)!==t.length)return!1;for(let i=0;i=F&&r%4===0&&e+r<=t&&i<=r-F}inodeIsAllocated(e){let t=this.r32(Ye);if(e<=0||e>=t)return!1;let r=this.r32(ct)*4096;return(Atomics.load(this.i32,(r>>2)+(e>>5))&1<<(e&31))!==0}rebuildDirIndex(e,t,r,i){let o=new Map,s=[],a=0;for(;a4096-l&&(m=4096-l);let f=l;for(;f=F&&s.push({abs:p,recLen:y});f+=y}a+=m}let c={generation:t,mutationSequence:r,size:i,entries:o,free:s};return this.dirIndexes.set(e,c),c}getDirIndex(e){let t=this.inodeOffset(e),r=this.r64(t+P),i=this.r64(t+pe),o=Atomics.load(this.i32,t+qo>>2)>>>0,s=this.dirIndexes.get(e);return s&&s.generation===i&&s.mutationSequence===o&&s.size===r?s:(s&&this.dirIndexes.delete(e),r=0;s--){let a=e.free[s];if(!(a.recLen4096-c&&(d=4096-c);let h=c;for(;hr)return-1;a=c,s+=u}return s===r?a:-1}dirAppendEntry(e,t,r,i=-1){let o=this.inodeOffset(e),s=this.r64(o+P),a=Je(F+t.length),c=s,u=Math.floor(c/4096),l=c%4096,d=0;if(l!==0&&l+a>4096){let f=4096-l,p=0;if(f>=F){if(p=this.inodeBlockMap(e,u,!1),p<=0)return $}else if(i<0&&(i=this.findLastDirEntryInBlock(e,u,l)),i<0)return $;if(d=this.inodeBlockMap(e,u+1,!0),d<0)return d;if(f>=F){let _=p*4096+l;this.w32(_,0),this.view.setUint16(_+4,f,!0),this.view.setUint16(_+6,0,!0)}else{let y=this.view.getUint16(i+4,!0)+f;this.view.setUint16(i+4,y,!0),this.updateDirIndexRecLen(e,i,y)}c=(u+1)*4096,u++,l=0}let h;if(l===0){if(h=d||this.inodeBlockMap(e,u,!0),h<0)return h}else if(h=this.inodeBlockMap(e,u,!1),h<=0)return $;let m=h*4096+l;return this.w32(m,r),this.view.setUint16(m+4,a,!0),this.view.setUint16(m+6,t.length,!0),this.u8.set(t,m+F),this.w64(o+P,c+a),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,m,a),0}dirAddEntry(e,t,r){let i=this.getDirIndex(e);if(typeof i=="number")return i;if(i)return this.useDirIndexFreeSlot(i,e,t,r)?0:this.dirAppendEntry(e,t,r);let o=this.inodeOffset(e),s=this.r64(o+P),a=Je(F+t.length),c=-1,u=0;for(;u4096-d&&(f=4096-d);let p=d;for(;pd+f||E>g-F)return $;if(y===0&&g>=a)return this.w32(_,r),this.view.setUint16(_+6,t.length,!0),this.u8.set(t,_+F),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,_,g),0;let O=Je(F+E),S=g-O;if(y!==0&&S>=a){this.view.setUint16(_+4,O,!0);let w=_+O;return this.w32(w,r),this.view.setUint16(w+4,S,!0),this.view.setUint16(w+6,t.length,!0),this.u8.set(t,w+F),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,w,S),0}c=_,p+=g}u+=f}return this.dirAppendEntry(e,t,r,c)}dirRemoveEntry(e,t){let r=this.getDirIndex(e);if(typeof r=="number")return r;if(r){let a=this.dirNameKey(t),c=r.entries.get(a);if(!c)return Ie;if(this.r32(c.abs)===c.ino&&this.view.getUint16(c.abs+4,!0)===c.recLen&&this.view.getUint16(c.abs+6,!0)===c.nameLen&&this.dirEntryNameMatches(c.abs,t))return this.w32(c.abs,0),r.entries.delete(a),r.free.push({abs:c.abs,recLen:c.recLen}),this.touchDirectoryMutation(e),0;r.entries.delete(a)}let i=this.inodeOffset(e),o=this.r64(i+P),s=0;for(;s4096-c&&(d=4096-c);let h=c;for(;h4096-u&&(h=4096-u);let m=u;for(;m4096-s&&(u=4096-s);let l=s;for(;ls+u||f>m-F)throw new A($);if(h!==0){if(f===1&&this.u8[d+F]===46){l+=m;continue}if(f===2&&this.u8[d+F]===46&&this.u8[d+F+1]===46){l+=m;continue}return!1}l+=m}i+=u}return!0}dirIsAncestor(e,t){let r=t;for(let i=0;i<8*1024;i++){if(r===e)return!0;if(r===1)return!1;let o=this.dirLookup(r,Jo);if(o<0||o===r)throw new A($);r=o}throw new A($)}pathResolve(e,t){if(!e.startsWith("/"))return Ie;let r=1,i=e.split("/").filter(s=>s.length>0),o=0;for(let s=0;s255)return Jn;let c=_e.encode(a),u;this.inodeReadLock(r);try{let h=this.inodeOffset(r);if((this.r32(h+C)&W)!==H)return Fe;u=this.dirLookup(r,c)}finally{this.inodeReadUnlock(r)}if(u<0)return u;let l=this.inodeOffset(u);if((this.r32(l+C)&W)===wt&&(!(s===i.length-1)||t)){if(++o>8)return ts;let m=this.r64(l+P),f;if(m<=40)f=It(this.u8.subarray(l+oe,l+oe+m));else{let p=new Uint8Array(m);this.inodeReadData(u,0,p,m),f=ur.decode(p)}if(f.startsWith("/")){r=1;let p=f.split("/").filter(y=>y.length>0),_=i.slice(s+1);i.length=0,i.push(...p,..._),s=-1}else{let p=f.split("/").filter(y=>y.length>0),_=i.slice(s+1);i.length=s,i.push(...p,..._),s--}continue}r=u}return r}pathResolveParent(e){if(!e.startsWith("/"))throw new A(j,"Path must be absolute");let t=e.split("/").filter(c=>c.length>0);if(t.length===0)throw new A(j,"Cannot operate on /");let r=t.pop();if(r.length>255)throw new A(Jn);let i="/"+t.join("/"),o=this.pathResolve(i,!0);if(o<0)throw new A(o);let s=this.inodeOffset(o);if((this.r32(s+C)&W)!==H)throw new A(Fe);return{parentIno:o,name:r}}fdAlloc(e,t,r){for(let i=0;i>2;if(Atomics.compareExchange(this.i32,s,0,1)===0)return this.w32(o+Zo,e),this.w64(o+ke,0),this.w32(o+Yo,t),this.w32(o+Xo,r?1:0),this.inodeAddOpenRef(e)?i:(Atomics.store(this.i32,s,0),Ie)}return Qo}fdGet(e){if(e<0||e>=Gr)return null;let t=256+e*24;return Atomics.load(this.i32,t>>2)?{base:t,ino:this.r32(t+Zo),offset:this.r64(t+ke),flags:this.r32(t+Yo),isDir:this.r32(t+Xo)!==0}:null}fdFree(e){if(e>=0&&e>2,0)}}buildStat(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+pe),dataSequence:this.r32(t+he),mode:this.r32(t+C),linkCount:this.r32(t+B),size:this.r64(t+P),mtime:this.r64(t+me),ctime:this.r64(t+ee),atime:this.r64(t+ir),uid:this.r32(t+sr),gid:this.r32(t+ar)}}namespaceEntryIdentity(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+pe),linkCount:this.r32(t+B),mode:this.r32(t+C)}}open(e,t,r=420){return this.withNamespaceLock(()=>this.openUnlocked(e,t,r))}createLazyStub(e,t){return this.withNamespaceLock(()=>{let r=this.openUnlocked(e,Go|cr,t);try{let i=this.fdGet(r);if(!i)throw new A(re);this.inodeWriteLock(i.ino);try{return this.inodeTruncate(i.ino,0,!0),this.buildStat(i.ino)}finally{this.inodeWriteUnlock(i.ino)}}finally{this.closeUnlocked(r)}})}replaceIfIdentity(e,t,r,i,o){return this.withNamespaceLock(()=>{let s=this.pathResolve(e,!0);if(s<0||s!==t)return!1;let a=this.inodeOffset(s);if(this.r64(a+pe)!==r||this.r32(a+he)!==i||(this.r32(a+C)&W)!==tr)return!1;this.validateFileSize(o.byteLength),this.inodeWriteLock(s);try{if(this.r64(a+pe)!==r||this.r32(a+he)!==i||this.r64(a+P)!==0)return!1;let c=this.r64(a+me),u=this.r64(a+ee);this.inodeTruncate(s,0,!0);let l=o.byteLength>0?this.inodeWriteData(s,0,o,o.byteLength):0;if(l!==o.byteLength)throw this.inodeTruncate(s,0,!0),Atomics.store(this.i32,a+he>>2,i),this.w64(a+me,c),this.w64(a+ee,u),new A(l<0?l:se);return!0}finally{this.inodeWriteUnlock(s)}})}replaceManyIfIdentities(e,t=[]){return e.length===0&&t.length===0?!0:this.withNamespaceLock(()=>{let r=[],i=new Set,o=a=>{let c=this.pathResolve(a.path,!1);if(c<0||c!==a.expectedIno)return!1;let u=this.inodeOffset(c);return this.r64(u+pe)===a.expectedGeneration&&this.r32(u+he)===a.expectedDataSequence&&this.r32(u+C)===a.expectedMode&&this.r32(u+B)===a.expectedLinkCount&&this.r64(u+P)===a.expectedSize&&this.r32(u+sr)===a.expectedUid&&this.r32(u+ar)===a.expectedGid};for(let a of t)if(!o(a))return!1;for(let a of e){this.validateFileSize(a.data.byteLength);let c=-1;for(let u of a.paths){let l=this.pathResolve(u,!0);if(l!==a.expectedIno)continue;let d=this.inodeOffset(l);if(this.r64(d+pe)===a.expectedGeneration&&this.r32(d+he)===a.expectedDataSequence&&(this.r32(d+C)&W)===tr&&this.r64(d+P)===0){c=l;break}}if(c<0)return!1;if(i.has(c))throw new A(j,"duplicate conditional replacement inode");i.add(c),r.push({...a,ino:c})}let s=[...i].sort((a,c)=>a-c);for(let a of s)this.inodeWriteLock(a);try{for(let u of r){let l=this.inodeOffset(u.ino);if(this.r64(l+pe)!==u.expectedGeneration||this.r32(l+he)!==u.expectedDataSequence||(this.r32(l+C)&W)!==tr||this.r64(l+P)!==0)return!1}for(let u of t)if(!o(u))return!1;let a=r.map(u=>{let l=this.inodeOffset(u.ino);return{ino:u.ino,dataSequence:this.r32(l+he),mtime:this.r64(l+me),ctime:this.r64(l+ee)}}),c=0;try{for(let u of r){c++,this.inodeTruncate(u.ino,0,!0);let l=u.data.byteLength>0?this.inodeWriteData(u.ino,0,u.data,u.data.byteLength):0;if(l!==u.data.byteLength)throw new A(l<0?l:se)}}catch(u){for(let l=c-1;l>=0;l--){let d=a[l],h=this.inodeOffset(d.ino);this.inodeTruncate(d.ino,0,!0),Atomics.store(this.i32,h+he>>2,d.dataSequence),this.w64(h+me,d.mtime),this.w64(h+ee,d.ctime)}throw u}return!0}finally{for(let a=s.length-1;a>=0;a--)this.inodeWriteUnlock(s[a])}})}openUnlocked(e,t,r=420){let i=t&rr,o=(t&cr)!==0,s=(t&ei)!==0;if(o&&s){let d=this.pathResolve(e,!1);if(d>=0)throw new A(Tt);if(d!==Ie)throw new A(d)}let a=this.pathResolve(e,!0);if(a<0&&a===Ie&&o){let{parentIno:d,name:h}=this.pathResolveParent(e);this.inodeWriteLock(d);try{let m=_e.encode(h),f=this.dirLookup(d,m);if(f>=0){if(s)throw new A(Tt);a=f}else{let p=this.inodeAlloc();if(p<0)throw new A(se);let _=this.inodeOffset(p);this.w32(_+C,tr|r&4095),this.w32(_+B,1),this.w64(_+P,0);let y=Date.now();this.w64(_+ir,y),this.w64(_+me,y),this.w64(_+ee,y);let g=this.dirAddEntry(d,m,p);if(g<0)throw this.inodeFree(p),new A(g);a=p}}finally{this.inodeWriteUnlock(d)}}if(a<0)throw new A(a);let c=this.inodeOffset(a),u=this.r32(c+C);if((u&W)===H&&i!==at)throw new A(ut);if(t&lu&&(u&W)!==H)throw new A(Fe);if(t&dr){if((u&W)===H)throw new A(ut);this.inodeWriteLock(a),this.inodeTruncate(a,0,!0),this.inodeWriteUnlock(a)}let l=this.fdAlloc(a,t,!1);if(l<0)throw new A(l);return l}close(e){this.withNamespaceLock(()=>this.closeUnlocked(e))}closeUnlocked(e){let t=this.fdGet(e);if(!t)throw new A(re);this.fdFree(e),this.inodeDropOpenRef(t.ino)}read(e,t){let r=this.fdGet(e);if(!r)throw new A(re);let i=this.inodeOffset(r.ino);if((this.r32(i+C)&W)===H)throw new A(ut);this.inodeReadLock(r.ino);try{let s=this.inodeReadData(r.ino,r.offset,t,t.length),a=256+e*24;return this.w64(a+ke,r.offset+s),s}finally{this.inodeReadUnlock(r.ino)}}readAt(e,t,r){let i=this.fdGet(e);if(!i)throw new A(re);let o=this.inodeOffset(i.ino);if((this.r32(o+C)&W)===H)throw new A(ut);this.validateSeekPosition(r),this.inodeReadLock(i.ino);try{return this.inodeReadData(i.ino,r,t,t.length)}finally{this.inodeReadUnlock(i.ino)}}write(e,t){let r=this.fdGet(e);if(!r)throw new A(re);if((r.flags&rr)===at)throw new A(re);this.inodeWriteLock(r.ino);try{let o=r.offset;if(r.flags&du){let c=this.inodeOffset(r.ino);o=this.r64(c+P)}if(!Number.isSafeInteger(o)||o<0)throw new A(j);if(o>je||t.length>je-o)throw new A(dt);let s=this.inodeWriteData(r.ino,o,t,t.length);if(s<0)return s;let a=256+e*24;return this.w64(a+ke,o+s),s}finally{this.inodeWriteUnlock(r.ino)}}append(e,t,r){let i=this.fdGet(e);if(!i)throw new A(re);if((i.flags&rr)===at)throw new A(re);if(r!==null&&(!Number.isSafeInteger(r)||r<0))throw new A(j);this.inodeWriteLock(i.ino);try{let s=this.inodeOffset(i.ino),a=this.r64(s+P);if(!Number.isSafeInteger(a)||a<0)throw new A(j);if(a>je)throw new A(dt);if(r!==null&&a>=r){let f=256+e*24;return this.w64(f+ke,a),{written:0,end:a}}let c=r===null?t.length:Math.min(t.length,r-a),u=je-a;if(c>u)throw new A(dt);let l=t.subarray(0,c),d=this.inodeWriteData(i.ino,a,l,l.length);if(d<0)throw new A(d);let h=256+e*24,m=a+d;return this.w64(h+ke,m),{written:d,end:m}}finally{this.inodeWriteUnlock(i.ino)}}writeAt(e,t,r){let i=this.fdGet(e);if(!i)throw new A(re);if((i.flags&rr)===at)throw new A(re);this.validateSeekPosition(r),this.inodeWriteLock(i.ino);try{if(r>je||t.length>je-r)throw new A(dt);return this.inodeWriteData(i.ino,r,t,t.length)}finally{this.inodeWriteUnlock(i.ino)}}lseek(e,t,r){let i=this.fdGet(e);if(!i)throw new A(re);let o;if(r===fu)o=t;else if(r===pu)o=i.offset+t;else if(r===hu){let a=this.inodeOffset(i.ino);o=this.r64(a+P)+t}else throw new A(j);this.validateSeekPosition(o);let s=256+e*24;return this.w64(s+ke,o),o}ftruncate(e,t){let r=this.fdGet(e);if(!r)throw new A(re);if((r.flags&rr)===at)throw new A(re);this.validateFileSize(t),this.inodeWriteLock(r.ino);try{this.inodeTruncate(r.ino,t,!0)}finally{this.inodeWriteUnlock(r.ino)}}fstat(e){let t=this.fdGet(e);if(!t)throw new A(re);this.inodeReadLock(t.ino);try{return this.buildStat(t.ino)}finally{this.inodeReadUnlock(t.ino)}}stat(e){return this.withNamespaceLock(()=>this.statUnlocked(e))}statUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new A(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}lstat(e){return this.withNamespaceLock(()=>this.lstatUnlocked(e))}lstatUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new A(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}unlink(e){return this.withNamespaceLock(()=>this.unlinkUnlocked(e))}unlinkUnlocked(e){let{parentIno:t,name:r}=this.pathResolveParent(e),i=_e.encode(r),o=e.length>1&&e.endsWith("/");this.inodeWriteLock(t);try{let s=this.dirLookup(t,i);if(s<0)throw new A(s);let a=this.inodeOffset(s),c=this.r32(a+C);if(o&&(c&W)!==H)throw new A(Fe);if((c&W)===H)throw new A(ut);let u=this.namespaceEntryIdentity(s),l=this.dirRemoveEntry(t,i);if(l<0)throw new A(l);let d=!1;this.inodeWriteLock(s);try{d=this.inodeDropLinkRefLocked(s)}finally{this.inodeWriteUnlock(s)}return d&&this.inodeFree(s),u}finally{this.inodeWriteUnlock(t)}}rename(e,t){return this.withNamespaceLock(()=>this.renameUnlocked(e,t))}renameUnlocked(e,t){let{parentIno:r,name:i}=this.pathResolveParent(e),{parentIno:o,name:s}=this.pathResolveParent(t);if(Xn(i)||Xn(s))throw new A(j);let a=_e.encode(i),c=_e.encode(s),u=e.length>1&&e.endsWith("/"),l=t.length>1&&t.endsWith("/"),d=Math.min(r,o),h=Math.max(r,o);this.inodeWriteLock(d),d!==h&&this.inodeWriteLock(h);try{let m=this.dirLookup(r,a);if(m<0)throw new A(m);let f=this.inodeOffset(m),_=this.r32(f+C)&W,y=this.namespaceEntryIdentity(m);if((u||l)&&_!==H)throw new A(Fe);if(_===H&&this.dirIsAncestor(m,o))throw new A(j);let g=this.dirLookup(o,c),E=!1,O;if(g>=0){if(g===m)return{source:y,replaced:y};O=this.namespaceEntryIdentity(g);let w=this.inodeOffset(g),x=this.r32(w+C)&W;if(_===H&&x!==H)throw new A(Fe);if(_!==H&&x===H)throw new A(ut);let I=!1,R=g===r||g===o;R||this.inodeWriteLock(g);try{if(x===H&&!this.dirIsEmpty(g))throw new A(Qn);let L=this.dirReplaceEntryIno(o,c,m);if(L<0)throw new A(L);I=x===H?this.inodeOrphanLocked(g):this.inodeDropLinkRefLocked(g)}finally{R||this.inodeWriteUnlock(g)}I&&this.inodeFree(g),E=x===H}else{let w=this.dirAddEntry(o,c,m);if(w<0)throw new A(w)}let S=this.dirRemoveEntry(r,a);if(S<0)throw new A(S);if(_===H){if(r!==o){let w=this.inodeOffset(r);this.w32(w+B,this.r32(w+B)-1);let z=this.inodeOffset(o);this.w32(z+B,this.r32(z+B)+1),this.inodeWriteLock(m);try{let x=this.dirReplaceEntryIno(m,Jo,o);if(x<0)throw new A(x);this.w64(f+ee,Date.now())}finally{this.inodeWriteUnlock(m)}}if(E){let w=this.inodeOffset(o);this.w32(w+B,this.r32(w+B)-1)}}else if(E){let w=this.inodeOffset(o);this.w32(w+B,this.r32(w+B)-1)}return{source:y,replaced:O}}finally{d!==h&&this.inodeWriteUnlock(h),this.inodeWriteUnlock(d)}}mkdir(e,t=493){this.withNamespaceLock(()=>this.mkdirUnlocked(e,t))}mkdirUnlocked(e,t=493){let{parentIno:r,name:i}=this.pathResolveParent(e),o=_e.encode(i);this.inodeWriteLock(r);try{if(this.dirLookup(r,o)>=0)throw new A(Tt);let a=this.inodeAlloc();if(a<0)throw new A(se);let c=this.inodeOffset(a);this.w32(c+C,H|t),this.w32(c+B,2),this.w64(c+P,0);let u=Date.now();this.w64(c+ir,u),this.w64(c+me,u),this.w64(c+ee,u);let l=this.blockAllocWithGrow();if(l<0)throw this.inodeFree(a),new A(se);this.w32(c+oe,l);let d=l*4096,h=Je(F+1),m=Je(F+2);this.w32(d,a),this.view.setUint16(d+4,h,!0),this.view.setUint16(d+6,1,!0),this.u8[d+F]=46;let f=d+h;this.w32(f,r),this.view.setUint16(f+4,m,!0),this.view.setUint16(f+6,2,!0),this.u8[f+F]=46,this.u8[f+F+1]=46,this.w64(c+P,h+m);let p=this.dirAddEntry(r,o,a);if(p<0)throw this.blockFree(l),this.inodeFree(a),new A(p);let _=this.inodeOffset(r);this.w32(_+B,this.r32(_+B)+1)}finally{this.inodeWriteUnlock(r)}}rmdir(e){this.withNamespaceLock(()=>this.rmdirUnlocked(e))}rmdirUnlocked(e){let{parentIno:t,name:r}=this.pathResolveParent(e);if(Xn(r))throw new A(j);let i=_e.encode(r);this.inodeWriteLock(t);try{let o=this.dirLookup(t,i);if(o<0)throw new A(o);let s=this.inodeOffset(o);if((this.r32(s+C)&W)!==H)throw new A(Fe);let c=!1;this.inodeWriteLock(o);try{if(!this.dirIsEmpty(o))throw new A(Qn);let l=this.dirRemoveEntry(t,i);if(l<0)throw new A(l);c=this.inodeOrphanLocked(o)}finally{this.inodeWriteUnlock(o)}c&&this.inodeFree(o);let u=this.inodeOffset(t);this.w32(u+B,this.r32(u+B)-1)}finally{this.inodeWriteUnlock(t)}}symlink(e,t){this.withNamespaceLock(()=>this.symlinkUnlocked(e,t))}symlinkUnlocked(e,t){let{parentIno:r,name:i}=this.pathResolveParent(t),o=_e.encode(i),s=_e.encode(e);this.inodeWriteLock(r);try{if(this.dirLookup(r,o)>=0)throw new A(Tt);let c=this.inodeAlloc();if(c<0)throw new A(se);let u=this.inodeOffset(c);if(this.w32(u+C,wt|511),this.w32(u+B,1),s.length<=40)this.u8.set(s,u+oe),this.w64(u+P,s.length);else{this.w64(u+P,0);let d=this.inodeWriteData(c,0,s,s.length);if(d!==s.length)throw d>0&&this.inodeTruncate(c,0),this.inodeFree(c),new A(d<0?d:se)}let l=this.dirAddEntry(r,o,c);if(l<0)throw s.length<=40?(this.u8.fill(0,u+oe,u+oe+40),this.w64(u+P,0)):this.inodeTruncate(c,0),this.inodeFree(c),new A(l)}finally{this.inodeWriteUnlock(r)}}chmod(e,t){this.withNamespaceLock(()=>this.chmodUnlocked(e,t))}chmodUnlocked(e,t){let r=this.pathResolve(e,!0);if(r<0)throw new A(r);this.inodeWriteLock(r);try{let i=this.inodeOffset(r),o=this.r32(i+C);this.w32(i+C,o&W|t&4095),this.w64(i+ee,Date.now())}finally{this.inodeWriteUnlock(r)}}fchmod(e,t){let r=this.fdGet(e);if(!r)throw new A(re);this.inodeWriteLock(r.ino);try{let i=this.inodeOffset(r.ino),o=this.r32(i+C);this.w32(i+C,o&W|t&4095),this.w64(i+ee,Date.now())}finally{this.inodeWriteUnlock(r.ino)}}chown(e,t,r){this.withNamespaceLock(()=>this.chownUnlocked(e,t,r))}chownUnlocked(e,t,r){let i=this.pathResolve(e,!0);if(i<0)throw new A(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,r)}finally{this.inodeWriteUnlock(i)}}fchown(e,t,r){let i=this.fdGet(e);if(!i)throw new A(re);this.inodeWriteLock(i.ino);try{this.chownInodeUnlocked(i.ino,t,r)}finally{this.inodeWriteUnlock(i.ino)}}lchown(e,t,r){this.withNamespaceLock(()=>this.lchownUnlocked(e,t,r))}lchownUnlocked(e,t,r){let i=this.pathResolve(e,!1);if(i<0)throw new A(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,r)}finally{this.inodeWriteUnlock(i)}}chownInodeUnlocked(e,t,r){let i=this.inodeOffset(e);t!==Wo&&this.w32(i+sr,t),r!==Wo&&this.w32(i+ar,r);let o=this.r32(i+C);(o&W)===tr&&(o&uu)!==0&&this.w32(i+C,o&~(au|cu)),this.w64(i+ee,Date.now())}utimens(e,t,r,i,o){this.withNamespaceLock(()=>this.utimensUnlocked(e,t,r,i,o))}utimensUnlocked(e,t,r,i,o){let s=this.pathResolve(e,!0);if(s<0)throw new A(s);this.inodeWriteLock(s);try{let a=this.inodeOffset(s),c=1073741823,u=1073741822,l=Date.now();if(r!==u){let d=r===c?l:t*1e3+Math.floor(r/1e6);this.w64(a+ir,d)}if(o!==u){let d=o===c?l:i*1e3+Math.floor(o/1e6);this.w64(a+me,d)}this.w64(a+ee,l)}finally{this.inodeWriteUnlock(s)}}link(e,t){return this.withNamespaceLock(()=>this.linkUnlocked(e,t))}linkUnlocked(e,t){let r=this.pathResolve(e,!1);if(r<0)throw new A(r);let i=this.inodeOffset(r);if((this.r32(i+C)&W)===H)throw new A(yu);let{parentIno:s,name:a}=this.pathResolveParent(t),c=_e.encode(a);this.inodeWriteLock(s);try{if(this.dirLookup(s,c)>=0)throw new A(Tt);let l=this.dirAddEntry(s,c,r);if(l<0)throw new A(l);this.inodeWriteLock(r);try{let d=this.r32(i+B);this.w32(i+B,d+1),this.w64(i+ee,Date.now())}finally{this.inodeWriteUnlock(r)}return{...this.namespaceEntryIdentity(r),linkCount:this.r32(i+B)}}finally{this.inodeWriteUnlock(s)}}readlink(e){return this.withNamespaceLock(()=>this.readlinkUnlocked(e))}readlinkUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new A(t);return this.readSymlinkInodeUnlocked(t)}readSymlinkInodeUnlocked(e){let t=this.inodeOffset(e);if((this.r32(t+C)&W)!==wt)throw new A(j);let i=this.r64(t+P);if(i<=40)return It(this.u8.subarray(t+oe,t+oe+i));this.inodeReadLock(e);try{let o=new Uint8Array(i);return this.inodeReadData(e,0,o,i),ur.decode(o)}finally{this.inodeReadUnlock(e)}}opendir(e){return this.withNamespaceLock(()=>this.opendirUnlocked(e))}opendirUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new A(t);let r=this.inodeOffset(t);if((this.r32(r+C)&W)!==H)throw new A(Fe);let o=this.fdAlloc(t,at,!0);if(o<0)throw new A(o);return o}readdirEntry(e){return this.withNamespaceLock(()=>this.readdirEntryUnlocked(e))}readdirEntryUnlocked(e){let t=this.fdGet(e);if(!t||!t.isDir)throw new A(re);let r=this.inodeOffset(t.ino),i=this.r64(r+P);for(;t.offset=this.r32(Ye))throw new A($);let p=this.r32(ct)*4096;if((this.r32(p+(l>>5)*4)&1<<(l&31))===0)throw new A($);let y=It(this.u8.subarray(u+F,u+F+h)),g=this.buildStat(l);return this.w64(f+ke,m),t.offset=m,{name:y,stat:g}}return null}closedir(e){this.close(e)}readdir(e){let t=this.opendir(e),r=[];try{let i;for(;(i=this.readdirEntry(t))!==null;)i.name!=="."&&i.name!==".."&&r.push(i.name)}finally{this.closedir(t)}return r}writeFile(e,t){let r=typeof t=="string"?_e.encode(t):t,i=this.open(e,Go|cr|dr);try{this.write(i,r)}finally{this.close(i)}}readFile(e){let t=this.open(e,at);try{let r=this.fstat(t),i=new Uint8Array(r.size);return this.read(t,i),i}finally{this.close(t)}}readFileText(e){return ur.decode(this.readFile(e))}};function ns(n,e){let t=new Map,r=new Map;for(let s of n){if(t.has(s.path))throw new Error(`${e} duplicates path ${s.path}`);if(t.set(s.path,s),s.type==="file"){if(!s.inodeGroup)throw new Error(`${e} file ${s.path} has no inode group`);if(r.has(s.inodeGroup))throw new Error(`${e} inode group ${s.inodeGroup} has multiple files`);r.set(s.inodeGroup,s)}}let i=new Set,o=new Map;for(let s of n){if(s.type!=="hardlink"||o.has(s.path))continue;let a=[],c=s,u;for(;c.type==="hardlink";){let d=o.get(c.path);if(d){u=d;break}if(i.has(c.path))throw new Error(`${e} hardlink cycle reaches ${c.path}`);if(i.add(c.path),a.push(c),!c.target)throw new Error(`${e} hardlink ${c.path} has no target`);let h=t.get(c.target);if(!h)throw new Error(`${e} hardlink ${c.path} target ${c.target} is missing`);if(h.type!=="file"&&h.type!=="hardlink"||!c.inodeGroup||h.inodeGroup!==c.inodeGroup||h.size!==c.size||h.mode!==c.mode)throw new Error(`${e} hardlink ${c.path} has an invalid target`);c=h}u??=c.type==="file"?c:void 0;let l=r.get(s.inodeGroup??"");if(!u||u!==l)throw new Error(`${e} hardlink ${s.path} does not resolve to its inode`);for(let d=a.length-1;d>=0;d-=1){let h=a[d];if(r.get(h.inodeGroup??"")!==u)throw new Error(`${e} hardlink ${h.path} does not resolve to its inode`);i.delete(h.path),o.set(h.path,u)}}return{canonicalByGroup:r,canonicalTargetByPath:o}}var D={maxArchiveBytes:268435456,maxExpandedBytes:268435456,maxPayloadBytes:268435456,maxEntries:1e5,maxPathBytes:4096,maxSymlinkTargetBytes:65536,maxStringBytes:8192,maxTransportsPerTree:8,maxActivationCapabilities:32,maxActivationRoots:64,maxActivationCapabilityBytes:255,maxMaterializationAssertions:32,maxMaterializationAssertionBytes:1048576,maxMaterializationRecipes:32,maxMaterializationTransforms:1e5,maxMaterializationDecodedBytes:8388608,maxTransformReplacements:32,maxTransformPatternBytes:8192},be={maxArchiveBytes:512*1024*1024,maxExpandedBytes:512*1024*1024,maxPayloadBytes:512*1024*1024,maxEntries:1e5,maxGroups:512};function is(n,e="Deferred tree collection"){for(let[t,r]of Object.entries(n))if(!Number.isSafeInteger(r)||r<0)throw new Error(`${e} ${t} usage is invalid`);if(n.groups>be.maxGroups)throw new Error(`${e} exceeds the ${be.maxGroups}-group cap`);if(n.archiveBytes>be.maxArchiveBytes)throw new Error(`${e} exceeds the archive-byte cap`);if(n.expandedBytes>be.maxExpandedBytes)throw new Error(`${e} exceeds the expansion cap`);if(n.payloadBytes>be.maxPayloadBytes)throw new Error(`${e} exceeds the payload-byte cap`);if(n.entries>be.maxEntries)throw new Error(`${e} exceeds the entry-count cap`)}function Rt(n,e="Canonical text"){for(let t=0;t57343)){if(r<=56319&&t+1=56320&&n.charCodeAt(t+1)<=57343){t+=1;continue}throw new Error(`${e} must contain only Unicode scalar values`)}}}function lr(n,e){Rt(n),Rt(e);let t=0,r=0;for(;t65535?2:1,r+=o>65535?2:1}return tD.maxEntries)throw new Error("Lazy tree materialization source inventory is unbounded");let t=new Map;for(let[h,m]of e.entries.entries()){let f=ti(m.sourcePath,`Lazy tree materialization source ${h} path`);if(t.has(f))throw new Error(`Lazy tree materialization source repeats ${f}`);if(m.type!=="directory"&&m.type!=="file"&&m.type!=="symlink"&&m.type!=="hardlink")throw new Error(`Lazy tree materialization source ${f} has invalid type`);ds(m.size,`Lazy tree materialization source ${f} byte count`,0,D.maxPayloadBytes),t.set(f,m)}let r=Lt(n,["schema","kind","assertions","recipes","transforms"],"Lazy tree materialization plan");if(r.schema!==1||r.kind!=="archive-byte-transforms-v1")throw new Error("Lazy tree materialization plan has an unsupported identity");let i=0,o=new Set,s=pr(r.assertions,"Lazy tree materialization assertions",0,D.maxMaterializationAssertions).map((h,m)=>{let f=Lt(h,["sourcePath","bytesHex"],`Lazy tree materialization assertion ${m}`),p=ti(f.sourcePath,`Lazy tree materialization assertion ${m} source path`);if(o.has(p))throw new Error(`Lazy tree materialization repeats assertion ${p}`);o.add(p);let _=t.get(p);if(_?.type!=="file")throw new Error(`Lazy tree materialization assertion ${p} is not a regular source`);let y=hr(f.bytesHex,`Lazy tree materialization assertion ${p} bytes`,D.maxMaterializationAssertionBytes,!0);if(i=jr(i,y.length/2),y.length/2!==_.size)throw new Error(`Lazy tree materialization assertion ${p} size differs from source`);return{sourcePath:p,bytesHex:y}}),a=new Map,c=pr(r.recipes,"Lazy tree materialization recipes",0,D.maxMaterializationRecipes).map((h,m)=>{let f=us(h,`Lazy tree materialization recipe ${m}`);if(a.has(f.recipe.id))throw new Error(`Lazy tree materialization duplicates recipe ${f.recipe.id}`);return i=jr(i,f.decodedBytes),a.set(f.recipe.id,f.recipe),f.recipe}),u=new Set,l=new Set,d=pr(r.transforms,"Lazy tree materialization transforms",0,D.maxMaterializationTransforms).map((h,m)=>{let f=Lt(h,["sourcePath","recipe","input","output"],`Lazy tree materialization transform ${m}`),p=ti(f.sourcePath,`Lazy tree materialization transform ${m} source path`);if(u.has(p))throw new Error(`Lazy tree materialization repeats transform ${p}`);u.add(p);let _=t.get(p);if(_?.type!=="file")throw new Error(`Lazy tree materialization transform ${p} is not a regular source`);let y=ii(f.recipe,`Lazy tree materialization transform ${p} recipe`,D.maxStringBytes);if(!a.has(y))throw new Error(`Lazy tree materialization transform ${p} has no recipe ${y}`);l.add(y);let g=os(f.input,`Lazy tree materialization transform ${p} input`),E=os(f.output,`Lazy tree materialization transform ${p} output`);if(g.bytes!==_.size)throw new Error(`Lazy tree materialization transform ${p} input size differs from source`);return{sourcePath:p,recipe:y,input:g,output:E}});if(s.length===0&&d.length===0)throw new Error("Lazy tree materialization plan has no assertions or transforms");if(c.some(h=>!l.has(h.id)))throw new Error("Lazy tree materialization plan contains an unused recipe");if(!ri(s.map(h=>h.sourcePath))||!ri(c.map(h=>h.id))||!ri(d.map(h=>h.sourcePath)))throw new Error("Lazy tree materialization plan is not in canonical order");return{schema:1,kind:"archive-byte-transforms-v1",assertions:s,recipes:c,transforms:d}}function fr(n){let e=hr(n,"Materialization bytes",D.maxMaterializationDecodedBytes,!0),t=new Uint8Array(e.length/2);for(let r=0;rD.maxPayloadBytes)throw new Error("Lazy tree byte transform exceeds its source-byte limit");let t=us(e,"Lazy tree byte transform recipe").recipe,r=n;for(let i of t.replacements)r=Au(r,fr(i.matchHex),fr(i.replacementHex));for(let i of t.rejectHex)if(zu(r,fr(i)))throw new Error(`Lazy tree byte transform retains rejected byte sequence ${i}`);return r}function us(n,e){let t=Lt(n,["id","replacements","rejectHex"],e),r=ii(t.id,`${e} id`,D.maxStringBytes);if(!xu(r))throw new Error(`${e} id is invalid`);let i=0,o=pr(t.replacements,`${e} replacements`,0,D.maxTransformReplacements).map((a,c)=>{let u=Lt(a,["matchHex","replacementHex"],`${e} replacement ${c}`),l=hr(u.matchHex,`${e} match`,D.maxTransformPatternBytes,!1),d=hr(u.replacementHex,`${e} replacement`,D.maxTransformPatternBytes,!0);return i=jr(i,l.length/2+d.length/2),{matchHex:l,replacementHex:d}}),s=pr(t.rejectHex,`${e} rejected patterns`,0,D.maxTransformReplacements).map((a,c)=>{let u=hr(a,`${e} rejected pattern ${c}`,D.maxTransformPatternBytes,!1);return i=jr(i,u.length/2),u});if(o.length===0&&s.length===0||new Set(s).size!==s.length)throw new Error(`${e} is empty or ambiguous`);return{recipe:{id:r,replacements:o,rejectHex:s},decodedBytes:i}}function Au(n,e,t){let r=0;for(let u=0;u<=n.byteLength-e.byteLength;)ni(n,e,u)?(r+=1,u+=e.byteLength):u+=1;if(r===0)return n;let i=t.byteLength-e.byteLength,o=n.byteLength+r*i;if(!Number.isSafeInteger(o)||o<0||o>D.maxPayloadBytes)throw new Error("Lazy tree byte transform exceeds its transformed-byte limit");let s=new Uint8Array(o),a=0,c=0;for(;an.byteLength)return!1;for(let t=0;t<=n.byteLength-e.byteLength;t+=1)if(ni(n,e,t))return!0;return!1}function ni(n,e,t){if(t+e.byteLength>n.byteLength)return!1;for(let r=0;rs!==o[a]))throw new Error(`${t} has unexpected fields`);return r}function pr(n,e,t,r){if(!Array.isArray(n)||n.lengthr)throw new Error(`${e} must contain ${t} to ${r} items`);return n}function ii(n,e,t){if(typeof n!="string")throw new Error(`${e} is invalid or exceeds ${t} bytes`);if(Rt(n,e),n.length===0||n.includes("\0")||new TextEncoder().encode(n).byteLength>t)throw new Error(`${e} is invalid or exceeds ${t} bytes`);return n}function ds(n,e,t,r){if(!Number.isSafeInteger(n)||Number(n)r)throw new Error(`${e} must be an integer between ${t} and ${r}`);return Number(n)}function hr(n,e,t,r){if(typeof n!="string"||!r&&n.length===0||n.length%2!==0||n.length/2>t||!ls(n))throw new Error(`${e} is not canonical bounded hexadecimal bytes`);return n}function ti(n,e){let t=ii(n,e,D.maxPathBytes);if(t.startsWith("/")||t.includes("\\")||t.split("/").some(r=>r===""||r==="."||r===".."))throw new Error(`${e} is not a canonical relative path`);return t}function jr(n,e){let t=n+e;if(!Number.isSafeInteger(t)||t>D.maxMaterializationDecodedBytes)throw new Error("Lazy tree materialization plan exceeds its decoded byte limit");return t}function ri(n){return n.every((e,t)=>t===0||lr(n[t-1],e)<0)}function xu(n){if(!ss(n.charCodeAt(0)))return!1;for(let e=1;e=97&&n<=122||n>=48&&n<=57}function ls(n,e){if(e!==void 0&&n.length!==e)return!1;for(let t=0;t=48&&r<=57)&&!(r>=97&&r<=102))return!1}return!0}var lt=Reflect.apply,hd=Object.create,md=Object.defineProperties,vi=Object.freeze,yd=Object.getOwnPropertyDescriptors,_d=Object.setPrototypeOf;var Ms=SharedArrayBuffer,gd=Uint8Array,Ed=Uint8Array.prototype.set,Sd=WeakSet.prototype.add,Bf=WeakSet.prototype.has,wd=WeakMap.prototype.get,Od=WeakMap.prototype.set,$f=Set.prototype.has,Uf=Map.prototype.get;var Ad=Number.isInteger,zd=TypeError,xd=le.mount,Id=le.mkfs,Td=le.prototype.snapshotState,Rd=new WeakSet,Ds=new WeakMap,Ld=1;function ea(n){let e=hd(null);return md(e,yd(n)),vi(e)}var bd=ea(le.prototype),Wf=vi({kind:"nosuid"}),Gf=vi({kind:"trusted-root-product",guestWritable:!1,stableExecutableIdentity:!0}),vd=Symbol("DeferredTreeMaterializationHandle"),Er=[40,181,47,253],zi=1447449417,xi=1,gi=1,nn=2,Ei=4,Si=8,ye=16,{S_IFMT:Ee,S_IFREG:kt,S_IFDIR:Qe,S_IFLNK:Sr}=ie,{DT_UNKNOWN:Pd,DT_REG:kd,DT_DIR:Fd,DT_LNK:Nd}=wo,Cd=He.O_RDONLY,Hf=He.O_ACCMODE,Vf=He.O_CREAT,qf=He.O_TRUNC,Zf=Eo.W_OK,Ks=He.O_WRONLY|He.O_CREAT|He.O_TRUNC,Md=1024*1024,Dd=16*1024*1024,Ct=64*1024,on=16*1024*1024,sn=16*1024*1024,Bs=D.maxArchiveBytes,Kd=D.maxExpandedBytes,an=D.maxPayloadBytes,Bd=2,$d=4,Mt=D.maxEntries,ta=be.maxGroups,ln=D.maxPathBytes,ra=D.maxSymlinkTargetBytes,Pi=D.maxStringBytes,Ud=D.maxActivationCapabilities,Wd=D.maxActivationRoots,$s=D.maxActivationCapabilityBytes,Us=4294967294,Ws=3,Gd=250,na=5e3,Ii=/^[0-9a-f]{64}$/,wr="kandelo-legacy-zip-v1",Or="kandelo-deferred-tree-v1",Ti="kandelo-deferred-tree-v2",ft="kandelo-deferred-tree-v3",Hd=new Set(["ECONNABORTED","ECONNREFUSED","ECONNRESET","EHOSTUNREACH","ENETDOWN","ENETRESET","ENETUNREACH","EPIPE","ETIMEDOUT","EAI_AGAIN","UND_ERR_CONNECT_TIMEOUT","UND_ERR_HEADERS_TIMEOUT","UND_ERR_SOCKET"]),cn=class extends Error{constructor(t,r){super(`HTTP ${t}`);this.status=t;this.retryAfterMs=r;this.name="LazyHttpResponseError"}status;retryAfterMs};function Ar(n){if(typeof n!="string"||!n.startsWith("/")||new TextEncoder().encode(n).byteLength>ln||n.includes("\0")||n.includes("\\"))throw new Error(`Lazy archive mount prefix must be an absolute POSIX path: ${JSON.stringify(n)}`);let e=n.replace(/\/+$/,"");if(e==="")return"/";if(e.slice(1).split("/").some(r=>r===""||r==="."||r===".."))throw new Error(`Lazy archive mount prefix is not canonical: ${JSON.stringify(n)}`);return e}function Vd(n,e,t,r){let i=Ar(t),o=new Map,s=e.map(a=>{let c=a.fileName,u=`Lazy archive ${JSON.stringify(n)} member ${JSON.stringify(c)}`;if(c.length===0)throw new Error(`${u} has an empty path`);if(c.includes("\0"))throw new Error(`${u} contains a NUL byte`);if(c.includes("\\"))throw new Error(`${u} contains a backslash`);if(c.startsWith("/")||/^[A-Za-z]:\//.test(c))throw new Error(`${u} must be relative, not absolute`);if(a.isDirectory&&a.isSymlink)throw new Error(`${u} has conflicting directory and symlink types`);if(a.isDirectory!==c.endsWith("/"))throw new Error(`${u} has inconsistent directory metadata`);let l=a.isDirectory?c.slice(0,-1):c,d=l.split("/");if(l.length===0||d.some(h=>h===""||h==="."||h===".."))throw new Error(`${u} is not a canonical relative POSIX path`);if(o.has(l))throw new Error(`${u} collides with another member at ${JSON.stringify(l)}`);if(a.isSymlink&&!r?.has(c))throw new Error(`Lazy archive symlink target was not provided: ${c}`);return o.set(l,a),{entry:a,archivePath:l,vfsPath:i==="/"?`/${l}`:`${i}/${l}`}});for(let{archivePath:a}of s){let c=a.split("/");for(let u=1;uCt)throw new Error(`VFS image metadata exceeds ${Ct} bytes`);let e;try{e=JSON.parse(new TextDecoder().decode(n))}catch(t){let r=t instanceof Error?t.message:String(t);throw new Error(`Invalid VFS image metadata JSON: ${r}`)}return ki(e)}function Zd(n){if(n===null)return new Uint8Array(0);let e=ki(n),t=new TextEncoder().encode(JSON.stringify(e));if(t.byteLength>Ct)throw new Error(`VFS image metadata exceeds ${Ct} bytes`);return t}function Yd(n){return n.byteLength>=Er.length&&n[0]===Er[0]&&n[1]===Er[1]&&n[2]===Er[2]&&n[3]===Er[3]?El(n):n}function Qr(n){let e=Yd(n);if(e.byteLengthon)throw new Error(`VFS image lazy metadata exceeds ${on} bytes`);if(n.byteLengthsn)throw new Error(`VFS image lazy archive metadata exceeds ${sn} bytes`);if(n.byteLength=0?r:void 0}function Jd(n){return n===408||n===429||n>=500&&n<=599}function Qd(n,e=Date.now()){let t=n?.get("retry-after")?.trim();if(!t)return;let r;if(/^\d+$/.test(t))r=Number(t)*1e3;else{let i=Date.parse(t);if(!Number.isFinite(i))return;r=Math.max(0,i-e)}if(!(!Number.isSafeInteger(r)||r<0))return Math.min(r,na)}function el(n){if(!(typeof n!="object"||n===null||!("cause"in n)))return n.cause}function ia(n){if(!(typeof n!="object"||n===null||!("name"in n)))return typeof n.name=="string"?n.name:void 0}function oa(n){if(!(typeof n!="object"||n===null||!("code"in n)))return typeof n.code=="string"?n.code:void 0}function sa(n,e){let t=new Set,r=n;for(let i=0;r!==void 0&&i<8;i+=1){if(t.has(r))return!1;if(t.add(r),e(r))return!0;r=el(r)}return!1}function aa(n){return sa(n,e=>ia(e)==="AbortError"||oa(e)==="ABORT_ERR")}function tl(n){return aa(n)?!1:sa(n,e=>{let t=ia(e),r=oa(e);return e instanceof TypeError||t==="NetworkError"||t==="TimeoutError"||r!==void 0&&Hd.has(r)})}function rl(n,e){if(n instanceof cn){if(!Jd(n.status))return null;if(n.retryAfterMs!==void 0)return n.retryAfterMs}else if(!tl(n))return null;return Math.min(Gd*2**e,na)}function te(n){if(n?.aborted)throw n.reason}function nl(n,e){return te(e),n===0?Promise.resolve():new Promise((t,r)=>{let i=setTimeout(()=>a(!1),n),o=()=>a(!0,e.reason),s=!1;function a(c,u){s||(s=!0,clearTimeout(i),e?.removeEventListener("abort",o),c?r(u):t())}e?.addEventListener("abort",o,{once:!0}),e?.aborted&&o()})}async function wi(n,e){try{await n.body?.cancel(e)}catch{}}function il(n,e){if(n.length===1)return n[0];let t=new Uint8Array(e),r=0;for(let i of n)t.set(i,r),r+=i.byteLength;return t}function zr(n){if(n===void 0)return;if(typeof n!="object"||n===null||Array.isArray(n))throw new Error("Lazy archive integrity must be an object");let e=n;if(Object.keys(e).length!==2||!("sha256"in e)||!("bytes"in e))throw new Error("Lazy archive integrity has unexpected fields");if(typeof e.sha256!="string"||!Ii.test(e.sha256))throw new Error("Lazy archive integrity has an invalid SHA-256 digest");if(!Number.isSafeInteger(e.bytes)||Number(e.bytes)<=0||Number(e.bytes)>Bs)throw new Error(`Lazy archive integrity byte count must be between 1 and ${Bs}`);return{sha256:e.sha256,bytes:Number(e.bytes)}}function et(n,e,t){if(typeof n!="object"||n===null||Array.isArray(n))throw new Error(`${t} must be an object`);let r=n;if(Object.keys(r).length!==e.length||e.some(o=>!Object.prototype.hasOwnProperty.call(r,o)))throw new Error(`${t} has unexpected or missing fields`);return r}function Ri(n,e,t,r){if(typeof n!="object"||n===null||Array.isArray(n))throw new Error(`${r} must be an object`);let i=n,o=new Set(e);if(Object.keys(i).some(s=>!o.has(s))||t.some(s=>!Object.prototype.hasOwnProperty.call(i,s)))throw new Error(`${r} has unexpected or missing fields`);return i}function Me(n,e,t,r){if(!Array.isArray(n)||n.lengthr)throw new Error(`${e} must contain ${t} to ${r} items`);return n}function Se(n,e,t){if(typeof n!="string")throw new Error(`${e} is invalid or exceeds ${t} bytes`);if(Rt(n,e),n.length===0||n.includes("\0")||new TextEncoder().encode(n).byteLength>t)throw new Error(`${e} is invalid or exceeds ${t} bytes`);return n}function ce(n,e,t,r){if(!Number.isSafeInteger(n)||Number(n)r)throw new Error(`${e} must be an integer between ${t} and ${r}`);return Number(n)}function un(n,e=1){let t=n,r=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.source!==void 0,i=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.modePolicy!==void 0,o=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.materialization!==void 0,s=et(n,["decoder","mediaType","sha256","bytes","expandedBytes","sourceEntryCount","transports",...i?["modePolicy"]:[],...r?["source"]:[],...o?["materialization"]:[]],"Lazy tree content"),a=s.decoder==="zip-v1"?"application/zip":s.decoder==="tar-gzip-v1"?"application/vnd.oci.image.layer.v1.tar+gzip":null;if(a===null||s.mediaType!==a)throw new Error("Lazy tree decoder and media type are inconsistent");let c=zr({sha256:s.sha256,bytes:s.bytes});if(!c)throw new Error("Lazy tree integrity is required");let u=Me(s.transports,"Lazy tree transports",e,D.maxTransportsPerTree).map((p,_)=>Se(p,`Lazy tree transport ${_}`,Pi));if(new Set(u).size!==u.length)throw new Error("Lazy tree transports contain duplicates");let l=ce(s.expandedBytes,"Lazy tree expanded byte count",0,Kd),d=ce(s.sourceEntryCount,"Lazy tree source entry count",1,Mt),h=r?al(s.source,s.decoder):void 0,m=o?as(s.materialization,h):void 0,f=i?s.modePolicy:void 0;if(f!==void 0&&(f!=="portable-posix-v1"||s.decoder!=="zip-v1"||r))throw new Error("Lazy tree mode policy is invalid for its decoder");if(h!==void 0&&h.entries.length!==d)throw new Error("Lazy tree source inventory count differs from its content");return{decoder:s.decoder,mediaType:a,sha256:c.sha256,bytes:c.bytes,expandedBytes:l,sourceEntryCount:d,transports:u,...f===void 0?{}:{modePolicy:f},...h===void 0?{}:{source:h},...m===void 0?{}:{materialization:m}}}function ca(n){let e={groups:n.length,archiveBytes:0,expandedBytes:0,payloadBytes:0,entries:0};for(let t of n)t.content===void 0||t.inventory===void 0||(e.archiveBytes+=t.content.bytes,e.expandedBytes+=t.content.expandedBytes,e.payloadBytes+=t.inventory.filter(r=>r.type==="file").reduce((r,i)=>r+i.size,0),e.entries+=t.inventory.length+(t.content.source?.entries.length??0));return e}function Li(n){is(n,"Serialized lazy tree collection")}function ol(n){Li(ca(n))}function sl(n){let e=new Map;for(let t of n){let r=t.activation?.atomicGroup;if(r===void 0)continue;if(!Ft(r))throw new Error(`Serialized lazy atomic activation group ${r.id} is unsealed`);let i=e.get(r.id);if(i===void 0)i={expectedCount:r.expectedCount,cohortSha256:r.cohortSha256,members:new Set,descriptors:new Set},e.set(r.id,i);else if(i.expectedCount!==r.expectedCount||i.cohortSha256!==r.cohortSha256)throw new Error(`Serialized lazy atomic activation group ${r.id} has inconsistent seals`);if(i.members.has(r.member)||i.descriptors.has(r.descriptorSha256))throw new Error(`Serialized lazy atomic activation group ${r.id} duplicates a member`);i.members.add(r.member),i.descriptors.add(r.descriptorSha256)}for(let[t,r]of e)if(r.members.size!==r.expectedCount)throw new Error(`Serialized lazy atomic activation group ${t} has ${r.members.size} of ${r.expectedCount} members`)}function qs(n){for(let[e,t]of n.entries())if(t.kind===Or||t.kind===Ti||t.kind===ft)la(t,t.kind);else if(t.kind===wr)bi(t,!1);else throw new Error(`Serialized lazy archive group ${e} has an unsupported kind`);ol(n),sl(n)}function al(n,e){if(e!=="zip-v1"&&e!=="tar-gzip-v1")throw new Error("Lazy tree source inventory requires a supported archive decoder");let t=et(n,["schema","kind","entries"],"Lazy tree source inventory");if(t.schema!==1||t.kind!=="archive-source-inventory-v1")throw new Error("Lazy tree source inventory has an unsupported identity");let r=new Map,i=Me(t.entries,"Lazy tree source entries",1,Mt).map((s,a)=>{let c=s,u=typeof c=="object"&&c!==null&&!Array.isArray(c)?c.type:void 0,l=u==="directory"||u==="file"?["sourcePath","type","mode","size"]:u==="symlink"||u==="hardlink"?["sourcePath","type","mode","size","target"]:null;if(l===null)throw new Error(`Lazy tree source entry ${a} has invalid type`);let d=et(s,l,`Lazy tree source entry ${a}`),h=we(d.sourcePath,!1,`Lazy tree source entry ${a} path`);if(r.has(h))throw new Error(`Lazy tree source inventory duplicates ${h}`);let m=ce(d.mode,`Lazy tree source entry ${h} mode`,0,ie.S_MODE_BITS),f=ce(d.size,`Lazy tree source entry ${h} size`,0,an),p;if((u==="directory"||u==="symlink"||u==="hardlink")&&f!==0)throw new Error(`Lazy tree source ${h} has payload for ${String(u)}`);u==="symlink"?p=Se(d.target,`Lazy tree source symlink ${h} target`,ra):u==="hardlink"&&(p=we(d.target,!1,`Lazy tree source hardlink ${h} target`));let _={sourcePath:h,type:u,mode:m,size:f,...p===void 0?{}:{target:p}};return r.set(h,_),_}),o=i.map(s=>s.sourcePath);if(o.some((s,a)=>a>0&&lr(o[a-1],s)>=0))throw new Error("Lazy tree source inventory is not in canonical path order");return{schema:1,kind:"archive-source-inventory-v1",entries:i}}function cl(n){let e=new Map(n.map(r=>[r.sourcePath,r])),t=new Map;for(let r of n){if(r.type!=="hardlink"||t.has(r.sourcePath))continue;let i=[],o=new Set,s=r,a;for(;s.type==="hardlink"&&(a=t.get(s.sourcePath),a===void 0);){if(o.has(s.sourcePath))throw new Error(`Lazy tree source hardlink cycle includes ${s.sourcePath}`);o.add(s.sourcePath),i.push(s);let c=e.get(s.target);if(c===void 0)throw new Error(`Lazy tree source hardlink ${s.sourcePath} target is absent`);if(c.type!=="file"&&c.type!=="hardlink")throw new Error(`Lazy tree source hardlink ${s.sourcePath} target is not regular`);s=c}a===void 0&&(a=s);for(let c of i)t.set(c.sourcePath,a)}return t}function we(n,e,t,r=!1){if(typeof n!="string"||n.length===0||new TextEncoder().encode(n).byteLength>ln||n.includes("\0")||n.includes("\\")||n.startsWith("/")!==e)throw new Error(`${t} is not a canonical ${e?"absolute":"relative"} path`);if(r&&e&&n==="/")return n;if(n.slice(e?1:0).split("/").some(o=>o===""||o==="."||o===".."))throw new Error(`${t} has an unsafe path segment`);return n}function ua(n){let e=typeof n=="object"&&n!==null&&!Array.isArray(n)?n:null,t=e!==null&&(Object.hasOwn(e,"descriptorSha256")||Object.hasOwn(e,"expectedCount")||Object.hasOwn(e,"cohortSha256")),r=et(n,t?["id","member","descriptorSha256","expectedCount","cohortSha256"]:["id","member"],"Lazy tree atomic activation membership"),i=Se(r.id,"Lazy tree atomic activation group",$s),o=Se(r.member,"Lazy tree atomic activation member",$s);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(i)||!/^[a-z0-9][a-z0-9:+._/-]*$/.test(o)||o.includes("//")||o.endsWith("/"))throw new Error("Lazy tree atomic activation membership is invalid");if(!t)return{id:i,member:o};let s=Se(r.descriptorSha256,"Lazy tree atomic member descriptor digest",64),a=Se(r.cohortSha256,"Lazy tree atomic cohort digest",64);if(!Ii.test(s)||!Ii.test(a))throw new Error("Lazy tree atomic activation digest is invalid");return{id:i,member:o,descriptorSha256:s,expectedCount:ce(r.expectedCount,"Lazy tree atomic activation expected member count",1,ta),cohortSha256:a}}function Ft(n){return n.descriptorSha256!==void 0&&n.expectedCount!==void 0&&n.cohortSha256!==void 0}function ul(n){let e=et(n,["uid","gid"],"Lazy tree registration owner");return{uid:ce(e.uid,"Lazy tree registration owner uid",0,Us),gid:ce(e.gid,"Lazy tree registration owner gid",0,Us)}}function da(n,e,t,r,i=1){let o=un(n,i),s=Ar(t),a=["mode","capabilities","roots",...typeof r=="object"&&r!==null&&!Array.isArray(r)&&Object.hasOwn(r,"atomicGroup")?["atomicGroup"]:[]],c=et(r,a,"Lazy tree activation");if(c.mode!=="boot-prefetch"&&c.mode!=="first-use")throw new Error("Lazy tree activation mode is invalid");let u=Me(c.capabilities,"Lazy tree activation capabilities",1,Ud).map((z,x)=>{let I=Se(z,`Lazy tree activation capability ${x}`,D.maxActivationCapabilityBytes);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(I))throw new Error(`Lazy tree activation capability ${x} is invalid`);return I}),l=Me(c.roots,"Lazy tree activation roots",1,Wd).map((z,x)=>we(z,!0,`Lazy tree activation root ${x}`,!0));if(new Set(u).size!==u.length||new Set(l).size!==l.length)throw new Error("Lazy tree activation contains duplicates");let d=c.atomicGroup===void 0?void 0:ua(c.atomicGroup);if(d!==void 0&&c.mode!=="first-use")throw new Error("Lazy tree atomic activation group requires a valid first-use identity");let h={mode:c.mode,capabilities:u,roots:l,...d===void 0?{}:{atomicGroup:d}},m=Me(e,"Lazy tree inventory",1,Mt),f=[],p=new Map,_=new Map,y=o.source===void 0?void 0:new Map(o.source.entries.map(z=>[z.sourcePath,z])),g=o.source===void 0?void 0:cl(o.source.entries),E=new Map(o.materialization?.transforms.map(z=>[z.sourcePath,z])??[]),O=0;for(let[z,x]of m.entries()){if(typeof x!="object"||x===null||Array.isArray(x))throw new Error(`Lazy tree entry ${z} must be an object`);let I=x.type,R=I==="directory"?["vfsPath","sourcePath","type","mode","size"]:I==="file"?["vfsPath","sourcePath","type","mode","size","inodeGroup"]:I==="symlink"?["vfsPath","sourcePath","type","mode","size","target"]:I==="hardlink"?["vfsPath","sourcePath","type","mode","size","target","inodeGroup"]:null;if(!R)throw new Error(`Lazy tree entry ${z} has an invalid type`);let L=et(x,[...R,...y===void 0?[]:["materialization"]],`Lazy tree entry ${z}`),v=we(L.vfsPath,!0,`Lazy tree entry ${z} VFS path`),Z=we(L.sourcePath,!1,`Lazy tree entry ${z} source path`),N=y===void 0?void 0:L.materialization;if(y!==void 0&&N!=="archive"&&N!=="archive-copy"&&N!=="archive-copy-mode"&&N!=="descriptor")throw new Error(`Lazy tree entry ${v} has invalid materialization provenance`);if(s!=="/"&&v!==s&&!v.startsWith(`${s}/`))throw new Error(`Lazy tree entry ${v} escapes its mount prefix`);if(p.has(v))throw new Error(`Lazy tree duplicates VFS path ${v}`);let k=ce(L.mode,`Lazy tree entry ${v} mode`,0,ie.S_MODE_BITS),G=ce(L.size,`Lazy tree entry ${v} size`,0,an),ue,Oe;if(I==="directory"){if(G!==0)throw new Error(`Lazy tree directory ${v} has nonzero size`)}else if(I==="symlink"){if(ue=Se(L.target,`Lazy tree symlink ${v} target`,ra),new TextEncoder().encode(ue).byteLength!==G)throw new Error(`Lazy tree symlink ${v} size differs from its target`)}else Oe=Se(L.inodeGroup,`Lazy tree entry ${v} inode group`,ln),I==="hardlink"&&(ue=we(L.target,!0,`Lazy tree hardlink ${v} target`));if(I!=="hardlink"&&(O+=G,O>an))throw new Error("Lazy tree inventory exceeds the expansion limit");let M={vfsPath:v,sourcePath:Z,...N===void 0?{}:{materialization:N},type:I,mode:k,size:G,...ue===void 0?{}:{target:ue},...Oe===void 0?{}:{inodeGroup:Oe}};if(y===void 0){let de=_.get(Z);if(de){if(o.decoder!=="zip-v1"||M.type!=="hardlink"||de.inodeGroup!==M.inodeGroup)throw new Error(`Lazy tree duplicates source path ${Z}`)}else{if(o.decoder==="zip-v1"&&M.type==="hardlink")throw new Error(`Lazy ZIP hardlink ${v} does not reuse a canonical source path`);_.set(Z,M)}}else if(M.materialization==="descriptor"){if(M.type!=="directory"&&M.type!=="symlink")throw new Error(`Lazy tree descriptor entry ${v} is not structural`);if(y.has(Z))throw new Error(`Lazy tree descriptor entry ${v} impersonates a source member`)}else{let de=y.get(Z);if(de===void 0)throw new Error(`Lazy tree entry ${v} names absent source ${Z}`);if(M.materialization==="archive-copy"||M.materialization==="archive-copy-mode"){if(M.type!=="file"||de.type!=="file"||M.materialization==="archive-copy"&&M.mode!==de.mode)throw new Error(`Lazy tree archive copy ${v} differs from its source`)}else if(de.type!==M.type||M.type==="symlink"&&de.target!==M.target||M.type!=="hardlink"&&de.mode!==M.mode)throw new Error(`Lazy tree archive entry ${v} differs from its source`)}f.push(M),p.set(v,M)}for(let z of f){let x=z.vfsPath.split("/").filter(Boolean);for(let I=1;I({path:z.vfsPath,type:z.type,mode:z.mode,size:z.size,target:z.target,inodeGroup:z.inodeGroup})),"Lazy tree");if(y!==void 0){let z=new Set;for(let x of f){if(x.materialization==="descriptor"||x.type!=="file"&&x.type!=="hardlink")continue;let I=y.get(x.sourcePath),R=I.type==="file"?I:g.get(I.sourcePath);R?.type==="file"&&z.add(R.sourcePath);let L=R?.type==="file"?E.get(R.sourcePath):void 0;if(R?.type!=="file"||x.size!==(L?.output.bytes??R.size))throw new Error(`Lazy tree archive entry ${x.vfsPath} differs from its source`)}for(let x of E.keys())if(!z.has(x))throw new Error(`Lazy tree materialization transform ${x} has no destination`);for(let x of f){if(x.type!=="hardlink"||x.materialization!=="archive")continue;let I=y.get(x.sourcePath),R=p.get(x.target),L=g.get(I.sourcePath);if(I.target!==R?.sourcePath||L?.type!=="file"||L.mode!==x.mode||R?.mode!==x.mode)throw new Error(`Lazy tree hardlink ${x.vfsPath} differs from its source`)}}if(o.sourceEntryCount!==(y===void 0?_.size:y.size))throw new Error("Lazy tree source entry count differs from its inventory");if(o.source===void 0&&o.expandedBytesx.vfsPath===z||x.vfsPath.startsWith(`${z}/`)))throw new Error(`Lazy tree activation root ${z} is not owned by its inventory`);let w=new Map;for(let z of f)z.type==="file"&&w.set(z.inodeGroup,z);if(w.size!==S.canonicalByGroup.size)throw new Error("Lazy tree regular inode inventory is inconsistent");return{content:o,entries:f,mountPrefix:s,activation:h,canonicalByGroup:w}}function dn(n){return JSON.stringify([n.sourcePath,n.type,n.inodeGroup,n.target])}function bi(n,e){let t=Ri(n,["kind","content","url","mountPrefix","integrity","materialized","entries"],["url","mountPrefix","materialized","entries"],"Serialized legacy lazy archive");if(t.kind===void 0){if(!e)throw new Error("Serialized lazy archive is missing its kind discriminator")}else if(t.kind!==wr)throw new Error("Serialized legacy lazy archive has an unsupported kind");let r=Se(t.url,"Serialized legacy lazy archive URL",Pi),i=Ar(t.mountPrefix),o=zr(t.integrity);if(t.content!==void 0){if(!e||t.kind!==void 0)throw new Error("Typed legacy lazy archives cannot carry generic content");let c=un(t.content);if(c.decoder!=="zip-v1"||c.transports.length!==1||c.transports[0]!==r||!o||c.sha256!==o.sha256||c.bytes!==o.bytes)throw new Error("Untagged legacy ZIP content identity is inconsistent")}if(t.materialized!==!1)throw new Error("Serialized legacy lazy archive must describe pending content");let s=new Set,a=Me(t.entries,"Serialized legacy lazy archive entries",1,Mt).map((c,u)=>{let l=Ri(c,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","size","isSymlink","deleted"],`Serialized legacy lazy archive entry ${u}`),d=we(l.vfsPath,!0,`Serialized legacy lazy archive entry ${u} VFS path`);if(s.has(d))throw new Error(`Serialized legacy lazy archive duplicates path ${d}`);s.add(d);let h=ce(l.ino,`Serialized legacy lazy archive entry ${d} inode`,1,Number.MAX_SAFE_INTEGER),m=l.generation===void 0?void 0:ce(l.generation,`Serialized legacy lazy archive entry ${d} generation`,0,Number.MAX_SAFE_INTEGER),f=l.dataSequence===void 0?void 0:ce(l.dataSequence,`Serialized legacy lazy archive entry ${d} data sequence`,0,Number.MAX_SAFE_INTEGER),p=ce(l.size,`Serialized legacy lazy archive entry ${d} size`,0,an);if(l.isSymlink!==!1||l.deleted!==!1||l.materialized!==void 0&&l.materialized!==!1)throw new Error(`Serialized legacy lazy archive entry ${d} is not pending`);if(l.type!==void 0&&l.type!=="file")throw new Error(`Serialized legacy lazy archive entry ${d} has an invalid type`);let _=l.archivePath===void 0?void 0:we(l.archivePath,!1,`Serialized legacy lazy archive entry ${d} archive path`),y=l.sourcePath===void 0?void 0:we(l.sourcePath,!1,`Serialized legacy lazy archive entry ${d} source path`),g=l.inodeGroup===void 0?void 0:Se(l.inodeGroup,`Serialized legacy lazy archive entry ${d} inode group`,ln);if(l.target!==void 0)throw new Error(`Serialized legacy lazy archive entry ${d} has a link target`);return{vfsPath:d,ino:h,...m===void 0?{}:{generation:m},...f===void 0?{}:{dataSequence:f},size:p,isSymlink:!1,deleted:!1,materialized:!1,..._===void 0?{}:{archivePath:_},...y===void 0?{}:{sourcePath:y},type:"file",...g===void 0?{}:{inodeGroup:g}}});return{kind:wr,url:r,mountPrefix:i,...o===void 0?{}:{integrity:o},materialized:!1,entries:a}}function la(n,e){let t=et(n,["kind","content","inventory","activation","url","mountPrefix","integrity","materialized","entries"],"Serialized lazy tree");if(t.kind!==e)throw new Error("Serialized lazy tree has an unsupported kind");let r=da(t.content,t.inventory,t.mountPrefix,t.activation);if(e!==ft&&e===Or!=(r.content.source===void 0))throw new Error(e===Or?"Serialized deferred-tree-v1 cannot contain complete source metadata":"Serialized deferred-tree-v2 requires complete source metadata");let i=r.activation.atomicGroup;if(e===ft?i===void 0||!Ft(i):i!==void 0)throw new Error(e===ft?"Serialized deferred-tree-v3 requires a sealed atomic activation":"Atomic activation requires serialized deferred-tree-v3");let o=Se(t.url,"Serialized lazy tree URL",Pi);if(o!==r.content.transports[0])throw new Error("Serialized lazy tree URL differs from its primary transport");let s=zr(t.integrity);if(!s||s.sha256!==r.content.sha256||s.bytes!==r.content.bytes)throw new Error("Serialized lazy tree integrity differs from its content");if(t.materialized!==!1)throw new Error("Serialized lazy tree must describe pending content");let a=new Map(r.entries.map(h=>[h.vfsPath,h])),c=new Map(r.entries.map(h=>[dn(h),h])),u=Me(t.entries,"Serialized lazy tree entries",0,Mt),l=new Set,d=u.map((h,m)=>{let f=Ri(h,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup"],`Serialized lazy tree entry ${m}`),p=we(f.vfsPath,!0,`Serialized lazy tree entry ${m} VFS path`);if(l.has(p))throw new Error(`Serialized lazy tree duplicates pending path ${p}`);l.add(p);let _=we(f.sourcePath,!1,`Serialized lazy tree entry ${m} source path`),y=we(f.archivePath,!1,`Serialized lazy tree entry ${m} archive path`),g=a.get(p),E=c.get(dn({sourcePath:_,type:typeof f.type=="string"?f.type:void 0,inodeGroup:typeof f.inodeGroup=="string"?f.inodeGroup:void 0,target:typeof f.target=="string"?f.target:void 0}))??g;if(!E||E.type!=="file"&&E.type!=="hardlink"||g?.inodeGroup!==void 0&&g.inodeGroup!==E.inodeGroup)throw new Error(`Serialized lazy tree entry ${p} is absent from its inventory`);let O=r.canonicalByGroup.get(E.inodeGroup);if(f.type!==E.type||f.inodeGroup!==E.inodeGroup||f.size!==E.size||y!==O?.sourcePath||f.target!==E.target||f.isSymlink!==!1||f.deleted!==!1||f.materialized!==!1)throw new Error(`Serialized lazy tree entry ${p} disagrees with its inventory`);let S=ce(f.ino,`Serialized lazy tree entry ${p} inode`,1,Number.MAX_SAFE_INTEGER),w=ce(f.generation,`Serialized lazy tree entry ${p} generation`,0,Number.MAX_SAFE_INTEGER),z=ce(f.dataSequence,`Serialized lazy tree entry ${p} data sequence`,0,Number.MAX_SAFE_INTEGER);return{vfsPath:p,ino:S,generation:w,dataSequence:z,size:E.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:y,sourcePath:_,type:E.type,inodeGroup:E.inodeGroup,...E.target===void 0?{}:{target:E.target}}});for(let h of r.entries)if(r.activation.atomicGroup!==void 0&&(h.type==="file"||h.type==="hardlink")&&!l.has(h.vfsPath))throw new Error(`Serialized lazy tree omits pending path ${h.vfsPath}`);return{kind:e,content:r.content,inventory:r.entries,activation:r.activation,url:o,mountPrefix:r.mountPrefix,integrity:s,materialized:!1,entries:d}}async function Nt(n,e){let t=globalThis.crypto?.subtle;if(!t)throw new Error(`${e} SHA-256 verification is unavailable`);let r=new Uint8Array(n.byteLength);r.set(n);let i=new Uint8Array(await t.digest("SHA-256",r));return Array.from(i,o=>o.toString(16).padStart(2,"0")).join("")}async function Oi(n,e,t){if(t===void 0)return;if(n.byteLength!==t.bytes)throw new Error(`Lazy ${e} byte count ${n.byteLength} does not match expected ${t.bytes}`);let r=await Nt(n,`Lazy ${e}`);if(r!==t.sha256)throw new Error(`Lazy ${e} SHA-256 ${r} does not match expected ${t.sha256}`)}async function Zs(n,e,t){if(n.byteLength!==e.bytes)throw new Error(`${t} byte count ${n.byteLength} does not match expected ${e.bytes}`);let r=await Nt(n,t);if(r!==e.sha256)throw new Error(`${t} SHA-256 ${r} does not match expected ${e.sha256}`)}function dl(n,e,t,r){let i=r.atomicGroup;if(i===void 0)throw new Error("Lazy atomic member is missing its typed tree descriptor");let o={schema:1,content:{decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...n.source===void 0?{}:{source:n.source},...n.materialization===void 0?{}:{materialization:n.materialization}},mountPrefix:t,inventory:[...e].sort((s,a)=>lr(s.vfsPath,a.vfsPath)),activation:{mode:r.mode,capabilities:r.capabilities,roots:r.roots,atomicGroup:{id:i.id,member:i.member}}};return new TextEncoder().encode(JSON.stringify(o))}function Ys(n,e){let t=e.map(({member:r,descriptorSha256:i})=>({member:r,descriptorSha256:i}));return new TextEncoder().encode(JSON.stringify({schema:1,id:n,members:t.sort((r,i)=>r.memberi.member?1:0)}))}function ll(n,e){if(n.byteLength!==e.byteLength)return!1;for(let t=0;tObject.freeze({...o}))};r!==void 0&&(Object.freeze(r.entries),Object.freeze(r));let i=n.materialization===void 0?void 0:fl(n.materialization);return Object.freeze({decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,transports:t,...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...r===void 0?{}:{source:r},...i===void 0?{}:{materialization:i}})}function fa(n){return{schema:1,kind:"archive-byte-transforms-v1",assertions:n.assertions.map(e=>({...e})),recipes:n.recipes.map(e=>({id:e.id,replacements:e.replacements.map(t=>({...t})),rejectHex:[...e.rejectHex]})),transforms:n.transforms.map(e=>({sourcePath:e.sourcePath,recipe:e.recipe,input:{...e.input},output:{...e.output}}))}}function fl(n){let e=fa(n);for(let t of e.assertions)Object.freeze(t);Object.freeze(e.assertions);for(let t of e.recipes){for(let r of t.replacements)Object.freeze(r);Object.freeze(t.replacements),Object.freeze(t.rejectHex),Object.freeze(t)}Object.freeze(e.recipes);for(let t of e.transforms)Object.freeze(t.input),Object.freeze(t.output),Object.freeze(t);return Object.freeze(e.transforms),Object.freeze(e)}function en(n){return{decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,transports:[...n.transports],...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...n.source===void 0?{}:{source:{schema:1,kind:"archive-source-inventory-v1",entries:n.source.entries.map(e=>({...e}))}},...n.materialization===void 0?{}:{materialization:fa(n.materialization)}}}function pa(n){let e=n.map(t=>Object.freeze({...t}));return Object.freeze(e),e}function pl(n,e,t){let r=[...n.capabilities],i=[...n.roots];return Object.freeze(r),Object.freeze(i),Object.freeze({mode:n.mode,capabilities:r,roots:i,atomicGroup:Object.freeze({id:e,member:t})})}function hl(n){let e=[...n.capabilities],t=[...n.roots];return Object.freeze(e),Object.freeze(t),Object.freeze({mode:n.mode,capabilities:e,roots:t})}function tn(n,e,t,r,i,o,s,a){let c=s.map(u=>Object.freeze({...u}));return Object.freeze(c),Object.freeze({content:fn(n),inventory:pa(e),activation:hl(t),url:r,mountPrefix:i,integrity:Object.freeze({...o}),entries:c,materialized:a})}function ml(n,e,t){let r=e.map(i=>Object.freeze({...i}));return Object.freeze(r),Object.freeze({...n,entries:r,materialized:t})}function Ai(n){return Array.from(n,([e,t])=>({vfsPath:e,...t}))}function Xs(n){return new Map(n.map(({vfsPath:e,...t})=>[e,t]))}function yl(n){return{mode:n.activation.mode,capabilities:[...n.activation.capabilities],roots:[...n.activation.roots],atomicGroup:{id:n.id,member:n.member,descriptorSha256:n.descriptorSha256,expectedCount:n.expectedCount,cohortSha256:n.cohortSha256}}}function _l(n,e){return n.ino===e.ino&&n.generation===e.generation&&n.dataSequence===e.dataSequence&&n.size===e.size&&n.isSymlink===e.isSymlink&&n.deleted===e.deleted&&n.materialized===e.materialized&&n.archivePath===e.archivePath&&n.sourcePath===e.sourcePath&&n.type===e.type&&n.inodeGroup===e.inodeGroup&&n.target===e.target}function rn(n,e,t){let r=n.content,i=n.inventory,o=n.activation,s=n.integrity,a=n.entries,c=n.url,u=n.mountPrefix,l=n.materialized,d=o?.atomicGroup;if(r===void 0||i===void 0||o===void 0||d===void 0||o.mode!=="first-use"||d.id!==e||d.member!==t||l)throw new Error(`Lazy atomic activation member ${t} changed before snapshot`);if(s?.sha256!==r.sha256||s?.bytes!==r.bytes||c!==(r.transports[0]??""))throw new Error(`Lazy atomic activation member ${t} has inconsistent integrity`);let h=fn(r),m=pa(i),f=pl(o,e,t),p=new Map;for(let O of m)O.type==="file"&&p.set(O.inodeGroup,O.sourcePath);let _=m.filter(O=>O.type!=="directory");if(a.size!==_.length)throw new Error(`Lazy atomic activation member ${t} has inconsistent runtime entries`);let y=_.map(O=>{let S=a.get(O.vfsPath),w=O.type==="symlink",z=w?O.sourcePath:p.get(O.inodeGroup),x=S!==void 0&&(S.sourcePath===O.sourcePath&&S.type===O.type&&S.target===O.target||O.type==="hardlink"&&S.sourcePath===z&&S.type==="file"&&S.target===void 0),I=S===void 0?["missing"]:[z===void 0?"archivePath source":void 0,S.generation===void 0?"generation":void 0,S.dataSequence===void 0?"dataSequence":void 0,S.size!==O.size?"size":void 0,S.isSymlink!==w?"symlink kind":void 0,S.deleted?"deletion state":void 0,S.materialized!==w?"materialization state":void 0,S.archivePath!==z?"archivePath":void 0,x?void 0:"descriptor mapping",S.inodeGroup!==O.inodeGroup?"inode group":void 0].filter(L=>L!==void 0);if(I.length>0)throw new Error(`Lazy atomic activation member ${t} has inconsistent mapping at ${O.vfsPath}: ${I.join(", ")}`);let R=S;return Object.freeze({vfsPath:O.vfsPath,ino:R.ino,generation:R.generation,dataSequence:R.dataSequence,size:R.size,isSymlink:R.isSymlink,deleted:!1,materialized:R.materialized,archivePath:z,sourcePath:O.sourcePath,type:O.type,...O.inodeGroup===void 0?{}:{inodeGroup:O.inodeGroup},...O.target===void 0?{}:{target:O.target}})});Object.freeze(y);let g=Object.freeze({sha256:h.sha256,bytes:h.bytes}),E=dl(h,m,u,f);return Object.freeze({id:e,member:t,descriptorBytes:E,content:h,inventory:m,activation:f,url:h.transports[0]??"",mountPrefix:u,integrity:g,entries:y})}function js(n,e,t,r){return Object.freeze({...n,descriptorSha256:e,expectedCount:t,cohortSha256:r})}function Js(n,e){return n.id!==e.id||n.member!==e.member||n.url!==e.url||n.mountPrefix!==e.mountPrefix||n.integrity.sha256!==e.integrity.sha256||n.integrity.bytes!==e.integrity.bytes||n.content.transports.length!==e.content.transports.length||n.content.transports.some((t,r)=>t!==e.content.transports[r])||!ll(n.descriptorBytes,e.descriptorBytes)||n.entries.length!==e.entries.length?!1:n.entries.every((t,r)=>{let i=e.entries[r];return i!==void 0&&t.vfsPath===i.vfsPath&&_l(t,i)})}function gl(n,e){let t=fn(n.content,n.content.transports.map(e));return Object.freeze({...n,content:t,url:t.transports[0]??""})}function Qs(n){return{paths:Array.from(n.paths),expectedIno:n.ino,expectedGeneration:n.generation,expectedDataSequence:n.dataSequence,data:n.content}}function V(n,e){return`${n}:${e}`}var q=class n{fs;imageMetadata;lazyFiles=new Map;lazyArchiveGroups=[];deferredTreeMaterializationHandles=new WeakMap;lazyArchiveInodes=new Map;lazyAtomicGroups=new Map;lazyAtomicGroupByTree=new WeakMap;sealedLazyAtomicStates=new WeakMap;ordinaryLazyTreeDefinitions=new WeakMap;lazyDownloadListeners=new Set;lazyPreparations=new Map;lazyTransport={fetcher:(e,t)=>t===void 0?globalThis.fetch(e):globalThis.fetch(e,t)};constructor(e,t=null){this.fs=e,this.imageMetadata=t,lt(Sd,Rd,[this])}snapshotForImmutableProduct(){if(this.lazyFiles.size!==0||this.lazyArchiveInodes.size!==0)throw new Error("immutable product source must be completely materialized");let{bytes:e}=lt(Td,this.fs,[]),t=new Ms(e.byteLength);lt(Ed,new gd(t),[e]);let r=lt(xd,le,[t,{restoreImage:!0}]);return _d(r,bd),new n(r,Gs(this.imageMetadata))}qualifiedInodeIdentity(e){let t=this.fs.lstat(e),r=lt(wd,Ds,[this.fs.buffer]);return r===void 0&&(r=Ld++,lt(Od,Ds,[this.fs.buffer,r])),{dev:r,ino:t.ino,generation:t.generation}}static canAdoptLegacyLazyStub(e){return(e.mode&Ee)===kt&&e.size===0&&e.dataSequence<=1}replaceOrdinaryLazyTreeRuntimeState(e,t,r){let i=this.ordinaryLazyTreeDefinitions.get(e);if(i===void 0)return;let o=ml(i,t,r);this.ordinaryLazyTreeDefinitions.set(e,o);try{e.entries=Xs(o.entries),e.materialized=o.materialized}catch{}return o}reconcileLazyIdentityState(e){for(let[t,r]of this.lazyFiles){let i=e.get(t);if(!i||i.dataSequence!==r.dataSequence||i.paths.length===0){this.lazyFiles.delete(t);continue}r.paths=new Set(i.paths),r.paths.has(r.path)||(r.path=i.paths[0])}this.lazyArchiveInodes.clear();for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(!r?.committed)for(let c of i.snapshot.entries){if(c.isSymlink||c.materialized||c.generation===void 0)continue;let u=V(c.ino,c.generation),l=e.get(u);l!==void 0&&l.dataSequence===c.dataSequence&&l.paths.length>0&&this.lazyArchiveInodes.set(u,t)}continue}let o=this.ordinaryLazyTreeDefinitions.get(t);if(o!==void 0){if(o.materialized){this.replaceOrdinaryLazyTreeRuntimeState(t,o.entries,!0);continue}let c=new Map,u=o.entries.filter(l=>l.deleted||l.materialized||l.isSymlink).map(l=>({...l}));for(let l of o.entries){if(l.deleted||l.materialized||l.isSymlink||l.generation===void 0)continue;let d=V(l.ino,l.generation),h=c.get(d)??[];h.push(l),c.set(d,h)}for(let[l,d]of c){let h=e.get(l);if(h===void 0||h.dataSequence!==(d[0].dataSequence??0)){if(h!==void 0)for(let p of d)u.push({...p,materialized:!0});continue}let m=new Map(d.map(p=>[p.vfsPath,p])),f=d.find(p=>p.type==="file")??d[0];for(let p of h.paths){let _=m.get(p)??f;u.push({..._,vfsPath:p,ino:h.ino,generation:h.generation,dataSequence:h.dataSequence,deleted:!1,materialized:!1})}h.paths.length>0&&this.lazyArchiveInodes.set(l,t)}this.replaceOrdinaryLazyTreeRuntimeState(t,u,!1);continue}let s=new Map;for(let c of t.entries.values()){if(c.deleted||c.materialized||c.generation===void 0)continue;let u=V(c.ino,c.generation);s.has(u)||s.set(u,c)}let a=new Map(Array.from(t.entries.entries()).filter(([,c])=>c.deleted||c.isSymlink&&!c.deleted));for(let[c,u]of s){let l=e.get(c);if(!(!l||l.dataSequence!==(u.dataSequence??0))){for(let d of l.paths)a.set(d,{...u,ino:l.ino,generation:l.generation,dataSequence:l.dataSequence,deleted:!1,materialized:!1});l.paths.length>0&&this.lazyArchiveInodes.set(c,t)}}t.entries=a,t.materialized=!Array.from(a.values()).some(c=>!c.isSymlink&&!c.materialized)&&(r===void 0||r.committed)}}validatePendingLazyTreeNamespaceState(e){let t=new Map;for(let r of e.values())for(let i of r.paths){if(t.has(i))throw new Error(`SharedFS namespace identity is ambiguous at ${i}`);t.set(i,r)}for(let r of this.lazyArchiveGroups){let i=this.lazyAtomicGroupByTree.get(r),s=this.sealedLazyAtomicStates.get(r)?.snapshot,a=this.ordinaryLazyTreeDefinitions.get(r);if(i?.committed||s===void 0&&(a?.materialized??r.materialized)||s===void 0&&a===void 0)continue;let c=s?.inventory??a.inventory,u=new Map((s?.entries??a.entries).map(f=>[f.vfsPath,f])),l=new Map,d=new Map,h=new Set;for(let f of u.values())f.deleted&&f.inodeGroup!==void 0&&h.add(f.inodeGroup);for(let f of c){if(f.type!=="file"&&f.type!=="hardlink")continue;l.set(f.inodeGroup,(l.get(f.inodeGroup)??0)+1);let p=d.get(f.inodeGroup)??[];p.push(f.vfsPath),d.set(f.inodeGroup,p)}let m=new Set([...h].filter(f=>d.get(f)?.every(p=>!t.has(p))));for(let f of c){let p=t.get(f.vfsPath);if(p===void 0){if(f.inodeGroup!==void 0&&m.has(f.inodeGroup))continue;throw new Error(`Lazy tree namespace entry ${f.vfsPath} is missing from the captured filesystem state`)}let _=f.type==="directory"?Qe:f.type==="symlink"?Sr:kt;if((p.mode&Ee)!==_||(p.mode&ie.S_MODE_BITS)!==f.mode)throw new Error(`Lazy tree namespace entry ${f.vfsPath} disagrees with its captured type or mode`);if(f.type==="directory")continue;let y=u.get(f.vfsPath);if(y===void 0||y.ino!==p.ino||y.generation!==p.generation||y.dataSequence!==p.dataSequence)throw new Error(`Lazy tree namespace entry ${f.vfsPath} changed identity before serialization`);if(f.type==="symlink"){let g=new TextEncoder().encode(f.target).byteLength;if(p.linkCount!==1||p.size!==f.size||p.size!==g||p.symlinkTarget!==f.target)throw new Error(`Lazy tree symlink ${f.vfsPath} disagrees with its captured inventory`);continue}if(p.size!==0||p.linkCount!==l.get(f.inodeGroup))throw new Error(`Lazy tree stub ${f.vfsPath} has changed data or undeclared aliases`)}}}lazyFileForStat(e){let t=V(e.ino,e.generation),r=this.lazyFiles.get(t);if(r&&r.dataSequence!==e.dataSequence){this.lazyFiles.delete(t);return}return r}lazyArchiveEntriesForRead(e){let t=this.lazyAtomicGroupByTree.get(e),r=this.sealedLazyAtomicStates.get(e);if(r!==void 0&&!t?.committed)return r.snapshot.entries;let i=this.ordinaryLazyTreeDefinitions.get(e);return i!==void 0?i.entries:Array.from(e.entries,([o,s])=>({vfsPath:o,...s}))}lazyArchiveForStat(e){let t=V(e.ino,e.generation),r=this.lazyArchiveInodes.get(t);if(!r)return;let i=this.lazyArchiveEntriesForRead(r).filter(s=>s.ino===e.ino&&s.generation===e.generation&&!s.deleted&&!s.materialized);if(i.some(s=>s.dataSequence===e.dataSequence))return r;if(this.lazyArchiveInodes.delete(t),this.sealedLazyAtomicStates.get(r)===void 0){let s=this.ordinaryLazyTreeDefinitions.get(r);if(s!==void 0)this.replaceOrdinaryLazyTreeRuntimeState(r,s.entries.map(a=>a.ino===e.ino&&a.generation===e.generation?{...a,materialized:!0}:a),s.materialized);else for(let a of i)a.materialized=!0}}lazyBackingForStat(e){let t=V(e.ino,e.generation),r=this.lazyFiles.get(t);if(r)return{token:r,path:r.path};let i=this.lazyArchiveInodes.get(t);if(!i)return null;let o=this.lazyArchiveEntriesForRead(i).find(a=>a.ino===e.ino&&a.generation===e.generation&&!a.deleted&&!a.materialized)?.vfsPath;if(o===void 0)return null;let s=this.lazyAtomicGroupByTree.get(i);return s===void 0?{token:i,path:o}:{token:s.token,path:o,atomicGroup:s}}lazyBackingForPath(e){let t=this.lazyArchiveGroups.find(r=>{let i=this.lazyAtomicGroupByTree.get(r),o=this.sealedLazyAtomicStates.get(r)?.snapshot,s=this.ordinaryLazyTreeDefinitions.get(r),a=o===void 0?!(s?.materialized??r.materialized):!i?.committed,c=o?.content??s?.content,u=o?.inventory??s?.inventory,l=o?.activation??s?.activation,d=o?.entries??s?.entries??Array.from(r.entries.values());return a&&c!==void 0&&u!==void 0&&l!==void 0&&d.every(h=>h.deleted||h.materialized||h.isSymlink)&&l.roots.some(h=>h==="/"||e===h||e.startsWith(`${h}/`))});if(t){let r=this.lazyAtomicGroupByTree.get(t);return{token:r?.token??t,path:e,directGroup:t,...r===void 0?{}:{atomicGroup:r}}}try{let r=this.fs.stat(e),i=this.lazyBackingForStat(r);return i?{...i,path:e}:null}catch{return null}}startLazyPreparation(e){let{path:t,token:r}=e,i={status:"pending",promise:Promise.resolve(!1)},o=e.atomicGroup?this.ensureAtomicLazyGroupMaterialized(e.atomicGroup).then(()=>!0):e.directGroup?this.ensureArchiveMaterialized(e.directGroup).then(()=>!0):this.materializePath(t);return i.promise=o.then(s=>(i.status="fulfilled",this.lazyPreparations.get(r)===i&&this.lazyPreparations.delete(r),s),s=>{throw i.status="rejected",i.error=s,s}),i.promise.catch(()=>{}),this.lazyPreparations.set(r,i),i}registerLazyAtomicGroupMembership(e,t=!1){let r=e.activation?.atomicGroup;if(r===void 0)return;let{id:i,member:o}=r;if(e.content===void 0||e.inventory===void 0||e.activation?.mode!=="first-use")throw new Error(`Lazy atomic activation group ${i} accepts only typed first-use trees`);let s=this.lazyAtomicGroups.get(i);if(s===void 0)s={id:i,token:Object.freeze({id:i}),groups:new Map,committed:!1},this.lazyAtomicGroups.set(i,s);else if(s.committed)throw new Error(`Lazy atomic activation group ${i} is already materialized`);if(s.groups.has(o))throw new Error(`Lazy atomic activation group ${i} duplicates member ${o}`);if(Ft(r)){if(s.expectedCount!==void 0&&(s.expectedCount!==r.expectedCount||s.cohortSha256!==r.cohortSha256))throw new Error(`Lazy atomic activation group ${i} has inconsistent seals`);s.expectedCount=r.expectedCount,s.cohortSha256=r.cohortSha256;let a=rn(e,i,o);this.sealedLazyAtomicStates.set(e,{snapshot:js(a,r.descriptorSha256,r.expectedCount,r.cohortSha256),verified:t})}else if(s.expectedCount!==void 0)throw new Error(`Lazy atomic activation group ${i} mixes sealed and unsealed members`);s.groups.set(o,e),this.lazyAtomicGroupByTree.set(e,s)}async sealLazyAtomicGroup(e,t){let r=t.map(u=>ua({id:e,member:u}).member).sort();if(r.length===0||new Set(r).size!==r.length)throw new Error(`Lazy atomic activation group ${e} expected members are invalid`);let i=this.lazyAtomicGroups.get(e);if(i===void 0||i.committed)throw new Error(`Lazy atomic activation group ${e} is not pending`);let o=[...i.groups.keys()].sort();if(JSON.stringify(o)!==JSON.stringify(r))throw new Error(`Lazy atomic activation group ${e} members differ from its seal`);if(i.expectedCount!==void 0){if(i.expectedCount!==r.length||i.cohortSha256===void 0)throw new Error(`Lazy atomic activation group ${e} has an invalid existing seal`);await this.ensureLazyAtomicGroupSealValidated(i,r.map(u=>i.groups.get(u)),!0);return}let s=r.map(u=>rn(i.groups.get(u),e,u)),a=[];for(let u of s)a.push({member:u.member,descriptorSha256:await Nt(u.descriptorBytes,`Lazy atomic member ${u.member}`),source:u});let c=await Nt(Ys(e,a),`Lazy atomic activation group ${e}`);for(let u of a){let l=i.groups.get(u.member),d=rn(l,e,u.member);if(!Js(u.source,d))throw new Error(`Lazy atomic activation member ${u.member} changed while sealing`)}for(let u of a){let l=i.groups.get(u.member);l.activation.atomicGroup={id:e,member:u.member,descriptorSha256:u.descriptorSha256,expectedCount:a.length,cohortSha256:c},this.sealedLazyAtomicStates.set(l,{snapshot:js(u.source,u.descriptorSha256,a.length,c),verified:!0})}i.expectedCount=a.length,i.cohortSha256=c}async verifyImportedLazyAtomicGroupSeals(){await this.validatePendingLazyAtomicGroupSeals(!0)}guardSynchronousLazyAccess(e){let t=this.lazyBackingForPath(e);if(!t)return;let r=this.lazyPreparations.get(t.token);if(r?.status==="fulfilled"){this.lazyPreparations.delete(t.token);let o=this.lazyBackingForPath(e);if(!o)return;r=this.lazyPreparations.get(o.token)??this.startLazyPreparation(o)}else if(r?.status==="rejected"){this.lazyPreparations.delete(t.token);let o=r.error instanceof Error?r.error.message:String(r.error),s=new Error(`EIO: lazy backing for ${e} failed: ${o}`);throw s.code="EIO",s.cause=r.error,s}else r||(r=this.startLazyPreparation(t));let i=new Error(`EAGAIN: lazy backing for ${e} is being prepared`);throw i.code="EAGAIN",i}invalidateLazyData(e){let t=V(e.ino,e.generation);this.lazyFiles.delete(t);let r=this.lazyArchiveInodes.get(t);if(!r)return;this.lazyArchiveInodes.delete(t);let i=this.ordinaryLazyTreeDefinitions.get(r);if(i!==void 0){this.replaceOrdinaryLazyTreeRuntimeState(r,i.entries.map(o=>o.ino===e.ino&&o.generation===e.generation?{...o,materialized:!0}:o),i.materialized);return}for(let o of r.entries.values())o.ino!==e.ino||o.generation!==e.generation||(o.materialized=!0)}rewriteLazyNamespacePaths(e,t,r){let i=t.length>1?t.replace(/\/+$/,""):t,o=r.length>1?r.replace(/\/+$/,""):r,s=`${i}/`,a=`${o}/`,c=V(e.ino,e.generation),u=(e.mode&Ee)===Qe,l=d=>d===i?o:u&&d.startsWith(s)?a+d.slice(s.length):d;for(let[d,h]of this.lazyFiles)!u&&d!==c||(h.paths=new Set(Array.from(h.paths,l)),h.path=l(h.path));for(let d of this.lazyArchiveGroups){let h=this.ordinaryLazyTreeDefinitions.get(d);if(h!==void 0){let f=h.entries.map(g=>{let E=g.generation===void 0?null:V(g.ino,g.generation),O=u||E===c?l(g.vfsPath):g.vfsPath;return{...g,vfsPath:O,...g.type==="hardlink"&&g.target!==void 0?{target:l(g.target)}:{}}}),p=h.inventory.map(g=>({...g,vfsPath:l(g.vfsPath),...g.type==="hardlink"&&g.target!==void 0?{target:l(g.target)}:{}})),_={...h.activation,capabilities:[...h.activation.capabilities],roots:h.activation.roots.map(l)},y=tn(h.content,p,_,h.url,h.mountPrefix,h.integrity,f,h.materialized);this.ordinaryLazyTreeDefinitions.set(d,y);try{d.entries=Xs(y.entries),d.materialized=y.materialized,d.inventory=y.inventory.map(g=>({...g})),d.activation={...y.activation,capabilities:[...y.activation.capabilities],roots:[...y.activation.roots]}}catch{}continue}let m=new Map;for(let[f,p]of d.entries){let _=p.generation===void 0?null:V(p.ino,p.generation);m.set(u||_===c?l(f):f,p)}d.entries=m,d.inventory&&(d.inventory=d.inventory.map(f=>({...f,vfsPath:l(f.vfsPath),...f.type==="hardlink"&&f.target!==void 0?{target:l(f.target)}:{}}))),d.activation&&(d.activation={...d.activation,roots:d.activation.roots.map(l)})}}get sharedBuffer(){return this.fs.buffer}static create(e,t){return new n(le.mkfs(e,t))}static createFresh(e){if(typeof e!="number"||!Ad(e)||e<=0)throw new zd("fresh MemoryFileSystem byte length must be a positive integer");let t=new Ms(e),r=lt(Id,le,[t]);return new n(r)}static fromExisting(e){return new n(le.mount(e))}rebaseToNewFileSystem(e){if(!Number.isSafeInteger(e)||e<=0)throw new Error(`Invalid MemoryFileSystem maxByteLength: ${e}`);let t=SharedArrayBuffer,{bytes:r,identities:i}=this.fs.snapshotState();this.reconcileLazyIdentityState(i);let o=this.serializeLazyEntries(),s=this.serializeValidatedLazyArchiveEntries(i),a=new t(r.byteLength);new Uint8Array(a).set(r);let c=new n(le.mount(a,{restoreImage:!0}),this.imageMetadata);c.importLazyEntries(o),c.importLazyArchiveEntriesInternal(s,!1,!0,"verified");let u=Math.min(e,Math.max(r.byteLength,Dd)),l=new t(u,{maxByteLength:e}),d=n.create(l,e);d.setImageMetadata(this.imageMetadata);let h=new Set(o.flatMap(f=>f.paths??[f.path])),m=new Set;for(let f of s)if(!f.materialized)for(let p of f.entries)!p.deleted&&!p.isSymlink&&m.add(p.vfsPath);return c.copyPathToFreshFileSystem("/",d,h,m,new Map),d.importLazyEntries(o.map(f=>{let p=d.fs.lstat(f.path);return{...f,ino:p.ino,generation:p.generation,dataSequence:p.dataSequence}})),d.importLazyArchiveEntriesInternal(s.map(f=>({...f,entries:f.entries.map(p=>{if(p.deleted)return{...p,ino:0,generation:void 0};let _=d.fs.lstat(p.vfsPath);return{...p,ino:_.ino,generation:_.generation,dataSequence:_.dataSequence}})})),!1,!0,"verified"),d}getImageMetadata(){return Gs(this.imageMetadata)}setImageMetadata(e){this.imageMetadata=e===null?null:ki(e)}subscribeLazyDownloads(e){return this.lazyDownloadListeners.add(e),()=>this.lazyDownloadListeners.delete(e)}setLazyFetcher(e,t={}){this.lazyTransport={fetcher:e,...t.signal===void 0?{}:{signal:t.signal}}}emitLazyDownload(e){if(this.lazyDownloadListeners.size===0)return;let t={...e,t:Xd()};for(let r of this.lazyDownloadListeners)try{r(t)}catch{}}async fetchLazyBytes(e,t){let r=0,i=e.integrity?.bytes??e.fallbackTotalBytes,o={id:e.id,kind:e.kind,url:e.url,path:e.path,mountPrefix:e.mountPrefix};for(let s=0;se.integrity.bytes)throw new Error(`Lazy ${e.kind} exceeded expected byte count ${e.integrity.bytes}`);this.emitLazyDownload({...o,status:"progress",loadedBytes:r,totalBytes:i})}}}catch(d){try{await c.cancel(d)}catch{}throw d}}finally{c.releaseLock()}let l=il(u,r);return te(t.signal),await Oi(l,e.kind,e.integrity),te(t.signal),this.emitLazyDownload({...o,status:"complete",loadedBytes:r,totalBytes:i??r}),l}catch(a){if(t.signal?.aborted){let l=t.signal.reason,d=l instanceof Error?l.message:String(l);throw this.emitLazyDownload({...o,status:"error",loadedBytes:r,totalBytes:i,error:d}),l}let c=s+1({...g})),activation:d,entries:new Map},_=g=>{let E=g.split("/").filter(Boolean),O="";for(let S=0;SE.vfsPath.split("/").length-O.vfsPath.split("/").length))if(g.type==="directory"){_(g.vfsPath);try{this.fs.mkdir(g.vfsPath,g.mode),this.fs.chmod(g.vfsPath,g.mode)}catch{if((this.fs.lstat(g.vfsPath).mode&Ee)!==Qe)throw new Error(`Lazy tree directory collides at ${g.vfsPath}`)}}for(let g of u){if(g.type!=="symlink")continue;_(g.vfsPath),this.fs.symlink(g.target,g.vfsPath);let E=this.fs.lstat(g.vfsPath);p.entries.set(g.vfsPath,{ino:E.ino,generation:E.generation,dataSequence:E.dataSequence,size:g.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:g.sourcePath,sourcePath:g.sourcePath,type:"symlink",target:g.target})}let y=new Map;for(let g of u){if(g.type!=="file")continue;_(g.vfsPath);let E=this.fs.createLazyStub(g.vfsPath,g.mode);this.invalidateLazyData(E),y.set(g.inodeGroup,E);let O={ino:E.ino,generation:E.generation,dataSequence:E.dataSequence,size:g.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:g.sourcePath,sourcePath:g.sourcePath,type:"file",inodeGroup:g.inodeGroup};p.entries.set(g.vfsPath,O)}for(let g of u){if(g.type!=="hardlink")continue;let E=h.get(g.inodeGroup);_(g.vfsPath),this.fs.link(E.vfsPath,g.vfsPath);let O=this.fs.lstat(g.vfsPath),S=y.get(g.inodeGroup);if(O.ino!==S.ino||O.generation!==S.generation)throw new Error(`Lazy tree hardlink ${g.vfsPath} did not share its inode`);p.entries.set(g.vfsPath,{ino:O.ino,generation:O.generation,dataSequence:O.dataSequence,size:g.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:E.sourcePath,sourcePath:g.sourcePath,type:"hardlink",inodeGroup:g.inodeGroup,target:g.target})}if(m!==void 0)for(let g of u)this.lchown(g.vfsPath,m.uid,m.gid);for(let g of p.entries.values())g.isSymlink||g.generation===void 0||this.lazyArchiveInodes.set(V(g.ino,g.generation),p);return this.lazyArchiveGroups.push(p),this.registerLazyAtomicGroupMembership(p),d.atomicGroup===void 0&&this.ordinaryLazyTreeDefinitions.set(p,tn(c,u,d,p.url,l,p.integrity,Ai(p.entries),!1)),p}registerLazyTreeWithMaterializationHandle(e,t,r="/",i,o){let s=this.registerLazyTreeInternal(e,t,r,i,!0,o),a=Object.freeze({[vd]:!0});return this.deferredTreeMaterializationHandles.set(a,s),a}registerLazyArchiveFromEntries(e,t,r,i,o){let s=Ar(r),a=Vd(e,t,s,i);a.some(({entry:u})=>!u.isDirectory&&!u.isSymlink)&&this.assertCanRegisterPendingLazyArchiveGroup();let c={...o?{content:un({decoder:"zip-v1",mediaType:"application/zip",sha256:o.sha256,bytes:o.bytes,expandedBytes:a.reduce((u,l)=>u+l.entry.uncompressedSize,0),sourceEntryCount:a.length,transports:[e]})}:{},url:e,mountPrefix:s,integrity:zr(o),materialized:!1,entries:new Map};for(let{entry:u,vfsPath:l}of a){if(u.isDirectory)continue;let d=l.split("/").filter(Boolean),h="";for(let m=0;mu.deleted||u.materialized),this.lazyArchiveGroups.push(c),c}importLazyArchiveEntries(e){this.importLazyArchiveEntriesInternal(e,!1,!0,"reject")}async importVerifiedLazyArchiveEntries(e){let t=structuredClone(e),r=this.exportLazyArchiveEntries(),i=n.fromExisting(this.sharedBuffer);i.importLazyArchiveEntriesInternal([...r,...t],!1,!0,"pending"),await i.verifyImportedLazyAtomicGroupSeals(),this.importLazyArchiveEntriesInternal(t,!1,!0,"verified")}importLazyArchiveEntriesInternal(e,t,r,i){let o=Me(e,"Serialized lazy archive groups",0,ta).map((l,d)=>{if(typeof l!="object"||l===null||Array.isArray(l))throw new Error(`Serialized lazy archive group ${d} must be an object`);let h=l.kind;if(h===Or||h===Ti||h===ft)return la(l,h);if(h===wr)return bi(l,!1);if(h!==void 0)throw new Error(`Serialized lazy archive group ${d} has an unsupported kind`);if(r)throw new Error(`Serialized lazy archive group ${d} is missing its kind discriminator`);return bi(l,!0)}),s=this.fs.identityState();this.reconcileLazyIdentityState(s);let a=[...this.serializeValidatedLazyArchiveEntries(s),...o];qs(a);let c=[],u=new Map;for(let l of o){let d=new Map,h=l.mountPrefix.replace(/\/+$/,""),m=l.content!==void 0&&l.inventory!==void 0&&l.activation!==void 0,f=m?new Map(l.inventory.map(S=>[S.vfsPath,S])):null,p=m?new Map(l.inventory.map(S=>[dn(S),S])):null,_=new Map,y=new Map,g=new Map;for(let S of l.entries){let w=null,z=l.materialized||S.materialized===!0||S.isSymlink;if(!S.deleted&&!z){if((S.generation===void 0||S.dataSequence===void 0)&&!t)throw new Error("Live lazy-archive metadata requires inode generation and data sequence");try{w=this.fs.lstat(S.vfsPath)}catch{if(m)throw new Error(`Serialized lazy tree stub ${S.vfsPath} is missing from the filesystem`);continue}if(w.ino!==S.ino){if(m)throw new Error(`Serialized lazy tree stub ${S.vfsPath} has a different inode`);continue}if(S.generation!==void 0&&w.generation!==S.generation){if(m)throw new Error(`Serialized lazy tree stub ${S.vfsPath} has a different generation`);continue}if(S.dataSequence===void 0){if(!n.canAdoptLegacyLazyStub(w)){if(m)throw new Error(`Serialized lazy tree stub ${S.vfsPath} is not pristine`);continue}}else if(w.dataSequence!==S.dataSequence){if(m)throw new Error(`Serialized lazy tree stub ${S.vfsPath} has a different data sequence`);continue}if(m){g.set(S.vfsPath,w);let I=f.get(S.vfsPath),R=p.get(dn(S))??I;if(!R||(w.mode&Ee)!==kt||w.size!==0||(w.mode&ie.S_MODE_BITS)!==R.mode||I?.inodeGroup!==void 0&&I.inodeGroup!==R.inodeGroup)throw new Error(`Serialized lazy tree stub ${S.vfsPath} disagrees with its inventory`);let L=V(w.ino,w.generation),v=S.inodeGroup,Z=_.get(v),N=y.get(L);if(Z!==void 0&&Z!==L||N!==void 0&&N!==v)throw new Error(`Serialized lazy tree inode group ${v} disagrees with the filesystem`);_.set(v,L),y.set(L,v)}}d.set(S.vfsPath,{ino:S.ino,generation:w?.generation??S.generation,dataSequence:w?.dataSequence??S.dataSequence,size:S.size,isSymlink:S.isSymlink,deleted:S.deleted,materialized:z,archivePath:S.archivePath??S.vfsPath.slice(h.length+1),sourcePath:S.sourcePath??S.archivePath??S.vfsPath.slice(h.length+1),type:S.type??(S.isSymlink?"symlink":"file"),inodeGroup:S.inodeGroup,target:S.target})}if(m){let S=new Map;for(let w of l.inventory){if(w.type==="file"||w.type==="hardlink"){S.set(w.inodeGroup,(S.get(w.inodeGroup)??0)+1);continue}let z;try{z=this.fs.lstat(w.vfsPath)}catch{throw new Error(`Serialized lazy tree namespace entry ${w.vfsPath} is missing from the filesystem`)}let x=w.type==="directory"?Qe:Sr;if((z.mode&Ee)!==x||(z.mode&ie.S_MODE_BITS)!==w.mode||w.type==="symlink"&&(z.size!==new TextEncoder().encode(w.target).byteLength||this.fs.readlink(w.vfsPath)!==w.target))throw new Error(`Serialized lazy tree namespace entry ${w.vfsPath} disagrees with its inventory`);w.type==="symlink"&&d.set(w.vfsPath,{ino:z.ino,generation:z.generation,dataSequence:z.dataSequence,size:w.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:w.sourcePath,sourcePath:w.sourcePath,type:"symlink",target:w.target})}if(l.activation?.atomicGroup!==void 0)for(let w of l.inventory){if(w.type!=="file"&&w.type!=="hardlink")continue;if(g.get(w.vfsPath).linkCount!==S.get(w.inodeGroup))throw new Error(`Serialized lazy atomic tree inode group ${w.inodeGroup} has undeclared aliases`)}}let E=l.content===void 0?void 0:un(l.content),O={content:E,url:E?.transports[0]??l.url,mountPrefix:l.mountPrefix,integrity:E?{sha256:E.sha256,bytes:E.bytes}:zr(l.integrity),materialized:l.materialized||!(E&&l.inventory)&&Array.from(d.values()).every(S=>S.deleted||S.materialized),inventory:l.inventory?.map(S=>({...S})),activation:l.activation?{mode:l.activation.mode,capabilities:[...l.activation.capabilities],roots:[...l.activation.roots],...l.activation.atomicGroup===void 0?{}:{atomicGroup:{...l.activation.atomicGroup}}}:void 0,entries:d};if(c.push(O),!O.materialized){for(let[,S]of d)if(!S.deleted&&!S.materialized&&S.generation!==void 0){let w=V(S.ino,S.generation),z=u.get(w);if(z!==void 0&&z!==O)throw new Error(`Serialized lazy archive groups share pending inode ${w}`);if(this.lazyArchiveInodes.has(w))throw new Error(`Serialized lazy archive group collides with pending inode ${w}`);u.set(w,O)}}}for(let l of c){let d=l.activation?.atomicGroup;if(d!==void 0&&this.lazyAtomicGroups.get(d.id)?.committed)throw new Error(`Lazy atomic activation group ${d.id} is already materialized`)}if(i==="reject"&&c.some(l=>{let d=l.activation?.atomicGroup;return d!==void 0&&Ft(d)}))throw new Error("Sealed lazy archive registrations require importVerifiedLazyArchiveEntries()");this.lazyArchiveGroups.push(...c);for(let l of c)this.registerLazyAtomicGroupMembership(l,i==="verified"),l.content!==void 0&&l.inventory!==void 0&&l.activation!==void 0&&l.activation.atomicGroup===void 0&&this.ordinaryLazyTreeDefinitions.set(l,tn(l.content,l.inventory,l.activation,l.url,l.mountPrefix,l.integrity,Ai(l.entries),l.materialized));for(let[l,d]of u)this.lazyArchiveInodes.set(l,d)}rewriteLazyArchiveUrls(e){for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0&&!r?.committed){this.assertLazyAtomicSnapshotMatchesPublic(t);let s=gl(i.snapshot,e);t.content=en(s.content),t.url=s.url,t.integrity={...s.integrity},i.snapshot=s;continue}let o=this.ordinaryLazyTreeDefinitions.get(t);if(o!==void 0){let s=fn(o.content,o.content.transports.map(e)),a=tn(s,o.inventory,o.activation,s.transports[0],o.mountPrefix,o.integrity,o.entries,o.materialized);this.ordinaryLazyTreeDefinitions.set(t,a),t.content=en(a.content),t.url=a.url,t.integrity={...a.integrity}}else t.content?(t.content={...t.content,transports:t.content.transports.map(e)},t.url=t.content.transports[0]):t.url=e(t.url)}}serializeLazyArchiveEntries(){let e=[];for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(r?.committed)continue;let _=i.snapshot;if(_.content.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");e.push({kind:ft,content:en(_.content),inventory:_.inventory.map(y=>({...y})),activation:yl(_),url:_.url,mountPrefix:_.mountPrefix,integrity:{..._.integrity},materialized:!1,entries:_.entries.filter(y=>!y.deleted&&!y.materialized).map(({vfsPath:y,...g})=>({vfsPath:y,...g}))});continue}let o=this.ordinaryLazyTreeDefinitions.get(t),s=o?.materialized??t.materialized,a=(o?.entries??Ai(t.entries)).map(_=>({..._})).filter(_=>!_.deleted&&!_.materialized),c=o?.content??t.content,u=o?.inventory??t.inventory,l=o?.activation??t.activation,d=o?.url??t.url,h=o?.mountPrefix??t.mountPrefix,m=o?.integrity??t.integrity;if(a.length===0&&!(c!==void 0&&u!==void 0&&!s))continue;let f=c!==void 0&&u!==void 0&&l!==void 0;if(f&&c.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");let p=l?.atomicGroup;if(p!==void 0&&!Ft(p))throw new Error(`Lazy atomic activation group ${p.id} must be sealed before serialization`);e.push(f?{kind:p!==void 0?ft:c.source===void 0?Or:Ti,content:en(c),inventory:u.map(_=>({..._})),activation:{...l,capabilities:[...l.capabilities],roots:[...l.roots]},url:d,mountPrefix:h,integrity:{...m},materialized:!1,entries:a}:{kind:wr,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:a})}return e}serializeValidatedLazyArchiveEntries(e){this.assertPendingLazyAtomicSnapshotsReadyForSerialization();let t=this.serializeLazyArchiveEntries();return qs(t),this.validatePendingLazyTreeNamespaceState(e),t}exportLazyArchiveEntries(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),this.serializeValidatedLazyArchiveEntries(e)}pendingDeferredTreeUsage(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),ca(this.serializeValidatedLazyArchiveEntries(e))}assertCanAppendDeferredTreeUsage(e){Li(e);let t=this.pendingDeferredTreeUsage();Li({groups:t.groups+e.groups,archiveBytes:t.archiveBytes+e.archiveBytes,expandedBytes:t.expandedBytes+e.expandedBytes,payloadBytes:t.payloadBytes+e.payloadBytes,entries:t.entries+e.entries})}assertCanRegisterPendingLazyArchiveGroup(){if(this.reconcileLazyIdentityState(this.fs.identityState()),this.lazyArchiveGroups.filter(t=>{let r=this.lazyAtomicGroupByTree.get(t);if(this.sealedLazyAtomicStates.get(t)?.snapshot!==void 0)return!r?.committed;let o=this.ordinaryLazyTreeDefinitions.get(t);return!(o?.materialized??t.materialized)&&(o!==void 0||Array.from(t.entries.values()).some(s=>!s.deleted&&!s.materialized))}).length>=be.maxGroups)throw new Error(`Cannot register another lazy archive group: ${be.maxGroups} pending groups already exist`)}async preparePath(e){let t=!1,r=Math.max(3,this.lazyArchiveGroups.length+1);for(let i=0;i{let s=this.ordinaryLazyTreeDefinitions.get(o);return!(s?.materialized??o.materialized)&&(s?.activation??o.activation)?.mode==="boot-prefetch"}),t=0,r,i=Array.from({length:Math.min(e.length,Bd)},async()=>{for(;r===void 0;){let o=t;if(t+=1,o>=e.length)return;try{await this.prepareLazyTreeGroup(e[o])}catch(s){r??=s}}});if(await Promise.all(i),r!==void 0)throw r;return e.length}async materializeRegisteredDeferredTree(e,t){let r=this.deferredTreeMaterializationHandles.get(e);if(r===void 0)throw new Error("Deferred-tree handle was not issued by this filesystem");let i=this.lazyAtomicGroupByTree.get(r);if(i!==void 0)throw new Error(`Deferred tree belongs to atomic activation group ${i.id}; materialize the complete group instead`);if(this.ordinaryLazyTreeDefinitions.get(r)?.materialized??r.materialized)return!1;let s=this.lazyPreparations.get(r);if(s!==void 0)return s.promise;let a=new Uint8Array(t.byteLength);a.set(t);let c={status:"pending",promise:Promise.resolve(!1)};c.promise=Promise.resolve().then(async()=>{let u=this.ordinaryLazyTreeDefinitions.get(r)?.integrity??r.integrity;return await Oi(a,"tree",u),await this.materializeArchiveBytes(r,a),!0}).then(u=>(c.status="fulfilled",u),u=>{throw c.status="rejected",c.error=u,u}),c.promise.catch(()=>{}),this.lazyPreparations.set(r,c);try{return await c.promise}finally{this.lazyPreparations.get(r)===c&&this.lazyPreparations.delete(r)}}async prepareLazyTreeGroup(e){let t=this.lazyAtomicGroupByTree.get(e),r=this.ordinaryLazyTreeDefinitions.get(e);if(t?.committed||t===void 0&&(r?.materialized??e.materialized))return!1;let i=this.sealedLazyAtomicStates.get(e)?.snapshot,o={token:t?.token??e,path:i?.activation.roots[0]??r?.activation.roots[0]??r?.mountPrefix??e.activation?.roots[0]??e.mountPrefix,directGroup:e,...t===void 0?{}:{atomicGroup:t}},s=this.lazyPreparations.get(o.token)??this.startLazyPreparation(o);try{return await s.promise}finally{this.lazyPreparations.get(o.token)===s&&this.lazyPreparations.delete(o.token)}}async ensureMaterialized(e){return this.preparePath(e)}async materializePath(e){if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0)return!1;let t;try{t=this.fs.stat(e)}catch{return!1}let r=V(t.ino,t.generation),i=this.lazyFiles.get(r);if(i){let s=this.lazyTransport,a=await this.fetchLazyBytes({id:`file:${t.ino}`,kind:"file",url:i.url,path:i.path,fallbackTotalBytes:i.size},s);for(let c=0;c<3;c++){if(this.lazyFiles.get(r)!==i)return!1;for(let u of new Set([e,...i.paths]))if(te(s.signal),this.fs.replaceIfIdentity(u,i.ino,i.generation,i.dataSequence,a))return i.path=u,this.lazyFiles.delete(r),!0;this.reconcileLazyIdentityState(this.fs.identityState())}throw new Error(`Lazy file kept changing names while materializing: ${e}`)}let o=this.lazyArchiveInodes.get(r);return o?(await this.ensureArchiveMaterialized(o,{path:e,ino:t.ino,generation:t.generation}),!this.lazyArchiveInodes.has(r)):!1}async decodeAndValidateLazyTree(e,t,r){let i=this.ordinaryLazyTreeDefinitions.get(e),o=r?.content??i?.content??e.content,s=r?.inventory??i?.inventory??e.inventory;if(!o||!s)throw new Error("Lazy tree is missing its decoder or complete inventory");let a=new Map,c=new Map(s.map(m=>[m.vfsPath,m]));if(o.source!==void 0)for(let m of o.source.entries)a.set(m.sourcePath,m);else for(let m of s){if(m.type==="hardlink"){let p=c.get(m.target);if(!p)throw new Error(`Lazy tree hardlink target disappeared: ${m.target}`);if(m.sourcePath===p.sourcePath)continue}if(a.get(m.sourcePath))throw new Error(`Lazy tree inventory duplicates source member ${m.sourcePath}`);a.set(m.sourcePath,{sourcePath:m.sourcePath,type:m.type,mode:m.mode,size:m.size,...m.type==="symlink"?{target:m.target}:{},...m.type==="hardlink"?{target:c.get(m.target)?.sourcePath}:{}})}let u=new Map,l=0;if(o.decoder==="zip-v1"){let{parseZipCentralDirectory:m,extractZipEntryBounded:f}=await Promise.resolve().then(()=>(fi(),li)),p=m(t);if(p.length!==o.sourceEntryCount||p.length!==a.size)throw new Error("Lazy ZIP tree decoded inventory counts differ from its descriptor");for(let _ of p){let y=_.isDirectory?_.fileName.replace(/\/$/,""):_.fileName;if(u.has(y))throw new Error(`Lazy ZIP tree duplicates source member ${y}`);let g=a.get(y);if(!g)throw new Error(`Lazy ZIP tree has undeclared source member ${y}`);if(l+=_.uncompressedSize,l>o.expandedBytes||_.uncompressedSize!==g.size)throw new Error(`Lazy ZIP tree member ${y} exceeds its inventory`);let E=_.isDirectory?"directory":_.isSymlink?"symlink":"file",O=o.modePolicy==="portable-posix-v1"?E==="directory"?493:E==="symlink"?511:(_.mode&73)!==0?493:420:_.mode&ie.S_MODE_BITS;if(E!==g.type||O!==g.mode)throw new Error(`Lazy ZIP tree member ${y} differs from inventory`);if(_.isDirectory)u.set(y,{type:"directory",mode:O});else{let S=f(t,_,g.size);if(_.isSymlink){let w;try{w=new TextDecoder("utf-8",{fatal:!0}).decode(S)}catch{throw new Error(`Lazy ZIP tree symlink ${y} is not UTF-8`)}u.set(y,{type:"symlink",mode:O,target:w})}else u.set(y,{type:"file",mode:O,data:S})}}}else{let{parseTarGzip:m}=await Promise.resolve().then(()=>(Cs(),Ns)),f=m(t,{label:`Lazy tree ${o.sha256}`,limits:{maxCompressedBytes:o.bytes,maxUncompressedBytes:o.expandedBytes,maxEntries:o.sourceEntryCount}});l=new DataView(t.buffer,t.byteOffset,t.byteLength).getUint32(t.byteLength-4,!0);for(let p of f){if(u.has(p.path))throw new Error(`Lazy TAR tree duplicates source member ${p.path}`);p.type==="file"?u.set(p.path,{type:"file",mode:p.mode,data:p.data}):p.type==="directory"?u.set(p.path,{type:"directory",mode:p.mode}):u.set(p.path,{type:p.type,mode:p.mode,target:p.linkName})}}if(u.size!==o.sourceEntryCount||u.size!==a.size||l!==o.expandedBytes)throw new Error("Lazy tree decoded inventory counts differ from its descriptor");for(let[m,f]of a){let p=u.get(m);if(!p)throw new Error(`Lazy tree is missing source member ${m}`);let _=f.type;if(p.type!==_)throw new Error(`Lazy tree member ${m} is ${p.type}, expected ${_}`);if((p.mode&ie.S_MODE_BITS)!==f.mode)throw new Error(`Lazy tree member ${m} mode differs from inventory`);if(_==="file"&&p.data?.byteLength!==f.size)throw new Error(`Lazy tree member ${m} size differs from inventory`);if(_==="symlink"&&p.target!==f.target)throw new Error(`Lazy tree symlink ${m} target differs from inventory`);if(_==="hardlink"&&p.target!==f.target)throw new Error(`Lazy tree hardlink ${m} target differs from inventory`)}let d=o.materialization;if(d!==void 0){for(let f of d.assertions){let p=u.get(f.sourcePath),_=fr(f.bytesHex);if(p?.type!=="file"||p.data===void 0||p.data.byteLength!==_.byteLength||p.data.some((y,g)=>y!==_[g]))throw new Error(`Lazy tree source assertion ${f.sourcePath} differs from archive bytes`)}let m=new Map(d.recipes.map(f=>[f.id,f]));for(let f of d.transforms){let p=u.get(f.sourcePath);if(p?.type!=="file"||p.data===void 0)throw new Error(`Lazy tree transform ${f.sourcePath} is not a regular source`);await Zs(p.data,f.input,`Lazy tree transform ${f.sourcePath} input`);let _=cs(p.data,m.get(f.recipe));await Zs(_,f.output,`Lazy tree transform ${f.sourcePath} output`),p.data=_}}let h=new Map;for(let m of s){if(m.type!=="file"||m.materialization==="descriptor")continue;let f=u.get(m.sourcePath);if(f?.type!=="file"||!f.data)throw new Error(`Lazy tree has no file content for ${m.sourcePath}`);h.set(m.sourcePath,f.data)}return h}async ensureArchiveMaterialized(e,t){let r=this.lazyAtomicGroupByTree.get(e);if(r!==void 0){if(r.committed)return;let a=this.sealedLazyAtomicStates.get(e)?.snapshot,c=this.lazyPreparations.get(r.token)??this.startLazyPreparation({token:r.token,path:a?.activation.roots[0]??e.activation?.roots[0]??e.mountPrefix,atomicGroup:r});try{await c.promise}finally{this.lazyPreparations.get(r.token)===c&&this.lazyPreparations.delete(r.token)}return}if(this.ordinaryLazyTreeDefinitions.get(e)?.materialized??e.materialized)return;let o=this.lazyTransport,s=await this.fetchLazyArchiveData(e,o);te(o.signal),await this.materializeArchiveBytes(e,s,t,o.signal)}async fetchLazyArchiveData(e,t,r){let i=this.ordinaryLazyTreeDefinitions.get(e),o=r?.content??i?.content??e.content,s=r?.inventory??i?.inventory??e.inventory,a=o!==void 0&&s!==void 0,c=r?.mountPrefix??i?.mountPrefix??e.mountPrefix,u=r?.integrity??i?.integrity??e.integrity,l=a?o.transports:[r?.url??i?.url??e.url],d=[],h=null;for(let[m,f]of l.entries())try{h=await this.fetchLazyBytes({id:`archive:${c}:${o?.sha256??f}:${m}`,kind:a?"tree":"archive",url:f,mountPrefix:c,integrity:u},t);break}catch(p){if(te(t.signal),aa(p))throw p;d.push(p instanceof Error?p.message:String(p))}if(te(t.signal),h===null)throw new Error(`All ${l.length} lazy ${a?"tree":"archive"} transports failed: ${d.join("; ")}`);return h}async materializeArchiveBytes(e,t,r,i){if(te(i),this.ordinaryLazyTreeDefinitions.get(e)?.materialized??e.materialized)return;let s=await this.prepareLazyArchiveContents(e,t,i),a=r?V(r.ino,r.generation):null;for(let c=0;c<3;c++){let u=this.collectLazyArchiveReplacements(e,s,r);if(u.size>0&&(te(i),!this.fs.replaceManyIfIdentities(Array.from(u.values(),Qs)))){if(this.reconcileLazyIdentityState(this.fs.identityState()),a&&!this.lazyArchiveInodes.has(a))return;continue}if(te(i),this.publishLazyArchiveReplacements(e,u),(this.ordinaryLazyTreeDefinitions.get(e)?.materialized??e.materialized)||(this.reconcileLazyIdentityState(this.fs.identityState()),a&&!this.lazyArchiveInodes.has(a)))return}if(a&&this.lazyArchiveInodes.has(a))throw new Error(`Lazy archive member kept changing names while materializing: ${r?.path}`)}async prepareLazyArchiveContents(e,t,r,i){te(r);let o=this.ordinaryLazyTreeDefinitions.get(e),s=i?.content??o?.content??e.content,a=i?.inventory??o?.inventory??e.inventory,u=s!==void 0&&a!==void 0?await this.decodeAndValidateLazyTree(e,t,i):null;te(r);let{parseZipCentralDirectory:l,extractZipEntry:d}=await Promise.resolve().then(()=>(fi(),li));te(r);let h=u?[]:l(t),m=new Map;for(let E of h){if(m.has(E.fileName))throw new Error(`Lazy archive contains duplicate member: ${E.fileName}`);m.set(E.fileName,E)}let p=(i?.mountPrefix??o?.mountPrefix??e.mountPrefix).replace(/\/+$/,""),_=new Map,y=i?.entries??o?.entries,g=y===void 0?Array.from(e.entries):y.map(E=>[E.vfsPath,E]);for(let[E,O]of g){if(O.deleted||O.materialized)continue;let S=O.archivePath??E.slice(p.length+1),w=u?void 0:m.get(S),z=u?.get(S);if(u){if(z===void 0||z.byteLength!==O.size)throw new Error(`Lazy tree member ${S} does not match its registered metadata`)}else if(w===void 0||w.isDirectory||w.isSymlink||w.uncompressedSize!==O.size)throw new Error(`Lazy archive member ${S} does not match its registered metadata`);if(O.generation===void 0)continue;let x=V(O.ino,O.generation),I=_.get(x);if(I&&I.archivePath!==S)throw new Error(`Lazy archive aliases for inode ${x} name different members`);if(!I){let R=z??d(t,w);if(R.byteLength!==O.size)throw new Error(`Lazy archive member ${S} extracted ${R.byteLength} bytes, expected ${O.size}`);_.set(x,{archivePath:S,content:R})}}return _}collectLazyArchiveReplacements(e,t,r,i){let o=new Map,s=this.ordinaryLazyTreeDefinitions.get(e),a=i?.entries??s?.entries,c=a===void 0?Array.from(e.entries):a.map(u=>[u.vfsPath,u]);for(let[u,l]of c){if(l.deleted||l.materialized||l.generation===void 0)continue;let d=V(l.ino,l.generation);if(this.lazyArchiveInodes.get(d)!==e)continue;let h=t.get(d);if(!h)throw new Error(`Lazy archive has no extracted content for inode ${d}`);let m=o.get(d);m||(m={ino:l.ino,generation:l.generation,dataSequence:l.dataSequence??0,paths:new Set,content:h.content},o.set(d,m)),m.paths.add(u),r&&r.ino===l.ino&&r.generation===l.generation&&m.paths.add(r.path)}return o}publishLazyArchiveReplacements(e,t){let r=this.ordinaryLazyTreeDefinitions.get(e);if(r!==void 0){let i=r.entries.map(o=>{let s=o.generation===void 0?void 0:V(o.ino,o.generation);return s===void 0||!t.has(s)?o:(this.lazyArchiveInodes.delete(s),{...o,materialized:!0})});this.replaceOrdinaryLazyTreeRuntimeState(e,i,i.every(o=>o.deleted||o.materialized));return}for(let[i,o]of t){this.lazyArchiveInodes.delete(i);for(let s of e.entries.values())s.ino===o.ino&&s.generation===o.generation&&(s.materialized=!0)}e.materialized=Array.from(e.entries.values()).every(i=>i.deleted||i.materialized)}collectAtomicTreeNamespace(e,t){let r=new Set,i=new Map,o=new Map,s=new Map(t.entries.map(c=>[c.vfsPath,c]));for(let c of t.inventory)(c.type==="file"||c.type==="hardlink")&&o.set(c.inodeGroup,(o.get(c.inodeGroup)??0)+1);let a=[];for(let c of t.inventory){let u;try{u=this.fs.lstat(c.vfsPath)}catch{throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`)}let l=c.type==="directory"?Qe:c.type==="symlink"?Sr:kt;if((u.mode&Ee)!==l||(u.mode&ie.S_MODE_BITS)!==c.mode)throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`);if(c.type==="symlink"){let d=s.get(c.vfsPath);if(d===void 0||!d.isSymlink||d.deleted||d.ino!==u.ino||d.generation!==u.generation||d.dataSequence!==u.dataSequence||this.fs.readlink(c.vfsPath)!==c.target)throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`)}else if(c.type==="file"||c.type==="hardlink"){let d=s.get(c.vfsPath);if(d===void 0||d.deleted||d.materialized||d.isSymlink||d.generation===void 0||d.inodeGroup!==c.inodeGroup||d.ino!==u.ino||d.generation!==u.generation||d.dataSequence!==u.dataSequence||u.size!==0||u.linkCount!==o.get(c.inodeGroup))throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`);let h=V(d.ino,d.generation);if(this.lazyArchiveInodes.get(h)!==e)throw new Error(`Lazy atomic tree lost deferred ownership of ${c.vfsPath}`);let m=i.get(c.inodeGroup);if(m!==void 0&&m!==h)throw new Error(`Lazy atomic tree split hard links at ${c.vfsPath}`);i.set(c.inodeGroup,h),r.add(h)}a.push({path:c.vfsPath,expectedIno:u.ino,expectedGeneration:u.generation,expectedDataSequence:u.dataSequence,expectedMode:u.mode,expectedLinkCount:u.linkCount,expectedSize:u.size,expectedUid:u.uid,expectedGid:u.gid})}return{guards:a,pendingIdentities:r.size}}assertLazyAtomicSnapshotMatchesPublic(e){let t=this.sealedLazyAtomicStates.get(e),r=t?.snapshot,i=e.activation?.atomicGroup,o=r?.member??i?.member??"unknown",s;if(r!==void 0)try{s=rn(e,r.id,r.member)}catch{s=void 0}if(t===void 0||r===void 0||i===void 0||!Ft(i)||i.id!==r.id||i.member!==r.member||i.descriptorSha256!==r.descriptorSha256||i.expectedCount!==r.expectedCount||i.cohortSha256!==r.cohortSha256||s===void 0||!Js(r,s))throw new Error(`Lazy atomic activation member ${o} changed after sealing`);return t}assertPendingLazyAtomicSnapshotsReadyForSerialization(){for(let e of this.lazyArchiveGroups){if(!this.sealedLazyAtomicStates.has(e)||this.lazyAtomicGroupByTree.get(e)?.committed)continue;let r=this.assertLazyAtomicSnapshotMatchesPublic(e);if(!r.verified)throw new Error(`Lazy atomic activation group ${r.snapshot.id} has not been cryptographically verified after import`)}}async validatePendingLazyAtomicGroupSeals(e){for(let t of this.lazyAtomicGroups.values()){if(t.committed||t.expectedCount===void 0||t.cohortSha256===void 0)continue;let r=[...t.groups.entries()].sort(([i],[o])=>io?1:0).map(([,i])=>i);await this.ensureLazyAtomicGroupSealValidated(t,r,e)}}async ensureLazyAtomicGroupSealValidated(e,t,r){if(this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r))return;let i=e.sealValidationFlight;if(i===void 0&&(i=this.validateLazyAtomicGroupSealOnce(e,t),e.sealValidationFlight=i,i.then(()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)},()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)})),await i,!this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r))throw new Error(`Lazy atomic activation group ${e.id} seal verification did not authenticate every member`)}assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r){if(e.committed)return!0;if(e.expectedCount===void 0||e.cohortSha256===void 0||t.length!==e.expectedCount)throw new Error(`Lazy atomic activation group ${e.id} is not completely sealed`);let i=!0,o=[];for(let s of t){let a=this.assertLazyAtomicSnapshotMatchesPublic(s),c=a.snapshot;if(c.id!==e.id||c.expectedCount!==e.expectedCount||c.cohortSha256!==e.cohortSha256||e.groups.get(c.member)!==s)throw new Error(`Lazy atomic activation group ${e.id} has an inconsistent member`);i&&=a.verified,o.push(a)}if(i&&r)for(let s=0;sfp?1:0).map(([,f])=>f);if(t.length===0)throw new Error(`Lazy atomic activation group ${e.id} has no trees`);if(await this.ensureLazyAtomicGroupSealValidated(e,t,!0),e.committed)return;let r=t.map(f=>this.sealedLazyAtomicStates.get(f).snapshot),i=t.map((f,p)=>({group:f,...this.collectAtomicTreeNamespace(f,r[p])})),o=this.lazyTransport,s=new Array(t.length),a=0,c=!1,u,l=Array.from({length:Math.min($d,t.length)},async()=>{for(;!c;){let f=a++;if(f>=t.length)return;let p=t[f],_=r[f];try{let y=await this.fetchLazyArchiveData(p,o,_);te(o.signal),s[f]={group:p,snapshot:_,contents:await this.prepareLazyArchiveContents(p,y,o.signal,_)}}catch(y){c||(c=!0,u=y)}}});if(await Promise.all(l),c)throw s.fill(void 0),u;te(o.signal);for(let f of t)this.assertLazyAtomicSnapshotMatchesPublic(f);let d=[],h=[],m=[];for(let f=0;f{let a=this.lazyAtomicGroupByTree.get(s),c=this.sealedLazyAtomicStates.get(s)?.snapshot,u=this.ordinaryLazyTreeDefinitions.get(s);return c===void 0?u!==void 0&&!u.materialized:!a?.committed});if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0&&r.length===0)return;let i=Array.from(this.lazyFiles.values(),s=>s.path);for(let s of i)await this.ensureMaterialized(s);let o=new Set(this.lazyArchiveInodes.values());for(let s of r)o.add(s);for(let s of o)await this.prepareLazyTreeGroup(s)}this.reconcileLazyIdentityState(this.fs.identityState());let e=this.lazyArchiveGroups.some(t=>{let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t)?.snapshot,o=this.ordinaryLazyTreeDefinitions.get(t);return i===void 0?o!==void 0&&!o.materialized:!r?.committed});if(this.lazyFiles.size!==0||this.lazyArchiveInodes.size!==0||e)throw new Error("Cannot create a self-contained VFS image while lazy entries remain pending")}async saveImage(e){e?.materializeAll&&await this.materializeAllLazyEntries(),await this.validatePendingLazyAtomicGroupSeals(!1);let{bytes:t,identities:r}=this.fs.snapshotState({normalizeTimestampsMs:e?.normalizeTimestampsMs});this.reconcileLazyIdentityState(r);let i=this.serializeLazyEntries(),o=i.length>0,s=o?new TextEncoder().encode(JSON.stringify(i)):new Uint8Array(0);if(s.byteLength>on)throw new Error(`VFS image lazy metadata exceeds ${on} bytes`);let a=this.serializeValidatedLazyArchiveEntries(r),c=a.length>0,u=c?new TextEncoder().encode(JSON.stringify(a)):new Uint8Array(0);if(u.byteLength>sn)throw new Error(`VFS image lazy archive metadata exceeds ${sn} bytes`);let l=e?.metadata===void 0?this.imageMetadata:e.metadata,d=Zd(l),h=d.byteLength>0,m=c?4+u.byteLength:0,f=h?4+d.byteLength:0,p=ye+t.byteLength+4+s.byteLength+m+f,_=new Uint8Array(p),y=new DataView(_.buffer);y.setUint32(0,zi,!0),y.setUint32(4,xi,!0),y.setUint32(8,(o?gi:0)|(c?nn:0)|(c?Si:0)|(h?Ei:0),!0),y.setUint32(12,t.byteLength,!0),_.set(t,ye);let g=ye+t.byteLength;if(y.setUint32(g,s.byteLength,!0),s.byteLength>0&&_.set(s,g+4),c){let E=g+4+s.byteLength;y.setUint32(E,u.byteLength,!0),_.set(u,E+4)}if(h){let E=g+4+s.byteLength+m;y.setUint32(E,d.byteLength,!0),_.set(d,E+4)}return _}static readImageMetadata(e){let t=Qr(e);if(!(t.flags&Ei))return null;let{metadataOffset:r}=Hs(t.image,t.view,t.flags,t.sabLen);if(t.image.byteLengthCt)throw new Error(`VFS image metadata exceeds ${Ct} bytes`);if(t.image.byteLength0){let _=r.subarray(f+4,f+4+p),y=Me(Vs(_,"VFS image lazy metadata"),"VFS image lazy entries",0,Mt);m.importLazyEntriesInternal(y,!0)}if(o&nn){let _=a.archiveOffset,y=i.getUint32(_,!0);if(y>0){let g=r.subarray(_+4,_+4+y),E=Vs(g,"VFS image lazy archive metadata");m.importLazyArchiveEntriesInternal(E,!0,!!(o&Si),"pending")}}return m}adaptStat(e){return{dev:0,ino:e.ino,mode:e.mode,nlink:e.linkCount,uid:e.uid,gid:e.gid,size:e.size,atimeMs:e.atime,mtimeMs:e.mtime,ctimeMs:e.ctime}}adaptStatWithLazySize(e){let t=this.adaptStat(e),r=this.lazyFileForStat(e);if(r)return t.size=r.size,t;let i=this.lazyArchiveForStat(e);if(i){for(let o of this.lazyArchiveEntriesForRead(i))if(o.ino===e.ino&&o.generation===e.generation&&!o.deleted){t.size=o.size;break}}return t}open(e,t,r){(t&dr)===0&&!((t&cr)!==0&&(t&ei)!==0)&&this.guardSynchronousLazyAccess(e);let i=this.fs.open(e,t,r);return(t&dr)!==0&&this.invalidateLazyData(this.fs.fstat(i)),i}close(e){return this.fs.close(e),0}read(e,t,r,i){if(i>0){let o=this.lazyBackingForStat(this.fs.fstat(e));o&&(this.reconcileLazyIdentityState(this.fs.identityState()),o=this.lazyBackingForStat(this.fs.fstat(e)),o&&this.guardSynchronousLazyAccess(o.path))}return r!==null?this.fs.readAt(e,t.subarray(0,i),typeof r=="bigint"?Un(r):r):this.fs.read(e,t.subarray(0,i))}write(e,t,r,i){if(r!==null){let s=this.fs.writeAt(e,t.subarray(0,i),typeof r=="bigint"?Un(r):r);return s>0&&this.invalidateLazyData(this.fs.fstat(e)),s}let o=this.fs.write(e,t.subarray(0,i));return o>0&&this.invalidateLazyData(this.fs.fstat(e)),o}append(e,t,r,i){let o=this.fs.append(e,t.subarray(0,r),Bo(i));return o.written>0&&this.invalidateLazyData(this.fs.fstat(e)),o}seek(e,t,r){return this.fs.lseek(e,typeof t=="bigint"?$n(t):t,r)}fstat(e){return this.adaptStatWithLazySize(this.fs.fstat(e))}fpathconf(e,t){let r=this.fstat(e);return Gn(r,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}ftruncate(e,t){this.fs.ftruncate(e,t),this.invalidateLazyData(this.fs.fstat(e))}fsync(e){}fchmod(e,t){this.fs.fchmod(e,t)}fchown(e,t,r){this.fs.fchown(e,t,r)}stat(e){return this.adaptStatWithLazySize(this.fs.stat(e))}lstat(e){return this.adaptStatWithLazySize(this.fs.lstat(e))}statfs(e){this.fs.stat(e);let t=this.fs.statfs();return{type:1397114451,bsize:t.blockSize,blocks:t.totalBlocks,bfree:t.freeBlocks,bavail:t.freeBlocks,files:t.totalInodes,ffree:t.freeInodes,fsid:0,namelen:t.maxName,frsize:t.blockSize,flags:Uo}}pathconf(e,t){let r=this.stat(e);return Gn(r,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}mkdir(e,t){this.fs.mkdir(e,t)}rmdir(e){this.fs.rmdir(e)}unlink(e){let t=this.fs.unlink(e),r=V(t.ino,t.generation);if(t.linkCount>1&&(this.lazyFiles.has(r)||this.lazyArchiveInodes.has(r))){this.reconcileLazyIdentityState(this.fs.identityState());return}let i=this.lazyFiles.get(r);i&&(i.paths.delete(e),t.linkCount<=1?this.lazyFiles.delete(r):i.path===e&&(i.path=i.paths.values().next().value));let o=this.lazyArchiveInodes.get(r);if(o){let s=this.ordinaryLazyTreeDefinitions.get(o);if(s!==void 0){let a=t.linkCount<=1?s.entries.map(c=>c.ino===t.ino&&c.generation===t.generation?{...c,deleted:!0}:c):s.entries.filter(c=>c.vfsPath!==e);this.replaceOrdinaryLazyTreeRuntimeState(o,a,s.materialized),t.linkCount<=1&&this.lazyArchiveInodes.delete(r)}else{let a=o.entries.get(e);if(t.linkCount<=1){for(let c of o.entries.values())c.ino===t.ino&&c.generation===t.generation&&(c.deleted=!0);this.lazyArchiveInodes.delete(r)}else a&&o.entries.delete(e)}}}rename(e,t){let{source:r,replaced:i}=this.fs.rename(e,t);if(i&&i.ino===r.ino&&i.generation===r.generation)return;let o=!1;if(i){let s=V(i.ino,i.generation);i.linkCount>1&&(this.lazyFiles.has(s)||this.lazyArchiveInodes.has(s))&&(this.reconcileLazyIdentityState(this.fs.identityState()),o=!0);let a=this.lazyFiles.get(s);!o&&a&&(a.paths.delete(t),i.linkCount<=1?this.lazyFiles.delete(s):a.path===t&&(a.path=a.paths.values().next().value));let c=this.lazyArchiveInodes.get(s);if(!o&&c){let u=this.ordinaryLazyTreeDefinitions.get(c);if(u!==void 0){let l=i.linkCount<=1?u.entries.map(d=>d.ino===i.ino&&d.generation===i.generation?{...d,deleted:!0}:d):u.entries.filter(d=>d.vfsPath!==t);this.replaceOrdinaryLazyTreeRuntimeState(c,l,u.materialized),i.linkCount<=1&&this.lazyArchiveInodes.delete(s)}else{let l=c.entries.get(t);i.linkCount<=1?(l&&(l.deleted=!0),this.lazyArchiveInodes.delete(s)):l&&c.entries.delete(t)}}}o||this.rewriteLazyNamespacePaths(r,e,t)}link(e,t){let r=this.fs.link(e,t),i=V(r.ino,r.generation),o=this.lazyFiles.get(i);o&&o.paths.add(t);let s=this.lazyArchiveInodes.get(i);if(s){let a=this.ordinaryLazyTreeDefinitions.get(s);if(a!==void 0){let c=a.entries.find(u=>u.ino===r.ino&&u.generation===r.generation);c!==void 0&&this.replaceOrdinaryLazyTreeRuntimeState(s,[...a.entries,{...c,vfsPath:t}],a.materialized)}else{let c=Array.from(s.entries.values()).find(u=>u.ino===r.ino&&u.generation===r.generation);c&&s.entries.set(t,{...c})}}}symlink(e,t){this.fs.symlink(e,t)}readlink(e){return this.fs.readlink(e)}chmod(e,t){this.fs.chmod(e,t)}chown(e,t,r){this.fs.chown(e,t,r)}lchown(e,t,r){this.fs.lchown(e,t,r)}createFileWithOwner(e,t,r,i,o){let s=this.open(e,Ks,t);o.length>0&&this.write(s,o,null,o.length),this.close(s),this.chown(e,r,i),this.chmod(e,t)}mkdirWithOwner(e,t,r,i){this.mkdir(e,t),this.chown(e,r,i),this.chmod(e,t)}symlinkWithOwner(e,t,r,i){this.symlink(e,t),this.lchown(t,r,i)}copyPathToFreshFileSystem(e,t,r,i,o){let s=this.lstat(e),a=s.mode&Ee,c=s.mode&ie.S_MODE_BITS;if(a===Qe){e==="/"?(t.chown(e,s.uid,s.gid),t.chmod(e,c)):t.mkdirWithOwner(e,c,s.uid,s.gid);let h=this.opendir(e);try{for(;;){let m=this.readdir(h);if(!m)break;m.name==="."||m.name===".."||this.copyPathToFreshFileSystem(e==="/"?`/${m.name}`:`${e}/${m.name}`,t,r,i,o)}}finally{this.closedir(h)}n.applyTimes(t,e,s);return}let u=s.nlink>1?`${s.dev}:${s.ino}`:null,l=u?o.get(u):void 0;if(l){t.link(l,e);return}if(a===Sr){t.symlinkWithOwner(this.readlink(e),e,s.uid,s.gid),u&&o.set(u,e);return}if(a!==kt)throw new Error(`Unsupported file type while rebasing VFS: ${e}`);if(r.has(e)||i.has(e)){t.createFileWithOwner(e,c,s.uid,s.gid,new Uint8Array(0)),n.applyTimes(t,e,s),u&&o.set(u,e);return}this.copyRegularFileToFreshFileSystem(e,t,s,c),u&&o.set(u,e)}copyRegularFileToFreshFileSystem(e,t,r,i){let o=this.open(e,Cd,0),s=null;try{s=t.open(e,Ks,i);let a=new Uint8Array(Math.min(Md,Math.max(1,r.size))),c=r.size;for(;c>0;){let u=Math.min(a.byteLength,c),l=this.read(o,a,null,u);if(l<=0)throw new Error(`Unexpected EOF while rebasing VFS file: ${e}`);let d=0;for(;d!e||e==="."||e===".."))throw new Error(`Binary resolver path must be a normalized portable relative path: ${JSON.stringify(n)}`);return n}var mt=new Set(["wasm32","wasm64"]);function Ke(n){if(Rl(n),!n.startsWith("programs/"))return n;let e=n.slice(9),t=e.split("/",1)[0];return mt.has(t)?n:`programs/wasm32/${e}`}function Ll(n,e=U(Bi(),"wasm")){let t=Ke(n),r=[U(e,t)];return n==="kernel.wasm"?r.push(U(e,"kandelo-kernel.wasm")):n==="userspace.wasm"?r.push(U(e,"wasm_posix_userspace.wasm")):n==="rootfs.vfs"&&r.push(U(e,"rootfs.vfs")),r}var mn=class extends Error{constructor(e){super(e),this.name="BinaryNotFoundError"}};function xa(){let n=[],e=!1;try{let r=ht();e=!0;for(let[i,o]of[["local-binaries",U(r,"local-binaries")],["binaries",U(r,"binaries")]])n.push({label:i,root:o,identity:i==="local-binaries"?"local-generation":"program-cache",allowRegularFileClosure:!1,candidatesFor(s){return[U(o,Ke(s))]}})}catch{}let t=U(Bi(),"wasm");return n.push({label:"installed package",root:t,identity:"installed-package",allowRegularFileClosure:!e,candidatesFor(r){return Ll(r,t)}}),n}function Dt(n,e){return new Error(`Invalid package manifest ${n}: ${e}`)}function fe(n){try{return yn(n),!0}catch(e){if(e instanceof Error&&"code"in e&&e.code==="ENOENT")return!1;throw e}}function ma(n,e,t){if(n.length===0||n.startsWith("/")||n.includes("\\")||n.includes("\0")||n.split("/").some(r=>!r||r==="."||r===".."))throw Dt(e,`${t} must be a normalized portable relative path`);return n}function pn(n,e,t,r=!0){if(n.length===0||n==="."||n===".."||n.includes("/")||n.includes("\\")||n.includes("\0")||!r&&n.includes("@"))throw Dt(e,`${t} must be a safe single path component`);return n}var ya="kandelo-program-packages-v2",De="program-packages.json",_a=null,bl=null,hn=null,Ni=0;function $i(){return bl??U(Bi(),"wasm",De)}function Ia(){if(Object.prototype.hasOwnProperty.call(process.env,"WASM_POSIX_DEPS_REGISTRY")){let t=null;return(process.env.WASM_POSIX_DEPS_REGISTRY??"").split(":").filter(Boolean).map(r=>r.startsWith("~/")&&process.env.HOME!==void 0?U(process.env.HOME,r.slice(2)):_n(r)?Re(r):(t??=ht(),Re(t,r)))}let n;try{n=U(ht(),"packages","registry")}catch{return null}let e=!1;if(fe(n)){if(!rt(n).isDirectory())return[n];e=Oa(n,{withFileTypes:!0}).filter(t=>t.isDirectory()||t.isSymbolicLink()).some(t=>fe(U(n,t.name,"package.toml")))}return!e&&Ta()===null&&fe($i())?null:[n]}function Ta(){let n;try{n=ht()}catch{return null}if(!xr(U(n,"tools","xtask","Cargo.toml"))||!xr(U(n,"scripts","dev-shell.sh")))return null;try{let e=Le(Ki()),t=Le(n);return[U(t,"host"),U(t,"scripts")].some(i=>xr(i)&&Vi(Le(i),e))?t:null}catch{return null}}function Ui(n,e,t){let r=[typeof t.stderr=="string"?t.stderr.trim():"",typeof t.stdout=="string"?t.stdout.trim():"",t.error?.message??""].filter(Boolean).join(` `);return`${n} ${e.join(" ")} failed${t.status===null?"":` with status ${t.status}`}${r?`: ${r}`:""}`}function vl(n){let e=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,t=e?"rustc":"bash",r=e?["-vV"]:[U(n,"scripts","dev-shell.sh"),"rustc","-vV"],i=Di(t,r,{cwd:n,encoding:"utf8"});if(i.status!==0)throw new Error(Ui(t,r,i));let o=i.stdout.split(/\r?\n/).find(s=>s.startsWith("host: "))?.slice(6).trim();if(!o)throw new Error(`Could not determine the Rust host target for ${n}`);return o}function Ci(n){try{if(yn(n).isFile())return Le(n)}catch{}throw new Error(`Prepared xtask is not a regular file: ${n}`)}function Pl(n){let e=process.env.WASM_POSIX_XTASK_BIN;if(e!==void 0){let u=_n(e)?Re(e):Re(n,e);return Ci(u)}if(hn?.sourceRepoRoot===n)return Ci(hn.xtaskPath);let t=vl(n),r=U(n,"target",t,"release",process.platform==="win32"?"xtask.exe":"xtask"),i=["build","--release","-p","xtask","--target",t,"--quiet"],o=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,s=o?"cargo":"bash",a=o?i:[U(n,"scripts","dev-shell.sh"),"cargo",...i],c=Di(s,a,{cwd:n,encoding:"utf8"});if(c.status!==0)throw new Error(Ui(s,a,c));return hn={sourceRepoRoot:n,xtaskPath:Ci(r)},hn.xtaskPath}function kl(){let n=Ta();if(n===null)return;let e=Ia();if(e===null)return;if(_a){_a(n,e);return}let t=Pl(n),r=["build-deps","program-index-context-check","--source-repo-root",n],i=Di(t,r,{cwd:n,encoding:"utf8",env:{...process.env,WASM_POSIX_DEPS_REGISTRY:e.join(":")}});if(i.status!==0)throw new Error(`Program package source projection is not current: ${Ui(t,r,i)}`)}function Fl(n,e){if(Ni>0||!n.some(t=>t.startsWith("programs/")))return e();Ni+=1;try{return kl(),e()}finally{Ni-=1}}function tt(n,e){let t=Object.keys(n).sort(),r=[...e].sort();return t.length===r.length&&t.every((i,o)=>i===r[o])}function Mi(n){let e;try{e=JSON.parse(pt(n,"utf8"))}catch(s){throw new Error(`Invalid program package index ${n}: ${s instanceof Error?s.message:String(s)}`)}if(typeof e!="object"||e===null||!tt(e,["format","identities","packages"])||e.format!==ya||typeof e.identities!="object"||e.identities===null||Array.isArray(e.identities)||typeof e.packages!="object"||e.packages===null||Array.isArray(e.packages))throw new Error(`Invalid program package index ${n}: expected ${ya}`);let t=new Map,r=e.identities;for(let[s,a]of Object.entries(r)){if(pn(s,n,"identity package name",!1),typeof a!="object"||a===null||!tt(a,["manifestSha256","cacheKeys"])||typeof a.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(a.manifestSha256)||typeof a.cacheKeys!="object"||a.cacheKeys===null||Array.isArray(a.cacheKeys))throw new Error(`Invalid program package index ${n}: malformed identity ${JSON.stringify(s)}`);let c=a.cacheKeys;if(!tt(c,["wasm32","wasm64"])||Object.values(c).some(u=>typeof u!="string"||!/^[a-f0-9]{64}$/.test(u)))throw new Error(`Invalid program package index ${n}: identity ${JSON.stringify(s)} has invalid contextual cache keys`);t.set(s,{manifestSha256:a.manifestSha256,cacheKeys:c})}let i=new Map,o=e.packages;for(let[s,a]of Object.entries(o)){if(pn(s,n,"package name",!1),typeof a!="object"||a===null||!tt(a,["manifestSha256","arches","cacheKeys","dependencyClosures","members"])||!Array.isArray(a.arches)||typeof a.cacheKeys!="object"||a.cacheKeys===null||Array.isArray(a.cacheKeys)||typeof a.dependencyClosures!="object"||a.dependencyClosures===null||Array.isArray(a.dependencyClosures)||!Array.isArray(a.members)||typeof a.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(a.manifestSha256))throw new Error(`Invalid program package index ${n}: malformed package ${JSON.stringify(s)}`);let c=a.arches;if(c.length===0||new Set(c).size!==c.length||c.some(p=>typeof p!="string"||!mt.has(p)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} has invalid arches`);let u=a.cacheKeys;if(!tt(u,c)||Object.values(u).some(p=>typeof p!="string"||!/^[a-f0-9]{64}$/.test(p)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} has invalid cache keys`);let l=a.dependencyClosures;if(!tt(l,c))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} has invalid dependency closure arches`);let d={};for(let p of c){let _=l[p];if(!Array.isArray(_))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} has a malformed dependency closure for ${p}`);let y=new Set;d[p]=_.map((g,E)=>{if(typeof g!="object"||g===null||!tt(g,["packageName","manifestSha256","cacheKey"])||typeof g.packageName!="string"||typeof g.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(g.manifestSha256)||typeof g.cacheKey!="string"||!/^[a-f0-9]{64}$/.test(g.cacheKey))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} dependency ${E+1} for ${p} is malformed`);let O=g;if(pn(O.packageName,n,`${s} dependency packageName`,!1),O.packageName===s||y.has(O.packageName))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} dependency closure for ${p} must contain unique dependencies other than itself`);y.add(O.packageName);let S=t.get(O.packageName);if(!S||S.manifestSha256!==O.manifestSha256||S.cacheKeys[p]!==O.cacheKey)throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} dependency ${JSON.stringify(O.packageName)} for ${p} does not match the index's authoritative contextual identity`);return O})}let h=a.members.map((p,_)=>{if(typeof p!="object"||p===null||p.kind!=="output"&&p.kind!=="runtime-file"||typeof p.sourceArtifact!="string"||typeof p.mirrorPath!="string")throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} member ${_+1} is malformed`);let y=p,g=y.kind==="output"?["kind","sourceArtifact","mirrorPath","outputName","forkInstrumentation"]:["kind","sourceArtifact","mirrorPath","guestPath","mode"];if(!tt(y,g))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} member ${_+1} has unknown or missing fields`);if(ma(y.sourceArtifact,n,`${s} sourceArtifact`),ma(y.mirrorPath,n,`${s} mirrorPath`),y.kind==="output"){if(typeof y.outputName!="string"||y.forkInstrumentation!=="auto"&&y.forkInstrumentation!=="disabled")throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} output member lacks outputName or forkInstrumentation`);pn(y.outputName,n,`${s} outputName`)}else if(typeof y.guestPath!="string"||!y.guestPath.startsWith("/")||!Number.isInteger(y.mode)||y.mode<0||y.mode>511)throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} runtime member lacks valid guestPath or mode`);return y});if(h.length===0||new Set(h.map(p=>p.sourceArtifact)).size!==h.length||new Set(h.map(p=>p.mirrorPath)).size!==h.length||h.length===1&&h[0].mirrorPath.includes("/")||h.length>1&&h.some(p=>!p.mirrorPath.startsWith(`${s}/`)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} members are empty, collide, or violate scalar/package-directory layout`);let m=a.manifestSha256,f=t.get(s);if(!f||f.manifestSha256!==m||c.some(p=>f.cacheKeys[p]!==u[p]))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} does not match its contextual package identity`);i.set(s,{manifestSha256:m,arches:c,cacheKeys:u,dependencyClosures:d,members:h})}return{identities:t,packages:i,indexPath:n}}function Ra(n){return JSON.stringify({manifestSha256:n.manifestSha256,arches:n.arches,cacheKeys:Object.fromEntries(n.arches.map(e=>[e,n.cacheKeys[e]])),dependencyClosures:Object.fromEntries(n.arches.map(e=>[e,[...n.dependencyClosures[e]].sort((t,r)=>t.packageNamer.packageName?1:0)])),members:n.members.map(e=>e.kind==="output"?{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,outputName:e.outputName,forkInstrumentation:e.forkInstrumentation}:{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,guestPath:e.guestPath,mode:e.mode})})}function Wi(){let n=$i();return fe(n)?Mi(n):null}function Nl(n){let e=Wi();if(!e)return null;let t=n.split("/");if(t[0]!=="programs"||!mt.has(t[1]))return null;let r=t[1];if(t.length>=4){let o=t[2];return e.packages.get(o)?.arches.includes(r)?o:null}if(t.length!==3)return null;let i=t[2];for(let[o,s]of e.packages)if(s.arches.includes(r)&&s.members.some(a=>a.kind==="output"&&a.mirrorPath.split("/").at(-1)===i))return o;return null}function ga(n){let e=Nl(n);if(e)throw new Error(`Installed package resolver path ${JSON.stringify(n)} is owned by ${JSON.stringify(e)}, but that package is not selected by the configured program registry`)}function La(){let n=Ia(),e=new Map,t=new Map,r=new Map,i=new Map,o=[];if(n===null){let u=$i();if(!fe(u))return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:o};let l=Mi(u);for(let[d,h]of l.identities)e.set(d,{...h,packageName:d,policyPath:`${l.indexPath}#identities.${d}`});for(let[d,h]of l.packages)o.push({packageName:d,projection:h,selected:!0}),r.set(d,{...h,packageName:d,policyPath:`${l.indexPath}#${d}`});return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:o}}let s=new Set,a=null,c=null;for(let u of n){if(!fe(u))continue;if(!rt(u).isDirectory())throw new Error(`Program registry root is not a directory: ${u}`);let l=U(u,De);if(!fe(l))throw new Error(`Program registry ${u} is missing ${De}; generate it with xtask build-deps program-index`);let d=Mi(l);a??=d.identities,c??=d.packages;let h=Oa(u,{withFileTypes:!0}).filter(m=>m.isDirectory()||m.isSymbolicLink()).sort((m,f)=>m.name.localeCompare(f.name));for(let m of h){let f=m.name,p=U(u,f,"package.toml");if(!fe(p))continue;let _=!1;try{_=rt(p).isFile()}catch{_=!1}if(!_)continue;let y=d.packages.get(f),g=!s.has(f);if(y&&o.push({packageName:f,projection:y,selected:g}),!g)continue;s.add(f);let E=a.get(f);E?e.set(f,{...E,packageName:f,manifestPath:p,policyPath:p}):t.set(f,p);let O=c.get(f);if(!O){i.set(f,p);continue}r.set(f,{...O,packageName:f,manifestPath:p,policyPath:p})}}return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:o}}function Ea(n){if(!n.manifestPath)return;let e;try{e=pt(n.manifestPath)}catch(r){throw new Error(`Program package identity cannot verify ${n.manifestPath}: ${r instanceof Error?r.message:String(r)}`)}if(Aa("sha256").update(e).digest("hex")!==n.manifestSha256)throw new Error(`Program package identity is stale for ${n.manifestPath}; regenerate ${De}`)}function Cl(n){if(!n.manifestPath)return;let e;try{e=pt(n.manifestPath)}catch(r){throw new Error(`Program package projection cannot verify ${n.manifestPath}: ${r instanceof Error?r.message:String(r)}`)}if(Aa("sha256").update(e).digest("hex")!==n.manifestSha256)throw new Error(`Program package projection is stale for ${n.manifestPath}; regenerate ${De}`)}function Ir(n){let e=Gi(),t=e.packages.get(n);if(t)return Cl(t),t;let r=e.unprojectedPackages.get(n);if(r)throw new Error(`Package ${JSON.stringify(n)} is selected at ${r} but is absent from ${De}; regenerate the registry projection`);return null}function Ml(n,e){let t=n.dependencyClosures[e];if(!t)throw Dt(n.policyPath,`package ${JSON.stringify(n.packageName)} lacks a dependency identity closure for ${e}`);let r=La(),i=r.identities.get(n.packageName);if(!i){let s=r.unidentifiedPackages.get(n.packageName);throw new Error(`Program package ${JSON.stringify(n.packageName)} has no authoritative contextual identity for ${e}${s?` at ${s}`:""}; regenerate ${De} with the exact ordered registry roots`)}Ea(i);let o=i.cacheKeys[e];if(i.manifestSha256!==n.manifestSha256||o!==n.cacheKeys[e])throw new Error(`Program package ${JSON.stringify(n.packageName)} was projected with manifest ${n.manifestSha256} and cache key ${n.cacheKeys[e]} for ${e}, but the authoritative first-hit registry context at ${i.policyPath} requires manifest ${i.manifestSha256} and cache key ${o??""}. Regenerate this program projection with the exact ordered registry roots; the highest-priority index must carry the complete combined-context projection rather than relying on a lower suffix-context build identity.`);for(let s of t){let a=r.identities.get(s.packageName);if(!a){let u=r.unidentifiedPackages.get(s.packageName);throw u?new Error(`Program package ${JSON.stringify(n.packageName)} was generated against dependency ${JSON.stringify(s.packageName)}, but the first-hit package at ${u} has no contextual identity in ${De}`):new Error(`Program package ${JSON.stringify(n.packageName)} was generated against dependency ${JSON.stringify(s.packageName)}, but that dependency is absent from the configured first-hit registry roots`)}Ea(a);let c=a.cacheKeys[e];if(a.manifestSha256!==s.manifestSha256||c!==s.cacheKey)throw new Error(`Program package ${JSON.stringify(n.packageName)} has a contextual cache identity mismatch for ${e}: its projection expects dependency ${JSON.stringify(s.packageName)} manifest ${s.manifestSha256} and cache key ${s.cacheKey}, but first-hit selection at ${a.policyPath} provides manifest ${a.manifestSha256} and cache key ${c??""}. Regenerate the program projection with the exact ordered registry roots; the complete highest-priority projection must bind every selected program to the same combined dependency context.`)}}function Gi(){let n=La(),{physicalProgramClaims:e,...t}=n,r={...t,legacyFlatOutputs:new Map,forkInstrumentationDisabledOutputs:new Map},i=[];for(let o of n.packages.values()){let s=o.members.length>1;for(let a of o.arches)for(let c of o.members){let u=i.find(m=>m.arch===a&&(m.path===c.mirrorPath||m.path.startsWith(`${c.mirrorPath}/`)||c.mirrorPath.startsWith(`${m.path}/`)));if(u)throw new Error(`Program resolver paths programs/${a}/${u.path} and programs/${a}/${c.mirrorPath} conflict between selected packages ${JSON.stringify(u.packageName)} and ${JSON.stringify(o.packageName)}`);if(i.push({arch:a,path:c.mirrorPath,packageName:o.packageName}),c.kind!=="output")continue;let l=c.mirrorPath.split("/").at(-1),d=`${a}/${l}`,h=r.legacyFlatOutputs.get(d);h||(h={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},r.legacyFlatOutputs.set(d,h)),s?h.packagePaths.set(`programs/${a}/${c.mirrorPath}`,o.packageName):h.scalarOwners.add(o.packageName),c.forkInstrumentation==="disabled"&&r.forkInstrumentationDisabledOutputs.set(`${a}/${c.mirrorPath}`,o.packageName)}}for(let{packageName:o,projection:s,selected:a}of e)if(!(a&&n.packages.has(o)))for(let c of s.arches)for(let u of s.members){if(u.kind!=="output")continue;let l=u.mirrorPath.split("/").at(-1),d=`${c}/${l}`,h=r.legacyFlatOutputs.get(d);h||(h={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},r.legacyFlatOutputs.set(d,h)),h.shadowedOwners.add(o)}return r}function Dl(n){let e=n.split("/");if(e.length!==3||e[0]!=="programs"||!mt.has(e[1]))return null;let t=Gi().legacyFlatOutputs.get(`${e[1]}/${e[2]}`);if(!t)return null;for(let r of t.scalarOwners){let i=Ir(r);if(i)return i}for(let r of t.packagePaths.values())Ir(r);if(t.packagePaths.size>0)throw new Error(`Legacy flat resolver path ${JSON.stringify(n)} belongs to a multi-member package; use ${[...t.packagePaths.keys()].sort().map(r=>JSON.stringify(r)).join(" or ")}`);for(let r of t.shadowedOwners){let i=Ir(r);if(i)return i;throw new Error(`Legacy flat resolver path ${JSON.stringify(n)} is claimed by a lower-root program package ${JSON.stringify(r)}, but its first-hit selected package does not project that program; stale scalar mirror fallback is forbidden`)}return null}function Sa(n,e,t){if(!n.arches.includes(e))throw Dt(n.policyPath,`package ${JSON.stringify(n.packageName)} does not declare resolver artifacts for ${e}`);let r=n.cacheKeys[e];if(!r)throw Dt(n.policyPath,`package ${JSON.stringify(n.packageName)} lacks a cache identity for ${e}`);Ml(n,e);let i=Ra(n),o=n.members.map(s=>({packageName:n.packageName,relPath:`programs/${e}/${s.mirrorPath}`,sourceArtifact:s.sourceArtifact,cacheKey:r,forkInstrumentation:s.kind==="output"?s.forkInstrumentation??null:null,projectionIdentity:i}));if(!o.some(s=>s.relPath===t))throw Dt(n.policyPath,`resolver path ${JSON.stringify(t)} is not a declared member of package ${JSON.stringify(n.packageName)}`);return{manifestPath:n.policyPath,packageName:n.packageName,members:o}}function Kl(n){let e=Ke(n),t=e.split("/");if(t[0]==="programs"&&!Il()&&Wi()===null)throw new Error(`Installed host package is missing wasm/${De}; program artifacts cannot be resolved without packaged policy`);if(t.length===3){let s=Dl(e);return s?Sa(s,t[1],e):(ga(e),null)}if(t.length<4||t[0]!=="programs"||!mt.has(t[1]))return null;let r=t[1],i=t[2],o=Ir(i);return o?Sa(o,r,e):(ga(e),null)}function Bl(n){let e=Ke(n);for(let t of["programs/wasm32/","programs/wasm64/"])if(e.startsWith(t))return e.slice(t.length);return null}function $l(n){let e=Ke(n);for(let t of mt){let r=`programs/${t}/`;if(e.startsWith(r)){let i=Gi().forkInstrumentationDisabledOutputs.get(`${t}/${e.slice(r.length)}`);return i?Ir(i)!==null:!1}}return!1}function Ul(n){let e=Ke(n);if(e==="kernel.wasm")return Ao;let t=Bl(e);if(t&&t.endsWith(".wasm"))return zl}var Wl=Object.freeze(["kernel_exec_prepare","kernel_exec_setup","kernel_exec_setup_for_thread","kernel_execve","kernel_execveat"]);function Gl(n){return Ke(n)==="kernel.wasm"?Wl:void 0}function Hl(n,e,t){if(!n.endsWith(".wasm"))return!1;try{let r=pt(n),i=r.buffer.slice(r.byteOffset,r.byteOffset+r.byteLength),o=t===void 0?$l(e):t==="disabled";return Fo(i,{expectedAbi:43,requiredExports:Ul(e),forbiddenExports:Gl(e),requireForkInstrumentation:o?!1:void 0,forbidForkInstrumentation:o}).length>0}catch{return!0}}function Vl(n){if(!n.endsWith(".vfs")&&!n.endsWith(".vfs.zst"))return!1;try{let t=q.readImageMetadata(pt(n))?.kernelAbi;return t!==void 0&&t!==43}catch{return!0}}function Hi(n,e,t){return Hl(n,e,t)||Vl(n)}function ba(n,e,t){let r=n.filter(fe);return r.length===0?null:r.find(i=>{try{return rt(i).isFile()&&!Hi(i,e,t)}catch{return!1}})??null}function va(n,e,t){try{if(!yn(n).isSymbolicLink())return n;let i=Le(n);if(!rt(i).isFile()||Hi(i,e,t))throw new Error("canonical target is not an accepted regular file");if(Ke(e).startsWith("programs/")&&ql(i))throw new Error("resolver-owned program generation has no matching selected package projection");return i}catch(r){throw new Error(`Binary changed or became invalid while pinning ${e}: ${r instanceof Error?r.message:String(r)}`)}}function ql(n){let e=[za()];try{e.push(U(ht(),"local-binaries",".kandelo-local-generations"))}catch{}return e.some(t=>{try{return fe(t)&&Vi(Le(t),n)}catch{return!1}})}function Vi(n,e){let t=wl(n,e);return t===""||t!==".."&&!t.startsWith(`..${Ol}`)&&!_n(t)}function Zl(n,e){let t=e.split("/"),r=n;for(let i=0;ia.packageName!==o))return"declared package members do not share a valid program namespace";if(!rt(e).isDirectory())return"shared package generation root is not a directory";let s=t[0].cacheKey;if(!/^[a-f0-9]{64}$/.test(s)||t.some(a=>a.cacheKey!==s))return"declared package members do not share one valid cache identity";if(n.identity==="local-generation"){let a=U(n.root,".kandelo-local-generations",i,o,s);if(!fe(a))return"local mirror targets are not one direct immutable local generation";let c=Le(a);return Tr(e)===c?null:"local mirror targets are not one direct immutable local generation"}if(n.identity==="program-cache"){let a=za();if(!fe(a))return"fetched mirror targets are not one canonical program-cache generation";let c=Le(a),u=Sl(e),l=u.startsWith(`${o}-`)&&new RegExp(`-rev[0-9]+-${i}-${s}$`).test(u);return Tr(e)===c&&l?null:"fetched mirror targets are not one canonical program-cache generation"}return"installed-package symlink closures are not an immutable installed identity"}function Xl(n,e,t){if(e.length!==t.length)return{failure:"internal member/path count mismatch"};try{let r=e.map(u=>{let l=yn(u);return l.isSymbolicLink()?"symlink":l.isFile()?"file":"other"});if(r.includes("other"))return{failure:"a selected mirror member is neither a regular file nor a symlink"};let i=r.every(u=>u==="symlink"),o=r.every(u=>u==="file");if(!i&&!o)return{failure:"regular files and symlinks cannot share one package identity"};if(o){if(!n.allowRegularFileClosure)return{failure:"a mutable source-checkout wasm tree is not an installed package identity"};let u=t[0].packageName,l=t[0].projectionIdentity;if(t.some(p=>p.packageName!==u||p.projectionIdentity!==l))return{failure:"declared members do not share one selected package projection"};let h=Wi()?.packages.get(u);if(!h||Ra(h)!==l)return{failure:"installed bytes do not match the selected package projection"};let m=Le(n.root),f=[];for(let p of e){let _=Le(p);if(!Vi(m,_)||!rt(_).isFile())return{failure:"an installed-package member escapes its immutable wasm tree"};f.push(_)}return{paths:f}}let s=null,a=[];for(let u=0;ujl(n))}function jl(n){let e=Ke(n),t=Kl(e);if(t){let s=Jl(t.members.map(a=>a.relPath),t.members);if(s)return s[t.members.findIndex(a=>a.relPath===e)];throw new mn(`Package artifacts not found for ${t.packageName}: ${e}`)}let r=[],i=[];for(let s of xa())for(let a of s.candidatesFor(n))r.push(a),i.push(a);let o=ba(i,n);if(o)return va(o,n);throw i.some(fe)?new Error(`Binary exists but was rejected by artifact policy: ${n} diff --git a/scripts/test-wasm-artifact-guards.sh b/scripts/test-wasm-artifact-guards.sh index 342d51f46c..34a77236c9 100755 --- a/scripts/test-wasm-artifact-guards.sh +++ b/scripts/test-wasm-artifact-guards.sh @@ -29,6 +29,7 @@ cat >"$work/target-aware-exec.wat" <<'WAT' (func (export "kernel_exec_target_read")) (func (export "kernel_exec_target_cancel")) (func (export "kernel_exec_commit")) + (func (export "kernel_publish_spawn_child")) (func (export "kernel_spawn_exec_commit"))) WAT wat2wasm "$work/target-aware-exec.wat" -o "$work/target-aware-exec.wasm" @@ -41,6 +42,7 @@ cat >"$work/hybrid-exec.wat" <<'WAT' (func (export "kernel_exec_target_read")) (func (export "kernel_exec_target_cancel")) (func (export "kernel_exec_commit")) + (func (export "kernel_publish_spawn_child")) (func (export "kernel_spawn_exec_commit")) (func (export "kernel_exec_prepare")) (func (export "kernel_exec_setup")) diff --git a/scripts/wasm-artifact-guards.sh b/scripts/wasm-artifact-guards.sh index 908f238f84..84fb9f3bf4 100644 --- a/scripts/wasm-artifact-guards.sh +++ b/scripts/wasm-artifact-guards.sh @@ -1327,6 +1327,7 @@ wasm_require_target_aware_exec_authority() { kernel_exec_target_read \ kernel_exec_target_cancel \ kernel_exec_commit \ + kernel_publish_spawn_child \ kernel_spawn_exec_commit && wasm_reject_exports "$path" \ kernel_exec_prepare \ From aef278cfa953dcbb5ba1229f766a9c5049814675 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Tue, 11 Aug 2026 07:41:24 -0400 Subject: [PATCH 63/82] Libc: Enter secure startup for set-ID images --- abi/snapshot.json | 8 +- .../test/secure-exec-startup.spec.ts | 303 ++++++++ crates/kernel/src/wasm_api.rs | 15 + crates/shared/src/lib.rs | 3 +- docs/architecture.md | 11 + docs/browser-support.md | 6 +- docs/sdk-guide.md | 8 +- examples/dlopen/build.sh | 2 +- host/src/browser-kernel-host.ts | 56 ++ host/src/browser-kernel-protocol.ts | 12 +- host/src/browser-kernel-worker-entry.ts | 40 +- host/src/generated/abi.ts | 3 +- host/src/kernel-worker.ts | 38 + host/src/node-kernel-worker-entry.ts | 16 + host/src/vfs/privileged-projection.ts | 71 +- host/src/worker-main.ts | 20 +- host/src/worker-protocol.ts | 4 + host/test/centralized-test-helper.ts | 118 +++ host/test/dri-cube-pyramid.test.ts | 2 + host/test/dri-smoke.test.ts | 1 + host/test/fork-dlopen-replay-e2e.test.ts | 2 +- .../fork-from-dlopen-side-module-e2e.test.ts | 2 +- host/test/framebuffer-integration.test.ts | 1 + host/test/kernel-authority-boundary.test.ts | 1 + host/test/kernel-telemetry-entry.test.ts | 24 +- host/test/mouse-integration.test.ts | 1 + host/test/privileged-projection.test.ts | 24 + host/test/secure-exec.test.ts | 243 ++++++ host/test/support/kernel-scratch-instance.ts | 4 + host/test/vfork-side-module-fixture.ts | 2 +- host/test/worker-entry.test.ts | 2 + libc/glue/syscall_imports.h | 3 + libc/musl-overlay/src/env/__libc_start_main.c | 37 +- packages/registry/lsof/build-lsof.sh | 2 +- .../mariadb/wasm32-posix-toolchain.cmake | 2 +- .../mariadb/wasm64-posix-toolchain.cmake | 2 +- packages/registry/program-packages.json | 716 +++++++++--------- programs/secure-exec-probe.c | 202 +++++ scripts/build-programs.sh | 4 +- scripts/run-browser-libc-tests.sh | 2 +- scripts/run-browser-posix-tests.sh | 2 +- scripts/run-browser-sortix-tests.sh | 2 +- scripts/run-libc-tests.sh | 2 +- scripts/run-posix-tests.sh | 2 +- scripts/run-sortix-tests.sh | 2 +- sdk/kandelo/bin/wasm32posix-cc | 2 +- sdk/src/lib/flags.ts | 4 +- sdk/test/cc.test.ts | 6 +- sdk/test/flags.test.ts | 6 +- tools/xtask/src/dump_abi.rs | 1 + 50 files changed, 1643 insertions(+), 399 deletions(-) create mode 100644 apps/browser-demos/test/secure-exec-startup.spec.ts create mode 100644 host/test/secure-exec.test.ts create mode 100644 programs/secure-exec-probe.c diff --git a/abi/snapshot.json b/abi/snapshot.json index bad5bbb544..5f15ade07d 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -1104,6 +1104,8 @@ "kernel_process_metadata_cancel", "kernel_process_metadata_commit", "kernel_process_metadata_stage", + "kernel_process_secure_exec", + "kernel_publish_spawn_child", "kernel_reap_exited_child", "kernel_remove_process", "kernel_semctl_array_bytes", @@ -1111,7 +1113,6 @@ "kernel_set_current_tid", "kernel_set_cwd", "kernel_shmid_ds_bytes", - "kernel_publish_spawn_child", "kernel_spawn_exec_commit", "kernel_spawn_exec_target_prepare", "kernel_spawn_process", @@ -2759,6 +2760,11 @@ "name": "kernel_process_metadata_stage", "signature": "(i32,i32,i32,i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_process_secure_exec", + "signature": "(i32) -> (i32)" + }, { "kind": "func", "name": "kernel_pselect6", diff --git a/apps/browser-demos/test/secure-exec-startup.spec.ts b/apps/browser-demos/test/secure-exec-startup.spec.ts new file mode 100644 index 0000000000..71cf92e321 --- /dev/null +++ b/apps/browser-demos/test/secure-exec-startup.spec.ts @@ -0,0 +1,303 @@ +import { expect, test } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const browserKernelModulePath = resolve( + here, + "../../../host/src/browser-kernel-host.ts", +); +const memoryFsModulePath = resolve( + here, + "../../../host/src/vfs/memory-fs.ts", +); +const privilegedProjectionModulePath = resolve( + here, + "../../../host/src/vfs/privileged-projection.ts", +); +const probePath = resolve( + here, + "../../../local-binaries/programs/wasm32/secure-exec-probe.wasm", +); +const SECURE_STDOUT_SENTINEL = "secure-stdout-sentinel\n"; +const SECURE_STDERR_SENTINEL = "secure-stderr-sentinel\n"; + +test("ordinary startup receives the kernel-owned non-secure marker", async ({ + page, + baseURL, +}) => { + expect(baseURL).toBeTruthy(); + const runtimeErrors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") { + runtimeErrors.push(`console: ${message.text()}`); + } + }); + page.on("pageerror", (error) => { + runtimeErrors.push(`pageerror: ${error.message}`); + }); + await page.route("**/favicon.ico", (route) => + route.fulfill({ status: 204 }), + ); + await page.goto(new URL("/trap-signal-test.html", baseURL).href); + + const asViteUrl = (path: string) => new URL(`/@fs${path}`, baseURL).href; + const result = await page.evaluate(async ({ + browserKernelModuleUrl, + memoryFsModuleUrl, + probeBytes, + }) => { + const { BrowserKernel } = await import( + /* @vite-ignore */ browserKernelModuleUrl + ); + const { MemoryFileSystem } = await import( + /* @vite-ignore */ memoryFsModuleUrl + ); + const image = MemoryFileSystem.create( + new SharedArrayBuffer(2 * 1024 * 1024), + ); + let stdout = ""; + let stderr = ""; + const hostDiagnostics: unknown[] = []; + const kernel = new BrowserKernel({ + maxWorkers: 2, + onStdout: (data: Uint8Array) => { + stdout += new TextDecoder().decode(data); + }, + onStderr: (data: Uint8Array) => { + stderr += new TextDecoder().decode(data); + }, + onHostDiagnostic: (diagnostic: unknown) => { + hostDiagnostics.push(diagnostic); + }, + }); + await kernel.initFromImage({ vfsImage: await image.saveImage() }); + try { + const exitCode = await kernel.spawn( + new Uint8Array(probeBytes).buffer, + ["secure-exec-probe", "startup-target", "0", "0"], + { env: ["KANDELO_UNTRUSTED=ordinary-browser-startup"] }, + ); + return { exitCode, stdout, stderr, hostDiagnostics }; + } finally { + await kernel.destroy(); + } + }, { + browserKernelModuleUrl: asViteUrl(browserKernelModulePath), + memoryFsModuleUrl: asViteUrl(memoryFsModulePath), + probeBytes: Array.from(readFileSync(probePath)), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toBe( + "secure=0 ctor_secure=0 untrusted_visible=1 ctor_visible=1\n", + ); + expect(result.stderr).toBe(""); + expect(result.hostDiagnostics).toEqual([]); + expect(runtimeErrors).toEqual([]); +}); + +test("browser worker preserves postcommit secure-exec state", async ({ + page, + baseURL, +}) => { + expect(baseURL).toBeTruthy(); + const runtimeErrors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") { + runtimeErrors.push(`console: ${message.text()}`); + } + }); + page.on("pageerror", (error) => { + runtimeErrors.push(`pageerror: ${error.message}`); + }); + await page.route("**/favicon.ico", (route) => + route.fulfill({ status: 204 }), + ); + await page.goto(new URL("/trap-signal-test.html", baseURL).href); + + const asViteUrl = (path: string) => new URL(`/@fs${path}`, baseURL).href; + const result = await page.evaluate(async ({ + browserKernelModuleUrl, + memoryFsModuleUrl, + privilegedProjectionModuleUrl, + probeBytes, + }) => { + const { BrowserKernel } = await import( + /* @vite-ignore */ browserKernelModuleUrl + ); + const { MemoryFileSystem } = await import( + /* @vite-ignore */ memoryFsModuleUrl + ); + const { + createReviewedPrivilegedProgramPolicy, + publishPrivilegedProgramProduct, + } = await import(/* @vite-ignore */ privilegedProjectionModuleUrl); + const probe = Uint8Array.from(probeBytes); + const digest = Array.from(new Uint8Array( + await crypto.subtle.digest("SHA-256", probe), + )).map((byte) => byte.toString(16).padStart(2, "0")).join(""); + const destinations = [ + ["login", "/usr/bin/login"], + ["sudo-lite", "/usr/bin/sudo-lite"], + ["sudo", "/usr/bin/sudo"], + ] as const; + + const sourceFs = MemoryFileSystem.create( + new SharedArrayBuffer(Math.max(8 * 1024 * 1024, probe.byteLength * 4)), + ); + for (const [sourcePath] of destinations) { + sourceFs.createFileWithOwner(`/${sourcePath}`, 0o755, 1000, 1000, probe); + } + const bottleSha256 = "a".repeat(64); + const policy = createReviewedPrivilegedProgramPolicy( + destinations.map(([sourcePath, destinationPath]) => ({ + schema: 1, + formula: `kandelo-test/${sourcePath}`, + bottleSha256, + sourcePath, + destinationPath, + uid: 0, + gid: 0, + mode: 0o4755, + mountPoint: "trusted-root-product", + artifactValidationSha256: digest, + })), + ); + const privilegedProduct = await publishPrivilegedProgramProduct({ + policy, + sources: destinations.map(([sourcePath]) => ({ + formula: `kandelo-test/${sourcePath}`, + bottleSha256, + fs: sourceFs, + inventory: { + entries: [{ sourcePath, type: "file", size: probe.byteLength }], + }, + guestPathForSource: (path: string) => `/${path}`, + })), + writableBottleFileSystems: [sourceFs], + }); + + const nosuidFs = MemoryFileSystem.create( + new SharedArrayBuffer(Math.max(4 * 1024 * 1024, probe.byteLength * 2)), + ); + nosuidFs.mkdir("/bin", 0o755); + nosuidFs.mkdir("/usr", 0o755); + nosuidFs.mkdir("/usr/bin", 0o755); + nosuidFs.createFileWithOwner( + "/bin/secure-parent", + 0o4755, + 0, + 0, + probe, + ); + nosuidFs.createFileWithOwner( + "/bin/secure-child", + 0o755, + 0, + 0, + probe, + ); + const nosuidImage = await nosuidFs.saveImage(); + + const run = async ( + vfsImage: Uint8Array, + argv: string[], + trusted = false, + ) => { + let stdout = ""; + let stderr = ""; + const hostDiagnostics: unknown[] = []; + const kernel = new BrowserKernel({ + maxWorkers: 4, + onStdout: (data: Uint8Array) => { + stdout += new TextDecoder().decode(data); + }, + onStderr: (data: Uint8Array) => { + stderr += new TextDecoder().decode(data); + }, + onHostDiagnostic: (diagnostic: unknown) => { + hostDiagnostics.push(diagnostic); + }, + }); + if (trusted) { + await kernel.initFromPublishedPrivilegedProgramProduct({ + vfsImage, + privilegedProduct, + }); + } else { + await kernel.initFromImage({ vfsImage }); + } + try { + const exitCode = await kernel.spawn( + probe.buffer, + argv, + { uid: 1000, gid: 1000 }, + ); + return { exitCode, stdout, stderr, hostDiagnostics }; + } finally { + await kernel.destroy(); + } + }; + + return { + trusted: await run(nosuidImage, [ + "secure-exec-probe", "launch", "/usr/bin/login", + "startup-target", "1", "0", + ], true), + nosuid: await run(nosuidImage, [ + "secure-exec-probe", "launch", "/bin/secure-parent", + "startup-target", "0", "0", + ]), + spawnPreserve: await run(nosuidImage, [ + "secure-exec-probe", "launch", "/usr/bin/login", + "spawn-parent", "1", "0", "/bin/secure-child", "startup-target", + ], true), + spawnReset: await run(nosuidImage, [ + "secure-exec-probe", "launch", "/usr/bin/login", + "spawn-parent", "1", "1", "/bin/secure-child", "startup-target", + ], true), + stdioOpen: await run(nosuidImage, [ + "secure-exec-probe", "launch", "/usr/bin/login", + "stdio-target", "1", "0", + ], true), + stdioClosed: await run(nosuidImage, [ + "secure-exec-probe", "launch", "/usr/bin/login", + "stdio-target", "1", "7", + ], true), + }; + }, { + browserKernelModuleUrl: asViteUrl(browserKernelModulePath), + memoryFsModuleUrl: asViteUrl(memoryFsModulePath), + privilegedProjectionModuleUrl: asViteUrl(privilegedProjectionModulePath), + probeBytes: Array.from(readFileSync(probePath)), + }); + + expect(result.trusted.exitCode, result.trusted.stderr).toBe(0); + expect(result.trusted.stdout).toBe( + "secure=1 ctor_secure=1 untrusted_visible=0 ctor_visible=0\n", + ); + expect(result.nosuid.exitCode, result.nosuid.stderr).toBe(0); + expect(result.nosuid.stdout).toBe( + "secure=0 ctor_secure=0 untrusted_visible=1 ctor_visible=1\n", + ); + expect(result.spawnPreserve.exitCode, result.spawnPreserve.stderr).toBe(0); + expect(result.spawnPreserve.stdout).toContain( + "secure=1 ctor_secure=1 untrusted_visible=0 ctor_visible=0", + ); + expect(result.spawnReset.exitCode, result.spawnReset.stderr).toBe(0); + expect(result.spawnReset.stdout).toContain( + "secure=0 ctor_secure=0 untrusted_visible=1 ctor_visible=1", + ); + expect(result.stdioOpen.exitCode, result.stdioOpen.stderr).toBe(0); + expect(result.stdioOpen.stdout).toContain(SECURE_STDOUT_SENTINEL); + expect(result.stdioOpen.stderr).toContain(SECURE_STDERR_SENTINEL); + expect(result.stdioClosed.exitCode, result.stdioClosed.stderr).toBe(0); + expect(result.stdioClosed.stdout).not.toContain(SECURE_STDOUT_SENTINEL); + expect(result.stdioClosed.stderr).not.toContain(SECURE_STDERR_SENTINEL); + for (const acceptance of Object.values(result)) { + expect(acceptance.hostDiagnostics).toEqual([]); + } + expect(runtimeErrors).toEqual([]); +}); diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index cd37b013ad..2c2e1b1472 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -1629,6 +1629,21 @@ pub extern "C" fn kernel_set_process_credentials(pid: u32, uid: u32, gid: u32) - } } +/// Return the sticky secure-execution marker for one committed process image. +/// +/// The host queries this only after the kernel has committed the exact exec +/// target (or after creating/copying an initial/fork process record). The +/// value is kernel-owned: launch paths must not reconstruct it from ids, +/// paths, argv, environment, or host configuration. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_process_secure_exec(pid: u32) -> i32 { + let table = unsafe { &*PROCESS_TABLE.0.get() }; + match table.get(pid) { + Some(proc) => i32::from(proc.secure_exec), + None => -(Errno::ESRCH as i32), + } +} + /// Begin one Rust-owned argv/environment replacement. /// /// The returned positive token owns two initially empty staging vectors. The diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 1c736cbc7d..d5e6d517ac 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -3020,6 +3020,8 @@ pub mod abi { "kernel_process_metadata_cancel", "kernel_process_metadata_commit", "kernel_process_metadata_stage", + "kernel_process_secure_exec", + "kernel_publish_spawn_child", "kernel_reap_exited_child", "kernel_remove_process", "kernel_semctl_array_bytes", @@ -3027,7 +3029,6 @@ pub mod abi { "kernel_set_current_tid", "kernel_set_cwd", "kernel_shmid_ds_bytes", - "kernel_publish_spawn_child", "kernel_spawn_exec_commit", "kernel_spawn_exec_target_prepare", "kernel_spawn_process", diff --git a/docs/architecture.md b/docs/architecture.md index 87b24d78a0..de4e97968a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1708,6 +1708,17 @@ capability. Ordinary image, scratch, host, OPFS, device, and user-provided backends cannot acquire that capability from public fields, prototypes, or configuration. +The browser peer consumes a published product through +`BrowserKernel.initFromPublishedPrivilegedProgramProduct`. The publisher keeps +a private serialized `/usr/bin` projection behind the exact publication +object; neither mutation of the public build artifact nor a structurally +similar object can retrieve it. The browser main thread copies that private +projection into its worker-only init message, where the VFS-owning worker +verifies the image, snapshots it behind a new immutable-product backend, and +mounts it read-only at `/usr/bin` over the ordinary `nosuid` root image. Public +boot descriptors, shared URLs, and `initFromImage` have no field that can +request this mount or supply its authority. + The browser host layers two additional, host-specific mounts on top: `/dev/shm` (the POSIX-semaphore SAB shared with main-thread surfaces) and `/dev` (`DeviceFileSystem` for `/dev/null`, `/dev/zero`, `/dev/urandom`, `/dev/ptmx`, `/dev/pts/N`). Sticky bits, the uid 1000 owner on `/home/user`, mode `0700` on `/root`, etc. are baked into the rootfs image at build time per the canonical `MANIFEST` and reflected honestly through the `MemoryFileSystem` inode metadata. Scratch mounts on Node start owned by uid/gid 0 because `HostFileSystem` synthesises them. ### rootfs image as the source of truth diff --git a/docs/browser-support.md b/docs/browser-support.md index 46108c0632..26b4c41205 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -804,7 +804,11 @@ inventory. The owning bottle is materialized within the private composition transaction; unrelated bottles stay lazy. Chromium, Firefox, and WebKit use the same copy-and-admit path as Node and receive a separate immutable product backend, while the composed Homebrew filesystem remains the writable `nosuid` -tree. +tree. `BrowserKernel.initFromPublishedPrivilegedProgramProduct` accepts only +that exact published object and overlays its privately retained, immutable +`/usr/bin` projection in the VFS-owning worker. Ordinary image init and all +public descriptor/URL inputs remain `nosuid` and cannot populate the private +worker message. There is no per-file or byte-range retrieval inside the gzip/TAR. A failed fetch, digest, decode, inventory check, or allocation leaves every regular inode pending and diff --git a/docs/sdk-guide.md b/docs/sdk-guide.md index e00332d01a..e86a2b8acd 100644 --- a/docs/sdk-guide.md +++ b/docs/sdk-guide.md @@ -213,7 +213,8 @@ leave unresolved host imports in linked programs. ``` -nostdlib # Don't use system libc --Wl,--entry=_start # Entry point +-Wl,--no-entry # Leave startup sequencing to musl +-Wl,--export=_start # Export the host-invoked process entry -Wl,--import-memory # Memory provided by host -Wl,--shared-memory # Enable SharedArrayBuffer -Wl,--max-memory=1073741824 # 1GB max memory @@ -227,6 +228,11 @@ leave unresolved host imports in linked programs. -Wl,--export=__wasm_init_tls # TLS initialization ``` +Kandelo deliberately uses reactor link mode while retaining the exported +`_start` function. This prevents `wasm-ld` from inserting a constructor call +ahead of libc startup; musl runs constructors only after it has installed the +process environment and secure-execution state. + `--allow-undefined` is not permission for arbitrary Kandelo-private symbols to escape into a package. `install_local_binary` rejects unresolved imports in the reserved `env.__wasm_posix_*` namespace unless they are explicitly implemented diff --git a/examples/dlopen/build.sh b/examples/dlopen/build.sh index cd6ede7193..b54f9d682c 100644 --- a/examples/dlopen/build.sh +++ b/examples/dlopen/build.sh @@ -63,7 +63,7 @@ LINK_FLAGS=( "$GLUE_DIR/dlopen.c" "$SYSROOT/lib/crt1.o" "$SYSROOT/lib/libc.a" - -Wl,--entry=_start + -Wl,--no-entry -Wl,--export=_start -Wl,--export=__heap_base -Wl,--import-memory diff --git a/host/src/browser-kernel-host.ts b/host/src/browser-kernel-host.ts index c5b23f98fa..7a181e0ebf 100644 --- a/host/src/browser-kernel-host.ts +++ b/host/src/browser-kernel-host.ts @@ -38,6 +38,10 @@ import { FILE_MODES } from "./generated/abi"; import { BrowserPcmDriver } from "./audio/browser-pcm-driver"; import type { PcmOutputState } from "./audio/pcm-driver"; import type { PcmTransportDescriptor } from "./audio/pcm-transport"; +import { + snapshotPublishedPrivilegedProgramBrowserMount, + type PublishedPrivilegedProgramProduct, +} from "./vfs/privileged-projection"; const DESTROY_REQUEST_TIMEOUT_MS = 2_000; const defaultPcmWorkletUrl = new URL( @@ -337,6 +341,40 @@ export class BrowserKernel { }); } + /** + * Overlay a publisher-admitted privileged program product at `/usr/bin` on + * an ordinary browser root image. The publisher's module-private brand is + * checked before any authority crosses into the owning worker; raw images, + * boot descriptors, and structurally similar objects use {@link initFromImage} + * and remain `nosuid`. + */ + async initFromPublishedPrivilegedProgramProduct(options: { + kernelWasm?: ArrayBuffer; + vfsImage: Uint8Array | "default"; + privilegedProduct: PublishedPrivilegedProgramProduct; + }): Promise { + const privilegedProgramMount = + snapshotPublishedPrivilegedProgramBrowserMount( + options.privilegedProduct, + ); + const [wasmBytes, vfsImage] = await Promise.all([ + options.kernelWasm + ? Promise.resolve(options.kernelWasm) + : fetchDefaultBrowserKernelArtifact("kernelWasm"), + options.vfsImage === "default" + ? fetchDefaultBrowserKernelArtifact("rootfsVfs") + .then((bytes) => new Uint8Array(bytes)) + : Promise.resolve(options.vfsImage), + ]); + await this.bootWorker({ + kernelWasmBytes: wasmBytes, + vfsImage, + lazyUrlBase: import.meta.env.BASE_URL, + takeVfsImageOwnership: false, + privilegedProgramMount, + }); + } + /** * Load an image by transferring its one whole ordinary ArrayBuffer to the * VFS-owning worker. Unlike {@link initFromImage}, this deliberately @@ -376,6 +414,10 @@ export class BrowserKernel { closedLazyAssets?: readonly ClosedLazyAsset[]; rootfsMountSpec?: readonly MountSpec[]; takeVfsImageOwnership: boolean; + privilegedProgramMount?: { + mountPoint: "/usr/bin"; + imageBytes: Uint8Array; + }; }): Promise { if ( opts.takeVfsImageOwnership && @@ -466,6 +508,15 @@ export class BrowserKernel { type: "init", kernelWasmBytes: transferBuf, vfsImage: opts.vfsImage, + ...(opts.privilegedProgramMount !== undefined + ? { + privilegedProgramMount: { + kind: "published-privileged-program-product" as const, + mountPoint: opts.privilegedProgramMount.mountPoint, + imageBytes: opts.privilegedProgramMount.imageBytes, + }, + } + : {}), lazyUrlBase: opts.lazyUrlBase, closedLazyAssets, rootfsMountSpec: opts.rootfsMountSpec === undefined @@ -495,6 +546,11 @@ export class BrowserKernel { // worker restores its own kernel-owned filesystem. transfer.push(opts.vfsImage.buffer as ArrayBuffer); } + if (opts.privilegedProgramMount !== undefined) { + transfer.push( + opts.privilegedProgramMount.imageBytes.buffer as ArrayBuffer, + ); + } for (const asset of closedLazyAssets ?? []) { // snapshotClosedLazyAssets always allocates one ordinary ArrayBuffer // per binding, so transferring it cannot detach caller-owned bytes. diff --git a/host/src/browser-kernel-protocol.ts b/host/src/browser-kernel-protocol.ts index fb7c0bddf8..9abf868d11 100644 --- a/host/src/browser-kernel-protocol.ts +++ b/host/src/browser-kernel-protocol.ts @@ -34,8 +34,16 @@ export interface InitMessage { * apps/browser-demos/lib/kernel-owned-boot.ts::overlayEtcFromRootfs). */ vfsImage: Uint8Array; - /** Exact image/scratch mount contract. Absent preserves the host default. */ - rootfsMountSpec?: MountSpec[]; + /** + * Private host-to-worker authority for an image admitted by the privileged + * product publisher. Public boot descriptors and ordinary image init cannot + * populate this field. + */ + privilegedProgramMount?: { + kind: "published-privileged-program-product"; + mountPoint: "/usr/bin"; + imageBytes: Uint8Array; + }; /** Base URL for relative lazy file/archive URLs stored in vfsImage. */ lazyUrlBase?: string; /** Exhaustive exact-byte lazy transport for this image; no network fallback. */ diff --git a/host/src/browser-kernel-worker-entry.ts b/host/src/browser-kernel-worker-entry.ts index 94b8c4a97a..c76d5b908c 100644 --- a/host/src/browser-kernel-worker-entry.ts +++ b/host/src/browser-kernel-worker-entry.ts @@ -33,13 +33,17 @@ import { readPreparedPlatformFile, VirtualPlatformIO, } from "./vfs/vfs"; -import { MemoryFileSystem } from "./vfs/memory-fs"; +import { + createImmutableProductBackend, + MemoryFileSystem, +} from "./vfs/memory-fs"; import { createClosedLazyAssetFetcherFromOwnedAssets } from "./vfs/closed-lazy-assets"; import { createBrowserLazyFetcher } from "./vfs/browser-lazy-fetcher"; import { resolveLazyUrl } from "./vfs/lazy-url"; import { DeviceFileSystem } from "./vfs/device-fs"; import { BrowserTimeProvider } from "./vfs/time"; import { restoreBrowserKernelInitMounts } from "./browser-kernel-vfs-init"; +import { restoreVerifiedVfsImage } from "./vfs/load-image"; import type { MountConfig } from "./vfs/types"; import { TlsNetworkBackend } from "./networking/tls-network-backend"; import { patchWasmForThread } from "./worker-main"; @@ -200,6 +204,8 @@ interface ProcessInfo extends ProcessGenerationOwnership { argv: string[]; channelOffset: number; ptrWidth: 4 | 8; + /** Kernel-owned sticky secure-execution state for this exact image. */ + secureExec: boolean; layout: ProcessMemoryLayout; threadAllocator: ThreadPageAllocator; /** Exact broker authority for this PID's current Wasm image. */ @@ -1043,9 +1049,27 @@ async function handleInit(msg: Extract) { // CORP alone cannot make an opaque response body readable to JavaScript. memfs.setLazyFetcher(createBrowserLazyFetcher(msg.config.corsProxyUrl)); } + const privilegedProgramMount = msg.privilegedProgramMount?.kind === + "published-privileged-program-product" + ? { + mountPoint: msg.privilegedProgramMount.mountPoint, + backend: createImmutableProductBackend( + await restoreVerifiedVfsImage( + msg.privilegedProgramMount.imageBytes, + ), + ), + readonly: true, + setIdCapability: { + kind: "trusted-root-product" as const, + guestWritable: false, + stableExecutableIdentity: true, + }, + } + : undefined; const mounts: MountConfig[] = [ { mountPoint: "/dev/shm", backend: shmfs }, { mountPoint: "/dev", backend: devfs }, + ...(privilegedProgramMount === undefined ? [] : [privilegedProgramMount]), ...specMounts, ]; memfs.subscribeLazyDownloads((event) => { @@ -1425,6 +1449,7 @@ async function handleSpawn(msg: Extract) createdMemoryRegistered = true; kernelWorker.setCredentials(pid, { uid: msg.uid, gid: msg.gid }); + const secureExec = kernelWorker.processSecureExec(pid); if (msg.cwd) { kernelWorker.setCwd(pid, msg.cwd); } @@ -1470,6 +1495,7 @@ async function handleSpawn(msg: Extract) programBytes, memory, channelOffset, + secureExec, externrefGenerationId: externrefGeneration.id, forkHostImports: forkHostImports.init, env: launchEnv, @@ -1496,6 +1522,7 @@ async function handleSpawn(msg: Extract) argv: msg.argv, channelOffset, ptrWidth, + secureExec, layout, threadAllocator, externrefGeneration, @@ -2001,6 +2028,7 @@ async function handleVfork( programModule: parentInfo.programModule, memory: parentMemory, channelOffset: childChannelOffset, + secureExec: kernelWorker.processSecureExec(childPid), externrefGenerationId: externrefGrant.generation.id, forkHostImports: forkHostImports.init, isForkChild: true, @@ -2047,6 +2075,7 @@ async function handleVfork( argv: parentInfo.argv, channelOffset: childChannelOffset, ptrWidth, + secureExec: childInitData.secureExec, layout: childLayout, threadAllocator: threadAllocatorForLayout(childLayout, ptrWidth, childPid), forkReplayContext, @@ -2369,6 +2398,7 @@ async function handleOrdinaryFork( programModule: parentInfo.programModule, memory: childMemory, channelOffset: childChannelOffset, + secureExec: kernelWorker.processSecureExec(childPid), externrefGenerationId: externrefGrant.generation.id, forkHostImports: forkHostImports.init, isForkChild: true, @@ -2401,6 +2431,7 @@ async function handleOrdinaryFork( argv: parentInfo.argv, channelOffset: childChannelOffset, ptrWidth, + secureExec: childInitData.secureExec, layout: childLayout, threadAllocator: threadAllocatorForLayout(childLayout, ptrWidth, childPid), forkReplayContext, @@ -2595,6 +2626,7 @@ async function handleExec( } launchPlanState = "started"; try { + const secureExec = kernelWorker.processSecureExec(pid); vmInterruptTimers.clear(pid, initiatingInfo); // Wake the exact old execution generation through the existing internal @@ -2718,6 +2750,7 @@ async function handleExec( programModule, memory: newMemory, channelOffset: newChannelOffset, + secureExec, externrefGenerationId: replacementExternrefGeneration.id, forkHostImports: replacementForkHostImports.init, argv: launchArgv, @@ -2785,6 +2818,7 @@ async function handleExec( argv: launchArgv, channelOffset: newChannelOffset, ptrWidth, + secureExec, layout: newLayout, threadAllocator: newThreadAllocator, externrefGeneration: replacementExternrefGeneration, @@ -3003,6 +3037,7 @@ async function handlePosixSpawn( program: ResolvedSpawnProgram, envp: string[], ): Promise { + const secureExec = kernelWorker.processSecureExec(childPid); await waitForProcessTeardowns(); // Unrelated teardown waits yield to the event loop. Keep a successfully @@ -3090,6 +3125,7 @@ async function handlePosixSpawn( programModule, memory: newMemory, channelOffset: newChannelOffset, + secureExec, externrefGenerationId: processExternrefGeneration.id, forkHostImports: processForkHostImports.init, argv, @@ -3117,6 +3153,7 @@ async function handlePosixSpawn( argv, channelOffset: newChannelOffset, ptrWidth, + secureExec, layout: newLayout, threadAllocator, externrefGeneration: processExternrefGeneration, @@ -3277,6 +3314,7 @@ async function handleClone( memory, processChannelOffset: processInfo.channelOffset, channelOffset: alloc.channelOffset, + secureExec: processInfo.secureExec, externrefGenerationId: processInfo.externrefGeneration.id, forkHostImports: forkHostImports.init, fnPtr, diff --git a/host/src/generated/abi.ts b/host/src/generated/abi.ts index aec5f45b26..79dcfb8ad0 100644 --- a/host/src/generated/abi.ts +++ b/host/src/generated/abi.ts @@ -689,6 +689,8 @@ export const HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS = [ "kernel_process_metadata_cancel", "kernel_process_metadata_commit", "kernel_process_metadata_stage", + "kernel_process_secure_exec", + "kernel_publish_spawn_child", "kernel_reap_exited_child", "kernel_remove_process", "kernel_semctl_array_bytes", @@ -696,7 +698,6 @@ export const HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS = [ "kernel_set_current_tid", "kernel_set_cwd", "kernel_shmid_ds_bytes", - "kernel_publish_spawn_child", "kernel_spawn_exec_commit", "kernel_spawn_exec_target_prepare", "kernel_spawn_process", diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index d32e4dd544..f20eeb34dd 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -29839,6 +29839,44 @@ export class CentralizedKernelWorker { return count; } + /** Kernel-owned sticky secure-execution state for one exact image. */ + processSecureExec(pid: number): boolean { + if (this.#kernelFatalError !== null) throw this.#kernelFatalError; + if (this.#kernelInstance === null) { + throw new Error("kernel_process_secure_exec export is unavailable"); + } + if (this.#kernelEntryGate.shouldDeferVoidIngress) { + throw new KernelReentrantEntryError( + `secure-exec query pid=${pid}`, + ); + } + let marker: boolean | undefined; + const deferred = this.#runOrDeferKernelEntry( + `secure-exec query pid=${pid}`, + (entry) => { + const query = this.#kernelInstanceForEntry(entry).exports + .kernel_process_secure_exec as ((pid: number) => number) | undefined; + if (typeof query !== "function") { + throw new Error("kernel_process_secure_exec export is unavailable"); + } + const raw = query(pid); + if (raw !== 0 && raw !== 1) { + throw new Error( + `kernel_process_secure_exec rejected pid=${pid}: ${raw}`, + ); + } + marker = raw === 1; + return undefined; + }, + ); + if (deferred || marker === undefined) { + throw new KernelReentrantEntryError( + `secure-exec query pid=${pid}`, + ); + } + return marker; + } + /** * Current size of the kernel's own Wasm linear memory in 64 KiB pages. * diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts index eeff148252..197f13393c 100644 --- a/host/src/node-kernel-worker-entry.ts +++ b/host/src/node-kernel-worker-entry.ts @@ -234,6 +234,8 @@ interface ProcessInfo extends ProcessGenerationOwnership { worker: ReturnType; channelOffset: number; ptrWidth: 4 | 8; + /** Kernel-owned sticky secure-execution state for this exact image. */ + secureExec: boolean; layout: ProcessMemoryLayout; threadAllocator: ThreadPageAllocator; /** Exact broker authority for this PID's current Wasm image. */ @@ -1277,6 +1279,7 @@ async function handleSpawn(msg: SpawnMessage) { createdMemoryRegistered = true; kernelWorker.setCredentials(pid, { uid: msg.uid, gid: msg.gid }); + const secureExec = kernelWorker.processSecureExec(pid); if (msg.cwd) { kernelWorker.setCwd(pid, msg.cwd); } @@ -1330,6 +1333,7 @@ async function handleSpawn(msg: SpawnMessage) { programModule, memory, channelOffset, + secureExec, externrefGenerationId: externrefGeneration.id, forkHostImports: forkHostImports.init, env: msg.env, @@ -1354,6 +1358,7 @@ async function handleSpawn(msg: SpawnMessage) { worker, channelOffset, ptrWidth, + secureExec, layout, threadAllocator, externrefGeneration, @@ -1728,6 +1733,7 @@ async function handleVfork( programModule: parentInfo.programModule, memory: parentMemory, channelOffset: childChannelOffset, + secureExec: kernelWorker.processSecureExec(childPid), externrefGenerationId: externrefGrant.generation.id, forkHostImports: forkHostImports.init, isForkChild: true, @@ -1763,6 +1769,7 @@ async function handleVfork( worker: childWorker, channelOffset: childChannelOffset, ptrWidth, + secureExec: childInitData.secureExec, layout: childLayout, threadAllocator: threadAllocatorForLayout(childLayout, ptrWidth, childPid), forkReplayContext, @@ -2081,6 +2088,7 @@ async function handleOrdinaryFork( programModule: parentInfo.programModule, memory: childMemory, channelOffset: childChannelOffset, + secureExec: kernelWorker.processSecureExec(childPid), externrefGenerationId: externrefGrant.generation.id, forkHostImports: forkHostImports.init, isForkChild: true, @@ -2109,6 +2117,7 @@ async function handleOrdinaryFork( worker, channelOffset: childChannelOffset, ptrWidth, + secureExec: childInitData.secureExec, layout: childLayout, threadAllocator: threadAllocatorForLayout(childLayout, ptrWidth, childPid), forkReplayContext, @@ -2290,6 +2299,7 @@ async function handleExec( } launchPlanState = "started"; try { + const secureExec = kernelWorker.processSecureExec(pid); vmInterruptTimers.clear(pid, initiatingInfo); // Wake the exact old execution generation through the internal exec @@ -2396,6 +2406,7 @@ async function handleExec( programModule, memory: newMemory, channelOffset: newChannelOffset, + secureExec, externrefGenerationId: replacementExternrefGeneration.id, forkHostImports: replacementForkHostImports.init, argv: launchArgv, @@ -2447,6 +2458,7 @@ async function handleExec( worker: replacementWorker, channelOffset: newChannelOffset, ptrWidth: newPtrWidth, + secureExec, layout: newLayout, threadAllocator: newThreadAllocator, externrefGeneration: replacementExternrefGeneration, @@ -2666,6 +2678,7 @@ async function handlePosixSpawn( program: ResolvedSpawnProgram, envp: string[], ): Promise { + const secureExec = kernelWorker.processSecureExec(childPid); // Preserve a child that became a zombie before launch, but do not resurrect // it by registering a new execution generation. if (!kernelWorker.shouldLaunchPendingChild(childPid)) return 0; @@ -2739,6 +2752,7 @@ async function handlePosixSpawn( programModule, memory, channelOffset, + secureExec, externrefGenerationId: processExternrefGeneration.id, forkHostImports: processForkHostImports.init, argv, @@ -2762,6 +2776,7 @@ async function handlePosixSpawn( worker, channelOffset, ptrWidth, + secureExec, layout, threadAllocator, externrefGeneration: processExternrefGeneration, @@ -2916,6 +2931,7 @@ async function handleClone( memory, processChannelOffset: processInfo.channelOffset, channelOffset: alloc.channelOffset, + secureExec: processInfo.secureExec, externrefGenerationId: processInfo.externrefGeneration.id, forkHostImports: forkHostImports.init, fnPtr, diff --git a/host/src/vfs/privileged-projection.ts b/host/src/vfs/privileged-projection.ts index 86c490621c..777166b0a3 100644 --- a/host/src/vfs/privileged-projection.ts +++ b/host/src/vfs/privileged-projection.ts @@ -40,6 +40,10 @@ const MAX_PRODUCT_BYTES = 128 * 1024 * 1024; const intrinsicHasOwnProperty = Object.prototype.hasOwnProperty; const reviewedPolicies = new WeakMap(); const privatelyStagedCandidates = new WeakSet(); +const publishedProductBrowserMounts = new WeakMap< + object, + PublishedPrivilegedProgramBrowserMount +>(); export interface PrivilegedProgramProjection { schema: 1; @@ -93,6 +97,11 @@ export interface PublishedPrivilegedProgramProduct { imageBytes: Uint8Array; } +export interface PublishedPrivilegedProgramBrowserMount { + mountPoint: "/usr/bin"; + imageBytes: Uint8Array; +} + export interface PublishPrivilegedProgramProductOptions { policy: ReviewedPrivilegedProgramPolicy; sources: readonly PrivilegedProgramSource[]; @@ -225,6 +234,27 @@ export function readReviewedPrivilegedProgramPolicy( return projections.map((projection) => ({ ...projection })); } +/** + * Snapshot the serialized tree of a product admitted by this publisher. + * + * BrowserKernel uses this private-module boundary before it sends trusted-root + * authority to its owning worker. A structurally similar object, or mutation + * of the public build artifact bytes after publication, cannot mint a trusted + * browser mount. + */ +export function snapshotPublishedPrivilegedProgramBrowserMount( + product: PublishedPrivilegedProgramProduct, +): PublishedPrivilegedProgramBrowserMount { + const mount = publishedProductBrowserMounts.get(product); + if (mount === undefined) { + throw new Error("privileged program product lacks publication authority"); + } + return { + mountPoint: mount.mountPoint, + imageBytes: mount.imageBytes.slice(), + }; +} + /** * Copy all reviewed members into one unpublished tree, then admit the group. * A failed member leaves no returned backend and never mutates a bottle tree. @@ -314,6 +344,10 @@ async function publishAuthenticatedCandidate( backend, options.authenticated, ); + const browserMountImageBytes = await serializeImmutableBrowserMount( + backend, + options.authenticated, + ); // WHY: the projection record's mountPoint is a policy identity, not mount // authority. Only Task 6's private backend brand plus this resolved mount // capability can authorize the trusted product tree. @@ -328,12 +362,17 @@ async function publishAuthenticatedCandidate( }, }; resolveMountSetIdCapability(mount); - return { + const product: PublishedPrivilegedProgramProduct = { projections: options.projections.map((projection) => ({ ...projection })), evidence, mount, - imageBytes, + imageBytes: imageBytes.slice(), }; + publishedProductBrowserMounts.set(product, { + mountPoint: "/usr/bin", + imageBytes: browserMountImageBytes, + }); + return product; } function validateAuthenticatedCandidate( @@ -474,6 +513,34 @@ async function serializeImmutableProduct( return artifactFs.saveImage({ normalizeTimestampsMs: 0 }); } +async function serializeImmutableBrowserMount( + backend: FileSystemBackend, + authenticatedSources: readonly AuthenticatedProgramSource[], +): Promise { + const productBytes = authenticatedSources.reduce( + (sum, source) => sum + source.bytes.byteLength, + 0, + ); + const artifactFs = MemoryFileSystem.create(new SharedArrayBuffer(Math.max( + MIN_PRODUCT_CAPACITY, + productBytes + 2 * 1024 * 1024, + ))); + artifactFs.chown("/", 0, 0); + artifactFs.chmod("/", 0o755); + for (const { projection } of authenticatedSources) { + const destination = projection.destinationPath.slice("/usr/bin".length); + writeVfsBinary( + artifactFs, + destination, + readRegularFile(backend, projection.destinationPath), + 0o755, + ); + artifactFs.chown(destination, 0, 0); + artifactFs.chmod(destination, 0o4755); + } + return artifactFs.saveImage({ normalizeTimestampsMs: 0 }); +} + async function authenticateProgramSources( projections: readonly PrivilegedProgramProjection[], sources: readonly PrivilegedProgramSource[], diff --git a/host/src/worker-main.ts b/host/src/worker-main.ts index bcb3ac34fb..00059fb7e5 100644 --- a/host/src/worker-main.ts +++ b/host/src/worker-main.ts @@ -391,8 +391,9 @@ function buildKernelImports( memory: WebAssembly.Memory, channelOffset: number, ptrWidth: 4 | 8, - argv?: string[], - envVars?: string[], + argv: string[] | undefined, + envVars: string[] | undefined, + secureExec: boolean, onKernelExit?: (status: number) => void, ): KernelImports { const metadata = encodeStartupMetadata(argv ?? [], envVars ?? [], ptrWidth); @@ -455,6 +456,9 @@ function buildKernelImports( return copyEntry(metadata.env, index, bufPtr, bufMax, "kernel_environ_get"); }, + // Sticky kernel-owned state captured for this exact process image. + kernel_get_secure_exec: (): number => secureExec ? 1 : 0, + // Fork/exec state — not a fork child. kernel_is_fork_child: (): number => 0, kernel_apply_fork_fd_actions: (): number => 0, @@ -597,8 +601,16 @@ export function buildKernelImportsForTest( ptrWidth: 4 | 8, argv: string[] = [], env: string[] = [], + secureExec: boolean = false, ): Record { - return buildKernelImports(memory, channelOffset, ptrWidth, argv, env); + return buildKernelImports( + memory, + channelOffset, + ptrWidth, + argv, + env, + secureExec, + ); } export interface DlopenSupport { @@ -3265,6 +3277,7 @@ export async function centralizedWorkerMain( ptrWidth, initData.argv || [], initData.env || [], + initData.secureExec, (status) => { kernelExitStatus = status; }, @@ -5555,6 +5568,7 @@ export async function centralizedThreadWorkerMain( ptrWidth, undefined, undefined, + initData.secureExec, (status) => { kernelThreadExitStatus = status; }, diff --git a/host/src/worker-protocol.ts b/host/src/worker-protocol.ts index 090b2f6d6c..b1ad0974e0 100644 --- a/host/src/worker-protocol.ts +++ b/host/src/worker-protocol.ts @@ -42,6 +42,8 @@ export interface CentralizedWorkerInitMessage { memory: WebAssembly.Memory; /** Channel offset within the shared Memory for this thread's syscall channel */ channelOffset: number; + /** Kernel-owned sticky secure-execution state for this exact image. */ + secureExec: boolean; /** * Exact process-image generation issued by the kernel-side externref owner. * Workers use this scalar only when routing token-bearing host imports; the @@ -124,6 +126,8 @@ export interface CentralizedThreadInitMessage { * archive head relative to this live shared-memory anchor before fork. */ processChannelOffset: number; channelOffset: number; + /** Same sticky image marker as the process worker. */ + secureExec: boolean; /** * Same process-image externref generation as the process's main Worker. * Optional only for direct non-fork harnesses. diff --git a/host/test/centralized-test-helper.ts b/host/test/centralized-test-helper.ts index 2d071688e5..e296339368 100644 --- a/host/test/centralized-test-helper.ts +++ b/host/test/centralized-test-helper.ts @@ -477,6 +477,119 @@ async function runOnMainThread(options: RunProgramOptions): Promise { + const mappedProgram = options.execPrograms?.get(path); + if (!mappedProgram) return null; + const spawnProgramBytes = loadProgramWasm(mappedProgram); + try { + return { + programBytes: spawnProgramBytes, + programModule: await WebAssembly.compile(spawnProgramBytes), + argv, + }; + } catch (error) { + if (error instanceof WebAssembly.CompileError) return { errno: 8 }; + throw error; + } + }, + onSpawn: async (_parentPid, childPid, program, envp) => { + if (!kernelWorker.shouldLaunchPendingChild(childPid)) return 0; + const childPtrWidth = detectPtrWidth(program.programBytes); + const { + memory: childMemory, + layout: childLayout, + threadAllocator: childThreadAllocator, + } = createFreshProcessMemory( + program.programBytes, + childPtrWidth, + () => kernelWorker.reserveHostRegion( + childPid, + PAGES_PER_THREAD * WASM_PAGE_SIZE, + ) / WASM_PAGE_SIZE, + options.maxPages, + ); + if (!kernelWorker.shouldLaunchPendingChild(childPid)) return 0; + + const childChannelOffset = childLayout.channelOffset; + kernelWorker.registerProcess(childPid, childMemory, [childChannelOffset], { + ptrWidth: childPtrWidth, + brkBase: childLayout.brkBase, + mmapBase: childLayout.mmapBase, + maxAddr: childLayout.maxAddr, + }); + + const childGeneration = externrefProcessOwner.startGeneration(childPid); + let childWorker: ReturnType; + const childForkHostImports = forkHostImportOwnerRuntime.createWorker({ + pid: childPid, + generationId: childGeneration.id, + authorizeSender: () => { + if ( + workers.get(childPid) !== childWorker + || externrefGenerations.get(childPid) !== childGeneration + ) { + throw new Error( + `stale centralized-test host-import sender for spawn pid=${childPid}`, + ); + } + }, + }); + const childInitData: CentralizedWorkerInitMessage = { + type: "centralized_init", + pid: childPid, + programBytes: program.programBytes, + programModule: program.programModule, + memory: childMemory, + channelOffset: childChannelOffset, + secureExec: kernelWorker.processSecureExec(childPid), + argv: program.argv, + env: envp, + ptrWidth: childPtrWidth, + externrefGenerationId: childGeneration.id, + forkHostImports: childForkHostImports.init, + }; + + try { + childWorker = workerAdapter.createWorker(childInitData); + } catch (error) { + childForkHostImports.close(); + externrefProcessOwner.releaseGeneration(childGeneration); + kernelWorker.deactivateProcess(childPid); + throw error; + } + workers.set(childPid, childWorker); + externrefGenerations.set(childPid, childGeneration); + processForkHostImports.set(childPid, childForkHostImports); + processProgramBytes.set(childPid, program.programBytes); + processLayouts.set(childPid, childLayout); + threadAllocators.set(childPid, childThreadAllocator); + processPtrWidths.set(childPid, childPtrWidth); + + const finalizeSpawnWorkerError = (reason: unknown): void => { + if (workers.get(childPid) !== childWorker) return; + const message = reason instanceof Error ? reason.message : String(reason); + stderr += `[spawn child ${childPid}] ${message}\n`; + try { kernelWorker.notifyHostProcessCrashed(childPid, SIGSEGV); } catch { /* best-effort */ } + try { kernelWorker.deactivateProcess(childPid); } catch { /* best-effort */ } + workers.delete(childPid); + processProgramBytes.delete(childPid); + processLayouts.delete(childPid); + threadAllocators.delete(childPid); + processPtrWidths.delete(childPid); + releaseProcessReferenceOwner(childPid); + childWorker.terminate().catch(() => {}); + }; + childWorker.on("error", finalizeSpawnWorkerError); + childWorker.on("message", (msg: unknown) => { + const message = msg as WorkerToHostMessage; + if (message.type === "error" && message.pid === childPid) { + finalizeSpawnWorkerError(message.message); + } else if (message.type === "fork_host_import") { + childForkHostImports.dispatch(message.wake); + } + }); + return 0; + }, onFork: async ({ parentPid, childPid, @@ -562,6 +675,7 @@ async function runOnMainThread(options: RunProgramOptions): Promise { programBytes, memory, channelOffset, + secureExec: kernel.processSecureExec(pid), argv: ["dri-smoke"], env: [], ptrWidth, diff --git a/host/test/fork-dlopen-replay-e2e.test.ts b/host/test/fork-dlopen-replay-e2e.test.ts index ad7960c8da..d2188a8989 100644 --- a/host/test/fork-dlopen-replay-e2e.test.ts +++ b/host/test/fork-dlopen-replay-e2e.test.ts @@ -179,7 +179,7 @@ function buildMainProgram(source: string, name: string, forceExports: string[] = join(GLUE_DIR, "dlopen.c"), join(SYSROOT, "lib", "crt1.o"), join(SYSROOT, "lib", "libc.a"), - "-Wl,--entry=_start", + "-Wl,--no-entry", "-Wl,--export=_start", "-Wl,--export=__heap_base", "-Wl,--import-memory", diff --git a/host/test/fork-from-dlopen-side-module-e2e.test.ts b/host/test/fork-from-dlopen-side-module-e2e.test.ts index cb3c013917..e82f7bac85 100644 --- a/host/test/fork-from-dlopen-side-module-e2e.test.ts +++ b/host/test/fork-from-dlopen-side-module-e2e.test.ts @@ -118,7 +118,7 @@ function buildMainProgram(source: string): string { join(glueDir, "dlopen.c"), join(sysroot, "lib", "crt1.o"), join(sysroot, "lib", "libc.a"), - "-Wl,--entry=_start", + "-Wl,--no-entry", "-Wl,--export=_start", "-Wl,--export=__heap_base", "-Wl,--import-memory", diff --git a/host/test/framebuffer-integration.test.ts b/host/test/framebuffer-integration.test.ts index cda924e191..29bc364e75 100644 --- a/host/test/framebuffer-integration.test.ts +++ b/host/test/framebuffer-integration.test.ts @@ -136,6 +136,7 @@ describe.skipIf(!existsSync(fbtestBinary))("framebuffer integration", () => { programBytes, memory, channelOffset, + secureExec: kernel.processSecureExec(pid), argv: ["fbtest"], env: [], ptrWidth, diff --git a/host/test/kernel-authority-boundary.test.ts b/host/test/kernel-authority-boundary.test.ts index da70eb83d2..b981d75cdf 100644 --- a/host/test/kernel-authority-boundary.test.ts +++ b/host/test/kernel-authority-boundary.test.ts @@ -96,6 +96,7 @@ const hiddenPackageSymbols = [ "reviewedPrivilegedProgramPolicyForPlan", "composeHomebrewRuntimeLayersWithReviewedProduct", "publishPrivilegedProgramProduct", + "snapshotPublishedPrivilegedProgramBrowserMount", "admitPrivilegedProgramProductCandidate", "admitPrivilegedProgramProductCandidateForTest", "validatePrivilegedProgramProductCandidate", diff --git a/host/test/kernel-telemetry-entry.test.ts b/host/test/kernel-telemetry-entry.test.ts index 6446a70b01..33de375e51 100644 --- a/host/test/kernel-telemetry-entry.test.ts +++ b/host/test/kernel-telemetry-entry.test.ts @@ -17,6 +17,7 @@ const TELEMETRY_EXPORT_NAMES = [ "kernel_get_fork_count", "kernel_get_memory_pages", "kernel_inject_mouse_event", + "kernel_process_secure_exec", "kernel_spawn_scratch_retained_capacity", "kernel_vblank", ] as const; @@ -48,6 +49,7 @@ function makeHarness( kernel_get_fork_count: () => 11n, kernel_get_memory_pages: () => 321, kernel_inject_mouse_event: () => 0, + kernel_process_secure_exec: () => 0, kernel_spawn_scratch_retained_capacity: () => kernelPointer(pointerWidth, 84_386), kernel_vblank: () => 0, @@ -100,10 +102,12 @@ describe("kernel telemetry entry authority", () => { const getMemoryPages = vi.fn(() => 321); const getCapacity = vi.fn(() => kernelPointer(pointerWidth, 84_386)); + const getSecureExec = vi.fn(() => 1); const harness = makeHarness(pointerWidth, { implementations: { kernel_get_fork_count: getForkCount, kernel_get_memory_pages: getMemoryPages, + kernel_process_secure_exec: getSecureExec, kernel_spawn_scratch_retained_capacity: getCapacity, }, }); @@ -111,9 +115,11 @@ describe("kernel telemetry entry authority", () => { expect(harness.worker.getForkCount(47)).toBe(11n); expect(harness.worker.getKernelMemoryPages()).toBe(321); expect(harness.worker.getSpawnScratchCapacity()).toBe(84_386); + expect(harness.worker.processSecureExec(47)).toBe(true); expect(getForkCount).toHaveBeenCalledExactlyOnceWith(47); expect(getMemoryPages).toHaveBeenCalledOnce(); expect(getCapacity).toHaveBeenCalledOnce(); + expect(getSecureExec).toHaveBeenCalledExactlyOnceWith(47); harness.implementations.kernel_spawn_scratch_retained_capacity = () => pointerWidth === 8 ? -1n : -1; @@ -126,6 +132,17 @@ describe("kernel telemetry entry authority", () => { harness.implementations.kernel_spawn_scratch_retained_capacity = () => kernelPointer(pointerWidth, 4_096); expect(harness.worker.getSpawnScratchCapacity()).toBe(4_096); + + for (const invalidMarker of [-3, 2]) { + const invalidHarness = makeHarness(pointerWidth, { + implementations: { + kernel_process_secure_exec: () => invalidMarker, + }, + }); + expect(() => invalidHarness.worker.processSecureExec(47)).toThrow( + /secure-exec query pid=47 failed/, + ); + } }, ); @@ -133,18 +150,21 @@ describe("kernel telemetry entry authority", () => { const forkCount = vi.fn(() => 13n); const memoryPages = vi.fn(() => 77); const capacity = vi.fn(() => 98_304); + const secureExec = vi.fn(() => 1); const caught: unknown[] = []; let harness!: TelemetryHarness; harness = makeHarness(4, { implementations: { kernel_get_fork_count: forkCount, kernel_get_memory_pages: memoryPages, + kernel_process_secure_exec: secureExec, kernel_spawn_scratch_retained_capacity: capacity, kernel_vblank: () => { for (const query of [ () => harness.worker.getForkCount(51), () => harness.worker.getKernelMemoryPages(), () => harness.worker.getSpawnScratchCapacity(), + () => harness.worker.processSecureExec(51), ]) { try { query(); @@ -162,17 +182,19 @@ describe("kernel telemetry entry authority", () => { )(); await Promise.resolve(); - expect(caught).toHaveLength(3); + expect(caught).toHaveLength(4); for (const error of caught) { expect(error).toBeInstanceOf(KernelReentrantEntryError); } expect(forkCount).not.toHaveBeenCalled(); expect(memoryPages).not.toHaveBeenCalled(); expect(capacity).not.toHaveBeenCalled(); + expect(secureExec).not.toHaveBeenCalled(); expect(harness.worker.getForkCount(51)).toBe(13n); expect(harness.worker.getKernelMemoryPages()).toBe(77); expect(harness.worker.getSpawnScratchCapacity()).toBe(98_304); + expect(harness.worker.processSecureExec(51)).toBe(true); }); it("materializes optional/missing-export outcomes after scope revocation", () => { diff --git a/host/test/mouse-integration.test.ts b/host/test/mouse-integration.test.ts index a9b266fa04..51541101e7 100644 --- a/host/test/mouse-integration.test.ts +++ b/host/test/mouse-integration.test.ts @@ -148,6 +148,7 @@ describe.skipIf(!existsSync(mousetestBinary))("mouse integration", () => { programBytes, memory, channelOffset, + secureExec: kernel.processSecureExec(pid), argv: ["mousetest", "3"], env: [], ptrWidth, diff --git a/host/test/privileged-projection.test.ts b/host/test/privileged-projection.test.ts index e2dc73b1ff..a84b25fd43 100644 --- a/host/test/privileged-projection.test.ts +++ b/host/test/privileged-projection.test.ts @@ -6,6 +6,7 @@ import { createReviewedPrivilegedProgramPolicy, parsePrivilegedProgramProjections, publishPrivilegedProgramProduct, + snapshotPublishedPrivilegedProgramBrowserMount, validatePrivilegedProgramProductCandidate, type PrivilegedProgramProjection, type PrivilegedProgramSource, @@ -296,6 +297,29 @@ describe("privileged product publication", () => { expect(repeated.imageBytes).toEqual(product.imageBytes); }); + it("snapshots browser mount authority only from the exact publication", async () => { + const source = sourceFixture(); + const product = await publishPrivilegedProgramProduct({ + policy: reviewedPolicy(), + sources: source.sources, + writableBottleFileSystems: [source.writableBottleFs], + }); + const first = snapshotPublishedPrivilegedProgramBrowserMount(product); + const expected = first.imageBytes.slice(); + + expect(first.mountPoint).toBe("/usr/bin"); + expect(MemoryFileSystem.fromImage(first.imageBytes).lstat("/login")) + .toMatchObject({ uid: 0, gid: 0, nlink: 1 }); + first.imageBytes.fill(0); + product.imageBytes.fill(0); + expect(snapshotPublishedPrivilegedProgramBrowserMount(product).imageBytes) + .toEqual(expected); + expect(() => snapshotPublishedPrivilegedProgramBrowserMount({ + ...product, + imageBytes: expected, + })).toThrow(/publication authority/i); + }); + it("resolves an authenticated bottle hardlink but publishes a fresh inode", async () => { const source = sourceFixture({ sourceHardlink: { diff --git a/host/test/secure-exec.test.ts b/host/test/secure-exec.test.ts new file mode 100644 index 0000000000..ad91be647b --- /dev/null +++ b/host/test/secure-exec.test.ts @@ -0,0 +1,243 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { tryResolveBinary } from "../src/binary-resolver"; +import { DeviceFileSystem } from "../src/vfs/device-fs"; +import { + createImmutableProductBackend, + MemoryFileSystem, +} from "../src/vfs/memory-fs"; +import { NodeTimeProvider } from "../src/vfs/time"; +import { VirtualPlatformIO } from "../src/vfs/vfs"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const probeBinary = tryResolveBinary("programs/secure-exec-probe.wasm"); +const hasProbe = probeBinary !== null; +const SECURE_STDOUT_SENTINEL = "secure-stdout-sentinel\n"; +const SECURE_STDERR_SENTINEL = "secure-stderr-sentinel\n"; +const TRUSTED_ROOT_PRODUCT = { + kind: "trusted-root-product", + guestWritable: false, + stableExecutableIdentity: true, +} as const; +const localeMo = new Uint8Array([ + 0xde, 0x12, 0x04, 0x95, 0, 0, 0, 0, + 1, 0, 0, 0, 28, 0, 0, 0, 36, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 3, 0, 0, 0, 44, 0, 0, 0, + 3, 0, 0, 0, 48, 0, 0, 0, + 0x53, 0x75, 0x6e, 0, 0x4c, 0x6f, 0x6b, 0, +]); +const emptyCatalog = new Uint8Array([ + 0xff, 0x88, 0xff, 0x89, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, +]); +const testZone = new Uint8Array([ + 0x54, 0x5a, 0x69, 0x66, 0x31, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 0, 0, 1, 0, 0, 0, 4, + 0, 0, 0x0e, 0x10, 0, 0, + 0x54, 0x53, 0x54, 0, +]); + +function createProbeIo(trusted: boolean): VirtualPlatformIO { + const bytes = new Uint8Array(readFileSync(probeBinary!)); + const root = MemoryFileSystem.create( + new SharedArrayBuffer(Math.max(4 * 1024 * 1024, bytes.byteLength * 3)), + ); + root.mkdir("/bin", 0o755); + root.mkdir("/dev", 0o755); + root.mkdir("/tmp", 0o1777); + root.createFileWithOwner("/bin/secure-parent", 0o4755, 0, 0, bytes); + root.createFileWithOwner("/bin/secure-child", 0o755, 0, 0, bytes); + const tmp = MemoryFileSystem.create(new SharedArrayBuffer(1024 * 1024)); + tmp.chmod("/", 0o1777); + tmp.createFileWithOwner("/zz_TEST", 0o644, 1000, 1000, localeMo); + tmp.createFileWithOwner("/secure.cat", 0o644, 1000, 1000, emptyCatalog); + tmp.createFileWithOwner("/secure-zone", 0o644, 1000, 1000, testZone); + + return new VirtualPlatformIO([ + trusted + ? { + mountPoint: "/", + backend: createImmutableProductBackend(root), + readonly: true, + setIdCapability: TRUSTED_ROOT_PRODUCT, + } + : { mountPoint: "/", backend: root }, + { mountPoint: "/dev", backend: new DeviceFileSystem() }, + { mountPoint: "/tmp", backend: tmp }, + ], new NodeTimeProvider()); +} + +async function launch( + trusted: boolean, + mode: "target" | "stdio-target" | "spawn-parent", + secure: boolean, + maskOrReset: number, +) { + return runCentralizedProgram({ + programPath: probeBinary!, + argv: [ + "secure-exec-probe", + "launch", + "/bin/secure-parent", + mode, + secure ? "1" : "0", + String(maskOrReset), + ], + env: ["KANDELO_UNTRUSTED=visible-only-outside-secure-startup"], + uid: 1000, + gid: 1000, + io: createProbeIo(trusted), + execPrograms: new Map([["/bin/secure-child", probeBinary!]]), + timeout: 20_000, + }); +} + +describe.skipIf(!hasProbe)("secure exec startup", () => { + it("keeps constructor dispatch out of the linker-synthesized entry prefix", () => { + const disassembly = execFileSync( + "wasm-objdump", + ["-d", probeBinary!], + { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }, + ); + const startOffset = disassembly.search(/func\[\d+\] <_start>:/); + const nextFunctionOffset = disassembly.indexOf(" func[", startOffset + 1); + const startBody = disassembly.slice( + startOffset, + nextFunctionOffset < 0 ? undefined : nextFunctionOffset, + ); + const argcCall = startBody.indexOf(""); + const secureCall = startBody.indexOf(""); + const constructorDispatch = startBody.indexOf("call_indirect"); + + expect(startOffset).toBeGreaterThanOrEqual(0); + expect(argcCall).toBeGreaterThanOrEqual(0); + expect(secureCall).toBeGreaterThan(argcCall); + expect(constructorDispatch).toBeGreaterThan(secureCall); + }); + + it("keeps an ordinary image outside secure startup", async () => { + const result = await runCentralizedProgram({ + programPath: probeBinary!, + argv: ["secure-exec-probe", "target", "0", "0"], + env: ["KANDELO_UNTRUSTED=visible-only-outside-secure-startup"], + io: createProbeIo(false), + uid: 1000, + gid: 1000, + timeout: 20_000, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain( + "secure=0 ctor_secure=0 untrusted_visible=1 ctor_visible=1", + ); + expect(result.stdout).toContain( + "locale=Lok timezone=TST catalog=loaded fds=ok", + ); + }); + + it("carries the ordinary marker through the production Node worker host", async () => { + const result = await runCentralizedProgram({ + programPath: probeBinary!, + argv: ["secure-exec-probe", "startup-target", "0", "0"], + env: ["KANDELO_UNTRUSTED=visible-only-outside-secure-startup"], + uid: 1000, + gid: 1000, + useDefaultRootfs: false, + timeout: 20_000, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain( + "secure=0 ctor_secure=0 untrusted_visible=1 ctor_visible=1", + ); + }); + + it("enters secure startup only for a trusted set-ID exec", async () => { + const result = await launch(true, "target", true, 0); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain( + "secure=1 ctor_secure=1 untrusted_visible=0 ctor_visible=0", + ); + expect(result.stdout).toContain( + "locale=Sun timezone=UTC catalog=blocked fds=ok", + ); + }); + + it("does not enter secure startup for a nosuid set-ID file", async () => { + const result = await launch(false, "target", false, 0); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain( + "secure=0 ctor_secure=0 untrusted_visible=1 ctor_visible=1", + ); + }); + + it.each([ + [0, "preserves", true], + [1, "resets", false], + ] as const)( + "%s: posix_spawn %s IDs before exact secure-state commit", + async (resetIds, _verb, expectedSecure) => { + const result = await launch(true, "spawn-parent", true, resetIds); + expect(result.exitCode).toBe(0); + if (expectedSecure) { + expect(result.stdout).toContain( + "secure=1 ctor_secure=1 untrusted_visible=0 ctor_visible=0", + ); + } else { + expect(result.stdout).toContain( + "secure=0 ctor_secure=0 untrusted_visible=1 ctor_visible=1", + ); + } + }, + ); + + it.each([0, 1, 2, 3, 4, 5, 6, 7])( + "repairs secure standard descriptors for closed mask %i", + async (mask) => { + const result = await launch(true, "stdio-target", true, mask); + expect(result.exitCode).toBe(0); + if (mask & 2) { + expect(result.stdout).not.toContain(SECURE_STDOUT_SENTINEL); + } else { + expect(result.stdout).toContain(SECURE_STDOUT_SENTINEL); + } + if (mask & 4) { + expect(result.stderr).not.toContain(SECURE_STDERR_SENTINEL); + } else { + expect(result.stderr).toContain(SECURE_STDERR_SENTINEL); + } + }, + ); + + it("does not repair closed standard descriptors for an ordinary image", async () => { + const result = await launch(false, "stdio-target", false, 1); + expect(result.exitCode).toBe(40); + }); + + it("exits 127 when secure standard-descriptor repair cannot allocate a descriptor", async () => { + const result = await runCentralizedProgram({ + programPath: probeBinary!, + argv: [ + "secure-exec-probe", + "launch-nofile", + "/bin/secure-parent", + "stdio-target", + "1", + "1", + ], + env: ["KANDELO_UNTRUSTED=visible-only-outside-secure-startup"], + uid: 1000, + gid: 1000, + io: createProbeIo(true), + execPrograms: new Map([["/bin/secure-child", probeBinary!]]), + timeout: 20_000, + }); + expect(result.exitCode).toBe(127); + }); +}); diff --git a/host/test/support/kernel-scratch-instance.ts b/host/test/support/kernel-scratch-instance.ts index 50b3c8c118..c7375554fc 100644 --- a/host/test/support/kernel-scratch-instance.ts +++ b/host/test/support/kernel-scratch-instance.ts @@ -367,6 +367,10 @@ function signatures( parameters: [i32, i32, i32, pointer, i32], result: i32, }, + kernel_process_secure_exec: { + parameters: [i32], + result: i32, + }, kernel_pty_create: { parameters: [i32], result: i32, diff --git a/host/test/vfork-side-module-fixture.ts b/host/test/vfork-side-module-fixture.ts index 21b3ee94b0..41545f75ef 100644 --- a/host/test/vfork-side-module-fixture.ts +++ b/host/test/vfork-side-module-fixture.ts @@ -94,7 +94,7 @@ export function buildVforkSideModuleFixture( join(glueDir, "dlopen.c"), join(sysroot, "lib", "crt1.o"), join(sysroot, "lib", "libc.a"), - "-Wl,--entry=_start", + "-Wl,--no-entry", "-Wl,--export=_start", "-Wl,--export=__heap_base", "-Wl,--import-memory", diff --git a/host/test/worker-entry.test.ts b/host/test/worker-entry.test.ts index aaa3b38b22..e1dc81f0a8 100644 --- a/host/test/worker-entry.test.ts +++ b/host/test/worker-entry.test.ts @@ -44,6 +44,7 @@ describe.skipIf(!hasBinary)("centralizedWorkerMain", () => { programBytes: loadProgramBytes(), memory, channelOffset, + secureExec: false, }; // Note: centralizedWorkerMain will call _start() which uses channel IPC. @@ -59,6 +60,7 @@ describe.skipIf(!hasBinary)("centralizedWorkerMain", () => { programBytes: new ArrayBuffer(0), memory, channelOffset, + secureExec: false, }; await centralizedWorkerMain(errorPort as any, errorInitData); diff --git a/libc/glue/syscall_imports.h b/libc/glue/syscall_imports.h index 6da83205f8..c1e4bec1bc 100644 --- a/libc/glue/syscall_imports.h +++ b/libc/glue/syscall_imports.h @@ -184,6 +184,9 @@ int32_t kernel_seekdir(int32_t dir_handle, uint32_t loc_lo, uint32_t loc_hi); /* Process info */ /* ------------------------------------------------------------------ */ +KERNEL_IMPORT(kernel_get_secure_exec) +int32_t kernel_get_secure_exec(void); + KERNEL_IMPORT(kernel_getpid) int32_t kernel_getpid(void); diff --git a/libc/musl-overlay/src/env/__libc_start_main.c b/libc/musl-overlay/src/env/__libc_start_main.c index 105030d934..157437e1fd 100644 --- a/libc/musl-overlay/src/env/__libc_start_main.c +++ b/libc/musl-overlay/src/env/__libc_start_main.c @@ -12,6 +12,9 @@ #include #include +#include +#include +#include #include "syscall.h" #include "atomic.h" #include "libc.h" @@ -29,13 +32,35 @@ extern unsigned long __wasm_tp_storage[64]; extern _Thread_local unsigned long __wasm_thread_pointer; int __init_tp(void *); +extern int32_t kernel_get_secure_exec(void) + __attribute__((import_module("kernel"), import_name("kernel_get_secure_exec"))); + +static _Noreturn void secure_startup_failure(void) +{ + __syscall(SYS_exit_group, 127); + for (;;) __asm__ ("" ::: "memory"); +} + +static void secure_standard_fds(void) +{ + for (int fd = 0; fd != 3; ++fd) { + if (__syscall(SYS_fcntl, fd, F_GETFD) != -EBADF) continue; + int opened = __syscall(SYS_openat, AT_FDCWD, "/dev/null", O_RDWR, 0); + if (opened < 0) secure_startup_failure(); + if (opened != fd && __syscall(SYS_dup2, opened, fd) < 0) + secure_startup_failure(); + if (opened != fd) __syscall(SYS_close, opened); + } +} + void __init_libc(char **envp, char *pn) { size_t i; + libc.secure = kernel_get_secure_exec() != 0; + if (libc.secure) secure_standard_fds(); __environ = envp; - /* On Wasm, there is no auxv, TLS, or secure-execution mode. - * Set up minimal libc state only. */ + /* On Wasm, there is no auxv. Set up minimal libc state only. */ libc.page_size = 65536; /* Wasm page size */ /* Set minimal TLS metrics so pthread_create's __copy_tls can @@ -59,9 +84,11 @@ void __init_libc(char **envp, char *pn) static void libc_start_init(void) { - /* For wasm command modules, wasm-ld synthesizes a call to - * __wasm_call_ctors from _start when constructors are present. Running - * the init array here as well invokes C++ static constructors twice. */ + /* Kandelo links process modules in reactor mode while retaining the + * exported _start entry. This leaves constructor ownership here, after + * __init_libc has installed environment and secure-startup state. */ + extern void __wasm_call_ctors(void); + __wasm_call_ctors(); } weak_alias(libc_start_init, __libc_start_init); diff --git a/packages/registry/lsof/build-lsof.sh b/packages/registry/lsof/build-lsof.sh index 551a044eaf..c7baca5479 100755 --- a/packages/registry/lsof/build-lsof.sh +++ b/packages/registry/lsof/build-lsof.sh @@ -75,7 +75,7 @@ LINK_FLAGS=( "$GLUE_DIR/compiler_rt.c" "$SYSROOT/lib/crt1.o" "$SYSROOT/lib/libc.a" - -Wl,--entry=_start + -Wl,--no-entry -Wl,--export=_start -Wl,--import-memory -Wl,--shared-memory diff --git a/packages/registry/mariadb/wasm32-posix-toolchain.cmake b/packages/registry/mariadb/wasm32-posix-toolchain.cmake index a125fc178a..be07cfd261 100644 --- a/packages/registry/mariadb/wasm32-posix-toolchain.cmake +++ b/packages/registry/mariadb/wasm32-posix-toolchain.cmake @@ -89,7 +89,7 @@ set(CMAKE_CXX_FLAGS_INIT "${WASM32_FLAGS_STR} -nostdinc++ -isystem ${WASM_POSIX_ # --- Linker flags (mirror sdk/src/lib/flags.ts LINK_FLAGS) --- set(WASM32_LINK_FLAGS "-nostdlib" - "-Wl,--entry=_start" + "-Wl,--no-entry" "-Wl,--export=_start" "-Wl,--export=__heap_base" "-Wl,--import-memory" diff --git a/packages/registry/mariadb/wasm64-posix-toolchain.cmake b/packages/registry/mariadb/wasm64-posix-toolchain.cmake index 09eeec50cc..284e17bc07 100644 --- a/packages/registry/mariadb/wasm64-posix-toolchain.cmake +++ b/packages/registry/mariadb/wasm64-posix-toolchain.cmake @@ -87,7 +87,7 @@ set(CMAKE_CXX_FLAGS_INIT "${WASM64_FLAGS_STR} -nostdinc++ -isystem ${WASM_POSIX_ # --- Linker flags --- set(WASM64_LINK_FLAGS "-nostdlib" - "-Wl,--entry=_start" + "-Wl,--no-entry" "-Wl,--export=_start" "-Wl,--export=__heap_base" "-Wl,--import-memory" diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index ef8d6a429c..9adb682a45 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -4,344 +4,344 @@ "bash": { "manifestSha256": "5dc4d97dc4f154f265ff57e9d972e7d5a64a2e15fadedfa246733b830dda3497", "cacheKeys": { - "wasm32": "f07a60d864434909721977f82b8a3467b54e32dc42e358b530e8ded261da0538", - "wasm64": "4d6c6fdb4f64d1a0eea097b7a21067227d8dee1b82e5daa9eafae6a4094586f6" + "wasm32": "2d577fa298afea12b9245e372d0e8712bb166f5d759f612f86c7b658367268ff", + "wasm64": "16506663240de8625fe8e5fc6c6f19cdb9ff1066405eca675b9b9390677912d0" } }, "bc": { "manifestSha256": "43b61e92c29098720bc7f4871a3b611cc01ffd124024290d42967cebb6ffd459", "cacheKeys": { - "wasm32": "408e5b33718c929736bc0a5c697be47fc7e39ab9874b3c4b4344b99fdbd06858", - "wasm64": "fe16744ddcb25529c447a9e31d88aec82a8a19525d92831951b2d22687634fc2" + "wasm32": "f95f8a79fd939755345af1acccdec0b21f82def30156d8bc1bfeca2ee9f4b57b", + "wasm64": "9c091c12127829ea1ff4606dd3d97b9cf30c015a4eb9e5c2bdc45a5acab8dc92" } }, "bzip2": { "manifestSha256": "59e1e53f5675e7c9148634933ed0f4c7192743727025cae4e843b6924c489abf", "cacheKeys": { - "wasm32": "fb25499f183169e8db67b97587baf931d5b02e34b4603a17c11e044247f2970f", - "wasm64": "f9698d5c1bf3f00214f5401b5522269da306dd3748a3a90d62d8139aea34e22d" + "wasm32": "cdb8b3ef6ef84d9b962df3cad7f0c09ddd146e8a1e1fa873b31ba09265fdfcd3", + "wasm64": "20dff598bd14330ccd05207d108b099c0e79c6e00f68a8e298f1b2ba93c0b0c2" } }, "coreutils": { "manifestSha256": "72fc9c3818fb11cebaa047a623743252395dc729163fa804a7272d59f87fe82d", "cacheKeys": { - "wasm32": "cce36c322c0b3920569d5a2c270ed073aba92ceb684af714b497a805a25c4b94", - "wasm64": "7878a840c190fe2dc7666fd459253dd9becb32c2a49ae96ecd1a81976a67e1ad" + "wasm32": "9d4a7186e54db60edf09a2aba993d4f1d4660dcd37f5f280581941d09e4fb054", + "wasm64": "d9232813053ca651f5660a2360e15f019ee2b428e8982ced50194ff3bb263191" } }, "cpython": { "manifestSha256": "7dd4f446697a73941ec940c2ccba4d53be73fe6947f1e701031a4d0aa964c4ff", "cacheKeys": { - "wasm32": "5cc4364991db0475c376fe9b665b98f028b193080389ca3bfff6571050f5740c", - "wasm64": "03314c1a6672c3a4d1eeab859a59f6d9a86d2a3b10303b6655a210d957b17ed9" + "wasm32": "2b100dd2bfbb68d67c935fa9de93e1e82721f50d2040a7a1d22cfa2656053884", + "wasm64": "7e19d49523cfbe6dbe5831477aa2f3c96ae7e1ddf2333923732edffa9fb79d8d" } }, "curl": { "manifestSha256": "55523d50261f46dc4aaaff458d1cd87c6f96eaecd687a7540ead35c96906366e", "cacheKeys": { - "wasm32": "08caaa4fa5611ec4f8fadbd17172305a1efda6e5861f87430ae0c56b0ed89863", - "wasm64": "092af89d05c259f7f36f3374b35ae4532ab5faedf4dde17bb761c34fb02ac344" + "wasm32": "18890ef7352b7881c2c0880b032e15166dbbe18b24bd3082bda7728755451528", + "wasm64": "8766258487a29ad15c77b282aba60639cd1f8335fb4b726d2c5bef69b3286d20" } }, "dash": { "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", "cacheKeys": { - "wasm32": "9c286ef92425fea87cd0c23f3ebacad335791389bc0fe8cd00572fac8c2b3dca", - "wasm64": "4899a2545851c9cc17d489308f64f99aa66652349a3caebf1ef1532984579d29" + "wasm32": "ed8a5db2cd63484a368e0181b72f177d072a64bf4b053f62fae128bb5e8915d3", + "wasm64": "f9ce165a14ec14b4bdbc81a5fd1238f415d365beb1efa51a55e435b99d807a60" } }, "diffutils": { "manifestSha256": "2286203eb762eca487939963e38ea1b901ac2dc9a830d3260c2fb291757df208", "cacheKeys": { - "wasm32": "febb5cd14a0493af2060c4dab4ea9316fb814f5861c6af3e31fd19f9c5d99678", - "wasm64": "eec48b6f0fc19fb5cd147acf1705234693e0c1a6f088f37157666bdd08706e43" + "wasm32": "247e25a24952124aa930b6b0bb5092fa2c74653e52bb2e928a049ebf109ff9fb", + "wasm64": "7c0453b0ffb280a8ec28e301fe88fdba554905638460ff7baae8f758b93cdc7f" } }, "dinit": { "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", "cacheKeys": { - "wasm32": "81dcb6d5f937d5cca3481492df72cb19bf9f8c84973ef6093dde0e149ba42daa", - "wasm64": "b201d49a3d0d5ce1203443a382281a885cafdaf52305b5c44c4c1a87e392fb5d" + "wasm32": "0cfd3dca3b9499921d565e03a274c5ebbaff265e6c05e98aa8ef889d2140ccd2", + "wasm64": "e64d25c0a8a6aa5a8032094c312432783dfaaf60846c27cb0077637138429320" } }, "erlang": { "manifestSha256": "475d6037e73a0f1e2f4381067194b2422751206979ea17209e10efa5a2a93bbb", "cacheKeys": { - "wasm32": "86516154326e2765f9ac8d232c4be618191de0a3757891ba284d826622b84d19", - "wasm64": "34427fd4797b61bfb39e44a637639d3291f0e55c1d7ef916b6ee17aabeef11f7" + "wasm32": "ee489dc49dce5553b05b5eed8d5e56943dece8dcd8bed0fa01e39dc95c63faaa", + "wasm64": "53ce5fc8184f5c155924a2317cb4c32acf78166aca3ac50ff5415e89ae57832f" } }, "erlang-vfs": { "manifestSha256": "15efb74f1825e2b85bedfeb8d98c19fa648c4be39cb9defb65330a8f21eb9141", "cacheKeys": { - "wasm32": "621bcdd99ed2660c5daed2981241676bf3befde53bda53e0f4213bb27e2e4c17", - "wasm64": "ab3050fc5056186dc395203221aec37401d52ae3c97401fc5226f79c0451fa32" + "wasm32": "e9fbd981a01570739a315fd8a4c8177eec4d0c381e0080b4d950157cef6e6de2", + "wasm64": "a8162a4d5c5c74d4e5a321b1bec93a7f224aad38ad6691969ae65366b6a8ba92" } }, "fbdoom": { "manifestSha256": "7ff2127ca940e41be90ba45204c89089a7a2093567b9bff581a9549b57138626", "cacheKeys": { - "wasm32": "2bf2226875f72a078553c3ec6dc6c51bc59ff73aa010937c4b39b679fee48422", - "wasm64": "d9b417e64fff81383c9bfc26cd2870d55867d535f7ab10345eda8afef714a9b3" + "wasm32": "a19efa9f3f880a2f7582794fd6c080d18f0f52cebfe7380713096fdb5e0268ef", + "wasm64": "35679ebce821ff21e73b7460dcd40df1958f55c6475db1423443caf1261e9db9" } }, "file": { "manifestSha256": "7874c1affcbbf2c8ab8c8d4beb087f275af57b5caae6a8947bf5a63a181552b2", "cacheKeys": { - "wasm32": "954c87e26e61e45fda6aa1953de7738da41760d7e6a15c153e7b0ee5a6510e18", - "wasm64": "376b8f8f204562a66d9975fb57d9631471a704145351d5fbd2d9e21138fa3a10" + "wasm32": "4677df78cfe25250903279ae481372f6985aa54a9f85682499d0ed2a94408eaa", + "wasm64": "1c5eaf4737bbc34ebbcfb90814b6dad13e84ba990fb400fd35b3da6d92d89e62" } }, "findutils": { "manifestSha256": "a9969cb102403cef105e758ef602692500fddd75ec96e4e3f168c50094b6c931", "cacheKeys": { - "wasm32": "1927dd4585688c78835726fbc504184b52af1901b4da0baf60485cae3ab18c9b", - "wasm64": "366fef7e641315da941918ee22ab42f73c5c27c3a996acf0a7cc5ca1602406ec" + "wasm32": "adcd350e6f8977e86330c59b57f52b11524143c02dcd4a18a2447e5f635c88e7", + "wasm64": "c8c74d264f4e6379000ac03b7454aa50cad602f1dd1668c90cfa188934504440" } }, "gawk": { "manifestSha256": "b788f3de724cd363ea68d08826586ddf544a75fb5dd9df1369ffafe06d8681b7", "cacheKeys": { - "wasm32": "ef897546ac91a4c6879281455689ad9a7d6cc2acb50c3dd356dc6fb5df30d380", - "wasm64": "662591a7bfcaaa596ae454a63e3c6863d6d238d0bb6349f47d80222db7ac2212" + "wasm32": "915c98a7739cbeefb8b4ed326e62effaf0c376c0d187141254527c08eae134e3", + "wasm64": "db09ead6f160c47708688b8fedc13dc1f0219b727039c7a7c47afcaecb57d5a2" } }, "git": { "manifestSha256": "c2bc79e62c9a4e840ae94a16af0f41070460eaf3976a990dc60d2db977bee952", "cacheKeys": { - "wasm32": "91dc732cb54b0ae02135f60f815d728a9a52e5d3efe470d51199b498c3aa49e4", - "wasm64": "4dbc31f08bfdad8685e265f1791ee41ae58bb0660b69626ec077fac11b1799f2" + "wasm32": "44e9c599cc26e9a485303555de6911f9ef46c7e4d8eeeb049d72e9bf9bb22c79", + "wasm64": "945f3b50f7caa917fcb3bba560e8b2b69d4e0a3eddb904f026e1b374e30227c0" } }, "grep": { "manifestSha256": "7c61b894807b831c7e8816cb1f39c78e3a69a4ced2fa3d18f0abdf00bf18334b", "cacheKeys": { - "wasm32": "c1c9f271d06c271dd1e85f008f986b835d0ffd40a64537da54dfda1ce9f3b824", - "wasm64": "a1251865f2fc5ba31c411ce6239080505305d635ef0226545d841c92ce7b9afe" + "wasm32": "a0c03526e72d0581fde4e5ef55a4e5f38a8047e2d93501aa56db6aefff6a33ba", + "wasm64": "dfb070bc86df833270c34b7d6f59d0b9cace5fd6ab17da00c3cd4e5b1902c57b" } }, "gzip": { "manifestSha256": "1485add843bbcd744244e707c353208fe11192b7b248feed1550875d3b75a917", "cacheKeys": { - "wasm32": "0367e5e2485ddaf8a30fd43d95584271ae2d7096d4935de8727b65ace3f9105d", - "wasm64": "decf2ded37c5e2a9d9a370afdea1119e1b7e2a3f25ab2ce332ab7c03dc807839" + "wasm32": "0760ed1ee2e04a89dc5e47ad9134d50d981e08035c624856c0e0f0248727c4f9", + "wasm64": "094ce7c65c4a45239511af4cae666925af287ea2b4ae68973009fec49aa84eac" } }, "homebrew-bootstrap": { "manifestSha256": "183004a8385ab5afc388cd43401341b82812c67f97b08ad349ebd7f4dbbebd0f", "cacheKeys": { - "wasm32": "bf4a420c5bde02b90ce908972fce7b7da51a58ca2604224e89ce64900293b572", - "wasm64": "cd748c06d9d93ab13b529fa9297c4015ab21e75a7dc5747070c4f8018606626c" + "wasm32": "9a4810e7dc1daf506b1b74873cfa601dfe5aeb0d868b39c8279ebcadc04b0ccb", + "wasm64": "00c0cf8a0f0cf33922d1bef636c100b029361198d3a89a6a72e1d07f604ddbdf" } }, "icu": { "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", "cacheKeys": { - "wasm32": "2785cf0bf052747d2ade36d90fe0ddb40a058f305d78e845ca5bb25dc553cf5d", - "wasm64": "f51016e902caa89ba7d66803599622a3c04c6d0ad0481c8dab72f9315effa246" + "wasm32": "3fd9598c6138054150f5d457a0b0f62d44ab0920c2a5ea1419bbbd16f2cfd4ec", + "wasm64": "3ee6c490a5c87d833b954527a7c9e3615d6d0ca2a153daa3bdd6e1cd7c962a48" } }, "kandelo-sdk": { "manifestSha256": "c081879f1becd855917cff96f17429bcf2b9fd61bf95211eca9ff8fb625bb2eb", "cacheKeys": { - "wasm32": "462a0eab5fc9741b99b0ab4b4e07e2bdfc7ccd78e6a84463c9c28c15b94f389b", - "wasm64": "c622818d4529848c2986786ff1791e2be8590da9e84ced8af0440ad0f3358c38" + "wasm32": "0c81181a836e1262ded47966f272a53b9e54f92ecc0b514d6b27f087f0152b94", + "wasm64": "2e35685ba04c89d3e237fa187427ab04210709ca2d370ce472c8e04f87963178" } }, "kernel": { "manifestSha256": "db1d66db8575562ac7b3e720dbaa06d7dd5eef577d80220ea4167dc2d69b863b", "cacheKeys": { - "wasm32": "0645f1c9514cdc63e297c4c66111011d462165cf134bcadeabf31f54324557d4", - "wasm64": "5d36d84daca5a8161d1bf4b8952c6879c4ca9406e5fa5c2fd1e91202ef3aa5ae" + "wasm32": "2f8f321f40b81120644d66fd1c5ed1e949be708781c568e14b62d7171e9f9f60", + "wasm64": "7cf3cdd692159922c87e99144ea77d1ba027ed8450410078311bd65f27ff4213" } }, "lamp": { "manifestSha256": "250b64635d64f8537178188fb5488dbfd2980d79a1c2ffbf346af67008088ba0", "cacheKeys": { - "wasm32": "c29e679e6caf9c2f50d12df47f080b21cd7f0f223e4150222f6bf5877ccb7129", - "wasm64": "b18a0317ce8a554b2ed873e4069abbdaf5358145864681ec8702325a4d01e229" + "wasm32": "49bebcac721dcc56c84213c2f3dba851ccdde69183b10be3356293c926180581", + "wasm64": "52bb215e95eb16c2f448c5f82ab3dd1f726badecc8463a51948f418fb8a3ad56" } }, "less": { "manifestSha256": "996d61545cfe83dcb663a33e8763e0edbd4db4a605fd69a91b243cf79b1f9b17", "cacheKeys": { - "wasm32": "9bb5323df1cb2ef84cf27efc789566560a79649bd3deb00be219131ee2ea8911", - "wasm64": "326adbe9177f840d860a07eeaa067cdaa2d5bbd643a84104a787d117dd568674" + "wasm32": "26a936bd1d8d5817eae86a8e5680890c0c741be7fa88f0b92035552c28f18b0e", + "wasm64": "a721791e2e345a080def3fe746dc4abc0c92dd554ed34655fcbd2f0dcd70c2d5" } }, "libcurl": { "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", "cacheKeys": { - "wasm32": "797282f3a1e94fb74c16c1fe0924d478eceb9fc9ae23cdefa40d675a76616ff7", - "wasm64": "18644e97abf2a0b5175ea0dbd8ff3c1ff77f84666f463ad852aa439ec6436e48" + "wasm32": "65727e0e1a26b1faaea4e0f0fbddce5e5a44012df69dcda1bb7e3e8626487268", + "wasm64": "19faa6e2474c3b9f4c974966a531686623513acd1455f2a015310edb14dbe199" } }, "libcxx": { "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", "cacheKeys": { - "wasm32": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866", - "wasm64": "fd3503736c73dcbe633dca3304e10e72ad4dfb9c8597f301a064180a3e193e91" + "wasm32": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd", + "wasm64": "b65406cc252ff13d6049ae52461196e07423734583b162422829821838609693" } }, "libiconv": { "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", "cacheKeys": { - "wasm32": "918054f8e258e000a8f3ac9ef70870d7a0cb1153ecb6e0ac5b99b6152abae905", - "wasm64": "d860fcb323ef454ae7e7f7f26868e2430deeb1d1ee5af491266920f31782841d" + "wasm32": "6aa4acaad6784b9152ec957d4e2e8da31db5c0b610414110f1ddfc5623772f71", + "wasm64": "b4e459de04bec0d2952f9371c5a329fb45f6508aa8df57452df1180ab47932e9" } }, "libpng": { "manifestSha256": "79ed5c5c072a0267cce5c7a2fe37d8494571b17e44b952f619e04ec1c4a9db10", "cacheKeys": { - "wasm32": "cbf7b239bb280c976b263f0a4f7fe6ce7b458f1d555e8306e8625d43c097547b", - "wasm64": "a258a9d0e309d77ba120e02de167a513145da565929e088a8038e1fbc016a70d" + "wasm32": "2ab54d9a91761c737ce35bc700d809edbb1a575f5365372a1cc3fb3e10977742", + "wasm64": "e4aaf285ad36569d1deb1be3c54df7eb0701e2479d25a95a022211815a7c704f" } }, "libxml2": { "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", "cacheKeys": { - "wasm32": "72264a21723fe72302c5e1b5205aad7117476ef656f54538e98a66c70219be8d", - "wasm64": "bf7a86b08899217e0b94018e8b612a48463e84c7de8c4ee40305945b19061602" + "wasm32": "c11983c6357c1b77a011aaeafde22882bdaa777c65c40dd99d4c48777acf23c0", + "wasm64": "46c8e8a6bd3b65051924aa6f0ee61d89c18b42a213759c1aea39749deb01e057" } }, "libzip": { "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", "cacheKeys": { - "wasm32": "01eec0bd3a1139f3baea193789492b632875c70d38aaf0be16794e43d0874245", - "wasm64": "a26b93101b8d05382ee4e2211c8a0688353d13a2c1a2baa07d6c129d0e4b396b" + "wasm32": "2b8c3acda63a9e373a63ac32b693eb0a3b7d1f01d19dc114a71ec88c5c0f2168", + "wasm64": "b64b8c3a3294a08f9f92292e1d0ec475c4eaf5cc148a6089093dcd1eb96721ff" } }, "lsof": { "manifestSha256": "cfd199bbe082435f7a2e703e2738a368af1eeb5b76b6e93c376054f21ce94419", "cacheKeys": { - "wasm32": "539fe25b0bfe42849aed4bb1203fef9924225f243196185c663c70ecdc9c0593", - "wasm64": "3a52e57e3bc1b03991bd101c6059d574a6283cf46b9676e5b0528cd9ba0e1bfe" + "wasm32": "909d4418866795cd511ebaa25cd751c7e1075657e24c334c859397cb6366e7d5", + "wasm64": "b1b3448fcc285bb589d11d09d24685d1cc55edd3e907c1bf6805023bec5a3925" } }, "m4": { "manifestSha256": "9b5838bdde8531079f23a89e135aa3832bbbbb7ad6099dd1503db877d52afcad", "cacheKeys": { - "wasm32": "3da16454c105fff20c5e2cb8fef979e3a1a6166aa6e8eaa02a1bbef4b879f91f", - "wasm64": "b8dea51e77300f2067e09a45a046a491731f8f7e11efe23b6e2c31bdb89bf33d" + "wasm32": "b887bbc00ed62a5228e955593dcc4a31d98e8fe71972d59971e32bd76b7ad536", + "wasm64": "aaa2937b0bedd1be62339182746a482c18c57f28e1a39b071d089fde98a6d6e7" } }, "make": { "manifestSha256": "62e8b36a6df7e981d191061a5bb7f2db85b2deaf365352a7d590a6c6c06f2b1e", "cacheKeys": { - "wasm32": "0c45cd8ffb70f1a4615b8c0b7a0cff5acbe2645ffb795be91642bb510d070822", - "wasm64": "eb6542f3a2a610025f8ca2840641453d1ff6f443c66ec74363e8bf3b900b7fd9" + "wasm32": "de30b90416e0a0d62ada5726860e8e7cefe891eb5ab51d8b26f18f50e0e06fe5", + "wasm64": "12bfc99c5799e98b42dfb9d54cf4d60132804c73ed3597f9e61606a9812115b8" } }, "mariadb": { "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", "cacheKeys": { - "wasm32": "8f70905c8e42c2fcba97985f98ca1754ef32b0045daa638cc51410e90debb876", - "wasm64": "57287289e381c0e77d76a2bab7825b897a736f7b51fa0c41ff98dbe2047e9d53" + "wasm32": "ab24370d6f159ba9c14e964ff902c1182a303d29dd51639659c6938dfdfc45e5", + "wasm64": "43cfb64022b7de7df2e7b95760a9f4cc8808ce59449d388eda35d6f79a6869b9" } }, "mariadb-test": { "manifestSha256": "d4ccce161d659a5a87c80fa1117054bbccea870e0d4a46bd8a070d3930ad0e3f", "cacheKeys": { - "wasm32": "c6ea058fadd400811a3278f8fd3e5c457e65c321784894ecd1186169d4bf4ef9", - "wasm64": "5239737854203bec755ca69da5845111cb9ca83d94e0b59ecfca593e471d762c" + "wasm32": "08446236c220b97848a9444d26251cecc730bb916f8457021df56a4046e1c503", + "wasm64": "c9192668bae4075d07d61f4925c04a01fcc4cb1ee755913cabf2ec6b9dd53cae" } }, "mariadb-vfs": { "manifestSha256": "26e74ac84d89b2a839ed72783437061a23022ef2f88ad0f52b6aacd0442ee1cf", "cacheKeys": { - "wasm32": "5d73bc2fddc13270b98c9bd446a871c944d20b64222b1ce1f7f72d004625916c", - "wasm64": "838bcf9a59b106bb02221d0aae8c53730d98e5dc24661fcb080059345c99cf7a" + "wasm32": "91aff7b9e519b98353523db54a4e0ce139a8f6b37df64b07d53a9b1132afbc3c", + "wasm64": "e584bd74f7e9f6cf1b5fc5d83184afbea7e2e1afba4de58d8b08208e9176fa6f" } }, "modeset": { "manifestSha256": "36a8683c1c7309701d361c5f77e543a6c1531397b26c72dbb864528c59c42954", "cacheKeys": { - "wasm32": "c1bca0979d20f7414d74615869904854fec454a3ccf1c08035c30135ba8d503b", - "wasm64": "0de21db45ad8f701626567a351ab800861b5b214eb71d8dc939fe14797532b21" + "wasm32": "cb77410ec2dad89f43c7ceef9c33edf3c1ec39a5430b9327a70136349794350f", + "wasm64": "b9db674257b0930100fe21e29a984cf3f68b4026435c14b0b779ce2faddfd3b2" } }, "msmtpd": { "manifestSha256": "686ff665b5e7907e67618790b1a3eddce2ff47ed665595d17209bdcda1e0b274", "cacheKeys": { - "wasm32": "cde71f771c392d62a09b6227c18c4c4ba5ede3605a085c57289966f5734e09d3", - "wasm64": "290f24e6624afac54d68ec99d262c20511fbf3a7e212c57a917fd4ac0c513820" + "wasm32": "e52466139aaf8c185e8f8983b548dac2413ea241af093f7c854ee99325999176", + "wasm64": "c6484b456b6de24b58c6645199e50eb08e26a08640fe514dbcf28397dc22facc" } }, "nano": { "manifestSha256": "c5a52f0c37e08b475bb815367b1f0d63d0d2b06649d99fe9f461b72d0b9cff51", "cacheKeys": { - "wasm32": "245ebf3255ccf561998dffcdc1be5b6cc584c6db353279fac0ac3ff5db449118", - "wasm64": "ca58ba1842ea4c42197f67e0f9ece74130671a67cab92a5d46a8bbb375046daa" + "wasm32": "43267a08d7d10ce343f1ac2354ad9e3cf2193931b1f3dec522353ac9b0e93323", + "wasm64": "95930c0b62cece94c566a2d94b1a3df552b2f84f25426c8462938ac824181142" } }, "ncurses": { "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", "cacheKeys": { - "wasm32": "b12820cd19b19d2999c58fbcf2afaeafacf7cc897268558936aea2b76d4a5182", - "wasm64": "e5858f05f3604dbbfa4a283ffa6b8675464e214d44e51d6a1292661fa3f80e9a" + "wasm32": "0fa705045e9b3026abaf8e26a8e3c38d5486a4ad9a967752458768a442e32e0f", + "wasm64": "2d459f7bbf22e823569ef41559196fceab6a3a8d40e13948ff2136ae70d65a74" } }, "netcat": { "manifestSha256": "4cf33cd1ab768b3ad0108da8b68c2ff72e98469cd86a0bf2d0da54a7d4f6fa16", "cacheKeys": { - "wasm32": "8f6a38fe2dcf6645ce394283228cc6c1274a74d0711304b2b480ae5eee6fc38e", - "wasm64": "3784cd11eca4d59b6b30d222c34d481b2a92f6106bebed078fc33cd77af92fed" + "wasm32": "b98fe24204f370fb495b84b0241db465ad666e470b2caa081a8b7b453b2c971a", + "wasm64": "0b1c6b5766a50e3b5036997685ff66bde3bc815a6dcd89e23bd53a2997aeb4cb" } }, "nethack": { "manifestSha256": "1a2f12ec2770bd8d40006d6c878a3493b97c71c2bb63ad08c7f6ad99bfd53504", "cacheKeys": { - "wasm32": "37063f8f247d962d17573762cf5f2b8cc285aeed905fd9aeb75a9ed518502f95", - "wasm64": "37d5b955c47f3fd8d80386f013d956843e31a3dfa26401814cf6b34f99ce95a2" + "wasm32": "df15777c107fbc3302c8ef14d38b7cea8081eb74b0928e38530deef188b0170d", + "wasm64": "87124c7e8d711b596f58555bf7b548019d6d6f33b4957fbb3e26ecb922df4461" } }, "nethack-browser-bundle": { "manifestSha256": "b8294981da7ce4299dfb271d4aecb90ababa97a4d1acc9b716ae05bbe52beb53", "cacheKeys": { - "wasm32": "21d8c4f96a174ea9dae1ef8b55d6c43762d3c511074501c8f12ff8ab3aa6c85d", - "wasm64": "15f5be5f3300dd36a4ee6f31ba2c0781669ff30d4eb54117824a3ad1d227809a" + "wasm32": "eb8580b1a03a8f9575477c2618419e3ec1df34880c26eba312ec7d7f066931d1", + "wasm64": "8db819579bbbd5369f05360934d8afd1bc1359c8fbfd9a83c12586c19138cbe7" } }, "nginx": { "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", "cacheKeys": { - "wasm32": "a678a30cf48177514b258ae186bc92282a3f564327629060f9e75f19443b75a2", - "wasm64": "8e1841744c2abc7f49cc3be268a0156c4da856a7b5ef5890bf9b31fcdf2462e8" + "wasm32": "9e932bd478a344cdfdb68da0f300f147b2d9c60bb385cf1ffe7906e315d357a8", + "wasm64": "497a4ba4f07e8d7812f9a18058b1c11840031ddbef337d2a0527acae665a4e39" } }, "nginx-php-vfs": { "manifestSha256": "97976410cb02f8ba710d856b4ac904bcf976d677b50eefdee39fb64176070d4b", "cacheKeys": { - "wasm32": "7e17179fb8a3b3264f944ca62a6418af19387bbfa879ffd87bdcc6b75e7c13a8", - "wasm64": "1f86154e03f2ca07e84977cad71d9e98cb09720b71be33f21757c1d09175ddd3" + "wasm32": "d923e282640dc6b68f226f68f89a147eda0aa775bd6899246bccea534a5be3be", + "wasm64": "6037fd41edc9fdada3e2ca7b9ac99cad1f9c09101794936527cfe5df09432bf9" } }, "nginx-vfs": { "manifestSha256": "46aa2d3250ac5cd0f102a85c3a36a086c7a50ba1f9e05fa1c2aae3d07ad16f10", "cacheKeys": { - "wasm32": "e70252d6f9dc8738b944236168f343f87aea2ac1684f21b44d8f7260087c37ba", - "wasm64": "904623e429616b482a09b6ea2881b1c205ba9dd0ef527db8ad29e34866452714" + "wasm32": "403a4321700039225a1224421293a0aeaa6c7ad6456759a3399e5d0a4c6a534c", + "wasm64": "a2978396e9be434d2733d7ab35b15dff00ff58a05c6dc375d07f62ddb6207ab8" } }, "node": { "manifestSha256": "2131a24dfda8587860e57fc65b573086477d376b065b3b9ca4e1dfe4d79f5790", "cacheKeys": { - "wasm32": "9ebb636f0ebc5111d9cc16c4194eab9ab54ee537a777bd772b4ae4c0ece7d24d", - "wasm64": "a7a36a3e566ae8aa463203565f92d82e5653d9f7898dc95ed2940cc4b40dcbdf" + "wasm32": "1ade84c0949d68410604abeecc1372019d5cb5be36257a1ea019e8d3eac8554f", + "wasm64": "4c5262332b58ed7b38d120f636e97854c814ee40f2f0c636dfab990f1f4b06fc" } }, "node-vfs": { "manifestSha256": "977f219defe57e18017ecc963159df32efdd21c53d6fea05943703d04739d8de", "cacheKeys": { - "wasm32": "a23dc8ce824ad822e0f2491691fb0a9872ab3842e1eac0721d89f766edc4f603", - "wasm64": "6de8d9fcbf40d86e1cf22b9fe4bc0c6a18dc8f9e486782ee772987cc9af2588e" + "wasm32": "401cb759b750c5ccc6bcfec5ff04500c270718b2345e604630b3fa2a9769f0c6", + "wasm64": "2f195ee9f67d8481526e6f7c7dbecf4690318b60b7c6e53fca1569cde2349562" } }, "openssl": { "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", "cacheKeys": { - "wasm32": "8645e6be336d82ed97665c93c9122f4046869d0495f464b9c89b3157426b39e7", - "wasm64": "c55193193dad788287e65554d297d8303684f09b1e5552c9326b0799b5280e90" + "wasm32": "36d99a823cd97e42e1bfd1aefa9b4a0bb79b7879eef2faf1dd9e932445b21d47", + "wasm64": "a60d45397ac4db3a2ebd16c8750f4ef8e0f550b292b241cf6220dc0ef9c33dab" } }, "pcre2-source": { @@ -354,225 +354,225 @@ "perl": { "manifestSha256": "6cdc4dbc54d0e4008cff41f82ec8918c0e44b4aef23903539c1bc0f538fe3ad2", "cacheKeys": { - "wasm32": "c8cf2040ac50eaf4de04912a3c4fc437004a54358c97bc2f8e2b4df40544ab01", - "wasm64": "e1bfd873c505a551e85e5f76c88c4b3dd227f977cdece78021866fe3a0f47f38" + "wasm32": "3fe8bfa8774d6b5f5c2b80013c6495d152b689b52d42cd78f0a394a212c782c8", + "wasm64": "5ad54aa9e47dff952bdb0d2ad922f88af6965492cca061fc03633039583f8796" } }, "perl-vfs": { "manifestSha256": "1345cc102bc8c24a6bc276410c00b4fcc99ba5f6adc9b031f882aaa35d53c8bc", "cacheKeys": { - "wasm32": "1600ccb83a5d813b97cd998fe6be47fa67dc9d7fe4eefca8198d9a85d8c6bb2e", - "wasm64": "200cfe3ddd6e38e5abfe6985f61033c8026c2d3c2f87c4474c216cc11def158a" + "wasm32": "5ba81a4073afa11a913a2cb53865af56cd58731d9c262240fea425e5fe63ddc2", + "wasm64": "049b633cd911bed80d24c219f08db2c3886633278c2345e5468ee82f5e15579a" } }, "php": { "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", "cacheKeys": { - "wasm32": "0e623672a33a21b893c7f44854e7215bdda530a9e66a87dd9d91602e123d1d89", - "wasm64": "072e91a8aa48ab42a9ca7fa1d43537f835d325ddfef4b426394c0dc7325f191c" + "wasm32": "635e5ed39400e0333d51d16278634afc56c8c17e56be858498bff4886c93fee5", + "wasm64": "114c23199ba4c51eacdc8f893b013de831bc1a2b3865b95235fa2bc490f82cee" } }, "posix-utils-lite": { "manifestSha256": "8fd7190b2848ef80143adc7b9268c79e13cd4db3430f7105faf49a4b4407a06f", "cacheKeys": { - "wasm32": "e41f65aaebc190dcee228d1ea98c2457067fe7d663600295edd3c4dba5fa7fe9", - "wasm64": "f1f74230a45246a83199389b0720846c0d1bca49cb74b89c27e5183c88c34bf4" + "wasm32": "dc7e6124ef4a64df36ade1ea208b7e2fd1f0929773482f92e93f7063eeeac5fb", + "wasm64": "0a7a1e68253db606ab8deb6e5b95dafc30ba930206518f69055586d888a515f4" } }, "python-vfs": { "manifestSha256": "d1aa99feae65f06c0ca8cf52c2176534b2723fd4ee6eba6e72c205245e1c9c26", "cacheKeys": { - "wasm32": "a2c3bbc0db8e3b1e219839757b46be850e7e31c5e8b0ffa75ef1d217516887fa", - "wasm64": "9e2ee4f073c9a26bdc818cf27b38b75a917f95193c3c4732b32859e5a08413fd" + "wasm32": "97185cb95c82b5abc19b794210e309a128a07648dde2a7efbbb2e79a9d7da16d", + "wasm64": "8354e74df5fa090dfeea55d5d7e6cdadcd1b6623bc8fa53318d576302223ad30" } }, "redis": { "manifestSha256": "151fb507de953ba2ba94b8f881bc7d66b4c741c1359a2f590cc07f9a4cd368a3", "cacheKeys": { - "wasm32": "68c241d9052b09d4f4f59f8632f504b723c15d2fd2141f8f13996890423c3a34", - "wasm64": "1dfac5d2ff93f5485902696a2c444901288b616e123a51d227c7636e34a7e5eb" + "wasm32": "de47bd67078bc531a4218667ed729408d85fcc26a6b36b144c65e0303c54f34d", + "wasm64": "7d2160d57fe48f007c944a17b46c9cb27cff9601dc9dbb95bed36a8721757d35" } }, "redis-vfs": { "manifestSha256": "234c45c40f94e2a89f98295313b82cb9fce15a412ac829d9e87394e0dc731813", "cacheKeys": { - "wasm32": "3c5f12bc722762e45ef2f30741e2def167d4797051eaae874df2f455a2ef95f8", - "wasm64": "de15b4b9f890b477727b219765eafdc92a1738624f1c8d03ad8f0d6c220dc7b9" + "wasm32": "1d08e1584b3fb853cb2dd2aca185d24077d872f26004e162f26732358b4f5ee2", + "wasm64": "167ac3506374c23515e89e1db076edd517f8f3c0dca9fd3c63ba4d3780643094" } }, "rootfs": { "manifestSha256": "0420201025170f48c32949ac62707d4577cdc13a53ad1f01f59d318ec07c8d6e", "cacheKeys": { - "wasm32": "56ae667726be17a2b20e5a5a577d907fb8706f643bfb6703c44248b9de0dd192", - "wasm64": "b3c4e178198703b5bb5f56d7b21ad177b07064d7965b3997887498533cddc6ba" + "wasm32": "58dbbd378cd51158808cb319f248416c4b948d03ff73d21fbb2120ec7a9fae22", + "wasm64": "87996c111d49497b414d46e7c9682a2f2108b0193dc9d77bfda5e1ba041ab82e" } }, "ruby": { "manifestSha256": "6ea67a246c29cd927a19decbb36ae4c4d478ff6642d2c77e08a6b2cfeb8251d2", "cacheKeys": { - "wasm32": "41165bfbfcf42d350f539ac8a20d05f909f617cbc3c31257de01e64a4cbef237", - "wasm64": "75dc1bd97ef72cebde9d02e0555fe5ccb3f170f3399d4d25baaf1814972adb06" + "wasm32": "8c4c3aadd4a06e761a018c6738e0eed5b3f113e2b648959aa6167511c39b0757", + "wasm64": "82f8c893890997ee5b0660665886a5fac30255877ba7ca32abb2589428bb5d45" } }, "sdl-dsp-test": { "manifestSha256": "a988bef0b27403846a675965d951a286245fa79e84c209657d70a9a1200e8037", "cacheKeys": { - "wasm32": "23ddd4d1743f14e568e25d94947036e4a98a6eef19142bec72b16ba5d631ee17", - "wasm64": "c8f9b1cd958c93221e45093c9ec37b9c27d277971f407b8e24b0e8b3abdd98fa" + "wasm32": "649d96fbae5f45cd1d440f148b9ac042bfe1ff752f6f0e20a67bbaf812d7dce7", + "wasm64": "833232f6c4a044beae087fa58b72c22debb5eaf90f7a4bc04b3a284986fbf4ba" } }, "sdl2": { "manifestSha256": "327ce0acca768f51e36ddab8ab58fd57b24cfd9e40722e818103c439f7263dd7", "cacheKeys": { - "wasm32": "85121752bf52d980d182b208b2d7f57492fdf7b8e888b43d7caf3a1d771ed478", - "wasm64": "116def5078eab67849bcaaaf72356aff96439762214b6594e9f0961f13ce8ed1" + "wasm32": "8a1705083bca64cf5ffa73cd0c90e2dfa52d1eae9b9f872b36b4789bf56cb446", + "wasm64": "4528759b73ad796323d7cb1dd53cc4e6d18d2d53ed3a57c9e3789581b2d4096d" } }, "sdl2-mixer-playwave": { "manifestSha256": "5ff3863e9f83cb9ad62931e067ee6e417826e06391862d9d0a58d6cc6b4dc570", "cacheKeys": { - "wasm32": "5230b431f084081e44db0bbd1506f7f84c3822c1e642bb0fa0c289486317817a", - "wasm64": "8d8a65de82e80fe7c4fd575ae4bb24329535df7049ed54a4771ad33358c1b698" + "wasm32": "8942d37250b17a645ef33e37e4920af34f1e9f78cf337fe7a7b49b51e589c76b", + "wasm64": "7566fa007ee0846d555b59833057ab8e9821f3b2477648811f6ba0b815225d12" } }, "sdl3": { "manifestSha256": "3b7c64605b941e14d336b1a127d8219c50402dece009e8855297a0a0696bd67e", "cacheKeys": { - "wasm32": "fa755e2cfc15d19c47865398b970f3fcd01fe62f0bcac272a5eeab22a40892f1", - "wasm64": "3792ae18a69857236241f438b29d30cb10c34d5b61e4abdbb3f511af119f7df2" + "wasm32": "4302058a87376e2fd2d7e9f2bf1713e8bccf00016f6e17b77ca21169662f897f", + "wasm64": "22bdd74942d2890c90e7ec569acf21fcb80d8fc9191d23851c662e4acefda33f" } }, "sed": { "manifestSha256": "0c0d82d53907c19825941501f833252cf9adf35ebcebf6786baae28e5eb6958b", "cacheKeys": { - "wasm32": "38d1afd014557aea38dbbcade2478fffffd2d23ec5541defbe48fa3bd5d04991", - "wasm64": "c5b8f6b65705b7b44f71a1206a8517794d3a4a02df6f077be36379d307ae8186" + "wasm32": "2daf8e8139e879d4294b143e0c24c89d67eeacf5ff3c45cfaee97b812d58fc44", + "wasm64": "6151aa5ff9cf0fe5a115ee9843ba57667251ff997bd7ef4f119f478195445e45" } }, "shell": { "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", "cacheKeys": { - "wasm32": "1fe39b11e701eeddeba9be5dcc7521f51df1b889fb7eb2ba35098f9a071dcc97", - "wasm64": "b2490222101a2d008cdd3cd9541737c1695e69bd1589cae8144142efaf2566d2" + "wasm32": "96e587a94acbf662c70c87ec7e265b61d313b4816101ad0a41ec8e5b94498c5f", + "wasm64": "45884340af1d2115bd3e31cb268aa1b119aa85e3c5916e15fd634f520a87e19f" } }, "spidermonkey": { "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", "cacheKeys": { - "wasm32": "87b26b326af7e0b7c9fa6f9241cfad32900a995883615d3b1bfff22d18d2f0e5", - "wasm64": "40e4937c69c24d1d723bf7fcba666af4014b210b4ff220360f3953ba1587521f" + "wasm32": "f01d6d631bafb7a31b034ec33970b67778a7ff9e4af3089685ccb941ea2fce18", + "wasm64": "c3d32b76012ed5bdba923ff7b68eec1bcd067eed0d2b455f3109dcc12299f29f" } }, "spidermonkey-node": { "manifestSha256": "f156c4c5044d2a9447324e7a2a35f8c3e6fa367b3e44f674dcc30ab6b946fae7", "cacheKeys": { - "wasm32": "e57264ee39fcf39708f464e9d124edca122c9e82eed80f68abe39783ba36b42a", - "wasm64": "c60bbf37cf43aef1ec150e1cbb52f9e37185881933031226dbb1fb62007a882f" + "wasm32": "b01e36db0587980ec1c1318bdcd39519166b044f3c2b5bc8c135febff91257d4", + "wasm64": "b3d381775911200ea9311a6518d34ef170c6aab386bb70ce469d8228d45633b7" } }, "sqlite": { "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", "cacheKeys": { - "wasm32": "fa91f92503bb45a1c89a925780c6b43f4eeb50c9fda686c15c2357e50d448e29", - "wasm64": "7f54d0c6782e686de04a6521fedd584669e6ebe387e335723efa7cac7b3c43fd" + "wasm32": "73402dbeec78ac534e0ae3f472f887982200c5c9867e4dc8d64d30d760eeaa7c", + "wasm64": "9ca62f77befac1baa13c473a518c0042522439baa5d919699adda09b43f05d95" } }, "sqlite-cli": { "manifestSha256": "1cebb768f9473dc58fc6e72c9b24565760aedaec4cbdb29ad43b22a0a5fd7030", "cacheKeys": { - "wasm32": "f1a0483d3dc6f1f65b97cb7fa7ea6c337523a1fe006fe645694ccd36e70f8c41", - "wasm64": "fe01dc84fffd6f331ed5c3abd353617eb06fd7252a55c135fd7d481511a8642f" + "wasm32": "1d2c9a7c6028ae54f6fcfdd24f590d070bcb9d784a83e4a9b663b368f0c845b6", + "wasm64": "99e7c0ee82f48233a8810405caef49f3f31b3d6c38c0bbb0f26aae490c3bb4e0" } }, "tar": { "manifestSha256": "4502355c246362a2035e52aa7b35d274882fad4b4404c03470458baee723ae68", "cacheKeys": { - "wasm32": "5a11abbdc91622ae1883c3ab3adc47651d5688843bce2248387651861256b2fb", - "wasm64": "476b7603c7336635a873d4d796c5e0ea83f5a5ec9a56d41922633abdaafac03d" + "wasm32": "e6ee4090d0aabc015554e1b5d4b337ec7f26cc465a8f32c007495ccd2c37e453", + "wasm64": "a315bcd0a07ad64b74805ca5e3d8acd43f18409f74c37d8455fe916218f76600" } }, "tcl": { "manifestSha256": "b98f237f741b11f5f5da55db75530b421755ba4c8a88d314553106faa1cca1ff", "cacheKeys": { - "wasm32": "6e9f0cfe84b03be8525fcb19bf79b3782e5b09dda920ba9c33752f457fe103af", - "wasm64": "0fcedf5bfd13031b03ba5451354c80ddb67f5721e92734382c54527a7c6b9174" + "wasm32": "16fbf3be0917c4cf47561820538c4065276df77b9373904c032986976ebc8e3d", + "wasm64": "2c39daef3f2f067ee784ca7d2215a00d0a0ba9b222b568e98940ee8098a22fb3" } }, "texlive": { "manifestSha256": "028ada023f8a8f9966c8a28575242a339aaaf0e8870fe4a221986133b381c3c0", "cacheKeys": { - "wasm32": "d5c3a05d1aee35d8c8d5e86b57d039a7f06a47a8e55afb3d047ba69f31c8ae92", - "wasm64": "7de9a225b30fb7a0953c50b5bc333e70ddf7da7367848df02ca5f399c22f18a5" + "wasm32": "e1092a98c2200f9ca0a1dbd4eba7cfc78518a37863ca98df71e7265a273c4227", + "wasm64": "48f3ce16d978011ff7ab08696494c3ba761b8a789e531b2ab6a3747dcc099dea" } }, "unzip": { "manifestSha256": "ef4e5e34258827ab3cbc1930da3fd9514f08f2d7d56c7c3e0d5c495a4d3a20e9", "cacheKeys": { - "wasm32": "5e292a602b1b645bdf8c4582fb3b3f1b323ef8d2f3642bd74a9c033f21d04fd4", - "wasm64": "5d956eb94a073ecf79fd330c283883dea278ee77a66eaa55467e46295a51c798" + "wasm32": "ec8dad55b6005ca98236e500622ec77ca0a8d44693df24eb66f5f6c62b706dd6", + "wasm64": "e06df476bae63f16755a73e519eee223c4002ae497bc82680864ec672b599792" } }, "userspace": { "manifestSha256": "221176f2a096dee19bbefd89da5c7f50138ecd92d37ec9d7d6b35dbb25e86924", "cacheKeys": { - "wasm32": "922bf7641c67c4521a684c39b27332329fc113b173c2338ee5007457d3e552a3", - "wasm64": "eaf6e71447b343c7b978a77f4c83afd45b6bc3e1d1fd6feebbf7e6c3b64063bc" + "wasm32": "0cfdb897357a4d55c885579252cdaa8c456d4eab84ad56b439e7cd0537324ec1", + "wasm64": "00ce2220bd1099e6010b33c3e8c15c704caa375d37dd17dab67606b2f11265e6" } }, "vim": { "manifestSha256": "c211660ef41d01f95f3fbdf675b74891e897e3f304e04f1bf5586f326007382c", "cacheKeys": { - "wasm32": "94bc46ef11607b637182ec2d8d7612321768db384c4b955889dee573e65fab61", - "wasm64": "0e31d190fc4d43af81a12bc887701e2f2dbf9b75eed8b6ee0a9a8ff3e554dc74" + "wasm32": "5b84470427b17fc94f6e4803bef63d23fe317fc5d3af73c035b642ee48fbffdc", + "wasm64": "01a192db98343fb5a81798ef83fd0b1e27830024064d9b78b44c58aa484925ca" } }, "vim-browser-bundle": { "manifestSha256": "e31d84057db49c3534381604d0c8f3c7d765e33ede560a7ea7b01a5a8f1aa0d3", "cacheKeys": { - "wasm32": "bbfc2756d6d5ac700fbfadd2743b22b041f141b10ce78de366e1bb58c3ecee01", - "wasm64": "e761bd455ba69db80317de10b344bab0b5b53e283065b326528023c8fc0778ff" + "wasm32": "abd41342ceaf4212159653327c03680599b9bd53fdd4477c57c0131c7dc1bbd3", + "wasm64": "926bc8bac763b1e58d259dd5caba0d432083be21f92a8cbcb6c822ee583553c8" } }, "wget": { "manifestSha256": "61420b6cb1be12471f425b0aebf5aec58c49d0b43d939dd54508b305accbf54e", "cacheKeys": { - "wasm32": "e3ba55fc9ecb1094c18f0ca40c9fb6767785201bfebbb64d02e2efffdce5bed2", - "wasm64": "42c994cc3c9fd7bb213133ed45827642c870e6c71960e1bda8874f526d0a3e1f" + "wasm32": "793da9e9a98944ecfffefdfbbaf66eb2437bce78224b3b723c382d46f38fce89", + "wasm64": "4c91ffe8ce0283722bf0f8bf2fd2b20053f8e7c47cf59bd4c6b631d9e055ed11" } }, "wordpress": { "manifestSha256": "36465e0596a06e855a8524eecfd87da98643bc13b37ee12939f5aab44bf33418", "cacheKeys": { - "wasm32": "46a808b1ec1670f2611cd69d0ae1b27a6990c8e79a5b5c4b67b8fb35c26d4571", - "wasm64": "6ca16ac43b2c5317666a396133f97b6af100a79651f3d1312a89af7b41205dad" + "wasm32": "c5cc2988d67af648504715c20b76b299992ed6eca495c112e52a4b3f655ed4ab", + "wasm64": "7d4ba0390545775a17c239d18458954c45936002f4dac9914b609922c4f4d188" } }, "xz": { "manifestSha256": "8707d35b8647b1af8fa165dcb6ce7b2a6a007e19ab2daca2680c39395864a737", "cacheKeys": { - "wasm32": "16efcc1048e122ed6098b46edb053dc5ee50ac5caaeba9e93b767df224e75921", - "wasm64": "9f0fd11f06c53a28d35add458916a300dbebd845972fadf434097cf0ca5bf33d" + "wasm32": "e671549aa3e6fbf95c8d0a3ab365700c7354bf4e4faf4ffd6b0af2fa62485830", + "wasm64": "6de5a6b3fc48e18bc9b74d32200b9ae1bc6c8aea5a80bf7a38c7c00f9a5cc78d" } }, "zip": { "manifestSha256": "9c9cfa8220aaeaea26736b55f7cd0a7517b8853c07f9f0d241ad67dd43592e36", "cacheKeys": { - "wasm32": "307cb35847ff802b569012e26c3f42b308ab6dea07c62ffe6428d886f3c81189", - "wasm64": "eeab81ad2f0cfa1c8d0fe5a6218d14e2d4f2dc437d5a12e3ac994489ded2179d" + "wasm32": "b188c0bf07f2c79821cd474d934c36bcc2a092512dddca33bc3cbe6d68832b5b", + "wasm64": "cb629a22496c4e1fd12bb754078a2c997eefc4a57bcaca27ba4fbe4d788c414f" } }, "zlib": { "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", "cacheKeys": { - "wasm32": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966", - "wasm64": "4f92cfedd1f97a89c34ca0dca461851c8bdeb2da6395e27c4ee9762a97f185cd" + "wasm32": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e", + "wasm64": "0b402b1805b4b8a460b96a14f93e8c8eda7f7cf3afd1f94e5eb1efa423f953fc" } }, "zstd": { "manifestSha256": "89f266938c2c253700a838e6352c9de66c976c84883325aaa979f72b732357e4", "cacheKeys": { - "wasm32": "013d3afc8e3ea69b7b88d930a8c845860f060b1b50ae80c66206871e8917c20b", - "wasm64": "a11e20fe3f3dee6c4a538f689f4df5c39e6b753500952700f139e3696c9c97c2" + "wasm32": "bf9867e234e6d73257a275e30917058094135eb4f90c13352df67504acee097c", + "wasm64": "2558590ae68f08390876c524cf98a59694df6bef3b75115b211a85fd53ab8b53" } } }, @@ -583,14 +583,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f07a60d864434909721977f82b8a3467b54e32dc42e358b530e8ded261da0538" + "wasm32": "2d577fa298afea12b9245e372d0e8712bb166f5d759f612f86c7b658367268ff" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "b12820cd19b19d2999c58fbcf2afaeafacf7cc897268558936aea2b76d4a5182" + "cacheKey": "0fa705045e9b3026abaf8e26a8e3c38d5486a4ad9a967752458768a442e32e0f" } ] }, @@ -610,7 +610,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "408e5b33718c929736bc0a5c697be47fc7e39ab9874b3c4b4344b99fdbd06858" + "wasm32": "f95f8a79fd939755345af1acccdec0b21f82def30156d8bc1bfeca2ee9f4b57b" }, "dependencyClosures": { "wasm32": [] @@ -631,7 +631,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "fb25499f183169e8db67b97587baf931d5b02e34b4603a17c11e044247f2970f" + "wasm32": "cdb8b3ef6ef84d9b962df3cad7f0c09ddd146e8a1e1fa873b31ba09265fdfcd3" }, "dependencyClosures": { "wasm32": [] @@ -652,7 +652,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "cce36c322c0b3920569d5a2c270ed073aba92ceb684af714b497a805a25c4b94" + "wasm32": "9d4a7186e54db60edf09a2aba993d4f1d4660dcd37f5f280581941d09e4fb054" }, "dependencyClosures": { "wasm32": [] @@ -673,14 +673,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "5cc4364991db0475c376fe9b665b98f028b193080389ca3bfff6571050f5740c" + "wasm32": "2b100dd2bfbb68d67c935fa9de93e1e82721f50d2040a7a1d22cfa2656053884" }, "dependencyClosures": { "wasm32": [ { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966" + "cacheKey": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e" } ] }, @@ -707,19 +707,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "08caaa4fa5611ec4f8fadbd17172305a1efda6e5861f87430ae0c56b0ed89863" + "wasm32": "18890ef7352b7881c2c0880b032e15166dbbe18b24bd3082bda7728755451528" }, "dependencyClosures": { "wasm32": [ { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "8645e6be336d82ed97665c93c9122f4046869d0495f464b9c89b3157426b39e7" + "cacheKey": "36d99a823cd97e42e1bfd1aefa9b4a0bb79b7879eef2faf1dd9e932445b21d47" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966" + "cacheKey": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e" } ] }, @@ -739,7 +739,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "9c286ef92425fea87cd0c23f3ebacad335791389bc0fe8cd00572fac8c2b3dca" + "wasm32": "ed8a5db2cd63484a368e0181b72f177d072a64bf4b053f62fae128bb5e8915d3" }, "dependencyClosures": { "wasm32": [] @@ -760,7 +760,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "febb5cd14a0493af2060c4dab4ea9316fb814f5861c6af3e31fd19f9c5d99678" + "wasm32": "247e25a24952124aa930b6b0bb5092fa2c74653e52bb2e928a049ebf109ff9fb" }, "dependencyClosures": { "wasm32": [] @@ -802,14 +802,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "81dcb6d5f937d5cca3481492df72cb19bf9f8c84973ef6093dde0e149ba42daa" + "wasm32": "0cfd3dca3b9499921d565e03a274c5ebbaff265e6c05e98aa8ef889d2140ccd2" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" + "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" } ] }, @@ -843,7 +843,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "86516154326e2765f9ac8d232c4be618191de0a3757891ba284d826622b84d19" + "wasm32": "ee489dc49dce5553b05b5eed8d5e56943dece8dcd8bed0fa01e39dc95c63faaa" }, "dependencyClosures": { "wasm32": [] @@ -871,14 +871,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "621bcdd99ed2660c5daed2981241676bf3befde53bda53e0f4213bb27e2e4c17" + "wasm32": "e9fbd981a01570739a315fd8a4c8177eec4d0c381e0080b4d950157cef6e6de2" }, "dependencyClosures": { "wasm32": [ { "packageName": "erlang", "manifestSha256": "475d6037e73a0f1e2f4381067194b2422751206979ea17209e10efa5a2a93bbb", - "cacheKey": "86516154326e2765f9ac8d232c4be618191de0a3757891ba284d826622b84d19" + "cacheKey": "ee489dc49dce5553b05b5eed8d5e56943dece8dcd8bed0fa01e39dc95c63faaa" } ] }, @@ -898,7 +898,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2bf2226875f72a078553c3ec6dc6c51bc59ff73aa010937c4b39b679fee48422" + "wasm32": "a19efa9f3f880a2f7582794fd6c080d18f0f52cebfe7380713096fdb5e0268ef" }, "dependencyClosures": { "wasm32": [] @@ -919,7 +919,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "954c87e26e61e45fda6aa1953de7738da41760d7e6a15c153e7b0ee5a6510e18" + "wasm32": "4677df78cfe25250903279ae481372f6985aa54a9f85682499d0ed2a94408eaa" }, "dependencyClosures": { "wasm32": [] @@ -947,7 +947,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "1927dd4585688c78835726fbc504184b52af1901b4da0baf60485cae3ab18c9b" + "wasm32": "adcd350e6f8977e86330c59b57f52b11524143c02dcd4a18a2447e5f635c88e7" }, "dependencyClosures": { "wasm32": [] @@ -975,7 +975,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ef897546ac91a4c6879281455689ad9a7d6cc2acb50c3dd356dc6fb5df30d380" + "wasm32": "915c98a7739cbeefb8b4ed326e62effaf0c376c0d187141254527c08eae134e3" }, "dependencyClosures": { "wasm32": [] @@ -996,7 +996,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "91dc732cb54b0ae02135f60f815d728a9a52e5d3efe470d51199b498c3aa49e4" + "wasm32": "44e9c599cc26e9a485303555de6911f9ef46c7e4d8eeeb049d72e9bf9bb22c79" }, "dependencyClosures": { "wasm32": [] @@ -1024,7 +1024,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c1c9f271d06c271dd1e85f008f986b835d0ffd40a64537da54dfda1ce9f3b824" + "wasm32": "a0c03526e72d0581fde4e5ef55a4e5f38a8047e2d93501aa56db6aefff6a33ba" }, "dependencyClosures": { "wasm32": [] @@ -1045,7 +1045,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0367e5e2485ddaf8a30fd43d95584271ae2d7096d4935de8727b65ace3f9105d" + "wasm32": "0760ed1ee2e04a89dc5e47ad9134d50d981e08035c624856c0e0f0248727c4f9" }, "dependencyClosures": { "wasm32": [] @@ -1066,7 +1066,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "bf4a420c5bde02b90ce908972fce7b7da51a58ca2604224e89ce64900293b572" + "wasm32": "9a4810e7dc1daf506b1b74873cfa601dfe5aeb0d868b39c8279ebcadc04b0ccb" }, "dependencyClosures": { "wasm32": [] @@ -1094,14 +1094,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "462a0eab5fc9741b99b0ab4b4e07e2bdfc7ccd78e6a84463c9c28c15b94f389b" + "wasm32": "0c81181a836e1262ded47966f272a53b9e54f92ecc0b514d6b27f087f0152b94" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" + "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" } ] }, @@ -1121,64 +1121,64 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c29e679e6caf9c2f50d12df47f080b21cd7f0f223e4150222f6bf5877ccb7129" + "wasm32": "49bebcac721dcc56c84213c2f3dba851ccdde69183b10be3356293c926180581" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "81dcb6d5f937d5cca3481492df72cb19bf9f8c84973ef6093dde0e149ba42daa" + "cacheKey": "0cfd3dca3b9499921d565e03a274c5ebbaff265e6c05e98aa8ef889d2140ccd2" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "2785cf0bf052747d2ade36d90fe0ddb40a058f305d78e845ca5bb25dc553cf5d" + "cacheKey": "3fd9598c6138054150f5d457a0b0f62d44ab0920c2a5ea1419bbbd16f2cfd4ec" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "797282f3a1e94fb74c16c1fe0924d478eceb9fc9ae23cdefa40d675a76616ff7" + "cacheKey": "65727e0e1a26b1faaea4e0f0fbddce5e5a44012df69dcda1bb7e3e8626487268" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" + "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "918054f8e258e000a8f3ac9ef70870d7a0cb1153ecb6e0ac5b99b6152abae905" + "cacheKey": "6aa4acaad6784b9152ec957d4e2e8da31db5c0b610414110f1ddfc5623772f71" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "72264a21723fe72302c5e1b5205aad7117476ef656f54538e98a66c70219be8d" + "cacheKey": "c11983c6357c1b77a011aaeafde22882bdaa777c65c40dd99d4c48777acf23c0" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "01eec0bd3a1139f3baea193789492b632875c70d38aaf0be16794e43d0874245" + "cacheKey": "2b8c3acda63a9e373a63ac32b693eb0a3b7d1f01d19dc114a71ec88c5c0f2168" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "8f70905c8e42c2fcba97985f98ca1754ef32b0045daa638cc51410e90debb876" + "cacheKey": "ab24370d6f159ba9c14e964ff902c1182a303d29dd51639659c6938dfdfc45e5" }, { "packageName": "msmtpd", "manifestSha256": "09de04a422ddf631a29a259d816e081f8d317d1077808ede55d8c500baae748f", - "cacheKey": "cde71f771c392d62a09b6227c18c4c4ba5ede3605a085c57289966f5734e09d3" + "cacheKey": "e52466139aaf8c185e8f8983b548dac2413ea241af093f7c854ee99325999176" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "a678a30cf48177514b258ae186bc92282a3f564327629060f9e75f19443b75a2" + "cacheKey": "9e932bd478a344cdfdb68da0f300f147b2d9c60bb385cf1ffe7906e315d357a8" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "8645e6be336d82ed97665c93c9122f4046869d0495f464b9c89b3157426b39e7" + "cacheKey": "36d99a823cd97e42e1bfd1aefa9b4a0bb79b7879eef2faf1dd9e932445b21d47" }, { "packageName": "pcre2-source", @@ -1188,22 +1188,22 @@ { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "0e623672a33a21b893c7f44854e7215bdda530a9e66a87dd9d91602e123d1d89" + "cacheKey": "635e5ed39400e0333d51d16278634afc56c8c17e56be858498bff4886c93fee5" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "1fe39b11e701eeddeba9be5dcc7521f51df1b889fb7eb2ba35098f9a071dcc97" + "cacheKey": "96e587a94acbf662c70c87ec7e265b61d313b4816101ad0a41ec8e5b94498c5f" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "fa91f92503bb45a1c89a925780c6b43f4eeb50c9fda686c15c2357e50d448e29" + "cacheKey": "73402dbeec78ac534e0ae3f472f887982200c5c9867e4dc8d64d30d760eeaa7c" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966" + "cacheKey": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e" } ] }, @@ -1223,7 +1223,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "9bb5323df1cb2ef84cf27efc789566560a79649bd3deb00be219131ee2ea8911" + "wasm32": "26a936bd1d8d5817eae86a8e5680890c0c741be7fa88f0b92035552c28f18b0e" }, "dependencyClosures": { "wasm32": [] @@ -1244,7 +1244,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "539fe25b0bfe42849aed4bb1203fef9924225f243196185c663c70ecdc9c0593" + "wasm32": "909d4418866795cd511ebaa25cd751c7e1075657e24c334c859397cb6366e7d5" }, "dependencyClosures": { "wasm32": [] @@ -1265,7 +1265,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "3da16454c105fff20c5e2cb8fef979e3a1a6166aa6e8eaa02a1bbef4b879f91f" + "wasm32": "b887bbc00ed62a5228e955593dcc4a31d98e8fe71972d59971e32bd76b7ad536" }, "dependencyClosures": { "wasm32": [] @@ -1286,7 +1286,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0c45cd8ffb70f1a4615b8c0b7a0cff5acbe2645ffb795be91642bb510d070822" + "wasm32": "de30b90416e0a0d62ada5726860e8e7cefe891eb5ab51d8b26f18f50e0e06fe5" }, "dependencyClosures": { "wasm32": [] @@ -1308,15 +1308,15 @@ "wasm64" ], "cacheKeys": { - "wasm32": "8f70905c8e42c2fcba97985f98ca1754ef32b0045daa638cc51410e90debb876", - "wasm64": "57287289e381c0e77d76a2bab7825b897a736f7b51fa0c41ff98dbe2047e9d53" + "wasm32": "ab24370d6f159ba9c14e964ff902c1182a303d29dd51639659c6938dfdfc45e5", + "wasm64": "43cfb64022b7de7df2e7b95760a9f4cc8808ce59449d388eda35d6f79a6869b9" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" + "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" }, { "packageName": "pcre2-source", @@ -1328,7 +1328,7 @@ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "fd3503736c73dcbe633dca3304e10e72ad4dfb9c8597f301a064180a3e193e91" + "cacheKey": "b65406cc252ff13d6049ae52461196e07423734583b162422829821838609693" }, { "packageName": "pcre2-source", @@ -1360,34 +1360,34 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c6ea058fadd400811a3278f8fd3e5c457e65c321784894ecd1186169d4bf4ef9" + "wasm32": "08446236c220b97848a9444d26251cecc730bb916f8457021df56a4046e1c503" }, "dependencyClosures": { "wasm32": [ { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "cce36c322c0b3920569d5a2c270ed073aba92ceb684af714b497a805a25c4b94" + "cacheKey": "9d4a7186e54db60edf09a2aba993d4f1d4660dcd37f5f280581941d09e4fb054" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "9c286ef92425fea87cd0c23f3ebacad335791389bc0fe8cd00572fac8c2b3dca" + "cacheKey": "ed8a5db2cd63484a368e0181b72f177d072a64bf4b053f62fae128bb5e8915d3" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "81dcb6d5f937d5cca3481492df72cb19bf9f8c84973ef6093dde0e149ba42daa" + "cacheKey": "0cfd3dca3b9499921d565e03a274c5ebbaff265e6c05e98aa8ef889d2140ccd2" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" + "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "8f70905c8e42c2fcba97985f98ca1754ef32b0045daa638cc51410e90debb876" + "cacheKey": "ab24370d6f159ba9c14e964ff902c1182a303d29dd51639659c6938dfdfc45e5" }, { "packageName": "pcre2-source", @@ -1413,35 +1413,35 @@ "wasm64" ], "cacheKeys": { - "wasm32": "5d73bc2fddc13270b98c9bd446a871c944d20b64222b1ce1f7f72d004625916c", - "wasm64": "838bcf9a59b106bb02221d0aae8c53730d98e5dc24661fcb080059345c99cf7a" + "wasm32": "91aff7b9e519b98353523db54a4e0ce139a8f6b37df64b07d53a9b1132afbc3c", + "wasm64": "e584bd74f7e9f6cf1b5fc5d83184afbea7e2e1afba4de58d8b08208e9176fa6f" }, "dependencyClosures": { "wasm32": [ { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "cce36c322c0b3920569d5a2c270ed073aba92ceb684af714b497a805a25c4b94" + "cacheKey": "9d4a7186e54db60edf09a2aba993d4f1d4660dcd37f5f280581941d09e4fb054" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "9c286ef92425fea87cd0c23f3ebacad335791389bc0fe8cd00572fac8c2b3dca" + "cacheKey": "ed8a5db2cd63484a368e0181b72f177d072a64bf4b053f62fae128bb5e8915d3" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "81dcb6d5f937d5cca3481492df72cb19bf9f8c84973ef6093dde0e149ba42daa" + "cacheKey": "0cfd3dca3b9499921d565e03a274c5ebbaff265e6c05e98aa8ef889d2140ccd2" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" + "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "8f70905c8e42c2fcba97985f98ca1754ef32b0045daa638cc51410e90debb876" + "cacheKey": "ab24370d6f159ba9c14e964ff902c1182a303d29dd51639659c6938dfdfc45e5" }, { "packageName": "pcre2-source", @@ -1453,27 +1453,27 @@ { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "7878a840c190fe2dc7666fd459253dd9becb32c2a49ae96ecd1a81976a67e1ad" + "cacheKey": "d9232813053ca651f5660a2360e15f019ee2b428e8982ced50194ff3bb263191" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "4899a2545851c9cc17d489308f64f99aa66652349a3caebf1ef1532984579d29" + "cacheKey": "f9ce165a14ec14b4bdbc81a5fd1238f415d365beb1efa51a55e435b99d807a60" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "b201d49a3d0d5ce1203443a382281a885cafdaf52305b5c44c4c1a87e392fb5d" + "cacheKey": "e64d25c0a8a6aa5a8032094c312432783dfaaf60846c27cb0077637138429320" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "fd3503736c73dcbe633dca3304e10e72ad4dfb9c8597f301a064180a3e193e91" + "cacheKey": "b65406cc252ff13d6049ae52461196e07423734583b162422829821838609693" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "57287289e381c0e77d76a2bab7825b897a736f7b51fa0c41ff98dbe2047e9d53" + "cacheKey": "43cfb64022b7de7df2e7b95760a9f4cc8808ce59449d388eda35d6f79a6869b9" }, { "packageName": "pcre2-source", @@ -1498,7 +1498,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c1bca0979d20f7414d74615869904854fec454a3ccf1c08035c30135ba8d503b" + "wasm32": "cb77410ec2dad89f43c7ceef9c33edf3c1ec39a5430b9327a70136349794350f" }, "dependencyClosures": { "wasm32": [] @@ -1519,7 +1519,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "cde71f771c392d62a09b6227c18c4c4ba5ede3605a085c57289966f5734e09d3" + "wasm32": "e52466139aaf8c185e8f8983b548dac2413ea241af093f7c854ee99325999176" }, "dependencyClosures": { "wasm32": [] @@ -1540,7 +1540,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "245ebf3255ccf561998dffcdc1be5b6cc584c6db353279fac0ac3ff5db449118" + "wasm32": "43267a08d7d10ce343f1ac2354ad9e3cf2193931b1f3dec522353ac9b0e93323" }, "dependencyClosures": { "wasm32": [] @@ -1561,7 +1561,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b12820cd19b19d2999c58fbcf2afaeafacf7cc897268558936aea2b76d4a5182" + "wasm32": "0fa705045e9b3026abaf8e26a8e3c38d5486a4ad9a967752458768a442e32e0f" }, "dependencyClosures": { "wasm32": [] @@ -1645,7 +1645,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "8f6a38fe2dcf6645ce394283228cc6c1274a74d0711304b2b480ae5eee6fc38e" + "wasm32": "b98fe24204f370fb495b84b0241db465ad666e470b2caa081a8b7b453b2c971a" }, "dependencyClosures": { "wasm32": [] @@ -1666,14 +1666,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "37063f8f247d962d17573762cf5f2b8cc285aeed905fd9aeb75a9ed518502f95" + "wasm32": "df15777c107fbc3302c8ef14d38b7cea8081eb74b0928e38530deef188b0170d" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "b12820cd19b19d2999c58fbcf2afaeafacf7cc897268558936aea2b76d4a5182" + "cacheKey": "0fa705045e9b3026abaf8e26a8e3c38d5486a4ad9a967752458768a442e32e0f" } ] }, @@ -1693,19 +1693,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "21d8c4f96a174ea9dae1ef8b55d6c43762d3c511074501c8f12ff8ab3aa6c85d" + "wasm32": "eb8580b1a03a8f9575477c2618419e3ec1df34880c26eba312ec7d7f066931d1" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "b12820cd19b19d2999c58fbcf2afaeafacf7cc897268558936aea2b76d4a5182" + "cacheKey": "0fa705045e9b3026abaf8e26a8e3c38d5486a4ad9a967752458768a442e32e0f" }, { "packageName": "nethack", "manifestSha256": "1a2f12ec2770bd8d40006d6c878a3493b97c71c2bb63ad08c7f6ad99bfd53504", - "cacheKey": "37063f8f247d962d17573762cf5f2b8cc285aeed905fd9aeb75a9ed518502f95" + "cacheKey": "df15777c107fbc3302c8ef14d38b7cea8081eb74b0928e38530deef188b0170d" } ] }, @@ -1725,7 +1725,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a678a30cf48177514b258ae186bc92282a3f564327629060f9e75f19443b75a2" + "wasm32": "9e932bd478a344cdfdb68da0f300f147b2d9c60bb385cf1ffe7906e315d357a8" }, "dependencyClosures": { "wasm32": [] @@ -1746,79 +1746,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "7e17179fb8a3b3264f944ca62a6418af19387bbfa879ffd87bdcc6b75e7c13a8" + "wasm32": "d923e282640dc6b68f226f68f89a147eda0aa775bd6899246bccea534a5be3be" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "81dcb6d5f937d5cca3481492df72cb19bf9f8c84973ef6093dde0e149ba42daa" + "cacheKey": "0cfd3dca3b9499921d565e03a274c5ebbaff265e6c05e98aa8ef889d2140ccd2" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "2785cf0bf052747d2ade36d90fe0ddb40a058f305d78e845ca5bb25dc553cf5d" + "cacheKey": "3fd9598c6138054150f5d457a0b0f62d44ab0920c2a5ea1419bbbd16f2cfd4ec" }, { "packageName": "kernel", "manifestSha256": "db1d66db8575562ac7b3e720dbaa06d7dd5eef577d80220ea4167dc2d69b863b", - "cacheKey": "0645f1c9514cdc63e297c4c66111011d462165cf134bcadeabf31f54324557d4" + "cacheKey": "2f8f321f40b81120644d66fd1c5ed1e949be708781c568e14b62d7171e9f9f60" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "797282f3a1e94fb74c16c1fe0924d478eceb9fc9ae23cdefa40d675a76616ff7" + "cacheKey": "65727e0e1a26b1faaea4e0f0fbddce5e5a44012df69dcda1bb7e3e8626487268" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" + "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "918054f8e258e000a8f3ac9ef70870d7a0cb1153ecb6e0ac5b99b6152abae905" + "cacheKey": "6aa4acaad6784b9152ec957d4e2e8da31db5c0b610414110f1ddfc5623772f71" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "72264a21723fe72302c5e1b5205aad7117476ef656f54538e98a66c70219be8d" + "cacheKey": "c11983c6357c1b77a011aaeafde22882bdaa777c65c40dd99d4c48777acf23c0" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "01eec0bd3a1139f3baea193789492b632875c70d38aaf0be16794e43d0874245" + "cacheKey": "2b8c3acda63a9e373a63ac32b693eb0a3b7d1f01d19dc114a71ec88c5c0f2168" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "a678a30cf48177514b258ae186bc92282a3f564327629060f9e75f19443b75a2" + "cacheKey": "9e932bd478a344cdfdb68da0f300f147b2d9c60bb385cf1ffe7906e315d357a8" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "8645e6be336d82ed97665c93c9122f4046869d0495f464b9c89b3157426b39e7" + "cacheKey": "36d99a823cd97e42e1bfd1aefa9b4a0bb79b7879eef2faf1dd9e932445b21d47" }, { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "0e623672a33a21b893c7f44854e7215bdda530a9e66a87dd9d91602e123d1d89" + "cacheKey": "635e5ed39400e0333d51d16278634afc56c8c17e56be858498bff4886c93fee5" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "1fe39b11e701eeddeba9be5dcc7521f51df1b889fb7eb2ba35098f9a071dcc97" + "cacheKey": "96e587a94acbf662c70c87ec7e265b61d313b4816101ad0a41ec8e5b94498c5f" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "fa91f92503bb45a1c89a925780c6b43f4eeb50c9fda686c15c2357e50d448e29" + "cacheKey": "73402dbeec78ac534e0ae3f472f887982200c5c9867e4dc8d64d30d760eeaa7c" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966" + "cacheKey": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e" } ] }, @@ -1838,29 +1838,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e70252d6f9dc8738b944236168f343f87aea2ac1684f21b44d8f7260087c37ba" + "wasm32": "403a4321700039225a1224421293a0aeaa6c7ad6456759a3399e5d0a4c6a534c" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "81dcb6d5f937d5cca3481492df72cb19bf9f8c84973ef6093dde0e149ba42daa" + "cacheKey": "0cfd3dca3b9499921d565e03a274c5ebbaff265e6c05e98aa8ef889d2140ccd2" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" + "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "a678a30cf48177514b258ae186bc92282a3f564327629060f9e75f19443b75a2" + "cacheKey": "9e932bd478a344cdfdb68da0f300f147b2d9c60bb385cf1ffe7906e315d357a8" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "1fe39b11e701eeddeba9be5dcc7521f51df1b889fb7eb2ba35098f9a071dcc97" + "cacheKey": "96e587a94acbf662c70c87ec7e265b61d313b4816101ad0a41ec8e5b94498c5f" } ] }, @@ -1880,29 +1880,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "9ebb636f0ebc5111d9cc16c4194eab9ab54ee537a777bd772b4ae4c0ece7d24d" + "wasm32": "1ade84c0949d68410604abeecc1372019d5cb5be36257a1ea019e8d3eac8554f" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" + "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "8645e6be336d82ed97665c93c9122f4046869d0495f464b9c89b3157426b39e7" + "cacheKey": "36d99a823cd97e42e1bfd1aefa9b4a0bb79b7879eef2faf1dd9e932445b21d47" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "87b26b326af7e0b7c9fa6f9241cfad32900a995883615d3b1bfff22d18d2f0e5" + "cacheKey": "f01d6d631bafb7a31b034ec33970b67778a7ff9e4af3089685ccb941ea2fce18" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966" + "cacheKey": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e" } ] }, @@ -1922,39 +1922,39 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a23dc8ce824ad822e0f2491691fb0a9872ab3842e1eac0721d89f766edc4f603" + "wasm32": "401cb759b750c5ccc6bcfec5ff04500c270718b2345e604630b3fa2a9769f0c6" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" + "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" }, { "packageName": "node", "manifestSha256": "2131a24dfda8587860e57fc65b573086477d376b065b3b9ca4e1dfe4d79f5790", - "cacheKey": "9ebb636f0ebc5111d9cc16c4194eab9ab54ee537a777bd772b4ae4c0ece7d24d" + "cacheKey": "1ade84c0949d68410604abeecc1372019d5cb5be36257a1ea019e8d3eac8554f" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "8645e6be336d82ed97665c93c9122f4046869d0495f464b9c89b3157426b39e7" + "cacheKey": "36d99a823cd97e42e1bfd1aefa9b4a0bb79b7879eef2faf1dd9e932445b21d47" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "1fe39b11e701eeddeba9be5dcc7521f51df1b889fb7eb2ba35098f9a071dcc97" + "cacheKey": "96e587a94acbf662c70c87ec7e265b61d313b4816101ad0a41ec8e5b94498c5f" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "87b26b326af7e0b7c9fa6f9241cfad32900a995883615d3b1bfff22d18d2f0e5" + "cacheKey": "f01d6d631bafb7a31b034ec33970b67778a7ff9e4af3089685ccb941ea2fce18" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966" + "cacheKey": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e" } ] }, @@ -1974,7 +1974,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c8cf2040ac50eaf4de04912a3c4fc437004a54358c97bc2f8e2b4df40544ab01" + "wasm32": "3fe8bfa8774d6b5f5c2b80013c6495d152b689b52d42cd78f0a394a212c782c8" }, "dependencyClosures": { "wasm32": [] @@ -1995,14 +1995,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "1600ccb83a5d813b97cd998fe6be47fa67dc9d7fe4eefca8198d9a85d8c6bb2e" + "wasm32": "5ba81a4073afa11a913a2cb53865af56cd58731d9c262240fea425e5fe63ddc2" }, "dependencyClosures": { "wasm32": [ { "packageName": "perl", "manifestSha256": "6cdc4dbc54d0e4008cff41f82ec8918c0e44b4aef23903539c1bc0f538fe3ad2", - "cacheKey": "c8cf2040ac50eaf4de04912a3c4fc437004a54358c97bc2f8e2b4df40544ab01" + "cacheKey": "3fe8bfa8774d6b5f5c2b80013c6495d152b689b52d42cd78f0a394a212c782c8" } ] }, @@ -2022,54 +2022,54 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0e623672a33a21b893c7f44854e7215bdda530a9e66a87dd9d91602e123d1d89" + "wasm32": "635e5ed39400e0333d51d16278634afc56c8c17e56be858498bff4886c93fee5" }, "dependencyClosures": { "wasm32": [ { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "2785cf0bf052747d2ade36d90fe0ddb40a058f305d78e845ca5bb25dc553cf5d" + "cacheKey": "3fd9598c6138054150f5d457a0b0f62d44ab0920c2a5ea1419bbbd16f2cfd4ec" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "797282f3a1e94fb74c16c1fe0924d478eceb9fc9ae23cdefa40d675a76616ff7" + "cacheKey": "65727e0e1a26b1faaea4e0f0fbddce5e5a44012df69dcda1bb7e3e8626487268" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" + "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "918054f8e258e000a8f3ac9ef70870d7a0cb1153ecb6e0ac5b99b6152abae905" + "cacheKey": "6aa4acaad6784b9152ec957d4e2e8da31db5c0b610414110f1ddfc5623772f71" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "72264a21723fe72302c5e1b5205aad7117476ef656f54538e98a66c70219be8d" + "cacheKey": "c11983c6357c1b77a011aaeafde22882bdaa777c65c40dd99d4c48777acf23c0" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "01eec0bd3a1139f3baea193789492b632875c70d38aaf0be16794e43d0874245" + "cacheKey": "2b8c3acda63a9e373a63ac32b693eb0a3b7d1f01d19dc114a71ec88c5c0f2168" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "8645e6be336d82ed97665c93c9122f4046869d0495f464b9c89b3157426b39e7" + "cacheKey": "36d99a823cd97e42e1bfd1aefa9b4a0bb79b7879eef2faf1dd9e932445b21d47" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "fa91f92503bb45a1c89a925780c6b43f4eeb50c9fda686c15c2357e50d448e29" + "cacheKey": "73402dbeec78ac534e0ae3f472f887982200c5c9867e4dc8d64d30d760eeaa7c" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966" + "cacheKey": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e" } ] }, @@ -2145,7 +2145,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e41f65aaebc190dcee228d1ea98c2457067fe7d663600295edd3c4dba5fa7fe9" + "wasm32": "dc7e6124ef4a64df36ade1ea208b7e2fd1f0929773482f92e93f7063eeeac5fb" }, "dependencyClosures": { "wasm32": [] @@ -2418,19 +2418,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a2c3bbc0db8e3b1e219839757b46be850e7e31c5e8b0ffa75ef1d217516887fa" + "wasm32": "97185cb95c82b5abc19b794210e309a128a07648dde2a7efbbb2e79a9d7da16d" }, "dependencyClosures": { "wasm32": [ { "packageName": "cpython", "manifestSha256": "7dd4f446697a73941ec940c2ccba4d53be73fe6947f1e701031a4d0aa964c4ff", - "cacheKey": "5cc4364991db0475c376fe9b665b98f028b193080389ca3bfff6571050f5740c" + "cacheKey": "2b100dd2bfbb68d67c935fa9de93e1e82721f50d2040a7a1d22cfa2656053884" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966" + "cacheKey": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e" } ] }, @@ -2450,7 +2450,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "68c241d9052b09d4f4f59f8632f504b723c15d2fd2141f8f13996890423c3a34" + "wasm32": "de47bd67078bc531a4218667ed729408d85fcc26a6b36b144c65e0303c54f34d" }, "dependencyClosures": { "wasm32": [] @@ -2478,24 +2478,24 @@ "wasm32" ], "cacheKeys": { - "wasm32": "3c5f12bc722762e45ef2f30741e2def167d4797051eaae874df2f455a2ef95f8" + "wasm32": "1d08e1584b3fb853cb2dd2aca185d24077d872f26004e162f26732358b4f5ee2" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "81dcb6d5f937d5cca3481492df72cb19bf9f8c84973ef6093dde0e149ba42daa" + "cacheKey": "0cfd3dca3b9499921d565e03a274c5ebbaff265e6c05e98aa8ef889d2140ccd2" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" + "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" }, { "packageName": "redis", "manifestSha256": "151fb507de953ba2ba94b8f881bc7d66b4c741c1359a2f590cc07f9a4cd368a3", - "cacheKey": "68c241d9052b09d4f4f59f8632f504b723c15d2fd2141f8f13996890423c3a34" + "cacheKey": "de47bd67078bc531a4218667ed729408d85fcc26a6b36b144c65e0303c54f34d" } ] }, @@ -2515,79 +2515,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "56ae667726be17a2b20e5a5a577d907fb8706f643bfb6703c44248b9de0dd192" + "wasm32": "58dbbd378cd51158808cb319f248416c4b948d03ff73d21fbb2120ec7a9fae22" }, "dependencyClosures": { "wasm32": [ { "packageName": "bash", "manifestSha256": "6478060f28d430d18a6ebe7c603392d35a41d9bcf6bc74322351d1450b9c5335", - "cacheKey": "f07a60d864434909721977f82b8a3467b54e32dc42e358b530e8ded261da0538" + "cacheKey": "2d577fa298afea12b9245e372d0e8712bb166f5d759f612f86c7b658367268ff" }, { "packageName": "bc", "manifestSha256": "a65661463bb7047b91ff00153bd99fd962c96e571934ab4e923f5215fb6ecfbd", - "cacheKey": "408e5b33718c929736bc0a5c697be47fc7e39ab9874b3c4b4344b99fdbd06858" + "cacheKey": "f95f8a79fd939755345af1acccdec0b21f82def30156d8bc1bfeca2ee9f4b57b" }, { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "cce36c322c0b3920569d5a2c270ed073aba92ceb684af714b497a805a25c4b94" + "cacheKey": "9d4a7186e54db60edf09a2aba993d4f1d4660dcd37f5f280581941d09e4fb054" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "9c286ef92425fea87cd0c23f3ebacad335791389bc0fe8cd00572fac8c2b3dca" + "cacheKey": "ed8a5db2cd63484a368e0181b72f177d072a64bf4b053f62fae128bb5e8915d3" }, { "packageName": "diffutils", "manifestSha256": "3a78f0a46bae43ce5ea235c6638c2cbd1d8559b0b62b1fb42443b35968c1aca3", - "cacheKey": "febb5cd14a0493af2060c4dab4ea9316fb814f5861c6af3e31fd19f9c5d99678" + "cacheKey": "247e25a24952124aa930b6b0bb5092fa2c74653e52bb2e928a049ebf109ff9fb" }, { "packageName": "file", "manifestSha256": "7874c1affcbbf2c8ab8c8d4beb087f275af57b5caae6a8947bf5a63a181552b2", - "cacheKey": "954c87e26e61e45fda6aa1953de7738da41760d7e6a15c153e7b0ee5a6510e18" + "cacheKey": "4677df78cfe25250903279ae481372f6985aa54a9f85682499d0ed2a94408eaa" }, { "packageName": "findutils", "manifestSha256": "cceedf52aea67fbb0da03cfce6f2e9b1a7b5561fa1656c2762b43c651a625d02", - "cacheKey": "1927dd4585688c78835726fbc504184b52af1901b4da0baf60485cae3ab18c9b" + "cacheKey": "adcd350e6f8977e86330c59b57f52b11524143c02dcd4a18a2447e5f635c88e7" }, { "packageName": "gawk", "manifestSha256": "a2567b8b0778805e385e1d3a41ac8b5d74aaf9d293b222001b17d09b11e5a0ba", - "cacheKey": "ef897546ac91a4c6879281455689ad9a7d6cc2acb50c3dd356dc6fb5df30d380" + "cacheKey": "915c98a7739cbeefb8b4ed326e62effaf0c376c0d187141254527c08eae134e3" }, { "packageName": "grep", "manifestSha256": "59270d3bfb33167b32d13246bf855b49308f8c9c0a66d9635c804f702421fbc6", - "cacheKey": "c1c9f271d06c271dd1e85f008f986b835d0ffd40a64537da54dfda1ce9f3b824" + "cacheKey": "a0c03526e72d0581fde4e5ef55a4e5f38a8047e2d93501aa56db6aefff6a33ba" }, { "packageName": "m4", "manifestSha256": "2c6582d99d6eabfb9da52badf49b899e9fdcba76a728536b25bc3741f3331543", - "cacheKey": "3da16454c105fff20c5e2cb8fef979e3a1a6166aa6e8eaa02a1bbef4b879f91f" + "cacheKey": "b887bbc00ed62a5228e955593dcc4a31d98e8fe71972d59971e32bd76b7ad536" }, { "packageName": "make", "manifestSha256": "f878d2d730f36a4c6dfe1fff1b4ccc704757ea22d8c4c8ccb95b11cd641e5fed", - "cacheKey": "0c45cd8ffb70f1a4615b8c0b7a0cff5acbe2645ffb795be91642bb510d070822" + "cacheKey": "de30b90416e0a0d62ada5726860e8e7cefe891eb5ab51d8b26f18f50e0e06fe5" }, { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "b12820cd19b19d2999c58fbcf2afaeafacf7cc897268558936aea2b76d4a5182" + "cacheKey": "0fa705045e9b3026abaf8e26a8e3c38d5486a4ad9a967752458768a442e32e0f" }, { "packageName": "posix-utils-lite", "manifestSha256": "8fd7190b2848ef80143adc7b9268c79e13cd4db3430f7105faf49a4b4407a06f", - "cacheKey": "e41f65aaebc190dcee228d1ea98c2457067fe7d663600295edd3c4dba5fa7fe9" + "cacheKey": "dc7e6124ef4a64df36ade1ea208b7e2fd1f0929773482f92e93f7063eeeac5fb" }, { "packageName": "sed", "manifestSha256": "2a08e9c5dacc5facc8983c1ff35ff2a72db328405e9a1f464cc7ad155c4d08af", - "cacheKey": "38d1afd014557aea38dbbcade2478fffffd2d23ec5541defbe48fa3bd5d04991" + "cacheKey": "2daf8e8139e879d4294b143e0c24c89d67eeacf5ff3c45cfaee97b812d58fc44" } ] }, @@ -2607,14 +2607,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "41165bfbfcf42d350f539ac8a20d05f909f617cbc3c31257de01e64a4cbef237" + "wasm32": "8c4c3aadd4a06e761a018c6738e0eed5b3f113e2b648959aa6167511c39b0757" }, "dependencyClosures": { "wasm32": [ { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966" + "cacheKey": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e" } ] }, @@ -2641,19 +2641,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "23ddd4d1743f14e568e25d94947036e4a98a6eef19142bec72b16ba5d631ee17" + "wasm32": "649d96fbae5f45cd1d440f148b9ac042bfe1ff752f6f0e20a67bbaf812d7dce7" }, "dependencyClosures": { "wasm32": [ { "packageName": "sdl2", "manifestSha256": "327ce0acca768f51e36ddab8ab58fd57b24cfd9e40722e818103c439f7263dd7", - "cacheKey": "85121752bf52d980d182b208b2d7f57492fdf7b8e888b43d7caf3a1d771ed478" + "cacheKey": "8a1705083bca64cf5ffa73cd0c90e2dfa52d1eae9b9f872b36b4789bf56cb446" }, { "packageName": "sdl3", "manifestSha256": "3b7c64605b941e14d336b1a127d8219c50402dece009e8855297a0a0696bd67e", - "cacheKey": "fa755e2cfc15d19c47865398b970f3fcd01fe62f0bcac272a5eeab22a40892f1" + "cacheKey": "4302058a87376e2fd2d7e9f2bf1713e8bccf00016f6e17b77ca21169662f897f" } ] }, @@ -2680,14 +2680,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "5230b431f084081e44db0bbd1506f7f84c3822c1e642bb0fa0c289486317817a" + "wasm32": "8942d37250b17a645ef33e37e4920af34f1e9f78cf337fe7a7b49b51e589c76b" }, "dependencyClosures": { "wasm32": [ { "packageName": "sdl2", "manifestSha256": "327ce0acca768f51e36ddab8ab58fd57b24cfd9e40722e818103c439f7263dd7", - "cacheKey": "85121752bf52d980d182b208b2d7f57492fdf7b8e888b43d7caf3a1d771ed478" + "cacheKey": "8a1705083bca64cf5ffa73cd0c90e2dfa52d1eae9b9f872b36b4789bf56cb446" } ] }, @@ -2707,7 +2707,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "38d1afd014557aea38dbbcade2478fffffd2d23ec5541defbe48fa3bd5d04991" + "wasm32": "2daf8e8139e879d4294b143e0c24c89d67eeacf5ff3c45cfaee97b812d58fc44" }, "dependencyClosures": { "wasm32": [] @@ -2728,7 +2728,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "1fe39b11e701eeddeba9be5dcc7521f51df1b889fb7eb2ba35098f9a071dcc97" + "wasm32": "96e587a94acbf662c70c87ec7e265b61d313b4816101ad0a41ec8e5b94498c5f" }, "dependencyClosures": { "wasm32": [] @@ -2749,24 +2749,24 @@ "wasm32" ], "cacheKeys": { - "wasm32": "87b26b326af7e0b7c9fa6f9241cfad32900a995883615d3b1bfff22d18d2f0e5" + "wasm32": "f01d6d631bafb7a31b034ec33970b67778a7ff9e4af3089685ccb941ea2fce18" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" + "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "8645e6be336d82ed97665c93c9122f4046869d0495f464b9c89b3157426b39e7" + "cacheKey": "36d99a823cd97e42e1bfd1aefa9b4a0bb79b7879eef2faf1dd9e932445b21d47" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966" + "cacheKey": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e" } ] }, @@ -2786,29 +2786,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e57264ee39fcf39708f464e9d124edca122c9e82eed80f68abe39783ba36b42a" + "wasm32": "b01e36db0587980ec1c1318bdcd39519166b044f3c2b5bc8c135febff91257d4" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" + "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "8645e6be336d82ed97665c93c9122f4046869d0495f464b9c89b3157426b39e7" + "cacheKey": "36d99a823cd97e42e1bfd1aefa9b4a0bb79b7879eef2faf1dd9e932445b21d47" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "87b26b326af7e0b7c9fa6f9241cfad32900a995883615d3b1bfff22d18d2f0e5" + "cacheKey": "f01d6d631bafb7a31b034ec33970b67778a7ff9e4af3089685ccb941ea2fce18" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966" + "cacheKey": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e" } ] }, @@ -2828,7 +2828,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f1a0483d3dc6f1f65b97cb7fa7ea6c337523a1fe006fe645694ccd36e70f8c41" + "wasm32": "1d2c9a7c6028ae54f6fcfdd24f590d070bcb9d784a83e4a9b663b368f0c845b6" }, "dependencyClosures": { "wasm32": [] @@ -2849,7 +2849,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "5a11abbdc91622ae1883c3ab3adc47651d5688843bce2248387651861256b2fb" + "wasm32": "e6ee4090d0aabc015554e1b5d4b337ec7f26cc465a8f32c007495ccd2c37e453" }, "dependencyClosures": { "wasm32": [] @@ -2870,7 +2870,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "6e9f0cfe84b03be8525fcb19bf79b3782e5b09dda920ba9c33752f457fe103af" + "wasm32": "16fbf3be0917c4cf47561820538c4065276df77b9373904c032986976ebc8e3d" }, "dependencyClosures": { "wasm32": [] @@ -2891,19 +2891,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "d5c3a05d1aee35d8c8d5e86b57d039a7f06a47a8e55afb3d047ba69f31c8ae92" + "wasm32": "e1092a98c2200f9ca0a1dbd4eba7cfc78518a37863ca98df71e7265a273c4227" }, "dependencyClosures": { "wasm32": [ { "packageName": "libpng", "manifestSha256": "79ed5c5c072a0267cce5c7a2fe37d8494571b17e44b952f619e04ec1c4a9db10", - "cacheKey": "cbf7b239bb280c976b263f0a4f7fe6ce7b458f1d555e8306e8625d43c097547b" + "cacheKey": "2ab54d9a91761c737ce35bc700d809edbb1a575f5365372a1cc3fb3e10977742" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966" + "cacheKey": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e" } ] }, @@ -2930,7 +2930,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "5e292a602b1b645bdf8c4582fb3b3f1b323ef8d2f3642bd74a9c033f21d04fd4" + "wasm32": "ec8dad55b6005ca98236e500622ec77ca0a8d44693df24eb66f5f6c62b706dd6" }, "dependencyClosures": { "wasm32": [] @@ -2951,7 +2951,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "94bc46ef11607b637182ec2d8d7612321768db384c4b955889dee573e65fab61" + "wasm32": "5b84470427b17fc94f6e4803bef63d23fe317fc5d3af73c035b642ee48fbffdc" }, "dependencyClosures": { "wasm32": [] @@ -2972,14 +2972,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "bbfc2756d6d5ac700fbfadd2743b22b041f141b10ce78de366e1bb58c3ecee01" + "wasm32": "abd41342ceaf4212159653327c03680599b9bd53fdd4477c57c0131c7dc1bbd3" }, "dependencyClosures": { "wasm32": [ { "packageName": "vim", "manifestSha256": "c211660ef41d01f95f3fbdf675b74891e897e3f304e04f1bf5586f326007382c", - "cacheKey": "94bc46ef11607b637182ec2d8d7612321768db384c4b955889dee573e65fab61" + "cacheKey": "5b84470427b17fc94f6e4803bef63d23fe317fc5d3af73c035b642ee48fbffdc" } ] }, @@ -2999,7 +2999,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e3ba55fc9ecb1094c18f0ca40c9fb6767785201bfebbb64d02e2efffdce5bed2" + "wasm32": "793da9e9a98944ecfffefdfbbaf66eb2437bce78224b3b723c382d46f38fce89" }, "dependencyClosures": { "wasm32": [] @@ -3020,79 +3020,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "46a808b1ec1670f2611cd69d0ae1b27a6990c8e79a5b5c4b67b8fb35c26d4571" + "wasm32": "c5cc2988d67af648504715c20b76b299992ed6eca495c112e52a4b3f655ed4ab" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "81dcb6d5f937d5cca3481492df72cb19bf9f8c84973ef6093dde0e149ba42daa" + "cacheKey": "0cfd3dca3b9499921d565e03a274c5ebbaff265e6c05e98aa8ef889d2140ccd2" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "2785cf0bf052747d2ade36d90fe0ddb40a058f305d78e845ca5bb25dc553cf5d" + "cacheKey": "3fd9598c6138054150f5d457a0b0f62d44ab0920c2a5ea1419bbbd16f2cfd4ec" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "797282f3a1e94fb74c16c1fe0924d478eceb9fc9ae23cdefa40d675a76616ff7" + "cacheKey": "65727e0e1a26b1faaea4e0f0fbddce5e5a44012df69dcda1bb7e3e8626487268" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "cd7d36204206d8e1c09210ca5482c939dd8a5332ea2c47312d3898d73d3cc866" + "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "918054f8e258e000a8f3ac9ef70870d7a0cb1153ecb6e0ac5b99b6152abae905" + "cacheKey": "6aa4acaad6784b9152ec957d4e2e8da31db5c0b610414110f1ddfc5623772f71" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "72264a21723fe72302c5e1b5205aad7117476ef656f54538e98a66c70219be8d" + "cacheKey": "c11983c6357c1b77a011aaeafde22882bdaa777c65c40dd99d4c48777acf23c0" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "01eec0bd3a1139f3baea193789492b632875c70d38aaf0be16794e43d0874245" + "cacheKey": "2b8c3acda63a9e373a63ac32b693eb0a3b7d1f01d19dc114a71ec88c5c0f2168" }, { "packageName": "msmtpd", "manifestSha256": "09de04a422ddf631a29a259d816e081f8d317d1077808ede55d8c500baae748f", - "cacheKey": "cde71f771c392d62a09b6227c18c4c4ba5ede3605a085c57289966f5734e09d3" + "cacheKey": "e52466139aaf8c185e8f8983b548dac2413ea241af093f7c854ee99325999176" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "a678a30cf48177514b258ae186bc92282a3f564327629060f9e75f19443b75a2" + "cacheKey": "9e932bd478a344cdfdb68da0f300f147b2d9c60bb385cf1ffe7906e315d357a8" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "8645e6be336d82ed97665c93c9122f4046869d0495f464b9c89b3157426b39e7" + "cacheKey": "36d99a823cd97e42e1bfd1aefa9b4a0bb79b7879eef2faf1dd9e932445b21d47" }, { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "0e623672a33a21b893c7f44854e7215bdda530a9e66a87dd9d91602e123d1d89" + "cacheKey": "635e5ed39400e0333d51d16278634afc56c8c17e56be858498bff4886c93fee5" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "1fe39b11e701eeddeba9be5dcc7521f51df1b889fb7eb2ba35098f9a071dcc97" + "cacheKey": "96e587a94acbf662c70c87ec7e265b61d313b4816101ad0a41ec8e5b94498c5f" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "fa91f92503bb45a1c89a925780c6b43f4eeb50c9fda686c15c2357e50d448e29" + "cacheKey": "73402dbeec78ac534e0ae3f472f887982200c5c9867e4dc8d64d30d760eeaa7c" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "0d4393de8ddb2cb75888a0862a4ed8443b781d8b495ebda813fd9d0c68c0b966" + "cacheKey": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e" } ] }, @@ -3112,7 +3112,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "16efcc1048e122ed6098b46edb053dc5ee50ac5caaeba9e93b767df224e75921" + "wasm32": "e671549aa3e6fbf95c8d0a3ab365700c7354bf4e4faf4ffd6b0af2fa62485830" }, "dependencyClosures": { "wasm32": [] @@ -3133,7 +3133,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "307cb35847ff802b569012e26c3f42b308ab6dea07c62ffe6428d886f3c81189" + "wasm32": "b188c0bf07f2c79821cd474d934c36bcc2a092512dddca33bc3cbe6d68832b5b" }, "dependencyClosures": { "wasm32": [] @@ -3154,7 +3154,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "013d3afc8e3ea69b7b88d930a8c845860f060b1b50ae80c66206871e8917c20b" + "wasm32": "bf9867e234e6d73257a275e30917058094135eb4f90c13352df67504acee097c" }, "dependencyClosures": { "wasm32": [] diff --git a/programs/secure-exec-probe.c b/programs/secure-exec-probe.c new file mode 100644 index 0000000000..a65eef326c --- /dev/null +++ b/programs/secure-exec-probe.c @@ -0,0 +1,202 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern char **environ; + +static int constructor_issetugid = -1; +static int constructor_untrusted_visible = -1; +static const char secure_stdout_sentinel[] = "secure-stdout-sentinel\n"; +static const char secure_stderr_sentinel[] = "secure-stderr-sentinel\n"; + +__attribute__((constructor)) +static void observe_secure_startup(void) +{ + constructor_issetugid = issetugid(); + constructor_untrusted_visible = + secure_getenv("KANDELO_UNTRUSTED") != NULL; +} + +static int check_sensitive_lookups(int secure) +{ + if (setenv("LC_TIME", "zz_TEST", 1)) return 31; + if (setenv("MUSL_LOCPATH", "/tmp", 1)) return 32; + if (!setlocale(LC_TIME, "")) return 33; + if (strcmp(nl_langinfo(ABDAY_1), secure ? "Sun" : "Lok")) return 34; + + if (setenv("TZ", ":/tmp/secure-zone", 1)) return 35; + tzset(); + if (strcmp(tzname[0], secure ? "UTC" : "TST")) return 36; + + if (setenv("NLSPATH", "/tmp/%N", 1)) return 37; + nl_catd cat = catopen("secure.cat", NL_CAT_LOCALE); + if (secure ? cat != (nl_catd)-1 : cat == (nl_catd)-1) return 38; + if (cat != (nl_catd)-1) catclose(cat); + return 0; +} + +static int check_secure_state(int secure, int check_constructor) +{ + if (!!issetugid() != secure) return 10; + if ((secure_getenv("KANDELO_UNTRUSTED") != NULL) != !secure) return 11; + if (check_constructor && constructor_issetugid != secure) return 12; + if (check_constructor && constructor_untrusted_visible != !secure) return 13; + return 0; +} + +static int check_standard_fds(unsigned mask) +{ + for (int fd = 0; fd < 3; fd++) { + if (fcntl(fd, F_GETFD) < 0) return 40 + fd; + } + if (mask & 1) { + unsigned char byte; + if (read(0, &byte, 1) != 0) return 43; + } + if (write(1, secure_stdout_sentinel, + sizeof(secure_stdout_sentinel) - 1) != + (ssize_t)(sizeof(secure_stdout_sentinel) - 1)) return 44; + if (write(2, secure_stderr_sentinel, + sizeof(secure_stderr_sentinel) - 1) != + (ssize_t)(sizeof(secure_stderr_sentinel) - 1)) return 45; + return 0; +} + +static int target_main(int argc, char **argv) +{ + if (argc < 4) return 2; + int secure = atoi(argv[2]) != 0; + unsigned mask = (unsigned)strtoul(argv[3], NULL, 0); + + int rc = check_secure_state(secure, 1); + if (rc) return rc; + rc = check_standard_fds(mask); + if (rc) return rc; + rc = check_sensitive_lookups(secure); + if (rc) return rc; + + if (secure) { + if (setuid(getuid())) return 50; + rc = check_secure_state(1, 1); + if (rc) return 51; + } + + printf( + "secure=%d ctor_secure=%d untrusted_visible=%d ctor_visible=%d " + "locale=%s timezone=%s catalog=%s fds=ok\n", + !!issetugid(), constructor_issetugid, + secure_getenv("KANDELO_UNTRUSTED") != NULL, + constructor_untrusted_visible, + nl_langinfo(ABDAY_1), tzname[0], secure ? "blocked" : "loaded"); + return 0; +} + +static int stdio_target_main(int argc, char **argv) +{ + if (argc < 4) return 2; + return check_standard_fds((unsigned)strtoul(argv[3], NULL, 0)); +} + +static int startup_target_main(int argc, char **argv) +{ + if (argc < 4) return 2; + int secure = atoi(argv[2]) != 0; + int rc = check_secure_state(secure, 1); + if (rc) return rc; + printf( + "secure=%d ctor_secure=%d untrusted_visible=%d ctor_visible=%d\n", + !!issetugid(), constructor_issetugid, + secure_getenv("KANDELO_UNTRUSTED") != NULL, + constructor_untrusted_visible); + return 0; +} + +static int exec_target(const char *path, const char *mode, const char *secure, + const char *mask, const char *child_path, + const char *child_mode) +{ + char *const target_argv[] = { + (char *)path, (char *)mode, (char *)secure, (char *)mask, + (char *)child_path, (char *)child_mode, NULL, + }; + char *const target_env[] = { + "KANDELO_UNTRUSTED=visible-only-outside-secure-startup", + "LC_TIME=zz_TEST", + "MUSL_LOCPATH=/tmp", + "TZ=:/tmp/secure-zone", + "NLSPATH=/tmp/%N", + NULL, + }; + execve(path, target_argv, target_env); + return errno == ENOENT ? 60 : 61; +} + +static int launch_main(int argc, char **argv, int exhaust_fds) +{ + if (argc < 6) return 2; + if (exhaust_fds) { + struct rlimit limit; + if (getrlimit(RLIMIT_NOFILE, &limit)) return 63; + limit.rlim_cur = 0; + if (setrlimit(RLIMIT_NOFILE, &limit)) return 64; + } + unsigned mask = (unsigned)strtoul(argv[5], NULL, 0); + for (int fd = 0; fd < 3; fd++) { + if ((mask & (1u << fd)) && close(fd) && errno != EBADF) return 62; + } + return exec_target(argv[2], argv[3], argv[4], argv[5], + argc > 6 ? argv[6] : NULL, + argc > 7 ? argv[7] : NULL); +} + +static int spawn_parent_main(int argc, char **argv) +{ + if (argc < 4) return 2; + int resetids = atoi(argv[3]) != 0; + int rc = check_secure_state(1, 1); + if (rc) return rc; + + posix_spawnattr_t attr; + if (posix_spawnattr_init(&attr)) return 70; + if (resetids && posix_spawnattr_setflags(&attr, POSIX_SPAWN_RESETIDS)) { + return 71; + } + char expected[] = { resetids ? '0' : '1', 0 }; + char *child_argv[] = { + argc > 4 ? argv[4] : "/bin/secure-child", + argc > 5 ? argv[5] : "target", expected, "0", NULL, + }; + pid_t child = -1; + rc = posix_spawn(&child, child_argv[0], NULL, &attr, child_argv, environ); + posix_spawnattr_destroy(&attr); + if (rc) return 72; + int status = 0; + if (waitpid(child, &status, 0) != child) return 73; + if (!WIFEXITED(status)) return 74; + return WEXITSTATUS(status); +} + +int main(int argc, char **argv) +{ + if (argc < 2) return 2; + if (!strcmp(argv[1], "launch")) return launch_main(argc, argv, 0); + if (!strcmp(argv[1], "launch-nofile")) return launch_main(argc, argv, 1); + if (!strcmp(argv[1], "target")) return target_main(argc, argv); + if (!strcmp(argv[1], "startup-target")) return startup_target_main(argc, argv); + if (!strcmp(argv[1], "stdio-target")) return stdio_target_main(argc, argv); + if (!strcmp(argv[1], "spawn-parent")) return spawn_parent_main(argc, argv); + return 3; +} diff --git a/scripts/build-programs.sh b/scripts/build-programs.sh index efeedbf576..b209b07139 100755 --- a/scripts/build-programs.sh +++ b/scripts/build-programs.sh @@ -146,7 +146,7 @@ LINK_PRE_LIBS=( # linker pass. LINK_POST_LIBS=( "$SYSROOT/lib/libc.a" - -Wl,--entry=_start + -Wl,--no-entry -Wl,--export=_start -Wl,--import-memory -Wl,--shared-memory @@ -387,7 +387,7 @@ if [ -f "$SYSROOT64/lib/libc.a" ]; then "$GLUE_DIR/compiler_rt.c" "$SYSROOT64/lib/crt1.o" "$SYSROOT64/lib/libc.a" - -Wl,--entry=_start + -Wl,--no-entry -Wl,--export=_start -Wl,--import-memory -Wl,--shared-memory diff --git a/scripts/run-browser-libc-tests.sh b/scripts/run-browser-libc-tests.sh index d326d84d1f..6dfd4aa931 100755 --- a/scripts/run-browser-libc-tests.sh +++ b/scripts/run-browser-libc-tests.sh @@ -117,7 +117,7 @@ LINK_FLAGS=( "$GLUE_DIR/compiler_rt.c" "$SYSROOT/lib/crt1.o" "$SYSROOT/lib/libc.a" - -Wl,--entry=_start + -Wl,--no-entry -Wl,--export=_start -Wl,--import-memory -Wl,--shared-memory diff --git a/scripts/run-browser-posix-tests.sh b/scripts/run-browser-posix-tests.sh index 132d7ffba7..251365842d 100755 --- a/scripts/run-browser-posix-tests.sh +++ b/scripts/run-browser-posix-tests.sh @@ -85,7 +85,7 @@ LINK_FLAGS=( "$GLUE_DIR/compiler_rt.c" "$SYSROOT/lib/crt1.o" "$SYSROOT/lib/libc.a" - -Wl,--entry=_start + -Wl,--no-entry -Wl,--export=_start -Wl,--import-memory -Wl,--shared-memory diff --git a/scripts/run-browser-sortix-tests.sh b/scripts/run-browser-sortix-tests.sh index eec04bcbc1..7c4b5c0fe9 100755 --- a/scripts/run-browser-sortix-tests.sh +++ b/scripts/run-browser-sortix-tests.sh @@ -169,7 +169,7 @@ LINK_FLAGS=( "$GLUE_DIR/dlopen.c" "$SYSROOT/lib/crt1.o" "$SYSROOT/lib/libc.a" - -Wl,--entry=_start + -Wl,--no-entry -Wl,--export=_start -Wl,--import-memory -Wl,--shared-memory diff --git a/scripts/run-libc-tests.sh b/scripts/run-libc-tests.sh index eed6350a34..fdb89bbda4 100755 --- a/scripts/run-libc-tests.sh +++ b/scripts/run-libc-tests.sh @@ -103,7 +103,7 @@ LINK_FLAGS=( "$GLUE_DIR/compiler_rt.c" "$SYSROOT/lib/crt1.o" "$SYSROOT/lib/libc.a" - -Wl,--entry=_start + -Wl,--no-entry -Wl,--export=_start -Wl,--import-memory -Wl,--shared-memory diff --git a/scripts/run-posix-tests.sh b/scripts/run-posix-tests.sh index 5e28e28227..c1edf733ac 100755 --- a/scripts/run-posix-tests.sh +++ b/scripts/run-posix-tests.sh @@ -71,7 +71,7 @@ LINK_FLAGS=( "$GLUE_DIR/compiler_rt.c" "$SYSROOT/lib/crt1.o" "$SYSROOT/lib/libc.a" - -Wl,--entry=_start + -Wl,--no-entry -Wl,--export=_start -Wl,--import-memory -Wl,--shared-memory diff --git a/scripts/run-sortix-tests.sh b/scripts/run-sortix-tests.sh index 863117cd76..84404ceef0 100755 --- a/scripts/run-sortix-tests.sh +++ b/scripts/run-sortix-tests.sh @@ -148,7 +148,7 @@ LINK_FLAGS=( "$GLUE_DIR/dlopen.c" "$SYSROOT/lib/crt1.o" "$SYSROOT/lib/libc.a" - -Wl,--entry=_start + -Wl,--no-entry -Wl,--export=_start -Wl,--import-memory -Wl,--shared-memory diff --git a/sdk/kandelo/bin/wasm32posix-cc b/sdk/kandelo/bin/wasm32posix-cc index b8a8aa7563..8de43c8bcf 100755 --- a/sdk/kandelo/bin/wasm32posix-cc +++ b/sdk/kandelo/bin/wasm32posix-cc @@ -366,7 +366,7 @@ max_executable_memory_size=1073741824 exe_link_flags=( -nostdlib - -Wl,--entry=_start + -Wl,--no-entry -Wl,--export=_start -Wl,--export=__heap_base -Wl,--import-memory diff --git a/sdk/src/lib/flags.ts b/sdk/src/lib/flags.ts index c393655ad0..1c9f7618e5 100644 --- a/sdk/src/lib/flags.ts +++ b/sdk/src/lib/flags.ts @@ -259,7 +259,9 @@ export function linkFlags( ): string[] { return [ '-nostdlib', - '-Wl,--entry=_start', + // Reactor link mode leaves constructor ownership with musl. The host still + // enters through the explicitly exported `_start` below. + '-Wl,--no-entry', '-Wl,--export=_start', '-Wl,--export=__heap_base', '-Wl,--import-memory', diff --git a/sdk/test/cc.test.ts b/sdk/test/cc.test.ts index 1cc7d84b2c..24f7e43492 100644 --- a/sdk/test/cc.test.ts +++ b/sdk/test/cc.test.ts @@ -44,7 +44,7 @@ describe('buildClangArgs', () => { it('compile+link: adds both compile and link flags plus glue', () => { const args = build(['foo.c', '-o', 'foo.wasm']); expect(args).toContain('--target=wasm32-unknown-unknown'); - expect(args).toContain('-Wl,--entry=_start'); + expect(args).toContain('-Wl,--no-entry'); expect(args).toContain('-Wl,--import-memory'); expect(args.join(' ')).toContain('channel_syscall.c'); expect(args.join(' ')).toContain('compiler_rt.c'); @@ -116,7 +116,7 @@ describe('buildClangArgs', () => { it('link-only: object files without -c get link flags plus compile flags for glue', () => { const args = build(['foo.o', 'bar.o', '-o', 'out.wasm']); - expect(args).toContain('-Wl,--entry=_start'); + expect(args).toContain('-Wl,--no-entry'); expect(args.join(' ')).toContain('libc.a'); expect(args).toContain('--target=wasm32-unknown-unknown'); // Compile flags are present because glue .c files are compiled during linking @@ -213,7 +213,7 @@ describe('buildClangArgs', () => { it('treats linker response lists as link commands', () => { const args = build(['-fuse-ld=lld', '-o', 'out.wasm', '-Wl,@/tmp/objects.list']); - expect(args).toContain('-Wl,--entry=_start'); + expect(args).toContain('-Wl,--no-entry'); expect(args.join(' ')).toContain('channel_syscall.c'); expect(args.join(' ')).toContain('libc.a'); }); diff --git a/sdk/test/flags.test.ts b/sdk/test/flags.test.ts index 2f574c1e56..8c56072152 100644 --- a/sdk/test/flags.test.ts +++ b/sdk/test/flags.test.ts @@ -203,8 +203,10 @@ describe('COMPILE_FLAGS', () => { }); describe('LINK_FLAGS', () => { - it('includes entry and memory flags', () => { - expect(LINK_FLAGS).toContain('-Wl,--entry=_start'); + it('leaves constructors to musl while exporting the process entry', () => { + expect(LINK_FLAGS).toContain('-Wl,--no-entry'); + expect(LINK_FLAGS).toContain('-Wl,--export=_start'); + expect(LINK_FLAGS).not.toContain('-Wl,--entry=_start'); expect(LINK_FLAGS).toContain('-Wl,--import-memory'); expect(LINK_FLAGS).toContain('-Wl,--shared-memory'); }); diff --git a/tools/xtask/src/dump_abi.rs b/tools/xtask/src/dump_abi.rs index 9dfb7d9051..c2a1ec9579 100644 --- a/tools/xtask/src/dump_abi.rs +++ b/tools/xtask/src/dump_abi.rs @@ -8111,6 +8111,7 @@ mod tests { "export const WAKE_PROCESS_CONTINUED = 32 as const;", "\"kernel_get_process_state\"", "\"kernel_has_sa_nocldstop\"", + "\"kernel_process_secure_exec\"", "\"kernel_wait_child_poll\"", ] { assert!( From ad05f6b23a083350c969e2be27f67610a2eabfdf Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Tue, 11 Aug 2026 08:32:37 -0400 Subject: [PATCH 64/82] VFS: Resolve create metadata from the parent route --- host/src/vfs/vfs.ts | 14 ++++- host/test/vfs-create-route.test.ts | 94 ++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 host/test/vfs-create-route.test.ts diff --git a/host/src/vfs/vfs.ts b/host/src/vfs/vfs.ts index d47906ab39..9334295dd9 100644 --- a/host/src/vfs/vfs.ts +++ b/host/src/vfs/vfs.ts @@ -15,6 +15,7 @@ import { type TimeProvider, } from "./types"; import { resolveMountSetIdCapability } from "./memory-fs"; +import { OPEN_FLAGS } from "../generated/abi"; interface MountEntry { prefix: string; @@ -53,6 +54,11 @@ function normalizeMountPoint(mp: string): string { return mp; } +function parentPath(path: string): string { + const slash = path.lastIndexOf("/"); + return slash <= 0 ? "/" : path.slice(0, slash); +} + export class VirtualPlatformIO implements PlatformIO { private mounts: MountEntry[]; private time: TimeProvider; @@ -202,7 +208,13 @@ export class VirtualPlatformIO implements PlatformIO { open(path: string, flags: number, mode: number): number { const { backend, backendId, relativePath, setIdCapability } = this.resolve(path); - const backendStatfs = backend.statfs(relativePath); + // O_CREAT may name a missing final component. Its already-resolved backend + // and existing parent provide the filesystem metadata; the open below + // remains the sole authority for validating and creating the final path. + const statfsPath = (flags & OPEN_FLAGS.O_CREAT) !== 0 + ? parentPath(relativePath) + : relativePath; + const backendStatfs = backend.statfs(statfsPath); const statfs = { ...backendStatfs, flags: setIdCapability.kind === "nosuid" diff --git a/host/test/vfs-create-route.test.ts b/host/test/vfs-create-route.test.ts new file mode 100644 index 0000000000..1ad171fece --- /dev/null +++ b/host/test/vfs-create-route.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { VirtualPlatformIO } from "../src/vfs/vfs"; +import { HostFileSystem } from "../src/vfs/host-fs"; +import { MemoryFileSystem } from "../src/vfs/memory-fs"; +import { NodeTimeProvider } from "../src/vfs/time"; +import { ST_NOSUID } from "../src/vfs/types"; +import { O_CREAT, O_RDWR } from "../src/vfs/sharedfs-vendor"; + +const roots: string[] = []; + +afterEach(() => { + vi.restoreAllMocks(); + while (roots.length > 0) { + rmSync(roots.pop()!, { recursive: true, force: true }); + } +}); + +function memoryFileSystem(): MemoryFileSystem { + return MemoryFileSystem.create(new SharedArrayBuffer(1024 * 1024)); +} + +describe("VirtualPlatformIO create-route metadata", () => { + it("creates a missing final path from its existing parent route", () => { + const backend = memoryFileSystem(); + const statfs = vi.spyOn(backend, "statfs"); + const io = new VirtualPlatformIO( + [{ mountPoint: "/", backend }], + new NodeTimeProvider(), + ); + + const fd = io.open("/created", O_CREAT | O_RDWR, 0o6755); + try { + expect(statfs).toHaveBeenCalledWith("/"); + expect(io.stat("/created").mode & 0o7777).toBe(0o6755); + expect(io.fstatfs(fd).flags & ST_NOSUID).toBe(ST_NOSUID); + } finally { + io.close(fd); + } + }); + + it("uses the selected nested mount's parent without consulting root", () => { + const root = memoryFileSystem(); + const nested = memoryFileSystem(); + const rootStatfs = vi.spyOn(root, "statfs"); + const nestedStatfs = vi.spyOn(nested, "statfs"); + const io = new VirtualPlatformIO( + [ + { mountPoint: "/", backend: root }, + { mountPoint: "/tmp", backend: nested }, + ], + new NodeTimeProvider(), + ); + + const fd = io.open("/tmp/created", O_CREAT | O_RDWR, 0o600); + io.close(fd); + + expect(nestedStatfs).toHaveBeenCalledWith("/"); + expect(rootStatfs).not.toHaveBeenCalled(); + expect(nested.stat("/created").mode & 0o7777).toBe(0o600); + expect(() => root.stat("/tmp/created")).toThrow(); + }); + + it("retains target ENOENT when O_CREAT is absent", () => { + const backend = memoryFileSystem(); + const statfs = vi.spyOn(backend, "statfs"); + const io = new VirtualPlatformIO( + [{ mountPoint: "/", backend }], + new NodeTimeProvider(), + ); + + expect(() => io.open("/missing", O_RDWR, 0)).toThrow(/No such file/); + expect(statfs).toHaveBeenCalledWith("/missing"); + expect(() => backend.stat("/missing")).toThrow(); + }); + + it("does not let parent lookup authorize traversal outside a host mount", () => { + const root = mkdtempSync(join(tmpdir(), "kandelo-create-route-")); + roots.push(root); + const outside = `${root}-escape`; + const escapeName = outside.slice(outside.lastIndexOf("/") + 1); + rmSync(outside, { force: true }); + const io = new VirtualPlatformIO( + [{ mountPoint: "/", backend: new HostFileSystem(root) }], + new NodeTimeProvider(), + ); + + expect(() => io.open(`/../${escapeName}`, O_CREAT | O_RDWR, 0o600)) + .toThrow(/EACCES/); + expect(existsSync(outside)).toBe(false); + }); +}); From 9a8291501323a55110362ef761cab4e7cabfa6b4 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Tue, 11 Aug 2026 10:01:39 -0400 Subject: [PATCH 65/82] VFS: Preserve set-ID metadata semantics --- .../browser-demos/test/chown-sentinel.spec.ts | 8 +- docs/posix-status.md | 14 +- examples/chown_sentinel_test.c | 115 ++++- host/src/native-positioned-write.ts | 187 ++++++-- host/src/platform/native-metadata.ts | 51 ++- host/src/platform/node.ts | 26 +- host/src/vfs/host-fs.ts | 25 +- host/src/vfs/sharedfs-vendor.ts | 286 +++++++++---- host/test/chown-sentinel.test.ts | 1 + .../sharedfs-fd-reservation-worker.ts | 37 ++ host/test/native-open-create-race.test.ts | 400 +++++++++++++++++- host/test/node-host-vfs-only-metadata.test.ts | 213 +++++++++- host/test/platform/native-metadata.test.ts | 59 ++- host/test/vfs.test.ts | 238 +++++++++++ host/test/vfs/sharedfs-positioned-io.test.ts | 105 +++++ host/test/vfs/sharedfs-uid-gid.test.ts | 336 ++++++++++++++- packages/registry/program-packages.json | 96 ++--- 17 files changed, 1982 insertions(+), 215 deletions(-) create mode 100644 host/test/fixtures/sharedfs-fd-reservation-worker.ts diff --git a/apps/browser-demos/test/chown-sentinel.spec.ts b/apps/browser-demos/test/chown-sentinel.spec.ts index c05e635e83..26a74238d3 100644 --- a/apps/browser-demos/test/chown-sentinel.spec.ts +++ b/apps/browser-demos/test/chown-sentinel.spec.ts @@ -8,15 +8,14 @@ const programPath = resolve( "../../../examples/chown_sentinel_test.wasm", ); -test("chown sentinels and no-follow link ownership work in Chromium", async ({ +test("file mutation and chown set-ID invalidation work", async ({ page, baseURL, - browserName, }) => { - test.skip(browserName !== "chromium", "the aggregate browser gate uses Chromium"); expect(baseURL).toBeTruthy(); - await page.goto(new URL("/pages/test-runner/", baseURL).href); + // This probe needs the real browser worker and VFS, but no shell packages. + await page.goto(new URL("/pages/test-runner/?minimal=1", baseURL).href); await page.waitForFunction(() => (window as any).__testRunnerReady === true); const programUrl = new URL(`/@fs/${programPath}`, baseURL).href; @@ -33,6 +32,7 @@ test("chown sentinels and no-follow link ownership work in Chromium", async ({ }, { programUrl }); expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("SETID_MUTATION_MATRIX_PASS"); expect(result.stdout).toContain("CHOWN_SENTINEL_PASS"); expect(result.stderr).toBe(""); }); diff --git a/docs/posix-status.md b/docs/posix-status.md index 92b485a1a8..bec2a9cad9 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -55,13 +55,13 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve | Function | Status | Notes | |----------|--------|-------| -| `open()` | Partial | Host-delegated for ordinary files. O_CREAT, O_EXCL, O_TRUNC, O_APPEND, O_NONBLOCK, O_CLOEXEC, O_DIRECTORY, O_NOFOLLOW flags handled. umask applied to mode on O_CREAT. Named FIFOs use kernel-owned rendezvous state: blocking read/write-only opens reserve an fd and wait for a peer, nonblocking write-only open returns ENXIO without a reader, and O_RDWR opens both ends without waiting. O_PATH/O_SEARCH opens retain a FIFO for metadata and `*at()` use without creating an I/O endpoint. Signals, thread cancellation, exec, and process exit release incomplete-open reservations; a cancellation request remains pending while cancellation is disabled and the FIFO open continues blocking until a peer arrives or a signal interrupts it. Virtual device interception (`/dev/null`, `/dev/zero`, `/dev/urandom`, `/dev/full`, `/dev/fd/N`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`). | +| `open()` | Partial | Host-delegated for ordinary files. O_CREAT, O_EXCL, O_TRUNC, O_APPEND, O_NONBLOCK, O_CLOEXEC, O_DIRECTORY, O_NOFOLLOW flags handled. umask applied to mode on O_CREAT. Size-changing O_TRUNC clears S_ISUID and S_ISGID on a metadata-backed regular file; creating a file or truncating an already-empty file preserves the requested/current mode. Named FIFOs use kernel-owned rendezvous state: blocking read/write-only opens reserve an fd and wait for a peer, nonblocking write-only open returns ENXIO without a reader, and O_RDWR opens both ends without waiting. O_PATH/O_SEARCH opens retain a FIFO for metadata and `*at()` use without creating an I/O endpoint. Signals, thread cancellation, exec, and process exit release incomplete-open reservations; a cancellation request remains pending while cancellation is disabled and the FIFO open continues blocking until a peer arrives or a signal interrupts it. Virtual device interception (`/dev/null`, `/dev/zero`, `/dev/urandom`, `/dev/full`, `/dev/fd/N`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`). | | `openat()` | Full | AT_FDCWD delegates to open(). Absolute paths handled. Real dirfd supported via stored OFD paths. | | `close()` | Partial | Ref-counted OFD cleanup. Host handle closed when last ref dropped. Closing any descriptor for a file releases every process lock held by that PID on the file; OFD locks survive duplicated/inherited references and disappear only with the final machine-wide OFD reference. EINTR not yet handled. | | `read()` | Partial | Host-delegated for files. Pipe/socket reads from kernel ring buffer with blocking when empty (EINTR on signal). Short reads permitted. O_NONBLOCK returns EAGAIN. | | `pread()` | Partial | Host-backed files use one positioned backend read without changing the OFD cursor; in-kernel files retain their native positioned path. Rejects pipes/sockets with ESPIPE. Signed-i64 offsets stay exact through the host contract; number-only backends return EOVERFLOW rather than rounding an unrepresentable offset. | -| `write()` | Partial | Host-delegated for files. Pipe writes to kernel ring buffer with blocking when full (EINTR on signal). EPIPE + SIGPIPE on closed read end (POSIX-compliant). `O_APPEND` is one EOF/limit/write transaction that returns the exact written prefix and ending offset: memfds and shared-memory files serialize under their backing lock, OPFS serializes in its channel handler, and lifecycle-owned Node scratch mounts use a verified native append route. Node session seeds are copied to new private inodes before readiness and therefore retain that lifecycle-owned route; no mutation is written back to the source tree. Externally mutable `HostFileSystem` mounts and the legacy raw Node adapter cannot prove the exact ending offset and return `EOPNOTSUPP` before mutation. For regular files and memfds, `RLIMIT_FSIZE` applies once per logical operation: a crossing operation returns the prefix that fits without a signal; a later non-empty operation with no room fails with `EFBIG` and generates thread-directed `SIGXFSZ`. | -| `pwrite()` | Partial | Host-backed files use one positioned backend write without changing the OFD cursor; in-kernel files retain their native positioned path. Rejects pipes/sockets with ESPIPE. Uses the same operation-wide RLIMIT_FSIZE rule as write. Number-only backends, including Node's synchronous positioned-write API above JavaScript's safe-integer range, return EOVERFLOW rather than rounding. | +| `write()` | Partial | Host-delegated for files. A successful non-empty metadata-backed regular-file write or append clears S_ISUID and S_ISGID; a zero-byte or failed operation leaves mode unchanged. Kandelo deliberately applies this to non-executable S_ISGID too: POSIX permits clearing both bits after write or ftruncate, and Kandelo does not implement the System V mandatory-locking interpretation of that bit. Pipe writes to kernel ring buffer with blocking when full (EINTR on signal). EPIPE + SIGPIPE on closed read end (POSIX-compliant). `O_APPEND` is one EOF/limit/write transaction that returns the exact written prefix and ending offset: memfds and shared-memory files serialize under their backing lock, OPFS serializes in its channel handler, and lifecycle-owned Node scratch mounts use a verified native append route. Node session seeds are copied to new private inodes before readiness and therefore retain that lifecycle-owned route; no mutation is written back to the source tree. Externally mutable `HostFileSystem` mounts and the legacy raw Node adapter cannot prove the exact ending offset and return `EOPNOTSUPP` before mutation. For regular files and memfds, `RLIMIT_FSIZE` applies once per logical operation: a crossing operation returns the prefix that fits without a signal; a later non-empty operation with no room fails with `EFBIG` and generates thread-directed `SIGXFSZ`. | +| `pwrite()` | Partial | Host-backed files use one positioned backend write without changing the OFD cursor; in-kernel files retain their native positioned path. Successful non-empty regular-file writes use the same set-ID invalidation rule as `write()`. Rejects pipes/sockets with ESPIPE. Uses the same operation-wide RLIMIT_FSIZE rule as write. Number-only backends, including Node's synchronous positioned-write API above JavaScript's safe-integer range, return EOVERFLOW rather than rounding. | | `lseek()` | Partial | Regular files support SEEK_SET, SEEK_CUR, and SEEK_END; SEEK_END delegates to the host for size calculation. Directories accept a nonnegative next-record cookie with SEEK_SET and expose the current cookie through SEEK_CUR with offset zero; other directory seeks fail with EINVAL without changing the cursor. A regular-file seek whose result would be negative likewise fails with EINVAL, and arithmetic or host-number overflow fails with EOVERFLOW. Inherited and transferred descriptors share the same OFD position. | | `dup()` | Full | Lowest available fd. FD_CLOEXEC cleared. Shares OFD with original. | | `dup2()` | Full | Atomic close-and-dup. Same-fd no-op. FD_CLOEXEC cleared. | @@ -71,12 +71,12 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve | `readv()` | Full | Validates the complete caller-native iovec table and `IOV_MAX`, performs one contiguous scalar read, then scatters only the returned prefix. This preserves datagram/record boundaries and stops naturally on a short read or EOF even when the vector exceeds ordinary channel scratch. | | `writev()` | Full | Validates and gathers the complete vector, then performs one scalar write. Pipe/datagram operation boundaries and operation-wide `RLIMIT_FSIZE` are preserved even when the vector exceeds ordinary channel scratch. | | `fstat()` | Partial | Host-delegated for regular files. Anonymous pipes report S_IFIFO with synthetic metadata; named FIFOs preserve their VFS permissions, ownership, timestamps, and authoritative link count across rename and unlink while an fd remains open. Removing the final name sets the cached inode link count to zero and advances ctime. ABI 39 does not report `st_rdev`, `st_blksize`, or `st_blocks`; libc initializes those fields to zero instead of exposing uninitialized memory. Truthful backend metadata is tracked in [issue #928](https://github.com/Automattic/kandelo/issues/928). | -| `ftruncate()` | Partial | Host-delegated for regular files, with in-kernel memfd support. Requires write access, validates length >= 0, rejects non-regular fds, and enforces RLIMIT_FSIZE before changing either backing. | +| `ftruncate()` | Partial | Host-delegated for regular files, with in-kernel memfd support. A size change clears S_ISUID and S_ISGID on metadata-backed regular files; a same-size or failed operation leaves mode unchanged. Requires write access, validates length >= 0, rejects non-regular fds, and enforces RLIMIT_FSIZE before changing either backing. | | `fsync()` | Partial | Host-delegated for regular files and directories. Node-backed directories use the native durability barrier; memory-backed filesystems have no queued writes. Browser OPFS flushes regular-file access handles, but its API exposes no separate directory durability barrier. Rejects pipes and sockets. | | `fdatasync()` | Partial | Alias for fsync(). No metadata distinction in Wasm environment. | | `truncate()` | Partial | Path-based. Named FIFOs fail with EINVAL without entering their open rendezvous; ordinary paths open O_WRONLY, call ftruncate, and close. | | `fchmod()` | Partial | Regular files, directories, and named FIFOs update VFS metadata; an unlinked but open named FIFO retains the updated cached inode metadata. O_PATH/O_SEARCH descriptors return EBADF. Other kernel-owned pipes/sockets accept the call as a no-op. Node host-backed files never receive native mode changes after creation. | -| `fchown()` | Partial | Regular files, directories, and named FIFOs update VFS metadata. `(uid_t)-1` and `(gid_t)-1` preserve the corresponding current ID without bypassing descriptor, authorization, or backend-error checks. Root may select arbitrary IDs; an unprivileged owner must preserve its user ID and may select its effective GID or Kandelo's one synthesized supplementary GID (the real GID). On metadata-backed SharedFS and Node regular files, successful ownership calls clear S_ISUID and S_ISGID when any execute bit is set. O_PATH/O_SEARCH descriptors return EBADF. Unlinked open named FIFOs retain updated cached ownership; other kernel-owned non-file descriptors still accept the call as a metadata-less no-op, and Node host-backed ownership changes stay virtual. Arbitrary supplementary-group lists remain unsupported. | +| `fchown()` | Partial | Regular files, directories, and named FIFOs update VFS metadata. `(uid_t)-1` and `(gid_t)-1` preserve the corresponding current ID without bypassing descriptor, authorization, or backend-error checks. Root may select arbitrary IDs; an unprivileged owner must preserve its user ID and may select its effective GID or any authoritative supplementary group. On metadata-backed SharedFS and Node regular files, every successful ownership call clears S_ISUID and S_ISGID, regardless of execute bits. Directories and symlinks retain their modes. O_PATH/O_SEARCH descriptors return EBADF. Unlinked open named FIFOs retain updated cached ownership; other kernel-owned non-file descriptors still accept the call as a metadata-less no-op, and Node host-backed ownership changes stay virtual. | | `preadv()` | Full | Validates the complete vector and performs one exact-offset scalar read, then scatters only the returned prefix without changing the OFD cursor. | | `pwritev()` | Full | Validates and gathers the complete vector, then performs one exact-offset scalar write without changing the OFD cursor. The aggregate `RLIMIT_FSIZE` decision applies once to that operation. | | `preadv2()` / `pwritev2()` | Partial | Delegates to preadv/pwritev. Extra flags parameter ignored. | @@ -93,7 +93,7 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve | `renameat()` | Full | Both dirfds supported (AT_FDCWD, absolute, or real dirfd). | | `faccessat()` | Full | AT_FDCWD delegates to access(). Absolute paths and real dirfd supported. | | `fchmodat()` | Full | AT_FDCWD delegates to chmod(). AT_SYMLINK_NOFOLLOW accepted. Real dirfd supported. | -| `fchownat()` | Partial | AT_FDCWD and real dirfds are supported, including unchanged-ID sentinels and the same root/owner/group authorization as `chown()`. The final symlink is followed by default and changed directly with `AT_SYMLINK_NOFOLLOW`. Unsupported flags, including `AT_EMPTY_PATH`, return EINVAL. Arbitrary supplementary-group lists remain unsupported. | +| `fchownat()` | Partial | AT_FDCWD and real dirfds are supported, including unchanged-ID sentinels and the same root/owner/group authorization as `chown()`. The final symlink is followed by default and changed directly with `AT_SYMLINK_NOFOLLOW`. Unsupported flags, including `AT_EMPTY_PATH`, return EINVAL. | | `linkat()` | Full | Both dirfds supported (AT_FDCWD, absolute, or real dirfd). | | `symlinkat()` | Full | Target stored as-is. Linkpath resolved via dirfd. Real dirfd supported. | | `readlinkat()` | Full | AT_FDCWD delegates to readlink(). Real dirfd supported. | @@ -240,7 +240,7 @@ to a different directory than the original OFD. | `rename()` | Partial | Host-delegated. Both paths resolved via kernel cwd. Named-FIFO identities follow file and containing-directory renames, including destination replacement. | | `stat()` / `lstat()` | Partial | Host-delegated. stat follows symlinks, lstat does not. Procfs fd magic links are validated against live fd/OFD pairs: following `/proc//fd/N` returns the target OFD metadata even after its pathname is unlinked, while no-follow operations report the symlink and closed slots return ENOENT. Registered AF_UNIX pathname sockets preserve the backing VFS inode's uid, gid, permissions, timestamps, and link count while reporting `S_IFSOCK`. Registered named FIFOs likewise preserve VFS metadata while reporting `S_IFIFO`; `readdir()` and `getdents64()` report `DT_FIFO`. | | `statfs()` / `fstatfs()` | Partial | Host-backed and virtual filesystem statistics are reported. Mounts default to `ST_NOSUID` in both Node and browser hosts. Only a read-only product backend admitted through a module-private brand over a privately snapshotted, fully materialized and behaviorally isolated tree can clear it; trusted operations use private prototype copies, module-lexical helpers, and captured scalar ABI semantics rather than producer-reachable prototypes, class properties, or generated tables. The resolved mount capability authoritatively sets or clears the bit instead of trusting raw backend flags. The kernel can compute a set-ID transition proposal from retained target metadata, but exec does not commit that proposal to process credentials yet. | -| `chmod()` / `chown()` / `lchown()` | Partial | VFS metadata updates. `chown()` follows the final symlink; `lchown()` changes the link itself, including dangling links. Ownership calls preserve either unchanged-ID sentinel and validate the selected object and authorization before delegation. Root may select arbitrary IDs; an unprivileged owner must preserve its user ID and may select its effective GID or Kandelo's synthesized supplementary/real GID. On metadata-backed SharedFS and Node regular files, successful calls clear S_ISUID and S_ISGID when any execute bit is set while leaving non-executable files, directories, and symlink targets selected by `lchown()` unchanged. Node host-backed changes stay in virtual metadata; browser memory-backed mounts store them in the VFS. OPFS has neither symlinks nor ownership metadata, so its existing ownership operations are no-ops. Arbitrary supplementary-group lists remain unsupported. | +| `chmod()` / `chown()` / `lchown()` | Partial | VFS metadata updates. `chown()` follows the final symlink; `lchown()` changes the link itself, including dangling links. Ownership calls preserve either unchanged-ID sentinel and validate the selected object and authorization before delegation. Root may select arbitrary IDs; an unprivileged owner must preserve its user ID and may select its effective GID or any authoritative supplementary group. On metadata-backed SharedFS and Node regular files, every successful ownership call clears S_ISUID and S_ISGID, regardless of execute bits, while directories and symlinks retain their modes. Node host-backed changes stay in virtual metadata; browser memory-backed mounts store them in the VFS. OPFS has neither symlinks nor ownership metadata, so its existing ownership operations are no-ops. | | `access()` | Partial | Resolves the pathname component-wise and checks traversal plus target permissions with real credentials. `faccessat(..., AT_EACCESS)` selects effective credentials. Both use effective GID and the process's complete supplementary-group membership for group checks. | | `realpath()` | Full | Uses the global component walker against cwd, including mount crossings and relative or absolute symlinks; `missing/..` fails instead of being collapsed lexically, trailing slash requires a directory, and more than 40 symlinks returns ELOOP. | | `symlink()` / `readlink()` | Partial | Host-delegated. Symlink target stored as-is, linkpath resolved. | diff --git a/examples/chown_sentinel_test.c b/examples/chown_sentinel_test.c index fda47fb62c..c0e444d5d2 100644 --- a/examples/chown_sentinel_test.c +++ b/examples/chown_sentinel_test.c @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -61,6 +62,105 @@ static int expect_path_mode_ids(const char *path, mode_t mode, uid_t uid, return 0; } +static int expect_path_fd_mode(const char *path, int fd, mode_t mode, + const char *step) +{ + struct stat path_st; + struct stat fd_st; + if (stat(path, &path_st) != 0 || fstat(fd, &fd_st) != 0) { + perror(step); + return -1; + } + if ((path_st.st_mode & 07777) != mode || + (fd_st.st_mode & 07777) != mode) { + fprintf(stderr, + "%s: got path mode=%04o fd mode=%04o, expected mode=%04o\n", + step, (unsigned)(path_st.st_mode & 07777), + (unsigned)(fd_st.st_mode & 07777), (unsigned)mode); + return -1; + } + return 0; +} + +static int arm_setid(const char *path, int fd, const char *step) +{ + if (fchmod(fd, 06755) != 0) { + perror(step); + return -1; + } + return expect_path_fd_mode(path, fd, 06755, step); +} + +static int run_setid_mutation_matrix(void) +{ + const char *path = "/tmp/setid-mutation-matrix"; + const char byte = 'x'; + int fd = open(path, O_CREAT | O_RDWR | O_TRUNC, 0600); + if (fd < 0) + return 46; + + if (arm_setid(path, fd, "arm write") != 0 || + write(fd, &byte, 1) != 1 || + expect_path_fd_mode(path, fd, 0755, "write") != 0) + return 47; + if (arm_setid(path, fd, "arm pwrite") != 0 || + pwrite(fd, &byte, 1, 0) != 1 || + expect_path_fd_mode(path, fd, 0755, "pwrite") != 0) + return 48; + + int status_flags = fcntl(fd, F_GETFL); + if (status_flags < 0 || fcntl(fd, F_SETFL, status_flags | O_APPEND) != 0 || + arm_setid(path, fd, "arm append") != 0 || + write(fd, &byte, 1) != 1 || + expect_path_fd_mode(path, fd, 0755, "append") != 0 || + fcntl(fd, F_SETFL, status_flags) != 0) + return 49; + + if (arm_setid(path, fd, "arm O_TRUNC") != 0) + return 50; + int truncate_fd = open(path, O_WRONLY | O_TRUNC); + if (truncate_fd < 0 || close(truncate_fd) != 0 || + expect_path_fd_mode(path, fd, 0755, "O_TRUNC") != 0) + return 51; + + if (pwrite(fd, &byte, 1, 0) != 1 || + arm_setid(path, fd, "arm truncate") != 0 || truncate(path, 0) != 0 || + expect_path_fd_mode(path, fd, 0755, "truncate") != 0) + return 52; + if (pwrite(fd, &byte, 1, 0) != 1 || + arm_setid(path, fd, "arm ftruncate") != 0 || ftruncate(fd, 0) != 0 || + expect_path_fd_mode(path, fd, 0755, "ftruncate") != 0) + return 53; + + if (arm_setid(path, fd, "arm chown") != 0 || + chown(path, (uid_t)-1, (gid_t)-1) != 0 || + expect_path_fd_mode(path, fd, 0755, "chown") != 0) + return 54; + if (arm_setid(path, fd, "arm fchown") != 0 || + fchown(fd, (uid_t)-1, (gid_t)-1) != 0 || + expect_path_fd_mode(path, fd, 0755, "fchown") != 0) + return 55; + if (arm_setid(path, fd, "arm lchown") != 0 || + lchown(path, (uid_t)-1, (gid_t)-1) != 0 || + expect_path_fd_mode(path, fd, 0755, "lchown") != 0) + return 56; + + if (arm_setid(path, fd, "arm zero mutations") != 0 || + write(fd, &byte, 0) != 0 || pwrite(fd, &byte, 0, 0) != 0 || + truncate(path, 0) != 0 || ftruncate(fd, 0) != 0 || + expect_path_fd_mode(path, fd, 06755, "zero mutations") != 0) + return 57; + truncate_fd = open(path, O_WRONLY | O_TRUNC); + if (truncate_fd < 0 || close(truncate_fd) != 0 || + expect_path_fd_mode(path, fd, 06755, "empty O_TRUNC") != 0) + return 58; + + close(fd); + unlink(path); + puts("SETID_MUTATION_MATRIX_PASS"); + return 0; +} + int main(void) { const char *path = "/tmp/chown-sentinel"; @@ -194,16 +294,17 @@ int main(void) if (seteuid(0) != 0) return 16; - /* Exercise _POSIX_CHOWN_RESTRICTED with Kandelo's current credential - * model: the first call uses effective uid/gid 1000; the distinct real - * gid then exercises the single synthesized supplementary group exposed - * by getgroups(). */ + /* Exercise _POSIX_CHOWN_RESTRICTED with an explicit supplementary group: + * the first call uses effective uid/gid 1000, and the second selects the + * authoritative supplementary group 2000. */ const char *restricted = "/tmp/chown-restricted"; int restricted_fd = open(restricted, O_CREAT | O_RDWR | O_TRUNC, 0644); if (restricted_fd < 0 || fchown(restricted_fd, 1000, 4000) != 0 || fchmod(restricted_fd, 06755) != 0) return 34; - if (setgid(2000) != 0 || setegid(1000) != 0 || seteuid(1000) != 0) + gid_t supplementary_group = 2000; + if (setgroups(1, &supplementary_group) != 0 || setgid(2000) != 0 || + setegid(1000) != 0 || seteuid(1000) != 0) return 35; if (chown(restricted, (uid_t)-1, 1000) != 0 || @@ -252,6 +353,10 @@ int main(void) return 44; close(restricted_fd); + int mutation_result = run_setid_mutation_matrix(); + if (mutation_result != 0) + return mutation_result; + close(fd); puts("CHOWN_SENTINEL_PASS"); return 0; diff --git a/host/src/native-positioned-write.ts b/host/src/native-positioned-write.ts index d11863776b..37f10de89b 100644 --- a/host/src/native-positioned-write.ts +++ b/host/src/native-positioned-write.ts @@ -90,9 +90,11 @@ export interface NativeBackingOpenResult { * an atomic O_CREAT|O_EXCL attempt, then an existing-only attempt. ENOENT on * the second operation means the name raced away, so the transaction retries. * - * O_TRUNC, O_APPEND, and O_NOFOLLOW stay present in both operations. Ordinary - * O_CREAT still follows a dangling final symlink by continuing the transaction - * at its target; caller-requested O_EXCL or O_NOFOLLOW never takes that path. + * Caller-selected native flags stay present in both operations. Ordinary + * O_CREAT follows a dangling final symlink by continuing the transaction at + * its target; caller-requested O_EXCL or O_NOFOLLOW never takes that path. + * Native backends deliberately omit O_TRUNC here, finish fallible route and + * metadata setup on the exact returned handle, then truncate that handle. */ export function openNativeBackingFile( nativePath: string, @@ -192,6 +194,45 @@ function nativeWriteError( return error; } +function nativeErrorCode(error: unknown): string | null { + if ( + typeof error === "object" + && error !== null + && "code" in error + && typeof error.code === "string" + && /^[A-Z][A-Z0-9_]*$/.test(error.code) + ) { + return error.code; + } + return null; +} + +function nativeCompanionError( + error: unknown, + purpose: string, +): Error & { code: string } { + const code = nativeErrorCode(error) ?? "EIO"; + const wrapped = new Error( + `${code}: cannot establish exact native ${purpose} companion`, + ) as Error & { code: string }; + wrapped.code = code; + return wrapped; +} + +const NATIVE_COMPANION_FALLBACK_CODES = new Set([ + "ENOENT", + "ENOTDIR", + "ENODEV", + "ENOSYS", + "EOPNOTSUPP", + "ENOTSUP", +]); + +function nativeCompanionStrategyUnavailable(error: unknown): boolean { + const code = nativeErrorCode(error); + return code !== null && NATIVE_COMPANION_FALLBACK_CODES.has(code); +} + function sameNativeFile(primary: number, candidate: number): boolean { const primaryStat = fs.fstatSync(primary, { bigint: true }); const candidateStat = fs.fstatSync(candidate, { bigint: true }); @@ -201,6 +242,54 @@ function sameNativeFile(primary: number, candidate: number): boolean { ); } +function openExactNativeCompanion( + primary: number, + nativePath: string, + nativeFlags: number, + purpose: string, +): number { + const candidates = process.platform === "linux" + ? [ + { path: `/proc/self/fd/${primary}`, mayFallback: true }, + { path: nativePath, mayFallback: false }, + ] + : [{ path: nativePath, mayFallback: false }]; + + for (const candidate of candidates) { + let companion: number; + try { + companion = fs.openSync(candidate.path, nativeFlags); + } catch (error) { + if (candidate.mayFallback && nativeCompanionStrategyUnavailable(error)) { + continue; + } + throw nativeCompanionError(error, purpose); + } + + try { + if (!sameNativeFile(primary, companion)) { + throw nativeWriteError( + "EIO", + `native ${purpose} companion does not name the opened file`, + ); + } + return companion; + } catch (error) { + try { + fs.closeSync(companion); + } catch { + // Preserve the identity failure. + } + throw nativeCompanionError(error, purpose); + } + } + + throw nativeWriteError( + "EOPNOTSUPP", + `no native ${purpose} companion strategy is available`, + ); +} + /** * Own both native routes required by one writable regular-file handle. * @@ -211,11 +300,13 @@ function sameNativeFile(primary: number, candidate: number): boolean { * * Linux `/proc/self/fd` acquires the companion from the live inode. Other * hosts reopen the pathname immediately and accept it only after dev+ino - * identity verification. If no exact companion can be established, open - * fails honestly with EOPNOTSUPP instead of deferring a broken transition. + * identity verification. A missing live-fd strategy may fall back to the + * path; authoritative permission, filesystem, and resource errors retain + * their native errno instead of being mislabeled as unsupported. */ export class NativePositionedWriteHandles { private readonly routes = new Map(); + private readonly readOnlyTruncates = new Map(); register(primary: number, linuxFlags: number, nativePath: string): void { const access = nativeWriteAccess(linuxFlags); @@ -227,50 +318,43 @@ export class NativePositionedWriteHandles { const primaryIsAppend = (linuxFlags & LINUX_O_APPEND) !== 0; const companionFlags = access | (primaryIsAppend ? 0 : fs.constants.O_APPEND); - const candidates = process.platform === "linux" - ? [`/proc/self/fd/${primary}`, nativePath] - : [nativePath]; - let lastFailure: unknown; - - for (const candidatePath of candidates) { - let companion: number; - try { - companion = fs.openSync(candidatePath, companionFlags); - } catch (error) { - lastFailure = error; - continue; - } - - try { - if (!sameNativeFile(primary, companion)) { - throw nativeWriteError( - "EIO", - "native write companion does not name the opened file", - ); - } - } catch (error) { - try { - fs.closeSync(companion); - } catch { - // Preserve the identity failure. - } - lastFailure = error; - continue; - } - - this.routes.set(primary, { - companion, - append: primaryIsAppend ? primary : companion, - positioned: primaryIsAppend ? companion : primary, - }); - return; - } + const companion = openExactNativeCompanion( + primary, + nativePath, + companionFlags, + "write-route", + ); + this.routes.set(primary, { + companion, + append: primaryIsAppend ? primary : companion, + positioned: primaryIsAppend ? companion : primary, + }); + } - throw nativeWriteError( - "EOPNOTSUPP", - "cannot establish exact append and positioned routes for this file", - lastFailure, + /** + * Return an exact writable handle for deferred O_TRUNC. + * + * WHY: Linux accepts O_RDONLY | O_TRUNC, but the read-only descriptor cannot + * be passed to ftruncate after native O_TRUNC is deferred. Keep the primary + * descriptor read-only and retain a verified companion until close, so all + * fallible route setup precedes mutation and no pathname race can select a + * different inode for truncation. + */ + forTruncate(primary: number, linuxFlags: number, nativePath: string): number { + if (nativeWriteAccess(linuxFlags) !== null) return primary; + + const existing = this.readOnlyTruncates.get(primary); + if (existing !== undefined) return existing; + if (!fs.fstatSync(primary, { bigint: true }).isFile()) return primary; + + const companion = openExactNativeCompanion( + primary, + nativePath, + fs.constants.O_WRONLY, + "read-only truncate", ); + this.readOnlyTruncates.set(primary, companion); + return companion; } forWrite(primary: number, positioned: boolean): number { @@ -305,6 +389,8 @@ export class NativePositionedWriteHandles { close(primary: number): void { const route = this.routes.get(primary); this.routes.delete(primary); + const truncateCompanion = this.readOnlyTruncates.get(primary); + this.readOnlyTruncates.delete(primary); let closeError: unknown; if (route !== undefined) { @@ -314,6 +400,13 @@ export class NativePositionedWriteHandles { closeError = error; } } + if (truncateCompanion !== undefined) { + try { + fs.closeSync(truncateCompanion); + } catch (error) { + closeError ??= error; + } + } try { fs.closeSync(primary); } catch (error) { diff --git a/host/src/platform/native-metadata.ts b/host/src/platform/native-metadata.ts index badf313be1..95aed4f59e 100644 --- a/host/src/platform/native-metadata.ts +++ b/host/src/platform/native-metadata.ts @@ -43,9 +43,25 @@ function checkedMilliseconds(valueNs: bigint, field: string): number { } const S_IFMT = FILE_MODES.S_IFMT; +const S_IFREG = FILE_MODES.S_IFREG; const S_IFDIR = FILE_MODES.S_IFDIR; const S_IFLNK = FILE_MODES.S_IFLNK; +export function modeAfterRegularFileMutation( + mode: number, + kind: "content" | "ownership", +): number { + if ((mode & S_IFMT) !== S_IFREG) return mode; + // WHY: Kandelo has no System V mandatory-locking interpretation for a + // non-executable S_ISGID bit. POSIX permits clearing both bits after content + // mutation, so both audited mutation kinds deliberately share one policy. + switch (kind) { + case "content": + case "ownership": + return mode & ~SET_ID_BITS; + } +} + /** * Windows has no POSIX permission model: `fs.statSync` reports every entry as * `0o666` (writable) or `0o444` (read-only), with no owner/group/other split @@ -160,9 +176,10 @@ export class NativeMetadataOverlay { const metadata = this.metadataFor(s); if (uid !== UID_GID_UNCHANGED) metadata.uid = uid; if (gid !== UID_GID_UNCHANGED) metadata.gid = gid; - const mode = metadata.mode ?? (checkedNumber(s.mode, "st_mode") & MODE_CHANGE_MASK); - if (s.isFile() && (mode & EXECUTE_BITS) !== 0) { - metadata.mode = mode & ~SET_ID_BITS; + const mode = this.modeFor(s, metadata); + const nextMode = modeAfterRegularFileMutation(mode, "ownership"); + if (nextMode !== mode) { + metadata.mode = nextMode & MODE_CHANGE_MASK; } metadata.ctimeMs = Date.now(); } @@ -186,9 +203,24 @@ export class NativeMetadataOverlay { } noteNativeContentChange(s: BigIntStats): void { - const metadata = this.entries.get(this.key(s)); - if (metadata === undefined) return; - this.clearTimeOverrides(metadata); + this.prepareNativeContentChange(s)(); + } + + /** + * Finish every fallible identity/mode allocation before native bytes change. + * The returned synchronous commit only mutates an already-owned metadata + * object, so an open/ftruncate failure can leave guest mode untouched. + */ + prepareNativeContentChange(s: BigIntStats): () => void { + let metadata = this.entries.get(this.key(s)); + const mode = this.modeFor(s, metadata); + const nextMode = modeAfterRegularFileMutation(mode, "content"); + if (metadata === undefined && nextMode === mode) return () => {}; + metadata ??= this.metadataFor(s); + return () => { + if (nextMode !== mode) metadata.mode = nextMode & MODE_CHANGE_MASK; + this.clearTimeOverrides(metadata); + }; } forget(s: BigIntStats): void { @@ -227,6 +259,13 @@ export class NativeMetadataOverlay { return metadata; } + private modeFor(s: BigIntStats, metadata?: VirtualMetadata): number { + const nativeMode = checkedNumber(s.mode, "st_mode"); + if (metadata?.mode === undefined) return nativeMode; + return (nativeMode & ~MODE_CHANGE_MASK) | + (metadata.mode & MODE_CHANGE_MASK); + } + private reconcileNativeTimes( metadata: VirtualMetadata, nativeAtimeMs: number, diff --git a/host/src/platform/node.ts b/host/src/platform/node.ts index 6a5fcba453..9358da1991 100644 --- a/host/src/platform/node.ts +++ b/host/src/platform/node.ts @@ -32,6 +32,7 @@ import { filesystemPathconf } from "../pathconf"; import { nativeStatfs, translateOpenFlags } from "../vfs/host-fs"; import { zeroCapacityStatfs } from "../statfs"; import { ST_NOSUID } from "../vfs/types"; +import { OPEN_FLAGS } from "../generated/abi"; import { NativeMetadataOverlay } from "./native-metadata"; const UTIME_NOW = 0x3fffffff; @@ -93,9 +94,11 @@ export class NodePlatformIO implements PlatformIO { open(path: string, flags: number, mode: number): number { const nativePath = this.rewritePath(path); + const truncate = (flags & OPEN_FLAGS.O_TRUNC) !== 0; + const nativeFlags = translateOpenFlags(flags); const { fd, created } = openNativeBackingFile( nativePath, - translateOpenFlags(flags), + truncate ? nativeFlags & ~fs.constants.O_TRUNC : nativeFlags, flags, mode, ); @@ -105,6 +108,19 @@ export class NodePlatformIO implements PlatformIO { } this.fdPositions.set(fd, 0); this.positionedWrites.register(fd, flags, nativePath); + if (!created && truncate) { + const truncateHandle = this.positionedWrites.forTruncate( + fd, + flags, + nativePath, + ); + const before = fs.fstatSync(fd, { bigint: true }); + const commit = before.size === 0n + ? null + : this.metadata.prepareNativeContentChange(before); + fs.ftruncateSync(truncateHandle, 0); + commit?.(); + } return fd; } catch (error) { this.fdPositions.delete(fd); @@ -438,10 +454,12 @@ export class NodePlatformIO implements PlatformIO { } ftruncate(handle: number, length: number): void { + const before = fs.fstatSync(handle, { bigint: true }); + const commit = before.size === BigInt(length) + ? null + : this.metadata.prepareNativeContentChange(before); fs.ftruncateSync(handle, length); - this.metadata.noteNativeContentChange( - fs.fstatSync(handle, { bigint: true }), - ); + commit?.(); } fsync(handle: number): void { diff --git a/host/src/vfs/host-fs.ts b/host/src/vfs/host-fs.ts index cedd08b6f9..4321ad0b15 100644 --- a/host/src/vfs/host-fs.ts +++ b/host/src/vfs/host-fs.ts @@ -309,9 +309,11 @@ export class HostFileSystem implements FileSystemBackend { ((flags & OPEN_FLAGS.O_CREAT) !== 0 && (flags & OPEN_FLAGS.O_EXCL) !== 0); const nativePath = this.safePath(path, !noFollowFinal); + const truncate = (flags & OPEN_FLAGS.O_TRUNC) !== 0; + const nativeFlags = translateOpenFlags(flags); const { fd, created } = openNativeBackingFile( nativePath, - translateOpenFlags(flags), + truncate ? nativeFlags & ~fs.constants.O_TRUNC : nativeFlags, flags, mode, ); @@ -321,6 +323,19 @@ export class HostFileSystem implements FileSystemBackend { } this.fdPositions.set(fd, 0); this.positionedWrites.register(fd, flags, nativePath); + if (!created && truncate) { + const truncateHandle = this.positionedWrites.forTruncate( + fd, + flags, + nativePath, + ); + const before = fs.fstatSync(fd, { bigint: true }); + const commit = before.size === 0n + ? null + : this.metadata.prepareNativeContentChange(before); + fs.ftruncateSync(truncateHandle, 0); + commit?.(); + } return fd; } catch (error) { this.fdPositions.delete(fd); @@ -542,10 +557,12 @@ export class HostFileSystem implements FileSystemBackend { } ftruncate(handle: number, length: number): void { + const before = fs.fstatSync(handle, { bigint: true }); + const commit = before.size === BigInt(length) + ? null + : this.metadata.prepareNativeContentChange(before); fs.ftruncateSync(handle, length); - this.metadata.noteNativeContentChange( - fs.fstatSync(handle, { bigint: true }), - ); + commit?.(); } fsync(handle: number): void { diff --git a/host/src/vfs/sharedfs-vendor.ts b/host/src/vfs/sharedfs-vendor.ts index b6c05dbc0b..9d72d863ee 100644 --- a/host/src/vfs/sharedfs-vendor.ts +++ b/host/src/vfs/sharedfs-vendor.ts @@ -43,9 +43,23 @@ export const S_IFLNK = 0xa000; export const S_IFMT = 0xf000; const S_ISUID = 0o4000; const S_ISGID = 0o2000; -const EXECUTE_BITS = 0o111; const UID_GID_UNCHANGED = 0xffffffff; +function modeAfterRegularFileMutation( + mode: number, + kind: "content" | "ownership", +): number { + if ((mode & S_IFMT) !== S_IFREG) return mode; + // WHY: Kandelo does not implement System V mandatory locking for a + // non-executable S_ISGID bit. Keep the mutation kind explicit for audit + // call sites while deliberately applying the same clear-both policy. + switch (kind) { + case "content": + case "ownership": + return mode & ~(S_ISUID | S_ISGID); + } +} + // Open flags export const O_RDONLY = 0x0000; export const O_WRONLY = 0x0001; @@ -126,6 +140,9 @@ const INO_DATA_SEQUENCE = 120; // u32, incremented after explicit data mutation // 124-127 reserved for future fields (flags, xattrs, etc.) // FD entry layout +const FD_FREE = 0; +const FD_PUBLISHED = 1; +const FD_RESERVED = 2; const FD_INO = 4; const FD_OFFSET = 8; // uint64 const FD_FLAGS = 16; @@ -1324,6 +1341,16 @@ export class SharedFS { return totalWritten; } + private invalidateSetIdAfterRegularFileMutation( + ino: number, + kind: "content" | "ownership", + ): void { + const inoOff = this.inodeOffset(ino); + const mode = this.r32(inoOff + INO_MODE); + const nextMode = modeAfterRegularFileMutation(mode, kind); + if (nextMode !== mode) this.w32(inoOff + INO_MODE, nextMode); + } + private zeroInodeRange(ino: number, start: number, end: number): void { while (start < end) { const fileBlock = Math.floor(start / BLOCK_SIZE); @@ -2261,24 +2288,79 @@ export class SharedFS { // ── FD table ───────────────────────────────────────────────────── - private fdAlloc(ino: number, flags: number, isDir: boolean): number { + /** + * Claim the lowest free slot without exposing a usable descriptor. + * fdGet() recognizes only FD_PUBLISHED, while the atomic reserved state + * prevents another SharedFS worker from selecting the same number. + */ + private fdReserve(): number { for (let i = 0; i < MAX_FDS; i++) { const base = FD_TABLE_OFFSET + i * FD_ENTRY_SIZE; const idx = base >> 2; - const old = Atomics.compareExchange(this.i32, idx, 0, 1); - if (old === 0) { - this.w32(base + FD_INO, ino); - this.w64(base + FD_OFFSET, 0); - this.w32(base + FD_FLAGS, flags); - this.w32(base + FD_IS_DIR, isDir ? 1 : 0); - if (!this.inodeAddOpenRef(ino)) { - Atomics.store(this.i32, idx, 0); - return ENOENT; + const old = Atomics.compareExchange( + this.i32, + idx, + FD_FREE, + FD_RESERVED, + ); + if (old === FD_FREE) return i; + } + return EMFILE; + } + + private fdPrepare( + fd: number, + ino: number, + flags: number, + isDir: boolean, + ): void { + const base = FD_TABLE_OFFSET + fd * FD_ENTRY_SIZE; + if (Atomics.load(this.i32, base >> 2) !== FD_RESERVED) { + throw new SFSError(EIO); + } + this.w32(base + FD_INO, ino); + this.w64(base + FD_OFFSET, 0); + this.w32(base + FD_FLAGS, flags); + this.w32(base + FD_IS_DIR, isDir ? 1 : 0); + } + + private fdPublish(fd: number): void { + const base = FD_TABLE_OFFSET + fd * FD_ENTRY_SIZE; + Atomics.store(this.i32, base >> 2, FD_PUBLISHED); + } + + private fdReleaseReservation(fd: number): void { + const base = FD_TABLE_OFFSET + fd * FD_ENTRY_SIZE; + const old = Atomics.compareExchange( + this.i32, + base >> 2, + FD_RESERVED, + FD_FREE, + ); + if (old !== FD_RESERVED) throw new SFSError(EIO); + } + + private fdAlloc(ino: number, flags: number, isDir: boolean): number { + const fd = this.fdReserve(); + if (fd < 0) return fd; + let referenced = false; + let published = false; + try { + this.fdPrepare(fd, ino, flags, isDir); + if (!this.inodeAddOpenRef(ino)) return ENOENT; + referenced = true; + this.fdPublish(fd); + published = true; + return fd; + } finally { + if (!published) { + try { + if (referenced) this.inodeDropOpenRef(ino); + } finally { + this.fdReleaseReservation(fd); } - return i; } } - return EMFILE; } private fdGet(fd: number): { @@ -2290,8 +2372,8 @@ export class SharedFS { } | null { if (fd < 0 || fd >= MAX_FDS) return null; const base = FD_TABLE_OFFSET + fd * FD_ENTRY_SIZE; - const inUse = Atomics.load(this.i32, base >> 2); - if (!inUse) return null; + const state = Atomics.load(this.i32, base >> 2); + if (state !== FD_PUBLISHED) return null; return { base, ino: this.r32(base + FD_INO), @@ -2304,7 +2386,7 @@ export class SharedFS { private fdFree(fd: number): void { if (fd >= 0 && fd < MAX_FDS) { const base = FD_TABLE_OFFSET + fd * FD_ENTRY_SIZE; - Atomics.store(this.i32, base >> 2, 0); + Atomics.store(this.i32, base >> 2, FD_FREE); } } @@ -2599,77 +2681,105 @@ export class SharedFS { const accMode = flags & O_ACCMODE; const creating = (flags & O_CREAT) !== 0; const exclusive = (flags & O_EXCL) !== 0; + const fd = this.fdReserve(); + if (fd < 0) throw new SFSError(fd); + let referencedIno: number | null = null; + let published = false; - if (creating && exclusive) { - const existing = this.pathResolve(path, false); - if (existing >= 0) throw new SFSError(EEXIST); - if (existing !== ENOENT) throw new SFSError(existing); - } - - let ino = this.pathResolve(path, true); + try { + if (creating && exclusive) { + const existing = this.pathResolve(path, false); + if (existing >= 0) throw new SFSError(EEXIST); + if (existing !== ENOENT) throw new SFSError(existing); + } - if (ino < 0 && ino === ENOENT && creating) { - // Create the file - const { parentIno, name } = this.pathResolveParent(path); - this.inodeWriteLock(parentIno); - try { - // Double-check it doesn't exist now - const nameBytes = encoder.encode(name); - const existing = this.dirLookup(parentIno, nameBytes); - if (existing >= 0) { - if (exclusive) throw new SFSError(EEXIST); - ino = existing; - } else { - const newIno = this.inodeAlloc(); - if (newIno < 0) throw new SFSError(ENOSPC); + let ino = this.pathResolve(path, true); + let created = false; - const newOff = this.inodeOffset(newIno); - this.w32(newOff + INO_MODE, S_IFREG | (createMode & 0o7777)); - this.w32(newOff + INO_LINK_COUNT, 1); - this.w64(newOff + INO_SIZE, 0); - const now = Date.now(); - this.w64(newOff + INO_ATIME, now); - this.w64(newOff + INO_MTIME, now); - this.w64(newOff + INO_CTIME, now); - - const rc = this.dirAddEntry(parentIno, nameBytes, newIno); - if (rc < 0) { - this.inodeFree(newIno); - throw new SFSError(rc); + if (ino < 0 && ino === ENOENT && creating) { + // Create the file + const { parentIno, name } = this.pathResolveParent(path); + this.inodeWriteLock(parentIno); + try { + // Double-check it doesn't exist now + const nameBytes = encoder.encode(name); + const existing = this.dirLookup(parentIno, nameBytes); + if (existing >= 0) { + if (exclusive) throw new SFSError(EEXIST); + ino = existing; + } else { + const newIno = this.inodeAlloc(); + if (newIno < 0) throw new SFSError(ENOSPC); + + const newOff = this.inodeOffset(newIno); + this.w32(newOff + INO_MODE, S_IFREG | (createMode & 0o7777)); + this.w32(newOff + INO_LINK_COUNT, 1); + this.w64(newOff + INO_SIZE, 0); + const now = Date.now(); + this.w64(newOff + INO_ATIME, now); + this.w64(newOff + INO_MTIME, now); + this.w64(newOff + INO_CTIME, now); + + const rc = this.dirAddEntry(parentIno, nameBytes, newIno); + if (rc < 0) { + this.inodeFree(newIno); + throw new SFSError(rc); + } + ino = newIno; + created = true; } - ino = newIno; + } finally { + this.inodeWriteUnlock(parentIno); } - } finally { - this.inodeWriteUnlock(parentIno); } - } - if (ino < 0) throw new SFSError(ino); + if (ino < 0) throw new SFSError(ino); - const inoOff = this.inodeOffset(ino); - const mode = this.r32(inoOff + INO_MODE); + const inoOff = this.inodeOffset(ino); + const mode = this.r32(inoOff + INO_MODE); - if ((mode & S_IFMT) === S_IFDIR) { - if (accMode !== O_RDONLY) throw new SFSError(EISDIR); - } + if ((mode & S_IFMT) === S_IFDIR) { + if (accMode !== O_RDONLY) throw new SFSError(EISDIR); + } - // O_DIRECTORY: reject non-directories - if (flags & O_DIRECTORY && (mode & S_IFMT) !== S_IFDIR) { - throw new SFSError(ENOTDIR); - } + // O_DIRECTORY: reject non-directories + if (flags & O_DIRECTORY && (mode & S_IFMT) !== S_IFDIR) { + throw new SFSError(ENOTDIR); + } - // Truncate if requested - if (flags & O_TRUNC) { - if ((mode & S_IFMT) === S_IFDIR) throw new SFSError(EISDIR); - this.inodeWriteLock(ino); - this.inodeTruncate(ino, 0, true); - this.inodeWriteUnlock(ino); - } + this.fdPrepare(fd, ino, flags, false); + if (!this.inodeAddOpenRef(ino)) throw new SFSError(ENOENT); + referencedIno = ino; - const fd = this.fdAlloc(ino, flags, false); - if (fd < 0) throw new SFSError(fd); + // Truncate if requested + if (flags & O_TRUNC) { + if ((mode & S_IFMT) === S_IFDIR) throw new SFSError(EISDIR); + this.inodeWriteLock(ino); + try { + const sizeChanged = this.r64(inoOff + INO_SIZE) !== 0; + this.inodeTruncate(ino, 0, true); + if (!created && sizeChanged) { + this.invalidateSetIdAfterRegularFileMutation(ino, "content"); + } + } finally { + this.inodeWriteUnlock(ino); + } + } - return fd; + this.fdPublish(fd); + published = true; + return fd; + } finally { + if (!published) { + try { + if (referencedIno !== null) { + this.inodeDropOpenRef(referencedIno); + } + } finally { + this.fdReleaseReservation(fd); + } + } + } } close(fd: number): void { @@ -2751,6 +2861,9 @@ export class SharedFS { data.length, ); if (nwritten < 0) return nwritten; + if (nwritten > 0) { + this.invalidateSetIdAfterRegularFileMutation(entry.ino, "content"); + } // Update offset const base = FD_TABLE_OFFSET + fd * FD_ENTRY_SIZE; this.w64(base + FD_OFFSET, offset + nwritten); @@ -2812,6 +2925,9 @@ export class SharedFS { writable.length, ); if (nwritten < 0) throw new SFSError(nwritten); + if (nwritten > 0) { + this.invalidateSetIdAfterRegularFileMutation(entry.ino, "content"); + } const base = FD_TABLE_OFFSET + fd * FD_ENTRY_SIZE; const end = offset + nwritten; this.w64(base + FD_OFFSET, end); @@ -2835,7 +2951,16 @@ export class SharedFS { if (offset > MAX_FILE_SIZE || data.length > MAX_FILE_SIZE - offset) { throw new SFSError(EFBIG); } - return this.inodeWriteData(entry.ino, offset, data, data.length); + const nwritten = this.inodeWriteData( + entry.ino, + offset, + data, + data.length, + ); + if (nwritten > 0) { + this.invalidateSetIdAfterRegularFileMutation(entry.ino, "content"); + } + return nwritten; } finally { this.inodeWriteUnlock(entry.ino); } @@ -2873,7 +2998,13 @@ export class SharedFS { this.inodeWriteLock(entry.ino); try { + const sizeChanged = this.r64( + this.inodeOffset(entry.ino) + INO_SIZE, + ) !== length; this.inodeTruncate(entry.ino, length, true); + if (sizeChanged) { + this.invalidateSetIdAfterRegularFileMutation(entry.ino, "content"); + } } finally { this.inodeWriteUnlock(entry.ino); } @@ -3357,10 +3488,7 @@ export class SharedFS { if (uid !== UID_GID_UNCHANGED) this.w32(off + INO_UID, uid); if (gid !== UID_GID_UNCHANGED) this.w32(off + INO_GID, gid); - const mode = this.r32(off + INO_MODE); - if ((mode & S_IFMT) === S_IFREG && (mode & EXECUTE_BITS) !== 0) { - this.w32(off + INO_MODE, mode & ~(S_ISUID | S_ISGID)); - } + this.invalidateSetIdAfterRegularFileMutation(ino, "ownership"); this.w64(off + INO_CTIME, Date.now()); } diff --git a/host/test/chown-sentinel.test.ts b/host/test/chown-sentinel.test.ts index 10723ae315..4dc7ca4f6d 100644 --- a/host/test/chown-sentinel.test.ts +++ b/host/test/chown-sentinel.test.ts @@ -20,6 +20,7 @@ describe("chown ownership, authorization, and set-ID semantics", () => { }); expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("SETID_MUTATION_MATRIX_PASS"); expect(result.stdout).toContain("CHOWN_SENTINEL_PASS"); expect(result.stderr).toBe(""); }, diff --git a/host/test/fixtures/sharedfs-fd-reservation-worker.ts b/host/test/fixtures/sharedfs-fd-reservation-worker.ts new file mode 100644 index 0000000000..a556316689 --- /dev/null +++ b/host/test/fixtures/sharedfs-fd-reservation-worker.ts @@ -0,0 +1,37 @@ +import { parentPort, workerData } from "node:worker_threads"; +import { MemoryFileSystem } from "../../src/vfs/memory-fs"; +import { O_RDONLY } from "../../src/vfs/sharedfs-vendor"; + +const { fsBuffer, controlBuffer, slot } = workerData as { + fsBuffer: SharedArrayBuffer; + controlBuffer: SharedArrayBuffer; + slot: number; +}; +const control = new Int32Array(controlBuffer); +const fs = MemoryFileSystem.fromExisting(fsBuffer); + +while (Atomics.load(control, 0) === 0) Atomics.wait(control, 0, 0); + +let fd: number | null = null; +try { + fd = fs.open("/reservation-race", O_RDONLY, 0); + Atomics.store(control, 3 + slot, fd); + Atomics.add(control, 1, 1); + Atomics.notify(control, 1, 1); + while (Atomics.load(control, 2) === 0) Atomics.wait(control, 2, 0); + fs.close(fd); + fd = null; + parentPort!.postMessage({ ok: true }); +} catch (error) { + if (fd !== null) { + try { + fs.close(fd); + } catch { + // Preserve the reservation/open failure. + } + } + parentPort!.postMessage({ + ok: false, + error: error instanceof Error ? error.stack ?? error.message : String(error), + }); +} diff --git a/host/test/native-open-create-race.test.ts b/host/test/native-open-create-race.test.ts index 2798dbebfd..dd238b10ae 100644 --- a/host/test/native-open-create-race.test.ts +++ b/host/test/native-open-create-race.test.ts @@ -2,13 +2,45 @@ import { afterEach, describe, expect, it, vi } from "vitest"; const openRace = vi.hoisted(() => ({ armedPath: null as string | null, + replacement: null as null | { + path: string; + retainedPath: string; + contents: string; + nativeMode: number; + }, + afterReplacement: null as null | (() => void), + failNextFstat: false, nativeMode: 0o640, + captureCompanionOpens: false, + captureDescriptors: false, + companionAttempts: [] as Array<"proc" | "path">, + companionFailures: [] as Array<{ + strategy: "proc" | "path"; + code: "EACCES" | "EROFS" | "EMFILE" | "ENFILE" | "ENOENT"; + errno: number; + }>, + companionRedirectPath: null as string | null, + openedDescriptors: [] as number[], })); vi.mock("node:fs", async (importOriginal) => { const actual = await importOriginal(); const openSync = ((...args: Parameters) => { const candidate = args[0]; + if ( + openRace.replacement !== null + && candidate === openRace.replacement.path + ) { + const replacement = openRace.replacement; + openRace.replacement = null; + actual.renameSync(replacement.path, replacement.retainedPath); + actual.writeFileSync(replacement.path, replacement.contents, { + flag: "wx", + mode: replacement.nativeMode, + }); + actual.chmodSync(replacement.path, replacement.nativeMode); + openRace.afterReplacement?.(); + } if ( openRace.armedPath !== null && candidate === openRace.armedPath @@ -20,16 +52,57 @@ vi.mock("node:fs", async (importOriginal) => { mode: openRace.nativeMode, }); } - return actual.openSync(...args); + const numericFlags = typeof args[1] === "number" ? args[1] : 0; + const writeOnly = (numericFlags & 0x3) === 0x1; + if (openRace.captureCompanionOpens && writeOnly) { + const candidateText = String(candidate); + const strategy = candidateText.startsWith("/proc/self/fd/") + ? "proc" + : "path"; + openRace.companionAttempts.push(strategy); + const failureIndex = openRace.companionFailures.findIndex( + (failure) => failure.strategy === strategy, + ); + if (failureIndex >= 0) { + const [failure] = openRace.companionFailures.splice(failureIndex, 1); + throw Object.assign( + new Error(`${failure.code}: injected companion open failure`), + { + code: failure.code, + errno: failure.errno, + syscall: "open", + path: candidateText, + }, + ); + } + if (openRace.companionRedirectPath !== null) { + const fd = actual.openSync(openRace.companionRedirectPath, numericFlags); + if (openRace.captureDescriptors) openRace.openedDescriptors.push(fd); + return fd; + } + } + const fd = actual.openSync(...args); + if (openRace.captureDescriptors) openRace.openedDescriptors.push(fd); + return fd; }) as typeof actual.openSync; return { ...actual, + fstatSync: ((...args: Parameters) => { + if (openRace.failNextFstat) { + openRace.failNextFstat = false; + throw Object.assign(new Error("injected exact-handle stat failure"), { + code: "EIO", + }); + } + return actual.fstatSync(...args); + }) as typeof actual.fstatSync, openSync, }; }); import { + fstatSync, linkSync, lstatSync, mkdtempSync, @@ -38,10 +111,13 @@ import { rmSync, statSync, symlinkSync, + writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { NodePlatformIO } from "../src/platform/node"; +import { NativePositionedWriteHandles } from "../src/native-positioned-write"; +import { NativeMetadataOverlay } from "../src/platform/native-metadata"; import type { StatResult } from "../src/types"; import { HostFileSystem } from "../src/vfs/host-fs"; @@ -55,9 +131,18 @@ const PERMISSION_MASK = 0o777; interface OpenBackend { open(path: string, flags: number, mode: number): number; close(handle: number): number; + chmod(path: string, mode: number): void; + chown(path: string, uid: number, gid: number): void; + ftruncate(handle: number, length: number): void; fstat(handle: number): StatResult; stat(path: string): StatResult; lstat(path: string): StatResult; + write( + handle: number, + buffer: Uint8Array, + offset: number | null, + length: number, + ): number; } interface BackendCase { @@ -72,6 +157,16 @@ const roots: string[] = []; afterEach(() => { openRace.armedPath = null; + openRace.replacement = null; + openRace.afterReplacement = null; + openRace.failNextFstat = false; + openRace.captureCompanionOpens = false; + openRace.captureDescriptors = false; + openRace.companionAttempts.length = 0; + openRace.companionFailures.length = 0; + openRace.companionRedirectPath = null; + openRace.openedDescriptors.length = 0; + vi.restoreAllMocks(); while (roots.length > 0) { rmSync(roots.pop()!, { recursive: true, force: true }); } @@ -92,6 +187,80 @@ function makeRoot(prefix: string): string { return root; } +function withProcessPlatform(platform: NodeJS.Platform, fn: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(process, "platform"); + if (descriptor === undefined) throw new Error("process.platform missing"); + Object.defineProperty(process, "platform", { + ...descriptor, + value: platform, + }); + try { + return fn(); + } finally { + Object.defineProperty(process, "platform", descriptor); + } +} + +const COMPANION_ERRNOS = [ + ["EACCES", -13], + ["EROFS", -30], + ["EMFILE", -24], + ["ENFILE", -23], +] as const; + +function expectDescriptorsClosed(descriptors: readonly number[]): void { + for (const descriptor of descriptors) { + expect(() => fstatSync(descriptor)).toThrow(/EBADF|bad file descriptor/i); + } +} + +function expectReadOnlyTruncateFailure( + c: BackendCase, + code: string, + expectedAttempts: ReadonlyArray<"proc" | "path">, +): void { + const contents = "retain bytes after companion failure"; + writeFileSync(c.nativePath, contents); + c.backend.chown(c.guestPath, 1234, 5678); + c.backend.chmod(c.guestPath, 0o6755); + openRace.captureDescriptors = true; + + let returnedHandle: number | null = null; + let failure: unknown; + try { + returnedHandle = c.backend.open(c.guestPath, O_TRUNC, 0); + } catch (error) { + failure = error; + } + if (returnedHandle !== null) c.backend.close(returnedHandle); + const transactionDescriptors = [...openRace.openedDescriptors]; + openRace.captureDescriptors = false; + + expect(failure).toBeInstanceOf(Error); + expect(failure).toMatchObject({ code }); + expect(failure).not.toHaveProperty("cause"); + expect(failure).not.toHaveProperty("path"); + expect(failure).not.toHaveProperty("syscall"); + expect(openRace.companionAttempts).toEqual(expectedAttempts); + expect(readFileSync(c.nativePath, "utf8")).toBe(contents); + expect(c.backend.stat(c.guestPath)).toMatchObject({ + size: contents.length, + uid: 1234, + gid: 5678, + }); + expect(c.backend.stat(c.guestPath).mode & 0o7777).toBe(0o6755); + expectDescriptorsClosed(transactionDescriptors); + + const probe = c.backend.open(c.guestPath, 0, 0); + try { + expect(() => + c.backend.write(probe, new Uint8Array([0x78]), null, 1) + ).toThrow(/EBADF|bad file descriptor|invalid argument/i); + } finally { + c.backend.close(probe); + } +} + const backendFactories: Array<[string, () => BackendCase]> = [ [ "HostFileSystem", @@ -159,6 +328,235 @@ describe.each(backendFactories)("%s O_CREAT transaction", (_name, makeCase) => { expect(readFileSync(c.nativePath)).toHaveLength(0); }); + it("preserves expected missing-path behavior with and without O_CREAT", () => { + const c = makeCase(); + expect(() => c.backend.open(c.guestPath, O_RDWR | O_TRUNC, 0)) + .toThrow(/ENOENT/); + + const fd = c.backend.open(c.guestPath, O_RDWR | O_CREAT | O_TRUNC, 0o6755); + try { + expect(c.backend.fstat(fd).mode & 0o7777).toBe(0o6755); + } finally { + c.backend.close(fd); + } + expect(readFileSync(c.nativePath)).toHaveLength(0); + expect(c.backend.stat(c.guestPath).mode & 0o7777).toBe(0o6755); + }); + + it("invalidates only the non-empty inode selected by the open race", () => { + const c = makeCase(); + const retainedPath = c.nativeSibling("retained-nonempty"); + const retainedGuestPath = c.guestSibling("retained-nonempty"); + writeFileSync(c.nativePath, "original"); + c.backend.chmod(c.guestPath, 0o6755); + openRace.replacement = { + path: c.nativePath, + retainedPath, + contents: "replacement", + nativeMode: 0o6755, + }; + openRace.afterReplacement = () => c.backend.chmod(c.guestPath, 0o6755); + + const fd = c.backend.open(c.guestPath, O_RDWR | O_TRUNC, 0); + try { + expect(c.backend.fstat(fd).mode & 0o7777).toBe(0o755); + expect(c.backend.stat(c.guestPath).mode & 0o7777).toBe(0o755); + } finally { + c.backend.close(fd); + } + + expect(readFileSync(c.nativePath)).toHaveLength(0); + expect(readFileSync(retainedPath, "utf8")).toBe("original"); + expect(c.backend.stat(retainedGuestPath).mode & 0o7777).toBe(0o6755); + }); + + it("does not invalidate an already-empty inode that replaces the path before open", () => { + const c = makeCase(); + const retainedPath = c.nativeSibling("retained"); + const retainedGuestPath = c.guestSibling("retained"); + writeFileSync(c.nativePath, "original"); + c.backend.chmod(c.guestPath, 0o6755); + openRace.replacement = { + path: c.nativePath, + retainedPath, + contents: "", + nativeMode: 0o6755, + }; + openRace.afterReplacement = () => c.backend.chmod(c.guestPath, 0o6755); + + const fd = c.backend.open(c.guestPath, O_RDWR | O_TRUNC, 0); + try { + expect(c.backend.fstat(fd).mode & 0o7777).toBe(0o6755); + expect(c.backend.stat(c.guestPath).mode & 0o7777).toBe(0o6755); + } finally { + c.backend.close(fd); + } + + expect(readFileSync(c.nativePath)).toHaveLength(0); + expect(readFileSync(retainedPath, "utf8")).toBe("original"); + expect(c.backend.stat(retainedGuestPath).mode & 0o7777).toBe(0o6755); + }); + + it("does not truncate when positioned-route setup fails", () => { + const c = makeCase(); + writeFileSync(c.nativePath, "route-setup"); + c.backend.chmod(c.guestPath, 0o6755); + vi.spyOn(NativePositionedWriteHandles.prototype, "register") + .mockImplementationOnce(() => { + throw Object.assign(new Error("injected route setup failure"), { + code: "EOPNOTSUPP", + }); + }); + + expect(() => c.backend.open(c.guestPath, O_RDWR | O_TRUNC, 0)) + .toThrow(/injected route setup failure/); + expect(readFileSync(c.nativePath, "utf8")).toBe("route-setup"); + expect(c.backend.stat(c.guestPath).mode & 0o7777).toBe(0o6755); + }); + + it("does not truncate when a read-only truncate route cannot be established", () => { + const c = makeCase(); + writeFileSync(c.nativePath, "read-only-route-setup"); + c.backend.chmod(c.guestPath, 0o6755); + vi.spyOn(NativePositionedWriteHandles.prototype, "forTruncate") + .mockImplementationOnce(() => { + throw Object.assign(new Error("injected truncate route failure"), { + code: "EOPNOTSUPP", + }); + }); + + expect(() => c.backend.open(c.guestPath, O_TRUNC, 0)) + .toThrow(/injected truncate route failure/); + expect(readFileSync(c.nativePath, "utf8")).toBe("read-only-route-setup"); + expect(c.backend.stat(c.guestPath).mode & 0o7777).toBe(0o6755); + }); + + it.each(COMPANION_ERRNOS)( + "preserves %s from the authoritative fallback-path open", + (code, errno) => { + const c = makeCase(); + openRace.captureCompanionOpens = true; + openRace.companionFailures.push( + { strategy: "proc", code: "ENOENT", errno: -2 }, + { strategy: "path", code, errno }, + ); + + withProcessPlatform("linux", () => { + expectReadOnlyTruncateFailure(c, code, ["proc", "path"]); + }); + }, + ); + + it.each(COMPANION_ERRNOS)( + "does not try a second strategy after authoritative %s", + (code, errno) => { + const c = makeCase(); + openRace.captureCompanionOpens = true; + openRace.companionFailures.push({ strategy: "proc", code, errno }); + + withProcessPlatform("linux", () => { + expectReadOnlyTruncateFailure(c, code, ["proc"]); + }); + }, + ); + + it("falls back only when the live-fd strategy is unavailable", () => { + const c = makeCase(); + writeFileSync(c.nativePath, "fallback bytes"); + c.backend.chmod(c.guestPath, 0o6755); + openRace.captureCompanionOpens = true; + openRace.captureDescriptors = true; + openRace.companionFailures.push({ + strategy: "proc", + code: "ENOENT", + errno: -2, + }); + + withProcessPlatform("linux", () => { + const fd = c.backend.open(c.guestPath, O_TRUNC, 0); + try { + expect(openRace.companionAttempts).toEqual(["proc", "path"]); + expect(c.backend.fstat(fd).size).toBe(0); + expect(c.backend.fstat(fd).mode & 0o7777).toBe(0o755); + expect(() => + c.backend.write(fd, new Uint8Array([0x78]), null, 1) + ).toThrow(/EBADF|bad file descriptor|invalid argument/i); + } finally { + c.backend.close(fd); + } + }); + expectDescriptorsClosed(openRace.openedDescriptors); + }); + + it("closes a mismatched companion and primary before returning EIO", () => { + const c = makeCase(); + const otherPath = c.nativeSibling("wrong-companion"); + writeFileSync(c.nativePath, "selected bytes"); + writeFileSync(otherPath, "other bytes"); + c.backend.chown(c.guestPath, 1234, 5678); + c.backend.chmod(c.guestPath, 0o6755); + openRace.captureCompanionOpens = true; + openRace.captureDescriptors = true; + openRace.companionRedirectPath = otherPath; + + withProcessPlatform("darwin", () => { + expectReadOnlyTruncateFailure(c, "EIO", ["path"]); + }); + expect(readFileSync(otherPath, "utf8")).toBe("other bytes"); + }); + + it("propagates exact-handle stat failure before truncating", () => { + const c = makeCase(); + writeFileSync(c.nativePath, "stat-setup"); + c.backend.chmod(c.guestPath, 0o6755); + openRace.failNextFstat = true; + + expect(() => c.backend.open(c.guestPath, O_RDWR | O_TRUNC, 0)) + .toThrow(/injected exact-handle stat failure/); + expect(readFileSync(c.nativePath, "utf8")).toBe("stat-setup"); + expect(c.backend.stat(c.guestPath).mode & 0o7777).toBe(0o6755); + }); + + it("does not truncate when metadata preparation fails", () => { + const c = makeCase(); + writeFileSync(c.nativePath, "metadata-setup"); + c.backend.chmod(c.guestPath, 0o6755); + vi.spyOn(NativeMetadataOverlay.prototype, "prepareNativeContentChange") + .mockImplementationOnce(() => { + throw Object.assign(new Error("injected metadata setup failure"), { + code: "ENOMEM", + }); + }); + + expect(() => c.backend.open(c.guestPath, O_RDWR | O_TRUNC, 0)) + .toThrow(/injected metadata setup failure/); + expect(readFileSync(c.nativePath, "utf8")).toBe("metadata-setup"); + expect(c.backend.stat(c.guestPath).mode & 0o7777).toBe(0o6755); + }); + + it("does not ftruncate when exact-handle metadata setup fails", () => { + const c = makeCase(); + writeFileSync(c.nativePath, "ftruncate-setup"); + c.backend.chmod(c.guestPath, 0o6755); + const fd = c.backend.open(c.guestPath, O_RDWR, 0); + try { + vi.spyOn(NativeMetadataOverlay.prototype, "prepareNativeContentChange") + .mockImplementationOnce(() => { + throw Object.assign(new Error("injected ftruncate setup failure"), { + code: "ENOMEM", + }); + }); + + expect(() => c.backend.ftruncate(fd, 0)) + .toThrow(/injected ftruncate setup failure/); + expect(readFileSync(c.nativePath, "utf8")).toBe("ftruncate-setup"); + expect(c.backend.fstat(fd).mode & 0o7777).toBe(0o6755); + expect(c.backend.stat(c.guestPath).mode & 0o7777).toBe(0o6755); + } finally { + c.backend.close(fd); + } + }); + it("retains O_EXCL when another actor wins creation", () => { const c = makeCase(); openRace.armedPath = c.nativePath; diff --git a/host/test/node-host-vfs-only-metadata.test.ts b/host/test/node-host-vfs-only-metadata.test.ts index 758edbf760..753f87106d 100644 --- a/host/test/node-host-vfs-only-metadata.test.ts +++ b/host/test/node-host-vfs-only-metadata.test.ts @@ -15,7 +15,10 @@ import { join } from "node:path"; import { NodePlatformIO } from "../src/platform/node"; import { NativeMetadataOverlay } from "../src/platform/native-metadata"; import type { StatResult } from "../src/types"; -import { HostFileSystem } from "../src/vfs/host-fs"; +import { + createSessionOwnedHostFileSystem, + HostFileSystem, +} from "../src/vfs/host-fs"; import { VirtualPlatformIO } from "../src/vfs/vfs"; import { NodeTimeProvider } from "../src/vfs/time"; import { DEFAULT_MOUNT_SPEC } from "../src/vfs/default-mounts"; @@ -34,12 +37,21 @@ interface MetadataBackend { open(path: string, flags: number, mode: number): number; close(handle: number): number; read(handle: number, buffer: Uint8Array, offset: number | null, length: number): number; + write(handle: number, buffer: Uint8Array, offset: number | null, length: number): number; + append( + handle: number, + buffer: Uint8Array, + length: number, + limit: number | null, + ): { written: number; end: number | bigint }; seek(handle: number, offset: number, whence: number): number; fstat(handle: number): StatResult; chmod(path: string, mode: number): void; chown(path: string, uid: number, gid: number): void; + lchown(path: string, uid: number, gid: number): void; fchmod(handle: number, mode: number): void; fchown(handle: number, uid: number, gid: number): void; + ftruncate(handle: number, length: number): void; mkdir(path: string, mode: number): void; access(path: string, mode: number): void; link(existingPath: string, newPath: string): void; @@ -52,6 +64,7 @@ interface BackendCase { backend: MetadataBackend; vfsPath(name: string): string; nativePath(name: string): string; + appendSupported: boolean; } const tempRoots: string[] = []; @@ -107,9 +120,10 @@ const backendFactories: Array<[string, () => BackendCase]> = [ const root = makeTempRoot("wasm-posix-host-fs-vfs-only-"); return { root, - backend: new HostFileSystem(root), + backend: createSessionOwnedHostFileSystem(root), vfsPath: (name) => `/${name}`, nativePath: (name) => join(root, name), + appendSupported: true, }; }, ], @@ -122,12 +136,34 @@ const backendFactories: Array<[string, () => BackendCase]> = [ backend: new NodePlatformIO() as MetadataBackend, vfsPath: (name) => join(root, name), nativePath: (name) => join(root, name), + appendSupported: false, }; }, ], ]; describe.each(backendFactories)("%s", (_name, makeCase) => { + it("keeps O_RDONLY | O_TRUNC truncation and descriptor access coherent", () => { + const c = makeCase(); + const path = c.vfsPath("read-only-truncate"); + const native = c.nativePath("read-only-truncate"); + writeFileSync(native, "truncate through the selected inode"); + c.backend.chmod(path, 0o6755); + + const fd = c.backend.open(path, O_TRUNC, 0); + try { + expect(c.backend.stat(path)).toMatchObject({ size: 0 }); + expect(c.backend.fstat(fd)).toMatchObject({ size: 0 }); + expect(c.backend.stat(path).mode & MODE_MASK).toBe(0o755); + expect(c.backend.fstat(fd).mode & MODE_MASK).toBe(0o755); + expect(() => + c.backend.write(fd, new Uint8Array([0x78]), null, 1) + ).toThrow(); + } finally { + c.backend.close(fd); + } + }); + it("returns exact bigint identity with checked numeric size and times", () => { const c = makeCase(); const native = c.nativePath("exact-stat"); @@ -244,13 +280,13 @@ describe.each(backendFactories)("%s", (_name, makeCase) => { expectNativeMetadataUnchanged(fdNative, fdBefore); }); - it("retains set-ID bits on non-executable regular files and directories", () => { + it("clears set-ID bits on non-executable regular files but not directories", () => { const c = makeCase(); const fileNative = c.nativePath("set-id-data"); writeFileSync(fileNative, "data"); c.backend.chmod(c.vfsPath("set-id-data"), 0o6600); c.backend.chown(c.vfsPath("set-id-data"), 1234, 5678); - expect(c.backend.stat(c.vfsPath("set-id-data")).mode & MODE_MASK).toBe(0o6600); + expect(c.backend.stat(c.vfsPath("set-id-data")).mode & MODE_MASK).toBe(0o600); c.backend.mkdir(c.vfsPath("set-id-dir"), 0o770); c.backend.chmod(c.vfsPath("set-id-dir"), 0o6770); @@ -258,6 +294,175 @@ describe.each(backendFactories)("%s", (_name, makeCase) => { expect(c.backend.stat(c.vfsPath("set-id-dir")).mode & MODE_MASK).toBe(0o6770); }); + it("invalidates set-ID metadata after every qualifying file mutation", () => { + const c = makeCase(); + const path = c.vfsPath("mutation-matrix"); + const native = c.nativePath("mutation-matrix"); + writeFileSync(native, "seed"); + chmodSync(native, 0o600); + + const fd = c.backend.open(path, O_RDWR, 0); + const byte = new Uint8Array([0x78]); + const expectMode = (mode: number): void => { + expect(c.backend.stat(path).mode & MODE_MASK).toBe(mode); + expect(c.backend.fstat(fd).mode & MODE_MASK).toBe(mode); + }; + const arm = (mode = 0o6755): void => { + c.backend.chmod(path, mode); + expectMode(mode); + }; + + try { + arm(); + expect(c.backend.write(fd, byte, null, 1)).toBe(1); + expectMode(0o755); + + arm(); + expect(c.backend.write(fd, byte, 0, 1)).toBe(1); + expectMode(0o755); + + arm(); + if (c.appendSupported) { + expect(c.backend.append(fd, byte, 1, null).written).toBe(1); + expectMode(0o755); + } else { + expect(() => c.backend.append(fd, byte, 1, null)).toThrow(/EOPNOTSUPP/); + expectMode(0o6755); + } + + arm(); + const truncateFd = c.backend.open(path, O_RDWR | O_TRUNC, 0); + c.backend.close(truncateFd); + expectMode(0o755); + + expect(c.backend.write(fd, byte, 0, 1)).toBe(1); + arm(); + c.backend.ftruncate(fd, 0); + expectMode(0o755); + + arm(); + c.backend.chown(path, 1001, 2001); + expectMode(0o755); + + arm(); + c.backend.fchown(fd, 1002, 2002); + expectMode(0o755); + + arm(); + c.backend.lchown(path, 1003, 2003); + expectMode(0o755); + + arm(0o6600); + expect(c.backend.write(fd, byte, 0, 1)).toBe(1); + expectMode(0o600); + + arm(0o6600); + c.backend.chown(path, 1004, 2004); + expectMode(0o600); + + arm(); + expect(c.backend.write(fd, byte, null, 0)).toBe(0); + expect(c.backend.write(fd, byte, 0, 0)).toBe(0); + if (c.appendSupported) { + expect(c.backend.append(fd, byte, 0, null).written).toBe(0); + } else { + expect(() => c.backend.append(fd, byte, 0, null)).toThrow(/EOPNOTSUPP/); + } + expectMode(0o6755); + + const unchangedSize = c.backend.fstat(fd).size; + c.backend.ftruncate(fd, unchangedSize); + expectMode(0o6755); + if (unchangedSize !== 0) { + c.backend.ftruncate(fd, 0); + arm(); + } + const emptyTruncateFd = c.backend.open(path, O_RDWR | O_TRUNC, 0); + c.backend.close(emptyTruncateFd); + expectMode(0o6755); + + const readOnlyFd = c.backend.open(path, 0, 0); + try { + expect(() => c.backend.write(readOnlyFd, byte, null, 1)).toThrow(); + expect(() => c.backend.ftruncate(readOnlyFd, 0)).toThrow(); + } finally { + c.backend.close(readOnlyFd); + } + expectMode(0o6755); + } finally { + c.backend.close(fd); + } + + c.backend.mkdir(c.vfsPath("mutation-directory"), 0o755); + c.backend.chmod(c.vfsPath("mutation-directory"), 0o6770); + c.backend.chown(c.vfsPath("mutation-directory"), 3001, 3002); + expect(c.backend.stat(c.vfsPath("mutation-directory")).mode & MODE_MASK) + .toBe(0o6770); + }); + + it("keeps mode coherent after positive and failed mutation attempts", () => { + const c = makeCase(); + const path = c.vfsPath("mutation-failures"); + const native = c.nativePath("mutation-failures"); + writeFileSync(native, "seed"); + chmodSync(native, 0o600); + const fd = c.backend.open(path, O_RDWR, 0); + const bytes = new Uint8Array([0x78, 0x79]); + const expectMode = (mode: number): void => { + expect(c.backend.stat(path).mode & MODE_MASK).toBe(mode); + expect(c.backend.fstat(fd).mode & MODE_MASK).toBe(mode); + }; + const arm = (): void => { + c.backend.chmod(path, 0o6755); + expectMode(0o6755); + }; + const expectFailure = (operation: () => unknown): void => { + expect(operation).toThrow(); + expectMode(0o6755); + }; + + try { + arm(); + expect(c.backend.write(fd, bytes, null, 1)).toBe(1); + expectMode(0o755); + + arm(); + expect(c.backend.write(fd, bytes, 0, 1)).toBe(1); + expectMode(0o755); + + if (c.appendSupported) { + arm(); + const limit = c.backend.fstat(fd).size + 1; + expect(c.backend.append(fd, bytes, bytes.length, limit).written).toBe(1); + expectMode(0o755); + } + + arm(); + const readOnlyFd = c.backend.open(path, 0, 0); + try { + expectFailure(() => c.backend.write(readOnlyFd, bytes, null, 1)); + expectFailure(() => c.backend.write(readOnlyFd, bytes, 0, 1)); + expectFailure(() => c.backend.append(readOnlyFd, bytes, 1, null)); + expectFailure(() => c.backend.ftruncate(readOnlyFd, 0)); + } finally { + c.backend.close(readOnlyFd); + } + + expectFailure(() => + c.backend.open(c.vfsPath("missing-truncate"), O_RDWR | O_TRUNC, 0) + ); + expectFailure(() => + c.backend.chown(c.vfsPath("missing-chown"), 1000, 2000) + ); + expectFailure(() => c.backend.fchown(999_999, 1000, 2000)); + expectFailure(() => + c.backend.lchown(c.vfsPath("missing-lchown"), 1000, 2000) + ); + } finally { + c.backend.close(fd); + } + }); + it("uses a private native create mode and records the requested guest mode", () => { const c = makeCase(); const fd = withUmask(0, () => diff --git a/host/test/platform/native-metadata.test.ts b/host/test/platform/native-metadata.test.ts index c1b146d83a..52ac21165a 100644 --- a/host/test/platform/native-metadata.test.ts +++ b/host/test/platform/native-metadata.test.ts @@ -1,9 +1,64 @@ -import { describe, it, expect } from "vitest"; -import { synthesizePosixMode } from "../../src/platform/native-metadata"; +import { afterEach, describe, it, expect } from "vitest"; +import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + modeAfterRegularFileMutation, + NativeMetadataOverlay, + synthesizePosixMode, +} from "../../src/platform/native-metadata"; const S_IFDIR = 0o040000; const S_IFREG = 0o100000; const S_IFLNK = 0o120000; +const roots: string[] = []; + +afterEach(() => { + while (roots.length > 0) { + rmSync(roots.pop()!, { recursive: true, force: true }); + } +}); + +describe("modeAfterRegularFileMutation", () => { + it.each(["content", "ownership"] as const)( + "clears both set-ID bits after a regular-file %s mutation", + (kind) => { + expect(modeAfterRegularFileMutation(S_IFREG | 0o6755, kind)).toBe( + S_IFREG | 0o755, + ); + expect(modeAfterRegularFileMutation(S_IFREG | 0o6600, kind)).toBe( + S_IFREG | 0o600, + ); + }, + ); + + it.each([ + ["directory", S_IFDIR], + ["symlink", S_IFLNK], + ] as const)("leaves a %s unchanged", (_name, type) => { + expect(modeAfterRegularFileMutation(type | 0o6755, "ownership")).toBe( + type | 0o6755, + ); + }); +}); + +describe("NativeMetadataOverlay content transactions", () => { + it("prepares invalidation without exposing it before content changes", () => { + const root = mkdtempSync(join(tmpdir(), "kandelo-native-metadata-")); + roots.push(root); + const path = join(root, "file"); + writeFileSync(path, "content"); + const stat = statSync(path, { bigint: true }); + const metadata = new NativeMetadataOverlay(); + metadata.chmod(stat, 0o6755); + + const commit = metadata.prepareNativeContentChange(stat); + expect(metadata.toStatResult(stat).mode & 0o7777).toBe(0o6755); + + commit(); + expect(metadata.toStatResult(stat).mode & 0o7777).toBe(0o755); + }); +}); // Windows has no POSIX permission model: Node's `fs.statSync` reports every // entry as 0o666 (writable) or 0o444 (read-only), with no execute/search bit diff --git a/host/test/vfs.test.ts b/host/test/vfs.test.ts index cce593b53b..485453f54f 100644 --- a/host/test/vfs.test.ts +++ b/host/test/vfs.test.ts @@ -617,6 +617,162 @@ describe("MemoryFileSystem", () => { mfs.close(fd); }); + it("routes complete set-ID invalidation semantics through VirtualPlatformIO", () => { + const sab = new SharedArrayBuffer(4 * 1024 * 1024); + const mfs = MemoryFileSystem.create(sab); + const path = "/mutation-matrix"; + const localFd = mfs.open(path, O_CREAT | O_RDWR | O_TRUNC, 0o6755); + mfs.close(localFd); + const vfs = new VirtualPlatformIO( + [{ mountPoint: "/", backend: mfs }], + new NodeTimeProvider(), + ); + const fd = vfs.open(path, O_RDWR, 0); + const byte = new Uint8Array([0x78]); + const expectMode = (mode: number): void => { + expect(vfs.stat(path).mode & 0o7777).toBe(mode); + expect(vfs.fstat(fd).mode & 0o7777).toBe(mode); + }; + const arm = (mode = 0o6755): void => { + vfs.chmod(path, mode); + expectMode(mode); + }; + + try { + arm(); + expect(vfs.write(fd, byte, null, 1)).toBe(1); + expectMode(0o755); + + arm(); + expect(vfs.write(fd, byte, 0, 1)).toBe(1); + expectMode(0o755); + + arm(); + expect(vfs.append(fd, byte, 1, null).written).toBe(1); + expectMode(0o755); + + arm(); + const truncateFd = vfs.open(path, O_RDWR | O_TRUNC, 0); + vfs.close(truncateFd); + expectMode(0o755); + + expect(vfs.write(fd, byte, 0, 1)).toBe(1); + arm(); + vfs.ftruncate(fd, 0); + expectMode(0o755); + + arm(); + vfs.chown(path, 1001, 2001); + expectMode(0o755); + + arm(); + vfs.fchown(fd, 1002, 2002); + expectMode(0o755); + + arm(); + vfs.lchown(path, 1003, 2003); + expectMode(0o755); + + arm(0o6600); + expect(vfs.write(fd, byte, 0, 1)).toBe(1); + expectMode(0o600); + + arm(0o6600); + vfs.chown(path, 1004, 2004); + expectMode(0o600); + + arm(); + expect(vfs.write(fd, byte, null, 0)).toBe(0); + expect(vfs.write(fd, byte, 0, 0)).toBe(0); + expect(vfs.append(fd, byte, 0, null).written).toBe(0); + expectMode(0o6755); + + const unchangedSize = vfs.fstat(fd).size; + vfs.ftruncate(fd, unchangedSize); + expectMode(0o6755); + if (unchangedSize !== 0) { + vfs.ftruncate(fd, 0); + arm(); + } + const emptyTruncateFd = vfs.open(path, O_RDWR | O_TRUNC, 0); + vfs.close(emptyTruncateFd); + expectMode(0o6755); + + const readOnlyFd = vfs.open(path, O_RDONLY, 0); + try { + expect(() => vfs.write(readOnlyFd, byte, null, 1)).toThrow(); + expect(() => vfs.ftruncate(readOnlyFd, 0)).toThrow(); + } finally { + vfs.close(readOnlyFd); + } + expectMode(0o6755); + } finally { + vfs.close(fd); + } + + vfs.mkdir("/mutation-directory", 0o6770); + vfs.chown("/mutation-directory", 3001, 3002); + expect(vfs.stat("/mutation-directory").mode & 0o7777).toBe(0o6770); + }); + + it("routes positive and failed mutation attempts without changing armed mode", () => { + const mfs = MemoryFileSystem.create(new SharedArrayBuffer(4 * 1024 * 1024)); + const path = "/mutation-failures"; + const localFd = mfs.open(path, O_CREAT | O_RDWR | O_TRUNC, 0o600); + mfs.close(localFd); + const vfs = new VirtualPlatformIO( + [{ mountPoint: "/", backend: mfs }], + new NodeTimeProvider(), + ); + const fd = vfs.open(path, O_RDWR, 0); + const bytes = new Uint8Array([0x78, 0x79]); + const expectMode = (mode: number): void => { + expect(vfs.stat(path).mode & 0o7777).toBe(mode); + expect(vfs.fstat(fd).mode & 0o7777).toBe(mode); + }; + const arm = (): void => { + vfs.chmod(path, 0o6755); + expectMode(0o6755); + }; + const expectFailure = (operation: () => unknown): void => { + expect(operation).toThrow(); + expectMode(0o6755); + }; + + try { + arm(); + expect(vfs.write(fd, bytes, null, 1)).toBe(1); + expectMode(0o755); + + arm(); + expect(vfs.write(fd, bytes, 0, 1)).toBe(1); + expectMode(0o755); + + arm(); + const limit = vfs.fstat(fd).size + 1; + expect(vfs.append(fd, bytes, bytes.length, limit).written).toBe(1); + expectMode(0o755); + + arm(); + const readOnlyFd = vfs.open(path, O_RDONLY, 0); + try { + expectFailure(() => vfs.write(readOnlyFd, bytes, null, 1)); + expectFailure(() => vfs.write(readOnlyFd, bytes, 0, 1)); + expectFailure(() => vfs.append(readOnlyFd, bytes, 1, null)); + expectFailure(() => vfs.ftruncate(readOnlyFd, 0)); + } finally { + vfs.close(readOnlyFd); + } + + expectFailure(() => vfs.open("/missing-truncate", O_RDWR | O_TRUNC, 0)); + expectFailure(() => vfs.chown("/missing-chown", 1000, 2000)); + expectFailure(() => vfs.fchown(999_999, 1000, 2000)); + expectFailure(() => vfs.lchown("/missing-lchown", 1000, 2000)); + } finally { + vfs.close(fd); + } + }); + it("opens more than the old 64-descriptor SharedFS table limit", () => { expect(MAX_FDS).toBe( Math.floor((BLOCK_SIZE - FD_TABLE_OFFSET) / FD_ENTRY_SIZE), @@ -674,6 +830,88 @@ describe("MemoryFileSystem", () => { expect((error as SFSError).code).toBe(EMFILE); }); + it("routes O_TRUNC EMFILE without changing the selected file", () => { + const mfs = MemoryFileSystem.create(new SharedArrayBuffer(4 * 1024 * 1024)); + const vfs = new VirtualPlatformIO( + [{ mountPoint: "/", backend: mfs }], + new NodeTimeProvider(), + ); + const path = "/routed-emfile-truncate"; + const contents = new TextEncoder().encode("routed bytes remain"); + const fd = vfs.open(path, O_CREAT | O_RDWR, 0o600); + expect(vfs.write(fd, contents, null, contents.byteLength)).toBe( + contents.byteLength, + ); + vfs.chown(path, 2468, 1357); + vfs.chmod(path, 0o6755); + + const fillers: number[] = []; + try { + for (let index = 1; index < MAX_FDS; index++) { + fillers.push(vfs.open(path, O_RDONLY, 0)); + } + + let failure: unknown; + try { + vfs.open(path, O_RDWR | O_TRUNC, 0); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(SFSError); + expect((failure as SFSError).code).toBe(EMFILE); + + const pathStat = vfs.stat(path); + const fdStat = vfs.fstat(fd); + expect(pathStat).toMatchObject({ + size: contents.byteLength, + uid: 2468, + gid: 1357, + }); + expect(fdStat).toMatchObject({ + size: contents.byteLength, + uid: 2468, + gid: 1357, + }); + expect(pathStat.mode & 0o7777).toBe(0o6755); + expect(fdStat.mode & 0o7777).toBe(0o6755); + const observed = new Uint8Array(contents.byteLength); + expect(vfs.read(fd, observed, 0, observed.byteLength)).toBe( + contents.byteLength, + ); + expect(observed).toEqual(contents); + } finally { + for (const filler of fillers) vfs.close(filler); + vfs.close(fd); + } + }); + + it("routes O_RDONLY | O_TRUNC without granting write access", () => { + const mfs = MemoryFileSystem.create(new SharedArrayBuffer(1024 * 1024)); + const vfs = new VirtualPlatformIO( + [{ mountPoint: "/", backend: mfs }], + new NodeTimeProvider(), + ); + const path = "/routed-read-only-truncate"; + const seed = vfs.open(path, O_CREAT | O_RDWR, 0o600); + expect(vfs.write(seed, new TextEncoder().encode("truncate me"), null, 11)) + .toBe(11); + vfs.close(seed); + vfs.chmod(path, 0o6755); + + const fd = vfs.open(path, O_RDONLY | O_TRUNC, 0); + try { + expect(vfs.stat(path).size).toBe(0); + expect(vfs.fstat(fd).size).toBe(0); + expect(vfs.stat(path).mode & 0o7777).toBe(0o755); + expect(vfs.fstat(fd).mode & 0o7777).toBe(0o755); + expect(() => + vfs.write(fd, new Uint8Array([0x78]), null, 1) + ).toThrow(); + } finally { + vfs.close(fd); + } + }); + it("creates and lists directories", () => { const sab = new SharedArrayBuffer(4 * 1024 * 1024); const mfs = MemoryFileSystem.create(sab); diff --git a/host/test/vfs/sharedfs-positioned-io.test.ts b/host/test/vfs/sharedfs-positioned-io.test.ts index e9df913c70..d338ae84c6 100644 --- a/host/test/vfs/sharedfs-positioned-io.test.ts +++ b/host/test/vfs/sharedfs-positioned-io.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest"; import { Worker } from "node:worker_threads"; import { MemoryFileSystem } from "../../src/vfs/memory-fs"; import { + MAX_FDS, O_APPEND, O_CREAT, + O_RDONLY, O_RDWR, O_TRUNC, SEEK_SET, @@ -111,6 +113,38 @@ describe("SharedFS positioned I/O", () => { expect(text(full)).toBe("aXc!"); }); + it("clears set-ID after a genuinely short positive scalar write", () => { + const fs = SharedFS.mkfs(new SharedArrayBuffer(1024 * 1024)); + const path = "/short-scalar"; + const fd = fs.open(path, O_RDWR | O_CREAT | O_TRUNC, 0o600); + fs.chmod(path, 0o6755); + const requested = new Uint8Array(2 * 1024 * 1024); + + const written = fs.write(fd, requested); + + expect(written).toBeGreaterThan(0); + expect(written).toBeLessThan(requested.byteLength); + expect(fs.stat(path).mode & 0o7777).toBe(0o755); + expect(fs.fstat(fd).mode & 0o7777).toBe(0o755); + fs.close(fd); + }); + + it("clears set-ID after a genuinely short positive positioned write", () => { + const fs = MemoryFileSystem.create(new SharedArrayBuffer(1024 * 1024)); + const path = "/short-positioned"; + const fd = fs.open(path, O_RDWR | O_CREAT | O_TRUNC, 0o600); + fs.chmod(path, 0o6755); + const requested = new Uint8Array(2 * 1024 * 1024); + + const written = fs.write(fd, requested, 0, requested.byteLength); + + expect(written).toBeGreaterThan(0); + expect(written).toBeLessThan(requested.byteLength); + expect(fs.stat(path).mode & 0o7777).toBe(0o755); + expect(fs.fstat(fd).mode & 0o7777).toBe(0o755); + fs.close(fd); + }); + it("applies the append limit under the inode lock and reports exact EOF", () => { const sab = new SharedArrayBuffer(4 * 1024 * 1024); const first = MemoryFileSystem.create(sab); @@ -203,4 +237,75 @@ describe("SharedFS positioned I/O", () => { } fs.close(verifyFd); }, 10_000); + + it("publishes distinct lowest descriptors across concurrent workers", async () => { + const sab = new SharedArrayBuffer(4 * 1024 * 1024); + const fs = MemoryFileSystem.create(sab); + const seed = fs.open( + "/reservation-race", + O_RDWR | O_CREAT | O_TRUNC, + 0o600, + ); + fs.close(seed); + const fillers: number[] = []; + for (let index = 0; index < MAX_FDS - 2; index++) { + fillers.push(fs.open("/reservation-race", O_RDONLY, 0)); + } + + const controlBuffer = new SharedArrayBuffer(5 * Int32Array.BYTES_PER_ELEMENT); + const control = new Int32Array(controlBuffer); + const workerUrl = new URL( + "../fixtures/sharedfs-fd-reservation-worker.ts", + import.meta.url, + ); + const workers = [0, 1].map( + (slot) => + new Worker(workerUrl, { + execArgv: ["--import", "tsx"], + workerData: { fsBuffer: sab, controlBuffer, slot }, + }), + ); + const results = workers.map( + (worker) => + new Promise<{ ok: boolean; error?: string }>((resolve, reject) => { + worker.once("message", resolve); + worker.once("error", reject); + worker.once("exit", (code) => { + if (code !== 0) reject(new Error(`fd worker exited ${code}`)); + }); + }), + ); + + Atomics.store(control, 0, 1); + Atomics.notify(control, 0, workers.length); + try { + const deadline = Date.now() + 5_000; + while (Atomics.load(control, 1) !== workers.length) { + if (Date.now() >= deadline) { + throw new Error("fd reservation workers did not publish in time"); + } + await Atomics.waitAsync( + control, + 1, + Atomics.load(control, 1), + 100, + ).value; + } + expect(new Set([Atomics.load(control, 3), Atomics.load(control, 4)])) + .toEqual(new Set([MAX_FDS - 2, MAX_FDS - 1])); + + Atomics.store(control, 2, 1); + Atomics.notify(control, 2, workers.length); + expect(await Promise.all(results)).toEqual([{ ok: true }, { ok: true }]); + + const reused = fs.open("/reservation-race", O_RDONLY, 0); + expect(reused).toBe(MAX_FDS - 2); + fs.close(reused); + } finally { + Atomics.store(control, 2, 1); + Atomics.notify(control, 2, workers.length); + await Promise.all(workers.map((worker) => worker.terminate())); + for (const filler of fillers) fs.close(filler); + } + }, 10_000); }); diff --git a/host/test/vfs/sharedfs-uid-gid.test.ts b/host/test/vfs/sharedfs-uid-gid.test.ts index bdd825b325..480b4b7c70 100644 --- a/host/test/vfs/sharedfs-uid-gid.test.ts +++ b/host/test/vfs/sharedfs-uid-gid.test.ts @@ -1,6 +1,21 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { MemoryFileSystem } from "../../src/vfs/memory-fs"; import { ensureDirRecursive } from "../../src/vfs/image-helpers"; +import { + EEXIST, + EBADF, + EISDIR, + EMFILE, + ENOENT, + ENOTDIR, + MAX_FDS, + O_DIRECTORY, + O_EXCL, + O_RDONLY, + O_RDWR, + SFSError, + SharedFS, +} from "../../src/vfs/sharedfs-vendor"; // O_WRONLY | O_CREAT | O_TRUNC, matching sharedfs-vendor.ts constants. const O_WRONLY = 0x0001; @@ -120,7 +135,7 @@ describe("SharedFS uid/gid", () => { expect(st.gid).toBe(600); }); - it("chown-family operations clear set-ID bits only on executable regular files", () => { + it("chown-family operations clear set-ID bits on regular files", () => { const sab = new SharedArrayBuffer(1024 * 1024); const fs = MemoryFileSystem.create(sab); const create = (path: string, mode: number): number => @@ -167,7 +182,7 @@ describe("SharedFS uid/gid", () => { fd = create("/non-executable", 0o6600); fs.close(fd); fs.chown("/non-executable", 1000, 1000); - expect(fs.stat("/non-executable").mode & 0o7777).toBe(0o6600); + expect(fs.stat("/non-executable").mode & 0o7777).toBe(0o600); fs.mkdir("/directory", 0o6770); fs.chown("/directory", 1000, 1000); @@ -180,6 +195,297 @@ describe("SharedFS uid/gid", () => { expect(fs.stat("/path").mode & 0o7777).toBe(0o6755); }); + it("invalidates set-ID metadata after every qualifying file mutation", () => { + const sab = new SharedArrayBuffer(1024 * 1024); + const fs = MemoryFileSystem.create(sab); + const path = "/mutation-matrix"; + const byte = new Uint8Array([0x78]); + const fd = fs.open(path, O_WRONLY | O_CREAT | O_TRUNC, 0o6755); + const expectMode = (mode: number): void => { + expect(fs.stat(path).mode & 0o7777).toBe(mode); + expect(fs.fstat(fd).mode & 0o7777).toBe(mode); + }; + const arm = (mode = 0o6755): void => { + fs.chmod(path, mode); + expectMode(mode); + }; + + try { + arm(); + expect(fs.write(fd, byte, null, 1)).toBe(1); + expectMode(0o755); + + arm(); + expect(fs.write(fd, byte, 0, 1)).toBe(1); + expectMode(0o755); + + arm(); + expect(fs.append(fd, byte, 1, null).written).toBe(1); + expectMode(0o755); + + arm(); + const truncateFd = fs.open(path, O_WRONLY | O_TRUNC, 0); + fs.close(truncateFd); + expectMode(0o755); + + expect(fs.write(fd, byte, 0, 1)).toBe(1); + arm(); + fs.ftruncate(fd, 0); + expectMode(0o755); + + arm(); + fs.chown(path, 1001, 2001); + expectMode(0o755); + + arm(); + fs.fchown(fd, 1002, 2002); + expectMode(0o755); + + arm(); + fs.lchown(path, 1003, 2003); + expectMode(0o755); + + arm(0o6600); + expect(fs.write(fd, byte, 0, 1)).toBe(1); + expectMode(0o600); + + arm(0o6600); + fs.chown(path, 1004, 2004); + expectMode(0o600); + + arm(); + expect(fs.write(fd, byte, null, 0)).toBe(0); + expect(fs.write(fd, byte, 0, 0)).toBe(0); + expect(fs.append(fd, byte, 0, null).written).toBe(0); + expectMode(0o6755); + + const unchangedSize = fs.fstat(fd).size; + fs.ftruncate(fd, unchangedSize); + expectMode(0o6755); + if (unchangedSize !== 0) { + fs.ftruncate(fd, 0); + arm(); + } + const emptyTruncateFd = fs.open(path, O_WRONLY | O_TRUNC, 0); + fs.close(emptyTruncateFd); + expectMode(0o6755); + + const readOnlyFd = fs.open(path, 0, 0); + try { + expect(() => fs.write(readOnlyFd, byte, null, 1)).toThrow(); + expect(() => fs.ftruncate(readOnlyFd, 0)).toThrow(); + } finally { + fs.close(readOnlyFd); + } + expectMode(0o6755); + } finally { + fs.close(fd); + } + + fs.mkdir("/mutation-directory", 0o6770); + fs.chown("/mutation-directory", 3001, 3002); + expect(fs.stat("/mutation-directory").mode & 0o7777).toBe(0o6770); + }); + + it("keeps mode coherent after positive and failed mutation attempts", () => { + const fs = MemoryFileSystem.create(new SharedArrayBuffer(1024 * 1024)); + const path = "/mutation-failures"; + const bytes = new Uint8Array([0x78, 0x79]); + const fd = fs.open(path, O_WRONLY | O_CREAT | O_TRUNC, 0o600); + const expectMode = (mode: number): void => { + expect(fs.stat(path).mode & 0o7777).toBe(mode); + expect(fs.fstat(fd).mode & 0o7777).toBe(mode); + }; + const arm = (): void => { + fs.chmod(path, 0o6755); + expectMode(0o6755); + }; + const expectFailure = (operation: () => unknown): void => { + expect(operation).toThrow(); + expectMode(0o6755); + }; + + try { + arm(); + expect(fs.write(fd, bytes, null, 1)).toBe(1); + expectMode(0o755); + + arm(); + expect(fs.write(fd, bytes, 0, 1)).toBe(1); + expectMode(0o755); + + arm(); + const limit = fs.fstat(fd).size + 1; + expect(fs.append(fd, bytes, bytes.length, limit).written).toBe(1); + expectMode(0o755); + + arm(); + const readOnlyFd = fs.open(path, 0, 0); + try { + expectFailure(() => fs.write(readOnlyFd, bytes, null, 1)); + expectFailure(() => fs.write(readOnlyFd, bytes, 0, 1)); + expectFailure(() => fs.append(readOnlyFd, bytes, 1, null)); + expectFailure(() => fs.ftruncate(readOnlyFd, 0)); + } finally { + fs.close(readOnlyFd); + } + + expectFailure(() => fs.open("/missing-truncate", O_WRONLY | O_TRUNC, 0)); + expectFailure(() => fs.chown("/missing-chown", 1000, 2000)); + expectFailure(() => fs.fchown(999_999, 1000, 2000)); + expectFailure(() => fs.lchown("/missing-lchown", 1000, 2000)); + } finally { + fs.close(fd); + } + }); + + it("leaves an armed file unchanged when O_TRUNC cannot reserve a descriptor", () => { + const fs = SharedFS.mkfs(new SharedArrayBuffer(4 * 1024 * 1024)); + const path = "/emfile-truncate"; + const contents = new TextEncoder().encode("retain exact bytes"); + const fd = fs.open(path, O_CREAT | O_RDWR, 0o600); + expect(fs.write(fd, contents)).toBe(contents.byteLength); + fs.chown(path, 1234, 5678); + fs.chmod(path, 0o6755); + + const fillers: number[] = []; + try { + for (let index = 1; index < MAX_FDS; index++) { + fillers.push(fs.open(path, O_RDONLY, 0)); + } + + let failure: unknown; + try { + fs.open(path, O_RDWR | O_TRUNC, 0); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(SFSError); + expect((failure as SFSError).code).toBe(EMFILE); + + const pathStat = fs.stat(path); + const fdStat = fs.fstat(fd); + expect(pathStat).toMatchObject({ + size: contents.byteLength, + uid: 1234, + gid: 5678, + }); + expect(fdStat).toMatchObject({ + size: contents.byteLength, + uid: 1234, + gid: 5678, + }); + expect(pathStat.mode & 0o7777).toBe(0o6755); + expect(fdStat.mode & 0o7777).toBe(0o6755); + const observed = new Uint8Array(contents.byteLength); + expect(fs.readAt(fd, observed, 0)).toBe(contents.byteLength); + expect(observed).toEqual(contents); + + const released = fillers.shift()!; + fs.close(released); + const reused = fs.open(path, O_RDONLY, 0); + expect(reused).toBe(released); + fs.close(reused); + } finally { + for (const filler of fillers) fs.close(filler); + fs.close(fd); + } + }); + + it("accepts O_RDONLY | O_TRUNC and keeps the descriptor read-only", () => { + const fs = SharedFS.mkfs(new SharedArrayBuffer(1024 * 1024)); + const path = "/read-only-truncate"; + const seed = fs.open(path, O_CREAT | O_RDWR, 0o600); + expect(fs.write(seed, new TextEncoder().encode("truncate me"))).toBe(11); + fs.close(seed); + fs.chmod(path, 0o6755); + + const fd = fs.open(path, O_RDONLY | O_TRUNC, 0); + try { + expect(fs.stat(path).size).toBe(0); + expect(fs.fstat(fd).size).toBe(0); + expect(fs.stat(path).mode & 0o7777).toBe(0o755); + expect(fs.fstat(fd).mode & 0o7777).toBe(0o755); + expect(() => fs.write(fd, new Uint8Array([0x78]))).toThrow(); + } finally { + fs.close(fd); + } + }); + + it("releases the lowest reservation once after every pre-publish failure", () => { + const fs = SharedFS.mkfs(new SharedArrayBuffer(4 * 1024 * 1024)); + const file = fs.open("/reservation-file", O_CREAT | O_RDWR, 0o600); + fs.close(file); + fs.mkdir("/reservation-directory", 0o700); + + const fillers: number[] = []; + try { + for (let index = 0; index < MAX_FDS - 1; index++) { + fillers.push(fs.open("/reservation-file", O_RDONLY, 0)); + } + const expectedFd = MAX_FDS - 1; + const failures: Array<[() => unknown, number]> = [ + [() => fs.open("/missing", O_RDONLY, 0), ENOENT], + [ + () => fs.open("/reservation-file", O_CREAT | O_EXCL | O_RDWR, 0), + EEXIST, + ], + [() => fs.open("/reservation-file", O_DIRECTORY, 0), ENOTDIR], + [() => fs.open("/reservation-directory", O_RDWR, 0), EISDIR], + ]; + + for (const [operation, code] of failures) { + let failure: unknown; + try { + operation(); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(SFSError); + expect((failure as SFSError).code).toBe(code); + + const probe = fs.open("/reservation-file", O_RDONLY, 0); + expect(probe).toBe(expectedFd); + fs.close(probe); + } + } finally { + for (const filler of fillers) fs.close(filler); + } + }); + + it("keeps a reentrant observer from seeing a reserved descriptor", () => { + const sab = new SharedArrayBuffer(4 * 1024 * 1024); + const fs = SharedFS.mkfs(sab); + const observer = SharedFS.mount(sab); + const internals = fs as unknown as { + inodeAddOpenRef(ino: number): boolean; + }; + const addOpenRef = internals.inodeAddOpenRef.bind(fs); + let observedReservation = false; + vi.spyOn(internals, "inodeAddOpenRef").mockImplementation((ino) => { + let failure: unknown; + try { + observer.fstat(0); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(SFSError); + expect((failure as SFSError).code).toBe(EBADF); + observedReservation = true; + return addOpenRef(ino); + }); + + try { + const fd = fs.open("/private-reservation", O_CREAT | O_RDWR, 0o600); + expect(fd).toBe(0); + expect(observedReservation).toBe(true); + expect(observer.fstat(fd).mode & 0o7777).toBe(0o600); + fs.close(fd); + } finally { + vi.restoreAllMocks(); + } + }); + it("preserves IDs selected with the unchanged sentinels", () => { const sab = new SharedArrayBuffer(1024 * 1024); const fs = MemoryFileSystem.create(sab); @@ -249,10 +555,32 @@ describe("SharedFS uid/gid", () => { const st = fs.stat("/data"); expect(st.uid).toBe(4242); expect(st.gid).toBe(9999); - expect(st.mode & 0o777).toBe(0o600); + expect(st.mode & 0o7777).toBe(0o600); expect(st.size).toBe(5); }); + it("preserves reviewed set-ID metadata until a later guest mutation", () => { + const sab = new SharedArrayBuffer(1024 * 1024); + const fs = MemoryFileSystem.create(sab); + const bytes = new TextEncoder().encode("reviewed"); + fs.createFileWithOwner("/published", 0o6755, 0, 0, bytes); + expect(fs.stat("/published")).toMatchObject({ + uid: 0, + gid: 0, + size: bytes.byteLength, + }); + expect(fs.stat("/published").mode & 0o7777).toBe(0o6755); + + const fd = fs.open("/published", O_WRONLY, 0); + try { + expect(fs.write(fd, new Uint8Array([0x78]), null, 1)).toBe(1); + expect(fs.fstat(fd).mode & 0o7777).toBe(0o755); + expect(fs.stat("/published").mode & 0o7777).toBe(0o755); + } finally { + fs.close(fd); + } + }); + it("mkdirWithOwner sets uid/gid at creation", () => { const sab = new SharedArrayBuffer(1024 * 1024); const fs = MemoryFileSystem.create(sab); diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index 9adb682a45..93c44a1fc7 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -74,8 +74,8 @@ "erlang-vfs": { "manifestSha256": "15efb74f1825e2b85bedfeb8d98c19fa648c4be39cb9defb65330a8f21eb9141", "cacheKeys": { - "wasm32": "e9fbd981a01570739a315fd8a4c8177eec4d0c381e0080b4d950157cef6e6de2", - "wasm64": "a8162a4d5c5c74d4e5a321b1bec93a7f224aad38ad6691969ae65366b6a8ba92" + "wasm32": "7b88273b7d5ca43aebb2d65c9fbe03ab56639ce1166bf9261ffff67e119d9235", + "wasm64": "bb10fec9d503b9508ab16adfd7eeabe9dd6b25acd19e86e31bac83a11e0657ec" } }, "fbdoom": { @@ -144,8 +144,8 @@ "kandelo-sdk": { "manifestSha256": "c081879f1becd855917cff96f17429bcf2b9fd61bf95211eca9ff8fb625bb2eb", "cacheKeys": { - "wasm32": "0c81181a836e1262ded47966f272a53b9e54f92ecc0b514d6b27f087f0152b94", - "wasm64": "2e35685ba04c89d3e237fa187427ab04210709ca2d370ce472c8e04f87963178" + "wasm32": "b2801ad140482e877a4893251d043e033801d25ef4135c92a73b2e50eb9aca92", + "wasm64": "4dfd6a39cb9aaaa006cb833a9855dfb08f696e2cf77cc26a4e7712c9ed12c919" } }, "kernel": { @@ -158,8 +158,8 @@ "lamp": { "manifestSha256": "250b64635d64f8537178188fb5488dbfd2980d79a1c2ffbf346af67008088ba0", "cacheKeys": { - "wasm32": "49bebcac721dcc56c84213c2f3dba851ccdde69183b10be3356293c926180581", - "wasm64": "52bb215e95eb16c2f448c5f82ab3dd1f726badecc8463a51948f418fb8a3ad56" + "wasm32": "29838c85d239e83665cd9201eae6b4ebeef875e798ddf01a1f07804d5d519aa7", + "wasm64": "0d9f52c79901f89f12daa3e0a2dee3233348a4635f58dde3e2149bb61b5d5309" } }, "less": { @@ -242,15 +242,15 @@ "mariadb-test": { "manifestSha256": "d4ccce161d659a5a87c80fa1117054bbccea870e0d4a46bd8a070d3930ad0e3f", "cacheKeys": { - "wasm32": "08446236c220b97848a9444d26251cecc730bb916f8457021df56a4046e1c503", - "wasm64": "c9192668bae4075d07d61f4925c04a01fcc4cb1ee755913cabf2ec6b9dd53cae" + "wasm32": "eaba21ea0e184a6cf00a87c99273c88d6b0ff8aee4fabcf4459770aa3483f0cc", + "wasm64": "19ab61784c3a99cdfaff373982e0f9ebeef25ed2e605e7e677bbdb7b6878cb7f" } }, "mariadb-vfs": { "manifestSha256": "26e74ac84d89b2a839ed72783437061a23022ef2f88ad0f52b6aacd0442ee1cf", "cacheKeys": { - "wasm32": "91aff7b9e519b98353523db54a4e0ce139a8f6b37df64b07d53a9b1132afbc3c", - "wasm64": "e584bd74f7e9f6cf1b5fc5d83184afbea7e2e1afba4de58d8b08208e9176fa6f" + "wasm32": "c9ac6a6efa075623c727c7b33b34439a31caf814d626d11bc6bfa22fe4d25b0c", + "wasm64": "b01238af64fcee0d288a1413f069cc86be29621cf8c602fffc0e563176cab991" } }, "modeset": { @@ -312,15 +312,15 @@ "nginx-php-vfs": { "manifestSha256": "97976410cb02f8ba710d856b4ac904bcf976d677b50eefdee39fb64176070d4b", "cacheKeys": { - "wasm32": "d923e282640dc6b68f226f68f89a147eda0aa775bd6899246bccea534a5be3be", - "wasm64": "6037fd41edc9fdada3e2ca7b9ac99cad1f9c09101794936527cfe5df09432bf9" + "wasm32": "e1dead1a43da2fbc91fe897cbd65c61f5720aa8046670bd7de0b0be61787f8d7", + "wasm64": "cdcfb6efcff3c1732874c4cd52e412717d0002b3c4cbf68112a3cd0834a299c0" } }, "nginx-vfs": { "manifestSha256": "46aa2d3250ac5cd0f102a85c3a36a086c7a50ba1f9e05fa1c2aae3d07ad16f10", "cacheKeys": { - "wasm32": "403a4321700039225a1224421293a0aeaa6c7ad6456759a3399e5d0a4c6a534c", - "wasm64": "a2978396e9be434d2733d7ab35b15dff00ff58a05c6dc375d07f62ddb6207ab8" + "wasm32": "44f58deb30e411e6e91ef3f77a9ecfe5e52e67a1456a2fba07a8d3115a07e293", + "wasm64": "9d98820b84be1697a44775c2d046f3b58af1e0bb100ac35996960ae664199cdd" } }, "node": { @@ -333,8 +333,8 @@ "node-vfs": { "manifestSha256": "977f219defe57e18017ecc963159df32efdd21c53d6fea05943703d04739d8de", "cacheKeys": { - "wasm32": "401cb759b750c5ccc6bcfec5ff04500c270718b2345e604630b3fa2a9769f0c6", - "wasm64": "2f195ee9f67d8481526e6f7c7dbecf4690318b60b7c6e53fca1569cde2349562" + "wasm32": "b47cf3b19890b4cc2b0127339d5ac15009b78dfc3b491d43e83eb7d4c2165b3a", + "wasm64": "0e7998404c3ff6244b85681bf0939fbb3f0af0ee25670ea435d5b928aff79e79" } }, "openssl": { @@ -361,8 +361,8 @@ "perl-vfs": { "manifestSha256": "1345cc102bc8c24a6bc276410c00b4fcc99ba5f6adc9b031f882aaa35d53c8bc", "cacheKeys": { - "wasm32": "5ba81a4073afa11a913a2cb53865af56cd58731d9c262240fea425e5fe63ddc2", - "wasm64": "049b633cd911bed80d24c219f08db2c3886633278c2345e5468ee82f5e15579a" + "wasm32": "869931ebd15307cf07c27e09776891ab348bb5410aeba23385b15ac8617a3af5", + "wasm64": "4b5eae066a08ad1ede88e72547e0b8cc63dc6902e06bd5ad36105f8d0c01f1d6" } }, "php": { @@ -382,8 +382,8 @@ "python-vfs": { "manifestSha256": "d1aa99feae65f06c0ca8cf52c2176534b2723fd4ee6eba6e72c205245e1c9c26", "cacheKeys": { - "wasm32": "97185cb95c82b5abc19b794210e309a128a07648dde2a7efbbb2e79a9d7da16d", - "wasm64": "8354e74df5fa090dfeea55d5d7e6cdadcd1b6623bc8fa53318d576302223ad30" + "wasm32": "6499248529a342f180c4ee2d1bebdbbe878a177b2d50574c8c93ec24575ab0d0", + "wasm64": "1796dff2cb30a5c3fccdb892e7bfefda4ba0c9377160d4bc497b083a32eeb397" } }, "redis": { @@ -396,15 +396,15 @@ "redis-vfs": { "manifestSha256": "234c45c40f94e2a89f98295313b82cb9fce15a412ac829d9e87394e0dc731813", "cacheKeys": { - "wasm32": "1d08e1584b3fb853cb2dd2aca185d24077d872f26004e162f26732358b4f5ee2", - "wasm64": "167ac3506374c23515e89e1db076edd517f8f3c0dca9fd3c63ba4d3780643094" + "wasm32": "176cc998ce64ad14f5f99456093ac275bc23f49c539655f9a62e89bcdcc83c91", + "wasm64": "2b5c997e24466188b4c83cba1a301e3265012cf7decdfd2be5c53cf4dc107cbf" } }, "rootfs": { "manifestSha256": "0420201025170f48c32949ac62707d4577cdc13a53ad1f01f59d318ec07c8d6e", "cacheKeys": { - "wasm32": "58dbbd378cd51158808cb319f248416c4b948d03ff73d21fbb2120ec7a9fae22", - "wasm64": "87996c111d49497b414d46e7c9682a2f2108b0193dc9d77bfda5e1ba041ab82e" + "wasm32": "d4ed5891e3146cfe2a3708a135666f91112170e47e3ef0f182031b7830d9f124", + "wasm64": "a31fd8644a4df982bac1e1eb94c0d0562776f26acf06026a99ff583713ca0d9a" } }, "ruby": { @@ -452,8 +452,8 @@ "shell": { "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", "cacheKeys": { - "wasm32": "96e587a94acbf662c70c87ec7e265b61d313b4816101ad0a41ec8e5b94498c5f", - "wasm64": "45884340af1d2115bd3e31cb268aa1b119aa85e3c5916e15fd634f520a87e19f" + "wasm32": "edfa7d165b2d2ead001360dbcb3104e965c7aad1a75566500e4a9c7db90c7c08", + "wasm64": "5ef591140704f1b52f4485c7331ff7e751f464f58dade9ac16828c031c401d50" } }, "spidermonkey": { @@ -543,8 +543,8 @@ "wordpress": { "manifestSha256": "36465e0596a06e855a8524eecfd87da98643bc13b37ee12939f5aab44bf33418", "cacheKeys": { - "wasm32": "c5cc2988d67af648504715c20b76b299992ed6eca495c112e52a4b3f655ed4ab", - "wasm64": "7d4ba0390545775a17c239d18458954c45936002f4dac9914b609922c4f4d188" + "wasm32": "f62e8e8f07d047b7555eb931cb22109d997250896c38a16823204188aa3a43e5", + "wasm64": "7148765a475b279c978fc1836d6eb67358a5d154ebe19af8fac3cdd538997985" } }, "xz": { @@ -871,7 +871,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e9fbd981a01570739a315fd8a4c8177eec4d0c381e0080b4d950157cef6e6de2" + "wasm32": "7b88273b7d5ca43aebb2d65c9fbe03ab56639ce1166bf9261ffff67e119d9235" }, "dependencyClosures": { "wasm32": [ @@ -1094,7 +1094,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0c81181a836e1262ded47966f272a53b9e54f92ecc0b514d6b27f087f0152b94" + "wasm32": "b2801ad140482e877a4893251d043e033801d25ef4135c92a73b2e50eb9aca92" }, "dependencyClosures": { "wasm32": [ @@ -1121,7 +1121,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "49bebcac721dcc56c84213c2f3dba851ccdde69183b10be3356293c926180581" + "wasm32": "29838c85d239e83665cd9201eae6b4ebeef875e798ddf01a1f07804d5d519aa7" }, "dependencyClosures": { "wasm32": [ @@ -1193,7 +1193,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "96e587a94acbf662c70c87ec7e265b61d313b4816101ad0a41ec8e5b94498c5f" + "cacheKey": "edfa7d165b2d2ead001360dbcb3104e965c7aad1a75566500e4a9c7db90c7c08" }, { "packageName": "sqlite", @@ -1360,7 +1360,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "08446236c220b97848a9444d26251cecc730bb916f8457021df56a4046e1c503" + "wasm32": "eaba21ea0e184a6cf00a87c99273c88d6b0ff8aee4fabcf4459770aa3483f0cc" }, "dependencyClosures": { "wasm32": [ @@ -1413,8 +1413,8 @@ "wasm64" ], "cacheKeys": { - "wasm32": "91aff7b9e519b98353523db54a4e0ce139a8f6b37df64b07d53a9b1132afbc3c", - "wasm64": "e584bd74f7e9f6cf1b5fc5d83184afbea7e2e1afba4de58d8b08208e9176fa6f" + "wasm32": "c9ac6a6efa075623c727c7b33b34439a31caf814d626d11bc6bfa22fe4d25b0c", + "wasm64": "b01238af64fcee0d288a1413f069cc86be29621cf8c602fffc0e563176cab991" }, "dependencyClosures": { "wasm32": [ @@ -1746,7 +1746,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "d923e282640dc6b68f226f68f89a147eda0aa775bd6899246bccea534a5be3be" + "wasm32": "e1dead1a43da2fbc91fe897cbd65c61f5720aa8046670bd7de0b0be61787f8d7" }, "dependencyClosures": { "wasm32": [ @@ -1808,7 +1808,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "96e587a94acbf662c70c87ec7e265b61d313b4816101ad0a41ec8e5b94498c5f" + "cacheKey": "edfa7d165b2d2ead001360dbcb3104e965c7aad1a75566500e4a9c7db90c7c08" }, { "packageName": "sqlite", @@ -1838,7 +1838,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "403a4321700039225a1224421293a0aeaa6c7ad6456759a3399e5d0a4c6a534c" + "wasm32": "44f58deb30e411e6e91ef3f77a9ecfe5e52e67a1456a2fba07a8d3115a07e293" }, "dependencyClosures": { "wasm32": [ @@ -1860,7 +1860,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "96e587a94acbf662c70c87ec7e265b61d313b4816101ad0a41ec8e5b94498c5f" + "cacheKey": "edfa7d165b2d2ead001360dbcb3104e965c7aad1a75566500e4a9c7db90c7c08" } ] }, @@ -1922,7 +1922,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "401cb759b750c5ccc6bcfec5ff04500c270718b2345e604630b3fa2a9769f0c6" + "wasm32": "b47cf3b19890b4cc2b0127339d5ac15009b78dfc3b491d43e83eb7d4c2165b3a" }, "dependencyClosures": { "wasm32": [ @@ -1944,7 +1944,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "96e587a94acbf662c70c87ec7e265b61d313b4816101ad0a41ec8e5b94498c5f" + "cacheKey": "edfa7d165b2d2ead001360dbcb3104e965c7aad1a75566500e4a9c7db90c7c08" }, { "packageName": "spidermonkey", @@ -1995,7 +1995,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "5ba81a4073afa11a913a2cb53865af56cd58731d9c262240fea425e5fe63ddc2" + "wasm32": "869931ebd15307cf07c27e09776891ab348bb5410aeba23385b15ac8617a3af5" }, "dependencyClosures": { "wasm32": [ @@ -2418,7 +2418,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "97185cb95c82b5abc19b794210e309a128a07648dde2a7efbbb2e79a9d7da16d" + "wasm32": "6499248529a342f180c4ee2d1bebdbbe878a177b2d50574c8c93ec24575ab0d0" }, "dependencyClosures": { "wasm32": [ @@ -2478,7 +2478,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "1d08e1584b3fb853cb2dd2aca185d24077d872f26004e162f26732358b4f5ee2" + "wasm32": "176cc998ce64ad14f5f99456093ac275bc23f49c539655f9a62e89bcdcc83c91" }, "dependencyClosures": { "wasm32": [ @@ -2515,7 +2515,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "58dbbd378cd51158808cb319f248416c4b948d03ff73d21fbb2120ec7a9fae22" + "wasm32": "d4ed5891e3146cfe2a3708a135666f91112170e47e3ef0f182031b7830d9f124" }, "dependencyClosures": { "wasm32": [ @@ -2728,7 +2728,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "96e587a94acbf662c70c87ec7e265b61d313b4816101ad0a41ec8e5b94498c5f" + "wasm32": "edfa7d165b2d2ead001360dbcb3104e965c7aad1a75566500e4a9c7db90c7c08" }, "dependencyClosures": { "wasm32": [] @@ -3020,7 +3020,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c5cc2988d67af648504715c20b76b299992ed6eca495c112e52a4b3f655ed4ab" + "wasm32": "f62e8e8f07d047b7555eb931cb22109d997250896c38a16823204188aa3a43e5" }, "dependencyClosures": { "wasm32": [ @@ -3082,7 +3082,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "96e587a94acbf662c70c87ec7e265b61d313b4816101ad0a41ec8e5b94498c5f" + "cacheKey": "edfa7d165b2d2ead001360dbcb3104e965c7aad1a75566500e4a9c7db90c7c08" }, { "packageName": "sqlite", From ece8ea24fe3fc2b1c548a7c5399234186455926c Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Tue, 11 Aug 2026 10:37:44 -0400 Subject: [PATCH 66/82] PTY: Preserve slave ownership and mode --- apps/browser-demos/test/pty-ownership.spec.ts | 42 + crates/kernel/src/pty.rs | 32 +- crates/kernel/src/syscalls.rs | 853 ++++++++++++++++-- docs/posix-status.md | 12 +- host/test/pty-ownership.test.ts | 26 + programs/pty-ownership.c | 298 ++++++ 6 files changed, 1189 insertions(+), 74 deletions(-) create mode 100644 apps/browser-demos/test/pty-ownership.spec.ts create mode 100644 host/test/pty-ownership.test.ts create mode 100644 programs/pty-ownership.c diff --git a/apps/browser-demos/test/pty-ownership.spec.ts b/apps/browser-demos/test/pty-ownership.spec.ts new file mode 100644 index 0000000000..edc0e5817a --- /dev/null +++ b/apps/browser-demos/test/pty-ownership.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from "@playwright/test"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runFetchedWasmProgram } from "./run-fetched-wasm-program"; + +const here = dirname(fileURLToPath(import.meta.url)); +const programPath = resolve( + here, + "../../../local-binaries/programs/wasm32/pty-ownership.wasm", +); + +test("production browser workers preserve devpts metadata and permissions", async ({ + page, + baseURL, +}) => { + expect(baseURL).toBeTruthy(); + const runtimeErrors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") { + runtimeErrors.push(`console: ${message.text()}`); + } + }); + page.on("pageerror", (error) => { + runtimeErrors.push(`pageerror: ${error.message}`); + }); + + await page.goto(new URL("/pages/test-runner/?minimal=1", baseURL).href); + await page.waitForFunction(() => (window as any).__testRunnerReady === true); + + const programUrl = new URL(`/@fs/${programPath}`, baseURL).href; + const result = await page.evaluate(runFetchedWasmProgram, { + programUrl, + argv: ["pty-ownership"], + timeoutMs: 20_000, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("PTY_OWNERSHIP_PASS"); + expect(result.stderr).toBe(""); + expect(result.hostDiagnostics).toEqual([]); + expect(runtimeErrors).toEqual([]); +}); diff --git a/crates/kernel/src/pty.rs b/crates/kernel/src/pty.rs index bd92ae145c..a2db53cfe8 100644 --- a/crates/kernel/src/pty.rs +++ b/crates/kernel/src/pty.rs @@ -7,6 +7,9 @@ use core::cell::UnsafeCell; /// Maximum number of concurrent PTY pairs. pub const MAX_PTYS: usize = 64; +/// Initial permissions for a newly allocated devpts slave. +pub const DEFAULT_SLAVE_MODE: u32 = 0o620; + /// Default capacity for PTY data buffers (bytes). const PTY_BUF_CAPACITY: usize = 4096; @@ -16,9 +19,12 @@ const PTY_BUF_CAPACITY: usize = 4096; /// master write → line discipline → slave read (input: keyboard → program) /// slave write → output processing → master read (output: program → screen) pub struct PtyPair { - /// Stable owner captured from the creator's effective credentials. + /// Persistent devpts metadata initialized from the creator's effective + /// credentials. In the absence of a separately configured tty group, the + /// creator's effective GID is the authoritative tty-group source. owner_uid: u32, owner_gid: u32, + mode: u32, /// Terminal state (termios attributes, winsize, foreground pgrp). pub terminal: TerminalState, /// Input buffer: data written by master, readable from slave (after line discipline). @@ -38,6 +44,7 @@ impl PtyPair { PtyPair { owner_uid, owner_gid, + mode: DEFAULT_SLAVE_MODE, terminal: TerminalState::new(), input_buf: VecDeque::with_capacity(PTY_BUF_CAPACITY), output_buf: VecDeque::with_capacity(PTY_BUF_CAPACITY), @@ -55,6 +62,19 @@ impl PtyPair { self.owner_gid } + pub fn mode(&self) -> u32 { + self.mode + } + + pub fn set_mode(&mut self, mode: u32) { + self.mode = mode & 0o7777; + } + + pub fn set_owner(&mut self, uid: u32, gid: u32) { + self.owner_uid = uid; + self.owner_gid = gid; + } + /// Process a byte through the line discipline (for master→slave input). /// Returns an optional signal number if ISIG matched a signal character. /// Echo bytes are appended to the output buffer (master read side). @@ -276,6 +296,7 @@ mod tests { let pty = get_pty(idx).unwrap(); assert!(pty.locked); assert_eq!((pty.owner_uid(), pty.owner_gid()), (1000, 2000)); + assert_eq!(pty.mode(), DEFAULT_SLAVE_MODE); assert_eq!(pty.master_refs, 0); assert_eq!(pty.slave_refs, 0); @@ -285,6 +306,15 @@ mod tests { free_pty(idx); let idx3 = alloc_pty(5000, 6000).unwrap(); assert_eq!(idx3, 0); // reuses freed slot + let replacement = get_pty(idx3).unwrap(); + assert_eq!( + ( + replacement.owner_uid(), + replacement.owner_gid(), + replacement.mode() + ), + (5000, 6000, DEFAULT_SLAVE_MODE), + ); reset_table(); } diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index e94cdfcdb8..c2de26cf2d 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -2943,6 +2943,36 @@ fn try_open_fifo( .map(Some) } +/// Publish one new PTY endpoint OFD as an fd, undoing both ownership layers +/// if the descriptor table rejects publication. +fn publish_pty_fd( + proc: &mut Process, + host: &mut dyn HostIO, + file_type: FileType, + pty_idx: usize, + status_flags: u32, + fd_flags: u32, + path: Vec, +) -> Result { + let pty = crate::pty::get_pty(pty_idx).ok_or(Errno::EIO)?; + match file_type { + FileType::PtyMaster => pty.master_refs += 1, + FileType::PtySlave => pty.slave_refs += 1, + _ => return Err(Errno::EINVAL), + } + let ofd_idx = proc + .ofd_table + .create(file_type, status_flags, pty_idx as i64, path); + match proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags) { + Ok(fd) => Ok(fd), + Err(error) => { + let cleanup = release_ofd_reference_impl(proc, None, host, ofd_idx); + debug_assert!(cleanup.is_ok(), "PTY publication rollback failed"); + Err(error) + } + } +} + /// Open a file, returning the new file descriptor number. pub fn sys_open( proc: &mut Process, @@ -3048,15 +3078,17 @@ pub fn sys_open( if resolved == b"/dev/ptmx" { let pty_idx = crate::pty::alloc_pty(proc.effective_uid(), proc.effective_gid()) .ok_or(Errno::ENOSPC)?; - let pty = crate::pty::get_pty(pty_idx).ok_or(Errno::EIO)?; - pty.master_refs += 1; let status_flags = oflags & !CREATION_FLAGS; - let ofd_idx = - proc.ofd_table - .create(FileType::PtyMaster, status_flags, pty_idx as i64, resolved); let fd_flags = oflags_to_fd_flags(oflags); - let fd = proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags)?; - return Ok(fd); + return publish_pty_fd( + proc, + host, + FileType::PtyMaster, + pty_idx, + status_flags, + fd_flags, + resolved, + ); } // /dev/pts/N — open PTY slave @@ -3067,40 +3099,53 @@ pub fn sys_open( if pty.locked { return Err(Errno::EIO); // must call unlockpt first } - let stat = pty_pair_stat(pty_idx, pty.owner_uid(), pty.owner_gid()); + let stat = pty_pair_stat(pty_idx).ok_or(Errno::ENOENT)?; check_access(proc, &stat, open_access_mask(oflags, &stat))?; - pty.slave_refs += 1; let status_flags = oflags & !CREATION_FLAGS; - let ofd_idx = - proc.ofd_table - .create(FileType::PtySlave, status_flags, pty_idx as i64, resolved); let fd_flags = oflags_to_fd_flags(oflags); - let fd = proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags)?; - return Ok(fd); + return publish_pty_fd( + proc, + host, + FileType::PtySlave, + pty_idx, + status_flags, + fd_flags, + resolved, + ); } // /dev/tty — open controlling terminal (alias for current session's PTY or stdin) if resolved == b"/dev/tty" { // Check if any open fd refers to a PTY slave — use that - let pty_ofd = proc.fd_table.iter().find_map(|(_, entry)| { - proc.ofd_table - .get(entry.ofd_ref.0) - .is_some_and(|ofd| ofd.file_type == FileType::PtySlave) - .then_some(entry.ofd_ref) + let pty_idx = proc.fd_table.iter().find_map(|(_, entry)| { + let ofd = proc.ofd_table.get(entry.ofd_ref.0)?; + (ofd.file_type == FileType::PtySlave).then_some(ofd.host_handle as usize) }); - if let Some(ofd_ref) = pty_ofd { - proc.ofd_table.inc_ref(ofd_ref.0); + if let Some(pty_idx) = pty_idx { + let status_flags = oflags & !CREATION_FLAGS; let fd_flags = oflags_to_fd_flags(oflags); - let fd = proc.fd_table.alloc(ofd_ref, fd_flags)?; - return Ok(fd); + return publish_pty_fd( + proc, + host, + FileType::PtySlave, + pty_idx, + status_flags, + fd_flags, + resolved, + ); } // Fallback: dup stdin (fd 0) as the controlling terminal if let Ok(entry) = proc.fd_table.get(0) { let ofd_ref = entry.ofd_ref; proc.ofd_table.inc_ref(ofd_ref.0); let fd_flags = oflags_to_fd_flags(oflags); - let fd = proc.fd_table.alloc(ofd_ref, fd_flags)?; - return Ok(fd); + return match proc.fd_table.alloc(ofd_ref, fd_flags) { + Ok(fd) => Ok(fd), + Err(error) => { + proc.ofd_table.dec_ref(ofd_ref.0); + Err(error) + } + }; } return Err(Errno::ENXIO); } @@ -6416,10 +6461,13 @@ pub fn sys_fstat(proc: &Process, host: &mut dyn HostIO, fd: i32) -> Result WasmStat { - WasmStat { +fn pty_slave_index(resolved: &[u8]) -> Option { + resolved + .strip_prefix(b"/dev/pts/") + .and_then(parse_ascii_usize) +} + +/// Build devpts slave metadata from the one record owned by the live pair. +/// Both path and descriptor stat flow through this helper. +fn pty_pair_stat(pty_idx: usize) -> Option { + let pty = crate::pty::get_pty(pty_idx)?; + Some(WasmStat { st_dev: 5, st_ino: 0x50545900 + pty_idx as u64, - st_mode: S_IFCHR | 0o620, + st_mode: S_IFCHR | pty.mode(), st_nlink: 1, - st_uid: uid, - st_gid: gid, + st_uid: pty.owner_uid(), + st_gid: pty.owner_gid(), + st_size: 0, + st_atime_sec: 0, + st_atime_nsec: 0, + st_mtime_sec: 0, + st_mtime_nsec: 0, + st_ctime_sec: 0, + st_ctime_nsec: 0, + _pad: 0, + }) +} + +fn pty_alias_stat(ino: u64) -> WasmStat { + WasmStat { + st_dev: 5, + st_ino: ino, + st_mode: S_IFCHR | crate::pty::DEFAULT_SLAVE_MODE, + st_nlink: 1, + st_uid: 0, + st_gid: 0, st_size: 0, st_atime_sec: 0, st_atime_nsec: 0, @@ -6911,16 +6987,16 @@ fn pty_pair_stat(pty_idx: usize, uid: u32, gid: u32) -> WasmStat { } } -/// Return stable PTY-pair metadata. The non-pair clone and controlling-device -/// nodes are root-owned device metadata rather than observer-owned aliases. +/// Return persistent devpts metadata or stable metadata for the distinct +/// clone and controlling-device nodes. fn match_pty_stat(resolved: &[u8]) -> Option { - if resolved == b"/dev/ptmx" || resolved == b"/dev/tty" { - return Some(pty_pair_stat(0, 0, 0)); + if resolved == b"/dev/ptmx" { + return Some(pty_alias_stat(0x50544d58)); // "PTMX" } - let suffix = resolved.strip_prefix(b"/dev/pts/")?; - let pty_idx = parse_ascii_usize(suffix)?; - let pty = crate::pty::get_pty(pty_idx)?; - Some(pty_pair_stat(pty_idx, pty.owner_uid(), pty.owner_gid())) + if resolved == b"/dev/tty" { + return Some(pty_alias_stat(0x54545900)); // "TTY\0" + } + pty_pair_stat(pty_slave_index(resolved)?) } fn unix_socket_path_stat( @@ -7452,6 +7528,9 @@ pub fn sys_chmod( mode: u32, ) -> Result<(), Errno> { let resolved = resolve_namespace_path(proc, host, path, PathResolveOptions::FOLLOW)?.path; + if chmod_pty_path(proc, host, &resolved, mode)?.is_some() { + return Ok(()); + } ensure_host_mutable_namespace_path(&resolved)?; check_search_path(proc, host, &resolved)?; let st = host.host_stat(&resolved)?; @@ -7459,6 +7538,23 @@ pub fn sys_chmod( host.host_chmod(&resolved, mode) } +fn chmod_pty_path( + proc: &Process, + host: &mut dyn HostIO, + resolved: &[u8], + mode: u32, +) -> Result, Errno> { + let Some(pty_idx) = pty_slave_index(resolved) else { + return Ok(None); + }; + check_search_path(proc, host, resolved)?; + let st = pty_pair_stat(pty_idx).ok_or(Errno::ENOENT)?; + check_owner_or_root(proc, &st)?; + let pty = crate::pty::get_pty(pty_idx).ok_or(Errno::ENOENT)?; + pty.set_mode(mode); + Ok(Some(())) +} + const CHOWN_ID_UNCHANGED: u32 = u32::MAX; fn prepare_chown_ids( @@ -7492,6 +7588,24 @@ fn prepare_chown_ids( )) } +fn chown_pty_path( + proc: &Process, + host: &mut dyn HostIO, + resolved: &[u8], + uid: u32, + gid: u32, +) -> Result, Errno> { + let Some(pty_idx) = pty_slave_index(resolved) else { + return Ok(None); + }; + check_search_path(proc, host, resolved)?; + let st = pty_pair_stat(pty_idx).ok_or(Errno::ENOENT)?; + let (uid, gid) = prepare_chown_ids(proc, &st, uid, gid)?; + let pty = crate::pty::get_pty(pty_idx).ok_or(Errno::ENOENT)?; + pty.set_owner(uid, gid); + Ok(Some(())) +} + pub fn sys_chown( proc: &mut Process, host: &mut dyn HostIO, @@ -7500,6 +7614,9 @@ pub fn sys_chown( gid: u32, ) -> Result<(), Errno> { let resolved = resolve_namespace_path(proc, host, path, PathResolveOptions::FOLLOW)?.path; + if chown_pty_path(proc, host, &resolved, uid, gid)?.is_some() { + return Ok(()); + } ensure_host_mutable_namespace_path(&resolved)?; check_search_path(proc, host, &resolved)?; let st = host.host_stat(&resolved)?; @@ -7515,6 +7632,9 @@ pub fn sys_lchown( gid: u32, ) -> Result<(), Errno> { let resolved = resolve_namespace_path(proc, host, path, PathResolveOptions::NOFOLLOW)?; + if chown_pty_path(proc, host, &resolved.path, uid, gid)?.is_some() { + return Ok(()); + } ensure_host_mutable_namespace_path(&resolved.path)?; check_search_path(proc, host, &resolved.path)?; let st = resolved.stat.ok_or(Errno::ENOENT)?; @@ -13808,15 +13928,17 @@ pub fn sys_openat( if resolved == b"/dev/ptmx" { let pty_idx = crate::pty::alloc_pty(proc.effective_uid(), proc.effective_gid()) .ok_or(Errno::ENOSPC)?; - let pty = crate::pty::get_pty(pty_idx).ok_or(Errno::EIO)?; - pty.master_refs += 1; let status_flags = oflags & !CREATION_FLAGS; - let ofd_idx = - proc.ofd_table - .create(FileType::PtyMaster, status_flags, pty_idx as i64, resolved); let fd_flags = oflags_to_fd_flags(oflags); - let fd = proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags)?; - return Ok(fd); + return publish_pty_fd( + proc, + host, + FileType::PtyMaster, + pty_idx, + status_flags, + fd_flags, + resolved, + ); } // /dev/pts/N — open PTY slave @@ -13827,38 +13949,51 @@ pub fn sys_openat( if pty.locked { return Err(Errno::EIO); } - let stat = pty_pair_stat(pty_idx, pty.owner_uid(), pty.owner_gid()); + let stat = pty_pair_stat(pty_idx).ok_or(Errno::ENOENT)?; check_access(proc, &stat, open_access_mask(oflags, &stat))?; - pty.slave_refs += 1; let status_flags = oflags & !CREATION_FLAGS; - let ofd_idx = - proc.ofd_table - .create(FileType::PtySlave, status_flags, pty_idx as i64, resolved); let fd_flags = oflags_to_fd_flags(oflags); - let fd = proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags)?; - return Ok(fd); + return publish_pty_fd( + proc, + host, + FileType::PtySlave, + pty_idx, + status_flags, + fd_flags, + resolved, + ); } // /dev/tty — open controlling terminal if resolved == b"/dev/tty" { - let pty_ofd = proc.fd_table.iter().find_map(|(_, entry)| { - proc.ofd_table - .get(entry.ofd_ref.0) - .is_some_and(|ofd| ofd.file_type == FileType::PtySlave) - .then_some(entry.ofd_ref) + let pty_idx = proc.fd_table.iter().find_map(|(_, entry)| { + let ofd = proc.ofd_table.get(entry.ofd_ref.0)?; + (ofd.file_type == FileType::PtySlave).then_some(ofd.host_handle as usize) }); - if let Some(ofd_ref) = pty_ofd { - proc.ofd_table.inc_ref(ofd_ref.0); + if let Some(pty_idx) = pty_idx { + let status_flags = oflags & !CREATION_FLAGS; let fd_flags = oflags_to_fd_flags(oflags); - let fd = proc.fd_table.alloc(ofd_ref, fd_flags)?; - return Ok(fd); + return publish_pty_fd( + proc, + host, + FileType::PtySlave, + pty_idx, + status_flags, + fd_flags, + resolved, + ); } if let Ok(entry) = proc.fd_table.get(0) { let ofd_ref = entry.ofd_ref; proc.ofd_table.inc_ref(ofd_ref.0); let fd_flags = oflags_to_fd_flags(oflags); - let fd = proc.fd_table.alloc(ofd_ref, fd_flags)?; - return Ok(fd); + return match proc.fd_table.alloc(ofd_ref, fd_flags) { + Ok(fd) => Ok(fd), + Err(error) => { + proc.ofd_table.dec_ref(ofd_ref.0); + Err(error) + } + }; } return Err(Errno::ENXIO); } @@ -13997,6 +14132,9 @@ pub fn sys_fstatat( proc.effective_gid(), )); } + if let Some(st) = match_pty_stat(&resolved) { + return Ok(st); + } if let Some(target_fd) = match_dev_fd(&resolved) { if flags & AT_SYMLINK_NOFOLLOW != 0 { return dev_fd_lstat(proc, &resolved, target_fd); @@ -15978,7 +16116,18 @@ pub fn sys_fchmod( check_owner_or_root(proc, &st)?; host.host_fchmod(ofd.host_handle, mode) } - // CharDevice / Pipe / Socket / PtyMaster / PtySlave / etc.: Linux + FileType::PtySlave => { + if ofd.path == b"/dev/tty" { + return Ok(()); + } + let pty_idx = usize::try_from(ofd.host_handle).map_err(|_| Errno::EIO)?; + let st = pty_pair_stat(pty_idx).ok_or(Errno::EIO)?; + check_owner_or_root(proc, &st)?; + let pty = crate::pty::get_pty(pty_idx).ok_or(Errno::EIO)?; + pty.set_mode(mode); + Ok(()) + } + // CharDevice / Pipe / Socket / PtyMaster / etc.: Linux // allows fchmod on these (e.g. on /dev/stderr or a unix-domain // socket fd — daemons like dinit do this routinely and abort on // EINVAL). The mode change has no observable effect for kernel- @@ -16032,6 +16181,17 @@ pub fn sys_fchown( let (uid, gid) = prepare_chown_ids(proc, &st, uid, gid)?; host.host_fchown(ofd.host_handle, uid, gid) } + FileType::PtySlave => { + if ofd.path == b"/dev/tty" { + return Ok(()); + } + let pty_idx = usize::try_from(ofd.host_handle).map_err(|_| Errno::EIO)?; + let st = pty_pair_stat(pty_idx).ok_or(Errno::EIO)?; + let (uid, gid) = prepare_chown_ids(proc, &st, uid, gid)?; + let pty = crate::pty::get_pty(pty_idx).ok_or(Errno::EIO)?; + pty.set_owner(uid, gid); + Ok(()) + } // Match sys_fchmod above — accept the call on kernel-owned fd types // whose ownership metadata is not independently mutable. Regular // files, directories, and named FIFOs above still use the complete @@ -16166,6 +16326,9 @@ pub fn sys_fchmodat( _flags: u32, ) -> Result<(), Errno> { let resolved = resolve_at_path(proc, host, dirfd, path, PathResolveOptions::FOLLOW)?.path; + if chmod_pty_path(proc, host, &resolved, mode)?.is_some() { + return Ok(()); + } ensure_host_mutable_namespace_path(&resolved)?; check_search_path(proc, host, &resolved)?; let st = host.host_stat(&resolved)?; @@ -16195,6 +16358,9 @@ pub fn sys_fchownat( PathResolveOptions::FOLLOW }; let resolved = resolve_at_path(proc, host, dirfd, path, options)?; + if chown_pty_path(proc, host, &resolved.path, uid, gid)?.is_some() { + return Ok(()); + } ensure_host_mutable_namespace_path(&resolved.path)?; check_search_path(proc, host, &resolved.path)?; let st = resolved.stat.ok_or(Errno::ENOENT)?; @@ -17111,6 +17277,35 @@ mod tests { }); } + fn assert_same_stat(actual: &WasmStat, expected: &WasmStat) { + assert_eq!(actual.st_dev, expected.st_dev); + assert_eq!(actual.st_ino, expected.st_ino); + assert_eq!(actual.st_mode, expected.st_mode); + assert_eq!(actual.st_nlink, expected.st_nlink); + assert_eq!(actual.st_uid, expected.st_uid); + assert_eq!(actual.st_gid, expected.st_gid); + assert_eq!(actual.st_size, expected.st_size); + assert_eq!(actual.st_atime_sec, expected.st_atime_sec); + assert_eq!(actual.st_atime_nsec, expected.st_atime_nsec); + assert_eq!(actual.st_mtime_sec, expected.st_mtime_sec); + assert_eq!(actual.st_mtime_nsec, expected.st_mtime_nsec); + assert_eq!(actual.st_ctime_sec, expected.st_ctime_sec); + assert_eq!(actual.st_ctime_nsec, expected.st_ctime_nsec); + } + + fn open_test_path( + proc: &mut Process, + host: &mut MockHostIO, + path: &[u8], + use_openat: bool, + ) -> Result { + if use_openat { + sys_openat(proc, host, AT_FDCWD, path, O_RDWR, 0) + } else { + sys_open(proc, host, path, O_RDWR, 0) + } + } + struct PtyFixture { _pty_table: std::sync::MutexGuard<'static, ()>, proc: Process, @@ -18639,6 +18834,530 @@ mod tests { crate::pty::free_pty(pty_idx); } + #[test] + fn pty_master_metadata_does_not_alias_the_devpts_slave() { + let _pty_table = crate::pty::test_table_lock(); + let mut host = MockHostIO::new(); + let mut creator = Process::new(28); + set_test_credentials(&mut creator, 1000, 1000, 2000, 2000, &[]); + let master_fd = sys_open(&mut creator, &mut host, b"/dev/ptmx", O_RDWR, 0).unwrap(); + let master_ofd = creator.fd_table.get(master_fd).unwrap().ofd_ref.0; + let pty_idx = creator.ofd_table.get(master_ofd).unwrap().host_handle as usize; + crate::pty::get_pty(pty_idx).unwrap().locked = false; + let slave_path = format!("/dev/pts/{pty_idx}").into_bytes(); + + let master_path_stat = sys_stat(&mut creator, &mut host, b"/dev/ptmx").unwrap(); + let master_fd_stat = sys_fstat(&creator, &mut host, master_fd).unwrap(); + let slave_stat = sys_stat(&mut creator, &mut host, &slave_path).unwrap(); + + assert_same_stat(&master_fd_stat, &master_path_stat); + assert_eq!( + (master_fd_stat.st_uid, master_fd_stat.st_gid), + (0, 0), + "the master endpoint is /dev/ptmx, not its allocated slave", + ); + assert_eq!( + (slave_stat.st_uid, slave_stat.st_gid), + (1000, 2000), + ); + assert_ne!( + master_fd_stat.st_ino, slave_stat.st_ino, + "the clone node and allocated slave are distinct devices", + ); + + sys_fchmod(&mut creator, &mut host, master_fd, 0o777).unwrap(); + sys_fchown(&mut creator, &mut host, master_fd, 77, 88).unwrap(); + assert_same_stat( + &sys_stat(&mut creator, &mut host, &slave_path).unwrap(), + &slave_stat, + ); + sys_close(&mut creator, &mut host, master_fd).unwrap(); + assert!(crate::pty::get_pty(pty_idx).is_none()); + } + + #[test] + fn pty_slave_fstatat_and_statx_use_the_live_pair_record() { + let _pty_table = crate::pty::test_table_lock(); + let mut host = MockHostIO::new(); + let mut owner = Process::new(28_100); + set_test_credentials(&mut owner, 1000, 1000, 2000, 2000, &[]); + let master_fd = sys_open(&mut owner, &mut host, b"/dev/ptmx", O_RDWR, 0).unwrap(); + let master_ofd = owner.fd_table.get(master_fd).unwrap().ofd_ref.0; + let pty_idx = owner.ofd_table.get(master_ofd).unwrap().host_handle as usize; + crate::pty::get_pty(pty_idx).unwrap().locked = false; + let slave_path = format!("/dev/pts/{pty_idx}").into_bytes(); + let slave_fd = sys_open(&mut owner, &mut host, &slave_path, O_RDWR, 0).unwrap(); + + sys_chmod(&mut owner, &mut host, &slave_path, 0o640).unwrap(); + set_test_credentials(&mut owner, 0, 0, 0, 0, &[]); + sys_chown(&mut owner, &mut host, &slave_path, 1200, 2200).unwrap(); + let expected = sys_stat(&mut owner, &mut host, &slave_path).unwrap(); + assert_eq!( + (expected.st_mode & 0o7777, expected.st_uid, expected.st_gid), + (0o640, 1200, 2200), + ); + + let via_fstatat = + sys_fstatat(&mut owner, &mut host, AT_FDCWD, &slave_path, 0).unwrap(); + assert_same_stat(&via_fstatat, &expected); + let via_statx = + sys_statx(&mut owner, &mut host, AT_FDCWD, &slave_path, 0, u32::MAX).unwrap(); + assert_same_stat(&via_statx, &expected); + + sys_close(&mut owner, &mut host, slave_fd).unwrap(); + sys_close(&mut owner, &mut host, master_fd).unwrap(); + assert!(crate::pty::get_pty(pty_idx).is_none()); + } + + #[test] + fn dev_tty_fd_keeps_control_alias_identity_and_cannot_mutate_slave() { + let _pty_table = crate::pty::test_table_lock(); + let mut host = MockHostIO::new(); + let mut owner = Process::new(28_200); + set_test_credentials(&mut owner, 1000, 1000, 2000, 2000, &[]); + let master_fd = sys_open(&mut owner, &mut host, b"/dev/ptmx", O_RDWR, 0).unwrap(); + let master_ofd = owner.fd_table.get(master_fd).unwrap().ofd_ref.0; + let pty_idx = owner.ofd_table.get(master_ofd).unwrap().host_handle as usize; + crate::pty::get_pty(pty_idx).unwrap().locked = false; + let slave_path = format!("/dev/pts/{pty_idx}").into_bytes(); + let slave_fd = sys_open(&mut owner, &mut host, &slave_path, O_RDWR, 0).unwrap(); + let slave_before = sys_stat(&mut owner, &mut host, &slave_path).unwrap(); + + set_test_credentials(&mut owner, 0, 0, 0, 0, &[]); + let tty_path = sys_stat(&mut owner, &mut host, b"/dev/tty").unwrap(); + let tty_fd = sys_open(&mut owner, &mut host, b"/dev/tty", O_RDWR, 0).unwrap(); + assert_ne!( + owner.fd_table.get(tty_fd).unwrap().ofd_ref, + owner.fd_table.get(slave_fd).unwrap().ofd_ref, + "/dev/tty must be a distinct open description, not a dup of the slave", + ); + assert_same_stat(&sys_fstat(&owner, &mut host, tty_fd).unwrap(), &tty_path); + assert_ne!(tty_path.st_ino, slave_before.st_ino); + + sys_fchmod(&mut owner, &mut host, tty_fd, 0o777).unwrap(); + sys_fchown(&mut owner, &mut host, tty_fd, 77, 88).unwrap(); + assert_same_stat(&sys_fstat(&owner, &mut host, tty_fd).unwrap(), &tty_path); + assert_same_stat( + &sys_stat(&mut owner, &mut host, &slave_path).unwrap(), + &slave_before, + ); + assert_same_stat( + &sys_fstat(&owner, &mut host, slave_fd).unwrap(), + &slave_before, + ); + + sys_close(&mut owner, &mut host, tty_fd).unwrap(); + sys_close(&mut owner, &mut host, slave_fd).unwrap(); + sys_close(&mut owner, &mut host, master_fd).unwrap(); + assert!(crate::pty::get_pty(pty_idx).is_none()); + } + + #[test] + fn pty_master_emfile_rolls_back_open_and_openat_lifetime() { + let _pty_table = crate::pty::test_table_lock(); + for use_openat in [false, true] { + let mut host = MockHostIO::new(); + let mut proc = Process::new(if use_openat { 28_301 } else { 28_300 }); + proc.fd_table.set_max_fds(3); + let live_ofds_before = proc.ofd_table.iter().count(); + + assert_eq!( + open_test_path(&mut proc, &mut host, b"/dev/ptmx", use_openat), + Err(Errno::EMFILE), + ); + assert_eq!(proc.ofd_table.iter().count(), live_ofds_before); + assert!( + crate::pty::get_pty(0).is_none(), + "failed master publication must destroy the unreferenced pair", + ); + + proc.fd_table.set_max_fds(4); + let master_fd = + open_test_path(&mut proc, &mut host, b"/dev/ptmx", use_openat).unwrap(); + let master_ofd = proc.fd_table.get(master_fd).unwrap().ofd_ref.0; + assert_eq!(master_ofd, live_ofds_before); + assert_eq!(proc.ofd_table.get(master_ofd).unwrap().host_handle, 0); + sys_close(&mut proc, &mut host, master_fd).unwrap(); + assert_eq!(proc.ofd_table.iter().count(), live_ofds_before); + assert!(crate::pty::get_pty(0).is_none()); + } + } + + #[test] + fn pty_slave_emfile_rolls_back_open_and_openat_lifetime() { + let _pty_table = crate::pty::test_table_lock(); + for use_openat in [false, true] { + let mut host = MockHostIO::new(); + let mut proc = Process::new(if use_openat { 28_401 } else { 28_400 }); + let master_fd = sys_open(&mut proc, &mut host, b"/dev/ptmx", O_RDWR, 0).unwrap(); + let master_ofd = proc.fd_table.get(master_fd).unwrap().ofd_ref.0; + let pty_idx = proc.ofd_table.get(master_ofd).unwrap().host_handle as usize; + assert_eq!(pty_idx, 0); + crate::pty::get_pty(pty_idx).unwrap().locked = false; + let slave_path = format!("/dev/pts/{pty_idx}").into_bytes(); + proc.fd_table.set_max_fds(4); + let live_ofds_before = proc.ofd_table.iter().count(); + + assert_eq!( + open_test_path(&mut proc, &mut host, &slave_path, use_openat), + Err(Errno::EMFILE), + ); + assert_eq!(proc.ofd_table.iter().count(), live_ofds_before); + assert_eq!(crate::pty::get_pty(pty_idx).unwrap().slave_refs, 0); + + proc.fd_table.set_max_fds(5); + let slave_fd = + open_test_path(&mut proc, &mut host, &slave_path, use_openat).unwrap(); + let slave_ofd = proc.fd_table.get(slave_fd).unwrap().ofd_ref.0; + assert_eq!(slave_ofd, live_ofds_before); + sys_close(&mut proc, &mut host, slave_fd).unwrap(); + assert_eq!(proc.ofd_table.iter().count(), live_ofds_before); + sys_close(&mut proc, &mut host, master_fd).unwrap(); + assert!(crate::pty::get_pty(pty_idx).is_none()); + + let replacement = sys_open(&mut proc, &mut host, b"/dev/ptmx", O_RDWR, 0).unwrap(); + let replacement_ofd = proc.fd_table.get(replacement).unwrap().ofd_ref.0; + assert_eq!(proc.ofd_table.get(replacement_ofd).unwrap().host_handle, 0); + sys_close(&mut proc, &mut host, replacement).unwrap(); + } + } + + #[test] + fn dev_tty_emfile_rolls_back_open_and_openat_lifetime() { + let _pty_table = crate::pty::test_table_lock(); + for use_openat in [false, true] { + let mut host = MockHostIO::new(); + let mut proc = Process::new(if use_openat { 28_501 } else { 28_500 }); + let master_fd = sys_open(&mut proc, &mut host, b"/dev/ptmx", O_RDWR, 0).unwrap(); + let master_ofd = proc.fd_table.get(master_fd).unwrap().ofd_ref.0; + let pty_idx = proc.ofd_table.get(master_ofd).unwrap().host_handle as usize; + assert_eq!(pty_idx, 0); + crate::pty::get_pty(pty_idx).unwrap().locked = false; + let slave_path = format!("/dev/pts/{pty_idx}").into_bytes(); + let slave_fd = sys_open(&mut proc, &mut host, &slave_path, O_RDWR, 0).unwrap(); + proc.fd_table.set_max_fds(5); + let live_ofds_before = proc.ofd_table.iter().count(); + + assert_eq!( + open_test_path(&mut proc, &mut host, b"/dev/tty", use_openat), + Err(Errno::EMFILE), + ); + assert_eq!(proc.ofd_table.iter().count(), live_ofds_before); + assert_eq!(crate::pty::get_pty(pty_idx).unwrap().slave_refs, 1); + + proc.fd_table.set_max_fds(6); + let tty_fd = + open_test_path(&mut proc, &mut host, b"/dev/tty", use_openat).unwrap(); + let tty_ofd = proc.fd_table.get(tty_fd).unwrap().ofd_ref.0; + assert_eq!(tty_ofd, live_ofds_before); + sys_close(&mut proc, &mut host, tty_fd).unwrap(); + assert_eq!(proc.ofd_table.iter().count(), live_ofds_before); + sys_close(&mut proc, &mut host, slave_fd).unwrap(); + sys_close(&mut proc, &mut host, master_fd).unwrap(); + assert!(crate::pty::get_pty(pty_idx).is_none()); + + proc.fd_table.set_max_fds(4); + let replacement = sys_open(&mut proc, &mut host, b"/dev/ptmx", O_RDWR, 0).unwrap(); + let replacement_ofd = proc.fd_table.get(replacement).unwrap().ofd_ref.0; + assert_eq!(proc.ofd_table.get(replacement_ofd).unwrap().host_handle, 0); + sys_close(&mut proc, &mut host, replacement).unwrap(); + } + } + + #[test] + fn dev_tty_stdin_fallback_emfile_rolls_back_ofd_reference() { + for use_openat in [false, true] { + let mut host = MockHostIO::new(); + let mut proc = Process::new(if use_openat { 28_601 } else { 28_600 }); + let stdin_ofd = proc.fd_table.get(0).unwrap().ofd_ref.0; + let refs_before = proc.ofd_table.get(stdin_ofd).unwrap().ref_count; + proc.fd_table.set_max_fds(3); + + assert_eq!( + open_test_path(&mut proc, &mut host, b"/dev/tty", use_openat), + Err(Errno::EMFILE), + ); + assert_eq!( + proc.ofd_table.get(stdin_ofd).unwrap().ref_count, + refs_before, + ); + + proc.fd_table.set_max_fds(4); + let tty_fd = + open_test_path(&mut proc, &mut host, b"/dev/tty", use_openat).unwrap(); + assert_eq!(proc.fd_table.get(tty_fd).unwrap().ofd_ref.0, stdin_ofd); + assert_eq!( + proc.ofd_table.get(stdin_ofd).unwrap().ref_count, + refs_before + 1, + ); + sys_close(&mut proc, &mut host, tty_fd).unwrap(); + assert_eq!( + proc.ofd_table.get(stdin_ofd).unwrap().ref_count, + refs_before, + ); + } + } + + #[test] + fn pty_slave_metadata_mutations_are_persistent_authorized_and_atomic() { + let _pty_table = crate::pty::test_table_lock(); + let mut host = MockHostIO::new(); + let mut owner = Process::new(29); + set_test_credentials(&mut owner, 1000, 1000, 2000, 2000, &[3000, 4000]); + let master_fd = sys_open(&mut owner, &mut host, b"/dev/ptmx", O_RDWR, 0).unwrap(); + let master_ofd = owner.fd_table.get(master_fd).unwrap().ofd_ref.0; + let pty_idx = owner.ofd_table.get(master_ofd).unwrap().host_handle as usize; + crate::pty::get_pty(pty_idx).unwrap().locked = false; + let slave_path = format!("/dev/pts/{pty_idx}").into_bytes(); + let slave_fd = sys_open(&mut owner, &mut host, &slave_path, O_RDWR, 0).unwrap(); + + let initial = sys_stat(&mut owner, &mut host, &slave_path).unwrap(); + assert_eq!( + (initial.st_mode & 0o7777, initial.st_uid, initial.st_gid), + (0o620, 1000, 2000), + ); + assert_same_stat(&sys_fstat(&owner, &mut host, slave_fd).unwrap(), &initial); + + set_test_credentials(&mut owner, 9000, 9000, 9000, 9000, &[]); + assert_eq!( + sys_chmod(&mut owner, &mut host, &slave_path, 0o600), + Err(Errno::EPERM), + ); + assert_eq!( + sys_fchmod(&mut owner, &mut host, slave_fd, 0o600), + Err(Errno::EPERM), + ); + assert_eq!( + sys_chown( + &mut owner, + &mut host, + &slave_path, + CHOWN_ID_UNCHANGED, + 9000, + ), + Err(Errno::EPERM), + ); + assert_eq!( + sys_lchown( + &mut owner, + &mut host, + &slave_path, + CHOWN_ID_UNCHANGED, + 9000, + ), + Err(Errno::EPERM), + ); + assert_eq!( + sys_fchown( + &mut owner, + &mut host, + slave_fd, + CHOWN_ID_UNCHANGED, + 9000, + ), + Err(Errno::EPERM), + ); + assert_same_stat( + &sys_stat(&mut owner, &mut host, &slave_path).unwrap(), + &initial, + ); + assert_same_stat(&sys_fstat(&owner, &mut host, slave_fd).unwrap(), &initial); + + set_test_credentials(&mut owner, 1000, 1000, 2000, 2000, &[3000, 4000]); + sys_chmod(&mut owner, &mut host, &slave_path, 0o640).unwrap(); + assert_eq!( + sys_stat(&mut owner, &mut host, &slave_path) + .unwrap() + .st_mode + & 0o7777, + 0o640, + ); + sys_fchmod(&mut owner, &mut host, slave_fd, 0o600).unwrap(); + sys_chown( + &mut owner, + &mut host, + &slave_path, + CHOWN_ID_UNCHANGED, + 3000, + ) + .unwrap(); + sys_lchown( + &mut owner, + &mut host, + &slave_path, + CHOWN_ID_UNCHANGED, + 4000, + ) + .unwrap(); + sys_fchown( + &mut owner, + &mut host, + slave_fd, + CHOWN_ID_UNCHANGED, + 2000, + ) + .unwrap(); + let owner_updated = sys_stat(&mut owner, &mut host, &slave_path).unwrap(); + assert_eq!( + ( + owner_updated.st_mode & 0o7777, + owner_updated.st_uid, + owner_updated.st_gid, + ), + (0o600, 1000, 2000), + ); + assert_same_stat( + &sys_fstat(&owner, &mut host, slave_fd).unwrap(), + &owner_updated, + ); + + assert_eq!( + sys_fchown( + &mut owner, + &mut host, + slave_fd, + 1001, + CHOWN_ID_UNCHANGED, + ), + Err(Errno::EPERM), + ); + assert_same_stat( + &sys_fstat(&owner, &mut host, slave_fd).unwrap(), + &owner_updated, + ); + + set_test_credentials(&mut owner, 0, 0, 0, 0, &[]); + sys_chown(&mut owner, &mut host, &slave_path, 1100, 2100).unwrap(); + sys_fchmod(&mut owner, &mut host, slave_fd, 0o660).unwrap(); + sys_fchown(&mut owner, &mut host, slave_fd, 1200, 2200).unwrap(); + sys_lchown(&mut owner, &mut host, &slave_path, 1300, 2300).unwrap(); + let root_updated = sys_stat(&mut owner, &mut host, &slave_path).unwrap(); + assert_eq!( + ( + root_updated.st_mode & 0o7777, + root_updated.st_uid, + root_updated.st_gid, + ), + (0o660, 1300, 2300), + ); + assert_same_stat( + &sys_fstat(&owner, &mut host, slave_fd).unwrap(), + &root_updated, + ); + + sys_close(&mut owner, &mut host, slave_fd).unwrap(); + sys_close(&mut owner, &mut host, master_fd).unwrap(); + assert!(crate::pty::get_pty(pty_idx).is_none()); + } + + #[test] + fn pty_slave_open_uses_live_mode_and_complete_group_membership() { + let _pty_table = crate::pty::test_table_lock(); + let mut host = MockHostIO::new(); + let mut creator = Process::new(30); + set_test_credentials(&mut creator, 1000, 1000, 2000, 2000, &[]); + let master_fd = sys_open(&mut creator, &mut host, b"/dev/ptmx", O_RDWR, 0).unwrap(); + let master_ofd = creator.fd_table.get(master_fd).unwrap().ofd_ref.0; + let pty_idx = creator.ofd_table.get(master_ofd).unwrap().host_handle as usize; + crate::pty::get_pty(pty_idx).unwrap().locked = false; + let slave_path = format!("/dev/pts/{pty_idx}").into_bytes(); + + let mut caller = Process::new(31); + set_test_credentials(&mut caller, 3000, 3000, 4000, 2000, &[]); + let effective_group_fd = + sys_open(&mut caller, &mut host, &slave_path, O_WRONLY, 0).unwrap(); + sys_close(&mut caller, &mut host, effective_group_fd).unwrap(); + assert_eq!( + sys_open(&mut caller, &mut host, &slave_path, O_RDONLY, 0), + Err(Errno::EACCES), + ); + + set_test_credentials(&mut caller, 3000, 3000, 4000, 4000, &[2000]); + let supplementary_fd = + sys_open(&mut caller, &mut host, &slave_path, O_WRONLY, 0).unwrap(); + sys_close(&mut caller, &mut host, supplementary_fd).unwrap(); + + set_test_credentials(&mut caller, 3000, 3000, 4000, 4000, &[]); + let refs_before_denial = crate::pty::get_pty(pty_idx).unwrap().slave_refs; + assert_eq!( + sys_open(&mut caller, &mut host, &slave_path, O_WRONLY, 0), + Err(Errno::EACCES), + ); + assert_eq!( + crate::pty::get_pty(pty_idx).unwrap().slave_refs, + refs_before_denial, + "a denied open must not publish a slave reference", + ); + + let mut root = Process::new(32); + let root_fd = sys_open(&mut root, &mut host, &slave_path, O_RDWR, 0).unwrap(); + sys_close(&mut root, &mut host, root_fd).unwrap(); + + sys_chmod(&mut creator, &mut host, &slave_path, 0o640).unwrap(); + set_test_credentials(&mut caller, 3000, 3000, 4000, 2000, &[]); + let group_read_fd = + sys_open(&mut caller, &mut host, &slave_path, O_RDONLY, 0).unwrap(); + sys_close(&mut caller, &mut host, group_read_fd).unwrap(); + assert_eq!( + sys_open(&mut caller, &mut host, &slave_path, O_WRONLY, 0), + Err(Errno::EACCES), + ); + set_test_credentials(&mut caller, 3000, 3000, 4000, 4000, &[2000]); + let supplementary_read_fd = + sys_open(&mut caller, &mut host, &slave_path, O_RDONLY, 0).unwrap(); + sys_close(&mut caller, &mut host, supplementary_read_fd).unwrap(); + + sys_close(&mut creator, &mut host, master_fd).unwrap(); + assert!(crate::pty::get_pty(pty_idx).is_none()); + } + + #[test] + fn pty_slave_metadata_survives_reopen_and_is_destroyed_with_the_pair() { + let _pty_table = crate::pty::test_table_lock(); + let mut host = MockHostIO::new(); + let mut owner = Process::new(33); + set_test_credentials(&mut owner, 1000, 1000, 2000, 2000, &[]); + let master_fd = sys_open(&mut owner, &mut host, b"/dev/ptmx", O_RDWR, 0).unwrap(); + let master_ofd = owner.fd_table.get(master_fd).unwrap().ofd_ref.0; + let pty_idx = owner.ofd_table.get(master_ofd).unwrap().host_handle as usize; + crate::pty::get_pty(pty_idx).unwrap().locked = false; + let slave_path = format!("/dev/pts/{pty_idx}").into_bytes(); + let slave_fd = sys_open(&mut owner, &mut host, &slave_path, O_RDWR, 0).unwrap(); + + sys_fchmod(&mut owner, &mut host, slave_fd, 0o600).unwrap(); + sys_close(&mut owner, &mut host, slave_fd).unwrap(); + let while_closed = sys_stat(&mut owner, &mut host, &slave_path).unwrap(); + assert_eq!(while_closed.st_mode & 0o7777, 0o600); + let reopened = sys_open(&mut owner, &mut host, &slave_path, O_RDWR, 0).unwrap(); + assert_same_stat( + &sys_fstat(&owner, &mut host, reopened).unwrap(), + &while_closed, + ); + sys_close(&mut owner, &mut host, reopened).unwrap(); + sys_close(&mut owner, &mut host, master_fd).unwrap(); + + assert!(crate::pty::get_pty(pty_idx).is_none()); + assert_eq!( + sys_stat(&mut owner, &mut host, &slave_path).unwrap_err(), + Errno::ENOENT, + ); + + set_test_credentials(&mut owner, 3000, 3000, 4000, 4000, &[]); + let next_master = sys_open(&mut owner, &mut host, b"/dev/ptmx", O_RDWR, 0).unwrap(); + let next_ofd = owner.fd_table.get(next_master).unwrap().ofd_ref.0; + let reused_idx = owner.ofd_table.get(next_ofd).unwrap().host_handle as usize; + assert_eq!(reused_idx, pty_idx); + crate::pty::get_pty(reused_idx).unwrap().locked = false; + let fresh_path = format!("/dev/pts/{reused_idx}").into_bytes(); + let fresh = sys_stat(&mut owner, &mut host, &fresh_path).unwrap(); + assert_eq!( + (fresh.st_mode & 0o7777, fresh.st_uid, fresh.st_gid), + (0o620, 3000, 4000), + ); + sys_close(&mut owner, &mut host, next_master).unwrap(); + assert!(crate::pty::get_pty(reused_idx).is_none()); + } + #[test] fn permission_sticky_directory_uses_effective_uid_not_saved_uid() { let mut proc = Process::new(22); diff --git a/docs/posix-status.md b/docs/posix-status.md index bec2a9cad9..f286db4867 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -75,8 +75,8 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve | `fsync()` | Partial | Host-delegated for regular files and directories. Node-backed directories use the native durability barrier; memory-backed filesystems have no queued writes. Browser OPFS flushes regular-file access handles, but its API exposes no separate directory durability barrier. Rejects pipes and sockets. | | `fdatasync()` | Partial | Alias for fsync(). No metadata distinction in Wasm environment. | | `truncate()` | Partial | Path-based. Named FIFOs fail with EINVAL without entering their open rendezvous; ordinary paths open O_WRONLY, call ftruncate, and close. | -| `fchmod()` | Partial | Regular files, directories, and named FIFOs update VFS metadata; an unlinked but open named FIFO retains the updated cached inode metadata. O_PATH/O_SEARCH descriptors return EBADF. Other kernel-owned pipes/sockets accept the call as a no-op. Node host-backed files never receive native mode changes after creation. | -| `fchown()` | Partial | Regular files, directories, and named FIFOs update VFS metadata. `(uid_t)-1` and `(gid_t)-1` preserve the corresponding current ID without bypassing descriptor, authorization, or backend-error checks. Root may select arbitrary IDs; an unprivileged owner must preserve its user ID and may select its effective GID or any authoritative supplementary group. On metadata-backed SharedFS and Node regular files, every successful ownership call clears S_ISUID and S_ISGID, regardless of execute bits. Directories and symlinks retain their modes. O_PATH/O_SEARCH descriptors return EBADF. Unlinked open named FIFOs retain updated cached ownership; other kernel-owned non-file descriptors still accept the call as a metadata-less no-op, and Node host-backed ownership changes stay virtual. | +| `fchmod()` | Partial | Regular files, directories, named FIFOs, and devpts slave descriptors update authoritative metadata; an unlinked but open named FIFO retains the updated cached inode metadata, and a devpts slave retains its mode for the PTY pair lifetime. O_PATH/O_SEARCH descriptors return EBADF. Other kernel-owned pipes/sockets accept the call as a no-op. Node host-backed files never receive native mode changes after creation. | +| `fchown()` | Partial | Regular files, directories, named FIFOs, and devpts slave descriptors update authoritative metadata. `(uid_t)-1` and `(gid_t)-1` preserve the corresponding current ID without bypassing descriptor, authorization, or backend-error checks. Root may select arbitrary IDs; an unprivileged owner must preserve its user ID and may select its effective GID or any authoritative supplementary group. On metadata-backed SharedFS and Node regular files, every successful ownership call clears S_ISUID and S_ISGID, regardless of execute bits. Directories, symlinks, and character-device PTY slaves retain their modes. O_PATH/O_SEARCH descriptors return EBADF. Unlinked open named FIFOs retain updated cached ownership; other kernel-owned non-file descriptors still accept the call as a metadata-less no-op, and Node host-backed ownership changes stay virtual. | | `preadv()` | Full | Validates the complete vector and performs one exact-offset scalar read, then scatters only the returned prefix without changing the OFD cursor. | | `pwritev()` | Full | Validates and gathers the complete vector, then performs one exact-offset scalar write without changing the OFD cursor. The aggregate `RLIMIT_FSIZE` decision applies once to that operation. | | `preadv2()` / `pwritev2()` | Partial | Delegates to preadv/pwritev. Extra flags parameter ignored. | @@ -240,7 +240,7 @@ to a different directory than the original OFD. | `rename()` | Partial | Host-delegated. Both paths resolved via kernel cwd. Named-FIFO identities follow file and containing-directory renames, including destination replacement. | | `stat()` / `lstat()` | Partial | Host-delegated. stat follows symlinks, lstat does not. Procfs fd magic links are validated against live fd/OFD pairs: following `/proc//fd/N` returns the target OFD metadata even after its pathname is unlinked, while no-follow operations report the symlink and closed slots return ENOENT. Registered AF_UNIX pathname sockets preserve the backing VFS inode's uid, gid, permissions, timestamps, and link count while reporting `S_IFSOCK`. Registered named FIFOs likewise preserve VFS metadata while reporting `S_IFIFO`; `readdir()` and `getdents64()` report `DT_FIFO`. | | `statfs()` / `fstatfs()` | Partial | Host-backed and virtual filesystem statistics are reported. Mounts default to `ST_NOSUID` in both Node and browser hosts. Only a read-only product backend admitted through a module-private brand over a privately snapshotted, fully materialized and behaviorally isolated tree can clear it; trusted operations use private prototype copies, module-lexical helpers, and captured scalar ABI semantics rather than producer-reachable prototypes, class properties, or generated tables. The resolved mount capability authoritatively sets or clears the bit instead of trusting raw backend flags. The kernel can compute a set-ID transition proposal from retained target metadata, but exec does not commit that proposal to process credentials yet. | -| `chmod()` / `chown()` / `lchown()` | Partial | VFS metadata updates. `chown()` follows the final symlink; `lchown()` changes the link itself, including dangling links. Ownership calls preserve either unchanged-ID sentinel and validate the selected object and authorization before delegation. Root may select arbitrary IDs; an unprivileged owner must preserve its user ID and may select its effective GID or any authoritative supplementary group. On metadata-backed SharedFS and Node regular files, every successful ownership call clears S_ISUID and S_ISGID, regardless of execute bits, while directories and symlinks retain their modes. Node host-backed changes stay in virtual metadata; browser memory-backed mounts store them in the VFS. OPFS has neither symlinks nor ownership metadata, so its existing ownership operations are no-ops. | +| `chmod()` / `chown()` / `lchown()` | Partial | VFS metadata updates, plus persistent updates to the live devpts slave record. `chown()` follows the final symlink; `lchown()` changes the link itself, including dangling links. Ownership calls preserve either unchanged-ID sentinel and validate the selected object and authorization before delegation. Root may select arbitrary IDs; an unprivileged owner must preserve its user ID and may select its effective GID or any authoritative supplementary group. On metadata-backed SharedFS and Node regular files, every successful ownership call clears S_ISUID and S_ISGID, regardless of execute bits, while directories, symlinks, and character-device PTY slaves retain their modes. Node host-backed changes stay in virtual metadata; browser memory-backed mounts store them in the VFS. OPFS has neither symlinks nor ownership metadata, so its existing ownership operations are no-ops. | | `access()` | Partial | Resolves the pathname component-wise and checks traversal plus target permissions with real credentials. `faccessat(..., AT_EACCESS)` selects effective credentials. Both use effective GID and the process's complete supplementary-group membership for group checks. | | `realpath()` | Full | Uses the global component walker against cwd, including mount crossings and relative or absolute symlinks; `missing/..` fails instead of being collapsed lexically, trailing slash requires a directory, and more than 40 symlinks returns ELOOP. | | `symlink()` / `readlink()` | Partial | Host-delegated. Symlink target stored as-is, linkpath resolved. | @@ -381,7 +381,7 @@ proves and reserves only the 128-byte prefix the kernel can write. | `tcgetattr()` / `tcsetattr()` | Partial | Host terminal and PTY fds round-trip musl's exact 60-byte termios layout, including all four flag words, `c_line`, `c_cc`, and input/output speeds; custom syscalls 70/71 use that same layout and no longer expose a second shortened format. Non-terminal character devices return ENOTTY. `TCSANOW` and `TCSADRAIN` preserve unread input across `ICANON` transitions: completed lines and the current edited partial line become raw-readable in byte order, while unread raw bytes become immediately readable if the mode changes back, matching Linux EOF-push behavior. `TCSAFLUSH` discards unread input before applying the change. PTY writes synchronously enter the output queue, so there is no deferred device transmission to await. Implemented line discipline includes `VERASE`, `VKILL`, non-empty-line `VEOF`, ICRNL/INLCR/IGNCR, and ECHO/ECHOE/ECHOK/ECHONL. Remaining gaps: `VMIN`/`VTIME` values round-trip but raw-read timing is approximated, an empty canonical `VEOF` does not create a queued EOF event, a canonical `read()` can return bytes from multiple completed lines instead of stopping after one line, `VWERASE` is not implemented, and exposed input/output flags outside the listed subset do not all have data-path semantics. | | `ioctl()` | Full | 16 terminal ioctls: TCGETS/TCSETS/TCSETSW/TCSETSF (termios), TIOCGPTN (PTY number), TIOCSPTLCK (unlock PTY), TIOCGPGRP/TIOCSPGRP (foreground pgid), TIOCGWINSZ/TIOCSWINSZ (window size + SIGWINCH), TCSBRK/TCXONC/TCFLSH, TIOCGSID/TIOCSCTTY/TIOCNOTTY (session/controlling terminal). Generic: FIONREAD, FIONBIO, FIOCLEX/FIONCLEX, FIOASYNC. Terminal and Linux-VT requests work on host terminals and PTYs and return ENOTTY on other character devices. | | `posix_openpt()` | Full | Opens `/dev/ptmx`, allocates PTY pair, returns master fd. | -| `grantpt()` / `unlockpt()` | Full | `grantpt()` is a no-op (no permissions to set). `unlockpt()` clears the lock flag on the PTY pair. | +| `grantpt()` / `unlockpt()` | Full | PTY allocation already initializes the persistent slave metadata, so `grantpt()` is a no-op. `unlockpt()` clears the lock flag on the PTY pair. | | `ptsname()` | Full | Returns `/dev/pts/N` path for the slave side. | | `ttyname()` | Full | Via `/proc/self/fd/N` readlink on PTY slave fds. | | `tcgetsid()` | Full | Via TIOCGSID ioctl. Returns session ID of the controlling terminal. | @@ -399,9 +399,9 @@ proves and reserves only the 128-byte prefix the kernel can write. | `/dev/stdin` | Full | Symlink alias for `/dev/fd/0`; following metadata is fd 0 metadata. | | `/dev/stdout` | Full | Symlink alias for `/dev/fd/1`; following metadata is fd 1 metadata. | | `/dev/stderr` | Full | Symlink alias for `/dev/fd/2`; following metadata is fd 2 metadata. | -| `/dev/tty` | Partial | Uses the first open PTY-slave OFD as the current controlling-terminal heuristic. When none is open, it currently falls back to fd 0 rather than returning ENXIO; `pathconf()` follows that same OFD selection and therefore does not advertise terminal variables for the captured, pipe-backed case. | +| `/dev/tty` | Partial | Uses the first open PTY-slave OFD as the current controlling-terminal heuristic. A PTY-backed open publishes a distinct control-alias OFD: descriptor stat keeps the root-owned `/dev/tty` identity, and descriptor chmod/chown do not mutate the selected slave. When no PTY slave is open, it currently falls back to fd 0 rather than returning ENXIO; `pathconf()` follows that same OFD selection and therefore does not advertise terminal variables for the captured, pipe-backed case. | | `/dev/ptmx` | Full | PTY master multiplexer. `open()` allocates a new PTY pair, returns master fd. | -| `/dev/pts/*` | Full | PTY slave devices. `posix_openpt()` + `grantpt()` + `unlockpt()` + `ptsname()`. Full line discipline, canonical/raw mode, OPOST/ONLCR, 16 terminal ioctls. | +| `/dev/pts/*` | Full | PTY slave devices. Allocation captures the creator's effective UID and, because no separate tty group is configured, effective GID, with mode `0620`. `stat()`, `lstat()`, `fstatat()`, `statx()`, and descriptor stat share that persistent record; authorized chmod/chown operations update it, and open checks use the caller's current effective credentials and complete supplementary groups. Metadata survives slave close/reopen and is discarded with the pair. `/dev/ptmx` remains a distinct root-owned clone node. Also supports `posix_openpt()` + `grantpt()` + `unlockpt()` + `ptsname()`, full line discipline, canonical/raw mode, OPOST/ONLCR, and 16 terminal ioctls. | | `/dev/fb0` | Full | Linux fbdev framebuffer. Single-open (`EBUSY` for second opener). 640×400 BGRA32 packed-pixel. ioctls: `FBIOGET_VSCREENINFO`, `FBIOGET_FSCREENINFO`, `FBIOPAN_DISPLAY` (no-op success), `FBIOPUT_VSCREENINFO` (validates geometry). `mmap` returns a region in process memory and notifies the host (`bind_framebuffer` callback) so the browser canvas can mirror pixels. `munmap`/`exit`/`exec` discard the image mapping; a surviving fd retains device ownership across exec. Ownership is released after both the final fd and any live mapping are gone, since a mapping remains valid after `close()`. Linux-VT keyboard ioctls (`KDGKBTYPE`/`KDGKBMODE`/`KDSKBMODE`) are accepted on the process's terminal fd so fbDOOM-style software works unmodified; `/dev/fb0` itself is not a terminal. | | `/dev/input/mice` | Full | Linux `mousedev` PS/2 mouse stream. Single-open (`EBUSY` for second pid). 3-byte packets: byte0 button bits + sign/overflow flags, bytes 1..2 signed dx/dy with positive-up dy. Host pushes events via `kernel_inject_mouse_event(dx, dy, buttons)`; the kernel buffers up to 4096 packets (whole-packet drop on overflow). `read()` drains queued bytes; returns `EAGAIN` when empty. `poll()` reports `POLLIN` only when bytes are queued. Ownership and queued packets survive exec with a non-CLOEXEC fd; last close or exit releases and clears them. No IMPS/2 wheel protocol, no `evdev`/`/dev/input/eventN`. | | `/dev/dsp` | Partial (playback only) | Source-compatible OSS PCM playback over the implementation-neutral Kandelo PCM core. U8/S16_LE/S16_BE, mono/stereo, 8–192 kHz; bounded fragment queue with blocking/nonblocking backpressure and audio-clock drain. Exclusive ownership is per OFD, not PID. See the matrix below. Capture, duplex, mmap, mixer controls, and multi-client mixing are unsupported. | diff --git a/host/test/pty-ownership.test.ts b/host/test/pty-ownership.test.ts new file mode 100644 index 0000000000..00dc45d6db --- /dev/null +++ b/host/test/pty-ownership.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const program = join( + repoRoot, + "local-binaries/programs/wasm32/pty-ownership.wasm", +); + +describe("persistent devpts ownership, mode, and permissions", () => { + it("uses authoritative PTY metadata for the full pair lifetime", async () => { + const result = await runCentralizedProgram({ + programPath: program, + argv: ["pty-ownership"], + timeout: 20_000, + useDefaultRootfs: false, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("PTY_OWNERSHIP_PASS"); + expect(result.stderr).toBe(""); + expect(result.hostDiagnostics).toEqual([]); + }); +}); diff --git a/programs/pty-ownership.c b/programs/pty-ownership.c new file mode 100644 index 0000000000..40098c3201 --- /dev/null +++ b/programs/pty-ownership.c @@ -0,0 +1,298 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static void fail(const char *what) +{ + fprintf(stderr, "PTY_OWNERSHIP_FAIL: %s: %s\n", what, strerror(errno)); + exit(1); +} + +static void check(int condition, const char *what) +{ + if (!condition) { + errno = EINVAL; + fail(what); + } +} + +static void become(uid_t uid, gid_t gid, const gid_t *groups, size_t count) +{ + if (seteuid(0) != 0) fail("restore root euid"); + if (setegid(0) != 0) fail("restore root egid"); + if (setgroups(count, groups) != 0) fail("replace supplementary groups"); + if (setegid(gid) != 0) fail("select effective gid"); + if (seteuid(uid) != 0) fail("select effective uid"); +} + +static struct stat path_stat(const char *path) +{ + struct stat st; + if (stat(path, &st) != 0) fail("stat PTY path"); + return st; +} + +static struct stat fd_stat(int fd) +{ + struct stat st; + if (fstat(fd, &st) != 0) fail("fstat PTY fd"); + return st; +} + +static void expect_metadata( + const struct stat *st, + mode_t mode, + uid_t uid, + gid_t gid, + const char *what) +{ + if (!S_ISCHR(st->st_mode) || (st->st_mode & 07777) != mode || + st->st_uid != uid || st->st_gid != gid) { + fprintf(stderr, + "PTY_OWNERSHIP_FAIL: %s: mode=%04o uid=%u gid=%u\n", + what, (unsigned)(st->st_mode & 07777), (unsigned)st->st_uid, + (unsigned)st->st_gid); + exit(1); + } +} + +static void expect_same_slave(const struct stat *left, + const struct stat *right, + const char *what) +{ + check(left->st_dev == right->st_dev && left->st_ino == right->st_ino && + left->st_mode == right->st_mode && left->st_uid == right->st_uid && + left->st_gid == right->st_gid, what); +} + +static void expect_path_stat_variants(const char *path, + const struct stat *expected, + const char *what) +{ + struct stat at_stat; + if (fstatat(AT_FDCWD, path, &at_stat, 0) != 0) fail(what); + expect_same_slave(&at_stat, expected, what); + + struct statx stx; + if (statx(AT_FDCWD, path, 0, STATX_BASIC_STATS, &stx) != 0) fail(what); + check((stx.stx_mode & S_IFMT) == S_IFCHR && + (stx.stx_mode & 07777) == (expected->st_mode & 07777) && + stx.stx_ino == expected->st_ino && + stx.stx_uid == expected->st_uid && stx.stx_gid == expected->st_gid, + what); +} + +static void expect_open_error(const char *path, int flags, int error, + const char *what) +{ + errno = 0; + int fd = open(path, flags | O_NOCTTY | O_NONBLOCK); + if (fd >= 0) close(fd); + check(fd == -1 && errno == error, what); +} + +static int open_slave(const char *path, int flags, const char *what) +{ + int fd = open(path, flags | O_NOCTTY | O_NONBLOCK); + if (fd < 0) fail(what); + return fd; +} + +int main(void) +{ + char slave_path[64]; + char fresh_path[64]; + gid_t owner_groups[] = { 3000, 4000 }; + gid_t supplementary_group[] = { 2000 }; + + check(getuid() == 0 && geteuid() == 0, "probe must start as root"); + become(1000, 2000, NULL, 0); + + int master = posix_openpt(O_RDWR | O_NOCTTY | O_NONBLOCK); + if (master < 0) fail("posix_openpt"); + if (grantpt(master) != 0) fail("grantpt"); + if (unlockpt(master) != 0) fail("unlockpt"); + if (ptsname_r(master, slave_path, sizeof(slave_path)) != 0) + fail("ptsname_r"); + + struct stat slave_initial = path_stat(slave_path); + expect_metadata(&slave_initial, 0620, 1000, 2000, + "creator metadata and configured effective gid"); + + struct stat master_path = path_stat("/dev/ptmx"); + struct stat master_fd = fd_stat(master); + expect_same_slave(&master_path, &master_fd, + "master fd must retain /dev/ptmx metadata"); + expect_metadata(&master_fd, 0620, 0, 0, + "master must not alias devpts slave ownership"); + check(master_fd.st_ino != slave_initial.st_ino, + "master and slave must have distinct inode identities"); + + int slave = open_slave(slave_path, O_RDWR, "owner open"); + struct stat slave_fd = fd_stat(slave); + expect_same_slave(&slave_initial, &slave_fd, + "initial path and fd metadata"); + expect_path_stat_variants(slave_path, &slave_initial, + "initial fstatat/statx metadata"); + + become(9000, 9000, NULL, 0); + errno = 0; + check(chmod(slave_path, 0600) == -1 && errno == EPERM, + "unrelated chmod denial"); + errno = 0; + check(fchmod(slave, 0600) == -1 && errno == EPERM, + "unrelated fchmod denial"); + errno = 0; + check(chown(slave_path, (uid_t)-1, 9000) == -1 && errno == EPERM, + "unrelated chown denial"); + errno = 0; + check(lchown(slave_path, (uid_t)-1, 9000) == -1 && errno == EPERM, + "unrelated lchown denial"); + errno = 0; + check(fchown(slave, (uid_t)-1, 9000) == -1 && errno == EPERM, + "unrelated fchown denial"); + struct stat rejected = path_stat(slave_path); + expect_same_slave(&rejected, &slave_initial, + "failed mutations must not change state"); + + become(1000, 2000, owner_groups, 2); + if (chmod(slave_path, 0640) != 0) fail("owner chmod"); + if (fchmod(slave, 0600) != 0) fail("owner fchmod"); + if (chown(slave_path, (uid_t)-1, 3000) != 0) + fail("owner chown supplementary group"); + if (lchown(slave_path, (uid_t)-1, 4000) != 0) + fail("owner lchown supplementary group"); + if (fchown(slave, (uid_t)-1, 2000) != 0) + fail("owner fchown effective group"); + struct stat owner_updated = path_stat(slave_path); + expect_metadata(&owner_updated, 0600, 1000, 2000, + "owner mutation round trip"); + slave_fd = fd_stat(slave); + expect_same_slave(&owner_updated, &slave_fd, + "owner path and fd metadata"); + + errno = 0; + check(fchown(slave, 1001, (gid_t)-1) == -1 && errno == EPERM, + "owner cannot give PTY away"); + rejected = fd_stat(slave); + expect_same_slave(&rejected, &owner_updated, + "failed owner fchown must be atomic"); + + become(0, 0, NULL, 0); + if (chown(slave_path, 1100, 2100) != 0) fail("root chown"); + if (fchmod(slave, 0660) != 0) fail("root fchmod"); + if (fchown(slave, 1200, 2200) != 0) fail("root fchown"); + if (lchown(slave_path, 1300, 2300) != 0) fail("root lchown"); + struct stat root_updated = path_stat(slave_path); + expect_metadata(&root_updated, 0660, 1300, 2300, + "root mutation round trip"); + slave_fd = fd_stat(slave); + expect_same_slave(&root_updated, &slave_fd, + "root path and fd metadata"); + expect_path_stat_variants(slave_path, &root_updated, + "mutated fstatat/statx metadata"); + + struct stat tty_path = path_stat("/dev/tty"); + int tty = open_slave("/dev/tty", O_RDWR, "open controlling alias"); + struct stat tty_fd = fd_stat(tty); + expect_same_slave(&tty_path, &tty_fd, + "control alias path and fd metadata"); + check(tty_fd.st_ino != root_updated.st_ino, + "control alias must not use slave inode identity"); + if (fchmod(tty, 0777) != 0) fail("control alias fchmod compatibility"); + if (fchown(tty, 77, 88) != 0) fail("control alias fchown compatibility"); + tty_fd = fd_stat(tty); + expect_same_slave(&tty_path, &tty_fd, + "control alias metadata remains stable"); + struct stat after_tty_mutation = path_stat(slave_path); + expect_same_slave(&after_tty_mutation, &root_updated, + "control alias calls must not mutate slave metadata"); + if (close(tty) != 0) fail("close controlling alias"); + + if (fchmod(master, 0777) != 0) fail("master fchmod compatibility"); + if (fchown(master, 77, 88) != 0) fail("master fchown compatibility"); + struct stat after_master_mutation = path_stat(slave_path); + expect_same_slave(&after_master_mutation, &root_updated, + "master calls must not mutate slave metadata"); + + if (fchown(slave, 1000, 2000) != 0) fail("restore slave ownership"); + if (fchmod(slave, 0620) != 0) fail("restore slave mode"); + + become(1000, 2000, NULL, 0); + int probe = open_slave(slave_path, O_RDWR, "owner read/write open"); + close(probe); + + become(3000, 2000, NULL, 0); + probe = open_slave(slave_path, O_WRONLY, "effective group write open"); + close(probe); + expect_open_error(slave_path, O_RDONLY, EACCES, + "effective group read denial"); + + become(3000, 4000, supplementary_group, 1); + probe = open_slave(slave_path, O_WRONLY, + "supplementary group write open"); + close(probe); + + become(3000, 4000, NULL, 0); + expect_open_error(slave_path, O_WRONLY, EACCES, + "unrelated user write denial"); + expect_open_error(slave_path, O_RDONLY, EACCES, + "unrelated user read denial"); + + become(0, 0, NULL, 0); + probe = open_slave(slave_path, O_RDWR, "root read/write open"); + close(probe); + + become(1000, 2000, NULL, 0); + if (chmod(slave_path, 0640) != 0) fail("owner group-read chmod"); + become(3000, 2000, NULL, 0); + probe = open_slave(slave_path, O_RDONLY, "effective group read open"); + close(probe); + expect_open_error(slave_path, O_WRONLY, EACCES, + "effective group write denial after chmod"); + become(3000, 4000, supplementary_group, 1); + probe = open_slave(slave_path, O_RDONLY, + "supplementary group read open"); + close(probe); + + become(1000, 2000, NULL, 0); + if (fchmod(slave, 0600) != 0) fail("persistent close mode"); + if (close(slave) != 0) fail("close original slave"); + struct stat while_closed = path_stat(slave_path); + expect_metadata(&while_closed, 0600, 1000, 2000, + "metadata survives last slave close"); + slave = open_slave(slave_path, O_RDWR, "owner reopen"); + slave_fd = fd_stat(slave); + expect_same_slave(&while_closed, &slave_fd, + "metadata survives slave reopen"); + if (close(slave) != 0) fail("close reopened slave"); + if (close(master) != 0) fail("close master"); + + errno = 0; + check(stat(slave_path, &slave_initial) == -1 && errno == ENOENT, + "pair destruction removes slave metadata"); + + become(3000, 4000, NULL, 0); + master = posix_openpt(O_RDWR | O_NOCTTY | O_NONBLOCK); + if (master < 0) fail("reallocate PTY"); + if (unlockpt(master) != 0) fail("unlock reallocated PTY"); + if (ptsname_r(master, fresh_path, sizeof(fresh_path)) != 0) + fail("fresh ptsname_r"); + check(strcmp(fresh_path, slave_path) == 0, + "destroyed pair slot must be reusable"); + struct stat fresh = path_stat(fresh_path); + expect_metadata(&fresh, 0620, 3000, 4000, + "reused pair receives fresh metadata"); + if (close(master) != 0) fail("close reallocated master"); + + puts("PTY_OWNERSHIP_PASS"); + return 0; +} From ded8e4fc684f65052aa1d5818a4aa0d790cd7317 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Tue, 11 Aug 2026 16:04:57 -0400 Subject: [PATCH 67/82] Docs: Clarify poll interruption semantics --- docs/architecture.md | 56 +++++- docs/browser-support.md | 8 + docs/posix-status.md | 12 +- ...8-10-abi43-login-sudo-vfork-integration.md | 183 +++++++++++++----- 4 files changed, 198 insertions(+), 61 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index de4e97968a..8dbe8b018b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -867,10 +867,41 @@ until libc runs the handler and clears it. If the syscall would otherwise remain blocked, the host captures and releases its exact retry authority, then completes the channel with `EINTR` before it can park again. Public nonblocking `EAGAIN` outcomes remain `EAGAIN`. After the handler, libc resubmits only its -reviewed zero-progress `SA_RESTART` allowlist, including `accept` and -`accept4`; timeout-bearing operations suppress restart when a new submission -would reset their deadline. The shared `CentralizedKernelWorker` state machine -provides the same behavior in Node.js and browser hosts. +reviewed zero-progress `SA_RESTART` allowlist, including `accept`, `accept4`, +and `ppoll`. POSIX does not give `ppoll` the `pselect` +restart-versus-`EINTR` exception, so it remains on that list; Kandelo +deliberately selects `EINTR` for `pselect`, whose `SA_RESTART` outcome POSIX +makes implementation-defined. Other timeout-bearing operations remain out +when a new submission would reset their deadline. The shared +`CentralizedKernelWorker` state machine provides the same behavior in Node.js +and browser hosts. + +For a signal-mask-swapping `ppoll` or `pselect`, each TID owns a LIFO stack of +wait contexts. Each context records both the caller's saved mask and the +replacement mask. An active frame accepts repeated kernel attempts. Once a +signal interrupts it, reuse additionally requires the current caught-handler +depth to have fallen below the handler depth recorded by that frame. A wait +entered by a catcher is therefore distinct from the interrupted outer wait +even if the catcher explicitly restores the replacement mask and reuses +identical syscall arguments. The signal record restores the mask current at +delivery, so a restarted `ppoll` keeps its replacement mask continuously +installed between attempts. Terminal success restores the top context in the +normal syscall path; final `EINTR` uses the existing exact-task host-wait +cancellation after the catcher returns. That cancellation also finalizes +`sigsuspend` and `pause` after their catcher. + +The Wasm setjmp runtime records caught-handler depth in every jump environment. +Both `longjmp` and `siglongjmp` first retire every abandoned handler and its +paired wait context through the existing `rt_sigreturn` and exact-task +cancellation operations. `siglongjmp` then applies the jump environment's +saved mask when requested; an application using `longjmp` from a catcher must +restore its signal mask as POSIX requires. An ordinary jump outside a catcher +has no handler context to retire. A finite restarted `ppoll` +keeps its absolute deadline in the libc call frame rather than host channel +state. Nested calls and later same-argument calls therefore have independent +deadlines, while catcher time is still charged to the interrupted call. The +internal timestamp request defers caught-signal publication so a signal +already pending at ppoll entry still interrupts ppoll itself. For a represented retry, the initial call uses token zero. Before returning `EAGAIN`, Rust pins any exact target required by that operation. The host @@ -2375,8 +2406,21 @@ Signals are delivered at syscall boundaries. When a process has a pending signal 1. `kernel_handle_channel` checks for pending signals after each syscall 2. If a signal handler is registered (SA_SIGINFO), the kernel writes signal info to the channel's data buffer -3. The glue reads the signal info and calls the handler on the process's stack (or alternate signal stack if SA_ONSTACK) -4. After the handler returns, the glue calls `SYS_RT_SIGRETURN` to restore the signal mask +3. The glue reads the signal info and calls the handler on the process's stack + (or alternate signal stack if SA_ONSTACK). For a ppoll/pselect replacement + mask, handler setup starts from that current replacement mask, then adds + sa_mask and the delivered signal. +4. After the handler returns, the glue calls `SYS_RT_SIGRETURN` for + handler-frame state and applies the signal record's exact old mask with + `SYS_SIGPROCMASK`. For ppoll/pselect, the record contains the mask current + at delivery while Rust retains a per-TID LIFO wait context. A restarted + ppoll therefore preserves its replacement mask through resubmission; + terminal completion or exact post-handler cancellation restores the + pre-wait mask once. Nested ppoll, pselect, sigsuspend, and pause calls own + separate contexts. `sigsuspend` and `pause` use that same exact + post-handler cancellation to restore their pre-wait mask before returning + `EINTR`. `longjmp` and `siglongjmp` retire abandoned handler/wait contexts; + `siglongjmp` then applies the jump buffer's saved mask when requested. 5. If the signal interrupted a blocking syscall, EINTR is returned The host distinguishes the kernel's internal `EAGAIN` retry sentinel from a diff --git a/docs/browser-support.md b/docs/browser-support.md index 26b4c41205..e4eaac65a2 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -72,6 +72,14 @@ Service Worker ──MessagePort──> Kernel Worker │ `WebAssembly.Module.imports()` API cannot produce descriptors for them. Modules created by an external embedder without registered bytes retain the native reflection fallback. +- **Signal-wait engine matrix**: the real BrowserKernel worker path runs the + wasm32 ppoll/pselect interruption matrix and wait4 unknown-option rejection + on Chromium, Firefox, and WebKit. Chromium and Firefox also run its wasm64 + counterpart. The current Playwright WebKit engine rejects the Memory64 + module at `WebAssembly.validate`, so WebKit's truthful boundary is wasm32 + rather than a skipped or simulated wasm64 success. Browser injection waits + on guest-published atomic gates in the real process memory, so acceptance + does not depend on a fixed event-loop delay. - **Exec reads from filesystem**: Like a real OS, `exec()` reads binaries from the kernel-side `MemoryFileSystem`. Programs are baked into the VFS image at build time (or written by the page in the legacy path before spawning). Symlinks are used for multicall binaries (e.g., coreutils). - **dinit for service supervision**: Multi-process demos (nginx, redis, mariadb, nginx-php, wordpress, lamp, mariadb-test) bake `/sbin/dinit` and diff --git a/docs/posix-status.md b/docs/posix-status.md index f286db4867..65f00d5431 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -187,10 +187,10 @@ same final-OFD lifetime rules. | `kill()` | Partial | The centralized Rust process table validates the caller, resolves process and process-group targets, and owns pending signal state; the host only wakes exact channels selected from kernel-owned tasks. `sig=0` performs existence and permission checks without queuing. Pending signals are delivered at syscall boundaries. POSIX `EPERM` is enforced when an unprivileged caller's real/effective uid does not match the target. The immutable synthetic init reservation (PID 1, uid 0, no user worker) resolves existence checks without becoming a mutable delivery target; target 4 in compromising-xfails.md. | | `tkill()` / `tgkill()` | Partial | Linux-compatible exact-thread delivery within the calling process uses kernel-owned task records and the target thread's directed pending queue. TID 0 and unknown or exited targets return `ESRCH`; an exact-thread request never falls back to process-wide delivery. Signal 0 performs the same target validation without queuing a signal. Cross-process per-thread delivery is not yet supported and returns `ESRCH`. | | `signal()` | Full | Legacy API. Returns previous handler. Wraps sigaction() semantics. SIGKILL/SIGSTOP immutable. | -| `sigaction()` | Partial | Sets handler disposition (SIG_DFL, SIG_IGN, or function pointer) plus sa_flags and sa_mask. SIGKILL/SIGSTOP immutable. SA_RESTART is honored by the existing blocking read/write/recv/poll paths and by host-deferred waits. SA_SIGINFO calls `handler(signum, siginfo_ptr, ucontext_ptr)` with pointer-width-correct layout, but host-generated SIGCHLD currently lacks the exact child pid/CLD code/status metadata. SA_NOCLDWAIT auto-reaps children and suppresses SIGCHLD. SA_NOCLDSTOP suppresses stop/continue SIGCHLD notification without discarding waitable status. SIG_IGN discards pending signals; SIG_DFL discards pending signals for signals whose default action is "ignore" (e.g., SIGCHLD). **Note:** Programs must be linked with `--table-base=3 --export-table` so the host can dispatch handlers from the user program's function table (indices 0/1 reserved for SIG_DFL/SIG_IGN, index 2 reserved for `__main_void`). | -| `sigprocmask()` | Full | Block/unblock/setmask operations on 64-bit signal mask. SIGKILL and SIGSTOP cannot be blocked per POSIX. | -| `sigsuspend()` | Full | Atomically replaces signal mask and blocks until deliverable signal arrives. Uses SharedArrayBuffer + Atomics.wait/notify for cross-thread wake. Always returns EINTR. | -| `pause()` | Full | Suspends until a signal is delivered. Delegates to sigsuspend with current mask. Always returns EINTR. | +| `sigaction()` | Partial | Sets handler disposition (SIG_DFL, SIG_IGN, or function pointer) plus sa_flags and sa_mask. SIGKILL/SIGSTOP immutable. In the default SA_NODEFER-clear/SA_RESETHAND-clear case, the catcher runs with the current mask union sa_mask and the delivered signal. A ppoll/pselect replacement mask stays current through handler return and any ppoll restart dispatch; per-TID LIFO wait contexts preserve nested mask-swapping waits and restore each pre-wait mask once. `longjmp` and `siglongjmp` retire every abandoned handler/wait context; `siglongjmp` then applies the jump buffer's saved mask when requested. SA_RESTART is honored by the existing blocking read/write/recv/poll/ppoll paths and by host-deferred waits; pselect deliberately returns EINTR because POSIX permits that implementation-defined SA_RESTART outcome. SA_SIGINFO calls `handler(signum, siginfo_ptr, ucontext_ptr)` with pointer-width-correct layout, but host-generated SIGCHLD currently lacks the exact child pid/CLD code/status metadata. SA_NOCLDWAIT auto-reaps children and suppresses SIGCHLD. SA_NOCLDSTOP suppresses stop/continue SIGCHLD notification without discarding waitable status. SIG_IGN discards pending signals; SIG_DFL discards pending signals for signals whose default action is "ignore" (e.g., SIGCHLD). **Note:** Programs must be linked with `--table-base=3 --export-table` so the host can dispatch handlers from the user program's function table (indices 0/1 reserved for SIG_DFL/SIG_IGN, index 2 reserved for `__main_void`). | +| `sigprocmask()` | Full | Per-TID block/unblock/setmask and query operations on a 64-bit signal mask. SIGKILL and SIGSTOP cannot be blocked per POSIX. | +| `sigsuspend()` | Full | Atomically replaces the signal mask and blocks until a deliverable signal arrives. The catcher observes the replacement mask; exact post-handler cancellation restores the pre-wait mask once before returning EINTR. Uses SharedArrayBuffer + Atomics.wait/notify for cross-thread wake. | +| `pause()` | Full | Suspends until a signal is delivered. Delegates to sigsuspend with the current mask, then uses the same exact post-handler cleanup before returning EINTR. | | `raise()` | Full | Equivalent to kill(getpid(), sig). | | `alarm()` | Full | Sets SIGALRM timer via host setTimeout. Returns previous remaining seconds. alarm(0) cancels. Not inherited by fork; preserved across exec. | | `setitimer()` | Full | ITIMER_REAL: sets alarm deadline + interval via host_set_alarm. ITIMER_VIRTUAL/ITIMER_PROF: no-op (no CPU time tracking). Fixes musl's alarm() which internally calls setitimer. | @@ -287,8 +287,8 @@ shortcuts. | `shutdown()` | Partial | SHUT_RD, SHUT_WR, and SHUT_RDWR transitions are idempotent within a process and release each owned pipe/host reference once. UDP write shutdown returns EPIPE on datagram send; read shutdown is EOF-like for recv/poll. Sending to a read-shut AF_UNIX datagram peer returns EPIPE (and SIGPIPE unless MSG_NOSIGNAL is used), and the transition wakes blocked sends/readiness waits. Fork-inherited sockets still clone shutdown flags per process instead of sharing one socket-wide shutdown state, and the external host ABI has no half-shutdown operation. | | `select()` | Partial | Wrapper around poll(). Converts fd_set bitmasks to pollfd array. A finite wait keeps one absolute deadline across host retries and finishes with a zero-time kernel pass, except for the descriptor-free sleep form. A caught signal interrupts a would-block retry, including the no-fd sleep path, with EINTR; ignored signals leave it parked and a concurrently ready result is preserved. | | `poll()` | Partial | Checks readiness for regular files, pipes, and sockets. UDP poll reports queued datagrams, connected-peer filtering, EOF-like read shutdown, write-shutdown hangup, and pending socket errors. A finite wait keeps one absolute deadline across targeted wakeups and safety retries, then finishes with a zero-time kernel pass that clears `revents`. Returns EINTR on pending signals. | -| `ppoll()` | Full | Wraps poll() with atomic signal mask swap: save → set → poll → restore. The glue layer converts the timespec to milliseconds. A finite wait preserves its deadline across host retries and expires through the kernel so `revents` is copied back and the temporary mask is restored. | -| `pselect6()` | Partial | Wraps select() with an atomic signal-mask swap across the host retry loop. The pselect6-style `{sigset_t *, size_t}` argument supplies the mask; timeout precision is rounded to host milliseconds. A finite wait preserves its deadline and expires through a zero-time kernel pass. Caught signals interrupt a would-block retry with EINTR after the temporary mask is restored. | +| `ppoll()` | Full | Wraps poll() with atomic signal mask swap: save → set → poll → restore. If the replacement mask makes a caught signal deliverable, its catcher observes that replacement mask together with sa_mask and the delivered signal. Per-TID LIFO contexts keep nested waits distinct while SA_RESTART resubmits a zero-progress outer ppoll; terminal completion, exact final-EINTR cancellation, or `longjmp`/`siglongjmp` abandonment retires each context once. The glue layer converts the timespec to milliseconds. A finite call keeps its absolute deadline in that libc call frame across host retries and catcher syscalls, so nested or later identical-argument calls cannot inherit it; expiry still reaches the kernel so `revents` is copied back and the temporary mask is restored. | +| `pselect6()` | Partial | Wraps select() with an atomic signal-mask swap across the host retry loop. The pselect6-style `{sigset_t *, size_t}` argument supplies the mask; timeout precision is rounded to host milliseconds. A catcher observes the replacement mask while it runs, and exact final-EINTR cancellation restores the Rust-owned pre-wait mask once after the catcher. A finite wait preserves its deadline and expires through a zero-time kernel pass. Caught signals interrupt a would-block retry with EINTR; with SA_RESTART, Kandelo deliberately selects POSIX's implementation-defined EINTR result instead of restart. | | `epoll_create1()` | Full | Creates epoll instance with per-process interest list. EPOLL_CLOEXEC flag supported. | | `epoll_ctl()` | Full | EPOLL_CTL_ADD, EPOLL_CTL_MOD, EPOLL_CTL_DEL. Stores interest set with events + data. | | `epoll_pwait()` | Full | Builds pollfd from interest set, delegates to poll, maps results back to epoll_event structs. Optional signal mask swap. | diff --git a/docs/superpowers/plans/2026-08-10-abi43-login-sudo-vfork-integration.md b/docs/superpowers/plans/2026-08-10-abi43-login-sudo-vfork-integration.md index db97b20d40..94702915c5 100644 --- a/docs/superpowers/plans/2026-08-10-abi43-login-sudo-vfork-integration.md +++ b/docs/superpowers/plans/2026-08-10-abi43-login-sudo-vfork-integration.md @@ -1957,88 +1957,173 @@ git commit --author='Brandon Payton ' \ **Files:** +- Modify if red: `crates/kernel/src/process.rs` +- Modify if red: `crates/kernel/src/signal.rs` +- Modify if red: `crates/kernel/src/fork.rs` +- Modify if red: `crates/kernel/src/wasm_api.rs` - Modify if red: `crates/kernel/src/syscalls.rs` +- Modify if red: `libc/glue/channel_syscall.c` +- Modify if red: `libc/musl-overlay/src/signal/wasm32posix/sigsetjmp.c` - Modify if red: `host/src/kernel-worker.ts` +- Modify: `examples/select_signal_test.c` - Modify: `host/test/select-signal-guest.test.ts` -- Modify: `host/test/select-signal-outcome.test.ts` -- Modify: `host/test/readiness-wakeup.test.ts` -- Modify: `host/test/readiness-deadline.test.ts` - Modify: `host/test/kernel-blocking-retry-snapshot.test.ts` +- Modify: `host/test/process-wait-lifecycle.test.ts` +- Create: `apps/browser-demos/test/select-signal-browser.spec.ts` +- Modify: `packages/registry/program-packages.json` (generated source-projection + cache identities after the guest source changes) +- Validate: `host/test/select-signal-outcome.test.ts` +- Validate: `host/test/readiness-wakeup.test.ts` +- Validate: `host/test/readiness-deadline.test.ts` **Interfaces:** -- Consumes: current signal masks, blocking retry snapshots, and readiness - wakeups -- Produces: exact evidence for null/non-null replacement masks and - `SA_RESTART`; no `__WALL` constant or behavior +- Consumes: each TID's current and saved wait mask, caught-signal delivery + record, libc handler-return/retry state, and readiness wakeups +- Produces: handler masks formed from the current temporary wait mask, + exactly-once pre-wait restoration, real wasm32/wasm64 interruption + evidence in Node and BrowserKernel peers, and truthful rejection of Linux + all-children wait options +- Preserves: ABI 43 channel and signal-delivery layouts. This is a semantic + correction to existing batch behavior, not a new structure, protocol, or + ABI-version change. - [ ] **Step 1: Add the source branch's exact interruption cases** -Cover pending signal before entry and signal arriving while blocked for both -`ppoll` and `pselect`, each with null and non-null replacement masks, and with -and without `SA_RESTART`. Assert the original mask is restored exactly once, -the handler sees the temporary mask, readiness is not lost, and errno/result -matches POSIX. +Use a real C guest through `CentralizedKernelWorker`, not synthetic channel +or signal-record flags. Run the 16 semantic combinations for both wasm32 and +wasm64 (32 end-to-end cases total): `ppoll` and `pselect`, a signal pending +before entry and one arriving while blocked, null and non-null replacement +masks, and `SA_RESTART` clear and set. + +Give the original mask, replacement mask, `sa_mask`, and delivered signal +different sentinel bits. The catcher itself must query `sigprocmask()` and +prove that its actual mask is the current wait mask (the replacement mask when +non-null, otherwise the original mask), unioned with `sa_mask` and the +delivered signal; compare every supported signal membership rather than only +the sentinel bits. After the catcher returns, prove the original mask is +restored once, with neither temporary nor catcher-only bits retained. A +lower-level per-TID test must cover the cancellation/retry handoff so that a +consumed saved mask cannot be restored a second time, including a pthread TID. + +Use a host-observed guest gate to queue a signal before the wait entry, and a +host timer to deliver one while the real wait is blocked. Keep a finite +deadline and a pipe-readiness check in every case so an interruption cannot +drop either a wakeup or ordinary readiness. Record the libc-visible result and +`errno` before follow-up checks. + +Follow POSIX Issue 8 exactly: `sigaction()` forms a handler mask from the +current mask union `sa_mask` and, absent `SA_NODEFER`/`SA_RESETHAND`, the +delivered signal. `ppoll()` installs its non-null replacement mask before +examining descriptors and restores it before return. With `SA_RESTART` clear, +both waits report `EINTR` when no descriptor is ready. `pselect()` explicitly +makes `SA_RESTART` restart versus `EINTR` implementation-defined; Kandelo +chooses and tests `EINTR`. `ppoll()` has no such exception, so its +`SA_RESTART` cases resume and observe the catcher-produced pipe readiness +before their finite deadline. Preserve a restarted timeout no longer than the +original interval. + +Add a second-signal restart-window case: hold the first catcher after it makes +the pipe ready, queue a signal blocked by the ppoll replacement mask, and +release the catcher. The second signal must not run until restarted ppoll has +observed readiness and terminal restoration has made the original mask +current. Prove both signal masks, no lost readiness, and exactly-once final +restoration for wasm32 and wasm64. + +Also cover the other Rust-owned temporary-mask exits: `sigsuspend` and `pause` +must retain their replacement/current-at-delivery mask through the catcher and +restore their pre-wait mask once before final `EINTR`; masked pthread +cancellation must perform the same exact cleanup before returning +`ECANCELED`. A finite SA_RESTART `ppoll` whose catcher runs past the original +deadline must time out at that original absolute deadline, not start a fresh +interval. After a restarted ppoll completes with immediate readiness, repeat +the exact same argument addresses and prove the independent call receives a +fresh deadline rather than a stale remainder from the completed call. + +Exercise waits nested inside a caught handler, including ppoll, pselect, +sigsuspend, and pause on the main task and ppoll on a pthread. Reuse the same +ppoll descriptor, timeout, and mask arguments in an inner handler call and +explicitly restore the outer replacement mask before that inner call; prove +handler-depth identity, LIFO mask restoration, and an independent outer +deadline. Then leave a nested handler nonlocally with real +`sigsetjmp`/`siglongjmp` and `setjmp`/`longjmp`, issue a later +same-argument mask-swapping wait, and prove that every abandoned wait context +and deadline was retired. Prove `siglongjmp` honors both saved-mask modes, +generic `longjmp` preserves an explicitly restored application mask, and an +ordinary non-handler jump does not over-clean. +Deadline identity must be per logical libc invocation rather than a numeric +argument tuple or channel-global carry. + +Run the same guest through a direct BrowserKernel runner with an in-memory +empty VFS. Chromium, Firefox, and WebKit must run wasm32; engines whose +`WebAssembly.validate` accepts Memory64 must also run wasm64. Record WebKit's +current Memory64 rejection as an engine boundary. The guest's numeric wait4 +unknown-option rejection must execute through that real browser worker path. +Use a guest-published atomic gate in the real process memory for host signal +injection so the acceptance gate is deterministic rather than delay-based. + +References: [sigaction](https://pubs.opengroup.org/onlinepubs/9799919799/functions/sigaction.html), +[poll/ppoll](https://pubs.opengroup.org/onlinepubs/9799919799/functions/poll.html), +and [select/pselect](https://pubs.opengroup.org/onlinepubs/9799919799/functions/select.html). - [ ] **Step 2: Run the exact focused set** ```bash +scripts/dev-shell.sh bash scripts/build-musl.sh +scripts/dev-shell.sh bash scripts/build-musl.sh --arch wasm64posix +scripts/dev-shell.sh bash build.sh +scripts/dev-shell.sh bash -lc \ + 'host_target=$(rustc -vV | sed -n "s/^host: //p"); \ + cargo test -p kandelo --target "$host_target" --lib \ + temporary_wait_mask_forms_handler_mask_and_cancel_restores_once && \ + cargo test -p kandelo --target "$host_target" --lib \ + ordinary_handler_return_uses_the_current_mask' scripts/dev-shell.sh bash -lc \ 'cd host && npx vitest run \ test/select-signal-guest.test.ts \ test/select-signal-outcome.test.ts \ test/readiness-wakeup.test.ts \ test/readiness-deadline.test.ts \ - test/kernel-blocking-retry-snapshot.test.ts' + test/kernel-blocking-retry-snapshot.test.ts \ + test/process-wait-lifecycle.test.ts' +scripts/dev-shell.sh bash scripts/check-abi-version.sh +KANDELO_PLAYWRIGHT_PORT=56116 scripts/dev-shell.sh bash -lc \ + 'cd apps/browser-demos && npx playwright test \ + test/select-signal-browser.spec.ts \ + --output=/tmp/task16-playwright' ``` -Expected outcome A: all new cases PASS, proving current HEAD already contains -the behavior. Make no production change and keep only a focused test commit if -the added cases improve coverage. +Expected outcome A: the real guest cases pass without a production change. -Expected outcome B: a case FAILS. Keep it red, correct only mask install, -wakeup, restart, or restore logic in the layer identified by the trace, then -rerun the full set. Do not copy the old advisory-lock mock change unless an -independent advisory-lock test fails. +Expected outcome B: retain the red evidence, trace mask ownership through +`sys_ppoll`/`sys_pselect6`, `kernel_dequeue_signal`, handler setup, +`rt_sigreturn`, retry/cancel/failure, and pthread callers, then change only +the responsible install, wakeup, restart, or restoration layer. Do not copy +an advisory-lock or other unrelated mock change. - [ ] **Step 3: Assert the Linux wait flag remains absent** ```bash -! rg -n '__WALL|0x40000000' \ +! rg -n '__WALL' \ crates/kernel crates/shared host/src libc/musl-overlay \ --glob '!**/*test*' ``` -Expected: no production match. The sudo Formula patch in Task 18 removes its -use while preserving `WUNTRACED` and `WNOHANG`. +Expected: no production `__WALL` symbol/name. A raw `0x40000000` search is +not a valid assertion because unrelated production constants may share that +numeric value. Instead, add focused wait-family regression evidence that +`wait4` rejects `0x40000000` with `EINVAL` before polling or registering a +waiter. This proves the Linux all-children option cannot enable behavior by +numeric value. The sudo Formula patch in Task 18 removes its use while +preserving `WUNTRACED` and `WNOHANG`. -- [ ] **Step 4: Commit the regression boundary** +- [ ] **Step 4: Commit the focused boundary with source authorship** -If production changed: - -```bash -git add crates/kernel/src/syscalls.rs host/src/kernel-worker.ts \ - host/test/select-signal-guest.test.ts \ - host/test/select-signal-outcome.test.ts \ - host/test/readiness-wakeup.test.ts \ - host/test/readiness-deadline.test.ts \ - host/test/kernel-blocking-retry-snapshot.test.ts -git commit --author='Brandon Payton ' \ - -m "Signals: Preserve poll masks across interruption" -``` - -If current production already passes: - -```bash -git add host/test/select-signal-guest.test.ts \ - host/test/select-signal-outcome.test.ts \ - host/test/readiness-wakeup.test.ts \ - host/test/readiness-deadline.test.ts \ - host/test/kernel-blocking-retry-snapshot.test.ts -git diff --cached --quiet || \ - git commit --author='Brandon Payton ' \ - -m "Tests: Preserve poll interruption semantics" -``` +Keep conceptual changes separate: plan clarification, signal-mask ownership, +ppoll restart classification, the real guest plus wait-option regressions, and +any deterministic source-projection refresh caused by the guest source. Use +purpose-prefixed subjects and Brandon Payton as author. Do not commit the local +Task 16 report or unrelated submodule state. ### Task 17: Add first-party login and sudo-lite through the normal guest path From 8d1ec5bb3941ce588551b310b491e05c4812120f Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Tue, 11 Aug 2026 16:05:05 -0400 Subject: [PATCH 68/82] POSIX: Preserve and restart signal-interrupted waits --- .../test/select-signal-browser.spec.ts | 241 +++ crates/kernel/src/fork.rs | 8 +- crates/kernel/src/process.rs | 358 +++- crates/kernel/src/signal.rs | 47 +- crates/kernel/src/syscalls.rs | 96 +- crates/kernel/src/wasm_api.rs | 33 +- examples/select_signal_test.c | 1610 ++++++++++++++++- host/src/kernel-worker.ts | 39 +- .../kernel-blocking-retry-snapshot.test.ts | 9 +- host/test/process-wait-lifecycle.test.ts | 35 + host/test/select-signal-guest.test.ts | 166 +- libc/glue/channel_syscall.c | 187 +- libc/musl-overlay/src/setjmp/wasm32/rt.c | 12 + .../src/signal/wasm32posix/sigsetjmp.c | 18 +- packages/registry/program-packages.json | 716 ++++---- 15 files changed, 3029 insertions(+), 546 deletions(-) create mode 100644 apps/browser-demos/test/select-signal-browser.spec.ts diff --git a/apps/browser-demos/test/select-signal-browser.spec.ts b/apps/browser-demos/test/select-signal-browser.spec.ts new file mode 100644 index 0000000000..210068bd70 --- /dev/null +++ b/apps/browser-demos/test/select-signal-browser.spec.ts @@ -0,0 +1,241 @@ +import { expect, test } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +const browserKernelPath = resolve(repoRoot, "host/src/browser-kernel-host.ts"); +const memoryFsPath = resolve(repoRoot, "host/src/vfs/memory-fs.ts"); +const fixturePaths = { + wasm32: resolve(repoRoot, "examples/select_signal_test.wasm"), + wasm64: resolve(repoRoot, "examples/select_signal_test.wasm64.wasm"), +}; + +test("BrowserKernel runs the ppoll/pselect signal matrix and wait4 rejection", async ({ + browserName, + page, + baseURL, +}) => { + test.setTimeout(180_000); + expect(baseURL).toBeTruthy(); + await page.goto(new URL("/trap-signal-test.html", baseURL).href); + + const result = await page.evaluate( + async ({ browserKernelUrl, memoryFsUrl, wasm32Bytes, wasm64Bytes }) => { + const { BrowserKernel } = await import( + /* @vite-ignore */ browserKernelUrl + ); + const { MemoryFileSystem } = await import( + /* @vite-ignore */ memoryFsUrl + ); + const decoder = new TextDecoder(); + const pendingMarker = "TASK16_GATE="; + const restartMarker = "TASK16_RESTART_GATE="; + const timeoutMarker = "TASK16_TIMEOUT_GATE="; + const blockedMarker = "TASK16_BLOCK\n"; + const sigalrm = 14; + const sigterm = 15; + + const run = async (bytes: number[], arch: string) => { + let stdout = ""; + let stderr = ""; + let markerBuffer = ""; + let pid: number | undefined; + let resolvePid!: () => void; + const pidReady = new Promise((resolve) => { resolvePid = resolve; }); + let pendingSignals = 0; + let blockedSignals = 0; + let restartSignals = 0; + let timeoutGates = 0; + let injectionFailure: string | undefined; + let injectionChain = Promise.resolve(); + + const signal = (signum: number, kind: string) => { + injectionChain = injectionChain.then(async () => { + await pidReady; + if (pid === undefined) { + throw new Error(`${kind} arrived before BrowserKernel exposed pid`); + } + if (!(await kernel.signalProcess(pid, signum))) { + throw new Error(`BrowserKernel rejected ${kind}`); + } + }).catch((error) => { + injectionFailure = + error instanceof Error ? error.message : String(error); + }); + }; + + const releaseGate = ( + rawAddress: string, + signum: number | undefined, + kind: string, + delayMs = 0, + ) => { + injectionChain = injectionChain.then(async () => { + await pidReady; + if (pid === undefined) { + throw new Error(`${kind} arrived before BrowserKernel exposed pid`); + } + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + const address = Number.parseInt(rawAddress, 10); + const memory = kernel.getProcessMemory(pid); + if ( + !Number.isSafeInteger(address) || + address < 0 || + address % Int32Array.BYTES_PER_ELEMENT !== 0 || + memory === undefined || + address + Int32Array.BYTES_PER_ELEMENT > memory.buffer.byteLength + ) { + throw new Error(`invalid BrowserKernel ${kind} gate ${rawAddress}`); + } + if ( + signum !== undefined && + !(await kernel.signalProcess(pid, signum)) + ) { + throw new Error(`BrowserKernel rejected ${kind}`); + } + Atomics.store( + new Int32Array(memory.buffer), + address / Int32Array.BYTES_PER_ELEMENT, + 1, + ); + }).catch((error) => { + injectionFailure = + error instanceof Error ? error.message : String(error); + }); + }; + + const parseMarkers = () => { + for (;;) { + const pendingAt = markerBuffer.indexOf(pendingMarker); + const restartAt = markerBuffer.indexOf(restartMarker); + const timeoutAt = markerBuffer.indexOf(timeoutMarker); + const blockedAt = markerBuffer.indexOf(blockedMarker); + const candidates = [ + [pendingAt, "pending"] as const, + [restartAt, "restart"] as const, + [timeoutAt, "timeout"] as const, + [blockedAt, "blocked"] as const, + ].filter(([at]) => at >= 0).sort(([left], [right]) => left - right); + const next = candidates[0]; + if (!next) return; + const [at, kind] = next; + if (kind === "blocked") { + markerBuffer = markerBuffer.slice(at + blockedMarker.length); + blockedSignals += 1; + setTimeout(() => signal(sigalrm, "blocked SIGALRM"), 50); + continue; + } + const marker = kind === "restart" + ? restartMarker + : kind === "timeout" + ? timeoutMarker + : pendingMarker; + const lineEnd = markerBuffer.indexOf("\n", at); + if (lineEnd < 0) return; + const rawAddress = markerBuffer.slice(at + marker.length, lineEnd); + markerBuffer = markerBuffer.slice(lineEnd + 1); + if (kind === "restart") { + restartSignals += 1; + releaseGate( + rawAddress, + sigterm, + "restart-window SIGTERM", + 250, + ); + } else if (kind === "timeout") { + timeoutGates += 1; + releaseGate(rawAddress, undefined, "timeout release", 250); + } else { + pendingSignals += 1; + releaseGate(rawAddress, sigalrm, "pending SIGALRM"); + } + } + }; + + const kernel = new BrowserKernel({ + maxWorkers: 4, + onStdout: (data: Uint8Array) => { + const text = decoder.decode(data, { stream: true }); + stdout += text; + markerBuffer += text; + parseMarkers(); + }, + onStderr: (data: Uint8Array) => { + stderr += decoder.decode(data, { stream: true }); + }, + }); + try { + const image = MemoryFileSystem.create( + new SharedArrayBuffer(256 * 1024), + ); + await kernel.initFromImage({ vfsImage: await image.saveImage() }); + const exitCode = await kernel.spawn( + new Uint8Array(bytes).buffer, + ["select_signal_test", "--browser-gate"], + { onStarted: (startedPid: number) => { + pid = startedPid; + resolvePid(); + } }, + ); + await injectionChain; + stdout += decoder.decode(); + stderr += decoder.decode(); + return { + arch, + exitCode, + stdout, + stderr, + pendingSignals, + blockedSignals, + restartSignals, + timeoutGates, + injectionFailure, + }; + } finally { + await kernel.destroy(); + } + }; + + const wasm32 = await run(wasm32Bytes, "wasm32"); + const memory64Supported = WebAssembly.validate( + new Uint8Array(wasm64Bytes), + ); + const wasm64 = memory64Supported + ? await run(wasm64Bytes, "wasm64") + : null; + return { wasm32, wasm64, memory64Supported }; + }, + { + browserKernelUrl: new URL(`/@fs/${browserKernelPath}`, baseURL).href, + memoryFsUrl: new URL(`/@fs/${memoryFsPath}`, baseURL).href, + wasm32Bytes: Array.from(readFileSync(fixturePaths.wasm32)), + wasm64Bytes: Array.from(readFileSync(fixturePaths.wasm64)), + }, + ); + + const assertMatrix = (matrix: typeof result.wasm32) => { + expect(matrix.injectionFailure).toBeUndefined(); + expect(matrix.exitCode, matrix.stderr).toBe(0); + expect(matrix.pendingSignals).toBe(8); + expect(matrix.blockedSignals).toBe(14); + expect(matrix.restartSignals).toBe(1); + expect(matrix.timeoutGates).toBe(2); + expect(matrix.stdout).toContain( + "PASS ppoll/pselect signal mask interruption matrix", + ); + expect(matrix.stderr).toBe(""); + }; + + assertMatrix(result.wasm32); + if (browserName === "webkit") { + expect(result.memory64Supported).toBe(false); + expect(result.wasm64).toBeNull(); + } else { + expect(result.memory64Supported).toBe(true); + expect(result.wasm64).not.toBeNull(); + assertMatrix(result.wasm64!); + } +}); diff --git a/crates/kernel/src/fork.rs b/crates/kernel/src/fork.rs index 2e733de3c2..46dee82332 100644 --- a/crates/kernel/src/fork.rs +++ b/crates/kernel/src/fork.rs @@ -1619,7 +1619,9 @@ fn deserialize_fork_state_into(buf: &[u8], child: &mut Process) -> Result<(), Er // state snapshot has been installed. Never inherit a parent's transient // borrowing marker through serialized process state. child.vfork_child = false; - child.sigsuspend_saved_mask = None; + child.mask_waits.clear(); + child.caught_handler_depth = 0; + child.returned_handler_depths.clear(); child.fork_exec_path = fork_exec_path; child.fork_exec_argv = fork_exec_argv; child.fork_fd_actions = fork_fd_actions; @@ -2078,7 +2080,9 @@ pub fn deserialize_exec_state(buf: &[u8], pid: u32) -> Result { [0u8; wasm_posix_shared::kernel_scratch_wire::PRCTL_NAME_BYTES as usize]; process.fork_child = false; process.vfork_child = false; - process.sigsuspend_saved_mask = None; + process.mask_waits.clear(); + process.caught_handler_depth = 0; + process.returned_handler_depths.clear(); process.fork_exec_path = None; process.fork_exec_argv = None; process.fork_fd_actions.clear(); diff --git a/crates/kernel/src/process.rs b/crates/kernel/src/process.rs index c7f426167b..fe44ae2caf 100644 --- a/crates/kernel/src/process.rs +++ b/crates/kernel/src/process.rs @@ -807,9 +807,12 @@ pub struct Process { /// state. It prevents the borrower from creating another address-space or /// pthread owner before successful exec replaces the borrowed image. pub vfork_child: bool, - /// Saved signal mask during sigsuspend host retry. - /// Set on first sigsuspend call, restored when a signal is delivered. - pub sigsuspend_saved_mask: Option, + /// Nested signal-mask-swapping waits owned by the process leader. + pub mask_waits: Vec, + /// Caught-handler bookkeeping for the process leader. Pthreads keep the + /// same fields in their PerThreadSignalState. + pub caught_handler_depth: u32, + pub returned_handler_depths: Vec, /// Path to exec after fork (set by posix_spawn before forking). pub fork_exec_path: Option>, /// Argv for exec after fork. @@ -1100,7 +1103,9 @@ impl Process { thread_name: [0u8; wasm_posix_shared::kernel_scratch_wire::PRCTL_NAME_BYTES as usize], fork_child: false, vfork_child: false, - sigsuspend_saved_mask: None, + mask_waits: Vec::new(), + caught_handler_depth: 0, + returned_handler_depths: Vec::new(), fork_exec_path: None, fork_exec_argv: None, fork_fd_actions: Vec::new(), @@ -1969,35 +1974,240 @@ impl Process { removed } - /// Read the saved sigsuspend/ppoll/pselect mask for TID. - pub fn sigsuspend_saved_mask_for(&self, tid: u32) -> Option { + fn mask_waits_for( + &self, + tid: u32, + ) -> Option<&[crate::signal::SignalMaskWaitContext]> { if self.is_main_thread(tid) { - self.sigsuspend_saved_mask + Some(&self.mask_waits) } else { self.get_thread(tid) - .and_then(|t| t.signals.sigsuspend_saved_mask) + .map(|thread| thread.signals.mask_waits.as_slice()) } } - /// Set the saved sigsuspend/ppoll/pselect mask for TID. - pub fn set_sigsuspend_saved_mask_for(&mut self, tid: u32, val: Option) { + fn mask_waits_for_mut( + &mut self, + tid: u32, + ) -> Option<&mut Vec> { if self.is_main_thread(tid) { - self.sigsuspend_saved_mask = val; - } else if let Some(t) = self.get_thread_mut(tid) { - t.signals.sigsuspend_saved_mask = val; + Some(&mut self.mask_waits) + } else { + self.get_thread_mut(tid) + .map(|thread| &mut thread.signals.mask_waits) } } - /// Take (clear) the saved sigsuspend mask for TID, returning the old value. - pub fn take_sigsuspend_saved_mask_for(&mut self, tid: u32) -> Option { + pub fn mask_wait_depth_for(&self, tid: u32) -> usize { + self.mask_waits_for(tid).map_or(0, |waits| waits.len()) + } + + pub fn caught_handler_depth_for(&self, tid: u32) -> u32 { if self.is_main_thread(tid) { - self.sigsuspend_saved_mask.take() + self.caught_handler_depth + } else { + self.get_thread(tid) + .map_or(0, |thread| thread.signals.caught_handler_depth) + } + } + + fn set_caught_handler_depth_for(&mut self, tid: u32, depth: u32) { + if self.is_main_thread(tid) { + self.caught_handler_depth = depth; + } else if let Some(thread) = self.get_thread_mut(tid) { + thread.signals.caught_handler_depth = depth; + } + } + + fn returned_handler_depths_for_mut(&mut self, tid: u32) -> Option<&mut Vec> { + if self.is_main_thread(tid) { + Some(&mut self.returned_handler_depths) } else { self.get_thread_mut(tid) - .and_then(|t| t.signals.sigsuspend_saved_mask.take()) + .map(|thread| &mut thread.signals.returned_handler_depths) + } + } + + /// Enter a mask-swapping wait, distinguishing a host retry or libc + /// restart from a new wait nested inside a caught handler. + pub fn enter_signal_mask_wait_for( + &mut self, + tid: u32, + kind: crate::signal::SignalMaskWaitKind, + new_mask: u64, + ) { + use crate::signal::SignalMaskWaitState; + + let current_mask = self.blocked_for(tid); + let current_handler_depth = self.caught_handler_depth_for(tid); + let reuse = self + .mask_waits_for(tid) + .and_then(|waits| waits.last()) + .is_some_and(|wait| { + wait.kind == kind + && wait.replacement_mask == new_mask + && current_mask == wait.replacement_mask + && match wait.state { + // Repeated kernel attempts remain part of the active + // invocation, including a wait nested in a handler. + SignalMaskWaitState::Active => true, + // A libc restart can only occur after the handler + // which interrupted this frame has returned. At that + // point the current handler depth is lower. Equal or + // greater depth is a genuinely nested invocation, + // even when the handler restored the same mask. + SignalMaskWaitState::Interrupted { handler_depth } => { + current_handler_depth < handler_depth + } + } + }); + if reuse { + if let Some(wait) = self + .mask_waits_for_mut(tid) + .and_then(|waits| waits.last_mut()) + { + wait.state = SignalMaskWaitState::Active; + } + return; + } + + let saved_mask = self.blocked_for(tid); + if let Some(waits) = self.mask_waits_for_mut(tid) { + waits.push(crate::signal::SignalMaskWaitContext { + saved_mask, + replacement_mask: new_mask, + kind, + state: SignalMaskWaitState::Active, + }); + self.set_blocked_for(tid, new_mask); + } + } + + /// Complete the active top wait in strict LIFO order. + pub fn finish_signal_mask_wait_for( + &mut self, + tid: u32, + kind: crate::signal::SignalMaskWaitKind, + ) -> bool { + use crate::signal::SignalMaskWaitState; + + let saved = self.mask_waits_for_mut(tid).and_then(|waits| { + waits + .last() + .filter(|wait| wait.kind == kind && wait.state == SignalMaskWaitState::Active)?; + waits.pop().map(|wait| wait.saved_mask) + }); + if let Some(saved) = saved { + self.set_blocked_for(tid, saved); + true + } else { + false + } + } + + /// Record one normal or nonlocal rt_sigreturn boundary. + pub fn return_from_caught_handler_for(&mut self, tid: u32) -> bool { + let depth = self.caught_handler_depth_for(tid); + if depth == 0 { + return false; + } + self.set_caught_handler_depth_for(tid, depth - 1); + if let Some(returned) = self.returned_handler_depths_for_mut(tid) { + returned.push(depth); + } + true + } + + /// A following sigprocmask is libc's normal-return restoration, not a + /// siglongjmp abandonment. + pub fn acknowledge_caught_handler_mask_restore_for(&mut self, tid: u32) { + if let Some(returned) = self.returned_handler_depths_for_mut(tid) { + returned.pop(); } } + /// Retire one exact mask-wait context. A context paired with an + /// unacknowledged rt_sigreturn belongs to siglongjmp and is discarded + /// without exposing an intermediate mask; the jump buffer supplies the + /// final mask. Normal and host cancellations restore the saved mask. + pub fn cancel_signal_mask_wait_for(&mut self, tid: u32) -> bool { + use crate::signal::SignalMaskWaitState; + + let current_depth = self.caught_handler_depth_for(tid); + let returned_depth = self + .returned_handler_depths_for_mut(tid) + .and_then(|returned| returned.last().copied()); + if let Some(depth) = returned_depth { + if let Some(returned) = self.returned_handler_depths_for_mut(tid) { + returned.pop(); + } + let abandoned = self.mask_waits_for_mut(tid).and_then(|waits| { + waits + .last() + .filter(|wait| { + wait.state + == SignalMaskWaitState::Interrupted { + handler_depth: depth, + } + })?; + waits.pop() + }); + return abandoned.is_some(); + } + + let saved = self.mask_waits_for_mut(tid).and_then(|waits| { + let top = waits.last()?; + let eligible = match top.state { + SignalMaskWaitState::Active => true, + SignalMaskWaitState::Interrupted { handler_depth } => { + handler_depth == current_depth.saturating_add(1) + } + }; + eligible.then(|| waits.pop().expect("checked top mask wait")) + }); + if let Some(wait) = saved { + self.set_blocked_for(tid, wait.saved_mask); + true + } else { + false + } + } + + /// Install the mask active while a caught handler runs and return the + /// mask that normal handler return must restore. + /// + /// ppoll, pselect, and sigsuspend leave their replacement mask current + /// while the handler runs. Their saved pre-wait mask remains Rust-owned + /// until the wait reaches a terminal kernel attempt or exact host-owned + /// cancellation. The delivery record therefore restores the current + /// replacement mask after the handler, preserving one logical restarted + /// wait without exposing the caller's pre-wait mask between attempts. + pub(crate) fn install_caught_handler_mask_for( + &mut self, + tid: u32, + action_mask: u64, + signum: u32, + ) -> u64 { + use crate::signal::SignalMaskWaitState; + + let handler_base_mask = self.blocked_for(tid); + let handler_depth = self.caught_handler_depth_for(tid).saturating_add(1); + self.set_caught_handler_depth_for(tid, handler_depth); + if let Some(wait) = self + .mask_waits_for_mut(tid) + .and_then(|waits| waits.last_mut()) + { + if wait.state == SignalMaskWaitState::Active { + wait.state = SignalMaskWaitState::Interrupted { handler_depth }; + } + } + self.set_blocked_for( + tid, + handler_base_mask | action_mask | crate::signal::sig_bit(signum), + ); + handler_base_mask + } + /// Collect every TID that has `sig` unblocked (main + worker threads). /// Used by the host to decide which thread channels to wake when a new /// shared signal arrives. @@ -2307,6 +2517,120 @@ mod tests { socket_index } + #[test] + fn temporary_wait_mask_forms_handler_mask_and_cancel_restores_once() { + use crate::signal::{SignalMaskWaitKind, sig_bit}; + use crate::syscalls::cancel_host_owned_wait_for_tid; + use wasm_posix_shared::signal::{SIGALRM, SIGTERM, SIGUSR1, SIGUSR2}; + + let mut proc = Process::new(740); + let worker_tid = 741; + proc.add_thread(ThreadInfo::new(worker_tid, 0, 0, 0)); + + let original = sig_bit(SIGUSR2); + let temporary = sig_bit(SIGTERM); + let action_mask = sig_bit(SIGUSR1); + let handler_mask = temporary | action_mask | sig_bit(SIGALRM); + + for tid in [proc.pid, worker_tid] { + proc.set_blocked_for(tid, original); + proc.enter_signal_mask_wait_for(tid, SignalMaskWaitKind::Ppoll, temporary); + + let restore_mask = proc.install_caught_handler_mask_for(tid, action_mask, SIGALRM); + + assert_eq!(restore_mask, temporary); + assert_eq!(proc.blocked_for(tid), handler_mask); + assert_eq!(proc.mask_wait_depth_for(tid), 1); + + // Normal handler return preserves the replacement mask while the + // interrupted wait decides whether it will be resubmitted. + assert!(proc.return_from_caught_handler_for(tid)); + proc.set_blocked_for(tid, restore_mask); + proc.acknowledge_caught_handler_mask_restore_for(tid); + assert_eq!(proc.blocked_for(tid), temporary); + + assert!(cancel_host_owned_wait_for_tid(&mut proc, tid)); + assert_eq!(proc.blocked_for(tid), original); + assert_eq!(proc.mask_wait_depth_for(tid), 0); + assert!(!cancel_host_owned_wait_for_tid(&mut proc, tid)); + assert_eq!(proc.blocked_for(tid), original); + } + } + + #[test] + fn ordinary_handler_return_uses_the_current_mask() { + use crate::signal::sig_bit; + use wasm_posix_shared::signal::{SIGALRM, SIGUSR1, SIGUSR2}; + + let mut proc = Process::new(742); + let original = sig_bit(SIGUSR2); + let action_mask = sig_bit(SIGUSR1); + + proc.set_blocked_for(proc.pid, original); + let restore_mask = proc.install_caught_handler_mask_for(proc.pid, action_mask, SIGALRM); + + assert_eq!(restore_mask, original); + assert_eq!( + proc.blocked_for(proc.pid), + original | action_mask | sig_bit(SIGALRM), + ); + assert_eq!(proc.mask_wait_depth_for(proc.pid), 0); + assert!(proc.return_from_caught_handler_for(proc.pid)); + proc.acknowledge_caught_handler_mask_restore_for(proc.pid); + } + + #[test] + fn nested_mask_waits_restore_lifo_and_nonlocal_unwind_discards_each_frame() { + use crate::signal::{SignalMaskWaitKind, sig_bit}; + use wasm_posix_shared::signal::{SIGALRM, SIGTERM, SIGUSR1, SIGUSR2}; + + let mut proc = Process::new(743); + let tid = proc.pid; + let original = sig_bit(SIGUSR2); + let outer = sig_bit(SIGTERM); + let inner = sig_bit(SIGUSR1); + + proc.set_blocked_for(tid, original); + proc.enter_signal_mask_wait_for(tid, SignalMaskWaitKind::Ppoll, outer); + let outer_handler = proc.install_caught_handler_mask_for(tid, 0, SIGALRM); + // A handler may deliberately restore the outer replacement mask. A + // same-kind, same-mask wait entered at this handler depth is still a + // nested invocation, not a restart of the interrupted outer wait. + proc.set_blocked_for(tid, outer_handler); + proc.enter_signal_mask_wait_for(tid, SignalMaskWaitKind::Ppoll, outer); + assert_eq!(proc.mask_wait_depth_for(tid), 2); + assert!(proc.finish_signal_mask_wait_for(tid, SignalMaskWaitKind::Ppoll)); + assert_eq!(proc.blocked_for(tid), outer_handler); + assert_eq!(proc.mask_wait_depth_for(tid), 1); + + proc.enter_signal_mask_wait_for(tid, SignalMaskWaitKind::Pselect, inner); + assert_eq!(proc.mask_wait_depth_for(tid), 2); + assert!(proc.finish_signal_mask_wait_for(tid, SignalMaskWaitKind::Pselect)); + assert_eq!(proc.blocked_for(tid), outer_handler); + assert_eq!(proc.mask_wait_depth_for(tid), 1); + + assert!(proc.return_from_caught_handler_for(tid)); + proc.set_blocked_for(tid, outer_handler); + proc.acknowledge_caught_handler_mask_restore_for(tid); + proc.enter_signal_mask_wait_for(tid, SignalMaskWaitKind::Ppoll, outer); + assert_eq!(proc.mask_wait_depth_for(tid), 1); + assert!(proc.finish_signal_mask_wait_for(tid, SignalMaskWaitKind::Ppoll)); + assert_eq!(proc.blocked_for(tid), original); + + proc.enter_signal_mask_wait_for(tid, SignalMaskWaitKind::Ppoll, outer); + proc.install_caught_handler_mask_for(tid, 0, SIGALRM); + proc.enter_signal_mask_wait_for(tid, SignalMaskWaitKind::Sigsuspend, inner); + proc.install_caught_handler_mask_for(tid, 0, SIGUSR1); + assert_eq!(proc.mask_wait_depth_for(tid), 2); + assert!(proc.return_from_caught_handler_for(tid)); + assert!(proc.cancel_signal_mask_wait_for(tid)); + assert_eq!(proc.mask_wait_depth_for(tid), 1); + assert!(proc.return_from_caught_handler_for(tid)); + assert!(proc.cancel_signal_mask_wait_for(tid)); + assert_eq!(proc.mask_wait_depth_for(tid), 0); + assert!(!proc.cancel_signal_mask_wait_for(tid)); + } + #[test] fn fork_count_starts_at_zero() { let proc = Process::new(1); diff --git a/crates/kernel/src/signal.rs b/crates/kernel/src/signal.rs index 732ffca1c0..327d5ca795 100644 --- a/crates/kernel/src/signal.rs +++ b/crates/kernel/src/signal.rs @@ -1,7 +1,7 @@ use wasm_posix_shared::{Errno, signal::NSIG}; extern crate alloc; -use alloc::collections::VecDeque; +use alloc::{collections::VecDeque, vec::Vec}; use crate::process::{HostIO, Process, ProcessState}; @@ -101,9 +101,13 @@ fn terminate_process_by_signal_impl( host: &mut dyn HostIO, signum: u32, ) { - proc.sigsuspend_saved_mask = None; + proc.mask_waits.clear(); + proc.caught_handler_depth = 0; + proc.returned_handler_depths.clear(); for thread in proc.thread_states_mut() { - thread.signals.sigsuspend_saved_mask = None; + thread.signals.mask_waits.clear(); + thread.signals.caught_handler_depth = 0; + thread.signals.returned_handler_depths.clear(); } match locks { Some(locks) => crate::syscalls::sys_exit_by_signal_with_locks(proc, locks, host, signum), @@ -347,10 +351,35 @@ pub struct PerThreadSignalState { /// Queue of RT-signal and metadata-bearing standard-signal entries /// directed at this thread. Parallel bookkeeping to [`SignalState::rt_queue`]. pub rt_queue: VecDeque, - /// Saved blocked mask during sigsuspend / ppoll / pselect (per-thread). - /// Set on first entry into a blocking signal syscall that temporarily swaps - /// the mask, restored once a signal is dequeued or the call completes. - pub sigsuspend_saved_mask: Option, + /// Nested signal-mask-swapping waits owned by this exact task. + pub mask_waits: Vec, + /// Number of caught signal handlers whose control frames are active. + pub caught_handler_depth: u32, + /// Handler depths retired by rt_sigreturn but not yet classified as a + /// normal mask restore or a nonlocal unwind. + pub returned_handler_depths: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SignalMaskWaitKind { + Ppoll, + Pselect, + Sigsuspend, + Pause, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SignalMaskWaitState { + Active, + Interrupted { handler_depth: u32 }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SignalMaskWaitContext { + pub saved_mask: u64, + pub replacement_mask: u64, + pub kind: SignalMaskWaitKind, + pub state: SignalMaskWaitState, } impl PerThreadSignalState { @@ -359,7 +388,9 @@ impl PerThreadSignalState { blocked: 0, pending: 0, rt_queue: VecDeque::new(), - sigsuspend_saved_mask: None, + mask_waits: Vec::new(), + caught_handler_depth: 0, + returned_handler_depths: Vec::new(), } } diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index c2de26cf2d..729d01ef2e 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -959,7 +959,9 @@ fn commit_exec_state_impl( proc.exit_signal = 0; proc.thread_name = [0; wasm_posix_shared::kernel_scratch_wire::PRCTL_NAME_BYTES as usize]; proc.clear_threads(); - proc.sigsuspend_saved_mask = None; + proc.mask_waits.clear(); + proc.caught_handler_depth = 0; + proc.returned_handler_depths.clear(); proc.alt_stack_sp = 0; proc.alt_stack_flags = 2; // SS_DISABLE proc.alt_stack_size = 0; @@ -2658,8 +2660,7 @@ pub(crate) fn cancel_fifo_open_for_owner(proc: &mut Process, owner: u64) -> bool pub(crate) fn cancel_host_owned_wait_for_tid(proc: &mut Process, tid: u32) -> bool { let owner = ((proc.pid as u64) << 32) | tid as u64; let mut cancelled = cancel_fifo_open_for_owner(proc, owner); - if let Some(saved) = proc.take_sigsuspend_saved_mask_for(tid) { - proc.set_blocked_for(tid, saved); + if proc.cancel_signal_mask_wait_for(tid) { cancelled = true; } cancelled @@ -8689,36 +8690,41 @@ pub fn sys_sigtimedwait( /// Atomically replaces the process's signal mask with `mask` (SIGKILL and SIGSTOP cannot /// be blocked), then blocks until a deliverable signal arrives. The original mask is /// restored before returning. Always returns Err(EINTR). -pub fn sys_sigsuspend(proc: &mut Process, _host: &mut dyn HostIO, mask: u64) -> Result<(), Errno> { +fn sys_sigsuspend_with_kind( + proc: &mut Process, + mask: u64, + kind: crate::signal::SignalMaskWaitKind, +) -> Result<(), Errno> { use wasm_posix_shared::signal::{SIGKILL, SIGSTOP}; let tid = current_tid_for_process(proc); let sig_guard = crate::signal::sig_bit(SIGKILL) | crate::signal::sig_bit(SIGSTOP); let new_mask = mask & !sig_guard; - // Keep the sigsuspend mask active between EAGAIN retries. On first call, - // save old mask. On retries, the temp mask is already set. - if proc.sigsuspend_saved_mask_for(tid).is_none() { - let old = proc.blocked_for(tid); - proc.set_sigsuspend_saved_mask_for(tid, Some(old)); - proc.set_blocked_for(tid, new_mask); - } + proc.enter_signal_mask_wait_for(tid, kind, new_mask); if proc.deliverable_for(tid) != 0 { - // Signal arrived — return EINTR but keep temp mask active so that - // dequeueSignalForDelivery picks the signal that woke sigsuspend. - // The mask will be restored in kernel_dequeue_signal after dequeue. + // Signal arrived — return EINTR but keep the temporary mask active so + // dequeueSignalForDelivery picks the signal that woke sigsuspend. The + // handler frame restores that current mask; libc then invokes exact + // wait cleanup to consume and restore the Rust-owned pre-wait mask. return Err(Errno::EINTR); } Err(Errno::EAGAIN) } +pub fn sys_sigsuspend(proc: &mut Process, _host: &mut dyn HostIO, mask: u64) -> Result<(), Errno> { + sys_sigsuspend_with_kind(proc, mask, crate::signal::SignalMaskWaitKind::Sigsuspend) +} + /// pause -- suspend until a signal is delivered. /// /// Equivalent to sigsuspend with the current signal mask (blocks until any /// unblocked signal arrives). Always returns EINTR. pub fn sys_pause(proc: &mut Process, host: &mut dyn HostIO) -> Result<(), Errno> { - let current_mask = proc.signals.blocked; - sys_sigsuspend(proc, host, current_mask) + let _ = host; + let tid = current_tid_for_process(proc); + let current_mask = proc.blocked_for(tid); + sys_sigsuspend_with_kind(proc, current_mask, crate::signal::SignalMaskWaitKind::Pause) } /// Set signal action. Accepts full sigaction struct fields. @@ -15056,26 +15062,23 @@ pub fn sys_ppoll( timeout_ms: i32, mask: Option, ) -> Result { - // Use the sigsuspend_saved_mask pattern for atomic mask swap. The mask - // stays swapped across EAGAIN retries so cross-process signals arriving - // between retries are caught on the next poll. + // A per-task LIFO context keeps this swap distinct from waits nested in a + // caught handler while host retries reuse the active top context. let tid = current_tid_for_process(proc); if let Some(new_mask) = mask { use wasm_posix_shared::signal::{SIGKILL, SIGSTOP}; - if proc.sigsuspend_saved_mask_for(tid).is_none() { - proc.set_sigsuspend_saved_mask_for(tid, Some(proc.blocked_for(tid))); - let m = new_mask & !(crate::signal::sig_bit(SIGKILL) | crate::signal::sig_bit(SIGSTOP)); - proc.set_blocked_for(tid, m); - } + let m = new_mask & !(crate::signal::sig_bit(SIGKILL) | crate::signal::sig_bit(SIGSTOP)); + proc.enter_signal_mask_wait_for(tid, crate::signal::SignalMaskWaitKind::Ppoll, m); if proc.deliverable_for(tid) != 0 { return Err(Errno::EINTR); } } let result = sys_poll(proc, host, fds, timeout_ms); - if !matches!(result, Err(Errno::EAGAIN)) && proc.deliverable_for(tid) == 0 { - if let Some(saved) = proc.take_sigsuspend_saved_mask_for(tid) { - proc.set_blocked_for(tid, saved); - } + if mask.is_some() + && !matches!(result, Err(Errno::EAGAIN)) + && proc.deliverable_for(tid) == 0 + { + proc.finish_signal_mask_wait_for(tid, crate::signal::SignalMaskWaitKind::Ppoll); } result } @@ -15092,26 +15095,23 @@ pub fn sys_pselect6( timeout_ms: i32, mask: Option, ) -> Result { - // Use the sigsuspend_saved_mask pattern for atomic mask swap. The mask - // stays swapped across EAGAIN retries so cross-process signals arriving - // between retries are caught on the next select. + // A per-task LIFO context keeps this swap distinct from waits nested in a + // caught handler while host retries reuse the active top context. let tid = current_tid_for_process(proc); if let Some(new_mask) = mask { use wasm_posix_shared::signal::{SIGKILL, SIGSTOP}; - if proc.sigsuspend_saved_mask_for(tid).is_none() { - proc.set_sigsuspend_saved_mask_for(tid, Some(proc.blocked_for(tid))); - let m = new_mask & !(crate::signal::sig_bit(SIGKILL) | crate::signal::sig_bit(SIGSTOP)); - proc.set_blocked_for(tid, m); - } + let m = new_mask & !(crate::signal::sig_bit(SIGKILL) | crate::signal::sig_bit(SIGSTOP)); + proc.enter_signal_mask_wait_for(tid, crate::signal::SignalMaskWaitKind::Pselect, m); if proc.deliverable_for(tid) != 0 { return Err(Errno::EINTR); } } let result = sys_select(proc, host, nfds, readfds, writefds, exceptfds, timeout_ms); - if !matches!(result, Err(Errno::EAGAIN)) && proc.deliverable_for(tid) == 0 { - if let Some(saved) = proc.take_sigsuspend_saved_mask_for(tid) { - proc.set_blocked_for(tid, saved); - } + if mask.is_some() + && !matches!(result, Err(Errno::EAGAIN)) + && proc.deliverable_for(tid) == 0 + { + proc.finish_signal_mask_wait_for(tid, crate::signal::SignalMaskWaitKind::Pselect); } result } @@ -28628,7 +28628,7 @@ mod tests { ); assert_eq!(fds[0].revents, 0); assert_eq!(proc.blocked_for(tid), original_mask); - assert_eq!(proc.sigsuspend_saved_mask_for(tid), None); + assert_eq!(proc.mask_wait_depth_for(tid), 0); } #[test] @@ -32734,7 +32734,7 @@ mod tests { let tid = current_tid_for_process(&proc); assert_eq!(result, Err(Errno::EINTR)); assert_eq!(proc.signals.blocked, 0); - assert_eq!(proc.sigsuspend_saved_mask_for(tid), Some(0xFF)); + assert_eq!(proc.mask_wait_depth_for(tid), 1); } #[test] @@ -32765,7 +32765,7 @@ mod tests { let tid = current_tid_for_process(&proc); assert_eq!(result, Err(Errno::EAGAIN)); assert_eq!(proc.signals.blocked, 0); - assert_eq!(proc.sigsuspend_saved_mask_for(tid), Some(0xFF)); + assert_eq!(proc.mask_wait_depth_for(tid), 1); } // ---- *at() syscalls with real dirfd ---- @@ -43485,12 +43485,16 @@ mod tests { (proc.pid, 0x1234_u64, 0x5678_u64), (worker_tid, 0x9abc_u64, 0xdef0_u64), ] { - proc.set_blocked_for(tid, temporary); - proc.set_sigsuspend_saved_mask_for(tid, Some(original)); + proc.set_blocked_for(tid, original); + proc.enter_signal_mask_wait_for( + tid, + crate::signal::SignalMaskWaitKind::Ppoll, + temporary, + ); assert!(cancel_host_owned_wait_for_tid(&mut proc, tid)); assert_eq!(proc.blocked_for(tid), original); - assert_eq!(proc.sigsuspend_saved_mask_for(tid), None); + assert_eq!(proc.mask_wait_depth_for(tid), 0); assert!(!cancel_host_owned_wait_for_tid(&mut proc, tid)); assert_eq!(proc.blocked_for(tid), original); } diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index 2c2e1b1472..1135114a8f 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -2649,7 +2649,7 @@ pub extern "C" fn kernel_dequeue_signal( out_ptr: *mut u8, out_capacity: u32, ) -> i32 { - use crate::signal::{sig_bit, SignalHandler}; + use crate::signal::SignalHandler; use wasm_posix_shared::kernel_scratch_wire as signal_wire; if let Err(error) = crate::process_wire::validate_signal_delivery_output(out_ptr, out_capacity) @@ -2675,15 +2675,14 @@ pub extern "C" fn kernel_dequeue_signal( SignalHandler::Handler(idx) => { let (_sig, si_value, si_code, siginfo_word_1, siginfo_word_2) = dequeue_signal_for(proc, tid, signum); - // If returning from sigsuspend/ppoll/pselect, restore original - // mask *before* saving old_mask for the handler, so the - // handler's saved mask is the pre-sigsuspend mask. - if let Some(saved) = proc.take_sigsuspend_saved_mask_for(tid) { - proc.set_blocked_for(tid, saved); - } - // Save old mask, apply new (POSIX: block sa_mask + the signal itself) - let old_mask = proc.blocked_for(tid); - proc.set_blocked_for(tid, old_mask | action.mask | sig_bit(signum)); + // ppoll, pselect, and sigsuspend keep their replacement mask + // installed until the logical wait finally completes. Form + // the handler mask from that current mask and place the same + // current mask in the delivery record for normal handler + // return. The saved pre-wait mask stays Rust-owned across an + // SA_RESTART resubmission and is consumed only by terminal + // wait completion or exact host-owned cancellation. + let old_mask = proc.install_caught_handler_mask_for(tid, action.mask, signum); // If SA_ONSTACK and alt stack is configured (not SS_DISABLE), // mark that we're executing on the alt stack. const SA_ONSTACK: u32 = 0x08000000; @@ -3835,6 +3834,9 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr Ok(old) => old, Err(e) => return -(e as i32), }; + proc.acknowledge_caught_handler_mask_restore_for( + syscalls::current_tid_for_process(proc), + ); if args[2] != 0 { let ptr = channel_mut_ptr!(2, u8); unsafe { @@ -3850,9 +3852,10 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr } else { // set is NULL: just read the current mask without modifying if args[2] != 0 { + let tid = syscalls::current_tid_for_process(proc); let ptr = channel_mut_ptr!(2, u8); unsafe { - let bytes = proc.signals.blocked.to_le_bytes(); + let bytes = proc.blocked_for(tid).to_le_bytes(); for i in 0..8 { *ptr.add(i) = bytes[i]; } @@ -5016,6 +5019,7 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr // SYS_RT_SIGRETURN: signal handler return — clean up alt stack state 208 => { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; + proc.return_from_caught_handler_for(syscalls::current_tid_for_process(proc)); if proc.alt_stack_depth > 0 { proc.alt_stack_depth -= 1; if proc.alt_stack_depth == 0 { @@ -8438,7 +8442,12 @@ pub extern "C" fn kernel_sigprocmask(how: u32, set_lo: u32, set_hi: u32) -> i64 let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let set = ((set_hi as u64) << 32) | (set_lo as u64); let result = match syscalls::sys_sigprocmask(proc, how, set) { - Ok(old) => old as i64, + Ok(old) => { + proc.acknowledge_caught_handler_mask_restore_for( + syscalls::current_tid_for_process(proc), + ); + old as i64 + } Err(e) => -(e as i64), }; let mut host = WasmHostIO; diff --git a/examples/select_signal_test.c b/examples/select_signal_test.c index e087c7c407..f2159b8335 100644 --- a/examples/select_signal_test.c +++ b/examples/select_signal_test.c @@ -1,101 +1,1585 @@ #define _GNU_SOURCE #include +#include +#include +#include +#include +#include +#include #include +#include +#include #include #include #include -#include +#include +#include +#include +#include #include +#include -static volatile sig_atomic_t alarm_count; +enum wait_api { + WAIT_API_PPOLL, + WAIT_API_PSELECT, +}; -static void on_alarm(int signo) +enum signal_arrival { + SIGNAL_PENDING_BEFORE_ENTRY, + SIGNAL_ARRIVING_WHILE_BLOCKED, +}; + +static volatile sig_atomic_t handler_count; +static volatile sig_atomic_t handler_failed; +static volatile sig_atomic_t handler_saw_alarm; +static volatile sig_atomic_t handler_saw_sa_mask; +static volatile sig_atomic_t handler_saw_original; +static volatile sig_atomic_t handler_saw_temporary; +static volatile sig_atomic_t handler_mask_exact; +static volatile sig_atomic_t readiness_write_fd = -1; +static volatile sig_atomic_t readiness_read_fd = -1; +static volatile sig_atomic_t restart_window_enabled; +static volatile sig_atomic_t restart_signal_count; +static volatile sig_atomic_t restart_signal_failed; +static volatile sig_atomic_t restart_signal_saw_original; +static volatile sig_atomic_t restart_signal_mask_exact; +static volatile sig_atomic_t restart_signal_consumed_readiness; +static volatile sig_atomic_t deadline_window_enabled; +static volatile sig_atomic_t cancel_window_enabled; +static volatile sig_atomic_t nested_wait_kind; +static volatile sig_atomic_t nested_wait_result; +static volatile sig_atomic_t nested_wait_errno; +static volatile sig_atomic_t nested_wait_failed; +static volatile sig_atomic_t nested_observed_hup; +static volatile sig_atomic_t nested_observed_alrm; +static volatile sig_atomic_t nested_observed_usr1; +static volatile sig_atomic_t nested_observed_usr2; +static volatile sig_atomic_t nested_observed_term; +static volatile sig_atomic_t nested_wakeup_count; +static volatile sig_atomic_t collision_inner_result; +static volatile sig_atomic_t collision_inner_ready; +static volatile sig_atomic_t collision_handler_mask_exact; + +static _Atomic uint32_t host_gate; +static _Atomic uint32_t restart_gate; +static _Atomic uint32_t deadline_gate; +static _Atomic uint32_t nested_wake_gate; +static sigset_t expected_handler_mask; +static sigset_t expected_restart_signal_mask; +static sigset_t nested_replacement_mask; +static sigset_t nested_expected_handler_mask; +static const char blocked_marker[] = "TASK16_BLOCK\n"; +static char restart_marker[80]; +static size_t restart_marker_len; +static char deadline_marker[80]; +static size_t deadline_marker_len; +static int browser_framebuffer_fd = -1; +static void *browser_framebuffer_mapping = MAP_FAILED; +static int nested_ready_fd = -1; +static pthread_t nested_target_thread; +static pthread_t nonlocal_helper_thread; +static int nested_wakeup_signal; +static int nested_wakeup_delay_ms; +static struct pollfd *collision_pollfd; +static const struct timespec *collision_timeout; +static const sigset_t *collision_replacement; +static int collision_read_fd = -1; +static int collision_write_fd = -1; +static sigjmp_buf nonlocal_landing; +static jmp_buf generic_landing; +static sigjmp_buf no_mask_landing; +static sigset_t generic_landing_mask; +static sigset_t no_mask_landing_mask; +static volatile sig_atomic_t generic_jump_failed; +static volatile sig_atomic_t generic_jump_handler_depth; +static volatile sig_atomic_t no_mask_jump_failed; +static volatile sig_atomic_t no_mask_jump_handler_depth; + +extern unsigned long __wasm_posix_caught_handler_depth(void); + +enum nested_wait { + NESTED_WAIT_NONE, + NESTED_WAIT_PPOLL, + NESTED_WAIT_PSELECT, + NESTED_WAIT_SIGSUSPEND, + NESTED_WAIT_PAUSE, +}; + +static int bind_browser_gate_memory(void) { - (void)signo; - alarm_count++; + struct fb_fix_screeninfo fixed; + + browser_framebuffer_fd = open("/dev/fb0", O_RDWR); + if (browser_framebuffer_fd < 0 || + ioctl(browser_framebuffer_fd, FBIOGET_FSCREENINFO, &fixed) != 0) + return -1; + browser_framebuffer_mapping = mmap( + NULL, + fixed.smem_len, + PROT_READ | PROT_WRITE, + MAP_SHARED, + browser_framebuffer_fd, + 0 + ); + return browser_framebuffer_mapping == MAP_FAILED ? -1 : 0; } -static int arm_alarm(long usec) +static const char *api_name(enum wait_api api) { - struct itimerval timer = { - .it_value = { .tv_sec = 0, .tv_usec = usec }, - }; - return setitimer(ITIMER_REAL, &timer, NULL); + return api == WAIT_API_PPOLL ? "ppoll" : "pselect"; } -int main(void) +static const char *arrival_name(enum signal_arrival arrival) { - struct sigaction action = { .sa_handler = on_alarm }; - sigemptyset(&action.sa_mask); - if (sigaction(SIGALRM, &action, NULL) != 0) { - perror("sigaction"); - return 2; + return arrival == SIGNAL_PENDING_BEFORE_ENTRY + ? "pending-before-entry" + : "arriving-while-blocked"; +} + +static long elapsed_ms(struct timespec start, struct timespec end) +{ + return (end.tv_sec - start.tv_sec) * 1000L + + (end.tv_nsec - start.tv_nsec) / 1000000L; +} + +static int signal_masks_equal(const sigset_t *left, const sigset_t *right) +{ + for (int signum = 1; signum < NSIG; signum++) { + int left_member = sigismember(left, signum); + int right_member = sigismember(right, signum); + + if (left_member < 0 || right_member < 0 || + left_member != right_member) + return 0; } + return 1; +} - if (arm_alarm(20 * 1000) != 0) { - perror("setitimer(select)"); - return 3; +static void on_nested_wakeup(int signum) +{ + if (signum != nested_wakeup_signal) + nested_wait_failed = 1; + nested_wakeup_count++; +} + +static void *send_nested_wakeup(void *unused) +{ + struct timespec delay; + + (void)unused; + while (atomic_load_explicit(&nested_wake_gate, memory_order_acquire) == 0) + sched_yield(); + if (nested_wakeup_delay_ms > 0) { + delay.tv_sec = nested_wakeup_delay_ms / 1000; + delay.tv_nsec = (nested_wakeup_delay_ms % 1000) * 1000000L; + nanosleep(&delay, NULL); } + if (pthread_kill(nested_target_thread, nested_wakeup_signal) != 0) + nested_wait_failed = 1; + return NULL; +} + +static void on_nested_outer(int signum) +{ + const struct timespec no_wait = { 0, 0 }; + sigset_t observed; + int result = -2; + int result_errno = 0; + + if (signum != SIGALRM || + sigprocmask(SIG_SETMASK, NULL, &observed) != 0 || + !signal_masks_equal(&observed, &nested_expected_handler_mask)) { + nested_wait_failed = 1; + return; + } + errno = 0; - if (select(0, NULL, NULL, NULL, NULL) != -1 || errno != EINTR || alarm_count != 1) { - fprintf(stderr, "select result mismatch: errno=%d alarms=%d\n", - errno, (int)alarm_count); - return 4; + switch (nested_wait_kind) { + case NESTED_WAIT_PPOLL: { + struct pollfd pfd = { + .fd = nested_ready_fd, + .events = POLLIN, + .revents = 0, + }; + result = ppoll(&pfd, 1, &no_wait, &nested_replacement_mask); + if (result != 1 || (pfd.revents & POLLIN) == 0) + nested_wait_failed = 1; + break; + } + case NESTED_WAIT_PSELECT: { + fd_set readfds; + + FD_ZERO(&readfds); + FD_SET(nested_ready_fd, &readfds); + result = pselect( + nested_ready_fd + 1, + &readfds, + NULL, + NULL, + &no_wait, + &nested_replacement_mask + ); + if (result != 1 || !FD_ISSET(nested_ready_fd, &readfds)) + nested_wait_failed = 1; + break; + } + case NESTED_WAIT_SIGSUSPEND: + atomic_store_explicit(&nested_wake_gate, 1, memory_order_release); + result = sigsuspend(&nested_replacement_mask); + result_errno = errno; + if (result != -1 || result_errno != EINTR) + nested_wait_failed = 1; + break; + case NESTED_WAIT_PAUSE: + atomic_store_explicit(&nested_wake_gate, 1, memory_order_release); + result = pause(); + result_errno = errno; + if (result != -1 || result_errno != EINTR) + nested_wait_failed = 1; + break; + default: + nested_wait_failed = 1; + return; + } + nested_wait_result = result; + nested_wait_errno = result_errno; + if (sigprocmask(SIG_SETMASK, NULL, &observed) != 0) { + nested_wait_failed = 1; + } else { + nested_observed_hup = sigismember(&observed, SIGHUP); + nested_observed_alrm = sigismember(&observed, SIGALRM); + nested_observed_usr1 = sigismember(&observed, SIGUSR1); + nested_observed_usr2 = sigismember(&observed, SIGUSR2); + nested_observed_term = sigismember(&observed, SIGTERM); + if (!signal_masks_equal(&observed, &nested_expected_handler_mask)) + nested_wait_failed = 1; } +} + +static void on_collision_alarm(int signum) +{ + sigset_t observed; + char byte; - sigset_t alarm_set; - sigset_t old_set; - sigset_t empty_set; - sigset_t restored_set; - sigemptyset(&alarm_set); - sigaddset(&alarm_set, SIGALRM); - sigemptyset(&empty_set); - if (sigprocmask(SIG_BLOCK, &alarm_set, &old_set) != 0) { - perror("sigprocmask(block)"); - return 5; + if (signum != SIGALRM || collision_write_fd < 0 || + write(collision_write_fd, "c", 1) != 1) { + nested_wait_failed = 1; + return; + } + /* A handler may explicitly restore the replacement mask before entering + * a genuinely nested wait. Retry ownership must not be inferred from + * mask values alone: this inner ppoll still needs its own LIFO frame. */ + if (sigprocmask(SIG_SETMASK, collision_replacement, NULL) != 0) { + nested_wait_failed = 1; + return; } - if (arm_alarm(20 * 1000) != 0) { - perror("setitimer(pselect)"); - return 6; + collision_pollfd->revents = 0; + collision_inner_result = ppoll( + collision_pollfd, + 1, + collision_timeout, + collision_replacement + ); + collision_inner_ready = collision_inner_result == 1 && + (collision_pollfd->revents & POLLIN) != 0; + if (!collision_inner_ready || read(collision_read_fd, &byte, 1) != 1) + nested_wait_failed = 1; + if (sigprocmask(SIG_SETMASK, NULL, &observed) != 0) + nested_wait_failed = 1; + else + collision_handler_mask_exact = signal_masks_equal( + &observed, + collision_replacement + ); + if (deadline_marker_len == 0 || + write(STDOUT_FILENO, deadline_marker, deadline_marker_len) != + (ssize_t)deadline_marker_len) { + nested_wait_failed = 1; + return; + } + while (atomic_load_explicit(&deadline_gate, memory_order_acquire) == 0) { + /* Hold the outer catcher past its original absolute deadline. */ + } +} + +static void on_nonlocal_jump(int signum) +{ + if (signum != SIGUSR1) + nested_wait_failed = 1; + siglongjmp(nonlocal_landing, 1); +} + +static void on_nonlocal_outer(int signum) +{ + sigset_t inner_mask; + + if (signum != SIGALRM) { + nested_wait_failed = 1; + return; } + sigemptyset(&inner_mask); + sigaddset(&inner_mask, SIGTERM); + atomic_store_explicit(&nested_wake_gate, 1, memory_order_release); + (void)sigsuspend(&inner_mask); + nested_wait_failed = 1; +} - const struct timespec timeout = { .tv_sec = 5, .tv_nsec = 0 }; +static void on_generic_longjmp(int signum) +{ + generic_jump_handler_depth = + (sig_atomic_t)__wasm_posix_caught_handler_depth(); + if (signum != SIGALRM || + sigprocmask(SIG_SETMASK, &generic_landing_mask, NULL) != 0) + generic_jump_failed = 1; + longjmp(generic_landing, 7); +} + +static void on_no_mask_siglongjmp(int signum) +{ + no_mask_jump_handler_depth = + (sig_atomic_t)__wasm_posix_caught_handler_depth(); + if (signum != SIGUSR1 || + sigprocmask(SIG_SETMASK, &no_mask_landing_mask, NULL) != 0) + no_mask_jump_failed = 1; + siglongjmp(no_mask_landing, 9); +} + +/* + * The actual signal catcher, rather than a host test double, observes its + * installed mask. SIGUSR2 is the caller's original mask, SIGTERM is the + * replacement ppoll/pselect mask, SIGUSR1 is sa_mask, and SIGALRM is the + * delivered signal. They are deliberately disjoint sentinels. + */ +static void on_alarm(int signum) +{ + sigset_t observed; + char byte = 'r'; + + if (signum != SIGALRM || + sigprocmask(SIG_SETMASK, NULL, &observed) != 0 || + (!deadline_window_enabled && + (readiness_write_fd < 0 || + write((int)readiness_write_fd, &byte, 1) != 1))) { + handler_failed = 1; + return; + } + + handler_count++; + handler_saw_alarm = sigismember(&observed, SIGALRM) == 1; + handler_saw_sa_mask = sigismember(&observed, SIGUSR1) == 1; + handler_saw_original = sigismember(&observed, SIGUSR2) == 1; + handler_saw_temporary = sigismember(&observed, SIGTERM) == 1; + handler_mask_exact = signal_masks_equal(&observed, &expected_handler_mask); + + if (deadline_window_enabled) { + if (deadline_marker_len == 0 || + write(STDOUT_FILENO, deadline_marker, deadline_marker_len) != + (ssize_t)deadline_marker_len) { + handler_failed = 1; + return; + } + while (atomic_load_explicit(&deadline_gate, memory_order_acquire) == 0) { + /* The host holds this handler beyond the original ppoll deadline. */ + } + } else if (restart_window_enabled) { + if (restart_marker_len == 0 || + write(STDOUT_FILENO, restart_marker, restart_marker_len) != + (ssize_t)restart_marker_len) { + handler_failed = 1; + return; + } + while (atomic_load_explicit(&restart_gate, memory_order_acquire) == 0) { + /* The host queues SIGTERM before releasing this handler. */ + } + if (sigprocmask(SIG_SETMASK, NULL, &observed) != 0 || + sigismember(&observed, SIGTERM) != 1) { + handler_failed = 1; + } + } + if (cancel_window_enabled && pthread_cancel(pthread_self()) != 0) + handler_failed = 1; +} + +static void on_restart_signal(int signum) +{ + sigset_t observed; + char byte; + + if (signum != SIGTERM || + sigprocmask(SIG_SETMASK, NULL, &observed) != 0 || + readiness_read_fd < 0) { + restart_signal_failed = 1; + return; + } + + restart_signal_count++; + restart_signal_saw_original = sigismember(&observed, SIGUSR2) == 1; + restart_signal_mask_exact = signal_masks_equal( + &observed, + &expected_restart_signal_mask + ); + restart_signal_consumed_readiness = + read((int)readiness_read_fd, &byte, 1) == 1; +} + +/* + * The Node host test injects SIGALRM after it observes pending_marker. This + * deliberate non-syscall delay leaves the signal pending before the next + * ppoll/pselect entry, without using raise() from the guest as a stand-in for + * the host-to-kernel signal path. + */ +static int wait_for_host_injection(void) +{ + char marker[64]; + int marker_len; + + atomic_store_explicit(&host_gate, 0, memory_order_relaxed); + marker_len = snprintf( + marker, + sizeof(marker), + "TASK16_GATE=%lu\n", + (unsigned long)(uintptr_t)&host_gate + ); + if (marker_len <= 0 || marker_len >= (int)sizeof(marker) || + write(STDOUT_FILENO, marker, (size_t)marker_len) != marker_len) { + return -1; + } + while (atomic_load_explicit(&host_gate, memory_order_acquire) == 0) { + /* The host releases this gate after signalProcess() queued SIGALRM. */ + } + return 0; +} + +/* The host schedules SIGALRM after this marker, while this call is parked. */ +static int mark_wait_about_to_block(void) +{ + return write(STDOUT_FILENO, blocked_marker, sizeof(blocked_marker) - 1) + == (ssize_t)(sizeof(blocked_marker) - 1) ? 0 : -1; +} + +static int call_wait( + enum wait_api api, + int read_fd, + const struct timespec *timeout, + const sigset_t *replacement_mask, + int *ready +) +{ + if (api == WAIT_API_PPOLL) { + struct pollfd pollfd = { + .fd = read_fd, + .events = POLLIN, + .revents = 0, + }; + int result = ppoll(&pollfd, 1, timeout, replacement_mask); + + *ready = result == 1 && (pollfd.revents & POLLIN) != 0; + return result; + } + { + fd_set readfds; + int result; + + FD_ZERO(&readfds); + FD_SET(read_fd, &readfds); + result = pselect( + read_fd + 1, + &readfds, + NULL, + NULL, + timeout, + replacement_mask + ); + *ready = result == 1 && FD_ISSET(read_fd, &readfds); + return result; + } +} + +static int check_restored_mask(const sigset_t *original) +{ + sigset_t observed; + + if (sigprocmask(SIG_SETMASK, NULL, &observed) != 0) + return -1; + return signal_masks_equal(&observed, original) ? 0 : -1; +} + +static int check_readiness_after_interrupt(enum wait_api api, int read_fd) +{ + const struct timespec no_wait = { 0, 0 }; + char byte = 'x'; + int result; + + if (api == WAIT_API_PPOLL) { + struct pollfd ready = { + .fd = read_fd, + .events = POLLIN, + .revents = 0, + }; + result = ppoll(&ready, 1, &no_wait, NULL); + if (result != 1 || (ready.revents & POLLIN) == 0) + result = -1; + else + result = 0; + } else { + fd_set readfds; + + FD_ZERO(&readfds); + FD_SET(read_fd, &readfds); + result = pselect(read_fd + 1, &readfds, NULL, NULL, &no_wait, NULL); + if (result != 1 || !FD_ISSET(read_fd, &readfds)) + result = -1; + else + result = 0; + } + + if (result == 0 && read(read_fd, &byte, 1) != 1) + result = -1; + return result; +} + +static int run_case( + enum wait_api api, + enum signal_arrival arrival, + int use_replacement_mask, + int restart +) +{ + sigset_t original; + sigset_t replacement; + sigset_t action_mask; + struct sigaction action; + struct timespec timeout = { .tv_sec = 1, .tv_nsec = 0 }; + struct timespec start; + struct timespec end; + const sigset_t *wait_mask = NULL; + int pipefd[2] = { -1, -1 }; + int result; + int call_errno; + int wait_reported_readiness; + int expect_ppoll_restart = api == WAIT_API_PPOLL && restart; + long duration; + + sigemptyset(&original); + sigaddset(&original, SIGUSR2); + sigemptyset(&replacement); + sigaddset(&replacement, SIGTERM); + sigemptyset(&action_mask); + sigaddset(&action_mask, SIGUSR1); + + memset(&action, 0, sizeof(action)); + action.sa_handler = on_alarm; + action.sa_mask = action_mask; + action.sa_flags = restart ? SA_RESTART : 0; + if (sigaction(SIGALRM, &action, NULL) != 0 || + sigprocmask(SIG_SETMASK, &original, NULL) != 0) { + perror("task16 setup"); + return -1; + } + + handler_count = 0; + handler_failed = 0; + handler_saw_alarm = 0; + handler_saw_sa_mask = 0; + handler_saw_original = 0; + handler_saw_temporary = 0; + handler_mask_exact = 0; + expected_handler_mask = use_replacement_mask ? replacement : original; + sigaddset(&expected_handler_mask, SIGUSR1); + sigaddset(&expected_handler_mask, SIGALRM); + if (use_replacement_mask) + wait_mask = &replacement; + if (pipe(pipefd) != 0) { + perror("task16 readiness pipe"); + return -1; + } + readiness_write_fd = pipefd[1]; + + if (arrival == SIGNAL_PENDING_BEFORE_ENTRY) { + if (clock_gettime(CLOCK_MONOTONIC, &start) != 0 || + wait_for_host_injection() != 0) { + perror("task16 pending start/gate"); + return -1; + } + } else { + if (mark_wait_about_to_block() != 0 || + clock_gettime(CLOCK_MONOTONIC, &start) != 0) { + perror("task16 blocked marker/start clock"); + return -1; + } + } errno = 0; - if (pselect(0, NULL, NULL, NULL, &timeout, &empty_set) != -1 || - errno != EINTR || alarm_count != 2) { - fprintf(stderr, "pselect result mismatch: errno=%d alarms=%d\n", - errno, (int)alarm_count); - return 7; + result = call_wait( + api, + pipefd[0], + &timeout, + wait_mask, + &wait_reported_readiness + ); + call_errno = errno; + readiness_write_fd = -1; + if (clock_gettime(CLOCK_MONOTONIC, &end) != 0) { + perror("task16 end clock"); + close(pipefd[0]); + close(pipefd[1]); + return -1; } - if (sigprocmask(SIG_SETMASK, NULL, &restored_set) != 0 || - !sigismember(&restored_set, SIGALRM)) { - fputs("pselect did not restore the caller signal mask\n", stderr); - return 8; + duration = elapsed_ms(start, end); + /* + * POSIX Issue 8 makes pselect's SA_RESTART result implementation-defined; + * Kandelo documents and tests its EINTR choice. ppoll has no equivalent + * exception, so its SA_RESTART cases must resume through the real libc + * channel path and observe the handler-produced pipe readiness. + */ + /* errno is unspecified after ppoll succeeds following a restart. */ + if ((expect_ppoll_restart && (result != 1 || !wait_reported_readiness)) || + (!expect_ppoll_restart && (result != -1 || call_errno != EINTR)) || + handler_count != 1 || + handler_failed || !handler_saw_alarm || !handler_saw_sa_mask || + handler_saw_original != !use_replacement_mask || + handler_saw_temporary != use_replacement_mask || + !handler_mask_exact || + check_restored_mask(&original) != 0 || + check_readiness_after_interrupt(api, pipefd[0]) != 0 || + (arrival == SIGNAL_ARRIVING_WHILE_BLOCKED && duration < 20) || + duration >= 1000) { + fprintf( + stderr, + "%s %s mask=%s restart=%d: result=%d errno=%d count=%d " + "restart-ppoll=%d handler={failed=%d alarm=%d sa=%d " + "original=%d temporary=%d exact=%d} " + "elapsed=%ld\n", + api_name(api), arrival_name(arrival), + use_replacement_mask ? "replacement" : "null", restart, + result, call_errno, (int)handler_count, expect_ppoll_restart, + (int)handler_failed, + (int)handler_saw_alarm, (int)handler_saw_sa_mask, + (int)handler_saw_original, (int)handler_saw_temporary, + (int)handler_mask_exact, duration + ); + close(pipefd[0]); + close(pipefd[1]); + return -1; } - if (sigprocmask(SIG_SETMASK, &old_set, NULL) != 0) { - perror("sigprocmask(restore)"); - return 9; + + if (close(pipefd[0]) != 0 || close(pipefd[1]) != 0) + return -1; + return 0; +} + +/* + * Hold the first handler after it creates readiness, then have the host queue + * SIGTERM. SIGTERM is blocked by ppoll's replacement mask, so it must remain + * pending through handler return and the SA_RESTART resubmission. If libc + * exposes the original mask in that window, SIGTERM drains the readiness byte + * before ppoll is resubmitted and the restarted wait loses the wakeup. + */ +static int run_ppoll_restart_window_case(void) +{ + sigset_t original; + sigset_t replacement; + struct sigaction alarm_action; + struct sigaction restart_action; + const struct timespec timeout = { .tv_sec = 0, .tv_nsec = 600000000 }; + struct timespec second_start; + struct timespec second_end; + struct pollfd pfd; + int pipefd[2] = { -1, -1 }; + int ready = 0; + int result; + int second_result; + int marker_len; + long second_duration; + + sigemptyset(&original); + sigaddset(&original, SIGUSR2); + sigemptyset(&replacement); + sigaddset(&replacement, SIGTERM); + + memset(&alarm_action, 0, sizeof(alarm_action)); + alarm_action.sa_handler = on_alarm; + sigemptyset(&alarm_action.sa_mask); + alarm_action.sa_flags = SA_RESTART; + memset(&restart_action, 0, sizeof(restart_action)); + restart_action.sa_handler = on_restart_signal; + sigemptyset(&restart_action.sa_mask); + if (sigaction(SIGALRM, &alarm_action, NULL) != 0 || + sigaction(SIGTERM, &restart_action, NULL) != 0 || + sigprocmask(SIG_SETMASK, &original, NULL) != 0 || + pipe(pipefd) != 0) { + perror("task16 restart-window setup"); + return -1; } - action.sa_handler = SIG_IGN; - if (sigaction(SIGALRM, &action, NULL) != 0) { - perror("sigaction(ignore)"); - return 10; + atomic_store_explicit(&restart_gate, 0, memory_order_relaxed); + marker_len = snprintf( + restart_marker, + sizeof(restart_marker), + "TASK16_RESTART_GATE=%lu\n", + (unsigned long)(uintptr_t)&restart_gate + ); + if (marker_len <= 0 || marker_len >= (int)sizeof(restart_marker)) { + fputs("task16 restart-window marker overflow\n", stderr); + return -1; } - if (arm_alarm(20 * 1000) != 0) { - perror("setitimer(ignored select)"); - return 11; + restart_marker_len = (size_t)marker_len; + handler_count = 0; + handler_failed = 0; + restart_signal_count = 0; + restart_signal_failed = 0; + restart_signal_saw_original = 0; + restart_signal_mask_exact = 0; + restart_signal_consumed_readiness = 0; + restart_window_enabled = 1; + readiness_read_fd = pipefd[0]; + readiness_write_fd = pipefd[1]; + expected_handler_mask = replacement; + sigaddset(&expected_handler_mask, SIGALRM); + expected_restart_signal_mask = original; + sigaddset(&expected_restart_signal_mask, SIGTERM); + + if (mark_wait_about_to_block() != 0) { + perror("task16 restart-window blocked marker"); + return -1; } - struct timeval ignored_timeout = { .tv_sec = 0, .tv_usec = 50 * 1000 }; errno = 0; - if (select(0, NULL, NULL, NULL, &ignored_timeout) != 0 || - errno != 0 || alarm_count != 2) { - fprintf(stderr, "ignored select mismatch: errno=%d alarms=%d\n", - errno, (int)alarm_count); - return 12; + pfd.fd = pipefd[0]; + pfd.events = POLLIN; + pfd.revents = 0; + result = ppoll(&pfd, 1, &timeout, &replacement); + ready = result == 1 && (pfd.revents & POLLIN) != 0; + restart_window_enabled = 0; + readiness_read_fd = -1; + readiness_write_fd = -1; + if (result != 1 || !ready || handler_count != 1 || handler_failed || + !handler_saw_temporary || !handler_mask_exact || + restart_signal_count != 1 || restart_signal_failed || + !restart_signal_saw_original || !restart_signal_mask_exact || + !restart_signal_consumed_readiness || + check_restored_mask(&original) != 0) { + fprintf( + stderr, + "ppoll restart-window: result=%d errno=%d ready=%d " + "first={count=%d failed=%d temporary=%d exact=%d} " + "second={count=%d failed=%d original=%d exact=%d consumed=%d}\n", + result, errno, ready, (int)handler_count, (int)handler_failed, + (int)handler_saw_temporary, (int)handler_mask_exact, + (int)restart_signal_count, (int)restart_signal_failed, + (int)restart_signal_saw_original, + (int)restart_signal_mask_exact, + (int)restart_signal_consumed_readiness + ); + close(pipefd[0]); + close(pipefd[1]); + return -1; + } + + /* + * Reuse every ppoll argument address after the restarted call completed + * with immediate readiness. Its carried absolute deadline must have been + * consumed before that terminal dispatch, so this independent wait gets + * a fresh interval instead of inheriting the first call's remainder. + */ + pfd.revents = 0; + if (clock_gettime(CLOCK_MONOTONIC, &second_start) != 0) + return -1; + second_result = ppoll(&pfd, 1, &timeout, &replacement); + if (clock_gettime(CLOCK_MONOTONIC, &second_end) != 0) + return -1; + second_duration = elapsed_ms(second_start, second_end); + if (second_result != 0 || pfd.revents != 0 || + second_duration < 550 || second_duration >= 850 || + check_restored_mask(&original) != 0) { + fprintf( + stderr, + "ppoll restart carry: result=%d revents=%d elapsed=%ld\n", + second_result, pfd.revents, second_duration + ); + close(pipefd[0]); + close(pipefd[1]); + return -1; } - puts("PASS select and pselect EINTR"); + if (close(pipefd[0]) != 0 || close(pipefd[1]) != 0) + return -1; + return 0; +} + +static int run_ppoll_restart_deadline_case(void) +{ + sigset_t original; + sigset_t replacement; + struct sigaction action; + const struct timespec timeout = { .tv_sec = 0, .tv_nsec = 250000000 }; + struct timespec start; + struct timespec end; + int marker_len; + int result; + long duration; + + sigemptyset(&original); + sigaddset(&original, SIGUSR2); + sigemptyset(&replacement); + sigaddset(&replacement, SIGTERM); + memset(&action, 0, sizeof(action)); + action.sa_handler = on_alarm; + sigemptyset(&action.sa_mask); + action.sa_flags = SA_RESTART; + if (sigaction(SIGALRM, &action, NULL) != 0 || + sigprocmask(SIG_SETMASK, &original, NULL) != 0) + return -1; + + atomic_store_explicit(&deadline_gate, 0, memory_order_relaxed); + marker_len = snprintf( + deadline_marker, + sizeof(deadline_marker), + "TASK16_TIMEOUT_GATE=%lu\n", + (unsigned long)(uintptr_t)&deadline_gate + ); + if (marker_len <= 0 || marker_len >= (int)sizeof(deadline_marker)) + return -1; + deadline_marker_len = (size_t)marker_len; + handler_count = 0; + handler_failed = 0; + handler_mask_exact = 0; + deadline_window_enabled = 1; + expected_handler_mask = replacement; + sigaddset(&expected_handler_mask, SIGALRM); + + if (mark_wait_about_to_block() != 0 || + clock_gettime(CLOCK_MONOTONIC, &start) != 0) + return -1; + result = ppoll(NULL, 0, &timeout, &replacement); + deadline_window_enabled = 0; + if (clock_gettime(CLOCK_MONOTONIC, &end) != 0) + return -1; + duration = elapsed_ms(start, end); + + if (result != 0 || handler_count != 1 || handler_failed || + !handler_saw_temporary || !handler_mask_exact || + check_restored_mask(&original) != 0 || + duration < 250 || duration >= 450) { + fprintf( + stderr, + "ppoll restart deadline: result=%d elapsed=%ld " + "handler={count=%d failed=%d temporary=%d exact=%d}\n", + result, duration, (int)handler_count, (int)handler_failed, + (int)handler_saw_temporary, (int)handler_mask_exact + ); + return -1; + } + return 0; +} + +static int run_ppoll_masked_cancel_case(void) +{ + sigset_t original; + sigset_t replacement; + struct sigaction action; + const struct timespec timeout = { .tv_sec = 1, .tv_nsec = 0 }; + struct pollfd pfd; + int pipefd[2] = { -1, -1 }; + int old_cancel_state; + int result; + int call_errno; + char byte; + + sigemptyset(&original); + sigaddset(&original, SIGUSR2); + sigemptyset(&replacement); + sigaddset(&replacement, SIGTERM); + memset(&action, 0, sizeof(action)); + action.sa_handler = on_alarm; + sigemptyset(&action.sa_mask); + action.sa_flags = SA_RESTART; + if (sigaction(SIGALRM, &action, NULL) != 0 || + sigprocmask(SIG_SETMASK, &original, NULL) != 0 || + pipe(pipefd) != 0 || pthread_setcancelstate(2, &old_cancel_state) != 0) + return -1; + + handler_count = 0; + handler_failed = 0; + handler_mask_exact = 0; + cancel_window_enabled = 1; + readiness_write_fd = pipefd[1]; + expected_handler_mask = replacement; + sigaddset(&expected_handler_mask, SIGALRM); + pfd.fd = pipefd[0]; + pfd.events = POLLIN; + pfd.revents = 0; + + if (mark_wait_about_to_block() != 0) + return -1; + errno = 0; + result = ppoll(&pfd, 1, &timeout, &replacement); + call_errno = errno; + cancel_window_enabled = 0; + readiness_write_fd = -1; + + if (result != -1 || call_errno != ECANCELED || + read(pipefd[0], &byte, 1) != 1 || handler_count != 1 || + handler_failed || !handler_saw_temporary || !handler_mask_exact || + check_restored_mask(&original) != 0) { + fprintf( + stderr, + "ppoll masked cancel: result=%d errno=%d " + "handler={count=%d failed=%d temporary=%d exact=%d}\n", + result, call_errno, (int)handler_count, (int)handler_failed, + (int)handler_saw_temporary, (int)handler_mask_exact + ); + close(pipefd[0]); + close(pipefd[1]); + return -1; + } + + if (close(pipefd[0]) != 0 || close(pipefd[1]) != 0) + return -1; + return 0; +} + +static int check_wait4_unknown_option(void) +{ + const int task16_unknown_wait_option = 0x40000000; + + errno = 0; + if (syscall( + SYS_wait4, + -1, + (int *)NULL, + task16_unknown_wait_option, + (struct rusage *)NULL + ) != -1 || errno != EINVAL) { + fprintf(stderr, "wait4 unknown option: errno=%d\n", errno); + return -1; + } + return 0; +} + +static int run_pause_cleanup_case(void) +{ + sigset_t pause_mask; + sigset_t next_original; + sigset_t poll_mask; + struct pollfd pfd; + const struct timespec no_wait = { .tv_sec = 0, .tv_nsec = 0 }; + int pipefd[2] = { -1, -1 }; + int pause_result; + int pause_errno; + int poll_result; + char byte; + + sigemptyset(&pause_mask); + sigaddset(&pause_mask, SIGUSR2); + sigemptyset(&next_original); + sigaddset(&next_original, SIGTERM); + sigemptyset(&poll_mask); + sigaddset(&poll_mask, SIGUSR1); + if (sigprocmask(SIG_SETMASK, &pause_mask, NULL) != 0 || pipe(pipefd) != 0) + return -1; + + handler_count = 0; + handler_failed = 0; + readiness_write_fd = pipefd[1]; + expected_handler_mask = pause_mask; + sigaddset(&expected_handler_mask, SIGALRM); + + if (mark_wait_about_to_block() != 0) + return -1; + errno = 0; + pause_result = pause(); + pause_errno = errno; + + if (sigprocmask(SIG_SETMASK, &next_original, NULL) != 0) + return -1; + pfd.fd = pipefd[0]; + pfd.events = POLLIN; + pfd.revents = 0; + poll_result = ppoll(&pfd, 1, &no_wait, &poll_mask); + readiness_write_fd = -1; + + if (pause_result != -1 || pause_errno != EINTR || poll_result != 1 || + (pfd.revents & POLLIN) == 0 || read(pipefd[0], &byte, 1) != 1 || + handler_count != 1 || handler_failed || !handler_mask_exact || + check_restored_mask(&next_original) != 0) { + fprintf( + stderr, + "pause cleanup: pause={result=%d errno=%d} poll=%d revents=%d " + "handler={count=%d failed=%d exact=%d}\n", + pause_result, pause_errno, poll_result, pfd.revents, + (int)handler_count, (int)handler_failed, (int)handler_mask_exact + ); + close(pipefd[0]); + close(pipefd[1]); + return -1; + } + + if (close(pipefd[0]) != 0 || close(pipefd[1]) != 0) + return -1; + return 0; +} + +static int run_sigsuspend_cleanup_case(void) +{ + sigset_t original; + sigset_t replacement; + int pipefd[2] = { -1, -1 }; + int result; + int call_errno; + char byte; + + sigemptyset(&original); + sigaddset(&original, SIGUSR2); + sigaddset(&original, SIGALRM); + sigemptyset(&replacement); + sigaddset(&replacement, SIGTERM); + if (sigprocmask(SIG_SETMASK, &original, NULL) != 0 || pipe(pipefd) != 0) + return -1; + + handler_count = 0; + handler_failed = 0; + readiness_write_fd = pipefd[1]; + expected_handler_mask = replacement; + sigaddset(&expected_handler_mask, SIGALRM); + /* Target the task that owns this wait. A process-directed raise from the + * pthread subcase may legally route to the main thread instead. */ + if (pthread_kill(pthread_self(), SIGALRM) != 0) + return -1; + + errno = 0; + result = sigsuspend(&replacement); + call_errno = errno; + readiness_write_fd = -1; + + if (result != -1 || call_errno != EINTR || + read(pipefd[0], &byte, 1) != 1 || handler_count != 1 || + handler_failed || !handler_saw_temporary || !handler_mask_exact || + check_restored_mask(&original) != 0) { + fprintf( + stderr, + "sigsuspend cleanup: result=%d errno=%d " + "handler={count=%d failed=%d temporary=%d exact=%d}\n", + result, call_errno, (int)handler_count, (int)handler_failed, + (int)handler_saw_temporary, (int)handler_mask_exact + ); + close(pipefd[0]); + close(pipefd[1]); + return -1; + } + + if (close(pipefd[0]) != 0 || close(pipefd[1]) != 0) + return -1; + return 0; +} + +static int run_nested_wait_case(enum nested_wait kind) +{ + sigset_t original; + sigset_t outer_replacement; + struct sigaction outer_action; + struct sigaction wake_action; + const struct timespec no_wait = { 0, 0 }; + pthread_t helper; + int helper_started = 0; + int pipefd[2] = { -1, -1 }; + int outer_result; + char byte = 'n'; + + sigemptyset(&original); + sigaddset(&original, SIGALRM); + sigaddset(&original, SIGUSR2); + sigemptyset(&outer_replacement); + sigaddset(&outer_replacement, SIGTERM); + sigemptyset(&nested_replacement_mask); + sigaddset(&nested_replacement_mask, SIGHUP); + + memset(&outer_action, 0, sizeof(outer_action)); + outer_action.sa_handler = on_nested_outer; + sigemptyset(&outer_action.sa_mask); + sigaddset(&outer_action.sa_mask, SIGUSR1); + outer_action.sa_flags = SA_RESTART; + memset(&wake_action, 0, sizeof(wake_action)); + wake_action.sa_handler = on_nested_wakeup; + sigemptyset(&wake_action.sa_mask); + if (sigaction(SIGALRM, &outer_action, NULL) != 0 || + sigaction(SIGUSR2, &wake_action, NULL) != 0 || + sigprocmask(SIG_SETMASK, &original, NULL) != 0 || + pipe(pipefd) != 0) + return -1; + + nested_wait_kind = kind; + nested_wait_result = -2; + nested_wait_errno = 0; + nested_wait_failed = 0; + nested_observed_hup = -1; + nested_observed_alrm = -1; + nested_observed_usr1 = -1; + nested_observed_usr2 = -1; + nested_observed_term = -1; + nested_wakeup_count = 0; + nested_ready_fd = pipefd[0]; + nested_target_thread = pthread_self(); + nested_wakeup_signal = SIGUSR2; + nested_wakeup_delay_ms = 0; + atomic_store_explicit(&nested_wake_gate, 0, memory_order_relaxed); + nested_expected_handler_mask = outer_replacement; + sigaddset(&nested_expected_handler_mask, SIGALRM); + sigaddset(&nested_expected_handler_mask, SIGUSR1); + + if (kind == NESTED_WAIT_PPOLL || kind == NESTED_WAIT_PSELECT) { + if (write(pipefd[1], &byte, 1) != 1) + return -1; + } else { + if (pthread_create(&helper, NULL, send_nested_wakeup, NULL) != 0) + return -1; + helper_started = 1; + } + if (raise(SIGALRM) != 0) + return -1; + errno = 0; + outer_result = ppoll(NULL, 0, &no_wait, &outer_replacement); + if (helper_started && pthread_join(helper, NULL) != 0) + return -1; + + if (outer_result != 0 || nested_wait_failed || + nested_wait_result == -2 || + ((kind == NESTED_WAIT_SIGSUSPEND || kind == NESTED_WAIT_PAUSE) && + nested_wakeup_count != 1) || + check_restored_mask(&original) != 0) { + fprintf( + stderr, + "nested wait kind=%d: outer=%d inner={result=%d errno=%d " + "failed=%d wakeups=%d mask={hup=%d alrm=%d usr1=%d usr2=%d " + "term=%d}}\n", + kind, outer_result, (int)nested_wait_result, + (int)nested_wait_errno, (int)nested_wait_failed, + (int)nested_wakeup_count, + (int)nested_observed_hup, (int)nested_observed_alrm, + (int)nested_observed_usr1, (int)nested_observed_usr2, + (int)nested_observed_term + ); + close(pipefd[0]); + close(pipefd[1]); + return -1; + } + nested_wait_kind = NESTED_WAIT_NONE; + nested_ready_fd = -1; + if (close(pipefd[0]) != 0 || close(pipefd[1]) != 0) + return -1; + return 0; +} + +static void *run_pthread_nested_wait(void *result_ptr) +{ + int *result = result_ptr; + *result = run_nested_wait_case(NESTED_WAIT_PPOLL); + return NULL; +} + +static int run_nested_wait_matrix(void) +{ + pthread_t worker; + int worker_result = -1; + + for (enum nested_wait kind = NESTED_WAIT_PPOLL; + kind <= NESTED_WAIT_PAUSE; + kind++) { + if (run_nested_wait_case(kind) != 0) + return -1; + } + if (pthread_create(&worker, NULL, run_pthread_nested_wait, &worker_result) != 0 || + pthread_join(worker, NULL) != 0 || worker_result != 0) { + fputs("pthread nested ppoll failed\n", stderr); + return -1; + } + return 0; +} + +static int run_nested_same_argument_deadline_case(void) +{ + sigset_t original; + sigset_t replacement; + struct sigaction action; + const struct timespec timeout = { .tv_sec = 0, .tv_nsec = 250000000 }; + struct timespec start; + struct timespec end; + struct pollfd pfd; + int pipefd[2] = { -1, -1 }; + int marker_len; + int result; + long duration; + + sigemptyset(&original); + sigaddset(&original, SIGUSR2); + sigemptyset(&replacement); + sigaddset(&replacement, SIGTERM); + memset(&action, 0, sizeof(action)); + action.sa_handler = on_collision_alarm; + sigemptyset(&action.sa_mask); + action.sa_flags = SA_RESTART; + if (sigaction(SIGALRM, &action, NULL) != 0 || + sigprocmask(SIG_SETMASK, &original, NULL) != 0 || + pipe(pipefd) != 0) + return -1; + + marker_len = snprintf( + deadline_marker, + sizeof(deadline_marker), + "TASK16_TIMEOUT_GATE=%lu\n", + (unsigned long)(uintptr_t)&deadline_gate + ); + if (marker_len <= 0 || marker_len >= (int)sizeof(deadline_marker)) + return -1; + deadline_marker_len = (size_t)marker_len; + atomic_store_explicit(&deadline_gate, 0, memory_order_relaxed); + nested_wait_failed = 0; + collision_inner_result = -2; + collision_inner_ready = 0; + collision_handler_mask_exact = 0; + pfd.fd = pipefd[0]; + pfd.events = POLLIN; + pfd.revents = 0; + collision_pollfd = &pfd; + collision_timeout = &timeout; + collision_replacement = &replacement; + collision_read_fd = pipefd[0]; + collision_write_fd = pipefd[1]; + nested_expected_handler_mask = replacement; + sigaddset(&nested_expected_handler_mask, SIGALRM); + + if (mark_wait_about_to_block() != 0 || + clock_gettime(CLOCK_MONOTONIC, &start) != 0) + return -1; + result = ppoll(&pfd, 1, &timeout, &replacement); + if (clock_gettime(CLOCK_MONOTONIC, &end) != 0) + return -1; + duration = elapsed_ms(start, end); + + if (result != 0 || nested_wait_failed || collision_inner_result != 1 || + !collision_inner_ready || !collision_handler_mask_exact || + check_restored_mask(&original) != 0 || + duration < 250 || duration >= 450) { + fprintf( + stderr, + "nested same-args ppoll: outer=%d elapsed=%ld " + "inner={result=%d ready=%d mask=%d failed=%d}\n", + result, duration, (int)collision_inner_result, + (int)collision_inner_ready, (int)collision_handler_mask_exact, + (int)nested_wait_failed + ); + close(pipefd[0]); + close(pipefd[1]); + return -1; + } + collision_read_fd = -1; + collision_write_fd = -1; + if (close(pipefd[0]) != 0 || close(pipefd[1]) != 0) + return -1; + return 0; +} + +static int run_nested_siglongjmp_cleanup_case(void) +{ + sigset_t jump_mask; + sigset_t outer_original; + sigset_t replacement; + sigset_t later_original; + struct sigaction outer_action; + struct sigaction jump_action; + const struct timespec timeout = { .tv_sec = 0, .tv_nsec = 600000000 }; + struct timespec start; + struct timespec end; + int result; + long duration; + + sigemptyset(&jump_mask); + sigaddset(&jump_mask, SIGHUP); + sigemptyset(&outer_original); + sigaddset(&outer_original, SIGUSR2); + sigemptyset(&replacement); + sigaddset(&replacement, SIGTERM); + sigemptyset(&later_original); + sigaddset(&later_original, SIGCHLD); + memset(&outer_action, 0, sizeof(outer_action)); + outer_action.sa_handler = on_nonlocal_outer; + sigemptyset(&outer_action.sa_mask); + outer_action.sa_flags = SA_RESTART; + memset(&jump_action, 0, sizeof(jump_action)); + jump_action.sa_handler = on_nonlocal_jump; + sigemptyset(&jump_action.sa_mask); + if (sigaction(SIGALRM, &outer_action, NULL) != 0 || + sigaction(SIGUSR1, &jump_action, NULL) != 0 || + sigprocmask(SIG_SETMASK, &jump_mask, NULL) != 0) + return -1; + + if (sigsetjmp(nonlocal_landing, 1) == 0) { + if (sigprocmask(SIG_SETMASK, &outer_original, NULL) != 0) + return -1; + nested_wait_failed = 0; + nested_target_thread = pthread_self(); + nested_wakeup_signal = SIGUSR1; + nested_wakeup_delay_ms = 250; + atomic_store_explicit(&nested_wake_gate, 0, memory_order_relaxed); + if (pthread_create( + &nonlocal_helper_thread, + NULL, + send_nested_wakeup, + NULL + ) != 0) + return -1; + if (mark_wait_about_to_block() != 0) + return -1; + (void)ppoll(NULL, 0, &timeout, &replacement); + nested_wait_failed = 1; + return -1; + } + + if (pthread_join(nonlocal_helper_thread, NULL) != 0 || nested_wait_failed || + check_restored_mask(&jump_mask) != 0 || + sigprocmask(SIG_SETMASK, &later_original, NULL) != 0) + return -1; + if (clock_gettime(CLOCK_MONOTONIC, &start) != 0) + return -1; + result = ppoll(NULL, 0, &timeout, &replacement); + if (clock_gettime(CLOCK_MONOTONIC, &end) != 0) + return -1; + duration = elapsed_ms(start, end); + if (result != 0 || duration < 550 || duration >= 850 || + check_restored_mask(&later_original) != 0) { + fprintf( + stderr, + "nested siglongjmp follow-on ppoll: result=%d elapsed=%ld " + "failed=%d\n", + result, duration, (int)nested_wait_failed + ); + return -1; + } + return 0; +} + +static int run_ordinary_longjmp_case(void) +{ + jmp_buf landing; + sigset_t expected; + int jump_value; + + sigemptyset(&expected); + sigaddset(&expected, SIGHUP); + if (sigprocmask(SIG_SETMASK, &expected, NULL) != 0) + return -1; + + jump_value = setjmp(landing); + if (jump_value == 0) + longjmp(landing, 5); + if (jump_value != 5 || __wasm_posix_caught_handler_depth() != 0 || + check_restored_mask(&expected) != 0) { + fprintf( + stderr, + "ordinary longjmp over-clean: value=%d depth=%lu\n", + jump_value, __wasm_posix_caught_handler_depth() + ); + return -1; + } + return 0; +} + +static int run_siglongjmp_no_mask_case(void) +{ + sigset_t setjmp_mask; + sigset_t outer_original; + sigset_t replacement; + sigset_t later_original; + struct sigaction action; + const struct timespec timeout = { .tv_sec = 1, .tv_nsec = 0 }; + const struct timespec no_wait = { 0, 0 }; + int jump_value; + int later_result; + + sigemptyset(&setjmp_mask); + sigaddset(&setjmp_mask, SIGHUP); + sigemptyset(&outer_original); + sigaddset(&outer_original, SIGUSR2); + sigemptyset(&replacement); + sigaddset(&replacement, SIGTERM); + sigemptyset(&no_mask_landing_mask); + sigaddset(&no_mask_landing_mask, SIGCHLD); + sigemptyset(&later_original); + sigaddset(&later_original, SIGWINCH); + memset(&action, 0, sizeof(action)); + action.sa_handler = on_no_mask_siglongjmp; + sigemptyset(&action.sa_mask); + action.sa_flags = SA_RESTART; + if (sigaction(SIGUSR1, &action, NULL) != 0 || + sigprocmask(SIG_SETMASK, &setjmp_mask, NULL) != 0) + return -1; + + no_mask_jump_failed = 0; + no_mask_jump_handler_depth = -1; + jump_value = sigsetjmp(no_mask_landing, 0); + if (jump_value == 0) { + if (sigprocmask(SIG_SETMASK, &outer_original, NULL) != 0) + return -1; + nested_wait_failed = 0; + nested_target_thread = pthread_self(); + nested_wakeup_signal = SIGUSR1; + nested_wakeup_delay_ms = 0; + atomic_store_explicit(&nested_wake_gate, 0, memory_order_relaxed); + if (pthread_create( + &nonlocal_helper_thread, + NULL, + send_nested_wakeup, + NULL + ) != 0) + return -1; + atomic_store_explicit(&nested_wake_gate, 1, memory_order_release); + (void)ppoll(NULL, 0, &timeout, &replacement); + return -1; + } + + if (pthread_join(nonlocal_helper_thread, NULL) != 0 || + jump_value != 9 || nested_wait_failed || no_mask_jump_failed || + no_mask_jump_handler_depth != 1 || + __wasm_posix_caught_handler_depth() != 0 || + check_restored_mask(&no_mask_landing_mask) != 0 || + sigprocmask(SIG_SETMASK, &later_original, NULL) != 0) + return -1; + later_result = ppoll(NULL, 0, &no_wait, &replacement); + if (later_result != 0 || check_restored_mask(&later_original) != 0) { + fprintf( + stderr, + "siglongjmp savemask=0: value=%d handler_depth=%d depth=%lu " + "later=%d failed=%d\n", + jump_value, (int)no_mask_jump_handler_depth, + __wasm_posix_caught_handler_depth(), later_result, + (int)no_mask_jump_failed + ); + return -1; + } + return 0; +} + +static int run_generic_longjmp_cleanup_case(void) +{ + sigset_t outer_original; + sigset_t replacement; + sigset_t later_original; + struct sigaction action; + const struct timespec timeout = { .tv_sec = 1, .tv_nsec = 0 }; + const struct timespec no_wait = { 0, 0 }; + int jump_value; + int later_result; + unsigned long final_depth; + + sigemptyset(&generic_landing_mask); + sigaddset(&generic_landing_mask, SIGHUP); + sigemptyset(&outer_original); + sigaddset(&outer_original, SIGUSR2); + sigemptyset(&replacement); + sigaddset(&replacement, SIGTERM); + sigemptyset(&later_original); + sigaddset(&later_original, SIGCHLD); + memset(&action, 0, sizeof(action)); + action.sa_handler = on_generic_longjmp; + sigemptyset(&action.sa_mask); + action.sa_flags = SA_RESTART; + if (sigaction(SIGALRM, &action, NULL) != 0 || + sigprocmask(SIG_SETMASK, &generic_landing_mask, NULL) != 0) + return -1; + + generic_jump_failed = 0; + generic_jump_handler_depth = -1; + jump_value = setjmp(generic_landing); + if (jump_value == 0) { + if (sigprocmask(SIG_SETMASK, &outer_original, NULL) != 0) + return -1; + nested_wait_failed = 0; + nested_target_thread = pthread_self(); + nested_wakeup_signal = SIGALRM; + nested_wakeup_delay_ms = 0; + atomic_store_explicit(&nested_wake_gate, 0, memory_order_relaxed); + if (pthread_create( + &nonlocal_helper_thread, + NULL, + send_nested_wakeup, + NULL + ) != 0) + return -1; + atomic_store_explicit(&nested_wake_gate, 1, memory_order_release); + (void)ppoll(NULL, 0, &timeout, &replacement); + return -1; + } + + final_depth = __wasm_posix_caught_handler_depth(); + if (pthread_join(nonlocal_helper_thread, NULL) != 0 || + sigprocmask(SIG_SETMASK, &later_original, NULL) != 0) + return -1; + later_result = ppoll(NULL, 0, &no_wait, &replacement); + if (jump_value != 7 || nested_wait_failed || generic_jump_failed || + generic_jump_handler_depth != 1 || final_depth != 0 || + later_result != 0 || check_restored_mask(&later_original) != 0) { + fprintf( + stderr, + "generic handler longjmp cleanup: value=%d handler_depth=%d " + "final_depth=%lu later=%d failed=%d\n", + jump_value, (int)generic_jump_handler_depth, final_depth, + later_result, (int)generic_jump_failed + ); + return -1; + } + return 0; +} + +int main(int argc, char **argv) +{ + int cleanup_failed = 0; + int use_browser_gate = argc == 2 && + strcmp(argv[1], "--browser-gate") == 0; + + if ((argc != 1 && !use_browser_gate) || + (use_browser_gate && bind_browser_gate_memory() != 0) || + check_wait4_unknown_option() != 0) + return 2; + + for (enum wait_api api = WAIT_API_PPOLL; api <= WAIT_API_PSELECT; api++) { + for (enum signal_arrival arrival = SIGNAL_PENDING_BEFORE_ENTRY; + arrival <= SIGNAL_ARRIVING_WHILE_BLOCKED; + arrival++) { + for (int use_replacement_mask = 0; + use_replacement_mask <= 1; + use_replacement_mask++) { + for (int restart = 0; restart <= 1; restart++) { + if (run_case(api, arrival, use_replacement_mask, restart) != 0) + return 2; + } + } + } + } + + if (run_ppoll_restart_window_case() != 0) + return 2; + if (run_ppoll_masked_cancel_case() != 0) + return 2; + if (run_ppoll_restart_deadline_case() != 0) + return 2; + if (run_pause_cleanup_case() != 0) + cleanup_failed = 1; + if (run_sigsuspend_cleanup_case() != 0) + cleanup_failed = 1; + if (run_nested_wait_matrix() != 0) + cleanup_failed = 1; + if (run_nested_same_argument_deadline_case() != 0) + cleanup_failed = 1; + if (run_nested_siglongjmp_cleanup_case() != 0) + cleanup_failed = 1; + if (run_ordinary_longjmp_case() != 0) + cleanup_failed = 1; + if (run_siglongjmp_no_mask_case() != 0) + cleanup_failed = 1; + if (run_generic_longjmp_cleanup_case() != 0) + cleanup_failed = 1; + if (cleanup_failed) + return 2; + + puts("PASS ppoll/pselect signal mask interruption matrix"); return 0; } diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index f20eeb34dd..9fe170e184 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -15763,6 +15763,11 @@ export class CentralizedKernelWorker { * - otherwise (not blocked, or already completed): no-op. The target * will observe self->cancel on its next cancel-point entry. * + * The self-target form is also libc's exact post-handler cleanup for a + * non-restarted ppoll/pselect and for sigsuspend/pause. It retires only the + * caller's Rust-owned wait state, then checks signals made deliverable by + * final mask restoration. + * * The caller's own syscall always succeeds with 0. */ private handleThreadCancel( @@ -15771,8 +15776,28 @@ export class CentralizedKernelWorker { entry: KernelWorkerEntryContext, ): void { const targetTid = origArgs[0]; + const callerTid = this.guestTidForChannel(channel); const registration = this.processes.get(channel.pid); + // pthread_cancel(self) is resolved entirely in libc and never emits this + // syscall. Reserve the self-target form for libc's post-handler decision: + // a non-restarted ppoll/pselect or a completed sigsuspend/pause uses it to + // consume the exact Rust-owned saved mask only after preserving the + // replacement mask through handler return. This is an ordinary existing + // cleanup operation, not a second signal-mask owner or a new channel + // protocol field. + if (targetTid === callerTid) { + if (!this.#cancelLiveTaskKernelWait(channel, entry)) { + this.#failBlockingRetryProtocol( + "self wait cancellation could not restore exact task state", + ); + } + this.#dequeueSignalForDelivery(channel, entry); + if (this.#finishSignalTermination(channel, entry)) return; + this.completeChannelRawAndRelisten(channel, 0, 0, entry); + return; + } + // Always complete the caller's syscall first so pthread_cancel returns. this.completeChannelRawAndRelisten(channel, 0, 0, entry); @@ -17063,14 +17088,12 @@ export class CentralizedKernelWorker { return; } if (deliveredSignal > 0) { - if ( - syscallNr === SYS_PPOLL - && !this.#cancelHostOwnedKernelWait(channel, syscallNr, entry) - ) { - this.#failBlockingRetryProtocol( - "ppoll could not restore its temporary mask for EINTR", - ); - } + // Keep ppoll's saved pre-wait mask Rust-owned while libc runs the + // caught handler and decides whether SA_RESTART resubmits this logical + // wait. A final EINTR uses the exact self-target wait cancellation only + // after that decision, restoring the original mask exactly once. + // Cancelling here would expose the pre-wait mask to the handler and to + // a concurrently arriving signal. this.completeChannel( channel, syscallNr, diff --git a/host/test/kernel-blocking-retry-snapshot.test.ts b/host/test/kernel-blocking-retry-snapshot.test.ts index fa02296dee..8ba7de269b 100644 --- a/host/test/kernel-blocking-retry-snapshot.test.ts +++ b/host/test/kernel-blocking-retry-snapshot.test.ts @@ -540,10 +540,13 @@ describe("blocking retry snapshot contract", () => { expect(flagWrite).toBeGreaterThan(wakeAuthority); expect(pendingWrite).toBeGreaterThan(flagWrite); expect(CHANNEL_SYSCALL_SOURCE).toContain( - "return __do_syscall_impl(n, a1, a2, a3, a4, a5, a6, 0);", + "return __do_syscall_impl(n, a1, a2, a3, a4, a5, a6, 0, 0u);", ); expect(CHANNEL_SYSCALL_SOURCE).toContain( - "long r = __do_syscall_impl(n, a1, a2, a3, a4, a5, a6, 1);", + "long r = __do_syscall_impl(n, a1, a2, a3, a4, a5, a6, 1, 0u);", + ); + expect(CHANNEL_SYSCALL_SOURCE).toContain( + "CH_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY", ); expect(CHANNEL_SYSCALL_SOURCE).toContain( "request_flags |= CH_REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED", @@ -737,6 +740,7 @@ describe("blocking retry snapshot contract", () => { "mq_timedsend", "open", "openat", + "ppoll", "pread", "preadv", "preadv2", @@ -763,7 +767,6 @@ describe("blocking retry snapshot contract", () => { // progress cannot be reconstructed by the host. for (const syscall of [ "poll", - "ppoll", "select", "pselect6", "epoll_wait", diff --git a/host/test/process-wait-lifecycle.test.ts b/host/test/process-wait-lifecycle.test.ts index 92381722ca..a1877bf043 100644 --- a/host/test/process-wait-lifecycle.test.ts +++ b/host/test/process-wait-lifecycle.test.ts @@ -578,6 +578,41 @@ describe("Rust-owned process wait lifecycle", () => { ); }); + it.each([ + ["wasm32", 4], + ["wasm64", 8], + ] as const)( + "%s rejects Linux __WALL's numeric option before polling or queuing wait4", + (_name, pointerWidth) => { + const linuxWall = 0x40000000; + const waitChildPoll = vi.fn(() => 0); + const worker = createWorkerHarness( + { kernel_wait_child_poll: waitChildPoll }, + pointerWidth, + ); + const completeChannel = observeMarshalledCompletions(worker); + worker.waitingForChild = []; + const args = syscallArgs(-1, 0, linuxWall, 0); + const channel = registerMainChannel( + worker, + createChannel(7, createSharedMemory()), + ); + + dispatchLifecycleSyscall(worker, channel, ABI_SYSCALLS.Wait4, args); + + expect(waitChildPoll).not.toHaveBeenCalled(); + expect(worker.waitingForChild).toEqual([]); + expect(completeChannel).toHaveBeenCalledWith( + channel, + ABI_SYSCALLS.Wait4, + args, + undefined, + -1, + 22, + ); + }, + ); + it("waitid passes STOPPED+WNOWAIT and writes exact CLD, uid, status, and rusage", () => { const kernelMemory = createSharedMemory(); const processMemory = createSharedMemory(); diff --git a/host/test/select-signal-guest.test.ts b/host/test/select-signal-guest.test.ts index 7bc404d1b5..22f8fa700b 100644 --- a/host/test/select-signal-guest.test.ts +++ b/host/test/select-signal-guest.test.ts @@ -3,21 +3,161 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { runCentralizedProgram } from "./centralized-test-helper"; +import { ensureWasm64ExampleFixture } from "./wasm64-example-fixture"; const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); -const program = join(repoRoot, "examples/select_signal_test.wasm"); +const programs = [ + ["wasm32", join(repoRoot, "examples/select_signal_test.wasm")], + ["wasm64", join(repoRoot, "examples/select_signal_test.wasm64.wasm")], +] as const; +const pendingMarker = "TASK16_GATE="; +const restartMarker = "TASK16_RESTART_GATE="; +const timeoutMarker = "TASK16_TIMEOUT_GATE="; +const blockedMarker = "TASK16_BLOCK\n"; +const sigalrm = 14; +const sigterm = 15; -describe.skipIf(!existsSync(program))("select signal guest", () => { - it("interrupts select and pselect and restores the pselect mask", async () => { - const result = await runCentralizedProgram({ - programPath: program, - argv: ["select_signal_test"], - useDefaultRootfs: false, - timeout: 10_000, - }); +describe.skipIf(!existsSync(programs[0][1]))("select signal guest", () => { + it.each(programs)( + "runs the real ppoll/pselect interruption and signal-mask matrix for %s", + async (arch, program) => { + const programPath = + arch === "wasm64" + ? ensureWasm64ExampleFixture("select_signal_test.c") + : program; + let stdout = ""; + let stderr = ""; + let markerBuffer = ""; + let injectedSignals = 0; + let blockedSignals = 0; + let restartSignals = 0; + let timeoutGates = 0; + let injectionFailure: Error | undefined; + const stdoutDecoder = new TextDecoder(); + const stderrDecoder = new TextDecoder(); - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toContain("PASS select and pselect EINTR"); - expect(result.stderr).toBe(""); - }); + const result = await runCentralizedProgram({ + programPath, + argv: ["select_signal_test"], + useDefaultRootfs: false, + timeout: 20_000, + onKernelReady: (kernelWorker, pid) => { + kernelWorker.setOutputCallbacks({ + onStdout: (data) => { + const text = stdoutDecoder.decode(data, { stream: true }); + stdout += text; + markerBuffer += text; + for (;;) { + const pendingAt = markerBuffer.indexOf(pendingMarker); + const restartAt = markerBuffer.indexOf(restartMarker); + const timeoutAt = markerBuffer.indexOf(timeoutMarker); + const blockedAt = markerBuffer.indexOf(blockedMarker); + const candidates = [ + [pendingAt, "pending"] as const, + [restartAt, "restart"] as const, + [timeoutAt, "timeout"] as const, + [blockedAt, "blocked"] as const, + ] + .filter(([at]) => at >= 0) + .sort(([left], [right]) => left - right); + const next = candidates[0]; + if (!next) break; + const [nextAt, kind] = next; + if (kind === "blocked") { + markerBuffer = markerBuffer.slice( + nextAt + blockedMarker.length, + ); + setTimeout(() => { + try { + if (!kernelWorker.signalProcess(pid, sigalrm)) { + throw new Error("host rejected blocked-case SIGALRM"); + } + blockedSignals++; + } catch (error) { + injectionFailure = + error instanceof Error + ? error + : new Error(String(error)); + } + }, 50); + continue; + } + const gateMarker = kind === "restart" + ? restartMarker + : kind === "timeout" + ? timeoutMarker + : pendingMarker; + const lineEnd = markerBuffer.indexOf("\n", nextAt); + if (lineEnd === -1) break; + const rawAddress = markerBuffer.slice( + nextAt + gateMarker.length, + lineEnd, + ); + markerBuffer = markerBuffer.slice(lineEnd + 1); + const releaseGate = () => { + try { + const address = Number.parseInt(rawAddress, 10); + const memory = kernelWorker.getProcessMemory(pid); + if ( + !Number.isSafeInteger(address) || + address < 0 || + address % Int32Array.BYTES_PER_ELEMENT !== 0 || + memory === undefined || + address + Int32Array.BYTES_PER_ELEMENT > + memory.buffer.byteLength + ) { + throw new Error( + `invalid guest ${kind} gate ${rawAddress}`, + ); + } + const signal = kind === "restart" ? sigterm : sigalrm; + if ( + kind !== "timeout" && + !kernelWorker.signalProcess(pid, signal) + ) { + throw new Error( + `host rejected ${kind} signal ${signal}`, + ); + } + Atomics.store( + new Int32Array(memory.buffer), + address / Int32Array.BYTES_PER_ELEMENT, + 1, + ); + if (kind === "restart") restartSignals++; + else if (kind === "timeout") timeoutGates++; + else injectedSignals++; + } catch (error) { + injectionFailure = + error instanceof Error ? error : new Error(String(error)); + } + }; + if (kind === "restart" || kind === "timeout") { + setTimeout(releaseGate, 250); + } + else queueMicrotask(releaseGate); + } + }, + onStderr: (data) => { + stderr += stderrDecoder.decode(data, { stream: true }); + }, + }); + }, + }); + + stdout += stdoutDecoder.decode(); + stderr += stderrDecoder.decode(); + expect(result.exitCode, stderr).toBe(0); + expect(injectionFailure).toBeUndefined(); + expect(injectedSignals).toBe(8); + expect(blockedSignals).toBe(14); + expect(restartSignals).toBe(1); + expect(timeoutGates).toBe(2); + expect(stdout).toContain( + "PASS ppoll/pselect signal mask interruption matrix", + ); + expect(stderr).toBe(""); + }, + 30_000, + ); }); diff --git a/libc/glue/channel_syscall.c b/libc/glue/channel_syscall.c index 6694f9901e..04f33ab66f 100644 --- a/libc/glue/channel_syscall.c +++ b/libc/glue/channel_syscall.c @@ -20,10 +20,12 @@ #include #include #include +#include #include #include #include #include +#include #include "abi_constants.h" #ifdef __cplusplus @@ -86,6 +88,8 @@ int *__errno_location(void); WASM_POSIX_CHANNEL_REQUEST_FLAG_CANCELLATION_POINT #define CH_REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED \ WASM_POSIX_CHANNEL_REQUEST_FLAG_CANCELLATION_WAKE_ALLOWED +#define CH_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY \ + WASM_POSIX_CHANNEL_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY #define CH_SIG_SIGNUM WASM_POSIX_CHANNEL_SIG_SIGNUM_OFFSET #define CH_SIG_HANDLER WASM_POSIX_CHANNEL_SIG_HANDLER_OFFSET #define CH_SIG_FLAGS WASM_POSIX_CHANNEL_SIG_FLAGS_OFFSET @@ -130,11 +134,119 @@ _Static_assert(sizeof(uint64_t) == WASM_POSIX_CHANNEL_SIG_ALT_SIZE_BYTES, #define SYS_WAITID __NR_waitid #define SYS_SIGPROCMASK __NR_sigprocmask #define SYS_RT_SIGRETURN __NR_rt_sigreturn +#define SYS_GETTID __NR_gettid +#define SYS_CLOCK_GETTIME __NR_clock_gettime +#define SYS_THREAD_CANCEL KANDELO_SYS_THREAD_CANCEL #define KANDELO_FUTEX_WAIT 0 #define KANDELO_FUTEX_WAIT_BITSET 9 #define KANDELO_FUTEX_CMD_MASK 0x7f +static long __do_syscall(long n, long long a1, long long a2, long long a3, + long long a4, long long a5, long long a6); +static long __do_syscall_impl(long n, long long a1, long long a2, long long a3, + long long a4, long long a5, long long a6, + int cancellation_point, + uint32_t extra_request_flags); + +static _Thread_local uint32_t kandelo_caught_handler_depth; + +unsigned long __wasm_posix_caught_handler_depth(void) +{ + return kandelo_caught_handler_depth; +} + +void __wasm_posix_longjmp_cleanup(unsigned long target_depth) +{ + /* Generic setjmp/longjmp at equal depth is an ordinary nonlocal jump. + * Avoid even a diagnostic syscall so signal-aware longjmp can share this + * idempotent helper before its mask restore and the runtime throw. */ + if (kandelo_caught_handler_depth <= target_depth) + return; + + long tid = __do_syscall(SYS_GETTID, 0, 0, 0, 0, 0, 0); + + while (kandelo_caught_handler_depth > target_depth) { + /* Retire the handler frame first. The immediately following exact + * self cancellation is then distinguishable from normal return, + * whose rt_sigreturn is followed by libc's old-mask restoration. */ + kandelo_caught_handler_depth--; + (void)__do_syscall(SYS_RT_SIGRETURN, 0, 0, 0, 0, 0, 0); + if (tid > 0) + (void)__do_syscall(SYS_THREAD_CANCEL, tid, 0, 0, 0, 0, 0); + } +} + +static int kandelo_capture_ppoll_deadline( + long n, + long long timeout_arg, + struct timespec *deadline) +{ + struct timespec timeout; + struct timespec now; + uintptr_t timeout_ptr; + uintptr_t memory_bytes; + + if (n != __NR_ppoll || timeout_arg == 0) + return 0; + timeout_ptr = (uintptr_t)timeout_arg; + memory_bytes = (uintptr_t)__builtin_wasm_memory_size(0) * 65536u; + if (timeout_ptr > memory_bytes || + sizeof(timeout) > memory_bytes - timeout_ptr) + return 0; + __builtin_memcpy(&timeout, (const void *)timeout_ptr, sizeof(timeout)); + if (timeout.tv_sec < 0 || timeout.tv_nsec < 0 || + timeout.tv_nsec >= 1000000000L) + return 0; + /* Reading the clock is internal accounting for the enclosing ppoll, not + * a guest signal checkpoint. In particular, a signal already pending at + * ppoll entry must interrupt ppoll itself rather than this timestamp. */ + if (__do_syscall_impl( + SYS_CLOCK_GETTIME, + CLOCK_MONOTONIC, + (long long)(uintptr_t)&now, + 0, 0, 0, 0, + 0, + CH_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY + ) != 0) + return 0; + deadline->tv_sec = now.tv_sec + timeout.tv_sec; + deadline->tv_nsec = now.tv_nsec + timeout.tv_nsec; + if (deadline->tv_nsec >= 1000000000L) { + deadline->tv_sec++; + deadline->tv_nsec -= 1000000000L; + } + return 1; +} + +static void kandelo_ppoll_remaining( + const struct timespec *deadline, + struct timespec *remaining) +{ + struct timespec now; + + if (__do_syscall_impl( + SYS_CLOCK_GETTIME, + CLOCK_MONOTONIC, + (long long)(uintptr_t)&now, + 0, 0, 0, 0, + 0, + CH_REQUEST_FLAG_DEFER_SIGNAL_DELIVERY + ) != 0 || now.tv_sec > deadline->tv_sec || + (now.tv_sec == deadline->tv_sec && now.tv_nsec >= deadline->tv_nsec)) { + remaining->tv_sec = 0; + remaining->tv_nsec = 0; + return; + } + remaining->tv_sec = deadline->tv_sec - now.tv_sec; + if (deadline->tv_nsec < now.tv_nsec) { + remaining->tv_sec--; + remaining->tv_nsec = 1000000000L + deadline->tv_nsec - now.tv_nsec; + } else { + remaining->tv_nsec = deadline->tv_nsec - now.tv_nsec; + } +} + /* * Classify only operations whose zero-progress interruption may be submitted * again after the caught handler runs. @@ -142,9 +254,12 @@ _Static_assert(sizeof(uint64_t) == WASM_POSIX_CHANNEL_SIG_ALT_SIZE_BYTES, * WHY: CH_SIG_FLAGS carries the effective action flags for this interruption. * The host clears SA_RESTART in its owned signal record when an exact socket * OFD has SO_RCVTIMEO/SO_SNDTIMEO, so the socket cases below cannot reset a - * live deadline. Relative-time readiness calls, signal waits, sleeps, and - * SysV IPC are deliberately absent: Linux exposes EINTR for them even when - * the action was installed with SA_RESTART. + * live deadline. ppoll is included because POSIX requires an interruptible + * function to restart with SA_RESTART unless that function says otherwise; + * unlike pselect, ppoll has no implementation-defined EINTR exception. + * pselect, signal waits, sleeps, and SysV IPC are deliberately absent: + * Kandelo selects pselect's POSIX-permitted EINTR behavior, while the other + * operations have their own interruption rules. */ static int kandelo_should_restart_after_handler( long n, @@ -165,6 +280,7 @@ static int kandelo_should_restart_after_handler( case __NR_openat: case __NR_wait4: case __NR_waitid: + case __NR_ppoll: case __NR_read: case __NR_write: case __NR_pread: @@ -211,6 +327,34 @@ static int kandelo_should_restart_after_handler( } } +/* + * Cancel the Rust-owned state of an interrupted signal-mask-swapping wait. + * + * The kernel deliberately retains the pre-wait mask while libc runs a caught + * handler and decides whether SA_RESTART applies. A restarted ppoll simply + * resubmits with the replacement mask still current. A final ppoll/pselect + * EINTR, rt_sigsuspend, or pause instead uses the existing exact-task + * host-wait cancellation syscall after the handler has returned. The + * self-target form is reserved for this cleanup; pthread_cancel(self) never + * issues SYS_THREAD_CANCEL. No poll/select result buffers are touched and no + * second mask owner or channel field is introduced. + */ +static void kandelo_finish_interrupted_mask_wait( + long n, + long long a4, + long long a6) +{ + if ((n == __NR_ppoll && a4 != 0) || + (n == __NR_pselect6 && a6 != 0) || + n == __NR_rt_sigsuspend || + n == __NR_pause) { + long tid = __do_syscall(SYS_GETTID, 0, 0, 0, 0, 0, 0); + if (tid > 0) { + (void)__do_syscall(SYS_THREAD_CANCEL, tid, 0, 0, 0, 0, 0); + } + } +} + /* The kernel ABI deliberately keeps sigaction's transport record fixed at * 16 bytes: u32 table index, u32 flags, u64 mask. musl's internal * k_sigaction happens to match that prefix on wasm32, while its pointer and @@ -327,9 +471,6 @@ int32_t kernel_fork(int32_t mode); __attribute__((import_module("kernel"), import_name("kernel_exit"))) _Noreturn void kernel_exit(int32_t status); -static long __do_syscall(long n, long long a1, long long a2, long long a3, - long long a4, long long a5, long long a6); - /* * Complete one ordinary guest-owned channel request after a host import that * performed channel work in JavaScript. Those host-owned completions leave @@ -533,6 +674,8 @@ static uint32_t __deliver_pending_signal(uintptr_t base, int *delivered) __asm__ volatile("local.get %0\nglobal.set __stack_pointer" :: "r"(new_sp)); } + kandelo_caught_handler_depth++; + /* Invoke the signal handler via function pointer. * In Wasm, function pointers are table indices — casting the * handler_index to a function pointer and calling it uses @@ -584,6 +727,7 @@ static uint32_t __deliver_pending_signal(uintptr_t base, int *delivered) /* Notify kernel that signal handler has returned. * This clears SS_ONSTACK if we were on the alt stack. */ + kandelo_caught_handler_depth--; __do_syscall(SYS_RT_SIGRETURN, 0, 0, 0, 0, 0, 0); /* Restore the old blocked mask via sigprocmask syscall. @@ -601,8 +745,16 @@ static uint32_t __deliver_pending_signal(uintptr_t base, int *delivered) static long __do_syscall_impl(long n, long long a1, long long a2, long long a3, long long a4, long long a5, long long a6, - int cancellation_point) + int cancellation_point, + uint32_t extra_request_flags) { + struct timespec kandelo_ppoll_deadline; + struct timespec kandelo_ppoll_remaining_timeout; + int kandelo_ppoll_has_deadline = kandelo_capture_ppoll_deadline( + n, + a3, + &kandelo_ppoll_deadline + ); /* Fork/vfork are handled by fork()/_Fork()/vfork() overrides above, * which call kernel_fork(mode) directly. If we somehow get here (e.g. a * program calls __syscall(SYS_fork) directly), return ENOSYS because @@ -699,7 +851,7 @@ static long __do_syscall_impl(long n, long long a1, long long a2, long long a3, * waitpid and wait4). Publish the call-site identity before the * release-ordered PENDING store. The host consumes and clears it with this * request, so mailbox reuse cannot inherit cancellation authority. */ - uint32_t request_flags = 0u; + uint32_t request_flags = extra_request_flags; if (cancellation_point) { request_flags |= CH_REQUEST_FLAG_CANCELLATION_POINT; /* @@ -806,12 +958,25 @@ static long __do_syscall_impl(long n, long long a1, long long a2, long long a3, * cancellation exits through pthread_exit. */ if (cancellation_point) { long checked = __syscall_cp_check(-(long)EINTR); - if (checked != -(long)EINTR) + if (checked != -(long)EINTR) { + kandelo_finish_interrupted_mask_wait(n, a4, a6); return checked; + } + } + if (kandelo_ppoll_has_deadline) { + kandelo_ppoll_remaining( + &kandelo_ppoll_deadline, + &kandelo_ppoll_remaining_timeout + ); + a3 = (long long)(uintptr_t)&kandelo_ppoll_remaining_timeout; } goto restart_wait_syscall; } + if (err == EINTR && delivered_signal) { + kandelo_finish_interrupted_mask_wait(n, a4, a6); + } + /* Return in musl's expected format: negative errno on error. * musl's __syscall_ret() converts this to set errno and return -1. */ if (err) { @@ -823,7 +988,7 @@ static long __do_syscall_impl(long n, long long a1, long long a2, long long a3, static long __do_syscall(long n, long long a1, long long a2, long long a3, long long a4, long long a5, long long a6) { - return __do_syscall_impl(n, a1, a2, a3, a4, a5, a6, 0); + return __do_syscall_impl(n, a1, a2, a3, a4, a5, a6, 0, 0u); } /* ================================================================== */ @@ -900,7 +1065,7 @@ long __syscall_cp(long n, long long a1, long long a2, long long a3, { long pending = __syscall_cp_cancel_preflight(); if (pending) return pending; - long r = __do_syscall_impl(n, a1, a2, a3, a4, a5, a6, 1); + long r = __do_syscall_impl(n, a1, a2, a3, a4, a5, a6, 1, 0u); return __syscall_cp_check(r); } diff --git a/libc/musl-overlay/src/setjmp/wasm32/rt.c b/libc/musl-overlay/src/setjmp/wasm32/rt.c index bab5ed979c..cb5739ce25 100644 --- a/libc/musl-overlay/src/setjmp/wasm32/rt.c +++ b/libc/musl-overlay/src/setjmp/wasm32/rt.c @@ -12,6 +12,7 @@ #include #include +#include /* * function prototypes @@ -19,6 +20,8 @@ void __wasm_setjmp(void *env, uint32_t label, void *func_invocation_id); uint32_t __wasm_setjmp_test(void *env, void *func_invocation_id); void __wasm_longjmp(void *env, int val); +unsigned long __wasm_posix_caught_handler_depth(void); +void __wasm_posix_longjmp_cleanup(unsigned long target_depth); /* * jmp_buf should have large enough size and alignment to contain @@ -38,8 +41,15 @@ struct jmp_buf_impl { void *env; int val; } arg; + + /* Target caught-handler depth for generic and signal-aware longjmp. + * This stays inside the existing architecture jmp_buf storage. */ + unsigned long handler_depth; }; +_Static_assert(sizeof(struct jmp_buf_impl) <= sizeof(__jmp_buf), + "Wasm setjmp runtime exceeds architecture jmp_buf storage"); + void __wasm_setjmp(void *env, uint32_t label, void *func_invocation_id) { @@ -52,6 +62,7 @@ __wasm_setjmp(void *env, uint32_t label, void *func_invocation_id) } jb->func_invocation_id = func_invocation_id; jb->label = label; + jb->handler_depth = __wasm_posix_caught_handler_depth(); } uint32_t @@ -75,6 +86,7 @@ __wasm_longjmp(void *env, int val) { struct jmp_buf_impl *jb = env; struct arg *arg = &jb->arg; + __wasm_posix_longjmp_cleanup(jb->handler_depth); /* * C standard says: * The longjmp function cannot cause the setjmp macro to return diff --git a/libc/musl-overlay/src/signal/wasm32posix/sigsetjmp.c b/libc/musl-overlay/src/signal/wasm32posix/sigsetjmp.c index 216956bd31..36c4c65dfc 100644 --- a/libc/musl-overlay/src/signal/wasm32posix/sigsetjmp.c +++ b/libc/musl-overlay/src/signal/wasm32posix/sigsetjmp.c @@ -15,6 +15,12 @@ #include #include +extern unsigned long __wasm_posix_caught_handler_depth(void); +extern void __wasm_posix_longjmp_cleanup(unsigned long); + +#define KANDELO_SIGJMP_SAVEMASK 1UL +#define KANDELO_SIGJMP_DEPTH_SHIFT 1 + /* These reference the __fl and __ss fields of struct __jmp_buf_tag * defined in musl's include/setjmp.h. sigjmp_buf is typedef'd as * jmp_buf which is struct __jmp_buf_tag[1], so buf->__fl etc works. */ @@ -30,11 +36,11 @@ void __sigsetjmp_save(void *buf_raw, int savemask) unsigned long __ss[128/sizeof(unsigned long)]; } *buf = buf_raw; + buf->__fl = __wasm_posix_caught_handler_depth() + << KANDELO_SIGJMP_DEPTH_SHIFT; if (savemask) { sigprocmask(SIG_BLOCK, 0, (sigset_t *)buf->__ss); - buf->__fl = 1; - } else { - buf->__fl = 0; + buf->__fl |= KANDELO_SIGJMP_SAVEMASK; } } @@ -46,8 +52,10 @@ void __siglongjmp_restore(void *buf_raw) unsigned long __ss[128/sizeof(unsigned long)]; } *buf = buf_raw; - if (buf->__fl) { + __wasm_posix_longjmp_cleanup( + buf->__fl >> KANDELO_SIGJMP_DEPTH_SHIFT + ); + if (buf->__fl & KANDELO_SIGJMP_SAVEMASK) { sigprocmask(SIG_SETMASK, (const sigset_t *)buf->__ss, 0); } } - diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index 93c44a1fc7..50466cbf83 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -4,344 +4,344 @@ "bash": { "manifestSha256": "5dc4d97dc4f154f265ff57e9d972e7d5a64a2e15fadedfa246733b830dda3497", "cacheKeys": { - "wasm32": "2d577fa298afea12b9245e372d0e8712bb166f5d759f612f86c7b658367268ff", - "wasm64": "16506663240de8625fe8e5fc6c6f19cdb9ff1066405eca675b9b9390677912d0" + "wasm32": "9cac1217bd7951530fee52b9a19feb039be16c997c87b76d1b949cafa6154bbe", + "wasm64": "2d11dc589ff1ba210bc9de9b77a48f8a5151d604bb6e257800db4b99b30bc517" } }, "bc": { "manifestSha256": "43b61e92c29098720bc7f4871a3b611cc01ffd124024290d42967cebb6ffd459", "cacheKeys": { - "wasm32": "f95f8a79fd939755345af1acccdec0b21f82def30156d8bc1bfeca2ee9f4b57b", - "wasm64": "9c091c12127829ea1ff4606dd3d97b9cf30c015a4eb9e5c2bdc45a5acab8dc92" + "wasm32": "6833fd5275f1378837506fbbcd3db757a19d4ac6ea9081212bdc4150a64408f0", + "wasm64": "ac098d774e4553bed2574ab3d03013cd70eb414cb5e9c57663edb3ada5aa5e9a" } }, "bzip2": { "manifestSha256": "59e1e53f5675e7c9148634933ed0f4c7192743727025cae4e843b6924c489abf", "cacheKeys": { - "wasm32": "cdb8b3ef6ef84d9b962df3cad7f0c09ddd146e8a1e1fa873b31ba09265fdfcd3", - "wasm64": "20dff598bd14330ccd05207d108b099c0e79c6e00f68a8e298f1b2ba93c0b0c2" + "wasm32": "480102a8e3940e1eb894a1b9767efa89a359bbe93a61ae23dec2c12319a33447", + "wasm64": "d8835ce8a4075c87a98b7b058769a950955388f9fdc58de90c3ea6114c206ced" } }, "coreutils": { "manifestSha256": "72fc9c3818fb11cebaa047a623743252395dc729163fa804a7272d59f87fe82d", "cacheKeys": { - "wasm32": "9d4a7186e54db60edf09a2aba993d4f1d4660dcd37f5f280581941d09e4fb054", - "wasm64": "d9232813053ca651f5660a2360e15f019ee2b428e8982ced50194ff3bb263191" + "wasm32": "ffbcd2c6b9df24acb01de4da66c4167c07556aac83a2891ae182a934bb9ceeea", + "wasm64": "8079ebf92c30022c0ee2577221b641128fb0265c2530f45bc52612ff717144a0" } }, "cpython": { "manifestSha256": "7dd4f446697a73941ec940c2ccba4d53be73fe6947f1e701031a4d0aa964c4ff", "cacheKeys": { - "wasm32": "2b100dd2bfbb68d67c935fa9de93e1e82721f50d2040a7a1d22cfa2656053884", - "wasm64": "7e19d49523cfbe6dbe5831477aa2f3c96ae7e1ddf2333923732edffa9fb79d8d" + "wasm32": "8e36bc988b0f8fa0130bf7696b73a81b018e8704acc66005431b25ace7fbe048", + "wasm64": "2b51518ff1d5dcf41a0791ec00db026988139b8dd2cce56f85b6a7d997e1d41d" } }, "curl": { "manifestSha256": "55523d50261f46dc4aaaff458d1cd87c6f96eaecd687a7540ead35c96906366e", "cacheKeys": { - "wasm32": "18890ef7352b7881c2c0880b032e15166dbbe18b24bd3082bda7728755451528", - "wasm64": "8766258487a29ad15c77b282aba60639cd1f8335fb4b726d2c5bef69b3286d20" + "wasm32": "9c4a4a28c7644128e9d95ff53840d4a08f8727394dcd1248e18c6bf7235521f5", + "wasm64": "f53250e3420e80fec621db53195eb3aa16412fbb760ebf65307f92f949bddd12" } }, "dash": { "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", "cacheKeys": { - "wasm32": "ed8a5db2cd63484a368e0181b72f177d072a64bf4b053f62fae128bb5e8915d3", - "wasm64": "f9ce165a14ec14b4bdbc81a5fd1238f415d365beb1efa51a55e435b99d807a60" + "wasm32": "ff2bca584a197d92da417221bcd6a92171140b030ade50108eaa244905bf88f0", + "wasm64": "2d1fef92952071c38c2f7758c5a2313dc664966a4bd081820ed07e885aec2383" } }, "diffutils": { "manifestSha256": "2286203eb762eca487939963e38ea1b901ac2dc9a830d3260c2fb291757df208", "cacheKeys": { - "wasm32": "247e25a24952124aa930b6b0bb5092fa2c74653e52bb2e928a049ebf109ff9fb", - "wasm64": "7c0453b0ffb280a8ec28e301fe88fdba554905638460ff7baae8f758b93cdc7f" + "wasm32": "688e551eb9e8c698148a45d1c59efb2bf13120d54dc9516289395d2b73a7ff9e", + "wasm64": "b3a3f5699d1840ca4e51b353b32ec01b9742c685866d1a30d38c87ad3fca49c2" } }, "dinit": { "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", "cacheKeys": { - "wasm32": "0cfd3dca3b9499921d565e03a274c5ebbaff265e6c05e98aa8ef889d2140ccd2", - "wasm64": "e64d25c0a8a6aa5a8032094c312432783dfaaf60846c27cb0077637138429320" + "wasm32": "f7abd4cc88bcb4f9bf2e01872fa181c8ac84db6621da3e2c1a291f32c65f27ed", + "wasm64": "1fefd32aa29b1b65a011d4466550ba7f13da0029b7dc58fae072d5a0a756d842" } }, "erlang": { "manifestSha256": "475d6037e73a0f1e2f4381067194b2422751206979ea17209e10efa5a2a93bbb", "cacheKeys": { - "wasm32": "ee489dc49dce5553b05b5eed8d5e56943dece8dcd8bed0fa01e39dc95c63faaa", - "wasm64": "53ce5fc8184f5c155924a2317cb4c32acf78166aca3ac50ff5415e89ae57832f" + "wasm32": "e6f453949d018258bdefcf7dfa503e8b25ad6422635c57540e92b9c4a26223a0", + "wasm64": "a463f4164fe0ad7353723f4761efe1394043c47419c7af0360e349dee45910e6" } }, "erlang-vfs": { "manifestSha256": "15efb74f1825e2b85bedfeb8d98c19fa648c4be39cb9defb65330a8f21eb9141", "cacheKeys": { - "wasm32": "7b88273b7d5ca43aebb2d65c9fbe03ab56639ce1166bf9261ffff67e119d9235", - "wasm64": "bb10fec9d503b9508ab16adfd7eeabe9dd6b25acd19e86e31bac83a11e0657ec" + "wasm32": "2be200a6d32d1fd561bbce87700eb46d90b7e91684f69c359f3e45b1b092f967", + "wasm64": "6a8a5917a612a6eda12ee33e481a3fef7b46ca78a25c5dfd2088407a6dc2f6a1" } }, "fbdoom": { "manifestSha256": "7ff2127ca940e41be90ba45204c89089a7a2093567b9bff581a9549b57138626", "cacheKeys": { - "wasm32": "a19efa9f3f880a2f7582794fd6c080d18f0f52cebfe7380713096fdb5e0268ef", - "wasm64": "35679ebce821ff21e73b7460dcd40df1958f55c6475db1423443caf1261e9db9" + "wasm32": "f50b05c1d725e28d65baf028956336614efd4e495c0c7646e3a85855b0cb9e21", + "wasm64": "d0dc2b707f1d6a8eb44730c3aa1821eccf83a5d62fc254a4c7112e4c937d0e1e" } }, "file": { "manifestSha256": "7874c1affcbbf2c8ab8c8d4beb087f275af57b5caae6a8947bf5a63a181552b2", "cacheKeys": { - "wasm32": "4677df78cfe25250903279ae481372f6985aa54a9f85682499d0ed2a94408eaa", - "wasm64": "1c5eaf4737bbc34ebbcfb90814b6dad13e84ba990fb400fd35b3da6d92d89e62" + "wasm32": "306f637580638cf8c248f3cf9b15469d526030020ff04820ce60bd188b54ef80", + "wasm64": "48398308efb09c0a85f659ce470444501db954576a689dfe372b1984018fb3d6" } }, "findutils": { "manifestSha256": "a9969cb102403cef105e758ef602692500fddd75ec96e4e3f168c50094b6c931", "cacheKeys": { - "wasm32": "adcd350e6f8977e86330c59b57f52b11524143c02dcd4a18a2447e5f635c88e7", - "wasm64": "c8c74d264f4e6379000ac03b7454aa50cad602f1dd1668c90cfa188934504440" + "wasm32": "96312ac7e9087405539527a9c1e119db733e0f46395b9ecadb30c77f3eb83057", + "wasm64": "32a8417badbb263156214d7fe1c9c26dc5a21388e927bba68a1f6008d39dfde4" } }, "gawk": { "manifestSha256": "b788f3de724cd363ea68d08826586ddf544a75fb5dd9df1369ffafe06d8681b7", "cacheKeys": { - "wasm32": "915c98a7739cbeefb8b4ed326e62effaf0c376c0d187141254527c08eae134e3", - "wasm64": "db09ead6f160c47708688b8fedc13dc1f0219b727039c7a7c47afcaecb57d5a2" + "wasm32": "ea31e40a17bccfd311032d3e052f3a6707745b000f8303be9e72072628a1572a", + "wasm64": "0a497268d13926f29073ab68072eda07a5fd8bddbd1e09a648a859408efc65b9" } }, "git": { "manifestSha256": "c2bc79e62c9a4e840ae94a16af0f41070460eaf3976a990dc60d2db977bee952", "cacheKeys": { - "wasm32": "44e9c599cc26e9a485303555de6911f9ef46c7e4d8eeeb049d72e9bf9bb22c79", - "wasm64": "945f3b50f7caa917fcb3bba560e8b2b69d4e0a3eddb904f026e1b374e30227c0" + "wasm32": "a545be3bb60a666a7e6c7e130b91c37178b7c0a5bdf875d6b249deae3a7f1444", + "wasm64": "250424ecdf76d4fc070acae4e7a159e28c547796bb7b28bebc5e547b4c30a747" } }, "grep": { "manifestSha256": "7c61b894807b831c7e8816cb1f39c78e3a69a4ced2fa3d18f0abdf00bf18334b", "cacheKeys": { - "wasm32": "a0c03526e72d0581fde4e5ef55a4e5f38a8047e2d93501aa56db6aefff6a33ba", - "wasm64": "dfb070bc86df833270c34b7d6f59d0b9cace5fd6ab17da00c3cd4e5b1902c57b" + "wasm32": "68e8a6c294322d4f8bfc42c146bb4be5c47ff72b3fcf76e7423061a0117a29a2", + "wasm64": "17b76c2a271b3fab7de9ebebe154d3a0c5087b50a52e38f6b2ad938e31c20be4" } }, "gzip": { "manifestSha256": "1485add843bbcd744244e707c353208fe11192b7b248feed1550875d3b75a917", "cacheKeys": { - "wasm32": "0760ed1ee2e04a89dc5e47ad9134d50d981e08035c624856c0e0f0248727c4f9", - "wasm64": "094ce7c65c4a45239511af4cae666925af287ea2b4ae68973009fec49aa84eac" + "wasm32": "1a49313a1bd83c8b35f8c9b2d7874da4c572b48275dfce6d37a647935879a941", + "wasm64": "19a892b6637c60c3ca51a20f0c3070ce38d361613a2e29bbaa6c18c0db9ecd45" } }, "homebrew-bootstrap": { "manifestSha256": "183004a8385ab5afc388cd43401341b82812c67f97b08ad349ebd7f4dbbebd0f", "cacheKeys": { - "wasm32": "9a4810e7dc1daf506b1b74873cfa601dfe5aeb0d868b39c8279ebcadc04b0ccb", - "wasm64": "00c0cf8a0f0cf33922d1bef636c100b029361198d3a89a6a72e1d07f604ddbdf" + "wasm32": "6adc7842ddf5af1df0d21e89a6bd1fae1e9572dc61f1a3f48c9908c71b3c5d2b", + "wasm64": "b7ab1aeac0354de518a5623930a779af74b9ab87b6a766cbe5c1e346a15c967d" } }, "icu": { "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", "cacheKeys": { - "wasm32": "3fd9598c6138054150f5d457a0b0f62d44ab0920c2a5ea1419bbbd16f2cfd4ec", - "wasm64": "3ee6c490a5c87d833b954527a7c9e3615d6d0ca2a153daa3bdd6e1cd7c962a48" + "wasm32": "bf454728f58a75797a9d52c8c6d92c6a52c67c377be31c0253fb2a18883556d6", + "wasm64": "5622a11e71c220ac8db2817001a6488161d4e91aa9df2d5d06da4769441f3276" } }, "kandelo-sdk": { "manifestSha256": "c081879f1becd855917cff96f17429bcf2b9fd61bf95211eca9ff8fb625bb2eb", "cacheKeys": { - "wasm32": "b2801ad140482e877a4893251d043e033801d25ef4135c92a73b2e50eb9aca92", - "wasm64": "4dfd6a39cb9aaaa006cb833a9855dfb08f696e2cf77cc26a4e7712c9ed12c919" + "wasm32": "4838701321647a5e4869af903664a7b5cb5cb436a1a220b132423881bd77c8a7", + "wasm64": "1d280340377fb10b58e0741bf749203ab10f34a972fa37eca67a3040e0b964f2" } }, "kernel": { "manifestSha256": "db1d66db8575562ac7b3e720dbaa06d7dd5eef577d80220ea4167dc2d69b863b", "cacheKeys": { - "wasm32": "2f8f321f40b81120644d66fd1c5ed1e949be708781c568e14b62d7171e9f9f60", - "wasm64": "7cf3cdd692159922c87e99144ea77d1ba027ed8450410078311bd65f27ff4213" + "wasm32": "e59c55a65d4fc36dc5a5fb9a37db0f87290318a4137ecdbc833f7e7a4a8e8325", + "wasm64": "d0c0ee083fc0e3a0726ea6b0733109a5224f03be2425f85c6891ffa07e5c12c3" } }, "lamp": { "manifestSha256": "250b64635d64f8537178188fb5488dbfd2980d79a1c2ffbf346af67008088ba0", "cacheKeys": { - "wasm32": "29838c85d239e83665cd9201eae6b4ebeef875e798ddf01a1f07804d5d519aa7", - "wasm64": "0d9f52c79901f89f12daa3e0a2dee3233348a4635f58dde3e2149bb61b5d5309" + "wasm32": "7cdbe3ffe057d215c2a821636eb419dcced02d242b6c1249ce151d841ed44bde", + "wasm64": "a2a1c3b59dfb32ff9165021de48a3dd8120987fec57b04083f8afad891679d1a" } }, "less": { "manifestSha256": "996d61545cfe83dcb663a33e8763e0edbd4db4a605fd69a91b243cf79b1f9b17", "cacheKeys": { - "wasm32": "26a936bd1d8d5817eae86a8e5680890c0c741be7fa88f0b92035552c28f18b0e", - "wasm64": "a721791e2e345a080def3fe746dc4abc0c92dd554ed34655fcbd2f0dcd70c2d5" + "wasm32": "10bfdfc7cb4ee67172ac4c91545c2b0e00282be1f740b55aff5f4f3bd75274bf", + "wasm64": "8ab01b9364b82b43ab11dbd1f7048ee7ac439453ddb34085d4b3fef057660c03" } }, "libcurl": { "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", "cacheKeys": { - "wasm32": "65727e0e1a26b1faaea4e0f0fbddce5e5a44012df69dcda1bb7e3e8626487268", - "wasm64": "19faa6e2474c3b9f4c974966a531686623513acd1455f2a015310edb14dbe199" + "wasm32": "2a54294aa012c4a47fb5b540f49dacacd83725735c4db8ba3746fc4177146a6a", + "wasm64": "496f8087f49824cee36bb6942e0b977f3c48757158863b35bcbec0c0ac8b27ab" } }, "libcxx": { "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", "cacheKeys": { - "wasm32": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd", - "wasm64": "b65406cc252ff13d6049ae52461196e07423734583b162422829821838609693" + "wasm32": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a", + "wasm64": "f828b2c8c1b35ffcc95ed9b419b93b89133eb03ebe426e87e946889d5abea144" } }, "libiconv": { "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", "cacheKeys": { - "wasm32": "6aa4acaad6784b9152ec957d4e2e8da31db5c0b610414110f1ddfc5623772f71", - "wasm64": "b4e459de04bec0d2952f9371c5a329fb45f6508aa8df57452df1180ab47932e9" + "wasm32": "9824449919005d0862b1f40b310195b2bf5657733469a3b1aac4b94396ffc669", + "wasm64": "335e0b010c890dfe94d19457bcc1f2c3d10b7102160489f832bcba28f18a421c" } }, "libpng": { "manifestSha256": "79ed5c5c072a0267cce5c7a2fe37d8494571b17e44b952f619e04ec1c4a9db10", "cacheKeys": { - "wasm32": "2ab54d9a91761c737ce35bc700d809edbb1a575f5365372a1cc3fb3e10977742", - "wasm64": "e4aaf285ad36569d1deb1be3c54df7eb0701e2479d25a95a022211815a7c704f" + "wasm32": "9b8ebcc1b638d6821ead77d1a2beffdb8266c94cb1f9346b2131f1dff134d0d4", + "wasm64": "b641d2c250a10141e816fd3ac49ad583746215c7019605e892ecd8c9d58edc82" } }, "libxml2": { "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", "cacheKeys": { - "wasm32": "c11983c6357c1b77a011aaeafde22882bdaa777c65c40dd99d4c48777acf23c0", - "wasm64": "46c8e8a6bd3b65051924aa6f0ee61d89c18b42a213759c1aea39749deb01e057" + "wasm32": "966012e82a47f8afa17683175087d63b7fe7e191c770f14a62d82366e13ba959", + "wasm64": "5b900bc3f62fac456f2edf96c89bf804eb4e4c3394805ba4916f048a513a70fa" } }, "libzip": { "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", "cacheKeys": { - "wasm32": "2b8c3acda63a9e373a63ac32b693eb0a3b7d1f01d19dc114a71ec88c5c0f2168", - "wasm64": "b64b8c3a3294a08f9f92292e1d0ec475c4eaf5cc148a6089093dcd1eb96721ff" + "wasm32": "5cb2f96b95a2fcd0618db09f8393d8e2bd6a40617cb2b35f57667a482c671739", + "wasm64": "496ebabe3004af18f16981eae7600ce60212ebf973adc679234197068670bf50" } }, "lsof": { "manifestSha256": "cfd199bbe082435f7a2e703e2738a368af1eeb5b76b6e93c376054f21ce94419", "cacheKeys": { - "wasm32": "909d4418866795cd511ebaa25cd751c7e1075657e24c334c859397cb6366e7d5", - "wasm64": "b1b3448fcc285bb589d11d09d24685d1cc55edd3e907c1bf6805023bec5a3925" + "wasm32": "7b9f79de5b4f23e236e29a1f4a809e586d225478e49ae27a891a4f021ade9cdc", + "wasm64": "5c30296d4c0260a4ad58be7c7b3009ffab3f2374b3f9bdf8f8bf45e5c0b50595" } }, "m4": { "manifestSha256": "9b5838bdde8531079f23a89e135aa3832bbbbb7ad6099dd1503db877d52afcad", "cacheKeys": { - "wasm32": "b887bbc00ed62a5228e955593dcc4a31d98e8fe71972d59971e32bd76b7ad536", - "wasm64": "aaa2937b0bedd1be62339182746a482c18c57f28e1a39b071d089fde98a6d6e7" + "wasm32": "9413a8cb9d531fab82b389c7172bf1d59e3feefb5adcda8504d26934846f095e", + "wasm64": "ad05f0c65c4f2c0485a7f0ac37a80496f1c3b329e426472e326685a6d862f681" } }, "make": { "manifestSha256": "62e8b36a6df7e981d191061a5bb7f2db85b2deaf365352a7d590a6c6c06f2b1e", "cacheKeys": { - "wasm32": "de30b90416e0a0d62ada5726860e8e7cefe891eb5ab51d8b26f18f50e0e06fe5", - "wasm64": "12bfc99c5799e98b42dfb9d54cf4d60132804c73ed3597f9e61606a9812115b8" + "wasm32": "a95e8bfb3c1d51b81b6de917d410ab6677c2a2c25c1d5301654c796e331e47d9", + "wasm64": "ee0603d70307257917ff9537ec5f469eb4f78a2e621deffe0c309b7772fc1641" } }, "mariadb": { "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", "cacheKeys": { - "wasm32": "ab24370d6f159ba9c14e964ff902c1182a303d29dd51639659c6938dfdfc45e5", - "wasm64": "43cfb64022b7de7df2e7b95760a9f4cc8808ce59449d388eda35d6f79a6869b9" + "wasm32": "6d9b1c0dd88cc10290b63df2ab1720940ae58285e2745c91e6695b7b85a703a0", + "wasm64": "3f181cf9f51770f7deda50a72a1d8e8b4c38d515aec4485ec3934e429ba42344" } }, "mariadb-test": { "manifestSha256": "d4ccce161d659a5a87c80fa1117054bbccea870e0d4a46bd8a070d3930ad0e3f", "cacheKeys": { - "wasm32": "eaba21ea0e184a6cf00a87c99273c88d6b0ff8aee4fabcf4459770aa3483f0cc", - "wasm64": "19ab61784c3a99cdfaff373982e0f9ebeef25ed2e605e7e677bbdb7b6878cb7f" + "wasm32": "a0a852b02a9aa96daae3976cfa7b1cd6812669fe92ab7734d6795aa00273131c", + "wasm64": "dfef22d594c75fd36c8cd9b5aef8ab2d611d98abb2b8846ebba445de22aaacce" } }, "mariadb-vfs": { "manifestSha256": "26e74ac84d89b2a839ed72783437061a23022ef2f88ad0f52b6aacd0442ee1cf", "cacheKeys": { - "wasm32": "c9ac6a6efa075623c727c7b33b34439a31caf814d626d11bc6bfa22fe4d25b0c", - "wasm64": "b01238af64fcee0d288a1413f069cc86be29621cf8c602fffc0e563176cab991" + "wasm32": "3dd7c2e9e51c9adea3d3b52b1a8026281a0db6851900c2b9eb1a5cebd8d46db0", + "wasm64": "a430226e39f0ca6d815100e6ce7bc868f144f081f9cf432f2486a74c6c13a77c" } }, "modeset": { "manifestSha256": "36a8683c1c7309701d361c5f77e543a6c1531397b26c72dbb864528c59c42954", "cacheKeys": { - "wasm32": "cb77410ec2dad89f43c7ceef9c33edf3c1ec39a5430b9327a70136349794350f", - "wasm64": "b9db674257b0930100fe21e29a984cf3f68b4026435c14b0b779ce2faddfd3b2" + "wasm32": "2478e94c90c8c0ba8a043dd9b527493df59d678a4323f6f879d6ff819921802d", + "wasm64": "e84c6f8581cc84e5f291bf36257a33413a71216364e3514bedc6b6289ac91a2f" } }, "msmtpd": { "manifestSha256": "686ff665b5e7907e67618790b1a3eddce2ff47ed665595d17209bdcda1e0b274", "cacheKeys": { - "wasm32": "e52466139aaf8c185e8f8983b548dac2413ea241af093f7c854ee99325999176", - "wasm64": "c6484b456b6de24b58c6645199e50eb08e26a08640fe514dbcf28397dc22facc" + "wasm32": "ca4d269e24beb373937cfffa3a5a70be1c26f67b4afa19128d697ad9cdced81f", + "wasm64": "1a8071d7ebf7239aabfe9561c4875767de51dccfda4e1dd74ea6f6c1a4e950fd" } }, "nano": { "manifestSha256": "c5a52f0c37e08b475bb815367b1f0d63d0d2b06649d99fe9f461b72d0b9cff51", "cacheKeys": { - "wasm32": "43267a08d7d10ce343f1ac2354ad9e3cf2193931b1f3dec522353ac9b0e93323", - "wasm64": "95930c0b62cece94c566a2d94b1a3df552b2f84f25426c8462938ac824181142" + "wasm32": "1b0237e997d82bafe853ec28192c4b5a310331ce814f6cdfd4301c14290f54bb", + "wasm64": "72ddb80181d6b5514fa9f6d077060c5f3bd44ab22789bf4668f10ff59a412077" } }, "ncurses": { "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", "cacheKeys": { - "wasm32": "0fa705045e9b3026abaf8e26a8e3c38d5486a4ad9a967752458768a442e32e0f", - "wasm64": "2d459f7bbf22e823569ef41559196fceab6a3a8d40e13948ff2136ae70d65a74" + "wasm32": "9c773c442f97096978216d373d9c7c5d19c8a4a7b26aae0c4bc02b1c76cca382", + "wasm64": "16e18d2c2c4956f2d4883bd02e8ce1ce3f40b1963b091c7ccd592e0d900ecae2" } }, "netcat": { "manifestSha256": "4cf33cd1ab768b3ad0108da8b68c2ff72e98469cd86a0bf2d0da54a7d4f6fa16", "cacheKeys": { - "wasm32": "b98fe24204f370fb495b84b0241db465ad666e470b2caa081a8b7b453b2c971a", - "wasm64": "0b1c6b5766a50e3b5036997685ff66bde3bc815a6dcd89e23bd53a2997aeb4cb" + "wasm32": "bda506d821b1485688ab5f601bd28b53cfea8675f31fe5a617f62a89ef34808d", + "wasm64": "303a1f132c637d92eeae9ae49fd69e33e5a69a10eb30b1304fa6a5ff4e187a6a" } }, "nethack": { "manifestSha256": "1a2f12ec2770bd8d40006d6c878a3493b97c71c2bb63ad08c7f6ad99bfd53504", "cacheKeys": { - "wasm32": "df15777c107fbc3302c8ef14d38b7cea8081eb74b0928e38530deef188b0170d", - "wasm64": "87124c7e8d711b596f58555bf7b548019d6d6f33b4957fbb3e26ecb922df4461" + "wasm32": "1411c691c5832de251b4e337c4915797c271f07737ed6f82560788800ce7e92f", + "wasm64": "578b00658e8567be94a15f7a072e4089abb53eadb79e1e09a1633cece41107e3" } }, "nethack-browser-bundle": { "manifestSha256": "b8294981da7ce4299dfb271d4aecb90ababa97a4d1acc9b716ae05bbe52beb53", "cacheKeys": { - "wasm32": "eb8580b1a03a8f9575477c2618419e3ec1df34880c26eba312ec7d7f066931d1", - "wasm64": "8db819579bbbd5369f05360934d8afd1bc1359c8fbfd9a83c12586c19138cbe7" + "wasm32": "daafe55441efecae67a98181c0ffa0acf838ad23d2b0acf37160a5a5b80a7b43", + "wasm64": "ebeb03ca00fb9f6f84c7abf27df0f07a3cf65fe1f7e0d3be55212041529622a3" } }, "nginx": { "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", "cacheKeys": { - "wasm32": "9e932bd478a344cdfdb68da0f300f147b2d9c60bb385cf1ffe7906e315d357a8", - "wasm64": "497a4ba4f07e8d7812f9a18058b1c11840031ddbef337d2a0527acae665a4e39" + "wasm32": "2b5174ccf228be9d359389d8c2435f0cd34feebc66e57966558902f72682996d", + "wasm64": "c07b31c1b40249c1784c57b7abc6187119a614bca16214f08b5bd004e7f34151" } }, "nginx-php-vfs": { "manifestSha256": "97976410cb02f8ba710d856b4ac904bcf976d677b50eefdee39fb64176070d4b", "cacheKeys": { - "wasm32": "e1dead1a43da2fbc91fe897cbd65c61f5720aa8046670bd7de0b0be61787f8d7", - "wasm64": "cdcfb6efcff3c1732874c4cd52e412717d0002b3c4cbf68112a3cd0834a299c0" + "wasm32": "8dba20b1b93775b7ed4b93f304660ca4608d0037e007951b78f437837a6983bd", + "wasm64": "08f2526652948f7ab009b58a97ad55a6d19bb531021f58f3cf6f7e09edc529f6" } }, "nginx-vfs": { "manifestSha256": "46aa2d3250ac5cd0f102a85c3a36a086c7a50ba1f9e05fa1c2aae3d07ad16f10", "cacheKeys": { - "wasm32": "44f58deb30e411e6e91ef3f77a9ecfe5e52e67a1456a2fba07a8d3115a07e293", - "wasm64": "9d98820b84be1697a44775c2d046f3b58af1e0bb100ac35996960ae664199cdd" + "wasm32": "f4ec4615857967396cfc60a8de0221d49d9d5af58d85b2a844be5d5f5fdadc33", + "wasm64": "b4d3f67029626972290f1b2fa45a80c322aaf11827b2805f44e8a82f4dc001b8" } }, "node": { "manifestSha256": "2131a24dfda8587860e57fc65b573086477d376b065b3b9ca4e1dfe4d79f5790", "cacheKeys": { - "wasm32": "1ade84c0949d68410604abeecc1372019d5cb5be36257a1ea019e8d3eac8554f", - "wasm64": "4c5262332b58ed7b38d120f636e97854c814ee40f2f0c636dfab990f1f4b06fc" + "wasm32": "c17d3a4e332092ef2176fe855387810799527eb2aca0de81f960f6d97cb74cb4", + "wasm64": "c75b80e4acb80303e9f63eca1a62efa12cc1c988b61fa5da20d213b061ecf6ea" } }, "node-vfs": { "manifestSha256": "977f219defe57e18017ecc963159df32efdd21c53d6fea05943703d04739d8de", "cacheKeys": { - "wasm32": "b47cf3b19890b4cc2b0127339d5ac15009b78dfc3b491d43e83eb7d4c2165b3a", - "wasm64": "0e7998404c3ff6244b85681bf0939fbb3f0af0ee25670ea435d5b928aff79e79" + "wasm32": "785793c52437194d88f54f62c69bc17713138c18ce34bc7da6afa5b0389632d8", + "wasm64": "e900e2f9a7863da7ee698d5ec041b8b57a81e5f7f6fde4167cfd3808ba1371a3" } }, "openssl": { "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", "cacheKeys": { - "wasm32": "36d99a823cd97e42e1bfd1aefa9b4a0bb79b7879eef2faf1dd9e932445b21d47", - "wasm64": "a60d45397ac4db3a2ebd16c8750f4ef8e0f550b292b241cf6220dc0ef9c33dab" + "wasm32": "94d90e4c611c3ef8174635182f27493ec8f3c832861ae9a2428b1a35736aa22c", + "wasm64": "341bcb8e5eb286928d150eb0861590482bd3dcfe6f08e2637ca385081abb5273" } }, "pcre2-source": { @@ -354,225 +354,225 @@ "perl": { "manifestSha256": "6cdc4dbc54d0e4008cff41f82ec8918c0e44b4aef23903539c1bc0f538fe3ad2", "cacheKeys": { - "wasm32": "3fe8bfa8774d6b5f5c2b80013c6495d152b689b52d42cd78f0a394a212c782c8", - "wasm64": "5ad54aa9e47dff952bdb0d2ad922f88af6965492cca061fc03633039583f8796" + "wasm32": "20e34f8efc078aa6a8d7c1d23a186102bb8a3ad6a1e98cd0adb09cb1b6183045", + "wasm64": "35782f3612dcb21d9a84e4d4c57265035c49687952f8956cf1adf64a4d85d84d" } }, "perl-vfs": { "manifestSha256": "1345cc102bc8c24a6bc276410c00b4fcc99ba5f6adc9b031f882aaa35d53c8bc", "cacheKeys": { - "wasm32": "869931ebd15307cf07c27e09776891ab348bb5410aeba23385b15ac8617a3af5", - "wasm64": "4b5eae066a08ad1ede88e72547e0b8cc63dc6902e06bd5ad36105f8d0c01f1d6" + "wasm32": "c0c99b86a9b34f649f7ecb4eb07caa16ee505c1ae292ee57a510a785a18da25d", + "wasm64": "110a596c99cf59ceac57d5b788ae3646ea33618b46e4607eefc5b0737482b5bc" } }, "php": { "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", "cacheKeys": { - "wasm32": "635e5ed39400e0333d51d16278634afc56c8c17e56be858498bff4886c93fee5", - "wasm64": "114c23199ba4c51eacdc8f893b013de831bc1a2b3865b95235fa2bc490f82cee" + "wasm32": "78e404c3a0b1ad280ea8c08a87d9d2d8b69509590da06878f4138ad71fc9d92e", + "wasm64": "b211c308df432d8f1d10f53f4ccf31ab2eed3c676daf43ef89ee217674cfe7af" } }, "posix-utils-lite": { "manifestSha256": "8fd7190b2848ef80143adc7b9268c79e13cd4db3430f7105faf49a4b4407a06f", "cacheKeys": { - "wasm32": "dc7e6124ef4a64df36ade1ea208b7e2fd1f0929773482f92e93f7063eeeac5fb", - "wasm64": "0a7a1e68253db606ab8deb6e5b95dafc30ba930206518f69055586d888a515f4" + "wasm32": "91eedb0fa500deef8ef48023d799915f49b9fe9427c610bc06f46afa9847c60a", + "wasm64": "7178161f1bcdac0f0bbf5002ac049c501c889ce5b8a7a91bae16954fc3279d3f" } }, "python-vfs": { "manifestSha256": "d1aa99feae65f06c0ca8cf52c2176534b2723fd4ee6eba6e72c205245e1c9c26", "cacheKeys": { - "wasm32": "6499248529a342f180c4ee2d1bebdbbe878a177b2d50574c8c93ec24575ab0d0", - "wasm64": "1796dff2cb30a5c3fccdb892e7bfefda4ba0c9377160d4bc497b083a32eeb397" + "wasm32": "edc1345ac416c098b008479c66219615f00e2c5915bf9229ddb26acbac50ed1e", + "wasm64": "7037394f2bd35dcfc5a1f647f75a3611a9766e289aada6ef33d66b182b3dd736" } }, "redis": { "manifestSha256": "151fb507de953ba2ba94b8f881bc7d66b4c741c1359a2f590cc07f9a4cd368a3", "cacheKeys": { - "wasm32": "de47bd67078bc531a4218667ed729408d85fcc26a6b36b144c65e0303c54f34d", - "wasm64": "7d2160d57fe48f007c944a17b46c9cb27cff9601dc9dbb95bed36a8721757d35" + "wasm32": "11d2132bfe4524b34e4acd17133601f861afdaae5dcd114285f415a9eeb50471", + "wasm64": "04fcb53fe0a8b26939a585b5aeb4870ddff3cab140138bd865f49b476302d5cc" } }, "redis-vfs": { "manifestSha256": "234c45c40f94e2a89f98295313b82cb9fce15a412ac829d9e87394e0dc731813", "cacheKeys": { - "wasm32": "176cc998ce64ad14f5f99456093ac275bc23f49c539655f9a62e89bcdcc83c91", - "wasm64": "2b5c997e24466188b4c83cba1a301e3265012cf7decdfd2be5c53cf4dc107cbf" + "wasm32": "958d9b9270a57e643af8cb6d7f115f9a553af95f56a674d2f89080e8dc991562", + "wasm64": "87d0d461a226d438229084a1cc36c67d4a1956224263c1b313d1a824b3c191fe" } }, "rootfs": { "manifestSha256": "0420201025170f48c32949ac62707d4577cdc13a53ad1f01f59d318ec07c8d6e", "cacheKeys": { - "wasm32": "d4ed5891e3146cfe2a3708a135666f91112170e47e3ef0f182031b7830d9f124", - "wasm64": "a31fd8644a4df982bac1e1eb94c0d0562776f26acf06026a99ff583713ca0d9a" + "wasm32": "cb56c70a95c61d714cb6c525a567b80c7f8d7fb2b6f2056910b58d9243a857c7", + "wasm64": "c4d26e9e0c91ac6fe3df7c51aebb22e9ae9fd22daa8de3afff0012a858d59dbe" } }, "ruby": { "manifestSha256": "6ea67a246c29cd927a19decbb36ae4c4d478ff6642d2c77e08a6b2cfeb8251d2", "cacheKeys": { - "wasm32": "8c4c3aadd4a06e761a018c6738e0eed5b3f113e2b648959aa6167511c39b0757", - "wasm64": "82f8c893890997ee5b0660665886a5fac30255877ba7ca32abb2589428bb5d45" + "wasm32": "c990f064060821a8e076633a227c3e5cc1dc3e8c63710eb8121b0cc90fa7b1ae", + "wasm64": "3915deb5b6371895aeef5f20ac50717adfeda16abcb2472ee59c529d053eb6ae" } }, "sdl-dsp-test": { "manifestSha256": "a988bef0b27403846a675965d951a286245fa79e84c209657d70a9a1200e8037", "cacheKeys": { - "wasm32": "649d96fbae5f45cd1d440f148b9ac042bfe1ff752f6f0e20a67bbaf812d7dce7", - "wasm64": "833232f6c4a044beae087fa58b72c22debb5eaf90f7a4bc04b3a284986fbf4ba" + "wasm32": "91be68f6091b7033131e63c8a28f747d9d51302ce511e6c52632d4918e068699", + "wasm64": "a822b127814d20f47af4efc660ec0950bfd539c6841b27f254b17d7974892708" } }, "sdl2": { "manifestSha256": "327ce0acca768f51e36ddab8ab58fd57b24cfd9e40722e818103c439f7263dd7", "cacheKeys": { - "wasm32": "8a1705083bca64cf5ffa73cd0c90e2dfa52d1eae9b9f872b36b4789bf56cb446", - "wasm64": "4528759b73ad796323d7cb1dd53cc4e6d18d2d53ed3a57c9e3789581b2d4096d" + "wasm32": "1b990666208f36b3a4ac2521ed3a39e8227779b2e2fa21600b7f372f616d0621", + "wasm64": "3ebdf1d1cc272b4f629addd42ad2531e0879f84409de18e64150855ebe5e71f6" } }, "sdl2-mixer-playwave": { "manifestSha256": "5ff3863e9f83cb9ad62931e067ee6e417826e06391862d9d0a58d6cc6b4dc570", "cacheKeys": { - "wasm32": "8942d37250b17a645ef33e37e4920af34f1e9f78cf337fe7a7b49b51e589c76b", - "wasm64": "7566fa007ee0846d555b59833057ab8e9821f3b2477648811f6ba0b815225d12" + "wasm32": "b356d169ae065b0511ab6a7bfed157eb764f8137741d3192113168cf74988ce9", + "wasm64": "f3fa3c42fb82fd3b0f322e5d1b23853d4439570f069f317e9149e40ac65205db" } }, "sdl3": { "manifestSha256": "3b7c64605b941e14d336b1a127d8219c50402dece009e8855297a0a0696bd67e", "cacheKeys": { - "wasm32": "4302058a87376e2fd2d7e9f2bf1713e8bccf00016f6e17b77ca21169662f897f", - "wasm64": "22bdd74942d2890c90e7ec569acf21fcb80d8fc9191d23851c662e4acefda33f" + "wasm32": "41b437b4383f8c9d419b39c1b55963ac5b446705216a6029d39089718d28bbcf", + "wasm64": "147317ed5aae1f814257c2e3ab73fe3e1cdb0e07d802ad6a60e7437411500a2d" } }, "sed": { "manifestSha256": "0c0d82d53907c19825941501f833252cf9adf35ebcebf6786baae28e5eb6958b", "cacheKeys": { - "wasm32": "2daf8e8139e879d4294b143e0c24c89d67eeacf5ff3c45cfaee97b812d58fc44", - "wasm64": "6151aa5ff9cf0fe5a115ee9843ba57667251ff997bd7ef4f119f478195445e45" + "wasm32": "968b56b98643a7cb8a7e11101ebb352c71044fbdccec6aac88768095e38b077b", + "wasm64": "c2af8ff7648d60e81a11f72573e1788e0400ce0f1b0ff18a0aa50c88ba6061e1" } }, "shell": { "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", "cacheKeys": { - "wasm32": "edfa7d165b2d2ead001360dbcb3104e965c7aad1a75566500e4a9c7db90c7c08", - "wasm64": "5ef591140704f1b52f4485c7331ff7e751f464f58dade9ac16828c031c401d50" + "wasm32": "25d79cdafd592b123a20ce0da5fe2c0571f92d8bdb70270fe06d04e9eb93f5d7", + "wasm64": "f1eb210b33514d7c98cb5dbe0b7bb4f116b75d0ddd3b2e55ad6d997748549cfc" } }, "spidermonkey": { "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", "cacheKeys": { - "wasm32": "f01d6d631bafb7a31b034ec33970b67778a7ff9e4af3089685ccb941ea2fce18", - "wasm64": "c3d32b76012ed5bdba923ff7b68eec1bcd067eed0d2b455f3109dcc12299f29f" + "wasm32": "5c6935fabfdc6a8cc1ce812a87cf61d19df14a268e9e6ce33dced6e106ccc145", + "wasm64": "64611321d8dbcf24cc0d25c97ba593aa54f6b87e7147c7c3cee317e4c97313e0" } }, "spidermonkey-node": { "manifestSha256": "f156c4c5044d2a9447324e7a2a35f8c3e6fa367b3e44f674dcc30ab6b946fae7", "cacheKeys": { - "wasm32": "b01e36db0587980ec1c1318bdcd39519166b044f3c2b5bc8c135febff91257d4", - "wasm64": "b3d381775911200ea9311a6518d34ef170c6aab386bb70ce469d8228d45633b7" + "wasm32": "6deb4a4d5d3dba8f7f135b0e18d71ca30b144e58f871f9578c993aedb3dc9c59", + "wasm64": "29fb515712a3eb310dc8ff680bf2438e7b1ade8bdbaec4cfc39d48cb396555b4" } }, "sqlite": { "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", "cacheKeys": { - "wasm32": "73402dbeec78ac534e0ae3f472f887982200c5c9867e4dc8d64d30d760eeaa7c", - "wasm64": "9ca62f77befac1baa13c473a518c0042522439baa5d919699adda09b43f05d95" + "wasm32": "590f8dae0ef8972831a64291326763a1e9fa3697b144daa99d2d2d13cb91192d", + "wasm64": "9bd8425404ad7795a8e02e09cf39456a90247f2255ecf2737a55847a06bce7d1" } }, "sqlite-cli": { "manifestSha256": "1cebb768f9473dc58fc6e72c9b24565760aedaec4cbdb29ad43b22a0a5fd7030", "cacheKeys": { - "wasm32": "1d2c9a7c6028ae54f6fcfdd24f590d070bcb9d784a83e4a9b663b368f0c845b6", - "wasm64": "99e7c0ee82f48233a8810405caef49f3f31b3d6c38c0bbb0f26aae490c3bb4e0" + "wasm32": "78464a56a06dee769c41598f9f6922ade013ac0e8fddd86a52cd7bda70460dd9", + "wasm64": "d0a775b4a152287c9960606515c0ab9f95f7a891283ce511560856d91932d49c" } }, "tar": { "manifestSha256": "4502355c246362a2035e52aa7b35d274882fad4b4404c03470458baee723ae68", "cacheKeys": { - "wasm32": "e6ee4090d0aabc015554e1b5d4b337ec7f26cc465a8f32c007495ccd2c37e453", - "wasm64": "a315bcd0a07ad64b74805ca5e3d8acd43f18409f74c37d8455fe916218f76600" + "wasm32": "b9a0578b89fddf546f542eac3a63026f1f80a69071c13c029a9f54e7feefbeb1", + "wasm64": "e611e958a9354548a2d0671345e576240c8546183c6ad6a5bbb8720465edbec6" } }, "tcl": { "manifestSha256": "b98f237f741b11f5f5da55db75530b421755ba4c8a88d314553106faa1cca1ff", "cacheKeys": { - "wasm32": "16fbf3be0917c4cf47561820538c4065276df77b9373904c032986976ebc8e3d", - "wasm64": "2c39daef3f2f067ee784ca7d2215a00d0a0ba9b222b568e98940ee8098a22fb3" + "wasm32": "2feeb5a979a7b348ec5cd5e33a019ce766b6885be05e5079846a7e5fc2844cb3", + "wasm64": "17b3a889d6a49cb9a241747be944f2f086f0b92033f6bc9b72f066a731a7cb41" } }, "texlive": { "manifestSha256": "028ada023f8a8f9966c8a28575242a339aaaf0e8870fe4a221986133b381c3c0", "cacheKeys": { - "wasm32": "e1092a98c2200f9ca0a1dbd4eba7cfc78518a37863ca98df71e7265a273c4227", - "wasm64": "48f3ce16d978011ff7ab08696494c3ba761b8a789e531b2ab6a3747dcc099dea" + "wasm32": "00bca12d8baefcfdda858a5f6f2ca58c462fec39b14631060fa06a2257cb1f57", + "wasm64": "8ecdeea4c1f34d61ba298237dc23e5dd4a64002b73b63828f4c10d5e3caaa8c3" } }, "unzip": { "manifestSha256": "ef4e5e34258827ab3cbc1930da3fd9514f08f2d7d56c7c3e0d5c495a4d3a20e9", "cacheKeys": { - "wasm32": "ec8dad55b6005ca98236e500622ec77ca0a8d44693df24eb66f5f6c62b706dd6", - "wasm64": "e06df476bae63f16755a73e519eee223c4002ae497bc82680864ec672b599792" + "wasm32": "0e95e74674fa43f3d24c8b3457dc1a4ff9631154c71b86df537cb28661a67caf", + "wasm64": "c3f1ba915275cb158c230ffc4513c8e4410727e056405e39a5237c7b80f9704e" } }, "userspace": { "manifestSha256": "221176f2a096dee19bbefd89da5c7f50138ecd92d37ec9d7d6b35dbb25e86924", "cacheKeys": { - "wasm32": "0cfdb897357a4d55c885579252cdaa8c456d4eab84ad56b439e7cd0537324ec1", - "wasm64": "00ce2220bd1099e6010b33c3e8c15c704caa375d37dd17dab67606b2f11265e6" + "wasm32": "adac952005081ba013f663b01c07f7fa948f80c1c580accc8190bc0a4761a5cb", + "wasm64": "777175d27005e8daf58421e33c30eacd912c6e6abee5c0e085f411e3536f5cb6" } }, "vim": { "manifestSha256": "c211660ef41d01f95f3fbdf675b74891e897e3f304e04f1bf5586f326007382c", "cacheKeys": { - "wasm32": "5b84470427b17fc94f6e4803bef63d23fe317fc5d3af73c035b642ee48fbffdc", - "wasm64": "01a192db98343fb5a81798ef83fd0b1e27830024064d9b78b44c58aa484925ca" + "wasm32": "a75b0e135031cbac781aa636f5de6d6d1411d1c41f22021bc1a9a594fc78a35a", + "wasm64": "952ad0d54e9f46afa84b7d7f32a4499259a8fb5c6827c31bc7f94e16fb4859a6" } }, "vim-browser-bundle": { "manifestSha256": "e31d84057db49c3534381604d0c8f3c7d765e33ede560a7ea7b01a5a8f1aa0d3", "cacheKeys": { - "wasm32": "abd41342ceaf4212159653327c03680599b9bd53fdd4477c57c0131c7dc1bbd3", - "wasm64": "926bc8bac763b1e58d259dd5caba0d432083be21f92a8cbcb6c822ee583553c8" + "wasm32": "56b676a43f6cd4d9a07ad75323d3a7c7306cbd075484457a79569787c37ae188", + "wasm64": "6dfb87a7b84fa94fa50ba26baa85947cf886e3f26ef798029d6d77731ff350a8" } }, "wget": { "manifestSha256": "61420b6cb1be12471f425b0aebf5aec58c49d0b43d939dd54508b305accbf54e", "cacheKeys": { - "wasm32": "793da9e9a98944ecfffefdfbbaf66eb2437bce78224b3b723c382d46f38fce89", - "wasm64": "4c91ffe8ce0283722bf0f8bf2fd2b20053f8e7c47cf59bd4c6b631d9e055ed11" + "wasm32": "98a913f679104d3400c949da7f8bfeeba5f46add578d306a4adf16efb1daffc3", + "wasm64": "3f1b2dcb26b396baef7f6b383b2ae884379ea15611b2fd836efbe69a3a92c4b2" } }, "wordpress": { "manifestSha256": "36465e0596a06e855a8524eecfd87da98643bc13b37ee12939f5aab44bf33418", "cacheKeys": { - "wasm32": "f62e8e8f07d047b7555eb931cb22109d997250896c38a16823204188aa3a43e5", - "wasm64": "7148765a475b279c978fc1836d6eb67358a5d154ebe19af8fac3cdd538997985" + "wasm32": "fc621e5bcf2918136f7aea76a5617b3d7ed5e52e0a91f95e0ecaba3819da96f5", + "wasm64": "bd188f91a6c71fc52eef5c6c2e7c0dd50f06ee6d1f526f032cedad5a3aec9eee" } }, "xz": { "manifestSha256": "8707d35b8647b1af8fa165dcb6ce7b2a6a007e19ab2daca2680c39395864a737", "cacheKeys": { - "wasm32": "e671549aa3e6fbf95c8d0a3ab365700c7354bf4e4faf4ffd6b0af2fa62485830", - "wasm64": "6de5a6b3fc48e18bc9b74d32200b9ae1bc6c8aea5a80bf7a38c7c00f9a5cc78d" + "wasm32": "22f7c6e571abace32661baa8907311894d67f1ddf037fc3d211b15ad7585ac87", + "wasm64": "630e17ca1c7c7e993a5ea79f428a87f54cdd77ed08fd5342edc926df07ad510a" } }, "zip": { "manifestSha256": "9c9cfa8220aaeaea26736b55f7cd0a7517b8853c07f9f0d241ad67dd43592e36", "cacheKeys": { - "wasm32": "b188c0bf07f2c79821cd474d934c36bcc2a092512dddca33bc3cbe6d68832b5b", - "wasm64": "cb629a22496c4e1fd12bb754078a2c997eefc4a57bcaca27ba4fbe4d788c414f" + "wasm32": "5c25b080333ffac7a5271e6c17b3c361d325677272a7e7fca7a4b42cddb0ce16", + "wasm64": "84fc6fb4e6b10e67110c4035eb6703d13f04798c2709e6d8d69e760dec22e0bf" } }, "zlib": { "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", "cacheKeys": { - "wasm32": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e", - "wasm64": "0b402b1805b4b8a460b96a14f93e8c8eda7f7cf3afd1f94e5eb1efa423f953fc" + "wasm32": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a", + "wasm64": "4d5e9bd3f10d4e64da3f1ebe9438ee86378bac317b988691b0f7366043712a00" } }, "zstd": { "manifestSha256": "89f266938c2c253700a838e6352c9de66c976c84883325aaa979f72b732357e4", "cacheKeys": { - "wasm32": "bf9867e234e6d73257a275e30917058094135eb4f90c13352df67504acee097c", - "wasm64": "2558590ae68f08390876c524cf98a59694df6bef3b75115b211a85fd53ab8b53" + "wasm32": "9009660cf423b4f45a88bc99c6b37e5759661a82c64e9c89302b69c219efce49", + "wasm64": "c4809ac5756cb76ad523fad77b67fdfde31775b7c8c3bac1c8f825b7ebb591ad" } } }, @@ -583,14 +583,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2d577fa298afea12b9245e372d0e8712bb166f5d759f612f86c7b658367268ff" + "wasm32": "9cac1217bd7951530fee52b9a19feb039be16c997c87b76d1b949cafa6154bbe" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "0fa705045e9b3026abaf8e26a8e3c38d5486a4ad9a967752458768a442e32e0f" + "cacheKey": "9c773c442f97096978216d373d9c7c5d19c8a4a7b26aae0c4bc02b1c76cca382" } ] }, @@ -610,7 +610,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f95f8a79fd939755345af1acccdec0b21f82def30156d8bc1bfeca2ee9f4b57b" + "wasm32": "6833fd5275f1378837506fbbcd3db757a19d4ac6ea9081212bdc4150a64408f0" }, "dependencyClosures": { "wasm32": [] @@ -631,7 +631,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "cdb8b3ef6ef84d9b962df3cad7f0c09ddd146e8a1e1fa873b31ba09265fdfcd3" + "wasm32": "480102a8e3940e1eb894a1b9767efa89a359bbe93a61ae23dec2c12319a33447" }, "dependencyClosures": { "wasm32": [] @@ -652,7 +652,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "9d4a7186e54db60edf09a2aba993d4f1d4660dcd37f5f280581941d09e4fb054" + "wasm32": "ffbcd2c6b9df24acb01de4da66c4167c07556aac83a2891ae182a934bb9ceeea" }, "dependencyClosures": { "wasm32": [] @@ -673,14 +673,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2b100dd2bfbb68d67c935fa9de93e1e82721f50d2040a7a1d22cfa2656053884" + "wasm32": "8e36bc988b0f8fa0130bf7696b73a81b018e8704acc66005431b25ace7fbe048" }, "dependencyClosures": { "wasm32": [ { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e" + "cacheKey": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a" } ] }, @@ -707,19 +707,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "18890ef7352b7881c2c0880b032e15166dbbe18b24bd3082bda7728755451528" + "wasm32": "9c4a4a28c7644128e9d95ff53840d4a08f8727394dcd1248e18c6bf7235521f5" }, "dependencyClosures": { "wasm32": [ { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "36d99a823cd97e42e1bfd1aefa9b4a0bb79b7879eef2faf1dd9e932445b21d47" + "cacheKey": "94d90e4c611c3ef8174635182f27493ec8f3c832861ae9a2428b1a35736aa22c" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e" + "cacheKey": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a" } ] }, @@ -739,7 +739,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ed8a5db2cd63484a368e0181b72f177d072a64bf4b053f62fae128bb5e8915d3" + "wasm32": "ff2bca584a197d92da417221bcd6a92171140b030ade50108eaa244905bf88f0" }, "dependencyClosures": { "wasm32": [] @@ -760,7 +760,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "247e25a24952124aa930b6b0bb5092fa2c74653e52bb2e928a049ebf109ff9fb" + "wasm32": "688e551eb9e8c698148a45d1c59efb2bf13120d54dc9516289395d2b73a7ff9e" }, "dependencyClosures": { "wasm32": [] @@ -802,14 +802,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0cfd3dca3b9499921d565e03a274c5ebbaff265e6c05e98aa8ef889d2140ccd2" + "wasm32": "f7abd4cc88bcb4f9bf2e01872fa181c8ac84db6621da3e2c1a291f32c65f27ed" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" + "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" } ] }, @@ -843,7 +843,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ee489dc49dce5553b05b5eed8d5e56943dece8dcd8bed0fa01e39dc95c63faaa" + "wasm32": "e6f453949d018258bdefcf7dfa503e8b25ad6422635c57540e92b9c4a26223a0" }, "dependencyClosures": { "wasm32": [] @@ -871,14 +871,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "7b88273b7d5ca43aebb2d65c9fbe03ab56639ce1166bf9261ffff67e119d9235" + "wasm32": "2be200a6d32d1fd561bbce87700eb46d90b7e91684f69c359f3e45b1b092f967" }, "dependencyClosures": { "wasm32": [ { "packageName": "erlang", "manifestSha256": "475d6037e73a0f1e2f4381067194b2422751206979ea17209e10efa5a2a93bbb", - "cacheKey": "ee489dc49dce5553b05b5eed8d5e56943dece8dcd8bed0fa01e39dc95c63faaa" + "cacheKey": "e6f453949d018258bdefcf7dfa503e8b25ad6422635c57540e92b9c4a26223a0" } ] }, @@ -898,7 +898,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a19efa9f3f880a2f7582794fd6c080d18f0f52cebfe7380713096fdb5e0268ef" + "wasm32": "f50b05c1d725e28d65baf028956336614efd4e495c0c7646e3a85855b0cb9e21" }, "dependencyClosures": { "wasm32": [] @@ -919,7 +919,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "4677df78cfe25250903279ae481372f6985aa54a9f85682499d0ed2a94408eaa" + "wasm32": "306f637580638cf8c248f3cf9b15469d526030020ff04820ce60bd188b54ef80" }, "dependencyClosures": { "wasm32": [] @@ -947,7 +947,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "adcd350e6f8977e86330c59b57f52b11524143c02dcd4a18a2447e5f635c88e7" + "wasm32": "96312ac7e9087405539527a9c1e119db733e0f46395b9ecadb30c77f3eb83057" }, "dependencyClosures": { "wasm32": [] @@ -975,7 +975,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "915c98a7739cbeefb8b4ed326e62effaf0c376c0d187141254527c08eae134e3" + "wasm32": "ea31e40a17bccfd311032d3e052f3a6707745b000f8303be9e72072628a1572a" }, "dependencyClosures": { "wasm32": [] @@ -996,7 +996,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "44e9c599cc26e9a485303555de6911f9ef46c7e4d8eeeb049d72e9bf9bb22c79" + "wasm32": "a545be3bb60a666a7e6c7e130b91c37178b7c0a5bdf875d6b249deae3a7f1444" }, "dependencyClosures": { "wasm32": [] @@ -1024,7 +1024,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a0c03526e72d0581fde4e5ef55a4e5f38a8047e2d93501aa56db6aefff6a33ba" + "wasm32": "68e8a6c294322d4f8bfc42c146bb4be5c47ff72b3fcf76e7423061a0117a29a2" }, "dependencyClosures": { "wasm32": [] @@ -1045,7 +1045,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0760ed1ee2e04a89dc5e47ad9134d50d981e08035c624856c0e0f0248727c4f9" + "wasm32": "1a49313a1bd83c8b35f8c9b2d7874da4c572b48275dfce6d37a647935879a941" }, "dependencyClosures": { "wasm32": [] @@ -1066,7 +1066,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "9a4810e7dc1daf506b1b74873cfa601dfe5aeb0d868b39c8279ebcadc04b0ccb" + "wasm32": "6adc7842ddf5af1df0d21e89a6bd1fae1e9572dc61f1a3f48c9908c71b3c5d2b" }, "dependencyClosures": { "wasm32": [] @@ -1094,14 +1094,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b2801ad140482e877a4893251d043e033801d25ef4135c92a73b2e50eb9aca92" + "wasm32": "4838701321647a5e4869af903664a7b5cb5cb436a1a220b132423881bd77c8a7" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" + "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" } ] }, @@ -1121,64 +1121,64 @@ "wasm32" ], "cacheKeys": { - "wasm32": "29838c85d239e83665cd9201eae6b4ebeef875e798ddf01a1f07804d5d519aa7" + "wasm32": "7cdbe3ffe057d215c2a821636eb419dcced02d242b6c1249ce151d841ed44bde" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "0cfd3dca3b9499921d565e03a274c5ebbaff265e6c05e98aa8ef889d2140ccd2" + "cacheKey": "f7abd4cc88bcb4f9bf2e01872fa181c8ac84db6621da3e2c1a291f32c65f27ed" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "3fd9598c6138054150f5d457a0b0f62d44ab0920c2a5ea1419bbbd16f2cfd4ec" + "cacheKey": "bf454728f58a75797a9d52c8c6d92c6a52c67c377be31c0253fb2a18883556d6" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "65727e0e1a26b1faaea4e0f0fbddce5e5a44012df69dcda1bb7e3e8626487268" + "cacheKey": "2a54294aa012c4a47fb5b540f49dacacd83725735c4db8ba3746fc4177146a6a" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" + "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "6aa4acaad6784b9152ec957d4e2e8da31db5c0b610414110f1ddfc5623772f71" + "cacheKey": "9824449919005d0862b1f40b310195b2bf5657733469a3b1aac4b94396ffc669" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "c11983c6357c1b77a011aaeafde22882bdaa777c65c40dd99d4c48777acf23c0" + "cacheKey": "966012e82a47f8afa17683175087d63b7fe7e191c770f14a62d82366e13ba959" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "2b8c3acda63a9e373a63ac32b693eb0a3b7d1f01d19dc114a71ec88c5c0f2168" + "cacheKey": "5cb2f96b95a2fcd0618db09f8393d8e2bd6a40617cb2b35f57667a482c671739" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "ab24370d6f159ba9c14e964ff902c1182a303d29dd51639659c6938dfdfc45e5" + "cacheKey": "6d9b1c0dd88cc10290b63df2ab1720940ae58285e2745c91e6695b7b85a703a0" }, { "packageName": "msmtpd", "manifestSha256": "09de04a422ddf631a29a259d816e081f8d317d1077808ede55d8c500baae748f", - "cacheKey": "e52466139aaf8c185e8f8983b548dac2413ea241af093f7c854ee99325999176" + "cacheKey": "ca4d269e24beb373937cfffa3a5a70be1c26f67b4afa19128d697ad9cdced81f" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "9e932bd478a344cdfdb68da0f300f147b2d9c60bb385cf1ffe7906e315d357a8" + "cacheKey": "2b5174ccf228be9d359389d8c2435f0cd34feebc66e57966558902f72682996d" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "36d99a823cd97e42e1bfd1aefa9b4a0bb79b7879eef2faf1dd9e932445b21d47" + "cacheKey": "94d90e4c611c3ef8174635182f27493ec8f3c832861ae9a2428b1a35736aa22c" }, { "packageName": "pcre2-source", @@ -1188,22 +1188,22 @@ { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "635e5ed39400e0333d51d16278634afc56c8c17e56be858498bff4886c93fee5" + "cacheKey": "78e404c3a0b1ad280ea8c08a87d9d2d8b69509590da06878f4138ad71fc9d92e" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "edfa7d165b2d2ead001360dbcb3104e965c7aad1a75566500e4a9c7db90c7c08" + "cacheKey": "25d79cdafd592b123a20ce0da5fe2c0571f92d8bdb70270fe06d04e9eb93f5d7" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "73402dbeec78ac534e0ae3f472f887982200c5c9867e4dc8d64d30d760eeaa7c" + "cacheKey": "590f8dae0ef8972831a64291326763a1e9fa3697b144daa99d2d2d13cb91192d" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e" + "cacheKey": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a" } ] }, @@ -1223,7 +1223,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "26a936bd1d8d5817eae86a8e5680890c0c741be7fa88f0b92035552c28f18b0e" + "wasm32": "10bfdfc7cb4ee67172ac4c91545c2b0e00282be1f740b55aff5f4f3bd75274bf" }, "dependencyClosures": { "wasm32": [] @@ -1244,7 +1244,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "909d4418866795cd511ebaa25cd751c7e1075657e24c334c859397cb6366e7d5" + "wasm32": "7b9f79de5b4f23e236e29a1f4a809e586d225478e49ae27a891a4f021ade9cdc" }, "dependencyClosures": { "wasm32": [] @@ -1265,7 +1265,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b887bbc00ed62a5228e955593dcc4a31d98e8fe71972d59971e32bd76b7ad536" + "wasm32": "9413a8cb9d531fab82b389c7172bf1d59e3feefb5adcda8504d26934846f095e" }, "dependencyClosures": { "wasm32": [] @@ -1286,7 +1286,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "de30b90416e0a0d62ada5726860e8e7cefe891eb5ab51d8b26f18f50e0e06fe5" + "wasm32": "a95e8bfb3c1d51b81b6de917d410ab6677c2a2c25c1d5301654c796e331e47d9" }, "dependencyClosures": { "wasm32": [] @@ -1308,15 +1308,15 @@ "wasm64" ], "cacheKeys": { - "wasm32": "ab24370d6f159ba9c14e964ff902c1182a303d29dd51639659c6938dfdfc45e5", - "wasm64": "43cfb64022b7de7df2e7b95760a9f4cc8808ce59449d388eda35d6f79a6869b9" + "wasm32": "6d9b1c0dd88cc10290b63df2ab1720940ae58285e2745c91e6695b7b85a703a0", + "wasm64": "3f181cf9f51770f7deda50a72a1d8e8b4c38d515aec4485ec3934e429ba42344" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" + "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" }, { "packageName": "pcre2-source", @@ -1328,7 +1328,7 @@ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "b65406cc252ff13d6049ae52461196e07423734583b162422829821838609693" + "cacheKey": "f828b2c8c1b35ffcc95ed9b419b93b89133eb03ebe426e87e946889d5abea144" }, { "packageName": "pcre2-source", @@ -1360,34 +1360,34 @@ "wasm32" ], "cacheKeys": { - "wasm32": "eaba21ea0e184a6cf00a87c99273c88d6b0ff8aee4fabcf4459770aa3483f0cc" + "wasm32": "a0a852b02a9aa96daae3976cfa7b1cd6812669fe92ab7734d6795aa00273131c" }, "dependencyClosures": { "wasm32": [ { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "9d4a7186e54db60edf09a2aba993d4f1d4660dcd37f5f280581941d09e4fb054" + "cacheKey": "ffbcd2c6b9df24acb01de4da66c4167c07556aac83a2891ae182a934bb9ceeea" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "ed8a5db2cd63484a368e0181b72f177d072a64bf4b053f62fae128bb5e8915d3" + "cacheKey": "ff2bca584a197d92da417221bcd6a92171140b030ade50108eaa244905bf88f0" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "0cfd3dca3b9499921d565e03a274c5ebbaff265e6c05e98aa8ef889d2140ccd2" + "cacheKey": "f7abd4cc88bcb4f9bf2e01872fa181c8ac84db6621da3e2c1a291f32c65f27ed" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" + "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "ab24370d6f159ba9c14e964ff902c1182a303d29dd51639659c6938dfdfc45e5" + "cacheKey": "6d9b1c0dd88cc10290b63df2ab1720940ae58285e2745c91e6695b7b85a703a0" }, { "packageName": "pcre2-source", @@ -1413,35 +1413,35 @@ "wasm64" ], "cacheKeys": { - "wasm32": "c9ac6a6efa075623c727c7b33b34439a31caf814d626d11bc6bfa22fe4d25b0c", - "wasm64": "b01238af64fcee0d288a1413f069cc86be29621cf8c602fffc0e563176cab991" + "wasm32": "3dd7c2e9e51c9adea3d3b52b1a8026281a0db6851900c2b9eb1a5cebd8d46db0", + "wasm64": "a430226e39f0ca6d815100e6ce7bc868f144f081f9cf432f2486a74c6c13a77c" }, "dependencyClosures": { "wasm32": [ { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "9d4a7186e54db60edf09a2aba993d4f1d4660dcd37f5f280581941d09e4fb054" + "cacheKey": "ffbcd2c6b9df24acb01de4da66c4167c07556aac83a2891ae182a934bb9ceeea" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "ed8a5db2cd63484a368e0181b72f177d072a64bf4b053f62fae128bb5e8915d3" + "cacheKey": "ff2bca584a197d92da417221bcd6a92171140b030ade50108eaa244905bf88f0" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "0cfd3dca3b9499921d565e03a274c5ebbaff265e6c05e98aa8ef889d2140ccd2" + "cacheKey": "f7abd4cc88bcb4f9bf2e01872fa181c8ac84db6621da3e2c1a291f32c65f27ed" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" + "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "ab24370d6f159ba9c14e964ff902c1182a303d29dd51639659c6938dfdfc45e5" + "cacheKey": "6d9b1c0dd88cc10290b63df2ab1720940ae58285e2745c91e6695b7b85a703a0" }, { "packageName": "pcre2-source", @@ -1453,27 +1453,27 @@ { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "d9232813053ca651f5660a2360e15f019ee2b428e8982ced50194ff3bb263191" + "cacheKey": "8079ebf92c30022c0ee2577221b641128fb0265c2530f45bc52612ff717144a0" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "f9ce165a14ec14b4bdbc81a5fd1238f415d365beb1efa51a55e435b99d807a60" + "cacheKey": "2d1fef92952071c38c2f7758c5a2313dc664966a4bd081820ed07e885aec2383" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "e64d25c0a8a6aa5a8032094c312432783dfaaf60846c27cb0077637138429320" + "cacheKey": "1fefd32aa29b1b65a011d4466550ba7f13da0029b7dc58fae072d5a0a756d842" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "b65406cc252ff13d6049ae52461196e07423734583b162422829821838609693" + "cacheKey": "f828b2c8c1b35ffcc95ed9b419b93b89133eb03ebe426e87e946889d5abea144" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "43cfb64022b7de7df2e7b95760a9f4cc8808ce59449d388eda35d6f79a6869b9" + "cacheKey": "3f181cf9f51770f7deda50a72a1d8e8b4c38d515aec4485ec3934e429ba42344" }, { "packageName": "pcre2-source", @@ -1498,7 +1498,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "cb77410ec2dad89f43c7ceef9c33edf3c1ec39a5430b9327a70136349794350f" + "wasm32": "2478e94c90c8c0ba8a043dd9b527493df59d678a4323f6f879d6ff819921802d" }, "dependencyClosures": { "wasm32": [] @@ -1519,7 +1519,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e52466139aaf8c185e8f8983b548dac2413ea241af093f7c854ee99325999176" + "wasm32": "ca4d269e24beb373937cfffa3a5a70be1c26f67b4afa19128d697ad9cdced81f" }, "dependencyClosures": { "wasm32": [] @@ -1540,7 +1540,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "43267a08d7d10ce343f1ac2354ad9e3cf2193931b1f3dec522353ac9b0e93323" + "wasm32": "1b0237e997d82bafe853ec28192c4b5a310331ce814f6cdfd4301c14290f54bb" }, "dependencyClosures": { "wasm32": [] @@ -1561,7 +1561,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0fa705045e9b3026abaf8e26a8e3c38d5486a4ad9a967752458768a442e32e0f" + "wasm32": "9c773c442f97096978216d373d9c7c5d19c8a4a7b26aae0c4bc02b1c76cca382" }, "dependencyClosures": { "wasm32": [] @@ -1645,7 +1645,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b98fe24204f370fb495b84b0241db465ad666e470b2caa081a8b7b453b2c971a" + "wasm32": "bda506d821b1485688ab5f601bd28b53cfea8675f31fe5a617f62a89ef34808d" }, "dependencyClosures": { "wasm32": [] @@ -1666,14 +1666,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "df15777c107fbc3302c8ef14d38b7cea8081eb74b0928e38530deef188b0170d" + "wasm32": "1411c691c5832de251b4e337c4915797c271f07737ed6f82560788800ce7e92f" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "0fa705045e9b3026abaf8e26a8e3c38d5486a4ad9a967752458768a442e32e0f" + "cacheKey": "9c773c442f97096978216d373d9c7c5d19c8a4a7b26aae0c4bc02b1c76cca382" } ] }, @@ -1693,19 +1693,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "eb8580b1a03a8f9575477c2618419e3ec1df34880c26eba312ec7d7f066931d1" + "wasm32": "daafe55441efecae67a98181c0ffa0acf838ad23d2b0acf37160a5a5b80a7b43" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "0fa705045e9b3026abaf8e26a8e3c38d5486a4ad9a967752458768a442e32e0f" + "cacheKey": "9c773c442f97096978216d373d9c7c5d19c8a4a7b26aae0c4bc02b1c76cca382" }, { "packageName": "nethack", "manifestSha256": "1a2f12ec2770bd8d40006d6c878a3493b97c71c2bb63ad08c7f6ad99bfd53504", - "cacheKey": "df15777c107fbc3302c8ef14d38b7cea8081eb74b0928e38530deef188b0170d" + "cacheKey": "1411c691c5832de251b4e337c4915797c271f07737ed6f82560788800ce7e92f" } ] }, @@ -1725,7 +1725,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "9e932bd478a344cdfdb68da0f300f147b2d9c60bb385cf1ffe7906e315d357a8" + "wasm32": "2b5174ccf228be9d359389d8c2435f0cd34feebc66e57966558902f72682996d" }, "dependencyClosures": { "wasm32": [] @@ -1746,79 +1746,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e1dead1a43da2fbc91fe897cbd65c61f5720aa8046670bd7de0b0be61787f8d7" + "wasm32": "8dba20b1b93775b7ed4b93f304660ca4608d0037e007951b78f437837a6983bd" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "0cfd3dca3b9499921d565e03a274c5ebbaff265e6c05e98aa8ef889d2140ccd2" + "cacheKey": "f7abd4cc88bcb4f9bf2e01872fa181c8ac84db6621da3e2c1a291f32c65f27ed" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "3fd9598c6138054150f5d457a0b0f62d44ab0920c2a5ea1419bbbd16f2cfd4ec" + "cacheKey": "bf454728f58a75797a9d52c8c6d92c6a52c67c377be31c0253fb2a18883556d6" }, { "packageName": "kernel", "manifestSha256": "db1d66db8575562ac7b3e720dbaa06d7dd5eef577d80220ea4167dc2d69b863b", - "cacheKey": "2f8f321f40b81120644d66fd1c5ed1e949be708781c568e14b62d7171e9f9f60" + "cacheKey": "e59c55a65d4fc36dc5a5fb9a37db0f87290318a4137ecdbc833f7e7a4a8e8325" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "65727e0e1a26b1faaea4e0f0fbddce5e5a44012df69dcda1bb7e3e8626487268" + "cacheKey": "2a54294aa012c4a47fb5b540f49dacacd83725735c4db8ba3746fc4177146a6a" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" + "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "6aa4acaad6784b9152ec957d4e2e8da31db5c0b610414110f1ddfc5623772f71" + "cacheKey": "9824449919005d0862b1f40b310195b2bf5657733469a3b1aac4b94396ffc669" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "c11983c6357c1b77a011aaeafde22882bdaa777c65c40dd99d4c48777acf23c0" + "cacheKey": "966012e82a47f8afa17683175087d63b7fe7e191c770f14a62d82366e13ba959" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "2b8c3acda63a9e373a63ac32b693eb0a3b7d1f01d19dc114a71ec88c5c0f2168" + "cacheKey": "5cb2f96b95a2fcd0618db09f8393d8e2bd6a40617cb2b35f57667a482c671739" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "9e932bd478a344cdfdb68da0f300f147b2d9c60bb385cf1ffe7906e315d357a8" + "cacheKey": "2b5174ccf228be9d359389d8c2435f0cd34feebc66e57966558902f72682996d" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "36d99a823cd97e42e1bfd1aefa9b4a0bb79b7879eef2faf1dd9e932445b21d47" + "cacheKey": "94d90e4c611c3ef8174635182f27493ec8f3c832861ae9a2428b1a35736aa22c" }, { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "635e5ed39400e0333d51d16278634afc56c8c17e56be858498bff4886c93fee5" + "cacheKey": "78e404c3a0b1ad280ea8c08a87d9d2d8b69509590da06878f4138ad71fc9d92e" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "edfa7d165b2d2ead001360dbcb3104e965c7aad1a75566500e4a9c7db90c7c08" + "cacheKey": "25d79cdafd592b123a20ce0da5fe2c0571f92d8bdb70270fe06d04e9eb93f5d7" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "73402dbeec78ac534e0ae3f472f887982200c5c9867e4dc8d64d30d760eeaa7c" + "cacheKey": "590f8dae0ef8972831a64291326763a1e9fa3697b144daa99d2d2d13cb91192d" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e" + "cacheKey": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a" } ] }, @@ -1838,29 +1838,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "44f58deb30e411e6e91ef3f77a9ecfe5e52e67a1456a2fba07a8d3115a07e293" + "wasm32": "f4ec4615857967396cfc60a8de0221d49d9d5af58d85b2a844be5d5f5fdadc33" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "0cfd3dca3b9499921d565e03a274c5ebbaff265e6c05e98aa8ef889d2140ccd2" + "cacheKey": "f7abd4cc88bcb4f9bf2e01872fa181c8ac84db6621da3e2c1a291f32c65f27ed" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" + "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "9e932bd478a344cdfdb68da0f300f147b2d9c60bb385cf1ffe7906e315d357a8" + "cacheKey": "2b5174ccf228be9d359389d8c2435f0cd34feebc66e57966558902f72682996d" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "edfa7d165b2d2ead001360dbcb3104e965c7aad1a75566500e4a9c7db90c7c08" + "cacheKey": "25d79cdafd592b123a20ce0da5fe2c0571f92d8bdb70270fe06d04e9eb93f5d7" } ] }, @@ -1880,29 +1880,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "1ade84c0949d68410604abeecc1372019d5cb5be36257a1ea019e8d3eac8554f" + "wasm32": "c17d3a4e332092ef2176fe855387810799527eb2aca0de81f960f6d97cb74cb4" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" + "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "36d99a823cd97e42e1bfd1aefa9b4a0bb79b7879eef2faf1dd9e932445b21d47" + "cacheKey": "94d90e4c611c3ef8174635182f27493ec8f3c832861ae9a2428b1a35736aa22c" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "f01d6d631bafb7a31b034ec33970b67778a7ff9e4af3089685ccb941ea2fce18" + "cacheKey": "5c6935fabfdc6a8cc1ce812a87cf61d19df14a268e9e6ce33dced6e106ccc145" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e" + "cacheKey": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a" } ] }, @@ -1922,39 +1922,39 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b47cf3b19890b4cc2b0127339d5ac15009b78dfc3b491d43e83eb7d4c2165b3a" + "wasm32": "785793c52437194d88f54f62c69bc17713138c18ce34bc7da6afa5b0389632d8" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" + "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" }, { "packageName": "node", "manifestSha256": "2131a24dfda8587860e57fc65b573086477d376b065b3b9ca4e1dfe4d79f5790", - "cacheKey": "1ade84c0949d68410604abeecc1372019d5cb5be36257a1ea019e8d3eac8554f" + "cacheKey": "c17d3a4e332092ef2176fe855387810799527eb2aca0de81f960f6d97cb74cb4" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "36d99a823cd97e42e1bfd1aefa9b4a0bb79b7879eef2faf1dd9e932445b21d47" + "cacheKey": "94d90e4c611c3ef8174635182f27493ec8f3c832861ae9a2428b1a35736aa22c" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "edfa7d165b2d2ead001360dbcb3104e965c7aad1a75566500e4a9c7db90c7c08" + "cacheKey": "25d79cdafd592b123a20ce0da5fe2c0571f92d8bdb70270fe06d04e9eb93f5d7" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "f01d6d631bafb7a31b034ec33970b67778a7ff9e4af3089685ccb941ea2fce18" + "cacheKey": "5c6935fabfdc6a8cc1ce812a87cf61d19df14a268e9e6ce33dced6e106ccc145" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e" + "cacheKey": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a" } ] }, @@ -1974,7 +1974,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "3fe8bfa8774d6b5f5c2b80013c6495d152b689b52d42cd78f0a394a212c782c8" + "wasm32": "20e34f8efc078aa6a8d7c1d23a186102bb8a3ad6a1e98cd0adb09cb1b6183045" }, "dependencyClosures": { "wasm32": [] @@ -1995,14 +1995,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "869931ebd15307cf07c27e09776891ab348bb5410aeba23385b15ac8617a3af5" + "wasm32": "c0c99b86a9b34f649f7ecb4eb07caa16ee505c1ae292ee57a510a785a18da25d" }, "dependencyClosures": { "wasm32": [ { "packageName": "perl", "manifestSha256": "6cdc4dbc54d0e4008cff41f82ec8918c0e44b4aef23903539c1bc0f538fe3ad2", - "cacheKey": "3fe8bfa8774d6b5f5c2b80013c6495d152b689b52d42cd78f0a394a212c782c8" + "cacheKey": "20e34f8efc078aa6a8d7c1d23a186102bb8a3ad6a1e98cd0adb09cb1b6183045" } ] }, @@ -2022,54 +2022,54 @@ "wasm32" ], "cacheKeys": { - "wasm32": "635e5ed39400e0333d51d16278634afc56c8c17e56be858498bff4886c93fee5" + "wasm32": "78e404c3a0b1ad280ea8c08a87d9d2d8b69509590da06878f4138ad71fc9d92e" }, "dependencyClosures": { "wasm32": [ { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "3fd9598c6138054150f5d457a0b0f62d44ab0920c2a5ea1419bbbd16f2cfd4ec" + "cacheKey": "bf454728f58a75797a9d52c8c6d92c6a52c67c377be31c0253fb2a18883556d6" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "65727e0e1a26b1faaea4e0f0fbddce5e5a44012df69dcda1bb7e3e8626487268" + "cacheKey": "2a54294aa012c4a47fb5b540f49dacacd83725735c4db8ba3746fc4177146a6a" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" + "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "6aa4acaad6784b9152ec957d4e2e8da31db5c0b610414110f1ddfc5623772f71" + "cacheKey": "9824449919005d0862b1f40b310195b2bf5657733469a3b1aac4b94396ffc669" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "c11983c6357c1b77a011aaeafde22882bdaa777c65c40dd99d4c48777acf23c0" + "cacheKey": "966012e82a47f8afa17683175087d63b7fe7e191c770f14a62d82366e13ba959" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "2b8c3acda63a9e373a63ac32b693eb0a3b7d1f01d19dc114a71ec88c5c0f2168" + "cacheKey": "5cb2f96b95a2fcd0618db09f8393d8e2bd6a40617cb2b35f57667a482c671739" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "36d99a823cd97e42e1bfd1aefa9b4a0bb79b7879eef2faf1dd9e932445b21d47" + "cacheKey": "94d90e4c611c3ef8174635182f27493ec8f3c832861ae9a2428b1a35736aa22c" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "73402dbeec78ac534e0ae3f472f887982200c5c9867e4dc8d64d30d760eeaa7c" + "cacheKey": "590f8dae0ef8972831a64291326763a1e9fa3697b144daa99d2d2d13cb91192d" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e" + "cacheKey": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a" } ] }, @@ -2145,7 +2145,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "dc7e6124ef4a64df36ade1ea208b7e2fd1f0929773482f92e93f7063eeeac5fb" + "wasm32": "91eedb0fa500deef8ef48023d799915f49b9fe9427c610bc06f46afa9847c60a" }, "dependencyClosures": { "wasm32": [] @@ -2418,19 +2418,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "6499248529a342f180c4ee2d1bebdbbe878a177b2d50574c8c93ec24575ab0d0" + "wasm32": "edc1345ac416c098b008479c66219615f00e2c5915bf9229ddb26acbac50ed1e" }, "dependencyClosures": { "wasm32": [ { "packageName": "cpython", "manifestSha256": "7dd4f446697a73941ec940c2ccba4d53be73fe6947f1e701031a4d0aa964c4ff", - "cacheKey": "2b100dd2bfbb68d67c935fa9de93e1e82721f50d2040a7a1d22cfa2656053884" + "cacheKey": "8e36bc988b0f8fa0130bf7696b73a81b018e8704acc66005431b25ace7fbe048" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e" + "cacheKey": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a" } ] }, @@ -2450,7 +2450,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "de47bd67078bc531a4218667ed729408d85fcc26a6b36b144c65e0303c54f34d" + "wasm32": "11d2132bfe4524b34e4acd17133601f861afdaae5dcd114285f415a9eeb50471" }, "dependencyClosures": { "wasm32": [] @@ -2478,24 +2478,24 @@ "wasm32" ], "cacheKeys": { - "wasm32": "176cc998ce64ad14f5f99456093ac275bc23f49c539655f9a62e89bcdcc83c91" + "wasm32": "958d9b9270a57e643af8cb6d7f115f9a553af95f56a674d2f89080e8dc991562" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "0cfd3dca3b9499921d565e03a274c5ebbaff265e6c05e98aa8ef889d2140ccd2" + "cacheKey": "f7abd4cc88bcb4f9bf2e01872fa181c8ac84db6621da3e2c1a291f32c65f27ed" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" + "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" }, { "packageName": "redis", "manifestSha256": "151fb507de953ba2ba94b8f881bc7d66b4c741c1359a2f590cc07f9a4cd368a3", - "cacheKey": "de47bd67078bc531a4218667ed729408d85fcc26a6b36b144c65e0303c54f34d" + "cacheKey": "11d2132bfe4524b34e4acd17133601f861afdaae5dcd114285f415a9eeb50471" } ] }, @@ -2515,79 +2515,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "d4ed5891e3146cfe2a3708a135666f91112170e47e3ef0f182031b7830d9f124" + "wasm32": "cb56c70a95c61d714cb6c525a567b80c7f8d7fb2b6f2056910b58d9243a857c7" }, "dependencyClosures": { "wasm32": [ { "packageName": "bash", "manifestSha256": "6478060f28d430d18a6ebe7c603392d35a41d9bcf6bc74322351d1450b9c5335", - "cacheKey": "2d577fa298afea12b9245e372d0e8712bb166f5d759f612f86c7b658367268ff" + "cacheKey": "9cac1217bd7951530fee52b9a19feb039be16c997c87b76d1b949cafa6154bbe" }, { "packageName": "bc", "manifestSha256": "a65661463bb7047b91ff00153bd99fd962c96e571934ab4e923f5215fb6ecfbd", - "cacheKey": "f95f8a79fd939755345af1acccdec0b21f82def30156d8bc1bfeca2ee9f4b57b" + "cacheKey": "6833fd5275f1378837506fbbcd3db757a19d4ac6ea9081212bdc4150a64408f0" }, { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "9d4a7186e54db60edf09a2aba993d4f1d4660dcd37f5f280581941d09e4fb054" + "cacheKey": "ffbcd2c6b9df24acb01de4da66c4167c07556aac83a2891ae182a934bb9ceeea" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "ed8a5db2cd63484a368e0181b72f177d072a64bf4b053f62fae128bb5e8915d3" + "cacheKey": "ff2bca584a197d92da417221bcd6a92171140b030ade50108eaa244905bf88f0" }, { "packageName": "diffutils", "manifestSha256": "3a78f0a46bae43ce5ea235c6638c2cbd1d8559b0b62b1fb42443b35968c1aca3", - "cacheKey": "247e25a24952124aa930b6b0bb5092fa2c74653e52bb2e928a049ebf109ff9fb" + "cacheKey": "688e551eb9e8c698148a45d1c59efb2bf13120d54dc9516289395d2b73a7ff9e" }, { "packageName": "file", "manifestSha256": "7874c1affcbbf2c8ab8c8d4beb087f275af57b5caae6a8947bf5a63a181552b2", - "cacheKey": "4677df78cfe25250903279ae481372f6985aa54a9f85682499d0ed2a94408eaa" + "cacheKey": "306f637580638cf8c248f3cf9b15469d526030020ff04820ce60bd188b54ef80" }, { "packageName": "findutils", "manifestSha256": "cceedf52aea67fbb0da03cfce6f2e9b1a7b5561fa1656c2762b43c651a625d02", - "cacheKey": "adcd350e6f8977e86330c59b57f52b11524143c02dcd4a18a2447e5f635c88e7" + "cacheKey": "96312ac7e9087405539527a9c1e119db733e0f46395b9ecadb30c77f3eb83057" }, { "packageName": "gawk", "manifestSha256": "a2567b8b0778805e385e1d3a41ac8b5d74aaf9d293b222001b17d09b11e5a0ba", - "cacheKey": "915c98a7739cbeefb8b4ed326e62effaf0c376c0d187141254527c08eae134e3" + "cacheKey": "ea31e40a17bccfd311032d3e052f3a6707745b000f8303be9e72072628a1572a" }, { "packageName": "grep", "manifestSha256": "59270d3bfb33167b32d13246bf855b49308f8c9c0a66d9635c804f702421fbc6", - "cacheKey": "a0c03526e72d0581fde4e5ef55a4e5f38a8047e2d93501aa56db6aefff6a33ba" + "cacheKey": "68e8a6c294322d4f8bfc42c146bb4be5c47ff72b3fcf76e7423061a0117a29a2" }, { "packageName": "m4", "manifestSha256": "2c6582d99d6eabfb9da52badf49b899e9fdcba76a728536b25bc3741f3331543", - "cacheKey": "b887bbc00ed62a5228e955593dcc4a31d98e8fe71972d59971e32bd76b7ad536" + "cacheKey": "9413a8cb9d531fab82b389c7172bf1d59e3feefb5adcda8504d26934846f095e" }, { "packageName": "make", "manifestSha256": "f878d2d730f36a4c6dfe1fff1b4ccc704757ea22d8c4c8ccb95b11cd641e5fed", - "cacheKey": "de30b90416e0a0d62ada5726860e8e7cefe891eb5ab51d8b26f18f50e0e06fe5" + "cacheKey": "a95e8bfb3c1d51b81b6de917d410ab6677c2a2c25c1d5301654c796e331e47d9" }, { "packageName": "ncurses", "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "0fa705045e9b3026abaf8e26a8e3c38d5486a4ad9a967752458768a442e32e0f" + "cacheKey": "9c773c442f97096978216d373d9c7c5d19c8a4a7b26aae0c4bc02b1c76cca382" }, { "packageName": "posix-utils-lite", "manifestSha256": "8fd7190b2848ef80143adc7b9268c79e13cd4db3430f7105faf49a4b4407a06f", - "cacheKey": "dc7e6124ef4a64df36ade1ea208b7e2fd1f0929773482f92e93f7063eeeac5fb" + "cacheKey": "91eedb0fa500deef8ef48023d799915f49b9fe9427c610bc06f46afa9847c60a" }, { "packageName": "sed", "manifestSha256": "2a08e9c5dacc5facc8983c1ff35ff2a72db328405e9a1f464cc7ad155c4d08af", - "cacheKey": "2daf8e8139e879d4294b143e0c24c89d67eeacf5ff3c45cfaee97b812d58fc44" + "cacheKey": "968b56b98643a7cb8a7e11101ebb352c71044fbdccec6aac88768095e38b077b" } ] }, @@ -2607,14 +2607,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "8c4c3aadd4a06e761a018c6738e0eed5b3f113e2b648959aa6167511c39b0757" + "wasm32": "c990f064060821a8e076633a227c3e5cc1dc3e8c63710eb8121b0cc90fa7b1ae" }, "dependencyClosures": { "wasm32": [ { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e" + "cacheKey": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a" } ] }, @@ -2641,19 +2641,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "649d96fbae5f45cd1d440f148b9ac042bfe1ff752f6f0e20a67bbaf812d7dce7" + "wasm32": "91be68f6091b7033131e63c8a28f747d9d51302ce511e6c52632d4918e068699" }, "dependencyClosures": { "wasm32": [ { "packageName": "sdl2", "manifestSha256": "327ce0acca768f51e36ddab8ab58fd57b24cfd9e40722e818103c439f7263dd7", - "cacheKey": "8a1705083bca64cf5ffa73cd0c90e2dfa52d1eae9b9f872b36b4789bf56cb446" + "cacheKey": "1b990666208f36b3a4ac2521ed3a39e8227779b2e2fa21600b7f372f616d0621" }, { "packageName": "sdl3", "manifestSha256": "3b7c64605b941e14d336b1a127d8219c50402dece009e8855297a0a0696bd67e", - "cacheKey": "4302058a87376e2fd2d7e9f2bf1713e8bccf00016f6e17b77ca21169662f897f" + "cacheKey": "41b437b4383f8c9d419b39c1b55963ac5b446705216a6029d39089718d28bbcf" } ] }, @@ -2680,14 +2680,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "8942d37250b17a645ef33e37e4920af34f1e9f78cf337fe7a7b49b51e589c76b" + "wasm32": "b356d169ae065b0511ab6a7bfed157eb764f8137741d3192113168cf74988ce9" }, "dependencyClosures": { "wasm32": [ { "packageName": "sdl2", "manifestSha256": "327ce0acca768f51e36ddab8ab58fd57b24cfd9e40722e818103c439f7263dd7", - "cacheKey": "8a1705083bca64cf5ffa73cd0c90e2dfa52d1eae9b9f872b36b4789bf56cb446" + "cacheKey": "1b990666208f36b3a4ac2521ed3a39e8227779b2e2fa21600b7f372f616d0621" } ] }, @@ -2707,7 +2707,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2daf8e8139e879d4294b143e0c24c89d67eeacf5ff3c45cfaee97b812d58fc44" + "wasm32": "968b56b98643a7cb8a7e11101ebb352c71044fbdccec6aac88768095e38b077b" }, "dependencyClosures": { "wasm32": [] @@ -2728,7 +2728,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "edfa7d165b2d2ead001360dbcb3104e965c7aad1a75566500e4a9c7db90c7c08" + "wasm32": "25d79cdafd592b123a20ce0da5fe2c0571f92d8bdb70270fe06d04e9eb93f5d7" }, "dependencyClosures": { "wasm32": [] @@ -2749,24 +2749,24 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f01d6d631bafb7a31b034ec33970b67778a7ff9e4af3089685ccb941ea2fce18" + "wasm32": "5c6935fabfdc6a8cc1ce812a87cf61d19df14a268e9e6ce33dced6e106ccc145" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" + "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "36d99a823cd97e42e1bfd1aefa9b4a0bb79b7879eef2faf1dd9e932445b21d47" + "cacheKey": "94d90e4c611c3ef8174635182f27493ec8f3c832861ae9a2428b1a35736aa22c" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e" + "cacheKey": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a" } ] }, @@ -2786,29 +2786,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b01e36db0587980ec1c1318bdcd39519166b044f3c2b5bc8c135febff91257d4" + "wasm32": "6deb4a4d5d3dba8f7f135b0e18d71ca30b144e58f871f9578c993aedb3dc9c59" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" + "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "36d99a823cd97e42e1bfd1aefa9b4a0bb79b7879eef2faf1dd9e932445b21d47" + "cacheKey": "94d90e4c611c3ef8174635182f27493ec8f3c832861ae9a2428b1a35736aa22c" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "f01d6d631bafb7a31b034ec33970b67778a7ff9e4af3089685ccb941ea2fce18" + "cacheKey": "5c6935fabfdc6a8cc1ce812a87cf61d19df14a268e9e6ce33dced6e106ccc145" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e" + "cacheKey": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a" } ] }, @@ -2828,7 +2828,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "1d2c9a7c6028ae54f6fcfdd24f590d070bcb9d784a83e4a9b663b368f0c845b6" + "wasm32": "78464a56a06dee769c41598f9f6922ade013ac0e8fddd86a52cd7bda70460dd9" }, "dependencyClosures": { "wasm32": [] @@ -2849,7 +2849,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e6ee4090d0aabc015554e1b5d4b337ec7f26cc465a8f32c007495ccd2c37e453" + "wasm32": "b9a0578b89fddf546f542eac3a63026f1f80a69071c13c029a9f54e7feefbeb1" }, "dependencyClosures": { "wasm32": [] @@ -2870,7 +2870,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "16fbf3be0917c4cf47561820538c4065276df77b9373904c032986976ebc8e3d" + "wasm32": "2feeb5a979a7b348ec5cd5e33a019ce766b6885be05e5079846a7e5fc2844cb3" }, "dependencyClosures": { "wasm32": [] @@ -2891,19 +2891,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e1092a98c2200f9ca0a1dbd4eba7cfc78518a37863ca98df71e7265a273c4227" + "wasm32": "00bca12d8baefcfdda858a5f6f2ca58c462fec39b14631060fa06a2257cb1f57" }, "dependencyClosures": { "wasm32": [ { "packageName": "libpng", "manifestSha256": "79ed5c5c072a0267cce5c7a2fe37d8494571b17e44b952f619e04ec1c4a9db10", - "cacheKey": "2ab54d9a91761c737ce35bc700d809edbb1a575f5365372a1cc3fb3e10977742" + "cacheKey": "9b8ebcc1b638d6821ead77d1a2beffdb8266c94cb1f9346b2131f1dff134d0d4" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e" + "cacheKey": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a" } ] }, @@ -2930,7 +2930,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ec8dad55b6005ca98236e500622ec77ca0a8d44693df24eb66f5f6c62b706dd6" + "wasm32": "0e95e74674fa43f3d24c8b3457dc1a4ff9631154c71b86df537cb28661a67caf" }, "dependencyClosures": { "wasm32": [] @@ -2951,7 +2951,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "5b84470427b17fc94f6e4803bef63d23fe317fc5d3af73c035b642ee48fbffdc" + "wasm32": "a75b0e135031cbac781aa636f5de6d6d1411d1c41f22021bc1a9a594fc78a35a" }, "dependencyClosures": { "wasm32": [] @@ -2972,14 +2972,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "abd41342ceaf4212159653327c03680599b9bd53fdd4477c57c0131c7dc1bbd3" + "wasm32": "56b676a43f6cd4d9a07ad75323d3a7c7306cbd075484457a79569787c37ae188" }, "dependencyClosures": { "wasm32": [ { "packageName": "vim", "manifestSha256": "c211660ef41d01f95f3fbdf675b74891e897e3f304e04f1bf5586f326007382c", - "cacheKey": "5b84470427b17fc94f6e4803bef63d23fe317fc5d3af73c035b642ee48fbffdc" + "cacheKey": "a75b0e135031cbac781aa636f5de6d6d1411d1c41f22021bc1a9a594fc78a35a" } ] }, @@ -2999,7 +2999,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "793da9e9a98944ecfffefdfbbaf66eb2437bce78224b3b723c382d46f38fce89" + "wasm32": "98a913f679104d3400c949da7f8bfeeba5f46add578d306a4adf16efb1daffc3" }, "dependencyClosures": { "wasm32": [] @@ -3020,79 +3020,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f62e8e8f07d047b7555eb931cb22109d997250896c38a16823204188aa3a43e5" + "wasm32": "fc621e5bcf2918136f7aea76a5617b3d7ed5e52e0a91f95e0ecaba3819da96f5" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "0cfd3dca3b9499921d565e03a274c5ebbaff265e6c05e98aa8ef889d2140ccd2" + "cacheKey": "f7abd4cc88bcb4f9bf2e01872fa181c8ac84db6621da3e2c1a291f32c65f27ed" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "3fd9598c6138054150f5d457a0b0f62d44ab0920c2a5ea1419bbbd16f2cfd4ec" + "cacheKey": "bf454728f58a75797a9d52c8c6d92c6a52c67c377be31c0253fb2a18883556d6" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "65727e0e1a26b1faaea4e0f0fbddce5e5a44012df69dcda1bb7e3e8626487268" + "cacheKey": "2a54294aa012c4a47fb5b540f49dacacd83725735c4db8ba3746fc4177146a6a" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "0132e42de09517bbc9777591f77c01ff9497f284e3977f499346c7ebb706f8bd" + "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "6aa4acaad6784b9152ec957d4e2e8da31db5c0b610414110f1ddfc5623772f71" + "cacheKey": "9824449919005d0862b1f40b310195b2bf5657733469a3b1aac4b94396ffc669" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "c11983c6357c1b77a011aaeafde22882bdaa777c65c40dd99d4c48777acf23c0" + "cacheKey": "966012e82a47f8afa17683175087d63b7fe7e191c770f14a62d82366e13ba959" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "2b8c3acda63a9e373a63ac32b693eb0a3b7d1f01d19dc114a71ec88c5c0f2168" + "cacheKey": "5cb2f96b95a2fcd0618db09f8393d8e2bd6a40617cb2b35f57667a482c671739" }, { "packageName": "msmtpd", "manifestSha256": "09de04a422ddf631a29a259d816e081f8d317d1077808ede55d8c500baae748f", - "cacheKey": "e52466139aaf8c185e8f8983b548dac2413ea241af093f7c854ee99325999176" + "cacheKey": "ca4d269e24beb373937cfffa3a5a70be1c26f67b4afa19128d697ad9cdced81f" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "9e932bd478a344cdfdb68da0f300f147b2d9c60bb385cf1ffe7906e315d357a8" + "cacheKey": "2b5174ccf228be9d359389d8c2435f0cd34feebc66e57966558902f72682996d" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "36d99a823cd97e42e1bfd1aefa9b4a0bb79b7879eef2faf1dd9e932445b21d47" + "cacheKey": "94d90e4c611c3ef8174635182f27493ec8f3c832861ae9a2428b1a35736aa22c" }, { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "635e5ed39400e0333d51d16278634afc56c8c17e56be858498bff4886c93fee5" + "cacheKey": "78e404c3a0b1ad280ea8c08a87d9d2d8b69509590da06878f4138ad71fc9d92e" }, { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "edfa7d165b2d2ead001360dbcb3104e965c7aad1a75566500e4a9c7db90c7c08" + "cacheKey": "25d79cdafd592b123a20ce0da5fe2c0571f92d8bdb70270fe06d04e9eb93f5d7" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "73402dbeec78ac534e0ae3f472f887982200c5c9867e4dc8d64d30d760eeaa7c" + "cacheKey": "590f8dae0ef8972831a64291326763a1e9fa3697b144daa99d2d2d13cb91192d" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "667daaee2036fe3d18ac9637a06184a62b3a502c8a17346b303d8913fb73857e" + "cacheKey": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a" } ] }, @@ -3112,7 +3112,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e671549aa3e6fbf95c8d0a3ab365700c7354bf4e4faf4ffd6b0af2fa62485830" + "wasm32": "22f7c6e571abace32661baa8907311894d67f1ddf037fc3d211b15ad7585ac87" }, "dependencyClosures": { "wasm32": [] @@ -3133,7 +3133,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b188c0bf07f2c79821cd474d934c36bcc2a092512dddca33bc3cbe6d68832b5b" + "wasm32": "5c25b080333ffac7a5271e6c17b3c361d325677272a7e7fca7a4b42cddb0ce16" }, "dependencyClosures": { "wasm32": [] @@ -3154,7 +3154,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "bf9867e234e6d73257a275e30917058094135eb4f90c13352df67504acee097c" + "wasm32": "9009660cf423b4f45a88bc99c6b37e5759661a82c64e9c89302b69c219efce49" }, "dependencyClosures": { "wasm32": [] From 54b6a3475b2e7a1b7abaf28c69a95b170596bb81 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Tue, 11 Aug 2026 17:59:31 -0400 Subject: [PATCH 69/82] POSIX: Add real login and sudo-lite programs --- MANIFEST | 4 +- apps/browser-demos/lib/kernel-owned-boot.ts | 2 +- apps/browser-demos/pages/git-test/main.ts | 6 +- .../pages/kandelo/gallery-descriptor.ts | 15 +- .../pages/kandelo/kernel-host/live-setup.ts | 14 +- .../pages/kandelo/panes/Shell.tsx | 2 +- .../test/default-maker-profile.spec.ts | 222 ++++++++++ .../test/kandelo-merge-gate.spec.ts | 10 +- .../test/kandelo-source-rootfs-shell.spec.ts | 2 +- apps/browser-demos/test/sudo-lite.spec.ts | 389 +++++++++++++++++ docs-site/guide/browser-apps.md | 10 +- docs-site/guide/vfs-images.md | 2 +- docs/architecture.md | 11 +- docs/browser-support.md | 4 +- docs/homebrew-publishing.md | 2 +- examples/getpwent_smoke.c | 2 +- examples/run-example.ts | 6 +- homebrew/main-shell-migration-lock.json | 4 +- homebrew/test/homebrew_bootstrap_guest_env.ts | 12 +- .../test/homebrew_guest_lifecycle_browser.ts | 2 +- .../test/homebrew_guest_lifecycle_node.ts | 2 +- .../test/homebrew_guest_lifecycle_runner.ts | 6 +- ...rew_guest_lifecycle_runtime_inputs.test.ts | 4 +- host/src/browser-kernel-worker-entry.ts | 4 +- .../homebrew-runtime-support-materializer.ts | 6 +- host/src/vfs/default-mounts.ts | 4 +- host/test/demo-login-image.test.ts | 154 +++++++ host/test/fixtures/exec-argv.c | 19 + host/test/gallery-descriptor.test.ts | 18 +- host/test/getpwent.test.ts | 11 +- ...ebrew-runtime-support-materializer.test.ts | 2 +- host/test/homebrew-vfs-image-save.test.ts | 4 +- host/test/login.test.ts | 412 ++++++++++++++++++ host/test/node-demo-workspace.test.ts | 80 ++++ host/test/sudo-lite.test.ts | 403 +++++++++++++++++ host/test/vfs/browser-mount-layering.test.ts | 16 +- host/test/vfs/default-mounts.test.ts | 70 ++- images/rootfs/etc/group | 3 +- images/rootfs/etc/motd.autologin | 6 + images/rootfs/etc/passwd | 2 +- images/rootfs/etc/shadow | 2 +- images/rootfs/etc/sudoers | 1 + images/vfs/lib/demo-login.ts | 215 +++++++++ .../vfs/lib/init/spidermonkey-npm-runtime.ts | 15 + .../vfs/scripts/build-homebrew-vfs-image.ts | 4 +- images/vfs/scripts/build-node-vfs-image.ts | 17 +- images/vfs/scripts/dinit-image-helpers.ts | 4 +- images/vfs/scripts/shell-vfs-build.ts | 86 +++- packages/registry/node-vfs/package.toml | 10 +- packages/registry/program-packages.json | 76 ++-- .../test/spidermonkey-node-compat.test.ts | 46 +- programs/login.c | 246 +++++++++++ programs/sudo-lite.c | 290 ++++++++++++ scripts/build-homebrew-bootstrap.sh | 8 +- scripts/build-homebrew-main-shell-closure.sh | 2 +- scripts/build-programs.sh | 8 +- scripts/homebrew-language-runtime-smoke.ts | 2 +- scripts/homebrew-main-shell-node-smoke.ts | 12 +- scripts/source-rootfs-shell-node-smoke.ts | 6 +- .../test/kandelo-session.test.ts | 16 +- 60 files changed, 2814 insertions(+), 199 deletions(-) create mode 100644 apps/browser-demos/test/default-maker-profile.spec.ts create mode 100644 apps/browser-demos/test/sudo-lite.spec.ts create mode 100644 host/test/demo-login-image.test.ts create mode 100644 host/test/fixtures/exec-argv.c create mode 100644 host/test/login.test.ts create mode 100644 host/test/node-demo-workspace.test.ts create mode 100644 host/test/sudo-lite.test.ts create mode 100644 images/rootfs/etc/motd.autologin create mode 100644 images/rootfs/etc/sudoers create mode 100644 images/vfs/lib/demo-login.ts create mode 100644 programs/login.c create mode 100644 programs/sudo-lite.c diff --git a/MANIFEST b/MANIFEST index 5040fa93c7..668de8bd40 100644 --- a/MANIFEST +++ b/MANIFEST @@ -36,7 +36,7 @@ /etc/ssl/certs d 0755 0 0 # ── User home ────────────────────────────────────────────────────── -/home/user d 0755 1000 1000 +/home/maker d 0755 1000 1000 # ── /etc content (source: images/rootfs/etc/*) ──────────────────────────── # passwd/group/hosts live in rootfs.vfs, which is the source of truth @@ -53,6 +53,8 @@ /etc/os-release f 0644 0 0 /etc/profile f 0644 0 0 /etc/motd f 0644 0 0 +/etc/motd.autologin f 0644 0 0 +/etc/sudoers f 0440 0 0 # The rootfs file is the authoritative services database. Rootfs-derived # images inherit it rather than rebuilding or synthesizing a second table. /etc/services f 0644 0 0 diff --git a/apps/browser-demos/lib/kernel-owned-boot.ts b/apps/browser-demos/lib/kernel-owned-boot.ts index 99be8c9746..dbfc8dc137 100644 --- a/apps/browser-demos/lib/kernel-owned-boot.ts +++ b/apps/browser-demos/lib/kernel-owned-boot.ts @@ -82,7 +82,7 @@ export async function finalizeKernelOwnedImage(buildFs: MemoryFileSystem): Promi } /** Create a fresh, empty build-time MemoryFileSystem for assembling an image - * that the kernel worker will own. Scratch mounts (/tmp, /var, /home/user, …) + * that the kernel worker will own. Scratch mounts (/tmp, /var, /home/maker, …) * are provided worker-side, so only the image's `/` content (e.g. /etc, /bin) * needs to live here. */ export function createEmptyBuildFs(maxByteLength = 64 * 1024 * 1024): MemoryFileSystem { diff --git a/apps/browser-demos/pages/git-test/main.ts b/apps/browser-demos/pages/git-test/main.ts index dbfd5fb653..c5984abb14 100644 --- a/apps/browser-demos/pages/git-test/main.ts +++ b/apps/browser-demos/pages/git-test/main.ts @@ -30,7 +30,7 @@ let gitBytes: ArrayBuffer | null = null; let gitRemoteHttpBytes: ArrayBuffer | null = null; const DEMO_UID = 1000; const DEMO_GID = 1000; -const DEMO_HOME = "/home/user"; +const DEMO_HOME = "/home/maker"; /** Write a binary file to the virtual filesystem. */ function writeFileToFs( @@ -122,8 +122,8 @@ async function init() { "GIT_CONFIG_VALUE_3=main", `GIT_EXEC_PATH=${gitExecPath}`, `HOME=${DEMO_HOME}`, - "USER=user", - "LOGNAME=user", + "USER=maker", + "LOGNAME=maker", "TMPDIR=/tmp", ]; diff --git a/apps/browser-demos/pages/kandelo/gallery-descriptor.ts b/apps/browser-demos/pages/kandelo/gallery-descriptor.ts index eb21886628..b775c0e988 100644 --- a/apps/browser-demos/pages/kandelo/gallery-descriptor.ts +++ b/apps/browser-demos/pages/kandelo/gallery-descriptor.ts @@ -21,9 +21,14 @@ export function descriptorFromGalleryItem( // into Shell (or root service settings into user sessions). The live host // merges these identity overrides onto the selected profile's canonical // environment. - const userEnv = nodeBoot - ? { HOME: "/work", PWD: "/work", USER: "user", LOGNAME: "user" } - : { HOME: "/home/user", USER: "user", LOGNAME: "user" }; + const makerEnv = nodeBoot + ? { + HOME: "/home/maker", + PWD: "/home/maker", + USER: "maker", + LOGNAME: "maker", + } + : { HOME: "/home/maker", USER: "maker", LOGNAME: "maker" }; const rootEnv = { HOME: "/root", USER: "root", LOGNAME: "root" }; return { ...base, @@ -34,8 +39,8 @@ export function descriptorFromGalleryItem( boot: { ...base.boot, argv: item.bootCommand, - cwd: rootBoot ? "/root" : nodeBoot ? "/work" : "/home/user", - env: rootBoot ? rootEnv : userEnv, + cwd: rootBoot ? "/root" : "/home/maker", + env: rootBoot ? rootEnv : makerEnv, uid: rootBoot ? 0 : 1000, gid: rootBoot ? 0 : 1000, }, diff --git a/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts b/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts index 15819f6da5..4bce8e8d09 100644 --- a/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts +++ b/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts @@ -263,9 +263,8 @@ const MYSQL_UID = 101; const MYSQL_GID = 101; const DEMO_UID = 1000; const DEMO_GID = 1000; -const DEMO_USER = "user"; -const DEMO_HOME = "/home/user"; -const NODE_WORKDIR = "/work"; +const DEMO_USER = "maker"; +const DEMO_HOME = "/home/maker"; const DINITCTL_PATH = "/sbin/dinitctl"; const DINITCTL_SOCKET_PATH = "/tmp/dinitctl"; const DINIT_STARTING_POLL_INTERVAL_MS = 2_000; @@ -574,8 +573,8 @@ const SHELL_ENV: string[] = [ ]; const NODE_SHELL_ENV: string[] = [ - `HOME=${NODE_WORKDIR}`, - `PWD=${NODE_WORKDIR}`, + `HOME=${DEMO_HOME}`, + `PWD=${DEMO_HOME}`, "TMPDIR=/tmp", "TERM=xterm-256color", "LANG=en_US.UTF-8", @@ -583,7 +582,7 @@ const NODE_SHELL_ENV: string[] = [ `USER=${DEMO_USER}`, `LOGNAME=${DEMO_USER}`, "PS1=spidermonkey-node$ ", - `HISTFILE=${NODE_WORKDIR}/.bash_history`, + `HISTFILE=${DEMO_HOME}/.bash_history`, "SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt", "SSL_CERT_DIR=/etc/ssl/certs", "npm_config_cache=/tmp/.npm-cache", @@ -611,7 +610,7 @@ const SERVICE_ENV: string[] = [ const SHELL_PROFILES: Record = { default: { env: SHELL_ENV, cwd: DEMO_HOME }, - node: { env: NODE_SHELL_ENV, cwd: NODE_WORKDIR }, + node: { env: NODE_SHELL_ENV, cwd: DEMO_HOME }, }; const INIT_ENV_PROFILES: Record string[]> = { @@ -1850,7 +1849,6 @@ function ensureDemoHomes(fs: MemoryFileSystem): void { ensureDirRecursive(fs, "/home"); ensureOwnedDir(fs, DEMO_HOME, 0o755, DEMO_UID, DEMO_GID); ensureOwnedDir(fs, ROOT_HOME, 0o700, ROOT_UID, ROOT_GID); - ensureOwnedDir(fs, NODE_WORKDIR, 0o755, DEMO_UID, DEMO_GID); } function ensureOwnedDir( diff --git a/apps/browser-demos/pages/kandelo/panes/Shell.tsx b/apps/browser-demos/pages/kandelo/panes/Shell.tsx index da8dc165b9..fe83135f15 100644 --- a/apps/browser-demos/pages/kandelo/panes/Shell.tsx +++ b/apps/browser-demos/pages/kandelo/panes/Shell.tsx @@ -252,7 +252,7 @@ const PreBoot: React.FC<{ status: string }> = ({ status }) => ( | | | | | image: b3:9f2a3b81d2c47f1e |__|_|_|__| Waiting for the kernel to reach 'running'.`} - user@kandelo + maker@kandelo :~$ diff --git a/apps/browser-demos/test/default-maker-profile.spec.ts b/apps/browser-demos/test/default-maker-profile.spec.ts new file mode 100644 index 0000000000..abaf18a3a1 --- /dev/null +++ b/apps/browser-demos/test/default-maker-profile.spec.ts @@ -0,0 +1,222 @@ +import { resolve } from "node:path"; +import { expect, test } from "@playwright/test"; + +const repoRoot = resolve(import.meta.dirname, "../../.."); +const galleryDescriptorModule = resolve( + repoRoot, + "apps/browser-demos/pages/kandelo/gallery-descriptor.ts", +); +const defaultMountsModule = resolve(repoRoot, "host/src/vfs/default-mounts.ts"); +const imageHelpersModule = resolve(repoRoot, "host/src/vfs/image-helpers.ts"); +const memoryFsModule = resolve(repoRoot, "host/src/vfs/memory-fs.ts"); +const npmRuntimeModule = resolve( + repoRoot, + "images/vfs/lib/init/spidermonkey-npm-runtime.ts", +); +const timeModule = resolve(repoRoot, "host/src/vfs/time.ts"); +const vfsModule = resolve(repoRoot, "host/src/vfs/vfs.ts"); + +test("default browser profiles use the writable canonical maker home", async ({ + page, + baseURL, +}) => { + expect(baseURL).toBeTruthy(); + const asViteFsUrl = (path: string) => new URL(`/@fs${path}`, baseURL).href; + await page.goto(new URL("/trap-signal-test.html", baseURL).href); + + const result = await page.evaluate( + async ({ + galleryUrl, + imageHelpersUrl, + mountsUrl, + memoryFsUrl, + npmRuntimeUrl, + timeUrl, + vfsUrl, + }) => { + const { descriptorFromGalleryItem } = await import( + /* @vite-ignore */ galleryUrl + ); + const { DEFAULT_MOUNT_SPEC, resolveForBrowser } = await import( + /* @vite-ignore */ mountsUrl + ); + const { ensureDirRecursive, writeVfsFile } = await import( + /* @vite-ignore */ imageHelpersUrl + ); + const { MemoryFileSystem } = await import(/* @vite-ignore */ memoryFsUrl); + const { + NODE_WORKSPACE_PROFILE, + NODE_WORKSPACE_PROFILE_PATH, + stageSpiderMonkeyNpmRuntime, + } = await import( + /* @vite-ignore */ npmRuntimeUrl + ); + const { BrowserTimeProvider } = await import(/* @vite-ignore */ timeUrl); + const { VirtualPlatformIO } = await import(/* @vite-ignore */ vfsUrl); + + const root = MemoryFileSystem.create( + new SharedArrayBuffer(2 * 1024 * 1024), + ); + root.mkdir("/etc", 0o755); + const group = new TextEncoder().encode( + "root:x:0:\nnogroup:x:65534:\nnobody:x:65534:\n", + ); + const groupFd = root.open("/etc/group", 0x241, 0o644); + root.write(groupFd, group, null, group.length); + root.close(groupFd); + const image = await root.saveImage(); + const scratchSabBytes = Object.fromEntries( + DEFAULT_MOUNT_SPEC.filter( + (mount: { source: string }) => mount.source === "scratch", + ).map((mount: { path: string }) => [mount.path, 256 * 1024]), + ); + const mounts = await resolveForBrowser(DEFAULT_MOUNT_SPEC, image, { + scratchSabBytes, + }); + const io = new VirtualPlatformIO(mounts, new BrowserTimeProvider()); + const data = new TextEncoder().encode("maker browser profile"); + const fd = io.open("/home/maker/profile.txt", 0x241, 0o644); + io.write(fd, data, null, data.length); + io.close(fd); + const readFd = io.open("/home/maker/profile.txt", 0, 0); + const actual = new Uint8Array(64); + const length = io.read(readFd, actual, null, actual.length); + io.close(readFd); + + const base = { + version: 1, + id: "stale", + title: "Stale", + base: "kandelo:shell@abi43", + runtime: { + arch: "wasm32", + kernel: "kernel@local", + memoryPages: 2048, + features: ["shared-array-buffer", "pty"], + time: "real", + }, + packages: [], + mounts: [{ path: "/", source: "image", ref: "rootfs@local" }], + boot: { + argv: ["stale"], + cwd: "/stale", + env: { HOME: "/stale", USER: "stale", LOGNAME: "stale" }, + uid: 42, + gid: 42, + }, + }; + const shell = descriptorFromGalleryItem( + { + id: "shell", + title: "Shell", + description: "Shell", + bootCommand: ["bash", "-l", "-i"], + packages: [], + }, + base, + ); + const node = descriptorFromGalleryItem( + { + id: "node", + title: "Node", + description: "Node", + bootCommand: ["bash", "-l", "-i"], + packages: [], + }, + base, + ); + + const nodeFs = MemoryFileSystem.create( + new SharedArrayBuffer(4 * 1024 * 1024), + ); + for (const path of [ + "/usr/local/lib/npm/lib/utils/display.js", + "/usr/local/lib/npm/lib/commands/token.js", + "/usr/local/lib/npm/node_modules/cacache/lib/entry-index.js", + "/usr/local/lib/npm/node_modules/cacache/lib/verify.js", + ]) { + ensureDirRecursive(nodeFs, path.slice(0, path.lastIndexOf("/"))); + writeVfsFile(nodeFs, path, "", 0o644); + } + stageSpiderMonkeyNpmRuntime(nodeFs); + const profileStat = nodeFs.stat(NODE_WORKSPACE_PROFILE_PATH); + const profileFd = nodeFs.open(NODE_WORKSPACE_PROFILE_PATH, 0, 0); + const profileBytes = new Uint8Array(profileStat.size); + const profileLength = nodeFs.read( + profileFd, + profileBytes, + null, + profileBytes.length, + ); + nodeFs.close(profileFd); + let imageSeedsPackage = true; + try { + nodeFs.stat("/home/maker/package.json"); + } catch { + imageSeedsPackage = false; + } + let workExists = true; + try { + nodeFs.stat("/work"); + } catch { + workExists = false; + } + + const homeMount = mounts.find( + (mount: { mountPoint: string }) => mount.mountPoint === "/home/maker", + ); + return { + data: new TextDecoder().decode(actual.subarray(0, length)), + homeUid: homeMount?.backend.stat("/").uid, + homeGid: homeMount?.backend.stat("/").gid, + nodeWorkspaceProfile: new TextDecoder().decode( + profileBytes.subarray(0, profileLength), + ), + expectedNodeWorkspaceProfile: NODE_WORKSPACE_PROFILE, + imageSeedsPackage, + workExists, + shell: shell.boot, + node: node.boot, + }; + }, + { + galleryUrl: asViteFsUrl(galleryDescriptorModule), + imageHelpersUrl: asViteFsUrl(imageHelpersModule), + mountsUrl: asViteFsUrl(defaultMountsModule), + memoryFsUrl: asViteFsUrl(memoryFsModule), + npmRuntimeUrl: asViteFsUrl(npmRuntimeModule), + timeUrl: asViteFsUrl(timeModule), + vfsUrl: asViteFsUrl(vfsModule), + }, + ); + + expect(result).toEqual({ + data: "maker browser profile", + homeUid: 1000, + homeGid: 1000, + nodeWorkspaceProfile: expect.any(String), + expectedNodeWorkspaceProfile: expect.any(String), + imageSeedsPackage: false, + workExists: false, + shell: { + argv: ["bash", "-l", "-i"], + cwd: "/home/maker", + env: { HOME: "/home/maker", USER: "maker", LOGNAME: "maker" }, + uid: 1000, + gid: 1000, + }, + node: { + argv: ["bash", "-l", "-i"], + cwd: "/home/maker", + env: { + HOME: "/home/maker", + PWD: "/home/maker", + USER: "maker", + LOGNAME: "maker", + }, + uid: 1000, + gid: 1000, + }, + }); + expect(result.nodeWorkspaceProfile).toBe(result.expectedNodeWorkspaceProfile); +}); diff --git a/apps/browser-demos/test/kandelo-merge-gate.spec.ts b/apps/browser-demos/test/kandelo-merge-gate.spec.ts index e93da0b904..88532ee269 100644 --- a/apps/browser-demos/test/kandelo-merge-gate.spec.ts +++ b/apps/browser-demos/test/kandelo-merge-gate.spec.ts @@ -244,7 +244,7 @@ test("Kandelo shell demo runs bash, vim, and NetHack", async ({ page }) => { "else\n" + " printf 'KANDELO_BASH_FAIL:%s\\n' \"$PWD\"\n" + "fi", - /KANDELO_BASH_OK:[0-9][^\r\n]*:\/home\/user/, + /KANDELO_BASH_OK:[0-9][^\r\n]*:\/home\/maker/, ); await runGuideScript( page, @@ -311,8 +311,8 @@ test("Kandelo Node.js demo evaluates JavaScript in the terminal", async ({ page const nodeContractCommand = [ "node -e \"console.log('KANDELO_NODE_OK:' + (6 * 7))\"", "[ \"$(id -u)\" = 1000 ]", - "[ \"$HOME\" = /work ]", - "[ \"$PWD\" = /work ]", + "[ \"$HOME\" = /home/maker ]", + "[ \"$PWD\" = /home/maker ]", "[ \"$npm_config_cache\" = /tmp/.npm-cache ]", "[ \"$npm_config_registry\" = http://proxy.local/ ]", "spidermonkey-node -e \"console.log('KANDELO_NODE_ALIAS_OK')\"", @@ -355,7 +355,7 @@ test("Kandelo nginx demo serves its web preview", async ({ page }) => { await waitForTerminalContent(page, /kandelo\$ ?/, 120_000); await runTerminalCommand( page, - "set -eu; test \"$(id -u):$HOME:$(pwd)\" = '1000:/home/user:/home/user'; " + + "set -eu; test \"$(id -u):$HOME:$(pwd)\" = '1000:/home/maker:/home/maker'; " + "printf 'KANDELO_NGINX_TERMINAL_OK\\n'", "KANDELO_NGINX_TERMINAL_OK", ); @@ -388,7 +388,7 @@ test("Kandelo nginx + PHP demo serves dynamic PHP through the web preview", asyn await waitForTerminalContent(page, /kandelo\$ ?/, 120_000); await runTerminalCommand( page, - "set -eu; test \"$(id -u):$HOME:$(pwd)\" = '1000:/home/user:/home/user'; " + + "set -eu; test \"$(id -u):$HOME:$(pwd)\" = '1000:/home/maker:/home/maker'; " + "printf 'KANDELO_NGINX_PHP_TERMINAL_OK\\n'", "KANDELO_NGINX_PHP_TERMINAL_OK", ); diff --git a/apps/browser-demos/test/kandelo-source-rootfs-shell.spec.ts b/apps/browser-demos/test/kandelo-source-rootfs-shell.spec.ts index 100f44b428..319c4dc10f 100644 --- a/apps/browser-demos/test/kandelo-source-rootfs-shell.spec.ts +++ b/apps/browser-demos/test/kandelo-source-rootfs-shell.spec.ts @@ -75,7 +75,7 @@ test("the exact source-rootfs product shell runs Bash, Vim, and NetHack", async "else\n" + " printf 'SOURCE_ROOTFS_BASH_FAIL:%s\\n' \"$PWD\"\n" + "fi", - /SOURCE_ROOTFS_BASH_OK:[0-9][^\r\n]*:\/home\/user/, + /SOURCE_ROOTFS_BASH_OK:[0-9][^\r\n]*:\/home\/maker/, ); await runGuideScript( page, diff --git a/apps/browser-demos/test/sudo-lite.spec.ts b/apps/browser-demos/test/sudo-lite.spec.ts new file mode 100644 index 0000000000..a35208590e --- /dev/null +++ b/apps/browser-demos/test/sudo-lite.spec.ts @@ -0,0 +1,389 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "@playwright/test"; +import { + DEMO_LOGIN_PASSWORD, + DEMO_LOGIN_PASSWORD_HASH, +} from "../../../images/vfs/lib/demo-login"; +import { resolveBinary } from "../../../host/src/binary-resolver"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +const browserKernelModulePath = resolve( + repoRoot, + "host/src/browser-kernel-host.ts", +); +const memoryFsModulePath = resolve(repoRoot, "host/src/vfs/memory-fs.ts"); +const privilegedProjectionModulePath = resolve( + repoRoot, + "host/src/vfs/privileged-projection.ts", +); +const shellWasm = resolve(repoRoot, "local-binaries/programs/wasm32/sh.wasm"); +const loginWasm = resolve( + repoRoot, + "local-binaries/test-fixtures/wasm32/login.wasm", +); +const sudoWasm = resolve( + repoRoot, + "local-binaries/test-fixtures/wasm32/sudo-lite.wasm", +); +const credentialsWasm = resolve( + repoRoot, + "examples/initial-credentials-test.wasm", +); +const execArgvSource = resolve(repoRoot, "host/test/fixtures/exec-argv.c"); +const fixtureDir = mkdtempSync(join(tmpdir(), "kandelo-browser-sudo-lite-")); +const execArgvWasm = join(fixtureDir, "exec-argv.wasm"); + +test.beforeAll(() => { + execFileSync("wasm32posix-cc", [execArgvSource, "-o", execArgvWasm], { + cwd: repoRoot, + stdio: "pipe", + }); +}); + +test.afterAll(() => { + rmSync(fixtureDir, { recursive: true, force: true }); +}); + +test("browser login and sudo-lite enforce real guest authentication", async ({ + page, + baseURL, +}) => { + expect(baseURL).toBeTruthy(); + const runtimeErrors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") { + runtimeErrors.push(`console: ${message.text()}`); + } + }); + page.on("pageerror", (error) => { + runtimeErrors.push(`pageerror: ${error.message}`); + }); + await page.route("**/favicon.ico", (route) => route.fulfill({ status: 204 })); + const asViteFsUrl = (path: string) => new URL(`/@fs${path}`, baseURL).href; + await page.goto(new URL("/trap-signal-test.html", baseURL).href); + + const result = await page.evaluate( + async ({ + browserKernelModuleUrl, + memoryFsModuleUrl, + privilegedProjectionModuleUrl, + kernelWasmUrl, + shellWasmUrl, + loginWasmUrl, + sudoWasmUrl, + credentialsWasmUrl, + execArgvBytes, + password, + passwordHash, + }) => { + const { BrowserKernel } = await import( + /* @vite-ignore */ browserKernelModuleUrl + ); + const { MemoryFileSystem } = await import( + /* @vite-ignore */ memoryFsModuleUrl + ); + const { + createReviewedPrivilegedProgramPolicy, + publishPrivilegedProgramProduct, + } = await import(/* @vite-ignore */ privilegedProjectionModuleUrl); + const fetchBytes = async (url: string): Promise => { + const response = await fetch(url); + if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`); + return new Uint8Array(await response.arrayBuffer()); + }; + const [kernelBytes, shell, login, sudo, credentials] = await Promise.all([ + fetchBytes(kernelWasmUrl), + fetchBytes(shellWasmUrl), + fetchBytes(loginWasmUrl), + fetchBytes(sudoWasmUrl), + fetchBytes(credentialsWasmUrl), + ]); + const sha256 = async (bytes: Uint8Array): Promise => + Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + const [loginDigest, sudoDigest] = await Promise.all([ + sha256(login), + sha256(sudo), + ]); + + // Test fixture bytes use the same closed publication API as bottles. + // No fixture is installed in a product resolver or Homebrew path. + const sourceFs = MemoryFileSystem.create( + new SharedArrayBuffer(16 * 1024 * 1024), + ); + sourceFs.createFileWithOwner("/login", 0o755, 1000, 1000, login); + sourceFs.createFileWithOwner("/sudo-lite", 0o755, 1000, 1000, sudo); + sourceFs.createFileWithOwner("/sudo", 0o755, 1000, 1000, sudo); + const sourceRecords = [ + { + formula: "kandelo-test/login", + bottleSha256: "a".repeat(64), + sourcePath: "login", + destinationPath: "/usr/bin/login", + digest: loginDigest, + size: login.byteLength, + }, + { + formula: "kandelo-test/sudo-lite", + bottleSha256: "b".repeat(64), + sourcePath: "sudo-lite", + destinationPath: "/usr/bin/sudo-lite", + digest: sudoDigest, + size: sudo.byteLength, + }, + { + formula: "kandelo-test/sudo", + bottleSha256: "c".repeat(64), + sourcePath: "sudo", + destinationPath: "/usr/bin/sudo", + digest: sudoDigest, + size: sudo.byteLength, + }, + ]; + const policy = createReviewedPrivilegedProgramPolicy( + sourceRecords.map((record) => ({ + schema: 1, + formula: record.formula, + bottleSha256: record.bottleSha256, + sourcePath: record.sourcePath, + destinationPath: record.destinationPath, + uid: 0, + gid: 0, + mode: 0o4755, + mountPoint: "trusted-root-product", + artifactValidationSha256: record.digest, + })), + ); + const privilegedProduct = await publishPrivilegedProgramProduct({ + policy, + sources: sourceRecords.map((record) => ({ + formula: record.formula, + bottleSha256: record.bottleSha256, + fs: sourceFs, + inventory: { + entries: [ + { + sourcePath: record.sourcePath, + type: "file" as const, + size: record.size, + }, + ], + }, + guestPathForSource: (path: string) => `/${path}`, + })), + writableBottleFileSystems: [sourceFs], + }); + + const fs = MemoryFileSystem.create( + new SharedArrayBuffer(16 * 1024 * 1024), + ); + for (const path of [ + "/etc", + "/bin", + "/home", + "/root", + "/usr", + "/usr/bin", + "/var", + ]) { + fs.mkdir(path, 0o755); + } + fs.mkdirWithOwner("/home/maker", 0o755, 1000, 1000); + fs.chmod("/root", 0o700); + const enc = new TextEncoder(); + fs.createFileWithOwner( + "/etc/passwd", + 0o644, + 0, + 0, + enc.encode( + "root:x:0:0:root:/root:/bin/sh\n" + + "maker:x:1000:1000:maker:/home/maker:/bin/sh\n", + ), + ); + fs.createFileWithOwner( + "/etc/shadow", + 0o640, + 0, + 0, + enc.encode( + "root:*:0:0:99999:7:::\n" + `maker:${passwordHash}:0:0:99999:7:::\n`, + ), + ); + fs.createFileWithOwner( + "/etc/group", + 0o644, + 0, + 0, + enc.encode("root:x:0:\nwheel:x:10:maker\nmaker:x:1000:\n"), + ); + fs.createFileWithOwner( + "/etc/nsswitch.conf", + 0o644, + 0, + 0, + enc.encode("passwd: files\ngroup: files\nshadow: files\n"), + ); + fs.createFileWithOwner( + "/etc/sudoers", + 0o440, + 0, + 0, + enc.encode("%wheel ALL=(ALL:ALL) ALL\n"), + ); + fs.createFileWithOwner( + "/etc/motd", + 0o644, + 0, + 0, + enc.encode("Browser login\n"), + ); + fs.createFileWithOwner( + "/etc/motd.autologin", + 0o644, + 0, + 0, + enc.encode("Browser preauthentication\n"), + ); + fs.createFileWithOwner("/bin/sh", 0o755, 0, 0, shell); + fs.createFileWithOwner( + "/bin/initial-credentials", + 0o755, + 0, + 0, + credentials, + ); + const image = await fs.saveImage(); + const kernelWasm = kernelBytes.buffer.slice( + kernelBytes.byteOffset, + kernelBytes.byteOffset + kernelBytes.byteLength, + ); + const launcher = new Uint8Array(execArgvBytes); + + const run = async (passwordAttempt: string) => { + let stdout = ""; + let stderr = ""; + let terminal = ""; + const hostDiagnostics: unknown[] = []; + const stdoutDecoder = new TextDecoder(); + const stderrDecoder = new TextDecoder(); + const terminalDecoder = new TextDecoder(); + const kernel = new BrowserKernel({ + maxWorkers: 4, + onStdout(data: Uint8Array) { + stdout += stdoutDecoder.decode(data, { stream: true }); + }, + onStderr(data: Uint8Array) { + stderr += stderrDecoder.decode(data, { stream: true }); + }, + onHostDiagnostic(diagnostic: unknown) { + hostDiagnostics.push(diagnostic); + }, + }); + try { + await kernel.initFromPublishedPrivilegedProgramProduct({ + kernelWasm, + vfsImage: image, + privilegedProduct, + }); + const exitCode = await Promise.race([ + kernel.spawn( + launcher.slice().buffer, + ["exec-argv", "/usr/bin/login", "-f", "maker"], + { + uid: 0, + gid: 0, + pty: true, + env: ["TERM=xterm-kandelo", "KANDELO_UNTRUSTED=remove-me"], + onStarted(pid: number) { + let sentPassword = false; + let sentExit = false; + kernel.onPtyOutput(pid, (data: Uint8Array) => { + terminal += terminalDecoder.decode(data, { stream: true }); + if ( + !sentPassword && + terminal.includes("[sudo-lite] password for maker: ") + ) { + sentPassword = true; + // sudo changes the terminal with TCSAFLUSH after writing + // its prompt. Model a human response after that flush, + // rather than racing pending input into the flush. + setTimeout(() => { + kernel.ptyWrite( + pid, + enc.encode(`${passwordAttempt}\n`), + ); + }, 100); + } + if ( + !sentExit && + (terminal.includes("uid=0 euid=0 gid=0 egid=0") || + terminal.includes("sudo-lite: authentication failed")) + ) { + sentExit = true; + kernel.ptyWrite(pid, enc.encode("exit\n")); + } + }); + kernel.ptyWrite( + pid, + enc.encode("/usr/bin/sudo-lite /bin/initial-credentials\n"), + ); + }, + }, + ), + new Promise((resolve) => { + setTimeout(() => resolve(-999), 15_000); + }), + ]); + stdout += stdoutDecoder.decode(); + stderr += stderrDecoder.decode(); + terminal += terminalDecoder.decode(); + return { exitCode, stdout, stderr, terminal, hostDiagnostics }; + } finally { + await kernel.destroy().catch(() => {}); + } + }; + + return { + accepted: await run(password), + denied: await run("wrong"), + }; + }, + { + browserKernelModuleUrl: asViteFsUrl(browserKernelModulePath), + memoryFsModuleUrl: asViteFsUrl(memoryFsModulePath), + privilegedProjectionModuleUrl: asViteFsUrl( + privilegedProjectionModulePath, + ), + kernelWasmUrl: asViteFsUrl(resolveBinary("kernel.wasm")), + shellWasmUrl: asViteFsUrl(shellWasm), + loginWasmUrl: asViteFsUrl(loginWasm), + sudoWasmUrl: asViteFsUrl(sudoWasm), + credentialsWasmUrl: asViteFsUrl(credentialsWasm), + execArgvBytes: Array.from(readFileSync(execArgvWasm)), + password: DEMO_LOGIN_PASSWORD, + passwordHash: DEMO_LOGIN_PASSWORD_HASH, + }, + ); + + expect(result.accepted.exitCode, result.accepted.terminal).toBe(0); + expect(result.accepted.terminal).toContain("Browser login\r\n"); + expect(result.accepted.terminal).toContain("Browser preauthentication\r\n"); + expect(result.accepted.terminal).toContain( + "[sudo-lite] password for maker: ", + ); + expect(result.accepted.terminal).toContain("uid=0 euid=0 gid=0 egid=0"); + expect(result.accepted.terminal).not.toContain("remove-me"); + expect(result.accepted.hostDiagnostics).toEqual([]); + + expect(result.denied.exitCode).toBe(1); + expect(result.denied.terminal).toContain("sudo-lite: authentication failed"); + expect(result.denied.terminal).not.toContain("uid=0 euid=0 gid=0 egid=0"); + expect(result.denied.hostDiagnostics).toEqual([]); + expect(runtimeErrors).toEqual([]); +}); diff --git a/docs-site/guide/browser-apps.md b/docs-site/guide/browser-apps.md index d0999c73da..5e23a786b7 100644 --- a/docs-site/guide/browser-apps.md +++ b/docs-site/guide/browser-apps.md @@ -18,7 +18,7 @@ const kernel = new BrowserKernel({ kernelOwnedFs: true }); const { pid, exit } = await kernel.boot({ vfsImage, argv: ["bash", "-l", "-i"], - cwd: "/home/user", + cwd: "/home/maker", uid: 1000, gid: 1000, pty: true, @@ -67,11 +67,11 @@ const kernel = new BrowserKernel({ const { pid, exit } = await kernel.boot({ vfsImage, argv: ["bash", "-l", "-i"], - cwd: "/home/user", + cwd: "/home/maker", env: [ - "HOME=/home/user", - "USER=user", - "LOGNAME=user", + "HOME=/home/maker", + "USER=maker", + "LOGNAME=maker", "TERM=xterm-256color", "LANG=en_US.UTF-8", ], diff --git a/docs-site/guide/vfs-images.md b/docs-site/guide/vfs-images.md index 9c332f3de3..a94ba8e60c 100644 --- a/docs-site/guide/vfs-images.md +++ b/docs-site/guide/vfs-images.md @@ -90,7 +90,7 @@ Example: ```text /etc d 0755 0 0 /home d 0755 0 0 -/home/user d 0755 1000 1000 +/home/maker d 0755 1000 1000 /etc/passwd f 0644 0 0 /bin/sh l 0777 0 0 target=/usr/bin/bash /usr/bin/bash f 0755 0 0 lazy_url=binaries/programs/wasm32/bash.wasm lazy_size=1234567 diff --git a/docs/architecture.md b/docs/architecture.md index 8dbe8b018b..7a2f0615dd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1717,7 +1717,7 @@ operations require a lifecycle-owned backing, not merely a reachable one. | `/var/tmp` | scratch | empty `MemoryFileSystem` SAB | `HostFileSystem` under sessionDir | | `/var/log` | scratch | empty `MemoryFileSystem` SAB | `HostFileSystem` under sessionDir | | `/var/run` | scratch (ephemeral) | empty `MemoryFileSystem` SAB | `HostFileSystem` under sessionDir | -| `/home/user`| scratch | empty `MemoryFileSystem` SAB | `HostFileSystem` under sessionDir | +| `/home/maker` | scratch | empty `MemoryFileSystem` SAB | `HostFileSystem` under sessionDir | | `/root` | scratch | empty `MemoryFileSystem` SAB | `HostFileSystem` under sessionDir | | `/srv` | scratch | empty `MemoryFileSystem` SAB | `HostFileSystem` under sessionDir | @@ -1750,7 +1750,7 @@ mounts it read-only at `/usr/bin` over the ordinary `nosuid` root image. Public boot descriptors, shared URLs, and `initFromImage` have no field that can request this mount or supply its authority. -The browser host layers two additional, host-specific mounts on top: `/dev/shm` (the POSIX-semaphore SAB shared with main-thread surfaces) and `/dev` (`DeviceFileSystem` for `/dev/null`, `/dev/zero`, `/dev/urandom`, `/dev/ptmx`, `/dev/pts/N`). Sticky bits, the uid 1000 owner on `/home/user`, mode `0700` on `/root`, etc. are baked into the rootfs image at build time per the canonical `MANIFEST` and reflected honestly through the `MemoryFileSystem` inode metadata. Scratch mounts on Node start owned by uid/gid 0 because `HostFileSystem` synthesises them. +The browser host layers two additional, host-specific mounts on top: `/dev/shm` (the POSIX-semaphore SAB shared with main-thread surfaces) and `/dev` (`DeviceFileSystem` for `/dev/null`, `/dev/zero`, `/dev/urandom`, `/dev/ptmx`, `/dev/pts/N`). Sticky bits, the uid 1000 owner on `/home/maker`, mode `0700` on `/root`, etc. are baked into the rootfs image at build time per the canonical `MANIFEST` and reflected honestly through the `MemoryFileSystem` inode metadata. Scratch mounts on Node start owned by uid/gid 0 because `HostFileSystem` synthesises them. ### rootfs image as the source of truth @@ -1762,6 +1762,13 @@ default configuration/trust lookup reads the same image bytes that `cat` would. The kernel synthesizes `/etc/mtab` because it reports live mount state; it does not synthesize static `/etc` policy or trust data. +The Task 17 rootfs data defines the canonical interactive image account as +`maker` at uid/gid 1000 with home `/home/maker`. Its password hash, wheel +membership, sudoers policy, and login messages are ordinary rootfs files. +Task 18 must publish reviewed `login` and `sudo-lite` product artifacts through +the privileged-program publication path before that account is product-ready; +the rootfs does not synthesize those executables or a preauthenticated shell. + VFS images can also carry image-level metadata outside the guest file tree. The first declaration is `kernelAbi`, an exact `ABI_VERSION` requirement for images that carry ABI-bound Wasm programs. `MemoryFileSystem.readImageMetadata(image)` reads this declaration without materialising the filesystem, and `MemoryFileSystem.assertImageKernelAbi(image, abi)` validates it for callers that already know the running kernel ABI. Legacy/data-only images may omit the field. ### Node host diff --git a/docs/browser-support.md b/docs/browser-support.md index e4eaac65a2..ed06bb94e1 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -727,8 +727,8 @@ For local browser artifacts, force a rebuild with `./run.sh rebuild `. | Python (legacy opt-in) | `python-vfs.vfs.zst` | `bash packages/registry/python-vfs/build-python-vfs.sh` | ABI-bound CPython interpreter, complete stdlib, license, aliases, and demo metadata | | Erlang (legacy opt-in) | `erlang-vfs.vfs.zst` | `bash packages/registry/erlang-vfs/build-erlang-vfs.sh` | ABI-bound BEAM emulator, relocatable core OTP tree, executable helpers, and boot files | | Perl | `perl.vfs.zst` | `bash images/vfs/scripts/build-perl-vfs-image.sh` | Perl stdlib | -| Shell | `shell.vfs.zst` | `./run.sh build shell-vfs` | platform base plus the complete eager bottle closure selected by `homebrew/main-shell-flat-selection.json`; Bash, Ruby, `brew`, Formula data, profile, shell config, and demo config are self-contained, with no deferred Homebrew state | -| Node | `node-vfs.vfs.zst` | `bash images/vfs/scripts/build-node-vfs-image.sh` | exact self-contained shell image plus the package-resolved Node executable, npm 10.9.2 distribution, writable `/work`, and Node demo metadata | +| Shell | `shell.vfs.zst` | `./run.sh build shell-vfs` | platform base plus the complete reviewed current-shell closure: embedded `libcxx`/Ncurses/Bash, with the other base Formula trees independently lazy. `/usr/bin/brew` names a separate lazy source and an atomic runtime-support layer derived from the selected dependency graph; dependencies absent from the base, such as Ruby and its selected `libyaml` dependency, remain lazy together. | +| Node | `node-vfs.vfs.zst` | `bash images/vfs/scripts/build-node-vfs-image.sh` | embedded package-resolved Node executable + npm 10.9.2 dist + a guest profile that initializes the starter workspace inside the mounted canonical `/home/maker`; shell Formula trees remain lazy | | WordPress | `wordpress.vfs.zst` | `bash images/vfs/scripts/build-wp-vfs-image.sh` | WP files, nginx/PHP configs | | LAMP | `lamp.vfs.zst` | `bash images/vfs/scripts/build-lamp-vfs-image.sh` | MariaDB + WP + configs | | MariaDB test | `mariadb-test.vfs.zst` | `bash images/vfs/scripts/build-mariadb-test-vfs-image.sh` | MariaDB + test suite | diff --git a/docs/homebrew-publishing.md b/docs/homebrew-publishing.md index 5e4c3d1992..31271378c0 100644 --- a/docs/homebrew-publishing.md +++ b/docs/homebrew-publishing.md @@ -586,7 +586,7 @@ diagnostic bootstrap still use the retired prefix recorded in the guest layout contract. Do not describe that transitional layout as the campaign endpoint. -The target guest uses the existing `/home/user` account for writable +The target guest uses the existing `/home/maker` account for writable cache and configuration state and exposes `/usr/bin/brew` as the stable command. After cutover, new images must not create a `linuxbrew` user, install below `/home/linuxbrew`, or add a compatibility symlink for the diff --git a/examples/getpwent_smoke.c b/examples/getpwent_smoke.c index 6d01d4ea56..dc9445f114 100644 --- a/examples/getpwent_smoke.c +++ b/examples/getpwent_smoke.c @@ -102,7 +102,7 @@ int main(void) { int rc = 0; rc |= check_pwent_iteration(); rc |= check_pwnam("root", 1); - rc |= check_pwnam("user", 1); + rc |= check_pwnam("maker", 1); rc |= check_pwnam("nonexistent-user-xyz", 0); rc |= check_pwuid(0, 1); rc |= check_pwuid(1000, 1); diff --git a/examples/run-example.ts b/examples/run-example.ts index 36ce078b4a..f23b4a705e 100644 --- a/examples/run-example.ts +++ b/examples/run-example.ts @@ -461,8 +461,8 @@ async function main() { ["gc.auto", "0"], ["maintenance.auto", "false"], ["core.pager", "cat"], - ["user.name", "User"], - ["user.email", "user@wasm.local"], + ["user.name", "Maker"], + ["user.email", "maker@wasm.local"], ["init.defaultBranch", "main"], ]; const gitEnv: string[] = [ @@ -547,7 +547,7 @@ async function main() { runnerFilesystem.guestCwd, runnerFilesystem.isolated, process.env.KERNEL_PATH ?? "/usr/local/bin:/usr/bin:/bin", - uid !== undefined && uid !== 0 ? "/home/user" : "/root", + uid !== undefined && uid !== 0 ? "/home/maker" : "/root", ), ...gitEnv, ], diff --git a/homebrew/main-shell-migration-lock.json b/homebrew/main-shell-migration-lock.json index a8ebb358db..905cab783f 100644 --- a/homebrew/main-shell-migration-lock.json +++ b/homebrew/main-shell-migration-lock.json @@ -816,7 +816,7 @@ "mode": 420, "uid": 0, "gid": 0, - "contents": "alias ls='ls --color=auto'\nalias grep='grep --color=auto'\nexport USER=player\nexport NETHACKOPTIONS='windowtype:curses,color,lit_corridor,hilite_pet'\n", + "contents": "alias ls='ls --color=auto'\nalias grep='grep --color=auto'\nexport USER=maker\nexport NETHACKOPTIONS='windowtype:curses,color,lit_corridor,hilite_pet'\n", "reason": "Preserve the current shell's interactive aliases and guest environment without replacing the platform-owned /etc/profile." }, { @@ -826,7 +826,7 @@ "mode": 420, "uid": 0, "gid": 0, - "contents": "[maintenance]\n\tauto = false\n[gc]\n\tauto = 0\n[core]\n\tpager = cat\n[user]\n\tname = User\n\temail = user@wasm.local\n[init]\n\tdefaultBranch = main\n", + "contents": "[maintenance]\n\tauto = false\n[gc]\n\tauto = 0\n[core]\n\tpager = cat\n[user]\n\tname = Maker\n\temail = maker@wasm.local\n[init]\n\tdefaultBranch = main\n", "reason": "Preserve the shell image's deterministic Git identity, pager, maintenance, and default-branch behavior." }, { diff --git a/homebrew/test/homebrew_bootstrap_guest_env.ts b/homebrew/test/homebrew_bootstrap_guest_env.ts index 2c64382365..f02f7163df 100644 --- a/homebrew/test/homebrew_bootstrap_guest_env.ts +++ b/homebrew/test/homebrew_bootstrap_guest_env.ts @@ -81,16 +81,16 @@ async function main(): Promise { { env: [ "PATH=/opt/kandelo/homebrew/bin:/usr/bin:/bin", - "HOME=/home/user", - "USER=user", - "LOGNAME=user", + "HOME=/home/maker", + "USER=maker", + "LOGNAME=maker", "SHELL=/bin/bash", "TERM=dumb", - "HOMEBREW_CACHE=/home/user/.cache/Homebrew", - "HOMEBREW_USER_CONFIG_HOME=/home/user/.config/homebrew", + "HOMEBREW_CACHE=/home/maker/.cache/Homebrew", + "HOMEBREW_USER_CONFIG_HOME=/home/maker/.config/homebrew", "HOMEBREW_TEMP=/tmp", ], - cwd: "/home/user", + cwd: "/home/maker", uid: 1000, gid: 1000, onStarted: (startedPid) => { diff --git a/homebrew/test/homebrew_guest_lifecycle_browser.ts b/homebrew/test/homebrew_guest_lifecycle_browser.ts index c6ff78ffa7..d8429257b2 100644 --- a/homebrew/test/homebrew_guest_lifecycle_browser.ts +++ b/homebrew/test/homebrew_guest_lifecycle_browser.ts @@ -313,7 +313,7 @@ export function createBrowserLifecycleMachine(options: { [scriptOptions.shellArgv0, "-c", scriptOptions.script], { env: [...HOMEBREW_GUEST_LIFECYCLE_ENV], - cwd: "/home/user", + cwd: "/home/maker", uid: 1000, gid: 1000, stdin: new Uint8Array(), diff --git a/homebrew/test/homebrew_guest_lifecycle_node.ts b/homebrew/test/homebrew_guest_lifecycle_node.ts index d67099d0cb..18761db5bc 100644 --- a/homebrew/test/homebrew_guest_lifecycle_node.ts +++ b/homebrew/test/homebrew_guest_lifecycle_node.ts @@ -503,7 +503,7 @@ async function runGuestScript(options: { [options.shellArgv0, "-c", options.script], { env: [...HOMEBREW_GUEST_LIFECYCLE_ENV], - cwd: "/home/user", + cwd: "/home/maker", uid: 1000, gid: 1000, stdin: new Uint8Array(), diff --git a/homebrew/test/homebrew_guest_lifecycle_runner.ts b/homebrew/test/homebrew_guest_lifecycle_runner.ts index 4edd9b8aae..8e5ece3edb 100644 --- a/homebrew/test/homebrew_guest_lifecycle_runner.ts +++ b/homebrew/test/homebrew_guest_lifecycle_runner.ts @@ -25,9 +25,9 @@ import type { export const HOMEBREW_GUEST_LIFECYCLE_ENV = [ "PATH=/opt/kandelo/homebrew/bin:/usr/bin:/bin", - "HOME=/home/user", - "USER=user", - "LOGNAME=user", + "HOME=/home/maker", + "USER=maker", + "LOGNAME=maker", "SHELL=/bin/bash", "TERM=dumb", "TMPDIR=/tmp", diff --git a/homebrew/test/homebrew_guest_lifecycle_runtime_inputs.test.ts b/homebrew/test/homebrew_guest_lifecycle_runtime_inputs.test.ts index 2ba8e559df..6d1980dfd0 100644 --- a/homebrew/test/homebrew_guest_lifecycle_runtime_inputs.test.ts +++ b/homebrew/test/homebrew_guest_lifecycle_runtime_inputs.test.ts @@ -75,7 +75,7 @@ test("binds verified bootstrap bytes and bottle payloads to one exact image", as "/etc/homebrew", "/bin", "/home", - "/home/user", + "/home/maker", "/opt", "/opt/kandelo", "/opt/kandelo/homebrew", @@ -83,7 +83,7 @@ test("binds verified bootstrap bytes and bottle payloads to one exact image", as ]) { fs.mkdir(path, 0o755); } - fs.chown("/home/user", 1000, 1000); + fs.chown("/home/maker", 1000, 1000); fs.chown("/opt/kandelo/homebrew", 1000, 1000); assert.deepEqual( ["/home", "/opt", "/opt/kandelo"].map((path) => { diff --git a/host/src/browser-kernel-worker-entry.ts b/host/src/browser-kernel-worker-entry.ts index c76d5b908c..7fde174519 100644 --- a/host/src/browser-kernel-worker-entry.ts +++ b/host/src/browser-kernel-worker-entry.ts @@ -1017,7 +1017,7 @@ async function handleInit(msg: Extract) { // vfsImage path (Task 4.4): apply DEFAULT_MOUNT_SPEC through the shared // browser-worker VFS-init boundary, // giving 8 mounts — / from the image, plus scratch memfs at /tmp, /var/tmp, - // /var/log, /var/run, /home/user, /root, /srv. Layer /dev/shm and /dev on + // /var/log, /var/run, /home/maker, /root, /srv. Layer /dev/shm and /dev on // top: those are browser-platform internals (POSIX semaphore SAB, // kernel devices) not part of the canonical spec. // @@ -1028,7 +1028,7 @@ async function handleInit(msg: Extract) { const devfs = new DeviceFileSystem(); // The kernel worker OWNS the VFS: rebuild it from the demo's image bytes and // apply DEFAULT_MOUNT_SPEC (/ from the image + scratch mounts for /tmp, - // /var/*, /home/user, /root, /srv). /etc is part of the image, baked in by + // /var/*, /home/maker, /root, /srv). /etc is part of the image, baked in by // the demo (see apps/browser-demos/lib/kernel-owned-boot.ts). const specMounts = await restoreBrowserKernelInitMounts( msg.vfsImage, diff --git a/host/src/homebrew-runtime-support-materializer.ts b/host/src/homebrew-runtime-support-materializer.ts index 39cdb7295b..576f5f5cca 100644 --- a/host/src/homebrew-runtime-support-materializer.ts +++ b/host/src/homebrew-runtime-support-materializer.ts @@ -59,7 +59,7 @@ const MUTABLE_DIRECTORIES = Object.freeze([ `${PREFIX}/Library/Taps`, `${PREFIX}/var/homebrew/linked`, `${PREFIX}/var/homebrew/locks`, - "/home/user/.cache/Homebrew", + "/home/maker/.cache/Homebrew", ]); export interface PreparedHomebrewRuntimeSupport { @@ -197,7 +197,7 @@ export function finalizeHomebrewRuntimeSupport( } recursivelyLchown(fs, PREFIX, USER_ID, GROUP_ID); - recursivelyLchown(fs, "/home/user/.cache", USER_ID, GROUP_ID); + recursivelyLchown(fs, "/home/maker/.cache", USER_ID, GROUP_ID); assertFinalRuntimeSupport(fs, prepared, verifiedExtractionCommands); } catch (error) { if (error instanceof HomebrewRuntimeSupportMaterializationError) throw error; @@ -574,7 +574,7 @@ function assertFinalRuntimeSupport( ); } assertRecursiveOwnership(fs, PREFIX, USER_ID, GROUP_ID); - assertRecursiveOwnership(fs, "/home/user/.cache", USER_ID, GROUP_ID); + assertRecursiveOwnership(fs, "/home/maker/.cache", USER_ID, GROUP_ID); } function recursivelyLchown( diff --git a/host/src/vfs/default-mounts.ts b/host/src/vfs/default-mounts.ts index d0daee5a51..300076f031 100644 --- a/host/src/vfs/default-mounts.ts +++ b/host/src/vfs/default-mounts.ts @@ -44,7 +44,7 @@ export interface MountSpec { /** * Canonical mount layout. Mirrors the top-level system directories * declared in `MANIFEST` (Task 3.3): `/` is the read-only rootfs image; - * `/tmp`, `/var/*`, `/home/user`, `/root`, `/srv` are scratch. + * `/tmp`, `/var/*`, `/home/maker`, `/root`, `/srv` are scratch. */ export const DEFAULT_MOUNT_SPEC: MountSpec[] = [ { path: "/", source: "image", readonly: true }, @@ -52,7 +52,7 @@ export const DEFAULT_MOUNT_SPEC: MountSpec[] = [ { path: "/var/tmp", source: "scratch", mode: 0o1777 }, { path: "/var/log", source: "scratch", mode: 0o755 }, { path: "/var/run", source: "scratch", mode: 0o755, ephemeral: true }, - { path: "/home/user", source: "scratch", mode: 0o755, uid: 1000, gid: 1000 }, + { path: "/home/maker", source: "scratch", mode: 0o755, uid: 1000, gid: 1000 }, { path: "/root", source: "scratch", mode: 0o700, uid: 0, gid: 0 }, { path: "/srv", source: "scratch", mode: 0o755 }, ]; diff --git a/host/test/demo-login-image.test.ts b/host/test/demo-login-image.test.ts new file mode 100644 index 0000000000..4d1be96bbe --- /dev/null +++ b/host/test/demo-login-image.test.ts @@ -0,0 +1,154 @@ +import { existsSync, lstatSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + configureDemoLogin, + DEMO_AUTOLOGIN_MOTD_PATH, + DEMO_LOGIN_PASSWORD, + DEMO_LOGIN_PASSWORD_HASH, + DEMO_LOGIN_PROGRAM_PATH, + DEMO_LOGIN_USERNAME, + hasConfiguredDemoLogin, +} from "../../images/vfs/lib/demo-login"; +import { ensureDirRecursive } from "../src/vfs/image-helpers"; +import { MemoryFileSystem } from "../src/vfs/memory-fs"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +function readText(fs: MemoryFileSystem, path: string): string { + const st = fs.stat(path); + const fd = fs.open(path, 0, 0); + try { + const bytes = new Uint8Array(st.size); + const count = fs.read(fd, bytes, null, bytes.length); + return decoder.decode(bytes.subarray(0, count)); + } finally { + fs.close(fd); + } +} + +describe("canonical demo login image policy", () => { + it("derives the maker account, wheel policy, and autologin message from one credential source", () => { + const fs = MemoryFileSystem.create(new SharedArrayBuffer(2 * 1024 * 1024)); + ensureDirRecursive(fs, "/etc"); + ensureDirRecursive(fs, "/usr/bin"); + fs.createFileWithOwner( + DEMO_LOGIN_PROGRAM_PATH, + 0o4755, + 0, + 0, + new Uint8Array([0]), + ); + fs.createFileWithOwner( + "/etc/passwd", + 0o644, + 0, + 0, + encoder.encode( + [ + "root:x:0:0:root:/root:/bin/sh", + "maker:x:1000:1000:maker:/home/maker:/bin/sh", + "", + ].join("\n"), + ), + ); + fs.createFileWithOwner( + "/etc/shadow", + 0o640, + 0, + 0, + encoder.encode( + ["root:*:0:0:99999:7:::", "maker:*:0:0:99999:7:::", ""].join("\n"), + ), + ); + fs.createFileWithOwner( + "/etc/group", + 0o644, + 0, + 0, + encoder.encode("root:x:0:\nmaker:x:1000:\n"), + ); + fs.createFileWithOwner("/etc/motd", 0o644, 0, 0, new Uint8Array()); + + expect(hasConfiguredDemoLogin(fs)).toBe(false); + configureDemoLogin(fs, { home: "/work", shell: "/bin/bash" }); + // This predicate certifies configuration staging. Task 7's reviewed + // privileged-product publisher separately proves the executable bytes and + // trusted mount provenance before set-ID execution is possible. + expect(hasConfiguredDemoLogin(fs)).toBe(true); + + expect(DEMO_LOGIN_USERNAME).toBe("maker"); + expect(DEMO_LOGIN_PASSWORD).toBe("kandelo"); + expect(DEMO_LOGIN_PASSWORD_HASH).toBe( + "$6$kandelo$DKNPruix37YeUx9j4kJIGJ2NvXdqzxDr5b1D3xJZzbwFsNYuep8j3AtxB7OaTD6HWnz/adonyTamRx4XQwJ06/", + ); + expect(readText(fs, "/etc/passwd")).toContain( + "maker:x:1000:1000:maker:/work:/bin/bash", + ); + expect(readText(fs, "/etc/shadow")).toContain( + `maker:${DEMO_LOGIN_PASSWORD_HASH}:`, + ); + expect(readText(fs, "/etc/shadow")).toContain("root:*:"); + expect(readText(fs, "/etc/group")).toContain("wheel:x:10:maker"); + expect(readText(fs, "/etc/sudoers")).toBe("%wheel ALL=(ALL:ALL) ALL\n"); + expect(readText(fs, DEMO_AUTOLOGIN_MOTD_PATH)).toContain( + `login: ${DEMO_LOGIN_USERNAME}`, + ); + expect(readText(fs, DEMO_AUTOLOGIN_MOTD_PATH)).toContain( + `password: ${DEMO_LOGIN_PASSWORD}`, + ); + + expect(fs.stat("/etc/shadow")).toMatchObject({ uid: 0, gid: 0 }); + expect(fs.stat("/etc/shadow").mode & 0o7777).toBe(0o640); + expect(fs.stat("/etc/sudoers")).toMatchObject({ uid: 0, gid: 0 }); + expect(fs.stat("/etc/sudoers").mode & 0o7777).toBe(0o440); + + fs.chmod("/etc/sudoers", 0o644); + expect(hasConfiguredDemoLogin(fs)).toBe(false); + fs.chmod("/etc/sudoers", 0o440); + expect(hasConfiguredDemoLogin(fs)).toBe(true); + }); + + it("keeps canonical rootfs data truthful and product binaries outside local compiler paths", () => { + const passwd = readFileSync( + join(repoRoot, "images/rootfs/etc/passwd"), + "utf8", + ); + const group = readFileSync( + join(repoRoot, "images/rootfs/etc/group"), + "utf8", + ); + const shadow = readFileSync( + join(repoRoot, "images/rootfs/etc/shadow"), + "utf8", + ); + const sudoers = readFileSync( + join(repoRoot, "images/rootfs/etc/sudoers"), + "utf8", + ); + expect(passwd).toContain("maker:x:1000:1000:maker:/home/maker:/bin/sh"); + expect(group).toContain("wheel:x:10:maker"); + expect(shadow).toContain(`maker:${DEMO_LOGIN_PASSWORD_HASH}:`); + expect(sudoers).toBe("%wheel ALL=(ALL:ALL) ALL\n"); + + for (const name of ["login", "sudo-lite"]) { + const fixture = join( + repoRoot, + `local-binaries/test-fixtures/wasm32/${name}.wasm`, + ); + const productMirror = join( + repoRoot, + `local-binaries/programs/wasm32/${name}.wasm`, + ); + expect(existsSync(fixture), fixture).toBe(true); + if (existsSync(productMirror)) { + expect(lstatSync(productMirror).isSymbolicLink(), productMirror).toBe( + true, + ); + } + } + }); +}); diff --git a/host/test/fixtures/exec-argv.c b/host/test/fixtures/exec-argv.c new file mode 100644 index 0000000000..122b134d75 --- /dev/null +++ b/host/test/fixtures/exec-argv.c @@ -0,0 +1,19 @@ +#include +#include +#include +#include + +extern char **environ; + +int main(int argc, char **argv) +{ + if (argc < 2) { + fputs("exec-argv: missing executable\n", stderr); + return 2; + } + + execve(argv[1], &argv[1], environ); + int exec_errno = errno; + fprintf(stderr, "exec-argv: execve: %s\n", strerror(exec_errno)); + return exec_errno == ENOENT ? 127 : 126; +} diff --git a/host/test/gallery-descriptor.test.ts b/host/test/gallery-descriptor.test.ts index 6772a75280..0283ecb4a4 100644 --- a/host/test/gallery-descriptor.test.ts +++ b/host/test/gallery-descriptor.test.ts @@ -10,26 +10,26 @@ describe("gallery descriptor profiles", () => { { id: "node", command: ["bash", "-l", "-i"], - cwd: "/work", + cwd: "/home/maker", uid: 1000, gid: 1000, env: { - HOME: "/work", - PWD: "/work", - USER: "user", - LOGNAME: "user", + HOME: "/home/maker", + PWD: "/home/maker", + USER: "maker", + LOGNAME: "maker", }, }, { id: "shell", command: ["bash", "-l", "-i"], - cwd: "/home/user", + cwd: "/home/maker", uid: 1000, gid: 1000, env: { - HOME: "/home/user", - USER: "user", - LOGNAME: "user", + HOME: "/home/maker", + USER: "maker", + LOGNAME: "maker", }, }, { diff --git a/host/test/getpwent.test.ts b/host/test/getpwent.test.ts index d1ee129dd3..cde4e9e43c 100644 --- a/host/test/getpwent.test.ts +++ b/host/test/getpwent.test.ts @@ -56,14 +56,14 @@ describe.skipIf(!haveSmoke || !haveRootfs)("getpwent via rootfs.vfs mount", () = "PWENT 5 name=mysql uid=101 gid=101 home=/var/lib/mysql shell=/usr/sbin/nologin", ); expect(result.stdout).toContain( - "PWENT 6 name=user uid=1000 gid=1000 home=/home/user shell=/bin/sh", + "PWENT 6 name=maker uid=1000 gid=1000 home=/home/maker shell=/bin/sh", ); expect(result.stdout).toContain("PWENT count=7"); // Targeted name lookups. expect(result.stdout).toContain("PWNAM name=root uid=0 gid=0 home=/root shell=/bin/sh"); expect(result.stdout).toContain( - "PWNAM name=user uid=1000 gid=1000 home=/home/user shell=/bin/sh", + "PWNAM name=maker uid=1000 gid=1000 home=/home/maker shell=/bin/sh", ); // Missing entries must surface as NULL — proves we're not silently @@ -73,7 +73,7 @@ describe.skipIf(!haveSmoke || !haveRootfs)("getpwent via rootfs.vfs mount", () = // Targeted uid lookups. expect(result.stdout).toContain("PWUID uid=0 name=root gid=0 home=/root shell=/bin/sh"); expect(result.stdout).toContain( - "PWUID uid=1000 name=user gid=1000 home=/home/user shell=/bin/sh", + "PWUID uid=1000 name=maker gid=1000 home=/home/maker shell=/bin/sh", ); }); @@ -87,8 +87,9 @@ describe.skipIf(!haveSmoke || !haveRootfs)("getpwent via rootfs.vfs mount", () = expect(result.exitCode, result.stderr || result.stdout).toBe(0); expect(result.stdout).toContain("GRENT 0 name=root gid=0"); expect(result.stdout).toContain("GRENT 1 name=daemon gid=1"); - expect(result.stdout).toContain("GRENT 7 name=user gid=1000"); - expect(result.stdout).toContain("GRENT count=8"); + expect(result.stdout).toContain("GRENT 7 name=wheel gid=10"); + expect(result.stdout).toContain("GRENT 8 name=maker gid=1000"); + expect(result.stdout).toContain("GRENT count=9"); }); it("resolves canonical service names and aliases from the rootfs image", async () => { diff --git a/host/test/homebrew-runtime-support-materializer.test.ts b/host/test/homebrew-runtime-support-materializer.test.ts index 670a0f4888..0336d8fb9d 100644 --- a/host/test/homebrew-runtime-support-materializer.test.ts +++ b/host/test/homebrew-runtime-support-materializer.test.ts @@ -316,7 +316,7 @@ describe("flat Homebrew runtime support", () => { `${HOMEBREW_TEST_PREFIX}/Library/Taps`, `${HOMEBREW_TEST_PREFIX}/var/homebrew/linked`, `${HOMEBREW_TEST_PREFIX}/var/homebrew/locks`, - "/home/user/.cache/Homebrew", + "/home/maker/.cache/Homebrew", ]) { const stat = result.fs.lstat(path); expect(stat, path).toMatchObject({ uid: 1000, gid: 1000 }); diff --git a/host/test/homebrew-vfs-image-save.test.ts b/host/test/homebrew-vfs-image-save.test.ts index 0dfac3ecc8..88ab84d3eb 100644 --- a/host/test/homebrew-vfs-image-save.test.ts +++ b/host/test/homebrew-vfs-image-save.test.ts @@ -252,7 +252,7 @@ describe("Homebrew VFS image publication boundary", () => { fs.chown("/opt/kandelo/homebrew", 0, 0); break; case "cache-owner": - fs.chown("/home/user/.cache/Homebrew", 0, 0); + fs.chown("/home/maker/.cache/Homebrew", 0, 0); break; } @@ -513,7 +513,7 @@ function bootstrapConsumerFs(): MemoryFileSystem { "/opt/kandelo/homebrew/Cellar/existing/1", "/opt/kandelo/homebrew/Cellar/existing/1/bin", "/home", - "/home/user", + "/home/maker", "/usr", "/usr/bin", "/etc", diff --git a/host/test/login.test.ts b/host/test/login.test.ts new file mode 100644 index 0000000000..00e49809eb --- /dev/null +++ b/host/test/login.test.ts @@ -0,0 +1,412 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + DEMO_LOGIN_PASSWORD, + DEMO_LOGIN_PASSWORD_HASH, +} from "../../images/vfs/lib/demo-login"; +import { DeviceFileSystem } from "../src/vfs/device-fs"; +import { ensureDirRecursive } from "../src/vfs/image-helpers"; +import { + createImmutableProductBackend, + MemoryFileSystem, +} from "../src/vfs/memory-fs"; +import { NodeTimeProvider } from "../src/vfs/time"; +import { VirtualPlatformIO } from "../src/vfs/vfs"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const loginWasm = join( + repoRoot, + "local-binaries/test-fixtures/wasm32/login.wasm", +); +const shellWasm = join(repoRoot, "local-binaries/programs/wasm32/sh.wasm"); +const identityDir = mkdtempSync(join(tmpdir(), "kandelo-login-identity-")); +const identitySource = join(identityDir, "login-identity.c"); +const identityWasm = join(identityDir, "login-identity.wasm"); +const ttyFailureSource = join(identityDir, "login-tty-failure.c"); +const ttyFailureWasm = join(identityDir, "login-tty-failure.wasm"); +const execArgvSource = join(repoRoot, "host/test/fixtures/exec-argv.c"); +const execArgvWasm = join(identityDir, "exec-argv.wasm"); + +const identityProgram = String.raw` +#define _GNU_SOURCE +#include +#include +#include +#include + +static const char *env_value(const char *name) { + const char *value = getenv(name); + return value ? value : ""; +} + +int main(int argc, char **argv) { + uid_t ruid, euid, suid; + gid_t rgid, egid, sgid; + gid_t groups[32]; + char cwd[256]; + int count = getgroups(32, groups); + if (getresuid(&ruid, &euid, &suid) != 0 || + getresgid(&rgid, &egid, &sgid) != 0 || + count < 0 || getcwd(cwd, sizeof(cwd)) == NULL) return 90; + printf("ARGV0=%s argc=%d\n", argv[0], argc); + printf("UID r=%u e=%u s=%u\n", ruid, euid, suid); + printf("GID r=%u e=%u s=%u\n", rgid, egid, sgid); + printf("GROUPS count=%d", count); + for (int i = 0; i < count; i++) printf(" %u", groups[i]); + printf("\nCWD=%s\n", cwd); + const char *names[] = { + "HOME", "USER", "LOGNAME", "SHELL", "PATH", "TERM", + "KANDELO_UNTRUSTED", NULL + }; + for (int i = 0; names[i]; i++) + printf("%s=%s\n", names[i], env_value(names[i])); + return 0; +} +`; + +const ttyFailureProgram = String.raw` +#define main login_program_main +#include "${join(repoRoot, "programs/login.c")}" +#undef main + +#include + +static int stdin_reads; +char *__real_fgets(char *, int, FILE *); + +int __wrap_isatty(int fd) { + (void)fd; + return 1; +} + +int __wrap_tcgetattr(int fd, struct termios *attrs) { + (void)fd; + memset(attrs, 0, sizeof(*attrs)); + return 0; +} + +int __wrap_tcsetattr(int fd, int action, const struct termios *attrs) { + (void)fd; + (void)action; + (void)attrs; + errno = EIO; + return -1; +} + +char *__wrap_fgets(char *buf, int size, FILE *stream) { + if (stream == stdin) { + stdin_reads++; + return NULL; + } + return __real_fgets(buf, size, stream); +} + +int main(void) { + char *argv[] = {"login", "maker", NULL}; + int result = login_program_main(2, argv); + printf("STDIN_READS=%d\n", stdin_reads); + return result; +} +`; + +function bytes(path: string): Uint8Array { + return new Uint8Array(readFileSync(path)); +} + +function loginPlatform( + shell = "/bin/login-identity", + createHome = true, + accountHome = "/home/maker", +): VirtualPlatformIO { + const fs = MemoryFileSystem.create(new SharedArrayBuffer(16 * 1024 * 1024)); + const enc = new TextEncoder(); + for (const path of ["/etc", "/bin", "/home", "/usr/bin", "/var", "/tmp"]) { + ensureDirRecursive(fs, path); + } + if (createHome) { + fs.mkdirWithOwner("/home/maker", 0o755, 1000, 1000); + } + fs.createFileWithOwner( + "/etc/passwd", + 0o644, + 0, + 0, + enc.encode( + [ + "root:x:0:0:root:/root:/bin/sh", + `maker:x:1000:1000:maker:${accountHome}:${shell}`, + "", + ].join("\n"), + ), + ); + fs.createFileWithOwner( + "/etc/shadow", + 0o640, + 0, + 0, + enc.encode( + [ + "root:*:0:0:99999:7:::", + `maker:${DEMO_LOGIN_PASSWORD_HASH}:0:0:99999:7:::`, + "", + ].join("\n"), + ), + ); + fs.createFileWithOwner( + "/etc/group", + 0o644, + 0, + 0, + enc.encode( + [ + "root:x:0:", + "wheel:x:10:maker", + "audio:x:20:maker", + "maker:x:1000:", + "", + ].join("\n"), + ), + ); + fs.createFileWithOwner( + "/etc/nsswitch.conf", + 0o644, + 0, + 0, + enc.encode("passwd: files\ngroup: files\nshadow: files\n"), + ); + fs.createFileWithOwner( + "/etc/motd", + 0o644, + 0, + 0, + enc.encode("Ordinary login message\n"), + ); + fs.createFileWithOwner( + "/etc/motd.autologin", + 0o644, + 0, + 0, + enc.encode("Preauthenticated login message\n"), + ); + fs.createFileWithOwner("/bin/sh", 0o755, 0, 0, bytes(shellWasm)); + fs.createFileWithOwner( + "/bin/login-identity", + 0o755, + 0, + 0, + bytes(identityWasm), + ); + const product = MemoryFileSystem.create( + new SharedArrayBuffer(4 * 1024 * 1024), + ); + product.createFileWithOwner("/login", 0o4755, 0, 0, bytes(loginWasm)); + return new VirtualPlatformIO( + [ + { + mountPoint: "/usr/bin", + backend: createImmutableProductBackend(product), + readonly: true, + setIdCapability: { + kind: "trusted-root-product", + guestWritable: false, + stableExecutableIdentity: true, + }, + }, + { mountPoint: "/dev", backend: new DeviceFileSystem() }, + { mountPoint: "/", backend: fs }, + ], + new NodeTimeProvider(), + ); +} + +async function runLogin( + options: { + args?: string; + stdin?: string; + uid?: number; + gid?: number; + env?: string[]; + accountShell?: string; + createHome?: boolean; + accountHome?: string; + } = {}, +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + const result = await runCentralizedProgram({ + programPath: execArgvWasm, + argv: [ + "exec-argv", + "/usr/bin/login", + ...(options.args ?? "maker").split(" "), + ], + uid: options.uid ?? 1000, + gid: options.gid ?? 1000, + env: options.env, + stdin: options.stdin ?? `${DEMO_LOGIN_PASSWORD}\n`, + io: loginPlatform( + options.accountShell, + options.createHome, + options.accountHome, + ), + timeout: 20_000, + }); + return result; +} + +beforeAll(() => { + writeFileSync(identitySource, identityProgram); + writeFileSync(ttyFailureSource, ttyFailureProgram); + execFileSync("wasm32posix-cc", [identitySource, "-o", identityWasm], { + cwd: repoRoot, + stdio: "pipe", + }); + execFileSync("wasm32posix-cc", [execArgvSource, "-o", execArgvWasm], { + cwd: repoRoot, + stdio: "pipe", + }); + execFileSync( + "wasm32posix-cc", + [ + ttyFailureSource, + "-Wl,--wrap=isatty", + "-Wl,--wrap=tcgetattr", + "-Wl,--wrap=tcsetattr", + "-Wl,--wrap=fgets", + "-o", + ttyFailureWasm, + ], + { cwd: repoRoot, stdio: "pipe" }, + ); +}); + +afterAll(() => { + rmSync(identityDir, { recursive: true, force: true }); +}); + +describe("first-party guest login", () => { + it("authenticates from shadow before changing groups, IDs, CWD, and environment", async () => { + const result = await runLogin({ + env: ["TERM=xterm-kandelo", "KANDELO_UNTRUSTED=must-not-survive"], + }); + + expect(result.exitCode, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toContain("Password: "); + expect(result.stdout).toContain("ARGV0=-login-identity argc=1"); + expect(result.stdout).toContain("UID r=1000 e=1000 s=1000"); + expect(result.stdout).toContain("GID r=1000 e=1000 s=1000"); + expect(result.stdout).toContain("GROUPS count=3 1000 10 20"); + expect(result.stdout).toContain("CWD=/home/maker"); + expect(result.stdout).toContain("HOME=/home/maker"); + expect(result.stdout).toContain("USER=maker"); + expect(result.stdout).toContain("LOGNAME=maker"); + expect(result.stdout).toContain("SHELL=/bin/login-identity"); + expect(result.stdout).toContain("PATH=/usr/local/bin:/usr/bin:/bin"); + expect(result.stdout).toContain("TERM=xterm-kandelo"); + expect(result.stdout).toContain("KANDELO_UNTRUSTED=\n"); + expect(result.stdout).toContain("Ordinary login message\n"); + expect(result.stdout).not.toContain("Preauthenticated login message"); + }); + + it("fails instead of starting a shell outside the account's missing canonical home", async () => { + const result = await runLogin({ + args: "-f maker", + stdin: "", + uid: 0, + gid: 0, + createHome: false, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("login: chdir: No such file or directory"); + expect(result.stdout).not.toContain("ARGV0=-login-identity"); + expect(result.stdout).not.toContain("Ordinary login message"); + expect(result.stdout).not.toContain("Preauthenticated login message"); + }); + + it("rejects an account without a canonical home instead of using the root directory", async () => { + const result = await runLogin({ + args: "-f maker", + stdin: "", + uid: 0, + gid: 0, + accountHome: "", + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("login: account has no home directory"); + expect(result.stdout).not.toContain("ARGV0=-login-identity"); + expect(result.stdout).not.toContain("Ordinary login message"); + }); + + it.each([ + ["an incorrect password", "maker", "wrong\n"], + ["an unknown user", "missing-user", "irrelevant\n"], + ])("rejects %s without starting a shell", async (_label, user, stdin) => { + const result = await runLogin({ args: user, stdin }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Login incorrect"); + expect(result.stdout).not.toContain("ARGV0=-login-identity"); + }); + + it("allows a real-root manager to preserve a trusted environment and preauthenticate", async () => { + const result = await runLogin({ + args: "-p -f maker", + stdin: "", + uid: 0, + gid: 0, + env: [ + "TERM=xterm-256color", + "PATH=/trusted/bin", + "KANDELO_UNTRUSTED=trusted-manager-value", + ], + }); + + expect(result.exitCode, result.stderr || result.stdout).toBe(0); + expect(result.stdout).not.toContain("Password: "); + expect(result.stdout).toContain("PATH=/trusted/bin"); + expect(result.stdout).toContain("KANDELO_UNTRUSTED=trusted-manager-value"); + expect(result.stdout).toContain("Ordinary login message\n"); + expect(result.stdout).toContain("Preauthenticated login message\n"); + }); + + it.each(["-p maker", "-f maker"])( + "rejects non-root use of %s", + async (args) => { + const result = await runLogin({ args, stdin: "" }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("require a root caller"); + expect(result.stdout).not.toContain("ARGV0=-login-identity"); + }, + ); + + it("refuses to read a TTY password when echo cannot be disabled", async () => { + const result = await runCentralizedProgram({ + programPath: ttyFailureWasm, + argv: ["login-tty-failure"], + timeout: 20_000, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("login: tcsetattr: I/O error"); + expect(result.stdout).toContain("STDIN_READS=0"); + }); + + it("reports a missing account shell after completing the real login transition", async () => { + const result = await runLogin({ + args: "-f maker", + stdin: "", + uid: 0, + gid: 0, + accountShell: "/bin/missing-shell", + }); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain("Ordinary login message\n"); + expect(result.stdout).toContain("Preauthenticated login message\n"); + expect(result.stderr).toContain("login: exec: No such file or directory"); + }); +}); diff --git a/host/test/node-demo-workspace.test.ts b/host/test/node-demo-workspace.test.ts new file mode 100644 index 0000000000..41ba660bc7 --- /dev/null +++ b/host/test/node-demo-workspace.test.ts @@ -0,0 +1,80 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + NODE_WORKSPACE_PROFILE_PATH, + stageSpiderMonkeyNpmRuntime, +} from "../../images/vfs/lib/init/spidermonkey-npm-runtime"; +import { ensureDirRecursive, writeVfsFile } from "../src/vfs/image-helpers"; +import { MemoryFileSystem } from "../src/vfs/memory-fs"; +import { NodeTimeProvider } from "../src/vfs/time"; +import { VirtualPlatformIO } from "../src/vfs/vfs"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const SHELL_WASM = join(REPO_ROOT, "local-binaries/programs/wasm32/dash.wasm"); +const STARTER_PACKAGE = '{\n "name": "demo",\n "version": "0.0.1"\n}\n'; +const NPM_PATCH_INPUTS = [ + "/usr/local/lib/npm/lib/utils/display.js", + "/usr/local/lib/npm/lib/commands/token.js", + "/usr/local/lib/npm/node_modules/cacache/lib/entry-index.js", + "/usr/local/lib/npm/node_modules/cacache/lib/verify.js", +] as const; + +describe("Node demo workspace", () => { + it("initializes the starter package inside the mounted canonical maker home", async () => { + const rootfs = MemoryFileSystem.create( + new SharedArrayBuffer(4 * 1024 * 1024), + ); + for (const path of NPM_PATCH_INPUTS) { + ensureDirRecursive(rootfs, path.slice(0, path.lastIndexOf("/"))); + writeVfsFile(rootfs, path, "", 0o644); + } + ensureDirRecursive(rootfs, "/home/maker"); + stageSpiderMonkeyNpmRuntime(rootfs); + + const home = MemoryFileSystem.create(new SharedArrayBuffer(1024 * 1024)); + home.chown("/", 1000, 1000); + const io = new VirtualPlatformIO( + [ + { mountPoint: "/home/maker", backend: home }, + { mountPoint: "/", backend: rootfs }, + ], + new NodeTimeProvider(), + ); + + const result = await runCentralizedProgram({ + programPath: SHELL_WASM, + argv: ["sh", NODE_WORKSPACE_PROFILE_PATH], + uid: 1000, + gid: 1000, + env: [ + "HOME=/home/maker", + "USER=maker", + "LOGNAME=maker", + "PATH=/usr/local/bin:/usr/bin:/bin", + ], + io, + onKernelReady: (kernel, pid) => kernel.setCwd(pid, "/home/maker"), + timeout: 20_000, + }); + + expect(result.exitCode, result.stderr || result.stdout).toBe(0); + expect(readVfsText(home, "/package.json")).toBe(STARTER_PACKAGE); + expect(home.stat("/package.json")).toMatchObject({ uid: 1000, gid: 1000 }); + expect(() => rootfs.stat("/home/maker/package.json")).toThrow(); + expect(() => rootfs.stat("/work")).toThrow(); + }, 30_000); +}); + +function readVfsText(fs: MemoryFileSystem, path: string): string { + const stat = fs.stat(path); + const fd = fs.open(path, 0, 0); + try { + const bytes = new Uint8Array(stat.size); + const length = fs.read(fd, bytes, null, bytes.length); + return new TextDecoder().decode(bytes.subarray(0, length)); + } finally { + fs.close(fd); + } +} diff --git a/host/test/sudo-lite.test.ts b/host/test/sudo-lite.test.ts new file mode 100644 index 0000000000..c818dc9840 --- /dev/null +++ b/host/test/sudo-lite.test.ts @@ -0,0 +1,403 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + DEMO_LOGIN_PASSWORD, + DEMO_LOGIN_PASSWORD_HASH, +} from "../../images/vfs/lib/demo-login"; +import { DeviceFileSystem } from "../src/vfs/device-fs"; +import { ensureDirRecursive } from "../src/vfs/image-helpers"; +import { + createImmutableProductBackend, + MemoryFileSystem, +} from "../src/vfs/memory-fs"; +import { NodeTimeProvider } from "../src/vfs/time"; +import { VirtualPlatformIO } from "../src/vfs/vfs"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const sudoWasm = join( + repoRoot, + "local-binaries/test-fixtures/wasm32/sudo-lite.wasm", +); +const shellWasm = join(repoRoot, "local-binaries/programs/wasm32/sh.wasm"); +const identityDir = mkdtempSync(join(tmpdir(), "kandelo-sudo-identity-")); +const identitySource = join(identityDir, "sudo-identity.c"); +const identityWasm = join(identityDir, "sudo-identity.wasm"); +const ttyFailureSource = join(identityDir, "sudo-tty-failure.c"); +const ttyFailureWasm = join(identityDir, "sudo-tty-failure.wasm"); +const execArgvSource = join(repoRoot, "host/test/fixtures/exec-argv.c"); +const execArgvWasm = join(identityDir, "exec-argv.wasm"); +const safePath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; +const wheelPolicy = "%wheel ALL=(ALL:ALL) ALL\n"; + +const identityProgram = String.raw` +#define _GNU_SOURCE +#include +#include +#include +#include + +static const char *env_value(const char *name) { + const char *value = getenv(name); + return value ? value : ""; +} + +int main(int argc, char **argv) { + uid_t ruid, euid, suid; + gid_t rgid, egid, sgid; + gid_t groups[32]; + int count = getgroups(32, groups); + if (getresuid(&ruid, &euid, &suid) != 0 || + getresgid(&rgid, &egid, &sgid) != 0 || count < 0) return 90; + printf("ARGV0=%s argc=%d\n", argv[0], argc); + printf("UID r=%u e=%u s=%u\n", ruid, euid, suid); + printf("GID r=%u e=%u s=%u\n", rgid, egid, sgid); + printf("GROUPS count=%d", count); + for (int i = 0; i < count; i++) printf(" %u", groups[i]); + printf("\n"); + const char *names[] = { + "HOME", "USER", "LOGNAME", "SHELL", "PATH", "TERM", + "KANDELO_UNTRUSTED", NULL + }; + for (int i = 0; names[i]; i++) + printf("%s=%s\n", names[i], env_value(names[i])); + return 0; +} +`; + +const ttyFailureProgram = String.raw` +#define main sudo_program_main +#include "${join(repoRoot, "programs/sudo-lite.c")}" +#undef main + +static int stdin_reads; +char *__real_fgets(char *, int, FILE *); + +uid_t __wrap_getuid(void) { return 1000; } +uid_t __wrap_geteuid(void) { return 0; } +gid_t __wrap_getgid(void) { return 10; } +gid_t __wrap_getegid(void) { return 10; } + +int __wrap_isatty(int fd) { + (void)fd; + return 1; +} + +int __wrap_tcgetattr(int fd, struct termios *attrs) { + (void)fd; + memset(attrs, 0, sizeof(*attrs)); + return 0; +} + +int __wrap_tcsetattr(int fd, int action, const struct termios *attrs) { + (void)fd; + (void)action; + (void)attrs; + errno = EIO; + return -1; +} + +char *__wrap_fgets(char *buf, int size, FILE *stream) { + if (stream == stdin) { + stdin_reads++; + return NULL; + } + return __real_fgets(buf, size, stream); +} + +unsigned __wrap_sleep(unsigned seconds) { + (void)seconds; + return 0; +} + +int main(void) { + char *argv[] = {"sudo-lite", "/bin/sudo-identity", NULL}; + int result = sudo_program_main(2, argv); + printf("STDIN_READS=%d\n", stdin_reads); + return result; +} +`; + +function bytes(path: string): Uint8Array { + return new Uint8Array(readFileSync(path)); +} + +function sudoPlatform( + options: { + wheelMember?: boolean; + sudoers?: string; + sudoMode?: number; + } = {}, +): VirtualPlatformIO { + const fs = MemoryFileSystem.create(new SharedArrayBuffer(16 * 1024 * 1024)); + const enc = new TextEncoder(); + for (const path of [ + "/etc", + "/bin", + "/home", + "/root", + "/usr/bin", + "/var", + "/tmp", + ]) { + ensureDirRecursive(fs, path); + } + fs.mkdirWithOwner("/home/maker", 0o755, 1000, 1000); + fs.chmod("/root", 0o700); + fs.createFileWithOwner( + "/etc/passwd", + 0o644, + 0, + 0, + enc.encode( + [ + "root:x:0:0:root:/root:/bin/sh", + "maker:x:1000:1000:maker:/home/maker:/bin/sh", + "", + ].join("\n"), + ), + ); + fs.createFileWithOwner( + "/etc/shadow", + 0o640, + 0, + 0, + enc.encode( + [ + "root:*:0:0:99999:7:::", + `maker:${DEMO_LOGIN_PASSWORD_HASH}:0:0:99999:7:::`, + "", + ].join("\n"), + ), + ); + fs.createFileWithOwner( + "/etc/group", + 0o644, + 0, + 0, + enc.encode( + [ + "root:x:0:", + `wheel:x:10:${options.wheelMember === false ? "" : "maker"}`, + "maker:x:1000:", + "", + ].join("\n"), + ), + ); + fs.createFileWithOwner( + "/etc/nsswitch.conf", + 0o644, + 0, + 0, + enc.encode("passwd: files\ngroup: files\nshadow: files\n"), + ); + fs.createFileWithOwner( + "/etc/sudoers", + 0o440, + 0, + 0, + enc.encode(options.sudoers ?? wheelPolicy), + ); + fs.createFileWithOwner("/bin/sh", 0o755, 0, 0, bytes(shellWasm)); + const product = MemoryFileSystem.create( + new SharedArrayBuffer(4 * 1024 * 1024), + ); + product.createFileWithOwner( + "/sudo-lite", + options.sudoMode ?? 0o4755, + 0, + 0, + bytes(sudoWasm), + ); + fs.createFileWithOwner( + "/bin/sudo-identity", + 0o755, + 0, + 0, + bytes(identityWasm), + ); + const trusted = (options.sudoMode ?? 0o4755) === 0o4755; + return new VirtualPlatformIO( + [ + trusted + ? { + mountPoint: "/usr/bin", + backend: createImmutableProductBackend(product), + readonly: true, + setIdCapability: { + kind: "trusted-root-product" as const, + guestWritable: false, + stableExecutableIdentity: true, + }, + } + : { mountPoint: "/usr/bin", backend: product }, + { mountPoint: "/dev", backend: new DeviceFileSystem() }, + { mountPoint: "/", backend: fs }, + ], + new NodeTimeProvider(), + ); +} + +async function runSudo( + options: { + args?: string; + stdin?: string; + uid?: number; + gid?: number; + env?: string[]; + wheelMember?: boolean; + sudoers?: string; + sudoMode?: number; + } = {}, +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + const result = await runCentralizedProgram({ + programPath: execArgvWasm, + argv: [ + "exec-argv", + "/usr/bin/sudo-lite", + ...(options.args ?? "/bin/sudo-identity").split(" "), + ], + uid: options.uid ?? 1000, + // A login session supplies wheel as a supplementary group. Using it as + // the effective group exercises the same kernel membership predicate + // without adding host-only supplementary-group setup. + gid: options.gid ?? (options.wheelMember === false ? 1000 : 10), + env: options.env ?? [ + "TERM=xterm-kandelo", + "KANDELO_UNTRUSTED=must-not-survive", + ], + stdin: options.stdin ?? `${DEMO_LOGIN_PASSWORD}\n`, + io: sudoPlatform(options), + timeout: 20_000, + }); + return result; +} + +beforeAll(() => { + writeFileSync(identitySource, identityProgram); + writeFileSync(ttyFailureSource, ttyFailureProgram); + execFileSync("wasm32posix-cc", [identitySource, "-o", identityWasm], { + cwd: repoRoot, + stdio: "pipe", + }); + execFileSync("wasm32posix-cc", [execArgvSource, "-o", execArgvWasm], { + cwd: repoRoot, + stdio: "pipe", + }); + execFileSync( + "wasm32posix-cc", + [ + ttyFailureSource, + "-Wl,--wrap=getuid", + "-Wl,--wrap=geteuid", + "-Wl,--wrap=getgid", + "-Wl,--wrap=getegid", + "-Wl,--wrap=isatty", + "-Wl,--wrap=tcgetattr", + "-Wl,--wrap=tcsetattr", + "-Wl,--wrap=fgets", + "-Wl,--wrap=sleep", + "-o", + ttyFailureWasm, + ], + { cwd: repoRoot, stdio: "pipe" }, + ); +}); + +afterAll(() => { + rmSync(identityDir, { recursive: true, force: true }); +}); + +describe("first-party guest sudo-lite", () => { + it("authenticates a wheel user and establishes root groups, IDs, and a safe environment", async () => { + const result = await runSudo(); + expect(result.exitCode, result.stderr || result.stdout).toBe(0); + expect(result.stderr).toContain("[sudo-lite] password for maker: "); + expect(result.stdout).toContain("ARGV0=/bin/sudo-identity argc=1"); + expect(result.stdout).toContain("UID r=0 e=0 s=0"); + expect(result.stdout).toContain("GID r=0 e=0 s=0"); + expect(result.stdout).toContain("GROUPS count=1 0"); + expect(result.stdout).toContain("HOME=/root"); + expect(result.stdout).toContain("USER=root"); + expect(result.stdout).toContain("LOGNAME=root"); + expect(result.stdout).toContain("SHELL=/bin/sh"); + expect(result.stdout).toContain(`PATH=${safePath}`); + expect(result.stdout).toContain("TERM=xterm-kandelo"); + expect(result.stdout).toContain("KANDELO_UNTRUSTED=\n"); + }); + + it("rejects a wrong password before running the command", async () => { + const result = await runSudo({ stdin: "wrong\n" }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("sudo-lite: authentication failed"); + expect(result.stdout).not.toContain("UID r="); + }); + + it("refuses to read a TTY password when echo cannot be disabled", async () => { + const result = await runCentralizedProgram({ + programPath: ttyFailureWasm, + argv: ["sudo-tty-failure"], + uid: 0, + gid: 0, + io: sudoPlatform(), + timeout: 20_000, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("sudo-lite: tcsetattr: I/O error"); + expect(result.stdout).toContain("STDIN_READS=0"); + }); + + it("rejects a user outside wheel before asking for a password", async () => { + const result = await runSudo({ wheelMember: false }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("current wheel membership is required"); + expect(result.stderr).not.toContain("password for maker"); + expect(result.stdout).not.toContain("UID r="); + }); + + it("lists the parsed wheel policy after authentication", async () => { + const result = await runSudo({ args: "-l" }); + expect(result.exitCode, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toContain( + "User maker may run the following commands", + ); + expect(result.stdout).toContain("(ALL:ALL) ALL"); + }); + + it("fails closed on malformed sudoers policy", async () => { + const result = await runSudo({ sudoers: "wheel can do anything\n" }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("sudo-lite: malformed /etc/sudoers"); + expect(result.stderr).not.toContain("password for maker"); + expect(result.stdout).not.toContain("UID r="); + }); + + it("rejects a wheel user when sudoers does not grant wheel policy", async () => { + const result = await runSudo({ sudoers: "root ALL=(ALL:ALL) ALL\n" }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + "sudo-lite: wheel is not allowed by /etc/sudoers", + ); + expect(result.stderr).not.toContain("password for maker"); + }); + + it("reports execvp failure with the conventional not-found status", async () => { + const result = await runSudo({ args: "/bin/missing-command" }); + expect(result.exitCode).toBe(127); + expect(result.stderr).toContain( + "sudo-lite: exec /bin/missing-command: No such file or directory", + ); + }); + + it("fails loudly if the reviewed set-ID projection was not applied", async () => { + const result = await runSudo({ sudoMode: 0o755 }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("effective uid is not root"); + expect(result.stderr).toContain("mount nosuid policy"); + }); +}); diff --git a/host/test/vfs/browser-mount-layering.test.ts b/host/test/vfs/browser-mount-layering.test.ts index d7483dad97..ad5db25983 100644 --- a/host/test/vfs/browser-mount-layering.test.ts +++ b/host/test/vfs/browser-mount-layering.test.ts @@ -92,7 +92,7 @@ describe("browser host mount layering", () => { "/", "/dev", "/dev/shm", - "/home/user", + "/home/maker", "/root", "/srv", "/tmp", @@ -145,13 +145,19 @@ describe("browser host mount layering", () => { expect(() => rootfs.stat("/tmp/note")).toThrow(); }); - it("/home/user and /root resolve to distinct scratch backends", async () => { + it("keeps the maker profile writable and separate from /root", async () => { const { io } = await buildBrowserMounts(image); - const data = new TextEncoder().encode("h"); - const fdH = io.open("/home/user/x", O_WRONLY | O_CREAT | O_TRUNC, 0o644); + const data = new TextEncoder().encode("current profile"); + const fdH = io.open("/home/maker/x", O_WRONLY | O_CREAT | O_TRUNC, 0o644); io.write(fdH, data, null, data.length); io.close(fdH); - expect(() => io.stat("/home/user/x")).not.toThrow(); + const readFd = io.open("/home/maker/x", O_RDONLY, 0); + const actual = new Uint8Array(32); + const length = io.read(readFd, actual, null, actual.length); + io.close(readFd); + expect(new TextDecoder().decode(actual.subarray(0, length))).toBe( + "current profile", + ); expect(() => io.stat("/root/x")).toThrow(); }); }); diff --git a/host/test/vfs/default-mounts.test.ts b/host/test/vfs/default-mounts.test.ts index d6e9838a93..81c123650f 100644 --- a/host/test/vfs/default-mounts.test.ts +++ b/host/test/vfs/default-mounts.test.ts @@ -123,7 +123,7 @@ describe("DEFAULT_MOUNT_SPEC", () => { expect(paths).toEqual( [ "/", - "/home/user", + "/home/maker", "/root", "/srv", "/tmp", @@ -193,6 +193,38 @@ describe("resolveForNode", () => { expect(new TextDecoder().decode(onDisk)).toBe("hello via host fs"); }); + it("keeps the canonical maker profile on a writable Node scratch mount", async () => { + const makerSessionDir = mkdtempSync( + join(tmpdir(), "wasm-posix-maker-profile-"), + ); + const mounts = await resolveForNode( + DEFAULT_MOUNT_SPEC, + image, + makerSessionDir, + ); + const home = mounts.find((m) => m.mountPoint === "/home/maker"); + + try { + expect(home).toBeDefined(); + const data = new TextEncoder().encode("maker node profile"); + const fd = home!.backend.open( + "/profile.txt", + O_WRONLY | O_CREAT | O_TRUNC, + 0o644, + ); + home!.backend.write(fd, data, null, data.length); + home!.backend.close(fd); + expect( + readFileSync( + join(makerSessionDir, "home", "maker", "profile.txt"), + "utf8", + ), + ).toBe("maker node profile"); + } finally { + rmSync(makerSessionDir, { recursive: true, force: true }); + } + }); + it("pre-creates every scratch directory under sessionDir", async () => { await resolveForNode(DEFAULT_MOUNT_SPEC, image, sessionDir); for (const spec of DEFAULT_MOUNT_SPEC) { @@ -210,7 +242,7 @@ describe("resolveForNode", () => { ); const tmp = mounts.find((m) => m.mountPoint === "/tmp")!; const varTmp = mounts.find((m) => m.mountPoint === "/var/tmp")!; - const home = mounts.find((m) => m.mountPoint === "/home/user")!; + const home = mounts.find((m) => m.mountPoint === "/home/maker")!; const root = mounts.find((m) => m.mountPoint === "/root")!; try { @@ -698,24 +730,40 @@ describe("resolveForBrowser", () => { expect(new TextDecoder().decode(passwd)).toContain("root:x:0:0"); }); - it("scratch mounts are independent writable memfs instances", async () => { + it("keeps the maker profile on an independent writable browser scratch mount", async () => { const mounts = await resolveForBrowser(DEFAULT_MOUNT_SPEC, image, { scratchSabBytes: tinyScratch, }); const tmp = mounts.find((m) => m.mountPoint === "/tmp"); - const home = mounts.find((m) => m.mountPoint === "/home/user"); + const home = mounts.find((m) => m.mountPoint === "/home/maker"); expect(tmp).toBeDefined(); expect(home).toBeDefined(); expect(tmp!.backend).not.toBe(home!.backend); - const data = new TextEncoder().encode("scratch"); - const fd = tmp!.backend.open("/x.txt", O_WRONLY | O_CREAT | O_TRUNC, 0o644); - tmp!.backend.write(fd, data, null, data.length); - tmp!.backend.close(fd); + const tmpData = new TextEncoder().encode("tmp scratch"); + const tmpFd = tmp!.backend.open( + "/x.txt", + O_WRONLY | O_CREAT | O_TRUNC, + 0o644, + ); + tmp!.backend.write(tmpFd, tmpData, null, tmpData.length); + tmp!.backend.close(tmpFd); expect(new TextDecoder().decode(readMountFile(tmp!.backend, "/x.txt"))).toBe( - "scratch", + "tmp scratch", + ); + + const homeData = new TextEncoder().encode("profile scratch"); + const homeFd = home!.backend.open( + "/profile.txt", + O_WRONLY | O_CREAT | O_TRUNC, + 0o644, ); - expect(() => home!.backend.stat("/x.txt")).toThrow(); + home!.backend.write(homeFd, homeData, null, homeData.length); + home!.backend.close(homeFd); + expect( + new TextDecoder().decode(readMountFile(home!.backend, "/profile.txt")), + ).toBe("profile scratch"); + expect(() => tmp!.backend.stat("/profile.txt")).toThrow(); }); it("applies declared scratch root modes", async () => { @@ -724,7 +772,7 @@ describe("resolveForBrowser", () => { }); const tmp = mounts.find((m) => m.mountPoint === "/tmp")!.backend as MemoryFileSystem; const varTmp = mounts.find((m) => m.mountPoint === "/var/tmp")!.backend as MemoryFileSystem; - const home = mounts.find((m) => m.mountPoint === "/home/user")!.backend as MemoryFileSystem; + const home = mounts.find((m) => m.mountPoint === "/home/maker")!.backend as MemoryFileSystem; const root = mounts.find((m) => m.mountPoint === "/root")!.backend as MemoryFileSystem; expect(tmp.stat("/").mode & 0o7777).toBe(0o1777); expect(varTmp.stat("/").mode & 0o7777).toBe(0o1777); diff --git a/images/rootfs/etc/group b/images/rootfs/etc/group index 8c27ceccc3..99fd451774 100644 --- a/images/rootfs/etc/group +++ b/images/rootfs/etc/group @@ -5,4 +5,5 @@ nobody:x:65534: www-data:x:33: redis:x:100: mysql:x:101: -user:x:1000: +wheel:x:10:maker +maker:x:1000: diff --git a/images/rootfs/etc/motd.autologin b/images/rootfs/etc/motd.autologin new file mode 100644 index 0000000000..e08403ba24 --- /dev/null +++ b/images/rootfs/etc/motd.autologin @@ -0,0 +1,6 @@ +Welcome to Kandelo! + +Every new terminal logs in automatically. + +login: maker +password: kandelo diff --git a/images/rootfs/etc/passwd b/images/rootfs/etc/passwd index 8094d8da8a..94edb7f43a 100644 --- a/images/rootfs/etc/passwd +++ b/images/rootfs/etc/passwd @@ -4,4 +4,4 @@ nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin redis:x:100:100:redis:/var/lib/redis:/usr/sbin/nologin mysql:x:101:101:mysql:/var/lib/mysql:/usr/sbin/nologin -user:x:1000:1000:user:/home/user:/bin/sh +maker:x:1000:1000:maker:/home/maker:/bin/sh diff --git a/images/rootfs/etc/shadow b/images/rootfs/etc/shadow index 21f625b780..a9ba2034f1 100644 --- a/images/rootfs/etc/shadow +++ b/images/rootfs/etc/shadow @@ -4,4 +4,4 @@ nobody:*:0:0:99999:7::: www-data:*:0:0:99999:7::: redis:*:0:0:99999:7::: mysql:*:0:0:99999:7::: -user:*:0:0:99999:7::: +maker:$6$kandelo$DKNPruix37YeUx9j4kJIGJ2NvXdqzxDr5b1D3xJZzbwFsNYuep8j3AtxB7OaTD6HWnz/adonyTamRx4XQwJ06/:0:0:99999:7::: diff --git a/images/rootfs/etc/sudoers b/images/rootfs/etc/sudoers new file mode 100644 index 0000000000..40695bb149 --- /dev/null +++ b/images/rootfs/etc/sudoers @@ -0,0 +1 @@ +%wheel ALL=(ALL:ALL) ALL diff --git a/images/vfs/lib/demo-login.ts b/images/vfs/lib/demo-login.ts new file mode 100644 index 0000000000..ab655dcb96 --- /dev/null +++ b/images/vfs/lib/demo-login.ts @@ -0,0 +1,215 @@ +import type { MemoryFileSystem } from "../../../host/src/vfs/memory-fs"; + +export const DEMO_LOGIN_USERNAME = "maker"; +export const DEMO_LOGIN_HOME = "/home/maker"; +export const DEMO_LOGIN_PASSWORD = "kandelo"; +export const DEMO_LOGIN_PASSWORD_HASH = + "$6$kandelo$DKNPruix37YeUx9j4kJIGJ2NvXdqzxDr5b1D3xJZzbwFsNYuep8j3AtxB7OaTD6HWnz/adonyTamRx4XQwJ06/"; +export const DEMO_LOGIN_PROGRAM_PATH = "/usr/bin/login"; +export const DEMO_AUTOLOGIN_MOTD_PATH = "/etc/motd.autologin"; +export const DEMO_SUDOERS_PATH = "/etc/sudoers"; +export const DEMO_SUDOERS = "%wheel ALL=(ALL:ALL) ALL\n"; +export const DEMO_AUTOLOGIN_MOTD = [ + "Welcome to Kandelo!", + "", + "Every new terminal logs in automatically.", + "", + `login: ${DEMO_LOGIN_USERNAME}`, + `password: ${DEMO_LOGIN_PASSWORD}`, + "", +].join("\n"); + +export interface DemoLoginOptions { + home?: string; + shell?: string; +} + +/** + * Opt an image into the real guest login path. Account databases, policy, and + * the preauthentication-only greeting remain ordinary VFS files consumed by + * libc and the guest programs. + */ +export function configureDemoLogin( + fs: MemoryFileSystem, + options: DemoLoginOptions = {}, +): void { + const home = options.home ?? DEMO_LOGIN_HOME; + const shell = options.shell ?? "/bin/bash"; + const passwd = updateRequiredRecord( + readVfsText(fs, "/etc/passwd"), + DEMO_LOGIN_USERNAME, + (fields) => { + fields[5] = home; + fields[6] = shell; + }, + ); + const shadow = updateRequiredRecord( + readVfsText(fs, "/etc/shadow"), + DEMO_LOGIN_USERNAME, + (fields) => { + fields[1] = DEMO_LOGIN_PASSWORD_HASH; + }, + ); + const group = addGroupMember( + readVfsText(fs, "/etc/group"), + "wheel", + 10, + DEMO_LOGIN_USERNAME, + ); + + writeRootFile(fs, "/etc/passwd", passwd, 0o644); + writeRootFile(fs, "/etc/shadow", shadow, 0o640); + writeRootFile(fs, "/etc/group", group, 0o644); + writeRootFile(fs, DEMO_SUDOERS_PATH, DEMO_SUDOERS, 0o440); + writeRootFile(fs, DEMO_AUTOLOGIN_MOTD_PATH, DEMO_AUTOLOGIN_MOTD, 0o644); +} + +/** + * True when the canonical account/policy files and a root-owned set-ID login + * entry are staged. Task 7's privileged-product publication remains the + * authority that proves the executable bytes and trusted mount provenance. + */ +export function hasConfiguredDemoLogin(fs: MemoryFileSystem): boolean { + try { + const login = fs.stat(DEMO_LOGIN_PROGRAM_PATH); + const loginIsStaged = + (login.mode & 0o170000) === 0o100000 && + (login.mode & 0o7777) === 0o4755 && + login.uid === 0 && + login.gid === 0 && + fs.getLazyEntry(DEMO_LOGIN_PROGRAM_PATH) === null; + const shadowMetadata = fs.stat("/etc/shadow"); + const sudoersMetadata = fs.stat(DEMO_SUDOERS_PATH); + const passwd = readVfsText(fs, "/etc/passwd"); + const shadow = readVfsText(fs, "/etc/shadow"); + const group = readVfsText(fs, "/etc/group"); + const sudoers = readVfsText(fs, DEMO_SUDOERS_PATH); + const accountIsCanonical = passwd.split("\n").some((line) => { + const fields = line.split(":"); + const shell = fields[6] ?? ""; + return ( + fields[0] === DEMO_LOGIN_USERNAME && + fields[2] === "1000" && + fields[3] === "1000" && + shell.length > 0 && + !shell.endsWith("/nologin") + ); + }); + const accountCanAuthenticate = shadow.split("\n").some((line) => { + const fields = line.split(":"); + const hash = fields[1] ?? ""; + return ( + fields[0] === DEMO_LOGIN_USERNAME && + hash.length > 0 && + hash !== "x" && + hash[0] !== "!" && + hash[0] !== "*" + ); + }); + const wheelAllowsMaker = group.split("\n").some((line) => { + const fields = line.split(":"); + return ( + fields[0] === "wheel" && + fields[2] === "10" && + (fields[3] ?? "").split(",").includes(DEMO_LOGIN_USERNAME) + ); + }); + return ( + loginIsStaged && + shadowMetadata.uid === 0 && + shadowMetadata.gid === 0 && + (shadowMetadata.mode & 0o7777) === 0o640 && + sudoersMetadata.uid === 0 && + sudoersMetadata.gid === 0 && + (sudoersMetadata.mode & 0o7777) === 0o440 && + accountIsCanonical && + accountCanAuthenticate && + wheelAllowsMaker && + sudoers === DEMO_SUDOERS + ); + } catch { + return false; + } +} + +function updateRequiredRecord( + content: string, + name: string, + update: (fields: string[]) => void, +): string { + let found = false; + const lines = content + .replace(/\n$/, "") + .split("\n") + .map((line) => { + const fields = line.split(":"); + if (fields[0] !== name) return line; + found = true; + update(fields); + return fields.join(":"); + }); + if (!found) throw new Error(`demo login account ${name} is missing`); + return `${lines.join("\n")}\n`; +} + +function addGroupMember( + content: string, + name: string, + gid: number, + member: string, +): string { + let found = false; + const lines = content + .replace(/\n$/, "") + .split("\n") + .map((line) => { + const fields = line.split(":"); + if (fields[0] !== name) return line; + found = true; + const members = new Set((fields[3] ?? "").split(",").filter(Boolean)); + members.add(member); + fields[3] = Array.from(members).join(","); + return fields.join(":"); + }); + if (!found) lines.push(`${name}:x:${gid}:${member}`); + return `${lines.join("\n")}\n`; +} + +function readVfsText(fs: MemoryFileSystem, path: string): string { + const st = fs.stat(path); + const fd = fs.open(path, 0, 0); + try { + const bytes = new Uint8Array(st.size); + let offset = 0; + while (offset < bytes.length) { + const count = fs.read( + fd, + bytes.subarray(offset), + null, + bytes.length - offset, + ); + if (count <= 0) break; + offset += count; + } + return new TextDecoder().decode(bytes.subarray(0, offset)); + } finally { + fs.close(fd); + } +} + +function writeRootFile( + fs: MemoryFileSystem, + path: string, + content: string, + mode: number, +): void { + const bytes = new TextEncoder().encode(content); + const fd = fs.open(path, 0o1101, mode); + try { + fs.write(fd, bytes, 0, bytes.length); + } finally { + fs.close(fd); + } + fs.chown(path, 0, 0); + fs.chmod(path, mode); +} diff --git a/images/vfs/lib/init/spidermonkey-npm-runtime.ts b/images/vfs/lib/init/spidermonkey-npm-runtime.ts index 0524a77940..890c1c5c4f 100644 --- a/images/vfs/lib/init/spidermonkey-npm-runtime.ts +++ b/images/vfs/lib/init/spidermonkey-npm-runtime.ts @@ -59,6 +59,19 @@ process.argv.splice(2, 0, 'npx'); require('/usr/local/lib/kandelo/npm-runner.js'); `; +export const NODE_WORKSPACE_PROFILE_PATH = + "/etc/profile.d/kandelo-node-workspace.sh"; + +export const NODE_WORKSPACE_PROFILE = `# Initialize the Node demo in its mounted canonical home. +if [ "\${HOME:-}" = /home/maker ]; then + cd "$HOME" || return 1 + if [ ! -e package.json ]; then + umask 022 + printf '%s\\n' '{' ' "name": "demo",' ' "version": "0.0.1"' '}' > package.json || return 1 + fi +fi +`; + export const NPM_DISPLAY_SHIM = `function plain(...args) { return args.map((arg) => String(arg)).join(' '); } @@ -108,6 +121,7 @@ export function stageSpiderMonkeyNpmRuntime(fs: MemoryFileSystem): void { ensureDirRecursive(fs, "/usr/bin"); ensureDirRecursive(fs, "/usr/local/bin"); ensureDirRecursive(fs, "/usr/local/lib/kandelo"); + ensureDirRecursive(fs, "/etc/profile.d"); writeVfsFile(fs, "/usr/local/lib/kandelo/npm-runner.js", NPM_RUNNER, 0o644); writeVfsFile(fs, "/usr/local/lib/kandelo/npm-display-shim.js", NPM_DISPLAY_SHIM, 0o644); @@ -120,6 +134,7 @@ export function stageSpiderMonkeyNpmRuntime(fs: MemoryFileSystem): void { symlink(fs, "/usr/bin/npm", "/usr/local/bin/npm"); symlink(fs, "/usr/bin/npx", "/bin/npx"); symlink(fs, "/usr/bin/npx", "/usr/local/bin/npx"); + writeVfsFile(fs, NODE_WORKSPACE_PROFILE_PATH, NODE_WORKSPACE_PROFILE, 0o644); } export function patchNpmForSpiderMonkey(fs: MemoryFileSystem): void { diff --git a/images/vfs/scripts/build-homebrew-vfs-image.ts b/images/vfs/scripts/build-homebrew-vfs-image.ts index b5499ac9bc..bff36e35c9 100644 --- a/images/vfs/scripts/build-homebrew-vfs-image.ts +++ b/images/vfs/scripts/build-homebrew-vfs-image.ts @@ -259,7 +259,7 @@ const HOMEBREW_BOOTSTRAP_MUTABLE_PATHS = [ `${HOMEBREW_BOOTSTRAP_PREFIX}/Library/Taps`, `${HOMEBREW_BOOTSTRAP_PREFIX}/var/homebrew/linked`, `${HOMEBREW_BOOTSTRAP_PREFIX}/var/homebrew/locks`, - "/home/user/.cache/Homebrew", + "/home/maker/.cache/Homebrew", ] as const; const MAX_HOMEBREW_BOOTSTRAP_ENV_BYTES = 1024; const MAX_SIDECAR_JSON_BYTES = 16_777_216; @@ -1627,7 +1627,7 @@ export function prepareHomebrewBootstrapConsumerNamespace( // root; adopting the complete prefix here both avoids a false lazy-tree // collision and lets in-guest brew update Cellar, taps, links, and locks. chownVfsTree(fs, HOMEBREW_BOOTSTRAP_PREFIX, 1000, 1000); - chownVfsTree(fs, "/home/user/.cache", 1000, 1000); + chownVfsTree(fs, "/home/maker/.cache", 1000, 1000); } function chownVfsTree( diff --git a/images/vfs/scripts/build-node-vfs-image.ts b/images/vfs/scripts/build-node-vfs-image.ts index 21b8bb5117..cf7dcd1dcf 100644 --- a/images/vfs/scripts/build-node-vfs-image.ts +++ b/images/vfs/scripts/build-node-vfs-image.ts @@ -7,7 +7,7 @@ * /usr/bin/node — exact resolved Node executable bytes * /usr/local/lib/npm/... — full npm dist (bin/npm-cli.js + lib + node_modules) * /usr/bin/npm — wrapper that runs npm through the node binary - * /work/package.json — empty starter package, used as --prefix and HOME + * /etc/profile.d/... — guest initializer for the mounted maker home * /tmp/ — writable, mode 0o777 * * Excludes npm's man/ and docs/ (man pages + markdown docs add ~3 MB and @@ -56,8 +56,6 @@ const NPM_MOUNT = "/usr/local/lib/npm"; // The Node image contains the complete canonical shell plus npm. It cannot // truthfully advertise a smaller ceiling than its 512 MiB shell base. const NODE_IMAGE_MAX_BYTES = SHELL_DERIVED_VFS_PROFILE_MAX_BYTES; -const DEMO_UID = 1000; -const DEMO_GID = 1000; const NODE_WASM_ARTIFACT_POLICY = { path: NODE_BINARY_SPEC.vfsPath, forkInstrumentation: "disabled", @@ -88,11 +86,9 @@ export async function buildNodeVfsImage( // Node/npm workspace additions. ensureDirRecursive(fs, "/usr/local/lib"); - ensureDirRecursive(fs, "/work"); // /etc/ssl needs to exist before the browser kernel worker auto-writes // the MITM CA cert to /etc/ssl/certs/ca-certificates.crt on init. ensureDirRecursive(fs, "/etc/ssl"); - fs.chmod("/work", 0o777); // npm dist — skip man/ and docs/ (not used at install time) console.log(`Mounting npm dist at ${NPM_MOUNT}...`); @@ -102,17 +98,6 @@ export async function buildNodeVfsImage( }); console.log(` ${written} files written`); stageSpiderMonkeyNpmRuntime(fs); - - // Starter package.json so `npm install --prefix /work` has somewhere to write. - fs.createFileWithOwner( - "/work/package.json", - 0o644, - DEMO_UID, - DEMO_GID, - new TextEncoder().encode( - JSON.stringify({ name: "demo", version: "0.0.1" }, null, 2) + "\n", - ), - ); writeKandeloDemoConfig(fs, { version: 1, profiles: { diff --git a/images/vfs/scripts/dinit-image-helpers.ts b/images/vfs/scripts/dinit-image-helpers.ts index 6242020484..80899cc51c 100644 --- a/images/vfs/scripts/dinit-image-helpers.ts +++ b/images/vfs/scripts/dinit-image-helpers.ts @@ -232,7 +232,7 @@ const ETC_PASSWD = [ "www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin", "redis:x:100:100:redis:/var/lib/redis:/usr/sbin/nologin", "mysql:x:101:101:mysql:/var/lib/mysql:/usr/sbin/nologin", - "user:x:1000:1000:user:/home/user:/bin/sh", + "maker:x:1000:1000:maker:/home/maker:/bin/sh", "", ].join("\n"); @@ -244,7 +244,7 @@ const ETC_GROUP = [ "www-data:x:33:", "redis:x:100:", "mysql:x:101:", - "user:x:1000:", + "maker:x:1000:", "", ].join("\n"); diff --git a/images/vfs/scripts/shell-vfs-build.ts b/images/vfs/scripts/shell-vfs-build.ts index 15e94cf26e..d3f1931736 100644 --- a/images/vfs/scripts/shell-vfs-build.ts +++ b/images/vfs/scripts/shell-vfs-build.ts @@ -688,11 +688,93 @@ export function populateShellEnvironment( // ── System layout ─────────────────────────────────────────────── function populateSystem(fs: MemoryFileSystem): void { - populateShellRuntimeLayout(fs); + for (const dir of [ + "/bin", "/usr", "/usr/bin", "/usr/local", "/usr/local/bin", + "/usr/share", "/usr/share/misc", "/usr/share/file", + "/etc", "/root", "/tmp", "/home", "/home/maker", "/dev", "/usr/sbin", + // NetHack VAR_PLAYGROUND — writable saves, scores, bones. + "/home/.nethack", + ]) { + ensureDirRecursive(fs, dir); + } + fs.chmod("/tmp", 0o1777); + fs.chmod("/root", 0o700); + fs.chown("/home/maker", 1000, 1000); + populateNetHackPlayground(fs); + + const gitconfig = [ + "[maintenance]", + "\tauto = false", + "[gc]", + "\tauto = 0", + "[core]", + "\tpager = cat", + "[user]", + "\tname = Maker", + "\temail = maker@wasm.local", + "[init]", + "\tdefaultBranch = main", + "", + ].join("\n"); + writeVfsFile(fs, "/etc/gitconfig", gitconfig); + + // Shell profile — color aliases + NetHack defaults. NetHack's + // VAR_PLAYGROUND is pre-created above, so the profile only sets env. + const profile = [ + "alias ls='ls --color=auto'", + "alias grep='grep --color=auto'", + "export USER=maker", + "export NETHACKOPTIONS='windowtype:curses,color,lit_corridor,hilite_pet'", + "for kandelo_profile in /etc/profile.d/*.sh; do", + " [ -r \"$kandelo_profile\" ] && . \"$kandelo_profile\"", + "done", + "unset kandelo_profile", + "", + ].join("\n"); + writeVfsFile(fs, "/etc/profile", profile); } function populateShellOverlay(fs: MemoryFileSystem): void { - populateShellRuntimeLayout(fs); + for (const dir of [ + "/bin", "/usr", "/usr/bin", "/usr/local", "/usr/local/bin", + "/usr/share", "/usr/share/file", "/etc", "/root", "/tmp", "/home", + "/home/maker", "/dev", "/usr/sbin", "/home/.nethack", + ]) { + ensureDirRecursive(fs, dir); + } + fs.chmod("/tmp", 0o1777); + fs.chmod("/root", 0o700); + fs.chown("/home/maker", 1000, 1000); + populateNetHackPlayground(fs); + + const gitconfig = [ + "[maintenance]", + "\tauto = false", + "[gc]", + "\tauto = 0", + "[core]", + "\tpager = cat", + "[user]", + "\tname = Maker", + "\temail = maker@wasm.local", + "[init]", + "\tdefaultBranch = main", + "", + ].join("\n"); + writeVfsFile(fs, "/etc/gitconfig", gitconfig); + + const profile = [ + "alias ls='ls --color=auto'", + "alias grep='grep --color=auto'", + "export USER=maker", + "export NETHACKOPTIONS='windowtype:curses,color,lit_corridor,hilite_pet'", + "for kandelo_profile in /etc/profile.d/*.sh; do", + " [ -r \"$kandelo_profile\" ] && . \"$kandelo_profile\"", + "done", + "unset kandelo_profile", + "", + ].join("\n"); + writeVfsFile(fs, "/etc/profile", profile); // A rootfs artifact may provide the lazy binary inodes without the // user-facing aliases the shell demo expects. Recreate the aliases diff --git a/packages/registry/node-vfs/package.toml b/packages/registry/node-vfs/package.toml index 63bf31589e..7057d2e5bc 100644 --- a/packages/registry/node-vfs/package.toml +++ b/packages/registry/node-vfs/package.toml @@ -5,11 +5,11 @@ kernel_abi = 7 depends_on = ["node@0.1.0", "shell@0.1.0"] # Pre-built VFS image layered on the resolved shell.vfs.zst image, adding the -# exact resolved Node executable, npm 10.9.2, and a writable /work tree for the -# browser Node demos. Node is eager because this dedicated image always uses -# it; shell's own package owns the complete flat Homebrew filesystem and its -# authenticated composition, which node-vfs preserves instead of reconstructing -# from transitive dependencies. +# exact resolved Node executable, npm 10.9.2, and a guest profile that creates +# the starter workspace inside the mounted canonical /home/maker. Node is +# eager because this dedicated image always uses it; shell's own package still +# owns the lazy shell utility/archive inputs and node-vfs must not reconstruct +# that environment from transitive dependencies. [source] url = "https://registry.npmjs.org/npm/-/npm-10.9.2.tgz" sha256 = "5cd1e5ab971ea6333f910bc2d50700167c5ef4e66da279b2a3efc874c6b116e4" diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index 50466cbf83..848afcd4a9 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -158,8 +158,8 @@ "lamp": { "manifestSha256": "250b64635d64f8537178188fb5488dbfd2980d79a1c2ffbf346af67008088ba0", "cacheKeys": { - "wasm32": "7cdbe3ffe057d215c2a821636eb419dcced02d242b6c1249ce151d841ed44bde", - "wasm64": "a2a1c3b59dfb32ff9165021de48a3dd8120987fec57b04083f8afad891679d1a" + "wasm32": "a98443715c1bb0f1c1986a3bda1b11d09b9d1aed9b7af3bfe6602ea3b3b6ab7a", + "wasm64": "ee739fca808723e9461b80c352946ed83becf5fdf148b8178b5a7414a41b8676" } }, "less": { @@ -242,15 +242,15 @@ "mariadb-test": { "manifestSha256": "d4ccce161d659a5a87c80fa1117054bbccea870e0d4a46bd8a070d3930ad0e3f", "cacheKeys": { - "wasm32": "a0a852b02a9aa96daae3976cfa7b1cd6812669fe92ab7734d6795aa00273131c", - "wasm64": "dfef22d594c75fd36c8cd9b5aef8ab2d611d98abb2b8846ebba445de22aaacce" + "wasm32": "1bfee2a38b4585a79ccf13385402b26336d471cbb83bfdbea55457d2918eee7a", + "wasm64": "57e71d6ee11a4e7554fe95e6f11b74c5df7573d388d3a98e15772dc8d343ec85" } }, "mariadb-vfs": { "manifestSha256": "26e74ac84d89b2a839ed72783437061a23022ef2f88ad0f52b6aacd0442ee1cf", "cacheKeys": { - "wasm32": "3dd7c2e9e51c9adea3d3b52b1a8026281a0db6851900c2b9eb1a5cebd8d46db0", - "wasm64": "a430226e39f0ca6d815100e6ce7bc868f144f081f9cf432f2486a74c6c13a77c" + "wasm32": "3fd1adb0598b2c2c061d9985786dee0769d61e71f6acacec39b6a4ce6f4bce0a", + "wasm64": "cefbefb55d6a9238d88f7c40d94be06dcb8ae1ec2846fd4eb5ba5e37e175aa04" } }, "modeset": { @@ -312,15 +312,15 @@ "nginx-php-vfs": { "manifestSha256": "97976410cb02f8ba710d856b4ac904bcf976d677b50eefdee39fb64176070d4b", "cacheKeys": { - "wasm32": "8dba20b1b93775b7ed4b93f304660ca4608d0037e007951b78f437837a6983bd", - "wasm64": "08f2526652948f7ab009b58a97ad55a6d19bb531021f58f3cf6f7e09edc529f6" + "wasm32": "89cff29e4b5d556d19999fbb6b5184706b9588f267b5d885d4244300e2710d7a", + "wasm64": "bf6abe9c6e9dd97477dfda9d19ca23a9180c833a4ec47bc4da47614d6ed3e662" } }, "nginx-vfs": { "manifestSha256": "46aa2d3250ac5cd0f102a85c3a36a086c7a50ba1f9e05fa1c2aae3d07ad16f10", "cacheKeys": { - "wasm32": "f4ec4615857967396cfc60a8de0221d49d9d5af58d85b2a844be5d5f5fdadc33", - "wasm64": "b4d3f67029626972290f1b2fa45a80c322aaf11827b2805f44e8a82f4dc001b8" + "wasm32": "47d092779945fbc70eebb1c757202c336b472333ce25c233a1c3bcde19bea0a3", + "wasm64": "9b743535437d77a5e57de3cb5becffbc4c121eb43be6f461d00e612c57efac2b" } }, "node": { @@ -331,10 +331,10 @@ } }, "node-vfs": { - "manifestSha256": "977f219defe57e18017ecc963159df32efdd21c53d6fea05943703d04739d8de", + "manifestSha256": "a4a41b2b06da60ed2a89470caf54d390f2981b1e8cf55e07cf95b376e15011d6", "cacheKeys": { - "wasm32": "785793c52437194d88f54f62c69bc17713138c18ce34bc7da6afa5b0389632d8", - "wasm64": "e900e2f9a7863da7ee698d5ec041b8b57a81e5f7f6fde4167cfd3808ba1371a3" + "wasm32": "571346722517dca220a3af2d20536d24113252b53af8cf01a19de63dc975fd9f", + "wasm64": "cc01c546e80129c8720791ced7da9813bb57a8ec62164db72d134dffaefd88a6" } }, "openssl": { @@ -396,15 +396,15 @@ "redis-vfs": { "manifestSha256": "234c45c40f94e2a89f98295313b82cb9fce15a412ac829d9e87394e0dc731813", "cacheKeys": { - "wasm32": "958d9b9270a57e643af8cb6d7f115f9a553af95f56a674d2f89080e8dc991562", - "wasm64": "87d0d461a226d438229084a1cc36c67d4a1956224263c1b313d1a824b3c191fe" + "wasm32": "183561f71f8b7eb62c28b687853dc739fd0ca734d6ac1c5e5a120bbc40b5cc7b", + "wasm64": "3a6b2784b542c5653bac45728c26656cab4e1548a89d051526348a7b0c1d5e27" } }, "rootfs": { "manifestSha256": "0420201025170f48c32949ac62707d4577cdc13a53ad1f01f59d318ec07c8d6e", "cacheKeys": { - "wasm32": "cb56c70a95c61d714cb6c525a567b80c7f8d7fb2b6f2056910b58d9243a857c7", - "wasm64": "c4d26e9e0c91ac6fe3df7c51aebb22e9ae9fd22daa8de3afff0012a858d59dbe" + "wasm32": "faed2e0332805c98637d684fd5f922172f6aa7b0cf0055ec4be41e80a007aaa8", + "wasm64": "92fc100ce5aa50c34d5f8d7bcffd431db440a90290f9af4c118706905d4d0aea" } }, "ruby": { @@ -452,8 +452,8 @@ "shell": { "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", "cacheKeys": { - "wasm32": "25d79cdafd592b123a20ce0da5fe2c0571f92d8bdb70270fe06d04e9eb93f5d7", - "wasm64": "f1eb210b33514d7c98cb5dbe0b7bb4f116b75d0ddd3b2e55ad6d997748549cfc" + "wasm32": "aa84316cadca3547e61b0609c3e529db9bd37dec34b7b7086a268c478ac2eb78", + "wasm64": "3b1bcd6cf7d6e6c7ad6b30a3ae184470c8b6f3fbc6926db0a83804c0f07c3618" } }, "spidermonkey": { @@ -543,8 +543,8 @@ "wordpress": { "manifestSha256": "36465e0596a06e855a8524eecfd87da98643bc13b37ee12939f5aab44bf33418", "cacheKeys": { - "wasm32": "fc621e5bcf2918136f7aea76a5617b3d7ed5e52e0a91f95e0ecaba3819da96f5", - "wasm64": "bd188f91a6c71fc52eef5c6c2e7c0dd50f06ee6d1f526f032cedad5a3aec9eee" + "wasm32": "1ca1f24ae8f52f86e5354b869d9ed9434fabf3f415a5f3935de49c549060adb7", + "wasm64": "822cf5b8dec2f6114671bead6f3c9ebfbc023a9297a24791313c10aa84891a9b" } }, "xz": { @@ -1121,7 +1121,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "7cdbe3ffe057d215c2a821636eb419dcced02d242b6c1249ce151d841ed44bde" + "wasm32": "a98443715c1bb0f1c1986a3bda1b11d09b9d1aed9b7af3bfe6602ea3b3b6ab7a" }, "dependencyClosures": { "wasm32": [ @@ -1193,7 +1193,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "25d79cdafd592b123a20ce0da5fe2c0571f92d8bdb70270fe06d04e9eb93f5d7" + "cacheKey": "aa84316cadca3547e61b0609c3e529db9bd37dec34b7b7086a268c478ac2eb78" }, { "packageName": "sqlite", @@ -1360,7 +1360,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a0a852b02a9aa96daae3976cfa7b1cd6812669fe92ab7734d6795aa00273131c" + "wasm32": "1bfee2a38b4585a79ccf13385402b26336d471cbb83bfdbea55457d2918eee7a" }, "dependencyClosures": { "wasm32": [ @@ -1413,8 +1413,8 @@ "wasm64" ], "cacheKeys": { - "wasm32": "3dd7c2e9e51c9adea3d3b52b1a8026281a0db6851900c2b9eb1a5cebd8d46db0", - "wasm64": "a430226e39f0ca6d815100e6ce7bc868f144f081f9cf432f2486a74c6c13a77c" + "wasm32": "3fd1adb0598b2c2c061d9985786dee0769d61e71f6acacec39b6a4ce6f4bce0a", + "wasm64": "cefbefb55d6a9238d88f7c40d94be06dcb8ae1ec2846fd4eb5ba5e37e175aa04" }, "dependencyClosures": { "wasm32": [ @@ -1746,7 +1746,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "8dba20b1b93775b7ed4b93f304660ca4608d0037e007951b78f437837a6983bd" + "wasm32": "89cff29e4b5d556d19999fbb6b5184706b9588f267b5d885d4244300e2710d7a" }, "dependencyClosures": { "wasm32": [ @@ -1808,7 +1808,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "25d79cdafd592b123a20ce0da5fe2c0571f92d8bdb70270fe06d04e9eb93f5d7" + "cacheKey": "aa84316cadca3547e61b0609c3e529db9bd37dec34b7b7086a268c478ac2eb78" }, { "packageName": "sqlite", @@ -1838,7 +1838,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f4ec4615857967396cfc60a8de0221d49d9d5af58d85b2a844be5d5f5fdadc33" + "wasm32": "47d092779945fbc70eebb1c757202c336b472333ce25c233a1c3bcde19bea0a3" }, "dependencyClosures": { "wasm32": [ @@ -1860,7 +1860,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "25d79cdafd592b123a20ce0da5fe2c0571f92d8bdb70270fe06d04e9eb93f5d7" + "cacheKey": "aa84316cadca3547e61b0609c3e529db9bd37dec34b7b7086a268c478ac2eb78" } ] }, @@ -1917,12 +1917,12 @@ ] }, "node-vfs": { - "manifestSha256": "977f219defe57e18017ecc963159df32efdd21c53d6fea05943703d04739d8de", + "manifestSha256": "a4a41b2b06da60ed2a89470caf54d390f2981b1e8cf55e07cf95b376e15011d6", "arches": [ "wasm32" ], "cacheKeys": { - "wasm32": "785793c52437194d88f54f62c69bc17713138c18ce34bc7da6afa5b0389632d8" + "wasm32": "571346722517dca220a3af2d20536d24113252b53af8cf01a19de63dc975fd9f" }, "dependencyClosures": { "wasm32": [ @@ -1944,7 +1944,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "25d79cdafd592b123a20ce0da5fe2c0571f92d8bdb70270fe06d04e9eb93f5d7" + "cacheKey": "aa84316cadca3547e61b0609c3e529db9bd37dec34b7b7086a268c478ac2eb78" }, { "packageName": "spidermonkey", @@ -2478,7 +2478,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "958d9b9270a57e643af8cb6d7f115f9a553af95f56a674d2f89080e8dc991562" + "wasm32": "183561f71f8b7eb62c28b687853dc739fd0ca734d6ac1c5e5a120bbc40b5cc7b" }, "dependencyClosures": { "wasm32": [ @@ -2515,7 +2515,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "cb56c70a95c61d714cb6c525a567b80c7f8d7fb2b6f2056910b58d9243a857c7" + "wasm32": "faed2e0332805c98637d684fd5f922172f6aa7b0cf0055ec4be41e80a007aaa8" }, "dependencyClosures": { "wasm32": [ @@ -2728,7 +2728,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "25d79cdafd592b123a20ce0da5fe2c0571f92d8bdb70270fe06d04e9eb93f5d7" + "wasm32": "aa84316cadca3547e61b0609c3e529db9bd37dec34b7b7086a268c478ac2eb78" }, "dependencyClosures": { "wasm32": [] @@ -3020,7 +3020,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "fc621e5bcf2918136f7aea76a5617b3d7ed5e52e0a91f95e0ecaba3819da96f5" + "wasm32": "1ca1f24ae8f52f86e5354b869d9ed9434fabf3f415a5f3935de49c549060adb7" }, "dependencyClosures": { "wasm32": [ @@ -3082,7 +3082,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "25d79cdafd592b123a20ce0da5fe2c0571f92d8bdb70270fe06d04e9eb93f5d7" + "cacheKey": "aa84316cadca3547e61b0609c3e529db9bd37dec34b7b7086a268c478ac2eb78" }, { "packageName": "sqlite", diff --git a/packages/registry/spidermonkey/test/spidermonkey-node-compat.test.ts b/packages/registry/spidermonkey/test/spidermonkey-node-compat.test.ts index 3d483ef6c7..670c3f9321 100644 --- a/packages/registry/spidermonkey/test/spidermonkey-node-compat.test.ts +++ b/packages/registry/spidermonkey/test/spidermonkey-node-compat.test.ts @@ -430,11 +430,14 @@ describe.skipIf(!nodeWasm)("SpiderMonkey Node compatibility runtime", () => { describe.skipIf(!hasNpm)("npm package installation", () => { it("installs cowsay with npm and runs its package bin", async () => { const tempDir = mkdtempSync(join(tmpdir(), "sm-node-npm-")); - const workDir = join(tempDir, "work"); + const homeDir = join(tempDir, "home-maker"); const tmpMountDir = join(tempDir, "tmp"); - mkdirSync(workDir, { recursive: true }); + mkdirSync(homeDir, { recursive: true }); mkdirSync(tmpMountDir, { recursive: true }); - writeFileSync(join(workDir, "package.json"), JSON.stringify({ name: "demo", version: "0.0.1" })); + writeFileSync( + join(homeDir, "package.json"), + JSON.stringify({ name: "demo", version: "0.0.1" }), + ); const { npmDir, helperDir } = prepareNpmRuntime(tempDir); const { registryDir, cowsayTarballFilename } = createCowsayPackages(tempDir); const nodeBytes = loadWasm(nodeWasm!); @@ -444,8 +447,10 @@ describe.skipIf(!nodeWasm)("SpiderMonkey Node compatibility runtime", () => { let stderr = ""; let ptyOutput = ""; const env = [ - "HOME=/work", - "PWD=/work", + "HOME=/home/maker", + "PWD=/home/maker", + "USER=maker", + "LOGNAME=maker", "TMPDIR=/tmp", "TERM=xterm-256color", "LANG=en_US.UTF-8", @@ -467,10 +472,18 @@ describe.skipIf(!nodeWasm)("SpiderMonkey Node compatibility runtime", () => { rootfsImage: "default", extraMounts: [ { mountPoint: "/tmp", hostPath: tmpMountDir, readonly: false }, - { mountPoint: "/usr/local/lib/npm", hostPath: npmDir, readonly: true }, - { mountPoint: "/usr/local/lib/kandelo", hostPath: helperDir, readonly: true }, + { + mountPoint: "/usr/local/lib/npm", + hostPath: npmDir, + readonly: true, + }, + { + mountPoint: "/usr/local/lib/kandelo", + hostPath: helperDir, + readonly: true, + }, { mountPoint: "/registry", hostPath: registryDir, readonly: true }, - { mountPoint: "/work", hostPath: workDir, readonly: false }, + { mountPoint: "/home/maker", hostPath: homeDir, readonly: false }, ], onStdout: (_pid, data) => { stdout += decoder.decode(data); @@ -498,7 +511,14 @@ describe.skipIf(!nodeWasm)("SpiderMonkey Node compatibility runtime", () => { "--no-fund", "--no-audit", ], - { programModule: nodeModule, cwd: "/work", env, pty: true, ptyCols: 100, ptyRows: 30 }, + { + programModule: nodeModule, + cwd: "/home/maker", + env, + pty: true, + ptyCols: 100, + ptyRows: 30, + }, ), ); @@ -510,7 +530,9 @@ describe.skipIf(!nodeWasm)("SpiderMonkey Node compatibility runtime", () => { : ""; expect(installExitCode, `stdout:\n${stdout}\nstderr:\n${stderr}\npty:\n${ptyOutput}\nlogs:\n${npmLogs}`).toBe(0); expect(ptyOutput).toMatch(/added \d+ packages? in /); - expect(existsSync(join(workDir, "node_modules/cowsay/package.json"))).toBe(true); + expect( + existsSync(join(homeDir, "node_modules/cowsay/package.json")), + ).toBe(true); stdout = ""; stderr = ""; @@ -518,8 +540,8 @@ describe.skipIf(!nodeWasm)("SpiderMonkey Node compatibility runtime", () => { "run cowsay bin", host.spawn( nodeBytes, - ["node", "/work/node_modules/.bin/cowsay", "Kandelo"], - { programModule: nodeModule, cwd: "/work", env }, + ["node", "/home/maker/node_modules/.bin/cowsay", "Kandelo"], + { programModule: nodeModule, cwd: "/home/maker", env }, ), ); diff --git a/programs/login.c b/programs/login.c new file mode 100644 index 0000000000..c933c90676 --- /dev/null +++ b/programs/login.c @@ -0,0 +1,246 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static void scrub(char *s) { + if (!s) return; + volatile char *p = s; + while (*p) *p++ = '\0'; +} + +static int secure_streq(const char *a, const char *b) { + size_t alen = strlen(a); + size_t blen = strlen(b); + size_t max = alen > blen ? alen : blen; + size_t diff = alen ^ blen; + for (size_t i = 0; i < max; i++) { + unsigned char ac = i < alen ? (unsigned char)a[i] : 0; + unsigned char bc = i < blen ? (unsigned char)b[i] : 0; + diff |= (size_t)(ac ^ bc); + } + return diff == 0; +} + +static void chomp(char *s) { + size_t len = strlen(s); + while (len > 0 && (s[len - 1] == '\n' || s[len - 1] == '\r')) + s[--len] = '\0'; +} + +static int read_field(const char *prompt, char *buf, size_t buflen, int hide) { + struct termios old_term; + struct termios new_term; + int fd = fileno(stdin); + int restore_echo = 0; + + fputs(prompt, stdout); + fflush(stdout); + if (hide && isatty(fd)) { + if (tcgetattr(fd, &old_term) != 0) { + perror("login: tcgetattr"); + return -1; + } + new_term = old_term; + new_term.c_lflag &= (tcflag_t)~ECHO; + if (tcsetattr(fd, TCSAFLUSH, &new_term) != 0) { + perror("login: tcsetattr"); + return -1; + } + restore_echo = 1; + } + if (!fgets(buf, buflen, stdin)) { + if (restore_echo) { + tcsetattr(fd, TCSAFLUSH, &old_term); + fputc('\n', stdout); + } + return -1; + } + if (restore_echo) { + if (tcsetattr(fd, TCSAFLUSH, &old_term) != 0) { + perror("login: tcsetattr"); + return -1; + } + fputc('\n', stdout); + } + chomp(buf); + return 0; +} + +static int hash_is_locked(const char *hash) { + return !hash || hash[0] == '\0' || hash[0] == '!' || hash[0] == '*'; +} + +static int authenticate(const char *username, const char *password, + struct passwd **out_pw) { + struct passwd *pw = getpwnam(username); + if (!pw) return -1; + struct spwd *sp = getspnam(username); + const char *hash = sp && sp->sp_pwdp ? sp->sp_pwdp : pw->pw_passwd; + if (hash_is_locked(hash) || strcmp(hash, "x") == 0) return -1; + char *computed = crypt(password, hash); + if (!computed || !secure_streq(computed, hash)) return -1; + *out_pw = pw; + return 0; +} + +static int copy_value(char *out, size_t out_size, const char *value) { + int written = snprintf(out, out_size, "%s", value); + return written >= 0 && (size_t)written < out_size ? 0 : -1; +} + +static int set_login_environment(const struct passwd *pw, + int preserve_environment) { + const char *term = getenv("TERM"); + char term_buf[128]; + if (term && copy_value(term_buf, sizeof(term_buf), term) != 0) return -1; + if (!preserve_environment && clearenv() != 0) return -1; + if (setenv("HOME", pw->pw_dir, 1) != 0) + return -1; + if (setenv("SHELL", pw->pw_shell && pw->pw_shell[0] + ? pw->pw_shell : "/bin/sh", 1) != 0) + return -1; + if (setenv("USER", pw->pw_name, 1) != 0) return -1; + if (setenv("LOGNAME", pw->pw_name, 1) != 0) return -1; + if ((!preserve_environment || !getenv("PATH")) && + setenv("PATH", "/usr/local/bin:/usr/bin:/bin", 1) != 0) + return -1; + if (!preserve_environment && term && setenv("TERM", term_buf, 1) != 0) + return -1; + return 0; +} + +static void display_file(const char *path) { + FILE *file = fopen(path, "r"); + if (!file) return; + char buf[512]; + size_t nread; + while ((nread = fread(buf, 1, sizeof(buf), file)) > 0) { + if (fwrite(buf, 1, nread, stdout) != nread) break; + } + fclose(file); +} + +static char *login_shell_argv0(const char *shell) { + const char *base = strrchr(shell, '/'); + base = base ? base + 1 : shell; + if (!base[0]) base = "sh"; + size_t len = strlen(base); + char *argv0 = malloc(len + 2); + if (!argv0) return NULL; + argv0[0] = '-'; + memcpy(argv0 + 1, base, len + 1); + return argv0; +} + +static void usage(const char *argv0) { + fprintf(stderr, "usage: %s [-p] [-f username] [username]\n", argv0); +} + +int main(int argc, char **argv) { + char username[128]; + char password[512] = {0}; + const char *requested_user = NULL; + struct passwd *pw = NULL; + int preauthenticated = 0; + int preserve_environment = 0; + int opt; + + while ((opt = getopt(argc, argv, "f:p")) != -1) { + switch (opt) { + case 'f': requested_user = optarg; preauthenticated = 1; break; + case 'p': preserve_environment = 1; break; + default: usage(argv[0]); return 2; + } + } + if (argc - optind > 1 || (requested_user && optind < argc)) { + usage(argv[0]); + return 2; + } + if (!requested_user && optind < argc) requested_user = argv[optind]; + + /* Effective uid is root for every set-ID invocation; only real uid 0 + authorizes a terminal manager's preauthentication or environment. */ + if ((preauthenticated || preserve_environment) && getuid() != 0) { + fputs("login: -f and -p require a root caller\n", stderr); + return 1; + } + + if (requested_user) { + if (copy_value(username, sizeof(username), requested_user) != 0) + return 1; + } else if (read_field("login: ", username, sizeof(username), 0) != 0) { + return 1; + } + if (username[0] == '\0') return 1; + + if (preauthenticated) { + pw = getpwnam(username); + if (!pw) { + sleep(1); + fputs("Login incorrect\n", stderr); + return 1; + } + } else { + if (read_field("Password: ", password, sizeof(password), 1) != 0) { + scrub(password); + return 1; + } + if (authenticate(username, password, &pw) != 0) { + scrub(password); + sleep(1); + fputs("Login incorrect\n", stderr); + return 1; + } + scrub(password); + } + + if (!pw->pw_dir || !pw->pw_dir[0]) { + fputs("login: account has no home directory\n", stderr); + return 1; + } + + /* Dropping uid first would make the remaining group transitions fail. */ + if (initgroups(pw->pw_name, pw->pw_gid) != 0) { + perror("login: initgroups"); + return 1; + } + if (setgid(pw->pw_gid) != 0) { + perror("login: setgid"); + return 1; + } + if (setuid(pw->pw_uid) != 0) { + perror("login: setuid"); + return 1; + } + if (set_login_environment(pw, preserve_environment) != 0) { + perror("login: environment"); + return 1; + } + + if (chdir(pw->pw_dir) != 0) { + perror("login: chdir"); + return 1; + } + display_file("/etc/motd"); + if (preauthenticated) display_file("/etc/motd.autologin"); + + const char *shell = pw->pw_shell && pw->pw_shell[0] + ? pw->pw_shell : "/bin/sh"; + char *argv0 = login_shell_argv0(shell); + if (!argv0) { + perror("login: malloc"); + return 1; + } + char *shell_argv[] = {argv0, NULL}; + fflush(NULL); + execv(shell, shell_argv); + perror("login: exec"); + return 1; +} diff --git a/programs/sudo-lite.c b/programs/sudo-lite.c new file mode 100644 index 0000000000..a4a353010a --- /dev/null +++ b/programs/sudo-lite.c @@ -0,0 +1,290 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define AUTHORIZATION_GROUP "wheel" +#define SAFE_PATH "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +#define SUDOERS_PATH "/etc/sudoers" + +static void scrub(char *s) { + if (!s) return; + volatile char *p = s; + while (*p) *p++ = '\0'; +} + +static int secure_streq(const char *a, const char *b) { + size_t alen = strlen(a); + size_t blen = strlen(b); + size_t max = alen > blen ? alen : blen; + size_t diff = alen ^ blen; + for (size_t i = 0; i < max; i++) { + unsigned char ac = i < alen ? (unsigned char)a[i] : 0; + unsigned char bc = i < blen ? (unsigned char)b[i] : 0; + diff |= (size_t)(ac ^ bc); + } + return diff == 0; +} + +static void chomp(char *s) { + size_t len = strlen(s); + while (len > 0 && (s[len - 1] == '\n' || s[len - 1] == '\r')) + s[--len] = '\0'; +} + +static int read_password(const char *username, char *buf, size_t buflen) { + struct termios old_term; + struct termios new_term; + int fd = fileno(stdin); + int restore_echo = 0; + + fprintf(stderr, "[sudo-lite] password for %s: ", username); + fflush(stderr); + if (isatty(fd)) { + if (tcgetattr(fd, &old_term) != 0) { + perror("sudo-lite: tcgetattr"); + return -1; + } + new_term = old_term; + new_term.c_lflag &= (tcflag_t)~ECHO; + if (tcsetattr(fd, TCSAFLUSH, &new_term) != 0) { + perror("sudo-lite: tcsetattr"); + return -1; + } + restore_echo = 1; + } + if (!fgets(buf, buflen, stdin)) { + if (restore_echo) { + tcsetattr(fd, TCSAFLUSH, &old_term); + fputc('\n', stderr); + } + return -1; + } + if (restore_echo) { + if (tcsetattr(fd, TCSAFLUSH, &old_term) != 0) { + perror("sudo-lite: tcsetattr"); + return -1; + } + fputc('\n', stderr); + } + chomp(buf); + return 0; +} + +static int hash_is_locked(const char *hash) { + return !hash || hash[0] == '\0' || hash[0] == '!' || hash[0] == '*'; +} + +static int caller_is_authorized(void) { + struct group *wheel = getgrnam(AUTHORIZATION_GROUP); + if (!wheel) return 0; + gid_t wheel_gid = wheel->gr_gid; + if (getgid() == wheel_gid || getegid() == wheel_gid) return 1; + + int count = getgroups(0, NULL); + if (count <= 0) return count; + gid_t *groups = calloc((size_t)count, sizeof(*groups)); + if (!groups) return -1; + int actual = getgroups(count, groups); + if (actual < 0) { + int groups_errno = errno; + free(groups); + errno = groups_errno; + return -1; + } + int authorized = 0; + for (int i = 0; i < actual; i++) { + if (groups[i] == wheel_gid) { + authorized = 1; + break; + } + } + free(groups); + return authorized; +} + +static int authenticate_caller(uid_t caller_uid, char *username, + size_t username_size) { + char passwd_hash[1024]; + char password[512] = {0}; + struct passwd *pw = getpwuid(caller_uid); + if (!pw || !pw->pw_name || pw->pw_name[0] == '\0') return -1; + int username_len = snprintf(username, username_size, "%s", pw->pw_name); + if (username_len < 0 || (size_t)username_len >= username_size) return -1; + int hash_len = snprintf(passwd_hash, sizeof(passwd_hash), "%s", + pw->pw_passwd ? pw->pw_passwd : ""); + if (hash_len < 0 || (size_t)hash_len >= sizeof(passwd_hash)) return -1; + + struct spwd *sp = getspnam(username); + const char *hash = sp && sp->sp_pwdp ? sp->sp_pwdp : passwd_hash; + if (hash_is_locked(hash) || strcmp(hash, "x") == 0) return -1; + if (read_password(username, password, sizeof(password)) != 0) { + scrub(password); + return -1; + } + char *computed = crypt(password, hash); + int accepted = computed && secure_streq(computed, hash); + scrub(password); + return accepted ? 0 : -1; +} + +/* Unknown sudoers syntax fails closed instead of being silently ignored. */ +static int sudoers_allows_wheel(void) { + FILE *file = fopen(SUDOERS_PATH, "r"); + if (!file) return -2; + char line[512]; + int allows_wheel = 0; + while (fgets(line, sizeof(line), file)) { + if (!strchr(line, '\n') && !feof(file)) { + fclose(file); + return -1; + } + chomp(line); + char *cursor = line; + while (*cursor == ' ' || *cursor == '\t') cursor++; + char *comment = strchr(cursor, '#'); + if (comment) *comment = '\0'; + char *end = cursor + strlen(cursor); + while (end > cursor && (end[-1] == ' ' || end[-1] == '\t')) + *--end = '\0'; + if (*cursor == '\0') continue; + + char subject[128]; + char runas[128]; + char command[128]; + char extra; + int fields = sscanf(cursor, "%127s %127s %127s %c", + subject, runas, command, &extra); + if (fields != 3 || strcmp(runas, "ALL=(ALL:ALL)") != 0 || + strcmp(command, "ALL") != 0 || + (strcmp(subject, "root") != 0 && + strcmp(subject, "%" AUTHORIZATION_GROUP) != 0)) { + fclose(file); + return -1; + } + if (strcmp(subject, "%" AUTHORIZATION_GROUP) == 0) + allows_wheel = 1; + } + int read_error = ferror(file); + fclose(file); + return read_error ? -2 : allows_wheel; +} + +static int become_root(void) { + /* Supplementary groups must be replaced before root uid is committed. */ + if (initgroups("root", 0) != 0) return -1; + if (setresgid(0, 0, 0) != 0) return -1; + if (setresuid(0, 0, 0) != 0) return -1; + return 0; +} + +static int install_root_environment(void) { + const char *term = getenv("TERM"); + char term_buf[128]; + if (term) { + int term_len = snprintf(term_buf, sizeof(term_buf), "%s", term); + if (term_len < 0 || (size_t)term_len >= sizeof(term_buf)) return -1; + } + if (clearenv() != 0) return -1; + if (setenv("HOME", "/root", 1) != 0) return -1; + if (setenv("USER", "root", 1) != 0) return -1; + if (setenv("LOGNAME", "root", 1) != 0) return -1; + if (setenv("SHELL", "/bin/sh", 1) != 0) return -1; + if (setenv("PATH", SAFE_PATH, 1) != 0) return -1; + if (term && setenv("TERM", term_buf, 1) != 0) return -1; + return 0; +} + +static void usage(const char *argv0) { + fprintf(stderr, "usage: %s -l | [--] command [argument ...]\n", argv0); +} + +int main(int argc, char **argv) { + int command_index = 1; + int list_only = 0; + if (argc > 1 && strcmp(argv[1], "-l") == 0) { + if (argc != 2) { + usage(argv[0]); + return 2; + } + list_only = 1; + } else if (argc > 1 && strcmp(argv[1], "--") == 0) { + command_index = 2; + } else if (argc > 1 && argv[1][0] == '-') { + fprintf(stderr, "sudo-lite: unsupported option: %s\n", argv[1]); + usage(argv[0]); + return 2; + } + if (!list_only && command_index >= argc) { + usage(argv[0]); + return 2; + } + + uid_t caller_uid = getuid(); + if (geteuid() != 0) { + fputs("sudo-lite: effective uid is not root; check setuid mode and mount nosuid policy\n", + stderr); + return 1; + } + int policy = sudoers_allows_wheel(); + if (policy == -2) { + perror("sudo-lite: /etc/sudoers"); + return 1; + } + if (policy < 0) { + fputs("sudo-lite: malformed /etc/sudoers\n", stderr); + return 1; + } + + char username[128] = "root"; + if (caller_uid != 0) { + int authorization = caller_is_authorized(); + if (authorization < 0) { + perror("sudo-lite: authorization"); + return 1; + } + if (authorization == 0) { + fprintf(stderr, + "sudo-lite: uid %u is not authorized; current %s membership is required\n", + (unsigned)caller_uid, AUTHORIZATION_GROUP); + return 1; + } + if (policy == 0) { + fputs("sudo-lite: wheel is not allowed by /etc/sudoers\n", stderr); + return 1; + } + if (authenticate_caller(caller_uid, username, sizeof(username)) != 0) { + sleep(1); + fputs("sudo-lite: authentication failed\n", stderr); + return 1; + } + } + + if (list_only) { + printf("User %s may run the following commands on kandelo:\n", username); + puts(" (ALL:ALL) ALL"); + return 0; + } + if (become_root() != 0) { + perror("sudo-lite: credentials"); + return 1; + } + if (install_root_environment() != 0) { + perror("sudo-lite: environment"); + return 1; + } + + fflush(NULL); + execvp(argv[command_index], &argv[command_index]); + int exec_errno = errno; + fprintf(stderr, "sudo-lite: exec %s: %s\n", argv[command_index], + strerror(exec_errno)); + return exec_errno == ENOENT ? 127 : 126; +} diff --git a/scripts/build-homebrew-bootstrap.sh b/scripts/build-homebrew-bootstrap.sh index fd35b10cdd..404e607d98 100755 --- a/scripts/build-homebrew-bootstrap.sh +++ b/scripts/build-homebrew-bootstrap.sh @@ -259,10 +259,10 @@ cat > "$BOOTSTRAP_MANIFEST" <&1)" = /opt/kandelo/homebrew || brew_smoke_fail 'brew --repository differs from the guest repository' test "$(/usr/bin/brew --cellar 2>&1)" = /opt/kandelo/homebrew/Cellar || brew_smoke_fail 'brew --cellar differs from the guest Cellar' -test "$(/usr/bin/brew --cache 2>&1)" = /home/user/.cache/Homebrew || +test "$(/usr/bin/brew --cache 2>&1)" = /home/maker/.cache/Homebrew || brew_smoke_fail 'brew --cache differs from the guest cache' # WHY: \`brew ruby\` is a developer command and may query Homebrew's developer # package API. A temporary stock Bash command observes the same post-brew.env @@ -709,7 +709,7 @@ function assertHomebrewBootstrapConsumerContract( ); } assertTreeOwner(fs, "/opt/kandelo/homebrew", 1000, 1000); - assertTreeOwner(fs, "/home/user/.cache", 1000, 1000); + assertTreeOwner(fs, "/home/maker/.cache", 1000, 1000); const imageMetadata = asRecord(metadata, "main-shell image metadata"); const expected = { @@ -728,7 +728,7 @@ function assertHomebrewBootstrapConsumerContract( "/opt/kandelo/homebrew/Library/Taps", "/opt/kandelo/homebrew/var/homebrew/linked", "/opt/kandelo/homebrew/var/homebrew/locks", - "/home/user/.cache/Homebrew", + "/home/maker/.cache/Homebrew", ], }, }; @@ -1180,11 +1180,11 @@ async function spawnWithTimeout( const exitPromise = host.spawn(toArrayBuffer(programBytes), argv, { env: [ "PATH=/opt/kandelo/homebrew/bin:/usr/bin:/bin", - "HOME=/home/user", - "USER=user", + "HOME=/home/maker", + "USER=maker", "TMPDIR=/tmp", ], - cwd: "/home/user", + cwd: "/home/maker", uid: 1000, gid: 1000, stdin: new Uint8Array(), diff --git a/scripts/source-rootfs-shell-node-smoke.ts b/scripts/source-rootfs-shell-node-smoke.ts index 7f9e50665c..43d867083f 100644 --- a/scripts/source-rootfs-shell-node-smoke.ts +++ b/scripts/source-rootfs-shell-node-smoke.ts @@ -135,11 +135,11 @@ try { host.spawn(toArrayBuffer(shellBytes), [shellConfig.argv[0], "-c", command], { env: [ "PATH=/usr/bin:/bin", - "HOME=/home/user", - "USER=user", + "HOME=/home/maker", + "USER=maker", "TMPDIR=/tmp", ], - cwd: "/home/user", + cwd: "/home/maker", uid: 1000, gid: 1000, stdin: new Uint8Array(), diff --git a/web-libs/kandelo-session/test/kandelo-session.test.ts b/web-libs/kandelo-session/test/kandelo-session.test.ts index 14219c37bf..0a44b0b2a8 100644 --- a/web-libs/kandelo-session/test/kandelo-session.test.ts +++ b/web-libs/kandelo-session/test/kandelo-session.test.ts @@ -808,7 +808,7 @@ describe("LiveKernelHost: shell command queue", () => { programBytes: new ArrayBuffer(0), argv: ["bash", "-l", "-i"], env: ["PS1=kandelo$ "], - cwd: "/home/user", + cwd: "/home/maker", }); const pty = await host.attachPty("/dev/pts/0", { cols: 80, rows: 24 }); @@ -838,7 +838,7 @@ describe("LiveKernelHost: shell command queue", () => { programPath: "/opt/kandelo/homebrew/bin/dash", argv: ["dash", "-l", "-i"], env: ["PS1=kandelo$ "], - cwd: "/home/user", + cwd: "/home/maker", uid: 1000, gid: 1000, }); @@ -850,7 +850,7 @@ describe("LiveKernelHost: shell command queue", () => { ["dash", "-l", "-i"], expect.objectContaining({ pty: true, - cwd: "/home/user", + cwd: "/home/maker", uid: 1000, gid: 1000, ptyCols: 100, @@ -916,7 +916,7 @@ describe("LiveKernelHost: shell command queue", () => { programBytes: new ArrayBuffer(0), argv: ["bash", "-l", "-i"], env: ["PS1=kandelo$ "], - cwd: "/home/user", + cwd: "/home/maker", }); let completed = false; @@ -964,7 +964,7 @@ describe("LiveKernelHost: shell command queue", () => { programBytes: new ArrayBuffer(0), argv: ["bash", "-l", "-i"], env: ["PS1=kandelo$ "], - cwd: "/home/user", + cwd: "/home/maker", }); let completed = false; @@ -1020,7 +1020,7 @@ describe("LiveKernelHost: shell command queue", () => { programBytes: new ArrayBuffer(0), argv: ["bash", "-l", "-i"], env: ["PS1=kandelo$ "], - cwd: "/home/user", + cwd: "/home/maker", }); const visibleAttach = host.attachPty("/dev/pts/0", { cols: 80, rows: 24 }); @@ -1089,7 +1089,7 @@ describe("LiveKernelHost: shell command queue", () => { programBytes: new ArrayBuffer(0), argv: ["bash", "-l", "-i"], env: ["PS1=kandelo$ "], - cwd: "/home/user", + cwd: "/home/maker", }); const firstHandle = await host.attachPty("/dev/pts/0", { cols: 80, rows: 24 }); @@ -1145,7 +1145,7 @@ describe("LiveKernelHost: shell command queue", () => { programBytes: new ArrayBuffer(0), argv: ["bash", "-l", "-i"], env: ["PS1=kandelo$ "], - cwd: "/home/user", + cwd: "/home/maker", }); const firstHandle = await host.attachPty("/dev/pts/0", { cols: 80, rows: 24 }); From 812775d57fa73acbb170b9fab1c09b87a8550338 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Tue, 11 Aug 2026 21:49:43 -0400 Subject: [PATCH 70/82] Browser: Supervise real login sessions per terminal Keep one generation-safe login lifecycle per logical browser PTY. New demo terminals preauthenticate maker once, then restart ordinary login with bounded backoff while UI reattachment preserves the guest process. Require the exact canonical password hash and autologin message before selecting that policy, and cover fake-clock plus real Chromium, Firefox, and WebKit lifecycles. --- apps/browser-demos/pages/kandelo/app/App.tsx | 47 +- .../kandelo/app/TerminalDockControls.tsx | 67 +++ .../kandelo/kernel-host/configured-assets.ts | 49 ++ .../kandelo/kernel-host/demo-login-loader.ts | 59 ++ .../kernel-host/demo-terminal-sessions.ts | 19 + .../pages/kandelo/kernel-host/index.ts | 8 +- .../pages/kandelo/kernel-host/live-setup.ts | 127 ++--- .../pages/kandelo/kernel-host/react.tsx | 6 + .../pages/kandelo/panes/Shell.tsx | 4 + apps/browser-demos/pages/kandelo/styles.css | 31 + .../test/demo-login-loader.test.ts | 221 +++++++ .../test/fixtures/terminal-removal-ui.html | 8 + .../test/fixtures/terminal-removal-ui.tsx | 32 ++ .../test/login-terminal-session.spec.ts | 479 ++++++++++++++++ .../test/terminal-removal-ui.spec.ts | 26 + docs/architecture.md | 32 +- docs/browser-support.md | 28 + host/src/browser-kernel-host.ts | 2 + host/src/homebrew-runtime-layer-consumer.ts | 3 +- host/src/vfs/privileged-projection.ts | 35 ++ host/test/demo-login-image.test.ts | 127 ++++- images/vfs/lib/demo-login.ts | 90 +-- web-libs/kandelo-session/src/index.ts | 1 + web-libs/kandelo-session/src/kernel-host.ts | 537 ++++++++++++++---- .../test/kandelo-session.test.ts | 366 +++++++++++- 25 files changed, 2160 insertions(+), 244 deletions(-) create mode 100644 apps/browser-demos/pages/kandelo/app/TerminalDockControls.tsx create mode 100644 apps/browser-demos/pages/kandelo/kernel-host/configured-assets.ts create mode 100644 apps/browser-demos/pages/kandelo/kernel-host/demo-login-loader.ts create mode 100644 apps/browser-demos/pages/kandelo/kernel-host/demo-terminal-sessions.ts create mode 100644 apps/browser-demos/test/demo-login-loader.test.ts create mode 100644 apps/browser-demos/test/fixtures/terminal-removal-ui.html create mode 100644 apps/browser-demos/test/fixtures/terminal-removal-ui.tsx create mode 100644 apps/browser-demos/test/login-terminal-session.spec.ts create mode 100644 apps/browser-demos/test/terminal-removal-ui.spec.ts diff --git a/apps/browser-demos/pages/kandelo/app/App.tsx b/apps/browser-demos/pages/kandelo/app/App.tsx index 98c6d1482a..af3891e86e 100644 --- a/apps/browser-demos/pages/kandelo/app/App.tsx +++ b/apps/browser-demos/pages/kandelo/app/App.tsx @@ -18,6 +18,7 @@ import type { MachineAudioState, } from "../../../../../web-libs/kandelo-session/src/kernel-host"; import { lazyDownloadAssetLabel } from "../../../../../web-libs/kandelo-session/src/lazy-download"; +import { TerminalDockControls } from "./TerminalDockControls"; type InternalsTab = "syslog" | "procs" | "vfs" | "lazy-load" | "config" | "syscalls"; type ThemeFamily = "ubuntu" | "wordpress" | "kandelo"; @@ -224,6 +225,18 @@ export const App: React.FC = () => { setActiveTerminalId(terminal.id); }, []); + const onRemoveTerminalId = React.useCallback((id: string) => { + const removedIndex = terminals.findIndex((terminal) => terminal.id === id); + if (removedIndex < 0 || terminals.length <= 1) return; + const next = terminals.filter((terminal) => terminal.id !== id); + setTerminals(next); + setActiveTerminalId((active) => + active === id + ? next[Math.min(removedIndex, next.length - 1)]!.id + : active + ); + }, [terminals]); + const isEmpty = surface.status === "idle"; const dockActiveView: DockViewId | null = !isEmpty && surface.activeView !== "internals" ? surface.activeView @@ -238,6 +251,7 @@ export const App: React.FC = () => { activeTerminalId={activeTerminalId} onActiveTerminalId={setActiveTerminalId} onAddTerminal={onAddTerminal} + onRemoveTerminalId={onRemoveTerminalId} /> ) : null @@ -393,39 +407,6 @@ const AudioStatusToast: React.FC<{ ); }; -const TerminalDockControls: React.FC<{ - terminals: ShellTerminal[]; - activeTerminalId: string; - onActiveTerminalId: (id: string) => void; - onAddTerminal: () => void; -}> = ({ terminals, activeTerminalId, onActiveTerminalId, onAddTerminal }) => ( -
- {terminals.map((terminal) => ( - - ))} - -
-); - const InternalsPopup: React.FC<{ activeTab: string; onTab: (id: string) => void; diff --git a/apps/browser-demos/pages/kandelo/app/TerminalDockControls.tsx b/apps/browser-demos/pages/kandelo/app/TerminalDockControls.tsx new file mode 100644 index 0000000000..bfa6ec2055 --- /dev/null +++ b/apps/browser-demos/pages/kandelo/app/TerminalDockControls.tsx @@ -0,0 +1,67 @@ +import * as React from "react"; +import { useRemovePty } from "../kernel-host/react"; +import type { ShellTerminal } from "../panes/Shell"; + +export const TerminalDockControls: React.FC<{ + terminals: ShellTerminal[]; + activeTerminalId: string; + onActiveTerminalId: (id: string) => void; + onAddTerminal: () => void; + onRemoveTerminalId: (id: string) => void; +}> = ({ + terminals, + activeTerminalId, + onActiveTerminalId, + onAddTerminal, + onRemoveTerminalId, +}) => { + const removePty = useRemovePty(); + return ( +
+ {terminals.map((terminal) => ( + + + {terminals.length > 1 && ( + + )} + + ))} + +
+ ); +}; diff --git a/apps/browser-demos/pages/kandelo/kernel-host/configured-assets.ts b/apps/browser-demos/pages/kandelo/kernel-host/configured-assets.ts new file mode 100644 index 0000000000..7c1f4fc4a4 --- /dev/null +++ b/apps/browser-demos/pages/kandelo/kernel-host/configured-assets.ts @@ -0,0 +1,49 @@ +import { writeVfsBinary } from "../../../../../host/src/vfs/image-helpers"; +import type { MemoryFileSystem } from "../../../../../host/src/vfs/memory-fs"; +import type { DemoAssetConfig } from "../../../../../web-libs/kandelo-session/src/demo-config"; + +const DEV_CORS_PROXY_PATH = import.meta.env.BASE_URL + "__kandelo_cors_proxy"; + +/** Stage image-declared assets before any privileged product is published. */ +export async function stageConfiguredAssets( + fs: MemoryFileSystem, + assets: DemoAssetConfig[], + tick: (message: string) => void, + assertCurrent: () => void, +): Promise { + for (const asset of assets) { + tick(`staging ${asset.path}...`); + const response = await fetch(demoAssetFetchUrl(asset)); + if (!response.ok) { + throw new Error( + `fetch failed for ${asset.path}: ${response.status} ${response.statusText}`, + ); + } + const buffer = await response.arrayBuffer(); + assertCurrent(); + if (asset.sha256) { + const digest = await sha256Hex(buffer); + assertCurrent(); + if (digest !== asset.sha256) { + throw new Error( + `${asset.path} sha256 mismatch: expected ${asset.sha256}, got ${digest}`, + ); + } + } + writeVfsBinary(fs, asset.path, new Uint8Array(buffer), asset.mode ?? 0o644); + } +} + +function demoAssetFetchUrl(asset: DemoAssetConfig): string { + if (!asset.devCorsProxy || !import.meta.env.DEV) return asset.url; + const proxyUrl = new URL(DEV_CORS_PROXY_PATH, window.location.href); + proxyUrl.searchParams.set("url", asset.url); + return proxyUrl.href; +} + +async function sha256Hex(bytes: ArrayBuffer): Promise { + const digest = await crypto.subtle.digest("SHA-256", bytes); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} diff --git a/apps/browser-demos/pages/kandelo/kernel-host/demo-login-loader.ts b/apps/browser-demos/pages/kandelo/kernel-host/demo-login-loader.ts new file mode 100644 index 0000000000..16fcbaa065 --- /dev/null +++ b/apps/browser-demos/pages/kandelo/kernel-host/demo-login-loader.ts @@ -0,0 +1,59 @@ +import type { BrowserKernel } from "../../../../../host/src/browser-kernel-host"; +import type { ClosedLazyAsset } from "../../../../../host/src/vfs/closed-lazy-assets"; +import type { MemoryFileSystem } from "../../../../../host/src/vfs/memory-fs"; +import { + publishedPrivilegedProgramMatchesFile, + type PublishedPrivilegedProgramProduct, +} from "../../../../../host/src/vfs/privileged-projection"; +import { + DEMO_LOGIN_PROGRAM_PATH, + hasConfiguredDemoLogin, +} from "../../../../../images/vfs/lib/demo-login"; + +type DemoLoginKernel = Pick< + BrowserKernel, + "initFromImage" | "initFromPublishedPrivilegedProgramProduct" +>; + +export interface InitializeDemoLoginKernelOptions { + kernel: DemoLoginKernel; + fs: MemoryFileSystem; + kernelWasm?: ArrayBuffer; + vfsImage: Uint8Array | "default"; + closedLazyAssets?: readonly ClosedLazyAsset[]; + privilegedProduct?: PublishedPrivilegedProgramProduct; +} + +/** + * Select the privileged login path only from the fully staged image and the + * publisher's private product identity. Image/config data alone always boots + * through the ordinary, nosuid image path. + */ +export async function initializeDemoLoginKernel( + options: InitializeDemoLoginKernelOptions, +): Promise { + const { privilegedProduct } = options; + const loginSessionsEnabled = privilegedProduct !== undefined && + hasConfiguredDemoLogin(options.fs) && + await publishedPrivilegedProgramMatchesFile( + privilegedProduct, + options.fs, + DEMO_LOGIN_PROGRAM_PATH, + ); + const common = { + ...(options.kernelWasm === undefined ? {} : { kernelWasm: options.kernelWasm }), + vfsImage: options.vfsImage, + ...(options.closedLazyAssets === undefined + ? {} + : { closedLazyAssets: options.closedLazyAssets }), + }; + if (loginSessionsEnabled) { + await options.kernel.initFromPublishedPrivilegedProgramProduct({ + ...common, + privilegedProduct, + }); + } else { + await options.kernel.initFromImage(common); + } + return loginSessionsEnabled; +} diff --git a/apps/browser-demos/pages/kandelo/kernel-host/demo-terminal-sessions.ts b/apps/browser-demos/pages/kandelo/kernel-host/demo-terminal-sessions.ts new file mode 100644 index 0000000000..291e67830b --- /dev/null +++ b/apps/browser-demos/pages/kandelo/kernel-host/demo-terminal-sessions.ts @@ -0,0 +1,19 @@ +import type { TerminalSessionPolicy } from "../../../../../web-libs/kandelo-session/src/kernel-host"; + +export const DEMO_TERMINAL_SESSION_POLICY: TerminalSessionPolicy = { + initial: { + programPath: "/usr/bin/login", + argv: ["login", "-p", "-f", "maker"], + uid: 0, + gid: 0, + }, + afterExit: { + programPath: "/usr/bin/login", + argv: ["login", "-p"], + uid: 0, + gid: 0, + }, + shortRunThresholdMs: 2_000, + initialRestartDelayMs: 250, + maximumRestartDelayMs: 5_000, +}; diff --git a/apps/browser-demos/pages/kandelo/kernel-host/index.ts b/apps/browser-demos/pages/kandelo/kernel-host/index.ts index 6bd69ae25e..9333b94831 100644 --- a/apps/browser-demos/pages/kandelo/kernel-host/index.ts +++ b/apps/browser-demos/pages/kandelo/kernel-host/index.ts @@ -3,5 +3,11 @@ export * from "../../../../../web-libs/kandelo-session/src/kernel-host"; export { - KernelHostProvider, useKernelHost, useStatus, useDmesg, useSnapshot, useWebPreview, + KernelHostProvider, + useDmesg, + useKernelHost, + useRemovePty, + useSnapshot, + useStatus, + useWebPreview, } from "./react"; diff --git a/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts b/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts index 4bce8e8d09..59c6e931c3 100644 --- a/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts +++ b/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts @@ -31,6 +31,14 @@ import { composeBootDescriptorVfs, homebrewRuntimeLayerReferences, } from "../../../lib/init/homebrew-package-layers"; +import { + publishRuntimeLayerPrivilegedPrograms, + type RegisteredHomebrewRuntimeLayer, +} from "../../../../../host/src/homebrew-runtime-layer-consumer"; +import type { + PublishedPrivilegedProgramProduct, + ReviewedPrivilegedProgramPolicy, +} from "../../../../../host/src/vfs/privileged-projection"; import { homebrewBootstrapClosedBinding, homebrewClosedAcceptanceAssetRoot, @@ -63,7 +71,6 @@ import { resolveDemoGuide, resolveDemoIngest, resolveDemoPresentation, - type DemoAssetConfig, type KandeloDemoConfig, } from "../../../../../web-libs/kandelo-session/src/demo-config"; import { readKandeloDemoConfigFromVfs } from "../../../../../web-libs/kandelo-session/src/demo-config-vfs"; @@ -87,6 +94,7 @@ import { builtinDemoGuide, builtinDemoPresentation, } from "../../../../../web-libs/kandelo-session/src/demo-guides"; +import { hasConfiguredDemoLogin } from "../../../../../images/vfs/lib/demo-login"; import { PRESET_LIBRARY } from "../presets"; import { descriptorWithVfsImageUrl, @@ -114,10 +122,9 @@ import { resolveOptionalDemoVfsUrl, type OptionalDemoVfsImage, } from "./optional-demo-vfs"; -import { - createPagesVfsProductLoader, - type PagesVfsProductEntry, -} from "./pages-vfs-product-loader"; +import { DEMO_TERMINAL_SESSION_POLICY } from "./demo-terminal-sessions"; +import { stageConfiguredAssets } from "./configured-assets"; +import { initializeDemoLoginKernel } from "./demo-login-loader"; import kernelWasmUrl from "@kernel-wasm?url"; import shellVfsUrl from "@binaries/programs/wasm32/shell.vfs.zst?url"; @@ -533,7 +540,6 @@ const APP_PREFIX = import.meta.env.BASE_URL + "app/"; const APP_PATH = import.meta.env.BASE_URL + "app"; const PROTO = window.location.protocol === "https:" ? "https" : "http"; const SW_URL = import.meta.env.BASE_URL + "service-worker.js"; -const DEV_CORS_PROXY_PATH = import.meta.env.BASE_URL + "__kandelo_cors_proxy"; const BROWSER_CORS_PROXY_URL = resolveBrowserCorsProxyUrl({ configuredUrl: import.meta.env.VITE_CORS_PROXY_URL, development: import.meta.env.DEV, @@ -628,6 +634,10 @@ export interface CreateLiveHostOptions { demo?: string | null; vfsUrl?: string | null; fb?: FbDemo; + /** Product-owned reviewed authority; boot descriptors cannot construct it. */ + reviewedPrivilegedProgramPolicy?: ReviewedPrivilegedProgramPolicy; + /** Separately published authority; image/config/descriptor data cannot mint it. */ + publishedPrivilegedProgramProduct?: PublishedPrivilegedProgramProduct; } export async function createLiveHost( @@ -796,6 +806,8 @@ export async function createLiveHost( bootStartedAt, () => seq === bootSeq, requireServiceWorker, + opts.reviewedPrivilegedProgramPolicy, + opts.publishedPrivilegedProgramProduct, ); if (seq !== bootSeq) { await kernel.destroy().catch(() => {}); @@ -1317,6 +1329,8 @@ async function bootProfile( requireServiceWorker: ( tick?: (msg: string) => void, ) => Promise, + reviewedPrivilegedProgramPolicy?: ReviewedPrivilegedProgramPolicy, + publishedPrivilegedProgramProduct?: PublishedPrivilegedProgramProduct, ): Promise { const assertCurrent = () => { if (!isCurrent()) throw new BootSuperseded(); @@ -1408,6 +1422,7 @@ async function bootProfile( // image-switch OOM. const runtimeLayers = homebrewRuntimeLayerReferences(requestedDescriptor); let buildFs: MemoryFileSystem; + let registeredRuntimeLayers: RegisteredHomebrewRuntimeLayer[] = []; if (runtimeLayers.length > 0) { tick( `verifying ${runtimeLayers.length} selected runtime layer${ @@ -1422,6 +1437,7 @@ async function bootProfile( onStagedFileSystemDiscarded: trackTransientImageBuffer, }); buildFs = composed.fs; + registeredRuntimeLayers = composed.layers; } else { buildFs = MemoryFileSystem.fromImage(fetchedVfsImageBytes, { maxByteLength: profile.maxVfsByteLength, @@ -1552,6 +1568,21 @@ async function bootProfile( ); assertCurrent(); + let privilegedProduct = publishedPrivilegedProgramProduct; + if ( + privilegedProduct === undefined && + reviewedPrivilegedProgramPolicy !== undefined && + hasConfiguredDemoLogin(buildFs) + ) { + tick("publishing reviewed privileged programs..."); + privilegedProduct = await publishRuntimeLayerPrivilegedPrograms( + buildFs, + registeredRuntimeLayers, + reviewedPrivilegedProgramPolicy, + ); + assertCurrent(); + } + // Serialize the assembled image to transferable bytes, then let `buildFs` // go out of scope. `saveImage()` emits raw (uncompressed) bytes that // `MemoryFileSystem.fromImage` restores directly in the worker. @@ -1623,33 +1654,35 @@ async function bootProfile( ); }, }); - await kernel.initFromImage(profile.candidateEvidence === undefined - ? { - kernelWasm: kernelBytes, - vfsImage: vfsImageBytes, - ...(closedLazyAssets === undefined ? {} : { closedLazyAssets }), - } - : candidateEvidenceKernelInitOptions( - profile.candidateEvidence, - kernelBytes, - vfsImageBytes, - closedLazyAssets, - )); + const loginSessionsEnabled = await initializeDemoLoginKernel({ + kernel, + fs: buildFs, + kernelWasm: kernelBytes, + vfsImage: vfsImageBytes, + ...(closedLazyAssets === undefined ? {} : { closedLazyAssets }), + ...(privilegedProduct === undefined ? {} : { privilegedProduct }), + }); assertCurrent(); host.attachKernel(kernel); const shellIdentity = shellIdentityForProfile( profile, profile.init ? undefined : effectiveBoot, ); - host.setDefaultShell({ - programPath: candidateShell?.path ?? shellConfig?.path ?? "/bin/bash", - ...(shellProgramBytes ? { programBytes: shellProgramBytes } : {}), - argv: candidateShell?.argv ?? shellConfig?.argv ?? ["bash", "-l", "-i"], - env: shellIdentity.env, - cwd: shellIdentity.cwd, - uid: shellIdentity.uid, - gid: shellIdentity.gid, - }); + if (loginSessionsEnabled) { + host.setTerminalSessionPolicy(DEMO_TERMINAL_SESSION_POLICY); + } else { + // Custom and legacy images without the exact account, policy, message, + // and reviewed login entry keep their declared shell identity. + host.setDefaultShell({ + programPath: shellConfig?.path ?? "/bin/bash", + ...(shellProgramBytes ? { programBytes: shellProgramBytes } : {}), + argv: shellConfig?.argv ?? ["bash", "-l", "-i"], + env: shellIdentity.env, + cwd: shellIdentity.cwd, + uid: shellIdentity.uid, + gid: shellIdentity.gid, + }); + } if (profile.init?.web) { tick("initializing HTTP bridge..."); @@ -2186,46 +2219,6 @@ async function spawnLazy( } } -async function stageConfiguredAssets( - fs: MemoryFileSystem, - assets: DemoAssetConfig[], - tick: (msg: string) => void, - assertCurrent: () => void, -): Promise { - for (const asset of assets) { - tick(`staging ${asset.path}...`); - const buffer: ArrayBuffer = await fetch(demoAssetFetchUrl(asset)) - .then(failOn(asset.path)) - .then((r) => r.arrayBuffer()); - assertCurrent(); - const bytes = new Uint8Array(buffer); - if (asset.sha256) { - const digest = await sha256Hex(buffer); - assertCurrent(); - if (digest !== asset.sha256) { - throw new Error( - `${asset.path} sha256 mismatch: expected ${asset.sha256}, got ${digest}`, - ); - } - } - writeVfsBinary(fs, asset.path, bytes, asset.mode ?? 0o644); - } -} - -function demoAssetFetchUrl(asset: DemoAssetConfig): string { - if (!asset.devCorsProxy || !import.meta.env.DEV) return asset.url; - const proxyUrl = new URL(DEV_CORS_PROXY_PATH, window.location.href); - proxyUrl.searchParams.set("url", asset.url); - return proxyUrl.href; -} - -async function sha256Hex(bytes: ArrayBuffer): Promise { - const digest = await crypto.subtle.digest("SHA-256", bytes); - return Array.from(new Uint8Array(digest)) - .map((byte) => byte.toString(16).padStart(2, "0")) - .join(""); -} - function maybeMarkWebReady( host: LiveKernelHost, profile: LiveProfile, diff --git a/apps/browser-demos/pages/kandelo/kernel-host/react.tsx b/apps/browser-demos/pages/kandelo/kernel-host/react.tsx index 6fab68f0a7..5b7c4333b5 100644 --- a/apps/browser-demos/pages/kandelo/kernel-host/react.tsx +++ b/apps/browser-demos/pages/kandelo/kernel-host/react.tsx @@ -32,6 +32,12 @@ export function useKernelHost(): KernelHost { return host; } +/** Explicitly remove a logical terminal in response to a user UI action. */ +export function useRemovePty(): (path: string) => void { + const host = useKernelHost(); + return React.useCallback((path: string) => host.removePty(path), [host]); +} + export function useStatus(): MachineStatus { const host = useKernelHost(); const [s, setS] = React.useState(() => host.getStatus()); diff --git a/apps/browser-demos/pages/kandelo/panes/Shell.tsx b/apps/browser-demos/pages/kandelo/panes/Shell.tsx index fe83135f15..c9d4572774 100644 --- a/apps/browser-demos/pages/kandelo/panes/Shell.tsx +++ b/apps/browser-demos/pages/kandelo/panes/Shell.tsx @@ -110,6 +110,10 @@ const ShellTerminalHost: React.FC<{ if (status !== "running") return; if (!containerRef.current) return; + // React StrictMode mounts this effect twice. Clear renderer nodes that + // xterm leaves behind without removing the host-owned logical PTY. + containerRef.current.replaceChildren(); + const term = new Terminal({ cursorBlink: true, fontSize: 13, diff --git a/apps/browser-demos/pages/kandelo/styles.css b/apps/browser-demos/pages/kandelo/styles.css index 69395bac37..0506e67bf1 100644 --- a/apps/browser-demos/pages/kandelo/styles.css +++ b/apps/browser-demos/pages/kandelo/styles.css @@ -763,6 +763,37 @@ white-space: nowrap; } +.kdock-terminal-tab { + position: relative; + display: inline-flex; + flex-shrink: 0; +} + +.kdock-terminal-tab .kdock-view-tab { + padding-right: 24px; +} + +.kdock-terminal-close { + position: absolute; + top: 4px; + right: 4px; + width: 18px; + height: 18px; + padding: 0; + border: 0; + border-radius: 5px; + background: transparent; + color: inherit; + cursor: pointer; + line-height: 16px; +} + +.kdock-terminal-close:hover, +.kdock-terminal-close:focus-visible { + background: color-mix(in oklch, var(--kdock-text) 14%, transparent); + outline: none; +} + .kdock-view-iconbtn { width: 28px; display: inline-flex; diff --git a/apps/browser-demos/test/demo-login-loader.test.ts b/apps/browser-demos/test/demo-login-loader.test.ts new file mode 100644 index 0000000000..423debf256 --- /dev/null +++ b/apps/browser-demos/test/demo-login-loader.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it, vi } from "vitest"; +import { + DEMO_AUTOLOGIN_MOTD, + DEMO_LOGIN_PASSWORD_HASH, + DEMO_SUDOERS, +} from "../../../images/vfs/lib/demo-login"; +import { ensureDirRecursive } from "../../../host/src/vfs/image-helpers"; +import { MemoryFileSystem } from "../../../host/src/vfs/memory-fs"; +import { + createReviewedPrivilegedProgramPolicy, + publishPrivilegedProgramProduct, + type PrivilegedProgramSource, +} from "../../../host/src/vfs/privileged-projection"; +import { stageConfiguredAssets } from "../pages/kandelo/kernel-host/configured-assets"; +import { initializeDemoLoginKernel } from "../pages/kandelo/kernel-host/demo-login-loader"; + +const encoder = new TextEncoder(); + +function write( + fs: MemoryFileSystem, + path: string, + text: string, + mode: number, +): void { + fs.createFileWithOwner(path, mode, 0, 0, encoder.encode(text)); +} + +function canonicalFs(loginBytes: Uint8Array): MemoryFileSystem { + const fs = MemoryFileSystem.create(new SharedArrayBuffer(4 * 1024 * 1024)); + for (const path of ["/etc", "/usr", "/usr/bin"]) ensureDirRecursive(fs, path); + write( + fs, + "/etc/passwd", + "maker:x:1000:1000:maker:/home/maker:/bin/sh\n", + 0o644, + ); + write( + fs, + "/etc/shadow", + `maker:${DEMO_LOGIN_PASSWORD_HASH}:0:0:99999:7:::\n`, + 0o640, + ); + write(fs, "/etc/group", "wheel:x:10:maker\n", 0o644); + write(fs, "/etc/sudoers", DEMO_SUDOERS, 0o440); + write(fs, "/etc/motd.autologin", DEMO_AUTOLOGIN_MOTD, 0o644); + fs.createFileWithOwner("/usr/bin/login", 0o4755, 0, 0, loginBytes); + return fs; +} + +async function productFixture(loginBytes: Uint8Array) { + const digest = Array.from( + new Uint8Array(await crypto.subtle.digest("SHA-256", loginBytes)), + ).map((byte) => byte.toString(16).padStart(2, "0")).join(""); + const sourceFs = MemoryFileSystem.create( + new SharedArrayBuffer(4 * 1024 * 1024), + ); + const destinations = [ + ["login", "/usr/bin/login"], + ["sudo-lite", "/usr/bin/sudo-lite"], + ["sudo", "/usr/bin/sudo"], + ] as const; + for (const [sourcePath] of destinations) { + sourceFs.createFileWithOwner( + `/${sourcePath}`, + 0o755, + 1000, + 1000, + loginBytes, + ); + } + const bottleSha256 = "a".repeat(64); + const policy = createReviewedPrivilegedProgramPolicy( + destinations.map(([sourcePath, destinationPath]) => ({ + schema: 1, + formula: `test/${sourcePath}`, + bottleSha256, + sourcePath, + destinationPath, + uid: 0, + gid: 0, + mode: 0o4755, + mountPoint: "trusted-root-product", + artifactValidationSha256: digest, + })), + ); + const sources: PrivilegedProgramSource[] = destinations.map( + ([sourcePath]) => ({ + formula: `test/${sourcePath}`, + bottleSha256, + fs: sourceFs, + inventory: { + entries: [{ + sourcePath, + type: "file", + size: loginBytes.byteLength, + }], + }, + guestPathForSource: (path) => `/${path}`, + }), + ); + const publish = () => publishPrivilegedProgramProduct({ + policy, + sources, + writableBottleFileSystems: [sourceFs], + }); + return { policy, publish, sourceFs }; +} + +function fakeKernel() { + return { + initFromImage: vi.fn(async () => {}), + initFromPublishedPrivilegedProgramProduct: vi.fn(async () => {}), + }; +} + +describe("production demo login loader", () => { + it( + "keeps a third-party image ordinary alone and admits its exact bytes " + + "with a separate reviewed product", + async () => { + const loginBytes = new Uint8Array([0, 97, 115, 109, 1]); + const fs = canonicalFs(loginBytes); + const image = await fs.saveImage(); + const rawKernel = fakeKernel(); + + await expect(initializeDemoLoginKernel({ + kernel: rawKernel, + fs, + kernelWasm: new ArrayBuffer(0), + vfsImage: image, + })).resolves.toBe(false); + expect(rawKernel.initFromImage).toHaveBeenCalledOnce(); + expect(rawKernel.initFromPublishedPrivilegedProgramProduct) + .not.toHaveBeenCalled(); + + const { publish } = await productFixture(loginBytes); + const privilegedProduct = await publish(); + const reviewedKernel = fakeKernel(); + await expect(initializeDemoLoginKernel({ + kernel: reviewedKernel, + fs, + kernelWasm: new ArrayBuffer(0), + vfsImage: image, + privilegedProduct, + })).resolves.toBe(true); + expect(reviewedKernel.initFromImage).not.toHaveBeenCalled(); + expect( + reviewedKernel.initFromPublishedPrivilegedProgramProduct, + ).toHaveBeenCalledOnce(); + + const forgedKernel = fakeKernel(); + await expect(initializeDemoLoginKernel({ + kernel: forgedKernel, + fs, + kernelWasm: new ArrayBuffer(0), + vfsImage: image, + privilegedProduct: { ...privilegedProduct }, + })).rejects.toThrow("lacks publication authority"); + expect(forgedKernel.initFromImage).not.toHaveBeenCalled(); + expect(forgedKernel.initFromPublishedPrivilegedProgramProduct) + .not.toHaveBeenCalled(); + }, + ); + + it.each([ + ["/etc/passwd", "maker:x:1000:1000:maker:/home/user:/bin/sh\n", 0o644], + ["/etc/shadow", "maker:$6$wrong$hash:0:0:99999:7:::\n", 0o640], + ["/etc/motd.autologin", "image-selected credentials\n", 0o644], + ["/usr/bin/login", "image-selected program bytes", 0o4755], + ] as const)("rejects a configured-asset overwrite of %s", async ( + path, + text, + mode, + ) => { + const loginBytes = new Uint8Array([0, 97, 115, 109, 1]); + const fs = canonicalFs(loginBytes); + const { publish } = await productFixture(loginBytes); + const privilegedProduct = await publish(); + await stageConfiguredAssets( + fs, + [{ + path, + url: `data:application/octet-stream,${encodeURIComponent(text)}`, + mode, + }], + () => {}, + () => {}, + ); + const kernel = fakeKernel(); + + await expect(initializeDemoLoginKernel({ + kernel, + fs, + kernelWasm: new ArrayBuffer(0), + vfsImage: await fs.saveImage(), + privilegedProduct, + })).resolves.toBe(false); + expect(kernel.initFromImage).toHaveBeenCalledOnce(); + expect(kernel.initFromPublishedPrivilegedProgramProduct) + .not.toHaveBeenCalled(); + }); + + it( + "publishes only after configured assets leave every source byte exact", + async () => { + const loginBytes = new Uint8Array([0, 97, 115, 109, 1]); + const { publish, sourceFs } = await productFixture(loginBytes); + await stageConfiguredAssets( + sourceFs, + [{ + path: "/login", + url: "data:application/octet-stream;base64,AGFzbQI=", + mode: 0o755, + }], + () => {}, + () => {}, + ); + await expect(publish()).rejects.toThrow("artifact digest mismatch"); + }, + ); +}); diff --git a/apps/browser-demos/test/fixtures/terminal-removal-ui.html b/apps/browser-demos/test/fixtures/terminal-removal-ui.html new file mode 100644 index 0000000000..3aaff789e2 --- /dev/null +++ b/apps/browser-demos/test/fixtures/terminal-removal-ui.html @@ -0,0 +1,8 @@ + + + Terminal removal UI test + +
+ + + diff --git a/apps/browser-demos/test/fixtures/terminal-removal-ui.tsx b/apps/browser-demos/test/fixtures/terminal-removal-ui.tsx new file mode 100644 index 0000000000..86cdc41bf6 --- /dev/null +++ b/apps/browser-demos/test/fixtures/terminal-removal-ui.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; +import { createRoot } from "react-dom/client"; +import { TerminalDockControls } from "../../pages/kandelo/app/TerminalDockControls"; +import { KernelHostProvider } from "../../pages/kandelo/kernel-host/react"; +import type { KernelHost } from "../../../../web-libs/kandelo-session/src/kernel-host"; + +const removals: string[] = []; +const stateRemovals: string[] = []; +const host = { + removePty(path: string) { + removals.push(path); + }, +} as unknown as KernelHost; +const root = createRoot(document.getElementById("root")!); +root.render( + + {}} + onAddTerminal={() => {}} + onRemoveTerminalId={(id) => stateRemovals.push(id)} + /> + , +); + +Object.assign(window, { + __terminalRemovalTest: { root, removals, stateRemovals }, +}); diff --git a/apps/browser-demos/test/login-terminal-session.spec.ts b/apps/browser-demos/test/login-terminal-session.spec.ts new file mode 100644 index 0000000000..9c2f80c4bd --- /dev/null +++ b/apps/browser-demos/test/login-terminal-session.spec.ts @@ -0,0 +1,479 @@ +import { resolve } from "node:path"; +import { expect, test } from "@playwright/test"; +import { + DEMO_AUTOLOGIN_MOTD, + DEMO_LOGIN_PASSWORD, + DEMO_LOGIN_PASSWORD_HASH, + DEMO_SUDOERS, +} from "../../../images/vfs/lib/demo-login"; +import { resolveBinary } from "../../../host/src/binary-resolver"; + +const repoRoot = resolve(import.meta.dirname, "../../.."); +const modulePaths = { + browserKernel: resolve(repoRoot, "host/src/browser-kernel-host.ts"), + memoryFs: resolve(repoRoot, "host/src/vfs/memory-fs.ts"), + privilegedProjection: resolve( + repoRoot, + "host/src/vfs/privileged-projection.ts", + ), + sessionHost: resolve( + repoRoot, + "web-libs/kandelo-session/src/kernel-host.ts", + ), + terminalPolicy: resolve( + repoRoot, + "apps/browser-demos/pages/kandelo/kernel-host/demo-terminal-sessions.ts", + ), + demoLoginLoader: resolve( + repoRoot, + "apps/browser-demos/pages/kandelo/kernel-host/demo-login-loader.ts", + ), +}; +const shellWasm = resolve( + repoRoot, + "local-binaries/programs/wasm32/sh.wasm", +); +const loginWasm = resolve( + repoRoot, + "local-binaries/test-fixtures/wasm32/login.wasm", +); +const credentialsWasm = resolve( + repoRoot, + "examples/initial-credentials-test.wasm", +); + +test("BrowserKernel session supervises one real login lifecycle per logical PTY", async ({ + page, + baseURL, +}) => { + test.setTimeout(180_000); + expect(baseURL).toBeTruthy(); + const runtimeErrors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") { + runtimeErrors.push(`console: ${message.text()}`); + } + }); + page.on("pageerror", (error) => { + runtimeErrors.push(`pageerror: ${error.message}`); + }); + await page.route("**/favicon.ico", (route) => route.fulfill({ status: 204 })); + await page.goto(new URL("/trap-signal-test.html", baseURL).href); + const asViteFsUrl = (path: string) => new URL(`/@fs${path}`, baseURL).href; + + const result = await page.evaluate( + async ({ + autologinMotd, + browserKernelUrl, + credentialsUrl, + demoLoginLoaderUrl, + kernelUrl, + loginUrl, + memoryFsUrl, + password, + passwordHash, + privilegedProjectionUrl, + sessionHostUrl, + shellUrl, + sudoers, + terminalPolicyUrl, + }) => { + const { BrowserKernel } = await import( + /* @vite-ignore */ browserKernelUrl + ); + const { MemoryFileSystem } = await import( + /* @vite-ignore */ memoryFsUrl + ); + const { + createReviewedPrivilegedProgramPolicy, + publishPrivilegedProgramProduct, + } = await import(/* @vite-ignore */ privilegedProjectionUrl); + const { LiveKernelHost } = await import( + /* @vite-ignore */ sessionHostUrl + ); + const { DEMO_TERMINAL_SESSION_POLICY } = await import( + /* @vite-ignore */ terminalPolicyUrl + ); + const { initializeDemoLoginKernel } = await import( + /* @vite-ignore */ demoLoginLoaderUrl + ); + const fetchBytes = async (url: string): Promise => { + const response = await fetch(url); + if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`); + return new Uint8Array(await response.arrayBuffer()); + }; + const [kernelBytes, shell, login, credentials] = await Promise.all([ + fetchBytes(kernelUrl), + fetchBytes(shellUrl), + fetchBytes(loginUrl), + fetchBytes(credentialsUrl), + ]); + const loginDigest = Array.from( + new Uint8Array(await crypto.subtle.digest("SHA-256", login)), + ).map((byte) => byte.toString(16).padStart(2, "0")).join(""); + + const sourceFs = MemoryFileSystem.create( + new SharedArrayBuffer(8 * 1024 * 1024), + ); + const destinations = [ + ["login", "/usr/bin/login"], + ["sudo-lite", "/usr/bin/sudo-lite"], + ["sudo", "/usr/bin/sudo"], + ] as const; + for (const [sourcePath] of destinations) { + sourceFs.createFileWithOwner( + `/${sourcePath}`, + 0o755, + 1000, + 1000, + login, + ); + } + const bottleSha256 = "a".repeat(64); + const privilegedProduct = await publishPrivilegedProgramProduct({ + policy: createReviewedPrivilegedProgramPolicy( + destinations.map(([sourcePath, destinationPath]) => ({ + schema: 1, + formula: `kandelo-test/${sourcePath}`, + bottleSha256, + sourcePath, + destinationPath, + uid: 0, + gid: 0, + mode: 0o4755, + mountPoint: "trusted-root-product", + artifactValidationSha256: loginDigest, + })), + ), + sources: destinations.map(([sourcePath]) => ({ + formula: `kandelo-test/${sourcePath}`, + bottleSha256, + fs: sourceFs, + inventory: { + entries: [{ + sourcePath, + type: "file" as const, + size: login.byteLength, + }], + }, + guestPathForSource: (path: string) => `/${path}`, + })), + writableBottleFileSystems: [sourceFs], + }); + + const fs = MemoryFileSystem.create( + new SharedArrayBuffer(16 * 1024 * 1024), + ); + for (const path of [ + "/etc", + "/bin", + "/home", + "/root", + "/usr", + "/usr/bin", + "/var", + ]) fs.mkdir(path, 0o755); + fs.mkdirWithOwner("/home/maker", 0o755, 1000, 1000); + fs.chmod("/root", 0o700); + const enc = new TextEncoder(); + fs.createFileWithOwner( + "/etc/passwd", + 0o644, + 0, + 0, + enc.encode( + "root:x:0:0:root:/root:/bin/sh\n" + + "maker:x:1000:1000:maker:/home/maker:/bin/sh\n", + ), + ); + fs.createFileWithOwner( + "/etc/shadow", + 0o640, + 0, + 0, + enc.encode( + "root:*:0:0:99999:7:::\n" + + `maker:${passwordHash}:0:0:99999:7:::\n`, + ), + ); + fs.createFileWithOwner( + "/etc/group", + 0o644, + 0, + 0, + enc.encode("root:x:0:\nwheel:x:10:maker\nmaker:x:1000:\n"), + ); + fs.createFileWithOwner( + "/etc/nsswitch.conf", + 0o644, + 0, + 0, + enc.encode("passwd: files\ngroup: files\nshadow: files\n"), + ); + fs.createFileWithOwner( + "/etc/motd", + 0o644, + 0, + 0, + enc.encode("Browser ordinary login\n"), + ); + fs.createFileWithOwner( + "/etc/motd.autologin", + 0o644, + 0, + 0, + enc.encode(autologinMotd), + ); + fs.createFileWithOwner( + "/etc/sudoers", + 0o440, + 0, + 0, + enc.encode(sudoers), + ); + fs.createFileWithOwner("/bin/sh", 0o755, 0, 0, shell); + fs.createFileWithOwner( + "/bin/credentials", + 0o755, + 0, + 0, + credentials, + ); + // This represents an otherwise ordinary third-party image. Its local + // set-ID metadata is not authority; the separately published product + // below must still admit these exact bytes through the private loader. + fs.createFileWithOwner("/usr/bin/login", 0o4755, 0, 0, login); + const image = await fs.saveImage(); + const kernelWasm = kernelBytes.buffer.slice( + kernelBytes.byteOffset, + kernelBytes.byteOffset + kernelBytes.byteLength, + ); + + const diagnostics: string[] = []; + const kernel = new BrowserKernel({ + maxWorkers: 4, + env: ["TERM=xterm-kandelo", "PATH=/usr/local/bin:/usr/bin:/bin"], + onHostDiagnostic(diagnostic: { message: string }) { + diagnostics.push(diagnostic.message); + }, + }); + const spawns: Array<{ path: string; argv: string[]; pid: number }> = []; + const originalSpawnFromVfs = kernel.spawnFromVfs.bind(kernel); + kernel.spawnFromVfs = async ( + path: string, + argv: string[], + options?: Parameters[2], + ) => { + const spawned = await originalSpawnFromVfs(path, argv, options); + spawns.push({ path, argv: argv.slice(), pid: spawned.pid }); + return spawned; + }; + const waitFor = async ( + predicate: () => boolean | Promise, + label: string, + timeoutMs = 20_000, + ): Promise => { + const deadline = performance.now() + timeoutMs; + while (!(await predicate())) { + if (performance.now() >= deadline) { + throw new Error(`timed out waiting for ${label}`); + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + }; + const occurrences = (text: string, needle: string): number => + text.split(needle).length - 1; + const collect = async ( + host: InstanceType, + path: string, + ) => { + const pty = await host.attachPty(path, { cols: 100, rows: 30 }); + let text = ""; + const off = pty.onData((bytes: Uint8Array) => { + text += new TextDecoder().decode(bytes); + }); + return { pty, off, text: () => text }; + }; + + try { + const loginSessionsEnabled = await initializeDemoLoginKernel({ + kernel, + fs, + kernelWasm, + vfsImage: image, + privilegedProduct, + }); + if (!loginSessionsEnabled) { + throw new Error("production loader rejected the reviewed login product"); + } + const host = new LiveKernelHost({ + kernel, + status: "running", + }); + host.setTerminalSessionPolicy(DEMO_TERMINAL_SESSION_POLICY); + const primary = await collect(host, "/dev/pts/0"); + await waitFor( + () => primary.text().includes("Every new terminal logs in automatically."), + "initial autologin message", + ); + primary.pty.write("/bin/credentials\n"); + await waitFor( + () => primary.text().includes("uid=1000 euid=1000 gid=1000 egid=1000"), + "maker credentials", + ); + + const spawnCountBeforeReattach = spawns.length; + primary.off(); + primary.pty.close(); + const reattached = await collect(host, "/dev/pts/0"); + await new Promise((resolve) => setTimeout(resolve, 100)); + const spawnCountAfterReattach = spawns.length; + const autologinBeforeLogout = occurrences( + reattached.text(), + "Every new terminal logs in automatically.", + ); + + const loginPromptsBeforeLogout = occurrences( + reattached.text(), + "login: ", + ); + reattached.pty.write("exit\n"); + await waitFor(() => spawns.length >= 2, "ordinary login restart"); + await waitFor( + () => occurrences(reattached.text(), "login: ") > loginPromptsBeforeLogout, + "login prompt", + ); + reattached.pty.write("maker\n"); + await waitFor( + () => reattached.text().includes("Password: "), + "password prompt", + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + const loginPromptsBeforeFailure = occurrences( + reattached.text(), + "login: ", + ); + reattached.pty.write("definitely-wrong\n"); + await waitFor( + () => reattached.text().includes("Login incorrect"), + "failed password", + ); + await waitFor(() => spawns.length >= 3, "login after rejection"); + + await waitFor( + () => occurrences(reattached.text(), "login: ") > loginPromptsBeforeFailure, + "login prompt after rejection", + ); + const passwordPrompts = occurrences(reattached.text(), "Password: "); + reattached.pty.write("maker\n"); + await waitFor( + () => occurrences(reattached.text(), "Password: ") > passwordPrompts, + "second password prompt", + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + reattached.pty.write(`${password}\n`); + const credentialsBefore = occurrences(reattached.text(), "uid=1000"); + const ordinaryMotdsBefore = occurrences( + reattached.text(), + "Browser ordinary login", + ); + await waitFor( + () => occurrences( + reattached.text(), + "Browser ordinary login", + ) > ordinaryMotdsBefore, + "successful ordinary login", + ); + reattached.pty.write("/bin/credentials\n"); + await waitFor( + () => occurrences(reattached.text(), "uid=1000") > credentialsBefore, + "post-password maker credentials", + ); + + const secondary = await collect(host, "/dev/pts/1"); + await waitFor( + () => secondary.text().includes("Every new terminal logs in automatically."), + "secondary autologin", + ); + const secondaryPid = spawns.at(-1)?.pid; + if (secondaryPid === undefined) { + throw new Error("secondary login spawn did not report a pid"); + } + const secondarySpawnCount = spawns.length; + secondary.pty.close(); + const secondaryReattach = await collect(host, "/dev/pts/1"); + await new Promise((resolve) => setTimeout(resolve, 100)); + const secondarySpawnCountAfterReattach = spawns.length; + + host.removePty("/dev/pts/1"); + await waitFor( + async () => !(await kernel.enumProcs()).some( + (proc: { pid: number }) => proc.pid === secondaryPid, + ), + "removed secondary process", + ); + const replacement = await collect(host, "/dev/pts/1"); + await waitFor( + () => replacement.text().includes("Every new terminal logs in automatically."), + "replacement logical PTY autologin", + ); + + const beforeDetach = spawns.length; + host.detachKernel(); + await new Promise((resolve) => setTimeout(resolve, 5_250)); + return { + argv: spawns.map(({ argv }) => argv), + autologinAfterPassword: occurrences( + reattached.text(), + "Every new terminal logs in automatically.", + ), + autologinBeforeLogout, + beforeDetach, + diagnostics, + failedPasswordEchoed: reattached.text().includes("definitely-wrong"), + programPaths: spawns.map(({ path }) => path), + spawnCountAfterReattach, + spawnCountBeforeReattach, + spawnCountAfterDetachDelay: spawns.length, + secondarySpawnCount, + secondarySpawnCountAfterReattach, + loginSessionsEnabled, + }; + } finally { + await kernel.destroy().catch(() => {}); + } + }, + { + autologinMotd: DEMO_AUTOLOGIN_MOTD, + browserKernelUrl: asViteFsUrl(modulePaths.browserKernel), + credentialsUrl: asViteFsUrl(credentialsWasm), + demoLoginLoaderUrl: asViteFsUrl(modulePaths.demoLoginLoader), + kernelUrl: asViteFsUrl(resolveBinary("kernel.wasm")), + loginUrl: asViteFsUrl(loginWasm), + memoryFsUrl: asViteFsUrl(modulePaths.memoryFs), + password: DEMO_LOGIN_PASSWORD, + passwordHash: DEMO_LOGIN_PASSWORD_HASH, + privilegedProjectionUrl: asViteFsUrl(modulePaths.privilegedProjection), + sessionHostUrl: asViteFsUrl(modulePaths.sessionHost), + shellUrl: asViteFsUrl(shellWasm), + sudoers: DEMO_SUDOERS, + terminalPolicyUrl: asViteFsUrl(modulePaths.terminalPolicy), + }, + ); + + expect(result.argv[0]).toEqual(["login", "-p", "-f", "maker"]); + expect(result.loginSessionsEnabled).toBe(true); + expect(result.argv[1]).toEqual(["login", "-p"]); + expect(result.argv[2]).toEqual(["login", "-p"]); + expect(result.programPaths.every((path) => path === "/usr/bin/login")) + .toBe(true); + expect(result.spawnCountAfterReattach).toBe(result.spawnCountBeforeReattach); + expect(result.autologinAfterPassword).toBe(result.autologinBeforeLogout); + expect(result.failedPasswordEchoed).toBe(false); + expect(result.secondarySpawnCountAfterReattach).toBe( + result.secondarySpawnCount, + ); + expect(result.spawnCountAfterDetachDelay).toBe(result.beforeDetach); + expect(result.diagnostics).toEqual([]); + expect(runtimeErrors).toEqual([]); +}); diff --git a/apps/browser-demos/test/terminal-removal-ui.spec.ts b/apps/browser-demos/test/terminal-removal-ui.spec.ts new file mode 100644 index 0000000000..73aa503ecd --- /dev/null +++ b/apps/browser-demos/test/terminal-removal-ui.spec.ts @@ -0,0 +1,26 @@ +import { expect, test } from "@playwright/test"; + +test("terminal close is explicit logical removal and cleanup is only detach", async ({ + page, + baseURL, +}) => { + expect(baseURL).toBeTruthy(); + await page.goto( + new URL("/test/fixtures/terminal-removal-ui.html", baseURL).href, + ); + + await page.getByRole("button", { name: "Close TTY2" }).click(); + expect(await page.evaluate(() => (window as unknown as { + __terminalRemovalTest: { removals: string[]; stateRemovals: string[] }; + }).__terminalRemovalTest)).toMatchObject({ + removals: ["/dev/pts/1"], + stateRemovals: ["tty-2"], + }); + + await page.evaluate(() => (window as unknown as { + __terminalRemovalTest: { root: { unmount(): void } }; + }).__terminalRemovalTest.root.unmount()); + expect(await page.evaluate(() => (window as unknown as { + __terminalRemovalTest: { removals: string[] }; + }).__terminalRemovalTest.removals)).toEqual(["/dev/pts/1"]); +}); diff --git a/docs/architecture.md b/docs/architecture.md index 7a2f0615dd..0141bf2309 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1762,12 +1762,36 @@ default configuration/trust lookup reads the same image bytes that `cat` would. The kernel synthesizes `/etc/mtab` because it reports live mount state; it does not synthesize static `/etc` policy or trust data. -The Task 17 rootfs data defines the canonical interactive image account as +The rootfs data defines the canonical interactive image account as `maker` at uid/gid 1000 with home `/home/maker`. Its password hash, wheel membership, sudoers policy, and login messages are ordinary rootfs files. -Task 18 must publish reviewed `login` and `sudo-lite` product artifacts through -the privileged-program publication path before that account is product-ready; -the rootfs does not synthesize those executables or a preauthenticated shell. +Reviewed `login`, `sudo-lite`, and `sudo` artifacts are published through the +privileged-program path before that account is product-ready; the rootfs does +not synthesize those executables or a preauthenticated shell. + +The reusable browser session layer owns one lifecycle record per logical PTY. +Under the current browser trust boundary, the live loader selects that policy +only after all configured assets have been staged and both of these checks +succeed: the final writable image has the one exact canonical `maker` account, +password, wheel, sudoers, and autologin records; and a separately +publisher-admitted product supplies the same exact `login` bytes through +`BrowserKernel.initFromPublishedPrivilegedProgramProduct`. Image origin is not +part of this decision, so an otherwise third-party image remains eligible when +its final state is canonical and it is paired with that separate product. +Image/config/descriptor data alone cannot construct the private product +capability. This describes the repository's present safety boundary; the +larger trust model for deliberately user-selected images remains an open +architecture question rather than a policy settled by terminal sessions. + +For an eligible image/product pair, the first process is root-authorized +`login -p -f maker`; every later process is ordinary `login -p`. UI handles +only attach listeners to that record, while the terminal tab's explicit close +action, kernel detach, reboot, and destruction invalidate its process +generation, terminate the active process, and cancel pending restart. Short +processes back off from 250 milliseconds to a five-second cap, a process that +survives two seconds resets the delay, and a replacement launch failure remains +visible in the terminal without automatic retry. Password authentication stays +in the guest program and final VFS credentials rather than React. VFS images can also carry image-level metadata outside the guest file tree. The first declaration is `kernelAbi`, an exact `ABI_VERSION` requirement for images that carry ABI-bound Wasm programs. `MemoryFileSystem.readImageMetadata(image)` reads this declaration without materialising the filesystem, and `MemoryFileSystem.assertImageKernelAbi(image, abi)` validates it for callers that already know the running kernel ABI. Legacy/data-only images may omit the field. diff --git a/docs/browser-support.md b/docs/browser-support.md index ed06bb94e1..ff8c8a3920 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -710,6 +710,34 @@ readable `/etc/profile.d/*.sh` fragments there, so an image composer can add package-manager environment setup without teaching the browser about a particular package or prefix. +Under the current browser trust boundary, a supervised demo login requires the +final fully staged image to contain exactly one canonical `maker` passwd, +shadow, and wheel record, the exact sudoers policy and autologin message, and +an exact local `/usr/bin/login` byte match for a separately +publisher-admitted privileged product. The loader makes this decision after +configured assets and lazy inputs have been staged, then boots through +`BrowserKernel.initFromPublishedPrivilegedProgramProduct`. A raw image, +descriptor, or demo configuration cannot mint that private capability. +Conversely, image origin is not a gate: an otherwise third-party image with +the exact final state remains eligible when paired with a separately admitted +product. This documents current repository behavior only; the broader trust +model for deliberately user-selected images remains unresolved. + +For an eligible image/product pair, each newly allocated logical terminal +starts root-authorized `login -p -f maker` once. +When that login shell exits, the same terminal starts ordinary `login -p` with +a bounded restart delay. Closing and reopening the terminal UI only detaches +and reattaches its renderer; it neither repeats autologin nor replaces the +guest process. The explicit close control in the terminal tab removes the +logical terminal; that action, kernel detach, reboot, and host destruction +stop the active process and cancel pending restarts. + +Images that do not satisfy the complete final predicate, or that have no +separately admitted product, retain their declared default shell. The browser +does not infer readiness from an arbitrary unlocked password or implement +authentication in React; both preauthentication and password verification +remain in the guest `login` program and VFS state. + `terminal.run` sends a command through the persistent PTY-backed shell. `terminal.write` sends raw text to that PTY, which is useful for entering input into an already-running REPL. `guide.companion.srcDoc` runs in a sandboxed diff --git a/host/src/browser-kernel-host.ts b/host/src/browser-kernel-host.ts index 7a181e0ebf..243e324003 100644 --- a/host/src/browser-kernel-host.ts +++ b/host/src/browser-kernel-host.ts @@ -351,6 +351,7 @@ export class BrowserKernel { async initFromPublishedPrivilegedProgramProduct(options: { kernelWasm?: ArrayBuffer; vfsImage: Uint8Array | "default"; + closedLazyAssets?: readonly ClosedLazyAsset[]; privilegedProduct: PublishedPrivilegedProgramProduct; }): Promise { const privilegedProgramMount = @@ -370,6 +371,7 @@ export class BrowserKernel { kernelWasmBytes: wasmBytes, vfsImage, lazyUrlBase: import.meta.env.BASE_URL, + closedLazyAssets: options.closedLazyAssets, takeVfsImageOwnership: false, privilegedProgramMount, }); diff --git a/host/src/homebrew-runtime-layer-consumer.ts b/host/src/homebrew-runtime-layer-consumer.ts index 64c72e602f..254c6ee8c4 100644 --- a/host/src/homebrew-runtime-layer-consumer.ts +++ b/host/src/homebrew-runtime-layer-consumer.ts @@ -233,7 +233,8 @@ async function composeHomebrewRuntimeLayersInternal( } } -async function publishRuntimeLayerPrivilegedPrograms( +/** Product-owned final publication adapter; intentionally absent from barrels. */ +export async function publishRuntimeLayerPrivilegedPrograms( fs: MemoryFileSystem, layers: readonly RegisteredHomebrewRuntimeLayer[], policy: ReviewedPrivilegedProgramPolicy, diff --git a/host/src/vfs/privileged-projection.ts b/host/src/vfs/privileged-projection.ts index 777166b0a3..0d5d8c598f 100644 --- a/host/src/vfs/privileged-projection.ts +++ b/host/src/vfs/privileged-projection.ts @@ -44,6 +44,10 @@ const publishedProductBrowserMounts = new WeakMap< object, PublishedPrivilegedProgramBrowserMount >(); +const publishedProductProjections = new WeakMap< + object, + PrivilegedProgramProjection[] +>(); export interface PrivilegedProgramProjection { schema: 1; @@ -255,6 +259,33 @@ export function snapshotPublishedPrivilegedProgramBrowserMount( }; } +/** + * Compare a writable image file with the exact projection admitted for a + * privately branded product. This conveys no mount capability and is absent + * from public barrels; browser product loaders use it to reject stale staged + * destinations before granting a terminal policy. + */ +export async function publishedPrivilegedProgramMatchesFile( + product: PublishedPrivilegedProgramProduct, + fs: MemoryFileSystem, + destinationPath: string, +): Promise { + const projections = publishedProductProjections.get(product); + if (projections === undefined) { + throw new Error("privileged program product lacks publication authority"); + } + const matching = projections.filter( + (projection) => projection.destinationPath === destinationPath, + ); + if (matching.length !== 1) return false; + try { + return await sha256Hex(readRegularFile(fs, destinationPath)) === + matching[0]!.artifactValidationSha256; + } catch { + return false; + } +} + /** * Copy all reviewed members into one unpublished tree, then admit the group. * A failed member leaves no returned backend and never mutates a bottle tree. @@ -372,6 +403,10 @@ async function publishAuthenticatedCandidate( mountPoint: "/usr/bin", imageBytes: browserMountImageBytes, }); + publishedProductProjections.set( + product, + options.projections.map((projection) => ({ ...projection })), + ); return product; } diff --git a/host/test/demo-login-image.test.ts b/host/test/demo-login-image.test.ts index 4d1be96bbe..7f183b1041 100644 --- a/host/test/demo-login-image.test.ts +++ b/host/test/demo-login-image.test.ts @@ -3,12 +3,15 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { + DEMO_AUTOLOGIN_MOTD, configureDemoLogin, DEMO_AUTOLOGIN_MOTD_PATH, DEMO_LOGIN_PASSWORD, DEMO_LOGIN_PASSWORD_HASH, DEMO_LOGIN_PROGRAM_PATH, DEMO_LOGIN_USERNAME, + DEMO_SUDOERS, + DEMO_SUDOERS_PATH, hasConfiguredDemoLogin, } from "../../images/vfs/lib/demo-login"; import { ensureDirRecursive } from "../src/vfs/image-helpers"; @@ -18,6 +21,16 @@ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); const decoder = new TextDecoder(); const encoder = new TextEncoder(); +function writeText(fs: MemoryFileSystem, path: string, content: string): void { + const bytes = encoder.encode(content); + const fd = fs.open(path, 0o1101, 0o644); + try { + fs.write(fd, bytes, 0, bytes.length); + } finally { + fs.close(fd); + } +} + function readText(fs: MemoryFileSystem, path: string): string { const st = fs.stat(path); const fd = fs.open(path, 0, 0); @@ -78,6 +91,10 @@ describe("canonical demo login image policy", () => { // This predicate certifies configuration staging. Task 7's reviewed // privileged-product publisher separately proves the executable bytes and // trusted mount provenance before set-ID execution is possible. + expect(hasConfiguredDemoLogin(fs)).toBe(false); + configureDemoLogin(fs, { home: "/home/user", shell: "/bin/sh" }); + expect(hasConfiguredDemoLogin(fs)).toBe(false); + configureDemoLogin(fs, { home: "/home/maker", shell: "/bin/sh" }); expect(hasConfiguredDemoLogin(fs)).toBe(true); expect(DEMO_LOGIN_USERNAME).toBe("maker"); @@ -86,7 +103,7 @@ describe("canonical demo login image policy", () => { "$6$kandelo$DKNPruix37YeUx9j4kJIGJ2NvXdqzxDr5b1D3xJZzbwFsNYuep8j3AtxB7OaTD6HWnz/adonyTamRx4XQwJ06/", ); expect(readText(fs, "/etc/passwd")).toContain( - "maker:x:1000:1000:maker:/work:/bin/bash", + "maker:x:1000:1000:maker:/home/maker:/bin/sh", ); expect(readText(fs, "/etc/shadow")).toContain( `maker:${DEMO_LOGIN_PASSWORD_HASH}:`, @@ -110,6 +127,114 @@ describe("canonical demo login image policy", () => { expect(hasConfiguredDemoLogin(fs)).toBe(false); fs.chmod("/etc/sudoers", 0o440); expect(hasConfiguredDemoLogin(fs)).toBe(true); + + writeText( + fs, + "/etc/shadow", + "root:*:0:0:99999:7:::\n" + + "maker:$6$other$still-an-unlocked-hash:0:0:99999:7:::\n", + ); + expect(hasConfiguredDemoLogin(fs)).toBe(false); + writeText( + fs, + "/etc/shadow", + "root:*:0:0:99999:7:::\n" + + `maker:${DEMO_LOGIN_PASSWORD_HASH}:0:0:99999:7:::\n`, + ); + expect(hasConfiguredDemoLogin(fs)).toBe(true); + + writeText(fs, DEMO_AUTOLOGIN_MOTD_PATH, "forged credential hint\n"); + expect(hasConfiguredDemoLogin(fs)).toBe(false); + writeText(fs, DEMO_AUTOLOGIN_MOTD_PATH, DEMO_AUTOLOGIN_MOTD); + expect(hasConfiguredDemoLogin(fs)).toBe(true); + + fs.chmod(DEMO_AUTOLOGIN_MOTD_PATH, 0o600); + expect(hasConfiguredDemoLogin(fs)).toBe(false); + fs.chmod(DEMO_AUTOLOGIN_MOTD_PATH, 0o644); + expect(hasConfiguredDemoLogin(fs)).toBe(true); + + fs.chown(DEMO_AUTOLOGIN_MOTD_PATH, 1000, 1000); + expect(hasConfiguredDemoLogin(fs)).toBe(false); + }); + + it("rejects ambiguous account, password, and wheel records", () => { + const fs = MemoryFileSystem.create(new SharedArrayBuffer(2 * 1024 * 1024)); + ensureDirRecursive(fs, "/etc"); + ensureDirRecursive(fs, "/usr/bin"); + fs.createFileWithOwner( + DEMO_LOGIN_PROGRAM_PATH, + 0o4755, + 0, + 0, + new Uint8Array([0]), + ); + fs.createFileWithOwner( + "/etc/passwd", + 0o644, + 0, + 0, + encoder.encode("maker:x:1000:1000:maker:/home/maker:/bin/sh\n"), + ); + fs.createFileWithOwner( + "/etc/shadow", + 0o640, + 0, + 0, + encoder.encode(`maker:${DEMO_LOGIN_PASSWORD_HASH}:0:0:99999:7:::\n`), + ); + fs.createFileWithOwner( + "/etc/group", + 0o644, + 0, + 0, + encoder.encode("wheel:x:10:maker\n"), + ); + fs.createFileWithOwner( + DEMO_SUDOERS_PATH, + 0o440, + 0, + 0, + encoder.encode(DEMO_SUDOERS), + ); + fs.createFileWithOwner( + DEMO_AUTOLOGIN_MOTD_PATH, + 0o644, + 0, + 0, + encoder.encode(DEMO_AUTOLOGIN_MOTD), + ); + expect(hasConfiguredDemoLogin(fs)).toBe(true); + + writeText( + fs, + "/etc/passwd", + "maker:x:1000:1000:maker:/home/maker:/bin/sh\n" + + "maker:x:1000:1000:maker:/home/user:/bin/sh\n", + ); + expect(hasConfiguredDemoLogin(fs)).toBe(false); + writeText( + fs, + "/etc/passwd", + "maker:x:1000:1000:maker:/home/maker:/bin/sh\n", + ); + + writeText( + fs, + "/etc/shadow", + `maker:${DEMO_LOGIN_PASSWORD_HASH}:0:0:99999:7:::\n` + + `maker:${DEMO_LOGIN_PASSWORD_HASH}:0:0:99999:7:::\n`, + ); + expect(hasConfiguredDemoLogin(fs)).toBe(false); + writeText( + fs, + "/etc/shadow", + `maker:${DEMO_LOGIN_PASSWORD_HASH}:0:0:99999:7:::\n`, + ); + + writeText(fs, "/etc/group", "wheel:x:10:maker\nwheel:x:10:maker\n"); + expect(hasConfiguredDemoLogin(fs)).toBe(false); + writeText(fs, "/etc/group", "wheel:x:10:maker,root\n"); + expect(hasConfiguredDemoLogin(fs)).toBe(false); }); it("keeps canonical rootfs data truthful and product binaries outside local compiler paths", () => { diff --git a/images/vfs/lib/demo-login.ts b/images/vfs/lib/demo-login.ts index ab655dcb96..40fd20f028 100644 --- a/images/vfs/lib/demo-login.ts +++ b/images/vfs/lib/demo-login.ts @@ -2,6 +2,7 @@ import type { MemoryFileSystem } from "../../../host/src/vfs/memory-fs"; export const DEMO_LOGIN_USERNAME = "maker"; export const DEMO_LOGIN_HOME = "/home/maker"; +export const DEMO_LOGIN_SHELL = "/bin/sh"; export const DEMO_LOGIN_PASSWORD = "kandelo"; export const DEMO_LOGIN_PASSWORD_HASH = "$6$kandelo$DKNPruix37YeUx9j4kJIGJ2NvXdqzxDr5b1D3xJZzbwFsNYuep8j3AtxB7OaTD6HWnz/adonyTamRx4XQwJ06/"; @@ -34,7 +35,7 @@ export function configureDemoLogin( options: DemoLoginOptions = {}, ): void { const home = options.home ?? DEMO_LOGIN_HOME; - const shell = options.shell ?? "/bin/bash"; + const shell = options.shell ?? DEMO_LOGIN_SHELL; const passwd = updateRequiredRecord( readVfsText(fs, "/etc/passwd"), DEMO_LOGIN_USERNAME, @@ -65,9 +66,10 @@ export function configureDemoLogin( } /** - * True when the canonical account/policy files and a root-owned set-ID login - * entry are staged. Task 7's privileged-product publication remains the - * authority that proves the executable bytes and trusted mount provenance. + * True when the final staged filesystem contains one exact canonical account, + * password, wheel policy, credential message, and root-owned set-ID login + * entry. Privileged-product publication separately proves the executable + * bytes and trusted mount provenance before the browser grants session policy. */ export function hasConfiguredDemoLogin(fs: MemoryFileSystem): boolean { try { @@ -79,59 +81,75 @@ export function hasConfiguredDemoLogin(fs: MemoryFileSystem): boolean { login.gid === 0 && fs.getLazyEntry(DEMO_LOGIN_PROGRAM_PATH) === null; const shadowMetadata = fs.stat("/etc/shadow"); + const passwdMetadata = fs.stat("/etc/passwd"); + const groupMetadata = fs.stat("/etc/group"); const sudoersMetadata = fs.stat(DEMO_SUDOERS_PATH); + const autologinMotdMetadata = fs.stat(DEMO_AUTOLOGIN_MOTD_PATH); const passwd = readVfsText(fs, "/etc/passwd"); const shadow = readVfsText(fs, "/etc/shadow"); const group = readVfsText(fs, "/etc/group"); const sudoers = readVfsText(fs, DEMO_SUDOERS_PATH); - const accountIsCanonical = passwd.split("\n").some((line) => { - const fields = line.split(":"); - const shell = fields[6] ?? ""; - return ( - fields[0] === DEMO_LOGIN_USERNAME && - fields[2] === "1000" && - fields[3] === "1000" && - shell.length > 0 && - !shell.endsWith("/nologin") - ); - }); - const accountCanAuthenticate = shadow.split("\n").some((line) => { - const fields = line.split(":"); - const hash = fields[1] ?? ""; - return ( - fields[0] === DEMO_LOGIN_USERNAME && - hash.length > 0 && - hash !== "x" && - hash[0] !== "!" && - hash[0] !== "*" - ); - }); - const wheelAllowsMaker = group.split("\n").some((line) => { - const fields = line.split(":"); - return ( - fields[0] === "wheel" && - fields[2] === "10" && - (fields[3] ?? "").split(",").includes(DEMO_LOGIN_USERNAME) - ); - }); + const autologinMotd = readVfsText(fs, DEMO_AUTOLOGIN_MOTD_PATH); + const accountRecords = recordsNamed(passwd, DEMO_LOGIN_USERNAME); + const shadowRecords = recordsNamed(shadow, DEMO_LOGIN_USERNAME); + const wheelRecords = recordsNamed(group, "wheel"); + const account = accountRecords[0] ?? []; + const password = shadowRecords[0] ?? []; + const wheel = wheelRecords[0] ?? []; + const accountIsCanonical = + accountRecords.length === 1 && + account.length === 7 && + account[1] === "x" && + account[2] === "1000" && + account[3] === "1000" && + account[4] === DEMO_LOGIN_USERNAME && + account[5] === DEMO_LOGIN_HOME && + account[6] === DEMO_LOGIN_SHELL; + const accountHasCanonicalPassword = + shadowRecords.length === 1 && password[1] === DEMO_LOGIN_PASSWORD_HASH; + const wheelAllowsMaker = + wheelRecords.length === 1 && + wheel.length === 4 && + wheel[1] === "x" && + wheel[2] === "10" && + wheel[3] === DEMO_LOGIN_USERNAME; return ( loginIsStaged && + passwdMetadata.uid === 0 && + passwdMetadata.gid === 0 && + (passwdMetadata.mode & 0o7777) === 0o644 && shadowMetadata.uid === 0 && shadowMetadata.gid === 0 && (shadowMetadata.mode & 0o7777) === 0o640 && + groupMetadata.uid === 0 && + groupMetadata.gid === 0 && + (groupMetadata.mode & 0o7777) === 0o644 && sudoersMetadata.uid === 0 && sudoersMetadata.gid === 0 && (sudoersMetadata.mode & 0o7777) === 0o440 && + (autologinMotdMetadata.mode & 0o170000) === 0o100000 && + autologinMotdMetadata.uid === 0 && + autologinMotdMetadata.gid === 0 && + (autologinMotdMetadata.mode & 0o7777) === 0o644 && accountIsCanonical && - accountCanAuthenticate && + accountHasCanonicalPassword && wheelAllowsMaker && - sudoers === DEMO_SUDOERS + sudoers === DEMO_SUDOERS && + autologinMotd === DEMO_AUTOLOGIN_MOTD ); } catch { return false; } } +function recordsNamed(content: string, name: string): string[][] { + return content + .split("\n") + .filter((line) => line.length > 0) + .map((line) => line.split(":")) + .filter((fields) => fields[0] === name); +} + function updateRequiredRecord( content: string, name: string, diff --git a/web-libs/kandelo-session/src/index.ts b/web-libs/kandelo-session/src/index.ts index c5a4c21178..2c543e9523 100644 --- a/web-libs/kandelo-session/src/index.ts +++ b/web-libs/kandelo-session/src/index.ts @@ -1,6 +1,7 @@ // Re-exports for the Kandelo session surface. See kernel-host.ts for the // interface app UIs consume and the LiveKernelHost stub that wraps a kernel. export * from "./kernel-host"; +export type { TerminalProgram, TerminalSessionPolicy } from "./kernel-host"; export * from "./lazy-download"; export * from "./demo-config"; export * from "./demo-config-vfs"; diff --git a/web-libs/kandelo-session/src/kernel-host.ts b/web-libs/kandelo-session/src/kernel-host.ts index 7253a486bb..dcb23cd992 100644 --- a/web-libs/kandelo-session/src/kernel-host.ts +++ b/web-libs/kandelo-session/src/kernel-host.ts @@ -337,9 +337,28 @@ export interface PtyHandle { write(bytes: string | Uint8Array): void; onData(cb: (bytes: Uint8Array) => void): () => void; resize(cols: number, rows: number): void; + /** Detach this UI handle and its listeners without removing the logical PTY. */ close(): void; } +export interface TerminalProgram { + programPath: string; + programBytes?: ArrayBuffer; + argv: string[]; + env?: string[]; + cwd?: string; + uid?: number; + gid?: number; +} + +export interface TerminalSessionPolicy { + initial: TerminalProgram; + afterExit: TerminalProgram; + shortRunThresholdMs: number; + initialRestartDelayMs: number; + maximumRestartDelayMs: number; +} + /** * Handle returned by `attachFramebuffer`. The canvas is wired up to paint * frames; this handle lets the embedder bridge input for whichever @@ -619,6 +638,8 @@ export interface KernelHost { // shell / pty attachPty(path?: string, opts?: { cols: number; rows: number }): Promise; + /** Remove the logical PTY, including its process and pending restart. */ + removePty(path: string): void; /** Resolve after a command has been written, without waiting for a prompt. */ dispatchShellCommand(command: string): Promise; runShellCommand(command: string): Promise; @@ -719,11 +740,21 @@ class ListenerSet { } interface LivePtySession { + path: string; pid: number; - generation: number; + logicalGeneration: number; + processGeneration: number; + autologinConsumed: boolean; + startedAt: number; + restartDelayMs: number; + restartTimer: ReturnType | null; + removed: boolean; dataListeners: ListenerSet; history: Uint8Array[]; closed: boolean; + cols: number; + rows: number; + supervised: boolean; } function clampPendingRequestCount(count: number): number { @@ -787,6 +818,44 @@ function nowMs(): number { return typeof performance !== "undefined" ? performance.now() : Date.now(); } +function cloneTerminalProgram(program: TerminalProgram): TerminalProgram { + return { + ...program, + argv: program.argv.slice(), + ...(program.env ? { env: program.env.slice() } : {}), + }; +} + +function validateTerminalSessionPolicy(policy: TerminalSessionPolicy): void { + for (const [label, program] of [ + ["initial", policy.initial], + ["afterExit", policy.afterExit], + ] as const) { + if (!program.programPath.startsWith("/")) { + throw new Error( + `LiveKernelHost.setTerminalSessionPolicy ${label}.programPath must be absolute`, + ); + } + if (program.argv.length === 0) { + throw new Error( + `LiveKernelHost.setTerminalSessionPolicy ${label}.argv must not be empty`, + ); + } + } + if ( + !Number.isFinite(policy.shortRunThresholdMs) || + policy.shortRunThresholdMs < 0 || + !Number.isFinite(policy.initialRestartDelayMs) || + policy.initialRestartDelayMs < 0 || + !Number.isFinite(policy.maximumRestartDelayMs) || + policy.maximumRestartDelayMs < policy.initialRestartDelayMs + ) { + throw new Error( + "LiveKernelHost.setTerminalSessionPolicy requires bounded non-negative restart timings", + ); + } +} + // ── LiveKernelHost — wraps the real host runtime in host/src/ ────────────── // // LiveKernelHost owns the UI-facing session state: status, descriptor, @@ -914,6 +983,7 @@ export class LiveKernelHost implements KernelHost { private kernel?: KernelLike; private shell?: NonNullable; + private terminalSessions?: TerminalSessionPolicy; private ptySessions = new Map(); private ptyAttachPromises = new Map>(); private ptyCommandQueues = new Map>(); @@ -954,6 +1024,7 @@ export class LiveKernelHost implements KernelHost { /** Replace the wrapped KernelLike. Used after `boot` resolves. */ attachKernel(kernel: KernelLike): void { + const previousKernel = this.kernel; this.cancelLazyDownloads("kernel replaced"); this.clearLazyDownloadState(); this.offFramebufferAvailability?.(); @@ -962,11 +1033,8 @@ export class LiveKernelHost implements KernelHost { this.offLazyDownloads = null; this.offAudioState?.(); this.offAudioState = null; + this.invalidatePtySessions(previousKernel); this.kernel = kernel; - this.ptySessions.clear(); - this.ptyAttachPromises.clear(); - this.ptyCommandQueues.clear(); - this.shellPids.clear(); if (kernel.framebuffers) { this.offFramebufferAvailability = kernel.framebuffers.onChange(() => { this.refreshFramebufferAvailability(); @@ -990,6 +1058,7 @@ export class LiveKernelHost implements KernelHost { /** Clear the wrapped kernel after a failed boot without changing status. */ detachKernel(): void { + const detachedKernel = this.kernel; this.cancelLazyDownloads("kernel detached"); this.clearLazyDownloadState(); this.offFramebufferAvailability?.(); @@ -998,11 +1067,8 @@ export class LiveKernelHost implements KernelHost { this.offLazyDownloads = null; this.offAudioState?.(); this.offAudioState = null; + this.invalidatePtySessions(detachedKernel); this.kernel = undefined; - this.ptySessions.clear(); - this.ptyAttachPromises.clear(); - this.ptyCommandQueues.clear(); - this.shellPids.clear(); this.audioStateListeners.emit("unavailable"); this.refreshTerminalAvailability(); this.refreshFramebufferAvailability(); @@ -1017,6 +1083,21 @@ export class LiveKernelHost implements KernelHost { throw new Error("LiveKernelHost.setDefaultShell requires programPath or programBytes"); } this.shell = shell; + this.terminalSessions = undefined; + this.refreshTerminalAvailability(); + } + + /** Configure initial and post-exit programs for every logical PTY. */ + setTerminalSessionPolicy(policy: TerminalSessionPolicy): void { + validateTerminalSessionPolicy(policy); + this.terminalSessions = { + initial: cloneTerminalProgram(policy.initial), + afterExit: cloneTerminalProgram(policy.afterExit), + shortRunThresholdMs: policy.shortRunThresholdMs, + initialRestartDelayMs: policy.initialRestartDelayMs, + maximumRestartDelayMs: policy.maximumRestartDelayMs, + }; + this.shell = undefined; this.refreshTerminalAvailability(); } @@ -1067,7 +1148,8 @@ export class LiveKernelHost implements KernelHost { try { await previousCommandDone.catch(() => {}); const pty = await this.attachPty(sessionKey, { cols: 100, rows: 30 }); - const prompt = this.shell ? shellPrompt(this.shell) : null; + const terminalProgram = this.shell ?? this.terminalSessions?.initial; + const prompt = terminalProgram ? shellPrompt(terminalProgram) : null; await waitForPtyReadiness(pty, { includeHistory: true, timeoutMs: 1200, @@ -1225,7 +1307,9 @@ export class LiveKernelHost implements KernelHost { private refreshTerminalAvailability(): void { this.setSurfaceAvailability({ - terminal: this._status === "running" && Boolean(this.kernel && this.shell), + terminal: + this._status === "running" && + Boolean(this.kernel && (this.shell || this.terminalSessions)), }); } @@ -1287,10 +1371,14 @@ export class LiveKernelHost implements KernelHost { this.setSurfaceAvailability({ terminal: false, framebuffer: false, web: false, kms: false }); this.setDemoGuide(null); this.setDemoIngest(null); - await this.kernel?.destroy?.(); + const kernel = this.kernel; + this.invalidatePtySessions(kernel); + this.kernel = undefined; + await kernel?.destroy?.(); } async reboot(): Promise { + this.invalidatePtySessions(this.kernel); await this.applyBootDescriptor(this.getBootDescriptor()); } @@ -1340,49 +1428,79 @@ export class LiveKernelHost implements KernelHost { "or pass { kernel } to the constructor." ); } - if (!this.shell) { + if (!this.shell && !this.terminalSessions) { throw new Error( - "LiveKernelHost.attachPty: no default shell configured. " + - "Call setDefaultShell({ programPath or programBytes, argv, env, cwd }) before attachPty()." + "LiveKernelHost.attachPty: no terminal program configured. " + + "Call setDefaultShell(...) or setTerminalSessionPolicy(...) before attachPty()." ); } const kernel = this.kernel; - const shell = this.shell; const sessionKey = path || "/dev/pts/0"; const session = await this.withPtyAttachLock(sessionKey, () => - this.ensurePtySession(sessionKey, kernel, shell, opts), + this.ensurePtySession( + sessionKey, + kernel, + this.shell, + this.terminalSessions, + opts, + ), ); - kernel.ptyResize(session.pid, opts.rows, opts.cols); + session.cols = opts.cols; + session.rows = opts.rows; + if (session.pid > 0 && !session.closed) { + kernel.ptyResize(session.pid, opts.rows, opts.cols); + } const encoder = new TextEncoder(); let closed = false; + const dataSubscriptions = new Set<() => void>(); return { write: (bytes) => { if (closed) return; const buf = typeof bytes === "string" ? encoder.encode(bytes) : bytes; - if (session.closed) return; + if (!this.isCurrentPtySession(sessionKey, session) || session.closed) return; kernel.ptyWrite(session.pid, buf); }, onData: (cb) => { - for (const chunk of session.history) cb(chunk); - return session.dataListeners.add(cb); + if (closed) return () => {}; + const off = session.dataListeners.add(cb); + const detach = () => { + dataSubscriptions.delete(detach); + off(); + }; + dataSubscriptions.add(detach); + for (const chunk of session.history.slice()) cb(chunk); + return detach; }, resize: (cols, rows) => { if (closed) return; - if (session.closed) return; + session.cols = cols; + session.rows = rows; + if (!this.isCurrentPtySession(sessionKey, session) || session.closed) return; kernel.ptyResize(session.pid, rows, cols); }, close: () => { if (closed) return; closed = true; + for (const detach of Array.from(dataSubscriptions)) detach(); // Detach this UI handle only. The PTY-backed shell intentionally // persists across drawer open/close so users keep command history. }, }; } + removePty(path: string): void { + const sessionKey = path || "/dev/pts/0"; + const session = this.ptySessions.get(sessionKey); + if (!session) return; + this.invalidatePtySession(session, this.kernel); + this.ptySessions.delete(sessionKey); + this.ptyAttachPromises.delete(sessionKey); + this.ptyCommandQueues.delete(sessionKey); + } + private async withPtyAttachLock( sessionKey: string, ensureSession: () => Promise, @@ -1404,98 +1522,317 @@ export class LiveKernelHost implements KernelHost { private async ensurePtySession( sessionKey: string, kernel: KernelLike, - shell: NonNullable, + shell: LiveKernelHostOptions["shell"], + policy: TerminalSessionPolicy | undefined, opts: { cols: number; rows: number }, ): Promise { let session = this.ptySessions.get(sessionKey); if (session && !session.closed && !(await this.isPtySessionAlive(session.pid))) { - this.shellPids.delete(session.pid); - session.pid = 0; - session.history.length = 0; - session.closed = true; + if (session.supervised) { + this.handlePtyProcessExit( + sessionKey, + session, + kernel, + session.logicalGeneration, + session.processGeneration, + session.pid, + ); + } else { + this.shellPids.delete(session.pid); + session.pid = 0; + session.closed = true; + session.processGeneration++; + } } - if (!session || session.closed) { - let pid: number; - let exitPromise: Promise; - if (shell.programPath && kernel.spawnFromVfs) { - const spawned = await kernel.spawnFromVfs(shell.programPath, shell.argv, { - pty: true, - env: shell.env, - cwd: shell.cwd, - uid: shell.uid, - gid: shell.gid, - ptyCols: opts.cols, - ptyRows: opts.rows, - }); - pid = spawned.pid; - exitPromise = spawned.exit; - } else { - if (!shell.programBytes) { - throw new Error( - "LiveKernelHost.attachPty: the configured default shell is VFS-only, " + - "but this kernel does not support spawnFromVfs().", - ); - } - let resolveStarted!: (pid: number) => void; - let rejectStarted!: (reason?: unknown) => void; - const started = new Promise((resolve, reject) => { - resolveStarted = resolve; - rejectStarted = reject; - }); - exitPromise = kernel.spawn(shell.programBytes, shell.argv, { - pty: true, - env: shell.env, - cwd: shell.cwd, - uid: shell.uid, - gid: shell.gid, - ptyCols: opts.cols, - ptyRows: opts.rows, - onStarted: resolveStarted, - }); - void exitPromise.catch(rejectStarted); - pid = await started; + if (!session) { + session = { + path: sessionKey, + pid: 0, + logicalGeneration: 1, + processGeneration: 0, + autologinConsumed: false, + startedAt: 0, + restartDelayMs: policy?.initialRestartDelayMs ?? 0, + restartTimer: null, + removed: false, + dataListeners: new ListenerSet(), + history: [], + closed: true, + cols: opts.cols, + rows: opts.rows, + supervised: policy !== undefined, + }; + this.ptySessions.set(sessionKey, session); + } else { + session.cols = opts.cols; + session.rows = opts.rows; + } + + if (!session.closed) return session; + if (session.restartTimer !== null) return session; + + if (session.supervised) { + if (session.autologinConsumed || !policy) return session; + session.autologinConsumed = true; + try { + await this.startPtyProgram(sessionKey, session, kernel, policy.initial); + } catch (error) { + this.reportPtyStartFailure(sessionKey, session, error); + throw error; } - this.shellPids.set(pid, sessionKey); + } else if (shell) { + await this.startPtyProgram(sessionKey, session, kernel, shell); + } + return session; + } - if (session) { - session.pid = pid; - session.generation++; - session.closed = false; - session.history.length = 0; - } else { - session = { - pid, - generation: 0, - dataListeners: new ListenerSet(), - history: [], - closed: false, - }; + private async startPtyProgram( + sessionKey: string, + session: LivePtySession, + kernel: KernelLike, + program: NonNullable, + ): Promise { + const logicalGeneration = session.logicalGeneration; + const processGeneration = ++session.processGeneration; + let pid: number; + let exitPromise: Promise; + if (program.programPath && kernel.spawnFromVfs) { + const spawned = await kernel.spawnFromVfs(program.programPath, program.argv, { + pty: true, + env: program.env, + cwd: program.cwd, + uid: program.uid, + gid: program.gid, + ptyCols: session.cols, + ptyRows: session.rows, + }); + pid = spawned.pid; + exitPromise = spawned.exit; + } else { + if (!program.programBytes) { + throw new Error( + "LiveKernelHost.attachPty: the configured terminal program is VFS-only, " + + "but this kernel does not support spawnFromVfs().", + ); } - const activeSession = session; - const generation = activeSession.generation; - this.ptySessions.set(sessionKey, session); - kernel.onPtyOutput(pid, (data) => { - if (this.ptySessions.get(sessionKey) !== activeSession) return; - if (activeSession.closed || activeSession.generation !== generation) return; - const copy = data.slice(); - activeSession.history.push(copy); - if (activeSession.history.length > 2048) activeSession.history.shift(); - activeSession.dataListeners.emit(copy); + let resolveStarted!: (pid: number) => void; + let rejectStarted!: (reason?: unknown) => void; + const started = new Promise((resolve, reject) => { + resolveStarted = resolve; + rejectStarted = reject; }); - void exitPromise.finally(() => { - if (this.ptySessions.get(sessionKey) !== activeSession) return; - if (activeSession.generation !== generation) return; - activeSession.closed = true; - activeSession.pid = 0; - this.shellPids.delete(pid); + exitPromise = kernel.spawn(program.programBytes, program.argv, { + pty: true, + env: program.env, + cwd: program.cwd, + uid: program.uid, + gid: program.gid, + ptyCols: session.cols, + ptyRows: session.rows, + onStarted: resolveStarted, }); + void exitPromise.catch(rejectStarted); + pid = await started; } - if (!session) { - throw new Error("LiveKernelHost.attachPty: failed to create PTY session."); + if ( + !this.isCurrentPtySession(sessionKey, session) || + session.logicalGeneration !== logicalGeneration || + session.processGeneration !== processGeneration || + this.kernel !== kernel + ) { + void kernel.terminateProcess(pid).catch(() => {}); + throw new Error(`LiveKernelHost.attachPty: ${sessionKey} was removed during launch`); } - return session; + + session.pid = pid; + session.closed = false; + session.startedAt = nowMs(); + this.shellPids.set(pid, sessionKey); + kernel.onPtyOutput(pid, (data) => { + if (!this.isCurrentPtyProcess( + sessionKey, + session, + logicalGeneration, + processGeneration, + pid, + )) return; + this.emitPtyData(session, data); + }); + void exitPromise.then( + () => this.handlePtyProcessExit( + sessionKey, + session, + kernel, + logicalGeneration, + processGeneration, + pid, + ), + (error) => this.handlePtyProcessExit( + sessionKey, + session, + kernel, + logicalGeneration, + processGeneration, + pid, + error, + ), + ); + } + + private handlePtyProcessExit( + sessionKey: string, + session: LivePtySession, + kernel: KernelLike, + logicalGeneration: number, + processGeneration: number, + pid: number, + exitError?: unknown, + ): void { + if (!this.isCurrentPtyProcess( + sessionKey, + session, + logicalGeneration, + processGeneration, + pid, + )) return; + + session.pid = 0; + session.closed = true; + this.shellPids.delete(pid); + if (exitError !== undefined) { + this.emitPtyDiagnostic( + session, + `kandelo: terminal process failed: ${String(exitError)}`, + ); + } + if (!session.supervised || !this.terminalSessions || this.kernel !== kernel) { + return; + } + + const policy = this.terminalSessions; + const runtimeMs = Math.max(0, nowMs() - session.startedAt); + const delayMs = runtimeMs >= policy.shortRunThresholdMs + ? policy.initialRestartDelayMs + : session.restartDelayMs; + session.restartDelayMs = runtimeMs >= policy.shortRunThresholdMs + ? policy.initialRestartDelayMs + : Math.min( + policy.maximumRestartDelayMs, + Math.max(policy.initialRestartDelayMs, session.restartDelayMs * 2), + ); + if (session.restartTimer !== null) return; + + session.restartTimer = setTimeout(() => { + session.restartTimer = null; + if ( + !this.isCurrentPtySession(sessionKey, session) || + session.logicalGeneration !== logicalGeneration || + session.processGeneration !== processGeneration || + !session.closed || + this.kernel !== kernel + ) return; + void this.withPtyAttachLock(sessionKey, async () => { + if ( + !this.isCurrentPtySession(sessionKey, session) || + session.logicalGeneration !== logicalGeneration || + session.processGeneration !== processGeneration || + !session.closed || + this.kernel !== kernel + ) return session; + try { + await this.startPtyProgram( + sessionKey, + session, + kernel, + policy.afterExit, + ); + } catch (error) { + this.reportPtyStartFailure(sessionKey, session, error); + } + return session; + }); + }, delayMs); + } + + private reportPtyStartFailure( + sessionKey: string, + session: LivePtySession, + error: unknown, + ): void { + if (!this.isCurrentPtySession(sessionKey, session)) return; + session.pid = 0; + session.closed = true; + session.restartTimer = null; + this.emitPtyDiagnostic( + session, + `kandelo: unable to start terminal process: ${String(error)}`, + ); + } + + private emitPtyDiagnostic(session: LivePtySession, message: string): void { + this.emitPtyData(session, new TextEncoder().encode(`\r\n${message}\r\n`)); + } + + private emitPtyData(session: LivePtySession, data: Uint8Array): void { + const copy = data.slice(); + session.history.push(copy); + if (session.history.length > 2048) session.history.shift(); + session.dataListeners.emit(copy); + } + + private isCurrentPtySession( + sessionKey: string, + session: LivePtySession, + ): boolean { + return !session.removed && this.ptySessions.get(sessionKey) === session; + } + + private isCurrentPtyProcess( + sessionKey: string, + session: LivePtySession, + logicalGeneration: number, + processGeneration: number, + pid: number, + ): boolean { + return ( + this.isCurrentPtySession(sessionKey, session) && + session.logicalGeneration === logicalGeneration && + session.processGeneration === processGeneration && + !session.closed && + session.pid === pid + ); + } + + private invalidatePtySession( + session: LivePtySession, + kernel: KernelLike | undefined, + ): void { + session.removed = true; + session.logicalGeneration++; + session.processGeneration++; + if (session.restartTimer !== null) { + clearTimeout(session.restartTimer); + session.restartTimer = null; + } + const pid = session.pid; + session.pid = 0; + session.closed = true; + if (pid > 0) { + this.shellPids.delete(pid); + void kernel?.terminateProcess(pid).catch(() => {}); + } + } + + private invalidatePtySessions(kernel: KernelLike | undefined): void { + for (const session of this.ptySessions.values()) { + this.invalidatePtySession(session, kernel); + } + this.ptySessions.clear(); + this.ptyAttachPromises.clear(); + this.ptyCommandQueues.clear(); + this.shellPids.clear(); } private async isPtySessionAlive(pid: number): Promise { diff --git a/web-libs/kandelo-session/test/kandelo-session.test.ts b/web-libs/kandelo-session/test/kandelo-session.test.ts index 0a44b0b2a8..4c99c9b7ea 100644 --- a/web-libs/kandelo-session/test/kandelo-session.test.ts +++ b/web-libs/kandelo-session/test/kandelo-session.test.ts @@ -1,11 +1,13 @@ -import { describe, it, expect, vi } from "vitest"; +import { afterEach, describe, it, expect, vi } from "vitest"; import { LiveKernelHost, type BootDescriptor, type FileSystemLike, + type KernelLike, type LazyDownloadEvent, type MachineStatus, type ProcessEvent, + type TerminalSessionPolicy, } from "../src/kernel-host"; import { genericDemoPresentation, @@ -1171,6 +1173,368 @@ describe("LiveKernelHost: shell command queue", () => { }); }); +const LOGIN_SESSION_POLICY = { + initial: { + programPath: "/usr/bin/login", + argv: ["login", "-p", "-f", "maker"], + uid: 0, + gid: 0, + }, + afterExit: { + programPath: "/usr/bin/login", + argv: ["login", "-p"], + uid: 0, + gid: 0, + }, + shortRunThresholdMs: 2_000, + initialRestartDelayMs: 250, + maximumRestartDelayMs: 5_000, +} satisfies TerminalSessionPolicy; + +type TerminalSpawn = { + pid: number; + programPath: string; + argv: string[]; + uid?: number; + gid?: number; +}; + +function terminalSessionHarness() { + const encoder = new TextEncoder(); + const spawns: TerminalSpawn[] = []; + const activePids = new Set(); + const exitResolvers = new Map void>(); + const outputCallbacks = new Map void>(); + const terminateProcess = vi.fn(async (pid: number, status = 0) => { + exitResolvers.get(pid)?.(status); + }); + let nextPid = 1; + let nextSpawnError: Error | null = null; + let maximumActiveProcesses = 0; + + const kernel: KernelLike = { + spawn: vi.fn(), + async spawnFromVfs(programPath, argv, options) { + if (nextSpawnError) { + const error = nextSpawnError; + nextSpawnError = null; + throw error; + } + const pid = nextPid++; + spawns.push({ + pid, + programPath, + argv: argv.slice(), + uid: options?.uid, + gid: options?.gid, + }); + activePids.add(pid); + maximumActiveProcesses = Math.max( + maximumActiveProcesses, + activePids.size, + ); + let settled = false; + const exit = new Promise((resolve) => { + exitResolvers.set(pid, (status) => { + if (settled) return; + settled = true; + activePids.delete(pid); + resolve(status); + }); + }); + return { pid, exit }; + }, + onPtyOutput(pid, callback) { + outputCallbacks.set(pid, callback); + }, + ptyWrite: vi.fn(), + ptyResize: vi.fn(), + terminateProcess, + destroy: vi.fn(async () => {}), + enumProcs: vi.fn(async () => [999, ...activePids].map((pid) => ({ + pid, + ppid: 0, + uid: 0, + gid: 0, + vsizeBytes: 0, + state: "R" as const, + comm: "login", + cmdline: "login", + }))), + }; + const host = new LiveKernelHost({ kernel, status: "running" }); + host.setTerminalSessionPolicy(LOGIN_SESSION_POLICY); + + return { + activePids, + dropProcess(pid: number) { + activePids.delete(pid); + }, + exit(pid: number, status = 0) { + const resolve = exitResolvers.get(pid); + if (!resolve) throw new Error(`missing exit resolver for pid ${pid}`); + resolve(status); + }, + failNextSpawn(message = "exec failed") { + nextSpawnError = new Error(message); + }, + host, + kernel, + maximumActiveProcesses: () => maximumActiveProcesses, + output(pid: number, text: string) { + outputCallbacks.get(pid)?.(encoder.encode(text)); + }, + spawns, + terminateProcess, + }; +} + +async function settleTerminalSessionWork(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +describe("LiveKernelHost: supervised terminal sessions", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("autologins once per logical PTY and UI reattachment starts no process", async () => { + vi.useFakeTimers(); + const harness = terminalSessionHarness(); + + const first = await harness.host.attachPty("/dev/pts/0"); + let detachedOutput = ""; + first.onData((bytes) => { + detachedOutput += new TextDecoder().decode(bytes); + }); + first.close(); + harness.output(1, "after detach\n"); + await harness.host.attachPty("/dev/pts/0"); + await harness.host.attachPty("/dev/pts/1"); + + expect(harness.spawns.map(({ argv }) => argv)).toEqual([ + ["login", "-p", "-f", "maker"], + ["login", "-p", "-f", "maker"], + ]); + expect(detachedOutput).toBe(""); + expect(harness.activePids.size).toBe(2); + expect(vi.getTimerCount()).toBe(0); + }); + + it("uses ordinary login after logout and backs short runs off to the cap", async () => { + vi.useFakeTimers(); + const harness = terminalSessionHarness(); + await harness.host.attachPty("/dev/pts/0"); + const expectedDelays = [250, 500, 1_000, 2_000, 4_000, 5_000, 5_000]; + + for (const [index, delay] of expectedDelays.entries()) { + harness.exit(harness.spawns.at(-1)!.pid); + await settleTerminalSessionWork(); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(delay - 1); + expect(harness.spawns).toHaveLength(index + 1); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(1); + await settleTerminalSessionWork(); + expect(harness.spawns.at(-1)!.argv).toEqual(["login", "-p"]); + expect(vi.getTimerCount()).toBe(0); + } + + expect(harness.spawns[0].argv).toEqual([ + "login", + "-p", + "-f", + "maker", + ]); + expect(harness.spawns.slice(1).every(({ argv }) => + argv.length === 2 && argv[0] === "login" && argv[1] === "-p" + )).toBe(true); + expect(harness.maximumActiveProcesses()).toBe(1); + }); + + it("resets restart delay after a process survives for two seconds", async () => { + vi.useFakeTimers(); + const harness = terminalSessionHarness(); + await harness.host.attachPty("/dev/pts/0"); + + harness.exit(1); + await settleTerminalSessionWork(); + await vi.advanceTimersByTimeAsync(250); + await vi.advanceTimersByTimeAsync(2_000); + harness.exit(2); + await settleTerminalSessionWork(); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(249); + expect(harness.spawns).toHaveLength(2); + await vi.advanceTimersByTimeAsync(1); + expect(harness.spawns).toHaveLength(3); + + harness.exit(3); + await settleTerminalSessionWork(); + await vi.advanceTimersByTimeAsync(249); + expect(harness.spawns).toHaveLength(3); + await vi.advanceTimersByTimeAsync(1); + expect(harness.spawns).toHaveLength(4); + expect(harness.maximumActiveProcesses()).toBe(1); + }); + + it("prints a restart failure and neither retries nor repeats autologin", async () => { + vi.useFakeTimers(); + const harness = terminalSessionHarness(); + const pty = await harness.host.attachPty("/dev/pts/0"); + let output = ""; + pty.onData((bytes) => { + output += new TextDecoder().decode(bytes); + }); + + harness.failNextSpawn("missing /usr/bin/login"); + harness.exit(1); + await settleTerminalSessionWork(); + await vi.advanceTimersByTimeAsync(250); + await settleTerminalSessionWork(); + + expect(output).toContain("missing /usr/bin/login"); + expect(harness.spawns).toHaveLength(1); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(60_000); + pty.close(); + await harness.host.attachPty("/dev/pts/0"); + expect(harness.spawns).toHaveLength(1); + expect(vi.getTimerCount()).toBe(0); + }); + + it("consumes initial autologin before a failed first launch", async () => { + vi.useFakeTimers(); + const harness = terminalSessionHarness(); + harness.failNextSpawn("initial login launch failed"); + + await expect(harness.host.attachPty("/dev/pts/0")) + .rejects.toThrow("initial login launch failed"); + const pty = await harness.host.attachPty("/dev/pts/0"); + let output = ""; + pty.onData((bytes) => { + output += new TextDecoder().decode(bytes); + }); + + expect(output).toContain("initial login launch failed"); + expect(harness.spawns).toHaveLength(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("logical removal cancels restart and allocates fresh autologin state", async () => { + vi.useFakeTimers(); + const harness = terminalSessionHarness(); + await harness.host.attachPty("/dev/pts/0"); + harness.exit(1); + await settleTerminalSessionWork(); + expect(vi.getTimerCount()).toBe(1); + + harness.host.removePty("/dev/pts/0"); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(5_000); + await harness.host.attachPty("/dev/pts/0"); + + expect(harness.spawns.map(({ argv }) => argv)).toEqual([ + ["login", "-p", "-f", "maker"], + ["login", "-p", "-f", "maker"], + ]); + expect(harness.activePids).toEqual(new Set([2])); + }); + + it.each(["detach", "reboot", "halt"] as const)( + "cancels pending restart on kernel $transition", + async (transition) => { + vi.useFakeTimers(); + const harness = terminalSessionHarness(); + await harness.host.attachPty("/dev/pts/0"); + harness.exit(1); + await settleTerminalSessionWork(); + expect(vi.getTimerCount()).toBe(1); + + if (transition === "detach") harness.host.detachKernel(); + else if (transition === "reboot") await harness.host.reboot(); + else await harness.host.halt(); + + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(5_000); + expect(harness.spawns).toHaveLength(1); + }, + ); + + it.each(["remove", "detach", "reboot", "destroy"] as const)( + "terminates an active login process on $transition", + async (transition) => { + vi.useFakeTimers(); + const harness = terminalSessionHarness(); + await harness.host.attachPty("/dev/pts/0"); + expect(harness.activePids).toEqual(new Set([1])); + + if (transition === "remove") harness.host.removePty("/dev/pts/0"); + else if (transition === "detach") harness.host.detachKernel(); + else if (transition === "reboot") await harness.host.reboot(); + else await harness.host.halt(); + await settleTerminalSessionWork(); + + expect(harness.terminateProcess).toHaveBeenCalledWith(1); + expect(harness.activePids).toEqual(new Set()); + if (transition === "destroy") { + expect(harness.kernel.destroy).toHaveBeenCalledOnce(); + } + expect(vi.getTimerCount()).toBe(0); + }, + ); + + it("ignores stale exit and output callbacks after path reuse", async () => { + vi.useFakeTimers(); + const harness = terminalSessionHarness(); + const oldPty = await harness.host.attachPty("/dev/pts/0"); + let oldOutput = ""; + oldPty.onData((bytes) => { + oldOutput += new TextDecoder().decode(bytes); + }); + + harness.host.removePty("/dev/pts/0"); + const currentPty = await harness.host.attachPty("/dev/pts/0"); + let currentOutput = ""; + currentPty.onData((bytes) => { + currentOutput += new TextDecoder().decode(bytes); + }); + harness.output(1, "stale\n"); + harness.exit(1); + await settleTerminalSessionWork(); + + expect(oldOutput).toBe(""); + expect(currentOutput).toBe(""); + expect(harness.spawns).toHaveLength(2); + expect(harness.activePids).toEqual(new Set([2])); + expect(vi.getTimerCount()).toBe(0); + }); + + it("turns reattach-time liveness loss into one ordinary-login restart", async () => { + vi.useFakeTimers(); + const harness = terminalSessionHarness(); + const first = await harness.host.attachPty("/dev/pts/0"); + harness.dropProcess(1); + + first.close(); + await harness.host.attachPty("/dev/pts/0"); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(250); + await settleTerminalSessionWork(); + expect(harness.spawns.map(({ argv }) => argv)).toEqual([ + ["login", "-p", "-f", "maker"], + ["login", "-p"], + ]); + + harness.exit(1); + await settleTerminalSessionWork(); + expect(vi.getTimerCount()).toBe(0); + expect(harness.activePids).toEqual(new Set([2])); + }); +}); + describe("LiveKernelHost: descriptor", () => { it("getBootDescriptor returns a deep clone — callers can't mutate internal state", () => { const host = new LiveKernelHost({ descriptor: DUMMY_DESCRIPTOR }); From 88ae10127c4047321547c0aab1da0f42d33c928b Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Wed, 12 Aug 2026 13:27:03 -0400 Subject: [PATCH 71/82] Homebrew: Compose CI-owned ABI 43 login staging Combine the reviewed product contract and its CI staging interface into one purpose-led change. GitHub CI, not this worktree, owns Formula bottle execution and product evidence. --- .gitmodules | 2 +- .../pages/homebrew-vfs-test/main.ts | 496 ++++++-- .../kandelo/kernel-host/demo-login-loader.ts | 10 +- .../test/demo-login-loader.test.ts | 24 +- .../test/homebrew-login-lifecycle.spec.ts | 200 +++ ...8-10-abi43-login-sudo-vfork-integration.md | 85 +- .../main-shell-homebrew-runtime-support.json | 84 +- homebrew/main-shell-lazy-artifact-lock.json | 10 +- .../main-shell-materialization-policy.json | 14 +- homebrew/main-shell-migration-lock.json | 124 +- homebrew/main-shell-selection-lock.json | 6 +- homebrew/main-shell.Brewfile | 4 + .../0002-support-isolated-publisher.patch | 101 +- .../test/homebrew_guest_lifecycle_browser.ts | 49 + ...ew_guest_lifecycle_browser_fixture.test.ts | 34 + ...omebrew_guest_lifecycle_browser_fixture.ts | 77 +- host/src/homebrew-runtime-support.ts | 79 +- host/src/homebrew-vfs-builder.ts | 307 +++++ host/src/node-kernel-host.ts | 32 +- host/src/node-kernel-protocol.ts | 7 +- host/src/node-kernel-worker-entry.ts | 29 +- host/src/vfs/privileged-projection.ts | 10 +- host/test/homebrew-login-product.test.ts | 1069 +++++++++++++++++ host/test/homebrew-runtime-support.test.ts | 24 +- ...omebrew-vfs-materialization-policy.test.ts | 81 +- images/vfs/lib/demo-login.ts | 15 +- .../vfs/scripts/build-homebrew-vfs-image.ts | 78 ++ packages/registry/ncurses/build-ncurses.sh | 2 +- packages/registry/ncurses/package.toml | 8 +- packages/registry/program-packages.json | 106 +- scripts/build-homebrew-main-shell-closure.sh | 188 ++- .../check-homebrew-main-shell-brewfile.mjs | 134 ++- .../check-homebrew-publish-workflow-trust.rb | 14 +- ...e-homebrew-guest-lifecycle-fixture.test.ts | 42 + ...create-homebrew-guest-lifecycle-fixture.ts | 49 +- .../finalize-homebrew-main-shell-release.py | 16 + scripts/homebrew-bottle-build.sh | 247 +++- scripts/homebrew-bottle-runtime-evidence.py | 205 +++- scripts/homebrew-formula-runtime-closure.rb | 10 +- .../homebrew-generate-sidecars-from-env.sh | 197 ++- ...homebrew-main-shell-image-contract.test.ts | 21 + scripts/homebrew-main-shell-node-smoke.ts | 374 +++++- scripts/homebrew-merge-bottle-json.sh | 3 +- scripts/homebrew-oci-layout.py | 2 +- scripts/homebrew-patched-launcher.sh | 465 ++++++- scripts/homebrew-publish-sidecars.sh | 16 + scripts/homebrew-validate-build-handoff.sh | 3 +- .../homebrew-validate-host-dependency-plan.sh | 10 +- scripts/homebrew-verify-poured-bottle.sh | 65 +- scripts/measure-homebrew-vfork-rss.ts | 169 +++ scripts/run-login-stack-local.sh | 976 +++++++++++++++ ...st-finalize-homebrew-main-shell-release.py | 156 ++- .../test-homebrew-bottle-runtime-evidence.sh | 150 ++- .../test-homebrew-formula-runtime-closure.sh | 28 +- scripts/test-homebrew-main-shell-closure.sh | 69 +- scripts/test-homebrew-oci-layout.sh | 3 +- .../test-homebrew-patched-launcher-batch.sh | 275 +++++ scripts/test-homebrew-patched-launcher.sh | 112 +- scripts/test-homebrew-publish-workflow.sh | 24 +- .../test-homebrew-publisher-overlay-patch.sh | 160 ++- .../test-homebrew-sibling-bottle-policy.sh | 2 +- scripts/test-homebrew-tap-native-sidecars.sh | 90 +- ...-homebrew-validate-host-dependency-plan.sh | 12 +- scripts/test-seal-homebrew-formula-checker.sh | 25 + scripts/test-wasm-artifact-guards.sh | 76 ++ scripts/wasm-artifact-guards.sh | 82 ++ .../rootfs-verified-source-contract.test.ts | 14 + tools/xtask/src/build_deps.rs | 47 +- 68 files changed, 7248 insertions(+), 450 deletions(-) create mode 100644 apps/browser-demos/test/homebrew-login-lifecycle.spec.ts create mode 100644 host/test/homebrew-login-product.test.ts create mode 100755 scripts/measure-homebrew-vfork-rss.ts create mode 100755 scripts/run-login-stack-local.sh create mode 100755 scripts/test-homebrew-patched-launcher-batch.sh diff --git a/.gitmodules b/.gitmodules index 31e335423e..cd5f20460f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -3,7 +3,7 @@ url = https://github.com/ifduyue/musl.git [submodule "libc-test"] path = tests/libc/libc-test - url = git@github.com:PocketCluster/libc-test.git + url = https://github.com/PocketCluster/libc-test.git [submodule "os-test"] path = tests/sortix/os-test url = https://gitlab.com/sortix/os-test.git diff --git a/apps/browser-demos/pages/homebrew-vfs-test/main.ts b/apps/browser-demos/pages/homebrew-vfs-test/main.ts index 2338f6da46..dc0ca3fc99 100644 --- a/apps/browser-demos/pages/homebrew-vfs-test/main.ts +++ b/apps/browser-demos/pages/homebrew-vfs-test/main.ts @@ -17,29 +17,34 @@ import { composeBootDescriptorVfs, composeBootDescriptorVfsWithReviewedProduct, } from "../../lib/init/homebrew-package-layers"; -import { createReviewedPrivilegedProgramPolicy } from - "@host/vfs/privileged-projection"; -import * as privilegedProjectionModule from - "@host/vfs/privileged-projection"; +import { createReviewedPrivilegedProgramPolicy } from "@host/vfs/privileged-projection"; import { - homebrewClosedAcceptanceAssetRoot, -} from "../../lib/homebrew-closed-acceptance"; -import type { - BootDescriptor, -} from "../../../../web-libs/kandelo-session/src/kernel-host"; + assertLocalTestHomebrewTapBundle, + projectLocalTestHomebrewTapBundleBinding, +} from "@host/homebrew-vfs-builder"; +import * as privilegedProjectionModule from "@host/vfs/privileged-projection"; +import { homebrewClosedAcceptanceAssetRoot } from "../../lib/homebrew-closed-acceptance"; +import type { BootDescriptor } from "../../../../web-libs/kandelo-session/src/kernel-host"; import { createBrowserLifecycleMachine, runHomebrewFlatVfsShippingProofInBrowser, + runHomebrewGuestCoreShippingProofInBrowser, runHomebrewGuestLifecycleInBrowser, type HomebrewGuestLifecycleBrowserFixture, type HomebrewGuestLifecycleBrowserResult, } from "../../../../homebrew/test/homebrew_guest_lifecycle_browser"; -import type { - HomebrewFlatVfsShippingProofResult, -} from "../../../../homebrew/test/homebrew_flat_vfs_shipping_proof"; +import type { HomebrewFlatVfsShippingProofResult } from "../../../../homebrew/test/homebrew_flat_vfs_shipping_proof"; import { - runHomebrewSystemCommandSpawnProof, -} from "../../../../homebrew/test/homebrew_system_command_spawn_proof"; + createClosedFixtureSourceUrl, + loadHomebrewGuestLifecycleBrowserFixture, + projectHomebrewGuestLifecycleBrowserFixture, +} from "../../../../homebrew/test/homebrew_guest_lifecycle_browser_fixture"; +import { deriveHomebrewGuestLifecycleRuntimeInputs } from "../../../../homebrew/test/homebrew_guest_lifecycle_runtime_inputs"; +import { LiveKernelHost } from "../../../../web-libs/kandelo-session/src/kernel-host"; +import { DEMO_TERMINAL_SESSION_POLICY } from "../kandelo/kernel-host/demo-terminal-sessions"; +import { initializeDemoLoginKernel } from "../kandelo/kernel-host/demo-login-loader"; +import { publishPrivilegedProgramProduct } from "@host/vfs/privileged-projection"; +import { runHomebrewSystemCommandSpawnProof } from "../../../../homebrew/test/homebrew_system_command_spawn_proof"; import kernelWasmUrl from "@kernel-wasm?url"; import { validateHomebrewVfsAcceptanceRequest, @@ -60,6 +65,18 @@ const closedLifecycleAssetRoot = homebrewClosedAcceptanceAssetRoot( import.meta.env.VITE_KANDELO_HOMEBREW_CLOSED_ACCEPTANCE_ROOT as string | undefined, ); +let loginProductPhaseAcknowledgement: (() => void) | undefined; + +async function announceLoginProductPhase(phase: string): Promise { + if (loginProductPhaseAcknowledgement !== undefined) { + throw new Error("login product phase acknowledgement is already pending"); + } + window.__homebrewLoginProductPhase = phase; + await new Promise((resolve) => { + loginProductPhaseAcknowledgement = resolve; + }); + loginProductPhaseAcknowledgement = undefined; +} interface HomebrewVfsAcceptanceResult { exitCode: number; @@ -69,6 +86,298 @@ interface HomebrewVfsAcceptanceResult { kernelSha256: string; } +async function runHomebrewLoginProductLifecycle( + fixtureValue: unknown, + kernelBytes: ArrayBuffer, +): Promise<{ markers: string[] }> { + const fixture = projectHomebrewGuestLifecycleBrowserFixture(fixtureValue); + const loaded = await loadHomebrewGuestLifecycleBrowserFixture(fixture, { + sourceUrl: (canonicalUrl) => + createClosedFixtureSourceUrl(closedLifecycleAssetRoot, canonicalUrl), + }); + const runtime = await deriveHomebrewGuestLifecycleRuntimeInputs({ + imageBytes: loaded.imageBytes.slice(), + bootstrapSpecBytes: loaded.bootstrapSpecBytes, + bootstrapArchiveBytes: loaded.bootstrapArchiveBytes, + bootstrapArchiveSha256: fixture.bootstrap.archive.sha256, + bootstrapEnvironmentBytes: loaded.bootstrapEnvironmentBytes, + coreRevision: fixture.revisions.coreRevision, + transportMode: fixture.transportMode, + expectedEmbeddedBottlePlanBytes: loaded.bottleMirrorPlanBytes, + lazyUrlBase: "https://closed.kandelo.invalid/homebrew-login-product/", + closedBottleAssets: loaded.closedBottleAssets!, + }); + if (loaded.compositionReportBytes === undefined) { + throw new Error("login product fixture omits its composition report"); + } + let compositionReport: unknown; + try { + compositionReport = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode( + loaded.compositionReportBytes, + ), + ); + } catch (error) { + throw new Error("login product composition report is not UTF-8 JSON", { + cause: error, + }); + } + const fs = MemoryFileSystem.fromImage(loaded.imageBytes.slice()); + await fs.verifyImportedLazyAtomicGroupSeals(); + const localTest = ( + compositionReport as { + local_test?: { + source_tap_commit?: unknown; + prepared_tap_commit?: unknown; + staged_tap?: unknown; + }; + } + ).local_test; + const stagedTap = projectLocalTestHomebrewTapBundleBinding( + localTest?.staged_tap, + ); + if ( + localTest?.source_tap_commit !== fixture.revisions.coreRevision || + localTest.prepared_tap_commit !== stagedTap.prepared_commit || + stagedTap.source_commit !== fixture.revisions.coreRevision + ) { + throw new Error( + "login product staged tap differs from its source/prepared report binding", + ); + } + assertLocalTestHomebrewTapBundle(fs, stagedTap); + const projectionsValue = ( + compositionReport as { + privileged_programs?: { projections?: Array> }; + } + ).privileged_programs?.projections; + if (!Array.isArray(projectionsValue)) { + throw new Error("login product composition report omits projections"); + } + const projections = projectionsValue.map((entry) => ({ + schema: entry.schema, + formula: entry.formula, + bottleSha256: entry.bottle_sha256, + sourcePath: entry.source_path, + destinationPath: entry.destination_path, + uid: entry.uid, + gid: entry.gid, + mode: entry.mode, + mountPoint: entry.mount_point, + artifactValidationSha256: entry.artifact_validation_sha256, + })); + if ( + !projections.some((entry) => entry.destinationPath === "/usr/bin/login") + ) { + throw new Error("login product composition omits /usr/bin/login"); + } + const privilegedProduct = await publishPrivilegedProgramProduct({ + policy: createReviewedPrivilegedProgramPolicy(projections), + sources: projections.map((projection) => { + const sourcePath = String(projection.sourcePath); + const guestPath = `/opt/kandelo/homebrew/Cellar/${sourcePath}`; + return { + formula: String(projection.formula), + bottleSha256: String(projection.bottleSha256), + fs, + inventory: { + entries: [ + { + sourcePath, + type: "file" as const, + size: fs.stat(guestPath).size, + }, + ], + }, + guestPathForSource: (path: string) => + `/opt/kandelo/homebrew/Cellar/${path}`, + }; + }), + writableBottleFileSystems: [fs], + }); + if (loaded.privilegedProductBytes === undefined) { + throw new Error("login product fixture omits its serialized product"); + } + const serializedIdentity = ( + compositionReport as { + privileged_product?: { + image?: unknown; + sha256?: unknown; + bytes?: unknown; + }; + } + ).privileged_product; + const generatedSha256 = await sha256(privilegedProduct.imageBytes); + const loadedSha256 = await sha256(loaded.privilegedProductBytes); + if ( + serializedIdentity?.image !== "main-shell.vfs.privileged.vfs" || + serializedIdentity.sha256 !== loadedSha256 || + serializedIdentity.bytes !== loaded.privilegedProductBytes.byteLength || + generatedSha256 !== loadedSha256 || + privilegedProduct.imageBytes.byteLength !== + loaded.privilegedProductBytes.byteLength + ) { + throw new Error( + "published privileged product differs from the exact serialized artifact", + ); + } + + const diagnostics: string[] = []; + const kernel = new BrowserKernel({ + maxWorkers: 8, + env: ["TERM=xterm-kandelo", "PATH=/opt/kandelo/homebrew/bin:/usr/bin:/bin"], + corsProxyUrl, + onHostDiagnostic: (diagnostic) => diagnostics.push(diagnostic.message), + }); + const markers: string[] = []; + try { + await announceLoginProductPhase("before-boot"); + const enabled = await initializeDemoLoginKernel({ + kernel, + fs, + kernelWasm: kernelBytes, + vfsImage: runtime.imageBytes, + closedLazyAssets: runtime.lazyAssets, + lazyUrlBase: runtime.lazyUrlBase, + privilegedProduct, + }); + if (!enabled) throw new Error("reviewed login product was not admitted"); + const host = new LiveKernelHost({ kernel, status: "running" }); + host.setTerminalSessionPolicy(DEMO_TERMINAL_SESSION_POLICY); + const pty = await host.attachPty("/dev/pts/0", { cols: 100, rows: 30 }); + let output = ""; + const off = pty.onData((bytes) => { + output += new TextDecoder().decode(bytes); + }); + const waitFrom = async ( + needle: string, + start: number, + label: string, + ): Promise => { + const deadline = performance.now() + fixture.timeoutMs; + while (!output.slice(start).includes(needle)) { + if (performance.now() >= deadline) { + throw new Error( + `timed out waiting for ${label}; output=${JSON.stringify(output.slice(-4096))}`, + ); + } + await new Promise((resolveDelay) => setTimeout(resolveDelay, 20)); + } + }; + const command = async (text: string, marker: string): Promise => { + const start = output.length; + pty.write(`(${text}) && printf '${marker}\\n'\n`); + await waitFrom(marker, start, marker); + markers.push(marker); + }; + await waitFrom( + "Every new terminal logs in automatically.", + 0, + "automatic maker login", + ); + markers.push("automatic-maker-login-ok"); + await command("id | grep 'uid=1000'", "maker-id-ok"); + + let interactionStart = output.length; + pty.write("/usr/bin/sudo -S -k id\n"); + await waitFrom( + "Password:", + interactionStart, + "failed sudo password prompt", + ); + interactionStart = output.length; + pty.write("definitely-wrong\n"); + await waitFrom( + "Sorry, try again", + interactionStart, + "failed sudo password rejection", + ); + markers.push("failed-sudo-password-ok"); + interactionStart = output.length; + pty.write("kandelo\n"); + await waitFrom("uid=0", interactionStart, "sudo root identity"); + markers.push("sudo-id-ok"); + await command( + "printf 'kandelo\\n' | /usr/bin/sudo -S -l >/dev/null", + "sudo-list-ok", + ); + await command( + "cp /usr/bin/sudo-lite /tmp/sudo-lite && chmod 4755 /tmp/sudo-lite && ! /tmp/sudo-lite id >/dev/null 2>&1", + "nosuid-copy-rejected", + ); + + await announceLoginProductPhase("before-ruby"); + for (let repetition = 1; repetition <= 3; repetition += 1) { + const live = `ruby-child-${repetition}-live`; + const reaped = `ruby-child-${repetition}-reaped`; + const repetitionStart = output.length; + pty.write( + `ruby --disable-gems -e 'require "rbconfig"; p=Process.spawn(RbConfig.ruby,"--disable-gems","-e","sleep 2"); puts "${live}"; STDOUT.flush; Process.wait(p); puts "${reaped}"'\n`, + ); + await waitFrom(live, repetitionStart, live); + if (repetition === 1) await announceLoginProductPhase("peak"); + await waitFrom(reaped, repetitionStart, reaped); + if (repetition === 1) { + await announceLoginProductPhase("after-child-reaping"); + } + markers.push(reaped); + } + await announceLoginProductPhase("after-three-repetitions"); + await command( + "irb --version >/dev/null && erb --version >/dev/null && gem --version >/dev/null && bundle --version >/dev/null && rake --version >/dev/null", + "ruby-stock-tools-ok", + ); + + interactionStart = output.length; + pty.write("exit\n"); + await waitFrom("login: ", interactionStart, "ordinary login prompt"); + interactionStart = output.length; + pty.write("maker\n"); + await waitFrom("Password: ", interactionStart, "ordinary password prompt"); + interactionStart = output.length; + pty.write("definitely-wrong\n"); + await waitFrom( + "Login incorrect", + interactionStart, + "ordinary failed password", + ); + await waitFrom("login: ", interactionStart, "ordinary retry login prompt"); + interactionStart = output.length; + pty.write("maker\n"); + await waitFrom( + "Password: ", + interactionStart, + "ordinary second password prompt", + ); + interactionStart = output.length; + pty.write("kandelo\n"); + await new Promise((resolveDelay) => setTimeout(resolveDelay, 250)); + pty.write("id; printf 'ordinary-login-ok\\n'\n"); + await waitFrom( + "ordinary-login-ok", + interactionStart, + "ordinary maker identity", + ); + markers.push("ordinary-login-ok"); + await command( + `export HOMEBREW_NO_ANALYTICS=1 HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_FROM_API=1 HOMEBREW_AUTOMATICALLY_SET_NO_INSTALL_FROM_API=1 HOMEBREW_REQUIRE_TAP_TRUST=1 GIT_TERMINAL_PROMPT=0; /usr/bin/brew tap kandelo-dev/tap-core file:///opt/kandelo/homebrew/var/kandelo/local-test/homebrew-tap-core.bundle && tap=$(/usr/bin/brew --repository kandelo-dev/tap-core) && test "$(/opt/kandelo/homebrew/bin/git -C "$tap" rev-parse HEAD)" = ${stagedTap.prepared_commit} && /opt/kandelo/homebrew/bin/git -C "$tap" cat-file -e ${stagedTap.source_commit}^\\{commit\\} && /opt/kandelo/homebrew/bin/git -C "$tap" merge-base --is-ancestor ${stagedTap.source_commit} ${stagedTap.prepared_commit} && /usr/bin/brew uninstall --ignore-dependencies kandelo-dev/tap-core/bzip2 && /usr/bin/brew trust --formula kandelo-dev/tap-core/bzip2 && /usr/bin/brew install --no-ask --force-bottle kandelo-dev/tap-core/bzip2 && prefix=$(/usr/bin/brew --prefix kandelo-dev/tap-core/bzip2) && printf 'login-product-bzip2\\n' > /tmp/login-product-bzip2 && "$prefix/bin/bzip2" -f /tmp/login-product-bzip2 && "$prefix/bin/bzip2" -d -f /tmp/login-product-bzip2.bz2 && grep -Fx login-product-bzip2 /tmp/login-product-bzip2 >/dev/null`, + "brew-tap-install-execute-ok", + ); + off(); + pty.close(); + host.detachKernel(); + if (diagnostics.length !== 0) { + throw new Error( + `login product diagnostics: ${JSON.stringify(diagnostics)}`, + ); + } + } finally { + await kernel.destroy().catch(() => {}); + } + + return { markers }; +} + interface HomebrewSystemCommandProofRequest { vfsUrl: string; lazyUrlBase: string; @@ -242,6 +551,14 @@ declare global { __runHomebrewGuestLifecycleAcceptance: ( fixture: HomebrewGuestLifecycleBrowserFixture, ) => Promise; + __runHomebrewGuestCoreShippingProof: ( + fixture: HomebrewGuestLifecycleBrowserFixture, + ) => Promise<{ coreRevision: string; completedUrls: string[] }>; + __homebrewLoginProductPhase: string; + __ackHomebrewLoginProductPhase: () => void; + __runHomebrewLoginProductLifecycle: ( + fixture: HomebrewGuestLifecycleBrowserFixture, + ) => Promise<{ markers: string[] }>; __runHomebrewSystemCommandProof: ( request: HomebrewSystemCommandProofRequest, ) => Promise; @@ -283,7 +600,11 @@ async function extractExecutable( } } -function appendOutput(current: string, bytes: Uint8Array, label: string): string { +function appendOutput( + current: string, + bytes: Uint8Array, + label: string, +): string { const next = current + new TextDecoder().decode(bytes); if (new TextEncoder().encode(next).byteLength > MAX_OUTPUT_BYTES) { throw new Error(`${label} exceeded ${MAX_OUTPUT_BYTES} bytes`); @@ -292,7 +613,8 @@ function appendOutput(current: string, bytes: Uint8Array, label: string): string } async function sha256(bytes: ArrayBuffer | Uint8Array): Promise { - const source: BufferSource = bytes instanceof Uint8Array + const source: BufferSource = + bytes instanceof Uint8Array ? new Uint8Array( bytes.buffer as ArrayBuffer, bytes.byteOffset, @@ -300,12 +622,15 @@ async function sha256(bytes: ArrayBuffer | Uint8Array): Promise { ) : bytes; const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", source)); - return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""); + return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join( + "", + ); } async function fetchBytes(url: string, label: string): Promise { const response = await fetch(url); - if (!response.ok) throw new Error(`${label} fetch failed with HTTP ${response.status}`); + if (!response.ok) + throw new Error(`${label} fetch failed with HTTP ${response.status}`); return response.arrayBuffer(); } @@ -403,6 +728,25 @@ async function init(): Promise { : { closedAssetRootUrl: closedLifecycleAssetRoot }), afterMachineDestroy: settleWebKitReclaim, }); + window.__runHomebrewGuestCoreShippingProof = (fixture) => + runHomebrewGuestCoreShippingProofInBrowser({ + fixture, + kernelWasm: kernelBytes, + corsProxyUrl, + ...(closedLifecycleAssetRoot === undefined + ? {} + : { closedAssetRootUrl: closedLifecycleAssetRoot }), + afterMachineDestroy: settleWebKitReclaim, + }); + window.__homebrewLoginProductPhase = "idle"; + window.__ackHomebrewLoginProductPhase = () => { + if (loginProductPhaseAcknowledgement === undefined) { + throw new Error("no login product phase acknowledgement is pending"); + } + loginProductPhaseAcknowledgement(); + }; + window.__runHomebrewLoginProductLifecycle = (fixture) => + runHomebrewLoginProductLifecycle(fixture, kernelBytes); window.__runHomebrewFlatVfsShippingProof = async (request) => { const validated = validateHomebrewFlatVfsShippingProofRequest(request, { @@ -444,16 +788,15 @@ async function init(): Promise { throw new Error("Homebrew SystemCommand proof limits are invalid"); } const vfsUrl = sameOriginTestUrl(request.vfsUrl, "VFS image"); - const lazyUrlBase = sameOriginTestUrl( - request.lazyUrlBase, - "lazy URL base", - ); + const lazyUrlBase = sameOriginTestUrl(request.lazyUrlBase, "lazy URL base"); const bootstrapArchiveUrl = sameOriginTestUrl( request.bootstrapArchiveUrl, "bootstrap archive", ); if (!lazyUrlBase.pathname.endsWith("/")) { - throw new Error("Homebrew SystemCommand lazy URL base is not a directory"); + throw new Error( + "Homebrew SystemCommand lazy URL base is not a directory", + ); } const imageBytes = new Uint8Array( await fetchBytes(vfsUrl.href, "Homebrew SystemCommand VFS image"), @@ -511,8 +854,12 @@ async function init(): Promise { let stderr = ""; const kernel = new BrowserKernel({ kernelOwnedFs: true, - onStdout: (bytes) => { stdout = appendOutput(stdout, bytes, "stdout"); }, - onStderr: (bytes) => { stderr = appendOutput(stderr, bytes, "stderr"); }, + onStdout: (bytes) => { + stdout = appendOutput(stdout, bytes, "stdout"); + }, + onStderr: (bytes) => { + stderr = appendOutput(stderr, bytes, "stderr"); + }, }); let timer: ReturnType | undefined; try { @@ -554,7 +901,12 @@ async function init(): Promise { kernel.spawn(executable.buffer, request.argv, spawnOptions), new Promise((_resolve, reject) => { timer = setTimeout( - () => reject(new Error(`browser acceptance timed out after ${request.timeoutMs}ms`)), + () => + reject( + new Error( + `browser acceptance timed out after ${request.timeoutMs}ms`, + ), + ), request.timeoutMs, ); }), @@ -582,8 +934,12 @@ async function init(): Promise { const kernel = new BrowserKernel({ kernelOwnedFs: true, ...(request.corsProxyExternalLazyUrls ? { corsProxyUrl } : {}), - onStdout: (bytes) => { stdout = appendOutput(stdout, bytes, "stdout"); }, - onStderr: (bytes) => { stderr = appendOutput(stderr, bytes, "stderr"); }, + onStdout: (bytes) => { + stdout = appendOutput(stdout, bytes, "stdout"); + }, + onStderr: (bytes) => { + stderr = appendOutput(stderr, bytes, "stderr"); + }, }); let timer: ReturnType | undefined; try { @@ -602,7 +958,8 @@ async function init(): Promise { if (read === null && request.retryReadAfterFailure) { read = await kernel.readFileFromVfs(request.readPath); } - if (read === null) throw new Error(`missing VFS file ${request.readPath}`); + if (read === null) + throw new Error(`missing VFS file ${request.readPath}`); let exitCode: number | undefined; if (request.executable) { @@ -618,9 +975,12 @@ async function init(): Promise { spawned.exit, new Promise((_resolve, reject) => { timer = setTimeout( - () => reject(new Error( + () => + reject( + new Error( `lazy VFS acceptance timed out after ${request.timeoutMs}ms`, - )), + ), + ), request.timeoutMs, ); }), @@ -688,20 +1048,15 @@ async function init(): Promise { resolveLivePid = resolve; rejectLivePid = reject; }); - const liveExit = firstKernel.spawn( - liveProcessBytes, - ["block-forever"], - { + const liveExit = firstKernel + .spawn(liveProcessBytes, ["block-forever"], { onStarted: resolveLivePid, - }, - ).catch((error) => { + }) + .catch((error) => { rejectLivePid(error); throw error; }); - const pid = await withTimeout( - livePid, - "live process start", - ); + const pid = await withTimeout(livePid, "live process start"); liveProcessExportError = await withTimeout( rejectionMessage( firstKernel.exportRootfsImage(), @@ -716,10 +1071,7 @@ async function init(): Promise { ); teardownProcessExitCode = await withTimeout( - firstKernel.spawn( - teardownProcessBytes, - ["thread-exit-group"], - ), + firstKernel.spawn(teardownProcessBytes, ["thread-exit-group"]), "threaded process exit", ); // WHY: thread-exit-group exits from its child thread. The public exit @@ -740,10 +1092,7 @@ async function init(): Promise { resolveLazyStart = resolve; }); const unsubscribeLazy = firstKernel.subscribeLazyDownloads((event) => { - if ( - event.url === request.lazyReadUrl && - event.status === "started" - ) { + if (event.url === request.lazyReadUrl && event.status === "started") { resolveLazyStart(); } }); @@ -826,9 +1175,8 @@ async function init(): Promise { ); } const firstExportBuffer = firstExport.buffer; - let parsed: MemoryFileSystem | null = await restoreVerifiedVfsImage( - firstExport, - ); + let parsed: MemoryFileSystem | null = + await restoreVerifiedVfsImage(firstExport); const lazyEntries = parsed.exportLazyEntries().map((entry) => ({ path: entry.path, url: entry.url, @@ -838,9 +1186,7 @@ async function init(): Promise { readVfsFile(parsed, request.lazyReadPath), ); if (exportedLazyRead !== request.lazyReadText) { - throw new Error( - `exported rootfs changed ${request.lazyReadPath}`, - ); + throw new Error(`exported rootfs changed ${request.lazyReadPath}`); } const lateWritePresentInExport = vfsPathExists( parsed, @@ -945,7 +1291,7 @@ async function init(): Promise { descriptor: request.descriptor, baseImageBytes, kernelAbi: ABI_VERSION, - onStagedFileSystemDiscarded: (buffer) => { + onStagedFileSystemDiscarded: (buffer: SharedArrayBuffer) => { packageLayerDiscardedBufferCount += 1; trackTransientImageBuffer(buffer); }, @@ -956,7 +1302,8 @@ async function init(): Promise { ) { throw new Error("unknown reviewed package-layer product profile"); } - const composed = request.reviewedProductProfile === undefined + const composed = + request.reviewedProductProfile === undefined ? await composeBootDescriptorVfs(compositionOptions) : await composeBootDescriptorVfsWithReviewedProduct( compositionOptions, @@ -993,10 +1340,9 @@ async function init(): Promise { readonly = error instanceof Error && error.message.includes("EROFS"); } const firstProjection = composed.privilegedProduct.projections[0]!; - const ordinaryBottlePath = - `/opt/kandelo/homebrew/Cellar/${firstProjection.sourcePath}`; - const ordinaryBottleMode = composed.fs.lstat(ordinaryBottlePath).mode & - 0o7777; + const ordinaryBottlePath = `/opt/kandelo/homebrew/Cellar/${firstProjection.sourcePath}`; + const ordinaryBottleMode = + composed.fs.lstat(ordinaryBottlePath).mode & 0o7777; let ordinaryBottleWritable = false; try { composed.fs.chmod(ordinaryBottlePath, ordinaryBottleMode ^ 0o200); @@ -1018,16 +1364,17 @@ async function init(): Promise { }; }), uniqueIdentityCount: new Set( - composed.privilegedProduct.evidence.map((entry) => + composed.privilegedProduct.evidence.map( + (entry) => `${entry.destinationIdentity.dev}:` + `${entry.destinationIdentity.ino}:` + - `${entry.destinationIdentity.generation}` + `${entry.destinationIdentity.generation}`, ), ).size, readonly, trusted: - resolveMountSetIdCapability(composed.privilegedProduct.mount).kind === - "trusted-root-product", + resolveMountSetIdCapability(composed.privilegedProduct.mount) + .kind === "trusted-root-product", ordinaryBottleWritable, ordinaryMountNosuid: resolveMountSetIdCapability({ backend: composed.fs }).kind === @@ -1064,15 +1411,18 @@ async function init(): Promise { window.__readPackageLayerAcceptance = async (path) => { const machine = packageLayerMachine; - if (!machine) throw new Error("package-layer acceptance machine is not booted"); + if (!machine) + throw new Error("package-layer acceptance machine is not booted"); const bytes = await machine.kernel.readFileFromVfs(path); - if (bytes === null) throw new Error(`missing package-layer VFS file ${path}`); + if (bytes === null) + throw new Error(`missing package-layer VFS file ${path}`); return new TextDecoder().decode(bytes); }; window.__execPackageLayerAcceptance = async (request) => { const machine = packageLayerMachine; - if (!machine) throw new Error("package-layer acceptance machine is not booted"); + if (!machine) + throw new Error("package-layer acceptance machine is not booted"); if (!Array.isArray(request.argv) || request.argv.length === 0) { throw new Error("argv must contain at least one entry"); } @@ -1092,9 +1442,12 @@ async function init(): Promise { spawned.exit, new Promise((_resolve, reject) => { timer = setTimeout( - () => reject(new Error( + () => + reject( + new Error( `package-layer exec timed out after ${request.timeoutMs}ms`, - )), + ), + ), request.timeoutMs, ); }), @@ -1114,6 +1467,7 @@ async function init(): Promise { } init().catch((error) => { - document.getElementById("status")!.textContent = `Error: ${error instanceof Error ? error.message : String(error)}`; + document.getElementById("status")!.textContent = + `Error: ${error instanceof Error ? error.message : String(error)}`; console.error("Homebrew VFS test runner failed:", error); }); diff --git a/apps/browser-demos/pages/kandelo/kernel-host/demo-login-loader.ts b/apps/browser-demos/pages/kandelo/kernel-host/demo-login-loader.ts index 16fcbaa065..c24940867d 100644 --- a/apps/browser-demos/pages/kandelo/kernel-host/demo-login-loader.ts +++ b/apps/browser-demos/pages/kandelo/kernel-host/demo-login-loader.ts @@ -21,6 +21,7 @@ export interface InitializeDemoLoginKernelOptions { kernelWasm?: ArrayBuffer; vfsImage: Uint8Array | "default"; closedLazyAssets?: readonly ClosedLazyAsset[]; + lazyUrlBase?: string; privilegedProduct?: PublishedPrivilegedProgramProduct; } @@ -34,10 +35,12 @@ export async function initializeDemoLoginKernel( ): Promise { const { privilegedProduct } = options; const loginSessionsEnabled = privilegedProduct !== undefined && - hasConfiguredDemoLogin(options.fs) && + // The ordinary image owns accounts and policy. The independently + // published trusted product owns the final namespace's login executable. + hasConfiguredDemoLogin(options.fs, privilegedProduct.mount.backend) && await publishedPrivilegedProgramMatchesFile( privilegedProduct, - options.fs, + privilegedProduct.mount.backend, DEMO_LOGIN_PROGRAM_PATH, ); const common = { @@ -46,6 +49,9 @@ export async function initializeDemoLoginKernel( ...(options.closedLazyAssets === undefined ? {} : { closedLazyAssets: options.closedLazyAssets }), + ...(options.lazyUrlBase === undefined + ? {} + : { lazyUrlBase: options.lazyUrlBase }), }; if (loginSessionsEnabled) { await options.kernel.initFromPublishedPrivilegedProgramProduct({ diff --git a/apps/browser-demos/test/demo-login-loader.test.ts b/apps/browser-demos/test/demo-login-loader.test.ts index 423debf256..57e07cf9a3 100644 --- a/apps/browser-demos/test/demo-login-loader.test.ts +++ b/apps/browser-demos/test/demo-login-loader.test.ts @@ -162,11 +162,33 @@ describe("production demo login loader", () => { }, ); + it("admits the final composed namespace when login exists only in the product", async () => { + const loginBytes = new Uint8Array([0, 97, 115, 109, 1]); + const fs = canonicalFs(loginBytes); + fs.unlink("/usr/bin/login"); + const { publish } = await productFixture(loginBytes); + const privilegedProduct = await publish(); + const kernel = fakeKernel(); + + await expect( + initializeDemoLoginKernel({ + kernel, + fs, + kernelWasm: new ArrayBuffer(0), + vfsImage: await fs.saveImage(), + privilegedProduct, + }), + ).resolves.toBe(true); + expect( + kernel.initFromPublishedPrivilegedProgramProduct, + ).toHaveBeenCalledOnce(); + expect(kernel.initFromImage).not.toHaveBeenCalled(); + }); + it.each([ ["/etc/passwd", "maker:x:1000:1000:maker:/home/user:/bin/sh\n", 0o644], ["/etc/shadow", "maker:$6$wrong$hash:0:0:99999:7:::\n", 0o640], ["/etc/motd.autologin", "image-selected credentials\n", 0o644], - ["/usr/bin/login", "image-selected program bytes", 0o4755], ] as const)("rejects a configured-asset overwrite of %s", async ( path, text, diff --git a/apps/browser-demos/test/homebrew-login-lifecycle.spec.ts b/apps/browser-demos/test/homebrew-login-lifecycle.spec.ts new file mode 100644 index 0000000000..f5a3c46162 --- /dev/null +++ b/apps/browser-demos/test/homebrew-login-lifecycle.spec.ts @@ -0,0 +1,200 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, lstatSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { expect, test } from "@playwright/test"; + +import { appendProcessTreeRssSample } from "../../../scripts/measure-homebrew-vfork-rss"; +import { + projectHomebrewGuestLifecycleBrowserFixture, + type HomebrewGuestLifecycleBrowserFixture, +} from "../../../homebrew/test/homebrew_guest_lifecycle_browser_fixture"; + +declare global { + interface Window { + __homebrewVfsTestReady: boolean; + __runHomebrewGuestCoreShippingProof: ( + fixture: HomebrewGuestLifecycleBrowserFixture, + ) => Promise<{ coreRevision: string; completedUrls: string[] }>; + __homebrewLoginProductPhase: string; + __ackHomebrewLoginProductPhase: () => void; + __runHomebrewLoginProductLifecycle: ( + fixture: HomebrewGuestLifecycleBrowserFixture, + ) => Promise<{ markers: string[] }>; + } +} + +const FIXTURE_ENV = "KANDELO_HOMEBREW_GUEST_BROWSER_LIFECYCLE_FIXTURE_PATH"; +const RSS_ENV = "KANDELO_LOGIN_RSS_REPORT_PATH"; +const BROWSER_IDENTITY_ENV = "KANDELO_LOGIN_BROWSER_IDENTITY_PATH"; + +test("the ABI 43 login product installs and executes through stock Homebrew", async ({ + page, + baseURL, + browserName, + browser, +}) => { + const fixturePath = process.env[FIXTURE_ENV]; + if (fixturePath === undefined) + test.skip(true, "exact local product fixture is not configured"); + if (!baseURL) throw new Error("Playwright baseURL is required"); + const fixture = projectHomebrewGuestLifecycleBrowserFixture( + JSON.parse(readFileSync(resolve(fixturePath!), "utf8")), + ); + if (fixture.loginProduct === undefined) { + throw new Error("exact login product composition report is not configured"); + } + test.setTimeout( + fixture.timeoutMs * (browserName === "chromium" ? 3 : 1) + 180_000, + ); + + const rssPath = process.env[RSS_ENV]; + const chromiumPid = + browserName === "chromium" && rssPath !== undefined + ? findChromiumProcessRoot() + : undefined; + const sample = (phase: string): void => { + if (rssPath === undefined || chromiumPid === undefined) return; + appendProcessTreeRssSample({ + phase, + roots: new Map([["chromium", chromiumPid]]), + out: rssPath, + }); + }; + + await page.goto(new URL("/pages/homebrew-vfs-test/", baseURL).href); + await expect + .poll(() => page.evaluate(() => window.__homebrewVfsTestReady), { + timeout: 120_000, + }) + .toBe(true); + const proof = page.evaluate( + (exactFixture) => window.__runHomebrewLoginProductLifecycle(exactFixture), + fixture, + ); + for (const phase of [ + "before-boot", + "before-ruby", + "peak", + "after-child-reaping", + "after-three-repetitions", + ]) { + await expect + .poll(() => page.evaluate(() => window.__homebrewLoginProductPhase), { + timeout: 180_000, + }) + .toBe(phase); + sample(phase); + await page.evaluate(() => window.__ackHomebrewLoginProductPhase()); + } + const result = await proof; + expect(result.markers).toEqual( + expect.arrayContaining([ + "automatic-maker-login-ok", + "maker-id-ok", + "sudo-list-ok", + "sudo-id-ok", + "failed-sudo-password-ok", + "ordinary-login-ok", + "nosuid-copy-rejected", + "ruby-child-3-reaped", + "ruby-stock-tools-ok", + "brew-tap-install-execute-ok", + ]), + ); + const identityPath = process.env[BROWSER_IDENTITY_ENV]; + if (identityPath === undefined) { + throw new Error("exact browser identity report path is not configured"); + } + recordBrowserIdentity(identityPath, { + project: browserName, + version: browser.version(), + userAgent: await page.evaluate(() => navigator.userAgent), + }); +}); + +function recordBrowserIdentity( + pathValue: string, + identity: { project: string; version: string; userAgent: string }, +): void { + const path = resolve(pathValue); + if (existsSync(path) && lstatSync(path).isSymbolicLink()) { + throw new Error("browser identity report must not be a symbolic link"); + } + const document = existsSync(path) + ? JSON.parse(readFileSync(path, "utf8")) + : { + schema: 1, + provenance: { + schema: 1, + provenance_kind: "local-test", + promotable: false, + published: false, + }, + browsers: [], + }; + if ( + document.schema !== 1 || + document.provenance?.provenance_kind !== "local-test" || + document.provenance?.promotable !== false || + document.provenance?.published !== false || + !Array.isArray(document.browsers) || + !/^(chromium|firefox|webkit)$/.test(identity.project) || + identity.version.length === 0 || + identity.userAgent.length === 0 || + document.browsers.some( + (entry: { project?: unknown }) => entry.project === identity.project, + ) + ) { + throw new Error("browser identity report or engine identity is invalid"); + } + document.browsers.push(identity); + writeFileSync(path, `${JSON.stringify(document, null, 2)}\n`, { + encoding: "utf8", + flag: "w", + }); +} + +function findChromiumProcessRoot(): number { + const ps = process.platform === "darwin" ? "/bin/ps" : "/usr/bin/ps"; + if (!existsSync(ps)) { + throw new Error(`required process inventory tool is unavailable: ${ps}`); + } + const rows = execFileSync(ps, ["-axo", "pid=,ppid=,command="], { + encoding: "utf8", + }) + .split("\n") + .flatMap((line) => { + const match = /^\s*([0-9]+)\s+([0-9]+)\s+(.*)$/.exec(line); + return match + ? [ + { + pid: Number(match[1]), + ppid: Number(match[2]), + command: match[3]!, + }, + ] + : []; + }); + const byPid = new Map(rows.map((row) => [row.pid, row])); + const isDescendant = (pid: number): boolean => { + const seen = new Set(); + for (let current = pid; current > 1 && !seen.has(current);) { + if (current === process.ppid || current === process.pid) return true; + seen.add(current); + current = byPid.get(current)?.ppid ?? 0; + } + return false; + }; + const candidates = rows.filter( + (row) => + isDescendant(row.pid) && + /(?:^|\/)(?:chrome|chromium)(?:\s|$)/i.test(row.command) && + !row.command.includes("--type="), + ); + if (candidates.length !== 1) { + throw new Error( + `expected one Chromium browser root, found ${candidates.length}`, + ); + } + return candidates[0]!.pid; +} diff --git a/docs/superpowers/plans/2026-08-10-abi43-login-sudo-vfork-integration.md b/docs/superpowers/plans/2026-08-10-abi43-login-sudo-vfork-integration.md index 94702915c5..da9702d3aa 100644 --- a/docs/superpowers/plans/2026-08-10-abi43-login-sudo-vfork-integration.md +++ b/docs/superpowers/plans/2026-08-10-abi43-login-sudo-vfork-integration.md @@ -73,6 +73,33 @@ Python release tooling. --- +## Execution Amendment — 2026-08-12: CI-owned Homebrew bottle staging + +Brandon directed that the costly ABI 43 Homebrew bottle build and its product +evidence move out of this worktree. This amendment supersedes only the +local-staging portions of the original Task 20 plan; it does not turn unrun +work into evidence and does not change the publication or promotion boundary. + +Task 20 in this worktree now owns the checked-in product declarations, +selection and authority locks, privileged projection policy, provenance +rejection rules, CI invocation contract, and focused Node/browser fixture +contracts. `run-login-stack-local.sh` remains an implementation interface that +the staging lane may consume, but this worktree must not use it to build the +43-Formula closure or treat a local report as Task 20 completion evidence. + +The staging worktree `emdash/homebrew-pr-staging-1q1w6` and GitHub CI own the +actual ABI 43 Formula builds, sidecars, composed image, Node/browser lifecycle, +and RSS evidence. Their CI report is the sole success evidence for those +operations. Task 20 produces a frozen handoff containing the exact Kandelo and +tap heads, ABI, Formula closure, and required lifecycle assertions; it does not +stage, merge, publish, promote, or relabel artifacts. + +Accordingly, this amendment supersedes the original local-bottle statements in +the file/interface map, Task 18's local-harness direction, Task 20 Steps 6–10 +and 12, Task 23's local product run and manual demonstration, and Task 24's +local-test rerun. Later tasks consume the GitHub CI evidence from the staging +owner instead. The original text remains below as a historical record. + ## File and Interface Map ### New focused files @@ -94,8 +121,9 @@ Python release tooling. maps the demo product to the reusable session policy. - `scripts/run-vfork-readiness.sh` makes the mechanism and integration vfork gates repeatable and records exact commands and browser engines. -- `scripts/run-login-stack-local.sh` builds local-test bottles, composes a - disposable product, runs Node/browser evidence, and emits one bound report. +- `scripts/run-login-stack-local.sh` defines the CI staging invocation and + report contract. The staging worktree, not this worktree, runs its + 43-Formula bottle build and product evidence path. - `docs/measurements/2026-08-10-vfork-readiness.md` records exact-head vfork mechanism and integration results without turning unrun checks into claims. @@ -2493,7 +2521,7 @@ git commit --author='Brandon Payton ' \ -m "Browser: Supervise real login sessions per terminal" ``` -### Task 20: Compose the Homebrew product and local-test evidence harness +### Task 20: Compose the CI-ready Homebrew product and staging handoff **Files:** @@ -2520,8 +2548,16 @@ git commit --author='Brandon Payton ' \ - Consumes: exact clean tap checkout, local Formulae, generic materialization, privileged projections, login session policy, and existing Homebrew bottle, sidecar, composition, Node smoke, and closed-mirror tools -- Produces: `run-login-stack-local.sh --tap-root --work-root - [--browser-demo]`; immutable local image/mirror; bound `local-test` evidence +- Produces: checked-in product and authority contracts plus a frozen CI staging + handoff. `run-login-stack-local.sh --tap-root --work-root [--browser-demo]` + is the staging invocation interface; this worktree does not build the + 43-Formula closure or claim its execution evidence. + +> **Superseded execution steps:** Per the 2026-08-12 amendment above, original +> Steps 6–10 and 12 below are staging-owned. They are retained only as the +> historical interface specification. The staging worktree and GitHub CI run +> them and provide the success evidence; Task 20 here stops at reviewed source +> contracts and a frozen handoff. - [ ] **Step 1: Add product contract tests** @@ -3234,7 +3270,7 @@ git commit --author='Brandon Payton ' \ Do not hand-edit a bottle stanza, sidecar, candidate record, selection lock, or published metadata in this commit. -- [ ] **Step 4: Repeat the complete local-test proof on pristine Ruby** +- [ ] **Step 4: Bind pristine Ruby to the CI staging request** If Step 3 changed the tap commit, update Kandelo's migration lock and commit that exact selection independently: @@ -3245,13 +3281,13 @@ git commit --author='Brandon Payton ' \ -m "Homebrew: Select the pristine Ruby tap revision" ``` -Then rerun `scripts/run-login-stack-local.sh` from Task 20. Require the pinned -extracted source tree to match upstream before configure and require -`HAVE_VFORK`, `HAVE_WORKING_VFORK`, and `HAVE_WORKING_FORK` afterward. As uid -1000, Ruby's eligible fork-then-exec route must invoke vfork mode and construct -no child process Memory. The root/privileged route must use ordinary fork, -construct a distinct copied child Memory, and obey retirement admission. Keep -all outputs `local-test` and non-promotable. +Bind the new tap commit to the GitHub CI staging request. CI must prove that the +pinned extracted source tree matches upstream before configure, that +`HAVE_VFORK`, `HAVE_WORKING_VFORK`, and `HAVE_WORKING_FORK` hold afterward, +and that uid 1000 takes vfork without child process Memory while the +root/privileged route takes ordinary fork with distinct copied child Memory. +This worktree does not rerun the 43-Formula local harness or produce +`local-test` outputs for this check. - [ ] **Step 5: Request exact-head candidates through reviewed workflows** @@ -3261,18 +3297,15 @@ evidence, and authorization identity. Review the resulting candidate metadata and verify that every bottle was built by GitHub's isolated Formula builder. Do not upload, relabel, or promote any local-test byte. -- [ ] **Step 6: Run the exact hosted Homebrew lifecycle and RSS proof** +- [ ] **Step 6: Consume the exact GitHub CI lifecycle and RSS proof** -Against candidate bottles, perform real in-guest tap/install/execute for Ruby -and the complete closure, repeat at least three times, and record Node and -Chromium process-tree baseline/peak/post-reap RSS, renderer survival, parent -suspension, and fork mode. Run Firefox and WebKit functional coverage wherever -the platform path applies. Rerun ABI, libc, POSIX, Sortix, host, browser, -fork-instrument, and performance suites on the exact candidate head. Compare -the hosted result with Step 4's local proof, verify anonymous readback, and -promote only these fresh pristine-Ruby bytes. This is the required rebuild and -repeat of the exact lifecycle after #1166 removal; no earlier patched or local -artifact may satisfy it. +Review the GitHub CI report for real in-guest tap/install/execute for Ruby and +the complete closure, repeated at least three times, with Node and Chromium +process-tree baseline/peak/post-reap RSS, renderer survival, parent suspension, +and fork mode. The staging owner also supplies Firefox/WebKit functional +coverage and all required exact-head validation. Compare the CI result with +the checked-in product contracts and verify anonymous readback. No earlier +patched or local artifact may satisfy this evidence. - [ ] **Step 7: Verify linear history and contributor attribution** @@ -3361,8 +3394,8 @@ or companion tap PR without Brandon's explicit approval. truthful `EAGAIN`, and retains bounded documented retirement fallback. - [ ] Sparse cloning and Worker/module churn conclusions use real RSS and are not promoted from component measurements alone. -- [ ] Local Homebrew lifecycle and interactive browser demo finish entirely - from exact `local-test` inputs without remote mutation. +- [ ] GitHub CI reports the Homebrew lifecycle and interactive browser product + from exact reviewed heads. No private `local-test` run substitutes for it. - [ ] Whole Rust, ABI, host, browser, libc, POSIX, Sortix, fork-instrument, Homebrew, performance, RSS, and manual validation evidence is recorded. - [ ] Active hosted staging builds final candidates from exact reviewed heads. diff --git a/homebrew/main-shell-homebrew-runtime-support.json b/homebrew/main-shell-homebrew-runtime-support.json index 97f46dc85a..ba58c90351 100644 --- a/homebrew/main-shell-homebrew-runtime-support.json +++ b/homebrew/main-shell-homebrew-runtime-support.json @@ -5,7 +5,7 @@ "catalog": { "tap_repository": "kandelo-dev/homebrew-tap-core", "tap_name": "kandelo-dev/tap-core", - "tap_commit": "6ad0e3dbc60e5572c4288c86919238f71c1bc110" + "tap_commit": "af70e3ba06367dbafb8a95fabbacc3e1352b58b2" }, "base_formula_order": [ "kandelo-dev/tap-core/dash", @@ -45,7 +45,12 @@ "kandelo-dev/tap-core/zip", "kandelo-dev/tap-core/lsof", "kandelo-dev/tap-core/nano", - "kandelo-dev/tap-core/nethack" + "kandelo-dev/tap-core/nethack", + "kandelo-dev/tap-core/login", + "kandelo-dev/tap-core/sudo-lite", + "kandelo-dev/tap-core/sudo", + "kandelo-dev/tap-core/libyaml", + "kandelo-dev/tap-core/ruby" ], "activation": { "mode": "first-use-atomic", @@ -59,7 +64,7 @@ "homebrew-bootstrap.zip", "homebrew-brew.env" ], - "required_kernel_abi": 42 + "required_kernel_abi": 43 }, "base_image_default": "deferred", "demo_variant": "may-materialize" @@ -95,6 +100,7 @@ } ], "formula_order": [ + "kandelo-dev/tap-core/libyaml", "kandelo-dev/tap-core/zlib", "kandelo-dev/tap-core/ruby", "kandelo-dev/tap-core/coreutils", @@ -117,9 +123,7 @@ "kandelo-dev/tap-core/tar", "kandelo-dev/tap-core/posix-utils-lite" ], - "additional_formula_order": [ - "kandelo-dev/tap-core/ruby" - ], + "additional_formula_order": [], "required_commands": [ { "path": "/usr/bin/ruby", @@ -158,42 +162,62 @@ } ], "availability": { + "provenance": { + "schema": 1, + "provenance_kind": "local-test", + "promotable": false, + "published": false + }, "audited_catalog": { - "checkout_commit": "6ad0e3dbc60e5572c4288c86919238f71c1bc110", - "metadata_sha256": "34f1cb2454ff6d9c134195302a5c5fcac85b9502f2522d3e584bf55d539c9a7d", - "metadata_tap_commit": "b5ffda55d9b0e27efdfdec30ebb38d48a21518c4", - "kandelo_commit": "c647adda31d0918de944135543fb94039135cef1", - "runtime_bottle_provenance_sha256": "b9070afcb5ddcdc112c1b994c16f8f46251d78dff3877e01adf30b226e4c31d2", - "kandelo_abi": 42, - "release_tag": "bottles-abi-v42", + "checkout_commit": "af70e3ba06367dbafb8a95fabbacc3e1352b58b2", + "kandelo_abi": 43, + "release_tag": "bottles-abi-v43", "required_arch": "wasm32" }, - "reusable_public_abi42": [ - "kandelo-dev/tap-core/zlib", - "kandelo-dev/tap-core/ruby", - "kandelo-dev/tap-core/coreutils", + "local_test_formulae": [ "kandelo-dev/tap-core/dash", - "kandelo-dev/tap-core/ed", - "kandelo-dev/tap-core/diffutils", - "kandelo-dev/tap-core/grep", "kandelo-dev/tap-core/libcxx", "kandelo-dev/tap-core/ncurses", - "kandelo-dev/tap-core/less", - "kandelo-dev/tap-core/openssl", - "kandelo-dev/tap-core/libcurl", + "kandelo-dev/tap-core/bash", + "kandelo-dev/tap-core/coreutils", + "kandelo-dev/tap-core/gawk", + "kandelo-dev/tap-core/grep", "kandelo-dev/tap-core/sed", - "kandelo-dev/tap-core/vim", - "kandelo-dev/tap-core/git", - "kandelo-dev/tap-core/curl", + "kandelo-dev/tap-core/bc", "kandelo-dev/tap-core/bzip2", "kandelo-dev/tap-core/xz", + "kandelo-dev/tap-core/zlib", + "kandelo-dev/tap-core/libmagic", + "kandelo-dev/tap-core/file-formula", + "kandelo-dev/tap-core/m4", + "kandelo-dev/tap-core/make", "kandelo-dev/tap-core/findutils", - "kandelo-dev/tap-core/gawk", + "kandelo-dev/tap-core/ed", + "kandelo-dev/tap-core/diffutils", + "kandelo-dev/tap-core/posix-utils-lite", + "kandelo-dev/tap-core/fbdoom", + "kandelo-dev/tap-core/modeset", + "kandelo-dev/tap-core/less", "kandelo-dev/tap-core/gzip", "kandelo-dev/tap-core/tar", - "kandelo-dev/tap-core/posix-utils-lite", - "kandelo-dev/tap-core/libmagic", - "kandelo-dev/tap-core/file-formula" + "kandelo-dev/tap-core/openssl", + "kandelo-dev/tap-core/libcurl", + "kandelo-dev/tap-core/curl", + "kandelo-dev/tap-core/netcat", + "kandelo-dev/tap-core/wget", + "kandelo-dev/tap-core/vim", + "kandelo-dev/tap-core/git", + "kandelo-dev/tap-core/zstd", + "kandelo-dev/tap-core/unzip", + "kandelo-dev/tap-core/zip", + "kandelo-dev/tap-core/lsof", + "kandelo-dev/tap-core/nano", + "kandelo-dev/tap-core/nethack", + "kandelo-dev/tap-core/login", + "kandelo-dev/tap-core/sudo-lite", + "kandelo-dev/tap-core/sudo", + "kandelo-dev/tap-core/libyaml", + "kandelo-dev/tap-core/ruby" ], "requires_rebuild": [], "missing_metadata": [], diff --git a/homebrew/main-shell-lazy-artifact-lock.json b/homebrew/main-shell-lazy-artifact-lock.json index 11968976a4..0354e13215 100644 --- a/homebrew/main-shell-lazy-artifact-lock.json +++ b/homebrew/main-shell-lazy-artifact-lock.json @@ -5,12 +5,12 @@ "state": "pending", "inputs": { "bootstrap_tree_spec_sha256": "7160094ad36d0684210a46331ec73bd8fe938222358f045ca60d3d6b210a04c4", - "brewfile_sha256": "6f59fe83d93548bd2521a0fe9af1942ab321b2fb249939bf9e6a37f7de0f4722", + "brewfile_sha256": "91ae5dd3e333cedfcd6e6f7d1f10525b1c40a9866c24f100e467657e740619f6", "demo_config_sha256": "ef689e4eed5d9b59874e5c46fb29c8fe6e1792dfd8252a7e2be4292a4e3747ef", - "materialization_policy_sha256": "fb170c25f71e6e7fdd3470901b3ab3a42e7765648d55f62fad38230e30461fe0", - "migration_lock_sha256": "92b6c3946e40e9384de0ed98c13b6f9ec595e487a5112cc8a450bc2ac3ea524e", - "runtime_support_sha256": "4ee373429d0f26e459cd9fe5ea16901d1769f9947f89cffeaee262b94e403616", - "selection_lock_sha256": "7811caefed4ca503b49fb2bd7e338d6e7c21e3dd3ecdfc4783ea9d24514f5b73", + "materialization_policy_sha256": "594410cab6ae0147fd5412e2d82b933d8323c5a967a25f2e0ec09746ca889e8a", + "migration_lock_sha256": "9f26ff968b019e22ecbfe2bae7f004017aee80e2a2cc56eec92243286875827b", + "runtime_support_sha256": "68b96c6f9ad520f86b827ba294b65fb72fda57c9f3edb6eeb7c5b01cf21ece56", + "selection_lock_sha256": "aa31e2c3014dd6a32c54469d82801a66f8b3eea02f4dec4aebbfa0a3a2d4f6f5", "shell_config_sha256": "bc3c4027b5f83fd74178364bf0b073b546eca5a098bf8bf47bc38a555b4cfe0c" }, "image": null diff --git a/homebrew/main-shell-materialization-policy.json b/homebrew/main-shell-materialization-policy.json index beb6dd8d1d..66c293bd1b 100644 --- a/homebrew/main-shell-materialization-policy.json +++ b/homebrew/main-shell-materialization-policy.json @@ -2,11 +2,21 @@ "schema": 1, "kind": "kandelo-homebrew-vfs-materialization-policy", "embedded_roots": [ - "kandelo-dev/tap-core/bash" + "kandelo-dev/tap-core/bash", + "kandelo-dev/tap-core/login", + "kandelo-dev/tap-core/sudo-lite", + "kandelo-dev/tap-core/sudo", + "kandelo-dev/tap-core/ruby" ], "embedded_package_order": [ "kandelo-dev/tap-core/libcxx", "kandelo-dev/tap-core/ncurses", - "kandelo-dev/tap-core/bash" + "kandelo-dev/tap-core/bash", + "kandelo-dev/tap-core/zlib", + "kandelo-dev/tap-core/login", + "kandelo-dev/tap-core/sudo-lite", + "kandelo-dev/tap-core/sudo", + "kandelo-dev/tap-core/libyaml", + "kandelo-dev/tap-core/ruby" ] } diff --git a/homebrew/main-shell-migration-lock.json b/homebrew/main-shell-migration-lock.json index 905cab783f..6d261d6f73 100644 --- a/homebrew/main-shell-migration-lock.json +++ b/homebrew/main-shell-migration-lock.json @@ -3,7 +3,7 @@ "tap_repository": "kandelo-dev/homebrew-tap-core", "tap_name": "kandelo-dev/tap-core", "catalog": { - "tap_commit": "6ad0e3dbc60e5572c4288c86919238f71c1bc110" + "tap_commit": "af70e3ba06367dbafb8a95fabbacc3e1352b58b2" }, "consumer": { "profile": "main-shell", @@ -393,6 +393,54 @@ "revision": 0, "bottle_rebuild": 1 } + }, + { + "registry": { + "name": "login", + "version": "0.1.0" + }, + "formula": { + "name": "login", + "version": "0.1.0", + "revision": 0, + "bottle_rebuild": 0 + } + }, + { + "registry": { + "name": "sudo-lite", + "version": "0.1.0" + }, + "formula": { + "name": "sudo-lite", + "version": "0.1.0", + "revision": 0, + "bottle_rebuild": 0 + } + }, + { + "registry": { + "name": "sudo", + "version": "1.9.17p2" + }, + "formula": { + "name": "sudo", + "version": "1.9.17p2", + "revision": 0, + "bottle_rebuild": 0 + } + }, + { + "registry": { + "name": "ruby", + "version": "4.0.5" + }, + "formula": { + "name": "ruby", + "version": "4.0.5", + "revision": 3, + "bottle_rebuild": 0 + } } ], "formula_closure": [ @@ -433,7 +481,12 @@ "kandelo-dev/tap-core/zip", "kandelo-dev/tap-core/lsof", "kandelo-dev/tap-core/nano", - "kandelo-dev/tap-core/nethack" + "kandelo-dev/tap-core/nethack", + "kandelo-dev/tap-core/login", + "kandelo-dev/tap-core/sudo-lite", + "kandelo-dev/tap-core/sudo", + "kandelo-dev/tap-core/libyaml", + "kandelo-dev/tap-core/ruby" ], "reviewed_substitutions": [ { @@ -866,5 +919,72 @@ "reason": "Preserve the current shell's standard filesystem layout even when no package installs an sbin command." } ] + }, + "product": { + "ordinary_prefix_mount": { + "nosuid": true + }, + "registry_bridge": false, + "privileged_programs": [ + { + "schema": 1, + "formula": "kandelo-dev/tap-core/login", + "source_path": "login/0.1.0/bin/login", + "destination_path": "/usr/bin/login", + "uid": 0, + "gid": 0, + "mode": 2541, + "mount_point": "trusted-root-product" + }, + { + "schema": 1, + "formula": "kandelo-dev/tap-core/sudo-lite", + "source_path": "sudo-lite/0.1.0/bin/sudo-lite", + "destination_path": "/usr/bin/sudo-lite", + "uid": 0, + "gid": 0, + "mode": 2541, + "mount_point": "trusted-root-product" + }, + { + "schema": 1, + "formula": "kandelo-dev/tap-core/sudo", + "source_path": "sudo/1.9.17p2/bin/sudo", + "destination_path": "/usr/bin/sudo", + "uid": 0, + "gid": 0, + "mode": 2541, + "mount_point": "trusted-root-product" + } + ], + "ruby": { + "source_policy": "pristine-upstream", + "forbid_source_patches": true, + "forbid_ac_cv_func_vfork_no": true, + "required_config_defines": [ + "HAVE_VFORK", + "HAVE_WORKING_VFORK", + "HAVE_WORKING_FORK" + ], + "required_stock_executables": [ + "bundle", + "bundler", + "erb", + "gem", + "irb", + "minitest", + "rake", + "rdoc", + "ri", + "ruby", + "syntax_suggest", + "test-unit", + "typeprof" + ], + "instrumented_executable": "ruby" + }, + "sudo": { + "deferred_upstream_sudo_allowed": true + } } } diff --git a/homebrew/main-shell-selection-lock.json b/homebrew/main-shell-selection-lock.json index 6152bc5361..4eaf2c57e8 100644 --- a/homebrew/main-shell-selection-lock.json +++ b/homebrew/main-shell-selection-lock.json @@ -3,7 +3,7 @@ "inputs": { "brewfile": { "path": "homebrew/main-shell.Brewfile", - "sha256": "6f59fe83d93548bd2521a0fe9af1942ab321b2fb249939bf9e6a37f7de0f4722" + "sha256": "91ae5dd3e333cedfcd6e6f7d1f10525b1c40a9866c24f100e467657e740619f6" }, "guest_layout": { "path": "homebrew/kandelo-guest-layout.json", @@ -11,11 +11,11 @@ }, "migration_lock": { "path": "homebrew/main-shell-migration-lock.json", - "sha256": "92b6c3946e40e9384de0ed98c13b6f9ec595e487a5112cc8a450bc2ac3ea524e" + "sha256": "9f26ff968b019e22ecbfe2bae7f004017aee80e2a2cc56eec92243286875827b" }, "runtime_support": { "path": "homebrew/main-shell-homebrew-runtime-support.json", - "sha256": "4ee373429d0f26e459cd9fe5ea16901d1769f9947f89cffeaee262b94e403616" + "sha256": "68b96c6f9ad520f86b827ba294b65fb72fda57c9f3edb6eeb7c5b01cf21ece56" } }, "kind": "kandelo-homebrew-main-shell-closed-selection-lock", diff --git a/homebrew/main-shell.Brewfile b/homebrew/main-shell.Brewfile index 93da1484ee..cc0a5af014 100644 --- a/homebrew/main-shell.Brewfile +++ b/homebrew/main-shell.Brewfile @@ -39,3 +39,7 @@ brew "kandelo-dev/tap-core/lsof" brew "kandelo-dev/tap-core/nano" brew "kandelo-dev/tap-core/vim" brew "kandelo-dev/tap-core/nethack" +brew "kandelo-dev/tap-core/login" +brew "kandelo-dev/tap-core/sudo-lite" +brew "kandelo-dev/tap-core/sudo" +brew "kandelo-dev/tap-core/ruby" diff --git a/homebrew/patches/0002-support-isolated-publisher.patch b/homebrew/patches/0002-support-isolated-publisher.patch index 9c96d90dc0..f1cdddd6ad 100644 --- a/homebrew/patches/0002-support-isolated-publisher.patch +++ b/homebrew/patches/0002-support-isolated-publisher.patch @@ -56,6 +56,14 @@ Homebrew path remains subject to the normal writability check, and explicit `brew trust` operations still use the normal mutation path and fail against an immutable publisher store. +The sealed tapped checkout is also workflow-owned, so the isolated Formula +identity cannot use Homebrew's ordinary Git query without Git rejecting the +foreign ownership. Resolve only the selected tap through the root-owned +publisher plan: require its canonical read-only checkout path, run the +launcher-validated Git with command-scoped `safe.directory` in an otherwise +empty environment, and require the observed commit to equal the plan before +the receipt or bottle uses it. Other taps retain Homebrew's ordinary lookup. + This patch is applied only to the publisher's temporary Homebrew overlay. Guest Homebrew keeps its normal repository, trust, and dependency behavior. @@ -65,10 +73,11 @@ dependency behavior. Library/Homebrew/diagnostic.rb | 1 + Library/Homebrew/extend/os/linux/formula.rb | 4 + Library/Homebrew/extend/os/linux/sandbox.rb | 3 + - Library/Homebrew/kandelo_publisher.rb | 225 ++++++++++++++++++++ + Library/Homebrew/kandelo_publisher.rb | 280 +++++++++++++++++++++++++++ + Library/Homebrew/tap.rb | 8 ++++++-- Library/Homebrew/test.rb | 2 + Library/Homebrew/trust.rb | 1 + - 8 files changed, 278 insertions(+), 1 deletion(-) + 9 files changed, 339 insertions(+), 3 deletions(-) create mode 100644 Library/Homebrew/kandelo_publisher.rb diff --git a/Library/Homebrew/build.rb b/Library/Homebrew/build.rb @@ -226,14 +235,15 @@ index 6216a189c..b29e304ca 100644 diff --git a/Library/Homebrew/kandelo_publisher.rb b/Library/Homebrew/kandelo_publisher.rb new file mode 100644 -index 000000000..7ee01d23d +index 000000000..93929dc18 --- /dev/null +++ b/Library/Homebrew/kandelo_publisher.rb -@@ -0,0 +1,225 @@ +@@ -0,0 +1,280 @@ +# typed: false +# frozen_string_literal: true + +require "json" ++require "open3" + +# Publisher-only validation for the root-owned dependency plan. This file is +# added only to Kandelo's temporary Homebrew overlay. @@ -255,6 +265,10 @@ index 000000000..7ee01d23d + GNU_TAR_ENV = "HOMEBREW_KANDELO_GNU_TAR" + GNU_TAR_PATH = ENV.fetch(GNU_TAR_ENV, "").dup.freeze + NIX_GNU_TAR_PATH = %r{\A/nix/store/[0-9a-z]{32}-gnutar-[^/]+/bin/tar\z} ++ PRIMARY_TAP_ROOT_ENV = "HOMEBREW_KANDELO_PRIMARY_TAP_ROOT" ++ PRIMARY_TAP_ROOT = ENV.fetch(PRIMARY_TAP_ROOT_ENV, "").dup.freeze ++ PROTECTED_GIT_ENV = "HOMEBREW_GIT_PATH" ++ PROTECTED_GIT = ENV.fetch(PROTECTED_GIT_ENV, "").dup.freeze + TAP_GIT_HEAD = /\A[0-9a-f]{40}\z/ + + def self.active? @@ -270,6 +284,56 @@ index 000000000..7ee01d23d + end + end + ++ def self.selected_tap_git_head(tap) ++ plan = dependency_plan(nil) ++ return if plan.nil? || tap.name != plan.fetch("tap") ++ ++ tap_path = tap.path.to_s ++ valid_tap_path = !PRIMARY_TAP_ROOT.empty? && tap_path == PRIMARY_TAP_ROOT && ++ File.directory?(tap_path) && !File.symlink?(tap_path) && ++ File.realpath(tap_path) == tap_path && !File.writable?(tap_path) ++ raise "Kandelo publisher selected tap checkout path is invalid" unless valid_tap_path ++ ++ target_tap = plan.fetch("target_taps").find do |candidate| ++ candidate.fetch("tap_name") == plan.fetch("tap") ++ end ++ expected_commit = target_tap&.fetch("checkout_commit") ++ unless expected_commit.is_a?(String) && TAP_GIT_HEAD.match?(expected_commit) ++ raise "Kandelo publisher selected tap commit is invalid" ++ end ++ ++ git_stat = File.lstat(PROTECTED_GIT) ++ protected_git = File.file?(PROTECTED_GIT) && !File.symlink?(PROTECTED_GIT) && ++ File.executable?(PROTECTED_GIT) && (git_stat.mode & 0o022).zero? && ++ File.realpath(PROTECTED_GIT) == PROTECTED_GIT ++ raise "Kandelo publisher protected Git is invalid" unless protected_git ++ ++ stdout, _stderr, status = Open3.capture3( ++ { ++ "HOME" => "/nonexistent", ++ "PATH" => "/usr/bin:/bin", ++ "LC_ALL" => "C", ++ "GIT_CONFIG_NOSYSTEM" => "1", ++ "GIT_CONFIG_GLOBAL" => "/dev/null", ++ "GIT_NO_REPLACE_OBJECTS" => "1", ++ "GIT_OPTIONAL_LOCKS" => "0", ++ }, ++ PROTECTED_GIT, ++ "-c", "safe.directory=#{tap_path}", ++ "-C", tap_path, ++ "rev-parse", "--verify", "--quiet", "HEAD^{commit}", ++ unsetenv_others: true, ++ ) ++ actual_commit = stdout.strip ++ unless status.success? && TAP_GIT_HEAD.match?(actual_commit) && actual_commit == expected_commit ++ raise "Kandelo publisher selected tap commit differs from the protected plan" ++ end ++ ++ actual_commit ++ rescue Errno::ENOENT, Errno::ENOTDIR ++ raise "Kandelo publisher protected tap Git input is unavailable" ++ end ++ + def self.reproducible_gnu_tar + return unless active? + @@ -382,7 +446,7 @@ index 000000000..7ee01d23d + end + + plan = JSON.parse(contents) -+ identified_plan = plan.is_a?(Hash) && plan.keys.sort == PLAN_KEYS && plan["schema"] == 4 && ++ identified_plan = plan.is_a?(Hash) && plan.keys.sort == PLAN_KEYS && plan["schema"] == 5 && + plan["tap"].is_a?(String) && plan["formula"].is_a?(String) && + plan["full_name"] == "#{plan["tap"]}/#{plan["formula"]}" + raise "Kandelo publisher dependency plan has an invalid identity" unless identified_plan @@ -390,16 +454,16 @@ index 000000000..7ee01d23d + valid_target_taps = target_taps.is_a?(Array) && target_taps.length <= 9 && + target_taps.all? do |tap| + next false unless tap.is_a?(Hash) && -+ tap.keys.sort == %w[tap_commit tap_name tap_repository] ++ tap.keys.sort == %w[checkout_commit tap_commit tap_name tap_repository] + + tap_name = tap["tap_name"] + tap_repository = tap["tap_repository"] + owner, short_name = tap_name.split("/", 2) if tap_name.is_a?(String) + tap_name.is_a?(String) && tap_repository.is_a?(String) && -+ tap["tap_commit"].is_a?(String) && ++ tap["tap_commit"].is_a?(String) && tap["checkout_commit"].is_a?(String) && + TAP_NAME.match?(tap_name) && TAP_REPOSITORY.match?(tap_repository) && + tap_repository == "#{owner}/homebrew-#{short_name}" && -+ TAP_GIT_HEAD.match?(tap["tap_commit"]) ++ TAP_GIT_HEAD.match?(tap["tap_commit"]) && TAP_GIT_HEAD.match?(tap["checkout_commit"]) + end + target_names = valid_target_taps ? target_taps.map { |tap| tap.fetch("tap_name") } : [] + valid_target_taps &&= target_names == target_names.sort.uniq && target_names.include?(plan["tap"]) @@ -455,6 +519,27 @@ index 000000000..7ee01d23d + raise "Kandelo publisher dependency plan is invalid JSON: #{e.message}" + end +end +diff --git a/Library/Homebrew/tap.rb b/Library/Homebrew/tap.rb +index 0a4832c6b..babe21bf3 100644 +--- a/Library/Homebrew/tap.rb ++++ b/Library/Homebrew/tap.rb +@@ -4,4 +4,5 @@ + require "api" + require "commands" ++require "kandelo_publisher" + require "settings" + require "utils/output" +@@ -442,5 +443,8 @@ class Tap + def git_head + raise TapUnavailableError, name unless installed? +- +- @git_head ||= T.let(git_repository.head_ref, T.nilable(String)) ++ ++ @git_head ||= T.let( ++ KandeloPublisher.selected_tap_git_head(self) || git_repository.head_ref, ++ T.nilable(String), ++ ) + end diff --git a/Library/Homebrew/test.rb b/Library/Homebrew/test.rb index b0c522709..2d2350a54 100644 --- a/Library/Homebrew/test.rb diff --git a/homebrew/test/homebrew_guest_lifecycle_browser.ts b/homebrew/test/homebrew_guest_lifecycle_browser.ts index d8429257b2..1945fe7e8d 100644 --- a/homebrew/test/homebrew_guest_lifecycle_browser.ts +++ b/homebrew/test/homebrew_guest_lifecycle_browser.ts @@ -20,6 +20,7 @@ import { type HomebrewGuestObservedProcessEvent, type HomebrewGuestObservedScriptResult, runHomebrewGuestLifecycle, + runHomebrewGuestShippingProof, runHomebrewGuestLifecycleProcess, } from "./homebrew_guest_lifecycle_runner"; import { @@ -50,6 +51,54 @@ export interface HomebrewGuestLifecycleBrowserResult { phaseTwoLazyDownloads: readonly LazyDownloadEvent[]; } +export async function runHomebrewGuestCoreShippingProofInBrowser(options: { + fixture: unknown; + kernelWasm: ArrayBuffer; + corsProxyUrl: string; + closedAssetRootUrl?: string; + fetchImpl?: FetchLike; + afterMachineDestroy?: () => Promise; +}): Promise<{ coreRevision: string; completedUrls: string[] }> { + const fixture = projectHomebrewGuestLifecycleBrowserFixture(options.fixture); + const loaded = await loadHomebrewGuestLifecycleBrowserFixture(fixture, { + fetchImpl: options.fetchImpl, + sourceUrl: (canonicalUrl) => fixture.transportMode === "closed" + ? createClosedFixtureSourceUrl(options.closedAssetRootUrl, canonicalUrl) + : createCorsProxySourceUrl(options.corsProxyUrl, canonicalUrl), + }); + const runtime = await deriveHomebrewGuestLifecycleRuntimeInputs({ + imageBytes: loaded.imageBytes, + takeImageOwnership: true, + bootstrapSpecBytes: loaded.bootstrapSpecBytes, + bootstrapArchiveBytes: loaded.bootstrapArchiveBytes, + bootstrapArchiveSha256: fixture.bootstrap.archive.sha256, + bootstrapEnvironmentBytes: loaded.bootstrapEnvironmentBytes, + coreRevision: fixture.revisions.coreRevision, + transportMode: fixture.transportMode, + expectedEmbeddedBottlePlanBytes: loaded.bottleMirrorPlanBytes, + lazyUrlBase: "https://closed.kandelo.invalid/homebrew-login-product/", + ...(fixture.transportMode === "closed" + ? { closedBottleAssets: loaded.closedBottleAssets! } + : { expectedBootstrapTransportUrl: fixture.bootstrap.archive.url }), + }); + const result = await runHomebrewGuestShippingProof({ + runtime, + revisions: fixture.revisions, + scope: "core", + deadlineMs: Date.now() + fixture.timeoutMs, + createMachine: (machineRuntime) => createBrowserLifecycleMachine({ + runtime: machineRuntime, + kernelWasm: options.kernelWasm, + corsProxyUrl: options.corsProxyUrl, + afterDestroy: options.afterMachineDestroy, + }), + }); + return { + coreRevision: fixture.revisions.coreRevision, + completedUrls: [...result.completedUrls].sort(), + }; +} + type FetchLike = ( input: string | URL, init?: RequestInit, diff --git a/homebrew/test/homebrew_guest_lifecycle_browser_fixture.test.ts b/homebrew/test/homebrew_guest_lifecycle_browser_fixture.test.ts index 4a098d192b..f65a9c5fde 100644 --- a/homebrew/test/homebrew_guest_lifecycle_browser_fixture.test.ts +++ b/homebrew/test/homebrew_guest_lifecycle_browser_fixture.test.ts @@ -126,6 +126,14 @@ test("loads every exact fixture byte and binds payloads to the mirror plan", asy assert.deepEqual(loaded.bootstrapArchiveBytes, fixture.archiveBytes); assert.deepEqual(loaded.bootstrapEnvironmentBytes, fixture.environmentBytes); assert.deepEqual(loaded.bottleMirrorPlanBytes, fixture.planBytes); + assert.deepEqual( + loaded.compositionReportBytes, + fixture.compositionReportBytes, + ); + assert.deepEqual( + loaded.privilegedProductBytes, + fixture.privilegedProductBytes, + ); assert.deepEqual(loaded.closedBottleAssets, [{ url: fixture.plan.assets[0]!.url, sha256: fixture.plan.assets[0]!.sha256, @@ -207,6 +215,20 @@ test("rejects changed bytes and fixture identities that differ from the plan", a ); }); +test("requires the exact composition-report and serialized-product pair", () => { + const fixture = createFixture(); + assert.throws( + () => + projectHomebrewGuestLifecycleBrowserFixture({ + ...fixture.value, + loginProduct: { + compositionReport: fixture.value.loginProduct.compositionReport, + }, + }), + /login product has unknown or missing fields/, + ); +}); + test("loads the complete fixture under one aggregate transport budget and signal", async () => { const fixture = createFixture(); const signals = new Set(); @@ -465,6 +487,8 @@ function createFixture() { const archiveBytes = new Uint8Array([4]); const environmentBytes = new Uint8Array([5]); const payloadBytes = new Uint8Array([6, 7, 8]); + const compositionReportBytes = new Uint8Array([9, 10]); + const privilegedProductBytes = new Uint8Array([11, 12, 13]); const repository = "example/project"; const identity = { id: "bottle-test", @@ -499,6 +523,8 @@ function createFixture() { archive: "https://example.test/homebrew-bootstrap.zip", environment: "https://example.test/homebrew-brew.env", plan: `${releaseRoot}/${HOMEBREW_BOTTLE_MIRROR_PLAN_ASSET}`, + compositionReport: "https://example.test/composition-report.json", + privilegedProduct: "https://example.test/main-shell.vfs.privileged.vfs", }; const value = { schema: 1, @@ -517,6 +543,10 @@ function createFixture() { ...exact(plan.assets[0]!.url, payloadBytes), }], }, + loginProduct: { + compositionReport: exact(urls.compositionReport, compositionReportBytes), + privilegedProduct: exact(urls.privilegedProduct, privilegedProductBytes), + }, revisions: { coreRevision: "1".repeat(40), canaryRevision: "2".repeat(40), @@ -529,6 +559,8 @@ function createFixture() { [urls.archive, archiveBytes], [urls.environment, environmentBytes], [urls.plan, planBytes], + [urls.compositionReport, compositionReportBytes], + [urls.privilegedProduct, privilegedProductBytes], [plan.assets[0]!.url, payloadBytes], ]); return { @@ -539,6 +571,8 @@ function createFixture() { archiveBytes, environmentBytes, payloadBytes, + compositionReportBytes, + privilegedProductBytes, plan, planBytes, }; diff --git a/homebrew/test/homebrew_guest_lifecycle_browser_fixture.ts b/homebrew/test/homebrew_guest_lifecycle_browser_fixture.ts index 70a6954605..30d354a4cd 100644 --- a/homebrew/test/homebrew_guest_lifecycle_browser_fixture.ts +++ b/homebrew/test/homebrew_guest_lifecycle_browser_fixture.ts @@ -44,6 +44,10 @@ export interface HomebrewGuestLifecycleBrowserFixture { plan: HomebrewGuestLifecycleExactAsset; payloads?: HomebrewGuestLifecycleBottlePayloadFixture[]; }; + loginProduct?: { + compositionReport: HomebrewGuestLifecycleExactAsset; + privilegedProduct: HomebrewGuestLifecycleExactAsset; + }; revisions: HomebrewGuestLifecycleRevisions; timeoutMs: number; } @@ -55,6 +59,8 @@ export interface LoadedHomebrewGuestLifecycleBrowserFixture { bootstrapArchiveBytes: Uint8Array; bootstrapEnvironmentBytes: Uint8Array; bottleMirrorPlanBytes: Uint8Array; + compositionReportBytes?: Uint8Array; + privilegedProductBytes?: Uint8Array; closedBottleAssets?: readonly ClosedLazyAsset[]; } @@ -127,13 +133,14 @@ const TOP_LEVEL_KEYS = [ "revisions", "timeoutMs", ] as const; +const LOGIN_PRODUCT_KEYS = ["compositionReport", "privilegedProduct"] as const; const BOOTSTRAP_KEYS = ["spec", "archive", "environment"] as const; const MIRROR_KEYS = ["plan", "payloads"] as const; const REVISION_KEYS = ["coreRevision", "canaryRevision"] as const; const ASSET_KEYS = ["url", "sha256", "bytes"] as const; const PAYLOAD_KEYS = ["asset", "url", "sha256", "bytes"] as const; const SHA256_RE = /^[0-9a-f]{64}$/; -const FIXED_EXACT_ASSET_COUNT = 5; +const BASE_FIXED_EXACT_ASSET_COUNT = 5; /** * Reject ambient or partially specified live inputs before any browser fetch. @@ -145,7 +152,8 @@ export function projectHomebrewGuestLifecycleBrowserFixture( ): HomebrewGuestLifecycleBrowserFixture { if ( !isRecord(value) || - !hasExactKeys(value, TOP_LEVEL_KEYS) + (!hasExactKeys(value, TOP_LEVEL_KEYS) && + !hasExactKeys(value, [...TOP_LEVEL_KEYS, "loginProduct"])) ) { throw new Error( "Homebrew browser lifecycle fixture has unknown or missing fields", @@ -192,7 +200,15 @@ export function projectHomebrewGuestLifecycleBrowserFixture( ); } - const bottleMirror = projectBottleMirror(value.bottleMirror); + const loginProduct = value.loginProduct === undefined + ? undefined + : projectLoginProduct(value.loginProduct); + const fixedExactAssetCount = + BASE_FIXED_EXACT_ASSET_COUNT + (loginProduct === undefined ? 0 : 2); + const bottleMirror = projectBottleMirror( + value.bottleMirror, + fixedExactAssetCount, + ); if ( ( value.transportMode === "closed" && @@ -226,6 +242,7 @@ export function projectHomebrewGuestLifecycleBrowserFixture( ), }, bottleMirror, + ...(loginProduct === undefined ? {} : { loginProduct }), revisions, timeoutMs: value.timeoutMs as number, }; @@ -260,8 +277,17 @@ async function loadHomebrewGuestLifecycleBrowserFixtureImpl( fixture.bootstrap.archive, fixture.bootstrap.environment, fixture.bottleMirror.plan, + ...(fixture.loginProduct === undefined + ? [] + : [ + fixture.loginProduct.compositionReport, + fixture.loginProduct.privilegedProduct, + ]), ...payloads, ]; + const fixedExactAssetCount = + BASE_FIXED_EXACT_ASSET_COUNT + + (fixture.loginProduct === undefined ? 0 : 2); // WHY: validate the entire transport set before I/O so staging the // authority plan ahead of its payloads cannot accidentally grant each // stage a separate count/byte budget or permit a duplicate canonical URL. @@ -278,7 +304,7 @@ async function loadHomebrewGuestLifecycleBrowserFixtureImpl( // identities. Fetch fixed inputs and that plan first; do not issue payload // requests until the decoded plan proves the fixture declared the same set. const loadedFixedAssets = await loadFixtureAssetSources( - sources.slice(0, FIXED_EXACT_ASSET_COUNT), + sources.slice(0, fixedExactAssetCount), fetchImpl, options.signal, transportController, @@ -295,6 +321,12 @@ async function loadHomebrewGuestLifecycleBrowserFixtureImpl( const bootstrapArchiveBytes = bootstrapArchive!.bytes; const bootstrapEnvironmentBytes = bootstrapEnvironment!.bytes; const bottleMirrorPlanBytes = bottleMirrorPlan!.bytes; + const compositionReportBytes = fixture.loginProduct === undefined + ? undefined + : loadedFixedAssets[BASE_FIXED_EXACT_ASSET_COUNT]!.bytes; + const privilegedProductBytes = fixture.loginProduct === undefined + ? undefined + : loadedFixedAssets[BASE_FIXED_EXACT_ASSET_COUNT + 1]!.bytes; const plan = decodeHomebrewBottleMirrorPlan( bottleMirrorPlanBytes, "live Homebrew bottle mirror plan", @@ -316,6 +348,12 @@ async function loadHomebrewGuestLifecycleBrowserFixtureImpl( bootstrapArchiveBytes, bootstrapEnvironmentBytes, bottleMirrorPlanBytes, + ...(compositionReportBytes === undefined + ? {} + : { compositionReportBytes }), + ...(privilegedProductBytes === undefined + ? {} + : { privilegedProductBytes }), }; } @@ -345,7 +383,7 @@ async function loadHomebrewGuestLifecycleBrowserFixtureImpl( } const loadedPayloads = await loadFixtureAssetSources( - sources.slice(FIXED_EXACT_ASSET_COUNT), + sources.slice(fixedExactAssetCount), fetchImpl, options.signal, transportController, @@ -383,6 +421,8 @@ async function loadHomebrewGuestLifecycleBrowserFixtureImpl( bootstrapArchiveBytes, bootstrapEnvironmentBytes, bottleMirrorPlanBytes, + ...(compositionReportBytes === undefined ? {} : { compositionReportBytes }), + ...(privilegedProductBytes === undefined ? {} : { privilegedProductBytes }), closedBottleAssets, }; } @@ -457,7 +497,10 @@ async function loadFixtureAssetSources( } } -function projectBottleMirror(value: unknown): +function projectBottleMirror( + value: unknown, + fixedExactAssetCount: number, +): HomebrewGuestLifecycleBrowserFixture["bottleMirror"] { if ( !isRecord(value) || @@ -481,7 +524,7 @@ function projectBottleMirror(value: unknown): if ( value.payloads !== undefined && value.payloads.length > - MAX_CLOSED_LAZY_ASSETS - FIXED_EXACT_ASSET_COUNT + MAX_CLOSED_LAZY_ASSETS - fixedExactAssetCount ) { throw new Error( `Homebrew browser lifecycle fixture exceeds ` + @@ -515,6 +558,26 @@ function projectBottleMirror(value: unknown): }; } +function projectLoginProduct( + value: unknown, +): NonNullable { + if (!isRecord(value) || !hasExactKeys(value, LOGIN_PRODUCT_KEYS)) { + throw new Error( + "Homebrew browser lifecycle login product has unknown or missing fields", + ); + } + return { + compositionReport: projectExactAsset( + value.compositionReport, + "login product composition report", + ), + privilegedProduct: projectExactAsset( + value.privilegedProduct, + "serialized privileged product", + ), + }; +} + function projectBottlePayload( value: unknown, index: number, diff --git a/host/src/homebrew-runtime-support.ts b/host/src/homebrew-runtime-support.ts index 0a60c756c7..684396a05a 100644 --- a/host/src/homebrew-runtime-support.ts +++ b/host/src/homebrew-runtime-support.ts @@ -24,6 +24,21 @@ export interface HomebrewRuntimeSupportContract { capability: "homebrew:runtime"; root: "/usr/bin/brew"; atomicGroup: typeof RUNTIME_ID; + requiredKernelAbi: number; + }; + availability: { + provenance: { + schema: 1; + provenance_kind: "local-test"; + promotable: false; + published: false; + }; + auditedCatalog: { + checkoutCommit: string; + kandeloAbi: number; + releaseTag: string; + requiredArch: "wasm32"; + }; }; deferredRelocationFormulae: string[]; lifecycleInstall: { @@ -79,6 +94,7 @@ export function parseHomebrewRuntimeSupportContract( const additionalFormulaOrder = formulaArray( root.additional_formula_order, "Homebrew runtime-support additional Formula order", + true, ); if ( JSON.stringify(additionalFormulaOrder) !== @@ -114,7 +130,12 @@ export function parseHomebrewRuntimeSupportContract( activation.mode !== "first-use-atomic" || JSON.stringify(activation.roots) !== JSON.stringify(["/usr/bin/brew"]) || activation.capability !== "homebrew:runtime" || - activation.base_image_default !== "deferred" + activation.base_image_default !== "deferred" || + !record( + activation.bootstrap_package, + "Homebrew runtime-support bootstrap package", + ) || + activation.bootstrap_package.required_kernel_abi !== 43 ) { throw new Error( "Homebrew runtime support must be one deferred atomic /usr/bin/brew activation", @@ -161,25 +182,40 @@ export function parseHomebrewRuntimeSupportContract( root.availability, "Homebrew runtime-support availability", ); + const provenance = record( + availability.provenance, + "Homebrew runtime-support provenance", + ); + if ( + Object.keys(provenance).sort().join("\0") !== + "promotable\0provenance_kind\0published\0schema" || + provenance.schema !== 1 || + provenance.provenance_kind !== "local-test" || + provenance.promotable !== false || + provenance.published !== false + ) { + throw new Error( + "Homebrew runtime support must carry exact local-test provenance", + ); + } const audited = record( availability.audited_catalog, "Homebrew runtime-support audited catalog", ); if ( audited.checkout_commit !== tapCommit || - audited.kandelo_abi !== 42 || + audited.kandelo_abi !== 43 || audited.required_arch !== "wasm32" || - audited.release_tag !== "bottles-abi-v42" || - !GIT_SHA_RE.test(String(audited.kandelo_commit)) || - !GIT_SHA_RE.test(String(audited.metadata_tap_commit)) || - !SHA256_RE.test(String(audited.metadata_sha256)) || + audited.release_tag !== "bottles-abi-v43" || + JSON.stringify(availability.local_test_formulae) !== + JSON.stringify(baseFormulaOrder) || JSON.stringify(availability.requires_rebuild) !== "[]" || JSON.stringify(availability.missing_metadata) !== "[]" || JSON.stringify(availability.can_be_deferred) !== JSON.stringify(deferredRelocationFormulae) ) { throw new Error( - "Homebrew runtime-support availability is not a complete admitted ABI-42 closure", + "Homebrew runtime-support availability is not a complete local ABI-43 closure", ); } @@ -224,6 +260,21 @@ export function parseHomebrewRuntimeSupportContract( capability: "homebrew:runtime", root: "/usr/bin/brew", atomicGroup: RUNTIME_ID, + requiredKernelAbi: 43, + }, + availability: { + provenance: { + schema: 1, + provenance_kind: "local-test", + promotable: false, + published: false, + }, + auditedCatalog: { + checkoutCommit: tapCommit, + kandeloAbi: 43, + releaseTag: "bottles-abi-v43", + requiredArch: "wasm32", + }, }, deferredRelocationFormulae, lifecycleInstall: { @@ -252,7 +303,7 @@ export function assertHomebrewRuntimeSupportPlan( basePlan.kandeloCommit !== supportPlan.kandeloCommit || JSON.stringify(baseOrder) !== JSON.stringify(contract.baseFormulaOrder) || JSON.stringify(supportOrder) !== JSON.stringify(contract.formulaOrder) || - supportPlan.kandeloAbi !== 42 || + supportPlan.kandeloAbi !== 43 || supportPlan.packages.some( (pkg) => pkg.arch !== "wasm32" || @@ -298,9 +349,15 @@ function record(value: unknown, label: string): Record { return value as Record; } -function formulaArray(value: unknown, label: string): string[] { - if (!Array.isArray(value) || value.length === 0) { - throw new Error(`${label} must be a nonempty array`); +function formulaArray( + value: unknown, + label: string, + allowEmpty = false, +): string[] { + if (!Array.isArray(value) || (!allowEmpty && value.length === 0)) { + throw new Error( + `${label} must be ${allowEmpty ? "an" : "a nonempty"} array`, + ); } const result = value.map((entry, index) => formula(entry, `${label} ${index}`), diff --git a/host/src/homebrew-vfs-builder.ts b/host/src/homebrew-vfs-builder.ts index cf581b8475..c7e69d89c4 100644 --- a/host/src/homebrew-vfs-builder.ts +++ b/host/src/homebrew-vfs-builder.ts @@ -71,6 +71,313 @@ const MAX_MIGRATION_LOCK_BYTES = 65_536; const MAX_RUNTIME_STATE_TEXT_BYTES = 65_536; const MAX_RUNTIME_STATE_ID = 0x7fff_ffff; +const MAX_LOCAL_TEST_TAP_BUNDLE_BYTES = 32 * 1024 * 1024; + +export const LOCAL_TEST_HOMEBREW_TAP_BUNDLE_PATH = + "/opt/kandelo/homebrew/var/kandelo/local-test/homebrew-tap-core.bundle"; + +export interface LocalTestHomebrewTapBundleBinding { + path: typeof LOCAL_TEST_HOMEBREW_TAP_BUNDLE_PATH; + sha256: string; + bytes: number; + source_commit: string; + prepared_commit: string; +} +export interface LocalTestHomebrewProvenance { + schema: 1; + provenance_kind: "local-test"; + promotable: false; + published: false; +} + +export interface LocalTestPrivilegedProgramTemplate { + schema: 1; + formula: string; + source_path: string; + destination_path: string; + uid: 0; + gid: 0; + mode: number; + mount_point: "trusted-root-product"; +} + +/** Stage the exact final local tap without presenting it as a remote catalog. */ +export function installLocalTestHomebrewTapBundle( + fs: MemoryFileSystem, + value: Uint8Array, + commits: { sourceCommit: string; preparedCommit: string }, +): LocalTestHomebrewTapBundleBinding { + const bytes = Uint8Array.from(value); + if ( + bytes.byteLength === 0 || + bytes.byteLength > MAX_LOCAL_TEST_TAP_BUNDLE_BYTES || + !GIT_SHA_RE.test(commits.sourceCommit) || + !GIT_SHA_RE.test(commits.preparedCommit) || + commits.sourceCommit === commits.preparedCommit + ) { + throw new HomebrewVfsBuildError( + "local-test tap bundle identity is invalid", + ); + } + if (tryLstat(fs, LOCAL_TEST_HOMEBREW_TAP_BUNDLE_PATH) !== null) { + throw new HomebrewVfsBuildError( + "refusing to replace the local-test Homebrew tap bundle", + ); + } + const ownerRoot = "/opt/kandelo/homebrew/var/kandelo"; + const bundleRoot = `${ownerRoot}/local-test`; + ensureDirRecursive(fs, ownerRoot); + fs.mkdirWithOwner(bundleRoot, 0o555, 0, 0); + fs.createFileWithOwner( + LOCAL_TEST_HOMEBREW_TAP_BUNDLE_PATH, + 0o444, + 0, + 0, + bytes, + ); + const binding: LocalTestHomebrewTapBundleBinding = { + path: LOCAL_TEST_HOMEBREW_TAP_BUNDLE_PATH, + sha256: sha256(bytes), + bytes: bytes.byteLength, + source_commit: commits.sourceCommit, + prepared_commit: commits.preparedCommit, + }; + assertLocalTestHomebrewTapBundle(fs, binding); + return binding; +} + +/** Fail before guest lifecycle boot if the composed local tap bytes changed. */ +export function assertLocalTestHomebrewTapBundle( + fs: MemoryFileSystem, + binding: LocalTestHomebrewTapBundleBinding, +): void { + let stat: StatResult; + try { + stat = fs.lstat(binding.path); + } catch { + throw new HomebrewVfsBuildError( + "local-test Homebrew tap bundle is missing", + ); + } + if ( + binding.path !== LOCAL_TEST_HOMEBREW_TAP_BUNDLE_PATH || + !SHA256_RE.test(binding.sha256) || + !Number.isSafeInteger(binding.bytes) || + binding.bytes <= 0 || + binding.bytes > MAX_LOCAL_TEST_TAP_BUNDLE_BYTES || + !GIT_SHA_RE.test(binding.source_commit) || + !GIT_SHA_RE.test(binding.prepared_commit) || + binding.source_commit === binding.prepared_commit || + kind(stat) !== S_IFREG || + (stat.mode & MODE_BITS) !== 0o444 || + stat.uid !== 0 || + stat.gid !== 0 || + stat.size !== binding.bytes + ) { + throw new HomebrewVfsBuildError( + "local-test Homebrew tap bundle changed identity", + ); + } + const fd = fs.open(binding.path, 0, 0); + try { + const bytes = new Uint8Array(binding.bytes); + let offset = 0; + while (offset < bytes.byteLength) { + const read = fs.read(fd, bytes.subarray(offset), null, bytes.length - offset); + if (!Number.isInteger(read) || read <= 0) { + throw new HomebrewVfsBuildError( + "local-test Homebrew tap bundle changed identity", + ); + } + offset += read; + } + if (sha256(bytes) !== binding.sha256) { + throw new HomebrewVfsBuildError( + "local-test Homebrew tap bundle changed identity", + ); + } + } finally { + fs.close(fd); + } +} + +export function projectLocalTestHomebrewTapBundleBinding( + value: unknown, +): LocalTestHomebrewTapBundleBinding { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new HomebrewVfsBuildError( + "local-test Homebrew tap bundle binding is invalid", + ); + } + const record = value as Record; + if ( + Object.keys(record).sort().join("\0") !== + [ + "bytes", + "path", + "prepared_commit", + "sha256", + "source_commit", + ].join("\0") || + record.path !== LOCAL_TEST_HOMEBREW_TAP_BUNDLE_PATH || + typeof record.sha256 !== "string" || + !SHA256_RE.test(record.sha256) || + typeof record.bytes !== "number" || + !Number.isSafeInteger(record.bytes) || + record.bytes <= 0 || + record.bytes > MAX_LOCAL_TEST_TAP_BUNDLE_BYTES || + typeof record.source_commit !== "string" || + !GIT_SHA_RE.test(record.source_commit) || + typeof record.prepared_commit !== "string" || + !GIT_SHA_RE.test(record.prepared_commit) || + record.source_commit === record.prepared_commit + ) { + throw new HomebrewVfsBuildError( + "local-test Homebrew tap bundle binding is invalid", + ); + } + return { + path: LOCAL_TEST_HOMEBREW_TAP_BUNDLE_PATH, + sha256: record.sha256, + bytes: record.bytes, + source_commit: record.source_commit, + prepared_commit: record.prepared_commit, + }; +} + +/** + * Admit non-promotable provenance only at the explicit local evidence path. + * Callers outside this boundary must reject the marker rather than silently + * treating local bytes as published sidecars. + */ +export function assertLocalTestHomebrewProvenance( + value: unknown, + boundary: { localHarness: boolean; reviewPendingArtifact: boolean }, +): asserts value is LocalTestHomebrewProvenance { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new HomebrewVfsBuildError("local-test provenance must be an object"); + } + const record = value as Record; + const keys = Object.keys(record).sort(); + if ( + keys.join("\0") !== + ["promotable", "provenance_kind", "published", "schema"].join("\0") || + record.schema !== 1 || + record.provenance_kind !== "local-test" || + record.promotable !== false || + record.published !== false + ) { + throw new HomebrewVfsBuildError( + "local-test provenance must be exact and not promotable or published", + ); + } + if (!boundary.localHarness) { + throw new HomebrewVfsBuildError( + "local-test provenance is restricted to the local harness", + ); + } + if (!boundary.reviewPendingArtifact) { + throw new HomebrewVfsBuildError( + "local-test provenance requires the review-pending artifact boundary", + ); + } +} + +/** Bind reviewed path policy to the exact local bottle and Wasm digests. */ +export function createLocalTestPrivilegedProgramProjections( + value: unknown, + identities: { + bottleDigests: Record; + artifactDigests: Record; + }, +): Array<{ + schema: 1; + formula: string; + bottleSha256: string; + sourcePath: string; + destinationPath: string; + uid: 0; + gid: 0; + mode: number; + mountPoint: "trusted-root-product"; + artifactValidationSha256: string; +}> { + if (!Array.isArray(value) || value.length !== 3) { + throw new HomebrewVfsBuildError( + "local privileged program policy must contain exactly three programs", + ); + } + return value.map((entry, index) => { + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) { + throw new HomebrewVfsBuildError( + `local privileged program ${index} is invalid`, + ); + } + const template = entry as Record; + const keys = Object.keys(template).sort().join("\0"); + const expectedKeys = [ + "destination_path", + "formula", + "gid", + "mode", + "mount_point", + "schema", + "source_path", + "uid", + ].join("\0"); + if (keys !== expectedKeys) { + throw new HomebrewVfsBuildError( + `local privileged program ${index} must use the closed template schema`, + ); + } + const formula = template.formula; + if ( + typeof formula !== "string" || + !/^kandelo-dev\/tap-core\/[a-z0-9-]+$/.test(formula) + ) { + throw new HomebrewVfsBuildError( + `local privileged program ${index} formula is invalid`, + ); + } + const name = formula.split("/").at(-1)!; + const bottleSha256 = identities.bottleDigests[name]; + const artifactValidationSha256 = identities.artifactDigests[name]; + if ( + !SHA256_RE.test(bottleSha256 ?? "") || + !SHA256_RE.test(artifactValidationSha256 ?? "") + ) { + throw new HomebrewVfsBuildError( + `local privileged program ${formula} lacks exact bottle or artifact evidence`, + ); + } + if ( + template.schema !== 1 || + typeof template.source_path !== "string" || + typeof template.destination_path !== "string" || + template.uid !== 0 || + template.gid !== 0 || + template.mode !== 0o4755 || + template.mount_point !== "trusted-root-product" + ) { + throw new HomebrewVfsBuildError( + `local privileged program ${formula} violates the reviewed projection policy`, + ); + } + return { + schema: 1, + formula, + bottleSha256, + sourcePath: template.source_path, + destinationPath: template.destination_path, + uid: 0, + gid: 0, + mode: 0o4755, + mountPoint: "trusted-root-product" as const, + artifactValidationSha256, + }; + }); +} + export class HomebrewVfsBuildError extends Error { constructor(message: string) { super(message); diff --git a/host/src/node-kernel-host.ts b/host/src/node-kernel-host.ts index 1e4830f56b..b68383d00d 100644 --- a/host/src/node-kernel-host.ts +++ b/host/src/node-kernel-host.ts @@ -46,6 +46,10 @@ import { import { awaitGracefulKernelRealmDestroy } from "./kernel-realm-destroy"; import { FILE_MODES } from "./generated/abi"; import type { NodeSessionSeedTree } from "./vfs/default-mounts-node"; +import { + snapshotPublishedPrivilegedProgramBrowserMount, + type PublishedPrivilegedProgramProduct, +} from "./vfs/privileged-projection"; export type { HttpRequest, HttpResponse }; @@ -144,8 +148,8 @@ export interface NodeKernelHostOptions { * to a VFS-only world yet. */ rootfsImage?: "default" | ArrayBuffer | Uint8Array; - /** Exact image/scratch mount contract. Requires `rootfsImage`. */ - rootfsMountSpec?: readonly MountSpec[]; + /** Publisher-admitted peer of BrowserKernel's trusted `/usr/bin` product. */ + privilegedProduct?: PublishedPrivilegedProgramProduct; /** * Resolve relative lazy URLs embedded in rootfsImage before transport. * This is the Node peer of BrowserKernel's lazyUrlBase contract. @@ -231,6 +235,15 @@ export class NodeKernelHost { if (this.kernelFatalError !== null) throw this.kernelFatalError; const wasmBytes = kernelWasmBytes ?? loadKernelWasm(); const rootfsImage = resolveRootfsImage(this.options.rootfsImage); + const privilegedProgramMount = + this.options.privilegedProduct === undefined + ? undefined + : snapshotPublishedPrivilegedProgramBrowserMount( + this.options.privilegedProduct, + ); + if (privilegedProgramMount !== undefined && rootfsImage === null) { + throw new Error("privilegedProduct requires rootfsImage"); + } if (this.options.rootfsLazyAssets !== undefined && rootfsImage === null) { throw new Error("rootfsLazyAssets requires rootfsImage"); } @@ -406,9 +419,15 @@ export class NodeKernelHost { execPrograms: this.options.execPrograms, execProgramBytes, rootfsImage: rootfsImage ?? undefined, - rootfsMountSpec: this.options.rootfsMountSpec === undefined - ? undefined - : this.options.rootfsMountSpec.map((mount) => ({ ...mount })), + ...(privilegedProgramMount === undefined + ? {} + : { + privilegedProgramMount: { + kind: "published-privileged-program-product" as const, + mountPoint: privilegedProgramMount.mountPoint, + imageBytes: privilegedProgramMount.imageBytes, + }, + }), rootfsLazyUrlBase: this.options.rootfsLazyUrlBase, rootfsLazyAssets, rootfsLazyAssetSources, @@ -420,6 +439,9 @@ export class NodeKernelHost { ...(rootfsLazyAssets ?? []).map( (asset) => asset.bytes.buffer as ArrayBuffer, ), + ...(privilegedProgramMount === undefined + ? [] + : [privilegedProgramMount.imageBytes.buffer as ArrayBuffer]), ...new Set(Object.values(execProgramBytes ?? {})), ]; this.worker.postMessage(initMsg, transfer); diff --git a/host/src/node-kernel-protocol.ts b/host/src/node-kernel-protocol.ts index be3b485482..b012813761 100644 --- a/host/src/node-kernel-protocol.ts +++ b/host/src/node-kernel-protocol.ts @@ -56,8 +56,11 @@ export interface InitMessage { * (custom-io / legacy path). */ rootfsImage?: ArrayBuffer; - /** Exact image/scratch mount contract. Absent preserves the host default. */ - rootfsMountSpec?: MountSpec[]; + privilegedProgramMount?: { + kind: "published-privileged-program-product"; + mountPoint: "/usr/bin"; + imageBytes: Uint8Array; + }; /** Base used to resolve relative lazy URLs embedded in rootfsImage. */ rootfsLazyUrlBase?: string; /** Exhaustive exact-byte lazy transport for this rootfs; no network fallback. */ diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts index 197f13393c..7e2761cc39 100644 --- a/host/src/node-kernel-worker-entry.ts +++ b/host/src/node-kernel-worker-entry.ts @@ -50,6 +50,8 @@ import { createClosedLazyAssetSourceFetcher, } from "./vfs/closed-lazy-assets"; import { resolveLazyUrl } from "./vfs/lazy-url"; +import { createImmutableProductBackend } from "./vfs/memory-fs"; +import { restoreVerifiedVfsImage } from "./vfs/load-image"; import { TcpNetworkBackend } from "./networking/tcp-backend"; import { findRepoRoot } from "./binary-resolver"; import { NodeWorkerAdapter } from "./worker-adapter"; @@ -922,7 +924,7 @@ async function buildVirtualPlatformIO( sessionSeedTrees?: InitMessage["sessionSeedTrees"], rootfsLazyUrlBase?: InitMessage["rootfsLazyUrlBase"], rootfsLazyAssets?: InitMessage["rootfsLazyAssets"], - rootfsLazyAssetSources?: InitMessage["rootfsLazyAssetSources"], + privilegedProgramMount?: InitMessage["privilegedProgramMount"], ): Promise { const bootSessionDir = mkdtempSync(join(tmpdir(), "wasm-posix-session-")); sessionDir = bootSessionDir; @@ -933,7 +935,12 @@ async function buildVirtualPlatformIO( new Uint8Array(rootfsImage), bootSessionDir, sessionSeedTrees, - (extraMounts ?? []).map((mount) => mount.mountPoint), + [ + ...(extraMounts ?? []).map((mount) => mount.mountPoint), + ...(privilegedProgramMount === undefined + ? [] + : [privilegedProgramMount.mountPoint]), + ], ); } catch (error) { // WHY: imported-seal rejection occurs before scratch setup, but the Node @@ -953,10 +960,26 @@ async function buildVirtualPlatformIO( }), readonly: m.readonly, })); + const privilegedMount: MountConfig | undefined = + privilegedProgramMount?.kind === "published-privileged-program-product" + ? { + mountPoint: "/usr/bin", + backend: createImmutableProductBackend( + await restoreVerifiedVfsImage(privilegedProgramMount.imageBytes), + ), + readonly: true, + setIdCapability: { + kind: "trusted-root-product", + guestWritable: false, + stableExecutableIdentity: true, + }, + } + : undefined; const mounts = [ { mountPoint: "/dev/shm", backend: shmfs }, { mountPoint: "/dev", backend: new DeviceFileSystem() }, ...specMounts, + ...(privilegedMount === undefined ? [] : [privilegedMount]), ...extras, ]; const rootMount = mounts.find((m) => m.mountPoint === "/"); @@ -1041,7 +1064,7 @@ async function handleInit(msg: InitMessage) { msg.sessionSeedTrees, msg.rootfsLazyUrlBase, msg.rootfsLazyAssets, - msg.rootfsLazyAssetSources, + msg.privilegedProgramMount, ) : new NodePlatformIO(); vfsExecIO = msg.rootfsImage ? io : null; diff --git a/host/src/vfs/privileged-projection.ts b/host/src/vfs/privileged-projection.ts index 0d5d8c598f..ab4ab09930 100644 --- a/host/src/vfs/privileged-projection.ts +++ b/host/src/vfs/privileged-projection.ts @@ -260,14 +260,14 @@ export function snapshotPublishedPrivilegedProgramBrowserMount( } /** - * Compare a writable image file with the exact projection admitted for a - * privately branded product. This conveys no mount capability and is absent - * from public barrels; browser product loaders use it to reject stale staged - * destinations before granting a terminal policy. + * Compare a file in the final composed namespace with the exact projection + * admitted for a privately branded product. This conveys no mount capability + * and is absent from public barrels; product loaders use it before granting a + * terminal policy. */ export async function publishedPrivilegedProgramMatchesFile( product: PublishedPrivilegedProgramProduct, - fs: MemoryFileSystem, + fs: FileSystemBackend, destinationPath: string, ): Promise { const projections = publishedProductProjections.get(product); diff --git a/host/test/homebrew-login-product.test.ts b/host/test/homebrew-login-product.test.ts new file mode 100644 index 0000000000..45a25e21a6 --- /dev/null +++ b/host/test/homebrew-login-product.test.ts @@ -0,0 +1,1069 @@ +import { createHash } from "node:crypto"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { describe, expect, it } from "vitest"; +import { + assertLocalTestHomebrewTapBundle, + assertLocalTestHomebrewProvenance, + createLocalTestPrivilegedProgramProjections, + installLocalTestHomebrewTapBundle, + LOCAL_TEST_HOMEBREW_TAP_BUNDLE_PATH, +} from "../src/homebrew-vfs-builder"; +import { MemoryFileSystem } from "../src/vfs/memory-fs"; +import { parsePrivilegedProgramProjections } from "../src/vfs/privileged-projection"; +import { appendProcessTreeRssSample } from "../../scripts/measure-homebrew-vfork-rss"; + +const repositoryRoot = resolve(import.meta.dirname, "../.."); + +function readJson(path: string): any { + return JSON.parse(readFileSync(resolve(repositoryRoot, path), "utf8")); +} + +describe("ABI 43 Homebrew login product", () => { + it("selects the login, sudo, Ruby, and shell roots", () => { + const brewfile = readFileSync( + resolve(repositoryRoot, "homebrew/main-shell.Brewfile"), + "utf8", + ); + for (const formula of ["login", "sudo-lite", "sudo", "ruby", "bash"]) { + expect(brewfile).toContain(`brew "kandelo-dev/tap-core/${formula}"`); + } + + const lock = readJson("homebrew/main-shell-migration-lock.json"); + expect(lock.catalog.tap_commit).toBe( + "af70e3ba06367dbafb8a95fabbacc3e1352b58b2", + ); + expect(lock.packages.map((entry: any) => entry.formula.name)).toEqual( + expect.arrayContaining(["login", "sudo-lite", "sudo", "ruby", "bash"]), + ); + }); + + it("keeps the ordinary prefix nosuid and closes the trusted projection group", () => { + const lock = readJson("homebrew/main-shell-migration-lock.json"); + expect(lock.product.ordinary_prefix_mount).toEqual({ nosuid: true }); + expect(lock.product.registry_bridge).toBe(false); + + const artifactDigests = { + login: createHash("sha256").update("login").digest("hex"), + "sudo-lite": createHash("sha256").update("sudo-lite").digest("hex"), + sudo: createHash("sha256").update("sudo").digest("hex"), + }; + const bottleDigests = { + login: "1".repeat(64), + "sudo-lite": "2".repeat(64), + sudo: "3".repeat(64), + }; + const projections = createLocalTestPrivilegedProgramProjections( + lock.product.privileged_programs, + { artifactDigests, bottleDigests }, + ); + expect(() => parsePrivilegedProgramProjections(projections)).not.toThrow(); + expect(projections.map((entry) => entry.destinationPath)).toEqual([ + "/usr/bin/login", + "/usr/bin/sudo-lite", + "/usr/bin/sudo", + ]); + for (const projection of projections) { + expect(projection).toMatchObject({ + uid: 0, + gid: 0, + mode: 0o4755, + mountPoint: "trusted-root-product", + }); + } + }); + + it("admits local-test provenance only at the review-pending local boundary", () => { + const provenance = { + schema: 1, + provenance_kind: "local-test", + promotable: false, + published: false, + }; + expect(() => + assertLocalTestHomebrewProvenance(provenance, { + localHarness: true, + reviewPendingArtifact: true, + }), + ).not.toThrow(); + expect(() => + assertLocalTestHomebrewProvenance(provenance, { + localHarness: false, + reviewPendingArtifact: true, + }), + ).toThrow(/local harness/i); + expect(() => + assertLocalTestHomebrewProvenance(provenance, { + localHarness: true, + reviewPendingArtifact: false, + }), + ).toThrow(/review-pending/i); + expect(() => + assertLocalTestHomebrewProvenance( + { ...provenance, promotable: true }, + { localHarness: true, reviewPendingArtifact: true }, + ), + ).toThrow(/not promotable/i); + }); + + it("rejects local-test sidecars before the publisher mutates a tap", () => { + const root = mkdtempSync(resolve(tmpdir(), "kandelo-local-sidecar-")); + try { + writeFileSync( + resolve(root, "local-test-provenance.json"), + `${JSON.stringify({ + schema: 1, + provenance_kind: "local-test", + promotable: false, + published: false, + })}\n`, + ); + const result = spawnSync( + "bash", + [ + resolve(repositoryRoot, "scripts/homebrew-publish-sidecars.sh"), + "--tap-root", + resolve(root, "must-not-be-created"), + "--release-tag", + "bottles-abi-v43", + "--status", + "success", + "--formula", + "login", + "--arch", + "wasm32", + "--sidecar-root", + root, + ], + { encoding: "utf8" }, + ); + expect(result.status).toBe(1); + expect(result.stderr).toMatch( + /local-test provenance is not publishable/i, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects local provenance at selection finalization before tap reads", () => { + const root = mkdtempSync(resolve(tmpdir(), "kandelo-local-finalizer-")); + try { + const result = spawnSync( + "python3", + [ + resolve( + repositoryRoot, + "scripts/finalize-homebrew-main-shell-release.py", + ), + "--source-root", + repositoryRoot, + "--tap-root", + root, + ], + { encoding: "utf8" }, + ); + expect(result.status).toBe(1); + expect(result.stderr).toMatch( + /local-test provenance is not promotable or selectable/i, + ); + expect(result.stderr).not.toMatch(/tap root|tap checkout/i); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("keeps Ruby pristine and requires the installed executable inventory audit", () => { + const lock = readJson("homebrew/main-shell-migration-lock.json"); + expect(lock.product.ruby).toEqual({ + source_policy: "pristine-upstream", + forbid_source_patches: true, + forbid_ac_cv_func_vfork_no: true, + required_config_defines: [ + "HAVE_VFORK", + "HAVE_WORKING_VFORK", + "HAVE_WORKING_FORK", + ], + required_stock_executables: [ + "bundle", + "bundler", + "erb", + "gem", + "irb", + "minitest", + "rake", + "rdoc", + "ri", + "ruby", + "syntax_suggest", + "test-unit", + "typeprof", + ], + instrumented_executable: "ruby", + }); + const builder = readFileSync( + resolve(repositoryRoot, "scripts/homebrew-bottle-build.sh"), + "utf8", + ); + expect(builder).toContain("instrumented-ruby.wasm"); + expect(builder).toContain("ruby-runtime.zip"); + const harness = readFileSync( + resolve(repositoryRoot, "scripts/run-login-stack-local.sh"), + "utf8", + ); + expect(harness).toContain("ruby-installed-inventory.json"); + expect(harness).toContain("runtime_archive_executables"); + expect(harness).toContain("normal-upstream-install"); + expect(harness).toContain("make install failed, copying lib manually"); + expect(harness).toContain("required_stock_executables"); + expect(harness).toContain("instrumented-ruby.wasm"); + }); + + it("keeps the local harness complete, detached, and non-promotable", () => { + const harness = readFileSync( + resolve(repositoryRoot, "scripts/run-login-stack-local.sh"), + "utf8", + ); + expect(harness).toContain("git worktree add --detach"); + expect(harness).not.toContain( + 'git -C "$KANDELO_LOGIN_TAP_ROOT" worktree add', + ); + expect(harness).toMatch( + /git clone --no-local --no-checkout\s*\\?\s*"\$KANDELO_LOGIN_TAP_ROOT"/, + ); + expect(harness).toContain("formula_closure[]"); + expect(harness).toContain("homebrew-bottle-build.sh"); + expect(harness).toContain("KANDELO_HOMEBREW_PROVENANCE_KIND=local-test"); + expect(harness).toContain("--review-pending-artifact"); + expect(harness).toContain("--project=chromium"); + expect(harness).toContain("--project=firefox"); + expect(harness).toContain("--project=webkit"); + expect(harness).toContain("local-test-provenance.json"); + expect(harness).not.toContain("GITHUB_ACTIONS=true"); + }); + + it("runs every local bottle in the protected Formula and recipe identities", () => { + const harness = readFileSync( + resolve(repositoryRoot, "scripts/run-login-stack-local.sh"), + "utf8", + ); + expect(harness).toContain( + 'KANDELO_LOGIN_BUILD_USER="kandelo-homebrew-build"', + ); + expect(harness).toContain( + 'KANDELO_LOGIN_RECIPE_USER="kandelo-homebrew-recipe"', + ); + expect(harness).toMatch( + /for reserved_user in "\$KANDELO_LOGIN_BUILD_USER" "\$KANDELO_LOGIN_RECIPE_USER"; do[\s\S]*fail "reserved Homebrew identity already exists:/, + ); + expect(harness).toContain( + 'KANDELO_HOMEBREW_BUILD_USER="$KANDELO_LOGIN_BUILD_USER"', + ); + expect(harness).toContain( + 'KANDELO_HOMEBREW_RECIPE_USER="$KANDELO_LOGIN_RECIPE_USER"', + ); + expect(harness).toContain( + 'KANDELO_HOMEBREW_SHARED_TEMP="$KANDELO_LOGIN_SHARED_TEMP"', + ); + expect(harness).toContain('KANDELO_HOMEBREW_SUDO_BIN=/usr/bin/sudo'); + expect(harness).toContain( + 'KANDELO_HOMEBREW_SYSTEMD_RUN_BIN=/usr/bin/systemd-run', + ); + expect(harness).toContain( + 'WASM_POSIX_XTASK_BIN="$KANDELO_LOGIN_XTASK_BIN"', + ); + expect(harness.indexOf("seal-homebrew-formula-checker.sh")).toBeLessThan( + harness.indexOf('mapfile -t KANDELO_LOGIN_FORMULAE'), + ); + expect(harness).toContain("build-deps program-index-selected"); + expect(harness).toMatch( + /formula_isolation_env=\([\s\S]*HOMEBREW_CACHE="\$formula_host_cache"[\s\S]*HOMEBREW_TEMP="\$formula_host_temp"[\s\S]*KANDELO_HOMEBREW_BUILD_USER=/, + ); + }); + + it("shares one exact protected isolation environment with build and verification", () => { + const harness = readFileSync( + resolve(repositoryRoot, "scripts/run-login-stack-local.sh"), + "utf8", + ); + const environment = harness.match( + /formula_isolation_env=\(([\s\S]*?)\n \)/, + )?.[1]; + expect(environment).toBeDefined(); + expect(environment).not.toContain("KANDELO_HOMEBREW_LOCAL_BUILD_EVIDENCE"); + for (const assignment of [ + 'HOMEBREW_CACHE="$formula_host_cache"', + 'HOMEBREW_TEMP="$formula_host_temp"', + 'KANDELO_HOMEBREW_BUILD_USER="$KANDELO_LOGIN_BUILD_USER"', + 'KANDELO_HOMEBREW_RECIPE_USER="$KANDELO_LOGIN_RECIPE_USER"', + 'KANDELO_HOMEBREW_SHARED_TEMP="$KANDELO_LOGIN_SHARED_TEMP"', + "KANDELO_HOMEBREW_SUDO_BIN=/usr/bin/sudo", + "KANDELO_HOMEBREW_SYSTEMD_RUN_BIN=/usr/bin/systemd-run", + "KANDELO_HOMEBREW_SYSTEMCTL_BIN=/usr/bin/systemctl", + "KANDELO_HOMEBREW_GETENT_BIN=/usr/bin/getent", + "KANDELO_HOMEBREW_PGREP_BIN=/usr/bin/pgrep", + "KANDELO_HOMEBREW_PKILL_BIN=/usr/bin/pkill", + 'WASM_POSIX_XTASK_BIN="$KANDELO_LOGIN_XTASK_BIN"', + ]) { + expect(environment).toContain(assignment); + } + expect(harness).toMatch( + /env "\$\{formula_isolation_env\[@\]\}" "\$\{formula_build_evidence_env\[@\]\}" \\\n+ bash scripts\/homebrew-bottle-build\.sh/, + ); + expect(harness).toMatch( + /env "\$\{formula_isolation_env\[@\]\}" \\\n+ KANDELO_HOMEBREW_LOCAL_DEPENDENCY_CACHE=[\s\S]*?bash scripts\/homebrew-verify-poured-bottle\.sh/, + ); + expect(harness).not.toContain("builder_env=("); + }); + + it("provisions one protected Formula browser cache before every build", () => { + const harness = readFileSync( + resolve(repositoryRoot, "scripts/run-login-stack-local.sh"), + "utf8", + ); + const provision = harness.indexOf( + "bash scripts/homebrew-provision-formula-browser.sh", + ); + const formulaLoop = harness.indexOf( + 'for full_name in "${KANDELO_LOGIN_FORMULAE[@]}"; do', + ); + expect(provision).toBeGreaterThan( + harness.indexOf("npm --prefix apps/browser-demos ci"), + ); + expect(provision).toBeLessThan(formulaLoop); + expect(harness.slice(provision, formulaLoop)).toContain( + '--shared-temp "$KANDELO_LOGIN_SHARED_TEMP"', + ); + expect(harness.slice(provision, formulaLoop)).toContain( + '--build-user "$KANDELO_LOGIN_BUILD_USER"', + ); + expect(harness.slice(provision, formulaLoop)).toContain( + '--sudo-bin /usr/bin/sudo', + ); + expect(harness).toContain( + 'PLAYWRIGHT_BROWSERS_PATH="$KANDELO_LOGIN_SHARED_TEMP/ms-playwright"', + ); + }); + + it("retires only the source-built target before local bottle verification", () => { + const harness = readFileSync( + resolve(repositoryRoot, "scripts/run-login-stack-local.sh"), + "utf8", + ); + const builder = readFileSync( + resolve(repositoryRoot, "scripts/homebrew-bottle-build.sh"), + "utf8", + ); + const buildCall = harness.indexOf("bash scripts/homebrew-bottle-build.sh"); + const verifyCall = harness.indexOf( + "bash scripts/homebrew-verify-poured-bottle.sh", + ); + expect(buildCall).toBeGreaterThan(0); + expect(verifyCall).toBeGreaterThan(buildCall); + expect(harness.slice(buildCall, verifyCall)).toContain( + "--retire-source-install", + ); + const retireCall = builder.indexOf( + "homebrew_patched_launcher_retire_source_target", + ); + const teardown = builder.indexOf("homebrew_patched_launcher_teardown"); + expect(retireCall).toBeGreaterThan(0); + expect(retireCall).toBeLessThan(teardown); + expect(builder.slice(retireCall, teardown)).not.toContain("rm -rf"); + }); + + it("prebuilds the exact rootfs before the fetch-only Formula runtime projection", () => { + const harness = readFileSync( + resolve(repositoryRoot, "scripts/run-login-stack-local.sh"), + "utf8", + ); + const sourceBuild = harness.indexOf("--force-source-build resolve rootfs"); + const fetchOnly = harness.indexOf( + "for package in dash coreutils grep sed rootfs; do", + ); + expect(sourceBuild).toBeGreaterThan(harness.indexOf("bash build.sh")); + expect(sourceBuild).toBeLessThan(fetchOnly); + expect(harness.slice(fetchOnly)).toContain("--fetch-only resolve"); + expect(harness).toContain( + 'cmp "$KANDELO_LOGIN_SOURCE/host/wasm/rootfs.vfs" \\\n' + + ' "$KANDELO_LOGIN_SOURCE/binaries/programs/wasm32/rootfs.vfs"', + ); + expect(harness).toContain( + 'dash,coreutils,grep,sed,rootfs "$KANDELO_LOGIN_FORMULA_TEST_INDEX"', + ); + }); + + it("stages the workflow-equivalent admitted kernel before Formula tests", () => { + const harness = readFileSync( + resolve(repositoryRoot, "scripts/run-login-stack-local.sh"), + "utf8", + ); + const platformBuild = harness.indexOf("bash build.sh"); + const kernelBuild = harness.indexOf( + "bash packages/registry/kernel/build-kernel.sh", + ); + const formulaIndex = harness.indexOf( + 'KANDELO_LOGIN_FORMULA_TEST_INDEX="$KANDELO_LOGIN_SOURCE/target/', + ); + + expect(kernelBuild).toBeGreaterThan(platformBuild); + expect(kernelBuild).toBeLessThan(formulaIndex); + expect(harness.slice(kernelBuild, formulaIndex)).toContain( + "bash scripts/resolve-binary.sh kernel.wasm", + ); + expect(harness.slice(kernelBuild, formulaIndex)).toContain( + 'cmp "$KANDELO_LOGIN_FORMULA_TEST_KERNEL" \\\n' + + ' "$KANDELO_LOGIN_SOURCE/host/wasm/kandelo-kernel.wasm"', + ); + }); + + it("materializes libc++ inside the protected Formula sysroot", () => { + const harness = readFileSync( + resolve(repositoryRoot, "scripts/run-login-stack-local.sh"), + "utf8", + ); + const programsBuild = harness.indexOf("bash scripts/build-programs.sh"); + const materialize = harness.indexOf( + 'KANDELO_LOGIN_LIBCXX_PREFIX="$("$KANDELO_LOGIN_XTASK_BIN"', + ); + const formulaBuild = harness.indexOf( + 'mapfile -t KANDELO_LOGIN_FORMULAE', + ); + expect(materialize).toBeGreaterThan(programsBuild); + expect(materialize).toBeLessThan(formulaBuild); + expect(harness).toContain( + 'install -m 0644 "$KANDELO_LOGIN_LIBCXX_PREFIX/lib/$archive" \\\n' + + ' "$KANDELO_LOGIN_SOURCE/sysroot/lib/$archive"', + ); + expect(harness).toContain( + 'cp -a "$KANDELO_LOGIN_LIBCXX_PREFIX/include/c++/v1" \\\n' + + ' "$KANDELO_LOGIN_SOURCE/sysroot/include/c++/v1"', + ); + expect(harness).toMatch( + /homebrew_assert_tree_symlinks_contained \\\n\s+"\$KANDELO_LOGIN_SOURCE\/sysroot" sysroot/, + ); + }); + + it("admits only the canonical protected pkill-to-pgrep host alias", () => { + const harness = readFileSync( + resolve(repositoryRoot, "scripts/run-login-stack-local.sh"), + "utf8", + ); + expect(harness).toContain( + 'KANDELO_LOGIN_PKILL_TARGET="$(readlink -f -- /usr/bin/pkill', + ); + expect(harness).toContain( + '[ "$KANDELO_LOGIN_PKILL_TARGET" = /usr/bin/pgrep ]', + ); + expect(harness).toContain( + '[ "$(stat -c \'%u\' /usr/bin/pkill 2>/dev/null || true)" = 0 ]', + ); + expect(harness).toContain( + 'the Linux builder has an unsafe /usr/bin/pkill alias', + ); + }); + + it("binds the final prepared local tap bundle into the ordinary product", () => { + const fs = MemoryFileSystem.create(new SharedArrayBuffer(1024 * 1024)); + fs.mkdirWithOwner("/opt", 0o755, 1000, 1000); + fs.mkdirWithOwner("/opt/kandelo", 0o755, 1000, 1000); + fs.mkdirWithOwner("/opt/kandelo/homebrew", 0o755, 1000, 1000); + fs.mkdirWithOwner("/opt/kandelo/homebrew/var", 0o755, 1000, 1000); + fs.mkdirWithOwner( + "/opt/kandelo/homebrew/var/kandelo", + 0o755, + 1000, + 1000, + ); + const bytes = new TextEncoder().encode("exact local tap bundle"); + const binding = installLocalTestHomebrewTapBundle(fs, bytes, { + sourceCommit: "1".repeat(40), + preparedCommit: "2".repeat(40), + }); + expect(binding).toMatchObject({ + path: LOCAL_TEST_HOMEBREW_TAP_BUNDLE_PATH, + source_commit: "1".repeat(40), + prepared_commit: "2".repeat(40), + bytes: bytes.byteLength, + }); + expect(() => + assertLocalTestHomebrewTapBundle(fs, binding), + ).not.toThrow(); + const staged = fs.lstat(LOCAL_TEST_HOMEBREW_TAP_BUNDLE_PATH); + expect({ mode: staged.mode & 0o7777, uid: staged.uid, gid: staged.gid }) + .toEqual({ mode: 0o444, uid: 0, gid: 0 }); + const ordinaryAncestor = fs.lstat( + "/opt/kandelo/homebrew/var/kandelo", + ); + expect({ uid: ordinaryAncestor.uid, gid: ordinaryAncestor.gid }).toEqual({ + uid: 1000, + gid: 1000, + }); + + fs.unlink(LOCAL_TEST_HOMEBREW_TAP_BUNDLE_PATH); + fs.createFileWithOwner( + LOCAL_TEST_HOMEBREW_TAP_BUNDLE_PATH, + 0o444, + 0, + 0, + new TextEncoder().encode("modified local tap bundle"), + ); + expect(() => assertLocalTestHomebrewTapBundle(fs, binding)).toThrow( + /changed identity/i, + ); + fs.unlink(LOCAL_TEST_HOMEBREW_TAP_BUNDLE_PATH); + expect(() => assertLocalTestHomebrewTapBundle(fs, binding)).toThrow( + /missing/i, + ); + + const harness = readFileSync( + resolve(repositoryRoot, "scripts/run-login-stack-local.sh"), + "utf8", + ); + const composer = readFileSync( + resolve( + repositoryRoot, + "scripts/build-homebrew-main-shell-closure.sh", + ), + "utf8", + ); + expect(composer).toContain("git -C \"$TAP_ROOT\" bundle create"); + expect(composer).toContain("--local-test-tap-bundle"); + expect(harness).toContain("KANDELO_LOGIN_PREPARED_TAP_COMMIT"); + const nodeLifecycle = readFileSync( + resolve(repositoryRoot, "scripts/homebrew-main-shell-node-smoke.ts"), + "utf8", + ); + const browserLifecycle = readFileSync( + resolve( + repositoryRoot, + "apps/browser-demos/pages/homebrew-vfs-test/main.ts", + ), + "utf8", + ); + for (const lifecycle of [nodeLifecycle, browserLifecycle]) { + expect(lifecycle).toContain( + "file:///opt/kandelo/homebrew/var/kandelo/local-test/homebrew-tap-core.bundle", + ); + expect(lifecycle).toContain("assertLocalTestHomebrewTapBundle"); + } + expect(nodeLifecycle + browserLifecycle).not.toContain( + "https://github.com/Kandelo-dev/homebrew-tap-core.git", + ); + }); + + it("prepares the native Linux builder before any expensive work", () => { + const harness = readFileSync( + resolve(repositoryRoot, "scripts/run-login-stack-local.sh"), + "utf8", + ); + expect(harness).toContain("[ -x /usr/bin/sudo ]"); + expect(harness).toContain("/usr/bin/sudo -n true"); + const submodules = readFileSync( + resolve(repositoryRoot, ".gitmodules"), + "utf8", + ); + expect(submodules).toContain( + "url = https://github.com/PocketCluster/libc-test.git", + ); + expect(submodules).not.toContain("url = git@github.com:"); + expect(harness).toMatch( + /git -C "\$KANDELO_LOGIN_SOURCE" submodule sync --recursive/, + ); + expect(harness).toMatch( + /git -C "\$KANDELO_LOGIN_SOURCE"[\s\\]*-c 'url\.https:\/\/github\.com\/\.insteadOf=git@github\.com:'[\s\\]*submodule update --init --recursive/, + ); + expect(harness).toMatch( + /formula_out="\$KANDELO_LOGIN_BUILD_ROOT\/\$formula"\n\s*mkdir "\$formula_out"[\s\S]*tee "\$formula_out\/build\.log"/, + ); + }); + + it("reads the canonical builder bottle cellar field", () => { + const harness = readFileSync( + resolve(repositoryRoot, "scripts/run-login-stack-local.sh"), + "utf8", + ); + const builder = readFileSync( + resolve(repositoryRoot, "scripts/homebrew-bottle-build.sh"), + "utf8", + ); + expect(builder).toContain("to_entries[0].value.bottle"); + expect(builder).toContain(".[$key].bottle.rebuild"); + expect(builder).toContain(".[$key].formula.pkg_version"); + expect(harness).toContain("'.[$key].bottle.cellar'"); + expect(harness).not.toContain( + "'.[$key].bottle.tags.wasm32_kandelo.cellar'", + ); + const verifier = readFileSync( + resolve(repositoryRoot, "scripts/homebrew-verify-poured-bottle.sh"), + "utf8", + ); + expect(verifier).toContain('FORMULA_KEY="${TAP_NAME}/${FORMULA}"'); + expect(verifier).toContain("keys == [$formula_key]"); + expect(verifier).toContain(".[$formula_key].formula.pkg_version"); + expect(verifier).not.toContain("keys == [$formula]"); + }); + + it("restores only exact bootstrap roots before build and verification", () => { + const launcher = readFileSync( + resolve(repositoryRoot, "scripts/homebrew-patched-launcher.sh"), + "utf8", + ); + expect(launcher).toContain( + "homebrew_patched_launcher_restore_invoker_bootstrap_roots()", + ); + expect(launcher).toContain('"$prefix/var/homebrew/locks"'); + expect(launcher).toContain('"${HOMEBREW_CACHE:-}"'); + expect(launcher).toContain('"${HOMEBREW_TEMP:-}"'); + expect(launcher).toContain("bootstrap root has an unexpected owner"); + expect(launcher).not.toMatch(/chown[^\n]*-R[^\n]*"\$prefix"/); + expect(launcher).not.toMatch(/chown[^\n]*-R[^\n]*var\/homebrew(?:"|\s)/); + + for (const script of [ + "scripts/homebrew-bottle-build.sh", + "scripts/homebrew-verify-poured-bottle.sh", + ]) { + const consumer = readFileSync(resolve(repositoryRoot, script), "utf8"); + const handoff = consumer.indexOf( + "homebrew_patched_launcher_restore_invoker_bootstrap_roots", + ); + const firstPrefixRead = consumer.indexOf('$("$BREW_BIN" --prefix)'); + expect(handoff).toBeGreaterThan(-1); + expect(firstPrefixRead).toBeGreaterThan(handoff); + } + }); + + it("binds sidecars to non-empty absolute local build roots", () => { + const harness = readFileSync( + resolve(repositoryRoot, "scripts/run-login-stack-local.sh"), + "utf8", + ); + expect(harness).toContain("KANDELO_LOGIN_FORBIDDEN_ROOTS_JSON"); + expect(harness).toContain("$KANDELO_LOGIN_WORK_ROOT"); + expect(harness).not.toContain("KANDELO_HOMEBREW_FORBIDDEN_ROOTS_JSON='[]'"); + expect(harness).toContain("formula-source"); + expect(harness).toContain("formula-verify"); + expect(harness).toContain("build_formula_sha256"); + expect(harness).toContain("formula_source_sha256"); + expect(harness).toContain("archived_formula_sha256"); + expect(harness).toContain("archived_formula_report_sha256"); + expect(harness).toContain( + ".packages[0].bottles[0].archived_formula_sha256", + ); + expect(harness).toContain( + ".bottles[0].built_from.formula_sha256", + ); + expect(harness).toContain( + '[ "$archived_formula_report_sha256" = "$archived_formula_sha256" ]', + ); + expect(harness).not.toMatch( + /archived_formula_report_sha256" = "\$build_formula_sha256/, + ); + expect(harness).toContain( + 'KANDELO_HOMEBREW_FORMULA_SOURCE_ROOT="$formula_source_root"', + ); + expect(harness).toContain( + 'KANDELO_HOMEBREW_TAP_ROOT="$formula_verify_root"', + ); + const formulaIdentityCheck = harness.indexOf( + "ruby scripts/homebrew-formula-source-digest.rb", + ); + expect(formulaIdentityCheck).toBeGreaterThan(-1); + expect( + harness.indexOf("--equivalent-excluding-bottle", formulaIdentityCheck), + ).toBeGreaterThan(formulaIdentityCheck); + expect( + harness.indexOf( + '"$formula_source_root/Formula/$formula.rb"', + formulaIdentityCheck, + ), + ).toBeGreaterThan(formulaIdentityCheck); + expect( + harness.indexOf( + '"$formula_verify_root/Formula/$formula.rb"', + formulaIdentityCheck, + ), + ).toBeGreaterThan(formulaIdentityCheck); + expect(harness).toMatch( + /--tap-root "\$formula_verify_root"[\s\\]*--tap-repository/, + ); + expect(harness).toMatch(/--tap-checkout-commit "\$build_tap_commit"/); + expect(harness).toContain( + 'sidecar_formula_report="$sidecars/Kandelo/formula/$formula.json"', + ); + expect( + harness.indexOf("bash scripts/homebrew-generate-sidecars-from-env.sh"), + ).toBeLessThan( + harness.indexOf( + 'cp -p "$sidecars/Formula/$formula.rb" "$KANDELO_LOGIN_LOCAL_TAP/Formula/$formula.rb"', + ), + ); + }); + + it("rejects any Formula drift outside the reconstructed bottle block", () => { + const root = mkdtempSync(resolve(tmpdir(), "kandelo-formula-identity-")); + const source = resolve(root, "source.rb"); + const bottled = resolve(root, "bottled.rb"); + const drifted = resolve(root, "drifted.rb"); + const digest = resolve( + repositoryRoot, + "scripts/homebrew-formula-source-digest.rb", + ); + try { + writeFileSync(source, 'class Ruby < Formula\n desc "Ruby"\nend\n'); + writeFileSync( + bottled, + 'class Ruby < Formula\n desc "Ruby"\n\n' + + " bottle do\n" + + ' root_url "https://ghcr.io/v2/example/tap"\n' + + ` sha256 cellar: :any, wasm32_kandelo: "${"1".repeat(64)}"\n` + + " end\n\nend\n", + ); + writeFileSync( + drifted, + readFileSync(bottled, "utf8").replace('desc "Ruby"', 'desc "Drift"'), + ); + const equivalent = spawnSync( + "ruby", + [digest, "--equivalent-excluding-bottle", source, bottled], + { encoding: "utf8" }, + ); + expect(equivalent.status, equivalent.stderr).toBe(0); + const changed = spawnSync( + "ruby", + [digest, "--equivalent-excluding-bottle", source, drifted], + { encoding: "utf8" }, + ); + expect(changed.status).toBe(1); + expect(changed.stderr).toMatch( + /differ outside canonical bottle metadata/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("reseals the exact Formula checker between batched Formulae", () => { + const harness = readFileSync( + resolve(repositoryRoot, "scripts/run-login-stack-local.sh"), + "utf8", + ); + expect(harness).toContain("KANDELO_LOGIN_XTASK_SHA256"); + expect(harness).toContain("KANDELO_LOGIN_XTASK_UID"); + expect(harness).toContain("reseal_formula_test_checker()"); + expect(harness).toContain("Formula test checker identity changed during"); + expect(harness).toContain("Formula test checker bytes changed during"); + expect(harness).toContain("Formula test checker reseal failed after"); + const sidecars = harness.indexOf( + "bash scripts/homebrew-generate-sidecars-from-env.sh", + ); + const reseal = harness.indexOf( + 'reseal_formula_test_checker "$formula sidecar generation"', + sidecars, + ); + const nextTapMutation = harness.indexOf( + 'rsync -a --delete "$sidecars/Kandelo/"', + sidecars, + ); + expect(sidecars).toBeGreaterThan(-1); + expect(reseal).toBeGreaterThan(sidecars); + expect(nextTapMutation).toBeGreaterThan(reseal); + }); + + it("binds the browser fixture to the exact generated login product report", () => { + const creator = readFileSync( + resolve( + repositoryRoot, + "scripts/create-homebrew-guest-lifecycle-fixture.ts", + ), + "utf8", + ); + const harness = readFileSync( + resolve(repositoryRoot, "scripts/run-login-stack-local.sh"), + "utf8", + ); + const browser = readFileSync( + resolve( + repositoryRoot, + "apps/browser-demos/pages/homebrew-vfs-test/main.ts", + ), + "utf8", + ); + expect(creator).toContain("compositionReport"); + expect(creator).toContain('"--composition-report"'); + expect(harness).toContain( + '--composition-report "$KANDELO_LOGIN_WORK_ROOT/composition-report.json"', + ); + expect(creator).toContain("privilegedProduct"); + expect(creator).toContain('"--privileged-product"'); + expect(browser).toContain("loaded.compositionReportBytes"); + expect(browser).toContain("loaded.privilegedProductBytes"); + expect(browser).toContain("privilegedProduct.imageBytes"); + expect(browser).not.toContain("KANDELO_LOGIN_COMPOSITION_REPORT_PATH"); + expect(harness).toContain( + '--privileged-product "$KANDELO_LOGIN_WORK_ROOT/main-shell.vfs.privileged.vfs"', + ); + expect(harness).toContain(".stats.expected == 3"); + expect(harness).not.toContain(".stats.expected == 6"); + }); + + it("requires the exact dependency-first Formula build sequence", () => { + const checker = readFileSync( + resolve(repositoryRoot, "scripts/check-homebrew-main-shell-brewfile.mjs"), + "utf8", + ); + expect(checker).toMatch( + /assertExactSequence\(\s*actualClosure,\s*lock\.formula_closure,/, + ); + const harness = readFileSync( + resolve(repositoryRoot, "scripts/run-login-stack-local.sh"), + "utf8", + ); + expect(harness).toContain("homebrew-formula-runtime-closure.rb"); + expect( + harness.lastIndexOf("homebrew-formula-runtime-closure.rb"), + ).toBeLessThan(harness.lastIndexOf("homebrew-bottle-build.sh")); + expect(harness).toContain("dependency must precede its consumer"); + }); + + it("makes exact Node and Chromium process-tree RSS evidence mandatory", () => { + const harness = readFileSync( + resolve(repositoryRoot, "scripts/run-login-stack-local.sh"), + "utf8", + ); + expect(harness).toContain("KANDELO_LOGIN_RSS_REPORT"); + expect(harness).toContain('--rss-report "$KANDELO_LOGIN_RSS_REPORT"'); + expect(harness).toContain("KANDELO_LOGIN_RSS_REPORT_PATH"); + expect(harness).toMatch(/rss:\$rss\[0\]/); + expect(harness).not.toMatch(/RSS: exact samples are recorded only when/); + }); + + it("installs exact detached JavaScript dependencies before npx consumers", () => { + const harness = readFileSync( + resolve(repositoryRoot, "scripts/run-login-stack-local.sh"), + "utf8", + ); + const rootInstall = harness.indexOf("npm ci"); + const appInstall = harness.indexOf("npm --prefix apps/browser-demos ci"); + expect(rootInstall).toBeGreaterThan( + harness.indexOf('cd "$KANDELO_LOGIN_SOURCE"'), + ); + expect(appInstall).toBeGreaterThan(rootInstall); + expect(rootInstall).toBeLessThan(harness.indexOf("npx tsx")); + expect(appInstall).toBeLessThan(harness.indexOf("npx playwright")); + }); + + it("records the exact process inventory behind an RSS total", () => { + const root = mkdtempSync(resolve(tmpdir(), "kandelo-rss-sample-")); + const report = resolve(root, "rss.json"); + try { + appendProcessTreeRssSample({ + phase: "unit-sample", + roots: new Map([["vitest", process.pid]]), + out: report, + }); + const document = JSON.parse(readFileSync(report, "utf8")); + expect(document).toMatchObject({ + schema: 1, + unit: "KiB", + scope: "exact sampled process trees", + provenance: { + schema: 1, + provenance_kind: "local-test", + promotable: false, + published: false, + }, + }); + expect(document.samples).toHaveLength(1); + expect(document.samples[0].roots[0].processes).toEqual( + expect.arrayContaining([expect.objectContaining({ pid: process.pid })]), + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects unsafe local harness inputs before creating output", () => { + const harness = resolve(repositoryRoot, "scripts/run-login-stack-local.sh"); + const root = mkdtempSync(resolve(tmpdir(), "kandelo-login-harness-")); + const tap = resolve(root, "tap"); + const tapLink = resolve(root, "tap-link"); + const existingWork = resolve(root, "existing-work"); + mkdirSync(tap); + mkdirSync(existingWork); + symlinkSync(tap, tapLink); + try { + const unknown = spawnSync("bash", [harness, "--unknown"], { + encoding: "utf8", + }); + expect(unknown.status).toBe(2); + expect(unknown.stderr).toMatch(/unknown flag/); + + const existing = spawnSync( + "bash", + [harness, "--tap-root", tap, "--work-root", existingWork], + { encoding: "utf8", env: { ...process.env, IN_NIX_SHELL: "pure" } }, + ); + expect(existing.status).toBe(2); + expect(existing.stderr).toMatch(/work root must not exist/); + + const symlink = spawnSync( + "bash", + [ + harness, + "--tap-root", + tapLink, + "--work-root", + resolve(root, "new-work"), + ], + { encoding: "utf8", env: { ...process.env, IN_NIX_SHELL: "pure" } }, + ); + expect(symlink.status).toBe(2); + expect(symlink.stderr).toMatch(/tap root must be a real directory/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects a dirty tap before Linux build setup", () => { + const harness = resolve(repositoryRoot, "scripts/run-login-stack-local.sh"); + const root = realpathSync( + mkdtempSync(resolve(tmpdir(), "kandelo-login-dirty-tap-")), + ); + const tap = resolve(root, "tap"); + mkdirSync(tap); + try { + for (const args of [ + ["init", "-q"], + ["config", "user.name", "Kandelo Test"], + ["config", "user.email", "test@kandelo.invalid"], + ["commit", "--allow-empty", "-q", "-m", "fixture"], + ]) { + const git = spawnSync("git", ["-C", tap, ...args], { + encoding: "utf8", + }); + expect(git.status, git.stderr).toBe(0); + } + writeFileSync(resolve(tap, "untracked"), "must reject\n"); + const result = spawnSync( + "bash", + [harness, "--tap-root", tap, "--work-root", resolve(root, "new-work")], + { encoding: "utf8", env: { ...process.env, IN_NIX_SHELL: "pure" } }, + ); + expect(result.status).toBe(2); + expect(result.stderr).toMatch(/tap checkout must be completely clean/); + expect(() => readFileSync(resolve(root, "new-work"))).toThrow(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("requires publisher authority for the Node privileged-program peer", () => { + const host = readFileSync( + resolve(repositoryRoot, "host/src/node-kernel-host.ts"), + "utf8", + ); + const worker = readFileSync( + resolve(repositoryRoot, "host/src/node-kernel-worker-entry.ts"), + "utf8", + ); + expect(host).toContain("snapshotPublishedPrivilegedProgramBrowserMount"); + expect(host).toContain( + "privilegedProduct?: PublishedPrivilegedProgramProduct", + ); + expect(worker).toMatch( + /kind\s*===\s*"published-privileged-program-product"/, + ); + expect(worker).toContain('mountPoint: "/usr/bin"'); + }); + + it("authenticates imported lazy seals before reading product sources", () => { + const smoke = readFileSync( + resolve(repositoryRoot, "scripts/homebrew-main-shell-node-smoke.ts"), + "utf8", + ); + const importImage = smoke.indexOf("MemoryFileSystem.fromImage"); + const authenticateSeals = smoke.indexOf( + "await fs.verifyImportedLazyAtomicGroupSeals()", + importImage, + ); + const readProductSources = smoke.indexOf( + "await createNodePrivilegedProduct", + importImage, + ); + expect(importImage).toBeGreaterThan(-1); + expect(authenticateSeals).toBeGreaterThan(importImage); + expect(readProductSources).toBeGreaterThan(authenticateSeals); + }); + + it("runs all nine product interactions through the generated login product", () => { + const node = readFileSync( + resolve(repositoryRoot, "scripts/homebrew-main-shell-node-smoke.ts"), + "utf8", + ); + const browser = readFileSync( + resolve( + repositoryRoot, + "apps/browser-demos/pages/homebrew-vfs-test/main.ts", + ), + "utf8", + ); + for (const source of [node, browser]) { + expect(source).toContain("/usr/bin/login"); + expect(source).toContain("automatic-maker-login-ok"); + expect(source).toContain("maker-id-ok"); + expect(source).toContain("sudo-list-ok"); + expect(source).toContain("sudo-id-ok"); + expect(source).toContain("failed-sudo-password-ok"); + expect(source).toContain("ordinary-login-ok"); + expect(source).toContain("nosuid-copy-rejected"); + expect(source).toContain("ruby-child-${repetition}-reaped"); + expect(source).toContain("repetition <= 3"); + expect(source).toContain("ruby-stock-tools-ok"); + expect(source).toContain("brew-tap-install-execute-ok"); + for (const phase of [ + "before-boot", + "before-ruby", + "peak", + "after-child-reaping", + "after-three-repetitions", + ]) { + expect(source).toContain(`"${phase}"`); + } + } + expect(node).toContain("spawnFromVfs"); + expect(node).toContain("pty: true"); + expect(browser).toContain("waitFrom"); + }); + + it("binds versions, vfork evidence, and Ruby identities into the report", () => { + const harness = readFileSync( + resolve(repositoryRoot, "scripts/run-login-stack-local.sh"), + "utf8", + ); + const browserSpec = readFileSync( + resolve( + repositoryRoot, + "apps/browser-demos/test/homebrew-login-lifecycle.spec.ts", + ), + "utf8", + ); + expect(browserSpec).toContain("browser.version()"); + expect(browserSpec).toContain("KANDELO_LOGIN_BROWSER_IDENTITY_PATH"); + expect(harness).toContain("browser-identities.json"); + expect(harness).toContain("runtime_evidence:$runtime[0]"); + expect(harness).toContain("--slurpfile ruby_inventory"); + expect(harness).toContain("--slurpfile browser_identities"); + expect(harness).toContain("vfork_fork_mode_evidence"); + }); +}); diff --git a/host/test/homebrew-runtime-support.test.ts b/host/test/homebrew-runtime-support.test.ts index e78c753612..351b532da8 100644 --- a/host/test/homebrew-runtime-support.test.ts +++ b/host/test/homebrew-runtime-support.test.ts @@ -19,19 +19,35 @@ const source = JSON.parse( ); describe("Homebrew shell runtime-support contract", () => { - it("binds the declared runtime delta and admits file/libmagic through the base", () => { + it("binds the complete ABI 43 runtime closure to the base", () => { const contract = parseHomebrewRuntimeSupportContract(source); expect(contract.activation).toEqual({ capability: "homebrew:runtime", root: "/usr/bin/brew", atomicGroup: "homebrew-runtime-support", + requiredKernelAbi: 43, + }); + expect(contract.availability).toEqual({ + provenance: { + schema: 1, + provenance_kind: "local-test", + promotable: false, + published: false, + }, + auditedCatalog: { + checkoutCommit: "af70e3ba06367dbafb8a95fabbacc3e1352b58b2", + kandeloAbi: 43, + releaseTag: "bottles-abi-v43", + requiredArch: "wasm32", + }, }); expect(contract.additionalFormulaOrder).toEqual( contract.formulaOrder.filter( (name) => !contract.baseFormulaOrder.includes(name), ), ); - expect(contract.additionalFormulaOrder).toContain( + expect(contract.additionalFormulaOrder).toEqual([]); + expect(contract.baseFormulaOrder).toContain( "kandelo-dev/tap-core/ruby", ); expect(contract.deferredRelocationFormulae).toEqual([]); @@ -102,8 +118,8 @@ function plan(packageOrder: readonly string[]): HomebrewVfsPlan { tapCommit: "1".repeat(40), kandeloRepository: "Automattic/kandelo", kandeloCommit: "2".repeat(40), - kandeloAbi: 42, - releaseTag: "bottles-abi-v42", + kandeloAbi: 43, + releaseTag: "bottles-abi-v43", requestedPackages: ["runtime"], packages: packageOrder.map((fullName) => packagePlan(fullName)), }; diff --git a/host/test/homebrew-vfs-materialization-policy.test.ts b/host/test/homebrew-vfs-materialization-policy.test.ts index a875f477ae..d4c6f4d597 100644 --- a/host/test/homebrew-vfs-materialization-policy.test.ts +++ b/host/test/homebrew-vfs-materialization-policy.test.ts @@ -58,27 +58,53 @@ function shellPlan(): HomebrewVfsPlan { kandeloCommit: "2".repeat(40), kandeloAbi: 41, releaseTag: "bottles-abi-v41", - requestedPackages: ["dash", "bash", "coreutils"], + requestedPackages: [ + "dash", + "bash", + "coreutils", + "login", + "sudo-lite", + "sudo", + "ruby", + ], packages: [ pkg("dash"), pkg("libcxx"), pkg("ncurses", [dependency("libcxx")]), pkg("bash", [dependency("ncurses")]), pkg("coreutils"), + pkg("zlib"), + pkg("login"), + pkg("sudo-lite"), + pkg("sudo"), + pkg("libyaml"), + pkg("ruby", [dependency("libyaml"), dependency("zlib")]), ], }; } describe("Homebrew VFS materialization policy", () => { - it("pins Bash and its exact dependency-first closure in the main shell", () => { + it("pins the login product and its exact dependency-first closure", () => { expect(checkedInPolicy()).toEqual({ schema: 1, kind: "kandelo-homebrew-vfs-materialization-policy", - embedded_roots: [`${TAP_NAME}/bash`], + embedded_roots: [ + `${TAP_NAME}/bash`, + `${TAP_NAME}/login`, + `${TAP_NAME}/sudo-lite`, + `${TAP_NAME}/sudo`, + `${TAP_NAME}/ruby`, + ], embedded_package_order: [ `${TAP_NAME}/libcxx`, `${TAP_NAME}/ncurses`, `${TAP_NAME}/bash`, + `${TAP_NAME}/zlib`, + `${TAP_NAME}/login`, + `${TAP_NAME}/sudo-lite`, + `${TAP_NAME}/sudo`, + `${TAP_NAME}/libyaml`, + `${TAP_NAME}/ruby`, ], }); }); @@ -98,11 +124,23 @@ describe("Homebrew VFS materialization policy", () => { it("partitions every planned package without overlap or loss", () => { const selection = selectHomebrewVfsMaterialization(shellPlan(), checkedInPolicy()); - expect(selection.embeddedRoots).toEqual([`${TAP_NAME}/bash`]); + expect(selection.embeddedRoots).toEqual([ + `${TAP_NAME}/bash`, + `${TAP_NAME}/login`, + `${TAP_NAME}/sudo-lite`, + `${TAP_NAME}/sudo`, + `${TAP_NAME}/ruby`, + ]); expect(selection.embeddedPackages.map((entry) => entry.name)).toEqual([ "libcxx", "ncurses", "bash", + "zlib", + "login", + "sudo-lite", + "sudo", + "libyaml", + "ruby", ]); expect(selection.deferredPackages.map((entry) => entry.name)).toEqual([ "dash", @@ -120,11 +158,17 @@ describe("Homebrew VFS materialization policy", () => { const plan = shellPlan(); const selection = selectHomebrewVfsMaterialization(plan, checkedInPolicy()); expect(projectEmbeddedHomebrewVfsPlan(plan, selection)).toMatchObject({ - requestedPackages: ["bash"], + requestedPackages: ["bash", "login", "sudo-lite", "sudo", "ruby"], packages: [ { name: "libcxx" }, { name: "ncurses" }, { name: "bash" }, + { name: "zlib" }, + { name: "login" }, + { name: "sudo-lite" }, + { name: "sudo" }, + { name: "libyaml" }, + { name: "ruby" }, ], }); }); @@ -135,12 +179,28 @@ describe("Homebrew VFS materialization policy", () => { `${TAP_NAME}/dash`, `${TAP_NAME}/bash`, `${TAP_NAME}/coreutils`, + `${TAP_NAME}/login`, + `${TAP_NAME}/sudo-lite`, + `${TAP_NAME}/sudo`, + `${TAP_NAME}/ruby`, ]; plan.taps = []; const selection = selectHomebrewVfsMaterialization(plan, checkedInPolicy()); const projected = projectEmbeddedHomebrewVfsPlan(plan, selection) as HomebrewFederatedVfsPlan; - expect(projected.requestedFullNames).toEqual([`${TAP_NAME}/bash`]); - expect(projected.requestedPackages).toEqual(["bash"]); + expect(projected.requestedFullNames).toEqual([ + `${TAP_NAME}/bash`, + `${TAP_NAME}/login`, + `${TAP_NAME}/sudo-lite`, + `${TAP_NAME}/sudo`, + `${TAP_NAME}/ruby`, + ]); + expect(projected.requestedPackages).toEqual([ + "bash", + "login", + "sudo-lite", + "sudo", + "ruby", + ]); }); it("requires the embedded root to be an explicit reviewed plan root", () => { @@ -201,10 +261,9 @@ describe("Homebrew VFS materialization policy", () => { it("rejects an all-embedded plan because the cutover requires a deferred partition", () => { const plan = shellPlan(); - plan.requestedPackages = ["bash"]; - plan.packages = plan.packages.filter((entry) => - entry.name === "libcxx" || entry.name === "ncurses" || entry.name === "bash" - ); + const embedded = new Set(checkedInPolicy().embedded_package_order); + plan.requestedPackages = ["bash", "login", "sudo-lite", "sudo", "ruby"]; + plan.packages = plan.packages.filter((entry) => embedded.has(entry.fullName)); expect(() => selectHomebrewVfsMaterialization(plan, checkedInPolicy()) ).toThrow("policy leaves no deferred packages"); diff --git a/images/vfs/lib/demo-login.ts b/images/vfs/lib/demo-login.ts index 40fd20f028..62ea550c51 100644 --- a/images/vfs/lib/demo-login.ts +++ b/images/vfs/lib/demo-login.ts @@ -71,15 +71,24 @@ export function configureDemoLogin( * entry. Privileged-product publication separately proves the executable * bytes and trusted mount provenance before the browser grants session policy. */ -export function hasConfiguredDemoLogin(fs: MemoryFileSystem): boolean { +export function hasConfiguredDemoLogin( + fs: MemoryFileSystem, + privilegedProgramFs: Pick = fs, +): boolean { try { - const login = fs.stat(DEMO_LOGIN_PROGRAM_PATH); + const login = privilegedProgramFs.stat(DEMO_LOGIN_PROGRAM_PATH); + // A separately published product is an immutable, eagerly serialized + // tree. Only the ordinary MemoryFS path can carry a deferred entry. + const loginIsEager = + privilegedProgramFs === fs + ? fs.getLazyEntry(DEMO_LOGIN_PROGRAM_PATH) === null + : true; const loginIsStaged = (login.mode & 0o170000) === 0o100000 && (login.mode & 0o7777) === 0o4755 && login.uid === 0 && login.gid === 0 && - fs.getLazyEntry(DEMO_LOGIN_PROGRAM_PATH) === null; + loginIsEager; const shadowMetadata = fs.stat("/etc/shadow"); const passwdMetadata = fs.stat("/etc/passwd"); const groupMetadata = fs.stat("/etc/group"); diff --git a/images/vfs/scripts/build-homebrew-vfs-image.ts b/images/vfs/scripts/build-homebrew-vfs-image.ts index bff36e35c9..a22327597b 100644 --- a/images/vfs/scripts/build-homebrew-vfs-image.ts +++ b/images/vfs/scripts/build-homebrew-vfs-image.ts @@ -24,7 +24,10 @@ import { import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { + assertLocalTestHomebrewTapBundle, buildHomebrewVfs, + installLocalTestHomebrewTapBundle, + type LocalTestHomebrewTapBundleBinding, type HomebrewVfsBuildOptions, type HomebrewVfsBuildResult, type HomebrewVfsCompatibilityPolicy, @@ -127,6 +130,9 @@ interface CliOptions { homebrewRuntimeSupport?: string; privilegedProjections?: string; privilegedProductOut?: string; + localTestTapBundle?: string; + localTestTapSourceCommit?: string; + localTestTapPreparedCommit?: string; materializePackageTree: boolean; } @@ -545,6 +551,7 @@ export async function runHomebrewVfsImageBuilder( "privileged projection policy did not produce an independent product tree", ); } + let localTestTapBundle: LocalTestHomebrewTapBundleBinding | undefined; let packageTree: | { derived: DerivedPackageDeferredZipTree; @@ -612,6 +619,26 @@ export async function runHomebrewVfsImageBuilder( packageTree.state, ); } + // Bootstrap adoption intentionally makes the ordinary Homebrew prefix + // writable by maker. Install the exact evidence bundle only afterward so + // its dedicated root-owned/read-only subtree is not weakened by that step. + if (options.localTestTapBundle !== undefined) { + localTestTapBundle = installLocalTestHomebrewTapBundle( + fs, + readBoundedRegularFile( + options.localTestTapBundle, + 32 * 1024 * 1024, + "local-test Homebrew tap bundle", + ), + { + sourceCommit: options.localTestTapSourceCommit!, + preparedCommit: options.localTestTapPreparedCommit!, + }, + ); + } + if (localTestTapBundle !== undefined) { + assertLocalTestHomebrewTapBundle(fs, localTestTapBundle); + } materializedBuild?.assert(fs); if (shellConfig) { assertShellExecutable(fs, shellConfig.config.path); @@ -930,6 +957,9 @@ export async function runHomebrewVfsImageBuilder( bytes: result.privilegedProduct.imageBytes.byteLength, }, }), + ...(localTestTapBundle === undefined + ? {} + : { local_test_tap: localTestTapBundle }), // Report a reproducible artifact identity, not a runner/worktree path. image: basename(options.out), }; @@ -1146,6 +1176,24 @@ function parseArgs(args: string[]): CliOptions { } options.privilegedProductOut = requireValue(args, ++i, arg); break; + case "--local-test-tap-bundle": + if (options.localTestTapBundle !== undefined) { + usage("--local-test-tap-bundle may be provided only once"); + } + options.localTestTapBundle = requireValue(args, ++i, arg); + break; + case "--local-test-tap-source-commit": + if (options.localTestTapSourceCommit !== undefined) { + usage("--local-test-tap-source-commit may be provided only once"); + } + options.localTestTapSourceCommit = requireValue(args, ++i, arg); + break; + case "--local-test-tap-prepared-commit": + if (options.localTestTapPreparedCommit !== undefined) { + usage("--local-test-tap-prepared-commit may be provided only once"); + } + options.localTestTapPreparedCommit = requireValue(args, ++i, arg); + break; case "--materialize-package-tree": if (options.materializePackageTree) { usage("--materialize-package-tree may be provided only once"); @@ -1211,6 +1259,33 @@ function parseArgs(args: string[]): CliOptions { "--privileged-projections and --privileged-product-out must be provided together", ); } + const localTestTapOptionCount = [ + options.localTestTapBundle, + options.localTestTapSourceCommit, + options.localTestTapPreparedCommit, + ].filter((value) => value !== undefined).length; + if (localTestTapOptionCount !== 0 && localTestTapOptionCount !== 3) { + usage( + "--local-test-tap-bundle and its source/prepared commits must be provided together", + ); + } + if ( + options.localTestTapBundle !== undefined && + ( + !existsSync(options.localTestTapBundle) || + options.privilegedProjections === undefined || + options.packageTreeSpec === undefined || + options.homebrewBootstrapEnv === undefined || + options.catalogCommit === undefined || + options.localTestTapSourceCommit !== options.catalogCommit || + !GIT_SHA_RE.test(options.localTestTapPreparedCommit!) || + options.localTestTapPreparedCommit === options.localTestTapSourceCommit + ) + ) { + usage( + "local-test tap staging requires the exact catalog source, a distinct prepared commit, bootstrap, and privileged product", + ); + } if ( options.privilegedProjections !== undefined && !existsSync(options.privilegedProjections) @@ -2275,6 +2350,9 @@ function usage(message?: string, code = 2): never { [--shell-config ] [--demo-config ] \\ [--privileged-projections \\ --privileged-product-out ] \\ + [--local-test-tap-bundle \\ + --local-test-tap-source-commit \\ + --local-test-tap-prepared-commit ] \\ [--catalog-commit ] \\ [--migration-lock ] \\ [--materialization-policy \\ diff --git a/packages/registry/ncurses/build-ncurses.sh b/packages/registry/ncurses/build-ncurses.sh index 2b119512b6..6cc28ac9c7 100755 --- a/packages/registry/ncurses/build-ncurses.sh +++ b/packages/registry/ncurses/build-ncurses.sh @@ -43,7 +43,7 @@ BIN_DIR="$WORK_DIR/bin" # --- Inputs from resolver, with legacy fallbacks --- NCURSES_VERSION="${WASM_POSIX_DEP_VERSION:-${NCURSES_VERSION:-6.5}}" INSTALL_DIR="${KANDELO_PACKAGE_OUT_DIR:-$WORK_DIR/ncurses-install}" -SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://ftp.gnu.org/gnu/ncurses/ncurses-${NCURSES_VERSION}.tar.gz}" +SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://invisible-mirror.net/archives/ncurses/ncurses-${NCURSES_VERSION}.tar.gz}" SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-136d91bc269a9a5785e5f9e980bc76ab57428f604ce3e5a5a90cebc767971cc6}" VERIFIED_SOURCE_DIR="${WASM_POSIX_DEP_SOURCE_DIR:-}" SOURCE_MARKER="$SRC_DIR/.kandelo-ncurses-source" diff --git a/packages/registry/ncurses/package.toml b/packages/registry/ncurses/package.toml index 15645fc337..5e9bf6760f 100644 --- a/packages/registry/ncurses/package.toml +++ b/packages/registry/ncurses/package.toml @@ -19,10 +19,10 @@ kernel_abi = 7 depends_on = [] [source] -# Use GNU's canonical origin here. The redirecting mirror selector can fail -# before choosing a mirror, which makes a hash-verified source build unavailable -# even while the authoritative archive remains healthy. -url = "https://ftp.gnu.org/gnu/ncurses/ncurses-6.5.tar.gz" +# Use the ncurses maintainer's archive mirror. The GNU canonical origin can be +# unreachable even while the maintainer's identical hash-verified archive is +# available. +url = "https://invisible-mirror.net/archives/ncurses/ncurses-6.5.tar.gz" sha256 = "136d91bc269a9a5785e5f9e980bc76ab57428f604ce3e5a5a90cebc767971cc6" [license] diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index 848afcd4a9..99830bfed6 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -4,8 +4,8 @@ "bash": { "manifestSha256": "5dc4d97dc4f154f265ff57e9d972e7d5a64a2e15fadedfa246733b830dda3497", "cacheKeys": { - "wasm32": "9cac1217bd7951530fee52b9a19feb039be16c997c87b76d1b949cafa6154bbe", - "wasm64": "2d11dc589ff1ba210bc9de9b77a48f8a5151d604bb6e257800db4b99b30bc517" + "wasm32": "6a7e8445d507d03454d502f096fae6fd4d838b47f525b5b3a76f6341981a352f", + "wasm64": "adde4f0bcc33e61ff96c389d00532bb781e96600769b32990318d0caf1afff37" } }, "bc": { @@ -158,8 +158,8 @@ "lamp": { "manifestSha256": "250b64635d64f8537178188fb5488dbfd2980d79a1c2ffbf346af67008088ba0", "cacheKeys": { - "wasm32": "a98443715c1bb0f1c1986a3bda1b11d09b9d1aed9b7af3bfe6602ea3b3b6ab7a", - "wasm64": "ee739fca808723e9461b80c352946ed83becf5fdf148b8178b5a7414a41b8676" + "wasm32": "759b208ced9f41b74ca390a95361e802b8a4b89c4ad1ca36282bbc212e17a22c", + "wasm64": "f19926a167a81a4cabdfedd995a642e62fd8dfe0f21bb7b87ae23f5a1ccb2082" } }, "less": { @@ -275,10 +275,10 @@ } }, "ncurses": { - "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", + "manifestSha256": "30d07b8914e93d0e38c48770aed2ac2938f6076b03e9d7f65dea597b04efd908", "cacheKeys": { - "wasm32": "9c773c442f97096978216d373d9c7c5d19c8a4a7b26aae0c4bc02b1c76cca382", - "wasm64": "16e18d2c2c4956f2d4883bd02e8ce1ce3f40b1963b091c7ccd592e0d900ecae2" + "wasm32": "f59cd67650e79a30dd490e8e78b14089871ca4d05acbd7ef5cab5ed02c3196aa", + "wasm64": "86ed43d42973f5f3e3140a15fa809c9f5cfdaec6fdf45dee1e281c6a3ea87a92" } }, "netcat": { @@ -291,15 +291,15 @@ "nethack": { "manifestSha256": "1a2f12ec2770bd8d40006d6c878a3493b97c71c2bb63ad08c7f6ad99bfd53504", "cacheKeys": { - "wasm32": "1411c691c5832de251b4e337c4915797c271f07737ed6f82560788800ce7e92f", - "wasm64": "578b00658e8567be94a15f7a072e4089abb53eadb79e1e09a1633cece41107e3" + "wasm32": "3414337607f4efe93fd212f17afda5d3160e3aa05141944c110b6960827652e3", + "wasm64": "051a19b740daad2a464f8b24c52e7d5353d0b7854cedb334d41d5d068d1c6960" } }, "nethack-browser-bundle": { "manifestSha256": "b8294981da7ce4299dfb271d4aecb90ababa97a4d1acc9b716ae05bbe52beb53", "cacheKeys": { - "wasm32": "daafe55441efecae67a98181c0ffa0acf838ad23d2b0acf37160a5a5b80a7b43", - "wasm64": "ebeb03ca00fb9f6f84c7abf27df0f07a3cf65fe1f7e0d3be55212041529622a3" + "wasm32": "3d63f90f5885cd694f54d200a06042a2547610c2681ee32eb0ee051b7d731c4d", + "wasm64": "42d2fd4fa065679793df052daa0a7ac764c09aa37a223160a517028aa9bf39eb" } }, "nginx": { @@ -312,15 +312,15 @@ "nginx-php-vfs": { "manifestSha256": "97976410cb02f8ba710d856b4ac904bcf976d677b50eefdee39fb64176070d4b", "cacheKeys": { - "wasm32": "89cff29e4b5d556d19999fbb6b5184706b9588f267b5d885d4244300e2710d7a", - "wasm64": "bf6abe9c6e9dd97477dfda9d19ca23a9180c833a4ec47bc4da47614d6ed3e662" + "wasm32": "cce5fe94610e80a81e6b5f8ed86081d5ae36aebf479fbc1976418c31a6d87842", + "wasm64": "7fcb8caa30cd86d2c403afacffc74d18d4622c7f59744dc038de067794b9684e" } }, "nginx-vfs": { "manifestSha256": "46aa2d3250ac5cd0f102a85c3a36a086c7a50ba1f9e05fa1c2aae3d07ad16f10", "cacheKeys": { - "wasm32": "47d092779945fbc70eebb1c757202c336b472333ce25c233a1c3bcde19bea0a3", - "wasm64": "9b743535437d77a5e57de3cb5becffbc4c121eb43be6f461d00e612c57efac2b" + "wasm32": "e2917571e02bb7f2c21bf02f8881c65733124d81e1e08390988d1f2de4798d94", + "wasm64": "bed9bcbb29e9b8d7f90b5859c86bfaa9f0c92e81351e52838d6c22c1c1f8369e" } }, "node": { @@ -333,8 +333,8 @@ "node-vfs": { "manifestSha256": "a4a41b2b06da60ed2a89470caf54d390f2981b1e8cf55e07cf95b376e15011d6", "cacheKeys": { - "wasm32": "571346722517dca220a3af2d20536d24113252b53af8cf01a19de63dc975fd9f", - "wasm64": "cc01c546e80129c8720791ced7da9813bb57a8ec62164db72d134dffaefd88a6" + "wasm32": "9781cf92b61c7f4e9870dd8e022968c812ce47dce3068cf60dc3254b5953025a", + "wasm64": "011ca7cb78b171b93b7d3aa43f996d9b0d5e09692ca2443a2cfb4d26cc5115da" } }, "openssl": { @@ -396,15 +396,15 @@ "redis-vfs": { "manifestSha256": "234c45c40f94e2a89f98295313b82cb9fce15a412ac829d9e87394e0dc731813", "cacheKeys": { - "wasm32": "183561f71f8b7eb62c28b687853dc739fd0ca734d6ac1c5e5a120bbc40b5cc7b", - "wasm64": "3a6b2784b542c5653bac45728c26656cab4e1548a89d051526348a7b0c1d5e27" + "wasm32": "ab51911b630502a1b2cf7079963dc1e2a493dff850c5763379bfba81c8b25d8e", + "wasm64": "fa1e8b2adfd61b6eff665c5b9f23c0897300ced125fc2bf43b11581d9d82c4df" } }, "rootfs": { "manifestSha256": "0420201025170f48c32949ac62707d4577cdc13a53ad1f01f59d318ec07c8d6e", "cacheKeys": { - "wasm32": "faed2e0332805c98637d684fd5f922172f6aa7b0cf0055ec4be41e80a007aaa8", - "wasm64": "92fc100ce5aa50c34d5f8d7bcffd431db440a90290f9af4c118706905d4d0aea" + "wasm32": "61706131cb8837a994c8b7fac6e172c94894a22bb1d2d2994d05f0984f5d70d4", + "wasm64": "4ad8054459b819c2c4f59229708e87616e1a6cd1a84a42e5a07eb841739dc1c2" } }, "ruby": { @@ -452,8 +452,8 @@ "shell": { "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", "cacheKeys": { - "wasm32": "aa84316cadca3547e61b0609c3e529db9bd37dec34b7b7086a268c478ac2eb78", - "wasm64": "3b1bcd6cf7d6e6c7ad6b30a3ae184470c8b6f3fbc6926db0a83804c0f07c3618" + "wasm32": "958efa1cc35207453d9374f90621b4cd468a9f687148fd0f6d90dfa922afd1f1", + "wasm64": "acbc4017286bc6fad5f4636fd89f95fbd5254c90fd3c66fd129b7633ffc682c5" } }, "spidermonkey": { @@ -543,8 +543,8 @@ "wordpress": { "manifestSha256": "36465e0596a06e855a8524eecfd87da98643bc13b37ee12939f5aab44bf33418", "cacheKeys": { - "wasm32": "1ca1f24ae8f52f86e5354b869d9ed9434fabf3f415a5f3935de49c549060adb7", - "wasm64": "822cf5b8dec2f6114671bead6f3c9ebfbc023a9297a24791313c10aa84891a9b" + "wasm32": "c575facb2a29e42ea8e05ae749d116394bd6fd902046797f6ad036a45e1ce533", + "wasm64": "146dd354cb918b5def3f9f8f4671865593b6dac198756e967b660f30a4f6b67a" } }, "xz": { @@ -583,14 +583,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "9cac1217bd7951530fee52b9a19feb039be16c997c87b76d1b949cafa6154bbe" + "wasm32": "6a7e8445d507d03454d502f096fae6fd4d838b47f525b5b3a76f6341981a352f" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", - "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "9c773c442f97096978216d373d9c7c5d19c8a4a7b26aae0c4bc02b1c76cca382" + "manifestSha256": "30d07b8914e93d0e38c48770aed2ac2938f6076b03e9d7f65dea597b04efd908", + "cacheKey": "f59cd67650e79a30dd490e8e78b14089871ca4d05acbd7ef5cab5ed02c3196aa" } ] }, @@ -1121,7 +1121,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a98443715c1bb0f1c1986a3bda1b11d09b9d1aed9b7af3bfe6602ea3b3b6ab7a" + "wasm32": "759b208ced9f41b74ca390a95361e802b8a4b89c4ad1ca36282bbc212e17a22c" }, "dependencyClosures": { "wasm32": [ @@ -1193,7 +1193,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "aa84316cadca3547e61b0609c3e529db9bd37dec34b7b7086a268c478ac2eb78" + "cacheKey": "958efa1cc35207453d9374f90621b4cd468a9f687148fd0f6d90dfa922afd1f1" }, { "packageName": "sqlite", @@ -1556,12 +1556,12 @@ ] }, "ncurses": { - "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", + "manifestSha256": "30d07b8914e93d0e38c48770aed2ac2938f6076b03e9d7f65dea597b04efd908", "arches": [ "wasm32" ], "cacheKeys": { - "wasm32": "9c773c442f97096978216d373d9c7c5d19c8a4a7b26aae0c4bc02b1c76cca382" + "wasm32": "f59cd67650e79a30dd490e8e78b14089871ca4d05acbd7ef5cab5ed02c3196aa" }, "dependencyClosures": { "wasm32": [] @@ -1666,14 +1666,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "1411c691c5832de251b4e337c4915797c271f07737ed6f82560788800ce7e92f" + "wasm32": "3414337607f4efe93fd212f17afda5d3160e3aa05141944c110b6960827652e3" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", - "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "9c773c442f97096978216d373d9c7c5d19c8a4a7b26aae0c4bc02b1c76cca382" + "manifestSha256": "30d07b8914e93d0e38c48770aed2ac2938f6076b03e9d7f65dea597b04efd908", + "cacheKey": "f59cd67650e79a30dd490e8e78b14089871ca4d05acbd7ef5cab5ed02c3196aa" } ] }, @@ -1693,19 +1693,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "daafe55441efecae67a98181c0ffa0acf838ad23d2b0acf37160a5a5b80a7b43" + "wasm32": "3d63f90f5885cd694f54d200a06042a2547610c2681ee32eb0ee051b7d731c4d" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", - "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "9c773c442f97096978216d373d9c7c5d19c8a4a7b26aae0c4bc02b1c76cca382" + "manifestSha256": "30d07b8914e93d0e38c48770aed2ac2938f6076b03e9d7f65dea597b04efd908", + "cacheKey": "f59cd67650e79a30dd490e8e78b14089871ca4d05acbd7ef5cab5ed02c3196aa" }, { "packageName": "nethack", "manifestSha256": "1a2f12ec2770bd8d40006d6c878a3493b97c71c2bb63ad08c7f6ad99bfd53504", - "cacheKey": "1411c691c5832de251b4e337c4915797c271f07737ed6f82560788800ce7e92f" + "cacheKey": "3414337607f4efe93fd212f17afda5d3160e3aa05141944c110b6960827652e3" } ] }, @@ -1746,7 +1746,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "89cff29e4b5d556d19999fbb6b5184706b9588f267b5d885d4244300e2710d7a" + "wasm32": "cce5fe94610e80a81e6b5f8ed86081d5ae36aebf479fbc1976418c31a6d87842" }, "dependencyClosures": { "wasm32": [ @@ -1808,7 +1808,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "aa84316cadca3547e61b0609c3e529db9bd37dec34b7b7086a268c478ac2eb78" + "cacheKey": "958efa1cc35207453d9374f90621b4cd468a9f687148fd0f6d90dfa922afd1f1" }, { "packageName": "sqlite", @@ -1838,7 +1838,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "47d092779945fbc70eebb1c757202c336b472333ce25c233a1c3bcde19bea0a3" + "wasm32": "e2917571e02bb7f2c21bf02f8881c65733124d81e1e08390988d1f2de4798d94" }, "dependencyClosures": { "wasm32": [ @@ -1860,7 +1860,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "aa84316cadca3547e61b0609c3e529db9bd37dec34b7b7086a268c478ac2eb78" + "cacheKey": "958efa1cc35207453d9374f90621b4cd468a9f687148fd0f6d90dfa922afd1f1" } ] }, @@ -1922,7 +1922,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "571346722517dca220a3af2d20536d24113252b53af8cf01a19de63dc975fd9f" + "wasm32": "9781cf92b61c7f4e9870dd8e022968c812ce47dce3068cf60dc3254b5953025a" }, "dependencyClosures": { "wasm32": [ @@ -1944,7 +1944,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "aa84316cadca3547e61b0609c3e529db9bd37dec34b7b7086a268c478ac2eb78" + "cacheKey": "958efa1cc35207453d9374f90621b4cd468a9f687148fd0f6d90dfa922afd1f1" }, { "packageName": "spidermonkey", @@ -2478,7 +2478,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "183561f71f8b7eb62c28b687853dc739fd0ca734d6ac1c5e5a120bbc40b5cc7b" + "wasm32": "ab51911b630502a1b2cf7079963dc1e2a493dff850c5763379bfba81c8b25d8e" }, "dependencyClosures": { "wasm32": [ @@ -2515,14 +2515,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "faed2e0332805c98637d684fd5f922172f6aa7b0cf0055ec4be41e80a007aaa8" + "wasm32": "61706131cb8837a994c8b7fac6e172c94894a22bb1d2d2994d05f0984f5d70d4" }, "dependencyClosures": { "wasm32": [ { "packageName": "bash", "manifestSha256": "6478060f28d430d18a6ebe7c603392d35a41d9bcf6bc74322351d1450b9c5335", - "cacheKey": "9cac1217bd7951530fee52b9a19feb039be16c997c87b76d1b949cafa6154bbe" + "cacheKey": "6a7e8445d507d03454d502f096fae6fd4d838b47f525b5b3a76f6341981a352f" }, { "packageName": "bc", @@ -2576,8 +2576,8 @@ }, { "packageName": "ncurses", - "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", - "cacheKey": "9c773c442f97096978216d373d9c7c5d19c8a4a7b26aae0c4bc02b1c76cca382" + "manifestSha256": "30d07b8914e93d0e38c48770aed2ac2938f6076b03e9d7f65dea597b04efd908", + "cacheKey": "f59cd67650e79a30dd490e8e78b14089871ca4d05acbd7ef5cab5ed02c3196aa" }, { "packageName": "posix-utils-lite", @@ -2728,7 +2728,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "aa84316cadca3547e61b0609c3e529db9bd37dec34b7b7086a268c478ac2eb78" + "wasm32": "958efa1cc35207453d9374f90621b4cd468a9f687148fd0f6d90dfa922afd1f1" }, "dependencyClosures": { "wasm32": [] @@ -3020,7 +3020,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "1ca1f24ae8f52f86e5354b869d9ed9434fabf3f415a5f3935de49c549060adb7" + "wasm32": "c575facb2a29e42ea8e05ae749d116394bd6fd902046797f6ad036a45e1ce533" }, "dependencyClosures": { "wasm32": [ @@ -3082,7 +3082,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "aa84316cadca3547e61b0609c3e529db9bd37dec34b7b7086a268c478ac2eb78" + "cacheKey": "958efa1cc35207453d9374f90621b4cd468a9f687148fd0f6d90dfa922afd1f1" }, { "packageName": "sqlite", diff --git a/scripts/build-homebrew-main-shell-closure.sh b/scripts/build-homebrew-main-shell-closure.sh index f8475d0891..d179908492 100755 --- a/scripts/build-homebrew-main-shell-closure.sh +++ b/scripts/build-homebrew-main-shell-closure.sh @@ -384,16 +384,31 @@ else exit 2 fi ACTUAL_TAP_SHA="$(git -C "$TAP_ROOT" rev-parse HEAD)" - if [ "$ACTUAL_TAP_SHA" != "$EXPECTED_TAP_SHA" ]; then - echo "build-homebrew-main-shell-closure: tap HEAD $ACTUAL_TAP_SHA does not match expected $EXPECTED_TAP_SHA" >&2 - exit 1 - fi TAP_STATUS="$(git -C "$TAP_ROOT" status --porcelain=v1 --untracked-files=all)" if [ -n "$TAP_STATUS" ]; then echo "build-homebrew-main-shell-closure: exact tap checkout is dirty" >&2 printf '%s\n' "$TAP_STATUS" >&2 exit 1 fi + if [ "$REVIEW_PENDING_ARTIFACT" = true ]; then + if [ "${KANDELO_HOMEBREW_TAP_SOURCE_COMMIT:-}" != "$EXPECTED_TAP_SHA" ] || + [ ! -f "$TAP_ROOT/local-test-provenance.json" ] || + [ -L "$TAP_ROOT/local-test-provenance.json" ] || + ! jq -e ' + . == { + schema: 1, + provenance_kind: "local-test", + promotable: false, + published: false + } + ' "$TAP_ROOT/local-test-provenance.json" >/dev/null; then + echo "build-homebrew-main-shell-closure: review-pending tap requires the locked source commit and exact local-test provenance" >&2 + exit 1 + fi + elif [ "$ACTUAL_TAP_SHA" != "$EXPECTED_TAP_SHA" ]; then + echo "build-homebrew-main-shell-closure: tap HEAD $ACTUAL_TAP_SHA does not match expected $EXPECTED_TAP_SHA" >&2 + exit 1 + fi fi if [ -n "$PACKAGE_TREE_ARCHIVE" ] && @@ -445,7 +460,21 @@ DEMO_CONFIG_SHA="$(sha256sum "$DEMO_CONFIG")" DEMO_CONFIG_SHA="${DEMO_CONFIG_SHA%% *}" DEMO_CONFIG_BYTES="$(wc -c <"$DEMO_CONFIG" | tr -d '[:space:]')" -if [ -n "$CLOSED_SELECTION_REPORT" ]; then +if [ "$REVIEW_PENDING_ARTIFACT" = true ]; then + jq -e ' + .availability.provenance == { + schema: 1, + provenance_kind: "local-test", + promotable: false, + published: false + } + ' "$RUNTIME_SUPPORT" >/dev/null || { + echo "build-homebrew-main-shell-closure: review-pending composition requires exact local-test provenance" >&2 + exit 1 + } + node "$REPO_ROOT/scripts/check-homebrew-main-shell-brewfile.mjs" \ + "$BREWFILE" "$MIGRATION_LOCK" "" "$RUNTIME_SUPPORT" +elif [ -n "$CLOSED_SELECTION_REPORT" ]; then # WHY: the canonical checker binds the product-owned Brewfile and lock # contracts, but its metadata mode deliberately requires the complete live # tap byte-for-byte. A closed selection is a smaller, independently sealed @@ -521,6 +550,11 @@ node "$REPO_ROOT/tools/mkrootfs/bin/mkrootfs.mjs" build \ -o "$PLATFORM_BASE" MATERIALIZATION_ARGS=() +PRIVILEGED_PRODUCT_ARGS=() +LOCAL_TEST_TAP_ARGS=() +LOCAL_TEST_TAP_BUNDLE_SHA="" +LOCAL_TEST_TAP_BUNDLE_BYTES=0 +LOCAL_TEST_TAP_PREPARED_COMMIT="" PACKAGE_TREE_ARGS=() PACKAGE_TREE_JSON=null PACKAGE_TREE_ARCHIVE_SHA="" @@ -589,6 +623,94 @@ if [ "$LAZY_SHELL" = true ]; then MATERIALIZATION_JSON="$(jq -c . "$MATERIALIZATION_POLICY")" fi +if [ "$REVIEW_PENDING_ARTIFACT" = true ]; then + PRIVILEGED_PROJECTIONS="$WORK_DIR/privileged-projections.json" + PRIVILEGED_PRODUCT_OUT="${OUT%.zst}.privileged.vfs" + printf '[]\n' >"$PRIVILEGED_PROJECTIONS" + for formula in login sudo-lite sudo; do + bottle_sha="$(jq -er --arg formula "$formula" ' + [.packages[] | select(.name == $formula)] as $matches | + if ($matches | length) != 1 then error("missing local product package") + else [$matches[0].bottles[] | + select(.arch == "wasm32" and .status == "success" and + .kandelo_abi == 43)] as $bottles | + if ($bottles | length) != 1 then error("missing local product bottle") + else $bottles[0].sha256 end + end + ' "$TAP_ROOT/Kandelo/metadata.json")" + bottle="$BOTTLE_CACHE/$bottle_sha.tar.gz" + if [ ! -f "$bottle" ] || [ -L "$bottle" ] || + [ "$(sha256sum "$bottle" | awk '{print $1}')" != "$bottle_sha" ]; then + echo "build-homebrew-main-shell-closure: exact local $formula bottle is absent from the cache" >&2 + exit 1 + fi + source_path="$(jq -er --arg formula "kandelo-dev/tap-core/$formula" ' + [.product.privileged_programs[] | select(.formula == $formula)] as $matches | + if ($matches | length) == 1 then $matches[0].source_path + else error("missing privileged program template") end + ' "$MIGRATION_LOCK")" + artifact_sha="$(tar -xOf "$bottle" "$source_path" | sha256sum | awk '{print $1}')" + next="$WORK_DIR/privileged-projections.next.json" + jq \ + --arg formula "kandelo-dev/tap-core/$formula" \ + --arg bottle_sha "$bottle_sha" \ + --arg artifact_sha "$artifact_sha" ' + .product.privileged_programs[] | + select(.formula == $formula) | + { + schema, + formula, + bottleSha256: $bottle_sha, + sourcePath: .source_path, + destinationPath: .destination_path, + uid, + gid, + mode, + mountPoint: .mount_point, + artifactValidationSha256: $artifact_sha + } + ' "$MIGRATION_LOCK" | jq -s \ + --slurpfile prior "$PRIVILEGED_PROJECTIONS" \ + '$prior[0] + .' >"$next" + mv "$next" "$PRIVILEGED_PROJECTIONS" + done + PRIVILEGED_PRODUCT_ARGS=( + --privileged-projections "$PRIVILEGED_PROJECTIONS" + --privileged-product-out "$PRIVILEGED_PRODUCT_OUT" + ) + + LOCAL_TEST_TAP_BUNDLE="$WORK_DIR/homebrew-tap-core.bundle" + LOCAL_TEST_TAP_VERIFICATION="$WORK_DIR/local-test-tap-verification" + git -C "$TAP_ROOT" bundle create "$LOCAL_TEST_TAP_BUNDLE" HEAD + git bundle verify "$LOCAL_TEST_TAP_BUNDLE" >/dev/null + [ "$(git bundle list-heads "$LOCAL_TEST_TAP_BUNDLE")" = \ + "$ACTUAL_TAP_SHA HEAD" ] || { + echo "build-homebrew-main-shell-closure: local tap bundle omits the exact prepared HEAD" >&2 + exit 1 + } + git clone --no-hardlinks "$LOCAL_TEST_TAP_BUNDLE" \ + "$LOCAL_TEST_TAP_VERIFICATION" >/dev/null + [ "$(git -C "$LOCAL_TEST_TAP_VERIFICATION" rev-parse HEAD)" = \ + "$ACTUAL_TAP_SHA" ] && + git -C "$LOCAL_TEST_TAP_VERIFICATION" cat-file -e \ + "$EXPECTED_TAP_SHA^{commit}" && + git -C "$LOCAL_TEST_TAP_VERIFICATION" merge-base --is-ancestor \ + "$EXPECTED_TAP_SHA" "$ACTUAL_TAP_SHA" && + [ -z "$(git -C "$LOCAL_TEST_TAP_VERIFICATION" status \ + --porcelain=v1 --untracked-files=all)" ] || { + echo "build-homebrew-main-shell-closure: staged local tap bundle is not the exact prepared catalog" >&2 + exit 1 + } + LOCAL_TEST_TAP_ARGS=( + --local-test-tap-bundle "$LOCAL_TEST_TAP_BUNDLE" + --local-test-tap-source-commit "$EXPECTED_TAP_SHA" + --local-test-tap-prepared-commit "$ACTUAL_TAP_SHA" + ) + LOCAL_TEST_TAP_BUNDLE_SHA="$(sha256sum "$LOCAL_TEST_TAP_BUNDLE" | awk '{print $1}')" + LOCAL_TEST_TAP_BUNDLE_BYTES="$(wc -c <"$LOCAL_TEST_TAP_BUNDLE" | tr -d '[:space:]')" + LOCAL_TEST_TAP_PREPARED_COMMIT="$ACTUAL_TAP_SHA" +fi + "$REPO_ROOT/node_modules/.bin/tsx" \ "$VFS_IMAGE_BUILDER" \ --metadata "$TAP_ROOT/Kandelo/metadata.json" \ @@ -604,6 +726,8 @@ fi --migration-lock "$MIGRATION_LOCK" \ "${MATERIALIZATION_ARGS[@]}" \ "${PACKAGE_TREE_ARGS[@]}" \ + "${PRIVILEGED_PRODUCT_ARGS[@]}" \ + "${LOCAL_TEST_TAP_ARGS[@]}" \ --write-profile \ --shell-config "$SHELL_CONFIG" \ --demo-config "$DEMO_CONFIG" \ @@ -646,6 +770,37 @@ if [ -n "$CLOSED_SELECTION_REPORT" ]; then }' "$REPORT" >"$REPORT_WITH_SELECTION" mv "$REPORT_WITH_SELECTION" "$REPORT" fi +if [ "$REVIEW_PENDING_ARTIFACT" = true ]; then + if [ ! -f "$PRIVILEGED_PRODUCT_OUT" ] || + [ -L "$PRIVILEGED_PRODUCT_OUT" ]; then + echo "build-homebrew-main-shell-closure: review-pending composition did not produce the privileged product" >&2 + exit 1 + fi + LOCAL_REPORT="$WORK_DIR/main-shell-report-with-local-provenance.json" + PRIVILEGED_SHA="$(sha256sum "$PRIVILEGED_PRODUCT_OUT")" + PRIVILEGED_SHA="${PRIVILEGED_SHA%% *}" + PRIVILEGED_BYTES="$(wc -c <"$PRIVILEGED_PRODUCT_OUT" | tr -d '[:space:]')" + jq \ + --slurpfile provenance "$TAP_ROOT/local-test-provenance.json" \ + --arg source_tap_commit "$EXPECTED_TAP_SHA" \ + --arg prepared_tap_commit "$ACTUAL_TAP_SHA" \ + --arg privileged_sha256 "$PRIVILEGED_SHA" \ + --argjson privileged_bytes "$PRIVILEGED_BYTES" ' + . + { + local_test: { + provenance: $provenance[0], + source_tap_commit: $source_tap_commit, + prepared_tap_commit: $prepared_tap_commit, + staged_tap: .local_test_tap + }, + privileged_product: (.privileged_product + { + sha256: $privileged_sha256, + bytes: $privileged_bytes + }) + } | del(.local_test_tap) + ' "$REPORT" >"$LOCAL_REPORT" + mv "$LOCAL_REPORT" "$REPORT" +fi if [ "$LAZY_SHELL" = true ] && [ "$MATERIALIZE_PACKAGE_TREE" = false ]; then if [ "$REVIEW_PENDING_ARTIFACT" = true ]; then # WHY: the reviewed catalog changes before its deterministic image digest @@ -679,8 +834,12 @@ jq -e \ --argjson homebrew_bootstrap_env_bytes "$HOMEBREW_BOOTSTRAP_ENV_BYTES" \ --argjson materialize_package_tree "$MATERIALIZE_PACKAGE_TREE" \ --argjson lazy_shell "$LAZY_SHELL" \ + --argjson review_pending "$REVIEW_PENDING_ARTIFACT" \ --argjson abi "$ABI_VERSION" \ --arg catalog "$EXPECTED_TAP_SHA" \ + --arg local_tap_sha "$LOCAL_TEST_TAP_BUNDLE_SHA" \ + --argjson local_tap_bytes "$LOCAL_TEST_TAP_BUNDLE_BYTES" \ + --arg local_tap_prepared "$LOCAL_TEST_TAP_PREPARED_COMMIT" \ --arg lock_sha "$LOCK_SHA" \ --arg closed_selection_lock_sha "$CLOSED_SELECTION_LOCK_SHA" \ --argjson closed_selection_lock_bytes "$CLOSED_SELECTION_LOCK_BYTES" \ @@ -725,6 +884,25 @@ jq -e \ (.catalog.tap_name == $tap[0].tap_name) and (.catalog.checkout_commit == $catalog) and (.catalog.checkout_commit == $lock[0].catalog.tap_commit) and + (if $review_pending then + (.local_test.provenance == { + schema: 1, + provenance_kind: "local-test", + promotable: false, + published: false + }) and + (.local_test.source_tap_commit == $catalog) and + (.local_test.prepared_tap_commit == $local_tap_prepared) and + (.local_test.staged_tap == { + path: "/opt/kandelo/homebrew/var/kandelo/local-test/homebrew-tap-core.bundle", + sha256: $local_tap_sha, + bytes: $local_tap_bytes, + source_commit: $catalog, + prepared_commit: $local_tap_prepared + }) + else + (.local_test == null) + end) and (.migration_lock.sha256 == $lock_sha) and (.migration_lock.bytes == $lock_bytes) and (if $uses_closed_selection then diff --git a/scripts/check-homebrew-main-shell-brewfile.mjs b/scripts/check-homebrew-main-shell-brewfile.mjs index 695a4f00f8..8616ccf239 100755 --- a/scripts/check-homebrew-main-shell-brewfile.mjs +++ b/scripts/check-homebrew-main-shell-brewfile.mjs @@ -585,12 +585,24 @@ function validateTapMetadata(lock, runtimeSupport, path) { `the reviewed closure requires ${lock.formula_closure.length}`, ); } - assertExactSet( - actualClosure, - lock.formula_closure, - "tap metadata dependency closure does not match reviewed formula_closure", - (value) => value, - ); + if (lock.product === undefined) { + // Published ABI-42 locks historically reviewed the closure as a set. Keep + // that supported release contract while the local ABI-43 product requires + // its costly build sequence to be exact and dependency-first. + assertExactSet( + actualClosure, + lock.formula_closure, + "tap metadata dependency closure does not match reviewed formula_closure", + (value) => value, + ); + } else { + assertExactSequence( + actualClosure, + lock.formula_closure, + "tap metadata dependency-first order does not match reviewed formula_closure", + (value) => value, + ); + } const actualRuntimeSupportClosure = resolveTapFormulaClosure( runtimeSupport.formulaRoots.map((entry) => entry.package.slice(`${tapName}/`.length), @@ -603,21 +615,23 @@ function validateTapMetadata(lock, runtimeSupport, path) { "tap metadata dependency closure does not match the Homebrew runtime-support layer", (value) => value, ); - const actualRuntimeBottleProvenanceSha256 = - computeRuntimeBottleProvenanceSha256( - byName, - runtimeSupport.availability.reusablePublicAbi42, - auditedCatalog, - ); - if ( - actualRuntimeBottleProvenanceSha256 !== - auditedCatalog.runtime_bottle_provenance_sha256 - ) { - throw new Error( - "Homebrew runtime-support bottle provenance digest differs from the " + - `reviewed cohort: expected ${auditedCatalog.runtime_bottle_provenance_sha256}, ` + - `actual ${actualRuntimeBottleProvenanceSha256}`, - ); + if (runtimeSupport.availability.reusablePublicAbi42 !== undefined) { + const actualRuntimeBottleProvenanceSha256 = + computeRuntimeBottleProvenanceSha256( + byName, + runtimeSupport.availability.reusablePublicAbi42, + auditedCatalog, + ); + if ( + actualRuntimeBottleProvenanceSha256 !== + auditedCatalog.runtime_bottle_provenance_sha256 + ) { + throw new Error( + "Homebrew runtime-support bottle provenance digest differs from the " + + `reviewed cohort: expected ${auditedCatalog.runtime_bottle_provenance_sha256}, ` + + `actual ${actualRuntimeBottleProvenanceSha256}`, + ); + } } } @@ -779,7 +793,8 @@ function readRuntimeSupport(path, lock) { activation.bootstrap_package.name !== "homebrew-bootstrap" || JSON.stringify(activation.bootstrap_package.outputs) !== JSON.stringify(["homebrew-bootstrap.zip", "homebrew-brew.env"]) || - activation.bootstrap_package.required_kernel_abi !== 42 + activation.bootstrap_package.required_kernel_abi !== + availability.auditedCatalog.kandelo_abi ) { throw new Error( "Homebrew runtime support must be one atomic, deferred /usr/bin/brew activation", @@ -878,33 +893,59 @@ function readRuntimeSupportAvailability( deferredFormulae, compositionFormulaOrder, ) { + if (!isRecord(value) || !isRecord(value.audited_catalog)) { + throw new Error( + "Homebrew runtime-support availability partition is invalid", + ); + } + const keys = Object.keys(value).sort().join("\0"); + const local = + keys === + "audited_catalog\0can_be_deferred\0local_test_formulae\0missing_metadata\0provenance\0requires_rebuild"; + const publishedAbi42 = + keys === + "audited_catalog\0can_be_deferred\0missing_metadata\0requires_rebuild\0reusable_public_abi42"; if ( - !isRecord(value) || - Object.keys(value).sort().join("\0") !== - "audited_catalog\0can_be_deferred\0missing_metadata\0requires_rebuild\0reusable_public_abi42" || - !isRecord(value.audited_catalog) + (!local && !publishedAbi42) || + (local && + (!isRecord(value.provenance) || + Object.keys(value.provenance).sort().join("\0") !== + "promotable\0provenance_kind\0published\0schema" || + value.provenance.schema !== 1 || + value.provenance.provenance_kind !== "local-test" || + value.provenance.promotable !== false || + value.provenance.published !== false)) ) { throw new Error( "Homebrew runtime-support availability partition is invalid", ); } const auditedCatalog = value.audited_catalog; + const localCatalogValid = + Object.keys(auditedCatalog).sort().join("\0") === + "checkout_commit\0kandelo_abi\0release_tag\0required_arch" && + auditedCatalog.kandelo_abi === 43 && + auditedCatalog.release_tag === "bottles-abi-v43"; + const publishedCatalogValid = + Object.keys(auditedCatalog).sort().join("\0") === + "checkout_commit\0kandelo_abi\0kandelo_commit\0metadata_sha256\0metadata_tap_commit\0release_tag\0required_arch\0runtime_bottle_provenance_sha256" && + gitShaPattern.test(auditedCatalog.metadata_tap_commit) && + gitShaPattern.test(auditedCatalog.kandelo_commit) && + /^[0-9a-f]{64}$/.test(auditedCatalog.metadata_sha256 ?? "") && + /^[0-9a-f]{64}$/.test( + auditedCatalog.runtime_bottle_provenance_sha256 ?? "", + ) && + auditedCatalog.kandelo_abi === 42 && + auditedCatalog.release_tag === "bottles-abi-v42"; if ( - Object.keys(auditedCatalog).sort().join("\0") !== - "checkout_commit\0kandelo_abi\0kandelo_commit\0metadata_sha256\0metadata_tap_commit\0release_tag\0required_arch\0runtime_bottle_provenance_sha256" || auditedCatalog.checkout_commit !== lock.catalog.tap_commit || - !gitShaPattern.test(auditedCatalog.metadata_tap_commit) || - !gitShaPattern.test(auditedCatalog.kandelo_commit) || - typeof auditedCatalog.metadata_sha256 !== "string" || - !/^[0-9a-f]{64}$/.test(auditedCatalog.metadata_sha256) || - typeof auditedCatalog.runtime_bottle_provenance_sha256 !== "string" || - !/^[0-9a-f]{64}$/.test(auditedCatalog.runtime_bottle_provenance_sha256) || - auditedCatalog.kandelo_abi !== 42 || - auditedCatalog.release_tag !== "bottles-abi-v42" || - auditedCatalog.required_arch !== "wasm32" + auditedCatalog.required_arch !== "wasm32" || + (local ? !localCatalogValid : !publishedCatalogValid) ) { throw new Error( - "Homebrew runtime-support availability must bind the exact ABI-42 wasm32 catalog", + local + ? "Homebrew runtime-support availability must bind the local ABI-43 wasm32 catalog" + : "Homebrew runtime-support availability must bind the exact ABI-42 wasm32 catalog", ); } @@ -920,12 +961,14 @@ function readRuntimeSupportAvailability( assertUnique(entries, `Homebrew runtime-support availability.${key}`); return entries; }; - const reusablePublicAbi42 = readPartition("reusable_public_abi42"); + const admittedFormulae = readPartition( + local ? "local_test_formulae" : "reusable_public_abi42", + ); const requiresRebuild = readPartition("requires_rebuild"); const missingMetadata = readPartition("missing_metadata"); const canBeDeferred = readPartition("can_be_deferred"); const partition = [ - ...reusablePublicAbi42, + ...admittedFormulae, ...requiresRebuild, ...missingMetadata, ...canBeDeferred, @@ -944,12 +987,13 @@ function readRuntimeSupportAvailability( ); } const unavailableActivation = formulaOrder.filter( - (identity) => !reusablePublicAbi42.includes(identity), + (identity) => !admittedFormulae.includes(identity), ); if (unavailableActivation.length !== 0) { throw new Error( - "Homebrew runtime-support activation includes Formulae without admitted " + - `public ABI-42 bottles: ${unavailableActivation.join(", ")}`, + "Homebrew runtime-support activation includes Formulae without " + + (local ? "local-test bottles: " : "admitted public ABI-42 bottles: ") + + unavailableActivation.join(", "), ); } assertExactSequence( @@ -972,7 +1016,9 @@ function readRuntimeSupportAvailability( ); return { auditedCatalog, - reusablePublicAbi42, + ...(local + ? { localTestFormulae: admittedFormulae } + : { reusablePublicAbi42: admittedFormulae }), auditedFormulae: partition, }; } diff --git a/scripts/check-homebrew-publish-workflow-trust.rb b/scripts/check-homebrew-publish-workflow-trust.rb index e260d00b4f..4d46b3febb 100644 --- a/scripts/check-homebrew-publish-workflow-trust.rb +++ b/scripts/check-homebrew-publish-workflow-trust.rb @@ -4307,13 +4307,13 @@ def check_publisher(workflow) ) [ 'keys == ["build", "build_and_test", "formula", "full_name", "native_requirements", "runtime_and_test", "schema", "tap", "target_taps"]', - '.schema == 4', + '.schema == 5', '(.build | type == "array" and length <= 128)', '(.build_and_test | type == "array" and length <= 128)', '(.runtime_and_test | type == "array" and length <= 128)', 'keys == ["class", "formula", "sentinel", "tags"]', '--slurpfile resolved "$RESOLVED_TAPS"', - 'map({tap_name, tap_repository, tap_commit}) | sort_by(.tap_name)', + 'checkout_commit: (.checkout_commit // .tap_commit)', '(.native_requirements == (.native_requirements | sort_by(.class)))', '((.native_requirements | map(.class)) == (.native_requirements | map(.class) | unique))', '(.tags == ["build"] or .tags == ["build", "test"])', @@ -4420,9 +4420,9 @@ def check_publisher(workflow) "tap-recipe preflight trust boundary lacks #{fragment}") end host_dependency_plan_output = formula_closure[/elsif host_dependencies_only(.*?)elsif direct_only/m, 1] - check(host_dependency_plan_output&.include?('"schema" => 4') && + check(host_dependency_plan_output&.include?('"schema" => 5') && host_dependency_plan_output&.include?('"native_requirements" => native_requirements'), - "static Formula closure does not emit the sealed schema-4 native Requirement plan") + "static Formula closure does not emit the sealed schema-5 native Requirement plan") check(!formula_closure.include?("legacy_requires") && formula_closure.include?( "if runtime_initializer_index.nil? || runtime_assignment_index != runtime_initializer_index + 1" @@ -5501,9 +5501,11 @@ def check_publisher(workflow) "FileUtils.touch(bottle_path, mtime: tab_source_modified_time)", "def self.dependency_plan(formula = nil, require_match: true)", "def self.selected_tap_formula?(formula)", - 'tap.keys.sort == %w[tap_commit tap_name tap_repository]', + 'tap.keys.sort == %w[checkout_commit tap_commit tap_name tap_repository]', 'tap_repository == "#{owner}/homebrew-#{short_name}"', 'TAP_GIT_HEAD.match?(tap["tap_commit"])', + 'TAP_GIT_HEAD.match?(tap["checkout_commit"])', + 'target_tap&.fetch("checkout_commit")', 'target_names == target_names.sort.uniq', 'tap.fetch("tap_name")', 'PLAN_FILENAME = ".kandelo-publisher-build-dependencies.json"', @@ -5516,7 +5518,7 @@ def check_publisher(workflow) 'NATIVE_SENTINEL_CONSTANT = :KANDELO_NATIVE_SENTINEL', 'Dependency.new(requirement.fetch("formula"), [:build])', 'actual == expected', - 'plan["schema"] == 4', + 'plan["schema"] == 5', "MAX_DEPENDENCIES = 128", "value.length <= MAX_DEPENDENCIES", "direct_native_build_dependencies.sort_by(&:name)", diff --git a/scripts/create-homebrew-guest-lifecycle-fixture.test.ts b/scripts/create-homebrew-guest-lifecycle-fixture.test.ts index 235c7c7a4f..f427199e6d 100644 --- a/scripts/create-homebrew-guest-lifecycle-fixture.test.ts +++ b/scripts/create-homebrew-guest-lifecycle-fixture.test.ts @@ -35,6 +35,16 @@ test("creates one exact closed-browser lifecycle fixture", () => { const spec = write(root, "tree.json", new Uint8Array([2])); const archive = write(root, "homebrew-bootstrap.zip", new Uint8Array([3])); const environment = write(root, "homebrew-brew.env", new Uint8Array([4])); + const compositionReport = write( + root, + "composition-report.json", + new Uint8Array([7]), + ); + const privilegedProduct = write( + root, + "main-shell.vfs.privileged.vfs", + new Uint8Array([8]), + ); const mirror = join(root, "mirror"); mkdirSync(mirror); const payloadBytes = new Uint8Array([5, 6]); @@ -52,6 +62,8 @@ test("creates one exact closed-browser lifecycle fixture", () => { bootstrapArchive: archive, bootstrapEnvironment: environment, bottleMirror: mirror, + compositionReport, + privilegedProduct, fixedAssetUrlRoot: "https://closed.example.test/run/", coreRevision: "1".repeat(40), canaryRevision: "2".repeat(40), @@ -76,6 +88,36 @@ test("creates one exact closed-browser lifecycle fixture", () => { sha256: plan.assets[0]!.sha256, bytes: payloadBytes.byteLength, }]); + assert.deepEqual(fixture.loginProduct, { + compositionReport: { + url: "https://closed.example.test/run/composition-report.json", + sha256: sha256(new Uint8Array([7])), + bytes: 1, + }, + privilegedProduct: { + url: "https://closed.example.test/run/main-shell.vfs.privileged.vfs", + sha256: sha256(new Uint8Array([8])), + bytes: 1, + }, + }); + + assert.throws( + () => + createHomebrewGuestLifecycleFixture({ + image, + bootstrapSpec: spec, + bootstrapArchive: archive, + bootstrapEnvironment: environment, + bottleMirror: mirror, + compositionReport, + fixedAssetUrlRoot: "https://closed.example.test/run/", + coreRevision: "1".repeat(40), + canaryRevision: "2".repeat(40), + timeoutMs: 900_000, + out: join(root, "partial-product.json"), + }), + /one exact pair/, + ); writeFileSync(join(mirror, plan.assets[0]!.asset), new Uint8Array([9, 9])); assert.throws( diff --git a/scripts/create-homebrew-guest-lifecycle-fixture.ts b/scripts/create-homebrew-guest-lifecycle-fixture.ts index eab7fbf590..cfb585b371 100644 --- a/scripts/create-homebrew-guest-lifecycle-fixture.ts +++ b/scripts/create-homebrew-guest-lifecycle-fixture.ts @@ -29,6 +29,8 @@ export interface CreateHomebrewGuestLifecycleFixtureOptions { bootstrapArchive: string; bootstrapEnvironment: string; bottleMirror: string; + compositionReport?: string; + privilegedProduct?: string; fixedAssetUrlRoot: string; coreRevision: string; canaryRevision: string; @@ -134,6 +136,29 @@ export function createHomebrewGuestLifecycleFixture( }); const planUrl = `${plan.release_root}/${plan.manifest_asset}`; const planAsset = exactBytesAsset(planBytes, planUrl); + const loginProduct = options.compositionReport === undefined || + options.privilegedProduct === undefined + ? undefined + : { + compositionReport: exactLocalAsset( + options.compositionReport, + fixedAssetUrl(fixedAssetUrlRoot, options.compositionReport), + "login product composition report", + ), + privilegedProduct: exactLocalAsset( + options.privilegedProduct, + fixedAssetUrl(fixedAssetUrlRoot, options.privilegedProduct), + "serialized privileged product", + ), + }; + if ( + (options.compositionReport === undefined) !== + (options.privilegedProduct === undefined) + ) { + throw new Error( + "login product composition report and serialized product are one exact pair", + ); + } const fixture: HomebrewGuestLifecycleBrowserFixture = { schema: 1, @@ -149,6 +174,7 @@ export function createHomebrewGuestLifecycleFixture( plan: planAsset, ...((options.transportMode ?? "closed") === "closed" ? { payloads } : {}), }, + ...(loginProduct === undefined ? {} : { loginProduct }), revisions: { coreRevision: options.coreRevision, canaryRevision: options.canaryRevision, @@ -169,6 +195,8 @@ function parseOptions( "--homebrew-bootstrap-archive", "--homebrew-bootstrap-env", "--bottle-mirror", + "--composition-report", + "--privileged-product", "--fixed-asset-url-root", "--core-revision", "--canary-revision", @@ -190,7 +218,18 @@ function parseOptions( } values.set(option, value); } - if (values.size !== allowed.size - 1 && values.size !== allowed.size) { + const required = [...allowed].filter((option) => + option !== "--transport-mode" && + option !== "--composition-report" && + option !== "--privileged-product" + ); + if ( + !required.every((option) => values.has(option)) || + values.size !== required.length + + Number(values.has("--transport-mode")) + + Number(values.has("--composition-report")) + + Number(values.has("--privileged-product")) + ) { usage(); } @@ -234,6 +273,12 @@ function parseOptions( values.get("--homebrew-bootstrap-env")!, ), bottleMirror: resolve(values.get("--bottle-mirror")!), + ...(values.has("--composition-report") + ? { compositionReport: resolve(values.get("--composition-report")!) } + : {}), + ...(values.has("--privileged-product") + ? { privilegedProduct: resolve(values.get("--privileged-product")!) } + : {}), fixedAssetUrlRoot, coreRevision, canaryRevision, @@ -362,6 +407,8 @@ function usage(): never { "--homebrew-bootstrap-archive " + "--homebrew-bootstrap-env " + "--bottle-mirror --fixed-asset-url-root " + + "[--composition-report " + + "--privileged-product ] " + "--core-revision --canary-revision " + "--timeout-ms --out ", ); diff --git a/scripts/finalize-homebrew-main-shell-release.py b/scripts/finalize-homebrew-main-shell-release.py index 12f1130d28..c013f797de 100755 --- a/scripts/finalize-homebrew-main-shell-release.py +++ b/scripts/finalize-homebrew-main-shell-release.py @@ -859,6 +859,22 @@ def prepare_stable_inputs( } migration = exact_json(old[MIGRATION_PATH], "migration lock") support = exact_json(old[SUPPORT_PATH], "runtime support") + provenance = ( + support.get("availability", {}).get("provenance") + if isinstance(support, dict) + else None + ) + if ( + isinstance(provenance, dict) + and provenance.get("provenance_kind") == "local-test" + ): + # WHY: the review-pending harness can prove local bytes, but neither a + # clean tap nor a closed selection turns that evidence into release + # authority. Reject it before reading tap/selection inputs or staging + # any replacement lock bytes. + raise FinalizeError( + "local-test provenance is not promotable or selectable" + ) artifact_lock = require_artifact_lock( exact_json(old[ARTIFACT_PATH], "artifact lock") ) diff --git a/scripts/homebrew-bottle-build.sh b/scripts/homebrew-bottle-build.sh index 176b531375..07b1bf98ad 100755 --- a/scripts/homebrew-bottle-build.sh +++ b/scripts/homebrew-bottle-build.sh @@ -12,10 +12,12 @@ BOTTLE_ROOT_URL="" STAGING_CANDIDATE_ABI="" BUILD_USER="${KANDELO_HOMEBREW_BUILD_USER:-}" SHARED_TEMP="${KANDELO_HOMEBREW_SHARED_TEMP:-}" +LOCAL_BUILD_EVIDENCE="${KANDELO_HOMEBREW_LOCAL_BUILD_EVIDENCE:-}" +RETIRE_SOURCE_INSTALL=false usage() { cat >&2 <<'EOF' -usage: scripts/homebrew-bottle-build.sh --tap-root [--tap-repository ] [--tap-name ] --formula --arch --out --bottle-root-url [--staging-candidate-abi ] +usage: scripts/homebrew-bottle-build.sh --tap-root [--tap-repository ] [--tap-name ] --formula --arch --out --bottle-root-url [--retire-source-install] This script is intended to run inside scripts/dev-shell.sh. It invokes the absolute Homebrew executable named by HOMEBREW_BREW_FILE, avoiding host PATH @@ -38,7 +40,14 @@ while [ "$#" -gt 0 ]; do --arch) ARCH="${2:-}"; shift 2 ;; --out) OUT_DIR="${2:-}"; shift 2 ;; --bottle-root-url) BOTTLE_ROOT_URL="${2:-}"; shift 2 ;; - --staging-candidate-abi) STAGING_CANDIDATE_ABI="${2:-}"; shift 2 ;; + --retire-source-install) + [ "$RETIRE_SOURCE_INSTALL" = false ] || { + echo "homebrew-bottle-build.sh: duplicate --retire-source-install" >&2 + exit 2 + } + RETIRE_SOURCE_INSTALL=true + shift + ;; -h|--help) usage; exit 0 ;; *) echo "homebrew-bottle-build.sh: unknown flag $1" >&2; usage; exit 2 ;; esac @@ -73,6 +82,32 @@ case "$ARCH" in *) echo "homebrew-bottle-build.sh: invalid arch: $ARCH" >&2; exit 2 ;; esac +if [ -n "$LOCAL_BUILD_EVIDENCE" ]; then + if [ "${GITHUB_ACTIONS:-}" = "true" ]; then + echo "homebrew-bottle-build.sh: local build evidence is forbidden in CI" >&2 + exit 2 + fi + case "$LOCAL_BUILD_EVIDENCE" in + /*) ;; + *) echo "homebrew-bottle-build.sh: local build evidence path must be absolute" >&2; exit 2 ;; + esac + if [ -e "$LOCAL_BUILD_EVIDENCE" ] || [ -L "$LOCAL_BUILD_EVIDENCE" ]; then + echo "homebrew-bottle-build.sh: local build evidence output already exists" >&2 + exit 2 + fi +fi + +if [ "$RETIRE_SOURCE_INSTALL" = true ]; then + [ -n "$BUILD_USER" ] || { + echo "homebrew-bottle-build.sh: source target retirement requires isolated Formula execution" >&2 + exit 2 + } + [ "${GITHUB_ACTIONS:-}" != true ] || { + echo "homebrew-bottle-build.sh: source target retirement is local batch behavior" >&2 + exit 2 + } +fi + if [ "${GITHUB_ACTIONS:-}" = "true" ] && [ -z "$BUILD_USER" ]; then # WHY: every CI Formula must run as the isolated build identity. Reject the # missing authority before creating output, temporary realms, or loading @@ -145,6 +180,10 @@ PUBLISHER_ISOLATION_PATCH_FILE="$KANDELO_ROOT/homebrew/patches/0002-support-isol # shellcheck source=/dev/null . "$KANDELO_ROOT/scripts/homebrew-native-install-contract.sh" homebrew_patched_launcher_select_host_git +if [ -n "$BUILD_USER" ]; then + homebrew_patched_launcher_restore_invoker_bootstrap_roots \ + "$BUILD_USER" "$HOMEBREW_GUEST_PREFIX" +fi mkdir -p "$OUT_DIR/bottles" if [ -n "$BUILD_USER" ]; then if [ ! -d "$SHARED_TEMP" ] || [ -L "$SHARED_TEMP" ]; then @@ -237,6 +276,12 @@ export HOMEBREW_NO_AUTO_UPDATE="${HOMEBREW_NO_AUTO_UPDATE:-1}" export HOMEBREW_NO_INSTALL_CLEANUP="${HOMEBREW_NO_INSTALL_CLEANUP:-1}" export HOMEBREW_NO_ANALYTICS="${HOMEBREW_NO_ANALYTICS:-1}" export HOMEBREW_DEVELOPER="${HOMEBREW_DEVELOPER:-1}" +if [ -n "$LOCAL_BUILD_EVIDENCE" ]; then + # WHY: the local product harness must retain the real configure transcript + # and generated config.h from the same Homebrew build as the bottle. CI and + # publishable builds keep their existing ephemeral cleanup behavior. + export HOMEBREW_KEEP_TMP=1 +fi export KANDELO_HOMEBREW_ARCH="$ARCH" export KANDELO_HOMEBREW_KANDELO_ROOT="$KANDELO_ROOT" export HOMEBREW_KANDELO_ARCH="$ARCH" @@ -722,6 +767,43 @@ validate_dependency_list \ "$BUILD_TEST_DEPENDENCY_LIST" "build/test dependency list" validate_dependency_list "$DEPENDENCY_POUR_LIST" "dependency pour list" +LOCAL_DEPENDENCY_CACHE="${KANDELO_HOMEBREW_LOCAL_DEPENDENCY_CACHE:-}" +if [ -n "$LOCAL_DEPENDENCY_CACHE" ]; then + if [ "${GITHUB_ACTIONS:-}" = true ] || [ ! -d "$LOCAL_DEPENDENCY_CACHE" ] || + [ -L "$LOCAL_DEPENDENCY_CACHE" ]; then + echo "homebrew-bottle-build.sh: local dependency cache is restricted to a real non-CI directory" >&2 + exit 2 + fi + LOCAL_DEPENDENCY_CACHE="$(cd "$LOCAL_DEPENDENCY_CACHE" && pwd -P)" + LOCAL_DEPENDENCIES_JSON="$CONTROL_DIR/local-dependencies.json" + ruby "$KANDELO_ROOT/scripts/homebrew-formula-runtime-closure.rb" \ + "$TAP_ROOT" "$TAP_NAME" "$FORMULA" "$ARCH" >"$LOCAL_DEPENDENCIES_JSON" + while IFS= read -r dependency; do + [ -n "$dependency" ] || continue + dependency_sha="$(jq -er --arg dependency "$dependency" \ + '.[$dependency].sha256' "$LOCAL_DEPENDENCIES_JSON")" + source_archive="$LOCAL_DEPENDENCY_CACHE/$dependency_sha.tar.gz" + if [ ! -f "$source_archive" ] || [ -L "$source_archive" ] || + [ "$(sha256sum "$source_archive" | awk '{print $1}')" != "$dependency_sha" ]; then + echo "homebrew-bottle-build.sh: local dependency cache lacks exact $dependency bottle $dependency_sha" >&2 + exit 1 + fi + cache_archive="$(HOMEBREW_KANDELO_BOTTLE_TAG="$BOTTLE_TAG" \ + KANDELO_HOMEBREW_BOTTLE_TAG="$BOTTLE_TAG" \ + "$BREW_BIN" --cache --bottle-tag="$BOTTLE_TAG" --formula "$dependency")" + case "$cache_archive" in + "$HOMEBREW_CACHE"/*) ;; + *) + echo "homebrew-bottle-build.sh: Homebrew dependency cache path escapes its private cache" >&2 + exit 1 + ;; + esac + mkdir -p "$(dirname "$cache_archive")" + cp "$source_archive" "$cache_archive" + chmod 0444 "$cache_archive" + done <"$DEPENDENCY_POUR_LIST" +fi + while IFS= read -r dependency; do [ -n "$dependency" ] || continue if [ "$dependency" = "$FORMULA" ] || \ @@ -840,7 +922,8 @@ brew_install_build_bottle() { fi ) -TARGET_PREFIX="$("$BREW_BIN" --prefix "$FORMULA_REF")" +TARGET_PREFIX="$(homebrew_patched_launcher_resolve_installed_formula_keg \ + "$BREW_BIN" "$FORMULA_REF" "$FORMULA")" dependency_provenance_args=( capture --brew-bin "$BREW_BIN" \ @@ -866,11 +949,169 @@ fi python3 "$KANDELO_ROOT/scripts/homebrew-dependency-provenance.py" \ "${dependency_provenance_args[@]}" +retain_local_ruby_build_evidence() { + [ -n "$LOCAL_BUILD_EVIDENCE" ] || return 0 + [ "$FORMULA" = "ruby" ] || { + echo "homebrew-bottle-build.sh: local build evidence is supported only for ruby" >&2 + return 2 + } + + local config_h="" config_log="" process_c="" instrumented_ruby="" + local runtime_archive="" runtime_archive_sha256="" + local candidate candidate_sha256 search_dir installed_ruby_sha256 + while IFS= read -r candidate; do + if grep -Eq '^#define HAVE_VFORK 1$' "$candidate" && + grep -Eq '^#define HAVE_WORKING_VFORK 1$' "$candidate" && + grep -Eq '^#define HAVE_WORKING_FORK 1$' "$candidate"; then + config_h="$candidate" + break + fi + done < <(find -P "$NATIVE_BASE" "$WORK_DIR" -type f -name config.h -print | LC_ALL=C sort) + [ -n "$config_h" ] || { + echo "homebrew-bottle-build.sh: retained Ruby target config.h is unavailable" >&2 + return 1 + } + + search_dir="$(dirname "$config_h")" + while [ "$search_dir" != "/" ] && [ "$search_dir" != "$NATIVE_BASE" ]; do + if [ -f "$search_dir/config.log" ]; then + config_log="$search_dir/config.log" + break + fi + search_dir="$(dirname "$search_dir")" + done + if [ -z "$config_log" ]; then + while IFS= read -r candidate; do + if grep -F 'wasm32-unknown-none' "$candidate" >/dev/null; then + config_log="$candidate" + break + fi + done < <(find -P "$NATIVE_BASE" "$WORK_DIR" -type f -name config.log -print | LC_ALL=C sort) + fi + [ -n "$config_log" ] && [ -f "$config_log" ] || { + echo "homebrew-bottle-build.sh: retained Ruby configure transcript is unavailable" >&2 + return 1 + } + + while IFS= read -r candidate; do + if [ "$(sha256sum "$candidate" | awk '{print $1}')" = \ + "39286bbe88bc5e8627f91ac780aa00403052cb1f700c2f25b5407b7af807e608" ]; then + process_c="$candidate" + break + fi + done < <(find -P "$NATIVE_BASE" "$WORK_DIR" -type f -name process.c -print | LC_ALL=C sort) + [ -n "$process_c" ] || { + echo "homebrew-bottle-build.sh: pristine Ruby process.c build input is unavailable" >&2 + return 1 + } + + [ -f "$TARGET_PREFIX/bin/ruby" ] || { + echo "homebrew-bottle-build.sh: installed Ruby executable is unavailable" >&2 + return 1 + } + installed_ruby_sha256="$(sha256sum "$TARGET_PREFIX/bin/ruby" | awk '{print $1}')" + while IFS= read -r candidate; do + if [ "$(sha256sum "$candidate" | awk '{print $1}')" = "$installed_ruby_sha256" ]; then + instrumented_ruby="$candidate" + break + fi + done < <(find -P "$NATIVE_BASE" "$WORK_DIR" -type f -name ruby.wasm -print | LC_ALL=C sort) + [ -n "$instrumented_ruby" ] || { + echo "homebrew-bottle-build.sh: installed Ruby differs from the transformed recipe output" >&2 + return 1 + } + + while IFS= read -r candidate; do + candidate_sha256="$(sha256sum "$candidate" | awk '{print $1}')" + if [ -z "$runtime_archive" ]; then + runtime_archive="$candidate" + runtime_archive_sha256="$candidate_sha256" + elif [ "$candidate_sha256" != "$runtime_archive_sha256" ]; then + echo "homebrew-bottle-build.sh: Ruby build retained multiple runtime archive identities" >&2 + return 1 + fi + done < <(find -P "$NATIVE_BASE" "$WORK_DIR" -type f -name ruby-runtime.zip -print | LC_ALL=C sort -u) + [ -n "$runtime_archive" ] || { + echo "homebrew-bottle-build.sh: Ruby runtime archive is unavailable" >&2 + return 1 + } + + mkdir -m 0700 "$LOCAL_BUILD_EVIDENCE" + cp -p "$config_h" "$LOCAL_BUILD_EVIDENCE/config.h" + cp -p "$config_log" "$LOCAL_BUILD_EVIDENCE/config.log" + cp -p "$process_c" "$LOCAL_BUILD_EVIDENCE/process.c" + cp -p "$instrumented_ruby" "$LOCAL_BUILD_EVIDENCE/instrumented-ruby.wasm" + cp -p "$runtime_archive" "$LOCAL_BUILD_EVIDENCE/ruby-runtime.zip" + cp -p "$INSTALL_LOG" "$LOCAL_BUILD_EVIDENCE/homebrew-install.log" + { + printf 'schema=1\n' + printf 'provenance_kind=local-test\n' + printf 'promotable=false\n' + printf 'published=false\n' + printf 'process_c_sha256=39286bbe88bc5e8627f91ac780aa00403052cb1f700c2f25b5407b7af807e608\n' + printf 'instrumented_ruby_sha256=%s\n' "$installed_ruby_sha256" + printf 'runtime_archive_sha256=%s\n' "$runtime_archive_sha256" + } >"$LOCAL_BUILD_EVIDENCE/identity.env" + chmod 0600 "$LOCAL_BUILD_EVIDENCE"/* +} + +retain_local_ruby_build_evidence + +RETIRE_BOTTLE_SHA256="" +RETIRE_BOTTLE_JSON_SHA256="" +if [ "$RETIRE_SOURCE_INSTALL" = true ]; then + mapfile -t retire_bottle_jsons < <( + find "$WORK_DIR" -maxdepth 1 -type f -name '*.bottle.json' -print | sort + ) + mapfile -t retire_bottle_archives < <( + find "$WORK_DIR" -maxdepth 1 -type f -name '*.bottle*.tar.gz' -print | sort + ) + [ "${#retire_bottle_jsons[@]}" -eq 1 ] && + [ "${#retire_bottle_archives[@]}" -eq 1 ] || { + echo "homebrew-bottle-build.sh: local batch retirement requires one exact bottle JSON and archive" >&2 + exit 1 + } + RETIRE_BOTTLE_JSON="${retire_bottle_jsons[0]}" + RETIRE_BOTTLE_ARCHIVE="${retire_bottle_archives[0]}" + RETIRE_PKG_VERSION="${TARGET_PREFIX##*/}" + RETIRE_RECEIPT="$TARGET_PREFIX/INSTALL_RECEIPT.json" + [ "$TARGET_PREFIX" = \ + "$HOMEBREW_PATCHED_PREFIX/Cellar/$FORMULA/$RETIRE_PKG_VERSION" ] && + [[ "$RETIRE_PKG_VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._+,-]{0,255}$ ]] || { + echo "homebrew-bottle-build.sh: source-built target prefix is not canonical" >&2 + exit 1 + } + jq -e --arg tap "$TAP_NAME" --arg tap_commit "$TAP_CHECKOUT_COMMIT" ' + .built_as_bottle == true and + .poured_from_bottle == false and + .source.tap == $tap and + .source.tap_git_head == $tap_commit + ' "$RETIRE_RECEIPT" >/dev/null || { + echo "homebrew-bottle-build.sh: local batch retirement requires the exact source-built target receipt" >&2 + exit 1 + } + RETIRE_BOTTLE_SHA256="$(homebrew_sha256_stream <"$RETIRE_BOTTLE_ARCHIVE")" + RETIRE_BOTTLE_JSON_SHA256="$(homebrew_sha256_stream <"$RETIRE_BOTTLE_JSON")" + homebrew_patched_launcher_retire_source_target \ + "$BREW_BIN" "$FORMULA_REF" "$FORMULA" "$RETIRE_PKG_VERSION" \ + "$RETIRE_BOTTLE_ARCHIVE" "$RETIRE_BOTTLE_JSON" "$RETIRE_RECEIPT" +fi + if [ -n "$BUILD_USER" ]; then homebrew_patched_launcher_teardown "$BUILD_USER" homebrew_patched_launcher_verify_isolation fi +if [ "$RETIRE_SOURCE_INSTALL" = true ]; then + [ "$(homebrew_sha256_stream <"$RETIRE_BOTTLE_ARCHIVE")" = \ + "$RETIRE_BOTTLE_SHA256" ] && + [ "$(homebrew_sha256_stream <"$RETIRE_BOTTLE_JSON")" = \ + "$RETIRE_BOTTLE_JSON_SHA256" ] || { + echo "homebrew-bottle-build.sh: protected retirement changed canonical bottle artifacts" >&2 + exit 1 + } +fi + mapfile -t bottle_jsons < <(find "$WORK_DIR" -maxdepth 1 -type f -name '*.bottle.json' -print | sort) if [ "${#bottle_jsons[@]}" -ne 1 ]; then diff --git a/scripts/homebrew-bottle-runtime-evidence.py b/scripts/homebrew-bottle-runtime-evidence.py index c911706579..90be368535 100755 --- a/scripts/homebrew-bottle-runtime-evidence.py +++ b/scripts/homebrew-bottle-runtime-evidence.py @@ -13,6 +13,7 @@ import subprocess import sys from typing import Any +from urllib.parse import unquote, urlsplit from homebrew_cache_archive import ( CacheArchiveError, @@ -34,6 +35,29 @@ SOURCE_BUILD = re.compile(r"\b(?:building|built)\b.*\bfrom source\b", re.IGNORECASE) ANSI_ESCAPE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") OCI_TAG = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$") +RFC3339_UTC = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$") +RAW_FORMULA_KEYS = { + "desc", + "homepage", + "license", + "name", + "path", + "pkg_version", + "tap_git_path", + "tap_git_remote", + "tap_git_revision", +} +RAW_BOTTLE_KEYS = {"cellar", "date", "rebuild", "root_url", "tags"} +RAW_TAG_KEYS = { + "all_files", + "filename", + "installed_size", + "local_filename", + "path_exec_files", + "sbom", + "sha256", + "tab", +} class EvidenceError(RuntimeError): @@ -58,6 +82,13 @@ def require_string(value: Any, label: str, pattern: re.Pattern[str] | None = Non return value +def require_bounded_string(value: Any, label: str, maximum: int = 4_096) -> str: + value = require_string(value, label) + if len(value.encode("utf-8")) > maximum: + fail(f"{label} exceeds {maximum} bytes") + return value + + def require_bool(value: Any, label: str) -> bool: if not isinstance(value, bool): fail(f"{label} must be boolean") @@ -297,23 +328,74 @@ def dependency_closure_identity( return identity -def canonical_bottle(args: argparse.Namespace) -> tuple[str, str, int, str]: +def canonical_bottle( + args: argparse.Namespace, +) -> tuple[str, str, int, str, dict[str, str] | None]: document = load_json(pathlib.Path(args.bottle_json), "canonical bottle JSON") if not isinstance(document, dict) or len(document) != 1: fail("canonical bottle JSON must contain one Formula") formula_key, entry = next(iter(document.items())) - if formula_key != args.formula: + expected_formula_key = f"{normalized_tap_name(args)}/{args.formula}" + if formula_key != expected_formula_key: fail("canonical bottle JSON Formula key does not match") entry = exact_keys(entry, {"formula", "bottle"}, "canonical bottle entry") + formula_value = entry["formula"] + if not isinstance(formula_value, dict): + fail("canonical Formula identity must be an object") + raw_builder_json = set(formula_value) == RAW_FORMULA_KEYS formula = exact_keys( - entry["formula"], {"name", "path", "pkg_version"}, "canonical Formula identity" + formula_value, + RAW_FORMULA_KEYS if raw_builder_json else {"name", "path", "pkg_version"}, + "canonical Formula identity", ) bottle = exact_keys( - entry["bottle"], {"root_url", "cellar", "rebuild", "tags"}, "canonical bottle" + entry["bottle"], + RAW_BOTTLE_KEYS + if raw_builder_json + else {"root_url", "cellar", "rebuild", "tags"}, + "canonical bottle", + ) + version = require_string( + formula["pkg_version"], "canonical Formula version", PKG_VERSION ) - version = require_string(formula["pkg_version"], "canonical Formula version", PKG_VERSION) if formula["name"] != args.formula: fail("canonical Formula name does not match") + expected_formula_path = ( + f"Library/Taps/{normalized_tap_name(args).split('/', 1)[0]}/" + f"homebrew-{normalized_tap_name(args).split('/', 1)[1]}/" + f"Formula/{args.formula}.rb" + ) + if formula["path"] != expected_formula_path: + fail("canonical Formula path does not match the exact tap Formula") + raw_formula_metadata = None + if raw_builder_json: + if formula["tap_git_path"] != f"Formula/{args.formula}.rb": + fail("canonical Formula tap Git path does not match") + if formula["tap_git_revision"] != selected_tap_checkout_commit(args): + fail("canonical Formula tap Git revision does not match") + validate_raw_formula_remote( + args, + require_bounded_string( + formula["tap_git_remote"], "canonical Formula tap Git remote" + ), + formula["tap_git_revision"], + ) + description = require_bounded_string( + formula["desc"], "canonical Formula description" + ) + license_value = require_bounded_string( + formula["license"], "canonical Formula license" + ) + homepage = require_bounded_string( + formula["homepage"], "canonical Formula homepage" + ) + if not re.fullmatch(r"https?://[^\s]+", homepage): + fail("canonical Formula homepage is invalid") + raw_formula_metadata = { + "desc": description, + "homepage": homepage, + "license": license_value, + } rebuild = bottle["rebuild"] if not isinstance(rebuild, int) or isinstance(rebuild, bool) or rebuild < 0: fail("canonical bottle rebuild must be a non-negative integer") @@ -321,7 +403,11 @@ def canonical_bottle(args: argparse.Namespace) -> tuple[str, str, int, str]: tags = bottle["tags"] if not isinstance(tags, dict) or set(tags) != {tag_name}: fail(f"canonical bottle JSON must contain only {tag_name}") - tag = exact_keys(tags[tag_name], {"sha256"}, f"canonical {tag_name} bottle") + tag = exact_keys( + tags[tag_name], + RAW_TAG_KEYS if raw_builder_json else {"sha256"}, + f"canonical {tag_name} bottle", + ) if tag["sha256"] != args.bottle_sha256: fail("canonical bottle digest does not match the selected bytes") expected_metadata_root = args.bottle_root_url @@ -331,11 +417,91 @@ def canonical_bottle(args: argparse.Namespace) -> tuple[str, str, int, str]: fail("canonical bottle root URL does not match") rebuild_suffix = f".{rebuild}" if rebuild else "" filename = f"{args.formula}--{version}.{tag_name}.bottle{rebuild_suffix}.tar.gz" - return version, tag_name, rebuild, filename + if raw_builder_json: + require_string(bottle["date"], "canonical bottle date", RFC3339_UTC) + require_bounded_string(bottle["cellar"], "canonical bottle cellar") + url_filename = ( + f"{args.formula}-{version}.{tag_name}.bottle{rebuild_suffix}.tar.gz" + ) + if tag["filename"] != url_filename or tag["local_filename"] != filename: + fail("canonical bottle filenames do not match") + if ( + not isinstance(tag["installed_size"], int) + or isinstance(tag["installed_size"], bool) + or tag["installed_size"] <= 0 + ): + fail("canonical bottle installed size must be positive") + for field in ("all_files", "path_exec_files"): + values = tag[field] + if not isinstance(values, list) or len(values) > 65_536: + fail(f"canonical bottle {field} must be a bounded array") + for index, value in enumerate(values): + require_bounded_string(value, f"canonical bottle {field}[{index}]") + if not isinstance(tag["tab"], dict) or not isinstance(tag["sbom"], dict): + fail("canonical bottle tab and SBOM must be objects") + return version, tag_name, rebuild, filename, raw_formula_metadata + + +def validate_raw_formula_remote( + args: argparse.Namespace, remote: str, revision: str +) -> None: + repository = normalized_tap_repository(args) + if remote in ( + f"https://github.com/{repository}", + f"https://github.com/{repository}.git", + ): + return + parsed = urlsplit(remote) + if ( + parsed.scheme != "file" + or parsed.netloc + or parsed.query + or parsed.fragment + ): + fail("canonical Formula tap Git remote does not match the exact tap") + decoded = unquote(parsed.path) + path = pathlib.Path(decoded) + try: + resolved = path.resolve(strict=True) + except OSError: + fail("canonical Formula tap Git remote does not resolve") + if not path.is_absolute() or path.is_symlink() or resolved != path: + fail("canonical Formula tap Git remote is not one exact real path") + try: + head = subprocess.run( + ["git", "-C", str(path), "rev-parse", "HEAD"], + check=False, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=30, + ) + status = subprocess.run( + ["git", "-C", str(path), "status", "--short", "--untracked-files=all"], + check=False, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=30, + ) + except (OSError, subprocess.SubprocessError) as error: + fail(f"cannot authenticate canonical Formula tap Git remote: {error}") + if ( + head.returncode != 0 + or head.stdout.decode("ascii", errors="replace").strip() != revision + or status.returncode != 0 + or status.stdout + or status.stderr + ): + fail("canonical Formula tap Git remote does not identify the clean exact checkout") def validate_formula_info( - args: argparse.Namespace, version: str, tag_name: str, rebuild: int + args: argparse.Namespace, + version: str, + tag_name: str, + rebuild: int, + raw_formula_metadata: dict[str, str] | None, ) -> None: document = exact_keys( load_json(pathlib.Path(args.formula_info), "Homebrew Formula info"), @@ -343,14 +509,25 @@ def validate_formula_info( "Homebrew Formula info", ) formulae = document["formulae"] - if document["casks"] != [] or not isinstance(formulae, list) or len(formulae) != 1: + if ( + document["casks"] != [] + or not isinstance(formulae, list) + or len(formulae) != 1 + ): fail("Homebrew Formula info must contain one Formula and no casks") formula = formulae[0] if not isinstance(formula, dict): fail("Homebrew Formula info record must be an object") expected_full_name = f"{normalized_tap_name(args)}/{args.formula}" - if formula.get("name") != args.formula or str(formula.get("full_name", "")).lower() != expected_full_name: + if ( + formula.get("name") != args.formula + or str(formula.get("full_name", "")).lower() != expected_full_name + ): fail("Homebrew Formula info identity does not match the exact tap Formula") + if raw_formula_metadata is not None and any( + formula.get(field) != value for field, value in raw_formula_metadata.items() + ): + fail("canonical Formula metadata differs from Homebrew Formula info") versions = formula.get("versions") stable_version = versions.get("stable") if isinstance(versions, dict) else None revision = formula.get("revision") @@ -676,8 +853,10 @@ def selection_evidence(args: argparse.Namespace) -> dict[str, Any]: def build_document(args: argparse.Namespace) -> dict[str, Any]: validate_arguments(args) - version, tag_name, rebuild, bottle_filename = canonical_bottle(args) - validate_formula_info(args, version, tag_name, rebuild) + version, tag_name, rebuild, bottle_filename, raw_formula_metadata = ( + canonical_bottle(args) + ) + validate_formula_info(args, version, tag_name, rebuild, raw_formula_metadata) dependencies = validate_dependency_provenance(args) selection = selection_evidence(args) test_contract = formula_test_contract(args) @@ -799,7 +978,7 @@ def validate_document(document: Any, args: argparse.Namespace) -> None: ) if tap != expected_tap: fail("runtime evidence tap identity does not match") - version, tag_name, rebuild, bottle_filename = canonical_bottle(args) + version, tag_name, rebuild, bottle_filename, _ = canonical_bottle(args) bottle = exact_keys( root["bottle"], {"bytes", "sha256", "tag", "url", "version"}, "runtime evidence bottle" ) diff --git a/scripts/homebrew-formula-runtime-closure.rb b/scripts/homebrew-formula-runtime-closure.rb index 14b7ed2ef6..588ecce51d 100755 --- a/scripts/homebrew-formula-runtime-closure.rb +++ b/scripts/homebrew-formula-runtime-closure.rb @@ -2312,18 +2312,26 @@ end end immutable_target_taps = tap_contexts.values.sort_by { |context| context.fetch("tap_name") }.map do |context| + # Publication authority remains the reviewed source commit. A prefix + # campaign may execute a later, immutable bottle-block materialization; + # the sealed plan must preserve both identities for its install receipt. commit = context.fetch("tap_commit") + checkout_commit = context.fetch("checkout_commit", commit) unless commit.is_a?(String) && commit.match?(/\A[0-9a-f]{40}\z/) abort "host dependency plan requires an immutable resolved tap map" end + unless checkout_commit.is_a?(String) && checkout_commit.match?(/\A[0-9a-f]{40}\z/) + abort "host dependency plan requires an immutable resolved tap checkout" + end { "tap_name" => context.fetch("tap_name"), "tap_repository" => context.fetch("tap_repository"), "tap_commit" => commit, + "checkout_commit" => checkout_commit, } end puts JSON.generate({ - "schema" => 4, + "schema" => 5, "tap" => tap_name, "formula" => target, "full_name" => "#{tap_name}/#{target}", diff --git a/scripts/homebrew-generate-sidecars-from-env.sh b/scripts/homebrew-generate-sidecars-from-env.sh index 823f3f9cd4..07816029de 100755 --- a/scripts/homebrew-generate-sidecars-from-env.sh +++ b/scripts/homebrew-generate-sidecars-from-env.sh @@ -54,6 +54,15 @@ for name in \ require_env "$name" done +PROVENANCE_KIND="${KANDELO_HOMEBREW_PROVENANCE_KIND:-published}" +case "$PROVENANCE_KIND" in + published|local-test) ;; + *) + echo "homebrew-generate-sidecars-from-env.sh: provenance kind must be published or local-test" >&2 + exit 2 + ;; +esac + case "$KANDELO_HOMEBREW_ARCH" in wasm32|wasm64) ;; *) echo "homebrew-generate-sidecars-from-env.sh: invalid arch $KANDELO_HOMEBREW_ARCH" >&2; exit 2 ;; @@ -191,7 +200,14 @@ python3 "$KANDELO_ROOT/scripts/homebrew-bottle-runtime-evidence.py" validate \ --dependency-provenance "$KANDELO_HOMEBREW_DEPENDENCY_PROVENANCE" BREW_VERSION="Homebrew source commit $HOMEBREW_BREW_COMMIT" GENERATED_AT="$(date -u +%FT%TZ)" -RUN_URL="${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY:-local/kandelo}/actions/runs/${GITHUB_RUN_ID:-local}" +if [ "$PROVENANCE_KIND" = local-test ]; then + RUN_URL=local-test +else + for name in GITHUB_SERVER_URL GITHUB_REPOSITORY GITHUB_RUN_ID GITHUB_JOB RUNNER_OS; do + require_env "$name" + done + RUN_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" +fi INPUT_JSON="$KANDELO_HOMEBREW_SIDECAR_ROOT/sidecars-input.json" mkdir -p "$KANDELO_HOMEBREW_SIDECAR_ROOT" @@ -201,6 +217,7 @@ if [ -d "$FORMULA_SOURCE_ROOT/Kandelo" ]; then fi export ABI_VERSION CACHE_KEY_SHA SDK_FINGERPRINT SYSROOT_FINGERPRINT FORMULA_SHA256 BREW_VERSION export TAP_COMMIT TAP_CHECKOUT_COMMIT KANDELO_COMMIT GENERATED_AT RUN_URL +export PROVENANCE_KIND export TAP_NAME KANDELO_ROOT FORMULA_SOURCE_ROOT export FORMULA_PATH @@ -211,6 +228,7 @@ import pathlib import re import subprocess import sys +from urllib.parse import unquote, urlsplit out_path = pathlib.Path(sys.argv[1]) formula = os.environ["KANDELO_HOMEBREW_FORMULA"] @@ -251,9 +269,44 @@ if not isinstance(bottle_entry, dict) or set(bottle_entry) != {"formula", "bottl raise SystemExit("canonical bottle JSON entry has unexpected fields") bottle_formula = bottle_entry["formula"] bottle = bottle_entry["bottle"] -if not isinstance(bottle_formula, dict) or set(bottle_formula) != {"name", "path", "pkg_version"}: +# The runtime-evidence validator authenticates these same bytes immediately +# before this projection. Keep the exact raw/minimal key sets here as well: +# this shell-owned consumer must not silently discard new builder metadata. +# test-homebrew-tap-native-sidecars.sh cross-checks both accepted shapes. +minimal_formula_keys = {"name", "path", "pkg_version"} +raw_formula_keys = { + "desc", + "homepage", + "license", + "name", + "path", + "pkg_version", + "tap_git_path", + "tap_git_remote", + "tap_git_revision", +} +raw_bottle_keys = {"cellar", "date", "rebuild", "root_url", "tags"} +raw_tag_keys = { + "all_files", + "filename", + "installed_size", + "local_filename", + "path_exec_files", + "sbom", + "sha256", + "tab", +} +if not isinstance(bottle_formula, dict): raise SystemExit("canonical bottle Formula metadata has unexpected fields") -if not isinstance(bottle, dict) or set(bottle) != {"root_url", "cellar", "rebuild", "tags"}: +raw_builder_json = set(bottle_formula) == raw_formula_keys +if set(bottle_formula) != (raw_formula_keys if raw_builder_json else minimal_formula_keys): + raise SystemExit("canonical bottle Formula metadata has unexpected fields") +expected_bottle_keys = ( + raw_bottle_keys + if raw_builder_json + else {"root_url", "cellar", "rebuild", "tags"} +) +if not isinstance(bottle, dict) or set(bottle) != expected_bottle_keys: raise SystemExit("canonical bottle metadata has unexpected fields") tag_name = f"{arch}_kandelo" if not isinstance(bottle.get("tags"), dict) or set(bottle["tags"]) != {tag_name}: @@ -261,7 +314,8 @@ if not isinstance(bottle.get("tags"), dict) or set(bottle["tags"]) != {tag_name} tag = bottle["tags"].get(tag_name) if tag is None: raise SystemExit(f"bottle JSON lacks tag {tag_name}; tags={list(bottle['tags'])}") -if not isinstance(tag, dict) or set(tag) != {"sha256"}: +expected_tag_keys = raw_tag_keys if raw_builder_json else {"sha256"} +if not isinstance(tag, dict) or set(tag) != expected_tag_keys: raise SystemExit(f"canonical bottle tag {tag_name} has unexpected fields") root_url = bottle.get("root_url") if ( @@ -280,9 +334,9 @@ if isinstance(rebuild, bool) or not isinstance(rebuild, int) or rebuild < 0: raise SystemExit("canonical bottle JSON has an invalid rebuild") expected_full_name = f"{os.environ['TAP_NAME']}/{formula}" -if formula_key != formula: +if formula_key != expected_full_name: raise SystemExit( - f"canonical bottle formula key {formula_key!r} does not match {formula!r}" + f"canonical bottle formula key {formula_key!r} does not match {expected_full_name!r}" ) if bottle_formula.get("name") != formula: raise SystemExit( @@ -294,6 +348,86 @@ if bottle_formula.get("path") != formula_path: raise SystemExit( f"bottle formula path {bottle_formula.get('path')!r} does not match {formula_path!r}" ) +if raw_builder_json: + if bottle_formula["tap_git_path"] != f"Formula/{formula}.rb": + raise SystemExit("canonical Formula tap Git path does not match") + if bottle_formula["tap_git_revision"] != os.environ["TAP_CHECKOUT_COMMIT"]: + raise SystemExit("canonical Formula tap Git revision does not match") + for field in ("desc", "homepage", "license", "tap_git_remote"): + value = bottle_formula[field] + if ( + not isinstance(value, str) + or not value + or "\0" in value + or len(value.encode("utf-8")) > 4096 + ): + raise SystemExit(f"canonical Formula {field} is invalid") + if not re.fullmatch(r"https?://[^\s]+", bottle_formula["homepage"]): + raise SystemExit("canonical Formula homepage is invalid") + remote = bottle_formula["tap_git_remote"] + repository = os.environ["KANDELO_HOMEBREW_TAP_REPOSITORY"].lower() + if remote not in { + f"https://github.com/{repository}", + f"https://github.com/{repository}.git", + }: + parsed = urlsplit(remote) + if ( + parsed.scheme != "file" + or parsed.netloc + or parsed.query + or parsed.fragment + ): + raise SystemExit("canonical Formula tap Git remote does not match the exact tap") + remote_path = pathlib.Path(unquote(parsed.path)) + try: + resolved_remote = remote_path.resolve(strict=True) + except OSError: + raise SystemExit("canonical Formula tap Git remote does not resolve") + if ( + not remote_path.is_absolute() + or remote_path.is_symlink() + or resolved_remote != remote_path + ): + raise SystemExit("canonical Formula tap Git remote is not one exact real path") + try: + remote_head = subprocess.run( + ["git", "-C", str(remote_path), "rev-parse", "HEAD"], + check=False, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=30, + ) + remote_status = subprocess.run( + [ + "git", + "-C", + str(remote_path), + "status", + "--short", + "--untracked-files=all", + ], + check=False, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=30, + ) + except (OSError, subprocess.SubprocessError) as error: + raise SystemExit( + f"cannot authenticate canonical Formula tap Git remote: {error}" + ) + if ( + remote_head.returncode != 0 + or remote_head.stdout.decode("ascii", errors="replace").strip() + != bottle_formula["tap_git_revision"] + or remote_status.returncode != 0 + or remote_status.stdout + or remote_status.stderr + ): + raise SystemExit( + "canonical Formula tap Git remote does not identify the clean exact checkout" + ) if tag.get("sha256") != os.environ["CACHE_KEY_SHA"]: raise SystemExit("bottle JSON sha256 does not match the produced bottle archive") expected_bottle_url = ( @@ -322,6 +456,42 @@ if not isinstance(version, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._+,- revision_match = re.fullmatch(r".+_([1-9][0-9]*)", version) formula_revision = int(revision_match.group(1)) if revision_match else 0 payload_root = f"{formula}/{version}" +if raw_builder_json: + if not isinstance(bottle["date"], str) or not re.fullmatch( + r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z", + bottle["date"], + ): + raise SystemExit("canonical bottle date is invalid") + rebuild_suffix = f".{rebuild}" if rebuild else "" + expected_filename = f"{formula}-{version}.{tag_name}.bottle{rebuild_suffix}.tar.gz" + expected_local_filename = ( + f"{formula}--{version}.{tag_name}.bottle{rebuild_suffix}.tar.gz" + ) + if ( + tag["filename"] != expected_filename + or tag["local_filename"] != expected_local_filename + ): + raise SystemExit("canonical bottle filenames do not match") + if ( + isinstance(tag["installed_size"], bool) + or not isinstance(tag["installed_size"], int) + or tag["installed_size"] <= 0 + ): + raise SystemExit("canonical bottle installed size must be positive") + for field in ("all_files", "path_exec_files"): + values = tag[field] + if not isinstance(values, list) or len(values) > 65536: + raise SystemExit(f"canonical bottle {field} must be a bounded array") + for value in values: + if ( + not isinstance(value, str) + or not value + or "\0" in value + or len(value.encode("utf-8")) > 4096 + ): + raise SystemExit(f"canonical bottle {field} contains an invalid path") + if not isinstance(tag["tab"], dict) or not isinstance(tag["sbom"], dict): + raise SystemExit("canonical bottle tab and SBOM must be objects") def run_json_command(command, label, maximum_bytes, timeout=None): try: @@ -793,8 +963,8 @@ manifest = { "env": link_env, "build": { "github_run": os.environ["RUN_URL"], - "job": os.environ.get("GITHUB_JOB", "local"), - "runner_os": os.environ.get("RUNNER_OS", "local"), + "job": os.environ.get("GITHUB_JOB", os.environ["PROVENANCE_KIND"]), + "runner_os": os.environ.get("RUNNER_OS", os.environ["PROVENANCE_KIND"]), "brew_version": os.environ["BREW_VERSION"], "dev_shell": "scripts/dev-shell.sh", "sdk_fingerprint": os.environ["SDK_FINGERPRINT"], @@ -846,6 +1016,17 @@ manifest = { out_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") PY +if [ "$PROVENANCE_KIND" = local-test ]; then + cat >"$KANDELO_HOMEBREW_SIDECAR_ROOT/local-test-provenance.json" <<'EOF' +{ + "schema": 1, + "provenance_kind": "local-test", + "promotable": false, + "published": false +} +EOF +fi + mkdir -p "$KANDELO_HOMEBREW_SIDECAR_ROOT/Formula" cp "$MERGED_FORMULA_PATH" \ "$KANDELO_HOMEBREW_SIDECAR_ROOT/Formula/" diff --git a/scripts/homebrew-main-shell-image-contract.test.ts b/scripts/homebrew-main-shell-image-contract.test.ts index 9a044cb8ce..e7d9ef8aa3 100644 --- a/scripts/homebrew-main-shell-image-contract.test.ts +++ b/scripts/homebrew-main-shell-image-contract.test.ts @@ -244,6 +244,27 @@ test("accepts the exact reviewed root and Formula identities", () => { assert.doesNotThrow(() => assertMainShellImageContract(fixture())); }); +test("binds the ABI 43 login product inputs", () => { + assert.equal( + lock.catalog.tap_commit, + "af70e3ba06367dbafb8a95fabbacc3e1352b58b2", + ); + assert.deepEqual( + lock.packages + .map((entry: any) => entry.formula.name) + .filter((name: string) => + ["login", "sudo-lite", "sudo", "ruby"].includes(name), + ), + ["login", "sudo-lite", "sudo", "ruby"], + ); + assert.equal(runtimeSupport.activation.requiredKernelAbi, 43); + assert.equal(runtimeSupport.availability.auditedCatalog.kandeloAbi, 43); + assert.equal( + runtimeSupport.availability.auditedCatalog.releaseTag, + "bottles-abi-v43", + ); +}); + test("bounds operational Homebrew downloads to the reviewed support closure", () => { assert.doesNotThrow(() => assertMainShellOperationalRuntimeFetches(runtimeSupport, []), diff --git a/scripts/homebrew-main-shell-node-smoke.ts b/scripts/homebrew-main-shell-node-smoke.ts index 0098242735..82cf018955 100755 --- a/scripts/homebrew-main-shell-node-smoke.ts +++ b/scripts/homebrew-main-shell-node-smoke.ts @@ -42,6 +42,17 @@ import { declaredVfsMaxByteLength, } from "../web-libs/kandelo-session/src/vfs-capacity"; import { parseHomebrewRuntimeSupportContract } from "../host/src/homebrew-runtime-support"; +import { appendProcessTreeRssSample } from "./measure-homebrew-vfork-rss"; +import { + assertLocalTestHomebrewTapBundle, + projectLocalTestHomebrewTapBundleBinding, + type LocalTestHomebrewTapBundleBinding, +} from "../host/src/homebrew-vfs-builder"; +import { + createReviewedPrivilegedProgramPolicy, + publishPrivilegedProgramProduct, + type PrivilegedProgramProjection, +} from "../host/src/vfs/privileged-projection"; const { imagePath, @@ -54,6 +65,9 @@ const { demoConfigPath, transportMode, bottleMirrorPlanPath, + rssReportPath, + compositionReportPath, + privilegedProductPath, } = parseArgs(process.argv.slice(2)); if (homebrewBootstrapState !== "deferred") { throw new Error( @@ -106,9 +120,23 @@ assertVfsImageFitsProfile( const fs = MemoryFileSystem.fromImage(imageBytes, { maxByteLength: MAIN_SHELL_VFS_PROFILE_MAX_BYTES, }); -// WHY: the smoke exports lazy state as acceptance evidence, so imported -// atomic seals must be authenticated before the synchronous assertions run. +// WHY: product publication reads program sources from the imported image, so +// authenticate its lazy atomic seals before any composition or product read. await fs.verifyImportedLazyAtomicGroupSeals(); +const compositionReport = compositionReportPath === undefined + ? undefined + : parseJson( + readRegularFile(compositionReportPath, "composition report"), + compositionReportPath, + ); +const privilegedProduct = + compositionReport === undefined || privilegedProductPath === undefined + ? undefined + : await createNodePrivilegedProduct( + fs, + compositionReport, + readRegularFile(privilegedProductPath, "serialized privileged product"), + ); assertPackageDeferredZipTreeState( fs, homebrewBootstrapTree, @@ -228,11 +256,8 @@ if (pendingTrees.length !== mirrorPlan.assets.length) { ); } assertPendingTreeHomebrewBottleMirrorBinding(pendingTrees, mirrorPlan); -const expectedPendingBottlePackages = - guestPendingBottlePackages(guestManifest); -const mirrorPackages = mirrorPlan.assets - .map((asset) => asset.package) - .sort(); +const expectedPendingBottlePackages = guestPendingBottlePackages(guestManifest); +const mirrorPackages = mirrorPlan.assets.map((asset) => asset.package).sort(); if ( JSON.stringify(mirrorPackages) !== JSON.stringify(expectedPendingBottlePackages) @@ -319,6 +344,14 @@ const host = new NodeKernelHost({ }, }); +const sampleNodeRss = (phase: string): void => { + if (rssReportPath === undefined) return; + appendProcessTreeRssSample({ + phase, + roots: new Map([["node", process.pid]]), + out: rssReportPath, + }); +}; await host.init(); try { const offlineCommand = ` @@ -497,10 +530,7 @@ printf 'homebrew-atomic-runtime-activated\n' homebrewBootstrapTransportUrl, ); assertFetchedPackageSet( - withoutTransportUrl( - runtimeActivationEvents, - homebrewBootstrapTransportUrl, - ), + withoutTransportUrl(runtimeActivationEvents, homebrewBootstrapTransportUrl), pendingTrees, mirrorPlan, RUNTIME_SUPPORT_EXPECTED_PACKAGES, @@ -560,10 +590,7 @@ printf 'homebrew-operational-runtime-ok\n' ); const brewStdout = stdout.slice(brewOperationStdoutStart); const brewStderr = stderr.slice(brewOperationStderrStart); - if ( - brewStdout !== "homebrew-operational-runtime-ok\n" || - brewStderr !== "" - ) { + if (brewStdout !== "homebrew-operational-runtime-ok\n" || brewStderr !== "") { throw new Error( `operational Homebrew runtime returned unexpected output; ` + `stdout=${JSON.stringify(brewStdout)} stderr=${JSON.stringify(brewStderr)}`, @@ -599,11 +626,211 @@ printf 'homebrew-operational-runtime-ok\n' "repeated Homebrew runtime use", ); - const expectedFetchedPackages = [...new Set([ + if (privilegedProduct !== undefined) { + const catalog = asRecord( + asRecord(migrationLock, "migration lock").catalog, + "migration lock catalog", + ); + const productTapCommit = catalog.tap_commit; + if ( + typeof productTapCommit !== "string" || + !/^[0-9a-f]{40}$/.test(productTapCommit) + ) { + throw new Error("migration lock catalog tap commit is invalid"); + } + const stagedTap = readLocalTestTapBinding( + compositionReport, + productTapCommit, + ); + assertLocalTestHomebrewTapBundle(fs, stagedTap); + let loginPtyOutput = ""; + const loginHost = new NodeKernelHost({ + maxWorkers: 8, + rootfsImage: imageBytes, + rootfsLazyUrlBase: homebrewBootstrapLazyBase, + rootfsLazyAssets: closedLazyAssets, + privilegedProduct, + enableTcpNetwork: true, + onPtyOutput: (_pid, data) => { + loginPtyOutput += new TextDecoder().decode(data); + }, + }); + const markers: string[] = []; + const encoder = new TextEncoder(); + const waitFrom = async ( + needle: string, + start: number, + label: string, + ): Promise => { + const deadline = Date.now() + 120_000; + while (!loginPtyOutput.slice(start).includes(needle)) { + if (Date.now() >= deadline) { + throw new Error( + `${label} timed out; pty=${JSON.stringify(loginPtyOutput.slice(-4096))}`, + ); + } + await new Promise((resolveDelay) => setTimeout(resolveDelay, 20)); + } + }; + const write = (pid: number, text: string): void => { + loginHost.ptyWrite(pid, encoder.encode(text)); + }; + const command = async ( + pid: number, + text: string, + marker: string, + ): Promise => { + const start = loginPtyOutput.length; + write(pid, `(${text}) && printf '${marker}\\n'\n`); + await waitFrom(marker, start, marker); + markers.push(marker); + }; + const spawnLogin = (automatic: boolean) => + loginHost.spawnFromVfs( + "/usr/bin/login", + automatic ? ["login", "-p", "-f", "maker"] : ["login", "-p"], + { + env: [ + "TERM=xterm-kandelo", + "PATH=/opt/kandelo/homebrew/bin:/usr/bin:/bin", + ], + cwd: "/", + uid: 0, + gid: 0, + pty: true, + ptyCols: 100, + ptyRows: 30, + }, + ); + try { + sampleNodeRss("before-boot"); + await loginHost.init(); + const automatic = await spawnLogin(true); + await waitFrom( + "Every new terminal logs in automatically.", + 0, + "automatic maker login", + ); + markers.push("automatic-maker-login-ok"); + await command(automatic.pid, "id | grep 'uid=1000'", "maker-id-ok"); + + let interactionStart = loginPtyOutput.length; + write(automatic.pid, "/usr/bin/sudo -S -k id\n"); + await waitFrom("Password:", interactionStart, "sudo password prompt"); + interactionStart = loginPtyOutput.length; + write(automatic.pid, "definitely-wrong\n"); + await waitFrom( + "Sorry, try again", + interactionStart, + "failed sudo password rejection", + ); + markers.push("failed-sudo-password-ok"); + interactionStart = loginPtyOutput.length; + write(automatic.pid, "kandelo\n"); + await waitFrom("uid=0", interactionStart, "sudo root identity"); + markers.push("sudo-id-ok"); + await command( + automatic.pid, + "printf 'kandelo\\n' | /usr/bin/sudo -S -l >/dev/null", + "sudo-list-ok", + ); + await command( + automatic.pid, + "cp /usr/bin/sudo-lite /tmp/sudo-lite && chmod 4755 /tmp/sudo-lite && ! /tmp/sudo-lite id >/dev/null 2>&1", + "nosuid-copy-rejected", + ); + + sampleNodeRss("before-ruby"); + for (let repetition = 1; repetition <= 3; repetition += 1) { + const live = `ruby-child-${repetition}-live`; + const reaped = `ruby-child-${repetition}-reaped`; + const repetitionStart = loginPtyOutput.length; + write( + automatic.pid, + `ruby --disable-gems -e 'require "rbconfig"; p=Process.spawn(RbConfig.ruby,"--disable-gems","-e","sleep 2"); puts "${live}"; STDOUT.flush; Process.wait(p); puts "${reaped}"'\n`, + ); + await waitFrom(live, repetitionStart, live); + if (repetition === 1) sampleNodeRss("peak"); + await waitFrom(reaped, repetitionStart, reaped); + if (repetition === 1) sampleNodeRss("after-child-reaping"); + markers.push(reaped); + } + sampleNodeRss("after-three-repetitions"); + await command( + automatic.pid, + "irb --version >/dev/null && erb --version >/dev/null && gem --version >/dev/null && bundle --version >/dev/null && rake --version >/dev/null", + "ruby-stock-tools-ok", + ); + write(automatic.pid, "exit\n"); + await automatic.exit; + + const failedLoginStart = loginPtyOutput.length; + const failedLogin = await spawnLogin(false); + await waitFrom("login: ", failedLoginStart, "ordinary login prompt"); + interactionStart = loginPtyOutput.length; + write(failedLogin.pid, "maker\n"); + await waitFrom( + "Password: ", + interactionStart, + "ordinary password prompt", + ); + interactionStart = loginPtyOutput.length; + write(failedLogin.pid, "definitely-wrong\n"); + await waitFrom( + "Login incorrect", + interactionStart, + "ordinary failed password", + ); + await failedLogin.exit; + + const ordinaryStart = loginPtyOutput.length; + const ordinary = await spawnLogin(false); + await waitFrom("login: ", ordinaryStart, "ordinary retry login prompt"); + interactionStart = loginPtyOutput.length; + write(ordinary.pid, "maker\n"); + await waitFrom( + "Password: ", + interactionStart, + "ordinary retry password prompt", + ); + write(ordinary.pid, "kandelo\n"); + await command(ordinary.pid, "id | grep 'uid=1000'", "ordinary-login-ok"); + await command( + ordinary.pid, + `export HOMEBREW_NO_ANALYTICS=1 HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_FROM_API=1 HOMEBREW_AUTOMATICALLY_SET_NO_INSTALL_FROM_API=1 HOMEBREW_REQUIRE_TAP_TRUST=1 GIT_TERMINAL_PROMPT=0; /usr/bin/brew tap kandelo-dev/tap-core file:///opt/kandelo/homebrew/var/kandelo/local-test/homebrew-tap-core.bundle && tap=$(/usr/bin/brew --repository kandelo-dev/tap-core) && test "$(/opt/kandelo/homebrew/bin/git -C "$tap" rev-parse HEAD)" = ${stagedTap.prepared_commit} && /opt/kandelo/homebrew/bin/git -C "$tap" cat-file -e ${stagedTap.source_commit}^\\{commit\\} && /opt/kandelo/homebrew/bin/git -C "$tap" merge-base --is-ancestor ${stagedTap.source_commit} ${stagedTap.prepared_commit} && /usr/bin/brew uninstall --ignore-dependencies kandelo-dev/tap-core/bzip2 && /usr/bin/brew trust --formula kandelo-dev/tap-core/bzip2 && /usr/bin/brew install --no-ask --force-bottle kandelo-dev/tap-core/bzip2 && prefix=$(/usr/bin/brew --prefix kandelo-dev/tap-core/bzip2) && printf 'login-product-bzip2\\n' > /tmp/login-product-bzip2 && "$prefix/bin/bzip2" -f /tmp/login-product-bzip2 && "$prefix/bin/bzip2" -d -f /tmp/login-product-bzip2.bz2 && grep -Fx login-product-bzip2 /tmp/login-product-bzip2 >/dev/null`, + "brew-tap-install-execute-ok", + ); + write(ordinary.pid, "exit\n"); + await ordinary.exit; + const expectedMarkers = [ + "automatic-maker-login-ok", + "maker-id-ok", + "sudo-list-ok", + "sudo-id-ok", + "failed-sudo-password-ok", + "ordinary-login-ok", + "nosuid-copy-rejected", + "ruby-child-3-reaped", + "ruby-stock-tools-ok", + "brew-tap-install-execute-ok", + ]; + if (expectedMarkers.some((marker) => !markers.includes(marker))) { + throw new Error( + `Node generated login product omitted markers: ${JSON.stringify(markers)}`, + ); + } + } finally { + await loginHost.destroy().catch(() => {}); + } + } + + const expectedFetchedPackages = [ + ...new Set([ ...BASE_EXPECTED_FETCHED_PACKAGES, ...RUNTIME_SUPPORT_EXPECTED_PACKAGES, ...operationalRuntimePackages, - ])].sort(); + ]), + ].sort(); assertFetchedPackageSet( withoutTransportUrl(lazyDownloads, homebrewBootstrapTransportUrl), pendingTrees, @@ -1174,6 +1401,7 @@ async function spawnWithTimeout( argv: string[], label: string, output: () => { stdout: string; stderr: string }, + whileRunning?: () => Promise, ): Promise { let timeout: ReturnType | undefined; try { @@ -1195,6 +1423,7 @@ async function spawnWithTimeout( 120_000, ); }); + if (whileRunning !== undefined) await whileRunning(); const exitCode = await Promise.race([exitPromise, timeoutPromise]); if (exitCode !== 0) { const captured = output(); @@ -1219,6 +1448,9 @@ function parseArgs(args: string[]): { demoConfigPath: string; transportMode: "closed" | "public"; bottleMirrorPlanPath?: string; + rssReportPath?: string; + compositionReportPath?: string; + privilegedProductPath?: string; } { const values = new Map(); const allowed = new Set([ @@ -1232,6 +1464,9 @@ function parseArgs(args: string[]): { "--demo-config", "--transport-mode", "--bottle-mirror-plan", + "--rss-report", + "--composition-report", + "--privileged-product", ]); for (let index = 0; index < args.length; index += 2) { const option = args[index]; @@ -1256,6 +1491,9 @@ function parseArgs(args: string[]): { const demoConfig = values.get("--demo-config"); const mode = values.get("--transport-mode"); const plan = values.get("--bottle-mirror-plan"); + const rssReport = values.get("--rss-report"); + const compositionReport = values.get("--composition-report"); + const privilegedProduct = values.get("--privileged-product"); if ( !image || !migrationLock || @@ -1268,7 +1506,8 @@ function parseArgs(args: string[]): { homebrewBootstrapState !== "materialized") || (mode !== "closed" && mode !== "public") || (mode === "closed" && !plan) || - (mode === "public" && plan !== undefined) + (mode === "public" && plan !== undefined) || + (compositionReport === undefined) !== (privilegedProduct === undefined) ) { return smokeUsage(); } @@ -1283,6 +1522,13 @@ function parseArgs(args: string[]): { demoConfigPath: resolve(demoConfig), transportMode: mode, ...(plan === undefined ? {} : { bottleMirrorPlanPath: resolve(plan) }), + ...(rssReport === undefined ? {} : { rssReportPath: resolve(rssReport) }), + ...(compositionReport === undefined + ? {} + : { compositionReportPath: resolve(compositionReport) }), + ...(privilegedProduct === undefined + ? {} + : { privilegedProductPath: resolve(privilegedProduct) }), }; } @@ -1297,10 +1543,102 @@ function smokeUsage(): never { "--homebrew-runtime-support " + "--demo-config --transport-mode " + "[--bottle-mirror-plan ] " + + "[--rss-report ] " + + "[--composition-report ] " + + "[--privileged-product ] " + "(the plan is required only in closed mode)", ); } +async function createNodePrivilegedProduct( + fs: MemoryFileSystem, + reportValue: unknown, + serializedProduct: Uint8Array, +) { + const report = reportValue as { + privileged_programs?: { projections?: Array> }; + privileged_product?: { image?: unknown; sha256?: unknown; bytes?: unknown }; + }; + const raw = report.privileged_programs?.projections; + if (!Array.isArray(raw)) { + throw new Error("composition report omits privileged projections"); + } + const projections = raw.map((entry): PrivilegedProgramProjection => ({ + schema: entry.schema as 1, + formula: entry.formula as string, + bottleSha256: entry.bottle_sha256 as string, + sourcePath: entry.source_path as string, + destinationPath: entry.destination_path as string, + uid: entry.uid as 0, + gid: entry.gid as 0, + mode: entry.mode as number, + mountPoint: entry.mount_point as string, + artifactValidationSha256: entry.artifact_validation_sha256 as string, + })); + const product = await publishPrivilegedProgramProduct({ + policy: createReviewedPrivilegedProgramPolicy(projections), + sources: projections.map((projection) => { + const guestPath = `/opt/kandelo/homebrew/Cellar/${projection.sourcePath}`; + return { + formula: projection.formula, + bottleSha256: projection.bottleSha256, + fs, + inventory: { + entries: [ + { + sourcePath: projection.sourcePath, + type: "file" as const, + size: fs.stat(guestPath).size, + }, + ], + }, + guestPathForSource: (sourcePath: string) => + `/opt/kandelo/homebrew/Cellar/${sourcePath}`, + }; + }), + writableBottleFileSystems: [fs], + }); + const generatedSha256 = createHash("sha256") + .update(product.imageBytes) + .digest("hex"); + const serializedSha256 = createHash("sha256") + .update(serializedProduct) + .digest("hex"); + if ( + report.privileged_product?.image !== "main-shell.vfs.privileged.vfs" || + report.privileged_product.sha256 !== serializedSha256 || + report.privileged_product.bytes !== serializedProduct.byteLength || + generatedSha256 !== serializedSha256 || + product.imageBytes.byteLength !== serializedProduct.byteLength + ) { + throw new Error( + "Node published privileged product differs from the exact serialized artifact", + ); + } + return product; +} + +function readLocalTestTapBinding( + reportValue: unknown, + expectedSourceCommit: string, +): LocalTestHomebrewTapBundleBinding { + const report = asRecord(reportValue, "composition report"); + const localTest = asRecord(report.local_test, "composition local-test"); + const binding = projectLocalTestHomebrewTapBundleBinding( + localTest.staged_tap, + ); + if ( + localTest.source_tap_commit !== expectedSourceCommit || + localTest.prepared_tap_commit !== binding.prepared_commit || + binding.source_commit !== expectedSourceCommit + ) { + throw new Error( + "composition staged tap differs from its source/prepared report binding", + ); + } + return binding; +} + function readRuntimeState( fs: MemoryFileSystem, migrationLock: unknown, diff --git a/scripts/homebrew-merge-bottle-json.sh b/scripts/homebrew-merge-bottle-json.sh index 6910ae9c27..0347d6faac 100755 --- a/scripts/homebrew-merge-bottle-json.sh +++ b/scripts/homebrew-merge-bottle-json.sh @@ -114,13 +114,14 @@ TAG="${ARCH}_kandelo" FORMULA_JSON_PATH="Library/Taps/${TAP_NAME%%/*}/homebrew-${TAP_NAME#*/}/Formula/${FORMULA}.rb" jq -e \ --arg formula "$FORMULA" \ + --arg formula_key "$TAP_NAME/$FORMULA" \ --arg formula_path "$FORMULA_JSON_PATH" \ --arg tag "$TAG" \ --arg sha "$EXPECTED_SHA256" \ --arg root "$EXPECTED_ROOT_URL" \ --arg cellar "$EXPECTED_CELLAR" ' (keys | length) == 1 and - (to_entries[0].key == $formula) and + (to_entries[0].key == $formula_key) and (to_entries[0].value.formula.name == $formula) and (to_entries[0].value.formula.path == $formula_path) and (to_entries[0].value.bottle.root_url == $root) and diff --git a/scripts/homebrew-oci-layout.py b/scripts/homebrew-oci-layout.py index 40d542e977..426258b6d5 100755 --- a/scripts/homebrew-oci-layout.py +++ b/scripts/homebrew-oci-layout.py @@ -389,7 +389,7 @@ def canonical_bottle(args: argparse.Namespace) -> dict[str, Any]: if not isinstance(document, dict) or len(document) != 1: fail("canonical bottle JSON must contain exactly one Formula") key, record = next(iter(document.items())) - if key != args.formula: + if key != f"{selected_tap_name(args)}/{args.formula}": fail("canonical bottle JSON Formula key does not match") record = exact_keys(record, {"formula", "bottle"}, "canonical bottle record") formula = exact_keys( diff --git a/scripts/homebrew-patched-launcher.sh b/scripts/homebrew-patched-launcher.sh index cbca2db0b4..6e96bf5c16 100644 --- a/scripts/homebrew-patched-launcher.sh +++ b/scripts/homebrew-patched-launcher.sh @@ -6,6 +6,7 @@ HOMEBREW_PATCHED_REPO="" HOMEBREW_PATCHED_PREFIX="" HOMEBREW_PATCHED_OVERLAY="" +HOMEBREW_PATCHED_SEED_BREW_BIN="" HOMEBREW_PATCHED_LAUNCHER="" HOMEBREW_PATCHED_BREW_BIN="" HOMEBREW_PATCHED_PROTECTED_DIR="" @@ -1570,6 +1571,222 @@ homebrew_patched_launcher_snapshot_target_cellar_layout() { fi } +# Homebrew reports an installed Formula's stable opt path from `brew --prefix +# `. Resolve that logical path only after proving it is the exact +# canonical opt link for the selected Formula. Callers that need receipt or +# keg identity must not mistake the Formula name at the end of the opt path +# for the installed version at the end of the physical Cellar path. +homebrew_patched_launcher_resolve_installed_formula_keg() { + if [ "$#" -ne 3 ]; then + echo "homebrew_patched_launcher_resolve_installed_formula_keg: expected BREW FORMULA-REF FORMULA" >&2 + return 2 + fi + local brew_bin="$1" formula_ref="$2" formula="$3" + local tap_ref logical_prefix expected_opt target_rack target_keg version + + [ -n "$HOMEBREW_PATCHED_PREFIX" ] && + [ "$brew_bin" = "$HOMEBREW_PATCHED_BREW_BIN" ] && + [ -f "$brew_bin" ] && [ ! -L "$brew_bin" ] && [ -x "$brew_bin" ] || { + echo "homebrew-patched-launcher: Formula keg resolution requires the active protected Brew wrapper" >&2 + return 2 + } + tap_ref="${formula_ref%/*}" + [[ "$formula" =~ ^[a-z0-9][a-z0-9._-]*$ ]] && + [ "${formula_ref##*/}" = "$formula" ] && + [[ "$tap_ref" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || { + echo "homebrew-patched-launcher: Formula keg resolution received an invalid Formula identity" >&2 + return 2 + } + logical_prefix="$("$brew_bin" --prefix "$formula_ref")" || return + expected_opt="$HOMEBREW_PATCHED_PREFIX/opt/$formula" + target_rack="$HOMEBREW_PATCHED_PREFIX/Cellar/$formula" + [ "$logical_prefix" = "$expected_opt" ] && + [ -L "$logical_prefix" ] && + [ -d "$target_rack" ] && [ ! -L "$target_rack" ] && + [ "$(cd "$target_rack" && pwd -P)" = "$target_rack" ] || { + echo "homebrew-patched-launcher: Formula prefix is not the exact canonical opt link" >&2 + return 1 + } + target_keg="$(cd "$logical_prefix" && pwd -P)" || return + version="${target_keg##*/}" + [[ "$version" =~ ^[A-Za-z0-9][A-Za-z0-9._+,-]{0,255}$ ]] && + [ "$target_keg" = "$target_rack/$version" ] && + [ -d "$target_keg" ] && [ ! -L "$target_keg" ] && + [ "$(readlink "$logical_prefix")" = "../Cellar/$formula/$version" ] || { + echo "homebrew-patched-launcher: Formula opt link does not select one canonical installed keg" >&2 + return 1 + } + printf '%s\n' "$target_keg" +} + +# `brew list --versions --formula` returns status 1 with no output when the +# exact fully-qualified Formula is absent. Preserve that narrow semantic while +# rejecting command failures and warnings that an `|| true` check would hide. +homebrew_patched_launcher_require_formula_absent() { + if [ "$#" -ne 2 ]; then + echo "homebrew_patched_launcher_require_formula_absent: expected BREW FORMULA-REF" >&2 + return 2 + fi + local brew_bin="$1" formula_ref="$2" formula tap_ref output status + + [ -n "$HOMEBREW_PATCHED_PREFIX" ] && + [ "$brew_bin" = "$HOMEBREW_PATCHED_BREW_BIN" ] && + [ -f "$brew_bin" ] && [ ! -L "$brew_bin" ] && [ -x "$brew_bin" ] || { + echo "homebrew-patched-launcher: Formula absence requires the active protected Brew wrapper" >&2 + return 2 + } + formula="${formula_ref##*/}" + tap_ref="${formula_ref%/*}" + [[ "$formula" =~ ^[a-z0-9][a-z0-9._-]*$ ]] && + [[ "$tap_ref" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || { + echo "homebrew-patched-launcher: Formula absence received an invalid Formula identity" >&2 + return 2 + } + if output="$("$brew_bin" list --versions --formula "$formula_ref" 2>&1)"; then + status=0 + else + status=$? + fi + [ "$status" -eq 1 ] && [ -z "$output" ] || { + echo "homebrew-patched-launcher: Formula absence query did not report exact absence" >&2 + return 1 + } +} + +# A hosted bottle build and its verifier use different fresh runners. The +# local batch harness deliberately reuses one prefix, so remove only the +# source-built target through the still-active protected Brew wrapper before +# that bottle is independently poured. Installed dependencies must remain +# byte-for-byte the same Cellar layout for the next phase and Formula. +homebrew_patched_launcher_retire_source_target() { + if [ "$#" -ne 7 ]; then + echo "homebrew_patched_launcher_retire_source_target: expected BREW FORMULA-REF FORMULA VERSION BOTTLE BOTTLE-JSON RECEIPT" >&2 + return 2 + fi + local brew_bin="$1" formula_ref="$2" formula="$3" version="$4" + local bottle="$5" bottle_json="$6" receipt="$7" + local tap_ref target_rack target_keg target_opt expected_opt installed dependents + local before_layout expected_layout after_layout target_entries=0 entry + local bottle_state bottle_json_state bottle_sha bottle_json_sha state links + + [ -n "$HOMEBREW_PATCHED_BUILD_USER" ] && + [ -n "$HOMEBREW_PATCHED_PREFIX" ] && + [ "$brew_bin" = "$HOMEBREW_PATCHED_BREW_BIN" ] && + [ -f "$brew_bin" ] && [ ! -L "$brew_bin" ] && [ -x "$brew_bin" ] || { + echo "homebrew-patched-launcher: source target retirement requires the active protected Brew wrapper" >&2 + return 2 + } + tap_ref="${formula_ref%/*}" + [[ "$formula" =~ ^[a-z0-9][a-z0-9._-]*$ ]] && + [ "${formula_ref##*/}" = "$formula" ] && + [[ "$tap_ref" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] && + [[ "$version" =~ ^[A-Za-z0-9][A-Za-z0-9._+,-]{0,255}$ ]] || { + echo "homebrew-patched-launcher: source target retirement received an invalid Formula identity" >&2 + return 2 + } + target_rack="$HOMEBREW_PATCHED_PREFIX/Cellar/$formula" + target_keg="$target_rack/$version" + target_opt="$HOMEBREW_PATCHED_PREFIX/opt/$formula" + expected_opt="../Cellar/$formula/$version" + [ -d "$target_rack" ] && [ ! -L "$target_rack" ] && + [ -d "$target_keg" ] && [ ! -L "$target_keg" ] && + [ -L "$target_opt" ] && + [ "$(readlink "$target_opt")" = "$expected_opt" ] && + [ "$(cd "$target_opt" && pwd -P)" = "$target_keg" ] || { + echo "homebrew-patched-launcher: source target retirement found a changed target keg" >&2 + return 1 + } + + for entry in "$bottle" "$bottle_json" "$receipt"; do + [ -f "$entry" ] && [ ! -L "$entry" ] || { + echo "homebrew-patched-launcher: source target retirement input is not a regular file: $entry" >&2 + return 2 + } + if state="$(stat -c '%d:%i:%u:%g:%a:%h:%s' "$entry" 2>/dev/null)"; then + links="$(stat -c '%h' "$entry")" + else + state="$(stat -f '%d:%i:%u:%g:%Lp:%l:%z' "$entry")" || return 2 + links="$(stat -f '%l' "$entry")" || return 2 + fi + [ "$links" = 1 ] || { + echo "homebrew-patched-launcher: source target retirement input is not single-linked: $entry" >&2 + return 2 + } + case "$entry" in + "$bottle") bottle_state="$state" ;; + "$bottle_json") bottle_json_state="$state" ;; + esac + done + bottle_sha="$(homebrew_sha256_stream <"$bottle")" || return 2 + bottle_json_sha="$(homebrew_sha256_stream <"$bottle_json")" || return 2 + + installed="$("$brew_bin" list --versions --formula "$formula_ref")" || return + [ "$installed" = "$formula $version" ] || { + echo "homebrew-patched-launcher: source target retirement selected a different installed Formula: $installed" >&2 + return 1 + } + dependents="$("$brew_bin" uses --installed --formula "$formula_ref")" || return + [ -z "$dependents" ] || { + echo "homebrew-patched-launcher: source target retirement would remove an installed dependency: $dependents" >&2 + return 1 + } + before_layout="$(homebrew_patched_launcher_snapshot_target_cellar_layout)" || return + expected_layout="" + while IFS= read -r entry; do + case "$entry" in + "rack:$formula"|"keg:$formula/$version") + target_entries=$((target_entries + 1)) + ;; + "keg:$formula/"*) + echo "homebrew-patched-launcher: source target retirement found an additional target keg: $entry" >&2 + return 1 + ;; + *) + if [ -z "$expected_layout" ]; then + expected_layout="$entry" + else + expected_layout="$expected_layout"$'\n'"$entry" + fi + ;; + esac + done <<<"$before_layout" + [ "$target_entries" -eq 2 ] || { + echo "homebrew-patched-launcher: source target retirement did not find one exact target rack and keg" >&2 + return 1 + } + + "$brew_bin" uninstall --formula "$formula_ref" || return + homebrew_patched_launcher_require_formula_absent \ + "$brew_bin" "$formula_ref" || return + [ ! -e "$target_rack" ] && [ ! -L "$target_rack" ] && + [ ! -e "$target_opt" ] && [ ! -L "$target_opt" ] || { + echo "homebrew-patched-launcher: source target retirement left the target installed" >&2 + return 1 + } + after_layout="$(homebrew_patched_launcher_snapshot_target_cellar_layout)" || return + [ "$after_layout" = "$expected_layout" ] || { + echo "homebrew-patched-launcher: source target retirement changed installed dependencies" >&2 + return 1 + } + for entry in "$bottle" "$bottle_json"; do + if state="$(stat -c '%d:%i:%u:%g:%a:%h:%s' "$entry" 2>/dev/null)"; then + : + else + state="$(stat -f '%d:%i:%u:%g:%Lp:%l:%z' "$entry")" || return 2 + fi + case "$entry" in + "$bottle") + [ "$state" = "$bottle_state" ] && + [ "$(homebrew_sha256_stream <"$entry")" = "$bottle_sha" ] || return 1 + ;; + "$bottle_json") + [ "$state" = "$bottle_json_state" ] && + [ "$(homebrew_sha256_stream <"$entry")" = "$bottle_json_sha" ] || return 1 + ;; + esac + done +} + homebrew_patched_launcher_stage_control_file() { if [ "$#" -ne 5 ]; then echo "homebrew_patched_launcher_stage_control_file: expected KEY SOURCE BASENAME MAX_BYTES LABEL" >&2 @@ -1577,6 +1794,8 @@ homebrew_patched_launcher_stage_control_file() { fi local key="$1" source="$2" basename="$3" max_bytes="$4" label="$5" local destination source_state source_uid source_mode source_links bytes digest + local invoker_uid prefix_uid prefix_gid prefix_mode build_gid sudo_bin sudo_mode + local staging_path="" if [ -z "$HOMEBREW_PATCHED_PREFIX" ] || [ -n "$HOMEBREW_PATCHED_BUILD_USER" ]; then echo "homebrew-patched-launcher: stage the $label after preparation and before isolation" >&2 return 2 @@ -1622,8 +1841,61 @@ homebrew_patched_launcher_stage_control_file() { HOMEBREW_PATCHED_CONTROL_FILE_MAX_BYTES[$key]="$max_bytes" HOMEBREW_PATCHED_CONTROL_FILE_SHA256[$key]="" HOMEBREW_PATCHED_CONTROL_FILE_STATE[$key]="staging" - if ! cp "$source" "$destination" || ! chmod 0444 "$destination" || - ! digest="$(homebrew_sha256_stream <"$destination")"; then + invoker_uid="$(/usr/bin/id -u)" || return 2 + if source_state="$(stat -c '%u:%g:%a' "$HOMEBREW_PATCHED_PREFIX" 2>/dev/null)"; then + IFS=: read -r prefix_uid prefix_gid prefix_mode <<<"$source_state" + else + source_state="$(stat -f '%u:%g:%Lp' "$HOMEBREW_PATCHED_PREFIX")" || return 2 + IFS=: read -r prefix_uid prefix_gid prefix_mode <<<"$source_state" + fi + if [ "$prefix_uid" = "$invoker_uid" ]; then + if ! cp "$source" "$destination" || ! chmod 0444 "$destination"; then + echo "homebrew-patched-launcher: could not stage the $label" >&2 + homebrew_patched_launcher_remove_control_file "$key" || true + return 1 + fi + else + build_gid="$(/usr/bin/id -g "${KANDELO_HOMEBREW_BUILD_USER:-}" 2>/dev/null || true)" + sudo_bin="$HOMEBREW_PATCHED_SUDO_BIN" + sudo_mode="$(/usr/bin/stat -c '%a' "$sudo_bin" 2>/dev/null || true)" + if [ "$prefix_uid:$prefix_gid:$prefix_mode" != "0:$build_gid:1775" ] || + [ -z "$build_gid" ] || [ "$sudo_bin" != /usr/bin/sudo ] || + [ ! -f "$sudo_bin" ] || [ -L "$sudo_bin" ] || [ ! -x "$sudo_bin" ] || + [ "$(/usr/bin/stat -c '%u' "$sudo_bin" 2>/dev/null || true)" != 0 ] || + ! [[ "$sudo_mode" =~ ^[0-7]{3,4}$ ]] || + [ $((8#$sudo_mode & 0022)) -ne 0 ]; then + echo "homebrew-patched-launcher: reused prefix cannot stage the $label safely" >&2 + homebrew_patched_launcher_remove_control_file "$key" || true + return 2 + fi + staging_path="$( + "$sudo_bin" -n -- /usr/bin/mktemp \ + "$HOMEBREW_PATCHED_PREFIX/$basename.staging.XXXXXX" + )" || { + echo "homebrew-patched-launcher: could not stage the $label" >&2 + return 1 + } + case "$staging_path" in + "$destination".staging.??????) ;; + *) + echo "homebrew-patched-launcher: privileged $label staging path escaped the prefix" >&2 + return 1 + ;; + esac + if [ ! -f "$staging_path" ] || [ -L "$staging_path" ] || + [ "$(/usr/bin/stat -c '%u:%g:%a:%h' "$staging_path")" != "0:0:600:1" ] || + ! "$sudo_bin" -n -- /usr/bin/install -o root -g root -m 0444 -- \ + "$source" "$staging_path" || + ! "$sudo_bin" -n -- /usr/bin/ln -- "$staging_path" "$destination" || + ! "$sudo_bin" -n -- /usr/bin/rm -f -- "$staging_path"; then + echo "homebrew-patched-launcher: could not stage the $label" >&2 + "$sudo_bin" -n -- /usr/bin/rm -f -- "$staging_path" >/dev/null 2>&1 || true + homebrew_patched_launcher_remove_control_file "$key" || true + return 1 + fi + staging_path="" + fi + if ! digest="$(homebrew_sha256_stream <"$destination")"; then echo "homebrew-patched-launcher: could not stage the $label" >&2 homebrew_patched_launcher_remove_control_file "$key" || true return 1 @@ -1724,7 +1996,18 @@ homebrew_patched_launcher_remove_control_file() { echo "homebrew-patched-launcher: partial $label ownership is unsafe" >&2 return 1 fi - rm -f -- "$expected" || return + if [ "$destination_uid" = "$(/usr/bin/id -u)" ]; then + rm -f -- "$expected" || return + elif [ "$HOMEBREW_PATCHED_SUDO_BIN" = /usr/bin/sudo ] && + [ -f "$HOMEBREW_PATCHED_SUDO_BIN" ] && + [ ! -L "$HOMEBREW_PATCHED_SUDO_BIN" ] && + [ "$(/usr/bin/stat -c '%u' "$HOMEBREW_PATCHED_SUDO_BIN")" = 0 ]; then + "$HOMEBREW_PATCHED_SUDO_BIN" -n -- /usr/bin/rm -f -- \ + "$expected" || return + else + echo "homebrew-patched-launcher: refusing untrusted partial $label cleanup" >&2 + return 1 + fi fi else homebrew_patched_launcher_verify_control_file "$key" || return @@ -2589,9 +2872,162 @@ homebrew_patched_launcher_cleanup() { HOMEBREW_PATCHED_PREFIX="" HOMEBREW_PATCHED_BREW_BIN="" HOMEBREW_PATCHED_OVERLAY="" + HOMEBREW_PATCHED_SEED_BREW_BIN="" HOMEBREW_PATCHED_LAUNCHER="" } +homebrew_patched_launcher_create_as_build_user() { + if [ "$#" -ne 2 ]; then + echo "homebrew_patched_launcher_create_as_build_user: expected TARGET LINK" >&2 + return 2 + fi + local target="$1" link="$2" + local build_user="${KANDELO_HOMEBREW_BUILD_USER:-}" + local sudo_bin="${KANDELO_HOMEBREW_SUDO_BIN:-}" sudo_mode + [ -n "$build_user" ] && /usr/bin/id "$build_user" >/dev/null 2>&1 && + [ "$(/usr/bin/id -u "$build_user")" != "$(/usr/bin/id -u)" ] || return 1 + sudo_mode="$(/usr/bin/stat -c '%a' "$sudo_bin" 2>/dev/null || true)" + if [ "$sudo_bin" != /usr/bin/sudo ] || [ ! -f "$sudo_bin" ] || + [ -L "$sudo_bin" ] || [ ! -x "$sudo_bin" ] || + [ "$(/usr/bin/stat -c '%u' "$sudo_bin" 2>/dev/null || true)" != 0 ] || + ! [[ "$sudo_mode" =~ ^[0-7]{3,4}$ ]] || + [ $((8#$sudo_mode & 0022)) -ne 0 ]; then + return 1 + fi + # A prior Formula lifecycle deliberately leaves the insertion point writable + # only to the dedicated build group. Create the next ephemeral link through + # that already-declared identity, then immediately return its ownership to + # the trusted boundary before executing it. + "$sudo_bin" -n -H -u "$build_user" -- /usr/bin/ln -s -- \ + "$target" "$link" || return 1 + if ! "$sudo_bin" -n -- /usr/bin/chown -h root:root "$link" || + [ "$(/usr/bin/stat -c '%u:%g' "$link" 2>/dev/null || true)" != 0:0 ] || + [ "$(/usr/bin/readlink "$link" 2>/dev/null || true)" != "$target" ]; then + "$sudo_bin" -n -- /usr/bin/rm -f -- "$link" >/dev/null 2>&1 || true + return 1 + fi + HOMEBREW_PATCHED_SUDO_BIN="$sudo_bin" +} + +# Return the narrow Homebrew roots needed by the next trusted Brew bootstrap +# to the invoking workflow identity. The reusable publisher gives build and +# verification separate fresh runners; a local batch campaign deliberately +# reuses one target prefix, whose isolated Formula lifecycle made these roots +# build-user-owned. Never transfer the surrounding prefix, Cellar, or +# var/homebrew tree. +homebrew_patched_launcher_restore_invoker_bootstrap_roots() { + if [ "$#" -ne 2 ]; then + echo "homebrew_patched_launcher_restore_invoker_bootstrap_roots: expected BUILD_USER PREFIX" >&2 + return 2 + fi + local build_user="$1" prefix="$2" + local sudo_bin="${KANDELO_HOMEBREW_SUDO_BIN:-}" sudo_mode + local invoker_uid invoker_gid build_uid build_gid root physical unexpected + local lock_root prefix_owner + local -a roots + + [ -n "$build_user" ] && \ + [ "$build_user" = "${KANDELO_HOMEBREW_BUILD_USER:-}" ] && \ + /usr/bin/id "$build_user" >/dev/null 2>&1 || { + echo "homebrew-patched-launcher: bootstrap handoff requires the declared build user" >&2 + return 2 + } + invoker_uid="$(/usr/bin/id -u)" || return 2 + invoker_gid="$(/usr/bin/id -g)" || return 2 + build_uid="$(/usr/bin/id -u "$build_user")" || return 2 + build_gid="$(/usr/bin/id -g "$build_user")" || return 2 + [ "$build_uid" != "$invoker_uid" ] || { + echo "homebrew-patched-launcher: bootstrap build user must differ from the invoker" >&2 + return 2 + } + + sudo_mode="$(/usr/bin/stat -c '%a' "$sudo_bin" 2>/dev/null || true)" + if [ "$sudo_bin" != /usr/bin/sudo ] || [ ! -f "$sudo_bin" ] || + [ -L "$sudo_bin" ] || [ ! -x "$sudo_bin" ] || + [ "$(/usr/bin/stat -c '%u' "$sudo_bin" 2>/dev/null || true)" != 0 ] || + ! [[ "$sudo_mode" =~ ^[0-7]{3,4}$ ]] || + [ $((8#$sudo_mode & 0022)) -ne 0 ]; then + echo "homebrew-patched-launcher: bootstrap handoff requires protected /usr/bin/sudo" >&2 + return 2 + fi + [ -n "${HOMEBREW_GUEST_PREFIX:-}" ] && + [ "$prefix" = "$HOMEBREW_GUEST_PREFIX" ] && + [ -d "$prefix" ] && [ ! -L "$prefix" ] || { + echo "homebrew-patched-launcher: bootstrap handoff requires the selected real guest prefix" >&2 + return 2 + } + physical="$(/usr/bin/realpath -- "$prefix")" || return 2 + [ "$physical" = "$prefix" ] || { + echo "homebrew-patched-launcher: bootstrap handoff prefix is not canonical" >&2 + return 2 + } + + lock_root="$prefix/var/homebrew/locks" + prefix_owner="$(/usr/bin/stat -c '%u:%g' "$prefix")" || return 2 + roots=("${HOMEBREW_CACHE:-}" "${HOMEBREW_TEMP:-}") + if [ -e "$lock_root" ] || [ -L "$lock_root" ]; then + roots=("$lock_root" "${roots[@]}") + elif [ "$prefix_owner" != "$invoker_uid:$invoker_gid" ]; then + echo "homebrew-patched-launcher: reused prefix requires its exact Homebrew lock root" >&2 + return 2 + fi + for root in "${roots[@]}"; do + [ -n "$root" ] && [[ "$root" = /* ]] && + [ -d "$root" ] && [ ! -L "$root" ] || { + echo "homebrew-patched-launcher: bootstrap root must be a real absolute directory: $root" >&2 + return 2 + } + physical="$(/usr/bin/realpath -- "$root")" || return 2 + [ "$physical" = "$root" ] || { + echo "homebrew-patched-launcher: bootstrap root is not canonical: $root" >&2 + return 2 + } + done + case "${HOMEBREW_CACHE:-}/" in "$prefix/"*) + echo "homebrew-patched-launcher: Homebrew cache cannot be inside the target prefix" >&2 + return 2 + esac + case "${HOMEBREW_TEMP:-}/" in "$prefix/"*) + echo "homebrew-patched-launcher: Homebrew temp cannot be inside the target prefix" >&2 + return 2 + esac + case "${HOMEBREW_CACHE:-}/" in "${HOMEBREW_TEMP:-}/"*) + echo "homebrew-patched-launcher: Homebrew cache and temp roots overlap" >&2 + return 2 + esac + case "${HOMEBREW_TEMP:-}/" in "${HOMEBREW_CACHE:-}/"*) + echo "homebrew-patched-launcher: Homebrew temp and cache roots overlap" >&2 + return 2 + esac + + for root in "${roots[@]}"; do + unexpected="$( + "$sudo_bin" -n -- /usr/bin/find "$root" -xdev \ + ! \( \( -uid "$invoker_uid" -gid "$invoker_gid" \) -o \ + \( -uid "$build_uid" -gid "$build_gid" \) \) \ + -print -quit + )" || return 2 + [ -z "$unexpected" ] || { + echo "homebrew-patched-launcher: bootstrap root has an unexpected owner: $unexpected" >&2 + return 2 + } + done + for root in "${roots[@]}"; do + "$sudo_bin" -n -- /usr/bin/find "$root" -xdev \ + -exec /usr/bin/chown -h "$invoker_uid:$invoker_gid" -- '{}' + || + return + unexpected="$( + "$sudo_bin" -n -- /usr/bin/find "$root" -xdev \ + \( ! -uid "$invoker_uid" -o ! -gid "$invoker_gid" \) \ + -print -quit + )" || return 2 + [ -z "$unexpected" ] || { + echo "homebrew-patched-launcher: bootstrap root ownership handoff failed: $unexpected" >&2 + return 1 + } + done +} + # Return a child of BASE whose byte length exactly matches Linuxbrew's bottle # prefix. Homebrew's fixed-prefix binary relocation pads shorter replacements # with NUL bytes; some runtimes (notably Perl) expose those bytes through their @@ -2746,7 +3182,11 @@ homebrew_patched_launcher_prepare_native_prefix() { HOMEBREW_PATCHED_NATIVE_TEMP="${native_roots[2]}" HOMEBREW_PATCHED_NATIVE_CONFIG="${native_roots[3]}" HOMEBREW_PATCHED_NATIVE_HOME="${native_roots[4]}" - mkdir -p "$HOMEBREW_PATCHED_NATIVE_PREFIX/bin" + # Native Formula plans may be empty. Establish the Cellar contract during + # prefix preparation so later sealing authenticates the same real directory + # whether or not an install command happened to create it. + mkdir -p "$HOMEBREW_PATCHED_NATIVE_PREFIX/bin" \ + "$HOMEBREW_PATCHED_NATIVE_PREFIX/Cellar" native_brew="$HOMEBREW_PATCHED_NATIVE_PREFIX/bin/brew" [ ! -e "$native_brew" ] && [ ! -L "$native_brew" ] || { echo "homebrew-patched-launcher: native Homebrew launcher already exists" >&2 @@ -4005,6 +4445,12 @@ homebrew_patched_launcher_isolate() { done if [ ! -d "$HOMEBREW_PATCHED_PREFIX/bin" ] || \ [ -L "$HOMEBREW_PATCHED_PREFIX/bin" ] || \ + [ "$HOMEBREW_PATCHED_SEED_BREW_BIN" != \ + "$HOMEBREW_PATCHED_PREFIX/bin/brew" ] || \ + [ ! -f "$HOMEBREW_PATCHED_SEED_BREW_BIN" ] || \ + [ ! -x "$HOMEBREW_PATCHED_SEED_BREW_BIN" ] || \ + [ "$(/usr/bin/readlink -f -- "$HOMEBREW_PATCHED_SEED_BREW_BIN")" != \ + "$(/usr/bin/readlink -f -- "$HOMEBREW_PATCHED_REPO/bin/brew")" ] || \ [ ! -L "$HOMEBREW_PATCHED_LAUNCHER" ] || \ [ "${HOMEBREW_PATCHED_LAUNCHER%/*}" != "$HOMEBREW_PATCHED_PREFIX/bin" ] || \ [ "$(/usr/bin/readlink "$HOMEBREW_PATCHED_LAUNCHER")" != \ @@ -4069,8 +4515,12 @@ homebrew_patched_launcher_isolate() { "$HOMEBREW_PATCHED_PREFIX" "$HOMEBREW_PATCHED_PREFIX/bin" \ "$HOMEBREW_PATCHED_PREFIX/Cellar" "$HOMEBREW_PATCHED_PREFIX/opt" \ "$HOMEBREW_PATCHED_PREFIX/etc" + "$sudo_bin" /usr/bin/chown -h root:root \ + "$HOMEBREW_PATCHED_SEED_BREW_BIN" "$sudo_bin" /usr/bin/chown -h root:root "$HOMEBREW_PATCHED_LAUNCHER" - [ "$(/usr/bin/stat -c '%u:%g' "$HOMEBREW_PATCHED_LAUNCHER")" = "0:0" ] && \ + [ "$(/usr/bin/stat -c '%u:%g' "$HOMEBREW_PATCHED_SEED_BREW_BIN")" = \ + "0:0" ] && \ + [ "$(/usr/bin/stat -c '%u:%g' "$HOMEBREW_PATCHED_LAUNCHER")" = "0:0" ] && \ [ "$(/usr/bin/stat -c '%u:%g:%a' "$HOMEBREW_PATCHED_PREFIX/bin")" = \ "0:$build_gid:1775" ] && \ [ "$(/usr/bin/stat -c '%u:%g:%a' "$HOMEBREW_PATCHED_PREFIX/etc")" = \ @@ -5129,6 +5579,7 @@ homebrew_patched_launcher_prepare() { HOMEBREW_PATCHED_REPO="$("$brew_bin" --repository)" || return HOMEBREW_PATCHED_PREFIX="$("$brew_bin" --prefix)" || return + HOMEBREW_PATCHED_SEED_BREW_BIN="$brew_bin" HOMEBREW_PATCHED_BREW_BIN="$brew_bin" if [ ! -f "$patch_file" ] || @@ -5155,7 +5606,9 @@ homebrew_patched_launcher_prepare() { while [ "$attempt" -lt 100 ]; do attempt=$((attempt + 1)) candidate="$HOMEBREW_PATCHED_PREFIX/bin/.kandelo-brew-$$-${RANDOM}-${attempt}" - if ln -s "$HOMEBREW_PATCHED_OVERLAY/bin/brew" "$candidate" 2>/dev/null; then + if ln -s "$HOMEBREW_PATCHED_OVERLAY/bin/brew" "$candidate" 2>/dev/null || + homebrew_patched_launcher_create_as_build_user \ + "$HOMEBREW_PATCHED_OVERLAY/bin/brew" "$candidate" 2>/dev/null; then HOMEBREW_PATCHED_LAUNCHER="$candidate" break fi diff --git a/scripts/homebrew-publish-sidecars.sh b/scripts/homebrew-publish-sidecars.sh index a5a5171aca..0f6f03c014 100755 --- a/scripts/homebrew-publish-sidecars.sh +++ b/scripts/homebrew-publish-sidecars.sh @@ -107,6 +107,22 @@ require tap-root "$TAP_ROOT" require release-tag "$RELEASE_TAG" require status "$STATUS" +# A local evidence payload is deliberately recognizable before any worktree, +# copy, index, or publication state is created. It may be consumed only by the +# review-pending composition harness, never by this remote publisher. +if [ -n "$SIDECAR_ROOT" ] && + [ -f "$SIDECAR_ROOT/local-test-provenance.json" ]; then + echo "homebrew-publish-sidecars.sh: local-test provenance is not publishable" >&2 + exit 1 +fi +for handoff in "${PUBLICATION_HANDOFFS[@]}"; do + if [ -f "$handoff/composition/local-test-provenance.json" ] || + [ -f "$handoff/local-test-provenance.json" ]; then + echo "homebrew-publish-sidecars.sh: local-test provenance is not publishable" >&2 + exit 1 + fi +done + # shellcheck source=/dev/null . "$KANDELO_ROOT/scripts/homebrew-tap-identity.sh" TAP_NAME="$(homebrew_resolve_tap_name "$TAP_REPOSITORY" "$TAP_NAME_INPUT")" diff --git a/scripts/homebrew-validate-build-handoff.sh b/scripts/homebrew-validate-build-handoff.sh index e14f04f9da..37088aaa45 100755 --- a/scripts/homebrew-validate-build-handoff.sh +++ b/scripts/homebrew-validate-build-handoff.sh @@ -429,6 +429,7 @@ if [ -n "$OUT_BOTTLE_JSON" ]; then bottle_json_tmp="$(mktemp "$bottle_json_parent/.homebrew-canonical-bottle.XXXXXX")" jq -nS \ --arg formula "$FORMULA" \ + --arg formula_key "$FORMULA_KEY" \ --arg formula_path "$FORMULA_PATH" \ --arg pkg_version "$PKG_VERSION" \ --arg root_url "$BOTTLE_ROOT_URL" \ @@ -437,7 +438,7 @@ if [ -n "$OUT_BOTTLE_JSON" ]; then --arg cellar "$BOTTLE_RELOCATION_CELLAR" \ --arg sha256 "$ACTUAL_SHA256" ' { - ($formula): { + ($formula_key): { formula: { name: $formula, path: $formula_path, diff --git a/scripts/homebrew-validate-host-dependency-plan.sh b/scripts/homebrew-validate-host-dependency-plan.sh index 21aafb16b5..c9efeaf260 100755 --- a/scripts/homebrew-validate-host-dependency-plan.sh +++ b/scripts/homebrew-validate-host-dependency-plan.sh @@ -15,7 +15,7 @@ jq -e --arg tap "$EXPECTED_TAP" --arg formula "$FORMULA" \ --slurpfile resolved "$RESOLVED_TAPS" ' . as $plan | keys == ["build", "build_and_test", "formula", "full_name", "native_requirements", "runtime_and_test", "schema", "tap", "target_taps"] and - .schema == 4 and + .schema == 5 and .tap == $tap and .formula == $formula and .full_name == ($tap + "/" + $formula) and @@ -28,13 +28,15 @@ jq -e --arg tap "$EXPECTED_TAP" --arg formula "$FORMULA" \ (.runtime_and_test == (.runtime_and_test | sort | unique)) and (.target_taps == ( [$resolved[0].primary, $resolved[0].dependencies[]] | - map({tap_name, tap_repository, tap_commit}) | sort_by(.tap_name) + map({tap_name, tap_repository, tap_commit, + checkout_commit: (.checkout_commit // .tap_commit)}) | sort_by(.tap_name) )) and (.target_taps | all(.[]; - keys == ["tap_commit", "tap_name", "tap_repository"] and + keys == ["checkout_commit", "tap_commit", "tap_name", "tap_repository"] and (.tap_name | type == "string" and test("^[a-z0-9._-]+/[a-z0-9._-]+$")) and (.tap_repository | type == "string" and test("^[a-z0-9._-]+/homebrew-[a-z0-9._-]+$")) and - (.tap_commit | type == "string" and test("^[0-9a-f]{40}$")) + (.tap_commit | type == "string" and test("^[0-9a-f]{40}$")) and + (.checkout_commit | type == "string" and test("^[0-9a-f]{40}$")) )) and (.target_taps | map(.tap_name) | index($tap) != null) and ((.build - .build_and_test) | length) == 0 and diff --git a/scripts/homebrew-verify-poured-bottle.sh b/scripts/homebrew-verify-poured-bottle.sh index 26263c4512..d786f49ab6 100755 --- a/scripts/homebrew-verify-poured-bottle.sh +++ b/scripts/homebrew-verify-poured-bottle.sh @@ -144,6 +144,7 @@ KANDELO_ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" . "$KANDELO_ROOT/scripts/homebrew-formula-support-inputs.sh" TAP_NAME="$(homebrew_resolve_tap_name "$TAP_REPOSITORY" "$TAP_NAME_INPUT")" BOTTLE_TAG="${ARCH}_kandelo" +FORMULA_KEY="${TAP_NAME}/${FORMULA}" for file in "$BOTTLE" "$BOTTLE_JSON" "$DEPENDENCY_PROVENANCE" "$SELECTION_RECEIPT"; do [ -f "$file" ] && [ ! -L "$file" ] || { echo "homebrew-verify-poured-bottle.sh: required input is not a regular file: $file" >&2 @@ -156,26 +157,27 @@ if [ -n "$STAGING_CANDIDATE_ABI" ]; then fi if ! jq -e \ --arg formula "$FORMULA" \ + --arg formula_key "$FORMULA_KEY" \ --arg bottle_tag "$BOTTLE_TAG" \ --arg bottle_root_url "$FORMULA_BOTTLE_ROOT_URL" \ --arg sha256 "$BOTTLE_SHA256" ' - type == "object" and keys == [$formula] and - (.[$formula].formula | type == "object") and - .[$formula].formula.name == $formula and - (.[$formula].formula.pkg_version | + type == "object" and keys == [$formula_key] and + (.[$formula_key].formula | type == "object") and + .[$formula_key].formula.name == $formula and + (.[$formula_key].formula.pkg_version | type == "string" and test("^[A-Za-z0-9][A-Za-z0-9._+,-]{0,255}$")) and - (.[$formula].bottle | type == "object") and - .[$formula].bottle.root_url == $bottle_root_url and - (.[$formula].bottle.rebuild | + (.[$formula_key].bottle | type == "object") and + .[$formula_key].bottle.root_url == $bottle_root_url and + (.[$formula_key].bottle.rebuild | type == "number" and . >= 0 and floor == .) and - (.[$formula].bottle.tags | type == "object" and keys == [$bottle_tag]) and - .[$formula].bottle.tags[$bottle_tag].sha256 == $sha256 + (.[$formula_key].bottle.tags | type == "object" and keys == [$bottle_tag]) and + .[$formula_key].bottle.tags[$bottle_tag].sha256 == $sha256 ' "$BOTTLE_JSON" >/dev/null; then echo "homebrew-verify-poured-bottle.sh: canonical bottle JSON does not match the selected bottle" >&2 exit 2 fi -PKG_VERSION="$(jq -r --arg formula "$FORMULA" '.[$formula].formula.pkg_version' "$BOTTLE_JSON")" -BOTTLE_REBUILD="$(jq -r --arg formula "$FORMULA" '.[$formula].bottle.rebuild' "$BOTTLE_JSON")" +PKG_VERSION="$(jq -r --arg formula_key "$FORMULA_KEY" '.[$formula_key].formula.pkg_version' "$BOTTLE_JSON")" +BOTTLE_REBUILD="$(jq -r --arg formula_key "$FORMULA_KEY" '.[$formula_key].bottle.rebuild' "$BOTTLE_JSON")" BOTTLE_REBUILD_SUFFIX="" if [ "$BOTTLE_REBUILD" != "0" ]; then BOTTLE_REBUILD_SUFFIX=".$BOTTLE_REBUILD" @@ -245,6 +247,10 @@ PUBLISHER_ISOLATION_PATCH_FILE="$KANDELO_ROOT/homebrew/patches/0002-support-isol # shellcheck source=/dev/null . "$KANDELO_ROOT/scripts/homebrew-native-install-contract.sh" homebrew_patched_launcher_select_host_git +if [ -n "$BUILD_USER" ]; then + homebrew_patched_launcher_restore_invoker_bootstrap_roots \ + "$BUILD_USER" "$HOMEBREW_GUEST_PREFIX" +fi if [ -n "$BUILD_USER" ]; then HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')" XTASK_BIN="$KANDELO_ROOT/target/$HOST_TARGET/release/xtask" @@ -655,6 +661,43 @@ validate_dependency_list \ "$SAME_TAP_TEST_DEPENDENCY_LIST" "test dependency list" validate_dependency_list "$DEPENDENCY_POUR_LIST" "dependency pour list" +LOCAL_DEPENDENCY_CACHE="${KANDELO_HOMEBREW_LOCAL_DEPENDENCY_CACHE:-}" +if [ -n "$LOCAL_DEPENDENCY_CACHE" ]; then + if [ "${GITHUB_ACTIONS:-}" = true ] || [ ! -d "$LOCAL_DEPENDENCY_CACHE" ] || + [ -L "$LOCAL_DEPENDENCY_CACHE" ]; then + echo "homebrew-verify-poured-bottle.sh: local dependency cache is restricted to a real non-CI directory" >&2 + exit 2 + fi + LOCAL_DEPENDENCY_CACHE="$(cd "$LOCAL_DEPENDENCY_CACHE" && pwd -P)" + LOCAL_DEPENDENCIES_JSON="$CONTROL_DIR/local-dependencies.json" + ruby "$KANDELO_ROOT/scripts/homebrew-formula-runtime-closure.rb" \ + "$PROVENANCE_TAP_ROOT" "$TAP_NAME" "$FORMULA" "$ARCH" \ + >"$LOCAL_DEPENDENCIES_JSON" + while IFS= read -r dependency; do + [ -n "$dependency" ] || continue + dependency_sha="$(jq -er --arg dependency "$dependency" \ + '.[$dependency].sha256' "$LOCAL_DEPENDENCIES_JSON")" + source_archive="$LOCAL_DEPENDENCY_CACHE/$dependency_sha.tar.gz" + if [ ! -f "$source_archive" ] || [ -L "$source_archive" ] || + [ "$(sha256sum "$source_archive" | awk '{print $1}')" != "$dependency_sha" ]; then + echo "homebrew-verify-poured-bottle.sh: local dependency cache lacks exact $dependency bottle $dependency_sha" >&2 + exit 1 + fi + cache_archive="$(run_brew_for_kandelo_bottles "$BREW_BIN" --cache \ + --bottle-tag="$BOTTLE_TAG" --formula "$dependency")" + case "$cache_archive" in + "$HOMEBREW_CACHE"/*) ;; + *) + echo "homebrew-verify-poured-bottle.sh: Homebrew dependency cache path escapes its private cache" >&2 + exit 1 + ;; + esac + mkdir -p "$(dirname "$cache_archive")" + cp "$source_archive" "$cache_archive" + chmod 0444 "$cache_archive" + done <"$DEPENDENCY_POUR_LIST" +fi + while IFS= read -r dependency; do [ -n "$dependency" ] || continue if [ "$dependency" = "$FORMULA" ] || \ diff --git a/scripts/measure-homebrew-vfork-rss.ts b/scripts/measure-homebrew-vfork-rss.ts new file mode 100755 index 0000000000..5d66195538 --- /dev/null +++ b/scripts/measure-homebrew-vfork-rss.ts @@ -0,0 +1,169 @@ +#!/usr/bin/env -S npx tsx + +import { execFileSync } from "node:child_process"; +import { existsSync, lstatSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +interface ProcessRow { + pid: number; + ppid: number; + rssKiB: number; + command: string; +} + +export interface AppendProcessTreeRssSampleOptions { + phase: string; + roots: ReadonlyMap; + out: string; +} + +export function appendProcessTreeRssSample( + options: AppendProcessTreeRssSampleOptions, +): unknown { + if (!/^[a-z][a-z0-9-]*$/.test(options.phase)) { + throw new Error("RSS phase must be a lowercase stable identifier"); + } + if (options.roots.size === 0) { + throw new Error("RSS sampling requires at least one process root"); + } + const rows = processRows(); + const byPid = new Map(rows.map((row) => [row.pid, row])); + const byParent = new Map(); + for (const row of rows) { + const children = byParent.get(row.ppid) ?? []; + children.push(row); + byParent.set(row.ppid, children); + } + const tree = (rootPid: number): ProcessRow[] => { + if (!byPid.has(rootPid)) { + throw new Error(`RSS process root is not live: ${rootPid}`); + } + const selected: ProcessRow[] = []; + const pending = [rootPid]; + const seen = new Set(); + while (pending.length > 0) { + const pid = pending.pop()!; + if (seen.has(pid)) continue; + seen.add(pid); + const row = byPid.get(pid); + if (row) selected.push(row); + for (const child of byParent.get(pid) ?? []) pending.push(child.pid); + } + return selected.sort((left, right) => left.pid - right.pid); + }; + const sample = { + phase: options.phase, + sampled_at: new Date().toISOString(), + roots: [...options.roots].map(([label, pid]) => { + if (!/^[a-z][a-z0-9_-]*$/.test(label) || !Number.isSafeInteger(pid)) { + throw new Error("RSS roots require a stable label and integer PID"); + } + const processes = tree(pid); + return { + label, + pid, + rss_kib: processes.reduce((sum, process) => sum + process.rssKiB, 0), + processes: processes.map((process) => ({ + pid: process.pid, + ppid: process.ppid, + rss_kib: process.rssKiB, + command: process.command, + })), + }; + }), + }; + const out = resolve(options.out); + if (existsSync(out) && lstatSync(out).isSymbolicLink()) { + throw new Error("RSS report must not be a symbolic link"); + } + const document = existsSync(out) + ? JSON.parse(readFileSync(out, "utf8")) + : { + schema: 1, + unit: "KiB", + scope: "exact sampled process trees", + provenance: { + schema: 1, + provenance_kind: "local-test", + promotable: false, + published: false, + }, + samples: [], + }; + if ( + document.schema !== 1 || + document.unit !== "KiB" || + document.scope !== "exact sampled process trees" || + JSON.stringify(document.provenance) !== + JSON.stringify({ + schema: 1, + provenance_kind: "local-test", + promotable: false, + published: false, + }) || + !Array.isArray(document.samples) + ) { + throw new Error("existing RSS report has an unexpected schema"); + } + document.samples.push(sample); + writeFileSync(out, `${JSON.stringify(document, null, 2)}\n`, { flag: "w" }); + return sample; +} + +function processRows(): ProcessRow[] { + const ps = process.platform === "darwin" ? "/bin/ps" : "/usr/bin/ps"; + if (!existsSync(ps)) { + throw new Error(`required process inventory tool is unavailable: ${ps}`); + } + return execFileSync(ps, ["-axo", "pid=,ppid=,rss=,command="], { + encoding: "utf8", + }) + .split("\n") + .flatMap((line): ProcessRow[] => { + const match = /^\s*([0-9]+)\s+([0-9]+)\s+([0-9]+)\s+(.*)$/.exec(line); + return match + ? [ + { + pid: Number(match[1]), + ppid: Number(match[2]), + rssKiB: Number(match[3]), + command: match[4]!, + }, + ] + : []; + }); +} + +function runCli(args: readonly string[]): void { + let phase = ""; + let out = ""; + const roots = new Map(); + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]!; + if (argument === "--phase") phase = args[++index] ?? ""; + else if (argument === "--out") out = args[++index] ?? ""; + else if (argument === "--root") { + const value = args[++index] ?? ""; + const match = /^([a-z][a-z0-9_-]*)=([1-9][0-9]*)$/.exec(value); + if (!match) throw new Error("--root must use label=pid"); + roots.set(match[1]!, Number(match[2])); + } else throw new Error(`unknown argument: ${argument}`); + } + if (!phase || !out || roots.size === 0) { + throw new Error( + "usage: measure-homebrew-vfork-rss.ts --phase " + + "--root [--root ...] --out ", + ); + } + console.log( + JSON.stringify(appendProcessTreeRssSample({ phase, roots, out })), + ); +} + +if ( + process.argv[1] !== undefined && + pathToFileURL(resolve(process.argv[1])).href === import.meta.url +) { + runCli(process.argv.slice(2)); +} diff --git a/scripts/run-login-stack-local.sh b/scripts/run-login-stack-local.sh new file mode 100755 index 0000000000..2f22c81042 --- /dev/null +++ b/scripts/run-login-stack-local.sh @@ -0,0 +1,976 @@ +#!/usr/bin/env bash +# Build and exercise the complete ABI 43 Homebrew login product locally. +set -euo pipefail + +KANDELO_LOGIN_TAP_ROOT="" +KANDELO_LOGIN_WORK_ROOT="" +KANDELO_LOGIN_BROWSER_DEMO=false +KANDELO_LOGIN_BUILD_USER="kandelo-homebrew-build" +KANDELO_LOGIN_RECIPE_USER="kandelo-homebrew-recipe" +KANDELO_LOGIN_SHARED_TEMP="" +KANDELO_LOGIN_BUILD_USER_CREATED=false +KANDELO_LOGIN_RECIPE_USER_CREATED=false + +usage() { + cat >&2 <<'EOF' +usage: scripts/run-login-stack-local.sh --tap-root --work-root [--browser-demo] + +This local-only harness builds the complete selected ABI 43 Formula closure, +composes an immutable review-pending image and closed mirror, and preserves all +evidence below the exclusive work root. It never publishes or changes a +selection lock. +EOF +} + +fail() { + echo "run-login-stack-local.sh: $*" >&2 + exit 2 +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --tap-root) + [ -z "$KANDELO_LOGIN_TAP_ROOT" ] && [ "$#" -ge 2 ] || fail "duplicate or incomplete --tap-root" + KANDELO_LOGIN_TAP_ROOT="$2" + shift 2 + ;; + --work-root) + [ -z "$KANDELO_LOGIN_WORK_ROOT" ] && [ "$#" -ge 2 ] || fail "duplicate or incomplete --work-root" + KANDELO_LOGIN_WORK_ROOT="$2" + shift 2 + ;; + --browser-demo) + [ "$KANDELO_LOGIN_BROWSER_DEMO" = false ] || fail "duplicate --browser-demo" + KANDELO_LOGIN_BROWSER_DEMO=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "unknown flag: $1" + ;; + esac +done + +[ -n "$KANDELO_LOGIN_TAP_ROOT" ] || fail "--tap-root is required" +[ -n "$KANDELO_LOGIN_WORK_ROOT" ] || fail "--work-root is required" +case "$KANDELO_LOGIN_TAP_ROOT:$KANDELO_LOGIN_WORK_ROOT" in + /*:/*) ;; + *) fail "tap and work roots must be absolute" ;; +esac +[ "$KANDELO_LOGIN_TAP_ROOT" != / ] && [ "$KANDELO_LOGIN_WORK_ROOT" != / ] || fail "root paths are forbidden" +[ -d "$KANDELO_LOGIN_TAP_ROOT" ] && [ ! -L "$KANDELO_LOGIN_TAP_ROOT" ] || fail "tap root must be a real directory" +KANDELO_LOGIN_TAP_ROOT="$(cd "$KANDELO_LOGIN_TAP_ROOT" && pwd -P)" +[ ! -e "$KANDELO_LOGIN_WORK_ROOT" ] && [ ! -L "$KANDELO_LOGIN_WORK_ROOT" ] || fail "work root must not exist" +KANDELO_LOGIN_WORK_PARENT="$(dirname "$KANDELO_LOGIN_WORK_ROOT")" +[ -d "$KANDELO_LOGIN_WORK_PARENT" ] && [ ! -L "$KANDELO_LOGIN_WORK_PARENT" ] || fail "work-root parent must be a real directory" +KANDELO_LOGIN_WORK_PARENT="$(cd "$KANDELO_LOGIN_WORK_PARENT" && pwd -P)" +[ "$KANDELO_LOGIN_WORK_ROOT" = "$KANDELO_LOGIN_WORK_PARENT/$(basename "$KANDELO_LOGIN_WORK_ROOT")" ] || fail "work root must be below its physical parent" + +KANDELO_LOGIN_INVOKING_ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +KANDELO_LOGIN_KANDELO_COMMIT="$(git -C "$KANDELO_LOGIN_INVOKING_ROOT" rev-parse HEAD)" +KANDELO_LOGIN_TAP_COMMIT="$(git -C "$KANDELO_LOGIN_TAP_ROOT" rev-parse HEAD)" +for commit in "$KANDELO_LOGIN_KANDELO_COMMIT" "$KANDELO_LOGIN_TAP_COMMIT"; do + [[ "$commit" =~ ^[0-9a-f]{40}$ ]] || fail "Kandelo and tap commits must be exact 40-character SHA-1 identities" +done +[ -z "$(git -C "$KANDELO_LOGIN_TAP_ROOT" status --short --untracked-files=all)" ] || fail "tap checkout must be completely clean" +KANDELO_LOGIN_LOCK="$KANDELO_LOGIN_INVOKING_ROOT/homebrew/main-shell-migration-lock.json" +[ "$(jq -er '.catalog.tap_commit' "$KANDELO_LOGIN_LOCK")" = "$KANDELO_LOGIN_TAP_COMMIT" ] || fail "tap HEAD differs from the migration lock" +[ "$(sed -nE 's/^pub const ABI_VERSION: u32 = ([0-9]+);$/\1/p' "$KANDELO_LOGIN_INVOKING_ROOT/crates/shared/src/lib.rs")" = 43 ] || fail "the harness requires ABI 43" +[ "$(jq -r '.packages | length' "$KANDELO_LOGIN_LOCK")" = 36 ] || fail "product lock must select exactly 36 roots" +[ "$(jq -r '.formula_closure | length' "$KANDELO_LOGIN_LOCK")" = 43 ] || fail "product lock must resolve exactly 43 Formulae" +[ -n "${IN_NIX_SHELL:-}" ] || fail "run through scripts/dev-shell.sh" +[ -z "${PLAYWRIGHT_BROWSERS_PATH:-}" ] || fail "ambient Playwright browser cache authority is forbidden" +[ "$(uname -s)" = Linux ] || fail "ABI 43 bottle builds require a native Linux execution lane" +[ -d /run/systemd/system ] || fail "the Linux builder requires a running systemd manager" +[ -x /usr/bin/sudo ] || fail "the Linux builder requires /usr/bin/sudo" +/usr/bin/sudo -n true >/dev/null 2>&1 || fail "the Linux builder requires noninteractive sudo" +for protected_tool in \ + /usr/bin/systemd-run /usr/bin/systemctl /usr/bin/getent \ + /usr/bin/findmnt /usr/bin/pgrep \ + /usr/sbin/useradd /usr/sbin/userdel /usr/sbin/nologin; do + [ -f "$protected_tool" ] && [ ! -L "$protected_tool" ] && + [ -x "$protected_tool" ] && + [ "$(stat -c '%u' "$protected_tool" 2>/dev/null || true)" = 0 ] || + fail "the Linux builder lacks protected host tool $protected_tool" +done +KANDELO_LOGIN_PKILL_TARGET="$(readlink -f -- /usr/bin/pkill 2>/dev/null || true)" +KANDELO_LOGIN_PKILL_PARENT="$(dirname /usr/bin/pkill)" +KANDELO_LOGIN_PKILL_PARENT_MODE="$( + stat -c '%a' "$KANDELO_LOGIN_PKILL_PARENT" 2>/dev/null || true +)" +if [ -L /usr/bin/pkill ]; then + [ "$KANDELO_LOGIN_PKILL_TARGET" = /usr/bin/pgrep ] && + [ "$(stat -c '%u' /usr/bin/pkill 2>/dev/null || true)" = 0 ] && + [ "$(stat -c '%u' "$KANDELO_LOGIN_PKILL_PARENT" 2>/dev/null || true)" = 0 ] && + [[ "$KANDELO_LOGIN_PKILL_PARENT_MODE" =~ ^[0-7]{3,4}$ ]] && + [ $((8#$KANDELO_LOGIN_PKILL_PARENT_MODE & 0022)) -eq 0 ] || + fail "the Linux builder has an unsafe /usr/bin/pkill alias" +else + KANDELO_LOGIN_PKILL_MODE="$(stat -c '%a' /usr/bin/pkill 2>/dev/null || true)" + [ -f /usr/bin/pkill ] && [ -x /usr/bin/pkill ] && + [ "$(stat -c '%u' /usr/bin/pkill 2>/dev/null || true)" = 0 ] && + [[ "$KANDELO_LOGIN_PKILL_MODE" =~ ^[0-7]{3,4}$ ]] && + [ $((8#$KANDELO_LOGIN_PKILL_MODE & 0022)) -eq 0 ] || + fail "the Linux builder lacks protected host tool /usr/bin/pkill" +fi +/usr/bin/sudo -n -- /usr/bin/systemctl show --property=Version --value \ + >/dev/null || fail "the Linux builder cannot access the systemd manager" +/usr/bin/systemd-run --help | grep -F -- '--expand-environment=' >/dev/null || + fail "the Linux builder cannot preserve exact Brew arguments" +for reserved_user in "$KANDELO_LOGIN_BUILD_USER" "$KANDELO_LOGIN_RECIPE_USER"; do + ! /usr/bin/getent passwd "$reserved_user" >/dev/null || + fail "reserved Homebrew identity already exists: $reserved_user" +done +if [ -e /opt/kandelo/homebrew ] || [ -L /opt/kandelo/homebrew ]; then + fail "ambient /opt/kandelo/homebrew state is forbidden; use a fresh Linux builder" +fi + +# Validate only the active Ruby Formula and its declared recipe closure. The +# tap deliberately retains historical campaign evidence; it is not executable +# source for the current Formula and must not be confused with that closure. +bash "$KANDELO_LOGIN_INVOKING_ROOT/scripts/homebrew-validate-formula-source-closure.sh" \ + --tap-root "$KANDELO_LOGIN_TAP_ROOT" \ + --reviewed-tap-root "$KANDELO_LOGIN_TAP_ROOT" \ + --tap-repository kandelo-dev/homebrew-tap-core \ + --tap-name kandelo-dev/tap-core \ + --formula ruby \ + --base-ref "$KANDELO_LOGIN_TAP_COMMIT" +KANDELO_LOGIN_RUBY_RECIPE="$KANDELO_LOGIN_TAP_ROOT/Kandelo/recipes/ruby" +KANDELO_LOGIN_RUBY_CLOSURE="$(mktemp "$KANDELO_LOGIN_WORK_PARENT/.kandelo-login-ruby-closure.XXXXXX")" +trap 'rm -f -- "$KANDELO_LOGIN_RUBY_CLOSURE"; if [ -n "${KANDELO_LOGIN_RUBY_VALIDATION_ROOT:-}" ]; then rm -rf -- "$KANDELO_LOGIN_RUBY_VALIDATION_ROOT"; fi' EXIT +{ + printf '%s\n' "$KANDELO_LOGIN_TAP_ROOT/Formula/ruby.rb" "$KANDELO_LOGIN_RUBY_RECIPE/recipe.json" + jq -er '.files[].path' "$KANDELO_LOGIN_RUBY_RECIPE/recipe.json" | + while IFS= read -r relative; do + case "$relative" in + ""|/*|*..*|*\\*) fail "Ruby recipe has an unsafe declared path" ;; + esac + printf '%s/%s\n' "$KANDELO_LOGIN_RUBY_RECIPE" "$relative" + done +} >"$KANDELO_LOGIN_RUBY_CLOSURE" +while IFS= read -r path; do + [ -f "$path" ] && [ ! -L "$path" ] || fail "Ruby declared source is not a regular file: $path" +done <"$KANDELO_LOGIN_RUBY_CLOSURE" +while IFS=$'\t' read -r expected relative; do + [ "$(sha256sum "$KANDELO_LOGIN_RUBY_RECIPE/$relative" | awk '{print $1}')" = "$expected" ] || fail "Ruby declared source digest changed: $relative" +done < <(jq -r '.files[] | [.sha256,.path] | @tsv' "$KANDELO_LOGIN_RUBY_RECIPE/recipe.json") +if xargs -r grep -nE 'kandelo-posix-spawn[.]patch|github[.]com/Automattic/kandelo/pull/1166|ac_cv_func_vfork=no|828441ed6cd84b13ed064137ee5442c16a36ee44f7e3bdbb69557218277b63ea' <"$KANDELO_LOGIN_RUBY_CLOSURE"; then + fail "active Ruby declared source closure contains retired PR #1166 input" +fi +grep -F 'url "https://cache.ruby-lang.org/pub/ruby/4.0/ruby-4.0.5.tar.gz"' "$KANDELO_LOGIN_TAP_ROOT/Formula/ruby.rb" >/dev/null || fail "Ruby source URL changed" +grep -F 'sha256 "7d6149079a63f8ae1d326c9fa65c6019ba2dc3155eae7b39159817911c88958e"' "$KANDELO_LOGIN_TAP_ROOT/Formula/ruby.rb" >/dev/null || fail "Ruby source digest changed" +grep -F 'UPSTREAM_PROCESS_C_SHA256="39286bbe88bc5e8627f91ac780aa00403052cb1f700c2f25b5407b7af807e608"' "$KANDELO_LOGIN_RUBY_RECIPE/build.sh" >/dev/null || fail "Ruby pristine process.c marker changed" + +KANDELO_LOGIN_RUBY_VALIDATION_ROOT="$(mktemp -d "$KANDELO_LOGIN_WORK_PARENT/.kandelo-login-ruby-source.XXXXXX")" +curl --fail --location --proto '=https' --tlsv1.2 \ + --output "$KANDELO_LOGIN_RUBY_VALIDATION_ROOT/ruby.tar.gz" \ + https://cache.ruby-lang.org/pub/ruby/4.0/ruby-4.0.5.tar.gz +[ "$(sha256sum "$KANDELO_LOGIN_RUBY_VALIDATION_ROOT/ruby.tar.gz" | awk '{print $1}')" = "7d6149079a63f8ae1d326c9fa65c6019ba2dc3155eae7b39159817911c88958e" ] || fail "Ruby upstream archive digest changed" +mkdir "$KANDELO_LOGIN_RUBY_VALIDATION_ROOT/source" +tar -xzf "$KANDELO_LOGIN_RUBY_VALIDATION_ROOT/ruby.tar.gz" --strip-components=1 -C "$KANDELO_LOGIN_RUBY_VALIDATION_ROOT/source" +KANDELO_LOGIN_RUBY_TREE_SHA256="$( + cd "$KANDELO_LOGIN_RUBY_VALIDATION_ROOT/source" + find -P . -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum | sha256sum | awk '{print $1}' +)" +[ "$(sha256sum "$KANDELO_LOGIN_RUBY_VALIDATION_ROOT/source/process.c" | awk '{print $1}')" = "39286bbe88bc5e8627f91ac780aa00403052cb1f700c2f25b5407b7af807e608" ] || fail "extracted Ruby process.c is not pristine upstream" +! grep -F 'kandelo_execarg_can_posix_spawn' "$KANDELO_LOGIN_RUBY_VALIDATION_ROOT/source/process.c" >/dev/null || fail "extracted Ruby source contains PR #1166 residue" + +mkdir "$KANDELO_LOGIN_WORK_ROOT" +chmod 0700 "$KANDELO_LOGIN_WORK_ROOT" +KANDELO_LOGIN_SOURCE="$KANDELO_LOGIN_WORK_ROOT/kandelo-source" +( + cd "$KANDELO_LOGIN_INVOKING_ROOT" + git worktree add --detach "$KANDELO_LOGIN_SOURCE" "$KANDELO_LOGIN_KANDELO_COMMIT" +) +git -C "$KANDELO_LOGIN_SOURCE" submodule sync --recursive +git -C "$KANDELO_LOGIN_SOURCE" \ + -c 'url.https://github.com/.insteadOf=git@github.com:' \ + submodule update --init --recursive +mkdir "$KANDELO_LOGIN_WORK_ROOT/source-evidence" +cp -p "$KANDELO_LOGIN_RUBY_VALIDATION_ROOT/source/process.c" "$KANDELO_LOGIN_WORK_ROOT/source-evidence/ruby-process.c" +jq -nS \ + --arg archive_sha256 7d6149079a63f8ae1d326c9fa65c6019ba2dc3155eae7b39159817911c88958e \ + --arg tree_sha256 "$KANDELO_LOGIN_RUBY_TREE_SHA256" \ + --arg process_c_sha256 39286bbe88bc5e8627f91ac780aa00403052cb1f700c2f25b5407b7af807e608 \ + '{schema:1, source:"pristine-upstream", archive_sha256:$archive_sha256, tree_sha256:$tree_sha256, process_c_sha256:$process_c_sha256, configured:false}' \ + >"$KANDELO_LOGIN_WORK_ROOT/source-evidence/ruby-pristine-source.json" +rm -rf -- "$KANDELO_LOGIN_RUBY_VALIDATION_ROOT" +KANDELO_LOGIN_RUBY_VALIDATION_ROOT="" +rm -f -- "$KANDELO_LOGIN_RUBY_CLOSURE" +trap - EXIT + +cleanup_formula_identities() { + local original_status="$?" + local cleanup_status=0 + trap - EXIT + if [ -n "$KANDELO_LOGIN_SHARED_TEMP" ]; then + case "$KANDELO_LOGIN_SHARED_TEMP" in + /tmp/kandelo-homebrew.??????) + if [ -d "$KANDELO_LOGIN_SHARED_TEMP" ] && + [ ! -L "$KANDELO_LOGIN_SHARED_TEMP" ]; then + /usr/bin/sudo -n -- /usr/bin/rm -rf -- \ + "$KANDELO_LOGIN_SHARED_TEMP" || cleanup_status=1 + else + echo "run-login-stack-local.sh: refusing unsafe shared-temp cleanup" >&2 + cleanup_status=1 + fi + ;; + *) + echo "run-login-stack-local.sh: refusing unrecognized shared-temp cleanup" >&2 + cleanup_status=1 + ;; + esac + fi + if [ "$KANDELO_LOGIN_RECIPE_USER_CREATED" = true ]; then + /usr/bin/sudo -n -- /usr/sbin/userdel "$KANDELO_LOGIN_RECIPE_USER" || + cleanup_status=1 + fi + if [ "$KANDELO_LOGIN_BUILD_USER_CREATED" = true ]; then + /usr/bin/sudo -n -- /usr/sbin/userdel -r "$KANDELO_LOGIN_BUILD_USER" || + cleanup_status=1 + fi + if [ "$original_status" -ne 0 ]; then + exit "$original_status" + fi + exit "$cleanup_status" +} + +# The normal publisher executes Formula Ruby as a dedicated identity and tap +# recipes as a second, less-privileged identity. The local product harness uses +# the same boundary; otherwise Formula tests fall back to the invoking user's +# checkout and undeclared host tools instead of the sealed test projection. +trap cleanup_formula_identities EXIT +/usr/bin/sudo -n -- /usr/sbin/useradd --system --user-group --create-home \ + --home-dir "/home/$KANDELO_LOGIN_BUILD_USER" --shell /usr/sbin/nologin \ + "$KANDELO_LOGIN_BUILD_USER" +KANDELO_LOGIN_BUILD_USER_CREATED=true +/usr/bin/sudo -n -- /usr/sbin/useradd --system --user-group --no-create-home \ + --home-dir /nonexistent --shell /usr/sbin/nologin \ + "$KANDELO_LOGIN_RECIPE_USER" +KANDELO_LOGIN_RECIPE_USER_CREATED=true +[ "$(/usr/bin/id -u "$KANDELO_LOGIN_BUILD_USER")" != "$(/usr/bin/id -u)" ] && + [ "$(/usr/bin/id -u "$KANDELO_LOGIN_RECIPE_USER")" != "$(/usr/bin/id -u)" ] && + [ "$(/usr/bin/id -u "$KANDELO_LOGIN_RECIPE_USER")" != \ + "$(/usr/bin/id -u "$KANDELO_LOGIN_BUILD_USER")" ] || + fail "isolated Homebrew identities are not distinct" +if /usr/bin/sudo -n -H -u "$KANDELO_LOGIN_BUILD_USER" -- \ + /usr/bin/sudo -n true >/dev/null 2>&1; then + fail "Formula build identity unexpectedly has sudo access" +fi +if /usr/bin/sudo -n -H -u "$KANDELO_LOGIN_RECIPE_USER" -- \ + /usr/bin/sudo -n true >/dev/null 2>&1; then + fail "tap recipe identity unexpectedly has sudo access" +fi +KANDELO_LOGIN_SHARED_TEMP="$(mktemp -d /tmp/kandelo-homebrew.XXXXXX)" +/usr/bin/sudo -n -- /usr/bin/chown root:root "$KANDELO_LOGIN_SHARED_TEMP" +/usr/bin/sudo -n -- /usr/bin/chmod 1777 "$KANDELO_LOGIN_SHARED_TEMP" +[ "$(stat -c '%u:%g:%a' "$KANDELO_LOGIN_SHARED_TEMP")" = "0:0:1777" ] || + fail "shared Formula temporary root is not protected" + +cd "$KANDELO_LOGIN_SOURCE" +npm ci 2>&1 | tee "$KANDELO_LOGIN_WORK_ROOT/npm-root-ci.log" +npm --prefix apps/browser-demos ci \ + 2>&1 | tee "$KANDELO_LOGIN_WORK_ROOT/npm-browser-demos-ci.log" +KANDELO_LOGIN_NODE_BIN="$(command -v node)" +case "$KANDELO_LOGIN_NODE_BIN" in + /nix/store/*/bin/node) ;; + *) + fail "Formula browser provisioning resolved an undeclared Node: $KANDELO_LOGIN_NODE_BIN" + ;; +esac +bash scripts/homebrew-provision-formula-browser.sh \ + --shared-temp "$KANDELO_LOGIN_SHARED_TEMP" \ + --build-user "$KANDELO_LOGIN_BUILD_USER" \ + --sudo-bin /usr/bin/sudo \ + --node-bin "$KANDELO_LOGIN_NODE_BIN" \ + --browser-app "$KANDELO_LOGIN_SOURCE/apps/browser-demos" \ + 2>&1 | tee "$KANDELO_LOGIN_WORK_ROOT/formula-browser-provision.log" +KANDELO_LOGIN_FORMULA_BROWSER_CACHE="$KANDELO_LOGIN_SHARED_TEMP/ms-playwright" +[ -d "$KANDELO_LOGIN_FORMULA_BROWSER_CACHE" ] && + [ ! -L "$KANDELO_LOGIN_FORMULA_BROWSER_CACHE" ] && + [ "$(stat -c '%u:%g' "$KANDELO_LOGIN_FORMULA_BROWSER_CACHE")" = "0:0" ] || + fail "Formula browser cache is not the exact protected prepared directory" +bash scripts/build-musl.sh 2>&1 | tee "$KANDELO_LOGIN_WORK_ROOT/build-musl.log" +bash build.sh 2>&1 | tee "$KANDELO_LOGIN_WORK_ROOT/build.log" +# The reusable bottle workflow separately builds the package-owned kernel. +# Besides validating the kernel's host-adapter export contract, that path +# installs the exact admitted bytes at host/wasm/kandelo-kernel.wasm for the +# closed Formula-test runtime, which deliberately exposes no local-binaries +# source authority. +bash packages/registry/kernel/build-kernel.sh \ + 2>&1 | tee "$KANDELO_LOGIN_WORK_ROOT/build-formula-test-kernel.log" +KANDELO_LOGIN_FORMULA_TEST_KERNEL="$(bash scripts/resolve-binary.sh kernel.wasm)" +[ -f "$KANDELO_LOGIN_FORMULA_TEST_KERNEL" ] && + [ ! -L "$KANDELO_LOGIN_FORMULA_TEST_KERNEL" ] || + fail "Formula test kernel is not one pinned admitted generation member" +cmp "$KANDELO_LOGIN_FORMULA_TEST_KERNEL" \ + "$KANDELO_LOGIN_SOURCE/host/wasm/kandelo-kernel.wasm" || + fail "Formula test runtime kernel differs from its admitted generation" +bash scripts/build-programs.sh 2>&1 | tee "$KANDELO_LOGIN_WORK_ROOT/build-programs.log" + +# Materialize the same closed Formula-test checker and selected program index +# as the reusable bottle workflow. Formula tests receive this exact immutable +# projection, not Cargo/Nix authority or the live detached checkout. +KANDELO_LOGIN_HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')" +[ -n "$KANDELO_LOGIN_HOST_TARGET" ] || fail "unable to resolve the Rust host target" +cargo build --release -p xtask --target "$KANDELO_LOGIN_HOST_TARGET" --quiet +KANDELO_LOGIN_XTASK_BIN="$KANDELO_LOGIN_SOURCE/target/$KANDELO_LOGIN_HOST_TARGET/release/xtask" +[ -f "$KANDELO_LOGIN_XTASK_BIN" ] && [ ! -L "$KANDELO_LOGIN_XTASK_BIN" ] && + [ -x "$KANDELO_LOGIN_XTASK_BIN" ] && + [ "$(realpath -- "$KANDELO_LOGIN_XTASK_BIN")" = "$KANDELO_LOGIN_XTASK_BIN" ] || + fail "prepared Formula test xtask is not an exact executable" +sealed_xtask="$( + bash scripts/seal-homebrew-formula-checker.sh \ + --root "$KANDELO_LOGIN_SOURCE" --checker "$KANDELO_LOGIN_XTASK_BIN" +)" +[ "$sealed_xtask" = "$KANDELO_LOGIN_XTASK_BIN" ] || + fail "Formula test checker seal selected another executable" +KANDELO_LOGIN_XTASK_SHA256="$(sha256sum "$KANDELO_LOGIN_XTASK_BIN" | awk '{print $1}')" +KANDELO_LOGIN_XTASK_UID="$(stat -c '%u' "$KANDELO_LOGIN_XTASK_BIN")" +[[ "$KANDELO_LOGIN_XTASK_SHA256" =~ ^[0-9a-f]{64}$ ]] && + [ "$KANDELO_LOGIN_XTASK_UID" = "$(id -u)" ] || + fail "Formula test checker seal has an invalid identity" +reseal_formula_test_checker() { + local context="$1" + local actual_sha256 resealed_sha256 resealed_xtask + + [ -f "$KANDELO_LOGIN_XTASK_BIN" ] && + [ ! -L "$KANDELO_LOGIN_XTASK_BIN" ] && + [ -x "$KANDELO_LOGIN_XTASK_BIN" ] && + [ "$(realpath -- "$KANDELO_LOGIN_XTASK_BIN")" = "$KANDELO_LOGIN_XTASK_BIN" ] && + [ "$(stat -c '%u' "$KANDELO_LOGIN_XTASK_BIN")" = "$KANDELO_LOGIN_XTASK_UID" ] || + fail "Formula test checker identity changed during $context" + actual_sha256="$(sha256sum "$KANDELO_LOGIN_XTASK_BIN" 2>/dev/null || true)" + actual_sha256="${actual_sha256%% *}" + [ "$actual_sha256" = "$KANDELO_LOGIN_XTASK_SHA256" ] || + fail "Formula test checker bytes changed during $context" + if ! resealed_xtask="$( + bash scripts/seal-homebrew-formula-checker.sh \ + --root "$KANDELO_LOGIN_SOURCE" --checker "$KANDELO_LOGIN_XTASK_BIN" + )"; then + fail "Formula test checker reseal failed after $context" + fi + resealed_sha256="$(sha256sum "$KANDELO_LOGIN_XTASK_BIN" 2>/dev/null || true)" + resealed_sha256="${resealed_sha256%% *}" + [ "$resealed_xtask" = "$KANDELO_LOGIN_XTASK_BIN" ] && + [ "$(stat -c '%a:%h:%u' "$KANDELO_LOGIN_XTASK_BIN")" = "555:1:$KANDELO_LOGIN_XTASK_UID" ] && + [ "$resealed_sha256" = "$KANDELO_LOGIN_XTASK_SHA256" ] || + fail "Formula test checker reseal failed after $context" +} +# build-programs links libc++ from the developer resolver cache for ordinary +# in-worktree builds. Formula isolation cannot admit that mutable, external +# path. Match the reusable package-toolchain contract by replacing the three +# links with exact files inside the sysroot before it becomes protected input. +KANDELO_LOGIN_LIBCXX_PREFIX="$("$KANDELO_LOGIN_XTASK_BIN" \ + build-deps --arch wasm32 path libcxx)" +[ -d "$KANDELO_LOGIN_LIBCXX_PREFIX/include/c++/v1" ] && + [ ! -L "$KANDELO_LOGIN_LIBCXX_PREFIX/include/c++/v1" ] || + fail "resolved libc++ headers are not one real directory" +for archive in libc++.a libc++abi.a; do + [ -f "$KANDELO_LOGIN_LIBCXX_PREFIX/lib/$archive" ] && + [ ! -L "$KANDELO_LOGIN_LIBCXX_PREFIX/lib/$archive" ] || + fail "resolved libc++ archive is not one regular file: $archive" + rm -f "$KANDELO_LOGIN_SOURCE/sysroot/lib/$archive" + install -m 0644 "$KANDELO_LOGIN_LIBCXX_PREFIX/lib/$archive" \ + "$KANDELO_LOGIN_SOURCE/sysroot/lib/$archive" +done +rm -rf "$KANDELO_LOGIN_SOURCE/sysroot/include/c++/v1" +cp -a "$KANDELO_LOGIN_LIBCXX_PREFIX/include/c++/v1" \ + "$KANDELO_LOGIN_SOURCE/sysroot/include/c++/v1" +( + # shellcheck disable=SC1091 + . "$KANDELO_LOGIN_SOURCE/scripts/homebrew-patched-launcher.sh" + homebrew_assert_tree_symlinks_contained \ + "$KANDELO_LOGIN_SOURCE/sysroot" sysroot +) || fail "materialized Formula sysroot is not self-contained" +# The reusable publisher admits an exact package generation before this +# fetch-only loop. Local Task 20 evidence has no published ABI 43 generation, +# so build the current-source rootfs once into the canonical resolver cache, +# then prove it is byte-identical to the rootfs already built above. Formula +# execution still receives only the immutable fetch-only projection. +"$KANDELO_LOGIN_XTASK_BIN" build-deps --arch wasm32 \ + --binaries-dir "$KANDELO_LOGIN_SOURCE/binaries" \ + --force-source-build resolve rootfs \ + 2>&1 | tee "$KANDELO_LOGIN_WORK_ROOT/formula-runtime-rootfs-build.log" +cmp "$KANDELO_LOGIN_SOURCE/host/wasm/rootfs.vfs" \ + "$KANDELO_LOGIN_SOURCE/binaries/programs/wasm32/rootfs.vfs" || + fail "Formula runtime rootfs differs from the exact product rootfs" +for package in dash coreutils grep sed rootfs; do + "$KANDELO_LOGIN_XTASK_BIN" build-deps --arch wasm32 \ + --binaries-dir "$KANDELO_LOGIN_SOURCE/binaries" --fetch-only resolve "$package" +done +KANDELO_LOGIN_PROGRAM_CACHE="$($KANDELO_LOGIN_XTASK_BIN build-deps cache-root)" +case "$KANDELO_LOGIN_PROGRAM_CACHE" in + /*) ;; + *) fail "Formula test program cache root is not absolute" ;; +esac +bash scripts/materialize-resolver-binaries.sh \ + "$KANDELO_LOGIN_SOURCE/binaries" "$KANDELO_LOGIN_PROGRAM_CACHE" +KANDELO_LOGIN_FORMULA_TEST_INDEX="$KANDELO_LOGIN_SOURCE/target/$KANDELO_LOGIN_HOST_TARGET/release/formula-test-program-packages.json" +WASM_POSIX_DEPS_REGISTRY="$KANDELO_LOGIN_SOURCE/packages/registry" \ + "$KANDELO_LOGIN_XTASK_BIN" build-deps program-index-selected \ + --source-repo-root "$KANDELO_LOGIN_SOURCE" \ + dash,coreutils,grep,sed,rootfs "$KANDELO_LOGIN_FORMULA_TEST_INDEX" +[ -f "$KANDELO_LOGIN_FORMULA_TEST_INDEX" ] && + [ ! -L "$KANDELO_LOGIN_FORMULA_TEST_INDEX" ] && + [ "$(realpath -- "$KANDELO_LOGIN_FORMULA_TEST_INDEX")" = \ + "$KANDELO_LOGIN_FORMULA_TEST_INDEX" ] || + fail "Formula test program index is not one exact file" + +KANDELO_LOGIN_LOCAL_TAP="$KANDELO_LOGIN_WORK_ROOT/tap-local" +git clone --no-local --no-checkout \ + "$KANDELO_LOGIN_TAP_ROOT" "$KANDELO_LOGIN_LOCAL_TAP" +git -C "$KANDELO_LOGIN_LOCAL_TAP" checkout --detach "$KANDELO_LOGIN_TAP_COMMIT" +[ -z "$(git -C "$KANDELO_LOGIN_LOCAL_TAP" status --short --untracked-files=all)" ] || \ + fail "detached local tap clone is dirty" +git -C "$KANDELO_LOGIN_LOCAL_TAP" config user.name 'Brandon Payton' +git -C "$KANDELO_LOGIN_LOCAL_TAP" config user.email 'brandon@happycode.net' +git -C "$KANDELO_LOGIN_LOCAL_TAP" rm -r --ignore-unmatch \ + Kandelo/formula Kandelo/link Kandelo/reports Kandelo/metadata.json +cat >"$KANDELO_LOGIN_LOCAL_TAP/local-test-provenance.json" <<'EOF' +{ + "schema": 1, + "provenance_kind": "local-test", + "promotable": false, + "published": false +} +EOF +git -C "$KANDELO_LOGIN_LOCAL_TAP" add local-test-provenance.json +GIT_AUTHOR_NAME='Brandon Payton' GIT_AUTHOR_EMAIL='brandon@happycode.net' \ +GIT_COMMITTER_NAME='Brandon Payton' GIT_COMMITTER_EMAIL='brandon@happycode.net' \ + git -C "$KANDELO_LOGIN_LOCAL_TAP" commit -m 'Local-test: Initialize ABI 43 bottle catalog' + +KANDELO_LOGIN_HOMEBREW_COMMIT=a92554a538e81fad0c5074443885dbcc4c36221d +KANDELO_LOGIN_HOMEBREW_ROOT="$KANDELO_LOGIN_WORK_ROOT/homebrew-implementation" +git clone --filter=blob:none --no-checkout https://github.com/Homebrew/brew.git "$KANDELO_LOGIN_HOMEBREW_ROOT" +git -C "$KANDELO_LOGIN_HOMEBREW_ROOT" fetch --depth=1 origin "$KANDELO_LOGIN_HOMEBREW_COMMIT" +git -C "$KANDELO_LOGIN_HOMEBREW_ROOT" checkout --detach "$KANDELO_LOGIN_HOMEBREW_COMMIT" +[ -z "$(git -C "$KANDELO_LOGIN_HOMEBREW_ROOT" status --short --untracked-files=all)" ] || fail "reviewed Homebrew checkout is dirty" +bash scripts/homebrew-prepare-host-prefix.sh --layout-mode canonical --prefix /opt/kandelo/homebrew +rm -f /opt/kandelo/homebrew/bin/brew +ln -s "$KANDELO_LOGIN_HOMEBREW_ROOT/bin/brew" /opt/kandelo/homebrew/bin/brew +export HOMEBREW_BREW_FILE=/opt/kandelo/homebrew/bin/brew +export HOMEBREW_BREW_COMMIT="$KANDELO_LOGIN_HOMEBREW_COMMIT" +export HOMEBREW_CACHE="$KANDELO_LOGIN_WORK_ROOT/homebrew-cache" +export HOMEBREW_TEMP="$KANDELO_LOGIN_WORK_ROOT/homebrew-temp" +export HOMEBREW_NO_AUTO_UPDATE=1 +export HOMEBREW_NO_INSTALL_CLEANUP=1 +export HOMEBREW_NO_ANALYTICS=1 +export HOMEBREW_DEVELOPER=1 +mkdir "$HOMEBREW_CACHE" "$HOMEBREW_TEMP" +[ "$($HOMEBREW_BREW_FILE --prefix)" = /opt/kandelo/homebrew ] || fail "reviewed Homebrew selected the wrong prefix" +[ "$($HOMEBREW_BREW_FILE --cellar)" = /opt/kandelo/homebrew/Cellar ] || fail "reviewed Homebrew selected the wrong Cellar" + +KANDELO_LOGIN_BOTTLE_CACHE="$KANDELO_LOGIN_WORK_ROOT/bottle-cache" +KANDELO_LOGIN_BUILD_ROOT="$KANDELO_LOGIN_WORK_ROOT/formula-builds" +KANDELO_LOGIN_SIDECAR_ROOT="$KANDELO_LOGIN_WORK_ROOT/sidecars" +KANDELO_LOGIN_RESOLVED_TAPS="$KANDELO_LOGIN_WORK_ROOT/resolved-taps.json" +KANDELO_LOGIN_BOTTLE_ROOT=https://ghcr.io/v2/kandelo-dev/homebrew-tap-core +mkdir "$KANDELO_LOGIN_BOTTLE_CACHE" "$KANDELO_LOGIN_BUILD_ROOT" "$KANDELO_LOGIN_SIDECAR_ROOT" +KANDELO_LOGIN_FORBIDDEN_ROOTS_JSON="$( + jq -n --arg work "$KANDELO_LOGIN_WORK_ROOT" \ + --arg build "$KANDELO_LOGIN_BUILD_ROOT" \ + --arg temp "$HOMEBREW_TEMP" \ + --arg shared_temp "$KANDELO_LOGIN_SHARED_TEMP" \ + '[$work,$build,$temp,$shared_temp] | unique' +)" +jq -e 'length > 0 and all(.[]; startswith("/"))' \ + <<<"$KANDELO_LOGIN_FORBIDDEN_ROOTS_JSON" >/dev/null || \ + fail "local sidecar forbidden roots are invalid" +jq -nS \ + --arg kandelo_commit "$KANDELO_LOGIN_KANDELO_COMMIT" \ + --arg tap_commit "$KANDELO_LOGIN_TAP_COMMIT" \ + --arg homebrew_commit "$KANDELO_LOGIN_HOMEBREW_COMMIT" \ + --arg ruby_tree_sha256 "$KANDELO_LOGIN_RUBY_TREE_SHA256" \ + '{schema:1, provenance:{schema:1, provenance_kind:"local-test", promotable:false, published:false}, kandelo_commit:$kandelo_commit, tap_commit:$tap_commit, homebrew_commit:$homebrew_commit, abi:43, ruby_pristine_tree_sha256:$ruby_tree_sha256, formulae:[]}' \ + >"$KANDELO_LOGIN_WORK_ROOT/bottle-build-report.json" + +refresh_resolved_taps() { + local checkout_commit + checkout_commit="$(git -C "$KANDELO_LOGIN_LOCAL_TAP" rev-parse HEAD)" + rm -f "$KANDELO_LOGIN_RESOLVED_TAPS" + python3 scripts/homebrew-dependency-taps.py resolve \ + --tap-root "$KANDELO_LOGIN_LOCAL_TAP" \ + --tap-name kandelo-dev/tap-core \ + --tap-repository kandelo-dev/homebrew-tap-core \ + --tap-commit "$KANDELO_LOGIN_TAP_COMMIT" \ + --checkout-commit "$checkout_commit" \ + --out "$KANDELO_LOGIN_RESOLVED_TAPS" + export KANDELO_HOMEBREW_RESOLVED_TAPS_FILE="$KANDELO_LOGIN_RESOLVED_TAPS" + export KANDELO_HOMEBREW_TAP_SOURCE_COMMIT="$KANDELO_LOGIN_TAP_COMMIT" + export KANDELO_HOMEBREW_PREPARED_TAP_COMMIT="$checkout_commit" +} + +commit_local_tap() { + local subject="$1" + git -C "$KANDELO_LOGIN_LOCAL_TAP" add Formula Kandelo local-test-provenance.json + GIT_AUTHOR_NAME='Brandon Payton' GIT_AUTHOR_EMAIL='brandon@happycode.net' \ + GIT_COMMITTER_NAME='Brandon Payton' GIT_COMMITTER_EMAIL='brandon@happycode.net' \ + git -C "$KANDELO_LOGIN_LOCAL_TAP" commit -m "$subject" +} + +mapfile -t KANDELO_LOGIN_FORMULAE < <(jq -er '.formula_closure[]' homebrew/main-shell-migration-lock.json) +[ "${#KANDELO_LOGIN_FORMULAE[@]}" -eq 43 ] || fail "runtime Formula closure is incomplete" +# Use the same strict Formula parser as bottle construction to prove every +# exact same-tap dependency precedes its consumer in the costly build loop. +declare -A KANDELO_LOGIN_FORMULA_POSITION=() +for index in "${!KANDELO_LOGIN_FORMULAE[@]}"; do + KANDELO_LOGIN_FORMULA_POSITION["${KANDELO_LOGIN_FORMULAE[$index]}"]="$index" +done +for full_name in "${KANDELO_LOGIN_FORMULAE[@]}"; do + formula="${full_name##*/}" + while IFS= read -r dependency; do + [ -n "${KANDELO_LOGIN_FORMULA_POSITION[$dependency]+set}" ] || \ + fail "Formula build sequence omits dependency $dependency of $full_name" + [ "${KANDELO_LOGIN_FORMULA_POSITION[$dependency]}" -lt \ + "${KANDELO_LOGIN_FORMULA_POSITION[$full_name]}" ] || \ + fail "Formula dependency must precede its consumer: $dependency -> $full_name" + done < <(ruby scripts/homebrew-formula-runtime-closure.rb \ + "$KANDELO_LOGIN_LOCAL_TAP" kandelo-dev/tap-core "$formula" --direct) +done +for full_name in "${KANDELO_LOGIN_FORMULAE[@]}"; do + formula="${full_name##*/}" + formula_out="$KANDELO_LOGIN_BUILD_ROOT/$formula" + mkdir "$formula_out" + formula_source_root="$formula_out/formula-source" + formula_verify_root="$formula_out/formula-verify" + build_tap_commit="$(git -C "$KANDELO_LOGIN_LOCAL_TAP" rev-parse HEAD)" + git -C "$KANDELO_LOGIN_LOCAL_TAP" worktree add --detach \ + "$formula_source_root" "$build_tap_commit" + build_formula_sha256="$(sha256sum "$formula_source_root/Formula/$formula.rb" | awk '{print $1}')" + runtime_evidence="$formula_out/runtime-evidence.json" + selection_receipt="$formula_out/selection-receipt.json" + sidecars="$KANDELO_LOGIN_SIDECAR_ROOT/$formula" + refresh_resolved_taps + formula_host_cache="$KANDELO_LOGIN_SHARED_TEMP/cache-$formula" + formula_host_temp="$KANDELO_LOGIN_SHARED_TEMP/temp-$formula" + mkdir "$formula_host_cache" "$formula_host_temp" + formula_isolation_env=( + HOMEBREW_CACHE="$formula_host_cache" + HOMEBREW_TEMP="$formula_host_temp" + PLAYWRIGHT_BROWSERS_PATH="$KANDELO_LOGIN_SHARED_TEMP/ms-playwright" + KANDELO_HOMEBREW_LOCAL_DEPENDENCY_CACHE="$KANDELO_LOGIN_BOTTLE_CACHE" + KANDELO_HOMEBREW_BUILD_USER="$KANDELO_LOGIN_BUILD_USER" + KANDELO_HOMEBREW_RECIPE_USER="$KANDELO_LOGIN_RECIPE_USER" + KANDELO_HOMEBREW_SHARED_TEMP="$KANDELO_LOGIN_SHARED_TEMP" + KANDELO_HOMEBREW_SUDO_BIN=/usr/bin/sudo + KANDELO_HOMEBREW_SYSTEMD_RUN_BIN=/usr/bin/systemd-run + KANDELO_HOMEBREW_SYSTEMCTL_BIN=/usr/bin/systemctl + KANDELO_HOMEBREW_GETENT_BIN=/usr/bin/getent + KANDELO_HOMEBREW_PGREP_BIN=/usr/bin/pgrep + KANDELO_HOMEBREW_PKILL_BIN=/usr/bin/pkill + WASM_POSIX_XTASK_BIN="$KANDELO_LOGIN_XTASK_BIN" + ) + formula_build_evidence_env=() + if [ "$formula" = ruby ]; then + formula_build_evidence_env+=(KANDELO_HOMEBREW_LOCAL_BUILD_EVIDENCE="$KANDELO_LOGIN_WORK_ROOT/ruby-build-evidence") + fi + env "${formula_isolation_env[@]}" "${formula_build_evidence_env[@]}" \ + bash scripts/homebrew-bottle-build.sh \ + --tap-root "$KANDELO_LOGIN_LOCAL_TAP" \ + --tap-repository kandelo-dev/homebrew-tap-core \ + --tap-name kandelo-dev/tap-core \ + --formula "$formula" \ + --arch wasm32 \ + --out "$formula_out" \ + --bottle-root-url "$KANDELO_LOGIN_BOTTLE_ROOT" \ + --retire-source-install \ + 2>&1 | tee "$formula_out/build.log" + # shellcheck disable=SC1090 + . "$formula_out/build.env" + bottle_sha256="$(sha256sum "$BOTTLE_ARCHIVE" | awk '{print $1}')" + bottle_bytes="$(wc -c <"$BOTTLE_ARCHIVE" | tr -d '[:space:]')" + bottle_url="$KANDELO_LOGIN_BOTTLE_ROOT/$formula/blobs/sha256:$bottle_sha256" + formula_key="kandelo-dev/tap-core/$formula" + rebuild="$(jq -er --arg key "$formula_key" '.[$key].bottle.rebuild' "$BOTTLE_JSON")" + cellar="$(jq -er --arg key "$formula_key" '.[$key].bottle.cellar' "$BOTTLE_JSON")" + composed_formula="$formula_out/$formula.rb" + ruby scripts/homebrew-compose-formula-bottle.rb \ + "$formula_source_root/Formula/$formula.rb" \ + "$KANDELO_LOGIN_TAP_ROOT/Formula/$formula.rb" \ + "$KANDELO_LOGIN_BOTTLE_ROOT" "$rebuild" wasm32_kandelo "$cellar" \ + "$bottle_sha256" discard "$composed_formula" + # Mirror the publication workflow's split authority: verification sees the + # exact build checkout plus only the reconstructed target bottle block, + # while provenance hashes the unmodified Formula bytes Homebrew evaluated. + git -C "$KANDELO_LOGIN_LOCAL_TAP" worktree add --detach \ + "$formula_verify_root" "$build_tap_commit" + mv "$composed_formula" "$formula_verify_root/Formula/$formula.rb" + ruby scripts/homebrew-formula-source-digest.rb \ + --equivalent-excluding-bottle \ + "$formula_source_root/Formula/$formula.rb" \ + "$formula_verify_root/Formula/$formula.rb" >/dev/null || \ + fail "local $formula verification Formula drifted outside its bottle block" + jq -nS \ + --arg url "$bottle_url" --arg sha256 "$bottle_sha256" \ + --argjson bytes "$bottle_bytes" \ + '{schema:1,status:"success",bottle:{mode:"local-dry-run",url:$url,sha256:$sha256,bytes:$bytes},fetch:[("selected local-test bottle sha256:" + $sha256)]}' \ + >"$selection_receipt" + env "${formula_isolation_env[@]}" \ + KANDELO_HOMEBREW_LOCAL_DEPENDENCY_CACHE="$KANDELO_LOGIN_BOTTLE_CACHE" \ + KANDELO_HOMEBREW_PREPARED_TAP_COMMIT="$build_tap_commit" \ + bash scripts/homebrew-verify-poured-bottle.sh \ + --tap-root "$formula_verify_root" \ + --tap-repository kandelo-dev/homebrew-tap-core \ + --tap-name kandelo-dev/tap-core \ + --tap-commit "$KANDELO_LOGIN_TAP_COMMIT" \ + --tap-checkout-commit "$build_tap_commit" \ + --formula "$formula" --arch wasm32 --abi 43 \ + --bottle "$BOTTLE_ARCHIVE" --bottle-json "$BOTTLE_JSON" \ + --bottle-url "$bottle_url" --bottle-sha256 "$bottle_sha256" \ + --bottle-bytes "$bottle_bytes" --bottle-root-url "$KANDELO_LOGIN_BOTTLE_ROOT" \ + --dependency-provenance "$DEPENDENCY_PROVENANCE" \ + --selection-receipt "$selection_receipt" \ + --sysroot-build-root "$KANDELO_LOGIN_SOURCE" \ + --out "$runtime_evidence" \ + 2>&1 | tee "$formula_out/verify.log" + if [ "$formula" = ruby ]; then + ! grep -F 'make install failed, copying lib manually' \ + "$KANDELO_LOGIN_WORK_ROOT/ruby-build-evidence/homebrew-install.log" \ + >/dev/null || fail "Ruby used the payload-reducing fallback instead of normal upstream install" + ruby_pkg_version="$(jq -er --arg key "$formula_key" '.[$key].formula.pkg_version' "$BOTTLE_JSON")" + ruby_prefix="ruby/$ruby_pkg_version/bin/" + mapfile -t ruby_inventory < <( + tar -tzf "$BOTTLE_ARCHIVE" | + awk -v prefix="$ruby_prefix" 'index($0,prefix)==1 {name=substr($0,length(prefix)+1); if (name != "" && name !~ /\//) print name}' | + LC_ALL=C sort -u + ) + expected_ruby_inventory="$(jq -cS '.product.ruby.required_stock_executables | sort' "$KANDELO_LOGIN_LOCK")" + actual_ruby_inventory="$(printf '%s\n' "${ruby_inventory[@]}" | jq -Rsc 'split("\n")[:-1] | sort')" + [ "$actual_ruby_inventory" = "$expected_ruby_inventory" ] || \ + fail "Ruby installed executable inventory differs from normal upstream install: $actual_ruby_inventory" + mapfile -t ruby_runtime_inventory < <( + unzip -Z1 "$KANDELO_LOGIN_WORK_ROOT/ruby-build-evidence/ruby-runtime.zip" | + awk 'index($0,"usr/bin/")==1 {name=substr($0,length("usr/bin/")+1); if (name != "" && name !~ /\//) print name}' | + LC_ALL=C sort -u + ) + actual_ruby_runtime_inventory="$(printf '%s\n' "${ruby_runtime_inventory[@]}" | jq -Rsc 'split("\n")[:-1] | sort')" + [ "$actual_ruby_runtime_inventory" = "$expected_ruby_inventory" ] || \ + fail "Ruby runtime archive executable inventory differs from normal upstream install: $actual_ruby_runtime_inventory" + ruby_member="${ruby_prefix}ruby" + archive_ruby_sha256="$(tar -xOf "$BOTTLE_ARCHIVE" "$ruby_member" | sha256sum | awk '{print $1}')" + retained_ruby_sha256="$(sha256sum "$KANDELO_LOGIN_WORK_ROOT/ruby-build-evidence/instrumented-ruby.wasm" | awk '{print $1}')" + [ "$archive_ruby_sha256" = "$retained_ruby_sha256" ] || \ + fail "final bottle bin/ruby is not the root-spilled fork-instrumented recipe artifact" + for define in HAVE_VFORK HAVE_WORKING_VFORK HAVE_WORKING_FORK; do + grep -Eq "^#define $define 1$" \ + "$KANDELO_LOGIN_WORK_ROOT/ruby-build-evidence/config.h" || \ + fail "Ruby retained config.h omits $define" + done + jq -nS \ + --argjson installed "$actual_ruby_inventory" \ + --argjson runtime_archive "$actual_ruby_runtime_inventory" \ + --arg bottle_sha256 "$bottle_sha256" \ + --arg ruby_sha256 "$archive_ruby_sha256" \ + --arg config_h_sha256 "$(sha256sum "$KANDELO_LOGIN_WORK_ROOT/ruby-build-evidence/config.h" | awk '{print $1}')" \ + --arg config_log_sha256 "$(sha256sum "$KANDELO_LOGIN_WORK_ROOT/ruby-build-evidence/config.log" | awk '{print $1}')" \ + --arg process_c_sha256 "$(sha256sum "$KANDELO_LOGIN_WORK_ROOT/ruby-build-evidence/process.c" | awk '{print $1}')" \ + --arg runtime_archive_sha256 "$(sha256sum "$KANDELO_LOGIN_WORK_ROOT/ruby-build-evidence/ruby-runtime.zip" | awk '{print $1}')" ' + { + schema:1, + provenance:{schema:1,provenance_kind:"local-test",promotable:false,published:false}, + install_path:"normal-upstream-install", + runtime_archive_executables:$runtime_archive, + bottle_archive_executables:$installed, + installed_executables:$installed, + bottle_sha256:$bottle_sha256, + runtime_archive_sha256:$runtime_archive_sha256, + final_ruby_sha256:$ruby_sha256, + config_h_sha256:$config_h_sha256, + config_log_sha256:$config_log_sha256, + process_c_sha256:$process_c_sha256, + config_defines:["HAVE_VFORK","HAVE_WORKING_VFORK","HAVE_WORKING_FORK"], + root_spilled_and_fork_instrumented_recipe_artifact:true + }' \ + >"$KANDELO_LOGIN_WORK_ROOT/ruby-installed-inventory.json" + fi + mkdir "$sidecars" + KANDELO_HOMEBREW_TAP_ROOT="$formula_verify_root" \ + KANDELO_HOMEBREW_FORMULA_SOURCE_ROOT="$formula_source_root" \ + KANDELO_HOMEBREW_BUILD_ROOT="$KANDELO_LOGIN_SOURCE" \ + KANDELO_HOMEBREW_SIDECAR_ROOT="$sidecars" \ + KANDELO_HOMEBREW_FORMULA="$formula" \ + KANDELO_HOMEBREW_ARCH=wasm32 \ + KANDELO_HOMEBREW_RELEASE_TAG=bottles-abi-v43 \ + KANDELO_HOMEBREW_TAP_REPOSITORY=kandelo-dev/homebrew-tap-core \ + KANDELO_HOMEBREW_TAP_NAME=kandelo-dev/tap-core \ + KANDELO_HOMEBREW_BOTTLE_ARCHIVE="$BOTTLE_ARCHIVE" \ + KANDELO_HOMEBREW_BOTTLE_JSON="$BOTTLE_JSON" \ + KANDELO_HOMEBREW_BOTTLE_ROOT_URL="$KANDELO_LOGIN_BOTTLE_ROOT" \ + KANDELO_HOMEBREW_BOTTLE_URL="$bottle_url" \ + KANDELO_HOMEBREW_BOTTLE_SHA256="$bottle_sha256" \ + KANDELO_HOMEBREW_BOTTLE_BYTES="$bottle_bytes" \ + KANDELO_HOMEBREW_DEPENDENCY_PROVENANCE="$DEPENDENCY_PROVENANCE" \ + KANDELO_HOMEBREW_RUNTIME_EVIDENCE="$runtime_evidence" \ + KANDELO_HOMEBREW_FORBIDDEN_ROOTS_JSON="$KANDELO_LOGIN_FORBIDDEN_ROOTS_JSON" \ + KANDELO_HOMEBREW_PROVENANCE_KIND=local-test \ + KANDELO_HOMEBREW_PREPARED_TAP_COMMIT="$build_tap_commit" \ + bash scripts/homebrew-generate-sidecars-from-env.sh + reseal_formula_test_checker "$formula sidecar generation" + sidecar_formula_report="$sidecars/Kandelo/formula/$formula.json" + [ -f "$sidecar_formula_report" ] && [ ! -L "$sidecar_formula_report" ] || \ + fail "local $formula sidecar formula report is not a regular file" + archived_formula_sha256="$( + jq -er ' + .packages[0].bottles[0].archived_formula_sha256 | + select(type == "string" and test("^[0-9a-f]{64}$")) + ' "$sidecars/sidecars-input.json" + )" + archived_formula_report_sha256="$( + jq -er ' + .bottles[0].built_from.formula_sha256 | + select(type == "string" and test("^[0-9a-f]{64}$")) + ' "$sidecar_formula_report" + )" + [ "$(jq -er '.packages[0].formula_source_sha256' "$sidecars/sidecars-input.json")" = "$build_formula_sha256" ] || \ + fail "local $formula sidecar build-source Formula identity differs from the exact evaluated bytes" + [ "$archived_formula_report_sha256" = "$archived_formula_sha256" ] || \ + fail "local $formula sidecar built-from Formula identity differs from the exact archived receipt" + rsync -a --delete "$sidecars/Kandelo/" "$KANDELO_LOGIN_LOCAL_TAP/Kandelo/" + cp -p "$sidecars/Formula/$formula.rb" "$KANDELO_LOGIN_LOCAL_TAP/Formula/$formula.rb" + cp -p "$sidecars/local-test-provenance.json" "$KANDELO_LOGIN_LOCAL_TAP/local-test-provenance.json" + commit_local_tap "Local-test: Bind ABI 43 $formula bottle and evidence" + cp -p "$BOTTLE_ARCHIVE" "$KANDELO_LOGIN_BOTTLE_CACHE/$bottle_sha256.tar.gz" + next_report="$formula_out/bottle-build-report.next.json" + jq \ + --arg formula "$formula" --arg sha256 "$bottle_sha256" \ + --argjson bytes "$bottle_bytes" --arg bottle_url "$bottle_url" \ + --arg prepared_tap_commit "$(git -C "$KANDELO_LOGIN_LOCAL_TAP" rev-parse HEAD)" \ + --slurpfile runtime "$runtime_evidence" \ + '.formulae += [{formula:$formula,arch:"wasm32",kandelo_abi:43,sha256:$sha256,bytes:$bytes,url:$bottle_url,prepared_tap_commit:$prepared_tap_commit,runtime_evidence:$runtime[0],provenance:{schema:1,provenance_kind:"local-test",promotable:false,published:false}}]' \ + "$KANDELO_LOGIN_WORK_ROOT/bottle-build-report.json" >"$next_report" + mv "$next_report" "$KANDELO_LOGIN_WORK_ROOT/bottle-build-report.json" +done + +jq -e --argjson expected "$(printf '%s\n' "${KANDELO_LOGIN_FORMULAE[@]}" | jq -Rsc 'split("\n")[:-1]')" ' + (.packages | length) == 43 and + ([.packages[].full_name] == $expected) and + all(.packages[]; ([.bottles[] | select(.arch == "wasm32" and .status == "success" and .kandelo_abi == 43)] | length) == 1) and + ([.packages[].bottles[].kandelo_abi] | all(. == 43)) +' "$KANDELO_LOGIN_LOCAL_TAP/Kandelo/metadata.json" >/dev/null || fail "final local catalog is not the complete pure ABI 43 closure" +[ "$(jq '.formulae | length' "$KANDELO_LOGIN_WORK_ROOT/bottle-build-report.json")" = 43 ] || fail "bottle report omitted a Formula" + +# The published-sidecar boundary must reject this exact local catalog before +# it can copy a byte or mutate a tap. +if bash scripts/homebrew-publish-sidecars.sh \ + --tap-root "$KANDELO_LOGIN_WORK_ROOT/must-not-publish" \ + --release-tag bottles-abi-v43 --status success \ + --formula ruby --arch wasm32 --sidecar-root "$KANDELO_LOGIN_LOCAL_TAP" \ + >"$KANDELO_LOGIN_WORK_ROOT/publisher-rejection.log" 2>&1; then + fail "published-sidecar validator accepted local-test provenance" +fi +grep -F 'local-test provenance is not publishable' "$KANDELO_LOGIN_WORK_ROOT/publisher-rejection.log" >/dev/null || fail "publisher rejection did not report the local provenance boundary" + +KANDELO_LOGIN_BOOTSTRAP="$KANDELO_LOGIN_WORK_ROOT/homebrew-bootstrap" +mkdir "$KANDELO_LOGIN_BOOTSTRAP" +bash scripts/prepare-homebrew-bootstrap-source.sh \ + --repository https://github.com/Homebrew/brew.git \ + --revision d6c1be418446eec7de09fc72441ba4462282a142 \ + --patch "$KANDELO_LOGIN_SOURCE/homebrew/patches/0001-add-kandelo-wasm-bottle-tags.patch" \ + --expected-patch-sha256 faf62befeb70033ea450e88eb1b21427e221030a7f6b6ce932ad2c7c728ac2bc \ + --arch wasm32 --git-dir "$KANDELO_LOGIN_BOOTSTRAP/git" \ + --archive "$KANDELO_LOGIN_BOOTSTRAP/homebrew-bootstrap.zip" \ + --env "$KANDELO_LOGIN_BOOTSTRAP/homebrew-brew.env" \ + --provenance "$KANDELO_LOGIN_BOOTSTRAP/provenance.json" + +KANDELO_HOMEBREW_TAP_SOURCE_COMMIT="$KANDELO_LOGIN_TAP_COMMIT" \ +bash scripts/build-homebrew-main-shell-closure.sh \ + --tap-root "$KANDELO_LOGIN_LOCAL_TAP" \ + --expected-tap-sha "$KANDELO_LOGIN_TAP_COMMIT" \ + --work-dir "$KANDELO_LOGIN_WORK_ROOT/composition" \ + --out "$KANDELO_LOGIN_WORK_ROOT/main-shell.vfs.zst" \ + --report "$KANDELO_LOGIN_WORK_ROOT/composition-report.json" \ + --bottle-cache "$KANDELO_LOGIN_BOTTLE_CACHE" \ + --package-tree-spec "$KANDELO_LOGIN_SOURCE/homebrew/main-shell-brew-package-tree.json" \ + --package-tree-archive "$KANDELO_LOGIN_BOOTSTRAP/homebrew-bootstrap.zip" \ + --homebrew-bootstrap-env "$KANDELO_LOGIN_BOOTSTRAP/homebrew-brew.env" \ + --lazy-shell --review-pending-artifact + +KANDELO_LOGIN_PREPARED_TAP_COMMIT="$( + git -C "$KANDELO_LOGIN_LOCAL_TAP" rev-parse HEAD +)" +jq -e \ + --arg source "$KANDELO_LOGIN_TAP_COMMIT" \ + --arg prepared "$KANDELO_LOGIN_PREPARED_TAP_COMMIT" ' + .local_test.source_tap_commit == $source and + .local_test.prepared_tap_commit == $prepared and + .local_test.staged_tap.source_commit == $source and + .local_test.staged_tap.prepared_commit == $prepared and + .local_test.staged_tap.path == + "/opt/kandelo/homebrew/var/kandelo/local-test/homebrew-tap-core.bundle" and + (.local_test.staged_tap.sha256 | test("^[0-9a-f]{64}$")) and + .local_test.staged_tap.bytes > 0 +' "$KANDELO_LOGIN_WORK_ROOT/composition-report.json" >/dev/null || \ + fail "composition omitted the exact source/prepared local tap bundle" + +KANDELO_LOGIN_MIRROR="$KANDELO_LOGIN_WORK_ROOT/composition/bottle-mirror" +KANDELO_LOGIN_MIRROR_PLAN="$KANDELO_LOGIN_MIRROR/kandelo-homebrew-bottle-mirror-plan.json" +[ -f "$KANDELO_LOGIN_MIRROR_PLAN" ] || fail "composition omitted the closed bottle mirror" +KANDELO_LOGIN_RSS_REPORT="$KANDELO_LOGIN_WORK_ROOT/process-tree-rss.json" +npx tsx --test scripts/homebrew-main-shell-image-contract.test.ts \ + 2>&1 | tee "$KANDELO_LOGIN_WORK_ROOT/image-contract.log" +npx tsx scripts/homebrew-main-shell-node-smoke.ts \ + --image "$KANDELO_LOGIN_WORK_ROOT/main-shell.vfs.zst" \ + --migration-lock "$KANDELO_LOGIN_SOURCE/homebrew/main-shell-migration-lock.json" \ + --homebrew-bootstrap-spec "$KANDELO_LOGIN_SOURCE/homebrew/main-shell-brew-package-tree.json" \ + --homebrew-bootstrap-archive "$KANDELO_LOGIN_BOOTSTRAP/homebrew-bootstrap.zip" \ + --homebrew-bootstrap-env "$KANDELO_LOGIN_BOOTSTRAP/homebrew-brew.env" \ + --homebrew-bootstrap-state deferred \ + --homebrew-runtime-support "$KANDELO_LOGIN_SOURCE/homebrew/main-shell-homebrew-runtime-support.json" \ + --demo-config "$KANDELO_LOGIN_SOURCE/homebrew/main-shell-demo.json" \ + --transport-mode closed --bottle-mirror-plan "$KANDELO_LOGIN_MIRROR_PLAN" \ + --rss-report "$KANDELO_LOGIN_RSS_REPORT" \ + --composition-report "$KANDELO_LOGIN_WORK_ROOT/composition-report.json" \ + --privileged-product "$KANDELO_LOGIN_WORK_ROOT/main-shell.vfs.privileged.vfs" \ + 2>&1 | tee "$KANDELO_LOGIN_WORK_ROOT/node-smoke.log" + +KANDELO_LOGIN_FIXTURE="$KANDELO_LOGIN_WORK_ROOT/homebrew-login-lifecycle-fixture.json" +npx tsx scripts/create-homebrew-guest-lifecycle-fixture.ts \ + --transport-mode closed \ + --image "$KANDELO_LOGIN_WORK_ROOT/main-shell.vfs.zst" \ + --homebrew-bootstrap-spec "$KANDELO_LOGIN_SOURCE/homebrew/main-shell-brew-package-tree.json" \ + --homebrew-bootstrap-archive "$KANDELO_LOGIN_BOOTSTRAP/homebrew-bootstrap.zip" \ + --homebrew-bootstrap-env "$KANDELO_LOGIN_BOOTSTRAP/homebrew-brew.env" \ + --bottle-mirror "$KANDELO_LOGIN_MIRROR" \ + --composition-report "$KANDELO_LOGIN_WORK_ROOT/composition-report.json" \ + --privileged-product "$KANDELO_LOGIN_WORK_ROOT/main-shell.vfs.privileged.vfs" \ + --fixed-asset-url-root https://closed.kandelo.invalid/login-product/ \ + --core-revision "$KANDELO_LOGIN_TAP_COMMIT" \ + --canary-revision "$KANDELO_LOGIN_TAP_COMMIT" \ + --timeout-ms 1800000 --out "$KANDELO_LOGIN_FIXTURE" + +KANDELO_LOGIN_BROWSER_ASSETS=apps/browser-demos/public/homebrew-login-product +mkdir "$KANDELO_LOGIN_BROWSER_ASSETS" +for source in \ + "$KANDELO_LOGIN_WORK_ROOT/main-shell.vfs.zst" \ + "$KANDELO_LOGIN_WORK_ROOT/composition-report.json" \ + "$KANDELO_LOGIN_WORK_ROOT/main-shell.vfs.privileged.vfs" \ + "$KANDELO_LOGIN_SOURCE/homebrew/main-shell-brew-package-tree.json" \ + "$KANDELO_LOGIN_BOOTSTRAP/homebrew-bootstrap.zip" \ + "$KANDELO_LOGIN_BOOTSTRAP/homebrew-brew.env" \ + "$KANDELO_LOGIN_MIRROR"/*; do + cp -p "$source" "$KANDELO_LOGIN_BROWSER_ASSETS/$(basename "$source")" +done + +KANDELO_LOGIN_BROWSER_REPORT="$KANDELO_LOGIN_WORK_ROOT/browser-report.json" +KANDELO_LOGIN_BROWSER_IDENTITIES="$KANDELO_LOGIN_WORK_ROOT/browser-identities.json" +( + cd apps/browser-demos + CI=1 \ + KANDELO_PLAYWRIGHT_PORT=56431 \ + KANDELO_PLAYWRIGHT_VITE_MODE=homebrew-closed-acceptance \ + KANDELO_PLAYWRIGHT_CLOSED_ACCEPTANCE_ROOT=/homebrew-login-product \ + KANDELO_HOMEBREW_GUEST_BROWSER_LIFECYCLE_LIVE=1 \ + KANDELO_HOMEBREW_GUEST_BROWSER_LIFECYCLE_FIXTURE_PATH="$KANDELO_LOGIN_FIXTURE" \ + KANDELO_LOGIN_RSS_REPORT_PATH="$KANDELO_LOGIN_RSS_REPORT" \ + KANDELO_LOGIN_BROWSER_IDENTITY_PATH="$KANDELO_LOGIN_BROWSER_IDENTITIES" \ + npx playwright test test/homebrew-login-lifecycle.spec.ts \ + --project=chromium --project=firefox --project=webkit \ + --workers=1 --reporter=json \ + --output="$KANDELO_LOGIN_WORK_ROOT/playwright-output" \ + >"$KANDELO_LOGIN_BROWSER_REPORT" +) +jq -e '.stats.expected == 3 and .stats.unexpected == 0 and .stats.flaky == 0 and .stats.skipped == 0' "$KANDELO_LOGIN_BROWSER_REPORT" >/dev/null || fail "three-engine generated login product lifecycle did not report exactly three passes" +jq -e ' + .schema == 1 and + .provenance == {schema:1,provenance_kind:"local-test",promotable:false,published:false} and + ([.browsers[].project] | sort) == ["chromium","firefox","webkit"] and + all(.browsers[]; (.version | type == "string" and length > 0) and (.userAgent | type == "string" and length > 0)) +' "$KANDELO_LOGIN_BROWSER_IDENTITIES" >/dev/null || fail "three-engine browser identity evidence is incomplete" +jq -e ' + .schema == 1 and .unit == "KiB" and + .provenance == {schema:1,provenance_kind:"local-test",promotable:false,published:false} and + ([.samples[].phase] | sort) == (["before-boot","before-boot","before-ruby","before-ruby","peak","peak","after-child-reaping","after-child-reaping","after-three-repetitions","after-three-repetitions"] | sort) and + ([.samples[].roots[].label] | sort) == (["node","node","node","node","node","chromium","chromium","chromium","chromium","chromium"] | sort) and + all(.samples[]; (.roots | length) == 1 and all(.roots[]; .rss_kib > 0 and (.processes | length) > 0)) +' "$KANDELO_LOGIN_RSS_REPORT" >/dev/null || fail "Node and Chromium five-phase RSS evidence is incomplete" + +kernel_path="$(bash scripts/resolve-binary.sh kernel.wasm)" +image_sha256="$(sha256sum "$KANDELO_LOGIN_WORK_ROOT/main-shell.vfs.zst" | awk '{print $1}')" +kernel_sha256="$(sha256sum "$kernel_path" | awk '{print $1}')" +privileged_product="${KANDELO_LOGIN_WORK_ROOT}/main-shell.vfs.privileged.vfs" +privileged_sha256="$(sha256sum "$privileged_product" | awk '{print $1}')" +jq -nS \ + --arg kandelo_commit "$KANDELO_LOGIN_KANDELO_COMMIT" \ + --arg tap_commit "$KANDELO_LOGIN_TAP_COMMIT" \ + --arg prepared_tap_commit "$KANDELO_LOGIN_PREPARED_TAP_COMMIT" \ + --arg image_sha256 "$image_sha256" --arg kernel_sha256 "$kernel_sha256" \ + --arg privileged_product_sha256 "$privileged_sha256" \ + --slurpfile bottles "$KANDELO_LOGIN_WORK_ROOT/bottle-build-report.json" \ + --slurpfile composition "$KANDELO_LOGIN_WORK_ROOT/composition-report.json" \ + --slurpfile browsers "$KANDELO_LOGIN_BROWSER_REPORT" \ + --slurpfile browser_identities "$KANDELO_LOGIN_BROWSER_IDENTITIES" \ + --slurpfile ruby_inventory "$KANDELO_LOGIN_WORK_ROOT/ruby-installed-inventory.json" \ + --slurpfile rss "$KANDELO_LOGIN_RSS_REPORT" ' + { + schema:1, + status:"success", + provenance:{schema:1,provenance_kind:"local-test",promotable:false,published:false}, + kandelo_commit:$kandelo_commit, + tap_commit:$tap_commit, + prepared_tap_commit:$prepared_tap_commit, + abi:43, + formula_roots:36, + formula_closure:43, + image_sha256:$image_sha256, + kernel_sha256:$kernel_sha256, + privileged_product_sha256:$privileged_product_sha256, + bottles:$bottles[0], + composition:$composition[0], + ruby:$ruby_inventory[0], + browsers:{identities:$browser_identities[0].browsers,stats:$browsers[0].stats}, + rss:$rss[0], + commands:[ + {name:"automatic maker login",status:"passed"}, + {name:"id",status:"passed"}, + {name:"sudo -l",status:"passed"}, + {name:"sudo id",status:"passed"}, + {name:"failed-password rejection",status:"passed"}, + {name:"ordinary login after logout",status:"passed"}, + {name:"nosuid execution rejection",status:"passed"}, + {name:"Ruby spawning through vfork",status:"passed"}, + {name:"brew tap/install/execute",status:"passed"} + ], + vfork_fork_mode_evidence:($bottles[0].formulae[] | select(.formula == "ruby") | .runtime_evidence) + }' \ + >"$KANDELO_LOGIN_WORK_ROOT/evidence.json" +{ + printf '# ABI 43 local login product evidence\n\n' + printf -- '- Status: success (local-test; non-promotable; unpublished)\n' + printf -- '- Kandelo: `%s`\n' "$KANDELO_LOGIN_KANDELO_COMMIT" + printf -- '- Tap source: `%s`\n' "$KANDELO_LOGIN_TAP_COMMIT" + printf -- '- Prepared local tap: `%s`\n' "$KANDELO_LOGIN_PREPARED_TAP_COMMIT" + printf -- '- ABI: 43\n- Roots: 36\n- Formula closure: 43\n' + printf -- '- Image SHA-256: `%s`\n' "$image_sha256" + printf -- '- Kernel SHA-256: `%s`\n' "$kernel_sha256" + printf -- '- Privileged product SHA-256: `%s`\n\n' "$privileged_sha256" + printf '## Ruby\n\n' + jq -r ' + "- Install path: `" + .install_path + "`", + "- Runtime archive executables: `" + (.runtime_archive_executables | join(", ")) + "`", + "- Bottle/installed executables: `" + (.installed_executables | join(", ")) + "`", + "- Final instrumented Ruby SHA-256: `" + .final_ruby_sha256 + "`", + "- Config defines: `" + (.config_defines | join(", ")) + "`" + ' "$KANDELO_LOGIN_WORK_ROOT/ruby-installed-inventory.json" + printf '\n## Browsers\n\n' + jq -r '.browsers[] | "- " + .project + ": `" + .version + "` (`" + .userAgent + "`)"' \ + "$KANDELO_LOGIN_BROWSER_IDENTITIES" + printf '\n## Lifecycle\n\n' + jq -r '.commands[] | "- " + .name + ": " + .status' \ + "$KANDELO_LOGIN_WORK_ROOT/evidence.json" + printf '\n## RSS\n\n' + jq -r '.samples[] | "- " + .roots[0].label + " / " + .phase + ": " + (.roots[0].rss_kib | tostring) + " KiB across " + (.roots[0].processes | length | tostring) + " exact processes"' \ + "$KANDELO_LOGIN_RSS_REPORT" + printf '\nExact Node and Chromium process-tree inventories are in `process-tree-rss.json`; no broad extrapolation is made.\n' + printf '\nAll 43 Formula/bottle identities and the Ruby vfork fork-mode record are in `evidence.json`.\n' +} >"$KANDELO_LOGIN_WORK_ROOT/evidence.md" + +if [ "$KANDELO_LOGIN_BROWSER_DEMO" = true ]; then + printf 'Preserved local-test assets. Manual demo command (from the detached source):\n' + printf 'KANDELO_MAIN_SHELL_VFS=%q KANDELO_HOMEBREW_BOTTLE_MIRROR=%q ./run.sh browser\n' \ + "$KANDELO_LOGIN_WORK_ROOT/main-shell.vfs.zst" "$KANDELO_LOGIN_MIRROR" +fi +printf 'run-login-stack-local.sh: complete local-test evidence: %s\n' "$KANDELO_LOGIN_WORK_ROOT/evidence.json" diff --git a/scripts/test-finalize-homebrew-main-shell-release.py b/scripts/test-finalize-homebrew-main-shell-release.py index 8bba96f1e4..64396bb49a 100755 --- a/scripts/test-finalize-homebrew-main-shell-release.py +++ b/scripts/test-finalize-homebrew-main-shell-release.py @@ -114,12 +114,17 @@ def assert_product_state(source: pathlib.Path, expected: str) -> None: ) -def copy_source(root: pathlib.Path) -> pathlib.Path: +def copy_local_source(root: pathlib.Path) -> pathlib.Path: source = root / "source" for relative in COPIED: destination = source / relative destination.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(REPO / relative, destination) + return source + + +def copy_source(root: pathlib.Path) -> pathlib.Path: + source = copy_local_source(root) # WHY: this finalizer exercises the reviewed ABI-42 shell-delivery # contract, including its ABI-42 bottle cohort. An unrelated Kandelo ABI # bump must not silently turn those historical fixtures into an ABI-43 @@ -135,6 +140,131 @@ def copy_source(root: pathlib.Path) -> pathlib.Path: ) assert replacements == 1 abi_path.write_text(abi_source) + + # Preserve the still-supported public ABI-42 fixture independently of the + # repository's current review-pending local ABI-43 product. This lets the + # same finalizer prove that public inputs retain their existing behavior + # while local-test provenance fails before any candidate tap read. + old_commit = "6ad0e3dbc60e5572c4288c86919238f71c1bc110" + migration_path = source / "homebrew/main-shell-migration-lock.json" + migration = json.loads(migration_path.read_text()) + excluded_roots = {"login", "sudo-lite", "sudo", "ruby"} + excluded_closure = { + f"{TAP_NAME}/login", + f"{TAP_NAME}/sudo-lite", + f"{TAP_NAME}/sudo", + f"{TAP_NAME}/libyaml", + f"{TAP_NAME}/ruby", + } + migration["catalog"]["tap_commit"] = old_commit + migration["packages"] = [ + entry + for entry in migration["packages"] + if entry["formula"]["name"] not in excluded_roots + ] + migration["formula_closure"] = [ + identity + for identity in migration["formula_closure"] + if identity not in excluded_closure + ] + migration.pop("product", None) + write_json(migration_path, migration) + + brewfile_path = source / "homebrew/main-shell.Brewfile" + brewfile_path.write_text( + "".join( + line + for line in brewfile_path.read_text().splitlines(keepends=True) + if not any( + line == f'brew "{TAP_NAME}/{name}"\n' + for name in excluded_roots + ) + ) + ) + + write_json( + source / "homebrew/main-shell-materialization-policy.json", + { + "schema": 1, + "kind": "kandelo-homebrew-vfs-materialization-policy", + "embedded_roots": [f"{TAP_NAME}/bash"], + "embedded_package_order": [ + f"{TAP_NAME}/libcxx", + f"{TAP_NAME}/ncurses", + f"{TAP_NAME}/bash", + ], + }, + ) + + support_path = source / "homebrew/main-shell-homebrew-runtime-support.json" + support = json.loads(support_path.read_text()) + support["catalog"]["tap_commit"] = old_commit + support["base_formula_order"] = [ + identity + for identity in support["base_formula_order"] + if identity not in excluded_closure + ] + support["activation"]["bootstrap_package"]["required_kernel_abi"] = 42 + support["formula_order"] = [ + identity + for identity in support["formula_order"] + if identity != f"{TAP_NAME}/libyaml" + ] + support["additional_formula_order"] = [f"{TAP_NAME}/ruby"] + support["availability"] = { + "audited_catalog": { + "checkout_commit": old_commit, + "metadata_sha256": "1" * 64, + "metadata_tap_commit": "2" * 40, + "kandelo_commit": "3" * 40, + "runtime_bottle_provenance_sha256": "4" * 64, + "kandelo_abi": 42, + "release_tag": "bottles-abi-v42", + "required_arch": "wasm32", + }, + "reusable_public_abi42": [ + f"{TAP_NAME}/{name}" + for name in [ + "zlib", "ruby", "coreutils", "dash", "ed", "diffutils", + "grep", "libcxx", "ncurses", "less", "openssl", "libcurl", + "sed", "vim", "git", "curl", "bzip2", "xz", "findutils", + "gawk", "gzip", "tar", "posix-utils-lite", "libmagic", + "file-formula", + ] + ], + "requires_rebuild": [], + "missing_metadata": [], + "can_be_deferred": [], + } + write_json(support_path, support) + + # Keep the copied pending locks internally bound to the transformed + # ABI-42 fixture. The finalizer deliberately verifies these digests before + # it accepts a closed selection, so carrying the repository's ABI-43 + # input hashes here would test only stale-lock rejection. + selection_path = source / "homebrew/main-shell-selection-lock.json" + selection = json.loads(selection_path.read_text()) + for bound_input in selection["inputs"].values(): + bound_input["sha256"] = digest(source / bound_input["path"]) + write_json(selection_path, selection) + + artifact_path = source / "homebrew/main-shell-lazy-artifact-lock.json" + artifact = json.loads(artifact_path.read_text()) + artifact_inputs = { + "bootstrap_tree_spec_sha256": "homebrew/main-shell-brew-package-tree.json", + "brewfile_sha256": "homebrew/main-shell.Brewfile", + "demo_config_sha256": "homebrew/main-shell-demo.json", + "materialization_policy_sha256": + "homebrew/main-shell-materialization-policy.json", + "migration_lock_sha256": "homebrew/main-shell-migration-lock.json", + "runtime_support_sha256": + "homebrew/main-shell-homebrew-runtime-support.json", + "selection_lock_sha256": "homebrew/main-shell-selection-lock.json", + "shell_config_sha256": "homebrew/main-shell-default.json", + } + for key, relative in artifact_inputs.items(): + artifact["inputs"][key] = digest(source / relative) + write_json(artifact_path, artifact) return source @@ -493,6 +623,30 @@ def misorder_embedded_formulae(policy: dict) -> None: ) +with tempfile.TemporaryDirectory( + prefix="kandelo-shell-finalizer-local-rejection." +) as temporary: + root = pathlib.Path(temporary) + source = copy_local_source(root) + tap = root / "must-not-read-or-create" + paths = [source / relative for relative in COPIED] + before = {path: digest(path) for path in paths} + rejected = run( + "--source-root", + str(source), + "--tap-root", + str(tap), + "--apply", + success=False, + ) + assert_failure( + rejected, + "local-test provenance is not promotable or selectable", + ) + assert before == {path: digest(path) for path in paths} + assert not tap.exists() + + with tempfile.TemporaryDirectory(prefix="kandelo-shell-finalizer-test.") as temporary: root = pathlib.Path(temporary) source = copy_source(root) diff --git a/scripts/test-homebrew-bottle-runtime-evidence.sh b/scripts/test-homebrew-bottle-runtime-evidence.sh index 7e0338e5df..f3233a992c 100755 --- a/scripts/test-homebrew-bottle-runtime-evidence.sh +++ b/scripts/test-homebrew-bottle-runtime-evidence.sh @@ -3,6 +3,7 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" TMPDIR="$(mktemp -d)" +TMPDIR="$(cd "$TMPDIR" && pwd -P)" trap 'rm -rf "$TMPDIR"' EXIT fail() { @@ -36,6 +37,7 @@ arch="wasm32" abi=39 tap_repository="kandelo-dev/homebrew-tap-core" tap_name="kandelo-dev/tap-core" +formula_key="$tap_name/$formula" tap_commit="" bottle_root="https://ghcr.io/v2/kandelo-dev/homebrew-tap-core" bottle="$TMPDIR/hello--1.0.wasm32_kandelo.bottle.tar.gz" @@ -54,6 +56,8 @@ mkdir -p "$tap/Formula" "$target_prefix" cat >"$tap/Formula/hello.rb" <<'EOF' class Hello < Formula desc "fixture" + homepage "https://example.invalid/hello" + license "MIT" url "https://example.invalid/hello-1.0.tar.gz" sha256 "0000000000000000000000000000000000000000000000000000000000000000" @@ -83,13 +87,41 @@ git -C "$tap" commit -q -m fixture tap_commit="$(git -C "$tap" rev-parse HEAD)" formula_sha="$(sha256sum "$tap/Formula/hello.rb" | awk '{print $1}')" -jq -nS --arg sha "$bottle_sha" '{hello: { - formula: {name: "hello", path: "Library/Taps/kandelo-dev/homebrew-tap-core/Formula/hello.rb", pkg_version: "1.0"}, - bottle: {root_url: "https://ghcr.io/v2/kandelo-dev/homebrew-tap-core", cellar: "any_skip_relocation", rebuild: 0, - tags: {wasm32_kandelo: {sha256: $sha}}} -}}' >"$bottle_json" +jq -nS --arg sha "$bottle_sha" --arg formula_key "$formula_key" \ + --arg tap_commit "$tap_commit" --arg tap_remote "file://$tap" '{ + ($formula_key): { + formula: { + name: "hello", + path: "Library/Taps/kandelo-dev/homebrew-tap-core/Formula/hello.rb", + pkg_version: "1.0", + tap_git_path: "Formula/hello.rb", + tap_git_revision: $tap_commit, + tap_git_remote: $tap_remote, + desc: "fixture", + license: "MIT", + homepage: "https://example.invalid/hello" + }, + bottle: { + root_url: "https://ghcr.io/v2/kandelo-dev/homebrew-tap-core", + cellar: "any_skip_relocation", + rebuild: 0, + date: "2026-08-12T00:00:00Z", + tags: {wasm32_kandelo: { + filename: "hello-1.0.wasm32_kandelo.bottle.tar.gz", + local_filename: "hello--1.0.wasm32_kandelo.bottle.tar.gz", + sha256: $sha, + tab: {}, + sbom: {}, + path_exec_files: ["bin/hello"], + all_files: ["bin/hello"], + installed_size: 21 + }} + } + } +}' >"$bottle_json" jq -nS --arg sha "$bottle_sha" --arg url "$bottle_url" --arg formula_sha "$formula_sha" '{ formulae: [{name: "hello", full_name: "kandelo-dev/tap-core/hello", + desc: "fixture", homepage: "https://example.invalid/hello", license: "MIT", versions: {stable: "1.0", head: null, bottle: true}, revision: 0, ruby_source_checksum: {sha256: $formula_sha}, bottle: {stable: {rebuild: 0, files: {wasm32_kandelo: { @@ -175,12 +207,102 @@ set_capture_arg() { fail "capture argument is missing: $flag" } +set_validate_arg() { + local flag="$1" value="$2" index + for index in "${!validate_args[@]}"; do + if [ "${validate_args[$index]}" = "$flag" ]; then + validate_args[$((index + 1))]="$value" + return 0 + fi + done + fail "validate argument is missing: $flag" +} + python3 "$REPO_ROOT/scripts/homebrew-bottle-runtime-evidence.py" capture \ "${capture_args[@]}" --out "$evidence" python3 "$REPO_ROOT/scripts/homebrew-bottle-runtime-evidence.py" validate \ --input "$evidence" "${validate_args[@]}" +short_key_bottle_json="$TMPDIR/short-key-bottle.json" +jq --arg formula "$formula" 'with_entries(.key = $formula)' \ + "$bottle_json" >"$short_key_bottle_json" +set_capture_arg --bottle-json "$short_key_bottle_json" +expect_capture_error "short canonical bottle Formula key" \ + "canonical bottle JSON Formula key does not match" +set_capture_arg --bottle-json "$bottle_json" + +expect_bottle_mutation_error() { + local label="$1" filter="$2" pattern="$3" + local mutated="$TMPDIR/mutated-bottle.json" + jq --arg formula_key "$formula_key" "$filter" "$bottle_json" >"$mutated" + set_capture_arg --bottle-json "$mutated" + expect_capture_error "$label" "$pattern" + set_capture_arg --bottle-json "$bottle_json" +} + +expect_bottle_mutation_error \ + "unknown raw Formula field" \ + '.[$formula_key].formula.unexpected = true' \ + "canonical Formula identity must contain exactly" +expect_bottle_mutation_error \ + "wrong raw Formula path" \ + '.[$formula_key].formula.path = "Formula/hello.rb"' \ + "canonical Formula path does not match the exact tap Formula" +expect_bottle_mutation_error \ + "wrong raw Formula tap Git path" \ + '.[$formula_key].formula.tap_git_path = "Formula/other.rb"' \ + "canonical Formula tap Git path does not match" +expect_bottle_mutation_error \ + "wrong raw Formula tap revision" \ + '.[$formula_key].formula.tap_git_revision = ("a" * 40)' \ + "canonical Formula tap Git revision does not match" +expect_bottle_mutation_error \ + "wrong raw Formula tap remote" \ + '.[$formula_key].formula.tap_git_remote = "file:///does/not/exist"' \ + "canonical Formula tap Git remote does not resolve" +expect_bottle_mutation_error \ + "invalid raw Formula license" \ + '.[$formula_key].formula.license = null' \ + "canonical Formula license must be a non-empty string" +expect_bottle_mutation_error \ + "wrong raw Formula license" \ + '.[$formula_key].formula.license = "Apache-2.0"' \ + "canonical Formula metadata differs from Homebrew Formula info" +expect_bottle_mutation_error \ + "wrong raw Formula description" \ + '.[$formula_key].formula.desc = "other"' \ + "canonical Formula metadata differs from Homebrew Formula info" +expect_bottle_mutation_error \ + "wrong raw Formula homepage" \ + '.[$formula_key].formula.homepage = "https://example.invalid/other"' \ + "canonical Formula metadata differs from Homebrew Formula info" +printf 'unreviewed\n' >"$tap/untracked" +expect_capture_error \ + "dirty raw Formula tap remote" \ + "canonical Formula tap Git remote does not identify the clean exact checkout" +rm "$tap/untracked" +expect_bottle_mutation_error \ + "unknown raw bottle field" \ + '.[$formula_key].bottle.unexpected = true' \ + "canonical bottle must contain exactly" +expect_bottle_mutation_error \ + "unknown raw bottle tag field" \ + '.[$formula_key].bottle.tags.wasm32_kandelo.unexpected = true' \ + "canonical wasm32_kandelo bottle must contain exactly" + tap_checkout_commit="$(printf 'b%.0s' {1..40})" +minimal_bottle_json="$TMPDIR/minimal-bottle.json" +jq --arg formula_key "$formula_key" --arg tag "${arch}_kandelo" '{ + ($formula_key): { + formula: (.[$formula_key].formula | {name, path, pkg_version}), + bottle: (.[$formula_key].bottle | { + root_url, cellar, rebuild, + tags: {($tag): .tags[$tag] | {sha256}} + }) + } +}' "$bottle_json" >"$minimal_bottle_json" +set_capture_arg --bottle-json "$minimal_bottle_json" +set_validate_arg --bottle-json "$minimal_bottle_json" cp "$target_receipt" "$target_receipt.public-source" cp "$dependency_provenance" "$dependency_provenance.public-source" jq --arg checkout "$tap_checkout_commit" \ @@ -208,6 +330,8 @@ if python3 "$REPO_ROOT/scripts/homebrew-bottle-runtime-evidence.py" capture \ fi mv "$target_receipt.public-source" "$target_receipt" mv "$dependency_provenance.public-source" "$dependency_provenance" +set_capture_arg --bottle-json "$bottle_json" +set_validate_arg --bottle-json "$bottle_json" jq -e --arg sha "$bottle_sha" --arg url "$bottle_url" \ --arg tap_name "$tap_name" --arg cache_basename "${installed_bottle##*/}" \ @@ -256,7 +380,14 @@ mv "$install_log.manifest" "$install_log" cp "$bottle_json" "$bottle_json.rebuild0" cp "$formula_info" "$formula_info.rebuild0" cp "$install_log" "$install_log.rebuild0" -jq '.hello.bottle.rebuild = 1' "$bottle_json" >"$bottle_json.next" +jq --arg formula_key "$formula_key" --arg tag "${arch}_kandelo" ' + .[$formula_key].bottle.rebuild = 1 | + .[$formula_key].bottle.tags[$tag].filename = + "hello-1.0.wasm32_kandelo.bottle.1.tar.gz" | + .[$formula_key].bottle.tags[$tag].local_filename = + "hello--1.0.wasm32_kandelo.bottle.1.tar.gz" +' \ + "$bottle_json" >"$bottle_json.next" mv "$bottle_json.next" "$bottle_json" jq '.formulae[0].bottle.stable.rebuild = 1' "$formula_info" >"$formula_info.next" mv "$formula_info.next" "$formula_info" @@ -304,7 +435,8 @@ mv "$formula_info.rebuild1" "$formula_info" for invalid_rebuild in 'true' '-1' '1.5'; do cp "$bottle_json" "$bottle_json.valid" - jq --argjson rebuild "$invalid_rebuild" '.hello.bottle.rebuild = $rebuild' \ + jq --arg formula_key "$formula_key" --argjson rebuild "$invalid_rebuild" \ + '.[$formula_key].bottle.rebuild = $rebuild' \ "$bottle_json.valid" >"$bottle_json" expect_capture_error "invalid canonical bottle rebuild $invalid_rebuild" \ "canonical bottle rebuild must be a non-negative integer" @@ -469,6 +601,10 @@ git -C "$tap" commit -q -m support-data support_data_tap_commit="$(git -C "$tap" rev-parse HEAD)" support_data_formula_sha="$(sha256sum "$tap/Formula/hello.rb" | awk '{print $1}')" +jq --arg formula_key "$formula_key" --arg tap_commit "$support_data_tap_commit" \ + '.[$formula_key].formula.tap_git_revision = $tap_commit' \ + "$bottle_json" >"$bottle_json.next" +mv "$bottle_json.next" "$bottle_json" jq --arg sha "$support_data_formula_sha" \ '.formulae[0].ruby_source_checksum.sha256 = $sha' \ "$formula_info" >"$formula_info.next" diff --git a/scripts/test-homebrew-formula-runtime-closure.sh b/scripts/test-homebrew-formula-runtime-closure.sh index 46e4b85f55..fd97c88c90 100755 --- a/scripts/test-homebrew-formula-runtime-closure.sh +++ b/scripts/test-homebrew-formula-runtime-closure.sh @@ -164,11 +164,12 @@ host_plan="$(KANDELO_HOMEBREW_RESOLVED_TAPS_FILE="$PRIMARY_RESOLVED_TAPS" \ ruby "$resolver" "$TAP_ROOT" kandelo-dev/tap-core host-plan --host-dependencies-json)" jq -e ' keys == ["build", "build_and_test", "formula", "full_name", "native_requirements", "runtime_and_test", "schema", "tap", "target_taps"] and - .schema == 4 and + .schema == 5 and .tap == "kandelo-dev/tap-core" and .formula == "host-plan" and .full_name == "kandelo-dev/tap-core/host-plan" and .target_taps == [{ + checkout_commit: "1111111111111111111111111111111111111111", tap_commit: "1111111111111111111111111111111111111111", tap_name: "kandelo-dev/tap-core", tap_repository: "kandelo-dev/homebrew-tap-core" @@ -193,10 +194,19 @@ campaign_host_plan="$( ruby "$resolver" "$TAP_ROOT" kandelo-dev/tap-core host-plan \ --host-dependencies-json )" -[ "$campaign_host_plan" = "$host_plan" ] || { - echo "test-homebrew-formula-runtime-closure.sh: campaign checkout changed public dependency provenance" >&2 - exit 1 -} +jq -e ' + .schema == 5 and + .target_taps == [{ + checkout_commit: "2222222222222222222222222222222222222222", + tap_commit: "1111111111111111111111111111111111111111", + tap_name: "kandelo-dev/tap-core", + tap_repository: "kandelo-dev/homebrew-tap-core" + }] +' <<<"$campaign_host_plan" >/dev/null +jq -e --argjson normal "$host_plan" ' + .target_taps[0].tap_commit == $normal.target_taps[0].tap_commit and + .target_taps[0].checkout_commit != $normal.target_taps[0].checkout_commit +' <<<"$campaign_host_plan" >/dev/null cat >"$TAP_ROOT/Formula/third-party-plan.rb" <<'RUBY' class ThirdPartyPlan < Formula @@ -328,14 +338,16 @@ jq -e ' cross_host="$(KANDELO_HOMEBREW_RESOLVED_TAPS_FILE="$RESOLVED_TAPS" \ ruby "$resolver" "$TAP_ROOT" acme/tools m4 --host-dependencies-json)" jq -e ' - .schema == 4 and + .schema == 5 and .target_taps == [ { + checkout_commit: "1111111111111111111111111111111111111111", tap_commit: "1111111111111111111111111111111111111111", tap_name: "acme/tools", tap_repository: "acme/homebrew-tools" }, { + checkout_commit: "2222222222222222222222222222222222222222", tap_commit: "2222222222222222222222222222222222222222", tap_name: "kandelo-dev/tap-core", tap_repository: "kandelo-dev/homebrew-tap-core" @@ -591,7 +603,7 @@ native_plan="$(KANDELO_HOMEBREW_RESOLVED_TAPS_FILE="$PRIMARY_RESOLVED_TAPS" \ ruby "$resolver" "$TAP_ROOT" kandelo-dev/tap-core native-requirements \ --host-dependencies-json)" jq -e ' - .schema == 4 and + .schema == 5 and .build == ["binaryen", "pkgconf", "wabt"] and .build_and_test == ["binaryen", "pkgconf", "wabt"] and .native_requirements == [ @@ -1788,7 +1800,7 @@ KANDELO_HOMEBREW_RESOLVED_TAPS_FILE="$PRIMARY_RESOLVED_TAPS" \ ruby "$resolver" "$TAP_ROOT" kandelo-dev/tap-core retired-bottle \ --host-dependencies-json >"$TMP_ROOT/retired-host-plan.json" jq -e ' - .schema == 4 and + .schema == 5 and .formula == "retired-bottle" and .full_name == "kandelo-dev/tap-core/retired-bottle" and .native_requirements == [] diff --git a/scripts/test-homebrew-main-shell-closure.sh b/scripts/test-homebrew-main-shell-closure.sh index 7860ff0dbc..4f18c8b9e8 100755 --- a/scripts/test-homebrew-main-shell-closure.sh +++ b/scripts/test-homebrew-main-shell-closure.sh @@ -363,7 +363,8 @@ SOURCE_ROOT_COUNT="$(jq -er '.packages | length' "$SOURCE_LOCK")" SOURCE_CLOSURE_COUNT="$(jq -er '.formula_closure | length' "$SOURCE_LOCK")" RUNTIME_FORMULA_COUNT="$(jq -er '.formula_order | length' "$RUNTIME_SUPPORT")" AUDITED_FORMULA_COUNT="$(jq -er ' - [.availability.reusable_public_abi42, + [(.availability.reusable_public_abi42 // + .availability.local_test_formulae), .availability.requires_rebuild, .availability.missing_metadata, .availability.can_be_deferred] | add | length @@ -2946,8 +2947,21 @@ jq ' bytes: 1 } ' "$LAZY_ARTIFACT_LOCK" >"$sealed_fixture_lock" + +# Review-pending composition accepts only a clean prepared tap that carries +# the exact non-promotable marker while retaining the source catalog commit as +# separate authority. Build that second clean commit explicitly so this test +# reaches the artifact-state gate instead of failing earlier at provenance. +printf '%s\n' \ + '{"schema":1,"provenance_kind":"local-test","promotable":false,"published":false}' \ + >"$tap/local-test-provenance.json" +git -C "$tap" add local-test-provenance.json +git -C "$tap" commit -qm "Homebrew: Mark local review fixture" +review_tap_worktree="$TMP_ROOT/review-tap-worktree" +git -C "$tap" worktree add --detach "$review_tap_worktree" HEAD >/dev/null expect_failure "--review-pending-artifact requires a pending artifact lock" \ - "$BUILDER" --lazy-shell --tap-root "$tap_worktree" \ + env KANDELO_HOMEBREW_TAP_SOURCE_COMMIT="$tap_sha" \ + "$BUILDER" --lazy-shell --tap-root "$review_tap_worktree" \ --work-dir "$TMP_ROOT/work-review-sealed-lazy-lock" \ --migration-lock "$lock" \ --lazy-artifact-lock "$sealed_fixture_lock" \ @@ -3174,20 +3188,45 @@ expect_failure \ "$MATERIALIZATION_POLICY" metadata="$TMP_ROOT/main-shell-metadata.json" -jq --slurpfile support "$RUNTIME_SUPPORT" ' +public_runtime_support="$TMP_ROOT/public-abi42-runtime-support.json" +jq ' + .activation.bootstrap_package.required_kernel_abi = 42 | + .availability = { + audited_catalog: { + checkout_commit: .availability.audited_catalog.checkout_commit, + metadata_sha256: ("0" * 64), + metadata_tap_commit: ("1" * 40), + kandelo_commit: ("2" * 40), + runtime_bottle_provenance_sha256: ("0" * 64), + kandelo_abi: 42, + release_tag: "bottles-abi-v42", + required_arch: "wasm32" + }, + reusable_public_abi42: .availability.local_test_formulae, + requires_rebuild: .availability.requires_rebuild, + missing_metadata: .availability.missing_metadata, + can_be_deferred: .availability.can_be_deferred + } +' "$RUNTIME_SUPPORT" >"$public_runtime_support" +jq --slurpfile support "$public_runtime_support" ' def dependencies: if . == "bash" then ["ncurses"] elif . == "ncurses" then ["libcxx"] elif . == "m4" then ["dash"] - elif . == "file-formula" then ["libmagic"] + elif . == "libmagic" then ["bzip2", "xz", "zlib"] + elif . == "file-formula" then ["bzip2", "libmagic", "xz", "zlib"] elif . == "diffutils" then ["coreutils", "ed"] elif . == "tar" then ["dash", "gzip"] elif . == "curl" then ["libcurl", "openssl", "zlib"] elif . == "git" then ["coreutils", "dash", "diffutils", "grep", "less", "libcurl", "openssl", "sed", "vim", "zlib"] elif . == "libcurl" then ["openssl", "zlib"] - elif . == "less" or . == "vim" then ["ncurses"] - elif . == "ruby" then ["zlib"] + elif . == "less" or . == "vim" then ["dash", "ncurses"] + elif . == "make" then ["dash"] + elif . == "wget" then ["openssl", "zlib"] + elif . == "zip" then ["unzip"] + elif . == "nano" or . == "nethack" then ["ncurses"] + elif . == "ruby" then ["libyaml", "zlib"] else [] end; . as $lock | @@ -3235,7 +3274,7 @@ jq --slurpfile support "$RUNTIME_SUPPORT" ' arch: "wasm32", bottle_tag: "wasm32_kandelo", status: "success", - kandelo_abi: 42, + kandelo_abi: $audit.kandelo_abi, bytes: 1, sha256: ("a" * 64), cache_key_sha: ("a" * 64), @@ -3267,7 +3306,7 @@ jq --slurpfile support "$RUNTIME_SUPPORT" ' baseline_provenance_sha="$(node "$CHECKER" \ --print-runtime-bottle-provenance-sha256 \ - "$metadata" "$RUNTIME_SUPPORT")" + "$metadata" "$public_runtime_support")" checker_with_metadata() { local lock_path="$1" @@ -3282,7 +3321,7 @@ checker_with_metadata() { .availability.audited_catalog.runtime_bottle_provenance_sha256 = $provenance_sha ' \ - "$RUNTIME_SUPPORT" >"$support_path" + "$public_runtime_support" >"$support_path" node "$CHECKER" "$BREWFILE" "$lock_path" "$metadata_path" "$support_path" } @@ -3312,7 +3351,7 @@ jq --arg metadata_sha "$provenance_drift_metadata_sha" \ .availability.audited_catalog.metadata_sha256 = $metadata_sha | .availability.audited_catalog.runtime_bottle_provenance_sha256 = $provenance_sha - ' "$RUNTIME_SUPPORT" >"$provenance_drift_support" + ' "$public_runtime_support" >"$provenance_drift_support" expect_failure "runtime-support bottle provenance digest differs from the reviewed cohort" \ node "$CHECKER" "$BREWFILE" "$SOURCE_LOCK" \ "$provenance_drift" "$provenance_drift_support" @@ -3327,7 +3366,7 @@ jq --arg metadata_sha "$aggregate_drift_metadata_sha" \ .availability.audited_catalog.metadata_sha256 = $metadata_sha | .availability.audited_catalog.runtime_bottle_provenance_sha256 = $provenance_sha - ' "$RUNTIME_SUPPORT" >"$aggregate_drift_support" + ' "$public_runtime_support" >"$aggregate_drift_support" expect_failure "tap metadata differs from the exact audited ABI-42 catalog" \ node "$CHECKER" "$BREWFILE" "$SOURCE_LOCK" \ "$aggregate_drift" "$aggregate_drift_support" @@ -3336,7 +3375,7 @@ unknown_provenance_support="$TMP_ROOT/runtime-unknown-provenance-support.json" jq ' .availability.reusable_public_abi42[0] = "kandelo-dev/tap-core/unknown" -' "$RUNTIME_SUPPORT" >"$unknown_provenance_support" +' "$public_runtime_support" >"$unknown_provenance_support" expect_failure "Formula unknown has no admitted package metadata" \ node "$CHECKER" --print-runtime-bottle-provenance-sha256 \ "$metadata" "$unknown_provenance_support" @@ -3345,7 +3384,7 @@ duplicate_provenance_support="$TMP_ROOT/runtime-duplicate-provenance-support.jso jq ' .availability.reusable_public_abi42 += [.availability.reusable_public_abi42[0]] -' "$RUNTIME_SUPPORT" >"$duplicate_provenance_support" +' "$public_runtime_support" >"$duplicate_provenance_support" expect_failure "runtime-support provenance cohort contains duplicate" \ node "$CHECKER" --print-runtime-bottle-provenance-sha256 \ "$metadata" "$duplicate_provenance_support" @@ -3357,7 +3396,7 @@ jq ' ' "$metadata" >"$duplicate_runtime_bottle" expect_failure "Formula gawk has 2 wasm32 bottle identities, expected one" \ node "$CHECKER" --print-runtime-bottle-provenance-sha256 \ - "$duplicate_runtime_bottle" "$RUNTIME_SUPPORT" + "$duplicate_runtime_bottle" "$public_runtime_support" jq 'del(.formula_closure)' "$SOURCE_LOCK" >"$lock" expect_failure "packages/formula_closure/substitutions must be arrays" \ @@ -3418,7 +3457,7 @@ jq ' "dependencies":[] }] ' "$metadata" >"$TMP_ROOT/wrong-closure.json" -expect_failure "tap metadata dependency closure does not match reviewed formula_closure" \ +expect_failure "tap metadata dependency-first order does not match reviewed formula_closure" \ checker_with_metadata "$SOURCE_LOCK" "$TMP_ROOT/wrong-closure.json" jq '(.packages[] | select(.name == "libcxx") | .dependencies) = diff --git a/scripts/test-homebrew-oci-layout.sh b/scripts/test-homebrew-oci-layout.sh index 88c26e0910..cfc7fb405a 100755 --- a/scripts/test-homebrew-oci-layout.sh +++ b/scripts/test-homebrew-oci-layout.sh @@ -129,9 +129,10 @@ RUBY sha="$(sha256_file "$bottle")" jq -nS --arg arch "$arch" --arg sha "$sha" \ --argjson rebuild "$rebuild" \ + --arg formula_key "$(printf '%s' "$tap_name" | tr '[:upper:]' '[:lower:]')/hello" \ --arg formula_path "Library/Taps/$(printf '%s' "$tap_owner" | tr '[:upper:]' '[:lower:]')/homebrew-$(printf '%s' "$tap_short_name" | tr '[:upper:]' '[:lower:]')/Formula/hello.rb" \ --arg root_url "$root_url" '{ - hello: { + ($formula_key): { formula: { name: "hello", path: $formula_path, diff --git a/scripts/test-homebrew-patched-launcher-batch.sh b/scripts/test-homebrew-patched-launcher-batch.sh new file mode 100755 index 0000000000..71cc2ec64c --- /dev/null +++ b/scripts/test-homebrew-patched-launcher-batch.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TEST_ROOT="$(mktemp -d /tmp/kandelo-launcher-batch.XXXXXX)" +BUILD_USER="kandelo-hb-batch-$$" +BUILD_USER="${BUILD_USER:0:31}" +BUILD_USER_CREATED=false + +# shellcheck source=/dev/null +. "$REPO_ROOT/scripts/homebrew-patched-launcher.sh" + +cleanup() { + local original_status="$?" + trap - EXIT + homebrew_patched_launcher_cleanup >/dev/null 2>&1 || true + if [ "$BUILD_USER_CREATED" = true ] && /usr/bin/id "$BUILD_USER" >/dev/null 2>&1; then + /usr/bin/sudo -n -- /usr/bin/pkill -KILL -u "$(/usr/bin/id -u "$BUILD_USER")" \ + >/dev/null 2>&1 || true + /usr/bin/sudo -n -- /usr/sbin/userdel "$BUILD_USER" >/dev/null 2>&1 || true + fi + /usr/bin/sudo -n -- /usr/bin/rm -rf -- "$TEST_ROOT" >/dev/null 2>&1 || true + exit "$original_status" +} +trap cleanup EXIT + +fail() { + echo "test-homebrew-patched-launcher-batch.sh: $*" >&2 + exit 1 +} + +[ "$(uname -s)" = Linux ] || fail "requires Linux" +for required in /usr/bin/id /usr/bin/ln /usr/bin/pkill /usr/bin/readlink \ + /usr/bin/stat /usr/bin/sudo /usr/sbin/useradd /usr/sbin/userdel; do + [ -x "$required" ] || fail "missing required host tool: $required" +done +/usr/bin/sudo -n true >/dev/null 2>&1 || fail "requires passwordless sudo" +chmod 0711 "$TEST_ROOT" + +SOURCE_REPO="$TEST_ROOT/homebrew-source" +PREFIX="$TEST_ROOT/prefix" +FIRST_WORK="$TEST_ROOT/first-work" +SECOND_WORK="$TEST_ROOT/second-work" +CACHE="$TEST_ROOT/cache" +TEMP="$TEST_ROOT/temp" +PATCH_FILE="$TEST_ROOT/platform.patch" +DEPENDENCY_PLAN="$TEST_ROOT/dependency-plan.json" +TIER2_ATTESTATION="$TEST_ROOT/tier2-attestation.json" +POISON_SUDO="$TEST_ROOT/poison-sudo" +POISON_MARKER="$TEST_ROOT/poison-sudo-ran" +mkdir -p "$SOURCE_REPO/bin" "$PREFIX/bin" "$FIRST_WORK" "$SECOND_WORK" \ + "$CACHE" "$TEMP" +export BATCH_PREFIX="$PREFIX" + +cat >"$SOURCE_REPO/bin/brew" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +case "${1:-}" in + --repository) + resolved="$(/usr/bin/readlink -f -- "$0")" + printf '%s\n' "${resolved%/bin/brew}" + ;; + --prefix) + if [ "$#" -eq 1 ]; then + printf '%s\n' "${BATCH_PREFIX:?}" + else + printf '%s/opt/%s\n' "${BATCH_PREFIX:?}" "$2" + fi + ;; + --cellar) printf '%s/Cellar\n' "${BATCH_PREFIX:?}" ;; + *) exit 2 ;; +esac +EOF +chmod 0755 "$SOURCE_REPO/bin/brew" +printf 'unpatched\n' >"$SOURCE_REPO/marker.txt" +printf '{"schema":1,"dependencies":[]}\n' >"$DEPENDENCY_PLAN" +printf '{"schema":3,"formula":"batch"}\n' >"$TIER2_ATTESTATION" +chmod 0600 "$DEPENDENCY_PLAN" "$TIER2_ATTESTATION" +cat >"$POISON_SUDO" <"$POISON_MARKER" +exit 97 +EOF +chmod 0755 "$POISON_SUDO" +git -C "$SOURCE_REPO" init -q +git -C "$SOURCE_REPO" config user.name 'Kandelo Test' +git -C "$SOURCE_REPO" config user.email kandelo-test@example.invalid +git -C "$SOURCE_REPO" add . +git -C "$SOURCE_REPO" commit -q -m fixture +cat >"$PATCH_FILE" <<'EOF' +diff --git a/marker.txt b/marker.txt +index 5742de9..a95d2c7 100644 +--- a/marker.txt ++++ b/marker.txt +@@ -1 +1 @@ +-unpatched ++patched +EOF +ln -s "$SOURCE_REPO/bin/brew" "$PREFIX/bin/brew" + +/usr/bin/sudo -n -- /usr/sbin/useradd --system --user-group --no-create-home \ + --home-dir /nonexistent --shell /usr/sbin/nologin "$BUILD_USER" +BUILD_USER_CREATED=true +BUILD_GID="$(/usr/bin/id -g "$BUILD_USER")" +export KANDELO_HOMEBREW_BUILD_USER="$BUILD_USER" +export KANDELO_HOMEBREW_SUDO_BIN=/usr/bin/sudo +export HOMEBREW_CACHE="$CACHE" +export HOMEBREW_TEMP="$TEMP" +export HOMEBREW_GUEST_PREFIX="$PREFIX" + +# A freshly activated prefix is still owned by the trusted invoker and has not +# run Brew yet, so its lock directory is truthfully absent. The handoff must be +# a no-op for that exact state and must not manufacture unrelated prefix paths. +[ ! -e "$PREFIX/var" ] && [ ! -L "$PREFIX/var" ] || + fail "fresh prefix fixture unexpectedly contains Homebrew state" +homebrew_patched_launcher_restore_invoker_bootstrap_roots \ + "$BUILD_USER" "$PREFIX" +[ ! -e "$PREFIX/var" ] && [ ! -L "$PREFIX/var" ] || + fail "fresh bootstrap handoff created unrelated prefix state" + +# Once a Formula identity owns the reused prefix, a missing exact lock root is +# ambiguous and must reject instead of silently treating it as a fresh prefix. +/usr/bin/sudo -n -- /usr/bin/chown "$BUILD_USER:$BUILD_GID" "$PREFIX" +if homebrew_patched_launcher_restore_invoker_bootstrap_roots \ + "$BUILD_USER" "$PREFIX" >"$TEST_ROOT/missing-lock.out" \ + 2>"$TEST_ROOT/missing-lock.err"; then + fail "reused build-owned prefix accepted a missing lock root" +fi +grep -F "reused prefix requires its exact Homebrew lock root" \ + "$TEST_ROOT/missing-lock.err" >/dev/null || + fail "reused missing-lock rejection was not explicit" +/usr/bin/sudo -n -- /usr/bin/install -d -o "$BUILD_USER" -g "$BUILD_GID" \ + -m 0755 "$PREFIX/var" "$PREFIX/var/homebrew" \ + "$PREFIX/var/homebrew/locks" +/usr/bin/sudo -n -- /usr/bin/touch \ + "$PREFIX/var/homebrew/locks/attacker-owned" +if homebrew_patched_launcher_restore_invoker_bootstrap_roots \ + "$BUILD_USER" "$PREFIX" >"$TEST_ROOT/attacker-owner.out" \ + 2>"$TEST_ROOT/attacker-owner.err"; then + fail "reused prefix accepted an unexpected lock owner" +fi +grep -F "bootstrap root has an unexpected owner" \ + "$TEST_ROOT/attacker-owner.err" >/dev/null || + fail "unexpected lock owner rejection was not explicit" +/usr/bin/sudo -n -- /usr/bin/rm \ + "$PREFIX/var/homebrew/locks/attacker-owned" + +# Model the exact state left after one isolated Formula lifecycle: the +# canonical seed is protected and the insertion directory remains sticky and +# writable only to the continuing Formula build group. +/usr/bin/sudo -n -- /usr/bin/install -d -o root -g "$BUILD_GID" -m 1775 \ + "$PREFIX" "$PREFIX/bin" +/usr/bin/sudo -n -- /usr/bin/chown -h root:root "$PREFIX/bin/brew" +[ "$(/usr/bin/stat -c '%u:%g:%a' "$PREFIX/bin")" = "0:$BUILD_GID:1775" ] && + [ "$(/usr/bin/stat -c '%u:%g' "$PREFIX/bin/brew")" = "0:0" ] || + fail "fixture did not model a completed Formula launcher lifecycle" + +homebrew_patched_launcher_select_host_git +homebrew_patched_launcher_prepare \ + "$PREFIX/bin/brew" "$PATCH_FILE" "$FIRST_WORK" +FIRST_LAUNCHER="$HOMEBREW_PATCHED_LAUNCHER" +[ -L "$FIRST_LAUNCHER" ] && + [ "${FIRST_LAUNCHER%/*}" = "$PREFIX/bin" ] && + [ "$(/usr/bin/stat -c '%u:%g' "$FIRST_LAUNCHER")" = 0:0 ] && + [ "$(/usr/bin/readlink "$FIRST_LAUNCHER")" = \ + "$HOMEBREW_PATCHED_OVERLAY/bin/brew" ] || + fail "second Formula lifecycle did not create its exact patched launcher" +printf 'must remain unchanged\n' >"$TEST_ROOT/control-symlink-target" +/usr/bin/sudo -n -H -u "$BUILD_USER" -- /usr/bin/ln -s -- \ + "$TEST_ROOT/control-symlink-target" \ + "$PREFIX/.kandelo-publisher-build-dependencies.json" +if homebrew_patched_launcher_stage_dependency_plan \ + "$DEPENDENCY_PLAN" >/dev/null 2>&1; then + fail "batch control staging followed a pre-existing destination symlink" +fi +[ "$(cat "$TEST_ROOT/control-symlink-target")" = "must remain unchanged" ] || + fail "batch control staging changed a symlink target" +/usr/bin/sudo -n -- /usr/bin/rm -f -- \ + "$PREFIX/.kandelo-publisher-build-dependencies.json" +homebrew_patched_launcher_stage_dependency_plan "$DEPENDENCY_PLAN" +homebrew_patched_launcher_stage_tier2_attestation "$TIER2_ATTESTATION" +for control_file in \ + "$PREFIX/.kandelo-publisher-build-dependencies.json" \ + "$PREFIX/.kandelo-publisher-tier2-attestation.json"; do + [ -f "$control_file" ] && [ ! -L "$control_file" ] && + [ "$(/usr/bin/stat -c '%u:%g:%a:%h' "$control_file")" = "0:0:444:1" ] || + fail "batch control file was not atomically root-owned and immutable" +done +/usr/bin/cmp "$DEPENDENCY_PLAN" \ + "$PREFIX/.kandelo-publisher-build-dependencies.json" >/dev/null && + /usr/bin/cmp "$TIER2_ATTESTATION" \ + "$PREFIX/.kandelo-publisher-tier2-attestation.json" >/dev/null || + fail "batch control staging changed exact source bytes" +if homebrew_patched_launcher_stage_control_file invalid "$DEPENDENCY_PLAN" \ + ../arbitrary-prefix-write.json 65536 "invalid control" >/dev/null 2>&1; then + fail "batch control staging accepted an arbitrary prefix path" +fi +[ ! -e "$TEST_ROOT/arbitrary-prefix-write.json" ] || + fail "batch control staging escaped the exact prefix" +homebrew_patched_launcher_remove_tier2_attestation +homebrew_patched_launcher_remove_dependency_plan +[ ! -e "$PREFIX/.kandelo-publisher-build-dependencies.json" ] && + [ ! -e "$PREFIX/.kandelo-publisher-tier2-attestation.json" ] || + fail "batch control cleanup left staged prefix files" +VALID_SUDO="$HOMEBREW_PATCHED_SUDO_BIN" +HOMEBREW_PATCHED_SUDO_BIN="$POISON_SUDO" +if homebrew_patched_launcher_stage_dependency_plan \ + "$DEPENDENCY_PLAN" >/dev/null 2>&1; then + fail "batch control staging accepted caller-selected sudo" +fi +[ ! -e "$POISON_MARKER" ] || + fail "batch control staging executed caller-selected sudo" +HOMEBREW_PATCHED_SUDO_BIN="$VALID_SUDO" +homebrew_patched_launcher_cleanup +[ ! -e "$FIRST_LAUNCHER" ] && + [ "$(/usr/bin/stat -c '%u:%g:%a' "$PREFIX/bin")" = "0:$BUILD_GID:1775" ] && + [ "$(/usr/bin/stat -c '%u:%g' "$PREFIX/bin/brew")" = "0:0" ] || + fail "second Formula cleanup changed the protected prefix or canonical seed" + +# The reusable workflow gives build and verification separate fresh runners. +# A batch campaign reuses one prefix, so the next trusted workflow invocation +# must regain only its declared Homebrew bootstrap lock/cache/temp roots after +# the isolated Formula identity owned them. Keep all surrounding prefix state +# build-owned to prove this is not a broad ownership reset. +/usr/bin/sudo -n -- /usr/bin/install -d -o "$BUILD_USER" -g "$BUILD_GID" \ + -m 0755 "$PREFIX/var" "$PREFIX/var/homebrew" \ + "$PREFIX/var/homebrew/locks" "$PREFIX/Cellar" +/usr/bin/sudo -n -H -u "$BUILD_USER" -- /usr/bin/touch \ + "$PREFIX/var/homebrew/locks/vendor-install-ruby" +: >"$CACHE/download" +: >"$TEMP/work" +/usr/bin/sudo -n -- /usr/bin/chown -R "$BUILD_USER:$BUILD_GID" \ + "$PREFIX/var" "$PREFIX/Cellar" "$CACHE" "$TEMP" +/usr/bin/sudo -n -H -u "$BUILD_USER" -- /usr/bin/install -d -m 0700 \ + "$TEMP/private-worker-entry" +/usr/bin/sudo -n -H -u "$BUILD_USER" -- /usr/bin/touch \ + "$TEMP/private-worker-entry/state" +if /usr/bin/find "$TEMP" -xdev -print >/dev/null 2>&1; then + fail "private Formula temp fixture was unexpectedly traversable by invoker" +fi +homebrew_patched_launcher_restore_invoker_bootstrap_roots \ + "$BUILD_USER" "$PREFIX" +INVOKER_UID="$(/usr/bin/id -u)" +INVOKER_GID="$(/usr/bin/id -g)" +for restored in "$PREFIX/var/homebrew/locks" "$CACHE" "$TEMP"; do + [ -z "$(/usr/bin/find "$restored" -xdev \ + \( ! -uid "$INVOKER_UID" -o ! -gid "$INVOKER_GID" \) -print -quit)" ] || + fail "batch handoff did not restore exact invoker ownership: $restored" +done +[ "$(/usr/bin/stat -c '%u:%g' "$PREFIX/var/homebrew")" = \ + "$(/usr/bin/id -u "$BUILD_USER"):$BUILD_GID" ] && + [ "$(/usr/bin/stat -c '%u:%g' "$PREFIX/Cellar")" = \ + "$(/usr/bin/id -u "$BUILD_USER"):$BUILD_GID" ] || + fail "batch handoff changed Homebrew state outside bootstrap roots" +: >"$PREFIX/var/homebrew/locks/verifier-bootstrap" +: >"$CACHE/verifier-download" +: >"$TEMP/verifier-work" +[ -f "$TEMP/private-worker-entry/state" ] || + fail "batch handoff lost private Formula temporary state" + +export KANDELO_HOMEBREW_SUDO_BIN="$POISON_SUDO" +if homebrew_patched_launcher_restore_invoker_bootstrap_roots \ + "$BUILD_USER" "$PREFIX" >/dev/null 2>&1; then + fail "batch handoff accepted caller-selected sudo" +fi +[ ! -e "$POISON_MARKER" ] || fail "batch handoff executed caller-selected sudo" +if homebrew_patched_launcher_prepare \ + "$PREFIX/bin/brew" "$PATCH_FILE" "$SECOND_WORK" >/dev/null 2>&1; then + fail "sealed launcher preparation accepted caller-selected sudo" +fi +[ ! -e "$POISON_MARKER" ] || fail "sealed launcher preparation executed caller-selected sudo" +homebrew_patched_launcher_cleanup + +echo "test-homebrew-patched-launcher-batch.sh: ok" diff --git a/scripts/test-homebrew-patched-launcher.sh b/scripts/test-homebrew-patched-launcher.sh index 7b246d27ad..104665dfa1 100755 --- a/scripts/test-homebrew-patched-launcher.sh +++ b/scripts/test-homebrew-patched-launcher.sh @@ -1061,6 +1061,112 @@ if homebrew_patched_launcher_snapshot_target_cellar_layout >/dev/null 2>&1; then fail "launcher accepted a same-name symlinked target keg" fi rm -rf "$prefix/Cellar" + +retire_prefix="$TMPDIR/retire-prefix" +retire_brew="$TMPDIR/retire-brew" +retire_bottle="$TMPDIR/hello--1.0.wasm32_kandelo.bottle.tar.gz" +retire_json="$TMPDIR/hello--1.0.bottle.json" +retire_marker="$TMPDIR/retire-uninstall" +retire_receipt="$retire_prefix/Cellar/hello/1.0/INSTALL_RECEIPT.json" +mkdir -p "$retire_prefix/Cellar/dependency/2.0" \ + "$retire_prefix/Cellar/hello/1.0" "$retire_prefix/opt" +ln -s ../Cellar/hello/1.0 "$retire_prefix/opt/hello" +printf 'canonical bottle\n' >"$retire_bottle" +printf '{"canonical":true}\n' >"$retire_json" +printf '{"poured_from_bottle":false}\n' >"$retire_receipt" +cp "$retire_bottle" "$retire_bottle.before" +cp "$retire_json" "$retire_json.before" +cat >"$retire_brew" <&2; exit 2 ;; + noisy-absent) printf 'unexpected warning\n' >&2; exit 1 ;; + esac + if [ -d "$retire_prefix/Cellar/hello/1.0" ]; then + printf 'hello 1.0\n' + else + exit 1 + fi + ;; + 'uses --installed --formula kandelo-dev/tap-core/hello') + printf '%s' "\${RETIRE_DEPENDENTS:-}" + ;; + 'uninstall --formula kandelo-dev/tap-core/hello') + rm -rf "$retire_prefix/Cellar/hello" "$retire_prefix/opt/hello" + : >"$retire_marker" + ;; + *) exit 97 ;; +esac +EOF +chmod 0755 "$retire_brew" +( + HOMEBREW_PATCHED_PREFIX="$retire_prefix" + HOMEBREW_PATCHED_BREW_BIN="$retire_brew" + HOMEBREW_PATCHED_BUILD_USER=fixture-build-user + [ "$(homebrew_patched_launcher_resolve_installed_formula_keg \ + "$retire_brew" kandelo-dev/tap-core/hello hello)" = \ + "$retire_prefix/Cellar/hello/1.0" ] || + fail "logical Formula prefix did not resolve to its canonical installed keg" + RETIRE_PREFIX_OVERRIDE="$retire_prefix/opt/dependency" + export RETIRE_PREFIX_OVERRIDE + if homebrew_patched_launcher_resolve_installed_formula_keg \ + "$retire_brew" kandelo-dev/tap-core/hello hello \ + >/dev/null 2>&1; then + fail "Formula keg resolution accepted a caller-poisoned logical prefix" + fi + unset RETIRE_PREFIX_OVERRIDE + RETIRE_LIST_OVERRIDE=absent + export RETIRE_LIST_OVERRIDE + homebrew_patched_launcher_require_formula_absent \ + "$retire_brew" kandelo-dev/tap-core/hello + RETIRE_LIST_OVERRIDE=broken + if homebrew_patched_launcher_require_formula_absent \ + "$retire_brew" kandelo-dev/tap-core/hello \ + >/dev/null 2>&1; then + fail "Formula absence accepted a failed Brew query" + fi + RETIRE_LIST_OVERRIDE=noisy-absent + if homebrew_patched_launcher_require_formula_absent \ + "$retire_brew" kandelo-dev/tap-core/hello \ + >/dev/null 2>&1; then + fail "Formula absence accepted diagnostic output" + fi + unset RETIRE_LIST_OVERRIDE + if homebrew_patched_launcher_retire_source_target \ + "$retire_brew" kandelo-dev/tap-core/not-hello hello 1.0 \ + "$retire_bottle" "$retire_json" "$retire_receipt" \ + >/dev/null 2>&1; then + fail "source target retirement accepted a different Formula identity" + fi + export RETIRE_DEPENDENTS='kandelo-dev/tap-core/consumer' + if homebrew_patched_launcher_retire_source_target \ + "$retire_brew" kandelo-dev/tap-core/hello hello 1.0 \ + "$retire_bottle" "$retire_json" "$retire_receipt" \ + >/dev/null 2>&1; then + fail "source target retirement ignored an installed dependent" + fi + [ -d "$retire_prefix/Cellar/hello/1.0" ] && [ ! -e "$retire_marker" ] || + fail "rejected source target retirement changed the installed target" + unset RETIRE_DEPENDENTS + homebrew_patched_launcher_retire_source_target \ + "$retire_brew" kandelo-dev/tap-core/hello hello 1.0 \ + "$retire_bottle" "$retire_json" "$retire_receipt" +) +[ ! -e "$retire_prefix/Cellar/hello" ] && + [ ! -e "$retire_prefix/opt/hello" ] && + [ -d "$retire_prefix/Cellar/dependency/2.0" ] && + [ -e "$retire_marker" ] || + fail "source target retirement did not preserve only installed dependencies" +cmp "$retire_bottle.before" "$retire_bottle" >/dev/null && + cmp "$retire_json.before" "$retire_json" >/dev/null || + fail "source target retirement changed canonical bottle artifacts" [ "$($HOMEBREW_PATCHED_BREW_BIN --prefix cmake)" = "$prefix/opt/cmake" ] || fail "launcher moved a core dependency prefix" [ "$($HOMEBREW_PATCHED_BREW_BIN --repository)" = "$HOMEBREW_PATCHED_OVERLAY" ] || @@ -1496,6 +1602,8 @@ homebrew_patched_launcher_prepare_native_prefix \ native_base_mode="$(stat -c %a "$native_base" 2>/dev/null || stat -f %Lp "$native_base")" [ "$native_base_mode" = 711 ] || fail "native Homebrew changed its caller-owned parent mode: $native_base_mode" +[ -d "$native_prefix/Cellar" ] && [ ! -L "$native_prefix/Cellar" ] || + fail "prepared native Homebrew omitted its empty Cellar" for native_root in "$native_prefix" "$native_cache" "$native_temp" "$native_config" \ "$native_home"; do [ "$(stat -c %a "$native_root" 2>/dev/null || stat -f %Lp "$native_root")" = 700 ] || @@ -2217,7 +2325,7 @@ EOF printf 'target work\n' >"$isolated_work/target-work-marker" printf 'external target untouched\n' >"$external_cellar/sentinel" printf 'external target untouched\n' >"$external_opt/sentinel" - dependency_plan_json='{"build":["cmake"],"build_and_test":["cmake","ninja"],"formula":"hello","full_name":"kandelo-dev/tap-core/hello","native_requirements":[],"runtime_and_test":["ninja"],"schema":4,"tap":"kandelo-dev/tap-core","target_taps":[{"tap_commit":"1111111111111111111111111111111111111111","tap_name":"kandelo-dev/tap-core","tap_repository":"kandelo-dev/homebrew-tap-core"}]}' + dependency_plan_json='{"build":["cmake"],"build_and_test":["cmake","ninja"],"formula":"hello","full_name":"kandelo-dev/tap-core/hello","native_requirements":[],"runtime_and_test":["ninja"],"schema":5,"tap":"kandelo-dev/tap-core","target_taps":[{"checkout_commit":"1111111111111111111111111111111111111111","tap_commit":"1111111111111111111111111111111111111111","tap_name":"kandelo-dev/tap-core","tap_repository":"kandelo-dev/homebrew-tap-core"}]}' printf '%s\n' "$dependency_plan_json" >"$isolated_dependency_plan" chmod 0600 "$isolated_dependency_plan" tier2_attestation_json='{"arch":"wasm32","formula":"hello","formula_sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","full_name":"kandelo-dev/tap-core/hello","schema":3,"support_runtime_sha256":"1111111111111111111111111111111111111111111111111111111111111111","support_sha256":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","tap":"kandelo-dev/tap-core","tap_recipe":{"dependencies":[],"entrypoint":"build.sh","file_count":1,"manifest_sha256":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","pkg_version":"1.0_2","resources":[],"script_env_keys":[],"source_sha256":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd","source_url":"https://example.test/hello-1.0.tar.gz","total_bytes":1,"version":"1.0"},"tier2_bridge":null}' @@ -3801,7 +3909,7 @@ EOF chmod 0600 "$schema2_config/homebrew/trust.json" \ "$schema2_config/homebrew/trust.json.lock" printf '%s\n' \ - '{"build":["cmake"],"build_and_test":["cmake"],"formula":"hello","full_name":"kandelo-dev/tap-core/hello","native_requirements":[],"runtime_and_test":[],"schema":4,"tap":"kandelo-dev/tap-core","target_taps":[{"tap_commit":"1111111111111111111111111111111111111111","tap_name":"kandelo-dev/tap-core","tap_repository":"kandelo-dev/homebrew-tap-core"}]}' \ + '{"build":["cmake"],"build_and_test":["cmake"],"formula":"hello","full_name":"kandelo-dev/tap-core/hello","native_requirements":[],"runtime_and_test":[],"schema":5,"tap":"kandelo-dev/tap-core","target_taps":[{"checkout_commit":"1111111111111111111111111111111111111111","tap_commit":"1111111111111111111111111111111111111111","tap_name":"kandelo-dev/tap-core","tap_repository":"kandelo-dev/homebrew-tap-core"}]}' \ >"$schema2_dependency_plan" printf '%s\n' "$active_tier2_attestation_json" >"$schema2_attestation" chmod 0600 "$schema2_dependency_plan" "$schema2_attestation" diff --git a/scripts/test-homebrew-publish-workflow.sh b/scripts/test-homebrew-publish-workflow.sh index 02533630f6..5965b67cb7 100755 --- a/scripts/test-homebrew-publish-workflow.sh +++ b/scripts/test-homebrew-publish-workflow.sh @@ -1317,7 +1317,7 @@ RUBY --arch wasm32 \ --release-tag bottles-abi-v18 \ --bottle-json "$canonical_bottle_json" \ - --expected-sha256 "$(jq -er '.hello.bottle.tags.wasm32_kandelo.sha256' "$canonical_bottle_json")" \ + --expected-sha256 "$(jq -er '.["acme/tools/hello"].bottle.tags.wasm32_kandelo.sha256' "$canonical_bottle_json")" \ --expected-root-url https://ghcr.io/v2/acme/homebrew-tools \ --expected-cellar any_skip_relocation >/dev/null grep -F 'root_url "https://ghcr.io/v2/acme/homebrew-tools"' \ @@ -1491,20 +1491,20 @@ assert_build_handoff_is_minimal_and_validated() { fail "validated handoff env lost dependency provenance" ) jq -e --arg sha256 "$(jq -r '.bottle.sha256' "$handoff/manifest.json")" ' - keys == ["hello"] and - (.hello | keys == ["bottle", "formula"]) and - (.hello.formula | keys == ["name", "path", "pkg_version"]) and - .hello.formula == { + keys == ["kandelo-dev/tap-core/hello"] and + (.["kandelo-dev/tap-core/hello"] | keys == ["bottle", "formula"]) and + (.["kandelo-dev/tap-core/hello"].formula | keys == ["name", "path", "pkg_version"]) and + .["kandelo-dev/tap-core/hello"].formula == { name: "hello", path: "Library/Taps/kandelo-dev/homebrew-tap-core/Formula/hello.rb", pkg_version: "2.12.1" } and - (.hello.bottle | keys == ["cellar", "rebuild", "root_url", "tags"]) and - .hello.bottle.root_url == "https://ghcr.io/v2/kandelo-dev/homebrew-tap-core" and - .hello.bottle.cellar == "any_skip_relocation" and - .hello.bottle.rebuild == 0 and - (.hello.bottle.tags | keys == ["wasm32_kandelo"]) and - .hello.bottle.tags.wasm32_kandelo == { + (.["kandelo-dev/tap-core/hello"].bottle | keys == ["cellar", "rebuild", "root_url", "tags"]) and + .["kandelo-dev/tap-core/hello"].bottle.root_url == "https://ghcr.io/v2/kandelo-dev/homebrew-tap-core" and + .["kandelo-dev/tap-core/hello"].bottle.cellar == "any_skip_relocation" and + .["kandelo-dev/tap-core/hello"].bottle.rebuild == 0 and + (.["kandelo-dev/tap-core/hello"].bottle.tags | keys == ["wasm32_kandelo"]) and + .["kandelo-dev/tap-core/hello"].bottle.tags.wasm32_kandelo == { sha256: $sha256 } ' "$canonical_json" >/dev/null || @@ -4647,7 +4647,7 @@ EOF bottle_sha="$(sha256sum "$bottle" | awk '{print $1}')" bottle_bytes="$(wc -c <"$bottle" | tr -d '[:space:]')" jq -nS --arg sha256 "$bottle_sha" '{ - hello: { + "kandelo-dev/tap-core/hello": { formula: {name: "hello", path: "Formula/hello.rb", pkg_version: "1.0"}, bottle: { root_url: "https://example.invalid", diff --git a/scripts/test-homebrew-publisher-overlay-patch.sh b/scripts/test-homebrew-publisher-overlay-patch.sh index e186d34ef8..4a69e56056 100755 --- a/scripts/test-homebrew-publisher-overlay-patch.sh +++ b/scripts/test-homebrew-publisher-overlay-patch.sh @@ -4,14 +4,19 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" PATCH_FILE="$ROOT/homebrew/patches/0002-support-isolated-publisher.patch" TMPDIR="$(mktemp -d)" -trap 'rm -rf "$TMPDIR"' EXIT +cleanup() { + chmod -R u+w "$TMPDIR" 2>/dev/null || true + rm -rf "$TMPDIR" +} +trap cleanup EXIT mkdir -p "$TMPDIR/Library/Homebrew/dev-cmd" \ "$TMPDIR/Library/Homebrew/extend/os/linux" \ "$TMPDIR/Library/Homebrew/extend/os/linux/sandbox" \ "$TMPDIR/Library/Homebrew/utils/github" for fixture in abstract_command.rb global.rb build_options.rb keg.rb extend/ENV.rb \ - exceptions.rb system_command.rb utils/bottles.rb utils/popen.rb utils/github/actions.rb \ + api.rb commands.rb exceptions.rb settings.rb system_command.rb utils/bottles.rb \ + utils/output.rb utils/popen.rb utils/github/actions.rb \ extend/os/linux/sandbox/bubblewrap.rb extend/os/linux/sandbox/landlock.rb; do : >"$TMPDIR/Library/Homebrew/$fixture" done @@ -41,6 +46,40 @@ class Formula end end RUBY +cat >"$TMPDIR/Library/Homebrew/tap.rb" <<'RUBY' +# typed: strict +# frozen_string_literal: true + +require "api" +require "commands" +require "settings" +require "utils/output" + +class Tap + attr_reader :name, :path + + def initialize(name, path, git_repository) + @name = name + @path = path + @git_repository = git_repository + end + + def git_repository = @git_repository + + # Git HEAD for this {Tap}. + # + # @api public + sig { returns(T.nilable(String)) } + def git_head + raise TapUnavailableError, name unless installed? + + @git_head ||= T.let(git_repository.head_ref, T.nilable(String)) + end + + # Time since last git commit for this {Tap}. + def installed? = true +end +RUBY cat >"$TMPDIR/Library/Homebrew/dev-cmd/bottle.rb" <<'RUBY' # typed: strict # frozen_string_literal: true @@ -56,6 +95,9 @@ module T class Array def self.[](*) = Object end + + def self.let(value, _type) = value + def self.nilable(*) = Object end def sig(*) = nil @@ -546,6 +588,14 @@ patched_line_count="$(grep -c 'next if trusted_tap?(tap)' \ exit 1 } +selected_tap_head_count="$(grep -c \ + 'KandeloPublisher.selected_tap_git_head(self)' \ + "$TMPDIR/Library/Homebrew/tap.rb" || true)" +[ "$selected_tap_head_count" = "1" ] || { + echo "test-homebrew-publisher-overlay-patch.sh: patch did not route one selected tap HEAD lookup through the publisher" >&2 + exit 1 +} + repository_guard_count="$(grep -c \ 'reject { |dir| dir == HOMEBREW_REPOSITORY }' \ "$TMPDIR/Library/Homebrew/diagnostic.rb")" @@ -572,6 +622,39 @@ HOMEBREW_PREFIX = Pathname(ARGV.fetch(0))/"bottle-prefix" HOMEBREW_PREFIX.mkpath expected_gnu_tar = ARGV.fetch(1) +selected_tap_root = Pathname(ARGV.fetch(0))/"selected-tap" +selected_tap_root.mkpath +selected_tap_root = selected_tap_root.realpath +(selected_tap_root/".git").mkpath +selected_tap_head_file = selected_tap_root/".fixture-head" +selected_tap_head_file.write("1111111111111111111111111111111111111111\n") +selected_tap_head_file.chmod(0o444) +selected_tap_root.chmod(0o555) +git_invocations = Pathname(ARGV.fetch(0))/"selected-tap-git-invocations" +protected_git = Pathname(ARGV.fetch(0))/"protected-git" +protected_git.write(<<~SH) + #!/bin/sh + set -eu + [ "${GIT_CONFIG_NOSYSTEM:-}" = 1 ] + [ "${GIT_CONFIG_GLOBAL:-}" = /dev/null ] + [ -z "${GIT_CONFIG_COUNT+x}" ] + [ "$#" -eq 8 ] + [ "$1" = -c ] + [ "$2" = "safe.directory=#{selected_tap_root}" ] + [ "$3" = -C ] + [ "$4" = "#{selected_tap_root}" ] + [ "$5" = rev-parse ] + [ "$6" = --verify ] + [ "$7" = --quiet ] + [ "$8" = 'HEAD^{commit}' ] + printf 'called\n' >>"#{git_invocations}" + exec /bin/cat "$4/.fixture-head" +SH +protected_git.chmod(0o555) +protected_git = protected_git.realpath +ENV["HOMEBREW_KANDELO_PRIMARY_TAP_ROOT"] = selected_tap_root.to_s +ENV["HOMEBREW_GIT_PATH"] = protected_git.to_s + class FixtureBottleArgs def only_json_tab? = false end @@ -593,14 +676,15 @@ end plan_path = HOMEBREW_PREFIX/".kandelo-publisher-build-dependencies.json" plan = { - "schema" => 4, + "schema" => 5, "tap" => "kandelo-dev/tap-core", "formula" => "hello", "full_name" => "kandelo-dev/tap-core/hello", "target_taps" => [{ "tap_name" => "kandelo-dev/tap-core", "tap_repository" => "kandelo-dev/homebrew-tap-core", - "tap_commit" => "1111111111111111111111111111111111111111", + "tap_commit" => "2222222222222222222222222222222222222222", + "checkout_commit" => "1111111111111111111111111111111111111111", }], "build" => [], "build_and_test" => [], @@ -611,6 +695,51 @@ plan_path.write(JSON.generate(plan)) plan_path.chmod(0o444) require "dev-cmd/bottle" +require "tap" + +unexpected_git_repository = Object.new +def unexpected_git_repository.head_ref = raise("selected tap used the ambient Git lookup") +selected_tap = Tap.new("kandelo-dev/tap-core", selected_tap_root, unexpected_git_repository) +ENV["GIT_CONFIG_COUNT"] = "99" +ENV["GIT_CONFIG_KEY_0"] = "caller.poison" +ENV["GIT_CONFIG_VALUE_0"] = "caller-poison" +unless selected_tap.git_head == plan.fetch("target_taps").fetch(0).fetch("checkout_commit") + raise "selected tap did not resolve its exact protected checkout commit" +end +raise "selected tap Git was not invoked exactly once" unless git_invocations.readlines.length == 1 + +wrong_tap_root = Pathname(ARGV.fetch(0))/"wrong-selected-tap" +wrong_tap_root.mkpath +wrong_tap_root = wrong_tap_root.realpath +wrong_tap_root.chmod(0o555) +wrong_tap = Tap.new("kandelo-dev/tap-core", wrong_tap_root, unexpected_git_repository) +begin + wrong_tap.git_head + raise "publisher accepted a different tap checkout path" +rescue RuntimeError => e + raise unless e.message.include?("selected tap checkout path") +end +raise "path mismatch reached protected Git" unless git_invocations.readlines.length == 1 + +selected_tap_root.chmod(0o755) +selected_tap_head_file.chmod(0o644) +selected_tap_head_file.write("2222222222222222222222222222222222222222\n") +selected_tap_head_file.chmod(0o444) +selected_tap_root.chmod(0o555) +changed_tap = Tap.new("kandelo-dev/tap-core", selected_tap_root, unexpected_git_repository) +begin + changed_tap.git_head + raise "publisher accepted selected tap source drift" +rescue RuntimeError => e + raise unless e.message.include?("selected tap commit") +end +raise "changed tap was not queried exactly once" unless git_invocations.readlines.length == 2 + +selected_tap_root.chmod(0o755) +selected_tap_head_file.chmod(0o644) +selected_tap_head_file.write("1111111111111111111111111111111111111111\n") +selected_tap_head_file.chmod(0o444) +selected_tap_root.chmod(0o555) # KandeloPublisher captures this launcher-validated path before Formula source # can run. A Formula-side ENV mutation must not redirect archive creation. @@ -841,7 +970,7 @@ end plan_path = HOMEBREW_PREFIX/".kandelo-publisher-build-dependencies.json" plan = { - "schema" => 4, + "schema" => 5, "tap" => "kandelo-dev/tap-core", "formula" => "hello", "full_name" => "kandelo-dev/tap-core/hello", @@ -850,11 +979,13 @@ plan = { "tap_name" => "acme/tools", "tap_repository" => "acme/homebrew-tools", "tap_commit" => "1111111111111111111111111111111111111111", + "checkout_commit" => "1111111111111111111111111111111111111111", }, { "tap_name" => "kandelo-dev/tap-core", "tap_repository" => "kandelo-dev/homebrew-tap-core", "tap_commit" => "2222222222222222222222222222222222222222", + "checkout_commit" => "2222222222222222222222222222222222222222", }, ], "build" => ["binaryen", "wabt"], @@ -1218,7 +1349,7 @@ end plan_path = HOMEBREW_PREFIX/".kandelo-publisher-build-dependencies.json" plan = { - "schema" => 4, + "schema" => 5, "tap" => "kandelo-dev/tap-core", "formula" => "hello", "full_name" => "kandelo-dev/tap-core/hello", @@ -1227,11 +1358,13 @@ plan = { "tap_name" => "acme/tools", "tap_repository" => "acme/homebrew-tools", "tap_commit" => "1111111111111111111111111111111111111111", + "checkout_commit" => "1111111111111111111111111111111111111111", }, { "tap_name" => "kandelo-dev/tap-core", "tap_repository" => "kandelo-dev/homebrew-tap-core", "tap_commit" => "2222222222222222222222222222222222222222", + "checkout_commit" => "2222222222222222222222222222222222222222", }, ], "build" => ["wabt"], @@ -1291,6 +1424,18 @@ rescue RuntimeError => e raise unless e.message.include?("invalid target taps") end +mutable_checkout_plan = JSON.parse(JSON.generate(plan)) +mutable_checkout_plan.fetch("target_taps").first["checkout_commit"] = "main" +plan_path.chmod(0o644) +plan_path.write(JSON.generate(mutable_checkout_plan)) +plan_path.chmod(0o444) +begin + global_dependencies(target) + raise "mutable target tap checkout suppressed Linux global dependencies" +rescue RuntimeError => e + raise unless e.message.include?("invalid target taps") +end + mismatched_repository_plan = JSON.parse(JSON.generate(plan)) mismatched_repository_plan.fetch("target_taps").first["tap_repository"] = "example/homebrew-other" @@ -1354,7 +1499,7 @@ HOMEBREW_PREFIX = Pathname(ARGV.fetch(0))/"sandbox-prefix" HOMEBREW_PREFIX.mkpath plan_path = HOMEBREW_PREFIX/".kandelo-publisher-build-dependencies.json" plan = { - "schema" => 4, + "schema" => 5, "tap" => "kandelo-dev/tap-core", "formula" => "hello", "full_name" => "kandelo-dev/tap-core/hello", @@ -1362,6 +1507,7 @@ plan = { "tap_name" => "kandelo-dev/tap-core", "tap_repository" => "kandelo-dev/homebrew-tap-core", "tap_commit" => "1111111111111111111111111111111111111111", + "checkout_commit" => "1111111111111111111111111111111111111111", }], "build" => [], "build_and_test" => [], diff --git a/scripts/test-homebrew-sibling-bottle-policy.sh b/scripts/test-homebrew-sibling-bottle-policy.sh index 9e0eecceb2..948216b605 100644 --- a/scripts/test-homebrew-sibling-bottle-policy.sh +++ b/scripts/test-homebrew-sibling-bottle-policy.sh @@ -114,7 +114,7 @@ cp "$formula" "$tap/Formula/sqlite.rb" cp "$tap/Formula/sqlite.rb" "$TMPDIR/planned.rb" selected_sha="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" jq -n --arg sha "$selected_sha" '{ - sqlite: { + "kandelo-dev/tap-core/sqlite": { formula: { name: "sqlite", path: "Library/Taps/kandelo-dev/homebrew-tap-core/Formula/sqlite.rb", diff --git a/scripts/test-homebrew-tap-native-sidecars.sh b/scripts/test-homebrew-tap-native-sidecars.sh index 7be33f44b9..0ef4467fcb 100755 --- a/scripts/test-homebrew-tap-native-sidecars.sh +++ b/scripts/test-homebrew-tap-native-sidecars.sh @@ -4,6 +4,7 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" TMPDIR="$(mktemp -d)" +TMPDIR="$(cd "$TMPDIR" && pwd -P)" trap 'rm -rf "$TMPDIR"' EXIT TEST_FORBIDDEN_ROOT="/trusted/publisher/build-root" MOCK_BIN="$TMPDIR/mock-bin" @@ -527,6 +528,7 @@ generate_sidecars() { local bottle_filename local merged_tap="${out}-merged-tap" local canonical_json="${out}-merge-bottle.json" + local minimal_json="${out}-minimal-bottle.json" local dependency_provenance="${out}-dependency-provenance.json" local runtime_evidence="${out}-runtime-evidence.json" local formula_source_root tap_commit tap_checkout_commit @@ -546,12 +548,13 @@ generate_sidecars() { rm -rf "$merged_tap" "$out" cp -a "$formula_source_root" "$merged_tap" mkdir -p "$out" - jq -e --arg formula "$formula" --arg tag "${arch}_kandelo" ' + jq -e --arg formula "$formula" --arg formula_key "kandelo-dev/tap-core/$formula" \ + --arg tag "${arch}_kandelo" ' if type != "object" or length != 1 then error("expected one raw bottle entry") else to_entries[0].value as $entry | - {($formula): { + {($formula_key): { formula: { name: $entry.formula.name, path: $entry.formula.path, @@ -566,7 +569,51 @@ generate_sidecars() { }} end ' \ - "$bottle_json" >"$canonical_json" + "$bottle_json" >"$minimal_json" + case "${SIDECAR_BOTTLE_JSON_MODE:-minimal}" in + minimal) + cp "$minimal_json" "$canonical_json" + ;; + raw) + local raw_json="${out}-raw-bottle.json" + local raw_tmp="${out}-raw-bottle.tmp.json" + local url_filename="${bottle_filename/--/-}" + jq -e \ + --arg formula_key "kandelo-dev/tap-core/$formula" \ + --arg tag "${arch}_kandelo" \ + --arg tap_remote "file://$formula_source_root" \ + --arg url_filename "$url_filename" \ + --argjson installed_size "$bytes" ' + .[$formula_key] as $entry | + {($formula_key): { + formula: ($entry.formula + { + desc: "tap-native raw builder fixture", + homepage: "https://example.invalid/tap-native", + license: "MIT", + tap_git_remote: $tap_remote + }), + bottle: ($entry.bottle + { + date: "2026-08-12T00:00:00Z", + tags: {($tag): ($entry.bottle.tags[$tag] + { + filename: $url_filename, + installed_size: $installed_size, + sbom: {} + })} + }) + }} + ' "$bottle_json" >"$raw_json" + if [ -n "${SIDECAR_RAW_BOTTLE_MUTATION:-}" ]; then + jq --arg formula_key "kandelo-dev/tap-core/$formula" \ + "$SIDECAR_RAW_BOTTLE_MUTATION" "$raw_json" >"$raw_tmp" + mv "$raw_tmp" "$raw_json" + fi + cp "$raw_json" "$canonical_json" + ;; + *) + echo "unsupported sidecar bottle JSON fixture mode" >&2 + return 2 + ;; + esac write_dependency_provenance \ "$formula" "$arch" "$tap_commit" "$tap_checkout_commit" \ "$formula_source_root" "$dependency_provenance" @@ -576,7 +623,7 @@ generate_sidecars() { --formula "$formula" \ --arch "$arch" \ --release-tag "bottles-abi-v${ABI_VERSION}" \ - --bottle-json "$canonical_json" \ + --bottle-json "$minimal_json" \ --expected-sha256 "$sha" \ --expected-root-url https://ghcr.io/v2/kandelo-dev/homebrew-tap-core \ --expected-cellar any_skip_relocation >/dev/null @@ -585,7 +632,8 @@ generate_sidecars() { runtime_provenance_sha="${SIDECAR_RUNTIME_PROVENANCE_SHA:-$provenance_sha}" runtime_dependency_bottle_sha="${SIDECAR_RUNTIME_DEPENDENCY_BOTTLE_SHA:-}" runtime_dependency_receipt_sha="${SIDECAR_RUNTIME_DEPENDENCY_RECEIPT_SHA:-}" - version="$(jq -er --arg formula "$formula" '.[$formula].formula.pkg_version' \ + version="$(jq -er --arg formula_key "kandelo-dev/tap-core/$formula" \ + '.[$formula_key].formula.pkg_version' \ "$canonical_json")" jq -nS \ --arg formula "$formula" \ @@ -736,6 +784,38 @@ mapfile -t dep_bottle < <(make_dep_bottle) mapfile -t dep64_bottle < <(make_dep_wasm64_bottle "${dep_bottle[0]}" "${dep_bottle[1]}") mapfile -t data_bottle < <(make_data_bottle) generate_sidecars sidecar-dep "${dep_bottle[@]}" "$DEP_OUT" +RAW_DEP_OUT="$TMPDIR/dep-raw-sidecars" +SIDECAR_BOTTLE_JSON_MODE=raw \ + generate_sidecars sidecar-dep "${dep_bottle[@]}" "$RAW_DEP_OUT" +jq -S 'del(.generated_at, .packages[].bottles[].built_at)' \ + "$DEP_OUT/sidecars-input.json" >"$TMPDIR/dep-minimal-normalized.json" +jq -S 'del(.generated_at, .packages[].bottles[].built_at)' \ + "$RAW_DEP_OUT/sidecars-input.json" >"$TMPDIR/dep-raw-normalized.json" +cmp "$TMPDIR/dep-minimal-normalized.json" \ + "$TMPDIR/dep-raw-normalized.json" >/dev/null || { + echo "raw and publisher-minimal bottle JSON produced different sidecar inputs" >&2 + exit 1 +} +SIDECAR_BOTTLE_JSON_MODE=raw \ +SIDECAR_RAW_BOTTLE_MUTATION='.[$formula_key].formula.unexpected = true' \ + expect_generate_failure raw-builder-extra-formula-field \ + 'canonical Formula identity must contain exactly' \ + sidecar-dep "${dep_bottle[@]}" "$TMPDIR/raw-extra-sidecars" +SIDECAR_BOTTLE_JSON_MODE=raw \ +SIDECAR_RAW_BOTTLE_MUTATION='.[$formula_key].formula.tap_git_revision = ("0" * 40)' \ + expect_generate_failure raw-builder-wrong-tap-revision \ + 'canonical Formula tap Git revision does not match' \ + sidecar-dep "${dep_bottle[@]}" "$TMPDIR/raw-revision-sidecars" +SIDECAR_BOTTLE_JSON_MODE=raw \ +SIDECAR_RAW_BOTTLE_MUTATION='.[$formula_key].formula.tap_git_path = "Formula/other.rb"' \ + expect_generate_failure raw-builder-wrong-tap-path \ + 'canonical Formula tap Git path does not match' \ + sidecar-dep "${dep_bottle[@]}" "$TMPDIR/raw-path-sidecars" +SIDECAR_BOTTLE_JSON_MODE=raw \ +SIDECAR_RAW_BOTTLE_MUTATION='.[$formula_key].formula.tap_git_remote = "file:///does/not/exist"' \ + expect_generate_failure raw-builder-wrong-tap-remote \ + 'canonical Formula tap Git remote does not resolve' \ + sidecar-dep "${dep_bottle[@]}" "$TMPDIR/raw-remote-sidecars" # The public tap commit and the deterministic campaign checkout are separate # identities. Exercise the real validators and generator with A != B so a diff --git a/scripts/test-homebrew-validate-host-dependency-plan.sh b/scripts/test-homebrew-validate-host-dependency-plan.sh index e4722dad75..b71547b6bb 100755 --- a/scripts/test-homebrew-validate-host-dependency-plan.sh +++ b/scripts/test-homebrew-validate-host-dependency-plan.sh @@ -12,11 +12,12 @@ MUTATED="$TMP_ROOT/mutated.json" cat >"$RESOLVED" <<'JSON' { - "schema": 1, + "schema": 2, "primary": { "tap_name": "kandelo-dev/tap-core", "tap_repository": "kandelo-dev/homebrew-tap-core", "tap_commit": "1111111111111111111111111111111111111111", + "checkout_commit": "2222222222222222222222222222222222222222", "root": "/tmp/unused-tap-root" }, "dependencies": [] @@ -25,11 +26,12 @@ JSON cat >"$PLAN" <<'JSON' { - "schema": 4, + "schema": 5, "tap": "kandelo-dev/tap-core", "formula": "fixture", "full_name": "kandelo-dev/tap-core/fixture", "target_taps": [{ + "checkout_commit": "2222222222222222222222222222222222222222", "tap_name": "kandelo-dev/tap-core", "tap_repository": "kandelo-dev/homebrew-tap-core", "tap_commit": "1111111111111111111111111111111111111111" @@ -71,7 +73,11 @@ mutate_and_reject() { assert_rejected "$label" } -mutate_and_reject "legacy schema 3" '.schema = 3' +mutate_and_reject "legacy schema 4 without checkout identity" ' + .schema = 4 | .target_taps |= map(del(.checkout_commit)) +' +mutate_and_reject "source commit substituted for checkout commit" \ + '.target_taps[0].checkout_commit = .target_taps[0].tap_commit' mutate_and_reject "unsorted native Requirement records" '.native_requirements |= reverse' mutate_and_reject "duplicate native Requirement class" \ '.native_requirements += [.native_requirements[0]]' diff --git a/scripts/test-seal-homebrew-formula-checker.sh b/scripts/test-seal-homebrew-formula-checker.sh index 66ef4eebff..c8b236477f 100755 --- a/scripts/test-seal-homebrew-formula-checker.sh +++ b/scripts/test-seal-homebrew-formula-checker.sh @@ -49,6 +49,31 @@ printf 'changed deps artifact\n' >"$artifact" [ "$(sha256sum "$checker" | awk '{print $1}')" = "$source_sha256" ] || fail "Cargo's alternate path can mutate the sealed checker" +# A batched local build can run trusted Cargo tooling between Formulae. Model +# Cargo rematerializing the same authenticated bytes as its normal 0755, +# two-link release output, then require the shared sealer to restore the exact +# next-Formula boundary without accepting changed bytes. +rm "$artifact" +cp "$checker" "$artifact" +chmod 0755 "$artifact" +rm "$checker" +ln "$artifact" "$checker" +[ "$(stat -c '%h:%a' "$checker")" = "2:755" ] || + fail "second-Formula fixture did not recreate Cargo's release output" +[ "$(sha256sum "$checker" | awk '{print $1}')" = "$source_sha256" ] || + fail "second-Formula fixture changed the authenticated checker bytes" +reported="$( + bash "$REPO_ROOT/scripts/seal-homebrew-formula-checker.sh" \ + --root "$root" \ + --checker "$checker" +)" +[ "$reported" = "$checker" ] || + fail "second-Formula sealer did not report the exact checker" +[ "$(stat -c '%h:%a' "$checker")" = "1:555" ] || + fail "second-Formula checker is not one read-only inode" +[ "$(sha256sum "$checker" | awk '{print $1}')" = "$source_sha256" ] || + fail "second-Formula checker changed after resealing" + unsafe="$root/target/unsafe/release/xtask" mkdir -p "${unsafe%/*}" cp "$checker" "$unsafe" diff --git a/scripts/test-wasm-artifact-guards.sh b/scripts/test-wasm-artifact-guards.sh index 34a77236c9..5721822c1e 100755 --- a/scripts/test-wasm-artifact-guards.sh +++ b/scripts/test-wasm-artifact-guards.sh @@ -188,6 +188,82 @@ chmod +x "$work/bin/structural-identity-tool" # deliberately unusable WABT binary proves neither helper silently falls back # to full-module text decoding for a large ABI 43 artifact. structural_path="$work/bin/structural-identity-tool" +cat >"$work/structural-side.wat" <<'WAT' +(module + (@custom "dylink.0" (before type) "") + (import "env" "memory" (memory 1)) + (func (export "side_value") (result i32) + i32.const 17)) +WAT +wat2wasm --enable-annotations "$work/structural-side.wat" \ + -o "$work/structural-side.wasm" + +# ABI 43 C++ side modules contain proposal encodings that the installed WABT +# can partially print before returning nonzero. Loader role and import policy +# must use the wasmparser-backed identity as one exact result, never trust +# partial WABT stdout, and never fall back after an installed decoder fails. +side_identity=$'0\t1\t0\tmissing\t-\t0\t0\t0\t1\t1\t1\t0' +role_status=0 +role="$( + WASM_POSIX_FORK_INSTRUMENT="$structural_path" \ + MOCK_IDENTITY_RECORD="$side_identity" \ + PATH="$work/no-objdump-bin:$PATH" \ + wasm_artifact_role "$work/structural-side.wasm" +)" || role_status=$? +[ "$role_status" -eq 0 ] && [ "$role" = side-module ] || { + echo "ERROR: structural loader identity did not classify a modern side module" >&2 + exit 1 +} +arch_status=0 +arch="$( + WASM_POSIX_FORK_INSTRUMENT="$structural_path" \ + MOCK_IDENTITY_RECORD="$side_identity" \ + PATH="$work/no-objdump-bin:$PATH" \ + wasm_validate_side_module_imports "$work/structural-side.wasm" +)" || arch_status=$? +[ "$arch_status" -eq 0 ] && [ "$arch" = wasm32 ] || { + echo "ERROR: structural loader identity did not validate modern side imports" >&2 + exit 1 +} + +role_status=0 +WASM_POSIX_FORK_INSTRUMENT="$structural_path" \ + MOCK_IDENTITY_RECORD=malformed \ + wasm_artifact_role "$work/structural-side.wasm" >/dev/null 2>&1 || \ + role_status=$? +[ "$role_status" -gt 1 ] || { + echo "ERROR: side role fell back after structural decoder failure" >&2 + exit 1 +} +arch_status=0 +WASM_POSIX_FORK_INSTRUMENT="$structural_path" \ + MOCK_IDENTITY_RECORD=malformed \ + wasm_validate_side_module_imports "$work/structural-side.wasm" \ + >/dev/null 2>&1 || arch_status=$? +[ "$arch_status" -gt 1 ] || { + echo "ERROR: side import policy fell back after structural decoder failure" >&2 + exit 1 +} + +role_status=0 +WASM_POSIX_FORK_INSTRUMENT="$missing_structural_tool" \ + PATH="$work/no-objdump-bin:$PATH" \ + wasm_artifact_role "$work/structural-side.wasm" >/dev/null 2>&1 || \ + role_status=$? +[ "$role_status" -gt 1 ] || { + echo "ERROR: side role accepted when both structural and WABT decoders failed" >&2 + exit 1 +} +arch_status=0 +WASM_POSIX_FORK_INSTRUMENT="$missing_structural_tool" \ + PATH="$work/no-objdump-bin:$PATH" \ + wasm_validate_side_module_imports "$work/structural-side.wasm" \ + >/dev/null 2>&1 || arch_status=$? +[ "$arch_status" -gt 1 ] || { + echo "ERROR: side import policy accepted when both decoders failed" >&2 + exit 1 +} + actual="$( WASM_POSIX_FORK_INSTRUMENT="$structural_path" \ PATH="$work/no-objdump-bin:$PATH" wasm_extract_abi_version "$work/abi.wasm" diff --git a/scripts/wasm-artifact-guards.sh b/scripts/wasm-artifact-guards.sh index 84fb9f3bf4..9368956f76 100644 --- a/scripts/wasm-artifact-guards.sh +++ b/scripts/wasm-artifact-guards.sh @@ -840,6 +840,40 @@ wasm_imports_side_module_fork() { grep -a -q 'fork' "$path" 2>/dev/null } +# Print the exact loader fields from the wasmparser-backed artifact identity. +# Return 127 only when that decoder is unavailable. An installed decoder that +# fails or emits a malformed record is authoritative failure: callers must not +# reinterpret partial output from an older text decoder as a valid module. +_wasm_structural_loader_identity() { + local path="${1:-}" + local identity identity_status=0 + local -a fields + + identity="$(wasm_artifact_identity "$path")" || identity_status=$? + [ "$identity_status" -eq 0 ] || return "$identity_status" + IFS=$'\t' read -r -a fields <<< "$identity" + [ "${#fields[@]}" -eq 12 ] || return 2 + [[ "${fields[0]}" =~ ^[01]$ ]] && + [[ "${fields[1]}" =~ ^[0-9]+$ ]] && + [[ "${fields[2]}" =~ ^[0-9]+$ ]] && + [[ "${fields[5]}" =~ ^[0-9]+$ ]] && + [[ "${fields[6]}" =~ ^[0-9]+$ ]] && + [[ "${fields[7]}" =~ ^[01]$ ]] && + [[ "${fields[8]}" =~ ^[0-9]+$ ]] && + [[ "${fields[9]}" =~ ^[01]$ ]] && + [[ "${fields[10]}" =~ ^[0-9]+$ ]] && + [[ "${fields[11]}" =~ ^[0-9]+$ ]] || return 2 + case "${fields[3]}" in + present) [[ "${fields[4]}" =~ ^[0-9]+$ ]] || return 2 ;; + missing|invalid) [ "${fields[4]}" = - ] || return 2 ;; + *) return 2 ;; + esac + + printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ + "${fields[1]}" "${fields[2]}" "${fields[8]}" \ + "${fields[9]}" "${fields[10]}" "${fields[11]}" +} + # Print `executable` or `side-module` for a structurally decoded Wasm module. # A valid Kandelo side module carries exactly one `dylink.0` custom section as # its first section, matching the runtime loader contract in host/src/dylink.ts. @@ -849,6 +883,31 @@ wasm_imports_side_module_fork() { wasm_artifact_role() { local path="${1:-}" wasm_is_binary "$path" || return 2 + + local identity identity_status=0 + local memory_count memory64_count dylink_count dylink_first + local env_memory_count unsupported_side_import_count extra + identity="$(_wasm_structural_loader_identity "$path")" || identity_status=$? + if [ "$identity_status" -eq 0 ]; then + IFS=$'\t' read -r memory_count memory64_count dylink_count dylink_first \ + env_memory_count unsupported_side_import_count extra <<< "$identity" + [ -z "$extra" ] || return 2 + if [ "$dylink_count" = 0 ] && [ "$dylink_first" = 0 ]; then + printf 'executable\n' + return 0 + fi + if [ "$dylink_count" = 1 ] && [ "$dylink_first" = 1 ]; then + printf 'side-module\n' + return 0 + fi + return 3 + elif [ "$identity_status" -ne 127 ]; then + return "$identity_status" + fi + + # Source-only callers may run before the Rust decoder is installed. Keep + # the WABT path only for that explicit unavailable status; its entire + # command must succeed before any parsed stdout is trusted. command -v wasm-objdump >/dev/null 2>&1 || return 2 _wasm_stream_awk ' @@ -881,6 +940,29 @@ wasm_artifact_role() { wasm_validate_side_module_imports() { local path="${1:-}" wasm_is_binary "$path" || return 2 + + local identity identity_status=0 + local memory_count memory64_count dylink_count dylink_first + local env_memory_count unsupported_side_import_count extra + identity="$(_wasm_structural_loader_identity "$path")" || identity_status=$? + if [ "$identity_status" -eq 0 ]; then + IFS=$'\t' read -r memory_count memory64_count dylink_count dylink_first \ + env_memory_count unsupported_side_import_count extra <<< "$identity" + [ -z "$extra" ] || return 2 + [ "$memory_count" = 1 ] && + { [ "$memory64_count" = 0 ] || [ "$memory64_count" = 1 ]; } && + [ "$env_memory_count" = 1 ] && + [ "$unsupported_side_import_count" = 0 ] || return 3 + if [ "$memory64_count" = 1 ]; then + printf 'wasm64\n' + else + printf 'wasm32\n' + fi + return 0 + elif [ "$identity_status" -ne 127 ]; then + return "$identity_status" + fi + command -v wasm-objdump >/dev/null 2>&1 || return 2 _wasm_stream_awk ' diff --git a/tests/package-system/rootfs-verified-source-contract.test.ts b/tests/package-system/rootfs-verified-source-contract.test.ts index 780f549ece..990c3b238c 100644 --- a/tests/package-system/rootfs-verified-source-contract.test.ts +++ b/tests/package-system/rootfs-verified-source-contract.test.ts @@ -65,6 +65,20 @@ describe("source-rootfs verified archive contract", () => { "utf8", ); + it("uses the ncurses maintainer archive mirror for the hash-verified source", () => { + const manifest = readFileSync( + resolve(repoRoot, "packages/registry/ncurses/package.toml"), + "utf8", + ); + + expect(sourceField(manifest, "url")).toBe( + "https://invisible-mirror.net/archives/ncurses/ncurses-6.5.tar.gz", + ); + expect(sourceField(manifest, "sha256")).toBe( + "136d91bc269a9a5785e5f9e980bc76ab57428f604ce3e5a5a90cebc767971cc6", + ); + }); + for (const [ packageName, versionVariable, diff --git a/tools/xtask/src/build_deps.rs b/tools/xtask/src/build_deps.rs index c11892519f..9fa5c95e4a 100644 --- a/tools/xtask/src/build_deps.rs +++ b/tools/xtask/src/build_deps.rs @@ -2481,16 +2481,28 @@ fn hash_global_package_build_input( hash_build_input(path) } -fn hash_gitlink_input(root: &Path, input: &str) -> Result, String> { - let output = match Command::new("git") +fn gitlink_ls_tree_command(root: &Path, input: &str) -> Command { + let mut safe_directory = std::ffi::OsString::from("safe.directory="); + safe_directory.push(root.as_os_str()); + + let mut command = Command::new("git"); + command + .arg("-c") + .arg(safe_directory) .arg("-C") .arg(root) .arg("ls-tree") .arg("HEAD") .arg("--") - .arg(input) - .output() - { + .arg(input); + command +} + +fn hash_gitlink_input(root: &Path, input: &str) -> Result, String> { + // Package identity follows the exact source checkout's committed gitlink, + // even when a protected build user reads a checkout owned by the workflow + // user. Keep the trust exception scoped to this one command and directory. + let output = match gitlink_ls_tree_command(root, input).output() { Ok(output) => output, Err(_) => return Ok(None), }; @@ -12508,6 +12520,31 @@ index_url = "https://example.test/releases/binaries-abi-v{abi}/index.toml" ); } + #[test] + fn gitlink_identity_admits_the_exact_source_checkout_across_build_users() { + let root = Path::new("/tmp/kandelo source"); + let command = gitlink_ls_tree_command(root, "libc/musl"); + let args = command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + + assert_eq!( + args, + vec![ + "-c", + "safe.directory=/tmp/kandelo source", + "-C", + "/tmp/kandelo source", + "ls-tree", + "HEAD", + "--", + "libc/musl", + ], + "the protected checker must admit only its exact read-only source alias instead of falling back to mutable submodule contents", + ); + } + #[test] fn program_projection_cache_keys_change_with_global_toolchain_inputs() { let root = tempdir("program-projection-global-build-inputs"); From f6ab020538d7f215e0978b66c63a03095c66a169 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Wed, 12 Aug 2026 13:27:03 -0400 Subject: [PATCH 72/82] ABI: Complete vfork readiness integration Bind the final VFS, credential, memory-revalidation, and readiness evidence to the ABI 43 vfork contract. --- .../2026-07-31-affordable-fork-then-exec.md | 43 ++++++++++ .../2026-08-10-vfork-readiness.md | 19 ++++- examples/spawn-coverage.c | 20 +++-- host/test/centralized-spawn.test.ts | 7 +- .../process-memory-reclamation-rss.ts | 21 ++++- host/test/fork-instrument-coverage.test.ts | 9 +- host/test/host-file-offset.test.ts | 13 +++ host/test/host-owned-process-reap.test.ts | 17 +++- host/test/kernel-allocator-churn.test.ts | 16 ++++ host/test/kernel-scratch-contract.test.ts | 3 + host/test/node-rootfs-export.test.ts | 20 +---- ...cess-generation-detach-host-parity.test.ts | 2 +- host/test/shell-lazy-archive-inputs.test.ts | 84 ++++++++++++++++--- packages/registry/kernel/build-kernel.sh | 3 +- packages/registry/program-packages.json | 46 +++++----- packages/registry/shell/build.toml | 1 + programs/p_05_posix_spawn.c | 28 +------ programs/p_09_posix_spawn_fork.c | 28 +------ run.sh | 3 +- scripts/resolve-binary.bundle.mjs | 12 +-- scripts/run-vfork-readiness.sh | 2 +- .../test-artifacts/kernel-test-programs.json | 4 + 22 files changed, 266 insertions(+), 135 deletions(-) diff --git a/docs/measurements/2026-07-31-affordable-fork-then-exec.md b/docs/measurements/2026-07-31-affordable-fork-then-exec.md index 8d675c8d74..5b6d259451 100644 --- a/docs/measurements/2026-07-31-affordable-fork-then-exec.md +++ b/docs/measurements/2026-07-31-affordable-fork-then-exec.md @@ -588,6 +588,49 @@ unacceptable latency or CPU cost on Node and browsers. ### Current lifecycle measurements +### Task 21 revalidation — 2026-08-12 + +At final-head candidate `3f4ea731bca81c42bf3c7f6ae52a0ac837a7d56b` +(tree `215c366f5075a3a9253c86223b8399a4619a313a`), the ordinary-memory +suite passed 63 checks in five files. The RSS fixture explicitly stages its +child executable at the VFS path used by `posix_spawn`; the host executable +map remains a prepared-target preflight only and is not a substitute for that +guest filesystem entry. + +Two churn observations, each with four warm-up children and six waves of +eight 8 MiB children, completed with no guest stderr or host diagnostics: + +| Observation | Late slope | Late growth | +|---|---:|---:| +| A | -2.061 MiB/child | -69.313 MiB | +| B | -1.335 MiB/child | -40.750 MiB | + +The fixture is evidence that exact-fenced retired memories became +collectible under its bounded ordinary allocation pressure. It is not a +promise about the timing of physical memory reclamation. + +The component harness was also rerun twice on Apple Silicon macOS with Node +v24.15.0 from the repository dev shell. It used a 256 MiB shared memory with +16 MiB touched: + +| Case | Run A | Run B | +|---|---:|---:| +| Worker-only peak RSS growth | 13.469 MiB | 13.328 MiB | +| Module-Worker peak RSS growth | 14.594 MiB | 13.547 MiB | +| Shared-memory Worker growth | 10.984 MiB | 11.109 MiB | +| Full-clone RSS growth | 496.374 MiB | 496.345 MiB | +| Sparse-clone RSS growth | 262.844 MiB | 262.656 MiB | +| Full-clone elapsed | 33.442 ms | 32.838 ms | +| Sparse-clone elapsed | 76.602 ms | 79.343 ms | + +Worker and module churn remain small beside a complete address-space clone. +Sparse clone reduces component RSS in this artificial sparse workload, but it +still scans the complete parent and was 2.29–2.42 times slower than the full +clone. It remains unselected: component results alone are not real product or +cross-host application RSS evidence. The current admission thresholds and +bounded fallback remain allocation-accounting safeguards, not a claim that an +engine physically reclaimed bytes at a particular instant. + The Node process-lifecycle suite ran three rounds: ```sh diff --git a/docs/measurements/2026-08-10-vfork-readiness.md b/docs/measurements/2026-08-10-vfork-readiness.md index 3a10bf48bb..540dbf30e5 100644 --- a/docs/measurements/2026-08-10-vfork-readiness.md +++ b/docs/measurements/2026-08-10-vfork-readiness.md @@ -29,9 +29,26 @@ quiescence evidence. | Gate | Status | Evidence | | --- | --- | --- | | Mechanism | PASS | Exact wrapper exited 0 at `334703abc` | -| Integration | NOT RUN | Credential and secure-exec integration belongs to later tasks | +| Integration | PASS | Exact wrapper exited 0 at `9fe84cc44` on 2026-08-12 | | Release | NOT RUN | Outside this mechanism-readiness task | +### Integration revalidation — 2026-08-12 + +`scripts/dev-shell.sh bash scripts/run-vfork-readiness.sh integration` passed +at `9fe84cc4420a98a25a9b64fccc6056a35917a9bd`. The gate rebuilt its guest +fixtures, ran 126 host checks in 18 files, ran the complete host-target +`fork-instrument` suite, ran the focused kernel credential set (12 passed), +and ran 42 Playwright checks across Chromium, Firefox, and WebKit. + +The integration wrapper now invokes the workspace's actual kernel package, +`kandelo`, rather than its removed `wasm-posix-kernel` name. It covers +prepared target commit/failure, secure-exec and `nosuid` behavior, the +credential process record, exact caller-thread suspension, private borrowed +state, and ordinary-fork independence. The external compute-bound borrower +case still demonstrates whole-address-space containment in all browser +engines; it does not claim an exact portable external-kill quiescence fence or +safe parent resumption. + `ABI_VERSION` remains 43. No vfork import, fork mode, instrument-frame field, memory-ownership protocol, safe-point architecture, or host protocol was added. Ordinary fork mode 0 remains independent. diff --git a/examples/spawn-coverage.c b/examples/spawn-coverage.c index 1a41a410b6..0e3178a272 100644 --- a/examples/spawn-coverage.c +++ b/examples/spawn-coverage.c @@ -51,23 +51,27 @@ static void fail(const char *name, const char *reason) { /* --- 1. posix_spawnp without leading slash --- * - * Asserts that PATH search via libc's `access(X_OK)` correctly returns - * ENOENT when no PATH entry contains the binary in the VFS. The vitest - * harness wires `/usr/bin/hello` only via `execPrograms` (a host-side - * exec map), not as a VFS file, so PATH search SHOULD fail. This pins - * the behavior — sortix's `basic/spawn/posix_spawnp` covers the - * positive case (PATH entries that are real VFS dirs). + * Asserts that PATH search via libc's `access(X_OK)` finds a regular, + * executable program in the guest VFS. The Vitest harness stages the + * declared `/usr/bin/hello` exec target into that VFS, which is the same + * guest-visible state that POSIX path resolution observes. */ static void test_spawnp(void) { char *argv[] = { "hello", NULL }; pid_t pid; int rc = posix_spawnp(&pid, "hello", NULL, NULL, argv, environ); - if (rc != ENOENT) { + if (rc != 0) { char msg[128]; - snprintf(msg, sizeof(msg), "posix_spawnp: expected ENOENT, got %d (%s)", rc, strerror(rc)); + snprintf(msg, sizeof(msg), "posix_spawnp: %s", strerror(rc)); fail("spawnp", msg); return; } + int status; + if (waitpid(pid, &status, 0) < 0) { fail("spawnp", "waitpid"); return; } + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + fail("spawnp", "child did not exit 0"); + return; + } ok("spawnp"); } diff --git a/host/test/centralized-spawn.test.ts b/host/test/centralized-spawn.test.ts index 6f607bfbab..229b5a0327 100644 --- a/host/test/centralized-spawn.test.ts +++ b/host/test/centralized-spawn.test.ts @@ -89,10 +89,9 @@ describe("non-forking posix_spawn", () => { expect(result.stdout, `missing 'OK ${subtest}' in stdout`).toContain(`OK ${subtest}`); } expect(result.stdout).toContain("ALL OK"); - // GUARDRAIL: the two successful calls each create a child. The first - // (spawnp) fails before child creation, but a fork bump there would remain - // visible because fork_count is monotonic and taint both later samples. - expect(result.forkCountSamples).toEqual([0n, 0n]); + // GUARDRAIL: all three successful spawn calls create a child without + // changing the parent's monotonic fork counter. + expect(result.forkCountSamples).toEqual([0n, 0n, 0n]); }); it("reports ENOEXEC for non-Wasm spawn targets before launching a worker", async () => { diff --git a/host/test/fixtures/process-memory-reclamation-rss.ts b/host/test/fixtures/process-memory-reclamation-rss.ts index b6141c4492..e3bef9d69d 100644 --- a/host/test/fixtures/process-memory-reclamation-rss.ts +++ b/host/test/fixtures/process-memory-reclamation-rss.ts @@ -3,6 +3,11 @@ import process from "node:process"; import { resolveBinary } from "../../src/binary-resolver"; import { NodeKernelHost } from "../../src/node-kernel-host"; +import { MemoryFileSystem } from "../../src/vfs/memory-fs"; +import { + ensureDirRecursive, + writeVfsBinary, +} from "../../src/vfs/image-helpers"; const MIB = 1024 * 1024; const childPath = "/bin/process-memory-reclamation-churn"; @@ -35,6 +40,17 @@ function readArrayBuffer(path: string | URL): ArrayBuffer { ) as ArrayBuffer; } +async function rootfsWithChurnProgram( + program: ArrayBuffer, +): Promise { + const bytes = new Uint8Array(program); + const capacity = Math.max(4 * MIB, bytes.byteLength + MIB); + const rootfs = MemoryFileSystem.create(new SharedArrayBuffer(capacity)); + ensureDirRecursive(rootfs, "/bin"); + writeVfsBinary(rootfs, childPath, bytes, 0o755); + return await rootfs.saveImage(); +} + function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -95,7 +111,10 @@ async function main(): Promise { let currentPids = new Set(); const host = new NodeKernelHost({ execPrograms: { [childPath]: programPath.pathname }, - rootfsImage: undefined, + // `execPrograms` identifies the prepared host target, while the kernel + // executes the exact guest VFS path. Keep both authorities aligned so the + // churn child exercises normal posix_spawn rather than a host-only map. + rootfsImage: await rootfsWithChurnProgram(program), onStderr: (_pid, bytes) => { stderr.push(new TextDecoder().decode(bytes)); }, diff --git a/host/test/fork-instrument-coverage.test.ts b/host/test/fork-instrument-coverage.test.ts index 5fab231522..2f1fad5697 100644 --- a/host/test/fork-instrument-coverage.test.ts +++ b/host/test/fork-instrument-coverage.test.ts @@ -82,24 +82,19 @@ async function runFixture(relPath: string, expected: Expected) { /** Echo fixture registered for popen/posix_spawn child exec targets. */ const echoBinary = resolveBinary("programs/echo.wasm"); const echoExecMap = new Map([ - ["echo", echoBinary], - ["/echo", echoBinary], - ["/tmp/echo", echoBinary], ["/bin/echo", echoBinary], ["/usr/bin/echo", echoBinary], + ["/tmp/echo", echoBinary], ]); /** Minimal sh fixture built from programs/sh.c for popen("/bin/sh -c ..."). */ const shCandidate = resolveBinary("programs/sh.wasm"); const popenExecMap = new Map([ - ["sh", shCandidate], ["/bin/sh", shCandidate], ["/usr/bin/sh", shCandidate], - ["echo", echoBinary], - ["/echo", echoBinary], - ["/tmp/echo", echoBinary], ["/bin/echo", echoBinary], ["/usr/bin/echo", echoBinary], + ["/tmp/echo", echoBinary], ]); // --------------------------------------------------------------------------- diff --git a/host/test/host-file-offset.test.ts b/host/test/host-file-offset.test.ts index 92dbe3b15f..980eaccf59 100644 --- a/host/test/host-file-offset.test.ts +++ b/host/test/host-file-offset.test.ts @@ -98,6 +98,19 @@ describe("HostFileOffset VFS contract", () => { seeks.push(offset); return offset; }, + statfs: () => ({ + type: 0, + bsize: 4096, + blocks: 1, + bfree: 0, + bavail: 0, + files: 1, + ffree: 0, + fsid: 0, + namelen: 255, + frsize: 4096, + flags: 0, + }), } as unknown as FileSystemBackend; const io = new VirtualPlatformIO( [{ mountPoint: "/", backend }], diff --git a/host/test/host-owned-process-reap.test.ts b/host/test/host-owned-process-reap.test.ts index ba59e02add..0d855017d8 100644 --- a/host/test/host-owned-process-reap.test.ts +++ b/host/test/host-owned-process-reap.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest"; import { reapHostOwnedExitedProcess } from "../src/host-owned-process-reap"; import { NodeKernelHost } from "../src/node-kernel-host"; import { signalExitStatus, SIGILL } from "../src/trap-signals"; +import { MemoryFileSystem } from "../src/vfs/memory-fs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const helloWasm = join(__dirname, "../../examples/hello.wasm"); @@ -28,6 +29,20 @@ function loadProgramBytes(path: string): ArrayBuffer { return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); } +async function spawnSmokeRootfs(): Promise { + const fs = MemoryFileSystem.create(new SharedArrayBuffer(4 * 1024 * 1024)); + fs.mkdir("/usr", 0o755); + fs.mkdir("/usr/bin", 0o755); + fs.createFileWithOwner( + "/usr/bin/hello", + 0o755, + 0, + 0, + new Uint8Array(readFileSync(helloWasm)), + ); + return fs.saveImage(); +} + describe("host-owned exited-process reaping", () => { it("asks Rust to reap only a ppid=0 child", () => { const getParentPid = vi.fn(() => 0); @@ -144,7 +159,7 @@ describe("host-owned exited-process reaping", () => { }> = []; const host = new NodeKernelHost({ execPrograms: { "/usr/bin/hello": helloWasm }, - rootfsImage: undefined, + rootfsImage: await spawnSmokeRootfs(), onHostDiagnostic: (diagnostic) => diagnostics.push(diagnostic.message), onProcessEvent: (event) => processEvents.push(event), }); diff --git a/host/test/kernel-allocator-churn.test.ts b/host/test/kernel-allocator-churn.test.ts index 4836e7f3fd..0c44cea620 100644 --- a/host/test/kernel-allocator-churn.test.ts +++ b/host/test/kernel-allocator-churn.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { resolveBinary } from "../src/binary-resolver"; import { NodeKernelHost } from "../src/node-kernel-host"; +import { MemoryFileSystem } from "../src/vfs/memory-fs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const churnProgram = resolve( @@ -20,6 +21,19 @@ function readArrayBuffer(path: string): ArrayBuffer { ) as ArrayBuffer; } +async function spawnChurnRootfs(): Promise { + const fs = MemoryFileSystem.create(new SharedArrayBuffer(4 * 1024 * 1024)); + fs.mkdir("/bin", 0o755); + fs.createFileWithOwner( + "/bin/kernel_allocator_churn_test", + 0o755, + 0, + 0, + new Uint8Array(readFileSync(churnProgram)), + ); + return fs.saveImage(); +} + async function runChurn( mode: "pipe" | "fork" | "spawn", count: number, @@ -27,10 +41,12 @@ async function runChurn( let stdout = ""; let stderr = ""; const diagnostics: string[] = []; + const rootfsImage = mode === "spawn" ? await spawnChurnRootfs() : undefined; const host = new NodeKernelHost({ execPrograms: mode === "spawn" ? { "/bin/kernel_allocator_churn_test": churnProgram } : undefined, + rootfsImage, onStdout: (_pid, bytes) => { stdout += new TextDecoder().decode(bytes); }, diff --git a/host/test/kernel-scratch-contract.test.ts b/host/test/kernel-scratch-contract.test.ts index b5fcf7ab0d..fad510b63c 100644 --- a/host/test/kernel-scratch-contract.test.ts +++ b/host/test/kernel-scratch-contract.test.ts @@ -789,6 +789,9 @@ const reviewedScalarKernelExportCalls: AuditAllowance[] = [ reviewedScalarKernelExportCall( "host/src/kernel-worker.ts::CentralizedKernelWorker.getForkCount::kernel-export-direct-use::fn(pid)", ), + reviewedScalarKernelExportCall( + "host/src/kernel-worker.ts::CentralizedKernelWorker.processSecureExec::kernel-export-direct-use::query(pid)", + ), reviewedScalarKernelExportCall( "host/src/kernel-worker.ts::CentralizedKernelWorker.getKernelMemoryPages::kernel-export-direct-use::fn()", ), diff --git a/host/test/node-rootfs-export.test.ts b/host/test/node-rootfs-export.test.ts index 890e3b0cc8..d3dbf050aa 100644 --- a/host/test/node-rootfs-export.test.ts +++ b/host/test/node-rootfs-export.test.ts @@ -250,34 +250,20 @@ describe("NodeKernelHost rootfs export contract", () => { ); it.skipIf(!haveKernel || !haveSpawnSmoke || !haveWasiHello)( - "uses worker-owned exact bytes before a same-path lazy VFS entry", + "uses worker-owned VFS bytes without resolving an ambient executable", async () => { const kernel = new Uint8Array(readFileSync(kernelPath!)); const spawnSmoke = new Uint8Array(readFileSync(spawnSmokePath)); const programSource = new Uint8Array(readFileSync(wasiHelloPath)); - const fs = MemoryFileSystem.create( - new SharedArrayBuffer(8 * 1024 * 1024), - ); - fs.mkdir("/bin", 0o755); - fs.registerLazyFile( + const rootfs = await createExecutableRootfs( "/bin/exact-tool", - "https://packages.example.test/must-not-fetch.wasm", - programSource.byteLength, - 0o755, + programSource, ); - const rootfs = await fs.saveImage(); let stdout = ""; let lazyDownloads = 0; let ambientResolveRequests = 0; const host = new NodeKernelHost({ rootfsImage: rootfs, - rootfsLazyAssets: [{ - url: "https://packages.example.test/must-not-fetch.wasm", - sha256: "6e340b9cffb37a989ca544e6bb780a2c78901d3fb33738768511a30617afa01d", - size: 1, - bytes: new Uint8Array([0]), - }], - execProgramBytes: { "/bin/exact-tool": programSource }, onLazyDownload: () => { lazyDownloads += 1; }, diff --git a/host/test/process-generation-detach-host-parity.test.ts b/host/test/process-generation-detach-host-parity.test.ts index 75d365e558..3ce7c69998 100644 --- a/host/test/process-generation-detach-host-parity.test.ts +++ b/host/test/process-generation-detach-host-parity.test.ts @@ -102,7 +102,7 @@ describe("process generation detach host parity", () => { expect(source).toContain(`"${operation}"`); } expect( - source.match(/processMemoryCreators\s*\.run\(/g), + source.match(/processMemoryCreators\s*\.run(?:UntilCommitted)?\(/g), ).toHaveLength(5); }); diff --git a/host/test/shell-lazy-archive-inputs.test.ts b/host/test/shell-lazy-archive-inputs.test.ts index d161ebe89c..ed4fd23b44 100644 --- a/host/test/shell-lazy-archive-inputs.test.ts +++ b/host/test/shell-lazy-archive-inputs.test.ts @@ -611,19 +611,77 @@ describe("declared shell lazy-archive inputs", () => { "kandelo-dev/tap-core/nethack", ]), ); - for (const bottle of selection.bottles) { - expect(bottle.materialization).toBe( - bottle.name === "homebrew-bootstrap" - ? "homebrew-runtime-support-v1" - : "keg", - ); - expect(bottle.sha256).toMatch(/^[0-9a-f]{64}$/); - expect(bottle.bytes).toBeGreaterThan(0); - expect(bottle.url).toBe( - `https://ghcr.io/v2/kandelo-dev/homebrew-tap-core/${bottle.name}/` + - `blobs/sha256:${bottle.sha256}`, - ); - } + expect( + migrationLock.reviewed_substitutions + .filter( + ({ kind, registry }) => + kind === "formula_identity" && + retiredBundleNames.has( + registry.slice(0, registry.lastIndexOf("@")), + ), + ) + .map(({ kind, registry, formula }) => ({ kind, registry, formula })), + ).toEqual([]); + expect(runtimeSupport.activation).toEqual( + expect.objectContaining({ + base_image_default: "deferred", + roots: ["/usr/bin/brew"], + }), + ); + expect(runtimeSupport.base_formula_order).toEqual( + migrationLock.formula_closure, + ); + expect(runtimeSupport.additional_formula_order).toEqual( + runtimeSupport.formula_order.filter( + (name) => !runtimeSupport.base_formula_order.includes(name), + ), + ); + expect(runtimeSupport.base_formula_order).toContain( + "kandelo-dev/tap-core/ruby", + ); + const auditedFormulae = [ + ...runtimeSupport.availability.local_test_formulae, + ...runtimeSupport.availability.requires_rebuild, + ...runtimeSupport.availability.missing_metadata, + ...runtimeSupport.availability.can_be_deferred, + ]; + expect(new Set(auditedFormulae).size).toBe(auditedFormulae.length); + expect(runtimeSupport.availability.provenance).toEqual( + expect.objectContaining({ + provenance_kind: "local-test", + promotable: false, + published: false, + }), + ); + expect(runtimeSupport.availability.requires_rebuild).toEqual([]); + expect(runtimeSupport.availability.missing_metadata).toEqual([]); + expect(runtimeSupport.availability.can_be_deferred).toEqual([]); + expect(runtimeSupport.deferred_formulae).toEqual([]); + expect(runtimeSupport.lifecycle_installs).toEqual([ + expect.objectContaining({ + tap: "brandonpayton/kandelo-canary", + formula: "m4-canary", + phase: "guest-lifecycle", + image_closure: false, + }), + ]); + expect( + execFileSync( + process.execPath, + [ + join(repoRoot, "scripts/check-homebrew-main-shell-brewfile.mjs"), + brewfilePath, + migrationLockPath, + ], + { cwd: repoRoot, encoding: "utf8" }, + ), + ).toContain( + `${selection.packages.length} reviewed migration roots, ` + + `${migrationLock.formula_closure.length} base Formulae, ` + + `${runtimeSupport.formula_order.length} runtime Formulae, and ` + + `${auditedFormulae.length} audited Formulae; the runtime adds ` + + `${runtimeSupport.additional_formula_order.length} beyond the base`, + ); // The package build consumes the authenticated public selection instead // of an ambient tap checkout and materializes every selected bottle into diff --git a/packages/registry/kernel/build-kernel.sh b/packages/registry/kernel/build-kernel.sh index af86fafc11..a041680655 100755 --- a/packages/registry/kernel/build-kernel.sh +++ b/packages/registry/kernel/build-kernel.sh @@ -76,6 +76,8 @@ wasm_require_exports "$OUT" \ kernel_process_metadata_cancel \ kernel_process_metadata_commit \ kernel_process_metadata_stage \ + kernel_process_secure_exec \ + kernel_publish_spawn_child \ kernel_reap_exited_child \ kernel_remove_process \ kernel_semctl_array_bytes \ @@ -83,7 +85,6 @@ wasm_require_exports "$OUT" \ kernel_set_current_tid \ kernel_set_cwd \ kernel_shmid_ds_bytes \ - kernel_publish_spawn_child \ kernel_spawn_exec_commit \ kernel_spawn_exec_target_prepare \ kernel_spawn_process \ diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index 99830bfed6..eef0ebe33f 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -158,8 +158,8 @@ "lamp": { "manifestSha256": "250b64635d64f8537178188fb5488dbfd2980d79a1c2ffbf346af67008088ba0", "cacheKeys": { - "wasm32": "759b208ced9f41b74ca390a95361e802b8a4b89c4ad1ca36282bbc212e17a22c", - "wasm64": "f19926a167a81a4cabdfedd995a642e62fd8dfe0f21bb7b87ae23f5a1ccb2082" + "wasm32": "23c6df992f9aa1e71eafb0b6fa2828fbb6c4ce1eca8c6eb7cef4011ad090a784", + "wasm64": "ef0af0e0c0a0ab357046cf65c08b724287e9baf496bfc4d5f749e8b023c9de5a" } }, "less": { @@ -312,15 +312,15 @@ "nginx-php-vfs": { "manifestSha256": "97976410cb02f8ba710d856b4ac904bcf976d677b50eefdee39fb64176070d4b", "cacheKeys": { - "wasm32": "cce5fe94610e80a81e6b5f8ed86081d5ae36aebf479fbc1976418c31a6d87842", - "wasm64": "7fcb8caa30cd86d2c403afacffc74d18d4622c7f59744dc038de067794b9684e" + "wasm32": "efb105c205f0fedc92db8ddd3abfc01e6093488c323228c4af0e574442de6169", + "wasm64": "84010e07c2665c1404b30a61e0ae55c4c777bfc61db7d1a7a3096687a1b9d9eb" } }, "nginx-vfs": { "manifestSha256": "46aa2d3250ac5cd0f102a85c3a36a086c7a50ba1f9e05fa1c2aae3d07ad16f10", "cacheKeys": { - "wasm32": "e2917571e02bb7f2c21bf02f8881c65733124d81e1e08390988d1f2de4798d94", - "wasm64": "bed9bcbb29e9b8d7f90b5859c86bfaa9f0c92e81351e52838d6c22c1c1f8369e" + "wasm32": "b08f76f96cc69cc2d5c01469f095fcfd60d8f70787a6139f1de23aa25aac6702", + "wasm64": "1e008873d6bdfa8da071df5ce2ec8099b9ee76e07859dec89ef9984eeebf82aa" } }, "node": { @@ -333,8 +333,8 @@ "node-vfs": { "manifestSha256": "a4a41b2b06da60ed2a89470caf54d390f2981b1e8cf55e07cf95b376e15011d6", "cacheKeys": { - "wasm32": "9781cf92b61c7f4e9870dd8e022968c812ce47dce3068cf60dc3254b5953025a", - "wasm64": "011ca7cb78b171b93b7d3aa43f996d9b0d5e09692ca2443a2cfb4d26cc5115da" + "wasm32": "7e720e5ed98fbfe732da2f89c99b6d6247ffcd6f0135a456685881a315060ea1", + "wasm64": "d73d39fb24dcfebd33764ad7098f7adf7d9ff0fef11d00a3182bed78238d8346" } }, "openssl": { @@ -452,8 +452,8 @@ "shell": { "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", "cacheKeys": { - "wasm32": "958efa1cc35207453d9374f90621b4cd468a9f687148fd0f6d90dfa922afd1f1", - "wasm64": "acbc4017286bc6fad5f4636fd89f95fbd5254c90fd3c66fd129b7633ffc682c5" + "wasm32": "f16a327ad8a9d6e1ccd13b450551b47869d9e56fbdf9548e545a4d3dde47b371", + "wasm64": "d77d117e70b5f0779123498934ebaa586e311846f8cea79aa98afee72061f881" } }, "spidermonkey": { @@ -543,8 +543,8 @@ "wordpress": { "manifestSha256": "36465e0596a06e855a8524eecfd87da98643bc13b37ee12939f5aab44bf33418", "cacheKeys": { - "wasm32": "c575facb2a29e42ea8e05ae749d116394bd6fd902046797f6ad036a45e1ce533", - "wasm64": "146dd354cb918b5def3f9f8f4671865593b6dac198756e967b660f30a4f6b67a" + "wasm32": "3044dcb66449c2b6a9ea7653c74465516e5a1cdf168613119a9352916b850a51", + "wasm64": "5ef957fe986b6d09b0d29a8c945995c9323974ed66061710db0379a87c62cd67" } }, "xz": { @@ -1121,7 +1121,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "759b208ced9f41b74ca390a95361e802b8a4b89c4ad1ca36282bbc212e17a22c" + "wasm32": "23c6df992f9aa1e71eafb0b6fa2828fbb6c4ce1eca8c6eb7cef4011ad090a784" }, "dependencyClosures": { "wasm32": [ @@ -1193,7 +1193,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "958efa1cc35207453d9374f90621b4cd468a9f687148fd0f6d90dfa922afd1f1" + "cacheKey": "f16a327ad8a9d6e1ccd13b450551b47869d9e56fbdf9548e545a4d3dde47b371" }, { "packageName": "sqlite", @@ -1746,7 +1746,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "cce5fe94610e80a81e6b5f8ed86081d5ae36aebf479fbc1976418c31a6d87842" + "wasm32": "efb105c205f0fedc92db8ddd3abfc01e6093488c323228c4af0e574442de6169" }, "dependencyClosures": { "wasm32": [ @@ -1808,7 +1808,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "958efa1cc35207453d9374f90621b4cd468a9f687148fd0f6d90dfa922afd1f1" + "cacheKey": "f16a327ad8a9d6e1ccd13b450551b47869d9e56fbdf9548e545a4d3dde47b371" }, { "packageName": "sqlite", @@ -1838,7 +1838,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e2917571e02bb7f2c21bf02f8881c65733124d81e1e08390988d1f2de4798d94" + "wasm32": "b08f76f96cc69cc2d5c01469f095fcfd60d8f70787a6139f1de23aa25aac6702" }, "dependencyClosures": { "wasm32": [ @@ -1860,7 +1860,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "958efa1cc35207453d9374f90621b4cd468a9f687148fd0f6d90dfa922afd1f1" + "cacheKey": "f16a327ad8a9d6e1ccd13b450551b47869d9e56fbdf9548e545a4d3dde47b371" } ] }, @@ -1922,7 +1922,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "9781cf92b61c7f4e9870dd8e022968c812ce47dce3068cf60dc3254b5953025a" + "wasm32": "7e720e5ed98fbfe732da2f89c99b6d6247ffcd6f0135a456685881a315060ea1" }, "dependencyClosures": { "wasm32": [ @@ -1944,7 +1944,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "958efa1cc35207453d9374f90621b4cd468a9f687148fd0f6d90dfa922afd1f1" + "cacheKey": "f16a327ad8a9d6e1ccd13b450551b47869d9e56fbdf9548e545a4d3dde47b371" }, { "packageName": "spidermonkey", @@ -2728,7 +2728,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "958efa1cc35207453d9374f90621b4cd468a9f687148fd0f6d90dfa922afd1f1" + "wasm32": "f16a327ad8a9d6e1ccd13b450551b47869d9e56fbdf9548e545a4d3dde47b371" }, "dependencyClosures": { "wasm32": [] @@ -3020,7 +3020,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c575facb2a29e42ea8e05ae749d116394bd6fd902046797f6ad036a45e1ce533" + "wasm32": "3044dcb66449c2b6a9ea7653c74465516e5a1cdf168613119a9352916b850a51" }, "dependencyClosures": { "wasm32": [ @@ -3082,7 +3082,7 @@ { "packageName": "shell", "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "958efa1cc35207453d9374f90621b4cd468a9f687148fd0f6d90dfa922afd1f1" + "cacheKey": "f16a327ad8a9d6e1ccd13b450551b47869d9e56fbdf9548e545a4d3dde47b371" }, { "packageName": "sqlite", diff --git a/packages/registry/shell/build.toml b/packages/registry/shell/build.toml index b73ca9d9b4..315012618d 100644 --- a/packages/registry/shell/build.toml +++ b/packages/registry/shell/build.toml @@ -53,6 +53,7 @@ inputs = [ "host/src/vfs/memory-fs.ts", "host/src/vfs/package-deferred-tree-contract.ts", "host/src/vfs/package-deferred-tree.ts", + "host/src/vfs/privileged-projection.ts", "host/src/vfs/sharedfs-vendor.ts", "host/src/vfs/tar.ts", "host/src/vfs/types.ts", diff --git a/programs/p_05_posix_spawn.c b/programs/p_05_posix_spawn.c index b797820768..00719c8e41 100644 --- a/programs/p_05_posix_spawn.c +++ b/programs/p_05_posix_spawn.c @@ -16,37 +16,15 @@ #include #include #include -#include #include #include #include -#include #include extern char **environ; -static int prepare_echo_path(void) { - int fd = open("/tmp/echo", O_WRONLY | O_CREAT | O_TRUNC, 0755); - if (fd < 0) { - printf("FAIL: create /tmp/echo errno=%d\n", errno); - return -1; - } - const char placeholder[] = "placeholder\n"; - if (write(fd, placeholder, sizeof(placeholder) - 1) < 0) { - printf("FAIL: write /tmp/echo errno=%d\n", errno); - close(fd); - return -1; - } - if (fchmod(fd, 0755) != 0) { - printf("FAIL: chmod /tmp/echo errno=%d\n", errno); - close(fd); - return -1; - } - if (close(fd) != 0) { - printf("FAIL: close /tmp/echo errno=%d\n", errno); - return -1; - } - if (setenv("PATH", "/tmp", 1) != 0) { +static int select_staged_echo_path(void) { + if (setenv("PATH", "/bin", 1) != 0) { printf("FAIL: setenv PATH errno=%d\n", errno); return -1; } @@ -54,7 +32,7 @@ static int prepare_echo_path(void) { } int main(void) { - if (prepare_echo_path() != 0) { + if (select_staged_echo_path() != 0) { return 1; } diff --git a/programs/p_09_posix_spawn_fork.c b/programs/p_09_posix_spawn_fork.c index 4b28bb8980..90301f6b34 100644 --- a/programs/p_09_posix_spawn_fork.c +++ b/programs/p_09_posix_spawn_fork.c @@ -16,35 +16,13 @@ #include #include #include -#include #include #include -#include extern char **environ; -static int prepare_echo_path(void) { - int fd = open("/tmp/echo", O_WRONLY | O_CREAT | O_TRUNC, 0755); - if (fd < 0) { - printf("FAIL: create /tmp/echo errno=%d\n", errno); - return -1; - } - const char placeholder[] = "placeholder\n"; - if (write(fd, placeholder, sizeof(placeholder) - 1) < 0) { - printf("FAIL: write /tmp/echo errno=%d\n", errno); - close(fd); - return -1; - } - if (fchmod(fd, 0755) != 0) { - printf("FAIL: chmod /tmp/echo errno=%d\n", errno); - close(fd); - return -1; - } - if (close(fd) != 0) { - printf("FAIL: close /tmp/echo errno=%d\n", errno); - return -1; - } - if (setenv("PATH", "/tmp", 1) != 0) { +static int select_staged_echo_path(void) { + if (setenv("PATH", "/bin", 1) != 0) { printf("FAIL: setenv PATH errno=%d\n", errno); return -1; } @@ -55,7 +33,7 @@ int main(void) { printf("PRE_SPAWN\n"); fflush(stdout); - if (prepare_echo_path() != 0) { + if (select_staged_echo_path() != 0) { return 1; } diff --git a/run.sh b/run.sh index 96759942c7..e46b8d8b31 100755 --- a/run.sh +++ b/run.sh @@ -315,6 +315,8 @@ KERNEL_REQUIRED_EXPORTS=( kernel_process_metadata_cancel kernel_process_metadata_commit kernel_process_metadata_stage + kernel_process_secure_exec + kernel_publish_spawn_child kernel_reap_exited_child kernel_remove_process kernel_semctl_array_bytes @@ -322,7 +324,6 @@ KERNEL_REQUIRED_EXPORTS=( kernel_set_current_tid kernel_set_cwd kernel_shmid_ds_bytes - kernel_publish_spawn_child kernel_spawn_exec_commit kernel_spawn_exec_target_prepare kernel_spawn_process diff --git a/scripts/resolve-binary.bundle.mjs b/scripts/resolve-binary.bundle.mjs index 9e7c09fd29..076903f068 100644 --- a/scripts/resolve-binary.bundle.mjs +++ b/scripts/resolve-binary.bundle.mjs @@ -1,13 +1,13 @@ // Generated by scripts/build-resolve-binary-bundle.sh; do not edit. Third-party notices: resolve-binary.bundle.LICENSES.txt -var Fa=Object.defineProperty;var br=(n,e,t)=>()=>{if(t)throw t[0];try{return n&&(e=n(n=0)),e}catch(r){throw t=[r],r}};var qi=(n,e)=>{for(var t in e)Fa(n,t,{get:e[t],enumerable:!0})};var Bt,Zi,$t,Yi,gn,Xi,ne,ji,Ji,vr,Qi,En,eo,to,ro,no,Pr,kr,Sn,wn,On,An,zn,gt,Ut,Wt,Gt,Pe,io,oo,Fr,ze,so,Nr,xn,Cr,Mr,Et,Ht,Ge,In,Tn,ao,Dr,co,Y,uo,lo,Kr,Vt,fo,po,ho,X,mo,yo,Br,qt,_o,go,Rn,Ln,ot,St,bn,Zt,He,Eo,So,ie,wo,Oo,Ao,zo,Ve=br(()=>{"use strict";Bt="kandelo.wpk_fork.linked_frames",Zi=[75,76,67,70],$t=24,Yi=8,gn=3,Xi=[{bytes:4,chunkHeaderSize:32,nodeHeaderSize:24},{bytes:8,chunkHeaderSize:56,nodeHeaderSize:32}],ne="kandelo.wpk_fork.module_state",ji=1,Ji=[75,70,77,68],vr=24,Qi=8,En=7,eo=1,to=1,ro=1,no=[{bytes:4,chunkHeaderSize:40},{bytes:8,chunkHeaderSize:56}],Pr="__wpk_fork_global_",kr="__wpk_fork_table_",Sn=1,wn=2,On=3,An=4,zn=5,gt=6,Ut=7,Wt=8,Gt=9,Pe="kandelo.wpk_fork.capabilities",io=1,oo=7,Fr=4,ze="kandelo.wpk_fork.exception_codec",so=1,Nr=8,xn=16,Cr="env",Mr="__wpk_fork_unwind",Et="kandelo.wpk_fork.unwind_transport",Ht="__wpk_fork_static_root_catalog",Ge="kandelo.wpk_fork.static_root_catalog",In=1,Tn=0,ao=1,Dr=12,co=[75,70,83,82],Y="kandelo.wpk_fork.imported_globals",uo=[75,70,73,71],lo=1,Kr=16,Vt=24,fo=1,po=2,ho=3,X="kandelo.wpk_fork.imported_tables",mo=[75,70,73,84],yo=1,Br=16,qt=24,_o=1,go=1,Rn="env",Ln="__wpk_fork_module_activation",ot={module:"kernel",name:"kernel_fork",params:["i32"],results:["i32"]},St=[{module:"env",name:"__wpk_fork_frame_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_frame_next",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_peek",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_reserve",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_record_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_module_state_record_find",params:["i32","i32","i32","i32"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_record_reserve",params:["i32","i32","i32","ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_table_dirty_count",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_module_state_table_dirty_mark",params:["i32","i64","i64"],results:[]},{module:"env",name:"__wpk_fork_module_state_table_dirty_page",params:["i32","i32"],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_mutation_abort",params:[],results:[]},{module:"env",name:"__wpk_fork_module_state_table_mutation_begin",params:[],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_mutation_commit",params:["i32","i64","i64"],results:[]},{module:"env",name:"__wpk_fork_module_state_table_reconcile",params:[],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_state_owned",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_decode_funcref",params:["i32"],results:["funcref"]},{module:"env",name:"__wpk_fork_ref_encode_funcref",params:["funcref"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_broker_encode",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_broker_throw_recipe",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_cache_index",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_claim",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_define",params:["i32","i32","i32","i32","ptr","i32","ptr","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_ingress_throw",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_load",params:["i32","i32","i32","i32","ptr","i32","ptr","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_lookup",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_route",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_broker_encode",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_capture_layout",params:["i32","i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_claim",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_define",params:["i32","i32","i32","i32","i32","ptr","i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_i31",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_load",params:["i32","i32","i32","i32","i32","ptr","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_lookup",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_payload_len",params:["i32","i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_provenance_begin",params:["i32","i32","i32","i32","i64","i64","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_provenance_end",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_provenance_ref",params:["i32","i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_route",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_scratch_release",params:["ptr","ptr"],results:[]},{module:"env",name:"__wpk_fork_ref_scratch_reserve",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_ref_vector_append",params:["i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_vector_begin",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_vector_finish",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_vector_get",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_resume_peek",params:["i32"],results:["i32"]}],bn=[{module:"env",name:"__wpk_fork_ref_gc_transit",table64:!1,element:"anyref",minimum:1,maximum:null},{module:"env",name:"__wpk_fork_resume_table",table64:!1,element:"funcref",minimum:1,maximum:null}],Zt=[{name:"__wpk_fork_exception_materialize",params:["i32"],results:[]},{name:"__wpk_fork_ref_decode_exnref",params:["i32"],results:["exnref"]},{name:"__wpk_fork_ref_encode_exnref",params:["exnref"],results:["i32"]},{name:"__wpk_fork_ref_exn_abort",params:[],results:[]},{name:"__wpk_fork_ref_exn_clear",params:[],results:[]},{name:"__wpk_fork_ref_exn_encode_ingress",params:["i32"],results:["i32"]},{name:"__wpk_fork_ref_exn_throw_recipe",params:["i32"],results:[]},{name:"__wpk_fork_ref_exn_throw_slot",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_allocate",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_encode_slot",params:["i32"],results:["i32"]},{name:"__wpk_fork_ref_gc_fill",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_probe",params:["i32"],results:["i64"]},{name:"__wpk_fork_ref_gc_publish_externref",params:["i32","externref"],results:[]},{name:"__wpk_fork_static_root_harvest",params:[],results:[]},{name:"wpk_fork_abort_begin",params:["ptr"],results:[]},{name:"wpk_fork_abort_end",params:[],results:[]},{name:"wpk_fork_module_bootstrap",params:[],results:[]},{name:"wpk_fork_module_state_finish_restore",params:["i32"],results:[]},{name:"wpk_fork_module_state_restore",params:["i32"],results:[]},{name:"wpk_fork_module_state_save",params:["i32"],results:[]},{name:"wpk_fork_module_table_state_restore",params:["i32"],results:[]},{name:"wpk_fork_module_table_state_save",params:["i32"],results:[]},{name:"wpk_fork_module_thread_bootstrap",params:[],results:[]},{name:"wpk_fork_rewind_begin",params:["ptr"],results:[]},{name:"wpk_fork_rewind_end",params:[],results:[]},{name:"wpk_fork_state",params:[],results:["i32"]},{name:"wpk_fork_unwind_begin",params:["ptr"],results:[]},{name:"wpk_fork_unwind_end",params:[],results:[]}],He={O_RDONLY:0,O_WRONLY:1,O_RDWR:2,O_ACCMODE:3,O_CREAT:64,O_EXCL:128,O_NOCTTY:256,O_TRUNC:512,O_APPEND:1024,O_NONBLOCK:2048,O_ASYNC:8192,O_DIRECTORY:65536,O_NOFOLLOW:131072,O_CLOEXEC:524288,O_PATH:2097152,O_CLOFORK:8388608},Eo={F_OK:0,R_OK:4,W_OK:2,X_OK:1},So={ST_NOSUID:2},ie={S_IFMT:61440,S_IFSOCK:49152,S_IFLNK:40960,S_IFREG:32768,S_IFBLK:24576,S_IFDIR:16384,S_IFCHR:8192,S_IFIFO:4096,S_ISUID:2048,S_ISGID:1024,S_ISVTX:512,S_IRWXU:448,S_IRUSR:256,S_IWUSR:128,S_IXUSR:64,S_IRWXG:56,S_IRGRP:32,S_IWGRP:16,S_IXGRP:8,S_IRWXO:7,S_IROTH:4,S_IWOTH:2,S_IXOTH:1,S_MODE_BITS:4095},wo={DT_UNKNOWN:0,DT_FIFO:1,DT_CHR:2,DT_DIR:4,DT_BLK:6,DT_REG:8,DT_LNK:10,DT_SOCK:12},Oo=4096,Ao=["__abi_version","kernel_alloc_scratch","kernel_blocking_retry_release","kernel_blocking_retry_token","kernel_commit_process_exit","kernel_create_process","kernel_create_process_with_stdio","kernel_dequeue_signal","kernel_exec_commit","kernel_exec_target_cancel","kernel_exec_target_prepare","kernel_exec_target_read","kernel_exec_target_size","kernel_fork_process","kernel_get_cwd","kernel_get_dirfd_path","kernel_get_fd_path","kernel_get_parent_pid","kernel_get_process_exit_signal","kernel_get_process_state","kernel_get_socket_timeout_ms","kernel_handle_channel","kernel_has_sa_nocldstop","kernel_host_adapter_manifest_len","kernel_host_adapter_manifest_ptr","kernel_ipc_shm_lookup_mapping_for_task","kernel_ipc_shm_record_mapping_for_process","kernel_ipc_shm_record_mapping_for_task","kernel_ipc_shmat_for_process","kernel_ipc_shmat_for_task","kernel_ipc_shmdt_addr_for_process","kernel_ipc_shmdt_addr_for_task","kernel_ipc_shmdt_for_process","kernel_ipc_shmdt_for_task","kernel_is_fd_nonblock","kernel_mark_process_signaled","kernel_mq_descriptor_msgsize","kernel_msqid_ds_bytes","kernel_pcm_claim_transport","kernel_pcm_clock_update","kernel_pcm_reconcile","kernel_pcm_transport_len","kernel_pcm_transport_ptr","kernel_pick_signal_target_tid","kernel_pick_tcp_listener_target","kernel_pipe_has_readers","kernel_posix_timer_fire","kernel_process_metadata_begin","kernel_process_metadata_cancel","kernel_process_metadata_commit","kernel_process_metadata_stage","kernel_reap_exited_child","kernel_remove_process","kernel_semctl_array_bytes","kernel_semid_ds_bytes","kernel_set_current_tid","kernel_set_cwd","kernel_shmid_ds_bytes","kernel_publish_spawn_child","kernel_spawn_exec_commit","kernel_spawn_exec_target_prepare","kernel_spawn_process","kernel_spawn_reserved_process","kernel_spawn_scratch_begin","kernel_spawn_scratch_cancel","kernel_spawn_scratch_capacity","kernel_spawn_scratch_pointer","kernel_spawn_scratch_retained_capacity","kernel_take_process_timer_cleanup","kernel_thread_exit","kernel_thread_has_deliverable","kernel_transfer_channel_execute","kernel_transfer_io_execute","kernel_transfer_scratch_begin","kernel_transfer_scratch_cancel","kernel_transfer_scratch_capacity","kernel_transfer_scratch_pointer","kernel_validate_task","kernel_wait_child_poll"],zo={LINK_MAX:0,MAX_CANON:1,MAX_INPUT:2,NAME_MAX:3,PATH_MAX:4,PIPE_BUF:5,CHOWN_RESTRICTED:6,NO_TRUNC:7,VDISABLE:8,SYNC_IO:9,ASYNC_IO:10,PRIO_IO:11,SOCK_MAXBUF:12,FILESIZEBITS:13,REC_INCR_XFER_SIZE:14,REC_MAX_XFER_SIZE:15,REC_MIN_XFER_SIZE:16,REC_XFER_ALIGN:17,ALLOC_SIZE_MIN:18,SYMLINK_MAX:19,POSIX2_SYMLINKS:20,FALLOC:21,TEXTDOMAIN_MAX:22,TIMESTAMP_RESOLUTION:23}});import{createRequire as Iu}from"module";function Ss(n,e){return Es(n,{i:2},e&&e.out,e&&e.dictionary)}var Tu,Pt,Ru,Lu,ae,bt,bu,fs,ps,vu,hs,Pt,ms,Pu,ys,ku,Af,ai,Ne,K,mr,yr,K,K,K,K,_s,K,Fu,Nu,oi,Te,si,gs,Jr,Cu,ge,Es,Mu,Du,vt,ws,Ku,Bu,ci=br(()=>{Tu=Iu("/");try{Pt=Tu("worker_threads"),Ru=Pt.Worker,Lu=Pt.isMarkedAsUntransferable}catch{}ae=Uint8Array,bt=Uint16Array,bu=Int32Array,fs=new ae([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),ps=new ae([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),vu=new ae([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),hs=function(n,e){for(var t=new bt(31),r=0;r<31;++r)t[r]=e+=1<>1|(K&21845)<<1,Ne=(Ne&52428)>>2|(Ne&13107)<<2,Ne=(Ne&61680)>>4|(Ne&3855)<<4,ai[K]=((Ne&65280)>>8|(Ne&255)<<8)>>1;mr=(function(n,e,t){for(var r=n.length,i=0,o=new bt(e);i>c]=u}else for(a=new bt(r),i=0;i>15-n[i]);return a}),yr=new ae(288);for(K=0;K<144;++K)yr[K]=8;for(K=144;K<256;++K)yr[K]=9;for(K=256;K<280;++K)yr[K]=7;for(K=280;K<288;++K)yr[K]=8;_s=new ae(32);for(K=0;K<32;++K)_s[K]=5;Fu=mr(yr,9,1),Nu=mr(_s,5,1),oi=function(n){for(var e=n[0],t=1;te&&(e=n[t]);return e},Te=function(n,e,t){var r=e/8|0;return(n[r]|n[r+1]<<8)>>(e&7)&t},si=function(n,e){var t=e/8|0;return(n[t]|n[t+1]<<8|n[t+2]<<16)>>(e&7)},gs=function(n){return(n+7)/8|0},Jr=function(n,e,t){return(e==null||e<0)&&(e=0),(t==null||t>n.length)&&(t=n.length),new ae(n.subarray(e,t))},Cu=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],ge=function(n,e,t){var r=new Error(e||Cu[n]);if(r.code=n,Error.captureStackTrace&&Error.captureStackTrace(r,ge),!t)throw r;return r},Es=function(n,e,t,r){var i=n.length,o=r?r.length:0;if(!i||e.f&&!e.l)return t||new ae(0);var s=!t,a=s||e.i!=2,c=e.i;s&&(t=new ae(i*3));var u=function(Ue){var We=t.length;if(Ue>We){var Lr=new ae(Math.max(We*2,Ue));Lr.set(t),t=Lr}},l=e.f||0,d=e.p||0,h=e.b||0,m=e.l,f=e.d,p=e.m,_=e.n,y=i*8;do{if(!m){l=Te(n,d,1);var g=Te(n,d+1,3);if(d+=3,g)if(g==1)m=Fu,f=Nu,p=9,_=5;else if(g==2){var w=Te(n,d,31)+257,z=Te(n,d+10,15)+4,x=w+Te(n,d+5,31)+1;d+=14;for(var I=new ae(x),R=new ae(19),L=0;L>4;if(E<16)I[L++]=E;else{var G=0,ue=0;for(E==16?(ue=3+Te(n,d,3),d+=2,G=I[L-1]):E==17?(ue=3+Te(n,d,7),d+=3):E==18&&(ue=11+Te(n,d,127),d+=7);ue--;)I[L++]=G}}var Oe=I.subarray(0,w),M=I.subarray(w);p=oi(Oe),_=oi(M),m=mr(Oe,p,1),f=mr(M,_,1)}else ge(1);else{var E=gs(d)+4,O=n[E-4]|n[E-3]<<8,S=E+O;if(S>i){c&&ge(0);break}a&&u(h+O),t.set(n.subarray(E,S),h),e.b=h+=O,e.p=d=S*8,e.f=l;continue}if(d>y){c&&ge(0);break}}a&&u(h+131072);for(var de=(1<>4;if(d+=G&15,d>y){c&&ge(0);break}if(G||ge(2),ve<256)t[h++]=ve;else if(ve==256){nt=d,m=null;break}else{var Kt=ve-254;if(ve>264){var L=ve-257,Be=fs[L];Kt=Te(n,d,(1<>4;yt||ge(3),d+=yt&15;var M=ku[Ae];if(Ae>3){var Be=ps[Ae];M+=si(n,d)&(1<y){c&&ge(0);break}a&&u(h+131072);var $e=h+Kt;if(h>3&1)+(e>>4&1);r>0;r-=!n[t++]);return t+(e&2)},vt=(function(){function n(e,t){typeof e=="function"&&(t=e,e={}),this.ondata=t;var r=e&&e.dictionary&&e.dictionary.subarray(-32768);this.s={i:0,b:r?r.length:0},this.o=new ae(32768),this.p=new ae(0),r&&this.o.set(r)}return n.prototype.e=function(e){if(this.ondata||ge(5),this.d&&ge(4),!this.p.length)this.p=e;else if(e.length){var t=new ae(this.p.length+e.length);t.set(this.p),t.set(e,this.p.length),this.p=t}},n.prototype.c=function(e){this.s.i=+(this.d=e||!1);var t=this.s.b,r=Es(this.p,this.s,this.o);this.ondata(Jr(r,t,this.s.b),this.d),this.o=Jr(r,this.s.b-32768),this.s.b=this.o.length,this.p=Jr(this.p,this.s.p/8|0),this.s.p&=7},n.prototype.push=function(e,t){this.e(e),this.c(t)},n})();ws=(function(){function n(e,t){this.v=1,this.r=0,vt.call(this,e,t)}return n.prototype.push=function(e,t){if(vt.prototype.e.call(this,e),this.r+=e.length,this.v){var r=this.p.subarray(this.v-1),i=r.length>3?Du(r):4;if(i>r.length){if(!t)return}else this.v>1&&this.onmember&&this.onmember(this.r-r.length);this.p=r.subarray(i),this.v=0}vt.prototype.c.call(this,0),this.s.f&&!this.s.l?(this.v=gs(this.s.p)+9,this.s={i:0},this.o=new ae(0),this.push(new ae(0),t)):t&&vt.prototype.c.call(this,t)},n})(),Ku=typeof TextDecoder<"u"&&new TextDecoder,Bu=0;try{Ku.decode(Mu,{stream:!0}),Bu=1}catch{}});var li={};qi(li,{extractZipEntry:()=>Zu,extractZipEntryBounded:()=>Yu,fetchZipCentralDirectory:()=>ju,parseZipCentralDirectory:()=>_r});function Ts(n){let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=Math.max(0,n.length-zs);for(let r=n.length-Wu;r>=t;r--)if(e.getUint32(r,!0)===$u)return r;throw new Error("Zip EOCD record not found")}function _r(n){let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=Ts(n),r=e.getUint16(t+10,!0),i=e.getUint32(t+16,!0),o=[],s=i;for(let a=0;a>8,O;E===Os?O=p>>16&65535:g.startsWith("bin/")||g.startsWith("sbin/")||g.includes("/bin/")||g.includes("/sbin/")?O=493:O=420;let S=g.endsWith("/"),w=E===Os&&(O&Hu)===Gu;o.push({fileName:g,fileNameBytes:y,compressedSize:l,uncompressedSize:d,compressionMethod:u,localHeaderOffset:_,mode:O,isDirectory:S,isSymlink:w,externalAttrs:p,creatorOS:E}),s+=ui+h+m+f}return o}function Rs(n,e){if(n.byteLength!==e.byteLength)return!1;for(let t=0;t{if(a.byteLength>t-o)throw new Error(`ZIP member ${e.fileName} expands beyond ${t} bytes`);i.set(a,o),o+=a.byteLength}).push(r,!0),o!==t)throw new Error(`ZIP member ${e.fileName} expanded ${o} bytes, expected ${t}`);return i}function Xu(n,e){let t=new DataView(n.buffer,n.byteOffset,n.byteLength),r=e.localHeaderOffset;if(r<0||r>n.byteLength-di||t.getUint32(r,!0)!==As)throw new Error(`Invalid local file header signature at offset ${r}`);let i=t.getUint16(r+8,!0),o=t.getUint16(r+26,!0),s=t.getUint16(r+28,!0),a=r+di,c=a+o+s,u=c+e.compressedSize;if(i!==e.compressionMethod||cn.byteLength||!Rs(n.subarray(a,a+o),e.fileNameBytes))throw new Error(`ZIP member ${e.fileName} has inconsistent local metadata`);return n.subarray(c,u)}async function ju(n){let e=await fetch(n,{method:"HEAD"});if(!e.ok)throw new Error(`HEAD request failed: ${e.status} ${e.statusText}`);let t=parseInt(e.headers.get("content-length")||"0",10),r=e.headers.get("accept-ranges");if(!t||r!=="bytes"){let y=await fetch(n);if(!y.ok)throw new Error(`Fetch failed: ${y.status} ${y.statusText}`);let g=new Uint8Array(await y.arrayBuffer());return{entries:_r(g),totalSize:g.length}}let i=Math.min(t,zs),o=t-i,s=await fetch(n,{headers:{Range:`bytes=${o}-${t-1}`}});if(s.status!==206){let y=await fetch(n);if(!y.ok)throw new Error(`Fetch failed: ${y.status} ${y.statusText}`);let g=new Uint8Array(await y.arrayBuffer());return{entries:_r(g),totalSize:g.length}}let a=new Uint8Array(await s.arrayBuffer()),c=new DataView(a.buffer,a.byteOffset,a.byteLength),u=Ts(a),l=c.getUint32(u+12,!0),d=c.getUint32(u+16,!0);if(d>=o){let y=t,g=new Uint8Array(y);return g.set(a,o),{entries:_r(g),totalSize:y}}let h=d+l-1,m=await fetch(n,{headers:{Range:`bytes=${d}-${h}`}});if(m.status!==206)throw new Error(`Range request for CD failed: ${m.status}`);let f=new Uint8Array(await m.arrayBuffer()),p=t,_=new Uint8Array(p);return _.set(f,d),_.set(a,o),{entries:_r(_),totalSize:p}}var $u,Uu,As,zs,Wu,ui,di,xs,Is,Os,Gu,Hu,Vu,qu,fi=br(()=>{"use strict";ci();Ve();$u=101010256,Uu=33639248,As=67324752,zs=65557,Wu=22,ui=46,di=30,xs=0,Is=8,Os=3,{S_IFLNK:Gu,S_IFMT:Hu}=ie,Vu=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),qu=new TextEncoder});var Ns={};qi(Ns,{DEFAULT_TAR_GZIP_LIMITS:()=>Fs,TarParseError:()=>b,parseTarGzip:()=>td});function td(n,e={}){let t=e.label??"TAR gzip archive",r=nd(e.limits,t);if(n.byteLength===0||n.byteLength>r.maxCompressedBytes)throw new b(`${t}: compressed byte count ${n.byteLength} is outside 1..${r.maxCompressedBytes}`);let i=id(n,t);if(i===0||i>r.maxUncompressedBytes)throw new b(`${t}: declared uncompressed byte count ${i} is outside 1..${r.maxUncompressedBytes}`);let o=od(n,t,i);if(o.byteLength!==i)throw new b(`${t}: gzip expanded to ${o.byteLength} bytes, expected ${i}`);let s=new DataView(n.buffer,n.byteOffset,n.byteLength).getUint32(n.byteLength-8,!0);if(sd(o)!==s)throw new b(`${t}: gzip CRC32 mismatch`);return rd(o,t,r)}function rd(n,e,t){if(n.byteLength%Ce!==0)throw new b(`${e}: TAR byte count is not block-aligned`);let r=[],i=0,o=0,s=0,a=null,c={},u=!1;for(;i+Ce<=n.byteLength;){let l=n.subarray(i,i+Ce);if(i+=Ce,hi(l)){if(i+Ce>n.byteLength)throw new b(`${e}: TAR end marker is truncated`);let S=n.subarray(i,i+Ce);if(!hi(S))throw new b(`${e}: TAR has only one zero end block`);if(i+=Ce,!hi(n.subarray(i)))throw new b(`${e}: TAR has nonzero data after its end marker`);u=!0;break}dd(l,e);let d=gr(l,156,1,e)||"0",h=yi(l,124,12,`${e}: TAR entry size`),m=yi(l,100,8,`${e}: TAR entry mode`)&Ju,f=ld(l,e,t.maxPathBytes),p=gr(l,157,100,e);if(d==="x"||d==="g"){if(s+=1,s>t.maxEntries+1)throw new b(`${e}: TAR extension header count exceeds ${t.maxEntries+1}`);let S=bs(n,i,h,e);i=vs(i,h,n.byteLength,e);let w=cd(S,e,t);d==="x"?a=w:c={...c,...w};continue}if(o+=1,o>t.maxEntries)throw new b(`${e}: TAR entry count exceeds ${t.maxEntries}`);let _={...c,...a??{}};a=null;let y=_.size===void 0?h:ud(_.size,`${e}: PAX entry size`),g=bs(n,i,y,e);i=vs(i,y,n.byteLength,e);let E=mi(_.path??f,e,t.maxPathBytes),O=_.linkpath??p;switch(d){case"0":case"\0":r.push({path:E,type:"file",mode:m,data:g});break;case"5":pi(y,e,"directory",E),r.push({path:E,type:"directory",mode:m});break;case"2":pi(y,e,"symlink",E),Ps(O,e,E,t.maxLinkBytes,!1),r.push({path:E,type:"symlink",mode:m,linkName:O});break;case"1":pi(y,e,"hardlink",E),Ps(O,e,E,t.maxLinkBytes,!0),r.push({path:E,type:"hardlink",mode:m,linkName:mi(O,`${e}: hardlink target`,t.maxPathBytes)});break;case"3":case"4":case"6":throw new b(`${e}: unsupported TAR device/FIFO entry ${E}`);default:throw new b(`${e}: unsupported TAR entry type ${JSON.stringify(d)} for ${E}`)}}if(!u)throw new b(`${e}: TAR is missing its two-block end marker`);if(a!==null)throw new b(`${e}: local PAX header has no following entry`);return r}function nd(n,e){let t={...Fs,...n};for(let[r,i]of Object.entries(t))if(!Number.isSafeInteger(i)||i<=0)throw new b(`${e}: ${r} must be a positive safe integer`);return t}function id(n,e){if(n.byteLength<18||n[0]!==31||n[1]!==139||n[2]!==8)throw new b(`${e}: invalid gzip header`);return new DataView(n.buffer,n.byteOffset,n.byteLength).getUint32(n.byteLength-4,!0)}function od(n,e,t){let r=new Uint8Array(t),i=0,o=!1,s=new ws(a=>{if(a.byteLength>t-i)throw new b(`${e}: gzip expansion exceeds its declared ${t} bytes`);r.set(a,i),i+=a.byteLength});s.onmember=()=>{throw o=!0,new b(`${e}: concatenated gzip members are unsupported`)};try{s.push(n,!0)}catch(a){throw a instanceof b?a:new b(`${e}: cannot gunzip archive: ${pd(a)}`)}if(o)throw new b(`${e}: concatenated gzip members are unsupported`);return r.subarray(0,i)}function sd(n){let e=4294967295;for(let t of n)e=ed[(e^t)&255]^e>>>8;return(e^4294967295)>>>0}function ad(){let n=new Uint32Array(256);for(let e=0;e>>1^((t&1)===0?0:3988292384);n[e]=t>>>0}return n}function bs(n,e,t,r){if(t>n.byteLength-e)throw new b(`${r}: TAR entry is truncated`);return n.subarray(e,e+t)}function vs(n,e,t,r){let o=Math.ceil(e/Ce)*Ce;if(!Number.isSafeInteger(o)||o>t-n)throw new b(`${r}: TAR entry padding is truncated`);return n+o}function cd(n,e,t){let r={},i=0;for(;i9)throw new b(`${e}: invalid PAX record length`);if(s=s*10+p,!Number.isSafeInteger(s))throw new b(`${e}: invalid PAX record length`)}let a=i+s;if(s<=o-i+2||a>n.byteLength||n[a-1]!==10)throw new b(`${e}: truncated PAX record`);let c=o+1;for(;c=a-1)throw new b(`${e}: invalid PAX record`);let u=n.subarray(o+1,c);if(u.byteLength>256)throw new b(`${e}: PAX record key is too long`);let l=_i(u,`${e}: PAX record key`),d=n.subarray(c+1,a-1),h=l==="path"?t.maxPathBytes:l==="linkpath"?t.maxLinkBytes:l==="size"?32:0;if(h===0){i=a;continue}if(d.byteLength>h)throw new b(`${e}: PAX ${l} value is too long`);let m=_i(d,`${e}: PAX record value`);r[l]=m,i=a}return r}function ud(n,e){if(!/^(0|[1-9][0-9]*)$/.test(n))throw new b(`${e} is invalid`);let t=Number(n);if(!Number.isSafeInteger(t)||t<0)throw new b(`${e} is invalid`);return t}function dd(n,e){let t=yi(n,148,8,`${e}: TAR checksum`),r=0;for(let i=0;i=148&&i<156?32:n[i];if(t!==r)throw new b(`${e}: TAR checksum mismatch`)}function ld(n,e,t){let r=gr(n,0,100,e),i=gr(n,345,155,e);return mi(i?`${i}/${r}`:r,e,t)}function mi(n,e,t){let r=n;for(;r.startsWith("./");)r=r.slice(2);return r=r.replace(/\/+$/g,""),fd(r,`${e}: TAR path`,t),r}function gr(n,e,t,r){let i=e,o=e+t;for(;ir||n.includes("\0"))throw new b(`${e}: link target for ${t} is invalid`);if(i&&n.includes("\\"))throw new b(`${e}: hardlink target for ${t} is invalid`)}function fd(n,e,t){if(n.length===0||n.startsWith("/")||n.includes("\0")||n.includes("\\")||ks.encode(n).byteLength>t)throw new b(`${e} ${JSON.stringify(n)} must be a bounded relative POSIX path`);for(let r of n.split("/"))if(r.length===0||r==="."||r==="..")throw new b(`${e} ${JSON.stringify(n)} contains an unsafe path segment`)}function hi(n){for(let e of n)if(e!==0)return!1;return!0}function _i(n,e){try{return Qu.decode(n)}catch{throw new b(`${e} contains non-UTF-8 text`)}}function pd(n){return n instanceof Error?n.message:String(n)}var Ce,Ju,Ls,Qu,ks,ed,Fs,b,Cs=br(()=>{"use strict";ci();Ve();Ce=512,Ju=ie.S_MODE_BITS,Ls=1024*1024,Qu=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),ks=new TextEncoder,ed=ad(),Fs=Object.freeze({maxCompressedBytes:256*Ls,maxUncompressedBytes:512*Ls,maxEntries:1e5,maxPathBytes:4096,maxLinkBytes:65536}),b=class extends Error{constructor(e){super(e),this.name="TarParseError"}}});import{existsSync as xr,lstatSync as yn,readdirSync as Oa,readFileSync as pt,realpathSync as Le,statSync as rt}from"node:fs";import{createHash as Aa}from"node:crypto";import{spawnSync as Di}from"node:child_process";import{basename as Sl,dirname as Tr,isAbsolute as _n,join as U,relative as wl,resolve as Re,sep as Ol}from"node:path";import{fileURLToPath as Al}from"node:url";Ve();var Ca=Uint8Array.from(co);function T(n,e){let t=0,r=0,i=e;for(;;){let o=n[i++];if(t|=(o&127)<=n.length)throw new Error(`${r} is truncated`);if((n[e++]&128)===0)return e}throw new Error(`${r} has an overlong LEB128 encoding`)}function xe(n,e,t){if(e>=n.length)throw new Error(`${t} is truncated`);let r=n[e++];switch(r){case 127:case 126:case 125:case 124:case 123:case 117:case 116:case 115:case 114:case 113:case 112:case 111:case 110:case 109:case 108:case 107:case 106:case 105:case 104:return{code:r,shared:!1,next:e};case 98:case 99:case 100:{let i=n[e]===101;i&&e++;let o=vo(n,e,5,`${t} heap type`),[s]=bo(n,e);return{code:r,heapType:Number(s),shared:i,next:o}}default:throw new Error(`${t} has unknown value type 0x${r.toString(16)}`)}}function Ma(n,e,t){return n[e]===120||n[e]===119?{code:n[e],shared:!1,next:e+1}:xe(n,e,t)}function Da(n,e){let t=n[e];if(t===64||t===127||t===126||t===125||t===124||t===123||t===112||t===111)return e+1;let[,r]=Fn(n,e);return e+r}function Ka(n,e,t){let[r,i]=T(n,e);e+=i;let o=[],s=[];for(let d=0;d=n.length)throw new Error(`${t} mutability is truncated`);let i=n[e++];if(i!==0&&i!==1)throw new Error(`${t} has invalid mutability ${i}`);return e}function Ba(n,e,t,r){if(e===101){if(t>=n.length)throw new Error(`${r} shared type is truncated`);e=n[t++]}for(let i of[76,77]){if(e!==i)continue;let[,o]=T(n,t);if(t+=o,t>=n.length)throw new Error(`${r} descriptor is truncated`);e=n[t++]}if(e===96)return Ka(n,t,r);if(e===95){let[i,o]=T(n,t);t+=o;for(let s=0;s=n.length)throw new Error(`${t} is truncated`);let r=n[e++];if(r===79||r===80){let[i,o]=T(n,e);e+=o;for(let s=0;s=n.length)throw new Error(`${t} body is truncated`);r=n[e++]}return Ba(n,r,e,t)}function $a(n,e){let[t,r]=T(n,e);e+=r;let i=[];for(let o=0;o=21&&r<=34?Xt(e,t):r===84||r>=92&&r<=99||r>=112&&r<=123||r>=124&&r<=131||r>=156&&r<=159?t+1:t:n===254?r===0||r===1||r===2?Xt(e,t):r===3?t:r>=16&&r<=79?Xt(e,t):null:null}function Wa(n,e,t){let[r,i]=T(n,e);e+=i+r;let[o,s]=T(n,e);e+=s+o;let a=n[e++];if(a===0){t.funcImports++;let[,c]=T(n,e);e+=c}else if(a===1)e=xe(n,e,"table import type").next,e=Ze(n,e).next;else if(a===2)e=Ze(n,e).next;else if(a===3)t.globalImports++,e=xe(n,e,"global import type").next,e++;else if(a===4){e++;let[,c]=T(n,e);e+=c}return e}function $r(n){return n.length>=8&&n[0]===0&&n[1]===97&&n[2]===115&&n[3]===109}function qe(n,e){let[t,r]=T(n,e);return e+=r,[new TextDecoder().decode(n.subarray(e,e+t)),e+t]}function Ga(n,e){if(e.length===0)return!0;let t=new TextEncoder().encode(e);e:for(let r=0;r<=n.length-t.length;r++){for(let i=0;in);function To(n,e){switch(n.code){case 127:return Sn;case 126:return wn;case 125:return On;case 124:return An;case 123:return zn;case 112:case 115:return gt;case 111:case 114:return Ut;case 105:case 116:return Wt;case 104:case 106:case 107:case 108:case 109:case 110:case 113:case 117:return Gt;case 98:case 99:case 100:{let t=n.heapType;return t===void 0?null:t===-16||t===-13?gt:t===-17||t===-14?Ut:t===-23||t===-12?Wt:t>=0&&e[t]!==void 0?gt:Gt}default:return null}}function vn(n,e,t){if(!t)throw new Error(`function ${e} refers to an unknown type`);let r=n.get(e)??[];r.push(t),n.set(e,r)}function Yt(n,e,t){let r=n.get(e)??[];r.push(t),n.set(e,r)}function Ze(n,e){let[t,r]=T(n,e);e+=r;let[i,o]=T(n,e);e+=o;let s=null;if((t&1)!==0){let[a,c]=T(n,e);e+=c,s=a}return{flags:t,minimum:i,maximum:s,next:e}}function Va(n){let e=new Uint8Array(n);if(!$r(e))throw new Error("not a wasm binary");let t=[],r=[],i=[],o={functionTypes:t,functionTypeIndices:r,functionImports:new Map,functionImportEntries:[],globalImports:new Map,tableImports:new Map,tables:[],tagImports:new Map,functionExports:new Map,globalExports:new Map,tableExports:new Map,exports:new Map,memoryPointerWidths:[],forkCapabilities:[],linkedFrameDescriptors:[],exceptionCodecDescriptors:[],importedGlobalsDescriptors:[],importedTablesDescriptors:[],moduleStateDescriptors:[],staticRootDescriptors:[],unwindTransportDescriptors:[],nativeStartCount:0,importsKernelFork:!1},s=0,a=0,c=8;for(;ce.length)throw new Error("wasm section exceeds file size");let f=h,p=!1;if(u===0){let[_,y]=qe(e,f);_===Bt?o.linkedFrameDescriptors.push(e.slice(y,m)):_===Pe?o.forkCapabilities.push(e.slice(y,m)):_===ze?o.exceptionCodecDescriptors.push(e.slice(y,m)):_===Y?o.importedGlobalsDescriptors.push(e.slice(y,m)):_===X?o.importedTablesDescriptors.push(e.slice(y,m)):_===ne?o.moduleStateDescriptors.push(e.slice(y,m)):_===Ge?o.staticRootDescriptors.push(e.slice(y,m)):_===Et&&o.unwindTransportDescriptors.push(e.slice(y,m))}else if(u===1){p=!0;let _=$a(e,f);t.push(..._.types),f=_.next}else if(u===2){p=!0;let[_,y]=T(e,f);f+=y;for(let g=0;g<_;g++){let[E,O]=qe(e,f),[S,w]=qe(e,O);f=w;let z=e[f++];if(z===0){let[x,I]=T(e,f);f+=I;let R=r.length;r.push(x);let L=`${E}.${S}`,v=t[x];vn(o.functionImports,L,v),o.functionImportEntries.push({module:E,name:S,importOrdinal:g,functionIndex:R,signature:v}),L==="kernel.kernel_fork"&&(o.importsKernelFork=!0)}else if(z===1){let x=xe(e,f,`table import ${E}.${S}`);f=x.next;let I=Ze(e,f);f=I.next,Yt(o.tableImports,`${E}.${S}`,{module:E,name:S,importOrdinal:g,index:a++,elementType:x.code,recipeTypeCode:To(x,t),table64:(I.flags&4)!==0,minimum:I.minimum,maximum:I.maximum}),o.tables.push({elementType:x.code,table64:(I.flags&4)!==0,minimum:I.minimum,maximum:I.maximum})}else if(z===2){let x=Ze(e,f);f=x.next,o.memoryPointerWidths.push((x.flags&4)!==0?8:4)}else if(z===3){let x=xe(e,f,`global import ${E}.${S}`);if(f=x.next,f>=e.length)throw new Error(`global import ${E}.${S} is truncated`);let I=e[f++];if((I&-4)!==0)throw new Error(`global import ${E}.${S} has invalid flags ${I}`);Yt(o.globalImports,`${E}.${S}`,{module:E,name:S,importOrdinal:g,index:s++,valueType:x.code,recipeTypeCode:To(x,t),mutable:(I&1)!==0,shared:(I&2)!==0})}else if(z===4){let x=e[f++];if(x!==0)throw new Error(`unsupported wasm tag attribute ${x}`);let[I,R]=T(e,f);f+=R,vn(o.tagImports,`${E}.${S}`,t[I])}else throw new Error(`unsupported wasm import kind ${z}`)}}else if(u===3){p=!0;let[_,y]=T(e,f);f+=y;for(let g=0;g<_;g++){let[E,O]=T(e,f);f+=O,r.push(E)}}else if(u===4){p=!0;let[_,y]=T(e,f);f+=y;for(let g=0;g<_;g++){let E=xe(e,f,`defined table ${g}`);f=E.next;let O=Ze(e,f);f=O.next,o.tables.push({elementType:E.code,table64:(O.flags&4)!==0,minimum:O.minimum,maximum:O.maximum})}}else if(u===5){p=!0;let[_,y]=T(e,f);f+=y;for(let g=0;g<_;g++){let E=Ze(e,f);f=E.next,o.memoryPointerWidths.push((E.flags&4)!==0?8:4)}}else if(u===7){p=!0;let[_,y]=T(e,f);f+=y;for(let g=0;g<_;g++){let[E,O]=qe(e,f);f=O;let S=e[f++],[w,z]=T(e,f);f+=z,Yt(o.exports,E,{kind:S,index:w}),S===0?i.push({name:E,index:w}):S===3?Yt(o.globalExports,E,w):S===1&&Yt(o.tableExports,E,w)}}else if(u===8){p=!0,o.nativeStartCount++;let[,_]=T(e,f);f+=_}if(p&&f!==m)throw new Error(`malformed wasm section ${u}`);c=m}for(let{name:u,index:l}of i){let d=r[l];vn(o.functionExports,u,t[d])}return o}function qa(n){if(n.byteLength!==$t)throw new Error(`linked-frame descriptor has ${n.byteLength} bytes, expected ${$t}`);if(!Zi.every((a,c)=>n[c]===a))throw new Error("linked-frame descriptor has invalid magic");let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=e.getUint16(4,!0);if(t!==1)throw new Error(`linked-frame descriptor version ${t} is unsupported`);let r=e.getUint16(6,!0);if(r!==$t)throw new Error(`linked-frame descriptor declares size ${r}, expected ${$t}`);let i=e.getUint8(8),o=Xi.find(({bytes:a})=>a===i);if(!o)throw new Error(`linked-frame descriptor pointer width ${i} is unsupported`);if(e.getUint8(9)!==Yi)throw new Error(`linked-frame descriptor alignment ${e.getUint8(9)} is unsupported`);let s=e.getUint16(10,!0);if(s!==gn)throw new Error(`linked-frame descriptor flags 0x${s.toString(16)} do not equal required flags 0x${gn.toString(16)}`);if(e.getUint32(12,!0)!==o.chunkHeaderSize||e.getUint32(16,!0)!==o.nodeHeaderSize)throw new Error(`linked-frame descriptor header sizes do not match its ${i}-byte pointer width`);return o.bytes}function Za(n){if(n.length===0)return[`missing required ${Pe} capability`];if(n.length!==1)return[`has ${n.length} ${Pe} sections, expected exactly one`];let e=n[0];if(e.byteLength!==2)return[`${Pe} has ${e.byteLength} bytes, expected 2`];if(e[0]!==io)return[`${Pe} version ${e[0]} is unsupported`];let t=e[1];return(t&~oo)!==0?[`${Pe} has unknown flags 0x${t.toString(16)}`]:(t&Fr)!==Fr?[`${Pe} flags 0x${t.toString(16)} omit required activation-state safety flags 0x${Fr.toString(16)}`]:[]}function Ya(n){let e=[],t=`${Cr}.${Mr}`,r=n.tagImports.get(t);if(r?r.length!==1?e.push(`duplicate private fork-unwind tag import ${t}`):(r[0].params.length!==0||r[0].results.length!==0)&&e.push(`private fork-unwind tag ${t} must have an empty payload`):e.push(`missing required private fork-unwind tag import ${t}`),n.unwindTransportDescriptors.length===0)e.push(`missing required ${Et} descriptor`);else if(n.unwindTransportDescriptors.length!==1)e.push(`has ${n.unwindTransportDescriptors.length} ${Et} descriptors, expected exactly one`);else{let i=n.unwindTransportDescriptors[0];(i.length!==2||i[0]!==In||i[1]!==Tn)&&e.push(`${Et} must be [${In}, ${Tn}]`)}return e}function Xa(n,e){if(n.length===0)return[`missing required ${ne} descriptor`];if(n.length!==1)return[`has ${n.length} ${ne} descriptors, expected exactly one`];let t=n[0];if(t.byteLength!==vr)return[`${ne} has ${t.byteLength} bytes, expected ${vr}`];if(!Ji.every((p,_)=>t[_]===p))return[`${ne} has invalid magic`];let r=new DataView(t.buffer,t.byteOffset,t.byteLength),i=r.getUint16(4,!0),o=r.getUint16(6,!0),s=r.getUint8(8),a=no.find(({bytes:p})=>p===s),c=r.getUint8(9),u=r.getUint16(10,!0),l=r.getUint16(12,!0),d=r.getUint16(14,!0),h=r.getUint32(16,!0),m=r.getUint32(20,!0),f=[];return i!==ji&&f.push(`${ne} version ${i} is unsupported`),o!==vr&&f.push(`${ne} declares size ${o}`),a?e!==null&&s!==e&&f.push(`${ne} pointer width ${s} does not match linked frames ${e}`):f.push(`${ne} pointer width ${s} is unsupported`),c!==Qi&&f.push(`${ne} alignment ${c} is unsupported`),u!==En&&f.push(`${ne} flags 0x${u.toString(16)} do not equal required flags 0x${En.toString(16)}`),l!==eo&&f.push(`${ne} arena version ${l} is unsupported`),d!==to&&f.push(`${ne} record version ${d} is unsupported`),h!==ro&&f.push(`${ne} root word ${h} is unsupported`),m!==0&&f.push(`${ne} reserved field is nonzero`),f}function ja(n){if(n.length===0)return[`missing required ${ze} descriptor`];if(n.length!==1)return[`has ${n.length} ${ze} descriptors, expected exactly one`];let e=n[0];if(e.byteLength2147483647||s.has(l))&&r.push(`${ze} layout id ${l} is invalid or duplicated`),s.add(l)}return r}var Ja=new Set([Sn,wn,On,An,zn,gt,Ut,Wt,Gt]);function Ro(n){return!(n.module===Rn&&(n.name===Ln||n.name==="__channel_base"||n.name==="__wpk_fork_module_state_table_generation_addr"))}function Qa(n){let e=n.importedGlobalsDescriptors;if(e.length===0)return[`missing required ${Y} descriptor`];if(e.length!==1)return[`has ${e.length} ${Y} descriptors, expected exactly one`];let t=e[0];if(t.byteLengtht[_]===p)||i.push(`${Y} has invalid magic`),r.getUint16(4,!0)!==lo&&i.push(`${Y} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Kr&&i.push(`${Y} declares an invalid header size`);let o=r.getUint32(8,!0);r.getUint32(12,!0)!==0&&i.push(`${Y} reserved field is nonzero`);let s=new Set,a=new Set,c=new TextDecoder("utf-8",{fatal:!0}),u=[],l=-1,d=Kr;for(let p=0;pt.byteLength)return i.push(`${Y} record ${p} header is truncated`),i;let _=r.getUint32(d,!0),y=r.getUint32(d+4,!0),g=r.getUint8(d+8),E=r.getUint8(d+9),O=r.getUint32(d+12,!0),S=r.getUint32(d+16,!0),w=r.getUint32(d+20,!0),z=Vt+O+S;if(!Number.isSafeInteger(z)||_!==z||_t.byteLength)return i.push(`${Y} record ${p} has invalid bounds`),i;(y===0||s.has(y))&&i.push(`${Y} record ${p} has invalid or duplicated owner ${y}`),s.add(y),Ja.has(g)||i.push(`${Y} record ${p} has unknown value type ${g}`),(E&~ho)!==0&&i.push(`${Y} record ${p} has unknown flags 0x${E.toString(16)}`),r.getUint16(d+10,!0)!==0&&i.push(`${Y} record ${p} reserved fields are nonzero`),(a.has(w)||w<=l)&&i.push(`${Y} record ${p} has duplicated or unordered import ordinal`),a.add(w),l=w;let x=d+Vt;try{let I=c.decode(t.subarray(x,x+O)),R=c.decode(t.subarray(x+O,x+O+S));u.push({ownerId:y,typeCode:g,flags:E,importOrdinal:w,module:I,name:R})}catch{i.push(`${Y} record ${p} contains invalid UTF-8`)}d+=_}d!==t.byteLength&&i.push(`${Y} has trailing bytes`);let h=[...n.globalImports.values()].flat(),m=new Map(h.map(p=>[p.index,p])),f=new Set;for(let p of u){let _=`${Pr}${p.ownerId}`,y=n.exports.get(_);if(!y||y.length!==1||y[0].kind!==3){i.push(`${Y} owner ${p.ownerId} lacks exactly one global catalog export ${_}`);continue}let g=m.get(y[0].index);if(!g||!Ro(g)){i.push(`${Y} owner ${p.ownerId} does not identify a reconstructible imported global`);continue}if(g.module!==p.module||g.name!==p.name||g.importOrdinal!==p.importOrdinal||g.recipeTypeCode!==p.typeCode||g.mutable!==((p.flags&fo)!==0)||g.shared!==((p.flags&po)!==0)){i.push(`${Y} owner ${p.ownerId} does not match its imported global declaration`);continue}if(f.has(g.index)){i.push(`${Y} repeats imported global index ${g.index}`);continue}f.add(g.index)}for(let p of h)Ro(p)&&!f.has(p.index)&&i.push(`${Y} omits imported global ${p.module}.${p.name} at index ${p.index}`);for(let[p,_]of n.exports){if(!p.startsWith(Pr))continue;let y=p.slice(Pr.length),g=Number(y);(!/^[1-9][0-9]*$/.test(y)||!Number.isSafeInteger(g)||g>4294967295||_.length!==1||_[0].kind!==3)&&i.push(`malformed reserved fork global catalog export ${p}`)}return i}var ec=new Set([gt,Ut,Wt,Gt]);function Lo(n){return!bn.some(({module:e,name:t})=>n.module===e&&n.name===t)}function tc(n){let e=n.importedTablesDescriptors;if(e.length===0)return[`missing required ${X} descriptor`];if(e.length!==1)return[`has ${e.length} ${X} descriptors, expected exactly one`];let t=e[0];if(t.byteLengtht[_]===p)||i.push(`${X} has invalid magic`),r.getUint16(4,!0)!==yo&&i.push(`${X} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Br&&i.push(`${X} declares an invalid header size`);let o=r.getUint32(8,!0);r.getUint32(12,!0)!==0&&i.push(`${X} reserved field is nonzero`);let s=new Set,a=new Set,c=new TextDecoder("utf-8",{fatal:!0}),u=[],l=-1,d=Br;for(let p=0;pt.byteLength)return i.push(`${X} record ${p} header is truncated`),i;let _=r.getUint32(d,!0),y=r.getUint32(d+4,!0),g=r.getUint8(d+8),E=r.getUint8(d+9),O=r.getUint32(d+12,!0),S=r.getUint32(d+16,!0),w=r.getUint32(d+20,!0),z=qt+O+S;if(!Number.isSafeInteger(z)||_!==z||_t.byteLength)return i.push(`${X} record ${p} has invalid bounds`),i;(y===0||s.has(y))&&i.push(`${X} record ${p} has invalid or duplicated owner ${y}`),s.add(y),ec.has(g)||i.push(`${X} record ${p} has unknown element type ${g}`),(E&~go)!==0&&i.push(`${X} record ${p} has unknown flags 0x${E.toString(16)}`),r.getUint16(d+10,!0)!==0&&i.push(`${X} record ${p} reserved fields are nonzero`),(a.has(w)||w<=l)&&i.push(`${X} record ${p} has duplicated or unordered import ordinal`),a.add(w),l=w;let x=d+qt;try{let I=c.decode(t.subarray(x,x+O)),R=c.decode(t.subarray(x+O,x+O+S));u.push({ownerId:y,typeCode:g,flags:E,importOrdinal:w,module:I,name:R})}catch{i.push(`${X} record ${p} contains invalid UTF-8`)}d+=_}d!==t.byteLength&&i.push(`${X} has trailing bytes`);let h=[...n.tableImports.values()].flat(),m=new Map(h.map(p=>[p.index,p])),f=new Set;for(let p of u){let _=`${kr}${p.ownerId}`,y=n.exports.get(_);if(!y||y.length!==1||y[0].kind!==1){i.push(`${X} owner ${p.ownerId} lacks exactly one table catalog export ${_}`);continue}let g=m.get(y[0].index);if(!g||!Lo(g)){i.push(`${X} owner ${p.ownerId} does not identify a reconstructible imported table`);continue}if(g.module!==p.module||g.name!==p.name||g.importOrdinal!==p.importOrdinal||g.recipeTypeCode!==p.typeCode||g.table64!==((p.flags&_o)!==0)){i.push(`${X} owner ${p.ownerId} does not match its imported table declaration`);continue}if(f.has(g.index)){i.push(`${X} repeats imported table index ${g.index}`);continue}f.add(g.index)}for(let p of h)Lo(p)&&!f.has(p.index)&&i.push(`${X} omits imported table ${p.module}.${p.name} at index ${p.index}`);for(let[p,_]of n.exports){if(!p.startsWith(kr))continue;let y=p.slice(kr.length),g=Number(y);(!/^[1-9][0-9]*$/.test(y)||!Number.isSafeInteger(g)||g>4294967295||_.length!==1||_[0].kind!==1)&&i.push(`malformed reserved fork table catalog export ${p}`)}return i}function Nn(n,e){switch(n){case"ptr":return e===8?126:127;case"i32":return 127;case"i64":return 126;case"anyref":return 110;case"exnref":return 105;case"externref":return 111;case"funcref":return 112}}function Pn(n,e,t,r){return n.params.length===e.length&&n.results.length===t.length&&n.params.every((i,o)=>i===Nn(e[o],r))&&n.results.every((i,o)=>i===Nn(t[o],r))}function kn(n,e,t){let r=i=>i==="ptr"?t===8?"i64":"i32":i;return`(${n.map(r).join(", ")}) -> (${e.map(r).join(", ")})`}function rc(n){let e=`${Rn}.${Ln}`,t=n.globalImports.get(e);return t?t.length!==1?[`duplicate exception-codec activation import ${e}`]:t[0].valueType!==127||t[0].mutable?[`exception-codec activation import ${e} must be immutable i32`]:[]:[`missing required immutable exception-codec activation import ${e}`]}function nc(n){let e=[];for(let t of bn){let r=`${t.module}.${t.name}`,i=n.tableImports.get(r);if(!i){e.push(`missing required ABI 43 fork-runtime table import ${r}`);continue}if(i.length!==1){e.push(`duplicate ABI 43 fork-runtime table import ${r}`);continue}let o=i[0],s=Nn(t.element,4);(o.elementType!==s||o.table64!==t.table64||o.minimum!==t.minimum||o.maximum!==t.maximum)&&e.push(`ABI 43 fork-runtime table import ${r} has the wrong type or limits`)}return e}function ic(n){if(n.staticRootDescriptors.length===0)return[`missing required ${Ge} descriptor`];if(n.staticRootDescriptors.length!==1)return[`has ${n.staticRootDescriptors.length} ${Ge} descriptors, expected exactly one`];let e=n.staticRootDescriptors[0];if(e.byteLength!==Dr)return[`${Ge} has ${e.byteLength} bytes, expected ${Dr}`];let t=[];Ca.some((u,l)=>e[l]!==u)&&t.push(`${Ge} has invalid magic`);let r=new DataView(e.buffer,e.byteOffset,e.byteLength);r.getUint16(4,!0)!==ao&&t.push(`${Ge} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Dr&&t.push(`${Ge} declares an invalid header size`);let i=r.getUint32(8,!0),o=n.tableExports.get(Ht);if(!o||o.length!==1)return t.push(`missing exactly one table export ${Ht}`),t;let s=[...n.tableImports.values()].reduce((u,l)=>u+l.length,0),a=o[0],c=n.tables[a];return a!n.functionExports.has(c)).map(({name:c})=>c);if(t.length>0&&e.push(`incomplete wasm-fork-instrument exports; missing ${t.join(", ")}`),n.importsKernelFork){let c=`${ot.module}.${ot.name}`,u=n.functionImports.get(c);u?.length!==1?e.push(`duplicate ABI 43 process-fork import ${c}`):Pn(u[0],ot.params,ot.results,4)||e.push(`ABI 43 process-fork import ${c} has the wrong signature; expected ${kn(ot.params,ot.results,4)}`)}let r=null;if(n.linkedFrameDescriptors.length===0)e.push(`missing required ${Bt} descriptor`);else if(n.linkedFrameDescriptors.length!==1)e.push(`has ${n.linkedFrameDescriptors.length} ${Bt} descriptors, expected exactly one`);else try{r=qa(n.linkedFrameDescriptors[0])}catch(c){e.push(c instanceof Error?c.message:String(c))}e.push(...Xa(n.moduleStateDescriptors,r));let i=St.filter(({module:c,name:u})=>n.functionImports.has(`${c}.${u}`)),o=`${Cr}.${Mr}`,s=n.importsKernelFork||i.length>0;if((s||n.tagImports.has(o)||n.unwindTransportDescriptors.length>0)&&e.push(...Ya(n)),s){let c=St.filter(({module:u,name:l})=>!n.functionImports.has(`${u}.${l}`)).map(({module:u,name:l})=>`${u}.${l}`);c.length>0&&e.push(`incomplete ABI 43 fork-runtime imports; missing ${c.join(", ")}`);for(let u of St){let l=`${u.module}.${u.name}`,d=n.functionImports.get(l);d&&d.length!==1&&e.push(`duplicate ABI 43 fork-runtime import ${l}`)}}if(r!==null){if(n.memoryPointerWidths.length!==1)e.push(`ABI 43 fork instrumentation requires exactly one module memory, found ${n.memoryPointerWidths.length}`);else if(n.memoryPointerWidths[0]!==r){let c=r===8?"an":"a";e.push(`ABI 43 linked-frame descriptor declares ${c} ${r}-byte pointer but the module memory uses ${n.memoryPointerWidths[0]}-byte addresses`)}for(let c of Zt){let u=n.functionExports.get(c.name);u?.length===1&&!Pn(u[0],c.params,c.results,r)&&e.push(`ABI 43 wasm-fork-instrument export ${c.name} has the wrong signature; expected ${kn(c.params,c.results,r)}`)}if(s)for(let c of St){let u=`${c.module}.${c.name}`,l=n.functionImports.get(u);l?.length===1&&!Pn(l[0],c.params,c.results,r)&&e.push(`ABI 43 fork-runtime import ${u} has the wrong signature; expected ${kn(c.params,c.results,r)}`)}}return e}function Po(n,e){switch(n){case 0:return"function";case 1:return"table";case 2:return"memory";case 3:return"global";case 4:return"tag";default:throw new Error(`${e} has unsupported external kind ${n}`)}}function sc(n){let e=new Uint8Array(n);if(!$r(e))return[];let t=[],r=8;for(;r`${e}.${t}`)}function cc(n){let e=new Uint8Array(n);if(!$r(e))return[];let t=[],r=8;for(;re)}function ko(n){let e=new Uint8Array(n);if(!$r(e))return[];let t=[],r=8;for(;rt.startsWith("reloc."))}function Fo(n,e={}){let t=[],r=null;dc(n)&&t.push("contains asyncify_"),e.expectedAbi!==void 0&&e.expectedAbi!==null&&(r=pc(n),r!==null&&r!==e.expectedAbi&&t.push(`ABI ${r}, expected ${e.expectedAbi}`));let i=new Set(uc(n));if(e.requiredExports){let E=e.requiredExports.filter(O=>!i.has(O));E.length>0&&t.push(`missing required exports: ${E.join(", ")}`)}if(e.forbiddenExports){let E=e.forbiddenExports.filter(O=>i.has(O));E.length>0&&t.push(`forbidden exports present: ${E.join(", ")}`)}let o=Ha.filter(E=>i.has(E)),s=ac(n),a=ko(n),c=St.filter(({module:E,name:O})=>s.includes(`${E}.${O}`)),u=a.filter(E=>E===Bt).length,l=a.filter(E=>E===Pe).length,d=a.filter(E=>E===ne).length,h=a.filter(E=>E===ze).length,m=a.filter(E=>E===Y).length,f=a.filter(E=>E===X).length,p=a.filter(E=>E===Et).length,_=s.includes(`${Cr}.${Mr}`),y=o.length>0||c.length>0||u>0||l>0||d>0||h>0||m>0||f>0||p>0||_;if(e.expectedAbi!==void 0&&e.expectedAbi!==null&&y&&r===null&&t.push(`ABI ${e.expectedAbi} fork artifact is missing __abi_version; the activation-state capability epoch cannot be verified`),e.forbidForkInstrumentation&&y&&t.push("contains ABI 43 wasm-fork-instrument metadata, imports, or exports"),(e.requireForkInstrumentation??!lc(n))&&(y||s.includes("kernel.kernel_fork")))try{t.push(...oc(Va(n)))}catch(E){t.push(`cannot validate ABI 43 fork-artifact contract: ${E instanceof Error?E.message:String(E)}`)}return t}function fc(n,e){let t=new Uint8Array(n);if(t.length<8)return null;let r=0,i=null,o=null,s=8;for(;s=c)return null;let p=a;for(let g=0;g=f)return null;let[p,_]=T(t,m);m+=_;for(let y=0;yf)return null}return m}function h(m,f=0){if(f>4)return null;let p=l(m);if(!p)return null;let _=d(p.start,p.end);if(_===null)return null;let y=_,g=p.end;for(;y=32&&E<=38||E===208){let[,O]=T(t,y);y+=O}else if(E>=40&&E<=62)y=Xt(t,y);else if(E===63||E===64)y++;else if(E===66){let[,O]=bo(t,y);y+=O}else if(E===67)y+=4;else if(E===68)y+=8;else if(E===252||E===253||E===254){let O=Ua(E,t,y);if(O===null)return null;y=O}}return null}return h(i)}function pc(n){return fc(n,"__abi_version")}Ve();var hc=ArrayBuffer,J=Uint8Array,Ur=Uint16Array,mc=Int16Array;var Wr=Int32Array,Cn=function(n,e,t){if(J.prototype.slice)return J.prototype.slice.call(n,e,t);(e==null||e<0)&&(e=0),(t==null||t>n.length)&&(t=n.length);var r=new J(t-e);return r.set(n.subarray(e,t)),r},Jt=function(n,e,t,r){if(J.prototype.fill)return J.prototype.fill.call(n,e,t,r);for((t==null||t<0)&&(t=0),(r==null||r>n.length)&&(r=n.length);tn.length)&&(r=n.length);t2046MB)","invalid block type","FSE accuracy too high","match distance too far back","unexpected EOF"],Q=function(n,e,t){var r=new Error(e||_c[n]);if(r.code=n,Error.captureStackTrace&&Error.captureStackTrace(r,Q),!t)throw r;return r},No=function(n,e,t){for(var r=0,i=0;r>>0},Ec=function(n,e){var t=n[0]|n[1]<<8|n[2]<<16;if(t==3126568&&n[3]==253){var r=n[4],i=r>>5&1,o=r>>2&1,s=r&3,a=r>>6;r&8&&Q(0);var c=6-i,u=s==3?4:s,l=No(n,c,u);c+=u;var d=a?1<>3);m=f+(f>>3)*(n[5]&7)}m>2145386496&&Q(1);var p=new J((e==1?h||m:e?0:m)+12);return p[0]=1,p[4]=4,p[8]=8,{b:c+d,y:0,l:0,d:l,w:e&&e!=1?e:p.subarray(12),e:m,o:new Wr(p.buffer,0,3),u:h,c:o,m:Math.min(131072,m)}}else if((t>>4|n[3]<<20)==25481893)return gc(n,4)+8;Q(0)},st=function(n){for(var e=0;1<t&&Q(3);for(var o=1<0;){var g=st(s+1),E=r>>3,O=(1<>(r&7)&O,w=(1<w&&(S-=z)),h[++a]=--S,S==-1?(s+=S,_[--l]=a):s-=S,!S)do{var I=r>>3;c=(n[I]|n[I+1]<<8)>>(r&7)&3,r+=2,a+=c}while(c==3)}(a>255||s)&&Q(0);for(var R=0,L=(o>>1)+(o>>3)+3,v=o-1,Z=0;Z<=a;++Z){var N=h[Z];if(N<1){m[Z]=-N;continue}for(u=0;u=l)}}for(R&&Q(0),u=0;u>3,{b:i,s:_,n:y,t:f}]},Sc=function(n,e){var t=0,r=-1,i=new J(292),o=n[e],s=i.subarray(0,256),a=i.subarray(256,268),c=new Ur(i.buffer,268);if(o<128){var u=Qt(n,e+1,6),l=u[0],d=u[1];e+=o;var h=l<<3,m=n[e];m||Q(0);for(var f=0,p=0,_=d.b,y=_,g=(++e<<3)-8+st(m);g-=_,!(g>3;if(f+=(n[E]|n[E+1]<<8)>>(g&7)&(1<<_)-1,s[++r]=d.s[f],g-=y,g>3,p+=(n[E]|n[E+1]<<8)>>(g&7)&(1<255&&Q(0)}else{for(r=o-127;t>4,s[t+1]=O&15}++e}var S=0;for(t=0;t11&&Q(0),S+=w&&1<0;--t){var Z=c[t];Jt(v,t,Z,c[t-1]=Z+a[t]*(1<a&&d>3,m=(n[h]|n[h+1]<<8|n[h+2]<<16)>>(l&7);c=(c<>2,s=o<<1,a=o+s;jt(n.subarray(r,r+=n[0]|n[1]<<8),e.subarray(0,o),t),jt(n.subarray(r,r+=n[2]|n[3]<<8),e.subarray(o,s),t),jt(n.subarray(r,r+=n[4]|n[5]<<8),e.subarray(s,a),t),jt(n.subarray(r),e.subarray(a),t)},Tc=function(n,e,t){var r,i=e.b,o=n[i],s=o>>1&3;e.l=o&1;var a=o>>3|n[i+1]<<5|n[i+2]<<13,c=(i+=3)+a;if(s==1)return i>=n.length?void 0:(e.b=i+1,t?(Jt(t,n[i],e.y,e.y+=a),t):Jt(new J(a),n[i]));if(!(c>n.length)){if(s==0)return e.b=c,t?(t.set(n.subarray(i,c),e.y),e.y+=a,t):Cn(n,i,c);if(s==2){var u=n[i],l=u&3,d=u>>2&3,h=u>>4,m=0,f=0;l<2?d&1?h|=n[++i]<<4|(d&2&&n[++i]<<12):h=u>>3:(f=d,d<2?(h|=(n[++i]&63)<<4,m=n[i]>>6|n[++i]<<2):d==2?(h|=n[++i]<<4|(n[++i]&3)<<12,m=n[i]>>2|n[++i]<<6):(h|=n[++i]<<4|(n[++i]&63)<<12,m=n[i]>>6|n[++i]<<2|n[++i]<<10)),++i;var p=t?t.subarray(e.y,e.y+e.m):new J(e.m),_=p.length-h;if(l==0)p.set(n.subarray(i,i+=h),_);else if(l==1)Jt(p,n[i++],_);else{var y=e.h;if(l==2){var g=Sc(n,i);m+=i-(i=g[0]),e.h=y=g[1]}else y||Q(0);(f?Ic:jt)(n.subarray(i,i+=m),p.subarray(_),y)}var E=n[i++];if(E){E==255?E=(n[i++]|n[i++]<<8)+32512:E>127&&(E=E-128<<8|n[i++]);var O=n[i++];O&3&&Q(0);for(var S=[Oc,Ac,wc],w=2;w>-1;--w){var z=O>>(w<<1)+2&3;if(z==1){var x=new J([0,0,n[i++]]);S[w]={s:x.subarray(2,3),n:x.subarray(0,1),t:new Ur(x.buffer,0,1),b:0}}else z==2?(r=Qt(n,i,9-(w&1)),i=r[0],S[w]=r[1]):z==3&&(e.t||Q(0),S[w]=e.t[w])}var I=e.t=S,R=I[0],L=I[1],v=I[2],Z=n[c-1];Z||Q(0);var N=(c<<3)-8+st(Z)-v.b,k=N>>3,G=0,ue=(n[k]|n[k+1]<<8)>>(N&7)&(1<>3;var Oe=(n[k]|n[k+1]<<8)>>(N&7)&(1<>3;var M=(n[k]|n[k+1]<<8)>>(N&7)&(1<>3;var yt=1<>>(N&7)&yt-1);k=(N-=Dn[nt])>>3;var $e=xc[nt]+((n[k]|n[k+1]<<8|n[k+2]<<16)>>(N&7)&(1<>3;var it=zc[de]+((n[k]|n[k+1]<<8|n[k+2]<<16)>>(N&7)&(1<>3,ue=v.t[ue]+((n[k]|n[k+1]<<8)>>(N&7)&(1<>3,M=R.t[M]+((n[k]|n[k+1]<<8)>>(N&7)&(1<>3,Oe=L.t[Oe]+((n[k]|n[k+1]<<8)>>(N&7)&(1<3)e.o[2]=e.o[1],e.o[1]=e.o[0],e.o[0]=Ae-=3;else{var _t=Ae-(it!=0);_t?(Ae=_t==3?e.o[0]-1:e.o[_t],_t>1&&(e.o[2]=e.o[1]),e.o[1]=e.o[0],e.o[0]=Ae):Ae=e.o[0]}for(var w=0;w$e&&(We=$e);for(var w=0;wvc)throw er("EOVERFLOW","file offset is outside signed i64");return n}function kc(n){if(Bn(n)<0n)throw er("EINVAL","negative positioned I/O offset");return n}function $n(n){let e=Bn(n);if(eKo)throw er("EOVERFLOW","backend cannot represent the file offset exactly");return Do(e)}function Un(n){let e=kc(n);return $n(e)}function Bo(n){if(n===null)return null;let e=Bn(n);if(e<0n)throw er("EINVAL","negative file-size limit");return e>Ko?null:Do(e)}Ve();var{ALLOC_SIZE_MIN:Fc,ASYNC_IO:Nc,CHOWN_RESTRICTED:Cc,FALLOC:Mc,FILESIZEBITS:Dc,LINK_MAX:Kc,MAX_CANON:Bc,MAX_INPUT:$c,NAME_MAX:Uc,NO_TRUNC:Wc,PATH_MAX:Gc,PIPE_BUF:Hc,POSIX2_SYMLINKS:Vc,PRIO_IO:qc,REC_INCR_XFER_SIZE:Zc,REC_MAX_XFER_SIZE:Yc,REC_MIN_XFER_SIZE:Xc,REC_XFER_ALIGN:jc,SOCK_MAXBUF:Jc,SYMLINK_MAX:Qc,SYNC_IO:eu,TEXTDOMAIN_MAX:tu,TIMESTAMP_RESOLUTION:ru,VDISABLE:nu}=zo,{S_IFDIR:iu,S_IFIFO:ou,S_IFMT:$o,S_IFREG:su}=ie;function Wn(n){let e=new Error(`EINVAL: pathconf name ${n} is not associated with this object`);throw e.code="EINVAL",e}function Gn(n,e,t){switch(e){case Kc:return null;case Uc:return 255;case Gc:return Oo;case Cc:return 1;case Wc:return 1;case Nc:return(n.mode&$o)===su?1:Wn(e);case eu:case qc:case Dc:case Zc:case Yc:case Xc:case jc:case Fc:case Qc:case Mc:return null;case Vc:return t.supportsSymlinks?1:null;case tu:return 255;case ru:return t.timestampResolutionNs;case Hc:{let r=n.mode&$o;return r===ou||r===iu?null:Wn(e)}case Bc:case $c:case nu:case Jc:return Wn(e);default:{let r=new Error(`EINVAL: invalid pathconf name ${e}`);throw r.code="EINVAL",r}}}Ve();Ve();var Uo=So.ST_NOSUID;var Gr=Math.floor(160),Hn=1397114451,Vn=1,tr=32768,H=16384,wt=40960,W=61440,au=2048,cu=1024,uu=73,Wo=4294967295,at=0,Go=1;var cr=64,ei=128,dr=512,du=1024,lu=65536,rr=3,fu=0,pu=1,hu=2,F=8,mu=64*1024,yu=-1,Ie=-2,$=-5,re=-9,jn=-16,Tt=-17,Fe=-20,ut=-21,j=-22,Qo=-24,dt=-27,se=-28,es=-30,Jn=-36,Qn=-39,ts=-40,rs=-75,qn=0,Zn=4,Hr=8,Ot=12,Ye=16,At=20,Vr=24,ct=28,qr=32,Ho=36,Zr=40,_u=44,gu=48,Eu=52,Yn=56,Yr=60,Xr=64,nr=68,Vo=72,zt=0,C=8,B=12,P=16,me=24,ee=32,ir=40,oe=48,or=88,xt=92,sr=96,ar=100,pe=104,Xe=112,qo=116,he=120,Zo=4,ke=8,Yo=16,Xo=20,jo=-2147483648,Su=2147483647,wu=1034+1024*1024,je=wu*4096,Ou={[Ie]:"No such file or directory",[$]:"I/O error",[re]:"Bad file descriptor",[jn]:"Device or resource busy",[Tt]:"File exists",[Fe]:"Not a directory",[ut]:"Is a directory",[j]:"Invalid argument",[Qo]:"Too many open files",[dt]:"File too large",[se]:"No space left on device",[es]:"Read-only file system",[Jn]:"File name too long",[Qn]:"Directory not empty",[ts]:"Too many symbolic links",[rs]:"Value too large for data type"},A=class extends Error{constructor(t,r){super(r||Ou[t]||`Error ${t}`);this.code=t;this.name="SFSError"}code},_e=new TextEncoder,ur=new TextDecoder,Jo=_e.encode("..");function Xn(n){return n==="."||n===".."}function It(n){return n.buffer instanceof SharedArrayBuffer?ur.decode(new Uint8Array(n)):ur.decode(n)}function Je(n){return n+3&-4}var le=class n{constructor(e){this.buffer=e;this.view=new DataView(e),this.i32=new Int32Array(e),this.u8=new Uint8Array(e)}buffer;view;i32;u8;dirIndexes=new Map;blockAllocHint=0;inodeAllocHint=2;atomicsWaitAllowed;static mkfs(e,t){let r=e.byteLength;if(r<4096*16)throw new A(j);let i=Math.floor(r/4096),o=t?Math.floor(t/4096):i*4,s=Math.floor(o/4);s<32&&(s=32),s=Math.ceil(s/32)*32;let a=Math.ceil(s/(4096*8)),c=Math.ceil(o/(4096*8)),u=Math.ceil(s*128/4096),l=1,d=l+a,h=d+c,m=h+u;if(m>=i){let x=(m+1)*4096;try{e.grow(x)}catch{throw new A(se)}if(i=Math.floor(e.byteLength/4096),m>=i)throw new A(se)}new Uint8Array(e).fill(0);let f=new n(e);f.w32(qn,Hn),f.w32(Zn,Vn),f.w32(Hr,4096),f.w32(Ot,i),f.w32(Ye,s),f.w32(ct,l),f.w32(qr,d),f.w32(Ho,h),f.w32(Zr,m),f.w32(_u,a),f.w32(gu,c),f.w32(Eu,u),f.w32(nr,o),f.w32(Vo,256);let p=d*4096;for(let x=0;x>2)+(x>>5);f.i32[I]|=1<<(x&31)}let _=i-m;Atomics.store(f.i32,At>>2,_),f.blockAllocHint=m;let y=l*4096;f.i32[y>>2]|=3,Atomics.store(f.i32,Vr>>2,s-2),f.inodeAllocHint=2;let g=f.inodeOffset(1);f.w32(g+C,H|493),f.w32(g+B,2),f.w64(g+pe,1);let E=f.blockAlloc();if(E<0)throw new A(se);f.w32(g+oe,E);let O=E*4096,S=Je(F+1),w=Je(F+2);f.w32(O,1),f.view.setUint16(O+4,S,!0),f.view.setUint16(O+6,1,!0),f.u8[O+F]=46;let z=O+S;return f.w32(z,1),f.view.setUint16(z+4,w,!0),f.view.setUint16(z+6,2,!0),f.u8[z+F]=46,f.u8[z+F+1]=46,f.w64(g+P,S+w),Atomics.store(f.i32,Yn>>2,1),f}static inspectImageCapacity(e){if(e.byteLengththis.snapshotBytesUnlocked(e))}snapshotState(e){return this.withNamespaceLock(()=>({bytes:this.snapshotBytesUnlocked(e),identities:this.collectIdentityStateUnlocked()}))}identityState(){return this.withNamespaceLock(()=>this.collectIdentityStateUnlocked())}snapshotBytesUnlocked(e){let t=e?.normalizeTimestampsMs;if(t!==void 0&&(!Number.isSafeInteger(t)||t<0))throw new A(j,"Snapshot timestamp must be a non-negative safe integer in milliseconds");let r=t===void 0?void 0:BigInt(t);for(let a=0;a>2)!==0)throw new A(jn,"Cannot save a VFS image with open descriptors")}let i=this.r32(Ye);for(let a=0;a=1&&this.inodeIsAllocated(a)?r:0n;s.setBigUint64(c+ir,u,!0),s.setBigUint64(c+me,u,!0),s.setBigUint64(c+ee,u,!0)}}return o}collectIdentityStateUnlocked(){let e=new Map,t=this.inodeOffset(1),r=this.r64(t+pe);e.set(`1:${r}`,{ino:1,generation:r,dataSequence:Atomics.load(this.i32,t+he>>2)>>>0,mode:this.r32(t+C),linkCount:this.r32(t+B),size:this.r64(t+P),uid:this.r32(t+sr),gid:this.r32(t+ar),paths:["/"]});let i=[{ino:1,path:"/"}],o=new Set;for(;i.length>0;){let s=i.pop();if(o.has(s.ino))throw new A($);o.add(s.ino);let a=this.inodeOffset(s.ino);if((this.r32(a+C)&W)!==H)throw new A($);let c=this.r64(a+P),u=0;for(;u>2)>>>0,mode:R,linkCount:this.r32(w+B),size:this.r64(w+P),uid:this.r32(w+sr),gid:this.r32(w+ar),...(R&W)===wt?{symlinkTarget:this.readSymlinkInodeUnlocked(y)}:{},paths:[]},e.set(x,I)}I.paths.push(S),(this.r32(w+C)&W)===H&&i.push({ino:y,path:S})}}p+=g}u+=f}}return e}statfs(){let e=this.r32(Hr),t=this.r32(Ot),r=this.r32(nr),i=typeof this.buffer.maxByteLength=="number"?this.buffer.maxByteLength:this.buffer.byteLength,o=Math.floor(i/e),s=Math.max(t,Math.min(r,o)),a=Atomics.load(this.i32,At>>2),c=Math.max(0,s-t);return{blockSize:e,totalBlocks:s,freeBlocks:a+c,totalInodes:this.r32(Ye),freeInodes:Atomics.load(this.i32,Vr>>2),maxName:255}}r32(e){return this.view.getUint32(e,!0)}w32(e,t){this.view.setUint32(e,t,!0)}r64(e){return Number(this.view.getBigUint64(e,!0))}w64(e,t){this.view.setBigUint64(e,BigInt(t),!0)}waitForAtomicChange(e,t){if(this.atomicsWaitAllowed!==!1)try{Atomics.wait(this.i32,e,t),this.atomicsWaitAllowed=!0;return}catch(r){if(!(r instanceof TypeError))throw r;this.atomicsWaitAllowed=!1}for(;Atomics.load(this.i32,e)===t;);}resetAllocationHints(){this.blockAllocHint=this.findNextFreeBlockHint(),this.inodeAllocHint=this.findNextFreeInodeHint()}findNextFreeBlockHint(){let e=this.r32(Ot),t=this.r32(Zr),r=this.r32(qr)*4096;for(let i=t;i>2)+(i>>5),s=i&31;if((Atomics.load(this.i32,o)&1<>2)+(r>>5),o=r&31;if((Atomics.load(this.i32,i)&1<>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}sbUnlock(){let e=Yr>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}namespaceLock(){let e=Xr>>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}namespaceUnlock(){let e=Xr>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}withNamespaceLock(e){this.namespaceLock();try{return e()}finally{this.namespaceUnlock()}}resetRestoredRuntimeState(){Atomics.store(this.i32,Yr>>2,0),Atomics.store(this.i32,Xr>>2,0),this.u8.fill(0,256,4096);let e=this.r32(Ye),t=this.r32(ct)*4096;for(let r=0;r>5)*4)&1<<(r&31))===0||this.r32(i+B)!==0)continue;let s=this.r32(i+C),a=this.r64(i+P);(s&W)===wt&&a<=40?(this.u8.fill(0,i+oe,i+oe+40),this.w64(i+P,0)):this.inodeTruncate(r,0),this.inodeFree(r)}}blockAlloc(){let e=this.r32(Ot),t=this.r32(qr)*4096,r=this.r32(Zr),i=this.blockAllocHint>=r&&this.blockAllocHint>2)+(a>>5),u=a&31,l=Atomics.load(this.i32,c);if(l&1<>2,1),this.blockAllocHint=a+1>2)+(e>>5),i=e&31;for(;;){let o=Atomics.load(this.i32,r),s=o&~(1<>2,1),e>=this.r32(Zr)&&e>2)>0)return 0;let e=this.r32(Ot),t=this.r32(nr),r=this.r32(Vo),i=e+r;if(i>t&&(i=t,r=i-e,r===0))return se;let o=i*4096;if(this.buffer.byteLength>2,r),Atomics.add(this.i32,Yn>>2,1),this.blockAllocHint=e,0}finally{this.sbUnlock()}}inodeOffset(e){let r=this.r32(Ho)+Math.floor(e/32),i=e%32*128;return r*4096+i}inodeAlloc(){let e=this.r32(Ye),t=this.r32(ct)*4096,r=this.inodeAllocHint>=2&&this.inodeAllocHint>2)+(s>>5),c=s&31,u=Atomics.load(this.i32,a);if(u&1<>2,1),this.inodeAllocHint=s+1>2,1)+1}inodeFree(e){let r=(this.r32(ct)*4096>>2)+(e>>5),i=e&31;for(;;){let o=Atomics.load(this.i32,r);if((o&1<>2,1),e>=2&&e0&&this.w32(r+Xe,i-1),i<=1&&this.r32(r+B)===0&&(this.inodeTruncate(e,0),t=!0)}finally{this.inodeWriteUnlock(e)}t&&this.inodeFree(e)}inodeDropLinkRefLocked(e){let t=this.inodeOffset(e),r=this.r32(t+B);return r>1?(this.w32(t+B,r-1),this.w64(t+ee,Date.now()),!1):this.inodeOrphanLocked(e)}inodeOrphanLocked(e){let t=this.inodeOffset(e);if(this.w32(t+B,0),this.w64(t+ee,Date.now()),this.r32(t+Xe)>0)return!1;let r=this.r32(t+C),i=this.r64(t+P);return(r&W)===wt&&i<=40?(this.u8.fill(0,t+oe,t+oe+40),this.w64(t+P,0)):this.inodeTruncate(e,0),!0}inodeReadLock(e){let t=this.inodeOffset(e)+zt>>2;for(;;){let r=Atomics.load(this.i32,t);if(r&jo){this.waitForAtomicChange(t,r);continue}if(Atomics.compareExchange(this.i32,t,r,r+1)===r)return}}inodeReadUnlock(e){let t=this.inodeOffset(e)+zt>>2;(Atomics.sub(this.i32,t,1)&Su)===1&&Atomics.notify(this.i32,t,1)}inodeWriteLock(e){let t=this.inodeOffset(e)+zt>>2;for(;;){let r=Atomics.load(this.i32,t);if(r!==0){this.waitForAtomicChange(t,r);continue}if(Atomics.compareExchange(this.i32,t,0,jo)===0)return}}inodeWriteUnlock(e){let t=this.inodeOffset(e)+zt>>2;Atomics.store(this.i32,t,0),Atomics.notify(this.i32,t,1/0)}inodeBlockMap(e,t,r){let i=this.inodeOffset(e);if(t<10){let o=this.r32(i+oe+t*4);if(o!==0)return o;if(!r)return 0;let s=this.blockAllocWithGrow();return s<0||this.w32(i+oe+t*4,s),s}if(t-=10,t<1024){let o=this.r32(i+or),s=!1;if(o===0){if(!r)return 0;if(o=this.blockAllocWithGrow(),o<0)return o;this.w32(i+or,o),s=!0}let a=o*4096+t*4,c=this.r32(a);if(c!==0)return c;if(!r)return 0;let u=this.blockAllocWithGrow();return u<0?(s&&(this.w32(i+or,0),this.blockFree(o)),u):(this.w32(a,u),u)}if(t-=1024,t<1024*1024){let o=Math.floor(t/1024),s=t%1024,a=this.r32(i+xt),c=!1;if(a===0){if(!r)return 0;if(a=this.blockAllocWithGrow(),a<0)return a;this.w32(i+xt,a),c=!0}let u=a*4096+o*4,l=this.r32(u),d=!1;if(l===0){if(!r)return 0;if(l=this.blockAllocWithGrow(),l<0)return c&&(this.w32(i+xt,0),this.blockFree(a)),l;this.w32(u,l),d=!0}let h=l*4096+s*4,m=this.r32(h);if(m!==0)return m;if(!r)return 0;let f=this.blockAllocWithGrow();return f<0?(d&&(this.w32(u,0),this.blockFree(l)),c&&(this.w32(i+xt,0),this.blockFree(a)),f):(this.w32(h,f),f)}return j}inodeReadData(e,t,r,i){let o=this.inodeOffset(e),s=this.r64(o+P);if(t>=s)return 0;t+i>s&&(i=s-t);let a=0,c=0;for(;i>0;){let u=Math.floor(t/4096),l=t%4096,d=4096-l;d>i&&(d=i);let h=this.inodeBlockMap(e,u,!1);if(h<=0)r.fill(0,c,c+d);else{let m=h*4096+l;r.set(this.u8.subarray(m,m+d),c)}c+=d,t+=d,i-=d,a+=d}return a}inodeWriteData(e,t,r,i){let o=this.inodeOffset(e),s=this.r64(o+P);t>s&&this.zeroOldEofTail(e,s);let a=0,c=0;for(;i>0;){let u=Math.floor(t/4096),l=t%4096,d=4096-l;d>i&&(d=i);let h=this.inodeBlockMap(e,u,!0);if(h<0){if(a===0)return h;break}let m=h*4096+l;this.u8.set(r.subarray(c,c+d),m),c+=d,t+=d,i-=d,a+=d}if(a>0&&t>this.r64(o+P)&&this.w64(o+P,t),a>0){let u=Date.now();this.w64(o+me,u),this.w64(o+ee,u),Atomics.add(this.i32,o+he>>2,1)}return a}zeroInodeRange(e,t,r){for(;t0){let c=a*4096+o;this.u8.fill(0,c,c+s)}t+=s}}zeroOldEofTail(e,t){let r=t%4096;if(r===0)return;let i=Math.floor(t/4096),o=this.inodeBlockMap(e,i,!1);if(o<=0)return;let s=o*4096+r;this.u8.fill(0,s,o*4096+4096)}freeBlocksFrom(e,t){let r=this.inodeOffset(e);for(let s=t;s<10;s++){let a=this.r32(r+oe+s*4);a&&(this.blockFree(a),this.w32(r+oe+s*4,0))}let i=this.r32(r+or);if(i){let s=t>10?t-10:0;for(let a=s;a<1024;a++){let c=i*4096+a*4,u=this.r32(c);u&&(this.blockFree(u),this.w32(c,0))}s===0&&(this.blockFree(i),this.w32(r+or,0))}let o=this.r32(r+xt);if(o){let s=t>1034?t-10-1024:0,a=Math.floor(s/1024);for(let c=a;c<1024;c++){let u=o*4096+c*4,l=this.r32(u);if(!l)continue;let d=c===a?s%1024:0;for(let h=d;h<1024;h++){let m=l*4096+h*4,f=this.r32(m);f&&(this.blockFree(f),this.w32(m,0))}d===0&&(this.blockFree(l),this.w32(u,0))}a===0&&(this.blockFree(o),this.w32(r+xt,0))}}inodeTruncate(e,t,r=!1){let i=this.inodeOffset(e),o=this.r64(i+P),s=t!==o;if(t>=o){if(t>o&&this.zeroOldEofTail(e,o),this.w64(i+P,t),s||r){let c=Date.now();this.w64(i+me,c),this.w64(i+ee,c),Atomics.add(this.i32,i+he>>2,1)}return}t%4096!==0&&this.zeroInodeRange(e,t,Math.ceil(t/4096)*4096);let a=Math.ceil(t/4096);if(this.freeBlocksFrom(e,a),this.w64(i+P,t),s||r){let c=Date.now();this.w64(i+me,c),this.w64(i+ee,c),Atomics.add(this.i32,i+he>>2,1)}}validateFileSize(e){if(!Number.isSafeInteger(e)||e<0)throw new A(j);if(e>je)throw new A(dt)}validateSeekPosition(e){if(!Number.isSafeInteger(e))throw new A(rs);if(e<0)throw new A(j);if(e>je)throw new A(dt)}touchDirectoryMutation(e){let t=this.inodeOffset(e),r=Date.now();this.w64(t+me,r),this.w64(t+ee,r);let i=Atomics.add(this.i32,t+qo>>2,1)+1>>>0,o=this.dirIndexes.get(e);o&&(o.mutationSequence=i,o.size=this.r64(t+P))}dirNameKey(e){return It(e)}dirEntryNameMatches(e,t){if(this.view.getUint16(e+6,!0)!==t.length)return!1;for(let i=0;i=F&&r%4===0&&e+r<=t&&i<=r-F}inodeIsAllocated(e){let t=this.r32(Ye);if(e<=0||e>=t)return!1;let r=this.r32(ct)*4096;return(Atomics.load(this.i32,(r>>2)+(e>>5))&1<<(e&31))!==0}rebuildDirIndex(e,t,r,i){let o=new Map,s=[],a=0;for(;a4096-l&&(m=4096-l);let f=l;for(;f=F&&s.push({abs:p,recLen:y});f+=y}a+=m}let c={generation:t,mutationSequence:r,size:i,entries:o,free:s};return this.dirIndexes.set(e,c),c}getDirIndex(e){let t=this.inodeOffset(e),r=this.r64(t+P),i=this.r64(t+pe),o=Atomics.load(this.i32,t+qo>>2)>>>0,s=this.dirIndexes.get(e);return s&&s.generation===i&&s.mutationSequence===o&&s.size===r?s:(s&&this.dirIndexes.delete(e),r=0;s--){let a=e.free[s];if(!(a.recLen4096-c&&(d=4096-c);let h=c;for(;hr)return-1;a=c,s+=u}return s===r?a:-1}dirAppendEntry(e,t,r,i=-1){let o=this.inodeOffset(e),s=this.r64(o+P),a=Je(F+t.length),c=s,u=Math.floor(c/4096),l=c%4096,d=0;if(l!==0&&l+a>4096){let f=4096-l,p=0;if(f>=F){if(p=this.inodeBlockMap(e,u,!1),p<=0)return $}else if(i<0&&(i=this.findLastDirEntryInBlock(e,u,l)),i<0)return $;if(d=this.inodeBlockMap(e,u+1,!0),d<0)return d;if(f>=F){let _=p*4096+l;this.w32(_,0),this.view.setUint16(_+4,f,!0),this.view.setUint16(_+6,0,!0)}else{let y=this.view.getUint16(i+4,!0)+f;this.view.setUint16(i+4,y,!0),this.updateDirIndexRecLen(e,i,y)}c=(u+1)*4096,u++,l=0}let h;if(l===0){if(h=d||this.inodeBlockMap(e,u,!0),h<0)return h}else if(h=this.inodeBlockMap(e,u,!1),h<=0)return $;let m=h*4096+l;return this.w32(m,r),this.view.setUint16(m+4,a,!0),this.view.setUint16(m+6,t.length,!0),this.u8.set(t,m+F),this.w64(o+P,c+a),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,m,a),0}dirAddEntry(e,t,r){let i=this.getDirIndex(e);if(typeof i=="number")return i;if(i)return this.useDirIndexFreeSlot(i,e,t,r)?0:this.dirAppendEntry(e,t,r);let o=this.inodeOffset(e),s=this.r64(o+P),a=Je(F+t.length),c=-1,u=0;for(;u4096-d&&(f=4096-d);let p=d;for(;pd+f||E>g-F)return $;if(y===0&&g>=a)return this.w32(_,r),this.view.setUint16(_+6,t.length,!0),this.u8.set(t,_+F),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,_,g),0;let O=Je(F+E),S=g-O;if(y!==0&&S>=a){this.view.setUint16(_+4,O,!0);let w=_+O;return this.w32(w,r),this.view.setUint16(w+4,S,!0),this.view.setUint16(w+6,t.length,!0),this.u8.set(t,w+F),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,w,S),0}c=_,p+=g}u+=f}return this.dirAppendEntry(e,t,r,c)}dirRemoveEntry(e,t){let r=this.getDirIndex(e);if(typeof r=="number")return r;if(r){let a=this.dirNameKey(t),c=r.entries.get(a);if(!c)return Ie;if(this.r32(c.abs)===c.ino&&this.view.getUint16(c.abs+4,!0)===c.recLen&&this.view.getUint16(c.abs+6,!0)===c.nameLen&&this.dirEntryNameMatches(c.abs,t))return this.w32(c.abs,0),r.entries.delete(a),r.free.push({abs:c.abs,recLen:c.recLen}),this.touchDirectoryMutation(e),0;r.entries.delete(a)}let i=this.inodeOffset(e),o=this.r64(i+P),s=0;for(;s4096-c&&(d=4096-c);let h=c;for(;h4096-u&&(h=4096-u);let m=u;for(;m4096-s&&(u=4096-s);let l=s;for(;ls+u||f>m-F)throw new A($);if(h!==0){if(f===1&&this.u8[d+F]===46){l+=m;continue}if(f===2&&this.u8[d+F]===46&&this.u8[d+F+1]===46){l+=m;continue}return!1}l+=m}i+=u}return!0}dirIsAncestor(e,t){let r=t;for(let i=0;i<8*1024;i++){if(r===e)return!0;if(r===1)return!1;let o=this.dirLookup(r,Jo);if(o<0||o===r)throw new A($);r=o}throw new A($)}pathResolve(e,t){if(!e.startsWith("/"))return Ie;let r=1,i=e.split("/").filter(s=>s.length>0),o=0;for(let s=0;s255)return Jn;let c=_e.encode(a),u;this.inodeReadLock(r);try{let h=this.inodeOffset(r);if((this.r32(h+C)&W)!==H)return Fe;u=this.dirLookup(r,c)}finally{this.inodeReadUnlock(r)}if(u<0)return u;let l=this.inodeOffset(u);if((this.r32(l+C)&W)===wt&&(!(s===i.length-1)||t)){if(++o>8)return ts;let m=this.r64(l+P),f;if(m<=40)f=It(this.u8.subarray(l+oe,l+oe+m));else{let p=new Uint8Array(m);this.inodeReadData(u,0,p,m),f=ur.decode(p)}if(f.startsWith("/")){r=1;let p=f.split("/").filter(y=>y.length>0),_=i.slice(s+1);i.length=0,i.push(...p,..._),s=-1}else{let p=f.split("/").filter(y=>y.length>0),_=i.slice(s+1);i.length=s,i.push(...p,..._),s--}continue}r=u}return r}pathResolveParent(e){if(!e.startsWith("/"))throw new A(j,"Path must be absolute");let t=e.split("/").filter(c=>c.length>0);if(t.length===0)throw new A(j,"Cannot operate on /");let r=t.pop();if(r.length>255)throw new A(Jn);let i="/"+t.join("/"),o=this.pathResolve(i,!0);if(o<0)throw new A(o);let s=this.inodeOffset(o);if((this.r32(s+C)&W)!==H)throw new A(Fe);return{parentIno:o,name:r}}fdAlloc(e,t,r){for(let i=0;i>2;if(Atomics.compareExchange(this.i32,s,0,1)===0)return this.w32(o+Zo,e),this.w64(o+ke,0),this.w32(o+Yo,t),this.w32(o+Xo,r?1:0),this.inodeAddOpenRef(e)?i:(Atomics.store(this.i32,s,0),Ie)}return Qo}fdGet(e){if(e<0||e>=Gr)return null;let t=256+e*24;return Atomics.load(this.i32,t>>2)?{base:t,ino:this.r32(t+Zo),offset:this.r64(t+ke),flags:this.r32(t+Yo),isDir:this.r32(t+Xo)!==0}:null}fdFree(e){if(e>=0&&e>2,0)}}buildStat(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+pe),dataSequence:this.r32(t+he),mode:this.r32(t+C),linkCount:this.r32(t+B),size:this.r64(t+P),mtime:this.r64(t+me),ctime:this.r64(t+ee),atime:this.r64(t+ir),uid:this.r32(t+sr),gid:this.r32(t+ar)}}namespaceEntryIdentity(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+pe),linkCount:this.r32(t+B),mode:this.r32(t+C)}}open(e,t,r=420){return this.withNamespaceLock(()=>this.openUnlocked(e,t,r))}createLazyStub(e,t){return this.withNamespaceLock(()=>{let r=this.openUnlocked(e,Go|cr,t);try{let i=this.fdGet(r);if(!i)throw new A(re);this.inodeWriteLock(i.ino);try{return this.inodeTruncate(i.ino,0,!0),this.buildStat(i.ino)}finally{this.inodeWriteUnlock(i.ino)}}finally{this.closeUnlocked(r)}})}replaceIfIdentity(e,t,r,i,o){return this.withNamespaceLock(()=>{let s=this.pathResolve(e,!0);if(s<0||s!==t)return!1;let a=this.inodeOffset(s);if(this.r64(a+pe)!==r||this.r32(a+he)!==i||(this.r32(a+C)&W)!==tr)return!1;this.validateFileSize(o.byteLength),this.inodeWriteLock(s);try{if(this.r64(a+pe)!==r||this.r32(a+he)!==i||this.r64(a+P)!==0)return!1;let c=this.r64(a+me),u=this.r64(a+ee);this.inodeTruncate(s,0,!0);let l=o.byteLength>0?this.inodeWriteData(s,0,o,o.byteLength):0;if(l!==o.byteLength)throw this.inodeTruncate(s,0,!0),Atomics.store(this.i32,a+he>>2,i),this.w64(a+me,c),this.w64(a+ee,u),new A(l<0?l:se);return!0}finally{this.inodeWriteUnlock(s)}})}replaceManyIfIdentities(e,t=[]){return e.length===0&&t.length===0?!0:this.withNamespaceLock(()=>{let r=[],i=new Set,o=a=>{let c=this.pathResolve(a.path,!1);if(c<0||c!==a.expectedIno)return!1;let u=this.inodeOffset(c);return this.r64(u+pe)===a.expectedGeneration&&this.r32(u+he)===a.expectedDataSequence&&this.r32(u+C)===a.expectedMode&&this.r32(u+B)===a.expectedLinkCount&&this.r64(u+P)===a.expectedSize&&this.r32(u+sr)===a.expectedUid&&this.r32(u+ar)===a.expectedGid};for(let a of t)if(!o(a))return!1;for(let a of e){this.validateFileSize(a.data.byteLength);let c=-1;for(let u of a.paths){let l=this.pathResolve(u,!0);if(l!==a.expectedIno)continue;let d=this.inodeOffset(l);if(this.r64(d+pe)===a.expectedGeneration&&this.r32(d+he)===a.expectedDataSequence&&(this.r32(d+C)&W)===tr&&this.r64(d+P)===0){c=l;break}}if(c<0)return!1;if(i.has(c))throw new A(j,"duplicate conditional replacement inode");i.add(c),r.push({...a,ino:c})}let s=[...i].sort((a,c)=>a-c);for(let a of s)this.inodeWriteLock(a);try{for(let u of r){let l=this.inodeOffset(u.ino);if(this.r64(l+pe)!==u.expectedGeneration||this.r32(l+he)!==u.expectedDataSequence||(this.r32(l+C)&W)!==tr||this.r64(l+P)!==0)return!1}for(let u of t)if(!o(u))return!1;let a=r.map(u=>{let l=this.inodeOffset(u.ino);return{ino:u.ino,dataSequence:this.r32(l+he),mtime:this.r64(l+me),ctime:this.r64(l+ee)}}),c=0;try{for(let u of r){c++,this.inodeTruncate(u.ino,0,!0);let l=u.data.byteLength>0?this.inodeWriteData(u.ino,0,u.data,u.data.byteLength):0;if(l!==u.data.byteLength)throw new A(l<0?l:se)}}catch(u){for(let l=c-1;l>=0;l--){let d=a[l],h=this.inodeOffset(d.ino);this.inodeTruncate(d.ino,0,!0),Atomics.store(this.i32,h+he>>2,d.dataSequence),this.w64(h+me,d.mtime),this.w64(h+ee,d.ctime)}throw u}return!0}finally{for(let a=s.length-1;a>=0;a--)this.inodeWriteUnlock(s[a])}})}openUnlocked(e,t,r=420){let i=t&rr,o=(t&cr)!==0,s=(t&ei)!==0;if(o&&s){let d=this.pathResolve(e,!1);if(d>=0)throw new A(Tt);if(d!==Ie)throw new A(d)}let a=this.pathResolve(e,!0);if(a<0&&a===Ie&&o){let{parentIno:d,name:h}=this.pathResolveParent(e);this.inodeWriteLock(d);try{let m=_e.encode(h),f=this.dirLookup(d,m);if(f>=0){if(s)throw new A(Tt);a=f}else{let p=this.inodeAlloc();if(p<0)throw new A(se);let _=this.inodeOffset(p);this.w32(_+C,tr|r&4095),this.w32(_+B,1),this.w64(_+P,0);let y=Date.now();this.w64(_+ir,y),this.w64(_+me,y),this.w64(_+ee,y);let g=this.dirAddEntry(d,m,p);if(g<0)throw this.inodeFree(p),new A(g);a=p}}finally{this.inodeWriteUnlock(d)}}if(a<0)throw new A(a);let c=this.inodeOffset(a),u=this.r32(c+C);if((u&W)===H&&i!==at)throw new A(ut);if(t&lu&&(u&W)!==H)throw new A(Fe);if(t&dr){if((u&W)===H)throw new A(ut);this.inodeWriteLock(a),this.inodeTruncate(a,0,!0),this.inodeWriteUnlock(a)}let l=this.fdAlloc(a,t,!1);if(l<0)throw new A(l);return l}close(e){this.withNamespaceLock(()=>this.closeUnlocked(e))}closeUnlocked(e){let t=this.fdGet(e);if(!t)throw new A(re);this.fdFree(e),this.inodeDropOpenRef(t.ino)}read(e,t){let r=this.fdGet(e);if(!r)throw new A(re);let i=this.inodeOffset(r.ino);if((this.r32(i+C)&W)===H)throw new A(ut);this.inodeReadLock(r.ino);try{let s=this.inodeReadData(r.ino,r.offset,t,t.length),a=256+e*24;return this.w64(a+ke,r.offset+s),s}finally{this.inodeReadUnlock(r.ino)}}readAt(e,t,r){let i=this.fdGet(e);if(!i)throw new A(re);let o=this.inodeOffset(i.ino);if((this.r32(o+C)&W)===H)throw new A(ut);this.validateSeekPosition(r),this.inodeReadLock(i.ino);try{return this.inodeReadData(i.ino,r,t,t.length)}finally{this.inodeReadUnlock(i.ino)}}write(e,t){let r=this.fdGet(e);if(!r)throw new A(re);if((r.flags&rr)===at)throw new A(re);this.inodeWriteLock(r.ino);try{let o=r.offset;if(r.flags&du){let c=this.inodeOffset(r.ino);o=this.r64(c+P)}if(!Number.isSafeInteger(o)||o<0)throw new A(j);if(o>je||t.length>je-o)throw new A(dt);let s=this.inodeWriteData(r.ino,o,t,t.length);if(s<0)return s;let a=256+e*24;return this.w64(a+ke,o+s),s}finally{this.inodeWriteUnlock(r.ino)}}append(e,t,r){let i=this.fdGet(e);if(!i)throw new A(re);if((i.flags&rr)===at)throw new A(re);if(r!==null&&(!Number.isSafeInteger(r)||r<0))throw new A(j);this.inodeWriteLock(i.ino);try{let s=this.inodeOffset(i.ino),a=this.r64(s+P);if(!Number.isSafeInteger(a)||a<0)throw new A(j);if(a>je)throw new A(dt);if(r!==null&&a>=r){let f=256+e*24;return this.w64(f+ke,a),{written:0,end:a}}let c=r===null?t.length:Math.min(t.length,r-a),u=je-a;if(c>u)throw new A(dt);let l=t.subarray(0,c),d=this.inodeWriteData(i.ino,a,l,l.length);if(d<0)throw new A(d);let h=256+e*24,m=a+d;return this.w64(h+ke,m),{written:d,end:m}}finally{this.inodeWriteUnlock(i.ino)}}writeAt(e,t,r){let i=this.fdGet(e);if(!i)throw new A(re);if((i.flags&rr)===at)throw new A(re);this.validateSeekPosition(r),this.inodeWriteLock(i.ino);try{if(r>je||t.length>je-r)throw new A(dt);return this.inodeWriteData(i.ino,r,t,t.length)}finally{this.inodeWriteUnlock(i.ino)}}lseek(e,t,r){let i=this.fdGet(e);if(!i)throw new A(re);let o;if(r===fu)o=t;else if(r===pu)o=i.offset+t;else if(r===hu){let a=this.inodeOffset(i.ino);o=this.r64(a+P)+t}else throw new A(j);this.validateSeekPosition(o);let s=256+e*24;return this.w64(s+ke,o),o}ftruncate(e,t){let r=this.fdGet(e);if(!r)throw new A(re);if((r.flags&rr)===at)throw new A(re);this.validateFileSize(t),this.inodeWriteLock(r.ino);try{this.inodeTruncate(r.ino,t,!0)}finally{this.inodeWriteUnlock(r.ino)}}fstat(e){let t=this.fdGet(e);if(!t)throw new A(re);this.inodeReadLock(t.ino);try{return this.buildStat(t.ino)}finally{this.inodeReadUnlock(t.ino)}}stat(e){return this.withNamespaceLock(()=>this.statUnlocked(e))}statUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new A(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}lstat(e){return this.withNamespaceLock(()=>this.lstatUnlocked(e))}lstatUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new A(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}unlink(e){return this.withNamespaceLock(()=>this.unlinkUnlocked(e))}unlinkUnlocked(e){let{parentIno:t,name:r}=this.pathResolveParent(e),i=_e.encode(r),o=e.length>1&&e.endsWith("/");this.inodeWriteLock(t);try{let s=this.dirLookup(t,i);if(s<0)throw new A(s);let a=this.inodeOffset(s),c=this.r32(a+C);if(o&&(c&W)!==H)throw new A(Fe);if((c&W)===H)throw new A(ut);let u=this.namespaceEntryIdentity(s),l=this.dirRemoveEntry(t,i);if(l<0)throw new A(l);let d=!1;this.inodeWriteLock(s);try{d=this.inodeDropLinkRefLocked(s)}finally{this.inodeWriteUnlock(s)}return d&&this.inodeFree(s),u}finally{this.inodeWriteUnlock(t)}}rename(e,t){return this.withNamespaceLock(()=>this.renameUnlocked(e,t))}renameUnlocked(e,t){let{parentIno:r,name:i}=this.pathResolveParent(e),{parentIno:o,name:s}=this.pathResolveParent(t);if(Xn(i)||Xn(s))throw new A(j);let a=_e.encode(i),c=_e.encode(s),u=e.length>1&&e.endsWith("/"),l=t.length>1&&t.endsWith("/"),d=Math.min(r,o),h=Math.max(r,o);this.inodeWriteLock(d),d!==h&&this.inodeWriteLock(h);try{let m=this.dirLookup(r,a);if(m<0)throw new A(m);let f=this.inodeOffset(m),_=this.r32(f+C)&W,y=this.namespaceEntryIdentity(m);if((u||l)&&_!==H)throw new A(Fe);if(_===H&&this.dirIsAncestor(m,o))throw new A(j);let g=this.dirLookup(o,c),E=!1,O;if(g>=0){if(g===m)return{source:y,replaced:y};O=this.namespaceEntryIdentity(g);let w=this.inodeOffset(g),x=this.r32(w+C)&W;if(_===H&&x!==H)throw new A(Fe);if(_!==H&&x===H)throw new A(ut);let I=!1,R=g===r||g===o;R||this.inodeWriteLock(g);try{if(x===H&&!this.dirIsEmpty(g))throw new A(Qn);let L=this.dirReplaceEntryIno(o,c,m);if(L<0)throw new A(L);I=x===H?this.inodeOrphanLocked(g):this.inodeDropLinkRefLocked(g)}finally{R||this.inodeWriteUnlock(g)}I&&this.inodeFree(g),E=x===H}else{let w=this.dirAddEntry(o,c,m);if(w<0)throw new A(w)}let S=this.dirRemoveEntry(r,a);if(S<0)throw new A(S);if(_===H){if(r!==o){let w=this.inodeOffset(r);this.w32(w+B,this.r32(w+B)-1);let z=this.inodeOffset(o);this.w32(z+B,this.r32(z+B)+1),this.inodeWriteLock(m);try{let x=this.dirReplaceEntryIno(m,Jo,o);if(x<0)throw new A(x);this.w64(f+ee,Date.now())}finally{this.inodeWriteUnlock(m)}}if(E){let w=this.inodeOffset(o);this.w32(w+B,this.r32(w+B)-1)}}else if(E){let w=this.inodeOffset(o);this.w32(w+B,this.r32(w+B)-1)}return{source:y,replaced:O}}finally{d!==h&&this.inodeWriteUnlock(h),this.inodeWriteUnlock(d)}}mkdir(e,t=493){this.withNamespaceLock(()=>this.mkdirUnlocked(e,t))}mkdirUnlocked(e,t=493){let{parentIno:r,name:i}=this.pathResolveParent(e),o=_e.encode(i);this.inodeWriteLock(r);try{if(this.dirLookup(r,o)>=0)throw new A(Tt);let a=this.inodeAlloc();if(a<0)throw new A(se);let c=this.inodeOffset(a);this.w32(c+C,H|t),this.w32(c+B,2),this.w64(c+P,0);let u=Date.now();this.w64(c+ir,u),this.w64(c+me,u),this.w64(c+ee,u);let l=this.blockAllocWithGrow();if(l<0)throw this.inodeFree(a),new A(se);this.w32(c+oe,l);let d=l*4096,h=Je(F+1),m=Je(F+2);this.w32(d,a),this.view.setUint16(d+4,h,!0),this.view.setUint16(d+6,1,!0),this.u8[d+F]=46;let f=d+h;this.w32(f,r),this.view.setUint16(f+4,m,!0),this.view.setUint16(f+6,2,!0),this.u8[f+F]=46,this.u8[f+F+1]=46,this.w64(c+P,h+m);let p=this.dirAddEntry(r,o,a);if(p<0)throw this.blockFree(l),this.inodeFree(a),new A(p);let _=this.inodeOffset(r);this.w32(_+B,this.r32(_+B)+1)}finally{this.inodeWriteUnlock(r)}}rmdir(e){this.withNamespaceLock(()=>this.rmdirUnlocked(e))}rmdirUnlocked(e){let{parentIno:t,name:r}=this.pathResolveParent(e);if(Xn(r))throw new A(j);let i=_e.encode(r);this.inodeWriteLock(t);try{let o=this.dirLookup(t,i);if(o<0)throw new A(o);let s=this.inodeOffset(o);if((this.r32(s+C)&W)!==H)throw new A(Fe);let c=!1;this.inodeWriteLock(o);try{if(!this.dirIsEmpty(o))throw new A(Qn);let l=this.dirRemoveEntry(t,i);if(l<0)throw new A(l);c=this.inodeOrphanLocked(o)}finally{this.inodeWriteUnlock(o)}c&&this.inodeFree(o);let u=this.inodeOffset(t);this.w32(u+B,this.r32(u+B)-1)}finally{this.inodeWriteUnlock(t)}}symlink(e,t){this.withNamespaceLock(()=>this.symlinkUnlocked(e,t))}symlinkUnlocked(e,t){let{parentIno:r,name:i}=this.pathResolveParent(t),o=_e.encode(i),s=_e.encode(e);this.inodeWriteLock(r);try{if(this.dirLookup(r,o)>=0)throw new A(Tt);let c=this.inodeAlloc();if(c<0)throw new A(se);let u=this.inodeOffset(c);if(this.w32(u+C,wt|511),this.w32(u+B,1),s.length<=40)this.u8.set(s,u+oe),this.w64(u+P,s.length);else{this.w64(u+P,0);let d=this.inodeWriteData(c,0,s,s.length);if(d!==s.length)throw d>0&&this.inodeTruncate(c,0),this.inodeFree(c),new A(d<0?d:se)}let l=this.dirAddEntry(r,o,c);if(l<0)throw s.length<=40?(this.u8.fill(0,u+oe,u+oe+40),this.w64(u+P,0)):this.inodeTruncate(c,0),this.inodeFree(c),new A(l)}finally{this.inodeWriteUnlock(r)}}chmod(e,t){this.withNamespaceLock(()=>this.chmodUnlocked(e,t))}chmodUnlocked(e,t){let r=this.pathResolve(e,!0);if(r<0)throw new A(r);this.inodeWriteLock(r);try{let i=this.inodeOffset(r),o=this.r32(i+C);this.w32(i+C,o&W|t&4095),this.w64(i+ee,Date.now())}finally{this.inodeWriteUnlock(r)}}fchmod(e,t){let r=this.fdGet(e);if(!r)throw new A(re);this.inodeWriteLock(r.ino);try{let i=this.inodeOffset(r.ino),o=this.r32(i+C);this.w32(i+C,o&W|t&4095),this.w64(i+ee,Date.now())}finally{this.inodeWriteUnlock(r.ino)}}chown(e,t,r){this.withNamespaceLock(()=>this.chownUnlocked(e,t,r))}chownUnlocked(e,t,r){let i=this.pathResolve(e,!0);if(i<0)throw new A(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,r)}finally{this.inodeWriteUnlock(i)}}fchown(e,t,r){let i=this.fdGet(e);if(!i)throw new A(re);this.inodeWriteLock(i.ino);try{this.chownInodeUnlocked(i.ino,t,r)}finally{this.inodeWriteUnlock(i.ino)}}lchown(e,t,r){this.withNamespaceLock(()=>this.lchownUnlocked(e,t,r))}lchownUnlocked(e,t,r){let i=this.pathResolve(e,!1);if(i<0)throw new A(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,r)}finally{this.inodeWriteUnlock(i)}}chownInodeUnlocked(e,t,r){let i=this.inodeOffset(e);t!==Wo&&this.w32(i+sr,t),r!==Wo&&this.w32(i+ar,r);let o=this.r32(i+C);(o&W)===tr&&(o&uu)!==0&&this.w32(i+C,o&~(au|cu)),this.w64(i+ee,Date.now())}utimens(e,t,r,i,o){this.withNamespaceLock(()=>this.utimensUnlocked(e,t,r,i,o))}utimensUnlocked(e,t,r,i,o){let s=this.pathResolve(e,!0);if(s<0)throw new A(s);this.inodeWriteLock(s);try{let a=this.inodeOffset(s),c=1073741823,u=1073741822,l=Date.now();if(r!==u){let d=r===c?l:t*1e3+Math.floor(r/1e6);this.w64(a+ir,d)}if(o!==u){let d=o===c?l:i*1e3+Math.floor(o/1e6);this.w64(a+me,d)}this.w64(a+ee,l)}finally{this.inodeWriteUnlock(s)}}link(e,t){return this.withNamespaceLock(()=>this.linkUnlocked(e,t))}linkUnlocked(e,t){let r=this.pathResolve(e,!1);if(r<0)throw new A(r);let i=this.inodeOffset(r);if((this.r32(i+C)&W)===H)throw new A(yu);let{parentIno:s,name:a}=this.pathResolveParent(t),c=_e.encode(a);this.inodeWriteLock(s);try{if(this.dirLookup(s,c)>=0)throw new A(Tt);let l=this.dirAddEntry(s,c,r);if(l<0)throw new A(l);this.inodeWriteLock(r);try{let d=this.r32(i+B);this.w32(i+B,d+1),this.w64(i+ee,Date.now())}finally{this.inodeWriteUnlock(r)}return{...this.namespaceEntryIdentity(r),linkCount:this.r32(i+B)}}finally{this.inodeWriteUnlock(s)}}readlink(e){return this.withNamespaceLock(()=>this.readlinkUnlocked(e))}readlinkUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new A(t);return this.readSymlinkInodeUnlocked(t)}readSymlinkInodeUnlocked(e){let t=this.inodeOffset(e);if((this.r32(t+C)&W)!==wt)throw new A(j);let i=this.r64(t+P);if(i<=40)return It(this.u8.subarray(t+oe,t+oe+i));this.inodeReadLock(e);try{let o=new Uint8Array(i);return this.inodeReadData(e,0,o,i),ur.decode(o)}finally{this.inodeReadUnlock(e)}}opendir(e){return this.withNamespaceLock(()=>this.opendirUnlocked(e))}opendirUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new A(t);let r=this.inodeOffset(t);if((this.r32(r+C)&W)!==H)throw new A(Fe);let o=this.fdAlloc(t,at,!0);if(o<0)throw new A(o);return o}readdirEntry(e){return this.withNamespaceLock(()=>this.readdirEntryUnlocked(e))}readdirEntryUnlocked(e){let t=this.fdGet(e);if(!t||!t.isDir)throw new A(re);let r=this.inodeOffset(t.ino),i=this.r64(r+P);for(;t.offset=this.r32(Ye))throw new A($);let p=this.r32(ct)*4096;if((this.r32(p+(l>>5)*4)&1<<(l&31))===0)throw new A($);let y=It(this.u8.subarray(u+F,u+F+h)),g=this.buildStat(l);return this.w64(f+ke,m),t.offset=m,{name:y,stat:g}}return null}closedir(e){this.close(e)}readdir(e){let t=this.opendir(e),r=[];try{let i;for(;(i=this.readdirEntry(t))!==null;)i.name!=="."&&i.name!==".."&&r.push(i.name)}finally{this.closedir(t)}return r}writeFile(e,t){let r=typeof t=="string"?_e.encode(t):t,i=this.open(e,Go|cr|dr);try{this.write(i,r)}finally{this.close(i)}}readFile(e){let t=this.open(e,at);try{let r=this.fstat(t),i=new Uint8Array(r.size);return this.read(t,i),i}finally{this.close(t)}}readFileText(e){return ur.decode(this.readFile(e))}};function ns(n,e){let t=new Map,r=new Map;for(let s of n){if(t.has(s.path))throw new Error(`${e} duplicates path ${s.path}`);if(t.set(s.path,s),s.type==="file"){if(!s.inodeGroup)throw new Error(`${e} file ${s.path} has no inode group`);if(r.has(s.inodeGroup))throw new Error(`${e} inode group ${s.inodeGroup} has multiple files`);r.set(s.inodeGroup,s)}}let i=new Set,o=new Map;for(let s of n){if(s.type!=="hardlink"||o.has(s.path))continue;let a=[],c=s,u;for(;c.type==="hardlink";){let d=o.get(c.path);if(d){u=d;break}if(i.has(c.path))throw new Error(`${e} hardlink cycle reaches ${c.path}`);if(i.add(c.path),a.push(c),!c.target)throw new Error(`${e} hardlink ${c.path} has no target`);let h=t.get(c.target);if(!h)throw new Error(`${e} hardlink ${c.path} target ${c.target} is missing`);if(h.type!=="file"&&h.type!=="hardlink"||!c.inodeGroup||h.inodeGroup!==c.inodeGroup||h.size!==c.size||h.mode!==c.mode)throw new Error(`${e} hardlink ${c.path} has an invalid target`);c=h}u??=c.type==="file"?c:void 0;let l=r.get(s.inodeGroup??"");if(!u||u!==l)throw new Error(`${e} hardlink ${s.path} does not resolve to its inode`);for(let d=a.length-1;d>=0;d-=1){let h=a[d];if(r.get(h.inodeGroup??"")!==u)throw new Error(`${e} hardlink ${h.path} does not resolve to its inode`);i.delete(h.path),o.set(h.path,u)}}return{canonicalByGroup:r,canonicalTargetByPath:o}}var D={maxArchiveBytes:268435456,maxExpandedBytes:268435456,maxPayloadBytes:268435456,maxEntries:1e5,maxPathBytes:4096,maxSymlinkTargetBytes:65536,maxStringBytes:8192,maxTransportsPerTree:8,maxActivationCapabilities:32,maxActivationRoots:64,maxActivationCapabilityBytes:255,maxMaterializationAssertions:32,maxMaterializationAssertionBytes:1048576,maxMaterializationRecipes:32,maxMaterializationTransforms:1e5,maxMaterializationDecodedBytes:8388608,maxTransformReplacements:32,maxTransformPatternBytes:8192},be={maxArchiveBytes:512*1024*1024,maxExpandedBytes:512*1024*1024,maxPayloadBytes:512*1024*1024,maxEntries:1e5,maxGroups:512};function is(n,e="Deferred tree collection"){for(let[t,r]of Object.entries(n))if(!Number.isSafeInteger(r)||r<0)throw new Error(`${e} ${t} usage is invalid`);if(n.groups>be.maxGroups)throw new Error(`${e} exceeds the ${be.maxGroups}-group cap`);if(n.archiveBytes>be.maxArchiveBytes)throw new Error(`${e} exceeds the archive-byte cap`);if(n.expandedBytes>be.maxExpandedBytes)throw new Error(`${e} exceeds the expansion cap`);if(n.payloadBytes>be.maxPayloadBytes)throw new Error(`${e} exceeds the payload-byte cap`);if(n.entries>be.maxEntries)throw new Error(`${e} exceeds the entry-count cap`)}function Rt(n,e="Canonical text"){for(let t=0;t57343)){if(r<=56319&&t+1=56320&&n.charCodeAt(t+1)<=57343){t+=1;continue}throw new Error(`${e} must contain only Unicode scalar values`)}}}function lr(n,e){Rt(n),Rt(e);let t=0,r=0;for(;t65535?2:1,r+=o>65535?2:1}return tD.maxEntries)throw new Error("Lazy tree materialization source inventory is unbounded");let t=new Map;for(let[h,m]of e.entries.entries()){let f=ti(m.sourcePath,`Lazy tree materialization source ${h} path`);if(t.has(f))throw new Error(`Lazy tree materialization source repeats ${f}`);if(m.type!=="directory"&&m.type!=="file"&&m.type!=="symlink"&&m.type!=="hardlink")throw new Error(`Lazy tree materialization source ${f} has invalid type`);ds(m.size,`Lazy tree materialization source ${f} byte count`,0,D.maxPayloadBytes),t.set(f,m)}let r=Lt(n,["schema","kind","assertions","recipes","transforms"],"Lazy tree materialization plan");if(r.schema!==1||r.kind!=="archive-byte-transforms-v1")throw new Error("Lazy tree materialization plan has an unsupported identity");let i=0,o=new Set,s=pr(r.assertions,"Lazy tree materialization assertions",0,D.maxMaterializationAssertions).map((h,m)=>{let f=Lt(h,["sourcePath","bytesHex"],`Lazy tree materialization assertion ${m}`),p=ti(f.sourcePath,`Lazy tree materialization assertion ${m} source path`);if(o.has(p))throw new Error(`Lazy tree materialization repeats assertion ${p}`);o.add(p);let _=t.get(p);if(_?.type!=="file")throw new Error(`Lazy tree materialization assertion ${p} is not a regular source`);let y=hr(f.bytesHex,`Lazy tree materialization assertion ${p} bytes`,D.maxMaterializationAssertionBytes,!0);if(i=jr(i,y.length/2),y.length/2!==_.size)throw new Error(`Lazy tree materialization assertion ${p} size differs from source`);return{sourcePath:p,bytesHex:y}}),a=new Map,c=pr(r.recipes,"Lazy tree materialization recipes",0,D.maxMaterializationRecipes).map((h,m)=>{let f=us(h,`Lazy tree materialization recipe ${m}`);if(a.has(f.recipe.id))throw new Error(`Lazy tree materialization duplicates recipe ${f.recipe.id}`);return i=jr(i,f.decodedBytes),a.set(f.recipe.id,f.recipe),f.recipe}),u=new Set,l=new Set,d=pr(r.transforms,"Lazy tree materialization transforms",0,D.maxMaterializationTransforms).map((h,m)=>{let f=Lt(h,["sourcePath","recipe","input","output"],`Lazy tree materialization transform ${m}`),p=ti(f.sourcePath,`Lazy tree materialization transform ${m} source path`);if(u.has(p))throw new Error(`Lazy tree materialization repeats transform ${p}`);u.add(p);let _=t.get(p);if(_?.type!=="file")throw new Error(`Lazy tree materialization transform ${p} is not a regular source`);let y=ii(f.recipe,`Lazy tree materialization transform ${p} recipe`,D.maxStringBytes);if(!a.has(y))throw new Error(`Lazy tree materialization transform ${p} has no recipe ${y}`);l.add(y);let g=os(f.input,`Lazy tree materialization transform ${p} input`),E=os(f.output,`Lazy tree materialization transform ${p} output`);if(g.bytes!==_.size)throw new Error(`Lazy tree materialization transform ${p} input size differs from source`);return{sourcePath:p,recipe:y,input:g,output:E}});if(s.length===0&&d.length===0)throw new Error("Lazy tree materialization plan has no assertions or transforms");if(c.some(h=>!l.has(h.id)))throw new Error("Lazy tree materialization plan contains an unused recipe");if(!ri(s.map(h=>h.sourcePath))||!ri(c.map(h=>h.id))||!ri(d.map(h=>h.sourcePath)))throw new Error("Lazy tree materialization plan is not in canonical order");return{schema:1,kind:"archive-byte-transforms-v1",assertions:s,recipes:c,transforms:d}}function fr(n){let e=hr(n,"Materialization bytes",D.maxMaterializationDecodedBytes,!0),t=new Uint8Array(e.length/2);for(let r=0;rD.maxPayloadBytes)throw new Error("Lazy tree byte transform exceeds its source-byte limit");let t=us(e,"Lazy tree byte transform recipe").recipe,r=n;for(let i of t.replacements)r=Au(r,fr(i.matchHex),fr(i.replacementHex));for(let i of t.rejectHex)if(zu(r,fr(i)))throw new Error(`Lazy tree byte transform retains rejected byte sequence ${i}`);return r}function us(n,e){let t=Lt(n,["id","replacements","rejectHex"],e),r=ii(t.id,`${e} id`,D.maxStringBytes);if(!xu(r))throw new Error(`${e} id is invalid`);let i=0,o=pr(t.replacements,`${e} replacements`,0,D.maxTransformReplacements).map((a,c)=>{let u=Lt(a,["matchHex","replacementHex"],`${e} replacement ${c}`),l=hr(u.matchHex,`${e} match`,D.maxTransformPatternBytes,!1),d=hr(u.replacementHex,`${e} replacement`,D.maxTransformPatternBytes,!0);return i=jr(i,l.length/2+d.length/2),{matchHex:l,replacementHex:d}}),s=pr(t.rejectHex,`${e} rejected patterns`,0,D.maxTransformReplacements).map((a,c)=>{let u=hr(a,`${e} rejected pattern ${c}`,D.maxTransformPatternBytes,!1);return i=jr(i,u.length/2),u});if(o.length===0&&s.length===0||new Set(s).size!==s.length)throw new Error(`${e} is empty or ambiguous`);return{recipe:{id:r,replacements:o,rejectHex:s},decodedBytes:i}}function Au(n,e,t){let r=0;for(let u=0;u<=n.byteLength-e.byteLength;)ni(n,e,u)?(r+=1,u+=e.byteLength):u+=1;if(r===0)return n;let i=t.byteLength-e.byteLength,o=n.byteLength+r*i;if(!Number.isSafeInteger(o)||o<0||o>D.maxPayloadBytes)throw new Error("Lazy tree byte transform exceeds its transformed-byte limit");let s=new Uint8Array(o),a=0,c=0;for(;an.byteLength)return!1;for(let t=0;t<=n.byteLength-e.byteLength;t+=1)if(ni(n,e,t))return!0;return!1}function ni(n,e,t){if(t+e.byteLength>n.byteLength)return!1;for(let r=0;rs!==o[a]))throw new Error(`${t} has unexpected fields`);return r}function pr(n,e,t,r){if(!Array.isArray(n)||n.lengthr)throw new Error(`${e} must contain ${t} to ${r} items`);return n}function ii(n,e,t){if(typeof n!="string")throw new Error(`${e} is invalid or exceeds ${t} bytes`);if(Rt(n,e),n.length===0||n.includes("\0")||new TextEncoder().encode(n).byteLength>t)throw new Error(`${e} is invalid or exceeds ${t} bytes`);return n}function ds(n,e,t,r){if(!Number.isSafeInteger(n)||Number(n)r)throw new Error(`${e} must be an integer between ${t} and ${r}`);return Number(n)}function hr(n,e,t,r){if(typeof n!="string"||!r&&n.length===0||n.length%2!==0||n.length/2>t||!ls(n))throw new Error(`${e} is not canonical bounded hexadecimal bytes`);return n}function ti(n,e){let t=ii(n,e,D.maxPathBytes);if(t.startsWith("/")||t.includes("\\")||t.split("/").some(r=>r===""||r==="."||r===".."))throw new Error(`${e} is not a canonical relative path`);return t}function jr(n,e){let t=n+e;if(!Number.isSafeInteger(t)||t>D.maxMaterializationDecodedBytes)throw new Error("Lazy tree materialization plan exceeds its decoded byte limit");return t}function ri(n){return n.every((e,t)=>t===0||lr(n[t-1],e)<0)}function xu(n){if(!ss(n.charCodeAt(0)))return!1;for(let e=1;e=97&&n<=122||n>=48&&n<=57}function ls(n,e){if(e!==void 0&&n.length!==e)return!1;for(let t=0;t=48&&r<=57)&&!(r>=97&&r<=102))return!1}return!0}var lt=Reflect.apply,hd=Object.create,md=Object.defineProperties,vi=Object.freeze,yd=Object.getOwnPropertyDescriptors,_d=Object.setPrototypeOf;var Ms=SharedArrayBuffer,gd=Uint8Array,Ed=Uint8Array.prototype.set,Sd=WeakSet.prototype.add,Bf=WeakSet.prototype.has,wd=WeakMap.prototype.get,Od=WeakMap.prototype.set,$f=Set.prototype.has,Uf=Map.prototype.get;var Ad=Number.isInteger,zd=TypeError,xd=le.mount,Id=le.mkfs,Td=le.prototype.snapshotState,Rd=new WeakSet,Ds=new WeakMap,Ld=1;function ea(n){let e=hd(null);return md(e,yd(n)),vi(e)}var bd=ea(le.prototype),Wf=vi({kind:"nosuid"}),Gf=vi({kind:"trusted-root-product",guestWritable:!1,stableExecutableIdentity:!0}),vd=Symbol("DeferredTreeMaterializationHandle"),Er=[40,181,47,253],zi=1447449417,xi=1,gi=1,nn=2,Ei=4,Si=8,ye=16,{S_IFMT:Ee,S_IFREG:kt,S_IFDIR:Qe,S_IFLNK:Sr}=ie,{DT_UNKNOWN:Pd,DT_REG:kd,DT_DIR:Fd,DT_LNK:Nd}=wo,Cd=He.O_RDONLY,Hf=He.O_ACCMODE,Vf=He.O_CREAT,qf=He.O_TRUNC,Zf=Eo.W_OK,Ks=He.O_WRONLY|He.O_CREAT|He.O_TRUNC,Md=1024*1024,Dd=16*1024*1024,Ct=64*1024,on=16*1024*1024,sn=16*1024*1024,Bs=D.maxArchiveBytes,Kd=D.maxExpandedBytes,an=D.maxPayloadBytes,Bd=2,$d=4,Mt=D.maxEntries,ta=be.maxGroups,ln=D.maxPathBytes,ra=D.maxSymlinkTargetBytes,Pi=D.maxStringBytes,Ud=D.maxActivationCapabilities,Wd=D.maxActivationRoots,$s=D.maxActivationCapabilityBytes,Us=4294967294,Ws=3,Gd=250,na=5e3,Ii=/^[0-9a-f]{64}$/,wr="kandelo-legacy-zip-v1",Or="kandelo-deferred-tree-v1",Ti="kandelo-deferred-tree-v2",ft="kandelo-deferred-tree-v3",Hd=new Set(["ECONNABORTED","ECONNREFUSED","ECONNRESET","EHOSTUNREACH","ENETDOWN","ENETRESET","ENETUNREACH","EPIPE","ETIMEDOUT","EAI_AGAIN","UND_ERR_CONNECT_TIMEOUT","UND_ERR_HEADERS_TIMEOUT","UND_ERR_SOCKET"]),cn=class extends Error{constructor(t,r){super(`HTTP ${t}`);this.status=t;this.retryAfterMs=r;this.name="LazyHttpResponseError"}status;retryAfterMs};function Ar(n){if(typeof n!="string"||!n.startsWith("/")||new TextEncoder().encode(n).byteLength>ln||n.includes("\0")||n.includes("\\"))throw new Error(`Lazy archive mount prefix must be an absolute POSIX path: ${JSON.stringify(n)}`);let e=n.replace(/\/+$/,"");if(e==="")return"/";if(e.slice(1).split("/").some(r=>r===""||r==="."||r===".."))throw new Error(`Lazy archive mount prefix is not canonical: ${JSON.stringify(n)}`);return e}function Vd(n,e,t,r){let i=Ar(t),o=new Map,s=e.map(a=>{let c=a.fileName,u=`Lazy archive ${JSON.stringify(n)} member ${JSON.stringify(c)}`;if(c.length===0)throw new Error(`${u} has an empty path`);if(c.includes("\0"))throw new Error(`${u} contains a NUL byte`);if(c.includes("\\"))throw new Error(`${u} contains a backslash`);if(c.startsWith("/")||/^[A-Za-z]:\//.test(c))throw new Error(`${u} must be relative, not absolute`);if(a.isDirectory&&a.isSymlink)throw new Error(`${u} has conflicting directory and symlink types`);if(a.isDirectory!==c.endsWith("/"))throw new Error(`${u} has inconsistent directory metadata`);let l=a.isDirectory?c.slice(0,-1):c,d=l.split("/");if(l.length===0||d.some(h=>h===""||h==="."||h===".."))throw new Error(`${u} is not a canonical relative POSIX path`);if(o.has(l))throw new Error(`${u} collides with another member at ${JSON.stringify(l)}`);if(a.isSymlink&&!r?.has(c))throw new Error(`Lazy archive symlink target was not provided: ${c}`);return o.set(l,a),{entry:a,archivePath:l,vfsPath:i==="/"?`/${l}`:`${i}/${l}`}});for(let{archivePath:a}of s){let c=a.split("/");for(let u=1;uCt)throw new Error(`VFS image metadata exceeds ${Ct} bytes`);let e;try{e=JSON.parse(new TextDecoder().decode(n))}catch(t){let r=t instanceof Error?t.message:String(t);throw new Error(`Invalid VFS image metadata JSON: ${r}`)}return ki(e)}function Zd(n){if(n===null)return new Uint8Array(0);let e=ki(n),t=new TextEncoder().encode(JSON.stringify(e));if(t.byteLength>Ct)throw new Error(`VFS image metadata exceeds ${Ct} bytes`);return t}function Yd(n){return n.byteLength>=Er.length&&n[0]===Er[0]&&n[1]===Er[1]&&n[2]===Er[2]&&n[3]===Er[3]?El(n):n}function Qr(n){let e=Yd(n);if(e.byteLengthon)throw new Error(`VFS image lazy metadata exceeds ${on} bytes`);if(n.byteLengthsn)throw new Error(`VFS image lazy archive metadata exceeds ${sn} bytes`);if(n.byteLength=0?r:void 0}function Jd(n){return n===408||n===429||n>=500&&n<=599}function Qd(n,e=Date.now()){let t=n?.get("retry-after")?.trim();if(!t)return;let r;if(/^\d+$/.test(t))r=Number(t)*1e3;else{let i=Date.parse(t);if(!Number.isFinite(i))return;r=Math.max(0,i-e)}if(!(!Number.isSafeInteger(r)||r<0))return Math.min(r,na)}function el(n){if(!(typeof n!="object"||n===null||!("cause"in n)))return n.cause}function ia(n){if(!(typeof n!="object"||n===null||!("name"in n)))return typeof n.name=="string"?n.name:void 0}function oa(n){if(!(typeof n!="object"||n===null||!("code"in n)))return typeof n.code=="string"?n.code:void 0}function sa(n,e){let t=new Set,r=n;for(let i=0;r!==void 0&&i<8;i+=1){if(t.has(r))return!1;if(t.add(r),e(r))return!0;r=el(r)}return!1}function aa(n){return sa(n,e=>ia(e)==="AbortError"||oa(e)==="ABORT_ERR")}function tl(n){return aa(n)?!1:sa(n,e=>{let t=ia(e),r=oa(e);return e instanceof TypeError||t==="NetworkError"||t==="TimeoutError"||r!==void 0&&Hd.has(r)})}function rl(n,e){if(n instanceof cn){if(!Jd(n.status))return null;if(n.retryAfterMs!==void 0)return n.retryAfterMs}else if(!tl(n))return null;return Math.min(Gd*2**e,na)}function te(n){if(n?.aborted)throw n.reason}function nl(n,e){return te(e),n===0?Promise.resolve():new Promise((t,r)=>{let i=setTimeout(()=>a(!1),n),o=()=>a(!0,e.reason),s=!1;function a(c,u){s||(s=!0,clearTimeout(i),e?.removeEventListener("abort",o),c?r(u):t())}e?.addEventListener("abort",o,{once:!0}),e?.aborted&&o()})}async function wi(n,e){try{await n.body?.cancel(e)}catch{}}function il(n,e){if(n.length===1)return n[0];let t=new Uint8Array(e),r=0;for(let i of n)t.set(i,r),r+=i.byteLength;return t}function zr(n){if(n===void 0)return;if(typeof n!="object"||n===null||Array.isArray(n))throw new Error("Lazy archive integrity must be an object");let e=n;if(Object.keys(e).length!==2||!("sha256"in e)||!("bytes"in e))throw new Error("Lazy archive integrity has unexpected fields");if(typeof e.sha256!="string"||!Ii.test(e.sha256))throw new Error("Lazy archive integrity has an invalid SHA-256 digest");if(!Number.isSafeInteger(e.bytes)||Number(e.bytes)<=0||Number(e.bytes)>Bs)throw new Error(`Lazy archive integrity byte count must be between 1 and ${Bs}`);return{sha256:e.sha256,bytes:Number(e.bytes)}}function et(n,e,t){if(typeof n!="object"||n===null||Array.isArray(n))throw new Error(`${t} must be an object`);let r=n;if(Object.keys(r).length!==e.length||e.some(o=>!Object.prototype.hasOwnProperty.call(r,o)))throw new Error(`${t} has unexpected or missing fields`);return r}function Ri(n,e,t,r){if(typeof n!="object"||n===null||Array.isArray(n))throw new Error(`${r} must be an object`);let i=n,o=new Set(e);if(Object.keys(i).some(s=>!o.has(s))||t.some(s=>!Object.prototype.hasOwnProperty.call(i,s)))throw new Error(`${r} has unexpected or missing fields`);return i}function Me(n,e,t,r){if(!Array.isArray(n)||n.lengthr)throw new Error(`${e} must contain ${t} to ${r} items`);return n}function Se(n,e,t){if(typeof n!="string")throw new Error(`${e} is invalid or exceeds ${t} bytes`);if(Rt(n,e),n.length===0||n.includes("\0")||new TextEncoder().encode(n).byteLength>t)throw new Error(`${e} is invalid or exceeds ${t} bytes`);return n}function ce(n,e,t,r){if(!Number.isSafeInteger(n)||Number(n)r)throw new Error(`${e} must be an integer between ${t} and ${r}`);return Number(n)}function un(n,e=1){let t=n,r=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.source!==void 0,i=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.modePolicy!==void 0,o=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.materialization!==void 0,s=et(n,["decoder","mediaType","sha256","bytes","expandedBytes","sourceEntryCount","transports",...i?["modePolicy"]:[],...r?["source"]:[],...o?["materialization"]:[]],"Lazy tree content"),a=s.decoder==="zip-v1"?"application/zip":s.decoder==="tar-gzip-v1"?"application/vnd.oci.image.layer.v1.tar+gzip":null;if(a===null||s.mediaType!==a)throw new Error("Lazy tree decoder and media type are inconsistent");let c=zr({sha256:s.sha256,bytes:s.bytes});if(!c)throw new Error("Lazy tree integrity is required");let u=Me(s.transports,"Lazy tree transports",e,D.maxTransportsPerTree).map((p,_)=>Se(p,`Lazy tree transport ${_}`,Pi));if(new Set(u).size!==u.length)throw new Error("Lazy tree transports contain duplicates");let l=ce(s.expandedBytes,"Lazy tree expanded byte count",0,Kd),d=ce(s.sourceEntryCount,"Lazy tree source entry count",1,Mt),h=r?al(s.source,s.decoder):void 0,m=o?as(s.materialization,h):void 0,f=i?s.modePolicy:void 0;if(f!==void 0&&(f!=="portable-posix-v1"||s.decoder!=="zip-v1"||r))throw new Error("Lazy tree mode policy is invalid for its decoder");if(h!==void 0&&h.entries.length!==d)throw new Error("Lazy tree source inventory count differs from its content");return{decoder:s.decoder,mediaType:a,sha256:c.sha256,bytes:c.bytes,expandedBytes:l,sourceEntryCount:d,transports:u,...f===void 0?{}:{modePolicy:f},...h===void 0?{}:{source:h},...m===void 0?{}:{materialization:m}}}function ca(n){let e={groups:n.length,archiveBytes:0,expandedBytes:0,payloadBytes:0,entries:0};for(let t of n)t.content===void 0||t.inventory===void 0||(e.archiveBytes+=t.content.bytes,e.expandedBytes+=t.content.expandedBytes,e.payloadBytes+=t.inventory.filter(r=>r.type==="file").reduce((r,i)=>r+i.size,0),e.entries+=t.inventory.length+(t.content.source?.entries.length??0));return e}function Li(n){is(n,"Serialized lazy tree collection")}function ol(n){Li(ca(n))}function sl(n){let e=new Map;for(let t of n){let r=t.activation?.atomicGroup;if(r===void 0)continue;if(!Ft(r))throw new Error(`Serialized lazy atomic activation group ${r.id} is unsealed`);let i=e.get(r.id);if(i===void 0)i={expectedCount:r.expectedCount,cohortSha256:r.cohortSha256,members:new Set,descriptors:new Set},e.set(r.id,i);else if(i.expectedCount!==r.expectedCount||i.cohortSha256!==r.cohortSha256)throw new Error(`Serialized lazy atomic activation group ${r.id} has inconsistent seals`);if(i.members.has(r.member)||i.descriptors.has(r.descriptorSha256))throw new Error(`Serialized lazy atomic activation group ${r.id} duplicates a member`);i.members.add(r.member),i.descriptors.add(r.descriptorSha256)}for(let[t,r]of e)if(r.members.size!==r.expectedCount)throw new Error(`Serialized lazy atomic activation group ${t} has ${r.members.size} of ${r.expectedCount} members`)}function qs(n){for(let[e,t]of n.entries())if(t.kind===Or||t.kind===Ti||t.kind===ft)la(t,t.kind);else if(t.kind===wr)bi(t,!1);else throw new Error(`Serialized lazy archive group ${e} has an unsupported kind`);ol(n),sl(n)}function al(n,e){if(e!=="zip-v1"&&e!=="tar-gzip-v1")throw new Error("Lazy tree source inventory requires a supported archive decoder");let t=et(n,["schema","kind","entries"],"Lazy tree source inventory");if(t.schema!==1||t.kind!=="archive-source-inventory-v1")throw new Error("Lazy tree source inventory has an unsupported identity");let r=new Map,i=Me(t.entries,"Lazy tree source entries",1,Mt).map((s,a)=>{let c=s,u=typeof c=="object"&&c!==null&&!Array.isArray(c)?c.type:void 0,l=u==="directory"||u==="file"?["sourcePath","type","mode","size"]:u==="symlink"||u==="hardlink"?["sourcePath","type","mode","size","target"]:null;if(l===null)throw new Error(`Lazy tree source entry ${a} has invalid type`);let d=et(s,l,`Lazy tree source entry ${a}`),h=we(d.sourcePath,!1,`Lazy tree source entry ${a} path`);if(r.has(h))throw new Error(`Lazy tree source inventory duplicates ${h}`);let m=ce(d.mode,`Lazy tree source entry ${h} mode`,0,ie.S_MODE_BITS),f=ce(d.size,`Lazy tree source entry ${h} size`,0,an),p;if((u==="directory"||u==="symlink"||u==="hardlink")&&f!==0)throw new Error(`Lazy tree source ${h} has payload for ${String(u)}`);u==="symlink"?p=Se(d.target,`Lazy tree source symlink ${h} target`,ra):u==="hardlink"&&(p=we(d.target,!1,`Lazy tree source hardlink ${h} target`));let _={sourcePath:h,type:u,mode:m,size:f,...p===void 0?{}:{target:p}};return r.set(h,_),_}),o=i.map(s=>s.sourcePath);if(o.some((s,a)=>a>0&&lr(o[a-1],s)>=0))throw new Error("Lazy tree source inventory is not in canonical path order");return{schema:1,kind:"archive-source-inventory-v1",entries:i}}function cl(n){let e=new Map(n.map(r=>[r.sourcePath,r])),t=new Map;for(let r of n){if(r.type!=="hardlink"||t.has(r.sourcePath))continue;let i=[],o=new Set,s=r,a;for(;s.type==="hardlink"&&(a=t.get(s.sourcePath),a===void 0);){if(o.has(s.sourcePath))throw new Error(`Lazy tree source hardlink cycle includes ${s.sourcePath}`);o.add(s.sourcePath),i.push(s);let c=e.get(s.target);if(c===void 0)throw new Error(`Lazy tree source hardlink ${s.sourcePath} target is absent`);if(c.type!=="file"&&c.type!=="hardlink")throw new Error(`Lazy tree source hardlink ${s.sourcePath} target is not regular`);s=c}a===void 0&&(a=s);for(let c of i)t.set(c.sourcePath,a)}return t}function we(n,e,t,r=!1){if(typeof n!="string"||n.length===0||new TextEncoder().encode(n).byteLength>ln||n.includes("\0")||n.includes("\\")||n.startsWith("/")!==e)throw new Error(`${t} is not a canonical ${e?"absolute":"relative"} path`);if(r&&e&&n==="/")return n;if(n.slice(e?1:0).split("/").some(o=>o===""||o==="."||o===".."))throw new Error(`${t} has an unsafe path segment`);return n}function ua(n){let e=typeof n=="object"&&n!==null&&!Array.isArray(n)?n:null,t=e!==null&&(Object.hasOwn(e,"descriptorSha256")||Object.hasOwn(e,"expectedCount")||Object.hasOwn(e,"cohortSha256")),r=et(n,t?["id","member","descriptorSha256","expectedCount","cohortSha256"]:["id","member"],"Lazy tree atomic activation membership"),i=Se(r.id,"Lazy tree atomic activation group",$s),o=Se(r.member,"Lazy tree atomic activation member",$s);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(i)||!/^[a-z0-9][a-z0-9:+._/-]*$/.test(o)||o.includes("//")||o.endsWith("/"))throw new Error("Lazy tree atomic activation membership is invalid");if(!t)return{id:i,member:o};let s=Se(r.descriptorSha256,"Lazy tree atomic member descriptor digest",64),a=Se(r.cohortSha256,"Lazy tree atomic cohort digest",64);if(!Ii.test(s)||!Ii.test(a))throw new Error("Lazy tree atomic activation digest is invalid");return{id:i,member:o,descriptorSha256:s,expectedCount:ce(r.expectedCount,"Lazy tree atomic activation expected member count",1,ta),cohortSha256:a}}function Ft(n){return n.descriptorSha256!==void 0&&n.expectedCount!==void 0&&n.cohortSha256!==void 0}function ul(n){let e=et(n,["uid","gid"],"Lazy tree registration owner");return{uid:ce(e.uid,"Lazy tree registration owner uid",0,Us),gid:ce(e.gid,"Lazy tree registration owner gid",0,Us)}}function da(n,e,t,r,i=1){let o=un(n,i),s=Ar(t),a=["mode","capabilities","roots",...typeof r=="object"&&r!==null&&!Array.isArray(r)&&Object.hasOwn(r,"atomicGroup")?["atomicGroup"]:[]],c=et(r,a,"Lazy tree activation");if(c.mode!=="boot-prefetch"&&c.mode!=="first-use")throw new Error("Lazy tree activation mode is invalid");let u=Me(c.capabilities,"Lazy tree activation capabilities",1,Ud).map((z,x)=>{let I=Se(z,`Lazy tree activation capability ${x}`,D.maxActivationCapabilityBytes);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(I))throw new Error(`Lazy tree activation capability ${x} is invalid`);return I}),l=Me(c.roots,"Lazy tree activation roots",1,Wd).map((z,x)=>we(z,!0,`Lazy tree activation root ${x}`,!0));if(new Set(u).size!==u.length||new Set(l).size!==l.length)throw new Error("Lazy tree activation contains duplicates");let d=c.atomicGroup===void 0?void 0:ua(c.atomicGroup);if(d!==void 0&&c.mode!=="first-use")throw new Error("Lazy tree atomic activation group requires a valid first-use identity");let h={mode:c.mode,capabilities:u,roots:l,...d===void 0?{}:{atomicGroup:d}},m=Me(e,"Lazy tree inventory",1,Mt),f=[],p=new Map,_=new Map,y=o.source===void 0?void 0:new Map(o.source.entries.map(z=>[z.sourcePath,z])),g=o.source===void 0?void 0:cl(o.source.entries),E=new Map(o.materialization?.transforms.map(z=>[z.sourcePath,z])??[]),O=0;for(let[z,x]of m.entries()){if(typeof x!="object"||x===null||Array.isArray(x))throw new Error(`Lazy tree entry ${z} must be an object`);let I=x.type,R=I==="directory"?["vfsPath","sourcePath","type","mode","size"]:I==="file"?["vfsPath","sourcePath","type","mode","size","inodeGroup"]:I==="symlink"?["vfsPath","sourcePath","type","mode","size","target"]:I==="hardlink"?["vfsPath","sourcePath","type","mode","size","target","inodeGroup"]:null;if(!R)throw new Error(`Lazy tree entry ${z} has an invalid type`);let L=et(x,[...R,...y===void 0?[]:["materialization"]],`Lazy tree entry ${z}`),v=we(L.vfsPath,!0,`Lazy tree entry ${z} VFS path`),Z=we(L.sourcePath,!1,`Lazy tree entry ${z} source path`),N=y===void 0?void 0:L.materialization;if(y!==void 0&&N!=="archive"&&N!=="archive-copy"&&N!=="archive-copy-mode"&&N!=="descriptor")throw new Error(`Lazy tree entry ${v} has invalid materialization provenance`);if(s!=="/"&&v!==s&&!v.startsWith(`${s}/`))throw new Error(`Lazy tree entry ${v} escapes its mount prefix`);if(p.has(v))throw new Error(`Lazy tree duplicates VFS path ${v}`);let k=ce(L.mode,`Lazy tree entry ${v} mode`,0,ie.S_MODE_BITS),G=ce(L.size,`Lazy tree entry ${v} size`,0,an),ue,Oe;if(I==="directory"){if(G!==0)throw new Error(`Lazy tree directory ${v} has nonzero size`)}else if(I==="symlink"){if(ue=Se(L.target,`Lazy tree symlink ${v} target`,ra),new TextEncoder().encode(ue).byteLength!==G)throw new Error(`Lazy tree symlink ${v} size differs from its target`)}else Oe=Se(L.inodeGroup,`Lazy tree entry ${v} inode group`,ln),I==="hardlink"&&(ue=we(L.target,!0,`Lazy tree hardlink ${v} target`));if(I!=="hardlink"&&(O+=G,O>an))throw new Error("Lazy tree inventory exceeds the expansion limit");let M={vfsPath:v,sourcePath:Z,...N===void 0?{}:{materialization:N},type:I,mode:k,size:G,...ue===void 0?{}:{target:ue},...Oe===void 0?{}:{inodeGroup:Oe}};if(y===void 0){let de=_.get(Z);if(de){if(o.decoder!=="zip-v1"||M.type!=="hardlink"||de.inodeGroup!==M.inodeGroup)throw new Error(`Lazy tree duplicates source path ${Z}`)}else{if(o.decoder==="zip-v1"&&M.type==="hardlink")throw new Error(`Lazy ZIP hardlink ${v} does not reuse a canonical source path`);_.set(Z,M)}}else if(M.materialization==="descriptor"){if(M.type!=="directory"&&M.type!=="symlink")throw new Error(`Lazy tree descriptor entry ${v} is not structural`);if(y.has(Z))throw new Error(`Lazy tree descriptor entry ${v} impersonates a source member`)}else{let de=y.get(Z);if(de===void 0)throw new Error(`Lazy tree entry ${v} names absent source ${Z}`);if(M.materialization==="archive-copy"||M.materialization==="archive-copy-mode"){if(M.type!=="file"||de.type!=="file"||M.materialization==="archive-copy"&&M.mode!==de.mode)throw new Error(`Lazy tree archive copy ${v} differs from its source`)}else if(de.type!==M.type||M.type==="symlink"&&de.target!==M.target||M.type!=="hardlink"&&de.mode!==M.mode)throw new Error(`Lazy tree archive entry ${v} differs from its source`)}f.push(M),p.set(v,M)}for(let z of f){let x=z.vfsPath.split("/").filter(Boolean);for(let I=1;I({path:z.vfsPath,type:z.type,mode:z.mode,size:z.size,target:z.target,inodeGroup:z.inodeGroup})),"Lazy tree");if(y!==void 0){let z=new Set;for(let x of f){if(x.materialization==="descriptor"||x.type!=="file"&&x.type!=="hardlink")continue;let I=y.get(x.sourcePath),R=I.type==="file"?I:g.get(I.sourcePath);R?.type==="file"&&z.add(R.sourcePath);let L=R?.type==="file"?E.get(R.sourcePath):void 0;if(R?.type!=="file"||x.size!==(L?.output.bytes??R.size))throw new Error(`Lazy tree archive entry ${x.vfsPath} differs from its source`)}for(let x of E.keys())if(!z.has(x))throw new Error(`Lazy tree materialization transform ${x} has no destination`);for(let x of f){if(x.type!=="hardlink"||x.materialization!=="archive")continue;let I=y.get(x.sourcePath),R=p.get(x.target),L=g.get(I.sourcePath);if(I.target!==R?.sourcePath||L?.type!=="file"||L.mode!==x.mode||R?.mode!==x.mode)throw new Error(`Lazy tree hardlink ${x.vfsPath} differs from its source`)}}if(o.sourceEntryCount!==(y===void 0?_.size:y.size))throw new Error("Lazy tree source entry count differs from its inventory");if(o.source===void 0&&o.expandedBytesx.vfsPath===z||x.vfsPath.startsWith(`${z}/`)))throw new Error(`Lazy tree activation root ${z} is not owned by its inventory`);let w=new Map;for(let z of f)z.type==="file"&&w.set(z.inodeGroup,z);if(w.size!==S.canonicalByGroup.size)throw new Error("Lazy tree regular inode inventory is inconsistent");return{content:o,entries:f,mountPrefix:s,activation:h,canonicalByGroup:w}}function dn(n){return JSON.stringify([n.sourcePath,n.type,n.inodeGroup,n.target])}function bi(n,e){let t=Ri(n,["kind","content","url","mountPrefix","integrity","materialized","entries"],["url","mountPrefix","materialized","entries"],"Serialized legacy lazy archive");if(t.kind===void 0){if(!e)throw new Error("Serialized lazy archive is missing its kind discriminator")}else if(t.kind!==wr)throw new Error("Serialized legacy lazy archive has an unsupported kind");let r=Se(t.url,"Serialized legacy lazy archive URL",Pi),i=Ar(t.mountPrefix),o=zr(t.integrity);if(t.content!==void 0){if(!e||t.kind!==void 0)throw new Error("Typed legacy lazy archives cannot carry generic content");let c=un(t.content);if(c.decoder!=="zip-v1"||c.transports.length!==1||c.transports[0]!==r||!o||c.sha256!==o.sha256||c.bytes!==o.bytes)throw new Error("Untagged legacy ZIP content identity is inconsistent")}if(t.materialized!==!1)throw new Error("Serialized legacy lazy archive must describe pending content");let s=new Set,a=Me(t.entries,"Serialized legacy lazy archive entries",1,Mt).map((c,u)=>{let l=Ri(c,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","size","isSymlink","deleted"],`Serialized legacy lazy archive entry ${u}`),d=we(l.vfsPath,!0,`Serialized legacy lazy archive entry ${u} VFS path`);if(s.has(d))throw new Error(`Serialized legacy lazy archive duplicates path ${d}`);s.add(d);let h=ce(l.ino,`Serialized legacy lazy archive entry ${d} inode`,1,Number.MAX_SAFE_INTEGER),m=l.generation===void 0?void 0:ce(l.generation,`Serialized legacy lazy archive entry ${d} generation`,0,Number.MAX_SAFE_INTEGER),f=l.dataSequence===void 0?void 0:ce(l.dataSequence,`Serialized legacy lazy archive entry ${d} data sequence`,0,Number.MAX_SAFE_INTEGER),p=ce(l.size,`Serialized legacy lazy archive entry ${d} size`,0,an);if(l.isSymlink!==!1||l.deleted!==!1||l.materialized!==void 0&&l.materialized!==!1)throw new Error(`Serialized legacy lazy archive entry ${d} is not pending`);if(l.type!==void 0&&l.type!=="file")throw new Error(`Serialized legacy lazy archive entry ${d} has an invalid type`);let _=l.archivePath===void 0?void 0:we(l.archivePath,!1,`Serialized legacy lazy archive entry ${d} archive path`),y=l.sourcePath===void 0?void 0:we(l.sourcePath,!1,`Serialized legacy lazy archive entry ${d} source path`),g=l.inodeGroup===void 0?void 0:Se(l.inodeGroup,`Serialized legacy lazy archive entry ${d} inode group`,ln);if(l.target!==void 0)throw new Error(`Serialized legacy lazy archive entry ${d} has a link target`);return{vfsPath:d,ino:h,...m===void 0?{}:{generation:m},...f===void 0?{}:{dataSequence:f},size:p,isSymlink:!1,deleted:!1,materialized:!1,..._===void 0?{}:{archivePath:_},...y===void 0?{}:{sourcePath:y},type:"file",...g===void 0?{}:{inodeGroup:g}}});return{kind:wr,url:r,mountPrefix:i,...o===void 0?{}:{integrity:o},materialized:!1,entries:a}}function la(n,e){let t=et(n,["kind","content","inventory","activation","url","mountPrefix","integrity","materialized","entries"],"Serialized lazy tree");if(t.kind!==e)throw new Error("Serialized lazy tree has an unsupported kind");let r=da(t.content,t.inventory,t.mountPrefix,t.activation);if(e!==ft&&e===Or!=(r.content.source===void 0))throw new Error(e===Or?"Serialized deferred-tree-v1 cannot contain complete source metadata":"Serialized deferred-tree-v2 requires complete source metadata");let i=r.activation.atomicGroup;if(e===ft?i===void 0||!Ft(i):i!==void 0)throw new Error(e===ft?"Serialized deferred-tree-v3 requires a sealed atomic activation":"Atomic activation requires serialized deferred-tree-v3");let o=Se(t.url,"Serialized lazy tree URL",Pi);if(o!==r.content.transports[0])throw new Error("Serialized lazy tree URL differs from its primary transport");let s=zr(t.integrity);if(!s||s.sha256!==r.content.sha256||s.bytes!==r.content.bytes)throw new Error("Serialized lazy tree integrity differs from its content");if(t.materialized!==!1)throw new Error("Serialized lazy tree must describe pending content");let a=new Map(r.entries.map(h=>[h.vfsPath,h])),c=new Map(r.entries.map(h=>[dn(h),h])),u=Me(t.entries,"Serialized lazy tree entries",0,Mt),l=new Set,d=u.map((h,m)=>{let f=Ri(h,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup"],`Serialized lazy tree entry ${m}`),p=we(f.vfsPath,!0,`Serialized lazy tree entry ${m} VFS path`);if(l.has(p))throw new Error(`Serialized lazy tree duplicates pending path ${p}`);l.add(p);let _=we(f.sourcePath,!1,`Serialized lazy tree entry ${m} source path`),y=we(f.archivePath,!1,`Serialized lazy tree entry ${m} archive path`),g=a.get(p),E=c.get(dn({sourcePath:_,type:typeof f.type=="string"?f.type:void 0,inodeGroup:typeof f.inodeGroup=="string"?f.inodeGroup:void 0,target:typeof f.target=="string"?f.target:void 0}))??g;if(!E||E.type!=="file"&&E.type!=="hardlink"||g?.inodeGroup!==void 0&&g.inodeGroup!==E.inodeGroup)throw new Error(`Serialized lazy tree entry ${p} is absent from its inventory`);let O=r.canonicalByGroup.get(E.inodeGroup);if(f.type!==E.type||f.inodeGroup!==E.inodeGroup||f.size!==E.size||y!==O?.sourcePath||f.target!==E.target||f.isSymlink!==!1||f.deleted!==!1||f.materialized!==!1)throw new Error(`Serialized lazy tree entry ${p} disagrees with its inventory`);let S=ce(f.ino,`Serialized lazy tree entry ${p} inode`,1,Number.MAX_SAFE_INTEGER),w=ce(f.generation,`Serialized lazy tree entry ${p} generation`,0,Number.MAX_SAFE_INTEGER),z=ce(f.dataSequence,`Serialized lazy tree entry ${p} data sequence`,0,Number.MAX_SAFE_INTEGER);return{vfsPath:p,ino:S,generation:w,dataSequence:z,size:E.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:y,sourcePath:_,type:E.type,inodeGroup:E.inodeGroup,...E.target===void 0?{}:{target:E.target}}});for(let h of r.entries)if(r.activation.atomicGroup!==void 0&&(h.type==="file"||h.type==="hardlink")&&!l.has(h.vfsPath))throw new Error(`Serialized lazy tree omits pending path ${h.vfsPath}`);return{kind:e,content:r.content,inventory:r.entries,activation:r.activation,url:o,mountPrefix:r.mountPrefix,integrity:s,materialized:!1,entries:d}}async function Nt(n,e){let t=globalThis.crypto?.subtle;if(!t)throw new Error(`${e} SHA-256 verification is unavailable`);let r=new Uint8Array(n.byteLength);r.set(n);let i=new Uint8Array(await t.digest("SHA-256",r));return Array.from(i,o=>o.toString(16).padStart(2,"0")).join("")}async function Oi(n,e,t){if(t===void 0)return;if(n.byteLength!==t.bytes)throw new Error(`Lazy ${e} byte count ${n.byteLength} does not match expected ${t.bytes}`);let r=await Nt(n,`Lazy ${e}`);if(r!==t.sha256)throw new Error(`Lazy ${e} SHA-256 ${r} does not match expected ${t.sha256}`)}async function Zs(n,e,t){if(n.byteLength!==e.bytes)throw new Error(`${t} byte count ${n.byteLength} does not match expected ${e.bytes}`);let r=await Nt(n,t);if(r!==e.sha256)throw new Error(`${t} SHA-256 ${r} does not match expected ${e.sha256}`)}function dl(n,e,t,r){let i=r.atomicGroup;if(i===void 0)throw new Error("Lazy atomic member is missing its typed tree descriptor");let o={schema:1,content:{decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...n.source===void 0?{}:{source:n.source},...n.materialization===void 0?{}:{materialization:n.materialization}},mountPrefix:t,inventory:[...e].sort((s,a)=>lr(s.vfsPath,a.vfsPath)),activation:{mode:r.mode,capabilities:r.capabilities,roots:r.roots,atomicGroup:{id:i.id,member:i.member}}};return new TextEncoder().encode(JSON.stringify(o))}function Ys(n,e){let t=e.map(({member:r,descriptorSha256:i})=>({member:r,descriptorSha256:i}));return new TextEncoder().encode(JSON.stringify({schema:1,id:n,members:t.sort((r,i)=>r.memberi.member?1:0)}))}function ll(n,e){if(n.byteLength!==e.byteLength)return!1;for(let t=0;tObject.freeze({...o}))};r!==void 0&&(Object.freeze(r.entries),Object.freeze(r));let i=n.materialization===void 0?void 0:fl(n.materialization);return Object.freeze({decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,transports:t,...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...r===void 0?{}:{source:r},...i===void 0?{}:{materialization:i}})}function fa(n){return{schema:1,kind:"archive-byte-transforms-v1",assertions:n.assertions.map(e=>({...e})),recipes:n.recipes.map(e=>({id:e.id,replacements:e.replacements.map(t=>({...t})),rejectHex:[...e.rejectHex]})),transforms:n.transforms.map(e=>({sourcePath:e.sourcePath,recipe:e.recipe,input:{...e.input},output:{...e.output}}))}}function fl(n){let e=fa(n);for(let t of e.assertions)Object.freeze(t);Object.freeze(e.assertions);for(let t of e.recipes){for(let r of t.replacements)Object.freeze(r);Object.freeze(t.replacements),Object.freeze(t.rejectHex),Object.freeze(t)}Object.freeze(e.recipes);for(let t of e.transforms)Object.freeze(t.input),Object.freeze(t.output),Object.freeze(t);return Object.freeze(e.transforms),Object.freeze(e)}function en(n){return{decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,transports:[...n.transports],...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...n.source===void 0?{}:{source:{schema:1,kind:"archive-source-inventory-v1",entries:n.source.entries.map(e=>({...e}))}},...n.materialization===void 0?{}:{materialization:fa(n.materialization)}}}function pa(n){let e=n.map(t=>Object.freeze({...t}));return Object.freeze(e),e}function pl(n,e,t){let r=[...n.capabilities],i=[...n.roots];return Object.freeze(r),Object.freeze(i),Object.freeze({mode:n.mode,capabilities:r,roots:i,atomicGroup:Object.freeze({id:e,member:t})})}function hl(n){let e=[...n.capabilities],t=[...n.roots];return Object.freeze(e),Object.freeze(t),Object.freeze({mode:n.mode,capabilities:e,roots:t})}function tn(n,e,t,r,i,o,s,a){let c=s.map(u=>Object.freeze({...u}));return Object.freeze(c),Object.freeze({content:fn(n),inventory:pa(e),activation:hl(t),url:r,mountPrefix:i,integrity:Object.freeze({...o}),entries:c,materialized:a})}function ml(n,e,t){let r=e.map(i=>Object.freeze({...i}));return Object.freeze(r),Object.freeze({...n,entries:r,materialized:t})}function Ai(n){return Array.from(n,([e,t])=>({vfsPath:e,...t}))}function Xs(n){return new Map(n.map(({vfsPath:e,...t})=>[e,t]))}function yl(n){return{mode:n.activation.mode,capabilities:[...n.activation.capabilities],roots:[...n.activation.roots],atomicGroup:{id:n.id,member:n.member,descriptorSha256:n.descriptorSha256,expectedCount:n.expectedCount,cohortSha256:n.cohortSha256}}}function _l(n,e){return n.ino===e.ino&&n.generation===e.generation&&n.dataSequence===e.dataSequence&&n.size===e.size&&n.isSymlink===e.isSymlink&&n.deleted===e.deleted&&n.materialized===e.materialized&&n.archivePath===e.archivePath&&n.sourcePath===e.sourcePath&&n.type===e.type&&n.inodeGroup===e.inodeGroup&&n.target===e.target}function rn(n,e,t){let r=n.content,i=n.inventory,o=n.activation,s=n.integrity,a=n.entries,c=n.url,u=n.mountPrefix,l=n.materialized,d=o?.atomicGroup;if(r===void 0||i===void 0||o===void 0||d===void 0||o.mode!=="first-use"||d.id!==e||d.member!==t||l)throw new Error(`Lazy atomic activation member ${t} changed before snapshot`);if(s?.sha256!==r.sha256||s?.bytes!==r.bytes||c!==(r.transports[0]??""))throw new Error(`Lazy atomic activation member ${t} has inconsistent integrity`);let h=fn(r),m=pa(i),f=pl(o,e,t),p=new Map;for(let O of m)O.type==="file"&&p.set(O.inodeGroup,O.sourcePath);let _=m.filter(O=>O.type!=="directory");if(a.size!==_.length)throw new Error(`Lazy atomic activation member ${t} has inconsistent runtime entries`);let y=_.map(O=>{let S=a.get(O.vfsPath),w=O.type==="symlink",z=w?O.sourcePath:p.get(O.inodeGroup),x=S!==void 0&&(S.sourcePath===O.sourcePath&&S.type===O.type&&S.target===O.target||O.type==="hardlink"&&S.sourcePath===z&&S.type==="file"&&S.target===void 0),I=S===void 0?["missing"]:[z===void 0?"archivePath source":void 0,S.generation===void 0?"generation":void 0,S.dataSequence===void 0?"dataSequence":void 0,S.size!==O.size?"size":void 0,S.isSymlink!==w?"symlink kind":void 0,S.deleted?"deletion state":void 0,S.materialized!==w?"materialization state":void 0,S.archivePath!==z?"archivePath":void 0,x?void 0:"descriptor mapping",S.inodeGroup!==O.inodeGroup?"inode group":void 0].filter(L=>L!==void 0);if(I.length>0)throw new Error(`Lazy atomic activation member ${t} has inconsistent mapping at ${O.vfsPath}: ${I.join(", ")}`);let R=S;return Object.freeze({vfsPath:O.vfsPath,ino:R.ino,generation:R.generation,dataSequence:R.dataSequence,size:R.size,isSymlink:R.isSymlink,deleted:!1,materialized:R.materialized,archivePath:z,sourcePath:O.sourcePath,type:O.type,...O.inodeGroup===void 0?{}:{inodeGroup:O.inodeGroup},...O.target===void 0?{}:{target:O.target}})});Object.freeze(y);let g=Object.freeze({sha256:h.sha256,bytes:h.bytes}),E=dl(h,m,u,f);return Object.freeze({id:e,member:t,descriptorBytes:E,content:h,inventory:m,activation:f,url:h.transports[0]??"",mountPrefix:u,integrity:g,entries:y})}function js(n,e,t,r){return Object.freeze({...n,descriptorSha256:e,expectedCount:t,cohortSha256:r})}function Js(n,e){return n.id!==e.id||n.member!==e.member||n.url!==e.url||n.mountPrefix!==e.mountPrefix||n.integrity.sha256!==e.integrity.sha256||n.integrity.bytes!==e.integrity.bytes||n.content.transports.length!==e.content.transports.length||n.content.transports.some((t,r)=>t!==e.content.transports[r])||!ll(n.descriptorBytes,e.descriptorBytes)||n.entries.length!==e.entries.length?!1:n.entries.every((t,r)=>{let i=e.entries[r];return i!==void 0&&t.vfsPath===i.vfsPath&&_l(t,i)})}function gl(n,e){let t=fn(n.content,n.content.transports.map(e));return Object.freeze({...n,content:t,url:t.transports[0]??""})}function Qs(n){return{paths:Array.from(n.paths),expectedIno:n.ino,expectedGeneration:n.generation,expectedDataSequence:n.dataSequence,data:n.content}}function V(n,e){return`${n}:${e}`}var q=class n{fs;imageMetadata;lazyFiles=new Map;lazyArchiveGroups=[];deferredTreeMaterializationHandles=new WeakMap;lazyArchiveInodes=new Map;lazyAtomicGroups=new Map;lazyAtomicGroupByTree=new WeakMap;sealedLazyAtomicStates=new WeakMap;ordinaryLazyTreeDefinitions=new WeakMap;lazyDownloadListeners=new Set;lazyPreparations=new Map;lazyTransport={fetcher:(e,t)=>t===void 0?globalThis.fetch(e):globalThis.fetch(e,t)};constructor(e,t=null){this.fs=e,this.imageMetadata=t,lt(Sd,Rd,[this])}snapshotForImmutableProduct(){if(this.lazyFiles.size!==0||this.lazyArchiveInodes.size!==0)throw new Error("immutable product source must be completely materialized");let{bytes:e}=lt(Td,this.fs,[]),t=new Ms(e.byteLength);lt(Ed,new gd(t),[e]);let r=lt(xd,le,[t,{restoreImage:!0}]);return _d(r,bd),new n(r,Gs(this.imageMetadata))}qualifiedInodeIdentity(e){let t=this.fs.lstat(e),r=lt(wd,Ds,[this.fs.buffer]);return r===void 0&&(r=Ld++,lt(Od,Ds,[this.fs.buffer,r])),{dev:r,ino:t.ino,generation:t.generation}}static canAdoptLegacyLazyStub(e){return(e.mode&Ee)===kt&&e.size===0&&e.dataSequence<=1}replaceOrdinaryLazyTreeRuntimeState(e,t,r){let i=this.ordinaryLazyTreeDefinitions.get(e);if(i===void 0)return;let o=ml(i,t,r);this.ordinaryLazyTreeDefinitions.set(e,o);try{e.entries=Xs(o.entries),e.materialized=o.materialized}catch{}return o}reconcileLazyIdentityState(e){for(let[t,r]of this.lazyFiles){let i=e.get(t);if(!i||i.dataSequence!==r.dataSequence||i.paths.length===0){this.lazyFiles.delete(t);continue}r.paths=new Set(i.paths),r.paths.has(r.path)||(r.path=i.paths[0])}this.lazyArchiveInodes.clear();for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(!r?.committed)for(let c of i.snapshot.entries){if(c.isSymlink||c.materialized||c.generation===void 0)continue;let u=V(c.ino,c.generation),l=e.get(u);l!==void 0&&l.dataSequence===c.dataSequence&&l.paths.length>0&&this.lazyArchiveInodes.set(u,t)}continue}let o=this.ordinaryLazyTreeDefinitions.get(t);if(o!==void 0){if(o.materialized){this.replaceOrdinaryLazyTreeRuntimeState(t,o.entries,!0);continue}let c=new Map,u=o.entries.filter(l=>l.deleted||l.materialized||l.isSymlink).map(l=>({...l}));for(let l of o.entries){if(l.deleted||l.materialized||l.isSymlink||l.generation===void 0)continue;let d=V(l.ino,l.generation),h=c.get(d)??[];h.push(l),c.set(d,h)}for(let[l,d]of c){let h=e.get(l);if(h===void 0||h.dataSequence!==(d[0].dataSequence??0)){if(h!==void 0)for(let p of d)u.push({...p,materialized:!0});continue}let m=new Map(d.map(p=>[p.vfsPath,p])),f=d.find(p=>p.type==="file")??d[0];for(let p of h.paths){let _=m.get(p)??f;u.push({..._,vfsPath:p,ino:h.ino,generation:h.generation,dataSequence:h.dataSequence,deleted:!1,materialized:!1})}h.paths.length>0&&this.lazyArchiveInodes.set(l,t)}this.replaceOrdinaryLazyTreeRuntimeState(t,u,!1);continue}let s=new Map;for(let c of t.entries.values()){if(c.deleted||c.materialized||c.generation===void 0)continue;let u=V(c.ino,c.generation);s.has(u)||s.set(u,c)}let a=new Map(Array.from(t.entries.entries()).filter(([,c])=>c.deleted||c.isSymlink&&!c.deleted));for(let[c,u]of s){let l=e.get(c);if(!(!l||l.dataSequence!==(u.dataSequence??0))){for(let d of l.paths)a.set(d,{...u,ino:l.ino,generation:l.generation,dataSequence:l.dataSequence,deleted:!1,materialized:!1});l.paths.length>0&&this.lazyArchiveInodes.set(c,t)}}t.entries=a,t.materialized=!Array.from(a.values()).some(c=>!c.isSymlink&&!c.materialized)&&(r===void 0||r.committed)}}validatePendingLazyTreeNamespaceState(e){let t=new Map;for(let r of e.values())for(let i of r.paths){if(t.has(i))throw new Error(`SharedFS namespace identity is ambiguous at ${i}`);t.set(i,r)}for(let r of this.lazyArchiveGroups){let i=this.lazyAtomicGroupByTree.get(r),s=this.sealedLazyAtomicStates.get(r)?.snapshot,a=this.ordinaryLazyTreeDefinitions.get(r);if(i?.committed||s===void 0&&(a?.materialized??r.materialized)||s===void 0&&a===void 0)continue;let c=s?.inventory??a.inventory,u=new Map((s?.entries??a.entries).map(f=>[f.vfsPath,f])),l=new Map,d=new Map,h=new Set;for(let f of u.values())f.deleted&&f.inodeGroup!==void 0&&h.add(f.inodeGroup);for(let f of c){if(f.type!=="file"&&f.type!=="hardlink")continue;l.set(f.inodeGroup,(l.get(f.inodeGroup)??0)+1);let p=d.get(f.inodeGroup)??[];p.push(f.vfsPath),d.set(f.inodeGroup,p)}let m=new Set([...h].filter(f=>d.get(f)?.every(p=>!t.has(p))));for(let f of c){let p=t.get(f.vfsPath);if(p===void 0){if(f.inodeGroup!==void 0&&m.has(f.inodeGroup))continue;throw new Error(`Lazy tree namespace entry ${f.vfsPath} is missing from the captured filesystem state`)}let _=f.type==="directory"?Qe:f.type==="symlink"?Sr:kt;if((p.mode&Ee)!==_||(p.mode&ie.S_MODE_BITS)!==f.mode)throw new Error(`Lazy tree namespace entry ${f.vfsPath} disagrees with its captured type or mode`);if(f.type==="directory")continue;let y=u.get(f.vfsPath);if(y===void 0||y.ino!==p.ino||y.generation!==p.generation||y.dataSequence!==p.dataSequence)throw new Error(`Lazy tree namespace entry ${f.vfsPath} changed identity before serialization`);if(f.type==="symlink"){let g=new TextEncoder().encode(f.target).byteLength;if(p.linkCount!==1||p.size!==f.size||p.size!==g||p.symlinkTarget!==f.target)throw new Error(`Lazy tree symlink ${f.vfsPath} disagrees with its captured inventory`);continue}if(p.size!==0||p.linkCount!==l.get(f.inodeGroup))throw new Error(`Lazy tree stub ${f.vfsPath} has changed data or undeclared aliases`)}}}lazyFileForStat(e){let t=V(e.ino,e.generation),r=this.lazyFiles.get(t);if(r&&r.dataSequence!==e.dataSequence){this.lazyFiles.delete(t);return}return r}lazyArchiveEntriesForRead(e){let t=this.lazyAtomicGroupByTree.get(e),r=this.sealedLazyAtomicStates.get(e);if(r!==void 0&&!t?.committed)return r.snapshot.entries;let i=this.ordinaryLazyTreeDefinitions.get(e);return i!==void 0?i.entries:Array.from(e.entries,([o,s])=>({vfsPath:o,...s}))}lazyArchiveForStat(e){let t=V(e.ino,e.generation),r=this.lazyArchiveInodes.get(t);if(!r)return;let i=this.lazyArchiveEntriesForRead(r).filter(s=>s.ino===e.ino&&s.generation===e.generation&&!s.deleted&&!s.materialized);if(i.some(s=>s.dataSequence===e.dataSequence))return r;if(this.lazyArchiveInodes.delete(t),this.sealedLazyAtomicStates.get(r)===void 0){let s=this.ordinaryLazyTreeDefinitions.get(r);if(s!==void 0)this.replaceOrdinaryLazyTreeRuntimeState(r,s.entries.map(a=>a.ino===e.ino&&a.generation===e.generation?{...a,materialized:!0}:a),s.materialized);else for(let a of i)a.materialized=!0}}lazyBackingForStat(e){let t=V(e.ino,e.generation),r=this.lazyFiles.get(t);if(r)return{token:r,path:r.path};let i=this.lazyArchiveInodes.get(t);if(!i)return null;let o=this.lazyArchiveEntriesForRead(i).find(a=>a.ino===e.ino&&a.generation===e.generation&&!a.deleted&&!a.materialized)?.vfsPath;if(o===void 0)return null;let s=this.lazyAtomicGroupByTree.get(i);return s===void 0?{token:i,path:o}:{token:s.token,path:o,atomicGroup:s}}lazyBackingForPath(e){let t=this.lazyArchiveGroups.find(r=>{let i=this.lazyAtomicGroupByTree.get(r),o=this.sealedLazyAtomicStates.get(r)?.snapshot,s=this.ordinaryLazyTreeDefinitions.get(r),a=o===void 0?!(s?.materialized??r.materialized):!i?.committed,c=o?.content??s?.content,u=o?.inventory??s?.inventory,l=o?.activation??s?.activation,d=o?.entries??s?.entries??Array.from(r.entries.values());return a&&c!==void 0&&u!==void 0&&l!==void 0&&d.every(h=>h.deleted||h.materialized||h.isSymlink)&&l.roots.some(h=>h==="/"||e===h||e.startsWith(`${h}/`))});if(t){let r=this.lazyAtomicGroupByTree.get(t);return{token:r?.token??t,path:e,directGroup:t,...r===void 0?{}:{atomicGroup:r}}}try{let r=this.fs.stat(e),i=this.lazyBackingForStat(r);return i?{...i,path:e}:null}catch{return null}}startLazyPreparation(e){let{path:t,token:r}=e,i={status:"pending",promise:Promise.resolve(!1)},o=e.atomicGroup?this.ensureAtomicLazyGroupMaterialized(e.atomicGroup).then(()=>!0):e.directGroup?this.ensureArchiveMaterialized(e.directGroup).then(()=>!0):this.materializePath(t);return i.promise=o.then(s=>(i.status="fulfilled",this.lazyPreparations.get(r)===i&&this.lazyPreparations.delete(r),s),s=>{throw i.status="rejected",i.error=s,s}),i.promise.catch(()=>{}),this.lazyPreparations.set(r,i),i}registerLazyAtomicGroupMembership(e,t=!1){let r=e.activation?.atomicGroup;if(r===void 0)return;let{id:i,member:o}=r;if(e.content===void 0||e.inventory===void 0||e.activation?.mode!=="first-use")throw new Error(`Lazy atomic activation group ${i} accepts only typed first-use trees`);let s=this.lazyAtomicGroups.get(i);if(s===void 0)s={id:i,token:Object.freeze({id:i}),groups:new Map,committed:!1},this.lazyAtomicGroups.set(i,s);else if(s.committed)throw new Error(`Lazy atomic activation group ${i} is already materialized`);if(s.groups.has(o))throw new Error(`Lazy atomic activation group ${i} duplicates member ${o}`);if(Ft(r)){if(s.expectedCount!==void 0&&(s.expectedCount!==r.expectedCount||s.cohortSha256!==r.cohortSha256))throw new Error(`Lazy atomic activation group ${i} has inconsistent seals`);s.expectedCount=r.expectedCount,s.cohortSha256=r.cohortSha256;let a=rn(e,i,o);this.sealedLazyAtomicStates.set(e,{snapshot:js(a,r.descriptorSha256,r.expectedCount,r.cohortSha256),verified:t})}else if(s.expectedCount!==void 0)throw new Error(`Lazy atomic activation group ${i} mixes sealed and unsealed members`);s.groups.set(o,e),this.lazyAtomicGroupByTree.set(e,s)}async sealLazyAtomicGroup(e,t){let r=t.map(u=>ua({id:e,member:u}).member).sort();if(r.length===0||new Set(r).size!==r.length)throw new Error(`Lazy atomic activation group ${e} expected members are invalid`);let i=this.lazyAtomicGroups.get(e);if(i===void 0||i.committed)throw new Error(`Lazy atomic activation group ${e} is not pending`);let o=[...i.groups.keys()].sort();if(JSON.stringify(o)!==JSON.stringify(r))throw new Error(`Lazy atomic activation group ${e} members differ from its seal`);if(i.expectedCount!==void 0){if(i.expectedCount!==r.length||i.cohortSha256===void 0)throw new Error(`Lazy atomic activation group ${e} has an invalid existing seal`);await this.ensureLazyAtomicGroupSealValidated(i,r.map(u=>i.groups.get(u)),!0);return}let s=r.map(u=>rn(i.groups.get(u),e,u)),a=[];for(let u of s)a.push({member:u.member,descriptorSha256:await Nt(u.descriptorBytes,`Lazy atomic member ${u.member}`),source:u});let c=await Nt(Ys(e,a),`Lazy atomic activation group ${e}`);for(let u of a){let l=i.groups.get(u.member),d=rn(l,e,u.member);if(!Js(u.source,d))throw new Error(`Lazy atomic activation member ${u.member} changed while sealing`)}for(let u of a){let l=i.groups.get(u.member);l.activation.atomicGroup={id:e,member:u.member,descriptorSha256:u.descriptorSha256,expectedCount:a.length,cohortSha256:c},this.sealedLazyAtomicStates.set(l,{snapshot:js(u.source,u.descriptorSha256,a.length,c),verified:!0})}i.expectedCount=a.length,i.cohortSha256=c}async verifyImportedLazyAtomicGroupSeals(){await this.validatePendingLazyAtomicGroupSeals(!0)}guardSynchronousLazyAccess(e){let t=this.lazyBackingForPath(e);if(!t)return;let r=this.lazyPreparations.get(t.token);if(r?.status==="fulfilled"){this.lazyPreparations.delete(t.token);let o=this.lazyBackingForPath(e);if(!o)return;r=this.lazyPreparations.get(o.token)??this.startLazyPreparation(o)}else if(r?.status==="rejected"){this.lazyPreparations.delete(t.token);let o=r.error instanceof Error?r.error.message:String(r.error),s=new Error(`EIO: lazy backing for ${e} failed: ${o}`);throw s.code="EIO",s.cause=r.error,s}else r||(r=this.startLazyPreparation(t));let i=new Error(`EAGAIN: lazy backing for ${e} is being prepared`);throw i.code="EAGAIN",i}invalidateLazyData(e){let t=V(e.ino,e.generation);this.lazyFiles.delete(t);let r=this.lazyArchiveInodes.get(t);if(!r)return;this.lazyArchiveInodes.delete(t);let i=this.ordinaryLazyTreeDefinitions.get(r);if(i!==void 0){this.replaceOrdinaryLazyTreeRuntimeState(r,i.entries.map(o=>o.ino===e.ino&&o.generation===e.generation?{...o,materialized:!0}:o),i.materialized);return}for(let o of r.entries.values())o.ino!==e.ino||o.generation!==e.generation||(o.materialized=!0)}rewriteLazyNamespacePaths(e,t,r){let i=t.length>1?t.replace(/\/+$/,""):t,o=r.length>1?r.replace(/\/+$/,""):r,s=`${i}/`,a=`${o}/`,c=V(e.ino,e.generation),u=(e.mode&Ee)===Qe,l=d=>d===i?o:u&&d.startsWith(s)?a+d.slice(s.length):d;for(let[d,h]of this.lazyFiles)!u&&d!==c||(h.paths=new Set(Array.from(h.paths,l)),h.path=l(h.path));for(let d of this.lazyArchiveGroups){let h=this.ordinaryLazyTreeDefinitions.get(d);if(h!==void 0){let f=h.entries.map(g=>{let E=g.generation===void 0?null:V(g.ino,g.generation),O=u||E===c?l(g.vfsPath):g.vfsPath;return{...g,vfsPath:O,...g.type==="hardlink"&&g.target!==void 0?{target:l(g.target)}:{}}}),p=h.inventory.map(g=>({...g,vfsPath:l(g.vfsPath),...g.type==="hardlink"&&g.target!==void 0?{target:l(g.target)}:{}})),_={...h.activation,capabilities:[...h.activation.capabilities],roots:h.activation.roots.map(l)},y=tn(h.content,p,_,h.url,h.mountPrefix,h.integrity,f,h.materialized);this.ordinaryLazyTreeDefinitions.set(d,y);try{d.entries=Xs(y.entries),d.materialized=y.materialized,d.inventory=y.inventory.map(g=>({...g})),d.activation={...y.activation,capabilities:[...y.activation.capabilities],roots:[...y.activation.roots]}}catch{}continue}let m=new Map;for(let[f,p]of d.entries){let _=p.generation===void 0?null:V(p.ino,p.generation);m.set(u||_===c?l(f):f,p)}d.entries=m,d.inventory&&(d.inventory=d.inventory.map(f=>({...f,vfsPath:l(f.vfsPath),...f.type==="hardlink"&&f.target!==void 0?{target:l(f.target)}:{}}))),d.activation&&(d.activation={...d.activation,roots:d.activation.roots.map(l)})}}get sharedBuffer(){return this.fs.buffer}static create(e,t){return new n(le.mkfs(e,t))}static createFresh(e){if(typeof e!="number"||!Ad(e)||e<=0)throw new zd("fresh MemoryFileSystem byte length must be a positive integer");let t=new Ms(e),r=lt(Id,le,[t]);return new n(r)}static fromExisting(e){return new n(le.mount(e))}rebaseToNewFileSystem(e){if(!Number.isSafeInteger(e)||e<=0)throw new Error(`Invalid MemoryFileSystem maxByteLength: ${e}`);let t=SharedArrayBuffer,{bytes:r,identities:i}=this.fs.snapshotState();this.reconcileLazyIdentityState(i);let o=this.serializeLazyEntries(),s=this.serializeValidatedLazyArchiveEntries(i),a=new t(r.byteLength);new Uint8Array(a).set(r);let c=new n(le.mount(a,{restoreImage:!0}),this.imageMetadata);c.importLazyEntries(o),c.importLazyArchiveEntriesInternal(s,!1,!0,"verified");let u=Math.min(e,Math.max(r.byteLength,Dd)),l=new t(u,{maxByteLength:e}),d=n.create(l,e);d.setImageMetadata(this.imageMetadata);let h=new Set(o.flatMap(f=>f.paths??[f.path])),m=new Set;for(let f of s)if(!f.materialized)for(let p of f.entries)!p.deleted&&!p.isSymlink&&m.add(p.vfsPath);return c.copyPathToFreshFileSystem("/",d,h,m,new Map),d.importLazyEntries(o.map(f=>{let p=d.fs.lstat(f.path);return{...f,ino:p.ino,generation:p.generation,dataSequence:p.dataSequence}})),d.importLazyArchiveEntriesInternal(s.map(f=>({...f,entries:f.entries.map(p=>{if(p.deleted)return{...p,ino:0,generation:void 0};let _=d.fs.lstat(p.vfsPath);return{...p,ino:_.ino,generation:_.generation,dataSequence:_.dataSequence}})})),!1,!0,"verified"),d}getImageMetadata(){return Gs(this.imageMetadata)}setImageMetadata(e){this.imageMetadata=e===null?null:ki(e)}subscribeLazyDownloads(e){return this.lazyDownloadListeners.add(e),()=>this.lazyDownloadListeners.delete(e)}setLazyFetcher(e,t={}){this.lazyTransport={fetcher:e,...t.signal===void 0?{}:{signal:t.signal}}}emitLazyDownload(e){if(this.lazyDownloadListeners.size===0)return;let t={...e,t:Xd()};for(let r of this.lazyDownloadListeners)try{r(t)}catch{}}async fetchLazyBytes(e,t){let r=0,i=e.integrity?.bytes??e.fallbackTotalBytes,o={id:e.id,kind:e.kind,url:e.url,path:e.path,mountPrefix:e.mountPrefix};for(let s=0;se.integrity.bytes)throw new Error(`Lazy ${e.kind} exceeded expected byte count ${e.integrity.bytes}`);this.emitLazyDownload({...o,status:"progress",loadedBytes:r,totalBytes:i})}}}catch(d){try{await c.cancel(d)}catch{}throw d}}finally{c.releaseLock()}let l=il(u,r);return te(t.signal),await Oi(l,e.kind,e.integrity),te(t.signal),this.emitLazyDownload({...o,status:"complete",loadedBytes:r,totalBytes:i??r}),l}catch(a){if(t.signal?.aborted){let l=t.signal.reason,d=l instanceof Error?l.message:String(l);throw this.emitLazyDownload({...o,status:"error",loadedBytes:r,totalBytes:i,error:d}),l}let c=s+1({...g})),activation:d,entries:new Map},_=g=>{let E=g.split("/").filter(Boolean),O="";for(let S=0;SE.vfsPath.split("/").length-O.vfsPath.split("/").length))if(g.type==="directory"){_(g.vfsPath);try{this.fs.mkdir(g.vfsPath,g.mode),this.fs.chmod(g.vfsPath,g.mode)}catch{if((this.fs.lstat(g.vfsPath).mode&Ee)!==Qe)throw new Error(`Lazy tree directory collides at ${g.vfsPath}`)}}for(let g of u){if(g.type!=="symlink")continue;_(g.vfsPath),this.fs.symlink(g.target,g.vfsPath);let E=this.fs.lstat(g.vfsPath);p.entries.set(g.vfsPath,{ino:E.ino,generation:E.generation,dataSequence:E.dataSequence,size:g.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:g.sourcePath,sourcePath:g.sourcePath,type:"symlink",target:g.target})}let y=new Map;for(let g of u){if(g.type!=="file")continue;_(g.vfsPath);let E=this.fs.createLazyStub(g.vfsPath,g.mode);this.invalidateLazyData(E),y.set(g.inodeGroup,E);let O={ino:E.ino,generation:E.generation,dataSequence:E.dataSequence,size:g.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:g.sourcePath,sourcePath:g.sourcePath,type:"file",inodeGroup:g.inodeGroup};p.entries.set(g.vfsPath,O)}for(let g of u){if(g.type!=="hardlink")continue;let E=h.get(g.inodeGroup);_(g.vfsPath),this.fs.link(E.vfsPath,g.vfsPath);let O=this.fs.lstat(g.vfsPath),S=y.get(g.inodeGroup);if(O.ino!==S.ino||O.generation!==S.generation)throw new Error(`Lazy tree hardlink ${g.vfsPath} did not share its inode`);p.entries.set(g.vfsPath,{ino:O.ino,generation:O.generation,dataSequence:O.dataSequence,size:g.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:E.sourcePath,sourcePath:g.sourcePath,type:"hardlink",inodeGroup:g.inodeGroup,target:g.target})}if(m!==void 0)for(let g of u)this.lchown(g.vfsPath,m.uid,m.gid);for(let g of p.entries.values())g.isSymlink||g.generation===void 0||this.lazyArchiveInodes.set(V(g.ino,g.generation),p);return this.lazyArchiveGroups.push(p),this.registerLazyAtomicGroupMembership(p),d.atomicGroup===void 0&&this.ordinaryLazyTreeDefinitions.set(p,tn(c,u,d,p.url,l,p.integrity,Ai(p.entries),!1)),p}registerLazyTreeWithMaterializationHandle(e,t,r="/",i,o){let s=this.registerLazyTreeInternal(e,t,r,i,!0,o),a=Object.freeze({[vd]:!0});return this.deferredTreeMaterializationHandles.set(a,s),a}registerLazyArchiveFromEntries(e,t,r,i,o){let s=Ar(r),a=Vd(e,t,s,i);a.some(({entry:u})=>!u.isDirectory&&!u.isSymlink)&&this.assertCanRegisterPendingLazyArchiveGroup();let c={...o?{content:un({decoder:"zip-v1",mediaType:"application/zip",sha256:o.sha256,bytes:o.bytes,expandedBytes:a.reduce((u,l)=>u+l.entry.uncompressedSize,0),sourceEntryCount:a.length,transports:[e]})}:{},url:e,mountPrefix:s,integrity:zr(o),materialized:!1,entries:new Map};for(let{entry:u,vfsPath:l}of a){if(u.isDirectory)continue;let d=l.split("/").filter(Boolean),h="";for(let m=0;mu.deleted||u.materialized),this.lazyArchiveGroups.push(c),c}importLazyArchiveEntries(e){this.importLazyArchiveEntriesInternal(e,!1,!0,"reject")}async importVerifiedLazyArchiveEntries(e){let t=structuredClone(e),r=this.exportLazyArchiveEntries(),i=n.fromExisting(this.sharedBuffer);i.importLazyArchiveEntriesInternal([...r,...t],!1,!0,"pending"),await i.verifyImportedLazyAtomicGroupSeals(),this.importLazyArchiveEntriesInternal(t,!1,!0,"verified")}importLazyArchiveEntriesInternal(e,t,r,i){let o=Me(e,"Serialized lazy archive groups",0,ta).map((l,d)=>{if(typeof l!="object"||l===null||Array.isArray(l))throw new Error(`Serialized lazy archive group ${d} must be an object`);let h=l.kind;if(h===Or||h===Ti||h===ft)return la(l,h);if(h===wr)return bi(l,!1);if(h!==void 0)throw new Error(`Serialized lazy archive group ${d} has an unsupported kind`);if(r)throw new Error(`Serialized lazy archive group ${d} is missing its kind discriminator`);return bi(l,!0)}),s=this.fs.identityState();this.reconcileLazyIdentityState(s);let a=[...this.serializeValidatedLazyArchiveEntries(s),...o];qs(a);let c=[],u=new Map;for(let l of o){let d=new Map,h=l.mountPrefix.replace(/\/+$/,""),m=l.content!==void 0&&l.inventory!==void 0&&l.activation!==void 0,f=m?new Map(l.inventory.map(S=>[S.vfsPath,S])):null,p=m?new Map(l.inventory.map(S=>[dn(S),S])):null,_=new Map,y=new Map,g=new Map;for(let S of l.entries){let w=null,z=l.materialized||S.materialized===!0||S.isSymlink;if(!S.deleted&&!z){if((S.generation===void 0||S.dataSequence===void 0)&&!t)throw new Error("Live lazy-archive metadata requires inode generation and data sequence");try{w=this.fs.lstat(S.vfsPath)}catch{if(m)throw new Error(`Serialized lazy tree stub ${S.vfsPath} is missing from the filesystem`);continue}if(w.ino!==S.ino){if(m)throw new Error(`Serialized lazy tree stub ${S.vfsPath} has a different inode`);continue}if(S.generation!==void 0&&w.generation!==S.generation){if(m)throw new Error(`Serialized lazy tree stub ${S.vfsPath} has a different generation`);continue}if(S.dataSequence===void 0){if(!n.canAdoptLegacyLazyStub(w)){if(m)throw new Error(`Serialized lazy tree stub ${S.vfsPath} is not pristine`);continue}}else if(w.dataSequence!==S.dataSequence){if(m)throw new Error(`Serialized lazy tree stub ${S.vfsPath} has a different data sequence`);continue}if(m){g.set(S.vfsPath,w);let I=f.get(S.vfsPath),R=p.get(dn(S))??I;if(!R||(w.mode&Ee)!==kt||w.size!==0||(w.mode&ie.S_MODE_BITS)!==R.mode||I?.inodeGroup!==void 0&&I.inodeGroup!==R.inodeGroup)throw new Error(`Serialized lazy tree stub ${S.vfsPath} disagrees with its inventory`);let L=V(w.ino,w.generation),v=S.inodeGroup,Z=_.get(v),N=y.get(L);if(Z!==void 0&&Z!==L||N!==void 0&&N!==v)throw new Error(`Serialized lazy tree inode group ${v} disagrees with the filesystem`);_.set(v,L),y.set(L,v)}}d.set(S.vfsPath,{ino:S.ino,generation:w?.generation??S.generation,dataSequence:w?.dataSequence??S.dataSequence,size:S.size,isSymlink:S.isSymlink,deleted:S.deleted,materialized:z,archivePath:S.archivePath??S.vfsPath.slice(h.length+1),sourcePath:S.sourcePath??S.archivePath??S.vfsPath.slice(h.length+1),type:S.type??(S.isSymlink?"symlink":"file"),inodeGroup:S.inodeGroup,target:S.target})}if(m){let S=new Map;for(let w of l.inventory){if(w.type==="file"||w.type==="hardlink"){S.set(w.inodeGroup,(S.get(w.inodeGroup)??0)+1);continue}let z;try{z=this.fs.lstat(w.vfsPath)}catch{throw new Error(`Serialized lazy tree namespace entry ${w.vfsPath} is missing from the filesystem`)}let x=w.type==="directory"?Qe:Sr;if((z.mode&Ee)!==x||(z.mode&ie.S_MODE_BITS)!==w.mode||w.type==="symlink"&&(z.size!==new TextEncoder().encode(w.target).byteLength||this.fs.readlink(w.vfsPath)!==w.target))throw new Error(`Serialized lazy tree namespace entry ${w.vfsPath} disagrees with its inventory`);w.type==="symlink"&&d.set(w.vfsPath,{ino:z.ino,generation:z.generation,dataSequence:z.dataSequence,size:w.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:w.sourcePath,sourcePath:w.sourcePath,type:"symlink",target:w.target})}if(l.activation?.atomicGroup!==void 0)for(let w of l.inventory){if(w.type!=="file"&&w.type!=="hardlink")continue;if(g.get(w.vfsPath).linkCount!==S.get(w.inodeGroup))throw new Error(`Serialized lazy atomic tree inode group ${w.inodeGroup} has undeclared aliases`)}}let E=l.content===void 0?void 0:un(l.content),O={content:E,url:E?.transports[0]??l.url,mountPrefix:l.mountPrefix,integrity:E?{sha256:E.sha256,bytes:E.bytes}:zr(l.integrity),materialized:l.materialized||!(E&&l.inventory)&&Array.from(d.values()).every(S=>S.deleted||S.materialized),inventory:l.inventory?.map(S=>({...S})),activation:l.activation?{mode:l.activation.mode,capabilities:[...l.activation.capabilities],roots:[...l.activation.roots],...l.activation.atomicGroup===void 0?{}:{atomicGroup:{...l.activation.atomicGroup}}}:void 0,entries:d};if(c.push(O),!O.materialized){for(let[,S]of d)if(!S.deleted&&!S.materialized&&S.generation!==void 0){let w=V(S.ino,S.generation),z=u.get(w);if(z!==void 0&&z!==O)throw new Error(`Serialized lazy archive groups share pending inode ${w}`);if(this.lazyArchiveInodes.has(w))throw new Error(`Serialized lazy archive group collides with pending inode ${w}`);u.set(w,O)}}}for(let l of c){let d=l.activation?.atomicGroup;if(d!==void 0&&this.lazyAtomicGroups.get(d.id)?.committed)throw new Error(`Lazy atomic activation group ${d.id} is already materialized`)}if(i==="reject"&&c.some(l=>{let d=l.activation?.atomicGroup;return d!==void 0&&Ft(d)}))throw new Error("Sealed lazy archive registrations require importVerifiedLazyArchiveEntries()");this.lazyArchiveGroups.push(...c);for(let l of c)this.registerLazyAtomicGroupMembership(l,i==="verified"),l.content!==void 0&&l.inventory!==void 0&&l.activation!==void 0&&l.activation.atomicGroup===void 0&&this.ordinaryLazyTreeDefinitions.set(l,tn(l.content,l.inventory,l.activation,l.url,l.mountPrefix,l.integrity,Ai(l.entries),l.materialized));for(let[l,d]of u)this.lazyArchiveInodes.set(l,d)}rewriteLazyArchiveUrls(e){for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0&&!r?.committed){this.assertLazyAtomicSnapshotMatchesPublic(t);let s=gl(i.snapshot,e);t.content=en(s.content),t.url=s.url,t.integrity={...s.integrity},i.snapshot=s;continue}let o=this.ordinaryLazyTreeDefinitions.get(t);if(o!==void 0){let s=fn(o.content,o.content.transports.map(e)),a=tn(s,o.inventory,o.activation,s.transports[0],o.mountPrefix,o.integrity,o.entries,o.materialized);this.ordinaryLazyTreeDefinitions.set(t,a),t.content=en(a.content),t.url=a.url,t.integrity={...a.integrity}}else t.content?(t.content={...t.content,transports:t.content.transports.map(e)},t.url=t.content.transports[0]):t.url=e(t.url)}}serializeLazyArchiveEntries(){let e=[];for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(r?.committed)continue;let _=i.snapshot;if(_.content.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");e.push({kind:ft,content:en(_.content),inventory:_.inventory.map(y=>({...y})),activation:yl(_),url:_.url,mountPrefix:_.mountPrefix,integrity:{..._.integrity},materialized:!1,entries:_.entries.filter(y=>!y.deleted&&!y.materialized).map(({vfsPath:y,...g})=>({vfsPath:y,...g}))});continue}let o=this.ordinaryLazyTreeDefinitions.get(t),s=o?.materialized??t.materialized,a=(o?.entries??Ai(t.entries)).map(_=>({..._})).filter(_=>!_.deleted&&!_.materialized),c=o?.content??t.content,u=o?.inventory??t.inventory,l=o?.activation??t.activation,d=o?.url??t.url,h=o?.mountPrefix??t.mountPrefix,m=o?.integrity??t.integrity;if(a.length===0&&!(c!==void 0&&u!==void 0&&!s))continue;let f=c!==void 0&&u!==void 0&&l!==void 0;if(f&&c.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");let p=l?.atomicGroup;if(p!==void 0&&!Ft(p))throw new Error(`Lazy atomic activation group ${p.id} must be sealed before serialization`);e.push(f?{kind:p!==void 0?ft:c.source===void 0?Or:Ti,content:en(c),inventory:u.map(_=>({..._})),activation:{...l,capabilities:[...l.capabilities],roots:[...l.roots]},url:d,mountPrefix:h,integrity:{...m},materialized:!1,entries:a}:{kind:wr,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:a})}return e}serializeValidatedLazyArchiveEntries(e){this.assertPendingLazyAtomicSnapshotsReadyForSerialization();let t=this.serializeLazyArchiveEntries();return qs(t),this.validatePendingLazyTreeNamespaceState(e),t}exportLazyArchiveEntries(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),this.serializeValidatedLazyArchiveEntries(e)}pendingDeferredTreeUsage(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),ca(this.serializeValidatedLazyArchiveEntries(e))}assertCanAppendDeferredTreeUsage(e){Li(e);let t=this.pendingDeferredTreeUsage();Li({groups:t.groups+e.groups,archiveBytes:t.archiveBytes+e.archiveBytes,expandedBytes:t.expandedBytes+e.expandedBytes,payloadBytes:t.payloadBytes+e.payloadBytes,entries:t.entries+e.entries})}assertCanRegisterPendingLazyArchiveGroup(){if(this.reconcileLazyIdentityState(this.fs.identityState()),this.lazyArchiveGroups.filter(t=>{let r=this.lazyAtomicGroupByTree.get(t);if(this.sealedLazyAtomicStates.get(t)?.snapshot!==void 0)return!r?.committed;let o=this.ordinaryLazyTreeDefinitions.get(t);return!(o?.materialized??t.materialized)&&(o!==void 0||Array.from(t.entries.values()).some(s=>!s.deleted&&!s.materialized))}).length>=be.maxGroups)throw new Error(`Cannot register another lazy archive group: ${be.maxGroups} pending groups already exist`)}async preparePath(e){let t=!1,r=Math.max(3,this.lazyArchiveGroups.length+1);for(let i=0;i{let s=this.ordinaryLazyTreeDefinitions.get(o);return!(s?.materialized??o.materialized)&&(s?.activation??o.activation)?.mode==="boot-prefetch"}),t=0,r,i=Array.from({length:Math.min(e.length,Bd)},async()=>{for(;r===void 0;){let o=t;if(t+=1,o>=e.length)return;try{await this.prepareLazyTreeGroup(e[o])}catch(s){r??=s}}});if(await Promise.all(i),r!==void 0)throw r;return e.length}async materializeRegisteredDeferredTree(e,t){let r=this.deferredTreeMaterializationHandles.get(e);if(r===void 0)throw new Error("Deferred-tree handle was not issued by this filesystem");let i=this.lazyAtomicGroupByTree.get(r);if(i!==void 0)throw new Error(`Deferred tree belongs to atomic activation group ${i.id}; materialize the complete group instead`);if(this.ordinaryLazyTreeDefinitions.get(r)?.materialized??r.materialized)return!1;let s=this.lazyPreparations.get(r);if(s!==void 0)return s.promise;let a=new Uint8Array(t.byteLength);a.set(t);let c={status:"pending",promise:Promise.resolve(!1)};c.promise=Promise.resolve().then(async()=>{let u=this.ordinaryLazyTreeDefinitions.get(r)?.integrity??r.integrity;return await Oi(a,"tree",u),await this.materializeArchiveBytes(r,a),!0}).then(u=>(c.status="fulfilled",u),u=>{throw c.status="rejected",c.error=u,u}),c.promise.catch(()=>{}),this.lazyPreparations.set(r,c);try{return await c.promise}finally{this.lazyPreparations.get(r)===c&&this.lazyPreparations.delete(r)}}async prepareLazyTreeGroup(e){let t=this.lazyAtomicGroupByTree.get(e),r=this.ordinaryLazyTreeDefinitions.get(e);if(t?.committed||t===void 0&&(r?.materialized??e.materialized))return!1;let i=this.sealedLazyAtomicStates.get(e)?.snapshot,o={token:t?.token??e,path:i?.activation.roots[0]??r?.activation.roots[0]??r?.mountPrefix??e.activation?.roots[0]??e.mountPrefix,directGroup:e,...t===void 0?{}:{atomicGroup:t}},s=this.lazyPreparations.get(o.token)??this.startLazyPreparation(o);try{return await s.promise}finally{this.lazyPreparations.get(o.token)===s&&this.lazyPreparations.delete(o.token)}}async ensureMaterialized(e){return this.preparePath(e)}async materializePath(e){if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0)return!1;let t;try{t=this.fs.stat(e)}catch{return!1}let r=V(t.ino,t.generation),i=this.lazyFiles.get(r);if(i){let s=this.lazyTransport,a=await this.fetchLazyBytes({id:`file:${t.ino}`,kind:"file",url:i.url,path:i.path,fallbackTotalBytes:i.size},s);for(let c=0;c<3;c++){if(this.lazyFiles.get(r)!==i)return!1;for(let u of new Set([e,...i.paths]))if(te(s.signal),this.fs.replaceIfIdentity(u,i.ino,i.generation,i.dataSequence,a))return i.path=u,this.lazyFiles.delete(r),!0;this.reconcileLazyIdentityState(this.fs.identityState())}throw new Error(`Lazy file kept changing names while materializing: ${e}`)}let o=this.lazyArchiveInodes.get(r);return o?(await this.ensureArchiveMaterialized(o,{path:e,ino:t.ino,generation:t.generation}),!this.lazyArchiveInodes.has(r)):!1}async decodeAndValidateLazyTree(e,t,r){let i=this.ordinaryLazyTreeDefinitions.get(e),o=r?.content??i?.content??e.content,s=r?.inventory??i?.inventory??e.inventory;if(!o||!s)throw new Error("Lazy tree is missing its decoder or complete inventory");let a=new Map,c=new Map(s.map(m=>[m.vfsPath,m]));if(o.source!==void 0)for(let m of o.source.entries)a.set(m.sourcePath,m);else for(let m of s){if(m.type==="hardlink"){let p=c.get(m.target);if(!p)throw new Error(`Lazy tree hardlink target disappeared: ${m.target}`);if(m.sourcePath===p.sourcePath)continue}if(a.get(m.sourcePath))throw new Error(`Lazy tree inventory duplicates source member ${m.sourcePath}`);a.set(m.sourcePath,{sourcePath:m.sourcePath,type:m.type,mode:m.mode,size:m.size,...m.type==="symlink"?{target:m.target}:{},...m.type==="hardlink"?{target:c.get(m.target)?.sourcePath}:{}})}let u=new Map,l=0;if(o.decoder==="zip-v1"){let{parseZipCentralDirectory:m,extractZipEntryBounded:f}=await Promise.resolve().then(()=>(fi(),li)),p=m(t);if(p.length!==o.sourceEntryCount||p.length!==a.size)throw new Error("Lazy ZIP tree decoded inventory counts differ from its descriptor");for(let _ of p){let y=_.isDirectory?_.fileName.replace(/\/$/,""):_.fileName;if(u.has(y))throw new Error(`Lazy ZIP tree duplicates source member ${y}`);let g=a.get(y);if(!g)throw new Error(`Lazy ZIP tree has undeclared source member ${y}`);if(l+=_.uncompressedSize,l>o.expandedBytes||_.uncompressedSize!==g.size)throw new Error(`Lazy ZIP tree member ${y} exceeds its inventory`);let E=_.isDirectory?"directory":_.isSymlink?"symlink":"file",O=o.modePolicy==="portable-posix-v1"?E==="directory"?493:E==="symlink"?511:(_.mode&73)!==0?493:420:_.mode&ie.S_MODE_BITS;if(E!==g.type||O!==g.mode)throw new Error(`Lazy ZIP tree member ${y} differs from inventory`);if(_.isDirectory)u.set(y,{type:"directory",mode:O});else{let S=f(t,_,g.size);if(_.isSymlink){let w;try{w=new TextDecoder("utf-8",{fatal:!0}).decode(S)}catch{throw new Error(`Lazy ZIP tree symlink ${y} is not UTF-8`)}u.set(y,{type:"symlink",mode:O,target:w})}else u.set(y,{type:"file",mode:O,data:S})}}}else{let{parseTarGzip:m}=await Promise.resolve().then(()=>(Cs(),Ns)),f=m(t,{label:`Lazy tree ${o.sha256}`,limits:{maxCompressedBytes:o.bytes,maxUncompressedBytes:o.expandedBytes,maxEntries:o.sourceEntryCount}});l=new DataView(t.buffer,t.byteOffset,t.byteLength).getUint32(t.byteLength-4,!0);for(let p of f){if(u.has(p.path))throw new Error(`Lazy TAR tree duplicates source member ${p.path}`);p.type==="file"?u.set(p.path,{type:"file",mode:p.mode,data:p.data}):p.type==="directory"?u.set(p.path,{type:"directory",mode:p.mode}):u.set(p.path,{type:p.type,mode:p.mode,target:p.linkName})}}if(u.size!==o.sourceEntryCount||u.size!==a.size||l!==o.expandedBytes)throw new Error("Lazy tree decoded inventory counts differ from its descriptor");for(let[m,f]of a){let p=u.get(m);if(!p)throw new Error(`Lazy tree is missing source member ${m}`);let _=f.type;if(p.type!==_)throw new Error(`Lazy tree member ${m} is ${p.type}, expected ${_}`);if((p.mode&ie.S_MODE_BITS)!==f.mode)throw new Error(`Lazy tree member ${m} mode differs from inventory`);if(_==="file"&&p.data?.byteLength!==f.size)throw new Error(`Lazy tree member ${m} size differs from inventory`);if(_==="symlink"&&p.target!==f.target)throw new Error(`Lazy tree symlink ${m} target differs from inventory`);if(_==="hardlink"&&p.target!==f.target)throw new Error(`Lazy tree hardlink ${m} target differs from inventory`)}let d=o.materialization;if(d!==void 0){for(let f of d.assertions){let p=u.get(f.sourcePath),_=fr(f.bytesHex);if(p?.type!=="file"||p.data===void 0||p.data.byteLength!==_.byteLength||p.data.some((y,g)=>y!==_[g]))throw new Error(`Lazy tree source assertion ${f.sourcePath} differs from archive bytes`)}let m=new Map(d.recipes.map(f=>[f.id,f]));for(let f of d.transforms){let p=u.get(f.sourcePath);if(p?.type!=="file"||p.data===void 0)throw new Error(`Lazy tree transform ${f.sourcePath} is not a regular source`);await Zs(p.data,f.input,`Lazy tree transform ${f.sourcePath} input`);let _=cs(p.data,m.get(f.recipe));await Zs(_,f.output,`Lazy tree transform ${f.sourcePath} output`),p.data=_}}let h=new Map;for(let m of s){if(m.type!=="file"||m.materialization==="descriptor")continue;let f=u.get(m.sourcePath);if(f?.type!=="file"||!f.data)throw new Error(`Lazy tree has no file content for ${m.sourcePath}`);h.set(m.sourcePath,f.data)}return h}async ensureArchiveMaterialized(e,t){let r=this.lazyAtomicGroupByTree.get(e);if(r!==void 0){if(r.committed)return;let a=this.sealedLazyAtomicStates.get(e)?.snapshot,c=this.lazyPreparations.get(r.token)??this.startLazyPreparation({token:r.token,path:a?.activation.roots[0]??e.activation?.roots[0]??e.mountPrefix,atomicGroup:r});try{await c.promise}finally{this.lazyPreparations.get(r.token)===c&&this.lazyPreparations.delete(r.token)}return}if(this.ordinaryLazyTreeDefinitions.get(e)?.materialized??e.materialized)return;let o=this.lazyTransport,s=await this.fetchLazyArchiveData(e,o);te(o.signal),await this.materializeArchiveBytes(e,s,t,o.signal)}async fetchLazyArchiveData(e,t,r){let i=this.ordinaryLazyTreeDefinitions.get(e),o=r?.content??i?.content??e.content,s=r?.inventory??i?.inventory??e.inventory,a=o!==void 0&&s!==void 0,c=r?.mountPrefix??i?.mountPrefix??e.mountPrefix,u=r?.integrity??i?.integrity??e.integrity,l=a?o.transports:[r?.url??i?.url??e.url],d=[],h=null;for(let[m,f]of l.entries())try{h=await this.fetchLazyBytes({id:`archive:${c}:${o?.sha256??f}:${m}`,kind:a?"tree":"archive",url:f,mountPrefix:c,integrity:u},t);break}catch(p){if(te(t.signal),aa(p))throw p;d.push(p instanceof Error?p.message:String(p))}if(te(t.signal),h===null)throw new Error(`All ${l.length} lazy ${a?"tree":"archive"} transports failed: ${d.join("; ")}`);return h}async materializeArchiveBytes(e,t,r,i){if(te(i),this.ordinaryLazyTreeDefinitions.get(e)?.materialized??e.materialized)return;let s=await this.prepareLazyArchiveContents(e,t,i),a=r?V(r.ino,r.generation):null;for(let c=0;c<3;c++){let u=this.collectLazyArchiveReplacements(e,s,r);if(u.size>0&&(te(i),!this.fs.replaceManyIfIdentities(Array.from(u.values(),Qs)))){if(this.reconcileLazyIdentityState(this.fs.identityState()),a&&!this.lazyArchiveInodes.has(a))return;continue}if(te(i),this.publishLazyArchiveReplacements(e,u),(this.ordinaryLazyTreeDefinitions.get(e)?.materialized??e.materialized)||(this.reconcileLazyIdentityState(this.fs.identityState()),a&&!this.lazyArchiveInodes.has(a)))return}if(a&&this.lazyArchiveInodes.has(a))throw new Error(`Lazy archive member kept changing names while materializing: ${r?.path}`)}async prepareLazyArchiveContents(e,t,r,i){te(r);let o=this.ordinaryLazyTreeDefinitions.get(e),s=i?.content??o?.content??e.content,a=i?.inventory??o?.inventory??e.inventory,u=s!==void 0&&a!==void 0?await this.decodeAndValidateLazyTree(e,t,i):null;te(r);let{parseZipCentralDirectory:l,extractZipEntry:d}=await Promise.resolve().then(()=>(fi(),li));te(r);let h=u?[]:l(t),m=new Map;for(let E of h){if(m.has(E.fileName))throw new Error(`Lazy archive contains duplicate member: ${E.fileName}`);m.set(E.fileName,E)}let p=(i?.mountPrefix??o?.mountPrefix??e.mountPrefix).replace(/\/+$/,""),_=new Map,y=i?.entries??o?.entries,g=y===void 0?Array.from(e.entries):y.map(E=>[E.vfsPath,E]);for(let[E,O]of g){if(O.deleted||O.materialized)continue;let S=O.archivePath??E.slice(p.length+1),w=u?void 0:m.get(S),z=u?.get(S);if(u){if(z===void 0||z.byteLength!==O.size)throw new Error(`Lazy tree member ${S} does not match its registered metadata`)}else if(w===void 0||w.isDirectory||w.isSymlink||w.uncompressedSize!==O.size)throw new Error(`Lazy archive member ${S} does not match its registered metadata`);if(O.generation===void 0)continue;let x=V(O.ino,O.generation),I=_.get(x);if(I&&I.archivePath!==S)throw new Error(`Lazy archive aliases for inode ${x} name different members`);if(!I){let R=z??d(t,w);if(R.byteLength!==O.size)throw new Error(`Lazy archive member ${S} extracted ${R.byteLength} bytes, expected ${O.size}`);_.set(x,{archivePath:S,content:R})}}return _}collectLazyArchiveReplacements(e,t,r,i){let o=new Map,s=this.ordinaryLazyTreeDefinitions.get(e),a=i?.entries??s?.entries,c=a===void 0?Array.from(e.entries):a.map(u=>[u.vfsPath,u]);for(let[u,l]of c){if(l.deleted||l.materialized||l.generation===void 0)continue;let d=V(l.ino,l.generation);if(this.lazyArchiveInodes.get(d)!==e)continue;let h=t.get(d);if(!h)throw new Error(`Lazy archive has no extracted content for inode ${d}`);let m=o.get(d);m||(m={ino:l.ino,generation:l.generation,dataSequence:l.dataSequence??0,paths:new Set,content:h.content},o.set(d,m)),m.paths.add(u),r&&r.ino===l.ino&&r.generation===l.generation&&m.paths.add(r.path)}return o}publishLazyArchiveReplacements(e,t){let r=this.ordinaryLazyTreeDefinitions.get(e);if(r!==void 0){let i=r.entries.map(o=>{let s=o.generation===void 0?void 0:V(o.ino,o.generation);return s===void 0||!t.has(s)?o:(this.lazyArchiveInodes.delete(s),{...o,materialized:!0})});this.replaceOrdinaryLazyTreeRuntimeState(e,i,i.every(o=>o.deleted||o.materialized));return}for(let[i,o]of t){this.lazyArchiveInodes.delete(i);for(let s of e.entries.values())s.ino===o.ino&&s.generation===o.generation&&(s.materialized=!0)}e.materialized=Array.from(e.entries.values()).every(i=>i.deleted||i.materialized)}collectAtomicTreeNamespace(e,t){let r=new Set,i=new Map,o=new Map,s=new Map(t.entries.map(c=>[c.vfsPath,c]));for(let c of t.inventory)(c.type==="file"||c.type==="hardlink")&&o.set(c.inodeGroup,(o.get(c.inodeGroup)??0)+1);let a=[];for(let c of t.inventory){let u;try{u=this.fs.lstat(c.vfsPath)}catch{throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`)}let l=c.type==="directory"?Qe:c.type==="symlink"?Sr:kt;if((u.mode&Ee)!==l||(u.mode&ie.S_MODE_BITS)!==c.mode)throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`);if(c.type==="symlink"){let d=s.get(c.vfsPath);if(d===void 0||!d.isSymlink||d.deleted||d.ino!==u.ino||d.generation!==u.generation||d.dataSequence!==u.dataSequence||this.fs.readlink(c.vfsPath)!==c.target)throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`)}else if(c.type==="file"||c.type==="hardlink"){let d=s.get(c.vfsPath);if(d===void 0||d.deleted||d.materialized||d.isSymlink||d.generation===void 0||d.inodeGroup!==c.inodeGroup||d.ino!==u.ino||d.generation!==u.generation||d.dataSequence!==u.dataSequence||u.size!==0||u.linkCount!==o.get(c.inodeGroup))throw new Error(`Lazy atomic tree changed at ${c.vfsPath}`);let h=V(d.ino,d.generation);if(this.lazyArchiveInodes.get(h)!==e)throw new Error(`Lazy atomic tree lost deferred ownership of ${c.vfsPath}`);let m=i.get(c.inodeGroup);if(m!==void 0&&m!==h)throw new Error(`Lazy atomic tree split hard links at ${c.vfsPath}`);i.set(c.inodeGroup,h),r.add(h)}a.push({path:c.vfsPath,expectedIno:u.ino,expectedGeneration:u.generation,expectedDataSequence:u.dataSequence,expectedMode:u.mode,expectedLinkCount:u.linkCount,expectedSize:u.size,expectedUid:u.uid,expectedGid:u.gid})}return{guards:a,pendingIdentities:r.size}}assertLazyAtomicSnapshotMatchesPublic(e){let t=this.sealedLazyAtomicStates.get(e),r=t?.snapshot,i=e.activation?.atomicGroup,o=r?.member??i?.member??"unknown",s;if(r!==void 0)try{s=rn(e,r.id,r.member)}catch{s=void 0}if(t===void 0||r===void 0||i===void 0||!Ft(i)||i.id!==r.id||i.member!==r.member||i.descriptorSha256!==r.descriptorSha256||i.expectedCount!==r.expectedCount||i.cohortSha256!==r.cohortSha256||s===void 0||!Js(r,s))throw new Error(`Lazy atomic activation member ${o} changed after sealing`);return t}assertPendingLazyAtomicSnapshotsReadyForSerialization(){for(let e of this.lazyArchiveGroups){if(!this.sealedLazyAtomicStates.has(e)||this.lazyAtomicGroupByTree.get(e)?.committed)continue;let r=this.assertLazyAtomicSnapshotMatchesPublic(e);if(!r.verified)throw new Error(`Lazy atomic activation group ${r.snapshot.id} has not been cryptographically verified after import`)}}async validatePendingLazyAtomicGroupSeals(e){for(let t of this.lazyAtomicGroups.values()){if(t.committed||t.expectedCount===void 0||t.cohortSha256===void 0)continue;let r=[...t.groups.entries()].sort(([i],[o])=>io?1:0).map(([,i])=>i);await this.ensureLazyAtomicGroupSealValidated(t,r,e)}}async ensureLazyAtomicGroupSealValidated(e,t,r){if(this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r))return;let i=e.sealValidationFlight;if(i===void 0&&(i=this.validateLazyAtomicGroupSealOnce(e,t),e.sealValidationFlight=i,i.then(()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)},()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)})),await i,!this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r))throw new Error(`Lazy atomic activation group ${e.id} seal verification did not authenticate every member`)}assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r){if(e.committed)return!0;if(e.expectedCount===void 0||e.cohortSha256===void 0||t.length!==e.expectedCount)throw new Error(`Lazy atomic activation group ${e.id} is not completely sealed`);let i=!0,o=[];for(let s of t){let a=this.assertLazyAtomicSnapshotMatchesPublic(s),c=a.snapshot;if(c.id!==e.id||c.expectedCount!==e.expectedCount||c.cohortSha256!==e.cohortSha256||e.groups.get(c.member)!==s)throw new Error(`Lazy atomic activation group ${e.id} has an inconsistent member`);i&&=a.verified,o.push(a)}if(i&&r)for(let s=0;sfp?1:0).map(([,f])=>f);if(t.length===0)throw new Error(`Lazy atomic activation group ${e.id} has no trees`);if(await this.ensureLazyAtomicGroupSealValidated(e,t,!0),e.committed)return;let r=t.map(f=>this.sealedLazyAtomicStates.get(f).snapshot),i=t.map((f,p)=>({group:f,...this.collectAtomicTreeNamespace(f,r[p])})),o=this.lazyTransport,s=new Array(t.length),a=0,c=!1,u,l=Array.from({length:Math.min($d,t.length)},async()=>{for(;!c;){let f=a++;if(f>=t.length)return;let p=t[f],_=r[f];try{let y=await this.fetchLazyArchiveData(p,o,_);te(o.signal),s[f]={group:p,snapshot:_,contents:await this.prepareLazyArchiveContents(p,y,o.signal,_)}}catch(y){c||(c=!0,u=y)}}});if(await Promise.all(l),c)throw s.fill(void 0),u;te(o.signal);for(let f of t)this.assertLazyAtomicSnapshotMatchesPublic(f);let d=[],h=[],m=[];for(let f=0;f{let a=this.lazyAtomicGroupByTree.get(s),c=this.sealedLazyAtomicStates.get(s)?.snapshot,u=this.ordinaryLazyTreeDefinitions.get(s);return c===void 0?u!==void 0&&!u.materialized:!a?.committed});if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0&&r.length===0)return;let i=Array.from(this.lazyFiles.values(),s=>s.path);for(let s of i)await this.ensureMaterialized(s);let o=new Set(this.lazyArchiveInodes.values());for(let s of r)o.add(s);for(let s of o)await this.prepareLazyTreeGroup(s)}this.reconcileLazyIdentityState(this.fs.identityState());let e=this.lazyArchiveGroups.some(t=>{let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t)?.snapshot,o=this.ordinaryLazyTreeDefinitions.get(t);return i===void 0?o!==void 0&&!o.materialized:!r?.committed});if(this.lazyFiles.size!==0||this.lazyArchiveInodes.size!==0||e)throw new Error("Cannot create a self-contained VFS image while lazy entries remain pending")}async saveImage(e){e?.materializeAll&&await this.materializeAllLazyEntries(),await this.validatePendingLazyAtomicGroupSeals(!1);let{bytes:t,identities:r}=this.fs.snapshotState({normalizeTimestampsMs:e?.normalizeTimestampsMs});this.reconcileLazyIdentityState(r);let i=this.serializeLazyEntries(),o=i.length>0,s=o?new TextEncoder().encode(JSON.stringify(i)):new Uint8Array(0);if(s.byteLength>on)throw new Error(`VFS image lazy metadata exceeds ${on} bytes`);let a=this.serializeValidatedLazyArchiveEntries(r),c=a.length>0,u=c?new TextEncoder().encode(JSON.stringify(a)):new Uint8Array(0);if(u.byteLength>sn)throw new Error(`VFS image lazy archive metadata exceeds ${sn} bytes`);let l=e?.metadata===void 0?this.imageMetadata:e.metadata,d=Zd(l),h=d.byteLength>0,m=c?4+u.byteLength:0,f=h?4+d.byteLength:0,p=ye+t.byteLength+4+s.byteLength+m+f,_=new Uint8Array(p),y=new DataView(_.buffer);y.setUint32(0,zi,!0),y.setUint32(4,xi,!0),y.setUint32(8,(o?gi:0)|(c?nn:0)|(c?Si:0)|(h?Ei:0),!0),y.setUint32(12,t.byteLength,!0),_.set(t,ye);let g=ye+t.byteLength;if(y.setUint32(g,s.byteLength,!0),s.byteLength>0&&_.set(s,g+4),c){let E=g+4+s.byteLength;y.setUint32(E,u.byteLength,!0),_.set(u,E+4)}if(h){let E=g+4+s.byteLength+m;y.setUint32(E,d.byteLength,!0),_.set(d,E+4)}return _}static readImageMetadata(e){let t=Qr(e);if(!(t.flags&Ei))return null;let{metadataOffset:r}=Hs(t.image,t.view,t.flags,t.sabLen);if(t.image.byteLengthCt)throw new Error(`VFS image metadata exceeds ${Ct} bytes`);if(t.image.byteLength0){let _=r.subarray(f+4,f+4+p),y=Me(Vs(_,"VFS image lazy metadata"),"VFS image lazy entries",0,Mt);m.importLazyEntriesInternal(y,!0)}if(o&nn){let _=a.archiveOffset,y=i.getUint32(_,!0);if(y>0){let g=r.subarray(_+4,_+4+y),E=Vs(g,"VFS image lazy archive metadata");m.importLazyArchiveEntriesInternal(E,!0,!!(o&Si),"pending")}}return m}adaptStat(e){return{dev:0,ino:e.ino,mode:e.mode,nlink:e.linkCount,uid:e.uid,gid:e.gid,size:e.size,atimeMs:e.atime,mtimeMs:e.mtime,ctimeMs:e.ctime}}adaptStatWithLazySize(e){let t=this.adaptStat(e),r=this.lazyFileForStat(e);if(r)return t.size=r.size,t;let i=this.lazyArchiveForStat(e);if(i){for(let o of this.lazyArchiveEntriesForRead(i))if(o.ino===e.ino&&o.generation===e.generation&&!o.deleted){t.size=o.size;break}}return t}open(e,t,r){(t&dr)===0&&!((t&cr)!==0&&(t&ei)!==0)&&this.guardSynchronousLazyAccess(e);let i=this.fs.open(e,t,r);return(t&dr)!==0&&this.invalidateLazyData(this.fs.fstat(i)),i}close(e){return this.fs.close(e),0}read(e,t,r,i){if(i>0){let o=this.lazyBackingForStat(this.fs.fstat(e));o&&(this.reconcileLazyIdentityState(this.fs.identityState()),o=this.lazyBackingForStat(this.fs.fstat(e)),o&&this.guardSynchronousLazyAccess(o.path))}return r!==null?this.fs.readAt(e,t.subarray(0,i),typeof r=="bigint"?Un(r):r):this.fs.read(e,t.subarray(0,i))}write(e,t,r,i){if(r!==null){let s=this.fs.writeAt(e,t.subarray(0,i),typeof r=="bigint"?Un(r):r);return s>0&&this.invalidateLazyData(this.fs.fstat(e)),s}let o=this.fs.write(e,t.subarray(0,i));return o>0&&this.invalidateLazyData(this.fs.fstat(e)),o}append(e,t,r,i){let o=this.fs.append(e,t.subarray(0,r),Bo(i));return o.written>0&&this.invalidateLazyData(this.fs.fstat(e)),o}seek(e,t,r){return this.fs.lseek(e,typeof t=="bigint"?$n(t):t,r)}fstat(e){return this.adaptStatWithLazySize(this.fs.fstat(e))}fpathconf(e,t){let r=this.fstat(e);return Gn(r,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}ftruncate(e,t){this.fs.ftruncate(e,t),this.invalidateLazyData(this.fs.fstat(e))}fsync(e){}fchmod(e,t){this.fs.fchmod(e,t)}fchown(e,t,r){this.fs.fchown(e,t,r)}stat(e){return this.adaptStatWithLazySize(this.fs.stat(e))}lstat(e){return this.adaptStatWithLazySize(this.fs.lstat(e))}statfs(e){this.fs.stat(e);let t=this.fs.statfs();return{type:1397114451,bsize:t.blockSize,blocks:t.totalBlocks,bfree:t.freeBlocks,bavail:t.freeBlocks,files:t.totalInodes,ffree:t.freeInodes,fsid:0,namelen:t.maxName,frsize:t.blockSize,flags:Uo}}pathconf(e,t){let r=this.stat(e);return Gn(r,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}mkdir(e,t){this.fs.mkdir(e,t)}rmdir(e){this.fs.rmdir(e)}unlink(e){let t=this.fs.unlink(e),r=V(t.ino,t.generation);if(t.linkCount>1&&(this.lazyFiles.has(r)||this.lazyArchiveInodes.has(r))){this.reconcileLazyIdentityState(this.fs.identityState());return}let i=this.lazyFiles.get(r);i&&(i.paths.delete(e),t.linkCount<=1?this.lazyFiles.delete(r):i.path===e&&(i.path=i.paths.values().next().value));let o=this.lazyArchiveInodes.get(r);if(o){let s=this.ordinaryLazyTreeDefinitions.get(o);if(s!==void 0){let a=t.linkCount<=1?s.entries.map(c=>c.ino===t.ino&&c.generation===t.generation?{...c,deleted:!0}:c):s.entries.filter(c=>c.vfsPath!==e);this.replaceOrdinaryLazyTreeRuntimeState(o,a,s.materialized),t.linkCount<=1&&this.lazyArchiveInodes.delete(r)}else{let a=o.entries.get(e);if(t.linkCount<=1){for(let c of o.entries.values())c.ino===t.ino&&c.generation===t.generation&&(c.deleted=!0);this.lazyArchiveInodes.delete(r)}else a&&o.entries.delete(e)}}}rename(e,t){let{source:r,replaced:i}=this.fs.rename(e,t);if(i&&i.ino===r.ino&&i.generation===r.generation)return;let o=!1;if(i){let s=V(i.ino,i.generation);i.linkCount>1&&(this.lazyFiles.has(s)||this.lazyArchiveInodes.has(s))&&(this.reconcileLazyIdentityState(this.fs.identityState()),o=!0);let a=this.lazyFiles.get(s);!o&&a&&(a.paths.delete(t),i.linkCount<=1?this.lazyFiles.delete(s):a.path===t&&(a.path=a.paths.values().next().value));let c=this.lazyArchiveInodes.get(s);if(!o&&c){let u=this.ordinaryLazyTreeDefinitions.get(c);if(u!==void 0){let l=i.linkCount<=1?u.entries.map(d=>d.ino===i.ino&&d.generation===i.generation?{...d,deleted:!0}:d):u.entries.filter(d=>d.vfsPath!==t);this.replaceOrdinaryLazyTreeRuntimeState(c,l,u.materialized),i.linkCount<=1&&this.lazyArchiveInodes.delete(s)}else{let l=c.entries.get(t);i.linkCount<=1?(l&&(l.deleted=!0),this.lazyArchiveInodes.delete(s)):l&&c.entries.delete(t)}}}o||this.rewriteLazyNamespacePaths(r,e,t)}link(e,t){let r=this.fs.link(e,t),i=V(r.ino,r.generation),o=this.lazyFiles.get(i);o&&o.paths.add(t);let s=this.lazyArchiveInodes.get(i);if(s){let a=this.ordinaryLazyTreeDefinitions.get(s);if(a!==void 0){let c=a.entries.find(u=>u.ino===r.ino&&u.generation===r.generation);c!==void 0&&this.replaceOrdinaryLazyTreeRuntimeState(s,[...a.entries,{...c,vfsPath:t}],a.materialized)}else{let c=Array.from(s.entries.values()).find(u=>u.ino===r.ino&&u.generation===r.generation);c&&s.entries.set(t,{...c})}}}symlink(e,t){this.fs.symlink(e,t)}readlink(e){return this.fs.readlink(e)}chmod(e,t){this.fs.chmod(e,t)}chown(e,t,r){this.fs.chown(e,t,r)}lchown(e,t,r){this.fs.lchown(e,t,r)}createFileWithOwner(e,t,r,i,o){let s=this.open(e,Ks,t);o.length>0&&this.write(s,o,null,o.length),this.close(s),this.chown(e,r,i),this.chmod(e,t)}mkdirWithOwner(e,t,r,i){this.mkdir(e,t),this.chown(e,r,i),this.chmod(e,t)}symlinkWithOwner(e,t,r,i){this.symlink(e,t),this.lchown(t,r,i)}copyPathToFreshFileSystem(e,t,r,i,o){let s=this.lstat(e),a=s.mode&Ee,c=s.mode&ie.S_MODE_BITS;if(a===Qe){e==="/"?(t.chown(e,s.uid,s.gid),t.chmod(e,c)):t.mkdirWithOwner(e,c,s.uid,s.gid);let h=this.opendir(e);try{for(;;){let m=this.readdir(h);if(!m)break;m.name==="."||m.name===".."||this.copyPathToFreshFileSystem(e==="/"?`/${m.name}`:`${e}/${m.name}`,t,r,i,o)}}finally{this.closedir(h)}n.applyTimes(t,e,s);return}let u=s.nlink>1?`${s.dev}:${s.ino}`:null,l=u?o.get(u):void 0;if(l){t.link(l,e);return}if(a===Sr){t.symlinkWithOwner(this.readlink(e),e,s.uid,s.gid),u&&o.set(u,e);return}if(a!==kt)throw new Error(`Unsupported file type while rebasing VFS: ${e}`);if(r.has(e)||i.has(e)){t.createFileWithOwner(e,c,s.uid,s.gid,new Uint8Array(0)),n.applyTimes(t,e,s),u&&o.set(u,e);return}this.copyRegularFileToFreshFileSystem(e,t,s,c),u&&o.set(u,e)}copyRegularFileToFreshFileSystem(e,t,r,i){let o=this.open(e,Cd,0),s=null;try{s=t.open(e,Ks,i);let a=new Uint8Array(Math.min(Md,Math.max(1,r.size))),c=r.size;for(;c>0;){let u=Math.min(a.byteLength,c),l=this.read(o,a,null,u);if(l<=0)throw new Error(`Unexpected EOF while rebasing VFS file: ${e}`);let d=0;for(;d!e||e==="."||e===".."))throw new Error(`Binary resolver path must be a normalized portable relative path: ${JSON.stringify(n)}`);return n}var mt=new Set(["wasm32","wasm64"]);function Ke(n){if(Rl(n),!n.startsWith("programs/"))return n;let e=n.slice(9),t=e.split("/",1)[0];return mt.has(t)?n:`programs/wasm32/${e}`}function Ll(n,e=U(Bi(),"wasm")){let t=Ke(n),r=[U(e,t)];return n==="kernel.wasm"?r.push(U(e,"kandelo-kernel.wasm")):n==="userspace.wasm"?r.push(U(e,"wasm_posix_userspace.wasm")):n==="rootfs.vfs"&&r.push(U(e,"rootfs.vfs")),r}var mn=class extends Error{constructor(e){super(e),this.name="BinaryNotFoundError"}};function xa(){let n=[],e=!1;try{let r=ht();e=!0;for(let[i,o]of[["local-binaries",U(r,"local-binaries")],["binaries",U(r,"binaries")]])n.push({label:i,root:o,identity:i==="local-binaries"?"local-generation":"program-cache",allowRegularFileClosure:!1,candidatesFor(s){return[U(o,Ke(s))]}})}catch{}let t=U(Bi(),"wasm");return n.push({label:"installed package",root:t,identity:"installed-package",allowRegularFileClosure:!e,candidatesFor(r){return Ll(r,t)}}),n}function Dt(n,e){return new Error(`Invalid package manifest ${n}: ${e}`)}function fe(n){try{return yn(n),!0}catch(e){if(e instanceof Error&&"code"in e&&e.code==="ENOENT")return!1;throw e}}function ma(n,e,t){if(n.length===0||n.startsWith("/")||n.includes("\\")||n.includes("\0")||n.split("/").some(r=>!r||r==="."||r===".."))throw Dt(e,`${t} must be a normalized portable relative path`);return n}function pn(n,e,t,r=!0){if(n.length===0||n==="."||n===".."||n.includes("/")||n.includes("\\")||n.includes("\0")||!r&&n.includes("@"))throw Dt(e,`${t} must be a safe single path component`);return n}var ya="kandelo-program-packages-v2",De="program-packages.json",_a=null,bl=null,hn=null,Ni=0;function $i(){return bl??U(Bi(),"wasm",De)}function Ia(){if(Object.prototype.hasOwnProperty.call(process.env,"WASM_POSIX_DEPS_REGISTRY")){let t=null;return(process.env.WASM_POSIX_DEPS_REGISTRY??"").split(":").filter(Boolean).map(r=>r.startsWith("~/")&&process.env.HOME!==void 0?U(process.env.HOME,r.slice(2)):_n(r)?Re(r):(t??=ht(),Re(t,r)))}let n;try{n=U(ht(),"packages","registry")}catch{return null}let e=!1;if(fe(n)){if(!rt(n).isDirectory())return[n];e=Oa(n,{withFileTypes:!0}).filter(t=>t.isDirectory()||t.isSymbolicLink()).some(t=>fe(U(n,t.name,"package.toml")))}return!e&&Ta()===null&&fe($i())?null:[n]}function Ta(){let n;try{n=ht()}catch{return null}if(!xr(U(n,"tools","xtask","Cargo.toml"))||!xr(U(n,"scripts","dev-shell.sh")))return null;try{let e=Le(Ki()),t=Le(n);return[U(t,"host"),U(t,"scripts")].some(i=>xr(i)&&Vi(Le(i),e))?t:null}catch{return null}}function Ui(n,e,t){let r=[typeof t.stderr=="string"?t.stderr.trim():"",typeof t.stdout=="string"?t.stdout.trim():"",t.error?.message??""].filter(Boolean).join(` +var Ma=Object.defineProperty;var Lr=(n,e,t)=>()=>{if(t)throw t[0];try{return n&&(e=n(n=0)),e}catch(r){throw t=[r],r}};var Yi=(n,e)=>{for(var t in e)Ma(n,t,{get:e[t],enumerable:!0})};var Bt,Xi,$t,ji,Sn,Ji,ne,Qi,eo,vr,to,wn,ro,no,io,oo,Pr,kr,On,An,zn,xn,In,gt,Ut,Wt,Gt,Pe,so,ao,Fr,xe,co,Nr,Tn,Cr,Mr,Et,Ht,Ge,Rn,bn,uo,Dr,lo,Y,fo,po,Kr,Vt,ho,mo,yo,X,_o,go,Br,qt,Eo,So,Ln,vn,ot,St,Pn,Zt,He,wo,Oo,ie,Ao,zo,xo,Io,Ve=Lr(()=>{"use strict";Bt="kandelo.wpk_fork.linked_frames",Xi=[75,76,67,70],$t=24,ji=8,Sn=3,Ji=[{bytes:4,chunkHeaderSize:32,nodeHeaderSize:24},{bytes:8,chunkHeaderSize:56,nodeHeaderSize:32}],ne="kandelo.wpk_fork.module_state",Qi=1,eo=[75,70,77,68],vr=24,to=8,wn=7,ro=1,no=1,io=1,oo=[{bytes:4,chunkHeaderSize:40},{bytes:8,chunkHeaderSize:56}],Pr="__wpk_fork_global_",kr="__wpk_fork_table_",On=1,An=2,zn=3,xn=4,In=5,gt=6,Ut=7,Wt=8,Gt=9,Pe="kandelo.wpk_fork.capabilities",so=1,ao=7,Fr=4,xe="kandelo.wpk_fork.exception_codec",co=1,Nr=8,Tn=16,Cr="env",Mr="__wpk_fork_unwind",Et="kandelo.wpk_fork.unwind_transport",Ht="__wpk_fork_static_root_catalog",Ge="kandelo.wpk_fork.static_root_catalog",Rn=1,bn=0,uo=1,Dr=12,lo=[75,70,83,82],Y="kandelo.wpk_fork.imported_globals",fo=[75,70,73,71],po=1,Kr=16,Vt=24,ho=1,mo=2,yo=3,X="kandelo.wpk_fork.imported_tables",_o=[75,70,73,84],go=1,Br=16,qt=24,Eo=1,So=1,Ln="env",vn="__wpk_fork_module_activation",ot={module:"kernel",name:"kernel_fork",params:["i32"],results:["i32"]},St=[{module:"env",name:"__wpk_fork_frame_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_frame_next",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_peek",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_reserve",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_record_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_module_state_record_find",params:["i32","i32","i32","i32"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_record_reserve",params:["i32","i32","i32","ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_module_state_table_dirty_count",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_module_state_table_dirty_mark",params:["i32","i64","i64"],results:[]},{module:"env",name:"__wpk_fork_module_state_table_dirty_page",params:["i32","i32"],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_mutation_abort",params:[],results:[]},{module:"env",name:"__wpk_fork_module_state_table_mutation_begin",params:[],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_mutation_commit",params:["i32","i64","i64"],results:[]},{module:"env",name:"__wpk_fork_module_state_table_reconcile",params:[],results:["i64"]},{module:"env",name:"__wpk_fork_module_state_table_state_owned",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_decode_funcref",params:["i32"],results:["funcref"]},{module:"env",name:"__wpk_fork_ref_encode_funcref",params:["funcref"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_broker_encode",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_broker_throw_recipe",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_cache_index",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_claim",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_define",params:["i32","i32","i32","i32","ptr","i32","ptr","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_ingress_throw",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_exn_load",params:["i32","i32","i32","i32","ptr","i32","ptr","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_lookup",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_exn_route",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_broker_encode",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_capture_layout",params:["i32","i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_claim",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_define",params:["i32","i32","i32","i32","i32","ptr","i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_i31",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_load",params:["i32","i32","i32","i32","i32","ptr","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_lookup",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_payload_len",params:["i32","i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_provenance_begin",params:["i32","i32","i32","i32","i64","i64","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_gc_provenance_end",params:["i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_provenance_ref",params:["i32","i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_gc_route",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_scratch_release",params:["ptr","ptr"],results:[]},{module:"env",name:"__wpk_fork_ref_scratch_reserve",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_ref_vector_append",params:["i32","i32"],results:[]},{module:"env",name:"__wpk_fork_ref_vector_begin",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_vector_finish",params:["i32"],results:["i32"]},{module:"env",name:"__wpk_fork_ref_vector_get",params:["i32","i32"],results:["i32"]},{module:"env",name:"__wpk_fork_resume_peek",params:["i32"],results:["i32"]}],Pn=[{module:"env",name:"__wpk_fork_ref_gc_transit",table64:!1,element:"anyref",minimum:1,maximum:null},{module:"env",name:"__wpk_fork_resume_table",table64:!1,element:"funcref",minimum:1,maximum:null}],Zt=[{name:"__wpk_fork_exception_materialize",params:["i32"],results:[]},{name:"__wpk_fork_ref_decode_exnref",params:["i32"],results:["exnref"]},{name:"__wpk_fork_ref_encode_exnref",params:["exnref"],results:["i32"]},{name:"__wpk_fork_ref_exn_abort",params:[],results:[]},{name:"__wpk_fork_ref_exn_clear",params:[],results:[]},{name:"__wpk_fork_ref_exn_encode_ingress",params:["i32"],results:["i32"]},{name:"__wpk_fork_ref_exn_throw_recipe",params:["i32"],results:[]},{name:"__wpk_fork_ref_exn_throw_slot",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_allocate",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_encode_slot",params:["i32"],results:["i32"]},{name:"__wpk_fork_ref_gc_fill",params:["i32"],results:[]},{name:"__wpk_fork_ref_gc_probe",params:["i32"],results:["i64"]},{name:"__wpk_fork_ref_gc_publish_externref",params:["i32","externref"],results:[]},{name:"__wpk_fork_static_root_harvest",params:[],results:[]},{name:"wpk_fork_abort_begin",params:["ptr"],results:[]},{name:"wpk_fork_abort_end",params:[],results:[]},{name:"wpk_fork_module_bootstrap",params:[],results:[]},{name:"wpk_fork_module_state_finish_restore",params:["i32"],results:[]},{name:"wpk_fork_module_state_restore",params:["i32"],results:[]},{name:"wpk_fork_module_state_save",params:["i32"],results:[]},{name:"wpk_fork_module_table_state_restore",params:["i32"],results:[]},{name:"wpk_fork_module_table_state_save",params:["i32"],results:[]},{name:"wpk_fork_module_thread_bootstrap",params:[],results:[]},{name:"wpk_fork_rewind_begin",params:["ptr"],results:[]},{name:"wpk_fork_rewind_end",params:[],results:[]},{name:"wpk_fork_state",params:[],results:["i32"]},{name:"wpk_fork_unwind_begin",params:["ptr"],results:[]},{name:"wpk_fork_unwind_end",params:[],results:[]}],He={O_RDONLY:0,O_WRONLY:1,O_RDWR:2,O_ACCMODE:3,O_CREAT:64,O_EXCL:128,O_NOCTTY:256,O_TRUNC:512,O_APPEND:1024,O_NONBLOCK:2048,O_ASYNC:8192,O_DIRECTORY:65536,O_NOFOLLOW:131072,O_CLOEXEC:524288,O_PATH:2097152,O_CLOFORK:8388608},wo={F_OK:0,R_OK:4,W_OK:2,X_OK:1},Oo={ST_NOSUID:2},ie={S_IFMT:61440,S_IFSOCK:49152,S_IFLNK:40960,S_IFREG:32768,S_IFBLK:24576,S_IFDIR:16384,S_IFCHR:8192,S_IFIFO:4096,S_ISUID:2048,S_ISGID:1024,S_ISVTX:512,S_IRWXU:448,S_IRUSR:256,S_IWUSR:128,S_IXUSR:64,S_IRWXG:56,S_IRGRP:32,S_IWGRP:16,S_IXGRP:8,S_IRWXO:7,S_IROTH:4,S_IWOTH:2,S_IXOTH:1,S_MODE_BITS:4095},Ao={DT_UNKNOWN:0,DT_FIFO:1,DT_CHR:2,DT_DIR:4,DT_BLK:6,DT_REG:8,DT_LNK:10,DT_SOCK:12},zo=4096,xo=["__abi_version","kernel_alloc_scratch","kernel_blocking_retry_release","kernel_blocking_retry_token","kernel_commit_process_exit","kernel_create_process","kernel_create_process_with_stdio","kernel_dequeue_signal","kernel_exec_commit","kernel_exec_target_cancel","kernel_exec_target_prepare","kernel_exec_target_read","kernel_exec_target_size","kernel_fork_process","kernel_get_cwd","kernel_get_dirfd_path","kernel_get_fd_path","kernel_get_parent_pid","kernel_get_process_exit_signal","kernel_get_process_state","kernel_get_socket_timeout_ms","kernel_handle_channel","kernel_has_sa_nocldstop","kernel_host_adapter_manifest_len","kernel_host_adapter_manifest_ptr","kernel_ipc_shm_lookup_mapping_for_task","kernel_ipc_shm_record_mapping_for_process","kernel_ipc_shm_record_mapping_for_task","kernel_ipc_shmat_for_process","kernel_ipc_shmat_for_task","kernel_ipc_shmdt_addr_for_process","kernel_ipc_shmdt_addr_for_task","kernel_ipc_shmdt_for_process","kernel_ipc_shmdt_for_task","kernel_is_fd_nonblock","kernel_mark_process_signaled","kernel_mq_descriptor_msgsize","kernel_msqid_ds_bytes","kernel_pcm_claim_transport","kernel_pcm_clock_update","kernel_pcm_reconcile","kernel_pcm_transport_len","kernel_pcm_transport_ptr","kernel_pick_signal_target_tid","kernel_pick_tcp_listener_target","kernel_pipe_has_readers","kernel_posix_timer_fire","kernel_process_metadata_begin","kernel_process_metadata_cancel","kernel_process_metadata_commit","kernel_process_metadata_stage","kernel_process_secure_exec","kernel_publish_spawn_child","kernel_reap_exited_child","kernel_remove_process","kernel_semctl_array_bytes","kernel_semid_ds_bytes","kernel_set_current_tid","kernel_set_cwd","kernel_shmid_ds_bytes","kernel_spawn_exec_commit","kernel_spawn_exec_target_prepare","kernel_spawn_process","kernel_spawn_reserved_process","kernel_spawn_scratch_begin","kernel_spawn_scratch_cancel","kernel_spawn_scratch_capacity","kernel_spawn_scratch_pointer","kernel_spawn_scratch_retained_capacity","kernel_take_process_timer_cleanup","kernel_thread_exit","kernel_thread_has_deliverable","kernel_transfer_channel_execute","kernel_transfer_io_execute","kernel_transfer_scratch_begin","kernel_transfer_scratch_cancel","kernel_transfer_scratch_capacity","kernel_transfer_scratch_pointer","kernel_validate_task","kernel_wait_child_poll"],Io={LINK_MAX:0,MAX_CANON:1,MAX_INPUT:2,NAME_MAX:3,PATH_MAX:4,PIPE_BUF:5,CHOWN_RESTRICTED:6,NO_TRUNC:7,VDISABLE:8,SYNC_IO:9,ASYNC_IO:10,PRIO_IO:11,SOCK_MAXBUF:12,FILESIZEBITS:13,REC_INCR_XFER_SIZE:14,REC_MAX_XFER_SIZE:15,REC_MIN_XFER_SIZE:16,REC_XFER_ALIGN:17,ALLOC_SIZE_MIN:18,SYMLINK_MAX:19,POSIX2_SYMLINKS:20,FALLOC:21,TEXTDOMAIN_MAX:22,TIMESTAMP_RESOLUTION:23}});import{createRequire as bu}from"module";function As(n,e){return Os(n,{i:2},e&&e.out,e&&e.dictionary)}var Lu,Pt,vu,Pu,ae,Lt,ku,ms,ys,Fu,_s,Pt,gs,Nu,Es,Cu,If,ui,Ne,B,mr,yr,B,B,B,B,Ss,B,Mu,Du,ai,Te,ci,ws,en,Ku,Ee,Os,Bu,$u,vt,zs,Uu,Wu,di=Lr(()=>{Lu=bu("/");try{Pt=Lu("worker_threads"),vu=Pt.Worker,Pu=Pt.isMarkedAsUntransferable}catch{}ae=Uint8Array,Lt=Uint16Array,ku=Int32Array,ms=new ae([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),ys=new ae([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),Fu=new ae([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),_s=function(n,e){for(var t=new Lt(31),r=0;r<31;++r)t[r]=e+=1<>1|(B&21845)<<1,Ne=(Ne&52428)>>2|(Ne&13107)<<2,Ne=(Ne&61680)>>4|(Ne&3855)<<4,ui[B]=((Ne&65280)>>8|(Ne&255)<<8)>>1;mr=(function(n,e,t){for(var r=n.length,i=0,o=new Lt(e);i>a]=u}else for(c=new Lt(r),i=0;i>15-n[i]);return c}),yr=new ae(288);for(B=0;B<144;++B)yr[B]=8;for(B=144;B<256;++B)yr[B]=9;for(B=256;B<280;++B)yr[B]=7;for(B=280;B<288;++B)yr[B]=8;Ss=new ae(32);for(B=0;B<32;++B)Ss[B]=5;Mu=mr(yr,9,1),Du=mr(Ss,5,1),ai=function(n){for(var e=n[0],t=1;te&&(e=n[t]);return e},Te=function(n,e,t){var r=e/8|0;return(n[r]|n[r+1]<<8)>>(e&7)&t},ci=function(n,e){var t=e/8|0;return(n[t]|n[t+1]<<8|n[t+2]<<16)>>(e&7)},ws=function(n){return(n+7)/8|0},en=function(n,e,t){return(e==null||e<0)&&(e=0),(t==null||t>n.length)&&(t=n.length),new ae(n.subarray(e,t))},Ku=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],Ee=function(n,e,t){var r=new Error(e||Ku[n]);if(r.code=n,Error.captureStackTrace&&Error.captureStackTrace(r,Ee),!t)throw r;return r},Os=function(n,e,t,r){var i=n.length,o=r?r.length:0;if(!i||e.f&&!e.l)return t||new ae(0);var s=!t,c=s||e.i!=2,a=e.i;s&&(t=new ae(i*3));var u=function(Ue){var We=t.length;if(Ue>We){var br=new ae(Math.max(We*2,Ue));br.set(t),t=br}},d=e.f||0,l=e.p||0,h=e.b||0,m=e.l,f=e.d,p=e.m,g=e.n,y=i*8;do{if(!m){d=Te(n,l,1);var _=Te(n,l+1,3);if(l+=3,_)if(_==1)m=Mu,f=Du,p=9,g=5;else if(_==2){var O=Te(n,l,31)+257,z=Te(n,l+10,15)+4,x=O+Te(n,l+5,31)+1;l+=14;for(var I=new ae(x),R=new ae(19),b=0;b>4;if(E<16)I[b++]=E;else{var G=0,ue=0;for(E==16?(ue=3+Te(n,l,3),l+=2,G=I[b-1]):E==17?(ue=3+Te(n,l,7),l+=3):E==18&&(ue=11+Te(n,l,127),l+=7);ue--;)I[b++]=G}}var Ae=I.subarray(0,O),M=I.subarray(O);p=ai(Ae),g=ai(M),m=mr(Ae,p,1),f=mr(M,g,1)}else Ee(1);else{var E=ws(l)+4,w=n[E-4]|n[E-3]<<8,S=E+w;if(S>i){a&&Ee(0);break}c&&u(h+w),t.set(n.subarray(E,S),h),e.b=h+=w,e.p=l=S*8,e.f=d;continue}if(l>y){a&&Ee(0);break}}c&&u(h+131072);for(var de=(1<>4;if(l+=G&15,l>y){a&&Ee(0);break}if(G||Ee(2),ve<256)t[h++]=ve;else if(ve==256){nt=l,m=null;break}else{var Kt=ve-254;if(ve>264){var b=ve-257,Be=ms[b];Kt=Te(n,l,(1<>4;yt||Ee(3),l+=yt&15;var M=Cu[ze];if(ze>3){var Be=ys[ze];M+=ci(n,l)&(1<y){a&&Ee(0);break}c&&u(h+131072);var $e=h+Kt;if(h>3&1)+(e>>4&1);r>0;r-=!n[t++]);return t+(e&2)},vt=(function(){function n(e,t){typeof e=="function"&&(t=e,e={}),this.ondata=t;var r=e&&e.dictionary&&e.dictionary.subarray(-32768);this.s={i:0,b:r?r.length:0},this.o=new ae(32768),this.p=new ae(0),r&&this.o.set(r)}return n.prototype.e=function(e){if(this.ondata||Ee(5),this.d&&Ee(4),!this.p.length)this.p=e;else if(e.length){var t=new ae(this.p.length+e.length);t.set(this.p),t.set(e,this.p.length),this.p=t}},n.prototype.c=function(e){this.s.i=+(this.d=e||!1);var t=this.s.b,r=Os(this.p,this.s,this.o);this.ondata(en(r,t,this.s.b),this.d),this.o=en(r,this.s.b-32768),this.s.b=this.o.length,this.p=en(this.p,this.s.p/8|0),this.s.p&=7},n.prototype.push=function(e,t){this.e(e),this.c(t)},n})();zs=(function(){function n(e,t){this.v=1,this.r=0,vt.call(this,e,t)}return n.prototype.push=function(e,t){if(vt.prototype.e.call(this,e),this.r+=e.length,this.v){var r=this.p.subarray(this.v-1),i=r.length>3?$u(r):4;if(i>r.length){if(!t)return}else this.v>1&&this.onmember&&this.onmember(this.r-r.length);this.p=r.subarray(i),this.v=0}vt.prototype.c.call(this,0),this.s.f&&!this.s.l?(this.v=ws(this.s.p)+9,this.s={i:0},this.o=new ae(0),this.push(new ae(0),t)):t&&vt.prototype.c.call(this,t)},n})(),Uu=typeof TextDecoder<"u"&&new TextDecoder,Wu=0;try{Uu.decode(Bu,{stream:!0}),Wu=1}catch{}});var pi={};Yi(pi,{extractZipEntry:()=>ju,extractZipEntryBounded:()=>Ju,fetchZipCentralDirectory:()=>ed,parseZipCentralDirectory:()=>_r});function Ls(n){let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=Math.max(0,n.length-Ts);for(let r=n.length-Vu;r>=t;r--)if(e.getUint32(r,!0)===Gu)return r;throw new Error("Zip EOCD record not found")}function _r(n){let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=Ls(n),r=e.getUint16(t+10,!0),i=e.getUint32(t+16,!0),o=[],s=i;for(let c=0;c>8,w;E===xs?w=p>>16&65535:_.startsWith("bin/")||_.startsWith("sbin/")||_.includes("/bin/")||_.includes("/sbin/")?w=493:w=420;let S=_.endsWith("/"),O=E===xs&&(w&Zu)===qu;o.push({fileName:_,fileNameBytes:y,compressedSize:d,uncompressedSize:l,compressionMethod:u,localHeaderOffset:g,mode:w,isDirectory:S,isSymlink:O,externalAttrs:p,creatorOS:E}),s+=li+h+m+f}return o}function vs(n,e){if(n.byteLength!==e.byteLength)return!1;for(let t=0;t{if(c.byteLength>t-o)throw new Error(`ZIP member ${e.fileName} expands beyond ${t} bytes`);i.set(c,o),o+=c.byteLength}).push(r,!0),o!==t)throw new Error(`ZIP member ${e.fileName} expanded ${o} bytes, expected ${t}`);return i}function Qu(n,e){let t=new DataView(n.buffer,n.byteOffset,n.byteLength),r=e.localHeaderOffset;if(r<0||r>n.byteLength-fi||t.getUint32(r,!0)!==Is)throw new Error(`Invalid local file header signature at offset ${r}`);let i=t.getUint16(r+8,!0),o=t.getUint16(r+26,!0),s=t.getUint16(r+28,!0),c=r+fi,a=c+o+s,u=a+e.compressedSize;if(i!==e.compressionMethod||an.byteLength||!vs(n.subarray(c,c+o),e.fileNameBytes))throw new Error(`ZIP member ${e.fileName} has inconsistent local metadata`);return n.subarray(a,u)}async function ed(n){let e=await fetch(n,{method:"HEAD"});if(!e.ok)throw new Error(`HEAD request failed: ${e.status} ${e.statusText}`);let t=parseInt(e.headers.get("content-length")||"0",10),r=e.headers.get("accept-ranges");if(!t||r!=="bytes"){let y=await fetch(n);if(!y.ok)throw new Error(`Fetch failed: ${y.status} ${y.statusText}`);let _=new Uint8Array(await y.arrayBuffer());return{entries:_r(_),totalSize:_.length}}let i=Math.min(t,Ts),o=t-i,s=await fetch(n,{headers:{Range:`bytes=${o}-${t-1}`}});if(s.status!==206){let y=await fetch(n);if(!y.ok)throw new Error(`Fetch failed: ${y.status} ${y.statusText}`);let _=new Uint8Array(await y.arrayBuffer());return{entries:_r(_),totalSize:_.length}}let c=new Uint8Array(await s.arrayBuffer()),a=new DataView(c.buffer,c.byteOffset,c.byteLength),u=Ls(c),d=a.getUint32(u+12,!0),l=a.getUint32(u+16,!0);if(l>=o){let y=t,_=new Uint8Array(y);return _.set(c,o),{entries:_r(_),totalSize:y}}let h=l+d-1,m=await fetch(n,{headers:{Range:`bytes=${l}-${h}`}});if(m.status!==206)throw new Error(`Range request for CD failed: ${m.status}`);let f=new Uint8Array(await m.arrayBuffer()),p=t,g=new Uint8Array(p);return g.set(f,l),g.set(c,o),{entries:_r(g),totalSize:p}}var Gu,Hu,Is,Ts,Vu,li,fi,Rs,bs,xs,qu,Zu,Yu,Xu,hi=Lr(()=>{"use strict";di();Ve();Gu=101010256,Hu=33639248,Is=67324752,Ts=65557,Vu=22,li=46,fi=30,Rs=0,bs=8,xs=3,{S_IFLNK:qu,S_IFMT:Zu}=ie,Yu=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),Xu=new TextEncoder});var Ds={};Yi(Ds,{DEFAULT_TAR_GZIP_LIMITS:()=>Ms,TarParseError:()=>v,parseTarGzip:()=>id});function id(n,e={}){let t=e.label??"TAR gzip archive",r=sd(e.limits,t);if(n.byteLength===0||n.byteLength>r.maxCompressedBytes)throw new v(`${t}: compressed byte count ${n.byteLength} is outside 1..${r.maxCompressedBytes}`);let i=ad(n,t);if(i===0||i>r.maxUncompressedBytes)throw new v(`${t}: declared uncompressed byte count ${i} is outside 1..${r.maxUncompressedBytes}`);let o=cd(n,t,i);if(o.byteLength!==i)throw new v(`${t}: gzip expanded to ${o.byteLength} bytes, expected ${i}`);let s=new DataView(n.buffer,n.byteOffset,n.byteLength).getUint32(n.byteLength-8,!0);if(ud(o)!==s)throw new v(`${t}: gzip CRC32 mismatch`);return od(o,t,r)}function od(n,e,t){if(n.byteLength%Ce!==0)throw new v(`${e}: TAR byte count is not block-aligned`);let r=[],i=0,o=0,s=0,c=null,a={},u=!1;for(;i+Ce<=n.byteLength;){let d=n.subarray(i,i+Ce);if(i+=Ce,yi(d)){if(i+Ce>n.byteLength)throw new v(`${e}: TAR end marker is truncated`);let S=n.subarray(i,i+Ce);if(!yi(S))throw new v(`${e}: TAR has only one zero end block`);if(i+=Ce,!yi(n.subarray(i)))throw new v(`${e}: TAR has nonzero data after its end marker`);u=!0;break}pd(d,e);let l=gr(d,156,1,e)||"0",h=gi(d,124,12,`${e}: TAR entry size`),m=gi(d,100,8,`${e}: TAR entry mode`)&td,f=hd(d,e,t.maxPathBytes),p=gr(d,157,100,e);if(l==="x"||l==="g"){if(s+=1,s>t.maxEntries+1)throw new v(`${e}: TAR extension header count exceeds ${t.maxEntries+1}`);let S=ks(n,i,h,e);i=Fs(i,h,n.byteLength,e);let O=ld(S,e,t);l==="x"?c=O:a={...a,...O};continue}if(o+=1,o>t.maxEntries)throw new v(`${e}: TAR entry count exceeds ${t.maxEntries}`);let g={...a,...c??{}};c=null;let y=g.size===void 0?h:fd(g.size,`${e}: PAX entry size`),_=ks(n,i,y,e);i=Fs(i,y,n.byteLength,e);let E=_i(g.path??f,e,t.maxPathBytes),w=g.linkpath??p;switch(l){case"0":case"\0":r.push({path:E,type:"file",mode:m,data:_});break;case"5":mi(y,e,"directory",E),r.push({path:E,type:"directory",mode:m});break;case"2":mi(y,e,"symlink",E),Ns(w,e,E,t.maxLinkBytes,!1),r.push({path:E,type:"symlink",mode:m,linkName:w});break;case"1":mi(y,e,"hardlink",E),Ns(w,e,E,t.maxLinkBytes,!0),r.push({path:E,type:"hardlink",mode:m,linkName:_i(w,`${e}: hardlink target`,t.maxPathBytes)});break;case"3":case"4":case"6":throw new v(`${e}: unsupported TAR device/FIFO entry ${E}`);default:throw new v(`${e}: unsupported TAR entry type ${JSON.stringify(l)} for ${E}`)}}if(!u)throw new v(`${e}: TAR is missing its two-block end marker`);if(c!==null)throw new v(`${e}: local PAX header has no following entry`);return r}function sd(n,e){let t={...Ms,...n};for(let[r,i]of Object.entries(t))if(!Number.isSafeInteger(i)||i<=0)throw new v(`${e}: ${r} must be a positive safe integer`);return t}function ad(n,e){if(n.byteLength<18||n[0]!==31||n[1]!==139||n[2]!==8)throw new v(`${e}: invalid gzip header`);return new DataView(n.buffer,n.byteOffset,n.byteLength).getUint32(n.byteLength-4,!0)}function cd(n,e,t){let r=new Uint8Array(t),i=0,o=!1,s=new zs(c=>{if(c.byteLength>t-i)throw new v(`${e}: gzip expansion exceeds its declared ${t} bytes`);r.set(c,i),i+=c.byteLength});s.onmember=()=>{throw o=!0,new v(`${e}: concatenated gzip members are unsupported`)};try{s.push(n,!0)}catch(c){throw c instanceof v?c:new v(`${e}: cannot gunzip archive: ${yd(c)}`)}if(o)throw new v(`${e}: concatenated gzip members are unsupported`);return r.subarray(0,i)}function ud(n){let e=4294967295;for(let t of n)e=nd[(e^t)&255]^e>>>8;return(e^4294967295)>>>0}function dd(){let n=new Uint32Array(256);for(let e=0;e>>1^((t&1)===0?0:3988292384);n[e]=t>>>0}return n}function ks(n,e,t,r){if(t>n.byteLength-e)throw new v(`${r}: TAR entry is truncated`);return n.subarray(e,e+t)}function Fs(n,e,t,r){let o=Math.ceil(e/Ce)*Ce;if(!Number.isSafeInteger(o)||o>t-n)throw new v(`${r}: TAR entry padding is truncated`);return n+o}function ld(n,e,t){let r={},i=0;for(;i9)throw new v(`${e}: invalid PAX record length`);if(s=s*10+p,!Number.isSafeInteger(s))throw new v(`${e}: invalid PAX record length`)}let c=i+s;if(s<=o-i+2||c>n.byteLength||n[c-1]!==10)throw new v(`${e}: truncated PAX record`);let a=o+1;for(;a=c-1)throw new v(`${e}: invalid PAX record`);let u=n.subarray(o+1,a);if(u.byteLength>256)throw new v(`${e}: PAX record key is too long`);let d=Ei(u,`${e}: PAX record key`),l=n.subarray(a+1,c-1),h=d==="path"?t.maxPathBytes:d==="linkpath"?t.maxLinkBytes:d==="size"?32:0;if(h===0){i=c;continue}if(l.byteLength>h)throw new v(`${e}: PAX ${d} value is too long`);let m=Ei(l,`${e}: PAX record value`);r[d]=m,i=c}return r}function fd(n,e){if(!/^(0|[1-9][0-9]*)$/.test(n))throw new v(`${e} is invalid`);let t=Number(n);if(!Number.isSafeInteger(t)||t<0)throw new v(`${e} is invalid`);return t}function pd(n,e){let t=gi(n,148,8,`${e}: TAR checksum`),r=0;for(let i=0;i=148&&i<156?32:n[i];if(t!==r)throw new v(`${e}: TAR checksum mismatch`)}function hd(n,e,t){let r=gr(n,0,100,e),i=gr(n,345,155,e);return _i(i?`${i}/${r}`:r,e,t)}function _i(n,e,t){let r=n;for(;r.startsWith("./");)r=r.slice(2);return r=r.replace(/\/+$/g,""),md(r,`${e}: TAR path`,t),r}function gr(n,e,t,r){let i=e,o=e+t;for(;ir||n.includes("\0"))throw new v(`${e}: link target for ${t} is invalid`);if(i&&n.includes("\\"))throw new v(`${e}: hardlink target for ${t} is invalid`)}function md(n,e,t){if(n.length===0||n.startsWith("/")||n.includes("\0")||n.includes("\\")||Cs.encode(n).byteLength>t)throw new v(`${e} ${JSON.stringify(n)} must be a bounded relative POSIX path`);for(let r of n.split("/"))if(r.length===0||r==="."||r==="..")throw new v(`${e} ${JSON.stringify(n)} contains an unsafe path segment`)}function yi(n){for(let e of n)if(e!==0)return!1;return!0}function Ei(n,e){try{return rd.decode(n)}catch{throw new v(`${e} contains non-UTF-8 text`)}}function yd(n){return n instanceof Error?n.message:String(n)}var Ce,td,Ps,rd,Cs,nd,Ms,v,Ks=Lr(()=>{"use strict";di();Ve();Ce=512,td=ie.S_MODE_BITS,Ps=1024*1024,rd=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),Cs=new TextEncoder,nd=dd(),Ms=Object.freeze({maxCompressedBytes:256*Ps,maxUncompressedBytes:512*Ps,maxEntries:1e5,maxPathBytes:4096,maxLinkBytes:65536}),v=class extends Error{constructor(e){super(e),this.name="TarParseError"}}});import{existsSync as xr,lstatSync as gn,readdirSync as xa,readFileSync as pt,realpathSync as be,statSync as rt}from"node:fs";import{createHash as Ia}from"node:crypto";import{spawnSync as Bi}from"node:child_process";import{basename as Al,dirname as Tr,isAbsolute as En,join as U,relative as zl,resolve as Re,sep as xl}from"node:path";import{fileURLToPath as Il}from"node:url";Ve();var Ka=Uint8Array.from(lo);function T(n,e){let t=0,r=0,i=e;for(;;){let o=n[i++];if(t|=(o&127)<=n.length)throw new Error(`${r} is truncated`);if((n[e++]&128)===0)return e}throw new Error(`${r} has an overlong LEB128 encoding`)}function Ie(n,e,t){if(e>=n.length)throw new Error(`${t} is truncated`);let r=n[e++];switch(r){case 127:case 126:case 125:case 124:case 123:case 117:case 116:case 115:case 114:case 113:case 112:case 111:case 110:case 109:case 108:case 107:case 106:case 105:case 104:return{code:r,shared:!1,next:e};case 98:case 99:case 100:{let i=n[e]===101;i&&e++;let o=ko(n,e,5,`${t} heap type`),[s]=Po(n,e);return{code:r,heapType:Number(s),shared:i,next:o}}default:throw new Error(`${t} has unknown value type 0x${r.toString(16)}`)}}function Ba(n,e,t){return n[e]===120||n[e]===119?{code:n[e],shared:!1,next:e+1}:Ie(n,e,t)}function $a(n,e){let t=n[e];if(t===64||t===127||t===126||t===125||t===124||t===123||t===112||t===111)return e+1;let[,r]=Cn(n,e);return e+r}function Ua(n,e,t){let[r,i]=T(n,e);e+=i;let o=[],s=[];for(let l=0;l=n.length)throw new Error(`${t} mutability is truncated`);let i=n[e++];if(i!==0&&i!==1)throw new Error(`${t} has invalid mutability ${i}`);return e}function Wa(n,e,t,r){if(e===101){if(t>=n.length)throw new Error(`${r} shared type is truncated`);e=n[t++]}for(let i of[76,77]){if(e!==i)continue;let[,o]=T(n,t);if(t+=o,t>=n.length)throw new Error(`${r} descriptor is truncated`);e=n[t++]}if(e===96)return Ua(n,t,r);if(e===95){let[i,o]=T(n,t);t+=o;for(let s=0;s=n.length)throw new Error(`${t} is truncated`);let r=n[e++];if(r===79||r===80){let[i,o]=T(n,e);e+=o;for(let s=0;s=n.length)throw new Error(`${t} body is truncated`);r=n[e++]}return Wa(n,r,e,t)}function Ga(n,e){let[t,r]=T(n,e);e+=r;let i=[];for(let o=0;o=21&&r<=34?Xt(e,t):r===84||r>=92&&r<=99||r>=112&&r<=123||r>=124&&r<=131||r>=156&&r<=159?t+1:t:n===254?r===0||r===1||r===2?Xt(e,t):r===3?t:r>=16&&r<=79?Xt(e,t):null:null}function Va(n,e,t){let[r,i]=T(n,e);e+=i+r;let[o,s]=T(n,e);e+=s+o;let c=n[e++];if(c===0){t.funcImports++;let[,a]=T(n,e);e+=a}else if(c===1)e=Ie(n,e,"table import type").next,e=Ze(n,e).next;else if(c===2)e=Ze(n,e).next;else if(c===3)t.globalImports++,e=Ie(n,e,"global import type").next,e++;else if(c===4){e++;let[,a]=T(n,e);e+=a}return e}function $r(n){return n.length>=8&&n[0]===0&&n[1]===97&&n[2]===115&&n[3]===109}function qe(n,e){let[t,r]=T(n,e);return e+=r,[new TextDecoder().decode(n.subarray(e,e+t)),e+t]}function qa(n,e){if(e.length===0)return!0;let t=new TextEncoder().encode(e);e:for(let r=0;r<=n.length-t.length;r++){for(let i=0;in);function bo(n,e){switch(n.code){case 127:return On;case 126:return An;case 125:return zn;case 124:return xn;case 123:return In;case 112:case 115:return gt;case 111:case 114:return Ut;case 105:case 116:return Wt;case 104:case 106:case 107:case 108:case 109:case 110:case 113:case 117:return Gt;case 98:case 99:case 100:{let t=n.heapType;return t===void 0?null:t===-16||t===-13?gt:t===-17||t===-14?Ut:t===-23||t===-12?Wt:t>=0&&e[t]!==void 0?gt:Gt}default:return null}}function kn(n,e,t){if(!t)throw new Error(`function ${e} refers to an unknown type`);let r=n.get(e)??[];r.push(t),n.set(e,r)}function Yt(n,e,t){let r=n.get(e)??[];r.push(t),n.set(e,r)}function Ze(n,e){let[t,r]=T(n,e);e+=r;let[i,o]=T(n,e);e+=o;let s=null;if((t&1)!==0){let[c,a]=T(n,e);e+=a,s=c}return{flags:t,minimum:i,maximum:s,next:e}}function Ya(n){let e=new Uint8Array(n);if(!$r(e))throw new Error("not a wasm binary");let t=[],r=[],i=[],o={functionTypes:t,functionTypeIndices:r,functionImports:new Map,functionImportEntries:[],globalImports:new Map,tableImports:new Map,tables:[],tagImports:new Map,functionExports:new Map,globalExports:new Map,tableExports:new Map,exports:new Map,memoryPointerWidths:[],forkCapabilities:[],linkedFrameDescriptors:[],exceptionCodecDescriptors:[],importedGlobalsDescriptors:[],importedTablesDescriptors:[],moduleStateDescriptors:[],staticRootDescriptors:[],unwindTransportDescriptors:[],nativeStartCount:0,importsKernelFork:!1},s=0,c=0,a=8;for(;ae.length)throw new Error("wasm section exceeds file size");let f=h,p=!1;if(u===0){let[g,y]=qe(e,f);g===Bt?o.linkedFrameDescriptors.push(e.slice(y,m)):g===Pe?o.forkCapabilities.push(e.slice(y,m)):g===xe?o.exceptionCodecDescriptors.push(e.slice(y,m)):g===Y?o.importedGlobalsDescriptors.push(e.slice(y,m)):g===X?o.importedTablesDescriptors.push(e.slice(y,m)):g===ne?o.moduleStateDescriptors.push(e.slice(y,m)):g===Ge?o.staticRootDescriptors.push(e.slice(y,m)):g===Et&&o.unwindTransportDescriptors.push(e.slice(y,m))}else if(u===1){p=!0;let g=Ga(e,f);t.push(...g.types),f=g.next}else if(u===2){p=!0;let[g,y]=T(e,f);f+=y;for(let _=0;_=e.length)throw new Error(`global import ${E}.${S} is truncated`);let I=e[f++];if((I&-4)!==0)throw new Error(`global import ${E}.${S} has invalid flags ${I}`);Yt(o.globalImports,`${E}.${S}`,{module:E,name:S,importOrdinal:_,index:s++,valueType:x.code,recipeTypeCode:bo(x,t),mutable:(I&1)!==0,shared:(I&2)!==0})}else if(z===4){let x=e[f++];if(x!==0)throw new Error(`unsupported wasm tag attribute ${x}`);let[I,R]=T(e,f);f+=R,kn(o.tagImports,`${E}.${S}`,t[I])}else throw new Error(`unsupported wasm import kind ${z}`)}}else if(u===3){p=!0;let[g,y]=T(e,f);f+=y;for(let _=0;_n[a]===c))throw new Error("linked-frame descriptor has invalid magic");let e=new DataView(n.buffer,n.byteOffset,n.byteLength),t=e.getUint16(4,!0);if(t!==1)throw new Error(`linked-frame descriptor version ${t} is unsupported`);let r=e.getUint16(6,!0);if(r!==$t)throw new Error(`linked-frame descriptor declares size ${r}, expected ${$t}`);let i=e.getUint8(8),o=Ji.find(({bytes:c})=>c===i);if(!o)throw new Error(`linked-frame descriptor pointer width ${i} is unsupported`);if(e.getUint8(9)!==ji)throw new Error(`linked-frame descriptor alignment ${e.getUint8(9)} is unsupported`);let s=e.getUint16(10,!0);if(s!==Sn)throw new Error(`linked-frame descriptor flags 0x${s.toString(16)} do not equal required flags 0x${Sn.toString(16)}`);if(e.getUint32(12,!0)!==o.chunkHeaderSize||e.getUint32(16,!0)!==o.nodeHeaderSize)throw new Error(`linked-frame descriptor header sizes do not match its ${i}-byte pointer width`);return o.bytes}function ja(n){if(n.length===0)return[`missing required ${Pe} capability`];if(n.length!==1)return[`has ${n.length} ${Pe} sections, expected exactly one`];let e=n[0];if(e.byteLength!==2)return[`${Pe} has ${e.byteLength} bytes, expected 2`];if(e[0]!==so)return[`${Pe} version ${e[0]} is unsupported`];let t=e[1];return(t&~ao)!==0?[`${Pe} has unknown flags 0x${t.toString(16)}`]:(t&Fr)!==Fr?[`${Pe} flags 0x${t.toString(16)} omit required activation-state safety flags 0x${Fr.toString(16)}`]:[]}function Ja(n){let e=[],t=`${Cr}.${Mr}`,r=n.tagImports.get(t);if(r?r.length!==1?e.push(`duplicate private fork-unwind tag import ${t}`):(r[0].params.length!==0||r[0].results.length!==0)&&e.push(`private fork-unwind tag ${t} must have an empty payload`):e.push(`missing required private fork-unwind tag import ${t}`),n.unwindTransportDescriptors.length===0)e.push(`missing required ${Et} descriptor`);else if(n.unwindTransportDescriptors.length!==1)e.push(`has ${n.unwindTransportDescriptors.length} ${Et} descriptors, expected exactly one`);else{let i=n.unwindTransportDescriptors[0];(i.length!==2||i[0]!==Rn||i[1]!==bn)&&e.push(`${Et} must be [${Rn}, ${bn}]`)}return e}function Qa(n,e){if(n.length===0)return[`missing required ${ne} descriptor`];if(n.length!==1)return[`has ${n.length} ${ne} descriptors, expected exactly one`];let t=n[0];if(t.byteLength!==vr)return[`${ne} has ${t.byteLength} bytes, expected ${vr}`];if(!eo.every((p,g)=>t[g]===p))return[`${ne} has invalid magic`];let r=new DataView(t.buffer,t.byteOffset,t.byteLength),i=r.getUint16(4,!0),o=r.getUint16(6,!0),s=r.getUint8(8),c=oo.find(({bytes:p})=>p===s),a=r.getUint8(9),u=r.getUint16(10,!0),d=r.getUint16(12,!0),l=r.getUint16(14,!0),h=r.getUint32(16,!0),m=r.getUint32(20,!0),f=[];return i!==Qi&&f.push(`${ne} version ${i} is unsupported`),o!==vr&&f.push(`${ne} declares size ${o}`),c?e!==null&&s!==e&&f.push(`${ne} pointer width ${s} does not match linked frames ${e}`):f.push(`${ne} pointer width ${s} is unsupported`),a!==to&&f.push(`${ne} alignment ${a} is unsupported`),u!==wn&&f.push(`${ne} flags 0x${u.toString(16)} do not equal required flags 0x${wn.toString(16)}`),d!==ro&&f.push(`${ne} arena version ${d} is unsupported`),l!==no&&f.push(`${ne} record version ${l} is unsupported`),h!==io&&f.push(`${ne} root word ${h} is unsupported`),m!==0&&f.push(`${ne} reserved field is nonzero`),f}function ec(n){if(n.length===0)return[`missing required ${xe} descriptor`];if(n.length!==1)return[`has ${n.length} ${xe} descriptors, expected exactly one`];let e=n[0];if(e.byteLength2147483647||s.has(d))&&r.push(`${xe} layout id ${d} is invalid or duplicated`),s.add(d)}return r}var tc=new Set([On,An,zn,xn,In,gt,Ut,Wt,Gt]);function Lo(n){return!(n.module===Ln&&(n.name===vn||n.name==="__channel_base"||n.name==="__wpk_fork_module_state_table_generation_addr"))}function rc(n){let e=n.importedGlobalsDescriptors;if(e.length===0)return[`missing required ${Y} descriptor`];if(e.length!==1)return[`has ${e.length} ${Y} descriptors, expected exactly one`];let t=e[0];if(t.byteLengtht[g]===p)||i.push(`${Y} has invalid magic`),r.getUint16(4,!0)!==po&&i.push(`${Y} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Kr&&i.push(`${Y} declares an invalid header size`);let o=r.getUint32(8,!0);r.getUint32(12,!0)!==0&&i.push(`${Y} reserved field is nonzero`);let s=new Set,c=new Set,a=new TextDecoder("utf-8",{fatal:!0}),u=[],d=-1,l=Kr;for(let p=0;pt.byteLength)return i.push(`${Y} record ${p} header is truncated`),i;let g=r.getUint32(l,!0),y=r.getUint32(l+4,!0),_=r.getUint8(l+8),E=r.getUint8(l+9),w=r.getUint32(l+12,!0),S=r.getUint32(l+16,!0),O=r.getUint32(l+20,!0),z=Vt+w+S;if(!Number.isSafeInteger(z)||g!==z||gt.byteLength)return i.push(`${Y} record ${p} has invalid bounds`),i;(y===0||s.has(y))&&i.push(`${Y} record ${p} has invalid or duplicated owner ${y}`),s.add(y),tc.has(_)||i.push(`${Y} record ${p} has unknown value type ${_}`),(E&~yo)!==0&&i.push(`${Y} record ${p} has unknown flags 0x${E.toString(16)}`),r.getUint16(l+10,!0)!==0&&i.push(`${Y} record ${p} reserved fields are nonzero`),(c.has(O)||O<=d)&&i.push(`${Y} record ${p} has duplicated or unordered import ordinal`),c.add(O),d=O;let x=l+Vt;try{let I=a.decode(t.subarray(x,x+w)),R=a.decode(t.subarray(x+w,x+w+S));u.push({ownerId:y,typeCode:_,flags:E,importOrdinal:O,module:I,name:R})}catch{i.push(`${Y} record ${p} contains invalid UTF-8`)}l+=g}l!==t.byteLength&&i.push(`${Y} has trailing bytes`);let h=[...n.globalImports.values()].flat(),m=new Map(h.map(p=>[p.index,p])),f=new Set;for(let p of u){let g=`${Pr}${p.ownerId}`,y=n.exports.get(g);if(!y||y.length!==1||y[0].kind!==3){i.push(`${Y} owner ${p.ownerId} lacks exactly one global catalog export ${g}`);continue}let _=m.get(y[0].index);if(!_||!Lo(_)){i.push(`${Y} owner ${p.ownerId} does not identify a reconstructible imported global`);continue}if(_.module!==p.module||_.name!==p.name||_.importOrdinal!==p.importOrdinal||_.recipeTypeCode!==p.typeCode||_.mutable!==((p.flags&ho)!==0)||_.shared!==((p.flags&mo)!==0)){i.push(`${Y} owner ${p.ownerId} does not match its imported global declaration`);continue}if(f.has(_.index)){i.push(`${Y} repeats imported global index ${_.index}`);continue}f.add(_.index)}for(let p of h)Lo(p)&&!f.has(p.index)&&i.push(`${Y} omits imported global ${p.module}.${p.name} at index ${p.index}`);for(let[p,g]of n.exports){if(!p.startsWith(Pr))continue;let y=p.slice(Pr.length),_=Number(y);(!/^[1-9][0-9]*$/.test(y)||!Number.isSafeInteger(_)||_>4294967295||g.length!==1||g[0].kind!==3)&&i.push(`malformed reserved fork global catalog export ${p}`)}return i}var nc=new Set([gt,Ut,Wt,Gt]);function vo(n){return!Pn.some(({module:e,name:t})=>n.module===e&&n.name===t)}function ic(n){let e=n.importedTablesDescriptors;if(e.length===0)return[`missing required ${X} descriptor`];if(e.length!==1)return[`has ${e.length} ${X} descriptors, expected exactly one`];let t=e[0];if(t.byteLengtht[g]===p)||i.push(`${X} has invalid magic`),r.getUint16(4,!0)!==go&&i.push(`${X} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Br&&i.push(`${X} declares an invalid header size`);let o=r.getUint32(8,!0);r.getUint32(12,!0)!==0&&i.push(`${X} reserved field is nonzero`);let s=new Set,c=new Set,a=new TextDecoder("utf-8",{fatal:!0}),u=[],d=-1,l=Br;for(let p=0;pt.byteLength)return i.push(`${X} record ${p} header is truncated`),i;let g=r.getUint32(l,!0),y=r.getUint32(l+4,!0),_=r.getUint8(l+8),E=r.getUint8(l+9),w=r.getUint32(l+12,!0),S=r.getUint32(l+16,!0),O=r.getUint32(l+20,!0),z=qt+w+S;if(!Number.isSafeInteger(z)||g!==z||gt.byteLength)return i.push(`${X} record ${p} has invalid bounds`),i;(y===0||s.has(y))&&i.push(`${X} record ${p} has invalid or duplicated owner ${y}`),s.add(y),nc.has(_)||i.push(`${X} record ${p} has unknown element type ${_}`),(E&~So)!==0&&i.push(`${X} record ${p} has unknown flags 0x${E.toString(16)}`),r.getUint16(l+10,!0)!==0&&i.push(`${X} record ${p} reserved fields are nonzero`),(c.has(O)||O<=d)&&i.push(`${X} record ${p} has duplicated or unordered import ordinal`),c.add(O),d=O;let x=l+qt;try{let I=a.decode(t.subarray(x,x+w)),R=a.decode(t.subarray(x+w,x+w+S));u.push({ownerId:y,typeCode:_,flags:E,importOrdinal:O,module:I,name:R})}catch{i.push(`${X} record ${p} contains invalid UTF-8`)}l+=g}l!==t.byteLength&&i.push(`${X} has trailing bytes`);let h=[...n.tableImports.values()].flat(),m=new Map(h.map(p=>[p.index,p])),f=new Set;for(let p of u){let g=`${kr}${p.ownerId}`,y=n.exports.get(g);if(!y||y.length!==1||y[0].kind!==1){i.push(`${X} owner ${p.ownerId} lacks exactly one table catalog export ${g}`);continue}let _=m.get(y[0].index);if(!_||!vo(_)){i.push(`${X} owner ${p.ownerId} does not identify a reconstructible imported table`);continue}if(_.module!==p.module||_.name!==p.name||_.importOrdinal!==p.importOrdinal||_.recipeTypeCode!==p.typeCode||_.table64!==((p.flags&Eo)!==0)){i.push(`${X} owner ${p.ownerId} does not match its imported table declaration`);continue}if(f.has(_.index)){i.push(`${X} repeats imported table index ${_.index}`);continue}f.add(_.index)}for(let p of h)vo(p)&&!f.has(p.index)&&i.push(`${X} omits imported table ${p.module}.${p.name} at index ${p.index}`);for(let[p,g]of n.exports){if(!p.startsWith(kr))continue;let y=p.slice(kr.length),_=Number(y);(!/^[1-9][0-9]*$/.test(y)||!Number.isSafeInteger(_)||_>4294967295||g.length!==1||g[0].kind!==1)&&i.push(`malformed reserved fork table catalog export ${p}`)}return i}function Mn(n,e){switch(n){case"ptr":return e===8?126:127;case"i32":return 127;case"i64":return 126;case"anyref":return 110;case"exnref":return 105;case"externref":return 111;case"funcref":return 112}}function Fn(n,e,t,r){return n.params.length===e.length&&n.results.length===t.length&&n.params.every((i,o)=>i===Mn(e[o],r))&&n.results.every((i,o)=>i===Mn(t[o],r))}function Nn(n,e,t){let r=i=>i==="ptr"?t===8?"i64":"i32":i;return`(${n.map(r).join(", ")}) -> (${e.map(r).join(", ")})`}function oc(n){let e=`${Ln}.${vn}`,t=n.globalImports.get(e);return t?t.length!==1?[`duplicate exception-codec activation import ${e}`]:t[0].valueType!==127||t[0].mutable?[`exception-codec activation import ${e} must be immutable i32`]:[]:[`missing required immutable exception-codec activation import ${e}`]}function sc(n){let e=[];for(let t of Pn){let r=`${t.module}.${t.name}`,i=n.tableImports.get(r);if(!i){e.push(`missing required ABI 43 fork-runtime table import ${r}`);continue}if(i.length!==1){e.push(`duplicate ABI 43 fork-runtime table import ${r}`);continue}let o=i[0],s=Mn(t.element,4);(o.elementType!==s||o.table64!==t.table64||o.minimum!==t.minimum||o.maximum!==t.maximum)&&e.push(`ABI 43 fork-runtime table import ${r} has the wrong type or limits`)}return e}function ac(n){if(n.staticRootDescriptors.length===0)return[`missing required ${Ge} descriptor`];if(n.staticRootDescriptors.length!==1)return[`has ${n.staticRootDescriptors.length} ${Ge} descriptors, expected exactly one`];let e=n.staticRootDescriptors[0];if(e.byteLength!==Dr)return[`${Ge} has ${e.byteLength} bytes, expected ${Dr}`];let t=[];Ka.some((u,d)=>e[d]!==u)&&t.push(`${Ge} has invalid magic`);let r=new DataView(e.buffer,e.byteOffset,e.byteLength);r.getUint16(4,!0)!==uo&&t.push(`${Ge} version ${r.getUint16(4,!0)} is unsupported`),r.getUint16(6,!0)!==Dr&&t.push(`${Ge} declares an invalid header size`);let i=r.getUint32(8,!0),o=n.tableExports.get(Ht);if(!o||o.length!==1)return t.push(`missing exactly one table export ${Ht}`),t;let s=[...n.tableImports.values()].reduce((u,d)=>u+d.length,0),c=o[0],a=n.tables[c];return c!n.functionExports.has(a)).map(({name:a})=>a);if(t.length>0&&e.push(`incomplete wasm-fork-instrument exports; missing ${t.join(", ")}`),n.importsKernelFork){let a=`${ot.module}.${ot.name}`,u=n.functionImports.get(a);u?.length!==1?e.push(`duplicate ABI 43 process-fork import ${a}`):Fn(u[0],ot.params,ot.results,4)||e.push(`ABI 43 process-fork import ${a} has the wrong signature; expected ${Nn(ot.params,ot.results,4)}`)}let r=null;if(n.linkedFrameDescriptors.length===0)e.push(`missing required ${Bt} descriptor`);else if(n.linkedFrameDescriptors.length!==1)e.push(`has ${n.linkedFrameDescriptors.length} ${Bt} descriptors, expected exactly one`);else try{r=Xa(n.linkedFrameDescriptors[0])}catch(a){e.push(a instanceof Error?a.message:String(a))}e.push(...Qa(n.moduleStateDescriptors,r));let i=St.filter(({module:a,name:u})=>n.functionImports.has(`${a}.${u}`)),o=`${Cr}.${Mr}`,s=n.importsKernelFork||i.length>0;if((s||n.tagImports.has(o)||n.unwindTransportDescriptors.length>0)&&e.push(...Ja(n)),s){let a=St.filter(({module:u,name:d})=>!n.functionImports.has(`${u}.${d}`)).map(({module:u,name:d})=>`${u}.${d}`);a.length>0&&e.push(`incomplete ABI 43 fork-runtime imports; missing ${a.join(", ")}`);for(let u of St){let d=`${u.module}.${u.name}`,l=n.functionImports.get(d);l&&l.length!==1&&e.push(`duplicate ABI 43 fork-runtime import ${d}`)}}if(r!==null){if(n.memoryPointerWidths.length!==1)e.push(`ABI 43 fork instrumentation requires exactly one module memory, found ${n.memoryPointerWidths.length}`);else if(n.memoryPointerWidths[0]!==r){let a=r===8?"an":"a";e.push(`ABI 43 linked-frame descriptor declares ${a} ${r}-byte pointer but the module memory uses ${n.memoryPointerWidths[0]}-byte addresses`)}for(let a of Zt){let u=n.functionExports.get(a.name);u?.length===1&&!Fn(u[0],a.params,a.results,r)&&e.push(`ABI 43 wasm-fork-instrument export ${a.name} has the wrong signature; expected ${Nn(a.params,a.results,r)}`)}if(s)for(let a of St){let u=`${a.module}.${a.name}`,d=n.functionImports.get(u);d?.length===1&&!Fn(d[0],a.params,a.results,r)&&e.push(`ABI 43 fork-runtime import ${u} has the wrong signature; expected ${Nn(a.params,a.results,r)}`)}}return e}function Fo(n,e){switch(n){case 0:return"function";case 1:return"table";case 2:return"memory";case 3:return"global";case 4:return"tag";default:throw new Error(`${e} has unsupported external kind ${n}`)}}function uc(n){let e=new Uint8Array(n);if(!$r(e))return[];let t=[],r=8;for(;r`${e}.${t}`)}function lc(n){let e=new Uint8Array(n);if(!$r(e))return[];let t=[],r=8;for(;re)}function No(n){let e=new Uint8Array(n);if(!$r(e))return[];let t=[],r=8;for(;rt.startsWith("reloc."))}function Co(n,e={}){let t=[],r=null;pc(n)&&t.push("contains asyncify_"),e.expectedAbi!==void 0&&e.expectedAbi!==null&&(r=yc(n),r!==null&&r!==e.expectedAbi&&t.push(`ABI ${r}, expected ${e.expectedAbi}`));let i=new Set(fc(n));if(e.requiredExports){let E=e.requiredExports.filter(w=>!i.has(w));E.length>0&&t.push(`missing required exports: ${E.join(", ")}`)}if(e.forbiddenExports){let E=e.forbiddenExports.filter(w=>i.has(w));E.length>0&&t.push(`forbidden exports present: ${E.join(", ")}`)}let o=Za.filter(E=>i.has(E)),s=dc(n),c=No(n),a=St.filter(({module:E,name:w})=>s.includes(`${E}.${w}`)),u=c.filter(E=>E===Bt).length,d=c.filter(E=>E===Pe).length,l=c.filter(E=>E===ne).length,h=c.filter(E=>E===xe).length,m=c.filter(E=>E===Y).length,f=c.filter(E=>E===X).length,p=c.filter(E=>E===Et).length,g=s.includes(`${Cr}.${Mr}`),y=o.length>0||a.length>0||u>0||d>0||l>0||h>0||m>0||f>0||p>0||g;if(e.expectedAbi!==void 0&&e.expectedAbi!==null&&y&&r===null&&t.push(`ABI ${e.expectedAbi} fork artifact is missing __abi_version; the activation-state capability epoch cannot be verified`),e.forbidForkInstrumentation&&y&&t.push("contains ABI 43 wasm-fork-instrument metadata, imports, or exports"),(e.requireForkInstrumentation??!hc(n))&&(y||s.includes("kernel.kernel_fork")))try{t.push(...cc(Ya(n)))}catch(E){t.push(`cannot validate ABI 43 fork-artifact contract: ${E instanceof Error?E.message:String(E)}`)}return t}function mc(n,e){let t=new Uint8Array(n);if(t.length<8)return null;let r=0,i=null,o=null,s=8;for(;s=a)return null;let p=c;for(let _=0;_=f)return null;let[p,g]=T(t,m);m+=g;for(let y=0;yf)return null}return m}function h(m,f=0){if(f>4)return null;let p=d(m);if(!p)return null;let g=l(p.start,p.end);if(g===null)return null;let y=g,_=p.end;for(;y<_;){let E=t[y++];if(E===11){if(y===_)return null;continue}if(E===65){let[w]=Cn(t,y),[,S]=Cn(t,y),O=y+S;if(t[O]===15||t[O]===11&&O+1===_)return w;y=O}else if(E===16){let[w,S]=T(t,y),O=y+S;if(t[O]===15||t[O]===11&&O+1===_){let z=h(w,f+1);if(z!==null)return z}y=O}else if(E===12||E===13||E===18||E===210){let[,w]=T(t,y);y+=w}else if(E===2||E===3||E===4)y=$a(t,y);else if(E===14){let[w,S]=T(t,y);y+=S;for(let O=0;O<=w;O++){let[,z]=T(t,y);y+=z}}else if(E===17){let[,w]=T(t,y);y+=w;let[,S]=T(t,y);y+=S}else if(E===28){let[w,S]=T(t,y);y+=S;for(let O=0;O=32&&E<=38||E===208){let[,w]=T(t,y);y+=w}else if(E>=40&&E<=62)y=Xt(t,y);else if(E===63||E===64)y++;else if(E===66){let[,w]=Po(t,y);y+=w}else if(E===67)y+=4;else if(E===68)y+=8;else if(E===252||E===253||E===254){let w=Ha(E,t,y);if(w===null)return null;y=w}}return null}return h(i)}function yc(n){return mc(n,"__abi_version")}Ve();var _c=ArrayBuffer,J=Uint8Array,Ur=Uint16Array,gc=Int16Array;var Wr=Int32Array,Dn=function(n,e,t){if(J.prototype.slice)return J.prototype.slice.call(n,e,t);(e==null||e<0)&&(e=0),(t==null||t>n.length)&&(t=n.length);var r=new J(t-e);return r.set(n.subarray(e,t)),r},Jt=function(n,e,t,r){if(J.prototype.fill)return J.prototype.fill.call(n,e,t,r);for((t==null||t<0)&&(t=0),(r==null||r>n.length)&&(r=n.length);tn.length)&&(r=n.length);t2046MB)","invalid block type","FSE accuracy too high","match distance too far back","unexpected EOF"],Q=function(n,e,t){var r=new Error(e||Sc[n]);if(r.code=n,Error.captureStackTrace&&Error.captureStackTrace(r,Q),!t)throw r;return r},Mo=function(n,e,t){for(var r=0,i=0;r>>0},Oc=function(n,e){var t=n[0]|n[1]<<8|n[2]<<16;if(t==3126568&&n[3]==253){var r=n[4],i=r>>5&1,o=r>>2&1,s=r&3,c=r>>6;r&8&&Q(0);var a=6-i,u=s==3?4:s,d=Mo(n,a,u);a+=u;var l=c?1<>3);m=f+(f>>3)*(n[5]&7)}m>2145386496&&Q(1);var p=new J((e==1?h||m:e?0:m)+12);return p[0]=1,p[4]=4,p[8]=8,{b:a+l,y:0,l:0,d,w:e&&e!=1?e:p.subarray(12),e:m,o:new Wr(p.buffer,0,3),u:h,c:o,m:Math.min(131072,m)}}else if((t>>4|n[3]<<20)==25481893)return wc(n,4)+8;Q(0)},st=function(n){for(var e=0;1<t&&Q(3);for(var o=1<0;){var _=st(s+1),E=r>>3,w=(1<<_+1)-1,S=(n[E]|n[E+1]<<8|n[E+2]<<16)>>(r&7)&w,O=(1<<_)-1,z=w-s-1,x=S&O;if(xO&&(S-=z)),h[++c]=--S,S==-1?(s+=S,g[--d]=c):s-=S,!S)do{var I=r>>3;a=(n[I]|n[I+1]<<8)>>(r&7)&3,r+=2,c+=a}while(a==3)}(c>255||s)&&Q(0);for(var R=0,b=(o>>1)+(o>>3)+3,P=o-1,Z=0;Z<=c;++Z){var N=h[Z];if(N<1){m[Z]=-N;continue}for(u=0;u=d)}}for(R&&Q(0),u=0;u>3,{b:i,s:g,n:y,t:f}]},Ac=function(n,e){var t=0,r=-1,i=new J(292),o=n[e],s=i.subarray(0,256),c=i.subarray(256,268),a=new Ur(i.buffer,268);if(o<128){var u=Qt(n,e+1,6),d=u[0],l=u[1];e+=o;var h=d<<3,m=n[e];m||Q(0);for(var f=0,p=0,g=l.b,y=g,_=(++e<<3)-8+st(m);_-=g,!(_>3;if(f+=(n[E]|n[E+1]<<8)>>(_&7)&(1<>3,p+=(n[E]|n[E+1]<<8)>>(_&7)&(1<255&&Q(0)}else{for(r=o-127;t>4,s[t+1]=w&15}++e}var S=0;for(t=0;t11&&Q(0),S+=O&&1<0;--t){var Z=a[t];Jt(P,t,Z,a[t-1]=Z+c[t]*(1<c&&l>3,m=(n[h]|n[h+1]<<8|n[h+2]<<16)>>(d&7);a=(a<>2,s=o<<1,c=o+s;jt(n.subarray(r,r+=n[0]|n[1]<<8),e.subarray(0,o),t),jt(n.subarray(r,r+=n[2]|n[3]<<8),e.subarray(o,s),t),jt(n.subarray(r,r+=n[4]|n[5]<<8),e.subarray(s,c),t),jt(n.subarray(r),e.subarray(c),t)},Lc=function(n,e,t){var r,i=e.b,o=n[i],s=o>>1&3;e.l=o&1;var c=o>>3|n[i+1]<<5|n[i+2]<<13,a=(i+=3)+c;if(s==1)return i>=n.length?void 0:(e.b=i+1,t?(Jt(t,n[i],e.y,e.y+=c),t):Jt(new J(c),n[i]));if(!(a>n.length)){if(s==0)return e.b=a,t?(t.set(n.subarray(i,a),e.y),e.y+=c,t):Dn(n,i,a);if(s==2){var u=n[i],d=u&3,l=u>>2&3,h=u>>4,m=0,f=0;d<2?l&1?h|=n[++i]<<4|(l&2&&n[++i]<<12):h=u>>3:(f=l,l<2?(h|=(n[++i]&63)<<4,m=n[i]>>6|n[++i]<<2):l==2?(h|=n[++i]<<4|(n[++i]&3)<<12,m=n[i]>>2|n[++i]<<6):(h|=n[++i]<<4|(n[++i]&63)<<12,m=n[i]>>6|n[++i]<<2|n[++i]<<10)),++i;var p=t?t.subarray(e.y,e.y+e.m):new J(e.m),g=p.length-h;if(d==0)p.set(n.subarray(i,i+=h),g);else if(d==1)Jt(p,n[i++],g);else{var y=e.h;if(d==2){var _=Ac(n,i);m+=i-(i=_[0]),e.h=y=_[1]}else y||Q(0);(f?bc:jt)(n.subarray(i,i+=m),p.subarray(g),y)}var E=n[i++];if(E){E==255?E=(n[i++]|n[i++]<<8)+32512:E>127&&(E=E-128<<8|n[i++]);var w=n[i++];w&3&&Q(0);for(var S=[xc,Ic,zc],O=2;O>-1;--O){var z=w>>(O<<1)+2&3;if(z==1){var x=new J([0,0,n[i++]]);S[O]={s:x.subarray(2,3),n:x.subarray(0,1),t:new Ur(x.buffer,0,1),b:0}}else z==2?(r=Qt(n,i,9-(O&1)),i=r[0],S[O]=r[1]):z==3&&(e.t||Q(0),S[O]=e.t[O])}var I=e.t=S,R=I[0],b=I[1],P=I[2],Z=n[a-1];Z||Q(0);var N=(a<<3)-8+st(Z)-P.b,k=N>>3,G=0,ue=(n[k]|n[k+1]<<8)>>(N&7)&(1<>3;var Ae=(n[k]|n[k+1]<<8)>>(N&7)&(1<>3;var M=(n[k]|n[k+1]<<8)>>(N&7)&(1<>3;var yt=1<>>(N&7)&yt-1);k=(N-=Bn[nt])>>3;var $e=Rc[nt]+((n[k]|n[k+1]<<8|n[k+2]<<16)>>(N&7)&(1<>3;var it=Tc[de]+((n[k]|n[k+1]<<8|n[k+2]<<16)>>(N&7)&(1<>3,ue=P.t[ue]+((n[k]|n[k+1]<<8)>>(N&7)&(1<>3,M=R.t[M]+((n[k]|n[k+1]<<8)>>(N&7)&(1<>3,Ae=b.t[Ae]+((n[k]|n[k+1]<<8)>>(N&7)&(1<3)e.o[2]=e.o[1],e.o[1]=e.o[0],e.o[0]=ze-=3;else{var _t=ze-(it!=0);_t?(ze=_t==3?e.o[0]-1:e.o[_t],_t>1&&(e.o[2]=e.o[1]),e.o[1]=e.o[0],e.o[0]=ze):ze=e.o[0]}for(var O=0;O$e&&(We=$e);for(var O=0;OFc)throw er("EOVERFLOW","file offset is outside signed i64");return n}function Cc(n){if(Un(n)<0n)throw er("EINVAL","negative positioned I/O offset");return n}function Wn(n){let e=Un(n);if(e$o)throw er("EOVERFLOW","backend cannot represent the file offset exactly");return Bo(e)}function Gn(n){let e=Cc(n);return Wn(e)}function Uo(n){if(n===null)return null;let e=Un(n);if(e<0n)throw er("EINVAL","negative file-size limit");return e>$o?null:Bo(e)}Ve();var{ALLOC_SIZE_MIN:Mc,ASYNC_IO:Dc,CHOWN_RESTRICTED:Kc,FALLOC:Bc,FILESIZEBITS:$c,LINK_MAX:Uc,MAX_CANON:Wc,MAX_INPUT:Gc,NAME_MAX:Hc,NO_TRUNC:Vc,PATH_MAX:qc,PIPE_BUF:Zc,POSIX2_SYMLINKS:Yc,PRIO_IO:Xc,REC_INCR_XFER_SIZE:jc,REC_MAX_XFER_SIZE:Jc,REC_MIN_XFER_SIZE:Qc,REC_XFER_ALIGN:eu,SOCK_MAXBUF:tu,SYMLINK_MAX:ru,SYNC_IO:nu,TEXTDOMAIN_MAX:iu,TIMESTAMP_RESOLUTION:ou,VDISABLE:su}=Io,{S_IFDIR:au,S_IFIFO:cu,S_IFMT:Wo,S_IFREG:uu}=ie;function Hn(n){let e=new Error(`EINVAL: pathconf name ${n} is not associated with this object`);throw e.code="EINVAL",e}function Vn(n,e,t){switch(e){case Uc:return null;case Hc:return 255;case qc:return zo;case Kc:return 1;case Vc:return 1;case Dc:return(n.mode&Wo)===uu?1:Hn(e);case nu:case Xc:case $c:case jc:case Jc:case Qc:case eu:case Mc:case ru:case Bc:return null;case Yc:return t.supportsSymlinks?1:null;case iu:return 255;case ou:return t.timestampResolutionNs;case Zc:{let r=n.mode&Wo;return r===cu||r===au?null:Hn(e)}case Wc:case Gc:case su:case tu:return Hn(e);default:{let r=new Error(`EINVAL: invalid pathconf name ${e}`);throw r.code="EINVAL",r}}}Ve();Ve();var Go=Oo.ST_NOSUID;var Gr=Math.floor(160),qn=1397114451,Zn=1,ar=32768,H=16384,wt=40960,W=61440,du=2048,lu=1024,Ho=4294967295;function fu(n,e){if((n&W)!==ar)return n;switch(e){case"content":case"ownership":return n&~(du|lu)}}var at=0,Vo=1;var cr=64,ri=128,dr=512,pu=1024,hu=65536,tr=3,mu=0,yu=1,_u=2,F=8,gu=64*1024,Eu=-1,_e=-2,K=-5,re=-9,Qn=-16,Tt=-17,Fe=-20,ut=-21,j=-22,rs=-24,dt=-27,se=-28,ns=-30,ei=-36,ti=-39,is=-40,os=-75,Yn=0,Xn=4,Hr=8,Ot=12,Ye=16,At=20,Vr=24,ct=28,qr=32,qo=36,Zr=40,Su=44,wu=48,Ou=52,jn=56,Yr=60,Xr=64,rr=68,Zo=72,zt=0,C=8,$=12,L=16,me=24,ee=32,nr=40,oe=48,ir=88,xt=92,or=96,sr=100,pe=104,Xe=112,Yo=116,he=120,jr=0,Xo=1,Jr=2,jo=4,ke=8,Jo=16,Qo=20,es=-2147483648,Au=2147483647,zu=1034+1024*1024,je=zu*4096,xu={[_e]:"No such file or directory",[K]:"I/O error",[re]:"Bad file descriptor",[Qn]:"Device or resource busy",[Tt]:"File exists",[Fe]:"Not a directory",[ut]:"Is a directory",[j]:"Invalid argument",[rs]:"Too many open files",[dt]:"File too large",[se]:"No space left on device",[ns]:"Read-only file system",[ei]:"File name too long",[ti]:"Directory not empty",[is]:"Too many symbolic links",[os]:"Value too large for data type"},A=class extends Error{constructor(t,r){super(r||xu[t]||`Error ${t}`);this.code=t;this.name="SFSError"}code},ge=new TextEncoder,ur=new TextDecoder,ts=ge.encode("..");function Jn(n){return n==="."||n===".."}function It(n){return n.buffer instanceof SharedArrayBuffer?ur.decode(new Uint8Array(n)):ur.decode(n)}function Je(n){return n+3&-4}var le=class n{constructor(e){this.buffer=e;this.view=new DataView(e),this.i32=new Int32Array(e),this.u8=new Uint8Array(e)}buffer;view;i32;u8;dirIndexes=new Map;blockAllocHint=0;inodeAllocHint=2;atomicsWaitAllowed;static mkfs(e,t){let r=e.byteLength;if(r<4096*16)throw new A(j);let i=Math.floor(r/4096),o=t?Math.floor(t/4096):i*4,s=Math.floor(o/4);s<32&&(s=32),s=Math.ceil(s/32)*32;let c=Math.ceil(s/(4096*8)),a=Math.ceil(o/(4096*8)),u=Math.ceil(s*128/4096),d=1,l=d+c,h=l+a,m=h+u;if(m>=i){let x=(m+1)*4096;try{e.grow(x)}catch{throw new A(se)}if(i=Math.floor(e.byteLength/4096),m>=i)throw new A(se)}new Uint8Array(e).fill(0);let f=new n(e);f.w32(Yn,qn),f.w32(Xn,Zn),f.w32(Hr,4096),f.w32(Ot,i),f.w32(Ye,s),f.w32(ct,d),f.w32(qr,l),f.w32(qo,h),f.w32(Zr,m),f.w32(Su,c),f.w32(wu,a),f.w32(Ou,u),f.w32(rr,o),f.w32(Zo,256);let p=l*4096;for(let x=0;x>2)+(x>>5);f.i32[I]|=1<<(x&31)}let g=i-m;Atomics.store(f.i32,At>>2,g),f.blockAllocHint=m;let y=d*4096;f.i32[y>>2]|=3,Atomics.store(f.i32,Vr>>2,s-2),f.inodeAllocHint=2;let _=f.inodeOffset(1);f.w32(_+C,H|493),f.w32(_+$,2),f.w64(_+pe,1);let E=f.blockAlloc();if(E<0)throw new A(se);f.w32(_+oe,E);let w=E*4096,S=Je(F+1),O=Je(F+2);f.w32(w,1),f.view.setUint16(w+4,S,!0),f.view.setUint16(w+6,1,!0),f.u8[w+F]=46;let z=w+S;return f.w32(z,1),f.view.setUint16(z+4,O,!0),f.view.setUint16(z+6,2,!0),f.u8[z+F]=46,f.u8[z+F+1]=46,f.w64(_+L,S+O),Atomics.store(f.i32,jn>>2,1),f}static inspectImageCapacity(e){if(e.byteLengththis.snapshotBytesUnlocked(e))}snapshotState(e){return this.withNamespaceLock(()=>({bytes:this.snapshotBytesUnlocked(e),identities:this.collectIdentityStateUnlocked()}))}identityState(){return this.withNamespaceLock(()=>this.collectIdentityStateUnlocked())}snapshotBytesUnlocked(e){let t=e?.normalizeTimestampsMs;if(t!==void 0&&(!Number.isSafeInteger(t)||t<0))throw new A(j,"Snapshot timestamp must be a non-negative safe integer in milliseconds");let r=t===void 0?void 0:BigInt(t);for(let c=0;c>2)!==0)throw new A(Qn,"Cannot save a VFS image with open descriptors")}let i=this.r32(Ye);for(let c=0;c=1&&this.inodeIsAllocated(c)?r:0n;s.setBigUint64(a+nr,u,!0),s.setBigUint64(a+me,u,!0),s.setBigUint64(a+ee,u,!0)}}return o}collectIdentityStateUnlocked(){let e=new Map,t=this.inodeOffset(1),r=this.r64(t+pe);e.set(`1:${r}`,{ino:1,generation:r,dataSequence:Atomics.load(this.i32,t+he>>2)>>>0,mode:this.r32(t+C),linkCount:this.r32(t+$),size:this.r64(t+L),uid:this.r32(t+or),gid:this.r32(t+sr),paths:["/"]});let i=[{ino:1,path:"/"}],o=new Set;for(;i.length>0;){let s=i.pop();if(o.has(s.ino))throw new A(K);o.add(s.ino);let c=this.inodeOffset(s.ino);if((this.r32(c+C)&W)!==H)throw new A(K);let a=this.r64(c+L),u=0;for(;u>2)>>>0,mode:R,linkCount:this.r32(O+$),size:this.r64(O+L),uid:this.r32(O+or),gid:this.r32(O+sr),...(R&W)===wt?{symlinkTarget:this.readSymlinkInodeUnlocked(y)}:{},paths:[]},e.set(x,I)}I.paths.push(S),(this.r32(O+C)&W)===H&&i.push({ino:y,path:S})}}p+=_}u+=f}}return e}statfs(){let e=this.r32(Hr),t=this.r32(Ot),r=this.r32(rr),i=typeof this.buffer.maxByteLength=="number"?this.buffer.maxByteLength:this.buffer.byteLength,o=Math.floor(i/e),s=Math.max(t,Math.min(r,o)),c=Atomics.load(this.i32,At>>2),a=Math.max(0,s-t);return{blockSize:e,totalBlocks:s,freeBlocks:c+a,totalInodes:this.r32(Ye),freeInodes:Atomics.load(this.i32,Vr>>2),maxName:255}}r32(e){return this.view.getUint32(e,!0)}w32(e,t){this.view.setUint32(e,t,!0)}r64(e){return Number(this.view.getBigUint64(e,!0))}w64(e,t){this.view.setBigUint64(e,BigInt(t),!0)}waitForAtomicChange(e,t){if(this.atomicsWaitAllowed!==!1)try{Atomics.wait(this.i32,e,t),this.atomicsWaitAllowed=!0;return}catch(r){if(!(r instanceof TypeError))throw r;this.atomicsWaitAllowed=!1}for(;Atomics.load(this.i32,e)===t;);}resetAllocationHints(){this.blockAllocHint=this.findNextFreeBlockHint(),this.inodeAllocHint=this.findNextFreeInodeHint()}findNextFreeBlockHint(){let e=this.r32(Ot),t=this.r32(Zr),r=this.r32(qr)*4096;for(let i=t;i>2)+(i>>5),s=i&31;if((Atomics.load(this.i32,o)&1<>2)+(r>>5),o=r&31;if((Atomics.load(this.i32,i)&1<>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}sbUnlock(){let e=Yr>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}namespaceLock(){let e=Xr>>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}namespaceUnlock(){let e=Xr>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}withNamespaceLock(e){this.namespaceLock();try{return e()}finally{this.namespaceUnlock()}}resetRestoredRuntimeState(){Atomics.store(this.i32,Yr>>2,0),Atomics.store(this.i32,Xr>>2,0),this.u8.fill(0,256,4096);let e=this.r32(Ye),t=this.r32(ct)*4096;for(let r=0;r>5)*4)&1<<(r&31))===0||this.r32(i+$)!==0)continue;let s=this.r32(i+C),c=this.r64(i+L);(s&W)===wt&&c<=40?(this.u8.fill(0,i+oe,i+oe+40),this.w64(i+L,0)):this.inodeTruncate(r,0),this.inodeFree(r)}}blockAlloc(){let e=this.r32(Ot),t=this.r32(qr)*4096,r=this.r32(Zr),i=this.blockAllocHint>=r&&this.blockAllocHint>2)+(c>>5),u=c&31,d=Atomics.load(this.i32,a);if(d&1<>2,1),this.blockAllocHint=c+1>2)+(e>>5),i=e&31;for(;;){let o=Atomics.load(this.i32,r),s=o&~(1<>2,1),e>=this.r32(Zr)&&e>2)>0)return 0;let e=this.r32(Ot),t=this.r32(rr),r=this.r32(Zo),i=e+r;if(i>t&&(i=t,r=i-e,r===0))return se;let o=i*4096;if(this.buffer.byteLength>2,r),Atomics.add(this.i32,jn>>2,1),this.blockAllocHint=e,0}finally{this.sbUnlock()}}inodeOffset(e){let r=this.r32(qo)+Math.floor(e/32),i=e%32*128;return r*4096+i}inodeAlloc(){let e=this.r32(Ye),t=this.r32(ct)*4096,r=this.inodeAllocHint>=2&&this.inodeAllocHint>2)+(s>>5),a=s&31,u=Atomics.load(this.i32,c);if(u&1<>2,1),this.inodeAllocHint=s+1>2,1)+1}inodeFree(e){let r=(this.r32(ct)*4096>>2)+(e>>5),i=e&31;for(;;){let o=Atomics.load(this.i32,r);if((o&1<>2,1),e>=2&&e0&&this.w32(r+Xe,i-1),i<=1&&this.r32(r+$)===0&&(this.inodeTruncate(e,0),t=!0)}finally{this.inodeWriteUnlock(e)}t&&this.inodeFree(e)}inodeDropLinkRefLocked(e){let t=this.inodeOffset(e),r=this.r32(t+$);return r>1?(this.w32(t+$,r-1),this.w64(t+ee,Date.now()),!1):this.inodeOrphanLocked(e)}inodeOrphanLocked(e){let t=this.inodeOffset(e);if(this.w32(t+$,0),this.w64(t+ee,Date.now()),this.r32(t+Xe)>0)return!1;let r=this.r32(t+C),i=this.r64(t+L);return(r&W)===wt&&i<=40?(this.u8.fill(0,t+oe,t+oe+40),this.w64(t+L,0)):this.inodeTruncate(e,0),!0}inodeReadLock(e){let t=this.inodeOffset(e)+zt>>2;for(;;){let r=Atomics.load(this.i32,t);if(r&es){this.waitForAtomicChange(t,r);continue}if(Atomics.compareExchange(this.i32,t,r,r+1)===r)return}}inodeReadUnlock(e){let t=this.inodeOffset(e)+zt>>2;(Atomics.sub(this.i32,t,1)&Au)===1&&Atomics.notify(this.i32,t,1)}inodeWriteLock(e){let t=this.inodeOffset(e)+zt>>2;for(;;){let r=Atomics.load(this.i32,t);if(r!==0){this.waitForAtomicChange(t,r);continue}if(Atomics.compareExchange(this.i32,t,0,es)===0)return}}inodeWriteUnlock(e){let t=this.inodeOffset(e)+zt>>2;Atomics.store(this.i32,t,0),Atomics.notify(this.i32,t,1/0)}inodeBlockMap(e,t,r){let i=this.inodeOffset(e);if(t<10){let o=this.r32(i+oe+t*4);if(o!==0)return o;if(!r)return 0;let s=this.blockAllocWithGrow();return s<0||this.w32(i+oe+t*4,s),s}if(t-=10,t<1024){let o=this.r32(i+ir),s=!1;if(o===0){if(!r)return 0;if(o=this.blockAllocWithGrow(),o<0)return o;this.w32(i+ir,o),s=!0}let c=o*4096+t*4,a=this.r32(c);if(a!==0)return a;if(!r)return 0;let u=this.blockAllocWithGrow();return u<0?(s&&(this.w32(i+ir,0),this.blockFree(o)),u):(this.w32(c,u),u)}if(t-=1024,t<1024*1024){let o=Math.floor(t/1024),s=t%1024,c=this.r32(i+xt),a=!1;if(c===0){if(!r)return 0;if(c=this.blockAllocWithGrow(),c<0)return c;this.w32(i+xt,c),a=!0}let u=c*4096+o*4,d=this.r32(u),l=!1;if(d===0){if(!r)return 0;if(d=this.blockAllocWithGrow(),d<0)return a&&(this.w32(i+xt,0),this.blockFree(c)),d;this.w32(u,d),l=!0}let h=d*4096+s*4,m=this.r32(h);if(m!==0)return m;if(!r)return 0;let f=this.blockAllocWithGrow();return f<0?(l&&(this.w32(u,0),this.blockFree(d)),a&&(this.w32(i+xt,0),this.blockFree(c)),f):(this.w32(h,f),f)}return j}inodeReadData(e,t,r,i){let o=this.inodeOffset(e),s=this.r64(o+L);if(t>=s)return 0;t+i>s&&(i=s-t);let c=0,a=0;for(;i>0;){let u=Math.floor(t/4096),d=t%4096,l=4096-d;l>i&&(l=i);let h=this.inodeBlockMap(e,u,!1);if(h<=0)r.fill(0,a,a+l);else{let m=h*4096+d;r.set(this.u8.subarray(m,m+l),a)}a+=l,t+=l,i-=l,c+=l}return c}inodeWriteData(e,t,r,i){let o=this.inodeOffset(e),s=this.r64(o+L);t>s&&this.zeroOldEofTail(e,s);let c=0,a=0;for(;i>0;){let u=Math.floor(t/4096),d=t%4096,l=4096-d;l>i&&(l=i);let h=this.inodeBlockMap(e,u,!0);if(h<0){if(c===0)return h;break}let m=h*4096+d;this.u8.set(r.subarray(a,a+l),m),a+=l,t+=l,i-=l,c+=l}if(c>0&&t>this.r64(o+L)&&this.w64(o+L,t),c>0){let u=Date.now();this.w64(o+me,u),this.w64(o+ee,u),Atomics.add(this.i32,o+he>>2,1)}return c}invalidateSetIdAfterRegularFileMutation(e,t){let r=this.inodeOffset(e),i=this.r32(r+C),o=fu(i,t);o!==i&&this.w32(r+C,o)}zeroInodeRange(e,t,r){for(;t0){let a=c*4096+o;this.u8.fill(0,a,a+s)}t+=s}}zeroOldEofTail(e,t){let r=t%4096;if(r===0)return;let i=Math.floor(t/4096),o=this.inodeBlockMap(e,i,!1);if(o<=0)return;let s=o*4096+r;this.u8.fill(0,s,o*4096+4096)}freeBlocksFrom(e,t){let r=this.inodeOffset(e);for(let s=t;s<10;s++){let c=this.r32(r+oe+s*4);c&&(this.blockFree(c),this.w32(r+oe+s*4,0))}let i=this.r32(r+ir);if(i){let s=t>10?t-10:0;for(let c=s;c<1024;c++){let a=i*4096+c*4,u=this.r32(a);u&&(this.blockFree(u),this.w32(a,0))}s===0&&(this.blockFree(i),this.w32(r+ir,0))}let o=this.r32(r+xt);if(o){let s=t>1034?t-10-1024:0,c=Math.floor(s/1024);for(let a=c;a<1024;a++){let u=o*4096+a*4,d=this.r32(u);if(!d)continue;let l=a===c?s%1024:0;for(let h=l;h<1024;h++){let m=d*4096+h*4,f=this.r32(m);f&&(this.blockFree(f),this.w32(m,0))}l===0&&(this.blockFree(d),this.w32(u,0))}c===0&&(this.blockFree(o),this.w32(r+xt,0))}}inodeTruncate(e,t,r=!1){let i=this.inodeOffset(e),o=this.r64(i+L),s=t!==o;if(t>=o){if(t>o&&this.zeroOldEofTail(e,o),this.w64(i+L,t),s||r){let a=Date.now();this.w64(i+me,a),this.w64(i+ee,a),Atomics.add(this.i32,i+he>>2,1)}return}t%4096!==0&&this.zeroInodeRange(e,t,Math.ceil(t/4096)*4096);let c=Math.ceil(t/4096);if(this.freeBlocksFrom(e,c),this.w64(i+L,t),s||r){let a=Date.now();this.w64(i+me,a),this.w64(i+ee,a),Atomics.add(this.i32,i+he>>2,1)}}validateFileSize(e){if(!Number.isSafeInteger(e)||e<0)throw new A(j);if(e>je)throw new A(dt)}validateSeekPosition(e){if(!Number.isSafeInteger(e))throw new A(os);if(e<0)throw new A(j);if(e>je)throw new A(dt)}touchDirectoryMutation(e){let t=this.inodeOffset(e),r=Date.now();this.w64(t+me,r),this.w64(t+ee,r);let i=Atomics.add(this.i32,t+Yo>>2,1)+1>>>0,o=this.dirIndexes.get(e);o&&(o.mutationSequence=i,o.size=this.r64(t+L))}dirNameKey(e){return It(e)}dirEntryNameMatches(e,t){if(this.view.getUint16(e+6,!0)!==t.length)return!1;for(let i=0;i=F&&r%4===0&&e+r<=t&&i<=r-F}inodeIsAllocated(e){let t=this.r32(Ye);if(e<=0||e>=t)return!1;let r=this.r32(ct)*4096;return(Atomics.load(this.i32,(r>>2)+(e>>5))&1<<(e&31))!==0}rebuildDirIndex(e,t,r,i){let o=new Map,s=[],c=0;for(;c4096-d&&(m=4096-d);let f=d;for(;f=F&&s.push({abs:p,recLen:y});f+=y}c+=m}let a={generation:t,mutationSequence:r,size:i,entries:o,free:s};return this.dirIndexes.set(e,a),a}getDirIndex(e){let t=this.inodeOffset(e),r=this.r64(t+L),i=this.r64(t+pe),o=Atomics.load(this.i32,t+Yo>>2)>>>0,s=this.dirIndexes.get(e);return s&&s.generation===i&&s.mutationSequence===o&&s.size===r?s:(s&&this.dirIndexes.delete(e),r=0;s--){let c=e.free[s];if(!(c.recLen4096-a&&(l=4096-a);let h=a;for(;hr)return-1;c=a,s+=u}return s===r?c:-1}dirAppendEntry(e,t,r,i=-1){let o=this.inodeOffset(e),s=this.r64(o+L),c=Je(F+t.length),a=s,u=Math.floor(a/4096),d=a%4096,l=0;if(d!==0&&d+c>4096){let f=4096-d,p=0;if(f>=F){if(p=this.inodeBlockMap(e,u,!1),p<=0)return K}else if(i<0&&(i=this.findLastDirEntryInBlock(e,u,d)),i<0)return K;if(l=this.inodeBlockMap(e,u+1,!0),l<0)return l;if(f>=F){let g=p*4096+d;this.w32(g,0),this.view.setUint16(g+4,f,!0),this.view.setUint16(g+6,0,!0)}else{let y=this.view.getUint16(i+4,!0)+f;this.view.setUint16(i+4,y,!0),this.updateDirIndexRecLen(e,i,y)}a=(u+1)*4096,u++,d=0}let h;if(d===0){if(h=l||this.inodeBlockMap(e,u,!0),h<0)return h}else if(h=this.inodeBlockMap(e,u,!1),h<=0)return K;let m=h*4096+d;return this.w32(m,r),this.view.setUint16(m+4,c,!0),this.view.setUint16(m+6,t.length,!0),this.u8.set(t,m+F),this.w64(o+L,a+c),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,m,c),0}dirAddEntry(e,t,r){let i=this.getDirIndex(e);if(typeof i=="number")return i;if(i)return this.useDirIndexFreeSlot(i,e,t,r)?0:this.dirAppendEntry(e,t,r);let o=this.inodeOffset(e),s=this.r64(o+L),c=Je(F+t.length),a=-1,u=0;for(;u4096-l&&(f=4096-l);let p=l;for(;pl+f||E>_-F)return K;if(y===0&&_>=c)return this.w32(g,r),this.view.setUint16(g+6,t.length,!0),this.u8.set(t,g+F),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,g,_),0;let w=Je(F+E),S=_-w;if(y!==0&&S>=c){this.view.setUint16(g+4,w,!0);let O=g+w;return this.w32(O,r),this.view.setUint16(O+4,S,!0),this.view.setUint16(O+6,t.length,!0),this.u8.set(t,O+F),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,r,O,S),0}a=g,p+=_}u+=f}return this.dirAppendEntry(e,t,r,a)}dirRemoveEntry(e,t){let r=this.getDirIndex(e);if(typeof r=="number")return r;if(r){let c=this.dirNameKey(t),a=r.entries.get(c);if(!a)return _e;if(this.r32(a.abs)===a.ino&&this.view.getUint16(a.abs+4,!0)===a.recLen&&this.view.getUint16(a.abs+6,!0)===a.nameLen&&this.dirEntryNameMatches(a.abs,t))return this.w32(a.abs,0),r.entries.delete(c),r.free.push({abs:a.abs,recLen:a.recLen}),this.touchDirectoryMutation(e),0;r.entries.delete(c)}let i=this.inodeOffset(e),o=this.r64(i+L),s=0;for(;s4096-a&&(l=4096-a);let h=a;for(;h4096-u&&(h=4096-u);let m=u;for(;m4096-s&&(u=4096-s);let d=s;for(;ds+u||f>m-F)throw new A(K);if(h!==0){if(f===1&&this.u8[l+F]===46){d+=m;continue}if(f===2&&this.u8[l+F]===46&&this.u8[l+F+1]===46){d+=m;continue}return!1}d+=m}i+=u}return!0}dirIsAncestor(e,t){let r=t;for(let i=0;i<8*1024;i++){if(r===e)return!0;if(r===1)return!1;let o=this.dirLookup(r,ts);if(o<0||o===r)throw new A(K);r=o}throw new A(K)}pathResolve(e,t){if(!e.startsWith("/"))return _e;let r=1,i=e.split("/").filter(s=>s.length>0),o=0;for(let s=0;s255)return ei;let a=ge.encode(c),u;this.inodeReadLock(r);try{let h=this.inodeOffset(r);if((this.r32(h+C)&W)!==H)return Fe;u=this.dirLookup(r,a)}finally{this.inodeReadUnlock(r)}if(u<0)return u;let d=this.inodeOffset(u);if((this.r32(d+C)&W)===wt&&(!(s===i.length-1)||t)){if(++o>8)return is;let m=this.r64(d+L),f;if(m<=40)f=It(this.u8.subarray(d+oe,d+oe+m));else{let p=new Uint8Array(m);this.inodeReadData(u,0,p,m),f=ur.decode(p)}if(f.startsWith("/")){r=1;let p=f.split("/").filter(y=>y.length>0),g=i.slice(s+1);i.length=0,i.push(...p,...g),s=-1}else{let p=f.split("/").filter(y=>y.length>0),g=i.slice(s+1);i.length=s,i.push(...p,...g),s--}continue}r=u}return r}pathResolveParent(e){if(!e.startsWith("/"))throw new A(j,"Path must be absolute");let t=e.split("/").filter(a=>a.length>0);if(t.length===0)throw new A(j,"Cannot operate on /");let r=t.pop();if(r.length>255)throw new A(ei);let i="/"+t.join("/"),o=this.pathResolve(i,!0);if(o<0)throw new A(o);let s=this.inodeOffset(o);if((this.r32(s+C)&W)!==H)throw new A(Fe);return{parentIno:o,name:r}}fdReserve(){for(let e=0;e>2;if(Atomics.compareExchange(this.i32,r,jr,Jr)===jr)return e}return rs}fdPrepare(e,t,r,i){let o=256+e*24;if(Atomics.load(this.i32,o>>2)!==Jr)throw new A(K);this.w32(o+jo,t),this.w64(o+ke,0),this.w32(o+Jo,r),this.w32(o+Qo,i?1:0)}fdPublish(e){let t=256+e*24;Atomics.store(this.i32,t>>2,Xo)}fdReleaseReservation(e){let t=256+e*24;if(Atomics.compareExchange(this.i32,t>>2,Jr,jr)!==Jr)throw new A(K)}fdAlloc(e,t,r){let i=this.fdReserve();if(i<0)return i;let o=!1,s=!1;try{return this.fdPrepare(i,e,t,r),this.inodeAddOpenRef(e)?(o=!0,this.fdPublish(i),s=!0,i):_e}finally{if(!s)try{o&&this.inodeDropOpenRef(e)}finally{this.fdReleaseReservation(i)}}}fdGet(e){if(e<0||e>=Gr)return null;let t=256+e*24;return Atomics.load(this.i32,t>>2)!==Xo?null:{base:t,ino:this.r32(t+jo),offset:this.r64(t+ke),flags:this.r32(t+Jo),isDir:this.r32(t+Qo)!==0}}fdFree(e){if(e>=0&&e>2,jr)}}buildStat(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+pe),dataSequence:this.r32(t+he),mode:this.r32(t+C),linkCount:this.r32(t+$),size:this.r64(t+L),mtime:this.r64(t+me),ctime:this.r64(t+ee),atime:this.r64(t+nr),uid:this.r32(t+or),gid:this.r32(t+sr)}}namespaceEntryIdentity(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+pe),linkCount:this.r32(t+$),mode:this.r32(t+C)}}open(e,t,r=420){return this.withNamespaceLock(()=>this.openUnlocked(e,t,r))}createLazyStub(e,t){return this.withNamespaceLock(()=>{let r=this.openUnlocked(e,Vo|cr,t);try{let i=this.fdGet(r);if(!i)throw new A(re);this.inodeWriteLock(i.ino);try{return this.inodeTruncate(i.ino,0,!0),this.buildStat(i.ino)}finally{this.inodeWriteUnlock(i.ino)}}finally{this.closeUnlocked(r)}})}replaceIfIdentity(e,t,r,i,o){return this.withNamespaceLock(()=>{let s=this.pathResolve(e,!0);if(s<0||s!==t)return!1;let c=this.inodeOffset(s);if(this.r64(c+pe)!==r||this.r32(c+he)!==i||(this.r32(c+C)&W)!==ar)return!1;this.validateFileSize(o.byteLength),this.inodeWriteLock(s);try{if(this.r64(c+pe)!==r||this.r32(c+he)!==i||this.r64(c+L)!==0)return!1;let a=this.r64(c+me),u=this.r64(c+ee);this.inodeTruncate(s,0,!0);let d=o.byteLength>0?this.inodeWriteData(s,0,o,o.byteLength):0;if(d!==o.byteLength)throw this.inodeTruncate(s,0,!0),Atomics.store(this.i32,c+he>>2,i),this.w64(c+me,a),this.w64(c+ee,u),new A(d<0?d:se);return!0}finally{this.inodeWriteUnlock(s)}})}replaceManyIfIdentities(e,t=[]){return e.length===0&&t.length===0?!0:this.withNamespaceLock(()=>{let r=[],i=new Set,o=c=>{let a=this.pathResolve(c.path,!1);if(a<0||a!==c.expectedIno)return!1;let u=this.inodeOffset(a);return this.r64(u+pe)===c.expectedGeneration&&this.r32(u+he)===c.expectedDataSequence&&this.r32(u+C)===c.expectedMode&&this.r32(u+$)===c.expectedLinkCount&&this.r64(u+L)===c.expectedSize&&this.r32(u+or)===c.expectedUid&&this.r32(u+sr)===c.expectedGid};for(let c of t)if(!o(c))return!1;for(let c of e){this.validateFileSize(c.data.byteLength);let a=-1;for(let u of c.paths){let d=this.pathResolve(u,!0);if(d!==c.expectedIno)continue;let l=this.inodeOffset(d);if(this.r64(l+pe)===c.expectedGeneration&&this.r32(l+he)===c.expectedDataSequence&&(this.r32(l+C)&W)===ar&&this.r64(l+L)===0){a=d;break}}if(a<0)return!1;if(i.has(a))throw new A(j,"duplicate conditional replacement inode");i.add(a),r.push({...c,ino:a})}let s=[...i].sort((c,a)=>c-a);for(let c of s)this.inodeWriteLock(c);try{for(let u of r){let d=this.inodeOffset(u.ino);if(this.r64(d+pe)!==u.expectedGeneration||this.r32(d+he)!==u.expectedDataSequence||(this.r32(d+C)&W)!==ar||this.r64(d+L)!==0)return!1}for(let u of t)if(!o(u))return!1;let c=r.map(u=>{let d=this.inodeOffset(u.ino);return{ino:u.ino,dataSequence:this.r32(d+he),mtime:this.r64(d+me),ctime:this.r64(d+ee)}}),a=0;try{for(let u of r){a++,this.inodeTruncate(u.ino,0,!0);let d=u.data.byteLength>0?this.inodeWriteData(u.ino,0,u.data,u.data.byteLength):0;if(d!==u.data.byteLength)throw new A(d<0?d:se)}}catch(u){for(let d=a-1;d>=0;d--){let l=c[d],h=this.inodeOffset(l.ino);this.inodeTruncate(l.ino,0,!0),Atomics.store(this.i32,h+he>>2,l.dataSequence),this.w64(h+me,l.mtime),this.w64(h+ee,l.ctime)}throw u}return!0}finally{for(let c=s.length-1;c>=0;c--)this.inodeWriteUnlock(s[c])}})}openUnlocked(e,t,r=420){let i=t&tr,o=(t&cr)!==0,s=(t&ri)!==0,c=this.fdReserve();if(c<0)throw new A(c);let a=null,u=!1;try{if(o&&s){let f=this.pathResolve(e,!1);if(f>=0)throw new A(Tt);if(f!==_e)throw new A(f)}let d=this.pathResolve(e,!0),l=!1;if(d<0&&d===_e&&o){let{parentIno:f,name:p}=this.pathResolveParent(e);this.inodeWriteLock(f);try{let g=ge.encode(p),y=this.dirLookup(f,g);if(y>=0){if(s)throw new A(Tt);d=y}else{let _=this.inodeAlloc();if(_<0)throw new A(se);let E=this.inodeOffset(_);this.w32(E+C,ar|r&4095),this.w32(E+$,1),this.w64(E+L,0);let w=Date.now();this.w64(E+nr,w),this.w64(E+me,w),this.w64(E+ee,w);let S=this.dirAddEntry(f,g,_);if(S<0)throw this.inodeFree(_),new A(S);d=_,l=!0}}finally{this.inodeWriteUnlock(f)}}if(d<0)throw new A(d);let h=this.inodeOffset(d),m=this.r32(h+C);if((m&W)===H&&i!==at)throw new A(ut);if(t&hu&&(m&W)!==H)throw new A(Fe);if(this.fdPrepare(c,d,t,!1),!this.inodeAddOpenRef(d))throw new A(_e);if(a=d,t&dr){if((m&W)===H)throw new A(ut);this.inodeWriteLock(d);try{let f=this.r64(h+L)!==0;this.inodeTruncate(d,0,!0),!l&&f&&this.invalidateSetIdAfterRegularFileMutation(d,"content")}finally{this.inodeWriteUnlock(d)}}return this.fdPublish(c),u=!0,c}finally{if(!u)try{a!==null&&this.inodeDropOpenRef(a)}finally{this.fdReleaseReservation(c)}}}close(e){this.withNamespaceLock(()=>this.closeUnlocked(e))}closeUnlocked(e){let t=this.fdGet(e);if(!t)throw new A(re);this.fdFree(e),this.inodeDropOpenRef(t.ino)}read(e,t){let r=this.fdGet(e);if(!r)throw new A(re);let i=this.inodeOffset(r.ino);if((this.r32(i+C)&W)===H)throw new A(ut);this.inodeReadLock(r.ino);try{let s=this.inodeReadData(r.ino,r.offset,t,t.length),c=256+e*24;return this.w64(c+ke,r.offset+s),s}finally{this.inodeReadUnlock(r.ino)}}readAt(e,t,r){let i=this.fdGet(e);if(!i)throw new A(re);let o=this.inodeOffset(i.ino);if((this.r32(o+C)&W)===H)throw new A(ut);this.validateSeekPosition(r),this.inodeReadLock(i.ino);try{return this.inodeReadData(i.ino,r,t,t.length)}finally{this.inodeReadUnlock(i.ino)}}write(e,t){let r=this.fdGet(e);if(!r)throw new A(re);if((r.flags&tr)===at)throw new A(re);this.inodeWriteLock(r.ino);try{let o=r.offset;if(r.flags&pu){let a=this.inodeOffset(r.ino);o=this.r64(a+L)}if(!Number.isSafeInteger(o)||o<0)throw new A(j);if(o>je||t.length>je-o)throw new A(dt);let s=this.inodeWriteData(r.ino,o,t,t.length);if(s<0)return s;s>0&&this.invalidateSetIdAfterRegularFileMutation(r.ino,"content");let c=256+e*24;return this.w64(c+ke,o+s),s}finally{this.inodeWriteUnlock(r.ino)}}append(e,t,r){let i=this.fdGet(e);if(!i)throw new A(re);if((i.flags&tr)===at)throw new A(re);if(r!==null&&(!Number.isSafeInteger(r)||r<0))throw new A(j);this.inodeWriteLock(i.ino);try{let s=this.inodeOffset(i.ino),c=this.r64(s+L);if(!Number.isSafeInteger(c)||c<0)throw new A(j);if(c>je)throw new A(dt);if(r!==null&&c>=r){let f=256+e*24;return this.w64(f+ke,c),{written:0,end:c}}let a=r===null?t.length:Math.min(t.length,r-c),u=je-c;if(a>u)throw new A(dt);let d=t.subarray(0,a),l=this.inodeWriteData(i.ino,c,d,d.length);if(l<0)throw new A(l);l>0&&this.invalidateSetIdAfterRegularFileMutation(i.ino,"content");let h=256+e*24,m=c+l;return this.w64(h+ke,m),{written:l,end:m}}finally{this.inodeWriteUnlock(i.ino)}}writeAt(e,t,r){let i=this.fdGet(e);if(!i)throw new A(re);if((i.flags&tr)===at)throw new A(re);this.validateSeekPosition(r),this.inodeWriteLock(i.ino);try{if(r>je||t.length>je-r)throw new A(dt);let s=this.inodeWriteData(i.ino,r,t,t.length);return s>0&&this.invalidateSetIdAfterRegularFileMutation(i.ino,"content"),s}finally{this.inodeWriteUnlock(i.ino)}}lseek(e,t,r){let i=this.fdGet(e);if(!i)throw new A(re);let o;if(r===mu)o=t;else if(r===yu)o=i.offset+t;else if(r===_u){let c=this.inodeOffset(i.ino);o=this.r64(c+L)+t}else throw new A(j);this.validateSeekPosition(o);let s=256+e*24;return this.w64(s+ke,o),o}ftruncate(e,t){let r=this.fdGet(e);if(!r)throw new A(re);if((r.flags&tr)===at)throw new A(re);this.validateFileSize(t),this.inodeWriteLock(r.ino);try{let i=this.r64(this.inodeOffset(r.ino)+L)!==t;this.inodeTruncate(r.ino,t,!0),i&&this.invalidateSetIdAfterRegularFileMutation(r.ino,"content")}finally{this.inodeWriteUnlock(r.ino)}}fstat(e){let t=this.fdGet(e);if(!t)throw new A(re);this.inodeReadLock(t.ino);try{return this.buildStat(t.ino)}finally{this.inodeReadUnlock(t.ino)}}stat(e){return this.withNamespaceLock(()=>this.statUnlocked(e))}statUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new A(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}lstat(e){return this.withNamespaceLock(()=>this.lstatUnlocked(e))}lstatUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new A(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}unlink(e){return this.withNamespaceLock(()=>this.unlinkUnlocked(e))}unlinkUnlocked(e){let{parentIno:t,name:r}=this.pathResolveParent(e),i=ge.encode(r),o=e.length>1&&e.endsWith("/");this.inodeWriteLock(t);try{let s=this.dirLookup(t,i);if(s<0)throw new A(s);let c=this.inodeOffset(s),a=this.r32(c+C);if(o&&(a&W)!==H)throw new A(Fe);if((a&W)===H)throw new A(ut);let u=this.namespaceEntryIdentity(s),d=this.dirRemoveEntry(t,i);if(d<0)throw new A(d);let l=!1;this.inodeWriteLock(s);try{l=this.inodeDropLinkRefLocked(s)}finally{this.inodeWriteUnlock(s)}return l&&this.inodeFree(s),u}finally{this.inodeWriteUnlock(t)}}rename(e,t){return this.withNamespaceLock(()=>this.renameUnlocked(e,t))}renameUnlocked(e,t){let{parentIno:r,name:i}=this.pathResolveParent(e),{parentIno:o,name:s}=this.pathResolveParent(t);if(Jn(i)||Jn(s))throw new A(j);let c=ge.encode(i),a=ge.encode(s),u=e.length>1&&e.endsWith("/"),d=t.length>1&&t.endsWith("/"),l=Math.min(r,o),h=Math.max(r,o);this.inodeWriteLock(l),l!==h&&this.inodeWriteLock(h);try{let m=this.dirLookup(r,c);if(m<0)throw new A(m);let f=this.inodeOffset(m),g=this.r32(f+C)&W,y=this.namespaceEntryIdentity(m);if((u||d)&&g!==H)throw new A(Fe);if(g===H&&this.dirIsAncestor(m,o))throw new A(j);let _=this.dirLookup(o,a),E=!1,w;if(_>=0){if(_===m)return{source:y,replaced:y};w=this.namespaceEntryIdentity(_);let O=this.inodeOffset(_),x=this.r32(O+C)&W;if(g===H&&x!==H)throw new A(Fe);if(g!==H&&x===H)throw new A(ut);let I=!1,R=_===r||_===o;R||this.inodeWriteLock(_);try{if(x===H&&!this.dirIsEmpty(_))throw new A(ti);let b=this.dirReplaceEntryIno(o,a,m);if(b<0)throw new A(b);I=x===H?this.inodeOrphanLocked(_):this.inodeDropLinkRefLocked(_)}finally{R||this.inodeWriteUnlock(_)}I&&this.inodeFree(_),E=x===H}else{let O=this.dirAddEntry(o,a,m);if(O<0)throw new A(O)}let S=this.dirRemoveEntry(r,c);if(S<0)throw new A(S);if(g===H){if(r!==o){let O=this.inodeOffset(r);this.w32(O+$,this.r32(O+$)-1);let z=this.inodeOffset(o);this.w32(z+$,this.r32(z+$)+1),this.inodeWriteLock(m);try{let x=this.dirReplaceEntryIno(m,ts,o);if(x<0)throw new A(x);this.w64(f+ee,Date.now())}finally{this.inodeWriteUnlock(m)}}if(E){let O=this.inodeOffset(o);this.w32(O+$,this.r32(O+$)-1)}}else if(E){let O=this.inodeOffset(o);this.w32(O+$,this.r32(O+$)-1)}return{source:y,replaced:w}}finally{l!==h&&this.inodeWriteUnlock(h),this.inodeWriteUnlock(l)}}mkdir(e,t=493){this.withNamespaceLock(()=>this.mkdirUnlocked(e,t))}mkdirUnlocked(e,t=493){let{parentIno:r,name:i}=this.pathResolveParent(e),o=ge.encode(i);this.inodeWriteLock(r);try{if(this.dirLookup(r,o)>=0)throw new A(Tt);let c=this.inodeAlloc();if(c<0)throw new A(se);let a=this.inodeOffset(c);this.w32(a+C,H|t),this.w32(a+$,2),this.w64(a+L,0);let u=Date.now();this.w64(a+nr,u),this.w64(a+me,u),this.w64(a+ee,u);let d=this.blockAllocWithGrow();if(d<0)throw this.inodeFree(c),new A(se);this.w32(a+oe,d);let l=d*4096,h=Je(F+1),m=Je(F+2);this.w32(l,c),this.view.setUint16(l+4,h,!0),this.view.setUint16(l+6,1,!0),this.u8[l+F]=46;let f=l+h;this.w32(f,r),this.view.setUint16(f+4,m,!0),this.view.setUint16(f+6,2,!0),this.u8[f+F]=46,this.u8[f+F+1]=46,this.w64(a+L,h+m);let p=this.dirAddEntry(r,o,c);if(p<0)throw this.blockFree(d),this.inodeFree(c),new A(p);let g=this.inodeOffset(r);this.w32(g+$,this.r32(g+$)+1)}finally{this.inodeWriteUnlock(r)}}rmdir(e){this.withNamespaceLock(()=>this.rmdirUnlocked(e))}rmdirUnlocked(e){let{parentIno:t,name:r}=this.pathResolveParent(e);if(Jn(r))throw new A(j);let i=ge.encode(r);this.inodeWriteLock(t);try{let o=this.dirLookup(t,i);if(o<0)throw new A(o);let s=this.inodeOffset(o);if((this.r32(s+C)&W)!==H)throw new A(Fe);let a=!1;this.inodeWriteLock(o);try{if(!this.dirIsEmpty(o))throw new A(ti);let d=this.dirRemoveEntry(t,i);if(d<0)throw new A(d);a=this.inodeOrphanLocked(o)}finally{this.inodeWriteUnlock(o)}a&&this.inodeFree(o);let u=this.inodeOffset(t);this.w32(u+$,this.r32(u+$)-1)}finally{this.inodeWriteUnlock(t)}}symlink(e,t){this.withNamespaceLock(()=>this.symlinkUnlocked(e,t))}symlinkUnlocked(e,t){let{parentIno:r,name:i}=this.pathResolveParent(t),o=ge.encode(i),s=ge.encode(e);this.inodeWriteLock(r);try{if(this.dirLookup(r,o)>=0)throw new A(Tt);let a=this.inodeAlloc();if(a<0)throw new A(se);let u=this.inodeOffset(a);if(this.w32(u+C,wt|511),this.w32(u+$,1),s.length<=40)this.u8.set(s,u+oe),this.w64(u+L,s.length);else{this.w64(u+L,0);let l=this.inodeWriteData(a,0,s,s.length);if(l!==s.length)throw l>0&&this.inodeTruncate(a,0),this.inodeFree(a),new A(l<0?l:se)}let d=this.dirAddEntry(r,o,a);if(d<0)throw s.length<=40?(this.u8.fill(0,u+oe,u+oe+40),this.w64(u+L,0)):this.inodeTruncate(a,0),this.inodeFree(a),new A(d)}finally{this.inodeWriteUnlock(r)}}chmod(e,t){this.withNamespaceLock(()=>this.chmodUnlocked(e,t))}chmodUnlocked(e,t){let r=this.pathResolve(e,!0);if(r<0)throw new A(r);this.inodeWriteLock(r);try{let i=this.inodeOffset(r),o=this.r32(i+C);this.w32(i+C,o&W|t&4095),this.w64(i+ee,Date.now())}finally{this.inodeWriteUnlock(r)}}fchmod(e,t){let r=this.fdGet(e);if(!r)throw new A(re);this.inodeWriteLock(r.ino);try{let i=this.inodeOffset(r.ino),o=this.r32(i+C);this.w32(i+C,o&W|t&4095),this.w64(i+ee,Date.now())}finally{this.inodeWriteUnlock(r.ino)}}chown(e,t,r){this.withNamespaceLock(()=>this.chownUnlocked(e,t,r))}chownUnlocked(e,t,r){let i=this.pathResolve(e,!0);if(i<0)throw new A(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,r)}finally{this.inodeWriteUnlock(i)}}fchown(e,t,r){let i=this.fdGet(e);if(!i)throw new A(re);this.inodeWriteLock(i.ino);try{this.chownInodeUnlocked(i.ino,t,r)}finally{this.inodeWriteUnlock(i.ino)}}lchown(e,t,r){this.withNamespaceLock(()=>this.lchownUnlocked(e,t,r))}lchownUnlocked(e,t,r){let i=this.pathResolve(e,!1);if(i<0)throw new A(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,r)}finally{this.inodeWriteUnlock(i)}}chownInodeUnlocked(e,t,r){let i=this.inodeOffset(e);t!==Ho&&this.w32(i+or,t),r!==Ho&&this.w32(i+sr,r),this.invalidateSetIdAfterRegularFileMutation(e,"ownership"),this.w64(i+ee,Date.now())}utimens(e,t,r,i,o){this.withNamespaceLock(()=>this.utimensUnlocked(e,t,r,i,o))}utimensUnlocked(e,t,r,i,o){let s=this.pathResolve(e,!0);if(s<0)throw new A(s);this.inodeWriteLock(s);try{let c=this.inodeOffset(s),a=1073741823,u=1073741822,d=Date.now();if(r!==u){let l=r===a?d:t*1e3+Math.floor(r/1e6);this.w64(c+nr,l)}if(o!==u){let l=o===a?d:i*1e3+Math.floor(o/1e6);this.w64(c+me,l)}this.w64(c+ee,d)}finally{this.inodeWriteUnlock(s)}}link(e,t){return this.withNamespaceLock(()=>this.linkUnlocked(e,t))}linkUnlocked(e,t){let r=this.pathResolve(e,!1);if(r<0)throw new A(r);let i=this.inodeOffset(r);if((this.r32(i+C)&W)===H)throw new A(Eu);let{parentIno:s,name:c}=this.pathResolveParent(t),a=ge.encode(c);this.inodeWriteLock(s);try{if(this.dirLookup(s,a)>=0)throw new A(Tt);let d=this.dirAddEntry(s,a,r);if(d<0)throw new A(d);this.inodeWriteLock(r);try{let l=this.r32(i+$);this.w32(i+$,l+1),this.w64(i+ee,Date.now())}finally{this.inodeWriteUnlock(r)}return{...this.namespaceEntryIdentity(r),linkCount:this.r32(i+$)}}finally{this.inodeWriteUnlock(s)}}readlink(e){return this.withNamespaceLock(()=>this.readlinkUnlocked(e))}readlinkUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new A(t);return this.readSymlinkInodeUnlocked(t)}readSymlinkInodeUnlocked(e){let t=this.inodeOffset(e);if((this.r32(t+C)&W)!==wt)throw new A(j);let i=this.r64(t+L);if(i<=40)return It(this.u8.subarray(t+oe,t+oe+i));this.inodeReadLock(e);try{let o=new Uint8Array(i);return this.inodeReadData(e,0,o,i),ur.decode(o)}finally{this.inodeReadUnlock(e)}}opendir(e){return this.withNamespaceLock(()=>this.opendirUnlocked(e))}opendirUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new A(t);let r=this.inodeOffset(t);if((this.r32(r+C)&W)!==H)throw new A(Fe);let o=this.fdAlloc(t,at,!0);if(o<0)throw new A(o);return o}readdirEntry(e){return this.withNamespaceLock(()=>this.readdirEntryUnlocked(e))}readdirEntryUnlocked(e){let t=this.fdGet(e);if(!t||!t.isDir)throw new A(re);let r=this.inodeOffset(t.ino),i=this.r64(r+L);for(;t.offset=this.r32(Ye))throw new A(K);let p=this.r32(ct)*4096;if((this.r32(p+(d>>5)*4)&1<<(d&31))===0)throw new A(K);let y=It(this.u8.subarray(u+F,u+F+h)),_=this.buildStat(d);return this.w64(f+ke,m),t.offset=m,{name:y,stat:_}}return null}closedir(e){this.close(e)}readdir(e){let t=this.opendir(e),r=[];try{let i;for(;(i=this.readdirEntry(t))!==null;)i.name!=="."&&i.name!==".."&&r.push(i.name)}finally{this.closedir(t)}return r}writeFile(e,t){let r=typeof t=="string"?ge.encode(t):t,i=this.open(e,Vo|cr|dr);try{this.write(i,r)}finally{this.close(i)}}readFile(e){let t=this.open(e,at);try{let r=this.fstat(t),i=new Uint8Array(r.size);return this.read(t,i),i}finally{this.close(t)}}readFileText(e){return ur.decode(this.readFile(e))}};function ss(n,e){let t=new Map,r=new Map;for(let s of n){if(t.has(s.path))throw new Error(`${e} duplicates path ${s.path}`);if(t.set(s.path,s),s.type==="file"){if(!s.inodeGroup)throw new Error(`${e} file ${s.path} has no inode group`);if(r.has(s.inodeGroup))throw new Error(`${e} inode group ${s.inodeGroup} has multiple files`);r.set(s.inodeGroup,s)}}let i=new Set,o=new Map;for(let s of n){if(s.type!=="hardlink"||o.has(s.path))continue;let c=[],a=s,u;for(;a.type==="hardlink";){let l=o.get(a.path);if(l){u=l;break}if(i.has(a.path))throw new Error(`${e} hardlink cycle reaches ${a.path}`);if(i.add(a.path),c.push(a),!a.target)throw new Error(`${e} hardlink ${a.path} has no target`);let h=t.get(a.target);if(!h)throw new Error(`${e} hardlink ${a.path} target ${a.target} is missing`);if(h.type!=="file"&&h.type!=="hardlink"||!a.inodeGroup||h.inodeGroup!==a.inodeGroup||h.size!==a.size||h.mode!==a.mode)throw new Error(`${e} hardlink ${a.path} has an invalid target`);a=h}u??=a.type==="file"?a:void 0;let d=r.get(s.inodeGroup??"");if(!u||u!==d)throw new Error(`${e} hardlink ${s.path} does not resolve to its inode`);for(let l=c.length-1;l>=0;l-=1){let h=c[l];if(r.get(h.inodeGroup??"")!==u)throw new Error(`${e} hardlink ${h.path} does not resolve to its inode`);i.delete(h.path),o.set(h.path,u)}}return{canonicalByGroup:r,canonicalTargetByPath:o}}var D={maxArchiveBytes:268435456,maxExpandedBytes:268435456,maxPayloadBytes:268435456,maxEntries:1e5,maxPathBytes:4096,maxSymlinkTargetBytes:65536,maxStringBytes:8192,maxTransportsPerTree:8,maxActivationCapabilities:32,maxActivationRoots:64,maxActivationCapabilityBytes:255,maxMaterializationAssertions:32,maxMaterializationAssertionBytes:1048576,maxMaterializationRecipes:32,maxMaterializationTransforms:1e5,maxMaterializationDecodedBytes:8388608,maxTransformReplacements:32,maxTransformPatternBytes:8192},Le={maxArchiveBytes:512*1024*1024,maxExpandedBytes:512*1024*1024,maxPayloadBytes:512*1024*1024,maxEntries:1e5,maxGroups:512};function as(n,e="Deferred tree collection"){for(let[t,r]of Object.entries(n))if(!Number.isSafeInteger(r)||r<0)throw new Error(`${e} ${t} usage is invalid`);if(n.groups>Le.maxGroups)throw new Error(`${e} exceeds the ${Le.maxGroups}-group cap`);if(n.archiveBytes>Le.maxArchiveBytes)throw new Error(`${e} exceeds the archive-byte cap`);if(n.expandedBytes>Le.maxExpandedBytes)throw new Error(`${e} exceeds the expansion cap`);if(n.payloadBytes>Le.maxPayloadBytes)throw new Error(`${e} exceeds the payload-byte cap`);if(n.entries>Le.maxEntries)throw new Error(`${e} exceeds the entry-count cap`)}function Rt(n,e="Canonical text"){for(let t=0;t57343)){if(r<=56319&&t+1=56320&&n.charCodeAt(t+1)<=57343){t+=1;continue}throw new Error(`${e} must contain only Unicode scalar values`)}}}function lr(n,e){Rt(n),Rt(e);let t=0,r=0;for(;t65535?2:1,r+=o>65535?2:1}return tD.maxEntries)throw new Error("Lazy tree materialization source inventory is unbounded");let t=new Map;for(let[h,m]of e.entries.entries()){let f=ni(m.sourcePath,`Lazy tree materialization source ${h} path`);if(t.has(f))throw new Error(`Lazy tree materialization source repeats ${f}`);if(m.type!=="directory"&&m.type!=="file"&&m.type!=="symlink"&&m.type!=="hardlink")throw new Error(`Lazy tree materialization source ${f} has invalid type`);ps(m.size,`Lazy tree materialization source ${f} byte count`,0,D.maxPayloadBytes),t.set(f,m)}let r=bt(n,["schema","kind","assertions","recipes","transforms"],"Lazy tree materialization plan");if(r.schema!==1||r.kind!=="archive-byte-transforms-v1")throw new Error("Lazy tree materialization plan has an unsupported identity");let i=0,o=new Set,s=pr(r.assertions,"Lazy tree materialization assertions",0,D.maxMaterializationAssertions).map((h,m)=>{let f=bt(h,["sourcePath","bytesHex"],`Lazy tree materialization assertion ${m}`),p=ni(f.sourcePath,`Lazy tree materialization assertion ${m} source path`);if(o.has(p))throw new Error(`Lazy tree materialization repeats assertion ${p}`);o.add(p);let g=t.get(p);if(g?.type!=="file")throw new Error(`Lazy tree materialization assertion ${p} is not a regular source`);let y=hr(f.bytesHex,`Lazy tree materialization assertion ${p} bytes`,D.maxMaterializationAssertionBytes,!0);if(i=Qr(i,y.length/2),y.length/2!==g.size)throw new Error(`Lazy tree materialization assertion ${p} size differs from source`);return{sourcePath:p,bytesHex:y}}),c=new Map,a=pr(r.recipes,"Lazy tree materialization recipes",0,D.maxMaterializationRecipes).map((h,m)=>{let f=fs(h,`Lazy tree materialization recipe ${m}`);if(c.has(f.recipe.id))throw new Error(`Lazy tree materialization duplicates recipe ${f.recipe.id}`);return i=Qr(i,f.decodedBytes),c.set(f.recipe.id,f.recipe),f.recipe}),u=new Set,d=new Set,l=pr(r.transforms,"Lazy tree materialization transforms",0,D.maxMaterializationTransforms).map((h,m)=>{let f=bt(h,["sourcePath","recipe","input","output"],`Lazy tree materialization transform ${m}`),p=ni(f.sourcePath,`Lazy tree materialization transform ${m} source path`);if(u.has(p))throw new Error(`Lazy tree materialization repeats transform ${p}`);u.add(p);let g=t.get(p);if(g?.type!=="file")throw new Error(`Lazy tree materialization transform ${p} is not a regular source`);let y=si(f.recipe,`Lazy tree materialization transform ${p} recipe`,D.maxStringBytes);if(!c.has(y))throw new Error(`Lazy tree materialization transform ${p} has no recipe ${y}`);d.add(y);let _=cs(f.input,`Lazy tree materialization transform ${p} input`),E=cs(f.output,`Lazy tree materialization transform ${p} output`);if(_.bytes!==g.size)throw new Error(`Lazy tree materialization transform ${p} input size differs from source`);return{sourcePath:p,recipe:y,input:_,output:E}});if(s.length===0&&l.length===0)throw new Error("Lazy tree materialization plan has no assertions or transforms");if(a.some(h=>!d.has(h.id)))throw new Error("Lazy tree materialization plan contains an unused recipe");if(!ii(s.map(h=>h.sourcePath))||!ii(a.map(h=>h.id))||!ii(l.map(h=>h.sourcePath)))throw new Error("Lazy tree materialization plan is not in canonical order");return{schema:1,kind:"archive-byte-transforms-v1",assertions:s,recipes:a,transforms:l}}function fr(n){let e=hr(n,"Materialization bytes",D.maxMaterializationDecodedBytes,!0),t=new Uint8Array(e.length/2);for(let r=0;rD.maxPayloadBytes)throw new Error("Lazy tree byte transform exceeds its source-byte limit");let t=fs(e,"Lazy tree byte transform recipe").recipe,r=n;for(let i of t.replacements)r=Iu(r,fr(i.matchHex),fr(i.replacementHex));for(let i of t.rejectHex)if(Tu(r,fr(i)))throw new Error(`Lazy tree byte transform retains rejected byte sequence ${i}`);return r}function fs(n,e){let t=bt(n,["id","replacements","rejectHex"],e),r=si(t.id,`${e} id`,D.maxStringBytes);if(!Ru(r))throw new Error(`${e} id is invalid`);let i=0,o=pr(t.replacements,`${e} replacements`,0,D.maxTransformReplacements).map((c,a)=>{let u=bt(c,["matchHex","replacementHex"],`${e} replacement ${a}`),d=hr(u.matchHex,`${e} match`,D.maxTransformPatternBytes,!1),l=hr(u.replacementHex,`${e} replacement`,D.maxTransformPatternBytes,!0);return i=Qr(i,d.length/2+l.length/2),{matchHex:d,replacementHex:l}}),s=pr(t.rejectHex,`${e} rejected patterns`,0,D.maxTransformReplacements).map((c,a)=>{let u=hr(c,`${e} rejected pattern ${a}`,D.maxTransformPatternBytes,!1);return i=Qr(i,u.length/2),u});if(o.length===0&&s.length===0||new Set(s).size!==s.length)throw new Error(`${e} is empty or ambiguous`);return{recipe:{id:r,replacements:o,rejectHex:s},decodedBytes:i}}function Iu(n,e,t){let r=0;for(let u=0;u<=n.byteLength-e.byteLength;)oi(n,e,u)?(r+=1,u+=e.byteLength):u+=1;if(r===0)return n;let i=t.byteLength-e.byteLength,o=n.byteLength+r*i;if(!Number.isSafeInteger(o)||o<0||o>D.maxPayloadBytes)throw new Error("Lazy tree byte transform exceeds its transformed-byte limit");let s=new Uint8Array(o),c=0,a=0;for(;cn.byteLength)return!1;for(let t=0;t<=n.byteLength-e.byteLength;t+=1)if(oi(n,e,t))return!0;return!1}function oi(n,e,t){if(t+e.byteLength>n.byteLength)return!1;for(let r=0;rs!==o[c]))throw new Error(`${t} has unexpected fields`);return r}function pr(n,e,t,r){if(!Array.isArray(n)||n.lengthr)throw new Error(`${e} must contain ${t} to ${r} items`);return n}function si(n,e,t){if(typeof n!="string")throw new Error(`${e} is invalid or exceeds ${t} bytes`);if(Rt(n,e),n.length===0||n.includes("\0")||new TextEncoder().encode(n).byteLength>t)throw new Error(`${e} is invalid or exceeds ${t} bytes`);return n}function ps(n,e,t,r){if(!Number.isSafeInteger(n)||Number(n)r)throw new Error(`${e} must be an integer between ${t} and ${r}`);return Number(n)}function hr(n,e,t,r){if(typeof n!="string"||!r&&n.length===0||n.length%2!==0||n.length/2>t||!hs(n))throw new Error(`${e} is not canonical bounded hexadecimal bytes`);return n}function ni(n,e){let t=si(n,e,D.maxPathBytes);if(t.startsWith("/")||t.includes("\\")||t.split("/").some(r=>r===""||r==="."||r===".."))throw new Error(`${e} is not a canonical relative path`);return t}function Qr(n,e){let t=n+e;if(!Number.isSafeInteger(t)||t>D.maxMaterializationDecodedBytes)throw new Error("Lazy tree materialization plan exceeds its decoded byte limit");return t}function ii(n){return n.every((e,t)=>t===0||lr(n[t-1],e)<0)}function Ru(n){if(!us(n.charCodeAt(0)))return!1;for(let e=1;e=97&&n<=122||n>=48&&n<=57}function hs(n,e){if(e!==void 0&&n.length!==e)return!1;for(let t=0;t=48&&r<=57)&&!(r>=97&&r<=102))return!1}return!0}var lt=Reflect.apply,_d=Object.create,gd=Object.defineProperties,ki=Object.freeze,Ed=Object.getOwnPropertyDescriptors,Sd=Object.setPrototypeOf;var Bs=SharedArrayBuffer,wd=Uint8Array,Od=Uint8Array.prototype.set,Ad=WeakSet.prototype.add,Wf=WeakSet.prototype.has,zd=WeakMap.prototype.get,xd=WeakMap.prototype.set,Gf=Set.prototype.has,Hf=Map.prototype.get;var Id=Number.isInteger,Td=TypeError,Rd=le.mount,bd=le.mkfs,Ld=le.prototype.snapshotState,vd=new WeakSet,$s=new WeakMap,Pd=1;function na(n){let e=_d(null);return gd(e,Ed(n)),ki(e)}var kd=na(le.prototype),Vf=ki({kind:"nosuid"}),qf=ki({kind:"trusted-root-product",guestWritable:!1,stableExecutableIdentity:!0}),Fd=Symbol("DeferredTreeMaterializationHandle"),Er=[40,181,47,253],Ii=1447449417,Ti=1,Si=1,sn=2,wi=4,Oi=8,ye=16,{S_IFMT:Se,S_IFREG:kt,S_IFDIR:Qe,S_IFLNK:Sr}=ie,{DT_UNKNOWN:Nd,DT_REG:Cd,DT_DIR:Md,DT_LNK:Dd}=Ao,Kd=He.O_RDONLY,Zf=He.O_ACCMODE,Yf=He.O_CREAT,Xf=He.O_TRUNC,jf=wo.W_OK,Us=He.O_WRONLY|He.O_CREAT|He.O_TRUNC,Bd=1024*1024,$d=16*1024*1024,Ct=64*1024,an=16*1024*1024,cn=16*1024*1024,Ws=D.maxArchiveBytes,Ud=D.maxExpandedBytes,un=D.maxPayloadBytes,Wd=2,Gd=4,Mt=D.maxEntries,ia=Le.maxGroups,pn=D.maxPathBytes,oa=D.maxSymlinkTargetBytes,Fi=D.maxStringBytes,Hd=D.maxActivationCapabilities,Vd=D.maxActivationRoots,Gs=D.maxActivationCapabilityBytes,Hs=4294967294,Vs=3,qd=250,sa=5e3,Ri=/^[0-9a-f]{64}$/,wr="kandelo-legacy-zip-v1",Or="kandelo-deferred-tree-v1",bi="kandelo-deferred-tree-v2",ft="kandelo-deferred-tree-v3",Zd=new Set(["ECONNABORTED","ECONNREFUSED","ECONNRESET","EHOSTUNREACH","ENETDOWN","ENETRESET","ENETUNREACH","EPIPE","ETIMEDOUT","EAI_AGAIN","UND_ERR_CONNECT_TIMEOUT","UND_ERR_HEADERS_TIMEOUT","UND_ERR_SOCKET"]),dn=class extends Error{constructor(t,r){super(`HTTP ${t}`);this.status=t;this.retryAfterMs=r;this.name="LazyHttpResponseError"}status;retryAfterMs};function Ar(n){if(typeof n!="string"||!n.startsWith("/")||new TextEncoder().encode(n).byteLength>pn||n.includes("\0")||n.includes("\\"))throw new Error(`Lazy archive mount prefix must be an absolute POSIX path: ${JSON.stringify(n)}`);let e=n.replace(/\/+$/,"");if(e==="")return"/";if(e.slice(1).split("/").some(r=>r===""||r==="."||r===".."))throw new Error(`Lazy archive mount prefix is not canonical: ${JSON.stringify(n)}`);return e}function Yd(n,e,t,r){let i=Ar(t),o=new Map,s=e.map(c=>{let a=c.fileName,u=`Lazy archive ${JSON.stringify(n)} member ${JSON.stringify(a)}`;if(a.length===0)throw new Error(`${u} has an empty path`);if(a.includes("\0"))throw new Error(`${u} contains a NUL byte`);if(a.includes("\\"))throw new Error(`${u} contains a backslash`);if(a.startsWith("/")||/^[A-Za-z]:\//.test(a))throw new Error(`${u} must be relative, not absolute`);if(c.isDirectory&&c.isSymlink)throw new Error(`${u} has conflicting directory and symlink types`);if(c.isDirectory!==a.endsWith("/"))throw new Error(`${u} has inconsistent directory metadata`);let d=c.isDirectory?a.slice(0,-1):a,l=d.split("/");if(d.length===0||l.some(h=>h===""||h==="."||h===".."))throw new Error(`${u} is not a canonical relative POSIX path`);if(o.has(d))throw new Error(`${u} collides with another member at ${JSON.stringify(d)}`);if(c.isSymlink&&!r?.has(a))throw new Error(`Lazy archive symlink target was not provided: ${a}`);return o.set(d,c),{entry:c,archivePath:d,vfsPath:i==="/"?`/${d}`:`${i}/${d}`}});for(let{archivePath:c}of s){let a=c.split("/");for(let u=1;uCt)throw new Error(`VFS image metadata exceeds ${Ct} bytes`);let e;try{e=JSON.parse(new TextDecoder().decode(n))}catch(t){let r=t instanceof Error?t.message:String(t);throw new Error(`Invalid VFS image metadata JSON: ${r}`)}return Ni(e)}function jd(n){if(n===null)return new Uint8Array(0);let e=Ni(n),t=new TextEncoder().encode(JSON.stringify(e));if(t.byteLength>Ct)throw new Error(`VFS image metadata exceeds ${Ct} bytes`);return t}function Jd(n){return n.byteLength>=Er.length&&n[0]===Er[0]&&n[1]===Er[1]&&n[2]===Er[2]&&n[3]===Er[3]?Ol(n):n}function tn(n){let e=Jd(n);if(e.byteLengthan)throw new Error(`VFS image lazy metadata exceeds ${an} bytes`);if(n.byteLengthcn)throw new Error(`VFS image lazy archive metadata exceeds ${cn} bytes`);if(n.byteLength=0?r:void 0}function tl(n){return n===408||n===429||n>=500&&n<=599}function rl(n,e=Date.now()){let t=n?.get("retry-after")?.trim();if(!t)return;let r;if(/^\d+$/.test(t))r=Number(t)*1e3;else{let i=Date.parse(t);if(!Number.isFinite(i))return;r=Math.max(0,i-e)}if(!(!Number.isSafeInteger(r)||r<0))return Math.min(r,sa)}function nl(n){if(!(typeof n!="object"||n===null||!("cause"in n)))return n.cause}function aa(n){if(!(typeof n!="object"||n===null||!("name"in n)))return typeof n.name=="string"?n.name:void 0}function ca(n){if(!(typeof n!="object"||n===null||!("code"in n)))return typeof n.code=="string"?n.code:void 0}function ua(n,e){let t=new Set,r=n;for(let i=0;r!==void 0&&i<8;i+=1){if(t.has(r))return!1;if(t.add(r),e(r))return!0;r=nl(r)}return!1}function da(n){return ua(n,e=>aa(e)==="AbortError"||ca(e)==="ABORT_ERR")}function il(n){return da(n)?!1:ua(n,e=>{let t=aa(e),r=ca(e);return e instanceof TypeError||t==="NetworkError"||t==="TimeoutError"||r!==void 0&&Zd.has(r)})}function ol(n,e){if(n instanceof dn){if(!tl(n.status))return null;if(n.retryAfterMs!==void 0)return n.retryAfterMs}else if(!il(n))return null;return Math.min(qd*2**e,sa)}function te(n){if(n?.aborted)throw n.reason}function sl(n,e){return te(e),n===0?Promise.resolve():new Promise((t,r)=>{let i=setTimeout(()=>c(!1),n),o=()=>c(!0,e.reason),s=!1;function c(a,u){s||(s=!0,clearTimeout(i),e?.removeEventListener("abort",o),a?r(u):t())}e?.addEventListener("abort",o,{once:!0}),e?.aborted&&o()})}async function Ai(n,e){try{await n.body?.cancel(e)}catch{}}function al(n,e){if(n.length===1)return n[0];let t=new Uint8Array(e),r=0;for(let i of n)t.set(i,r),r+=i.byteLength;return t}function zr(n){if(n===void 0)return;if(typeof n!="object"||n===null||Array.isArray(n))throw new Error("Lazy archive integrity must be an object");let e=n;if(Object.keys(e).length!==2||!("sha256"in e)||!("bytes"in e))throw new Error("Lazy archive integrity has unexpected fields");if(typeof e.sha256!="string"||!Ri.test(e.sha256))throw new Error("Lazy archive integrity has an invalid SHA-256 digest");if(!Number.isSafeInteger(e.bytes)||Number(e.bytes)<=0||Number(e.bytes)>Ws)throw new Error(`Lazy archive integrity byte count must be between 1 and ${Ws}`);return{sha256:e.sha256,bytes:Number(e.bytes)}}function et(n,e,t){if(typeof n!="object"||n===null||Array.isArray(n))throw new Error(`${t} must be an object`);let r=n;if(Object.keys(r).length!==e.length||e.some(o=>!Object.prototype.hasOwnProperty.call(r,o)))throw new Error(`${t} has unexpected or missing fields`);return r}function Li(n,e,t,r){if(typeof n!="object"||n===null||Array.isArray(n))throw new Error(`${r} must be an object`);let i=n,o=new Set(e);if(Object.keys(i).some(s=>!o.has(s))||t.some(s=>!Object.prototype.hasOwnProperty.call(i,s)))throw new Error(`${r} has unexpected or missing fields`);return i}function Me(n,e,t,r){if(!Array.isArray(n)||n.lengthr)throw new Error(`${e} must contain ${t} to ${r} items`);return n}function we(n,e,t){if(typeof n!="string")throw new Error(`${e} is invalid or exceeds ${t} bytes`);if(Rt(n,e),n.length===0||n.includes("\0")||new TextEncoder().encode(n).byteLength>t)throw new Error(`${e} is invalid or exceeds ${t} bytes`);return n}function ce(n,e,t,r){if(!Number.isSafeInteger(n)||Number(n)r)throw new Error(`${e} must be an integer between ${t} and ${r}`);return Number(n)}function ln(n,e=1){let t=n,r=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.source!==void 0,i=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.modePolicy!==void 0,o=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.materialization!==void 0,s=et(n,["decoder","mediaType","sha256","bytes","expandedBytes","sourceEntryCount","transports",...i?["modePolicy"]:[],...r?["source"]:[],...o?["materialization"]:[]],"Lazy tree content"),c=s.decoder==="zip-v1"?"application/zip":s.decoder==="tar-gzip-v1"?"application/vnd.oci.image.layer.v1.tar+gzip":null;if(c===null||s.mediaType!==c)throw new Error("Lazy tree decoder and media type are inconsistent");let a=zr({sha256:s.sha256,bytes:s.bytes});if(!a)throw new Error("Lazy tree integrity is required");let u=Me(s.transports,"Lazy tree transports",e,D.maxTransportsPerTree).map((p,g)=>we(p,`Lazy tree transport ${g}`,Fi));if(new Set(u).size!==u.length)throw new Error("Lazy tree transports contain duplicates");let d=ce(s.expandedBytes,"Lazy tree expanded byte count",0,Ud),l=ce(s.sourceEntryCount,"Lazy tree source entry count",1,Mt),h=r?dl(s.source,s.decoder):void 0,m=o?ds(s.materialization,h):void 0,f=i?s.modePolicy:void 0;if(f!==void 0&&(f!=="portable-posix-v1"||s.decoder!=="zip-v1"||r))throw new Error("Lazy tree mode policy is invalid for its decoder");if(h!==void 0&&h.entries.length!==l)throw new Error("Lazy tree source inventory count differs from its content");return{decoder:s.decoder,mediaType:c,sha256:a.sha256,bytes:a.bytes,expandedBytes:d,sourceEntryCount:l,transports:u,...f===void 0?{}:{modePolicy:f},...h===void 0?{}:{source:h},...m===void 0?{}:{materialization:m}}}function la(n){let e={groups:n.length,archiveBytes:0,expandedBytes:0,payloadBytes:0,entries:0};for(let t of n)t.content===void 0||t.inventory===void 0||(e.archiveBytes+=t.content.bytes,e.expandedBytes+=t.content.expandedBytes,e.payloadBytes+=t.inventory.filter(r=>r.type==="file").reduce((r,i)=>r+i.size,0),e.entries+=t.inventory.length+(t.content.source?.entries.length??0));return e}function vi(n){as(n,"Serialized lazy tree collection")}function cl(n){vi(la(n))}function ul(n){let e=new Map;for(let t of n){let r=t.activation?.atomicGroup;if(r===void 0)continue;if(!Ft(r))throw new Error(`Serialized lazy atomic activation group ${r.id} is unsealed`);let i=e.get(r.id);if(i===void 0)i={expectedCount:r.expectedCount,cohortSha256:r.cohortSha256,members:new Set,descriptors:new Set},e.set(r.id,i);else if(i.expectedCount!==r.expectedCount||i.cohortSha256!==r.cohortSha256)throw new Error(`Serialized lazy atomic activation group ${r.id} has inconsistent seals`);if(i.members.has(r.member)||i.descriptors.has(r.descriptorSha256))throw new Error(`Serialized lazy atomic activation group ${r.id} duplicates a member`);i.members.add(r.member),i.descriptors.add(r.descriptorSha256)}for(let[t,r]of e)if(r.members.size!==r.expectedCount)throw new Error(`Serialized lazy atomic activation group ${t} has ${r.members.size} of ${r.expectedCount} members`)}function Xs(n){for(let[e,t]of n.entries())if(t.kind===Or||t.kind===bi||t.kind===ft)ha(t,t.kind);else if(t.kind===wr)Pi(t,!1);else throw new Error(`Serialized lazy archive group ${e} has an unsupported kind`);cl(n),ul(n)}function dl(n,e){if(e!=="zip-v1"&&e!=="tar-gzip-v1")throw new Error("Lazy tree source inventory requires a supported archive decoder");let t=et(n,["schema","kind","entries"],"Lazy tree source inventory");if(t.schema!==1||t.kind!=="archive-source-inventory-v1")throw new Error("Lazy tree source inventory has an unsupported identity");let r=new Map,i=Me(t.entries,"Lazy tree source entries",1,Mt).map((s,c)=>{let a=s,u=typeof a=="object"&&a!==null&&!Array.isArray(a)?a.type:void 0,d=u==="directory"||u==="file"?["sourcePath","type","mode","size"]:u==="symlink"||u==="hardlink"?["sourcePath","type","mode","size","target"]:null;if(d===null)throw new Error(`Lazy tree source entry ${c} has invalid type`);let l=et(s,d,`Lazy tree source entry ${c}`),h=Oe(l.sourcePath,!1,`Lazy tree source entry ${c} path`);if(r.has(h))throw new Error(`Lazy tree source inventory duplicates ${h}`);let m=ce(l.mode,`Lazy tree source entry ${h} mode`,0,ie.S_MODE_BITS),f=ce(l.size,`Lazy tree source entry ${h} size`,0,un),p;if((u==="directory"||u==="symlink"||u==="hardlink")&&f!==0)throw new Error(`Lazy tree source ${h} has payload for ${String(u)}`);u==="symlink"?p=we(l.target,`Lazy tree source symlink ${h} target`,oa):u==="hardlink"&&(p=Oe(l.target,!1,`Lazy tree source hardlink ${h} target`));let g={sourcePath:h,type:u,mode:m,size:f,...p===void 0?{}:{target:p}};return r.set(h,g),g}),o=i.map(s=>s.sourcePath);if(o.some((s,c)=>c>0&&lr(o[c-1],s)>=0))throw new Error("Lazy tree source inventory is not in canonical path order");return{schema:1,kind:"archive-source-inventory-v1",entries:i}}function ll(n){let e=new Map(n.map(r=>[r.sourcePath,r])),t=new Map;for(let r of n){if(r.type!=="hardlink"||t.has(r.sourcePath))continue;let i=[],o=new Set,s=r,c;for(;s.type==="hardlink"&&(c=t.get(s.sourcePath),c===void 0);){if(o.has(s.sourcePath))throw new Error(`Lazy tree source hardlink cycle includes ${s.sourcePath}`);o.add(s.sourcePath),i.push(s);let a=e.get(s.target);if(a===void 0)throw new Error(`Lazy tree source hardlink ${s.sourcePath} target is absent`);if(a.type!=="file"&&a.type!=="hardlink")throw new Error(`Lazy tree source hardlink ${s.sourcePath} target is not regular`);s=a}c===void 0&&(c=s);for(let a of i)t.set(a.sourcePath,c)}return t}function Oe(n,e,t,r=!1){if(typeof n!="string"||n.length===0||new TextEncoder().encode(n).byteLength>pn||n.includes("\0")||n.includes("\\")||n.startsWith("/")!==e)throw new Error(`${t} is not a canonical ${e?"absolute":"relative"} path`);if(r&&e&&n==="/")return n;if(n.slice(e?1:0).split("/").some(o=>o===""||o==="."||o===".."))throw new Error(`${t} has an unsafe path segment`);return n}function fa(n){let e=typeof n=="object"&&n!==null&&!Array.isArray(n)?n:null,t=e!==null&&(Object.hasOwn(e,"descriptorSha256")||Object.hasOwn(e,"expectedCount")||Object.hasOwn(e,"cohortSha256")),r=et(n,t?["id","member","descriptorSha256","expectedCount","cohortSha256"]:["id","member"],"Lazy tree atomic activation membership"),i=we(r.id,"Lazy tree atomic activation group",Gs),o=we(r.member,"Lazy tree atomic activation member",Gs);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(i)||!/^[a-z0-9][a-z0-9:+._/-]*$/.test(o)||o.includes("//")||o.endsWith("/"))throw new Error("Lazy tree atomic activation membership is invalid");if(!t)return{id:i,member:o};let s=we(r.descriptorSha256,"Lazy tree atomic member descriptor digest",64),c=we(r.cohortSha256,"Lazy tree atomic cohort digest",64);if(!Ri.test(s)||!Ri.test(c))throw new Error("Lazy tree atomic activation digest is invalid");return{id:i,member:o,descriptorSha256:s,expectedCount:ce(r.expectedCount,"Lazy tree atomic activation expected member count",1,ia),cohortSha256:c}}function Ft(n){return n.descriptorSha256!==void 0&&n.expectedCount!==void 0&&n.cohortSha256!==void 0}function fl(n){let e=et(n,["uid","gid"],"Lazy tree registration owner");return{uid:ce(e.uid,"Lazy tree registration owner uid",0,Hs),gid:ce(e.gid,"Lazy tree registration owner gid",0,Hs)}}function pa(n,e,t,r,i=1){let o=ln(n,i),s=Ar(t),c=["mode","capabilities","roots",...typeof r=="object"&&r!==null&&!Array.isArray(r)&&Object.hasOwn(r,"atomicGroup")?["atomicGroup"]:[]],a=et(r,c,"Lazy tree activation");if(a.mode!=="boot-prefetch"&&a.mode!=="first-use")throw new Error("Lazy tree activation mode is invalid");let u=Me(a.capabilities,"Lazy tree activation capabilities",1,Hd).map((z,x)=>{let I=we(z,`Lazy tree activation capability ${x}`,D.maxActivationCapabilityBytes);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(I))throw new Error(`Lazy tree activation capability ${x} is invalid`);return I}),d=Me(a.roots,"Lazy tree activation roots",1,Vd).map((z,x)=>Oe(z,!0,`Lazy tree activation root ${x}`,!0));if(new Set(u).size!==u.length||new Set(d).size!==d.length)throw new Error("Lazy tree activation contains duplicates");let l=a.atomicGroup===void 0?void 0:fa(a.atomicGroup);if(l!==void 0&&a.mode!=="first-use")throw new Error("Lazy tree atomic activation group requires a valid first-use identity");let h={mode:a.mode,capabilities:u,roots:d,...l===void 0?{}:{atomicGroup:l}},m=Me(e,"Lazy tree inventory",1,Mt),f=[],p=new Map,g=new Map,y=o.source===void 0?void 0:new Map(o.source.entries.map(z=>[z.sourcePath,z])),_=o.source===void 0?void 0:ll(o.source.entries),E=new Map(o.materialization?.transforms.map(z=>[z.sourcePath,z])??[]),w=0;for(let[z,x]of m.entries()){if(typeof x!="object"||x===null||Array.isArray(x))throw new Error(`Lazy tree entry ${z} must be an object`);let I=x.type,R=I==="directory"?["vfsPath","sourcePath","type","mode","size"]:I==="file"?["vfsPath","sourcePath","type","mode","size","inodeGroup"]:I==="symlink"?["vfsPath","sourcePath","type","mode","size","target"]:I==="hardlink"?["vfsPath","sourcePath","type","mode","size","target","inodeGroup"]:null;if(!R)throw new Error(`Lazy tree entry ${z} has an invalid type`);let b=et(x,[...R,...y===void 0?[]:["materialization"]],`Lazy tree entry ${z}`),P=Oe(b.vfsPath,!0,`Lazy tree entry ${z} VFS path`),Z=Oe(b.sourcePath,!1,`Lazy tree entry ${z} source path`),N=y===void 0?void 0:b.materialization;if(y!==void 0&&N!=="archive"&&N!=="archive-copy"&&N!=="archive-copy-mode"&&N!=="descriptor")throw new Error(`Lazy tree entry ${P} has invalid materialization provenance`);if(s!=="/"&&P!==s&&!P.startsWith(`${s}/`))throw new Error(`Lazy tree entry ${P} escapes its mount prefix`);if(p.has(P))throw new Error(`Lazy tree duplicates VFS path ${P}`);let k=ce(b.mode,`Lazy tree entry ${P} mode`,0,ie.S_MODE_BITS),G=ce(b.size,`Lazy tree entry ${P} size`,0,un),ue,Ae;if(I==="directory"){if(G!==0)throw new Error(`Lazy tree directory ${P} has nonzero size`)}else if(I==="symlink"){if(ue=we(b.target,`Lazy tree symlink ${P} target`,oa),new TextEncoder().encode(ue).byteLength!==G)throw new Error(`Lazy tree symlink ${P} size differs from its target`)}else Ae=we(b.inodeGroup,`Lazy tree entry ${P} inode group`,pn),I==="hardlink"&&(ue=Oe(b.target,!0,`Lazy tree hardlink ${P} target`));if(I!=="hardlink"&&(w+=G,w>un))throw new Error("Lazy tree inventory exceeds the expansion limit");let M={vfsPath:P,sourcePath:Z,...N===void 0?{}:{materialization:N},type:I,mode:k,size:G,...ue===void 0?{}:{target:ue},...Ae===void 0?{}:{inodeGroup:Ae}};if(y===void 0){let de=g.get(Z);if(de){if(o.decoder!=="zip-v1"||M.type!=="hardlink"||de.inodeGroup!==M.inodeGroup)throw new Error(`Lazy tree duplicates source path ${Z}`)}else{if(o.decoder==="zip-v1"&&M.type==="hardlink")throw new Error(`Lazy ZIP hardlink ${P} does not reuse a canonical source path`);g.set(Z,M)}}else if(M.materialization==="descriptor"){if(M.type!=="directory"&&M.type!=="symlink")throw new Error(`Lazy tree descriptor entry ${P} is not structural`);if(y.has(Z))throw new Error(`Lazy tree descriptor entry ${P} impersonates a source member`)}else{let de=y.get(Z);if(de===void 0)throw new Error(`Lazy tree entry ${P} names absent source ${Z}`);if(M.materialization==="archive-copy"||M.materialization==="archive-copy-mode"){if(M.type!=="file"||de.type!=="file"||M.materialization==="archive-copy"&&M.mode!==de.mode)throw new Error(`Lazy tree archive copy ${P} differs from its source`)}else if(de.type!==M.type||M.type==="symlink"&&de.target!==M.target||M.type!=="hardlink"&&de.mode!==M.mode)throw new Error(`Lazy tree archive entry ${P} differs from its source`)}f.push(M),p.set(P,M)}for(let z of f){let x=z.vfsPath.split("/").filter(Boolean);for(let I=1;I({path:z.vfsPath,type:z.type,mode:z.mode,size:z.size,target:z.target,inodeGroup:z.inodeGroup})),"Lazy tree");if(y!==void 0){let z=new Set;for(let x of f){if(x.materialization==="descriptor"||x.type!=="file"&&x.type!=="hardlink")continue;let I=y.get(x.sourcePath),R=I.type==="file"?I:_.get(I.sourcePath);R?.type==="file"&&z.add(R.sourcePath);let b=R?.type==="file"?E.get(R.sourcePath):void 0;if(R?.type!=="file"||x.size!==(b?.output.bytes??R.size))throw new Error(`Lazy tree archive entry ${x.vfsPath} differs from its source`)}for(let x of E.keys())if(!z.has(x))throw new Error(`Lazy tree materialization transform ${x} has no destination`);for(let x of f){if(x.type!=="hardlink"||x.materialization!=="archive")continue;let I=y.get(x.sourcePath),R=p.get(x.target),b=_.get(I.sourcePath);if(I.target!==R?.sourcePath||b?.type!=="file"||b.mode!==x.mode||R?.mode!==x.mode)throw new Error(`Lazy tree hardlink ${x.vfsPath} differs from its source`)}}if(o.sourceEntryCount!==(y===void 0?g.size:y.size))throw new Error("Lazy tree source entry count differs from its inventory");if(o.source===void 0&&o.expandedBytesx.vfsPath===z||x.vfsPath.startsWith(`${z}/`)))throw new Error(`Lazy tree activation root ${z} is not owned by its inventory`);let O=new Map;for(let z of f)z.type==="file"&&O.set(z.inodeGroup,z);if(O.size!==S.canonicalByGroup.size)throw new Error("Lazy tree regular inode inventory is inconsistent");return{content:o,entries:f,mountPrefix:s,activation:h,canonicalByGroup:O}}function fn(n){return JSON.stringify([n.sourcePath,n.type,n.inodeGroup,n.target])}function Pi(n,e){let t=Li(n,["kind","content","url","mountPrefix","integrity","materialized","entries"],["url","mountPrefix","materialized","entries"],"Serialized legacy lazy archive");if(t.kind===void 0){if(!e)throw new Error("Serialized lazy archive is missing its kind discriminator")}else if(t.kind!==wr)throw new Error("Serialized legacy lazy archive has an unsupported kind");let r=we(t.url,"Serialized legacy lazy archive URL",Fi),i=Ar(t.mountPrefix),o=zr(t.integrity);if(t.content!==void 0){if(!e||t.kind!==void 0)throw new Error("Typed legacy lazy archives cannot carry generic content");let a=ln(t.content);if(a.decoder!=="zip-v1"||a.transports.length!==1||a.transports[0]!==r||!o||a.sha256!==o.sha256||a.bytes!==o.bytes)throw new Error("Untagged legacy ZIP content identity is inconsistent")}if(t.materialized!==!1)throw new Error("Serialized legacy lazy archive must describe pending content");let s=new Set,c=Me(t.entries,"Serialized legacy lazy archive entries",1,Mt).map((a,u)=>{let d=Li(a,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","size","isSymlink","deleted"],`Serialized legacy lazy archive entry ${u}`),l=Oe(d.vfsPath,!0,`Serialized legacy lazy archive entry ${u} VFS path`);if(s.has(l))throw new Error(`Serialized legacy lazy archive duplicates path ${l}`);s.add(l);let h=ce(d.ino,`Serialized legacy lazy archive entry ${l} inode`,1,Number.MAX_SAFE_INTEGER),m=d.generation===void 0?void 0:ce(d.generation,`Serialized legacy lazy archive entry ${l} generation`,0,Number.MAX_SAFE_INTEGER),f=d.dataSequence===void 0?void 0:ce(d.dataSequence,`Serialized legacy lazy archive entry ${l} data sequence`,0,Number.MAX_SAFE_INTEGER),p=ce(d.size,`Serialized legacy lazy archive entry ${l} size`,0,un);if(d.isSymlink!==!1||d.deleted!==!1||d.materialized!==void 0&&d.materialized!==!1)throw new Error(`Serialized legacy lazy archive entry ${l} is not pending`);if(d.type!==void 0&&d.type!=="file")throw new Error(`Serialized legacy lazy archive entry ${l} has an invalid type`);let g=d.archivePath===void 0?void 0:Oe(d.archivePath,!1,`Serialized legacy lazy archive entry ${l} archive path`),y=d.sourcePath===void 0?void 0:Oe(d.sourcePath,!1,`Serialized legacy lazy archive entry ${l} source path`),_=d.inodeGroup===void 0?void 0:we(d.inodeGroup,`Serialized legacy lazy archive entry ${l} inode group`,pn);if(d.target!==void 0)throw new Error(`Serialized legacy lazy archive entry ${l} has a link target`);return{vfsPath:l,ino:h,...m===void 0?{}:{generation:m},...f===void 0?{}:{dataSequence:f},size:p,isSymlink:!1,deleted:!1,materialized:!1,...g===void 0?{}:{archivePath:g},...y===void 0?{}:{sourcePath:y},type:"file",..._===void 0?{}:{inodeGroup:_}}});return{kind:wr,url:r,mountPrefix:i,...o===void 0?{}:{integrity:o},materialized:!1,entries:c}}function ha(n,e){let t=et(n,["kind","content","inventory","activation","url","mountPrefix","integrity","materialized","entries"],"Serialized lazy tree");if(t.kind!==e)throw new Error("Serialized lazy tree has an unsupported kind");let r=pa(t.content,t.inventory,t.mountPrefix,t.activation);if(e!==ft&&e===Or!=(r.content.source===void 0))throw new Error(e===Or?"Serialized deferred-tree-v1 cannot contain complete source metadata":"Serialized deferred-tree-v2 requires complete source metadata");let i=r.activation.atomicGroup;if(e===ft?i===void 0||!Ft(i):i!==void 0)throw new Error(e===ft?"Serialized deferred-tree-v3 requires a sealed atomic activation":"Atomic activation requires serialized deferred-tree-v3");let o=we(t.url,"Serialized lazy tree URL",Fi);if(o!==r.content.transports[0])throw new Error("Serialized lazy tree URL differs from its primary transport");let s=zr(t.integrity);if(!s||s.sha256!==r.content.sha256||s.bytes!==r.content.bytes)throw new Error("Serialized lazy tree integrity differs from its content");if(t.materialized!==!1)throw new Error("Serialized lazy tree must describe pending content");let c=new Map(r.entries.map(h=>[h.vfsPath,h])),a=new Map(r.entries.map(h=>[fn(h),h])),u=Me(t.entries,"Serialized lazy tree entries",0,Mt),d=new Set,l=u.map((h,m)=>{let f=Li(h,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup"],`Serialized lazy tree entry ${m}`),p=Oe(f.vfsPath,!0,`Serialized lazy tree entry ${m} VFS path`);if(d.has(p))throw new Error(`Serialized lazy tree duplicates pending path ${p}`);d.add(p);let g=Oe(f.sourcePath,!1,`Serialized lazy tree entry ${m} source path`),y=Oe(f.archivePath,!1,`Serialized lazy tree entry ${m} archive path`),_=c.get(p),E=a.get(fn({sourcePath:g,type:typeof f.type=="string"?f.type:void 0,inodeGroup:typeof f.inodeGroup=="string"?f.inodeGroup:void 0,target:typeof f.target=="string"?f.target:void 0}))??_;if(!E||E.type!=="file"&&E.type!=="hardlink"||_?.inodeGroup!==void 0&&_.inodeGroup!==E.inodeGroup)throw new Error(`Serialized lazy tree entry ${p} is absent from its inventory`);let w=r.canonicalByGroup.get(E.inodeGroup);if(f.type!==E.type||f.inodeGroup!==E.inodeGroup||f.size!==E.size||y!==w?.sourcePath||f.target!==E.target||f.isSymlink!==!1||f.deleted!==!1||f.materialized!==!1)throw new Error(`Serialized lazy tree entry ${p} disagrees with its inventory`);let S=ce(f.ino,`Serialized lazy tree entry ${p} inode`,1,Number.MAX_SAFE_INTEGER),O=ce(f.generation,`Serialized lazy tree entry ${p} generation`,0,Number.MAX_SAFE_INTEGER),z=ce(f.dataSequence,`Serialized lazy tree entry ${p} data sequence`,0,Number.MAX_SAFE_INTEGER);return{vfsPath:p,ino:S,generation:O,dataSequence:z,size:E.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:y,sourcePath:g,type:E.type,inodeGroup:E.inodeGroup,...E.target===void 0?{}:{target:E.target}}});for(let h of r.entries)if(r.activation.atomicGroup!==void 0&&(h.type==="file"||h.type==="hardlink")&&!d.has(h.vfsPath))throw new Error(`Serialized lazy tree omits pending path ${h.vfsPath}`);return{kind:e,content:r.content,inventory:r.entries,activation:r.activation,url:o,mountPrefix:r.mountPrefix,integrity:s,materialized:!1,entries:l}}async function Nt(n,e){let t=globalThis.crypto?.subtle;if(!t)throw new Error(`${e} SHA-256 verification is unavailable`);let r=new Uint8Array(n.byteLength);r.set(n);let i=new Uint8Array(await t.digest("SHA-256",r));return Array.from(i,o=>o.toString(16).padStart(2,"0")).join("")}async function zi(n,e,t){if(t===void 0)return;if(n.byteLength!==t.bytes)throw new Error(`Lazy ${e} byte count ${n.byteLength} does not match expected ${t.bytes}`);let r=await Nt(n,`Lazy ${e}`);if(r!==t.sha256)throw new Error(`Lazy ${e} SHA-256 ${r} does not match expected ${t.sha256}`)}async function js(n,e,t){if(n.byteLength!==e.bytes)throw new Error(`${t} byte count ${n.byteLength} does not match expected ${e.bytes}`);let r=await Nt(n,t);if(r!==e.sha256)throw new Error(`${t} SHA-256 ${r} does not match expected ${e.sha256}`)}function pl(n,e,t,r){let i=r.atomicGroup;if(i===void 0)throw new Error("Lazy atomic member is missing its typed tree descriptor");let o={schema:1,content:{decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...n.source===void 0?{}:{source:n.source},...n.materialization===void 0?{}:{materialization:n.materialization}},mountPrefix:t,inventory:[...e].sort((s,c)=>lr(s.vfsPath,c.vfsPath)),activation:{mode:r.mode,capabilities:r.capabilities,roots:r.roots,atomicGroup:{id:i.id,member:i.member}}};return new TextEncoder().encode(JSON.stringify(o))}function Js(n,e){let t=e.map(({member:r,descriptorSha256:i})=>({member:r,descriptorSha256:i}));return new TextEncoder().encode(JSON.stringify({schema:1,id:n,members:t.sort((r,i)=>r.memberi.member?1:0)}))}function hl(n,e){if(n.byteLength!==e.byteLength)return!1;for(let t=0;tObject.freeze({...o}))};r!==void 0&&(Object.freeze(r.entries),Object.freeze(r));let i=n.materialization===void 0?void 0:ml(n.materialization);return Object.freeze({decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,transports:t,...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...r===void 0?{}:{source:r},...i===void 0?{}:{materialization:i}})}function ma(n){return{schema:1,kind:"archive-byte-transforms-v1",assertions:n.assertions.map(e=>({...e})),recipes:n.recipes.map(e=>({id:e.id,replacements:e.replacements.map(t=>({...t})),rejectHex:[...e.rejectHex]})),transforms:n.transforms.map(e=>({sourcePath:e.sourcePath,recipe:e.recipe,input:{...e.input},output:{...e.output}}))}}function ml(n){let e=ma(n);for(let t of e.assertions)Object.freeze(t);Object.freeze(e.assertions);for(let t of e.recipes){for(let r of t.replacements)Object.freeze(r);Object.freeze(t.replacements),Object.freeze(t.rejectHex),Object.freeze(t)}Object.freeze(e.recipes);for(let t of e.transforms)Object.freeze(t.input),Object.freeze(t.output),Object.freeze(t);return Object.freeze(e.transforms),Object.freeze(e)}function rn(n){return{decoder:n.decoder,mediaType:n.mediaType,sha256:n.sha256,bytes:n.bytes,expandedBytes:n.expandedBytes,sourceEntryCount:n.sourceEntryCount,transports:[...n.transports],...n.modePolicy===void 0?{}:{modePolicy:n.modePolicy},...n.source===void 0?{}:{source:{schema:1,kind:"archive-source-inventory-v1",entries:n.source.entries.map(e=>({...e}))}},...n.materialization===void 0?{}:{materialization:ma(n.materialization)}}}function ya(n){let e=n.map(t=>Object.freeze({...t}));return Object.freeze(e),e}function yl(n,e,t){let r=[...n.capabilities],i=[...n.roots];return Object.freeze(r),Object.freeze(i),Object.freeze({mode:n.mode,capabilities:r,roots:i,atomicGroup:Object.freeze({id:e,member:t})})}function _l(n){let e=[...n.capabilities],t=[...n.roots];return Object.freeze(e),Object.freeze(t),Object.freeze({mode:n.mode,capabilities:e,roots:t})}function nn(n,e,t,r,i,o,s,c){let a=s.map(u=>Object.freeze({...u}));return Object.freeze(a),Object.freeze({content:hn(n),inventory:ya(e),activation:_l(t),url:r,mountPrefix:i,integrity:Object.freeze({...o}),entries:a,materialized:c})}function gl(n,e,t){let r=e.map(i=>Object.freeze({...i}));return Object.freeze(r),Object.freeze({...n,entries:r,materialized:t})}function xi(n){return Array.from(n,([e,t])=>({vfsPath:e,...t}))}function Qs(n){return new Map(n.map(({vfsPath:e,...t})=>[e,t]))}function El(n){return{mode:n.activation.mode,capabilities:[...n.activation.capabilities],roots:[...n.activation.roots],atomicGroup:{id:n.id,member:n.member,descriptorSha256:n.descriptorSha256,expectedCount:n.expectedCount,cohortSha256:n.cohortSha256}}}function Sl(n,e){return n.ino===e.ino&&n.generation===e.generation&&n.dataSequence===e.dataSequence&&n.size===e.size&&n.isSymlink===e.isSymlink&&n.deleted===e.deleted&&n.materialized===e.materialized&&n.archivePath===e.archivePath&&n.sourcePath===e.sourcePath&&n.type===e.type&&n.inodeGroup===e.inodeGroup&&n.target===e.target}function on(n,e,t){let r=n.content,i=n.inventory,o=n.activation,s=n.integrity,c=n.entries,a=n.url,u=n.mountPrefix,d=n.materialized,l=o?.atomicGroup;if(r===void 0||i===void 0||o===void 0||l===void 0||o.mode!=="first-use"||l.id!==e||l.member!==t||d)throw new Error(`Lazy atomic activation member ${t} changed before snapshot`);if(s?.sha256!==r.sha256||s?.bytes!==r.bytes||a!==(r.transports[0]??""))throw new Error(`Lazy atomic activation member ${t} has inconsistent integrity`);let h=hn(r),m=ya(i),f=yl(o,e,t),p=new Map;for(let w of m)w.type==="file"&&p.set(w.inodeGroup,w.sourcePath);let g=m.filter(w=>w.type!=="directory");if(c.size!==g.length)throw new Error(`Lazy atomic activation member ${t} has inconsistent runtime entries`);let y=g.map(w=>{let S=c.get(w.vfsPath),O=w.type==="symlink",z=O?w.sourcePath:p.get(w.inodeGroup),x=S!==void 0&&(S.sourcePath===w.sourcePath&&S.type===w.type&&S.target===w.target||w.type==="hardlink"&&S.sourcePath===z&&S.type==="file"&&S.target===void 0),I=S===void 0?["missing"]:[z===void 0?"archivePath source":void 0,S.generation===void 0?"generation":void 0,S.dataSequence===void 0?"dataSequence":void 0,S.size!==w.size?"size":void 0,S.isSymlink!==O?"symlink kind":void 0,S.deleted?"deletion state":void 0,S.materialized!==O?"materialization state":void 0,S.archivePath!==z?"archivePath":void 0,x?void 0:"descriptor mapping",S.inodeGroup!==w.inodeGroup?"inode group":void 0].filter(b=>b!==void 0);if(I.length>0)throw new Error(`Lazy atomic activation member ${t} has inconsistent mapping at ${w.vfsPath}: ${I.join(", ")}`);let R=S;return Object.freeze({vfsPath:w.vfsPath,ino:R.ino,generation:R.generation,dataSequence:R.dataSequence,size:R.size,isSymlink:R.isSymlink,deleted:!1,materialized:R.materialized,archivePath:z,sourcePath:w.sourcePath,type:w.type,...w.inodeGroup===void 0?{}:{inodeGroup:w.inodeGroup},...w.target===void 0?{}:{target:w.target}})});Object.freeze(y);let _=Object.freeze({sha256:h.sha256,bytes:h.bytes}),E=pl(h,m,u,f);return Object.freeze({id:e,member:t,descriptorBytes:E,content:h,inventory:m,activation:f,url:h.transports[0]??"",mountPrefix:u,integrity:_,entries:y})}function ea(n,e,t,r){return Object.freeze({...n,descriptorSha256:e,expectedCount:t,cohortSha256:r})}function ta(n,e){return n.id!==e.id||n.member!==e.member||n.url!==e.url||n.mountPrefix!==e.mountPrefix||n.integrity.sha256!==e.integrity.sha256||n.integrity.bytes!==e.integrity.bytes||n.content.transports.length!==e.content.transports.length||n.content.transports.some((t,r)=>t!==e.content.transports[r])||!hl(n.descriptorBytes,e.descriptorBytes)||n.entries.length!==e.entries.length?!1:n.entries.every((t,r)=>{let i=e.entries[r];return i!==void 0&&t.vfsPath===i.vfsPath&&Sl(t,i)})}function wl(n,e){let t=hn(n.content,n.content.transports.map(e));return Object.freeze({...n,content:t,url:t.transports[0]??""})}function ra(n){return{paths:Array.from(n.paths),expectedIno:n.ino,expectedGeneration:n.generation,expectedDataSequence:n.dataSequence,data:n.content}}function V(n,e){return`${n}:${e}`}var q=class n{fs;imageMetadata;lazyFiles=new Map;lazyArchiveGroups=[];deferredTreeMaterializationHandles=new WeakMap;lazyArchiveInodes=new Map;lazyAtomicGroups=new Map;lazyAtomicGroupByTree=new WeakMap;sealedLazyAtomicStates=new WeakMap;ordinaryLazyTreeDefinitions=new WeakMap;lazyDownloadListeners=new Set;lazyPreparations=new Map;lazyTransport={fetcher:(e,t)=>t===void 0?globalThis.fetch(e):globalThis.fetch(e,t)};constructor(e,t=null){this.fs=e,this.imageMetadata=t,lt(Ad,vd,[this])}snapshotForImmutableProduct(){if(this.lazyFiles.size!==0||this.lazyArchiveInodes.size!==0)throw new Error("immutable product source must be completely materialized");let{bytes:e}=lt(Ld,this.fs,[]),t=new Bs(e.byteLength);lt(Od,new wd(t),[e]);let r=lt(Rd,le,[t,{restoreImage:!0}]);return Sd(r,kd),new n(r,qs(this.imageMetadata))}qualifiedInodeIdentity(e){let t=this.fs.lstat(e),r=lt(zd,$s,[this.fs.buffer]);return r===void 0&&(r=Pd++,lt(xd,$s,[this.fs.buffer,r])),{dev:r,ino:t.ino,generation:t.generation}}static canAdoptLegacyLazyStub(e){return(e.mode&Se)===kt&&e.size===0&&e.dataSequence<=1}replaceOrdinaryLazyTreeRuntimeState(e,t,r){let i=this.ordinaryLazyTreeDefinitions.get(e);if(i===void 0)return;let o=gl(i,t,r);this.ordinaryLazyTreeDefinitions.set(e,o);try{e.entries=Qs(o.entries),e.materialized=o.materialized}catch{}return o}reconcileLazyIdentityState(e){for(let[t,r]of this.lazyFiles){let i=e.get(t);if(!i||i.dataSequence!==r.dataSequence||i.paths.length===0){this.lazyFiles.delete(t);continue}r.paths=new Set(i.paths),r.paths.has(r.path)||(r.path=i.paths[0])}this.lazyArchiveInodes.clear();for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(!r?.committed)for(let a of i.snapshot.entries){if(a.isSymlink||a.materialized||a.generation===void 0)continue;let u=V(a.ino,a.generation),d=e.get(u);d!==void 0&&d.dataSequence===a.dataSequence&&d.paths.length>0&&this.lazyArchiveInodes.set(u,t)}continue}let o=this.ordinaryLazyTreeDefinitions.get(t);if(o!==void 0){if(o.materialized){this.replaceOrdinaryLazyTreeRuntimeState(t,o.entries,!0);continue}let a=new Map,u=o.entries.filter(d=>d.deleted||d.materialized||d.isSymlink).map(d=>({...d}));for(let d of o.entries){if(d.deleted||d.materialized||d.isSymlink||d.generation===void 0)continue;let l=V(d.ino,d.generation),h=a.get(l)??[];h.push(d),a.set(l,h)}for(let[d,l]of a){let h=e.get(d);if(h===void 0||h.dataSequence!==(l[0].dataSequence??0)){if(h!==void 0)for(let p of l)u.push({...p,materialized:!0});continue}let m=new Map(l.map(p=>[p.vfsPath,p])),f=l.find(p=>p.type==="file")??l[0];for(let p of h.paths){let g=m.get(p)??f;u.push({...g,vfsPath:p,ino:h.ino,generation:h.generation,dataSequence:h.dataSequence,deleted:!1,materialized:!1})}h.paths.length>0&&this.lazyArchiveInodes.set(d,t)}this.replaceOrdinaryLazyTreeRuntimeState(t,u,!1);continue}let s=new Map;for(let a of t.entries.values()){if(a.deleted||a.materialized||a.generation===void 0)continue;let u=V(a.ino,a.generation);s.has(u)||s.set(u,a)}let c=new Map(Array.from(t.entries.entries()).filter(([,a])=>a.deleted||a.isSymlink&&!a.deleted));for(let[a,u]of s){let d=e.get(a);if(!(!d||d.dataSequence!==(u.dataSequence??0))){for(let l of d.paths)c.set(l,{...u,ino:d.ino,generation:d.generation,dataSequence:d.dataSequence,deleted:!1,materialized:!1});d.paths.length>0&&this.lazyArchiveInodes.set(a,t)}}t.entries=c,t.materialized=!Array.from(c.values()).some(a=>!a.isSymlink&&!a.materialized)&&(r===void 0||r.committed)}}validatePendingLazyTreeNamespaceState(e){let t=new Map;for(let r of e.values())for(let i of r.paths){if(t.has(i))throw new Error(`SharedFS namespace identity is ambiguous at ${i}`);t.set(i,r)}for(let r of this.lazyArchiveGroups){let i=this.lazyAtomicGroupByTree.get(r),s=this.sealedLazyAtomicStates.get(r)?.snapshot,c=this.ordinaryLazyTreeDefinitions.get(r);if(i?.committed||s===void 0&&(c?.materialized??r.materialized)||s===void 0&&c===void 0)continue;let a=s?.inventory??c.inventory,u=new Map((s?.entries??c.entries).map(f=>[f.vfsPath,f])),d=new Map,l=new Map,h=new Set;for(let f of u.values())f.deleted&&f.inodeGroup!==void 0&&h.add(f.inodeGroup);for(let f of a){if(f.type!=="file"&&f.type!=="hardlink")continue;d.set(f.inodeGroup,(d.get(f.inodeGroup)??0)+1);let p=l.get(f.inodeGroup)??[];p.push(f.vfsPath),l.set(f.inodeGroup,p)}let m=new Set([...h].filter(f=>l.get(f)?.every(p=>!t.has(p))));for(let f of a){let p=t.get(f.vfsPath);if(p===void 0){if(f.inodeGroup!==void 0&&m.has(f.inodeGroup))continue;throw new Error(`Lazy tree namespace entry ${f.vfsPath} is missing from the captured filesystem state`)}let g=f.type==="directory"?Qe:f.type==="symlink"?Sr:kt;if((p.mode&Se)!==g||(p.mode&ie.S_MODE_BITS)!==f.mode)throw new Error(`Lazy tree namespace entry ${f.vfsPath} disagrees with its captured type or mode`);if(f.type==="directory")continue;let y=u.get(f.vfsPath);if(y===void 0||y.ino!==p.ino||y.generation!==p.generation||y.dataSequence!==p.dataSequence)throw new Error(`Lazy tree namespace entry ${f.vfsPath} changed identity before serialization`);if(f.type==="symlink"){let _=new TextEncoder().encode(f.target).byteLength;if(p.linkCount!==1||p.size!==f.size||p.size!==_||p.symlinkTarget!==f.target)throw new Error(`Lazy tree symlink ${f.vfsPath} disagrees with its captured inventory`);continue}if(p.size!==0||p.linkCount!==d.get(f.inodeGroup))throw new Error(`Lazy tree stub ${f.vfsPath} has changed data or undeclared aliases`)}}}lazyFileForStat(e){let t=V(e.ino,e.generation),r=this.lazyFiles.get(t);if(r&&r.dataSequence!==e.dataSequence){this.lazyFiles.delete(t);return}return r}lazyArchiveEntriesForRead(e){let t=this.lazyAtomicGroupByTree.get(e),r=this.sealedLazyAtomicStates.get(e);if(r!==void 0&&!t?.committed)return r.snapshot.entries;let i=this.ordinaryLazyTreeDefinitions.get(e);return i!==void 0?i.entries:Array.from(e.entries,([o,s])=>({vfsPath:o,...s}))}lazyArchiveForStat(e){let t=V(e.ino,e.generation),r=this.lazyArchiveInodes.get(t);if(!r)return;let i=this.lazyArchiveEntriesForRead(r).filter(s=>s.ino===e.ino&&s.generation===e.generation&&!s.deleted&&!s.materialized);if(i.some(s=>s.dataSequence===e.dataSequence))return r;if(this.lazyArchiveInodes.delete(t),this.sealedLazyAtomicStates.get(r)===void 0){let s=this.ordinaryLazyTreeDefinitions.get(r);if(s!==void 0)this.replaceOrdinaryLazyTreeRuntimeState(r,s.entries.map(c=>c.ino===e.ino&&c.generation===e.generation?{...c,materialized:!0}:c),s.materialized);else for(let c of i)c.materialized=!0}}lazyBackingForStat(e){let t=V(e.ino,e.generation),r=this.lazyFiles.get(t);if(r)return{token:r,path:r.path};let i=this.lazyArchiveInodes.get(t);if(!i)return null;let o=this.lazyArchiveEntriesForRead(i).find(c=>c.ino===e.ino&&c.generation===e.generation&&!c.deleted&&!c.materialized)?.vfsPath;if(o===void 0)return null;let s=this.lazyAtomicGroupByTree.get(i);return s===void 0?{token:i,path:o}:{token:s.token,path:o,atomicGroup:s}}lazyBackingForPath(e){let t=this.lazyArchiveGroups.find(r=>{let i=this.lazyAtomicGroupByTree.get(r),o=this.sealedLazyAtomicStates.get(r)?.snapshot,s=this.ordinaryLazyTreeDefinitions.get(r),c=o===void 0?!(s?.materialized??r.materialized):!i?.committed,a=o?.content??s?.content,u=o?.inventory??s?.inventory,d=o?.activation??s?.activation,l=o?.entries??s?.entries??Array.from(r.entries.values());return c&&a!==void 0&&u!==void 0&&d!==void 0&&l.every(h=>h.deleted||h.materialized||h.isSymlink)&&d.roots.some(h=>h==="/"||e===h||e.startsWith(`${h}/`))});if(t){let r=this.lazyAtomicGroupByTree.get(t);return{token:r?.token??t,path:e,directGroup:t,...r===void 0?{}:{atomicGroup:r}}}try{let r=this.fs.stat(e),i=this.lazyBackingForStat(r);return i?{...i,path:e}:null}catch{return null}}startLazyPreparation(e){let{path:t,token:r}=e,i={status:"pending",promise:Promise.resolve(!1)},o=e.atomicGroup?this.ensureAtomicLazyGroupMaterialized(e.atomicGroup).then(()=>!0):e.directGroup?this.ensureArchiveMaterialized(e.directGroup).then(()=>!0):this.materializePath(t);return i.promise=o.then(s=>(i.status="fulfilled",this.lazyPreparations.get(r)===i&&this.lazyPreparations.delete(r),s),s=>{throw i.status="rejected",i.error=s,s}),i.promise.catch(()=>{}),this.lazyPreparations.set(r,i),i}registerLazyAtomicGroupMembership(e,t=!1){let r=e.activation?.atomicGroup;if(r===void 0)return;let{id:i,member:o}=r;if(e.content===void 0||e.inventory===void 0||e.activation?.mode!=="first-use")throw new Error(`Lazy atomic activation group ${i} accepts only typed first-use trees`);let s=this.lazyAtomicGroups.get(i);if(s===void 0)s={id:i,token:Object.freeze({id:i}),groups:new Map,committed:!1},this.lazyAtomicGroups.set(i,s);else if(s.committed)throw new Error(`Lazy atomic activation group ${i} is already materialized`);if(s.groups.has(o))throw new Error(`Lazy atomic activation group ${i} duplicates member ${o}`);if(Ft(r)){if(s.expectedCount!==void 0&&(s.expectedCount!==r.expectedCount||s.cohortSha256!==r.cohortSha256))throw new Error(`Lazy atomic activation group ${i} has inconsistent seals`);s.expectedCount=r.expectedCount,s.cohortSha256=r.cohortSha256;let c=on(e,i,o);this.sealedLazyAtomicStates.set(e,{snapshot:ea(c,r.descriptorSha256,r.expectedCount,r.cohortSha256),verified:t})}else if(s.expectedCount!==void 0)throw new Error(`Lazy atomic activation group ${i} mixes sealed and unsealed members`);s.groups.set(o,e),this.lazyAtomicGroupByTree.set(e,s)}async sealLazyAtomicGroup(e,t){let r=t.map(u=>fa({id:e,member:u}).member).sort();if(r.length===0||new Set(r).size!==r.length)throw new Error(`Lazy atomic activation group ${e} expected members are invalid`);let i=this.lazyAtomicGroups.get(e);if(i===void 0||i.committed)throw new Error(`Lazy atomic activation group ${e} is not pending`);let o=[...i.groups.keys()].sort();if(JSON.stringify(o)!==JSON.stringify(r))throw new Error(`Lazy atomic activation group ${e} members differ from its seal`);if(i.expectedCount!==void 0){if(i.expectedCount!==r.length||i.cohortSha256===void 0)throw new Error(`Lazy atomic activation group ${e} has an invalid existing seal`);await this.ensureLazyAtomicGroupSealValidated(i,r.map(u=>i.groups.get(u)),!0);return}let s=r.map(u=>on(i.groups.get(u),e,u)),c=[];for(let u of s)c.push({member:u.member,descriptorSha256:await Nt(u.descriptorBytes,`Lazy atomic member ${u.member}`),source:u});let a=await Nt(Js(e,c),`Lazy atomic activation group ${e}`);for(let u of c){let d=i.groups.get(u.member),l=on(d,e,u.member);if(!ta(u.source,l))throw new Error(`Lazy atomic activation member ${u.member} changed while sealing`)}for(let u of c){let d=i.groups.get(u.member);d.activation.atomicGroup={id:e,member:u.member,descriptorSha256:u.descriptorSha256,expectedCount:c.length,cohortSha256:a},this.sealedLazyAtomicStates.set(d,{snapshot:ea(u.source,u.descriptorSha256,c.length,a),verified:!0})}i.expectedCount=c.length,i.cohortSha256=a}async verifyImportedLazyAtomicGroupSeals(){await this.validatePendingLazyAtomicGroupSeals(!0)}guardSynchronousLazyAccess(e){let t=this.lazyBackingForPath(e);if(!t)return;let r=this.lazyPreparations.get(t.token);if(r?.status==="fulfilled"){this.lazyPreparations.delete(t.token);let o=this.lazyBackingForPath(e);if(!o)return;r=this.lazyPreparations.get(o.token)??this.startLazyPreparation(o)}else if(r?.status==="rejected"){this.lazyPreparations.delete(t.token);let o=r.error instanceof Error?r.error.message:String(r.error),s=new Error(`EIO: lazy backing for ${e} failed: ${o}`);throw s.code="EIO",s.cause=r.error,s}else r||(r=this.startLazyPreparation(t));let i=new Error(`EAGAIN: lazy backing for ${e} is being prepared`);throw i.code="EAGAIN",i}invalidateLazyData(e){let t=V(e.ino,e.generation);this.lazyFiles.delete(t);let r=this.lazyArchiveInodes.get(t);if(!r)return;this.lazyArchiveInodes.delete(t);let i=this.ordinaryLazyTreeDefinitions.get(r);if(i!==void 0){this.replaceOrdinaryLazyTreeRuntimeState(r,i.entries.map(o=>o.ino===e.ino&&o.generation===e.generation?{...o,materialized:!0}:o),i.materialized);return}for(let o of r.entries.values())o.ino!==e.ino||o.generation!==e.generation||(o.materialized=!0)}rewriteLazyNamespacePaths(e,t,r){let i=t.length>1?t.replace(/\/+$/,""):t,o=r.length>1?r.replace(/\/+$/,""):r,s=`${i}/`,c=`${o}/`,a=V(e.ino,e.generation),u=(e.mode&Se)===Qe,d=l=>l===i?o:u&&l.startsWith(s)?c+l.slice(s.length):l;for(let[l,h]of this.lazyFiles)!u&&l!==a||(h.paths=new Set(Array.from(h.paths,d)),h.path=d(h.path));for(let l of this.lazyArchiveGroups){let h=this.ordinaryLazyTreeDefinitions.get(l);if(h!==void 0){let f=h.entries.map(_=>{let E=_.generation===void 0?null:V(_.ino,_.generation),w=u||E===a?d(_.vfsPath):_.vfsPath;return{..._,vfsPath:w,..._.type==="hardlink"&&_.target!==void 0?{target:d(_.target)}:{}}}),p=h.inventory.map(_=>({..._,vfsPath:d(_.vfsPath),..._.type==="hardlink"&&_.target!==void 0?{target:d(_.target)}:{}})),g={...h.activation,capabilities:[...h.activation.capabilities],roots:h.activation.roots.map(d)},y=nn(h.content,p,g,h.url,h.mountPrefix,h.integrity,f,h.materialized);this.ordinaryLazyTreeDefinitions.set(l,y);try{l.entries=Qs(y.entries),l.materialized=y.materialized,l.inventory=y.inventory.map(_=>({..._})),l.activation={...y.activation,capabilities:[...y.activation.capabilities],roots:[...y.activation.roots]}}catch{}continue}let m=new Map;for(let[f,p]of l.entries){let g=p.generation===void 0?null:V(p.ino,p.generation);m.set(u||g===a?d(f):f,p)}l.entries=m,l.inventory&&(l.inventory=l.inventory.map(f=>({...f,vfsPath:d(f.vfsPath),...f.type==="hardlink"&&f.target!==void 0?{target:d(f.target)}:{}}))),l.activation&&(l.activation={...l.activation,roots:l.activation.roots.map(d)})}}get sharedBuffer(){return this.fs.buffer}static create(e,t){return new n(le.mkfs(e,t))}static createFresh(e){if(typeof e!="number"||!Id(e)||e<=0)throw new Td("fresh MemoryFileSystem byte length must be a positive integer");let t=new Bs(e),r=lt(bd,le,[t]);return new n(r)}static fromExisting(e){return new n(le.mount(e))}rebaseToNewFileSystem(e){if(!Number.isSafeInteger(e)||e<=0)throw new Error(`Invalid MemoryFileSystem maxByteLength: ${e}`);let t=SharedArrayBuffer,{bytes:r,identities:i}=this.fs.snapshotState();this.reconcileLazyIdentityState(i);let o=this.serializeLazyEntries(),s=this.serializeValidatedLazyArchiveEntries(i),c=new t(r.byteLength);new Uint8Array(c).set(r);let a=new n(le.mount(c,{restoreImage:!0}),this.imageMetadata);a.importLazyEntries(o),a.importLazyArchiveEntriesInternal(s,!1,!0,"verified");let u=Math.min(e,Math.max(r.byteLength,$d)),d=new t(u,{maxByteLength:e}),l=n.create(d,e);l.setImageMetadata(this.imageMetadata);let h=new Set(o.flatMap(f=>f.paths??[f.path])),m=new Set;for(let f of s)if(!f.materialized)for(let p of f.entries)!p.deleted&&!p.isSymlink&&m.add(p.vfsPath);return a.copyPathToFreshFileSystem("/",l,h,m,new Map),l.importLazyEntries(o.map(f=>{let p=l.fs.lstat(f.path);return{...f,ino:p.ino,generation:p.generation,dataSequence:p.dataSequence}})),l.importLazyArchiveEntriesInternal(s.map(f=>({...f,entries:f.entries.map(p=>{if(p.deleted)return{...p,ino:0,generation:void 0};let g=l.fs.lstat(p.vfsPath);return{...p,ino:g.ino,generation:g.generation,dataSequence:g.dataSequence}})})),!1,!0,"verified"),l}getImageMetadata(){return qs(this.imageMetadata)}setImageMetadata(e){this.imageMetadata=e===null?null:Ni(e)}subscribeLazyDownloads(e){return this.lazyDownloadListeners.add(e),()=>this.lazyDownloadListeners.delete(e)}setLazyFetcher(e,t={}){this.lazyTransport={fetcher:e,...t.signal===void 0?{}:{signal:t.signal}}}emitLazyDownload(e){if(this.lazyDownloadListeners.size===0)return;let t={...e,t:Qd()};for(let r of this.lazyDownloadListeners)try{r(t)}catch{}}async fetchLazyBytes(e,t){let r=0,i=e.integrity?.bytes??e.fallbackTotalBytes,o={id:e.id,kind:e.kind,url:e.url,path:e.path,mountPrefix:e.mountPrefix};for(let s=0;se.integrity.bytes)throw new Error(`Lazy ${e.kind} exceeded expected byte count ${e.integrity.bytes}`);this.emitLazyDownload({...o,status:"progress",loadedBytes:r,totalBytes:i})}}}catch(l){try{await a.cancel(l)}catch{}throw l}}finally{a.releaseLock()}let d=al(u,r);return te(t.signal),await zi(d,e.kind,e.integrity),te(t.signal),this.emitLazyDownload({...o,status:"complete",loadedBytes:r,totalBytes:i??r}),d}catch(c){if(t.signal?.aborted){let d=t.signal.reason,l=d instanceof Error?d.message:String(d);throw this.emitLazyDownload({...o,status:"error",loadedBytes:r,totalBytes:i,error:l}),d}let a=s+1({..._})),activation:l,entries:new Map},g=_=>{let E=_.split("/").filter(Boolean),w="";for(let S=0;SE.vfsPath.split("/").length-w.vfsPath.split("/").length))if(_.type==="directory"){g(_.vfsPath);try{this.fs.mkdir(_.vfsPath,_.mode),this.fs.chmod(_.vfsPath,_.mode)}catch{if((this.fs.lstat(_.vfsPath).mode&Se)!==Qe)throw new Error(`Lazy tree directory collides at ${_.vfsPath}`)}}for(let _ of u){if(_.type!=="symlink")continue;g(_.vfsPath),this.fs.symlink(_.target,_.vfsPath);let E=this.fs.lstat(_.vfsPath);p.entries.set(_.vfsPath,{ino:E.ino,generation:E.generation,dataSequence:E.dataSequence,size:_.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:_.sourcePath,sourcePath:_.sourcePath,type:"symlink",target:_.target})}let y=new Map;for(let _ of u){if(_.type!=="file")continue;g(_.vfsPath);let E=this.fs.createLazyStub(_.vfsPath,_.mode);this.invalidateLazyData(E),y.set(_.inodeGroup,E);let w={ino:E.ino,generation:E.generation,dataSequence:E.dataSequence,size:_.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:_.sourcePath,sourcePath:_.sourcePath,type:"file",inodeGroup:_.inodeGroup};p.entries.set(_.vfsPath,w)}for(let _ of u){if(_.type!=="hardlink")continue;let E=h.get(_.inodeGroup);g(_.vfsPath),this.fs.link(E.vfsPath,_.vfsPath);let w=this.fs.lstat(_.vfsPath),S=y.get(_.inodeGroup);if(w.ino!==S.ino||w.generation!==S.generation)throw new Error(`Lazy tree hardlink ${_.vfsPath} did not share its inode`);p.entries.set(_.vfsPath,{ino:w.ino,generation:w.generation,dataSequence:w.dataSequence,size:_.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:E.sourcePath,sourcePath:_.sourcePath,type:"hardlink",inodeGroup:_.inodeGroup,target:_.target})}if(m!==void 0)for(let _ of u)this.lchown(_.vfsPath,m.uid,m.gid);for(let _ of p.entries.values())_.isSymlink||_.generation===void 0||this.lazyArchiveInodes.set(V(_.ino,_.generation),p);return this.lazyArchiveGroups.push(p),this.registerLazyAtomicGroupMembership(p),l.atomicGroup===void 0&&this.ordinaryLazyTreeDefinitions.set(p,nn(a,u,l,p.url,d,p.integrity,xi(p.entries),!1)),p}registerLazyTreeWithMaterializationHandle(e,t,r="/",i,o){let s=this.registerLazyTreeInternal(e,t,r,i,!0,o),c=Object.freeze({[Fd]:!0});return this.deferredTreeMaterializationHandles.set(c,s),c}registerLazyArchiveFromEntries(e,t,r,i,o){let s=Ar(r),c=Yd(e,t,s,i);c.some(({entry:u})=>!u.isDirectory&&!u.isSymlink)&&this.assertCanRegisterPendingLazyArchiveGroup();let a={...o?{content:ln({decoder:"zip-v1",mediaType:"application/zip",sha256:o.sha256,bytes:o.bytes,expandedBytes:c.reduce((u,d)=>u+d.entry.uncompressedSize,0),sourceEntryCount:c.length,transports:[e]})}:{},url:e,mountPrefix:s,integrity:zr(o),materialized:!1,entries:new Map};for(let{entry:u,vfsPath:d}of c){if(u.isDirectory)continue;let l=d.split("/").filter(Boolean),h="";for(let m=0;mu.deleted||u.materialized),this.lazyArchiveGroups.push(a),a}importLazyArchiveEntries(e){this.importLazyArchiveEntriesInternal(e,!1,!0,"reject")}async importVerifiedLazyArchiveEntries(e){let t=structuredClone(e),r=this.exportLazyArchiveEntries(),i=n.fromExisting(this.sharedBuffer);i.importLazyArchiveEntriesInternal([...r,...t],!1,!0,"pending"),await i.verifyImportedLazyAtomicGroupSeals(),this.importLazyArchiveEntriesInternal(t,!1,!0,"verified")}importLazyArchiveEntriesInternal(e,t,r,i){let o=Me(e,"Serialized lazy archive groups",0,ia).map((d,l)=>{if(typeof d!="object"||d===null||Array.isArray(d))throw new Error(`Serialized lazy archive group ${l} must be an object`);let h=d.kind;if(h===Or||h===bi||h===ft)return ha(d,h);if(h===wr)return Pi(d,!1);if(h!==void 0)throw new Error(`Serialized lazy archive group ${l} has an unsupported kind`);if(r)throw new Error(`Serialized lazy archive group ${l} is missing its kind discriminator`);return Pi(d,!0)}),s=this.fs.identityState();this.reconcileLazyIdentityState(s);let c=[...this.serializeValidatedLazyArchiveEntries(s),...o];Xs(c);let a=[],u=new Map;for(let d of o){let l=new Map,h=d.mountPrefix.replace(/\/+$/,""),m=d.content!==void 0&&d.inventory!==void 0&&d.activation!==void 0,f=m?new Map(d.inventory.map(S=>[S.vfsPath,S])):null,p=m?new Map(d.inventory.map(S=>[fn(S),S])):null,g=new Map,y=new Map,_=new Map;for(let S of d.entries){let O=null,z=d.materialized||S.materialized===!0||S.isSymlink;if(!S.deleted&&!z){if((S.generation===void 0||S.dataSequence===void 0)&&!t)throw new Error("Live lazy-archive metadata requires inode generation and data sequence");try{O=this.fs.lstat(S.vfsPath)}catch{if(m)throw new Error(`Serialized lazy tree stub ${S.vfsPath} is missing from the filesystem`);continue}if(O.ino!==S.ino){if(m)throw new Error(`Serialized lazy tree stub ${S.vfsPath} has a different inode`);continue}if(S.generation!==void 0&&O.generation!==S.generation){if(m)throw new Error(`Serialized lazy tree stub ${S.vfsPath} has a different generation`);continue}if(S.dataSequence===void 0){if(!n.canAdoptLegacyLazyStub(O)){if(m)throw new Error(`Serialized lazy tree stub ${S.vfsPath} is not pristine`);continue}}else if(O.dataSequence!==S.dataSequence){if(m)throw new Error(`Serialized lazy tree stub ${S.vfsPath} has a different data sequence`);continue}if(m){_.set(S.vfsPath,O);let I=f.get(S.vfsPath),R=p.get(fn(S))??I;if(!R||(O.mode&Se)!==kt||O.size!==0||(O.mode&ie.S_MODE_BITS)!==R.mode||I?.inodeGroup!==void 0&&I.inodeGroup!==R.inodeGroup)throw new Error(`Serialized lazy tree stub ${S.vfsPath} disagrees with its inventory`);let b=V(O.ino,O.generation),P=S.inodeGroup,Z=g.get(P),N=y.get(b);if(Z!==void 0&&Z!==b||N!==void 0&&N!==P)throw new Error(`Serialized lazy tree inode group ${P} disagrees with the filesystem`);g.set(P,b),y.set(b,P)}}l.set(S.vfsPath,{ino:S.ino,generation:O?.generation??S.generation,dataSequence:O?.dataSequence??S.dataSequence,size:S.size,isSymlink:S.isSymlink,deleted:S.deleted,materialized:z,archivePath:S.archivePath??S.vfsPath.slice(h.length+1),sourcePath:S.sourcePath??S.archivePath??S.vfsPath.slice(h.length+1),type:S.type??(S.isSymlink?"symlink":"file"),inodeGroup:S.inodeGroup,target:S.target})}if(m){let S=new Map;for(let O of d.inventory){if(O.type==="file"||O.type==="hardlink"){S.set(O.inodeGroup,(S.get(O.inodeGroup)??0)+1);continue}let z;try{z=this.fs.lstat(O.vfsPath)}catch{throw new Error(`Serialized lazy tree namespace entry ${O.vfsPath} is missing from the filesystem`)}let x=O.type==="directory"?Qe:Sr;if((z.mode&Se)!==x||(z.mode&ie.S_MODE_BITS)!==O.mode||O.type==="symlink"&&(z.size!==new TextEncoder().encode(O.target).byteLength||this.fs.readlink(O.vfsPath)!==O.target))throw new Error(`Serialized lazy tree namespace entry ${O.vfsPath} disagrees with its inventory`);O.type==="symlink"&&l.set(O.vfsPath,{ino:z.ino,generation:z.generation,dataSequence:z.dataSequence,size:O.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:O.sourcePath,sourcePath:O.sourcePath,type:"symlink",target:O.target})}if(d.activation?.atomicGroup!==void 0)for(let O of d.inventory){if(O.type!=="file"&&O.type!=="hardlink")continue;if(_.get(O.vfsPath).linkCount!==S.get(O.inodeGroup))throw new Error(`Serialized lazy atomic tree inode group ${O.inodeGroup} has undeclared aliases`)}}let E=d.content===void 0?void 0:ln(d.content),w={content:E,url:E?.transports[0]??d.url,mountPrefix:d.mountPrefix,integrity:E?{sha256:E.sha256,bytes:E.bytes}:zr(d.integrity),materialized:d.materialized||!(E&&d.inventory)&&Array.from(l.values()).every(S=>S.deleted||S.materialized),inventory:d.inventory?.map(S=>({...S})),activation:d.activation?{mode:d.activation.mode,capabilities:[...d.activation.capabilities],roots:[...d.activation.roots],...d.activation.atomicGroup===void 0?{}:{atomicGroup:{...d.activation.atomicGroup}}}:void 0,entries:l};if(a.push(w),!w.materialized){for(let[,S]of l)if(!S.deleted&&!S.materialized&&S.generation!==void 0){let O=V(S.ino,S.generation),z=u.get(O);if(z!==void 0&&z!==w)throw new Error(`Serialized lazy archive groups share pending inode ${O}`);if(this.lazyArchiveInodes.has(O))throw new Error(`Serialized lazy archive group collides with pending inode ${O}`);u.set(O,w)}}}for(let d of a){let l=d.activation?.atomicGroup;if(l!==void 0&&this.lazyAtomicGroups.get(l.id)?.committed)throw new Error(`Lazy atomic activation group ${l.id} is already materialized`)}if(i==="reject"&&a.some(d=>{let l=d.activation?.atomicGroup;return l!==void 0&&Ft(l)}))throw new Error("Sealed lazy archive registrations require importVerifiedLazyArchiveEntries()");this.lazyArchiveGroups.push(...a);for(let d of a)this.registerLazyAtomicGroupMembership(d,i==="verified"),d.content!==void 0&&d.inventory!==void 0&&d.activation!==void 0&&d.activation.atomicGroup===void 0&&this.ordinaryLazyTreeDefinitions.set(d,nn(d.content,d.inventory,d.activation,d.url,d.mountPrefix,d.integrity,xi(d.entries),d.materialized));for(let[d,l]of u)this.lazyArchiveInodes.set(d,l)}rewriteLazyArchiveUrls(e){for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0&&!r?.committed){this.assertLazyAtomicSnapshotMatchesPublic(t);let s=wl(i.snapshot,e);t.content=rn(s.content),t.url=s.url,t.integrity={...s.integrity},i.snapshot=s;continue}let o=this.ordinaryLazyTreeDefinitions.get(t);if(o!==void 0){let s=hn(o.content,o.content.transports.map(e)),c=nn(s,o.inventory,o.activation,s.transports[0],o.mountPrefix,o.integrity,o.entries,o.materialized);this.ordinaryLazyTreeDefinitions.set(t,c),t.content=rn(c.content),t.url=c.url,t.integrity={...c.integrity}}else t.content?(t.content={...t.content,transports:t.content.transports.map(e)},t.url=t.content.transports[0]):t.url=e(t.url)}}serializeLazyArchiveEntries(){let e=[];for(let t of this.lazyArchiveGroups){let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t);if(i!==void 0){if(r?.committed)continue;let g=i.snapshot;if(g.content.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");e.push({kind:ft,content:rn(g.content),inventory:g.inventory.map(y=>({...y})),activation:El(g),url:g.url,mountPrefix:g.mountPrefix,integrity:{...g.integrity},materialized:!1,entries:g.entries.filter(y=>!y.deleted&&!y.materialized).map(({vfsPath:y,..._})=>({vfsPath:y,..._}))});continue}let o=this.ordinaryLazyTreeDefinitions.get(t),s=o?.materialized??t.materialized,c=(o?.entries??xi(t.entries)).map(g=>({...g})).filter(g=>!g.deleted&&!g.materialized),a=o?.content??t.content,u=o?.inventory??t.inventory,d=o?.activation??t.activation,l=o?.url??t.url,h=o?.mountPrefix??t.mountPrefix,m=o?.integrity??t.integrity;if(c.length===0&&!(a!==void 0&&u!==void 0&&!s))continue;let f=a!==void 0&&u!==void 0&&d!==void 0;if(f&&a.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");let p=d?.atomicGroup;if(p!==void 0&&!Ft(p))throw new Error(`Lazy atomic activation group ${p.id} must be sealed before serialization`);e.push(f?{kind:p!==void 0?ft:a.source===void 0?Or:bi,content:rn(a),inventory:u.map(g=>({...g})),activation:{...d,capabilities:[...d.capabilities],roots:[...d.roots]},url:l,mountPrefix:h,integrity:{...m},materialized:!1,entries:c}:{kind:wr,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:c})}return e}serializeValidatedLazyArchiveEntries(e){this.assertPendingLazyAtomicSnapshotsReadyForSerialization();let t=this.serializeLazyArchiveEntries();return Xs(t),this.validatePendingLazyTreeNamespaceState(e),t}exportLazyArchiveEntries(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),this.serializeValidatedLazyArchiveEntries(e)}pendingDeferredTreeUsage(){let e=this.fs.identityState();return this.reconcileLazyIdentityState(e),la(this.serializeValidatedLazyArchiveEntries(e))}assertCanAppendDeferredTreeUsage(e){vi(e);let t=this.pendingDeferredTreeUsage();vi({groups:t.groups+e.groups,archiveBytes:t.archiveBytes+e.archiveBytes,expandedBytes:t.expandedBytes+e.expandedBytes,payloadBytes:t.payloadBytes+e.payloadBytes,entries:t.entries+e.entries})}assertCanRegisterPendingLazyArchiveGroup(){if(this.reconcileLazyIdentityState(this.fs.identityState()),this.lazyArchiveGroups.filter(t=>{let r=this.lazyAtomicGroupByTree.get(t);if(this.sealedLazyAtomicStates.get(t)?.snapshot!==void 0)return!r?.committed;let o=this.ordinaryLazyTreeDefinitions.get(t);return!(o?.materialized??t.materialized)&&(o!==void 0||Array.from(t.entries.values()).some(s=>!s.deleted&&!s.materialized))}).length>=Le.maxGroups)throw new Error(`Cannot register another lazy archive group: ${Le.maxGroups} pending groups already exist`)}async preparePath(e){let t=!1,r=Math.max(3,this.lazyArchiveGroups.length+1);for(let i=0;i{let s=this.ordinaryLazyTreeDefinitions.get(o);return!(s?.materialized??o.materialized)&&(s?.activation??o.activation)?.mode==="boot-prefetch"}),t=0,r,i=Array.from({length:Math.min(e.length,Wd)},async()=>{for(;r===void 0;){let o=t;if(t+=1,o>=e.length)return;try{await this.prepareLazyTreeGroup(e[o])}catch(s){r??=s}}});if(await Promise.all(i),r!==void 0)throw r;return e.length}async materializeRegisteredDeferredTree(e,t){let r=this.deferredTreeMaterializationHandles.get(e);if(r===void 0)throw new Error("Deferred-tree handle was not issued by this filesystem");let i=this.lazyAtomicGroupByTree.get(r);if(i!==void 0)throw new Error(`Deferred tree belongs to atomic activation group ${i.id}; materialize the complete group instead`);if(this.ordinaryLazyTreeDefinitions.get(r)?.materialized??r.materialized)return!1;let s=this.lazyPreparations.get(r);if(s!==void 0)return s.promise;let c=new Uint8Array(t.byteLength);c.set(t);let a={status:"pending",promise:Promise.resolve(!1)};a.promise=Promise.resolve().then(async()=>{let u=this.ordinaryLazyTreeDefinitions.get(r)?.integrity??r.integrity;return await zi(c,"tree",u),await this.materializeArchiveBytes(r,c),!0}).then(u=>(a.status="fulfilled",u),u=>{throw a.status="rejected",a.error=u,u}),a.promise.catch(()=>{}),this.lazyPreparations.set(r,a);try{return await a.promise}finally{this.lazyPreparations.get(r)===a&&this.lazyPreparations.delete(r)}}async prepareLazyTreeGroup(e){let t=this.lazyAtomicGroupByTree.get(e),r=this.ordinaryLazyTreeDefinitions.get(e);if(t?.committed||t===void 0&&(r?.materialized??e.materialized))return!1;let i=this.sealedLazyAtomicStates.get(e)?.snapshot,o={token:t?.token??e,path:i?.activation.roots[0]??r?.activation.roots[0]??r?.mountPrefix??e.activation?.roots[0]??e.mountPrefix,directGroup:e,...t===void 0?{}:{atomicGroup:t}},s=this.lazyPreparations.get(o.token)??this.startLazyPreparation(o);try{return await s.promise}finally{this.lazyPreparations.get(o.token)===s&&this.lazyPreparations.delete(o.token)}}async ensureMaterialized(e){return this.preparePath(e)}async materializePath(e){if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0)return!1;let t;try{t=this.fs.stat(e)}catch{return!1}let r=V(t.ino,t.generation),i=this.lazyFiles.get(r);if(i){let s=this.lazyTransport,c=await this.fetchLazyBytes({id:`file:${t.ino}`,kind:"file",url:i.url,path:i.path,fallbackTotalBytes:i.size},s);for(let a=0;a<3;a++){if(this.lazyFiles.get(r)!==i)return!1;for(let u of new Set([e,...i.paths]))if(te(s.signal),this.fs.replaceIfIdentity(u,i.ino,i.generation,i.dataSequence,c))return i.path=u,this.lazyFiles.delete(r),!0;this.reconcileLazyIdentityState(this.fs.identityState())}throw new Error(`Lazy file kept changing names while materializing: ${e}`)}let o=this.lazyArchiveInodes.get(r);return o?(await this.ensureArchiveMaterialized(o,{path:e,ino:t.ino,generation:t.generation}),!this.lazyArchiveInodes.has(r)):!1}async decodeAndValidateLazyTree(e,t,r){let i=this.ordinaryLazyTreeDefinitions.get(e),o=r?.content??i?.content??e.content,s=r?.inventory??i?.inventory??e.inventory;if(!o||!s)throw new Error("Lazy tree is missing its decoder or complete inventory");let c=new Map,a=new Map(s.map(m=>[m.vfsPath,m]));if(o.source!==void 0)for(let m of o.source.entries)c.set(m.sourcePath,m);else for(let m of s){if(m.type==="hardlink"){let p=a.get(m.target);if(!p)throw new Error(`Lazy tree hardlink target disappeared: ${m.target}`);if(m.sourcePath===p.sourcePath)continue}if(c.get(m.sourcePath))throw new Error(`Lazy tree inventory duplicates source member ${m.sourcePath}`);c.set(m.sourcePath,{sourcePath:m.sourcePath,type:m.type,mode:m.mode,size:m.size,...m.type==="symlink"?{target:m.target}:{},...m.type==="hardlink"?{target:a.get(m.target)?.sourcePath}:{}})}let u=new Map,d=0;if(o.decoder==="zip-v1"){let{parseZipCentralDirectory:m,extractZipEntryBounded:f}=await Promise.resolve().then(()=>(hi(),pi)),p=m(t);if(p.length!==o.sourceEntryCount||p.length!==c.size)throw new Error("Lazy ZIP tree decoded inventory counts differ from its descriptor");for(let g of p){let y=g.isDirectory?g.fileName.replace(/\/$/,""):g.fileName;if(u.has(y))throw new Error(`Lazy ZIP tree duplicates source member ${y}`);let _=c.get(y);if(!_)throw new Error(`Lazy ZIP tree has undeclared source member ${y}`);if(d+=g.uncompressedSize,d>o.expandedBytes||g.uncompressedSize!==_.size)throw new Error(`Lazy ZIP tree member ${y} exceeds its inventory`);let E=g.isDirectory?"directory":g.isSymlink?"symlink":"file",w=o.modePolicy==="portable-posix-v1"?E==="directory"?493:E==="symlink"?511:(g.mode&73)!==0?493:420:g.mode&ie.S_MODE_BITS;if(E!==_.type||w!==_.mode)throw new Error(`Lazy ZIP tree member ${y} differs from inventory`);if(g.isDirectory)u.set(y,{type:"directory",mode:w});else{let S=f(t,g,_.size);if(g.isSymlink){let O;try{O=new TextDecoder("utf-8",{fatal:!0}).decode(S)}catch{throw new Error(`Lazy ZIP tree symlink ${y} is not UTF-8`)}u.set(y,{type:"symlink",mode:w,target:O})}else u.set(y,{type:"file",mode:w,data:S})}}}else{let{parseTarGzip:m}=await Promise.resolve().then(()=>(Ks(),Ds)),f=m(t,{label:`Lazy tree ${o.sha256}`,limits:{maxCompressedBytes:o.bytes,maxUncompressedBytes:o.expandedBytes,maxEntries:o.sourceEntryCount}});d=new DataView(t.buffer,t.byteOffset,t.byteLength).getUint32(t.byteLength-4,!0);for(let p of f){if(u.has(p.path))throw new Error(`Lazy TAR tree duplicates source member ${p.path}`);p.type==="file"?u.set(p.path,{type:"file",mode:p.mode,data:p.data}):p.type==="directory"?u.set(p.path,{type:"directory",mode:p.mode}):u.set(p.path,{type:p.type,mode:p.mode,target:p.linkName})}}if(u.size!==o.sourceEntryCount||u.size!==c.size||d!==o.expandedBytes)throw new Error("Lazy tree decoded inventory counts differ from its descriptor");for(let[m,f]of c){let p=u.get(m);if(!p)throw new Error(`Lazy tree is missing source member ${m}`);let g=f.type;if(p.type!==g)throw new Error(`Lazy tree member ${m} is ${p.type}, expected ${g}`);if((p.mode&ie.S_MODE_BITS)!==f.mode)throw new Error(`Lazy tree member ${m} mode differs from inventory`);if(g==="file"&&p.data?.byteLength!==f.size)throw new Error(`Lazy tree member ${m} size differs from inventory`);if(g==="symlink"&&p.target!==f.target)throw new Error(`Lazy tree symlink ${m} target differs from inventory`);if(g==="hardlink"&&p.target!==f.target)throw new Error(`Lazy tree hardlink ${m} target differs from inventory`)}let l=o.materialization;if(l!==void 0){for(let f of l.assertions){let p=u.get(f.sourcePath),g=fr(f.bytesHex);if(p?.type!=="file"||p.data===void 0||p.data.byteLength!==g.byteLength||p.data.some((y,_)=>y!==g[_]))throw new Error(`Lazy tree source assertion ${f.sourcePath} differs from archive bytes`)}let m=new Map(l.recipes.map(f=>[f.id,f]));for(let f of l.transforms){let p=u.get(f.sourcePath);if(p?.type!=="file"||p.data===void 0)throw new Error(`Lazy tree transform ${f.sourcePath} is not a regular source`);await js(p.data,f.input,`Lazy tree transform ${f.sourcePath} input`);let g=ls(p.data,m.get(f.recipe));await js(g,f.output,`Lazy tree transform ${f.sourcePath} output`),p.data=g}}let h=new Map;for(let m of s){if(m.type!=="file"||m.materialization==="descriptor")continue;let f=u.get(m.sourcePath);if(f?.type!=="file"||!f.data)throw new Error(`Lazy tree has no file content for ${m.sourcePath}`);h.set(m.sourcePath,f.data)}return h}async ensureArchiveMaterialized(e,t){let r=this.lazyAtomicGroupByTree.get(e);if(r!==void 0){if(r.committed)return;let c=this.sealedLazyAtomicStates.get(e)?.snapshot,a=this.lazyPreparations.get(r.token)??this.startLazyPreparation({token:r.token,path:c?.activation.roots[0]??e.activation?.roots[0]??e.mountPrefix,atomicGroup:r});try{await a.promise}finally{this.lazyPreparations.get(r.token)===a&&this.lazyPreparations.delete(r.token)}return}if(this.ordinaryLazyTreeDefinitions.get(e)?.materialized??e.materialized)return;let o=this.lazyTransport,s=await this.fetchLazyArchiveData(e,o);te(o.signal),await this.materializeArchiveBytes(e,s,t,o.signal)}async fetchLazyArchiveData(e,t,r){let i=this.ordinaryLazyTreeDefinitions.get(e),o=r?.content??i?.content??e.content,s=r?.inventory??i?.inventory??e.inventory,c=o!==void 0&&s!==void 0,a=r?.mountPrefix??i?.mountPrefix??e.mountPrefix,u=r?.integrity??i?.integrity??e.integrity,d=c?o.transports:[r?.url??i?.url??e.url],l=[],h=null;for(let[m,f]of d.entries())try{h=await this.fetchLazyBytes({id:`archive:${a}:${o?.sha256??f}:${m}`,kind:c?"tree":"archive",url:f,mountPrefix:a,integrity:u},t);break}catch(p){if(te(t.signal),da(p))throw p;l.push(p instanceof Error?p.message:String(p))}if(te(t.signal),h===null)throw new Error(`All ${d.length} lazy ${c?"tree":"archive"} transports failed: ${l.join("; ")}`);return h}async materializeArchiveBytes(e,t,r,i){if(te(i),this.ordinaryLazyTreeDefinitions.get(e)?.materialized??e.materialized)return;let s=await this.prepareLazyArchiveContents(e,t,i),c=r?V(r.ino,r.generation):null;for(let a=0;a<3;a++){let u=this.collectLazyArchiveReplacements(e,s,r);if(u.size>0&&(te(i),!this.fs.replaceManyIfIdentities(Array.from(u.values(),ra)))){if(this.reconcileLazyIdentityState(this.fs.identityState()),c&&!this.lazyArchiveInodes.has(c))return;continue}if(te(i),this.publishLazyArchiveReplacements(e,u),(this.ordinaryLazyTreeDefinitions.get(e)?.materialized??e.materialized)||(this.reconcileLazyIdentityState(this.fs.identityState()),c&&!this.lazyArchiveInodes.has(c)))return}if(c&&this.lazyArchiveInodes.has(c))throw new Error(`Lazy archive member kept changing names while materializing: ${r?.path}`)}async prepareLazyArchiveContents(e,t,r,i){te(r);let o=this.ordinaryLazyTreeDefinitions.get(e),s=i?.content??o?.content??e.content,c=i?.inventory??o?.inventory??e.inventory,u=s!==void 0&&c!==void 0?await this.decodeAndValidateLazyTree(e,t,i):null;te(r);let{parseZipCentralDirectory:d,extractZipEntry:l}=await Promise.resolve().then(()=>(hi(),pi));te(r);let h=u?[]:d(t),m=new Map;for(let E of h){if(m.has(E.fileName))throw new Error(`Lazy archive contains duplicate member: ${E.fileName}`);m.set(E.fileName,E)}let p=(i?.mountPrefix??o?.mountPrefix??e.mountPrefix).replace(/\/+$/,""),g=new Map,y=i?.entries??o?.entries,_=y===void 0?Array.from(e.entries):y.map(E=>[E.vfsPath,E]);for(let[E,w]of _){if(w.deleted||w.materialized)continue;let S=w.archivePath??E.slice(p.length+1),O=u?void 0:m.get(S),z=u?.get(S);if(u){if(z===void 0||z.byteLength!==w.size)throw new Error(`Lazy tree member ${S} does not match its registered metadata`)}else if(O===void 0||O.isDirectory||O.isSymlink||O.uncompressedSize!==w.size)throw new Error(`Lazy archive member ${S} does not match its registered metadata`);if(w.generation===void 0)continue;let x=V(w.ino,w.generation),I=g.get(x);if(I&&I.archivePath!==S)throw new Error(`Lazy archive aliases for inode ${x} name different members`);if(!I){let R=z??l(t,O);if(R.byteLength!==w.size)throw new Error(`Lazy archive member ${S} extracted ${R.byteLength} bytes, expected ${w.size}`);g.set(x,{archivePath:S,content:R})}}return g}collectLazyArchiveReplacements(e,t,r,i){let o=new Map,s=this.ordinaryLazyTreeDefinitions.get(e),c=i?.entries??s?.entries,a=c===void 0?Array.from(e.entries):c.map(u=>[u.vfsPath,u]);for(let[u,d]of a){if(d.deleted||d.materialized||d.generation===void 0)continue;let l=V(d.ino,d.generation);if(this.lazyArchiveInodes.get(l)!==e)continue;let h=t.get(l);if(!h)throw new Error(`Lazy archive has no extracted content for inode ${l}`);let m=o.get(l);m||(m={ino:d.ino,generation:d.generation,dataSequence:d.dataSequence??0,paths:new Set,content:h.content},o.set(l,m)),m.paths.add(u),r&&r.ino===d.ino&&r.generation===d.generation&&m.paths.add(r.path)}return o}publishLazyArchiveReplacements(e,t){let r=this.ordinaryLazyTreeDefinitions.get(e);if(r!==void 0){let i=r.entries.map(o=>{let s=o.generation===void 0?void 0:V(o.ino,o.generation);return s===void 0||!t.has(s)?o:(this.lazyArchiveInodes.delete(s),{...o,materialized:!0})});this.replaceOrdinaryLazyTreeRuntimeState(e,i,i.every(o=>o.deleted||o.materialized));return}for(let[i,o]of t){this.lazyArchiveInodes.delete(i);for(let s of e.entries.values())s.ino===o.ino&&s.generation===o.generation&&(s.materialized=!0)}e.materialized=Array.from(e.entries.values()).every(i=>i.deleted||i.materialized)}collectAtomicTreeNamespace(e,t){let r=new Set,i=new Map,o=new Map,s=new Map(t.entries.map(a=>[a.vfsPath,a]));for(let a of t.inventory)(a.type==="file"||a.type==="hardlink")&&o.set(a.inodeGroup,(o.get(a.inodeGroup)??0)+1);let c=[];for(let a of t.inventory){let u;try{u=this.fs.lstat(a.vfsPath)}catch{throw new Error(`Lazy atomic tree changed at ${a.vfsPath}`)}let d=a.type==="directory"?Qe:a.type==="symlink"?Sr:kt;if((u.mode&Se)!==d||(u.mode&ie.S_MODE_BITS)!==a.mode)throw new Error(`Lazy atomic tree changed at ${a.vfsPath}`);if(a.type==="symlink"){let l=s.get(a.vfsPath);if(l===void 0||!l.isSymlink||l.deleted||l.ino!==u.ino||l.generation!==u.generation||l.dataSequence!==u.dataSequence||this.fs.readlink(a.vfsPath)!==a.target)throw new Error(`Lazy atomic tree changed at ${a.vfsPath}`)}else if(a.type==="file"||a.type==="hardlink"){let l=s.get(a.vfsPath);if(l===void 0||l.deleted||l.materialized||l.isSymlink||l.generation===void 0||l.inodeGroup!==a.inodeGroup||l.ino!==u.ino||l.generation!==u.generation||l.dataSequence!==u.dataSequence||u.size!==0||u.linkCount!==o.get(a.inodeGroup))throw new Error(`Lazy atomic tree changed at ${a.vfsPath}`);let h=V(l.ino,l.generation);if(this.lazyArchiveInodes.get(h)!==e)throw new Error(`Lazy atomic tree lost deferred ownership of ${a.vfsPath}`);let m=i.get(a.inodeGroup);if(m!==void 0&&m!==h)throw new Error(`Lazy atomic tree split hard links at ${a.vfsPath}`);i.set(a.inodeGroup,h),r.add(h)}c.push({path:a.vfsPath,expectedIno:u.ino,expectedGeneration:u.generation,expectedDataSequence:u.dataSequence,expectedMode:u.mode,expectedLinkCount:u.linkCount,expectedSize:u.size,expectedUid:u.uid,expectedGid:u.gid})}return{guards:c,pendingIdentities:r.size}}assertLazyAtomicSnapshotMatchesPublic(e){let t=this.sealedLazyAtomicStates.get(e),r=t?.snapshot,i=e.activation?.atomicGroup,o=r?.member??i?.member??"unknown",s;if(r!==void 0)try{s=on(e,r.id,r.member)}catch{s=void 0}if(t===void 0||r===void 0||i===void 0||!Ft(i)||i.id!==r.id||i.member!==r.member||i.descriptorSha256!==r.descriptorSha256||i.expectedCount!==r.expectedCount||i.cohortSha256!==r.cohortSha256||s===void 0||!ta(r,s))throw new Error(`Lazy atomic activation member ${o} changed after sealing`);return t}assertPendingLazyAtomicSnapshotsReadyForSerialization(){for(let e of this.lazyArchiveGroups){if(!this.sealedLazyAtomicStates.has(e)||this.lazyAtomicGroupByTree.get(e)?.committed)continue;let r=this.assertLazyAtomicSnapshotMatchesPublic(e);if(!r.verified)throw new Error(`Lazy atomic activation group ${r.snapshot.id} has not been cryptographically verified after import`)}}async validatePendingLazyAtomicGroupSeals(e){for(let t of this.lazyAtomicGroups.values()){if(t.committed||t.expectedCount===void 0||t.cohortSha256===void 0)continue;let r=[...t.groups.entries()].sort(([i],[o])=>io?1:0).map(([,i])=>i);await this.ensureLazyAtomicGroupSealValidated(t,r,e)}}async ensureLazyAtomicGroupSealValidated(e,t,r){if(this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r))return;let i=e.sealValidationFlight;if(i===void 0&&(i=this.validateLazyAtomicGroupSealOnce(e,t),e.sealValidationFlight=i,i.then(()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)},()=>{e.sealValidationFlight===i&&(e.sealValidationFlight=void 0)})),await i,!this.assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r))throw new Error(`Lazy atomic activation group ${e.id} seal verification did not authenticate every member`)}assertLazyAtomicGroupSealValidatedAtLinearization(e,t,r){if(e.committed)return!0;if(e.expectedCount===void 0||e.cohortSha256===void 0||t.length!==e.expectedCount)throw new Error(`Lazy atomic activation group ${e.id} is not completely sealed`);let i=!0,o=[];for(let s of t){let c=this.assertLazyAtomicSnapshotMatchesPublic(s),a=c.snapshot;if(a.id!==e.id||a.expectedCount!==e.expectedCount||a.cohortSha256!==e.cohortSha256||e.groups.get(a.member)!==s)throw new Error(`Lazy atomic activation group ${e.id} has an inconsistent member`);i&&=c.verified,o.push(c)}if(i&&r)for(let s=0;sfp?1:0).map(([,f])=>f);if(t.length===0)throw new Error(`Lazy atomic activation group ${e.id} has no trees`);if(await this.ensureLazyAtomicGroupSealValidated(e,t,!0),e.committed)return;let r=t.map(f=>this.sealedLazyAtomicStates.get(f).snapshot),i=t.map((f,p)=>({group:f,...this.collectAtomicTreeNamespace(f,r[p])})),o=this.lazyTransport,s=new Array(t.length),c=0,a=!1,u,d=Array.from({length:Math.min(Gd,t.length)},async()=>{for(;!a;){let f=c++;if(f>=t.length)return;let p=t[f],g=r[f];try{let y=await this.fetchLazyArchiveData(p,o,g);te(o.signal),s[f]={group:p,snapshot:g,contents:await this.prepareLazyArchiveContents(p,y,o.signal,g)}}catch(y){a||(a=!0,u=y)}}});if(await Promise.all(d),a)throw s.fill(void 0),u;te(o.signal);for(let f of t)this.assertLazyAtomicSnapshotMatchesPublic(f);let l=[],h=[],m=[];for(let f=0;f{let c=this.lazyAtomicGroupByTree.get(s),a=this.sealedLazyAtomicStates.get(s)?.snapshot,u=this.ordinaryLazyTreeDefinitions.get(s);return a===void 0?u!==void 0&&!u.materialized:!c?.committed});if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0&&r.length===0)return;let i=Array.from(this.lazyFiles.values(),s=>s.path);for(let s of i)await this.ensureMaterialized(s);let o=new Set(this.lazyArchiveInodes.values());for(let s of r)o.add(s);for(let s of o)await this.prepareLazyTreeGroup(s)}this.reconcileLazyIdentityState(this.fs.identityState());let e=this.lazyArchiveGroups.some(t=>{let r=this.lazyAtomicGroupByTree.get(t),i=this.sealedLazyAtomicStates.get(t)?.snapshot,o=this.ordinaryLazyTreeDefinitions.get(t);return i===void 0?o!==void 0&&!o.materialized:!r?.committed});if(this.lazyFiles.size!==0||this.lazyArchiveInodes.size!==0||e)throw new Error("Cannot create a self-contained VFS image while lazy entries remain pending")}async saveImage(e){e?.materializeAll&&await this.materializeAllLazyEntries(),await this.validatePendingLazyAtomicGroupSeals(!1);let{bytes:t,identities:r}=this.fs.snapshotState({normalizeTimestampsMs:e?.normalizeTimestampsMs});this.reconcileLazyIdentityState(r);let i=this.serializeLazyEntries(),o=i.length>0,s=o?new TextEncoder().encode(JSON.stringify(i)):new Uint8Array(0);if(s.byteLength>an)throw new Error(`VFS image lazy metadata exceeds ${an} bytes`);let c=this.serializeValidatedLazyArchiveEntries(r),a=c.length>0,u=a?new TextEncoder().encode(JSON.stringify(c)):new Uint8Array(0);if(u.byteLength>cn)throw new Error(`VFS image lazy archive metadata exceeds ${cn} bytes`);let d=e?.metadata===void 0?this.imageMetadata:e.metadata,l=jd(d),h=l.byteLength>0,m=a?4+u.byteLength:0,f=h?4+l.byteLength:0,p=ye+t.byteLength+4+s.byteLength+m+f,g=new Uint8Array(p),y=new DataView(g.buffer);y.setUint32(0,Ii,!0),y.setUint32(4,Ti,!0),y.setUint32(8,(o?Si:0)|(a?sn:0)|(a?Oi:0)|(h?wi:0),!0),y.setUint32(12,t.byteLength,!0),g.set(t,ye);let _=ye+t.byteLength;if(y.setUint32(_,s.byteLength,!0),s.byteLength>0&&g.set(s,_+4),a){let E=_+4+s.byteLength;y.setUint32(E,u.byteLength,!0),g.set(u,E+4)}if(h){let E=_+4+s.byteLength+m;y.setUint32(E,l.byteLength,!0),g.set(l,E+4)}return g}static readImageMetadata(e){let t=tn(e);if(!(t.flags&wi))return null;let{metadataOffset:r}=Zs(t.image,t.view,t.flags,t.sabLen);if(t.image.byteLengthCt)throw new Error(`VFS image metadata exceeds ${Ct} bytes`);if(t.image.byteLength0){let g=r.subarray(f+4,f+4+p),y=Me(Ys(g,"VFS image lazy metadata"),"VFS image lazy entries",0,Mt);m.importLazyEntriesInternal(y,!0)}if(o&sn){let g=c.archiveOffset,y=i.getUint32(g,!0);if(y>0){let _=r.subarray(g+4,g+4+y),E=Ys(_,"VFS image lazy archive metadata");m.importLazyArchiveEntriesInternal(E,!0,!!(o&Oi),"pending")}}return m}adaptStat(e){return{dev:0,ino:e.ino,mode:e.mode,nlink:e.linkCount,uid:e.uid,gid:e.gid,size:e.size,atimeMs:e.atime,mtimeMs:e.mtime,ctimeMs:e.ctime}}adaptStatWithLazySize(e){let t=this.adaptStat(e),r=this.lazyFileForStat(e);if(r)return t.size=r.size,t;let i=this.lazyArchiveForStat(e);if(i){for(let o of this.lazyArchiveEntriesForRead(i))if(o.ino===e.ino&&o.generation===e.generation&&!o.deleted){t.size=o.size;break}}return t}open(e,t,r){(t&dr)===0&&!((t&cr)!==0&&(t&ri)!==0)&&this.guardSynchronousLazyAccess(e);let i=this.fs.open(e,t,r);return(t&dr)!==0&&this.invalidateLazyData(this.fs.fstat(i)),i}close(e){return this.fs.close(e),0}read(e,t,r,i){if(i>0){let o=this.lazyBackingForStat(this.fs.fstat(e));o&&(this.reconcileLazyIdentityState(this.fs.identityState()),o=this.lazyBackingForStat(this.fs.fstat(e)),o&&this.guardSynchronousLazyAccess(o.path))}return r!==null?this.fs.readAt(e,t.subarray(0,i),typeof r=="bigint"?Gn(r):r):this.fs.read(e,t.subarray(0,i))}write(e,t,r,i){if(r!==null){let s=this.fs.writeAt(e,t.subarray(0,i),typeof r=="bigint"?Gn(r):r);return s>0&&this.invalidateLazyData(this.fs.fstat(e)),s}let o=this.fs.write(e,t.subarray(0,i));return o>0&&this.invalidateLazyData(this.fs.fstat(e)),o}append(e,t,r,i){let o=this.fs.append(e,t.subarray(0,r),Uo(i));return o.written>0&&this.invalidateLazyData(this.fs.fstat(e)),o}seek(e,t,r){return this.fs.lseek(e,typeof t=="bigint"?Wn(t):t,r)}fstat(e){return this.adaptStatWithLazySize(this.fs.fstat(e))}fpathconf(e,t){let r=this.fstat(e);return Vn(r,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}ftruncate(e,t){this.fs.ftruncate(e,t),this.invalidateLazyData(this.fs.fstat(e))}fsync(e){}fchmod(e,t){this.fs.fchmod(e,t)}fchown(e,t,r){this.fs.fchown(e,t,r)}stat(e){return this.adaptStatWithLazySize(this.fs.stat(e))}lstat(e){return this.adaptStatWithLazySize(this.fs.lstat(e))}statfs(e){this.fs.stat(e);let t=this.fs.statfs();return{type:1397114451,bsize:t.blockSize,blocks:t.totalBlocks,bfree:t.freeBlocks,bavail:t.freeBlocks,files:t.totalInodes,ffree:t.freeInodes,fsid:0,namelen:t.maxName,frsize:t.blockSize,flags:Go}}pathconf(e,t){let r=this.stat(e);return Vn(r,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}mkdir(e,t){this.fs.mkdir(e,t)}rmdir(e){this.fs.rmdir(e)}unlink(e){let t=this.fs.unlink(e),r=V(t.ino,t.generation);if(t.linkCount>1&&(this.lazyFiles.has(r)||this.lazyArchiveInodes.has(r))){this.reconcileLazyIdentityState(this.fs.identityState());return}let i=this.lazyFiles.get(r);i&&(i.paths.delete(e),t.linkCount<=1?this.lazyFiles.delete(r):i.path===e&&(i.path=i.paths.values().next().value));let o=this.lazyArchiveInodes.get(r);if(o){let s=this.ordinaryLazyTreeDefinitions.get(o);if(s!==void 0){let c=t.linkCount<=1?s.entries.map(a=>a.ino===t.ino&&a.generation===t.generation?{...a,deleted:!0}:a):s.entries.filter(a=>a.vfsPath!==e);this.replaceOrdinaryLazyTreeRuntimeState(o,c,s.materialized),t.linkCount<=1&&this.lazyArchiveInodes.delete(r)}else{let c=o.entries.get(e);if(t.linkCount<=1){for(let a of o.entries.values())a.ino===t.ino&&a.generation===t.generation&&(a.deleted=!0);this.lazyArchiveInodes.delete(r)}else c&&o.entries.delete(e)}}}rename(e,t){let{source:r,replaced:i}=this.fs.rename(e,t);if(i&&i.ino===r.ino&&i.generation===r.generation)return;let o=!1;if(i){let s=V(i.ino,i.generation);i.linkCount>1&&(this.lazyFiles.has(s)||this.lazyArchiveInodes.has(s))&&(this.reconcileLazyIdentityState(this.fs.identityState()),o=!0);let c=this.lazyFiles.get(s);!o&&c&&(c.paths.delete(t),i.linkCount<=1?this.lazyFiles.delete(s):c.path===t&&(c.path=c.paths.values().next().value));let a=this.lazyArchiveInodes.get(s);if(!o&&a){let u=this.ordinaryLazyTreeDefinitions.get(a);if(u!==void 0){let d=i.linkCount<=1?u.entries.map(l=>l.ino===i.ino&&l.generation===i.generation?{...l,deleted:!0}:l):u.entries.filter(l=>l.vfsPath!==t);this.replaceOrdinaryLazyTreeRuntimeState(a,d,u.materialized),i.linkCount<=1&&this.lazyArchiveInodes.delete(s)}else{let d=a.entries.get(t);i.linkCount<=1?(d&&(d.deleted=!0),this.lazyArchiveInodes.delete(s)):d&&a.entries.delete(t)}}}o||this.rewriteLazyNamespacePaths(r,e,t)}link(e,t){let r=this.fs.link(e,t),i=V(r.ino,r.generation),o=this.lazyFiles.get(i);o&&o.paths.add(t);let s=this.lazyArchiveInodes.get(i);if(s){let c=this.ordinaryLazyTreeDefinitions.get(s);if(c!==void 0){let a=c.entries.find(u=>u.ino===r.ino&&u.generation===r.generation);a!==void 0&&this.replaceOrdinaryLazyTreeRuntimeState(s,[...c.entries,{...a,vfsPath:t}],c.materialized)}else{let a=Array.from(s.entries.values()).find(u=>u.ino===r.ino&&u.generation===r.generation);a&&s.entries.set(t,{...a})}}}symlink(e,t){this.fs.symlink(e,t)}readlink(e){return this.fs.readlink(e)}chmod(e,t){this.fs.chmod(e,t)}chown(e,t,r){this.fs.chown(e,t,r)}lchown(e,t,r){this.fs.lchown(e,t,r)}createFileWithOwner(e,t,r,i,o){let s=this.open(e,Us,t);o.length>0&&this.write(s,o,null,o.length),this.close(s),this.chown(e,r,i),this.chmod(e,t)}mkdirWithOwner(e,t,r,i){this.mkdir(e,t),this.chown(e,r,i),this.chmod(e,t)}symlinkWithOwner(e,t,r,i){this.symlink(e,t),this.lchown(t,r,i)}copyPathToFreshFileSystem(e,t,r,i,o){let s=this.lstat(e),c=s.mode&Se,a=s.mode&ie.S_MODE_BITS;if(c===Qe){e==="/"?(t.chown(e,s.uid,s.gid),t.chmod(e,a)):t.mkdirWithOwner(e,a,s.uid,s.gid);let h=this.opendir(e);try{for(;;){let m=this.readdir(h);if(!m)break;m.name==="."||m.name===".."||this.copyPathToFreshFileSystem(e==="/"?`/${m.name}`:`${e}/${m.name}`,t,r,i,o)}}finally{this.closedir(h)}n.applyTimes(t,e,s);return}let u=s.nlink>1?`${s.dev}:${s.ino}`:null,d=u?o.get(u):void 0;if(d){t.link(d,e);return}if(c===Sr){t.symlinkWithOwner(this.readlink(e),e,s.uid,s.gid),u&&o.set(u,e);return}if(c!==kt)throw new Error(`Unsupported file type while rebasing VFS: ${e}`);if(r.has(e)||i.has(e)){t.createFileWithOwner(e,a,s.uid,s.gid,new Uint8Array(0)),n.applyTimes(t,e,s),u&&o.set(u,e);return}this.copyRegularFileToFreshFileSystem(e,t,s,a),u&&o.set(u,e)}copyRegularFileToFreshFileSystem(e,t,r,i){let o=this.open(e,Kd,0),s=null;try{s=t.open(e,Us,i);let c=new Uint8Array(Math.min(Bd,Math.max(1,r.size))),a=r.size;for(;a>0;){let u=Math.min(c.byteLength,a),d=this.read(o,c,null,u);if(d<=0)throw new Error(`Unexpected EOF while rebasing VFS file: ${e}`);let l=0;for(;l!e||e==="."||e===".."))throw new Error(`Binary resolver path must be a normalized portable relative path: ${JSON.stringify(n)}`);return n}var mt=new Set(["wasm32","wasm64"]);function Ke(n){if(vl(n),!n.startsWith("programs/"))return n;let e=n.slice(9),t=e.split("/",1)[0];return mt.has(t)?n:`programs/wasm32/${e}`}function Pl(n,e=U(Ui(),"wasm")){let t=Ke(n),r=[U(e,t)];return n==="kernel.wasm"?r.push(U(e,"kandelo-kernel.wasm")):n==="userspace.wasm"?r.push(U(e,"wasm_posix_userspace.wasm")):n==="rootfs.vfs"&&r.push(U(e,"rootfs.vfs")),r}var _n=class extends Error{constructor(e){super(e),this.name="BinaryNotFoundError"}};function Ra(){let n=[],e=!1;try{let r=ht();e=!0;for(let[i,o]of[["local-binaries",U(r,"local-binaries")],["binaries",U(r,"binaries")]])n.push({label:i,root:o,identity:i==="local-binaries"?"local-generation":"program-cache",allowRegularFileClosure:!1,candidatesFor(s){return[U(o,Ke(s))]}})}catch{}let t=U(Ui(),"wasm");return n.push({label:"installed package",root:t,identity:"installed-package",allowRegularFileClosure:!e,candidatesFor(r){return Pl(r,t)}}),n}function Dt(n,e){return new Error(`Invalid package manifest ${n}: ${e}`)}function fe(n){try{return gn(n),!0}catch(e){if(e instanceof Error&&"code"in e&&e.code==="ENOENT")return!1;throw e}}function ga(n,e,t){if(n.length===0||n.startsWith("/")||n.includes("\\")||n.includes("\0")||n.split("/").some(r=>!r||r==="."||r===".."))throw Dt(e,`${t} must be a normalized portable relative path`);return n}function mn(n,e,t,r=!0){if(n.length===0||n==="."||n===".."||n.includes("/")||n.includes("\\")||n.includes("\0")||!r&&n.includes("@"))throw Dt(e,`${t} must be a safe single path component`);return n}var Ea="kandelo-program-packages-v2",De="program-packages.json",Sa=null,kl=null,yn=null,Mi=0;function Wi(){return kl??U(Ui(),"wasm",De)}function ba(){if(Object.prototype.hasOwnProperty.call(process.env,"WASM_POSIX_DEPS_REGISTRY")){let t=null;return(process.env.WASM_POSIX_DEPS_REGISTRY??"").split(":").filter(Boolean).map(r=>r.startsWith("~/")&&process.env.HOME!==void 0?U(process.env.HOME,r.slice(2)):En(r)?Re(r):(t??=ht(),Re(t,r)))}let n;try{n=U(ht(),"packages","registry")}catch{return null}let e=!1;if(fe(n)){if(!rt(n).isDirectory())return[n];e=xa(n,{withFileTypes:!0}).filter(t=>t.isDirectory()||t.isSymbolicLink()).some(t=>fe(U(n,t.name,"package.toml")))}return!e&&La()===null&&fe(Wi())?null:[n]}function La(){let n;try{n=ht()}catch{return null}if(!xr(U(n,"tools","xtask","Cargo.toml"))||!xr(U(n,"scripts","dev-shell.sh")))return null;try{let e=be($i()),t=be(n);return[U(t,"host"),U(t,"scripts")].some(i=>xr(i)&&Zi(be(i),e))?t:null}catch{return null}}function Gi(n,e,t){let r=[typeof t.stderr=="string"?t.stderr.trim():"",typeof t.stdout=="string"?t.stdout.trim():"",t.error?.message??""].filter(Boolean).join(` `);return`${n} ${e.join(" ")} failed${t.status===null?"":` with status ${t.status}`}${r?`: -${r}`:""}`}function vl(n){let e=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,t=e?"rustc":"bash",r=e?["-vV"]:[U(n,"scripts","dev-shell.sh"),"rustc","-vV"],i=Di(t,r,{cwd:n,encoding:"utf8"});if(i.status!==0)throw new Error(Ui(t,r,i));let o=i.stdout.split(/\r?\n/).find(s=>s.startsWith("host: "))?.slice(6).trim();if(!o)throw new Error(`Could not determine the Rust host target for ${n}`);return o}function Ci(n){try{if(yn(n).isFile())return Le(n)}catch{}throw new Error(`Prepared xtask is not a regular file: ${n}`)}function Pl(n){let e=process.env.WASM_POSIX_XTASK_BIN;if(e!==void 0){let u=_n(e)?Re(e):Re(n,e);return Ci(u)}if(hn?.sourceRepoRoot===n)return Ci(hn.xtaskPath);let t=vl(n),r=U(n,"target",t,"release",process.platform==="win32"?"xtask.exe":"xtask"),i=["build","--release","-p","xtask","--target",t,"--quiet"],o=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,s=o?"cargo":"bash",a=o?i:[U(n,"scripts","dev-shell.sh"),"cargo",...i],c=Di(s,a,{cwd:n,encoding:"utf8"});if(c.status!==0)throw new Error(Ui(s,a,c));return hn={sourceRepoRoot:n,xtaskPath:Ci(r)},hn.xtaskPath}function kl(){let n=Ta();if(n===null)return;let e=Ia();if(e===null)return;if(_a){_a(n,e);return}let t=Pl(n),r=["build-deps","program-index-context-check","--source-repo-root",n],i=Di(t,r,{cwd:n,encoding:"utf8",env:{...process.env,WASM_POSIX_DEPS_REGISTRY:e.join(":")}});if(i.status!==0)throw new Error(`Program package source projection is not current: -${Ui(t,r,i)}`)}function Fl(n,e){if(Ni>0||!n.some(t=>t.startsWith("programs/")))return e();Ni+=1;try{return kl(),e()}finally{Ni-=1}}function tt(n,e){let t=Object.keys(n).sort(),r=[...e].sort();return t.length===r.length&&t.every((i,o)=>i===r[o])}function Mi(n){let e;try{e=JSON.parse(pt(n,"utf8"))}catch(s){throw new Error(`Invalid program package index ${n}: ${s instanceof Error?s.message:String(s)}`)}if(typeof e!="object"||e===null||!tt(e,["format","identities","packages"])||e.format!==ya||typeof e.identities!="object"||e.identities===null||Array.isArray(e.identities)||typeof e.packages!="object"||e.packages===null||Array.isArray(e.packages))throw new Error(`Invalid program package index ${n}: expected ${ya}`);let t=new Map,r=e.identities;for(let[s,a]of Object.entries(r)){if(pn(s,n,"identity package name",!1),typeof a!="object"||a===null||!tt(a,["manifestSha256","cacheKeys"])||typeof a.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(a.manifestSha256)||typeof a.cacheKeys!="object"||a.cacheKeys===null||Array.isArray(a.cacheKeys))throw new Error(`Invalid program package index ${n}: malformed identity ${JSON.stringify(s)}`);let c=a.cacheKeys;if(!tt(c,["wasm32","wasm64"])||Object.values(c).some(u=>typeof u!="string"||!/^[a-f0-9]{64}$/.test(u)))throw new Error(`Invalid program package index ${n}: identity ${JSON.stringify(s)} has invalid contextual cache keys`);t.set(s,{manifestSha256:a.manifestSha256,cacheKeys:c})}let i=new Map,o=e.packages;for(let[s,a]of Object.entries(o)){if(pn(s,n,"package name",!1),typeof a!="object"||a===null||!tt(a,["manifestSha256","arches","cacheKeys","dependencyClosures","members"])||!Array.isArray(a.arches)||typeof a.cacheKeys!="object"||a.cacheKeys===null||Array.isArray(a.cacheKeys)||typeof a.dependencyClosures!="object"||a.dependencyClosures===null||Array.isArray(a.dependencyClosures)||!Array.isArray(a.members)||typeof a.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(a.manifestSha256))throw new Error(`Invalid program package index ${n}: malformed package ${JSON.stringify(s)}`);let c=a.arches;if(c.length===0||new Set(c).size!==c.length||c.some(p=>typeof p!="string"||!mt.has(p)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} has invalid arches`);let u=a.cacheKeys;if(!tt(u,c)||Object.values(u).some(p=>typeof p!="string"||!/^[a-f0-9]{64}$/.test(p)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} has invalid cache keys`);let l=a.dependencyClosures;if(!tt(l,c))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} has invalid dependency closure arches`);let d={};for(let p of c){let _=l[p];if(!Array.isArray(_))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} has a malformed dependency closure for ${p}`);let y=new Set;d[p]=_.map((g,E)=>{if(typeof g!="object"||g===null||!tt(g,["packageName","manifestSha256","cacheKey"])||typeof g.packageName!="string"||typeof g.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(g.manifestSha256)||typeof g.cacheKey!="string"||!/^[a-f0-9]{64}$/.test(g.cacheKey))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} dependency ${E+1} for ${p} is malformed`);let O=g;if(pn(O.packageName,n,`${s} dependency packageName`,!1),O.packageName===s||y.has(O.packageName))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} dependency closure for ${p} must contain unique dependencies other than itself`);y.add(O.packageName);let S=t.get(O.packageName);if(!S||S.manifestSha256!==O.manifestSha256||S.cacheKeys[p]!==O.cacheKey)throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} dependency ${JSON.stringify(O.packageName)} for ${p} does not match the index's authoritative contextual identity`);return O})}let h=a.members.map((p,_)=>{if(typeof p!="object"||p===null||p.kind!=="output"&&p.kind!=="runtime-file"||typeof p.sourceArtifact!="string"||typeof p.mirrorPath!="string")throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} member ${_+1} is malformed`);let y=p,g=y.kind==="output"?["kind","sourceArtifact","mirrorPath","outputName","forkInstrumentation"]:["kind","sourceArtifact","mirrorPath","guestPath","mode"];if(!tt(y,g))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} member ${_+1} has unknown or missing fields`);if(ma(y.sourceArtifact,n,`${s} sourceArtifact`),ma(y.mirrorPath,n,`${s} mirrorPath`),y.kind==="output"){if(typeof y.outputName!="string"||y.forkInstrumentation!=="auto"&&y.forkInstrumentation!=="disabled")throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} output member lacks outputName or forkInstrumentation`);pn(y.outputName,n,`${s} outputName`)}else if(typeof y.guestPath!="string"||!y.guestPath.startsWith("/")||!Number.isInteger(y.mode)||y.mode<0||y.mode>511)throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} runtime member lacks valid guestPath or mode`);return y});if(h.length===0||new Set(h.map(p=>p.sourceArtifact)).size!==h.length||new Set(h.map(p=>p.mirrorPath)).size!==h.length||h.length===1&&h[0].mirrorPath.includes("/")||h.length>1&&h.some(p=>!p.mirrorPath.startsWith(`${s}/`)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} members are empty, collide, or violate scalar/package-directory layout`);let m=a.manifestSha256,f=t.get(s);if(!f||f.manifestSha256!==m||c.some(p=>f.cacheKeys[p]!==u[p]))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} does not match its contextual package identity`);i.set(s,{manifestSha256:m,arches:c,cacheKeys:u,dependencyClosures:d,members:h})}return{identities:t,packages:i,indexPath:n}}function Ra(n){return JSON.stringify({manifestSha256:n.manifestSha256,arches:n.arches,cacheKeys:Object.fromEntries(n.arches.map(e=>[e,n.cacheKeys[e]])),dependencyClosures:Object.fromEntries(n.arches.map(e=>[e,[...n.dependencyClosures[e]].sort((t,r)=>t.packageNamer.packageName?1:0)])),members:n.members.map(e=>e.kind==="output"?{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,outputName:e.outputName,forkInstrumentation:e.forkInstrumentation}:{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,guestPath:e.guestPath,mode:e.mode})})}function Wi(){let n=$i();return fe(n)?Mi(n):null}function Nl(n){let e=Wi();if(!e)return null;let t=n.split("/");if(t[0]!=="programs"||!mt.has(t[1]))return null;let r=t[1];if(t.length>=4){let o=t[2];return e.packages.get(o)?.arches.includes(r)?o:null}if(t.length!==3)return null;let i=t[2];for(let[o,s]of e.packages)if(s.arches.includes(r)&&s.members.some(a=>a.kind==="output"&&a.mirrorPath.split("/").at(-1)===i))return o;return null}function ga(n){let e=Nl(n);if(e)throw new Error(`Installed package resolver path ${JSON.stringify(n)} is owned by ${JSON.stringify(e)}, but that package is not selected by the configured program registry`)}function La(){let n=Ia(),e=new Map,t=new Map,r=new Map,i=new Map,o=[];if(n===null){let u=$i();if(!fe(u))return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:o};let l=Mi(u);for(let[d,h]of l.identities)e.set(d,{...h,packageName:d,policyPath:`${l.indexPath}#identities.${d}`});for(let[d,h]of l.packages)o.push({packageName:d,projection:h,selected:!0}),r.set(d,{...h,packageName:d,policyPath:`${l.indexPath}#${d}`});return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:o}}let s=new Set,a=null,c=null;for(let u of n){if(!fe(u))continue;if(!rt(u).isDirectory())throw new Error(`Program registry root is not a directory: ${u}`);let l=U(u,De);if(!fe(l))throw new Error(`Program registry ${u} is missing ${De}; generate it with xtask build-deps program-index`);let d=Mi(l);a??=d.identities,c??=d.packages;let h=Oa(u,{withFileTypes:!0}).filter(m=>m.isDirectory()||m.isSymbolicLink()).sort((m,f)=>m.name.localeCompare(f.name));for(let m of h){let f=m.name,p=U(u,f,"package.toml");if(!fe(p))continue;let _=!1;try{_=rt(p).isFile()}catch{_=!1}if(!_)continue;let y=d.packages.get(f),g=!s.has(f);if(y&&o.push({packageName:f,projection:y,selected:g}),!g)continue;s.add(f);let E=a.get(f);E?e.set(f,{...E,packageName:f,manifestPath:p,policyPath:p}):t.set(f,p);let O=c.get(f);if(!O){i.set(f,p);continue}r.set(f,{...O,packageName:f,manifestPath:p,policyPath:p})}}return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:o}}function Ea(n){if(!n.manifestPath)return;let e;try{e=pt(n.manifestPath)}catch(r){throw new Error(`Program package identity cannot verify ${n.manifestPath}: ${r instanceof Error?r.message:String(r)}`)}if(Aa("sha256").update(e).digest("hex")!==n.manifestSha256)throw new Error(`Program package identity is stale for ${n.manifestPath}; regenerate ${De}`)}function Cl(n){if(!n.manifestPath)return;let e;try{e=pt(n.manifestPath)}catch(r){throw new Error(`Program package projection cannot verify ${n.manifestPath}: ${r instanceof Error?r.message:String(r)}`)}if(Aa("sha256").update(e).digest("hex")!==n.manifestSha256)throw new Error(`Program package projection is stale for ${n.manifestPath}; regenerate ${De}`)}function Ir(n){let e=Gi(),t=e.packages.get(n);if(t)return Cl(t),t;let r=e.unprojectedPackages.get(n);if(r)throw new Error(`Package ${JSON.stringify(n)} is selected at ${r} but is absent from ${De}; regenerate the registry projection`);return null}function Ml(n,e){let t=n.dependencyClosures[e];if(!t)throw Dt(n.policyPath,`package ${JSON.stringify(n.packageName)} lacks a dependency identity closure for ${e}`);let r=La(),i=r.identities.get(n.packageName);if(!i){let s=r.unidentifiedPackages.get(n.packageName);throw new Error(`Program package ${JSON.stringify(n.packageName)} has no authoritative contextual identity for ${e}${s?` at ${s}`:""}; regenerate ${De} with the exact ordered registry roots`)}Ea(i);let o=i.cacheKeys[e];if(i.manifestSha256!==n.manifestSha256||o!==n.cacheKeys[e])throw new Error(`Program package ${JSON.stringify(n.packageName)} was projected with manifest ${n.manifestSha256} and cache key ${n.cacheKeys[e]} for ${e}, but the authoritative first-hit registry context at ${i.policyPath} requires manifest ${i.manifestSha256} and cache key ${o??""}. Regenerate this program projection with the exact ordered registry roots; the highest-priority index must carry the complete combined-context projection rather than relying on a lower suffix-context build identity.`);for(let s of t){let a=r.identities.get(s.packageName);if(!a){let u=r.unidentifiedPackages.get(s.packageName);throw u?new Error(`Program package ${JSON.stringify(n.packageName)} was generated against dependency ${JSON.stringify(s.packageName)}, but the first-hit package at ${u} has no contextual identity in ${De}`):new Error(`Program package ${JSON.stringify(n.packageName)} was generated against dependency ${JSON.stringify(s.packageName)}, but that dependency is absent from the configured first-hit registry roots`)}Ea(a);let c=a.cacheKeys[e];if(a.manifestSha256!==s.manifestSha256||c!==s.cacheKey)throw new Error(`Program package ${JSON.stringify(n.packageName)} has a contextual cache identity mismatch for ${e}: its projection expects dependency ${JSON.stringify(s.packageName)} manifest ${s.manifestSha256} and cache key ${s.cacheKey}, but first-hit selection at ${a.policyPath} provides manifest ${a.manifestSha256} and cache key ${c??""}. Regenerate the program projection with the exact ordered registry roots; the complete highest-priority projection must bind every selected program to the same combined dependency context.`)}}function Gi(){let n=La(),{physicalProgramClaims:e,...t}=n,r={...t,legacyFlatOutputs:new Map,forkInstrumentationDisabledOutputs:new Map},i=[];for(let o of n.packages.values()){let s=o.members.length>1;for(let a of o.arches)for(let c of o.members){let u=i.find(m=>m.arch===a&&(m.path===c.mirrorPath||m.path.startsWith(`${c.mirrorPath}/`)||c.mirrorPath.startsWith(`${m.path}/`)));if(u)throw new Error(`Program resolver paths programs/${a}/${u.path} and programs/${a}/${c.mirrorPath} conflict between selected packages ${JSON.stringify(u.packageName)} and ${JSON.stringify(o.packageName)}`);if(i.push({arch:a,path:c.mirrorPath,packageName:o.packageName}),c.kind!=="output")continue;let l=c.mirrorPath.split("/").at(-1),d=`${a}/${l}`,h=r.legacyFlatOutputs.get(d);h||(h={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},r.legacyFlatOutputs.set(d,h)),s?h.packagePaths.set(`programs/${a}/${c.mirrorPath}`,o.packageName):h.scalarOwners.add(o.packageName),c.forkInstrumentation==="disabled"&&r.forkInstrumentationDisabledOutputs.set(`${a}/${c.mirrorPath}`,o.packageName)}}for(let{packageName:o,projection:s,selected:a}of e)if(!(a&&n.packages.has(o)))for(let c of s.arches)for(let u of s.members){if(u.kind!=="output")continue;let l=u.mirrorPath.split("/").at(-1),d=`${c}/${l}`,h=r.legacyFlatOutputs.get(d);h||(h={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},r.legacyFlatOutputs.set(d,h)),h.shadowedOwners.add(o)}return r}function Dl(n){let e=n.split("/");if(e.length!==3||e[0]!=="programs"||!mt.has(e[1]))return null;let t=Gi().legacyFlatOutputs.get(`${e[1]}/${e[2]}`);if(!t)return null;for(let r of t.scalarOwners){let i=Ir(r);if(i)return i}for(let r of t.packagePaths.values())Ir(r);if(t.packagePaths.size>0)throw new Error(`Legacy flat resolver path ${JSON.stringify(n)} belongs to a multi-member package; use ${[...t.packagePaths.keys()].sort().map(r=>JSON.stringify(r)).join(" or ")}`);for(let r of t.shadowedOwners){let i=Ir(r);if(i)return i;throw new Error(`Legacy flat resolver path ${JSON.stringify(n)} is claimed by a lower-root program package ${JSON.stringify(r)}, but its first-hit selected package does not project that program; stale scalar mirror fallback is forbidden`)}return null}function Sa(n,e,t){if(!n.arches.includes(e))throw Dt(n.policyPath,`package ${JSON.stringify(n.packageName)} does not declare resolver artifacts for ${e}`);let r=n.cacheKeys[e];if(!r)throw Dt(n.policyPath,`package ${JSON.stringify(n.packageName)} lacks a cache identity for ${e}`);Ml(n,e);let i=Ra(n),o=n.members.map(s=>({packageName:n.packageName,relPath:`programs/${e}/${s.mirrorPath}`,sourceArtifact:s.sourceArtifact,cacheKey:r,forkInstrumentation:s.kind==="output"?s.forkInstrumentation??null:null,projectionIdentity:i}));if(!o.some(s=>s.relPath===t))throw Dt(n.policyPath,`resolver path ${JSON.stringify(t)} is not a declared member of package ${JSON.stringify(n.packageName)}`);return{manifestPath:n.policyPath,packageName:n.packageName,members:o}}function Kl(n){let e=Ke(n),t=e.split("/");if(t[0]==="programs"&&!Il()&&Wi()===null)throw new Error(`Installed host package is missing wasm/${De}; program artifacts cannot be resolved without packaged policy`);if(t.length===3){let s=Dl(e);return s?Sa(s,t[1],e):(ga(e),null)}if(t.length<4||t[0]!=="programs"||!mt.has(t[1]))return null;let r=t[1],i=t[2],o=Ir(i);return o?Sa(o,r,e):(ga(e),null)}function Bl(n){let e=Ke(n);for(let t of["programs/wasm32/","programs/wasm64/"])if(e.startsWith(t))return e.slice(t.length);return null}function $l(n){let e=Ke(n);for(let t of mt){let r=`programs/${t}/`;if(e.startsWith(r)){let i=Gi().forkInstrumentationDisabledOutputs.get(`${t}/${e.slice(r.length)}`);return i?Ir(i)!==null:!1}}return!1}function Ul(n){let e=Ke(n);if(e==="kernel.wasm")return Ao;let t=Bl(e);if(t&&t.endsWith(".wasm"))return zl}var Wl=Object.freeze(["kernel_exec_prepare","kernel_exec_setup","kernel_exec_setup_for_thread","kernel_execve","kernel_execveat"]);function Gl(n){return Ke(n)==="kernel.wasm"?Wl:void 0}function Hl(n,e,t){if(!n.endsWith(".wasm"))return!1;try{let r=pt(n),i=r.buffer.slice(r.byteOffset,r.byteOffset+r.byteLength),o=t===void 0?$l(e):t==="disabled";return Fo(i,{expectedAbi:43,requiredExports:Ul(e),forbiddenExports:Gl(e),requireForkInstrumentation:o?!1:void 0,forbidForkInstrumentation:o}).length>0}catch{return!0}}function Vl(n){if(!n.endsWith(".vfs")&&!n.endsWith(".vfs.zst"))return!1;try{let t=q.readImageMetadata(pt(n))?.kernelAbi;return t!==void 0&&t!==43}catch{return!0}}function Hi(n,e,t){return Hl(n,e,t)||Vl(n)}function ba(n,e,t){let r=n.filter(fe);return r.length===0?null:r.find(i=>{try{return rt(i).isFile()&&!Hi(i,e,t)}catch{return!1}})??null}function va(n,e,t){try{if(!yn(n).isSymbolicLink())return n;let i=Le(n);if(!rt(i).isFile()||Hi(i,e,t))throw new Error("canonical target is not an accepted regular file");if(Ke(e).startsWith("programs/")&&ql(i))throw new Error("resolver-owned program generation has no matching selected package projection");return i}catch(r){throw new Error(`Binary changed or became invalid while pinning ${e}: ${r instanceof Error?r.message:String(r)}`)}}function ql(n){let e=[za()];try{e.push(U(ht(),"local-binaries",".kandelo-local-generations"))}catch{}return e.some(t=>{try{return fe(t)&&Vi(Le(t),n)}catch{return!1}})}function Vi(n,e){let t=wl(n,e);return t===""||t!==".."&&!t.startsWith(`..${Ol}`)&&!_n(t)}function Zl(n,e){let t=e.split("/"),r=n;for(let i=0;ia.packageName!==o))return"declared package members do not share a valid program namespace";if(!rt(e).isDirectory())return"shared package generation root is not a directory";let s=t[0].cacheKey;if(!/^[a-f0-9]{64}$/.test(s)||t.some(a=>a.cacheKey!==s))return"declared package members do not share one valid cache identity";if(n.identity==="local-generation"){let a=U(n.root,".kandelo-local-generations",i,o,s);if(!fe(a))return"local mirror targets are not one direct immutable local generation";let c=Le(a);return Tr(e)===c?null:"local mirror targets are not one direct immutable local generation"}if(n.identity==="program-cache"){let a=za();if(!fe(a))return"fetched mirror targets are not one canonical program-cache generation";let c=Le(a),u=Sl(e),l=u.startsWith(`${o}-`)&&new RegExp(`-rev[0-9]+-${i}-${s}$`).test(u);return Tr(e)===c&&l?null:"fetched mirror targets are not one canonical program-cache generation"}return"installed-package symlink closures are not an immutable installed identity"}function Xl(n,e,t){if(e.length!==t.length)return{failure:"internal member/path count mismatch"};try{let r=e.map(u=>{let l=yn(u);return l.isSymbolicLink()?"symlink":l.isFile()?"file":"other"});if(r.includes("other"))return{failure:"a selected mirror member is neither a regular file nor a symlink"};let i=r.every(u=>u==="symlink"),o=r.every(u=>u==="file");if(!i&&!o)return{failure:"regular files and symlinks cannot share one package identity"};if(o){if(!n.allowRegularFileClosure)return{failure:"a mutable source-checkout wasm tree is not an installed package identity"};let u=t[0].packageName,l=t[0].projectionIdentity;if(t.some(p=>p.packageName!==u||p.projectionIdentity!==l))return{failure:"declared members do not share one selected package projection"};let h=Wi()?.packages.get(u);if(!h||Ra(h)!==l)return{failure:"installed bytes do not match the selected package projection"};let m=Le(n.root),f=[];for(let p of e){let _=Le(p);if(!Vi(m,_)||!rt(_).isFile())return{failure:"an installed-package member escapes its immutable wasm tree"};f.push(_)}return{paths:f}}let s=null,a=[];for(let u=0;ujl(n))}function jl(n){let e=Ke(n),t=Kl(e);if(t){let s=Jl(t.members.map(a=>a.relPath),t.members);if(s)return s[t.members.findIndex(a=>a.relPath===e)];throw new mn(`Package artifacts not found for ${t.packageName}: ${e}`)}let r=[],i=[];for(let s of xa())for(let a of s.candidatesFor(n))r.push(a),i.push(a);let o=ba(i,n);if(o)return va(o,n);throw i.some(fe)?new Error(`Binary exists but was rejected by artifact policy: ${n} +${r}`:""}`}function Fl(n){let e=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,t=e?"rustc":"bash",r=e?["-vV"]:[U(n,"scripts","dev-shell.sh"),"rustc","-vV"],i=Bi(t,r,{cwd:n,encoding:"utf8"});if(i.status!==0)throw new Error(Gi(t,r,i));let o=i.stdout.split(/\r?\n/).find(s=>s.startsWith("host: "))?.slice(6).trim();if(!o)throw new Error(`Could not determine the Rust host target for ${n}`);return o}function Di(n){try{if(gn(n).isFile())return be(n)}catch{}throw new Error(`Prepared xtask is not a regular file: ${n}`)}function Nl(n){let e=process.env.WASM_POSIX_XTASK_BIN;if(e!==void 0){let u=En(e)?Re(e):Re(n,e);return Di(u)}if(yn?.sourceRepoRoot===n)return Di(yn.xtaskPath);let t=Fl(n),r=U(n,"target",t,"release",process.platform==="win32"?"xtask.exe":"xtask"),i=["build","--release","-p","xtask","--target",t,"--quiet"],o=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,s=o?"cargo":"bash",c=o?i:[U(n,"scripts","dev-shell.sh"),"cargo",...i],a=Bi(s,c,{cwd:n,encoding:"utf8"});if(a.status!==0)throw new Error(Gi(s,c,a));return yn={sourceRepoRoot:n,xtaskPath:Di(r)},yn.xtaskPath}function Cl(){let n=La();if(n===null)return;let e=ba();if(e===null)return;if(Sa){Sa(n,e);return}let t=Nl(n),r=["build-deps","program-index-context-check","--source-repo-root",n],i=Bi(t,r,{cwd:n,encoding:"utf8",env:{...process.env,WASM_POSIX_DEPS_REGISTRY:e.join(":")}});if(i.status!==0)throw new Error(`Program package source projection is not current: +${Gi(t,r,i)}`)}function Ml(n,e){if(Mi>0||!n.some(t=>t.startsWith("programs/")))return e();Mi+=1;try{return Cl(),e()}finally{Mi-=1}}function tt(n,e){let t=Object.keys(n).sort(),r=[...e].sort();return t.length===r.length&&t.every((i,o)=>i===r[o])}function Ki(n){let e;try{e=JSON.parse(pt(n,"utf8"))}catch(s){throw new Error(`Invalid program package index ${n}: ${s instanceof Error?s.message:String(s)}`)}if(typeof e!="object"||e===null||!tt(e,["format","identities","packages"])||e.format!==Ea||typeof e.identities!="object"||e.identities===null||Array.isArray(e.identities)||typeof e.packages!="object"||e.packages===null||Array.isArray(e.packages))throw new Error(`Invalid program package index ${n}: expected ${Ea}`);let t=new Map,r=e.identities;for(let[s,c]of Object.entries(r)){if(mn(s,n,"identity package name",!1),typeof c!="object"||c===null||!tt(c,["manifestSha256","cacheKeys"])||typeof c.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(c.manifestSha256)||typeof c.cacheKeys!="object"||c.cacheKeys===null||Array.isArray(c.cacheKeys))throw new Error(`Invalid program package index ${n}: malformed identity ${JSON.stringify(s)}`);let a=c.cacheKeys;if(!tt(a,["wasm32","wasm64"])||Object.values(a).some(u=>typeof u!="string"||!/^[a-f0-9]{64}$/.test(u)))throw new Error(`Invalid program package index ${n}: identity ${JSON.stringify(s)} has invalid contextual cache keys`);t.set(s,{manifestSha256:c.manifestSha256,cacheKeys:a})}let i=new Map,o=e.packages;for(let[s,c]of Object.entries(o)){if(mn(s,n,"package name",!1),typeof c!="object"||c===null||!tt(c,["manifestSha256","arches","cacheKeys","dependencyClosures","members"])||!Array.isArray(c.arches)||typeof c.cacheKeys!="object"||c.cacheKeys===null||Array.isArray(c.cacheKeys)||typeof c.dependencyClosures!="object"||c.dependencyClosures===null||Array.isArray(c.dependencyClosures)||!Array.isArray(c.members)||typeof c.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(c.manifestSha256))throw new Error(`Invalid program package index ${n}: malformed package ${JSON.stringify(s)}`);let a=c.arches;if(a.length===0||new Set(a).size!==a.length||a.some(p=>typeof p!="string"||!mt.has(p)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} has invalid arches`);let u=c.cacheKeys;if(!tt(u,a)||Object.values(u).some(p=>typeof p!="string"||!/^[a-f0-9]{64}$/.test(p)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} has invalid cache keys`);let d=c.dependencyClosures;if(!tt(d,a))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} has invalid dependency closure arches`);let l={};for(let p of a){let g=d[p];if(!Array.isArray(g))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} has a malformed dependency closure for ${p}`);let y=new Set;l[p]=g.map((_,E)=>{if(typeof _!="object"||_===null||!tt(_,["packageName","manifestSha256","cacheKey"])||typeof _.packageName!="string"||typeof _.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(_.manifestSha256)||typeof _.cacheKey!="string"||!/^[a-f0-9]{64}$/.test(_.cacheKey))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} dependency ${E+1} for ${p} is malformed`);let w=_;if(mn(w.packageName,n,`${s} dependency packageName`,!1),w.packageName===s||y.has(w.packageName))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} dependency closure for ${p} must contain unique dependencies other than itself`);y.add(w.packageName);let S=t.get(w.packageName);if(!S||S.manifestSha256!==w.manifestSha256||S.cacheKeys[p]!==w.cacheKey)throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} dependency ${JSON.stringify(w.packageName)} for ${p} does not match the index's authoritative contextual identity`);return w})}let h=c.members.map((p,g)=>{if(typeof p!="object"||p===null||p.kind!=="output"&&p.kind!=="runtime-file"||typeof p.sourceArtifact!="string"||typeof p.mirrorPath!="string")throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} member ${g+1} is malformed`);let y=p,_=y.kind==="output"?["kind","sourceArtifact","mirrorPath","outputName","forkInstrumentation"]:["kind","sourceArtifact","mirrorPath","guestPath","mode"];if(!tt(y,_))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} member ${g+1} has unknown or missing fields`);if(ga(y.sourceArtifact,n,`${s} sourceArtifact`),ga(y.mirrorPath,n,`${s} mirrorPath`),y.kind==="output"){if(typeof y.outputName!="string"||y.forkInstrumentation!=="auto"&&y.forkInstrumentation!=="disabled")throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} output member lacks outputName or forkInstrumentation`);mn(y.outputName,n,`${s} outputName`)}else if(typeof y.guestPath!="string"||!y.guestPath.startsWith("/")||!Number.isInteger(y.mode)||y.mode<0||y.mode>511)throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} runtime member lacks valid guestPath or mode`);return y});if(h.length===0||new Set(h.map(p=>p.sourceArtifact)).size!==h.length||new Set(h.map(p=>p.mirrorPath)).size!==h.length||h.length===1&&h[0].mirrorPath.includes("/")||h.length>1&&h.some(p=>!p.mirrorPath.startsWith(`${s}/`)))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} members are empty, collide, or violate scalar/package-directory layout`);let m=c.manifestSha256,f=t.get(s);if(!f||f.manifestSha256!==m||a.some(p=>f.cacheKeys[p]!==u[p]))throw new Error(`Invalid program package index ${n}: package ${JSON.stringify(s)} does not match its contextual package identity`);i.set(s,{manifestSha256:m,arches:a,cacheKeys:u,dependencyClosures:l,members:h})}return{identities:t,packages:i,indexPath:n}}function va(n){return JSON.stringify({manifestSha256:n.manifestSha256,arches:n.arches,cacheKeys:Object.fromEntries(n.arches.map(e=>[e,n.cacheKeys[e]])),dependencyClosures:Object.fromEntries(n.arches.map(e=>[e,[...n.dependencyClosures[e]].sort((t,r)=>t.packageNamer.packageName?1:0)])),members:n.members.map(e=>e.kind==="output"?{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,outputName:e.outputName,forkInstrumentation:e.forkInstrumentation}:{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,guestPath:e.guestPath,mode:e.mode})})}function Hi(){let n=Wi();return fe(n)?Ki(n):null}function Dl(n){let e=Hi();if(!e)return null;let t=n.split("/");if(t[0]!=="programs"||!mt.has(t[1]))return null;let r=t[1];if(t.length>=4){let o=t[2];return e.packages.get(o)?.arches.includes(r)?o:null}if(t.length!==3)return null;let i=t[2];for(let[o,s]of e.packages)if(s.arches.includes(r)&&s.members.some(c=>c.kind==="output"&&c.mirrorPath.split("/").at(-1)===i))return o;return null}function wa(n){let e=Dl(n);if(e)throw new Error(`Installed package resolver path ${JSON.stringify(n)} is owned by ${JSON.stringify(e)}, but that package is not selected by the configured program registry`)}function Pa(){let n=ba(),e=new Map,t=new Map,r=new Map,i=new Map,o=[];if(n===null){let u=Wi();if(!fe(u))return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:o};let d=Ki(u);for(let[l,h]of d.identities)e.set(l,{...h,packageName:l,policyPath:`${d.indexPath}#identities.${l}`});for(let[l,h]of d.packages)o.push({packageName:l,projection:h,selected:!0}),r.set(l,{...h,packageName:l,policyPath:`${d.indexPath}#${l}`});return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:o}}let s=new Set,c=null,a=null;for(let u of n){if(!fe(u))continue;if(!rt(u).isDirectory())throw new Error(`Program registry root is not a directory: ${u}`);let d=U(u,De);if(!fe(d))throw new Error(`Program registry ${u} is missing ${De}; generate it with xtask build-deps program-index`);let l=Ki(d);c??=l.identities,a??=l.packages;let h=xa(u,{withFileTypes:!0}).filter(m=>m.isDirectory()||m.isSymbolicLink()).sort((m,f)=>m.name.localeCompare(f.name));for(let m of h){let f=m.name,p=U(u,f,"package.toml");if(!fe(p))continue;let g=!1;try{g=rt(p).isFile()}catch{g=!1}if(!g)continue;let y=l.packages.get(f),_=!s.has(f);if(y&&o.push({packageName:f,projection:y,selected:_}),!_)continue;s.add(f);let E=c.get(f);E?e.set(f,{...E,packageName:f,manifestPath:p,policyPath:p}):t.set(f,p);let w=a.get(f);if(!w){i.set(f,p);continue}r.set(f,{...w,packageName:f,manifestPath:p,policyPath:p})}}return{identities:e,unidentifiedPackages:t,packages:r,unprojectedPackages:i,physicalProgramClaims:o}}function Oa(n){if(!n.manifestPath)return;let e;try{e=pt(n.manifestPath)}catch(r){throw new Error(`Program package identity cannot verify ${n.manifestPath}: ${r instanceof Error?r.message:String(r)}`)}if(Ia("sha256").update(e).digest("hex")!==n.manifestSha256)throw new Error(`Program package identity is stale for ${n.manifestPath}; regenerate ${De}`)}function Kl(n){if(!n.manifestPath)return;let e;try{e=pt(n.manifestPath)}catch(r){throw new Error(`Program package projection cannot verify ${n.manifestPath}: ${r instanceof Error?r.message:String(r)}`)}if(Ia("sha256").update(e).digest("hex")!==n.manifestSha256)throw new Error(`Program package projection is stale for ${n.manifestPath}; regenerate ${De}`)}function Ir(n){let e=Vi(),t=e.packages.get(n);if(t)return Kl(t),t;let r=e.unprojectedPackages.get(n);if(r)throw new Error(`Package ${JSON.stringify(n)} is selected at ${r} but is absent from ${De}; regenerate the registry projection`);return null}function Bl(n,e){let t=n.dependencyClosures[e];if(!t)throw Dt(n.policyPath,`package ${JSON.stringify(n.packageName)} lacks a dependency identity closure for ${e}`);let r=Pa(),i=r.identities.get(n.packageName);if(!i){let s=r.unidentifiedPackages.get(n.packageName);throw new Error(`Program package ${JSON.stringify(n.packageName)} has no authoritative contextual identity for ${e}${s?` at ${s}`:""}; regenerate ${De} with the exact ordered registry roots`)}Oa(i);let o=i.cacheKeys[e];if(i.manifestSha256!==n.manifestSha256||o!==n.cacheKeys[e])throw new Error(`Program package ${JSON.stringify(n.packageName)} was projected with manifest ${n.manifestSha256} and cache key ${n.cacheKeys[e]} for ${e}, but the authoritative first-hit registry context at ${i.policyPath} requires manifest ${i.manifestSha256} and cache key ${o??""}. Regenerate this program projection with the exact ordered registry roots; the highest-priority index must carry the complete combined-context projection rather than relying on a lower suffix-context build identity.`);for(let s of t){let c=r.identities.get(s.packageName);if(!c){let u=r.unidentifiedPackages.get(s.packageName);throw u?new Error(`Program package ${JSON.stringify(n.packageName)} was generated against dependency ${JSON.stringify(s.packageName)}, but the first-hit package at ${u} has no contextual identity in ${De}`):new Error(`Program package ${JSON.stringify(n.packageName)} was generated against dependency ${JSON.stringify(s.packageName)}, but that dependency is absent from the configured first-hit registry roots`)}Oa(c);let a=c.cacheKeys[e];if(c.manifestSha256!==s.manifestSha256||a!==s.cacheKey)throw new Error(`Program package ${JSON.stringify(n.packageName)} has a contextual cache identity mismatch for ${e}: its projection expects dependency ${JSON.stringify(s.packageName)} manifest ${s.manifestSha256} and cache key ${s.cacheKey}, but first-hit selection at ${c.policyPath} provides manifest ${c.manifestSha256} and cache key ${a??""}. Regenerate the program projection with the exact ordered registry roots; the complete highest-priority projection must bind every selected program to the same combined dependency context.`)}}function Vi(){let n=Pa(),{physicalProgramClaims:e,...t}=n,r={...t,legacyFlatOutputs:new Map,forkInstrumentationDisabledOutputs:new Map},i=[];for(let o of n.packages.values()){let s=o.members.length>1;for(let c of o.arches)for(let a of o.members){let u=i.find(m=>m.arch===c&&(m.path===a.mirrorPath||m.path.startsWith(`${a.mirrorPath}/`)||a.mirrorPath.startsWith(`${m.path}/`)));if(u)throw new Error(`Program resolver paths programs/${c}/${u.path} and programs/${c}/${a.mirrorPath} conflict between selected packages ${JSON.stringify(u.packageName)} and ${JSON.stringify(o.packageName)}`);if(i.push({arch:c,path:a.mirrorPath,packageName:o.packageName}),a.kind!=="output")continue;let d=a.mirrorPath.split("/").at(-1),l=`${c}/${d}`,h=r.legacyFlatOutputs.get(l);h||(h={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},r.legacyFlatOutputs.set(l,h)),s?h.packagePaths.set(`programs/${c}/${a.mirrorPath}`,o.packageName):h.scalarOwners.add(o.packageName),a.forkInstrumentation==="disabled"&&r.forkInstrumentationDisabledOutputs.set(`${c}/${a.mirrorPath}`,o.packageName)}}for(let{packageName:o,projection:s,selected:c}of e)if(!(c&&n.packages.has(o)))for(let a of s.arches)for(let u of s.members){if(u.kind!=="output")continue;let d=u.mirrorPath.split("/").at(-1),l=`${a}/${d}`,h=r.legacyFlatOutputs.get(l);h||(h={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},r.legacyFlatOutputs.set(l,h)),h.shadowedOwners.add(o)}return r}function $l(n){let e=n.split("/");if(e.length!==3||e[0]!=="programs"||!mt.has(e[1]))return null;let t=Vi().legacyFlatOutputs.get(`${e[1]}/${e[2]}`);if(!t)return null;for(let r of t.scalarOwners){let i=Ir(r);if(i)return i}for(let r of t.packagePaths.values())Ir(r);if(t.packagePaths.size>0)throw new Error(`Legacy flat resolver path ${JSON.stringify(n)} belongs to a multi-member package; use ${[...t.packagePaths.keys()].sort().map(r=>JSON.stringify(r)).join(" or ")}`);for(let r of t.shadowedOwners){let i=Ir(r);if(i)return i;throw new Error(`Legacy flat resolver path ${JSON.stringify(n)} is claimed by a lower-root program package ${JSON.stringify(r)}, but its first-hit selected package does not project that program; stale scalar mirror fallback is forbidden`)}return null}function Aa(n,e,t){if(!n.arches.includes(e))throw Dt(n.policyPath,`package ${JSON.stringify(n.packageName)} does not declare resolver artifacts for ${e}`);let r=n.cacheKeys[e];if(!r)throw Dt(n.policyPath,`package ${JSON.stringify(n.packageName)} lacks a cache identity for ${e}`);Bl(n,e);let i=va(n),o=n.members.map(s=>({packageName:n.packageName,relPath:`programs/${e}/${s.mirrorPath}`,sourceArtifact:s.sourceArtifact,cacheKey:r,forkInstrumentation:s.kind==="output"?s.forkInstrumentation??null:null,projectionIdentity:i}));if(!o.some(s=>s.relPath===t))throw Dt(n.policyPath,`resolver path ${JSON.stringify(t)} is not a declared member of package ${JSON.stringify(n.packageName)}`);return{manifestPath:n.policyPath,packageName:n.packageName,members:o}}function Ul(n){let e=Ke(n),t=e.split("/");if(t[0]==="programs"&&!bl()&&Hi()===null)throw new Error(`Installed host package is missing wasm/${De}; program artifacts cannot be resolved without packaged policy`);if(t.length===3){let s=$l(e);return s?Aa(s,t[1],e):(wa(e),null)}if(t.length<4||t[0]!=="programs"||!mt.has(t[1]))return null;let r=t[1],i=t[2],o=Ir(i);return o?Aa(o,r,e):(wa(e),null)}function Wl(n){let e=Ke(n);for(let t of["programs/wasm32/","programs/wasm64/"])if(e.startsWith(t))return e.slice(t.length);return null}function Gl(n){let e=Ke(n);for(let t of mt){let r=`programs/${t}/`;if(e.startsWith(r)){let i=Vi().forkInstrumentationDisabledOutputs.get(`${t}/${e.slice(r.length)}`);return i?Ir(i)!==null:!1}}return!1}function Hl(n){let e=Ke(n);if(e==="kernel.wasm")return xo;let t=Wl(e);if(t&&t.endsWith(".wasm"))return Tl}var Vl=Object.freeze(["kernel_exec_prepare","kernel_exec_setup","kernel_exec_setup_for_thread","kernel_execve","kernel_execveat"]);function ql(n){return Ke(n)==="kernel.wasm"?Vl:void 0}function Zl(n,e,t){if(!n.endsWith(".wasm"))return!1;try{let r=pt(n),i=r.buffer.slice(r.byteOffset,r.byteOffset+r.byteLength),o=t===void 0?Gl(e):t==="disabled";return Co(i,{expectedAbi:43,requiredExports:Hl(e),forbiddenExports:ql(e),requireForkInstrumentation:o?!1:void 0,forbidForkInstrumentation:o}).length>0}catch{return!0}}function Yl(n){if(!n.endsWith(".vfs")&&!n.endsWith(".vfs.zst"))return!1;try{let t=q.readImageMetadata(pt(n))?.kernelAbi;return t!==void 0&&t!==43}catch{return!0}}function qi(n,e,t){return Zl(n,e,t)||Yl(n)}function ka(n,e,t){let r=n.filter(fe);return r.length===0?null:r.find(i=>{try{return rt(i).isFile()&&!qi(i,e,t)}catch{return!1}})??null}function Fa(n,e,t){try{if(!gn(n).isSymbolicLink())return n;let i=be(n);if(!rt(i).isFile()||qi(i,e,t))throw new Error("canonical target is not an accepted regular file");if(Ke(e).startsWith("programs/")&&Xl(i))throw new Error("resolver-owned program generation has no matching selected package projection");return i}catch(r){throw new Error(`Binary changed or became invalid while pinning ${e}: ${r instanceof Error?r.message:String(r)}`)}}function Xl(n){let e=[Ta()];try{e.push(U(ht(),"local-binaries",".kandelo-local-generations"))}catch{}return e.some(t=>{try{return fe(t)&&Zi(be(t),n)}catch{return!1}})}function Zi(n,e){let t=zl(n,e);return t===""||t!==".."&&!t.startsWith(`..${xl}`)&&!En(t)}function jl(n,e){let t=e.split("/"),r=n;for(let i=0;ic.packageName!==o))return"declared package members do not share a valid program namespace";if(!rt(e).isDirectory())return"shared package generation root is not a directory";let s=t[0].cacheKey;if(!/^[a-f0-9]{64}$/.test(s)||t.some(c=>c.cacheKey!==s))return"declared package members do not share one valid cache identity";if(n.identity==="local-generation"){let c=U(n.root,".kandelo-local-generations",i,o,s);if(!fe(c))return"local mirror targets are not one direct immutable local generation";let a=be(c);return Tr(e)===a?null:"local mirror targets are not one direct immutable local generation"}if(n.identity==="program-cache"){let c=Ta();if(!fe(c))return"fetched mirror targets are not one canonical program-cache generation";let a=be(c),u=Al(e),d=u.startsWith(`${o}-`)&&new RegExp(`-rev[0-9]+-${i}-${s}$`).test(u);return Tr(e)===a&&d?null:"fetched mirror targets are not one canonical program-cache generation"}return"installed-package symlink closures are not an immutable installed identity"}function Ql(n,e,t){if(e.length!==t.length)return{failure:"internal member/path count mismatch"};try{let r=e.map(u=>{let d=gn(u);return d.isSymbolicLink()?"symlink":d.isFile()?"file":"other"});if(r.includes("other"))return{failure:"a selected mirror member is neither a regular file nor a symlink"};let i=r.every(u=>u==="symlink"),o=r.every(u=>u==="file");if(!i&&!o)return{failure:"regular files and symlinks cannot share one package identity"};if(o){if(!n.allowRegularFileClosure)return{failure:"a mutable source-checkout wasm tree is not an installed package identity"};let u=t[0].packageName,d=t[0].projectionIdentity;if(t.some(p=>p.packageName!==u||p.projectionIdentity!==d))return{failure:"declared members do not share one selected package projection"};let h=Hi()?.packages.get(u);if(!h||va(h)!==d)return{failure:"installed bytes do not match the selected package projection"};let m=be(n.root),f=[];for(let p of e){let g=be(p);if(!Zi(m,g)||!rt(g).isFile())return{failure:"an installed-package member escapes its immutable wasm tree"};f.push(g)}return{paths:f}}let s=null,c=[];for(let u=0;uef(n))}function ef(n){let e=Ke(n),t=Ul(e);if(t){let s=tf(t.members.map(c=>c.relPath),t.members);if(s)return s[t.members.findIndex(c=>c.relPath===e)];throw new _n(`Package artifacts not found for ${t.packageName}: ${e}`)}let r=[],i=[];for(let s of Ra())for(let c of s.candidatesFor(n))r.push(c),i.push(c);let o=ka(i,n);if(o)return Fa(o,n);throw i.some(fe)?new Error(`Binary exists but was rejected by artifact policy: ${n} `+r.map(s=>` checked: ${s}`).join(` -`)):new mn(`Binary not found: ${n} +`)):new _n(`Binary not found: ${n} `+r.map(s=>` checked: ${s}`).join(` `)+` - Run scripts/fetch-binaries.sh, place a file at local-binaries/${e}, or install a package that includes wasm/${n}.`)}function Jl(n,e){if(n.length===0)return[];let t=!1,r=[];for(let i of xa()){let o=[],s=[];if(e){let[a,c,u]=e[0].relPath.split("/");a==="programs"&&c&&u&&(t||=fe(U(i.root,a,c,u)))}for(let[a,c]of n.entries()){let u=i.candidatesFor(c),l=u.filter(fe);t||=l.length>0;let d=ba(u,c,e?.[a]?.forkInstrumentation);d?o.push(d):l.length>0?s.push(`${c} (rejected by artifact policy)`):s.push(`${c} (missing)`)}if(s.length===0&&e){let a=Xl(i,o,e);if("failure"in a)s.push(`shared package identity rejected: ${a.failure}`);else{let c=a.paths.flatMap((u,l)=>Hi(u,n[l],e[l].forkInstrumentation)?[n[l]]:[]);if(c.length>0)s.push(`pinned package generation rejected by artifact policy: ${c.join(", ")}`);else return a.paths}}if(s.length===0)return o.map((a,c)=>va(a,n[c],e?.[c]?.forkInstrumentation));r.push(` ${i.label} (${i.root}): ${s.join(", ")}`)}if(!t)return null;throw new Error(`Package artifact closure is incomplete: no single provenance tier contains every accepted artifact, and tiers will not be mixed. + Run scripts/fetch-binaries.sh, place a file at local-binaries/${e}, or install a package that includes wasm/${n}.`)}function tf(n,e){if(n.length===0)return[];let t=!1,r=[];for(let i of Ra()){let o=[],s=[];if(e){let[c,a,u]=e[0].relPath.split("/");c==="programs"&&a&&u&&(t||=fe(U(i.root,c,a,u)))}for(let[c,a]of n.entries()){let u=i.candidatesFor(a),d=u.filter(fe);t||=d.length>0;let l=ka(u,a,e?.[c]?.forkInstrumentation);l?o.push(l):d.length>0?s.push(`${a} (rejected by artifact policy)`):s.push(`${a} (missing)`)}if(s.length===0&&e){let c=Ql(i,o,e);if("failure"in c)s.push(`shared package identity rejected: ${c.failure}`);else{let a=c.paths.flatMap((u,d)=>qi(u,n[d],e[d].forkInstrumentation)?[n[d]]:[]);if(a.length>0)s.push(`pinned package generation rejected by artifact policy: ${a.join(", ")}`);else return c.paths}}if(s.length===0)return o.map((c,a)=>Fa(c,n[a],e?.[a]?.forkInstrumentation));r.push(` ${i.label} (${i.root}): ${s.join(", ")}`)}if(!t)return null;throw new Error(`Package artifact closure is incomplete: no single provenance tier contains every accepted artifact, and tiers will not be mixed. `+r.join(` -`))}var[ka,...Ql]=process.argv.slice(2);(!ka||Ql.length>0)&&(console.error("usage: scripts/resolve-binary.sh "),process.exit(2));try{process.stdout.write(`${Pa(ka)} +`))}var[Ca,...rf]=process.argv.slice(2);(!Ca||rf.length>0)&&(console.error("usage: scripts/resolve-binary.sh "),process.exit(2));try{process.stdout.write(`${Na(Ca)} `)}catch(n){console.error(n instanceof Error?n.message:String(n)),process.exit(1)} diff --git a/scripts/run-vfork-readiness.sh b/scripts/run-vfork-readiness.sh index 832d4a5200..ab9a101fa9 100755 --- a/scripts/run-vfork-readiness.sh +++ b/scripts/run-vfork-readiness.sh @@ -87,7 +87,7 @@ fi cargo test -p fork-instrument --target "$host_target" if $integration; then - cargo test -p wasm-posix-kernel --target "$host_target" \ + cargo test -p kandelo --target "$host_target" \ credentials -- --nocapture fi diff --git a/tests/test-artifacts/kernel-test-programs.json b/tests/test-artifacts/kernel-test-programs.json index ec02597e1d..8f631b1b7d 100644 --- a/tests/test-artifacts/kernel-test-programs.json +++ b/tests/test-artifacts/kernel-test-programs.json @@ -28,11 +28,15 @@ "resolver_path": "programs/exec-child.wasm", "consumers": [ "apps/browser-demos/test/nonzero-exit-diagnostic.spec.ts", + "apps/browser-demos/test/prepared-exec-target.spec.ts", "apps/browser-demos/test/process-memory-retirement.spec.ts", "apps/browser-demos/test/ruby-posix-spawn.spec.ts", "apps/browser-demos/test/vfork-lifecycle.spec.ts", "host/test/exec.test.ts", + "host/test/exec-state-tracking.test.ts", "host/test/fixtures/ordinary-nonzero-exit.ts", + "host/test/spawn-credential-order.test.ts", + "host/test/spawn-pid-authority.test.ts", "host/test/vfork-lifecycle-guest.test.ts", "packages/registry/ruby/test/posix-spawn.test.ts" ] From 134e4aa665f6fd692618e150cfb5886e4cad43e4 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Wed, 12 Aug 2026 13:23:09 -0400 Subject: [PATCH 73/82] Build: Pin native xtask archive tools --- scripts/install-local-binary.sh | 6 +++ scripts/test-install-local-generation.sh | 52 ++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/scripts/install-local-binary.sh b/scripts/install-local-binary.sh index 96bf764eac..40a9f6ef02 100755 --- a/scripts/install-local-binary.sh +++ b/scripts/install-local-binary.sh @@ -349,6 +349,8 @@ _wasm_posix_output_metadata() { ( cd "$repo_root" env -u CC -u CXX -u AR -u RANLIB -u CFLAGS -u CXXFLAGS -u CPPFLAGS -u LDFLAGS \ + AR="${LLVM_BIN:?install-local-binary: LLVM_BIN is required}/llvm-ar" \ + RANLIB="${LLVM_BIN:?install-local-binary: LLVM_BIN is required}/llvm-ranlib" \ cargo run -p xtask --target "$host_target" --quiet -- \ build-deps output-metadata "$package" "$artifact" ) @@ -501,6 +503,8 @@ install_local_binary() { if ! ( cd "$repo_root" env -u CC -u CXX -u AR -u RANLIB -u CFLAGS -u CXXFLAGS -u CPPFLAGS -u LDFLAGS \ + AR="${LLVM_BIN:?install-local-binary: LLVM_BIN is required}/llvm-ar" \ + RANLIB="${LLVM_BIN:?install-local-binary: LLVM_BIN is required}/llvm-ranlib" \ WASM_POSIX_LOCAL_INSTALL_SOURCE="$source_abs" \ WASM_POSIX_LOCAL_INSTALL_SESSION="$WASM_POSIX_LOCAL_INSTALL_SESSION" \ cargo run -p xtask --target "$host_target" --quiet -- \ @@ -587,6 +591,8 @@ install_local_runtime_file() { ( cd "$repo_root" env -u CC -u CXX -u AR -u RANLIB -u CFLAGS -u CXXFLAGS -u CPPFLAGS -u LDFLAGS \ + AR="${LLVM_BIN:?install-local-binary: LLVM_BIN is required}/llvm-ar" \ + RANLIB="${LLVM_BIN:?install-local-binary: LLVM_BIN is required}/llvm-ranlib" \ WASM_POSIX_LOCAL_INSTALL_SOURCE="$source_abs" \ WASM_POSIX_LOCAL_INSTALL_SESSION="$WASM_POSIX_LOCAL_INSTALL_SESSION" \ cargo run -p xtask --target "$host_target" --quiet -- \ diff --git a/scripts/test-install-local-generation.sh b/scripts/test-install-local-generation.sh index 50beeeb139..f29fd01aa3 100755 --- a/scripts/test-install-local-generation.sh +++ b/scripts/test-install-local-generation.sh @@ -28,6 +28,58 @@ fail() { exit 1 } +# Every native xtask lookup must pin the declared LLVM archive pair. On Darwin, +# LLVM supplies llvm-ar rather than an `ar` alias, so dropping AR/RANLIB makes +# a cold Cargo cache fall through to the incompatible Apple archiver. Target +# build variables and caller C/C++ compiler flags must not leak into the native +# helper invocation. +metadata_tools="$work/metadata-tools" +metadata_repo="$work/metadata-repo" +metadata_calls="$work/metadata-calls" +metadata_source="$work/metadata-source.wasm" +metadata_runtime="$work/metadata-runtime.dat" +mkdir -p "$metadata_tools" "$metadata_repo/scripts" +cp "$REPO_ROOT/scripts/install-local-binary.sh" "$metadata_repo/scripts/" +cp "$REPO_ROOT/scripts/wasm-artifact-guards.sh" "$metadata_repo/scripts/" +printf '\000asm\001\000\000\000' >"$metadata_source" +printf 'metadata runtime\n' >"$metadata_runtime" +cat >"$metadata_tools/cargo" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +[ "${AR:-}" = "$METADATA_TOOLS/llvm-ar" ] || exit 41 +[ "${RANLIB:-}" = "$METADATA_TOOLS/llvm-ranlib" ] || exit 42 +[ -z "${CC+x}" ] || exit 43 +[ -z "${CFLAGS+x}" ] || exit 44 +printf '%s\n' "$*" >>"$METADATA_CALLS" +case "$*" in + *'build-deps output-metadata kernel kandelo-kernel.wasm') + printf '%s\n' '{"source_artifact":"kandelo-kernel.wasm","mirror_path":"kernel.wasm","fork_instrumentation":"disabled"}' + ;; + *'build-deps --arch wasm32 --binaries-dir '*' install-local-artifact kernel kandelo-kernel.wasm'|*'build-deps --arch wasm32 --binaries-dir '*' install-local-artifact kernel metadata-runtime.dat') + ;; + *) exit 45 ;; +esac +EOF +chmod +x "$metadata_tools/cargo" +( + cd "$metadata_repo" + PATH="$metadata_tools:$PATH" + AR=caller-target-ar + RANLIB=caller-target-ranlib + LLVM_BIN="$metadata_tools" + CC=caller-compiler + CFLAGS=caller-flags + METADATA_TOOLS="$metadata_tools" + METADATA_CALLS="$metadata_calls" + export PATH AR RANLIB LLVM_BIN CC CFLAGS METADATA_TOOLS METADATA_CALLS + # shellcheck source=/dev/null + source "$metadata_repo/scripts/install-local-binary.sh" + install_local_binary kernel "$metadata_source" kandelo-kernel.wasm + install_local_runtime_file kernel "$metadata_runtime" metadata-runtime.dat +) +[ "$(wc -l <"$metadata_calls" | tr -d ' ')" = 3 ] || + fail "native metadata calls did not all retain the declared archive tools" + registry="$work/registry" package_dir="$registry/local-python" mirror="$work/local-binaries" From ea7115ec076839152287ebb1d57bd67617082b84 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Wed, 12 Aug 2026 13:23:09 -0400 Subject: [PATCH 74/82] Host: Initialize Node VFS rootfs mounts --- host/src/node-kernel-host.ts | 9 +++++++++ host/src/node-kernel-protocol.ts | 8 +++++++- host/src/node-kernel-worker-entry.ts | 26 ++++++++++++++------------ host/test/node-host-mounts.test.ts | 13 +++++++++++++ 4 files changed, 43 insertions(+), 13 deletions(-) diff --git a/host/src/node-kernel-host.ts b/host/src/node-kernel-host.ts index b68383d00d..53245ec3d0 100644 --- a/host/src/node-kernel-host.ts +++ b/host/src/node-kernel-host.ts @@ -43,6 +43,7 @@ import { DEFAULT_MAX_WORKERS, WASM_PAGE_SIZE, } from "./constants"; +import type { MountSpec } from "./vfs/default-mounts"; import { awaitGracefulKernelRealmDestroy } from "./kernel-realm-destroy"; import { FILE_MODES } from "./generated/abi"; import type { NodeSessionSeedTree } from "./vfs/default-mounts-node"; @@ -148,6 +149,8 @@ export interface NodeKernelHostOptions { * to a VFS-only world yet. */ rootfsImage?: "default" | ArrayBuffer | Uint8Array; + /** Exact image/scratch mount contract. Requires `rootfsImage`. */ + rootfsMountSpec?: readonly MountSpec[]; /** Publisher-admitted peer of BrowserKernel's trusted `/usr/bin` product. */ privilegedProduct?: PublishedPrivilegedProgramProduct; /** @@ -283,6 +286,9 @@ export class NodeKernelHost { const rootfsLazyAssets = this.options.rootfsLazyAssets === undefined ? undefined : snapshotClosedLazyAssets(this.options.rootfsLazyAssets); + const rootfsLazyAssetSources = this.options.rootfsLazyAssetSources === undefined + ? undefined + : snapshotClosedLazyAssetSources(this.options.rootfsLazyAssetSources); const sessionSeedTrees = this.options.sessionSeedTrees?.map( (seed) => ({ sourcePath: seed.sourcePath, @@ -419,6 +425,9 @@ export class NodeKernelHost { execPrograms: this.options.execPrograms, execProgramBytes, rootfsImage: rootfsImage ?? undefined, + rootfsMountSpec: this.options.rootfsMountSpec === undefined + ? undefined + : this.options.rootfsMountSpec.map((mount) => ({ ...mount })), ...(privilegedProgramMount === undefined ? {} : { diff --git a/host/src/node-kernel-protocol.ts b/host/src/node-kernel-protocol.ts index b012813761..641fc11011 100644 --- a/host/src/node-kernel-protocol.ts +++ b/host/src/node-kernel-protocol.ts @@ -14,7 +14,11 @@ import type { HttpRequest, HttpResponse } from "./networking/in-kernel-http"; import type { HostDiagnosticMessage } from "./host-diagnostic"; import type { LazyDownloadEvent } from "./vfs/memory-fs"; -import type { ClosedLazyAsset } from "./vfs/closed-lazy-assets"; +import type { + ClosedLazyAsset, + ClosedLazyAssetSource, +} from "./vfs/closed-lazy-assets"; +import type { MountSpec } from "./vfs/default-mounts"; import type { NodeSessionSeedTree } from "./vfs/default-mounts-node"; export type { HttpRequest, HttpResponse }; @@ -56,6 +60,8 @@ export interface InitMessage { * (custom-io / legacy path). */ rootfsImage?: ArrayBuffer; + /** Exact image/scratch mount contract. Absent preserves the host default. */ + rootfsMountSpec?: MountSpec[]; privilegedProgramMount?: { kind: "published-privileged-program-product"; mountPoint: "/usr/bin"; diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts index 7e2761cc39..9583346bf6 100644 --- a/host/src/node-kernel-worker-entry.ts +++ b/host/src/node-kernel-worker-entry.ts @@ -95,6 +95,7 @@ import { waitForWorkerQuiescence, } from "./worker-quiescence"; import { RootfsSnapshotGate } from "./rootfs-snapshot-gate"; +import { uninitializedKernelPipeResult } from "./kernel-pipe-transport"; import { ForkReplayGateCoordinator, observeForkReplayWorker, @@ -924,6 +925,7 @@ async function buildVirtualPlatformIO( sessionSeedTrees?: InitMessage["sessionSeedTrees"], rootfsLazyUrlBase?: InitMessage["rootfsLazyUrlBase"], rootfsLazyAssets?: InitMessage["rootfsLazyAssets"], + rootfsLazyAssetSources?: InitMessage["rootfsLazyAssetSources"], privilegedProgramMount?: InitMessage["privilegedProgramMount"], ): Promise { const bootSessionDir = mkdtempSync(join(tmpdir(), "wasm-posix-session-")); @@ -931,7 +933,7 @@ async function buildVirtualPlatformIO( let specMounts: MountConfig[]; try { specMounts = await resolveForNodeKernelSession( - DEFAULT_MOUNT_SPEC, + rootfsMountSpec ?? DEFAULT_MOUNT_SPEC, new Uint8Array(rootfsImage), bootSessionDir, sessionSeedTrees, @@ -1064,6 +1066,7 @@ async function handleInit(msg: InitMessage) { msg.sessionSeedTrees, msg.rootfsLazyUrlBase, msg.rootfsLazyAssets, + msg.rootfsLazyAssetSources, msg.privilegedProgramMount, ) : new NodePlatformIO(); @@ -3484,7 +3487,7 @@ function handlePipeRead( respond(msg.requestId, uninitializedKernelPipeResult("read")); return; } - respond(msg.requestId, kernelWorker.readHostPipe(msg.pid, msg.pipeIdx)); + respond(msg.requestId, kernelWorker.readPipeAvailable(msg.pid, msg.pipeIdx)); } function handlePipeWrite( @@ -3494,10 +3497,9 @@ function handlePipeWrite( respond(msg.requestId, uninitializedKernelPipeResult("write")); return; } - respond( - msg.requestId, - kernelWorker.writeHostPipe(msg.pid, msg.pipeIdx, msg.data), - ); + const written = kernelWorker.writePipeData(msg.pid, msg.pipeIdx, msg.data); + kernelWorker.notifyPipeReadable(msg.pipeIdx); + respond(msg.requestId, written); } function handleInjectConnection( @@ -3509,7 +3511,7 @@ function handleInjectConnection( } respond( msg.requestId, - kernelWorker.injectHostConnection( + kernelWorker.injectConnection( msg.pid, msg.fd, msg.peerAddr, @@ -3708,24 +3710,24 @@ port.on("message", (msg: MainToKernelMessage) => { handlePipeWrite(msg); break; case "pipe_close_read": - if (initReady) kernelWorker.closeHostPipeRead(msg.pid, msg.pipeIdx); + if (initReady) kernelWorker.closePipeRead(msg.pid, msg.pipeIdx); break; case "pipe_close_write": - if (initReady) kernelWorker.closeHostPipeWrite(msg.pid, msg.pipeIdx); + if (initReady) kernelWorker.closePipeWrite(msg.pid, msg.pipeIdx); break; case "pipe_is_write_open": respond( msg.requestId, initReady - ? kernelWorker.isHostPipeWriteOpen(msg.pid, msg.pipeIdx) + ? kernelWorker.isPipeWriteOpen(msg.pid, msg.pipeIdx) : uninitializedKernelPipeResult("is-write-open"), ); break; case "wake_blocked_readers": - if (initReady) kernelWorker.wakeHostPipeReaders(msg.pipeIdx); + if (initReady) kernelWorker.wakeBlockedReaders(msg.pipeIdx); break; case "wake_blocked_writers": - if (initReady) kernelWorker.wakeHostPipeWriters(msg.pipeIdx); + if (initReady) kernelWorker.wakeBlockedWriters(msg.pipeIdx); break; case "terminate_process": void handleTerminate(msg); diff --git a/host/test/node-host-mounts.test.ts b/host/test/node-host-mounts.test.ts index a04a334b57..3b93b0a244 100644 --- a/host/test/node-host-mounts.test.ts +++ b/host/test/node-host-mounts.test.ts @@ -50,6 +50,19 @@ const haveProbe = existsSync(probeWasm); const haveRootfs = existsSync(rootfsImage); describe("node session seed configuration", () => { + it("rejects a custom mount specification without a rootfs before starting a worker", async () => { + const host = new NodeKernelHost({ + rootfsMountSpec: [], + }); + try { + await expect(host.init(new ArrayBuffer(0))).rejects.toThrow( + "rootfsMountSpec requires rootfsImage", + ); + } finally { + await host.destroy(); + } + }); + it("rejects seeds without a rootfs before starting a worker", async () => { const host = new NodeKernelHost({ sessionSeedTrees: [{ From be14d3f4ac51566ca0ad18be2b716abc5eb5defb Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Wed, 12 Aug 2026 13:23:09 -0400 Subject: [PATCH 75/82] Host: Use reentrant Node pipe protocol --- host/src/kernel-worker.ts | 123 ------------- host/test/node-kernel-pipe-proxy.test.ts | 219 ++--------------------- 2 files changed, 16 insertions(+), 326 deletions(-) diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index 9fe170e184..0614321363 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -30663,121 +30663,6 @@ export class CentralizedKernelWorker { }); } - /** - * Inject one host-owned connection into an in-kernel listener. - * - * This is the shared Node/browser boundary used by protected protocol - * clients. The returned receive pipe and its adjacent send pipe live in the - * kernel's global pipe table, so callers use pid 0 for subsequent pipe - * operations. - */ - injectHostConnection( - pid: number, - listenerFd: number, - peerAddr: [number, number, number, number], - peerPort: number, - ): number { - if (!this.kernelInstance) return -1; - const injectConnection = this.kernelInstance.exports.kernel_inject_connection as ( - pid: number, - fd: number, - a: number, - b: number, - c: number, - d: number, - port: number, - ) => number; - const recvPipeIdx = injectConnection( - pid, - listenerFd, - peerAddr[0], - peerAddr[1], - peerAddr[2], - peerAddr[3], - peerPort, - ); - if (recvPipeIdx >= 0) this.scheduleWakeBlockedRetries(); - return recvPipeIdx; - } - - /** Drain at most one 64 KiB chunk from a host-owned kernel pipe. */ - readHostPipe(pid: number, pipeIdx: number): Uint8Array | null { - if (!this.kernelInstance) return null; - const pipeRead = this.kernelInstance.exports.kernel_pipe_read as ( - pid: number, - pipeIdx: number, - bufPtr: KernelPointer, - bufLen: number, - ) => number; - const maxBytes = 65_536; - const n = checkedKernelPipeTransferCount( - "kernel_pipe_read", - pipeRead( - pid, - pipeIdx, - this.toKernelPtr(this.tcpScratchOffset), - maxBytes, - ), - maxBytes, - ); - if (n <= 0) return null; - const result = this.getKernelMem().slice( - this.tcpScratchOffset, - this.tcpScratchOffset + n, - ); - this.notifyPipeWritable(pipeIdx); - return result; - } - - /** Write exact bytes to a host-owned kernel pipe and wake guest readers. */ - writeHostPipe(pid: number, pipeIdx: number, data: Uint8Array): number { - if (!this.kernelInstance) return -1; - const pipeWrite = this.kernelInstance.exports.kernel_pipe_write as ( - pid: number, - pipeIdx: number, - bufPtr: KernelPointer, - bufLen: number, - ) => number; - const written = this.writePipeChunked(pipeWrite, pid, pipeIdx, data); - if (written > 0) this.notifyPipeReadable(pipeIdx); - return written; - } - - closeHostPipeRead(pid: number, pipeIdx: number): void { - if (!this.kernelInstance) return; - const closeRead = this.kernelInstance.exports.kernel_pipe_close_read as ( - pid: number, - pipeIdx: number, - ) => number; - closeRead(pid, pipeIdx); - } - - closeHostPipeWrite(pid: number, pipeIdx: number): void { - if (!this.kernelInstance) return; - const closeWrite = this.kernelInstance.exports.kernel_pipe_close_write as ( - pid: number, - pipeIdx: number, - ) => number; - closeWrite(pid, pipeIdx); - } - - isHostPipeWriteOpen(pid: number, pipeIdx: number): boolean { - if (!this.kernelInstance) return false; - const isWriteOpen = this.kernelInstance.exports.kernel_pipe_is_write_open as ( - pid: number, - pipeIdx: number, - ) => number; - return isWriteOpen(pid, pipeIdx) === 1; - } - - wakeHostPipeReaders(pipeIdx: number): void { - this.notifyPipeReadable(pipeIdx); - } - - wakeHostPipeWriters(pipeIdx: number): void { - this.notifyPipeWritable(pipeIdx); - } - // --------------------------------------------------------------------------- // External HTTP request bridge (host → in-kernel server, no real TCP) // --------------------------------------------------------------------------- @@ -31206,14 +31091,6 @@ export class CentralizedKernelWorker { resolve(response); }; - const fail = (error: unknown) => { - pipeCloseRead(pid, sendPipeIdx); - pipeCloseWrite(pid, recvPipeIdx); - this.notifyPipeReadable(recvPipeIdx); - this.scheduleWakeBlockedRetries(); - reject(error instanceof Error ? error : new Error(String(error))); - }; - const tick = () => { if (settled) return; if (Date.now() - start > timeoutMs) { diff --git a/host/test/node-kernel-pipe-proxy.test.ts b/host/test/node-kernel-pipe-proxy.test.ts index aa3e2cc275..6cec71416d 100644 --- a/host/test/node-kernel-pipe-proxy.test.ts +++ b/host/test/node-kernel-pipe-proxy.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; -import { CentralizedKernelWorker } from "../src/kernel-worker"; import { NodeKernelHost } from "../src/node-kernel-host"; import { uninitializedKernelPipeResult } from "../src/kernel-pipe-transport"; import type { @@ -13,21 +13,6 @@ interface TestableNodeKernelHost { handleWorkerMessage(message: KernelToMainMessage): void; } -interface TestableKernelPipeWorker { - pumpHttpResponse( - pid: number, - sendPipeIdx: number, - recvPipeIdx: number, - pipeRead: (pid: number, pipeIdx: number, pointer: number, length: number) => number, - pipeIsWriteOpen: (pid: number, pipeIdx: number) => number, - pipeCloseRead: (pid: number, pipeIdx: number) => number, - pipeCloseWrite: (pid: number, pipeIdx: number) => number, - timeoutMs: number, - maxResponseBytes: number, - label: string, - ): Promise; -} - function fixture() { const sent: MainToKernelMessage[] = []; const host = new NodeKernelHost(); @@ -121,194 +106,22 @@ describe("NodeKernelHost kernel-pipe proxy parity", () => { { type: "wake_blocked_writers", pipeIdx: 21 }, ]); }); -}); - -describe("CentralizedKernelWorker host pipe boundary", () => { - it("bounds one read and wakes the guest writer after draining bytes", () => { - const memory = new WebAssembly.Memory({ initial: 2 }); - const bytes = new Uint8Array(memory.buffer); - let calls = 0; - const worker = Object.assign( - Object.create(CentralizedKernelWorker.prototype), - { - kernelInstance: { - exports: { - kernel_pipe_read: ( - _pid: number, - _pipeIdx: number, - pointer: number, - length: number, - ) => { - calls += 1; - if (calls > 1) return 0; - expect(length).toBe(65_536); - bytes.set([4, 5, 6], pointer); - return 3; - }, - }, - }, - kernelMemory: memory, - kernel: { toKernelPtr: (value: number | bigint) => value }, - tcpScratchOffset: 4_096, - cachedKernelMem: null, - cachedKernelBuffer: null, - notifyPipeWritable: vi.fn(), - }, - ) as CentralizedKernelWorker; - - expect(worker.readHostPipe(0, 21)).toEqual(new Uint8Array([4, 5, 6])); - expect(calls).toBe(1); - expect(worker.notifyPipeWritable).toHaveBeenCalledWith(21); - }); - - it.each([65_537, 1.5])( - "rejects invalid host-pipe read count %s before copying kernel memory", - (returned) => { - const memory = new WebAssembly.Memory({ initial: 2 }); - const worker = Object.assign( - Object.create(CentralizedKernelWorker.prototype), - { - kernelInstance: { - exports: { kernel_pipe_read: vi.fn(() => returned) }, - }, - kernelMemory: memory, - kernel: { toKernelPtr: (value: number | bigint) => value }, - tcpScratchOffset: 4_096, - cachedKernelMem: null, - cachedKernelBuffer: null, - notifyPipeWritable: vi.fn(), - }, - ) as CentralizedKernelWorker; - - expect(() => worker.readHostPipe(0, 21)).toThrow( - "kernel_pipe_read returned an invalid byte count", - ); - expect(worker.notifyPipeWritable).not.toHaveBeenCalled(); - }, - ); - - it.each([4, 1.5])( - "rejects invalid host-pipe write count %s before advancing the input", - (returned) => { - const memory = new WebAssembly.Memory({ initial: 2 }); - const worker = Object.assign( - Object.create(CentralizedKernelWorker.prototype), - { - kernelInstance: { - exports: { kernel_pipe_write: vi.fn(() => returned) }, - }, - kernelMemory: memory, - kernel: { toKernelPtr: (value: number | bigint) => value }, - tcpScratchOffset: 4_096, - cachedKernelMem: null, - cachedKernelBuffer: null, - notifyPipeReadable: vi.fn(), - }, - ) as CentralizedKernelWorker; - expect(() => worker.writeHostPipe(0, 20, new Uint8Array([1, 2, 3]))) - .toThrow("kernel_pipe_write returned an invalid byte count"); - expect(worker.notifyPipeReadable).not.toHaveBeenCalled(); - }, - ); - - it("rejects an overreported HTTP pipe read before retaining response bytes", async () => { - const getKernelMem = vi.fn(() => new Uint8Array(2 * 65_536)); - const closeRead = vi.fn(() => 0); - const closeWrite = vi.fn(() => 0); - const worker = Object.assign( - Object.create(CentralizedKernelWorker.prototype), - { - kernel: { toKernelPtr: (value: number | bigint) => value }, - tcpScratchOffset: 4_096, - getKernelMem, - notifyPipeReadable: vi.fn(), - notifyPipeWritable: vi.fn(), - scheduleWakeBlockedRetries: vi.fn(), - }, - ) as unknown as TestableKernelPipeWorker; - - await expect(worker.pumpHttpResponse( - 0, - 21, - 20, - () => 65_537, - () => 1, - closeRead, - closeWrite, - 1_000, - 1_000_000, - "bounded test", - )).rejects.toThrow("kernel_pipe_read returned an invalid byte count"); - expect(getKernelMem).not.toHaveBeenCalled(); - expect(closeRead).toHaveBeenCalledWith(0, 21); - expect(closeWrite).toHaveBeenCalledWith(0, 20); - }); - - it("injects and writes through exact typed kernel exports", () => { - const memory = new WebAssembly.Memory({ initial: 2 }); - const bytes = new Uint8Array(memory.buffer); - const inject = vi.fn(() => 20); - const write = vi.fn( - (_pid: number, _pipeIdx: number, pointer: number, length: number) => { - expect([...bytes.slice(pointer, pointer + length)]).toEqual([1, 2, 3]); - return length; - }, + it("routes the Node worker protocol through the reentrant-safe pipe API", () => { + const entry = readFileSync( + new URL("../src/node-kernel-worker-entry.ts", import.meta.url), + "utf8", ); - const worker = Object.assign( - Object.create(CentralizedKernelWorker.prototype), - { - kernelInstance: { - exports: { - kernel_inject_connection: inject, - kernel_pipe_write: write, - }, - }, - kernelMemory: memory, - kernel: { toKernelPtr: (value: number | bigint) => value }, - tcpScratchOffset: 4_096, - cachedKernelMem: null, - cachedKernelBuffer: null, - notifyPipeReadable: vi.fn(), - scheduleWakeBlockedRetries: vi.fn(), - }, - ) as CentralizedKernelWorker; - - expect(worker.injectHostConnection(9, 4, [127, 0, 0, 1], 12_000)).toBe(20); - expect(inject).toHaveBeenCalledWith(9, 4, 127, 0, 0, 1, 12_000); - expect(worker.writeHostPipe(0, 20, new Uint8Array([1, 2, 3]))).toBe(3); - expect(worker.notifyPipeReadable).toHaveBeenCalledWith(20); - }); - - it("closes, checks, and wakes through the shared host boundary", () => { - const closeRead = vi.fn(() => 0); - const closeWrite = vi.fn(() => 0); - const isWriteOpen = vi.fn(() => 1); - const worker = Object.assign( - Object.create(CentralizedKernelWorker.prototype), - { - kernelInstance: { - exports: { - kernel_pipe_close_read: closeRead, - kernel_pipe_close_write: closeWrite, - kernel_pipe_is_write_open: isWriteOpen, - }, - }, - notifyPipeReadable: vi.fn(), - notifyPipeWritable: vi.fn(), - }, - ) as CentralizedKernelWorker; - - worker.closeHostPipeRead(0, 21); - worker.closeHostPipeWrite(0, 20); - expect(worker.isHostPipeWriteOpen(0, 20)).toBe(true); - worker.wakeHostPipeReaders(20); - worker.wakeHostPipeWriters(21); - expect(closeRead).toHaveBeenCalledWith(0, 21); - expect(closeWrite).toHaveBeenCalledWith(0, 20); - expect(isWriteOpen).toHaveBeenCalledWith(0, 20); - expect(worker.notifyPipeReadable).toHaveBeenCalledWith(20); - expect(worker.notifyPipeWritable).toHaveBeenCalledWith(21); + expect(entry).toContain("kernelWorker.readPipeAvailable(msg.pid, msg.pipeIdx)"); + expect(entry).toContain("kernelWorker.writePipeData(msg.pid, msg.pipeIdx, msg.data)"); + expect(entry).toContain("kernelWorker.notifyPipeReadable(msg.pipeIdx)"); + expect(entry).toContain("kernelWorker.injectConnection("); + expect(entry).toContain("kernelWorker.closePipeRead(msg.pid, msg.pipeIdx)"); + expect(entry).toContain("kernelWorker.closePipeWrite(msg.pid, msg.pipeIdx)"); + expect(entry).toContain("kernelWorker.isPipeWriteOpen(msg.pid, msg.pipeIdx)"); + expect(entry).not.toContain("kernelWorker.readHostPipe("); + expect(entry).not.toContain("kernelWorker.writeHostPipe("); + expect(entry).not.toContain("kernelWorker.injectHostConnection("); }); }); From a941eb40bbee8731acecd5854f0c9c5951efa3c1 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Wed, 12 Aug 2026 13:23:09 -0400 Subject: [PATCH 76/82] Homebrew: Parse original bottle descriptors --- host/src/homebrew-runtime-layer-consumer.ts | 74 ++++++++++++++++++++- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/host/src/homebrew-runtime-layer-consumer.ts b/host/src/homebrew-runtime-layer-consumer.ts index 254c6ee8c4..95aab7d175 100644 --- a/host/src/homebrew-runtime-layer-consumer.ts +++ b/host/src/homebrew-runtime-layer-consumer.ts @@ -33,10 +33,12 @@ import { HOMEBREW_RUNTIME_LAYER_LIMITS, isHomebrewRuntimeLayerId, } from "./homebrew-runtime-layer-limits"; +import { KANDELO_HOMEBREW_GUEST_LAYOUT } from "./homebrew-guest-layout"; import { normalizeHomebrewBottleDestinationPrefix } from "./homebrew-bottle-relocation"; export { HOMEBREW_RUNTIME_LAYER_LIMITS } from "./homebrew-runtime-layer-limits"; const COMPOSITION_PATH = "/etc/kandelo/homebrew-vfs.json"; +const HOMEBREW_PREFIX = KANDELO_HOMEBREW_GUEST_LAYOUT.prefix; const ACCEPTANCE_ASSET = "kandelo-homebrew.vfs.zst"; const ACCEPTANCE_DESCRIPTOR_ASSET = "kandelo-homebrew-vfs.json"; const ACCEPTANCE_REPORT_ASSET = "kandelo-homebrew-vfs-report.json"; @@ -430,6 +432,7 @@ export function parseHomebrewOriginalBottleTreeDescriptor( "", [], 5, + HOMEBREW_PREFIX, "external-only", ); if ( @@ -444,8 +447,8 @@ export function parseHomebrewOriginalBottleTreeDescriptor( ) { throw new Error("Homebrew original-bottle tree differs from its exact bottle input"); } - validateCompleteDirectBottleDirectories(tree.inventory.entries); - validateStandaloneDirectBottleBinding(tree, expected.formula); + validateCompleteDirectBottleDirectories(tree.inventory.entries, HOMEBREW_PREFIX); + validateStandaloneDirectBottleBinding(tree, expected.formula, HOMEBREW_PREFIX); return { schema: 1, kind: "kandelo-homebrew-original-bottle-tree", @@ -1052,6 +1055,72 @@ function validateLayerDestinationPrefix( return prefixes.values().next().value!; } +function validateStandaloneDirectBottleBinding( + tree: HomebrewDeferredTreeDescriptor, + formula: string, + destinationPrefix: string, +): void { + const expectedKegPrefix = `${destinationPrefix}/Cellar/${formula}/`; + if ( + tree.activation.roots.length !== 1 || + !tree.activation.roots[0]!.startsWith(expectedKegPrefix) || + tree.activation.roots[0]!.slice(expectedKegPrefix.length).includes("/") + ) { + throw new Error( + `Homebrew original-bottle ${formula} activation does not name its exact keg`, + ); + } + const kegPath = tree.activation.roots[0]!.slice(1); + const version = tree.activation.roots[0]!.slice(expectedKegPrefix.length); + const optPath = `${destinationPrefix}/opt/${formula}`.slice(1); + const entries = tree.inventory.entries; + const keg = entries.find((entry) => entry.path === kegPath); + const opt = entries.find((entry) => entry.path === optPath); + if ( + keg?.type !== "directory" || + keg.ownership !== "layer" || + opt?.type !== "symlink" || + opt.ownership !== "layer" || + opt.target !== `../Cellar/${formula}/${version}` + ) { + throw new Error( + `Homebrew original-bottle ${formula} does not own its keg and opt link`, + ); + } + for (const entry of entries) { + if (entry.type === "directory") { + const expectedOwnership = + entry.path === kegPath || entry.path.startsWith(`${kegPath}/`) + ? "layer" + : "mergeable-directory"; + if (entry.ownership !== expectedOwnership) { + throw new Error( + `Homebrew original-bottle ${formula} directory /${entry.path} ` + + `must have ${expectedOwnership} ownership`, + ); + } + } + if ( + (entry.materialization === "archive" || + entry.materialization === "archive-homebrew-relocate") && + entry.path !== kegPath && + !entry.path.startsWith(`${kegPath}/`) + ) { + throw new Error( + `Homebrew original-bottle ${formula} maps an archive member outside its keg`, + ); + } + if (entry.materialization === "archive" && entry.type === "symlink") { + validateArchiveSymlinkTarget( + entry.path, + entry.target!, + kegPath, + tree.package!, + ); + } + } +} + function validateDirectBottleBindings( packages: readonly HomebrewLazyLayerPackageRecord[], trees: readonly HomebrewDeferredTreeDescriptor[], @@ -1897,6 +1966,7 @@ function validateDeferredTrees( }>, descriptorSchema: 4 | 5 | 6, destinationPrefix: string, + transportPolicy: "bundle-release" | "external-only" = "bundle-release", ): HomebrewDeferredTreeDescriptor[] { const values = requireArray( value, From 95fe6a44ade074fbaf65a4bbe3b5e9f515965a1c Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Wed, 12 Aug 2026 13:42:36 -0400 Subject: [PATCH 77/82] Homebrew: Declare embedded ABI 43 login roots --- images/vfs/products/browser-main-shell.toml | 4 +-- images/vfs/products/generated/catalog.json | 2 +- .../finalize-homebrew-main-shell-release.py | 5 ++++ ...st-finalize-homebrew-main-shell-release.py | 30 +++++++++++++++++++ scripts/vfs-product-catalog.test.mjs | 19 ++++++++++-- 5 files changed, 55 insertions(+), 5 deletions(-) diff --git a/images/vfs/products/browser-main-shell.toml b/images/vfs/products/browser-main-shell.toml index 4d2df87a8c..cb85df2317 100644 --- a/images/vfs/products/browser-main-shell.toml +++ b/images/vfs/products/browser-main-shell.toml @@ -35,7 +35,7 @@ materialization = "embedded" [[software.homebrew]] tap = "kandelo-dev/homebrew-tap-core" -formulae = ["bash"] +formulae = ["bash", "login", "sudo-lite", "sudo", "ruby"] materialization = "embedded" [[software.homebrew]] @@ -45,7 +45,7 @@ formulae = [ "file-formula", "m4", "make", "findutils", "diffutils", "posix-utils-lite", "fbdoom", "modeset", "less", "tar", "curl", "netcat", "wget", "git", "gzip", "bzip2", "xz", "zstd", "zip", - "unzip", "lsof", "nano", "vim", "nethack", "ruby", + "unzip", "lsof", "nano", "vim", "nethack", ] materialization = "lazy" diff --git a/images/vfs/products/generated/catalog.json b/images/vfs/products/generated/catalog.json index c06d90485e..2eb8c435a4 100644 --- a/images/vfs/products/generated/catalog.json +++ b/images/vfs/products/generated/catalog.json @@ -1 +1 @@ -{"kind":"kandelo-vfs-product-catalog","products":[{"manifest":{"architecture":"wasm32","boot":{"argv":["/usr/local/lib/erlang/erts-16.1.2/bin/beam.smp","--","-root","/usr/local/lib/erlang","-bindir","/usr/local/lib/erlang/erts-16.1.2/bin","-boot","/usr/local/lib/erlang/releases/28/start_clean","-noshell"],"cwd":"/home","env":{"BINDIR":"/usr/local/lib/erlang/erts-16.1.2/bin","HOME":"/home","PATH":"/usr/local/lib/erlang/erts-16.1.2/bin:/usr/local/bin:/usr/bin:/bin","ROOTDIR":"/usr/local/lib/erlang","TMPDIR":"/tmp"},"gid":1000,"uid":1000},"builder":"images/vfs/scripts/build-erlang-vfs-image.sh","composition":{"product":[],"repository":[]},"evidence":{"browser":{"test":"erlang-vfs-browser-smoke"},"node":{"test":"erlang-vfs-node-smoke"}},"id":"browser-erlang","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"erlang.vfs.zst","schema":1,"software":{"archive":[],"homebrew":[],"package":[{"materialization":"embedded","name":"erlang","outputs":["erlang","erlang-otp"],"role":"runtime","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/browser-erlang.toml","sha256":"39b4e21b637cbf4c8568396a2068418a3d4c143562bf7734e4b5df635ab52aae"},{"manifest":{"architecture":"wasm32","boot":{"argv":["/sbin/dinit","--container","-p","/tmp/dinitctl","nginx"],"cwd":"/root","env":{"HOME":"/root","LOGNAME":"root","PATH":"/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin","TMPDIR":"/tmp","USER":"root","WP_APP_PATH":"/app","WP_PROTO":"http"},"gid":0,"uid":0},"builder":"images/vfs/scripts/build-lamp-vfs-image.sh","composition":{"product":[{"id":"browser-main-shell","materialization":"embedded"}],"repository":[]},"evidence":{"browser":{"test":"wordpress-mariadb-browser-e2e"},"node":{"test":"wordpress-mariadb-node-startup"}},"id":"browser-lamp","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"lamp.vfs.zst","schema":1,"software":{"archive":[{"id":"wordpress-core","materialization":"embedded","role":"runtime","sha256":"530c8fdeb16fb0affdb53eb727b6a04bb8d166621c20029e389cabb01a0fa921","url":"https://wordpress.org/wordpress-7.0.tar.gz"}],"homebrew":[],"package":[{"materialization":"embedded","name":"mariadb","outputs":["mariadbd"],"role":"runtime","source_roles":["system-tables"]},{"materialization":"embedded","name":"nginx","outputs":["nginx"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"php","outputs":["php-fpm","opcache"],"role":"runtime","source_roles":[]},{"name":"php","outputs":["php"],"role":"build","source_roles":[]},{"materialization":"embedded","name":"dinit","outputs":["dinit","dinitctl"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"msmtpd","outputs":["msmtpd"],"role":"runtime","source_roles":[]},{"name":"kernel","outputs":["kernel"],"role":"build","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/browser-lamp.toml","sha256":"8971b93eed6c1d78238812fe5bf10dc08442d7e5be309b7b0181cb080a86cae6"},{"manifest":{"architecture":"wasm32","boot":{"argv":["bash","-l","-i"],"cwd":"/home/user","env":{"HISTFILE":"/home/user/.bash_history","HOME":"/home/user","LANG":"en_US.UTF-8","LOGNAME":"user","PATH":"/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin","PS1":"kandelo$ ","SSL_CERT_DIR":"/etc/ssl/certs","SSL_CERT_FILE":"/etc/ssl/certs/ca-certificates.crt","TERM":"xterm-256color","TMPDIR":"/tmp","USER":"user"},"gid":1000,"uid":1000},"builder":"scripts/build-homebrew-main-shell-product.sh","composition":{"product":[{"id":"platform-rootfs","materialization":"embedded"}],"repository":[{"id":"main-shell-config","materialization":"embedded","paths":["homebrew/main-shell-brew-package-tree.json","homebrew/main-shell-compatibility.json","homebrew/main-shell-default.json","homebrew/main-shell-demo.json"],"role":"runtime"}]},"evidence":{"browser":{"test":"main-shell-basic-e2e"},"node":{"test":"main-shell-startup"}},"id":"browser-main-shell","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"shell.vfs.zst","schema":1,"software":{"archive":[{"id":"doom-shareware-wad","materialization":"embedded","role":"runtime","sha256":"1d7d43be501e67d927e415e0b8f3e29c3bf33075e859721816f652a526cac771","url":"https://cdn.jsdelivr.net/gh/gaborbata/vanilla-mocha-doom@15825a07a48806bcfb242a42afd5ee7cb3c9a3a4/wads/doom1.wad"}],"homebrew":[{"formulae":["bash"],"materialization":"embedded","tap":"kandelo-dev/homebrew-tap-core"},{"formulae":["dash","ncurses","coreutils","gawk","grep","sed","bc","file-formula","m4","make","findutils","diffutils","posix-utils-lite","fbdoom","modeset","less","tar","curl","netcat","wget","git","gzip","bzip2","xz","zstd","zip","unzip","lsof","nano","vim","nethack","ruby"],"materialization":"lazy","tap":"kandelo-dev/homebrew-tap-core"}],"package":[{"materialization":"lazy","name":"homebrew-bootstrap","outputs":["homebrew-bootstrap"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"homebrew-bootstrap","outputs":["homebrew-brew"],"role":"runtime","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/browser-main-shell.toml","sha256":"3c37dfd511770daab6a8018ebb3b19bb3f9290a23dadf1f4d7ebcfd1db1ad9ae"},{"manifest":{"architecture":"wasm32","boot":{"argv":["/sbin/dinit","--container","-p","/tmp/dinitctl","aria-mariadb"],"cwd":"/root","env":{"HOME":"/root","LOGNAME":"root","PATH":"/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin","TMPDIR":"/tmp","USER":"root"},"gid":0,"uid":0},"builder":"images/vfs/scripts/build-mariadb-vfs-image.sh","composition":{"product":[],"repository":[{"id":"services-database","materialization":"embedded","paths":["images/rootfs/etc/services"],"role":"runtime"}]},"evidence":{"browser":{"test":"mariadb-wasm32-browser-startup"},"node":{"test":"mariadb-wasm32-node-startup"}},"id":"browser-mariadb-wasm32","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"mariadb.vfs.zst","schema":1,"software":{"archive":[],"homebrew":[],"package":[{"materialization":"embedded","name":"mariadb","outputs":["mariadbd"],"role":"runtime","source_roles":["system-tables"]},{"materialization":"embedded","name":"dash","outputs":["dash"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"coreutils","outputs":["coreutils"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"dinit","outputs":["dinit","dinitctl"],"role":"runtime","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/browser-mariadb-wasm32.toml","sha256":"3b39cad54057596e2f6dff1a6b0aa606c29ac95dd5290b1451e52a6ee7f6fe09"},{"manifest":{"architecture":"wasm64","boot":{"argv":["/sbin/dinit","--container","-p","/tmp/dinitctl","aria-mariadb"],"cwd":"/root","env":{"HOME":"/root","LOGNAME":"root","PATH":"/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin","TMPDIR":"/tmp","USER":"root"},"gid":0,"uid":0},"builder":"images/vfs/scripts/build-mariadb-vfs-image.sh","composition":{"product":[],"repository":[{"id":"services-database","materialization":"embedded","paths":["images/rootfs/etc/services"],"role":"runtime"}]},"evidence":{"browser":{"test":"mariadb-wasm64-browser-startup"},"node":{"test":"mariadb-wasm64-node-startup"}},"id":"browser-mariadb-wasm64","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"mariadb-64.vfs.zst","schema":1,"software":{"archive":[],"homebrew":[],"package":[{"materialization":"embedded","name":"mariadb","outputs":["mariadbd"],"role":"runtime","source_roles":["system-tables"]},{"materialization":"embedded","name":"dash","outputs":["dash"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"coreutils","outputs":["coreutils"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"dinit","outputs":["dinit","dinitctl"],"role":"runtime","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/browser-mariadb-wasm64.toml","sha256":"489c7d639def874515aab5ab0a2a8e1cd1795ec1c35b1e34cb96eff201dc621b"},{"manifest":{"architecture":"wasm32","boot":{"argv":["/sbin/dinit","--container","-p","/tmp/dinitctl","nginx"],"cwd":"/root","env":{"HOME":"/root","LOGNAME":"root","PATH":"/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin","TMPDIR":"/tmp","USER":"root"},"gid":0,"uid":0},"builder":"images/vfs/scripts/build-nginx-vfs-image.sh","composition":{"product":[{"id":"browser-main-shell","materialization":"embedded"}],"repository":[]},"evidence":{"browser":{"test":"nginx-vfs-browser-startup"},"node":{"test":"nginx-vfs-node-startup"}},"id":"browser-nginx","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"nginx.vfs.zst","schema":1,"software":{"archive":[],"homebrew":[],"package":[{"materialization":"embedded","name":"nginx","outputs":["nginx"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"dinit","outputs":["dinit","dinitctl"],"role":"runtime","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/browser-nginx.toml","sha256":"f9bc65b1e8a7fc697227e84d44016841aed42cad526cdf15dadb24ced14a6b63"},{"manifest":{"architecture":"wasm32","boot":{"argv":["/sbin/dinit","--container","-p","/tmp/dinitctl","nginx"],"cwd":"/root","env":{"HOME":"/root","LOGNAME":"root","PATH":"/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin","TMPDIR":"/tmp","USER":"root"},"gid":0,"uid":0},"builder":"images/vfs/scripts/build-nginx-php-vfs-image.sh","composition":{"product":[{"id":"browser-main-shell","materialization":"embedded"}],"repository":[]},"evidence":{"browser":{"test":"nginx-php-vfs-browser-startup"},"node":{"test":"nginx-php-vfs-node-startup"}},"id":"browser-nginx-php","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"nginx-php.vfs.zst","schema":1,"software":{"archive":[],"homebrew":[],"package":[{"materialization":"embedded","name":"nginx","outputs":["nginx"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"php","outputs":["php-fpm","opcache"],"role":"runtime","source_roles":[]},{"name":"php","outputs":["php"],"role":"build","source_roles":[]},{"materialization":"embedded","name":"dinit","outputs":["dinit","dinitctl"],"role":"runtime","source_roles":[]},{"name":"kernel","outputs":["kernel"],"role":"build","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/browser-nginx-php.toml","sha256":"b4447c2dac66d972c824ab7805a65b914a1d19954730739a7ccfbcd0ca237a8c"},{"manifest":{"architecture":"wasm32","boot":{"argv":["bash","-l","-i"],"cwd":"/work","env":{"HOME":"/work","LOGNAME":"user","PATH":"/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin","TMPDIR":"/tmp","USER":"user","npm_config_cache":"/tmp/.npm-cache","npm_config_registry":"http://proxy.local/"},"gid":1000,"uid":1000},"builder":"images/vfs/scripts/build-node-vfs-image.sh","composition":{"product":[{"id":"browser-main-shell","materialization":"embedded"}],"repository":[]},"evidence":{"browser":{"test":"node-vfs-browser-startup"},"node":{"test":"node-vfs-node-startup"}},"id":"browser-node","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"node-vfs.vfs.zst","schema":1,"software":{"archive":[{"id":"npm-runtime","materialization":"embedded","role":"runtime","sha256":"5cd1e5ab971ea6333f910bc2d50700167c5ef4e66da279b2a3efc874c6b116e4","url":"https://registry.npmjs.org/npm/-/npm-10.9.2.tgz"}],"homebrew":[],"package":[{"materialization":"embedded","name":"node","outputs":["node"],"role":"runtime","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/browser-node.toml","sha256":"745ffbcaf4773eefd7c57724b1f5fe2ccedad5b935171e12bb1205887c90fcd8"},{"manifest":{"architecture":"wasm32","boot":{"argv":["/usr/bin/perl","-e","print qq(Perl on Kandelo\\n)"],"cwd":"/home","env":{"HOME":"/home","PATH":"/usr/local/bin:/usr/bin:/bin","PERL5LIB":"/usr/lib/perl5","TMPDIR":"/tmp"},"gid":1000,"uid":1000},"builder":"images/vfs/scripts/build-perl-vfs-image.sh","composition":{"product":[],"repository":[]},"evidence":{"browser":{"test":"perl-vfs-browser-smoke"},"node":{"test":"perl-vfs-node-smoke"}},"id":"browser-perl","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"perl.vfs.zst","schema":1,"software":{"archive":[],"homebrew":[],"package":[{"materialization":"lazy","name":"perl","outputs":["perl"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"perl","outputs":[],"role":"runtime","source_roles":["standard-library"]}],"toolchain":[]}},"path":"images/vfs/products/browser-perl.toml","sha256":"dbdad2e5dd8e752c1f70a769f449478066dc6e0d9eafaa90b2b35fcb5616a78f"},{"manifest":{"architecture":"wasm32","boot":{"argv":["/usr/bin/python3"],"cwd":"/home","env":{"HOME":"/home","PATH":"/usr/local/bin:/usr/bin:/bin","PYTHONDONTWRITEBYTECODE":"1","PYTHONHOME":"/usr","TMPDIR":"/tmp"},"gid":1000,"uid":1000},"builder":"images/vfs/scripts/build-python-vfs-image.sh","composition":{"product":[],"repository":[]},"evidence":{"browser":{"test":"python-vfs-browser-smoke"},"node":{"test":"python-vfs-node-smoke"}},"id":"browser-python","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"python.vfs.zst","schema":1,"software":{"archive":[],"homebrew":[],"package":[{"materialization":"embedded","name":"cpython","outputs":["cpython","python-runtime"],"role":"runtime","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/browser-python.toml","sha256":"c85542a239cabd02b69a4daa8c7c47661ab63ec179cd8f7edcfa2acff80818f2"},{"manifest":{"architecture":"wasm32","boot":{"argv":["/sbin/dinit","--container","-p","/tmp/dinitctl","redis"],"cwd":"/root","env":{"HOME":"/root","LOGNAME":"root","PATH":"/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin","TMPDIR":"/tmp","USER":"root"},"gid":0,"uid":0},"builder":"images/vfs/scripts/build-redis-vfs-image.sh","composition":{"product":[],"repository":[{"id":"services-database","materialization":"embedded","paths":["images/rootfs/etc/services"],"role":"runtime"}]},"evidence":{"browser":{"test":"redis-vfs-browser-startup"},"node":{"test":"redis-vfs-node-startup"}},"id":"browser-redis","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"redis.vfs.zst","schema":1,"software":{"archive":[],"homebrew":[],"package":[{"materialization":"embedded","name":"redis","outputs":["redis-server"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"dinit","outputs":["dinit","dinitctl"],"role":"runtime","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/browser-redis.toml","sha256":"69ab9b6a04b1402e5bbeaea9bdd6e25360b6b50ad0c8c561b7dace28cf331492"},{"manifest":{"architecture":"wasm32","boot":{"argv":["/sbin/dinit","--container","-p","/tmp/dinitctl","nginx"],"cwd":"/root","env":{"HOME":"/root","LOGNAME":"root","PATH":"/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin","TMPDIR":"/tmp","USER":"root","WP_APP_PATH":"/app","WP_PROTO":"http"},"gid":0,"uid":0},"builder":"images/vfs/scripts/build-wp-vfs-image.sh","composition":{"product":[{"id":"browser-main-shell","materialization":"embedded"}],"repository":[]},"evidence":{"browser":{"test":"wordpress-sqlite-browser-e2e"},"node":{"test":"wordpress-sqlite-node-startup"}},"id":"browser-wordpress","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"wordpress.vfs.zst","schema":1,"software":{"archive":[{"id":"wordpress-core","materialization":"embedded","role":"runtime","sha256":"530c8fdeb16fb0affdb53eb727b6a04bb8d166621c20029e389cabb01a0fa921","url":"https://wordpress.org/wordpress-7.0.tar.gz"},{"id":"wordpress-sqlite-integration","materialization":"embedded","role":"runtime","sha256":"ccc69cada05983e6c2dac8c0962b548c437b4c96c00ea41b0e130fc128671391","url":"https://downloads.wordpress.org/plugin/sqlite-database-integration.2.1.16.zip"}],"homebrew":[],"package":[{"materialization":"embedded","name":"nginx","outputs":["nginx"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"php","outputs":["php-fpm","opcache"],"role":"runtime","source_roles":[]},{"name":"php","outputs":["php"],"role":"build","source_roles":[]},{"materialization":"embedded","name":"dinit","outputs":["dinit","dinitctl"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"msmtpd","outputs":["msmtpd"],"role":"runtime","source_roles":[]},{"name":"kernel","outputs":["kernel"],"role":"build","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/browser-wordpress.toml","sha256":"572b285afd199904bb5723587ac338cd7824c6856f12fd7062d93f873a650a8d"},{"manifest":{"architecture":"wasm32","boot":{"argv":["/bin/sh"],"cwd":"/home","env":{"HOME":"/home","PATH":"/usr/local/bin:/usr/bin:/bin","TMPDIR":"/tmp"},"gid":1000,"uid":1000},"builder":"images/vfs/scripts/build-kandelo-sdk-vfs-image.sh","composition":{"product":[],"repository":[{"id":"sdk-wrappers","materialization":"embedded","paths":["sdk/config.site","sdk/kandelo/bin"],"role":"runtime"},{"id":"sdk-glue","materialization":"embedded","paths":["libc/glue"],"role":"runtime"},{"id":"sdk-licenses","materialization":"embedded","paths":["COPYING.runtime","LICENSE","libc/musl/COPYRIGHT","sdk/kandelo/licenses"],"role":"runtime"}]},"evidence":{"node":{"test":"kandelo-sdk-node-compile"}},"id":"developer-kandelo-sdk","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"kandelo-sdk.vfs.zst","schema":1,"software":{"archive":[],"homebrew":[],"package":[{"materialization":"embedded","name":"libcxx","outputs":["libcxx"],"role":"runtime","source_roles":[]}],"toolchain":[{"component":"wasm32-sysroot","id":"wasm32-sysroot","materialization":"embedded","provider":"repository-dev-shell","role":"runtime"},{"component":"clang-resource-headers","id":"clang-resource-headers","materialization":"embedded","provider":"repository-dev-shell","role":"runtime"}]}},"path":"images/vfs/products/developer-kandelo-sdk.toml","sha256":"f132250bbfdd193351f65548227bcc6014f0adaad856e94d87f5f5715ccb0b53"},{"manifest":{"architecture":"wasm32","boot":{"argv":["/bin/sh"],"cwd":"/root","env":{"HOME":"/root","PATH":"/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin","TMPDIR":"/tmp"},"gid":0,"uid":0},"builder":"packages/registry/rootfs/build-rootfs-package.sh","composition":{"product":[],"repository":[{"id":"rootfs-source","materialization":"embedded","paths":["MANIFEST","images/rootfs"],"role":"runtime"}]},"evidence":{"browser":{"test":"rootfs-browser-startup"},"node":{"test":"rootfs-node-startup"}},"id":"platform-rootfs","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"rootfs.vfs","schema":1,"software":{"archive":[],"homebrew":[],"package":[{"materialization":"lazy","name":"dash","outputs":["dash"],"role":"runtime","source_roles":[]},{"materialization":"lazy","name":"bash","outputs":["bash"],"role":"runtime","source_roles":[]},{"materialization":"lazy","name":"ncurses","outputs":["clear","reset","tset","tput","tabs","tic","infocmp","toe","captoinfo","infotocap"],"role":"runtime","source_roles":[]},{"materialization":"lazy","name":"coreutils","outputs":["coreutils"],"role":"runtime","source_roles":[]},{"materialization":"lazy","name":"gawk","outputs":["gawk"],"role":"runtime","source_roles":[]},{"materialization":"lazy","name":"grep","outputs":["grep"],"role":"runtime","source_roles":[]},{"materialization":"lazy","name":"sed","outputs":["sed"],"role":"runtime","source_roles":[]},{"materialization":"lazy","name":"bc","outputs":["bc"],"role":"runtime","source_roles":[]},{"materialization":"lazy","name":"file","outputs":["file"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"file","outputs":["file-magic"],"role":"runtime","source_roles":[]},{"materialization":"lazy","name":"m4","outputs":["m4"],"role":"runtime","source_roles":[]},{"materialization":"lazy","name":"make","outputs":["make"],"role":"runtime","source_roles":[]},{"materialization":"lazy","name":"findutils","outputs":["find","xargs"],"role":"runtime","source_roles":[]},{"materialization":"lazy","name":"diffutils","outputs":["diff","cmp","diff3","sdiff"],"role":"runtime","source_roles":[]},{"materialization":"lazy","name":"posix-utils-lite","outputs":["ar","asa","cal","cflow","compress","ctags","cxref","ed","ex","fuser","gencat","getconf","gettext","iconv","ipcrm","ipcs","lex","locale","logger","man","more","msgfmt","ngettext","nm","patch","pax","pgrep","ps","renice","strings","strip","uncompress","uudecode","uuencode","what","xgettext","yacc"],"role":"runtime","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/platform-rootfs.toml","sha256":"64414e6f9252c93c45f96033e24854885d27bea3d71fbd5ed4d2ed5639805b1c"},{"manifest":{"architecture":"wasm32","boot":{"argv":["/sbin/dinit","--container","-p","/tmp/dinitctl","mariadb"],"cwd":"/root","env":{"HOME":"/root","LOGNAME":"root","PATH":"/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin","TMPDIR":"/tmp","USER":"root"},"gid":0,"uid":0},"builder":"images/vfs/scripts/build-mariadb-test-vfs-image.sh","composition":{"product":[],"repository":[{"id":"services-database","materialization":"embedded","paths":["images/rootfs/etc/services"],"role":"runtime"}]},"evidence":{"browser":{"test":"mariadb-suite-browser"},"node":{"test":"mariadb-suite-node"}},"id":"test-mariadb","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"mariadb-test.vfs.zst","schema":1,"software":{"archive":[],"homebrew":[],"package":[{"materialization":"embedded","name":"mariadb","outputs":["mariadbd","mysqltest"],"role":"runtime","source_roles":["system-tables","test-suite"]},{"materialization":"embedded","name":"dash","outputs":["dash"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"coreutils","outputs":["coreutils"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"dinit","outputs":["dinit","dinitctl"],"role":"runtime","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/test-mariadb.toml","sha256":"d22c29ce0cdaee199d7b5ca539c1272fe2524f2edae6a5bf6c34f76a513e170d"},{"manifest":{"architecture":"wasm32","boot":{"argv":["/bin/sh"],"cwd":"/php-src","env":{"HOME":"/root","PATH":"/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin","TMPDIR":"/tmp"},"gid":0,"uid":0},"builder":"images/vfs/scripts/build-php-test-vfs-image.sh","composition":{"product":[{"id":"platform-rootfs","materialization":"embedded"}],"repository":[{"id":"php-test-fixtures","materialization":"embedded","paths":["tests/php-fixtures"],"role":"runtime"}]},"evidence":{"browser":{"test":"php-suite-browser"},"node":{"test":"php-suite-node"}},"id":"test-php","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"php-test.vfs.zst","schema":1,"software":{"archive":[],"homebrew":[],"package":[{"materialization":"embedded","name":"php","outputs":["php","php-fpm","opcache","curl","phar","zend_test","zip","intl","icu-data"],"role":"runtime","source_roles":["test-suite"]}],"toolchain":[]}},"path":"images/vfs/products/test-php.toml","sha256":"38eb4efc59c8e84ac7269285cc7f7c9c324271440e4a710ee1a249ddae6979f6"},{"manifest":{"architecture":"wasm32","boot":{"argv":["/bin/sh"],"cwd":"/sqlite","env":{"HOME":"/root","PATH":"/usr/local/bin:/usr/bin:/bin","TCL_LIBRARY":"/usr/lib/tcl8.6","TMPDIR":"/tmp"},"gid":0,"uid":0},"builder":"images/vfs/scripts/build-sqlite-test-vfs-image.sh","composition":{"product":[],"repository":[]},"evidence":{"browser":{"test":"sqlite-suite-browser"},"node":{"test":"sqlite-suite-node"}},"id":"test-sqlite","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"sqlite-test.vfs.zst","schema":1,"software":{"archive":[{"id":"sqlite-full-source","materialization":"embedded","role":"runtime","sha256":"4404d93cbce818b1b98ca7259d0ba9b45db76f2fdd9373e56f2d29b519f4d43b","url":"https://www.sqlite.org/2025/sqlite-src-3490100.zip"}],"homebrew":[],"package":[{"materialization":"embedded","name":"sqlite","outputs":["sqlite3","testfixture"],"role":"runtime","source_roles":[]},{"name":"sqlite","outputs":["development-files"],"role":"build","source_roles":[]},{"materialization":"embedded","name":"tcl","outputs":[],"role":"runtime","source_roles":["runtime-library"]},{"name":"tcl","outputs":["development-files"],"role":"build","source_roles":[]},{"name":"zlib","outputs":["zlib"],"role":"build","source_roles":[]},{"materialization":"embedded","name":"dash","outputs":["dash"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"coreutils","outputs":["coreutils"],"role":"runtime","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/test-sqlite.toml","sha256":"87a2a9c75b1d504f8613376ea5da2e4ac4713c86fcb72f34bfbaf1e31741b1e4"}],"schema":1} +{"kind":"kandelo-vfs-product-catalog","products":[{"manifest":{"architecture":"wasm32","boot":{"argv":["/usr/local/lib/erlang/erts-16.1.2/bin/beam.smp","--","-root","/usr/local/lib/erlang","-bindir","/usr/local/lib/erlang/erts-16.1.2/bin","-boot","/usr/local/lib/erlang/releases/28/start_clean","-noshell"],"cwd":"/home","env":{"BINDIR":"/usr/local/lib/erlang/erts-16.1.2/bin","HOME":"/home","PATH":"/usr/local/lib/erlang/erts-16.1.2/bin:/usr/local/bin:/usr/bin:/bin","ROOTDIR":"/usr/local/lib/erlang","TMPDIR":"/tmp"},"gid":1000,"uid":1000},"builder":"images/vfs/scripts/build-erlang-vfs-image.sh","composition":{"product":[],"repository":[]},"evidence":{"browser":{"test":"erlang-vfs-browser-smoke"},"node":{"test":"erlang-vfs-node-smoke"}},"id":"browser-erlang","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"erlang.vfs.zst","schema":1,"software":{"archive":[],"homebrew":[],"package":[{"materialization":"embedded","name":"erlang","outputs":["erlang","erlang-otp"],"role":"runtime","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/browser-erlang.toml","sha256":"39b4e21b637cbf4c8568396a2068418a3d4c143562bf7734e4b5df635ab52aae"},{"manifest":{"architecture":"wasm32","boot":{"argv":["/sbin/dinit","--container","-p","/tmp/dinitctl","nginx"],"cwd":"/root","env":{"HOME":"/root","LOGNAME":"root","PATH":"/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin","TMPDIR":"/tmp","USER":"root","WP_APP_PATH":"/app","WP_PROTO":"http"},"gid":0,"uid":0},"builder":"images/vfs/scripts/build-lamp-vfs-image.sh","composition":{"product":[{"id":"browser-main-shell","materialization":"embedded"}],"repository":[]},"evidence":{"browser":{"test":"wordpress-mariadb-browser-e2e"},"node":{"test":"wordpress-mariadb-node-startup"}},"id":"browser-lamp","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"lamp.vfs.zst","schema":1,"software":{"archive":[{"id":"wordpress-core","materialization":"embedded","role":"runtime","sha256":"530c8fdeb16fb0affdb53eb727b6a04bb8d166621c20029e389cabb01a0fa921","url":"https://wordpress.org/wordpress-7.0.tar.gz"}],"homebrew":[],"package":[{"materialization":"embedded","name":"mariadb","outputs":["mariadbd"],"role":"runtime","source_roles":["system-tables"]},{"materialization":"embedded","name":"nginx","outputs":["nginx"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"php","outputs":["php-fpm","opcache"],"role":"runtime","source_roles":[]},{"name":"php","outputs":["php"],"role":"build","source_roles":[]},{"materialization":"embedded","name":"dinit","outputs":["dinit","dinitctl"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"msmtpd","outputs":["msmtpd"],"role":"runtime","source_roles":[]},{"name":"kernel","outputs":["kernel"],"role":"build","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/browser-lamp.toml","sha256":"8971b93eed6c1d78238812fe5bf10dc08442d7e5be309b7b0181cb080a86cae6"},{"manifest":{"architecture":"wasm32","boot":{"argv":["bash","-l","-i"],"cwd":"/home/user","env":{"HISTFILE":"/home/user/.bash_history","HOME":"/home/user","LANG":"en_US.UTF-8","LOGNAME":"user","PATH":"/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin","PS1":"kandelo$ ","SSL_CERT_DIR":"/etc/ssl/certs","SSL_CERT_FILE":"/etc/ssl/certs/ca-certificates.crt","TERM":"xterm-256color","TMPDIR":"/tmp","USER":"user"},"gid":1000,"uid":1000},"builder":"scripts/build-homebrew-main-shell-product.sh","composition":{"product":[{"id":"platform-rootfs","materialization":"embedded"}],"repository":[{"id":"main-shell-config","materialization":"embedded","paths":["homebrew/main-shell-brew-package-tree.json","homebrew/main-shell-compatibility.json","homebrew/main-shell-default.json","homebrew/main-shell-demo.json"],"role":"runtime"}]},"evidence":{"browser":{"test":"main-shell-basic-e2e"},"node":{"test":"main-shell-startup"}},"id":"browser-main-shell","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"shell.vfs.zst","schema":1,"software":{"archive":[{"id":"doom-shareware-wad","materialization":"embedded","role":"runtime","sha256":"1d7d43be501e67d927e415e0b8f3e29c3bf33075e859721816f652a526cac771","url":"https://cdn.jsdelivr.net/gh/gaborbata/vanilla-mocha-doom@15825a07a48806bcfb242a42afd5ee7cb3c9a3a4/wads/doom1.wad"}],"homebrew":[{"formulae":["bash","login","sudo-lite","sudo","ruby"],"materialization":"embedded","tap":"kandelo-dev/homebrew-tap-core"},{"formulae":["dash","ncurses","coreutils","gawk","grep","sed","bc","file-formula","m4","make","findutils","diffutils","posix-utils-lite","fbdoom","modeset","less","tar","curl","netcat","wget","git","gzip","bzip2","xz","zstd","zip","unzip","lsof","nano","vim","nethack"],"materialization":"lazy","tap":"kandelo-dev/homebrew-tap-core"}],"package":[{"materialization":"lazy","name":"homebrew-bootstrap","outputs":["homebrew-bootstrap"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"homebrew-bootstrap","outputs":["homebrew-brew"],"role":"runtime","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/browser-main-shell.toml","sha256":"3cbdd2ab441b8d137f6795e9ac7976a9c618caa329eda306ac95d2d97e701229"},{"manifest":{"architecture":"wasm32","boot":{"argv":["/sbin/dinit","--container","-p","/tmp/dinitctl","aria-mariadb"],"cwd":"/root","env":{"HOME":"/root","LOGNAME":"root","PATH":"/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin","TMPDIR":"/tmp","USER":"root"},"gid":0,"uid":0},"builder":"images/vfs/scripts/build-mariadb-vfs-image.sh","composition":{"product":[],"repository":[{"id":"services-database","materialization":"embedded","paths":["images/rootfs/etc/services"],"role":"runtime"}]},"evidence":{"browser":{"test":"mariadb-wasm32-browser-startup"},"node":{"test":"mariadb-wasm32-node-startup"}},"id":"browser-mariadb-wasm32","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"mariadb.vfs.zst","schema":1,"software":{"archive":[],"homebrew":[],"package":[{"materialization":"embedded","name":"mariadb","outputs":["mariadbd"],"role":"runtime","source_roles":["system-tables"]},{"materialization":"embedded","name":"dash","outputs":["dash"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"coreutils","outputs":["coreutils"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"dinit","outputs":["dinit","dinitctl"],"role":"runtime","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/browser-mariadb-wasm32.toml","sha256":"3b39cad54057596e2f6dff1a6b0aa606c29ac95dd5290b1451e52a6ee7f6fe09"},{"manifest":{"architecture":"wasm64","boot":{"argv":["/sbin/dinit","--container","-p","/tmp/dinitctl","aria-mariadb"],"cwd":"/root","env":{"HOME":"/root","LOGNAME":"root","PATH":"/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin","TMPDIR":"/tmp","USER":"root"},"gid":0,"uid":0},"builder":"images/vfs/scripts/build-mariadb-vfs-image.sh","composition":{"product":[],"repository":[{"id":"services-database","materialization":"embedded","paths":["images/rootfs/etc/services"],"role":"runtime"}]},"evidence":{"browser":{"test":"mariadb-wasm64-browser-startup"},"node":{"test":"mariadb-wasm64-node-startup"}},"id":"browser-mariadb-wasm64","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"mariadb-64.vfs.zst","schema":1,"software":{"archive":[],"homebrew":[],"package":[{"materialization":"embedded","name":"mariadb","outputs":["mariadbd"],"role":"runtime","source_roles":["system-tables"]},{"materialization":"embedded","name":"dash","outputs":["dash"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"coreutils","outputs":["coreutils"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"dinit","outputs":["dinit","dinitctl"],"role":"runtime","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/browser-mariadb-wasm64.toml","sha256":"489c7d639def874515aab5ab0a2a8e1cd1795ec1c35b1e34cb96eff201dc621b"},{"manifest":{"architecture":"wasm32","boot":{"argv":["/sbin/dinit","--container","-p","/tmp/dinitctl","nginx"],"cwd":"/root","env":{"HOME":"/root","LOGNAME":"root","PATH":"/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin","TMPDIR":"/tmp","USER":"root"},"gid":0,"uid":0},"builder":"images/vfs/scripts/build-nginx-vfs-image.sh","composition":{"product":[{"id":"browser-main-shell","materialization":"embedded"}],"repository":[]},"evidence":{"browser":{"test":"nginx-vfs-browser-startup"},"node":{"test":"nginx-vfs-node-startup"}},"id":"browser-nginx","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"nginx.vfs.zst","schema":1,"software":{"archive":[],"homebrew":[],"package":[{"materialization":"embedded","name":"nginx","outputs":["nginx"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"dinit","outputs":["dinit","dinitctl"],"role":"runtime","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/browser-nginx.toml","sha256":"f9bc65b1e8a7fc697227e84d44016841aed42cad526cdf15dadb24ced14a6b63"},{"manifest":{"architecture":"wasm32","boot":{"argv":["/sbin/dinit","--container","-p","/tmp/dinitctl","nginx"],"cwd":"/root","env":{"HOME":"/root","LOGNAME":"root","PATH":"/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin","TMPDIR":"/tmp","USER":"root"},"gid":0,"uid":0},"builder":"images/vfs/scripts/build-nginx-php-vfs-image.sh","composition":{"product":[{"id":"browser-main-shell","materialization":"embedded"}],"repository":[]},"evidence":{"browser":{"test":"nginx-php-vfs-browser-startup"},"node":{"test":"nginx-php-vfs-node-startup"}},"id":"browser-nginx-php","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"nginx-php.vfs.zst","schema":1,"software":{"archive":[],"homebrew":[],"package":[{"materialization":"embedded","name":"nginx","outputs":["nginx"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"php","outputs":["php-fpm","opcache"],"role":"runtime","source_roles":[]},{"name":"php","outputs":["php"],"role":"build","source_roles":[]},{"materialization":"embedded","name":"dinit","outputs":["dinit","dinitctl"],"role":"runtime","source_roles":[]},{"name":"kernel","outputs":["kernel"],"role":"build","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/browser-nginx-php.toml","sha256":"b4447c2dac66d972c824ab7805a65b914a1d19954730739a7ccfbcd0ca237a8c"},{"manifest":{"architecture":"wasm32","boot":{"argv":["bash","-l","-i"],"cwd":"/work","env":{"HOME":"/work","LOGNAME":"user","PATH":"/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin","TMPDIR":"/tmp","USER":"user","npm_config_cache":"/tmp/.npm-cache","npm_config_registry":"http://proxy.local/"},"gid":1000,"uid":1000},"builder":"images/vfs/scripts/build-node-vfs-image.sh","composition":{"product":[{"id":"browser-main-shell","materialization":"embedded"}],"repository":[]},"evidence":{"browser":{"test":"node-vfs-browser-startup"},"node":{"test":"node-vfs-node-startup"}},"id":"browser-node","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"node-vfs.vfs.zst","schema":1,"software":{"archive":[{"id":"npm-runtime","materialization":"embedded","role":"runtime","sha256":"5cd1e5ab971ea6333f910bc2d50700167c5ef4e66da279b2a3efc874c6b116e4","url":"https://registry.npmjs.org/npm/-/npm-10.9.2.tgz"}],"homebrew":[],"package":[{"materialization":"embedded","name":"node","outputs":["node"],"role":"runtime","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/browser-node.toml","sha256":"745ffbcaf4773eefd7c57724b1f5fe2ccedad5b935171e12bb1205887c90fcd8"},{"manifest":{"architecture":"wasm32","boot":{"argv":["/usr/bin/perl","-e","print qq(Perl on Kandelo\\n)"],"cwd":"/home","env":{"HOME":"/home","PATH":"/usr/local/bin:/usr/bin:/bin","PERL5LIB":"/usr/lib/perl5","TMPDIR":"/tmp"},"gid":1000,"uid":1000},"builder":"images/vfs/scripts/build-perl-vfs-image.sh","composition":{"product":[],"repository":[]},"evidence":{"browser":{"test":"perl-vfs-browser-smoke"},"node":{"test":"perl-vfs-node-smoke"}},"id":"browser-perl","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"perl.vfs.zst","schema":1,"software":{"archive":[],"homebrew":[],"package":[{"materialization":"lazy","name":"perl","outputs":["perl"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"perl","outputs":[],"role":"runtime","source_roles":["standard-library"]}],"toolchain":[]}},"path":"images/vfs/products/browser-perl.toml","sha256":"dbdad2e5dd8e752c1f70a769f449478066dc6e0d9eafaa90b2b35fcb5616a78f"},{"manifest":{"architecture":"wasm32","boot":{"argv":["/usr/bin/python3"],"cwd":"/home","env":{"HOME":"/home","PATH":"/usr/local/bin:/usr/bin:/bin","PYTHONDONTWRITEBYTECODE":"1","PYTHONHOME":"/usr","TMPDIR":"/tmp"},"gid":1000,"uid":1000},"builder":"images/vfs/scripts/build-python-vfs-image.sh","composition":{"product":[],"repository":[]},"evidence":{"browser":{"test":"python-vfs-browser-smoke"},"node":{"test":"python-vfs-node-smoke"}},"id":"browser-python","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"python.vfs.zst","schema":1,"software":{"archive":[],"homebrew":[],"package":[{"materialization":"embedded","name":"cpython","outputs":["cpython","python-runtime"],"role":"runtime","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/browser-python.toml","sha256":"c85542a239cabd02b69a4daa8c7c47661ab63ec179cd8f7edcfa2acff80818f2"},{"manifest":{"architecture":"wasm32","boot":{"argv":["/sbin/dinit","--container","-p","/tmp/dinitctl","redis"],"cwd":"/root","env":{"HOME":"/root","LOGNAME":"root","PATH":"/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin","TMPDIR":"/tmp","USER":"root"},"gid":0,"uid":0},"builder":"images/vfs/scripts/build-redis-vfs-image.sh","composition":{"product":[],"repository":[{"id":"services-database","materialization":"embedded","paths":["images/rootfs/etc/services"],"role":"runtime"}]},"evidence":{"browser":{"test":"redis-vfs-browser-startup"},"node":{"test":"redis-vfs-node-startup"}},"id":"browser-redis","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"redis.vfs.zst","schema":1,"software":{"archive":[],"homebrew":[],"package":[{"materialization":"embedded","name":"redis","outputs":["redis-server"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"dinit","outputs":["dinit","dinitctl"],"role":"runtime","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/browser-redis.toml","sha256":"69ab9b6a04b1402e5bbeaea9bdd6e25360b6b50ad0c8c561b7dace28cf331492"},{"manifest":{"architecture":"wasm32","boot":{"argv":["/sbin/dinit","--container","-p","/tmp/dinitctl","nginx"],"cwd":"/root","env":{"HOME":"/root","LOGNAME":"root","PATH":"/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin","TMPDIR":"/tmp","USER":"root","WP_APP_PATH":"/app","WP_PROTO":"http"},"gid":0,"uid":0},"builder":"images/vfs/scripts/build-wp-vfs-image.sh","composition":{"product":[{"id":"browser-main-shell","materialization":"embedded"}],"repository":[]},"evidence":{"browser":{"test":"wordpress-sqlite-browser-e2e"},"node":{"test":"wordpress-sqlite-node-startup"}},"id":"browser-wordpress","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"wordpress.vfs.zst","schema":1,"software":{"archive":[{"id":"wordpress-core","materialization":"embedded","role":"runtime","sha256":"530c8fdeb16fb0affdb53eb727b6a04bb8d166621c20029e389cabb01a0fa921","url":"https://wordpress.org/wordpress-7.0.tar.gz"},{"id":"wordpress-sqlite-integration","materialization":"embedded","role":"runtime","sha256":"ccc69cada05983e6c2dac8c0962b548c437b4c96c00ea41b0e130fc128671391","url":"https://downloads.wordpress.org/plugin/sqlite-database-integration.2.1.16.zip"}],"homebrew":[],"package":[{"materialization":"embedded","name":"nginx","outputs":["nginx"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"php","outputs":["php-fpm","opcache"],"role":"runtime","source_roles":[]},{"name":"php","outputs":["php"],"role":"build","source_roles":[]},{"materialization":"embedded","name":"dinit","outputs":["dinit","dinitctl"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"msmtpd","outputs":["msmtpd"],"role":"runtime","source_roles":[]},{"name":"kernel","outputs":["kernel"],"role":"build","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/browser-wordpress.toml","sha256":"572b285afd199904bb5723587ac338cd7824c6856f12fd7062d93f873a650a8d"},{"manifest":{"architecture":"wasm32","boot":{"argv":["/bin/sh"],"cwd":"/home","env":{"HOME":"/home","PATH":"/usr/local/bin:/usr/bin:/bin","TMPDIR":"/tmp"},"gid":1000,"uid":1000},"builder":"images/vfs/scripts/build-kandelo-sdk-vfs-image.sh","composition":{"product":[],"repository":[{"id":"sdk-wrappers","materialization":"embedded","paths":["sdk/config.site","sdk/kandelo/bin"],"role":"runtime"},{"id":"sdk-glue","materialization":"embedded","paths":["libc/glue"],"role":"runtime"},{"id":"sdk-licenses","materialization":"embedded","paths":["COPYING.runtime","LICENSE","libc/musl/COPYRIGHT","sdk/kandelo/licenses"],"role":"runtime"}]},"evidence":{"node":{"test":"kandelo-sdk-node-compile"}},"id":"developer-kandelo-sdk","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"kandelo-sdk.vfs.zst","schema":1,"software":{"archive":[],"homebrew":[],"package":[{"materialization":"embedded","name":"libcxx","outputs":["libcxx"],"role":"runtime","source_roles":[]}],"toolchain":[{"component":"wasm32-sysroot","id":"wasm32-sysroot","materialization":"embedded","provider":"repository-dev-shell","role":"runtime"},{"component":"clang-resource-headers","id":"clang-resource-headers","materialization":"embedded","provider":"repository-dev-shell","role":"runtime"}]}},"path":"images/vfs/products/developer-kandelo-sdk.toml","sha256":"f132250bbfdd193351f65548227bcc6014f0adaad856e94d87f5f5715ccb0b53"},{"manifest":{"architecture":"wasm32","boot":{"argv":["/bin/sh"],"cwd":"/root","env":{"HOME":"/root","PATH":"/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin","TMPDIR":"/tmp"},"gid":0,"uid":0},"builder":"packages/registry/rootfs/build-rootfs-package.sh","composition":{"product":[],"repository":[{"id":"rootfs-source","materialization":"embedded","paths":["MANIFEST","images/rootfs"],"role":"runtime"}]},"evidence":{"browser":{"test":"rootfs-browser-startup"},"node":{"test":"rootfs-node-startup"}},"id":"platform-rootfs","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"rootfs.vfs","schema":1,"software":{"archive":[],"homebrew":[],"package":[{"materialization":"lazy","name":"dash","outputs":["dash"],"role":"runtime","source_roles":[]},{"materialization":"lazy","name":"bash","outputs":["bash"],"role":"runtime","source_roles":[]},{"materialization":"lazy","name":"ncurses","outputs":["clear","reset","tset","tput","tabs","tic","infocmp","toe","captoinfo","infotocap"],"role":"runtime","source_roles":[]},{"materialization":"lazy","name":"coreutils","outputs":["coreutils"],"role":"runtime","source_roles":[]},{"materialization":"lazy","name":"gawk","outputs":["gawk"],"role":"runtime","source_roles":[]},{"materialization":"lazy","name":"grep","outputs":["grep"],"role":"runtime","source_roles":[]},{"materialization":"lazy","name":"sed","outputs":["sed"],"role":"runtime","source_roles":[]},{"materialization":"lazy","name":"bc","outputs":["bc"],"role":"runtime","source_roles":[]},{"materialization":"lazy","name":"file","outputs":["file"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"file","outputs":["file-magic"],"role":"runtime","source_roles":[]},{"materialization":"lazy","name":"m4","outputs":["m4"],"role":"runtime","source_roles":[]},{"materialization":"lazy","name":"make","outputs":["make"],"role":"runtime","source_roles":[]},{"materialization":"lazy","name":"findutils","outputs":["find","xargs"],"role":"runtime","source_roles":[]},{"materialization":"lazy","name":"diffutils","outputs":["diff","cmp","diff3","sdiff"],"role":"runtime","source_roles":[]},{"materialization":"lazy","name":"posix-utils-lite","outputs":["ar","asa","cal","cflow","compress","ctags","cxref","ed","ex","fuser","gencat","getconf","gettext","iconv","ipcrm","ipcs","lex","locale","logger","man","more","msgfmt","ngettext","nm","patch","pax","pgrep","ps","renice","strings","strip","uncompress","uudecode","uuencode","what","xgettext","yacc"],"role":"runtime","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/platform-rootfs.toml","sha256":"64414e6f9252c93c45f96033e24854885d27bea3d71fbd5ed4d2ed5639805b1c"},{"manifest":{"architecture":"wasm32","boot":{"argv":["/sbin/dinit","--container","-p","/tmp/dinitctl","mariadb"],"cwd":"/root","env":{"HOME":"/root","LOGNAME":"root","PATH":"/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin","TMPDIR":"/tmp","USER":"root"},"gid":0,"uid":0},"builder":"images/vfs/scripts/build-mariadb-test-vfs-image.sh","composition":{"product":[],"repository":[{"id":"services-database","materialization":"embedded","paths":["images/rootfs/etc/services"],"role":"runtime"}]},"evidence":{"browser":{"test":"mariadb-suite-browser"},"node":{"test":"mariadb-suite-node"}},"id":"test-mariadb","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"mariadb-test.vfs.zst","schema":1,"software":{"archive":[],"homebrew":[],"package":[{"materialization":"embedded","name":"mariadb","outputs":["mariadbd","mysqltest"],"role":"runtime","source_roles":["system-tables","test-suite"]},{"materialization":"embedded","name":"dash","outputs":["dash"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"coreutils","outputs":["coreutils"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"dinit","outputs":["dinit","dinitctl"],"role":"runtime","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/test-mariadb.toml","sha256":"d22c29ce0cdaee199d7b5ca539c1272fe2524f2edae6a5bf6c34f76a513e170d"},{"manifest":{"architecture":"wasm32","boot":{"argv":["/bin/sh"],"cwd":"/php-src","env":{"HOME":"/root","PATH":"/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin","TMPDIR":"/tmp"},"gid":0,"uid":0},"builder":"images/vfs/scripts/build-php-test-vfs-image.sh","composition":{"product":[{"id":"platform-rootfs","materialization":"embedded"}],"repository":[{"id":"php-test-fixtures","materialization":"embedded","paths":["tests/php-fixtures"],"role":"runtime"}]},"evidence":{"browser":{"test":"php-suite-browser"},"node":{"test":"php-suite-node"}},"id":"test-php","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"php-test.vfs.zst","schema":1,"software":{"archive":[],"homebrew":[],"package":[{"materialization":"embedded","name":"php","outputs":["php","php-fpm","opcache","curl","phar","zend_test","zip","intl","icu-data"],"role":"runtime","source_roles":["test-suite"]}],"toolchain":[]}},"path":"images/vfs/products/test-php.toml","sha256":"38eb4efc59c8e84ac7269285cc7f7c9c324271440e4a710ee1a249ddae6979f6"},{"manifest":{"architecture":"wasm32","boot":{"argv":["/bin/sh"],"cwd":"/sqlite","env":{"HOME":"/root","PATH":"/usr/local/bin:/usr/bin:/bin","TCL_LIBRARY":"/usr/lib/tcl8.6","TMPDIR":"/tmp"},"gid":0,"uid":0},"builder":"images/vfs/scripts/build-sqlite-test-vfs-image.sh","composition":{"product":[],"repository":[]},"evidence":{"browser":{"test":"sqlite-suite-browser"},"node":{"test":"sqlite-suite-node"}},"id":"test-sqlite","mounts":[{"path":"/","readonly":false,"source":"built-image"},{"ephemeral":true,"gid":0,"mode":"1777","path":"/tmp","source":"scratch","uid":0}],"output":"sqlite-test.vfs.zst","schema":1,"software":{"archive":[{"id":"sqlite-full-source","materialization":"embedded","role":"runtime","sha256":"4404d93cbce818b1b98ca7259d0ba9b45db76f2fdd9373e56f2d29b519f4d43b","url":"https://www.sqlite.org/2025/sqlite-src-3490100.zip"}],"homebrew":[],"package":[{"materialization":"embedded","name":"sqlite","outputs":["sqlite3","testfixture"],"role":"runtime","source_roles":[]},{"name":"sqlite","outputs":["development-files"],"role":"build","source_roles":[]},{"materialization":"embedded","name":"tcl","outputs":[],"role":"runtime","source_roles":["runtime-library"]},{"name":"tcl","outputs":["development-files"],"role":"build","source_roles":[]},{"name":"zlib","outputs":["zlib"],"role":"build","source_roles":[]},{"materialization":"embedded","name":"dash","outputs":["dash"],"role":"runtime","source_roles":[]},{"materialization":"embedded","name":"coreutils","outputs":["coreutils"],"role":"runtime","source_roles":[]}],"toolchain":[]}},"path":"images/vfs/products/test-sqlite.toml","sha256":"87a2a9c75b1d504f8613376ea5da2e4ac4713c86fcb72f34bfbaf1e31741b1e4"}],"schema":1} diff --git a/scripts/finalize-homebrew-main-shell-release.py b/scripts/finalize-homebrew-main-shell-release.py index c013f797de..bd2aecb821 100755 --- a/scripts/finalize-homebrew-main-shell-release.py +++ b/scripts/finalize-homebrew-main-shell-release.py @@ -33,6 +33,7 @@ ARTIFACT_PATH = "homebrew/main-shell-lazy-artifact-lock.json" DOC_PATH = "docs/homebrew-publishing.md" BREWFILE_PATH = "homebrew/main-shell.Brewfile" +PRODUCT_CATALOG_PATH = "images/vfs/products/generated/catalog.json" BOUND_INPUTS = { "bootstrap_tree_spec_sha256": "homebrew/main-shell-brew-package-tree.json", "brewfile_sha256": BREWFILE_PATH, @@ -376,6 +377,10 @@ def validate_with_canonical_checker( os.fspath(migration), os.fspath(metadata_path), os.fspath(support), + os.fspath(source_root / PRODUCT_CATALOG_PATH), + os.fspath( + source_root / "homebrew/main-shell-materialization-policy.json" + ), ], check=True, stdout=subprocess.PIPE, diff --git a/scripts/test-finalize-homebrew-main-shell-release.py b/scripts/test-finalize-homebrew-main-shell-release.py index 64396bb49a..641c547fd1 100755 --- a/scripts/test-finalize-homebrew-main-shell-release.py +++ b/scripts/test-finalize-homebrew-main-shell-release.py @@ -50,6 +50,7 @@ "homebrew/main-shell.Brewfile", "homebrew/main-shell-default.json", "homebrew/main-shell-demo.json", + "images/vfs/products/generated/catalog.json", "scripts/homebrew-brewfile-selection.rb", "docs/homebrew-publishing.md", ] @@ -85,6 +86,12 @@ def digest(path: pathlib.Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() +def canonical_catalog_json(value: object) -> bytes: + return ( + json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n" + ).encode() + + def run(*arguments: str, success: bool = True) -> subprocess.CompletedProcess[str]: result = subprocess.run( [str(FINALIZER), *arguments], @@ -196,6 +203,27 @@ def copy_source(root: pathlib.Path) -> pathlib.Path: }, ) + # The ABI-42 fixture must carry the product catalog that describes its + # own legacy root set. The current checked catalog intentionally names + # the review-pending ABI-43 login product instead. + catalog_path = source / "images/vfs/products/generated/catalog.json" + catalog = json.loads(catalog_path.read_text()) + shell = next( + entry + for entry in catalog["products"] + if entry["manifest"]["id"] == "browser-main-shell" + ) + homebrew = shell["manifest"]["software"]["homebrew"] + embedded = next(group for group in homebrew if group["materialization"] == "embedded") + lazy = next(group for group in homebrew if group["materialization"] == "lazy") + embedded["formulae"] = ["bash"] + if "ruby" not in lazy["formulae"]: + lazy["formulae"].append("ruby") + shell["sha256"] = hashlib.sha256( + canonical_catalog_json(shell["manifest"]) + ).hexdigest() + catalog_path.write_bytes(canonical_catalog_json(catalog)) + support_path = source / "homebrew/main-shell-homebrew-runtime-support.json" support = json.loads(support_path.read_text()) support["catalog"]["tap_commit"] = old_commit @@ -581,6 +609,8 @@ def run_checker( [ str(tap / "Kandelo/metadata.json"), str(source / "homebrew/main-shell-homebrew-runtime-support.json"), + str(source / "images/vfs/products/generated/catalog.json"), + str(source / "homebrew/main-shell-materialization-policy.json"), ] ) result = subprocess.run( diff --git a/scripts/vfs-product-catalog.test.mjs b/scripts/vfs-product-catalog.test.mjs index 01af700d62..1789f9e27b 100644 --- a/scripts/vfs-product-catalog.test.mjs +++ b/scripts/vfs-product-catalog.test.mjs @@ -78,7 +78,7 @@ test("loads the checked catalog and exposes exact Homebrew roots", () => { assert.equal(catalog.productById("browser-main-shell").output, "shell.vfs.zst"); assert.deepEqual( catalog.homebrewRoots("browser-main-shell").filter(({ formula }) => - ["bash", "ruby"].includes(formula) + ["bash", "login", "sudo-lite", "sudo", "ruby"].includes(formula) ), [ { @@ -86,10 +86,25 @@ test("loads the checked catalog and exposes exact Homebrew roots", () => { formula: "bash", materialization: "embedded", }, + { + tap: "kandelo-dev/homebrew-tap-core", + formula: "login", + materialization: "embedded", + }, + { + tap: "kandelo-dev/homebrew-tap-core", + formula: "sudo-lite", + materialization: "embedded", + }, + { + tap: "kandelo-dev/homebrew-tap-core", + formula: "sudo", + materialization: "embedded", + }, { tap: "kandelo-dev/homebrew-tap-core", formula: "ruby", - materialization: "lazy", + materialization: "embedded", }, ], ); From 5669d27fa171ad1bccf50031914dc6d997666276 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Wed, 12 Aug 2026 13:42:41 -0400 Subject: [PATCH 78/82] Homebrew: Derive flat-shell checks from the active ABI --- host/src/homebrew-bottle-selection.ts | 35 ++++++++------ host/test/homebrew-bottle-selection.test.ts | 12 +++++ packages/registry/shell/test-build-shell.sh | 28 +++++++++-- scripts/ci-homebrew-browser-mirror-state.sh | 3 +- scripts/inspect-canonical-flat-shell.test.ts | 6 +-- scripts/inspect-canonical-flat-shell.ts | 2 +- scripts/test-homebrew-main-shell-closure.sh | 50 ++++++++++++++++---- 7 files changed, 103 insertions(+), 33 deletions(-) diff --git a/host/src/homebrew-bottle-selection.ts b/host/src/homebrew-bottle-selection.ts index 25895ecd58..e202a57bd6 100644 --- a/host/src/homebrew-bottle-selection.ts +++ b/host/src/homebrew-bottle-selection.ts @@ -95,15 +95,8 @@ export function projectHomebrewBottleSelection( const hasExactAbiToken = new RegExp( `(?:^|[._-])${requiredAbiToken}(?:[._-]|$)`, ).test(requestedVfsFilename); - if ( - !OUTPUT_FILENAME_RE.test(requestedVfsFilename) || - !requestedVfsFilename.includes("experimental") || - !hasExactAbiToken - ) { - fail( - "Homebrew bottle selection.requestedVfsFilename must be a safe .vfs.zst " + - `basename containing experimental and ${requiredAbiToken}`, - ); + if (!OUTPUT_FILENAME_RE.test(requestedVfsFilename)) { + fail("Homebrew bottle selection.requestedVfsFilename must be a safe .vfs.zst basename"); } if ( root.resourcePolicy !== "kandelo-homebrew-vfs-generous-v1" && @@ -115,17 +108,29 @@ export function projectHomebrewBottleSelection( ); } const resourcePolicy = root.resourcePolicy; - const experimentalProduct = + const isExperimentalProduct = name.startsWith("experimental-") && arch === "wasm32" && - kandeloAbi === 42 && - requestedVfsFilename.includes("experimental") && - requestedVfsFilename.includes("abi42") && resourcePolicy === "kandelo-homebrew-vfs-generous-v1"; + if ( + isExperimentalProduct && + (!name.includes(requiredAbiToken) || + !requestedVfsFilename.includes("experimental") || + !hasExactAbiToken) + ) { + fail( + "Homebrew bottle selection.requestedVfsFilename must contain experimental " + + `and ${requiredAbiToken}`, + ); + } + const experimentalProduct = + isExperimentalProduct && + name.includes(requiredAbiToken) && + requestedVfsFilename.includes("experimental") && + hasExactAbiToken; const mainShellProduct = - name === "main-shell-abi42-wasm32" && + name === `main-shell-abi${kandeloAbi}-wasm32` && arch === "wasm32" && - kandeloAbi === 42 && requestedVfsFilename === "shell.vfs.zst" && resourcePolicy === "kandelo-homebrew-vfs-main-shell-v1"; if (!experimentalProduct && !mainShellProduct) { diff --git a/host/test/homebrew-bottle-selection.test.ts b/host/test/homebrew-bottle-selection.test.ts index 9aee743186..88245653d2 100644 --- a/host/test/homebrew-bottle-selection.test.ts +++ b/host/test/homebrew-bottle-selection.test.ts @@ -190,6 +190,18 @@ describe("flat Homebrew bottle selection", () => { } }); + it("admits the ABI-versioned main-shell selection form", () => { + const fixture = selectionFixture(); + fixture.name = "main-shell-abi43-wasm32"; + fixture.kandeloAbi = 43; + fixture.requestedVfsFilename = "shell.vfs.zst"; + fixture.resourcePolicy = "kandelo-homebrew-vfs-main-shell-v1"; + for (const bottle of fixture.bottles) bottle.kandeloAbi = 43; + + expect(() => projectHomebrewBottleSelection(fixture, { expectedAbi: 43 })) + .not.toThrow(); + }); + it("preserves bottle order in stable canonical encoding and rejects noncanonical bytes", () => { const fixture = selectionFixture(); const reversed = { ...fixture, bottles: [...fixture.bottles].reverse() }; diff --git a/packages/registry/shell/test-build-shell.sh b/packages/registry/shell/test-build-shell.sh index f70d8ac462..c59f6c7ed4 100755 --- a/packages/registry/shell/test-build-shell.sh +++ b/packages/registry/shell/test-build-shell.sh @@ -35,8 +35,9 @@ grep -Eq '^revision[[:space:]]*=[[:space:]]*23$' "$BUILD_TOML" || fail "canonical shell revision must be 23" grep -Eq '^commit[[:space:]]*=[[:space:]]*"UNPUBLISHED"$' "$BUILD_TOML" || fail "canonical shell must await publication under its authored commit" -grep -Eq '^publication_state[[:space:]]*=[[:space:]]*"ready"$' \ - "$BUILD_TOML" || fail "canonical flat shell must be publication-ready" +grep -Eq '^publication_state[[:space:]]*=[[:space:]]*"pending"$' \ + "$BUILD_TOML" || + fail "canonical flat shell must remain pending until ABI-specific CI publication" for input in \ homebrew/main-shell-flat-selection.json \ homebrew/main-shell-default.json \ @@ -46,11 +47,13 @@ for input in \ packages/registry/shell/prepare-build-tools.sh \ crates/shared/src/lib.rs \ host/src/constants.ts \ + host/src/file-offset.ts \ host/src/generated/abi.ts \ host/src/homebrew-bottle-descriptor.ts \ host/src/homebrew-bottle-relocation.ts \ host/src/homebrew-bottle-selection.ts \ host/src/homebrew-bottle-types.ts \ + host/src/homebrew-deferred-tree-adapter.ts \ host/src/homebrew-guest-layout.ts \ host/src/homebrew-lazy-layer-descriptor.ts \ host/src/homebrew-lazy-layer.ts \ @@ -65,7 +68,20 @@ for input in \ host/src/homebrew-vfs-resource-policy.ts \ host/src/pathconf.ts \ host/src/statfs.ts \ - host/src/vfs \ + host/src/types.ts \ + host/src/vfs/canonical-text.ts \ + host/src/vfs/closed-lazy-assets.ts \ + host/src/vfs/deferred-tree-limits.ts \ + host/src/vfs/hardlink-graph.ts \ + host/src/vfs/image-helpers.ts \ + host/src/vfs/materialization-plan.ts \ + host/src/vfs/memory-fs.ts \ + host/src/vfs/package-deferred-tree-contract.ts \ + host/src/vfs/package-deferred-tree.ts \ + host/src/vfs/sharedfs-vendor.ts \ + host/src/vfs/tar.ts \ + host/src/vfs/types.ts \ + host/src/vfs/zip.ts \ web-libs/kandelo-session/src/shell-config.ts \ web-libs/kandelo-session/src/demo-config.ts do @@ -342,8 +358,10 @@ grep -Fq -- '--sab-size 536870912' "$FAKE_LOG" || fail "platform base omitted the 512 MiB initial capacity" grep -Fq -- '--max-size 536870912' "$FAKE_LOG" || fail "platform base omitted the 512 MiB maximum capacity" -grep -Fq -- '--kernel-abi 42' "$FAKE_LOG" || - fail "platform base omitted ABI 42" +abi="$(grep -oE 'ABI_VERSION: u32 = [0-9]+' \ + "$SCRIPT_DIR/../../../crates/shared/src/lib.rs" | awk '{print $4}')" +grep -Fq -- "--kernel-abi $abi" "$FAKE_LOG" || + fail "platform base omitted the current ABI" for out_dir in "$parallel_one" "$parallel_two"; do source_root="$out_dir/.homebrew-shell-build/source" diff --git a/scripts/ci-homebrew-browser-mirror-state.sh b/scripts/ci-homebrew-browser-mirror-state.sh index 93a34c3382..c278dcca83 100755 --- a/scripts/ci-homebrew-browser-mirror-state.sh +++ b/scripts/ci-homebrew-browser-mirror-state.sh @@ -45,6 +45,7 @@ current_source_commit() { canonical_flat_shell_report_json() { local image="$1" + local selection_path="${KANDELO_CANONICAL_FLAT_SELECTION:-$REPO_ROOT/homebrew/main-shell-flat-selection.json}" local report_root local report local status @@ -52,7 +53,7 @@ canonical_flat_shell_report_json() { report="$report_root/report.json" npx tsx "$SCRIPT_DIR/inspect-canonical-flat-shell.ts" \ --image "$image" \ - --selection "$REPO_ROOT/homebrew/main-shell-flat-selection.json" \ + --selection "$selection_path" \ --shell-config "$REPO_ROOT/homebrew/main-shell-default.json" \ --demo-config "$REPO_ROOT/homebrew/main-shell-flat-demo.json" \ --out "$report" || { diff --git a/scripts/inspect-canonical-flat-shell.test.ts b/scripts/inspect-canonical-flat-shell.test.ts index e60e36d107..9b25f39644 100644 --- a/scripts/inspect-canonical-flat-shell.test.ts +++ b/scripts/inspect-canonical-flat-shell.test.ts @@ -21,13 +21,13 @@ import { type VfsImageMetadata, } from "../host/src/vfs/memory-fs"; import { homebrewTestBootstrapFixture } from "../host/test/fixtures/homebrew-flat-vfs"; +import { ABI_VERSION } from "../host/src/generated/abi.ts"; import { inspectCanonicalFlatShell, inspectCanonicalFlatShellFiles, } from "./inspect-canonical-flat-shell"; const MiB = 1024 * 1024; -const ABI_VERSION = 42; const IMAGE_MAX_BYTES = 512 * MiB; const SHELL_CONFIG_TEXT = `${JSON.stringify( { @@ -90,7 +90,7 @@ test("accepts one exact self-contained canonical flat shell", async () => { selection: { sha256: sha256(fixture.selectionBytes), bytes: fixture.selectionBytes.byteLength, - name: "main-shell-abi42-wasm32", + name: `main-shell-abi${ABI_VERSION}-wasm32`, arch: "wasm32", kandelo_abi: ABI_VERSION, requested_vfs_filename: "shell.vfs.zst", @@ -396,7 +396,7 @@ function canonicalSelectionBytes(environmentText: string): Uint8Array { }); return encodeHomebrewBottleSelection({ schema: 1, - name: "main-shell-abi42-wasm32", + name: `main-shell-abi${ABI_VERSION}-wasm32`, arch: "wasm32", kandeloAbi: ABI_VERSION, bottles: [bootstrap.descriptor], diff --git a/scripts/inspect-canonical-flat-shell.ts b/scripts/inspect-canonical-flat-shell.ts index 77355f4162..13674acb04 100644 --- a/scripts/inspect-canonical-flat-shell.ts +++ b/scripts/inspect-canonical-flat-shell.ts @@ -91,7 +91,7 @@ export async function inspectCanonicalFlatShell(input: { expectedAbi: ABI_VERSION, }); if ( - selection.name !== "main-shell-abi42-wasm32" || + selection.name !== `main-shell-abi${ABI_VERSION}-wasm32` || selection.arch !== "wasm32" || selection.requestedVfsFilename !== "shell.vfs.zst" || selection.resourcePolicy !== "kandelo-homebrew-vfs-main-shell-v1" diff --git a/scripts/test-homebrew-main-shell-closure.sh b/scripts/test-homebrew-main-shell-closure.sh index 4f18c8b9e8..9dd3d25dd0 100755 --- a/scripts/test-homebrew-main-shell-closure.sh +++ b/scripts/test-homebrew-main-shell-closure.sh @@ -1148,18 +1148,49 @@ flat_state_probe="$TMP_ROOT/canonical-flat-shell-state" mkdir -p "$flat_state_probe" flat_state_image="$flat_state_probe/shell.vfs.zst" lazy_state_image="$flat_state_probe/lazy-shell.vfs.zst" +flat_state_selection="$flat_state_probe/selection.json" +node --input-type=module - \ + "$REPO_ROOT/homebrew/main-shell-flat-selection.json" \ + "$flat_state_selection" "$abi" <<'NODE' +import { readFileSync, writeFileSync } from "node:fs"; + +const normalize = (value) => { + if (Array.isArray(value)) return value.map(normalize); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.keys(value).sort().map((key) => [key, normalize(value[key])]), + ); + } + return value; +}; +const selection = JSON.parse(readFileSync(process.argv[2], "utf8")); +const abi = Number(process.argv[4]); +if (!Number.isInteger(abi) || abi < 0) { + throw new Error("flat-state selection fixture requires a nonnegative integer ABI"); +} +selection.kandeloAbi = abi; +selection.name = `main-shell-abi${abi}-wasm32`; +for (const bottle of selection.bottles) bottle.kandeloAbi = abi; +writeFileSync(process.argv[3], `${JSON.stringify(normalize(selection))}\n`); +NODE KANDELO_FLAT_STATE_IMAGE="$flat_state_image" \ KANDELO_LAZY_STATE_IMAGE="$lazy_state_image" \ +KANDELO_FLAT_STATE_ABI="$abi" \ +KANDELO_FLAT_STATE_SELECTION="$flat_state_selection" \ npx tsx -e ' import { createHash } from "node:crypto"; import { readFileSync, writeFileSync } from "node:fs"; import { ensureDirRecursive, writeVfsBinary } from "./host/src/vfs/image-helpers.ts"; import { MemoryFileSystem } from "./host/src/vfs/memory-fs.ts"; const maxByteLength = 512 * 1024 * 1024; - const selection = new Uint8Array(readFileSync("homebrew/main-shell-flat-selection.json")); + const selection = new Uint8Array(readFileSync(process.env.KANDELO_FLAT_STATE_SELECTION)); const shellConfig = new Uint8Array(readFileSync("homebrew/main-shell-default.json")); const demoConfig = new Uint8Array(readFileSync("homebrew/main-shell-flat-demo.json")); const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); + const kernelAbi = Number(process.env.KANDELO_FLAT_STATE_ABI); + if (!Number.isInteger(kernelAbi) || kernelAbi < 0) { + throw new Error("flat-state fixture requires a nonnegative integer ABI"); + } const build = async (lazy) => { const fs = MemoryFileSystem.create( new SharedArrayBuffer(4 * 1024 * 1024, { maxByteLength }), @@ -1181,10 +1212,10 @@ KANDELO_LAZY_STATE_IMAGE="$lazy_state_image" \ if (lazy) fs.registerLazyFile("/lazy", "https://invalid.example/lazy", 1, 0o644); return fs.saveImage({metadata: { version: 1, - kernelAbi: 42, + kernelAbi, createdBy: "images/vfs/scripts/build-homebrew-flat-vfs-image.ts", capacity: {maxByteLength}, - baseImage: {sha256: "b".repeat(64), bytes: 1234, kernelAbi: 42}, + baseImage: {sha256: "b".repeat(64), bytes: 1234, kernelAbi}, homebrewFlat: { selectionSha256: sha256(selection), requestedVfsFilename: "shell.vfs.zst", @@ -1243,16 +1274,17 @@ revision = 23 [packages.binary.wasm32] status = "success" -archive_url = "shell-0.1.0-rev23-abi42-wasm32-97dd1a61.tar.zst" +archive_url = "shell-0.1.0-rev23-abi${abi}-wasm32-97dd1a61.tar.zst" archive_sha256 = "$(printf 'a%.0s' {1..64})" cache_key_sha = "$flat_cache_key" built_at = "2026-08-10T00:00:00Z" built_by = "https://example.invalid/run/1" EOF -bash "$CI_BROWSER_MIRROR_STATE" create \ +KANDELO_CANONICAL_FLAT_SELECTION="$flat_state_selection" \ + bash "$CI_BROWSER_MIRROR_STATE" create \ "$flat_state_expected" "$flat_state_blockers" \ "$flat_state_index" \ - https://github.com/Automattic/kandelo/releases/download/binaries-abi-v42/index.toml \ + https://github.com/Automattic/kandelo/releases/download/binaries-abi-v${abi}/index.toml \ "$flat_state_image" "$flat_state" jq -e ' .schema == 3 and @@ -1268,13 +1300,15 @@ jq -e ' .inspection.image.bytes == .image.bytes ' "$flat_state" >/dev/null || fail "resolved shell state did not preserve its flat inspection authority" -bash "$CI_BROWSER_MIRROR_STATE" validate producer \ +KANDELO_CANONICAL_FLAT_SELECTION="$flat_state_selection" \ + bash "$CI_BROWSER_MIRROR_STATE" validate producer \ "$flat_state" "$flat_state_blockers" "$flat_state_image" expect_failure "self-contained" \ + env KANDELO_CANONICAL_FLAT_SELECTION="$flat_state_selection" \ bash "$CI_BROWSER_MIRROR_STATE" create \ "$flat_state_expected" "$flat_state_blockers" \ "$flat_state_index" \ - https://github.com/Automattic/kandelo/releases/download/binaries-abi-v42/index.toml \ + https://github.com/Automattic/kandelo/releases/download/binaries-abi-v${abi}/index.toml \ "$lazy_state_image" "$flat_state_probe/lazy-state.json" override_probe="$TMP_ROOT/local-shell-override" From 1b5293bd2faa61d6dd9fee94bf74f7adc696f3d4 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Thu, 13 Aug 2026 12:03:10 -0400 Subject: [PATCH 79/82] Packages: Refresh ABI 43 program identities Regenerate the authoritative program index after the ABI 43 bump so exact\nruntime builds resolve current package cache keys. Refresh the protected\nrequest-policy digest that binds that index. --- abi/staging/request-policy.generated.json | 2 +- packages/registry/program-packages.json | 756 +++++++++++----------- 2 files changed, 379 insertions(+), 379 deletions(-) diff --git a/abi/staging/request-policy.generated.json b/abi/staging/request-policy.generated.json index 3f7ed2aeac..6158dbca84 100644 --- a/abi/staging/request-policy.generated.json +++ b/abi/staging/request-policy.generated.json @@ -1 +1 @@ -{"addressed_taps":["kandelo-dev/homebrew-tap-core"],"automatic_same_repository":true,"fork_authorization":"disabled","implementation":[{"path":".github/scripts/publish-abi-staging-request.sh","sha256":"efb4614eb4eb3c7eb4653b645365e030eda63a602bcf2a92e1f56c90d5b456cf"},{"path":".github/scripts/update-abi-staging-check.sh","sha256":"78d49f35fdf27d426dcae6d6a19aa3eeca4d8a879bda0df319b70a4d4cf31ff8"},{"path":".github/workflows/abi-staging-merge-gate.yml","sha256":"a238f389b670ac6a54438c5894ca73f0f897fb393dbe24e8ed31f93c018e568f"},{"path":".github/workflows/abi-staging-pages-canary.yml","sha256":"3f9ecd19eb0224b2c11402ed3828b6ac2de1f6e291c84cfdf2fe59bae3a18676"},{"path":".github/workflows/abi-staging-pr-check.yml","sha256":"85a9b363caafb3e20b909c4145e45ddf6e0bc871547a986213e56c2d859ccf3c"},{"path":".github/workflows/abi-staging-request-feed.yml","sha256":"8cb53b1e466c7df016d369e5f0d93e1e53e17277e8f6771d329baf36367682fd"},{"path":"Cargo.lock","sha256":"00884bdc10de0cc0e0be09ca3042dc8cd28852e52ecb52a098392977ab91888f"},{"path":"Cargo.toml","sha256":"e033e7c8489f41a9d12517e01be99d89054bd5ee831d74148702a50504c9ff02"},{"path":"abi/staging/evidence-definitions.generated.json","sha256":"d70164eae53fe4cbe15c32c014bafb6f667bf81fecb6ed41f5e9598b8470a715"},{"path":"abi/staging/evidence-definitions.toml","sha256":"1ff246db2cf82357f348af2e0ae8bcc30fa149705b523562f507106ff60eb416"},{"path":"apps/browser-demos/abi-staging-browser-harness.config.ts","sha256":"314d59a2b912625b3878d4f46e04c9f256615ed5cdd7bd85ae7aef8351e99202"},{"path":"apps/browser-demos/abi-staging-browser-host.config.ts","sha256":"af348730b45bf7ecc60c1f03e518979000f709de580fa05066dac6372aa4bc2c"},{"path":"apps/browser-demos/abi-staging-browser-host.ts","sha256":"beca82b52c32b0ef56bcf2cdc1f04138213993cb43e117c09661feae2ccc7004"},{"path":"apps/browser-demos/abi-staging-browser-no-default-artifacts.ts","sha256":"bc7d6655898d2fd7b712a20565723996a5d4cf9c23eb46989522aaa7d874ea98"},{"path":"apps/browser-demos/lib/homebrew-closed-acceptance.ts","sha256":"5e88d8ec55b7f495647c83db8c2db6e51254a6c437edeac45728c8a535e02b2d"},{"path":"apps/browser-demos/lib/mysql-client.ts","sha256":"bcb3b2fc913366bd9a828125cad80e41cdd1fac9ce37f3c2b45910463290b05c"},{"path":"apps/browser-demos/lib/redis-client.ts","sha256":"cde9e40502bdf6cb85f3b421273a023f290919c57f162ab71b7805c78d08a6ae"},{"path":"apps/browser-demos/package-lock.json","sha256":"b8301aef759220f7a14a7bd373459d37b4876aabf6c50bcb0c99ca725c0317ba"},{"path":"apps/browser-demos/package.json","sha256":"6c66d5f822e47f0705322b31d9db53ea163ad9caf7a5856e172fa9f4bdd8f5a2"},{"path":"apps/browser-demos/pages/abi-staging-product-evidence/index.html","sha256":"889d1e8afab89d05ea02bc1c18459ccecfe585dab0b52d50aeff3dec979e08e0"},{"path":"apps/browser-demos/pages/abi-staging-product-evidence/main.ts","sha256":"19ac44a9631a42087111ce85dc65b60dd8fe5a14ec67d72492a382760a93f898"},{"path":"apps/browser-demos/pages/abi-staging-product-evidence/pty-command.ts","sha256":"f33bcec8e48bad54ffa9317ef6e16442e6328b3a093b3f208cb751054f582eaf"},{"path":"apps/browser-demos/pages/kandelo/kernel-host/candidate-evidence-vfs.ts","sha256":"d707a48c6355c9d451251be599f37f7c031fb82326b0b01bfa3af062d2b57c49"},{"path":"apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts","sha256":"b5227e00db788c0bf4bb02cd03c44aa94d419a78f6144ab170cdae5814542276"},{"path":"apps/browser-demos/pages/kandelo/kernel-host/optional-demo-vfs.ts","sha256":"2ea2dfa31054b7b041ab342a1c1d1626088ac9f207df8c12d2040030457677bb"},{"path":"apps/browser-demos/pages/kandelo/kernel-host/pages-vfs-product-loader.ts","sha256":"60ff6a4cb6104f474e387b90638c2b71ec48587397fa19a9ef34da2dfb6192d9"},{"path":"apps/browser-demos/pages/network/network-demo-worker.ts","sha256":"152f23063c076ddbbc61470428df6e003e5f96e7413f8348472b4d1660a0c56d"},{"path":"apps/browser-demos/playwright-closed-acceptance.ts","sha256":"430fbc4eea4a92ccd8a36c0aeb1c5b23aadf22e95d7151f09dadc4ce4f49b43b"},{"path":"apps/browser-demos/playwright-server-policy.ts","sha256":"eda5aa8651bc6854d1634be1c5ffdbf3a91a97c87b91d9b065f997e12afa420f"},{"path":"apps/browser-demos/playwright.config.ts","sha256":"e6d9a7cc6dab59be4d12aca97da856dffe5120facb1c5812225e3526cac37b42"},{"path":"apps/browser-demos/test/abi-staging-product-evidence.spec.ts","sha256":"e90b265133cfcde059c91d066261e243b761e20750ea8377e7679e4d9d877706"},{"path":"apps/browser-demos/test/support/terminal-command.ts","sha256":"919e0898cd9f317c95eb29d6a0fb70d398f18fdca6192f705b8eee5093eecac3"},{"path":"apps/browser-demos/vite.config.ts","sha256":"6d0565eac538e9d42fe4dffea659ec83b6f994a6e446fd7f7caba19233cecb0d"},{"path":"flake.lock","sha256":"2df2e3397a8c75ecf4fb6c897959650c76f27f65721bf3ea5a08304b480ad6d8"},{"path":"flake.nix","sha256":"b763dd32449297363544906fae5455b45508743b4a73b3e921c977330e3c1486"},{"path":"host/package-lock.json","sha256":"9257dd7a5a545ee044b3db00e92dfb86662db23f26c5a3ca94ca227a8f26419a"},{"path":"host/package.json","sha256":"ba2ce1c59f063087a4ec942e152c8d15b0eff523960808e6ab2a88679e13849c"},{"path":"host/src/generated/abi.ts","sha256":"7545942c5f72cee974b44449486d50a8577e084866a4a1b6a54036d682a931e5"},{"path":"host/src/homebrew-bottle-relocation.ts","sha256":"3e52fa6cb250f916486ec9e416903ec431a2656edd7ebc0761dd179920d76454"},{"path":"host/src/homebrew-guest-layout.ts","sha256":"84866dbb5730ff6c6df1a6cfe23384f50ac9e438b18e40b20678b2b63b9eccef"},{"path":"host/src/pathconf.ts","sha256":"449c15f849ee262957198372728cf5d7fdb84d68ff1d539a0e0e24878320f872"},{"path":"host/src/statfs.ts","sha256":"3e11cd17f6460b7ee5965738f5a04295b3aec5da14c9c0251c6a2dc1283194a7"},{"path":"host/src/vfs/closed-lazy-assets.ts","sha256":"278e8cd36cd1c47ad876fdf310af4014d91e174f3717acda55c125c0b7d269fd"},{"path":"host/src/vfs/deferred-tree-limits.ts","sha256":"bc12ee922c48ec8356b90e5efc939b4d497774a42a811e18c8917be8758e19fb"},{"path":"host/src/vfs/hardlink-graph.ts","sha256":"9d19366a7e739d55ef81cea074ab9633f255e65f9332e5aa82da70b1d1ccd1cf"},{"path":"host/src/vfs/load-image.ts","sha256":"df62f1b2faf98fb2e9900a6f6b428ae16e7f43494a6ade01f709006ef764744b"},{"path":"host/src/vfs/memory-fs.ts","sha256":"ea3eee30f39c7913ee2ccb4cb763e01eb4931bdd36c6cae253b712a3b46ff38c"},{"path":"host/src/vfs/product-mount-contract.ts","sha256":"2aeb61b4eff2689a7c054a8919fe622f46e621006d64050390a04af3ef05f4f0"},{"path":"host/src/vfs/sharedfs-vendor.ts","sha256":"946d5a28288d662680c5e51164808b6f5ea224e6815b91adc95c74ea52be4f00"},{"path":"host/tsup.config.ts","sha256":"78564eae7b555e70e9895df17d7593a579e7311aee37072fc32438525723673e"},{"path":"images/vfs/scripts/repository-path-bundle.ts","sha256":"64bad99dca7b32d57c104a9da1dd4977f0e8de56dc5950fd94bc3cda19ac987a"},{"path":"package-lock.json","sha256":"352cb8d4baa12319b0924fabe9f809bf70d9e10ced90067ee3dff997170c1ac9"},{"path":"package.json","sha256":"ed332760587828c7a73e7a15703106d93a671b7bad841983f4f767dff3c10b80"},{"path":"packages/registry/program-packages.json","sha256":"09d24324d394405478d8204b5643cd867c01463d1787d07eb8d065e930951e4f"},{"path":"scripts/abi-staging-build-toolchain.sh","sha256":"ed73bcd74bb7e2c409c2ae6887b4717c97b82f35e4f3347f523a7a240f47e815"},{"path":"scripts/abi-staging-collect-product-inputs.ts","sha256":"4ecd65b0789e21cb0ba3c5d133c9f3589da280258d382a3c208d859eefa03221"},{"path":"scripts/abi-staging-pages-producer.ts","sha256":"067fbecd469f83e266ab71db076d8a6be904a43986d74320caa80bd07dfd777e"},{"path":"scripts/abi-staging-pages-readiness.ts","sha256":"a254cf3f7fedef367ecc6fb91fa2175fc255f87a6458c02d0bf4caad23019f3b"},{"path":"scripts/abi-staging-pages-site-builder.ts","sha256":"97ce037272c1f8200ae48a73d027371f90c13dbd16b7194846b261eb51326be7"},{"path":"scripts/abi-staging-prepare-runtime.sh","sha256":"38ff5658d1147c6f82c542559078204e89decc2475f6ddb626673cafb1961c26"},{"path":"scripts/abi-staging-product-browser-evidence.ts","sha256":"458c31795759e2745f8eb071603f51c947f123cdea51cea4d7efebbfb3aa2a98"},{"path":"scripts/abi-staging-product-input-sources.ts","sha256":"eca75b92c09a5a28c745ecc6ae6e2407b9637c8710d022b76f7aab9e05e5be14"},{"path":"scripts/abi-staging-product-node-evidence.ts","sha256":"786fe35d64cbbf7b4898b9ac58f5c4483d8cc6f2b90f6644beb782126034ff15"},{"path":"scripts/abi-staging-protected-browser-operation.ts","sha256":"858b417d9562cf8618a416893d47e3bc616cedb7b1cfed673c467f1d0737b935"},{"path":"scripts/check-dev-shell-tools.sh","sha256":"9a6b009baa14f77b3c21e48735116d8475278cd65f78078b2d42787a71c21dad"},{"path":"scripts/check-pages-vfs-product-registry.mjs","sha256":"6cbb24557f1e1a6961bd0fa7f11cbf2bae3035f5458971cf75ca31a891af2b0b"},{"path":"scripts/dev-shell.sh","sha256":"3931770c7b9f50ccee5a89e2957ed7a9856d4713e7fb6cb2631b29d0c6c4cff7"},{"path":"scripts/fetch-exact-musl-gitlink.sh","sha256":"8a14b1c97e2331f104d6f061f9a74874f1d0e94a3d58b413d69f73412701b8cc"},{"path":"scripts/run-vfs-product-builder.ts","sha256":"95654c6298474d02a64b7d93bc19ad7a54776bb636515efffa4ae518fc7e8a82"},{"path":"scripts/vfs-product-catalog.mjs","sha256":"3bb39bddb2add9c05b15f80964a22acdbb33648e43118d4d956b0ae062cd7eb0"},{"path":"tools/xtask/Cargo.toml","sha256":"b30b900521eaa9652937371cb7a9ebe38302a5e9d58aabb6743996acece87155"},{"path":"tools/xtask/src/abi_staging/builder_contract.rs","sha256":"7b52f1e143039dc32708e1e0bbbdb7075530ef682b73607e5d77818efa80de8f"},{"path":"tools/xtask/src/abi_staging/canonical_json.rs","sha256":"50e42796a53d517268e9afeea7f594e1db47b65e2e903e9d17e9db5dd9e1a6cf"},{"path":"tools/xtask/src/abi_staging/check_projection.rs","sha256":"9a2656947301affbaa792f53eaae08bb32800f0741c51be48d4a1424a9f30efe"},{"path":"tools/xtask/src/abi_staging/consumer_registry.rs","sha256":"1985490c56ffd89feb8d41f5c2bef0f41432c37e26a4282b27da39d3476574bb"},{"path":"tools/xtask/src/abi_staging/evidence_policy.rs","sha256":"45676d5a430f5d4f19b3519862b29bb9204eb8fb1f7ad5c987887dc57874882d"},{"path":"tools/xtask/src/abi_staging/guard_registry.rs","sha256":"eed45cfe19c48716da9d205f1aa6a3bbbc0e96e0e96e1e50bcaf6329cfc663fe"},{"path":"tools/xtask/src/abi_staging/mod.rs","sha256":"65eec6b0d987138f8159edd6de627643a39fc1fa01ced5836bf5e878b4367d75"},{"path":"tools/xtask/src/abi_staging/product_evidence.rs","sha256":"c5ce7790e478666d75381834ea3eddec26428bdd7c6707fdd5128259fbb50e8c"},{"path":"tools/xtask/src/abi_staging/product_manifest.rs","sha256":"4db3dd8e01bbbbd52a3b8c862f27b2e1feb685caa038cb7b8913d13cd9ac72e2"},{"path":"tools/xtask/src/abi_staging/records.rs","sha256":"6d21b7325d70874804da528a4714e5a488ed23fdeac0fd77ef61c68402d84203"},{"path":"tools/xtask/src/abi_staging/request_derivation.rs","sha256":"02190e3e19aed2d5b00cab14a2cd9ef761e9ba7d276405058db973a8aa36c8c7"},{"path":"tools/xtask/src/abi_staging/request_feed.rs","sha256":"38df80125ed5ce0559a40d85e8d0421353c5c82106306f9be7e4ad778142dfda"},{"path":"tools/xtask/src/abi_staging/request_policy.rs","sha256":"c96ea613def0ce69206fa5418bd43e7f7a8cb5648dfbff1c9e57621127d1bdf3"},{"path":"tools/xtask/src/abi_staging/selection.rs","sha256":"1c8c517a31d7fa2d7abc97b3d57db75df38c20fc98170d430e94dcafd82da2eb"}],"implementation_paths":[".github/scripts/publish-abi-staging-request.sh",".github/scripts/update-abi-staging-check.sh",".github/workflows/abi-staging-merge-gate.yml",".github/workflows/abi-staging-pages-canary.yml",".github/workflows/abi-staging-pr-check.yml",".github/workflows/abi-staging-request-feed.yml","Cargo.lock","Cargo.toml","abi/staging/evidence-definitions.generated.json","abi/staging/evidence-definitions.toml","apps/browser-demos/abi-staging-browser-harness.config.ts","apps/browser-demos/abi-staging-browser-host.config.ts","apps/browser-demos/abi-staging-browser-host.ts","apps/browser-demos/abi-staging-browser-no-default-artifacts.ts","apps/browser-demos/lib/homebrew-closed-acceptance.ts","apps/browser-demos/lib/mysql-client.ts","apps/browser-demos/lib/redis-client.ts","apps/browser-demos/package-lock.json","apps/browser-demos/package.json","apps/browser-demos/pages/abi-staging-product-evidence/index.html","apps/browser-demos/pages/abi-staging-product-evidence/main.ts","apps/browser-demos/pages/abi-staging-product-evidence/pty-command.ts","apps/browser-demos/pages/kandelo/kernel-host/candidate-evidence-vfs.ts","apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts","apps/browser-demos/pages/kandelo/kernel-host/optional-demo-vfs.ts","apps/browser-demos/pages/kandelo/kernel-host/pages-vfs-product-loader.ts","apps/browser-demos/pages/network/network-demo-worker.ts","apps/browser-demos/playwright-closed-acceptance.ts","apps/browser-demos/playwright-server-policy.ts","apps/browser-demos/playwright.config.ts","apps/browser-demos/test/abi-staging-product-evidence.spec.ts","apps/browser-demos/test/support/terminal-command.ts","apps/browser-demos/vite.config.ts","flake.lock","flake.nix","host/package-lock.json","host/package.json","host/src/generated/abi.ts","host/src/homebrew-bottle-relocation.ts","host/src/homebrew-guest-layout.ts","host/src/pathconf.ts","host/src/statfs.ts","host/src/vfs/closed-lazy-assets.ts","host/src/vfs/deferred-tree-limits.ts","host/src/vfs/hardlink-graph.ts","host/src/vfs/load-image.ts","host/src/vfs/memory-fs.ts","host/src/vfs/product-mount-contract.ts","host/src/vfs/sharedfs-vendor.ts","host/tsup.config.ts","images/vfs/scripts/repository-path-bundle.ts","package-lock.json","package.json","packages/registry/program-packages.json","scripts/abi-staging-build-toolchain.sh","scripts/abi-staging-collect-product-inputs.ts","scripts/abi-staging-pages-producer.ts","scripts/abi-staging-pages-readiness.ts","scripts/abi-staging-pages-site-builder.ts","scripts/abi-staging-prepare-runtime.sh","scripts/abi-staging-product-browser-evidence.ts","scripts/abi-staging-product-input-sources.ts","scripts/abi-staging-product-node-evidence.ts","scripts/abi-staging-protected-browser-operation.ts","scripts/check-dev-shell-tools.sh","scripts/check-pages-vfs-product-registry.mjs","scripts/dev-shell.sh","scripts/fetch-exact-musl-gitlink.sh","scripts/run-vfs-product-builder.ts","scripts/vfs-product-catalog.mjs","tools/xtask/Cargo.toml","tools/xtask/src/abi_staging/builder_contract.rs","tools/xtask/src/abi_staging/canonical_json.rs","tools/xtask/src/abi_staging/check_projection.rs","tools/xtask/src/abi_staging/consumer_registry.rs","tools/xtask/src/abi_staging/evidence_policy.rs","tools/xtask/src/abi_staging/guard_registry.rs","tools/xtask/src/abi_staging/mod.rs","tools/xtask/src/abi_staging/product_evidence.rs","tools/xtask/src/abi_staging/product_manifest.rs","tools/xtask/src/abi_staging/records.rs","tools/xtask/src/abi_staging/request_derivation.rs","tools/xtask/src/abi_staging/request_feed.rs","tools/xtask/src/abi_staging/request_policy.rs","tools/xtask/src/abi_staging/selection.rs"],"issuer_repository":"Automattic/kandelo","issuer_workflow":".github/workflows/abi-staging-request-feed.yml","kind":"kandelo-abi-staging-request-policy","max_evidence_bindings":512,"max_products":256,"request_asset_max_bytes":4194304,"request_release_tag_prefix":"abi-staging-pr-","schema":1,"version":16} +{"addressed_taps":["kandelo-dev/homebrew-tap-core"],"automatic_same_repository":true,"fork_authorization":"disabled","implementation":[{"path":".github/scripts/publish-abi-staging-request.sh","sha256":"efb4614eb4eb3c7eb4653b645365e030eda63a602bcf2a92e1f56c90d5b456cf"},{"path":".github/scripts/update-abi-staging-check.sh","sha256":"78d49f35fdf27d426dcae6d6a19aa3eeca4d8a879bda0df319b70a4d4cf31ff8"},{"path":".github/workflows/abi-staging-merge-gate.yml","sha256":"a238f389b670ac6a54438c5894ca73f0f897fb393dbe24e8ed31f93c018e568f"},{"path":".github/workflows/abi-staging-pages-canary.yml","sha256":"3f9ecd19eb0224b2c11402ed3828b6ac2de1f6e291c84cfdf2fe59bae3a18676"},{"path":".github/workflows/abi-staging-pr-check.yml","sha256":"85a9b363caafb3e20b909c4145e45ddf6e0bc871547a986213e56c2d859ccf3c"},{"path":".github/workflows/abi-staging-request-feed.yml","sha256":"8cb53b1e466c7df016d369e5f0d93e1e53e17277e8f6771d329baf36367682fd"},{"path":"Cargo.lock","sha256":"00884bdc10de0cc0e0be09ca3042dc8cd28852e52ecb52a098392977ab91888f"},{"path":"Cargo.toml","sha256":"e033e7c8489f41a9d12517e01be99d89054bd5ee831d74148702a50504c9ff02"},{"path":"abi/staging/evidence-definitions.generated.json","sha256":"d70164eae53fe4cbe15c32c014bafb6f667bf81fecb6ed41f5e9598b8470a715"},{"path":"abi/staging/evidence-definitions.toml","sha256":"1ff246db2cf82357f348af2e0ae8bcc30fa149705b523562f507106ff60eb416"},{"path":"apps/browser-demos/abi-staging-browser-harness.config.ts","sha256":"314d59a2b912625b3878d4f46e04c9f256615ed5cdd7bd85ae7aef8351e99202"},{"path":"apps/browser-demos/abi-staging-browser-host.config.ts","sha256":"af348730b45bf7ecc60c1f03e518979000f709de580fa05066dac6372aa4bc2c"},{"path":"apps/browser-demos/abi-staging-browser-host.ts","sha256":"beca82b52c32b0ef56bcf2cdc1f04138213993cb43e117c09661feae2ccc7004"},{"path":"apps/browser-demos/abi-staging-browser-no-default-artifacts.ts","sha256":"bc7d6655898d2fd7b712a20565723996a5d4cf9c23eb46989522aaa7d874ea98"},{"path":"apps/browser-demos/lib/homebrew-closed-acceptance.ts","sha256":"5e88d8ec55b7f495647c83db8c2db6e51254a6c437edeac45728c8a535e02b2d"},{"path":"apps/browser-demos/lib/mysql-client.ts","sha256":"bcb3b2fc913366bd9a828125cad80e41cdd1fac9ce37f3c2b45910463290b05c"},{"path":"apps/browser-demos/lib/redis-client.ts","sha256":"cde9e40502bdf6cb85f3b421273a023f290919c57f162ab71b7805c78d08a6ae"},{"path":"apps/browser-demos/package-lock.json","sha256":"b8301aef759220f7a14a7bd373459d37b4876aabf6c50bcb0c99ca725c0317ba"},{"path":"apps/browser-demos/package.json","sha256":"6c66d5f822e47f0705322b31d9db53ea163ad9caf7a5856e172fa9f4bdd8f5a2"},{"path":"apps/browser-demos/pages/abi-staging-product-evidence/index.html","sha256":"889d1e8afab89d05ea02bc1c18459ccecfe585dab0b52d50aeff3dec979e08e0"},{"path":"apps/browser-demos/pages/abi-staging-product-evidence/main.ts","sha256":"19ac44a9631a42087111ce85dc65b60dd8fe5a14ec67d72492a382760a93f898"},{"path":"apps/browser-demos/pages/abi-staging-product-evidence/pty-command.ts","sha256":"f33bcec8e48bad54ffa9317ef6e16442e6328b3a093b3f208cb751054f582eaf"},{"path":"apps/browser-demos/pages/kandelo/kernel-host/candidate-evidence-vfs.ts","sha256":"d707a48c6355c9d451251be599f37f7c031fb82326b0b01bfa3af062d2b57c49"},{"path":"apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts","sha256":"b5227e00db788c0bf4bb02cd03c44aa94d419a78f6144ab170cdae5814542276"},{"path":"apps/browser-demos/pages/kandelo/kernel-host/optional-demo-vfs.ts","sha256":"2ea2dfa31054b7b041ab342a1c1d1626088ac9f207df8c12d2040030457677bb"},{"path":"apps/browser-demos/pages/kandelo/kernel-host/pages-vfs-product-loader.ts","sha256":"60ff6a4cb6104f474e387b90638c2b71ec48587397fa19a9ef34da2dfb6192d9"},{"path":"apps/browser-demos/pages/network/network-demo-worker.ts","sha256":"152f23063c076ddbbc61470428df6e003e5f96e7413f8348472b4d1660a0c56d"},{"path":"apps/browser-demos/playwright-closed-acceptance.ts","sha256":"430fbc4eea4a92ccd8a36c0aeb1c5b23aadf22e95d7151f09dadc4ce4f49b43b"},{"path":"apps/browser-demos/playwright-server-policy.ts","sha256":"eda5aa8651bc6854d1634be1c5ffdbf3a91a97c87b91d9b065f997e12afa420f"},{"path":"apps/browser-demos/playwright.config.ts","sha256":"e6d9a7cc6dab59be4d12aca97da856dffe5120facb1c5812225e3526cac37b42"},{"path":"apps/browser-demos/test/abi-staging-product-evidence.spec.ts","sha256":"e90b265133cfcde059c91d066261e243b761e20750ea8377e7679e4d9d877706"},{"path":"apps/browser-demos/test/support/terminal-command.ts","sha256":"919e0898cd9f317c95eb29d6a0fb70d398f18fdca6192f705b8eee5093eecac3"},{"path":"apps/browser-demos/vite.config.ts","sha256":"6d0565eac538e9d42fe4dffea659ec83b6f994a6e446fd7f7caba19233cecb0d"},{"path":"flake.lock","sha256":"2df2e3397a8c75ecf4fb6c897959650c76f27f65721bf3ea5a08304b480ad6d8"},{"path":"flake.nix","sha256":"b763dd32449297363544906fae5455b45508743b4a73b3e921c977330e3c1486"},{"path":"host/package-lock.json","sha256":"9257dd7a5a545ee044b3db00e92dfb86662db23f26c5a3ca94ca227a8f26419a"},{"path":"host/package.json","sha256":"ba2ce1c59f063087a4ec942e152c8d15b0eff523960808e6ab2a88679e13849c"},{"path":"host/src/generated/abi.ts","sha256":"7545942c5f72cee974b44449486d50a8577e084866a4a1b6a54036d682a931e5"},{"path":"host/src/homebrew-bottle-relocation.ts","sha256":"3e52fa6cb250f916486ec9e416903ec431a2656edd7ebc0761dd179920d76454"},{"path":"host/src/homebrew-guest-layout.ts","sha256":"84866dbb5730ff6c6df1a6cfe23384f50ac9e438b18e40b20678b2b63b9eccef"},{"path":"host/src/pathconf.ts","sha256":"449c15f849ee262957198372728cf5d7fdb84d68ff1d539a0e0e24878320f872"},{"path":"host/src/statfs.ts","sha256":"3e11cd17f6460b7ee5965738f5a04295b3aec5da14c9c0251c6a2dc1283194a7"},{"path":"host/src/vfs/closed-lazy-assets.ts","sha256":"278e8cd36cd1c47ad876fdf310af4014d91e174f3717acda55c125c0b7d269fd"},{"path":"host/src/vfs/deferred-tree-limits.ts","sha256":"bc12ee922c48ec8356b90e5efc939b4d497774a42a811e18c8917be8758e19fb"},{"path":"host/src/vfs/hardlink-graph.ts","sha256":"9d19366a7e739d55ef81cea074ab9633f255e65f9332e5aa82da70b1d1ccd1cf"},{"path":"host/src/vfs/load-image.ts","sha256":"df62f1b2faf98fb2e9900a6f6b428ae16e7f43494a6ade01f709006ef764744b"},{"path":"host/src/vfs/memory-fs.ts","sha256":"ea3eee30f39c7913ee2ccb4cb763e01eb4931bdd36c6cae253b712a3b46ff38c"},{"path":"host/src/vfs/product-mount-contract.ts","sha256":"2aeb61b4eff2689a7c054a8919fe622f46e621006d64050390a04af3ef05f4f0"},{"path":"host/src/vfs/sharedfs-vendor.ts","sha256":"946d5a28288d662680c5e51164808b6f5ea224e6815b91adc95c74ea52be4f00"},{"path":"host/tsup.config.ts","sha256":"78564eae7b555e70e9895df17d7593a579e7311aee37072fc32438525723673e"},{"path":"images/vfs/scripts/repository-path-bundle.ts","sha256":"64bad99dca7b32d57c104a9da1dd4977f0e8de56dc5950fd94bc3cda19ac987a"},{"path":"package-lock.json","sha256":"352cb8d4baa12319b0924fabe9f809bf70d9e10ced90067ee3dff997170c1ac9"},{"path":"package.json","sha256":"ed332760587828c7a73e7a15703106d93a671b7bad841983f4f767dff3c10b80"},{"path":"packages/registry/program-packages.json","sha256":"a74e980368ad1761458be2997de2abb76b77b3dc609ebd106ae02c99aa26ed42"},{"path":"scripts/abi-staging-build-toolchain.sh","sha256":"ed73bcd74bb7e2c409c2ae6887b4717c97b82f35e4f3347f523a7a240f47e815"},{"path":"scripts/abi-staging-collect-product-inputs.ts","sha256":"4ecd65b0789e21cb0ba3c5d133c9f3589da280258d382a3c208d859eefa03221"},{"path":"scripts/abi-staging-pages-producer.ts","sha256":"067fbecd469f83e266ab71db076d8a6be904a43986d74320caa80bd07dfd777e"},{"path":"scripts/abi-staging-pages-readiness.ts","sha256":"a254cf3f7fedef367ecc6fb91fa2175fc255f87a6458c02d0bf4caad23019f3b"},{"path":"scripts/abi-staging-pages-site-builder.ts","sha256":"97ce037272c1f8200ae48a73d027371f90c13dbd16b7194846b261eb51326be7"},{"path":"scripts/abi-staging-prepare-runtime.sh","sha256":"38ff5658d1147c6f82c542559078204e89decc2475f6ddb626673cafb1961c26"},{"path":"scripts/abi-staging-product-browser-evidence.ts","sha256":"458c31795759e2745f8eb071603f51c947f123cdea51cea4d7efebbfb3aa2a98"},{"path":"scripts/abi-staging-product-input-sources.ts","sha256":"eca75b92c09a5a28c745ecc6ae6e2407b9637c8710d022b76f7aab9e05e5be14"},{"path":"scripts/abi-staging-product-node-evidence.ts","sha256":"786fe35d64cbbf7b4898b9ac58f5c4483d8cc6f2b90f6644beb782126034ff15"},{"path":"scripts/abi-staging-protected-browser-operation.ts","sha256":"858b417d9562cf8618a416893d47e3bc616cedb7b1cfed673c467f1d0737b935"},{"path":"scripts/check-dev-shell-tools.sh","sha256":"9a6b009baa14f77b3c21e48735116d8475278cd65f78078b2d42787a71c21dad"},{"path":"scripts/check-pages-vfs-product-registry.mjs","sha256":"6cbb24557f1e1a6961bd0fa7f11cbf2bae3035f5458971cf75ca31a891af2b0b"},{"path":"scripts/dev-shell.sh","sha256":"3931770c7b9f50ccee5a89e2957ed7a9856d4713e7fb6cb2631b29d0c6c4cff7"},{"path":"scripts/fetch-exact-musl-gitlink.sh","sha256":"8a14b1c97e2331f104d6f061f9a74874f1d0e94a3d58b413d69f73412701b8cc"},{"path":"scripts/run-vfs-product-builder.ts","sha256":"95654c6298474d02a64b7d93bc19ad7a54776bb636515efffa4ae518fc7e8a82"},{"path":"scripts/vfs-product-catalog.mjs","sha256":"3bb39bddb2add9c05b15f80964a22acdbb33648e43118d4d956b0ae062cd7eb0"},{"path":"tools/xtask/Cargo.toml","sha256":"b30b900521eaa9652937371cb7a9ebe38302a5e9d58aabb6743996acece87155"},{"path":"tools/xtask/src/abi_staging/builder_contract.rs","sha256":"7b52f1e143039dc32708e1e0bbbdb7075530ef682b73607e5d77818efa80de8f"},{"path":"tools/xtask/src/abi_staging/canonical_json.rs","sha256":"50e42796a53d517268e9afeea7f594e1db47b65e2e903e9d17e9db5dd9e1a6cf"},{"path":"tools/xtask/src/abi_staging/check_projection.rs","sha256":"9a2656947301affbaa792f53eaae08bb32800f0741c51be48d4a1424a9f30efe"},{"path":"tools/xtask/src/abi_staging/consumer_registry.rs","sha256":"1985490c56ffd89feb8d41f5c2bef0f41432c37e26a4282b27da39d3476574bb"},{"path":"tools/xtask/src/abi_staging/evidence_policy.rs","sha256":"45676d5a430f5d4f19b3519862b29bb9204eb8fb1f7ad5c987887dc57874882d"},{"path":"tools/xtask/src/abi_staging/guard_registry.rs","sha256":"eed45cfe19c48716da9d205f1aa6a3bbbc0e96e0e96e1e50bcaf6329cfc663fe"},{"path":"tools/xtask/src/abi_staging/mod.rs","sha256":"65eec6b0d987138f8159edd6de627643a39fc1fa01ced5836bf5e878b4367d75"},{"path":"tools/xtask/src/abi_staging/product_evidence.rs","sha256":"c5ce7790e478666d75381834ea3eddec26428bdd7c6707fdd5128259fbb50e8c"},{"path":"tools/xtask/src/abi_staging/product_manifest.rs","sha256":"4db3dd8e01bbbbd52a3b8c862f27b2e1feb685caa038cb7b8913d13cd9ac72e2"},{"path":"tools/xtask/src/abi_staging/records.rs","sha256":"6d21b7325d70874804da528a4714e5a488ed23fdeac0fd77ef61c68402d84203"},{"path":"tools/xtask/src/abi_staging/request_derivation.rs","sha256":"02190e3e19aed2d5b00cab14a2cd9ef761e9ba7d276405058db973a8aa36c8c7"},{"path":"tools/xtask/src/abi_staging/request_feed.rs","sha256":"38df80125ed5ce0559a40d85e8d0421353c5c82106306f9be7e4ad778142dfda"},{"path":"tools/xtask/src/abi_staging/request_policy.rs","sha256":"c96ea613def0ce69206fa5418bd43e7f7a8cb5648dfbff1c9e57621127d1bdf3"},{"path":"tools/xtask/src/abi_staging/selection.rs","sha256":"1c8c517a31d7fa2d7abc97b3d57db75df38c20fc98170d430e94dcafd82da2eb"}],"implementation_paths":[".github/scripts/publish-abi-staging-request.sh",".github/scripts/update-abi-staging-check.sh",".github/workflows/abi-staging-merge-gate.yml",".github/workflows/abi-staging-pages-canary.yml",".github/workflows/abi-staging-pr-check.yml",".github/workflows/abi-staging-request-feed.yml","Cargo.lock","Cargo.toml","abi/staging/evidence-definitions.generated.json","abi/staging/evidence-definitions.toml","apps/browser-demos/abi-staging-browser-harness.config.ts","apps/browser-demos/abi-staging-browser-host.config.ts","apps/browser-demos/abi-staging-browser-host.ts","apps/browser-demos/abi-staging-browser-no-default-artifacts.ts","apps/browser-demos/lib/homebrew-closed-acceptance.ts","apps/browser-demos/lib/mysql-client.ts","apps/browser-demos/lib/redis-client.ts","apps/browser-demos/package-lock.json","apps/browser-demos/package.json","apps/browser-demos/pages/abi-staging-product-evidence/index.html","apps/browser-demos/pages/abi-staging-product-evidence/main.ts","apps/browser-demos/pages/abi-staging-product-evidence/pty-command.ts","apps/browser-demos/pages/kandelo/kernel-host/candidate-evidence-vfs.ts","apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts","apps/browser-demos/pages/kandelo/kernel-host/optional-demo-vfs.ts","apps/browser-demos/pages/kandelo/kernel-host/pages-vfs-product-loader.ts","apps/browser-demos/pages/network/network-demo-worker.ts","apps/browser-demos/playwright-closed-acceptance.ts","apps/browser-demos/playwright-server-policy.ts","apps/browser-demos/playwright.config.ts","apps/browser-demos/test/abi-staging-product-evidence.spec.ts","apps/browser-demos/test/support/terminal-command.ts","apps/browser-demos/vite.config.ts","flake.lock","flake.nix","host/package-lock.json","host/package.json","host/src/generated/abi.ts","host/src/homebrew-bottle-relocation.ts","host/src/homebrew-guest-layout.ts","host/src/pathconf.ts","host/src/statfs.ts","host/src/vfs/closed-lazy-assets.ts","host/src/vfs/deferred-tree-limits.ts","host/src/vfs/hardlink-graph.ts","host/src/vfs/load-image.ts","host/src/vfs/memory-fs.ts","host/src/vfs/product-mount-contract.ts","host/src/vfs/sharedfs-vendor.ts","host/tsup.config.ts","images/vfs/scripts/repository-path-bundle.ts","package-lock.json","package.json","packages/registry/program-packages.json","scripts/abi-staging-build-toolchain.sh","scripts/abi-staging-collect-product-inputs.ts","scripts/abi-staging-pages-producer.ts","scripts/abi-staging-pages-readiness.ts","scripts/abi-staging-pages-site-builder.ts","scripts/abi-staging-prepare-runtime.sh","scripts/abi-staging-product-browser-evidence.ts","scripts/abi-staging-product-input-sources.ts","scripts/abi-staging-product-node-evidence.ts","scripts/abi-staging-protected-browser-operation.ts","scripts/check-dev-shell-tools.sh","scripts/check-pages-vfs-product-registry.mjs","scripts/dev-shell.sh","scripts/fetch-exact-musl-gitlink.sh","scripts/run-vfs-product-builder.ts","scripts/vfs-product-catalog.mjs","tools/xtask/Cargo.toml","tools/xtask/src/abi_staging/builder_contract.rs","tools/xtask/src/abi_staging/canonical_json.rs","tools/xtask/src/abi_staging/check_projection.rs","tools/xtask/src/abi_staging/consumer_registry.rs","tools/xtask/src/abi_staging/evidence_policy.rs","tools/xtask/src/abi_staging/guard_registry.rs","tools/xtask/src/abi_staging/mod.rs","tools/xtask/src/abi_staging/product_evidence.rs","tools/xtask/src/abi_staging/product_manifest.rs","tools/xtask/src/abi_staging/records.rs","tools/xtask/src/abi_staging/request_derivation.rs","tools/xtask/src/abi_staging/request_feed.rs","tools/xtask/src/abi_staging/request_policy.rs","tools/xtask/src/abi_staging/selection.rs"],"issuer_repository":"Automattic/kandelo","issuer_workflow":".github/workflows/abi-staging-request-feed.yml","kind":"kandelo-abi-staging-request-policy","max_evidence_bindings":512,"max_products":256,"request_asset_max_bytes":4194304,"request_release_tag_prefix":"abi-staging-pr-","schema":1,"version":16} diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index eef0ebe33f..0af1c10549 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -4,344 +4,344 @@ "bash": { "manifestSha256": "5dc4d97dc4f154f265ff57e9d972e7d5a64a2e15fadedfa246733b830dda3497", "cacheKeys": { - "wasm32": "6a7e8445d507d03454d502f096fae6fd4d838b47f525b5b3a76f6341981a352f", - "wasm64": "adde4f0bcc33e61ff96c389d00532bb781e96600769b32990318d0caf1afff37" + "wasm32": "ff050231b36c34eefa7fa3023ee401aeea4b6fe5712d5da003df026a76408e76", + "wasm64": "a9dcfa56c704a3b78b2d146e4f455f2b075c3c391ab47ca3d80eeaae32dd5bce" } }, "bc": { "manifestSha256": "43b61e92c29098720bc7f4871a3b611cc01ffd124024290d42967cebb6ffd459", "cacheKeys": { - "wasm32": "6833fd5275f1378837506fbbcd3db757a19d4ac6ea9081212bdc4150a64408f0", - "wasm64": "ac098d774e4553bed2574ab3d03013cd70eb414cb5e9c57663edb3ada5aa5e9a" + "wasm32": "6ff78d52e845671fd2419cc479a8b890c25440b8692eb179093fef6f416c1dbe", + "wasm64": "19e852d1ccbef2b5d7276b406d2a4e83b51f7b461e86aaca4c7f1e627c902171" } }, "bzip2": { "manifestSha256": "59e1e53f5675e7c9148634933ed0f4c7192743727025cae4e843b6924c489abf", "cacheKeys": { - "wasm32": "480102a8e3940e1eb894a1b9767efa89a359bbe93a61ae23dec2c12319a33447", - "wasm64": "d8835ce8a4075c87a98b7b058769a950955388f9fdc58de90c3ea6114c206ced" + "wasm32": "5aae9d6e520de28012cd03c4d86e1725e7f6ec19c51685db65d7ba0e8aa3589b", + "wasm64": "5155f49582ab7a48d1dbe6376c15915f860268f5c75e92557b444bdb01d5f073" } }, "coreutils": { "manifestSha256": "72fc9c3818fb11cebaa047a623743252395dc729163fa804a7272d59f87fe82d", "cacheKeys": { - "wasm32": "ffbcd2c6b9df24acb01de4da66c4167c07556aac83a2891ae182a934bb9ceeea", - "wasm64": "8079ebf92c30022c0ee2577221b641128fb0265c2530f45bc52612ff717144a0" + "wasm32": "8936299a107eb443d6256aecfb2cae70f8398fe8e5815899f80205b87fcbfbff", + "wasm64": "c7116594f2d0d99d4a1f33ac4bd8b9ec895cb2c8b471adac7e4b70a03fff2de2" } }, "cpython": { "manifestSha256": "7dd4f446697a73941ec940c2ccba4d53be73fe6947f1e701031a4d0aa964c4ff", "cacheKeys": { - "wasm32": "8e36bc988b0f8fa0130bf7696b73a81b018e8704acc66005431b25ace7fbe048", - "wasm64": "2b51518ff1d5dcf41a0791ec00db026988139b8dd2cce56f85b6a7d997e1d41d" + "wasm32": "c65a7f719520e003d5c2f06380dbbe8bd62a31a0f45829163a967331440ebd74", + "wasm64": "245a7266a3f3068ee520c1b3b893efc4da4297968798936841729b51ba6f4203" } }, "curl": { "manifestSha256": "55523d50261f46dc4aaaff458d1cd87c6f96eaecd687a7540ead35c96906366e", "cacheKeys": { - "wasm32": "9c4a4a28c7644128e9d95ff53840d4a08f8727394dcd1248e18c6bf7235521f5", - "wasm64": "f53250e3420e80fec621db53195eb3aa16412fbb760ebf65307f92f949bddd12" + "wasm32": "7df4bf731deed6b17465de754f198c7572702c2814003a85f98602b8ecc67c4a", + "wasm64": "86d1f510efdd9f04c33e8511dd41d9ac6d9990b628c99a3593b6e84812554225" } }, "dash": { "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", "cacheKeys": { - "wasm32": "ff2bca584a197d92da417221bcd6a92171140b030ade50108eaa244905bf88f0", - "wasm64": "2d1fef92952071c38c2f7758c5a2313dc664966a4bd081820ed07e885aec2383" + "wasm32": "b169d84aaead6d980d3819569bc405b0733be8dd9f8c72154257c25883aea337", + "wasm64": "2eedf8b7b4f5d721c8e20475f0f41db94964160b4307c502558ddab3c4b896ca" } }, "diffutils": { "manifestSha256": "2286203eb762eca487939963e38ea1b901ac2dc9a830d3260c2fb291757df208", "cacheKeys": { - "wasm32": "688e551eb9e8c698148a45d1c59efb2bf13120d54dc9516289395d2b73a7ff9e", - "wasm64": "b3a3f5699d1840ca4e51b353b32ec01b9742c685866d1a30d38c87ad3fca49c2" + "wasm32": "3367d5b618dc7949562d1ff8a9b7c0423f53c6dce0970cc824513a0fb0da54ef", + "wasm64": "be91c98f8bd976496356c1456f9713d033c4f8e0a99f7fa1ff2e8f1570593c3c" } }, "dinit": { "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", "cacheKeys": { - "wasm32": "f7abd4cc88bcb4f9bf2e01872fa181c8ac84db6621da3e2c1a291f32c65f27ed", - "wasm64": "1fefd32aa29b1b65a011d4466550ba7f13da0029b7dc58fae072d5a0a756d842" + "wasm32": "a8d93a950dd2e2a1bb8b6c19f1323e605f14de305fea7cbb31595dabc6dc00a5", + "wasm64": "4fe113b0334f8719ca1490220064024b42c0e8a47196461f6111d187c5e853ac" } }, "erlang": { "manifestSha256": "475d6037e73a0f1e2f4381067194b2422751206979ea17209e10efa5a2a93bbb", "cacheKeys": { - "wasm32": "e6f453949d018258bdefcf7dfa503e8b25ad6422635c57540e92b9c4a26223a0", - "wasm64": "a463f4164fe0ad7353723f4761efe1394043c47419c7af0360e349dee45910e6" + "wasm32": "aca67e055955e7871c9d55da3d99b6e3ea97c2250fed03303739f64e628f1dca", + "wasm64": "33891f880e68e793d74fbf5a880364f714ce2f620ec3a874d199ab75d3a83157" } }, "erlang-vfs": { "manifestSha256": "15efb74f1825e2b85bedfeb8d98c19fa648c4be39cb9defb65330a8f21eb9141", "cacheKeys": { - "wasm32": "2be200a6d32d1fd561bbce87700eb46d90b7e91684f69c359f3e45b1b092f967", - "wasm64": "6a8a5917a612a6eda12ee33e481a3fef7b46ca78a25c5dfd2088407a6dc2f6a1" + "wasm32": "3cc9af6c89dcc82851f15a4dccbd54c05b70abd4a2c3f47d1e60da8250005421", + "wasm64": "050ec75506ecd127120af08601067111dc89916d0c2e4f7e47942ead45d311e0" } }, "fbdoom": { "manifestSha256": "7ff2127ca940e41be90ba45204c89089a7a2093567b9bff581a9549b57138626", "cacheKeys": { - "wasm32": "f50b05c1d725e28d65baf028956336614efd4e495c0c7646e3a85855b0cb9e21", - "wasm64": "d0dc2b707f1d6a8eb44730c3aa1821eccf83a5d62fc254a4c7112e4c937d0e1e" + "wasm32": "2b011c8db4d3ff2330035b1f0d18e98d9c08066dc0de4c9649b072ccd45dd2ff", + "wasm64": "62c25eb8a003c11c1134f689eaddbc60228ca78d3af53bc972360cb0b5c31e98" } }, "file": { "manifestSha256": "7874c1affcbbf2c8ab8c8d4beb087f275af57b5caae6a8947bf5a63a181552b2", "cacheKeys": { - "wasm32": "306f637580638cf8c248f3cf9b15469d526030020ff04820ce60bd188b54ef80", - "wasm64": "48398308efb09c0a85f659ce470444501db954576a689dfe372b1984018fb3d6" + "wasm32": "c57a1bc8b345b6fd6a55a2a1ca867958e44938c0e535d2549ca94ca80ea23881", + "wasm64": "1dc5fd31aa6a41bc1f3a2eb5558a0632d036a8f156d5ec6df12efb97447fe84e" } }, "findutils": { "manifestSha256": "a9969cb102403cef105e758ef602692500fddd75ec96e4e3f168c50094b6c931", "cacheKeys": { - "wasm32": "96312ac7e9087405539527a9c1e119db733e0f46395b9ecadb30c77f3eb83057", - "wasm64": "32a8417badbb263156214d7fe1c9c26dc5a21388e927bba68a1f6008d39dfde4" + "wasm32": "8c5a4343baeabe77dfb9aa2edbf0d07caab41b2f2c3529a4195d4781a3b75dfd", + "wasm64": "04dad328b730266c6ac17abacb02993d07e879c82bfce392d00c9fa4f5ef9c02" } }, "gawk": { "manifestSha256": "b788f3de724cd363ea68d08826586ddf544a75fb5dd9df1369ffafe06d8681b7", "cacheKeys": { - "wasm32": "ea31e40a17bccfd311032d3e052f3a6707745b000f8303be9e72072628a1572a", - "wasm64": "0a497268d13926f29073ab68072eda07a5fd8bddbd1e09a648a859408efc65b9" + "wasm32": "79d2a540b97c5b3182a8967b02fa49351d05532688d6486a99fb16eaf5018780", + "wasm64": "2f261d79d0a9b5511d3723e507cbe0577d48f0a168c1d9c2155ec91cbeaacf60" } }, "git": { "manifestSha256": "c2bc79e62c9a4e840ae94a16af0f41070460eaf3976a990dc60d2db977bee952", "cacheKeys": { - "wasm32": "a545be3bb60a666a7e6c7e130b91c37178b7c0a5bdf875d6b249deae3a7f1444", - "wasm64": "250424ecdf76d4fc070acae4e7a159e28c547796bb7b28bebc5e547b4c30a747" + "wasm32": "c698f25d62f1bc6da29642a61a0e10057d14cd5fbfaa50ed8c60f461c4442df9", + "wasm64": "8c4d16c7ba63d6082f0dfed2272b7bb43930313452fb9cbd6fc74f14cc8af686" } }, "grep": { "manifestSha256": "7c61b894807b831c7e8816cb1f39c78e3a69a4ced2fa3d18f0abdf00bf18334b", "cacheKeys": { - "wasm32": "68e8a6c294322d4f8bfc42c146bb4be5c47ff72b3fcf76e7423061a0117a29a2", - "wasm64": "17b76c2a271b3fab7de9ebebe154d3a0c5087b50a52e38f6b2ad938e31c20be4" + "wasm32": "386a56b5f9011bfa0750872abb717a165459d968bcfcc67f2c43eecfe359adde", + "wasm64": "c6b550cd17f88f014c4e1b61f7abad56ebeddb8720612baa247b426e6d34cfa4" } }, "gzip": { "manifestSha256": "1485add843bbcd744244e707c353208fe11192b7b248feed1550875d3b75a917", "cacheKeys": { - "wasm32": "1a49313a1bd83c8b35f8c9b2d7874da4c572b48275dfce6d37a647935879a941", - "wasm64": "19a892b6637c60c3ca51a20f0c3070ce38d361613a2e29bbaa6c18c0db9ecd45" + "wasm32": "7b0b85b3405959d80da6399e5dfcf42b78a50a00414a44dbb0a5b92eb3b4530c", + "wasm64": "ceeb19b599cbe75c19c460f0cc56e115910563bfb4cde58e8c45dfc9158f77cf" } }, "homebrew-bootstrap": { "manifestSha256": "183004a8385ab5afc388cd43401341b82812c67f97b08ad349ebd7f4dbbebd0f", "cacheKeys": { - "wasm32": "6adc7842ddf5af1df0d21e89a6bd1fae1e9572dc61f1a3f48c9908c71b3c5d2b", - "wasm64": "b7ab1aeac0354de518a5623930a779af74b9ab87b6a766cbe5c1e346a15c967d" + "wasm32": "4174e87f3caf6702801f188f5b271022ce506c99db12d24f8c55957dd81413e7", + "wasm64": "5742b8c15cd07b599f611b6b33127fad17aef31dd91ca96d0ba9ef6ce47178a8" } }, "icu": { "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", "cacheKeys": { - "wasm32": "bf454728f58a75797a9d52c8c6d92c6a52c67c377be31c0253fb2a18883556d6", - "wasm64": "5622a11e71c220ac8db2817001a6488161d4e91aa9df2d5d06da4769441f3276" + "wasm32": "fc36cf8e40047f380fc8935f764c623d8ac1ba2f2d5e57b41de46707a1be4222", + "wasm64": "7c2268ff2a24ad888d956ab7b9cd5e8d42e6a1d51e0f127d2d1b7261084e8f26" } }, "kandelo-sdk": { "manifestSha256": "c081879f1becd855917cff96f17429bcf2b9fd61bf95211eca9ff8fb625bb2eb", "cacheKeys": { - "wasm32": "4838701321647a5e4869af903664a7b5cb5cb436a1a220b132423881bd77c8a7", - "wasm64": "1d280340377fb10b58e0741bf749203ab10f34a972fa37eca67a3040e0b964f2" + "wasm32": "4f012b0777ac9997653e7e957dbd16ad8850ff627d62e8c5f15492daf863c1d4", + "wasm64": "522f1197d4bcce4fd6e5ea94f3a066fc9c0cffecff94b486a248e03ddd0fe103" } }, "kernel": { "manifestSha256": "db1d66db8575562ac7b3e720dbaa06d7dd5eef577d80220ea4167dc2d69b863b", "cacheKeys": { - "wasm32": "e59c55a65d4fc36dc5a5fb9a37db0f87290318a4137ecdbc833f7e7a4a8e8325", - "wasm64": "d0c0ee083fc0e3a0726ea6b0733109a5224f03be2425f85c6891ffa07e5c12c3" + "wasm32": "c5d9215ae53bf18cbbfa612dd48ab285e2b8f29d0bab2d469111fba5c13d4e76", + "wasm64": "3535f3bc0174aa0d7ae35e4a7ad19cefcc32d5af1f4a56a6a2449cb5db179b3e" } }, "lamp": { "manifestSha256": "250b64635d64f8537178188fb5488dbfd2980d79a1c2ffbf346af67008088ba0", "cacheKeys": { - "wasm32": "23c6df992f9aa1e71eafb0b6fa2828fbb6c4ce1eca8c6eb7cef4011ad090a784", - "wasm64": "ef0af0e0c0a0ab357046cf65c08b724287e9baf496bfc4d5f749e8b023c9de5a" + "wasm32": "4a2ff1d92355b154804533575d429d6c1028830a5891008c5b58f54d501ffe76", + "wasm64": "fd0e8e93c594104e5842c856cd445362371bf3e0cf33d215698f45ed31ce81ee" } }, "less": { "manifestSha256": "996d61545cfe83dcb663a33e8763e0edbd4db4a605fd69a91b243cf79b1f9b17", "cacheKeys": { - "wasm32": "10bfdfc7cb4ee67172ac4c91545c2b0e00282be1f740b55aff5f4f3bd75274bf", - "wasm64": "8ab01b9364b82b43ab11dbd1f7048ee7ac439453ddb34085d4b3fef057660c03" + "wasm32": "5800ac25c02cebe20b4f41a5aab4441d008b0ad6349a3b207938a895335c40c3", + "wasm64": "6d19f05abffcc3c2f74542b56db6aaa4bc989ce4858ad33afe8d44e6be5ac34b" } }, "libcurl": { "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", "cacheKeys": { - "wasm32": "2a54294aa012c4a47fb5b540f49dacacd83725735c4db8ba3746fc4177146a6a", - "wasm64": "496f8087f49824cee36bb6942e0b977f3c48757158863b35bcbec0c0ac8b27ab" + "wasm32": "f16aac6b14f45497f3be37247041fdae68c3ab3b312443972623d69ca93c43f0", + "wasm64": "557d6f5416388850c6c18f35e32350b0af13d028c5012f5023041a461a9d482b" } }, "libcxx": { "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", "cacheKeys": { - "wasm32": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a", - "wasm64": "f828b2c8c1b35ffcc95ed9b419b93b89133eb03ebe426e87e946889d5abea144" + "wasm32": "78a34cecb5b6ee33cab8ad5f48b6c9c63bb26c7f981c5ef4ddff95752194b0fc", + "wasm64": "6522456735e14d4657ab0b42f3d6d955be795d18da4c951bb7dfb9fe3df7863e" } }, "libiconv": { "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", "cacheKeys": { - "wasm32": "9824449919005d0862b1f40b310195b2bf5657733469a3b1aac4b94396ffc669", - "wasm64": "335e0b010c890dfe94d19457bcc1f2c3d10b7102160489f832bcba28f18a421c" + "wasm32": "1bfd43d95f2adc7a4243f5215bfbda8fc7d1dca6ed05f78a5726a577ec20b6bc", + "wasm64": "601299d091496b5b975dddc3d2ee4d050126532c1eaa53de80a650a98bca9262" } }, "libpng": { "manifestSha256": "79ed5c5c072a0267cce5c7a2fe37d8494571b17e44b952f619e04ec1c4a9db10", "cacheKeys": { - "wasm32": "9b8ebcc1b638d6821ead77d1a2beffdb8266c94cb1f9346b2131f1dff134d0d4", - "wasm64": "b641d2c250a10141e816fd3ac49ad583746215c7019605e892ecd8c9d58edc82" + "wasm32": "286eac81ff2334c5fca9fac2f52d3bf4962a01bd64fc50941ed691cd041969a1", + "wasm64": "4e0437f1847a177d976cef9d99640d9c3059fa191a578dcc98e4b7da9b8d35d6" } }, "libxml2": { "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", "cacheKeys": { - "wasm32": "966012e82a47f8afa17683175087d63b7fe7e191c770f14a62d82366e13ba959", - "wasm64": "5b900bc3f62fac456f2edf96c89bf804eb4e4c3394805ba4916f048a513a70fa" + "wasm32": "f71f57a69a6b05752c40a1928937ea7aa259bccc4e74591577e8d328b42d2b0d", + "wasm64": "c7335c3717d0ccf749eccb1801691f48c716d6bf7763a47105381601f157227a" } }, "libzip": { "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", "cacheKeys": { - "wasm32": "5cb2f96b95a2fcd0618db09f8393d8e2bd6a40617cb2b35f57667a482c671739", - "wasm64": "496ebabe3004af18f16981eae7600ce60212ebf973adc679234197068670bf50" + "wasm32": "e57c568dfc864238b7e26f32fcc3c45a7ba84c754f5d03acdec5e3644c65f198", + "wasm64": "d3a023c4c19d54471606beaa47820403ec26008129ce5c5257eacbfe22df655f" } }, "lsof": { "manifestSha256": "cfd199bbe082435f7a2e703e2738a368af1eeb5b76b6e93c376054f21ce94419", "cacheKeys": { - "wasm32": "7b9f79de5b4f23e236e29a1f4a809e586d225478e49ae27a891a4f021ade9cdc", - "wasm64": "5c30296d4c0260a4ad58be7c7b3009ffab3f2374b3f9bdf8f8bf45e5c0b50595" + "wasm32": "20f5f635d926e5f1b4d9ec6b1a62ca79993b1ec9501fa72cb517b3d2d98808c6", + "wasm64": "9268a9ca5ada2d716bfd98c6becfdd0691a35816e1e2f24ada2b3e7eecfc7178" } }, "m4": { "manifestSha256": "9b5838bdde8531079f23a89e135aa3832bbbbb7ad6099dd1503db877d52afcad", "cacheKeys": { - "wasm32": "9413a8cb9d531fab82b389c7172bf1d59e3feefb5adcda8504d26934846f095e", - "wasm64": "ad05f0c65c4f2c0485a7f0ac37a80496f1c3b329e426472e326685a6d862f681" + "wasm32": "41abba58c9e3c73a7b14d9158b995a89228045bd911cbe0835cef45bb79d975a", + "wasm64": "ee285c2adabd642eb6774d6d7cf99adf2013536fad128f86f706043b784b6e4b" } }, "make": { "manifestSha256": "62e8b36a6df7e981d191061a5bb7f2db85b2deaf365352a7d590a6c6c06f2b1e", "cacheKeys": { - "wasm32": "a95e8bfb3c1d51b81b6de917d410ab6677c2a2c25c1d5301654c796e331e47d9", - "wasm64": "ee0603d70307257917ff9537ec5f469eb4f78a2e621deffe0c309b7772fc1641" + "wasm32": "25c88f3dc9605627ad1295f665fe825918c6db7b4645d6cdb3c60ad8e4a2de47", + "wasm64": "e4daba4bd75c253453f89d468a93957d9b65dddbae546694fc2ea123893162fd" } }, "mariadb": { "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", "cacheKeys": { - "wasm32": "6d9b1c0dd88cc10290b63df2ab1720940ae58285e2745c91e6695b7b85a703a0", - "wasm64": "3f181cf9f51770f7deda50a72a1d8e8b4c38d515aec4485ec3934e429ba42344" + "wasm32": "98c4098ba11e6be12f4c794128ff1e033923d28b070f446270cecc386bcadce9", + "wasm64": "33dec27e21e5c59923d487ddca6adab373e6f36a9c10ea8bfb3e71df4b001181" } }, "mariadb-test": { "manifestSha256": "d4ccce161d659a5a87c80fa1117054bbccea870e0d4a46bd8a070d3930ad0e3f", "cacheKeys": { - "wasm32": "1bfee2a38b4585a79ccf13385402b26336d471cbb83bfdbea55457d2918eee7a", - "wasm64": "57e71d6ee11a4e7554fe95e6f11b74c5df7573d388d3a98e15772dc8d343ec85" + "wasm32": "1d38fb8cdd93e5f959f8d4b4885c5f7b1e2b997f3997770530b5f1443568ca55", + "wasm64": "5674e28d2f08a03e7d43dc45be156810647a4eb1cb8b50cb53b97f1d33f8743d" } }, "mariadb-vfs": { "manifestSha256": "26e74ac84d89b2a839ed72783437061a23022ef2f88ad0f52b6aacd0442ee1cf", "cacheKeys": { - "wasm32": "3fd1adb0598b2c2c061d9985786dee0769d61e71f6acacec39b6a4ce6f4bce0a", - "wasm64": "cefbefb55d6a9238d88f7c40d94be06dcb8ae1ec2846fd4eb5ba5e37e175aa04" + "wasm32": "667a655877ef5f24966c54e959b86c5276504fc53de5ea8fec26d8e0c7e2e116", + "wasm64": "7e8dbe962001e74e218304cd4e6318d260274a47fbb542cfc8271cbe00023ef2" } }, "modeset": { "manifestSha256": "36a8683c1c7309701d361c5f77e543a6c1531397b26c72dbb864528c59c42954", "cacheKeys": { - "wasm32": "2478e94c90c8c0ba8a043dd9b527493df59d678a4323f6f879d6ff819921802d", - "wasm64": "e84c6f8581cc84e5f291bf36257a33413a71216364e3514bedc6b6289ac91a2f" + "wasm32": "50e057ad41d9fecde98fe9ef05f41c8705f6ee7eab42321fd3092a45ef6fb8b5", + "wasm64": "16afbcc23553fa05e4bc814045c3c02c5706c4ab3d42a801f0b7c61f6256b2c3" } }, "msmtpd": { "manifestSha256": "686ff665b5e7907e67618790b1a3eddce2ff47ed665595d17209bdcda1e0b274", "cacheKeys": { - "wasm32": "ca4d269e24beb373937cfffa3a5a70be1c26f67b4afa19128d697ad9cdced81f", - "wasm64": "1a8071d7ebf7239aabfe9561c4875767de51dccfda4e1dd74ea6f6c1a4e950fd" + "wasm32": "0b756d4bc1ef5f2566e19bfd7b02569c37ba60ed53972666ae46ddfab63f01dc", + "wasm64": "4dd55ba5dc4f092c81870bff7cd22bfba8ae67c9330f22ddebfaf8713eed5cf5" } }, "nano": { "manifestSha256": "c5a52f0c37e08b475bb815367b1f0d63d0d2b06649d99fe9f461b72d0b9cff51", "cacheKeys": { - "wasm32": "1b0237e997d82bafe853ec28192c4b5a310331ce814f6cdfd4301c14290f54bb", - "wasm64": "72ddb80181d6b5514fa9f6d077060c5f3bd44ab22789bf4668f10ff59a412077" + "wasm32": "3371556242b878af678fea16d50c78738f8bfe68f732996ffea128ee609df3d3", + "wasm64": "6adde1b17f08bc1af8383cab7faa744b505430fa85015cda2db03fc2e91e5697" } }, "ncurses": { "manifestSha256": "30d07b8914e93d0e38c48770aed2ac2938f6076b03e9d7f65dea597b04efd908", "cacheKeys": { - "wasm32": "f59cd67650e79a30dd490e8e78b14089871ca4d05acbd7ef5cab5ed02c3196aa", - "wasm64": "86ed43d42973f5f3e3140a15fa809c9f5cfdaec6fdf45dee1e281c6a3ea87a92" + "wasm32": "cdaa5424668b350e485717fa84d8f49f5c5062b91e5073c02eb0a9d5828a6d67", + "wasm64": "4310507f1ddafdad53cb853d70dfc880c981cd84b656836e033cbb6e37bfea7c" } }, "netcat": { "manifestSha256": "4cf33cd1ab768b3ad0108da8b68c2ff72e98469cd86a0bf2d0da54a7d4f6fa16", "cacheKeys": { - "wasm32": "bda506d821b1485688ab5f601bd28b53cfea8675f31fe5a617f62a89ef34808d", - "wasm64": "303a1f132c637d92eeae9ae49fd69e33e5a69a10eb30b1304fa6a5ff4e187a6a" + "wasm32": "aa5912ccc0216690d8f49ed186928796bc4c2bcc3ac13109cea5430012433b08", + "wasm64": "70ef71219e83735b22712013fe75687c0493962563c04f12720e5e8a3d443445" } }, "nethack": { "manifestSha256": "1a2f12ec2770bd8d40006d6c878a3493b97c71c2bb63ad08c7f6ad99bfd53504", "cacheKeys": { - "wasm32": "3414337607f4efe93fd212f17afda5d3160e3aa05141944c110b6960827652e3", - "wasm64": "051a19b740daad2a464f8b24c52e7d5353d0b7854cedb334d41d5d068d1c6960" + "wasm32": "7b929ba9eb21731309ad5d3a4bc134d71242010331b0f127eab94b3fbb00f411", + "wasm64": "156d2f046ee6eb90bb4ec454f453e493d5f9359e63b23db56a3c8588895a7ec7" } }, "nethack-browser-bundle": { "manifestSha256": "b8294981da7ce4299dfb271d4aecb90ababa97a4d1acc9b716ae05bbe52beb53", "cacheKeys": { - "wasm32": "3d63f90f5885cd694f54d200a06042a2547610c2681ee32eb0ee051b7d731c4d", - "wasm64": "42d2fd4fa065679793df052daa0a7ac764c09aa37a223160a517028aa9bf39eb" + "wasm32": "761d159033c24a22fe0c9cac0fa02a99850e13b7686c03264523e892a2765ed3", + "wasm64": "ccf90c4023543c0397fd62e51117f94b15873fec32cbadd9de340f4ee5be3895" } }, "nginx": { "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", "cacheKeys": { - "wasm32": "2b5174ccf228be9d359389d8c2435f0cd34feebc66e57966558902f72682996d", - "wasm64": "c07b31c1b40249c1784c57b7abc6187119a614bca16214f08b5bd004e7f34151" + "wasm32": "efa78ce5711e440d8db6bdfb837ef3749c67ce2401b7cb9acd6915090a564c41", + "wasm64": "f2214ffc439a094ab325f5af59c30a42928335fe244ef052db8385bf3da3efcd" } }, "nginx-php-vfs": { "manifestSha256": "97976410cb02f8ba710d856b4ac904bcf976d677b50eefdee39fb64176070d4b", "cacheKeys": { - "wasm32": "efb105c205f0fedc92db8ddd3abfc01e6093488c323228c4af0e574442de6169", - "wasm64": "84010e07c2665c1404b30a61e0ae55c4c777bfc61db7d1a7a3096687a1b9d9eb" + "wasm32": "d085274e8ac987640fa28ef533f18622767db4ed9ac90ddb9c2356b1fbf7c4d9", + "wasm64": "f48177df597facf28c618b3861b2145df60a61ecd5e0a8564d5dafbe6bc99fc3" } }, "nginx-vfs": { "manifestSha256": "46aa2d3250ac5cd0f102a85c3a36a086c7a50ba1f9e05fa1c2aae3d07ad16f10", "cacheKeys": { - "wasm32": "b08f76f96cc69cc2d5c01469f095fcfd60d8f70787a6139f1de23aa25aac6702", - "wasm64": "1e008873d6bdfa8da071df5ce2ec8099b9ee76e07859dec89ef9984eeebf82aa" + "wasm32": "0aa03d1ecfdd04959b5bedf68125fe9d7c1bd6a9f7b7818a9385758c34a4b256", + "wasm64": "05507960aa27dceeec8694c7b24d3929a5523df040b94a4c12ad60d9389173a6" } }, "node": { "manifestSha256": "2131a24dfda8587860e57fc65b573086477d376b065b3b9ca4e1dfe4d79f5790", "cacheKeys": { - "wasm32": "c17d3a4e332092ef2176fe855387810799527eb2aca0de81f960f6d97cb74cb4", - "wasm64": "c75b80e4acb80303e9f63eca1a62efa12cc1c988b61fa5da20d213b061ecf6ea" + "wasm32": "b55fef6acf129e5a0f15260103604df029abaf4474250b2097f4d8b3bd47b6a3", + "wasm64": "216b35f4a9947c6aebfbc61bee0ee9955a81eb79d1e30a87079f256de3e3c204" } }, "node-vfs": { "manifestSha256": "a4a41b2b06da60ed2a89470caf54d390f2981b1e8cf55e07cf95b376e15011d6", "cacheKeys": { - "wasm32": "7e720e5ed98fbfe732da2f89c99b6d6247ffcd6f0135a456685881a315060ea1", - "wasm64": "d73d39fb24dcfebd33764ad7098f7adf7d9ff0fef11d00a3182bed78238d8346" + "wasm32": "5c3fbda89cedfcb56e4b7ed5cd83d954782c91551a0bd3094a538f875fb283a4", + "wasm64": "e4bde1d2e16d0291e130df4151f9c5d4bbdbf689de4f77555f269f716fb78db9" } }, "openssl": { "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", "cacheKeys": { - "wasm32": "94d90e4c611c3ef8174635182f27493ec8f3c832861ae9a2428b1a35736aa22c", - "wasm64": "341bcb8e5eb286928d150eb0861590482bd3dcfe6f08e2637ca385081abb5273" + "wasm32": "f488a967be488932c7ecdc0632bd3bdbf426d07cd1cf8046bd501fd9258232a6", + "wasm64": "fa68327542d5350ad55d595937c6e86539b02c50e23351f1d0363466dee16812" } }, "pcre2-source": { @@ -354,225 +354,225 @@ "perl": { "manifestSha256": "6cdc4dbc54d0e4008cff41f82ec8918c0e44b4aef23903539c1bc0f538fe3ad2", "cacheKeys": { - "wasm32": "20e34f8efc078aa6a8d7c1d23a186102bb8a3ad6a1e98cd0adb09cb1b6183045", - "wasm64": "35782f3612dcb21d9a84e4d4c57265035c49687952f8956cf1adf64a4d85d84d" + "wasm32": "b35bc1d4d55103e7fd734aeff693ff247fb1d082aa505ba1f3391eb0a69cd5d5", + "wasm64": "4ab98f9d2485139be44b03be82bcf2c1446302ded1d5724dd7f54906835cd75b" } }, "perl-vfs": { "manifestSha256": "1345cc102bc8c24a6bc276410c00b4fcc99ba5f6adc9b031f882aaa35d53c8bc", "cacheKeys": { - "wasm32": "c0c99b86a9b34f649f7ecb4eb07caa16ee505c1ae292ee57a510a785a18da25d", - "wasm64": "110a596c99cf59ceac57d5b788ae3646ea33618b46e4607eefc5b0737482b5bc" + "wasm32": "177128411944f715056be286049f24e8f133f8156d14789219e954d9b707d91c", + "wasm64": "7b106372f9e643d3360c0af4193bc6c243c676c0c4311f9a481a212b0c1fe909" } }, "php": { "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", "cacheKeys": { - "wasm32": "78e404c3a0b1ad280ea8c08a87d9d2d8b69509590da06878f4138ad71fc9d92e", - "wasm64": "b211c308df432d8f1d10f53f4ccf31ab2eed3c676daf43ef89ee217674cfe7af" + "wasm32": "fd181cf83fe71c0e34c748144313f50dc038860146dee94411f0a5af93725949", + "wasm64": "daf2e9107caeece7eb7684afdf09ae7e4453e16bdf9a5cc3a029cbd7bc909f1a" } }, "posix-utils-lite": { "manifestSha256": "8fd7190b2848ef80143adc7b9268c79e13cd4db3430f7105faf49a4b4407a06f", "cacheKeys": { - "wasm32": "91eedb0fa500deef8ef48023d799915f49b9fe9427c610bc06f46afa9847c60a", - "wasm64": "7178161f1bcdac0f0bbf5002ac049c501c889ce5b8a7a91bae16954fc3279d3f" + "wasm32": "353dce9093050c8c2d3acc5c36b2e93e3dd90c0c50ecad74dac2a5a031c359e7", + "wasm64": "268ebfd201f83614ddb3dbb747357ecc3f85bc7ca600b4d5e91bd1f334c70370" } }, "python-vfs": { "manifestSha256": "d1aa99feae65f06c0ca8cf52c2176534b2723fd4ee6eba6e72c205245e1c9c26", "cacheKeys": { - "wasm32": "edc1345ac416c098b008479c66219615f00e2c5915bf9229ddb26acbac50ed1e", - "wasm64": "7037394f2bd35dcfc5a1f647f75a3611a9766e289aada6ef33d66b182b3dd736" + "wasm32": "180de3d93a88eef4e6022add381cf08e47f45df52dcfb30724349ff00e885346", + "wasm64": "35156f80af2d0f8d82f0b0ea5d3fe2d46bf9c3bc4001e54c101ef98bc7d941eb" } }, "redis": { "manifestSha256": "151fb507de953ba2ba94b8f881bc7d66b4c741c1359a2f590cc07f9a4cd368a3", "cacheKeys": { - "wasm32": "11d2132bfe4524b34e4acd17133601f861afdaae5dcd114285f415a9eeb50471", - "wasm64": "04fcb53fe0a8b26939a585b5aeb4870ddff3cab140138bd865f49b476302d5cc" + "wasm32": "553bc34b468d0c07c1bff8e8bc97fff4cc8dd3edef53256750fb9f743c9ee61a", + "wasm64": "c688b48613c749b7185cc569018f6c6ed8fdace514c1fde469631cc3fdb97658" } }, "redis-vfs": { "manifestSha256": "234c45c40f94e2a89f98295313b82cb9fce15a412ac829d9e87394e0dc731813", "cacheKeys": { - "wasm32": "ab51911b630502a1b2cf7079963dc1e2a493dff850c5763379bfba81c8b25d8e", - "wasm64": "fa1e8b2adfd61b6eff665c5b9f23c0897300ced125fc2bf43b11581d9d82c4df" + "wasm32": "078e42a04c95b30426f72b456e8f73c2b1dc5880d3fd237d8c0e076aef327fa8", + "wasm64": "cd3788ca3393be05e93d33b85938f73ea94b118bb29d249fec1bca0f4bcefff5" } }, "rootfs": { "manifestSha256": "0420201025170f48c32949ac62707d4577cdc13a53ad1f01f59d318ec07c8d6e", "cacheKeys": { - "wasm32": "61706131cb8837a994c8b7fac6e172c94894a22bb1d2d2994d05f0984f5d70d4", - "wasm64": "4ad8054459b819c2c4f59229708e87616e1a6cd1a84a42e5a07eb841739dc1c2" + "wasm32": "4c1df30f8848368404a222b31a9d5c411b4a4b4689a7980036e4876f4d0cfd1a", + "wasm64": "1c650bacd27b43136b55fb1a5b05164494532ad17a53eef628f426d96acd3658" } }, "ruby": { "manifestSha256": "6ea67a246c29cd927a19decbb36ae4c4d478ff6642d2c77e08a6b2cfeb8251d2", "cacheKeys": { - "wasm32": "c990f064060821a8e076633a227c3e5cc1dc3e8c63710eb8121b0cc90fa7b1ae", - "wasm64": "3915deb5b6371895aeef5f20ac50717adfeda16abcb2472ee59c529d053eb6ae" + "wasm32": "5bf6e98736011288a06a97016d022190f2b63a151a2b5079ae5cd5c55e9e65bb", + "wasm64": "396e8280ebb1945db700f5a6e3710723d6c006218a96386ac678106fa81781b5" } }, "sdl-dsp-test": { "manifestSha256": "a988bef0b27403846a675965d951a286245fa79e84c209657d70a9a1200e8037", "cacheKeys": { - "wasm32": "91be68f6091b7033131e63c8a28f747d9d51302ce511e6c52632d4918e068699", - "wasm64": "a822b127814d20f47af4efc660ec0950bfd539c6841b27f254b17d7974892708" + "wasm32": "5389c9cc111d301d1582301fbfd21f2867fe008e241aaf007867358c0930352f", + "wasm64": "94cb9d851bde2a9af0b55f01bae2dbf829d7b6f6344312c19cfab5620ecb2878" } }, "sdl2": { "manifestSha256": "327ce0acca768f51e36ddab8ab58fd57b24cfd9e40722e818103c439f7263dd7", "cacheKeys": { - "wasm32": "1b990666208f36b3a4ac2521ed3a39e8227779b2e2fa21600b7f372f616d0621", - "wasm64": "3ebdf1d1cc272b4f629addd42ad2531e0879f84409de18e64150855ebe5e71f6" + "wasm32": "49794fbdf038d586c70122ab262a4d260bc4e0b13ca820ea42136db76697d162", + "wasm64": "6ee6cfd8830b1a02332361af20e25eec4182116f14ae42d82c3a89750d0b6fc7" } }, "sdl2-mixer-playwave": { "manifestSha256": "5ff3863e9f83cb9ad62931e067ee6e417826e06391862d9d0a58d6cc6b4dc570", "cacheKeys": { - "wasm32": "b356d169ae065b0511ab6a7bfed157eb764f8137741d3192113168cf74988ce9", - "wasm64": "f3fa3c42fb82fd3b0f322e5d1b23853d4439570f069f317e9149e40ac65205db" + "wasm32": "277b6a77899f266f1c4bf1d4ef6a0aea52616cb3944ea66e99539719aa120c38", + "wasm64": "9863492b8bee20a0987d9ea6e3757a7f82a2b2eb277fec39d398e8a5ea850e9d" } }, "sdl3": { "manifestSha256": "3b7c64605b941e14d336b1a127d8219c50402dece009e8855297a0a0696bd67e", "cacheKeys": { - "wasm32": "41b437b4383f8c9d419b39c1b55963ac5b446705216a6029d39089718d28bbcf", - "wasm64": "147317ed5aae1f814257c2e3ab73fe3e1cdb0e07d802ad6a60e7437411500a2d" + "wasm32": "8ae8a3a8ba51e67be4e0aff2d11a07c26fbfd4e5a14e8e2915dfc58d7d0fd053", + "wasm64": "9c62971f30fbcffc1ae22e83205ed1f635d1d795c13b173411997dbe8019d238" } }, "sed": { "manifestSha256": "0c0d82d53907c19825941501f833252cf9adf35ebcebf6786baae28e5eb6958b", "cacheKeys": { - "wasm32": "968b56b98643a7cb8a7e11101ebb352c71044fbdccec6aac88768095e38b077b", - "wasm64": "c2af8ff7648d60e81a11f72573e1788e0400ce0f1b0ff18a0aa50c88ba6061e1" + "wasm32": "b08d6312fcb338691877cada2f8661a8d3386c98ba8eca7366170dea4df71704", + "wasm64": "3fc2cc7862d04b7c64d5f2320d189f184f6adea45e203bb679013a9e4f315c46" } }, "shell": { "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", "cacheKeys": { - "wasm32": "f16a327ad8a9d6e1ccd13b450551b47869d9e56fbdf9548e545a4d3dde47b371", - "wasm64": "d77d117e70b5f0779123498934ebaa586e311846f8cea79aa98afee72061f881" + "wasm32": "fc799ce2e4bc4dd48cf320f5dfe468e6b20fc6e7aa10feb833c1638d8c645e37", + "wasm64": "6e668fa5c3aa054b27c2dcee343018e5985a0060e9addc873e81455c0be9ec7a" } }, "spidermonkey": { "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", "cacheKeys": { - "wasm32": "5c6935fabfdc6a8cc1ce812a87cf61d19df14a268e9e6ce33dced6e106ccc145", - "wasm64": "64611321d8dbcf24cc0d25c97ba593aa54f6b87e7147c7c3cee317e4c97313e0" + "wasm32": "75ea71776150b11294eafaa89f8859d9751df45c798234e3e46026946b995a3e", + "wasm64": "cea0ec121a6abf2fd014aa67009cb54c4ff53506dcc9e7af9f5fa711dab424a3" } }, "spidermonkey-node": { "manifestSha256": "f156c4c5044d2a9447324e7a2a35f8c3e6fa367b3e44f674dcc30ab6b946fae7", "cacheKeys": { - "wasm32": "6deb4a4d5d3dba8f7f135b0e18d71ca30b144e58f871f9578c993aedb3dc9c59", - "wasm64": "29fb515712a3eb310dc8ff680bf2438e7b1ade8bdbaec4cfc39d48cb396555b4" + "wasm32": "21c95fda59eb8139edbdf288bdae7668646c6e632d370e7b0ddf9e2bcd6d109e", + "wasm64": "ffd7f55232a4065a43c263726bbd51ad71e140b5353b9fb7b58b93ca09f71e29" } }, "sqlite": { "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", "cacheKeys": { - "wasm32": "590f8dae0ef8972831a64291326763a1e9fa3697b144daa99d2d2d13cb91192d", - "wasm64": "9bd8425404ad7795a8e02e09cf39456a90247f2255ecf2737a55847a06bce7d1" + "wasm32": "9314f02cc5ff7e7f4d4db1b5122e99ff0683a0b1ec68cf4e73eaf723d8751d35", + "wasm64": "a666a4cd1b178683bd9d2c6d1d3834fe49e6d6235b39d05e9787c0ec2ade3edb" } }, "sqlite-cli": { "manifestSha256": "1cebb768f9473dc58fc6e72c9b24565760aedaec4cbdb29ad43b22a0a5fd7030", "cacheKeys": { - "wasm32": "78464a56a06dee769c41598f9f6922ade013ac0e8fddd86a52cd7bda70460dd9", - "wasm64": "d0a775b4a152287c9960606515c0ab9f95f7a891283ce511560856d91932d49c" + "wasm32": "7a544f84e919104ff06209fb24c46e5c0051138352f03ca6c4bb6f4e38b930c8", + "wasm64": "3dd3633bc5b05401491bb51a7a6b006c3061c9f75cdad064ce3f81e80d72d4e4" } }, "tar": { "manifestSha256": "4502355c246362a2035e52aa7b35d274882fad4b4404c03470458baee723ae68", "cacheKeys": { - "wasm32": "b9a0578b89fddf546f542eac3a63026f1f80a69071c13c029a9f54e7feefbeb1", - "wasm64": "e611e958a9354548a2d0671345e576240c8546183c6ad6a5bbb8720465edbec6" + "wasm32": "2bbab108245cc14d331369d06610451db81853907b004fbb53aefe0277abedcb", + "wasm64": "6f2ffa5b5e19d07011689b93c8894552855b06a1e015691375cf0d3d780655b4" } }, "tcl": { "manifestSha256": "b98f237f741b11f5f5da55db75530b421755ba4c8a88d314553106faa1cca1ff", "cacheKeys": { - "wasm32": "2feeb5a979a7b348ec5cd5e33a019ce766b6885be05e5079846a7e5fc2844cb3", - "wasm64": "17b3a889d6a49cb9a241747be944f2f086f0b92033f6bc9b72f066a731a7cb41" + "wasm32": "01849ba0bb079089467fcafefb9361fb84ca9707616d582bed66f416079499c0", + "wasm64": "b570528b7ac878e3415f537db76db16dda205e8a851c730f3eaf323d71905f7e" } }, "texlive": { "manifestSha256": "028ada023f8a8f9966c8a28575242a339aaaf0e8870fe4a221986133b381c3c0", "cacheKeys": { - "wasm32": "00bca12d8baefcfdda858a5f6f2ca58c462fec39b14631060fa06a2257cb1f57", - "wasm64": "8ecdeea4c1f34d61ba298237dc23e5dd4a64002b73b63828f4c10d5e3caaa8c3" + "wasm32": "544160ee83ecec389011ba955a3e4f0100076a1822683c4bd93e7f5e9cb12f18", + "wasm64": "fb28789c8aad53d65c94f8f891a633c89cd9e143d0cf18637dbc9a796783d113" } }, "unzip": { "manifestSha256": "ef4e5e34258827ab3cbc1930da3fd9514f08f2d7d56c7c3e0d5c495a4d3a20e9", "cacheKeys": { - "wasm32": "0e95e74674fa43f3d24c8b3457dc1a4ff9631154c71b86df537cb28661a67caf", - "wasm64": "c3f1ba915275cb158c230ffc4513c8e4410727e056405e39a5237c7b80f9704e" + "wasm32": "b88616dce4cc96694af58a5ad0bfec68f1cf50ed6d03318a83cda972431db0f4", + "wasm64": "8805b9af4783be5554c9f548db8a2c485d83da8e609f9eb8515922320b3e96a0" } }, "userspace": { "manifestSha256": "221176f2a096dee19bbefd89da5c7f50138ecd92d37ec9d7d6b35dbb25e86924", "cacheKeys": { - "wasm32": "adac952005081ba013f663b01c07f7fa948f80c1c580accc8190bc0a4761a5cb", - "wasm64": "777175d27005e8daf58421e33c30eacd912c6e6abee5c0e085f411e3536f5cb6" + "wasm32": "2602bb96f809acd8912f900d93104b024f3b21830054b6fb9daa1196ef71c013", + "wasm64": "53d4488e76eead2f0c3df0bf58cf8d259f8c4351c4029a4039949bca673703bf" } }, "vim": { "manifestSha256": "c211660ef41d01f95f3fbdf675b74891e897e3f304e04f1bf5586f326007382c", "cacheKeys": { - "wasm32": "a75b0e135031cbac781aa636f5de6d6d1411d1c41f22021bc1a9a594fc78a35a", - "wasm64": "952ad0d54e9f46afa84b7d7f32a4499259a8fb5c6827c31bc7f94e16fb4859a6" + "wasm32": "9266a61ea0dc6806ec9d9638c07f334e1404d2c4e00a92dc70d7c1c74e19ba26", + "wasm64": "bfe6adc2c879a1252ce4a97e5a52f868a391b4a2b0d8e73ab76ef78806c29920" } }, "vim-browser-bundle": { "manifestSha256": "e31d84057db49c3534381604d0c8f3c7d765e33ede560a7ea7b01a5a8f1aa0d3", "cacheKeys": { - "wasm32": "56b676a43f6cd4d9a07ad75323d3a7c7306cbd075484457a79569787c37ae188", - "wasm64": "6dfb87a7b84fa94fa50ba26baa85947cf886e3f26ef798029d6d77731ff350a8" + "wasm32": "6334e1fcabbbf214ebdf75abb9b6a42920ec2aa6661209009291b5e5c1d78961", + "wasm64": "724650f743e63ce87adee32e8560bda61f7bfbba80cf9f7934428bcecca95792" } }, "wget": { "manifestSha256": "61420b6cb1be12471f425b0aebf5aec58c49d0b43d939dd54508b305accbf54e", "cacheKeys": { - "wasm32": "98a913f679104d3400c949da7f8bfeeba5f46add578d306a4adf16efb1daffc3", - "wasm64": "3f1b2dcb26b396baef7f6b383b2ae884379ea15611b2fd836efbe69a3a92c4b2" + "wasm32": "172674ecaf48c516f70fb35e00a3b3f7288e09c1ce47c0b0bc9330f89f51a163", + "wasm64": "909eeb6fd043e977257cd90d66e52bb2e73dceb8d35d6b1f3e6e81385748f125" } }, "wordpress": { "manifestSha256": "36465e0596a06e855a8524eecfd87da98643bc13b37ee12939f5aab44bf33418", "cacheKeys": { - "wasm32": "3044dcb66449c2b6a9ea7653c74465516e5a1cdf168613119a9352916b850a51", - "wasm64": "5ef957fe986b6d09b0d29a8c945995c9323974ed66061710db0379a87c62cd67" + "wasm32": "faef007835b602b3bf7ac1555eb157c24aa50af75f66a193c6f10a538c8baeb8", + "wasm64": "237a0e1b75750128d129baa58cfa75b4d7a1c29dd0adf6e72b5309e26465181b" } }, "xz": { "manifestSha256": "8707d35b8647b1af8fa165dcb6ce7b2a6a007e19ab2daca2680c39395864a737", "cacheKeys": { - "wasm32": "22f7c6e571abace32661baa8907311894d67f1ddf037fc3d211b15ad7585ac87", - "wasm64": "630e17ca1c7c7e993a5ea79f428a87f54cdd77ed08fd5342edc926df07ad510a" + "wasm32": "a00821c9d76bdee7841e0761012f4b024922f37c817c4f465feea97654ccd669", + "wasm64": "e5baa5528f5e721f9dc1bf38bf094a24fc27449ae7e789d64d7f4177de8f6376" } }, "zip": { "manifestSha256": "9c9cfa8220aaeaea26736b55f7cd0a7517b8853c07f9f0d241ad67dd43592e36", "cacheKeys": { - "wasm32": "5c25b080333ffac7a5271e6c17b3c361d325677272a7e7fca7a4b42cddb0ce16", - "wasm64": "84fc6fb4e6b10e67110c4035eb6703d13f04798c2709e6d8d69e760dec22e0bf" + "wasm32": "f40c84abbfaae6bb7840287ffb3bd48fe6be4d78901e7e2332160d2d9469a7f3", + "wasm64": "9a8f7a10ce5db9462261d761a0d414702ca58dc216c57df09ab74c335b724ead" } }, "zlib": { "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", "cacheKeys": { - "wasm32": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a", - "wasm64": "4d5e9bd3f10d4e64da3f1ebe9438ee86378bac317b988691b0f7366043712a00" + "wasm32": "3811d90c0d4fb6bb5979b2abdde1cb8881f7f423f109533c63352efcb26595a0", + "wasm64": "8dff5fbd3cba103813a86da06debc9ed41917b94322d0cd72dcae94f5ff84df7" } }, "zstd": { "manifestSha256": "89f266938c2c253700a838e6352c9de66c976c84883325aaa979f72b732357e4", "cacheKeys": { - "wasm32": "9009660cf423b4f45a88bc99c6b37e5759661a82c64e9c89302b69c219efce49", - "wasm64": "c4809ac5756cb76ad523fad77b67fdfde31775b7c8c3bac1c8f825b7ebb591ad" + "wasm32": "42018146f71e4329d5db3f6a3b702a78bfefc5748d9206d091cd3f32eb724344", + "wasm64": "41d72e17c425d1f8e69c514ec85791de66cfaee8272f86e34b64eea13d08c932" } } }, @@ -583,14 +583,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "6a7e8445d507d03454d502f096fae6fd4d838b47f525b5b3a76f6341981a352f" + "wasm32": "ff050231b36c34eefa7fa3023ee401aeea4b6fe5712d5da003df026a76408e76" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "30d07b8914e93d0e38c48770aed2ac2938f6076b03e9d7f65dea597b04efd908", - "cacheKey": "f59cd67650e79a30dd490e8e78b14089871ca4d05acbd7ef5cab5ed02c3196aa" + "cacheKey": "cdaa5424668b350e485717fa84d8f49f5c5062b91e5073c02eb0a9d5828a6d67" } ] }, @@ -610,7 +610,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "6833fd5275f1378837506fbbcd3db757a19d4ac6ea9081212bdc4150a64408f0" + "wasm32": "6ff78d52e845671fd2419cc479a8b890c25440b8692eb179093fef6f416c1dbe" }, "dependencyClosures": { "wasm32": [] @@ -631,7 +631,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "480102a8e3940e1eb894a1b9767efa89a359bbe93a61ae23dec2c12319a33447" + "wasm32": "5aae9d6e520de28012cd03c4d86e1725e7f6ec19c51685db65d7ba0e8aa3589b" }, "dependencyClosures": { "wasm32": [] @@ -652,7 +652,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ffbcd2c6b9df24acb01de4da66c4167c07556aac83a2891ae182a934bb9ceeea" + "wasm32": "8936299a107eb443d6256aecfb2cae70f8398fe8e5815899f80205b87fcbfbff" }, "dependencyClosures": { "wasm32": [] @@ -673,14 +673,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "8e36bc988b0f8fa0130bf7696b73a81b018e8704acc66005431b25ace7fbe048" + "wasm32": "c65a7f719520e003d5c2f06380dbbe8bd62a31a0f45829163a967331440ebd74" }, "dependencyClosures": { "wasm32": [ { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a" + "cacheKey": "3811d90c0d4fb6bb5979b2abdde1cb8881f7f423f109533c63352efcb26595a0" } ] }, @@ -707,19 +707,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "9c4a4a28c7644128e9d95ff53840d4a08f8727394dcd1248e18c6bf7235521f5" + "wasm32": "7df4bf731deed6b17465de754f198c7572702c2814003a85f98602b8ecc67c4a" }, "dependencyClosures": { "wasm32": [ { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "94d90e4c611c3ef8174635182f27493ec8f3c832861ae9a2428b1a35736aa22c" + "cacheKey": "f488a967be488932c7ecdc0632bd3bdbf426d07cd1cf8046bd501fd9258232a6" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a" + "cacheKey": "3811d90c0d4fb6bb5979b2abdde1cb8881f7f423f109533c63352efcb26595a0" } ] }, @@ -739,7 +739,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ff2bca584a197d92da417221bcd6a92171140b030ade50108eaa244905bf88f0" + "wasm32": "b169d84aaead6d980d3819569bc405b0733be8dd9f8c72154257c25883aea337" }, "dependencyClosures": { "wasm32": [] @@ -760,7 +760,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "688e551eb9e8c698148a45d1c59efb2bf13120d54dc9516289395d2b73a7ff9e" + "wasm32": "3367d5b618dc7949562d1ff8a9b7c0423f53c6dce0970cc824513a0fb0da54ef" }, "dependencyClosures": { "wasm32": [] @@ -802,14 +802,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f7abd4cc88bcb4f9bf2e01872fa181c8ac84db6621da3e2c1a291f32c65f27ed" + "wasm32": "a8d93a950dd2e2a1bb8b6c19f1323e605f14de305fea7cbb31595dabc6dc00a5" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" + "cacheKey": "78a34cecb5b6ee33cab8ad5f48b6c9c63bb26c7f981c5ef4ddff95752194b0fc" } ] }, @@ -843,7 +843,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e6f453949d018258bdefcf7dfa503e8b25ad6422635c57540e92b9c4a26223a0" + "wasm32": "aca67e055955e7871c9d55da3d99b6e3ea97c2250fed03303739f64e628f1dca" }, "dependencyClosures": { "wasm32": [] @@ -871,14 +871,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2be200a6d32d1fd561bbce87700eb46d90b7e91684f69c359f3e45b1b092f967" + "wasm32": "3cc9af6c89dcc82851f15a4dccbd54c05b70abd4a2c3f47d1e60da8250005421" }, "dependencyClosures": { "wasm32": [ { "packageName": "erlang", "manifestSha256": "475d6037e73a0f1e2f4381067194b2422751206979ea17209e10efa5a2a93bbb", - "cacheKey": "e6f453949d018258bdefcf7dfa503e8b25ad6422635c57540e92b9c4a26223a0" + "cacheKey": "aca67e055955e7871c9d55da3d99b6e3ea97c2250fed03303739f64e628f1dca" } ] }, @@ -898,7 +898,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f50b05c1d725e28d65baf028956336614efd4e495c0c7646e3a85855b0cb9e21" + "wasm32": "2b011c8db4d3ff2330035b1f0d18e98d9c08066dc0de4c9649b072ccd45dd2ff" }, "dependencyClosures": { "wasm32": [] @@ -919,7 +919,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "306f637580638cf8c248f3cf9b15469d526030020ff04820ce60bd188b54ef80" + "wasm32": "c57a1bc8b345b6fd6a55a2a1ca867958e44938c0e535d2549ca94ca80ea23881" }, "dependencyClosures": { "wasm32": [] @@ -947,7 +947,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "96312ac7e9087405539527a9c1e119db733e0f46395b9ecadb30c77f3eb83057" + "wasm32": "8c5a4343baeabe77dfb9aa2edbf0d07caab41b2f2c3529a4195d4781a3b75dfd" }, "dependencyClosures": { "wasm32": [] @@ -975,7 +975,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ea31e40a17bccfd311032d3e052f3a6707745b000f8303be9e72072628a1572a" + "wasm32": "79d2a540b97c5b3182a8967b02fa49351d05532688d6486a99fb16eaf5018780" }, "dependencyClosures": { "wasm32": [] @@ -996,7 +996,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a545be3bb60a666a7e6c7e130b91c37178b7c0a5bdf875d6b249deae3a7f1444" + "wasm32": "c698f25d62f1bc6da29642a61a0e10057d14cd5fbfaa50ed8c60f461c4442df9" }, "dependencyClosures": { "wasm32": [] @@ -1024,7 +1024,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "68e8a6c294322d4f8bfc42c146bb4be5c47ff72b3fcf76e7423061a0117a29a2" + "wasm32": "386a56b5f9011bfa0750872abb717a165459d968bcfcc67f2c43eecfe359adde" }, "dependencyClosures": { "wasm32": [] @@ -1045,7 +1045,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "1a49313a1bd83c8b35f8c9b2d7874da4c572b48275dfce6d37a647935879a941" + "wasm32": "7b0b85b3405959d80da6399e5dfcf42b78a50a00414a44dbb0a5b92eb3b4530c" }, "dependencyClosures": { "wasm32": [] @@ -1066,7 +1066,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "6adc7842ddf5af1df0d21e89a6bd1fae1e9572dc61f1a3f48c9908c71b3c5d2b" + "wasm32": "4174e87f3caf6702801f188f5b271022ce506c99db12d24f8c55957dd81413e7" }, "dependencyClosures": { "wasm32": [] @@ -1094,14 +1094,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "4838701321647a5e4869af903664a7b5cb5cb436a1a220b132423881bd77c8a7" + "wasm32": "4f012b0777ac9997653e7e957dbd16ad8850ff627d62e8c5f15492daf863c1d4" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" + "cacheKey": "78a34cecb5b6ee33cab8ad5f48b6c9c63bb26c7f981c5ef4ddff95752194b0fc" } ] }, @@ -1121,64 +1121,64 @@ "wasm32" ], "cacheKeys": { - "wasm32": "23c6df992f9aa1e71eafb0b6fa2828fbb6c4ce1eca8c6eb7cef4011ad090a784" + "wasm32": "4a2ff1d92355b154804533575d429d6c1028830a5891008c5b58f54d501ffe76" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "f7abd4cc88bcb4f9bf2e01872fa181c8ac84db6621da3e2c1a291f32c65f27ed" + "cacheKey": "a8d93a950dd2e2a1bb8b6c19f1323e605f14de305fea7cbb31595dabc6dc00a5" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "bf454728f58a75797a9d52c8c6d92c6a52c67c377be31c0253fb2a18883556d6" + "cacheKey": "fc36cf8e40047f380fc8935f764c623d8ac1ba2f2d5e57b41de46707a1be4222" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "2a54294aa012c4a47fb5b540f49dacacd83725735c4db8ba3746fc4177146a6a" + "cacheKey": "f16aac6b14f45497f3be37247041fdae68c3ab3b312443972623d69ca93c43f0" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" + "cacheKey": "78a34cecb5b6ee33cab8ad5f48b6c9c63bb26c7f981c5ef4ddff95752194b0fc" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "9824449919005d0862b1f40b310195b2bf5657733469a3b1aac4b94396ffc669" + "cacheKey": "1bfd43d95f2adc7a4243f5215bfbda8fc7d1dca6ed05f78a5726a577ec20b6bc" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "966012e82a47f8afa17683175087d63b7fe7e191c770f14a62d82366e13ba959" + "cacheKey": "f71f57a69a6b05752c40a1928937ea7aa259bccc4e74591577e8d328b42d2b0d" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "5cb2f96b95a2fcd0618db09f8393d8e2bd6a40617cb2b35f57667a482c671739" + "cacheKey": "e57c568dfc864238b7e26f32fcc3c45a7ba84c754f5d03acdec5e3644c65f198" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "6d9b1c0dd88cc10290b63df2ab1720940ae58285e2745c91e6695b7b85a703a0" + "cacheKey": "98c4098ba11e6be12f4c794128ff1e033923d28b070f446270cecc386bcadce9" }, { "packageName": "msmtpd", - "manifestSha256": "09de04a422ddf631a29a259d816e081f8d317d1077808ede55d8c500baae748f", - "cacheKey": "ca4d269e24beb373937cfffa3a5a70be1c26f67b4afa19128d697ad9cdced81f" + "manifestSha256": "686ff665b5e7907e67618790b1a3eddce2ff47ed665595d17209bdcda1e0b274", + "cacheKey": "0b756d4bc1ef5f2566e19bfd7b02569c37ba60ed53972666ae46ddfab63f01dc" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "2b5174ccf228be9d359389d8c2435f0cd34feebc66e57966558902f72682996d" + "cacheKey": "efa78ce5711e440d8db6bdfb837ef3749c67ce2401b7cb9acd6915090a564c41" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "94d90e4c611c3ef8174635182f27493ec8f3c832861ae9a2428b1a35736aa22c" + "cacheKey": "f488a967be488932c7ecdc0632bd3bdbf426d07cd1cf8046bd501fd9258232a6" }, { "packageName": "pcre2-source", @@ -1188,22 +1188,22 @@ { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "78e404c3a0b1ad280ea8c08a87d9d2d8b69509590da06878f4138ad71fc9d92e" + "cacheKey": "fd181cf83fe71c0e34c748144313f50dc038860146dee94411f0a5af93725949" }, { "packageName": "shell", - "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "f16a327ad8a9d6e1ccd13b450551b47869d9e56fbdf9548e545a4d3dde47b371" + "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", + "cacheKey": "fc799ce2e4bc4dd48cf320f5dfe468e6b20fc6e7aa10feb833c1638d8c645e37" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "590f8dae0ef8972831a64291326763a1e9fa3697b144daa99d2d2d13cb91192d" + "cacheKey": "9314f02cc5ff7e7f4d4db1b5122e99ff0683a0b1ec68cf4e73eaf723d8751d35" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a" + "cacheKey": "3811d90c0d4fb6bb5979b2abdde1cb8881f7f423f109533c63352efcb26595a0" } ] }, @@ -1223,7 +1223,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "10bfdfc7cb4ee67172ac4c91545c2b0e00282be1f740b55aff5f4f3bd75274bf" + "wasm32": "5800ac25c02cebe20b4f41a5aab4441d008b0ad6349a3b207938a895335c40c3" }, "dependencyClosures": { "wasm32": [] @@ -1244,7 +1244,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "7b9f79de5b4f23e236e29a1f4a809e586d225478e49ae27a891a4f021ade9cdc" + "wasm32": "20f5f635d926e5f1b4d9ec6b1a62ca79993b1ec9501fa72cb517b3d2d98808c6" }, "dependencyClosures": { "wasm32": [] @@ -1265,7 +1265,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "9413a8cb9d531fab82b389c7172bf1d59e3feefb5adcda8504d26934846f095e" + "wasm32": "41abba58c9e3c73a7b14d9158b995a89228045bd911cbe0835cef45bb79d975a" }, "dependencyClosures": { "wasm32": [] @@ -1286,7 +1286,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a95e8bfb3c1d51b81b6de917d410ab6677c2a2c25c1d5301654c796e331e47d9" + "wasm32": "25c88f3dc9605627ad1295f665fe825918c6db7b4645d6cdb3c60ad8e4a2de47" }, "dependencyClosures": { "wasm32": [] @@ -1308,15 +1308,15 @@ "wasm64" ], "cacheKeys": { - "wasm32": "6d9b1c0dd88cc10290b63df2ab1720940ae58285e2745c91e6695b7b85a703a0", - "wasm64": "3f181cf9f51770f7deda50a72a1d8e8b4c38d515aec4485ec3934e429ba42344" + "wasm32": "98c4098ba11e6be12f4c794128ff1e033923d28b070f446270cecc386bcadce9", + "wasm64": "33dec27e21e5c59923d487ddca6adab373e6f36a9c10ea8bfb3e71df4b001181" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" + "cacheKey": "78a34cecb5b6ee33cab8ad5f48b6c9c63bb26c7f981c5ef4ddff95752194b0fc" }, { "packageName": "pcre2-source", @@ -1328,7 +1328,7 @@ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "f828b2c8c1b35ffcc95ed9b419b93b89133eb03ebe426e87e946889d5abea144" + "cacheKey": "6522456735e14d4657ab0b42f3d6d955be795d18da4c951bb7dfb9fe3df7863e" }, { "packageName": "pcre2-source", @@ -1360,34 +1360,34 @@ "wasm32" ], "cacheKeys": { - "wasm32": "1bfee2a38b4585a79ccf13385402b26336d471cbb83bfdbea55457d2918eee7a" + "wasm32": "1d38fb8cdd93e5f959f8d4b4885c5f7b1e2b997f3997770530b5f1443568ca55" }, "dependencyClosures": { "wasm32": [ { "packageName": "coreutils", - "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "ffbcd2c6b9df24acb01de4da66c4167c07556aac83a2891ae182a934bb9ceeea" + "manifestSha256": "72fc9c3818fb11cebaa047a623743252395dc729163fa804a7272d59f87fe82d", + "cacheKey": "8936299a107eb443d6256aecfb2cae70f8398fe8e5815899f80205b87fcbfbff" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "ff2bca584a197d92da417221bcd6a92171140b030ade50108eaa244905bf88f0" + "cacheKey": "b169d84aaead6d980d3819569bc405b0733be8dd9f8c72154257c25883aea337" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "f7abd4cc88bcb4f9bf2e01872fa181c8ac84db6621da3e2c1a291f32c65f27ed" + "cacheKey": "a8d93a950dd2e2a1bb8b6c19f1323e605f14de305fea7cbb31595dabc6dc00a5" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" + "cacheKey": "78a34cecb5b6ee33cab8ad5f48b6c9c63bb26c7f981c5ef4ddff95752194b0fc" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "6d9b1c0dd88cc10290b63df2ab1720940ae58285e2745c91e6695b7b85a703a0" + "cacheKey": "98c4098ba11e6be12f4c794128ff1e033923d28b070f446270cecc386bcadce9" }, { "packageName": "pcre2-source", @@ -1413,35 +1413,35 @@ "wasm64" ], "cacheKeys": { - "wasm32": "3fd1adb0598b2c2c061d9985786dee0769d61e71f6acacec39b6a4ce6f4bce0a", - "wasm64": "cefbefb55d6a9238d88f7c40d94be06dcb8ae1ec2846fd4eb5ba5e37e175aa04" + "wasm32": "667a655877ef5f24966c54e959b86c5276504fc53de5ea8fec26d8e0c7e2e116", + "wasm64": "7e8dbe962001e74e218304cd4e6318d260274a47fbb542cfc8271cbe00023ef2" }, "dependencyClosures": { "wasm32": [ { "packageName": "coreutils", - "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "ffbcd2c6b9df24acb01de4da66c4167c07556aac83a2891ae182a934bb9ceeea" + "manifestSha256": "72fc9c3818fb11cebaa047a623743252395dc729163fa804a7272d59f87fe82d", + "cacheKey": "8936299a107eb443d6256aecfb2cae70f8398fe8e5815899f80205b87fcbfbff" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "ff2bca584a197d92da417221bcd6a92171140b030ade50108eaa244905bf88f0" + "cacheKey": "b169d84aaead6d980d3819569bc405b0733be8dd9f8c72154257c25883aea337" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "f7abd4cc88bcb4f9bf2e01872fa181c8ac84db6621da3e2c1a291f32c65f27ed" + "cacheKey": "a8d93a950dd2e2a1bb8b6c19f1323e605f14de305fea7cbb31595dabc6dc00a5" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" + "cacheKey": "78a34cecb5b6ee33cab8ad5f48b6c9c63bb26c7f981c5ef4ddff95752194b0fc" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "6d9b1c0dd88cc10290b63df2ab1720940ae58285e2745c91e6695b7b85a703a0" + "cacheKey": "98c4098ba11e6be12f4c794128ff1e033923d28b070f446270cecc386bcadce9" }, { "packageName": "pcre2-source", @@ -1452,28 +1452,28 @@ "wasm64": [ { "packageName": "coreutils", - "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "8079ebf92c30022c0ee2577221b641128fb0265c2530f45bc52612ff717144a0" + "manifestSha256": "72fc9c3818fb11cebaa047a623743252395dc729163fa804a7272d59f87fe82d", + "cacheKey": "c7116594f2d0d99d4a1f33ac4bd8b9ec895cb2c8b471adac7e4b70a03fff2de2" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "2d1fef92952071c38c2f7758c5a2313dc664966a4bd081820ed07e885aec2383" + "cacheKey": "2eedf8b7b4f5d721c8e20475f0f41db94964160b4307c502558ddab3c4b896ca" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "1fefd32aa29b1b65a011d4466550ba7f13da0029b7dc58fae072d5a0a756d842" + "cacheKey": "4fe113b0334f8719ca1490220064024b42c0e8a47196461f6111d187c5e853ac" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "f828b2c8c1b35ffcc95ed9b419b93b89133eb03ebe426e87e946889d5abea144" + "cacheKey": "6522456735e14d4657ab0b42f3d6d955be795d18da4c951bb7dfb9fe3df7863e" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "3f181cf9f51770f7deda50a72a1d8e8b4c38d515aec4485ec3934e429ba42344" + "cacheKey": "33dec27e21e5c59923d487ddca6adab373e6f36a9c10ea8bfb3e71df4b001181" }, { "packageName": "pcre2-source", @@ -1498,7 +1498,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2478e94c90c8c0ba8a043dd9b527493df59d678a4323f6f879d6ff819921802d" + "wasm32": "50e057ad41d9fecde98fe9ef05f41c8705f6ee7eab42321fd3092a45ef6fb8b5" }, "dependencyClosures": { "wasm32": [] @@ -1519,7 +1519,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ca4d269e24beb373937cfffa3a5a70be1c26f67b4afa19128d697ad9cdced81f" + "wasm32": "0b756d4bc1ef5f2566e19bfd7b02569c37ba60ed53972666ae46ddfab63f01dc" }, "dependencyClosures": { "wasm32": [] @@ -1540,7 +1540,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "1b0237e997d82bafe853ec28192c4b5a310331ce814f6cdfd4301c14290f54bb" + "wasm32": "3371556242b878af678fea16d50c78738f8bfe68f732996ffea128ee609df3d3" }, "dependencyClosures": { "wasm32": [] @@ -1561,7 +1561,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f59cd67650e79a30dd490e8e78b14089871ca4d05acbd7ef5cab5ed02c3196aa" + "wasm32": "cdaa5424668b350e485717fa84d8f49f5c5062b91e5073c02eb0a9d5828a6d67" }, "dependencyClosures": { "wasm32": [] @@ -1645,7 +1645,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "bda506d821b1485688ab5f601bd28b53cfea8675f31fe5a617f62a89ef34808d" + "wasm32": "aa5912ccc0216690d8f49ed186928796bc4c2bcc3ac13109cea5430012433b08" }, "dependencyClosures": { "wasm32": [] @@ -1666,14 +1666,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "3414337607f4efe93fd212f17afda5d3160e3aa05141944c110b6960827652e3" + "wasm32": "7b929ba9eb21731309ad5d3a4bc134d71242010331b0f127eab94b3fbb00f411" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "30d07b8914e93d0e38c48770aed2ac2938f6076b03e9d7f65dea597b04efd908", - "cacheKey": "f59cd67650e79a30dd490e8e78b14089871ca4d05acbd7ef5cab5ed02c3196aa" + "cacheKey": "cdaa5424668b350e485717fa84d8f49f5c5062b91e5073c02eb0a9d5828a6d67" } ] }, @@ -1693,19 +1693,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "3d63f90f5885cd694f54d200a06042a2547610c2681ee32eb0ee051b7d731c4d" + "wasm32": "761d159033c24a22fe0c9cac0fa02a99850e13b7686c03264523e892a2765ed3" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", "manifestSha256": "30d07b8914e93d0e38c48770aed2ac2938f6076b03e9d7f65dea597b04efd908", - "cacheKey": "f59cd67650e79a30dd490e8e78b14089871ca4d05acbd7ef5cab5ed02c3196aa" + "cacheKey": "cdaa5424668b350e485717fa84d8f49f5c5062b91e5073c02eb0a9d5828a6d67" }, { "packageName": "nethack", "manifestSha256": "1a2f12ec2770bd8d40006d6c878a3493b97c71c2bb63ad08c7f6ad99bfd53504", - "cacheKey": "3414337607f4efe93fd212f17afda5d3160e3aa05141944c110b6960827652e3" + "cacheKey": "7b929ba9eb21731309ad5d3a4bc134d71242010331b0f127eab94b3fbb00f411" } ] }, @@ -1725,7 +1725,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2b5174ccf228be9d359389d8c2435f0cd34feebc66e57966558902f72682996d" + "wasm32": "efa78ce5711e440d8db6bdfb837ef3749c67ce2401b7cb9acd6915090a564c41" }, "dependencyClosures": { "wasm32": [] @@ -1746,79 +1746,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "efb105c205f0fedc92db8ddd3abfc01e6093488c323228c4af0e574442de6169" + "wasm32": "d085274e8ac987640fa28ef533f18622767db4ed9ac90ddb9c2356b1fbf7c4d9" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "f7abd4cc88bcb4f9bf2e01872fa181c8ac84db6621da3e2c1a291f32c65f27ed" + "cacheKey": "a8d93a950dd2e2a1bb8b6c19f1323e605f14de305fea7cbb31595dabc6dc00a5" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "bf454728f58a75797a9d52c8c6d92c6a52c67c377be31c0253fb2a18883556d6" + "cacheKey": "fc36cf8e40047f380fc8935f764c623d8ac1ba2f2d5e57b41de46707a1be4222" }, { "packageName": "kernel", "manifestSha256": "db1d66db8575562ac7b3e720dbaa06d7dd5eef577d80220ea4167dc2d69b863b", - "cacheKey": "e59c55a65d4fc36dc5a5fb9a37db0f87290318a4137ecdbc833f7e7a4a8e8325" + "cacheKey": "c5d9215ae53bf18cbbfa612dd48ab285e2b8f29d0bab2d469111fba5c13d4e76" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "2a54294aa012c4a47fb5b540f49dacacd83725735c4db8ba3746fc4177146a6a" + "cacheKey": "f16aac6b14f45497f3be37247041fdae68c3ab3b312443972623d69ca93c43f0" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" + "cacheKey": "78a34cecb5b6ee33cab8ad5f48b6c9c63bb26c7f981c5ef4ddff95752194b0fc" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "9824449919005d0862b1f40b310195b2bf5657733469a3b1aac4b94396ffc669" + "cacheKey": "1bfd43d95f2adc7a4243f5215bfbda8fc7d1dca6ed05f78a5726a577ec20b6bc" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "966012e82a47f8afa17683175087d63b7fe7e191c770f14a62d82366e13ba959" + "cacheKey": "f71f57a69a6b05752c40a1928937ea7aa259bccc4e74591577e8d328b42d2b0d" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "5cb2f96b95a2fcd0618db09f8393d8e2bd6a40617cb2b35f57667a482c671739" + "cacheKey": "e57c568dfc864238b7e26f32fcc3c45a7ba84c754f5d03acdec5e3644c65f198" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "2b5174ccf228be9d359389d8c2435f0cd34feebc66e57966558902f72682996d" + "cacheKey": "efa78ce5711e440d8db6bdfb837ef3749c67ce2401b7cb9acd6915090a564c41" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "94d90e4c611c3ef8174635182f27493ec8f3c832861ae9a2428b1a35736aa22c" + "cacheKey": "f488a967be488932c7ecdc0632bd3bdbf426d07cd1cf8046bd501fd9258232a6" }, { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "78e404c3a0b1ad280ea8c08a87d9d2d8b69509590da06878f4138ad71fc9d92e" + "cacheKey": "fd181cf83fe71c0e34c748144313f50dc038860146dee94411f0a5af93725949" }, { "packageName": "shell", - "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "f16a327ad8a9d6e1ccd13b450551b47869d9e56fbdf9548e545a4d3dde47b371" + "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", + "cacheKey": "fc799ce2e4bc4dd48cf320f5dfe468e6b20fc6e7aa10feb833c1638d8c645e37" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "590f8dae0ef8972831a64291326763a1e9fa3697b144daa99d2d2d13cb91192d" + "cacheKey": "9314f02cc5ff7e7f4d4db1b5122e99ff0683a0b1ec68cf4e73eaf723d8751d35" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a" + "cacheKey": "3811d90c0d4fb6bb5979b2abdde1cb8881f7f423f109533c63352efcb26595a0" } ] }, @@ -1838,29 +1838,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b08f76f96cc69cc2d5c01469f095fcfd60d8f70787a6139f1de23aa25aac6702" + "wasm32": "0aa03d1ecfdd04959b5bedf68125fe9d7c1bd6a9f7b7818a9385758c34a4b256" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "f7abd4cc88bcb4f9bf2e01872fa181c8ac84db6621da3e2c1a291f32c65f27ed" + "cacheKey": "a8d93a950dd2e2a1bb8b6c19f1323e605f14de305fea7cbb31595dabc6dc00a5" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" + "cacheKey": "78a34cecb5b6ee33cab8ad5f48b6c9c63bb26c7f981c5ef4ddff95752194b0fc" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "2b5174ccf228be9d359389d8c2435f0cd34feebc66e57966558902f72682996d" + "cacheKey": "efa78ce5711e440d8db6bdfb837ef3749c67ce2401b7cb9acd6915090a564c41" }, { "packageName": "shell", - "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "f16a327ad8a9d6e1ccd13b450551b47869d9e56fbdf9548e545a4d3dde47b371" + "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", + "cacheKey": "fc799ce2e4bc4dd48cf320f5dfe468e6b20fc6e7aa10feb833c1638d8c645e37" } ] }, @@ -1880,29 +1880,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c17d3a4e332092ef2176fe855387810799527eb2aca0de81f960f6d97cb74cb4" + "wasm32": "b55fef6acf129e5a0f15260103604df029abaf4474250b2097f4d8b3bd47b6a3" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" + "cacheKey": "78a34cecb5b6ee33cab8ad5f48b6c9c63bb26c7f981c5ef4ddff95752194b0fc" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "94d90e4c611c3ef8174635182f27493ec8f3c832861ae9a2428b1a35736aa22c" + "cacheKey": "f488a967be488932c7ecdc0632bd3bdbf426d07cd1cf8046bd501fd9258232a6" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "5c6935fabfdc6a8cc1ce812a87cf61d19df14a268e9e6ce33dced6e106ccc145" + "cacheKey": "75ea71776150b11294eafaa89f8859d9751df45c798234e3e46026946b995a3e" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a" + "cacheKey": "3811d90c0d4fb6bb5979b2abdde1cb8881f7f423f109533c63352efcb26595a0" } ] }, @@ -1922,39 +1922,39 @@ "wasm32" ], "cacheKeys": { - "wasm32": "7e720e5ed98fbfe732da2f89c99b6d6247ffcd6f0135a456685881a315060ea1" + "wasm32": "5c3fbda89cedfcb56e4b7ed5cd83d954782c91551a0bd3094a538f875fb283a4" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" + "cacheKey": "78a34cecb5b6ee33cab8ad5f48b6c9c63bb26c7f981c5ef4ddff95752194b0fc" }, { "packageName": "node", "manifestSha256": "2131a24dfda8587860e57fc65b573086477d376b065b3b9ca4e1dfe4d79f5790", - "cacheKey": "c17d3a4e332092ef2176fe855387810799527eb2aca0de81f960f6d97cb74cb4" + "cacheKey": "b55fef6acf129e5a0f15260103604df029abaf4474250b2097f4d8b3bd47b6a3" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "94d90e4c611c3ef8174635182f27493ec8f3c832861ae9a2428b1a35736aa22c" + "cacheKey": "f488a967be488932c7ecdc0632bd3bdbf426d07cd1cf8046bd501fd9258232a6" }, { "packageName": "shell", - "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "f16a327ad8a9d6e1ccd13b450551b47869d9e56fbdf9548e545a4d3dde47b371" + "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", + "cacheKey": "fc799ce2e4bc4dd48cf320f5dfe468e6b20fc6e7aa10feb833c1638d8c645e37" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "5c6935fabfdc6a8cc1ce812a87cf61d19df14a268e9e6ce33dced6e106ccc145" + "cacheKey": "75ea71776150b11294eafaa89f8859d9751df45c798234e3e46026946b995a3e" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a" + "cacheKey": "3811d90c0d4fb6bb5979b2abdde1cb8881f7f423f109533c63352efcb26595a0" } ] }, @@ -1974,7 +1974,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "20e34f8efc078aa6a8d7c1d23a186102bb8a3ad6a1e98cd0adb09cb1b6183045" + "wasm32": "b35bc1d4d55103e7fd734aeff693ff247fb1d082aa505ba1f3391eb0a69cd5d5" }, "dependencyClosures": { "wasm32": [] @@ -1995,14 +1995,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c0c99b86a9b34f649f7ecb4eb07caa16ee505c1ae292ee57a510a785a18da25d" + "wasm32": "177128411944f715056be286049f24e8f133f8156d14789219e954d9b707d91c" }, "dependencyClosures": { "wasm32": [ { "packageName": "perl", "manifestSha256": "6cdc4dbc54d0e4008cff41f82ec8918c0e44b4aef23903539c1bc0f538fe3ad2", - "cacheKey": "20e34f8efc078aa6a8d7c1d23a186102bb8a3ad6a1e98cd0adb09cb1b6183045" + "cacheKey": "b35bc1d4d55103e7fd734aeff693ff247fb1d082aa505ba1f3391eb0a69cd5d5" } ] }, @@ -2022,54 +2022,54 @@ "wasm32" ], "cacheKeys": { - "wasm32": "78e404c3a0b1ad280ea8c08a87d9d2d8b69509590da06878f4138ad71fc9d92e" + "wasm32": "fd181cf83fe71c0e34c748144313f50dc038860146dee94411f0a5af93725949" }, "dependencyClosures": { "wasm32": [ { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "bf454728f58a75797a9d52c8c6d92c6a52c67c377be31c0253fb2a18883556d6" + "cacheKey": "fc36cf8e40047f380fc8935f764c623d8ac1ba2f2d5e57b41de46707a1be4222" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "2a54294aa012c4a47fb5b540f49dacacd83725735c4db8ba3746fc4177146a6a" + "cacheKey": "f16aac6b14f45497f3be37247041fdae68c3ab3b312443972623d69ca93c43f0" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" + "cacheKey": "78a34cecb5b6ee33cab8ad5f48b6c9c63bb26c7f981c5ef4ddff95752194b0fc" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "9824449919005d0862b1f40b310195b2bf5657733469a3b1aac4b94396ffc669" + "cacheKey": "1bfd43d95f2adc7a4243f5215bfbda8fc7d1dca6ed05f78a5726a577ec20b6bc" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "966012e82a47f8afa17683175087d63b7fe7e191c770f14a62d82366e13ba959" + "cacheKey": "f71f57a69a6b05752c40a1928937ea7aa259bccc4e74591577e8d328b42d2b0d" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "5cb2f96b95a2fcd0618db09f8393d8e2bd6a40617cb2b35f57667a482c671739" + "cacheKey": "e57c568dfc864238b7e26f32fcc3c45a7ba84c754f5d03acdec5e3644c65f198" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "94d90e4c611c3ef8174635182f27493ec8f3c832861ae9a2428b1a35736aa22c" + "cacheKey": "f488a967be488932c7ecdc0632bd3bdbf426d07cd1cf8046bd501fd9258232a6" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "590f8dae0ef8972831a64291326763a1e9fa3697b144daa99d2d2d13cb91192d" + "cacheKey": "9314f02cc5ff7e7f4d4db1b5122e99ff0683a0b1ec68cf4e73eaf723d8751d35" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a" + "cacheKey": "3811d90c0d4fb6bb5979b2abdde1cb8881f7f423f109533c63352efcb26595a0" } ] }, @@ -2145,7 +2145,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "91eedb0fa500deef8ef48023d799915f49b9fe9427c610bc06f46afa9847c60a" + "wasm32": "353dce9093050c8c2d3acc5c36b2e93e3dd90c0c50ecad74dac2a5a031c359e7" }, "dependencyClosures": { "wasm32": [] @@ -2418,19 +2418,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "edc1345ac416c098b008479c66219615f00e2c5915bf9229ddb26acbac50ed1e" + "wasm32": "180de3d93a88eef4e6022add381cf08e47f45df52dcfb30724349ff00e885346" }, "dependencyClosures": { "wasm32": [ { "packageName": "cpython", "manifestSha256": "7dd4f446697a73941ec940c2ccba4d53be73fe6947f1e701031a4d0aa964c4ff", - "cacheKey": "8e36bc988b0f8fa0130bf7696b73a81b018e8704acc66005431b25ace7fbe048" + "cacheKey": "c65a7f719520e003d5c2f06380dbbe8bd62a31a0f45829163a967331440ebd74" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a" + "cacheKey": "3811d90c0d4fb6bb5979b2abdde1cb8881f7f423f109533c63352efcb26595a0" } ] }, @@ -2450,7 +2450,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "11d2132bfe4524b34e4acd17133601f861afdaae5dcd114285f415a9eeb50471" + "wasm32": "553bc34b468d0c07c1bff8e8bc97fff4cc8dd3edef53256750fb9f743c9ee61a" }, "dependencyClosures": { "wasm32": [] @@ -2478,24 +2478,24 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ab51911b630502a1b2cf7079963dc1e2a493dff850c5763379bfba81c8b25d8e" + "wasm32": "078e42a04c95b30426f72b456e8f73c2b1dc5880d3fd237d8c0e076aef327fa8" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "f7abd4cc88bcb4f9bf2e01872fa181c8ac84db6621da3e2c1a291f32c65f27ed" + "cacheKey": "a8d93a950dd2e2a1bb8b6c19f1323e605f14de305fea7cbb31595dabc6dc00a5" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" + "cacheKey": "78a34cecb5b6ee33cab8ad5f48b6c9c63bb26c7f981c5ef4ddff95752194b0fc" }, { "packageName": "redis", "manifestSha256": "151fb507de953ba2ba94b8f881bc7d66b4c741c1359a2f590cc07f9a4cd368a3", - "cacheKey": "11d2132bfe4524b34e4acd17133601f861afdaae5dcd114285f415a9eeb50471" + "cacheKey": "553bc34b468d0c07c1bff8e8bc97fff4cc8dd3edef53256750fb9f743c9ee61a" } ] }, @@ -2515,79 +2515,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "61706131cb8837a994c8b7fac6e172c94894a22bb1d2d2994d05f0984f5d70d4" + "wasm32": "4c1df30f8848368404a222b31a9d5c411b4a4b4689a7980036e4876f4d0cfd1a" }, "dependencyClosures": { "wasm32": [ { "packageName": "bash", - "manifestSha256": "6478060f28d430d18a6ebe7c603392d35a41d9bcf6bc74322351d1450b9c5335", - "cacheKey": "6a7e8445d507d03454d502f096fae6fd4d838b47f525b5b3a76f6341981a352f" + "manifestSha256": "5dc4d97dc4f154f265ff57e9d972e7d5a64a2e15fadedfa246733b830dda3497", + "cacheKey": "ff050231b36c34eefa7fa3023ee401aeea4b6fe5712d5da003df026a76408e76" }, { "packageName": "bc", - "manifestSha256": "a65661463bb7047b91ff00153bd99fd962c96e571934ab4e923f5215fb6ecfbd", - "cacheKey": "6833fd5275f1378837506fbbcd3db757a19d4ac6ea9081212bdc4150a64408f0" + "manifestSha256": "43b61e92c29098720bc7f4871a3b611cc01ffd124024290d42967cebb6ffd459", + "cacheKey": "6ff78d52e845671fd2419cc479a8b890c25440b8692eb179093fef6f416c1dbe" }, { "packageName": "coreutils", - "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "ffbcd2c6b9df24acb01de4da66c4167c07556aac83a2891ae182a934bb9ceeea" + "manifestSha256": "72fc9c3818fb11cebaa047a623743252395dc729163fa804a7272d59f87fe82d", + "cacheKey": "8936299a107eb443d6256aecfb2cae70f8398fe8e5815899f80205b87fcbfbff" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "ff2bca584a197d92da417221bcd6a92171140b030ade50108eaa244905bf88f0" + "cacheKey": "b169d84aaead6d980d3819569bc405b0733be8dd9f8c72154257c25883aea337" }, { "packageName": "diffutils", - "manifestSha256": "3a78f0a46bae43ce5ea235c6638c2cbd1d8559b0b62b1fb42443b35968c1aca3", - "cacheKey": "688e551eb9e8c698148a45d1c59efb2bf13120d54dc9516289395d2b73a7ff9e" + "manifestSha256": "2286203eb762eca487939963e38ea1b901ac2dc9a830d3260c2fb291757df208", + "cacheKey": "3367d5b618dc7949562d1ff8a9b7c0423f53c6dce0970cc824513a0fb0da54ef" }, { "packageName": "file", "manifestSha256": "7874c1affcbbf2c8ab8c8d4beb087f275af57b5caae6a8947bf5a63a181552b2", - "cacheKey": "306f637580638cf8c248f3cf9b15469d526030020ff04820ce60bd188b54ef80" + "cacheKey": "c57a1bc8b345b6fd6a55a2a1ca867958e44938c0e535d2549ca94ca80ea23881" }, { "packageName": "findutils", - "manifestSha256": "cceedf52aea67fbb0da03cfce6f2e9b1a7b5561fa1656c2762b43c651a625d02", - "cacheKey": "96312ac7e9087405539527a9c1e119db733e0f46395b9ecadb30c77f3eb83057" + "manifestSha256": "a9969cb102403cef105e758ef602692500fddd75ec96e4e3f168c50094b6c931", + "cacheKey": "8c5a4343baeabe77dfb9aa2edbf0d07caab41b2f2c3529a4195d4781a3b75dfd" }, { "packageName": "gawk", - "manifestSha256": "a2567b8b0778805e385e1d3a41ac8b5d74aaf9d293b222001b17d09b11e5a0ba", - "cacheKey": "ea31e40a17bccfd311032d3e052f3a6707745b000f8303be9e72072628a1572a" + "manifestSha256": "b788f3de724cd363ea68d08826586ddf544a75fb5dd9df1369ffafe06d8681b7", + "cacheKey": "79d2a540b97c5b3182a8967b02fa49351d05532688d6486a99fb16eaf5018780" }, { "packageName": "grep", - "manifestSha256": "59270d3bfb33167b32d13246bf855b49308f8c9c0a66d9635c804f702421fbc6", - "cacheKey": "68e8a6c294322d4f8bfc42c146bb4be5c47ff72b3fcf76e7423061a0117a29a2" + "manifestSha256": "7c61b894807b831c7e8816cb1f39c78e3a69a4ced2fa3d18f0abdf00bf18334b", + "cacheKey": "386a56b5f9011bfa0750872abb717a165459d968bcfcc67f2c43eecfe359adde" }, { "packageName": "m4", - "manifestSha256": "2c6582d99d6eabfb9da52badf49b899e9fdcba76a728536b25bc3741f3331543", - "cacheKey": "9413a8cb9d531fab82b389c7172bf1d59e3feefb5adcda8504d26934846f095e" + "manifestSha256": "9b5838bdde8531079f23a89e135aa3832bbbbb7ad6099dd1503db877d52afcad", + "cacheKey": "41abba58c9e3c73a7b14d9158b995a89228045bd911cbe0835cef45bb79d975a" }, { "packageName": "make", - "manifestSha256": "f878d2d730f36a4c6dfe1fff1b4ccc704757ea22d8c4c8ccb95b11cd641e5fed", - "cacheKey": "a95e8bfb3c1d51b81b6de917d410ab6677c2a2c25c1d5301654c796e331e47d9" + "manifestSha256": "62e8b36a6df7e981d191061a5bb7f2db85b2deaf365352a7d590a6c6c06f2b1e", + "cacheKey": "25c88f3dc9605627ad1295f665fe825918c6db7b4645d6cdb3c60ad8e4a2de47" }, { "packageName": "ncurses", "manifestSha256": "30d07b8914e93d0e38c48770aed2ac2938f6076b03e9d7f65dea597b04efd908", - "cacheKey": "f59cd67650e79a30dd490e8e78b14089871ca4d05acbd7ef5cab5ed02c3196aa" + "cacheKey": "cdaa5424668b350e485717fa84d8f49f5c5062b91e5073c02eb0a9d5828a6d67" }, { "packageName": "posix-utils-lite", "manifestSha256": "8fd7190b2848ef80143adc7b9268c79e13cd4db3430f7105faf49a4b4407a06f", - "cacheKey": "91eedb0fa500deef8ef48023d799915f49b9fe9427c610bc06f46afa9847c60a" + "cacheKey": "353dce9093050c8c2d3acc5c36b2e93e3dd90c0c50ecad74dac2a5a031c359e7" }, { "packageName": "sed", - "manifestSha256": "2a08e9c5dacc5facc8983c1ff35ff2a72db328405e9a1f464cc7ad155c4d08af", - "cacheKey": "968b56b98643a7cb8a7e11101ebb352c71044fbdccec6aac88768095e38b077b" + "manifestSha256": "0c0d82d53907c19825941501f833252cf9adf35ebcebf6786baae28e5eb6958b", + "cacheKey": "b08d6312fcb338691877cada2f8661a8d3386c98ba8eca7366170dea4df71704" } ] }, @@ -2607,14 +2607,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c990f064060821a8e076633a227c3e5cc1dc3e8c63710eb8121b0cc90fa7b1ae" + "wasm32": "5bf6e98736011288a06a97016d022190f2b63a151a2b5079ae5cd5c55e9e65bb" }, "dependencyClosures": { "wasm32": [ { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a" + "cacheKey": "3811d90c0d4fb6bb5979b2abdde1cb8881f7f423f109533c63352efcb26595a0" } ] }, @@ -2641,19 +2641,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "91be68f6091b7033131e63c8a28f747d9d51302ce511e6c52632d4918e068699" + "wasm32": "5389c9cc111d301d1582301fbfd21f2867fe008e241aaf007867358c0930352f" }, "dependencyClosures": { "wasm32": [ { "packageName": "sdl2", "manifestSha256": "327ce0acca768f51e36ddab8ab58fd57b24cfd9e40722e818103c439f7263dd7", - "cacheKey": "1b990666208f36b3a4ac2521ed3a39e8227779b2e2fa21600b7f372f616d0621" + "cacheKey": "49794fbdf038d586c70122ab262a4d260bc4e0b13ca820ea42136db76697d162" }, { "packageName": "sdl3", "manifestSha256": "3b7c64605b941e14d336b1a127d8219c50402dece009e8855297a0a0696bd67e", - "cacheKey": "41b437b4383f8c9d419b39c1b55963ac5b446705216a6029d39089718d28bbcf" + "cacheKey": "8ae8a3a8ba51e67be4e0aff2d11a07c26fbfd4e5a14e8e2915dfc58d7d0fd053" } ] }, @@ -2680,14 +2680,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b356d169ae065b0511ab6a7bfed157eb764f8137741d3192113168cf74988ce9" + "wasm32": "277b6a77899f266f1c4bf1d4ef6a0aea52616cb3944ea66e99539719aa120c38" }, "dependencyClosures": { "wasm32": [ { "packageName": "sdl2", "manifestSha256": "327ce0acca768f51e36ddab8ab58fd57b24cfd9e40722e818103c439f7263dd7", - "cacheKey": "1b990666208f36b3a4ac2521ed3a39e8227779b2e2fa21600b7f372f616d0621" + "cacheKey": "49794fbdf038d586c70122ab262a4d260bc4e0b13ca820ea42136db76697d162" } ] }, @@ -2707,7 +2707,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "968b56b98643a7cb8a7e11101ebb352c71044fbdccec6aac88768095e38b077b" + "wasm32": "b08d6312fcb338691877cada2f8661a8d3386c98ba8eca7366170dea4df71704" }, "dependencyClosures": { "wasm32": [] @@ -2728,7 +2728,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f16a327ad8a9d6e1ccd13b450551b47869d9e56fbdf9548e545a4d3dde47b371" + "wasm32": "fc799ce2e4bc4dd48cf320f5dfe468e6b20fc6e7aa10feb833c1638d8c645e37" }, "dependencyClosures": { "wasm32": [] @@ -2749,24 +2749,24 @@ "wasm32" ], "cacheKeys": { - "wasm32": "5c6935fabfdc6a8cc1ce812a87cf61d19df14a268e9e6ce33dced6e106ccc145" + "wasm32": "75ea71776150b11294eafaa89f8859d9751df45c798234e3e46026946b995a3e" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" + "cacheKey": "78a34cecb5b6ee33cab8ad5f48b6c9c63bb26c7f981c5ef4ddff95752194b0fc" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "94d90e4c611c3ef8174635182f27493ec8f3c832861ae9a2428b1a35736aa22c" + "cacheKey": "f488a967be488932c7ecdc0632bd3bdbf426d07cd1cf8046bd501fd9258232a6" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a" + "cacheKey": "3811d90c0d4fb6bb5979b2abdde1cb8881f7f423f109533c63352efcb26595a0" } ] }, @@ -2786,29 +2786,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "6deb4a4d5d3dba8f7f135b0e18d71ca30b144e58f871f9578c993aedb3dc9c59" + "wasm32": "21c95fda59eb8139edbdf288bdae7668646c6e632d370e7b0ddf9e2bcd6d109e" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" + "cacheKey": "78a34cecb5b6ee33cab8ad5f48b6c9c63bb26c7f981c5ef4ddff95752194b0fc" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "94d90e4c611c3ef8174635182f27493ec8f3c832861ae9a2428b1a35736aa22c" + "cacheKey": "f488a967be488932c7ecdc0632bd3bdbf426d07cd1cf8046bd501fd9258232a6" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "5c6935fabfdc6a8cc1ce812a87cf61d19df14a268e9e6ce33dced6e106ccc145" + "cacheKey": "75ea71776150b11294eafaa89f8859d9751df45c798234e3e46026946b995a3e" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a" + "cacheKey": "3811d90c0d4fb6bb5979b2abdde1cb8881f7f423f109533c63352efcb26595a0" } ] }, @@ -2828,7 +2828,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "78464a56a06dee769c41598f9f6922ade013ac0e8fddd86a52cd7bda70460dd9" + "wasm32": "7a544f84e919104ff06209fb24c46e5c0051138352f03ca6c4bb6f4e38b930c8" }, "dependencyClosures": { "wasm32": [] @@ -2849,7 +2849,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b9a0578b89fddf546f542eac3a63026f1f80a69071c13c029a9f54e7feefbeb1" + "wasm32": "2bbab108245cc14d331369d06610451db81853907b004fbb53aefe0277abedcb" }, "dependencyClosures": { "wasm32": [] @@ -2870,7 +2870,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2feeb5a979a7b348ec5cd5e33a019ce766b6885be05e5079846a7e5fc2844cb3" + "wasm32": "01849ba0bb079089467fcafefb9361fb84ca9707616d582bed66f416079499c0" }, "dependencyClosures": { "wasm32": [] @@ -2891,19 +2891,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "00bca12d8baefcfdda858a5f6f2ca58c462fec39b14631060fa06a2257cb1f57" + "wasm32": "544160ee83ecec389011ba955a3e4f0100076a1822683c4bd93e7f5e9cb12f18" }, "dependencyClosures": { "wasm32": [ { "packageName": "libpng", "manifestSha256": "79ed5c5c072a0267cce5c7a2fe37d8494571b17e44b952f619e04ec1c4a9db10", - "cacheKey": "9b8ebcc1b638d6821ead77d1a2beffdb8266c94cb1f9346b2131f1dff134d0d4" + "cacheKey": "286eac81ff2334c5fca9fac2f52d3bf4962a01bd64fc50941ed691cd041969a1" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a" + "cacheKey": "3811d90c0d4fb6bb5979b2abdde1cb8881f7f423f109533c63352efcb26595a0" } ] }, @@ -2930,7 +2930,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0e95e74674fa43f3d24c8b3457dc1a4ff9631154c71b86df537cb28661a67caf" + "wasm32": "b88616dce4cc96694af58a5ad0bfec68f1cf50ed6d03318a83cda972431db0f4" }, "dependencyClosures": { "wasm32": [] @@ -2951,7 +2951,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a75b0e135031cbac781aa636f5de6d6d1411d1c41f22021bc1a9a594fc78a35a" + "wasm32": "9266a61ea0dc6806ec9d9638c07f334e1404d2c4e00a92dc70d7c1c74e19ba26" }, "dependencyClosures": { "wasm32": [] @@ -2972,14 +2972,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "56b676a43f6cd4d9a07ad75323d3a7c7306cbd075484457a79569787c37ae188" + "wasm32": "6334e1fcabbbf214ebdf75abb9b6a42920ec2aa6661209009291b5e5c1d78961" }, "dependencyClosures": { "wasm32": [ { "packageName": "vim", "manifestSha256": "c211660ef41d01f95f3fbdf675b74891e897e3f304e04f1bf5586f326007382c", - "cacheKey": "a75b0e135031cbac781aa636f5de6d6d1411d1c41f22021bc1a9a594fc78a35a" + "cacheKey": "9266a61ea0dc6806ec9d9638c07f334e1404d2c4e00a92dc70d7c1c74e19ba26" } ] }, @@ -2999,7 +2999,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "98a913f679104d3400c949da7f8bfeeba5f46add578d306a4adf16efb1daffc3" + "wasm32": "172674ecaf48c516f70fb35e00a3b3f7288e09c1ce47c0b0bc9330f89f51a163" }, "dependencyClosures": { "wasm32": [] @@ -3020,79 +3020,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "3044dcb66449c2b6a9ea7653c74465516e5a1cdf168613119a9352916b850a51" + "wasm32": "faef007835b602b3bf7ac1555eb157c24aa50af75f66a193c6f10a538c8baeb8" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "f7abd4cc88bcb4f9bf2e01872fa181c8ac84db6621da3e2c1a291f32c65f27ed" + "cacheKey": "a8d93a950dd2e2a1bb8b6c19f1323e605f14de305fea7cbb31595dabc6dc00a5" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "bf454728f58a75797a9d52c8c6d92c6a52c67c377be31c0253fb2a18883556d6" + "cacheKey": "fc36cf8e40047f380fc8935f764c623d8ac1ba2f2d5e57b41de46707a1be4222" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "2a54294aa012c4a47fb5b540f49dacacd83725735c4db8ba3746fc4177146a6a" + "cacheKey": "f16aac6b14f45497f3be37247041fdae68c3ab3b312443972623d69ca93c43f0" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "c69f1759dd3e55abe342161ce39fd04ba639590bfcaa52658efdaf2f21c2641a" + "cacheKey": "78a34cecb5b6ee33cab8ad5f48b6c9c63bb26c7f981c5ef4ddff95752194b0fc" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "9824449919005d0862b1f40b310195b2bf5657733469a3b1aac4b94396ffc669" + "cacheKey": "1bfd43d95f2adc7a4243f5215bfbda8fc7d1dca6ed05f78a5726a577ec20b6bc" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "966012e82a47f8afa17683175087d63b7fe7e191c770f14a62d82366e13ba959" + "cacheKey": "f71f57a69a6b05752c40a1928937ea7aa259bccc4e74591577e8d328b42d2b0d" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "5cb2f96b95a2fcd0618db09f8393d8e2bd6a40617cb2b35f57667a482c671739" + "cacheKey": "e57c568dfc864238b7e26f32fcc3c45a7ba84c754f5d03acdec5e3644c65f198" }, { "packageName": "msmtpd", - "manifestSha256": "09de04a422ddf631a29a259d816e081f8d317d1077808ede55d8c500baae748f", - "cacheKey": "ca4d269e24beb373937cfffa3a5a70be1c26f67b4afa19128d697ad9cdced81f" + "manifestSha256": "686ff665b5e7907e67618790b1a3eddce2ff47ed665595d17209bdcda1e0b274", + "cacheKey": "0b756d4bc1ef5f2566e19bfd7b02569c37ba60ed53972666ae46ddfab63f01dc" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "2b5174ccf228be9d359389d8c2435f0cd34feebc66e57966558902f72682996d" + "cacheKey": "efa78ce5711e440d8db6bdfb837ef3749c67ce2401b7cb9acd6915090a564c41" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "94d90e4c611c3ef8174635182f27493ec8f3c832861ae9a2428b1a35736aa22c" + "cacheKey": "f488a967be488932c7ecdc0632bd3bdbf426d07cd1cf8046bd501fd9258232a6" }, { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "78e404c3a0b1ad280ea8c08a87d9d2d8b69509590da06878f4138ad71fc9d92e" + "cacheKey": "fd181cf83fe71c0e34c748144313f50dc038860146dee94411f0a5af93725949" }, { "packageName": "shell", - "manifestSha256": "28a871b7fcf72f3e289d5116c2d308e22fd4beffd6c5db4bffa262f9b16c1384", - "cacheKey": "f16a327ad8a9d6e1ccd13b450551b47869d9e56fbdf9548e545a4d3dde47b371" + "manifestSha256": "d95f1e0df1571acc4c65e9526420264e739ec80eb83c0080bd172b743795f53d", + "cacheKey": "fc799ce2e4bc4dd48cf320f5dfe468e6b20fc6e7aa10feb833c1638d8c645e37" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "590f8dae0ef8972831a64291326763a1e9fa3697b144daa99d2d2d13cb91192d" + "cacheKey": "9314f02cc5ff7e7f4d4db1b5122e99ff0683a0b1ec68cf4e73eaf723d8751d35" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "e3ccd65ef53e675292f9cf9b15b6bb322c3350afd9ddc3bb6ecefab4e7c4461a" + "cacheKey": "3811d90c0d4fb6bb5979b2abdde1cb8881f7f423f109533c63352efcb26595a0" } ] }, @@ -3112,7 +3112,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "22f7c6e571abace32661baa8907311894d67f1ddf037fc3d211b15ad7585ac87" + "wasm32": "a00821c9d76bdee7841e0761012f4b024922f37c817c4f465feea97654ccd669" }, "dependencyClosures": { "wasm32": [] @@ -3133,7 +3133,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "5c25b080333ffac7a5271e6c17b3c361d325677272a7e7fca7a4b42cddb0ce16" + "wasm32": "f40c84abbfaae6bb7840287ffb3bd48fe6be4d78901e7e2332160d2d9469a7f3" }, "dependencyClosures": { "wasm32": [] @@ -3154,7 +3154,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "9009660cf423b4f45a88bc99c6b37e5759661a82c64e9c89302b69c219efce49" + "wasm32": "42018146f71e4329d5db3f6a3b702a78bfefc5748d9206d091cd3f32eb724344" }, "dependencyClosures": { "wasm32": [] From b334b7856a60c735f03a8862d74a293569e67224 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Thu, 13 Aug 2026 12:12:02 -0400 Subject: [PATCH 80/82] Homebrew: Restore staging candidate ABI option --- scripts/homebrew-bottle-build.sh | 8 +++++++- scripts/test-homebrew-tap-identity.sh | 27 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/scripts/homebrew-bottle-build.sh b/scripts/homebrew-bottle-build.sh index 07b1bf98ad..0b11df9199 100755 --- a/scripts/homebrew-bottle-build.sh +++ b/scripts/homebrew-bottle-build.sh @@ -17,7 +17,7 @@ RETIRE_SOURCE_INSTALL=false usage() { cat >&2 <<'EOF' -usage: scripts/homebrew-bottle-build.sh --tap-root [--tap-repository ] [--tap-name ] --formula --arch --out --bottle-root-url [--retire-source-install] +usage: scripts/homebrew-bottle-build.sh --tap-root [--tap-repository ] [--tap-name ] --formula --arch --out --bottle-root-url [--staging-candidate-abi ] [--retire-source-install] This script is intended to run inside scripts/dev-shell.sh. It invokes the absolute Homebrew executable named by HOMEBREW_BREW_FILE, avoiding host PATH @@ -40,6 +40,7 @@ while [ "$#" -gt 0 ]; do --arch) ARCH="${2:-}"; shift 2 ;; --out) OUT_DIR="${2:-}"; shift 2 ;; --bottle-root-url) BOTTLE_ROOT_URL="${2:-}"; shift 2 ;; + --staging-candidate-abi) STAGING_CANDIDATE_ABI="${2:-}"; shift 2 ;; --retire-source-install) [ "$RETIRE_SOURCE_INSTALL" = false ] || { echo "homebrew-bottle-build.sh: duplicate --retire-source-install" >&2 @@ -81,6 +82,11 @@ case "$ARCH" in wasm32|wasm64) ;; *) echo "homebrew-bottle-build.sh: invalid arch: $ARCH" >&2; exit 2 ;; esac +if [ -n "$STAGING_CANDIDATE_ABI" ] && + ! [[ "$STAGING_CANDIDATE_ABI" =~ ^[1-9][0-9]*$ ]]; then + echo "homebrew-bottle-build.sh: invalid staging candidate ABI: $STAGING_CANDIDATE_ABI" >&2 + exit 2 +fi if [ -n "$LOCAL_BUILD_EVIDENCE" ]; then if [ "${GITHUB_ACTIONS:-}" = "true" ]; then diff --git a/scripts/test-homebrew-tap-identity.sh b/scripts/test-homebrew-tap-identity.sh index 38a55176c7..e6f73cda24 100755 --- a/scripts/test-homebrew-tap-identity.sh +++ b/scripts/test-homebrew-tap-identity.sh @@ -96,6 +96,33 @@ if homebrew_candidate_bottle_root_url \ fail "candidate bottle root accepted an unsafe Formula path" fi +# The exact staging adapter supplies this option to the normal bottle builder. +# Exercise the real parser and candidate namespace instead of merely checking +# the helper, so a later option-table merge cannot strand every hosted build. +candidate_tap="$TMPDIR/candidate-tap" +candidate_out="$TMPDIR/candidate-out" +candidate_err="$TMPDIR/candidate.err" +mkdir -p "$candidate_tap/Formula" +printf 'class Hello < Formula; end\n' >"$candidate_tap/Formula/hello.rb" +if HOMEBREW_BREW_FILE="$TMPDIR/missing-brew" \ + bash "$REPO_ROOT/scripts/homebrew-bottle-build.sh" \ + --tap-root "$candidate_tap" \ + --tap-repository kandelo-dev/homebrew-tap-core \ + --formula hello \ + --arch wasm32 \ + --out "$candidate_out" \ + --bottle-root-url \ + https://ghcr.io/v2/kandelo-dev/homebrew-tap-core-abi-43-candidates/hello \ + --staging-candidate-abi 43 \ + > /dev/null 2>"$candidate_err"; then + fail "candidate parser fixture unexpectedly completed without Homebrew" +fi +grep -Fq 'HOMEBREW_BREW_FILE does not name an executable brew' \ + "$candidate_err" || { + cat "$candidate_err" >&2 + fail "normal bottle builder did not accept its exact staging candidate ABI" +} + expect_identity_rejection "an implicit third-party tap name" Acme/homebrew-tools expect_identity_rejection "a nonconventional third-party repository" Acme/tools Acme/tools expect_identity_rejection "a mismatched third-party tap name" Acme/homebrew-tools Acme/other From be52756c6001e647c17b008096c5c20927cdcb13 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Thu, 13 Aug 2026 12:59:33 -0400 Subject: [PATCH 81/82] Homebrew: Reconcile ABI 43 integration fixtures --- ...8-10-abi43-login-sudo-vfork-integration.md | 7 ++-- .../homebrew-native-compatibility-lock.json | 42 +++++++++---------- scripts/homebrew-prefix-campaign-executor.py | 14 ++++++- scripts/test-homebrew-guest-layout.sh | 5 +++ scripts/test-homebrew-publish-workflow.sh | 13 ++++++ .../test-homebrew-publisher-real-lifecycle.sh | 3 ++ ...t-homebrew-rootfs-publication-selection.sh | 6 ++- ...test-homebrew-staging-candidate-compose.sh | 2 +- 8 files changed, 63 insertions(+), 29 deletions(-) diff --git a/docs/superpowers/plans/2026-08-10-abi43-login-sudo-vfork-integration.md b/docs/superpowers/plans/2026-08-10-abi43-login-sudo-vfork-integration.md index da9702d3aa..fafcbace39 100644 --- a/docs/superpowers/plans/2026-08-10-abi43-login-sudo-vfork-integration.md +++ b/docs/superpowers/plans/2026-08-10-abi43-login-sudo-vfork-integration.md @@ -561,9 +561,10 @@ partial if it uses containment. - [ ] **Step 1: Add failing prefix-authority cases** -Test `/home/linuxbrew/.linuxbrew` and `/opt/kandelo/homebrew`, plus a mismatch -between receipt destination and an ambient runtime default. The mismatch must -fail before publication; it must not silently relocate to the default. +Test the retired prefix declared by the guest-layout contract and +`/opt/kandelo/homebrew`, plus a mismatch between receipt destination and an +ambient runtime default. The mismatch must fail before publication; it must +not silently relocate to the default. - [ ] **Step 2: Run the focused tests to prove current behavior is wrong** diff --git a/homebrew/homebrew-native-compatibility-lock.json b/homebrew/homebrew-native-compatibility-lock.json index ff7341fd8b..78a7cb09ef 100644 --- a/homebrew/homebrew-native-compatibility-lock.json +++ b/homebrew/homebrew-native-compatibility-lock.json @@ -1028,8 +1028,7 @@ }, "ca-certificates": { "internal": { - "bottle_checksum": "0f62fd67d5a0cc3109ce51568cd7e1603049fb627a0626a0ad5a15d357a2c834", - "bottle_rebuild": 2, + "bottle_checksum": "797af9bcf375fb49a98db40c3d5674276db1eb1b5e605ec98359619ed50948ba", "bottle_tag": ":all", "caveats": "CA certificates have been bootstrapped from both the Mozilla CA store and the system CA store at\none of the following locations, depending on your distro:\n\n /etc/ssl/certs/ca-certificates.crt # Debian/Ubuntu, Alpine Linux, Arch Linux\n /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem # RHEL/CentOS/Fedora, Amazon Linux\n /etc/ssl/ca-bundle.pem # SUSE/openSUSE\n\n", "desc": "Mozilla CA certificate store", @@ -1049,16 +1048,15 @@ "type": "run" } ], - "ruby_source_checksum": "37b918f3e75c74d798bfddd77c6f62be7d1927569f0502bc30c5a43e5f1b8e46", - "stable_checksum": "3ff344e30b9b1ed2971044eabb438a08f2e2245ddb5f8ab1a3ad8b63ab4eaf91", + "ruby_source_checksum": "2583ef5a3ffed891fcddc431e958e857c94676104a37e36e2188fd74bbfa27de", + "stable_checksum": "f66dff1bdf8f96060b8177976f8b7d9254bc89bc4db933d769f7384d28480bc9", "stable_url_args": [ - "https://curl.se/ca/cacert-2026-07-16.pem" + "https://curl.se/ca/cacert-2026-08-13.pem" ], - "stable_version": "2026-07-16" + "stable_version": "2026-08-13" }, "public": { - "bottle_checksum": "0f62fd67d5a0cc3109ce51568cd7e1603049fb627a0626a0ad5a15d357a2c834", - "bottle_rebuild": 2, + "bottle_checksum": "797af9bcf375fb49a98db40c3d5674276db1eb1b5e605ec98359619ed50948ba", "bottle_tag": ":all", "desc": "Mozilla CA certificate store", "homepage": "https://curl.se/docs/caextract.html", @@ -1077,12 +1075,12 @@ "type": "run" } ], - "ruby_source_checksum": "37b918f3e75c74d798bfddd77c6f62be7d1927569f0502bc30c5a43e5f1b8e46", - "stable_checksum": "3ff344e30b9b1ed2971044eabb438a08f2e2245ddb5f8ab1a3ad8b63ab4eaf91", + "ruby_source_checksum": "2583ef5a3ffed891fcddc431e958e857c94676104a37e36e2188fd74bbfa27de", + "stable_checksum": "f66dff1bdf8f96060b8177976f8b7d9254bc89bc4db933d769f7384d28480bc9", "stable_url_args": [ - "https://curl.se/ca/cacert-2026-07-16.pem" + "https://curl.se/ca/cacert-2026-08-13.pem" ], - "stable_version": "2026-07-16" + "stable_version": "2026-08-13" } }, "cairo": { @@ -5156,7 +5154,7 @@ "harfbuzz": { "internal": { "bottle_cellar": "/home/linuxbrew/.linuxbrew/Cellar", - "bottle_checksum": "ddec7f52274aa95bd5ca762c97024de8d4bd10e7a4233511914dfd2696b6182a", + "bottle_checksum": "6c6352facf6fd17166f2c97e3953b23e71c48141b993bb53fae7e7baffcacbba", "desc": "OpenType text shaping engine", "executables": [ "hb-info", @@ -5205,8 +5203,8 @@ "homepage": "https://github.com/harfbuzz/harfbuzz", "license": "MIT", "name": "harfbuzz", - "ruby_source_checksum": "188aea0a97665d3a2a39ed72b37b249252f25ae92f84e4c9d4054f004b27f936", - "stable_checksum": "16070d77cfc4ba1f1e7327e83bf9b3f55898081cabdb94e56a33e04fc8874eae", + "ruby_source_checksum": "aed3e81a53aee699d54c2d6d38d6380632bfd6f28961a379ed38b17bb5b99235", + "stable_checksum": "9dae9538aae2ffdf70cec31f2c27bf68e2aaeeae3112688467697d5faf6194f7", "stable_dependencies": [ "cairo", "freetype", @@ -5238,13 +5236,13 @@ } ], "stable_url_args": [ - "https://github.com/harfbuzz/harfbuzz/releases/download/14.3.0/harfbuzz-14.3.0.tar.xz" + "https://github.com/harfbuzz/harfbuzz/releases/download/14.3.1/harfbuzz-14.3.1.tar.xz" ], - "stable_version": "14.3.0" + "stable_version": "14.3.1" }, "public": { "bottle_cellar": "/home/linuxbrew/.linuxbrew/Cellar", - "bottle_checksum": "ddec7f52274aa95bd5ca762c97024de8d4bd10e7a4233511914dfd2696b6182a", + "bottle_checksum": "6c6352facf6fd17166f2c97e3953b23e71c48141b993bb53fae7e7baffcacbba", "desc": "OpenType text shaping engine", "executables": [ "hb-info", @@ -5293,8 +5291,8 @@ "homepage": "https://github.com/harfbuzz/harfbuzz", "license": "MIT", "name": "harfbuzz", - "ruby_source_checksum": "188aea0a97665d3a2a39ed72b37b249252f25ae92f84e4c9d4054f004b27f936", - "stable_checksum": "16070d77cfc4ba1f1e7327e83bf9b3f55898081cabdb94e56a33e04fc8874eae", + "ruby_source_checksum": "aed3e81a53aee699d54c2d6d38d6380632bfd6f28961a379ed38b17bb5b99235", + "stable_checksum": "9dae9538aae2ffdf70cec31f2c27bf68e2aaeeae3112688467697d5faf6194f7", "stable_dependencies": [ "cairo", "freetype", @@ -5326,9 +5324,9 @@ } ], "stable_url_args": [ - "https://github.com/harfbuzz/harfbuzz/releases/download/14.3.0/harfbuzz-14.3.0.tar.xz" + "https://github.com/harfbuzz/harfbuzz/releases/download/14.3.1/harfbuzz-14.3.1.tar.xz" ], - "stable_version": "14.3.0" + "stable_version": "14.3.1" } }, "hicolor-icon-theme": { diff --git a/scripts/homebrew-prefix-campaign-executor.py b/scripts/homebrew-prefix-campaign-executor.py index 8802161e2d..9c6c3d581b 100755 --- a/scripts/homebrew-prefix-campaign-executor.py +++ b/scripts/homebrew-prefix-campaign-executor.py @@ -9333,7 +9333,19 @@ def compose_reuse_child( ) try: canonical_bottle = temporary / "bottle.json" - canonical_bottle.write_bytes(pretty_json(canonical)) + # WHY: dependency merging deliberately consumes a short Formula key, + # while OCI publication binds the complete tap-qualified Homebrew + # identity. Requalify the already-validated minimal record at this + # boundary instead of weakening either consumer's input contract. + canonical_bottle.write_bytes( + pretty_json( + { + f"{campaign['authority']['tap_name']}/{formula_name}": ( + canonical[formula_name] + ) + } + ) + ) child = temporary / "child" layout = child / "layout" receipt_path = child / "receipt.json" diff --git a/scripts/test-homebrew-guest-layout.sh b/scripts/test-homebrew-guest-layout.sh index 620d58119f..e60dc4c4b7 100755 --- a/scripts/test-homebrew-guest-layout.sh +++ b/scripts/test-homebrew-guest-layout.sh @@ -239,7 +239,10 @@ for retired_prefix in "${retired_prefixes[@]}"; do "$retired_prefix" "$native_compatibility_lock_validated" || fail "native compatibility lock cannot exempt $retired_prefix" ;; + host/test/homebrew-bottle-relocation.test.ts\|*"$retired_prefix"*) ;; host/test/homebrew-guest-layout.test.ts\|*"retired_prefixes: [\"$retired_prefix\"]"*) ;; + host/test/homebrew-vfs-builder.test.ts\|*"$retired_prefix"*) ;; + host/test/homebrew-vfs-builder.test.ts\|*"authenticated Linuxbrew destination"*) ;; scripts/test-homebrew-inspect-bottle.sh\|*"$retired_prefix"*) ;; scripts/test-homebrew-prefix-campaign.py\|*"RETIRED_PREFIX = \"$retired_prefix\""*) ;; scripts/test-homebrew-prefix-campaign-executor.py\|*"[\"$retired_prefix\"]"*) ;; @@ -250,6 +253,8 @@ for retired_prefix in "${retired_prefixes[@]}"; do scripts/test-homebrew-tap-recipe-runner.py\|*"destination = Path(\"$retired_prefix/etc/clang\")") ;; scripts/homebrew-guest-layout.sh\|*"index(\"$retired_prefix\")"*) ;; scripts/homebrew-formula-runtime-closure.rb\|*"\"retired_prefixes\" => [\"$retired_prefix\"]"*) ;; + scripts/test-homebrew-vfs-release.sh\|*"$retired_prefix"*) ;; + scripts/test-homebrew-vfs-release.sh\|*"historical Linuxbrew prefix"*) ;; scripts/homebrew-dependency-provenance.py\|*"$retired_prefix"*) ;; scripts/homebrew-inspect-bottle.py\|*"$retired_prefix"*) ;; tools/xtask/src/homebrew_guest_layout.rs\|*"const RETIRED_PREFIX: &str = \"$retired_prefix\""*) ;; diff --git a/scripts/test-homebrew-publish-workflow.sh b/scripts/test-homebrew-publish-workflow.sh index 5965b67cb7..1c2a16799b 100755 --- a/scripts/test-homebrew-publish-workflow.sh +++ b/scripts/test-homebrew-publish-workflow.sh @@ -375,6 +375,10 @@ homebrew_patched_launcher_select_host_git() { export HOMEBREW_GIT_PATH } +homebrew_patched_launcher_restore_invoker_bootstrap_roots() { + [ "$#" -eq 2 ] +} + homebrew_patched_launcher_prepare() { HOMEBREW_PATCHED_BREW_BIN="$1" HOMEBREW_PATCHED_PREFIX="${FAKE_BREW_PREFIX:?}" @@ -400,6 +404,15 @@ homebrew_patched_launcher_snapshot_target_cellar_layout() { done | LC_ALL=C sort } +homebrew_patched_launcher_seal_target_dependencies() { + [ "$#" -eq 2 ] +} + +homebrew_patched_launcher_resolve_installed_formula_keg() { + [ "$#" -eq 3 ] || return 2 + printf '%s/Cellar/%s/1.0\n' "$HOMEBREW_PATCHED_PREFIX" "$3" +} + homebrew_patched_launcher_native_prefix_path() { printf '%s/p\n' "$1" } diff --git a/scripts/test-homebrew-publisher-real-lifecycle.sh b/scripts/test-homebrew-publisher-real-lifecycle.sh index a41388a506..07ae3beb34 100755 --- a/scripts/test-homebrew-publisher-real-lifecycle.sh +++ b/scripts/test-homebrew-publisher-real-lifecycle.sh @@ -147,6 +147,7 @@ BREW_ENV=( HOMEBREW_NO_INSTALL_FROM_API=1 HOMEBREW_KANDELO_HERMETIC_LIFECYCLE_TEST=1 HOMEBREW_KANDELO_XTASK_BIN="$SEALED_XTASK" + HOMEBREW_GIT_PATH=/usr/bin/git HOMEBREW_TEMP="$BREW_ROOT/.tmp" WASM_POSIX_XTASK_BIN=caller-poison XDG_CONFIG_HOME="$BREW_ROOT/.config" @@ -430,6 +431,8 @@ jq -e ' cp "$HOST_DEPENDENCY_PLAN" \ "$BREW_ROOT/.kandelo-publisher-build-dependencies.json" chmod 0444 "$BREW_ROOT/.kandelo-publisher-build-dependencies.json" +chmod -R a-w "$TAP_ROOT" +BREW_ENV+=(HOMEBREW_KANDELO_PRIMARY_TAP_ROOT="$TAP_ROOT") run_offline_brew install install --build-bottle \ --ignore-dependencies kandelo-dev/tap-core/fixture diff --git a/scripts/test-homebrew-rootfs-publication-selection.sh b/scripts/test-homebrew-rootfs-publication-selection.sh index 9803e6693b..711d3929ea 100755 --- a/scripts/test-homebrew-rootfs-publication-selection.sh +++ b/scripts/test-homebrew-rootfs-publication-selection.sh @@ -393,11 +393,13 @@ if ARGV.last == "--host-dependencies-json" ) target_taps = [resolved.fetch("primary"), *resolved.fetch("dependencies")] .map do |entry| - entry.slice("tap_name", "tap_repository", "tap_commit") + entry.slice("tap_name", "tap_repository", "tap_commit").merge( + "checkout_commit" => entry.fetch("checkout_commit", entry.fetch("tap_commit")), + ) end .sort_by { |entry| entry.fetch("tap_name") } puts JSON.generate({ - "schema" => 4, + "schema" => 5, "tap" => tap, "formula" => formula, "full_name" => "#{tap}/#{formula}", diff --git a/scripts/test-homebrew-staging-candidate-compose.sh b/scripts/test-homebrew-staging-candidate-compose.sh index b54c7b13ea..e0572f457c 100755 --- a/scripts/test-homebrew-staging-candidate-compose.sh +++ b/scripts/test-homebrew-staging-candidate-compose.sh @@ -27,7 +27,7 @@ SHA256="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ROOT_URL="https://ghcr.io/v2/kandelo-dev/homebrew-tap-core-abi-8-candidates/asa" jq -ncS \ --arg sha256 "$SHA256" --arg root "$ROOT_URL" ' - {asa: { + {"kandelo-dev/tap-core/asa": { bottle: {cellar: "any_skip_relocation", rebuild: 1, root_url: $root, tags: {wasm32_kandelo: {sha256: $sha256}}}, formula: {name: "asa", path: From 266edf658b4544403a0b649d695dae78affa997e Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Thu, 13 Aug 2026 13:02:22 -0400 Subject: [PATCH 82/82] Homebrew: Refresh final native compatibility drift --- .../homebrew-native-compatibility-lock.json | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/homebrew/homebrew-native-compatibility-lock.json b/homebrew/homebrew-native-compatibility-lock.json index 78a7cb09ef..029e4e202b 100644 --- a/homebrew/homebrew-native-compatibility-lock.json +++ b/homebrew/homebrew-native-compatibility-lock.json @@ -12473,8 +12473,7 @@ "python@3" ], "bottle_cellar": "/home/linuxbrew/.linuxbrew/Cellar", - "bottle_checksum": "65459b177976d613314ccaa2ba4f16e9c119f901ed5cbd72172ffc1b6fef1745", - "bottle_rebuild": 1, + "bottle_checksum": "c1094d173aaae36f305c229bec64f43a23c77148f3c95244d1d3d0d4d28e8275", "caveats": "Python is installed as\n $HOMEBREW_PREFIX/bin/python3\n\nUnversioned symlinks `python`, `python-config`, `pip` etc. pointing to\n`python3`, `python3-config`, `pip3` etc., respectively, are installed into\n $HOMEBREW_PREFIX/opt/python@3.14/libexec/bin\n\n`idle3.14` requires tkinter, which is available separately:\n brew install python-tk@3.14\n\n`dbm.gnu` and `dbm.ndbm` are available separately:\n brew install python-gdbm@3.14\n\nFor more information about Homebrew and Python, see: https://docs.brew.sh/Homebrew-and-Python\n", "deprecate_args": { ":because": ":deprecated_upstream", @@ -12589,8 +12588,8 @@ "type": "set_permissions" } ], - "ruby_source_checksum": "3c8e08cd45c7ff01c5c6a1541244b6aa50c4f0ce9845fe53c1561238acd74938", - "stable_checksum": "74d0d71d0600e477651a077101d6e62d1e2e69b8e992ba18c993dd643b7ba222", + "ruby_source_checksum": "e7e3a023de8b88f5a7e2e09c861e9e15fb0d0a2d78b6bb619a1b27e7da155b9a", + "stable_checksum": "62859805f6fdf25e2bcbf3fa3217801e1996887ca33e6a2af80674bdfa2dbe07", "stable_dependencies": [ "mpdecimal", "openssl@3", @@ -12616,7 +12615,7 @@ } ], "stable_url_args": [ - "https://www.python.org/ftp/python/3.14.6/Python-3.14.6.tgz" + "https://www.python.org/ftp/python/3.14.7/Python-3.14.7.tgz" ], "stable_uses_from_macos": [ [ @@ -12638,7 +12637,7 @@ "ncurses" ] ], - "stable_version": "3.14.6", + "stable_version": "3.14.7", "versioned_formulae": [ "python@3.13", "python@3.12", @@ -12654,8 +12653,7 @@ "python@3" ], "bottle_cellar": "/home/linuxbrew/.linuxbrew/Cellar", - "bottle_checksum": "65459b177976d613314ccaa2ba4f16e9c119f901ed5cbd72172ffc1b6fef1745", - "bottle_rebuild": 1, + "bottle_checksum": "c1094d173aaae36f305c229bec64f43a23c77148f3c95244d1d3d0d4d28e8275", "deprecate_args": { ":because": ":deprecated_upstream", ":date": "2030-11-01" @@ -12769,8 +12767,8 @@ "type": "set_permissions" } ], - "ruby_source_checksum": "3c8e08cd45c7ff01c5c6a1541244b6aa50c4f0ce9845fe53c1561238acd74938", - "stable_checksum": "74d0d71d0600e477651a077101d6e62d1e2e69b8e992ba18c993dd643b7ba222", + "ruby_source_checksum": "e7e3a023de8b88f5a7e2e09c861e9e15fb0d0a2d78b6bb619a1b27e7da155b9a", + "stable_checksum": "62859805f6fdf25e2bcbf3fa3217801e1996887ca33e6a2af80674bdfa2dbe07", "stable_dependencies": [ "mpdecimal", "openssl@3", @@ -12796,7 +12794,7 @@ } ], "stable_url_args": [ - "https://www.python.org/ftp/python/3.14.6/Python-3.14.6.tgz" + "https://www.python.org/ftp/python/3.14.7/Python-3.14.7.tgz" ], "stable_uses_from_macos": [ [ @@ -12818,7 +12816,7 @@ "ncurses" ] ], - "stable_version": "3.14.6", + "stable_version": "3.14.7", "versioned_formulae": [ "python@3.13", "python@3.12",